metal-gpu-trace 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ {
2
+ "about": "Apple silicon GPU families Apple7 to Apple10: chips, first family with each feature, and limits that differ. Facts restated from Apple's public documents, not Apple's text. Check a device with MTLDevice.supportsFamily; families nest, so an Apple9 GPU also supports Apple7 and Apple8.",
3
+ "sources": {
4
+ "families": "https://developer.apple.com/documentation/metal/mtlgpufamily",
5
+ "feature_set_tables": "https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf",
6
+ "feature_set_tables_date": "2026-05-21",
7
+ "m3_tech_talk": "https://developer.apple.com/videos/play/tech-talks/111375/"
8
+ },
9
+ "guards": {
10
+ "M1 only": "Apple7 && !Apple8",
11
+ "before M3": "!Apple9"
12
+ },
13
+ "families": {
14
+ "Apple7": {
15
+ "mac_chips": ["M1", "M1 Pro", "M1 Max", "M1 Ultra"],
16
+ "other_chips": ["A14"],
17
+ "adds": ["mesh shading", "SIMD-scoped and quad-scoped reductions", "SIMD-scoped matrix multiply",
18
+ "floating-point atomics", "primitive ID", "barycentric coordinates", "border color",
19
+ "layered rendering to multisample textures", "MetalFX temporal upscaling",
20
+ "acquire/release memory ordering atomics", "Metal 4"],
21
+ "limits": {"mesh_grid_threadgroups": 1024, "argument_buffer_samplers_per_stage": 996,
22
+ "mesh_payload_and_geometry_per_draw": "up to 4 GB", "texture_2d_px": 16384}
23
+ },
24
+ "Apple8": {
25
+ "mac_chips": ["M2 series"],
26
+ "other_chips": ["A15", "A16"],
27
+ "adds": ["lossy texture compression", "SIMD shift and fill", "sparse depth/stencil on every device",
28
+ "placement sparse on every device", "query texture LOD on every device",
29
+ "64-bit atomic min/max on ulong in buffers and textures, macOS only"],
30
+ "limits": {"mesh_grid_threadgroups": 1024, "argument_buffer_samplers_per_stage": 996,
31
+ "mesh_payload_and_geometry_per_draw": "up to 4 GB", "texture_2d_px": 16384}
32
+ },
33
+ "Apple9": {
34
+ "mac_chips": ["M3 series", "M4 series"],
35
+ "other_chips": ["A17 Pro", "A18"],
36
+ "adds": ["full 64-bit atomics", "BC pixel formats on every device", "mesh draws in indirect command buffers",
37
+ "indirect mesh draw arguments", "ICB memory barriers for rendering",
38
+ "row-major matrices in acceleration structures", "per-component motion in ray tracing",
39
+ "MetalFX denoised upscaling", "Dynamic Caching (registers allocated as the program runs)"],
40
+ "limits": {"mesh_grid_threadgroups": 1048575, "argument_buffer_samplers_per_stage": 500000,
41
+ "mesh_payload_and_geometry_per_draw": "not stated", "texture_2d_px": 16384}
42
+ },
43
+ "Apple10": {
44
+ "mac_chips": ["M5 series"],
45
+ "other_chips": ["A19"],
46
+ "adds": ["depth bounds testing", "sampler min/max reduction", "sampler LOD bias",
47
+ "pre-raster per-vertex values", "ICB raster and depth/stencil state", "atomics on cube map textures",
48
+ "universal texture compression", "32 768 px textures", "16x16 tiles with 8x MSAA"],
49
+ "limits": {"mesh_grid_threadgroups": 4194303, "argument_buffer_samplers_per_stage": 500000,
50
+ "mesh_payload_and_geometry_per_draw": "not stated", "texture_2d_px": 32768}
51
+ }
52
+ },
53
+ "same_on_all": {"threads_per_threadgroup": 1024, "threadgroup_memory_bytes": 32768,
54
+ "mesh_pipeline_payload_bytes": 16384,
55
+ "argument_buffer_textures_or_buffers_per_stage": 1000000},
56
+ "footnotes": [
57
+ "64-bit atomics: none on Apple7; Apple8 has only ulong min/max, only on macOS; the full set starts with Apple9.",
58
+ "Mesh draws in indirect command buffers start with Apple9; earlier families refuse such mesh pipelines.",
59
+ "Inline constexpr samplers count toward the argument buffer sampler limit.",
60
+ "Mesh shaders on Apple7 and Apple8 can use up to 4 GB of payload and mesh geometry per draw.",
61
+ "Function pointers and ray tracing in render pipelines are not compatible with mesh shading."
62
+ ]
63
+ }
@@ -0,0 +1,108 @@
1
+ """Reads the command stream of a Metal .gputrace bundle directly, without Xcode, and summarizes it as JSON.
2
+
3
+ Usage: python3 -I gputrace_read.py <capture.gputrace> [--records]
4
+ The `capture` file is "MTSP": a 4-byte magic and a 4-byte word, then records of
5
+ [u32 size][i32 call id][...][u32 at +0x20][signature at +0x24, NUL-padded][arguments].
6
+ The record layout follows tmc/gputrace (MIT, https://github.com/tmc/gputrace); the 8-byte header, the call ids
7
+ and their names below were worked out here against a capture whose command list Xcode showed (macOS 27.0.1).
8
+ """
9
+ import argparse
10
+ import collections
11
+ import json
12
+ import pathlib
13
+ import re
14
+ import struct
15
+ import sys
16
+
17
+ # call id -> (Metal call, certainty): "confirmado" matched one by one against Xcode's command list
18
+ CALLS = {
19
+ -16244: ("pushDebugGroup", "confirmado"),
20
+ -16245: ("insertDebugSignpost", "confirmado"),
21
+ -16243: ("popDebugGroup", "confirmado"),
22
+ -16218: ("drawPrimitives", "confirmado"),
23
+ -16279: ("setRenderPipelineState", "confirmado"),
24
+ -16254: ("setDepthStencilState", "confirmado"),
25
+ -16253: ("setStencilReferenceValue", "confirmado"),
26
+ -16264: ("setTriangleFillMode", "confirmado"),
27
+ -16268: ("setCullMode", "confirmado"),
28
+ -16267: ("setDepthClipMode", "confirmado"),
29
+ -16266: ("setDepthBias", "confirmado"),
30
+ -16269: ("setFrontFacingWinding", "confirmado"),
31
+ -16255: ("setBlendColor", "confirmado"),
32
+ -16077: ("setViewports", "confirmado"),
33
+ -16076: ("setScissorRects", "confirmado"),
34
+ -16224: ("setVertexBufferOffset", "confirmado"),
35
+ -15819: ("useResource", "confirmado"),
36
+ -16353: ("renderCommandEncoder", "probable"),
37
+ -16246: ("endEncoding", "probable"),
38
+ -16343: ("commandBuffer", "probable"),
39
+ -10240: ("buffer contents", "probable"),
40
+ -15416: ("drawMeshThreadgroups", "probable"),
41
+ -15415: ("drawMeshThreadgroupsWithIndirectBuffer", "probable"),
42
+ -16327: ("dispatchThreadgroups", "probable"),
43
+ }
44
+
45
+
46
+ def records(path):
47
+ """Yields (offset, size, call id, signature, printable strings) for each record of an MTSP file."""
48
+ data = pathlib.Path(path).read_bytes()
49
+ off = 8 if data[:4] == b"MTSP" else 0
50
+ while off + 8 <= len(data):
51
+ size, call = struct.unpack_from("<Ii", data, off)
52
+ if size < 8 or off + size > len(data):
53
+ raise ValueError(f"{path}: record at 0x{off:x} has size {size}; the stream is not MTSP as expected")
54
+ rec = data[off:off + size]
55
+ signature = rec[0x24:].split(b"\0", 1)[0].decode("latin1") if size > 0x24 else ""
56
+ tail = rec[0x24 + len(signature) + 1:]
57
+ strings = [s.decode("latin1") for s in re.findall(rb"[ -~]{3,}", tail)]
58
+ yield off, size, call, signature, strings
59
+ off += size
60
+
61
+
62
+ def summarize(capture):
63
+ """Returns counts of encoders, draws and debug markers, and every call id seen, for a .gputrace bundle."""
64
+ capture = pathlib.Path(capture)
65
+ stream = capture / "capture"
66
+ if not stream.exists():
67
+ raise FileNotFoundError(f"{capture} has no capture file")
68
+ calls, labels = collections.Counter(), collections.defaultdict(list)
69
+ count = 0
70
+ for _, _, call, signature, strings in records(stream):
71
+ calls[(call, signature)] += 1
72
+ count += 1
73
+ if call in (-16244, -16245) and strings:
74
+ labels[CALLS[call][0]].append(strings[0])
75
+ named = lambda call: sum(n for (c, _), n in calls.items() if c == call)
76
+ return {
77
+ "capture": capture.name,
78
+ "records": count,
79
+ "command_buffers": named(-16343),
80
+ "render_encoders": named(-16353),
81
+ # only the drawPrimitives overload seen so far; indexed, mesh and indirect draws have other ids
82
+ "draw_primitives": named(-16218),
83
+ "debug_groups": labels.get("pushDebugGroup", []),
84
+ "signposts": labels.get("insertDebugSignpost", []),
85
+ "pops": named(-16243),
86
+ "calls": [
87
+ {"id": c, "signature": s, "count": n, "name": CALLS.get(c, (None, None))[0], "certainty": CALLS.get(c, (None, None))[1]}
88
+ for (c, s), n in sorted(calls.items(), key=lambda kv: -kv[1])
89
+ ],
90
+ }
91
+
92
+
93
+ def main():
94
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
95
+ parser.add_argument("capture", type=pathlib.Path)
96
+ parser.add_argument("--records", action="store_true", help="print every record instead of the summary")
97
+ args = parser.parse_args()
98
+ if args.records:
99
+ for off, size, call, signature, strings in records(args.capture / "capture"):
100
+ name = CALLS.get(call, ("?", ""))[0]
101
+ print(f"0x{off:06x} {size:5d} {call:7d} {signature:14s} {name:26s} {strings[:3]}")
102
+ return
103
+ json.dump(summarize(args.capture), sys.stdout, indent=2, ensure_ascii=False)
104
+ print()
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
@@ -0,0 +1,422 @@
1
+ """MCP server (stdio, JSON-RPC 2.0, standard library only) that records Wine or macOS programs with Instruments'
2
+ Metal System Trace and reads the recordings and Metal .gputrace captures.
3
+
4
+ Environment: METAL_TRACE_OUT is where recordings go (default ./perf-runs); METAL_TRACE_READ lists more directories,
5
+ separated by ':', whose files the tools may read; METAL_TRACE_WINE is the default Wine directory; KEEP_RUNS caps the
6
+ recordings kept; METAL_TRACE_LOG=off silences the one stderr line per call.
7
+ """
8
+ import json
9
+ import os
10
+ import pathlib
11
+ import shlex
12
+ import shutil
13
+ import signal
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+ __version__ = "0.4.0"
19
+ TOOLS_DIR = pathlib.Path(__file__).resolve().parent
20
+ sys.path.insert(0, str(TOOLS_DIR))
21
+ import gputrace_read # noqa: E402
22
+ import perf_report # noqa: E402
23
+
24
+ OUT = pathlib.Path(os.environ.get("METAL_TRACE_OUT", "perf-runs")).resolve()
25
+ READ = [OUT] + [pathlib.Path(p).resolve() for p in os.environ.get("METAL_TRACE_READ", "").split(":") if p]
26
+ FAMILIES = json.loads((TOOLS_DIR / "data" / "apple-gpu-families.json").read_text())
27
+ MAX_LIMIT = 500
28
+ # newest first; initialize answers with the client's version when listed here, else the first
29
+ PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
30
+ # results stay under Claude Code's 50 000-character limit, above which it saves them to a file
31
+ MAX_TEXT = 40_000
32
+ INSTRUCTIONS = (
33
+ "Records GPU use on Apple silicon with Metal System Trace. One recording runs at a time; keep other GPU work off "
34
+ "the Mac while it records. Under Wine record 30 s or more: Wine's start-up takes part of the recording. Reading a "
35
+ "recording back takes 1 to 9 s per recorded second. Compare only recordings with 100 frames or more, and repeat "
36
+ "a run before calling a change real. perf_diagnose says whether a run splits its GPU work into too many short "
37
+ "encoders, as translation layers do; perf_compare says how much two runs differ. trace_tables and trace_query need the .trace, kept only with KEEP_TRACE=1. "
38
+ "When perf_record ends it stops every program of its Wine prefix, or every process named like a native program."
39
+ )
40
+
41
+
42
+ class ToolError(Exception):
43
+ """A failure the caller can fix; its message says how."""
44
+
45
+
46
+ def allowed(path, roots=None):
47
+ """Returns `path` resolved, or raises ToolError when it lies outside the readable directories."""
48
+ resolved = pathlib.Path(path).expanduser().resolve()
49
+ if not any(resolved.is_relative_to(root) for root in roots or READ):
50
+ raise ToolError(f"{resolved.name} is outside the readable directories; add its directory to METAL_TRACE_READ")
51
+ return resolved
52
+
53
+
54
+ def name_ok(name):
55
+ """Returns whether `name` holds only letters, digits, '.', '_' and '-'."""
56
+ return name.replace("-", "").replace("_", "").replace(".", "").isalnum()
57
+
58
+
59
+ def run_path(run):
60
+ """Returns the summary path of a recording named as perf_runs lists it."""
61
+ if not name_ok(run):
62
+ raise ToolError("a run name holds only letters, digits, '.', '_' and '-'; take it from perf_runs")
63
+ path = OUT / f"{run}.json"
64
+ if not path.exists():
65
+ raise ToolError(f"no run {run}; perf_runs lists them")
66
+ return path
67
+
68
+
69
+ def trace_path(trace):
70
+ """Returns the .trace of a run named as perf_runs lists it, or `trace` as a path inside the readable
71
+ directories."""
72
+ if name_ok(trace):
73
+ return OUT / f"{trace.removesuffix('.trace')}.trace"
74
+ return allowed(trace)
75
+
76
+
77
+ def page(items, offset, limit):
78
+ """Returns one page of `items` with the total and the offset of the next page (None at the end)."""
79
+ offset, limit = max(0, int(offset)), min(max(1, int(limit)), MAX_LIMIT)
80
+ rows = items[offset:offset + limit]
81
+ more = offset + len(rows) < len(items)
82
+ return {"total": len(items), "offset": offset, "rows": rows, "next_offset": offset + len(rows) if more else None}
83
+
84
+
85
+ def tool_perf_record(label, program, seconds=20, wine=None, prefix=None, workdir=None, args=""):
86
+ """Records `program` (an .exe under Wine, or a macOS binary with wine="native") for `seconds` with Metal System
87
+ Trace and returns its summary: GPU ms per frame, frame intervals, FPS, Metal and resident memory. When it ends it
88
+ stops every program of that Wine prefix (wineserver -k), or every process named like the program with native."""
89
+ wine = wine or os.environ.get("METAL_TRACE_WINE")
90
+ if not wine:
91
+ raise ToolError("pass wine (a Wine directory holding bin/wine, or native) or set METAL_TRACE_WINE")
92
+ program = pathlib.Path(program).expanduser()
93
+ if not program.exists():
94
+ raise ToolError(f"no program at {program.name}")
95
+ workdir = pathlib.Path(workdir).expanduser() if workdir else program.parent
96
+ prefix = pathlib.Path(prefix or os.environ.get("WINEPREFIX", "~/.wine")).expanduser()
97
+ return record(label, seconds, wine, prefix, workdir, [str(program.resolve()), *shlex.split(args)])
98
+
99
+
100
+ def tool_perf_attach(label, process, seconds=20):
101
+ """Records a program that is already running, by pid or by a name in its command line (Game.exe under Wine),
102
+ for `seconds` and returns the same summary as perf_record; the program keeps running. For games started by
103
+ Steam, CrossOver, NotProton or any launcher: start the game or its benchmark there first."""
104
+ return record(label, seconds, "attach", "-", "-", [str(process)])
105
+
106
+
107
+ def deadline(seconds):
108
+ """Returns the seconds a recording of `seconds` may take in all; reading it back takes about 9 s per second."""
109
+ return int(seconds) * 10 + 120
110
+
111
+
112
+ def record(label, seconds, wine, prefix, workdir, command):
113
+ """Runs perf_record.sh and returns the summary it wrote, stopping the whole recording and deleting its files if
114
+ it overruns."""
115
+ if not name_ok(label):
116
+ raise ToolError("label holds only letters, digits, '.', '_' and '-'")
117
+ env = dict(os.environ, PERF_OUT=str(OUT))
118
+ before = set(OUT.glob(f"{label}_*"))
119
+ proc = subprocess.Popen(["zsh", str(TOOLS_DIR / "perf_record.sh"), label, str(int(seconds)), str(wine),
120
+ str(prefix), str(workdir), *command],
121
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env,
122
+ start_new_session=True)
123
+ try:
124
+ _, stderr = proc.communicate(timeout=deadline(seconds))
125
+ except subprocess.TimeoutExpired:
126
+ os.killpg(proc.pid, signal.SIGKILL)
127
+ proc.wait()
128
+ if wine not in ("native", "attach"):
129
+ subprocess.run([str(pathlib.Path(wine) / "bin" / "wineserver"), "-k"], env=dict(env, WINEPREFIX=str(prefix)),
130
+ capture_output=True, timeout=30)
131
+ if (OUT / ".recording").is_dir():
132
+ (OUT / ".recording").rmdir()
133
+ # the partial .trace and CSVs of this run
134
+ for path in set(OUT.glob(f"{label}_*")) - before:
135
+ shutil.rmtree(path) if path.is_dir() else path.unlink()
136
+ raise ToolError(f"the recording took over {deadline(seconds)} s and was stopped; nothing was saved")
137
+ new = sorted(set(OUT.glob(f"{label}_*.json")) - before)
138
+ if proc.returncode or not new:
139
+ reason = (stderr.strip().splitlines() or ["no summary was written"])[-1]
140
+ raise ToolError(f"recording failed (exit {proc.returncode}): {reason}")
141
+ return json.loads(new[-1].read_text())
142
+
143
+
144
+ def tool_perf_runs(offset=0, limit=50):
145
+ """Lists the saved recordings, newest first, with frames, FPS, GPU ms per frame and memory peaks."""
146
+ runs = []
147
+ for path in sorted(OUT.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
148
+ try:
149
+ s = json.loads(path.read_text())
150
+ except ValueError: # a summary still being written
151
+ continue
152
+ gpu = s.get("gpu", {})
153
+ runs.append({"run": path.stem, "program": (s.get("process") or {}).get("arguments"),
154
+ "frames": gpu.get("frames"), "fps": gpu.get("fps_estimate"),
155
+ "gpu_ms_mean": (gpu.get("frame_gpu_ms") or {}).get("mean"),
156
+ "metal_mib_peak": (s.get("metal_memory_mib") or {}).get("peak"),
157
+ "rss_mib_max": (s.get("process_rss_mib") or {}).get("max"),
158
+ "trace_kept": path.with_suffix(".trace").is_dir()})
159
+ return page(runs, offset, limit)
160
+
161
+
162
+ def tool_perf_compare(a, b):
163
+ """Compares two saved recordings (names from perf_runs): each headline number, its change and change in %.
164
+ Recordings with under 100 frames are flagged: too short to compare."""
165
+ sa, sb = (json.loads(run_path(r).read_text()) for r in (a, b))
166
+ metrics = {
167
+ "fps": lambda s: s["gpu"].get("fps_estimate"),
168
+ "gpu_ms_mean": lambda s: s["gpu"]["frame_gpu_ms"].get("mean"),
169
+ "gpu_ms_p95": lambda s: s["gpu"]["frame_gpu_ms"].get("p95"),
170
+ "frame_interval_ms_p95": lambda s: s["gpu"]["frame_interval_ms"].get("p95"),
171
+ "metal_mib_peak": lambda s: s.get("metal_memory_mib", {}).get("peak"),
172
+ "rss_mib_max": lambda s: s.get("process_rss_mib", {}).get("max"),
173
+ }
174
+ out = {}
175
+ for name, get in metrics.items():
176
+ try:
177
+ va, vb = get(sa), get(sb)
178
+ except (KeyError, TypeError):
179
+ va = vb = None
180
+ change = None if va is None or vb is None else round(vb - va, 3)
181
+ out[name] = {"a": va, "b": vb, "change": change,
182
+ "change_pct": None if change is None or not va else round(100 * change / va, 1)}
183
+ short = [r for r, s in ((a, sa), (b, sb)) if (s.get("gpu") or {}).get("frames", 0) < 100]
184
+ return {"a": a, "b": b, "metrics": out, "too_short": short}
185
+
186
+
187
+ def tool_perf_diagnose(run, min_encoders=4, short_us=25, short_share=0.25):
188
+ """Says whether a saved recording splits its GPU work into too many short encoders, the pattern of translation
189
+ layers such as DXMT, Mullion or MoltenVK, with the numbers behind it: encoders and command buffers per frame, the
190
+ share of short encoders, the labels with the most GPU time and the GPU's idle time. Read the idle time together
191
+ with the FPS: a program waiting for vsync idles too. Defaults come from bgfx 17-drawstress on an M1 Pro: 7
192
+ encoders per frame and 50% under 25 us under Mullion, 2 and 2% in native Metal."""
193
+ s = json.loads(run_path(run).read_text())
194
+ encoders = s.get("encoders")
195
+ if not encoders:
196
+ raise ToolError(f"{run} was summarized before version 0.4.0 and has no encoder numbers; record it again")
197
+ share = encoders["short_share"].get(str(int(short_us)))
198
+ if share is None:
199
+ raise ToolError(f"short_us is one of {', '.join(map(str, perf_report.SHORT_US))}")
200
+ per_frame = encoders["per_frame"].get("p50", 0)
201
+ frames = (s.get("gpu") or {}).get("frames", 0)
202
+ return {"run": run, "too_many_short_encoders": per_frame >= min_encoders and share >= short_share,
203
+ "thresholds": {"min_encoders": min_encoders, "short_us": int(short_us), "short_share": short_share},
204
+ "encoders_per_frame_p50": per_frame,
205
+ "command_buffers_per_frame_p50": encoders["command_buffers_per_frame"].get("p50"),
206
+ "short_encoder_share": share, "fps": (s.get("gpu") or {}).get("fps_estimate"),
207
+ "gpu_idle": s.get("gpu_idle"), "by_label": encoders["by_label"], "too_short": frames < 100}
208
+
209
+
210
+ def tool_trace_tables(trace):
211
+ """Lists the data tables (schemas) a .trace recording holds; recordings keep their .trace only with KEEP_TRACE=1."""
212
+ toc = perf_report.toc_of(trace_path(trace))
213
+ return {"tables": sorted({t.attrib["schema"] for t in toc.iter("table") if "schema" in t.attrib})}
214
+
215
+
216
+ def tool_trace_query(trace, schema, offset=0, limit=50, pid=None):
217
+ """Returns one page of rows of one table of a .trace as {column: value}, optionally only one process's rows."""
218
+ if not schema.replace("-", "").isalnum():
219
+ raise ToolError("schema holds only letters, digits and '-'; trace_tables lists them")
220
+ xpath = f'/trace-toc/run[@number="1"]/data/table[@schema="{schema}"]'
221
+ rows = []
222
+ for row in perf_report.rows(perf_report.export(trace_path(trace), xpath=xpath)):
223
+ if pid is not None and "process" in row and perf_report.pid_of(row["process"]) != int(pid):
224
+ continue
225
+ rows.append({k: (v.attrib.get("fmt") or v.text) if v is not None else None for k, v in row.items()})
226
+ return page(rows, offset, limit)
227
+
228
+
229
+ def tool_capture_summary(capture):
230
+ """Reads a Metal .gputrace capture without Xcode: command buffers, encoders, draws, debug groups, and the 100
231
+ most frequent call ids out of calls_total."""
232
+ summary = gputrace_read.summarize(allowed(capture))
233
+ summary["calls_total"] = len(summary["calls"])
234
+ summary["calls"] = summary["calls"][:100]
235
+ return summary
236
+
237
+
238
+ def tool_capture_records(capture, offset=0, limit=100):
239
+ """Returns one page of the records of a .gputrace command stream, with their Metal call names when known."""
240
+ out = [{"offset": off, "size": size, "id": call, "signature": signature,
241
+ "name": gputrace_read.CALLS.get(call, (None,))[0], "strings": strings[:3]}
242
+ for off, size, call, signature, strings in gputrace_read.records(allowed(capture) / "capture")]
243
+ return page(out, offset, limit)
244
+
245
+
246
+ def family_of(name):
247
+ """Returns the Metal family key for a family ("apple7") or chip name ("M1 Pro", "A15"), or None."""
248
+ wanted = name.strip().lower().replace(" ", "")
249
+ for key, family in FAMILIES["families"].items():
250
+ chips = [c.lower().replace(" ", "").removesuffix("series") for c in family["mac_chips"] + family["other_chips"]]
251
+ base = wanted.removesuffix("ultra").removesuffix("max").removesuffix("pro")
252
+ if wanted == key.lower() or wanted in chips or base in chips:
253
+ return key
254
+ return None
255
+
256
+
257
+ def tool_gpu_family(name):
258
+ """Apple's published facts for one GPU family, by family ("Apple7") or chip ("M1 Pro"): its chips, what it adds,
259
+ its limits, plus the limits shared by all families and the footnotes. Source URLs are in the result."""
260
+ key = family_of(name)
261
+ if key is None:
262
+ raise ToolError(f"unknown family or chip {name!r}; try one of {', '.join(FAMILIES['families'])} or a chip "
263
+ "such as M1 Pro, M2, M3 Max, A17 Pro")
264
+ return {"family": key, **FAMILIES["families"][key], "same_on_all": FAMILIES["same_on_all"],
265
+ "footnotes": FAMILIES["footnotes"], "guards": FAMILIES["guards"], "sources": FAMILIES["sources"]}
266
+
267
+
268
+ RESOURCES = {"metal-trace://gpu-families": ("Apple GPU families", FAMILIES["about"], lambda: FAMILIES)} | {
269
+ f"metal-trace://gpu-families/{key.lower()}": (f"{key} GPU family", f"Apple's published facts for {key}",
270
+ lambda key=key: tool_gpu_family(key))
271
+ for key in FAMILIES["families"]}
272
+
273
+ S, I = {"type": "string"}, {"type": "integer"}
274
+ PAGE = {"offset": {**I, "description": "first row, default 0"},
275
+ "limit": {**I, "description": f"rows per page, default 50, at most {MAX_LIMIT}"}}
276
+ TRACE = {**S, "description": "a run name from perf_runs whose trace_kept is true, or the path of a .trace"}
277
+ CAPTURE = {**S, "description": "path of a .gputrace bundle saved from Xcode, inside METAL_TRACE_OUT or METAL_TRACE_READ"}
278
+
279
+
280
+ def schema(properties, required=()):
281
+ """Returns a JSON Schema object with these properties."""
282
+ return {"type": "object", "properties": properties, "required": list(required)}
283
+
284
+
285
+ def hints(title, destructive=None):
286
+ """Returns a tool's MCP annotations: read-only unless `destructive` says whether its writes stop programs."""
287
+ if destructive is None:
288
+ return {"title": title, "readOnlyHint": True, "openWorldHint": False}
289
+ return {"title": title, "readOnlyHint": False, "destructiveHint": destructive, "idempotentHint": False,
290
+ "openWorldHint": False}
291
+
292
+
293
+ TOOLS = {
294
+ "perf_record": (tool_perf_record, schema(
295
+ {"label": {**S, "description": "name for the run: letters, digits, '.', '_', '-'"},
296
+ "program": {**S, "description": "path of the .exe, or of the macOS binary with wine=native"},
297
+ "seconds": {**I, "description": "recording length, default 20; use 30 under Wine, whose start-up takes part of it; the export afterwards takes about a minute"},
298
+ "wine": {**S, "description": "Wine directory holding bin/wine, or native"},
299
+ "prefix": {**S, "description": "Wine prefix, default WINEPREFIX or ~/.wine; ignored with native"},
300
+ "workdir": {**S, "description": "directory the program starts in, default the program's own"},
301
+ "args": {**S, "description": "program arguments as in a shell, quotes included"}}, ["label", "program"]),
302
+ hints("Record a program", destructive=True)),
303
+ "perf_attach": (tool_perf_attach, schema(
304
+ {"label": {**S, "description": "name for the run: letters, digits, '.', '_', '-'"},
305
+ "process": {**S, "description": "pid, or a name in the program's command line such as Game.exe"},
306
+ "seconds": {**I, "description": "recording length, default 20; the export afterwards takes about a minute"}},
307
+ ["label", "process"]), hints("Record a running program", destructive=False)),
308
+ "perf_runs": (tool_perf_runs, schema(PAGE), hints("List recordings")),
309
+ "perf_compare": (tool_perf_compare, schema(
310
+ {"a": {**S, "description": "run name from perf_runs: the reference, such as the run before a change"},
311
+ "b": {**S, "description": "run name from perf_runs: the run to compare against a"}}, ["a", "b"]),
312
+ hints("Compare two recordings")),
313
+ "perf_diagnose": (tool_perf_diagnose, schema(
314
+ {"run": {**S, "description": "a run name from perf_runs"},
315
+ "min_encoders": {**I, "description": "encoders per frame (median) from which the verdict can be yes, default 4"},
316
+ "short_us": {**I, "description": "an encoder under this GPU time counts as short: 10, 25 (default), 50, 100, "
317
+ "250, 500 or 1000"},
318
+ "short_share": {"type": "number", "description": "share of short encoders from which the verdict is yes, "
319
+ "default 0.25"}}, ["run"]),
320
+ hints("Diagnose encoder use")),
321
+ "trace_tables": (tool_trace_tables, schema({"trace": TRACE}, ["trace"]), hints("List a trace's tables")),
322
+ "trace_query": (tool_trace_query, schema(
323
+ {"trace": TRACE, "schema": {**S, "description": "a table name that trace_tables lists, such as metal-gpu-intervals"},
324
+ "pid": {**I, "description": "keep only this process's rows"}, **PAGE}, ["trace", "schema"]),
325
+ hints("Read a trace table")),
326
+ "capture_summary": (tool_capture_summary, schema({"capture": CAPTURE}, ["capture"]), hints("Summarize a capture")),
327
+ "capture_records": (tool_capture_records, schema({"capture": CAPTURE, **PAGE}, ["capture"]),
328
+ hints("Read a capture's records")),
329
+ "gpu_family": (tool_gpu_family, schema(
330
+ {"name": {**S, "description": "a family (Apple7 to Apple10) or a chip (M1 Pro, M2, M4 Max, A17 Pro)"}},
331
+ ["name"]), hints("Apple GPU family facts")),
332
+ }
333
+
334
+
335
+ def log(message):
336
+ """Writes one line to stderr, the only log channel a stdio server may use."""
337
+ if os.environ.get("METAL_TRACE_LOG") != "off":
338
+ print(f"metal-trace: {message}", file=sys.stderr, flush=True)
339
+
340
+
341
+ def call(name, arguments):
342
+ """Runs one tool and returns its MCP result, with failures as isError results that say what to do."""
343
+ started = time.monotonic()
344
+ try:
345
+ value = TOOLS[name][0](**arguments)
346
+ text = json.dumps(value, ensure_ascii=False)
347
+ if len(text) > MAX_TEXT:
348
+ raise ToolError(f"the result has {len(text)} characters; ask for a smaller page with limit")
349
+ result = {"content": [{"type": "text", "text": text}], "structuredContent": value}
350
+ except TypeError as error:
351
+ result = {"content": [{"type": "text", "text": f"bad arguments: {error}"}], "isError": True}
352
+ except (ToolError, FileNotFoundError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
353
+ result = {"content": [{"type": "text", "text": str(error)}], "isError": True}
354
+ except Exception as error: # an unexpected failure is reported to the client, not fatal to the server
355
+ result = {"content": [{"type": "text", "text": f"internal error: {type(error).__name__}"}], "isError": True}
356
+ log(f"{name} {'error' if result.get('isError') else 'ok'} in {time.monotonic() - started:.1f} s")
357
+ return result
358
+
359
+
360
+ def handle(message):
361
+ """Answers one JSON-RPC request; returns None for notifications."""
362
+ if not isinstance(message, dict):
363
+ return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}}
364
+ method, mid, params = message.get("method"), message.get("id"), message.get("params") or {}
365
+ if mid is None:
366
+ return None
367
+ if not isinstance(params, dict):
368
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32600, "message": "params must be an object"}}
369
+ if method == "initialize":
370
+ asked = params.get("protocolVersion")
371
+ result = {"protocolVersion": asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0],
372
+ "capabilities": {"tools": {}, "resources": {}},
373
+ "serverInfo": {"name": "metal-trace", "version": __version__}, "instructions": INSTRUCTIONS}
374
+ elif method == "ping":
375
+ result = {}
376
+ elif method == "resources/templates/list":
377
+ result = {"resourceTemplates": []}
378
+ elif method == "resources/list":
379
+ result = {"resources": [{"uri": uri, "name": name, "description": about, "mimeType": "application/json"}
380
+ for uri, (name, about, _) in RESOURCES.items()]}
381
+ elif method == "resources/read":
382
+ uri = params.get("uri")
383
+ if uri not in RESOURCES:
384
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32002, "message": "Resource not found",
385
+ "data": {"uri": uri}}}
386
+ result = {"contents": [{"uri": uri, "mimeType": "application/json",
387
+ "text": json.dumps(RESOURCES[uri][2](), ensure_ascii=False)}]}
388
+ elif method == "tools/list":
389
+ result = {"tools": [{"name": n, "description": f.__doc__.strip(), "inputSchema": s,
390
+ "annotations": hints} for n, (f, s, hints) in TOOLS.items()]}
391
+ elif method == "tools/call":
392
+ name = params.get("name")
393
+ if name not in TOOLS:
394
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": f"unknown tool {name}"}}
395
+ result = call(name, params.get("arguments") or {})
396
+ else:
397
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"unknown method {method}"}}
398
+ return {"jsonrpc": "2.0", "id": mid, "result": result}
399
+
400
+
401
+ def main():
402
+ """Serves newline-delimited JSON-RPC on stdin and stdout until stdin closes; --version prints the version."""
403
+ if sys.argv[1:] == ["--version"]:
404
+ print(f"metal-gpu-trace {__version__}")
405
+ return
406
+ for line in sys.stdin:
407
+ if not line.strip():
408
+ continue
409
+ try:
410
+ reply = handle(json.loads(line))
411
+ except json.JSONDecodeError:
412
+ reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
413
+ except Exception as error: # a malformed request must not stop the server
414
+ log(f"internal error {type(error).__name__}")
415
+ reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": "internal error"}}
416
+ if reply is not None:
417
+ sys.stdout.write(json.dumps(reply) + "\n")
418
+ sys.stdout.flush()
419
+
420
+
421
+ if __name__ == "__main__":
422
+ main()
@@ -0,0 +1,71 @@
1
+ #!/bin/zsh
2
+ # Records a Wine or macOS program with Metal System Trace into $PERF_OUT, keeping the newest KEEP_RUNS runs.
3
+ # Usage: [PERF_OUT=dir] [KEEP_TRACE=1] [KEEP_RUNS=N] perf_record.sh <label> <seconds> <wine dir|native> <prefix> <working dir> <program> [args...]
4
+ # or: perf_record.sh <label> <seconds> attach - - <pid|name> (records a running program, left running afterwards)
5
+ set -euo pipefail
6
+
7
+ here=${0:A:h}
8
+ label=$1 seconds=$2 wine=$3 prefix=$4 workdir=$5
9
+ shift 5
10
+ [[ $label =~ '^[A-Za-z0-9._-]+$' ]] || { echo "label may hold only letters, digits, '.', '_' and '-'" >&2; exit 2; }
11
+ [[ $seconds == <1-3600> ]] || { echo "seconds must be 1 to 3600" >&2; exit 2; }
12
+ [[ $wine == (native|attach) || -x $wine/bin/wine ]] || { echo "no Wine at $wine/bin/wine (or pass native or attach)" >&2; exit 2; }
13
+ if [[ $wine == attach ]]; then
14
+ pid=$1
15
+ # a name matches the newest process whose command line holds it, such as Game.exe under Wine
16
+ [[ $pid == <-> ]] || pid=$(ps -axo pid=,command= | awk -v name="$1" 'index($0, name) && !/xctrace|awk|zsh|perf_record|ps -axo/ {p = $1} END {print p}')
17
+ [[ -n $pid ]] && kill -0 $pid 2>/dev/null || { echo "no running process matches $1" >&2; exit 2; }
18
+ fi
19
+ name=${label}_$(date +%Y%m%d-%H%M%S)
20
+ # default: logs/perf beside the script's directory when it exists, else ./perf-runs
21
+ out=${PERF_OUT:-${here:h}/logs/perf}
22
+ [[ -n ${PERF_OUT:-} || -d $out ]] || out=$PWD/perf-runs
23
+ mkdir -p $out
24
+
25
+ # one recording at a time: two programs sharing the GPU spoil both measurements
26
+ lock=$out/.recording
27
+ mkdir $lock 2>/dev/null || { echo "another recording is running ($lock); remove it if none is" >&2; exit 3; }
28
+ trap 'rmdir $lock 2>/dev/null' EXIT
29
+
30
+ [[ $wine == attach ]] || cd $workdir
31
+ # the resident memory of the program's Wine process, once a second, while Instruments records
32
+ exe=${1:t}
33
+ (
34
+ echo "time_s,rss_kib" > $out/$name.rss.csv
35
+ for ((t = 0; t <= seconds + 5; t++)); do
36
+ if [[ $wine == attach ]]; then
37
+ rss=$(ps -o rss= -p $pid | awk '{print $1 + 0}')
38
+ else
39
+ rss=$(ps -axo rss=,command= | awk -v exe="$exe" 'index($0, exe) && !/xctrace|awk|zsh|perf_record|ps -axo/ {sum += $1} END {print sum + 0}')
40
+ fi
41
+ echo "$t,$rss" >> $out/$name.rss.csv
42
+ sleep 1
43
+ done
44
+ ) &
45
+ sampler=$!
46
+ # PERF_ENV="NAME=VALUE ..." sets the program's variables (MULLION_DIAG=profile labels Mullion's encoders)
47
+ extra=()
48
+ for pair in ${=PERF_ENV:-}; do extra+=(--env $pair); done
49
+ if [[ $wine == attach ]]; then
50
+ xcrun xctrace record --template "Metal System Trace" --time-limit ${seconds}s --output $out/$name.trace \
51
+ --attach $pid || true
52
+ elif [[ $wine == native ]]; then
53
+ xcrun xctrace record --template "Metal System Trace" --time-limit ${seconds}s --output $out/$name.trace \
54
+ $extra --launch -- "$@" || true
55
+ pkill -x $exe || true
56
+ else
57
+ xcrun xctrace record --template "Metal System Trace" --time-limit ${seconds}s --output $out/$name.trace \
58
+ --env WINEPREFIX=$prefix --env WINEDEBUG=-all --env "WINEDLLOVERRIDES=mscoree=;mshtml=" $extra \
59
+ --launch -- $wine/bin/wine "$@" || true
60
+ # the program outlives the recording; stop what is left of its Wine
61
+ WINEPREFIX=$prefix $wine/bin/wineserver -k || true
62
+ fi
63
+ kill $sampler 2>/dev/null || true
64
+ [ -d $out/$name.trace ] || { echo "no recording at $out/$name.trace" >&2; exit 1; }
65
+
66
+ python3 -I $here/perf_report.py $out/$name.trace --label $name --out $out
67
+ # the .trace goes once its numbers are in the .json; KEEP_TRACE=1 keeps it for trace_tables, trace_query or Instruments
68
+ [[ ${KEEP_TRACE:-0} == 1 ]] || rm -rf $out/$name.trace
69
+ if [[ ${KEEP_RUNS:-} == <1-> ]]; then
70
+ for old in $out/*.json(N.om[$((KEEP_RUNS + 1)),-1]); do rm -rf ${old:r}.*(N); done
71
+ fi
@@ -0,0 +1,293 @@
1
+ """Summarizes an Instruments "Metal System Trace" recording of one launched program: GPU time per frame and memory.
2
+
3
+ Usage: python3 -I perf_report.py <recording.trace> [--label NAME] [--out DIR]
4
+ Writes <out>/<label>.json (summary), <label>.frames.csv (one row per GPU frame) and <label>.metal_memory.csv (Metal
5
+ memory over time). A <recording>.rss.csv beside the trace adds the process's resident memory.
6
+ """
7
+ import argparse
8
+ import collections
9
+ import csv
10
+ import json
11
+ import os
12
+ import pathlib
13
+ import re
14
+ import statistics
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import xml.etree.ElementTree as ET
19
+
20
+
21
+ def export(trace, xpath=None, toc=False, timeout=600):
22
+ """Returns the path of a temporary XML file holding the trace's table of contents or the table that `xpath`
23
+ selects; `rows` and `toc_of` delete it once read."""
24
+ if not pathlib.Path(trace).is_dir():
25
+ raise FileNotFoundError(f"no recording at {pathlib.Path(trace).name}: perf_record.sh deletes the .trace once "
26
+ "its summary is written, unless KEEP_TRACE=1")
27
+ out = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
28
+ out.close()
29
+ cmd = ["xcrun", "xctrace", "export", "--input", str(trace), "--output", out.name]
30
+ cmd += ["--toc"] if toc else ["--xpath", xpath]
31
+ proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, timeout=timeout)
32
+ if proc.returncode:
33
+ os.unlink(out.name)
34
+ reason = (proc.stderr.strip().splitlines() or ["no message"])[-1]
35
+ raise RuntimeError(f"xctrace export of {pathlib.Path(trace).name} failed (exit {proc.returncode}): {reason}")
36
+ return out.name
37
+
38
+
39
+ def toc_of(trace):
40
+ """Returns the root element of the trace's table of contents."""
41
+ path = export(trace, toc=True)
42
+ try:
43
+ return ET.parse(path).getroot()
44
+ finally:
45
+ os.unlink(path)
46
+
47
+
48
+ def rows(path):
49
+ """Yields each row of an exported table as {column mnemonic: element}, with xctrace's id/ref values resolved,
50
+ and deletes the file when done or closed early."""
51
+ ids, columns = {}, []
52
+ try:
53
+ for _, el in ET.iterparse(path, events=("end",)):
54
+ if "id" in el.attrib:
55
+ ids[el.attrib["id"]] = el
56
+ if el.tag == "col":
57
+ columns.append(el.findtext("mnemonic"))
58
+ elif el.tag == "row":
59
+ values = [ids.get(c.attrib["ref"], c) if "ref" in c.attrib else c for c in el]
60
+ yield dict(zip(columns, values))
61
+ finally:
62
+ os.unlink(path)
63
+
64
+
65
+ def public_arguments(arguments):
66
+ """Returns a command line with each absolute path cut to its file name, so summaries can be shared."""
67
+ return " ".join(pathlib.Path(a).name if a.startswith("/") else a for a in (arguments or "").split())
68
+
69
+
70
+ def text(el):
71
+ """Returns an exported cell's formatted value, or "" for a missing cell."""
72
+ return (el.attrib.get("fmt") or el.text or "").strip() if el is not None else ""
73
+
74
+
75
+ def number(el):
76
+ return int(el.text) if el is not None and el.text and el.text.strip().lstrip("-").isdigit() else None
77
+
78
+
79
+ def pid_of(process):
80
+ """Returns the pid of a <process> value, whose <pid> child may itself be a reference."""
81
+ if process is None:
82
+ return None
83
+ pid = process.find("pid")
84
+ return int(pid.attrib.get("fmt") or pid.text) if pid is not None and (pid.text or pid.attrib.get("fmt")) else None
85
+
86
+
87
+ def stats(values):
88
+ """Returns count, mean, median, 95th percentile and maximum of `values`, rounded to microseconds when in ms."""
89
+ if not values:
90
+ return {"count": 0}
91
+ ordered = sorted(values)
92
+ return {
93
+ "count": len(ordered),
94
+ "mean": round(statistics.fmean(ordered), 3),
95
+ "p50": round(ordered[len(ordered) // 2], 3),
96
+ "p95": round(ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))], 3),
97
+ "max": round(ordered[-1], 3),
98
+ }
99
+
100
+
101
+ def missing_tables(toc):
102
+ """Returns the optional tables a trace's table of contents lacks; raises ValueError when it lacks the GPU
103
+ intervals every summary needs, as when an Xcode version renames them."""
104
+ schemas = {t.attrib.get("schema") for t in toc.iter("table")}
105
+ if "metal-gpu-intervals" not in schemas:
106
+ raise ValueError("the trace has no metal-gpu-intervals table; this Xcode may name it differently: "
107
+ "trace_tables lists the tables it has")
108
+ return sorted({"metal-current-allocated-size", "gpu-performance-state-intervals",
109
+ "metal-application-encoders-list"} - schemas)
110
+
111
+
112
+ # encoder GPU times, in µs, whose share of all encoders each summary keeps
113
+ SHORT_US = (10, 25, 50, 100, 250, 500, 1000)
114
+
115
+
116
+ def label_name(label):
117
+ """Returns an encoder label without Instruments' "[n] " prefix and trailing index, so the same pass groups."""
118
+ return re.sub(r" \d+$", "", re.sub(r"^\[\d+\] ", "", label)) or "?"
119
+
120
+
121
+ def encoder_stats(spans, labels):
122
+ """Returns encoders and command buffers per frame, each encoder's GPU time, the share of encoders shorter than
123
+ each of SHORT_US and the 20 labels with the most GPU time. `spans` holds (frame, start ns, duration ns, encoder
124
+ id, command buffer id) and `labels` maps an encoder id to its label."""
125
+ encoders, buffers, busy = collections.defaultdict(set), collections.defaultdict(set), collections.Counter()
126
+ for frame, _, duration, encoder, buffer in spans:
127
+ if encoder:
128
+ encoders[frame].add(encoder)
129
+ busy[encoder] += duration
130
+ if buffer:
131
+ buffers[frame].add(buffer)
132
+ us = [ns / 1000 for ns in busy.values()]
133
+ grouped = collections.defaultdict(list)
134
+ for encoder, ns in busy.items():
135
+ grouped[label_name(labels.get(encoder, ""))].append(ns / 1000)
136
+ frames = len({s[0] for s in spans}) or 1
137
+ top = sorted(grouped.items(), key=lambda kv: -sum(kv[1]))[:20]
138
+ return {
139
+ "per_frame": stats([len(e) for e in encoders.values()]),
140
+ "command_buffers_per_frame": stats([len(b) for b in buffers.values()]),
141
+ "encoder_us": stats(us),
142
+ "short_share": {str(cut): round(sum(u < cut for u in us) / len(us), 3) for cut in SHORT_US} if us else {},
143
+ "by_label": [{"label": name, "per_frame": round(len(v) / frames, 2), "total_ms": round(sum(v) / 1000, 3),
144
+ "mean_us": round(statistics.fmean(v), 1), "stdev_us": round(statistics.pstdev(v), 1)}
145
+ for name, v in top],
146
+ }
147
+
148
+
149
+ def idle_gaps(spans):
150
+ """Returns the time the GPU ran none of the process's work between its first and last interval: per frame (each
151
+ gap counts in the frame of the work that ends it), gaps over 1 ms and share of the whole span. `spans` as in
152
+ encoder_stats."""
153
+ ordered = sorted((start, start + duration, frame) for frame, start, duration, _, _ in spans)
154
+ if not ordered:
155
+ return {"idle_ms_per_frame": {"count": 0}}
156
+ idle, gaps, end = collections.Counter({frame: 0 for _, _, frame in ordered}), [], ordered[0][1]
157
+ for start, stop, frame in ordered[1:]:
158
+ if start > end:
159
+ gaps.append((start - end) / 1e6)
160
+ idle[frame] += (start - end) / 1e6
161
+ end = max(end, stop)
162
+ whole = (end - ordered[0][0]) / 1e6
163
+ return {"idle_ms_per_frame": stats(list(idle.values())), "gaps_over_1ms": sum(g > 1 for g in gaps),
164
+ "idle_share": round(sum(gaps) / whole, 3) if whole else None}
165
+
166
+
167
+ def summarize(trace, label=None, out=None):
168
+ """Summarizes `trace`, writes the JSON and CSV files into `out` (default: beside it) and returns the summary."""
169
+ trace = pathlib.Path(trace)
170
+ label = label or trace.stem
171
+ out = pathlib.Path(out) if out else trace.parent
172
+ out.mkdir(parents=True, exist_ok=True)
173
+
174
+ toc = toc_of(trace)
175
+ launched = next((p for p in toc.iter("process") if p.attrib.get("type") in ("launched", "attached")), None)
176
+ if launched is None:
177
+ raise ValueError("the trace has no launched or attached process; record it with xctrace --launch or --attach")
178
+ pid = int(launched.attrib["pid"])
179
+ summary = {
180
+ "label": label,
181
+ "trace": trace.name,
182
+ "process": {k: launched.attrib.get(k) for k in ("name", "pid", "termination-reason")}
183
+ | {"arguments": public_arguments(launched.attrib.get("arguments"))},
184
+ # the machine's name and UUID stay out: these summaries are meant to be shared
185
+ "device": {
186
+ k: v
187
+ for k, v in (toc.find(".//device").attrib.items() if toc.find(".//device") is not None else [])
188
+ if k in ("platform", "model", "memory-size", "os-version")
189
+ },
190
+ "template": toc.findtext(".//template-name"),
191
+ "duration_s": float(toc.findtext(".//summary/duration") or 0),
192
+ # their numbers come out empty
193
+ "missing_tables": missing_tables(toc),
194
+ }
195
+
196
+ # GPU work of the launched process, per frame and per channel (vertex, fragment, compute)
197
+ frames = collections.defaultdict(lambda: collections.defaultdict(float))
198
+ first_start = {}
199
+ intervals = 0
200
+ spans = []
201
+ for row in rows(export(trace, xpath='/trace-toc/run[@number="1"]/data/table[@schema="metal-gpu-intervals"]')):
202
+ if pid_of(row.get("process")) != pid:
203
+ continue
204
+ frame, start, duration = number(row.get("frame-number")), number(row.get("start")), number(row.get("duration"))
205
+ if frame is None or duration is None:
206
+ continue
207
+ channel = (row.get("channel-name").text or "?").strip() if row.get("channel-name") is not None else "?"
208
+ frames[frame][channel] += duration / 1e6
209
+ first_start[frame] = min(first_start.get(frame, start), start)
210
+ intervals += 1
211
+ if start is not None:
212
+ spans.append((frame, start, duration, text(row.get("encoder-id")), text(row.get("cmdbuffer-id"))))
213
+
214
+ ordered = sorted(frames)
215
+ channels = sorted({c for f in frames.values() for c in f})
216
+ totals = [sum(frames[f].values()) for f in ordered]
217
+ starts = [first_start[f] / 1e6 for f in ordered]
218
+ gaps = [b - a for a, b in zip(starts, starts[1:]) if b > a]
219
+ summary["gpu"] = {
220
+ "intervals": intervals,
221
+ "frames": len(ordered),
222
+ "frame_gpu_ms": stats(totals),
223
+ "per_channel_ms": {c: stats([frames[f].get(c, 0.0) for f in ordered]) for c in channels},
224
+ "frame_interval_ms": stats(gaps),
225
+ "fps_estimate": round(1000 / statistics.median(gaps), 1) if gaps else None,
226
+ }
227
+
228
+ # how the work splits into encoders, and when the GPU waits for it
229
+ labels = {}
230
+ if "metal-application-encoders-list" not in summary["missing_tables"]:
231
+ xpath = '/trace-toc/run[@number="1"]/data/table[@schema="metal-application-encoders-list"]'
232
+ labels = {text(row.get("encoder-id")): text(row.get("encoder-label-indexed"))
233
+ for row in rows(export(trace, xpath=xpath))}
234
+ summary["encoders"] = encoder_stats(spans, labels)
235
+ summary["gpu_idle"] = idle_gaps(spans)
236
+
237
+ # Metal memory the launched process holds over time
238
+ allocated = []
239
+ for row in rows(export(trace, xpath='/trace-toc/run[@number="1"]/data/table[@schema="metal-current-allocated-size"]')):
240
+ size, start = number(row.get("current-allocated-size")), number(row.get("start"))
241
+ if pid_of(row.get("process")) == pid and size is not None and start is not None:
242
+ allocated.append((start / 1e6, size))
243
+ allocated.sort()
244
+ mib = [size / 2**20 for _, size in allocated]
245
+ summary["metal_memory_mib"] = (
246
+ {"samples": len(mib), "peak": round(max(mib), 2), "final": round(mib[-1], 2), "mean": round(statistics.fmean(mib), 2)}
247
+ if mib else {"samples": 0}
248
+ )
249
+ with open(out / f"{label}.metal_memory.csv", "w", newline="") as f:
250
+ writer = csv.writer(f)
251
+ writer.writerow(["time_ms", "metal_allocated_bytes"])
252
+ writer.writerows((round(t, 3), size) for t, size in allocated)
253
+
254
+ # the process's resident memory, sampled by perf_record.sh beside the trace
255
+ rss_csv = trace.with_suffix(".rss.csv")
256
+ if rss_csv.exists():
257
+ with open(rss_csv) as f:
258
+ rss = [int(r["rss_kib"]) / 1024 for r in csv.DictReader(f) if r["rss_kib"].isdigit()]
259
+ summary["process_rss_mib"] = stats(rss) if rss else {"count": 0}
260
+
261
+ # the GPU's clock states over the recording, as a share of time
262
+ states = collections.Counter()
263
+ for row in rows(export(trace, xpath='/trace-toc/run[@number="1"]/data/table[@schema="gpu-performance-state-intervals"]')):
264
+ state, duration = row.get("gpu-performance-state"), number(row.get("duration"))
265
+ if state is not None and duration:
266
+ states[(state.attrib.get("fmt") or state.text or "?").strip()] += duration
267
+ total_state = sum(states.values())
268
+ summary["gpu_performance_state_share"] = {k: round(v / total_state, 3) for k, v in states.most_common()} if total_state else {}
269
+
270
+ with open(out / f"{label}.frames.csv", "w", newline="") as f:
271
+ writer = csv.writer(f)
272
+ writer.writerow(["frame", "start_ms"] + [f"gpu_ms_{c.lower()}" for c in channels] + ["gpu_ms_total"])
273
+ for frame, start, total in zip(ordered, starts, totals):
274
+ writer.writerow([frame, round(start, 3)] + [round(frames[frame].get(c, 0.0), 4) for c in channels] + [round(total, 4)])
275
+ (out / f"{label}.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n")
276
+ return summary
277
+
278
+
279
+ def main():
280
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
281
+ parser.add_argument("trace", type=pathlib.Path)
282
+ parser.add_argument("--label", help="name of the output files; defaults to the trace's name")
283
+ parser.add_argument("--out", type=pathlib.Path, help="output directory; defaults to the trace's directory")
284
+ args = parser.parse_args()
285
+ try:
286
+ summary = summarize(args.trace, args.label, args.out)
287
+ except (ValueError, FileNotFoundError, RuntimeError) as error:
288
+ sys.exit(str(error))
289
+ print(json.dumps({k: summary.get(k) for k in ("gpu", "metal_memory_mib", "process_rss_mib")}, indent=2))
290
+
291
+
292
+ if __name__ == "__main__":
293
+ main()
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.5
2
+ Name: metal-gpu-trace
3
+ Version: 0.4.0
4
+ Summary: Measure Wine and macOS programs' GPU use on Apple silicon with Metal System Trace, from the shell or an MCP client
5
+ Project-URL: Homepage, https://github.com/partprogramming09/metal-gpu-trace
6
+ Project-URL: Changelog, https://github.com/partprogramming09/metal-gpu-trace/blob/main/CHANGELOG.md
7
+ Project-URL: Issues, https://github.com/partprogramming09/metal-gpu-trace/issues
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: apple-silicon,gpu,instruments,mcp,mcp-server,metal,profiling,wine,xctrace
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Classifier: Topic :: System :: Benchmark
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+
22
+ # metal-gpu-trace
23
+
24
+ [![test](https://github.com/partprogramming09/metal-gpu-trace/actions/workflows/test.yml/badge.svg)](https://github.com/partprogramming09/metal-gpu-trace/actions/workflows/test.yml)
25
+
26
+ <!-- mcp-name: io.github.partprogramming09/metal-gpu-trace -->
27
+
28
+ Measure how a Wine or macOS program uses the GPU on an Apple silicon Mac, from the command line or through an AI
29
+ coding agent: GPU time per frame, frame pacing, FPS and memory, recorded with Instruments' Metal System Trace; whether
30
+ a translation layer (DXMT, Mullion, MoltenVK) splits the work into too many short encoders; Metal
31
+ `.gputrace` captures read without Xcode; and Apple's published facts on each GPU family to read the numbers against.
32
+ Python 3.9+ standard library, `zsh`, and Xcode (for `xctrace`). MIT license. [Español](es/README.md).
33
+
34
+ | File | What it does |
35
+ | --- | --- |
36
+ | `perf_record.sh` | Records a program for N seconds with Metal System Trace and writes `<label>_<date>.json` (GPU ms per frame, frame intervals, FPS, Metal and resident memory) plus per-frame and memory CSVs. Deletes the `.trace` afterwards unless `KEEP_TRACE=1`. |
37
+ | `perf_report.py` | Summarizes an existing `.trace` the same way. |
38
+ | `gputrace_read.py` | Reads a Metal `.gputrace` capture without Xcode: command buffers, encoders, draws, debug groups. Call names marked `probable` are not confirmed. |
39
+ | `data/apple-gpu-families.json` | Apple7 to Apple10: chips, what each family adds, limits that differ, with Apple's sources. |
40
+ | `metal_trace_mcp.py` | An MCP server over all of the above. |
41
+ | `test_metal_trace_mcp.py` | Self-check without Instruments: `python3 -I test_metal_trace_mcp.py` prints `ok`; `pytest` runs the same tests. |
42
+
43
+ ## Recording
44
+
45
+ ```sh
46
+ PERF_OUT=~/perf-runs zsh perf_record.sh game-d3d12 30 /path/to/wine ~/.wine /path/to/game game.exe
47
+ PERF_OUT=~/perf-runs zsh perf_record.sh app-metal 30 native - /path/to/app ./app
48
+ ```
49
+
50
+ Arguments: label, seconds, a Wine directory holding `bin/wine` (or `native`), the Wine prefix, the working directory,
51
+ the program and its arguments. Without `PERF_OUT`, runs go to `./perf-runs`. Reading the trace afterwards takes about
52
+ a minute on an M1 Pro (87 s for a 10 s recording); through the MCP server the whole call times out after 10 s per
53
+ recorded second plus 120 s, and an overrun recording leaves no files behind. One recording runs at a time; keep other GPU work off the Mac while it records.
54
+
55
+ Under Wine, give it 30 s: Wine's start-up takes part of the recording. A recording with under 100 frames is too short
56
+ to compare.
57
+
58
+ ## Disk use
59
+
60
+ Each recording keeps a JSON summary and three CSVs, about 2 MB for 10 s (most of it the Metal memory samples). The
61
+ `.trace` (tens to hundreds of MB) is deleted unless `KEEP_TRACE=1`. `KEEP_RUNS=N` keeps only the newest N recordings.
62
+
63
+ ## MCP server
64
+
65
+ ### Install
66
+
67
+ The server runs with [`uvx`](https://docs.astral.sh/uv/) straight from this repo, with nothing to clone
68
+ (`brew install uv` installs it). Recording also needs Xcode installed (`xcrun xctrace version` prints a version).
69
+
70
+ ```sh
71
+ uvx --from git+https://github.com/partprogramming09/metal-gpu-trace metal-gpu-trace
72
+ ```
73
+
74
+ This is the command each client below runs. Desktop apps do not see Homebrew's `PATH`: give them the full path that
75
+ `which uvx` prints (`/opt/homebrew/bin/uvx` on Apple silicon). `uvx` keeps the version it first downloaded; run
76
+ the command once with `--refresh` after `uvx` to get the latest.
77
+
78
+ The server reads these environment variables:
79
+
80
+ | Variable | What it sets |
81
+ | --- | --- |
82
+ | `METAL_TRACE_OUT` | Where recordings go. Give an absolute path: the default, `./perf-runs`, depends on the client's working directory. |
83
+ | `METAL_TRACE_WINE` | The Wine directory (holding `bin/wine`) that `perf_record` uses when the call names none. |
84
+ | `WINEPREFIX` | The Wine prefix used when the call names none; `~/.wine` otherwise. |
85
+ | `METAL_TRACE_READ` | More directories the tools may read, separated by `:`. |
86
+ | `METAL_TRACE_LOG` | `off` silences the one-line stderr log per call. |
87
+ | `KEEP_TRACE` | `1` keeps each recording's `.trace`, for `trace_tables`, `trace_query` or Instruments. |
88
+ | `KEEP_RUNS` | Keeps only the newest N recordings. |
89
+ | `PERF_ENV` | `NAME=VALUE` pairs, separated by spaces, set for the recorded program. |
90
+
91
+ ### Claude Code
92
+
93
+ ```sh
94
+ claude mcp add --scope user metal-trace -e METAL_TRACE_OUT=$HOME/perf-runs -e METAL_TRACE_WINE=/path/to/wine \
95
+ -- uvx --from git+https://github.com/partprogramming09/metal-gpu-trace metal-gpu-trace
96
+ claude mcp list
97
+ ```
98
+
99
+ `claude mcp list` shows `metal-trace` as connected. `--scope user` makes it available in every project; leave it out
100
+ to add it to the current project only.
101
+
102
+ ### Claude Desktop, Cursor and other JSON clients
103
+
104
+ These apps do not expand `~` or `$HOME`: write absolute paths.
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "metal-trace": {
110
+ "command": "/opt/homebrew/bin/uvx",
111
+ "args": ["--from", "git+https://github.com/partprogramming09/metal-gpu-trace", "metal-gpu-trace"],
112
+ "env": {
113
+ "METAL_TRACE_OUT": "/absolute/path/to/perf-runs",
114
+ "METAL_TRACE_WINE": "/absolute/path/to/wine"
115
+ }
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ | Client | File |
122
+ | --- | --- |
123
+ | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (Settings, Developer, Edit Config); restart the app |
124
+ | Cursor | `~/.cursor/mcp.json` |
125
+ | VS Code | `.vscode/mcp.json` in the project; the top key is `servers` instead of `mcpServers`, and the server adds `"type": "stdio"` |
126
+
127
+ ### Codex CLI
128
+
129
+ In `~/.codex/config.toml`:
130
+
131
+ ```toml
132
+ [mcp_servers.metal-trace]
133
+ command = "/opt/homebrew/bin/uvx"
134
+ args = ["--from", "git+https://github.com/partprogramming09/metal-gpu-trace", "metal-gpu-trace"]
135
+ env = { METAL_TRACE_OUT = "/absolute/path/to/perf-runs", METAL_TRACE_WINE = "/absolute/path/to/wine" }
136
+ ```
137
+
138
+ ### Tools
139
+
140
+ | Tool | Reads or records |
141
+ | --- | --- |
142
+ | `perf_record` | Records a program and returns its summary; when it ends it stops every program of its Wine prefix, or every process named like a native program |
143
+ | `perf_attach` | Records a program that is already running, by pid or name, and leaves it running |
144
+ | `perf_runs` | Lists recordings, newest first, one page at a time, and whether each kept its `.trace` |
145
+ | `perf_compare` | Compares two recordings; flags those under 100 frames |
146
+ | `perf_diagnose` | Says whether a recording splits its GPU work into too many short encoders, as translation layers do, with encoders and command buffers per frame, the labels with the most GPU time and the GPU's idle time |
147
+ | `trace_tables` | Lists the tables of a kept `.trace`, named by its run or its path |
148
+ | `trace_query` | One page of rows of one table, optionally one process |
149
+ | `capture_summary` | Summarizes a `.gputrace`; lists its 100 most frequent calls out of `calls_total` |
150
+ | `capture_records` | One page of a `.gputrace`'s records |
151
+ | `gpu_family` | Apple's facts for a family or chip (`Apple7`, `M1 Pro`, `M3 Max`) |
152
+
153
+ The same facts are MCP resources: `metal-trace://gpu-families` (all) and `metal-trace://gpu-families/apple7` to
154
+ `apple10`. They restate Apple's [Metal Feature Set Tables](https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf)
155
+ of 21 May 2026, [`MTLGPUFamily`](https://developer.apple.com/documentation/metal/mtlgpufamily) and the
156
+ [M3 tech talk](https://developer.apple.com/videos/play/tech-talks/111375/); check a device with `supportsFamily`.
157
+
158
+ How it follows the [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18) and
159
+ [Claude Code's limits](https://code.claude.com/docs/en/mcp):
160
+
161
+ - stdout carries only MCP messages; each tool call logs one line to stderr (`METAL_TRACE_LOG=off` silences it), with
162
+ no arguments or paths.
163
+ - Lists come in pages (`offset`, `limit`, default 50, at most 500) with `total` and `next_offset`; a result over
164
+ 40 000 characters is refused with a hint to ask for a smaller page.
165
+ - Files are read only inside `METAL_TRACE_OUT` and the directories in `METAL_TRACE_READ` (separated by `:`).
166
+ - Failures come back as `isError` results that say what to do, without stack traces or local paths. Summaries cut
167
+ absolute paths to file names, so they can be shared.
168
+ - Every `xctrace` call has a timeout.
169
+
170
+ ## Credits
171
+
172
+ - The record layout of a `.gputrace` command stream follows [tmc/gputrace](https://github.com/tmc/gputrace) (MIT); no
173
+ code is copied. The call ids were matched against Xcode's command list on macOS 27.0.1.
174
+ - GPU family facts: Apple's documents linked above, restated, not quoted.
@@ -0,0 +1,10 @@
1
+ metal_gpu_trace/data/apple-gpu-families.json,sha256=uxkfgIb0byeeAxScdTh1CM6zKSxdV11eN8kiTVassAU,3984
2
+ metal_gpu_trace/gputrace_read.py,sha256=NNJ-0aNCwhMwd4anUG3AyfqV_vZHbUoVvRPnKVqX_54,4781
3
+ metal_gpu_trace/metal_trace_mcp.py,sha256=YxNpEVoeJ_cnxia4Y6yoeCsBnOX_QtZBPk-V8ahGJ-g,23689
4
+ metal_gpu_trace/perf_record.sh,sha256=tpApDBuEG2D5S63NQKzk-YzCYPv5_aAUxzbH1ryhKqE,3651
5
+ metal_gpu_trace/perf_report.py,sha256=-2KLsNnxDaA-4zrvurTFb9fR4mDq5ZnL4_dlBhQ5oiY,14044
6
+ metal_gpu_trace-0.4.0.dist-info/METADATA,sha256=7s9R2-THLUfRTMS8_sMNbiBYE2Ncg_K3TOS3Svz-40Y,9443
7
+ metal_gpu_trace-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ metal_gpu_trace-0.4.0.dist-info/entry_points.txt,sha256=VoBr5DCK_qnj09z035IA6MNOgODMjX3o1Z7Qssj4qCA,73
9
+ metal_gpu_trace-0.4.0.dist-info/licenses/LICENSE,sha256=I-cLehXzQocq5DYArHNzaRh_8VFB7oQCegREwaWhF-c,1078
10
+ metal_gpu_trace-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ metal-gpu-trace = metal_gpu_trace.metal_trace_mcp:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hernan Flórez Burgos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.