File size: 21,923 Bytes
77e3c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d160c10
77e3c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d160c10
77e3c67
d160c10
 
 
77e3c67
d160c10
 
77e3c67
 
 
 
d160c10
77e3c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d160c10
77e3c67
 
 
d160c10
 
77e3c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d160c10
 
 
 
 
 
77e3c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Stage bench of warm forwards: the numbers OPT_BASELINE.md, OPT_REPORT.md and the card quote.

    bin/devrun -t 1800 -- python code/scripts/bench.py --iters 100 --json logs/bevdet/bench_eth_1cq.json
    bin/devrun -t 3600 -- python code/scripts/bench.py --dispatch worker --num-cqs 2 --json ...   # the D14 matrix

Each input is a short frame sequence cycled through ONE stream (every frame pushes the BEV history ring, as in
steady operation) after 6 warm frames:

- ``synthetic[=preset]``: six 1600x900 camera frames made by ``tt_bevdet.tt.warmup.synthetic_images`` (data
  generated by us, the node's native size: no resize) with a calibration preset (default ``synthetic_6cam``, the
  shipped sample's rig; OPT_BASELINE.md of 2026-10-08 used ``pandaset_019``, which now waits in the git-ignored
  ``staging_samples_pandaset/calib``), three seeds, Autoware semantics (identity ego pose: the AlignBEV grid never
  changes, so it is never re-uploaded);
