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.
- metal_gpu_trace/data/apple-gpu-families.json +63 -0
- metal_gpu_trace/gputrace_read.py +108 -0
- metal_gpu_trace/metal_trace_mcp.py +422 -0
- metal_gpu_trace/perf_record.sh +71 -0
- metal_gpu_trace/perf_report.py +293 -0
- metal_gpu_trace-0.4.0.dist-info/METADATA +174 -0
- metal_gpu_trace-0.4.0.dist-info/RECORD +10 -0
- metal_gpu_trace-0.4.0.dist-info/WHEEL +4 -0
- metal_gpu_trace-0.4.0.dist-info/entry_points.txt +2 -0
- metal_gpu_trace-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
[](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,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.
|