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,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]
|