| #!/usr/bin/env python3 | |
| """ | |
| Air Mouse Perfetto Trace Analyzer – Complete Edition | |
| Answers all 11 questions required for the exercise report. | |
| Usage: | |
| python perfetto_analyzer.py trace_file.perfetto-trace | |
| Output: | |
| - Printed report with statistics and explanations | |
| - Optionally saves a JSON summary file | |
| Requirements: pip install perfetto pandas | |
| """ | |
| import sys | |
| import json | |
| import os | |
| import pandas as pd | |
| from perfetto.trace_processor import TraceProcessor | |
| # --------------------------------------------------------------------- | |
| # 1. Configuration – adjust slice names to match your app's tracepoints | |
| # --------------------------------------------------------------------- | |
| TRACEPOINTS = { | |
| "sensor_read": "AirMouse#onSensorChanged_Gyro", # start of sensor callback | |
| "filter": "AirMouse#onSensorChanged_Gyro", # sensor fusion update | |
| "compute_delta": "AirMouse#detectGesture", # delta calculation | |
| "send_move": "AirMouse#onSensorChanged_Gyro", # move packet | |
| "send_click": "AirMouse#detectGesture", # click/scroll packet | |
| "click_detect": "AirMouse#detectGesture", # custom click trace | |
| "scroll_detect": "AirMouse#detectGesture", # scroll trace | |
| } | |
| # --------------------------------------------------------------------- | |
| # 2. Helper functions | |
| # --------------------------------------------------------------------- | |
| def query(tp: TraceProcessor, sql: str) -> pd.DataFrame: | |
| """Run SQL and return a DataFrame, with error handling.""" | |
| try: | |
| df = tp.query(sql).as_pandas_dataframe() | |
| return df | |
| except Exception as e: | |
| print(f"⚠️ Query failed: {e}\nSQL: {sql[:200]}...") | |
| return pd.DataFrame({"error": [str(e)]}) | |
| def safe_mean(df: pd.DataFrame, col: str) -> float: | |
| if col in df and not df[col].dropna().empty: | |
| return df[col].dropna().mean() | |
| return float('nan') | |
| def print_table(title: str, df: pd.DataFrame, max_rows=20): | |
| print(f"\n--- {title} ---") | |
| if df.empty or "error" in df.columns: | |
| print("No data available (check tracepoint names).") | |
| return | |
| with pd.option_context('display.max_rows', max_rows, 'display.max_columns', 10, 'display.width', 120): | |
| print(df) | |
| # --------------------------------------------------------------------- | |
| # 3. Main analysis function | |
| # --------------------------------------------------------------------- | |
| def analyze_trace(trace_file: str): | |
| print(f"🔍 Loading trace: {trace_file}") | |
| # Use local prebuilt trace_processor binary to bypass download errors | |
| from perfetto.trace_processor import TraceProcessorConfig | |
| config = TraceProcessorConfig( | |
| bin_path='/Users/tahamajs/.local/share/perfetto/prebuilts/trace_processor_shell' | |
| ) | |
| try: | |
| tp = TraceProcessor(trace=trace_file, config=config) | |
| except Exception as e: | |
| print(f"❌ Direct file load failed: {e}") | |
| sys.exit(1) | |
| # ----------------------------------------------------------------- | |
| # Q1: Timeline from sensor request to data delivery | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q1: Sensor data delivery steps (first 30 sensor_read slices)") | |
| print("="*70) | |
| q1_query = f""" | |
| SELECT | |
| slice.ts / 1e6 AS start_ms, | |
| slice.dur / 1e6 AS duration_ms, | |
| thread.name AS thread | |
| FROM slice | |
| JOIN thread_track ON slice.track_id = thread_track.id | |
| JOIN thread USING(utid) | |
| WHERE slice.name = '{TRACEPOINTS["sensor_read"]}' | |
| ORDER BY slice.ts | |
| LIMIT 30 | |
| """ | |
| df_q1 = query(tp, q1_query) | |
| print_table("Q1: Sensor read slices", df_q1) | |
| if not df_q1.empty and "duration_ms" in df_q1: | |
| avg_dur = df_q1["duration_ms"].mean() | |
| print(f"Average sensor callback duration: {avg_dur:.4f} ms") | |
| else: | |
| print("No sensor_read slices found. Check TRACEPOINTS.") | |
| # ----------------------------------------------------------------- | |
| # Q2: Why raw sensors have errors & how fusion helps (text) | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q2: Sensor errors and fusion") | |
| print("="*70) | |
| print("Gyroscope: bias and drift over time. Accelerometer: noisy, affected by linear acceleration.") | |
| print("Magnetometer: hard/soft iron distortion. Sensor fusion (Madgwick) combines:") | |
| print("- Gyro for short‑term fast response") | |
| print("- Accelerometer (gravity) to correct pitch/roll drift") | |
| print("- Magnetometer (Earth’s field) to correct yaw drift") | |
| print("Result: stable, drift‑free orientation used for Air Mouse.") | |
| # ----------------------------------------------------------------- | |
| # Q3: Sampling period vs configured rate | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q3: Actual vs. configured sampling period") | |
| print("="*70) | |
| q3_query = f""" | |
| WITH events AS ( | |
| SELECT ts | |
| FROM slice | |
| WHERE name = '{TRACEPOINTS["sensor_read"]}' | |
| ORDER BY ts | |
| ) | |
| SELECT | |
| (ts - LAG(ts) OVER (ORDER BY ts)) / 1e6 AS interval_ms | |
| FROM events | |
| LIMIT 1000 | |
| """ | |
| df_q3 = query(tp, q3_query) | |
| if not df_q3.empty and "interval_ms" in df_q3: | |
| intervals = df_q3["interval_ms"].dropna() | |
| mean_period = intervals.mean() | |
| std_period = intervals.std() | |
| print(f"Configured period (example): 20 ms (SENSOR_DELAY_GAME)") | |
| print(f"Actual mean interval: {mean_period:.2f} ms, std: {std_period:.2f} ms") | |
| if mean_period > 22: | |
| print("Note: intervals larger than requested likely due to system load/batching.") | |
| else: | |
| print("No interval data. Ensure sensor_read events are present.") | |
| # ----------------------------------------------------------------- | |
| # Q4: Thread contention (using thread_state) | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q4: Thread contention – waiting times") | |
| print("="*70) | |
| q4_query = """ | |
| SELECT | |
| thread.name AS thread_name, | |
| thread_state.state, | |
| ROUND(SUM(thread_state.dur) / 1e6, 3) AS total_ms, | |
| COUNT(*) AS occurrences | |
| FROM thread_state | |
| JOIN thread USING(utid) | |
| WHERE thread.name IN ('main', 'RenderThread', 'surfaceflinger') | |
| OR thread.name GLOB 'sensor*' | |
| GROUP BY thread_name, state | |
| ORDER BY total_ms DESC | |
| LIMIT 30 | |
| """ | |
| df_q4 = query(tp, q4_query) | |
| print_table("Q4: Thread waiting/blocked times", df_q4) | |
| print("Interpretation: Look for 'main' or 'RenderThread' in 'S' (sleeping) or 'D' (waiting) states,") | |
| print("which can indicate contention with sensor processing. In a well-optimized app, sensor work") | |
| print("should be on a dedicated thread and not block UI for more than a few ms.") | |
| # ----------------------------------------------------------------- | |
| # Q5: Wake-up vs non-wake-up sensors (text) | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q5: Wake-up vs. non‑wake‑up sensors") | |
| print("="*70) | |
| print("Wake‑up sensors (e.g., significant motion) can wake the CPU from suspend.") | |
| print("Non‑wake‑up sensors deliver data only when the CPU is already awake.") | |
| print("Air Mouse uses non‑wake‑up sensors because the screen is on and we need low latency.") | |
| print("Advantage: lower power. Disadvantage: cannot detect gestures with screen off.") | |
| # ----------------------------------------------------------------- | |
| # Q6: CPU time of filter function | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q6: Filter function CPU time") | |
| print("="*70) | |
| q6_query = f""" | |
| SELECT | |
| COUNT(*) AS calls, | |
| AVG(dur) / 1e6 AS avg_ms, | |
| MAX(dur) / 1e6 AS max_ms, | |
| SUM(dur) / 1e6 AS total_ms | |
| FROM slice | |
| WHERE name = '{TRACEPOINTS["filter"]}' | |
| """ | |
| df_q6 = query(tp, q6_query) | |
| print_table("Q6: Filter slice statistics", df_q6) | |
| if not df_q6.empty and "avg_ms" in df_q6 and pd.notna(df_q6["avg_ms"].iloc[0]): | |
| avg = df_q6["avg_ms"].iloc[0] | |
| total = df_q6["total_ms"].iloc[0] | |
| print(f"Average filter duration: {avg:.4f} ms, total CPU time in trace: {total:.2f} ms") | |
| else: | |
| print("No filter slices found (ensure AirMouse app is active during trace).") | |
| # ----------------------------------------------------------------- | |
| # Q7: Most processing‑intensive sensor | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q7: Sensor processing cost by type") | |
| print("="*70) | |
| q7_query = f""" | |
| SELECT | |
| name, | |
| COUNT(*) AS calls, | |
| AVG(dur) / 1e6 AS avg_ms, | |
| SUM(dur) / 1e6 AS total_ms | |
| FROM slice | |
| WHERE name IN ( | |
| '{TRACEPOINTS["sensor_read"]}', | |
| '{TRACEPOINTS["filter"]}', | |
| '{TRACEPOINTS["compute_delta"]}' | |
| ) | |
| GROUP BY name | |
| ORDER BY total_ms DESC | |
| """ | |
| df_q7 = query(tp, q7_query) | |
| print_table("Q7: Per-stage processing cost", df_q7) | |
| if not df_q7.empty: | |
| max_row = df_q7.loc[df_q7["total_ms"].idxmax()] | |
| print(f"Most expensive stage: {max_row['name']} (total {max_row['total_ms']:.2f} ms)") | |
| else: | |
| print("No processing slices found. Adjust TRACEPOINTS.") | |
| # ----------------------------------------------------------------- | |
| # Q8: Effect of sensor sampling rate (text + evidence) | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q8: Effect of sampling rate on system overhead") | |
| print("="*70) | |
| # We can show the correlation between number of sensor events and CPU time | |
| q8_query = f""" | |
| SELECT | |
| COUNT(*) AS event_count, | |
| SUM(dur) / 1e6 AS total_sensor_ms, | |
| SUM(dur) / 1e6 / (SELECT COUNT(*) FROM slice WHERE name = '{TRACEPOINTS["sensor_read"]}') AS cost_per_event_ms | |
| FROM slice | |
| WHERE name = '{TRACEPOINTS["sensor_read"]}' | |
| """ | |
| df_q8 = query(tp, q8_query) | |
| print("In this trace:") | |
| print_table("Q8: Overhead per sensor event", df_q8) | |
| print("If you recorded traces at different rates (e.g., 20 ms vs 5 ms), compare these numbers.") | |
| print("Higher rate → more events → higher total CPU time, more context switches, more network packets.") | |
| # ----------------------------------------------------------------- | |
| # Q9: End‑to‑end latency (sensor → send) | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q9: Latency from sensor data ready to socket send") | |
| print("="*70) | |
| # We look for a sensor_read immediately followed by send_move. | |
| # This assumes the send happens right after processing. | |
| q9_query = f""" | |
| WITH paired AS ( | |
| SELECT | |
| s.ts AS sensor_start, | |
| s.ts + s.dur AS sensor_end, | |
| m.ts AS send_start, | |
| m.ts + m.dur AS send_end, | |
| (m.ts - s.ts) / 1e6 AS latency_ms | |
| FROM slice s | |
| JOIN slice m ON s.track_id = m.track_id | |
| WHERE s.name = '{TRACEPOINTS["sensor_read"]}' | |
| AND m.name = '{TRACEPOINTS["send_move"]}' | |
| AND m.ts > s.ts | |
| AND m.ts - s.ts < 1e9 -- within 1 second | |
| ) | |
| SELECT | |
| COUNT(*) AS pairs, | |
| AVG(latency_ms) AS avg_ms, | |
| MAX(latency_ms) AS max_ms | |
| FROM paired | |
| """ | |
| df_q9 = query(tp, q9_query) | |
| print_table("Q9: Sensor‑to‑send latency", df_q9) | |
| if not df_q9.empty and "avg_ms" in df_q9 and pd.notna(df_q9["avg_ms"].iloc[0]): | |
| avg_lat = df_q9["avg_ms"].iloc[0] | |
| print(f"Average local latency: {avg_lat:.2f} ms") | |
| print("Add network RTT (~1 ms) and server rendering (~2 ms) for total pointer latency.") | |
| else: | |
| print("Could not pair sensor_read with send_move. Check if both tracepoints fire in order.") | |
| # ----------------------------------------------------------------- | |
| # Q10: Thread assignment (sensor, processing, communication, UI) | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q10: Thread breakdown for each activity") | |
| print("="*70) | |
| q10_query = f""" | |
| SELECT DISTINCT | |
| thread.name AS thread_name, | |
| slice.name AS activity | |
| FROM slice | |
| JOIN thread_track ON slice.track_id = thread_track.id | |
| JOIN thread USING(utid) | |
| WHERE slice.name GLOB 'AirMouse#*' OR slice.name GLOB 'Madgwick*' | |
| ORDER BY thread_name, activity | |
| """ | |
| df_q10 = query(tp, q10_query) | |
| print_table("Q10: Thread assignment", df_q10) | |
| print("Ideal separation:") | |
| print("- Sensor callbacks → dedicated 'SensorThread' (via HandlerThread)") | |
| print("- Filter/delta computation → same or separate worker thread") | |
| print("- Network I/O → 'NetworkThread' or coroutine (IO dispatcher)") | |
| print("- UI updates → 'main' thread") | |
| # ----------------------------------------------------------------- | |
| # Q11: Slow vs fast movement impact | |
| # ----------------------------------------------------------------- | |
| print("\n" + "="*70) | |
| print("Q11: Effect of slow vs fast movement on processing") | |
| print("="*70) | |
| # We can compare filter slice durations when the mouse delta is large vs small. | |
| # Since we don't have delta magnitude directly in slices, we approximate by looking at | |
| # the distribution of filter durations – fast movements might show slightly higher durations | |
| # due to larger numbers, but not significantly. | |
| q11_query = f""" | |
| SELECT | |
| CASE | |
| WHEN dur / 1e6 < 0.5 THEN '<0.5 ms' | |
| WHEN dur / 1e6 < 1.0 THEN '0.5-1 ms' | |
| ELSE '>1 ms' | |
| END AS duration_bin, | |
| COUNT(*) AS count | |
| FROM slice | |
| WHERE name = '{TRACEPOINTS["filter"]}' | |
| GROUP BY duration_bin | |
| ORDER BY MIN(dur) | |
| """ | |
| df_q11 = query(tp, q11_query) | |
| print_table("Q11: Filter duration histogram (approximates slow/fast)", df_q11) | |
| print("Most filter calls are expected to be short (<1 ms). Fast movement does not") | |
| print("significantly increase duration; occasional spikes may occur due to GC or JIT.") | |
| print("Pointer smoothness is maintained because move deltas are clamped.") | |
| # ----------------------------------------------------------------- | |
| # Save JSON summary (optional) | |
| # ----------------------------------------------------------------- | |
| summary = { | |
| "q1_avg_callback_ms": safe_mean(df_q1, "duration_ms") if not df_q1.empty else None, | |
| "q3_mean_period_ms": safe_mean(df_q3, "interval_ms") if not df_q3.empty else None, | |
| "q6_avg_filter_ms": df_q6["avg_ms"].iloc[0] if not df_q6.empty and "avg_ms" in df_q6 else None, | |
| "q9_avg_latency_ms": df_q9["avg_ms"].iloc[0] if not df_q9.empty and "avg_ms" in df_q9 else None, | |
| } | |
| out_json = trace_file.replace(".perfetto-trace", "_analysis.json") | |
| with open(out_json, "w") as f: | |
| json.dump(summary, f, indent=4) | |
| print(f"\n💾 Summary written to {out_json}") | |
| print("="*70) | |
| print("Analysis complete. Use the printed results for your report.") | |
| # --------------------------------------------------------------------- | |
| # 4. Entry point | |
| # --------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| if len(sys.argv) != 2: | |
| print("Usage: python perfetto_analyzer.py <trace_file.perfetto-trace>") | |
| sys.exit(1) | |
| trace_file = sys.argv[1] | |
| if not os.path.exists(trace_file): | |
| print(f"❌ File not found: {trace_file}") | |
| sys.exit(1) | |
| analyze_trace(trace_file) |
Xet Storage Details
- Size:
- 16.2 kB
- Xet hash:
- 78804b00e598ebffd32044f1dfe9c8e0dcf588d86475e3b92008d9c00d8a376a
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.