inferencekit-cli 0.1.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.
Files changed (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
@@ -0,0 +1,518 @@
1
+ """Where decode time goes, from an Nsight Systems sqlite export.
2
+
3
+ Tables used (all optional except the kernel table):
4
+ CUPTI_ACTIVITY_KIND_KERNEL one row per kernel execution on the GPU (start/end in ns)
5
+ CUPTI_ACTIVITY_KIND_MEMCPY copies; CUPTI_ACTIVITY_KIND_MEMSET memsets
6
+ CUPTI_ACTIVITY_KIND_RUNTIME CUDA API calls on the CPU (cudaLaunchKernel, cudaGraphLaunch...)
7
+ NVTX_EVENTS annotation ranges pushed by the application
8
+ StringIds id -> text for kernel / API / NVTX names
9
+
10
+ Every GPU activity has a ``correlationId`` that matches the CPU API call that
11
+ launched it. Correlation ids are unique only *within one process*, so every join here
12
+ is on (process, correlation id): ``globalPid`` on the GPU side, and on the CPU side
13
+ ``globalTid`` with its low 24 thread bits cleared (the same encoding nsys's own reports
14
+ use). With ``--cuda-graph-trace=node`` the kernels inside a CUDA graph carry the
15
+ correlation id of their ``cudaGraphLaunch``; that is how a graph replay is reassembled
16
+ from individual kernels.
17
+
18
+ What comes out:
19
+ * GPU busy vs idle over the trace window (union of all GPU intervals);
20
+ * a histogram of idle gaps (many 10-100 us gaps = launch overhead; ms gaps = CPU stalls);
21
+ * top kernels and kernel families (gemm/gemv, attention, norm, ...);
22
+ * cudaGraphLaunch count and the CPU time each call takes;
23
+ * per decode step: span (first activity start to last activity end), busy, idle inside
24
+ the step, the gap to the next step, and per family the critical-path time and share.
25
+ The family shares plus the idle share add up to 100% of the step.
26
+ * the names (never the values) of the environment variables the trace file stores.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import bisect
32
+ import collections
33
+ import re
34
+ import sqlite3
35
+ from dataclasses import dataclass
36
+
37
+ from inferencekit.stats import quantile
38
+
39
+ NS_PER_US = 1e3
40
+ NS_PER_MS = 1e6
41
+
42
+ # Ordered: the first match wins. Norms go first because fused norm kernels can carry library
43
+ # names that look like attention ("flashinfer...fused_add_rmsnorm"). Attention is tested before
44
+ # gemm because fused attention kernels are often built from GEMM templates ("gemm", "cutlass").
45
+ FAMILY_RULES = [
46
+ ("norm", r"rms_?norm|layer_?norm"),
47
+ ("attention", r"attn|attention|flash|fmha|mha|paged|flashinfer|xformers|mla_"),
48
+ ("gemm/gemv", r"gemm|gemv|matmul|mul_mat|mat_?vec|mmvq|mmq|cutlass|cublas|xmma|nvjet|wgmma|splitk|"
49
+ r"sm\d+_|_tn_|_nt_|_nn_|_tt_|dot"),
50
+ # Activation quantization before a quantized matmul (e.g. llama.cpp quantize_q8_1, FP8 per-token
51
+ # activation scaling). Its own family because it is pure overhead around the GEMV.
52
+ ("quantize", r"quantize|quant_|_quant|scaled_fp8|fp8_quant|per_token_group"),
53
+ ("norm", r"rms_?norm|layer_?norm|norm_kernel|_norm"),
54
+ ("rope", r"rope|rotary|pos_enc"),
55
+ ("sampling", r"argmax|topk|top_k|top_p|topp|sampl|multinomial|softmax|logsumexp|gumbel"),
56
+ ("elementwise", r"elementwise|act_and_mul|silu|gelu|swiglu|copy|fill|cast|convert|scatter|gather|index|"
57
+ r"reduce|cat_|_cat|add|mul"),
58
+ ]
59
+ _FAMILY_RX = [(name, re.compile(rx, re.IGNORECASE)) for name, rx in FAMILY_RULES]
60
+ GAP_BUCKETS = [("<10us", 0, 10e3), ("10-100us", 10e3, 100e3), ("0.1-1ms", 100e3, 1e6), ("1-10ms", 1e6, 10e6),
61
+ (">=10ms", 10e6, float("inf"))]
62
+ GRAPH_LAUNCH_RX = re.compile(r"^cu(da)?GraphLaunch")
63
+ DEFAULT_STEP_REGEX = r"^(step|decode)\b"
64
+ # Two decode steps further apart than this are in different requests: the pause is request
65
+ # setup or host work, not part of a step, and it is left out of the step-gap statistics.
66
+ BETWEEN_REQUESTS_NS = 20 * NS_PER_MS
67
+
68
+
69
+ def family(name: str) -> str:
70
+ for fam, rx in _FAMILY_RX:
71
+ if rx.search(name):
72
+ return fam
73
+ return "other"
74
+
75
+
76
+ @dataclass
77
+ class Activity:
78
+ start: int
79
+ end: int
80
+ kind: str # kernel | memcpy | memset
81
+ name: str
82
+ family: str
83
+ corr: int | None
84
+ stream: int | None
85
+ device: int | None
86
+ pid: int | None = None # globalPid of the launching process (None in exports without that column)
87
+
88
+
89
+ @dataclass
90
+ class ApiCall:
91
+ start: int
92
+ end: int
93
+ tid: int
94
+ corr: int
95
+ name: str
96
+ pid: int | None = None # the launching thread's process, from its globalTid
97
+
98
+
99
+ def pid_of_tid(global_tid: int | None) -> int | None:
100
+ """nsys packs process and thread into one 64-bit globalTid; the low 24 bits are the thread.
101
+
102
+ Clearing them gives the process's globalPid, the value the GPU activity tables carry.
103
+ """
104
+ return None if global_tid is None else global_tid >> 24 << 24
105
+
106
+
107
+ # --------------------------------------------------------------------------- interval helpers
108
+
109
+ def merge(intervals) -> list[list[int]]:
110
+ """Sorted, non-overlapping [start, end] list covering the same time as the input."""
111
+ out: list[list[int]] = []
112
+ for s, e in sorted(intervals):
113
+ if out and s <= out[-1][1]:
114
+ out[-1][1] = max(out[-1][1], e)
115
+ else:
116
+ out.append([s, e])
117
+ return out
118
+
119
+
120
+ def covered(merged: list[list[int]], starts: list[int], a: int, b: int) -> int:
121
+ """How much of [a, b] is covered by the merged intervals (``starts`` = their start times)."""
122
+ if b <= a:
123
+ return 0
124
+ i = max(0, bisect.bisect_right(starts, a) - 1)
125
+ total = 0
126
+ while i < len(merged) and merged[i][0] < b:
127
+ lo, hi = max(merged[i][0], a), min(merged[i][1], b)
128
+ if hi > lo:
129
+ total += hi - lo
130
+ i += 1
131
+ return total
132
+
133
+
134
+ def dist(values: list[float], digits: int = 3) -> dict:
135
+ """n / p50 / p95 / max / total of a list of durations."""
136
+ if not values:
137
+ return {"n": 0}
138
+ r = lambda x: round(x, digits) # noqa: E731
139
+ return {"n": len(values), "p50": r(quantile(values, 50)), "p95": r(quantile(values, 95)), "max": r(max(values)),
140
+ "total": r(sum(values))}
141
+
142
+
143
+ # --------------------------------------------------------------------------- loading
144
+
145
+ def _tables(con) -> set[str]:
146
+ return {r[0] for r in con.execute("select name from sqlite_master where type in ('table', 'view')")}
147
+
148
+
149
+ def _columns(con, table) -> set[str]:
150
+ return {r[1] for r in con.execute(f"pragma table_info({table})")}
151
+
152
+
153
+ def load(con) -> dict:
154
+ tables = _tables(con)
155
+ strings = dict(con.execute("select id, value from StringIds")) if "StringIds" in tables else {}
156
+ name_of = lambda v: strings.get(v, str(v)) if isinstance(v, int) else (v or "?") # noqa: E731
157
+
158
+ def optional(table, column): # a column that older nsys exports may lack
159
+ return column if column in _columns(con, table) else "NULL"
160
+
161
+ acts: list[Activity] = []
162
+ if "CUPTI_ACTIVITY_KIND_KERNEL" in tables:
163
+ t = "CUPTI_ACTIVITY_KIND_KERNEL"
164
+ cols = _columns(con, t)
165
+ short = "shortName" if "shortName" in cols else "demangledName"
166
+ full = "demangledName" if "demangledName" in cols else short
167
+ for s, e, st, c, d, pid, sn, fn in con.execute(
168
+ f"select start, end, streamId, correlationId, {optional(t, 'deviceId')}, {optional(t, 'globalPid')}, "
169
+ f"{short}, {full} from {t}"):
170
+ label, long = name_of(sn), name_of(fn)
171
+ acts.append(Activity(s, e, "kernel", label, family(long + " " + label), c, st, d, pid))
172
+ if "CUPTI_ACTIVITY_KIND_MEMCPY" in tables:
173
+ t = "CUPTI_ACTIVITY_KIND_MEMCPY"
174
+ for s, e, st, c, d, pid, nbytes in con.execute(
175
+ f"select start, end, streamId, correlationId, {optional(t, 'deviceId')}, {optional(t, 'globalPid')}, "
176
+ f"bytes from {t}"):
177
+ acts.append(Activity(s, e, "memcpy", f"memcpy {nbytes} B", "memcpy", c, st, d, pid))
178
+ if "CUPTI_ACTIVITY_KIND_MEMSET" in tables:
179
+ t = "CUPTI_ACTIVITY_KIND_MEMSET"
180
+ for s, e, st, c, d, pid in con.execute(
181
+ f"select start, end, streamId, correlationId, {optional(t, 'deviceId')}, {optional(t, 'globalPid')} "
182
+ f"from {t}"):
183
+ acts.append(Activity(s, e, "memset", "memset", "memset", c, st, d, pid))
184
+
185
+ calls: list[ApiCall] = []
186
+ if "CUPTI_ACTIVITY_KIND_RUNTIME" in tables:
187
+ for s, e, tid, c, nid in con.execute(
188
+ "select start, end, globalTid, correlationId, nameId from CUPTI_ACTIVITY_KIND_RUNTIME"):
189
+ # API names carry version suffixes (cudaGraphLaunch_v10000): strip them.
190
+ calls.append(ApiCall(s, e, tid, c, re.sub(r"_v\d+$", "", name_of(nid)), pid_of_tid(tid)))
191
+
192
+ ranges = []
193
+ if "NVTX_EVENTS" in tables:
194
+ cols = _columns(con, "NVTX_EVENTS")
195
+ text_id = "textId" if "textId" in cols else "NULL"
196
+ # eventType 59 = push/pop range, 60 = start/end range
197
+ for s, e, text, tid_, tid in con.execute(
198
+ f"select start, end, text, {text_id}, globalTid from NVTX_EVENTS "
199
+ f"where eventType in (59, 60) and end is not null and end > start"):
200
+ label = text if text is not None else strings.get(tid_, "")
201
+ ranges.append((s, e, label, tid))
202
+
203
+ gpu_name = None
204
+ if "TARGET_INFO_GPU" in tables and "name" in _columns(con, "TARGET_INFO_GPU"):
205
+ row = con.execute("select name from TARGET_INFO_GPU limit 1").fetchone()
206
+ gpu_name = row[0] if row else None
207
+ return {"activities": acts, "calls": calls, "ranges": ranges, "gpu_name": gpu_name,
208
+ "capture_env": capture_env(con, tables)}
209
+
210
+
211
+ # --------------------------------------------------------------------------- what the file stores about the launch
212
+
213
+ # META_DATA_CAPTURE rows in which nsys stores how the program was launched.
214
+ LAUNCH_ROWS = ("PROCESS_%:COMMAND", "PROCESS_%:ARGUMENT_%", "PROCESS_%:WORKING_DIR", "PROCESS_%:USER_NAME")
215
+ ENV_ROW = "PROCESS_%:ENVIRONMENT_VARIABLE" # one row per variable, value "NAME=value"
216
+ HOST_ROWS = ("Hostname", "SystemUID", "NicInfo") # in TARGET_INFO_SYSTEM_ENV
217
+ REDACTED = "<redacted>"
218
+
219
+
220
+ def capture_env(con, tables=None) -> dict:
221
+ """Names (never values) of the variables the trace stores, and whether the launch details are still there."""
222
+ tables = tables if tables is not None else _tables(con)
223
+ if "META_DATA_CAPTURE" not in tables:
224
+ return {"env_names": [], "values_present": False}
225
+ rows = [v or "" for (v,) in con.execute("select value from META_DATA_CAPTURE where name like ?", (ENV_ROW,))]
226
+ names = sorted({v.split("=", 1)[0] for v in rows})
227
+ present = any(v.split("=", 1)[1:] not in ([], [REDACTED]) for v in rows)
228
+ args = con.execute("select count(*) from META_DATA_CAPTURE where (name like ? or name like ?) and value <> ?",
229
+ ("PROCESS_%:ARGUMENT_%", "PROCESS_%:COMMAND", REDACTED)).fetchone()[0]
230
+ return {"env_names": names, "values_present": present or args > 0}
231
+
232
+
233
+ def scrub_metadata(sqlite_path: str) -> dict:
234
+ """Blank what nsys stores about the launch and the host, in an exported sqlite file (in place).
235
+
236
+ nsys records the profiled program's command line, working directory, user name and
237
+ ``NAME=value`` of every environment variable it received, plus the host name and
238
+ every process running on the host. None of it is needed for the timeline analysis.
239
+ Variable names are kept (useful to check the allowlist); values become <redacted>.
240
+ The .nsys-rep this file was exported from still holds all of it and cannot be edited.
241
+ """
242
+ con = sqlite3.connect(sqlite_path)
243
+ try:
244
+ tables = _tables(con)
245
+ before = capture_env(con, tables)
246
+ blanked = 0
247
+ if "META_DATA_CAPTURE" in tables:
248
+ where = " or ".join("name like ?" for _ in LAUNCH_ROWS)
249
+ blanked += con.execute(f"update META_DATA_CAPTURE set value = ? where {where}",
250
+ (REDACTED, *LAUNCH_ROWS)).rowcount
251
+ # "NAME=value" -> "NAME=<redacted>"
252
+ blanked += con.execute("update META_DATA_CAPTURE set value = substr(value, 1, instr(value, '=')) || ? "
253
+ "where name like ? and instr(value, '=') > 0", (REDACTED, ENV_ROW)).rowcount
254
+ if "TARGET_INFO_SYSTEM_ENV" in tables:
255
+ marks = ", ".join("?" for _ in HOST_ROWS)
256
+ blanked += con.execute(f"update TARGET_INFO_SYSTEM_ENV set value = ? where name in ({marks})",
257
+ (REDACTED, *HOST_ROWS)).rowcount
258
+ dropped = 0
259
+ kernels = "CUPTI_ACTIVITY_KIND_KERNEL"
260
+ if "PROCESSES" in tables and kernels in tables and "globalPid" in _columns(con, kernels):
261
+ # keep only the processes that ran GPU work
262
+ dropped = con.execute("delete from PROCESSES where globalPid not in "
263
+ "(select distinct globalPid from CUPTI_ACTIVITY_KIND_KERNEL)").rowcount
264
+ con.commit()
265
+ finally:
266
+ con.close()
267
+ return {"env_names": before["env_names"], "rows_blanked": blanked, "other_processes_dropped": dropped}
268
+
269
+
270
+ # --------------------------------------------------------------------------- step segmentation
271
+
272
+ # Each segmenter returns (groups, method, replay_ns) or None when it does not apply.
273
+ # groups {step key: [activities of that step]}
274
+ # method how steps were found ("nvtx", "graph_launch", "gap>20us")
275
+ # replay_ns {step key: span of the graph replay alone} for graph segmentation, else None
276
+
277
+ def _segment_by_nvtx(acts, calls, ranges, step_rx) -> tuple[dict, str, None] | None:
278
+ """Group activities by the outermost NVTX step range open on the launching thread at launch time."""
279
+ steps = [(s, e, tid) for s, e, label, tid in ranges if step_rx.search(label or "")]
280
+ if not steps:
281
+ return None
282
+ by_thread: dict[int, list[tuple[int, int]]] = collections.defaultdict(list)
283
+ for s, e, tid in sorted(steps):
284
+ outer = by_thread[tid]
285
+ if outer and s < outer[-1][1]: # nested inside the previous step range: keep the outer one
286
+ continue
287
+ outer.append((s, e))
288
+ call_of = {(c.pid, c.corr): c for c in calls} # correlation ids repeat across processes
289
+ groups: dict[tuple, list[Activity]] = collections.defaultdict(list)
290
+ for a in acts:
291
+ c = call_of.get((a.pid, a.corr))
292
+ if c is None:
293
+ continue
294
+ rs = by_thread.get(c.tid, [])
295
+ i = bisect.bisect_right([r[0] for r in rs], c.start) - 1
296
+ if i >= 0 and c.start <= rs[i][1]:
297
+ groups[(c.tid, rs[i][0])].append(a)
298
+ return (groups, "nvtx", None) if groups else None
299
+
300
+
301
+ def _segment_by_graph(acts, calls) -> tuple[dict, str, dict] | None:
302
+ """One step per cudaGraphLaunch: from the first kernel of replay i to the first kernel of replay i+1.
303
+
304
+ Graph-node kernels carry the correlation id of their cudaGraphLaunch, which finds each
305
+ replay. But a decode step is more than the graph: engines also run eager kernels
306
+ between replays (vLLM: the lm_head GEMM and sampling, about 0.9 ms of a 6.7 ms token).
307
+ So every activity that starts after replay i began and before replay i+1 begins is
308
+ part of step i, unless the two replays are BETWEEN_REQUESTS_NS or more apart (then
309
+ the work in between belongs to request setup, not to decoding). Eager work after the
310
+ last replay has no next replay to bound it and is left out.
311
+ """
312
+ launch_start = {(c.pid, c.corr): c.start for c in calls if GRAPH_LAUNCH_RX.match(c.name)}
313
+ if not launch_start:
314
+ return None
315
+ groups: dict[tuple, list[Activity]] = collections.defaultdict(list)
316
+ eager = []
317
+ for a in acts:
318
+ key = (a.pid, a.corr)
319
+ if key in launch_start:
320
+ groups[(launch_start[key],) + key].append(a)
321
+ else:
322
+ eager.append(a)
323
+ if not groups:
324
+ return None
325
+ order = sorted(groups, key=lambda k: min(a.start for a in groups[k]))
326
+ firsts = [min(a.start for a in groups[k]) for k in order]
327
+ replay_ns = {k: max(a.end for a in groups[k]) - first for k, first in zip(order, firsts)}
328
+ for a in eager:
329
+ i = bisect.bisect_right(firsts, a.start) - 1 # the last replay that began at or before this activity
330
+ if 0 <= i < len(order) - 1 and firsts[i + 1] - firsts[i] < BETWEEN_REQUESTS_NS:
331
+ groups[order[i]].append(a)
332
+ return groups, "graph_launch", replay_ns
333
+
334
+
335
+ def _segment_by_gaps(acts, gap_us) -> tuple[dict, str, None]:
336
+ """Fallback: a host-side pause longer than gap_us between kernels starts a new step."""
337
+ groups: dict[tuple, list[Activity]] = {}
338
+ current: list[Activity] = []
339
+ running_end = 0
340
+ for a in sorted(acts, key=lambda x: x.start):
341
+ if current and a.start - running_end > gap_us * NS_PER_US:
342
+ groups[(current[0].start,)] = current
343
+ current = []
344
+ if not current:
345
+ running_end = a.end
346
+ current.append(a)
347
+ running_end = max(running_end, a.end)
348
+ if current:
349
+ groups[(current[0].start,)] = current
350
+ return groups, f"gap>{gap_us:g}us", None
351
+
352
+
353
+ def step_stats(groups: dict, merged_all, starts_all, replay_ns: dict | None = None) -> dict:
354
+ """Span / busy / idle per step, gaps between steps, and per-family critical path."""
355
+ ordered = sorted(((sorted(v, key=lambda a: a.start), k) for k, v in groups.items() if v),
356
+ key=lambda sk: sk[0][0].start)
357
+ steps = [s for s, _ in ordered]
358
+ if not steps:
359
+ return {"steps": 0}
360
+ counts = collections.Counter(len(s) for s in steps)
361
+ typical = counts.most_common(1)[0][0]
362
+ # Steps with the most common activity count are the steady-state decode steps
363
+ # (prefill or the first, cold step usually look different); drop the very first one.
364
+ typical_idx = [i for i, s in enumerate(steps) if len(s) == typical]
365
+ steady_idx = typical_idx[1:] or typical_idx
366
+ steady = [steps[i] for i in steady_idx]
367
+ spans, busys, idles, sums, idle_pcts = [], [], [], [], []
368
+ crit = collections.defaultdict(list)
369
+ crit_pct_total = collections.defaultdict(float)
370
+ dur = collections.defaultdict(list)
371
+ per_step_launches = collections.Counter(a.family for a in steady[0])
372
+ for s in steady:
373
+ span = max(a.end for a in s) - s[0].start
374
+ busy = sum(e - b for b, e in merge((a.start, a.end) for a in s))
375
+ spans.append(span / NS_PER_US)
376
+ busys.append(busy / NS_PER_US)
377
+ idles.append((span - busy) / NS_PER_US)
378
+ idle_pcts.append(100 * (span - busy) / span if span else 0.0)
379
+ sums.append(sum(a.end - a.start for a in s) / NS_PER_US)
380
+ # Critical path: walk the step in start order and keep the running end time of
381
+ # everything seen so far. Each activity is charged only for how far it pushes that
382
+ # end time; a stretch with nothing running before it is idle, not the activity's
383
+ # time (idle is reported on its own as idle_in_step_us). So the family times plus
384
+ # idle add up exactly to the step span. With overlapping kernels (programmatic
385
+ # dependent launch, several streams) summed durations exceed the span; this counts
386
+ # each nanosecond of the step once.
387
+ step_crit, step_dur = collections.defaultdict(float), collections.defaultdict(float)
388
+ running_end = s[0].start
389
+ for a in s:
390
+ running_end = max(running_end, a.start) # skip [running_end, a.start): nothing ran, it is idle
391
+ new_end = max(running_end, a.end)
392
+ step_crit[a.family] += (new_end - running_end) / NS_PER_US
393
+ running_end = new_end
394
+ step_dur[a.family] += (a.end - a.start) / NS_PER_US
395
+ for f, v in step_crit.items():
396
+ crit[f].append(v)
397
+ crit_pct_total[f] += 100 * v * NS_PER_US / span if span else 0.0
398
+ for f, v in step_dur.items():
399
+ dur[f].append(v)
400
+ gaps, fully_idle = [], []
401
+ for prev, nxt in zip(steps, steps[1:]):
402
+ end_prev = max(a.end for a in prev)
403
+ gap = nxt[0].start - end_prev
404
+ if gap < BETWEEN_REQUESTS_NS: # longer pauses are between requests, not between decode steps
405
+ gaps.append(gap / NS_PER_US)
406
+ idle = gap - covered(merged_all, starts_all, end_prev, nxt[0].start) if gap > 0 else 0
407
+ fully_idle.append(idle / NS_PER_US)
408
+ # critical_pct_of_step is the mean over steady steps of (family critical time / step span).
409
+ # Means, unlike medians, add up: the families' shares plus idle_pct_of_step are 100%.
410
+ families = {}
411
+ for f in sorted(crit, key=lambda f: -quantile(crit[f], 50)):
412
+ families[f] = {"launches_per_step": per_step_launches.get(f, 0),
413
+ "critical_us_p50": round(quantile(crit[f], 50), 2),
414
+ "critical_pct_of_step": round(crit_pct_total[f] / len(steady), 2),
415
+ "duration_us_p50": round(quantile(dur[f], 50), 2)}
416
+ out = {"steps": len(steps), "steady_steps": len(steady), "kernels_per_step": typical,
417
+ "span_us": dist(spans, 2), "busy_us": dist(busys, 2), "idle_in_step_us": dist(idles, 2),
418
+ "idle_pct_of_step": round(sum(idle_pcts) / len(idle_pcts), 2),
419
+ "kernel_sum_us": dist(sums, 2), "gap_to_next_step_us": dist(gaps, 2),
420
+ "gap_fully_idle_us": dist(fully_idle, 2), "families": families}
421
+ if replay_ns: # graph segmentation: the replay alone, without the eager work after it
422
+ out["graph_replay_us"] = dist([replay_ns[ordered[i][1]] / NS_PER_US for i in steady_idx], 2)
423
+ return out
424
+
425
+
426
+ # --------------------------------------------------------------------------- top level
427
+
428
+ def analyze(sqlite_path: str, step_regex: str = DEFAULT_STEP_REGEX, gap_us: float = 20.0, top: int = 25) -> dict:
429
+ con = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
430
+ try:
431
+ d = load(con)
432
+ finally:
433
+ con.close()
434
+ acts: list[Activity] = d["activities"]
435
+ if not acts:
436
+ return {"error": "no GPU activity in this trace (was CUDA tracing on?)", "gpu_name": d["gpu_name"]}
437
+ # Several GPUs: analyse the one with the most kernel time.
438
+ per_dev = collections.Counter()
439
+ for a in acts:
440
+ per_dev[a.device] += a.end - a.start
441
+ device = per_dev.most_common(1)[0][0]
442
+ notes = []
443
+ if len(per_dev) > 1:
444
+ notes.append(f"{len(per_dev)} devices in trace; analysed device {device} (most GPU time)")
445
+ acts = [a for a in acts if a.device == device]
446
+ # Keep only the API calls of the processes that ran work on this device. Correlation ids
447
+ # are per process, so another process's calls would otherwise be joined to these kernels.
448
+ calls: list[ApiCall] = d["calls"]
449
+ pids = {a.pid for a in acts}
450
+ if None in pids: # an export without globalPid: fall back to joining on correlation id alone
451
+ for x in [*acts, *calls]:
452
+ x.pid = None
453
+ notes.append("trace has no globalPid column: kernels joined to API calls by correlation id alone "
454
+ "(wrong if several processes launch work)")
455
+ else:
456
+ calls = [c for c in calls if c.pid in pids]
457
+
458
+ merged = merge((a.start, a.end) for a in acts)
459
+ starts = [m[0] for m in merged]
460
+ t0, t1 = merged[0][0], merged[-1][1]
461
+ window = t1 - t0
462
+ busy = sum(e - s for s, e in merged)
463
+ gaps = [merged[i + 1][0] - merged[i][1] for i in range(len(merged) - 1)]
464
+ histogram = {label: {"count": sum(1 for g in gaps if lo <= g < hi),
465
+ "idle_ms": round(sum(g for g in gaps if lo <= g < hi) / NS_PER_MS, 3)}
466
+ for label, lo, hi in GAP_BUCKETS}
467
+
468
+ kernels = [a for a in acts if a.kind == "kernel"]
469
+ kernel_ns = sum(a.end - a.start for a in kernels) or 1
470
+ by_name: dict[str, list] = collections.defaultdict(lambda: [0, 0, ""])
471
+ by_family: dict[str, list] = collections.defaultdict(lambda: [0, 0])
472
+ for a in kernels:
473
+ x = by_name[a.name]
474
+ x[0] += 1
475
+ x[1] += a.end - a.start
476
+ x[2] = a.family
477
+ f = by_family[a.family]
478
+ f[0] += 1
479
+ f[1] += a.end - a.start
480
+ top_kernels = [{"name": n[:160], "family": v[2], "count": v[0], "total_ms": round(v[1] / NS_PER_MS, 3),
481
+ "mean_us": round(v[1] / v[0] / NS_PER_US, 2), "pct_kernel_time": round(100 * v[1] / kernel_ns, 2)}
482
+ for n, v in sorted(by_name.items(), key=lambda kv: -kv[1][1])[:top]]
483
+ families = {f: {"count": v[0], "total_ms": round(v[1] / NS_PER_MS, 3),
484
+ "pct_kernel_time": round(100 * v[1] / kernel_ns, 2)}
485
+ for f, v in sorted(by_family.items(), key=lambda kv: -kv[1][1])}
486
+
487
+ launches = [c for c in calls if GRAPH_LAUNCH_RX.match(c.name)]
488
+ graph = {"launches": len(launches), "host_us": dist([(c.end - c.start) / NS_PER_US for c in launches], 2),
489
+ "host_ms_total": round(sum(c.end - c.start for c in launches) / NS_PER_MS, 3)}
490
+
491
+ step_rx = re.compile(step_regex)
492
+ seg = (_segment_by_nvtx(acts, calls, d["ranges"], step_rx) or _segment_by_graph(acts, calls)
493
+ or _segment_by_gaps(acts, gap_us))
494
+ groups, method, replay_ns = seg
495
+ steps = step_stats(groups, merged, starts, replay_ns)
496
+ steps["segmentation"] = method
497
+ if method.startswith("gap"):
498
+ notes.append("no NVTX step ranges and no graph launches: steps split at host gaps (approximate)")
499
+ if d["capture_env"]["values_present"]:
500
+ notes.append("this trace file stores the profiled command line and NAME=value of every environment "
501
+ f"variable passed ({', '.join(d['capture_env']['env_names']) or 'none'}): treat it as sensitive")
502
+
503
+ return {
504
+ "gpu_name": d["gpu_name"],
505
+ "device": device,
506
+ "window_ms": round(window / NS_PER_MS, 3),
507
+ "gpu_busy_ms": round(busy / NS_PER_MS, 3),
508
+ "gpu_idle_ms": round((window - busy) / NS_PER_MS, 3),
509
+ "gpu_busy_pct": round(100 * busy / window, 2) if window else None,
510
+ "activity_counts": dict(collections.Counter(a.kind for a in acts)),
511
+ "idle_gap_histogram": histogram,
512
+ "families": families,
513
+ "top_kernels": top_kernels,
514
+ "graph_launch": graph,
515
+ "steps": steps,
516
+ "capture_env_names": d["capture_env"]["env_names"],
517
+ "notes": notes,
518
+ }
@@ -0,0 +1,104 @@
1
+ """Canonical JSON (RFC 8785 style) and digests.
2
+
3
+ Why: an evidence id is the sha256 of the record body. For the id to be stable, the
4
+ same data must always serialise to the same bytes, on any machine and any Python
5
+ version. Plain ``json.dumps`` is close but not exact (it writes ``1e-05`` where
6
+ RFC 8785 writes ``0.00001``, and ``1.0`` where RFC 8785 writes ``1``), so the
7
+ number formatting below follows RFC 8785 / ECMAScript ``Number.prototype.toString``.
8
+
9
+ Rules:
10
+ * object keys sorted by their UTF-16 code units, no whitespace anywhere;
11
+ * strings: UTF-8, only the mandatory escapes (``"`` ``\\`` and control chars);
12
+ * numbers: shortest round-trip digits, ECMAScript layout; NaN/Infinity rejected.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import math
20
+ from typing import Any
21
+
22
+
23
+ def _format_number(x: float) -> str:
24
+ """ECMAScript Number-to-string, which RFC 8785 adopts for JSON numbers."""
25
+ if math.isnan(x) or math.isinf(x):
26
+ raise ValueError("NaN and Infinity cannot be represented in canonical JSON")
27
+ if x == 0:
28
+ return "0" # also maps -0.0 to "0", as ECMAScript does
29
+ sign = "-" if x < 0 else ""
30
+ # repr() gives the shortest digit string that round-trips (same digits ECMAScript picks).
31
+ mantissa, _, exp_text = repr(abs(x)).partition("e")
32
+ int_part, _, frac_part = mantissa.partition(".")
33
+ digits = (int_part + frac_part).lstrip("0")
34
+ exp10 = (int(exp_text) if exp_text else 0) - len(frac_part) # value = int(digits) * 10**exp10
35
+ stripped = digits.rstrip("0")
36
+ exp10 += len(digits) - len(stripped)
37
+ digits = stripped
38
+ k = len(digits) # number of significant digits
39
+ n = exp10 + k # value = 0.d1d2..dk * 10**n
40
+ if k <= n <= 21: # integer: digits then zeros
41
+ return sign + digits + "0" * (n - k)
42
+ if 0 < n <= 21: # decimal point inside the digits
43
+ return sign + digits[:n] + "." + digits[n:]
44
+ if -6 < n <= 0: # small: 0.000ddd
45
+ return sign + "0." + "0" * (-n) + digits
46
+ exponent = n - 1 # scientific: d.ddde+N
47
+ mant = digits[0] + ("." + digits[1:] if k > 1 else "")
48
+ return f"{sign}{mant}e{'+' if exponent > 0 else '-'}{abs(exponent)}"
49
+
50
+
51
+ def _utf16_key(s: str) -> bytes:
52
+ # RFC 8785 sorts by UTF-16 code units; big-endian UTF-16 bytes sort the same way.
53
+ return s.encode("utf-16-be")
54
+
55
+
56
+ def canonical_json(value: Any) -> str:
57
+ """Serialise ``value`` to canonical JSON text."""
58
+ if value is None:
59
+ return "null"
60
+ if value is True:
61
+ return "true"
62
+ if value is False:
63
+ return "false"
64
+ if isinstance(value, int):
65
+ return str(value)
66
+ if isinstance(value, float):
67
+ return _format_number(value)
68
+ if isinstance(value, str):
69
+ # ensure_ascii=False keeps non-ASCII as UTF-8; json escapes control chars as \u00xx.
70
+ return json.dumps(value, ensure_ascii=False)
71
+ if isinstance(value, (list, tuple)):
72
+ return "[" + ",".join(canonical_json(v) for v in value) + "]"
73
+ if isinstance(value, dict):
74
+ for k in value:
75
+ if not isinstance(k, str):
76
+ raise TypeError(f"canonical JSON object keys must be strings, got {type(k).__name__}")
77
+ items = sorted(value.items(), key=lambda kv: _utf16_key(kv[0]))
78
+ return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical_json(v) for k, v in items) + "}"
79
+ raise TypeError(f"not JSON-serialisable: {type(value).__name__}")
80
+
81
+
82
+ def sha256_hex(data: bytes | str) -> str:
83
+ if isinstance(data, str):
84
+ data = data.encode("utf-8")
85
+ return hashlib.sha256(data).hexdigest()
86
+
87
+
88
+ def digest(value: Any) -> str:
89
+ """``sha256:<hex>`` of the canonical JSON of ``value`` (used for argv digests)."""
90
+ return "sha256:" + sha256_hex(canonical_json(value))
91
+
92
+
93
+ def file_sha256(path, chunk: int = 1 << 20) -> tuple[str, int]:
94
+ """(sha256 hex, size in bytes) of a file, read in 1 MiB chunks so large traces fit in memory."""
95
+ h = hashlib.sha256()
96
+ size = 0
97
+ with open(path, "rb") as f:
98
+ while True:
99
+ block = f.read(chunk)
100
+ if not block:
101
+ break
102
+ h.update(block)
103
+ size += len(block)
104
+ return h.hexdigest(), size