llmperfscope 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.
llmperfscope/cli.py ADDED
@@ -0,0 +1,502 @@
1
+ """Command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from . import __version__, adapters, capabilities, compare, driver, figures, html, nsys, report, runner, share
13
+
14
+
15
+ def _default_out(label):
16
+ return f"runs/{label or 'run'}-{time.strftime('%Y%m%d-%H%M%S')}"
17
+
18
+
19
+ def _parse_marks(values):
20
+ """regex=phase or regex=phase:group."""
21
+ marks = []
22
+ for value in values or []:
23
+ regex, _, target = value.rpartition("=")
24
+ if not regex:
25
+ raise SystemExit(f"--mark needs regex=phase, got {value!r}")
26
+ phase, _, group = target.partition(":")
27
+ marks.append((regex, phase, group or None))
28
+ return marks
29
+
30
+
31
+ def _parse_env(values):
32
+ out = {}
33
+ for value in values or []:
34
+ key, sep, val = value.partition("=")
35
+ if not sep:
36
+ raise SystemExit(f"--env needs KEY=VALUE, got {value!r}")
37
+ out[key] = val
38
+ return out
39
+
40
+
41
+ def _common(parser):
42
+ parser.add_argument("--out", help="run directory (default runs/<label>-<time>)")
43
+ parser.add_argument("--label", default="")
44
+ parser.add_argument("--hz", type=float, default=50.0, help="fast sampling rate")
45
+ parser.add_argument("--no-pcie", action="store_true", help="do not sample PCIe traffic")
46
+ parser.add_argument("--timeout", type=float, default=0.0, help="stop the target after this many seconds")
47
+ parser.add_argument("--stall", type=float, default=None,
48
+ help="stop the target after this many seconds without output or resource change "
49
+ "(default 600 for engines that report progress, otherwise off)")
50
+ parser.add_argument("--precheck", choices=["warn", "wait", "require", "skip"], default="warn",
51
+ help="what to do when the machine is not clean before the run")
52
+ parser.add_argument("--adapter", default="auto")
53
+ parser.add_argument("--mark", action="append", help="phase from output: 'regex=phase' or 'regex=phase:group'")
54
+ parser.add_argument("--env", action="append", help="KEY=VALUE for the target")
55
+ parser.add_argument("--tee", action="store_true", help="also show the target's output")
56
+ parser.add_argument("--quiet", action="store_true")
57
+ parser.add_argument("--nsys", action="store_true", help="record the GPU timeline (perturbs speed)")
58
+ parser.add_argument("--nsys-arg", action="append", default=[], help="extra argument for nsys profile")
59
+ parser.add_argument("--cpu-profile", action="store_true", help="sample host CPU stacks, no perf needed (perturbs speed)")
60
+ parser.add_argument("--cpu-hz", type=int, default=200)
61
+ parser.add_argument("--cold-start", action="store_true", help="drop the model from the page cache first, to measure a real disk load")
62
+ parser.add_argument("--model-file", help="the model's path, when llmperfscope cannot tell from the command")
63
+
64
+
65
+ def _options(args):
66
+ label = args.label
67
+ return runner.Options(
68
+ cold_start=args.cold_start,
69
+ model=args.model_file,
70
+ pcie=not args.no_pcie,
71
+ out=args.out or _default_out(label),
72
+ label=label,
73
+ hz=args.hz,
74
+ timeout_s=args.timeout,
75
+ stall_s=args.stall,
76
+ precheck=args.precheck,
77
+ adapter=args.adapter,
78
+ marks=_parse_marks(args.mark),
79
+ env=_parse_env(args.env),
80
+ tee=args.tee,
81
+ quiet=args.quiet,
82
+ nsys=args.nsys,
83
+ nsys_args=args.nsys_arg,
84
+ cpu_profile=args.cpu_profile,
85
+ cpu_hz=args.cpu_hz,
86
+ )
87
+
88
+
89
+ def _finish(result, options):
90
+ print(report.render(result))
91
+ page = html.write(options.out)
92
+ print(f"[llmperfscope] charts: {page}\n[llmperfscope] all numbers: {Path(options.out) / 'run.json'}")
93
+ return 0 if result["status"] == "ok" else 1
94
+
95
+
96
+ def _split_command(argv):
97
+ """Everything after `--` is the target."""
98
+ if "--" in argv:
99
+ i = argv.index("--")
100
+ return argv[:i], argv[i + 1:]
101
+ return argv, []
102
+
103
+
104
+ def cmd_run(args, target):
105
+ if not target:
106
+ raise SystemExit("usage: llmperfscope run [options] -- command ...")
107
+ options = _options(args)
108
+ try:
109
+ return _finish(runner.Run(target, options).execute(), options)
110
+ except runner.Refused as e:
111
+ print(f"[llmperfscope] refused: {e}", file=sys.stderr)
112
+ return 3
113
+
114
+
115
+ def _rows_from(args):
116
+ if args.prompts:
117
+ filters = dict(item.split("=", 1) for item in args.filter or [])
118
+ rows = driver.load_prompts(args.prompts, filters)
119
+ if not rows:
120
+ raise SystemExit("no prompts left after the filter")
121
+ return rows[:args.limit] if args.limit else rows
122
+ return driver.synthetic_prompts(args.synthetic_words, args.limit or 8)
123
+
124
+
125
+ def _serve_workload(args, preset, marks_load):
126
+ """Waits until the server is ready, warms it up, drives each concurrency level, collects the server's own metrics."""
127
+
128
+ def workload(run):
129
+ if marks_load:
130
+ run.emit("load", t=run.t_spawn)
131
+ base = args.url.rstrip("/")
132
+ is_alive = (lambda: Path(f"/proc/{run.pid}").exists())
133
+ ready_s = driver.wait_ready(base + args.ready_path if args.ready_path else base + preset["ready_path"], is_alive,
134
+ timeout_s=args.ready_timeout)
135
+ run.log(f"server ready after {ready_s:.2f} s")
136
+ extra = dict(preset.get("extra_body") or {})
137
+ if args.extra_body:
138
+ extra.update(json.loads(args.extra_body))
139
+ client = driver.Client(base + preset["base_path"], model=args.model, max_tokens=args.max_tokens, extra_body=extra,
140
+ ignore_eos=not args.allow_eos, api_key=args.api_key)
141
+ if args.pull:
142
+ run.emit("download")
143
+ driver.pull_ollama_model(base, args.model)
144
+ if not client.model:
145
+ try:
146
+ client.discover_model()
147
+ except (OSError, ValueError, KeyError) as e:
148
+ run.log(f"could not read the model name from /models: {e!r}")
149
+ rows = _rows_from(args)
150
+ result = {"ready_s": round(ready_s, 3), "model": client.model, "prompts": len(rows)}
151
+
152
+ run.emit("warmup")
153
+ # A longer reply than strictly needed: a model that thinks first shows no text for its first tokens.
154
+ warm = client.request({"prompt": "Warm up.", "max_tokens": 32} if "messages" not in rows[0]
155
+ else {"messages": [{"role": "user", "content": "Warm up."}], "max_tokens": 32})
156
+ if not warm["ok"]:
157
+ raise RuntimeError(f"warmup request failed: {warm.get('error')}")
158
+ result["warmup_ttft_ms"] = round(warm["ttft_s"] * 1000, 2)
159
+
160
+ metrics_path = args.metrics_path if args.metrics_path is not None else preset.get("metrics_path")
161
+ metrics_before = driver.fetch_metrics(base + metrics_path) if metrics_path else None
162
+ watcher = driver.GaugeWatcher(base + metrics_path) if metrics_before else None
163
+ if watcher:
164
+ watcher.start()
165
+ throughput = {}
166
+ with open(run.dir / "requests.jsonl", "w") as log:
167
+ current = {}
168
+
169
+ def record(item):
170
+ row = {k: v for k, v in item.items() if k not in ("t_send", "t_done")}
171
+ row["level"] = current.get("concurrency")
172
+ row["itl_s"] = [round(gap, 6) for gap in item.get("itl_s", [])]
173
+ log.write(json.dumps(row) + "\n")
174
+
175
+ for concurrency in [int(x) for x in str(args.concurrency).split(",")]:
176
+ name = f"serve_c{concurrency}"
177
+ current["concurrency"] = concurrency
178
+ run.emit(name, group="serve", concurrency=concurrency)
179
+ results, wall = driver.run_load(client, rows, concurrency=concurrency, repeat=args.repeat, seed=args.seed,
180
+ emit_each=record, level=f"c{concurrency}", unique=not args.reuse_prompts)
181
+ summary = driver.summarize_load(results, wall)
182
+ summary["concurrency"] = concurrency
183
+ throughput[name] = summary
184
+ run.log(f"{name}: {summary.get('output_tokens_per_s')} tokens/s, {summary.get('failed')} failed")
185
+ # Tokens go on the phase, so energy per token comes out of the same bookkeeping as everywhere else.
186
+ with run.lock:
187
+ for phase in run.phases:
188
+ if phase["name"] == name:
189
+ phase["tokens"] = summary.get("completion_tokens", 0)
190
+ if watcher:
191
+ watcher.stop()
192
+ result["server_gauges_peak"] = watcher.peaks
193
+ if metrics_path:
194
+ after = driver.fetch_metrics(base + metrics_path)
195
+ if metrics_before is not None and after is not None:
196
+ result["server_metrics_delta"] = driver.metrics_delta(metrics_before, after)
197
+ result["throughput"] = throughput
198
+ return result
199
+
200
+ return workload
201
+
202
+
203
+ def _add_energy_per_token(result):
204
+ """Energy per output token for each concurrency level, from the phase it ran in."""
205
+ for phase in result.get("phases", []):
206
+ if phase["group"] != "serve" or not phase.get("tokens"):
207
+ continue
208
+ energy = (phase.get("metrics", {}).get("gpu") or {}).get("energy_j")
209
+ entry = result.get("throughput", {}).get(phase["name"])
210
+ if energy is not None and entry is not None:
211
+ entry["j_per_output_token"] = round(energy / phase["tokens"], 4)
212
+
213
+
214
+ def cmd_serve(args, target):
215
+ if not target:
216
+ raise SystemExit("usage: llmperfscope serve [options] -- server command ...")
217
+ options = _options(args)
218
+ options.stop_timeout_s = args.stop_timeout
219
+ preset_name = args.preset or adapters.preset_for(target)
220
+ preset = adapters.SERVER_PRESETS[preset_name]
221
+ options.stop_signal = preset.get("stop_signal", "TERM")
222
+ if not args.url:
223
+ args.url = adapters.guess_url(target, preset_name)
224
+ for extra in preset.get("extra_args", []):
225
+ if extra not in target:
226
+ target = target + [extra]
227
+ if preset_name == "ollama" and not args.model:
228
+ raise SystemExit("Ollama starts with no model: name one with --model, for example --model qwen3:0.6b --pull")
229
+ print(f"[llmperfscope] server preset {preset_name}, expecting it at {args.url}")
230
+ knows_load_lines = adapters.select(options.adapter, target).name == "llama.cpp:llama-server"
231
+ run = runner.Run(target, options, workload=_serve_workload(args, preset, marks_load=not knows_load_lines))
232
+ try:
233
+ result = run.execute()
234
+ except runner.Refused as e:
235
+ print(f"[llmperfscope] refused: {e}", file=sys.stderr)
236
+ return 3
237
+ _add_energy_per_token(result)
238
+ with open(Path(options.out) / "run.json", "w") as f:
239
+ json.dump(result, f, indent=1)
240
+ return _finish(result, options)
241
+
242
+
243
+ def _docker_pid(name):
244
+ done = subprocess.run(["docker", "inspect", "-f", "{{.State.Pid}}", name], capture_output=True, text=True, check=False)
245
+ if done.returncode != 0 or not done.stdout.strip().isdigit():
246
+ raise SystemExit(f"no running container {name!r}: {done.stderr.strip()}")
247
+ return int(done.stdout.strip())
248
+
249
+
250
+ def cmd_attach(args, _target):
251
+ """Profile something already running, such as a container. Load phases are gone, everything after is measured."""
252
+ pid = args.pid or (_docker_pid(args.docker) if args.docker else None)
253
+ if not pid:
254
+ raise SystemExit("attach needs --pid or --docker")
255
+ options = _options(args)
256
+ options.stall_s = 0
257
+ if args.url:
258
+ preset = adapters.SERVER_PRESETS[args.preset or "openai"]
259
+ workload = _serve_workload(args, preset, marks_load=False)
260
+ else:
261
+ def workload(run):
262
+ run.emit("observe")
263
+ time.sleep(args.duration)
264
+ return {}
265
+ run = runner.Run([f"/proc/{pid}/exe"], options, workload=workload, attach_pid=pid)
266
+ try:
267
+ result = run.execute()
268
+ except runner.Refused as e:
269
+ print(f"[llmperfscope] refused: {e}", file=sys.stderr)
270
+ return 3
271
+ _add_energy_per_token(result)
272
+ with open(Path(options.out) / "run.json", "w") as f:
273
+ json.dump(result, f, indent=1)
274
+ return _finish(result, options)
275
+
276
+
277
+ def _serve_args(parser):
278
+ parser.add_argument("--url", help="server address (default: from --port, a container's published port, or the engine's usual port)")
279
+ parser.add_argument("--pull", action="store_true", help="Ollama: download --model first; this time is its own phase")
280
+ parser.add_argument("--preset", choices=sorted(adapters.SERVER_PRESETS), help="default: guessed from the command")
281
+ parser.add_argument("--model", help="model name for requests (default: first of /v1/models)")
282
+ parser.add_argument("--prompts", help="JSONL with `prompt` or `messages` per row")
283
+ parser.add_argument("--filter", action="append", help="keep rows where column=value")
284
+ parser.add_argument("--limit", type=int, help="at most this many prompts")
285
+ parser.add_argument("--synthetic-words", type=int, default=512, help="length of generated prompts when --prompts is not given")
286
+ parser.add_argument("--concurrency", default="1", help="comma-separated levels, each runs the whole prompt list")
287
+ parser.add_argument("--repeat", type=int, default=1)
288
+ parser.add_argument("--seed", type=int, default=0)
289
+ parser.add_argument("--max-tokens", type=int, default=64)
290
+ parser.add_argument("--allow-eos", action="store_true", help="let the model stop early (default: fixed output length)")
291
+ parser.add_argument("--reuse-prompts", action="store_true",
292
+ help="send prompts unchanged, to measure a server's prefix cache (default: a short nonce in front defeats it)")
293
+ parser.add_argument("--extra-body", help="JSON merged into every request")
294
+ parser.add_argument("--api-key", help="bearer token (never stored in the report)")
295
+ parser.add_argument("--ready-path", help="health path (default from the preset)")
296
+ parser.add_argument("--ready-timeout", type=float, default=3600.0)
297
+ parser.add_argument("--metrics-path", help="Prometheus path (default from the preset, empty to turn off)")
298
+ parser.add_argument("--stop-timeout", type=float, default=120.0, help="seconds between the stop signal and SIGKILL")
299
+
300
+
301
+ def cmd_show(args, _target):
302
+ print(report.render(compare.load_run(args.run)))
303
+ return 0
304
+
305
+
306
+ def cmd_html(args, _target):
307
+ print(html.write(args.run))
308
+ return 0
309
+
310
+
311
+ def _figure_style(args):
312
+ return figures.Style(width=figures.parse_width(args.width), height=args.height or 0.0, formats=tuple(args.format.split(",")), dpi=args.dpi,
313
+ font={"sans": "sans-serif", "serif": "serif"}.get(args.font, args.font), font_size=args.font_size, print_safe=args.print_safe,
314
+ title=args.title or "", letters=args.letters)
315
+
316
+
317
+ def cmd_figure(args, _target):
318
+ style = _figure_style(args)
319
+ if args.matrix:
320
+ written, skipped = figures.make_matrix_figure(args.matrix, args.out or str(Path(args.matrix).parent / "figures"), style)
321
+ elif args.compare:
322
+ specs = []
323
+ for item in args.compare:
324
+ label, sep, path = item.partition("=")
325
+ if not sep:
326
+ raise SystemExit(f"--compare needs LABEL=RUN_DIR, got {item!r}")
327
+ specs.append((label, path))
328
+ kinds = figures.COMPARE_KINDS if args.kind == "all" else tuple(args.kind.split(","))
329
+ unknown = set(kinds) - set(figures.COMPARE_KINDS)
330
+ if unknown:
331
+ raise SystemExit(f"unknown --kind {sorted(unknown)}; with --compare choose from {list(figures.COMPARE_KINDS)}")
332
+ written, skipped = figures.make_compare_figures(specs, args.out or "figures-compare", style, kinds)
333
+ else:
334
+ if not args.run:
335
+ raise SystemExit("usage: llmperfscope figure RUN_DIR, or --compare LABEL=RUN_DIR ..., or --matrix matrix.json")
336
+ kinds = figures.RUN_KINDS if args.kind == "all" else tuple(args.kind.split(","))
337
+ unknown = set(kinds) - set(figures.RUN_KINDS)
338
+ if unknown:
339
+ raise SystemExit(f"unknown --kind {sorted(unknown)}; choose from {list(figures.RUN_KINDS)}")
340
+ panels = tuple(args.panels.split(",")) if args.panels else figures.DEFAULT_PANELS
341
+ unknown = set(panels) - set(figures.ALL_PANELS)
342
+ if unknown:
343
+ raise SystemExit(f"unknown --panels {sorted(unknown)}; choose from {list(figures.ALL_PANELS)}")
344
+ xlim = tuple(float(x) for x in args.xlim.split(",")) if args.xlim else None
345
+ written, skipped = figures.make_run_figures(args.run, args.out or str(Path(args.run) / "figures"), style, kinds, panels, args.per_gpu, xlim)
346
+ for path in written:
347
+ print(path)
348
+ if skipped:
349
+ print(f"[llmperfscope] nothing to draw for: {', '.join(skipped)} (gpu-time needs a run with --nsys, hotspots --cpu-profile, serving `llmperfscope serve`)", file=sys.stderr)
350
+ return 0
351
+
352
+
353
+ def cmd_anonymize(args, _target):
354
+ print(share.anonymize(args.run, args.out))
355
+ return 0
356
+
357
+
358
+ def cmd_diff(args, _target):
359
+ runs_a = [compare.load_run(p) for p in args.a]
360
+ runs_b = [compare.load_run(p) for p in args.b]
361
+ print(compare.render_diff(compare.diff(runs_a, runs_b), args.a_name, args.b_name))
362
+ return 0
363
+
364
+
365
+ def cmd_matrix(args, _target):
366
+ def side(value):
367
+ name, sep, path = value.partition("=")
368
+ if not sep:
369
+ raise SystemExit(f"expected NAME=BIN_DIR, got {value!r}")
370
+ return name, path
371
+
372
+ noise = None
373
+ if args.noise:
374
+ with open(args.noise) as f:
375
+ noise = json.load(f)
376
+ table = compare.run_matrix(compare.load_cases(args.cases), side(args.a), side(args.b), args.out, order=args.order,
377
+ timeout_s=args.timeout, a_env=_parse_env(args.a_env), b_env=_parse_env(args.b_env), only=args.only,
378
+ noise=noise)
379
+ print(Path(args.out, "table.md").read_text())
380
+ worse = [c["label"] for c in table["cases"] if c.get("verdict") in ("regression", "tradeoff")]
381
+ if worse:
382
+ print("worse in some metric: " + ", ".join(worse))
383
+ return 1 if worse else 0
384
+
385
+
386
+ def cmd_noise(args, _target):
387
+ floors = compare.noise_from_null(args.matrix)
388
+ with open(args.out, "w") as f:
389
+ json.dump(floors, f, indent=1)
390
+ print(f"noise floors for {len(floors)} cases written to {args.out}")
391
+ return 0
392
+
393
+
394
+ def cmd_nsys_report(args, _target):
395
+ print(json.dumps(nsys.analyze(args.sqlite, comm_regex=args.comm_regex), indent=1))
396
+ return 0
397
+
398
+
399
+ def cmd_check(_args, _target):
400
+ print(capabilities.render(capabilities.collect()))
401
+ return 0
402
+
403
+
404
+ def build_parser():
405
+ parser = argparse.ArgumentParser(prog="llmperfscope", description="Whole-lifecycle profiler for LLM inference engines.")
406
+ parser.add_argument("--version", action="version", version=__version__)
407
+ sub = parser.add_subparsers(dest="command", required=True)
408
+
409
+ p = sub.add_parser("run", help="profile any command: llmperfscope run -- command ...")
410
+ _common(p)
411
+ p.set_defaults(func=cmd_run)
412
+
413
+ p = sub.add_parser("serve", help="start an OpenAI-compatible server, load it, stop it, profile the whole life")
414
+ _common(p)
415
+ _serve_args(p)
416
+ p.set_defaults(func=cmd_serve)
417
+
418
+ p = sub.add_parser("attach", help="profile a running process or container")
419
+ _common(p)
420
+ _serve_args(p)
421
+ p.add_argument("--pid", type=int)
422
+ p.add_argument("--docker", help="container name")
423
+ p.add_argument("--duration", type=float, default=60.0, help="seconds to observe when no --url is given")
424
+ p.set_defaults(func=cmd_attach)
425
+
426
+ p = sub.add_parser("show", help="print the report of a run")
427
+ p.add_argument("run")
428
+ p.set_defaults(func=cmd_show)
429
+
430
+ p = sub.add_parser("html", help="write the charts of a run to report.html")
431
+ p.add_argument("run")
432
+ p.set_defaults(func=cmd_html)
433
+
434
+ p = sub.add_parser("figure", help="figures for papers: vector PDF and SVG, high-resolution PNG, the data as CSV, suggested captions")
435
+ p.add_argument("run", nargs="?", help="run directory")
436
+ p.add_argument("--compare", nargs="+", metavar="LABEL=RUN_DIR", help="compare runs against each other")
437
+ p.add_argument("--matrix", help="a matrix.json from `llmperfscope matrix`")
438
+ p.add_argument("--kind", default="all", help=f"comma-separated. For a run: {', '.join(figures.RUN_KINDS)}. With --compare: {', '.join(figures.COMPARE_KINDS)}")
439
+ p.add_argument("--out", help="output directory (default: <run>/figures)")
440
+ p.add_argument("--format", default="pdf,png,svg", help="comma-separated formats")
441
+ p.add_argument("--width", default="double", help="single (3.5 in), double (7.16 in), or inches")
442
+ p.add_argument("--height", type=float, help="inches (default: fits the content)")
443
+ p.add_argument("--dpi", type=int, default=300, help="for PNG")
444
+ p.add_argument("--font", default="sans", help="sans, serif, or a font name")
445
+ p.add_argument("--font-size", type=float, default=7.5, help="points")
446
+ p.add_argument("--print-safe", action="store_true", help="add line styles, for grayscale print")
447
+ p.add_argument("--letters", action="store_true", help="label panels (a), (b), ...")
448
+ p.add_argument("--title", help="figure title (default: none, papers use the caption)")
449
+ p.add_argument("--panels", help=f"lifecycle panels, comma-separated: {', '.join(figures.ALL_PANELS)}")
450
+ p.add_argument("--per-gpu", action="store_true", help="one line per GPU in lifecycle panels")
451
+ p.add_argument("--xlim", help="lifecycle time range in seconds: START,END")
452
+ p.set_defaults(func=cmd_figure)
453
+
454
+ p = sub.add_parser("anonymize", help="a copy of a run without host and user names, paths, serial numbers or prompts, to share or publish")
455
+ p.add_argument("run")
456
+ p.add_argument("--out", required=True)
457
+ p.set_defaults(func=cmd_anonymize)
458
+
459
+ p = sub.add_parser("diff", help="compare finished runs")
460
+ p.add_argument("--a", nargs="+", required=True)
461
+ p.add_argument("--b", nargs="+", required=True)
462
+ p.add_argument("--a-name", default="A")
463
+ p.add_argument("--b-name", default="B")
464
+ p.set_defaults(func=cmd_diff)
465
+
466
+ p = sub.add_parser("matrix", help="A/B across many cases, alternating order, machine re-checked before every arm")
467
+ p.add_argument("--cases", required=True)
468
+ p.add_argument("--a", required=True, help="NAME=BIN_DIR")
469
+ p.add_argument("--b", required=True, help="NAME=BIN_DIR")
470
+ p.add_argument("--a-env", action="append")
471
+ p.add_argument("--b-env", action="append")
472
+ p.add_argument("--order", default="ABBA")
473
+ p.add_argument("--only", help="regex on case labels")
474
+ p.add_argument("--noise", help="floors from `llmperfscope noise`")
475
+ p.add_argument("--timeout", type=float, default=1800.0)
476
+ p.add_argument("--out", required=True)
477
+ p.set_defaults(func=cmd_matrix)
478
+
479
+ p = sub.add_parser("noise", help="noise floors from a null matrix (same binary on both sides)")
480
+ p.add_argument("matrix")
481
+ p.add_argument("--out", required=True)
482
+ p.set_defaults(func=cmd_noise)
483
+
484
+ p = sub.add_parser("nsys-report", help="analyze an exported nsys sqlite file")
485
+ p.add_argument("sqlite")
486
+ p.add_argument("--comm-regex", default=nsys.DEFAULT_COMM_REGEX)
487
+ p.set_defaults(func=cmd_nsys_report)
488
+
489
+ p = sub.add_parser("check", help="what can be measured on this machine")
490
+ p.set_defaults(func=cmd_check)
491
+ return parser
492
+
493
+
494
+ def main(argv=None):
495
+ argv = list(sys.argv[1:] if argv is None else argv)
496
+ argv, target = _split_command(argv)
497
+ args = build_parser().parse_args(argv)
498
+ return args.func(args, target)
499
+
500
+
501
+ if __name__ == "__main__":
502
+ sys.exit(main())