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,392 @@
1
+ """roofline: the speed limit for batch-1 decode.
2
+
3
+ Two methods. ``--method headers`` (the default whenever safetensors are available) sums the
4
+ real stored bytes of every tensor from the checkpoint's safetensors headers, grouped by module
5
+ role, with MoE top-k, sliding windows and linear-attention state handled exactly; see
6
+ ``inferencekit.roofline_headers``. ``--method config`` is the original dense estimate from
7
+ config.json, described below.
8
+
9
+ At batch 1, generating one token means reading every weight once (each weight is
10
+ used for a single multiply-add per token, so the GPU spends its time waiting on
11
+ memory, not computing) plus the KV cache of the context so far. So
12
+
13
+ ceiling tok/s = DRAM bandwidth (bytes/s) / bytes read per token
14
+
15
+ No kernel can beat this on a given GPU; a measured tok/s as a percentage of it says
16
+ how much headroom the engine has left. Counted per token, for a dense
17
+ Llama/Qwen-style decoder:
18
+
19
+ * per layer: q, k, v, o projections, gate / up / down MLP projections, the two
20
+ RMSNorm weights (+ q/k norms for Qwen3), optional q/k/v biases;
21
+ * the final norm and the full lm_head (vocab x hidden). A tied lm_head is the
22
+ embedding matrix, still read in full every token to compute the logits;
23
+ * embeddings: only the one row of the current token is read, not the matrix;
24
+ * KV cache: 2 (K and V) x kv_heads x head_dim x bytes x layers x context.
25
+
26
+ Weight formats:
27
+ bf16 2 bytes / weight
28
+ fp8 1 byte / weight (e4m3) + one fp32 scale per 128x128 block = 4/16384 bytes per
29
+ weight (+0.024%, kept for honesty, negligible); norms stay bf16
30
+ q8_0 blocks of 32 int8 + one fp16 scale = 34 bytes / 32 weights = 8.5 bits;
31
+ norms are fp32 in GGUF files
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import math
38
+ from pathlib import Path
39
+
40
+ from inferencekit import evidence as ev
41
+ from inferencekit.registry import Arg, OpError, operation
42
+
43
+ GB = 1e9 # decimal gigabytes: bandwidths are quoted in GB/s = 1e9 bytes/s
44
+
45
+ # bytes per weight of the large matrices, and of small vectors (norms, biases)
46
+ WEIGHT_BYTES = {"bf16": 2.0, "fp16": 2.0, "fp8": 1.0, "q8_0": 34 / 32}
47
+ VECTOR_BYTES = {"bf16": 2.0, "fp16": 2.0, "fp8": 2.0, "q8_0": 4.0}
48
+ DEFAULT_LM_HEAD = {"bf16": "bf16", "fp16": "fp16", "fp8": "bf16", "q8_0": "q8_0"}
49
+ KV_BYTES = {"bf16": 2.0, "fp16": 2.0, "fp8": 1.0, "q8_0": 34 / 32}
50
+ FP8_BLOCK = 128
51
+
52
+
53
+ def matrix_bytes(rows: int, cols: int, dtype: str) -> float:
54
+ """Bytes to read one weight matrix of shape rows x cols in ``dtype``."""
55
+ n = rows * cols
56
+ if dtype == "fp8":
57
+ blocks = math.ceil(rows / FP8_BLOCK) * math.ceil(cols / FP8_BLOCK)
58
+ return n * 1.0 + blocks * 4.0 # e4m3 values + one fp32 scale per 128x128 block
59
+ return n * WEIGHT_BYTES[dtype]
60
+
61
+
62
+ def text_config(config: dict) -> dict:
63
+ """Vision-language configs nest the language model under text_config / llm_config."""
64
+ if "hidden_size" in config:
65
+ return config
66
+ for key in ("text_config", "llm_config", "language_config"):
67
+ if isinstance(config.get(key), dict) and "hidden_size" in config[key]:
68
+ return config[key]
69
+ raise OpError("config has no hidden_size (is this a decoder-only LLM config?)")
70
+
71
+
72
+ def decode_bytes(config: dict, dtype: str, context: int, kv_dtype: str = "bf16",
73
+ lm_head_dtype: str | None = None) -> dict:
74
+ """Bytes read from DRAM to decode one token at batch 1. Pure function: no I/O."""
75
+ if dtype not in WEIGHT_BYTES:
76
+ raise OpError(f"unsupported dtype {dtype}; use one of {sorted(WEIGHT_BYTES)}")
77
+ c = text_config(config)
78
+ for moe_key in ("num_experts", "num_local_experts", "n_routed_experts"):
79
+ if c.get(moe_key):
80
+ raise OpError("mixture-of-experts configs need the real tensor sizes: use --method headers "
81
+ "(the config-only method covers dense models)")
82
+ hidden = int(c["hidden_size"])
83
+ layers = int(c["num_hidden_layers"])
84
+ inter = int(c["intermediate_size"])
85
+ heads = int(c["num_attention_heads"])
86
+ kv_heads = int(c.get("num_key_value_heads") or heads)
87
+ head_dim = int(c.get("head_dim") or hidden // heads)
88
+ vocab = int(c["vocab_size"])
89
+ tied = bool(c.get("tie_word_embeddings", config.get("tie_word_embeddings", False)))
90
+ model_type = str(c.get("model_type") or config.get("model_type") or "")
91
+ qkv_bias = bool(c.get("attention_bias", model_type.startswith("qwen2")))
92
+ qk_norm = model_type.startswith("qwen3")
93
+ lm_dtype = lm_head_dtype or DEFAULT_LM_HEAD[dtype]
94
+ vec = VECTOR_BYTES[dtype]
95
+
96
+ q_out, kv_out = heads * head_dim, kv_heads * head_dim
97
+ per_layer = {
98
+ "q_proj": matrix_bytes(q_out, hidden, dtype),
99
+ "k_proj": matrix_bytes(kv_out, hidden, dtype),
100
+ "v_proj": matrix_bytes(kv_out, hidden, dtype),
101
+ "o_proj": matrix_bytes(hidden, q_out, dtype),
102
+ "gate_proj": matrix_bytes(inter, hidden, dtype),
103
+ "up_proj": matrix_bytes(inter, hidden, dtype),
104
+ "down_proj": matrix_bytes(hidden, inter, dtype),
105
+ "norms": 2 * hidden * vec + (2 * head_dim * vec if qk_norm else 0),
106
+ "qkv_bias": (q_out + 2 * kv_out) * vec if qkv_bias else 0.0,
107
+ }
108
+ layer_total = sum(per_layer.values())
109
+ linear_params = (q_out * hidden + 2 * kv_out * hidden + hidden * q_out + 3 * inter * hidden) * layers
110
+ lm_head = matrix_bytes(vocab, hidden, lm_dtype)
111
+ # One embedding row per token. FP8 checkpoints keep embeddings in bf16; GGUF quantises them.
112
+ embed_dtype = lm_dtype if tied else ("bf16" if dtype == "fp8" else dtype)
113
+ embed_row = hidden * WEIGHT_BYTES[embed_dtype]
114
+ final_norm = hidden * vec
115
+ weights = layer_total * layers + lm_head + embed_row + final_norm
116
+ kv_per_token_of_context = 2 * kv_out * KV_BYTES[kv_dtype] * layers
117
+ kv = kv_per_token_of_context * context
118
+ return {
119
+ "shape": {"hidden": hidden, "layers": layers, "intermediate": inter, "heads": heads, "kv_heads": kv_heads,
120
+ "head_dim": head_dim, "vocab": vocab, "tied_lm_head": tied, "qk_norm": qk_norm,
121
+ "qkv_bias": qkv_bias, "linear_params": linear_params},
122
+ "dtypes": {"weights": dtype, "lm_head": lm_dtype, "kv": kv_dtype},
123
+ "per_layer_bytes": per_layer,
124
+ "layers_bytes": layer_total * layers,
125
+ "lm_head_bytes": lm_head,
126
+ "embedding_row_bytes": embed_row,
127
+ "final_norm_bytes": final_norm,
128
+ "weight_bytes": weights,
129
+ "kv_bytes_per_context_token": kv_per_token_of_context,
130
+ "context": context,
131
+ "kv_bytes": kv,
132
+ "bytes_per_token": weights + kv,
133
+ }
134
+
135
+
136
+ def load_config(model: str, revision: str | None = None) -> tuple[dict, str, str | None]:
137
+ """config.json from a file, a model directory, or the Hugging Face Hub.
138
+ Returns (config, source, Hub commit when known)."""
139
+ from inferencekit import checkpoint
140
+
141
+ p = Path(model)
142
+ if p.is_file():
143
+ return json.loads(p.read_text()), "file", None
144
+ if p.is_dir() and (p / "config.json").is_file():
145
+ return json.loads((p / "config.json").read_text()), "directory", None
146
+ try: # use the hub client's cache, offline mode and auth when it is installed
147
+ from huggingface_hub import hf_hub_download
148
+
149
+ path = hf_hub_download(model, "config.json", revision=revision)
150
+ return json.loads(Path(path).read_text()), "hub", checkpoint.snapshot_commit(Path(path))
151
+ except ImportError:
152
+ pass
153
+ except Exception as e: # noqa: BLE001 - surface hub errors as a user-facing message
154
+ raise OpError(f"could not fetch config.json for '{model}': {e}") from None
155
+ cached = checkpoint.cached_file(model, "config.json", revision)
156
+ if cached is not None:
157
+ return json.loads(cached.read_text()), "hub-cache", checkpoint.snapshot_commit(cached)
158
+ raw, commit = checkpoint.hub_small_file(model, "config.json", revision) # HF_TOKEN used, never recorded
159
+ if raw is None:
160
+ raise OpError(f"'{model}' has no config.json at revision {revision or 'main'}")
161
+ return json.loads(raw.decode()), "hub", commit
162
+
163
+
164
+ def resolve_bandwidth(gpu_bw: str | None) -> tuple[float | None, dict, list[str]]:
165
+ """GB/s from a number, an evidence id of kind=ceiling, or 'measure' (runs measure_bandwidth)."""
166
+ if gpu_bw in (None, ""):
167
+ return None, {"source": "none"}, []
168
+ if gpu_bw == "measure":
169
+ from inferencekit import registry
170
+
171
+ rec = registry.call("measure_bandwidth", {})
172
+ return _bw_from_record(rec)
173
+ try:
174
+ value = float(gpu_bw)
175
+ return value, {"source": "user", "value_gbs": value}, [
176
+ "bandwidth is a user-supplied figure; a datasheet number is usually 5-15% above what kernels can "
177
+ "sustain, so the ceiling is optimistic (use --gpu-bw measure)"]
178
+ except ValueError:
179
+ pass
180
+ return _bw_from_record(ev.Store().load(gpu_bw))
181
+
182
+
183
+ def _bw_from_record(rec: dict) -> tuple[float | None, dict, list[str]]:
184
+ if rec.get("kind") != "ceiling":
185
+ raise OpError(f"{rec.get('evidence_id')} is a '{rec.get('kind')}' record, expected kind=ceiling")
186
+ problem = ev.integrity_problem(rec)
187
+ if problem:
188
+ raise OpError(f"{problem}; refusing to use it as the bandwidth")
189
+ best = next((m for m in rec["metrics"] if m["name"] == "dram_read_gbs" and m["stat"] == "best_sustained"), None)
190
+ if best is None or best["value"] is None:
191
+ raise OpError(f"{rec['evidence_id']} has no best sustained read bandwidth")
192
+ return float(best["value"]), {"source": "evidence", "evidence_id": rec["evidence_id"],
193
+ "value_gbs": best["value"]}, []
194
+
195
+
196
+ def _precision_label(config: dict, by_format: dict) -> tuple[str, str | None]:
197
+ """(dtype, quant) for the record identity. dtype: the weight formats (nvfp4, mxfp4, fp8, bf16...,
198
+ each with its own scale tensors) that carry >= 1% of the bytes read, largest first. quant: the
199
+ quant config's algorithm; a mixed-precision one names the algorithms of its quantized layers."""
200
+ total = sum(by_format.values()) or 1.0
201
+ main = [f for f, b in sorted(by_format.items(), key=lambda kv: -kv[1]) if b / total >= 0.01]
202
+ q = config.get("quantization_config") or {}
203
+ quant = str(q.get("quant_algo") or q.get("quant_method") or "").lower() or None
204
+ layers = q.get("quantized_layers")
205
+ if quant == "mixed_precision" and isinstance(layers, dict):
206
+ algos: dict[str, int] = {}
207
+ for v in layers.values():
208
+ a = str((v or {}).get("quant_algo") or "").lower() if isinstance(v, dict) else ""
209
+ if a:
210
+ algos[a] = algos.get(a, 0) + 1
211
+ if algos:
212
+ quant += "(" + "+".join(a for a, _ in sorted(algos.items(), key=lambda kv: (-kv[1], kv[0]))) + ")"
213
+ return ("+".join(main) or "unknown"), quant
214
+
215
+
216
+ def _config_weight_dtype(config: dict) -> tuple[str | None, str | None]:
217
+ """(--dtype for the config method, problem) from the config's quantization_config, for the
218
+ auto fallback: fp8 checkpoints are fp8, unquantized ones bf16; other formats the dense config
219
+ method cannot represent are counted as bf16 and flagged."""
220
+ q = config.get("quantization_config") or text_config(config).get("quantization_config") or {}
221
+ if not q:
222
+ return None, None
223
+ names = {str(q.get(k) or "").lower() for k in ("quant_method", "quant_algo")} - {""}
224
+ if names & {"fp8"} and not names - {"fp8", "modelopt", "compressed-tensors"}:
225
+ return "fp8", None
226
+ return None, (f"the config declares {'/'.join(sorted(names)) or 'a'} quantization, which the config-only "
227
+ "method does not model; weights counted as bf16 (bytes overstated, ceiling understated)")
228
+
229
+
230
+ def _declared_kv_dtype(config: dict) -> str | None:
231
+ """The KV-cache format the quant config declares (no KV scales need to ship in the checkpoint)."""
232
+ q = config.get("quantization_config") or {}
233
+ scheme = q.get("kv_cache_scheme") or {}
234
+ if isinstance(scheme, dict) and scheme.get("num_bits") == 8 and scheme.get("type") == "float":
235
+ return "fp8"
236
+ algo = str(q.get("kv_cache_quant_algo") or "").lower()
237
+ return "fp8" if algo.startswith("fp8") else None
238
+
239
+
240
+ def _headers_roofline(model, revision, ckpt, config, context, kv_dtype, state_dtype, include_mtp, dtype_given):
241
+ """(counts, summary prefix, limitations, missing, identity model dict) for --method headers."""
242
+ from inferencekit.roofline_headers import header_decode_bytes
243
+
244
+ counts = header_decode_bytes(ckpt, config, context, kv_dtype, state_dtype, include_mtp)
245
+ limitations = list(counts["notes"])
246
+ missing = [f"UNPROVEN: {u}" for u in counts["unproven"]]
247
+ if dtype_given:
248
+ limitations.append(f"--dtype {dtype_given} ignored: with --method headers every tensor's dtype comes "
249
+ "from the checkpoint")
250
+ declared = _declared_kv_dtype(config)
251
+ if declared and declared != kv_dtype:
252
+ limitations.append(f"the checkpoint's quant config declares an {declared} KV cache but KV was counted as "
253
+ f"{kv_dtype}; pass --kv-dtype {declared} if the engine ran an {declared} cache")
254
+ limitations.append(f"KV cache counted as {kv_dtype} (--kv-dtype); use the cache dtype the engine logged, "
255
+ "not the config's")
256
+ st = counts["state"]
257
+ if st and st["status"] == "ok":
258
+ per = st["per_layer"]
259
+ limitations.append(f"linear-attention state counted as {per['state_dtype']} ({per['state_dtype_source']}), "
260
+ f"conv state as {per['conv_dtype']}, each read and written once per token; engines pick "
261
+ "their own state dtype (e.g. vLLM --mamba-ssm-cache-dtype), so pass --state-dtype to "
262
+ "match what the engine logged")
263
+ if counts["moe"]:
264
+ limitations.append(counts["moe"]["caveat"])
265
+ ex = counts["excluded"]
266
+ if "towers" in ex:
267
+ limitations.append(f"vision/audio towers and projectors ({ex['towers']['stored_bytes'] / GB:.3f} GB stored) "
268
+ "excluded: text decode does not run them")
269
+ if "mtp" in ex:
270
+ limitations.append(f"MTP / next-n head ({ex['mtp']['stored_bytes'] / GB:.3f} GB stored) excluded; with "
271
+ "MTP speculative decoding on, rerun with --include-mtp and read the result per step, "
272
+ "not per token")
273
+ if counts["other_patterns"]:
274
+ limitations.append(f"{len(counts['other_patterns'])} tensor pattern(s) not matched to a module role were "
275
+ "counted as read every token (group 'other'): "
276
+ + ", ".join(counts["other_patterns"][:8]))
277
+ dtype_label, quant = _precision_label(config, counts["read_bytes_by_format"])
278
+ revision_ = ckpt.commit or revision
279
+ model_id = {"id": model, "revision": revision_, "dtype": dtype_label, "quant": quant}
280
+ kv, state = counts["kv_bytes"], counts["state_bytes"]
281
+ terms = f"weights {counts['weight_bytes'] / GB:.3f} + KV {kv / GB:.3f}"
282
+ if counts["state"] is not None:
283
+ terms += " + state " + ("UNPROVEN" if state is None else f"{state / GB:.3f}")
284
+ prefix = (f"{model} ({dtype_label}): {counts['bytes_per_token'] / GB:.3f} GB/token at context {context} "
285
+ f"[safetensors headers: {terms} GB]")
286
+ return counts, prefix, limitations, missing, model_id
287
+
288
+
289
+ @operation("roofline", ("roofline",), "Bytes read per decode token (weights + KV cache / recurrent state at a "
290
+ "context length) and the bandwidth ceiling in tok/s. Default: exact tensor sizes from the safetensors "
291
+ "headers (NVFP4/FP8/MoE/hybrid/sliding-window aware, no weights downloaded); --method config for the "
292
+ "config-only dense estimate.",
293
+ [Arg("model", "string", "Hugging Face id, a model directory, or a config.json path.", required=True),
294
+ Arg("dtype", "string", "Weight format of the linear layers (--method config only; headers read the "
295
+ "real dtypes). Default bf16.", choices=("fp8", "bf16", "q8_0")),
296
+ Arg("gpu_bw", "string", "DRAM bandwidth: GB/s number, 'measure', or an evidence id of kind=ceiling."),
297
+ Arg("context", "integer", "Tokens already in the KV cache when the token is decoded.", default=1024),
298
+ Arg("kv_dtype", "string", "KV cache format.", default="bf16", choices=("bf16", "fp16", "fp8", "q8_0")),
299
+ Arg("lm_head_dtype", "string", "lm_head format for --method config (default: bf16 for fp8 "
300
+ "checkpoints, else the weight dtype).", choices=("bf16", "fp16", "fp8", "q8_0")),
301
+ Arg("revision", "string", "Hub revision (branch, tag or commit)."),
302
+ Arg("method", "string", "headers: sum real tensor bytes from the safetensors headers; config: dense "
303
+ "estimate from config.json; auto: headers whenever safetensors are available.", default="auto",
304
+ choices=("auto", "headers", "config")),
305
+ Arg("state_dtype", "string", "Linear-attention (Gated DeltaNet) recurrent-state format (default: the "
306
+ "config's mamba_ssm_dtype, else bf16).", choices=("fp32", "bf16", "fp16")),
307
+ Arg("include_mtp", "boolean", "Count the MTP / next-n head (excluded by default: plain decode does "
308
+ "not run it).", default=False)])
309
+ def roofline(call, model, dtype, gpu_bw, context, kv_dtype, lm_head_dtype, revision, method="auto",
310
+ state_dtype=None, include_mtp=False):
311
+ from inferencekit import checkpoint
312
+
313
+ ckpt = None
314
+ no_headers = "no safetensors headers available"
315
+ if method in ("auto", "headers"):
316
+ try:
317
+ ckpt = checkpoint.load(model, revision)
318
+ except checkpoint.HubUnavailable as e:
319
+ if method == "headers":
320
+ raise
321
+ no_headers = f"safetensors headers unavailable ({e})"
322
+ if ckpt is None and method == "headers":
323
+ raise OpError(f"no safetensors files found for '{model}'; use --method config")
324
+ if ckpt is not None and ckpt.local_dir is not None and (ckpt.local_dir / "config.json").is_file():
325
+ # headers from the local HF cache: the config of the same snapshot
326
+ config, source, commit = json.loads((ckpt.local_dir / "config.json").read_text()), "hub-cache", ckpt.commit
327
+ else:
328
+ # pin the config to the commit the headers came from, so both describe the same checkpoint
329
+ config, source, commit = load_config(model, (ckpt.commit if ckpt else None) or revision)
330
+ limitations: list[str] = []
331
+ missing: list[str] = []
332
+ if ckpt is not None:
333
+ counts, summary, limitations, missing, model_id = _headers_roofline(
334
+ model, revision, ckpt, config, context, kv_dtype, state_dtype, include_mtp, dtype)
335
+ if counts["unproven"]:
336
+ summary += f" ({len(counts['unproven'])} UNPROVEN term(s): ceiling is an upper bound)"
337
+ if ckpt.source == "hub-cache":
338
+ limitations.append(f"headers read from the local Hugging Face cache (snapshot {ckpt.commit}), not "
339
+ "fetched from the Hub")
340
+ else:
341
+ if method == "auto" and dtype is None: # a fallback: take the format the config declares
342
+ dtype, why = _config_weight_dtype(config)
343
+ if why:
344
+ limitations.append(why)
345
+ missing.append(f"UNPROVEN: {why}")
346
+ dtype = dtype or "bf16"
347
+ counts = decode_bytes(config, dtype, context, kv_dtype, lm_head_dtype)
348
+ counts["method"] = "config"
349
+ summary = f"{model} {dtype}: {counts['bytes_per_token'] / GB:.3f} GB/token at context {context}"
350
+ model_id = {"id": model, "revision": commit or revision, "dtype": dtype,
351
+ "quant": dtype if dtype in ("fp8", "q8_0") else None}
352
+ if method == "auto":
353
+ limitations.append(f"{no_headers}: config-only estimate (--method config)")
354
+ # The stored request pins what was resolved (the method that ran, the commit read), so the
355
+ # reproduce command rebuilds this record rather than whatever 'auto' / 'main' resolve to later.
356
+ request = call.request()
357
+ requested = {k: request["args"].get(k) for k in ("method", "revision", "dtype")}
358
+ request["args"]["method"] = counts["method"]
359
+ if model_id["revision"] and checkpoint.is_hub_id(model):
360
+ request["args"]["revision"] = model_id["revision"]
361
+ if counts["method"] == "config":
362
+ request["args"]["dtype"] = dtype # the format counted, also when the auto fallback inferred it
363
+ if requested != {k: request["args"].get(k) for k in requested}:
364
+ request["requested"] = requested
365
+ bw, bw_info, bw_limits = resolve_bandwidth(gpu_bw)
366
+ per_token = counts["bytes_per_token"]
367
+ metrics = [
368
+ ev.metric("bytes_per_token", per_token, "B", "derived"),
369
+ ev.metric("weight_bytes", counts["weight_bytes"], "B", "derived"),
370
+ ev.metric("kv_bytes", counts["kv_bytes"], "B", "derived"),
371
+ ]
372
+ if counts["method"] == "headers":
373
+ if counts["state"] is not None:
374
+ metrics.append(ev.metric("state_bytes", counts["state_bytes"], "B", "derived"))
375
+ metrics += [ev.metric(f"bytes_{g}", v["read_bytes"], "B", "derived") for g, v in counts["groups"].items()]
376
+ if bw:
377
+ ceiling = bw * GB / per_token
378
+ metrics += [ev.metric("ceiling_tok_s", ceiling, "tok/s", "ceiling"),
379
+ ev.metric("min_token_latency_ms", 1e3 / ceiling, "ms", "ceiling"),
380
+ ev.metric("bandwidth_gbs", bw, "GB/s", "input")]
381
+ summary += f"; ceiling {ceiling:.1f} tok/s at {bw:.0f} GB/s"
382
+ else:
383
+ missing.append("bandwidth (pass --gpu-bw)")
384
+ limitations = bw_limits + limitations
385
+ limitations += ["assumes every weight is read once per token from DRAM (true at batch 1; L2 reuse across "
386
+ "tokens is negligible for multi-GB models)",
387
+ "ignores activations, logits and sampling traffic (small next to the weights)"]
388
+ ident = ev.identity(model=model_id, workload={"prompt_len": context, "gen_len": 1, "batch": 1})
389
+ record = ev.build(call, kind="roofline", claim_class="derived", summary=summary, identity_=ident,
390
+ metrics=metrics, missing=missing, limitations=limitations,
391
+ data={"counts": counts, "bandwidth": bw_info, "config_source": source, "request": request})
392
+ return ev.Store().save(record)
@@ -0,0 +1,61 @@
1
+ """evidence list / evidence show: query the store. Long lists are paginated (offset / limit)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from inferencekit import evidence as ev
6
+ from inferencekit.registry import Arg, OpError, operation
7
+
8
+
9
+ def get_path(record: dict, path: str):
10
+ """Follow a dotted path such as 'data.top_kernels' or 'metrics.0'."""
11
+ node = record
12
+ for part in [p for p in path.split(".") if p]:
13
+ if isinstance(node, dict) and part in node:
14
+ node = node[part]
15
+ elif isinstance(node, list) and part.lstrip("-").isdigit() and -len(node) <= int(part) < len(node):
16
+ node = node[int(part)]
17
+ else:
18
+ raise OpError(f"path '{path}' not found (stopped at '{part}')")
19
+ return node
20
+
21
+
22
+ def page(items: list, offset: int, limit: int) -> dict:
23
+ offset = max(0, offset)
24
+ chunk = items[offset:offset + max(1, limit)]
25
+ nxt = offset + len(chunk)
26
+ return {"total": len(items), "offset": offset, "items": chunk, "next_offset": nxt if nxt < len(items) else None}
27
+
28
+
29
+ @operation("list_evidence", ("evidence", "list"), "List stored evidence records, newest first.",
30
+ [Arg("kind", "string", "Only this kind.", choices=ev.KINDS),
31
+ Arg("offset", "integer", "Skip this many records.", default=0),
32
+ Arg("limit", "integer", "Page size.", default=20)])
33
+ def list_evidence(call, kind, offset, limit):
34
+ rows = []
35
+ for rec in ev.Store().records():
36
+ if kind and rec.get("kind") != kind:
37
+ continue
38
+ rows.append({"evidence_id": rec.get("evidence_id"), "kind": rec.get("kind"),
39
+ "claim_class": rec.get("claim_class"), "started": rec.get("provenance", {}).get("started"),
40
+ "summary": rec.get("summary"), "complete": rec.get("coverage", {}).get("complete")})
41
+ rows.sort(key=lambda r: r["started"] or "", reverse=True)
42
+ return {"root": str(ev.Store().root), **page(rows, offset, limit)}
43
+
44
+
45
+ @operation("inspect_evidence", ("evidence", "show"), "Show one evidence record (or a page of a list inside it, "
46
+ "e.g. path=data.top_kernels), with an integrity check.",
47
+ [Arg("id", "string", "Evidence id, a unique prefix (6+ hex chars), or a path to evidence.json.",
48
+ required=True, positional=True),
49
+ Arg("path", "string", "Dotted path inside the record, e.g. data.kernels or metrics."),
50
+ Arg("offset", "integer", "For list values: first item.", default=0),
51
+ Arg("limit", "integer", "For list values: page size.", default=50)])
52
+ def inspect_evidence(call, id, path, offset, limit): # noqa: A002
53
+ rec = ev.Store().load(id)
54
+ integrity = ev.verify(rec)
55
+ if not path:
56
+ return {"integrity": integrity, "record": rec}
57
+ value = get_path(rec, path)
58
+ if isinstance(value, list):
59
+ return {"evidence_id": rec.get("evidence_id"), "path": path, "integrity": integrity,
60
+ **page(value, offset, limit)}
61
+ return {"evidence_id": rec.get("evidence_id"), "path": path, "integrity": integrity, "value": value}
inferencekit/proc.py ADDED
@@ -0,0 +1,172 @@
1
+ """Running external tools: nvidia-smi, nvcc, nsys, ncu, engine binaries.
2
+
3
+ Profilers run under a *clean environment*: only an allowlist of variables is passed
4
+ (PATH, HOME, CUDA_VISIBLE_DEVICES, plus names the user adds with ``--keep-env``).
5
+ This is what ``env -i PATH=... HOME=... <cmd>`` does; we pass the dict through
6
+ ``subprocess`` instead of putting ``NAME=value`` pairs on a command line, so the
7
+ values never show up in ``ps`` output. Why bother: Nsight Systems and Nsight Compute
8
+ store the profiled process's command line and ``NAME=value`` for every variable it
9
+ received inside the report file, and a report shared for review must not carry
10
+ tokens. So secret-looking names are refused outright (see ``clean_env``).
11
+
12
+ Benchmarks run with the caller's full environment instead (engines need their usual
13
+ setup). The two policies meet in ``config_env_names``: both kinds of record list the
14
+ *names* of engine-configuration variables (VLLM_*, TORCH_*, ...), and a profile also
15
+ lists the ones it did not pass on, so a profile of a different configuration than the
16
+ benchmark is visible.
17
+
18
+ Every child runs in its own process group, and a timeout kills the whole group: a
19
+ profiler's or an engine's own children (the program under nsys, a vLLM engine core)
20
+ would otherwise keep running on the GPU and slow down the next measurement.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import re
27
+ import shutil
28
+ import signal
29
+ import subprocess
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+
33
+ from inferencekit.evidence import looks_secret_name
34
+ from inferencekit.registry import OpError
35
+
36
+ BASE_ENV_ALLOWLIST = ("PATH", "HOME", "CUDA_VISIBLE_DEVICES", "ROCR_VISIBLE_DEVICES",
37
+ "HIP_VISIBLE_DEVICES", "GPU_DEVICE_ORDINAL")
38
+ # Variables that change what an engine runs (attention backend, GEMM library, compile caches,
39
+ # which libraries load). Only their NAMES are ever recorded.
40
+ CONFIG_ENV_RX = re.compile(r"^(VLLM_|SGLANG_|TORCH_|PYTORCH_|TRITON_|NCCL_|CUDA_|CUBLAS|CUDNN_|GGML_|LLAMA_|"
41
+ r"FLASHINFER_|ROCR_|HIP_|HSA_|GPU_DEVICE_ORDINAL$|HF_HOME$|HF_HUB_OFFLINE$|"
42
+ r"LD_LIBRARY_PATH$|LD_PRELOAD$|OMP_NUM_THREADS$)")
43
+
44
+
45
+ @dataclass
46
+ class Result:
47
+ returncode: int
48
+ stdout: str
49
+ stderr: str
50
+
51
+ @property
52
+ def ok(self) -> bool:
53
+ return self.returncode == 0
54
+
55
+
56
+ def which(name: str) -> str | None:
57
+ """Find a binary on PATH, then in the usual CUDA install locations."""
58
+ found = shutil.which(name)
59
+ if found:
60
+ return found
61
+ if name == "rocprofv3":
62
+ rocm_tool = Path("/opt/rocm/bin", name)
63
+ if rocm_tool.is_file():
64
+ return str(rocm_tool)
65
+ for root in [os.environ.get("CUDA_HOME"), "/usr/local/cuda"] + sorted(
66
+ (str(p) for p in Path("/usr/local").glob("cuda-*")), reverse=True):
67
+ if root and Path(root, "bin", name).is_file():
68
+ return str(Path(root, "bin", name))
69
+ for p in sorted(Path("/opt/nvidia").glob(f"nsight-*/*/bin/{name}"), reverse=True): # nsight standalone installs
70
+ return str(p)
71
+ return None
72
+
73
+
74
+ def _run_in_group(argv: list[str], timeout: float | None, **popen_kw) -> tuple[int, str | None, str | None]:
75
+ """subprocess.run, except that a timeout kills the child's whole process group.
76
+
77
+ ``start_new_session=True`` makes the child the leader of a new process group; its own
78
+ children (the program nsys profiles, an engine's worker processes) inherit that group.
79
+ ``subprocess.run`` would SIGKILL only the direct child and orphan the rest, still
80
+ holding GPU memory and bandwidth. ``os.killpg`` signals every process in the group.
81
+ The group also no longer receives the terminal's Ctrl-C, so any exception here,
82
+ KeyboardInterrupt included, kills the group before re-raising.
83
+ """
84
+ p = subprocess.Popen(argv, start_new_session=True, text=True, **popen_kw)
85
+ try:
86
+ out, err = p.communicate(timeout=timeout)
87
+ except BaseException: # TimeoutExpired, KeyboardInterrupt, ...
88
+ try:
89
+ os.killpg(p.pid, signal.SIGKILL)
90
+ except ProcessLookupError: # the group already exited
91
+ pass
92
+ p.communicate() # reap the child and close its pipes
93
+ raise
94
+ return p.returncode, out, err
95
+
96
+
97
+ def run(argv: list[str], timeout: float | None = 120, env: dict | None = None, cwd: str | None = None,
98
+ stdout_path: Path | None = None, stderr_path: Path | None = None) -> Result:
99
+ """Run a command and capture output (to memory, or to files for long-running jobs)."""
100
+ try:
101
+ if stdout_path or stderr_path:
102
+ with open(stdout_path or os.devnull, "w") as out, open(stderr_path or os.devnull, "w") as err:
103
+ code, _, _ = _run_in_group(argv, timeout, stdout=out, stderr=err, env=env, cwd=cwd)
104
+ so = Path(stdout_path).read_text(errors="replace") if stdout_path else ""
105
+ se = Path(stderr_path).read_text(errors="replace") if stderr_path else ""
106
+ return Result(code, so, se)
107
+ code, so, se = _run_in_group(argv, timeout, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env,
108
+ cwd=cwd)
109
+ return Result(code, so or "", se or "")
110
+ except FileNotFoundError:
111
+ return Result(127, "", f"not found: {argv[0]}")
112
+ except subprocess.TimeoutExpired:
113
+ return Result(124, "", f"timed out after {timeout} s: {argv[0]} (its whole process group was killed)")
114
+
115
+
116
+ def clean_env(keep: list[str] | None = None) -> tuple[dict, list[str]]:
117
+ """The allowlisted environment for a profiled run, and the *names* it contains.
118
+
119
+ Only names are recorded in the evidence record. The profiler is a different matter:
120
+ nsys and ncu write the full command line and ``NAME=value`` for every variable passed
121
+ here into the report file. That is why secret-looking names (``*TOKEN*``, ``*KEY*``,
122
+ ``*SECRET*``...) are refused: make the run need no secret instead (download weights
123
+ first, set ``HF_HUB_OFFLINE=1``).
124
+ """
125
+ secret = [k for k in (keep or []) if looks_secret_name(k)]
126
+ if secret:
127
+ raise OpError(f"refusing --keep-env {', '.join(secret)}: profiler reports store NAME=value for every "
128
+ "variable passed to the profiled program, so a secret would end up in the report file. "
129
+ "Make the run need no secret (download weights first, set HF_HUB_OFFLINE=1).")
130
+ names = list(BASE_ENV_ALLOWLIST) + [k for k in (keep or []) if k not in BASE_ENV_ALLOWLIST]
131
+ env = {k: os.environ[k] for k in names if k in os.environ}
132
+ env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
133
+ return env, sorted(env)
134
+
135
+
136
+ def config_env_names(env: dict | None = None) -> list[str]:
137
+ """Names (never values) of engine-configuration variables set in ``env`` (default: this process)."""
138
+ return sorted(k for k in (os.environ if env is None else env) if CONFIG_ENV_RX.match(k))
139
+
140
+
141
+ def dropped_config_env(env: dict) -> list[str]:
142
+ """Engine-configuration variables set here but not passed to a profiled run (``env``).
143
+
144
+ A benchmark runs with the full environment, a profile with the allowlist. If, say,
145
+ VLLM_ATTENTION_BACKEND is set here but not kept, the profile traces a different
146
+ configuration than the benchmark did. Names only.
147
+ """
148
+ return [k for k in config_env_names() if k not in env and not looks_secret_name(k)]
149
+
150
+
151
+ def require(name: str, hint: str) -> str:
152
+ path = which(name)
153
+ if not path:
154
+ raise OpError(f"{name} not found on PATH. {hint}")
155
+ return path
156
+
157
+
158
+ def tool_version(name: str) -> str | None:
159
+ """First version-looking token of ``<tool> --version`` (nsys, ncu, nvcc...)."""
160
+ path = which(name)
161
+ if not path:
162
+ return None
163
+ r = run([path, "--version"], timeout=30)
164
+ text = r.stdout + r.stderr
165
+ m = re.search(r"release (\d+\.\d+)", text) # nvcc: "release 12.8, V12.8.93"
166
+ if m and name == "nvcc":
167
+ return m.group(1)
168
+ m = re.search(r"(?:version|Version)\s+v?(\d+(?:\.\d+)+)", text)
169
+ if m:
170
+ return m.group(1)
171
+ m = re.search(r"(\d+\.\d+(?:\.\d+)*)", text)
172
+ return m.group(1) if m else (text.strip().splitlines() or [None])[0]