import xml.etree.ElementTree as ET
import statistics
import json
import sys
import os
def resolve_refs(elem, id_map):
if elem is None:
return None
ref = elem.get('ref')
if ref is not None:
return id_map.get(ref)
eid = elem.get('id')
if eid is not None:
id_map[eid] = elem
return elem
def parse_surfaces(path, target_display='Display 1'):
tree = ET.parse(path)
root = tree.getroot()
id_maps = {}
def get_map(tag):
if tag not in id_maps:
id_maps[tag] = {}
return id_maps[tag]
rows_out = []
for row in root.iter('row'):
st = row.find('start-time')
dur = row.findall('duration') # can be 1 or 2 duration elements (display-duration, cpu-to-display-latency)
dn = row.find('display-name')
st_r = resolve_refs(st, get_map('start-time'))
dn_r = resolve_refs(dn, get_map('display-name'))
if st_r is None or dn_r is None:
continue
dname = dn_r.get('fmt')
if dname != target_display:
continue
# first duration = display duration (how long frame stayed on screen)
dur_r = None
if len(dur) >= 1:
dur_r = resolve_refs(dur[0], get_map('duration'))
ts = int(st_r.text) if st_r.text else None
dur_ns = int(dur_r.text) if (dur_r is not None and dur_r.text) else None
if ts is None or dur_ns is None:
continue
rows_out.append((ts, dur_ns))
return sorted(rows_out)
def analyze(path, expected_interval_ns, label):
rows = parse_surfaces(path)
durations_ms = [d/1e6 for _, d in rows]
times_s = [(t - rows[0][0])/1e9 for t, _ in rows]
n = len(durations_ms)
mean = statistics.mean(durations_ms)
stdev = statistics.pstdev(durations_ms)
expected_ms = expected_interval_ns/1e6
threshold = expected_ms * 1.5
outlier_idx = [i for i, v in enumerate(durations_ms) if v > threshold]
outliers = [durations_ms[i] for i in outlier_idx]
outlier_times = [times_s[i] for i in outlier_idx]
print(f"=== {label} ===")
print(f"Total surfaces displayed: {n} | Span: {times_s[-1]:.2f}s")
print(f"Mean display duration: {mean:.4f} ms (expected ~{expected_ms:.3f} ms per single-vsync hold)")
print(f"Stdev: {stdev:.4f} ms")
print(f"Min: {min(durations_ms):.4f} ms Max: {max(durations_ms):.4f} ms")
print(f"Frames held > {threshold:.3f} ms (>1.5x expected single interval = stutter/dropped frame): {len(outliers)} ({100*len(outliers)/n:.2f}%)")
if outliers:
top = sorted(zip(outliers, outlier_times), reverse=True)[:20]
print(f" Worst stutters (duration_ms @ time_s): {[(round(v,3), round(t,2)) for v,t in top]}")
# distribution of duration "buckets" rounded to nearest expected multiple
from collections import Counter
buckets = Counter(round(v/expected_ms) for v in durations_ms)
print(f" Duration buckets (multiples of {expected_ms:.3f}ms): {dict(sorted(buckets.items()))}")
# collapsed summary matching the report table: 1x / 2x / 3x+ / longest stall
n_1x = buckets.get(1, 0)
n_2x = buckets.get(2, 0)
n_3x_plus = sum(c for k, c in buckets.items() if k >= 3)
max_ms = max(durations_ms)
print()
print(f" Total frames: {n}")
print(f" Held for exactly 1 interval (healthy): {n_1x} ({100*n_1x/n:.1f}%)")
print(f" Held for exactly 2x interval (dropped/repeated): {n_2x} ({100*n_2x/n:.1f}%)")
print(f" Held 3x+ longer (multi-frame stalls): {n_3x_plus} ({100*n_3x_plus/n:.1f}%)")
print(f" Longest single stall: {max_ms:.1f} ms")
print()
return {
'label': label, 'n': n, 'span_s': times_s[-1], 'mean_ms': mean, 'stdev_ms': stdev,
'min_ms': min(durations_ms), 'max_ms': max_ms, 'expected_ms': expected_ms,
'threshold_ms': threshold, 'n_outliers': len(outliers), 'pct_outliers': 100*len(outliers)/n,
'outliers_ms': outliers, 'outlier_times_s': outlier_times,
'durations_ms': durations_ms, 'times_s': times_s,
'n_1x': n_1x, 'pct_1x': 100*n_1x/n,
'n_2x': n_2x, 'pct_2x': 100*n_2x/n,
'n_3x_plus': n_3x_plus, 'pct_3x_plus': 100*n_3x_plus/n,
}
def guess_interval_ns(path):
"""Infer expected vsync interval from a refresh rate found in the filename, e.g. '60hz'/'120hz'."""
import re
name = os.path.basename(path).lower()
match = re.search(r'(\d+)\s*hz', name)
if not match:
return None
hz = int(match.group(1))
if hz <= 0:
return None
return round(1e9 / hz)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(
description="Analyze displayed-surfaces-interval XML exported via xctrace."
)
parser.add_argument('xml_path', help='Path to the exported surfaces XML file')
parser.add_argument(
'--interval-ns', type=int, default=None,
help='Expected single-vsync interval in nanoseconds (e.g. 8333333 for 120Hz, 16666667 for 60Hz). '
'If omitted, inferred from a "<N>hz" pattern in the filename (e.g. surfaces_60hz.xml -> 60Hz).'
)
parser.add_argument('--label', default=None, help='Label to print for this run')
parser.add_argument(
'--out', default=None,
help='Optional path to save results as JSON (default: <xml_path_stem>_results.json next to input)'
)
args = parser.parse_args()
interval_ns = args.interval_ns
if interval_ns is None:
interval_ns = guess_interval_ns(args.xml_path)
if interval_ns is None:
parser.error(
"Could not infer refresh rate from filename (expected e.g. '60hz' or '120hz' in the name). "
"Pass --interval-ns explicitly."
)
print(f"Inferred interval from filename: {interval_ns} ns "
f"({1e9/interval_ns:.1f} Hz)")
label = args.label or os.path.basename(args.xml_path)
result = analyze(args.xml_path, interval_ns, label)
out_path = args.out or os.path.splitext(args.xml_path)[0] + '_results.json'
with open(out_path, 'w') as f:
json.dump(result, f)
print(f"Saved results to {out_path}")