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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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
|