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,9 @@
1
+ """inferencekit: measure-first LLM inference optimisation.
2
+
3
+ Every operation produces an *evidence record*: a small JSON document that says what
4
+ was measured, on which GPU / engine / model / workload, with which tool, and what
5
+ the result does NOT show (``limitations``). Records are content-addressed, so a
6
+ report can cite exactly the numbers it used.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m inferencekit ...`` as an alias of the ``inferencekit`` command."""
2
+
3
+ from inferencekit.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Pure parsers and analyses (no subprocesses, no GPU): easy to unit-test on synthetic inputs."""
@@ -0,0 +1,284 @@
1
+ """Parse Nsight Compute CSV exports into one summary per profiled kernel launch.
2
+
3
+ Two CSV layouts, both from ``ncu --import report.ncu-rep --csv``:
4
+
5
+ --page raw wide: row 1 = metric names, row 2 = units, then one row per launch.
6
+ Exact metric names (dram__bytes_read.sum, sm__throughput...).
7
+ --page details long: one row per (launch, section, metric) with human names
8
+ ("DRAM Throughput", "Achieved Occupancy").
9
+
10
+ Raw is preferred; details fills whatever raw lacks. Values are converted to base
11
+ units (ns, bytes, bytes/s) using the unit row, because ncu picks units per value
12
+ (usecond for one kernel, msecond for another) and its spelling changes between
13
+ releases ("Gbyte/second" in older ones, "Gbyte/s" and "us" in newer ones). Units are
14
+ parsed by structure (SI prefix + byte + optional per-second), and every field says
15
+ which kind of quantity it must be. A value whose unit is unknown, or of the wrong kind
16
+ (a "%" where a byte rate is expected), becomes None and is listed in
17
+ ``unparsed_units``; it is never silently read as base units.
18
+
19
+ The "bound" label is a speed-of-light rule of thumb: ncu reports each unit's
20
+ throughput as % of its peak. If DRAM or SM is >= 60% of peak, that unit is the
21
+ limiter. If both are < 40%, neither is busy: the kernel is waiting (latency-bound:
22
+ too little parallelism, dependencies, or launch/tail effects). In between: mixed.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import csv
28
+ import io
29
+ import re
30
+
31
+ # Time units -> nanoseconds.
32
+ TIME_NS = {"nsecond": 1.0, "ns": 1.0, "usecond": 1e3, "us": 1e3, "msecond": 1e6, "ms": 1e6, "second": 1e9, "s": 1e9}
33
+ SI_PREFIX = {"": 1.0, "K": 1e3, "M": 1e6, "G": 1e9, "T": 1e12}
34
+ # Byte amounts and byte rates: "byte", "Kbyte", "GB", "Gbyte/second", "Gbyte/s", "TB/s"...
35
+ BYTES_RX = re.compile(r"^(?P<prefix>[KMGT]?)(?:byte|B)(?P<rate>/(?:second|s))?$")
36
+ # Units that carry a physical dimension we do not convert (cycle/nsecond, Gbyte/ms, Hz...).
37
+ UNKNOWN_DIMENSION_RX = re.compile(r"byte|second|^[KMGT]?B(/|$)|hz|watt|/(?:[num]?s)$", re.IGNORECASE)
38
+
39
+ # What each field must be. Fields not listed are counts (registers, grid size, waves...).
40
+ FIELD_KIND = {"duration_ns": "time", "dram_read_bytes": "bytes", "dram_write_bytes": "bytes",
41
+ "dram_bytes_per_s": "byte_rate", "dram_pct": "percent", "sm_pct": "percent", "mem_pct": "percent",
42
+ "occupancy_achieved_pct": "percent", "occupancy_theoretical_pct": "percent", "l2_hit_pct": "percent"}
43
+
44
+ # field -> raw-page metric names, first present wins
45
+ RAW_FIELDS = {
46
+ "duration_ns": ["gpu__time_duration.sum"],
47
+ # Metric names differ across Nsight Compute releases (2025.x uses the *_op_* spelling).
48
+ "dram_read_bytes": ["dram__bytes_read.sum", "dram__bytes_op_read.sum"],
49
+ "dram_write_bytes": ["dram__bytes_write.sum", "dram__bytes_op_write.sum"],
50
+ "dram_pct": ["gpu__dram_throughput.avg.pct_of_peak_sustained_elapsed",
51
+ "dram__throughput.avg.pct_of_peak_sustained_elapsed"],
52
+ "sm_pct": ["sm__throughput.avg.pct_of_peak_sustained_elapsed"],
53
+ "mem_pct": ["gpu__compute_memory_throughput.avg.pct_of_peak_sustained_elapsed"],
54
+ "occupancy_achieved_pct": ["sm__warps_active.avg.pct_of_peak_sustained_active"],
55
+ "occupancy_theoretical_pct": ["sm__maximum_warps_per_active_cycle_pct"],
56
+ "registers": ["launch__registers_per_thread"],
57
+ "waves": ["launch__waves_per_multiprocessor"],
58
+ "grid": ["launch__grid_size"],
59
+ "block": ["launch__block_size"],
60
+ "limit_registers": ["launch__occupancy_limit_registers"],
61
+ "limit_shared_mem": ["launch__occupancy_limit_shared_mem"],
62
+ "limit_warps": ["launch__occupancy_limit_warps"],
63
+ "limit_blocks": ["launch__occupancy_limit_blocks"],
64
+ "l2_hit_pct": ["lts__t_sector_hit_rate.pct"],
65
+ }
66
+
67
+ # field -> (section, metric) in the details page; section None = any section
68
+ DETAIL_FIELDS = {
69
+ "duration_ns": [("GPU Speed Of Light Throughput", "Duration")],
70
+ "dram_pct": [("GPU Speed Of Light Throughput", "DRAM Throughput")],
71
+ "sm_pct": [("GPU Speed Of Light Throughput", "Compute (SM) Throughput")],
72
+ "mem_pct": [("GPU Speed Of Light Throughput", "Memory Throughput")],
73
+ "dram_bytes_per_s": [("Memory Workload Analysis", "Memory Throughput")],
74
+ "occupancy_achieved_pct": [("Occupancy", "Achieved Occupancy")],
75
+ "occupancy_theoretical_pct": [("Occupancy", "Theoretical Occupancy")],
76
+ "registers": [("Launch Statistics", "Registers Per Thread")],
77
+ "waves": [("Launch Statistics", "Waves Per SM")],
78
+ "grid": [("Launch Statistics", "Grid Size")],
79
+ "block": [("Launch Statistics", "Block Size")],
80
+ "limit_registers": [("Occupancy", "Block Limit Registers")],
81
+ "limit_shared_mem": [("Occupancy", "Block Limit Shared Mem")],
82
+ "limit_warps": [("Occupancy", "Block Limit Warps")],
83
+ "limit_blocks": [("Occupancy", "Block Limit SM")],
84
+ "l2_hit_pct": [("Memory Workload Analysis", "L2 Hit Rate")],
85
+ }
86
+
87
+ # Warp stall reasons: cycles stalled per issued instruction, or PC-sampling counts.
88
+ STALL_RATIO_RX = re.compile(r"^smsp__average_warps_issue_stalled_(.+)_per_issue_active\.ratio$")
89
+ STALL_PCSAMP_RX = re.compile(r"^smsp__pcsamp_warps_issue_stalled_(.+?)(?:\.sum)?$")
90
+ NOT_A_STALL = {"selected", "not_selected"} # "selected" = the warp issued; not a stall
91
+
92
+
93
+ def unit_kind(unit: str) -> tuple[str, float] | None:
94
+ """(kind, factor to base units) for an ncu unit string, or None when it cannot be converted.
95
+
96
+ 'usecond' -> ('time', 1e3) 'Gbyte/s' -> ('byte_rate', 1e9) 'Kbyte' -> ('bytes', 1e3)
97
+ '%' -> ('percent', 1.0) 'block', 'register/thread', '' -> ('count', 1.0)
98
+ 'cycle/nsecond', 'Gbyte/ms' -> None (a dimension this parser does not handle)
99
+ """
100
+ u = (unit or "").strip()
101
+ if u == "%":
102
+ return "percent", 1.0
103
+ if u in TIME_NS:
104
+ return "time", TIME_NS[u]
105
+ m = BYTES_RX.match(u)
106
+ if m:
107
+ return ("byte_rate" if m["rate"] else "bytes"), SI_PREFIX[m["prefix"]]
108
+ if UNKNOWN_DIMENSION_RX.search(u):
109
+ return None
110
+ return "count", 1.0
111
+
112
+
113
+ def to_number(text, unit: str = "", kind: str | None = None) -> float | None:
114
+ """'1,234.5' + 'usecond' -> 1234500.0 (ns). Non-numeric -> None.
115
+
116
+ ``kind`` is what the caller needs ('time', 'bytes', 'byte_rate', 'percent', 'count').
117
+ Returns None when the unit is unknown or of another kind: 763 'Gbyte/s' must never be
118
+ taken as 763 bytes/s, and a '%' must never be taken as a byte rate. A percent field
119
+ also accepts a bare number (no unit).
120
+ """
121
+ try:
122
+ x = float(str(text).replace(",", "").strip())
123
+ except ValueError:
124
+ return None
125
+ found = unit_kind(unit)
126
+ if found is None:
127
+ return None
128
+ got, factor = found
129
+ if kind is not None and got != kind and not (kind == "percent" and got == "count" and not unit.strip()):
130
+ return None
131
+ return x * factor
132
+
133
+
134
+ def _field_value(field: str, text: str, unit: str, problems: list[str]) -> float | None:
135
+ """Convert one field's value; note it in ``problems`` if it was there but could not be read."""
136
+ value = to_number(text, unit, FIELD_KIND.get(field, "count"))
137
+ if value is None and str(text).replace(",", "").strip() not in ("", "n/a", "N/A"):
138
+ problems.append(f"{field}: {text!r} {unit!r}")
139
+ return value
140
+
141
+
142
+ def _csv_rows(text: str) -> list[list[str]]:
143
+ # ncu may print "==PROF==" or warning lines around the CSV; keep the quoted CSV lines only.
144
+ lines = [ln for ln in text.splitlines() if ln.startswith('"')]
145
+ return list(csv.reader(io.StringIO("\n".join(lines))))
146
+
147
+
148
+ def parse_raw(text: str) -> list[dict]:
149
+ """--page raw --csv -> [{id, kernel, fields..., stalls}] in profiling order."""
150
+ rows = _csv_rows(text)
151
+ if len(rows) < 2:
152
+ return []
153
+ header, units, data = rows[0], rows[1], rows[2:]
154
+ col = {h: i for i, h in enumerate(header)}
155
+ out = []
156
+ for k, row in enumerate(data):
157
+ if len(row) < len(header):
158
+ continue
159
+ rec = {"index": k, "id": row[col["ID"]] if "ID" in col else str(k),
160
+ "kernel": row[col["Kernel Name"]] if "Kernel Name" in col else "", "_problems": []}
161
+ for field, names in RAW_FIELDS.items():
162
+ name = next((n for n in names if n in col), None)
163
+ rec[field] = _field_value(field, row[col[name]], units[col[name]], rec["_problems"]) if name else None
164
+ stalls = {}
165
+ for name, i in col.items():
166
+ m = STALL_PCSAMP_RX.match(name)
167
+ # each reason also has a "_not_issued" twin (a subset): skip it to avoid double counting
168
+ if m and m.group(1) not in NOT_A_STALL and not m.group(1).endswith("not_issued"):
169
+ stalls[m.group(1)] = ("pcsamp", to_number(row[i]))
170
+ if not stalls:
171
+ for name, i in col.items():
172
+ m = STALL_RATIO_RX.match(name)
173
+ if m and m.group(1) not in NOT_A_STALL:
174
+ stalls[m.group(1)] = ("ratio", to_number(row[i]))
175
+ rec["_stalls"] = stalls
176
+ out.append(rec)
177
+ return out
178
+
179
+
180
+ def parse_details(text: str) -> dict[str, dict]:
181
+ """--page details --csv -> {launch id: {kernel, fields...}}."""
182
+ rows = _csv_rows(text)
183
+ if len(rows) < 2:
184
+ return {}
185
+ header = rows[0]
186
+ col = {h: i for i, h in enumerate(header)}
187
+ need = ("ID", "Metric Name", "Metric Unit", "Metric Value")
188
+ if any(n not in col for n in need):
189
+ return {}
190
+ values: dict[str, dict] = {}
191
+ for row in rows[1:]:
192
+ if len(row) <= col["Metric Value"]:
193
+ continue
194
+ kid = row[col["ID"]]
195
+ d = values.setdefault(kid, {"kernel": row[col["Kernel Name"]] if "Kernel Name" in col else "", "_m": {}})
196
+ section = row[col["Section Name"]] if "Section Name" in col else ""
197
+ d["_m"][(section, row[col["Metric Name"]])] = (row[col["Metric Value"]], row[col["Metric Unit"]])
198
+ out = {}
199
+ for kid, d in values.items():
200
+ rec = {"kernel": d["kernel"], "_problems": []}
201
+ for field, keys in DETAIL_FIELDS.items():
202
+ rec[field] = None
203
+ for section, name in keys:
204
+ hit = d["_m"].get((section, name))
205
+ if hit is None: # section renamed in this ncu version: accept the name if it is unambiguous
206
+ same = [v for (_s, n), v in d["_m"].items() if n == name]
207
+ hit = same[0] if len(same) == 1 else None
208
+ if hit is not None:
209
+ # With --set basic there is no Memory Workload Analysis section, and the only
210
+ # "Memory Throughput" left is the Speed Of Light one, in %. The kind check
211
+ # rejects it for the byte-rate field instead of reading 43% as 43 bytes/s.
212
+ rec[field] = _field_value(field, *hit, rec["_problems"])
213
+ break
214
+ out[kid] = rec
215
+ return out
216
+
217
+
218
+ def bound_label(dram_pct: float | None, sm_pct: float | None) -> str:
219
+ if dram_pct is None and sm_pct is None:
220
+ return "unknown"
221
+ d, s = dram_pct or 0.0, sm_pct or 0.0
222
+ if max(d, s) >= 60:
223
+ return "memory-bound" if d >= s else "compute-bound"
224
+ if d < 40 and s < 40:
225
+ return "latency-bound"
226
+ return "mixed"
227
+
228
+
229
+ def occupancy_limiter(rec: dict) -> str | None:
230
+ """Which resource caps blocks per SM: the smallest of the per-resource block limits."""
231
+ limits = {"registers": rec.get("limit_registers"), "shared memory": rec.get("limit_shared_mem"),
232
+ "warps": rec.get("limit_warps"), "blocks per SM": rec.get("limit_blocks")}
233
+ limits = {k: v for k, v in limits.items() if v is not None}
234
+ return min(limits, key=limits.get) if limits else None
235
+
236
+
237
+ def summarize(raw_text: str = "", details_text: str = "", peak_gbs: float | None = None) -> list[dict]:
238
+ """Merge both pages and derive GB/s, % of measured peak, limiter, stalls, bound label."""
239
+ raw = parse_raw(raw_text) if raw_text else []
240
+ details = parse_details(details_text) if details_text else {}
241
+ if not raw: # details only: build records from it, in id order
242
+ raw = [{"index": i, "id": kid, **rec, "_stalls": {}}
243
+ for i, (kid, rec) in enumerate(sorted(details.items(), key=lambda kv: _int_or(kv[0])))]
244
+ details = {} # every field is in raw now; nothing left to merge
245
+ out = []
246
+ for rec in raw:
247
+ extra = details.get(rec["id"], {})
248
+ problems = rec.pop("_problems", []) + extra.get("_problems", [])
249
+ for k, v in extra.items():
250
+ if k != "_problems" and rec.get(k) is None and v is not None:
251
+ rec[k] = v
252
+ dur = rec.get("duration_ns")
253
+ moved = (rec.get("dram_read_bytes") or 0) + (rec.get("dram_write_bytes") or 0)
254
+ gbs = moved / dur if dur and moved else None # bytes per ns == GB/s
255
+ if gbs is None and rec.get("dram_bytes_per_s") is not None:
256
+ gbs = rec["dram_bytes_per_s"] / 1e9
257
+ stalls = rec.pop("_stalls", {})
258
+ total = sum(v for _, v in stalls.values() if v) or 0.0
259
+ top = sorted(((v or 0.0, name, how) for name, (how, v) in stalls.items()), reverse=True)[:4]
260
+ out.append({
261
+ "index": rec["index"], "id": rec["id"], "kernel": rec.get("kernel", "")[:200],
262
+ "duration_us": round(dur / 1e3, 3) if dur else None,
263
+ "dram_gbs": round(gbs, 1) if gbs is not None else None,
264
+ "dram_pct_of_measured_peak": round(100 * gbs / peak_gbs, 1) if gbs is not None and peak_gbs else None,
265
+ "dram_pct": rec.get("dram_pct"), "sm_pct": rec.get("sm_pct"), "mem_pct": rec.get("mem_pct"),
266
+ "occupancy_achieved_pct": rec.get("occupancy_achieved_pct"),
267
+ "occupancy_theoretical_pct": rec.get("occupancy_theoretical_pct"),
268
+ "occupancy_limiter": occupancy_limiter(rec),
269
+ "registers": rec.get("registers"), "waves": rec.get("waves"), "grid": rec.get("grid"),
270
+ "block": rec.get("block"), "l2_hit_pct": rec.get("l2_hit_pct"),
271
+ "top_stalls": [{"reason": name, "value": round(v, 3), "share_pct": round(100 * v / total, 1) if total
272
+ else None, "source": how} for v, name, how in top if v > 0],
273
+ "bound": bound_label(rec.get("dram_pct") if rec.get("dram_pct") is not None else rec.get("mem_pct"),
274
+ rec.get("sm_pct")),
275
+ "unparsed_units": problems,
276
+ })
277
+ return out
278
+
279
+
280
+ def _int_or(text: str):
281
+ try:
282
+ return (0, int(text))
283
+ except ValueError:
284
+ return (1, text)