- ``pandaset[=dir]``: ``pandaset_019_f{38,39,40}.json`` from ``dir``, ``samples/`` or the git-ignored
  ``staging_samples_pandaset/samples`` (1920x1080: the node's ``cv::resize`` to 1600x900 runs on the host), Autoware
  semantics;
- ``sample=<manifest>[+<manifest>...]``: any sample manifest(s) of ``load_sample`` (e.g. the shipped sample);
- ``nuscenes=<manifest>``: key-frames of a ``research/bevdet/public_data`` manifest (nuScenes v1.0-mini, CC
  BY-NC-SA 4.0: local measurements only, never shipped), 1600x900, with the manifest's ego poses
  (``stream.T_world_from_ego``: a new AlignBEV grid is uploaded every frame).

Images are decoded once before timing (Autoware hands the node decoded images), so ``host_pre`` is the node
transport + Preprocess gather + normalisation + NHWC rows + the per-calibration cache lookups + the temporal plan.

Two loops per input (``ttaw.profiling.StageBench``; p50 / p99 / mean / min / max in ms):

1. **instrumented**: one ``model(...)`` call split into synchronized stages by wrapping the model's host hooks and the
   ``TraceRunner`` I/O: ``host_pre``; ``host_in.encode`` (numpy -> ttnn host tensors) and ``h2d.encode`` (enqueue +
   synchronize); ``trace.encode`` (one replay + synchronize); ``d2h.encode`` (device -> host copy) and
   ``host_out.encode`` (TILE -> numpy conversion); ``bevpool`` (host BEVPool + history grid + plan); the same four
   for ``decode``; ``trace.push`` / ``trace.fill`` (ring update); ``host_post`` (decode + scale-NMS + node filter);
   ``e2e_instrumented``.
2. **plain**: ``model(...)`` exactly as a client calls it (no extra synchronization): ``e2e`` and the API's own
   ``timing_ms`` (``api.preprocess`` ... ``api.total``).

Device-only, once per process (input independent): ``replay.<variant>`` one blocking replay; ``b2b.<variant>``
back-to-back replays per replay; ``b2b.frame`` = encode + decode + push back to back per frame (device-bound
throughput). AICLK / power / temperature are sampled during the loops (``AiclkSampler``), and an agreement check of
PandaSet f38-40 against the stored CPU-reference boxes (``tests/goldens``, ``host.agreement``) is run first, so every
dispatch / CQ configuration's numbers come with its accuracy.
"""
from __future__ import annotations

import argparse
import json
import os
import platform
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Tuple

import numpy as np

HERE = Path(__file__).resolve()
CODE = HERE.parents[1]
sys.path.insert(0, str(CODE))
SAMPLES = CODE / "tt_bevdet" / "samples"
GOLDENS = CODE / "tt_bevdet" / "tests" / "goldens"
ROOT = Path(os.environ.get("TT_MODELS_ROOT", "/home/ubuntu/experiments/tt-models"))
DEFAULT_NUSCENES = ROOT / "research/bevdet/public_data/nuscenes/manifests/port2hz__scene-0103.json"


# ------------------------------------------------------------------------------------------------- inputs
def synthetic_frames(cfg, n: int = 3, preset: str = "") -> List[Dict[str, Any]]:
    from tt_bevdet.host.inputs import CALIB_DIR
    from tt_bevdet.tt.warmup import WARMUP_PRESET, synthetic_images
    from tt_bevdet.ttaw import io as tio

    preset = preset or WARMUP_PRESET
    calib_dir = CALIB_DIR
    if not (CALIB_DIR / f"{preset}.json").is_file() and (CODE.parent / "staging_samples_pandaset" / "calib"
                                                          / f"{preset}.json").is_file():
        calib_dir = CODE.parent / "staging_samples_pandaset" / "calib"
    calib = tio.resolve_calibration({"preset": preset}, calib_dir)
    return [{"images": synthetic_images(cfg, seed=s + 1), "calibration": calib} for s in range(n)]


def pandaset_dir(override: str = "") -> Path:
    """Where the PandaSet 019 f38-40 manifests are: ``override``, else ``samples/``, else the git-ignored
    ``staging_samples_pandaset/samples`` of the bundle (where they wait while they may not ship)."""
    cands = [Path(override)] if override else []
    cands += [SAMPLES, CODE.parent / "staging_samples_pandaset" / "samples"]
    for c in cands:
        if all((c / f"pandaset_019_f{n}.json").is_file() for n in (38, 39, 40)):
            return c
    return cands[0]


def pandaset_frames(override: str = "") -> List[Dict[str, Any]]:
    from tt_bevdet import load_sample

    d = pandaset_dir(override)
    paths = [d / f"pandaset_019_f{n}.json" for n in (38, 39, 40)]
    if not all(p.is_file() for p in paths):
        return []
    return [load_sample(p) for p in paths]


def nuscenes_frames(manifest: Path, n: int = 3) -> List[Dict[str, Any]]:
    """The first ``n`` key-frames of a public-data manifest as decoded requests with the ego pose (no reset flag:
    the cycle is a steady stream)."""
    sys.path.insert(0, str(HERE.parent))
    from run_public_tt import frame_request
    from tt_bevdet.ttaw import io as tio

    m = json.loads(manifest.read_text())
    out = []
    for f in [f for f in m["frames"] if f.get("key_frame", True)][:n]:
        req = frame_request(m, f, "bench")
        req["images"] = {k: tio.load_image(v) for k, v in req["images"].items()}
        stream = {k: v for k, v in req.pop("stream").items() if k == "T_world_from_ego"}
        req["_stream"] = stream
        out.append(req)
    return out


def describe_frames(frames: List[Dict[str, Any]]) -> Dict[str, Any]:
    shapes = set()
    for fr in frames:
        imgs = fr["images"]
        vals = imgs.values() if isinstance(imgs, dict) else imgs
        for v in vals:
            a = getattr(v, "image", v)
            shapes.add(tuple(int(s) for s in np.asarray(a).shape))
    return {"frames": len(frames), "image_shapes": sorted(shapes)}


# ------------------------------------------------------------------------------------------- instrumentation
class Probe:
    """Splits one ``model(...)`` call into synchronized stages (see the module docstring). Installed on the model
    instance and its runner (instance attributes shadow the methods); ``active`` switches the timing on."""

    def __init__(self, model, bench):
        import ttnn

        import tt_bevdet.ttaw.trace as trace_mod

        self.model, self.bench, self.active = model, bench, False
        self._seg = "encode"
        r, dev = model.runner, model.device
        sync = lambda: ttnn.synchronize_device(dev)  # noqa: E731
        orig_collect, orig_enqueue, orig_execute, orig_read = (r._collect_uploads, r._enqueue_uploads, r._execute,
                                                               r.read)
        orig_prepare, orig_post = model._prepare, model._postprocess
        orig_to_numpy = trace_mod.to_numpy
        self._restore = [(r, "_collect_uploads"), (r, "_enqueue_uploads"), (r, "_execute"), (r, "read"),
                         (model, "_prepare"), (model, "_postprocess")]
        self._trace_mod, self._orig_to_numpy = trace_mod, orig_to_numpy
        self._conv_ms = 0.0

        def timed(name, fn, *a, **k):
            if not self.active:
                return fn(*a, **k)
            t0 = time.perf_counter()
            out = fn(*a, **k)
            self.bench.add(name, (time.perf_counter() - t0) * 1e3)
            return out

        def collect(inputs, params):
            self._seg = "encode" if inputs and ("img" in inputs or "crop" in inputs) else "decode"
            return timed(f"host_in.{self._seg}", orig_collect, inputs, params)

        def enqueue(uploads):
            def run():
                orig_enqueue(uploads)
                sync()
            return timed(f"h2d.{self._seg}", run)

        def execute(trace):
            def run():
                orig_execute(trace)
                sync()
            return timed(f"trace.{trace.variant}", run)

        def to_numpy(*a, **k):
            t0 = time.perf_counter()
            out = orig_to_numpy(*a, **k)
            self._conv_ms += (time.perf_counter() - t0) * 1e3
            return out

        def read(variant=None, **kw):
            if not self.active:
                return orig_read(variant, **kw)
            self._conv_ms = 0.0
            t0 = time.perf_counter()
            out = orig_read(variant, **kw)
            total = (time.perf_counter() - t0) * 1e3
            self.bench.add(f"d2h.{variant}", total - self._conv_ms)
            self.bench.add(f"host_out.{variant}", self._conv_ms)
            return out

        r._collect_uploads, r._enqueue_uploads, r._execute, r.read = collect, enqueue, execute, read
        model._prepare = lambda *a, **k: timed("host_pre", orig_prepare, *a, **k)
        model._postprocess = lambda *a, **k: timed("host_post", orig_post, *a, **k)
        trace_mod.to_numpy = to_numpy

    def remove(self) -> None:
        for obj, name in self._restore:
            obj.__dict__.pop(name, None)
        self._trace_mod.to_numpy = self._orig_to_numpy


# ---------------------------------------------------------------------------------------------- environment
def environment() -> Dict[str, Any]:
    env: Dict[str, Any] = {"host": platform.node(), "python": platform.python_version(), "loadavg_start": os.getloadavg(),
                           "cpus": os.cpu_count(), "omp_num_threads": os.environ.get("OMP_NUM_THREADS")}
    try:
        env["cpu_model"] = next(line.split(":", 1)[1].strip() for line in open("/proc/cpuinfo")
                                if line.startswith("model name"))
    except (OSError, StopIteration):
        pass
    gov = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
    env["cpu_governor"] = gov.read_text().strip() if gov.is_file() else None
    tm = ROOT / "tt-metal"
    try:
        env["tt_metal_commit"] = subprocess.run(["git", "-C", str(tm), "rev-parse", "HEAD"], capture_output=True,
                                                text=True, env=dict(os.environ, GIT_OPTIONAL_LOCKS="0")).stdout.strip()
    except OSError:
        pass
    topo = tm / "tt_metal/impl/dispatch/topology.cpp"
    env["eth_patch_marker"] = topo.is_file() and "single_chip_arch_1cq_no_dispatch_s" in topo.read_text()
    try:
        from tt_bevdet.ttaw import __version__ as ttaw_version

        env["ttaw"] = ttaw_version
        env["ttaw_vendored"] = json.loads((CODE / "tt_bevdet/ttaw/VENDORED.json").read_text()).get("source_commit")
    except (ImportError, OSError, ValueError):
        pass
    try:
        env["bundle_commit"] = subprocess.run(["git", "-C", str(CODE.parent), "rev-parse", "HEAD"],
                                              capture_output=True, text=True,
                                              env=dict(os.environ, GIT_OPTIONAL_LOCKS="0")).stdout.strip()
        env["bundle_dirty"] = bool(subprocess.run(["git", "-C", str(CODE.parent), "status", "--porcelain", "code"],
                                                  capture_output=True, text=True,
                                                  env=dict(os.environ, GIT_OPTIONAL_LOCKS="0")).stdout.strip())
    except OSError:
        pass
    return env


# ------------------------------------------------------------------------------------------------- measure
def agreement_check(model) -> Dict[str, Any]:
    """PandaSet f38-40 through a fresh stream vs the stored CPU-reference boxes (the e2e gate's definition)."""
    from tt_bevdet.host.agreement import agreement, vendor_boxes

    from tt_bevdet.tests import paths

    gold = paths.pandaset_golden() or GOLDENS / paths.PANDASET_GOLDEN
    frames = pandaset_frames()
    if not frames or not gold.is_file():
        return {"skipped": "PandaSet sample or its goldens not in the bundle"}
    rec = json.loads(gold.read_text())
    res = {}
    for n, req in zip((38, 39, 40), frames):
        out = model(**req, stream={"id": "bench-agreement"}, score_threshold=0.0)
        a = agreement(vendor_boxes(out), rec["frames"][f"f{n}"]["boxes"])
        res[f"f{n}"] = {k: (round(v, 5) if isinstance(v, float) else v) for k, v in a.items() if k != "score_errs"}
    return res


def bench_input(model, name: str, frames: List[Dict[str, Any]], iters: int, warm: int) -> Dict[str, Any]:
    from tt_bevdet.ttaw.profiling import AiclkSampler, StageBench

    sid = f"bench-{name}"

    def call(i: int):
        fr = dict(frames[i % len(frames)])
        stream = dict(fr.pop("_stream", {}), id=sid)
        return model(**fr, stream=stream)

    for i in range(warm):
        call(i)
    inst = StageBench(f"{name} instrumented")
    probe = Probe(model, inst)
    try:
        probe.active = True
        with AiclkSampler(interval_s=0.05) as clk_i:
            for i in range(iters):
                t0 = time.perf_counter()
                call(warm + i)
                inst.add("e2e_instrumented", (time.perf_counter() - t0) * 1e3)
        probe.active = False
    finally:
        probe.remove()
    plain = StageBench(f"{name} plain")
    ndet = []
    with AiclkSampler(interval_s=0.05) as clk_p:
        for i in range(iters):
            t0 = time.perf_counter()
            out = call(warm + iters + i)
            plain.add("e2e", (time.perf_counter() - t0) * 1e3)
            for k, v in out.timing_ms.items():
                plain.add(f"api.{k}", v)
            ndet.append(len(out.scores))
    return {"input": name, **describe_frames(frames), "iters": iters, "warm": warm,
            "instrumented": inst.summary(), "plain": plain.summary(),
            "detections_per_frame": {"min": int(min(ndet)), "max": int(max(ndet)), "mean": float(np.mean(ndet))},
            "aiclk_instrumented": clk_i.summary(), "aiclk_plain": clk_p.summary(), "loadavg": os.getloadavg()}


def device_only(model, iters: int, b2b: int) -> Dict[str, Any]:
    """Replays only (no host work between them, no uploads): one blocking replay, back-to-back replays, and the
    frame sequence encode + decode + push back to back. The AlignBEV grid is set to the identity of a full history
    (Autoware mode, every slot a bit-exact copy); when the last input uploaded another grid (the nuScenes ego-pose
    frames), ``decode`` and the frame are measured again with that grid (``*_moving``): grid_sample's cost depends
    on the sampling positions."""
    import ttnn

    from tt_bevdet.tt.bev_fusion import RING_SLOTS, history_grid
    from tt_bevdet.ttaw.profiling import AiclkSampler, StageBench, time_b2b

    r, dev = model.runner, model.device
    sync = lambda: ttnn.synchronize_device(dev)  # noqa: E731
    slot = r._slot("grid", "param")
    last = None if slot.last_value is None else np.frombuffer(slot.last_value, np.float32).reshape(slot.shape).copy()
    identity = history_grid(np.tile(np.array([1, 0, 0, 0, 1, 0], np.float32), (RING_SLOTS, 1)),
                            list(range(RING_SLOTS)), 1, model.cfg.bev_hw)
    moving = None if last is None or np.array_equal(last, identity) else last
    sb = StageBench("device only")
    mv = "decode_move" if getattr(model.tt, "tile_ring", None) is not None else "decode"   # BEVDET_RING=tile

    after = getattr(model.tt, "after_variant", lambda v, d: v)       # push_x after an identity decode (R4.3)

    def frame(dec="decode"):
        r.replay("encode")
        r.replay(dec)
        r.replay(after("push", dec))

    with AiclkSampler(interval_s=0.05) as clk:
        r.upload(params={"grid": identity})
        sync()
        for v in ("encode", "decode", "push", "fill") + (("push_x",) if getattr(model.tt, "push_x", False) else ()):
            r.replay(v)
            sync()
            for _ in range(iters):
                t0 = time.perf_counter()
                r.replay(v)
                sync()
                sb.add(f"replay.{v}", (time.perf_counter() - t0) * 1e3)
            for _ in range(3):
                sb.add(f"b2b.{v}", time_b2b(lambda: r.replay(v), sync, n=b2b, warmup=2))
        for _ in range(3):
            sb.add("b2b.frame", time_b2b(frame, sync, n=b2b, warmup=2))
        if moving is not None:
            r.upload(params={"grid": moving})
            sync()
            for _ in range(iters):
                t0 = time.perf_counter()
                r.replay(mv)
                sync()
                sb.add("replay.decode_moving", (time.perf_counter() - t0) * 1e3)
            for _ in range(3):
                sb.add("b2b.decode_moving", time_b2b(lambda: r.replay(mv), sync, n=b2b, warmup=2))
                sb.add("b2b.frame_moving", time_b2b(lambda: frame(mv), sync, n=b2b, warmup=2))
    return {"stages_ms": sb.summary(), "aiclk": clk.summary(), "moving_grid": moving is not None}


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("--iters", type=int, default=100, help="timed frames per loop and input (>= 50)")
    ap.add_argument("--warm", type=int, default=6)
    ap.add_argument("--b2b", type=int, default=50, help="back-to-back replays per measurement")
    ap.add_argument("--inputs", default="synthetic,pandaset",
                    help="comma list of synthetic, pandaset[=dir], nuscenes[=manifest], sample=<manifest>[+<manifest>...] "
                         f"(default nuScenes manifest: {DEFAULT_NUSCENES})")
    ap.add_argument("--dispatch", default=None, choices=[None, "eth", "worker"])
    ap.add_argument("--num-cqs", type=int, default=None)
    ap.add_argument("--weights-dir", default=None)
    ap.add_argument("--no-agreement", action="store_true")
    ap.add_argument("--json")
    a = ap.parse_args()
    env = environment()
    from tt_bevdet import BEVDet

    t0 = time.perf_counter()
    model = BEVDet.from_pretrained(dispatch=a.dispatch, num_command_queues=a.num_cqs, weights_dir=a.weights_dir)
    res: Dict[str, Any] = {"env": env}
    try:
        res.update({"config": model.device_info, "knobs": model.knobs.as_dict(),
                    "load_s": round(time.perf_counter() - t0, 2),
                    "warmup_ms": {k: round(v, 1) for k, v in model.warmup_ms.items()},
                    "program_cache_entries": getattr(model.device, "num_program_cache_entries", lambda: None)()})
        print("config:", json.dumps(res["config"]), "| knobs:", json.dumps(res["knobs"]), flush=True)
        res["conv_placement"] = model.tt.encoder.placements()
        res["bottleneck_tails"] = model.tt.encoder.tails()
        if res["conv_placement"]:
            from collections import Counter
            print("conv placement:", dict(Counter(res["conv_placement"].values())),
                  {k: v for k, v in res["conv_placement"].items() if v != "l1"}, flush=True)
        if not a.no_agreement:
            res["agreement_pandaset_f38_40"] = agreement_check(model)
            print("agreement:", json.dumps(res["agreement_pandaset_f38_40"]), flush=True)
        inputs: List[Tuple[str, List[Dict[str, Any]]]] = []
        for spec in [s.strip() for s in a.inputs.split(",") if s.strip()]:
            if spec.split("=", 1)[0] == "synthetic":
                inputs.append(("synthetic", synthetic_frames(model.cfg, preset=spec.partition("=")[2])))
            elif spec.split("=", 1)[0] == "pandaset":
                fr = pandaset_frames(spec.split("=", 1)[1] if "=" in spec else "")
                if fr:
                    inputs.append(("pandaset", fr))
                else:
                    print("skip pandaset: pandaset_019_f38-40.json not found", flush=True)
            elif spec.startswith("sample="):
                from tt_bevdet import load_sample

                paths = [Path(x) for x in spec.split("=", 1)[1].split("+")]
                inputs.append((paths[0].stem, [load_sample(x) for x in paths]))
            elif spec.startswith("nuscenes"):
                man = Path(spec.split("=", 1)[1]) if "=" in spec else DEFAULT_NUSCENES
                if man.is_file():
                    inputs.append(("nuscenes", nuscenes_frames(man)))
                else:
                    print(f"skip nuscenes: {man} not found", flush=True)
            else:
                raise SystemExit(f"unknown input {spec!r}")
        res["inputs"] = {}
        for name, frames in inputs:
            r = bench_input(model, name, frames, a.iters, a.warm)
            res["inputs"][name] = r
            p, i = r["plain"], r["instrumented"]
            print(f"[{name}] e2e p50 {p['e2e']['p50']:.1f} / p99 {p['e2e']['p99']:.1f} ms | "
                  + " | ".join(f"{k} {v['p50']:.2f}" for k, v in i.items()), flush=True)
        res["device_only"] = device_only(model, a.iters, a.b2b)
        print("device only:", " | ".join(f"{k} p50 {v['p50']:.3f}" for k, v in
                                          res["device_only"]["stages_ms"].items()), flush=True)
        res["trace"] = model.runner.describe()
        res["env"]["loadavg_end"] = os.getloadavg()
    finally:
        model.close()
    if a.json:
        Path(a.json).parent.mkdir(parents=True, exist_ok=True)
        Path(a.json).write_text(json.dumps(res, indent=1, default=str) + "\n")
        print("wrote", a.json)
    return 0


if __name__ == "__main__":
    sys.exit(main())