diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..a6259bbdf9a8707cf13acd47485be0a0369fa4fa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,22 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/263c3393dbadf7dc0379d0fc3f574de2e1bf1f288d4cf6d258c862eb309fed88 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/2d8b1b67a1bfa51712efa01fd56489f97766c9d4439440d132b211edfefea304 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/2fcc025d0fe89554778cf89bcb74f1084552925ef59ab8520a3e20034f91cfe7 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/4d1d911054857424c0bbb8ffeac532a517210d4ce9dd8b458c995a934dc16c95 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/60918f793df50b25c4d69e6063db590866f48304ceb84738f3588a4ea3af1d39 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/7f8e824c081d26d72fc21097753f339930fd6fa33ee4f6e4f79ebdcfae6bb4a9 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/8a364a911fa5ef61e168db9466a9c1916109d07dd609a66102a077600a7928ce filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/955c696fdd4076cc41ef9b404f0f248af208a7058c33f685360e8ad5a8a7e96f filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/98c4455a98982b35380ec62f90d5cf0edff02eeb4b3bb18595d871fdeb0c0c67 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/9e742f886b889d3b324cb9e64252ac7b15c41dc59b900afea699edefda392ddc filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/ac4a0c0abcd00417126e1fdeb179cd923c8bd292a1c7a8b3091cdbfd79cf1a59 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/bffe9990635d2390c86310d6135a1f2fb109a288c2678108e6981ee0997f8d71 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/c241aa41a5566f12cb0d8c8d32dc73b0580c202137bdd5e6dd934fa7c51a13a4 filter=lfs diff=lfs merge=lfs -text +image/blobs/sha256/d325fca6b89f9da7c9aec617b11eeae4e04520f0fb771d27e965e27ac7b660fb filter=lfs diff=lfs merge=lfs -text +media/frnet_ot128_nuscenes_scene-0103_20_tt_NC.png filter=lfs diff=lfs merge=lfs -text +media/frnet_ot128_pandaset_019_40_tt.png filter=lfs diff=lfs merge=lfs -text +media/frnet_ot128_pandaset_019_40_tt_vs_cpu.png filter=lfs diff=lfs merge=lfs -text +media/frnet_ot128_pandaset_019_bev_tt.gif filter=lfs diff=lfs merge=lfs -text +media/frnet_ot128_pandaset_029_40_tt.png filter=lfs diff=lfs merge=lfs -text diff --git a/OPT_BASELINE.md b/OPT_BASELINE.md new file mode 100644 index 0000000000000000000000000000000000000000..920c15830a69da65e25f08ce57c35195b585cf47 --- /dev/null +++ b/OPT_BASELINE.md @@ -0,0 +1,557 @@ +# frnet-p150 baseline on the p150 (before optimization) + +Date 2026-10-09. The model code is the port's final commit `14fa968` (M2-M4; VERIFY_PORT round 1: PASS), unchanged. +The baseline commits add only the measurement scripts (`c246f92`: `code/scripts/bench.py`, `profile_ops.py`, +`profile_summary.py`, `visualizer_report.py`; this commit: `visualizer_report.py --json-dir`) and this file. +tt-metal `44d66500520` + `patches/tt-metal-eth-dispatch.patch`. Weights `AutowareFoundation/lidar_frnet` @ +`a53a1c11b0b` (tag `v2.0`), read from the sha256-pinned local copy through `FRNET_WEIGHTS_DIR`. + +Both serve profiles are measured: **`ot128`** (default; Hesai OT128, 128 × 1024 frustum grid, `N_cap` 160,000 +points, `M_cap` 60,000 cells) and **`qt128`** (Hesai QT128, 128 × 256 grid, `N_cap` 120,000, `M_cap` 60,000). The +device graph has fixed shapes per profile (`N_cap`, `M_cap`). So the point-wise device work does not depend on the +frame's point count, while the host rows and the data-dependent K1 segment reduction do. + +Inputs, batch 1: +- **Synthetic scan** (generated by us, no dataset, Apache-2.0): + - made by `research/frnet/scripts/frnet_preproc_ref.py synthetic_scan` (ray cast against ground, cars, facades, + poles and pedestrians; seed 0; sensor 2 m above the ground) through + `logs/frnet/m5_baseline/scripts/make_bench_inputs.py`. Its fp32 CPU reference (ONNX Runtime on the pinned + deployed ONNX) sits next to it; + - OT128: 512 columns, 53,411 points, 52,613 after the ego crop, no interpolated point, N = M = 52,613; + - QT128: 600 columns, 45,783 points, 41,806 after the crop, N = 69,553 after interpolation, M = 18,926; + - it stands in for the release's synthetic shipped sample (the same generator), which is not built yet. Update at + the release: the shipped `code/tt_frnet/samples/synthetic_.npz` (`code/scripts/make_sample.py`) are + byte-identical to these bench inputs, so the synthetic rows below are the shipped sample's. +- **PandaSet 019 frame 40** (CC BY 4.0 + PandaSet terms), today's `code/tt_frnet/samples/pandaset_019_40.npz`: + - 107,146 points, 106,501 after the ego crop; + - OT128: N = 145,961 after interpolation, M = 40,691; QT128: N = 106,585, M = 6,255. + + The first release moved it to the git-ignored `staging_samples_pandaset/samples/` (not shipped until PandaSet + samples are approved); the commands below name its old path. + +Configuration of every number, unless a row says otherwise: +- ETH dispatch, 1 CQ, 12×10 grid (`device.compute_with_storage_grid_size()`, printed in each log); +- the shipped precision knobs of `tt-model.yaml` serve.env: `FRNET_POINT_TERMS=2` (two-term bf16 point island), + `FRNET_MAP_DTYPE=float32` (fp32 image-branch maps), `FRNET_CONV_TERMS=1`. The job unsets every other `FRNET_*`, so + the code defaults (= the serve.env pins) apply. + +## Environment + +| item | value | +|---|---| +| tt-metal | `44d66500520` (main) + `patches/tt-metal-eth-dispatch.patch` (sha256 `08d0ddf6…`, the same file as the workspace copy) | +| ttaw | 0.20.0, vendored from `common` `89dec49` (`code/tt_frnet/ttaw/VENDORED.json`, `source_dirty: false`). `vendor.py --check`: 0 differences, so no re-vendor was needed | +| device | Blackhole p150b, KMD 2.10.0. ETH dispatch, 1 CQ, 12×10 = 120 cores, `l1_small_size` 64 KiB, `trace_region_size` 256 MiB (8.7 MiB used by OT128, 5.6 MiB by QT128). One trace per process; 227 programs in the program cache (OT128), 174 (QT128) | +| AICLK | 1350 MHz median (min 1343) over 4,568 (OT128) and 3,278 (QT128) samples, one every 50 ms, during the stage benches (`ttaw.profiling.AiclkSampler`, sysfs); the same in every matrix run. Board power median 78 W (max 209 W), ASIC 64.9–71.7 °C. The profiler CSV header reads CHIP_FREQ 1350 MHz | +| numerics | point island (encoder, compression, point stem / fusions / fuse, head): fp32 activations, every linear as three bf16 matmuls (`x_hi W_hi + x_hi W_lo + x_lo W_hi`) with fp32 outputs summed in fp32, HiFi4. Image branch: fp32 weights, bf16 conv operands, fp32 maps (convs write fp32; HardSwish, adds and `addcmul` in fp32), HiFi4 + fp32 dest acc + packer L1 acc. Gathers and resize outputs bf16. The profile shows every matmul and conv as HiFi4 | +| host | AMD EPYC-Rome VM, 8 vCPUs, 90 GB RAM, Linux 6.8. Shared with other agents' jobs: load average 4.1–6.6 during the device job. `OMP_NUM_THREADS=4`. Host rows move with that load; device rows do not (b2b rounds repeat to 0.02 ms) | + +## How to run + +```bash +ROOT=/home/ubuntu/experiments/tt-models; cd $ROOT/bundles/frnet-p150; source $ROOT/bin/tt-env.sh +export PYTHONPATH=$PWD/code:$PYTHONPATH HF_HUB_OFFLINE=1 OMP_NUM_THREADS=4 \ + FRNET_WEIGHTS_DIR=$ROOT/assets/frnet/hf_lidar_frnet # v2.0, sha256-pinned +# accuracy gates (per-module PCC + end-to-end agreement vs the fp32 CPU reference, both profiles), one job +$ROOT/bin/devrun -t 1800 -- env TTAW_GATES_READONLY=1 python -m pytest -p no:cacheprovider -q -s \ + code/tt_frnet/tests/test_pcc_device.py code/tt_frnet/tests/test_e2e_device.py +# stage breakdown (pre split, host staging, H2D split, trace, b2b, D2H split, unsort, post, e2e p50/p99), with a check +# of each served output against its stored CPU reference (..reference.json next to the input) +$ROOT/bin/devrun -t 900 -- python code/scripts/bench.py --variant ot128 --iters 100 --warmup 10 --b2b-iters 50 \ + --b2b-rounds 5 --input --input code/tt_frnet/samples/pandaset_019_40.npz --json .json +# dispatch / CQ matrix: one process per configuration +$ROOT/bin/devrun -t 900 -- python code/scripts/bench.py --variant qt128 --iters 50 --input --dispatch worker --cqs 2 --json .json +# device profile: one eager forward (module / layer signposts) + one traced replay between signposts (1,104 programs: +# above the profiler's default 1,000-op buffer, hence --op-support-count; each section is flushed on its own) +$ROOT/bin/devrun -t 3600 -- python -m tracy -r -p -v --no-web-server --op-support-count 4000 \ + -o $ROOT/generated/profiler/frnet_baseline code/scripts/profile_ops.py --variant ot128 --input +tt-perf-report --start-signpost trace --end-signpost trace_end --tracing-mode +python code/scripts/profile_summary.py profile_summary.json --md profile_summary.md +# ttnn-visualizer memory + graph report of one eager forward (slow: never used for timing; the 7.5 GB graph JSON goes to tmpfs) +$ROOT/bin/devrun -t 2400 -- python code/scripts/visualizer_report.py $ROOT/generated/ttnn_visualizer/frnet_baseline \ + --variant ot128 --input code/tt_frnet/samples/pandaset_019_40.npz --json-dir /dev/shm/frnet_visualizer +ttnn-visualizer --profiler-path $ROOT/generated/ttnn_visualizer/frnet_baseline \ + --performance-path $ROOT/generated/profiler/frnet_baseline/reports/2026_10_09_13_56_53 +``` + +- All device measurements ran in two `bin/devrun` windows on 2026-10-09, every step rc 0 (job script + `logs/frnet/m5_baseline/scripts/job1_all.sh`, environment `env.sh`): + - 13:08:29–13:10:07 UTC: the device suite (step 1); + - 13:26:44–14:04:39 UTC: stage benches, matrix, both profiles and the visualizer report (steps 2–5). + + The first window also started steps 2–5, which failed in 1 s each: the HF cache holds no snapshot of the pinned + revision, so `env.sh` now sets `FRNET_WEIGHTS_DIR` (logs of that attempt: `logs/frnet/m5_baseline/try0/`). +- The OT128 replay is **1,104 programs**, above the device profiler's default buffer of 1,000. The run raises the + buffer with `--op-support-count 4000`, and `profile_ops.py` flushes each section with `ttnn.ReadDeviceProfiler`. + The replay's op sequence matches the eager one op code by op code (1,104 / 1,104; QT128 759 / 759), so no op was + dropped. + +## Accuracy (baseline) + +The device suite ran read-only (`TTAW_GATES_READONLY=1`, reports tagged `_m5`: `logs/frnet/{pcc,e2e,latency}_{ot128,qt128}_m5.json`, +`logs/frnet/m5_baseline/try0/suite_r1.log`): **12 passed** in 94.5 s. The values are bit-identical to PORT_LOG M4 (j6) +and VERIFY_PORT round 1 (the graph is deterministic). The gate JSONs are unchanged (`git status` clean). + +| check | gate | OT128 | QT128 | +|---|---|---|---| +| every gated module tap (PCC vs the fp32 reference, sample, replay outputs) | ≥ 0.999 | min 0.999729 (`point_fusion1`) | min 0.999649 (`attention4`) | +| `pred_probs` PCC / raw-point argmax agreement, sample | ≥ 0.998 / ≥ 98.5 % | 0.999942 / 99.581 % | 0.999844 / 99.295 % | +| public frames (OT128 17, QT128 5): min `pred_probs` PCC | ≥ 0.998 | 0.999688 | 0.999805 | +| public frames: min raw-point argmax agreement | ≥ 98.5 % | **98.907 %** (nuScenes 0916 kf40; margin 0.41 pt) | 99.158 % (PandaSet 029 f40) | +| replay == eager | bit-exact | 45 / 45 taps | 45 / 45 taps | +| CPU reference vs ONNX Runtime on the deployed ONNX (the reference itself is right) | ≥ 0.9999 per tap | ≥ 0.99999999999 (VERIFY_PORT, research venv) | same | + +- **Every bench run also checks its served output** against the stored fp32 CPU reference with the container-smoke + gates (`server/smoke_test.py` `REFERENCE_GATES`); all passed: + - OT128: synthetic labels 99.55 % / filter 99.77 % (max |Δscore| 0.072); PandaSet 99.59 % / 99.76 % (0.109); + - QT128: synthetic 99.62 % / 99.87 % (0.144); PandaSet 99.29 % / 99.62 % (0.150); + - every matrix run (ETH / WORKER × 1 / 2 CQs, both profiles) passes the same check. +- **The binding constraint of the optimization phase is nuScenes 0916 kf40 (OT128): 98.907 % against the 98.5 % + gate.** Every precision-reducing change measured in the port failed it: bf16 maps 98.28 %, an fp32 × fp32 point + island 97.50 % (PORT_LOG M3). See "The precision cost of the policy" below. +- Host suite on the fake ttnn with the new scripts: **112 passed, 13 skipped** (the device files and the + onnxruntime-only test). Log: `logs/frnet/m5_baseline/host_suite.log`. + +## Performance (warm, batch 1, median of 100 iterations) + +Each stage is synchronised on its own, so the rows do not add up to e2e exactly. Values are p50 (p99) in ms; b2b is +the median of 5 rounds of 50 back-to-back replays. `pre` includes the file decode (`pre.load`); an HTTP request +decodes its points envelope inside the call. + +| stage | OT128 synthetic | OT128 PandaSet 019 f40 | QT128 synthetic | QT128 PandaSet 019 f40 | +|---|---:|---:|---:|---:| +| host preprocess as served (`model._prepare`) | 55.3 (59.1) | 107.2 (120.0) | 54.2 (69.7) | 83.5 (106.1) | +| … decode (`load_points`) | 6.9 | 12.9 | 6.2 | 13.1 | +| … ego crop / projection / unique cells | 1.6 / 3.4 / 1.8 | 3.2 / 8.0 / 2.4 | 1.5 / 4.7 / 0.9 | 3.3 / 5.9 / 1.0 | +| … fp16 interpolation grid + interpolated points | 18.4 | 37.4 | 17.2 | 29.3 | +| … device plan (`build_plan`: sort, CSR offsets, gather tables, 8-d features) | 22.5 | 41.8 | 23.3 | 31.4 | +| `FrameStaging.write` (range checks + persistent host buffers) | 3.3 | 3.2 | 2.4 | 2.3 | +| H2D, 9 inputs (8.6 MB OT128, 6.2 MB QT128) | 5.42 (5.57) | 5.41 (5.65) | 4.04 (5.59) | 4.00 (4.48) | +| … of which `features` fp32 `[N_cap, 8]` | 4.13 | 4.13 | 3.14 | 3.13 | +| device trace, one blocking forward | 210.02 (210.15) | 211.72 (211.89) | 133.53 (134.01) | 134.91 (135.12) | +| **back-to-back trace replays, per forward** | **209.948** | **211.643** | **133.434** | **134.866** | +| D2H as served (fp32 `pred_probs` `[N_cap, 32]` → numpy) | 7.65 (20.2) | 7.43 (19.8) | 5.80 (8.7) | 5.55 (7.6) | +| … copy / host untilize (to numpy) | 2.25 / 4.95 | 2.29 / 5.01 | 1.77 / 4.15 | 1.78 / 3.75 | +| unsort (first N rows / 27 columns, network-input order) | 7.1 (14.1) | 12.8 (23.3) | 6.1 (13.2) | 5.9 (13.7) | +| host postprocess (first-max argmax, filter, output assembly) | 5.0 (6.8) | 14.8 (19.3) | 6.8 (9.8) | 10.5 (15.9) | +| **e2e `model()`** | **292.6 (299.4)** | **355.7 (367.0)** | **221.1 (233.5)** | **244.3 (258.7)** | + +- **Throughput.** + - Back-to-back replays give **4.72 frames/s** of device throughput on OT128 (PandaSet) and **7.41 frames/s** on + QT128. + - A synchronous `model()` call gives 2.81 frames/s (OT128 PandaSet), 3.42 (OT128 synthetic), 4.09 (QT128 + PandaSet) and 4.52 (QT128 synthetic). +- **The API's own `timing_ms` medians** (OT128 PandaSet): preprocess 105.4 ms, device 235.7 ms, postprocess 14.6 ms, + total 355.7 ms. The API's `device` stage is staging + H2D + trace + D2H + unsort: 24 of its 236 ms are outside the replay. +- **The device is 60 % of e2e on OT128** (211.6 of 355.7 ms, PandaSet) and 55 % on QT128 (134.9 of 244.3 ms). The + host preprocess is 30 % (OT128) and 34 % (QT128). +- **The device time barely depends on the frame** (fixed `N_cap` / `M_cap`): the synthetic OT128 scan with 36 % of + PandaSet's points replays only 1.7 ms faster (209.95 vs 211.64 ms). The difference is in K1 and the gathers. +- **Load** (warm JIT cache): `from_pretrained` 6.5 s OT128 / 6.9 s QT128 in the stage bench (build 0.28 s, + warm-up + capture 2.0 s). WORKER (11×10) compiles its own program set: 137 s OT128, 82 s QT128. A process with the + device profiler compiles its own kernels (146 s). + +## Dispatch / CQ matrix (D14) + +Setup: +- synthetic scan, one process per configuration, all in one devrun job (13:34–13:53 UTC); +- 50 iterations plus 5 × 30 back-to-back replays each; +- host load 4.1–5.8 throughout. + +| profile | configuration | grid | programs | b2b replay ms | trace p50 ms | H2D p50 / min ms | D2H p50 / min ms | e2e p50 / min ms | +|---|---|---|---:|---:|---:|---:|---:|---:| +| OT128 | **ETH-1CQ** (shipped) | 12×10 | 227 | **209.948** | 210.00 | 5.39 / 5.35 | 7.61 / 7.33 | 292.9 / 291.1 | +| OT128 | ETH-2CQ | 12×10 | 227 | 211.317 | 211.45 | 5.49 / 5.46 | 7.65 / 7.30 | 290.5 / 289.7 | +| OT128 | WORKER-2CQ | 11×10 | 231 | 211.567 | 211.73 | 4.57 / 4.55 | 7.56 / 7.19 | 293.4 / 291.7 | +| OT128 | WORKER-1CQ | 11×10 | 231 | 211.563 | 211.63 | 4.49 / 4.47 | 7.48 / 7.32 | 291.7 / 291.2 | +| OT128 | ETH-1CQ again (drift check) | 12×10 | 227 | 209.959 | 210.02 | 5.40 / 5.34 | 7.46 / 7.17 | 291.7 / 290.8 | +| QT128 | **ETH-1CQ** (shipped) | 12×10 | 174 | **133.437** | 133.51 | 4.02 / 3.98 | 5.51 / 5.33 | 208.8 / 207.5 | +| QT128 | ETH-2CQ | 12×10 | 174 | 134.250 | 134.38 | 4.11 / 4.08 | 5.50 / 5.28 | 210.0 / 209.1 | +| QT128 | WORKER-2CQ | 11×10 | 174 | 153.721 | 153.82 | 3.45 / 3.42 | 5.53 / 5.31 | 229.3 / 227.7 | +| QT128 | WORKER-1CQ | 11×10 | 174 | 153.709 | 153.77 | 3.39 / 3.37 | 5.43 / 5.30 | 226.0 / 225.3 | +| QT128 | ETH-1CQ again (drift check) | 12×10 | 174 | 133.433 | 133.50 | 4.01 / 3.98 | 5.42 / 5.31 | 206.3 / 205.5 | + +- **ETH stays the default: it is better on the device in both profiles.** + - OT128: ETH replays 1.6 ms faster than WORKER (209.95 vs 211.56 ms, −0.8 %). + - QT128: ETH replays **20.3 ms faster** (133.44 vs 153.71 ms, −13.2 %). On the 128 × 256 grid the 11×10 + partitioning of the point matmuls and convs is clearly worse. This is the largest ETH/WORKER gap measured so + far in this workspace. + - WORKER's H2D is 0.6–0.9 ms faster, as in the other matrices. It does not offset the replay. +- **1 CQ.** ETH with 2 CQs replays 0.8–1.4 ms slower (ETH-2CQ runs without dispatch_s, PLAN §0.2). A synchronous + request gains nothing from the second queue (e2e within host noise). Uploads are 5.4 ms and readbacks 7.5 ms per + frame, so pipelined 2CQ staging could hide at most about 13 ms of a 212 ms frame (−6 %). + - **Decision: ETH, 1 CQ stays pinned** (`serve.env FRNET_DISPATCH=eth`, `FRNET_NUM_CQS=1`, D14). + - Re-measure a pipelined 2CQ mode once the replay is no longer 15–30× the transfers. +- The drift checks repeat to 0.01 ms (OT128) and 0.004 ms (QT128). One OT128 ETH-1CQ b2b round read 211.76 ms + (the other four 209.94–209.95), and the median is unaffected. + +## Host vs device, and every host<->device transfer per forward + +**Host.** Only the documented fallbacks of PLAN §2.8 run on the host; there is no host round trip and no host +fallback op inside the device graph. + +Before the device (`host/preprocess.py`, numpy), in the node's order: +- the ego crop (float32 `R p + t`); +- the float32 projection; +- the fp16 farthest-point interpolation grid and the interpolated points; +- the unique cells and the profile checks; +- the device plan: the sort by cell, CSR offsets, `point2pix_s` / `pix2vox_s` for s = 1, 2, 4, 8, and the 8-d + features with `pre_norm` folded. + +After the readback: +- the un-permute (`plan.unsort`) of the first N rows / 27 columns; +- the first-max argmax with the −1e9 start (never a device last-dim argmax, P10), the score, the class filter + (`prob ≥ 0.05` on `drivable_surface`) and the output assembly. + +**H2D, 9 per frame.** `copy_host_to_device_tensor` from the persistent `HostStaging` buffers into the trace inputs, +all ROW_MAJOR in DRAM (OT128 bytes; QT128 in brackets): + +| input | shape, dtype | bytes | ms | +|---|---|---:|---:| +| `features` | `[1, 1, N_cap, 8]` fp32 (32 B pages) | 5,120,000 (3,840,000) | 4.13 (3.13) | +| `offsets` (K1 CSR) | `[1, 60002]` uint32 | 240,008 | 0.17 | +| `p2p_1/2/4/8` (pixel2point) | 4 × `[1, N_cap]` uint32 | 4 × 640,000 (480,000) | 0.27–0.31 each | +| `v2p_1/2/4/8` (frustum2pixel) | `[1, (H/s)(W/s)]` uint32 | 524,288 + 131,072 + 32,768 + 8,192 | 0.06–0.26 | +| **total** | | **8,616,328** (6,174,088) | **5.41** (4.00) | + +`features` moves at 1.24 GB/s: 160,000 pages of 32 bytes. The other inputs reach 1.4–2.1 GB/s. + +**D2H, 1 per frame.** The fp32 `pred_probs` TILE `[N_cap, 32]`: +- 20,480,000 B (OT128) / 15,360,000 B (QT128); +- of the 32 columns, 27 hold classes and 5 are padding (bias −1e30: probability 0); +- rows beyond N are padding (PandaSet OT128 uses 145,961 of 160,000, the nuScenes frames 20–28 k); +- copy 2.29 ms (8.9 GB/s), then host untilize 5.0 ms. + +**The served device path** (the API's `device` stage) is 236 ms on OT128 PandaSet. 212 ms of it run on the device. + +## Grid usage + +- **Nothing hard-codes 11×10 or 12×10** (VERIFY_PORT §3(b): the grid comes from `device.compute_with_storage_grid_size()`; + K1 splits its work on the device by two binary searches, so ETH and WORKER give the same outputs). On 11×10, WORKER + builds 231 instead of 227 programs (OT128). +- **OT128: 456 of the 1,104 programs per frame run on all 120 cores, 648 on fewer** (26.3 ms of kernel time): + - every point-island op on `N_cap` rows, the K1 kernels and the softmax run on 120 cores. The `M_cap`-row + compression matmuls run on 118; + - the image-branch convs run on 96 (28 conv programs), 108 (22) and 40 cores (stage 4, `pixel_fusion4`, + `attention4`: the 16 × 128 stride-8 map). Their glue (halo, move, padded slice, slice write, resharding) runs on + 40–114 cores. The height-sharded configurations use whole row groups; + - the convs reach a weighted 59.7 % of the HiFi4 FLOPs (tt-perf-report). The ten block-sharded conv programs reach + 26.1 %, but take only 0.42 ms. +- **QT128: the point matmuls run on 118 cores** (3,750 tile rows of 120,000 points). The convs of the narrow + 128 × 256 grid use 32–108 cores. +- **L1 is empty between ops.** The ttnn-visualizer report shows no L1 buffer at any op boundary. + - Every activation round-trips DRAM interleaved. + - DRAM peaks at **218.6 MB per bank (1.75 GB in all, weights included)** at the first partial-sum add of + `decoder.point_fuse1`. At that point the fp32 point features p0–p4 (`[160000, 128]`, 82 MB each) are still alive + next to the fp32 `[160000, 256]` partials (164 MB each). + - L1 for tensors is 167.5 MB (120 × 1,395,712 B, the visualizer's device record). +- **What would fit in L1:** + - a 128 × 1024 × 128 image map: 33.5 MB in bf16, 67 MB in fp32; + - every point-island weight: the largest, `point_fuse1` 640 × 256, is 655 KB as two bf16 terms. + + No fp32 point tensor at `N_cap` fits (82–164 MB each), so the point island needs row-block streaming. + +## What is already fused / traced + +**Done:** +- **One trace per process** covers the whole device graph: encoder → K1 → compression → frustum2pixel → stem → + point stem → fusion stem → 4 stages → decoder → point fuse → head → softmax → one packed output. + - Capture takes 2.6 s (OT128) / 2.0 s (QT128). + - No eager op runs between replays; replay == eager bit for bit (a gate). + - Eagerly, under the profiler, the same 1,104 programs span 260.2 ms (op-to-op gaps 47.6 ms of host dispatch), + against 213.3 ms traced. +- **K1 `segment_reduce`** (owned `ttnn.generic_op`, bit-exact). It replaces the log-step stock-op fallback, which + would take 3R + 1 programs per site, with one program per segmented-max site (6 per frame). It reads the + matmul's TILE output directly (no untilize). Sites 1–5 write the bf16 frustum2pixel gather table, zero row + included. +- **Exact rewrites** (all from the port, each tested against ORT or an fp32 emulation): + - BN folded into the 14 point linears and the 53 convs; + - `pre_norm` folded into the host features; + - frustum2pixel / pixel2point as TILE + PADDED `ttnn.embedding` gathers with a zero sentinel row (C19); + - concat-free point layers (K-split linears) and K-split fusion convs. `fuse_layer1` is a 5 × 128 split-K + `KSplitConv` (P14), so the 160 MiB decoder concat never exists; + - align-corners resizes as interpolation matmuls (C18); + - the 27 logits padded to 32 with a −1e30 bias, so the softmax is exact; + - fused SIGMOID in the attention convs, ReLU in the point linears; + - a bf16 input of a two-term linear (the gathered map rows) takes two matmuls instead of three (`lo = 0`). +- **Host side:** `HostStaging` writes the plan into persistent zero-copy host tensors (no per-frame `from_torch`), + and out-of-profile frames return before touching the device. + +**Not fused / not resident:** +- **the two-term point island:** + - each point linear runs one split per fp32 input: typecast to bf16, typecast back, subtract, typecast. So + 4 programs per input; + - then 2–3 bf16 matmuls per part, each writing a full fp32 `[N_cap, C_out]` partial to DRAM; + - then one fp32 add per partial. + + The 5-part `point_fuse1` alone is 49 programs and 39.9 ms; +- every activation in DRAM interleaved between ops. The 128 × 1024 convs run ttnn's automatic DRAM slicing, each + slice with its own padded slice, halo, move and slice write; +- every HardSwish is its own fp32 `ttnn.hardswish` (P5: a fused HardSwish is silently skipped), 46 per frame; +- the resizes (C18) run as transpose + matmul + reshape + tilize / untilize chains with bf16 outputs (C18 cannot + write fp32 rows at 1024 × 128); +- pixel2point: untilize of the map, then the gather, then a typecast to fp32; +- every point-wise op runs on all `N_cap` rows and K1 writes all `M_cap` segments, whatever the frame's N and M; +- no megakernel. Projection, interpolation, the plan, the argmax and the filter run on the host. + +## Device profile (traced replay; kernel sum vs span, op-to-op gaps) + +One traced replay, ETH-1CQ, PandaSet 019 f40. Reports: OT128 `generated/profiler/frnet_baseline/reports/2026_10_09_13_56_53/`, +QT128 `generated/profiler/frnet_baseline_qt128/reports/2026_10_09_13_59_21/`. + +| | OT128 | QT128 | +|---|---:|---:| +| programs per frame | **1,104** | **759** | +| distinct programs (program cache) | 227 | 174 | +| ttnn calls per frame (visualizer, OT128) | 491: 123 typecast, 84 add, 72 linear, 62 conv2d, 46 hardswish, 27 to_layout, 21 reshape, 20 subtract, 12 embedding, 6 generic_op (K1), 6 transpose, 6 matmul (resize), 4 addcmul, 1 pad, 1 softmax; plus 439 deallocate | – | +| device kernel sum | **212.64 ms** | **134.81 ms** | +| span (first FW start → last FW end at 1350 MHz) | 213.27 ms (b2b 211.64) | n/a (see below; b2b 134.87) | +| op-to-op gaps | 0.628 ms in all, median 0.55 µs | 0.443 ms, median 0.57 µs | +| conv + matmul kernels | 76.70 ms (36 %) | 51.62 ms (38 %) | +| math fidelity | HiFi4 on all 677 compute ops | HiFi4 on all 539 | + +- **The trace is kernel-time bound: dispatch is not a sink** (0.3 % of the span). Cutting programs pays only through + the kernel time of the programs it removes. A megakernel's dispatch saving is at most 0.6 ms. +- QT128's span is not usable: one replayed conv (row 3631 of the CSV, `backbone.layer4`) reports a FW duration of + 3.18 s against a 41 µs kernel, a profiler artefact. The b2b replay (134.87 ms, five rounds within 0.01 ms) shows no + such stall. The kernel sum plus gaps (135.25 ms) is the QT128 reference. + +**By part of the graph** (`logs/frnet/m5_baseline/scripts/group_profile.py`; ops attributed through the eager +module / layer signposts): + +| part | OT128 ops | OT128 kernel ms | share | QT128 ms | share | of which (OT128) | +|---|---:|---:|---:|---:|---:|---| +| **point island** (encoder ffe0–3, compression, point stem, point fusion 1–4, point fuse, head + softmax) | 215 | **133.66** | **62.9 %** | **101.12** | **75.0 %** | matmul 61.2, fp32 adds 52.6, typecasts (two-term splits) 19.5 | +| … encoder ffe0–3 (8 → 64 → 128 → 256 → 256) | 36 | 22.14 | 10.4 % | 16.71 | 12.4 % | `ffe3` 9.9 (three 1.6 ms matmuls) | +| … point stem + point fusion 1–4 (K-split 384 / 256 → 128) | 65 | 37.88 | 17.8 % | 28.36 | 21.0 % | 7.0 per fusion, 10.0 the stem | +| … point fuse (640 → 256 → 128) | 58 | 47.40 | **22.3 %** | 35.77 | 26.5 % | `point_fuse1` 39.9: 15 matmuls 17.4, 15 adds 16.4, 20 split ops 6.1 | +| … head (4 × linear + skips, logits, softmax) | 44 | 23.89 | 11.2 % | 17.95 | 13.3 % | | +| … compression (256 → 16, on `M_cap` rows) | 12 | 2.35 | 1.1 % | 2.33 | 1.7 % | | +| **K1 segment max** (6 sites) + tilize | 7 | **17.22** | 8.1 % | **15.18** | 11.3 % | encoder site 4.93 (fp32 × 256), 5 × 2.39 (× 128) | +| pixel2point gathers (6 × untilize + embedding + typecast) | 18 | 6.15 | 2.9 % | 3.50 | 2.6 % | embedding 3.47 | +| frustum2pixel gathers (6 embeddings) | 6 | 0.65 | 0.3 % | 0.15 | 0.1 % | | +| decoder resizes (C18, 3 maps) | 39 | 9.03 | 4.2 % | 2.24 | 1.7 % | matmul 2.89, reshape 2.62, (un)tilize 2.30 | +| image branch: stem, fusion stem, stages 1–4 (convs + glue) | 605 | 30.66 | 14.4 % | 8.63 | 6.4 % | conv 7.62, fp32 HardSwish 5.25, typecast 4.24, DRAM slicing 5.19, adds 3.21, halo 1.96 | +| image branch: decoder `fuse1` (K-split 5 × 128 → 256) + `fuse2` | 214 | 15.26 | 7.2 % | 3.98 | 3.0 % | conv 4.96, K-split adds 3.62, slicing 3.89 | +| **total** | **1,104** | **212.64** | | **134.81** | | | + +| sink (op type, OT128) | µs | % | where | +|---|---:|---:|---| +| Matmul (78) | 64,118 | 30.2 | 72 point-island matmuls at 1.4–7.9 % of the HiFi4 FLOPs and 130–200 GB/s (26–39 % of the DRAM peak): each writes a full fp32 partial `[160000, C_out]` (82–164 MB). The `ffe3` parts take 1.6 ms each, a `point_fuse1` part 1.16 ms, a point-fusion part 0.67 ms. Plus 6 resize matmuls (2.9 ms) | +| BinaryNg (104) | 59,441 | 28.0 | point island 52.6 ms: 59 fp32 adds (the two-term / K-split partial sums and the head's skips; 1.09 ms each at `[160000, 256]`, about 450 GB/s: DRAM-bound) and the 20 subtracts of the two-term splits. Image branch 6.8 ms: fp32 residual adds and the decoder's K-split sums (3.6 ms) | +| Typecast (123) | 26,795 | 12.6 | 61 fp32 ↔ bf16 casts of the two-term splits (19.5 ms); 53 casts of the fp32 maps to bf16 before the convs (5.2 ms); 6 pixel2point casts to fp32 (1.5 ms); 3 in the resizes (0.7 ms) | +| GenericOp: K1 (6) | 16,903 | 7.9 | scalar RISC-V segment max. The encoder site reads 164 MB in 4.93 ms (33 GB/s); each 128-channel site takes 2.39 ms. It scales with `M_cap` (60,000 output rows each) more than with the rows: QT128 runs the same sites in 2.0–4.4 ms on 75 % of the rows | +| Conv2d (138) | 12,582 | 5.9 | 53 convs in 2–4 DRAM slices each, weighted 59.7 % of the HiFi4 FLOPs | +| SliceWrite + PaddedSlice (194) | 9,073 | 4.3 | ttnn's DRAM slicing of the 128 × 1024 convs | +| Unary (46) | 6,269 | 2.9 | the 46 fp32 HardSwish | +| Embedding (12) | 4,118 | 1.9 | pixel2point (3.47 ms) and frustum2pixel (0.65 ms) gathers | +| Halo + Move + resharding (358) | 4,816 | 2.3 | conv glue | +| Reshape + (Un)Tilize + Transpose (39) | 7,340 | 3.5 | resize chains, pixel2point untilize, the input tilize | +| Ternary `addcmul` (4) | 852 | 0.4 | attention gating | + +Top 10 device ops of the OT128 replay: + +| # | op | where | µs | cores | +|---:|---|---|---:|---:| +| 1 | K1 `segment_reduce` | `segmax.encoder`: `[160000, 256]` fp32 → `[60000, 256]` fp32 (voxel max) | 4,928 | 120 | +| 2–6 | K1 `segment_reduce` | `segmax.points` sites 1–5: `[160000, 128]` → the bf16 gather table `[60001, 128]` | 2,394–2,396 each | 120 | +| 7–9 | Matmul | `encoder.ffe3` (256 → 256), the three two-term products `[160000, 256] × [256, 256]` → fp32 | 1,641 / 1,599 / 1,598 | 120 | +| 10 | Matmul | `head.mlp2` part `[160000, 256] × [256, 128]` → fp32 | 1,221 | 120 | + +Next come the 15 `point_fuse1` matmuls (1.15–1.20 ms each), the 25 fp32 adds at `[160000, 256]` (1.08–1.11 +ms each) and the `point_stem` part matmuls (1.19 ms). + +**Most expensive layers:** +- `decoder.point_fuse1`: 39.85 ms (18.7 % of the replay), of which 17.41 ms matmul; +- `decoder.fuse1`: 12.55 ms in 157 programs, of which 3.92 ms conv; +- K1 `segmax.points` (5 sites): 11.98 ms; +- `backbone.point_stem`: 9.96 ms; +- `encoder.ffe3`: 9.88 ms; +- `head.mlp1` / `head.mlp2`: 8.23 / 8.09 ms; +- each point fusion: 6.97–6.99 ms; +- stage-1 convs: 1.43–1.90 ms each, of which 0.37 ms compute (the rest is the DRAM slicing, halo and the fp32 + output). + +`logs/frnet/m5_baseline/profile_summary_{ot128,qt128}.{json,md}` have the per-module, per-layer and per-op tables, +`tt_perf_report_{ot128,qt128}.txt` the tt-perf-report output (with its per-op advice), and +`grp_{ot128,qt128}.txt` the part breakdown above. + +**The precision cost of the policy.** These are the port's same-session device A/Bs on the OT128 frames (PORT_LOG +M1–M3, jobs j2–j5, gates read-only). They were not re-run for this baseline. + +| policy | replay ms | Δ vs shipped | OT128 frames min argmax (nuScenes 0916 kf40) | gate | +|---|---:|---:|---|---| +| fp32 × fp32 point island (`POINT_TERMS=1`), bf16 maps (j2) | 107 | −105 ms (−50 %) | **97.50 %** | fails | +| two-term point island, bf16 maps (j3) | 201 | −11 ms (−5 %) | **98.28 %** | fails | +| two-term point island, bf16 maps, `CONV_TERMS=2` (j4) | 244 | +32 ms | **98.35 %** | fails | +| **two-term point island, fp32 maps (shipped; j5 / this baseline)** | **212** | – | **98.91 %** | passes, 0.41 pt margin | +| two-term point island, fp32 maps fed to the convs directly (j5) | 217 | +5 ms | **98.13 %** | fails | +| two-term point island, fp32 maps, `CONV_TERMS=2` (j5) | 249 | +37 ms | 99.00 % | passes, 0.50 pt margin | + +- **The two-term point island costs about 94 ms per OT128 frame** (j2 → j3: 107 → 201 ms, +88 %). That is 44 % of + today's replay, and it is how the port reaches the gate: the device's fp32 matmul is TF32-like (P12). The + profile shows that the cost is not the extra products (bf16 HiFi4 is exact) but the fp32 partial tensors, their + DRAM adds and the split typecasts. Item 1 below targets exactly that, with the same products. +- **The fp32 maps cost about 11 ms** (201 → 212 ms): fp32 HardSwish, residual adds and `addcmul`, and a cast to bf16 + before every conv. They buy 0.63 pt on the binding frame. +- **Cheaper precision fails.** Every cheaper policy measured fails the binding nuScenes frame. HiFi2 / LoFi or + bfp8 were not measured: with a 0.41 pt margin and 98.28 % for bf16 maps, they need fresh device evidence, and the + FPU is not the bottleneck (the matmuls run at 1.4–7.9 % of the FLOPs). +- **More margin costs time.** `CONV_TERMS=2` raises the margin to 0.50 pt for +37 ms. fp32 resize outputs (blocked + by C18 at 1024 × 128 rows) and fp32 gathers for the 128-channel maps are unmeasured options. + +## Ranked optimization opportunities + +How to read the estimates: +- Gains are on the OT128 trace (211.6 ms, PandaSet) unless marked e2e (OT128 PandaSet e2e 355.7 ms p50). QT128 + follows the same ranking (the point island is 75 % of its 134.9 ms). +- Each estimate is the measured kernel or host time of the work the item removes, with a range for what replaces + it. Items overlap, so the gains do not add up. +- Every item keeps the frozen gates and the precision policy (PLAN §9.3), and lands as one commit with one + `FRNET_*` A/B knob (PLAN §5.1). +- **Judge numerics changes on the binding frame first: nuScenes 0916 kf40, OT128, 98.907 % against the 98.5 % gate.** + A change in the order of fp32 accumulation is not bit-identical to today, so items 1–2 must re-run the full + device suite. + +1. **Two-term point linears as one K-concatenated matmul per layer (trace −60..−80 ms, −28..−38 %; the largest + item).** + - Today each point linear writes one fp32 `[N_cap, C_out]` partial per product: 2–3 per input part, up to 15 for + `point_fuse1`. One fp32 add per partial then sums them. Matmuls take 61.2 ms and the adds 52.6 ms, at + 130–450 GB/s: DRAM traffic, not FLOPs (1.4–7.9 % of HiFi4). + - The fix: one matmul per layer over the K-concatenated bf16 operands `[x_hi | x_hi | x_lo]` (all parts) × + `[W_hi; W_lo; W_hi]`, with fp32 dest accumulation and packer L1 accumulation. + - The products are the same exact bf16 products. Only the fp32 summation order changes, so the gates must be + re-run. + - The fp32 output is written once. The bf16 inputs are a quarter of an fp32 partial's bytes. + - The concat of the bf16 operands costs about 0.3–1 ms per layer, unless the split kernel (item 3) writes them + straight into the K-slices of one tensor. + - Expected result: matmul about 61 → 25–35 ms, adds 52.6 → about 5 ms (the residual / skip adds stay). +2. **MK-PointMLP: a row-block-streamed point MLP megakernel (trace −100..−115 ms beyond today, overlapping 1; + D19).** + - The point island is 133.7 ms (OT128) and 101.1 ms (QT128): 63–75 % of the replay. Its work is row-local + except the gathers and K1. + - A kernel per row block (for example 32–64 points per core) chains the linears of one segment in L1: + - the encoder `ffe0..ffe3`; + - point fuse `640 → 256 → 128`; + - the head's 4 layers + skips + logits + softmax. + + It does the two-term splits on the fly, holds the bf16 weight terms in L1 (at most 655 KB for `point_fuse1`) + and writes only the segment's outputs. The head segment writes `pred_probs` directly. + - Its DRAM floor is a few tens of MB per frame, about 0.1–0.3 ms. Compute at the measured bf16 rates fits in + roughly 15–30 ms for the whole island. + - Stepping stones: + - item 1; + - then fuse each linear's split into the producer's epilogue (item 3); + - then one segment at a time, the head first (23.9 ms, no gather inside). +3. **A fused two-term split (trace −10..−14 ms; on the way to 1–2).** + - Each fp32 input today runs 4 programs: typecast, typecast back, subtract, typecast. That is 19.5 ms for 61 + casts, plus 20 subtracts (in the BinaryNg row), at `N_cap`. + - One `generic_op` (or SFPU eltwise) pass can read fp32 and write hi and lo bf16, about 0.3–0.5 ms per + `[160000, 256]` input. It can write into the K-slices of item 1's operand. Exact: the same RNE casts. +4. **N_cap / M_cap buckets (trace −100..−160 ms on frames with N ≤ 32–64 k; e.g. every nuScenes frame).** + - Every point-wise op runs on all 160,000 rows (OT128) or 120,000 (QT128), and K1 writes all 60,000 + segments. That covers the point island, K1, the gathers and the readback: about 157 ms of the OT128 replay. + - The catalogued nuScenes frames have N = 20–28 k (13–17 % of `N_cap`) and M = 17–25 k. The QT128 PandaSet + frame has M = 6,255 against `M_cap` = 60,000. + - The fix: two or three trace variants per profile (for example `N_cap` 32 k / 64 k / 160 k, `M_cap` 16 k / + 60 k), sharing weights. The host picks the smallest bucket that covers the frame. Each variant costs about + 6–9 MiB of trace region (256 MiB available) plus its activations. + - PandaSet-like frames (N = 146 k) gain nothing. The readback shrinks with N too (item 9). +5. **K1 throughput (trace −10..−14 ms; owned shared kernel).** + - K1 takes 16.9 ms: 33 GB/s on the 164 MB encoder site, and 2.0–2.4 ms per 128-channel site almost + independent of the row count. So per-segment output overhead dominates: 60,000 segments of one row each + through an 8-row staging ring. + - Fixes, in order: + - batch output rows per worker (contiguous segments → one NOC write per block); + - move the accumulator from L1 into the RISC-V's local memory (PORT_LOG known issue); + - with item 4, write only M_cap-bucket segments; + - a compute-engine reduction is the long form. + - Bit-exactness must stay (max of integer keys). The shared-kernel rule (PLAN §5.1.5) applies: re-vendor and + re-run K1's host / device tests. +6. **Image branch DRAM slicing and glue (trace −8..−14 ms; MK-ImageStage).** + - The 128 × 1024 convs spend 12.6 ms in conv kernels but 9.1 ms in DRAM slicing (padded slice + slice write) and + 4.8 ms in halo / move / resharding. The fp32-map glue adds the following: + - 46 fp32 HardSwish: 6.3 ms; + - the casts to bf16 before each conv: 5.2 ms; + - fp32 residual adds and K-split sums: 6.8 ms. + - A 128 × 1024 × 128 map is 33.5 MB in bf16 / 67 MB in fp32 against 167.5 MB of L1. So the stride-1 stage chain + can stay height-sharded in L1 (C17 `slicing="l1"`), which drops the slicing. + - Fold the bf16 cast of the conv input and the HardSwish into one eltwise pass (exact: the same ops). + - The longer form is a BasicBlock megakernel with halo exchange (MK-ImageStage). +7. **Decoder resizes and `fuse1` (trace −5..−8 ms).** + - Resizes take 9.0 ms: reshape 2.6, tilize / untilize 2.3, transpose 0.4 and matmul 2.9. + - `decoder.fuse1` takes 12.5 ms in 157 programs: a 5-part K-split over five 128 × 1024 maps, with 3.6 ms of + K-split adds and 3.9 ms of slicing. + - Fix: run the resize matmuls in TILE layout end to end, so no reshape / (un)tilize is needed. That also lifts + the C18 fp32 limitation, a precision-margin option. Accumulate the K-split parts in the conv (dest / L1 + accumulation) instead of fp32 adds. +8. **pixel2point gathers (trace −2..−4 ms).** + - Today: untilize of each map (1.2 ms), the bf16 embedding (3.5 ms), a typecast to fp32 (1.5 ms). + - The two-term linear already treats a bf16 input as exact (`lo = 0`). So feed the gather output as bf16 straight + into item 1's operand (no cast). Exact. + - Later: gather from the TILE map in the MK-PointMLP reader. +9. **Host preprocess (e2e −60..−85 ms OT128).** + - Today 107 ms on PandaSet: + - plan 41.8 ms: sort, CSR, gather tables, features; + - fp16 interpolation 37.4 ms; + - decode 12.9 ms; + - projection 8.0 ms; + - crop and unique cells 5.6 ms. + - The fixes: + - a C++ / numba implementation of the node's CUDA kernels (projection + interpolation grid in one pass, + counting-sort plan): about 10–20 ms in all; + - keep the decode outside the timed request path. + - The numerics must stay bit-identical to today's host code (float32 / fp16 policies, the documented race + policies). The host tests and the research goldens check that bit for bit. +10. **Host after the device (e2e −15..−25 ms OT128).** + - Today: unsort 12.8 ms + post 14.8 ms + host untilize 5.0 ms. + - Fixes: read back row-major (device untilize, or a ROW_MAJOR output), with only N rows (item 4). Fuse the + un-permute into the argmax pass: take the argmax over the 27 columns on the permuted rows, then scatter + int8 labels and float scores instead of the 27-column float rows. + - Exact: the same first-max rule on the same fp32 values. +11. **H2D `features` layout (e2e −3.5 ms).** `features` moves 5.1 MB at 1.24 GB/s in 160,000 pages of 32 bytes. Upload + it as `[N_cap / 4, 32]` rows (128 B pages) and view or reshape on the device. A TILE upload would also drop the + input tilize (0.36 ms). +12. **Throughput by pipelining (frames/s; latency unchanged).** + - Device b2b allows 4.72 frames/s (OT128) and 7.41 (QT128). A synchronous request gives 2.81 / 4.09 frames/s on + PandaSet. + - Overlap the host pre / post of frames k ± 1 with the device work of frame k in the server. + - 2CQ staging can hide the 5.4 ms upload (and, with double-buffered outputs, the 7.4 ms readback). It is worth + measuring only after items 1–2 shrink the replay. +13. **Megakernel candidates (D19, PLAN §5.2 / §5.3).** + - The §5.2 gate is met through DRAM round trips and layout glue, not dispatch. 64 % of the replay is not conv / + matmul compute, while op-to-op gaps are 0.3 %. + - Candidates, by measured block time: + - **MK-PointMLP** (point island, 133.7 ms OT128 / 101.1 ms QT128; items 1–3, 8): first, by far. It is + row-local, and its weights fit L1; + - **MK-SegGather** (K1 + the frustum2pixel / pixel2point gathers, 24 ms; items 5, 8): fold the gather tables + into K1's writer and the pixel2point gather into the point reader; + - **MK-ImageStage** (stem + stages 1–4 + decoder convs, 46 ms, of which 12.6 ms conv compute; items 6–7): + band-fused BasicBlocks with halo exchange. The 128 × 1024 maps fit L1 in bf16. + - A single whole-model kernel is not practical: the fp32 point tensors at `N_cap` (82–164 MB each) do not fit L1. + MK-PointMLP's row-block streaming is the practical "single" form for 63–75 % of the work. +14. **Precision margin (accuracy, not speed; optimization-phase precision item).** + - The margin on nuScenes 0916 kf40 is 0.41 pt. Items 1–2 change the fp32 accumulation order, so re-check the + margin after each step. + - Options to buy margin, measured: `CONV_TERMS=2` +0.09 pt for +37 ms. Unmeasured: fp32 resize outputs (after + item 7) and fp32 gathers of the 128-channel maps (`ttnn.embedding` is bf16-only; a K1-style generic_op gather). + - No precision relaxation (bf16 maps, a TF32 point island, HiFi2) is a candidate without new device evidence on + that frame: each one measured so far fails it. + +## Raw logs and files + +All under `/home/ubuntu/experiments/tt-models/`: +- `logs/frnet/m5_baseline/job1.log` (+ `job1.rcs`): the device job (steps 2–5). `try0/`: the first attempt (suite log + `suite_r1.log`; the other steps failed on the weights path). `job1_prereboot.log`: the job queued before the server + reboot, which never started; +- `logs/frnet/m5_baseline/bench_eth1cq_{ot128,qt128}.{json,log}`: the stage benches; +- `logs/frnet/m5_baseline/matrix_{ot128,qt128}_{eth-1cq,eth-2cq,worker-2cq,worker-1cq,eth-1cq-repeat}.{json,log}`; +- `logs/frnet/m5_baseline/profile_{ot128,qt128}.log`, `profile_summary_{ot128,qt128}.{json,md}`, + `tt_perf_report_{ot128,qt128}.txt`, `grp_{ot128,qt128}.txt`; +- `logs/frnet/m5_baseline/visualizer.log`, `host_suite.log`; `logs/frnet/{pcc,pcc_small,e2e,latency}_{ot128,qt128}_m5.json`; +- `logs/frnet/m5_baseline/inputs/`: the synthetic scans, their calibrations and CPU references; +- device profiles: `generated/profiler/frnet_baseline/reports/2026_10_09_13_56_53/ops_perf_results_2026_10_09_13_56_53.csv` + (OT128) and `generated/profiler/frnet_baseline_qt128/reports/2026_10_09_13_59_21/ops_perf_results_2026_10_09_13_59_21.csv` + (QT128); +- ttnn-visualizer report: `generated/ttnn_visualizer/frnet_baseline/` (`db.sqlite` 26 MB, `config.json`; OT128 PandaSet, + one eager forward. The 7.5 GB graph JSON went to `/dev/shm` and was deleted after the import). diff --git a/OPT_REPORT.md b/OPT_REPORT.md new file mode 100644 index 0000000000000000000000000000000000000000..72f648ec3ef1312cc50dab52e61706dfc4e02267 --- /dev/null +++ b/OPT_REPORT.md @@ -0,0 +1,157 @@ +# frnet-p150 optimization report (p150, ETH dispatch, 12×10 grid) + +**Status: baseline port; optimization pending.** This is the first public release: the functional port, measured +once (`OPT_BASELINE.md`, baseline commit `0dd52b2`, 2026-10-09) and not optimized yet. No optimization round has run, +so there is no step, no rejected attempt and no hang to report, and every number below is the baseline. The device +graph runs as one metal trace per serve profile: 1,104 programs per frame for `ot128` (Autoware's default sensor +model) and 759 for `qt128`, 211.64 / 134.87 ms per back-to-back replay on a PandaSet frame (4.72 / 7.41 frames/s of +device throughput). A synchronous `model()` call takes 292.6 ms (ot128) / 221.1 ms (qt128) on the shipped synthetic +sample and 355.7 / 244.3 ms on the PandaSet frame; the device is 60 % (ot128) / 55 % (qt128) of it. The ranked +backlog at the end says where the time goes. The largest item is the precision island of the point-wise layers +(63 % of the ot128 replay, 75 % of qt128), whose cost is DRAM traffic of fp32 partial sums, not compute; then the +image-branch convs and glue (46 ms) and the custom K1 segment-max kernel (16.9 ms). The binding accuracy constraint +of every step is the nuScenes 0916 key-frame 40 frame on ot128: 98.907 % argmax agreement against the 98.5 % gate, +a 0.41-point margin. + +All numbers: `code/scripts/bench.py` (median of 100 warm iterations, batch 1, the shipped sample +`code/tt_frnet/samples/synthetic_.npz` and the staged PandaSet 019 frame 40), ETH dispatch, 1 CQ, 12×10 grid, +the serve.env precision knobs (`FRNET_POINT_TERMS=2`: two-term bf16 operands with fp32 accumulation in the point-wise +layers; `FRNET_MAP_DTYPE=float32`: fp32 image-branch maps with bf16 conv operands; `FRNET_CONV_TERMS=1`), HiFi4 with +fp32 accumulation everywhere, AICLK 1350 MHz, unless marked otherwise. Device profile: `code/scripts/profile_ops.py` +under the device profiler (one traced replay between signposts). Accuracy gates: `OPT_BASELINE.md` "How to run". + +## Summary + +| | baseline `0dd52b2` (2026-10-09) | **final (= baseline: no round yet)** | +|---|---|---| +| device trace, one blocking forward (ot128 / qt128; shipped sample) | 210.02 / 133.53 ms | same | +| back-to-back traces (ot128 / qt128; PandaSet 019 f40) | 211.64 / 134.87 ms (4.72 / 7.41 frames/s) | same | +| e2e `model()` p50 (p99), shipped sample: ot128 / qt128 | 292.6 (299.4) / 221.1 (233.5) ms | same | +| e2e `model()` p50 (p99), PandaSet 019 f40 (107,146 points): ot128 / qt128 | 355.7 (367.0) / 244.3 (258.7) ms | same | +| host preprocess / H2D / D2H / un-permute / postprocess, ot128, PandaSet | 107.2 / 5.41 / 7.43 / 12.8 / 14.8 ms | same | +| device programs per frame (distinct programs in the trace): ot128 / qt128 | 1,104 (227) / 759 (174) | same | +| kernel sum / span / op-to-op gaps, ot128 | 212.64 / 213.27 / 0.63 ms | same | +| kernel sum / gaps, qt128 (span unusable: a profiler artefact, see `OPT_BASELINE.md`) | 134.81 / 0.44 ms | same | +| accuracy gates (vs the fp32 CPU reference / ONNX Runtime) | every frozen gate passes: module PCC min 0.999729 / 0.999649; public frames min `pred_probs` PCC 0.99969 / 0.99980, min argmax agreement **98.907 %** (nuScenes 0916 kf40, gate 98.5 %) / 99.158 % | same | +| `from_pretrained` load, warm JIT cache | 6.5 / 6.9 s (capture 2.6 / 2.0 s) | same | + +Host-side rows move with the load of the shared host (load average 4.1–6.6 during the baseline job); the device rows +repeat to 0.01 ms. The release re-check (`VERIFICATION_2026-10-09.md`) reproduced the device rows on the release +commit. + +## Steps (chronological; each row is one commit) + +| step | commit | what | trace / b2b ms (ot128; qt128) | e2e ms (shipped sample: ot128; qt128) | accuracy | revert switch | +|---|---|---|---|---|---|---| +| baseline | `0dd52b2` | first correct port: one trace per profile, ETH 1CQ 12×10; two-term bf16 point layers, fp32 image-branch maps, K1 `segment_reduce` (bit-exact); host pre-processing, argmax and filter | 210.02 / 209.95; 133.53 / 133.43 | 292.6; 221.1 | all frozen gates (above) | – | + +The release commit after the baseline changed no model numerics and no device code: the shipped sample became a +synthetic scan per serve profile (generated by `code/scripts/make_sample.py`, byte-identical to the baseline's +synthetic bench inputs), the PandaSet sample moved out of the shipped `code/` (staged until PandaSet samples are +approved), test hardening (`test_public_frames` fails on a missing golden and pins the frame counts; a new +`test_shipped_sample` gate), docs and demo media. The device suite was re-run on it with identical values for every +existing gate (`VERIFICATION_2026-10-09.md`). + +## Round 1 + +Not started. The optimization phase follows the publication of every model of the collection (`research/PLAN.md` +§4.1, §5): each step is one commit with one `FRNET_*` A/B knob, keeps the frozen gates, and the precision policy +(PLAN.md §9.3) may be relaxed per module only with gate evidence. **Every numerics change is judged on the binding +frame first: nuScenes 0916 kf40, ot128, 98.907 % against the 98.5 % gate.** A change of the fp32 accumulation order +is not bit-identical to today, so it re-runs the full device suite. + +### Findings (measured) + +The baseline profile (`OPT_BASELINE.md` "Device profile") is the starting point: + +- The trace is **kernel-bound, not dispatch-bound**: op-to-op gaps total 0.63 ms of a 213.27 ms span (ot128, 0.3 %). + Cutting programs pays only through the kernel time of the programs removed; a megakernel's dispatch saving is at + most 0.6 ms. +- **The point-wise layers kept at higher precision are 63 % of ot128 (133.7 ms) and 75 % of qt128 (101.1 ms).** Each + linear splits its fp32 input into two bf16 terms and runs 2–3 bf16 matmuls whose fp32 partials are summed in fp32. + The cost is DRAM traffic: 61.2 ms of matmuls at 1.4–7.9 % of the HiFi4 FLOPs, 52.6 ms of fp32 adds of the + partials, 19.5 ms of typecasts for the splits. `decoder.point_fuse1` alone is 39.9 ms. +- **K1 `segment_reduce`: 16.9 ms** (8 % of ot128, 11 % of qt128): 2.39 ms per 128-channel site almost independent of + the row count, so per-segment output overhead dominates. +- **Image-branch convs and glue: 46 ms on ot128**, of which 12.6 ms conv kernels; the rest is ttnn's DRAM slicing of + the 128 × 1024 convs (9.1 ms), halo / move / resharding (4.8 ms), 46 fp32 HardSwish (6.3 ms), bf16 casts before + every conv (5.2 ms), fp32 residual and K-split adds (6.8 ms), resizes (9.0 ms). +- Every point-wise op runs on the profile's `N_cap` rows (160,000 / 120,000) and K1 writes all 60,000 segments, + whatever the frame: the nuScenes frames (N = 20–28 k) cost the same as PandaSet (N = 146 k). +- L1 holds nothing between ops; DRAM use peaks at 218.6 MB per bank at the first partial-sum add of + `decoder.point_fuse1`. +- Dispatch / CQ matrix (back-to-back): ot128 ETH-1CQ 209.95, ETH-2CQ 211.32, WORKER 211.56 ms; qt128 133.44, 134.25, + 153.71 ms. ETH and 1 CQ stay pinned (D14): on qt128's narrow grid WORKER's 11×10 partitioning costs 20.3 ms. +- The precision policy is how the port reaches the gate. Measured in the port (PORT_LOG M1–M3): an fp32 × fp32 point + island (the device's TF32-like matmul) replays in 107 ms but gives 97.50 % on the binding frame; bf16 image maps + 98.28 %; `CONV_TERMS=2` buys +0.09 pt for +37 ms. + +### Megakernel / fusion work + +None yet. The candidates by measured block time are listed in the backlog (item 13). + +## Known hangs (all rounds) + +| when (UTC) | command | cause | status | +|---|---|---|---| +| – | – | none: no hang, device timeout, reset or FAULT marker in any device job of the port, its verification round, the baseline or the release runs (one build-time `TT_THROW` of the shared C18 resize at fp32 1024 × 128 rows, an L1 overflow reported at program build, is a known issue in `PORT_LOG.md`, not a hang) | – | + +## Rejected / not kept + +None yet in the optimization phase. Configurations measured during the port that are **not** the default, for +accuracy (`PORT_LOG.md` M1–M3; `OPT_BASELINE.md` "The precision cost of the policy"; ot128, binding frame nuScenes +0916 kf40): + +| configuration | replay | binding frame | why not | +|---|---:|---:|---| +| fp32 × fp32 point island (`POINT_TERMS=1`), bf16 maps | 107 ms | 97.50 % | fails the 98.5 % gate | +| two-term point island, bf16 maps (`MAP_DTYPE=bfloat16`) | 201 ms | 98.28 % | fails | +| two-term point island, bf16 maps, `CONV_TERMS=2` | 244 ms | 98.35 % | fails, slower | +| two-term point island, fp32 maps fed to the convs directly (`MAP_DTYPE=float32_direct`) | 217 ms | 98.13 % | fails | +| two-term point island, fp32 maps, `CONV_TERMS=2` | 249 ms | 99.00 % | passes, +37 ms for +0.09 pt of margin | + +## Profile at the end (trace replay) + +The baseline profile (`OPT_BASELINE.md`): ot128 1,104 programs, kernel sum 212.64 ms, span 213.27 ms; qt128 759 +programs, 134.81 ms. Top sinks by op type (ot128): Matmul 64.1 ms (30 %), BinaryNg 59.4 ms (28 %), Typecast 26.8 ms +(13 %), K1 16.9 ms (8 %), Conv2d 12.6 ms (6 %), DRAM slicing 9.1 ms, HardSwish 6.3 ms, resize chains 7.3 ms. + +## Remaining backlog (gains on the ot128 trace of 211.6 ms, PandaSet, unless marked e2e; estimates, not measurements) + +Each gain is the measured kernel or host time of the work an item removes, with a range for what replaces it. Items +overlap, so the gains do not add up. qt128 follows the same ranking. From `OPT_BASELINE.md` "Ranked optimization +opportunities": + +1. **Two-term point linears as one K-concatenated matmul per layer (trace −60..−80 ms).** One matmul over + `[x_hi | x_hi | x_lo] × [W_hi; W_lo; W_hi]` with fp32 dest and packer L1 accumulation replaces 2–3 matmuls per part + and their fp32 partial adds. Same exact bf16 products; only the fp32 summation order changes (re-run the gates). +2. **MK-PointMLP, a row-block-streamed point MLP megakernel (trace −100..−115 ms beyond today, overlapping 1; D19).** + The point layers are row-local except the gathers and K1; their bf16 weight terms fit L1 (at most 655 KB). Chain + the encoder, the point fuse and the head per row block in L1, with the two-term split on the fly. +3. **A fused two-term split (trace −10..−14 ms; on the way to 1–2).** One pass reads fp32 and writes the hi / lo bf16 + terms (exact: the same RNE casts) instead of 4 programs per input. +4. **`N_cap` / `M_cap` buckets (trace −100..−160 ms on frames with N ≤ 32–64 k, e.g. every nuScenes frame).** Two or + three trace variants per profile sharing weights; the host picks the smallest that covers the frame. PandaSet-like + frames (N = 146 k) gain nothing. +5. **K1 throughput (trace −10..−14 ms; owned shared kernel).** Batch output rows per worker, accumulator in the + RISC-V local memory, then a compute-engine reduction; bit-exactness stays. +6. **Image-branch DRAM slicing and glue (trace −8..−14 ms; MK-ImageStage).** Keep the stride-1 stage chain + height-sharded in L1 (a 128 × 1024 × 128 map is 33.5 MB in bf16), fold the bf16 cast and the HardSwish into one pass. +7. **Decoder resizes and `fuse1` (trace −5..−8 ms).** Resize matmuls in TILE layout end to end (also lifts the C18 fp32 + limitation), K-split accumulation inside the conv. +8. **pixel2point gathers (trace −2..−4 ms).** Feed the bf16 gather output straight into item 1's operand (exact). +9. **Host pre-processing (e2e −60..−85 ms).** 107 ms on PandaSet: a C++ / numba implementation of the node's CUDA + kernels (projection + interpolation grid in one pass, counting-sort plan), bit-identical to today's host code. +10. **Host after the device (e2e −15..−25 ms).** Row-major readback of only N rows; argmax over the permuted rows, then + scatter labels and scores instead of 27-column float rows (exact: the same first-max rule). +11. **H2D `features` layout (e2e −3.5 ms).** 160,000 pages of 32 bytes at 1.24 GB/s: upload as 128-byte rows. +12. **Throughput by pipelining (frames/s; latency unchanged).** Overlap the host stages of neighbouring frames with the + device work; 2 CQ staging once items 1–2 shrink the replay. +13. **Megakernel candidates (D19, PLAN §5.2 / §5.3)** by measured block time: MK-PointMLP (133.7 / 101.1 ms; first), + MK-SegGather (K1 + gathers, 24 ms), MK-ImageStage (46 ms). A whole-model kernel is not practical: the fp32 point + tensors at `N_cap` (82–164 MB each) do not fit L1. +14. **Precision margin (accuracy, an optimization-phase precision item).** The 0.41-point margin on nuScenes 0916 kf40 + (ot128) is the narrowest of the release. Items 1–2 change the accumulation order, so re-check it after each step. + Measured option: `CONV_TERMS=2` (+0.09 pt, +37 ms). Unmeasured: fp32 resize outputs (after item 7), fp32 gathers of + the 128-channel maps. No relaxation (bf16 maps, a TF32 point island, HiFi2, bfp8) is a candidate without new device + evidence on that frame. diff --git a/PYTHON.md b/PYTHON.md new file mode 100644 index 0000000000000000000000000000000000000000..fbac4c9dc7aec795f118f6b9fd55662a7d556660 --- /dev/null +++ b/PYTHON.md @@ -0,0 +1,220 @@ +# Python API: FRNet (Autoware lidar_frnet) on Blackhole + +Use this API from Python code (a pipeline, a notebook, a ROS 2 node wrapper). You do not need the HTTP server: the +API and the server share the decoders, the device graph and the post-processing, so the outputs and the speed are +the same. + +## Install + +Install the package on top of an environment that already has `ttnn` (a tt-metal `python_env` at `44d66500520` +with `patches/tt-metal-eth-dispatch.patch`, or the tt-model container). From the root of the model repository (the +directory that holds `pyproject.toml`, `README.md` and `code/`): + +```bash +pip install -e . # the Python API +pip install -e ".[server,test]" # + the HTTP server and the tests +``` + +The pip project is the repository's top-level `pyproject.toml`; it installs the package from `code/tt_frnet` (there is no +`pyproject.toml` inside `code/`, because the container build copies `code/` over the tt-metal tree). + +The package carries `tt_frnet.ttaw`, the shared code of the Autoware ports to Blackhole (device open, trace runner, +decoders, model base class, HTTP app, the K1 `segment_reduce` kernel), vendored at the version recorded in +`code/tt_frnet/ttaw/VENDORED.json` (0.20.0). + +| You want to run | Extras | +|---|---| +| the Python API | none | +| the HTTP server (`tt_frnet.server.app`, see `SERVING.md`) | `server` | +| host tests (no device; device tests are skipped): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests` | `server,test` | +| device tests: `python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py code/tt_frnet/tests/test_e2e_device.py` | `test` | + +## Quickstart + +Run from the repository root (the sample paths are relative to it): + +```python +from tt_frnet import FRNet + +with FRNet.from_pretrained(device_id=0) as model: # variant="qt128" for the Hesai QT128 profile + out = model("code/tt_frnet/samples/synthetic_ot128.npz", # one sweep in the sensor frame + calibration={"preset": "synthetic_ot128"}) # its sensor -> base_link transform (ego crop box) + +print(out.class_counts()) # points per class (raw + interpolated) +labels, scores, xyz = out.label_ids, out.scores, out.points[:, :3] +``` + +`examples/quickstart.py` runs this snippet and writes `quickstart.json` (the `/predict` body) and `quickstart_bev.png` +(a bird's-eye view of the p150 classes next to the stored CPU reference of the shipped sample). + +## `FRNet.from_pretrained(...)` + +```python +FRNet.from_pretrained( + model_id=None, # HF repo or a local directory with the weights files; default AutowareFoundation/lidar_frnet + *, + revision=None, # default for the default repo: the validated commit a53a1c11b0b (tag v2.0) + variant=None, # load-time variant: "ot128" (Hesai OT128, Autoware's default) | "qt128"; default $FRNET_VARIANT or "ot128" + device_id=None, # chip to open; default $TT_DEVICE_ID or 0 + device=None, # an already-opened ttnn device (tt_frnet.device.open_device); close() does not close it + dispatch=None, # "eth" (p150 target, 12x10 grid) | "worker" (A/B only, 11x10) | "auto"; default $FRNET_DISPATCH or "eth" + num_command_queues=None, # default $FRNET_NUM_CQS or 1 + weights_dir=None, # explicit local weights directory; no Hub access + warmup_variants="default", # trace variants to capture now; see "Warm-up" + verbose=False, +) -> FRNet +``` + +What it does: resolves the weights first, so a Hub problem never claims the chip (`weights_dir` > +`$FRNET_WEIGHTS_DIR` > a local `model_id` directory > the HF snapshot at the pinned revision, restricted to +`frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml`, with an offline fallback to the cache), checks +the sha256 of the variant's ONNX against the pinned one, reads the variant's `ml_package_frnet_.param.yaml` +(FOV, frustum and interpolation grids, the 27 class names, the palette, the point / cell profiles), opens the chip +(ETH dispatch, 12×10, 1 CQ, `l1_small_size` 64 KiB, `trace_region_size` 256 MiB: `DEVICE_DEFAULTS` in +`tt_frnet/device.py`, overridable with `FRNET_*`), builds the graph, then compiles and captures the metal trace. If +ETH dispatch cannot open (tt-metal without the patch), it warns and falls back to WORKER dispatch. + +`variant` picks one of the two networks of the weights repo. Each is tied to its sensor (Autoware's `sensor_model`): + +| variant | sensor | FOV up / down | frustum grid (H × W) | interpolation grid | points (min / max) | unique cells (min / max) | +|---|---|---|---|---|---|---| +| `ot128` (default) | Hesai OT128 | +15° / −25° | 128 × 1024 | 128 × 4096 | 5,000 / 160,000 | 3,000 / 60,000 | +| `qt128` | Hesai QT128 | +52.6° / −52.6° | 128 × 256 | 128 × 1024 | 5,000 / 120,000 | 3,000 / 60,000 | + +Precision knobs (read once at load from the environment, pinned in the package's `serve.env`; the defaults are the +validated configuration and every number of the model card uses them): `FRNET_POINT_TERMS=2` (each point-wise linear +as two-term bf16 operands, about 16 mantissa bits, fp32 accumulation), `FRNET_MAP_DTYPE=float32` (fp32 image-branch +maps, bf16 conv operands), `FRNET_CONV_TERMS=1`. Other values are A/B switches for the optimization phase; they fail +the frozen accuracy gates (`OPT_BASELINE.md`, "The precision cost of the policy"). + +## Warm-up + +`from_pretrained` returns a warm model: it builds the graph of the variant, runs it once eagerly on an empty frame +(this first run compiles the kernels and uploads the weights) and captures it as ONE metal trace, so no call compiles +anything. There are no shape buckets: every frame is padded to the profile's fixed device shapes (`N_cap` points = +160,000 / 120,000, `M_cap` cells = 60,000), so one trace serves every input of the profile. + +- Measured (2026-10-09, the shipped sample): with an empty JIT cache the load took 156.6 s for ot128 and + 151.9 s for qt128 (firmware and every kernel compiled); with that cache warm, in a new process, + 7.5 s and 6.6 s. The trace is captured during the load, so the first call costs about as + much as the next ones (320.0 / 304.7 ms for ot128, 241.1 / 214.9 ms for qt128, first / second call, on a shared host). +- `warmup_variants="none"` (server: `FRNET_WARMUP=none`) skips the capture at load; call `model.warmup()` before the + first frame (it is idempotent). +- The JIT cache is tt-metal's (`~/.cache/tt-metal-cache`, or `TT_METAL_CACHE`); inside the container it is + `~/.cache/tt-model/frnet-p150/cache` of the host. + +## Call: `model(points, calibration=..., **kwargs)` + +| Argument | Type | Description | +|---|---|---| +| `points` (first positional) | path, `bytes` + `fmt=`, (N, C) array / tensor, structured array, `PointCloud`, JSON envelope | ONE LiDAR sweep in the **sensor frame** (x forward, y left, z up, metres), fields `x, y, z, intensity` with the raw intensity the node casts (0-255 for uint8 drivers); see "Input types" | +| `calibration` | `{"T_base_link_from_sensor": }`, `{"preset": name}` (a file of `code/tt_frnet/calib/`), optional `"ego_crop_box"` | the static sensor -> `base_link` transform the node looks up once for its ego crop box. `ego_crop_box`: `[min_x, min_y, min_z, max_x, max_y, max_z]` in `base_link` (default Autoware's `(-1.53, -1.15, -0.1)` .. `(6.1, 1.15, 3.1)`), or `None` (or all zeros) to disable the box, the node's own switch; a disabled box needs no transform | +| `class_probability_threshold` | float in [0, 1], default 0.05 | the filtered cloud keeps a point unless its probability of a filter class is >= this value (Autoware's `filter.class_probability_threshold`) | +| `filter_classes` | comma-separated class names, `""` = none, default `"drivable_surface"` | Autoware's `filter.classes` (at most 16); an unknown name is refused | + +Both knobs only change `filter_keep`; the class decision (`label_ids`, `scores`) never depends on them. They are +host-only (no device shape changes), the defaults reproduce Autoware's `frnet.param.yaml`, and a bad value raises +`tt_frnet.io.InputError` (HTTP 400) before the device runs. `model.info["runtime_params"]` (and the server's `/info`) +lists them with their defaults. + +### Input types + +- Point clouds: a path (`.bin` raw float32 rows -- pass `fields=` if not `x, y, z, intensity`; `.npy`; `.npz`; + `.pcd` ascii / binary / binary_compressed), raw bytes with `fmt=`, an (N, C) numpy array or torch tensor, a + structured array, the JSON envelope of `/predict`, or `tt_frnet.io.PointCloud`. The frame is the **sensor** frame + (Autoware's `~/input/pointcloud` before any transform), one sweep, not motion-compensated. +- Rows with a non-finite x / y / z are dropped by the decoder; `input_index` refers to the decoded rows. +- Calibration transforms: a 4×4 (or 3×4) row-major matrix, `{"translation": [x, y, z], "rotation_wxyz": [w, x, y, z]}`, + or Autoware's `{x, y, z, roll, pitch, yaw}`. With the crop box on and no transform the call is refused (the node would + skip every frame because its TF lookup fails); the error names both fixes. +- `sweeps`, `images`, `stream` and `inputs` are refused: FRNet takes one sweep and its calibration only. +- A frame outside the variant's profile (input points, points after interpolation, or unique cells out of the table + above) is not an error: as the node, which logs and publishes nothing, the call returns an empty output whose + `meta["skipped"]` holds the node's message, and nothing runs on the device. + +## Output + +`model(...)` returns a `tt_frnet.FRNetSegmentation` (a `ttaw.outputs.Segmentation3D`); `out.to_dict()` is exactly the +`POST /predict` body (SERVING.md section 3.2). The point order is the node's: the input points that survive the ego +crop box, in input order, then the points Autoware's interpolation appends (D4: published as Autoware does). + +| field | type | meaning | +|---|---|---| +| `label_ids` | uint8 `[N]` | Autoware's `class_id` (first maximum of the 27 probabilities, as `fill_cloud_kernel`); names in `class_names` | +| `scores` | float32 `[N]` | Autoware's `probability`: the softmax probability of that class | +| `points` | float32 `[N, 4]` | x, y, z, intensity of every output point in the sensor frame (the segmentation cloud's coordinates; the interpolated points carry interpolated values) | +| `filter_keep` | bool `[N]` | membership of Autoware's `~/output/pointcloud/filtered` cloud (False = removed by the class filter) | +| `num_points_raw` | int | the first `num_points_raw` rows are input points, the rest are interpolated | +| `input_index` | int32 `[num_points_raw]` | the decoded input row of every raw output point | +| `class_names` | 27 str | `drivable_surface`, `other_flat_surface`, `sidewalk`, `manmade`, `vegetation`, `car`, `bus`, `emergency_vehicle`, `train`, `truck`, `tractor_unit`, `semi_trailer`, `construction_vehicle`, `forklift`, `kart`, `motorcycle`, `bicycle`, `pedestrian`, `personal_mobility`, `animal`, `pushable_pullable`, `traffic_cone`, `debris`, `stroller`, `other_stuff`, `noise+ghost_point`, `unknown` (Autoware's names; ids 22 / 23 were trained as stroller / debris, see Limits) | +| `meta` | dict | `variant`; `num_points_input`; `ego_crop` (the box and transform used); the network-input counts (`num_points_raw`, `num_points_interpolated`, `num_points`, `num_unique_coors`, `cropped_by_ego_box`, `crop_applied`, `max_points_per_cell`); `plan` (`n`, `m`, `n_cap`, `m_cap`); `filter` (classes, threshold, points kept); `skipped` (the node's message) for an out-of-profile frame | +| `timing_ms` | dict | `preprocess`, `device` (staging + H2D + trace + D2H + un-permute), `postprocess`, `total` of this call | + +`out.class_counts()` gives the points per class (raw + interpolated); `out.to_dicts()` one row per class +(`label`, `label_id`, `points`); `len(out)` the number of output points. The node's visualization cloud is +`model.config.palette` indexed by `label_ids` (`tt_frnet.host.postprocess.visualization_rgb`). + +## Lifetime and information + +- `model.close()` releases the trace and the device tensors and closes the chip if the model opened it; idempotent. + `with` calls it for you; an unclosed model is closed when Python exits. +- `model.info`: weights (repo, tag, revision, path), device (`dispatch`, `grid`, `cores`, `num_command_queues`, + `fallback`), variant, warm variants, warm-up times, runtime parameter defaults, the variant config (`frnet`), the + trace and graph descriptions. +- Calls from several threads are safe: the device calls are serialised by a lock. One model per process per chip. +- `tt_frnet.viz.render_bev(points, label_ids, palette=...)` and `render_compare(...)` draw bird's-eye views (PIL, no + OpenCV). +- `model.device_probs(plan)` returns the device's `pred_probs` `(N, 27)` of a prepared frame (tests and tools; call it + under `model._lock`). + +## Speed + +Warm calls, batch 1, ETH dispatch, 1 CQ, 12x10, the serve.env precision knobs (`code/scripts/bench.py`, 100 +iterations; the numbers of `OPT_BASELINE.md`, 2026-10-09, on a shared host whose load moves the host stages): + +| stage, p50 | ot128, shipped sample | ot128, PandaSet 019 f40 | qt128, shipped sample | qt128, PandaSet 019 f40 | +|---|---:|---:|---:|---:| +| `model(path)` end to end | 292.6 ms | 355.7 ms | 221.1 ms | 244.3 ms | +| host pre-processing (decode, crop, projection, fp16 interpolation, unique cells, device plan) | 55.3 ms | 107.2 ms | 54.2 ms | 83.5 ms | +| H2D (9 inputs, 8.6 / 6.2 MB) | 5.42 ms | 5.41 ms | 4.04 ms | 4.00 ms | +| device trace, one forward | 210.02 ms | 211.72 ms | 133.53 ms | 134.91 ms | +| D2H (fp32 `pred_probs`) | 7.65 ms | 7.43 ms | 5.80 ms | 5.55 ms | +| un-permute + host post-processing (argmax, filter) | 7.1 + 5.0 ms | 12.8 + 14.8 ms | 6.1 + 6.8 ms | 5.9 + 10.5 ms | + +- Back-to-back replays take 211.64 ms (ot128) and 134.87 ms (qt128) per PandaSet frame: 4.72 and 7.41 frames/s of + device throughput. A synchronous call gives 2.81 / 4.09 frames/s on that frame. +- The device time barely depends on the frame: every point-wise op runs on the profile's `N_cap` rows. The host + stages grow with the cloud. +- The shipped samples are synthetic scans: ot128 53,411 points (N = M = 52,613 after the crop, no interpolated point), + qt128 45,783 points (N = 69,553 after interpolation, M = 18,926). PandaSet 019 frame 40 is a 107,146-point Pandar64 + sweep (N = 145,961 / M = 40,691 on ot128); it is not shipped. +- `OPT_REPORT.md` ranks what the optimization phase will change (the two-term point layers, K1, the image-branch glue, + shape buckets, the host pre-processing). + +## Tests + +From the repository root, with `code/` on `PYTHONPATH` (or the package installed): + +- Host (no device, no ttnn call): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests`. Inside the + tt-models workspace the suite runs on its fake ttnn (`-p fake_ttnn_plugin`), which also enables `test_tt_host.py`; + tests that need workspace-only oracles or data skip elsewhere, and FAIL inside the workspace when that data is + missing. `test_reference_cpu.py` needs onnxruntime. +- Device (one p150): `python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py + code/tt_frnet/tests/test_e2e_device.py` (per-module PCC against the fp32 CPU reference on replay outputs, the K1 + segment-max sites included; end-to-end agreement with ONNX Runtime on 17 OT128 / 5 QT128 frames, the shipped samples + against their stored references, replay == eager, API == `/predict`). The public-frame gates run only inside the + tt-models workspace, where their data lives: PandaSet, nuScenes and Autoware's sample rosbag, of which only PandaSet + may be redistributed. +- The gates are frozen in `tests/*.gates.json` at the first green run and are never loosened. + +## Limits + +- Batch 1 on the chip; one model per process; one frame (one sweep) per call; one variant per model. +- The device input has a fixed size per profile (`N_cap` 160,000 / 120,000 points, `M_cap` 60,000 cells): a frame + outside Autoware's profile ranges is skipped exactly as the node skips it. +- Label names 22 / 23: Autoware's `class_names` read `debris` / `stroller`, but the weights were trained with + stroller = 22 and debris = 23 (AWML config). The port keeps Autoware's names for parity. +- Numerics: the device's per-point probabilities differ slightly from the fp32 reference, so points near a class + boundary can take the other class (98.9-99.6 % of the points agree on the public frames, see the model card). +- Not ported: Autoware's `PointCloud2` layouts and the filtered cloud's `output_format` conversion; the API takes + arrays or files and returns arrays. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ca171ad8a262bf6bae08c1eec98c4f2262a7a1c6 --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +--- +tags: +- blackhole +- p150 +- tt-dit-server +- tt-model-cache +- tt-model-container +- tenstorrent +- ttnn +- tt-metal +- tt-nn +- autoware +- autonomous-driving +- lidar +- point-cloud +- semantic-segmentation +- frnet +- range-view +- tt-model-catalog +pipeline_tag: image-segmentation +license: apache-2.0 +license_link: https://huggingface.co/AutowareFoundation/lidar_frnet +base_model: +- AutowareFoundation/lidar_frnet +--- + +# frnet-p150 + +FRNet (Autoware lidar_frnet): the LiDAR semantic segmentation network of Autoware's opt-in `autoware_lidar_frnet` node, ported to one Tenstorrent Blackhole p150 with tt-nn. One LiDAR sweep in the sensor frame (x, y, z, raw 0-255 intensity) in; a class id and probability for every point (27 T4 classes, Autoware's interpolated points included) and the node's filtered-cloud mask out, with the node's exact pre- and post-processing. One image, two serve profiles: `ot128` (Hesai OT128, the default) and `qt128` (Hesai QT128). +Weights: [AutowareFoundation/lidar_frnet `v2.0`](https://huggingface.co/AutowareFoundation/lidar_frnet/tree/a53a1c11b0b28d31fd50a03abef5db0e830a5b3c) · Paper: [arXiv:2312.04484](https://arxiv.org/abs/2312.04484) · Autoware package: [autoware_lidar_frnet](https://github.com/autowarefoundation/autoware_universe/tree/9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd/perception/autoware_lidar_frnet) · Training code: [tier4/AWML (projects/FRNet)](https://github.com/tier4/AWML) · Port: [`code/`](https://huggingface.co/changh95/frnet-p150/tree/main/code) + +Runs on **p150** (mesh `P150`). Configuration: dispatch on the ETH cores, 1 command queue, 12×10 compute grid. Precision: fp32 weights; the point-wise layers (point MLPs, fusions, head) take fp32 activations as two bf16 terms with fp32 accumulation; the frustum CNN runs fp32 feature maps with bf16 conv operands; HiFi4 math with fp32 accumulation everywhere; no bfp8. Both serve profiles run in this configuration, and all numbers on this card were measured in it. + +Packaged and published with [tt-model-manager](https://github.com/tenstorrent/tt-model-manager) 0.1.0 (manifest schema 5.1). + +## Quickstart (Python) + +Prerequisite: a tt-metal / ttnn environment at tt-metal [`44d66500520`](https://github.com/tenstorrent/tt-metal/commit/44d66500520fda9f2c7060c0f6b41ec48f7ab37e) with [`patches/tt-metal-eth-dispatch.patch`](patches/tt-metal-eth-dispatch.patch) applied. ttnn is not on PyPI. + +```bash +hf download changh95/frnet-p150 --exclude "image/*" --local-dir frnet-p150 && cd frnet-p150 +pip install -e . # adds numpy<2, pillow, pyyaml, onnx, huggingface_hub; ttnn and torch come from tt-metal +pip install -e ".[server,test]" # optional: the HTTP server and the tests +``` + +Run the snippet from the model repo root: the sample and its calibration preset are paths relative to it. + +```python +from tt_frnet import FRNet + +with FRNet.from_pretrained(device_id=0) as model: # variant="qt128" for the Hesai QT128 profile + out = model("code/tt_frnet/samples/synthetic_ot128.npz", # one sweep in the sensor frame: path, (N, 4) array, .npy/.npz/.pcd, or a PointCloud + calibration={"preset": "synthetic_ot128"}) # its sensor -> base_link transform, for the node's ego crop box + +print(out.class_counts()) # points per class (raw + interpolated) +labels, scores, xyz = out.label_ids, out.scores, out.points[:, :3] +``` + +- `from_pretrained` downloads [`AutowareFoundation/lidar_frnet`](https://huggingface.co/AutowareFoundation/lidar_frnet) at the pinned commit `a53a1c11b0b` (tag `v2.0`, 85 MB) to your HF cache, checks the ONNX sha256, opens the chip, builds the graph of the variant and captures the metal trace. The first load compiles the kernels (156.6 s for ot128 and 151.9 s for qt128 with an empty JIT cache); later loads take 7.5 / 6.6 s. +- The trace is captured during the load, so the first call compiles nothing: it took 320 ms (ot128) / 241 ms (qt128), against 305 / 215 ms for the second call. +- The `with` block releases the trace and closes the chip. Without `with`, call `model.close()`. +- For your own cloud, pass its transform: `calibration={"T_base_link_from_sensor": T}` (4×4, or `{x, y, z, roll, pitch, yaw}`), or `{"ego_crop_box": None}` to disable the ego crop box. + +| | | +|---|---| +| **Input** | ONE LiDAR sweep in the **sensor frame** (x forward, y left, z up; Autoware's `~/input/pointcloud`): path (`.bin` / `.npy` / `.npz` / `.pcd`), raw bytes with `fmt=`, an (N, C) float array or tensor, or a `PointCloud`; fields `x, y, z, intensity` (raw, 0-255). `calibration`: `{"T_base_link_from_sensor": ...}` or `{"preset": name}` for the node's ego crop box, optional `ego_crop_box` (bounds, or `None` to disable it). | +| **Options** | `class_probability_threshold=0.05`, `filter_classes="drivable_surface"` (Autoware's filter: they change `filter_keep` only). `from_pretrained(device_id=0, variant="ot128" \| "qt128", dispatch="eth", weights_dir=None, device=None)`. | +| **Output** | `FRNetSegmentation`: `label_ids` uint8 [N] (Autoware's `class_id`), `scores` float32 [N] (its `probability`), `points` float32 [N, 4], `filter_keep` bool [N] (the filtered cloud), `num_points_raw`, `input_index` [N_raw], `class_names` (27), `meta` (crop, counts, `skipped`), `timing_ms`. Order: the input points kept by the ego crop box (input order), then Autoware's interpolated points. | +| **Methods** | `out.to_dict()` gives the `/predict` JSON. `out.class_counts()` gives the points per class, `out.to_dicts()` one row per class. `tt_frnet.viz.render_bev(points, label_ids)` draws a bird's-eye view. | + +- The API gives the same output as the HTTP server `/predict`: both share the decoders, the device trace and the host post-processing (checked on the device by `test_api_equals_server`, both profiles). +- `variant` selects the network at load time. Each is tied to its sensor's FOV and frustum grid: `ot128` +15° / −25°, 128 × 1024 (Autoware's default `sensor_model`); `qt128` ±52.6°, 128 × 256. +- A frame outside the variant's profile (5,000-160,000 / 120,000 points, 3,000-60,000 cells) returns an empty output with the node's message in `meta["skipped"]`, as the node publishes nothing; nothing runs on the device. +- One model uses one chip; calls from several threads are serialised. +- Full reference: [`code/PYTHON.md`](code/PYTHON.md). Runnable example: [`examples/quickstart.py`](examples/quickstart.py) (also writes `quickstart_bev.png`, the p150 classes from above next to the stored CPU reference). + +## Serving (HTTP) + +```bash +tt-model pull changh95/frnet-p150 --with-weights +tt-model serve changh95/frnet-p150 # QT128: tt-model serve --profile qt128 changh95/frnet-p150 (options go before the repo id); tt-cli: tt serve changh95/frnet-p150 +python3 code/tt_frnet/server/client.py --points code/tt_frnet/samples/synthetic_ot128.npz --calib-preset synthetic_ot128 --out req.json +curl -s localhost:20000/predict -H 'Content-Type: application/json' -d @req.json +tt model stop changh95/frnet-p150 +``` + +- The image does not contain the weights. `--with-weights` puts them in your HF cache. +- The server uses port 20000 (or the next free port). It is ready when the log shows `Application startup complete`. +- Two serve profiles (`tt-model profiles changh95/frnet-p150`): `ot128` (the default) and `qt128`. Each serves one variant. +- `client.py` builds the request with the standard library only; add `--url http://127.0.0.1:20000` to send it, `--calib my_calib.json` for your own transform. +- `POST /predict`: `points` (base64 `.bin` / `.npy` / `.npz` / `.pcd`, sensor frame, fields x, y, z, intensity); `calibration` (`T_base_link_from_sensor`, or `{"preset": name}`; optional `ego_crop_box`, `null` disables the box); optional `params` (`class_probability_threshold`, `filter_classes`), `output_format`. Also `GET /health`, `GET /info` (with the effective default of every knob and the calibration presets), `GET /v1/models` (stub). Contract: [`SERVING.md`](SERVING.md) section 3. + +```json +{"model": "frnet-p150", "frame_id": "sensor", "num_points": 52613, "num_points_raw": 52613, + "labels": {"format": "npz", "key": "labels", "dtype": "uint8", "shape": [52613], "data": "..."}, + "scores": {"format": "npz", "key": "scores", "dtype": "float32", "shape": [52613], "data": "..."}, + "points": {"format": "npz", "key": "points", "dtype": "float32", "shape": [52613, 4], "data": "..."}, + "filter_keep": {"format": "npz", "key": "filter_keep", "dtype": "bool", "shape": [52613], "data": "..."}, + "input_index": {"format": "npz", "key": "input_index", "dtype": "int32", "shape": [52613], "data": "..."}, + "class_names": ["drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "..."], + "class_counts": {"drivable_surface": 2913, "other_flat_surface": 20118, "sidewalk": 5, "manmade": 22898, "vegetation": 6526, "car": 13, "semi_trailer": 140, "...": 0}, + "meta": {"variant": "ot128", "num_points_input": 53411, "cropped_by_ego_box": 798, "num_points_interpolated": 0, "num_unique_coors": 52613, + "ego_crop": {"enabled": true, "bounds": [-1.53, -1.15, -0.1, 6.1, 1.15, 3.1], "reference_frame": "base_link", "...": "..."}, + "plan": {"n": 52613, "m": 52613, "n_cap": 160000, "m_cap": 60000, "...": "..."}, + "filter": {"classes": ["drivable_surface"], "class_probability_threshold": 0.05, "kept": 44747}, "...": "..."}, + "timing_ms": {"decode": 24.711, "preprocess": 47.664, "device": 229.164, "postprocess": 5.018, "model_call": 281.879, "total": 310.689}} +``` + +- `labels` / `scores` / `points` / `filter_keep` / `input_index` are base64 NPZ arrays (`tt_frnet.io.decode_array`, or numpy on the decoded bytes); `class_counts` and `meta` are plain JSON. +- The first `num_points_raw` points are input points (`input_index` maps them back to the input rows); the rest are the points Autoware's interpolation appends, published as Autoware does. + +## Demo + +The p150 output on the shipped samples: synthetic scans generated by this repository (a ray cast against boxes; no dataset involved). Left: the p150 classes; middle: the stored fp32 CPU reference; right: the points whose class differs. + +| OT128 profile: p150 vs the CPU reference | QT128 profile: p150 vs the CPU reference | +|:---:|:---:| +| ![](media/frnet_synthetic_ot128_tt_vs_cpu.png) | ![](media/frnet_synthetic_qt128_tt_vs_cpu.png) | + +On public driving datasets (p150 outputs, OT128 profile; the frames themselves are not in this repository). The caption of each image gives its agreement with the fp32 CPU reference on that frame. + +| PandaSet 019, frame 40: front camera and bird's-eye view, p150 classes vs the mapped dataset labels | PandaSet 029, frame 40 | +|:---:|:---:| +| ![](media/frnet_ot128_pandaset_019_40_tt.png) | ![](media/frnet_ot128_pandaset_029_40_tt.png) | +| **PandaSet 019, frame 40: p150 vs the CPU reference** | **PandaSet 019, frames 00-75 (every 5th frame)** | +| ![](media/frnet_ot128_pandaset_019_40_tt_vs_cpu.png) | ![](media/frnet_ot128_pandaset_019_bev_tt.gif) | +| **nuScenes v1.0-mini scene-0103, key-frame 20: non-commercial, CC BY-NC-SA 4.0** | **nuScenes v1.0-mini scene-0916, key-frame 40, p150 vs the CPU reference (the hardest frame of the release): non-commercial, CC BY-NC-SA 4.0** | +| ![](media/frnet_ot128_nuscenes_scene-0103_20_tt_NC.png) | ![](media/frnet_ot128_nuscenes_scene-0916_40_tt_vs_cpu_NC.png) | + +PandaSet renders: contains data from PandaSet (Scale AI and Hesai), https://pandaset.org, licensed under CC BY 4.0 and the PandaSet Dataset Terms; converted to the sensor frame, rendered as bird's-eye views and downscaled camera images with projected points; Scale AI and Hesai do not endorse this work. The nuScenes renders are **non-commercial (CC BY-NC-SA 4.0)**: rendered from the nuScenes dataset, © Motional AD Inc., nuScenes Terms of Use; Motional does not endorse this work. Sources, changes and the full attributions: [`media/ATTRIBUTION.md`](media/ATTRIBUTION.md). + +## Demo & Performances + +Warm, batch 1, 2026-10-09. Latency: the stage bench of [`OPT_BASELINE.md`](OPT_BASELINE.md) (`code/scripts/bench.py`, 100 iterations per stage; re-checked on the release commit to within 0.05 ms on the device rows) on the shipped synthetic sample (OT128: 53,411 points, 52,613 after the ego crop; QT128: 45,783 points, 69,553 after Autoware's interpolation) and on a PandaSet frame (107,146 points; not shipped); the served rows from uvicorn on the host (the app the container runs) and a loopback client, 50 requests of the shipped sample. The host is shared with other jobs, so the host stages move with its load; the device rows repeat to 0.01 ms. Accuracy: the p150 output against the fp32 CPU reference of the same network (ONNX Runtime on the deployed ONNX, with the port's Autoware pre- and post-processing, which is bit-identical to the research reference), on every raw point the node scores (public frames); on the shipped samples, on every output point, Autoware's interpolated points included. + +| Metric | Performance | +|---|---:| +| Class agreement with the fp32 CPU reference, shipped sample | OT128 **99.55 %** of 52,613 points (filtered-cloud mask 99.77 %) · QT128 **99.62 %** of 69,553 (99.87 %) | +| Class agreement, all 113 converted public frames per profile: PandaSet 019 / 029 (32 frames, every 5th) and nuScenes v1.0-mini mini_val (81 key-frames), production API | OT128: PandaSet **99.66 %** pooled (worst frame 99.35 %), nuScenes **99.19 %** (worst 98.37 %) · QT128: PandaSet 99.29 % (98.69 %), nuScenes 99.44 % (98.10 %) | +| Frozen device gates, 17 OT128 / 5 QT128 frames (PandaSet, nuScenes, Autoware's sample rosbag; gates `pred_probs` PCC ≥ 0.998, agreement ≥ 98.5 %) | **all pass**: OT128 min PCC 0.99969, min agreement **98.907 %** (nuScenes 0916 kf40, the binding frame); QT128 0.99980 / 99.158 % | +| Module PCC vs the fp32 reference (replay outputs, PandaSet 019 f40) | every gated module ≥ 0.9996: worst OT128 `point_fusion1` 0.999729, QT128 `attention4` 0.999649 (gate 0.999) | +| Mapped-label mIoU, OT128 (a sanity check, not a paper metric: dataset labels mapped to the 27 T4 classes, see Caveats) | PandaSet 32 frames: coarse **p150 0.714** · CPU 0.715, fine (classes with ≥ 1,000 labelled points) 0.447 · 0.448 · nuScenes 81 frames: coarse **p150 0.483** · CPU 0.483, fine (same rule) 0.451 · 0.453 | +| Python `model()` call, shipped sample (host pre-processing, H2D, trace, D2H, host post-processing) | OT128 **292.6 ms p50** (3.4 calls/s) · QT128 **221.1 ms p50** (4.5 calls/s) | +| Python `model()` call, PandaSet frame (107,146 points; not shipped) | OT128 355.7 ms · QT128 244.3 ms p50 | +| Served `/predict` `timing_ms.total` (uvicorn on the host, the shipped sample) | OT128 **310.4 ms** · QT128 **228.9 ms** median (including 24.3 / 20.7 ms of request decode) | +| Served client round trip, loopback | OT128 328.1 ms · QT128 247.3 ms median | +| Device trace, one blocking forward (shipped sample) | OT128 **210.02 ms** · QT128 **133.53 ms** | +| Back-to-back trace replays | OT128 **209.95 ms** per forward (4.76 frames/s; 211.64 ms on PandaSet) · QT128 **133.43 ms** (7.49 frames/s; 134.87 ms) | +| Host pre-processing · H2D · D2H · un-permute + post-processing (shipped sample) | OT128 55.3 · 5.42 · 7.65 · 7.1 + 5.0 ms; QT128 54.2 · 4.04 · 5.80 · 6.1 + 6.8 ms | +| `from_pretrained` load: empty JIT cache / warm cache | OT128 156.6 s / 7.5 s · QT128 151.9 s / 6.6 s | + +All numbers in this table were measured with dispatch on the ETH cores, 1 command queue and a 12×10 compute grid on one p150 at 1350 MHz AICLK, with the published precision (`FRNET_POINT_TERMS=2`, `FRNET_MAP_DTYPE=float32`, `FRNET_CONV_TERMS=1`). The wider 113-frame agreement set is not gated: 3 of its 226 frame-profile pairs fall below 98.5 % (nuScenes scene-0916 key-frames 11 and 1 on OT128, 98.37 % and 98.499 %; key-frame 0 on QT128, 98.10 %), all on the out-of-domain Singapore scene (see Caveats). The mIoU rows are indicative only: approximate label mappings, small sets, sensors and taxonomy different from the training data, and no published number exists for these weights. Details: [`VERIFICATION_2026-10-09.md`](VERIFICATION_2026-10-09.md), [`OPT_BASELINE.md`](OPT_BASELINE.md), [`OPT_REPORT.md`](OPT_REPORT.md). + +No GPU comparison: no GPU was available on the host where this port was built and measured, so this card makes no GPU speed claim. The reference rows are the port's own fp32 CPU reference on the same host (a correctness baseline, not a speed target). Autoware runs this network as a TensorRT engine (fp16 by default, per the weights card); this port was validated against the fp32 ONNX Runtime reference, not against a TensorRT engine. p150 power was not measured, so no efficiency comparison is made. + +## Caveats + +- First release: **baseline port, optimization pending.** Each profile runs as one metal trace of 1,104 (ot128) / 759 (qt128) programs and is kernel-bound (op-to-op gaps 0.3 %). 63 % (ot128) / 75 % (qt128) of the device time is the point-wise layers kept at higher precision, whose cost is DRAM traffic of fp32 partial sums, not compute; every point-wise op also runs on the profile's full capacity (160,000 / 120,000 rows) whatever the frame. [`OPT_REPORT.md`](OPT_REPORT.md) ranks what comes next. +- Deployment status in Autoware: FRNet is an **opt-in, standalone node**. `autoware_lidar_frnet` ships in autoware_universe with its own launch file (`lidar_frnet.launch.xml`, `sensor_model:=ot128 | qt128`), but no Autoware launch configuration wires it into the default perception stack. This bundle is not a ROS 2 node (Python API and HTTP) and not a certified Autoware component; do not use it for safety-critical driving decisions. +- **Narrow accuracy margin on one frame.** On the hardest public frame of the release (nuScenes scene-0916 key-frame 40, OT128: a 32-beam sensor, out of the training domain, low-confidence outputs), the p150 agrees with the fp32 reference on 98.907 % of the points, 0.41 points above the 98.5 % gate. Every cheaper precision configuration measured during the port failed that frame (bf16 maps 98.28 %, an fp32 × fp32 point island 97.50 %), which is why the release keeps the slower two-term point layers and fp32 maps. Beyond the gated frames, 3 of the 226 frame-profile pairs of the wider public set fall just below 98.5 % (98.10-98.499 %), all on the same Singapore scene-0916. It is the binding constraint, and an explicit precision item, of the optimization phase. +- Points near a class boundary can take the other class: the device's probabilities differ slightly from the fp32 reference (`pred_probs` PCC ≥ 0.9997 on every public frame), and the argmax turns a small difference into a different class. The figures are in the agreement rows above. +- Documented deviations from the node: + - Autoware's CUDA races are replaced by fixed, documented policies: interpolated points are appended in row-major pixel order after the raw points (Autoware's atomic append order varies), the fp16 interpolation grid keeps the farthest point with ties to the later point, a duplicated cell keeps the last voxel (as ONNX Runtime); the output is deterministic; + - the projection uses numpy's float32 `arctan2` / `arcsin`, which may differ from CUDA's `atan2f` / `asinf` by an ulp, so a point exactly on a bin edge could change cell; + - the pre-processing, the argmax and the class filter run on the host (bit-identical to the CPU reference); the network on the device; + - inputs are arrays or files, not `PointCloud2` messages; the filtered cloud is returned as a mask (`filter_keep`), not converted to Autoware's `output_format`; + - label names 22 / 23: Autoware's `class_names` read `debris` / `stroller`, but the weights were trained with stroller = 22 and debris = 23 (AWML config). The port keeps Autoware's names for parity. +- Precision policy of this release: fp32 weights; the point-wise linears split their fp32 inputs and weights into two bf16 terms (three exact bf16 products, fp32 accumulation; the device's own fp32 matmul is TF32-like and fails the gates); fp32 feature maps in the frustum CNN with bf16 conv operands and fp32 weights; HiFi4 with fp32 accumulation and packer L1 accumulation; K1 `segment_reduce` (a custom `generic_op` kernel) is bit-exact. Pinned in `serve.env` (`FRNET_POINT_TERMS=2`, `FRNET_MAP_DTYPE=float32`, `FRNET_CONV_TERMS=1`). +- Domain: the weights were trained by TIER IV on about 16,000 frames of the T4 dataset from Hesai OT128 / QT128 sensors (not public), with a 27-class T4 taxonomy. No public dataset has these sensors or labels, so the card shows agreement with the fp32 reference, not accuracy. On the public frames the reference itself shows the gap: PandaSet's Hesai Pandar64 shares the OT128's vertical FOV and segments cleanly (coarse mapped mIoU 0.714 on the p150, 0.715 for the reference, OT128), nuScenes' 32-beam HDL-32E much less so (0.483 for both), and the QT128 network on these sensors is out of distribution (coarse 0.19 on PandaSet; used here only as a numerical test). Traffic cones are almost never found and pedestrians next to structures often become `manmade`, in the reference as on the p150. +- Validation scope: agreement with the fp32 CPU reference (ONNX Runtime on the deployed ONNX, with the port's bit-exact Autoware pre- and post-processing) on public driving data (PandaSet, nuScenes v1.0-mini) and Autoware's sample rosbag, plus a mapped-label mIoU sanity check (not a paper metric: different taxonomy and sensors). The knobs were validated at their Autoware defaults. +- Does not scale to multiple p150 in a mesh configuration. The build uses a 12×10 compute grid of Tensix cores: the dispatch functions move from one Tensix column to the ETH cores (`patches/tt-metal-eth-dispatch.patch`), so this build assumes that you do not need chip-to-chip ethernet communication. +- `dispatch="worker"` (server: `FRNET_DISPATCH=worker`) is an A/B opt-in. On a p150 it gives an 11×10 grid (211.56 ms per ot128 replay instead of 209.95 ms; 153.71 ms instead of 133.44 ms for qt128); if ETH dispatch is not available (tt-metal without the patch), the model falls back to it with a warning. The numbers on this card do not apply to that mode. +- Batch 1, one sweep per request; requests are serialised on the chip; one variant per process (the serve profile). The device input has a fixed size per profile (160,000 / 120,000 points, 60,000 cells), whatever the cloud. +- Not an OpenAI-compatible API; `GET /v1/models` is a stub so the tt-model ready card does not 404. +- p150 power was not measured, so no efficiency comparison is made. + +## Licensing + +- Weights: [AutowareFoundation/lidar_frnet](https://huggingface.co/AutowareFoundation/lidar_frnet) at tag `v2.0` (commit `a53a1c11b0b28d31fd50a03abef5db0e830a5b3c`), Apache-2.0 per its model card. Not redistributed here: the package only points to them. The upstream card's training data: the T4Dataset (TIER IV), about 16,000 frames (4,000 frames × 4 surrounding sensors), trained with AWML; it states no further data licence notice. +- Pre- and post-processing ported from autoware_universe [`perception/autoware_lidar_frnet`](https://github.com/autowarefoundation/autoware_universe/tree/9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd/perception/autoware_lidar_frnet) (Apache-2.0); the FOV, grids, class names, palette and profiles are read from the weights repo's `ml_package_frnet_*.param.yaml` at load time. +- Port and serving code (`code/`): Apache-2.0. `patches/tt-metal-eth-dispatch.patch` modifies tt-metal (Apache-2.0). +- Sample data: `code/tt_frnet/samples/synthetic_ot128.npz` and `synthetic_qt128.npz` are synthetic scans generated by `code/scripts/make_sample.py` (no dataset involved), with their calibration presets and stored CPU-reference outputs: Apache-2.0. Only these samples ship; the public-dataset frames of the accuracy rows are not in this repository. +- Demo media (`media/`, sources and changes in [`media/ATTRIBUTION.md`](media/ATTRIBUTION.md)): + - PandaSet renders: Contains data from PandaSet (Scale AI and Hesai), https://pandaset.org, licensed under CC BY 4.0 and the PandaSet Dataset Terms. Changes: the Pandar64 sweep re-expressed in an estimated sensor frame, bird's-eye views rendered, the front-camera image downscaled and dimmed, points and labels overlaid. Scale AI and Hesai do not endorse this work. Cite: P. Xiao et al., *PandaSet: Advanced Sensor Suite Dataset for Autonomous Driving*, ITSC 2021. + - nuScenes renders (`media/*_NC.png`), **non-commercial, CC BY-NC-SA 4.0**: Rendered from the nuScenes dataset, © Motional AD Inc., CC BY-NC-SA 4.0 and the nuScenes Terms of Use (https://www.nuscenes.org/terms-of-use). Non-commercial use only; adaptations under the same license. Motional does not endorse this work. Cite: H. Caesar et al., *nuScenes: A Multimodal Dataset for Autonomous Driving*, CVPR 2020; W. K. Fong et al., *Panoptic nuScenes*, RA-L 2022 (lidarseg labels). + - `frnet_synthetic_*` renders: Apache-2.0. + - The agreement and mapped-mIoU rows use PandaSet (CC BY 4.0) and nuScenes v1.0-mini with nuScenes-lidarseg (CC BY-NC-SA 4.0); only the resulting numbers are on this card. + +## Provenance + +These are the exact sources the container image was built from: + +| component | built from | +| --- | --- | +| tt-metal | [`44d66500520fda9f2c7060c0f6b41ec48f7ab37e`](https://github.com/tenstorrent/tt-metal/commit/44d66500520fda9f2c7060c0f6b41ec48f7ab37e) + [`patches/tt-metal-eth-dispatch.patch`](patches/tt-metal-eth-dispatch.patch) (sha256 `08d0ddf6…`; dirty tree: the image includes the patch) | +| weights | [`AutowareFoundation/lidar_frnet@a53a1c11b0b28d31fd50a03abef5db0e830a5b3c`](https://huggingface.co/AutowareFoundation/lidar_frnet/tree/a53a1c11b0b28d31fd50a03abef5db0e830a5b3c) (tag `v2.0`), files `frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml` (ONNX sha256 ot128 `d29bf595…`, qt128 `647af927…`) | +| Autoware reference | autoware_universe [`9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd`](https://github.com/autowarefoundation/autoware_universe/tree/9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd) (`perception/autoware_lidar_frnet`, package 0.53.0) | +| shared package | `ttaw` 0.20.0, vendored as `code/tt_frnet/ttaw` from the Autoware ports' shared `common` repository at commit `89dec49` (`code/tt_frnet/ttaw/VENDORED.json`: version, commit and per-file sha256); it holds the K1 `segment_reduce` kernel | +| `code/` digest (image) | `f0b62790446a51a8` (sha256, first 16 hex digits; `built.code_sha256` of `tt_kernel_manifest.json`) | +| image | `tt-model/frnet-p150:85cbf6cc72b9` (`sha256:85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c`) | +| base images | build stage `ghcr.io/tenstorrent/tt-metal/tt-metalium/ubuntu-22.04-dev-amd64:latest` @ `sha256:df9d279c7f85c17c6fad982d196802682d669cca1b7ced9cbaad8181339cd5fc`; runtime stage `docker.io/library/ubuntu:22.04` @ `sha256:5ec03bb3441e8b0bf3b4f9cd4629a1ae763010dc3035bb8da3ae6cf026486401` (tt-model's `FROM` tags float; these are the digests this build resolved, see [`build_info.json`](build_info.json)) | +| built | 2026-10-09T17:04:34+00:00 by tt-model 0.1.0 | diff --git a/SERVING.md b/SERVING.md new file mode 100644 index 0000000000000000000000000000000000000000..8a92645f83a95374afc9cbe80ccf0ed2fe27cf2a --- /dev/null +++ b/SERVING.md @@ -0,0 +1,211 @@ +# Serving FRNet (Autoware lidar_frnet) on Blackhole with tt-model-manager + +This repo is the authoring source of the **tt-model container package** `changh95/frnet-p150` +(`kind: tt-dit-server`, schema 5.1): `tt-model.yaml` is the manifest, `code/` the port, and +`code/tt_frnet/server/app.py` the ASGI app uvicorn runs. Weights are a pinned pointer, never baked into the image. +The device open, the decoders, the Python-API contract and the HTTP contract come from `code/tt_frnet/ttaw/`, the +shared package of the Autoware ports vendored into this repo (`ttaw/VENDORED.json` records its version and file +hashes; it is not edited here). + +| item | value | +|---|---| +| tt-metal tree | `/home/ubuntu/experiments/tt-models/tt-metal` (main `44d66500520`, `v0.80.0-dev20261006-78-g44d66500520`, + `patches/tt-metal-eth-dispatch.patch`; torch 2.11.0+cpu) | +| weights | `AutowareFoundation/lidar_frnet` @ `a53a1c11b0b28d31fd50a03abef5db0e830a5b3c` (tag `v2.0`; files `frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml`; 85 MB (frnet_ot128.onnx + frnet_qt128.onnx, 42.4 MB each); Apache-2.0; public, ungated) | +| Autoware reference | `perception/autoware_lidar_frnet` @ autoware_universe `9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd` | +| app | `tt_frnet.server.app:app` (uvicorn; the ASGI lifespan does weights -> device -> graph -> trace capture) | +| device recipe | `ttnn.open_device(device_id, dispatch_core_config=DispatchCoreConfig(ETH), l1_small_size=65536, trace_region_size=256 MiB, num_command_queues=1)` (`DEVICE_DEFAULTS` in `code/tt_frnet/device.py`; the trace uses 8.7 MiB (ot128) / 5.6 MiB (qt128)) | +| hardware | one Blackhole p150 (`hardware: p150`, `mesh_device: P150`, `TT_MESH_SHAPE=1x1`), 12×10 compute grid | +| serve profiles | `ot128` (default; Hesai OT128, `FRNET_VARIANT=ot128`) and `qt128` (Hesai QT128); one variant per process. Both pin `FRNET_DISPATCH=eth`, `FRNET_NUM_CQS=1` and the precision knobs `FRNET_POINT_TERMS=2`, `FRNET_MAP_DTYPE=float32`, `FRNET_CONV_TERMS=1` | + +## 1. Run on the HOST (hardware validation, no Docker) + +The tt-metal `python_env` has ttnn and torch; put the HTTP stack in a side directory instead of installing into it. +On the shared workspace box every command that opens the chip goes through the device lock (`bin/devrun`). + +```bash +ROOT=/home/ubuntu/experiments/tt-models +source $ROOT/bin/tt-env.sh # TT_METAL_HOME, PYTHONPATH, tt-metal python_env +mkdir -p /tmp/frnet-http && uv pip install --python $TT_METAL_HOME/python_env/bin/python \ + --target /tmp/frnet-http fastapi uvicorn onnx +cd $ROOT/bundles/frnet-p150 +export PYTHONPATH=$PWD/code:$TT_METAL_HOME:$TT_METAL_HOME/ttnn:/tmp/frnet-http +export HF_MODEL=AutowareFoundation/lidar_frnet TT_MODEL_WEIGHTS_REVISION=a53a1c11b0b28d31fd50a03abef5db0e830a5b3c TT_MESH_SHAPE=1x1 + +# host tests (no device; the device tests are skipped; ttnn is the fake of common/tests/host: a real ttnn host +# tensor opens the chip, so only devrun jobs may build one) +PYTHONPATH=$ROOT/research/packaging/scripts:$PYTHONPATH TT_VISIBLE_DEVICES=none \ + python -m pytest -q -p no:cacheprovider -p fake_ttnn_plugin code/tt_frnet/tests +# device tests and the server, under the lock +$ROOT/bin/devrun -t 1800 -- python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py code/tt_frnet/tests/test_e2e_device.py +# (FRNET_VARIANT=qt128 for the second profile; the smoke test posts the shipped sample of the served variant) +$ROOT/bin/devrun -t 1800 -- bash -c 'python -m uvicorn --host 127.0.0.1 --port 20000 --lifespan on tt_frnet.server.app:app & \ + S=$!; python code/tt_frnet/server/smoke_test.py --url http://127.0.0.1:20000 --wait 1200; R=$?; kill -TERM $S; wait $S; exit $R' +``` + +Boot log landmarks (they drive `tt-model serve`'s progress view, `boot_progress.py` TT_DIT_PHASES): +`Loading weights` -> `Opening device` -> `Warming up: capturing trace ...` -> `Warmup complete` -> uvicorn +`Application startup complete`. A cold JIT cache compiles kernels for minutes; later boots take seconds. Startup +failures raise and uvicorn exits non-zero (no CPU fallback). SIGTERM: the lifespan releases the traces and closes the +chip (within `tt-model stop`'s 120 s budget). + +Offline override (no Hub): `FRNET_WEIGHTS_DIR=`. + +## 2. Package, serve, push (Docker) + +Docker 29 on this box is **rootful** (`/var/lib/docker`), and the user's session may not carry the `docker` group: +wrap every docker-using command in `sg docker -c "..."` (no `docker-env.sh` is needed here; the reference bundles' +`docker-env.sh` was the authors' rootless-Docker box). Run every `tt-model` command **from this directory** +(`source.tt_metal` and `extra_code[].root: code` are CWD-relative) and point `--out` outside it. + +```bash +ROOT=/home/ubuntu/experiments/tt-models; cd $ROOT/bundles/frnet-p150 +TTM=$HOME/.local/share/uv/tools/tt-model/bin/python + +# offline validation (seconds; no docker, no device) -- must print VALID +$TTM -c "from tt_kernel.container_manifest import load_container_manifest as L; m=L('tt-model.yaml', check_sources=True); p=m.resolve_profile(); print('VALID', m.name, m.kind, p.hardware, p.mesh_device, m.weights_ref)" + +# build (one at a time on the box: the absolute lock file): ~35 min cold, ~12 min after a code/ change (the C++ +# build re-runs, ccache-warm, whenever code/ changes), ~1 min for a manifest- or lock-only change +flock $ROOT/.package.lock sg docker -c "tt-model package --container tt-model.yaml --out $ROOT/build" # log: ~/.cache/tt-model/build/frnet-p150.log +# provenance: the base-image digests this build resolved (tt-model's FROM tags float) -> build_info.json (published) +sg docker -c "python3 $ROOT/research/packaging/scripts/record_build_info.py --staged $ROOT/build/frnet-p150 --bundle ." + +# serve + smoke + stop, all inside ONE device-lock window, once per serve profile (`tt-model profiles +# $ROOT/build/frnet-p150/tt_kernel_manifest.json` lists them; serve returns once READY and leaves the container +# running; the script's trap saves the container log and always runs `tt-model stop`, also when devrun's timeout +# fires: -k 150 > the 120 s grace). No token reaches the public weights download. Evidence: logs/smoke/ (--log-dir). +for prof in ot128 qt128; do + $ROOT/bin/devrun -t 3600 -k 150 -- env -u HF_TOKEN -u HUGGING_FACE_HUB_TOKEN \ + sg docker -c "bash code/scripts/container_smoke.sh $ROOT/build/frnet-p150 20000 --profile $prof" +done + +# publish: overlay the hand-written docs, the pip project and the build record onto the staged dir, then push +# (HF_TOKEN only from the environment) +rsync -a README.md SERVING.md OPT_BASELINE.md OPT_REPORT.md VERIFICATION_*.md tt-model.yaml pyproject.toml build_info.json \ + examples media patches $ROOT/build/frnet-p150/ +cp code/PYTHON.md $ROOT/build/frnet-p150/PYTHON.md 2>/dev/null || true +tt-model push $ROOT/build/frnet-p150 --publish +``` + +The smoke test fails unless `/info` reports ETH dispatch, the 12x10 grid and what the staged package pins for the +profile (`FRNET_DISPATCH`, `FRNET_NUM_CQS`, `FRNET_VARIANT`, the weights revision), and it compares the +served output of the shipped sample of the profile (`samples/synthetic_.npz`) with its stored CPU-reference +output (`samples/synthetic_..reference.json`; >= 98.5 % label and filter agreement). + +`serve` publishes the first free port from 20000 and exports into the container: `HF_MODEL=AutowareFoundation/lidar_frnet`, +`MESH_DEVICE=P150`, `TT_MESH_SHAPE=1x1`, `TT_MODEL_WEIGHTS_REVISION=a53a1c11b0b28d31fd50a03abef5db0e830a5b3c`, then `serve.env`. The HF cache is +mounted at `/hf` (rw), the JIT cache at `/cache` (host `~/.cache/tt-model/frnet-p150/cache`), and the container +sees only its own chip (`--device /dev/tenstorrent/`). tt-cli users: `tt serve changh95/frnet-p150` / +`tt model stop changh95/frnet-p150` (point tt at this tt-model with +`tt config set tools.override.tt-model ~/.local/bin/tt-model` or `TT_TOOL_BIN_TT_MODEL`). + +What `push` does to the repo: `code/` and `image/` on the Hub become exactly the staged trees (`extra_code.paths` + +`models/common/lightweightmodule.py`); every top-level file of the staged dir is uploaded (the overlaid README +replaces the generated card; `pyproject.toml` and `build_info.json` arrive the same way, never through `code/`); +top-level files already on the Hub and not in the staged dir are kept. + +## 3. The request / response contract + +| route | purpose | +|---|---| +| `GET /health` | `{"status": "ok" \| "starting" \| "error", "model", "device": {"dispatch", "grid", "cores", "device_id"}, "error"}`, always 200; `ok` only after warm-up | +| `GET /v1/health` | same as `/health` (tt-model's hint for non-chat packages points here) | +| `GET /info` | model, task, io, Autoware package + commit, weights {repo, tag, revision, path, license}, device (dispatch, grid), input limits (point formats, fields, camera order, calibration presets), labels, variant, warm variants, runtime params + defaults, compile params, warm-up and boot times | +| `GET /v1/models` | `{"object": "list", "data": [{"id": "AutowareFoundation/lidar_frnet", "object": "model", "owned_by": "changh95"}]}` -- a stub so OpenAI-shaped probes do not 404; NOT a chat API | +| `POST /predict` | one frame -> the model output (below) | + +### 3.1 Request (`application/json`; unknown fields are a 422) + +| field | type | meaning | +|---|---|---| +| `points` | object | `{"format": "bin" \| "npy" \| "npz" \| "pcd" \| "list", "data": , "fields": [...], "dtype", "key", "frame_id"}`. `bin` = raw little-endian float32 rows, N × len(`fields`) (default fields `x, y, z, intensity`); `npy` / `npz` = (N, C) float or structured arrays; `pcd` = PCD v0.7 ascii / binary / binary_compressed; `list` = `"values": [[x, y, z, i], ...]` (small clouds). ONE sweep in the **sensor frame** (x forward, y left, z up, metres), raw intensity (0-255 for uint8 drivers), not motion-compensated: Autoware's `~/input/pointcloud`. Rows with a non-finite x / y / z are dropped | +| `calibration` | object | `{"T_base_link_from_sensor": }` (the static transform the node looks up once for its ego crop box) or `{"preset": ""}` (presets in `/info`: `synthetic_ot128`, `synthetic_qt128`, the shipped samples' transforms); optional `"ego_crop_box"`: `[min_x, min_y, min_z, max_x, max_y, max_z]` in `base_link` (default Autoware's `(-1.53, -1.15, -0.1)` .. `(6.1, 1.15, 3.1)`) or `null` to disable the box (then no transform is needed). Required while the box is on: without it the node would skip every frame, here it is a 400 | +| `params` | object | per-request knobs (host post-processing only; they change `filter_keep`, never the classes): `class_probability_threshold` (float in [0, 1], default 0.05), `filter_classes` (comma-separated class names, `""` = none, default `"drivable_surface"`, at most 16) | +| `output_format` | str | `json` (default) or `npz` (adds lossless base64 NPZ arrays) | +| `sweeps`, `images`, `stream`, `inputs` | – | fields of the collection's common envelope that FRNet does not take: a request with any of them is a 400 | + +Transforms (`T__from_`): a 4×4 (or 3×4) row-major matrix, `{"translation": [x, y, z], "rotation_wxyz": [w, x, y, z]}` +(or `rotation_xyzw`), or `{"x", "y", "z", "roll", "pitch", "yaw"}` (Autoware `sensor_kit_calibration.yaml`, tf2 RPY). +Intrinsics: 3×3 K, 3×4 P, or `{"fx", "fy", "cx", "cy"}`. Base64 may be standard or URL-safe, wrapped, with or without a +`data:` prefix. + +### 3.2 Response (200) + +The shipped OT128 sample (`synthetic_ot128.npz` with the preset `synthetic_ot128`), abridged (`data` fields cut): + +```json +{"model": "frnet-p150", "frame_id": "sensor", "num_points": 52613, "num_points_raw": 52613, + "labels": {"format": "npz", "key": "labels", "dtype": "uint8", "shape": [52613], "data": "..."}, + "scores": {"format": "npz", "key": "scores", "dtype": "float32", "shape": [52613], "data": "..."}, + "points": {"format": "npz", "key": "points", "dtype": "float32", "shape": [52613, 4], "data": "..."}, + "filter_keep": {"format": "npz", "key": "filter_keep", "dtype": "bool", "shape": [52613], "data": "..."}, + "input_index": {"format": "npz", "key": "input_index", "dtype": "int32", "shape": [52613], "data": "..."}, + "class_names": ["drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "..."], + "class_counts": {"drivable_surface": 2913, "other_flat_surface": 20118, "sidewalk": 5, "manmade": 22898, "vegetation": 6526, "car": 13, "semi_trailer": 140, "...": 0}, + "meta": {"variant": "ot128", "num_points_input": 53411, "cropped_by_ego_box": 798, "num_points_interpolated": 0, "num_unique_coors": 52613, + "ego_crop": {"enabled": true, "bounds": [-1.53, -1.15, -0.1, 6.1, 1.15, 3.1], "reference_frame": "base_link", "...": "..."}, + "plan": {"n": 52613, "m": 52613, "n_cap": 160000, "m_cap": 60000, "...": "..."}, + "filter": {"classes": ["drivable_surface"], "class_probability_threshold": 0.05, "kept": 44747}, "...": "..."}, + "timing_ms": {"decode": 24.711, "preprocess": 47.664, "device": 229.164, "postprocess": 5.018, "model_call": 281.879, "total": 310.689}} +``` + +- Output point order (the node's): the input points that survive the ego crop box, in input order (after non-finite + rows are dropped), then the interpolated points Autoware appends; the first `num_points_raw` rows are raw points and + `input_index` maps them back to the decoded input rows. The interpolated points are published, as Autoware does. +- `labels` is Autoware's `class_id`, `scores` its `probability` (the softmax probability of that class), `points` the + x, y, z, intensity of every output point in the sensor frame, `filter_keep` the membership of Autoware's + `~/output/pointcloud/filtered` cloud. +- An out-of-profile frame (input points, points after interpolation or unique cells outside the variant's ranges: + ot128 5,000-160,000 points, qt128 5,000-120,000, both 3,000-60,000 cells) answers 200 with `num_points: 0` and the + node's message in `meta.skipped`: the node publishes nothing for such a frame. Nothing runs on the device. +- `meta` also has the crop and network-input counts (`num_points_input`, `cropped_by_ego_box`, `num_points_interpolated`, + `num_unique_coors`, ...), the device plan sizes and the applied filter. + +`timing_ms`: `decode` (base64 + parsing), `preprocess`, `device` (staging + H2D + trace + D2H + un-permute), +`postprocess`, `model_call`, `total` (server side, after the body arrived). + +### 3.3 Errors + +**400** undecodable / malformed input (bad base64, wrong row size, no calibration while the ego crop box is on, unknown +or out-of-range `params` such as an unknown filter class, `sweeps` / `images` / `stream` / `inputs`, no `points`), **422** schema violation (unknown field, wrong type), **503** while starting +(or when the boot failed), **500** `inference failed: : ` if the device call raises. + +### 3.4 Environment the app reads (lifespan only, never at import) + +| var | set by | meaning / default | +|---|---|---| +| `HF_MODEL` | launcher (`weights.repo`) | weights repo id; default `AutowareFoundation/lidar_frnet` | +| `TT_MODEL_WEIGHTS_REVISION` | launcher (`weights.revision`) | pinned commit; `TT_WEIGHTS_REVISION` (`serve.env`) is the same for older tt-model | +| `FRNET_WEIGHTS_DIR` | you (offline) | local weights directory; overrides the Hub | +| `TT_MESH_SHAPE` | launcher (`runtime.mesh_shape_env`) | `1x1`; anything else -> RuntimeError at startup | +| `TT_DEVICE_ID` | you | chip to open, default 0 | +| `FRNET_DISPATCH` | `serve.env` | `eth` (default) \| `worker` (A/B only) \| `auto` (ETH if the patch is present) | +| `FRNET_NUM_CQS` | `serve.env` | `1` | +| `FRNET_VARIANT` | `serve.env` / profile | `ot128` (profile `ot128`) or `qt128` (profile `qt128`) | +| `FRNET_POINT_TERMS`, `FRNET_MAP_DTYPE`, `FRNET_CONV_TERMS` | `serve.env` | the precision knobs: `2`, `float32`, `1` (the validated configuration; other values are optimization-phase A/B switches that fail the frozen accuracy gates) | +| `FRNET_WARMUP` | you | JSON list of warm-up variants, `default` or `none` | +| `FRNET_TRACE_REGION`, `FRNET_L1_SMALL`, `FRNET_WORKER_L1_SIZE` | you | device-open overrides (validated values: `DEVICE_DEFAULTS` in `device.py`) | +| `FRNET_MAX_BODY_MB` | you | request size guard, default 256 | +| `TT_METAL_VISIBLE_DEVICES`, `MESH_DEVICE` | `serve.env` / launcher | `0`, `P150` (informational) | + +Server-side pipeline: JSON -> `tt_frnet.io` decoders -> `FRNet.__call__` under one lock: host pre-processing ported +from `perception/autoware_lidar_frnet` (ego crop, float32 projection, fp16 interpolation grid, unique cells, profile +checks, the device plan: sorted points, CSR offsets, gather tables, 8-d features) -> 9 H2D copies into persistent +device inputs -> `execute_trace` (point MLP, K1 segment max, frustum CNN, point head, softmax) -> one D2H of the fp32 +probabilities -> un-permute -> host post-processing (first-max argmax, probability, class filter) -> +`Output.to_dict()`. + +## 4. Caveats + +- Batch 1, one sweep per request; concurrent clients queue on the lock. +- Fixed device shapes per profile: `N_cap` 160,000 (ot128) / 120,000 (qt128) points and `M_cap` 60,000 cells; every + point-wise op runs on all `N_cap` rows, so the device time hardly depends on the frame. Frames outside Autoware's + profile ranges are skipped as the node skips them (200, empty output, `meta.skipped`). +- One variant per process: serve `--profile qt128` for the Hesai QT128 network. The profiles share the image. +- Warm-up captures every trace variant inside the lifespan, so READY means warm; the first cold boot pays the ttnn JIT. +- Weights are pinned by sha in `weights.revision`, exported by the launcher and repeated in `serve.env`; + `snapshot_download(..., revision=)` is a cache hit after `serve`'s pre-download and falls back to + `local_files_only=True` if the Hub is unreachable. +- The runtime image has no host C/C++ compiler: device kernels JIT-compile (sfpi ships in the image), host-side C + helpers must have a numpy fallback. +- `tt-model curl` and the ready card's `/v1/models` hint are OpenAI-shaped and are not this API; use the routes above. diff --git a/VERIFICATION_2026-10-09.md b/VERIFICATION_2026-10-09.md new file mode 100644 index 0000000000000000000000000000000000000000..ae1097f4ae77ea65af3b3a5742334abd2ca6be79 --- /dev/null +++ b/VERIFICATION_2026-10-09.md @@ -0,0 +1,166 @@ +# frnet-p150: independent verification, 2026-10-09 + +## Verdict + +**PASS.** This is the light verification pass of the first publish (`research/PLAN.md` §6.3): the frozen gates +re-run, the baseline numbers re-checked, and every device-open path audited for ETH dispatch. It was made by the agent +that wrote the release docs, which is neither the porter, nor the adversarial verifier of `VERIFY_PORT.md`, nor the +baseline agent, on the release-code commit `04c39b5` (the model code of the baseline `0dd52b2`; the shipped sample +became a synthetic scan per serve profile, the PandaSet sample is staged and not shipped, test hardening; the docs +commit that follows changes `make_demo.py`'s render captions and the docs only). The device runs ran on 2026-10-09 +15:53-16:10 UTC in one `bin/devrun` window (a first queueing attempt died while waiting for the lock, when the +workspace's `bin/devrun` was edited under it; it had not touched the device). Against the baseline commit `0dd52b2`: + +- **Frozen gates:** the device suite (`test_pcc_device.py` + `test_e2e_device.py`, one pytest) passed **14 / 14** + tests. Every existing gate passes with values **bit-identical** to the baseline run (the `_m5` and `_m5docs` reports: + 2,144 leaves compared, 0 differ; timings excluded), including the binding frame: OT128 min agreement 98.907 % + (nuScenes 0916 kf40) against the 98.5 % gate. The two new tests (`test_shipped_sample`, and `test_public_frames` + now requiring all 17 / 5 frames) passed; the four new shipped-sample gates were frozen at this first green run + (label agreement OT128 0.99550, QT128 0.99623; filtered-cloud agreement 0.99772 / 0.99872; threshold 0.985). +- **Baseline re-check:** the device rows of `OPT_BASELINE.md` repeat to within 0.03 ms (OT128 210.02 / 209.95 ms + trace / b2b on the synthetic sample vs 210.00 / 209.95 ms now; QT128 133.53 / 133.43 vs 133.50 / 133.43 ms). Host + rows within the load noise (OT128 `model()` 292.6 -> 289.3 ms p50 on the synthetic sample, 355.7 -> 360.1 ms on + PandaSet, at load average 4.1). +- **Shipped samples:** the shipped `synthetic_.npz` are byte-identical to the baseline's synthetic bench + inputs (`cmp`), so the baseline's synthetic rows are the shipped sample's. p150 vs the stored CPU reference: + OT128 99.550 % of 52,613 points, QT128 99.623 % of 69,553, the same through `bench.py`, the device test, the + quickstart, `make_demo.py` and the served smoke test. +- **Public data (113 frames per profile, production API):** pooled class agreement OT128 PandaSet 99.66 % / nuScenes + 99.19 %, QT128 99.29 % / 99.44 %; identical scored rows to the CPU reference on all 226 frame-profile pairs, no + profile skip. 3 pairs fall below 98.5 % (not gated; see "Review findings"). Mapped-label mIoU on the p150 equals the + CPU reference's within 0.002. +- **ETH dispatch:** every device-open path opens ETH / 1 CQ / 12×10 by default; every run of this pass printed + `dispatch eth`, `grid 12x10`, `cores 120`, `fallback null`, `eth_patch true` (both serve profiles, API and server). +- **No numerics change** and no new bug: nothing in the model code was changed by this pass. + +## Setup + +| item | value | +|---|---| +| Hardware | one Blackhole p150b, ETH dispatch, 1 CQ, 12×10 compute grid (120 cores), AICLK 1350 MHz median (min 1343) during the benches | +| tt-metal | `44d66500520` with `patches/tt-metal-eth-dispatch.patch` (sha256 `08d0ddf6…`, byte-identical to the workspace copy) | +| Weights | `AutowareFoundation/lidar_frnet` @ `a53a1c11b0b28d31fd50a03abef5db0e830a5b3c` (tag `v2.0`): the device suite and the demo run read the sha256-pinned workspace copy; the quickstart, the served runs and the load-time runs resolved the HF cache at the pinned revision offline (downloaded without a token for this pass; every file identical to the workspace copy, ONNX sha256 ot128 `d29bf595…`, qt128 `647af927…`) | +| Verified commit | `04c39b5` (ttaw 0.20.0, `common` `89dec49`, `source_dirty: false`) | +| Baseline commit | `0dd52b2` (`OPT_BASELINE.md`) | +| Workload | the shipped samples `code/tt_frnet/samples/synthetic_{ot128,qt128}.npz` and the staged PandaSet 019 f40 (107,146 points, local), batch 1, warm; the 17 / 5 gate frames; 113 converted public frames per profile (local only) | +| Bench method | `code/scripts/bench.py --iters 100 --warmup 10 --b2b-iters 50 --b2b-rounds 5`, one run per serve profile (the baseline's settings), with each output checked against its stored CPU reference | +| Served method | uvicorn on the host with the serve pins of `tt-model.yaml` (the container's app; `FRNET_VARIANT` per profile), the bundle's `smoke_test.py` against it, then 5 warm-up + 50 requests of the shipped sample from a loopback client, the README's `client.py` + `curl` recipe, a knobs request and a request without calibration | +| Accuracy reference | the fp32 CPU reference (`code/tt_frnet/reference/`, ONNX Runtime on the deployed ONNX; VERIFY_PORT: within 1e-11 of an independent ORT run); for the 113-frame sets the research ORT runs (`research/frnet/public_data/runs`) | +| Host | AMD EPYC VM, 8 vCPUs, shared with other agents' jobs (load average 4.1-6.1 during the runs) | +| Logs | `logs/frnet/m5_docs/` of the workspace (job scripts in `scripts/`) | + +## Measured baseline vs re-check + +All times in ms, p50 of 100 iterations (b2b: 5 rounds of 50 replays). Baseline: `OPT_BASELINE.md` (`0dd52b2`); +re-check: `code/scripts/bench.py` with the baseline's settings on `04c39b5` +(`logs/frnet/m5_docs/bench_recheck_{ot128,qt128}.json`). + +| row | baseline | re-check | +|---|---:|---:| +| OT128, shipped sample: trace, one replay / back-to-back | 210.02 / 209.948 | 210.00 / 209.946 | +| OT128, PandaSet 019 f40: trace / back-to-back | 211.72 / 211.643 | 211.73 / 211.639 | +| QT128, shipped sample: trace / back-to-back | 133.53 / 133.434 | 133.50 / 133.429 | +| QT128, PandaSet 019 f40: trace / back-to-back | 134.91 / 134.866 | 134.92 / 134.856 | +| OT128 H2D / D2H, shipped sample | 5.42 / 7.65 | 5.42 / 7.56 | +| OT128 host pre-processing, shipped sample / PandaSet | 55.3 / 107.2 | 54.7 / 107.4 | +| OT128 `model()` p50, shipped sample / PandaSet | 292.6 / 355.7 | 289.3 / 360.1 | +| QT128 `model()` p50, shipped sample / PandaSet | 221.1 / 244.3 | 205.4 / 241.5 | +| served `/predict` `timing_ms.total`, OT128 / QT128 (median of 50) | – | 310.4 / 228.9 | +| served client round trip, loopback | – | 328.1 / 247.3 | +| `from_pretrained`, empty JIT cache / warm cache, OT128 | – / 6.5 s | 156.6 / 7.5 s | +| `from_pretrained`, empty JIT cache / warm cache, QT128 | – / 6.9 s | 151.9 / 6.6 s | + +The served and cold-load rows are new for the card (the baseline measured neither). The first two calls after a load +took 320.0 / 304.7 ms (OT128) and 241.1 / 214.9 ms (QT128): the trace is captured during the load. The cold JIT cache +holds 682 MB (OT128) / 644 MB (QT128) after the load. + +## Accuracy gate results + +`logs/frnet/m5_docs/suite.log`; reports `logs/frnet/{pcc,pcc_small,e2e,latency}_{ot128,qt128}_m5docs.json`. + +| check | gate | OT128 | QT128 | +|---|---|---|---| +| every gated module tap (PCC vs the fp32 reference, PandaSet sample, replay outputs) | ≥ 0.999 | min 0.999729 (`point_fusion1`) | min 0.999649 (`attention4`) | +| `pred_probs` PCC / raw-point agreement, PandaSet sample (staged) | ≥ 0.998 / ≥ 98.5 % | 0.999942 / 99.581 % | 0.999844 / 99.295 % | +| public frames (17 / 5): min `pred_probs` PCC | ≥ 0.998 | 0.999688 | 0.999805 | +| public frames: min raw-point agreement | ≥ 98.5 % | **98.907 %** (nuScenes 0916 kf40) | 99.158 % (PandaSet 029 f40) | +| shipped sample vs its stored reference body: labels / filtered-cloud mask (new) | ≥ 98.5 % | 99.550 % / 99.772 % | 99.623 % / 99.872 % | +| replay == eager | bit-exact | 45 / 45 taps | 45 / 45 taps | +| API == `/predict` handler | identical | yes | yes | + +Host suite (fake ttnn, gates read-only): **117 passed, 15 skipped** (the 14 device tests and the onnxruntime-only +test), `logs/frnet/m5_docs/host_suite.log`. Research venv (ONNX Runtime + torch): **85 passed, 2 skipped** (by design), +`logs/frnet/m5_docs/research_venv.log`. The counts differ from VERIFY_PORT's (112 / 13 host, 83 / 2 research venv) by the tests the release added or +changed (release hygiene of the shipped samples, the top-level `PYTHON.md` copy, the shipped-sample device test). + +### Public-frame agreement beyond the gates + +`code/scripts/make_demo.py run` (the production API, `model(points, calibration=...)`) on every converted public frame +of `research/frnet/public_data/inputs`, both profiles, against the research ORT runs +(`logs/frnet/m5_docs/demo/agreement.json`): + +| set | frames | scored raw points | OT128 pooled (worst frame) | QT128 pooled (worst frame) | +|---|---:|---:|---:|---:| +| PandaSet 019 + 029, every 5th frame | 32 | 3,442,737 | 99.66 % (99.35 %) | 99.29 % (98.69 %) | +| nuScenes v1.0-mini mini_val, all key-frames | 81 | 2,067,589 | 99.19 % (98.37 %) | 99.44 % (98.10 %) | + +Scored rows equal the reference's on all 226 frame-profile pairs (same crop, same profile decisions); no frame was +skipped. Mapped-label mIoU (`research/frnet/public_data/scripts/frnet_public_eval.py`, not a paper metric; +`logs/frnet/m5_docs/miou/`): OT128 PandaSet coarse 0.7136 (p150) vs 0.7146 (CPU), fine 0.4471 vs 0.4479; nuScenes +coarse 0.4827 vs 0.4831, fine 0.4515 vs 0.4531 (fine = mean over the classes with at least 1,000 labelled points, +`miou_classes_ge_1000_gt_points`; over all labelled classes: PandaSet 0.3229 vs 0.3235, nuScenes 0.3285 vs 0.3292); QT128 PandaSet coarse 0.1869 vs 0.1873, nuScenes 0.3255 vs 0.3266. + +## Disclosed numerics changes + +None: the model code, the precision knobs and the kernels are those of the baseline, and every existing gate value is +bit-identical to it. The release changed what ships (samples), the tests (stricter) and the docs. + +## Review findings + +- **VERIFY_PORT round 1, low findings** (all resolved or disclosed): + 1. stale PORT_LOG header and latency: fixed (ttaw 0.20.0 @ `89dec49`; the superseded first-cut latency replaced by + the current numbers; the duplicated known-issue paragraphs removed); + 2. `test_public_frames` skipped missing goldens silently: inside the workspace a missing golden, research frame or + staged sample now FAILS (`tests/_data.py need()`), and the test asserts 17 / 5 frames; + 3. narrow precision margin (0.41 pt on nuScenes 0916 kf40): disclosed on the card (Caveats) and in `OPT_REPORT.md` + as the binding constraint and backlog item 14; + 4. end-to-end latency depends on the host load: the card quotes the stage bench with its load conditions and the + device rows separately; + 5. `test_stage_pcc` replays on its capture inputs: unchanged (hardening note); input rewriting is covered by + `test_small_graph`, `test_public_frames` (17 / 5 frames through one trace per profile) and the new `test_shipped_sample`. +- **New, low:** on the wider (ungated) public set, 3 of 226 frame-profile pairs agree below 98.5 %: nuScenes + scene-0916 key-frames 11 (98.374 %) and 1 (98.499 %) on OT128, key-frame 0 (98.105 %) on QT128. They are on the same + out-of-domain scene (32-beam HDL-32E, Singapore) as the binding gated frame, are consistent with its 0.41-pt margin, + and are disclosed on the card. They do not touch a frozen gate. The optimization phase should add them to the + frames it judges precision changes on. +- **Gates:** no threshold was changed; the gate JSON gained only the four shipped-sample entries (0.985, the PLAN.md 2.8 + argmax gate). No test compares TT with TT except replay == eager. +- **Samples and data:** `code/` ships only the two synthetic scans with their presets and references + (`test_only_redistributable_samples_ship`); no PandaSet, nuScenes or rosbag data is tracked; the PandaSet sample + lives in the git-ignored `staging_samples_pandaset/`. nuScenes appears only as two renders labelled non-commercial. + +## p150 ETH-dispatch compliance + +Every path that opens the device goes through `ttaw.device.open_device`, whose default is +`DispatchCoreConfig(ETH)` (falling back to WORKER only with a `RuntimeWarning`, or never with `allow_fallback=False`): + +| path | how it opens | dispatch | evidence | +|---|---|---|---| +| `FRNet.from_pretrained` (API, quickstart, bench, profile, make_demo) | `ModelBase.device_config` -> `DeviceConfig` (`DEVICE_DEFAULTS` < `FRNET_*` env < args) -> `open_device` | `eth` default; `FRNET_DISPATCH` / `dispatch=` switch | every run printed `dispatch eth, grid 12x10, fallback None` | +| HTTP server (`tt_frnet.server.app`, the container) | the lifespan calls `FRNet.from_pretrained` with the env; `serve.env` pins `FRNET_DISPATCH=eth`, `FRNET_NUM_CQS=1` | `eth` (pinned; `check_bundle.py` refuses another value) | served runs: `/info` `dispatch eth`, `grid 12x10`; `smoke_test.py` fails on anything else | +| device tests (`conftest.py` `device` fixture) | `tt_frnet.device.open_device(allow_fallback=False)` | `eth`, no fallback | suite log | +| `code/scripts/visualizer_report.py` | `open_device(allow_fallback=False)` | `eth` | baseline log | +| `code/scripts/diag_conv_precision.py`, `diag_frame_taps.py` | `tt_frnet.device.device_session()` | `eth` default | code read | +| `tests/test_tt_host.py` | `ttaw.device.open_device(0, dispatch="eth")` on the fake ttnn only | – (no device) | host suite | + +The grid is never hard-coded: it comes from `device.compute_with_storage_grid_size()` (`tt/layers.py`, K1 +`segment.py`); the only 12×10 literal is the expected-grid warning in `ttaw/device.py` and the smoke test's assertion. +The image's `verify:` line asserts the patch marker in `tt_metal/impl/dispatch/topology.cpp`. + +## Not covered by this pass + +- The container image: it is built and smoke-tested per serve profile (`ot128`, `qt128`) at packaging + (`code/scripts/container_smoke.sh`), after this pass. +- A full adversarial verification of the release (that is `VERIFY_PORT.md` for the port; the next full verification + follows the first optimization round). +- TensorRT parity: the reference is the fp32 ONNX Runtime run of the deployed ONNX, not Autoware's fp16 TensorRT engine. diff --git a/build_info.json b/build_info.json new file mode 100644 index 0000000000000000000000000000000000000000..e5f99e08c40b216456e6bd07e11bf2123207d192 --- /dev/null +++ b/build_info.json @@ -0,0 +1,48 @@ +{ + "schema": "ttaw-build-info/1", + "bundle": "frnet-p150", + "recorded_at": "2026-10-09T17:15:08Z", + "image": { + "tag": "tt-model/frnet-p150:85cbf6cc72b9", + "digest": "sha256:85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c", + "built_at": "2026-10-09T17:04:34+00:00", + "code_sha256": "f0b62790446a51a86fe941bb4367561fecd9c560adc4d02b9b25b1e364358ede", + "size_bytes": 4071619125, + "layers": 23 + }, + "base_images": { + "build": { + "stage": "prep", + "ref": "ghcr.io/tenstorrent/tt-metal/tt-metalium/ubuntu-22.04-dev-amd64:latest", + "digest": "sha256:df9d279c7f85c17c6fad982d196802682d669cca1b7ced9cbaad8181339cd5fc", + "source": "docker build log", + "local_tag_matches": false, + "local_id": "sha256:3fd1e6013e658c65df1bc6b084543e7174d854c15af97373e493df0c1787c393", + "created": "2026-10-07T01:17:29.186036377Z", + "note": "the local tag moved after this build; the digest above is the one the image was built from" + }, + "runtime": { + "stage": "runtime", + "ref": "docker.io/library/ubuntu:22.04", + "digest": "sha256:5ec03bb3441e8b0bf3b4f9cd4629a1ae763010dc3035bb8da3ae6cf026486401", + "source": "docker build log", + "local_tag_matches": null, + "note": "not in the local image store (BuildKit pulled it into its cache)" + } + }, + "tt_metal": { + "sha": "44d66500520fda9f2c7060c0f6b41ec48f7ab37e", + "describe": "v0.80.0-dev20261006-78-g44d6650052-dirty", + "dirty": true, + "scm_version": "0.65.2.dev11169+g44d66500520", + "mode": "local", + "remote": "https://github.com/tenstorrent/tt-metal.git", + "branch": "main", + "pushed": true + }, + "tools": { + "tt_model": "0.1.0", + "docker": "29.8.2" + }, + "notes": "tt-model's FROM tags float (build: tt-metalium dev image :latest, runtime: ubuntu:); these are the digests this image was built from. A moved build base costs a cold C++ build." +} diff --git a/code/PYTHON.md b/code/PYTHON.md new file mode 100644 index 0000000000000000000000000000000000000000..fbac4c9dc7aec795f118f6b9fd55662a7d556660 --- /dev/null +++ b/code/PYTHON.md @@ -0,0 +1,220 @@ +# Python API: FRNet (Autoware lidar_frnet) on Blackhole + +Use this API from Python code (a pipeline, a notebook, a ROS 2 node wrapper). You do not need the HTTP server: the +API and the server share the decoders, the device graph and the post-processing, so the outputs and the speed are +the same. + +## Install + +Install the package on top of an environment that already has `ttnn` (a tt-metal `python_env` at `44d66500520` +with `patches/tt-metal-eth-dispatch.patch`, or the tt-model container). From the root of the model repository (the +directory that holds `pyproject.toml`, `README.md` and `code/`): + +```bash +pip install -e . # the Python API +pip install -e ".[server,test]" # + the HTTP server and the tests +``` + +The pip project is the repository's top-level `pyproject.toml`; it installs the package from `code/tt_frnet` (there is no +`pyproject.toml` inside `code/`, because the container build copies `code/` over the tt-metal tree). + +The package carries `tt_frnet.ttaw`, the shared code of the Autoware ports to Blackhole (device open, trace runner, +decoders, model base class, HTTP app, the K1 `segment_reduce` kernel), vendored at the version recorded in +`code/tt_frnet/ttaw/VENDORED.json` (0.20.0). + +| You want to run | Extras | +|---|---| +| the Python API | none | +| the HTTP server (`tt_frnet.server.app`, see `SERVING.md`) | `server` | +| host tests (no device; device tests are skipped): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests` | `server,test` | +| device tests: `python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py code/tt_frnet/tests/test_e2e_device.py` | `test` | + +## Quickstart + +Run from the repository root (the sample paths are relative to it): + +```python +from tt_frnet import FRNet + +with FRNet.from_pretrained(device_id=0) as model: # variant="qt128" for the Hesai QT128 profile + out = model("code/tt_frnet/samples/synthetic_ot128.npz", # one sweep in the sensor frame + calibration={"preset": "synthetic_ot128"}) # its sensor -> base_link transform (ego crop box) + +print(out.class_counts()) # points per class (raw + interpolated) +labels, scores, xyz = out.label_ids, out.scores, out.points[:, :3] +``` + +`examples/quickstart.py` runs this snippet and writes `quickstart.json` (the `/predict` body) and `quickstart_bev.png` +(a bird's-eye view of the p150 classes next to the stored CPU reference of the shipped sample). + +## `FRNet.from_pretrained(...)` + +```python +FRNet.from_pretrained( + model_id=None, # HF repo or a local directory with the weights files; default AutowareFoundation/lidar_frnet + *, + revision=None, # default for the default repo: the validated commit a53a1c11b0b (tag v2.0) + variant=None, # load-time variant: "ot128" (Hesai OT128, Autoware's default) | "qt128"; default $FRNET_VARIANT or "ot128" + device_id=None, # chip to open; default $TT_DEVICE_ID or 0 + device=None, # an already-opened ttnn device (tt_frnet.device.open_device); close() does not close it + dispatch=None, # "eth" (p150 target, 12x10 grid) | "worker" (A/B only, 11x10) | "auto"; default $FRNET_DISPATCH or "eth" + num_command_queues=None, # default $FRNET_NUM_CQS or 1 + weights_dir=None, # explicit local weights directory; no Hub access + warmup_variants="default", # trace variants to capture now; see "Warm-up" + verbose=False, +) -> FRNet +``` + +What it does: resolves the weights first, so a Hub problem never claims the chip (`weights_dir` > +`$FRNET_WEIGHTS_DIR` > a local `model_id` directory > the HF snapshot at the pinned revision, restricted to +`frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml`, with an offline fallback to the cache), checks +the sha256 of the variant's ONNX against the pinned one, reads the variant's `ml_package_frnet_.param.yaml` +(FOV, frustum and interpolation grids, the 27 class names, the palette, the point / cell profiles), opens the chip +(ETH dispatch, 12×10, 1 CQ, `l1_small_size` 64 KiB, `trace_region_size` 256 MiB: `DEVICE_DEFAULTS` in +`tt_frnet/device.py`, overridable with `FRNET_*`), builds the graph, then compiles and captures the metal trace. If +ETH dispatch cannot open (tt-metal without the patch), it warns and falls back to WORKER dispatch. + +`variant` picks one of the two networks of the weights repo. Each is tied to its sensor (Autoware's `sensor_model`): + +| variant | sensor | FOV up / down | frustum grid (H × W) | interpolation grid | points (min / max) | unique cells (min / max) | +|---|---|---|---|---|---|---| +| `ot128` (default) | Hesai OT128 | +15° / −25° | 128 × 1024 | 128 × 4096 | 5,000 / 160,000 | 3,000 / 60,000 | +| `qt128` | Hesai QT128 | +52.6° / −52.6° | 128 × 256 | 128 × 1024 | 5,000 / 120,000 | 3,000 / 60,000 | + +Precision knobs (read once at load from the environment, pinned in the package's `serve.env`; the defaults are the +validated configuration and every number of the model card uses them): `FRNET_POINT_TERMS=2` (each point-wise linear +as two-term bf16 operands, about 16 mantissa bits, fp32 accumulation), `FRNET_MAP_DTYPE=float32` (fp32 image-branch +maps, bf16 conv operands), `FRNET_CONV_TERMS=1`. Other values are A/B switches for the optimization phase; they fail +the frozen accuracy gates (`OPT_BASELINE.md`, "The precision cost of the policy"). + +## Warm-up + +`from_pretrained` returns a warm model: it builds the graph of the variant, runs it once eagerly on an empty frame +(this first run compiles the kernels and uploads the weights) and captures it as ONE metal trace, so no call compiles +anything. There are no shape buckets: every frame is padded to the profile's fixed device shapes (`N_cap` points = +160,000 / 120,000, `M_cap` cells = 60,000), so one trace serves every input of the profile. + +- Measured (2026-10-09, the shipped sample): with an empty JIT cache the load took 156.6 s for ot128 and + 151.9 s for qt128 (firmware and every kernel compiled); with that cache warm, in a new process, + 7.5 s and 6.6 s. The trace is captured during the load, so the first call costs about as + much as the next ones (320.0 / 304.7 ms for ot128, 241.1 / 214.9 ms for qt128, first / second call, on a shared host). +- `warmup_variants="none"` (server: `FRNET_WARMUP=none`) skips the capture at load; call `model.warmup()` before the + first frame (it is idempotent). +- The JIT cache is tt-metal's (`~/.cache/tt-metal-cache`, or `TT_METAL_CACHE`); inside the container it is + `~/.cache/tt-model/frnet-p150/cache` of the host. + +## Call: `model(points, calibration=..., **kwargs)` + +| Argument | Type | Description | +|---|---|---| +| `points` (first positional) | path, `bytes` + `fmt=`, (N, C) array / tensor, structured array, `PointCloud`, JSON envelope | ONE LiDAR sweep in the **sensor frame** (x forward, y left, z up, metres), fields `x, y, z, intensity` with the raw intensity the node casts (0-255 for uint8 drivers); see "Input types" | +| `calibration` | `{"T_base_link_from_sensor": }`, `{"preset": name}` (a file of `code/tt_frnet/calib/`), optional `"ego_crop_box"` | the static sensor -> `base_link` transform the node looks up once for its ego crop box. `ego_crop_box`: `[min_x, min_y, min_z, max_x, max_y, max_z]` in `base_link` (default Autoware's `(-1.53, -1.15, -0.1)` .. `(6.1, 1.15, 3.1)`), or `None` (or all zeros) to disable the box, the node's own switch; a disabled box needs no transform | +| `class_probability_threshold` | float in [0, 1], default 0.05 | the filtered cloud keeps a point unless its probability of a filter class is >= this value (Autoware's `filter.class_probability_threshold`) | +| `filter_classes` | comma-separated class names, `""` = none, default `"drivable_surface"` | Autoware's `filter.classes` (at most 16); an unknown name is refused | + +Both knobs only change `filter_keep`; the class decision (`label_ids`, `scores`) never depends on them. They are +host-only (no device shape changes), the defaults reproduce Autoware's `frnet.param.yaml`, and a bad value raises +`tt_frnet.io.InputError` (HTTP 400) before the device runs. `model.info["runtime_params"]` (and the server's `/info`) +lists them with their defaults. + +### Input types + +- Point clouds: a path (`.bin` raw float32 rows -- pass `fields=` if not `x, y, z, intensity`; `.npy`; `.npz`; + `.pcd` ascii / binary / binary_compressed), raw bytes with `fmt=`, an (N, C) numpy array or torch tensor, a + structured array, the JSON envelope of `/predict`, or `tt_frnet.io.PointCloud`. The frame is the **sensor** frame + (Autoware's `~/input/pointcloud` before any transform), one sweep, not motion-compensated. +- Rows with a non-finite x / y / z are dropped by the decoder; `input_index` refers to the decoded rows. +- Calibration transforms: a 4×4 (or 3×4) row-major matrix, `{"translation": [x, y, z], "rotation_wxyz": [w, x, y, z]}`, + or Autoware's `{x, y, z, roll, pitch, yaw}`. With the crop box on and no transform the call is refused (the node would + skip every frame because its TF lookup fails); the error names both fixes. +- `sweeps`, `images`, `stream` and `inputs` are refused: FRNet takes one sweep and its calibration only. +- A frame outside the variant's profile (input points, points after interpolation, or unique cells out of the table + above) is not an error: as the node, which logs and publishes nothing, the call returns an empty output whose + `meta["skipped"]` holds the node's message, and nothing runs on the device. + +## Output + +`model(...)` returns a `tt_frnet.FRNetSegmentation` (a `ttaw.outputs.Segmentation3D`); `out.to_dict()` is exactly the +`POST /predict` body (SERVING.md section 3.2). The point order is the node's: the input points that survive the ego +crop box, in input order, then the points Autoware's interpolation appends (D4: published as Autoware does). + +| field | type | meaning | +|---|---|---| +| `label_ids` | uint8 `[N]` | Autoware's `class_id` (first maximum of the 27 probabilities, as `fill_cloud_kernel`); names in `class_names` | +| `scores` | float32 `[N]` | Autoware's `probability`: the softmax probability of that class | +| `points` | float32 `[N, 4]` | x, y, z, intensity of every output point in the sensor frame (the segmentation cloud's coordinates; the interpolated points carry interpolated values) | +| `filter_keep` | bool `[N]` | membership of Autoware's `~/output/pointcloud/filtered` cloud (False = removed by the class filter) | +| `num_points_raw` | int | the first `num_points_raw` rows are input points, the rest are interpolated | +| `input_index` | int32 `[num_points_raw]` | the decoded input row of every raw output point | +| `class_names` | 27 str | `drivable_surface`, `other_flat_surface`, `sidewalk`, `manmade`, `vegetation`, `car`, `bus`, `emergency_vehicle`, `train`, `truck`, `tractor_unit`, `semi_trailer`, `construction_vehicle`, `forklift`, `kart`, `motorcycle`, `bicycle`, `pedestrian`, `personal_mobility`, `animal`, `pushable_pullable`, `traffic_cone`, `debris`, `stroller`, `other_stuff`, `noise+ghost_point`, `unknown` (Autoware's names; ids 22 / 23 were trained as stroller / debris, see Limits) | +| `meta` | dict | `variant`; `num_points_input`; `ego_crop` (the box and transform used); the network-input counts (`num_points_raw`, `num_points_interpolated`, `num_points`, `num_unique_coors`, `cropped_by_ego_box`, `crop_applied`, `max_points_per_cell`); `plan` (`n`, `m`, `n_cap`, `m_cap`); `filter` (classes, threshold, points kept); `skipped` (the node's message) for an out-of-profile frame | +| `timing_ms` | dict | `preprocess`, `device` (staging + H2D + trace + D2H + un-permute), `postprocess`, `total` of this call | + +`out.class_counts()` gives the points per class (raw + interpolated); `out.to_dicts()` one row per class +(`label`, `label_id`, `points`); `len(out)` the number of output points. The node's visualization cloud is +`model.config.palette` indexed by `label_ids` (`tt_frnet.host.postprocess.visualization_rgb`). + +## Lifetime and information + +- `model.close()` releases the trace and the device tensors and closes the chip if the model opened it; idempotent. + `with` calls it for you; an unclosed model is closed when Python exits. +- `model.info`: weights (repo, tag, revision, path), device (`dispatch`, `grid`, `cores`, `num_command_queues`, + `fallback`), variant, warm variants, warm-up times, runtime parameter defaults, the variant config (`frnet`), the + trace and graph descriptions. +- Calls from several threads are safe: the device calls are serialised by a lock. One model per process per chip. +- `tt_frnet.viz.render_bev(points, label_ids, palette=...)` and `render_compare(...)` draw bird's-eye views (PIL, no + OpenCV). +- `model.device_probs(plan)` returns the device's `pred_probs` `(N, 27)` of a prepared frame (tests and tools; call it + under `model._lock`). + +## Speed + +Warm calls, batch 1, ETH dispatch, 1 CQ, 12x10, the serve.env precision knobs (`code/scripts/bench.py`, 100 +iterations; the numbers of `OPT_BASELINE.md`, 2026-10-09, on a shared host whose load moves the host stages): + +| stage, p50 | ot128, shipped sample | ot128, PandaSet 019 f40 | qt128, shipped sample | qt128, PandaSet 019 f40 | +|---|---:|---:|---:|---:| +| `model(path)` end to end | 292.6 ms | 355.7 ms | 221.1 ms | 244.3 ms | +| host pre-processing (decode, crop, projection, fp16 interpolation, unique cells, device plan) | 55.3 ms | 107.2 ms | 54.2 ms | 83.5 ms | +| H2D (9 inputs, 8.6 / 6.2 MB) | 5.42 ms | 5.41 ms | 4.04 ms | 4.00 ms | +| device trace, one forward | 210.02 ms | 211.72 ms | 133.53 ms | 134.91 ms | +| D2H (fp32 `pred_probs`) | 7.65 ms | 7.43 ms | 5.80 ms | 5.55 ms | +| un-permute + host post-processing (argmax, filter) | 7.1 + 5.0 ms | 12.8 + 14.8 ms | 6.1 + 6.8 ms | 5.9 + 10.5 ms | + +- Back-to-back replays take 211.64 ms (ot128) and 134.87 ms (qt128) per PandaSet frame: 4.72 and 7.41 frames/s of + device throughput. A synchronous call gives 2.81 / 4.09 frames/s on that frame. +- The device time barely depends on the frame: every point-wise op runs on the profile's `N_cap` rows. The host + stages grow with the cloud. +- The shipped samples are synthetic scans: ot128 53,411 points (N = M = 52,613 after the crop, no interpolated point), + qt128 45,783 points (N = 69,553 after interpolation, M = 18,926). PandaSet 019 frame 40 is a 107,146-point Pandar64 + sweep (N = 145,961 / M = 40,691 on ot128); it is not shipped. +- `OPT_REPORT.md` ranks what the optimization phase will change (the two-term point layers, K1, the image-branch glue, + shape buckets, the host pre-processing). + +## Tests + +From the repository root, with `code/` on `PYTHONPATH` (or the package installed): + +- Host (no device, no ttnn call): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests`. Inside the + tt-models workspace the suite runs on its fake ttnn (`-p fake_ttnn_plugin`), which also enables `test_tt_host.py`; + tests that need workspace-only oracles or data skip elsewhere, and FAIL inside the workspace when that data is + missing. `test_reference_cpu.py` needs onnxruntime. +- Device (one p150): `python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py + code/tt_frnet/tests/test_e2e_device.py` (per-module PCC against the fp32 CPU reference on replay outputs, the K1 + segment-max sites included; end-to-end agreement with ONNX Runtime on 17 OT128 / 5 QT128 frames, the shipped samples + against their stored references, replay == eager, API == `/predict`). The public-frame gates run only inside the + tt-models workspace, where their data lives: PandaSet, nuScenes and Autoware's sample rosbag, of which only PandaSet + may be redistributed. +- The gates are frozen in `tests/*.gates.json` at the first green run and are never loosened. + +## Limits + +- Batch 1 on the chip; one model per process; one frame (one sweep) per call; one variant per model. +- The device input has a fixed size per profile (`N_cap` 160,000 / 120,000 points, `M_cap` 60,000 cells): a frame + outside Autoware's profile ranges is skipped exactly as the node skips it. +- Label names 22 / 23: Autoware's `class_names` read `debris` / `stroller`, but the weights were trained with + stroller = 22 and debris = 23 (AWML config). The port keeps Autoware's names for parity. +- Numerics: the device's per-point probabilities differ slightly from the fp32 reference, so points near a class + boundary can take the other class (98.9-99.6 % of the points agree on the public frames, see the model card). +- Not ported: Autoware's `PointCloud2` layouts and the filtered cloud's `output_format` conversion; the API takes + arrays or files and returns arrays. diff --git a/code/conftest.py b/code/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..12e935c11b72c51ed7715170dd20d3f07c96e8f7 --- /dev/null +++ b/code/conftest.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +"""pytest fixtures of frnet-p150 (no dependency on tt-metal's own conftest). + +- ``device`` (session): one chip opened like the published numbers (``tt_frnet.device.open_device``: + ETH dispatch, 12x10 grid, the port's validated sizes and CQs). ``--device-id N`` or ``TT_DEVICE_ID`` selects + the chip (default 0); ``FRNET_DISPATCH=worker`` is the A/B opt-in, and the other ``FRNET_*`` + variables apply as for the server. A failing ETH open is an error here, never a silent WORKER fallback: gates + are only valid on the published setup. +- Tests marked ``device`` are skipped when ttnn is missing or ``TT_VISIBLE_DEVICES=none`` (host-only runs). + On the shared workspace box run them through the lock and name the test files: + ``bin/devrun -t 1800 -- python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py``. +""" +from __future__ import annotations + +import gc +import importlib.util +import os + +import pytest + + +def pytest_addoption(parser): + parser.addoption("--device-id", action="store", default=None, help="chip id (default $TT_DEVICE_ID or 0)") + + +def pytest_configure(config): + config.addinivalue_line("markers", "device: needs a Tenstorrent chip (skipped on host-only runs)") + + +def _no_device_reason(): + if os.environ.get("TT_VISIBLE_DEVICES", "").lower() == "none": + return "TT_VISIBLE_DEVICES=none (host-only run)" + if importlib.util.find_spec("ttnn") is None: + return "ttnn is not installed" + return None + + +def pytest_collection_modifyitems(config, items): + reason = _no_device_reason() + if reason: + skip = pytest.mark.skip(reason=reason) + for item in items: + if "device" in item.keywords: + item.add_marker(skip) + + +@pytest.fixture(autouse=True) +def _gc_between_tests(): + gc.collect() + + +@pytest.fixture(scope="session") +def device(request): + from tt_frnet.device import close_device, open_device + + cli = request.config.getoption("--device-id") + dev = open_device(int(cli) if cli is not None else None, allow_fallback=False) + yield dev + close_device(dev) diff --git a/code/models/common/lightweightmodule.py b/code/models/common/lightweightmodule.py new file mode 100644 index 0000000000000000000000000000000000000000..6743db9026f0547737bda649db0b3ab8c78289c0 --- /dev/null +++ b/code/models/common/lightweightmodule.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: © 2023 Tenstorrent USA, Inc. + +# SPDX-License-Identifier: Apache-2.0 + + +class LightweightModule: + """Torch modules add a surprising amount of host overhead for attribute + access and method calls. This class is a lightweight alternative that + just wraps a forward function for now.""" + + def __call__(self, *args, **kwargs): + return self.forward(*args, **kwargs) diff --git a/code/scripts/README.md b/code/scripts/README.md new file mode 100644 index 0000000000000000000000000000000000000000..dbe6317ecfbda1e0388720632449309aeb4c376c --- /dev/null +++ b/code/scripts/README.md @@ -0,0 +1,19 @@ +# code/scripts + +| script | purpose | device | +|---|---|---| +| `make_sample.py` | generates the SHIPPED samples (`samples/synthetic_.npz`, a deterministic synthetic scan per serve profile; no dataset involved), their calibration presets and their stored fp32 CPU-reference outputs (ONNX Runtime) | no | +| `bench.py` | stage breakdown of a warm forward (pre / H2D / trace / back-to-back / D2H / unsort / post / e2e; AICLK; a check of each output against its stored CPU reference); the card / OPT numbers | yes, via `bin/devrun` | +| `profile_ops.py` | one eager forward with module / layer signposts + one traced replay between signposts under the device profiler (`python -m tracy -r -p -v ...`) | yes | +| `profile_summary.py` | per-module / per-layer / per-op tables from the profiler CSV (`OPT_BASELINE.md`) | no | +| `visualizer_report.py` | ttnn-visualizer memory + graph report of one eager forward (not for timing) | yes | +| `container_smoke.sh` | serve ONE serve profile of the built package (`--profile NAME`; default profile otherwise), run `server/smoke_test.py` against it (asserts ETH dispatch, the 12x10 grid and the profile's pins; compares with the stored CPU reference), keep the evidence (container log, `/info`, the `/predict` output and the result, in `logs/smoke/` of the repo or `--log-dir DIR`), always stop it; one `bin/devrun -t 3600 -k 150` window per profile | yes | +| `make_demo.py` | workspace tool: runs the production API on the shipped samples and the public-dataset frames of `research/frnet/public_data` (`run`, device) and renders the card's `media/` (`render`, CPU) | `run`: yes | +| `make_samples.py` | workspace tool: rebuilds the STAGED PandaSet 019 frame 40 sample (git-ignored `staging_samples_pandaset/`, not shipped) and its CPU references | no | +| `make_goldens.py` | workspace tool: the large module-tap goldens and the host pre-processing hashes of the tests | no | +| `precision_study.py`, `diag_conv_precision.py`, `diag_frame_taps.py` | precision studies of the port: the CPU emulation of the TT numerics, and device diagnostics (per-unit conv precision; per-module device-vs-emulation error on one frame) | the `diag_*` scripts: yes | +| `fetch_samples.sh` | downloads public sample data that may not be redistributed (sha256-checked); no entry is active in this release | no | + +Everything here ships in `code/` on the Hub and inside the image (`source.extra_code` lists `scripts`). The workspace +tools need the tt-models research tree (`research/frnet/`) and the raw datasets; they document how the card's +numbers and renders were made but do not run from a Hub clone. diff --git a/code/scripts/bench.py b/code/scripts/bench.py new file mode 100644 index 0000000000000000000000000000000000000000..c78b23ca8c17cbe8bf7c184e3e12397e95dc6cb1 --- /dev/null +++ b/code/scripts/bench.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Stage breakdown of warm forwards: the numbers OPT_BASELINE.md / OPT_REPORT.md / the card quote. + + bin/devrun -t 900 -- python code/scripts/bench.py --iters 100 --json logs/baseline_bench.json + bin/devrun -t 900 -- python code/scripts/bench.py --variant qt128 --input a.npz --input b.npz --dispatch worker --cqs 2 + +For every ``--input`` cloud (default: the shipped synthetic sample of the variant), batch 1, after ``--warmup`` calls: ``--iters`` +iterations of the served path cut into stages, each synchronised on its own (p50 / p99 / mean / min in ms, so the +rows do not add up to ``e2e`` exactly), then ``--iters`` plain ``model(points, calibration=...)`` calls, then +back-to-back replays: + +===================== ================================================================================================ +``pre`` host pre-processing as served (``model._prepare``): decode, ego crop, float32 projection, fp16 + interpolation, unique cells, profile checks, the device plan (sort, CSR offsets, gather tables, + 8-d features with ``pre_norm`` folded) +``pre.load`` the file decode alone (``ttaw.io.load_points``) +``pre.crop`` the ego crop box (``crop_keep_mask``: float32 ``R p + t``) +``pre.interp`` the fp16 interpolation grid and the interpolated points (``interpolation_grid`` + + ``interpolate_points``) +``pre.project`` float32 projection of every point to the frustum grid (``project2d``) +``pre.unique`` unique cells and the inverse map (``unique_coors``) +``pre.plan`` ``build_plan``: the sort, CSR offsets, ``point2pix_s`` / ``pix2vox_s`` and the 8-d features +``host_in`` ``FrameStaging.write``: range checks + the plan written into the persistent host buffers +``h2d`` upload of the 9 trace inputs + device synchronise (``TraceRunner.upload``) +``h2d.`` the upload split per input (``features`` fp32, ``offsets``, ``p2p_``, ``v2p_`` uint32) +``trace`` one blocking replay of the whole-graph trace +``d2h`` ``TraceRunner.read`` as served: the fp32 ``pred_probs`` ``[N_cap, 32]`` -> numpy +``d2h.copy`` the readback split: the device-to-host copy into a preallocated host tensor ... +``d2h.to_numpy`` ... and its host conversion to numpy (host untilize of the TILE rows) +``unsort`` the first N rows / 27 columns back in the network-input order (``plan.unsort``) +``post`` host post-processing (first-max argmax, probability, class filter, output assembly) +``e2e`` ``model(points, calibration=...)`` as served (its ``timing_ms`` medians are reported too) +``b2b`` back-to-back replays, one synchronise per round of ``--b2b-iters``: device time per forward +===================== ================================================================================================ + +AICLK / power / temperature are sampled from sysfs during the run (``ttaw.profiling.AiclkSampler``), and the host +load average is recorded (the host stages move with it). ``--check`` (default) compares the served output of each +input with a stored fp32 CPU-reference ``/predict`` body (``--reference PATH`` per input, ``-`` for none; default: +``..reference.json`` next to the input) with the container-smoke gates (``server/smoke_test.py`` +``REFERENCE_GATES``). The calibration of an input (``--calib`` per input): ``preset:`` (``calib/``), a JSON +file holding the calibration dict, ``none`` (crop disabled); default: the preset named like the input's stem, else +``.calib.json`` next to the input, else the crop disabled. Always quote the configuration line (dispatch / +CQs / grid) with the numbers. +""" +from __future__ import annotations + +import argparse +import json +import os +import platform +import statistics +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +import numpy as np + +from tt_frnet import FRNet +from tt_frnet.ttaw.profiling import AiclkSampler, StageBench, time_b2b + +CODE = Path(__file__).resolve().parents[1] +SAMPLES = CODE / "tt_frnet" / "samples" # the shipped synthetic_.npz +CALIB_DIR = CODE / "tt_frnet" / "calib" + + +def calibration_for(path: Path, spec: Optional[str] = None) -> Dict[str, Any]: + """The ``calibration`` argument for ``path`` (module docstring).""" + if spec: + if spec == "none": + return {"ego_crop_box": None} + if spec.startswith("preset:"): + return {"preset": spec[len("preset:"):]} + return json.loads(Path(spec).read_text()) + stem = path.name.split(".")[0] + if (CALIB_DIR / f"{stem}.json").is_file(): + return {"preset": stem} + side = path.parent / f"{stem}.calib.json" + if side.is_file(): + return json.loads(side.read_text()) + return {"ego_crop_box": None} + + +def check_output(model, path: Path, calib: Dict[str, Any], reference: Optional[Path]) -> Dict[str, Any]: + """Served output vs the stored fp32 CPU-reference ``/predict`` body (the container-smoke comparison).""" + from tt_frnet.server import smoke_test as st + from tt_frnet.ttaw.server import smoke + + out = model(str(path), calibration=calib).to_dict() + res: Dict[str, Any] = {"num_points": out.get("num_points"), "num_points_raw": out.get("num_points_raw"), + "skipped": (out.get("meta") or {}).get("skipped")} + if reference is not None and reference.is_file(): + metrics, fails = smoke.compare_with_reference(out, smoke.load_json(reference), gates=st.REFERENCE_GATES) + res["reference"] = {"file": str(reference), "metrics": metrics, "failures": fails} + return res + + +def bench_input(model, path: Path, calib: Dict[str, Any], *, iters: int, warmup: int, b2b_iters: int, + b2b_rounds: int, name: str) -> Dict[str, Any]: + import ttnn + + from tt_frnet.host.preprocess import (build_plan, crop_keep_mask, interpolate_points, interpolation_grid, + project2d, unique_coors) + from tt_frnet.host.frame import POINT_FIELDS + from tt_frnet.ttaw import io as tio + from tt_frnet.ttaw.tensors import to_numpy + + tr = model.runner # ttaw.trace.TraceRunner, variant "default" + dev, pre, cfg = model.device, model.pre, model.config + params = model.validate_params({}) + src = str(path) + bench = StageBench(name) + for _ in range(warmup): + model(src, calibration=calib) + out_dev = tr.outputs("default") # the persistent trace output (device) + host_out = ttnn.allocate_tensor_on_host(out_dev.spec, dev) + names = list(model.graph.input_specs()) + slots = {k: tr._slot(k, "input") for k in names} + T12, bounds, _ = pre.crop_setup(calib) + prepared = None + for _ in range(iters): + t0 = time.perf_counter() + prepared = model._prepare(points=src, calibration=calib) + t1 = time.perf_counter() + plan = prepared.plan + inputs = model.staging.write(plan) + t2 = time.perf_counter() + tr.upload(inputs) + ttnn.synchronize_device(dev) + t3 = time.perf_counter() + tr.replay("default") + ttnn.synchronize_device(dev) + t4 = time.perf_counter() + rows = tr.read("default") + t5 = time.perf_counter() + probs = np.asarray(rows, np.float32).reshape(-1, rows.shape[-1]) + raw = plan.unsort(probs[:, : cfg.num_classes]) + t6 = time.perf_counter() + model._postprocess(raw, prepared, params) + t7 = time.perf_counter() + for stage, a, b in (("pre", t0, t1), ("host_in", t1, t2), ("h2d", t2, t3), ("trace", t3, t4), + ("d2h", t4, t5), ("unsort", t5, t6), ("post", t6, t7)): + bench.add(stage, (b - a) * 1e3) + # the split rows (outside the served path; the same data and functions as autoware_preprocess) + with bench.stage("pre.load"): + xyzi = np.ascontiguousarray(tio.load_points(src).select(POINT_FIELDS), np.float32) + with bench.stage("pre.crop"): + keep = (crop_keep_mask(xyzi[:, :3], T12, np.asarray(bounds, np.float64).astype(np.float32)) + if T12 is not None else np.ones(len(xyzi), bool)) + rawp = xyzi[keep] + with bench.stage("pre.interp"): + grid, filled = interpolation_grid(rawp, cfg) + interp = interpolate_points(grid, filled, cfg) + allpts = np.concatenate([rawp, interp], axis=0) if len(interp) else rawp + with bench.stage("pre.project"): + fx, fy = project2d(allpts[:, :3], cfg.frustum_w, cfg.frustum_h, cfg.fov) + coors = np.stack([np.zeros_like(fy), fy, fx], axis=1).astype(np.int64) + with bench.stage("pre.unique"): + unique_coors(coors, cfg.frustum_w, cfg.frustum_h) + with bench.stage("pre.plan"): + build_plan(prepared.inputs, cfg, pre_norm=pre.pre_norm) + for k, slot in slots.items(): + with bench.stage(f"h2d.{k}"): + ttnn.copy_host_to_device_tensor(inputs[k], slot.buffers[0], cq_id=0) + ttnn.synchronize_device(dev) + with bench.stage("d2h.copy"): + ttnn.copy_device_to_host_tensor(out_dev, host_out, blocking=True) + with bench.stage("d2h.to_numpy"): + to_numpy(host_out) + timing: List[Dict[str, float]] = [] + for _ in range(iters): + t0 = time.perf_counter() + out = model(src, calibration=calib) + bench.add("e2e", (time.perf_counter() - t0) * 1e3) + timing.append(dict(out.timing_ms)) + rounds = [time_b2b(lambda: tr.replay("default"), lambda: ttnn.synchronize_device(dev), n=b2b_iters) + for _ in range(b2b_rounds)] + for r in rounds: + bench.add("b2b", r) + b2b = statistics.median(rounds) + elem = {"float32": 4, "uint32": 4, "bfloat16": 2} + in_bytes = {k: int(np.prod(shape)) * elem[dtype] for k, (shape, dtype) in model.graph.input_specs().items()} + return {"stages_ms": bench.summary(), "table": bench.table(), + "timing_ms_median": {k: statistics.median(t[k] for t in timing) for k in timing[0]}, + "b2b_rounds_ms": rounds, "b2b_iters": b2b_iters, "device_fps_b2b": 1e3 / b2b, + "e2e_fps_sync": 1e3 / bench.summary()["e2e"]["p50"], + "frame": {k: v for k, v in prepared.stats().items() if k != "ego_crop"}, + "bytes": {**{f"h2d.{k}": v for k, v in in_bytes.items()}, "h2d": sum(in_bytes.values()), + "d2h": int(np.prod(out_dev.shape)) * 4}} + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--input", action="append", default=None, help="cloud .npz / .pcd / .bin; repeatable; " + "default: the shipped synthetic sample of the variant") + ap.add_argument("--calib", action="append", default=None, + help="per --input: preset: | | none (default: see the docstring)") + ap.add_argument("--reference", action="append", default=None, + help="fp32 CPU-reference /predict body per --input ('-' for none; default ." + ".reference.json next to the input)") + ap.add_argument("--iters", type=int, default=100) + ap.add_argument("--warmup", type=int, default=10) + ap.add_argument("--b2b-iters", type=int, default=50) + ap.add_argument("--b2b-rounds", type=int, default=5) + ap.add_argument("--variant", default=None, help="ot128 | qt128 (default: FRNET_VARIANT or ot128)") + ap.add_argument("--dispatch", default=None, choices=["eth", "worker"], help="default: FRNET_DISPATCH or eth") + ap.add_argument("--cqs", type=int, default=None, choices=[1, 2], help="command queues (default: FRNET_NUM_CQS or 1)") + ap.add_argument("--allow-fallback", action="store_true", help="accept a WORKER fallback of an ETH open") + ap.add_argument("--no-check", dest="check", action="store_false") + ap.add_argument("--tag", default="", help="label of this configuration in the output") + ap.add_argument("--json") + a = ap.parse_args() + inputs = [Path(p) for p in (a.input or [str(SAMPLES / f"synthetic_{a.variant or os.environ.get('FRNET_VARIANT', 'ot128')}.npz")])] + calibs = list(a.calib or []) + [None] * len(inputs) + refs = [None if r in (None, "-") else Path(r) for r in (a.reference or [])] + [None] * len(inputs) + load0 = os.getloadavg() + t0 = time.perf_counter() + with FRNet.from_pretrained(variant=a.variant, dispatch=a.dispatch, num_command_queues=a.cqs) as model: + load_s = time.perf_counter() - t0 + info = dict(model.device_info) + want = a.dispatch or os.environ.get("FRNET_DISPATCH", "eth") + if info.get("dispatch") != want and not a.allow_fallback: + raise SystemExit(f"device opened with dispatch {info.get('dispatch')!r}, asked for {want!r}") + tag = a.tag or f"{info.get('dispatch')}-{info.get('num_command_queues')}cq {info.get('grid')}" + res: Dict[str, Any] = {"tag": tag, "config": info, "variant": model.variant, + "graph": model.graph.describe(), "trace": model.runner.describe(), + "load_s": load_s, "warmup_ms": dict(model.warmup_ms), "iters": a.iters, + "host": {"cpus": os.cpu_count(), "machine": platform.processor() or platform.machine(), + "loadavg_start": load0}, "inputs": {}} + with AiclkSampler(interval_s=0.05) as clk: + for k, (path, cspec, ref) in enumerate(zip(inputs, calibs, refs)): + calib = calibration_for(path, cspec) + if ref is None and a.reference is None: + cand = path.parent / f"{path.name.split('.')[0]}.{model.variant}.reference.json" + ref = cand if cand.is_file() else None + r = bench_input(model, path, calib, iters=a.iters, warmup=a.warmup, b2b_iters=a.b2b_iters, + b2b_rounds=a.b2b_rounds, name=f"{path.name} | {tag} | {model.variant}") + r["input"], r["calibration"] = str(path), calib + if a.check: + r["check"] = check_output(model, path, calib, ref) + res["inputs"][f"{k}:{path.name}"] = r + print(r["table"], flush=True) + print(f"b2b rounds ms: {[round(x, 3) for x in r['b2b_rounds_ms']]} " + f"timing_ms medians: { {k2: round(v, 2) for k2, v in r['timing_ms_median'].items()} } " + f"frame: {r['frame']} bytes: {r['bytes']}", flush=True) + if a.check: + print("check:", json.dumps(r["check"], default=str), flush=True) + res["aiclk"] = clk.summary() + res["trace_after"] = model.runner.describe() + res["host"]["loadavg_end"] = os.getloadavg() + print(json.dumps({"tag": tag, "variant": res["variant"], "config": info, "aiclk": res["aiclk"], + "load_s": round(load_s, 2), "program_cache_entries": + res["trace_after"].get("program_cache_entries"), + "loadavg": [load0, res["host"]["loadavg_end"]]}, default=str), flush=True) + 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") + + +if __name__ == "__main__": + main() diff --git a/code/scripts/container_smoke.sh b/code/scripts/container_smoke.sh new file mode 100644 index 0000000000000000000000000000000000000000..06d2dc6ecdebf1055513c651f1aa8d78907445e5 --- /dev/null +++ b/code/scripts/container_smoke.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Serve ONE serve profile of the BUILT container package, run the smoke test against it, keep the evidence, and always +# stop it -- in one device-lock window. Run it once per serve profile (`tt-model profiles +# /tt_kernel_manifest.json` lists them; without --profile the package's default profile is served): +# +# ROOT=/home/ubuntu/experiments/tt-models +# $ROOT/bin/devrun -t 3600 -k 150 -- env -u HF_TOKEN -u HUGGING_FACE_HUB_TOKEN sg docker -c \ +# "bash code/scripts/container_smoke.sh $ROOT/build/frnet-p150 [port] [--profile NAME] [--log-dir DIR]" +# +# The smoke test FAILS unless /info reports ETH dispatch and the 12x10 grid and runs what the package pins for the +# profile (dispatch, CQs, variant, weights revision); it also compares the output with the stored CPU reference of the +# sample when one exists (code/tt_frnet/server/smoke_test.py). Exit code: the smoke test's (0 = PASS); 1 when +# serve fails, 2 on a usage error. +# +# Evidence, kept whatever the outcome (--log-dir, default: logs/smoke/ of this repo, next to code/), named +# [-]-.*: +# .container.log the container's whole log (boot, requests, shutdown), followed while the container stops +# .info.json GET /info as soon as the server is READY (device: dispatch, grid, cores; pins; versions) +# .smoke.json the smoke test's /predict output (the SMOKE_OUT environment variable overrides this path) +# .result.json profile, port, exit code, start / end times +# +# `tt-model serve` returns once the server is READY and leaves the container running, so the stop must happen before +# the lock is released, also when devrun's timeout TERMs this script: the EXIT trap saves the log, then stops the +# container cleanly with SIGTERM (120 s grace, hence devrun -k 150), never `docker kill`, which would leave the chip +# dirty. Needs docker access (sg docker) and python3. +set -u +usage() { echo "usage: container_smoke.sh [port] [--profile NAME] [--log-dir DIR]" >&2; } +PROFILE="" +LOG_DIR="" +POSITIONAL=() +while [ $# -gt 0 ]; do + case "$1" in + --profile) [ $# -ge 2 ] || { usage; exit 2; }; PROFILE="$2"; shift 2 ;; + --profile=*) PROFILE="${1#--profile=}"; shift ;; + --log-dir) [ $# -ge 2 ] || { usage; exit 2; }; LOG_DIR="$2"; shift 2 ;; + --log-dir=*) LOG_DIR="${1#--log-dir=}"; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "container_smoke.sh: unknown option $1" >&2; usage; exit 2 ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done +[ ${#POSITIONAL[@]} -ge 1 ] && [ ${#POSITIONAL[@]} -le 2 ] || { usage; exit 2; } +STAGED="${POSITIONAL[0]}" +PORT="${POSITIONAL[1]:-20000}" +MANIFEST="$STAGED/tt_kernel_manifest.json" +[ -f "$MANIFEST" ] || { echo "container_smoke.sh: $MANIFEST not found (run tt-model package first)" >&2; exit 2; } +HERE="$(cd "$(dirname "$0")" && pwd)" +PROFILE_ARGS=() +[ -n "$PROFILE" ] && PROFILE_ARGS=(--profile "$PROFILE") + +# The package name and the served profile (tt-model's rule: --profile, else default_profile, else the first serve +# profile), hence the container name tt-model gives it: tt-model-- (tt_kernel/container.py). +read -r NAME PROFILE_NAME < <(python3 - "$MANIFEST" "$PROFILE" <<'EOF' +import json, sys +m = json.load(open(sys.argv[1])) +c = m.get("container") or {} +profiles = c.get("serve_profiles") or [{}] +print(m.get("name") or "model", sys.argv[2] or c.get("default_profile") or profiles[0].get("name") or "default") +EOF +) +[ -n "${NAME:-}" ] || { echo "container_smoke.sh: cannot read the package name from $MANIFEST" >&2; exit 2; } +CONTAINER="tt-model-$NAME-$PROFILE_NAME" + +[ -n "$LOG_DIR" ] || LOG_DIR="$(cd "$HERE/../.." && pwd)/logs/smoke" +if ! mkdir -p "$LOG_DIR" 2>/dev/null || [ ! -w "$LOG_DIR" ]; then + echo "container_smoke.sh: cannot write $LOG_DIR; keeping the evidence in ${TMPDIR:-/tmp}" >&2 + LOG_DIR="${TMPDIR:-/tmp}" +fi +LOG_DIR="$(cd "$LOG_DIR" && pwd)" +STARTED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +STEM="$LOG_DIR/$NAME${PROFILE:+-$PROFILE}-$(date -u +%Y%m%dT%H%M%SZ)" +SMOKE_JSON="${SMOKE_OUT:-$STEM.smoke.json}" + +fetch() { # fetch URL FILE: one GET with a 30 s timeout; the body goes to FILE + python3 - "$1" "$2" <<'EOF' +import sys, urllib.request +try: + with urllib.request.urlopen(sys.argv[1], timeout=30) as r: + body = r.read() +except Exception as e: # noqa: BLE001 -- best effort: the smoke test reports the server's state + sys.exit(f"container_smoke.sh: GET {sys.argv[1]} failed: {e}") +open(sys.argv[2], "wb").write(body) +EOF +} + +CHILD="" +STOPPED=0 +cleanup() { + local rc=$? + [ "$STOPPED" = 1 ] && return + STOPPED=1 + if [ -n "$CHILD" ]; then kill -TERM "$CHILD" 2>/dev/null; wait "$CHILD" 2>/dev/null; fi + # The log is lost with the container: follow it (whole history, then the shutdown lines) while it stops. + local logger="" + if docker inspect "$CONTAINER" >/dev/null 2>&1; then + docker logs --follow "$CONTAINER" > "$STEM.container.log" 2>&1 & + logger=$! + else + tt-model logs "${PROFILE_ARGS[@]}" "$MANIFEST" > "$STEM.container.log" 2>&1 || true + fi + tt-model stop "${PROFILE_ARGS[@]}" "$MANIFEST" || true + if [ -n "$logger" ]; then + for _ in $(seq 1 30); do kill -0 "$logger" 2>/dev/null || break; sleep 1; done + kill "$logger" 2>/dev/null + wait "$logger" 2>/dev/null + fi + python3 - "$STEM.result.json" "$NAME" "$PROFILE_NAME" "$PORT" "$rc" "$STARTED" "$CONTAINER" "$MANIFEST" <<'EOF' +import datetime, json, sys +out, name, profile, port, rc, started, container, manifest = sys.argv[1:] +ended = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +json.dump({"bundle": name, "profile": profile, "port": int(port) if port.isdigit() else port, "rc": int(rc), + "result": "PASS" if rc == "0" else "FAIL", + "started": started, "ended": ended, "container": container, "manifest": manifest}, open(out, "w"), indent=1) +EOF + echo "container_smoke.sh: rc=$rc; evidence in $STEM.*" +} +trap cleanup EXIT +trap 'exit 143' TERM +trap 'exit 130' INT + +# Each step runs in the background and is waited for: bash defers a trap while a FOREGROUND command runs, so a TERM +# sent to this script alone (devrun's timeout signals the whole process group) would otherwise wait for the step. +step() { + "$@" & + CHILD=$! + wait "$CHILD" + local rc=$? + CHILD="" + return "$rc" +} + +# --port and --profile BEFORE the target: options after it are passed through to the container (tt-model cli rule) +step tt-model serve --port "$PORT" "${PROFILE_ARGS[@]}" "$MANIFEST" || exit 1 +step fetch "http://127.0.0.1:$PORT/info" "$STEM.info.json" +step python3 "$HERE/../tt_frnet/server/smoke_test.py" --url "http://127.0.0.1:$PORT" --wait 600 \ + --manifest "$MANIFEST" "${PROFILE_ARGS[@]}" --out "$SMOKE_JSON" diff --git a/code/scripts/diag_conv_precision.py b/code/scripts/diag_conv_precision.py new file mode 100644 index 0000000000000000000000000000000000000000..7ccd2c2ee43cdad70736415bf02d682bf49251ac --- /dev/null +++ b/code/scripts/diag_conv_precision.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Device diagnostic (M1b follow-up): where does the device lose accuracy outside the point island? + + cd bundles/frnet-p150 && bin/devrun -t 1200 -- env PYTHONPATH=code python code/scripts/diag_conv_precision.py \\ + --out /home/ubuntu/experiments/tt-models/logs/frnet/m1b/diag_conv_precision.json + +Teacher-forced units on the OT128 sample's golden taps (torch fp32 reference): each unit's device output is compared +with the golden output (``dev``) and with a CPU emulation of the device's rounding points with exact fp32 weights +(``emu``: bf16 inputs, conv outputs rounded to bf16 before / after each activation, bf16 adds); ``dev_vs_emu`` +rel-L2 measures the device's arithmetic error beyond the emulated bf16 roundings (0 for a perfect fp32 conv engine). +Units: the stem (3 convs + HardSwish), every stage's BasicBlocks (stage input -> ``layer``), every attention +(``pixel_fusion`` -> ``attention``), the ``addcmul`` (-> ``stage``), ``fuse2``, the softmax, the first conv +of each stage alone (no activation), and the element-wise ops (HardSwish, sigmoid) on bf16 inputs. Configurations: +``FRNET_CONV_TERMS`` 1 (float32 conv weights: the device's TF32-like path) and 2 (C17 two-term bf16 weights). +Eager runs (no trace): a diagnostic, not a gate. +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve() +sys.path.insert(0, str(HERE.parents[1])) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--terms", nargs="*", type=int, default=[1, 2]) + args = ap.parse_args() + + import torch + import torch.nn.functional as F + + torch.set_num_threads(4) + from tt_frnet.device import device_session + from tt_frnet.reference.config import FRNetConfig + from tt_frnet.reference.weights import load_weights + from tt_frnet.tests._data import golden_dir, weights_dir + from tt_frnet.tt.layers import free + from tt_frnet.tt.model import KNOBS, LOGIT_PAD, TtFRNet + from tt_frnet.ttaw.metrics import error_stats, pcc + from tt_frnet.ttaw.ops.conv import FeatureMap, fmap_from_numpy, fmap_to_numpy + from tt_frnet.ttaw.tensors import round_to_bf16 + + variant = "ot128" + wdir = weights_dir() + cfg = FRNetConfig.from_model_dir(wdir, variant) + weights = load_weights(wdir, variant) + taps = np.load(golden_dir() / variant / "pandaset_019_40.taps.npz", mmap_mode="r") + + def g(name): + return np.asarray(taps[name], np.float32) + + def bf(a): + return round_to_bf16(np.asarray(a, np.float32)) + + def t(a): + return torch.from_numpy(np.ascontiguousarray(a, np.float32)) + + # ---- CPU emulation of the device rounding points (exact fp32 weights) + def emu_conv(x, layer, act=True): + y = F.conv2d(t(x), t(layer.weight), t(layer.bias), stride=layer.stride, padding=layer.padding).numpy() + y = bf(y) + if act and layer.activation == "hardswish": + y = bf(F.hardswish(t(y)).numpy()) + elif act and layer.activation == "sigmoid": + y = bf(torch.sigmoid(t(y)).numpy()) + return y + + def emu_blocks(x, st): + for c1, c2, ds in st.blocks: + y = emu_conv(emu_conv(x, c1), c2) + idt = emu_conv(x, ds) if ds is not None else x + x = bf(F.hardswish(t(bf(y + idt))).numpy()) + return x + + def stats(dev, emu, gold): + d = error_stats(dev, gold) + e = error_stats(emu, gold) + de = error_stats(dev, emu) + return {"pcc_dev": d["pcc"], "pcc_emu": e["pcc"], "rel_l2_dev": d["rel_l2"], "rel_l2_emu": e["rel_l2"], + "rel_l2_dev_vs_emu": de["rel_l2"], "max_abs_dev_vs_emu": de["max_abs"]} + + results = {"variant": variant, "frame": "pandaset_019_40", "configs": {}} + stage_inputs = ["backbone.fusion_stem", "backbone.stage1", "backbone.stage2", "backbone.stage3"] + emu_cache = {} + with device_session() as device: + for terms in args.terms: + t0 = time.time() + knobs = KNOBS.read(CONV_TERMS=terms) + net = TtFRNet(device, weights, cfg, knobs=knobs) + res = {} + + def up(nchw): + return fmap_from_numpy(bf(nchw), device, dtype="bfloat16") + + def report(name, dev, emu, gold): + if callable(emu): # the CPU emulation is the same for every configuration + if name not in emu_cache: + emu_cache[name] = emu() + emu = emu_cache[name] + res[name] = stats(dev, emu, gold) + r = res[name] + print(f"[terms={terms}] {name:28s} pcc dev {r['pcc_dev']:.6f} emu {r['pcc_emu']:.6f} | rel_l2 dev " + f"{r['rel_l2_dev']:.3e} emu {r['rel_l2_emu']:.3e} dev-vs-emu {r['rel_l2_dev_vs_emu']:.3e}", + flush=True) + + # stem + fr = g("backbone.frustum") + fr32 = np.concatenate([fr, np.zeros_like(fr)], 1) + x = up(fr32) + for conv in net.stem: + y = conv(x) + free(x) + x = y + dev = fmap_to_numpy(x) + free(x) + def emu_stem(): + e = bf(fr) + for c in weights.stem: + e = emu_conv(e, c) + return e + report("stem", dev, emu_stem, g("backbone.stem")) + # stages + for st_w, st, src in zip(weights.stages, net.stages, stage_inputs): + l = st["index"] + xin = g(src) + # first conv alone (pre-activation) + c1 = st["blocks"][0][0] + x = up(xin) + y = c1.conv(x) + dev = fmap_to_numpy(y) + free(y) + lay = st_w.blocks[0][0] + exact = F.conv2d(t(bf(xin)), t(lay.weight), t(lay.bias), stride=lay.stride, + padding=lay.padding).numpy() + report(f"layer{l}.block0.conv1(noact)", dev, bf(exact), exact) + # blocks + for c1_, c2_, ds_ in st["blocks"]: + y1 = c1_(x) + y = c2_(y1) + free(y1) + idt = ds_(x) if ds_ is not None else x + import ttnn + + s = ttnn.add(y.tensor, idt.tensor, memory_config=ttnn.DRAM_MEMORY_CONFIG) + out = y.with_tensor(ttnn.hardswish(s)) + free(s, y) + if ds_ is not None: + free(idt) + free(x) + x = out + dev = fmap_to_numpy(x) + free(x) + report(f"layer{l}", dev, lambda: emu_blocks(bf(xin), st_w), g(f"backbone.layer{l}")) + # attention + pfz = g(f"backbone.pixel_fusion{l}") + x = up(pfz) + a0 = st["attention"][0](x) + a1 = st["attention"][1](a0) + free(a0) + dev = fmap_to_numpy(a1) + free(a1) + report(f"attention{l}", dev, + lambda: emu_conv(emu_conv(bf(pfz), st_w.attention[0]), st_w.attention[1]), + g(f"backbone.attention{l}")) + # addcmul + lay_g, att_g = g(f"backbone.layer{l}"), g(f"backbone.attention{l}") + xl, att = up(lay_g), up(att_g) + out = ttnn.addcmul(xl.tensor, x.tensor, att.tensor, memory_config=ttnn.DRAM_MEMORY_CONFIG) + dev = fmap_to_numpy(x.with_tensor(out)) + free(out, xl, att, x) + e = bf(bf(lay_g) + bf(pfz) * bf(att_g)) + report(f"stage{l}(addcmul)", dev, e, g(f"backbone.stage{l}")) + # fuse2 + f1 = g("decoder.fuse1") + x = up(f1) + y = net.fuse2(x) + dev = fmap_to_numpy(y) + free(x, y) + report("fuse2", dev, lambda: emu_conv(bf(f1), weights.fuse_layer2), g("decoder.fuse2")) + # element-wise ops on bf16 inputs (synthetic: the stage-1 map scaled to a wide range) + import torch as _t + import ttnn + + src = g("backbone.layer1")[:, :, :32, :] * 3.0 + xin = up(src) + for opname, fn, ref in (("hardswish", ttnn.hardswish, F.hardswish), ("sigmoid", ttnn.sigmoid, + torch.sigmoid)): + yo = xin.with_tensor(fn(xin.tensor)) + dev = fmap_to_numpy(yo) + free(yo) + exact = ref(t(bf(src))).numpy() + report(f"op.{opname}(bf16)", dev, bf(exact), exact) + free(xin) + # softmax (fp32 logits, padded with -1e30) + lg = g("head.logits")[:65536] + pad = np.full((lg.shape[0], LOGIT_PAD), -1e30, np.float32) + pad[:, : lg.shape[1]] = lg + lt = ttnn.from_torch(_t.from_numpy(pad.reshape(1, 1, *pad.shape)), dtype=ttnn.float32, + layout=ttnn.TILE_LAYOUT, device=device, memory_config=ttnn.DRAM_MEMORY_CONFIG) + pr = ttnn.softmax(lt, dim=-1, compute_kernel_config=net._softmax_cfg, numeric_stable=True) + dev = ttnn.to_torch(pr).float().numpy().reshape(-1, LOGIT_PAD)[:, : lg.shape[1]] + free(lt, pr) + exact = torch.softmax(t(lg), -1).numpy() + report("softmax(fp32)", dev, exact, g("pred_probs")[:65536]) + net.release() + res["seconds"] = round(time.time() - t0, 1) + results["configs"][f"conv_terms={terms}"] = res + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(results, indent=1)) + print("wrote", args.out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/diag_frame_taps.py b/code/scripts/diag_frame_taps.py new file mode 100644 index 0000000000000000000000000000000000000000..8afc5961c286bd20caed153b293f81694a76089a --- /dev/null +++ b/code/scripts/diag_frame_taps.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Device diagnostic: per-module error of the device graph on one catalogued frame, next to the CPU emulation. + + cd bundles/frnet-p150 && bin/devrun -t 1200 -- env PYTHONPATH=code python code/scripts/diag_frame_taps.py \\ + --frame nuscenes_scene-0916_40.ot128 --out /home/ubuntu/experiments/tt-models/logs/frnet/m1b/diag_0916_40.json + +The exact fp32 TT dataflow (``reference/rewrites.py FRNetPlanTorch``) is the reference of every tap; the CPU +emulation (``--emulation``, default the ``plan_exact_points`` row of ``code/scripts/precision_study.py``: bf16 conv +operands, gathers and segment tables, exact point layers) and the device ``taps`` variant (one replay; knobs from the +``FRNET_*`` environment) are compared with it tap by tap: where the device's ``1 - PCC`` grows faster than the +emulation's is where the device loses accuracy that the emulation does not model. Also reported: ``pred_probs`` PCC +and raw-point argmax agreement vs ONNX Runtime for the device and the emulation. The CPU work runs first (in the +device lock: ~1-2 min at OT128). +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve() +sys.path.insert(0, str(HERE.parents[1])) +sys.path.insert(0, str(HERE.parent)) + +TAPS = ("encoder.pf3", "encoder.voxel_feats", "backbone.stem", "backbone.point_stem", "backbone.fusion_stem", + *(f"backbone.{k}{i}" for i in range(1, 5) for k in ("layer", "point_fusion", "pixel_fusion", "attention", + "stage")), + "decoder.fuse1", "decoder.fuse2", "decoder.point_fuse1", "decoder.point_fuse2", + *(f"head.mlp{i}" for i in range(4)), "head.logits", "pred_probs") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--frame", required=True) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--emulation", default="plan_exact_points") + ap.add_argument("--threads", type=int, default=4) + ap.add_argument("--cpu-only", action="store_true", help="stop after the CPU part (a dry run without a device)") + args = ap.parse_args() + + import torch + + torch.set_num_threads(args.threads) + from precision_study import CONFIGS + from tt_frnet.host.preprocess import build_plan + from tt_frnet.reference.config import FRNetConfig + from tt_frnet.reference.rewrites import Emulation, FRNetPlanTorch + from tt_frnet.reference.weights import load_weights + from tt_frnet.tests import _data + from tt_frnet.ttaw.golden import TapRegistry + from tt_frnet.ttaw.metrics import argmax_agreement, pcc + + variant = args.frame.rpartition(".")[2] + wdir = _data.weights_dir() + cfg = FRNetConfig.from_model_dir(wdir, variant) + w = load_weights(wdir, variant) + inputs, ort_probs, _ = _data.load_frame(args.frame) + plan = build_plan(inputs, cfg, pre_norm=(w.pre_norm_scale, w.pre_norm_shift)) + n, n_raw = plan.n, inputs.num_points_raw + H, W = plan.frustum_hw + t0 = time.time() + exact = TapRegistry(include=list(TAPS)) + FRNetPlanTorch(w)(plan, taps=exact) + emu = TapRegistry(include=list(TAPS)) + emu_probs = plan.unsort(FRNetPlanTorch(w, emulation=Emulation.parse(CONFIGS[args.emulation]))(plan, taps=emu)) + cpu_s = time.time() - t0 + + def rows(name, a): + """point taps: the first N sorted rows; voxel taps: the first M rows.""" + a = np.asarray(a) + if a.shape[0] == plan.n_cap: + return a[:n] + return a[: plan.m] if a.shape[0] in (cfg.m_cap, cfg.m_cap + 1) else a + + if args.cpu_only: + ort = np.asarray(ort_probs, np.float32) + print(f"cpu {cpu_s:.0f} s; emulation pred_probs pcc {pcc(emu_probs, ort):.6f} argmax " + f"{argmax_agreement(emu_probs[:n_raw], ort[:n_raw]):.5f}") + return 0 + from tt_frnet.device import device_session + from tt_frnet.tt.model import TtFRNet, build_runner, frame_arrays + + report = {"frame": args.frame, "N": n, "M": plan.m, "emulation": args.emulation, "cpu_s": round(cpu_s, 1), + "taps": {}} + with device_session() as device: + graph = TtFRNet(device, w, cfg) + arrays = frame_arrays(plan, cfg.m_cap) + runner = build_runner(graph, variants=("taps",), name="frnet_diag", init=arrays) + try: + runner.capture() + dev = runner("taps", inputs=arrays) + report["knobs"] = graph.knobs.as_dict() + for name in TAPS: + if name not in dev or name not in exact: + continue + ref = np.asarray(exact[name], np.float32) + d = np.asarray(dev[name], np.float32) + if ref.ndim == 4: + _, c, h, ww = ref.shape + d = d.reshape(-1, d.shape[-1])[: h * ww, :c].reshape(1, h, ww, c).transpose(0, 3, 1, 2) + e = np.asarray(emu[name], np.float32) + else: + ref = rows(name, ref)[:, : ref.shape[-1]] + d = rows(name, d.reshape(-1, d.shape[-1]))[:, : ref.shape[-1]] + e = rows(name, np.asarray(emu[name], np.float32))[:, : ref.shape[-1]] + p_dev, p_emu = float(pcc(d, ref)), float(pcc(e, ref)) + ratio = (1 - p_dev) / max(1 - p_emu, 1e-12) + report["taps"][name] = {"pcc_dev": p_dev, "pcc_emu": p_emu, "err_ratio": ratio} + print(f"{name:26s} dev {p_dev:.6f} emu {p_emu:.6f} (1-pcc) ratio {ratio:6.2f}", flush=True) + probs = plan.unsort(np.asarray(dev["pred_probs"], np.float32).reshape(-1, 32)[:, :27]) + finally: + runner.release() + graph.release() + ort = np.asarray(ort_probs, np.float32) + report["device"] = {"pred_probs_pcc": float(pcc(probs, ort)), + "argmax_agreement_raw": float(argmax_agreement(probs[:n_raw], ort[:n_raw]))} + report["emulation_result"] = {"pred_probs_pcc": float(pcc(emu_probs, ort)), + "argmax_agreement_raw": float(argmax_agreement(emu_probs[:n_raw], ort[:n_raw]))} + print("device", report["device"], "emulation", report["emulation_result"]) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(report, indent=1)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/fetch_samples.sh b/code/scripts/fetch_samples.sh new file mode 100644 index 0000000000000000000000000000000000000000..2dce8fbb68a05cae15a19e21ff4716abbe2ed839 --- /dev/null +++ b/code/scripts/fetch_samples.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Download the public sample data that frnet-p150 may NOT redistribute (license unstated or non-commercial) +# into ${FRNET_SAMPLES:-$HOME/.cache/tt_frnet/samples}, sha256-checked. Tests and benchmarks read it from there. +# Nothing downloaded here is executed; parse it with numpy / sqlite3 only. +set -euo pipefail +DEST="${FRNET_SAMPLES:-$HOME/.cache/tt_frnet/samples}" +mkdir -p "$DEST" + +fetch() { # fetch + local url="$1" sum="$2" name="$3" + if [ -f "$DEST/$name" ] && echo "$sum $DEST/$name" | sha256sum -c --status; then + echo "ok $name"; return + fi + curl -fL --retry 3 -o "$DEST/$name.part" "$url" + echo "$sum $DEST/$name.part" | sha256sum -c --status || { echo "sha256 mismatch: $name" >&2; exit 1; } + mv "$DEST/$name.part" "$DEST/$name"; echo "fetched $name" +} + +# Example (Autoware demo rosbag; pinned in autoware/ansible/roles/demo_artifacts/tasks/main.yaml:48-53): +# fetch https://autoware-files.s3.us-west-2.amazonaws.com/recordings/bags/demos/sample-rosbag.zip \ +# 5f9d36353393b3d249212153c19049822b1298db56512aa045b4f7f6fc37cf88 sample-rosbag.zip + +echo "samples in $DEST" diff --git a/code/scripts/make_demo.py b/code/scripts/make_demo.py new file mode 100644 index 0000000000000000000000000000000000000000..1b3d0873d780695223fc29e10b33da965fa43398 --- /dev/null +++ b/code/scripts/make_demo.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Demo media of the model card and the wider TT-vs-CPU agreement check on public frames (workspace tool: it reads +the public-dataset inputs and CPU runs of ``research/frnet/public_data`` and the raw datasets for the camera images; +none of them ship). + +Two steps, so the device is held only for inference: + + # 1. device (tt-env, under bin/devrun): the production API on the shipped samples, the demo frames and every + # converted public frame (PandaSet 019 / 029 every 5th frame, all 81 nuScenes mini_val key-frames), both + # profiles; per-frame p150 classes + agreement with the CPU reference (ONNX Runtime fp32 runs of the research + # pipeline: research/frnet/public_data/runs) + bin/devrun -t 1800 -- python code/scripts/make_demo.py run --out logs/frnet/m5_docs/demo + # 2. CPU (research venv: OpenCV for the camera JPEGs): the renders into media/ + tools/research-venv/bin/python code/scripts/make_demo.py render --out logs/frnet/m5_docs/demo --media media + +``run`` writes ``/run/_.npz`` (``pred``: the p150 class of every input row, 255 = not scored: +ego crop box or profile skip; ``cpu``: the reference's, same layout) and ``/agreement.json`` (per frame and per +set: raw-point argmax agreement of p150 vs CPU, N / M, timing). Agreement here is on the class decision of every raw +point the node scores; the frozen device gates (``tests/test_e2e_device.py``) additionally compare the probabilities. +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve() +CODE = HERE.parents[1] +sys.path.insert(0, str(CODE)) + +WS = Path("/home/ubuntu/experiments/tt-models") +PUB = WS / "research" / "frnet" / "public_data" +IGNORE = 255 +VARIANTS = ("ot128", "qt128") +# camera + BEV renders (PandaSet front camera, nuScenes CAM_FRONT); TT vs CPU compares; the PandaSet BEV sequence +CAMERA_FRAMES = ["pandaset_019_40", "pandaset_029_40", "nuscenes_scene-0103_20"] +COMPARE_FRAMES = ["pandaset_019_40", "nuscenes_scene-0916_40"] +GIF_FRAMES = [f"pandaset_019_{k:02d}" for k in range(0, 80, 5)] + + +def public_frames(): + return sorted(p.stem for p in (PUB / "inputs").glob("*.npz")) + + +# ------------------------------------------------------------------------------------------------ run (device) +def cmd_run(a) -> int: + from tt_frnet import FRNet + from tt_frnet.ttaw.server.smoke import decode_array + + out = Path(a.out) + (out / "run").mkdir(parents=True, exist_ok=True) + frames = public_frames() if not a.frames else a.frames + report = {"frames": {}, "sets": {}, "shipped": {}} + for variant in a.variants: + t0 = time.perf_counter() + with FRNet.from_pretrained(variant=variant, weights_dir=a.weights_dir) as model: + load_s = time.perf_counter() - t0 + dev = model.info.get("device") + print(f"[{variant}] loaded in {load_s:.1f} s, device {dev}", flush=True) + report.setdefault("device", {})[variant] = dev + # the shipped synthetic sample vs its stored reference body + sample = CODE / "tt_frnet" / "samples" / f"synthetic_{variant}.npz" + res = model(sample, calibration={"preset": f"synthetic_{variant}"}) + ref = json.loads(sample.with_name(f"synthetic_{variant}.{variant}.reference.json").read_text()) + ref_labels = np.asarray(decode_array(ref["labels"]).values, np.uint8) + np.savez_compressed(out / "run" / f"synthetic_{variant}.npz", points=res.points, pred=res.label_ids, + cpu=ref_labels, num_points_raw=res.num_points_raw) + report["shipped"][variant] = {"agreement": float(np.mean(res.label_ids == ref_labels)), + "n": len(res), "n_raw": int(res.num_points_raw), + "timing_ms": res.timing_ms} + print(f"[{variant}] shipped: {report['shipped'][variant]}", flush=True) + agree_rows = {"pandaset": [0, 0], "nuscenes": [0, 0]} + per_set = {"pandaset": [], "nuscenes": []} + for name in frames: + z = np.load(PUB / "inputs" / f"{name}.npz", allow_pickle=False) + xyzi, T = z["xyzi"], z["T_base_link_sensor"] + run = np.load(PUB / "runs" / f"{name}_{variant}.npz", allow_pickle=False) + cpu = np.full(len(xyzi), IGNORE, np.uint8) + cpu[run["input_rows"]] = run["class_id"] + t1 = time.perf_counter() + res = model(xyzi, calibration={"T_base_link_from_sensor": T.tolist()}) + ms = (time.perf_counter() - t1) * 1e3 + pred = np.full(len(xyzi), IGNORE, np.uint8) + skipped = res.meta.get("skipped") + if not skipped: + pred[res.input_index] = res.label_ids[: res.num_points_raw] + same_rows = bool(np.array_equal(pred != IGNORE, cpu != IGNORE)) + scored = cpu != IGNORE + n_eq = int(np.sum(pred[scored] == cpu[scored])) + n_sc = int(scored.sum()) + rec = {"variant": variant, "skipped": skipped, "same_rows": same_rows, "n_raw": n_sc, + "agree": n_eq / n_sc if n_sc else None, "n": len(res), "ms": round(ms, 1)} + report["frames"][f"{name}.{variant}"] = rec + kind = name.split("_")[0] + if n_sc: + agree_rows[kind][0] += n_eq + agree_rows[kind][1] += n_sc + per_set[kind].append(rec["agree"]) + np.savez_compressed(out / "run" / f"{name}_{variant}.npz", pred=pred, cpu=cpu) + print(f"[{variant}] {name}: agree {rec['agree']} rows_equal {same_rows} skipped {skipped} " + f"{ms:.0f} ms", flush=True) + for kind, (eq, n) in agree_rows.items(): + if n: + report["sets"][f"{kind}.{variant}"] = { + "frames": len(per_set[kind]), "points": n, "pooled": eq / n, + "min_frame": min(per_set[kind]), "median_frame": float(np.median(per_set[kind]))} + print(f"[{variant}] sets: { {k: v for k, v in report['sets'].items() if k.endswith(variant)} }", + flush=True) + (out / "agreement.json").write_text(json.dumps(report, indent=1, default=float)) + bad = [k for k, r in report["frames"].items() if not r["same_rows"]] + if bad: + print(f"scored rows differ from the CPU reference on {bad}", flush=True) + return 1 + return 0 + + +# ------------------------------------------------------------------------------------------------ render (CPU) +ATTR_PANDASET = ("Contains data from PandaSet (Scale AI and Hesai), https://pandaset.org, licensed under CC BY 4.0 and " + "the PandaSet Dataset Terms. Changes: Pandar64 sweep re-expressed in an estimated sensor frame, " + "front-camera image downscaled and dimmed, points and labels overlaid. Scale AI and Hesai do not " + "endorse this work. Cite: P. Xiao et al., PandaSet: Advanced Sensor Suite Dataset for Autonomous " + "Driving, ITSC 2021.") +ATTR_NUSCENES = ("Rendered from the nuScenes dataset, (c) Motional AD Inc., CC BY-NC-SA 4.0 and the nuScenes Terms of " + "Use (https://www.nuscenes.org/terms-of-use). Non-commercial use only; adaptations under the same " + "license. Motional does not endorse this work. Changes: camera image downscaled and dimmed, LiDAR " + "points and labels overlaid. Cite: H. Caesar et al., nuScenes: A Multimodal Dataset for Autonomous " + "Driving, CVPR 2020; W. K. Fong et al., Panoptic nuScenes, RA-L 2022 (lidarseg labels).") +NC_BADGE = "CC BY-NC-SA 4.0 · non-commercial" + + +def _research_render(): + sys.path.insert(0, str(PUB / "scripts")) + sys.path.insert(0, str(WS / "datasets" / "tools")) + import frnet_public_common as C # noqa: E402 (workspace code) + import frnet_public_render as R # noqa: E402 + + return C, R + + +def _agree_text(rep, key): + r = rep["frames"].get(key) + if not r or r["agree"] is None: + return "" + return f"p150 vs fp32 CPU reference: {r['agree'] * 100:.2f} % of the {r['n_raw']:,} scored raw points agree" + + +def render_camera(name: str, run_dir: Path, rep: dict, out_path: Path) -> None: + import cv2 # noqa: F401 (read_jpeg) + + C, R = _research_render() + inp = np.load(PUB / "inputs" / f"{name}.npz", allow_pickle=False) + meta = json.loads(str(inp["meta_json"])) + pred = np.load(run_dir / f"{name}_ot128.npz", allow_pickle=False)["pred"] + gjson = json.loads((PUB / "runs" / f"{name}_ot128.json").read_text()) + proc = pred != IGNORE + xyz = inp["xyzi"][:, :3] + agree = _agree_text(rep, f"{name}.ot128") + if name.startswith("pandaset"): + from pandaset_meta_loader import project + from pandaset_seq_loader import load_frame, load_sequence, read_jpeg + + seq, k = meta["sequence"], meta["frame"] + m = load_sequence(C.PANDASET_ROOT, seq) + fr = load_frame(C.PANDASET_ROOT, seq, k, m) + world = fr["lidar"][inp["src_index"], :3] + cam = fr["cameras"]["front_camera"] + img = read_jpeg(cam["path"]) + uv, idx = project(world[proc], cam["pose"], cam["K"], img.shape[1], img.shape[0]) + sel = np.flatnonzero(proc)[idx] + depth = np.linalg.norm(inp["xyzi"][sel, :3], axis=1) + cam_im = R.camera_overlay(img, uv, depth, C.PALETTE_RGB[pred[sel]], R.W / img.shape[1]) + bl, br = R.bev(xyz, R.colors_for(pred, ignore_rgb=R.UNPROC)), R.bev(xyz, R.colors_for(inp["gt27"])) + subtitle = (f"PandaSet sequence {seq}, frame {k:02d} · Hesai Pandar64, one sweep in the sensor frame · Autoware " + f"lidar_frnet v2.0 weights, Autoware pre- and post-processing, network on one Tenstorrent " + f"Blackhole p150 (N={gjson['N']:,}, M={gjson['M']:,}). {agree}.") + cam_caption = "front_camera (52° HFOV), points coloured by the p150 class" + cap_r = "PandaSet semseg mapped to FRNet classes (dark grey: no T4 counterpart, e.g. Ground, Pickup)" + footer, badge = ATTR_PANDASET, None + else: + import nuscenes_mini as nm + from pandaset_seq_loader import read_jpeg + + ds = nm.NuScenesMini(C.NUSC_ROOT) + s = ds.get("sample", meta["sample_token"]) + sd = ds.sensor_data(s, "LIDAR_TOP") + pts = ds.lidar(s) + T_lidar = ds.T_global_ego(sd) @ ds.T_ego_sensor(sd) + c = ds.cameras(s, ["CAM_FRONT"])["CAM_FRONT"] + T_cam_lidar = np.linalg.inv(c["T_global_ego"] @ c["T_ego_sensor"]) @ T_lidar + img = read_jpeg(c["path"]) + xyz_c = nm.transform_points(T_cam_lidar, pts[:, :3].astype(np.float64)) + ok = proc & (xyz_c[:, 2] > 1.0) + uvw = xyz_c[ok] @ c["K"].T + uv = uvw[:, :2] / uvw[:, 2:3] + ins = (uv[:, 0] >= 0) & (uv[:, 0] < img.shape[1]) & (uv[:, 1] >= 0) & (uv[:, 1] < img.shape[0]) + sel = np.flatnonzero(ok)[ins] + cam_im = R.camera_overlay(img, uv[ins], xyz_c[sel, 2], C.PALETTE_RGB[pred[sel]], R.W / img.shape[1], + radius=2) + bl = R.bev(xyz, R.colors_for(pred, ignore_rgb=R.UNPROC), x_range=(-30.0, 50.0), y_half=40.0) + br = R.bev(xyz, R.colors_for(inp["gt27"]), x_range=(-30.0, 50.0), y_half=40.0) + subtitle = (f"nuScenes {meta['scene']}, key-frame {meta['keyframe_index']} (mini_val) · Velodyne HDL-32E " + f"LIDAR_TOP rotated to x forward · Autoware lidar_frnet v2.0, network on one Tenstorrent Blackhole " + f"p150 (N={gjson['N']:,}, M={gjson['M']:,}). Domain gap: a 32-beam sensor (+10/-30°) vs the " + f"128-beam OT128 of the training data. {agree}.") + cam_caption = "CAM_FRONT, points coloured by the p150 class" + cap_r = "nuScenes-lidarseg mapped to FRNet classes (dark grey: no T4 counterpart, e.g. terrain, barrier)" + footer, badge = ATTR_NUSCENES, NC_BADGE + R.compose("FRNet OT128 · Tenstorrent Blackhole p150 (tt-nn)", subtitle, badge, cam_im, cam_caption, bl, br, + "p150 prediction (dark grey: not scored, ego crop box)", cap_r, pred[proc], footer, str(out_path)) + + +def render_compare_frame(name: str, run_dir: Path, rep: dict, out_path: Path, variant: str = "ot128") -> None: + from tt_frnet import viz + from tt_frnet.api import LABELS + + inp = np.load(PUB / "inputs" / f"{name}.npz", allow_pickle=False) + r = np.load(run_dir / f"{name}_{variant}.npz", allow_pickle=False) + pred, cpu = r["pred"], r["cpu"] + scored = cpu != IGNORE + xyz = inp["xyzi"][scored, :3] + meta = json.loads(str(inp["meta_json"])) + nusc = name.startswith("nuscenes") + where = (f"nuScenes {meta['scene']}, key-frame {meta['keyframe_index']} (mini_val)" if nusc else + f"PandaSet sequence {meta['sequence']}, frame {meta['frame']:02d}") + header = (f"FRNet {variant.upper()} on {where}: every raw point the node scores, coloured by the p150 class (left) " + f"and by the fp32 CPU reference's (ONNX Runtime on the deployed ONNX, middle); right: the points whose " + f"class differs. {_agree_text(rep, f'{name}.{variant}')}.") + if nusc: + header += " Non-commercial render (CC BY-NC-SA 4.0)." + rng = ((-30.0, 50.0), 40.0) if nusc else ((-40.0, 60.0), 50.0) + img = viz.render_compare(xyz, pred[scored], cpu[scored], class_names=LABELS, + titles=(f"p150 ({variant.upper()})", "fp32 CPU reference"), header=header, + footer=(ATTR_NUSCENES.replace("camera image downscaled and dimmed, LiDAR points and " + "labels overlaid", "LiDAR points rendered from above") + if nusc else ATTR_PANDASET.replace("front-camera image downscaled and dimmed, " + "points and labels overlaid", + "points rendered from above")), + x_range=rng[0], y_half=rng[1], size=420) + img.save(out_path, optimize=True) + print("wrote", out_path, img.size) + + +def render_shipped(run_dir: Path, rep: dict, media: Path) -> None: + from tt_frnet import viz + from tt_frnet.api import LABELS + + for variant in VARIANTS: + r = np.load(run_dir / f"synthetic_{variant}.npz", allow_pickle=False) + s = rep["shipped"][variant] + header = (f"Shipped sample synthetic_{variant}.npz (generated by code/scripts/make_sample.py, no dataset): " + f"{s['n']:,} output points ({s['n_raw']:,} raw + Autoware's interpolated points). Left: the p150 " + f"classes ({variant.upper()} profile); middle: the stored fp32 CPU reference; right: the points " + f"whose class differs ({s['agreement'] * 100:.2f} % agree).") + img = viz.render_compare(r["points"], r["pred"], r["cpu"], class_names=LABELS, + titles=(f"p150 ({variant.upper()})", "fp32 CPU reference (stored)"), header=header, + x_range=(-60.0, 60.0), y_half=60.0, size=420) + out = media / f"frnet_synthetic_{variant}_tt_vs_cpu.png" + img.save(out, optimize=True) + print("wrote", out, img.size) + + +def render_gif(run_dir: Path, rep: dict, out_path: Path) -> None: + from PIL import Image, ImageDraw + + C, R = _research_render() + frames = [] + for name in GIF_FRAMES: + inp = np.load(PUB / "inputs" / f"{name}.npz", allow_pickle=False) + pred = np.load(run_dir / f"{name}_ot128.npz", allow_pickle=False)["pred"] + im = R.bev(inp["xyzi"][:, :3], R.colors_for(pred, ignore_rgb=R.UNPROC), size=480) + canvas = Image.new("RGB", (480, 480 + 44), R.BG) + canvas.paste(im, (0, 22)) + d = ImageDraw.Draw(canvas) + k = int(name.split("_")[-1]) + a = rep["frames"][f"{name}.ot128"]["agree"] + d.text((6, 3), f"FRNet OT128 on p150 · PandaSet 019, frame {k:02d} (every 5th: 2 Hz)", font=R.font(13), + fill=R.INK) + d.text((6, 504), f"agreement with the fp32 CPU reference {a * 100:.2f} % · CC BY 4.0, PandaSet (Scale AI, " + f"Hesai)", font=R.font(11), fill=R.INK2) + frames.append(canvas.convert("P", palette=Image.ADAPTIVE, colors=128)) + frames[0].save(out_path, save_all=True, append_images=frames[1:], duration=500, loop=0, optimize=True) + print("wrote", out_path, f"{out_path.stat().st_size / 1e6:.2f} MB") + + +def cmd_render(a) -> int: + out, media = Path(a.out), Path(a.media) + media.mkdir(parents=True, exist_ok=True) + run_dir = out / "run" + rep = json.loads((out / "agreement.json").read_text()) + render_shipped(run_dir, rep, media) + for name in CAMERA_FRAMES: + nc = "_NC" if name.startswith("nuscenes") else "" + render_camera(name, run_dir, rep, media / f"frnet_ot128_{name}_tt{nc}.png") + for name in COMPARE_FRAMES: + nc = "_NC" if name.startswith("nuscenes") else "" + render_compare_frame(name, run_dir, rep, media / f"frnet_ot128_{name}_tt_vs_cpu{nc}.png") + render_gif(run_dir, rep, media / "frnet_ot128_pandaset_019_bev_tt.gif") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + r = sub.add_parser("run") + r.add_argument("--out", required=True) + r.add_argument("--variants", nargs="*", default=list(VARIANTS)) + r.add_argument("--frames", nargs="*", help="input stems (default: every converted public frame)") + r.add_argument("--weights-dir", default=None) + d = sub.add_parser("render") + d.add_argument("--out", required=True) + d.add_argument("--media", required=True) + a = ap.parse_args() + return cmd_run(a) if a.cmd == "run" else cmd_render(a) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/make_goldens.py b/code/scripts/make_goldens.py new file mode 100644 index 0000000000000000000000000000000000000000..07f7372592c55a6ce304a04ee4106fb373eda7b4 --- /dev/null +++ b/code/scripts/make_goldens.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Goldens of frnet-p150 from the independent fp32 CPU reference (research venv: torch, onnx, onnxruntime; CPU only). + + cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\ + /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python code/scripts/make_goldens.py --set all + +Sets (output under ``research/frnet/goldens`` unless ``--out``; never shipped, except ``host``): + +- ``taps``: the PandaSet 019 f40 sample (staged) in both variants through the bundle's host pre-processing and the + torch fp32 reference with every module tap (``reference/weights.py`` ``MODULE_TAPS`` + the per-voxel max of every + point branch, ``backbone.segmax0-4``) -> ``/pandaset_019_40.taps.npz`` (+ the four network inputs as + ``input.*`` and ONNX Runtime's ``pred_probs`` as ``ort.pred_probs``) and a ``.json`` with each tap's shape, + sha256 and its PCC / max |diff| against the same tensor of the deployed graph under ONNX Runtime (intermediates); +- ``frames``: every catalogued frame (``tests/_data.py`` ``FRAMES``: 16 OT128 + 4 QT128 public goldens, the VLS-128 + x > 0 halves) through the torch reference vs the stored ORT ``pred_probs``: PCC, max |diff|, argmax agreement (raw + rows and all rows) and the ORT top-2 margin of every disagreeing point -> ``manifest.json``; +- ``host``: ``code/tt_frnet/tests/goldens/preprocess.json`` -- sha256 of the four network inputs that the RESEARCH + pre-processing reference (``research/frnet/scripts/frnet_preproc_ref.py``) produces for the PandaSet 019 f40 sample (with + its crop transform) and for the research synthetic scans, so the host tests can check the bundle's + pre-processing where the research tree is absent. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import time +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve() +CODE = HERE.parents[1] +sys.path.insert(0, str(CODE)) + +WS = Path("/home/ubuntu/experiments/tt-models") +RES = WS / "research" / "frnet" +STEM = "pandaset_019_40" + + +def sha(a: np.ndarray) -> str: + return hashlib.sha256(np.ascontiguousarray(a).tobytes()).hexdigest() + + +def pcc64(a: np.ndarray, b: np.ndarray) -> float: + from tt_frnet.ttaw.metrics import pcc + + return float(pcc(a, b)) + + +def make_taps(out: Path, weights_dir: str, threads: int) -> None: + import torch + + from tt_frnet.reference.ort import OrtFRNet + from tt_frnet.reference.pipeline import FRNetReference + from tt_frnet.reference.weights import MODULE_TAPS + from tt_frnet.ttaw.golden import TapRegistry + + torch.set_num_threads(threads) + patterns = list(MODULE_TAPS) + ["backbone.segmax*"] + for variant in ("ot128", "qt128"): + t0 = time.time() + ref = FRNetReference(weights_dir, variant, backend="torch", threads=threads) + taps = TapRegistry(include=patterns) + staged = CODE.parent / "staging_samples_pandaset" # git-ignored (tests/_data.py) + T = json.loads((staged / "calib" / f"{STEM}.json").read_text())["T_base_link_from_sensor"] + run = ref.run(staged / "samples" / f"{STEM}.npz", calibration={"T_base_link_from_sensor": T}, taps=taps) + t_torch = time.time() - t0 + inp = run.prepared.inputs + ort_taps = TapRegistry(include=patterns) + ort = OrtFRNet(weights_dir, variant, threads=threads, intermediates=True, tap_patterns=patterns, + weights=ref.weights) + probs_ort = ort(inp.points, inp.coors, inp.voxel_coors, inp.inverse_map, taps=ort_taps) + del ort + gold = np.load(RES / "public_data" / "golden" / f"{STEM}_{variant}.npz", allow_pickle=False) + if not np.array_equal(probs_ort, gold["autoware_pred_probs"]): + sys.exit(f"{variant}: ORT pred_probs differ from the research golden (onnxruntime version?)") + meta = {"frame": STEM, "variant": variant, "reference": "tt_frnet.reference.model (torch fp32)", + "onnx_sha256": ref.weights.onnx_sha256, "torch": torch.__version__, "threads": threads, + "N": inp.num_points, "N_raw": inp.num_points_raw, "M": inp.num_voxels, + "torch_s": round(t_torch, 1), "taps": {}} + arrays = {f"input.{k}": v for k, v in inp.onnx_feeds().items()} + arrays["ort.pred_probs"] = probs_ort + for name in taps.names(): + v = np.ascontiguousarray(taps[name], np.float32) + arrays[name] = v + entry = {"shape": list(v.shape), "sha256": sha(v)} + if name in ort_taps: + o = np.asarray(ort_taps[name], np.float32) + entry.update(pcc_vs_ort=pcc64(v, o), max_abs_vs_ort=float(np.abs(v - o).max())) + meta["taps"][name] = entry + worst = min((e["pcc_vs_ort"], k) for k, e in meta["taps"].items() if "pcc_vs_ort" in e) + meta["worst_tap_vs_ort"] = {"tap": worst[1], "pcc": worst[0]} + d = out / variant + d.mkdir(parents=True, exist_ok=True) + path = d / f"{STEM}.taps.npz" + np.savez(path, **arrays) + meta["file_sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + meta["bytes"] = path.stat().st_size + (d / f"{STEM}.taps.json").write_text(json.dumps(meta, indent=1) + "\n") + print(f"taps {variant}: {len(taps.names())} taps, {path.stat().st_size / 1e9:.2f} GB, worst vs ORT " + f"{worst[1]} {worst[0]:.12f}, {time.time() - t0:.0f} s", flush=True) + del taps, ort_taps, arrays + + +def make_frames(out: Path, weights_dir: str, threads: int) -> None: + import torch + + from tt_frnet.reference.model import FRNetTorch + from tt_frnet.reference.weights import load_weights + from tt_frnet.tests import _data + + torch.set_num_threads(threads) + nets = {} + manifest = {"what": "torch fp32 reference vs the ORT pred_probs stored in each research golden", + "torch": torch.__version__, "threads": threads, "frames": {}} + for name, (rel, prefix, variant) in _data.FRAMES.items(): + path = RES / rel + if not path.is_file(): + print(f"{name}: missing {rel}", flush=True) + continue + if variant not in nets: + nets[variant] = FRNetTorch(load_weights(weights_dir, variant)) + inputs, ort_probs, g = _data.load_frame(name) + t0 = time.time() + probs = nets[variant](inputs.points, inputs.coors, inputs.voxel_coors, inputs.inverse_map) + dt = time.time() - t0 + n_raw = inputs.num_points_raw + a, b = probs.argmax(1), ort_probs.argmax(1) + diff = np.flatnonzero(a != b) + top2 = np.sort(ort_probs[diff], axis=1)[:, -2:] if len(diff) else np.zeros((0, 2), np.float32) + counts = np.bincount(inputs.inverse_map) + manifest["frames"][name] = { + "golden": f"research/frnet/{rel}", "variant": variant, "N": inputs.num_points, "N_raw": n_raw, + "M": inputs.num_voxels, "max_points_per_cell": int(counts.max()), + "pcc": pcc64(probs, ort_probs), "max_abs": float(np.abs(probs - ort_probs).max()), + "argmax_agreement": float((a == b).mean()), "argmax_agreement_raw": float((a[:n_raw] == b[:n_raw]).mean()), + "disagreements": int(len(diff)), + "disagreement_ort_margin_max": float((top2[:, 1] - top2[:, 0]).max()) if len(diff) else 0.0, + "torch_s": round(dt, 1)} + print(f"{name}: N {inputs.num_points} M {inputs.num_voxels} pcc {manifest['frames'][name]['pcc']:.12f} " + f"max|d| {manifest['frames'][name]['max_abs']:.2e} argmax {manifest['frames'][name]['argmax_agreement']:.6f}" + f" ({len(diff)} flips) {dt:.1f} s", flush=True) + out.mkdir(parents=True, exist_ok=True) + (out / "manifest.json").write_text(json.dumps(manifest, indent=1) + "\n") + + +def make_host(weights_dir: str) -> None: + from tt_frnet.reference.config import FRNetConfig + + sys.path.insert(0, str(RES / "scripts")) + import frnet_preproc_ref as R # noqa: E402 (workspace research code, numpy only) + + out = {"what": "sha256 of the four network inputs produced by research/frnet/scripts/frnet_preproc_ref.py " + "(variant 'autoware'); the bundle's host pre-processing must reproduce them", + "cases": {}} + pub = RES / "public_data" + for variant in ("ot128", "qt128"): + cfg = FRNetConfig.from_model_dir(weights_dir, variant) + z = np.load(pub / "inputs" / f"{STEM}.npz", allow_pickle=False) + T = z["T_base_link_sensor"] + crop12 = np.concatenate([T[:3, :3].reshape(-1), T[:3, 3]]).astype(np.float32) + cases = {f"{STEM}.{variant}": (z["xyzi"], crop12, cfg.crop_bounds)} + pts, _ = R.synthetic_scan(R.CFGS[variant], n_cols=600 if variant == "qt128" else 1200, seed=0) + cases[f"synthetic_seed0.{variant}"] = (pts, None, None) + for name, (xyzi, crop, bounds) in cases.items(): + pre = R.autoware_preprocess(xyzi, R.CFGS[variant], sensor_to_ref_12=crop, crop_bounds=bounds, + variant="autoware") + out["cases"][name] = {"N0": int(len(xyzi)), "N_raw": int(pre["num_points_raw"]), + "N": int(pre["num_points"]), "M": int(pre["num_unique"]), + "input_sha256": sha(np.ascontiguousarray(xyzi, np.float32)), + **{k: sha(pre[k]) for k in ("points", "coors", "voxel_coors", "inverse_map")}} + print(f"host {name}: N {pre['num_points']} M {pre['num_unique']}", flush=True) + path = CODE / "tt_frnet" / "tests" / "goldens" / "preprocess.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(out, indent=1) + "\n") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--set", choices=("taps", "frames", "host", "all"), default="all") + ap.add_argument("--out", type=Path, default=RES / "goldens") + ap.add_argument("--weights-dir", default=str(WS / "assets" / "frnet" / "hf_lidar_frnet")) + ap.add_argument("--threads", type=int, default=4) + a = ap.parse_args() + if a.set in ("host", "all"): + make_host(a.weights_dir) + if a.set in ("frames", "all"): + make_frames(a.out, a.weights_dir, a.threads) + if a.set in ("taps", "all"): + make_taps(a.out, a.weights_dir, a.threads) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/make_sample.py b/code/scripts/make_sample.py new file mode 100644 index 0000000000000000000000000000000000000000..29679ad09c72057ea18f80a86ee9cc3547053c96 --- /dev/null +++ b/code/scripts/make_sample.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Build the SHIPPED samples of frnet-p150: one synthetic LiDAR sweep per serve profile, generated here (no dataset +involved, Apache-2.0), and their stored fp32 CPU-reference outputs (the container smoke test's reference). CPU only, +no device; the reference needs ONNX Runtime (research venv). + + cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\ + /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python code/scripts/make_sample.py \\ + [--weights-dir ] [--check-against ] + +The scan is a deterministic ray cast from a sensor 2 m above a flat road (seed 0) against 25 cars, 16 building +facades, 30 poles / trees and 10 pedestrians (axis-aligned boxes), with 5 % beam dropout, 2 cm range noise and +random 0-255 intensities, in the **sensor frame** (x forward, y left, z up). The beam layout follows the profile's +own sensor: 128 rows evenly spread over the vertical field of view (OT128 +15 / -25 deg, QT128 +-52.6 deg) and a +fixed number of columns over 360 deg, chosen so that the frame lies inside Autoware's profile (the node skips a frame +with more than 60,000 unique cells): OT128 512 columns, QT128 600 columns. The generator is a copy of the research +reference ``research/frnet/scripts/frnet_preproc_ref.py synthetic_scan`` (``--check-against`` compares the arrays +with an earlier output, e.g. the M5 bench inputs ``logs/frnet/m5_baseline/inputs``). + +Writes, per profile ```` (``ot128``, ``qt128``): + +- ``code/tt_frnet/samples/synthetic_.npz``: ``points``, a structured (x, y, z, intensity) float32 array; +- ``code/tt_frnet/calib/synthetic_.json``: the calibration preset of the same name (sensor -> base_link = a pure + 2 m lift: base_link on the road under the sensor), for the ego crop box; +- ``code/tt_frnet/samples/synthetic_..reference.json``: the ``/predict`` body of the fp32 CPU reference + (``tt_frnet.reference.pipeline`` with ONNX Runtime on the deployed ONNX, the reference of record), reduced to what + the container smoke compares (``labels``, ``scores``, ``filter_keep``, npz_compressed) plus the counts. + +Regenerate the references whenever the reference, the weights or the post-processing changes. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve() +CODE = HERE.parents[1] +sys.path.insert(0, str(CODE)) + +WS = Path("/home/ubuntu/experiments/tt-models") +KEEP = ("model", "frame_id", "num_points", "num_points_raw", "class_names", "class_counts") +# per profile: the sensor's vertical field of view (deg) and rows (the ml_package frustum height) and the columns +SENSORS = {"ot128": dict(fov_up_deg=15.0, fov_down_deg=-25.0, rows=128, cols=512), + "qt128": dict(fov_up_deg=52.6, fov_down_deg=-52.6, rows=128, cols=600)} +SENSOR_HEIGHT = 2.0 +SEED = 0 + + +def synthetic_scan(fov_up_deg: float, fov_down_deg: float, rows: int, n_cols: int, seed: int = 0, + sensor_height: float = 2.0, max_range: float = 200.0): + """Ray cast against a ground plane and boxes; returns ``(points (n, 4) float32 x, y, z, intensity, labels)`` in + a seeded random order (the order a driver would not guarantee either). Same arithmetic and random stream as + ``research/frnet/scripts/frnet_preproc_ref.py synthetic_scan`` (cfg = the profile's FOV, ``interp_h`` rows).""" + rng = np.random.default_rng(seed) + elev = np.deg2rad(np.linspace(fov_down_deg + 0.05, fov_up_deg - 0.05, rows)) + azim = np.deg2rad(np.arange(n_cols) * (360.0 / n_cols) - 180.0) + E, A = np.meshgrid(elev, azim, indexing="ij") + d = np.stack([np.cos(E) * np.cos(A), np.cos(E) * np.sin(A), np.sin(E)], axis=-1).reshape(-1, 3) + t_best = np.full(len(d), np.inf) + label = np.full(len(d), -1) + with np.errstate(divide="ignore", invalid="ignore"): # ground plane z = -sensor_height + tg = np.where(d[:, 2] < -1e-6, -sensor_height / d[:, 2], np.inf) + upd = tg < t_best + t_best[upd] = tg[upd] + label[upd] = 0 + boxes = [] # (cx, cy, cz_bottom, lx, ly, lz, class) + for _ in range(25): # cars + cx, cy = rng.uniform(-60, 60), rng.choice([-1, 1]) * rng.uniform(3, 12) + boxes.append((cx, cy, -sensor_height, 4.5, 1.9, 1.6, 5)) + for side in (-1, 1): # building facades + for k in range(8): + boxes.append((-80 + 20 * k, side * rng.uniform(18, 25), -sensor_height, 15, 3, rng.uniform(6, 20), 3)) + for _ in range(30): # poles / trees + boxes.append((rng.uniform(-70, 70), rng.choice([-1, 1]) * rng.uniform(13, 16), -sensor_height, .4, .4, 6, 4)) + for _ in range(10): # pedestrians + boxes.append((rng.uniform(-30, 30), rng.choice([-1, 1]) * rng.uniform(12, 14), -sensor_height, .6, .6, 1.7, + 17)) + for (cx, cy, cz, lx, ly, lz, cls) in boxes: + lo = np.array([cx - lx / 2, cy - ly / 2, cz]) + hi = np.array([cx + lx / 2, cy + ly / 2, cz + lz]) + with np.errstate(divide="ignore", invalid="ignore"): + inv = 1.0 / d + t1 = lo * inv + t2 = hi * inv + tmin = np.nanmax(np.minimum(t1, t2), axis=1) + tmax = np.nanmin(np.maximum(t1, t2), axis=1) + hit = (tmax >= np.maximum(tmin, 0)) & (tmin > 0.5) + upd = hit & (tmin < t_best) + t_best[upd] = tmin[upd] + label[upd] = cls + ok = np.isfinite(t_best) & (t_best < max_range) + ok &= rng.random(len(d)) > 0.05 # beam dropout + xyz = d[ok] * (t_best[ok, None] + rng.normal(0, 0.02, size=(ok.sum(), 1))) # range noise + inten = np.clip(rng.normal(40, 25, size=ok.sum()), 0, 255).round() # 0..255 intensity + pts = np.concatenate([xyz, inten[:, None]], axis=1).astype(np.float32) + perm = rng.permutation(len(pts)) + return pts[perm], label[ok][perm] + + +def structured(xyzi: np.ndarray) -> np.ndarray: + pts = np.zeros(len(xyzi), dtype=[("x", " int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--weights-dir", default=str(WS / "assets" / "frnet" / "hf_lidar_frnet")) + ap.add_argument("--threads", type=int, default=4) + ap.add_argument("--variants", nargs="*", default=list(SENSORS)) + ap.add_argument("--check-against", type=Path, help="directory with synthetic_.npz to compare the points with") + ap.add_argument("--no-reference", action="store_true", help="write the scans and presets only") + a = ap.parse_args() + samples, calib = CODE / "tt_frnet" / "samples", CODE / "tt_frnet" / "calib" + T = np.eye(4) + T[2, 3] = SENSOR_HEIGHT + for variant in a.variants: + s = SENSORS[variant] + xyzi, _ = synthetic_scan(s["fov_up_deg"], s["fov_down_deg"], s["rows"], s["cols"], seed=SEED, + sensor_height=SENSOR_HEIGHT) + pts = structured(xyzi) + stem = f"synthetic_{variant}" + if a.check_against is not None: + old = np.load(a.check_against / f"{stem}.npz", allow_pickle=False)["points"] + if not (old.dtype == pts.dtype and np.array_equal(old, pts)): + sys.exit(f"{stem}: the points differ from {a.check_against}") + print(f"{stem}: identical to {a.check_against / (stem + '.npz')}", flush=True) + np.savez_compressed(samples / f"{stem}.npz", points=pts) + preset = {"name": stem, + "description": f"synthetic {variant.upper()}-like scan (code/scripts/make_sample.py): the sensor " + f"{SENSOR_HEIGHT:g} m above a flat road, base_link on the road under it (a pure lift)", + "source": "code/scripts/make_sample.py (generated, no dataset; Apache-2.0)", + "T_base_link_from_sensor": T.tolist()} + (calib / f"{stem}.json").write_text(json.dumps(preset, indent=1) + "\n") + print(f"{stem}: {len(pts)} points ({s['rows']} rows x {s['cols']} columns, seed {SEED})", flush=True) + if a.no_reference: + continue + from tt_frnet.io import encode_array + from tt_frnet.reference.pipeline import FRNetReference + + ref = FRNetReference(a.weights_dir, variant, backend="ort", threads=a.threads) + run = ref.run(samples / f"{stem}.npz", calibration={"preset": stem}) + if run.prepared.skipped is not None: + sys.exit(f"{stem}: outside the {variant} profile: {run.prepared.skipped}") + out = run.output + body = {k: v for k, v in out.to_dict().items() if k in KEEP} + body["labels"] = encode_array(out.label_ids, fmt="npz_compressed", key="labels") + body["scores"] = encode_array(out.scores, fmt="npz_compressed", key="scores") + body["filter_keep"] = encode_array(out.filter_keep, fmt="npz_compressed", key="filter_keep") + inp = run.prepared.inputs + body["meta"] = {"variant": variant, "reference": "onnxruntime fp32 (deployed ONNX)", + "onnx_sha256": ref.weights.onnx_sha256, "num_unique_coors": inp.num_voxels, + "cropped_by_ego_box": int((~inp.keep_mask).sum()), + "generator": f"code/scripts/make_sample.py synthetic_scan({s['rows']} rows, {s['cols']} " + f"columns, seed {SEED})"} + path = samples / f"{stem}.{variant}.reference.json" + path.write_text(json.dumps(body) + "\n") + print(f"{stem}: N {inp.num_points} (raw {inp.num_points_raw}) M {inp.num_voxels} -> {path.name} " + f"({path.stat().st_size / 1e6:.2f} MB)", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/make_samples.py b/code/scripts/make_samples.py new file mode 100644 index 0000000000000000000000000000000000000000..f957c328173d4165478133454233e4efa168ea84 --- /dev/null +++ b/code/scripts/make_samples.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Build the STAGED PandaSet sample of frnet-p150 and its stored CPU-reference outputs (research venv: numpy, onnx, +onnxruntime; CPU only, no device). It does not ship until the user approves PandaSet-derived samples +(research/DATASETS.md N5): it goes to the git-ignored ``staging_samples_pandaset/`` of the bundle checkout (laid out +like the package: ``samples/``, ``calib/``), where the tests find it; the frozen ``sample`` gates and the PCC frame +still use it. The shipped samples are the synthetic scans of ``code/scripts/make_sample.py``. + + cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\ + /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python code/scripts/make_samples.py + +Writes (from research/frnet/public_data, PandaSet 019 frame 40, CC BY 4.0 + PandaSet Dataset Terms; D18), under +``staging_samples_pandaset/``: + +- ``samples/pandaset_019_40.npz``: ``points``, a structured (x, y, z, intensity) float32 array of the + sensor-frame Pandar64 sweep, byte-identical values to ``shippable/pandaset_019_40_pandar64_xyzi.npy`` (sha256 + checked against its meta); +- ``samples/PANDASET_LICENSE.txt``: the PandaSet Dataset Terms of Use (verbatim, sha256 checked); +- ``calib/pandaset_019_40.json``: the sensor -> base_link transform at full float64 precision (the + converted input's ``T_base_link_sensor``: its float32 cast is the research golden's crop transform bit for bit; + the 6-decimal copy in the sample meta is not); +- ``samples/pandaset_019_40..reference.json`` (ot128, qt128): the ``to_dict()`` of the + reference pipeline with ONNX Runtime on the deployed ONNX (the reference of record, S:frnet:361-365), reduced to + what the container smoke compares (``labels``, ``scores``, ``filter_keep``, npz_compressed) plus counts. The class + ids are checked against the research golden ``public_data/golden/pandaset_019_40_.npz`` (equal). +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve() +CODE = HERE.parents[1] +sys.path.insert(0, str(CODE)) + +from tt_frnet.io import encode_array # noqa: E402 +from tt_frnet.reference.config import VARIANTS # noqa: E402 + +WS = Path("/home/ubuntu/experiments/tt-models") +PUB = WS / "research" / "frnet" / "public_data" +STEM = "pandaset_019_40" +KEEP = ("model", "frame_id", "num_points", "num_points_raw", "class_names", "class_counts") + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--weights-dir", default=str(WS / "assets" / "frnet" / "hf_lidar_frnet")) + ap.add_argument("--threads", type=int, default=4) + ap.add_argument("--variants", nargs="*", default=list(VARIANTS)) + a = ap.parse_args() + staging = CODE.parent / "staging_samples_pandaset" + samples, calib = staging / "samples", staging / "calib" + samples.mkdir(parents=True, exist_ok=True) + calib.mkdir(parents=True, exist_ok=True) + meta = json.loads((PUB / "shippable" / f"{STEM}_meta.json").read_text()) + src = PUB / "shippable" / f"{STEM}_pandar64_xyzi.npy" + lic = PUB / "shippable" / "LICENSE.txt" + for path in (src, lic): + if sha256(path) != meta["files"][path.name]: + sys.exit(f"{path}: sha256 differs from {STEM}_meta.json") + xyzi = np.load(src, allow_pickle=False) + pts = np.zeros(len(xyzi), dtype=[("x", " base_link transform (fitted mount; " + "base_link = the lidar-pose origin projected to the road, x forward) for the ego crop box", + "source": "research/frnet/public_data/inputs/pandaset_019_40.npz T_base_link_sensor " + "(calib/pandaset_pandar64_mount.json fit)", + "T_base_link_from_sensor": [[float(v) for v in row] for row in T]} + (calib / f"{STEM}.json").write_text(json.dumps(preset, indent=1) + "\n") + print(f"sample {len(pts)} points, calibration preset {STEM}", flush=True) + + from tt_frnet.reference.pipeline import FRNetReference + + for variant in a.variants: + ref = FRNetReference(a.weights_dir, variant, backend="ort", threads=a.threads) + run = ref.run(samples / f"{STEM}.npz", + calibration={"T_base_link_from_sensor": preset["T_base_link_from_sensor"]}) + gold = np.load(PUB / "golden" / f"{STEM}_{variant}.npz", allow_pickle=False) + out = run.output + if not (np.array_equal(run.prepared.inputs.points, gold["autoware_points"]) and + np.array_equal(out.label_ids, gold["autoware_class_id"]) and + np.array_equal(out.filter_keep, gold["autoware_filter_keep"])): + sys.exit(f"{variant}: the reference output differs from the research golden") + body = {k: v for k, v in out.to_dict().items() if k in KEEP} + body["labels"] = encode_array(out.label_ids, fmt="npz_compressed", key="labels") + body["scores"] = encode_array(out.scores, fmt="npz_compressed", key="scores") + body["filter_keep"] = encode_array(out.filter_keep, fmt="npz_compressed", key="filter_keep") + body["meta"] = {"variant": variant, "reference": "onnxruntime fp32 (deployed ONNX)", + "onnx_sha256": ref.weights.onnx_sha256, "num_unique_coors": run.prepared.inputs.num_voxels, + "cropped_by_ego_box": int((~run.prepared.inputs.keep_mask).sum())} + path = samples / f"{STEM}.{variant}.reference.json" + path.write_text(json.dumps(body) + "\n") + print(f"{variant}: N {out.num_points if hasattr(out, 'num_points') else len(out)} " + f"M {run.prepared.inputs.num_voxels} -> {path.name} ({path.stat().st_size / 1e6:.2f} MB)", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/precision_study.py b/code/scripts/precision_study.py new file mode 100644 index 0000000000000000000000000000000000000000..9bd7ab814bf54885e6dea3dd57023d880325a8c3 --- /dev/null +++ b/code/scripts/precision_study.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""First-order CPU emulation of the TT numerics of FRNet, to size the fp32 islands of the port before the device runs +(research venv: torch + onnx; CPU only, no device). + + cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\ + /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python code/scripts/precision_study.py \\ + --out /home/ubuntu/experiments/tt-models/logs/frnet/m0/precision_study.json + +The TT dataflow (``reference/rewrites.py`` ``FRNetPlanTorch``) runs with operand rounding per site +(``Emulation``): ``bf16`` (RNE), ``tf32`` (truncation to 10 mantissa bits: probe P12's model of the device's fp32 +matmul, whose measured error is 1.4-1.75x that model's, so ``tf32`` rows are optimistic). Configurations: + +- ``bf16_all``: bf16 everywhere (weights and activations): the research simulation's worst case; +- ``plan``: the planned functional numerics -- conv activations bf16 with fp32 weights (PLAN.md 9.3 precision policy), + the point / voxel layers as an fp32 island (device fp32 matmul ~ tf32), bf16 rows out of the ``ttnn.embedding`` + gathers (bf16-only tables), fp32 interpolation matmuls (~ tf32); +- ``plan_bf16_points``: ``plan`` with bf16 activations and fp32 weights in the point layers (the fast path); +- ``plan_fp32_gather``: ``plan`` with exact fp32 gathers (what a fp32 row-gather kernel, K3, would buy); +- ``plan_fp32_convs`` / ``plan_exact_points``: ``plan`` with fp32 conv activations, resp. exact fp32 point layers + (e.g. two-term bf16 operands): which island to deepen first when a frame misses a gate. + +Metrics per frame against the stored ONNX Runtime ``pred_probs`` (the reference of record): PCC over all points and +argmax agreement on the raw points (the PLAN.md 2.8 gates: >= 0.998 and >= 98.5 %), and the module PCC of the device +modules' outputs against the exact fp32 dataflow (gate >= 0.999). Indicative only: no math-fidelity phases, no tile +layouts, no on-device accumulation order. +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve() +sys.path.insert(0, str(HERE.parents[1])) + +WS = Path("/home/ubuntu/experiments/tt-models") +CONFIGS = { + "bf16_all": "conv_act=bf16,conv_weight=bf16,point_act=bf16,point_weight=bf16,gather=bf16,upsample=bf16", + "plan": "conv_act=bf16,point_act=tf32,point_weight=tf32,gather=bf16,upsample=tf32", + "plan_bf16_points": "conv_act=bf16,point_act=bf16,gather=bf16,upsample=tf32", + "plan_fp32_gather": "conv_act=bf16,point_act=tf32,point_weight=tf32,upsample=tf32", + "plan_fp32_convs": "point_act=tf32,point_weight=tf32,gather=bf16,upsample=tf32", + "plan_exact_points": "conv_act=bf16,gather=bf16,upsample=tf32", + # M1b device follow-up (PORT_LOG "M1 device results"): the device's float32 conv-weight path is not exact + # (C17 / YOLOX: TF32-like, close to bf16 weights); the point island exact (two-term operands) + "exact_points_tf32_conv_w": "conv_act=bf16,conv_weight=tf32,gather=bf16,upsample=tf32", + "exact_points_bf16_conv_w": "conv_act=bf16,conv_weight=bf16,gather=bf16,upsample=tf32", +} +FRAMES = ["pandaset_019_40.ot128", "pandaset_029_40.ot128", "nuscenes_scene-0103_20.ot128", + "nuscenes_scene-0916_20.ot128", "vls128_frame000_fronthalf.ot128", "pandaset_019_40.qt128", + "vls128_frame000_fronthalf.qt128"] +MODULES = ("encoder.pf3", "encoder.voxel_feats", "backbone.stem", "backbone.point_stem", "backbone.fusion_stem", + "backbone.stage1", "backbone.stage2", "backbone.stage3", "backbone.stage4", "decoder.fuse2", + "decoder.point_fuse2", "head.logits") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--frames", nargs="*", default=FRAMES) + ap.add_argument("--configs", nargs="*", default=list(CONFIGS)[:4]) + ap.add_argument("--threads", type=int, default=4) + ap.add_argument("--weights-dir", default=str(WS / "assets" / "frnet" / "hf_lidar_frnet")) + a = ap.parse_args() + + import torch + + from tt_frnet.host.preprocess import build_plan + from tt_frnet.reference.config import FRNetConfig + from tt_frnet.reference.rewrites import Emulation, FRNetPlanTorch + from tt_frnet.reference.weights import load_weights + from tt_frnet.tests import _data + from tt_frnet.ttaw.golden import TapRegistry + from tt_frnet.ttaw.metrics import pcc + + torch.set_num_threads(a.threads) + weights, configs = {}, {} + result = {"configs": {k: CONFIGS[k] for k in a.configs}, "frames": {}, "note": __doc__.split("\n\n")[2]} + for frame in a.frames: + variant = frame.rpartition(".")[2] + if variant not in weights: + weights[variant] = load_weights(a.weights_dir, variant) + configs[variant] = FRNetConfig.from_model_dir(a.weights_dir, variant) + w = weights[variant] + inputs, ort_probs, _ = _data.load_frame(frame) + plan = build_plan(inputs, configs[variant], pre_norm=(w.pre_norm_scale, w.pre_norm_shift)) + n_raw = inputs.num_points_raw + ref_label = ort_probs.argmax(1) + t0 = time.time() + exact = TapRegistry(include=list(MODULES)) + FRNetPlanTorch(w)(plan, taps=exact) + rows = {} + for name in a.configs: + taps = TapRegistry(include=list(MODULES)) + probs = plan.unsort(FRNetPlanTorch(w, emulation=Emulation.parse(CONFIGS[name]))(plan, taps=taps)) + label = probs.argmax(1) + rows[name] = {"pred_probs_pcc": float(pcc(probs, ort_probs)), + "argmax_agreement_raw": float((label[:n_raw] == ref_label[:n_raw]).mean()), + "argmax_agreement_all": float((label == ref_label).mean()), + "modules": {m: float(pcc(taps[m][: plan.n] if taps[m].shape[0] == plan.n_cap else taps[m], + exact[m][: plan.n] if exact[m].shape[0] == plan.n_cap else exact[m])) + for m in MODULES}} + r = rows[name] + worst = min(r["modules"].items(), key=lambda kv: kv[1]) + print(f"{frame} {name}: pcc {r['pred_probs_pcc']:.6f} argmax(raw) {r['argmax_agreement_raw']:.4%} " + f"worst module {worst[0]} {worst[1]:.6f}", flush=True) + result["frames"][frame] = {"N": inputs.num_points, "N_raw": n_raw, "M": inputs.num_voxels, + "seconds": round(time.time() - t0, 1), "results": rows} + a.out.parent.mkdir(parents=True, exist_ok=True) + a.out.write_text(json.dumps(result, indent=1) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/profile_ops.py b/code/scripts/profile_ops.py new file mode 100644 index 0000000000000000000000000000000000000000..f281c179a1feab8612e6542ebcfd2af4b98f0630 --- /dev/null +++ b/code/scripts/profile_ops.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Device profile of frnet-p150: one eager forward and one traced replay of the whole graph between Tracy +signposts, for one serve profile (``--variant ot128 | qt128``). + + ROOT=/home/ubuntu/experiments/tt-models + $ROOT/bin/devrun -t 3600 -- python -m tracy -r -p -v --no-web-server --op-support-count 4000 \\ + -o $ROOT/generated/profiler/frnet_baseline code/scripts/profile_ops.py --variant ot128 + tt-perf-report --start-signpost trace --end-signpost trace_end --tracing-mode + python code/scripts/profile_summary.py profile_summary.json --md profile_summary.md + +Sections (signposts): ``eager`` .. ``eager_end``, one eager forward after the warm-up (``TraceRunner.run_eager``: +the same graph and programs as the trace, nothing compiled), and ``trace`` .. ``trace_end``, ``--replays`` replays +of the captured ``default`` trace (the served device path; the uploaded inputs are the plan of ``--input``). The +device profiler buffer is flushed (``ttnn.ReadDeviceProfiler``) before and after each section. Inside the eager +section every module of the graph opens a signpost (``m:``: ``encoder``, ``segmax``, ``frustum2pixel``, +``pixel2point``, ``stem``, ``point_stem``, ``fusion_stem``, ``layer``, ``point_fusion``, ``pixel_fusion``, +``attention``, ``decoder.up``, ``decoder.fuse1``, ``decoder.fuse2``, ``point_fuse``, ``head``) and every layer a +layer signpost (``c:``; ``--no-layer-signposts`` turns them off). Ops that the graph issues inline after a +module (the residual ``add`` + ``hardswish`` of a BasicBlock, ``addcmul`` after the attention, the softmax after the +logits) belong to the module signposted last. Through the identical op order the ops of the replay inherit the +eager attribution (``profile_summary.py``). The output folder must be absolute and outside the bundle (PLAN.md 5.1). +""" +from __future__ import annotations + +import argparse +import json +import os +import time +from pathlib import Path +from typing import Callable, List, Sequence + +from tt_frnet import FRNet +from tt_frnet.ttaw.profiling import read_device_profiler, signpost, signposted + +CODE = Path(__file__).resolve().parents[1] +SAMPLES = CODE / "tt_frnet" / "samples" # the shipped synthetic_.npz + + +class _Signposted: + """A callable stand-in for a module that emits its signposts, then calls the module (attributes forward).""" + + def __init__(self, inner, labels: Sequence[str]): + self.inner, self._labels = inner, list(labels) + + def __call__(self, *args, **kwargs): + for label in self._labels: + signpost(label) + return self.inner(*args, **kwargs) + + def __getattr__(self, name): + return getattr(self.inner, name) + + +def module_signposts(graph, layers: bool = True) -> Callable[[], None]: + """Wrap the modules of a ``tt.model.TtFRNet`` (and the C19 gathers its forward calls) with signposting + stand-ins; returns the function that restores them.""" + from tt_frnet.tt import model as tt_model + + undo: List[Callable[[], None]] = [] + + def labels(module: str, layer: str) -> List[str]: + return [f"m:{module}"] + ([f"c:{layer}"] if layers else []) + + def swap(obj, attr, lab): + inner = getattr(obj, attr) + setattr(obj, attr, _Signposted(inner, lab)) + undo.append(lambda: setattr(obj, attr, inner)) + + def swap_item(seq, key, lab): + inner = seq[key] + seq[key] = _Signposted(inner, lab) + undo.append(lambda: seq.__setitem__(key, inner)) + + for fn_name in ("frustum2pixel", "pixel2point"): + orig = getattr(tt_model, fn_name) + + def wrapped(*args, _orig=orig, _name=fn_name, **kwargs): + signpost(f"m:{_name}") + return _orig(*args, **kwargs) + + setattr(tt_model, fn_name, wrapped) + undo.append(lambda _n=fn_name, _o=orig: setattr(tt_model, _n, _o)) + for i in range(len(graph.ffe)): + swap_item(graph.ffe, i, labels("encoder", f"encoder.ffe{i}")) + swap(graph, "compression", labels("encoder", "encoder.compression")) + swap(graph, "segmax_encoder", labels("segmax", "segmax.encoder")) + swap(graph, "segmax_points", labels("segmax", "segmax.points")) + for i in range(len(graph.stem)): + swap_item(graph.stem, i, labels("stem", f"backbone.stem{i}")) + swap(graph, "point_stem", labels("point_stem", "backbone.point_stem")) + swap(graph, "fusion_stem", labels("fusion_stem", "backbone.fusion_stem")) + for st in graph.stages: + l = st["index"] + blocks = st["blocks"] + for b, (c1, c2, ds) in enumerate(blocks): + pre = f"backbone.layer{l}.block{b}" + new = (_Signposted(c1, labels(f"layer{l}", f"{pre}.conv1")), + _Signposted(c2, labels(f"layer{l}", f"{pre}.conv2")), + None if ds is None else _Signposted(ds, labels(f"layer{l}", f"{pre}.downsample"))) + blocks[b] = new + undo.append(lambda _bl=blocks, _b=b, _o=(c1, c2, ds): _bl.__setitem__(_b, _o)) + swap_item(st, "point_fusion", labels(f"point_fusion{l}", f"backbone.point_fusion{l}")) + swap_item(st, "pixel_fusion", labels(f"pixel_fusion{l}", f"backbone.pixel_fusion{l}")) + for j in range(len(st["attention"])): + swap_item(st["attention"], j, labels(f"attention{l}", f"backbone.attention{l}.conv{j}")) + for s in list(graph.ups): + swap_item(graph.ups, s, labels("decoder.up", f"decoder.up{s}")) + swap(graph, "fuse1", labels("decoder.fuse1", "decoder.fuse1")) + swap(graph, "fuse2", labels("decoder.fuse2", "decoder.fuse2")) + swap(graph, "point_fuse1", labels("point_fuse", "decoder.point_fuse1")) + swap(graph, "point_fuse2", labels("point_fuse", "decoder.point_fuse2")) + for i in range(len(graph.mlps)): + swap_item(graph.mlps, i, labels("head", f"head.mlp{i}")) + swap(graph, "logits", labels("head", "head.logits")) + + def restore() -> None: + for fn in reversed(undo): + fn() + + return restore + + +def calibration_for(path: Path, spec=None) -> dict: + """The ``calibration`` of ``path`` (the rules of ``bench.py calibration_for``).""" + if spec: + if spec == "none": + return {"ego_crop_box": None} + if spec.startswith("preset:"): + return {"preset": spec[len("preset:"):]} + return json.loads(Path(spec).read_text()) + stem = path.name.split(".")[0] + if (CODE / "tt_frnet" / "calib" / f"{stem}.json").is_file(): + return {"preset": stem} + side = path.parent / f"{stem}.calib.json" + return json.loads(side.read_text()) if side.is_file() else {"ego_crop_box": None} + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--input", default=None, help="default: the shipped synthetic sample of the variant") + ap.add_argument("--calib", default=None, help="preset: | | none (bench.py rules)") + ap.add_argument("--variant", default=None, help="ot128 | qt128 (default: FRNET_VARIANT or ot128)") + ap.add_argument("--dispatch", default=None, choices=["eth", "worker"]) + ap.add_argument("--cqs", type=int, default=None, choices=[1, 2]) + ap.add_argument("--replays", type=int, default=1, help="replays inside the trace section") + ap.add_argument("--no-eager", dest="eager", action="store_false") + ap.add_argument("--no-layer-signposts", dest="layers", action="store_false") + a = ap.parse_args() + import ttnn + + src = Path(a.input or SAMPLES / f"synthetic_{a.variant or os.environ.get('FRNET_VARIANT', 'ot128')}.npz") + calib = calibration_for(src, a.calib) + t0 = time.perf_counter() + with FRNet.from_pretrained(variant=a.variant, dispatch=a.dispatch, num_command_queues=a.cqs) as model: + tr, dev = model.runner, model.device + print("loaded in %.1f s:" % (time.perf_counter() - t0), json.dumps(model.device_info), flush=True) + prepared = model._prepare(points=str(src), calibration=calib) + out = model(str(src), calibration=calib) # one served frame: upload + replay + readback + print(f"variant {model.variant}: N {prepared.plan.n} M {prepared.plan.m} (N_cap {prepared.plan.n_cap}, " + f"M_cap {prepared.plan.m_cap}); {len(out.label_ids)} labelled points", flush=True) + ttnn.synchronize_device(dev) + read_device_profiler(dev) # warm-up / capture / first frame out of the buffer + inputs = model.staging.write(prepared.plan) + if a.eager: + restore = module_signposts(model.graph, layers=a.layers) + try: + with signposted("eager"): + tr.run_eager("default", inputs=inputs) + ttnn.synchronize_device(dev) + finally: + restore() + read_device_profiler(dev) + tr.upload(inputs) + ttnn.synchronize_device(dev) + read_device_profiler(dev) + with signposted("trace"): + tr.replay("default", n=a.replays) + ttnn.synchronize_device(dev) + read_device_profiler(dev) + print("trace:", json.dumps(tr.describe()), flush=True) + + +if __name__ == "__main__": + main() diff --git a/code/scripts/profile_summary.py b/code/scripts/profile_summary.py new file mode 100644 index 0000000000000000000000000000000000000000..82ef293aaaa25622882c98da47004d1ad8718bec --- /dev/null +++ b/code/scripts/profile_summary.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Summary of a device profile of ``profile_ops.py`` (CPU only, standard library): the OPT_BASELINE.md tables. + + python code/scripts/profile_summary.py [--md out.md] + +Reads the Tracy ops CSV (``python -m tracy -r ...``) and its sections ``eager`` .. ``eager_end`` and ``trace`` .. +``trace_end``. The eager section carries per-module (``m:``) and per-conv (``c:``) signposts; the +replay has the same op sequence (checked op code by op code), so every replayed op inherits the module / layer of +the eager op at the same position. Per section: program count, device kernel sum, FW sum, op-to-op gap sum and +median, span (first FW start -> last FW end at the CSV's chip frequency), cores per op, math fidelities; tables per +category, module, op code and layer; the top single ops; the largest gaps. If the trace section holds several +replays (``profile_ops.py --replays N``), the last replay session is summarised. Input shapes of the CSV are those of +the first op that built a cached program (``--no-op-info-cache`` in tracy shows every op's own), so read shapes of +later ops of the same program with care; durations are per op. +""" +from __future__ import annotations + +import argparse +import csv +import json +import re +import statistics +from collections import OrderedDict, defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +KERNEL = "DEVICE KERNEL DURATION [ns]" +FW = "DEVICE FW DURATION [ns]" +GAP = "OP TO OP LATENCY [ns]" +START = "DEVICE FW START CYCLE" +END = "DEVICE FW END CYCLE" + +CATEGORIES = [ # (category, regex on the op code), first match wins + ("segment_reduce (K1 generic_op)", r"GenericOp|generic_op|Generic"), + ("conv2d", r"Conv2d|conv2d|Conv2D"), + ("halo", r"Halo|halo"), + ("matmul (point linears, 1x1 convs, resize matmuls)", r"Matmul|matmul|Linear"), + ("slice / DRAM-slicing copies", r"Slice|slice"), + ("ternary (addcmul)", r"Ternary|ternary|Addcmul|addcmul"), + ("binary (adds, two-term / K-split sums)", r"Binary|binary"), + ("softmax", r"Softmax|softmax"), + ("reduction", r"Reduce|reduce|Max\b|MaxDevice"), + ("embedding (gathers)", r"Embedding|embedding"), + ("tilize / untilize / layout", r"Tilize|tilize|Untilize|untilize|ToLayout|Layout"), + ("reshape / transpose / permute / concat", r"Reshape|reshape|Transpose|transpose|Permute|permute|View|Concat|concat"), + ("move / copy / reshard", r"Move|move|Copy|copy|Shard|shard|Interleaved|interleaved|Reallocate"), + ("pad / fill", r"Pad|pad|Fill|fill"), + ("unary / typecast (hardswish, relu, sigmoid)", r"Unary|unary|Typecast|typecast"), +] +COMPUTE = ("Conv2dDeviceOperation", "MatmulDeviceOperation") + + +def category(op: str) -> str: + for name, pat in CATEGORIES: + if re.search(pat, op): + return name + return "other" + + +def num(row: Dict[str, str], key: str) -> float: + v = (row.get(key) or "").strip() + try: + return float(v) if v else 0.0 + except ValueError: + return 0.0 + + +def logical(v: str) -> str: + """'32[16]' (padded[logical]) -> '16'; '7' -> '7'.""" + m = re.match(r"\s*([0-9]+)(?:\[([0-9]+)\])?", v or "") + return (m.group(2) or m.group(1)) if m else "?" + + +def chip_freq_mhz(csv_path: Path) -> Optional[float]: + for cand in [csv_path.parent / "profile_log_device.csv", csv_path.parent.parent / "profile_log_device.csv", + csv_path.parent / ".logs" / "profile_log_device.csv", + csv_path.parent.parent / ".logs" / "profile_log_device.csv"]: + try: + head = cand.read_text(errors="ignore").splitlines()[0] + except (OSError, IndexError): + continue + m = re.search(r"CHIP_FREQ\[MHz\]:\s*([0-9.]+)", head) + if m: + return float(m.group(1)) + return None + + +def sections(rows: List[Dict[str, str]], name_key: str) -> Dict[str, List[Tuple[Dict[str, str], str, str]]]: + """{"eager": [(row, module, layer)], "trace": [...]} from the signposts.""" + out: Dict[str, List[Tuple[Dict[str, str], str, str]]] = {"eager": [], "trace": []} + cur: Optional[str] = None + module, layer = "", "" + for r in rows: + if (r.get("OP TYPE") or "") == "signpost": + code = r.get(name_key, "") + if code in ("eager", "trace"): + cur, module, layer = code, "", "" + elif code in ("eager_end", "trace_end"): + cur = None + elif code.startswith("m:"): + module, layer = code[2:], "" + elif code.startswith("c:"): + layer = code[2:] + continue + if cur is not None: + out[cur].append((r, module, layer)) + return out + + +def last_replay(sec: List[Tuple[Dict[str, str], str, str]]) -> List[Tuple[Dict[str, str], str, str]]: + """The ops of the last trace replay session (all of them when the CSV has no session ids).""" + ids = [(r.get("METAL TRACE REPLAY SESSION ID") or "").strip() for r, _, _ in sec] + if not any(ids): + return sec + last = [i for i in ids if i][-1] + return [x for x, i in zip(sec, ids) if i == last] + + +def summarize(sec: List[Tuple[Dict[str, str], str, str]], name_key: str, freq: float, top: int = 10) -> Dict[str, Any]: + rows = [r for r, _, _ in sec] + kern = [num(r, KERNEL) / 1e3 for r in rows] + fw = [num(r, FW) / 1e3 for r in rows] + gaps = [num(r, GAP) / 1e3 for r in rows] + if gaps: + gaps[0] = 0.0 # the first op's gap reaches back across the section boundary + starts = [num(r, START) for r in rows if num(r, START) > 0] + ends = [num(r, END) for r in rows if num(r, END) > 0] + span = (max(ends) - min(starts)) / freq if starts and ends else None + total = sum(kern) or 1.0 + + def group(keyfn) -> List[Dict[str, Any]]: + agg: Dict[str, List[float]] = OrderedDict() + for (r, m, l), k, g in zip(sec, kern, gaps): + key = keyfn(r, m, l) + a = agg.setdefault(key, [0, 0.0, 0.0, 0.0]) + a[0] += 1 + a[1] += k + a[2] += g + if r.get(name_key, "") in COMPUTE: + a[3] += k + return [{"key": key, "ops": int(c), "kernel_us": round(t, 1), "share": round(t / total, 4), + "gap_us": round(gs, 1), "compute_us": round(cu, 1)} + for key, (c, t, gs, cu) in sorted(agg.items(), key=lambda kv: -kv[1][1])] + + fid: Dict[str, int] = defaultdict(int) + cores = [] + for r in rows: + f = (r.get("MATH FIDELITY") or "").strip() + if f: + fid[f] += 1 + cores.append(num(r, "CORE COUNT")) + order = sorted(range(len(rows)), key=lambda i: -kern[i])[:top] + top_ops = [] + for i in order: + r, m, l = sec[i] + shape = "x".join(logical(r.get(f"INPUT_0_{d}_PAD[LOGICAL]") or r.get(f"INPUT_0_{d}") or "") for d in "WZYX") + oshape = "x".join(logical(r.get(f"OUTPUT_0_{d}_PAD[LOGICAL]") or r.get(f"OUTPUT_0_{d}") or "") for d in "WZYX") + top_ops.append({"index": i, "op": r.get(name_key), "module": m, "layer": l, "kernel_us": round(kern[i], 1), + "fw_us": round(fw[i], 1), "cores": int(num(r, "CORE COUNT")), + "fidelity": (r.get("MATH FIDELITY") or "").strip(), "input0": shape, "output0": oshape, + "in0_dtype": (r.get("INPUT_0_DATATYPE") or "").strip(), + "in0_mem": (r.get("INPUT_0_MEMORY") or "").strip(), + "out0_mem": (r.get("OUTPUT_0_MEMORY") or "").strip()}) + gap_order = sorted(range(1, len(rows)), key=lambda i: -gaps[i])[:top] + top_gaps = [{"index": i, "op": rows[i].get(name_key), "after": rows[i - 1].get(name_key), + "gap_us": round(gaps[i], 1), "module": sec[i][1], "layer": sec[i][2]} for i in gap_order] + nz_gaps = [g for g in gaps[1:] if g > 0] + ops_list = [{"index": i, "op": r.get(name_key), "module": m, "layer": l, "kernel_us": round(k, 2), + "gap_us": round(g, 2), "cores": int(num(r, "CORE COUNT")), + "fidelity": (r.get("MATH FIDELITY") or "").strip(), + "input0": "x".join(logical(r.get(f"INPUT_0_{d}_PAD[LOGICAL]") or r.get(f"INPUT_0_{d}") or "") + for d in "WZYX"), + "in0_mem": (r.get("INPUT_0_MEMORY") or "").strip(), + "out0_mem": (r.get("OUTPUT_0_MEMORY") or "").strip()} + for i, ((r, m, l), k, g) in enumerate(zip(sec, kern, gaps))] + return { + "ops": len(rows), "kernel_sum_us": round(sum(kern), 1), "fw_sum_us": round(sum(fw), 1), + "op2op_sum_us": round(sum(gaps), 1), + "op2op_median_us": round(statistics.median(nz_gaps), 2) if nz_gaps else None, + "span_us": round(span, 1) if span else None, "freq_mhz": freq, + "cores_median": statistics.median(cores) if cores else None, + "ops_below_120_cores": sum(1 for c in cores if 0 < c < 120), + "compute_us": round(sum(k for r, k in zip(rows, kern) if r.get(name_key, "") in COMPUTE), 1), + "fidelity": dict(fid), + "by_op": group(lambda r, m, l: r.get(name_key, "?")), + "by_category": group(lambda r, m, l: category(r.get(name_key, "?"))), + "by_module": group(lambda r, m, l: m or "?"), + "by_layer": group(lambda r, m, l: l or f"[{m or '?'}]"), + "top_ops": top_ops, "top_gaps": top_gaps, "ops_list": ops_list, + } + + +def md_table(rows: List[Dict[str, Any]], n: int, label: str) -> str: + out = [f"| {label} | ops | kernel us | share | compute us | op-to-op us |", "|---|---:|---:|---:|---:|---:|"] + for r in rows[:n]: + out.append(f"| {r['key']} | {r['ops']} | {r['kernel_us']:.1f} | {100 * r['share']:.1f} % | " + f"{r['compute_us']:.1f} | {r['gap_us']:.1f} |") + return "\n".join(out) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("csv") + ap.add_argument("out_json") + ap.add_argument("--md") + ap.add_argument("--freq-mhz", type=float, default=None) + a = ap.parse_args() + path = Path(a.csv) + with open(path, newline="") as f: + rows = list(csv.DictReader(f)) + name_key = "OP CODE" if rows and "OP CODE" in rows[0] else "OP NAME" + freq = a.freq_mhz or chip_freq_mhz(path) or 1350.0 + secs = sections(rows, name_key) + eager, trace_all = secs["eager"], secs["trace"] + trace = last_replay(trace_all) + # the replay inherits the module / layer of the eager op at the same position (identical op sequence) + same = len(eager) == len(trace) and all(e[0].get(name_key) == t[0].get(name_key) for e, t in zip(eager, trace)) + if same: + trace = [(r, e[1], e[2]) for (r, _, _), e in zip(trace, eager)] + res = {"csv": str(path), "columns": list(rows[0].keys()) if rows else [], "freq_mhz": freq, + "trace_matches_eager_op_sequence": same, "trace_section_ops": len(trace_all), + "trace_ids": sorted({(r.get("METAL TRACE ID") or "").strip() for r, _, _ in trace_all}), + "replay_sessions": sorted({(r.get("METAL TRACE REPLAY SESSION ID") or "").strip() for r, _, _ in trace_all}), + "eager": summarize(eager, name_key, freq), "trace": summarize(trace, name_key, freq)} + Path(a.out_json).write_text(json.dumps(res, indent=1) + "\n") + t = res["trace"] + lines = [f"csv: {path}", f"freq {freq} MHz; trace op sequence == eager: {same}; trace ids {res['trace_ids']}; " + f"replay sessions {res['replay_sessions']} ({len(trace_all)} ops in the trace section)"] + for s in ("eager", "trace"): + x = res[s] + lines.append(f"{s}: ops {x['ops']} kernel {x['kernel_sum_us']:.1f} us (conv/matmul {x['compute_us']:.1f}) " + f"fw {x['fw_sum_us']:.1f} us op2op {x['op2op_sum_us']:.1f} us (median {x['op2op_median_us']}) " + f"span {x['span_us']} us cores median {x['cores_median']} <120 cores: " + f"{x['ops_below_120_cores']} fidelity {x['fidelity']}") + lines += ["", md_table(t["by_category"], 20, "category (trace)"), "", md_table(t["by_module"], 20, "module (trace)"), + "", md_table(t["by_op"], 25, "op code (trace)"), "", md_table(t["by_layer"], 40, "layer (trace)"), "", + "| # | op | module | layer | kernel us | cores | fidelity | input0 | output0 | in mem | out mem |", + "|---:|---|---|---|---:|---:|---|---|---|---|---|"] + for k, o in enumerate(t["top_ops"], 1): + lines.append(f"| {k} | {o['op']} | {o['module']} | {o['layer']} | {o['kernel_us']:.1f} | {o['cores']} | " + f"{o['fidelity']} | {o['input0']} | {o['output0']} | {o['in0_mem']} | {o['out0_mem']} |") + lines += ["", "largest op-to-op gaps (trace): " + json.dumps(t["top_gaps"])] + text = "\n".join(lines) + print(text) + if a.md: + Path(a.md).write_text(text + "\n") + + +if __name__ == "__main__": + main() diff --git a/code/scripts/visualizer_report.py b/code/scripts/visualizer_report.py new file mode 100644 index 0000000000000000000000000000000000000000..d29ca87ce4305fbecce7a8f1d43fd2b65d2ee11b --- /dev/null +++ b/code/scripts/visualizer_report.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""ttnn-visualizer memory + graph report of ONE eager forward of the served graph (default precision policy and +knobs, ETH dispatch, the device grid). Never use it for timing: graph capture and detailed buffer tracing +synchronise after every op. + + ROOT=/home/ubuntu/experiments/tt-models; cd $ROOT/bundles/frnet-p150 + $ROOT/bin/devrun -t 2400 -- env PYTHONPATH=$PWD/code:$PYTHONPATH HF_HUB_OFFLINE=1 \\ + python code/scripts/visualizer_report.py $ROOT/generated/ttnn_visualizer/frnet_baseline [--variant ot128] + ttnn-visualizer --profiler-path $ROOT/generated/ttnn_visualizer/frnet_baseline \\ + --performance-path $ROOT/generated/profiler/frnet_baseline/reports/ + +What the pytest fixture of ttnn does (ttnn/graph_report.py ``_run_pytest_graph_report_fixture``), in a script: +warm-up forward (JIT, weight upload), then the second forward inside ``ttnn.graph.full_graph_capture`` (Python I/O, +stack traces, detailed buffer tracing), ``ttnn.graph_report.import_report`` -> ``db.sqlite``, and ``config.json`` +(the settings the capture ran with). The intermediate graph JSON is deleted after the import. The inputs are the +device plan of ``--input`` (default: the shipped synthetic sample of the variant with its calibration preset). +""" +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path + +CODE = Path(__file__).resolve().parents[1] +SAMPLES = CODE / "tt_frnet" / "samples" # the shipped synthetic_.npz + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("out_dir") + ap.add_argument("--variant", default="ot128") + ap.add_argument("--input", default=None, help="default: the shipped synthetic sample of the variant") + ap.add_argument("--calib", default=None, help="preset: | | none (default: the preset " + "named like the input, else crop disabled)") + ap.add_argument("--json-dir", default=None, help="where the intermediate graph JSON goes (default: out_dir; " + "it can be several GB, e.g. /dev/shm/); deleted after the import") + a = ap.parse_args() + out_dir = Path(a.out_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + import ttnn + + from tt_frnet.api import FRNet + from tt_frnet.device import close_device, describe_device, open_device + from tt_frnet.host.frame import FRNetPreprocessor + from tt_frnet.reference.config import FRNetConfig + from tt_frnet.reference.weights import load_weights + from tt_frnet.tt.layers import free + from tt_frnet.tt.model import TtFRNet, default_policy, frame_arrays + from tt_frnet.ttaw.tensors import to_host_tensor + + src = Path(a.input or SAMPLES / f"synthetic_{a.variant}.npz") + if a.calib == "none": + calib = {"ego_crop_box": None} + elif a.calib and a.calib.startswith("preset:"): + calib = {"preset": a.calib[len("preset:"):]} + elif a.calib: + calib = json.loads(Path(a.calib).read_text()) + else: + stem = src.name.split(".")[0] + calib = {"preset": stem} if (CODE / "tt_frnet" / "calib" / f"{stem}.json").is_file() else {"ego_crop_box": None} + weights_path = FRNet.resolve_weights() + config = FRNetConfig.from_model_dir(weights_path, a.variant) + weights = load_weights(weights_path, a.variant) + pre = FRNetPreprocessor(config, pre_norm=(weights.pre_norm_scale, weights.pre_norm_shift)) + prepared = pre(str(src), calib) + if prepared.plan is None: + raise SystemExit(f"the frame is skipped: {prepared.skipped}") + dev = open_device(allow_fallback=False) + try: + print("device:", json.dumps(describe_device(dev, 0)), flush=True) + graph = TtFRNet(dev, weights, config, policy=default_policy()) + arrays = frame_arrays(prepared.plan, graph.m_cap) + inp = {} + for key, (shape, dtype) in graph.input_specs().items(): + tdt = ttnn.float32 if dtype == "float32" else ttnn.uint32 + t = ttnn.allocate_tensor_on_device(ttnn.Shape(list(shape)), tdt, ttnn.ROW_MAJOR_LAYOUT, dev, + ttnn.DRAM_MEMORY_CONFIG) + ttnn.copy_host_to_device_tensor(to_host_tensor(arrays[key].reshape(shape), dtype, ttnn.ROW_MAJOR_LAYOUT), t) + inp[key] = t + + def forward(): + out = graph.forward(inp) + ttnn.synchronize_device(dev) + free(out) + + t0 = time.perf_counter() + forward() # warm-up: JIT + weight upload (host writes) + print(f"warm-up forward {time.perf_counter() - t0:.1f} s", flush=True) + json_dir = Path(a.json_dir).resolve() if a.json_dir else out_dir + json_dir.mkdir(parents=True, exist_ok=True) + json_path = json_dir / "graph_capture.json" + t0 = time.perf_counter() + with ttnn.graph.full_graph_capture(str(json_path)): + forward() + print(f"captured forward {time.perf_counter() - t0:.1f} s -> {json_path} " + f"({json_path.stat().st_size / 1e6:.1f} MB)", flush=True) + free(*inp.values()) + graph.release() + finally: + close_device(dev) + t0 = time.perf_counter() + db = ttnn.graph_report.import_report(json_path, out_dir) + ttnn.save_config_to_json_file(out_dir / "config.json") + cfg = json.loads((out_dir / "config.json").read_text()) # record what the capture ran with + cfg.update({"enable_fast_runtime_mode": False, "enable_graph_report": True, "enable_graph_python_stack_traces": True, + "enable_detailed_buffer_report": True, "root_report_path": str(out_dir.parent), + "report_name": out_dir.name}) + (out_dir / "config.json").write_text(json.dumps(cfg, indent=4) + "\n") + print(f"imported in {time.perf_counter() - t0:.1f} s -> {db} ({Path(db).stat().st_size / 1e6:.1f} MB)", flush=True) + json_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/code/tt_frnet/__init__.py b/code/tt_frnet/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4d4170ddcbc1ce4c3f083e68583430054daafd15 --- /dev/null +++ b/code/tt_frnet/__init__.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: Apache-2.0 +"""FRNet (Autoware lidar_frnet) on a Tenstorrent Blackhole p150 (tt-nn), packaged as changh95/frnet-p150. + +Python API (see PYTHON.md):: + + from tt_frnet import FRNet + + with FRNet.from_pretrained(device_id=0) as model: + out = model("code/tt_frnet/samples/synthetic_ot128.npz", calibration={"preset": "synthetic_ot128"}) + +Importing the package has no side effects (no device, no ttnn / torch import, no network); the names below load +on first use. The HTTP server is ``tt_frnet.server.app:app`` (SERVING.md). ``tt_frnet.ttaw`` is the +vendored shared package of the Autoware ports (``ttaw/VENDORED.json`` records its version and file hashes; never +edit it here). +""" + +__version__ = "0.1.0" +__all__ = ["FRNet", "Output", "open_device", "load_points", "load_image", "__version__"] + +_LAZY = { + "FRNet": (".api", "FRNet"), + "Output": (".api", "Output"), + "open_device": (".device", "open_device"), + "load_points": (".io", "load_points"), + "load_image": (".io", "load_image"), +} + + +def __getattr__(name): + if name in _LAZY: + import importlib + + module, attr = _LAZY[name] + return getattr(importlib.import_module(module, __name__), attr) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(list(globals()) + __all__) diff --git a/code/tt_frnet/api.py b/code/tt_frnet/api.py new file mode 100644 index 0000000000000000000000000000000000000000..e7147e2d74a21dfec7279f41061e7c085e8d0dd0 --- /dev/null +++ b/code/tt_frnet/api.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Python API: FRNet (Autoware lidar_frnet) on one Tenstorrent Blackhole p150. + + from tt_frnet import FRNet + + with FRNet.from_pretrained(device_id=0) as model: # weights -> HF cache, device open, traces captured + out = model("code/tt_frnet/samples/synthetic_ot128.npz", calibration={"preset": "synthetic_ot128"}) + print(out.to_dict()) # the same JSON as POST /predict + +The contract shared by every bundle of the Autoware collection is the vendored ``ttaw.api_base.ModelBase`` +(BUNDLE_CONVENTIONS.md section 8): ``from_pretrained`` resolves the pinned weights before it claims the chip, opens it +(ETH dispatch, 12x10), builds the graph and captures every trace variant in ``warmup_variants``, so the first call is +as fast as the later ones; calls are serialised by a lock (one chip, batch 1) and fill ``timing_ms``; ``close()`` is +idempotent, also runs at interpreter exit, and closes the chip only if the model opened it. The HTTP server +(``tt_frnet.server.app``) calls this class, so ``/predict`` and ``model(...)`` agree bit for bit. + +This module holds the identity of the port and its hooks: host pre- and post-processing ported from Autoware live in +``tt_frnet.host`` (shared with the CPU reference ``tt_frnet.reference``, so device and reference see identical +network inputs), the ttnn graph in ``tt_frnet.tt`` (run by a ``ttaw.trace.TraceRunner``; milestones M1-M3 of +PORT_LOG.md). Importing it has no side effects: ttnn / torch are imported inside the hooks. + +Inputs (``host/frame.py`` has the full contract): ``points`` -- ONE sweep in the **sensor frame**, fields x, y, z, +intensity (raw 0-255); ``calibration`` -- ``{"T_base_link_from_sensor": ...}`` (or ``{"preset": name}`` of +``calib/``) for the node's ego crop box, ``ego_crop_box`` to override or disable the box (``None``). +``variant``: ``ot128`` (Autoware's default sensor model) or ``qt128``, a load-time choice (weights + grid). + +Output: :class:`FRNetSegmentation` (a ``ttaw.outputs.Segmentation3D``) in the node's output order: the kept raw +points (input order) then Autoware's interpolated points; ``label_ids`` = class_id, ``scores`` = probability, +``points``, ``filter_keep`` (the filtered cloud), ``input_index``. A frame outside the node's profiles gives an empty +output whose ``meta["skipped"]`` is the node's message (the node publishes nothing). +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from . import io as tio +from .device import DEVICE_DEFAULTS +from .host.postprocess import RUNTIME_PARAMS, FRNetSegmentation +from .reference.config import DEFAULT_VARIANT, VARIANTS +from .ttaw.api_base import ModelBase + +__all__ = ["FRNet", "Output", "FRNetSegmentation", "LABELS"] + +# The result class of this model (ttaw.outputs: Detections3D, Detections2D, Segmentation3D, Mask2D, Trajectory). +Output = FRNetSegmentation + +# ml_package_frnet_{ot128,qt128}.param.yaml class_names (identical in both variants; _build_host checks the yaml). +# Indices 22 / 23 read "debris" / "stroller" here, but the weights were trained with stroller = 22, debris = 23 +# (AWML frnet_1xb8_t4dataset-ot128-seg.py:60-61; S:frnet:314-317): Autoware's names are kept for parity. +LABELS = ("drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "bus", + "emergency_vehicle", "train", "truck", "tractor_unit", "semi_trailer", "construction_vehicle", "forklift", + "kart", "motorcycle", "bicycle", "pedestrian", "personal_mobility", "animal", "pushable_pullable", + "traffic_cone", "debris", "stroller", "other_stuff", "noise+ghost_point", "unknown") + + +def _given(value: Any) -> bool: + """A real input (``None`` and empty containers are 'not given'); never the truth value of an array.""" + if value is None: + return False + if isinstance(value, (list, tuple, dict, str)): + return len(value) > 0 + return True + + +class FRNet(ModelBase): + """FRNet (Autoware lidar_frnet) on one Blackhole p150. Create it with :meth:`from_pretrained`.""" + + MODEL_NAME = "frnet-p150" + ENV_PREFIX = "FRNET" # prefix of the environment knobs (SERVING.md section 3.4) + DEFAULT_REPO = "AutowareFoundation/lidar_frnet" + DEFAULT_TAG = "v2.0" # the Autoware ansible artifacts pin + # the commit DEFAULT_TAG points to (pinned: tags can move) + DEFAULT_REVISION = "a53a1c11b0b28d31fd50a03abef5db0e830a5b3c" + ALLOW_PATTERNS = ["frnet_*.onnx", "ml_package_frnet_*.param.yaml", "deploy_metadata.yaml"] + VARIANTS = list(VARIANTS) # load-time: selects the ONNX, the frustum grid and the trace shapes + DEFAULT_VARIANT = DEFAULT_VARIANT + INPUT_KIND = "lidar" # lidar | camera | multicam | lidar+multicam | planner + CAMERA_ORDER = () # multicam models: the fixed camera order of the network + POINT_FIELDS = tio.DEFAULT_POINT_FIELDS # columns the network consumes, in order (x, y, z, intensity) + LABELS = list(LABELS) + # Per-request knobs (host post-processing only; defaults are Autoware's): host/postprocess.py + RUNTIME_PARAMS = RUNTIME_PARAMS + EXTRA_INPUTS = () + DEVICE_DEFAULTS = DEVICE_DEFAULTS # validated open parameters (device.py) + + # ---- port-specific hooks (called by ModelBase; keep host work out of _forward) --------------------------- + def _build(self) -> None: + """Host side (config, weights, pre- / post-processors), then the ttnn graph of ``tt_frnet.tt`` + (``TtFRNet``: point encoder, K1 segmented max, gathers, convs, decoder, head) as the ``"default"`` variant of + a ``ttaw.trace.TraceRunner`` with the per-frame tables as persistent inputs (no capture here; ``_warm_one`` + captures). Warm-up runs an empty frame (every index valid).""" + from .tt.model import FrameStaging, TtFRNet, build_runner, default_policy + + self._build_host() + self.graph = TtFRNet(self.device, self.frnet_weights, self.config, policy=default_policy()) + self.runner = build_runner(self.graph, variants=("default",), name=f"{self.MODEL_NAME}/{self.variant}") + self.staging = FrameStaging(self.graph) # persistent host buffers of the inputs (written in place) + + def _build_host(self) -> None: + """The host half of ``_build`` (no device): usable alone by host tests and tools.""" + from .host.frame import FRNetPreprocessor + from .host.postprocess import FRNetPostprocessor + from .reference.config import FRNetConfig + from .reference.weights import check_variant_grid, load_weights + + self.config = FRNetConfig.from_model_dir(self.weights_path, self.variant) + if tuple(self.config.class_names) != tuple(self.LABELS): + raise ValueError(f"ml_package class_names differ from FRNet.LABELS: {self.config.class_names}") + self.frnet_weights = load_weights(self.weights_path, self.variant) + check_variant_grid(self.frnet_weights, self.config.frustum_h, self.config.frustum_w) + self.pre = FRNetPreprocessor(self.config, pre_norm=(self.frnet_weights.pre_norm_scale, + self.frnet_weights.pre_norm_shift)) + self.post = FRNetPostprocessor(self.config) + + def _warm_one(self, variant: Dict[str, Any]) -> None: + """Warm up and capture the traces of one entry of ``default_warmup_variants()``. ``TraceRunner.capture`` + runs every pending variant eagerly (kernel JIT, program cache) before any capture, then captures with + program-cache misses forbidden; it is idempotent.""" + self.runner.capture() + + def _prepare(self, points: Any = None, *, sweeps: Optional[Any] = None, images: Any = None, + calibration: Any = None, stream: Any = None, inputs: Any = None, **_: Any): + """Host pre-processing (``host/frame.py``): decoding, the ego crop, float32 projection, fp16 interpolation, + unique cells, the node's profile checks and the device plan. Client mistakes raise ``tio.InputError`` + (HTTP 400); an out-of-profile frame is ``prepared.skipped`` (no device run, empty output).""" + if points is None: + raise tio.InputError("this model needs 'points'") + extra = [name for name, value in (("sweeps", sweeps), ("images", images), ("inputs", inputs), + ("stream", stream)) if _given(value)] + if extra: + raise tio.InputError(f"FRNet takes one sensor-frame sweep ('points') and its 'calibration' only; got " + f"{extra}") + return self.pre(points, calibration) + + def _forward(self, prepared: Any) -> Any: + """Upload the frame plan (features, CSR offsets, gather tables) into the persistent device inputs, replay + the trace, read ``pred_probs`` back (one D2H) and return it as ``(N, 27)`` float32 in the network-input order + (``plan.unsort``). A skipped frame runs nothing. Runs under the model lock.""" + if prepared.skipped is not None: + return None + return self.device_probs(prepared.plan) + + def device_probs(self, plan: Any) -> Any: + """``pred_probs`` ``(N, 27)`` float32 (network-input order) of one ``host.preprocess.FramePlan`` (the device + half of ``_forward``; tests and tools feed stored network inputs through it). Call it under the model + lock (``model(...)`` does).""" + import numpy as np + + raw = self.runner("default", inputs=self.staging.write(plan)) + probs = np.asarray(raw, np.float32).reshape(-1, raw.shape[-1]) + return plan.unsort(probs[:, : self.config.num_classes]) + + def _postprocess(self, raw: Any, prepared: Any, params: Dict[str, Any]) -> Output: + """Host post-processing (``host/postprocess.py``, ``fill_cloud_kernel``): first-max argmax, probability, + the class-probability filter -> ``FRNetSegmentation``; ``raw`` = ``pred_probs`` or None (skipped).""" + meta = {"variant": self.variant, **prepared.stats()} + self.post.filter_indices(params["filter_classes"]) # an unknown class name is a 400 either way + if raw is None: + return self.post.empty(model=self.MODEL_NAME, frame_id=prepared.frame_id, meta=meta) + inp = prepared.inputs + return self.post(raw, points=inp.points, num_points_raw=inp.num_points_raw, + input_index=prepared.input_index, params=params, model=self.MODEL_NAME, + frame_id=prepared.frame_id, meta=meta) + + def _release(self) -> None: + """Release the traces, persistent tensors and device weights; also called when ``from_pretrained`` fails + half-way (tolerates a partial build).""" + runner = getattr(self, "runner", None) + if runner is not None: + runner.release() + self.runner = None + graph = getattr(self, "graph", None) + if graph is not None: + graph.release() + self.graph = None + + def extra_info(self) -> Dict[str, Any]: + """Additions to ``model.info`` and ``/info``: the node parameters and the runner's traces.""" + out: Dict[str, Any] = {} + config = getattr(self, "config", None) + if config is not None: + out["frnet"] = config.describe() + runner = getattr(self, "runner", None) + if runner is not None: + out["trace"] = runner.describe() + graph = getattr(self, "graph", None) + if graph is not None: + out["graph"] = graph.describe() + return out diff --git a/code/tt_frnet/calib/README.md b/code/tt_frnet/calib/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f52f80ce83ad9f85055e649e3c5722ce7457d891 --- /dev/null +++ b/code/tt_frnet/calib/README.md @@ -0,0 +1,18 @@ +# Calibration presets + +FRNet takes one LiDAR sweep in the **sensor frame**; the only calibration it uses is the static +`sensor -> base_link` transform of the node's ego crop box (Autoware looks it up once from TF, +`lidar_frnet_node.cpp:154-182`). A preset file holds `{"name", "description", "source", "T_base_link_from_sensor": +4x4 row-major}` (any `ttaw.io.parse_transform` spelling works, also `{translation, rotation_wxyz}` or +`{x, y, z, roll, pitch, yaw}`), optionally `"ego_crop_box": [min_x, min_y, min_z, max_x, max_y, max_z]` to replace +Autoware's default box (`frnet.param.yaml`: -1.53, -1.15, -0.1, 6.1, 1.15, 3.1 in base_link) or `null` to disable it. +The server lists the presets in `/info` (`calibration_presets`); a request may say `"calibration": {"preset": +""}` (the Python API too) instead of sending the matrix. + +| preset | content | source | +|---|---|---| +| `synthetic_ot128.json`, `synthetic_qt128.json` | the sensor -> base_link transform of the shipped synthetic samples: a pure 2 m lift (base_link on the road under the sensor, x forward) | `code/scripts/make_sample.py` | + +The PandaSet 019 frame 40 preset (`pandaset_019_40.json`, the fitted Pandar64 mount) belongs to the staged PandaSet +sample and moves here with it once PandaSet-derived samples are approved (`staging_samples_pandaset/` of the bundle +checkout). diff --git a/code/tt_frnet/calib/synthetic_ot128.json b/code/tt_frnet/calib/synthetic_ot128.json new file mode 100644 index 0000000000000000000000000000000000000000..03d6f42752656345c4bb7edb8143dc0eabf05661 --- /dev/null +++ b/code/tt_frnet/calib/synthetic_ot128.json @@ -0,0 +1,31 @@ +{ + "name": "synthetic_ot128", + "description": "synthetic OT128-like scan (code/scripts/make_sample.py): the sensor 2 m above a flat road, base_link on the road under it (a pure lift)", + "source": "code/scripts/make_sample.py (generated, no dataset; Apache-2.0)", + "T_base_link_from_sensor": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 1.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 1.0, + 2.0 + ], + [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + ] +} diff --git a/code/tt_frnet/calib/synthetic_qt128.json b/code/tt_frnet/calib/synthetic_qt128.json new file mode 100644 index 0000000000000000000000000000000000000000..73d9c440432225404c3c776475aa73d5b85097cb --- /dev/null +++ b/code/tt_frnet/calib/synthetic_qt128.json @@ -0,0 +1,31 @@ +{ + "name": "synthetic_qt128", + "description": "synthetic QT128-like scan (code/scripts/make_sample.py): the sensor 2 m above a flat road, base_link on the road under it (a pure lift)", + "source": "code/scripts/make_sample.py (generated, no dataset; Apache-2.0)", + "T_base_link_from_sensor": [ + [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 1.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 1.0, + 2.0 + ], + [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + ] +} diff --git a/code/tt_frnet/device.py b/code/tt_frnet/device.py new file mode 100644 index 0000000000000000000000000000000000000000..801f725cfbd94013313afc03d1ad91b98541f8a2 --- /dev/null +++ b/code/tt_frnet/device.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Opening the Blackhole p150 the way every published number of frnet-p150 was measured. + +The implementation is the vendored ``ttaw.device`` (C01): dispatch on the idle ETH cores +(``ttnn.DispatchCoreConfig(ttnn.DispatchCoreType.ETH)``), which gives a 12x10 = 120-core compute grid on a p150 +(11x10 with WORKER dispatch, the A/B switch). ETH dispatch needs ``patches/tt-metal-eth-dispatch.patch`` on tt-metal +44d6650 (the container image is built from a patched tree); if the ETH open fails, a ``RuntimeWarning`` is issued and +WORKER dispatch is used, unless ``allow_fallback=False``. Never hard-code the grid: use :func:`compute_grid` or +``device.compute_with_storage_grid_size()``. + +This module binds it to the validated open parameters of this port, :data:`DEVICE_DEFAULTS` (the model class uses +the same dict), which the ``FRNET_DISPATCH``, ``_NUM_CQS``, ``_L1_SMALL``, ``_TRACE_REGION`` and +``_WORKER_L1_SIZE`` variables and ``TT_DEVICE_ID`` override per process (SERVING.md section 3.4). Importing it has +no side effects (``ttnn`` is imported when a device is opened). +""" +from __future__ import annotations + +import contextlib +import dataclasses +from typing import Any, Dict, Iterator, Optional + +from .ttaw.device import DeviceConfig, close_device, compute_grid, core_grid, describe_device, full_core_range_set + +__all__ = ["ENV_PREFIX", "DEVICE_DEFAULTS", "DeviceConfig", "device_config", "open_device", "device_session", + "close_device", "describe_device", "compute_grid", "core_grid", "full_core_range_set"] + +ENV_PREFIX = "FRNET" + +# Validated device-open parameters of this port (fill per model; the card's numbers are measured with them). +DEVICE_DEFAULTS: Dict[str, Any] = { + "num_command_queues": 1, # 1, or 2 when the input upload (CQ1) overlaps the trace (CQ0) + "l1_small_size": 65536, # L1_SMALL bytes per core (conv config tensors; P5 / P14 conv values, measured at M2) + "trace_region_size": 256 << 20, # DRAM bytes for the traces of all warm-up variants (measured at M2) +} + + +def device_config(**overrides: Any) -> DeviceConfig: + """:data:`DEVICE_DEFAULTS` < the ``FRNET_*`` / ``TT_DEVICE_ID`` environment < explicit non-None ``overrides`` + (``device_id``, ``dispatch``, ``num_command_queues``, ``l1_small_size``, ``trace_region_size``, + ``worker_l1_size``, ``allow_fallback``): the same resolution as ``FRNet.from_pretrained``.""" + config = DeviceConfig.from_env(ENV_PREFIX, **DEVICE_DEFAULTS) + return dataclasses.replace(config, **{k: v for k, v in overrides.items() if v is not None}) + + +def open_device(device_id: Optional[int] = None, *, dispatch: Optional[str] = None, **overrides: Any): + """Open one chip like the published numbers: ETH dispatch (``dispatch="worker"`` is the A/B opt-in) and this + port's sizes. Close it with :func:`close_device`, or use :func:`device_session`.""" + return device_config(device_id=device_id, dispatch=dispatch, **overrides).open() + + +@contextlib.contextmanager +def device_session(device_id: Optional[int] = None, **overrides: Any) -> Iterator[Any]: + """``with device_session() as dev:`` opens with :func:`open_device` and always closes, also when the body + raises.""" + device = open_device(device_id, **overrides) + try: + yield device + finally: + close_device(device) diff --git a/code/tt_frnet/host/__init__.py b/code/tt_frnet/host/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d1f795628c71c6eeaa2879e99457eb70e0a43d4d --- /dev/null +++ b/code/tt_frnet/host/__init__.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Host pre- and post-processing of FRNet with the exact ``autoware_lidar_frnet`` semantics (numpy only), shared by +the device path (``api.py``) and the CPU reference (``reference/pipeline.py``), so TT and CPU always see identical +network inputs: + +- ``preprocess.py`` the node's pre-processing (ego crop, float32 projection, fp16 farthest-point interpolation, + counting-sort unique cells, profile checks) -> the four ONNX inputs; and the TT device plan + (points sorted by cell and padded to N_cap, per-cell segments, the fp32 8-d features with + ``pre_norm`` folded, the gather tables that replace every scatter of the graph); +- ``frame.py`` one request -> a prepared frame (decoding, calibration / crop transform, profile verdict); +- ``postprocess.py`` ``fill_cloud_kernel``: first-max argmax, probability, palette, class-probability filter, and + the ``FRNetSegmentation`` output. + +Every function is tested against the research reference scripts (research/frnet/scripts/frnet_preproc_ref.py and +the independent verify_preproc_ort_independent.py) and the stored research goldens (``tests/test_host_vs_research.py``). +""" diff --git a/code/tt_frnet/host/frame.py b/code/tt_frnet/host/frame.py new file mode 100644 index 0000000000000000000000000000000000000000..fe46b79f6f3c7b20e81e8b23b66f875699281a96 --- /dev/null +++ b/code/tt_frnet/host/frame.py @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: Apache-2.0 +"""One request -> one prepared frame (the host half of ``FRNet._prepare``), numpy only. + +Input contract (research/frnet/SPEC.md section 2; the node's ``~/input/pointcloud``): + +- ``points``: ONE LiDAR sweep in the **sensor frame** (x forward, y left, z up), fields ``x, y, z, intensity`` + (intensity raw, as the node casts it: 0-255 for uint8 drivers, floats passed through); any ``ttaw.io.load_points`` + form. Rows with a non-finite x / y / z are dropped by the decoder (PCL ``is_dense=false`` clouds); the output's + ``input_index`` refers to the decoded rows; +- ``calibration`` (optional mapping, or ``{"preset": name}`` of ``calib/.json``): + ``T_base_link_from_sensor`` (any ``ttaw.io.parse_transform`` spelling; alias ``T_ref_from_sensor``) -- the static + transform the node looks up once for its ego crop box (``lidar_frnet_node.cpp:154-182``); ``ego_crop_box`` -- + ``[min_x, min_y, min_z, max_x, max_y, max_z]`` in base_link or a mapping of those keys (default: Autoware's + ``frnet.param.yaml`` box), ``null`` / all zeros = crop disabled (the node's own switch). + +With the crop enabled and no transform, the node skips every frame (its TF lookup fails); here that is a client error +(``InputError``, HTTP 400) naming both fixes. A frame outside the node's profiles is not an error: the node logs and +publishes nothing, so :class:`Prepared` carries ``skipped`` (the node's message), no device run happens and the +output is empty with that reason. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Mapping, Optional, Tuple + +import numpy as np + +from .. import io as tio +from ..reference.config import FRNetConfig, parse_crop_box +from .preprocess import FramePlan, NetworkInputs, autoware_preprocess, build_plan, crop_transform_12, profile_errors + +__all__ = ["Prepared", "FRNetPreprocessor", "CALIB_DIR", "TRANSFORM_KEYS"] + +CALIB_DIR = Path(__file__).resolve().parents[1] / "calib" +TRANSFORM_KEYS = ("T_base_link_from_sensor", "T_ref_from_sensor") +POINT_FIELDS = ("x", "y", "z", "intensity") + + +@dataclass +class Prepared: + """A prepared frame. ``skipped`` set: the node would publish nothing (no device run).""" + + num_input_points: int + inputs: Optional[NetworkInputs] + plan: Optional[FramePlan] + skipped: Optional[str] + crop: Dict[str, Any] = field(default_factory=dict) + frame_id: str = "sensor" + + @property + def input_index(self) -> np.ndarray: + """(num_points_raw,) int64: decoded input row of every raw output point.""" + if self.inputs is None: + return np.zeros(0, np.int64) + return np.flatnonzero(self.inputs.keep_mask).astype(np.int64) + + def stats(self) -> Dict[str, Any]: + out: Dict[str, Any] = {"num_points_input": self.num_input_points, "ego_crop": dict(self.crop)} + if self.inputs is not None: + out.update(self.inputs.stats()) + if self.plan is not None: + out["plan"] = self.plan.stats() + if self.skipped is not None: + out["skipped"] = self.skipped + return out + + +class FRNetPreprocessor: + """``FRNetPreprocessor(config, pre_norm=(scale, shift))(points, calibration)`` -> :class:`Prepared`. + ``plan=False`` stops after the four network inputs (the CPU reference needs nothing else).""" + + def __init__(self, config: FRNetConfig, *, pre_norm: Optional[Tuple[np.ndarray, np.ndarray]] = None, + calib_dir: Optional[Path] = CALIB_DIR, plan: bool = True): + self.config = config + self.pre_norm = pre_norm + self.calib_dir = calib_dir + self.plan = plan + + # ---- calibration ------------------------------------------------------------------------------------------- + def crop_setup(self, calibration: Optional[Mapping[str, Any]]) -> Tuple[Optional[np.ndarray], Tuple[float, ...], + Dict[str, Any]]: + """``calibration`` -> ``(transform 12 float32 or None, bounds (6,), description)``; client mistakes raise + ``InputError``.""" + if calibration is not None and not isinstance(calibration, Mapping): + raise tio.InputError("calibration must be an object") + calib = tio.resolve_calibration(calibration, self.calib_dir) if calibration is not None else {} + unknown = sorted(set(calib) - set(TRANSFORM_KEYS) - {"ego_crop_box", "name", "description", "source"}) + if unknown: + raise tio.InputError(f"calibration: unknown keys {unknown}; FRNet takes {list(TRANSFORM_KEYS)[:1]} " + "and ego_crop_box") + bounds = tuple(self.config.crop_bounds) + if "ego_crop_box" in calib: + try: + bounds = parse_crop_box(calib["ego_crop_box"]) + except ValueError as e: + raise tio.InputError(f"calibration.{e}") from None + given = [k for k in TRANSFORM_KEYS if k in calib and calib[k] is not None] + if len(given) > 1: + raise tio.InputError(f"calibration: give one of {list(TRANSFORM_KEYS)}, not both") + T12 = None + if given: + T = tio.parse_transform(calib[given[0]], field=f"calibration.{given[0]}") + R = T[:3, :3] + if not (np.allclose(R @ R.T, np.eye(3), atol=1e-4) and abs(np.linalg.det(R) - 1.0) < 1e-4): + raise tio.InputError(f"calibration.{given[0]}: the rotation is not a proper rotation matrix") + T12 = crop_transform_12(T) + enabled = bool(np.any(np.asarray(bounds, np.float64).astype(np.float32) != 0.0)) + if enabled and T12 is None: + raise tio.InputError( + "the ego crop box (Autoware frnet.param.yaml filter.ego_crop_box) needs the static sensor -> base_link " + "transform: pass calibration={'T_base_link_from_sensor': <4x4 | {translation, rotation_*} | " + "{x, y, z, roll, pitch, yaw}>} (or a calib preset), or disable the crop with " + "calibration={'ego_crop_box': None} (the node skips every frame when its TF lookup fails)") + desc = {"enabled": enabled, "bounds": [float(b) for b in bounds], + "reference_frame": self.config.crop_reference_frame} + if T12 is not None: + desc["sensor_to_ref_12"] = [float(v) for v in T12] + return T12, bounds, desc + + # ---- one frame --------------------------------------------------------------------------------------------- + def __call__(self, points: Any, calibration: Optional[Mapping[str, Any]] = None) -> Prepared: + if points is None: + raise tio.InputError("this model needs 'points'") + T12, bounds, crop_desc = self.crop_setup(calibration) + cloud = tio.load_points(points) + xyzi = cloud.select(POINT_FIELDS) + frame_id = cloud.frame_id if cloud.frame_id not in ("", "base_link") else "sensor" + n0 = int(len(xyzi)) + errs = profile_errors(n0, None, self.config) + if errs: + return Prepared(n0, None, None, errs[0], crop_desc, frame_id) + inputs = autoware_preprocess(xyzi, self.config, sensor_to_ref_12=T12, crop_bounds=bounds) + errs = profile_errors(n0, inputs, self.config) + if errs: + return Prepared(n0, inputs, None, errs[0], crop_desc, frame_id) + plan = build_plan(inputs, self.config, pre_norm=self.pre_norm) if self.plan else None + return Prepared(n0, inputs, plan, None, crop_desc, frame_id) diff --git a/code/tt_frnet/host/postprocess.py b/code/tt_frnet/host/postprocess.py new file mode 100644 index 0000000000000000000000000000000000000000..d3b5113c01bc6c4761684be6d24e22d9af1df4f0 --- /dev/null +++ b/code/tt_frnet/host/postprocess.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Host post-processing of ``autoware_lidar_frnet`` (research/frnet/SPEC.md section 5), numpy only. + +``fill_cloud_kernel`` (``PKG/lib/postprocess/postprocess_kernel.cu:200-264`` @ universe ``9ceaccf``), one thread per +point over all ``N`` points (interpolated ones included): + +- ``class_id``: ``best = -1e9f``, ``class_id = num_classes - 1``, then ``if (prob > best)`` over the classes in + order -- the **first** maximum wins and a NaN never wins (:func:`autoware_argmax`); ``probability = best``; +- segmentation cloud: (x, y, z of the network input points, class_id uint8, probability float32); +- visualization cloud: (x, y, z, ``palette[class_id]``: ``(r << 16) | (g << 8) | b`` as a float32); +- filtered cloud: a point is dropped iff **any** filter class has ``prob >= threshold`` (float32 compare against + ``static_cast(0.05)``); kept raw points are the original records, kept interpolated points are synthesized + from x, y, z, intensity with the other fields 0. The CUDA kernel appends them with ``atomicAdd`` (any order); + here the filtered cloud is the mask :attr:`FRNetSegmentation.filter_keep` over the output points. + +AWML's offline max-probability threshold (``AWML/deploy/postprocessing.py``) is not used by Autoware and not here. +The output class adds the point arrays the node publishes to ``ttaw.outputs.Segmentation3D``. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Mapping, Optional, Sequence, Tuple + +import numpy as np + +from ..reference.config import NODE_PARAMS, FRNetConfig, parse_filter_classes +from ..ttaw.io import InputError, encode_array +from ..ttaw.outputs import Segmentation3D + +__all__ = ["RUNTIME_PARAMS", "autoware_argmax", "filter_keep_mask", "visualization_rgb", "FRNetSegmentation", + "FRNetPostprocessor"] + +# Per-request knobs (RT-host), Autoware's defaults (frnet.param.yaml:6-7). ``filter_classes``: comma-separated +# class names, "" = none; the node allows at most 16. +RUNTIME_PARAMS: Dict[str, Tuple[Any, Any, Any, Any]] = { + "class_probability_threshold": (float, 0.0, 1.0, NODE_PARAMS["class_probability_threshold"]), + "filter_classes": (str, None, None, ",".join(NODE_PARAMS["filter_classes"])), +} + +ARGMAX_INIT = np.float32(-1e9) + + +def autoware_argmax(pred_probs: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """``fill_cloud_kernel`` L218-228 -> ``(class_id uint8 (N,), probability float32 (N,))``.""" + p = np.asarray(pred_probs, np.float32) + if p.ndim != 2: + raise ValueError(f"pred_probs must be (N, C), got {p.shape}") + n, c = p.shape + if n == 0: + return np.zeros(0, np.uint8), np.zeros(0, np.float32) + q = np.where(np.isnan(p), np.float32(-np.inf), p) + idx = np.argmax(q, axis=1) # first maximum (NaN treated as never greater) + best = q[np.arange(n), idx] + none = ~(best > ARGMAX_INIT) # no class beat the -1e9 start: class C - 1, prob -1e9 + if none.any(): + idx = idx.copy() + best = best.copy() + idx[none] = c - 1 + best[none] = ARGMAX_INIT + return idx.astype(np.uint8), best.astype(np.float32) + + +def filter_keep_mask(pred_probs: np.ndarray, class_indices: Sequence[int], threshold: float) -> np.ndarray: + """``fill_cloud_kernel`` L245-254: True = the point stays in the filtered cloud (no filter class reaches the + float32 threshold).""" + p = np.asarray(pred_probs, np.float32) + thr = np.float32(threshold) + hit = np.zeros(p.shape[0], bool) + for c in class_indices: + hit |= p[:, int(c)] >= thr + return ~hit + + +def visualization_rgb(class_id: np.ndarray, config: FRNetConfig) -> np.ndarray: + """The visualization cloud's packed ``rgb`` float32 per point (``const_palette[class_id]``).""" + return config.palette_packed[np.asarray(class_id, np.int64)] + + +@dataclass +class FRNetSegmentation(Segmentation3D): + """Per-point classes of one frame in the node's output order: the input points that survive the ego crop box + (input order, after non-finite rows are dropped), then the interpolated points (``num_points_raw`` raw rows + first). ``label_ids`` = Autoware ``class_id``, ``scores`` = its ``probability``; ``points`` (N, 4) the network + input x, y, z, intensity (the segmentation cloud's coordinates); ``filter_keep`` (N,) the filtered cloud's + membership; ``input_index`` (num_points_raw,) int32 the input row of every raw output point.""" + + points: Optional[np.ndarray] = None + filter_keep: Optional[np.ndarray] = None + input_index: Optional[np.ndarray] = None + num_points_raw: int = 0 + extras: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + super().__post_init__() + n = len(self) + if self.points is not None: + self.points = np.asarray(self.points, np.float32) + if self.points.ndim != 2 or self.points.shape[0] != n: + raise ValueError(f"points must be ({n}, C), got {self.points.shape}") + if self.filter_keep is not None: + self.filter_keep = np.asarray(self.filter_keep, bool).reshape(n) + if self.input_index is not None: + self.input_index = np.asarray(self.input_index, np.int32).reshape(-1) # decoded rows < 2**31 + if self.input_index.shape[0] != self.num_points_raw: + raise ValueError("input_index must have num_points_raw entries") + if self.num_points_raw > n: + raise ValueError("num_points_raw exceeds the number of points") + + def to_dict(self, output_format: str = "json") -> Dict[str, Any]: + body = super().to_dict(output_format) + body["num_points_raw"] = int(self.num_points_raw) + if self.points is not None: + body["points"] = encode_array(self.points, key="points") + if self.filter_keep is not None: + body["filter_keep"] = encode_array(self.filter_keep, key="filter_keep") + if self.input_index is not None: + body["input_index"] = encode_array(self.input_index, key="input_index") + for k, v in self.extras.items(): + body[k] = encode_array(np.asarray(v), key=k) + return body + + +class FRNetPostprocessor: + """``pred_probs`` (N, 27) of the network input points -> :class:`FRNetSegmentation`.""" + + def __init__(self, config: FRNetConfig): + self.config = config + + def filter_indices(self, filter_classes: Any) -> Tuple[int, ...]: + """Request ``filter_classes`` (comma-separated names) -> class indices; unknown names are a client error + (the node throws at start-up).""" + try: + return self.config.filter_class_indices(parse_filter_classes(filter_classes)) + except ValueError as e: + raise InputError(f"filter_classes: {e}") from None + + def __call__(self, pred_probs: np.ndarray, *, points: np.ndarray, num_points_raw: int, + input_index: Optional[np.ndarray], params: Mapping[str, Any], model: str = "", + frame_id: str = "sensor", meta: Optional[Dict[str, Any]] = None) -> FRNetSegmentation: + p = {k: v[3] for k, v in RUNTIME_PARAMS.items()} + p.update(params or {}) + indices = self.filter_indices(p["filter_classes"]) + class_id, prob = autoware_argmax(pred_probs) + keep = filter_keep_mask(pred_probs, indices, p["class_probability_threshold"]) + meta = dict(meta or {}) + meta.update(filter={"classes": [self.config.class_names[i] for i in indices], + "class_probability_threshold": float(p["class_probability_threshold"]), + "kept": int(keep.sum())}) + return FRNetSegmentation(class_id, class_names=self.config.class_names, scores=prob, model=model, + frame_id=frame_id, meta=meta, points=points, filter_keep=keep, + input_index=input_index, num_points_raw=int(num_points_raw)) + + def empty(self, *, model: str = "", frame_id: str = "sensor", + meta: Optional[Dict[str, Any]] = None) -> FRNetSegmentation: + """No output (a frame the node refuses: profile checks), with the reason in ``meta``.""" + return FRNetSegmentation(np.zeros(0, np.uint8), class_names=self.config.class_names, + scores=np.zeros(0, np.float32), model=model, frame_id=frame_id, + meta=dict(meta or {}), points=np.zeros((0, 4), np.float32), + filter_keep=np.zeros(0, bool), input_index=np.zeros(0, np.int32), num_points_raw=0) diff --git a/code/tt_frnet/host/preprocess.py b/code/tt_frnet/host/preprocess.py new file mode 100644 index 0000000000000000000000000000000000000000..64a1d1325201bf5a97b95f45c71a1a41c40f61b9 --- /dev/null +++ b/code/tt_frnet/host/preprocess.py @@ -0,0 +1,367 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Host pre-processing of ``autoware_lidar_frnet`` (research/frnet/SPEC.md section 3; PLAN.md 2.8), numpy only. + +Two halves, both deterministic: + +1. :func:`autoware_preprocess` -- the node's pre-processing, ``PKG/lib/preprocess/preprocess_kernel.cu`` + + ``PKG/lib/lidar_frnet.cpp:196-262`` @ universe ``9ceaccf``, producing the four network inputs exactly as + the deployed ONNX takes them (unpadded: the graph sizes its voxel tensors as ``max(inverse_map) + 1``, + S:frnet:255-262, so the ONNX Runtime / torch goldens must never see padding): + + - **ego crop** (``projectPoints_kernel`` L211-228): with the cached static ``sensor -> base_link`` transform as 12 + float32 (row-major R, then t), a point is dropped iff it lies *strictly* inside the box on all three axes; the + reference-frame coordinates are evaluated in float32 as the kernel writes them + (``((r0 * x + r1 * y) + r2 * z) + t``; FMA contraction by nvcc could move a point within one ulp of a face); + - **projection** (``project2d`` L151-163) in float32: ``depth = sqrtf(x^2+y^2+z^2)``, ``yaw = -atan2f(y, x)``, + ``pitch = asinf(z / depth)``, ``u = 0.5 (yaw / pi + 1) W``, ``v = (1 - (pitch + |down|) / total) H``, NaN -> 0 + (``fmaxf``), clamp to ``[0, W - 1]`` / ``[0, H - 1]``, truncate. float64 math moves 0.33 % of the points to + another cell (S:frnet:190-193); numpy's float32 ``arctan2`` / ``arcsin`` can still differ from CUDA's + ``atan2f`` / ``asinf`` by an ulp (unavoidable, documented); + - **interpolation grid** (L232-258): one point per pixel stored as 4 x fp16 (x, y, z, intensity); the + **farthest** point wins, by ``depth^2`` computed in float32 from the fp16-rounded x, y, z (exact: products of + fp16 values are exact in float32, so nvcc's FMA contraction cannot change it). The CUDA race between the + first plain store and the CAS loop, and the order among equal depths, are fixed here: the farthest point + wins, ties go to the later input point (the research reference's policy); + - **interpolation** (``interpolatePoints_kernel`` L332-379): an empty pixel with ``1 <= x <= W - 2`` whose left + and right neighbours are filled gets ``float(left + right) * 0.5`` (fp16 sum, no azimuth wrap-around), appended + after the raw points in row-major pixel order (CUDA appends with ``atomicAdd``: non-deterministic there); + - **frustum cells and unique** (``generateUniqueCoors`` L403-468): ``coors = (0, row, col)``, key + ``row * W + col``; ``voxel_coors`` = the occupied cells in ascending key order, ``inverse_map`` = the rank of + each point's cell -- here a counting sort over the ``H * W`` cells (S:frnet:460-466); + - **profile checks** (``lidar_frnet.cpp:138-149, 227-254``): the input count, ``N`` after interpolation and ``M`` + must lie in the ``num_points`` / ``num_unique_coors`` profiles. The CUDA appends are not bounds-checked (an + over-profile frame overflows the device buffers before the ``N`` check, S:frnet:201-205); here every count is + checked after it is known and an out-of-profile frame is refused (:func:`profile_errors`). + +2. :func:`build_plan` -- the tables of the TT device graph (PLAN.md 2.8; PORT_LOG.md "trace layout"): the points + **sorted by cell** (stable, so a cell's points keep their input order) and padded to the capacity ``N_cap`` = + ``num_points.max``, the per-cell segments (CSR offsets, last row) of the sorted points, the fp32 8-d point + features with ``pre_norm`` folded in, and the gather indices that replace every scatter of the graph: + ``point2pix[s]`` (pixel of each sorted point at stride s: pixel2point) and ``pix2vox[s]`` (cell -> voxel row + or the zero sentinel row ``M_cap``: frustum2pixel; at s > 1 several cells fall on one pixel and the **last + voxel in voxel order** wins, which is what ORT's sequential ScatterND does, S:frnet:285-287). The network is + permutation-equivariant in the points, so the host un-permutes the device output with ``plan.order``. + +Nothing here imports torch, onnx or ttnn. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from ..reference.config import STRIDES, FRNetConfig + +__all__ = ["NetworkInputs", "FramePlan", "crop_transform_12", "crop_keep_mask", "project2d", "interpolation_grid", + "interpolate_points", "unique_coors", "autoware_preprocess", "profile_errors", "decorate_points", + "build_plan", "segmax_rounds", "segmax_shift_tables"] + +PI_F32 = np.float32(np.pi) # M_PIf + + +# ------------------------------------------------------------------------------------------------- the node + +@dataclass +class NetworkInputs: + """The four ONNX inputs of one frame, unpadded, plus the bookkeeping of how they were made.""" + + points: np.ndarray # (N, 4) float32: kept raw points (input order), then interpolated points + coors: np.ndarray # (N, 3) int64 (0, row, col) frustum cell of every point + voxel_coors: np.ndarray # (M, 3) int64 occupied cells in ascending key order + inverse_map: np.ndarray # (N,) int64 voxel row of every point + num_points_raw: int # rows [0, num_points_raw) of `points` are raw input points + keep_mask: np.ndarray # (N0,) bool: input rows that survived the ego crop box + crop_applied: bool = True + + @property + def num_points(self) -> int: + return int(self.points.shape[0]) + + @property + def num_voxels(self) -> int: + return int(self.voxel_coors.shape[0]) + + @property + def num_interpolated(self) -> int: + return self.num_points - self.num_points_raw + + def onnx_feeds(self) -> Dict[str, np.ndarray]: + """``{points, coors, voxel_coors, inverse_map}`` as the deployed ONNX binds them (float32 / int64).""" + return {"points": np.ascontiguousarray(self.points, np.float32), + "coors": np.ascontiguousarray(self.coors, np.int64), + "voxel_coors": np.ascontiguousarray(self.voxel_coors, np.int64), + "inverse_map": np.ascontiguousarray(self.inverse_map, np.int64)} + + def stats(self) -> Dict[str, Any]: + counts = np.bincount(self.inverse_map) if self.num_points else np.zeros(1, np.int64) + return {"num_points_input": int(self.keep_mask.shape[0]), "num_points_raw": self.num_points_raw, + "num_points_interpolated": self.num_interpolated, "num_points": self.num_points, + "num_unique_coors": self.num_voxels, "cropped_by_ego_box": int((~self.keep_mask).sum()), + "crop_applied": self.crop_applied, "max_points_per_cell": int(counts.max()) if counts.size else 0} + + +def crop_transform_12(T_ref_from_sensor: Any) -> np.ndarray: + """The node's cached crop transform (``lidar_frnet_node.cpp:160-180``): ``static_cast`` of the 3x3 + rotation (row-major) then the translation of the double ``base_link <- sensor`` transform -> (12,) float32.""" + T = np.asarray(T_ref_from_sensor, np.float64) + if T.shape not in ((4, 4), (3, 4)): + raise ValueError(f"crop transform must be 4x4 or 3x4, got {T.shape}") + return np.concatenate([T[:3, :3].reshape(-1), T[:3, 3]]).astype(np.float32) + + +def crop_keep_mask(xyz: np.ndarray, sensor_to_ref_12: np.ndarray, bounds_f32: np.ndarray) -> np.ndarray: + """``projectPoints_kernel`` L211-228: True = keep (outside the open box ``min < p < max`` on some axis).""" + T = np.asarray(sensor_to_ref_12, np.float32).reshape(12) + b = np.asarray(bounds_f32, np.float32).reshape(6) + x, y, z = (np.asarray(xyz[:, i], np.float32) for i in range(3)) + px = ((T[0] * x + T[1] * y) + T[2] * z) + T[9] + py = ((T[3] * x + T[4] * y) + T[5] * z) + T[10] + pz = ((T[6] * x + T[7] * y) + T[8] * z) + T[11] + inside = (px > b[0]) & (px < b[3]) & (py > b[1]) & (py < b[4]) & (pz > b[2]) & (pz < b[5]) + return ~inside + + +def project2d(xyz: np.ndarray, scale_x: int, scale_y: int, fov: Tuple[np.float32, np.float32, np.float32] + ) -> Tuple[np.ndarray, np.ndarray]: + """``project2d`` (preprocess_kernel.cu L151-163) in float32 -> (col, row) int64, clamped then truncated.""" + _, down, total = fov + x = np.asarray(xyz[:, 0], np.float32) + y = np.asarray(xyz[:, 1], np.float32) + z = np.asarray(xyz[:, 2], np.float32) + depth = np.sqrt(x * x + y * y + z * z, dtype=np.float32) + yaw = -np.arctan2(y, x).astype(np.float32) + with np.errstate(invalid="ignore", divide="ignore"): + pitch = np.arcsin(z / depth).astype(np.float32) + proj_x = np.float32(0.5) * (yaw / PI_F32 + np.float32(1.0)) + proj_y = np.float32(1.0) - (pitch + np.float32(abs(down))) / np.float32(total) + proj_x = proj_x * np.float32(scale_x) + proj_y = proj_y * np.float32(scale_y) + proj_x = np.where(np.isnan(proj_x), np.float32(0.0), proj_x) # fmaxf(NaN, 0) == 0 + proj_y = np.where(np.isnan(proj_y), np.float32(0.0), proj_y) + px = np.minimum(np.maximum(proj_x, np.float32(0.0)), np.float32(scale_x) - np.float32(1.0)) + py = np.minimum(np.maximum(proj_y, np.float32(0.0)), np.float32(scale_y) - np.float32(1.0)) + return px.astype(np.int64), py.astype(np.int64) + + +def interpolation_grid(raw: np.ndarray, config: FRNetConfig) -> Tuple[np.ndarray, np.ndarray]: + """The interpolation grid of ``projectPoints_kernel`` (L232-258) -> ``(grid (IH * IW, 4) float16, filled + (IH * IW,) bool)``: per pixel the farthest point by fp16 ``depth^2``, ties to the later input point.""" + iw, ih = config.interp_w, config.interp_h + grid = np.zeros((iw * ih, 4), np.float16) + filled = np.zeros(iw * ih, bool) + if not len(raw): + return grid, filled + u, v = project2d(raw[:, :3], iw, ih, config.fov) + pix = v * iw + u + h = raw.astype(np.float16) # half4(x, y, z, intensity): __float2half (RNE) + hf = h[:, :3].astype(np.float32) + depth2 = hf[:, 0] * hf[:, 0] + hf[:, 1] * hf[:, 1] + hf[:, 2] * hf[:, 2] # exact products (fp16 operands) + order = np.lexsort((np.arange(len(raw)), depth2, pix)) # by pixel, then depth, then input index + ps = pix[order] + last = np.r_[ps[1:] != ps[:-1], True] # last of each pixel group = farthest, latest on ties + grid[ps[last]] = h[order[last]] + filled[ps[last]] = True + return grid, filled + + +def interpolate_points(grid: np.ndarray, filled: np.ndarray, config: FRNetConfig) -> np.ndarray: + """``interpolatePoints_kernel`` (L332-379) -> (K, 4) float32 points in row-major pixel order.""" + iw, ih = config.interp_w, config.interp_h + f2 = filled.reshape(ih, iw) + cand = np.zeros_like(f2) + cand[:, 1:-1] = ~f2[:, 1:-1] & f2[:, :-2] & f2[:, 2:] + ys, xs = np.nonzero(cand) + g2 = grid.reshape(ih, iw, 4) + with np.errstate(over="ignore"): # an fp16 overflow is inf, as __hadd gives + s16 = (g2[ys, xs - 1] + g2[ys, xs + 1]).astype(np.float16) # __half operator+ (RNE) + return s16.astype(np.float32) * np.float32(0.5) + + +def unique_coors(coors: np.ndarray, frustum_w: int, frustum_h: int) -> Tuple[np.ndarray, np.ndarray]: + """``generateUniqueCoors`` (L403-468) as a counting sort over the frustum cells -> ``(voxel_coors (M, 3) int64, + inverse_map (N,) int64)``: the occupied cells in ascending key order and each point's cell rank.""" + keys = coors[:, 1] * frustum_w + coors[:, 2] + counts = np.bincount(keys, minlength=frustum_w * frustum_h) + occupied = counts > 0 + cell_rank = np.cumsum(occupied) - 1 + cells = np.flatnonzero(occupied) + voxel_coors = np.stack([np.zeros_like(cells), cells // frustum_w, cells % frustum_w], axis=1).astype(np.int64) + return voxel_coors, cell_rank[keys].astype(np.int64) + + +def autoware_preprocess(points_xyzi: np.ndarray, config: FRNetConfig, *, + sensor_to_ref_12: Optional[np.ndarray] = None, + crop_bounds: Optional[Sequence[float]] = None) -> NetworkInputs: + """The node's pre-processing of one sensor-frame cloud ``(N0, 4)`` x, y, z, intensity (raw; float32) -> + :class:`NetworkInputs`. ``crop_bounds`` (6 numbers) overrides the config's box; the crop runs iff a bound is + non-zero, and then needs ``sensor_to_ref_12`` (:func:`crop_transform_12`): the node skips the frame when its TF + lookup fails, so a missing transform raises ``ValueError`` here. Profile checks are separate + (:func:`profile_errors`).""" + pts = np.ascontiguousarray(points_xyzi, np.float32) + if pts.ndim != 2 or pts.shape[1] != 4: + raise ValueError(f"points must be (N, 4) x, y, z, intensity, got {pts.shape}") + bounds = config.crop_bounds_f32 if crop_bounds is None else np.asarray(crop_bounds, np.float64).astype(np.float32) + crop = bool(np.any(bounds != 0.0)) + if crop: + if sensor_to_ref_12 is None: + raise ValueError("the ego crop box is enabled but no sensor -> base_link transform was given") + keep = crop_keep_mask(pts[:, :3], sensor_to_ref_12, bounds) + else: + keep = np.ones(len(pts), bool) + raw = pts[keep] + grid, filled = interpolation_grid(raw, config) + interp = interpolate_points(grid, filled, config) + allpts = np.concatenate([raw, interp], axis=0) if len(interp) else raw.copy() + fx, fy = project2d(allpts[:, :3], config.frustum_w, config.frustum_h, config.fov) + coors = np.stack([np.zeros_like(fy), fy, fx], axis=1).astype(np.int64) + voxel_coors, inverse_map = unique_coors(coors, config.frustum_w, config.frustum_h) + return NetworkInputs(points=np.ascontiguousarray(allpts, np.float32), coors=coors, voxel_coors=voxel_coors, + inverse_map=inverse_map, num_points_raw=int(len(raw)), keep_mask=keep, crop_applied=crop) + + +def profile_errors(num_input_points: int, inputs: Optional[NetworkInputs], config: FRNetConfig) -> List[str]: + """The node's three profile checks, in its order (``lidar_frnet.cpp:138-149`` input count before the crop, + L227-237 ``N`` after interpolation, L243-254 ``M``); empty when the frame is accepted.""" + lo, opt, hi = config.num_points + if not lo <= num_input_points <= hi: + return [f"Number of points ({num_input_points}) in input pointcloud does not match the profile " + f"[min: {lo}, opt: {opt}, max: {hi}]"] + if inputs is None: + return [] + if not lo <= inputs.num_points <= hi: + return [f"Number of points ({inputs.num_points}) after interpolation does not match the profile " + f"[min: {lo}, opt: {opt}, max: {hi}]"] + ulo, uopt, uhi = config.num_unique + if not ulo <= inputs.num_voxels <= uhi: + return [f"Number of unique coors ({inputs.num_voxels}) does not match the profile " + f"[min: {ulo}, opt: {uopt}, max: {uhi}]"] + return [] + + +# ------------------------------------------------------------------------------------------- the TT device plan + +def decorate_points(points: np.ndarray, inverse_map: np.ndarray, num_voxels: int, *, + pre_norm: Optional[Tuple[np.ndarray, np.ndarray]] = None) -> np.ndarray: + """The frustum encoder's 8-d point features ``[x, y, z, i, |xyz|, x - mx, y - my, z - mz]`` (``m`` = the + cell mean of x, y, z; AWML ``frustum_encoder.py:79-100``), computed in float64. With ``pre_norm = (scale, + shift)`` the ``pre_norm`` BatchNorm1d is applied (``x * scale + shift``, float64) -- the port's host fold + (PLAN.md 2.8: xyz up to +-200 m minus a cell mean needs fp32 or better, never bf16). One rounding to float32.""" + p = np.asarray(points, np.float64) + n = len(p) + out = np.empty((n, 8), np.float64) + out[:, :4] = p + out[:, 4] = np.sqrt((p[:, :3] ** 2).sum(axis=1)) + if n: + counts = np.bincount(inverse_map, minlength=num_voxels).astype(np.float64) + for c in range(3): + mean = np.bincount(inverse_map, weights=p[:, c], minlength=num_voxels) / np.maximum(counts, 1.0) + out[:, 5 + c] = p[:, c] - mean[inverse_map] + if pre_norm is not None: + scale, shift = (np.asarray(a, np.float64).reshape(8) for a in pre_norm) + out = out * scale + shift + return out.astype(np.float32) + + +def segmax_rounds(max_segment_length: int) -> int: + """Rounds of the log-step segmented max (Hillis-Steele) that cover segments up to that length: + ``ceil(log2(L))`` (0 for L <= 1). PLAN.md 1.3: 5 for OT128 (<= 17 points per cell seen), 7 for QT128 (<= 111).""" + return int(np.ceil(np.log2(max_segment_length))) if max_segment_length > 1 else 0 + + +@dataclass +class FramePlan: + """The per-frame host tables of the device graph (module docstring, part 2). Row ``i`` of every per-point table + is the ``i``-th point in **sorted** order; rows ``N .. N_cap - 1`` are padding (one trailing sentinel segment: + their voxel id is ``M_cap``, their pixel 0, their features 0, and nothing the output keeps reads them).""" + + n: int # real points + m: int # real voxels (occupied cells) + n_raw: int + n_cap: int + m_cap: int + frustum_hw: Tuple[int, int] + order: np.ndarray # (N,) int64: sorted row -> network-input point index + seg_id: np.ndarray # (N_cap,) int64 voxel id of each sorted row (padding: M_cap) + seg_offsets: np.ndarray # (M + 1,) int64 CSR offsets of the voxels' sorted rows + seg_last: np.ndarray # (M_cap,) uint32 last sorted row of each voxel (unused voxels: N_cap - 1) + point2pix: Dict[int, np.ndarray] # stride -> (N_cap,) uint32 pixel of each sorted row (padding: 0) + pix2vox: Dict[int, np.ndarray] # stride -> ((H/s) * (W/s),) uint32 voxel row or M_cap (zero row) + features: np.ndarray # (N_cap, 8) float32 decorated (+ pre_norm) features + max_segment_length: int = 0 + strides: Tuple[int, ...] = field(default=STRIDES) + + @property + def rank(self) -> np.ndarray: + """(N,) int64: network-input point index -> sorted row (the inverse of ``order``).""" + r = np.empty(self.n, np.int64) + r[self.order] = np.arange(self.n) + return r + + def unsort(self, sorted_rows: np.ndarray) -> np.ndarray: + """Device rows (sorted, padded) -> the network-input point order (the first ``N`` sorted rows only).""" + return np.asarray(sorted_rows)[: self.n][self.rank] + + def stats(self) -> Dict[str, Any]: + return {"n": self.n, "m": self.m, "n_cap": self.n_cap, "m_cap": self.m_cap, + "max_points_per_cell": self.max_segment_length, + "segmax_rounds": segmax_rounds(self.max_segment_length)} + + +def build_plan(inputs: NetworkInputs, config: FRNetConfig, *, + pre_norm: Optional[Tuple[np.ndarray, np.ndarray]] = None, + n_cap: Optional[int] = None, m_cap: Optional[int] = None, + strides: Sequence[int] = STRIDES) -> FramePlan: + """The device tables of one accepted frame (:class:`FramePlan`). ``n_cap`` / ``m_cap`` default to the + config's capacities (COMPILE: the profile maxima); a frame above them raises ``ValueError`` (the profile check + refuses it first).""" + n_cap = config.n_cap if n_cap is None else int(n_cap) + m_cap = config.m_cap if m_cap is None else int(m_cap) + n, m = inputs.num_points, inputs.num_voxels + if n > n_cap or m > m_cap: + raise ValueError(f"frame N={n} / M={m} exceeds the capacity N_cap={n_cap} / M_cap={m_cap}") + H, W = config.frustum_h, config.frustum_w + inv = inputs.inverse_map + order = np.argsort(inv, kind="stable") # voxel ids are key ranks: == the stable sort by cell key + counts = np.bincount(inv, minlength=m).astype(np.int64) + offsets = np.zeros(m + 1, np.int64) + np.cumsum(counts, out=offsets[1:]) + seg_id = np.full(n_cap, m_cap, np.int64) + seg_id[:n] = inv[order] + seg_last = np.full(m_cap, n_cap - 1, np.uint32) + seg_last[:m] = (offsets[1:] - 1).astype(np.uint32) + coors_sorted = inputs.coors[order] + vox = inputs.voxel_coors + point2pix: Dict[int, np.ndarray] = {} + pix2vox: Dict[int, np.ndarray] = {} + for s in strides: + hs, ws = H // s, W // s + p2p = np.zeros(n_cap, np.uint32) + p2p[:n] = ((coors_sorted[:, 1] // s) * ws + coors_sorted[:, 2] // s).astype(np.uint32) + point2pix[s] = p2p + last = np.full(hs * ws, -1, np.int64) + np.maximum.at(last, (vox[:, 1] // s) * ws + vox[:, 2] // s, np.arange(m, dtype=np.int64)) + pix2vox[s] = np.where(last < 0, m_cap, last).astype(np.uint32) + feats = np.zeros((n_cap, 8), np.float32) + feats[:n] = decorate_points(inputs.points[order], inv[order], m, pre_norm=pre_norm) + return FramePlan(n=n, m=m, n_raw=inputs.num_points_raw, n_cap=n_cap, m_cap=m_cap, frustum_hw=(H, W), + order=order, seg_id=seg_id, seg_offsets=offsets, seg_last=seg_last, point2pix=point2pix, + pix2vox=pix2vox, features=feats, max_segment_length=int(counts.max()) if m else 0, + strides=tuple(strides)) + + +def segmax_shift_tables(seg_id: np.ndarray, rounds: int) -> List[np.ndarray]: + """The gather tables of the exact log-step segmented max (PLAN.md 1.3, the K1 stock-op fallback): round + ``r`` uses ``idx[i] = i - 2^r`` if row ``i - 2^r`` is in the same segment, else ``i`` (max is idempotent); + after ``rounds`` rounds each segment's last row holds the segment max when its length is <= ``2^rounds``. + -> ``rounds`` x (N_cap,) uint32.""" + seg = np.asarray(seg_id) + i = np.arange(len(seg), dtype=np.int64) + out = [] + for r in range(rounds): + d = 1 << r + j = i - d + same = np.zeros(len(seg), bool) + same[d:] = seg[d:] == seg[:-d] + out.append(np.where(same, j, i).astype(np.uint32)) + return out diff --git a/code/tt_frnet/io.py b/code/tt_frnet/io.py new file mode 100644 index 0000000000000000000000000000000000000000..62d9222559c758c3b2f29cf8479ae0226d6b8a62 --- /dev/null +++ b/code/tt_frnet/io.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Input decoding and output encoding shared by the Python API and the HTTP server of frnet-p150. + +The implementation is the vendored ``ttaw.io`` (C08): point clouds (``bin`` / ``npy`` / ``npz`` / ``pcd`` / +``list``), images, cameras + calibration, transforms, named arrays and the output encoders; numpy only, data +parsing only, and every client mistake raises :class:`InputError`, which the server maps to HTTP 400. This module +re-exports it with the Autoware input layout of this model bound as the default point fields, so ``model(points)`` +and ``POST /predict`` decode the same bytes into the same array. +""" +from __future__ import annotations + +from typing import Any, Mapping, Optional, Sequence + +from .ttaw import io as _io +from .ttaw.io import * # noqa: F401,F403 (the decoders and encoders: ttaw/API.md section 9) +from .ttaw.io import MAX_POINTS_DEFAULT, PointCloud + +__all__ = list(_io.__all__) # the same public names; the three below are rebound to this model's layout + +DEFAULT_POINT_FIELDS = tuple(["x", "y", "z", "intensity"]) # the Autoware input layout of this model + + +def decode_points(spec: Mapping[str, Any], *, max_points: int = MAX_POINTS_DEFAULT, max_bytes: Optional[int] = None, + default_fields: Sequence[str] = DEFAULT_POINT_FIELDS) -> PointCloud: + """The ``points`` envelope of ``/predict`` -> :class:`PointCloud` (``ttaw.io.decode_points`` with this model's + default fields).""" + return _io.decode_points(spec, max_points=max_points, max_bytes=max_bytes, default_fields=default_fields) + + +def load_points(source: Any, *, fields: Optional[Sequence[str]] = None, fmt: Optional[str] = None, + frame_id: str = "base_link", default_fields: Sequence[str] = DEFAULT_POINT_FIELDS) -> PointCloud: + """Python-API point input (path, bytes + ``fmt``, (N, C) array or tensor, structured array, envelope, + :class:`PointCloud`) -> :class:`PointCloud` (``ttaw.io.load_points`` with this model's default fields).""" + return _io.load_points(source, fields=fields, fmt=fmt, frame_id=frame_id, default_fields=default_fields) diff --git a/code/tt_frnet/reference/__init__.py b/code/tt_frnet/reference/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fd38899737adf94ca075d44fc76d2a76eb8b302f --- /dev/null +++ b/code/tt_frnet/reference/__init__.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: Apache-2.0 +"""CPU reference of FRNet (Autoware lidar_frnet) -- the ground truth every PCC / output-agreement gate compares +against (PLAN.md 0.3 item 1). It runs where ttnn is absent (the research venv) and never imports ttnn. + +- ``config.py`` the Autoware parameters this port reproduces (``ml_package_frnet_.param.yaml`` of the + weights, ``frnet.param.yaml`` node parameters) and the COMPILE constants, each with its source line; +- ``weights.py`` the deployed ONNX read as data with the vendored ``ttaw.weights.OnnxWeights`` (initializers by + consuming node; structure and every wiring assumption checked on the graph; BN folded in float64 + into the point layers, ``pre_norm`` kept as the host's scale / shift) -- the loader the TT port + uses too; +- ``model.py`` fp32 torch rebuild of the deployed graph with per-module taps (``ttaw.golden.TapRegistry``; names + in ``weights.MODULE_TAPS`` / ``OPT_IN_TAPS``), ported from research/frnet/scripts/frnet_torch_ref.py; +- ``rewrites.py`` the TT device dataflow (sorted + padded points, gather-form scatters with a zero sentinel row, the + exact log-step segmented max, K-split convs and point layers, interpolation-matrix up-sampling) + proven equal to ``model.py`` on the CPU, and a first-order precision emulation; +- ``ort.py`` ONNX Runtime golden of the deployed ONNX with matching intermediates (research / tests only: + onnxruntime is not in the image); +- ``pipeline.py`` the reference end to end: ``host/`` pre-processing -> torch / ORT / plan network -> ``host/`` + post-processing -> ``FRNetSegmentation``. + +Pre- and post-processing are not duplicated here: they live in ``tt_frnet.host`` (shared with the device path, so +TT and CPU see identical network inputs) and are tested against the research scripts. + +The reduced ``to_dict()`` of the ORT reference on a sample is stored per variant next to it as +``samples/synthetic_..reference.json``: ``server/smoke_test.py`` compares the served output with it. +""" diff --git a/code/tt_frnet/reference/config.py b/code/tt_frnet/reference/config.py new file mode 100644 index 0000000000000000000000000000000000000000..ee55b1ff4b5acdf114887e8896054acf28f39a78 --- /dev/null +++ b/code/tt_frnet/reference/config.py @@ -0,0 +1,284 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The Autoware ``lidar_frnet`` parameters this port reproduces, and the port's compile-time constants. + +Sources (autoware_universe main ``9ceaccf``, ``perception/autoware_lidar_frnet`` = ``PKG/``; weights +``AutowareFoundation/lidar_frnet@v2.0`` = commit ``a53a1c11``; research/frnet/SPEC.md sections 1-3): + +- per variant, from the weights repo: ``ml_package_frnet_.param.yaml`` (``fov_up_deg`` / ``fov_down_deg``, + ``frustum_width`` / ``frustum_height`` (the network grid), ``interpolation_width`` / ``interpolation_height`` (the + pre-processing grid), the 27 ``class_names``, the 81-int ``palette``, the ``num_points`` / ``num_unique_coors`` + [min, opt, max] profiles) and ``deploy_metadata.yaml`` (``version: v2.0``); +- node-wide, :data:`NODE_PARAMS` = ``PKG/config/frnet.param.yaml:3-16`` (filter threshold and classes, the filtered + cloud's output format, the ego crop box). + +Every validation the node does on these values is repeated here: palette size and 0-255 range +(``PKG/include/.../utils.hpp`` ``make_palette``), filter classes must be class names (``make_filter_class_indices``), +the crop box is enabled iff any float32 bound is non-zero (``PKG/src/lidar_frnet_node.cpp:51-66``). ``yaml`` is +imported when a file is read, never at module import; nothing here imports torch / onnx / ttnn. + +The two variants are an inseparable tuple (weights, frustum H x W baked into the ONNX, FOV, interpolation grid: +S:frnet:535-538): a user selects ``ot128`` / ``qt128`` only (LOAD), every number below follows from it. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any, Dict, Mapping, Optional, Sequence, Tuple + +import numpy as np + +__all__ = [ + "VARIANTS", "DEFAULT_VARIANT", "DEPLOY_VERSION", "ONNX_SHA256", "NODE_PARAMS", "NUM_CLASSES", "STRIDES", + "BLOCKS", "CROP_BOUND_KEYS", "FRNetConfig", "variant_files", "parse_crop_box", "parse_filter_classes", +] + +VARIANTS = ("ot128", "qt128") # PKG/launch/lidar_frnet.launch.xml:9-12 (sensor_model choices) +DEFAULT_VARIANT = "ot128" # the launch default (Hesai OT128) +DEPLOY_VERSION = "v2.0" # deploy_metadata.yaml of the pinned weights; Autoware's ansible pin (S:frnet:75-80) + +# sha256 of the deployed graphs at the pinned revision (S:frnet:84-93): a different file is refused. +ONNX_SHA256 = { + "ot128": "d29bf5950432360d800c22ff86a590d4d9d006acc501563ac611cfa33747c11f", + "qt128": "647af92790e3d591a19d065fe94d3ded797916ac9ffe5bf96b5e8b8324b31ad2", +} + +# PKG/config/frnet.param.yaml:3-16 (node parameters; RT-host in this port) +NODE_PARAMS: Dict[str, Any] = { + "class_probability_threshold": 0.05, + "filter_classes": ("drivable_surface",), + "output_format": "", + "ego_crop_box": {"reference_frame": "base_link", "min_x": -1.53, "min_y": -1.15, "min_z": -0.1, + "max_x": 6.1, "max_y": 1.15, "max_z": 3.1}, +} +CROP_BOUND_KEYS = ("min_x", "min_y", "min_z", "max_x", "max_y", "max_z") + +NUM_CLASSES = 27 # conv_seg Gemm 64 -> 27; ml_package class_names (COMPILE) +STRIDES = (1, 2, 4, 8) # AWML configs/model/frnet.py: backbone strides of layer1..4 (checked on the graph) +BLOCKS = (3, 4, 6, 3) # BasicBlocks per stage (checked on the graph) +POSTPROCESS_MAX_CLASSES = 64 # PKG/lib/postprocess/postprocess_kernel.cu:32 (const_palette[64]) +POSTPROCESS_MAX_FILTER_CLASSES = 16 # postprocess_kernel.cu:30 (const_filter_class_indices[16]) + + +def variant_files(weights_dir: Any, variant: str) -> Dict[str, Path]: + """The files of one variant in a weights directory (the flat HF repo layout).""" + if variant not in VARIANTS: + raise ValueError(f"unknown variant {variant!r}; choose one of {VARIANTS}") + root = Path(weights_dir) + return {"onnx": root / f"frnet_{variant}.onnx", "ml_package": root / f"ml_package_frnet_{variant}.param.yaml", + "deploy_metadata": root / "deploy_metadata.yaml"} + + +def _ros_params(path: Path) -> Dict[str, Any]: + import yaml + + with open(path) as f: + doc = yaml.safe_load(f) + try: + return doc["/**"]["ros__parameters"] + except (KeyError, TypeError): + raise ValueError(f"{path}: not a ROS 2 parameter file (no /**: ros__parameters)") from None + + +def _profile(values: Sequence[Any], name: str) -> Tuple[int, int, int]: + v = tuple(int(x) for x in values) + if len(v) != 3 or not (0 < v[0] <= v[1] <= v[2]): + raise ValueError(f"{name} must be [min, opt, max] with 0 < min <= opt <= max, got {list(values)}") + return v # type: ignore[return-value] + + +@dataclass(frozen=True) +class FRNetConfig: + """One variant's parameters. COMPILE values (device shapes): ``frustum_h`` x ``frustum_w``, ``n_cap``, + ``m_cap``, ``num_classes``; LOAD: the variant itself; RT-host: the node parameters (filter, crop box).""" + + variant: str + version: str + fov_up_deg: float + fov_down_deg: float + frustum_w: int + frustum_h: int + interp_w: int + interp_h: int + class_names: Tuple[str, ...] + palette: Tuple[int, ...] # 3 x num_classes ints, RGB per class + num_points: Tuple[int, int, int] # [min, opt, max] after interpolation (and of the raw input) + num_unique: Tuple[int, int, int] # [min, opt, max] unique frustum cells + class_probability_threshold: float = NODE_PARAMS["class_probability_threshold"] + filter_classes: Tuple[str, ...] = NODE_PARAMS["filter_classes"] + crop_bounds: Tuple[float, ...] = tuple(NODE_PARAMS["ego_crop_box"][k] for k in CROP_BOUND_KEYS) + crop_reference_frame: str = NODE_PARAMS["ego_crop_box"]["reference_frame"] + model_dir: Optional[str] = field(default=None, compare=False) + + # ---- construction -------------------------------------------------------------------------------------- + @classmethod + def from_model_dir(cls, model_dir: Any, variant: str = DEFAULT_VARIANT, *, + check_version: bool = True) -> "FRNetConfig": + """Read ``ml_package_frnet_.param.yaml`` (and check ``deploy_metadata.yaml``) of a weights + directory laid out as the HF repo.""" + files = variant_files(model_dir, variant) + p = _ros_params(files["ml_package"]) + version = DEPLOY_VERSION + if files["deploy_metadata"].is_file(): + import yaml + + meta = yaml.safe_load(files["deploy_metadata"].read_text()) or {} + version = str(meta.get("version", "")) + if check_version and version != DEPLOY_VERSION: + raise ValueError(f"deploy_metadata.yaml version {version!r} != the pinned {DEPLOY_VERSION!r}") + cfg = cls(variant=variant, version=version, fov_up_deg=float(p["fov_up_deg"]), + fov_down_deg=float(p["fov_down_deg"]), frustum_w=int(p["frustum_width"]), + frustum_h=int(p["frustum_height"]), interp_w=int(p["interpolation_width"]), + interp_h=int(p["interpolation_height"]), class_names=tuple(str(c) for c in p["class_names"]), + palette=tuple(int(v) for v in p["palette"]), num_points=_profile(p["num_points"], "num_points"), + num_unique=_profile(p["num_unique_coors"], "num_unique_coors"), model_dir=str(model_dir)) + cfg.validate() + return cfg + + def validate(self) -> None: + """The node's own checks (utils.hpp ``Dims2d``, ``make_palette``, ``make_filter_class_indices``).""" + for name in ("frustum_w", "frustum_h", "interp_w", "interp_h"): + if getattr(self, name) <= 0: + raise ValueError(f"{name} must be positive") + n = len(self.class_names) + if n == 0 or n > POSTPROCESS_MAX_CLASSES: + raise ValueError(f"{n} classes; the node supports 1..{POSTPROCESS_MAX_CLASSES}") + if len(self.palette) % 3: + raise ValueError("Palette size must be a multiple of 3.") + if len(self.palette) != 3 * n: + raise ValueError("Palette size does not match class names size.") + if any(v < 0 or v > 255 for v in self.palette): + raise ValueError("Color values must be within 0-255 range.") + self.filter_class_indices() + if len(self.crop_bounds) != 6: + raise ValueError("ego crop box needs 6 bounds (min_x, min_y, min_z, max_x, max_y, max_z)") + + def with_node_params(self, **overrides: Any) -> "FRNetConfig": + """A copy with other node parameters (``class_probability_threshold``, ``filter_classes``, + ``crop_bounds``); re-validated.""" + cfg = replace(self, **overrides) + cfg.validate() + return cfg + + # ---- derived values (each computed as the node computes it) --------------------------------------------- + @property + def num_classes(self) -> int: + return len(self.class_names) + + @property + def fov(self) -> Tuple[np.float32, np.float32, np.float32]: + """utils.hpp ``FieldOfView``: ``(up, down, total)`` radians, computed in double, stored as float32.""" + up = np.float32(np.pi * self.fov_up_deg / 180.0) + down = np.float32(np.pi * self.fov_down_deg / 180.0) + total = np.float32(np.pi * abs(self.fov_up_deg - self.fov_down_deg) / 180.0) + return up, down, total + + @property + def crop_bounds_f32(self) -> np.ndarray: + """``static_cast`` of the six bounds (lidar_frnet_node.cpp:53-58).""" + return np.asarray(self.crop_bounds, np.float64).astype(np.float32) + + @property + def crop_enabled(self) -> bool: + """Enabled iff any float32 bound is non-zero (lidar_frnet_node.cpp:59-66).""" + return bool(np.any(self.crop_bounds_f32 != 0.0)) + + def filter_class_indices(self, names: Optional[Sequence[str]] = None) -> Tuple[int, ...]: + """Indices of ``names`` (default: the configured filter classes) in ``class_names``; an unknown name raises + ``ValueError`` (the node throws ``Filter class '' not found in class names.``).""" + names = self.filter_classes if names is None else tuple(names) + if len(names) > POSTPROCESS_MAX_FILTER_CLASSES: + raise ValueError(f"at most {POSTPROCESS_MAX_FILTER_CLASSES} filter classes") + out = [] + for name in names: + if name not in self.class_names: + raise ValueError(f"Filter class '{name}' not found in class names.") + out.append(self.class_names.index(name)) + return tuple(out) + + @property + def palette_rgb(self) -> np.ndarray: + """``(num_classes, 3)`` uint8.""" + return np.asarray(self.palette, np.int64).reshape(-1, 3).astype(np.uint8) + + @property + def palette_packed(self) -> np.ndarray: + """utils.hpp ``make_palette``: ``(r << 16) | (g << 8) | b`` reinterpreted as float32 (the visualization + cloud's ``rgb`` field), ``(num_classes,)`` float32.""" + rgb = self.palette_rgb.astype(np.uint32) + return ((rgb[:, 0] << 16) | (rgb[:, 1] << 8) | rgb[:, 2]).astype(np.uint32).view(np.float32) + + @property + def n_cap(self) -> int: + """Point capacity of the device graph (COMPILE): the profile maximum, so every frame Autoware accepts fits + (functional port; smaller buckets are optimization work).""" + return self.num_points[2] + + @property + def m_cap(self) -> int: + """Frustum-cell (voxel) capacity of the device graph (COMPILE); the gather tables add one zero row.""" + return self.num_unique[2] + + @property + def frustum_cells(self) -> int: + return self.frustum_h * self.frustum_w + + def research_cfg(self) -> Dict[str, Any]: + """The same values in the layout of research/frnet/scripts/frnet_preproc_ref.py ``CFGS[variant]``.""" + return dict(name=self.variant, fov_up_deg=self.fov_up_deg, fov_down_deg=self.fov_down_deg, + frustum_w=self.frustum_w, frustum_h=self.frustum_h, interp_w=self.interp_w, + interp_h=self.interp_h, num_points=self.num_points, num_unique=self.num_unique) + + def describe(self) -> Dict[str, Any]: + return {"variant": self.variant, "version": self.version, "fov_deg": [self.fov_up_deg, self.fov_down_deg], + "frustum_hw": [self.frustum_h, self.frustum_w], "interpolation_hw": [self.interp_h, self.interp_w], + "num_points": list(self.num_points), "num_unique_coors": list(self.num_unique), + "num_classes": self.num_classes, "class_probability_threshold": self.class_probability_threshold, + "filter_classes": list(self.filter_classes), "ego_crop_box": dict(zip(CROP_BOUND_KEYS, + self.crop_bounds)), + "ego_crop_reference_frame": self.crop_reference_frame} + + +def parse_crop_box(value: Any) -> Optional[Tuple[float, ...]]: + """An ego crop box override: ``None`` / ``False`` (disabled = all-zero bounds, the node's own switch), six + numbers ``[min_x, min_y, min_z, max_x, max_y, max_z]``, or a mapping with those keys (missing keys keep + Autoware's defaults). Raises ``ValueError`` on anything else.""" + if value is None or value is False: + return (0.0,) * 6 + if isinstance(value, Mapping): + unknown = sorted(set(value) - set(CROP_BOUND_KEYS) - {"reference_frame"}) + if unknown: + raise ValueError(f"ego_crop_box: unknown keys {unknown}") + if value.get("reference_frame", "base_link") != "base_link": + raise ValueError("ego_crop_box.reference_frame must be base_link (the transform's target frame)") + base = NODE_PARAMS["ego_crop_box"] + vals = [value.get(k, base[k]) for k in CROP_BOUND_KEYS] + else: + vals = list(value) + if len(vals) != 6: + raise ValueError("ego_crop_box needs 6 numbers [min_x, min_y, min_z, max_x, max_y, max_z]") + try: + out = tuple(float(v) for v in vals) + except (TypeError, ValueError): + raise ValueError("ego_crop_box bounds must be numbers") from None + if not all(np.isfinite(out)): + raise ValueError("ego_crop_box bounds must be finite") + return out + + +_IDENT = re.compile(r"^[A-Za-z0-9_+\-]+$") + + +def parse_filter_classes(value: Any) -> Tuple[str, ...]: + """``filter_classes`` as a request gives it: a comma-separated string (``""`` = no filter class) or a list of + names. Names are checked against the class list by :meth:`FRNetConfig.filter_class_indices`.""" + if value is None: + return () + if isinstance(value, str): + names = [v.strip() for v in value.split(",") if v.strip()] + else: + names = [str(v).strip() for v in value] + bad = [n for n in names if not _IDENT.match(n)] + if bad: + raise ValueError(f"invalid filter class names {bad}") + return tuple(names) diff --git a/code/tt_frnet/reference/model.py b/code/tt_frnet/reference/model.py new file mode 100644 index 0000000000000000000000000000000000000000..856e595474e2f1b15deaa9589fbcb6de06fa15bb --- /dev/null +++ b/code/tt_frnet/reference/model.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +"""fp32 torch rebuild of the deployed FRNet graph (AWML ``frustum_encoder.py:76-137``, ``frnet_backbone.py:252-356``, +``frnet_head.py:52-70`` in deploy mode), with per-module taps. Ported from the verified research reference +``research/frnet/scripts/frnet_torch_ref.py`` (max |diff| vs ORT 1.6e-5 .. 3.0e-5 on the public goldens), on the +weights as the TT port holds them (``reference/weights.py``: point-layer BatchNorms folded, ``pre_norm`` as x * scale ++ shift). + +Input: the four ONNX inputs of one frame, **unpadded** (``host/preprocess.py`` ``NetworkInputs``); the graph sizes +its voxel tensors as ``max(inverse_map) + 1`` (S:frnet:255-262). Every sparse op is deterministic and follows ORT: + +- cell mean: index-add of the points / of ones, divided (``ScatterElements(add)`` x2 + ``Div``); +- per-cell max (``ScatterElements(max)``, init ``-FLT_MAX``): ``scatter_reduce(amax, include_self=True)``; +- frustum2pixel (``ScatterND`` into zeros, NHWC -> NCHW): at strides 2 / 4 / 8 several cells fall on one pixel + and ORT applies the updates sequentially, so the **last voxel row** wins (S:frnet:285-287); +- pixel2point (``Gather``): row ``(row // s) * (W // s) + col // s`` of the NHWC map; +- decoder: bilinear ``align_corners=True`` resize of the five maps to H x W, channel concat. + +Taps (``ttaw.golden.TapRegistry``; names in ``weights.MODULE_TAPS`` / ``OPT_IN_TAPS``): point tensors ``(N, C)``, +voxel tensors ``(M, C)``, maps ``(1, C, h, w)`` NCHW, all float32 numpy. ``forward`` returns ``pred_probs`` +``(N, 27)`` float32. torch is imported here (research venv / tt-env), never by ``api.py`` at module level. +""" +from __future__ import annotations + +from typing import Any, Optional + +import numpy as np +import torch +import torch.nn.functional as F + +from ..ttaw.golden import NULL_TAPS, TapRegistry +from .weights import Conv, FRNetWeights, Linear + +__all__ = ["FRNetTorch", "scatter_max", "frustum2pixel", "pixel2point", "TorchLayers"] + + +def _t(a: np.ndarray) -> torch.Tensor: + a = np.ascontiguousarray(a) + return torch.from_numpy(a if a.flags.writeable else a.copy()) + + +class TorchLayers: + """The weights as torch tensors (shared by the literal model and the rewrites).""" + + def __init__(self, weights: FRNetWeights): + self.w = weights + self.lin = {lin.name: (_t(lin.weight), _t(lin.bias), lin.relu) for lin in weights.linears} + self.conv = {c.name: (_t(c.weight), _t(c.bias), c.stride, c.padding, c.activation) for c in weights.convs} + self.pre_scale = _t(weights.pre_norm_scale) + self.pre_shift = _t(weights.pre_norm_shift) + + def linear(self, x: torch.Tensor, layer: Linear) -> torch.Tensor: + w, b, relu = self.lin[layer.name] + y = torch.addmm(b, x, w) + return F.relu(y) if relu else y + + def conv2d(self, x: torch.Tensor, layer: Conv, *, activation: bool = True) -> torch.Tensor: + w, b, stride, pad, act = self.conv[layer.name] + y = F.conv2d(x, w, b, stride=stride, padding=pad) + if activation and act == "hardswish": + y = F.hardswish(y) + elif activation and act == "sigmoid": + y = torch.sigmoid(y) + return y + + +def scatter_max(src: torch.Tensor, inverse_map: torch.Tensor, m: int) -> torch.Tensor: + """``ScatterElements(reduction=max)`` into ``-FLT_MAX`` rows (every row of a frame owns >= 1 point).""" + out = torch.full((m, src.shape[1]), torch.finfo(src.dtype).min, dtype=src.dtype) + return out.scatter_reduce(0, inverse_map.unsqueeze(-1).expand(-1, src.shape[1]), src, reduce="amax", + include_self=True) + + +def frustum2pixel(feats: torch.Tensor, voxel_coors: np.ndarray, hw: tuple, stride: int) -> torch.Tensor: + """``ScatterND`` of voxel rows into a zero ``(1, h, w, C)`` image at ``voxel_coors // stride`` (last voxel wins + on duplicates), returned NCHW.""" + H, W = hw + h, w = H // stride, W // stride + lin = (voxel_coors[:, 1] // stride) * w + voxel_coors[:, 2] // stride + last = np.full(h * w, -1, np.int64) + np.maximum.at(last, lin, np.arange(len(lin), dtype=np.int64)) + pix = np.flatnonzero(last >= 0) + img = torch.zeros(h * w, feats.shape[1], dtype=feats.dtype) + img[_t(pix)] = feats[_t(last[pix])] + return img.reshape(1, h, w, -1).permute(0, 3, 1, 2).contiguous() + + +def pixel2point(x: torch.Tensor, coors: np.ndarray, stride: int) -> torch.Tensor: + """``Gather`` of the NHWC rows of ``x`` (NCHW) at each point's cell ``// stride``.""" + _, c, h, w = x.shape + flat = x.permute(0, 2, 3, 1).reshape(h * w, c) + return flat[_t((coors[:, 1] // stride) * w + coors[:, 2] // stride)] + + +class FRNetTorch: + """``FRNetTorch(weights)(points, coors, voxel_coors, inverse_map, taps=TapRegistry())`` -> pred_probs.""" + + def __init__(self, weights: FRNetWeights, *, threads: Optional[int] = None): + if threads: + torch.set_num_threads(int(threads)) + self.weights = weights + self.layers = TorchLayers(weights) + + def __call__(self, *args: Any, **kwargs: Any) -> np.ndarray: + return self.forward(*args, **kwargs) + + @torch.no_grad() + def forward(self, points: np.ndarray, coors: np.ndarray, voxel_coors: np.ndarray, inverse_map: np.ndarray, + taps: TapRegistry = NULL_TAPS) -> np.ndarray: + W, L = self.weights, self.layers + hw = W.frustum_hw + m = int(inverse_map.max()) + 1 + voxel_coors = np.asarray(voxel_coors)[:m] + coors = np.asarray(coors) + pts = _t(np.asarray(points, np.float32)) + inv = _t(np.asarray(inverse_map, np.int64)) + + def tap(name: str, value: torch.Tensor) -> torch.Tensor: + if taps.wants(name): + taps.tap(name, value.numpy()) + return value + + # ---- FrustumFeatureEncoder + dist = torch.linalg.norm(pts[:, :3], dim=1, keepdim=True) + vsum = torch.zeros(m, 4).index_add_(0, inv, pts) + vcnt = torch.zeros(m, 4).index_add_(0, inv, torch.ones_like(pts)) + f_cluster = pts[:, :3] - (vsum / vcnt)[inv][:, :3] + x = torch.cat([pts, dist, f_cluster], 1) * L.pre_scale + L.pre_shift + tap("encoder.input", x) + pf = [] + for i, lin in enumerate(W.ffe): + x = L.linear(x, lin) + pf.append(tap(f"encoder.pf{i}", x)) + vmax = tap("encoder.voxel_max", scatter_max(x, inv, m)) + vfeat = tap("encoder.voxel_feats", L.linear(vmax, W.compression)) + + # ---- backbone: stem, point stem, fusion stem + img = tap("backbone.frustum", frustum2pixel(vfeat, voxel_coors, hw, 1)) + for c in W.stem: + img = L.conv2d(img, c) + tap("backbone.stem", img) + p = L.linear(torch.cat([pixel2point(img, coors, 1), pf[-1]], 1), W.point_stem) + tap("backbone.point_stem", p) + seg = tap("backbone.segmax0", scatter_max(p, inv, m)) + x = L.conv2d(torch.cat([frustum2pixel(seg, voxel_coors, hw, 1), img], 1), W.fusion_stem) + tap("backbone.fusion_stem", x) + maps, pts_out = [x], [p] + + # ---- four stages: BasicBlocks, point fusion, pixel fusion, attention + for st in W.stages: + li, s = st.index, st.stride + for bi, (c1, c2, ds) in enumerate(st.blocks): + y = L.conv2d(L.conv2d(x, c1), c2) + idt = L.conv2d(x, ds) if ds is not None else x + x = tap(f"backbone.layer{li}.block{bi}", F.hardswish(y + idt)) + tap(f"backbone.layer{li}", x) + p = L.linear(torch.cat([pixel2point(x, coors, s), p], 1), st.point_fusion) + tap(f"backbone.point_fusion{li}", p) + seg = tap(f"backbone.segmax{li}", scatter_max(p, inv, m)) + fuse = L.conv2d(torch.cat([frustum2pixel(seg, voxel_coors, hw, s), x], 1), st.pixel_fusion) + tap(f"backbone.pixel_fusion{li}", fuse) + att = tap(f"backbone.attention{li}", L.conv2d(L.conv2d(fuse, st.attention[0]), st.attention[1])) + x = tap(f"backbone.stage{li}", fuse * att + x) + maps.append(x) + pts_out.append(p) + + # ---- decoder (align-corners bilinear up-sampling to H x W, fuse convs) and the point branch + ups = [tap(f"decoder.up{k}", F.interpolate(mp, size=hw, mode="bilinear", align_corners=True)) + for k, mp in enumerate(maps)] + y = tap("decoder.fuse1", L.conv2d(torch.cat(ups, 1), W.fuse_layer1)) + y = tap("decoder.fuse2", L.conv2d(y, W.fuse_layer2)) + q = tap("decoder.point_fuse1", L.linear(torch.cat(pts_out, 1), W.point_fuse[0])) + q = tap("decoder.point_fuse2", L.linear(q, W.point_fuse[1])) + + # ---- FRHead + h = pixel2point(y, coors, 1) + for i, (lin, skip) in enumerate(zip(W.head_mlps, (q, pf[2], pf[1], pf[0]))): + h = tap(f"head.mlp{i}", L.linear(h, lin) + skip) + logits = tap("head.logits", L.linear(h, W.conv_seg)) + return tap("pred_probs", torch.softmax(logits, -1)).numpy() diff --git a/code/tt_frnet/reference/ort.py b/code/tt_frnet/reference/ort.py new file mode 100644 index 0000000000000000000000000000000000000000..d6f4c30a617f354d74d1114aa11b7ff10bdaf20a --- /dev/null +++ b/code/tt_frnet/reference/ort.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +"""ONNX Runtime golden: the deployed ``frnet_.onnx`` of AutowareFoundation/lidar_frnet@v2.0 run as-is (CPU +execution provider; re-runs are bit-identical, S:frnet:332) on the four unpadded network inputs, exactly as +the node binds them (``points`` float32 (N, 4), ``coors`` int64 (N, 3), ``voxel_coors`` int64 (M, 3), +``inverse_map`` int64 (N,)). + +With ``intermediates=True`` the in-memory graph also exposes the ONNX tensors that hold the same values as the torch +reference's taps (the mapping is ``FRNetWeights.onnx_taps``, recovered from the graph by ``reference/weights.py``), +so ``tests/test_reference_cpu.py`` compares module by module. Adding graph outputs changes nothing that is +computed. Choose which taps to expose with ``tap_patterns`` (fnmatch); OT128 module taps at 146k points are ~1 GB. + +onnxruntime is a research / test dependency only (not in the image): it is imported lazily, and the tests skip +where it is absent. +""" +from __future__ import annotations + +import fnmatch +from typing import Any, Dict, Optional, Sequence + +import numpy as np + +from ..ttaw.golden import NULL_TAPS, TapRegistry +from .config import variant_files +from .weights import MODULE_TAPS, FRNetWeights, load_weights + +__all__ = ["OrtFRNet", "onnxruntime_available"] + + +def onnxruntime_available() -> bool: + import importlib.util + + return importlib.util.find_spec("onnxruntime") is not None and importlib.util.find_spec("onnx") is not None + + +def _session(model_bytes: bytes, threads: int): + import onnxruntime as ort + + so = ort.SessionOptions() + so.intra_op_num_threads = int(threads) + so.inter_op_num_threads = 1 + so.log_severity_level = 3 + return ort.InferenceSession(model_bytes, so, providers=["CPUExecutionProvider"]) + + +def _with_outputs(path: Any, tensors: Sequence[str]) -> bytes: + """The ONNX model at ``path`` with ``tensors`` added as graph outputs (in memory).""" + import onnx + from onnx import TensorProto, helper + + model = onnx.load(str(path)) + existing = {o.name for o in model.graph.output} + for t in tensors: + if t not in existing: + model.graph.output.append(helper.make_tensor_value_info(t, TensorProto.FLOAT, None)) + existing.add(t) + return model.SerializeToString() + + +class OrtFRNet: + """The deployed graph of one variant under ONNX Runtime (module docstring).""" + + def __init__(self, weights_dir: Any, variant: str, *, threads: int = 2, intermediates: bool = False, + tap_patterns: Sequence[str] = MODULE_TAPS, weights: Optional[FRNetWeights] = None): + self.variant = variant + self.path = variant_files(weights_dir, variant)["onnx"] + self.weights = weights or load_weights(weights_dir, variant) + self.taps: Dict[str, str] = {} + if intermediates: + self.taps = {name: tensor for name, tensor in self.weights.onnx_taps.items() + if name != "pred_probs" and any(fnmatch.fnmatchcase(name, p) for p in tap_patterns)} + model_bytes = _with_outputs(self.path, list(self.taps.values())) + else: + model_bytes = self.path.read_bytes() + self.session = _session(model_bytes, threads) + self.output_names = [o.name for o in self.session.get_outputs()] + + def __call__(self, *args: Any, **kwargs: Any) -> np.ndarray: + return self.forward(*args, **kwargs) + + def forward(self, points: np.ndarray, coors: np.ndarray, voxel_coors: np.ndarray, inverse_map: np.ndarray, + taps: TapRegistry = NULL_TAPS) -> np.ndarray: + feeds = {"points": np.ascontiguousarray(points, np.float32), "coors": np.ascontiguousarray(coors, np.int64), + "voxel_coors": np.ascontiguousarray(voxel_coors, np.int64), + "inverse_map": np.ascontiguousarray(inverse_map, np.int64)} + outs = dict(zip(self.output_names, self.session.run(None, feeds))) + for name, tensor in self.taps.items(): + if taps.wants(name): + taps.tap(name, outs[tensor]) + probs = outs["pred_probs"] + taps.tap("pred_probs", probs) + return probs diff --git a/code/tt_frnet/reference/pipeline.py b/code/tt_frnet/reference/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..e5f1712da811ed715516b5e37536903d4748cd81 --- /dev/null +++ b/code/tt_frnet/reference/pipeline.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The CPU reference end to end: ``host/`` pre-processing -> network (torch fp32, ONNX Runtime, or the TT dataflow +of ``rewrites.py``) -> ``host/`` post-processing -> ``FRNetSegmentation``. The same host code feeds the device path +(``api.py``), so CPU and TT always see identical network inputs. + + ref = FRNetReference(weights_dir, "ot128", backend="torch", threads=4) + run = ref.run("samples/synthetic_ot128.npz", calibration={"preset": "synthetic_ot128"}) + run.output.to_dict() # the stored smoke reference (samples/..reference.json) +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Optional, Sequence + +import numpy as np + +from ..host.frame import FRNetPreprocessor, Prepared +from ..host.postprocess import FRNetPostprocessor, FRNetSegmentation +from ..host.preprocess import NetworkInputs, build_plan +from ..ttaw.golden import NULL_TAPS, TapRegistry +from .config import FRNetConfig +from .weights import MODULE_TAPS, FRNetWeights, check_variant_grid, load_weights + +__all__ = ["FRNetReference", "ReferenceRun", "BACKENDS"] + +BACKENDS = ("torch", "ort", "plan") +MODEL_NAME = "frnet-p150" + + +@dataclass +class ReferenceRun: + prepared: Prepared + pred_probs: Optional[np.ndarray] # (N, 27) float32, network-input order; None for a skipped frame + output: FRNetSegmentation + + +class FRNetReference: + """One variant's CPU reference (module docstring). ``backend``: ``torch`` (``model.py``, literal graph), + ``ort`` (the deployed ONNX; research / tests only), ``plan`` (``rewrites.py``, the TT dataflow; optionally with + an ``emulation``).""" + + def __init__(self, weights_dir: Any, variant: str, *, backend: str = "torch", threads: int = 4, + intermediates: bool = False, tap_patterns: Sequence[str] = MODULE_TAPS, + weights: Optional[FRNetWeights] = None, emulation: Any = None): + if backend not in BACKENDS: + raise ValueError(f"backend must be one of {BACKENDS}") + self.variant = variant + self.backend = backend + self.config = FRNetConfig.from_model_dir(weights_dir, variant) + self.weights = weights or load_weights(weights_dir, variant) + check_variant_grid(self.weights, self.config.frustum_h, self.config.frustum_w) + pre_norm = (self.weights.pre_norm_scale, self.weights.pre_norm_shift) + self.pre = FRNetPreprocessor(self.config, pre_norm=pre_norm, plan=backend == "plan") + self.post = FRNetPostprocessor(self.config) + if backend == "torch": + import torch + + from .model import FRNetTorch + + torch.set_num_threads(int(threads)) + self.net = FRNetTorch(self.weights) + elif backend == "ort": + from .ort import OrtFRNet + + self.net = OrtFRNet(weights_dir, variant, threads=threads, intermediates=intermediates, + tap_patterns=tap_patterns, weights=self.weights) + else: + import torch + + from .rewrites import FRNetPlanTorch + + torch.set_num_threads(int(threads)) + self.net = FRNetPlanTorch(self.weights, emulation=emulation) + + def network(self, inputs: NetworkInputs, *, plan: Any = None, taps: TapRegistry = NULL_TAPS) -> np.ndarray: + """``pred_probs`` (N, 27) of the network inputs (network-input point order).""" + if self.backend == "plan": + plan = plan if plan is not None else build_plan( + inputs, self.config, pre_norm=(self.weights.pre_norm_scale, self.weights.pre_norm_shift)) + return plan.unsort(self.net(plan, taps)) + return self.net(inputs.points, inputs.coors, inputs.voxel_coors, inputs.inverse_map, taps=taps) + + def run(self, points: Any, calibration: Optional[Mapping[str, Any]] = None, *, + params: Optional[Mapping[str, Any]] = None, taps: TapRegistry = NULL_TAPS, + model_name: str = MODEL_NAME) -> ReferenceRun: + prep = self.pre(points, calibration) + meta = {"variant": self.variant, "reference": self.backend, **prep.stats()} + if prep.skipped is not None: + out = self.post.empty(model=model_name, frame_id=prep.frame_id, meta=meta) + return ReferenceRun(prep, None, out) + probs = self.network(prep.inputs, plan=prep.plan, taps=taps) + out = self.post(probs, points=prep.inputs.points, num_points_raw=prep.inputs.num_points_raw, + input_index=prep.input_index, params=dict(params or {}), model=model_name, + frame_id=prep.frame_id, meta=meta) + return ReferenceRun(prep, probs, out) diff --git a/code/tt_frnet/reference/rewrites.py b/code/tt_frnet/reference/rewrites.py new file mode 100644 index 0000000000000000000000000000000000000000..29a8542d44bd0fe81f9855ed8ccfd12e73e96a20 --- /dev/null +++ b/code/tt_frnet/reference/rewrites.py @@ -0,0 +1,275 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The TT device dataflow of FRNet (PORT_LOG.md "trace layout"), run on the CPU in fp32 and proven equal to the +literal graph (``reference/model.py``) by ``tests/test_reference_rewrites_host.py``. Each rewrite is exact in real +arithmetic; in float32 they differ only by summation order: + +- **points sorted by cell and padded** to ``N_cap`` (``host/preprocess.py`` ``FramePlan``): the network is + permutation-equivariant in the points; padded rows form one trailing sentinel segment whose results nothing reads; +- **host features**: the 8-d decoration and ``pre_norm`` in float64, one rounding (``decorate_points``); +- **segmented max** (6 sites: ``ScatterElements(max)``) as the exact **log-step** form of PLAN.md 1.3 (the K1 + kernel's stock-op fallback): round ``r`` takes ``x = max(x, x[idx_r])`` with the host shift table ``idx_r``, + then one gather of each segment's last row (``seg_last``) -> ``(M_cap, C)``; :func:`segmax_logstep`; +- **frustum2pixel** (6 ``ScatterND``) as a gather: ``image[pixel] = table[pix2vox_s[pixel]]`` from the voxel table + with one appended **zero row** (index ``M_cap``) for empty pixels -- ``ttaw.ops.gather.scatter_rows`` semantics + (C19); duplicates at s > 1 resolved on the host (last voxel wins); +- **pixel2point** as a gather of NHWC rows (``point2pix_s``); +- **concat-free convs**: ``conv(concat(a, b))`` = ``conv_a(a) + conv_b(b)`` for the fusion convs (256 = 128 + 128) + and the 5-way K-split ``fuse_layer1`` (640 = 5 x 128, probe P14: 8.3 vs 14.2 ms); +- **concat-free point layers**: ``linear(concat(a, b))`` = ``a @ W_a + b @ W_b + bias`` (point stem, point fusion, + ``point_fuse_layer1``), which also lets the two halves keep different dtypes; +- **align-corners bilinear** as interpolation matrices ``A_h X A_w^T`` (C18 ``interp_matrix``, float64 -> float32). + +:class:`Emulation` optionally rounds layer operands the way the device stores them (bf16, or the TF32-like +mantissa of the device's fp32 matmul, probe P12) -- a first-order CPU model to size the fp32 islands of the +port before the device runs (``code/scripts/precision_study.py``); it models no math-fidelity phases or tile +layouts, so its numbers are indicative only. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Sequence + +import numpy as np +import torch +import torch.nn.functional as F + +from ..host.preprocess import FramePlan, segmax_rounds, segmax_shift_tables +from ..ttaw.golden import NULL_TAPS, TapRegistry +from ..ttaw.ops.upsample import interp_matrix +from .model import TorchLayers, _t +from .weights import Conv, FRNetWeights, Linear + +__all__ = ["Emulation", "round_mode", "segmax_logstep", "FRNetPlanTorch", "plan_taps_to_literal"] + +MODES = ("fp32", "bf16", "tf32") + + +def round_mode(x: torch.Tensor, mode: str) -> torch.Tensor: + """``fp32``: unchanged; ``bf16``: round to nearest even bfloat16; ``tf32``: truncate to 10 mantissa bits (the + probe P12 truncation model of the device's fp32 matmul operands; the device measured 1.4-1.75x its error).""" + if mode == "fp32": + return x + if mode == "bf16": + return x.to(torch.bfloat16).to(torch.float32) + if mode == "tf32": + return (x.contiguous().view(torch.int32) & ~0x1FFF).view(torch.float32) + raise ValueError(f"unknown rounding mode {mode!r}; one of {MODES}") + + +@dataclass(frozen=True) +class Emulation: + """Operand rounding per site (``fp32`` = exact float32). ``conv_act`` / ``conv_weight``: inputs and weights + (+ bias) of the 53 convs; ``point_act`` / ``point_weight``: inputs and weights of the 17 point / voxel linears; + ``gather``: rows produced by the embedding gathers (frustum2pixel, pixel2point: ``ttnn.embedding`` tables are + bf16-only, so ``bf16`` is the stock-op device); ``upsample``: the decoder's interpolation operands. ``overrides``: + per-layer modes by layer name (e.g. ``{"head.logits": ("tf32", "fp32")}``: (act, weight)).""" + + conv_act: str = "fp32" + conv_weight: str = "fp32" + point_act: str = "fp32" + point_weight: str = "fp32" + gather: str = "fp32" + upsample: str = "fp32" + overrides: Dict[str, tuple] = field(default_factory=dict) + + @classmethod + def parse(cls, spec: str) -> "Emulation": + """``"conv_act=bf16,point_act=tf32,gather=bf16"`` (unset sites stay fp32).""" + kw = {} + for part in filter(None, (p.strip() for p in spec.split(","))): + k, _, v = part.partition("=") + if k not in cls.__dataclass_fields__ or k == "overrides" or v not in MODES: + raise ValueError(f"bad emulation item {part!r}") + kw[k] = v + return cls(**kw) + + @property + def exact(self) -> bool: + return all(getattr(self, k) == "fp32" for k in ("conv_act", "conv_weight", "point_act", "point_weight", + "gather", "upsample")) and not self.overrides + + def label(self) -> str: + items = [f"{k}={getattr(self, k)}" for k in ("conv_act", "conv_weight", "point_act", "point_weight", + "gather", "upsample") if getattr(self, k) != "fp32"] + items += [f"{k}:{v}" for k, v in sorted(self.overrides.items())] + return ",".join(items) or "fp32" + + +def segmax_logstep(x: torch.Tensor, plan: FramePlan, rounds: Optional[int] = None) -> torch.Tensor: + """Per-voxel max of the sorted, padded rows ``x`` ``(N_cap, C)`` -> ``(M_cap, C)``: ``rounds`` (default: just + enough for the frame's longest segment) log-step rounds over the host shift tables, then a gather of every + segment's last row (PLAN.md 1.3). Exact (max is order-independent).""" + rounds = segmax_rounds(plan.max_segment_length) if rounds is None else int(rounds) + for idx in segmax_shift_tables(plan.seg_id, rounds): + x = torch.maximum(x, x[_t(idx.astype(np.int64))]) + return x[_t(plan.seg_last.astype(np.int64))] + + +def _with_zero_row(table: torch.Tensor) -> torch.Tensor: + return torch.cat([table, table.new_zeros(1, table.shape[1])], 0) + + +def _nhwc(x: torch.Tensor) -> torch.Tensor: + _, c, h, w = x.shape + return x.permute(0, 2, 3, 1).reshape(h * w, c) + + +def _nchw(rows: torch.Tensor, h: int, w: int) -> torch.Tensor: + return rows.reshape(1, h, w, -1).permute(0, 3, 1, 2).contiguous() + + +class FRNetPlanTorch: + """``FRNetPlanTorch(weights)(plan, taps)`` -> ``pred_probs`` of the sorted, padded rows ``(N_cap, 27)`` + (``plan.unsort`` gives the network-input order). Taps use the names of ``reference/model.py``; point taps are in + sorted padded order, voxel taps have ``M_cap`` rows (:func:`plan_taps_to_literal` aligns them).""" + + def __init__(self, weights: FRNetWeights, *, emulation: Optional[Emulation] = None, + rounds: Optional[int] = None): + self.weights = weights + self.layers = TorchLayers(weights) + self.emu = emulation or Emulation() + self.rounds = rounds + H, W = weights.frustum_hw + self._interp = {} + for st in [1] + [s.stride for s in weights.stages]: + ah = interp_matrix(H // st, H, mode="linear", align_corners=True) + aw = interp_matrix(W // st, W, mode="linear", align_corners=True) + self._interp[st] = (_t(ah.astype(np.float32)), _t(aw.astype(np.float32))) + # K-split weights of the concat-consuming layers + self._split_lin = {} + for lin, parts in ((weights.point_stem, (128, 256)), (weights.point_fuse[0], (128,) * 5), + *((st.point_fusion, (128, 128)) for st in weights.stages)): + w_t, b_t, _ = self.layers.lin[lin.name] + if w_t.shape[0] != sum(parts): + raise ValueError(f"{lin.name}: {w_t.shape[0]} inputs, expected {sum(parts)}") + self._split_lin[lin.name] = (list(torch.split(w_t, list(parts), 0)), b_t) + self._split_conv = {} + for conv, parts in ((weights.fusion_stem, (128, 128)), (weights.fuse_layer1, (128,) * 5), + *((st.pixel_fusion, (128, 128)) for st in weights.stages)): + w_t, b_t, stride, pad, act = self.layers.conv[conv.name] + if w_t.shape[1] != sum(parts): + raise ValueError(f"{conv.name}: {w_t.shape[1]} inputs, expected {sum(parts)}") + self._split_conv[conv.name] = (list(torch.split(w_t, list(parts), 1)), b_t, stride, pad, act) + + # ---- rounded layer applications --------------------------------------------------------------------------- + def _modes(self, name: str, act: str, weight: str) -> tuple: + return self.emu.overrides.get(name, (act, weight)) + + def linear(self, parts: Sequence[torch.Tensor], layer: Linear) -> torch.Tensor: + act_m, w_m = self._modes(layer.name, self.emu.point_act, self.emu.point_weight) + if layer.name in self._split_lin: + ws, b = self._split_lin[layer.name] + else: + w, b, _ = self.layers.lin[layer.name] + ws = [w] + if len(parts) != len(ws): + raise ValueError(f"{layer.name}: {len(parts)} input parts for {len(ws)} weight parts") + y = round_mode(b, w_m).expand(parts[0].shape[0], -1).clone() + for x, w in zip(parts, ws): + y = y + round_mode(x, act_m) @ round_mode(w, w_m) + return F.relu(y) if layer.relu else y + + def conv(self, parts: Sequence[torch.Tensor], layer: Conv) -> torch.Tensor: + act_m, w_m = self._modes(layer.name, self.emu.conv_act, self.emu.conv_weight) + if layer.name in self._split_conv: + ws, b, stride, pad, act = self._split_conv[layer.name] + else: + w, b, stride, pad, act = self.layers.conv[layer.name] + ws = [w] + if len(parts) != len(ws): + raise ValueError(f"{layer.name}: {len(parts)} input parts for {len(ws)} weight parts") + y = None + for i, (x, w) in enumerate(zip(parts, ws)): + part = F.conv2d(round_mode(x, act_m), round_mode(w, w_m), round_mode(b, w_m) if i == 0 else None, + stride=stride, padding=pad) + y = part if y is None else y + part + if act == "hardswish": + y = F.hardswish(y) + elif act == "sigmoid": + y = torch.sigmoid(y) + return y + + def gather(self, table: torch.Tensor, index: np.ndarray) -> torch.Tensor: + return round_mode(table, self.emu.gather)[_t(index.astype(np.int64))] + + def upsample(self, x: torch.Tensor, stride: int) -> torch.Tensor: + if stride == 1: + return x + ah, aw = self._interp[stride] + m = self.emu.upsample + x = round_mode(x, m) + return torch.einsum("hi,bcij,wj->bchw", round_mode(ah, m), x, round_mode(aw, m)) + + # ---- the device graph ------------------------------------------------------------------------------------- + def __call__(self, plan: FramePlan, taps: TapRegistry = NULL_TAPS) -> np.ndarray: + return self.forward(plan, taps) + + @torch.no_grad() + def forward(self, plan: FramePlan, taps: TapRegistry = NULL_TAPS) -> np.ndarray: + Wt = self.weights + H, W = plan.frustum_hw + if (H, W) != Wt.frustum_hw: + raise ValueError(f"plan grid {plan.frustum_hw} != weights grid {Wt.frustum_hw}") + + def tap(name: str, value: torch.Tensor) -> torch.Tensor: + if taps.wants(name): + taps.tap(name, value.numpy()) + return value + + def to_image(table: torch.Tensor, s: int) -> torch.Tensor: + return _nchw(self.gather(_with_zero_row(table), plan.pix2vox[s]), H // s, W // s) + + def to_points(img: torch.Tensor, s: int) -> torch.Tensor: + return self.gather(_nhwc(img), plan.point2pix[s]) + + x = tap("encoder.input", _t(plan.features)) + pf: List[torch.Tensor] = [] + for i, lin in enumerate(Wt.ffe): + x = self.linear([x], lin) + pf.append(tap(f"encoder.pf{i}", x)) + vmax = tap("encoder.voxel_max", segmax_logstep(x, plan, self.rounds)) + vfeat = tap("encoder.voxel_feats", self.linear([vmax], Wt.compression)) + img = tap("backbone.frustum", to_image(vfeat, 1)) + for c in Wt.stem: + img = self.conv([img], c) + tap("backbone.stem", img) + p = tap("backbone.point_stem", self.linear([to_points(img, 1), pf[-1]], Wt.point_stem)) + seg = tap("backbone.segmax0", segmax_logstep(p, plan, self.rounds)) + x = tap("backbone.fusion_stem", self.conv([to_image(seg, 1), img], Wt.fusion_stem)) + maps, pts_out = [x], [p] + for st in Wt.stages: + li, s = st.index, st.stride + for bi, (c1, c2, ds) in enumerate(st.blocks): + y = self.conv([self.conv([x], c1)], c2) + idt = self.conv([x], ds) if ds is not None else x + x = tap(f"backbone.layer{li}.block{bi}", F.hardswish(y + idt)) + tap(f"backbone.layer{li}", x) + p = tap(f"backbone.point_fusion{li}", self.linear([to_points(x, s), p], st.point_fusion)) + seg = tap(f"backbone.segmax{li}", segmax_logstep(p, plan, self.rounds)) + fuse = tap(f"backbone.pixel_fusion{li}", self.conv([to_image(seg, s), x], st.pixel_fusion)) + att = tap(f"backbone.attention{li}", self.conv([self.conv([fuse], st.attention[0])], st.attention[1])) + x = tap(f"backbone.stage{li}", fuse * att + x) + maps.append(x) + pts_out.append(p) + strides = [1] + [st.stride for st in Wt.stages] + ups = [tap(f"decoder.up{k}", self.upsample(mp, s)) for k, (mp, s) in enumerate(zip(maps, strides))] + y = tap("decoder.fuse1", self.conv(ups, Wt.fuse_layer1)) + y = tap("decoder.fuse2", self.conv([y], Wt.fuse_layer2)) + q = tap("decoder.point_fuse1", self.linear(pts_out, Wt.point_fuse[0])) + q = tap("decoder.point_fuse2", self.linear([q], Wt.point_fuse[1])) + h = to_points(y, 1) + for i, (lin, skip) in enumerate(zip(Wt.head_mlps, (q, pf[2], pf[1], pf[0]))): + h = tap(f"head.mlp{i}", self.linear([h], lin) + skip) + logits = tap("head.logits", self.linear([h], Wt.conv_seg)) + return tap("pred_probs", torch.softmax(logits, -1)).numpy() + + +def plan_taps_to_literal(name: str, value: np.ndarray, plan: FramePlan) -> np.ndarray: + """A tap of :class:`FRNetPlanTorch` in the layout of ``reference/model.py``: point tensors (``N_cap`` rows) + unsorted to the network-input order, voxel tensors (``M_cap`` rows) cut to ``M``, maps unchanged.""" + v = np.asarray(value) + if v.ndim == 2 and v.shape[0] == plan.n_cap: + return plan.unsort(v) + if v.ndim == 2 and v.shape[0] == plan.m_cap: + return v[: plan.m] + return v diff --git a/code/tt_frnet/reference/weights.py b/code/tt_frnet/reference/weights.py new file mode 100644 index 0000000000000000000000000000000000000000..a0c73d895f26b989d2df1d8f00339c4541eae3fa --- /dev/null +++ b/code/tt_frnet/reference/weights.py @@ -0,0 +1,437 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The deployed ``frnet_.onnx`` of AutowareFoundation/lidar_frnet@v2.0 read as DATA, the way the TT port +reads it: the vendored ``ttaw.weights.OnnxWeights`` (initializers addressed by the node that consumes them; nothing +is executed). 120 of the 186 initializer *names* differ between the OT128 and QT128 exports (``onnx::Conv_1487`` vs +``onnx::Conv_1485``, S:frnet:98-101), node names do not, so every weight is found from its node. + +Where BatchNorm is folded (float64, one final rounding to float32; ``ttaw.weights.fold_bn_linear``): + +- every point layer ``MatMul (no bias) -> BatchNormalization(eps 1e-3) -> Relu`` (``ffe_layers.0-2``, + ``point_stem``, ``point_fusion_layers.0-3``, ``point_fuse_layer1-2``, ``mlps.0-3``) becomes one + ``y = relu(x @ W' + b')`` -- exactly the port's ``ttnn.linear(..., bias, activation="relu")``; +- ``pre_norm`` (BatchNorm1d over the 8 decorated features) is kept as ``(scale, shift)`` float32 (``x * scale + + shift``): the port applies it on the host with the feature decoration (PLAN.md 2.8), so the first device layer is + a plain linear; +- the 53 convs already carry the exporter's BN fold (Conv with bias, S:frnet:245); the three Gemms + (``ffe_layers.3``, ``compression_layers.0``, ``conv_seg``) have their own bias. + +The structure is recovered from the graph, never re-typed: stage count, BasicBlocks per stage, strides, the +frustum grid of every frustum2pixel (the ``ConstantOfShape`` shape of its ``ScatterND``), every activation (the +conv's consumer), and every wiring assumption of the reference model (concat orders, skip connections, the +decoder's resize inputs, the head's residual skips, the Softmax axis) is asserted on the graph +(:func:`load_weights` raises ``ValueError`` when one does not hold). Each layer records the ONNX tensor that holds +its output, so ``reference/ort.py`` exposes the same taps from ONNX Runtime. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from ..ttaw.weights import BatchNorm, OnnxWeights, file_sha256, fold_bn_linear +from .config import BLOCKS, NUM_CLASSES, ONNX_SHA256, STRIDES, variant_files + +__all__ = ["Linear", "Conv", "Stage", "FRNetWeights", "load_weights", "check_variant_grid", "MODULE_TAPS", + "OPT_IN_TAPS"] + +VE = "/model/voxel_encoder" +BB = "/model/backbone" + + +@dataclass(frozen=True) +class Linear: + """``y = act(x @ weight + bias)``; ``weight`` (in, out) float32 (BN folded where the graph has one).""" + + name: str + weight: np.ndarray + bias: np.ndarray + relu: bool + onnx_node: str + onnx_output: str # ONNX tensor equal to this layer's output (after the ReLU when there is one) + bn_node: Optional[str] = None + + @property + def in_features(self) -> int: + return int(self.weight.shape[0]) + + @property + def out_features(self) -> int: + return int(self.weight.shape[1]) + + +@dataclass(frozen=True) +class Conv: + """``y = act(conv2d(x, weight, bias, stride, padding))`` (NCHW, BN already folded by the exporter).""" + + name: str + weight: np.ndarray # (Cout, Cin, k, k) float32 + bias: np.ndarray # (Cout,) float32 + stride: int + padding: int + activation: Optional[str] # "hardswish" | "sigmoid" | None (a residual add follows) + onnx_node: str + onnx_output: str # ONNX tensor equal to the output (after the activation when there is one) + + @property + def kernel(self) -> int: + return int(self.weight.shape[-1]) + + @property + def in_channels(self) -> int: + return int(self.weight.shape[1]) + + @property + def out_channels(self) -> int: + return int(self.weight.shape[0]) + + +@dataclass(frozen=True) +class Stage: + """One backbone stage ``layer`` (1-based) plus its point / pixel fusion (``*_layers.``).""" + + index: int + stride: int # cumulative stride of the stage output (1, 2, 4, 8) + blocks: Tuple[Tuple[Conv, Conv, Optional[Conv]], ...] # (conv1 + HardSwish, conv2, downsample) per BasicBlock + block_outputs: Tuple[str, ...] # ONNX tensor of each block output (after add + HardSwish) + point_fusion: Linear # [pixel2point(x_s), p] 256 -> 128 + BN + ReLU + pixel_fusion: Conv # [frustum2pixel(segmax(p)), x_s] 256 -> 128 + HardSwish + attention: Tuple[Conv, Conv] # 128 -> 128 + HardSwish, 128 -> 128 + Sigmoid + segmax_output: str # ONNX tensor: per-voxel max of point_fusion's output (M, 128) + output: str # ONNX tensor: x = pixel_fusion * attention + x_s + + +@dataclass(frozen=True) +class FRNetWeights: + variant: str + frustum_hw: Tuple[int, int] + strides: Tuple[int, ...] + pre_norm: BatchNorm + pre_norm_scale: np.ndarray # (8,) float32: pre_norm(x) == x * scale + shift + pre_norm_shift: np.ndarray + ffe: Tuple[Linear, ...] # 8 -> 64 -> 128 -> 256 (ReLU, BN folded), 256 -> 256 (Gemm, no activation) + compression: Linear # 256 -> 16 + ReLU (on the per-voxel max) + stem: Tuple[Conv, ...] # 16 -> 64 -> 128 -> 128, each + HardSwish + point_stem: Linear # [pixel2point(stem), pf3] 384 -> 128 + BN + ReLU + point_stem_segmax: str # ONNX tensor of the per-voxel max of point_stem (M, 128) + fusion_stem: Conv # [frustum2pixel(segmax(p0)), stem] 256 -> 128 + HardSwish + stages: Tuple[Stage, ...] + fuse_layer1: Conv # concat of the 5 up-sampled maps 640 -> 256 + HardSwish + fuse_layer2: Conv # 256 -> 128 + HardSwish + point_fuse: Tuple[Linear, Linear] # 640 -> 256, 256 -> 128 (+ BN + ReLU) + head_mlps: Tuple[Linear, ...] # 128 -> 128, 128 -> 256, 256 -> 128, 128 -> 64 (+ BN + ReLU), then + skip + conv_seg: Linear # 64 -> 27 + onnx_taps: Dict[str, str] # tap name -> ONNX tensor (module and opt-in taps) + onnx_path: str = "" + onnx_sha256: str = "" + raw_parameters: int = 0 # learned values in the ONNX initializers + folded_parameters: int = field(default=0) # values after the BN folds (what the device holds) + + @property + def convs(self) -> List[Conv]: + out = list(self.stem) + [self.fusion_stem] + for st in self.stages: + for c1, c2, ds in st.blocks: + out += [c1, c2] + ([ds] if ds is not None else []) + out += [st.pixel_fusion, *st.attention] + return out + [self.fuse_layer1, self.fuse_layer2] + + @property + def linears(self) -> List[Linear]: + return [*self.ffe, self.compression, self.point_stem, *(s.point_fusion for s in self.stages), + *self.point_fuse, *self.head_mlps, self.conv_seg] + + @property + def num_classes(self) -> int: + return self.conv_seg.out_features + + +# Module taps of the reference (tests/test_reference_cpu.py compares every one with ORT; the TT port's per-module +# gates use the same names). 1-based stage numbers: stage l = ONNX layer + *_layers.. +MODULE_TAPS: Tuple[str, ...] = ( + "encoder.input", "encoder.pf0", "encoder.pf1", "encoder.pf2", "encoder.pf3", "encoder.voxel_max", + "encoder.voxel_feats", "backbone.frustum", "backbone.stem", "backbone.point_stem", "backbone.fusion_stem", + *(f"backbone.{kind}{i}" for i in range(1, 5) + for kind in ("layer", "point_fusion", "pixel_fusion", "attention", "stage")), + "decoder.fuse1", "decoder.fuse2", "decoder.point_fuse1", "decoder.point_fuse2", + *(f"head.mlp{i}" for i in range(4)), "head.logits", "pred_probs", +) +# Opt-in taps (TapRegistry include patterns): every BasicBlock output, the per-voxel max of every point branch +# (the K1 / segmented-max sites), the five up-sampled decoder inputs. +OPT_IN_TAPS: Tuple[str, ...] = ( + "backbone.layer*.block*", "backbone.segmax*", "decoder.up*", +) + + +def _fail(msg: str) -> None: + raise ValueError(f"frnet ONNX structure: {msg}") + + +def _single(w: OnnxWeights, tensor: str, op_type: str) -> Any: + try: + return w.consumer_of(tensor, op_type) + except KeyError as e: + _fail(str(e)) + + +def _point_layer(w: OnnxWeights, matmul: str, name: str) -> Linear: + mm = w.node(matmul) + if mm.op_type != "MatMul" or not w.has(mm.inputs[1]) or w.has(mm.inputs[0]): + _fail(f"{matmul} is not x @ W with a constant W") + bn = _single(w, mm.outputs[0], "BatchNormalization") + relu = _single(w, bn.outputs[0], "Relu") + wf, bf = fold_bn_linear(w.matmul_weight(matmul), None, w.batchnorm(bn.name), layout="in_out") + return Linear(name, wf, bf, True, mm.name, relu.outputs[0], bn.name) + + +def _gemm(w: OnnxWeights, node: str, name: str) -> Linear: + g = w.gemm(node) + if g.trans_a or g.alpha != 1.0 or g.beta != 1.0 or g.bias is None: + _fail(f"{node}: unexpected Gemm attributes") + weight = np.asarray(g.weight, np.float32) + weight = weight.T if g.trans_b else weight + out = w.node(node).outputs[0] + relus = [c for c in w.consumers(out) if c.op_type == "Relu"] + relu = len(relus) == 1 and len(w.consumers(out)) == 1 + return Linear(name, np.ascontiguousarray(weight), np.asarray(g.bias, np.float32), relu, node, + relus[0].outputs[0] if relu else out) + + +def _conv(w: OnnxWeights, node: str, name: str) -> Conv: + c = w.conv(node) + k = c.kernel_shape + if (c.group != 1 or set(c.dilations) != {1} or len(k) != 2 or k[0] != k[1] or len(set(c.strides)) != 1 + or len(set(c.pads)) != 1 or c.bias is None or c.auto_pad not in ("NOTSET", "")): + _fail(f"{node}: unexpected Conv attributes {c.kernel_shape} {c.strides} {c.pads} {c.dilations} {c.group}") + out = w.node(node).outputs[0] + cons = w.consumers(out) + act, tensor = None, out + if len(cons) == 1 and cons[0].op_type in ("HardSwish", "Sigmoid"): + act, tensor = cons[0].op_type.lower(), cons[0].outputs[0] + return Conv(name, np.asarray(c.weight, np.float32), np.asarray(c.bias, np.float32), int(c.strides[0]), + int(c.pads[0]), act, node, tensor) + + +def _producer_op(w: OnnxWeights, tensor: str) -> str: + p = w.producer(tensor) + return p.op_type if p is not None else "" + + +def _scatter_max_of(w: OnnxWeights, tensor: str) -> str: + found = [c for c in w.consumers(tensor) if c.op_type == "ScatterElements" and c.attrs.get("reduction") == "max"] + if len(found) != 1: + _fail(f"{tensor} does not feed exactly one ScatterElements(max)") + return found[0].outputs[0] + + +def load_weights(model_dir: Any, variant: str, *, check_sha256: bool = True) -> FRNetWeights: + """Parse ``frnet_.onnx`` of a weights directory (module docstring).""" + path = variant_files(model_dir, variant)["onnx"] + digest = file_sha256(path) + if check_sha256 and digest != ONNX_SHA256[variant]: # refused before it is parsed + raise ValueError(f"{path.name}: sha256 {digest} is not the pinned v2.0 file {ONNX_SHA256[variant]}") + w = OnnxWeights(path) + if w.input_names != ("points", "coors", "voxel_coors", "inverse_map") or w.output_names != ("pred_probs",): + _fail(f"inputs {w.input_names} / outputs {w.output_names}") + taps: Dict[str, str] = {} + + # ---- frustum grids of the six frustum2pixel (ScatterND) sites, in graph order + grids = [] + for sc in w.nodes("ScatterND"): + shape_node = w.producer(w.producer(sc.inputs[0]).inputs[0]) + dims = [w.array(t).tolist() for t in shape_node.inputs[:3] if w.has(t)] + if len(dims) != 3 or dims[0] != [1]: + _fail(f"{sc.name}: cannot read its (1, H, W, C) image shape") + grids.append((int(dims[1][0]), int(dims[2][0]))) + if len(grids) != 2 + len(STRIDES): + _fail(f"{len(grids)} ScatterND sites, expected {2 + len(STRIDES)}") + H, W = grids[0] + strides = tuple(H // h for h, _ in grids[2:]) + if grids[1] != (H, W) or strides != STRIDES or any(W // s != g[1] or H // s != g[0] + for s, g in zip(strides, grids[2:])): + _fail(f"frustum grids {grids} do not follow strides {STRIDES}") + + # ---- encoder + bn_pre = w.find_node(f"{VE}/pre_norm/*", "BatchNormalization") + pre = w.batchnorm(bn_pre.name) + taps["encoder.input"] = bn_pre.outputs[0] + ffe = [_point_layer(w, f"{VE}/ffe_layers.{i}/ffe_layers.{i}.0/MatMul", f"encoder.ffe{i}") for i in range(3)] + for i in range(1, 3): + if w.node(ffe[i].onnx_node).inputs[0] != ffe[i - 1].onnx_output: + _fail(f"ffe_layers.{i} does not consume ffe_layers.{i - 1}") + if w.node(ffe[0].onnx_node).inputs[0] != bn_pre.outputs[0]: + _fail("ffe_layers.0 does not consume pre_norm") + ffe3 = _gemm(w, f"{VE}/ffe_layers.3/Gemm", "encoder.ffe3") + if ffe3.relu or w.node(ffe3.onnx_node).inputs[0] != ffe[2].onnx_output: + _fail("ffe_layers.3 is not a plain Gemm on ffe_layers.2") + ffe.append(ffe3) + for i, lin in enumerate(ffe): + taps[f"encoder.pf{i}"] = lin.onnx_output + taps["encoder.voxel_max"] = _scatter_max_of(w, ffe3.onnx_output) + compression = _gemm(w, f"{VE}/compression_layers/compression_layers.0/Gemm", "encoder.compression") + if not compression.relu or w.node(compression.onnx_node).inputs[0] != taps["encoder.voxel_max"]: + _fail("compression_layers is not Gemm + ReLU on the per-voxel max") + taps["encoder.voxel_feats"] = compression.onnx_output + + # ---- stem and point stem + stem = tuple(_conv(w, f"{BB}/stem/stem.{i}/Conv", f"backbone.stem{j}") for j, i in enumerate((0, 3, 6))) + if any(c.activation != "hardswish" or c.stride != 1 for c in stem): + _fail("stem convs must be stride 1 + HardSwish") + taps["backbone.frustum"] = w.node(stem[0].onnx_node).inputs[0] + if _producer_op(w, taps["backbone.frustum"]) != "Transpose": + _fail("the stem does not read the transposed frustum image") + taps["backbone.stem"] = stem[-1].onnx_output + point_stem = _point_layer(w, f"{BB}/point_stem/point_stem.0/MatMul", "backbone.point_stem") + cat = w.producer(w.node(point_stem.onnx_node).inputs[0]) + if cat.op_type != "Concat" or len(cat.inputs) != 2 or cat.inputs[1] != ffe3.onnx_output: + _fail("point_stem input is not concat[pixel2point(stem), ffe_layers.3]") + taps["backbone.point_stem"] = point_stem.onnx_output + point_stem_segmax = _scatter_max_of(w, point_stem.onnx_output) + taps["backbone.segmax0"] = point_stem_segmax + fusion_stem = _conv(w, f"{BB}/fusion_stem/fusion_stem.0/Conv", "backbone.fusion_stem") + cat = w.producer(w.node(fusion_stem.onnx_node).inputs[0]) + if fusion_stem.activation != "hardswish" or cat.op_type != "Concat" or cat.inputs[1] != stem[-1].onnx_output: + _fail("fusion_stem is not HardSwish(conv(concat[frustum2pixel(p0), stem]))") + taps["backbone.fusion_stem"] = fusion_stem.onnx_output + + # ---- stages + stages: List[Stage] = [] + x_in, p_prev = fusion_stem.onnx_output, point_stem.onnx_output + for li in range(1, 5): + conv1s = w.nodes("Conv", f"{BB}/layer{li}/layer{li}.*/conv1/Conv") + if len(conv1s) != BLOCKS[li - 1]: + _fail(f"layer{li} has {len(conv1s)} BasicBlocks, expected {BLOCKS[li - 1]}") + blocks, outs = [], [] + for bi in range(len(conv1s)): + pre_b = f"{BB}/layer{li}/layer{li}.{bi}" + c1 = _conv(w, f"{pre_b}/conv1/Conv", f"backbone.layer{li}.block{bi}.conv1") + c2 = _conv(w, f"{pre_b}/conv2/Conv", f"backbone.layer{li}.block{bi}.conv2") + ds = None + if w.nodes("Conv", f"{pre_b}/downsample/*"): + ds = _conv(w, f"{pre_b}/downsample/downsample.0/Conv", f"backbone.layer{li}.block{bi}.downsample") + if c1.activation != "hardswish" or c2.activation is not None or (ds is not None and ds.activation): + _fail(f"{pre_b}: unexpected activations") + if w.node(c1.onnx_node).inputs[0] != x_in or (ds is not None and w.node(ds.onnx_node).inputs[0] != x_in): + _fail(f"{pre_b} does not read the previous block output") + add = _single(w, c2.onnx_output, "Add") + idt = ds.onnx_output if ds is not None else x_in + if set(add.inputs) != {c2.onnx_output, idt}: + _fail(f"{pre_b}: the residual add does not take conv2 + identity") + hs = _single(w, add.outputs[0], "HardSwish") + blocks.append((c1, c2, ds)) + outs.append(hs.outputs[0]) + taps[f"backbone.layer{li}.block{bi}"] = hs.outputs[0] + x_in = hs.outputs[0] + x_s = outs[-1] + taps[f"backbone.layer{li}"] = x_s + pf = _point_layer(w, f"{BB}/point_fusion_layers.{li - 1}/point_fusion_layers.{li - 1}.0/MatMul", + f"backbone.point_fusion{li}") + cat = w.producer(w.node(pf.onnx_node).inputs[0]) + if cat.op_type != "Concat" or len(cat.inputs) != 2 or cat.inputs[1] != p_prev: + _fail(f"point_fusion_layers.{li - 1} input is not concat[pixel2point(x), p]") + seg = _scatter_max_of(w, pf.onnx_output) + taps[f"backbone.point_fusion{li}"] = pf.onnx_output + taps[f"backbone.segmax{li}"] = seg + pix = _conv(w, f"{BB}/pixel_fusion_layers.{li - 1}/pixel_fusion_layers.{li - 1}.0/Conv", + f"backbone.pixel_fusion{li}") + cat = w.producer(w.node(pix.onnx_node).inputs[0]) + if pix.activation != "hardswish" or cat.op_type != "Concat" or cat.inputs[1] != x_s: + _fail(f"pixel_fusion_layers.{li - 1} is not HardSwish(conv(concat[frustum2pixel(p), x]))") + a0 = _conv(w, f"{BB}/attention_layers.{li - 1}/attention_layers.{li - 1}.0/Conv", + f"backbone.attention{li}.conv0") + a1 = _conv(w, f"{BB}/attention_layers.{li - 1}/attention_layers.{li - 1}.3/Conv", + f"backbone.attention{li}.conv1") + if (a0.activation != "hardswish" or a1.activation != "sigmoid" or w.node(a0.onnx_node).inputs[0] != + pix.onnx_output or w.node(a1.onnx_node).inputs[0] != a0.onnx_output): + _fail(f"attention_layers.{li - 1} is not sigmoid(conv(hswish(conv(pixel_fusion))))") + mul = _single(w, a1.onnx_output, "Mul") + if set(mul.inputs) != {pix.onnx_output, a1.onnx_output}: + _fail(f"stage {li}: the attention product is not pixel_fusion * attention") + add = _single(w, mul.outputs[0], "Add") + if set(add.inputs) != {mul.outputs[0], x_s}: + _fail(f"stage {li}: the output is not pixel_fusion * attention + x") + taps[f"backbone.pixel_fusion{li}"] = pix.onnx_output + taps[f"backbone.attention{li}"] = a1.onnx_output + taps[f"backbone.stage{li}"] = add.outputs[0] + stride = STRIDES[li - 1] + stages.append(Stage(li, stride, tuple(blocks), tuple(outs), pf, pix, (a0, a1), seg, add.outputs[0])) + x_in, p_prev = add.outputs[0], pf.onnx_output + got = [1] + [st.blocks[0][0].stride for st in stages[1:]] + if tuple(int(np.prod(got[: i + 1])) for i in range(4)) != STRIDES: + _fail(f"stage strides {got} do not give {STRIDES}") + + # ---- decoder + fuse1 = _conv(w, f"{BB}/fuse_layer1/conv/Conv", "decoder.fuse1") + fuse2 = _conv(w, f"{BB}/fuse_layer2/conv/Conv", "decoder.fuse2") + cat = w.producer(w.node(fuse1.onnx_node).inputs[0]) + sources = [fusion_stem.onnx_output] + [st.output for st in stages] + if cat.op_type != "Concat" or len(cat.inputs) != 5: + _fail("fuse_layer1 does not read a 5-way concat") + for k, (t, src) in enumerate(zip(cat.inputs, sources)): + rs = w.producer(t) + if rs.op_type != "Resize" or rs.inputs[0] != src or rs.attrs.get("mode") != "linear" or \ + rs.attrs.get("coordinate_transformation_mode") != "align_corners": + _fail(f"decoder input {k} is not an align_corners bilinear Resize of {src}") + taps[f"decoder.up{k}"] = t + if fuse1.activation != "hardswish" or fuse2.activation != "hardswish" or \ + w.node(fuse2.onnx_node).inputs[0] != fuse1.onnx_output: + _fail("fuse_layer1 / fuse_layer2 are not HardSwish convs in sequence") + taps["decoder.fuse1"] = fuse1.onnx_output + taps["decoder.fuse2"] = fuse2.onnx_output + pf1 = _point_layer(w, f"{BB}/point_fuse_layer1/point_fuse_layer1.0/MatMul", "decoder.point_fuse1") + pf2 = _point_layer(w, f"{BB}/point_fuse_layer2/point_fuse_layer2.0/MatMul", "decoder.point_fuse2") + cat = w.producer(w.node(pf1.onnx_node).inputs[0]) + if cat.op_type != "Concat" or list(cat.inputs) != [point_stem.onnx_output] + [s.point_fusion.onnx_output + for s in stages]: + _fail("point_fuse_layer1 input is not concat[p0, p1, p2, p3, p4]") + if w.node(pf2.onnx_node).inputs[0] != pf1.onnx_output: + _fail("point_fuse_layer2 does not consume point_fuse_layer1") + taps["decoder.point_fuse1"] = pf1.onnx_output + taps["decoder.point_fuse2"] = pf2.onnx_output + + # ---- head: h = pixel2point(fuse2); h = mlp_i(h) + skip_i; logits; softmax + mlps = [_point_layer(w, f"/model/mlps.{i}/mlps.{i}.0/MatMul", f"head.mlp{i}") for i in range(4)] + skips = [pf2.onnx_output, ffe[2].onnx_output, ffe[1].onnx_output, ffe[0].onnx_output] + prev = None + for i, (lin, skip) in enumerate(zip(mlps, skips)): + add = _single(w, lin.onnx_output, "Add") + if set(add.inputs) != {lin.onnx_output, skip}: + _fail(f"head mlps.{i} skip is not {skip}") + if prev is not None and w.node(lin.onnx_node).inputs[0] != prev: + _fail(f"head mlps.{i} does not consume the previous residual sum") + taps[f"head.mlp{i}"] = add.outputs[0] + prev = add.outputs[0] + conv_seg = _gemm(w, "/model/conv_seg/Gemm", "head.logits") + if conv_seg.relu or w.node(conv_seg.onnx_node).inputs[0] != prev or conv_seg.out_features != NUM_CLASSES: + _fail("conv_seg is not the final 64 -> 27 Gemm on the last residual sum") + sm = _single(w, conv_seg.onnx_output, "Softmax") + if sm.attrs.get("axis", -1) not in (-1, 1) or sm.outputs[0] != "pred_probs": + _fail("the output is not Softmax(axis=-1) of conv_seg") + taps["head.logits"] = conv_seg.onnx_output + taps["pred_probs"] = "pred_probs" + + scale = pre.scale.astype(np.float32) + shift = pre.shift.astype(np.float32) + raw = int(sum(int(np.asarray(w.array(t)).size) for t in w.initializer_names() + if np.issubdtype(np.asarray(w.array(t)).dtype, np.floating))) + out = FRNetWeights(variant=variant, frustum_hw=(H, W), strides=strides, pre_norm=pre, pre_norm_scale=scale, + pre_norm_shift=shift, ffe=tuple(ffe), compression=compression, stem=stem, + point_stem=point_stem, point_stem_segmax=point_stem_segmax, fusion_stem=fusion_stem, + stages=tuple(stages), fuse_layer1=fuse1, fuse_layer2=fuse2, point_fuse=(pf1, pf2), + head_mlps=tuple(mlps), conv_seg=conv_seg, onnx_taps=taps, onnx_path=str(path), + onnx_sha256=digest, raw_parameters=raw) + folded = sum(c.weight.size + c.bias.size for c in out.convs) + sum( + lin.weight.size + lin.bias.size for lin in out.linears) + 16 + object.__setattr__(out, "folded_parameters", int(folded)) + missing = [t for t in MODULE_TAPS if t not in taps] + if missing: + _fail(f"no ONNX tensor for taps {missing}") + return out + + +def check_variant_grid(weights: FRNetWeights, frustum_h: int, frustum_w: int) -> None: + """The ml_package grid must equal the grid baked into the ONNX (S:frnet:95-98: 1024 OT128 / 256 QT128).""" + if weights.frustum_hw != (frustum_h, frustum_w): + raise ValueError(f"ml_package frustum {frustum_h}x{frustum_w} != the ONNX's {weights.frustum_hw}") + diff --git a/code/tt_frnet/samples/README.md b/code/tt_frnet/samples/README.md new file mode 100644 index 0000000000000000000000000000000000000000..85bbf0849bce58c78cd18dc8635e07c46ddcac81 --- /dev/null +++ b/code/tt_frnet/samples/README.md @@ -0,0 +1,27 @@ +# Sample inputs shipped with frnet-p150 + +Only data generated by this bundle ships here: one **synthetic** LiDAR sweep per serve profile, made by +`code/scripts/make_sample.py` (no dataset involved; Apache-2.0, like the code). Public-dataset frames (PandaSet, +nuScenes) are used for validation and demo renders but are not in this repository: PandaSet-derived samples ship only +once they are approved, and non-commercial data (nuScenes) and the Autoware demo rosbag's VLS-128 frames (licence +unstated) never ship. + +Never use the `.bin` suffix here (or `.pt`, `.pth`, `.ckpt`, `.safetensors`): tt-model's staging silently drops +those suffixes from `code/` (`CODE_IGNORE`, tt-model-manager `src/tt_kernel/build.py:328-332`). Store point clouds as +`.npy` / `.npz` / `.pcd`. + +| file | content | +|---|---| +| `synthetic_ot128.npz` | `points`: structured float32 (x, y, z, intensity), 53,411 rows in the **sensor frame** (x forward, y left, z up): a ray cast from a sensor 2 m above a flat road against box-shaped cars, building facades, poles and pedestrians; 128 rows over the OT128's +15 / -25 deg, 512 columns over 360 deg, 5 % dropout, 2 cm range noise, random 0-255 intensity (seed 0). With the calibration preset `synthetic_ot128` (a pure 2 m lift): 52,613 points after the ego crop box, no interpolated point, N = M = 52,613 cells | +| `synthetic_qt128.npz` | the same scene seen by a QT128-like sensor: 128 rows over +-52.6 deg, 600 columns; 45,783 rows. With the preset `synthetic_qt128`: 41,806 points after the crop, N = 69,553 after Autoware's interpolation, M = 18,926 cells | +| `synthetic_..reference.json` | the stored fp32 CPU-reference output of the sample for profile ``: the `/predict` body of `tt_frnet.reference.pipeline` with ONNX Runtime on the deployed ONNX (the reference of record), reduced to what the container smoke compares: `labels` (Autoware `class_id`), `scores` (its `probability`), `filter_keep` (the filtered cloud) and the counts | + +`server/smoke_test.py` posts the sample of the served variant with its preset and requires >= 98.5 % label agreement +with the stored reference (PLAN.md 2.8 argmax gate); `tests/test_e2e_device.py::test_shipped_sample` gates the same +on the device. Regenerate the references with `code/scripts/make_sample.py` whenever the reference, the weights or +the post-processing changes. + +The scans are geometric toys: the network has never seen such scenes (it labels most of the synthetic road +`other_flat_surface` on the OT128 profile and finds few car points). They exercise the full input contract and the +device path, and they are the smoke test's input; they say nothing about accuracy on real data. The model card's +accuracy rows come from public datasets. diff --git a/code/tt_frnet/samples/synthetic_ot128.npz b/code/tt_frnet/samples/synthetic_ot128.npz new file mode 100644 index 0000000000000000000000000000000000000000..9e15d82001a2d324b987c6c696dde7d1ab4b0183 --- /dev/null +++ b/code/tt_frnet/samples/synthetic_ot128.npz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f95d27f004dc7e8ed4f14e9a14da38d07775657e3f92f615fdd429e69eaa5e49 +size 679575 diff --git a/code/tt_frnet/samples/synthetic_ot128.ot128.reference.json b/code/tt_frnet/samples/synthetic_ot128.ot128.reference.json new file mode 100644 index 0000000000000000000000000000000000000000..76a011e1f9a8317e8728633593b94388cdae8eb7 --- /dev/null +++ b/code/tt_frnet/samples/synthetic_ot128.ot128.reference.json @@ -0,0 +1 @@ +{"model": "frnet-p150", "frame_id": "sensor", "num_points": 52613, "class_names": ["drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "bus", "emergency_vehicle", "train", "truck", "tractor_unit", "semi_trailer", "construction_vehicle", "forklift", "kart", "motorcycle", "bicycle", "pedestrian", "personal_mobility", "animal", "pushable_pullable", "traffic_cone", "debris", "stroller", "other_stuff", "noise+ghost_point", "unknown"], "class_counts": {"drivable_surface": 2803, "other_flat_surface": 20311, "sidewalk": 5, "manmade": 22906, "vegetation": 6443, "car": 14, "bus": 0, "emergency_vehicle": 0, "train": 0, "truck": 0, "tractor_unit": 0, "semi_trailer": 131, "construction_vehicle": 0, "forklift": 0, "kart": 0, "motorcycle": 0, "bicycle": 0, "pedestrian": 0, "personal_mobility": 0, "animal": 0, "pushable_pullable": 0, "traffic_cone": 0, "debris": 0, "stroller": 0, "other_stuff": 0, "noise+ghost_point": 0, "unknown": 0}, "num_points_raw": 52613, "labels": {"format": "npz_compressed", "key": "labels", "dtype": "uint8", "shape": [52613], "data": "UEsDBC0AAAAIAAAAIQDr1TPk//////////8KABQAbGFiZWxzLm5weQEAEAAFzgAAAAAAAHw4AAAAAAAAnZ270mxPjtVTIx2Heon2GiLGAQIMHgAPAgcDi5hgmsAgGKIHcICn4IU5lWv9lvJrPP6Xc75L1d6ZuixJS8pd/+df/9t/9W/+XZ3/cf7nH//2T3//H/78x3/xhz/+r//+j//413/443/8uz//tz//zX/593/357/90/fn//Jv/vPf/+n3z//+P/3Nf/3T7+//4T/7J//8H//Tv/5Hf/2H//2H/89//kFXdU33779/f9Ezv////dX5/f/vH8396feP3//4VXpB39/fn+j7/r69Di+Y+/rf35/vr8593fc3fS/5+z7fL869533L9xXfn56a80tr+f2u+8vzXcf3i/vn949mJb7x98dznvV+b16+5f3jo9eN9nkX/L2Tr/WVwHfJXK2969KKR5fre/nvos4VWem1Wsu5L9W72rf9Xr91q+8339+PZX3leSVvedw7f19/7k7u23WpoxVqocg5S2zpQuL7XugrirmvY0lXdH1FP+0t15n7dv78vvAMah2rX7ezuu/F7pbmu7YZKeP+XPfW0rz7PhgVIvq0RdGxlN3U1cX3opNdeqMzlurXSsbXG5R9ZeZNVhZlgWAMv69+SlY6uusUqh9bgERn88AsrraxtfGC7ibP70t+731Q0BVKxZb0svHWueR97bWn78/O196P9vX1PLvTVci1pbE+6rAR/M4rLSR1fbYj97Y5oXtb8lXh0bWvgXx3MDW58b0u/9Y5+Jc3Yeu4y7E9XMX9/omUISGVzee0F3ptz/6Aw2qFdxFfuxREYHZW89cO4+xfS/iu/fotXmFzQZtyUJmHHLYbMcR3vl/I1m1yvsLR+r+/+DTvtonO9ZiRi4FFXxEevez+0r49q6hrZmdeNwQArkC+sPV9n34utxucZq6XnOud6/LWrmQvy7DhPt7aAK3QQKp7QXseJJwrIlAIk8vO6zHMM0aDmb3UuRr/rBG1PPSr3cJ9okiJXjI8x6+VfwmA52LkYcHe8HAh+7tx7PimcwGtCSL35UdxZRSNFla8LAMnsitAxKvHdLvXEq5jft9z7ELnDO8MahAMp3wzvXnBDqGcu5vpyAYUkXIri2l518hItdir1mMJzAVDBW/ULAufvUgMKBElyy3MtrMB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"}, "scores": {"format": "npz_compressed", "key": "scores", "dtype": "float32", "shape": [52613], "data": "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"}, "filter_keep": {"format": "npz_compressed", "key": "filter_keep", "dtype": "bool", "shape": [52613], "data": "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"}, "meta": {"variant": "ot128", "reference": "onnxruntime fp32 (deployed ONNX)", "onnx_sha256": "d29bf5950432360d800c22ff86a590d4d9d006acc501563ac611cfa33747c11f", "num_unique_coors": 52613, "cropped_by_ego_box": 798, "generator": "code/scripts/make_sample.py synthetic_scan(128 rows, 512 columns, seed 0)"}} diff --git a/code/tt_frnet/samples/synthetic_qt128.npz b/code/tt_frnet/samples/synthetic_qt128.npz new file mode 100644 index 0000000000000000000000000000000000000000..e5b98b9c386977cb4ef8618ed1dbdfc175acfbdc --- /dev/null +++ b/code/tt_frnet/samples/synthetic_qt128.npz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01c8aa6c2740b903e9133942e12cb9a091a05ea11b9cf6ed03fe1af61aef13c7 +size 579254 diff --git a/code/tt_frnet/samples/synthetic_qt128.qt128.reference.json b/code/tt_frnet/samples/synthetic_qt128.qt128.reference.json new file mode 100644 index 0000000000000000000000000000000000000000..94475a8cd4a766c50a5d9ab275392eeb8d9fa8bd --- /dev/null +++ b/code/tt_frnet/samples/synthetic_qt128.qt128.reference.json @@ -0,0 +1 @@ +{"model": "frnet-p150", "frame_id": "sensor", "num_points": 69553, "class_names": ["drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "bus", "emergency_vehicle", "train", "truck", "tractor_unit", "semi_trailer", "construction_vehicle", "forklift", "kart", "motorcycle", "bicycle", "pedestrian", "personal_mobility", "animal", "pushable_pullable", "traffic_cone", "debris", "stroller", "other_stuff", "noise+ghost_point", "unknown"], "class_counts": {"drivable_surface": 47616, "other_flat_surface": 168, "sidewalk": 14, "manmade": 20199, "vegetation": 605, "car": 161, "bus": 3, "emergency_vehicle": 0, "train": 0, "truck": 785, "tractor_unit": 0, "semi_trailer": 2, "construction_vehicle": 0, "forklift": 0, "kart": 0, "motorcycle": 0, "bicycle": 0, "pedestrian": 0, "personal_mobility": 0, "animal": 0, "pushable_pullable": 0, "traffic_cone": 0, "debris": 0, "stroller": 0, "other_stuff": 0, "noise+ghost_point": 0, "unknown": 0}, "num_points_raw": 41806, "labels": {"format": "npz_compressed", "key": "labels", "dtype": "uint8", "shape": [69553], "data": "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"}, "scores": {"format": "npz_compressed", "key": "scores", "dtype": "float32", "shape": [69553], "data": "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"}, "filter_keep": {"format": "npz_compressed", "key": "filter_keep", "dtype": "bool", "shape": [69553], "data": "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"}, "meta": {"variant": "qt128", "reference": "onnxruntime fp32 (deployed ONNX)", "onnx_sha256": "647af92790e3d591a19d065fe94d3ded797916ac9ffe5bf96b5e8b8324b31ad2", "num_unique_coors": 18926, "cropped_by_ego_box": 3977, "generator": "code/scripts/make_sample.py synthetic_scan(128 rows, 600 columns, seed 0)"}} diff --git a/code/tt_frnet/server/__init__.py b/code/tt_frnet/server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8e2f18f1e61f965b3ba821501abfd8e20d6285f3 --- /dev/null +++ b/code/tt_frnet/server/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +"""HTTP serving of frnet-p150, thin bindings of the vendored ``ttaw.server``: ``app.py`` (the ASGI app tt-model +runs with uvicorn), ``client.py`` (stdlib request builder, runnable as a script), ``smoke_test.py`` (stdlib PASS/FAIL +check of a running server: ETH dispatch and the 12x10 grid asserted, agreement with the stored CPU reference).""" diff --git a/code/tt_frnet/server/app.py b/code/tt_frnet/server/app.py new file mode 100644 index 0000000000000000000000000000000000000000..78ac5f367e7592fc6102feea8faece15803b410c --- /dev/null +++ b/code/tt_frnet/server/app.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: Apache-2.0 +"""ASGI serving app of frnet-p150 on one Tenstorrent Blackhole p150: the HTTP contract of the Autoware +collection (vendored ``ttaw.server.app``, BUNDLE_CONVENTIONS.md section 7) bound to :class:`FRNet`. + +Served by tt-model-manager as ``kind: tt-dit-server``:: + + python -m uvicorn --host 0.0.0.0 --port

--lifespan on tt_frnet.server.app:app + +Routes: ``GET /``, ``/health`` and ``/v1/health`` (always 200: ``ok`` / ``starting`` / ``error``), ``/info``, +``/v1/models`` (stub), ``POST /predict``; errors 400 / 422 / 503 / 500 (SERVING.md section 3). Everything that +touches the device happens in the lifespan: weights -> device (ETH dispatch, 12x10) -> graph -> trace capture of +every warm-up variant, so uvicorn's ``Application startup complete`` (the line ``tt-model serve`` waits for) means +warm; SIGTERM (``tt-model stop``, 120 s) closes the model under the lock. ``/predict`` calls the Python API, so it +returns exactly what ``model(...)`` returns. Importing this module has no side effects beyond importing fastapi and +pydantic (the image's ``verify:`` imports it without a device); the environment is read in the lifespan only. + +Model-specific request fields (e.g. PointPainting ``rois``): subclass ``PredictRequest``, pass ``request_model=`` and +``decode_extra=`` (which adds the decoded field to the call kwargs) to :class:`ServerSpec`, and list the keyword in +``EXTRA_INPUTS`` of the model class. Host tests swap the model before starting the app: +``app.state.ttaw.model_factory = Stub``; ``app.state.ttaw.predict(request)`` is the route handler itself. +""" +from pathlib import Path + +from .. import __version__ +from ..api import FRNet +from ..ttaw.server.app import PredictRequest, ServerSpec, create_app, parse_mesh_shape + +__all__ = ["SPEC", "app", "PredictRequest", "parse_mesh_shape"] + +SPEC = ServerSpec( + model_name=FRNet.MODEL_NAME, + env_prefix=FRNet.ENV_PREFIX, + model_cls=FRNet, + task="LiDAR 3D semantic segmentation", + default_weights=FRNet.DEFAULT_REPO, + owner="changh95", + io="one LiDAR sweep in the sensor frame (x, y, z, raw 0-255 intensity) in, a class id and probability for every point (27 T4 classes; Autoware's interpolated points included) out", + autoware={"package": "autoware_lidar_frnet", + "path": "perception/autoware_lidar_frnet", + "autoware_universe": "9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd"}, + source={"repo": "https://huggingface.co/changh95/frnet-p150", "license": "Apache-2.0"}, + calib_dir=Path(__file__).resolve().parents[1] / "calib", + version=__version__, + description="FRNet (Autoware lidar_frnet) on one Tenstorrent Blackhole p150. " + "Not an OpenAI-compatible API.", +) + +app = create_app(SPEC) diff --git a/code/tt_frnet/server/client.py b/code/tt_frnet/server/client.py new file mode 100644 index 0000000000000000000000000000000000000000..24ac7d0f5455afcfab82c1ffde4b60ba18f9e1fa --- /dev/null +++ b/code/tt_frnet/server/client.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Build (and optionally send) a ``POST /predict`` request for frnet-p150. Standard library only. + + # point cloud (.bin needs --fields; .npy / .npz / .pcd carry their own layout) + python3 code/tt_frnet/server/client.py --points code/tt_frnet/samples/synthetic_ot128.npz \ + --calib-preset synthetic_ot128 --out req.json + curl -s localhost:20000/predict -H 'Content-Type: application/json' -d @req.json + + # send it directly and print the response + python3 code/tt_frnet/server/client.py --points code/tt_frnet/samples/synthetic_ot128.npz \ + --calib-preset synthetic_ot128 --url http://127.0.0.1:20000 + + # another cloud: its own sensor -> base_link transform for the ego crop box (a JSON file), or no box + python3 code/tt_frnet/server/client.py --points my_scan.pcd --calib my_calib.json --out req.json + +The implementation is the vendored ``ttaw/server/client.py`` (C08); this file runs it from the model repository +with any Python 3.9+, without numpy and without installing the package. In Python, use +``tt_frnet.ttaw.server.client`` (``build_request``, ``post``, ``wait_ready``, ...). +""" +import runpy +from pathlib import Path + +if __name__ == "__main__": + runpy.run_path(str(Path(__file__).resolve().parents[1] / "ttaw" / "server" / "client.py"), run_name="__main__") diff --git a/code/tt_frnet/server/smoke_test.py b/code/tt_frnet/server/smoke_test.py new file mode 100644 index 0000000000000000000000000000000000000000..da5b54a94d64b82154fb6edb3526787bc5620854 --- /dev/null +++ b/code/tt_frnet/server/smoke_test.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Smoke test of a running frnet-p150 server (standard library only; any Python 3.9+, no numpy). + + python3 code/tt_frnet/server/smoke_test.py --url http://127.0.0.1:20000 --wait 1800 + # a served container package, one serve profile (what code/scripts/container_smoke.sh runs): + python3 code/tt_frnet/server/smoke_test.py --url http://127.0.0.1:20000 --wait 600 \ + --manifest /frnet-p150/tt_kernel_manifest.json [--profile ] --out /tmp/frnet.json + +Checks (all failures are collected; prints ONE line ``PASS ...`` / ``FAIL ...``, exit code 0 / 1): + * ``/health`` reports ``ok`` (waiting up to ``--wait`` seconds), ``/info`` and ``/v1/models`` answer; + * ``/info`` reports ETH dispatch and the 12x10 grid, the p150 target of every published number (PLAN.md 0.3 + item 6, D14): WORKER dispatch (e.g. an ETH open that fell back) or any other grid FAILS; + * with ``--manifest``: ``/info`` runs what the staged package pins for the serve profile (``FRNET_DISPATCH``, + ``FRNET_NUM_CQS``, ``FRNET_VARIANT``, the weights revision); + * ``POST /predict`` of the shipped sample of the served variant (``samples/synthetic_.npz`` with its + calibration preset; ``--input`` overrides) answers 200 with the documented fields and passes this model's output + gates (:func:`output_gates`; ``--expect LABEL:N`` minimum points per class, default per variant); + * the served output agrees with the stored CPU-reference output of the sample within :data:`REFERENCE_GATES`: + ``--reference``, else the first of ``..reference.json``, + ``..reference.json``, ``.reference.json`` next to the sample (skipped when none exists); + * a malformed request answers 400 (not 500). + +The checks themselves are the vendored ``ttaw/server/client.py`` and ``ttaw/server/smoke.py`` (stdlib only, loaded +by path); this file holds the model-specific parts: the sample request, the output gates and their thresholds. +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent / "ttaw" / "server")) +import client as ttaw_client # noqa: E402 (stdlib-only modules of the vendored ttaw) +import smoke as ttaw_smoke # noqa: E402 + +MODEL = "frnet-p150" +ENV_PREFIX = "FRNET" +EXPECT_DISPATCH, EXPECT_GRID = "eth", "12x10" # the p150 target; deliberately not a command-line option +SAMPLES_DIR = HERE.parent / "samples" +CALIB_DIR = HERE.parent / "calib" +DEFAULT_VARIANT = "ot128" + + +def default_input(variant: Optional[str]) -> Path: + """The shipped synthetic sample of the served variant (``code/scripts/make_sample.py``).""" + return SAMPLES_DIR / f"synthetic_{variant or DEFAULT_VARIANT}.npz" + + +# What the CPU reference finds on the shipped samples (points per class, raw + interpolated; OT128: manmade 22906, +# other_flat_surface 20311; QT128: drivable_surface 47616, manmade 20199): about half of it, a plausibility floor -- +# REFERENCE_GATES is the gate. Used only with the default input. +DEFAULT_EXPECT = {"ot128": "manmade:11000,other_flat_surface:10000", + "qt128": "drivable_surface:24000,manmade:10000"} +# Agreement with the stored CPU reference (names of ttaw_smoke.DEFAULT_GATES); keep them in line with the gates of +# tests/test_e2e_device.py: per-point labels (and the filter mask) agree on >= 98.5 % of the points (PLAN.md 2.8 +# argmax gate), float arrays (scores) are reported. +REFERENCE_GATES: Dict[str, Optional[float]] = {"min_label_agreement": 0.985, "max_abs_err": None} +REQUIRED_KEYS = ("model", "frame_id", "timing_ms", "num_points", "num_points_raw", "labels", "scores", + "class_names", "class_counts", "points", "filter_keep", "input_index") + + +def build_sample_request(path: Path) -> Dict[str, Any]: + """The ``/predict`` body for the sample: the cloud, plus the calibration preset of the same name when + ``calib/.json`` exists (the sensor -> base_link transform of the ego crop box).""" + stem = path.name.split(".")[0] + preset = stem if (CALIB_DIR / f"{stem}.json").is_file() else None + return ttaw_client.build_request(points=str(path), calib_preset=preset) + + +def output_gates(body: Dict[str, Any], expect: str) -> List[str]: + """This model's plausibility gates on a 200 response: per-point arrays of ``num_points`` rows, labels inside the + class list, scores in [0, 1], and the ``expect`` minimum points per class (``class_counts``).""" + fails: List[str] = [] + n = body.get("num_points", -1) + names = body.get("class_names") or [] + try: + labels = ttaw_smoke.decode_array(body["labels"]) + scores = ttaw_smoke.decode_array(body["scores"]) + keep = ttaw_smoke.decode_array(body["filter_keep"]) + except (KeyError, TypeError, ValueError) as e: + return [f"cannot decode the per-point arrays: {type(e).__name__}: {e}"] + for key, arr in (("labels", labels), ("scores", scores), ("filter_keep", keep)): + if list(arr.shape)[:1] != [n]: + fails.append(f"{key} has shape {list(arr.shape)}, expected [{n}]") + if labels.values and max(labels.values) >= len(names): + fails.append(f"label {max(labels.values)} outside the {len(names)} classes") + if scores.values and not all(0.0 <= v <= 1.0 for v in scores.values): + fails.append("scores outside [0, 1]") + counts = body.get("class_counts") or {} + for part in filter(None, (p.strip() for p in expect.split(","))): + label, _, k = part.partition(":") + if counts.get(label, 0) < int(k or 1): + fails.append(f"expected >= {k or 1} {label} points, got {counts.get(label, 0)}") + return fails + + +def main(argv: Optional[List[str]] = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--url", default="http://127.0.0.1:20000") + ap.add_argument("--input", type=Path, help="sample to POST (default: the shipped sample of the served variant)") + ap.add_argument("--expect", help="LABEL:N[,LABEL:N] minimum points per class (default: per shipped sample)") + ap.add_argument("--reference", type=Path, help="stored CPU-reference /predict body of --input " + "(default: looked up next to it)") + ap.add_argument("--manifest", type=Path, help="staged tt_kernel_manifest.json: /info must run what it pins") + ap.add_argument("--profile", help="serve profile being smoke-tested (default: the package's default)") + ap.add_argument("--wait", type=float, default=0.0, help="seconds to wait for /health == ok") + ap.add_argument("--out", type=Path, help="write the /predict response here") + a = ap.parse_args(argv) + base = a.url.rstrip("/") + + health = ttaw_client.wait_ready(base, wait_s=a.wait) + if health.get("status") != "ok": + print(ttaw_client.smoke_line(MODEL, f"/health status={health.get('status')!r}", + [f"not ready after {a.wait:.0f} s (error: {health.get('error')})"])) + return 1 + info, fails = ttaw_client.check_service(base, expect_dispatch=EXPECT_DISPATCH, expect_grid=EXPECT_GRID) + if not info: + print(ttaw_client.smoke_line(MODEL, "/info unreachable", fails)) + return 1 + profile = a.profile + if a.manifest: + try: + pinned = ttaw_smoke.pinned_config(a.manifest, a.profile) + except (OSError, ValueError) as e: + fails.append(f"manifest: {e}") + else: + profile = pinned["profile"] + fails += ttaw_smoke.check_pinned(info, pinned, ENV_PREFIX) + + variant = info.get("variant") or DEFAULT_VARIANT + sample = a.input or default_input(variant) + expect = a.expect if a.expect is not None else ("" if a.input else DEFAULT_EXPECT.get(variant, "")) + code, body = 0, {} + t0 = time.perf_counter() + try: + code, body = ttaw_client.post(base, build_sample_request(sample)) + except (OSError, ValueError) as e: # unreadable sample, unknown suffix, server gone + fails.append(f"/predict: {type(e).__name__}: {e}") + rtt_ms = (time.perf_counter() - t0) * 1e3 + if a.out: + a.out.write_text(json.dumps(body, indent=1)) + ref_summary = "none" + if code and code != 200: + fails.append(f"/predict HTTP {code}: {str(body)[:300]}") + elif code == 200: + fails += [f"missing key {k!r}" for k in REQUIRED_KEYS if k not in body] + fails += output_gates(body, expect) + ref_path = a.reference or ttaw_smoke.find_reference(sample, profile, info.get("variant")) + if ref_path: + try: + metrics, more = ttaw_smoke.compare_with_reference(body, ttaw_smoke.load_json(ref_path), + gates=REFERENCE_GATES) + except (OSError, ValueError) as e: + metrics, more = {}, [f"reference {ref_path}: {e}"] + fails += more + ref_summary = f"{ref_path.name} ({ttaw_smoke.describe_metrics(metrics)})" + try: + bad = ttaw_client.check_bad_request(base) + except OSError as e: + bad = f"malformed-request check: {type(e).__name__}: {e}" + if bad: + fails.append(bad) + + device, timing = info.get("device") or {}, body.get("timing_ms") or {} + summary = (f"profile={profile or '-'} variant={info.get('variant')} dispatch={device.get('dispatch')} " + f"grid={device.get('grid')} cqs={device.get('num_command_queues')} sample={sample.name} n={body.get('num_points')} " + f"reference={ref_summary} device_ms={timing.get('device')} total_ms={timing.get('total')} " + f"rtt_ms={rtt_ms:.1f}") + print(ttaw_client.smoke_line(MODEL, summary, fails)) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/tt_frnet/tests/__init__.py b/code/tt_frnet/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9881313609aae2c16a19dae665af4268bd54431d --- /dev/null +++ b/code/tt_frnet/tests/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: Apache-2.0 diff --git a/code/tt_frnet/tests/_data.py b/code/tt_frnet/tests/_data.py new file mode 100644 index 0000000000000000000000000000000000000000..21adb7a9227c119bd6d030425ad0e183aa5c3d4f --- /dev/null +++ b/code/tt_frnet/tests/_data.py @@ -0,0 +1,202 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Where the tests find data that does not ship in code/: the AutowareFoundation/lidar_frnet weights, the research +scripts and goldens of the workspace, the large module-tap goldens and the staged PandaSet sample. Outside the +tt-models workspace (the container image, a Hub clone) missing data skips the test that needs it. Inside the +workspace (``research/frnet`` exists) a missing golden, research frame or staged sample FAILS the test instead +(:func:`need`): a frozen gate must not vanish silently or run on a subset (VERIFY_PORT round 1, finding 2). + +The shipped samples are the synthetic scans ``samples/synthetic_.npz`` (``code/scripts/make_sample.py``, +Apache-2.0) with the calibration presets of the same name. The PandaSet 019 frame 40 sample (CC BY 4.0 + PandaSet +Dataset Terms) does not ship until the user approves PandaSet-derived samples (PLAN.md 6.3, research/DATASETS.md N5): +it is staged in the git-ignored ``staging_samples_pandaset/`` of the bundle checkout (``$FRNET_PANDASET_DIR`` +overrides; laid out like the package: ``samples/``, ``calib/``), and the frozen ``sample`` gates and the module-tap +frame still use it. + +- weights: ``$FRNET_WEIGHTS_DIR``, else the workspace download ``assets/frnet/hf_lidar_frnet`` (sha256-verified, + S:frnet:84-93), else the local HF cache snapshot at the pinned revision (``local_files_only``: tests never + download); +- research: ``$FRNET_RESEARCH_DIR``, else ``research/frnet`` of the workspace (scripts, ``golden/`` VLS-128 and + synthetic goldens, ``public_data/golden/`` PandaSet / nuScenes goldens; never shipped); +- large goldens: ``$FRNET_GOLDEN_DIR``, else ``research/frnet/goldens`` (``code/scripts/make_goldens.py``). + +:data:`FRAMES` is the frame catalogue shared by the tests and ``code/scripts/make_goldens.py``. +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import pytest + +PKG_DIR = Path(__file__).resolve().parents[1] +BUNDLE_DIR = PKG_DIR.parents[1] +SAMPLES = PKG_DIR / "samples" +GOLDENS = Path(__file__).resolve().parent / "goldens" +WORKSPACE = Path(os.environ.get("TT_MODELS_ROOT", "/home/ubuntu/experiments/tt-models")) +STAGING_PANDASET = BUNDLE_DIR / "staging_samples_pandaset" # git-ignored, never shipped (PLAN.md 6.3) + + +def shipped(variant: str) -> Path: + """The shipped synthetic sample of a variant (its calibration preset has the same stem).""" + return SAMPLES / f"synthetic_{variant}.npz" + + +def shipped_calib(variant: str) -> dict: + return {"preset": f"synthetic_{variant}"} + + +def _pandaset_root() -> Path: + env = os.environ.get("FRNET_PANDASET_DIR") + if env: + return Path(env) + if (SAMPLES / "pandaset_019_40.npz").is_file(): # swapped in after the user's OK + return PKG_DIR + return STAGING_PANDASET + + +# The PandaSet 019 f40 sample (the frozen ``sample`` gates and the module-tap frame): staged, not shipped. +PANDASET_ROOT = _pandaset_root() +SAMPLE_STEM = "pandaset_019_40" +SAMPLE_DIR = PANDASET_ROOT / "samples" +SAMPLE = SAMPLE_DIR / f"{SAMPLE_STEM}.npz" +SAMPLE_CALIB_FILE = PANDASET_ROOT / "calib" / f"{SAMPLE_STEM}.json" +# The explicit transform (not ``{"preset": ...}``: the preset is not in the package's calib/ while it is staged). +SAMPLE_CALIB = ({"T_base_link_from_sensor": json.loads(SAMPLE_CALIB_FILE.read_text())["T_base_link_from_sensor"]} + if SAMPLE_CALIB_FILE.is_file() else None) + + +def in_workspace() -> bool: + """True inside the tt-models workspace, where every golden and the staged sample must be present.""" + return (WORKSPACE / "research" / "frnet").is_dir() + + +def need(ok: bool, what: str) -> None: + """Skip (outside the workspace) or FAIL (inside it) when ``ok`` is false: data a frozen gate needs.""" + if ok: + return + if in_workspace(): + pytest.fail(f"{what} is missing: inside the workspace a frozen gate must not be skipped") + pytest.skip(f"{what} is missing") + + +def need_pandaset() -> Path: + """The staged PandaSet 019 f40 sample (see the module docstring); skip outside / fail inside the workspace.""" + need(SAMPLE.is_file() and SAMPLE_CALIB is not None, + f"the PandaSet 019 f40 sample {SAMPLE} and its calibration {SAMPLE_CALIB_FILE} (staging_samples_pandaset/ of " + "the bundle checkout or $FRNET_PANDASET_DIR; it does not ship until the user approves PandaSet samples)") + return SAMPLE + + +def _first_dir(*candidates: Optional[Path]) -> Optional[Path]: + for c in candidates: + if c is not None and c.is_dir(): + return c + return None + + +def find_weights_dir() -> Optional[Path]: + env = os.environ.get("FRNET_WEIGHTS_DIR") + found = _first_dir(Path(env) if env else None, WORKSPACE / "assets" / "frnet" / "hf_lidar_frnet") + if found is not None and (found / "frnet_ot128.onnx").is_file(): + return found + try: + from huggingface_hub import snapshot_download + + from tt_frnet.api import FRNet + + snap = Path(snapshot_download(FRNet.DEFAULT_REPO, revision=FRNet.DEFAULT_REVISION, + allow_patterns=list(FRNet.ALLOW_PATTERNS), local_files_only=True)) + if (snap / "frnet_ot128.onnx").is_file(): + return snap + except Exception: # noqa: BLE001 -- not cached / no huggingface_hub: the caller skips + pass + return None + + +def weights_dir() -> Path: + d = find_weights_dir() + need(d is not None, "the lidar_frnet weights (FRNET_WEIGHTS_DIR, assets/frnet/hf_lidar_frnet or the HF cache)") + return d + + +def research_dir() -> Path: + env = os.environ.get("FRNET_RESEARCH_DIR") + d = _first_dir(Path(env) if env else None, WORKSPACE / "research" / "frnet") + need(d is not None and (d / "scripts" / "frnet_preproc_ref.py").is_file(), + "research/frnet (the research reference scripts and goldens)") + return d + + +def research_module(name: str): + """Import a research script by name (``frnet_preproc_ref``, ``verify_preproc_ort_independent``, ...); their + directory is workspace code, not a download. Importing them reads no data and runs nothing.""" + scripts = str(research_dir() / "scripts") + if scripts not in sys.path: + sys.path.insert(0, scripts) + import importlib + + return importlib.import_module(name) + + +def golden_dir() -> Path: + env = os.environ.get("FRNET_GOLDEN_DIR") + d = _first_dir(Path(env) if env else None, WORKSPACE / "research" / "frnet" / "goldens") + need(d is not None, "large goldens (research/frnet/goldens; run code/scripts/make_goldens.py)") + return d + + +# ------------------------------------------------------------------------------------------ frame catalogue +# name -> (golden .npz relative to research/frnet, key prefix, variant). The public goldens hold the cloud handed +# to the node (raw_points), its crop transform and the four network inputs + ORT pred_probs (`autoware_` prefix); +# the VLS-128 goldens hold the x > 0 half's network inputs (no crop) + ORT pred_probs (S:frnet:361-365). +PUBLIC_OT128 = [f"pandaset_019_{f}" for f in (10, 25, 40, 55, 70)] + \ + [f"pandaset_029_{f}" for f in (10, 25, 40, 55, 70)] + \ + [f"nuscenes_scene-0103_{f:02d}" for f in (0, 20, 39)] + \ + [f"nuscenes_scene-0916_{f:02d}" for f in (0, 20, 40)] +PUBLIC_QT128 = ["pandaset_019_40", "pandaset_029_40", "nuscenes_scene-0103_20", "nuscenes_scene-0916_20"] + + +def frames() -> Dict[str, Tuple[str, str, str]]: + out: Dict[str, Tuple[str, str, str]] = {} + for name in PUBLIC_OT128: + out[f"{name}.ot128"] = (f"public_data/golden/{name}_ot128.npz", "autoware_", "ot128") + for name in PUBLIC_QT128: + out[f"{name}.qt128"] = (f"public_data/golden/{name}_qt128.npz", "autoware_", "qt128") + for v in ("ot128", "qt128"): + out[f"vls128_frame000_fronthalf.{v}"] = (f"golden/vls128_frame000_fronthalf_{v}.npz", "autoware_", v) + return out + + +FRAMES = frames() +# The frames whose module taps are stored in full (research/frnet/goldens//.taps.npz): the shipped +# sample in both variants. +TAP_FRAMES: List[str] = [f"{SAMPLE_STEM}.ot128", f"{SAMPLE_STEM}.qt128"] +# The hardest public frame for bf16 (research/frnet/public_data README section 3) and the densest-cell frame. +HARD_FRAMES: List[str] = ["nuscenes_scene-0916_20.ot128", "vls128_frame000_fronthalf.qt128"] + + +def frame_path(name: str) -> Path: + rel, _, _ = FRAMES[name] + path = research_dir() / rel + need(path.is_file(), f"golden {rel}") + return path + + +def load_frame(name: str): + """``(NetworkInputs, pred_probs (N, 27) ORT, golden npz)`` of a catalogued frame (unpadded).""" + import numpy as np + + from tt_frnet.host.preprocess import NetworkInputs + + _, prefix, _ = FRAMES[name] + g = np.load(frame_path(name), allow_pickle=False) + n0 = len(g["raw_points"]) if "raw_points" in g.files else len(g[prefix + "points"]) + keep = g[prefix + "keep_mask"] if prefix + "keep_mask" in g.files else np.ones(n0, bool) + n_raw = int(g[prefix + "num_points_raw"]) if prefix + "num_points_raw" in g.files else int(keep.sum()) + inputs = NetworkInputs(points=g[prefix + "points"], coors=g[prefix + "coors"], + voxel_coors=g[prefix + "voxel_coors"], inverse_map=g[prefix + "inverse_map"], + num_points_raw=n_raw, keep_mask=keep) + return inputs, g[prefix + "pred_probs"], g diff --git a/code/tt_frnet/tests/goldens/README.md b/code/tt_frnet/tests/goldens/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ffec82d550a106262c5f32d9fb3b1feec90c21ff --- /dev/null +++ b/code/tt_frnet/tests/goldens/README.md @@ -0,0 +1,9 @@ +# Small goldens (shippable) + +- `preprocess.json`: sha256 of the four network inputs (`points`, `coors`, `voxel_coors`, `inverse_map`) that the + RESEARCH pre-processing reference (`research/frnet/scripts/frnet_preproc_ref.py`, variant `autoware`) produces for + the shipped sample (with its crop transform) and for the research synthetic scans, both variants; written by + `code/scripts/make_goldens.py --set host`. `tests/test_host_vs_research.py` checks the bundle's host + pre-processing against them, also where the research tree is absent. + +The large goldens (module taps, the 22-frame manifest) live in `research/frnet/goldens` (never shipped). diff --git a/code/tt_frnet/tests/goldens/preprocess.json b/code/tt_frnet/tests/goldens/preprocess.json new file mode 100644 index 0000000000000000000000000000000000000000..92a991c8be7e5e7d6a3404de8c201be447a58510 --- /dev/null +++ b/code/tt_frnet/tests/goldens/preprocess.json @@ -0,0 +1,49 @@ +{ + "what": "sha256 of the four network inputs produced by research/frnet/scripts/frnet_preproc_ref.py (variant 'autoware'); the bundle's host pre-processing must reproduce them", + "cases": { + "pandaset_019_40.ot128": { + "N0": 107146, + "N_raw": 106501, + "N": 145961, + "M": 40691, + "input_sha256": "920398c928dd204607bdfd1d17d0ed4e08f7366b43400f98de92b452a7ebcc89", + "points": "82f65f15cdff3d98b27a3cc2f8212f65cc51bb17b96aa2e0c577f70c2f38be82", + "coors": "d0d98fcf0b931870d5ab32b790511f163d7cd50c6b737bc956db64960316d157", + "voxel_coors": "deb83f578ac46ddbbc5067a8bcfc398df623b4d0ca2663f76b0407a7d70f0033", + "inverse_map": "043b06a23ba733eb4bd872e0fb0087f51db25ae836db786fe384fde7b815f7e4" + }, + "synthetic_seed0.ot128": { + "N0": 125524, + "N_raw": 125524, + "N": 125524, + "M": 108145, + "input_sha256": "e0022550983f2a7bbc02386c419c6f2d02f91d934ad4b73f3b94406f04c8647e", + "points": "e0022550983f2a7bbc02386c419c6f2d02f91d934ad4b73f3b94406f04c8647e", + "coors": "8069675e0e89dc1d04c824dab5af1b1d35f9e2dee92608beb990dfa414bb3f62", + "voxel_coors": "6fd44ae7d840d29e3575289e6d6973972e09e4cc1dd5e0964d0ba9868c795b53", + "inverse_map": "c43c853fdedd0c9bcedc7a955da44201340f77002a2adee4e9a6d7fbebaa37d5" + }, + "pandaset_019_40.qt128": { + "N0": 107146, + "N_raw": 106501, + "N": 106585, + "M": 6255, + "input_sha256": "920398c928dd204607bdfd1d17d0ed4e08f7366b43400f98de92b452a7ebcc89", + "points": "783635ccd5dd2bd41d6fea234ea212f2df58cfc58a2dbb725648f921c62ee56f", + "coors": "3cedfeff2c4522bf19aea41bef6e17233e350ac42dd76aaaaaa85a167abc3def", + "voxel_coors": "afd193c92cfcdda40add7cf25976136ee24c9cfd22dd1bed4b853fd2ba1713d0", + "inverse_map": "08ff17424809627a8fa219d4a5427f88480f89946063aa1af861b86d129eabef" + }, + "synthetic_seed0.qt128": { + "N0": 45783, + "N_raw": 45783, + "N": 76260, + "M": 20662, + "input_sha256": "e3ce510b6951b708fe29b7fa00a1ad021318df4e9fe3a0fbfd4e22ee43b56871", + "points": "778e59b2d40d4bfdc9746fd0950dfeda95a32f45a595fb5e34ac8e9a0869439d", + "coors": "b58c38539b82bd92b1bc4fdea3e8d932853d31c6f73976b7b691043874349897", + "voxel_coors": "dc39f6d6a3ebec4c1ce19dcfecc3c4e80a01b150bc684220528c5947d7816d9a", + "inverse_map": "d949a9c46f7d5822bdc58a4f2e7bcffa4f0c9579b366e38c655b39e132323968" + } + } +} diff --git a/code/tt_frnet/tests/stubs.py b/code/tt_frnet/tests/stubs.py new file mode 100644 index 0000000000000000000000000000000000000000..03d6dd5f32c428527aa27a6a75a4fdc323fcb89c --- /dev/null +++ b/code/tt_frnet/tests/stubs.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A stand-in for the model class of ``tt_frnet.api`` with no device and no weights, for the host tests of the +server and the smoke test. It quacks like the real model (``validate_params``, ``__call__``, ``info``, +``device_info``, ``close``), decodes points with the bundle's own ``io`` and returns a per-point segmentation +(``FRNetSegmentation``) that depends on the input deterministically: the class of a point is ``int(|x| * 10) % 27``, +its score ``0.5``, every point a raw point (no crop, no interpolation).""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +import numpy as np + +from tt_frnet import io as tio +from tt_frnet.api import FRNet, Output + +ETH_P150 = {"dispatch": "eth", "dispatch_requested": "eth", "fallback": None, "grid": "12x10", "grid_x": 12, + "grid_y": 10, "cores": 120, "arch": "blackhole", "eth_patch": True, "device_id": 0, + "num_command_queues": FRNet.DEVICE_DEFAULTS["num_command_queues"]} + + +class StubModel: + """``StubModel(cfg)`` is a valid ``app.state.ttaw.model_factory`` (``cfg`` = ``config_from_env`` output).""" + + device_info: Dict[str, Any] = ETH_P150 + validate_params = FRNet.validate_params + + def __init__(self, cfg: Optional[Dict[str, Any]] = None): + self.cfg = dict(cfg or {}) + self.closed = False + + @property + def info(self) -> Dict[str, Any]: + cls = FRNet + return {"model": cls.MODEL_NAME, "variant": self.cfg.get("variant") or cls.DEFAULT_VARIANT, + "weights": {"repo": self.cfg.get("model_id") or cls.DEFAULT_REPO, + "revision": self.cfg.get("revision") or cls.DEFAULT_REVISION}, + "device": self.device_info, "input_kind": cls.INPUT_KIND, "point_fields": list(cls.POINT_FIELDS), + "camera_order": list(cls.CAMERA_ORDER), "labels": list(cls.LABELS), + "runtime_params": {k: v[3] for k, v in cls.RUNTIME_PARAMS.items()}} + + def __call__(self, points: Any = None, calibration: Any = None, **params: Any) -> Output: + if points is None: + raise tio.InputError("this model needs 'points'") + p = self.validate_params(params) + cloud = tio.load_points(points) + xyzi = cloud.select(("x", "y", "z", "intensity")) + n = len(xyzi) + labels = (np.abs(xyzi[:, 0]) * 10).astype(np.int64) % len(FRNet.LABELS) + return Output(labels, class_names=FRNet.LABELS, scores=np.full(n, 0.5, np.float32), model=FRNet.MODEL_NAME, + frame_id="sensor", timing_ms={"device": 1.0}, points=xyzi, + filter_keep=labels != 0, input_index=np.arange(n), num_points_raw=n, + meta={"calibration": sorted(calibration or {}), "params": p}) + + def close(self) -> None: + self.closed = True diff --git a/code/tt_frnet/tests/test_api_host.py b/code/tt_frnet/tests/test_api_host.py new file mode 100644 index 0000000000000000000000000000000000000000..923d68951c4c2f9f4a59bd43c0ebc6d37b67c063 --- /dev/null +++ b/code/tt_frnet/tests/test_api_host.py @@ -0,0 +1,143 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Host tests of the API wiring (no device): identity and runtime parameters; the host half of the hooks +(``_build_host``, ``_prepare``, ``_postprocess``) with the fp32 torch reference network in the device's place, +against the stored CPU references of the shipped synthetic samples and the staged PandaSet sample (compared with the container smoke's own comparator, +``ttaw.server.smoke``); the calibration contract (crop transform required while the box is on, presets, overrides, +refusals); a frame the node refuses (profile check) gives an empty output with the node's message. + + TT_VISIBLE_DEVICES=none python -m pytest -q -p fake_ttnn_plugin code/tt_frnet/tests/test_api_host.py +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +from tt_frnet import io as tio +from tt_frnet.api import LABELS, FRNet +from tt_frnet.host.postprocess import RUNTIME_PARAMS, FRNetSegmentation +from tt_frnet.tests import _data +from tt_frnet.ttaw.server import smoke + + +def test_identity_and_runtime_params(): + assert tuple(FRNet.VARIANTS) == ("ot128", "qt128") and FRNet.DEFAULT_VARIANT == "ot128" + assert tuple(FRNet.LABELS) == LABELS and len(LABELS) == 27 and FRNet.LABELS[0] == "drivable_surface" + assert FRNet.RUNTIME_PARAMS is RUNTIME_PARAMS and FRNet.EXTRA_INPUTS == () + assert FRNet.DEFAULT_REVISION == "a53a1c11b0b28d31fd50a03abef5db0e830a5b3c" and FRNet.DEFAULT_TAG == "v2.0" + p = FRNet.validate_params({}) + assert p == {"class_probability_threshold": 0.05, "filter_classes": "drivable_surface"} + assert FRNet.validate_params({"class_probability_threshold": "0.5"})["class_probability_threshold"] == 0.5 + assert FRNet.validate_params({"filter_classes": ""})["filter_classes"] == "" + for bad in ({"class_probability_threshold": 1.5}, {"class_probability_threshold": True}, {"score_threshold": 0.1}): + with pytest.raises(tio.InputError): + FRNet.validate_params(bad) + + +def _host_model(variant: str = "ot128") -> FRNet: + m = FRNet.__new__(FRNet) # no device: the host half only + m.weights_path = _data.weights_dir() + m.variant = variant + m._build_host() + return m + + +@pytest.fixture(scope="module") +def FRNetTorch(): + torch = pytest.importorskip("torch") + pytest.importorskip("onnx") + from tt_frnet.reference.model import FRNetTorch + + torch.set_num_threads(4) + return FRNetTorch + + +@pytest.mark.parametrize("case", ["pandaset.ot128", "pandaset.qt128", "shipped.ot128", "shipped.qt128"]) +def test_hooks_reproduce_the_stored_reference(FRNetTorch, case): + """The API hooks with the torch fp32 reference in place of the device reproduce the stored ONNX Runtime reference + bodies: the staged PandaSet sample and the shipped synthetic samples (the container smoke's references).""" + kind, _, variant = case.partition(".") + m = _host_model(variant) + if kind == "pandaset": + sample, calib, ref_path = (_data.need_pandaset(), _data.SAMPLE_CALIB, + _data.SAMPLE_DIR / f"{_data.SAMPLE_STEM}.{variant}.reference.json") + else: + sample, calib = _data.shipped(variant), _data.shipped_calib(variant) + ref_path = _data.SAMPLES / f"synthetic_{variant}.{variant}.reference.json" + prep = m._prepare(points=sample, calibration=calib) + assert prep.skipped is None and prep.plan is not None and prep.plan.n == prep.inputs.num_points + inp = prep.inputs + probs = FRNetTorch(m.frnet_weights)(inp.points, inp.coors, inp.voxel_coors, inp.inverse_map) + out = m._postprocess(probs, prep, m.validate_params({})) + assert isinstance(out, FRNetSegmentation) and out.frame_id == "sensor" and out.model == "frnet-p150" + body = out.to_dict() + ref = json.loads(ref_path.read_text()) + metrics, fails = smoke.compare_with_reference(body, ref, gates={"min_label_agreement": 0.9999, + "max_abs_err": 1e-4}) + assert fails == [], (fails, metrics) + assert body["num_points"] == ref["num_points"] and body["num_points_raw"] == ref["num_points_raw"] + assert body["meta"]["variant"] == variant and body["meta"]["ego_crop"]["enabled"] + assert body["meta"]["num_unique_coors"] == ref["meta"]["num_unique_coors"] + np.testing.assert_array_equal(out.input_index, np.flatnonzero(inp.keep_mask)) + + +def test_prepare_calibration_contract(): + m = _host_model() + pts = np.c_[np.random.default_rng(0).uniform(-40, 40, (8000, 3)), np.full(8000, 7.0)].astype(np.float32) + with pytest.raises(tio.InputError, match="this model needs 'points'"): + m._prepare(points=None) + with pytest.raises(tio.InputError, match="ego crop box"): + m._prepare(points=pts) # the box is on, no transform: refused + with pytest.raises(tio.InputError, match="one sensor-frame sweep"): + m._prepare(points=pts, sweeps=[{"points": pts}], calibration={"ego_crop_box": None}) + with pytest.raises(tio.InputError, match=r"\['images'\]"): + m._prepare(points=pts, images=np.zeros((2, 2, 3), np.uint8), calibration={"ego_crop_box": None}) + with pytest.raises(tio.InputError, match="calibration must be an object"): + m._prepare(points=pts, calibration=[1, 2]) + assert m._prepare(points=pts, sweeps=[], stream=None, calibration={"ego_crop_box": None}).inputs is not None + with pytest.raises(tio.InputError, match="unknown keys"): + m._prepare(points=pts, calibration={"T_lidar": np.eye(4).tolist()}) + with pytest.raises(tio.InputError, match="proper rotation"): + m._prepare(points=pts, calibration={"T_base_link_from_sensor": (2 * np.eye(4))[:3].tolist()}) # 3x4, scaled + with pytest.raises(tio.InputError, match="preset"): + m._prepare(points=pts, calibration={"preset": "no_such_rig"}) + off = m._prepare(points=pts, calibration={"ego_crop_box": None}) + assert off.crop["enabled"] is False and off.inputs.num_points_raw == 8000 + on = m._prepare(points=pts, calibration={"T_base_link_from_sensor": {"x": 0, "y": 0, "z": 0, "roll": 0, + "pitch": 0, "yaw": 0}, + "ego_crop_box": [-10, -10, -10, 10, 10, 10]}) + inside = (np.abs(pts[:, :3]) < 10).all(1) + assert on.crop["enabled"] and on.inputs.num_points_raw == int((~inside).sum()) + preset = m._prepare(points=_data.shipped("ot128"), calibration=_data.shipped_calib("ot128")) + assert preset.crop["enabled"] and preset.inputs.num_points_raw == 52613 and preset.inputs.num_points == 52613 + _data.need_pandaset() + preset = m._prepare(points=_data.SAMPLE, calibration=_data.SAMPLE_CALIB) + assert preset.inputs.num_points_raw == 106501 and preset.inputs.num_points == 145961 + + +def test_a_frame_the_node_refuses_gives_an_empty_output(): + m = _host_model() + pts = np.c_[np.random.default_rng(1).uniform(-40, 40, (2000, 3)), np.zeros(2000)].astype(np.float32) + prep = m._prepare(points=pts, calibration={"ego_crop_box": None}) + assert prep.skipped is not None and "in input pointcloud does not match the profile" in prep.skipped + raw = m._forward(prep) # no device touched + out = m._postprocess(raw, prep, m.validate_params({})) + body = out.to_dict() + assert raw is None and body["num_points"] == 0 and "does not match the profile" in body["meta"]["skipped"] + with pytest.raises(tio.InputError, match="filter_classes"): + m._postprocess(raw, prep, m.validate_params({"filter_classes": "road"})) + + +def test_filter_params_change_only_the_filter_mask(): + m = _host_model("qt128") + prep = m._prepare(points=_data.shipped("qt128"), calibration=_data.shipped_calib("qt128")) + rng = np.random.default_rng(2) + probs = rng.dirichlet(np.ones(27), size=prep.inputs.num_points).astype(np.float32) + a = m._postprocess(probs, prep, m.validate_params({})) + b = m._postprocess(probs, prep, m.validate_params({"class_probability_threshold": 0.9, + "filter_classes": "drivable_surface,sidewalk"})) + c = m._postprocess(probs, prep, m.validate_params({"filter_classes": ""})) + np.testing.assert_array_equal(a.label_ids, b.label_ids) + assert c.filter_keep.all() and b.filter_keep.sum() > a.filter_keep.sum() + assert b.meta["filter"]["classes"] == ["drivable_surface", "sidewalk"] diff --git a/code/tt_frnet/tests/test_bundle_host.py b/code/tt_frnet/tests/test_bundle_host.py new file mode 100644 index 0000000000000000000000000000000000000000..8d1cef505c317e800e4c8f2e982ada044f4e1bad --- /dev/null +++ b/code/tt_frnet/tests/test_bundle_host.py @@ -0,0 +1,466 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Host tests of the bundle wiring (no device, no weights): the vendored ttaw copy is intact and every module +imports without ttnn / torch; the identity in api.py / device.py matches tt-model.yaml; the device configuration +resolves like the server's; the stdlib client and smoke test work against this bundle's app served over real HTTP +(ETH dispatch and 12x10 asserted, serve-profile pins, agreement with a stored reference output); the pip project is the +repo-root pyproject.toml; code/scripts/container_smoke.sh (run against fake tt-model / docker tools) keeps its evidence +and always stops the container. + + TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests/test_bundle_host.py +""" +from __future__ import annotations + +import contextlib +import fnmatch +import hashlib +import json +import os +import shutil +import socket +import subprocess +import sys +import threading +from pathlib import Path +from typing import Iterator + +import numpy as np +import pytest + +import tt_frnet +from tt_frnet import device as tdev +from tt_frnet import io as tio +from tt_frnet.api import FRNet +from tt_frnet.tests.stubs import ETH_P150, StubModel + +PKG_DIR = Path(tt_frnet.__file__).resolve().parent +CODE_DIR, BUNDLE_DIR, TTAW_DIR = PKG_DIR.parent, PKG_DIR.parents[1], PKG_DIR / "ttaw" +PATCH_MARKER = "single_chip_arch_1cq_no_dispatch_s" # added to topology.cpp by patches/tt-metal-eth-dispatch.patch +VENDOR_IGNORE = ("__pycache__", "*.pyc", "*.pyo", ".pytest_cache", "*.bin", "*.log", ".DS_Store", "VENDORED.json") + +BLOCKER = """ +import importlib, importlib.abc, sys +BLOCKED = set(sys.argv[2].split(",")) +class Block(importlib.abc.MetaPathFinder): + def find_spec(self, name, path, target=None): + if name.split(".")[0] in BLOCKED: + raise ImportError("blocked import of " + name) +sys.meta_path.insert(0, Block()) +sys.path.insert(0, sys.argv[1]) +for target in sys.argv[3:]: + module, _, attr = target.partition(":") + mod = importlib.import_module(module) + if attr: + getattr(mod, attr) +leaked = sorted(k for k in sys.modules if k.split(".")[0] in BLOCKED) +print("ok" if not leaked else "leaked: " + " ".join(leaked)) +""" + + +# ------------------------------------------------------------------------------------------ vendored ttaw + +def test_vendored_ttaw_matches_its_manifest(): + """The copy under code/tt_frnet/ttaw is exactly what common/tools/vendor.py wrote (never edit it here).""" + manifest = json.loads((TTAW_DIR / "VENDORED.json").read_text()) + recorded = manifest["files"] + actual = {} + for p in sorted(TTAW_DIR.rglob("*")): + rel = p.relative_to(TTAW_DIR) + if p.is_file() and not any(fnmatch.fnmatch(part, pat) for part in rel.parts for pat in VENDOR_IGNORE): + actual[rel.as_posix()] = hashlib.sha256(p.read_bytes()).hexdigest() + problems = ([f"missing: {k}" for k in sorted(set(recorded) - set(actual))] + + [f"extra: {k}" for k in sorted(set(actual) - set(recorded))] + + [f"modified: {k}" for k in sorted(set(recorded) & set(actual)) if recorded[k] != actual[k]]) + assert problems == [], "re-vendor with common/tools/vendor.py instead of editing the copy" + from tt_frnet import ttaw + + assert manifest["package"] == "ttaw" and manifest["version"] == ttaw.__version__ + + +@pytest.mark.parametrize("blocked,targets", [ + ("ttnn,torch", ["tt_frnet:FRNet", "tt_frnet:Output", "tt_frnet:open_device", + "tt_frnet:load_points", "tt_frnet:load_image", "tt_frnet.device", + "tt_frnet.io", "tt_frnet.server", "tt_frnet.server.smoke_test"]), + ("ttnn,torch,onnx,onnxruntime,yaml", ["tt_frnet.reference", "tt_frnet.reference.config", + "tt_frnet.reference.weights", "tt_frnet.reference.pipeline", + "tt_frnet.host", "tt_frnet.host.preprocess", "tt_frnet.host.postprocess", + "tt_frnet.host.frame", "tt_frnet.reference.ort"]), + ("ttnn", ["tt_frnet.reference.model", "tt_frnet.reference.rewrites"]), # torch: research venv / tt-env +]) +def test_imports_have_no_side_effects(blocked, targets): + if blocked == "ttnn,torch": + pytest.importorskip("fastapi") + targets = targets + ["tt_frnet.server.app:app"] + out = subprocess.run([sys.executable, "-c", BLOCKER, str(CODE_DIR), blocked] + targets, capture_output=True, + text=True) + assert out.returncode == 0 and out.stdout.strip() == "ok", out.stderr[-2000:] + + +# ----------------------------------------------------------------------------------- identity and config + +@pytest.fixture(scope="module") +def manifest(): + yaml = pytest.importorskip("yaml") + path = BUNDLE_DIR / "tt-model.yaml" + if not path.is_file(): + pytest.skip("no tt-model.yaml next to code/ (installed package or container image)") + return yaml.safe_load(path.read_text()) + + +def test_identity_matches_manifest(manifest): + cls = FRNet + assert manifest["name"] == cls.MODEL_NAME and manifest["repo"].endswith("/" + cls.MODEL_NAME) + w = manifest["weights"] + assert (w["repo"], w["revision"]) == (cls.DEFAULT_REPO, cls.DEFAULT_REVISION) + assert list(w.get("allow_patterns") or []) == list(cls.ALLOW_PATTERNS or []) + assert manifest["runtime"]["app"] == "tt_frnet.server.app:app" + env = manifest["serve"]["env"] + assert env["TT_WEIGHTS_REVISION"] == cls.DEFAULT_REVISION + assert int(env["FRNET_NUM_CQS"]) == tdev.DEVICE_DEFAULTS["num_command_queues"] + for profile in [{"name": "serve", "env": {}}] + list(manifest.get("serve_profiles") or []): + merged = {**env, **(profile.get("env") or {})} + assert merged.get("FRNET_DISPATCH") == "eth", f"{profile['name']}: the p150 target is ETH dispatch" + assert merged.get("FRNET_VARIANT") in cls.VARIANTS, profile["name"] + verify = "\n".join(manifest["verify"]) + assert PATCH_MARKER in verify and cls.DEFAULT_REVISION in verify + + +def test_device_config_resolution(monkeypatch): + for name in ("DISPATCH", "NUM_CQS", "L1_SMALL", "TRACE_REGION", "WORKER_L1_SIZE"): + monkeypatch.delenv(f"{tdev.ENV_PREFIX}_{name}", raising=False) + monkeypatch.delenv("TT_DEVICE_ID", raising=False) + cfg = tdev.device_config() + assert (cfg.dispatch, cfg.device_id, cfg.allow_fallback) == ("eth", 0, True) + assert {k: getattr(cfg, k) for k in tdev.DEVICE_DEFAULTS} == tdev.DEVICE_DEFAULTS + assert cfg == FRNet.device_config() + monkeypatch.setenv("FRNET_DISPATCH", "worker") + monkeypatch.setenv("FRNET_TRACE_REGION", str(1 << 20)) + monkeypatch.setenv("TT_DEVICE_ID", "1") + cfg = tdev.device_config(num_command_queues=2, worker_l1_size=None) + assert (cfg.dispatch, cfg.trace_region_size, cfg.device_id, cfg.num_command_queues) == ("worker", 1 << 20, 1, 2) + assert cfg == FRNet.device_config(num_command_queues=2) + assert tdev.device_config(dispatch="eth").dispatch == "eth" # an explicit argument beats the environment + + +# ------------------------------------------------------------------------------- client and smoke test + +def test_client_script_runs_standalone(tmp_path): + """server/client.py runs with any python3 in isolated mode: no package install, no numpy needed.""" + np.save(tmp_path / "p.npy", np.zeros((2, len(tio.DEFAULT_POINT_FIELDS)), np.float32)) + subprocess.run([sys.executable, "-I", str(PKG_DIR / "server" / "client.py"), "--points", str(tmp_path / "p.npy"), + "--param", "class_probability_threshold=0.5", "--out", str(tmp_path / "req.json")], check=True, + capture_output=True, text=True) + req = json.loads((tmp_path / "req.json").read_text()) + assert req["points"]["format"] == "npy" and req["params"] == {"class_probability_threshold": 0.5} + + +class WorkerStub(StubModel): + """A server whose ETH open fell back to WORKER dispatch.""" + + device_info = dict(ETH_P150, dispatch="worker", fallback="RuntimeError: no ETH", grid="11x10", grid_x=11, + cores=110) + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@contextlib.contextmanager +def _serve(stub_cls) -> Iterator[str]: + """This bundle's real app under uvicorn on a free port with ``stub_cls`` as the model; yields the base URL.""" + uvicorn = pytest.importorskip("uvicorn") + pytest.importorskip("fastapi") + from tt_frnet.server import app as server + from tt_frnet.server import smoke_test + + state = server.app.state.ttaw + saved, state.model_factory = state.model_factory, stub_cls + srv = uvicorn.Server(uvicorn.Config(server.app, host="127.0.0.1", port=_free_port(), log_level="warning", + lifespan="on")) + thread = threading.Thread(target=srv.run, daemon=True) + thread.start() + base = f"http://127.0.0.1:{srv.config.port}" + try: + assert smoke_test.ttaw_client.wait_ready(base, wait_s=30, poll_s=0.1)["status"] == "ok" + yield base + finally: + srv.should_exit = True + thread.join(timeout=30) + state.model_factory = saved + + +def _staged_manifest(tmp_path: Path) -> Path: + """A staged tt_kernel_manifest.json (wire format) with a default profile and one whose pins differ.""" + env = {"TT_WEIGHTS_REVISION": FRNet.DEFAULT_REVISION, "FRNET_DISPATCH": "eth", + "FRNET_NUM_CQS": str(tdev.DEVICE_DEFAULTS["num_command_queues"]), + "FRNET_VARIANT": FRNet.DEFAULT_VARIANT} + wire = {"name": FRNet.MODEL_NAME, + "weights": {"repo_id": FRNet.DEFAULT_REPO, "revision": FRNet.DEFAULT_REVISION}, + "container": {"serve": {"env": env}, "default_profile": None, + "serve_profiles": [{"name": "default", "env": {}}, + {"name": "other", "env": {"FRNET_VARIANT": "not-a-variant"}}]}} + path = tmp_path / "tt_kernel_manifest.json" + path.write_text(json.dumps(wire)) + return path + + +def test_smoke_test_against_live_server(tmp_path, monkeypatch, capsys): + from tt_frnet.server import smoke_test + + monkeypatch.setenv("TT_MESH_SHAPE", "1x1") + monkeypatch.setenv("TT_MODEL_WEIGHTS_REVISION", FRNet.DEFAULT_REVISION) + for name in ("HF_MODEL", "FRNET_VARIANT", "FRNET_DISPATCH", "FRNET_NUM_CQS"): + monkeypatch.delenv(name, raising=False) + sample = tmp_path / "sample.npy" + np.save(sample, np.random.default_rng(0).normal(size=(50, len(tio.DEFAULT_POINT_FIELDS))).astype(np.float32)) + reference = StubModel()(sample).to_dict() # the stub plays the CPU reference of the sample + (tmp_path / "sample.reference.json").write_text(json.dumps(reference)) + shifted = json.loads(json.dumps(reference)) # every label moved to the next class: agreement 0 + shifted["labels"] = tio.encode_array((StubModel()(sample).label_ids + 1).astype(np.uint8), key="labels") + (tmp_path / "shifted.json").write_text(json.dumps(shifted)) + args = ["--input", str(sample), "--expect", "", "--out", str(tmp_path / "out.json")] + pinned = args + ["--manifest", str(_staged_manifest(tmp_path))] + + with _serve(StubModel) as url: + assert smoke_test.main(["--url", url] + pinned) == 0 + out = capsys.readouterr().out + assert out.startswith("PASS frnet-p150: profile=default") and "dispatch=eth grid=12x10" in out + assert "reference=sample.reference.json (labels agree=1.0000; scores max_abs=0;" in out + assert json.loads((tmp_path / "out.json").read_text())["num_points"] == 50 + assert smoke_test.main(["--url", url, "--profile", "other"] + pinned) == 1 + assert "profile 'other' pins FRNET_VARIANT=not-a-variant" in capsys.readouterr().out + assert smoke_test.main(["--url", url, "--reference", str(tmp_path / "shifted.json")] + args) == 1 + assert "labels: agreement 0.0000 < 0.985 (50 of 50 elements differ)" in capsys.readouterr().out + assert smoke_test.main(["--url", url, "--reference", str(tmp_path / "missing.json")] + args) == 1 + assert "reference " in capsys.readouterr().out + assert smoke_test.main(["--url", url, "--input", str(tmp_path / "missing.npy"), "--expect", ""]) == 1 + assert "/predict: FileNotFoundError" in capsys.readouterr().out # a FAIL line, not a traceback + with _serve(WorkerStub) as url: + assert smoke_test.main(["--url", url] + args) == 1 + out = capsys.readouterr().out + assert out.startswith("FAIL frnet-p150:") + assert "dispatch is 'worker', expected 'eth'" in out and "grid is '11x10', expected '12x10'" in out + + +# ------------------------------------------------------------------------- pip project and container smoke + +def test_pip_project_at_repo_root(): + """The pip project is the repo-root pyproject.toml over code/ (never code/pyproject.toml: tt-model copies code/ + over the tt-metal tree before building ttnn, PACKAGING_PILOT.md problem 1).""" + tomllib = pytest.importorskip("tomllib") + path = BUNDLE_DIR / "pyproject.toml" + if not (BUNDLE_DIR / "tt-model.yaml").is_file(): + pytest.skip("not a bundle source checkout (installed package or container image)") + assert not (CODE_DIR / "pyproject.toml").exists(), "the pip project lives at the repo root, never in code/" + project = tomllib.loads(path.read_text()) + find = project["tool"]["setuptools"]["packages"]["find"] + assert find["where"] == ["code"] and any(fnmatch.fnmatch("tt_frnet", pat) for pat in find["include"]) + data = project["tool"]["setuptools"]["package-data"] + assert {"API.md", "VENDORED.json"} <= set(data["tt_frnet.ttaw"]) + assert {"kernels/*.cpp", "kernels/*.hpp", "kernels/*.h"} <= set(data["tt_frnet.ttaw.ops"]) + assert (BUNDLE_DIR / project["project"]["readme"]).is_file() + assert project["tool"]["setuptools"]["dynamic"]["version"]["attr"] == "tt_frnet.__version__" + yaml = pytest.importorskip("yaml") + shipped = [p for e in yaml.safe_load((BUNDLE_DIR / "tt-model.yaml").read_text())["source"]["extra_code"] + for p in e["paths"]] + assert "pyproject.toml" not in shipped and "setup.py" not in shipped + setuptools = pytest.importorskip("setuptools") + found = set(setuptools.find_packages(where=str(CODE_DIR), include=find["include"], exclude=find.get("exclude", []))) + assert {"tt_frnet", "tt_frnet.ttaw", "tt_frnet.ttaw.server", "tt_frnet.ttaw.ops"} <= found + assert not any(p.startswith("tt_frnet.tests") for p in found) + + +FAKE_TT_MODEL = r''' +import json, os, signal, subprocess, sys, time, urllib.request +from pathlib import Path +state = Path(os.environ["FAKE_STATE"]) +args = sys.argv[1:] +with open(state / "calls.txt", "a") as f: + f.write(" ".join(args) + "\n") +opt = lambda name: args[args.index(name) + 1] if name in args else None +if args[0] == "serve": + port, manifest = int(opt("--port")), json.load(open(args[-1])) + (state / "container").write_text("tt-model-" + manifest["name"] + "-" + (opt("--profile") or "default")) + (state / "container.log").write_text("boot: Opening device 0 (dispatch eth, 1 CQ)\n") + if os.environ.get("FAKE_SERVE_FAIL"): + sys.exit("fake serve: the boot failed") + srv = subprocess.Popen([sys.executable, str(state / "server.py"), str(port)], start_new_session=True) + (state / "server.pid").write_text(str(srv.pid)) + for _ in range(200): + try: + urllib.request.urlopen("http://127.0.0.1:%d/health" % port, timeout=1) + break + except OSError: + time.sleep(0.05) +elif args[0] == "stop": + if (state / "server.pid").exists(): + os.kill(int((state / "server.pid").read_text()), signal.SIGTERM) + with open(state / "container.log", "a") as f: + f.write("shutdown: traces released, device closed\n") + (state / "stopped").write_text("1") +elif args[0] == "logs": + print("fallback: tt-model logs") +else: + sys.exit(2) +''' + +FAKE_DOCKER = r''' +import os, sys, time +from pathlib import Path +state = Path(os.environ["FAKE_STATE"]) +args = sys.argv[1:] +with open(state / "docker_calls.txt", "a") as f: + f.write(" ".join(args) + "\n") +known = (state / "container").read_text() if (state / "container").exists() else None +if args[0] == "inspect": + sys.exit(0 if args[-1] == known else 1) +if args[0] == "logs" and args[-1] == known: + deadline = time.time() + 20 + while "--follow" in args and not (state / "stopped").exists() and time.time() < deadline: + time.sleep(0.05) + sys.stdout.write((state / "container.log").read_text()) + sys.exit(0) +sys.exit(1) +''' + +FAKE_SERVER = r''' +import json, sys +from http.server import BaseHTTPRequestHandler, HTTPServer +INFO = {"model": "fake", "device": {"dispatch": "eth", "grid": "12x10", "cores": 120}} +class Handler(BaseHTTPRequestHandler): + def _send(self, code, body): + data = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + def do_GET(self): + self._send(200, {"status": "ok"} if self.path.startswith("/health") else INFO if self.path == "/info" else {}) + def do_POST(self): + self._send(500, {"detail": "fake server"}) + def log_message(self, *args): + pass +HTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever() +''' + + +def _fake_tools(tmp_path: Path) -> dict: + """PATH with fake `tt-model` / `docker` / `python3` (the test interpreter) and a stub server: no docker, no chip.""" + state, bin_dir = tmp_path / "state", tmp_path / "bin" + state.mkdir() + bin_dir.mkdir() + (state / "server.py").write_text(FAKE_SERVER) + for name, body in (("tt-model", FAKE_TT_MODEL), ("docker", FAKE_DOCKER)): + (bin_dir / name).write_text(f"#!{sys.executable}\n{body}") + (bin_dir / name).chmod(0o755) + (bin_dir / "python3").write_text(f'#!/bin/sh\nexec "{sys.executable}" "$@"\n') + (bin_dir / "python3").chmod(0o755) + env = {k: v for k, v in os.environ.items() if k not in ("SMOKE_OUT", "HF_TOKEN", "HUGGING_FACE_HUB_TOKEN")} + env.update(PATH=f"{bin_dir}:{env.get('PATH', '/usr/bin:/bin')}", FAKE_STATE=str(state)) + for tool in ("tt-model", "docker", "python3"): # never the real tools: a real serve would claim the chip + assert shutil.which(tool, path=env["PATH"]) == str(bin_dir / tool) + return env + + +@pytest.mark.parametrize("serve_fails", [False, True]) +def test_container_smoke_keeps_evidence_and_stops(tmp_path, serve_fails): + """code/scripts/container_smoke.sh passes --profile through, saves /info once READY and the container log through + the shutdown (whatever the outcome), and always stops the container.""" + staged = tmp_path / "staged" + staged.mkdir() + manifest = _staged_manifest(staged) + env, logs, port = _fake_tools(tmp_path), tmp_path / "evidence", _free_port() + profile = [] if serve_fails else ["--profile", "other"] + if serve_fails: + env["FAKE_SERVE_FAIL"] = "1" + r = subprocess.run(["bash", str(CODE_DIR / "scripts" / "container_smoke.sh"), str(staged), str(port), *profile, + f"--log-dir={logs}"], env=env, capture_output=True, text=True, timeout=180) + assert r.returncode == 1, r.stdout + r.stderr # the stub serves no valid /predict: the smoke FAILS + calls = (tmp_path / "state" / "calls.txt").read_text().splitlines() + assert calls[0] == " ".join(["serve", "--port", str(port), *profile, str(manifest)]) + assert calls[-1] == " ".join(["stop", *profile, str(manifest)]) and len(calls) == 2 + stem = "frnet-p150" + ("" if serve_fails else "-other") + "-" + files = {p.name.split(".", 1)[1]: p for p in logs.iterdir()} + assert all(p.name.startswith(stem) for p in files.values()), sorted(files) + log = files["container.log"].read_text() + assert "boot: Opening device 0" in log and "shutdown: traces released" in log # followed through the stop + result = json.loads(files["result.json"].read_text()) + assert (result["rc"], result["result"], result["profile"]) == (1, "FAIL", "default" if serve_fails else "other") + if serve_fails: + assert "info.json" not in files + else: + assert json.loads(files["info.json"].read_text())["device"] == {"dispatch": "eth", "grid": "12x10", + "cores": 120} + assert "FAIL frnet-p150:" in r.stdout + + +def test_serve_env_pins_the_knob_defaults(): + """The image pins every optimization / accuracy knob at its default (ttaw.knobs rule): tt-model.yaml serve.env + == ``tt.model.KNOBS.serve_env()`` on those keys.""" + import yaml + + from tt_frnet.tt.model import KNOBS + + path = BUNDLE_DIR / "tt-model.yaml" + if not path.is_file(): + pytest.skip("no tt-model.yaml next to code/ (installed package or container image)") + manifest = yaml.safe_load(path.read_text()) + env = {str(k): str(v) for k, v in manifest["serve"]["env"].items()} + pins = KNOBS.serve_env() + assert pins, "no knobs" + for key, value in pins.items(): + assert env.get(key) == str(value), (key, env.get(key), value) + + +# ------------------------------------------------------------------------------------------ release hygiene +SHIPPABLE = { + "samples": {"README.md", "synthetic_ot128.npz", "synthetic_qt128.npz", "synthetic_ot128.ot128.reference.json", + "synthetic_qt128.qt128.reference.json"}, + "calib": {"README.md", "synthetic_ot128.json", "synthetic_qt128.json"}, +} + + +def test_only_redistributable_samples_ship(): + """code/ ships only the synthetic samples (Apache-2.0, generated by code/scripts/make_sample.py): no PandaSet file + until the user approves PandaSet samples, no nuScenes / VLS-128 data, no ``*.bin`` (tt-model's CODE_IGNORE).""" + for sub, allowed in SHIPPABLE.items(): + names = {p.name for p in (PKG_DIR / sub).iterdir() if p.is_file()} + assert names == allowed, (sub, sorted(names ^ allowed)) + shipped_files = [p for p in CODE_DIR.rglob("*") if p.is_file() and "__pycache__" not in p.parts] + assert not [p for p in shipped_files if p.suffix == ".bin"] + data_like = [p for p in shipped_files if p.suffix in (".npz", ".npy", ".pcd", ".jpg", ".png", ".gif")] + assert {p.name for p in data_like} == {"synthetic_ot128.npz", "synthetic_qt128.npz"}, data_like + for p in (PKG_DIR / "samples").glob("*.json"): + body = json.loads(p.read_text()) + assert "generator" in body["meta"] and "make_sample.py" in body["meta"]["generator"], p + for p in (PKG_DIR / "calib").glob("*.json"): + assert "make_sample.py" in json.loads(p.read_text())["source"], p + + +def test_shipped_samples_are_reproducible(): + """The shipped scans are exactly what code/scripts/make_sample.py generates (deterministic, seed 0).""" + import importlib.util + + spec = importlib.util.spec_from_file_location("make_sample", CODE_DIR / "scripts" / "make_sample.py") + if spec is None or not (CODE_DIR / "scripts" / "make_sample.py").is_file(): + pytest.skip("code/scripts not present") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + for variant, s in mod.SENSORS.items(): + xyzi, _ = mod.synthetic_scan(s["fov_up_deg"], s["fov_down_deg"], s["rows"], s["cols"], seed=mod.SEED, + sensor_height=mod.SENSOR_HEIGHT) + stored = np.load(PKG_DIR / "samples" / f"synthetic_{variant}.npz", allow_pickle=False)["points"] + assert np.array_equal(stored, mod.structured(xyzi)), variant + preset = json.loads((PKG_DIR / "calib" / f"synthetic_{variant}.json").read_text()) + assert np.allclose(preset["T_base_link_from_sensor"], np.array([[1, 0, 0, 0], [0, 1, 0, 0], + [0, 0, 1, mod.SENSOR_HEIGHT], [0, 0, 0, 1]])) + + +def test_python_md_top_level_copy(): + """PYTHON.md is published twice: code/PYTHON.md (staged with code/) and the repo-root copy (overlay).""" + top = BUNDLE_DIR / "PYTHON.md" + if not (BUNDLE_DIR / "tt-model.yaml").is_file(): + pytest.skip("not a bundle source checkout (installed package or container image)") + assert top.read_text() == (CODE_DIR / "PYTHON.md").read_text() diff --git a/code/tt_frnet/tests/test_e2e_device.gates.json b/code/tt_frnet/tests/test_e2e_device.gates.json new file mode 100644 index 0000000000000000000000000000000000000000..0fce5545944430a537696eda386e74b6ddd38214 --- /dev/null +++ b/code/tt_frnet/tests/test_e2e_device.gates.json @@ -0,0 +1,90 @@ +{ + "version": 1, + "note": "Frozen at the first green run; never loosen (PLAN.md 0.3).", + "gates": { + "ot128.frames.min_argmax_agreement_raw": { + "threshold": 0.985, + "direction": "min", + "metric": "argmax_agreement", + "value_at_freeze": 0.9890718852935477, + "frozen_at": "2026-10-09T12:01:56+00:00" + }, + "ot128.frames.min_pred_probs_pcc": { + "threshold": 0.998, + "direction": "min", + "metric": "pcc", + "value_at_freeze": 0.9992741747629741, + "frozen_at": "2026-10-09T04:53:11+00:00" + }, + "ot128.sample.argmax_agreement_raw": { + "threshold": 0.985, + "direction": "min", + "metric": "argmax_agreement", + "value_at_freeze": 0.9933240063473583, + "frozen_at": "2026-10-08T07:11:26+00:00" + }, + "ot128.sample.pred_probs_pcc": { + "threshold": 0.998, + "direction": "min", + "metric": "pcc", + "value_at_freeze": 0.999812448588133, + "frozen_at": "2026-10-08T07:11:26+00:00" + }, + "ot128.shipped.filter_agreement": { + "threshold": 0.985, + "direction": "min", + "metric": "label_agreement", + "value_at_freeze": 0.9977191948757912, + "frozen_at": "2026-10-09T15:54:32+00:00" + }, + "ot128.shipped.label_agreement": { + "threshold": 0.985, + "direction": "min", + "metric": "label_agreement", + "value_at_freeze": 0.9954954098796875, + "frozen_at": "2026-10-09T15:54:32+00:00" + }, + "qt128.frames.min_argmax_agreement_raw": { + "threshold": 0.985, + "direction": "min", + "metric": "argmax_agreement", + "value_at_freeze": 0.9874960867723699, + "frozen_at": "2026-10-08T07:13:09+00:00" + }, + "qt128.frames.min_pred_probs_pcc": { + "threshold": 0.998, + "direction": "min", + "metric": "pcc", + "value_at_freeze": 0.999539015689557, + "frozen_at": "2026-10-08T07:13:09+00:00" + }, + "qt128.sample.argmax_agreement_raw": { + "threshold": 0.985, + "direction": "min", + "metric": "argmax_agreement", + "value_at_freeze": 0.98794377517582, + "frozen_at": "2026-10-08T07:13:08+00:00" + }, + "qt128.sample.pred_probs_pcc": { + "threshold": 0.998, + "direction": "min", + "metric": "pcc", + "value_at_freeze": 0.999539015689557, + "frozen_at": "2026-10-08T07:13:08+00:00" + }, + "qt128.shipped.filter_agreement": { + "threshold": 0.985, + "direction": "min", + "metric": "label_agreement", + "value_at_freeze": 0.9987204002702975, + "frozen_at": "2026-10-09T15:54:46+00:00" + }, + "qt128.shipped.label_agreement": { + "threshold": 0.985, + "direction": "min", + "metric": "label_agreement", + "value_at_freeze": 0.9962330884361567, + "frozen_at": "2026-10-09T15:54:46+00:00" + } + } +} diff --git a/code/tt_frnet/tests/test_e2e_device.py b/code/tt_frnet/tests/test_e2e_device.py new file mode 100644 index 0000000000000000000000000000000000000000..d51340c3f17205c54584db56ccb84d6d71680aa7 --- /dev/null +++ b/code/tt_frnet/tests/test_e2e_device.py @@ -0,0 +1,231 @@ +# SPDX-License-Identifier: Apache-2.0 +"""End-to-end agreement vs the CPU reference on PUBLIC sample data, API == server, and the first-cut latency (needs +one p150; milestone M3 of PORT_LOG.md). + + bin/devrun -t 1800 -- python -m pytest -q -s code/tt_frnet/tests/test_e2e_device.py + +The model is built once per variant (``FRNet.from_pretrained(device=..., variant=...)``: weights, graph, trace +capture) and every test runs the production path: the host pre-processing, the traced device graph, the host +post-processing. No dataset-level metric: the gate is agreement with the fp32 CPU reference of the same Autoware +network on the same inputs (ONNX Runtime's ``pred_probs`` stored in each research golden): + +- ``test_sample_agreement``: the PandaSet 019 f40 sample (staged, not shipped: ``tests/_data.py``) through the API + (``model(points, calibration=...)``): the device ``pred_probs`` of the frame vs ORT's (the research golden of the + same frame; the host pre-processing equals the research one bit for bit, ``test_host_vs_research.py``), and the + API's labels / filter mask vs its stored reference body (``..reference.json``); +- ``test_shipped_sample``: the shipped synthetic sample of the variant (``samples/synthetic_.npz`` with its + preset) through the API vs its stored CPU-reference body (``samples/synthetic_..reference.json``, + what the container smoke compares): label and filter-mask agreement on every output point; +- ``test_public_frames``: every catalogued golden of the variant (``tests/_data.py`` ``FRAMES``: 16 OT128 + 4 QT128 + public frames, the VLS-128 x > 0 halves), fed their stored network inputs through ``FRNet.device_probs``; inside + the workspace a missing golden fails the test (the frame-set gates never run on a subset); +- ``test_api_equals_server``: ``/predict``'s handler and ``model(...)`` give identical outputs (shipped sample); +- ``test_latency``: the first-cut latency (trace replay back to back, end to end through the API), recorded in + ``logs/frnet/latency_.json`` (no gate: speed is the optimization phase's). + +Gates (PLAN.md 2.8; frozen in ``test_e2e_device.gates.json``; keep ``server/smoke_test.py`` ``REFERENCE_GATES`` in +line): per frame ``pred_probs`` **PCC >= 0.998** and **argmax agreement >= 98.5 %** on the raw points; for the set, +the minimum over its frames is gated and every frame is reported in ``logs/frnet/e2e_.json``. +""" +from __future__ import annotations + +import base64 +import json +import os +import time + +import numpy as np +import pytest + +from tt_frnet.tests import _data +from tt_frnet.ttaw.golden import GateRegistry + +pytestmark = pytest.mark.device + +VARIANTS = ("ot128", "qt128") +LOG_DIR = _data.WORKSPACE / "logs" / "frnet" +TAG = os.environ.get("FRNET_E2E_TAG", "") # report-name suffix of an A/B run (e.g. FRNET_POINT_TERMS=2) +GATES = GateRegistry.for_test(__file__, { + **{f"{v}.sample.pred_probs_pcc": (0.998, "min", "pcc") for v in VARIANTS}, + **{f"{v}.sample.argmax_agreement_raw": (0.985, "min", "argmax_agreement") for v in VARIANTS}, + **{f"{v}.frames.min_pred_probs_pcc": (0.998, "min", "pcc") for v in VARIANTS}, + **{f"{v}.frames.min_argmax_agreement_raw": (0.985, "min", "argmax_agreement") for v in VARIANTS}, + # the shipped synthetic sample vs its stored CPU-reference body (the container smoke's REFERENCE_GATES) + **{f"{v}.shipped.label_agreement": (0.985, "min", "label_agreement") for v in VARIANTS}, + **{f"{v}.shipped.filter_agreement": (0.985, "min", "label_agreement") for v in VARIANTS}, +}) +# Frames per variant the frame-set gates must see (FRAMES: 16 OT128 / 4 QT128 public frames + the VLS-128 half). +EXPECTED_FRAMES = {"ot128": 17, "qt128": 5} +_REPORT: dict = {} + + +def _save(variant: str, key: str, value) -> None: + _REPORT.setdefault(variant, {})[key] = value + LOG_DIR.mkdir(parents=True, exist_ok=True) + (LOG_DIR / f"e2e_{variant}{TAG}.json").write_text(json.dumps(_REPORT[variant], indent=1, default=float)) + + +@pytest.fixture(scope="module", params=VARIANTS) +def model(request, device): + from tt_frnet import FRNet + + t0 = time.perf_counter() + m = FRNet.from_pretrained(device=device, variant=request.param, weights_dir=str(_data.weights_dir())) + m.load_s = time.perf_counter() - t0 + yield m + m.close() + + +def memory_view(device) -> dict: + """Allocated bytes of the trace region and of DRAM (``ttnn.get_memory_view``; {} if unavailable).""" + import ttnn + + out = {} + for name, bt in (("trace", "TRACE"), ("dram", "DRAM")): + try: + mv = ttnn.get_memory_view(device, getattr(ttnn.BufferType, bt)) + out[f"{name}_bytes_allocated"] = int(mv.total_bytes_allocated_per_bank) * int(mv.num_banks) + out[f"{name}_bytes_total"] = int(mv.total_bytes_per_bank) * int(mv.num_banks) + except Exception as e: # noqa: BLE001 -- reporting only + out[f"{name}_error"] = repr(e) + return out + + +def frame_metrics(probs: np.ndarray, ref: np.ndarray, n_raw: int) -> dict: + from tt_frnet.ttaw.metrics import argmax_agreement, error_stats + + stats = error_stats(probs, ref) + return {"pcc": stats["pcc"], "max_abs": stats["max_abs"], "rel_l2": stats["rel_l2"], + "argmax_agreement_raw": argmax_agreement(probs[:n_raw], ref[:n_raw]), + "argmax_agreement_all": argmax_agreement(probs, ref), "n": int(len(ref)), "n_raw": int(n_raw)} + + +def test_sample_agreement(model): + """The API on the staged PandaSet sample: device ``pred_probs`` vs ORT, labels vs the stored reference body.""" + from tt_frnet.ttaw.metrics import label_agreement + + v = model.variant + _data.need_pandaset() + name = f"{_data.SAMPLE_STEM}.{v}" + inputs_ref, ort_probs, _ = _data.load_frame(name) + prepared = model._prepare(str(_data.SAMPLE), calibration=_data.SAMPLE_CALIB) + assert prepared.skipped is None + for key in ("points", "coors", "voxel_coors", "inverse_map"): # identical network inputs + assert np.array_equal(getattr(prepared.inputs, key), getattr(inputs_ref, key)), key + with model._lock: + probs = model.device_probs(prepared.plan) + m = frame_metrics(probs, np.asarray(ort_probs, np.float32), prepared.inputs.num_points_raw) + out = model(str(_data.SAMPLE), calibration=_data.SAMPLE_CALIB).to_dict() + ref_body = json.loads((_data.SAMPLE_DIR / f"{_data.SAMPLE_STEM}.{v}.reference.json").read_text()) + from tt_frnet.ttaw.server.smoke import decode_array + + got_labels = np.asarray(decode_array(out["labels"]).values) + ref_labels = np.asarray(decode_array(ref_body["labels"]).values) + m["api_label_agreement"] = label_agreement(got_labels, ref_labels) + m["api_filter_agreement"] = float(np.mean(np.asarray(decode_array(out["filter_keep"]).values) == + np.asarray(decode_array(ref_body["filter_keep"]).values))) + m["timing_ms"] = out["timing_ms"] + _save(v, "sample", m) + print(f"[{v}] sample: {m}") + r1 = GATES.check(f"{v}.sample.pred_probs_pcc", m["pcc"]) + r2 = GATES.check(f"{v}.sample.argmax_agreement_raw", m["argmax_agreement_raw"]) + print(r1, r2) + assert r1.passed and r2.passed + assert m["api_label_agreement"] >= 0.985 # the smoke test's REFERENCE_GATES + assert out["num_points"] == len(ref_labels) and out["model"] == "frnet-p150" + + +def test_shipped_sample(model): + """The shipped synthetic sample of the variant through the API vs its stored CPU-reference body (what the + container smoke test compares, with the same 98.5 % label gate).""" + from tt_frnet.ttaw.server.smoke import decode_array + from tt_frnet.ttaw.metrics import label_agreement + + v = model.variant + sample = _data.shipped(v) + ref_body = json.loads((_data.SAMPLES / f"synthetic_{v}.{v}.reference.json").read_text()) + out = model(sample, calibration=_data.shipped_calib(v)).to_dict() + assert out["num_points"] == ref_body["num_points"] and out["num_points_raw"] == ref_body["num_points_raw"] + got_scores = np.asarray(decode_array(out["scores"]).values, np.float64) + ref_scores = np.asarray(decode_array(ref_body["scores"]).values, np.float64) + m = {"label_agreement": label_agreement(np.asarray(decode_array(out["labels"]).values), + np.asarray(decode_array(ref_body["labels"]).values)), + "filter_agreement": float(np.mean(np.asarray(decode_array(out["filter_keep"]).values) == + np.asarray(decode_array(ref_body["filter_keep"]).values))), + "max_abs_score_err": float(np.max(np.abs(got_scores - ref_scores))), + "num_points": out["num_points"], "num_points_raw": out["num_points_raw"], + "class_counts": out["class_counts"], "timing_ms": out["timing_ms"]} + _save(v, "shipped", m) + print(f"[{v}] shipped: {m}") + r1 = GATES.check(f"{v}.shipped.label_agreement", m["label_agreement"]) + r2 = GATES.check(f"{v}.shipped.filter_agreement", m["filter_agreement"]) + print(r1, r2) + assert r1.passed and r2.passed + + +def test_public_frames(model): + from tt_frnet.host.preprocess import build_plan + + v = model.variant + names = [n for n, (_, _, var) in _data.FRAMES.items() if var == v] + assert len(names) == EXPECTED_FRAMES[v], names + per = {} + for name in names: + inputs, ort_probs, _ = _data.load_frame(name) # a missing golden fails inside the workspace + plan = build_plan(inputs, model.config, pre_norm=(model.frnet_weights.pre_norm_scale, + model.frnet_weights.pre_norm_shift)) + with model._lock: + probs = model.device_probs(plan) + per[name] = frame_metrics(probs, np.asarray(ort_probs, np.float32), inputs.num_points_raw) + per[name]["m"] = plan.m + print(f"[{v}] {name}: pcc={per[name]['pcc']:.6f} agree_raw={per[name]['argmax_agreement_raw']:.5f}") + assert len(per) == EXPECTED_FRAMES[v], sorted(per) + worst_pcc = min(p["pcc"] for p in per.values()) + worst_agree = min(p["argmax_agreement_raw"] for p in per.values()) + _save(v, "frames", {"per_frame": per, "min_pred_probs_pcc": worst_pcc, "min_argmax_agreement_raw": worst_agree}) + r1 = GATES.check(f"{v}.frames.min_pred_probs_pcc", worst_pcc) + r2 = GATES.check(f"{v}.frames.min_argmax_agreement_raw", worst_agree) + print(r1, r2) + assert r1.passed and r2.passed + + +def test_api_equals_server(model, monkeypatch): + """The server's ``/predict`` handler and ``model(...)`` give identical outputs (same decoders, same graph).""" + pytest.importorskip("fastapi") + from tt_frnet.server.app import PredictRequest, app + + state = app.state.ttaw + monkeypatch.setattr(state, "model", model) + monkeypatch.setattr(state, "ready", True) + sample, calib = _data.shipped(model.variant), _data.shipped_calib(model.variant) + req = PredictRequest(points={"format": "npz", "data": base64.b64encode(sample.read_bytes()).decode()}, + calibration=calib) + served = state.predict(req) + api = model(sample, calibration=calib).to_dict() + for key in ("labels", "scores", "filter_keep", "points", "input_index", "class_counts", "num_points"): + assert served[key] == api[key], key + + +def test_latency(model): + """First-cut latency: trace replay back to back and the API end to end on the sample (reported only).""" + import ttnn + + v = model.variant + _data.need_pandaset() + model(_data.SAMPLE, calibration=_data.SAMPLE_CALIB) # inputs of the sample in the trace inputs + n = 10 + t0 = time.perf_counter() + model.runner.replay("default", n=n) + ttnn.synchronize_device(model.device) + replay_ms = (time.perf_counter() - t0) / n * 1e3 + timings = [] + for _ in range(3): + out = model(_data.SAMPLE, calibration=_data.SAMPLE_CALIB) + timings.append(out.timing_ms) + e2e = {k: float(np.median([t[k] for t in timings])) for k in timings[0]} + rec = {"replay_ms": round(replay_ms, 2), "api_timing_ms_median": e2e, "load_s": round(model.load_s, 1), + "device": model.info.get("device"), "memory": memory_view(model.device), + "knobs": model.graph.knobs.as_dict(), "num_command_queues": model.runner.num_command_queues} + _save(v, "latency", rec) + (LOG_DIR / f"latency_{v}{TAG}.json").write_text(json.dumps(rec, indent=1, default=float)) + print(f"[{v}] latency: {rec}") diff --git a/code/tt_frnet/tests/test_host_autoware_units.py b/code/tt_frnet/tests/test_host_autoware_units.py new file mode 100644 index 0000000000000000000000000000000000000000..cf483bbbe424535ab3ec4c6bc3b6d2096f2fab9b --- /dev/null +++ b/code/tt_frnet/tests/test_host_autoware_units.py @@ -0,0 +1,316 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests of the Autoware ``autoware_lidar_frnet`` semantics the host code reproduces (numpy only: no weights, no +research data, no device). Each test names the CUDA / C++ lines it pins (universe ``9ceaccf``, +``PKG = perception/autoware_lidar_frnet``): + +- ego crop (``preprocess_kernel.cu:211-228``): strict open box, float32 evaluation, enabled iff a float32 bound != 0; +- ``project2d`` (L151-163): float32, NaN -> 0, clamp then truncate, the column / row conventions; +- interpolation grid (L232-258, L116-141): farthest point by fp16 depth^2, ties to the later point, fp16 storage; +- ``interpolatePoints_kernel`` (L332-379): both neighbours filled, 1 <= x <= W - 2, no wrap, fp16 sum, appended in + row-major order after the raw points; +- ``generateUniqueCoors`` (L403-468): ascending keys, inverse map = cell rank; +- profile checks (``lidar_frnet.cpp:138-149, 227-254``), message order; +- ``fill_cloud_kernel`` (``postprocess_kernel.cu:200-264``): first max wins, NaN never wins, the -1e9 start and the + ``num_classes - 1`` default, the float32 ``>=`` filter on ANY filter class, the packed palette (utils.hpp); +- the TT plan (``host/preprocess.py`` ``build_plan``): segments, last-voxel-wins pixel tables, padding, the log-step + shift tables, the float64 feature decoration. + + TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests/test_host_autoware_units.py +""" +from __future__ import annotations + +import numpy as np +import pytest + +from tt_frnet.host import preprocess as P +from tt_frnet.host.postprocess import (RUNTIME_PARAMS, FRNetPostprocessor, FRNetSegmentation, autoware_argmax, + filter_keep_mask, visualization_rgb) +from tt_frnet.reference.config import NODE_PARAMS, FRNetConfig, parse_crop_box, parse_filter_classes +from tt_frnet.ttaw.io import InputError + +NAMES = ("drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "bus", + "emergency_vehicle", "train", "truck", "tractor_unit", "semi_trailer", "construction_vehicle", "forklift", + "kart", "motorcycle", "bicycle", "pedestrian", "personal_mobility", "animal", "pushable_pullable", + "traffic_cone", "debris", "stroller", "other_stuff", "noise+ghost_point", "unknown") + + +def cfg(**kw) -> FRNetConfig: + """A small OT128-like configuration (FOV +15 / -25 deg) with a tiny grid for exact hand checks.""" + base = dict(variant="ot128", version="v2.0", fov_up_deg=15.0, fov_down_deg=-25.0, frustum_w=16, frustum_h=8, + interp_w=32, interp_h=8, class_names=NAMES, palette=tuple(range(81)), + num_points=(1, 10, 1000), num_unique=(1, 10, 500)) + base.update(kw) + c = FRNetConfig(**base) + c.validate() + return c + + +# ------------------------------------------------------------------------------------------------ config + +def test_config_validation_mirrors_the_node(): + with pytest.raises(ValueError, match="multiple of 3"): + cfg(palette=tuple(range(80))) + with pytest.raises(ValueError, match="does not match class names"): + cfg(palette=tuple(range(84))) + with pytest.raises(ValueError, match="0-255"): + cfg(palette=(256,) + tuple(range(80))) + with pytest.raises(ValueError, match="Filter class 'road' not found"): + cfg(filter_classes=("road",)) + c = cfg() + assert c.filter_class_indices() == (0,) and c.filter_class_indices(["car", "bus"]) == (5, 6) + assert c.crop_enabled and not cfg(crop_bounds=(0.0,) * 6).crop_enabled + assert cfg(crop_bounds=(0, 0, 0, 0, 0, 1e-46)).crop_enabled is False # float32 cast of a denormal: 0 + up, down, total = c.fov + assert up.dtype == np.float32 and down == np.float32(np.pi * -25.0 / 180.0) + assert total == np.float32(np.pi * 40.0 / 180.0) + + +def test_palette_is_packed_like_make_palette(): + c = cfg(palette=(255, 0, 255) + (0, 150, 245) + (0,) * 75) + bits = c.palette_packed.view(np.uint32) + assert bits[0] == (255 << 16 | 0 << 8 | 255) and bits[1] == (0 << 16 | 150 << 8 | 245) and bits[2] == 0 + assert visualization_rgb(np.array([1, 0, 1], np.uint8), c).view(np.uint32).tolist() == [bits[1], bits[0], bits[1]] + + +def test_crop_box_and_filter_class_parsing(): + assert parse_crop_box(None) == (0.0,) * 6 and parse_crop_box([1, 2, 3, 4, 5, 6]) == (1.0, 2.0, 3.0, 4.0, 5.0, 6.0) + box = parse_crop_box({"max_x": 9.0}) + assert box[3] == 9.0 and box[0] == NODE_PARAMS["ego_crop_box"]["min_x"] + for bad in ([1, 2, 3], {"min_q": 1}, [1, 2, 3, 4, 5, float("nan")], {"reference_frame": "map"}): + with pytest.raises(ValueError): + parse_crop_box(bad) + assert parse_filter_classes("drivable_surface, sidewalk") == ("drivable_surface", "sidewalk") + assert parse_filter_classes("") == () and parse_filter_classes(["car"]) == ("car",) + with pytest.raises(ValueError): + parse_filter_classes("car;drop table") + + +# ------------------------------------------------------------------------------------------- pre-processing + +def test_crop_is_a_strict_open_box_in_float32(): + T = P.crop_transform_12(np.eye(4)) + b = np.array([-1.0, -1.0, -1.0, 1.0, 1.0, 1.0], np.float32) + xyz = np.array([[0, 0, 0], [1, 0, 0], [0.999, 0.999, 0.999], [-1, 0.5, 0.5], [2, 0, 0], [0, 0, -1.0001]], + np.float32) + assert P.crop_keep_mask(xyz, T, b).tolist() == [False, True, False, True, True, True] + # a translation (sensor 2 m above base_link): points around the sensor origin map to z ~ 2 + T2 = np.eye(4) + T2[2, 3] = 2.0 + keep = P.crop_keep_mask(np.array([[0, 0, 0], [0, 0, -1.5]], np.float32), P.crop_transform_12(T2), b) + assert keep.tolist() == [True, False] + t12 = P.crop_transform_12(np.arange(16, dtype=np.float64).reshape(4, 4) / 7.0) + assert t12.dtype == np.float32 and t12[9] == np.float32(3 / 7.0) and t12[3] == np.float32(4 / 7.0) + + +def test_project2d_conventions(): + c = cfg() + fov = c.fov + # +x -> column W/2; +y (left) -> W/4; -y (right) -> 3W/4; the seam (u = 0) is behind the sensor + xyz = np.array([[10, 0, 0], [0, 10, 0], [0, -10, 0], [-10, 1e-3, 0]], np.float32) + u, v = P.project2d(xyz, 16, 8, fov) + assert u.tolist() == [8, 4, 12, 0] + # up / down: pitch +15 deg -> row 0 (clamped), -25 deg and below -> last row (no FOV filter) + xyz = np.array([[1, 0, np.tan(np.radians(16))], [1, 0, np.tan(np.radians(-40))]], np.float32) + _, v = P.project2d(xyz, 16, 8, fov) + assert v.tolist() == [0, 7] + # a zero-range point: pitch = asinf(0 / 0) = NaN -> row 0 (fmaxf), yaw = -atan2f(0, 0) = -0 -> column W/2 + u, v = P.project2d(np.zeros((1, 3), np.float32), 16, 8, fov) + assert (u[0], v[0]) == (8, 0) + + +def test_interpolation_grid_keeps_the_farthest_point_fp16_and_later_on_ties(): + c = cfg() + raw = np.array([[10.0, 0.0, 0.0, 1.0], [20.0, 0.0, 0.0, 2.0], [15.0, 0.0, 0.0, 3.0], # same pixel + [5.0003, 0.0, 0.0, 4.0], [5.0001, 0.0, 0.0, 5.0]], np.float32) # fp16-equal depths + u, v = P.project2d(raw[:, :3], c.interp_w, c.interp_h, c.fov) + assert len(set((v * c.interp_w + u).tolist())) == 1 + grid, filled = P.interpolation_grid(raw, c) + pix = int(v[0] * c.interp_w + u[0]) + assert filled.sum() == 1 and filled[pix] + assert grid[pix].astype(np.float32).tolist() == [20.0, 0.0, 0.0, 2.0] + grid, _ = P.interpolation_grid(raw[3:], c) # 5.0003 and 5.0001 round to the same fp16: later wins + assert np.float16(5.0003) == np.float16(5.0001) and grid[pix][3] == np.float16(5.0) + + +def test_interpolate_points_rules(): + c = cfg(interp_w=8, interp_h=2) + grid = np.zeros((16, 4), np.float16) + filled = np.zeros(16, bool) + + def put(x, y, val): + filled[y * 8 + x] = True + grid[y * 8 + x] = val + + put(0, 0, [1, 1, 1, 10]) + put(2, 0, [2, 2, 2, 11]) # (1, 0) is interpolated: both neighbours filled + put(5, 0, [65504, 0, 0, 0]) + put(7, 0, [65504, 0, 0, 0]) # (6, 0): x = W - 2 is allowed; the fp16 sum overflows to inf (__hadd) + put(0, 1, [3, 3, 3, 3]) + put(6, 1, [3, 3, 3, 3]) # (7, 1) would need a right neighbour: x = W - 1 is never filled (no wrap) + out = P.interpolate_points(grid, filled, c) + assert out.shape == (2, 4) # (3, 0) and (4, 0) miss a neighbour + np.testing.assert_array_equal(out[0], np.float32([1.5, 1.5, 1.5, 10.5])) + assert np.isinf(out[1, 0]) and out[1, 3] == 0 + + +def test_unique_coors_is_a_key_sort(): + coors = np.array([[0, 2, 3], [0, 0, 1], [0, 2, 3], [0, 0, 0], [0, 1, 5]], np.int64) + vox, inv = P.unique_coors(coors, 16, 8) + assert vox.tolist() == [[0, 0, 0], [0, 0, 1], [0, 1, 5], [0, 2, 3]] and inv.tolist() == [3, 1, 3, 0, 2] + + +def test_autoware_preprocess_crop_needs_a_transform_and_counts(): + c = cfg() + rng = np.random.default_rng(0) + pts = np.c_[rng.uniform(-20, 20, (300, 3)), rng.uniform(0, 255, 300)].astype(np.float32) + with pytest.raises(ValueError, match="no sensor -> base_link transform"): + P.autoware_preprocess(pts, c) + off = P.autoware_preprocess(pts, c, crop_bounds=(0,) * 6) + assert off.keep_mask.all() and not off.crop_applied and off.num_points_raw == 300 + on = P.autoware_preprocess(pts, c, sensor_to_ref_12=P.crop_transform_12(np.eye(4)), + crop_bounds=(-5, -5, -5, 5, 5, 5)) + assert on.num_points_raw == int(on.keep_mask.sum()) < 300 + np.testing.assert_array_equal(on.points[:on.num_points_raw], pts[on.keep_mask]) + assert on.points.dtype == np.float32 and on.coors.dtype == np.int64 and on.inverse_map.max() + 1 == on.num_voxels + + +def test_profile_checks_follow_the_node_order(): + c = cfg(num_points=(10, 20, 30), num_unique=(2, 5, 8)) + + def inputs(n, m): + return P.NetworkInputs(np.zeros((n, 4), np.float32), np.zeros((n, 3), np.int64), np.zeros((m, 3), np.int64), + np.arange(n) % m, n, np.ones(n, bool)) + + assert "(9) in input pointcloud" in P.profile_errors(9, None, c)[0] + assert "(31) in input pointcloud" in P.profile_errors(31, None, c)[0] + assert P.profile_errors(10, None, c) == [] and P.profile_errors(25, inputs(20, 5), c) == [] + assert "(31) after interpolation" in P.profile_errors(25, inputs(31, 3), c)[0] + assert "(9) after interpolation" in P.profile_errors(25, inputs(9, 3), c)[0] + assert "unique coors (9)" in P.profile_errors(25, inputs(20, 9), c)[0] + assert "unique coors (1)" in P.profile_errors(25, inputs(20, 1), c)[0] + assert "in input pointcloud" in P.profile_errors(5, inputs(31, 9), c)[0] # the input count comes first + assert "after interpolation" in P.profile_errors(25, inputs(31, 9), c)[0] # then N, then M + + +# ------------------------------------------------------------------------------------------ post-processing + +def test_argmax_first_max_and_nan_rules(): + p = np.array([[0.2, 0.5, 0.5], [np.nan, 0.1, 0.1], [np.nan] * 3, [0.0, 0.0, 0.0], [-2e9, -3e9, -1e10]], + np.float32) + cls, prob = autoware_argmax(p) + assert cls.tolist() == [1, 1, 2, 0, 2] # ties: first; NaN skipped; all-NaN / all <= -1e9: C - 1 + assert prob[0] == np.float32(0.5) and prob[2] == np.float32(-1e9) and prob[4] == np.float32(-1e9) + assert cls.dtype == np.uint8 and prob.dtype == np.float32 + + +def test_filter_is_float32_ge_on_any_filter_class(): + p = np.zeros((4, 27), np.float32) + p[0, 0] = np.float32(0.05) # == float32(0.05): filtered (>=) + p[1, 0] = np.nextafter(np.float32(0.05), np.float32(0)) # just below: kept + p[2, 2] = 0.9 # only the second filter class + p[3, :] = np.nan # NaN compares false: kept + assert filter_keep_mask(p, (0,), 0.05).tolist() == [False, True, True, True] + assert filter_keep_mask(p, (0, 2), 0.05).tolist() == [False, True, False, True] + assert filter_keep_mask(p, (), 0.05).all() + + +def test_postprocessor_output_and_request_errors(): + c = cfg() + post = FRNetPostprocessor(c) + rng = np.random.default_rng(1) + probs = rng.dirichlet(np.ones(27), size=6).astype(np.float32) + pts = rng.normal(size=(6, 4)).astype(np.float32) + params = {k: v[3] for k, v in RUNTIME_PARAMS.items()} + out = post(probs, points=pts, num_points_raw=4, input_index=np.array([0, 2, 3, 5]), params=params, + model="m", frame_id="lidar_top") + assert isinstance(out, FRNetSegmentation) and len(out) == 6 and out.num_points_raw == 4 + assert out.label_ids.tolist() == probs.argmax(1).tolist() + body = out.to_dict() + assert body["num_points"] == 6 and body["num_points_raw"] == 4 and body["frame_id"] == "lidar_top" + assert {"labels", "scores", "points", "filter_keep", "input_index", "class_counts"} <= set(body) + assert body["meta"]["filter"]["classes"] == ["drivable_surface"] + with pytest.raises(InputError, match="filter_classes"): + post(probs, points=pts, num_points_raw=4, input_index=np.arange(4), + params=dict(params, filter_classes="road")) + with pytest.raises(ValueError): + FRNetSegmentation(np.zeros(3, np.uint8), points=np.zeros((3, 4)), input_index=np.arange(2), num_points_raw=3) + empty = post.empty(model="m", meta={"skipped": "x"}).to_dict() + assert empty["num_points"] == 0 and empty["meta"]["skipped"] == "x" + + +# --------------------------------------------------------------------------------------------- the TT plan + +def _frame(c, n=400, seed=0): + rng = np.random.default_rng(seed) + pts = np.c_[rng.uniform(-30, 30, (n, 2)), rng.uniform(-3, 2, n), rng.uniform(0, 255, n)].astype(np.float32) + return P.autoware_preprocess(pts, c, crop_bounds=(0,) * 6) + + +def test_plan_segments_and_tables(): + c = cfg(frustum_w=32, frustum_h=16, interp_w=64, interp_h=16) + inp = _frame(c) + plan = P.build_plan(inp, c, n_cap=inp.num_points + 37, m_cap=inp.num_voxels + 11) + n, m = inp.num_points, inp.num_voxels + # sorted by voxel, stable: each cell keeps its input order + np.testing.assert_array_equal(plan.seg_id[:n], np.sort(inp.inverse_map, kind="stable")) + assert (plan.seg_id[n:] == plan.m_cap).all() + for v in range(m): + rows = plan.order[plan.seg_offsets[v]:plan.seg_offsets[v + 1]] + assert (inp.inverse_map[rows] == v).all() and (np.diff(rows) > 0).all() + assert plan.seg_last[v] == plan.seg_offsets[v + 1] - 1 + assert (plan.seg_last[m:] == plan.n_cap - 1).all() + np.testing.assert_array_equal(plan.unsort(np.arange(plan.n_cap)), plan.rank) + # pixel tables: stride 1 is a bijection voxel <-> occupied pixel; stride s keeps the LAST voxel per pixel + for s in plan.strides: + w = c.frustum_w // s + pix = (inp.voxel_coors[:, 1] // s) * w + inp.voxel_coors[:, 2] // s + expect = np.full((c.frustum_h // s) * w, plan.m_cap, np.int64) + for v, p in enumerate(pix): # sequential ScatterND: later rows overwrite + expect[p] = v + np.testing.assert_array_equal(plan.pix2vox[s], expect) + cs = inp.coors[plan.order] + np.testing.assert_array_equal(plan.point2pix[s][:n], (cs[:, 1] // s) * w + cs[:, 2] // s) + assert (plan.point2pix[s][n:] == 0).all() + assert plan.features.shape == (plan.n_cap, 8) and (plan.features[n:] == 0).all() + with pytest.raises(ValueError, match="exceeds the capacity"): + P.build_plan(inp, c, n_cap=n - 1) + + +def test_decorate_points_matches_float64_formula_and_pre_norm(): + c = cfg() + inp = _frame(c, n=200, seed=3) + f = P.decorate_points(inp.points, inp.inverse_map, inp.num_voxels) + p = inp.points.astype(np.float64) + for v in range(inp.num_voxels): + rows = inp.inverse_map == v + mean = p[rows, :3].mean(0) + np.testing.assert_allclose(f[rows, 5:], (p[rows, :3] - mean).astype(np.float32), rtol=0, atol=2e-6) + np.testing.assert_allclose(f[:, 4], np.linalg.norm(p[:, :3], axis=1), rtol=1e-7) + np.testing.assert_array_equal(f[:, :4], inp.points) + s, t = np.arange(1, 9, dtype=np.float32) / 10, np.arange(8, dtype=np.float32) + g = P.decorate_points(inp.points, inp.inverse_map, inp.num_voxels, pre_norm=(s, t)) + np.testing.assert_allclose(g, f.astype(np.float64) * s + t, rtol=1e-6, atol=1e-5) + + +@pytest.mark.parametrize("max_len", [1, 2, 3, 17, 64, 111]) +def test_logstep_shift_tables_give_the_segment_max(max_len): + rng = np.random.default_rng(max_len) + lengths = rng.integers(1, max_len + 1, size=40) + lengths[0] = max_len + seg = np.repeat(np.arange(len(lengths)), lengths) + seg = np.r_[seg, np.full(13, 999)] # padded rows: one trailing sentinel segment + x = rng.normal(size=(len(seg), 3)) + x[0] = 100.0 # segment 0 (the longest) has its max on its FIRST row + rounds = P.segmax_rounds(max_len) + assert 2 ** rounds >= max_len and (rounds == 0 or 2 ** (rounds - 1) < max_len) + y = x.copy() + for idx in P.segmax_shift_tables(seg, rounds): + y = np.maximum(y, y[idx]) + last = np.cumsum(lengths) - 1 + for v, end in enumerate(last): + np.testing.assert_array_equal(y[end], x[seg == v].max(0)) + if max_len > 2: # one round fewer is not enough for the longest segment + y = x.copy() + for idx in P.segmax_shift_tables(seg, rounds - 1): + y = np.maximum(y, y[idx]) + assert not np.array_equal(y[last[0]], x[seg == 0].max(0)) diff --git a/code/tt_frnet/tests/test_host_vs_research.py b/code/tt_frnet/tests/test_host_vs_research.py new file mode 100644 index 0000000000000000000000000000000000000000..43885f7b525f53b6ce05008cff2e957d2eef15d6 --- /dev/null +++ b/code/tt_frnet/tests/test_host_vs_research.py @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The host pre- / post-processing (``tt_frnet.host``) against the research reference scripts and goldens +(research/frnet; skipped where absent, except the stored-sha256 check, which needs nothing): + +- pre-processing == ``research/frnet/scripts/frnet_preproc_ref.py`` (deterministic Autoware CUDA semantics) and == + the independent from-scratch re-implementation ``verify_preproc_ort_independent.py``, bit for bit, on the shipped + sample (with its crop transform), the research synthetic scans and the VLS-128 x > 0 half, both variants; +- the request path (``FRNetPreprocessor``: decoding, calibration, crop, profile checks) reproduces the four network + inputs and the crop mask of EVERY catalogued research golden (16 OT128 + 4 QT128 public frames, 2 VLS-128 frames) + bit for bit; +- post-processing == the research ``postprocess`` (class_id, probability, filtered mask) on the stored ORT + ``pred_probs`` of every golden; +- ``FRNetConfig`` read from the weights' yaml == the research configuration. + + TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests/test_host_vs_research.py +""" +from __future__ import annotations + +import hashlib +import json + +import numpy as np +import pytest + +from tt_frnet.host import preprocess as P +from tt_frnet.host.frame import FRNetPreprocessor +from tt_frnet.host.postprocess import RUNTIME_PARAMS, FRNetPostprocessor, autoware_argmax, filter_keep_mask +from tt_frnet.reference.config import VARIANTS, FRNetConfig +from tt_frnet.tests import _data + +FEEDS = ("points", "coors", "voxel_coors", "inverse_map") + + +def sha(a) -> str: + return hashlib.sha256(np.ascontiguousarray(a).tobytes()).hexdigest() + + +@pytest.fixture(scope="module") +def configs(): + w = _data.weights_dir() + return {v: FRNetConfig.from_model_dir(w, v) for v in VARIANTS} + + +def _assert_same_inputs(ours: P.NetworkInputs, ref: dict, prefix: str = "") -> None: + for k in FEEDS: + a, b = getattr(ours, k), ref[prefix + k] + assert a.shape == b.shape and a.dtype == b.dtype and np.array_equal(a, b), k + if prefix + "num_points_raw" in ref: + assert ours.num_points_raw == int(ref[prefix + "num_points_raw"]) + + +def test_config_equals_the_research_configuration(configs): + R = _data.research_module("frnet_preproc_ref") + for v, c in configs.items(): + r = R.CFGS[v] + assert (c.fov_up_deg, c.fov_down_deg, c.frustum_w, c.frustum_h, c.interp_w, c.interp_h) == ( + r["fov_up_deg"], r["fov_down_deg"], r["frustum_w"], r["frustum_h"], r["interp_w"], r["interp_h"]) + assert c.num_points == tuple(r["num_points"]) and c.num_unique == tuple(r["num_unique"]) + assert tuple(c.fov) == tuple(R.fov_rad(r)) + assert c.research_cfg()["frustum_w"] == r["frustum_w"] + + +def _sample_cloud(): + z = np.load(_data.need_pandaset(), allow_pickle=False)["points"] + return np.stack([z[k] for k in ("x", "y", "z", "intensity")], 1).astype(np.float32) + + +def _sample_crop12(): + calib = json.loads(_data.SAMPLE_CALIB_FILE.read_text()) + return P.crop_transform_12(np.asarray(calib["T_base_link_from_sensor"], np.float64)) + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_preprocess_equals_research_reference(configs, variant): + R = _data.research_module("frnet_preproc_ref") + c = configs[variant] + cases = {"sample": (_sample_cloud(), _sample_crop12(), c.crop_bounds)} + pts, _ = R.synthetic_scan(R.CFGS[variant], n_cols=600 if variant == "qt128" else 1200, seed=0) + cases["synthetic"] = (pts, None, (0.0,) * 6) + vls = _data.research_dir() / "samples" / "vls128_frame000.npy" + if vls.is_file(): + raw = np.load(vls).astype(np.float32) + cases["vls128_front"] = (raw[raw[:, 0] > 0], None, (0.0,) * 6) + for name, (xyzi, crop, bounds) in cases.items(): + ours = P.autoware_preprocess(xyzi, c, sensor_to_ref_12=crop, crop_bounds=bounds) + ref = R.autoware_preprocess(xyzi, R.CFGS[variant], sensor_to_ref_12=crop, + crop_bounds=None if crop is None else bounds, variant="autoware") + _assert_same_inputs(ours, ref) + assert np.array_equal(ours.keep_mask, ref["keep_mask"]), name + assert (ours.num_points, ours.num_voxels) == (ref["num_points"], ref["num_unique"]), name + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_preprocess_equals_independent_reimplementation(configs, variant): + V = _data.research_module("verify_preproc_ort_independent") + raw = np.load(_data.research_dir() / "samples" / "vls128_frame000.npy").astype(np.float32) + raw = raw[raw[:, 0] > 0] + ours = P.autoware_preprocess(raw, configs[variant], crop_bounds=(0.0,) * 6) + ref = V.autoware_pre(raw, V.CFG[variant]) + _assert_same_inputs(ours, ref) + assert ours.num_points_raw == ref["n_raw"] and ours.num_interpolated == ref["n_interp"] + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_preprocess_matches_stored_research_sha256(configs, variant): + """Needs no research tree (only the staged PandaSet sample): the sha256s of the research reference's outputs stored by make_goldens.py --set host.""" + stored = json.loads((_data.GOLDENS / "preprocess.json").read_text())["cases"][f"{_data.SAMPLE_STEM}.{variant}"] + xyzi = _sample_cloud() + assert sha(xyzi) == stored["input_sha256"] + ours = P.autoware_preprocess(xyzi, configs[variant], sensor_to_ref_12=_sample_crop12()) + assert (ours.num_points_raw, ours.num_points, ours.num_voxels) == (stored["N_raw"], stored["N"], stored["M"]) + assert {k: sha(getattr(ours, k)) for k in FEEDS} == {k: stored[k] for k in FEEDS} + + +@pytest.mark.parametrize("frame", sorted(_data.FRAMES)) +def test_request_path_reproduces_every_research_golden(configs, frame): + """``FRNetPreprocessor`` (the API's ``_prepare``) on the cloud each golden handed to the node: the same four + inputs, crop mask and raw count, bit for bit; and the host post-processing on the stored ORT probabilities + gives the golden's class ids, probabilities and filtered mask.""" + _, prefix, variant = _data.FRAMES[frame] + inputs, probs, g = _data.load_frame(frame) + c = configs[variant] + if "crop_sensor_to_ref_12" in g.files: + calib = {"T_base_link_from_sensor": None} + T = np.eye(4) + T12 = g["crop_sensor_to_ref_12"].astype(np.float64) + T[:3, :3], T[:3, 3] = T12[:9].reshape(3, 3), T12[9:] + calib = {"T_base_link_from_sensor": T.tolist(), "ego_crop_box": g["crop_bounds"].astype(np.float64).tolist()} + # the float32 crop transform must round-trip: float32 -> float64 -> float32 is exact + assert np.array_equal(P.crop_transform_12(T), g["crop_sensor_to_ref_12"]) + else: + calib = {"ego_crop_box": None} # the VLS-128 goldens: no ego crop + prep = FRNetPreprocessor(c, calib_dir=None, plan=False)(g["raw_points"], calib) + assert prep.skipped is None + _assert_same_inputs(prep.inputs, g, prefix) + if prefix + "keep_mask" in g.files: + assert np.array_equal(prep.inputs.keep_mask, g[prefix + "keep_mask"]) + np.testing.assert_array_equal(prep.input_index, np.flatnonzero(prep.inputs.keep_mask)) + if prefix + "class_id" in g.files: + cls, prob = autoware_argmax(probs) + keep = filter_keep_mask(probs, c.filter_class_indices(), c.class_probability_threshold) + assert np.array_equal(cls, g[prefix + "class_id"]) + assert np.array_equal(prob, g[prefix + "probability"]) + assert np.array_equal(keep, g[prefix + "filter_keep"]) + R = _data.research_module("frnet_preproc_ref") + rc, rp, rk = R.postprocess(probs) + assert np.array_equal(cls, rc) and np.array_equal(prob, rp) and np.array_equal(keep, rk) + out = FRNetPostprocessor(c)(probs, points=prep.inputs.points, num_points_raw=prep.inputs.num_points_raw, + input_index=prep.input_index, + params={k: v[3] for k, v in RUNTIME_PARAMS.items()}) + assert np.array_equal(out.label_ids, cls) and np.array_equal(out.filter_keep, keep) diff --git a/code/tt_frnet/tests/test_io_host.py b/code/tt_frnet/tests/test_io_host.py new file mode 100644 index 0000000000000000000000000000000000000000..70392734c48a59d4e9a902c8f4bc7db7443c1321 --- /dev/null +++ b/code/tt_frnet/tests/test_io_host.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Host tests (no device, no weights): the request decoders shared by the API and the server, i.e. the vendored +``ttaw.io`` as this bundle binds it (its full suite is common/tests/host/test_io_outputs_host.py). + + TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests/test_io_host.py +""" +from __future__ import annotations + +import base64 +import io +import math + +import numpy as np +import pytest + +from tt_frnet import io as tio +from tt_frnet.api import FRNet + +PCD_FIELDS = ("x", "y", "z", "intensity") + + +def _b64(b: bytes) -> str: + return base64.b64encode(b).decode("ascii") + + +@pytest.fixture +def cloud(): + """A float32 cloud in this model's default layout (DEFAULT_POINT_FIELDS columns).""" + rng = np.random.default_rng(0) + pts = rng.normal(size=(1000, len(tio.DEFAULT_POINT_FIELDS))).astype(np.float32) + pts[:, 3:] = rng.uniform(0, 255, (1000, pts.shape[1] - 3)) + return pts + + +def _pcd(pts: np.ndarray, mode: str) -> bytes: + head = ("# .PCD v0.7 - Point Cloud Data file format\nVERSION 0.7\nFIELDS x y z intensity\nSIZE 4 4 4 4\n" + f"TYPE F F F F\nCOUNT 1 1 1 1\nWIDTH {len(pts)}\nHEIGHT 1\nVIEWPOINT 0 0 0 1 0 0 0\n" + f"POINTS {len(pts)}\nDATA {mode}\n").encode("ascii") + if mode == "ascii": + return head + "\n".join(" ".join(f"{v:.9g}" for v in row) for row in pts).encode("ascii") + b"\n" + if mode == "binary": + return head + pts.astype("/pandaset_019_40.taps.npz``: torch fp32 reference, every tap within PCC + 0.99999999999 of ONNX Runtime): the ``taps`` variant is captured, the gates are checked on its REPLAY outputs, and + an eager run of the same variant must equal the replay bit for bit. + +Gates (PLAN.md 2.8; S:frnet:354,571), on replay outputs: + +- every module output in ``MODULE_GATES`` at **PCC >= 0.999** (``.``); +- ``pred_probs`` **PCC >= 0.998** and per-point **argmax agreement >= 98.5 %** on the raw points vs ONNX Runtime + (the end-to-end gates over many frames are in ``test_e2e_device.py``). + +``GATES`` (vendored ``ttaw.golden.GateRegistry``) freezes each gate into ``test_pcc_device.gates.json`` next to this +file at its first green run and refuses a looser declaration afterwards (PLAN.md 0.3; ``TTAW_GATES_READONLY=1`` for +verification runs). Changing a frozen gate means editing that JSON by hand and disclosing it in +VERIFICATION_.md. Gates are never loosened: a failing module gets fp32 islands / more precise weights (PLAN.md +9.3 precision policy), not a lower threshold. Every tap (gated or not) is reported in +``logs/frnet/pcc_.json``. +""" +from __future__ import annotations + +import dataclasses +import json +import os +import time + +import numpy as np +import pytest + +from tt_frnet.tests._data import WORKSPACE, golden_dir, weights_dir +from tt_frnet.ttaw.golden import GateRegistry +from tt_frnet.ttaw.metrics import argmax_agreement, error_stats, pcc + +pytestmark = pytest.mark.device + +VARIANTS = ("ot128", "qt128") +FRAME = "pandaset_019_40" +LOG_DIR = WORKSPACE / "logs" / "frnet" +TAG = os.environ.get("FRNET_PCC_TAG", "") # report-name suffix of an A/B run (e.g. FRNET_POINT_TERMS=2) +SMALL_HW = (32, 128) +SMALL_CAPS = (4096, 2048) # N_cap, M_cap of the small graph +SMALL_PCC = 0.99 # sanity bound of the small graph (real gates: GATES) +# The device modules of PORT_LOG.md (M1) and the tap that is each one's output. +MODULE_GATES = ("encoder.pf3", "encoder.voxel_feats", "backbone.stem", "backbone.point_stem", + "backbone.fusion_stem", "backbone.stage1", "backbone.stage2", "backbone.stage3", "backbone.stage4", + "decoder.fuse2", "decoder.point_fuse2", "head.logits") +GATES = GateRegistry.for_test(__file__, { + **{f"{v}.{tap}": 0.999 for v in VARIANTS for tap in MODULE_GATES}, + **{f"{v}.pred_probs": 0.998 for v in VARIANTS}, + **{f"{v}.argmax_agreement": (0.985, "min", "argmax_agreement") for v in VARIANTS}, +}) + + +# ------------------------------------------------------------------------------------------- layouts + +def device_to_reference(name: str, dev: np.ndarray, ref_shape: tuple, plan) -> np.ndarray: + """A read-back device tap -> the reference layout: point rows (N_cap) unsorted to the network-input order, + voxel rows (M_cap or M_cap + 1) cut to M, NHWC map rows -> NCHW; padded channels dropped.""" + a = np.asarray(dev, np.float32) + a = a.reshape(-1, a.shape[-1]) + if len(ref_shape) == 4: # (1, C, h, w) + _, c, h, w = ref_shape + return np.ascontiguousarray(a[: h * w, :c].reshape(1, h, w, c).transpose(0, 3, 1, 2)) + rows, c = ref_shape + if a.shape[0] >= plan.n_cap and rows == plan.n: # point tensor + return plan.unsort(a[:, :c]) + return a[:rows, :c] # voxel tensor + + +def compare_taps(dev_taps: dict, ref_taps, plan) -> dict: + out = {} + for name, dev in dev_taps.items(): + if name not in ref_taps: + continue + ref = np.asarray(ref_taps[name], np.float32) + d = device_to_reference(name, dev, ref.shape, plan) + out[name] = error_stats(d, ref) + return out + + +# --------------------------------------------------------------------------------------------- small graph + +def _small_frame(seed: int, hw: tuple, m: int): + """Synthetic network inputs on a small grid: ``m`` occupied cells, 1-40 points each, LiDAR-like features.""" + from tt_frnet.host.preprocess import NetworkInputs + + rng = np.random.default_rng(seed) + H, W = hw + cells = np.sort(rng.choice(H * W, size=m, replace=False)) + counts = rng.integers(1, 41, size=m) + few = rng.random(m) < 0.7 + counts[few] = rng.integers(1, 4, size=int(few.sum())) + inv = np.repeat(np.arange(m), counts) + perm = rng.permutation(len(inv)) # network-input order is not sorted by cell + inv = inv[perm] + voxel_coors = np.stack([np.zeros(m, np.int64), cells // W, cells % W], 1).astype(np.int64) + coors = voxel_coors[inv] + n = len(inv) + xyz = rng.normal(0, 15, size=(n, 3)).astype(np.float32) + xyz[:, 2] = rng.normal(-1, 1.5, size=n) + inten = rng.integers(0, 256, size=(n, 1)).astype(np.float32) + pts = np.concatenate([xyz, inten], 1).astype(np.float32) + return NetworkInputs(points=pts, coors=coors, voxel_coors=voxel_coors, inverse_map=inv.astype(np.int64), + num_points_raw=n, keep_mask=np.ones(n, bool)) + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_small_graph(device, variant): + from tt_frnet.host.preprocess import build_plan + from tt_frnet.reference.config import FRNetConfig + from tt_frnet.reference.model import FRNetTorch + from tt_frnet.reference.weights import MODULE_TAPS, OPT_IN_TAPS, load_weights + from tt_frnet.tt.model import TAP_NAMES, TtFRNet, build_runner, frame_arrays + from tt_frnet.ttaw.golden import TapRegistry + + wdir = weights_dir() + cfg = FRNetConfig.from_model_dir(wdir, variant) + H, W = SMALL_HW + n_cap, m_cap = SMALL_CAPS + cfg = dataclasses.replace(cfg, frustum_h=H, frustum_w=W, num_points=(1, 2000, n_cap), + num_unique=(1, 1000, m_cap)) + weights = load_weights(wdir, variant) + weights_small = dataclasses.replace(weights, frustum_hw=(H, W)) + pre_norm = (weights.pre_norm_scale, weights.pre_norm_shift) + frames = [_small_frame(seed, (H, W), m) for seed, m in ((1, 300), (2, 220))] + plans = [build_plan(f, cfg, pre_norm=pre_norm) for f in frames] + ref_net = FRNetTorch(weights_small, threads=4) + refs = [] + for f in frames: + taps = TapRegistry(include=MODULE_TAPS + OPT_IN_TAPS) + ref_net.forward(f.points, f.coors, f.voxel_coors, f.inverse_map, taps=taps) + refs.append(taps.to_dict()) + graph = TtFRNet(device, weights_small, cfg) + arrays = [frame_arrays(p, m_cap) for p in plans] + runner = build_runner(graph, variants=("taps",), warmup_runs=2, name=f"frnet_small_{variant}", init=arrays[0]) + try: + runner.capture() # eager x2 on frame A, then the capture + report = {} + for k, (plan, ref, arr) in enumerate(zip(plans, refs, arrays)): + replay = runner("taps", inputs=arr) + eager = runner.run_eager("taps", inputs=arr) + for name in replay: + assert np.array_equal(np.asarray(replay[name]), np.asarray(eager[name])), \ + f"frame {k}: replay != eager for {name}" + stats = compare_taps(replay, ref, plan) + report[f"frame{k}"] = stats + print(f"[small {variant} frame {k}] N={plan.n} M={plan.m} " + + " ".join(f"{n}={s['pcc']:.5f}" for n, s in stats.items())) + for name in TAP_NAMES: + s = stats.get(name) + if s is None or name.startswith(("backbone.point_fusion", "backbone.segmax")) and \ + name[-1] in "234": + continue # the (nearly) dead stride > 1 point branch + assert s["pcc"] >= SMALL_PCC, f"frame {k}: {name} PCC {s['pcc']}" + LOG_DIR.mkdir(parents=True, exist_ok=True) + (LOG_DIR / f"pcc_small_{variant}{TAG}.json").write_text(json.dumps(report, indent=1, default=float)) + finally: + runner.release() + graph.release() + + +# ------------------------------------------------------------------------------------------- full graph + +@pytest.mark.parametrize("variant", VARIANTS) +def test_stage_pcc(device, variant): + from tt_frnet.host.preprocess import NetworkInputs, build_plan + from tt_frnet.reference.config import FRNetConfig + from tt_frnet.reference.weights import load_weights + from tt_frnet.tt.model import TtFRNet, build_runner, frame_arrays + + gdir = golden_dir() / variant + taps_npz, taps_json = gdir / f"{FRAME}.taps.npz", gdir / f"{FRAME}.taps.json" + if not taps_npz.is_file(): + pytest.skip(f"{taps_npz} missing (code/scripts/make_goldens.py --set taps)") + meta = json.loads(taps_json.read_text()) + wdir = weights_dir() + cfg = FRNetConfig.from_model_dir(wdir, variant) + weights = load_weights(wdir, variant) + gold = np.load(taps_npz) + n_raw = int(meta["N_raw"]) + inputs = NetworkInputs(points=gold["input.points"], coors=gold["input.coors"], + voxel_coors=gold["input.voxel_coors"], inverse_map=gold["input.inverse_map"], + num_points_raw=n_raw, keep_mask=np.ones(n_raw, bool)) + plan = build_plan(inputs, cfg, pre_norm=(weights.pre_norm_scale, weights.pre_norm_shift)) + arrays = frame_arrays(plan, cfg.m_cap) + graph = TtFRNet(device, weights, cfg) + runner = build_runner(graph, variants=("taps",), name=f"frnet_taps_{variant}", init=arrays) + try: + t0 = time.perf_counter() + runner.capture() + capture_s = time.perf_counter() - t0 + t0 = time.perf_counter() + replay = runner("taps", inputs=arrays) # gates on REPLAY outputs + replay_s = time.perf_counter() - t0 + stats = compare_taps(replay, gold, plan) + dev_probs = device_to_reference("pred_probs", replay["pred_probs"], (plan.n, 27), plan) + ort = np.asarray(gold["ort.pred_probs"], np.float32) + agree = argmax_agreement(dev_probs[:n_raw], ort[:n_raw]) + stats["pred_probs_vs_ort"] = error_stats(dev_probs, ort) + eager = runner.run_eager("taps", inputs=arrays) + bit_exact = {name: bool(np.array_equal(np.asarray(replay[name]), np.asarray(eager[name]))) + for name in replay} + report = {"variant": variant, "frame": FRAME, "N": plan.n, "M": plan.m, "N_raw": n_raw, + "capture_s": round(capture_s, 1), "replay_read_s": round(replay_s, 2), + "argmax_agreement_raw": agree, "replay_equals_eager": bit_exact, "stats": stats, + "graph": graph.describe(), "trace": runner.describe()} + LOG_DIR.mkdir(parents=True, exist_ok=True) + (LOG_DIR / f"pcc_{variant}{TAG}.json").write_text(json.dumps(report, indent=1, default=float)) + for name, s in stats.items(): + print(f"[{variant}] {name:24s} pcc={s['pcc']:.6f} max_abs={s['max_abs']:.4g} rel_l2={s['rel_l2']:.4g}") + print(f"[{variant}] argmax agreement (raw points, vs ORT) = {agree:.5f}") + results = [GATES.check(f"{variant}.{tap}", stats[tap]["pcc"]) for tap in MODULE_GATES] + results.append(GATES.check(f"{variant}.pred_probs", stats["pred_probs"]["pcc"])) + results.append(GATES.check(f"{variant}.argmax_agreement", agree)) + for r in results: + print(r) + assert all(bit_exact.values()), f"replay != eager: {[k for k, v in bit_exact.items() if not v]}" + failed = [r for r in results if not r.passed] + assert not failed, failed + finally: + runner.release() + graph.release() + gold.close() diff --git a/code/tt_frnet/tests/test_reference_cpu.py b/code/tt_frnet/tests/test_reference_cpu.py new file mode 100644 index 0000000000000000000000000000000000000000..f1ab8cac9bf5361bba684947c42721b25e89e8bd --- /dev/null +++ b/code/tt_frnet/tests/test_reference_cpu.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The CPU reference (``reference/``: ONNX initializers by node, BN folded where the port folds it, torch fp32) vs +ONNX Runtime on the deployed ONNX as Autoware binds it (PLAN.md 0.3 item 1; BUNDLE_CONVENTIONS.md section 10): + +- FRNet has no data-dependent selection inside the network (no top-k, no NMS): every module tap + (``weights.MODULE_TAPS``: encoder point features and voxel max, frustum image, stem, point stem, fusion stem, the + four stages' blocks / point fusion / pixel fusion / attention / outputs, decoder, head, logits, ``pred_probs``) + and the opt-in taps (per-voxel max of every point branch, the five up-sampled decoder inputs) at + **PCC >= 0.9999** against the same tensor of the deployed graph (ORT exposes it as an extra graph output); +- after the selection, i.e. the per-point argmax of the post-processing: **set-based**, the class ids agree on + >= 99.99 % of the points and every disagreement is a near-tie of the ORT probabilities (top-2 margin < 1e-4); +- cases: the staged PandaSet sample through the request path (both variants), the hardest public frame for bf16 (nuScenes + 0916 kf 20, OT128) and the densest-cell frame (VLS-128 x > 0 half, QT128: up to 60 points per cell); +- ORT itself reproduces the research goldens' ``pred_probs`` bit for bit (deterministic CPU EP), and the TT dataflow + (``rewrites.py``) is held to the same tap gates against ORT on the OT128 sample and the VLS-128 QT128 frame. + +Runs in the research venv (onnxruntime + torch, CPU only; keep threads <= 4 on the shared host):: + + cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\ + /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python -m pytest -q -p no:cacheprovider \\ + code/tt_frnet/tests/test_reference_cpu.py + +Skipped where onnxruntime or the weights are absent. +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +pytest.importorskip("onnxruntime") +pytest.importorskip("onnx") +torch = pytest.importorskip("torch") + +from tt_frnet.host.preprocess import build_plan # noqa: E402 +from tt_frnet.reference.model import FRNetTorch # noqa: E402 +from tt_frnet.reference.ort import OrtFRNet # noqa: E402 +from tt_frnet.reference.pipeline import FRNetReference # noqa: E402 +from tt_frnet.reference.rewrites import FRNetPlanTorch, plan_taps_to_literal # noqa: E402 +from tt_frnet.reference.weights import MODULE_TAPS, load_weights # noqa: E402 +from tt_frnet.tests import _data # noqa: E402 +from tt_frnet.ttaw.golden import TapRegistry # noqa: E402 +from tt_frnet.ttaw.metrics import pcc # noqa: E402 + +PCC_MIN = 0.9999 # PLAN.md 0.3 item 1 +AGREE_MIN = 0.9999 # set-based class ids after the argmax +TIE = 1e-4 # a disagreement must be an ORT near-tie +THREADS = 4 +PATTERNS = list(MODULE_TAPS) + ["backbone.segmax*", "decoder.up*"] + + +@pytest.fixture(scope="module") +def weights(): + w = _data.weights_dir() + torch.set_num_threads(THREADS) + return {v: load_weights(w, v) for v in ("ot128", "qt128")} + + +def _inputs(case, weights): + """``(NetworkInputs, research golden pred_probs or None)`` of a case: ``sample.`` goes through the + request path (staged PandaSet sample + its calibration), a catalogued frame is read from its research golden.""" + kind, _, variant = case.rpartition(".") + if kind == "sample": + _data.need_pandaset() + ref = FRNetReference(_data.weights_dir(), variant, backend="torch", threads=THREADS, weights=weights[variant]) + prep = ref.pre(_data.SAMPLE, _data.SAMPLE_CALIB) + assert prep.skipped is None + _, gold_probs, _ = _data.load_frame(f"{_data.SAMPLE_STEM}.{variant}") + return prep.inputs, gold_probs + inputs, gold_probs, _ = _data.load_frame(case) + return inputs, gold_probs + + +CASES = ["sample.ot128", "sample.qt128"] + _data.HARD_FRAMES + + +@pytest.fixture(scope="module", params=CASES) +def run(request, weights): + """One case's torch and ORT outputs with every tap (module-scoped and parametrized: pytest runs every test of a + case together and drops the case before the next one; OT128 taps are ~2.5 GB per side).""" + case = request.param + variant = case.rpartition(".")[2] + inputs, gold = _inputs(case, weights) + tt, to = TapRegistry(include=PATTERNS), TapRegistry(include=PATTERNS) + probs_t = FRNetTorch(weights[variant])(inputs.points, inputs.coors, inputs.voxel_coors, inputs.inverse_map, + taps=tt) + ort = OrtFRNet(_data.weights_dir(), variant, threads=THREADS, intermediates=True, tap_patterns=PATTERNS, + weights=weights[variant]) + probs_o = ort(inputs.points, inputs.coors, inputs.voxel_coors, inputs.inverse_map, taps=to) + del ort + yield case, inputs, gold, probs_t, probs_o, tt, to + tt.clear() + to.clear() + + +def test_taps_match_onnxruntime(run): + case, inputs, gold, probs_t, probs_o, tt, to = run + names = to.names() + assert set(MODULE_TAPS) <= set(names) and {f"backbone.segmax{i}" for i in range(5)} <= set(names) + worst = {} + for name in names: + x, y = tt[name], to[name] + assert x.shape == y.shape, name + worst[name] = pcc(x, y) + print(case, json.dumps({k: round(v, 12) for k, v in sorted(worst.items(), key=lambda kv: kv[1])[:4]})) + bad = {k: v for k, v in worst.items() if not v >= PCC_MIN} + assert not bad, bad + assert np.abs(probs_t - probs_o).max() < 1e-4 + + +def test_class_ids_set_based_after_the_argmax(run): + case, inputs, gold, probs_t, probs_o, _, _ = run + a, b = probs_t.argmax(1), probs_o.argmax(1) + assert (a == b).mean() >= AGREE_MIN + flips = np.flatnonzero(a != b) + if len(flips): + top2 = np.sort(probs_o[flips], axis=1)[:, -2:] + assert (top2[:, 1] - top2[:, 0] < TIE).all(), "a disagreement that is not a near-tie" + + +def test_onnxruntime_reproduces_the_research_golden(run): + """Deterministic CPU EP: the same inputs give the research golden's pred_probs bit for bit.""" + _, _, gold, _, probs_o, _, _ = run + assert gold is not None and np.array_equal(probs_o, gold) + + +def test_tt_dataflow_matches_onnxruntime(run, weights): + """``rewrites.py`` (sorted + padded points, gather-form scatters, log-step max, K-split, interpolation + matrices) against the deployed graph at the same tap gates (OT128 sample and the VLS-128 QT128 frame, whose + cells hold up to 60 points: 6 log-step rounds).""" + case, inputs, _, _, probs_o, _, to = run + if case not in ("sample.ot128", "vls128_frame000_fronthalf.qt128"): + pytest.skip("the TT dataflow is compared on the OT128 sample and the densest-cell frame") + variant = case.rpartition(".")[2] + w = weights[variant] + cfg = FRNetReference(_data.weights_dir(), variant, backend="torch", weights=w).config + plan = build_plan(inputs, cfg, pre_norm=(w.pre_norm_scale, w.pre_norm_shift)) + tr = TapRegistry(include=PATTERNS) + out = plan.unsort(FRNetPlanTorch(w)(plan, taps=tr)) + worst = min((pcc(plan_taps_to_literal(n, tr[n], plan), to[n]), n) for n in to.names()) + print(case, "plan vs ORT worst tap", worst, "rounds", plan.stats()["segmax_rounds"]) + assert worst[0] >= PCC_MIN, worst + assert np.abs(out - probs_o).max() < 1e-4 and (out.argmax(1) == probs_o.argmax(1)).mean() >= AGREE_MIN diff --git a/code/tt_frnet/tests/test_reference_rewrites_host.py b/code/tt_frnet/tests/test_reference_rewrites_host.py new file mode 100644 index 0000000000000000000000000000000000000000..e0d9674021a6721794f14e2f59f98f8c4377ca5c --- /dev/null +++ b/code/tt_frnet/tests/test_reference_rewrites_host.py @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The TT device dataflow (``reference/rewrites.py``) equals the literal graph (``reference/model.py``) on the CPU +(torch + the weights; no device, no onnxruntime): + +- the whole network on the staged PandaSet sample (QT128, the faster variant; OT128 on the same frame in + ``test_reference_cpu.py``) and on a small synthetic frame with tight capacities: every module tap PCC >= 0.9999999 + and ``pred_probs`` within 1e-4, argmax identical -- the plan's sorted / padded points, zero-sentinel gathers, + last-voxel-wins pixel tables, log-step segmented max, K-split convs and point layers and interpolation-matrix + up-sampling change nothing but the summation order; +- padding is harmless: two capacities give the same outputs on the real rows (to the last bits of BLAS blocking); +- the log-step segmented max equals ``scatter_reduce(amax)`` for any rounds >= the frame's need, and fails with one + round fewer when a segment is longer; +- the align-corners interpolation matrices equal ``F.interpolate``; the emulation modes round as documented. + + TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests/test_reference_rewrites_host.py +""" +from __future__ import annotations + +import numpy as np +import pytest + +torch = pytest.importorskip("torch") +pytest.importorskip("onnx") + +import torch.nn.functional as F # noqa: E402 + +from tt_frnet.host.frame import FRNetPreprocessor # noqa: E402 +from tt_frnet.host.preprocess import autoware_preprocess, build_plan, segmax_rounds # noqa: E402 +from tt_frnet.reference.config import FRNetConfig # noqa: E402 +from tt_frnet.reference.model import FRNetTorch, scatter_max # noqa: E402 +from tt_frnet.reference.rewrites import (Emulation, FRNetPlanTorch, plan_taps_to_literal, round_mode, # noqa: E402 + segmax_logstep) +from tt_frnet.reference.weights import MODULE_TAPS, load_weights # noqa: E402 +from tt_frnet.tests import _data # noqa: E402 +from tt_frnet.ttaw.golden import TapRegistry # noqa: E402 +from tt_frnet.ttaw.metrics import pcc # noqa: E402 +from tt_frnet.ttaw.ops.upsample import interp_matrix # noqa: E402 + +THREADS = 4 +PATTERNS = list(MODULE_TAPS) + ["backbone.segmax*", "decoder.up*"] + + +@pytest.fixture(scope="module") +def qt128(): + torch.set_num_threads(THREADS) + w = _data.weights_dir() + return load_weights(w, "qt128"), FRNetConfig.from_model_dir(w, "qt128") + + +def _compare(weights, cfg, inputs, n_cap=None, m_cap=None): + plan = build_plan(inputs, cfg, pre_norm=(weights.pre_norm_scale, weights.pre_norm_shift), n_cap=n_cap, + m_cap=m_cap) + tl, tr = TapRegistry(include=PATTERNS), TapRegistry(include=PATTERNS) + lit = FRNetTorch(weights)(inputs.points, inputs.coors, inputs.voxel_coors, inputs.inverse_map, taps=tl) + out = FRNetPlanTorch(weights)(plan, taps=tr) + worst = min((pcc(plan_taps_to_literal(n, tr[n], plan), tl[n]), n) for n in tl.names()) + return plan, lit, out, worst, tr + + +def test_plan_equals_literal_on_the_sample(qt128): + weights, cfg = qt128 + _data.need_pandaset() + prep = FRNetPreprocessor(cfg, plan=False)(_data.SAMPLE, _data.SAMPLE_CALIB) + plan, lit, out, worst, _ = _compare(weights, cfg, prep.inputs) + assert plan.n_cap == cfg.n_cap == 120000 and plan.m_cap == 60000 + assert worst[0] >= 0.9999999, worst + got = plan.unsort(out) + assert np.abs(got - lit).max() < 1e-4 + np.testing.assert_array_equal(got.argmax(1), lit.argmax(1)) + assert np.allclose(out[plan.n:].sum(1), 1.0, atol=1e-5) # padded rows: finite, ignored + + +def test_plan_on_a_small_frame_and_padding_is_harmless(qt128): + weights, cfg = qt128 + rng = np.random.default_rng(7) + n = 6000 + az = rng.uniform(-np.pi, np.pi, n) + el = np.radians(rng.uniform(-50, 50, n)) + r = rng.uniform(2, 60, n) + pts = np.c_[r * np.cos(el) * np.cos(az), r * np.cos(el) * np.sin(az), r * np.sin(el), + rng.uniform(0, 255, n)].astype(np.float32) + pts[:500, :3] = pts[0, :3] + rng.normal(0, 0.01, (500, 3)) # one dense cell: a long segment + inputs = autoware_preprocess(pts, cfg, crop_bounds=(0.0,) * 6) + p1, lit, o1, worst, t1 = _compare(weights, cfg, inputs, n_cap=inputs.num_points + 1, m_cap=inputs.num_voxels + 1) + assert p1.max_segment_length >= 400 and segmax_rounds(p1.max_segment_length) >= 9 + assert worst[0] >= 0.9999999, worst + assert np.abs(p1.unsort(o1) - lit).max() < 1e-4 + p2 = build_plan(inputs, cfg, pre_norm=(weights.pre_norm_scale, weights.pre_norm_shift), + n_cap=inputs.num_points + 4096, m_cap=inputs.num_voxels + 777) + o2 = FRNetPlanTorch(weights)(p2) + # padded rows never reach a real row: only BLAS blocking of the taller matrices may move the last bits + np.testing.assert_allclose(p2.unsort(o2), p1.unsort(o1), rtol=0, atol=1e-6) + + +def test_segmax_logstep_equals_scatter_reduce(qt128): + _, cfg = qt128 + rng = np.random.default_rng(3) + pts = np.c_[rng.normal(0, 15, (3000, 3)), rng.uniform(0, 255, 3000)].astype(np.float32) + pts[:300, :3] = pts[0, :3] + inputs = autoware_preprocess(pts, cfg, crop_bounds=(0.0,) * 6) + plan = build_plan(inputs, cfg, n_cap=inputs.num_points + 64, m_cap=inputs.num_voxels + 9) + x = torch.randn(plan.n_cap, 5) + longest = int(np.argmax(np.diff(plan.seg_offsets))) + x[int(plan.seg_offsets[longest])] = 100.0 # the longest segment's max sits on its FIRST row + ref = scatter_max(x[: plan.n], torch.from_numpy(plan.seg_id[: plan.n]), plan.m) + need = segmax_rounds(plan.max_segment_length) + for rounds in (need, need + 2): + assert torch.equal(segmax_logstep(x, plan, rounds)[: plan.m], ref) + assert not torch.equal(segmax_logstep(x, plan, need - 1)[: plan.m], ref) + + +@pytest.mark.parametrize("hw,s", [((128, 1024), 2), ((128, 256), 8), ((16, 128), 4)]) +def test_interp_matrices_equal_align_corners_interpolate(hw, s): + H, W = hw + x = torch.randn(1, 3, H // s, W // s, dtype=torch.float64) + ah = torch.from_numpy(interp_matrix(H // s, H, mode="linear", align_corners=True)) + aw = torch.from_numpy(interp_matrix(W // s, W, mode="linear", align_corners=True)) + y = torch.einsum("hi,bcij,wj->bchw", ah, x, aw) + ref = F.interpolate(x, size=(H, W), mode="bilinear", align_corners=True) + assert torch.allclose(y, ref, atol=1e-12) + + +def test_rounding_modes(): + x = torch.tensor([1.0, 1.0 + 2 ** -8, 1.0 + 2 ** -7 + 2 ** -9, -3.14159265, 1e-30]) + assert torch.equal(round_mode(x, "fp32"), x) + assert torch.equal(round_mode(x, "bf16"), x.to(torch.bfloat16).float()) + t = round_mode(x, "tf32") + assert (t.view(torch.int32) & 0x1FFF).eq(0).all() and (t.abs() <= x.abs()).all() + e = Emulation.parse("conv_act=bf16,point_act=tf32") + assert e.label() == "conv_act=bf16,point_act=tf32" and not e.exact and Emulation().exact + with pytest.raises(ValueError): + Emulation.parse("point_act=fp16") diff --git a/code/tt_frnet/tests/test_reference_structure_host.py b/code/tt_frnet/tests/test_reference_structure_host.py new file mode 100644 index 0000000000000000000000000000000000000000..fd4fe494631b89279d12cc35ba63ea57c65e962a --- /dev/null +++ b/code/tt_frnet/tests/test_reference_structure_host.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The deployed ONNX as ``reference/weights.py`` reads it (onnx + the weights; no torch, no device): the structure +recovered from the graph (S:frnet:250-254: 53 convs = 47 x 3x3 s1 + 3 x 3x3 s2 + 3 x 1x1 s2, 17 point layers, +BasicBlocks {3, 4, 6, 3}, strides 1 / 2 / 4 / 8, the frustum grid of both variants), the folds (float64, one +rounding: ``x @ W' + b'`` == BatchNorm(x @ W) for every point layer; ``pre_norm`` == ``x * scale + shift``), the +parameter count (10,016,971 values in the initializers), the pinned file hashes and every module tap's ONNX tensor. + + TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests/test_reference_structure_host.py +""" +from __future__ import annotations + +import numpy as np +import pytest + +pytest.importorskip("onnx") + +from tt_frnet.api import FRNet # noqa: E402 +from tt_frnet.reference.config import BLOCKS, ONNX_SHA256, STRIDES, VARIANTS, FRNetConfig # noqa: E402 +from tt_frnet.reference.weights import MODULE_TAPS, OPT_IN_TAPS, load_weights # noqa: E402 +from tt_frnet.tests import _data # noqa: E402 +from tt_frnet.ttaw.weights import OnnxWeights # noqa: E402 + +GRID = {"ot128": (128, 1024), "qt128": (128, 256)} + + +@pytest.fixture(scope="module", params=VARIANTS) +def loaded(request): + w = _data.weights_dir() + return request.param, load_weights(w, request.param), FRNetConfig.from_model_dir(w, request.param), w + + +def test_structure(loaded): + variant, W, cfg, _ = loaded + assert W.frustum_hw == GRID[variant] == (cfg.frustum_h, cfg.frustum_w) and W.strides == STRIDES + assert tuple(len(s.blocks) for s in W.stages) == BLOCKS + convs = W.convs + kinds = sorted((c.kernel, c.stride) for c in convs) + assert len(convs) == 53 and kinds.count((3, 1)) == 47 and kinds.count((3, 2)) == 3 and kinds.count((1, 2)) == 3 + assert sum(c.activation == "hardswish" for c in convs) == 3 + 1 + 16 + 4 + 4 + 2 + assert sum(c.activation == "sigmoid" for c in convs) == 4 + assert [(lin.in_features, lin.out_features) for lin in W.linears] == [ + (8, 64), (64, 128), (128, 256), (256, 256), (256, 16), (384, 128), (256, 128), (256, 128), (256, 128), + (256, 128), (640, 256), (256, 128), (128, 128), (128, 256), (256, 128), (128, 64), (64, 27)] + assert [lin.relu for lin in W.linears] == [True] * 3 + [False, True] + [True] * 11 + [False] + assert W.raw_parameters == 10_016_971 and W.num_classes == 27 + assert W.onnx_sha256 == ONNX_SHA256[variant] + assert set(MODULE_TAPS) <= set(W.onnx_taps) and len(W.onnx_taps) == 67 + assert {t for t in W.onnx_taps if t.startswith("backbone.segmax")} == {f"backbone.segmax{i}" for i in range(5)} + assert len(OPT_IN_TAPS) == 3 + + +def test_folds_are_exact_in_float64(loaded): + variant, W, _, wdir = loaded + onnx = OnnxWeights(wdir / f"frnet_{variant}.onnx") + rng = np.random.default_rng(0) + for lin in W.linears: + x = rng.normal(size=(64, lin.in_features)) + ours = x @ lin.weight.astype(np.float64) + lin.bias + if lin.bn_node is not None: + raw = x @ onnx.matmul_weight(lin.onnx_node).astype(np.float64) + ref = onnx.batchnorm(lin.bn_node).apply(raw, axis=1) + else: + g = onnx.gemm(lin.onnx_node) + ref = x @ np.asarray(g.weight, np.float64).T + g.bias + np.testing.assert_allclose(ours, ref, rtol=2e-6, atol=2e-6 * np.abs(ref).max(), err_msg=lin.name) + bn = onnx.batchnorm(onnx.find_node("/model/voxel_encoder/pre_norm/*", "BatchNormalization").name) + x = rng.normal(size=(64, 8)) * 30 + np.testing.assert_allclose(x * W.pre_norm_scale + W.pre_norm_shift, bn.apply(x, axis=1), rtol=1e-6, atol=1e-5) + assert bn.eps == pytest.approx(1e-3) + + +def test_both_variants_share_classes_and_differ_in_weights(): + w = _data.weights_dir() + a, b = (FRNetConfig.from_model_dir(w, v) for v in VARIANTS) + assert a.class_names == b.class_names == tuple(FRNet.LABELS) and a.palette == b.palette + assert (a.frustum_w, a.interp_w, a.num_points[2]) == (1024, 4096, 160000) + assert (b.frustum_w, b.interp_w, b.num_points[2]) == (256, 1024, 120000) + assert a.num_unique == b.num_unique == (3000, 30000, 60000) and a.version == "v2.0" + wa, wb = (load_weights(w, v) for v in VARIANTS) + assert not np.array_equal(wa.stem[0].weight, wb.stem[0].weight) + assert [c.name for c in wa.convs] == [c.name for c in wb.convs] + + +def test_a_modified_file_is_refused(tmp_path): + w = _data.weights_dir() + for name in ("frnet_ot128.onnx", "ml_package_frnet_ot128.param.yaml", "deploy_metadata.yaml"): + (tmp_path / name).write_bytes((w / name).read_bytes()) + with open(tmp_path / "frnet_ot128.onnx", "ab") as f: + f.write(b"\0") + with pytest.raises(ValueError, match="sha256"): + load_weights(tmp_path, "ot128") + (tmp_path / "deploy_metadata.yaml").write_text("version: v1.0\n") + with pytest.raises(ValueError, match="v1.0"): + FRNetConfig.from_model_dir(tmp_path, "ot128") diff --git a/code/tt_frnet/tests/test_server_host.py b/code/tt_frnet/tests/test_server_host.py new file mode 100644 index 0000000000000000000000000000000000000000..e5d05da66e0e9854c39ec12bdae0349d27589883 --- /dev/null +++ b/code/tt_frnet/tests/test_server_host.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Host tests of the HTTP contract (no device, no weights): the real app of this bundle with a stub model. + + TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests/test_server_host.py +""" +from __future__ import annotations + +import base64 +import io + +import numpy as np +import pytest + +fastapi = pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +from tt_frnet.api import FRNet # noqa: E402 +from tt_frnet.server import app as server # noqa: E402 +from tt_frnet.tests.stubs import StubModel # noqa: E402 + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("TT_MESH_SHAPE", "1x1") + monkeypatch.setenv("TT_MODEL_WEIGHTS_REVISION", "0" * 40) + monkeypatch.setattr(server.app.state.ttaw, "model_factory", StubModel) + with TestClient(server.app) as c: + yield c + + +def _npy_b64(a): + buf = io.BytesIO() + np.save(buf, a) + return base64.b64encode(buf.getvalue()).decode() + + +def test_health_info_models(client): + assert client.get("/health").json()["status"] == "ok" + assert client.get("/v1/health").json()["status"] == "ok" + info = client.get("/info").json() + assert info["model"] == FRNet.MODEL_NAME and info["weights"]["revision"] == "0" * 40 + assert info["device"]["dispatch"] == "eth" and info["device"]["grid"] == "12x10" + assert info["autoware"]["package"] == "autoware_lidar_frnet" + assert info["output"]["labels"] == list(FRNet.LABELS) + assert info["input"]["point_fields"] == list(FRNet.POINT_FIELDS) + assert info["variant"] == FRNet.DEFAULT_VARIANT + assert client.get("/v1/models").json()["data"][0]["id"] == "AutowareFoundation/lidar_frnet" + + +def test_predict_ok_and_equals_api(client): + pts = np.random.default_rng(0).normal(size=(500, 4)).astype(np.float32) + r = client.post("/predict", json={"points": {"format": "npy", "data": _npy_b64(pts)}, + "params": {"class_probability_threshold": 0.4}, + "calibration": {"T_base_link_from_sensor": np.eye(4).tolist()}}) + assert r.status_code == 200, r.text + body = r.json() + assert body["num_points"] == 500 and body["frame_id"] == "sensor" and body["model"] == FRNet.MODEL_NAME + assert body["labels"]["dtype"] == "uint8" and body["labels"]["shape"] == [500] + assert body["points"]["shape"] == [500, 4] and body["filter_keep"]["dtype"] == "bool" + assert body["class_names"] == list(FRNet.LABELS) and sum(body["class_counts"].values()) == 500 + assert body["meta"]["params"]["class_probability_threshold"] == 0.4 + assert body["meta"]["calibration"] == ["T_base_link_from_sensor"] + assert {"decode", "model_call", "total", "device"} <= set(body["timing_ms"]) + state = server.app.state.ttaw + req = server.PredictRequest(points={"format": "npy", "data": _npy_b64(pts)}) + assert state.predict(req)["labels"] == state.model(pts).to_dict()["labels"] + + +@pytest.mark.parametrize("payload,code", [ + ({"points": {"format": "npy", "data": "not base64!"}}, 400), # undecodable + ({"points": {"format": "bin", "data": base64.b64encode(b"\0" * 10).decode()}}, 400), # bad row size + ({"params": {"class_probability_threshold": 2.0}, "points": {"format": "list", "values": [[0, 0, 0, 0]]}}, 400), + ({"params": {"score_threshold": 0.5}, "points": {"format": "list", "values": [[0, 0, 0, 0]]}}, 400), + ({"params": {"no_such_knob": 1}, "points": {"format": "list", "values": [[0, 0, 0, 0]]}}, 400), + ({"calibration": {"preset": "../etc"}, "points": {"format": "list", "values": [[0, 0, 0, 0]]}}, 400), + ({}, 400), # no points for a LiDAR model + ({"pointz": {}}, 422), # schema violation (typo) +]) +def test_predict_errors(client, payload, code): + r = client.post("/predict", json=payload) + assert r.status_code == code, r.text + + +def test_503_while_starting(client, monkeypatch): + monkeypatch.setattr(server.app.state.ttaw, "ready", False) + assert client.post("/predict", json={}).status_code == 503 + assert client.get("/health").json()["status"] == "starting" + + +def test_mesh_shape_parser(): + p = server.parse_mesh_shape + assert p("1x1") == p("(1, 1)") == p("1,1") == p(None) == (1, 1) + with pytest.raises(RuntimeError): + p("two by two") diff --git a/code/tt_frnet/tests/test_tt_host.py b/code/tt_frnet/tests/test_tt_host.py new file mode 100644 index 0000000000000000000000000000000000000000..3b234e2aa3f97f9a6b7f1b5a64f89463580c1a0d --- /dev/null +++ b/code/tt_frnet/tests/test_tt_host.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Host tests of the device graph (``tt/``) on the fake ttnn: the per-frame device inputs, and the whole graph +(encoder -> K1 -> compression -> frustum2pixel -> stem -> 4 stages with point / pixel fusion -> decoder -> head, +inside a ``TraceRunner``) against the fp32 torch reference on a small frustum grid, replay == eager. + + TT_VISIBLE_DEVICES=none python -m pytest -q -p fake_ttnn_plugin code/tt_frnet/tests/test_tt_host.py + +The op sets of the fake (``common/tests/host/fake_ttnn_cnn.py``, ``fake_ttnn_lidar.py``, ``fake_ttnn_segment.py``) +are workspace files: without them the graph tests skip. The device suite (``test_pcc_device.py``) runs the same graph +on the p150. The fake computes in float64 with the dtype roundings of each op's output, so the bound here is a +wiring check (every tap PCC >= 0.999 vs the literal graph), not a precision statement. +""" +from __future__ import annotations + +import dataclasses +import importlib.util +import sys + +import numpy as np +import pytest + +from tt_frnet.tests import _data + +FAKE_DIR = _data.WORKSPACE / "common" / "tests" / "host" +SMALL_HW = (32, 128) +SMALL_CAPS = (4096, 2048) + + +def _load(name: str): + path = FAKE_DIR / f"{name}.py" + if not path.is_file(): + pytest.skip(f"{path} not found (workspace-only fake op set)") + spec = importlib.util.spec_from_file_location(f"frnet_{name}", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def fake_ttnn(): + fake = sys.modules.get("ttnn") + if fake is None or "fake_ttnn" not in getattr(fake, "__file__", ""): + pytest.skip("needs the fake ttnn (-p fake_ttnn_plugin)") + undo = [_load(n).install(fake) for n in ("fake_ttnn_cnn", "fake_ttnn_lidar", "fake_ttnn_segment")] + fake.reset() + yield fake + for u in reversed(undo): + u() + + +def _small_setup(variant: str): + from tt_frnet.reference.config import FRNetConfig + from tt_frnet.reference.weights import load_weights + + wdir = _data.weights_dir() + cfg = FRNetConfig.from_model_dir(wdir, variant) + H, W = SMALL_HW + n_cap, m_cap = SMALL_CAPS + cfg = dataclasses.replace(cfg, frustum_h=H, frustum_w=W, num_points=(1, 2000, n_cap), num_unique=(1, 1000, m_cap)) + weights = dataclasses.replace(load_weights(wdir, variant), frustum_hw=(H, W)) + return cfg, weights + + +def test_frame_arrays_layout(): + from tt_frnet.host.preprocess import build_plan + from tt_frnet.tests.test_pcc_device import _small_frame + from tt_frnet.tt.model import frame_arrays + + cfg, weights = _small_setup("ot128") + frame = _small_frame(3, SMALL_HW, 200) + plan = build_plan(frame, cfg, pre_norm=(weights.pre_norm_scale, weights.pre_norm_shift)) + arr = frame_arrays(plan, cfg.m_cap) + n_cap, m_cap = SMALL_CAPS + assert arr["features"].shape == (1, 1, n_cap, 8) and arr["features"].dtype == np.float32 + off = arr["offsets"] + assert off.shape == (1, m_cap + 2) and off.dtype == np.uint32 + assert off[0, 0] == 0 and np.all(np.diff(off[0].astype(np.int64)) >= 0) and off[0, -1] == plan.n + np.testing.assert_array_equal(off[0, : plan.m + 1], plan.seg_offsets) + for s in (1, 2, 4, 8): + assert arr[f"p2p_{s}"].shape == (1, n_cap) and arr[f"v2p_{s}"].shape == (1, (32 // s) * (128 // s)) + v = arr[f"v2p_{s}"][0] + assert np.all((v < plan.m) | (v == m_cap)) + bad = dataclasses.replace(plan, pix2vox={**plan.pix2vox, 2: plan.pix2vox[2] + np.uint32(10 ** 6)}) + with pytest.raises(ValueError): + frame_arrays(bad, cfg.m_cap) + + +@pytest.mark.parametrize("variant", ["ot128", "qt128"]) +def test_graph_matches_reference_on_fake(fake_ttnn, variant): + pytest.importorskip("torch") + from tt_frnet.host.preprocess import build_plan + from tt_frnet.reference.model import FRNetTorch + from tt_frnet.reference.weights import MODULE_TAPS, OPT_IN_TAPS + from tt_frnet.tests.test_pcc_device import _small_frame, compare_taps + from tt_frnet.tt.model import TAP_NAMES, TtFRNet, build_runner, frame_arrays + from tt_frnet.ttaw.device import close_device, open_device + from tt_frnet.ttaw.golden import TapRegistry + + cfg, weights = _small_setup(variant) + frames = [_small_frame(seed, SMALL_HW, m) for seed, m in ((1, 300), (2, 220))] + plans = [build_plan(f, cfg, pre_norm=(weights.pre_norm_scale, weights.pre_norm_shift)) for f in frames] + ref_net = FRNetTorch(weights, threads=2) + dev = open_device(0, dispatch="eth") + graph = TtFRNet(dev, weights, cfg) + arrays = [frame_arrays(p, cfg.m_cap) for p in plans] + runner = build_runner(graph, variants=("taps", "default"), name="frnet_fake", init=arrays[0]) + try: + runner.capture() + for frame, plan, arr in zip(frames, plans, arrays): + taps = TapRegistry(include=MODULE_TAPS + OPT_IN_TAPS) + ref_probs = ref_net.forward(frame.points, frame.coors, frame.voxel_coors, frame.inverse_map, taps=taps) + ref = taps.to_dict() + replay = runner("taps", inputs=arr) + eager = runner.run_eager("taps", inputs=arr) + assert set(replay) == set(TAP_NAMES) + for name in replay: + np.testing.assert_array_equal(np.asarray(replay[name]), np.asarray(eager[name]), err_msg=name) + stats = compare_taps(replay, ref, plan) + low = {k: v["pcc"] for k, v in stats.items() if not v["pcc"] >= 0.999 and not ( + k[-1] in "234" and k.startswith(("backbone.point_fusion", "backbone.segmax")))} + assert not low, low + probs = np.asarray(runner("default", inputs=arr)).reshape(-1, 32) + got = plan.unsort(probs[:, :27]) + assert np.abs(probs[:, 27:]).max() == 0.0 # padded classes: probability 0 + from tt_frnet.ttaw.metrics import argmax_agreement, pcc + + assert pcc(got, ref_probs) >= 0.999 and argmax_agreement(got, ref_probs) >= 0.98 + finally: + runner.release() + graph.release() + close_device(dev) diff --git a/code/tt_frnet/tt/__init__.py b/code/tt_frnet/tt/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bd624f48a41e200cfe9ade90578bd89c30c84b7c --- /dev/null +++ b/code/tt_frnet/tt/__init__.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: Apache-2.0 +"""tt-nn implementation of FRNet (Autoware lidar_frnet) for one Blackhole p150 (12x10 grid, ETH dispatch). + +- ``model.py`` ``TtFRNet``: the whole device graph of one variant (point encoder, K1 segmented max, gathers + between points / voxels / pixels, stem, 4 stages, decoder, head, softmax) built from the deployed + ONNX weights (``reference.weights``), ``build_runner`` (a ``ttaw.trace.TraceRunner`` with the + per-frame tables as persistent inputs; variants ``default`` and ``taps``), ``frame_arrays`` / + ``FrameStaging`` (the per-frame device inputs of a ``host.preprocess.FramePlan``), ``KNOBS``. +- ``layers.py`` the building blocks: ``PointLinear`` (concat-free point layers, fp32 island, optional two-term + bf16 operands), ``pixel2point`` / ``frustum2pixel`` (C19 gathers), ``ConvAct`` / ``KSplitConvAct`` + (C17 convs with HardSwish as its own op, probe P5). +- ``kernels/`` bundle-local kernel sources: none. The one custom kernel of the port, K1 ``segment_reduce``, is + owned by this port but lives in the shared package (``ttaw/ops/segment.py``, + ``ttaw/ops/kernels/segment_reduce_dm.cpp``; PLAN.md 1.3). + +Rules: the grid is read from the device (``device.compute_with_storage_grid_size()``; never 12x10 / 11x10 in code); +explicit ``compute_kernel_config`` everywhere (``ttaw.precision``; matmul silently drops to LoFi otherwise); no host +reads inside a trace capture; every HardSwish is a separate ``ttnn.hardswish``. ``api.py`` imports this package +inside ``_build`` only, so ``import tt_frnet`` stays free of ttnn; the modules here import ttnn inside functions too +(host tests run them on the fake ttnn). +""" diff --git a/code/tt_frnet/tt/kernels/README.md b/code/tt_frnet/tt/kernels/README.md new file mode 100644 index 0000000000000000000000000000000000000000..024126026976d4775cbea6713fddd78ffbb7c42b --- /dev/null +++ b/code/tt_frnet/tt/kernels/README.md @@ -0,0 +1,11 @@ +# Custom kernels of frnet-p150 + +No bundle-local kernel. The port's custom kernel, **K1 `segment_reduce`** (the per-voxel max of FRNet's six +`ScatterElements(max)` sites), is owned by this port and lives in the shared, vendored package: + +| file | op | used by | status | +|---|---|---|---| +| `tt_frnet/ttaw/ops/kernels/segment_reduce_dm.cpp` | K1 per-segment max (both data-movement RISC-Vs, exact on integer keys, device-side work split) | `tt_frnet/ttaw/ops/segment.py` `SegmentReduce` <- `tt_frnet/tt/model.py` | device-verified (ttaw 0.20.0): bit-exact vs the numpy oracle and vs the log-step stock-op fallback at FRNet's real shapes; watcher bring-up clean | + +Host oracle and tests: `common/tests/host/test_segment_host.py`, `common/tests/device/test_segment_reduce_device.py` +(workspace); a `verify:` line of `tt-model.yaml` asserts the source ships in the image. diff --git a/code/tt_frnet/tt/layers.py b/code/tt_frnet/tt/layers.py new file mode 100644 index 0000000000000000000000000000000000000000..e1ad9f0c66762f477a391558a0a3d0aab63a2929 --- /dev/null +++ b/code/tt_frnet/tt/layers.py @@ -0,0 +1,332 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Device building blocks of the FRNet graph (PORT_LOG.md "Device graph"): the fp32 point island, the gathers +between points, voxels and pixels, and the conv layers with their separate HardSwish (probe P5). + +- :class:`PointLinear` -- ``act(sum_i x_i @ W_i + b) (+ skip)`` on ``[1, 1, N, K_i]`` TILE rows: the concat-free + point layers (point stem 128 + 256, point fusion 128 + 128, ``point_fuse_layer1`` 5 x 128; the rewrite + ``linear(concat(a, b)) == a @ W_a + b @ W_b`` of ``reference/rewrites.py``) and the plain ones. fp32 activations + and fp32 weights with HiFi4 + fp32 accumulation by default (the fp32 point island the CPU precision study requires, + PORT_LOG.md "Precision study"); ``Precision`` per layer from the ``FRNET_PRECISION`` policy. +- :func:`pixel2point` -- ``map rows -> point rows``: the NHWC map (bf16 TILE) untilized, ``ttnn.embedding`` with the + per-frame ``point2pix_s`` index (C19 ``gather_rows``), then typecast to the point dtype. +- :func:`frustum2pixel` -- ``voxel rows -> map``: C19 PADDED gather of the per-frame ``pix2vox_s`` index into a bf16 + ROW_MAJOR table whose row ``M_cap`` is zero (empty pixels; at strides 2 / 4 / 8 the host index already resolved + the duplicates: last voxel wins, ORT's ScatterND order). +- :class:`ConvAct` / :class:`KSplitConvAct` -- C17 ``Conv2d`` / ``KSplitConv`` with ``hardswish`` applied as its own + ``ttnn.hardswish`` (a fused HARDSWISH is silently skipped on this tree, P5) or ``sigmoid`` fused in the epilogue. + +Nothing here imports ttnn at module import (ttaw rule kept for the bundle: host tests use the fake ttnn). +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from ..reference.weights import Conv, Linear +from ..ttaw.ops.conv import Conv2d, FeatureMap, KSplitConv +from ..ttaw.ops.gather import gather_rows +from ..ttaw.precision import Precision +from ..ttaw.tensors import round_to_bf16, ttnn_dtype + +__all__ = ["PointLinear", "pixel2point", "frustum2pixel", "ConvAct", "KSplitConvAct", "hardswish", "free", + "pad_conv_input_channels", "conv_precision", "cast_map"] + + +def free(*tensors: Any) -> None: + """Deallocate device tensors that still hold their buffer (``force=False``: views are left alone, P2).""" + import ttnn + + for t in tensors: + t = t.tensor if isinstance(t, FeatureMap) else t + if t is not None and hasattr(t, "is_allocated") and t.is_allocated(): + ttnn.deallocate(t, False) + + +def hardswish(t: Any) -> Any: + """HardSwish as its own op (P5: fused, it is silently skipped by conv2d and by binary ``activations=``).""" + import ttnn + + return ttnn.hardswish(t) + + +class PointLinear: + """``act(sum_i x_i @ W_i + b) (+ skip)`` for point / voxel rows (module docstring). + + ``layer``: a ``reference.weights.Linear`` (BN folded, ``weight`` ``(in, out)``); ``parts``: input widths of the + concat it replaces (default one part); ``out_pad``: output width padded with zero columns (e.g. the compression + layer 16 -> 32, so its rows can be a bf16 gather table, D % 32 == 0) and ``pad_bias`` for those columns (``0``; + ``-1e30`` for logits whose padded classes must get zero probability). ``precision``: ``Precision`` / spec; its + ``activations`` dtype is the output dtype (fp32 for the island) and the dtype inputs are cast to. + + ``terms``: 1 -- one matmul per part with the precision's operand dtypes (fp32 x fp32: the device's fp32 matmul is + TF32-like, ~10 mantissa bits, probe P12); 2 -- **two-term bf16 operands**: ``x = x_hi + x_lo`` and ``W = W_hi + + W_lo`` with ``hi = bf16(v)``, ``lo = bf16(v - hi)`` (about 16 significant bits each), ``x @ W ~ x_hi @ W_hi + x_hi @ + W_lo + x_lo @ W_hi`` as three bf16 matmuls with exact HiFi4 products and fp32 accumulation into an fp32 output + (the ``lo @ lo`` term is below fp32 rounding of the sum; a bf16 input has ``lo = 0`` and takes two matmuls). The + PORT_LOG.md precision study's next step if the TF32-like path misses the gates.""" + + def __init__(self, layer: Linear, *, parts: Optional[Sequence[int]] = None, precision: Any = None, + out_pad: Optional[int] = None, pad_bias: float = 0.0, terms: int = 1, name: Optional[str] = None): + self.name = name or layer.name + self.relu = bool(layer.relu) + w = np.asarray(layer.weight, np.float32) + b = np.asarray(layer.bias, np.float32) + self.in_features, self.logical_out = int(w.shape[0]), int(w.shape[1]) + self.parts = tuple(int(p) for p in (parts or (self.in_features,))) + if sum(self.parts) != self.in_features: + raise ValueError(f"{self.name}: parts {self.parts} do not add up to {self.in_features} inputs") + out = self.logical_out if out_pad is None else int(out_pad) + if out < self.logical_out: + raise ValueError(f"{self.name}: out_pad {out} < {self.logical_out}") + if out > self.logical_out: + w = np.concatenate([w, np.zeros((w.shape[0], out - self.logical_out), np.float32)], axis=1) + b = np.concatenate([b, np.full(out - self.logical_out, pad_bias, np.float32)]) + self.out_features = out + if int(terms) not in (1, 2): + raise ValueError(f"{self.name}: terms must be 1 or 2, got {terms!r}") + self.terms = int(terms) + self.weights_host: List[np.ndarray] = [] + start = 0 + for p in self.parts: + self.weights_host.append(np.array(w[start:start + p], np.float32, copy=True, order="C")) + start += p + self.bias_host = np.array(b, np.float32, copy=True) + self.precision = Precision.parse(precision or "HiFi4+fp32:w=float32:a=float32") + self._dev: Optional[tuple] = None + + def host_weight_terms(self, part: int) -> List[np.ndarray]: + """The device weights of one part: ``[W]`` (terms=1) or ``[W_hi, W_lo]`` (bf16-exact float32 arrays).""" + w = self.weights_host[part] + if self.terms == 1: + return [w] + hi = round_to_bf16(w) + return [hi, round_to_bf16(w - hi)] + + def _upload(self, device: Any): + import torch + import ttnn + + wdt = ttnn_dtype(self.precision.weights) if self.terms == 1 else ttnn.bfloat16 + bdt = ttnn.float32 if self.terms == 2 else (ttnn.bfloat16 if wdt == ttnn.bfloat8_b else wdt) + g = device.compute_with_storage_grid_size() + ws = [[ttnn.from_torch(torch.from_numpy(t), dtype=wdt, layout=ttnn.TILE_LAYOUT, device=device, + memory_config=ttnn.DRAM_MEMORY_CONFIG) for t in self.host_weight_terms(i)] + for i in range(len(self.parts))] + b = ttnn.from_torch(torch.from_numpy(self.bias_host.reshape(1, -1)), dtype=bdt, layout=ttnn.TILE_LAYOUT, + device=device, memory_config=ttnn.DRAM_MEMORY_CONFIG) + self._dev = (ws, b, ttnn.CoreGrid(y=int(g.y), x=int(g.x)), self.precision.compute_kernel_config()) + return self._dev + + def _split(self, x: Any) -> Tuple[Any, Any, List[Any]]: + """``x -> (hi, lo or None, temporaries)`` in bf16 (two-term mode).""" + import ttnn + + if x.dtype == ttnn.bfloat16: + return x, None, [] + hi = ttnn.typecast(x, ttnn.bfloat16) + hi32 = ttnn.typecast(hi, ttnn.float32) + rem = ttnn.subtract(x, hi32) + lo = ttnn.typecast(rem, ttnn.bfloat16) + free(hi32, rem) + return hi, lo, [hi, lo] + + def __call__(self, xs: Sequence[Any], *, skip: Any = None) -> Any: + """``xs``: one TILE device tensor ``[1, 1, N, K_i]`` per part (K_i logical; TILE padding must be zero; bf16 + or fp32); ``skip``: added after the activation (FRHead's residuals). -> ``[1, 1, N, out_features]`` TILE + DRAM in the precision's activation dtype (fp32 in two-term mode).""" + import ttnn + + if len(xs) != len(self.parts): + raise ValueError(f"{self.name}: {len(xs)} inputs for {len(self.parts)} parts") + for x, p in zip(xs, self.parts): + if int(x.shape[-1]) != p: + raise ValueError(f"{self.name}: an input has {int(x.shape[-1])} channels, the part takes {p}") + ws, b, grid, cfg = self._dev or self._upload(xs[0].device()) + out_dt = ttnn.float32 if self.terms == 2 else ttnn_dtype(self.precision.activations) + relu_act = ttnn.UnaryWithParam(ttnn.UnaryOpType.RELU) + # the products: (activation, weight) pairs, the bias on the first + temps: List[Any] = [] + products: List[Tuple[Any, Any]] = [] + for x, w in zip(xs, ws): + if self.terms == 1: + if x.dtype != out_dt: + x = ttnn.typecast(x, out_dt) + temps.append(x) + products.append((x, w[0])) + else: + hi, lo, t = self._split(x) + temps += t + products += [(hi, w[0]), (hi, w[1])] + ([(lo, w[0])] if lo is not None else []) + y = None + n = len(products) + for i, (x, w) in enumerate(products): + fuse = self.relu and n == 1 + part = ttnn.linear(x, w, bias=b if i == 0 else None, activation="relu" if fuse else None, + compute_kernel_config=cfg, core_grid=grid, memory_config=ttnn.DRAM_MEMORY_CONFIG, + dtype=out_dt) + if y is None: + y = part + continue + last = i == n - 1 + acts = [relu_act] if (last and self.relu) else [] + summed = ttnn.add(y, part, activations=acts, memory_config=ttnn.DRAM_MEMORY_CONFIG) + free(y, part) + y = summed + free(*temps) + if skip is not None: + summed = ttnn.add(y, skip, memory_config=ttnn.DRAM_MEMORY_CONFIG) + free(y) + y = summed + return y + + def release(self) -> None: + if self._dev is not None: + free(*[t for terms in self._dev[0] for t in terms], self._dev[1]) + self._dev = None + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "in": self.in_features, "out": self.out_features, "logical_out": self.logical_out, + "parts": list(self.parts), "relu": self.relu, "precision": self.precision.label, "terms": self.terms} + + +def pixel2point(fmap: FeatureMap, point2pix: Any, *, dtype: str = "float32") -> Any: + """``Gather`` of the NHWC rows of a map at each point's pixel (``point2pix_s`` uint32 ``[1, N_cap]``): + untilize (``ttnn.embedding`` tables are ROW_MAJOR bf16), gather (TILE out), typecast to ``dtype``.""" + import ttnn + + t = fmap.tensor + table = t if t.layout == ttnn.ROW_MAJOR_LAYOUT else ttnn.to_layout(t, ttnn.ROW_MAJOR_LAYOUT) + if table.dtype != ttnn.bfloat16: + tb = ttnn.typecast(table, ttnn.bfloat16) + if table is not t: + free(table) + table = tb + rows = gather_rows(point2pix, table, layout="tile") + if table is not t: + free(table) + if dtype != "bfloat16": + out = ttnn.typecast(rows, ttnn_dtype(dtype)) + free(rows) + rows = out + return rows + + +def frustum2pixel(table: Any, pix2vox: Any, *, sentinel: int, height: int, width: int) -> FeatureMap: + """``ScatterND`` of voxel rows into the ``height x width`` map as a gather: ``table`` bf16 ROW_MAJOR + ``[1, 1, M_cap + 1, C]`` with a zero row at ``sentinel`` (= M_cap), ``pix2vox`` uint32 ``[1, h * w]`` -> + NHWC TILE map (C19 PADDED gather, probe P4).""" + rows = gather_rows(pix2vox, table, sentinel=sentinel, layout="tile") + return FeatureMap(rows, 1, int(height), int(width), int(table.shape[-1])) + + +def pad_conv_input_channels(weight: np.ndarray, channels: int) -> np.ndarray: + """A conv weight ``[Cout, Cin, k, k]`` with zero input channels appended up to ``channels`` (an input map whose + extra channels are zero: the 16-channel frustum image travels as 32 channels).""" + w = np.asarray(weight, np.float32) + if w.shape[1] > channels: + raise ValueError(f"conv has {w.shape[1]} input channels > {channels}") + if w.shape[1] == channels: + return w + pad = np.zeros((w.shape[0], channels - w.shape[1]) + w.shape[2:], np.float32) + return np.ascontiguousarray(np.concatenate([w, pad], axis=1)) + + +def conv_precision(precision: Any, weight_terms: int) -> Precision: + """The conv's ``Precision``: with ``weight_terms=2`` the weights travel as two bf16 terms (C17: ``hi = bf16(w)``, + ``lo = bf16(w - hi)``, ~16 significant bits, two convs into fp32 outputs summed in fp32), so the weight dtype is + bf16 and the bf16 x bf16 HiFi4 products are exact. The device's float32 weight path is TF32-like (C17 / YOLOX: + 1.5x less error than bf16 weights, two terms 3.5x), which the M0 CPU study modelled as exact.""" + prec = Precision.parse(precision) + if int(weight_terms) not in (1, 2): + raise ValueError(f"weight_terms must be 1 or 2, got {weight_terms!r}") + return prec.with_(weights="bfloat16") if int(weight_terms) == 2 else prec + + +def cast_map(x: FeatureMap, dtype: Optional[str]) -> Tuple[FeatureMap, Optional[FeatureMap]]: + """``x`` in ``dtype`` (None: unchanged) -> (map, temporary to free after use or None).""" + import ttnn + + if dtype is None or x.tensor.dtype == ttnn_dtype(dtype): + return x, None + y = x.with_tensor(ttnn.typecast(x.tensor, ttnn_dtype(dtype))) + return y, y + + +class ConvAct: + """One conv of the graph with its activation: ``"hardswish"`` as a separate ``ttnn.hardswish``, ``"sigmoid"`` + fused into the conv epilogue (``weight_terms=1``) or applied to the fp32 sum of the two terms (``weight_terms=2``, + C17), None (a residual add follows). ``in_channels``: pad the weight's input channels with zeros (frustum image + 16 -> 32). ``weight_terms``: C17's two-term bf16 weights (:func:`conv_precision`). ``output_dtype``: the map + dtype written by the conv (and its activation); ``input_dtype``: the dtype the input is cast to first (None: + fed as it is), e.g. fp32 maps with bf16 conv operands.""" + + def __init__(self, layer: Conv, *, precision: Any, in_channels: Optional[int] = None, weight_terms: int = 1, + output_dtype: str = "bfloat16", input_dtype: Optional[str] = None, name: Optional[str] = None): + self.name = name or layer.name + self.activation = layer.activation + self.input_dtype = input_dtype + w = layer.weight if in_channels is None else pad_conv_input_channels(layer.weight, in_channels) + fused = "sigmoid" if layer.activation == "sigmoid" else None + self.conv = Conv2d(w, layer.bias, stride=layer.stride, padding=layer.padding, activation=fused, + precision=conv_precision(precision, weight_terms), weight_terms=int(weight_terms), + output_dtype=output_dtype, name=self.name) + + @property + def out_channels(self) -> int: + return self.conv.out_channels + + def __call__(self, x: FeatureMap) -> FeatureMap: + xc, tmp = cast_map(x, self.input_dtype) + y = self.conv(xc) + free(tmp) + if self.activation == "hardswish": + z = y.with_tensor(hardswish(y.tensor)) + free(y) + y = z + return y + + def release(self) -> None: + self.conv.release() + + def describe(self) -> Dict[str, Any]: + return {**self.conv.describe(), "post_activation": self.activation} + + +class KSplitConvAct: + """``act(conv(concat(x_1..x_n)))`` without the concat (C17 ``KSplitConv``, P14): partial sums in + ``accumulate_dtype`` (fp32 by default: one rounding to ``output_dtype`` at the end), HardSwish as its own op; + ``input_dtype`` as :class:`ConvAct`.""" + + def __init__(self, layer: Conv, parts: Sequence[int], *, precision: Any, accumulate_dtype: str = "float32", + weight_terms: int = 1, output_dtype: str = "bfloat16", input_dtype: Optional[str] = None, + name: Optional[str] = None): + if layer.activation not in ("hardswish", None): + raise ValueError(f"{layer.name}: activation {layer.activation!r} not expected on a K-split conv") + self.name = name or layer.name + self.activation = layer.activation + self.input_dtype = input_dtype + self.conv = KSplitConv(layer.weight, layer.bias, parts, activation=None, output_dtype=output_dtype, + accumulate_dtype=accumulate_dtype, stride=layer.stride, padding=layer.padding, + precision=conv_precision(precision, weight_terms), weight_terms=int(weight_terms), + name=self.name) + + @property + def out_channels(self) -> int: + return self.conv.out_channels + + def __call__(self, xs: Sequence[FeatureMap]) -> FeatureMap: + cast = [cast_map(x, self.input_dtype) for x in xs] + y = self.conv([c[0] for c in cast]) + free(*[c[1] for c in cast]) + if self.activation == "hardswish": + z = y.with_tensor(hardswish(y.tensor)) + free(y) + y = z + return y + + def release(self) -> None: + self.conv.release() + + def describe(self) -> Dict[str, Any]: + return {**self.conv.describe(), "post_activation": self.activation} diff --git a/code/tt_frnet/tt/model.py b/code/tt_frnet/tt/model.py new file mode 100644 index 0000000000000000000000000000000000000000..5b7bce6c954b4fa1053c3661be2347bf1ce8de17 --- /dev/null +++ b/code/tt_frnet/tt/model.py @@ -0,0 +1,464 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The FRNet device graph (PORT_LOG.md "Device graph"), one trace per process. + +Built once from the variant's weights (``reference.weights.load_weights``: the deployed ONNX initializers addressed +by node, point-layer BNs folded in float64) and run inside a ``ttaw.trace.TraceRunner`` variant: + +| # | module | device form | +|---|---|---| +| 1 | point encoder | tilize the host features (fp32, ``pre_norm`` folded on the host), 4 ``PointLinear`` 8 -> 64 -> 128 -> 256 -> 256 (fp32 island, two-term bf16 operands) | +| 2 | segmented max (6 sites) | **K1** ``ttaw.ops.segment.SegmentReduce`` on the sorted fp32 rows: site 0 -> ``[M_cap, 256]`` fp32, sites 1-5 -> ``[M_cap + 1, 128]`` bf16 = the gather table itself (row ``M_cap`` empty -> 0) | +| 3 | compression | tilize, ``PointLinear`` 256 -> 16 (+ 16 zero columns) + ReLU, bf16, zero row appended (C19 ``with_zero_rows``) | +| 4 | frustum2pixel | C19 PADDED gather of ``pix2vox_s`` into the table -> NHWC map | +| 5 | stem | 3 C17 convs (16 -> 32 zero-padded input channels) + separate ``ttnn.hardswish`` | +| 6 | pixel2point | untilize + ``ttnn.embedding`` of ``point2pix_s`` + typecast fp32 | +| 7 | point stem | ``PointLinear`` [pix 128 ; pf3 256] -> 128 + ReLU | +| 8 | fusion stem | segmax -> frustum2pixel -> ``KSplitConv`` [img 128 ; stem 128] -> 128 + hardswish | +| 9 | stages 1-4 | BasicBlocks (conv + hswish, conv, identity / 1x1 s2, ``ttnn.add``, hswish); point fusion; segmax; frustum2pixel; pixel fusion K-split + hswish; attention conv + hswish, conv + fused sigmoid; ``ttnn.addcmul`` | +| 10 | decoder | C18 ``Resize2d`` align_corners x2 / x4 / x8 (fp32 interpolation matmuls), ``KSplitConv`` 5 x 128 -> 256 + hswish, conv 256 -> 128 + hswish | +| 11 | point fuse | ``PointLinear`` [p0 .. p4] 640 -> 256 + ReLU, 256 -> 128 + ReLU | +| 12 | head | pixel2point(fuse2), 4 x (``PointLinear`` + ReLU, + skip q / pf2 / pf1 / pf0), 64 -> 27 (+ 5 columns with bias -1e30: zero probability), fp32 ``ttnn.softmax`` | + +Inputs (persistent, rewritten per frame = RT-dev; :func:`frame_arrays` / :class:`FrameStaging`): ``features`` +fp32 ROW_MAJOR ``[1, 1, N_cap, 8]``, ``offsets`` uint32 ``[1, M_cap + 2]`` (CSR offsets of the sorted points; voxels +``M .. M_cap`` empty), ``p2p_`` uint32 ``[1, N_cap]`` (pixel of each sorted point at stride s), ``v2p_`` uint32 +``[1, (H / s) (W / s)]`` (voxel row of each pixel or ``M_cap``). N and M never reach the device: padded points belong +to no segment, padded voxels are never referenced, and the host keeps the first N rows of the output. + +Output: ``pred_probs`` fp32 TILE ``[1, 1, N_cap, 32]`` (27 classes + 5 zero columns), sorted point order (the host +un-permutes with ``plan.unsort``). The ``taps`` variant returns every module output as well (PCC gates). + +Precision (``ttaw.precision`` policy; ``FRNET_PRECISION`` overrides per module, e.g. ``"head.*=HiFi4+fp32:w=bf16"``): +the point / voxel layers are an fp32 island (fp32 activations between layers, HiFi4 + fp32 accumulation) whose +matmul operands are **two-term bf16** by default (``FRNET_POINT_TERMS=2``: ``x_hi @ W_hi + x_hi @ W_lo + x_lo @ +W_hi``, ~16 significant bits; ``1`` = fp32 x fp32, the device's TF32-like matmul, which misses the OT128 e2e gates on +nuScenes frames: PORT_LOG.md "M1 device results"). The image branch keeps **fp32 maps** (``FRNET_MAP_DTYPE=float32``: +every conv, HardSwish, residual add and ``addcmul`` writes fp32; each conv's input is cast to bf16 first, so the conv +operands are bf16 x fp32 weights with HiFi4 + fp32 accumulation + packer L1 accumulation; ``bfloat16`` maps round +every one of those outputs and miss the OT128 e2e argmax gate on nuScenes 0916 kf40: PORT_LOG.md "M3 precision +follow-up"); ``FRNET_CONV_TERMS=2`` (C17 two-term bf16 conv weights) is an A/B option (+0.1 pt argmax on the +hardest frame, +18 % replay). The decoder interpolation is fp32 HiFi4 with bf16 outputs (the inputs of +``fuse_layer1``'s bf16 operands). Module names: ``encoder.ffe``, ``encoder.compression``, ``backbone.stem``, +``backbone.point_stem``, ``backbone.fusion_stem``, ``backbone.layer.block.conv`` / ``.downsample``, +``backbone.point_fusion``, ``backbone.pixel_fusion``, ``backbone.attention.conv``, ``decoder.up``, +``decoder.fuse``, ``decoder.point_fuse``, ``head.mlp``, ``head.logits``. +""" +from __future__ import annotations + +import functools +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +from ..reference.config import FRNetConfig +from ..reference.weights import FRNetWeights +from ..ttaw.knobs import Knob, Knobs +from ..ttaw.ops.conv import FeatureMap +from ..ttaw.ops.gather import with_zero_rows +from ..ttaw.ops.segment import SegmentReduce, SegmentReduceSpec +from ..ttaw.ops.upsample import Resize2d +from ..ttaw.precision import PrecisionPolicy +from .layers import ConvAct, KSplitConvAct, PointLinear, free, frustum2pixel, pixel2point + +__all__ = ["TtFRNet", "build_runner", "default_policy", "frame_arrays", "FrameStaging", "TAP_NAMES", "KNOBS", + "ENV_PREFIX", "POINT_PRECISION", "CONV_PRECISION", "UPSAMPLE_PRECISION", "LOGIT_PAD"] + +ENV_PREFIX = "FRNET" +POINT_PRECISION = "HiFi4+fp32:w=float32:a=float32" +CONV_PRECISION = "HiFi4+fp32+l1acc:w=float32" +UPSAMPLE_PRECISION = "HiFi4+fp32" +LOGIT_PAD = 32 # logits / probabilities: 27 classes padded to one tile width +FRUSTUM_CHANNELS = 32 # compression 16 -> 32 (bf16 gather table rows: D % 32 == 0) +STRIDES = (1, 2, 4, 8) +# Optimization / accuracy knobs, read once at build (``FRNET_`` env A/B switches; pinned in tt-model.yaml). +# POINT_TERMS default 2 (measured, PORT_LOG.md "M1 device results"): with fp32 x fp32 operands (the device's +# TF32-like matmul, probe P12) OT128 misses the e2e gates on the nuScenes 0916 frames (pred_probs PCC 0.99746, +# argmax 97.30 % on kf20); the two-term bf16 operands carry ~16 bits. +KNOBS = Knobs(ENV_PREFIX, [ + Knob("POINT_TERMS", 2, "point / voxel layers: 2 = two-term bf16 operands (~16 bits, 3 matmuls; default), 1 = " + "operands in the precision's dtypes (fp32 x fp32: the TF32-like device matmul)", + choices=(1, 2)), + Knob("CONV_TERMS", 1, "convs: 2 = C17 two-term bf16 weights and biases (~16 bits, 2 convs summed in fp32), 1 = " + "the precision's weight dtype (float32: the device's TF32-like weight path)", + choices=(1, 2)), + Knob("MAP_DTYPE", "float32", "image-branch maps: bfloat16 (every conv / HardSwish / add output rounded to bf16), " + "float32 (fp32 maps, each conv's input cast to bf16 first), float32_direct (fp32 maps " + "fed to the convs as they are)", + choices=("bfloat16", "float32", "float32_direct")), +]) +# MAP_DTYPE -> (map dtype written by convs / activations / adds / resizes, dtype each conv input is cast to) +MAP_MODES = {"bfloat16": ("bfloat16", None), "float32": ("float32", "bfloat16"), "float32_direct": ("float32", None)} + +# Module taps of the debug variant (names of reference/weights.py MODULE_TAPS + the K1 sites) +TAP_NAMES = ( + "encoder.pf0", "encoder.pf1", "encoder.pf2", "encoder.pf3", "encoder.voxel_max", "encoder.voxel_feats", + "backbone.frustum", "backbone.stem", "backbone.point_stem", "backbone.segmax0", "backbone.fusion_stem", + *(f"backbone.{kind}{i}" for i in range(1, 5) + for kind in ("layer", "point_fusion", "segmax", "pixel_fusion", "attention", "stage")), + "decoder.fuse1", "decoder.fuse2", "decoder.point_fuse1", "decoder.point_fuse2", + *(f"head.mlp{i}" for i in range(4)), "head.logits", "pred_probs", +) + + +def default_policy(env: Optional[Dict[str, str]] = None) -> PrecisionPolicy: + """The port's precision policy (module docstring), then ``FRNET_PRECISION`` (read once).""" + rules = { + "encoder.*": POINT_PRECISION, "backbone.point_*": POINT_PRECISION, "decoder.point_fuse*": POINT_PRECISION, + "head.*": POINT_PRECISION, "decoder.up*": UPSAMPLE_PRECISION, + } + return PrecisionPolicy(rules, default=CONV_PRECISION).with_env(ENV_PREFIX, env) + + +class TtFRNet: + """The device graph of one variant (module docstring). :meth:`forward` / :meth:`forward_taps` take the dict of + persistent input tensors and run inside a trace capture (no host I/O).""" + + def __init__(self, device: Any, weights: FRNetWeights, config: FRNetConfig, *, + policy: Optional[PrecisionPolicy] = None, knobs: Any = None): + self.device = device + self.config = config + self.policy = policy or default_policy() + self.knobs = knobs if knobs is not None else KNOBS.read() + terms = int(self.knobs.POINT_TERMS) + map_dtype, conv_in = MAP_MODES[str(self.knobs.MAP_DTYPE)] + self.map_dtype = map_dtype + _Conv = functools.partial(ConvAct, weight_terms=int(self.knobs.CONV_TERMS), output_dtype=map_dtype, + input_dtype=conv_in) + _KConv = functools.partial(KSplitConvAct, weight_terms=int(self.knobs.CONV_TERMS), output_dtype=map_dtype, + input_dtype=conv_in) + self.H, self.W = int(config.frustum_h), int(config.frustum_w) + if tuple(weights.frustum_hw) != (self.H, self.W): + raise ValueError(f"weights grid {weights.frustum_hw} != config grid {(self.H, self.W)}") + self.n_cap, self.m_cap = int(config.n_cap), int(config.m_cap) + if self.n_cap % 32 or self.m_cap % 32: + raise ValueError(f"N_cap {self.n_cap} and M_cap {self.m_cap} must be multiples of 32") + P = self.policy.resolve + self.num_classes = int(weights.num_classes) + # 1 / 3: point encoder, compression + self.ffe = [PointLinear(lin, precision=P(f"encoder.ffe{i}"), terms=terms, name=f"encoder.ffe{i}") + for i, lin in enumerate(weights.ffe)] + self.compression = PointLinear(weights.compression, out_pad=FRUSTUM_CHANNELS, terms=terms, + precision=P("encoder.compression"), name="encoder.compression") + # 2: K1 sites (site 0: fp32 voxel max for the compression; sites 1-5: the bf16 gather tables) + width0 = int(weights.ffe[-1].out_features) + self.segmax_encoder = SegmentReduce(SegmentReduceSpec( + num_rows=self.n_cap, channels=width0, num_segments=self.m_cap, input_dtype="float32", + input_layout="tile", output_dtype="float32"), name="segmax.encoder") + width = int(weights.point_stem.out_features) + self.segmax_points = SegmentReduce(SegmentReduceSpec( + num_rows=self.n_cap, channels=width, num_segments=self.m_cap + 1, input_dtype="float32", + input_layout="tile", output_dtype="bfloat16"), name="segmax.points") + # 5 / 7 / 8: stem, point stem, fusion stem + self.stem = [_Conv(c, precision=P(f"backbone.stem{i}"), name=f"backbone.stem{i}", + in_channels=FRUSTUM_CHANNELS if i == 0 else None) for i, c in enumerate(weights.stem)] + stem_ch = self.stem[-1].out_channels + self.point_stem = PointLinear(weights.point_stem, parts=(stem_ch, width0), precision=P("backbone.point_stem"), + terms=terms, name="backbone.point_stem") + self.fusion_stem = _KConv(weights.fusion_stem, (width, stem_ch), precision=P("backbone.fusion_stem"), + name="backbone.fusion_stem") + # 9: stages + self.stages = [] + for st in weights.stages: + pre = f"backbone.layer{st.index}" + blocks = [] + for b, (c1, c2, ds) in enumerate(st.blocks): + blocks.append((_Conv(c1, precision=P(f"{pre}.block{b}.conv1"), name=f"{pre}.block{b}.conv1"), + _Conv(c2, precision=P(f"{pre}.block{b}.conv2"), name=f"{pre}.block{b}.conv2"), + None if ds is None else _Conv(ds, precision=P(f"{pre}.block{b}.downsample"), + name=f"{pre}.block{b}.downsample"))) + l = st.index + self.stages.append({ + "index": l, "stride": int(st.stride), "blocks": blocks, + "point_fusion": PointLinear(st.point_fusion, parts=(width, width), terms=terms, + precision=P(f"backbone.point_fusion{l}"), name=f"backbone.point_fusion{l}"), + "pixel_fusion": _KConv(st.pixel_fusion, (width, width), precision=P(f"backbone.pixel_fusion{l}"), + name=f"backbone.pixel_fusion{l}"), + "attention": [_Conv(st.attention[0], precision=P(f"backbone.attention{l}.conv0"), + name=f"backbone.attention{l}.conv0"), + _Conv(st.attention[1], precision=P(f"backbone.attention{l}.conv1"), + name=f"backbone.attention{l}.conv1")], + }) + # 10: decoder (identity resizes at stride 1). The resizes write bf16 in every MAP_DTYPE mode: their outputs + # only feed fuse_layer1, whose inputs are bf16 operands anyway, and C18's fp32 output at W' * C = 131,072 + # overflows L1 in its ROW_MAJOR reshape (j5: 2.2 MB of circular buffers > 1.5 MB). + strides = [1] + [s["stride"] for s in self.stages] + self.map_strides = strides + self.ups = {s: Resize2d((self.H // s, self.W // s), (self.H, self.W), channels=width, mode="linear", + align_corners=True, dtype="float32", output_dtype="bfloat16", + precision=P(f"decoder.up{s}"), name=f"decoder.up{s}") + for s in sorted(set(strides)) if s > 1} + self.fuse1 = _KConv(weights.fuse_layer1, (width,) * len(strides), precision=P("decoder.fuse1"), + name="decoder.fuse1") + self.fuse2 = _Conv(weights.fuse_layer2, precision=P("decoder.fuse2"), name="decoder.fuse2") + # 11: point decoder + self.point_fuse1 = PointLinear(weights.point_fuse[0], parts=(width,) * len(strides), terms=terms, + precision=P("decoder.point_fuse1"), name="decoder.point_fuse1") + self.point_fuse2 = PointLinear(weights.point_fuse[1], precision=P("decoder.point_fuse2"), terms=terms, + name="decoder.point_fuse2") + # 12: head + self.mlps = [PointLinear(lin, precision=P(f"head.mlp{i}"), terms=terms, name=f"head.mlp{i}") + for i, lin in enumerate(weights.head_mlps)] + self.logits = PointLinear(weights.conv_seg, out_pad=LOGIT_PAD, pad_bias=-1e30, precision=P("head.logits"), + terms=terms, name="head.logits") + self._softmax_cfg = self.policy.resolve("head.logits").compute_kernel_config() + self.width, self.width0, self.stem_channels = width, width0, stem_ch + + # ------------------------------------------------------------------------------------------ shapes + def input_specs(self) -> Dict[str, Tuple[Tuple[int, ...], str]]: + """``{name: (shape, dtype)}`` of the persistent inputs (all ROW_MAJOR, DRAM).""" + specs = {"features": ((1, 1, self.n_cap, 8), "float32"), "offsets": ((1, self.m_cap + 2), "uint32")} + for s in STRIDES: + specs[f"p2p_{s}"] = ((1, self.n_cap), "uint32") + specs[f"v2p_{s}"] = ((1, (self.H // s) * (self.W // s)), "uint32") + return specs + + # ------------------------------------------------------------------------------------------ pieces + def _image(self, table: Any, inp: Dict[str, Any], s: int) -> FeatureMap: + return frustum2pixel(table, inp[f"v2p_{s}"], sentinel=self.m_cap, height=self.H // s, width=self.W // s) + + def _encoder(self, inp: Dict[str, Any], taps: Optional[Dict[str, Any]]): + import ttnn + + x = ttnn.to_layout(inp["features"], ttnn.TILE_LAYOUT) # [1, 1, N_cap, 8] fp32, zero-padded + pf: List[Any] = [] + for lin in self.ffe: + y = lin([x]) + if x is not inp["features"] and not pf: + free(x) + pf.append(y) + x = y + vmax_rm = self.segmax_encoder(pf[-1], inp["offsets"]) # [1, 1, M_cap, 256] fp32 ROW_MAJOR + vmax = ttnn.to_layout(vmax_rm, ttnn.TILE_LAYOUT) + free(vmax_rm) + vfeat = self.compression([vmax]) # [1, 1, M_cap, 32] fp32 + vfeat_b = ttnn.typecast(vfeat, ttnn.bfloat16) + table = with_zero_rows(vfeat_b) # [1, 1, M_cap + 1, 32] bf16 ROW_MAJOR + free(vfeat_b) + img = self._image(table, inp, 1) + free(table) + if taps is not None: + taps.update({f"encoder.pf{i}": t for i, t in enumerate(pf)}) + taps["encoder.voxel_max"] = vmax + taps["encoder.voxel_feats"] = vfeat + taps["backbone.frustum"] = img.tensor + else: + free(vmax, vfeat) + return pf, img + + def _segmax_image(self, p: Any, inp: Dict[str, Any], s: int, name: str, taps: Optional[Dict[str, Any]]): + table = self.segmax_points(p, inp["offsets"]) # [1, 1, M_cap + 1, 128] bf16 ROW_MAJOR + img = self._image(table, inp, s) + if taps is not None: + taps[name] = table + else: + free(table) + return img + + def _stage(self, st: Dict[str, Any], x: FeatureMap, p: Any, inp: Dict[str, Any], + taps: Optional[Dict[str, Any]]): + import ttnn + + l, s = st["index"], st["stride"] + for b, (c1, c2, ds) in enumerate(st["blocks"]): + y1 = c1(x) + y = c2(y1) + free(y1) + idt = ds(x) if ds is not None else x + summed = ttnn.add(y.tensor, idt.tensor, memory_config=ttnn.DRAM_MEMORY_CONFIG) + out = y.with_tensor(ttnn.hardswish(summed)) + free(summed, y) + if ds is not None: + free(idt) + if b > 0: + free(x) # block 0's input is the previous map (kept for the decoder) + x = out + if taps is not None: + taps[f"backbone.layer{l}"] = x.tensor + pix = pixel2point(x, inp[f"p2p_{s}"], dtype="bfloat16") + p_new = st["point_fusion"]([pix, p]) + free(pix) + img = self._segmax_image(p_new, inp, s, f"backbone.segmax{l}", taps) + fuse = st["pixel_fusion"]([img, x]) + free(img) + a0 = st["attention"][0](fuse) + att = st["attention"][1](a0) + free(a0) + out = x.with_tensor(ttnn.addcmul(x.tensor, fuse.tensor, att.tensor, memory_config=ttnn.DRAM_MEMORY_CONFIG)) + if taps is not None: + taps.update({f"backbone.point_fusion{l}": p_new, f"backbone.pixel_fusion{l}": fuse.tensor, + f"backbone.attention{l}": att.tensor, f"backbone.stage{l}": out.tensor}) + else: + free(fuse, att, x) + return out, p_new + + # ------------------------------------------------------------------------------------------ forward + def forward(self, inp: Dict[str, Any], taps: Optional[Dict[str, Any]] = None) -> Any: + """-> ``pred_probs`` fp32 TILE ``[1, 1, N_cap, 32]``. With ``taps`` (a dict), every module output is kept + and stored under its tap name (the debug variant).""" + import ttnn + + pf, img = self._encoder(inp, taps) + for i, conv in enumerate(self.stem): + nxt = conv(img) + if i > 0 or taps is None: + free(img) # the frustum image (i == 0) is a tap + img = nxt + stem = img + pix = pixel2point(stem, inp["p2p_1"], dtype="bfloat16") + p = self.point_stem([pix, pf[3]]) + free(pix) + if taps is None: + free(pf[3]) + img_p = self._segmax_image(p, inp, 1, "backbone.segmax0", taps) + x = self.fusion_stem([img_p, stem]) + free(img_p) + if taps is not None: + taps.update({"backbone.stem": stem.tensor, "backbone.point_stem": p, "backbone.fusion_stem": x.tensor}) + else: + free(stem) + maps: List[FeatureMap] = [x] + points: List[Any] = [p] + for st in self.stages: + x, p = self._stage(st, x, p, inp, taps) + maps.append(x) + points.append(p) + # decoder + ups = [] + for mp, s in zip(maps, self.map_strides): + ups.append(mp if s == 1 else self.ups[s](mp)) + y1 = self.fuse1(ups) + for mp, up in zip(maps, ups): + if up is not mp: + free(up) + if taps is None: + free(mp) + y2 = self.fuse2(y1) + if taps is not None: + taps.update({"decoder.fuse1": y1.tensor, "decoder.fuse2": y2.tensor}) + else: + free(y1) + q1 = self.point_fuse1(points) + if taps is None: + free(*points) + q = self.point_fuse2([q1]) + if taps is not None: + taps.update({"decoder.point_fuse1": q1, "decoder.point_fuse2": q}) + else: + free(q1) + # head + h = pixel2point(y2, inp["p2p_1"], dtype="bfloat16") + if taps is None: + free(y2) + for i, (lin, skip) in enumerate(zip(self.mlps, (q, pf[2], pf[1], pf[0]))): + nxt = lin([h], skip=skip) + if i == 0 or taps is None: + free(h) # pixel2point(fuse2) is no tap; mlp outputs are + h = nxt + if taps is not None: + taps[f"head.mlp{i}"] = h + if taps is None: + free(q, pf[0], pf[1], pf[2]) + logits = self.logits([h]) + if taps is None: + free(h) + probs = ttnn.softmax(logits, dim=-1, compute_kernel_config=self._softmax_cfg, numeric_stable=True) + if taps is not None: + taps["head.logits"] = logits + taps["pred_probs"] = probs + else: + free(logits) + return probs + + def forward_taps(self, inp: Dict[str, Any]) -> Dict[str, Any]: + """The same graph keeping every module output: ``{tap: device tensor}`` (:data:`TAP_NAMES`).""" + taps: Dict[str, Any] = {} + self.forward(inp, taps) + out = {k: taps[k] for k in TAP_NAMES if k in taps} + dead = [k for k, t in out.items() if hasattr(t, "is_allocated") and not t.is_allocated()] + if dead: # a freed tap would be read back from a released buffer + raise RuntimeError(f"taps deallocated inside the graph: {dead}") + return out + + # ------------------------------------------------------------------------------------------ bookkeeping + def modules(self) -> List[Any]: + out: List[Any] = [*self.ffe, self.compression, *self.stem, self.point_stem, self.fusion_stem] + for st in self.stages: + for c1, c2, ds in st["blocks"]: + out += [c1, c2] + ([ds] if ds is not None else []) + out += [st["point_fusion"], st["pixel_fusion"], *st["attention"]] + out += list(self.ups.values()) + [self.fuse1, self.fuse2, self.point_fuse1, self.point_fuse2, *self.mlps, + self.logits] + return out + + def describe(self) -> Dict[str, Any]: + return {"grid_hw": [self.H, self.W], "n_cap": self.n_cap, "m_cap": self.m_cap, + "precision": self.policy.describe(), "knobs": self.knobs.as_dict(), "segmax": [self.segmax_encoder.describe(), + self.segmax_points.describe()], + "inputs": {k: [list(v[0]), v[1]] for k, v in self.input_specs().items()}} + + def release(self) -> None: + for m in self.modules(): + m.release() + + +def frame_arrays(plan: Any, m_cap: int) -> Dict[str, np.ndarray]: + """``host.preprocess.FramePlan`` -> the persistent inputs as numpy (``TtFRNet.input_specs`` shapes): features + ``[1, 1, N_cap, 8]`` fp32, CSR ``offsets`` ``[1, M_cap + 2]`` (voxels M .. M_cap + 1 empty), ``p2p_``, + ``v2p_``. Index tables are range-checked (an out-of-range gather index reads outside the table).""" + from ..ttaw.ops.gather import check_index + from ..ttaw.ops.segment import check_offsets + + n_cap = int(plan.n_cap) + off = check_offsets(plan.seg_offsets, num_segments=int(plan.m), num_rows=n_cap, length=int(m_cap) + 2) + out = {"features": np.ascontiguousarray(plan.features.reshape(1, 1, n_cap, -1), np.float32), "offsets": off} + for s in STRIDES: + out[f"p2p_{s}"] = check_index(plan.point2pix[s], num_rows=(plan.frustum_hw[0] // s) * (plan.frustum_hw[1] // s)) + out[f"v2p_{s}"] = check_index(plan.pix2vox[s], num_rows=int(plan.m), sentinel=int(m_cap)) + return out + + +def build_runner(graph: TtFRNet, *, variants=("default",), num_command_queues: Optional[int] = None, + warmup_runs: int = 1, name: str = "frnet", init: Optional[Dict[str, np.ndarray]] = None): + """A ``ttaw.trace.TraceRunner`` over ``graph`` with the persistent inputs and the requested variants: + ``"default"`` (``pred_probs``) and / or ``"taps"`` (:meth:`TtFRNet.forward_taps`). Nothing is captured here. + ``init``: warm-up inputs (:func:`frame_arrays` of a real frame); default an empty frame (zero features, every + segment empty, every pixel the zero row, every point pixel 0): valid indices everywhere.""" + from ..ttaw.trace import TraceRunner + + runner = TraceRunner(graph.device, num_command_queues=num_command_queues, warmup_runs=warmup_runs, name=name) + for key, (shape, dtype) in graph.input_specs().items(): + value = None if init is None else init.get(key) + if value is None: + if key.startswith("v2p_"): + value = np.full(shape, graph.m_cap, np.uint32) + else: + value = np.zeros(shape, np.float32 if dtype == "float32" else np.uint32) + runner.add_input(key, init=np.asarray(value).reshape(shape), dtype=dtype) + unknown = set(variants) - {"default", "taps"} + if unknown: + raise ValueError(f"unknown variants {sorted(unknown)}") + names = list(graph.input_specs()) + + def inputs(ctx): + return {k: ctx[k] for k in names} + + if "default" in variants: + runner.add_variant("default", lambda ctx: graph.forward(inputs(ctx))) + if "taps" in variants: + runner.add_variant("taps", lambda ctx: graph.forward_taps(inputs(ctx))) + return runner + + +class FrameStaging: + """Persistent host buffers of the per-frame inputs (``ttaw.tensors.HostStaging``: written in place, no + ``from_torch`` per frame). ``write(plan)`` returns the ``TraceRunner`` inputs.""" + + def __init__(self, graph: TtFRNet): + from ..ttaw.tensors import HostStaging + + self.m_cap = graph.m_cap + self.buffers = {k: HostStaging(shape, dtype) for k, (shape, dtype) in graph.input_specs().items()} + + def write(self, plan: Any) -> Dict[str, Any]: + arrays = frame_arrays(plan, self.m_cap) + return {k: self.buffers[k].write(v) for k, v in arrays.items()} diff --git a/code/tt_frnet/ttaw/API.md b/code/tt_frnet/ttaw/API.md new file mode 100644 index 0000000000000000000000000000000000000000..3ed0782e3e79ed808a1a6cd607cd456d62704b87 --- /dev/null +++ b/code/tt_frnet/ttaw/API.md @@ -0,0 +1,1272 @@ +# ttaw API guide (v0.20.0) + +`ttaw` is the shared code of the 13 Autoware ports to one Blackhole p150b (PLAN.md section 1). Source of truth: +`common/ttaw/`. Every bundle gets a vendored copy at `bundles/-p150/code//ttaw/` and imports it +relatively (`from .ttaw.trace import TraceRunner`). Tests: `common/tests/host` (CPU, fake ttnn) and +`common/tests/device` (p150, through `bin/devrun`). Changes: `common/CHANGELOG.md`. + +Rules that keep one tree valid top-level and vendored: + +- relative imports only; importing any module never imports ttnn / torch / onnx, opens no device, reads no + environment and touches no network (host tests and the image's `verify:` step import everything without a device); +- every optimization knob is read once at model build and has an env A/B switch (`ttaw.knobs`); +- kernel `.cpp` files are package data under `ops/kernels/`, located with `ops.kernel_path(name)`. + +Contents: 1. bundle wiring - 2. device - 3. TraceRunner - 4. tensors - 5. weights - 6. precision and knobs - +7. metrics, goldens, gates - 8. profiling - 9. I/O and outputs - 10. ModelBase - 11. server - 12. vendoring - +13. measured facts and pitfalls - 14. image pre-processing (C14) - 15. LiDAR host pipeline (C10-C12, C15, C16) - +16. CNN ops: conv builders (C17), up-sampling and interpolation (C18) - 17. attention (C20) - +18. LiDAR device modules: gather-form scatter (C19), SECOND + SECONDFPN (C24), CenterHead (C25) - +19. grid_sample helpers (C23) - 20. ResNet builders (C27) - 21. query heads: top-k (C21), heatmap peaks (C22), +TransFusion query head (C26) - 22. pillar feature net and input staging (C24 companion) - 23. segment reductions (K1). + +--------------------------------------------------------------------------------------------------------------------- + +## 1. Wiring a bundle (thin wrappers over ttaw) + +```bash +python common/tools/vendor.py bundles/-p150 # committed ttaw/ (HEAD) -> code//ttaw + VENDORED.json +python common/tools/vendor.py bundles/-p150 --check # drift report (exit 1 on drift; section 12) +``` + +`research/BUNDLE_TEMPLATE` is the canonical wiring (`instantiate_bundle.py --out` renders it and vendors ttaw; the +values file's `INPUT_KIND` picks its flavour: `lidar` as below, `camera`, `multi_camera`, `lidar_camera`, `planner` +with their own `api.py` / `io.py` / stub / smoke / tests, `research/BUNDLE_TEMPLATE/TEMPLATE_NOTES.md` "Flavours"): +`device.py` and `io.py` bind ttaw to the model, `api.py` subclasses `ModelBase`, `server/app.py` calls `create_app`, +and the stdlib `server/client.py` / `server/smoke_test.py` run `ttaw/server/{client,smoke}.py` by path: + +```python +# code//device.py: ttaw.device bound to the port's validated open parameters (the model class uses the same dict) +from .ttaw.device import DeviceConfig, close_device, describe_device # noqa: F401 +DEVICE_DEFAULTS = {"num_command_queues": 1, "l1_small_size": 32768, "trace_region_size": 64 << 20} +def device_config(**overrides): ... # DEVICE_DEFAULTS < _* / TT_DEVICE_ID < non-None overrides +def open_device(device_id=None, *, dispatch=None, **overrides): ... # device_config(...).open() + +# code//io.py: ttaw.io re-exported, decode_points / load_points rebound to the model's layout +from .ttaw.io import * # noqa: F401,F403 +DEFAULT_POINT_FIELDS = ("x", "y", "z", "intensity") + +# code//api.py (section 10 has the full hook contract) +from .ttaw.api_base import ModelBase +class CenterPoint(ModelBase): + MODEL_NAME, ENV_PREFIX = "centerpoint-p150", "CENTERPOINT" + DEVICE_DEFAULTS = device.DEVICE_DEFAULTS + ... + +# code//server/app.py (section 11) +from pathlib import Path +from ..api import CenterPoint +from ..ttaw.server.app import ServerSpec, create_app, parse_mesh_shape # noqa: F401 (tt-model.yaml verify: line) +app = create_app(ServerSpec(model_name=CenterPoint.MODEL_NAME, env_prefix="CENTERPOINT", model_cls=CenterPoint, + task="LiDAR 3D object detection", default_weights=CenterPoint.DEFAULT_REPO, + calib_dir=Path(__file__).resolve().parents[1] / "calib")) +``` + +`pyproject.toml` package data: `".ttaw" = ["API.md", "VENDORED.json"]`, `".ttaw.ops" = ["kernels/*.cpp", +"kernels/*.hpp", "kernels/*.h"]`. The tt-model `extra_code` path `` already ships the sub-package. + +--------------------------------------------------------------------------------------------------------------------- + +## 2. Device (C01, `ttaw.device`) + +```python +from .ttaw.device import DeviceConfig, describe_device, device_session, open_device, compute_grid + +with device_session(dispatch="eth", num_command_queues=2, trace_region_size=64 << 20) as dev: + print(describe_device(dev)) # {'dispatch': 'eth', 'grid': '12x10', 'cores': 120, 'num_command_queues': 2, + # 'arch': 'blackhole', 'fallback': None, 'eth_patch': True, ...} + gx, gy = compute_grid(dev) # never hard-code 12x10: program configs read the grid + +cfg = DeviceConfig.from_env("CENTERPOINT", num_command_queues=1) # _DISPATCH / _NUM_CQS / _L1_SMALL / +dev = cfg.open() # _TRACE_REGION / _WORKER_L1_SIZE, TT_DEVICE_ID +``` + +| name | signature / meaning | +|---|---| +| `open_device` | `(device_id=0, *, dispatch="eth", num_command_queues=1, l1_small_size=32768, trace_region_size=64<<20, worker_l1_size=None, allow_fallback=True)` -> ttnn device. `dispatch`: `eth` (12x10), `worker` (11x10, A/B only), `auto` (ETH if the patch marker is in `tt_metal/impl/dispatch/topology.cpp`, else WORKER + `RuntimeWarning`). A failed ETH open warns and falls back to WORKER; `allow_fallback=False` re-raises (use it in container smoke / CI). Warns if ETH does not give 12x10 | +| `DeviceConfig` | frozen dataclass of the arguments above; `.from_env(prefix, env=None, **defaults)`, `.open()` | +| `describe_device` | `(device, device_id=None)` -> dict: `dispatch` (used), `dispatch_requested`, `fallback`, `num_command_queues`, `grid` "12x10", `grid_x`, `grid_y`, `cores`, `arch`, open sizes, `eth_patch`. `dispatch="unknown"` for devices not opened by ttaw | +| `open_info(device)` | what `open_device` recorded for this device object (`{}` for a device opened elsewhere; the record is tied to the object, never to a reused `id`) | +| `close_device` / `device_session` | close (syncs first; idempotent: a second close of the same device is a no-op) / context manager that always closes | +| `resolve_dispatch(dispatch, *, home=None)`, `eth_dispatch_patch_present(home=None)`, `tt_metal_home()` | patch detection (file read; `TT_METAL_HOME`, `TT_METAL_RUNTIME_ROOT`, or the tree holding `ttnn`, found without importing it) | +| `compute_grid(dev)` / `core_grid(dev)` / `full_core_range_set(dev)` | `(x, y)` / `ttnn.CoreGrid` / one-rectangle `ttnn.CoreRangeSet` of the whole grid | +| constants | `DISPATCH_MODES`, `PATCH_MARKER`, `DEFAULT_L1_SMALL_SIZE`, `DEFAULT_TRACE_REGION_SIZE`, `P150_ETH_GRID` | + +`worker_l1_size` is absolute bytes of allocatable L1 per core; tt-metal's default is computed at open time +(about 1,461,248 B on this tree, TT_PLATFORM.md section 1). Shrinking it grows the kernel-config ring buffer. + +--------------------------------------------------------------------------------------------------------------------- + +## 3. Traces (C02, `ttaw.trace`) + +Every device stage runs inside traces. `TraceRunner` owns the persistent tensors, the warm-up, the captures, the +replays and the readback. + +```python +import ttnn +from .ttaw.trace import TraceRunner, pack_outputs + +runner = TraceRunner(device, num_command_queues=2, name="centerpoint") # CQ count defaults to the open's +runner.add_input("pillars", shape=(1, 1, 40000, 64), dtype="bfloat16") # ROW_MAJOR in DRAM by default +runner.add_input("index", init=warm_idx, dtype="uint32") # give real data if the graph gathers +runner.add_param("score_thr", 0.35) # RT-dev: fp32 [1,1,1,1] TILE +runner.add_state("prev_bev", shape=(1, 1, 22500, 256), dtype="bfloat16", pingpong=True) # temporal models + +def forward(ctx): # ctx[name]: input / param / state read buffer; no host I/O in here + x = model.backbone(ttnn.to_layout(ctx["pillars"], ttnn.TILE_LAYOUT)) + bev = model.temporal(x, ctx["prev_bev"]) + ctx.write_state("prev_bev", bev) # ttnn.copy into the state's write buffer (part of the trace) + heat = ttnn.gt(model.head(bev), ctx["score_thr"]) + return pack_outputs({"heat": heat, "boxes": model.box(bev)}) # one D2H for several outputs + +runner.add_variant("default", forward) # one trace per variant (buckets: "n8k", "n16k", ...) +runner.capture() # warm-up of every variant, then strict captures +out = runner("default", inputs={"pillars": p, "index": idx}, params={"score_thr": 0.4}) # {"heat": ..., "boxes": ...} +runner.release() # or `with TraceRunner(...) as runner:` +``` + +**What it guarantees.** Persistent tensors exist (with defined contents) before the first capture; every variant is +warmed `warmup_runs` times before *any* capture, and so are the runner's own eager programs (the `stage_inputs` +staging copy, the state <-> bank copies) and the callables registered with `add_eager_warmup(fn)`; capture runs with +`device.set_program_cache_misses_allowed(False)` +(a miss raises `... program cache miss occurred, but cache misses are forbidden` naming the op), `end_trace_capture` +runs in `finally` and a failed capture is released, and the program-cache size must not change during capture; +adding a variant after a capture releases all traces, warms the new one and recaptures everything; warm-up writes to +states are undone (`reset_state`) before capture; unchanged params are not re-uploaded. + +**No program may be compiled after the first capture.** A new program's kernel binaries are a DRAM buffer allocated +when it is first enqueued, in the address space of the traces' freed intermediates, so a replay can overwrite them +(tt-metal `tech_reports/AdvancedPerformanceOptimizationsForModels/TraceCorrectness.md`: corruption or a hang). Any +eager op the model runs between replays (host-fallback glue, an eager `ttnn.to_layout`, `tensors.upload_u8`, +`fp32_island`, ...) must therefore run once before the first capture: register it with `add_eager_warmup(fn)`. The +runner refuses its own eager work that compiles after a capture (`stage_fn` copies, `save_state` / `load_state`, +`reset_state` from a device tensor of another spec, `run_eager`) with `RuntimeError: ... compiled a new program after +capture`, and every port runs its device tests once with `TT_METAL_TRACE_ALLOC_TRACKING=1` (the tracker refuses a +replay while such a buffer is alive; ttaw 0.2.0's `stage_inputs` path tripped it: +`logs/ttaw/review_old_staged_tracking_probe.log`). + +**1CQ / 2CQ.** 1CQ: uploads and replays on CQ0. 2CQ: CQ1 waits for the last replay's event, uploads, records; CQ0 +waits, replays, records (the `check_dispatch.py` pattern; the events exist even after a partially failed `capture()` +left some variants runnable). `stage_inputs=True` (2CQ only) is the `tt_cnn` executor +pattern: CQ1 writes DRAM staging copies while the previous replay still runs, an eager `ttnn.copy` (or your +`add_input(..., stage_fn=lambda staging, dst: ...)`, e.g. a reshard into a sharded L1 input) moves them into the +trace inputs on CQ0; that copy is compiled in the first `capture()` (a staging buffer mirrors every +`write_input`). `read(variant, cq_id=1)` (2CQ) waits for that trace's completion event on the host and reads on CQ1, +so CQ0 can already run the next segment. + +**Segments.** Split a long graph into variants run back to back; hand intermediates from segment k to k+1 through an +in-place state (`ctx.write_state("mid", t)` in seg1, `ctx["mid"]` in seg2): a variant cannot see another variant's +outputs at warm-up time, a state buffer exists from the start. + +**Ping-pong state.** `pingpong=True` allocates two buffers and captures two traces per variant (phase 0 reads A / +writes B, phase 1 the reverse). A variant that writes the ping-pong states must write all of them ("stepping"); the +phase flips after each run of a stepping variant; read-only variants follow the current phase. In-place states +(default) read and write one buffer, so read what you need before the write. Reset with `reset_state(name, value)` +(host data, or a device tensor copied on the device). `write_state` traces one `ttnn.copy`; compute the new state +straight into `ctx.write_target(name)` (`ttnn.add(old, x, output_tensor=ctx.write_target("mem"))`, then +`ctx.write_state("mem", new)`) and no copy is traced (probe P13: one program less per state per frame). +`write_state` / `write_output` check shape, layout (ttnn.copy keeps it) and dtype changes (TILE only) before tracing. +For a ring buffer use `ttnn.copy` / slices into a fresh tensor and `write_state`; `ttnn.experimental.slice_write` +into a TILE state re-binds the handle and breaks traces (probe P13). + +**Per-stream state (PLAN.md D16).** A trace bakes one set of state addresses, so several `stream.id`s share them: +`add_state(..., banks=S_MAX)` allocates `S_MAX` DRAM banks of the state before capture, `save_state(name, i)` / +`load_state(name, i)` copy between the state and bank `i` (eager `ttnn.copy` on CQ0, ordered after the enqueued +replays; ~80 us for a StreamPETR-size state), and `StreamBanks` is the policy every temporal bundle uses: + +```python +from .ttaw.trace import StreamBanks +runner.add_state("prev_bev", shape=(1, 1, 22500, 256), dtype="bfloat16", pingpong=True, + banks=S_MAX if S_MAX > 1 else 0) # S_MAX = 1 at the first publish +streams = StreamBanks(runner, ["prev_bev"], max_streams=S_MAX, on_full="reject", max_gap_s=2.0) +fresh = streams.select(stream.get("id", "default"), reset=stream.get("reset", False), + timestamp_s=stream.get("timestamp_s")) # in _prepare, under the model lock +params = {"use_prev_bev": 0.0 if fresh else 1.0} # first-frame RT-dev params +``` + +`select` returns True when the stream starts fresh (new id, `reset=True`, timestamp backwards or a gap above +`max_gap_s`) after resetting its states to `init`; a new id beyond `max_streams` raises `InputError` (HTTP 400) with +`on_full="reject"` or takes the least recently used stream's bank with `"evict"` (`S_MAX=1`: restart the one state). +`forget(id)`, `streams` (most recent first), `active`, `describe()` (for `model.info`). + +**Outputs.** A variant returns either the tensors its last ops produce (allocated during the capture, alive as long +as the trace) or persistent outputs: `add_output(name, shape=...)` allocates a buffer before any capture and +`ctx.write_output(name, value)` copies into it inside the trace and returns it. Persistent outputs keep their address +across recaptures, can be shared by several variants (shape buckets with one readback) and are never overwritten by +another variant's replay; they cost one `ttnn.copy` per output. Op-produced outputs of a variant captured later can +be overwritten when an earlier-captured variant replays: read them before running another variant (`read` and +`__call__` return copies), and give segments read on CQ1 while CQ0 already replays the next one persistent outputs. +Warm-up and release free op outputs without `force`, so an output that shares memory with something the runner +does not own (a view of a weight) is never freed by the runner. + +| `TraceRunner` member | signature / meaning | +|---|---| +| constructor | `TraceRunner(device, *, num_command_queues=None, warmup_runs=1, stage_inputs=False, forbid_cache_misses=True, alloc_tracking=None, name="model")` | +| `add_input` | `(name, init=None, *, shape=None, dtype="bfloat16", layout=ROW_MAJOR, memory_config=DRAM, stage_fn=None)` -> device tensor. `init`: numpy / torch / ttnn host tensor (warm-up data), else zeros | +| `add_param` | `(name, value=0.0, *, shape=(1,1,1,1), dtype="float32", layout=TILE, memory_config=DRAM)` -> device tensor; broadcasts in ttnn binary ops | +| `add_state` | `(name, init=None, *, shape=None, dtype="float32", layout=TILE, memory_config=DRAM, pingpong=False, banks=0)` -> buffer or (A, B) | +| `add_output` | `(name, *, shape, dtype="float32", layout=TILE, memory_config=DRAM)` -> persistent output buffer (zeros) | +| `add_variant` | `(name, fn, *, warmup_runs=None)`; `fn(ctx)` returns a device tensor, a `Packed`, or a list / tuple / dict of them | +| `add_eager_warmup` | `(fn)`: eager device work the model runs between replays; `fn()` runs in the first `capture()`, before any trace (refused after it) | +| `capture()` | warm-up + capture of all pending variants (idempotent) | +| `run(variant=None, inputs=None, params=None)` | upload + non-blocking replay; returns the device outputs | +| `read(variant=None, *, cq_id=0, as_torch=False)` | blocking read into preallocated host tensors -> numpy (or torch); `Packed` -> `{name: array}` | +| `__call__(variant=None, inputs=None, params=None, *, as_torch=False)` | `run` + `read` | +| `upload(inputs=None, params=None)` / `replay(variant=None, n=1)` | the two halves of `run` (benches, pipelining) | +| `run_eager(variant=None, inputs=None, params=None)` | same function without a trace, buffers freed before returning: replay-vs-eager bit checks | +| `set_params(**values)` / `write_input(name, value)` | queue param values for the next run / upload an input now (CQ0; with 2 CQs the event the next CQ1 upload waits for is re-recorded after it) | +| `reset_state(name=None, value=None)` / `read_state(name)` / `state_buffer(name)` / `phase` | state control (on CQ0, ordered after enqueued replays); `value` may be a device tensor | +| `save_state(name, bank)` / `load_state(name, bank)` | state <-> bank copies on CQ0 (`add_state(banks=...)`; D16) | +| `outputs(variant=None, phase=None)`, `trace_ids()`, `describe()`, `timings_ms`, `phases`, `captured` | introspection (`describe()` is JSON-able: put it in `model.info`) | +| `release()` | release traces and persistent tensors (idempotent; also `__exit__`) | + +`TraceContext` (the `ctx` of a variant): `ctx[name]` (input, param, persistent output, or a state's read buffer), +`ctx.state(name)`, `ctx.write_target(name)`, `ctx.write_state(name, value)`, `ctx.write_output(name, value) -> +buffer`, `ctx.variant`, `ctx.phase`, `ctx.capturing`, `ctx.device`. Registering inputs / states / variants from +inside a variant function (during warm-up or capture) raises. + +`StreamBanks(runner, states, *, max_streams=1, on_full="reject", max_gap_s=None)`: `.select(stream_id, *, +reset=False, timestamp_s=None) -> fresh`, `.forget(id)`, `.streams`, `.active`, `.describe()` (above). + +`pack_outputs(tensors: dict, *, dtype="float32", align=32, row_elems=None) -> Packed`: one ROW_MAJOR tensor holding +every output (typecast to `dtype`; float32 is exact for bf16 and integers < 2**24), read with ONE device-to-host copy; +`TraceRunner.read` unpacks it, `Packed.layout.unpack(flat)` gives `{name: array}` (views) from any array of +`layout.total` elements (`PackLayout(entries, total, rows, row_elems)`, `.shape` = the device shape; `PackEntry(name, +offset, numel, shape, pitch=0)` indexes the elements in row-major order; `pitch > 0`: the tensor's rows of +`shape[-1]` elements are stored `pitch` elements apart, zero-padded, and `unpack` / `entry.view(flat)` return a +strided view). Layout (since 0.15.0, YOLOX PORT_LOG Q9): + +- packed total <= `SINGLE_ROW_MAX_ELEMS` (131072 = 512 KiB of fp32): one `[1, 1, 1, total]` row, each tensor + flattened and zero-padded to `align` elements -- exactly the layout and programs of 0.1.0-0.14.0; +- above it, or with `row_elems=R` (a multiple of 32): `[1, 1, rows, R]`, R = `PACK_ROW_ELEMS` (1024) by default. + Each tensor starts on a row boundary and is zero-padded to whole rows (a tensor of at most 64 KiB is flattened + and padded by < R elements; a larger `[.., n, c]` gets zero rows appended until `n * c` fills whole rows of R, + i.e. n rounded up to a multiple of `R / gcd(c, R)`; when that wastes more than 1/8 of the tensor beyond the best + alternative -- few rows of an awkward width: 0.15.0-0.18.0 packed `[1, 1, 2, 20001]` fp32 as 80 MB -- it is + flattened if its padded row fits 128 KiB, else its rows are zero-padded to a pitch `p` = c rounded up to a power + of two dividing R, or to R, the smallest segment: one `ttnn.pad` of the row and last dims, then the reshape into + rows of R; segments stay below 1.4x the tensor + one row, typical shapes keep the contiguous layout), then the + segments are concatenated on the row dim. No ROW_MAJOR page above 128 KiB is reshaped, so outputs of tens of MB + pack; a flat vector `[1, 1, 1, N]` wider than that is cut into `ttnn.slice` chunks; any other tensor with rows + wider than 128 KiB raises `ValueError` (give it a narrower last dim). Measured (p150b, + `tests/device/test_pack_outputs_device.py`, two runs, 1CQ / 2CQ; pack = replay of the packing trace, read = D2H + + unpack): 64 KB single row 0.15-0.22 + 0.14-0.18 ms; 1 MB (YOLOX 14400 x 13 fp32 head level + a 240 x 240 uint32 + map) 0.69-0.75 + 0.30-0.54 ms; 16 MB (`[1, 1, 262144, 16]` fp32) 1.24-1.74 + 3.7-8.1 ms (2-4 GB/s); bit-exact + through trace replays (`logs/ttaw/q9_pack_outputs_device*.log`). + Why: a single ROW_MAJOR row is ONE page, and `ttnn.reshape` / `concat` stage whole pages in L1 (2 x the + destination page per kernel copy for pages that are not 16-byte aligned), so the 0.14.0 layout failed above + ~0.6 MB with `TT_FATAL: RM reshape dest staging does not fit in L1`. + +`CQ_COMPUTE = 0`, `CQ_INPUT = 1`. + +**Alloc tracking (debug).** `TT_METAL_TRACE_ALLOC_TRACKING=1` must be exported before Python imports ttnn; then +`ttnn.execute_trace` refuses to replay while a buffer allocated after a capture is alive. `alloc_tracking_enabled()` +reports it; `TraceRunner(alloc_tracking=True)` raises if it is off. The runner acknowledges the outputs of every trace +captured after the first (plain ttnn would flag them: `tests/device/test_trace_alloc_tracking_device.py`). + +--------------------------------------------------------------------------------------------------------------------- + +## 4. Tensors (C03, `ttaw.tensors`) + +| name | meaning | +|---|---| +| `TILE`, `round_up(n, multiple=32)`, `tile_padded_shape(shape)`, `pad_to_tile(a, value=0)` | tile geometry (host) | +| `pad_to_capacity(a, capacity, *, axis=0, value=0, truncate=False) -> (padded, n_valid)` | fixed-capacity buffers (capacities are grid-independent constants) | +| `as_4d(a)` | prepend unit dims to rank 4 | +| `float32_to_bf16_bits`, `bf16_bits_to_float32`, `round_to_bf16` | RNE bf16 conversion identical to torch (expected values for exact tests) | +| `ttnn_dtype(name)`, `dtype_name(dtype)` | `"bf16"`, `"fp32"`, `"bfp8"`, `"bfp4"`, `"uint32"`, `"int32"`, `"uint16"`, `"uint8"` aliases | +| `to_host_tensor(value, dtype, layout=None, *, shape=None)` | numpy / torch / ttnn host tensor -> ttnn host tensor; integers never pass through a float intermediate (int64 input to `ttnn.from_torch` would) | +| `to_device(value, device, dtype="bf16", layout=None (TILE), memory_config=None (DRAM))` | eager upload | +| `to_numpy(t)` | ttnn (device or host) / torch -> numpy (bf16 -> float32) | +| `to_layout(t, layout)`, `typecast(t, dtype)`, `to_fp32(t)`, `to_bf16(t)` | no-ops when nothing changes (no extra program) | +| `fp32_island(fn, *tensors, out_dtype="bfloat16")` | run `fn` on fp32 copies, cast results back | +| `upload_u8(array, device, *, out_dtype="bfloat16", layout=None, memory_config=None)` | uint8 upload + on-device typecast (exact); in a trace keep the uint8 tensor as the input and `ttnn.typecast` as the first op | +| `HostStaging(shape, dtype)` | persistent ROW_MAJOR host tensor; `.write(array)` copies into its buffer through a `torch.from_dlpack` alias (`zero_copy=True` on this tree for float32 / bfloat16 / uint32 / int32 / uint16 / uint8) and returns `.tensor`; pass it as a `run(inputs=...)` value | + +--------------------------------------------------------------------------------------------------------------------- + +## 5. Weights (C04, `ttaw.weights`) + +```python +from .ttaw.weights import OnnxWeights, WeightCache, fold_bn_conv + +w = OnnxWeights(weights_path / "pts_backbone_neck_head_centerpoint.onnx") # parsed as data, never executed +for node in w.nodes("Conv", "/backbone/*"): # graph order, glob or regex + wf, bf = w.fold_conv_bn(node.name) # + its BatchNormalization consumer, fp64, rounded once +k = w.param("/dit/blocks.0/attn/MatMul", 1) # anonymous initializer, addressed by consuming node + slot +cache = WeightCache("tt_centerpoint/base", w.sha256, version=f"{__version__}.prep1") # $TT_CACHE_PATH | ~/.cache/ttaw +tw = cache.get("backbone.0.conv.w", lambda: wf, dtype="bfloat16", layout=ttnn.ROW_MAJOR_LAYOUT, device=dev) +``` + +| name | meaning | +|---|---| +| `OnnxWeights(path, *, load_external_data=True)` | `.array(name)` (initializer / Constant, through Identity), `.has`, `.initializer_names()`, `.state_dict()`, `.node(name)`, `.nodes(op_type=None, pattern=None, *, regex=False)`, `.find_node(pattern, op_type=None, *, regex=False)`, `.producer(t)`, `.consumers(t)`, `.consumer_of(t, op_type=None)`, `.param(node, slot)`, `.params(node)`, `.conv(node) -> ConvParams`, `.gemm(node) -> GemmParams`, `.matmul_weight(node)`, `.batchnorm(node) -> BatchNorm`, `.fold_conv_bn(conv, bn=None, *, dtype=np.float32)`, `.sha256`, `.input_names`, `.output_names`, `.opset`. External data outside the model directory is refused. Unnamed nodes are `"_"` | +| `OnnxNode`, `ConvParams` (weight, bias, strides, pads, dilations, group, kernel_shape, output_padding, auto_pad, output_shape), `GemmParams` (weight as stored, bias, trans_a, trans_b, alpha, beta) | frozen records; `pads` is the explicit attribute: with `auto_pad` `SAME_*` derive the padding from the input size | +| `BatchNorm(gamma, beta, mean, var, eps=1e-5)` | float64; `.scale`, `.shift`, `.channels`, `.apply(x, axis=1)`, `.from_state_dict(sd, prefix, eps)` | +| `fold_bn(w, b, bn, *, axis=0, dtype=np.float32)` | generic fold (`dtype=None` keeps float64 for a single final rounding to the device dtype) | +| `fold_bn_conv(w, b, bn)` | Conv weight `[Cout, Cin/g, k...]`, any groups | +| `fold_bn_linear(w, b, bn, *, layout="out_in")` | torch Linear / Gemm transB=1 (`out_in`) or MatMul / Gemm transB=0 (`in_out`); also BN1d | +| `fold_bn_conv_transpose(w, b, bn, *, groups=1)` | ConvTranspose weight `[Cin, Cout/g, k...]` | +| `load_safetensors(path)`, `load_torch_checkpoint(path, *, key=None)` (`torch.load(weights_only=True)`), `load_state_dict(path)` -> `StateDict` | `.safetensors`, `.pth/.pt/.ckpt/.bin`, `.npz` | +| `StateDict(tensors)` | `.sub(prefix)`, `.strip(prefix)`, `.bn(name, eps)` | +| `WeightCache(namespace, source_digest="", *, version="", root=None, enabled=None)` | `.get(key, make, *, dtype, layout=None, device=None, memory_config=None, shape=None)` caches ttnn host tensors as `.tensorbin` (atomic writes; rebuilt if unreadable or of another dtype / layout / `shape`), `.path(...)`, `.clear()`, `.hits`, `.misses`; `TTAW_WEIGHT_CACHE=0` disables. The cache outlives images (`/tensor-cache` is the host's `~/.cache/tt-model//tensors`): pass `version` (bundle `__version__` + a prep revision) and bump it whenever the code that prepares the tensors changes | +| `cache_root()`, `file_sha256(path)` | helpers | + +--------------------------------------------------------------------------------------------------------------------- + +## 6. Precision and knobs (C05, `ttaw.precision`, `ttaw.knobs`) + +`ttnn.matmul` / `linear` drop to LoFi when a `program_config` or `core_grid` comes without a compute config, and +`ttnn.WormholeComputeKernelConfig()` without `math_fidelity` is `MathFidelity.Invalid`. Always pass one: + +```python +from .ttaw.precision import PrecisionPolicy, compute_kernel_config +POLICY = PrecisionPolicy({"head.reg*": "accurate", "backbone.*": "HiFi2+fp32:w=bfp8"}, default="balanced") +policy = POLICY.with_env("CENTERPOINT") # CENTERPOINT_PRECISION="backbone.*=HiFi4+fp32;*=HiFi2+fp32" +y = ttnn.linear(x, w, compute_kernel_config=policy.compute_kernel_config("head.reg.fc1"), program_config=pc) +w_dtype = policy.resolve("backbone.block3.conv2").weights_dtype() +``` + +| name | meaning | +|---|---| +| `compute_kernel_config(fidelity="HiFi2", *, fp32_acc=True, approx=False, packer_l1_acc=False, dst_full_sync=False)` | explicit `ttnn.WormholeComputeKernelConfig` (= `BlackholeComputeKernelConfig`) | +| `Precision(fidelity="HiFi2", fp32_acc=True, approx=False, packer_l1_acc=False, dst_full_sync=False, weights="bfloat16", activations="bfloat16")` | `.parse("HiFi4+fp32+approx+l1acc+fullsync:w=bfp8:a=bf16" or preset)`, `.label`, `.with_(**)`, `.compute_kernel_config()`, `.weights_dtype()`, `.activations_dtype()` | +| `PRESETS` | `accurate` (HiFi4 + fp32), `balanced` (HiFi2 + fp32, the default), `fast` (LoFi, bfp8 weights; only after gates pass) | +| `PrecisionPolicy(rules, default="balanced")` | first matching glob wins; `.resolve(module)`, `.compute_kernel_config(module)` (cached), `.override(spec)`, `.with_env(prefix, env=None)`, `.describe()` (rules + which modules resolved to what) | +| `FIDELITIES` | `("LoFi", "HiFi2", "HiFi3", "HiFi4")` | + +Other silent defaults to override by hand: `ttnn.layer_norm` epsilon 1e-12, SDPA `is_causal=True`, `ttnn.embedding` +PADDED returning the cached pad row, HARDSWISH fused into conv2d skipped. + +Knobs (one per optimization, default = measured best, pinned in `tt-model.yaml serve.env`): + +```python +from .ttaw.knobs import Knob, Knobs +KNOBS = Knobs("CENTERPOINT", [Knob("FUSED_HEAD", True, "merged head convs"), + Knob("ACT_BLOCK_H", 64, "conv act_block_h", choices=(32, 64, 128)), + Knob("BFP8_WEIGHTS", False, "bfp8 backbone weights", experiment=True)]) +knobs = KNOBS.read() # once, in _build; env CENTERPOINT_FUSED_HEAD=0 is the A/B switch +assert KNOBS.serve_env() == yaml_serve_env_subset # host test: the image pins the defaults +``` + +`Knob(name, default, doc="", choices=None, experiment=False)`; `Knobs(prefix, knobs)`: `.read(env=None, +**overrides) -> KnobValues` (attribute / item access, `.source(name)`, `.overridden()`, `.as_dict()`, immutable; +warns when an experiment knob is set), `.defaults()`, `.serve_env(values=None)`, `.doc_table()`, `.env_name(name)`; +`parse_bool(text)`. + +--------------------------------------------------------------------------------------------------------------------- + +## 7. Metrics, goldens, gates (C06, `ttaw.metrics`, `ttaw.golden`) + +```python +# tests/test_pcc_device.py of a bundle +from ..ttaw.golden import GateRegistry, compare_taps, load_goldens +from ..ttaw.metrics import pcc, match_detections + +GATES = GateRegistry.for_test(__file__, {"backbone": 0.999, "head.heatmap": 0.99, + "dets.recall": (0.95, "min", "recall"), "plan.ade": (0.5, "max", "ade")}) + +def test_taps(device_taps): # numpy dict from an eager device run or trace outputs + with load_goldens(SPEC_DIR / "golden/sample0.npz") as gold: + report = compare_taps(device_taps, gold, gates=GATES, names=["backbone", "head.heatmap"]) + report.save_json(LOGS / "compare_backbone.json"); print(report.table()) + assert report.passed +``` + +`GateRegistry` stores frozen gates in `.gates.json` next to the test at the first green run. A later +declaration that is looser (lower `min`, higher `max`, other direction or metric) raises `GateLoosenedError`; +tighter ones are re-frozen; failing checks freeze nothing. `TTAW_GATES_READONLY=1` (or `write=False`) never writes. +Changing a frozen gate = editing the JSON by hand + disclosure in VERIFICATION_.md. + +| name | meaning | +|---|---| +| `pcc(t, r)` | float64 Pearson; NaN on non-finite input; a constant side (detected exactly, max == min) gives 1.0 for two equal constants (`np.allclose`) and 0.0 otherwise, also for two different constants | +| `masked_pcc(t, r, mask)`, `valid_row_pcc(t, r, n_valid=None, *, rows=None, axis=0)` | padded / masked buffers | +| `error_stats(t, r)` | `{pcc, max_abs, mean_abs, rel_l2, n}` | +| `argmax_agreement(t, r, *, axis=-1, mask=None)`, `label_agreement(t, r, *, mask=None)` | class decisions (ties -> lowest index) | +| `mask_iou(a, b)`, `mean_iou(t, r, num_classes, *, ignore_index=None) -> (miou, per_class)`, `box_iou_xyxy(a, b)` | IoU | +| `topk_overlap(t_idx, r_idx, k=None)`, `topk_set_overlap(t_scores, r_scores, k)` | data-dependent selections | +| `match_detections(t_centers, t_labels, t_scores, r_centers, r_labels, r_scores, *, max_dist=0.5, dims=2, same_label=True) -> DetectionMatch` | greedy same-label BEV-centre matching (`.pairs`, `.recall`, `.precision`, `.matched`, `.center_errors`, `.score_errors`, `.to_dict()`); box arrays `[N, 7]` work as centres; a box with a non-finite centre never matches | +| `ade_fde(pred, ref, *, dims=2) -> (ade, fde)` | trajectories `[..., T, D]` | +| `as_array(x)` | numpy / torch (bf16 ok) / list -> numpy | +| `save_goldens(path, tensors, meta=None, *, compress=False)`, `load_goldens(path) -> Goldens` | `.npz` + JSON `__meta__`; `Goldens` is a lazy mapping with `.meta`, `.sha256`, `.close()`, context manager | +| `TapRegistry(enabled=True, *, include=("*",), exclude=())` | `.tap(name, value) -> value` (device tensors read at once; raises inside a capture), `.scope(prefix)`, `.wants`, `.names()`, `.to_dict()`, `.save(path, meta)`, `.clear()`; `NULL_TAPS` is a disabled registry | +| `Gate(threshold, direction="min", metric="pcc")` | `.of(spec, metric=None)` (a bare number takes the direction of `metric` from `DIRECTIONS` and is refused for a metric of unknown direction; a direction contradicting a known metric, e.g. a `"max"` PCC gate, is refused), `.passes(v)` (NaN fails), `.looser_than(other)` | +| `GateRegistry(path, gates=None, *, write=None)` | `.for_test(test_file, gates)`, `.check(name, value, gate=None) -> GateResult`, `.require(...)` (AssertionError), `.gate(name)`, `.names()`, `.frozen`, `.results`, `.save()` | +| `CompareReport(title="", meta=None, gates=None)` | `.add(name, test, ref, *, metric="pcc", gate=None)`, `.add_value(name, value, *, metric, gate=None, stats=None)`, `.passed`, `.table()`, `.to_dict()`, `.save_json(path)`; `TapComparison` records | +| `compare_taps(test, golden, *, gates=None, metric="pcc", names=None, title="", meta=None)` | one report over common (or named) taps; missing taps fail when gated | +| `METRICS` | name -> (function, direction) used by reports (`pcc`, `masked_pcc`, `argmax_agreement`, `label_agreement`, `mask_iou`, `max_abs`, `mean_abs`, `rel_l2`) | +| `DIRECTIONS` | metric name -> `"min"` / `"max"` for every gate metric that may be given as a bare number: the `METRICS` plus `recall`, `precision`, `iou`, `miou`, `agreement`, `topk_overlap` (min) and `ade`, `fde`, `center_err*`, `score_err*`, `max_abs_err` (max) | + +--------------------------------------------------------------------------------------------------------------------- + +## 8. Bench and profile (C07, `ttaw.profiling`) + +```python +from .ttaw.profiling import AiclkSampler, bench_trace_runner, signposted, summarize_ops + +with AiclkSampler(interval_s=0.05) as clk: # sysfs tt_aiclk + hwmon power / temperature + bench = bench_trace_runner(model.runner, "default", {"pillars": p}, iters=100, post=model.decode) +bench.save_json(f"{ROOT}/logs/centerpoint/bench_baseline.json", config=model.device_info, aiclk=clk.summary()) +print(bench.table()) # host_in / h2d / trace / d2h / post / e2e / b2b, p50 p99 + +with signposted("trace"): # profile_ops.py under `python -m tracy -r -p -v -o ...` + model.runner.replay("default"); ttnn.synchronize_device(dev) +print(summarize_ops(f"{ROOT}/generated/profiler/centerpoint_baseline").table()) # ops, kernel sum, op2op, span +``` + +| name | meaning | +|---|---| +| `StageBench(name="")` | `.stage(name)` context, `.add(name, ms)`, `.summary()` (`{stage: {n, p50, p99, mean, min, max}}`), `.table()`, `.to_dict(**extra)`, `.save_json(path, **extra)`; `STAGES` order | +| `bench_trace_runner(runner, variant, inputs, *, params=None, iters=100, warmup=10, post=None, b2b_iters=None, name="")` | the standard stage breakdown of a `TraceRunner` variant | +| `time_b2b(enqueue, sync, n=100, warmup=5) -> ms` | back-to-back device time per iteration | +| `signpost(name, message=None) -> bool`, `signposted(name)` | Tracy markers (no-op without `tracy`) | +| `read_device_profiler(device)` | `ttnn.ReadDeviceProfiler` per trace segment (the buffer holds ~1000 ops) | +| `summarize_ops(source, *, start="trace", end=None, last_replay_session=None, freq_mhz=None, top_gaps=10) -> OpsSummary` | `ops_perf_results*.csv` / `cpp_device_perf_report.csv` / directory / rows; `OpsSummary`: `ops`, `kernel_sum_us`, `fw_sum_us`, `op2op_sum_us`, `span_us`, `by_op`, `fidelity`, `top_gaps`, `.table(top)`, `.to_dict()`. CLI: `python -m .ttaw.profiling [--start trace] [--end trace_end] [--json out]` | +| `find_ops_csv(path)` | newest ops CSV under a directory | +| `AiclkSampler(chip=0, interval_s=0.05, *, root="/sys/class/tenstorrent")` | `.start()`, `.stop()`, context manager, `.sample_once()`, `.summary()`, `.available` | + +`common/tools/p2_trace_bench.py --mode eth-1cq|eth-2cq|eth-2cq-staged|worker-2cq` measures the runner itself. + +--------------------------------------------------------------------------------------------------------------------- + +## 9. I/O and outputs (C08, `ttaw.io`, `ttaw.outputs`) + +`ttaw.io` (numpy only; every client mistake raises `InputError`, which the server maps to HTTP 400): +`b64decode(s, *, field, max_bytes)`, `PointCloud(points, fields, frame_id)` (`.select(names, fill=None)`; rows with a +non-finite `x` / `y` / `z` column -- the first three columns when unnamed -- dropped), +`decode_points(spec, *, max_points=2_000_000, max_bytes=None, default_fields=DEFAULT_POINT_FIELDS)`, +`load_points(source, *, fields=None, fmt=None, frame_id="base_link", default_fields=...)` (path `.bin/.npy/.npz/.pcd`, +bytes + `fmt`, arrays, structured arrays, envelopes), `decode_image(raw, *, fmt="auto", max_side=8192)`, +`load_image(image)` -> RGB uint8 HxWx3, `CameraImage`, `decode_cameras(images, calibration=None, *, order=None, +require_calibration=True, max_bytes=None)` (the server's `images[]`), its Python-API twins (0.15.0) +`load_camera(source, *, name=None, calibration=None, require_calibration=False)` (a `CameraImage`, `{"camera", +"image" | "path" | "data", "intrinsics", "T_ref_from_camera", "distortion", "timestamp_s"}` or any `load_image` source +with `name`; inline calibration wins over `calibration["cameras"][name]`) and `load_cameras(images, calibration=None, +*, order=None, require_calibration=True, calib_dir=None)` (a list or a `{name: image}` mapping; `{"preset": name}` +resolved in `calib_dir`; reordered to `order`, every camera exactly once: `model(images=..., calibration=...)` and +`/predict` see the same cameras), `decode_rois(rois, *, cameras=None, labels=None, max_rois=4096)` (2-D detections +as an input, `[{"camera", "label", "score", "box_xyxy"}]`: names / indices checked against `labels`, score in [0, 1], +finite ordered boxes; PointPainting `rois`, BUNDLE_CONVENTIONS.md 7.2), `parse_transform(obj, *, field)` (4x4 / 3x4 / `{matrix}` / +`{translation, rotation_wxyz|rotation_xyzw|rotation}` / `{x, y, z, roll, pitch, yaw}` tf2 RPY), +`parse_intrinsics(obj, *, field)`, `resolve_calibration(calibration, calib_dir)` (`{"preset": name}` -> +`calib_dir/.json` merged), `decode_named_arrays(spec, schema=None, *, max_bytes=None)` (planner inputs), +`check_named_arrays(arrays, schema)` (names, shapes, numeric and finite values, integral values in range for integer +dtypes; every problem is an `InputError`), `load_named_arrays(source, schema=None)` (mapping / envelope / `.npz` +path or bytes: the Python-API form), +`encode_array(arr, *, fmt="npz"|"npz_compressed"|"raw"|"list", key)`, `encode_png(image, *, key)`, `to_jsonable(obj)` +(float32 / float16 scalars rounded to 6 significant digits, float64 exact), +`POINT_FORMATS`, `DEFAULT_POINT_FIELDS = ("x", "y", "z", "intensity")`, `MAX_POINTS_DEFAULT`. + +`ttaw.outputs` (each has `to_dicts()` and `to_dict(output_format="json"|"npz")` = the `/predict` body with `model`, +`frame_id`, `meta`, `timing_ms`): + +| class | fields | +|---|---| +| `Detections3D(boxes [N,7] x y z l w h yaw, scores, label_ids, velocities=None, labels=(), model="", frame_id="base_link", timing_ms, meta)` | rows sorted by score at construction; `detections[] {label, label_id, score, center, size, yaw, velocity}`; npz adds `arrays` | +| `Detections2D(boxes_xyxy, scores, label_ids, labels=(), extras={}, model, frame_id="camera", ...)` | `detections[] {label, label_id, score, box_xyxy}`; `extras` (e.g. `{"semseg": Mask2D}`) encoded by name | +| `Segmentation3D(label_ids [N], class_names=(), scores=None, ...)` | `labels` (npz uint8 / uint16), `class_names`, `class_counts`, `scores` | +| `Mask2D(mask (H, W), class_names=(), encoding="png", ...)` | `mask` (png, or npz for `output_format="npz"` / non-uint8), `class_counts`; `.payload(fmt)` | +| `Trajectory(poses [T, D], columns=("x", "y", "yaw"), turn_indicator=None, predicted_agents=None, ...)` | `trajectory`, `columns`, `num_poses`, `turn_indicator`, `predicted_agents` (npz) | + +`label_name(labels, id)` maps ids to names (the id as text when out of range). + +--------------------------------------------------------------------------------------------------------------------- + +## 10. `ModelBase` (C08, `ttaw.api_base`) + +```python +from .ttaw.api_base import ModelBase +from .ttaw.outputs import Detections3D + +class CenterPoint(ModelBase): + MODEL_NAME = "centerpoint-p150"; ENV_PREFIX = "CENTERPOINT" + DEFAULT_REPO = "AutowareFoundation/lidar_centerpoint"; DEFAULT_TAG = "v4.1" + DEFAULT_REVISION = "494c8171def40bd36cc2feb323e0a5acbfab132b"; ALLOW_PATTERNS = ["base/*", "tiny/*"] + VARIANTS = ("base", "tiny"); DEFAULT_VARIANT = "base"; INPUT_KIND = "lidar" + LABELS = ("CAR", "TRUCK", "BUS", "BICYCLE", "PEDESTRIAN") + RUNTIME_PARAMS = {"score_threshold": (float, 0.0, 1.0, 0.35), "max_detections": (int, 1, 1000, 500)} + DEVICE_DEFAULTS = {"num_command_queues": 1, "trace_region_size": 64 << 20} + + def _build(self): # weights -> device tensors, TraceRunner + inputs / params / states / variants + self.runner = TraceRunner(self.device, name=self.MODEL_NAME); ... + def _warm_one(self, v): # capture (default_warmup_variants() -> [{"variant": self.variant}]) + self.runner.capture() + def _prepare(self, points=None, **kw): # host pre-processing; raise io.InputError for client mistakes + ... + def _forward(self, prep): # under the model lock + return self.runner("default", inputs={...}) + def _postprocess(self, raw, prep, params) -> Detections3D: + return Detections3D(boxes, scores, ids, labels=self.LABELS, model=self.MODEL_NAME) + def _release(self): + self.runner.release() + def extra_info(self): + return {"trace": self.runner.describe(), "knobs": self.knobs.as_dict()} + +with CenterPoint.from_pretrained() as model: # weights (pinned) -> device (ETH, 12x10) -> build -> warm + out = model("samples/test.npz", score_threshold=0.4) + print(out.to_dict()["num_detections"], out.timing_ms, model.info) +``` + +`from_pretrained(model_id=None, *, revision=None, variant=None, device_id=None, device=None, dispatch=None, +num_command_queues=None, weights_dir=None, warmup_variants="default", verbose=False, **compile_params)`: weights are +resolved first (`weights_dir` > `_WEIGHTS_DIR` > local dir `model_id` > HF snapshot at the pinned revision, +offline fallback to the cache), then the device opens with `DEVICE_DEFAULTS` < `_*` / `TT_DEVICE_ID` < explicit +arguments; a caller-provided `device` is left open by `close()`. Variant default: `_VARIANT` or +`DEFAULT_VARIANT`. A failing build closes everything. + +Other members: `resolve_weights(...)` (also module-level `resolve_weights(model_id, *, revision, allow_patterns, +weights_dir, env_var)`), `device_config(**overrides)`, `requires_calibration()`, `validate_params(params)` (unknown / +outside `[min, max]` (either bound may be None) / wrong type -> `InputError`; bools must be real booleans, ints must +be integral and not booleans, floats finite; numeric strings are accepted), `warmup(variants="default")` (idempotent), +`default_warmup_variants()`, `__call__(points=None, *, sweeps, images, calibration, stream, inputs, **params)` / +`predict` (one re-entrant lock around prepare + forward + postprocess; fills `timing_ms` preprocess / device / +postprocess / total), `info`, `closed`, `close()` (idempotent, also at interpreter exit), context manager. +Class attributes also: `WEIGHTS_LICENSE`, `CAMERA_ORDER`, `REQUIRE_CALIBRATION` (None: true for multicam), +`POINT_FIELDS`, `EXTRA_INPUTS` (model-specific input keywords handed to `_prepare` instead of being validated as +runtime params, e.g. PointPainting `("rois",)`; the server's `decode_extra` adds them to the call kwargs) and +`INPUT_SCHEMA` (`{name: (shape with None for free dims, dtype)}`: `inputs=` is decoded with +`io.load_named_arrays` and checked on every call, API and server alike). `RUNTIME_PARAMS` may not reuse an input +keyword (`from_pretrained` raises `TypeError`). `info` adds `extra_inputs` and `input_schema`; `__call__` re-checks +`closed` once it holds the lock. + +--------------------------------------------------------------------------------------------------------------------- + +## 11. Server (C08, `ttaw.server`) + +`create_app(spec: ServerSpec, *, model_factory=None) -> FastAPI` implements BUNDLE_CONVENTIONS.md section 7: +`GET /` (routes), `GET /health` and `/v1/health` (always 200: `ok` / `starting` / `error`, plus device), +`GET /info` (model, task, io, autoware, weights, device, input limits, calibration presets, labels, variants, +runtime / compile params, warm-up and boot ms, source), `GET /v1/models` (stub), `POST /predict`. Errors: 400 +(`InputError`, bad params, body above `_MAX_BODY_MB` by Content-Length), 422 (schema: unknown fields are +refused), 503 (starting / failed boot), 500 (`inference failed: : `). The lifespan reads the environment +once (`config_from_env`), refuses a `TT_MESH_SHAPE` other than 1x1, builds the model through +`spec.model_cls.from_pretrained` (or `model_factory(cfg)`), and closes it on shutdown under the lock. A body holding a +NaN / infinity (strict JSON has none) is a 500 `inference failed: non-finite value at body.`. `/info` `input` +also lists `extra_inputs` and `input_schema`. + +`ServerSpec(model_name, env_prefix, model_cls=None, task="", default_weights="", owner="changh95", hardware=HARDWARE, +io="", autoware={}, source={}, calib_dir=None, default_variant=None, version="0.1.0", description="", +request_model=PredictRequest, decode_extra=None, info_extra=None)`. Model-specific request fields: subclass +`PredictRequest` (e.g. `rois: Optional[List[...]] = None`), add them to the call kwargs in +`decode_extra(request, kwargs, max_bytes)` and list them in the model's `EXTRA_INPUTS`. `app.state.ttaw` +(`ServerState`) holds `model`, `config`, `ready`, `error`, `lock`, `model_factory` (tests: assign a stub before +`TestClient(app)`) and `predict` (the route handler, for API == server checks). Also exported: +`parse_mesh_shape`, `config_from_env(spec, env=None)`, `default_model_factory(spec)`, `decode_request(req, spec, +max_bytes)`, the request models `PointsSpec`, `SweepSpec`, `ImageSpec`, `StreamSpec`, `ArraysSpec`, +`PredictRequest`. + +`ttaw.server.client` and `ttaw.server.smoke` are standard-library only and import nothing from ttaw, so a bundle's +`smoke_test.py` (run by `container_smoke.sh` with the host's `python3`, which has no numpy) loads them by path: + +```python +HERE = Path(__file__).resolve().parent # code//server +sys.path.insert(0, str(HERE.parent / "ttaw" / "server")) +import client as ttaw_client +import smoke as ttaw_smoke +health = ttaw_client.wait_ready(url, wait_s=600) +info, fails = ttaw_client.check_service(url, expect_dispatch="eth", expect_grid="12x10") # PLAN 0.3 item 6 +pinned = ttaw_smoke.pinned_config(f"{staged}/tt_kernel_manifest.json", profile) # serve.env + profile +fails += ttaw_smoke.check_pinned(info, pinned, "CENTERPOINT") # /info runs the pins +code, body = ttaw_client.post(url, ttaw_client.build_request(points=str(SAMPLE))) +if ref := ttaw_smoke.find_reference(SAMPLE, pinned["profile"], info.get("variant")): # stored CPU output + metrics, more = ttaw_smoke.compare_with_reference(body, ttaw_smoke.load_json(ref), gates={"min_recall": 0.95}) + fails += more +fails += [f] if (f := ttaw_client.check_bad_request(url)) else [] +print(ttaw_client.smoke_line("centerpoint-p150", f"n={body.get('num_detections')}", fails)) +``` + +Functions: `build_request(points=None, fields=None, images=(), calib=None, calib_preset=None, inputs=None, +params=None, stream_id=None, reset=False, output_format="json")`, `post(url, payload, timeout=120)`, +`get_json(url, timeout=10)`, `wait_ready(base, wait_s=0, poll_s=5)`, `check_service(base, *, expect_dispatch="eth", +expect_grid="12x10") -> (info, failures)`, `check_bad_request(base, payload=None)`, `smoke_line(model, summary, +failures)`, `b64file`, `main` (CLI: `--points/--image/--calib/--calib-preset/--inputs/--param/--out/--url`). + +`ttaw.server.smoke` functions: + +| name | meaning | +|---|---| +| `compare_with_reference(body, reference, gates=None) -> (metrics, failures)` | served `/predict` body vs the stored CPU-reference body of the same input, by the reference's keys: `detections` (greedy same-label matching in descending reference score; 3-D rows by BEV centre distance, 2-D rows by IoU; recall, precision, max score difference), `trajectory` (ADE / FDE over x, y), every encoded array (integer: fraction of equal elements; float: max abs difference, equal infinities agree, a NaN fails), `model` / `frame_id` equal. Undecodable input is a failure, never an exception | +| `DEFAULT_GATES` | `max_center_dist` 0.5 m, `min_iou` 0.5, `min_recall` 0.95, `min_precision` 0.95, `max_score_err` 0.05, `min_label_agreement` 0.99, `max_abs_err` None (report only), `max_ade` 0.5, `max_fde` 1.0; `gates=` overrides by name (unknown names raise) | +| `find_reference(sample, *keys)` | first existing `..reference.json` (keys: serve profile, model variant), else `.reference.json`, else None | +| `pinned_config(manifest, profile=None)` | `{"profile", "env", "weights"}` of a staged `tt_kernel_manifest.json`: `serve.env` with the profile's env on top (tt-model's merge), default profile when None | +| `check_pinned(info, pinned, prefix)` | `/info` agrees with the pinned `_DISPATCH`, `_NUM_CQS`, `_VARIANT` and the weights revision (absent pins are not checked) | +| `decode_array(spec) -> Array(shape, dtype, values)` | numpy-free decoding of `io.encode_array` (npz, npz_compressed, raw, list; C or Fortran order, either byte order) and `io.encode_png` (8-bit grey / RGB(A), every PNG filter) | +| `describe_metrics(metrics)`, `load_json(path)`, `ARRAY_FORMATS` | helpers | + +The reference body is the `to_dict()` of the fp32 CPU reference on the shipped sample, stored next to it as +`.reference.json` (per profile or variant when the output depends on it). Synthetic samples may give no +detections, so agreement with that reference, not a detection count, is their smoke gate (PLAN.md section 6.3). + +--------------------------------------------------------------------------------------------------------------------- + +## 12. Vendoring (C09, `common/tools/vendor.py`) + +`python common/tools/vendor.py [--pkg tt_] [--rev REV] [--allow-dirty] [--check] [--dry-run]`. + +- **What is vendored is a committed revision**: `ttaw/` as committed at `--rev` (default `HEAD`), read with `git + archive`, never the working tree of `common/` (other agents' uncommitted modules live there: YOLOX PORT_LOG Q7). + Uncommitted changes under `common/ttaw` (staged, unstaged, untracked) are listed in a WARNING and left out: + commit your change (`git add `, bump the version + CHANGELOG), then vendor. The revision is resolved + once, so a commit landing meanwhile never mixes into the recorded `source_commit`. +- `--allow-dirty` vendors the working tree instead, for a local experiment only: `source_dirty` is true when it had + uncommitted changes or its files differ from `ttaw/` of HEAD (.gitignore'd files included: `source_dirty: false` + always means "verified against `source_commit`"); `check_bundle.py` warns about such a copy and refuses it at + `--stage publish`. Outside a git checkout the working tree is vendored with `source_commit: null` (same + treatment). +- The destination must be a plain directory: one that is, or reaches through a symlink, `common/ttaw` (or an + ancestor of it) is refused before anything is written (mirroring HEAD there would delete other agents' untracked + files and revert their uncommitted changes). +- The copy mirrors that tree into `code//ttaw` (stale files removed; `__pycache__`, `*.pyc`, `*.bin`, `*.log` + never copied) and writes `VENDORED.json`: `version` (of the vendored `__init__.py`), `source_commit` (full sha), + `source_ref` (the `--rev` given), `source_tree` (`commit` | `working-tree`), `source_dirty`, `vendored_at`, + per-file sha256. `research/packaging/scripts/instantiate_bundle.py --out` vendors the same way (`--vendor-rev`, + `--vendor-allow-dirty`; an existing copy is kept unless `--force`, and always with `--only `). +- `--check` (exit 1 on any line): "modified in bundle" / "missing in bundle" / "extra in bundle" (the copy vs its + `VENDORED.json`); "not at recorded revision " (`VENDORED.json` is not `ttaw/` of `source_commit`; + "unverified:" when that commit is not in `common/`; skipped for a `source_dirty` copy); "outdated:" (`VENDORED.json` + vs `ttaw/` committed at `--rev`, default HEAD: re-vendor and re-run the gates). Uncommitted work in `common/ttaw` + never makes a bundle look outdated. `check_bundle.py` reports "outdated:" and "unverified:" as warnings (errors at + `--stage publish`), every other line as an error. + +Never edit the vendored copy: change `common/ttaw`, bump `__version__` + CHANGELOG, commit, re-vendor every consumer +and re-run their gates (PLAN.md section 5.1 step 5). Functions: `vendor(bundle, pkg=None, *, dry_run=False, rev=None, +allow_dirty=False)`, `check(bundle, pkg=None, *, rev=None)`, `load_source(rev=None, *, allow_dirty=False) -> Source`, +`rev_files(rev)`, `rev_hashes(rev)`, `rev_version(rev)`, `resolve_rev(rev)`, `dirty_paths()`, `tree_hashes(root)`, +`source_version()` (working tree), `git_state()`, `find_package(bundle, pkg)`; git calls never take the index lock +(`GIT_OPTIONAL_LOCKS=0`). + +--------------------------------------------------------------------------------------------------------------------- + +## 13. Measured facts and pitfalls (this p150b, tt-metal 44d6650 + ETH patch) + +Device results of the ttaw suites and probe P2 are recorded in `common/probes/p2-trace-runner.md`. In short: + +- ETH dispatch opens 12x10 with 1 and 2 CQs, WORKER 11x10, `auto` -> ETH (`tests/device/test_open_device.py`). +- In-trace `ttnn.copy` into persistent float32 / uint32 / bfloat16 tensors, ping-pong traces, RT-dev scalar params, + packed readback, 2CQ uploads (direct and staged), CQ1 readback and replay == eager are bit-exact. +- A ROW_MAJOR tensor is one page per row, and the RM `reshape` / `pad` / `concat` programs stage whole pages in L1: + `reshape_rm_program_factory.cpp` needs 2 x (destination page + 80 B) per kernel copy (two copies when the pages + divide each other) when the pages are not 16-byte aligned, out of ~1.43 MB free L1 per core + (`TT_FATAL ... RM reshape dest staging does not fit in L1: need at least 1497632 B dest + 512 B source, have + 1461248 B`, YOLOX's `[1, 1, 1, 187200]` fp32 row). Keep RM rows (last dim x element size) well below 0.5 MB; + `pack_outputs` does (section 3). +- A program-cache miss inside a capture raises with the op name and leaves the device usable. +- numpy uint32 arrays (including values >= 2**31) upload exactly; int64 arrays must not be passed to + `ttnn.from_torch` directly (they would go through bf16): use `tensors.to_host_tensor`. +- `numpy.asarray(host_buffer)` fails and `numpy.from_dlpack` is read-only; `torch.from_dlpack` is a writable alias + (`HostStaging`). +- `ttnn.matmul` with an explicit HiFi4 + fp32 config: max |error| 0.03 vs fp64 on a 64x512x128 product; LoFi 2.4. +- Runner cost (34-program toy graph, 4 MiB input): `replay` b2b equals a raw `execute_trace` (0.857 ms); streaming + `run()` per frame 1.07 ms (1CQ), 1.09 ms (direct 2CQ: CQ1 must wait for the previous replay before overwriting the + input), **0.92 ms with `stage_inputs=True`** (upload hidden behind the replay). Use staging when uploads are large + and frames are pipelined; it buys nothing for a single synchronous request. +- `ttnn.to_torch` of a host tensor copies, so `read()` results stay valid after later reads. +- Review of 0.2.0 (ttaw 0.3.0, `logs/ttaw/review_*`): `ttnn.reshape` of a ROW_MAJOR tensor returns a view on the + same buffer, and `ttnn.deallocate(view)` (default `force=True`) frees the base tensor's memory + (`review_force_dealloc_probe.log`); a program first enqueued after a capture is a "program_cache" buffer the + allocation tracker refuses before the next replay (`review_old_staged_tracking_probe.log`). Ping-pong states + written through `ctx.write_target` (`output_tensor=`), D16 bank switches between two streams, `reset_state` from + a bank, `write_input` followed by a CQ1 upload, and a 2CQ run after a partially failed capture are bit-exact on + the p150b in 1CQ and 2CQ; with `TT_METAL_TRACE_ALLOC_TRACKING=1` bank switches and the 2CQ staged path allocate + nothing after capture. +- Where the 13 specs' other needs live: fp32 row gathers are `ttnn.gather` (FLOAT32, TILE; bit-exact but ~1 us per + picked index), bf16 rows `ttnn.embedding` (3-5 us; probe P13, `probes/g1-dispatch-genericop-state.md`); top-k, + argmax, conv / pool, attention / grid_sample facts are in `probes/g2..g4-*.md`; the op wrappers C17-C23 and + kernels K1-K11 are added by the first port that needs them (PLAN.md 1.2-1.3). RT-dev values of any shape are + `add_param` (re-uploaded only when they change) or, when they change every frame (index tables), `add_input`; + shape buckets are variants sharing `add_output` buffers; trace segments around a host fallback are variants handed + off through states or a read + `run(inputs=...)`; serve profiles pin `_VARIANT` / `_NUM_CQS` / `_DISPATCH`, + checked by `server.smoke.check_pinned`; temporal state per stream is `StreamBanks` (section 3). +- Device tests: run only your own files. `common/tests/device/` also holds the probe agent's `test_probe_p*.py`, + some of which spawn device subprocesses and must not run inside another pytest process. + +--------------------------------------------------------------------------------------------------------------------- + +## 14. Image pre-processing (C14, `ttaw.image`) + +Bit-exact host emulations of the resize kernels the Autoware camera nodes deploy, driven by per-source-size lookup +tables computed once and cached (a camera keeps its size). numpy only. A standard resize in their place moves the +network input enough to fail the PCC gates (YOLOX: `cv2.resize` drops the det-output PCC to 0.94, +research/yolox/SPEC.md section 3), so each preset is tested against a scalar transliteration of the deployed code. + +```python +from .ttaw.image import yolox_letterbox, yolox_letterbox_geometry + +bgr = rgb[:, :, ::-1] # Autoware feeds BGR8 (cv_bridge toCvCopy BGR8) +x_u8, geom = yolox_letterbox(bgr) # (960, 960, 3) uint8 HWC, BGR, pad 114 +x_onnx = yolox_letterbox(bgr, layout="nchw")[0].astype("float32") # == the ONNX input `images` (1, 3, 960, 960) +geom.scale, geom.resized_hw, geom.mask_hw # float32 scale (decoder), (r_h, r_w), mask crop + +from .ttaw.image_linear import sceneseg_preprocess, sceneseg_resize, opencv_resize_nearest +u8 = sceneseg_resize(bgr) # (320, 640, 3) uint8: cv::resize INTER_LINEAR, exact +x = sceneseg_preprocess(bgr, channel_order="bgr") # == the SceneSeg ONNX input (1, 3, 320, 640) float32 +mask_src = opencv_resize_nearest(mask_320x640, bgr.shape[:2]) # the node's INTER_NEAREST back to the source size +``` + +| name | meaning | +|---|---| +| `yolox_letterbox(image, dst_hw=(960, 960), *, pad_value=114, layout="hwc", out=None)` | autoware_tensorrt_yolox `preprocess.cu:43-129` @ 9ceaccf: inverted half-magnitude "bilinear" weights, `fmaf` single roundings, float -> int truncation between the passes, `lroundf`, top-left letterbox (114), channel order kept -> `(uint8 HWC or (1, C, H, W), LetterboxGeometry)`. Needs a source of at least 2x2 | +| `yolox_letterbox_geometry(src_h, src_w, dst_h=960, dst_w=960)` -> `LetterboxGeometry` | `scale` (exact float32 value as a float; `scale_f32`), `resized_hw` = `(r_h, r_w)`, `mask_hw` (Autoware's mask crop, the same pair), `src_hw`, `dst_hw`, `pad_value`, `to_dict()` | +| `yolox_letterbox_lut(src_h, src_w, ...)` -> `YoloxLetterboxLUT` | the cached tables (`rows`, `row_w`, `cols`, `col_w`, `k`); `.apply(image, *, out=None, chunk_rows=96)` (any channel count) | +| `LUTCache(maxsize=8)` | the thread-safe LRU the presets use (`.get(key, make)`, `.hits`, `.misses`, `.clear()`) | +| `lroundf(x)`, `f32(x)` | C `lroundf` (half away from zero) and one float32 rounding, for presets and their tests | +| `bevdet_nearest_crop(image, *, channels="rgb", src_hw=(900, 1600), dst_hw=(256, 704), crop_hw=(140, 0), out=None)` | the `bevdet::Preprocess` plugin of autoware_tensorrt_bevdet (bevdet_vendor 0.2.1, research/bevdet/SPEC.md 3.2): nearest resize by r = (float)dst_w / src_w = 0.44f and crop (140, 0) of the 1600x900 image, `roundf(i / r + crop_h / r)` rows (318, 320, 323, ..., 898) and columns (0, 2, 5, ..., 1598) in float32 -> `(3, 256, 704)` uint8 **B, G, R** planes. `channels` is the input order (`"rgb"` from `ttaw.io.load_image`, `"bgr"` from OpenCV). The node's `cv::resize` of other image sizes to 1600x900 is the caller's (OpenCV) | +| `bevdet_nearest_lut(src_hw, dst_hw, crop_hw)` -> `NearestCropLUT` | the cached gather (`rows`, `cols`, `resize`); `.apply(image, *, channels, out)`, `.apply_planes(planes [..., C, H, W])` | +| `bevdet_normalize(crop, mean=BEVDET_MEAN, std=BEVDET_STD)` | `(x - mean[c]) / std[c]` in float32 on B, G, R planes (`BEVDET_MEAN` / `BEVDET_STD` are the "RGB" ImageNet numbers, applied to BGR planes as trained) | +| `bevformer_preprocess(images, *, mean=BEVFORMER_MEAN_BGR, std=BEVFORMER_STD, scale=0.8, pad_divisor=32, out=None, workers=1)` | autoware_tensorrt_bevformer's pipeline (research/bevformer/SPEC.md 3.1): N BGR uint8 images of one size (the node's 1600x900) -> normalise first (`(x - mean_c) / std_c` as OpenCV's `convertTo`: BGR means 103.53 / 116.28 / 123.675, std 1) -> `cv::resize` x0.8 INTER_LINEAR **on float** -> zero pad bottom / right to /32 -> `[N, 3, 736, 1280]` float32 B, G, R planes (the ONNX input `image` without its leading 1). `workers` threads over the cameras. The node's uint8 `cv::resize` of other sizes to 1600x900 is the caller's (OpenCV) | +| `bevformer_input_geometry(src_hw=(900, 1600), scale=0.8, pad_divisor=32)` | `{"src_hw", "resized_hw", "padded_hw"}`: `(int)(src * 0.8f)` in float32 and the /32 pad: (720, 1280) and (736, 1280) deployed (the network normalises its UV by the padded size) | +| `bevformer_normalize(image, mean, std)` | the normalisation alone, HWC float32 | +| `opencv_linear_lut(src_hw, dst_hw, *, strict=True)` -> `LinearResizeLUT`; `opencv_resize_linear_f32(image, dst_hw, *, strict=True)` | OpenCV's float INTER_LINEAR (`resize.cpp` coefficients `f = (float)((d + 0.5) * scale - 0.5)`, border clamps; each pass `fma(x1 - x0, w, x0)` in float32, horizontal first), cached tables (`rows0`, `rows1`, `wy`, `cols0`, `cols1`, `wx`); `.apply(image HWC)`, `.apply_planes(planes [..., H, W])` (strided block slices for periodic taps). `strict=True` refuses geometries not verified bit-exact (weights not multiples of 1/8: OpenCV computes other scales' coefficients differently) and exact 2x down-scales (OpenCV's INTER_AREA path) | +| `PRESETS` | name -> (function, description) of the implemented presets | +| `image_area.meteor_resize(image, *, out=None)` | METEOR (tier4/METEOR `hf/onnx_smoke_test.py:42-44`, research/meteor/SPEC.md 3): one uint8 frame (any channel order) -> 432x768 with OpenCV `INTER_AREA` (a copy when already 432x768). Down-scaling only (raises `ValueError` for any axis smaller than the target) | +| `image_area.opencv_resize_area_u8(image, dst_hw, *, out=None)`, `opencv_area_lut(src_hw, dst_hw)` -> `AreaResizeLUT` | OpenCV `cv::resize(INTER_AREA)` of uint8 HxW / HxWxC images, any down-scale: `resizeArea_` (`computeResizeAreaTab` float weights from double, horizontal then vertical float32 accumulation in table order, `cvRound`) and `resizeAreaFast_` for integer factors (float32 `s * (1.f / area)`, except the 2x2 `fast_mode` of 1-, 3- and 4-channel images: `(s + 2) >> 2`); cached tables (`fast`, `rows`, `row_w`, `cols`, `col_w`) | +| `image_area.scale_intrinsics(K, src_hw, dst_hw=(432, 768))` | K of the resized image: row 0 x dst_w / src_w, row 1 x dst_h / src_h (METEOR `extract_gt.py:128-130`; anisotropic resizes keep fx != fy), float64 | +| `image_linear.sceneseg_preprocess(image_bgr, *, channel_order="bgr")` | VisionPilot SceneSeg (`middleware_recipes/common/backends/onnx_runtime_backend.cpp:41-60` @ vision_pilot 04fa3e80; research/sceneseg/SPEC.md 3): one HxWx3 B, G, R uint8 frame -> the ONNX input `float32 [1, 3, 320, 640]`, bit-exact = `sceneseg_normalize(sceneseg_resize(image))`. `channel_order`: `"bgr"` (deployed: B, G, R planes, BGR-ordered ImageNet constants) or `"rgb"` (training order: R, G, B planes, RGB constants; a channel permutation of the bgr input) | +| `image_linear.sceneseg_resize(image_bgr, *, dst_hw=(320, 640), out=None)`, `sceneseg_normalize(resized_bgr, *, channel_order="bgr")` | the node's squash `cv::resize(img, Size(640, 320))` (INTER_LINEAR, aspect not kept) -> uint8 `(320, 640, 3)`, channel order kept (the TT device input); the normalisation as OpenCV computes it: `convertTo(CV_32F, 1/255)` (float32 product), `cv::subtract(Scalar)` in float32, `cv::divide(Scalar)` in double rounded to float32 (a float32 division differs by 1 ulp in ~27 % of the values), `cv::split` -> NCHW. `SCENESEG_INPUT_HW`, `SCENESEG_MEAN_RGB`, `SCENESEG_STD_RGB`, `SCENESEG_CHANNEL_ORDERS` | +| `image_linear.opencv_resize_linear_u8(image, dst_hw, *, out=None)`, `opencv_linear_u8_lut(src_hw, dst_hw)` -> `LinearResizeU8LUT` | OpenCV `cv::resize(INTER_LINEAR)` of uint8 HxW / HxWxC images, any scale: `resize()`'s float coefficients (`fx = (float)((dx + 0.5) * scale - 0.5)`, x-border fix only), `cvRound(w * 2048)` weights, the exact int32 horizontal pass and the uchar fixed-point vertical pass `(((b0 * (h0 >> 4)) >> 16) + ((b1 * (h1 >> 4)) >> 16) + 2) >> 2`; a copy for equal sizes and OpenCV's INTER_AREA fast path (`image_area`) for an exact 2x down-scale. Cached tables (`mode`, `cols0`, `cols1`, `a0`, `a1`, `rows`, `r0`, `r1`, `b0`, `b1`); `.apply(image, *, out=None)` | +| `image_linear.opencv_resize_nearest(image, dst_hw, *, out=None)`, `opencv_nearest_lut(src_hw, dst_hw)` -> `NearestResizeLUT` | OpenCV `cv::resize(INTER_NEAREST)` (`resizeNN`: `min(floor(x * (1.0 / inv_scale)), W - 1)` in double) of HxW / HxWxC images of any dtype (a gather): e.g. SceneSeg's 320x640 mask back to the source size (`run_model_node.cpp:117-188`) | +| `image_triangle.streampetr_preprocess(image, *, preset="autoware_main", channels="rgb", dst_hw=(480, 640), fma=False, out=None)` | autoware_camera_streampetr (universe main @ 9ceaccf, `lib/network/preprocess_kernel.cu:73-176` + `camera_data_store.cpp:287-316`; research/streampetr/SPEC.md 3.1): one uint8 `(H, W, 3)` frame (`channels` = its byte order) -> the float32 network input `(3, 480, 640)`: `resize = max(480 / H, 640 / W)` (float32), the virtual resized image `((int)(H * resize), (int)(W * resize))`, top rows cropped (bottom kept) and the width centred, a PIL-style triangle filter with adaptive support (`centre = (r + 0.5) * scale - 0.5`, taps `ceilf(centre - support) .. floorf(centre + support)`, `w = max(0, 1 - |d| / support)` per axis, `w = wx * wy`), accumulated **y outer, x inner** in float32, divided by the weight sum, then `(v - mean[c]) / std[c]`, planar. `preset` (`STREAMPETR_PRESETS`): `autoware_main` (RGB planes, RGB stats; PLAN D7 default), `autoware_0.52` (an rgb8 camera on 0.52.1: RGB planes, BGR-ordered stats), `awml_training` (BGR planes, BGR stats). `fma=True` models nvcc's default `--fmad=true` contraction (`fmaf` centre, channel and weight sums); `False` (default) rounds every C operation once, as the SPEC's port and the research goldens | +| `image_triangle.streampetr_preprocess_batch(images, *, preset, channels, dst_hw, fma, workers=1, out=None)` | the cameras of one frame -> `(N, 3, 480, 640)` float32 (each camera its own size); `workers` threads over the cameras | +| `image_triangle.autoware_resize_geometry(src_h, src_w, dst_h=480, dst_w=640)` -> `TriangleGeometry`, `triangle_resize_lut(src_hw, dst_hw=(480, 640), *, geometry=None, fma=False)` -> `TriangleResizeLUT` | `calculate_image_processing_params` in float32 (`src_hw`, `resized_hw`, `roi_hw`, `roi_start` (y, x), `resize`, `to_dict()`); the cached per-size tap tables (`rows`, `row_w`, `cols`, `col_w`; zero-weight padding) with `.apply(image u8 HxWxC) -> (C, roi_h, roi_w)` float32 weighted means (before the normalisation; any channel count). `geometry=` serves other nodes of the kernel family (BEVFusion's camera branch: verify its kernel first) | +| `image_triangle.fma_f32(a, b, c)` | `fmaf` on float32 arrays: one rounding, exact (TwoSum-corrected float64; a float64 sum landing on a float32 tie is broken towards the exact value) | + +Facts: 0 mismatches against the scalar kernel on 12 source sizes and on the research golden input of the Autoware +test image; 40-170 ms per frame on the shared host (1080p about 130 ms), a few ms per new size for the tables. The +BEVDet gather equals the research golden network input of the nuScenes key-frame `sample0` bit for bit +(`tests/host/test_image_bevdet_host.py`). The BEVFormer pipeline equals OpenCV 4.8.1 and 4.11.0 bit for bit on +arbitrary float images for every x0.8 down-scale (the textbook two-product interpolation differs in ~10 % of the +samples), the research reference's OpenCV pipeline on random uint8 images, and the research golden `image` of the +nuScenes key-frame `sample0` (`tests/host/test_image_bevformer_host.py`, which also holds a scalar exact-rational +oracle); 0.5 s per 6 x 1600x900 frame single-threaded on the shared host, 0.15 s with `workers=4` (OpenCV itself: +0.16 s). The device version (K9, uint8 upload + on-device resample) is an optimization item. Presets still to add +(their ports): BEVFusion triangle resize with truncation (PLAN.md C14; `ttaw.image_triangle` holds the same kernel +family). The METEOR `INTER_AREA` preset (`ttaw.image_area`) equals `cv2.resize` of OpenCV 4.8.1 and 4.11.0 bit +for bit at every METEOR source size tested and a scalar transliteration of `resize.cpp` on small geometries +(`tests/host/test_image_area_host.py`); about 0.1-0.15 s per 1920x1080 frame, 0.4 s at 2880x1860, numpy single thread. +The SceneSeg preset (`ttaw.image_linear`, 0.16.0) equals `cv2.resize` INTER_LINEAR / INTER_NEAREST and OpenCV's +`subtract` / `divide` scalar arithmetic of OpenCV 4.8.1 and 4.11.0 bit for bit on 23 fixed and 40 random geometries +(down / up, odd sizes, 1-4 channels, the exact-2x and copy shortcuts), a scalar transliteration of `resize.cpp`, the +23 research device inputs of the SceneSeg public-data set (`input_u8_bgr_640x320.png`) and, within one ulp, the SPEC +reference's float pre-processing (`tests/host/test_image_sceneseg_host.py`); 25-35 ms per 640x320 squash (414x727 to +1080p) and about 8 ms for the normalisation on the shared host, numpy single thread (OpenCV: 2-3 ms). +The StreamPETR preset (`ttaw.image_triangle`, 0.17.0) equals a scalar transliteration of the CUDA kernel bit for bit +in both contraction models (4 small geometries: down / up-scaling, crops, identity; 300 sampled pixels + the corners of +a 1920x1080 -> 480x640 frame), a scalar transliteration of `calculate_image_processing_params` on 15 source sizes, the +SPEC's numpy port (`research/streampetr/scripts/sp_common.py`) and the sha256 of the float32 network inputs of the +research goldens of the PandaSet ship sample (`tests/host/test_image_triangle_host.py`). The two contraction models +differ in ~43 % of the inputs by at most ~3e-5 (normalised units; the centre's last ulp moves the tap weights), far +below the device's bf16 input rounding. About 0.25-0.4 s per 1920x1080 camera on the shared host (numpy, one +thread), ~1.1 s per five-camera frame with `workers=4`. + +--------------------------------------------------------------------------------------------------------------------- + +## 15. LiDAR host pipeline (C10 `ttaw.geometry`, C11 `ttaw.pointcloud`, C12 `ttaw.voxelize`, C15 `ttaw.nms`, C16 `ttaw.decode`) + +Host pre- and post-processing of the Autoware LiDAR detectors, built by the CenterPoint port (research/centerpoint/ +SPEC.md sections 3 and 5) for CP, PP, TF and BF. numpy only. Each reproduces the deployed C++ / CUDA, including its +float32 arithmetic where that decides a cell or a threshold; the non-deterministic parts of Autoware (time-seeded +point shuffle, atomic slot / pillar order, unstable sorts) become fixed, documented policies (PLAN.md D1). + +```python +from .ttaw.pointcloud import StreamDensifiers, densify_sweeps, nonzero_rows +from .ttaw.voxelize import PillarGridSpec, assign_pillars, decorate_pillar_features, canvas_gather_index +from .ttaw.decode import CenterHeadDecodeConfig, decode_centerhead, sort_by_score, to_detected_objects +from .ttaw.nms import circle_nms, iou_bev_nms, ClassRemapper + +spec = PillarGridSpec((-76.8, -76.8, -4.0), (76.8, 76.8, 6.0), (0.32, 0.32, 10.0), 32, 40000, order="flipped_x") +cache = StreamDensifiers(num_past_frames=1).get("front") # Autoware's PointCloudDensification, per stream +cache.enqueue(xyz, stamp_s, T_world_from_ego) # False: no pose -> frame skipped, not cached +pts, info = cache.sweep_points() # (N, 4) x, y, z, time_lag; current sweep first +pil = assign_pillars(pts, spec, shuffle_seed=0) # first 32 per cell, ids in flipped-x order +feats = decorate_pillar_features(pil, spec, encoder_in_feature_size=9) # (40000, 32, 9) generateFeatures_kernel +index = canvas_gather_index(pil.coords, pil.num_pillars, spec, sentinel=40000) # NHWC canvas = table[index] +boxes = sort_by_score(decode_centerhead(heads, cfg)) # cfg = CenterHeadDecodeConfig.create(...) +keep = circle_nms(boxes.x, boxes.y, 0.5) +objs = to_detected_objects(boxes.take(keep), cfg.class_names) # ObjectClassification labels, yaw_ros +objs = objs.take(iou_bev_nms(objs.x, objs.y, objs.length, objs.width, objs.yaw, objs.label)) +objs.label = ClassRemapper.from_params(remapper_yaml_params).apply(objs.label, objs.length, objs.width) +``` + +| module / name | meaning | +|---|---| +| `geometry.compose`, `invert_rigid`, `transform_points`, `as_transform`, `transform_from_xyz_rpy`, `rotation_from_rpy` | float64 rigid transforms (`T_a_from_b`); `as_transform` accepts every `io.parse_transform` spelling | +| `geometry.invert_affine_f32`, `compose_f32`, `transform_points_f32` | Autoware's float32 path: `Eigen::Affine3f` inverse (cofactors, column-0 determinant) and product, and `generateSweepPoints_kernel` (`((m0 x + m1 y) + m2 z) + m3`, no FMA) | +| `geometry.mmdet_yaw_to_ros`, `ros_yaw_to_mmdet`, `quaternion_wxyz_from_yaw`, `yaw_from_quaternion_wxyz`, `yaw_from_rotation`, `wrap_angle` | yaw conventions (`yaw_ros = -yaw_net - pi/2`, float result as `ros_utils.cpp:55`) | +| `pointcloud.SweepDensifier(num_past_frames=1, cloud_capacity=2_000_000)` | one stream's cache: `.enqueue(xyz, stamp_s, T_world_from_sensor=None, *, intensity=None) -> bool` (pose required when `num_past_frames > 0`; a missing pose leaves the cache unchanged), `.sweep_points(*, point_feature_size=4\|5) -> (points, DensifyInfo)` (float32 `world2current @ past2world` per sweep, `time_lag = float32(t_now - t_sweep)`, capacity drops a sweep and every older one), `.reset()`, `.stamps`, `.describe()` | +| `pointcloud.StreamDensifiers(..., max_streams=16)` | per-`stream.id` caches with LRU eviction: `.get(id, reset=False)`, `.forget(id)`, `.streams`, `.describe()` | +| `pointcloud.densify_sweeps(current_xyz, [(xyz, time_lag_s, T_current_from_sweep)], ...)` | the stateless client-side variant -> `(points, DensifyInfo)` | +| `pointcloud.finite_rows`, `nonzero_rows`, `range_mask`, `seeded_permutation(n, seed)` | hygiene masks (`nonzero_rows` = `|x|+|y|+|z| > 0`, the research rule for organized-cloud fillers; `range_mask` half-open, NaN fails) and `default_rng(seed).permutation(n)` | +| `voxelize.PillarGridSpec(range_min, range_max, voxel_size, max_points_per_pillar=32, max_pillars=40000, order="flipped_x"\|"raster")` | grid sizes in float32 as `centerpoint_config.hpp` (`grid_x`, `grid_y`, `num_cells`) | +| `voxelize.assign_pillars(points, spec, *, shuffle_seed=None) -> PillarSet` | deterministic pillars: optional seeded permutation, half-open range, float32 `floor((v - min) / voxel)` clamped, first K per cell in input order, ids in ascending cell order capped at `max_pillars`. `PillarSet`: `points (P, K, F)`, `num_points`, `coords (z, y, x) = (0, iy, ix)`, `num_pillars`, `num_pillars_total`, `overflow`, `stats()` | +| `voxelize.decorate_pillar_features(pillars, spec, *, encoder_in_feature_size=9\|10\|11)` | `generateFeatures_kernel`: raw columns, offsets to the slot-ordered float32 mean, offsets to the pillar centre `voxel/2 + coord*voxel + min`; padded slots 0 | +| `voxelize.scatter_canvas(features, coords, n, spec)`, `canvas_gather_index(coords, n, spec, *, sentinel)` | `scatterFeatures_kernel` (C, H, W) and its gather form for the device (C19): cell `iy * grid_x + ix` -> pillar row or the zero sentinel row | +| `decode.CenterHeadDecodeConfig.create(class_names, voxel_size_xy, range_min_xy, downsample_factor, distance_bin_upper_limits, score_thresholds, yaw_norm_thresholds, has_variance=False, has_twist=False)` | Autoware's coercions (thresholds outside [0, 1) -> 0, ascending bins, one yaw threshold per class); thresholds `(bins, classes)`; `.with_score_threshold(v)` | +| `decode.decode_centerhead(heads, cfg) -> DecodedBoxes`, `sort_by_score` | `generateBoxes3D_kernel` in float32 (first-max label, no +0.5 offset, bins, `score < thr` drops, yaw-norm gate, dims (w, l, h), `atan2`), rows in cell order; stable descending sort (ties by cell). No max-pool, no top-K | +| `decode.head_values_at(heads, cells) -> {name: (C, K)}`, `decode_cells(values, cells, grid_w, cfg) -> DecodedCells` | the same decoder at given cells, dropping none (agreement metrics: device vs fp32 reference at the same cells). `DecodedCells`: the `DecodedBoxes` fields (bit-identical to `decode_centerhead`'s row for every cell it keeps) plus `yaw_norm`, `distance_bin`, `score_threshold`, `yaw_norm_threshold`, `passes_score`, `passes_yaw_norm`, `.keep`, `.take`, `.to_boxes()`; `head_values_at` stores the maps at a cell set (compact goldens) | +| `decode.to_detected_objects(boxes, class_names, *, has_twist=False) -> DetectedObjects` | `box3DToDetectedObject`: ObjectClassification labels (`AUTOWARE_LABELS`, `LABEL_IDS`, `semantic_label`), `yaw_ros`, (length, width, height), `orientation_availability` SIGN_UNKNOWN for car-like labels, object-frame twist; `.take`, `.boxes_xyzlwh_yaw()` (the `outputs.Detections3D` layout), `.to_records()` | +| `nms.circle_nms(x, y, dist)` | `circleNMS` on score-sorted boxes: float32 `dx^2 + dy^2 < dist^2` (strict), greedy, class-agnostic | +| `nms.iou_bev_nms(x, y, length, width, yaw, labels, *, search_distance_2d=10.0, iou_threshold=0.1, scores=None, sort=False)` | `perception_utils::IouBevNms::apply`: every earlier object (kept or not) suppresses, pedestrian-vs-other pairs skipped, `<=` search gate, break at `iou > thr`, keep iff `max_iou <= thr`; exact rotated-rectangle IoU (`iou_bev`, `clip_convex`, `polygon_area`, `bev_box_corners`) with the 1e-6 / 0.01 area guards | +| `nms.ClassRemapper.from_params(yaml_params)` / `.from_lists(allow, min, max)` | `DetectionClassRemapper`: `.apply(labels, length, width)` with `bev_area = float(l * w)`, first allowed destination in label order | + +Facts (CenterPoint port, research/centerpoint): voxel tensors bit-identical to the research reference (input order +and seed 0, including a 41,918-pillar overflow frame); decode + NMS + remapper give the same objects as two +independent research implementations on 16 head-map goldens; about 0.3 s voxelization and 30-120 ms +post-processing per 200k-point frame on the shared host (numpy). Host tests: +`tests/host/test_geometry_pointcloud_host.py`, `test_voxelize_host.py`, `test_decode_nms_host.py`. Still to add (their +ports): PointPainting 12-feature decoration and raster order use, TransFusion / BEVFusion decoders, OpenPCDet and +METEOR NMS variants (PLAN.md C12, C15, C16). + +--------------------------------------------------------------------------------------------------------------------- + +## 16. CNN ops: conv builders (C17 `ttaw.ops.conv`), up-sampling and interpolation (C18 `ttaw.ops.upsample`) + +Built by the YOLOX port (PLAN.md C17 / C18; probes P5, P14). A feature map is a `FeatureMap(tensor, batch, height, +width, channels)`: the device tensor holds N*H*W rows of C channels (ttnn's flattened `[1, 1, N*H*W, C]`, TILE or +ROW_MAJOR), plus the spatial size ttnn's layout no longer carries. Every builder prepares its device weights on its +first (eager) call and reuses them, so call each layer once before capturing (the `TraceRunner` warm-up does): a +host weight write inside a capture fails ("Writes are not supported during trace capture"). + +```python +from .ttaw.ops.conv import Conv2d, KSplitConv, ConvTranspose2d, FeatureMap, concat_channels, residual_add +from .ttaw.ops.upsample import upsample_nearest, Resize2d, interp_matrix + +conv = Conv2d(w_oihw, b, stride=2, activation="relu6") # padding "same" (k // 2); HiFi4 + fp32 + L1 acc +x = FeatureMap(ctx["image_bf16"], 1, 960, 960, 3) # or fmap_from_numpy(nchw, device) (under devrun) +y = conv(x) # FeatureMap [1, 1, 480*480, 32], DRAM, TILE +y = residual_add(conv2(y), y, "relu6") # RELU / RELU6 fused into ttnn.add (P5) +z = concat_channels([upsample_nearest(lateral, 2), d4]) # nearest x2: bit-exact; concat along C +head = Conv2d(w, b, output_dtype="float32") # fp32 logits for thresholds +ks = KSplitConv(w_full, b, parts=(128, 128, 128), activation="relu") # conv(concat) without the concat (P14) +up = ConvTranspose2d(w_iokk, b, stride=4, activation="relu") # k = 2: conv_transpose2d; k >= 3: linear + d2s (P5) +rs = Resize2d((16, 44), (64, 176), channels=256, mode="linear", align_corners=True) # FPN_LSS-style bilinear +``` + +| name | meaning | +|---|---| +| `Conv2d(weight, bias=None, *, stride=1, padding="same", dilation=1, groups=1, activation=None, precision=CONV_PRECISION, output_dtype="bfloat16", output_layout="tile", slicing="auto", output_memory_config="dram", conv_config=None, weight_terms=1, name="conv")` | `ttnn.conv2d` with weights prepared once per input spec. `__call__(fmap)` (or a raw tensor + `batch=`, `height=`, `width=`) -> `FeatureMap`. `slicing="auto"`: a DRAM input runs ttnn's DRAM-sliced path with automatic slice counts (P14: traceable; explicit counts are often rejected) and writes DRAM interleaved; an L1 input runs the L1 path; `"l1"` = `Conv2dL1FullSliceConfig`; `("height"\|"width", n)` explicit (diagnostics). 1x1 stride-1 convs are matmuls on the L1 path; `output_memory_config` (`"dram"` default, `"l1"`, None = the op's sharded output) places their output. `conv_config`: extra `ttnn.Conv2dConfig` fields (optimization knobs: `act_block_h_override`, `shard_layout`, double buffers, ...). `is_matmul`, `out_hw(h, w)`, `takes_l1_path(t)`, `prepared_specs`, `release()`, `describe()` | +| `weight_terms=2` | the weight and the bias as two bf16 terms (`hi = bf16(x)`, `lo = bf16(x - hi)`: ~16 significant bits; `host_terms()`), run as two convs into fp32 outputs, summed in fp32 with the activation fused into the add, cast to `output_dtype` (TILE outputs only): ~fp32 weights and bias for 2x the MACs and 3 more programs. **bf16 rounding of weights and biases, not of activations, dominates a deep CNN's error** (a per-channel bias error is the same at every pixel): YOLOX's mask agreement on PandaSet side cameras is 97.5 % with bf16 weights and 99.3 % with two terms (CPU emulation; the device matches it). The device's float32 weight path (`precision=":w=float32"`) is no substitute: 1.5x less error than bf16 vs 3.5x (fp32 output) for two terms; with a bf16 output two terms land within 1.05x of the output-rounding floor (`logs/yolox/m3_weight_precision_probe.log`, `m3_c17_weight_terms_device_r2.log`) | +| `CONV_PRECISION` | `"HiFi4+fp32+l1acc"`, the default of every builder. **Without packer L1 accumulation a conv whose inner dim spans several blocks keeps its partial sums in the output dtype (bf16) between blocks** (`conv2d_op_program_factory_common.cpp:175-178`): YOLOX 3x3 256 -> 256 PCC 0.99998 / max abs 0.25 vs 0.9999993 / 0.025 with it, at the same speed (`logs/ttaw/c17_c18_device_results*.json`) | +| `normalize_activation`, `unary_with_param`, `apply_activation`, `FUSED_ACTIVATIONS`, `BINARY_FUSED_ACTIVATIONS` | fused conv activations: relu, relu6, silu, gelu (exact), sigmoid; binary fused: relu, relu6. **HARDSWISH is refused** (fused, it is silently skipped: P5); apply `ttnn.hardswish` as its own op | +| `KSplitConv(weight, bias, parts, *, activation=None, output_dtype="bfloat16", accumulate_dtype="bfloat16", **conv_kwargs)` | `conv(concat(x_1..x_n))` as `sum_i conv_i(x_i)` (bias on the first part, activation fused into the last add or after it); `__call__([fmaps])`. `accumulate_dtype="float32"` keeps partials fp32. `split_k_weights(w, parts)` is the host split | +| `ConvTranspose2d(weight [Cin, Cout, k, k], bias=None, *, stride=k, activation=None, precision=CONV_PRECISION, output_dtype="bfloat16", method="auto", output_layout="tile", memory_config="dram")` | kernel == stride, no padding. `auto`: k = 2 -> `ttnn.conv_transpose2d`, k >= 3 -> `ttnn.linear` (bias + activation fused) + depth-to-space (RM reshape / permute / reshape); `linear_weights()` gives the (i, j, c)-ordered host matrix | +| `merge_sibling_convs(weights, biases) -> (w, b, sizes)`, `split_channels(fmap, sizes)` | one conv for sibling convs reading the same input (exact), split by `ttnn.slice` on C | +| `concat_channels(fmaps, *, memory_config="dram")`, `residual_add(a, b, activation=None)` | glue on maps of one spatial size | +| `FeatureMap` (`rows`, `hw`, `with_tensor(t, channels=None)`, `deallocate()`), `fmap_from_numpy(nchw, device, dtype, layout, memory_config)`, `fmap_to_numpy(fmap)`, `conv_padding`, `conv_out_hw`, `is_dram` | helpers (TILE uploads touch the device: run under `bin/devrun`) | +| `upsample_nearest(fmap, scale=2, *, output_layout="tile", memory_config="dram")` | integer-factor nearest (floor): TILE -> RM -> `[N, H, W, C]` -> `ttnn.upsample` -> `[1, 1, N*sH*H*sW*W, C]` -> TILE. Bit-exact (ONNX Resize nearest / asymmetric / floor) | +| `interp_matrix(n_in, n_out, *, mode="linear"\|"nearest", align_corners=False, scale=None)` -> `[n_out, n_in]` float64; `resize_matrices(in_hw, out_hw, ...)` | 1-D interpolation matrices equal to `F.interpolate` (torch's half-pixel rule clamped at 0, `align_corners`, the `scale_factor` rule); host tests compare them with torch at 1e-12 | +| `Resize2d(in_hw, out_hw, *, channels, batch=1, mode="linear", align_corners=False, scale=None, dtype="float32", output_dtype="bfloat16", precision="accurate", output_layout="tile")` | separable resize on the device for any sizes: W pass `[N*H, C, W] @ A_w^T` (transposes around one matmul), H pass `kron(I_N, A_h) @ [N*H, W'*C]`; fp32 operands by default (bf16 cannot hold weights like 511/1023; a device fp32 matmul is TF32-like, P12). `reference(nchw)` is its host float64 twin. `ttnn.upsample`'s own bilinear is half-pixel only (integer scales, sharded bf16 input) | + +Device results (`common/tests/device/test_conv_upsample_device.py`, every case eager x2, captured with cache misses +forbidden, replayed on a new input, and equal to an eager run after the capture bit for bit; log +`logs/yolox/m1_c17_c18_device_r1.log` / `_r2_l1acc.log`, results `logs/ttaw/c17_c18_device_results*.json`): all 54 +distinct conv shapes of YOLOX seg16 (1x1 to 9x9, stride 1 / 2, 960^2 down to 30^2, fused RELU6, fp32 head preds) +PCC >= 0.9999 (>= 0.9999992 for kxk with `CONV_PRECISION`); K-split 7 x 64 -> 128 0.99999; ConvTranspose k = 2 / 4 +0.99999; the 7 YOLOX nearest x2 shapes bit-exact (0.03-3.6 ms: the RM round trip dominates at 16 x 480^2); `Resize2d` +align_corners True / False, integer and non-integer, PCC >= 0.9999998. Host tests: `tests/host/test_conv_upsample_host.py` +(54) on the fake ttnn plus `tests/host/fake_ttnn_cnn.py` (conv2d / conv_transpose2d / upsample / matmul / argmax / +where / ... for CNN graph tests; `install(fake)` returns its undo). + +--------------------------------------------------------------------------------------------------------------------- + +## 17. Attention (C20, `ttaw.ops.attention`) + +Built by the Diffusion Planner port (PLAN.md C20; probe P7). `ttnn.transformer.scaled_dot_product_attention` has +four traps; `sdpa` is the only way the ports call it: + +1. `is_causal` defaults to True: with Sq == Sk it silently runs causal attention, with Sq != Sk it is rejected. + `sdpa` always passes `is_causal=False`. +2. The default `scale` is `1/sqrt(padded head dim)` (wrong after a 16 -> 32 head padding). `scale=` is required. +3. The default 32/32 chunks are 2.5-7x slower and inaccurate over >= 32k keys. `sdpa` passes a program config from + `chunk_config(sq, sk)` (the P7 table plus Sk-range rules) and refuses `program_config=None` once Sk >= 1,000. +4. **With a user mask the op masks the padded keys only at tile granularity**: key tiles past `ceil(Sk/32)` get + `-inf`, but the last partial tile is read from the mask, whose tile padding is 0 after a host tilize + (`sdpa/device/kernels/dataflow/reader_interleaved.cpp`, "Mask read"). Up to 31 padded keys then join the + softmax (score `q . k_pad`, value `v_pad`): rel-L2 0.18 instead of 0.025 at 321 x 321 with 89 valid keys, and + 0.06 for P7's masked 564 x 564 case. `sdpa` refuses a mask with `Sk % 32 != 0`: run masked attention on + `aligned_keys(n)` keys (the extra keys masked with `-inf` in the mask; free, the tiles are padded anyway). + Without a mask an unaligned Sk is exact (the op generates an element-wise padding mask). + +```python +from .ttaw.ops import attention as A + +q, k, v = A.split_qkv(qkv, 8) # [1, 1, S, 3*H*D] -> 3 x [1, H, S, D] (one program) +q, k, v = A.split_q_kv(q_proj, kv_proj, 8) # Q from LN(x), K|V from x, same S +qh = A.split_heads(q_cross, 8) # Q-only (cross-attention; K / V hoisted elsewhere) +mask = A.expand_key_bias(ctx["key_row"], 576) # [1,1,1,Sk] bias row (persistent input) -> [1,1,Sq,Sk] DRAM +o = A.sdpa(q, k, v, scale=1 / 32 ** 0.5, attn_mask=mask, concat_heads=True) # [1, 1, Sq, H*D] +runner.run("plan", inputs={"key_row": A.key_bias_row(np.r_[valid, np.zeros(12, bool)])}) # 564 real + 12 masked +wq, bq = A.pad_head_columns(w_q, b_q, 8, 16) # head dim 16 -> 32 in the weights (exact, free) +wo = A.pad_head_rows(w_out, 8, 16) +``` + +| name | meaning | +|---|---| +| `sdpa(q, k, v, *, scale, attn_mask=None, program_config="auto", compute_kernel_config=None, fp32_acc=False, concat_heads=False, memory_config=None)` | `[B, H, Sq, D]` x `[B, Hkv, Sk, D]` (bf16 / bfp8 / bfp4 TILE interleaved, D % 32 == 0) -> `[B, H, Sq, D]`, or `[B, 1, Sq, H*D]` with `concat_heads=True` (the op's `output_concat_heads`, no extra program). Mask: additive bf16 TILE in DRAM, `[1\|B, 1\|H, Sq, Sk]`, Sk tile-aligned; `-inf` is safe | +| `chunk_config(sq, sk) -> (q_chunk, k_chunk)`, `CHUNK_TABLE` | exact measured shapes (evidence string per entry), else Sk >= 16,384 q64 k1024; >= 4,096 q64 k512; >= 1,000 q64 k128; shorter q64 k64 (32 when the sequence fits one tile) | +| `sdpa_program_config(device, sq, sk, *, q_chunk=None, k_chunk=None, exp_approx_mode=None)`, `sdpa_compute_config(*, fp32_acc=False, fidelity="HiFi2", approx=True)` | `SDPAProgramConfig` over the grid read from the device; the explicit compute config equal to the op default (P7: HiFi4 / exact exp do not help; `fp32_acc` halves the max error at ~1.4x the time) | +| `split_heads(x, num_heads)`, `split_qkv(qkv, num_heads, *, num_kv_heads=None)`, `split_q_kv(q, kv, num_heads, *, num_kv_heads=None)`, `merge_heads(x)` | `[B, 1, S, H*D]` <-> `[B, H, S, D]` through `ttnn.experimental.nlp_create_qkv_heads` / `nlp_concat_heads` (one program each, bit-exact moves, the logical S kept). `split_q_kv` needs the same S for Q and K / V; cross-attention splits Q and K / V separately | +| `key_bias_row(valid) -> [1, 1, 1, Sk]`, `key_bias(valid, sq) -> [1, 1, Sq, Sk]`, `expand_key_bias(row, sq)`, `aligned_keys(n)` | host masks (0 / `-inf`, float32; upload bf16 TILE) and the traced expansion of a persistent bias row (`ttnn.repeat`, DRAM; one program per plan, not per call) | +| `padded_head_dim(d)`, `pad_head_dim(x)`, `pad_head_columns(w, b, H, D)`, `pad_head_rows(w, H, D)` | head dim padding to a multiple of 32 in the projection weights (exact: the padded output columns are 0, P7) | +| `attention_reference(q, k, v, *, scale, bias=None)`, `attention_matmul(q, k, v, *, scale, attn_mask=None, compute_kernel_config=None)` | float64 numpy oracle; small-MHA fallback with two matmuls + softmax for what SDPA rejects (e.g. fp32 Q / K / V; tile-aligned Sk) | + +Device results (`common/tests/device/test_attention_device.py`, 27 cases through `TraceRunner`: eager x2 +bit-identical, strict capture, replay on a new input set equal to an eager run bit for bit; set A nearly uniform +attention, set B peaked; gates frozen in `test_attention_device.gates.json`; `logs/diffusion-planner/c20_device_r2.log`, +results `logs/ttaw/attention_results.json`): PCC vs float64 0.99966-0.99983 at the DP shapes (576 x 576 and 352 x 352 +masked, 352 / 321 x 564), TF self 500 x 500 head dim 16 -> 32, SP self 900 x 1,668 and BF cross 500 x 32,400 +(q64 k1024, 0.46 ms); rel-L2 0.024-0.025 everywhere; split / merge / mask expansion bit-exact; the fp32 matmul +fallback PCC 0.999998. Trace ms per call: DP fusion 576 x 576 masked 0.061-0.092, DiT self 352 x 352 masked 0.063, +DiT cross 352 x 564 0.033. A canary (`test_raw_op_mask_padding_leak`) keeps the trap-4 evidence (raw op, 321 keys: +rel-L2 0.183); when tt-metal masks partial tiles it fails and the refusal can be relaxed. Watcher bring-up of the +smallest shape (`TT_METAL_WATCHER=2`) clean: `logs/diffusion-planner/c20_watcher_smoke.log`. Host tests: +`tests/host/test_attention_host.py` (27) with `tests/host/fake_ttnn_attention.py` (an SDPA fake that keeps the op's +defaults, so a caller forgetting `is_causal=False` / `scale` fails; head ops, repeat, matmul, softmax). + +--------------------------------------------------------------------------------------------------------------------- + +## 18. LiDAR device modules: gather-form scatter (C19 `ttaw.ops.gather`), SECOND + SECONDFPN (C24 `ttaw.models.second`), CenterHead (C25 `ttaw.models.centerhead`) + +Built by the CenterPoint port (PLAN.md C19, C24, C25; probes P4, P5, P14) on the C17 builders of section 16, for +CP, PP, TF and BF. Every module is built from BN-folded host (numpy) weights, prepares its device weights on its first +(eager) call, keeps every output in DRAM (interleaved TILE; L1 residency is optimization work) and traces. + +```python +from .ttaw.ops.gather import check_index, scatter_rows, gather_rows, scatter_rows_numpy +from .ttaw.models.second import ConvSpec, Second, SecondFPN, RowLinear, free_maps +from .ttaw.models.centerhead import CenterHead, merge_heads +from .ttaw.ops.conv import FeatureMap + +index = check_index(prep.canvas_index, num_rows=prep.num_pillars, sentinel=40000) # host, [1, H*W] uint32 +backbone = Second([[ConvSpec(w, b, stride=s) for w, b, s in block] for block in blocks]) # 3x3 + ReLU +neck = SecondFPN([ConvSpec(w0, b0), ConvSpec(w1, b1, stride=2, kind="conv_transpose"), + ConvSpec(w2, b2, stride=4, kind="conv_transpose")]) # 1x1, k2 ConvT, k4 ConvT +head = CenterHead(shared_spec, [128, 128, 128], [(name, hidden_spec, final_spec), ...]) # K-split + merged heads + +def forward(ctx): # a TraceRunner variant + canvas, table = scatter_rows(pillar_rows, ctx["canvas_index"]) # [1,1,P,32] rows -> [1,1,H*W,32] TILE + blocks = backbone(FeatureMap(canvas, 1, 480, 480, 32)) + return head(neck(blocks)).tensor # one fp32 [1, 1, H*W, 32] tensor (15 channels used) +maps = head.unpack(runner("default", inputs=...), 480, 480) # host: {name: (C, H, W)} +``` + +| name | meaning | +|---|---| +| `gather.check_index(index, *, num_rows, sentinel=None)` | host validation of a gather index -> contiguous `uint32 [1, N]`; every entry in `[0, num_rows)` or equal to `sentinel`, else `ValueError` (an out-of-range index reads outside the table on the device) | +| `gather.gather_rows(index, table, *, sentinel=None, layout="tile", memory_config=None)` | `ttnn.embedding` of a UINT32 `[1, N]` index into a bf16 ROW_MAJOR `[1, 1, V, D]` table -> `[1, 1, N, D]`; with `sentinel` it is P4's fast `EmbeddingsType.PADDED` form (`padding_idx=sentinel`), which **returns the table's own row there**: the row must hold zeros | +| `gather.with_zero_rows(rows, count=1)` | `[1, 1, P, D]` rows (TILE or ROW_MAJOR) -> ROW_MAJOR table `[1, 1, P + count, D]` with zero rows appended (one `ttnn.pad`) | +| `gather.scatter_rows(rows, index, *, sentinel=None, layout="tile", memory_config=None) -> (canvas, table)` | the gather-form scatter: `canvas[n] = rows[index[n]]`, zeros where `index[n] == sentinel` (default `P`); static shapes, no canvas clear; P4: 0.21 ms at CP 480 x 480 | +| `gather.gather_rows_numpy`, `gather.scatter_rows_numpy(rows, index, *, sentinel)` | host oracles | +| `second.ConvSpec(weight, bias, stride=1, padding=None, relu=True, kind="conv"\|"conv_transpose", name="")` | one BN-folded layer: conv weight `[Cout, Cin, k, k]` (padding None = `k // 2`), ConvTranspose weight `[Cin, Cout, s, s]` (kernel == stride) | +| `second.Second(blocks, *, policy=None, prefix="backbone")` | SECOND: `blocks[i]` = list of 3x3 `ConvSpec` (block stride on the first); `__call__(fmap) -> [block outputs]` (input kept, inner layer outputs freed), `out_shapes(h, w)`, `release()`, `describe()`. C17 `Conv2d` layers: a DRAM input runs ttnn's automatic DRAM slicing (P14) | +| `second.SecondFPN(deblocks, *, policy=None, prefix="neck")` | one deblock per block output: `"conv"` 1x1 -> `RowLinear`; ConvTranspose k = 1 -> `RowLinear`, k = 2 -> `ttnn.conv_transpose2d`, k >= 3 -> linear + depth-to-space (C17 `ConvTranspose2d`, P5); every output moved to DRAM; the concat is never built | +| `second.RowLinear(weight, bias=None, *, activation=None\|"relu", precision=DEFAULT_PRECISION, output_dtype="bfloat16")` | a 1x1 conv / per-row dense layer: `ttnn.linear` on `[..., R, K]` TILE rows with an explicit compute config and `core_grid` from the device (keeps the ReLU fused), output DRAM; `weight` `[K, N]` or `[N, K, 1, 1]`; device weights uploaded on the first call | +| `second.DEFAULT_PRECISION` | C17's `CONV_PRECISION` (`HiFi4+fp32+l1acc`); a `ttaw.precision.PrecisionPolicy` overrides per module (`backbone.block.conv`, `neck.deblock`, `head.shared`, `head.hidden`, `head.out`); a precision's `activations` dtype (`:a=fp32`) is the dtype of that module's OUTPUT (bf16 by default; the K-split partial sums follow the shared conv's) | +| `second.free_maps(*maps)` | deallocate feature maps / tensors that still hold their buffer (`force=False`: views are left alone, P2) | +| `second.second_numpy(blocks, x_nchw)`, `second.second_fpn_numpy(deblocks, blocks_nchw)` | fp32 torch oracles (NCHW) | +| `centerhead.CenterHead(shared, split, heads, *, policy=None, prefix="head", output_dtype="float32", k_split=True, accumulate_dtype="bfloat16")` | `relu(conv3x3(concat(inputs)))` as a C17 `KSplitConv` over `split` (P14: 3.6 vs 14.8 ms at CP 480 x 480, more accurate), then the merged heads: `relu(x @ [Wh_1 \| ... \| Wh_n] + bh)` and the block-diagonal `@ Wo + bo` (two `RowLinear`s) -> ONE `[1, 1, H*W, C_pad]` tensor (`C_pad` = sum of head channels rounded up to 32). `shared_forward(inputs)`, `heads_forward(shared)`, `__call__(inputs)`, `unpack(rows, h, w) -> {name: (C, H, W)}`, `slices`, `release()`, `describe()` | +| `centerhead.merge_heads(heads, *, pad_to=32)` | host: `(Wh, bh, Wo, bo, slices)` of `[(name, hidden 1x1 + ReLU, final 1x1)]` (exact rewrite) | +| `centerhead.centerhead_numpy(shared, heads, inputs_nchw)` | fp32 torch oracle of the literal graph (concat, conv, n heads) | + +Device results (`common/tests/device/test_lidar_models_device.py`, TraceRunner: eager x2, strict capture, replay on a +new input, replay == eager bit for bit): the CP scatter at its real shape (40,000 rows into 480 x 480) bit-exact, +sentinel rows zero; CP-shaped SECOND (4 / 6 / 6 convs, 64 / 128 / 256 channels) + SECONDFPN + CenterHead with random +weights at 64 x 64 and 128 x 128 (first stride 1 and 2) PCC >= 0.999 for every block, deblock, the shared conv and +every head vs the torch fp32 oracle (`logs/ttaw/c19_c24_c25_device_results.json`). The CenterPoint bundle runs the same +modules with the real weights (`bundles/centerpoint-p150/PORT_LOG.md`). Host tests: +`tests/host/test_lidar_models_host.py` (13) on the fake ttnn plus `tests/host/fake_ttnn_lidar.py` (`embedding` with +P4's PADDED semantics, `max`, `EmbeddingsType`; `install(fake)` returns its undo). + +--------------------------------------------------------------------------------------------------------------------- + +## 19. grid_sample helpers (C23, `ttaw.ops.deform`) + +Built by the BEVDet port (PLAN.md C23; probe P9) for AlignBEV (8 history slots warped by per-frame affine grids) +and FPN_LSS's align_corners bilinear up-sampling; BEVFormer (DCN / rotate) and METEOR extend it. Grids are built on +the host (numpy, float32) and uploaded as ROW_MAJOR fp32 tensors (a per-frame RT-dev value or a static input); +`grid_sample` is the device call with the P9 rules checked first. + +```python +from .ttaw.ops import deform + +ring = runner.add_state("ring", shape=(8, 128, 128, 96), dtype="bfloat16", layout=ttnn.ROW_MAJOR_LAYOUT) # C % 32 +grid = deform.affine_grid(transforms_8x6, (128, 128), valid=[h >= 0 for h in history]) # [8, 128, 128, 2] fp32 +runner.add_param("grid", grid, shape=grid.shape, dtype="float32", layout=ttnn.ROW_MAJOR_LAYOUT) +aligned = deform.grid_sample(ctx["ring"], ctx["grid"]) # in a variant: bilinear, zeros, align_corners=False +up = deform.grid_sample(f2_rm, static_grid) # static_grid = deform.resize_grid((16, 16), (64, 64)) +``` + +| name | meaning | +|---|---| +| `pixel_to_grid(px, size)` | pixel coordinate (0 = first pixel centre) -> normalised `(2 px + 1) / size - 1` (float32): the align_corners=False form, exact for integer `px` when `size` is a power of two | +| `kernel_pixels(g, size)` | what the device kernel recovers: `g * size/2 + (size - 1)/2` in float32 (`grid_sample_reader_common.hpp`) | +| `affine_pixel_coords(transforms, out_hw)` | `ix = a w + b h + c`, `iy = d w + e h + f` per batch (float32, one rounding per op): the BEVDet AlignBEV pixel affine | +| `affine_grid(transforms [N, 6], in_hw, out_hw=None, *, valid=None)` | fp32 grid `[N, H, W, 2]`; `valid[n] == False` -> `OUTSIDE` (an exact zero sample: an empty history slot) | +| `resize_pixel_coords(in, out, align_corners=True)`, `resize_grid(in_hw, out_hw, *, align_corners=True, batch=1)` | static bilinear-resize grids (float64 source pixels, align_corners=False normalisation); for align_corners=True every source pixel is inside, so it equals `F.interpolate` up to the weight truncation | +| `padded_channels(c)`, `pad_channels(x)` | the input channel rule (multiple of 32; pad channels stay exactly 0 through the op) | +| `emulate_grid_sample(x, grid)` | host emulation of the kernel (fp32 fractions, weights **truncated** to bf16, taps outside skipped): the device test's oracle | +| `grid_sample(x, grid, *, compute_kernel_config=None, memory_config=None, batch_output_channels=False)` | `ttnn.grid_sample` bilinear / zeros / align_corners=False; refuses a TILE input or grid, input C % 32 != 0, a grid batch != input batch, a bf16 grid, and a FLOAT32 input with `fp32_dest_acc_en` (P9 defect); output DRAM by default | +| `OUTSIDE` | `-4.0`: a normalised coordinate whose four taps miss every input | + +Rules (P9): fp32 grids for every data-dependent grid (bf16 grids: PCC 0.9988 at 53 px, 0.992 at 128 px); C padded to +a multiple of 32; grids expanded per batch (no broadcast); ROW_MAJOR interleaved / height-sharded input and grid; +never a FLOAT32 input with `fp32_dest_acc_en`; call the op with align_corners=False on the align_corners=False +normalisation of the pixel coordinates you mean (zero padding acts in pixel space, so it is the same sampling as the +model's align_corners=True formulation, but integer coordinates stay exact: an identity warp is a bit-exact copy; +the literal ac1 normalisation leaves 2.8-7.6 % of values off by up to 1/32). The bilinear weights are truncated to +bf16 (0.33-0.6 % rrmse floor; K5 removes it); C18 `Resize2d` (fp32 interpolation matmuls) is the accurate +alternative for static resizes. + +Device results (`tests/device/test_deform_device.py`, TraceRunner: eager warm-up, strict capture, replay on new +data, replay == eager bit for bit; `logs/ttaw/deform_device_results.json`): AlignBEV [8, 128, 128, 96] identity slots +bit-exact, `OUTSIDE` slots exactly 0, pad channels 0, rigid-motion slots PCC >= 0.9999 vs torch fp32 and within 2 +bf16 ulps of `emulate_grid_sample`; the FPN_LSS resizes 16 -> 64 (640 ch) and 64 -> 128 (512 ch) PCC >= 0.99999 vs +`F.interpolate(align_corners=True)`, corners exact. Host tests: `tests/host/test_deform_host.py` (13). The BEVDet +bundle runs the same calls at its real shapes (`bundles/bevdet-p150/PORT_LOG.md`: AlignBEV moving slots PCC 0.999995 +vs the CPU reference). + +--------------------------------------------------------------------------------------------------------------------- + +## 20. ResNet builders (C27, `ttaw.models.resnet`) + +Built by the BEVDet port (PLAN.md C27) for its ResNet-50 image backbone (6 cameras as batch) and CustomResNet BEV +encoder; BEVFormer and METEOR reuse it. Built on the C17 builders of section 16 from BN-folded host weights; every op +traces, each conv prepares its device weights on its first (eager) call, every activation is a DRAM-interleaved TILE +`[1, 1, N*H*W, C]` map and intermediates are freed as soon as they are consumed. + +```python +from .ttaw.models import resnet as rn + +prov = lambda module: rn.ConvParams(w[module], b[module], stride, padding_tblr, dilation, groups) # BN folded +stem = rn.ResNetStem(prov, "img_backbone.conv1") # 7x7 s2 + ReLU + maxpool 3x3 s2 +r50 = rn.ResNetStages(prov, rn.mmdet_names("img_backbone"), [3, 4, 6, 3]) # bottleneck, pytorch style +c4, c5 = r50(stem(img_fmap), keep=(2, 3), free_input=True) # FPN inputs; the rest freed +bev = rn.ResNetStages(prov, rn.custom_resnet_names("img_bev_encoder_backbone"), [2, 2, 2], block="basic", + factory=rn.conv_factory("HiFi4+fp32+l1acc:w=fp32")) +f0, f1, f2 = bev(bev_in, keep=(0, 1, 2)) +``` + +| name | meaning | +|---|---| +| `ConvParams(weight [Cout, Cin/g, kh, kw], bias=None, stride=(1, 1), padding=(t, b, l, r), dilation=(1, 1), groups=1)` | one BN-folded conv as a provider returns it (numpy float32); a provider is `module name -> ConvParams` (BEVDet's reads `OnnxWeights` by consuming node, so no kernel size or stride is re-typed) | +| `conv_factory(precision=DEFAULT_PRECISION, **conv_kwargs)` | `(module, params, activation) -> callable`: one C17 `Conv2d` per module; `precision` a spec / `Precision` or a `PrecisionPolicy` resolved per module name. **DCN hook:** pass your own factory and return a deformable layer for the modules you name | +| `mmdet_names(prefix)`, `custom_resnet_names(prefix)` | `(stage, block) -> {conv1, conv2[, conv3], downsample, block}`: `layer{s+1}.{b}.conv{k}` / `.downsample.0`, or `layers.{s}.{b}.conv{k}` / `.downsample` | +| `Bottleneck(provider, names, *, has_downsample, factory)` | `relu(conv3(relu(conv2(relu(conv1(x))))) + idt)`, stride on `conv2` (pytorch style) | +| `BasicBlock(provider, names, *, has_downsample, factory)` | `relu(conv2(relu(conv1(x))) + idt)`; the projection may be any conv (CustomResNet: 3x3 stride 2) | +| `ResNetStem(provider, conv_module, *, factory=None, pool=True)` | `maxpool3x3s2p1(relu(conv1(x)))`; the pool runs DRAM-sliced and writes TILE | +| `ResNetStages(provider, names, layers, *, block="bottleneck"\|"basic", factory=None, downsample_first=())` | `__call__(x, keep=(), *, free_input=False) -> [kept stage outputs]` (input kept unless `free_input`; every other block output freed once consumed); `stages`, `convs()` | +| `maxpool_out_hw(h, w)`, `DEFAULT_PRECISION` (= C17 `CONV_PRECISION`) | helpers | +| `resnet_numpy(x_nchw, provider, names, layers, *, block, stem=None, keep=(), downsample_first=())` | fp32 torch oracle of the same blocks | + +Precision: the ports pick it. BEVDet runs `HiFi4+fp32+l1acc:w=fp32` (fp32 weights and biases: the bf16 rounding of +the BN-folded biases, applied per channel at every pixel, cost it depth PCC 0.9998 vs 0.99993 and 3 of the 52 sample0 +boxes, at no measured time cost); C17's `weight_terms=2` is the other accurate option. Device results +(`tests/device/test_resnet_device.py`, TraceRunner protocol; `logs/ttaw/resnet_device_results.json`): stem + two +bottleneck stages on [6, 3, 128, 352] and a CustomResNet stage (3x3 s2 projection) on [1, 864, 128, 128] -> 160, +random weights, PCC >= 0.999 vs `resnet_numpy` with both precisions, replay == eager bit for bit. Host tests: +`tests/host/test_resnet_host.py` (6, fake ttnn + `fake_ttnn_cnn` + a local max-pool fake). The BEVDet bundle runs the +real ResNet-50 / CustomResNet (`bundles/bevdet-p150/PORT_LOG.md`: depth / feat PCC 0.99994, chained heads >= 0.9995 +vs the fp32 CPU reference). + +--------------------------------------------------------------------------------------------------------------------- + +## 21. Query heads: top-k (C21 `ttaw.ops.topk`), heatmap peaks (C22 `ttaw.ops.heatmap`), TransFusion head (C26 `ttaw.models.transfusion_head`) + +Built by the TransFusion port (PLAN.md C21, C22, C26; probes P6, P7, P8, P13) for TF, BF and PT: everything after the +dense heatmap -- the 3x3 local maximum, the top-K proposal selection and the query decoder -- as one traceable device +graph with one packed output. numpy only at import. + +```python +from .ttaw.ops.heatmap import LocalMax, sigmoid_heat +from .ttaw.ops.topk import TopKSelect, selection_metrics +from .ttaw.models.transfusion_head import QueryHeadWeights, TransFusionQueryHead, assemble_transfusion + +local_max = LocalMax(192, 192, 5, rule="transfusion") # "bevfusion" / "ptv3": pooled_classes, passing cells +head = TransFusionQueryHead(QueryHeadWeights(...), prefix="decoder") # host numpy weights, BN folded +head.prepare(device) # tables + LayerNorm rows (before any capture) + +def forward(ctx): # a TraceRunner variant + heat, _ = sigmoid_heat(logits_fp32) # fp32 sigmoid -> bf16 (max_pool2d / top-k are bf16) + heat_nms = local_max(heat) # [1, 1, H*W, C] TILE + out = head(lidar_feat, heat_nms, ctx.get("topk_indices")) # None: device top-k; else teacher forcing + return pack_outputs(out) # heads (K x 32 fp32), query heat (Kp x 32), indices +raw = runner("default", inputs=...) +outs = assemble_transfusion(head.unpack_heads(raw["heads"]), raw["query_heat"], raw["indices"], bev_pos, + num_classes=5, num_proposals=500, cells=36864) # cls_score0 / bbox_pred0 / dir_cls_pred0 +``` + +| name | meaning | +|---|---| +| `heatmap.local_max_masks(h, w, classes, *, rule="transfusion"\|"bevfusion"\|"ptv3", pooled_classes=None, kernel=3)` | the constant masks `(m_pass, m_eq)` (NHWC rows `[h*w, classes]`, 0 / 1, disjoint) of `keep = m_pass + (heat == maxpool_kxk_s1_p(k//2)(heat)) * m_eq`: TF interior `m_eq`, no pass; BF pooled classes (default 0-3) as TF, the others pass everywhere; PT `m_pass = 1 - m_eq` (border and unpooled classes pass) | +| `heatmap.LocalMax(h, w, classes, *, rule, pooled_classes=None, kernel=3, memory_config="dram")` | the device chain on a bf16 TILE `[1, 1, h*w, classes]` heat (`max_pool2d` p1, `eq`, `multiply` by `m_eq` or `addcmul(m_pass, eq, m_eq)`, `multiply` by the heat); masks uploaded by the first call; `.numpy(heat_rows)` its host twin; refuses fp32 (max_pool2d is bf16 only) | +| `heatmap.sigmoid_heat(logits, *, dtype="bfloat16") -> (heat, sig)` | the sigmoid in the logits' dtype (fp32 logits: fp32 sigmoid), cast once; `sigmoid_heat_numpy` its host model | +| `heatmap.local_max_numpy`, `local_max_literal_numpy(heat_nchw, rule)` | oracles: the masked chain, and each model's literal `p0` + border / unpooled-class form | +| `topk.TopKSelect(cells, classes, k)` | `__call__(heat_nhwc) -> (indices, pos, cls)` (uint32 ROW_MAJOR `[1, padded_k(k)]`): `class_major_row` (transpose + untilize + reshape to one bf16 row, index `c * cells + p` = the ONNX `TopK` order), `topk_indices` (`topk_large_indices`, descending, ties unspecified), `decode` | +| `topk.decode_class_major(indices, cells, classes, *, clamp=True)` | exact decode with fp32 comparisons (`cls = sum_c idx >= c * cells`, `pos = idx - cls * cells`; no division), clamped so a 0xFFFFFFFF sentinel cannot become an out-of-range gather index; uint32 ROW_MAJOR rows for `ttnn.embedding` | +| `topk.padded_k(k)`, `K_MULTIPLE`, `MASK_VALUE` (-1e30), `SENTINEL` | k rounded up to a multiple of 16 (500 -> 512); mask scores with a finite value, never `-inf` (P8) | +| `topk.topk_numpy(scores, k)`, `class_major_numpy`, `decode_class_major_numpy`, `selection_metrics(dev_idx, ref_idx, *, reference_scores=None, flat_scores=None, strong=0.1)` | ONNX-semantics host top-k (ties to the lower index), layouts, and the set-based metrics of a device selection (`shared`, `strong_total` / `strong_kept` / `strong_overlap` / `strong_missed`) | +| `topk.topk_with_values(scores_tile, k)` | the bf16 `ttnn.topk` composite (values + indices, P8) | +| `transfusion_head.QueryHeadWeights(height, width, num_classes, num_proposals, class_table, bev_pos, self_posembed, cross_posembed, self_attn, cross_attn, norms, ffn, heads)` | model-agnostic host record (`MHAWeights`, `PosEmbedMLP`, `PredictionHead`; `[in, out]` layouts): `.validate()`, `.query_pos_table()` / `.key_pos_table()` (the position MLPs on `bev_pos`, float64), `.merged_heads()` | +| `transfusion_head.TransFusionQueryHead(weights, *, policy=None, prefix="head", attn_fp32_acc=False)` | `__call__(lidar_feat, heat_nms, indices=None, taps=None) -> {"heads", "query_heat", "indices"}`; pieces `init_queries`, `keys`, `decoder`, `predict`, `query_heat`; `unpack_heads(raw) -> {name: (c, K)}`; `prepare(device)`, `release()`, `describe()` | +| `transfusion_head.QueryHeadOracle(weights)` | the literal head in torch fp32 (posembed MLPs on `bev_pos[pos]`, unpadded attention, six heads) from NCHW maps and given indices: the device tests' reference | +| `transfusion_head.assemble_transfusion(heads, query_heat, indices, bev_pos, *, num_classes, num_proposals, cells)` | TF's (mmdeploy-patched) outputs on the host in float32: `cls_score0 = query_heat * sigmoid(heatmap)`, `bbox_pred0 = [center + query_pos, height, dim, vel]`, `dir_cls_pred0 = rot` | + +Device form of the head (every rewrite exact in real arithmetic; the oracle runs the literal forms): queries +`lidar_feat[pos] + class_table[cls]` (bf16 `ttnn.embedding` row gathers, uint32 indices from the exact decode); the +query position embedding is a function of `pos` only, so it is a constant **QPE table** `self_posembed(bev_pos)` +gathered by `pos` (`query_pos` itself, `k + 0.5` up to 191.5, never passes through bf16: S:transfusion:241); keys +`lidar_feat + KPE` with the constant `KPE = cross_posembed(bev_pos)`; head dim 16 -> 32 in the projection weights +(C20 `pad_head_columns` / `pad_head_rows`, exact), `scale` from the weights, C20 `sdpa` (non-causal, chunk table); +self-attention on the logical K queries with no mask (Q / K / V from one fused projection); LayerNorm with the +model's epsilon (`ttnn.layer_norm(o, residual_input_tensor=x, epsilon=...)`); merged heads (`relu(x @ [Wh_i]) @ +blockdiag(Wo_i)`, fp32 out). One decoder layer only (a second layer would re-embed the predicted centres: no constant +table). Precision `HiFi4+fp32:w=fp32` (`default_policy()`; per module `.self_attn` / `.cross_attn` / `.ffn` / +`.heads` / `.norm`). + +Device results (`tests/device/test_transfusion_head_device.py`, 13 cases through `TraceRunner`: eager x2, strict +capture, replay on a new input set, replay == eager bit for bit; watcher bring-up of the small shapes clean; +`logs/transfusion/m1/j2_ttaw_{small_watcher,full}.log`, results `logs/ttaw/c21_c22_c26_device_results.json`, gates +frozen in `test_transfusion_head_device.gates.json`): the masked local max is **bit-exact** against each model's +literal form at TF 192x192x5, BF 180x180x5 and PT 256x256x7 (plateaus and bright borders: ties everywhere); the +top-512 of TF 184,320 / BF 162,000 / PT 458,752 values has exactly the host's value multiset, unique in-range +indices in descending order, and an exact decode (also exhaustively over all 184,320 TF indices); surviving `-inf` +scores give 0xFFFFFFFF (22 of 32 in the canary) and the decode clamps them in range; the query head with random +weights, teacher-forced proposals, vs the literal fp32 oracle: TF (36,864 keys) PCC >= 0.9970 (queries 0.999998, +decoder layers >= 0.9996, heads >= 0.9970), BF (32,400 keys, the (i, j) `bev_pos`) >= 0.9922, query heat exact. +The TransFusion bundle runs the same modules with the deployed weights (`bundles/transfusion-p150/PORT_LOG.md`). +The selection chain at TF size (sigmoid, local max, class-major row, top-512, decode, one row gather) replays in +about 1.1 ms (`logs/transfusion/m1/exp1_full_*.log`: 2.0-2.4 ms for two chains). Host tests: +`tests/host/test_transfusion_head_host.py` (29) with `tests/host/fake_ttnn_query.py` (`topk_large_indices` with +HIGHEST-index ties, bf16-only `max_pool2d`, `layer_norm` with ttnn's 1e-12 default epsilon, `eq` / `ge` / +`subtract` / `addcmul`, an `embedding` that also records computed indices inside a capture). + +--------------------------------------------------------------------------------------------------------------------- + +## 22. Pillar feature net and input staging (C24 companion, `ttaw.models.pillars`) + +The PillarFeatureNet of the pillar detectors (CenterPoint, PointPainting, TransFusion) on the device and the host +staging of its two inputs. Promoted by the PointPainting port (ttaw 0.19.0) from the CenterPoint bundle's +`tt/pfn.py` + `FrameStaging` (device-verified there: PFN PCC 0.99997) with the TransFusion bundle's generalisation +(any hidden / output width; pillars of K < 32 points). The CenterPoint and TransFusion bundles keep their own copies +until they re-vendor and switch (their PORT_LOGs note it). + +```python +from .ttaw.models.pillars import FrameStaging, PfnPlan, TtPillarFeatureNet, features_shape, pack_inputs +from .ttaw.ops.gather import scatter_rows + +plan = PfnPlan.from_weights(w0, b0, w1, b1) # BN-folded [in, out] matrices: (F, H), (2H, O) +pfn = TtPillarFeatureNet(plan, num_pillars=40000, precision="HiFi4+fp32:w=fp32:a=fp32") # a= : hidden h / z dtype +runner.add_input("features", shape=features_shape(40000), dtype="bfloat16") # ROW_MAJOR, 1 KB / pillar +runner.add_input("canvas_index", init=np.full((1, H * W), 40000, np.uint32), dtype="uint32") + +def forward(ctx): # a TraceRunner variant + rows = pfn(ctx["features"]) # [1, 1, P, O] bf16 ROW_MAJOR + canvas, table = scatter_rows(rows, ctx["canvas_index"], sentinel=40000) # C19: NHWC [1, 1, H*W, O] TILE + ... +staging = FrameStaging(40000, H * W) # persistent host tensors, written in place per frame +out = runner("default", inputs=staging.write(features, canvas_index, num_pillars)) +``` + +| name | meaning | +|---|---| +| `PfnPlan.from_weights(w0, b0, w1, b1, *, in_padded=16)` | the device matrices of the identity-folded split PFN: `W0 (16, H_pad)`, `Wz = [I_H \| W1a \| 0] (H_pad, Z)`, `Wy = [W1b ; I_O ; 0] (Z, O)` (`H_pad` = H rounded up to 32, `Z` = H + O rounded up to 32); F <= 16, O % 32 == 0 (the C19 gather table). `.forward_numpy(features, *, hidden_round=None)`: its float32 host emulation (`hidden_round` models the hidden dtype) | +| `pfn_reference_numpy(features, w0, b0, w1, b1)` | the literal encoder in float64 (concat form; max over the given slots, padded slots included): the oracle | +| `TtPillarFeatureNet(plan, *, num_pillars, precision="HiFi4+fp32:w=fp32", name="pfn")` | `[1, 1, P, 32*16]` bf16 ROW_MAJOR -> `[1, 1, P, O]` bf16 ROW_MAJOR rows: reshape to `[1, 1, P*32, 16]`, tilize, `linear` W0 + ReLU, `linear` Wz, `max` over the 32 slots (dim -2), `linear` Wy + b1 + ReLU, untilize. HiFi4 required (the identity blocks); `Precision.activations` = the dtype of the hidden `h` / `z` (the output rows are always bf16); `.release()`, `.describe()` | +| `features_shape(P)` -> `(1, 1, P, 512)`; `pack_features(features (P_kept, K, F), P, *, out=None)` | the device input: one 1 KB row per pillar (32 slots x 16 features, zero-padded), rows past `P_kept` zero; **K < 32 slots: slots K..31 repeat slot 0** (TransFusion's 20-point pillars: the slot maxima are unchanged, zero rows would add `relu(b0)`) | +| `pack_inputs(features, canvas_index, num_pillars, *, capacity)` | `{"features", "canvas_index"}` as numpy (tests and tools); the index range-checked (C19 `check_index`, sentinel = `capacity`) | +| `FrameStaging(num_pillars, num_cells)` | persistent bf16 `features` / uint32 `canvas_index` host tensors (`tensors.HostStaging`); `.write(features, canvas_index, num_pillars=None)` converts only the kept pillars (torch fp32 -> bf16 RNE, K < 32 slot rule), zeroes the rows the previous frame used beyond them, copies the checked index and returns the `TraceRunner` inputs; `.zero_copy` (False: falls back to `pack_features` + `HostStaging.write`, same values) | + +Device form (exact rewrites, also in floating point): one pillar = one 32-row tile, so a slot max is a tile-column +reduction; `relu(. + c)` is monotonic, so the per-pillar term `c = m @ W1b + b1` is added after the max (no slot +broadcast); `h` and the max ride through the matmuls in identity blocks (x * 1.0 is exact with HiFi4). Every pillar +row is computed, also past the frame's pillar count: the scatter index never points there, so no count reaches the +device. Precision: the bf16 rounding of the hidden `h` / `z` is the PFN's largest error term (PointPainting's CPU +emulation on its golden frames: output rel. L2 0.0045 with bf16 hidden tensors, 0.0020 with fp32 hidden tensors and +TF32 weights, 0.0017 for the bf16 output rounding alone), hence the `a=fp32` option; a model with large-magnitude input +columns (absolute x / y up to 121.6 m) can rewrite its first layer around the pillar centre on the host (PointPainting +`tt/pfn_input.py`, a promotion candidate) and feed this module unchanged. + +Device results (`tests/device/test_pillars_device.py`, TraceRunner: eager x2, strict capture, replay on input set B, +replay == eager bit for bit; the 512-pillar cases first under `TT_METAL_WATCHER=2`, clean; random weights, bf16 +inputs, vs the literal float64 encoder on the same inputs; `logs/pointpainting/m1/j1_pillars_*.log`, results +`logs/ttaw/pillars_device_results.json`): CenterPoint widths (9 -> 16, 32 -> 32) PCC 0.999996 / rel. L2 0.0025; +PointPainting (16 -> 16, 32 -> 32) bf16 hidden 0.999996 / 0.0025, fp32 hidden 0.9999975 / 0.0024; TransFusion (11 -> +32, 64 -> 64, 20-point pillars) bf16 hidden 0.999995 / 0.0027, fp32 hidden 0.999997 / 0.0025; the real capacity +(40,000 pillars, 28,137 kept, fp32 hidden) 0.9999975; `FrameStaging`'s zero-copy path equals `pack_features` + bf16 +RNE for a large frame, a smaller one (its stale rows zeroed) and 20-point pillars. The PointPainting bundle runs it +with its real weights (PFN PCC 0.99999 on two golden frames, `bundles/pointpainting-p150/PORT_LOG.md`). Host tests: +`tests/host/test_pillars_host.py` (12; fake ttnn: the plan exact against the literal encoder for the CP / PP / TF +widths and the 20-slot rule, packing, the module through `TraceRunner` replay == eager with bf16 and fp32 hidden +tensors, `FrameStaging`'s fallback path). + +--------------------------------------------------------------------------------------------------------------------- + +## 23. Segment reductions: K1 `segment_reduce` and the log-step fallback (`ttaw.ops.segment`) + +Built by the FRNet port (PLAN.md 1.3 row K1; owned by it) for FRNet's six `ScatterElements(max)` sites (points +sorted by frustum cell); PTv3 pooling and BEVPool (sum, optimization phase) are of the same form once the host sorts +the rows. A *segmented* tensor holds rows sorted by segment id: segment `s` is the contiguous row range +`[off[s], off[s + 1])` (CSR offsets, non-decreasing); rows past `off[-1]` are padding that nothing reads. + +```python +from .ttaw.ops import segment as seg + +off = seg.segment_offsets(seg_id_sorted, num_segments) # host CSR offsets, uint32 [S + 1] +row = seg.check_offsets(off, num_segments=S, num_rows=N_cap, length=S + 1) # [1, K] for the device +k1 = seg.SegmentReduce(seg.SegmentReduceSpec(num_rows=N_cap, channels=128, num_segments=M_cap + 1, + input_dtype="float32", input_layout="tile", + output_dtype="bfloat16")) +runner.add_input("offsets", init=row, dtype="uint32") # RT-dev: rewritten per frame +def forward(ctx): # a TraceRunner variant + table = k1(point_rows_fp32_tile, ctx["offsets"]) # [1, 1, M_cap + 1, 128] bf16 ROW_MAJOR; empty rows -> 0 + return gather_rows(ctx["pix2vox"], table, sentinel=M_cap) # C19: straight into a gather table +``` + +| name | meaning | +|---|---| +| `SegmentReduceSpec(num_rows, channels, num_segments, input_dtype="float32", input_layout="tile", output_dtype="float32", op="max", empty_value=0.0, workers_per_core=2)` | static shape of one call site (COMPILE). `num_rows % 32 == 0`, `channels % 32 == 0`; bf16 / fp32 in, TILE or ROW_MAJOR; bf16 (round to nearest even) / fp32 out; `op` `"max"` only on the device (`sum` / `mean` raise `NotImplementedError`: the data-movement RISC-Vs have no float unit, see below); `.l1_bytes_per_worker()`, `.check_l1(budget)` | +| `SegmentReduce(spec, *, name, grid=None)` | `__call__(x, offsets, *, output=None)` -> `[1, 1, num_segments, C]` ROW_MAJOR DRAM (allocated per call, or the persistent `output` of that spec): ONE `ttnn.generic_op`, eager or inside a capture. `x` `[1, 1, num_rows, C]` DRAM interleaved of the spec's dtype / layout; `offsets` uint32 ROW_MAJOR `[1, K >= num_segments + 1]` DRAM. `program(x, offsets, out)` (the `ProgramDescriptor`), `allocate_output(device)`, `output_spec()`, `describe()`. `grid` overrides the device grid (tests: one core) | +| `segment_reduce(x, offsets, *, num_segments, op="max", output_dtype=None, empty_value=0.0, output=None)` | one-shot form (spec from the tensors) | +| `logstep_segment_max(x, shift_tables, last_rows, *, layout="tile")` | the exact stock-op fallback (PLAN.md 1.3): `R` rounds `x = max(x, x[idx_r])` (untilize + `ttnn.embedding` + `ttnn.maximum`: 3 programs each), then a PADDED gather of `last_rows` from the result with a zero row appended at `R` (empty segments -> 0). bf16 in / out only (`ttnn.embedding` tables are bf16); exact for segments of up to `2^R` rows; `R` is COMPILE (bucket it per frame) | +| `segment_offsets(seg_id, num_segments, *, num_rows=None)`, `check_offsets(offsets, *, num_segments, num_rows, length=None)` | host CSR offsets from sorted ids (ids `>= num_segments` = trailing padding); validation (starts at 0, non-decreasing, ends `<= num_rows`) into the device `[1, length]` row | +| `segment_rounds(max_len)`, `segment_shift_tables(seg_id, rounds)`, `segment_last_rows(offsets, *, empty_row)` | the fallback's tables: `ceil(log2 L)`; `idx_r[i] = i - 2^r` in the same segment, else `i`; each segment's last row (`empty_row` = the row count: the appended zero row) | +| `segment_reduce_numpy(x, offsets, num_segments=None, *, op="max"\|"min"\|"sum"\|"mean", empty_value=0.0)`, `logstep_segment_max_numpy(x, shift_tables, last_rows)` | oracles (max / min exact in the input dtype; sum / mean in float64) | +| `worker_segments(offsets, num_segments, num_workers, *, num_rows=None)` | the kernel's work split, host twin (tests, diagnostics) | +| `OPS`, `DEVICE_OPS`, `KERNEL` | `("max", "min", "sum", "mean")`, `("max",)`, `"segment_reduce_dm.cpp"` | + +**Kernel** (`ops/kernels/segment_reduce_dm.cpp`, one source for both data-movement RISC-Vs of every core). Exact by +construction: the maximum is taken on integer keys of the float bit patterns (`key = bits ^ ((bits >> 31) & +0x7fffffff)`: signed order == float order, -0 < +0; NaN unsupported), so bf16 / fp32 results are bit-exact, and an +fp32 input with a bf16 output equals rounding first (RNE is monotonic). Work split without per-core runtime args +(probe P3 rule; common args = the three buffer addresses): worker `w` (= core index x 2 + RISC-V) owns the segments +whose cost `off[s] + s` (rows + one output row) lies in `[w T / W, (w + 1) T / W)`, found on the device by two binary +searches over the offsets (64-byte DRAM probes), so nothing in the host tables depends on the grid and ETH (12x10) +and WORKER (11x10) give identical outputs. Each worker streams its contiguous rows in units of 32 (one tile-row in +TILE mode; double-buffered NOC reads), reduces into an L1 accumulator and writes one output row per segment through +a ring of 8 staging rows; empty segments get `empty_value` (FRNet's frustum2pixel zero row comes out of the kernel). +Offsets are clamped to `[0, num_rows]` and to non-decreasing order, so a corrupt table cannot make it read outside +`x`. L1 per worker: 2 units + 1 KiB offsets window + 128 B probe + C x 4 B accumulator + 8 output rows (FRNet's +widest site, 256 fp32 channels: 75 KiB). Hang protocol (PLAN.md 4.4): no CB push / pop (CBs are plain scratch), no +multicast, no semaphores; DRAM reads keep the source's 64-byte alignment offset in L1 (Blackhole +`NOC_DRAM_READ_ALIGNMENT_BYTES`), every read is waited on (the barrier also invalidates the Blackhole L1 cache) +before use, staging rows are reused after `noc_async_writes_flushed`, and the kernel ends with both barriers. + +Why max only: the data-movement RISC-Vs (rv32im + Zba / Zbb, no F extension) compare integers natively (Zbb `max`) +but would emulate float adds in software; a `sum` / `mean` K1 needs the compute engine (the optimization-phase +consumers, PTv3 / BEVPool, add it with their device tests). + +Device results (`tests/device/test_segment_reduce_device.py`, the FRNet port's jobs; results +`logs/ttaw/k1_segment_reduce_device_results.json`): tiny bring-up under `TT_METAL_WATCHER=2` clean (64 x 32, 6 +segments incl. empty / 1-row / tile-row-crossing ones; one core with 1 and 2 workers; eager x2 and a 2-call trace +replayed on new data, bit-exact); FRNet's real sites through `TraceRunner` with the offsets rewritten per frame (10 +replays x 5 rewrites), every output bit-exact vs the oracle and replay == eager, 240 workers (12x10 ETH grid, 2 per +core): OT128 encoder site 160,000 x 256 fp32 -> 60,000 fp32 rows 5.52 ms per replay, OT128 point sites 160,000 x 128 +fp32 -> 60,001 bf16 rows 2.89 ms, QT128-like dense segments (6,255 segments, one of 500 rows) 2.22 ms, 2,048-row cases +0.04-0.08 ms; the log-step fallback equals K1 bit for bit (4 rounds on 4,096 rows; 7 rounds on 120,000 rows with +111-row segments) (`logs/frnet/m1a/j1_bringup_and_graph.log`, watcher log `logs/frnet/m1a/j1_tiny_watcher.log`, +`logs/ttaw/k1_segment_reduce_device_results.json`). Inside the FRNet graph (six sites per frame, OT128 sample): every +tap downstream PCC >= 0.9995 vs the fp32 reference (`bundles/frnet-p150/PORT_LOG.md`). A 2,000-launch soak +(`test_soak`) runs in the port's next job. Host tests: +`tests/host/test_segment_host.py` with `tests/host/fake_ttnn_segment.py` (program-descriptor records, a +`generic_op` that emulates the kernel from its compile-time args and checks the P3 rules and the split, `maximum`, +`hardswish`, `softmax`, `addcmul`). diff --git a/code/tt_frnet/ttaw/VENDORED.json b/code/tt_frnet/ttaw/VENDORED.json new file mode 100644 index 0000000000000000000000000000000000000000..b24d7c54786073589259afbfa183f7d479e4d7a7 --- /dev/null +++ b/code/tt_frnet/ttaw/VENDORED.json @@ -0,0 +1,55 @@ +{ + "package": "ttaw", + "version": "0.20.0", + "source": "common/ttaw", + "source_commit": "89dec49a7e66836ee745660e1cfedf228a4c070b", + "source_ref": "HEAD", + "source_tree": "commit", + "source_dirty": false, + "vendored_at": "2026-10-08T06:51:40+00:00", + "files": { + "API.md": "643842c2199e6079269f7ec5f3ef0fa9eeebcaab8289b9c4a6b1c3a3b090d9e2", + "__init__.py": "fb754d83e40d05651a667c469e1c42ad1d5d0b3f779bf9710fae16c88b1bbcfe", + "api_base.py": "189ee4215f937b812afee40d076bb948861a489e0b9e42a102bba311e0536179", + "decode.py": "f8aa2efa7ec664da7b8af94bed2d5771552b08b1a6d0bf97cbafcd18ea805c90", + "device.py": "06357ba55c9f7a4254982fb1effc15b9cb85d3158c33afc6d6c5447071d918ce", + "geometry.py": "087dcf7f43ef187fb051a504fe15365985c2c543eb3e05554a9017bcef90fcd5", + "golden.py": "d675431f052bfd01438aa79d3a5e281380a498b1f762e70b0ae0f3b4f194c7ad", + "image.py": "0a755647744d15f84077fe396d2514ba996a3ca056d172327874df2f21b52d76", + "image_area.py": "8d9e171167cf3fe2b59505ae9e5661cf1c032e21f282aa3e78361b4140e3d98c", + "image_linear.py": "debd3ade236a69f74859ae91ac9013c4757198ac29508f06f85d195c38e49d31", + "image_triangle.py": "b3f2f08a3c2512c4df28b987d8002358ef5b7556bc1ad29f5b91c956b7d154c2", + "io.py": "c2ac25cb589351a47b26688759d198204b5f0c25fd92f2a5c1c6428cea5a149e", + "knobs.py": "888783782c8f2e230538f416c360791f5cf8b21e5330414596fda7898308a279", + "metrics.py": "6d3a6b36d287aba05537b57d3c7560fd93537063e6b5c592492ed7d6e410e9e0", + "models/__init__.py": "2d2783c72c0382a0ffa34f71447fe75279fd357099b663c506af76d6bc2c2ec5", + "models/centerhead.py": "0f404b39b5f063e8d29e546887e03c3681630d1f4d98fffab693c501fc92cff6", + "models/pillars.py": "a7357e3c43365608fe46bbb6a3cd7cef86b2afaa751a28c0dfa93b617334dc1c", + "models/resnet.py": "51183499e25f8146c68a417d2ac1a176c8f941ca224b58609c34bb356fe500b0", + "models/second.py": "8e98236f9903c8ce52b79ccc18869b5a8cccae8401baa9f2294c4dd2d7b2111d", + "models/transfusion_head.py": "e040f86979278aa61b420a6d077a854482967e73cfa419926f6df721eb3c812a", + "nms.py": "c3c9e63b201c68211fcb6a9a2844e63f7233212354e9b61eb869d9ae12097116", + "ops/__init__.py": "6d6aa15abd7f3cded433e083988a6f420f351e2bf0d7765d9e8c9c05d26fec64", + "ops/attention.py": "73e170cbd483590de08867881a282816b28d359bde91ff70de93dec6c786e404", + "ops/conv.py": "d8c5a31ef710aeb9568e638b721d4255f63743a1b88f822e6f411dee4e158532", + "ops/deform.py": "ac10088af29bc83fd66fab02712c836ffa5795ec4f7304846722ca54181897cd", + "ops/gather.py": "e2934f898b49088c387e8e89db31681257cdba03204efbee4160e055a38f0280", + "ops/heatmap.py": "05f13986f0ed7c59e1d80877ab5ca8cca7528e30c60e43dc489086f58732f596", + "ops/kernels/segment_reduce_dm.cpp": "a313c69865990decdccebcbd0ef91253b0a0f1b4f97370212bdbd89a11b0305f", + "ops/segment.py": "db4ea1a73e24868956240474a473f95ac94bd00dd87ec8ba6c2fb5cbce828457", + "ops/topk.py": "a87817a35afb3448eda140376d8170d69ddc9acf74b3717c62a2987fc4bcddf4", + "ops/upsample.py": "bbea4ef32298277393c73d427fa00aa2fd4d68d478c770de731010ab4c9e34dd", + "outputs.py": "e345695de6a888ee617afbb1a79bfd4251e768a31446fadf7d2ca6aba330d6ca", + "pointcloud.py": "fba6368408de16d258946b2814e7e1e286495c870750dafe52c92f7eec985758", + "precision.py": "8a01f1d048c3d317cc3caa095421e9695c2f6b7fdad2623a8d0c26364bdeaf3d", + "profiling.py": "c65aefbf0c2df111246f927f20220619686c51ad3b959ddb030655e97d8461f4", + "server/__init__.py": "3bbfe7beb80bf1b7a983c264e1ae3f08e162da17289b397ff0c4f425bb4e689a", + "server/app.py": "23cbf230d7393f743527fda1706f180c90fb8c6c2089fbf6559bd29d10d951dc", + "server/client.py": "e78f07ebdb132cb06c069259903dc7f971044f75f4918aacd184e5a59d7ca987", + "server/smoke.py": "acc547a03bcfe719ca1b5fba4e551d0dadecf77505f8e39848747b32c082b33b", + "tensors.py": "8abc00a10f5d0524134b49225a6ee4aba1104bfc19fc875eb777c3db73da5fbc", + "trace.py": "56215f3cdcad85c4d7063ff6e4a6a6acc5d4f90c2d855b2524898aaefd4d0ad6", + "voxelize.py": "88183715a10be92d489d35acf88defa4eaf47763fcb0356a71d64d97acc859b6", + "weights.py": "c8857817b7273e48278bb8194cece7fc38d99722119caacf8eb8fb8689648fad" + } +} diff --git a/code/tt_frnet/ttaw/__init__.py b/code/tt_frnet/ttaw/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c47c91d101e964a2a347823ed192d71a7255a9ef --- /dev/null +++ b/code/tt_frnet/ttaw/__init__.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: Apache-2.0 +"""ttaw: shared infrastructure of the Autoware ports to one Tenstorrent Blackhole p150 (ttnn / tt-metalium). + +The package is the source of truth under ``common/ttaw`` and is vendored into every bundle as the sub-package +``code//ttaw`` by ``common/tools/vendor.py``. Rules that keep one tree valid in both places: + +- relative imports only (``from .device import open_device``), never ``import ttaw``; +- importing any module has no side effects: no ``import ttnn`` / ``torch`` at module level, no device, no network, + no environment reads (ttnn and torch are imported inside the functions that need them); +- optimization knobs are read once at model build (:mod:`.knobs`) and each has an env A/B switch; +- custom kernel ``.cpp`` files are package data, located with :func:`.ops.kernel_path`. + +Modules (see ``API.md`` for the full guide): + +================== ====== ============================================================================= +module id what it provides +================== ====== ============================================================================= +``device`` C01 ``open_device`` (ETH dispatch default, ``auto`` patch detection), ``describe_device`` +``trace`` C02 ``TraceRunner``: persistent I/O, warm-up, capture, variants, 1CQ / 2CQ, state +``tensors`` C03 tile padding, layout / dtype helpers, fp32 islands, zero-copy host staging buffers +``weights`` C04 ``OnnxWeights``, BN folding in fp64, safetensors / ``.pth`` loading, weight cache +``precision`` C05 explicit ``compute_kernel_config`` factory and per-module policy tables +``knobs`` -- env A/B knobs read once at build +``metrics`` C06 PCC variants, argmax agreement, IoU, top-K overlap, detection matching, ADE/FDE +``golden`` C06 golden ``.npz`` files, tap registry, gate registry that never loosens, reports +``profiling`` C07 stage bench (p50/p99), Tracy signposts, ops-CSV summarizer, AICLK sampler +``io`` C08 point cloud / image / calibration decoding, output encoders +``outputs`` C08 ``Detections3D`` ``Detections2D`` ``Segmentation3D`` ``Mask2D`` ``Trajectory`` +``api_base`` C08 ``ModelBase`` (from_pretrained, weights resolution, warm-up, lock, info, close) +``server`` C08 FastAPI app factory (``server.app``); stdlib client, smoke and reference checks + (``server.client``, ``server.smoke``) +``image`` C14 bit-exact Autoware image pre-processing from cached LUTs (YOLOX letterbox, BEVDet crop) +``image_area`` C14 METEOR preset: OpenCV ``INTER_AREA`` down-scaling of uint8 frames, bit-exact +``image_linear`` C14 SceneSeg preset: OpenCV ``INTER_LINEAR`` / ``INTER_NEAREST`` resizes of uint8 frames and the + VisionPilot normalisation (BGR / RGB), bit-exact +``image_triangle`` C14 StreamPETR preset: Autoware's anti-aliased triangle resize + crop + normalise kernel, + bit-exact (normalisation presets autoware_main / autoware_0.52 / awml_training) +``geometry`` C10 rigid transforms (float64), Autoware's float32 sweep transform, yaw conventions +``pointcloud`` C11 Autoware multi-sweep densification state machine (per stream), hygiene masks +``voxelize`` C12 deterministic pillars, 9/10/11-feature decoration, canvas scatter / gather index +``nms`` C15 circle NMS, perception_utils IoU-BEV NMS, area class remapper +``decode`` C16 CenterHead dense decode (and at given cells), Autoware DetectedObject mapping +``ops`` -- kernel package-data helpers (custom ``generic_op`` kernels live in ``ops/kernels``) +``ops.conv`` C17 conv2d builders (weights prepared once, fused activations, DRAM slicing), K-split, + ConvTranspose k == s, feature-map glue +``ops.upsample`` C18 nearest up-sampling, ``F.interpolate`` matrices, separable device resize +``ops.attention`` C20 SDPA wrapper (non-causal, explicit scale, chunk table, aligned masked keys), heads +``ops.gather`` C19 gather-form scatter (zero sentinel row + TILE / PADDED ``ttnn.embedding``), row gathers +``ops.topk`` C21 top-k proposal selection (class-major ``topk_large_indices`` row, exact decode, set metrics) +``ops.heatmap`` C22 sigmoid + 3x3 local max of TF / BF / PT heatmaps (one exact masked chain) +``ops.deform`` C23 grid_sample helpers: fp32 affine / resize grids (exact identity), P9 rules, kernel emulation +``ops.segment`` K1 per-segment max over rows sorted by segment (``generic_op`` kernel, exact, no + per-core args), the log-step stock-op fallback, CSR / shift tables, oracles +``models`` C24 C25 device sub-networks: ``models.second`` (SECOND + SECONDFPN), ``models.pillars`` + C27 C26 (PillarFeatureNet + its input staging), ``models.centerhead`` (CenterPoint dense + head: K-split shared conv, merged heads), ``models.resnet`` (ResNet + bottleneck / basic-block builders, cameras as batch), ``models.transfusion_head`` + (TransFusion query head: selection, query init, decoder layer, merged heads) +================== ====== ============================================================================= +""" + +__version__ = "0.20.0" + +__all__ = [ + "__version__", + "api_base", + "decode", + "device", + "geometry", + "golden", + "image", + "image_area", + "image_linear", + "image_triangle", + "io", + "knobs", + "metrics", + "models", + "nms", + "ops", + "outputs", + "pointcloud", + "precision", + "profiling", + "server", + "tensors", + "trace", + "voxelize", + "weights", +] + + +def __getattr__(name: str): + """Load submodules on first attribute access (``ttaw.trace``), keeping ``import ttaw`` free of side effects.""" + if name in __all__ and name != "__version__": + import importlib + + return importlib.import_module(f".{name}", __name__) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(globals()) | set(__all__)) diff --git a/code/tt_frnet/ttaw/api_base.py b/code/tt_frnet/ttaw/api_base.py new file mode 100644 index 0000000000000000000000000000000000000000..abee4fc03eec88d852e5b832f2c6b6ae8d41715b --- /dev/null +++ b/code/tt_frnet/ttaw/api_base.py @@ -0,0 +1,383 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C08 ``ModelBase``: the Python API contract shared by every bundle (BUNDLE_CONVENTIONS.md section 8). + +A bundle's ``api.py`` subclasses :class:`ModelBase`, sets the identity class attributes and implements six hooks:: + + class CenterPoint(ModelBase): + MODEL_NAME = "centerpoint-p150" + ENV_PREFIX = "CENTERPOINT" + DEFAULT_REPO = "AutowareFoundation/lidar_centerpoint" + DEFAULT_TAG, DEFAULT_REVISION = "v4.1", "494c8171def40bd36cc2feb323e0a5acbfab132b" + ALLOW_PATTERNS = ["base/*", "tiny/*"] + VARIANTS, DEFAULT_VARIANT = ("base", "tiny"), "base" + LABELS = ("CAR", "TRUCK", "BUS", "BICYCLE", "PEDESTRIAN") + RUNTIME_PARAMS = {"score_threshold": (float, 0.0, 1.0, 0.35)} + DEVICE_DEFAULTS = {"num_command_queues": 1, "trace_region_size": 64 << 20} + + def _build(self): ... # weights -> device tensors, TraceRunner + variants (no capture) + def _warm_one(self, v): ... # capture the variant's traces (TraceRunner.capture) + def _prepare(self, **kw): ... # host pre-processing -> host buffers + def _forward(self, prep): ... # upload + replay + read (under the model lock) + def _postprocess(self, raw, prep, params): ... # -> an ttaw.outputs object + def _release(self): ... # TraceRunner.release() + +Contract: ``from_pretrained`` resolves the pinned weights *before* opening the chip (a Hub problem must not claim +the device), opens it (ETH dispatch, 12x10), builds, and warms every default variant, so the first call is as fast +as the next. Calls are serialized by a re-entrant lock; ``close()`` is idempotent and also runs at interpreter exit. +Importing this module has no side effects (no ttnn, no torch, no network). +""" +from __future__ import annotations + +import atexit +import logging +import math +import os +import threading +import time +import weakref +from pathlib import Path +from typing import Any, ClassVar, Dict, List, Mapping, Optional, Sequence, Tuple + +import numpy as np + +from . import io as tio +from .device import DeviceConfig, close_device, describe_device + +__all__ = ["ModelBase", "resolve_weights"] + +log = logging.getLogger(__name__) + +# the input keyword arguments of ModelBase.__call__ (never runtime params) +_CALL_INPUTS = ("points", "sweeps", "images", "calibration", "stream", "inputs") + + +def resolve_weights(model_id: str, *, revision: Optional[str] = None, allow_patterns: Optional[Sequence[str]] = None, + weights_dir: Optional[str] = None, env_var: Optional[str] = None) -> Path: + """Local directory holding the weights files. + + Order: ``weights_dir`` > ``$`` > ``model_id`` if it is a local directory > the Hugging Face snapshot of + ``model_id`` at ``revision`` restricted to ``allow_patterns``. If the Hub is unreachable the cached snapshot is + used (``local_files_only=True``). Never needs a token for public repos; never uploads.""" + for cand in (weights_dir, os.environ.get(env_var) if env_var else None): + if cand: + p = Path(cand).expanduser() + if not p.is_dir(): + raise FileNotFoundError(f"weights directory {p} does not exist") + return p + if Path(model_id).expanduser().is_dir(): + return Path(model_id).expanduser() + from huggingface_hub import snapshot_download + + patterns = list(allow_patterns) if allow_patterns else None + try: + return Path(snapshot_download(model_id, revision=revision, allow_patterns=patterns)) + except Exception as e: # noqa: BLE001 -- offline or rate limited: fall back to the local cache + log.warning("Hub not reachable for %s@%s (%s: %s); trying the local HF cache", model_id, revision, + type(e).__name__, e) + return Path(snapshot_download(model_id, revision=revision, allow_patterns=patterns, local_files_only=True)) + + +_LIVE: "weakref.WeakSet" = weakref.WeakSet() + + +@atexit.register +def _close_all() -> None: + """A model that was not closed is closed when Python exits, so the chip is left clean.""" + for model in list(_LIVE): + try: + model.close() + except Exception: # noqa: BLE001 -- best effort at exit + pass + + +class ModelBase: + """Base class of every bundle's model class. Create instances with :meth:`from_pretrained`.""" + + # ---- identity (override in the bundle) --------------------------------------------------------------------- + MODEL_NAME: ClassVar[str] = "model" + ENV_PREFIX: ClassVar[str] = "MODEL" # _WEIGHTS_DIR, _DISPATCH, _NUM_CQS, ... + DEFAULT_REPO: ClassVar[str] = "" + DEFAULT_TAG: ClassVar[Optional[str]] = None + DEFAULT_REVISION: ClassVar[Optional[str]] = None # the commit DEFAULT_TAG points to (tags can move) + ALLOW_PATTERNS: ClassVar[Optional[Sequence[str]]] = None + WEIGHTS_LICENSE: ClassVar[str] = "Apache-2.0 (AutowareFoundation model card)" + VARIANTS: ClassVar[Sequence[str]] = ("default",) + DEFAULT_VARIANT: ClassVar[str] = "default" + INPUT_KIND: ClassVar[str] = "lidar" # lidar | camera | multicam | lidar+multicam | planner + CAMERA_ORDER: ClassVar[Sequence[str]] = () + REQUIRE_CALIBRATION: ClassVar[Optional[bool]] = None # None: True for multicam / lidar+multicam + POINT_FIELDS: ClassVar[Sequence[str]] = tio.DEFAULT_POINT_FIELDS + LABELS: ClassVar[Sequence[str]] = () + # name -> (type, min, max, default): host-side knobs only (BUNDLE_CONVENTIONS.md section 9); min / max may be + # None for an open side + RUNTIME_PARAMS: ClassVar[Mapping[str, Tuple[type, Any, Any, Any]]] = {} + # model-specific input keyword arguments passed to _prepare (not runtime params), e.g. PointPainting ("rois",): + # the server's ServerSpec.decode_extra adds them to the call kwargs + EXTRA_INPUTS: ClassVar[Sequence[str]] = () + # planner-style named inputs: name -> (shape with None for free dims, dtype); when set, ``inputs=`` is decoded + # and checked with io.load_named_arrays on every call (API and server alike) + INPUT_SCHEMA: ClassVar[Optional[Mapping[str, Tuple[Sequence[Optional[int]], Any]]]] = None + # defaults for ttaw.device.DeviceConfig; _* environment variables override them + DEVICE_DEFAULTS: ClassVar[Mapping[str, Any]] = {} + + def __init__(self, *_args: Any, **_kwargs: Any): + raise TypeError(f"use {type(self).__name__}.from_pretrained(...)") + + # ---- weights / device ------------------------------------------------------------------------------------ + @classmethod + def requires_calibration(cls) -> bool: + if cls.REQUIRE_CALIBRATION is not None: + return bool(cls.REQUIRE_CALIBRATION) + return cls.INPUT_KIND in ("multicam", "lidar+multicam") + + @classmethod + def resolve_weights(cls, model_id: Optional[str] = None, revision: Optional[str] = None, + weights_dir: Optional[str] = None) -> Path: + """:func:`resolve_weights` with this model's defaults (pinned revision for the default repo).""" + model_id = model_id or cls.DEFAULT_REPO + rev = revision or (cls.DEFAULT_REVISION if model_id == cls.DEFAULT_REPO else None) + return resolve_weights(model_id, revision=rev, allow_patterns=cls.ALLOW_PATTERNS, weights_dir=weights_dir, + env_var=f"{cls.ENV_PREFIX}_WEIGHTS_DIR") + + @classmethod + def device_config(cls, **overrides: Any) -> DeviceConfig: + """``DEVICE_DEFAULTS`` < ``_*`` / ``TT_DEVICE_ID`` environment < explicit non-None ``overrides`` + (``device_id``, ``dispatch``, ``num_command_queues``, ...).""" + base = DeviceConfig.from_env(cls.ENV_PREFIX, **dict(cls.DEVICE_DEFAULTS)) + values = {k: getattr(base, k) for k in base.__dataclass_fields__} + values.update({k: v for k, v in overrides.items() if v is not None}) + return DeviceConfig(**values) + + @classmethod + def from_pretrained(cls, model_id: Optional[str] = None, *, revision: Optional[str] = None, + variant: Optional[str] = None, device_id: Optional[int] = None, device: Any = None, + dispatch: Optional[str] = None, num_command_queues: Optional[int] = None, + weights_dir: Optional[str] = None, warmup_variants: Any = "default", verbose: bool = False, + **compile_params: Any) -> "ModelBase": + """Resolve weights, open the chip (unless ``device`` is given; ``close()`` then leaves it open), build the + graph and capture the traces of ``warmup_variants`` (``"default"``, ``"none"`` or a list). + + ``variant``: one of ``VARIANTS`` (load-time; default ``_VARIANT`` or ``DEFAULT_VARIANT``). + ``device_id``: default ``TT_DEVICE_ID`` or 0. ``dispatch``: ``"eth"`` / ``"worker"`` / ``"auto"`` (default + ``_DISPATCH`` or eth). ``num_command_queues``: default ``_NUM_CQS`` or ``DEVICE_DEFAULTS``. + ``compile_params``: shape-defining options, validated in ``_build``.""" + variant = variant or os.environ.get(f"{cls.ENV_PREFIX}_VARIANT") or cls.DEFAULT_VARIANT + if variant not in cls.VARIANTS: + raise ValueError(f"variant={variant!r}: expected one of {list(cls.VARIANTS)}") + clash = sorted(set(cls.RUNTIME_PARAMS) & (set(_CALL_INPUTS) | set(cls.EXTRA_INPUTS))) + if clash: + raise TypeError(f"{cls.__name__}: RUNTIME_PARAMS {clash} clash with input keyword arguments") + self = cls.__new__(cls) + self._lock = threading.RLock() + self._closed = False + self._owns_device = device is None + self.device = None + self.variant = variant + self.compile_params = dict(compile_params) + self.verbose = verbose + self.warmup_ms: Dict[str, float] = {} + self.warm_variants: List[Any] = [] + self.device_info: Dict[str, Any] = {} + model_id = model_id or cls.DEFAULT_REPO + rev = revision or (cls.DEFAULT_REVISION if model_id == cls.DEFAULT_REPO else None) + t0 = time.perf_counter() + log.info("Loading weights %s@%s (variant %s)", model_id, rev, variant) + self.weights_path = cls.resolve_weights(model_id, revision, weights_dir) + self.weights = {"repo": model_id, "tag": cls.DEFAULT_TAG if model_id == cls.DEFAULT_REPO else None, + "revision": rev, "path": str(self.weights_path), "license": cls.WEIGHTS_LICENSE} + self.warmup_ms["weights"] = (time.perf_counter() - t0) * 1e3 + try: + if device is None: + cfg = cls.device_config(device_id=device_id, dispatch=dispatch, num_command_queues=num_command_queues) + log.info("Opening device %d (dispatch %s, %d CQ)", cfg.device_id, cfg.dispatch, cfg.num_command_queues) + device = cfg.open() + device_id = cfg.device_id + self.device = device + self.device_info = describe_device(device, device_id) + t1 = time.perf_counter() + self._build() + self.warmup_ms["build"] = (time.perf_counter() - t1) * 1e3 + self.warmup(warmup_variants) + except BaseException: + self.close() + raise + self.warmup_ms["total"] = (time.perf_counter() - t0) * 1e3 + _LIVE.add(self) + return self + + # ---- warm-up --------------------------------------------------------------------------------------------- + def default_warmup_variants(self) -> List[Any]: + """The trace variants captured by default (e.g. pillar-count buckets). Override per port.""" + return [{"variant": self.variant}] + + def warmup(self, variants: Any = "default") -> Dict[str, Any]: + """Compile + capture each variant (idempotent). Returns ``{"ms": ..., "variants": [...]}``.""" + self._check_open() + if variants == "default": + todo = self.default_warmup_variants() + elif variants in (None, "none"): + todo = [] + else: + todo = list(variants) + t0 = time.perf_counter() + with self._lock: + for v in todo: + if v in self.warm_variants: + continue + log.info("Warming up: capturing trace for %s", v) + self._warm_one(v) + self.warm_variants.append(v) + ms = (time.perf_counter() - t0) * 1e3 + self.warmup_ms["warmup"] = self.warmup_ms.get("warmup", 0.0) + ms + log.info("Warmup complete: %d variant(s) in %.0f ms", len(self.warm_variants), ms) + return {"ms": ms, "variants": list(self.warm_variants)} + + # ---- inference ------------------------------------------------------------------------------------------- + @classmethod + def validate_params(cls, params: Optional[Mapping[str, Any]]) -> Dict[str, Any]: + """Per-request knobs -> typed values with defaults. Unknown names, values outside ``[min, max]`` (either + bound may be None), non-booleans for ``bool``, and booleans or non-integral numbers for ``int`` raise + ``InputError``; numeric strings are accepted for numbers. ``None`` is accepted for a param whose default + is ``None`` (it means "not set"), so ``validate_params(validate_params(p)) == validate_params(p)``.""" + out = {k: spec[3] for k, spec in cls.RUNTIME_PARAMS.items()} + for k, v in (params or {}).items(): + if k not in cls.RUNTIME_PARAMS: + raise tio.InputError(f"unknown parameter {k!r}; allowed: {sorted(cls.RUNTIME_PARAMS)}") + typ, lo, hi, default = cls.RUNTIME_PARAMS[k] + if v is None and default is None: # "not set" (JSON null) = the default, so validation is idempotent: + out[k] = None # the server validates, then model(**params) validates again + continue + if typ is bool and not isinstance(v, bool): + raise tio.InputError(f"parameter {k!r} must be a boolean") + if typ in (int, float) and isinstance(v, bool): + raise tio.InputError(f"parameter {k!r} must be {typ.__name__}, not a boolean") + try: + if typ is int and not isinstance(v, int): + number = float(v) + if not number.is_integer(): + raise ValueError(v) + v = int(number) + else: + v = typ(v) + except (TypeError, ValueError, OverflowError): + raise tio.InputError(f"parameter {k!r} must be {typ.__name__}") from None + if typ is float and not math.isfinite(v): + raise tio.InputError(f"parameter {k!r} must be finite") + if (lo is not None and v < lo) or (hi is not None and v > hi): + raise tio.InputError(f"parameter {k!r}={v} is outside [{lo}, {hi}]") + out[k] = v + return out + + def __call__(self, points: Any = None, *, sweeps: Optional[Sequence] = None, images: Any = None, + calibration: Optional[Mapping] = None, stream: Optional[Mapping] = None, + inputs: Any = None, **params: Any): + """One frame (batch 1). ``points``: path / bytes / (N, C) array / PointCloud / JSON envelope. + ``sweeps``: ``[{"points", "time_lag_s", "T_current_from_sweep"}]``. ``images``: list of camera dicts or + CameraImage. ``calibration``: dict (or ``{"preset": name}``, resolved by the server). ``stream``: + ``{"id", "reset", "timestamp_s", "T_world_from_ego"}``. ``inputs``: named arrays (planner; checked + against ``INPUT_SCHEMA`` when the class sets one). Keyword arguments named in ``EXTRA_INPUTS`` go to + ``_prepare``; the others are ``RUNTIME_PARAMS``. Returns the model's output object with ``timing_ms``.""" + self._check_open() + extra = {k: params.pop(k) for k in list(params) if k in self.EXTRA_INPUTS} + p = self.validate_params(params) + if inputs is not None and self.INPUT_SCHEMA is not None: + inputs = tio.load_named_arrays(inputs, self.INPUT_SCHEMA) + with self._lock: + self._check_open() # close() may have run while this call waited for the lock + t0 = time.perf_counter() + prepared = self._prepare(points=points, sweeps=sweeps, images=images, calibration=calibration, + stream=stream, inputs=inputs, **extra) + t1 = time.perf_counter() + raw = self._forward(prepared) + t2 = time.perf_counter() + out = self._postprocess(raw, prepared, p) + t3 = time.perf_counter() + out.timing_ms.update({"preprocess": (t1 - t0) * 1e3, "device": (t2 - t1) * 1e3, + "postprocess": (t3 - t2) * 1e3, "total": (t3 - t0) * 1e3}) + return out + + predict = __call__ + + # ---- lifetime -------------------------------------------------------------------------------------------- + def extra_info(self) -> Dict[str, Any]: + """Port-specific additions to :attr:`info` (knobs, precision policy, TraceRunner.describe() ...).""" + return {} + + @property + def info(self) -> Dict[str, Any]: + cls = type(self) + info = {"model": cls.MODEL_NAME, "variant": getattr(self, "variant", None), + "weights": getattr(self, "weights", None), "device": getattr(self, "device_info", None), + "warm_variants": getattr(self, "warm_variants", []), "warmup_ms": getattr(self, "warmup_ms", {}), + "compile_params": getattr(self, "compile_params", {}), "input_kind": cls.INPUT_KIND, + "point_fields": list(cls.POINT_FIELDS), "camera_order": list(cls.CAMERA_ORDER), + "labels": list(cls.LABELS), "runtime_params": {k: v[3] for k, v in cls.RUNTIME_PARAMS.items()}, + "extra_inputs": list(cls.EXTRA_INPUTS)} + if cls.INPUT_SCHEMA is not None: + info["input_schema"] = {name: {"shape": [None if d is None else int(d) for d in shape], + "dtype": np.dtype(dtype).name} + for name, (shape, dtype) in cls.INPUT_SCHEMA.items()} + if not getattr(self, "_closed", True): + info.update(self.extra_info()) + return info + + @property + def closed(self) -> bool: + return getattr(self, "_closed", True) + + def _check_open(self) -> None: + if self.closed: + raise RuntimeError(f"{type(self).__name__} is closed") + + def close(self) -> None: + """Release traces and device tensors; close the chip if this model opened it. Idempotent.""" + if getattr(self, "_closed", True): + return + self._closed = True + _LIVE.discard(self) + with self._lock: + try: + self._release() + finally: + dev = getattr(self, "device", None) + if dev is not None and self._owns_device: + close_device(dev) + self.device = None + + def __enter__(self) -> "ModelBase": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def __repr__(self) -> str: + state = ("closed" if self.closed + else f"{self.device_info.get('dispatch')} {self.device_info.get('grid')}") + return f"<{type(self).__name__} {self.MODEL_NAME} variant={getattr(self, 'variant', '?')} {state}>" + + # ---- port-specific hooks --------------------------------------------------------------------------------- + def _build(self) -> None: + """Load weights from ``self.weights_path``, convert them to device tensors, build the ttnn graph and its + ``TraceRunner`` (inputs, params, states, variants). No capture here.""" + raise NotImplementedError("port-specific: build the ttnn graph") + + def _warm_one(self, variant: Any) -> None: + """Warm up and capture the traces of one variant (``TraceRunner.capture``).""" + raise NotImplementedError("port-specific: compile + capture one variant") + + def _prepare(self, **kwargs: Any) -> Any: + """Host pre-processing ported from the Autoware node -> host buffers. Raise ``InputError`` for client + mistakes (missing input, wrong shape).""" + raise NotImplementedError("port-specific: host pre-processing") + + def _forward(self, prepared: Any) -> Any: + """Upload into the persistent device inputs, replay the matching trace, read the outputs.""" + raise NotImplementedError("port-specific: traced device forward") + + def _postprocess(self, raw: Any, prepared: Any, params: Dict[str, Any]) -> Any: + """Host post-processing ported from Autoware -> an output object of :mod:`ttaw.outputs`.""" + raise NotImplementedError("port-specific: host post-processing") + + def _release(self) -> None: + """Release traces and persistent device tensors (``TraceRunner.release()``). Also called when + ``from_pretrained`` fails half-way, so tolerate a partially built model (``getattr(self, "runner", None)``).""" diff --git a/code/tt_frnet/ttaw/decode.py b/code/tt_frnet/ttaw/decode.py new file mode 100644 index 0000000000000000000000000000000000000000..6b11c799629da67c74eb2bd6698814a84a5315df --- /dev/null +++ b/code/tt_frnet/ttaw/decode.py @@ -0,0 +1,424 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C16 decode: the dense CenterHead decoder of Autoware's lidar_centerpoint (also PointPainting's +image_projection_based_fusion), and the mapping of decoded boxes to Autoware ``DetectedObject`` fields. + +``decode_centerhead`` is ``generateBoxes3D_kernel`` + the ``is_score_keep`` compaction +(``autoware_lidar_centerpoint/lib/postprocess/postprocess_kernel.cu:52-178,220-226``), evaluated per BEV cell in +float32 as the kernel does (S:centerpoint:281-287): + +- ``score_c = sigmoid(heatmap[c])``; ``label`` = first class with the largest score (strict ``>`` scan); +- ``x = (voxel_x * ds) * (xi + reg[0]) + min_x``, ``y`` likewise with ``yi``: **no +0.5 cell offset**; +- the distance bin is the first ``i`` with ``sqrt(x^2 + y^2) < upper[i]``; beyond the last bin the cell is dropped; +- the cell is dropped when ``score < thresholds[bin][label]`` or ``sqrt(rot0^2 + rot1^2) < yaw_norm[label]``, and a + zero score is never kept; +- box: ``z = height[0]`` (box centre), ``width = exp(dim[0])``, ``length = exp(dim[1])``, ``height = exp(dim[2])`` + (the deployed ONNX regresses (w, l, h)), ``yaw = atan2(rot[0], rot[1])``, ``vel = vel[0:2]``. + +No max-pool peak finding and no top-K: every cell over threshold is a candidate, as in Autoware (S:centerpoint:310). +Thresholds outside [0, 1) are coerced to 0 (``centerpoint_config.hpp:79-93``): :meth:`CenterHeadDecodeConfig.create` +applies that. The decoder works on head maps from any source (ONNX Runtime, the torch reference, a device readback). + +Order: rows come out in cell order (``yi * W + xi``); :func:`sort_by_score` is the ``thrust::sort`` by descending +score, made deterministic (stable: ties keep the ascending cell order; thrust's sort is not stable). + +``decode_cells`` evaluates the same decoder at given cells and drops none: it keeps each cell's gate inputs +(distance bin, score threshold, yaw norm) and outcomes, so two head-map sources (device and fp32 reference) can be +compared at the same cells and every cell one keeps and the other drops can be explained. ``head_values_at`` stores +the maps at a set of cells (compact agreement goldens). + +``to_detected_objects`` is ``box3DToDetectedObject`` (``ros_utils.cpp:29-87``): the class name maps to an +``autoware_perception_msgs/ObjectClassification`` label (``getSemanticType``), ``yaw_ros = -yaw - pi/2`` (float), +dimensions (length, width, height), ``orientation_availability`` SIGN_UNKNOWN for car-like labels, and the twist in +the object frame when ``has_twist``. + +numpy only; no side effects on import. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass, field, fields, replace +from typing import Any, Dict, List, Mapping, Optional, Sequence + +import numpy as np + +__all__ = [ + "AUTOWARE_LABELS", + "LABEL_IDS", + "SIGN_UNKNOWN", + "UNAVAILABLE", + "semantic_label", + "is_car_like", + "CenterHeadDecodeConfig", + "DecodedBoxes", + "sigmoid_f32", + "decode_centerhead", + "sort_by_score", + "head_values_at", + "DecodedCells", + "decode_cells", + "DetectedObjects", + "to_detected_objects", +] + +# autoware_perception_msgs/msg/ObjectClassification.msg label values (S:centerpoint:290) +AUTOWARE_LABELS = ("UNKNOWN", "CAR", "TRUCK", "BUS", "TRAILER", "MOTORCYCLE", "BICYCLE", "PEDESTRIAN") +LABEL_IDS = {name: i for i, name in enumerate(AUTOWARE_LABELS)} +# autoware_perception_msgs/msg/DetectedObjectKinematics.msg orientation_availability +UNAVAILABLE, SIGN_UNKNOWN = 0, 1 + +_SEMANTIC = {"CAR": "CAR", "TRUCK": "TRUCK", "BUS": "BUS", "TRAILER": "TRAILER", "BICYCLE": "BICYCLE", + "MOTORBIKE": "MOTORCYCLE", "PEDESTRIAN": "PEDESTRIAN"} + + +def semantic_label(class_name: str) -> int: + """``getSemanticType`` (``ros_utils.cpp:89-108``): network class name -> ObjectClassification label; MOTORBIKE -> + MOTORCYCLE; anything else -> UNKNOWN.""" + return LABEL_IDS[_SEMANTIC.get(class_name, "UNKNOWN")] + + +def is_car_like(label: Any) -> Any: + """``object_recognition_utils::isCarLikeVehicle``: CAR, TRUCK, BUS, TRAILER.""" + lab = np.asarray(label) + return np.isin(lab, [LABEL_IDS["CAR"], LABEL_IDS["TRUCK"], LABEL_IDS["BUS"], LABEL_IDS["TRAILER"]]) + + +def _coerce01(values: Any) -> np.ndarray: + v = np.asarray(values, dtype=np.float32) + return np.where((v >= 0.0) & (v < 1.0), v, np.float32(0.0)).astype(np.float32) + + +@dataclass(frozen=True) +class CenterHeadDecodeConfig: + """Decoder parameters. ``score_thresholds`` is (num_bins, num_classes), the node's ``[bin][class]`` layout + (``node.cpp:129-167``; read as ``thresholds[bin * C + label]``, ``postprocess_kernel.cu:114``).""" + + class_names: tuple + voxel_size_xy: tuple + range_min_xy: tuple + downsample_factor: int + distance_bin_upper_limits: tuple + score_thresholds: np.ndarray + yaw_norm_thresholds: np.ndarray + has_variance: bool = False + has_twist: bool = False + + @classmethod + def create(cls, *, class_names: Sequence[str], voxel_size_xy: Sequence[float], range_min_xy: Sequence[float], + downsample_factor: int, distance_bin_upper_limits: Sequence[float], score_thresholds: Any, + yaw_norm_thresholds: Sequence[float], has_variance: bool = False, + has_twist: bool = False) -> "CenterHeadDecodeConfig": + """Validated config with Autoware's coercions: thresholds outside [0, 1) -> 0, ascending bin limits, one + yaw-norm threshold per class. ``score_thresholds`` may be a scalar, a per-class list, or (bins, classes).""" + names = tuple(class_names) + bins = tuple(float(v) for v in distance_bin_upper_limits) + if list(bins) != sorted(bins): + raise ValueError("distance_bin_upper_limits must be ascending (centerpoint_config.hpp:67-70)") + thr = np.asarray(score_thresholds, dtype=np.float32) + if thr.ndim == 0: + thr = np.full((len(bins), len(names)), thr, np.float32) + elif thr.ndim == 1 and thr.shape[0] == len(names): + thr = np.tile(thr[None, :], (len(bins), 1)) + if thr.shape != (len(bins), len(names)): + raise ValueError(f"score_thresholds must be (bins={len(bins)}, classes={len(names)}), got {thr.shape}") + yaw = np.asarray(yaw_norm_thresholds, dtype=np.float64) + if yaw.shape != (len(names),): + raise ValueError("yaw_norm_thresholds needs one value per class (node.cpp:82-85)") + return cls(names, tuple(float(v) for v in voxel_size_xy), tuple(float(v) for v in range_min_xy), + int(downsample_factor), bins, _coerce01(thr), _coerce01(yaw), bool(has_variance), bool(has_twist)) + + def with_score_threshold(self, value: Optional[float]) -> "CenterHeadDecodeConfig": + """The same config with every class / bin threshold set to ``value`` (coerced like Autoware); None keeps it.""" + if value is None: + return self + return replace(self, score_thresholds=_coerce01(np.full_like(self.score_thresholds, value))) + + @property + def num_classes(self) -> int: + return len(self.class_names) + + +@dataclass +class DecodedBoxes: + """Decoded candidates (struct of arrays, all of length N). ``yaw`` is the network ("mmdet3d") yaw.""" + + cell: np.ndarray # int64 cell index yi * W + xi + label: np.ndarray # int32 class index into class_names + score: np.ndarray # float32 max sigmoid + x: np.ndarray # float32 box centre, model frame + y: np.ndarray + z: np.ndarray + length: np.ndarray # float32 exp(dim[1]) + width: np.ndarray # float32 exp(dim[0]) + height: np.ndarray # float32 exp(dim[2]) + yaw: np.ndarray # float32 atan2(rot0, rot1) + vel_x: np.ndarray + vel_y: np.ndarray + + def __len__(self) -> int: + return int(self.score.shape[0]) + + def take(self, idx: Any) -> "DecodedBoxes": + idx = np.asarray(idx, dtype=np.int64) + return DecodedBoxes(**{f.name: getattr(self, f.name)[idx] for f in fields(self)}) + + def to_dict(self) -> Dict[str, np.ndarray]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +def sigmoid_f32(x: Any) -> np.ndarray: + """``1.0f / (1.0f + expf(-x))`` in float32 (``postprocess_kernel.cu:46-49``).""" + v = np.asarray(x, dtype=np.float32) + with np.errstate(over="ignore"): + return (np.float32(1.0) / (np.float32(1.0) + np.exp(-v))).astype(np.float32) + + +def decode_centerhead(heads: Mapping[str, Any], cfg: CenterHeadDecodeConfig) -> DecodedBoxes: + """Decode the six head maps ``heatmap (C, H, W)``, ``reg (2, H, W)``, ``height (1, H, W)``, ``dim (3, H, W)``, + ``rot (2, H, W)``, ``vel (2, H, W)`` (a leading batch axis of 1 is accepted) -> candidates in cell order.""" + if cfg.has_variance: + raise NotImplementedError("variance heads (CenterPoint-sigma) are not supported by this decoder") + + def get(name: str, channels: int) -> np.ndarray: + a = np.asarray(heads[name], dtype=np.float32) + if a.ndim == 4: + if a.shape[0] != 1: + raise ValueError(f"{name}: batch {a.shape[0]} != 1") + a = a[0] + if a.ndim != 3 or a.shape[0] < channels: + raise ValueError(f"{name}: expected ({channels}, H, W), got {a.shape}") + return a + + hm = get("heatmap", cfg.num_classes)[:cfg.num_classes] + C, H, W = hm.shape + reg, hei, dim, rot = get("reg", 2), get("height", 1), get("dim", 3), get("rot", 2) + vel = get("vel", 2) if "vel" in heads else np.zeros((2, H, W), np.float32) + for name, a in (("reg", reg), ("height", hei), ("dim", dim), ("rot", rot), ("vel", vel)): + if a.shape[1:] != (H, W): + raise ValueError(f"{name} is {a.shape[1:]}, heatmap is {(H, W)}") + scores = sigmoid_f32(hm) + nan = np.isnan(scores) + if nan.any(): # NaN never wins the strict '>' scan; an all-NaN cell keeps label -1 and score 0 + scores = np.where(nan, np.float32(-np.inf), scores) + label = np.argmax(scores, axis=0).astype(np.int32) # first max == strict '>' from -1 + max_score = np.take_along_axis(scores, label[None].astype(np.int64), 0)[0] + found = np.isfinite(max_score) + max_score = np.where(found, max_score, np.float32(0.0)).astype(np.float32) + yi, xi = np.meshgrid(np.arange(H, dtype=np.float32), np.arange(W, dtype=np.float32), indexing="ij") + f32 = np.float32 + sx = f32(f32(cfg.voxel_size_xy[0]) * f32(cfg.downsample_factor)) + sy = f32(f32(cfg.voxel_size_xy[1]) * f32(cfg.downsample_factor)) + x = (sx * (xi + reg[0]) + f32(cfg.range_min_xy[0])).astype(np.float32) + y = (sy * (yi + reg[1]) + f32(cfg.range_min_xy[1])).astype(np.float32) + radial = np.sqrt(x * x + y * y).astype(np.float32) + bucket = np.full((H, W), -1, dtype=np.int64) + for i in range(len(cfg.distance_bin_upper_limits) - 1, -1, -1): # first upper limit above the distance + bucket = np.where(radial < f32(cfg.distance_bin_upper_limits[i]), i, bucket) + thr = cfg.score_thresholds[np.clip(bucket, 0, None), np.clip(label, 0, None)] + yaw_norm = np.sqrt(rot[0] * rot[0] + rot[1] * rot[1]).astype(np.float32) + yaw_thr = cfg.yaw_norm_thresholds[np.clip(label, 0, None)] + keep = found & (bucket >= 0) & ~(max_score < thr) & (yaw_norm >= yaw_thr) & (max_score > 0.0) + cell = np.nonzero(keep.ravel())[0] + + def at(a: np.ndarray) -> np.ndarray: + return a.reshape(-1)[cell] + + return DecodedBoxes( + cell=cell.astype(np.int64), label=at(label).astype(np.int32), score=at(max_score), x=at(x), y=at(y), + z=at(hei[0]), length=np.exp(at(dim[1])).astype(np.float32), width=np.exp(at(dim[0])).astype(np.float32), + height=np.exp(at(dim[2])).astype(np.float32), yaw=np.arctan2(at(rot[0]), at(rot[1])).astype(np.float32), + vel_x=at(vel[0]), vel_y=at(vel[1])) + + +def sort_by_score(boxes: DecodedBoxes) -> DecodedBoxes: + """Descending score, stable (ties keep cell order): a deterministic ``thrust::sort(..., score_greater())``.""" + return boxes.take(np.argsort(-boxes.score, kind="stable")) + + +# ------------------------------------------------------------------ the decoder at given cells (agreement metrics) + +def head_values_at(heads: Mapping[str, Any], cells: Any) -> Dict[str, np.ndarray]: + """The channels of every head map ``(C, H, W)`` (a leading batch axis of 1 is accepted) at the BEV cells + ``cells`` (``yi * W + xi``, any order) -> ``{name: (C, K) float32}``: an exact, compact form of the maps at the + cells that matter (agreement goldens). :func:`decode_cells` decodes it.""" + idx = np.asarray(cells, dtype=np.int64).reshape(-1) + out: Dict[str, np.ndarray] = {} + for name, a in heads.items(): + a = np.asarray(a, dtype=np.float32) + if a.ndim == 4: + if a.shape[0] != 1: + raise ValueError(f"{name}: batch {a.shape[0]} != 1") + a = a[0] + if a.ndim != 3: + raise ValueError(f"{name}: expected (C, H, W), got {a.shape}") + flat = a.reshape(a.shape[0], -1) + if idx.size and (idx.min() < 0 or idx.max() >= flat.shape[1]): + raise IndexError(f"{name}: cells outside [0, {flat.shape[1]})") + out[name] = np.ascontiguousarray(flat[:, idx]) + return out + + +@dataclass +class DecodedCells: + """:func:`decode_centerhead` evaluated at given cells with **no cell dropped** (struct of arrays, length K). The + box fields are those of :class:`DecodedBoxes`, bit-identical to ``decode_centerhead``'s row for every cell it + keeps (``keep``); the gate inputs and outcomes are kept per cell, so a cell one implementation keeps and another + drops can be explained (score threshold vs yaw-norm gate).""" + + cell: np.ndarray # int64 cell index yi * W + xi + label: np.ndarray # int32 first-max class; -1 when every class score is NaN + score: np.ndarray # float32 max sigmoid (0 when every class score is NaN) + x: np.ndarray + y: np.ndarray + z: np.ndarray + length: np.ndarray + width: np.ndarray + height: np.ndarray + yaw: np.ndarray # float32 network yaw atan2(rot0, rot1) + vel_x: np.ndarray + vel_y: np.ndarray + yaw_norm: np.ndarray # float32 sqrt(rot0^2 + rot1^2): the input of the yaw-norm gate + distance_bin: np.ndarray # int64 first bin with radial distance < its upper limit; -1 beyond the last + score_threshold: np.ndarray # float32 thresholds[bin][label] (bin and label clipped to 0, as the decoder) + yaw_norm_threshold: np.ndarray # float32 yaw-norm threshold of the label + passes_score: np.ndarray # bool: a label, inside a bin, not (score < threshold), score > 0 + passes_yaw_norm: np.ndarray # bool: yaw_norm >= the label's yaw-norm threshold + + @property + def keep(self) -> np.ndarray: + """The cells ``decode_centerhead`` keeps (both gates pass).""" + return self.passes_score & self.passes_yaw_norm + + def __len__(self) -> int: + return int(self.score.shape[0]) + + def take(self, idx: Any) -> "DecodedCells": + idx = np.asarray(idx, dtype=np.int64) + return DecodedCells(**{f.name: getattr(self, f.name)[idx] for f in fields(self)}) + + def to_boxes(self) -> DecodedBoxes: + """The :class:`DecodedBoxes` fields of every cell (gates not applied: ``.take(np.nonzero(keep)[0])`` first + for the decoder's rows).""" + return DecodedBoxes(**{f.name: getattr(self, f.name) for f in fields(DecodedBoxes)}) + + +def decode_cells(values: Mapping[str, Any], cells: Any, grid_w: int, cfg: CenterHeadDecodeConfig) -> DecodedCells: + """The decoder at the cells ``cells`` of a head grid ``grid_w`` cells wide, from their channel values ``values`` + (``{name: (C, K)}``, :func:`head_values_at`; ``vel`` optional), in the float32 arithmetic of + :func:`decode_centerhead` and with the gate outcomes instead of the drop (:class:`DecodedCells`).""" + if cfg.has_variance: + raise NotImplementedError("variance heads (CenterPoint-sigma) are not supported by this decoder") + idx = np.asarray(cells, dtype=np.int64).reshape(-1) + k = idx.size + + def get(name: str, channels: int) -> np.ndarray: + a = np.asarray(values[name], dtype=np.float32) + if a.ndim != 2 or a.shape[0] < channels or a.shape[1] != k: + raise ValueError(f"{name}: expected ({channels}, {k}), got {a.shape}") + return a + + hm = get("heatmap", cfg.num_classes)[:cfg.num_classes] + reg, hei, dim, rot = get("reg", 2), get("height", 1), get("dim", 3), get("rot", 2) + vel = get("vel", 2) if "vel" in values else np.zeros((2, k), np.float32) + scores = sigmoid_f32(hm) + nan = np.isnan(scores) + if nan.any(): + scores = np.where(nan, np.float32(-np.inf), scores) + label = np.argmax(scores, axis=0).astype(np.int32) if k else np.zeros(0, np.int32) + max_score = np.take_along_axis(scores, label[None].astype(np.int64), 0)[0] if k else np.zeros(0, np.float32) + found = np.isfinite(max_score) + max_score = np.where(found, max_score, np.float32(0.0)).astype(np.float32) + f32 = np.float32 + w = int(grid_w) + yi, xi = (idx // w).astype(np.float32), (idx % w).astype(np.float32) + sx = f32(f32(cfg.voxel_size_xy[0]) * f32(cfg.downsample_factor)) + sy = f32(f32(cfg.voxel_size_xy[1]) * f32(cfg.downsample_factor)) + x = (sx * (xi + reg[0]) + f32(cfg.range_min_xy[0])).astype(np.float32) + y = (sy * (yi + reg[1]) + f32(cfg.range_min_xy[1])).astype(np.float32) + radial = np.sqrt(x * x + y * y).astype(np.float32) + bucket = np.full(k, -1, dtype=np.int64) + for i in range(len(cfg.distance_bin_upper_limits) - 1, -1, -1): + bucket = np.where(radial < f32(cfg.distance_bin_upper_limits[i]), i, bucket) + thr = cfg.score_thresholds[np.clip(bucket, 0, None), np.clip(label, 0, None)].astype(np.float32) + yaw_norm = np.sqrt(rot[0] * rot[0] + rot[1] * rot[1]).astype(np.float32) + yaw_thr = cfg.yaw_norm_thresholds[np.clip(label, 0, None)].astype(np.float32) + return DecodedCells( + cell=idx.copy(), label=np.where(found, label, -1).astype(np.int32), score=max_score, x=x, y=y, + z=hei[0].copy(), length=np.exp(dim[1]).astype(np.float32), width=np.exp(dim[0]).astype(np.float32), + height=np.exp(dim[2]).astype(np.float32), yaw=np.arctan2(rot[0], rot[1]).astype(np.float32), + vel_x=vel[0].copy(), vel_y=vel[1].copy(), yaw_norm=yaw_norm, distance_bin=bucket, score_threshold=thr, + yaw_norm_threshold=yaw_thr, passes_score=found & (bucket >= 0) & ~(max_score < thr) & (max_score > 0.0), + passes_yaw_norm=yaw_norm >= yaw_thr) + + +@dataclass +class DetectedObjects: + """Autoware ``DetectedObject`` fields of N objects (struct of arrays). Positions and dimensions hold the float32 + values of the decoder (the message stores them as double); ``yaw`` is the ROS yaw (float32 arithmetic).""" + + label: np.ndarray # uint8 ObjectClassification label + existence_probability: np.ndarray # float32 + x: np.ndarray + y: np.ndarray + z: np.ndarray + yaw: np.ndarray + length: np.ndarray + width: np.ndarray + height: np.ndarray + orientation_availability: np.ndarray # uint8: SIGN_UNKNOWN for car-like labels, else UNAVAILABLE + twist_x: Optional[np.ndarray] = None # object-frame twist (has_twist only) + twist_y: Optional[np.ndarray] = None + source_index: Optional[np.ndarray] = None # row of the DecodedBoxes each object came from + label_before_remap: Optional[np.ndarray] = None + extra: Dict[str, np.ndarray] = field(default_factory=dict) + + def __len__(self) -> int: + return int(self.existence_probability.shape[0]) + + def take(self, idx: Any) -> "DetectedObjects": + idx = np.asarray(idx, dtype=np.int64) + out = {} + for f in fields(self): + v = getattr(self, f.name) + if f.name == "extra": + out[f.name] = {k: a[idx] for k, a in v.items()} + else: + out[f.name] = None if v is None else v[idx] + return DetectedObjects(**out) + + def boxes_xyzlwh_yaw(self) -> np.ndarray: + """(N, 7) float32 x, y, z, length, width, height, yaw (the ``ttaw.outputs.Detections3D`` layout).""" + return np.stack([self.x, self.y, self.z, self.length, self.width, self.height, self.yaw], + axis=1).astype(np.float32) + + def to_records(self, labels: Sequence[str] = AUTOWARE_LABELS) -> List[Dict[str, Any]]: + out = [] + for i in range(len(self)): + d = {"label": labels[int(self.label[i])], "existence_probability": float(self.existence_probability[i]), + "x": float(self.x[i]), "y": float(self.y[i]), "z": float(self.z[i]), "yaw": float(self.yaw[i]), + "length": float(self.length[i]), "width": float(self.width[i]), "height": float(self.height[i]), + "orientation_availability": int(self.orientation_availability[i])} + if self.label_before_remap is not None and self.label_before_remap[i] != self.label[i]: + d["label_before_remap"] = labels[int(self.label_before_remap[i])] + out.append(d) + return out + + +def to_detected_objects(boxes: DecodedBoxes, class_names: Sequence[str], *, has_twist: bool = False) -> DetectedObjects: + """``box3DToDetectedObject`` for every row (module docstring).""" + table = np.array([semantic_label(n) for n in class_names], dtype=np.uint8) + lab = np.asarray(boxes.label, dtype=np.int64) + valid = (lab >= 0) & (lab < len(class_names)) + label = np.where(valid, table[np.clip(lab, 0, len(class_names) - 1)], LABEL_IDS["UNKNOWN"]).astype(np.uint8) + yaw = (-np.asarray(boxes.yaw, dtype=np.float32).astype(np.float64) - math.pi / 2.0).astype(np.float32) + orient = np.where(is_car_like(label), SIGN_UNKNOWN, UNAVAILABLE).astype(np.uint8) + tx = ty = None + if has_twist: + c, s = np.cos(yaw), np.sin(yaw) # float32, as std::cos(float) + tx = (c * boxes.vel_x + s * boxes.vel_y).astype(np.float32) + ty = (-s * boxes.vel_x + c * boxes.vel_y).astype(np.float32) + return DetectedObjects(label=label, existence_probability=np.asarray(boxes.score, np.float32), + x=boxes.x.copy(), y=boxes.y.copy(), z=boxes.z.copy(), yaw=yaw, + length=boxes.length.copy(), width=boxes.width.copy(), height=boxes.height.copy(), + orientation_availability=orient, twist_x=tx, twist_y=ty, + source_index=np.arange(len(boxes), dtype=np.int64), label_before_remap=label.copy(), + extra={"cell": np.asarray(boxes.cell, dtype=np.int64)}) diff --git a/code/tt_frnet/ttaw/device.py b/code/tt_frnet/ttaw/device.py new file mode 100644 index 0000000000000000000000000000000000000000..426cecd8a53a00d408c421c5a6ab0568c53f4fc5 --- /dev/null +++ b/code/tt_frnet/ttaw/device.py @@ -0,0 +1,309 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C01: open the Blackhole p150 the way every published number is measured. + +Default: dispatch on the idle Ethernet cores (``ttnn.DispatchCoreConfig(ttnn.DispatchCoreType.ETH)``), which frees the +Tensix dispatch column and gives a 12x10 = 120-core compute grid on a p150b (11x10 with stock WORKER dispatch). ETH +dispatch needs ``patches/tt-metal-eth-dispatch.patch`` on tt-metal 44d6650; the patch adds the marker +``single_chip_arch_1cq_no_dispatch_s`` to ``tt_metal/impl/dispatch/topology.cpp``, which ``dispatch="auto"`` looks +for. If the ETH open fails, a ``RuntimeWarning`` is issued and WORKER dispatch is used (``allow_fallback=False`` +raises instead, for the container smoke). Never hard-code the grid: use :func:`compute_grid` / +``device.compute_with_storage_grid_size()``. + +``ttnn`` is imported inside the functions, so importing this module has no side effects. + +Example:: + + from .ttaw.device import device_session, describe_device + + with device_session(dispatch="eth", num_command_queues=2, trace_region_size=64 << 20) as dev: + print(describe_device(dev)) # {'dispatch': 'eth', 'grid': '12x10', 'cores': 120, 'num_command_queues': 2, ...} +""" +from __future__ import annotations + +import contextlib +import importlib.util +import os +import warnings +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, Iterator, Mapping, Optional, Tuple + +__all__ = [ + "DISPATCH_MODES", + "PATCH_MARKER", + "DEFAULT_L1_SMALL_SIZE", + "DEFAULT_TRACE_REGION_SIZE", + "P150_ETH_GRID", + "DeviceConfig", + "tt_metal_home", + "eth_dispatch_patch_present", + "resolve_dispatch", + "open_device", + "describe_device", + "open_info", + "close_device", + "device_session", + "compute_grid", + "core_grid", + "full_core_range_set", +] + +DISPATCH_MODES = ("eth", "worker", "auto") +PATCH_MARKER = "single_chip_arch_1cq_no_dispatch_s" +PATCH_RELPATH = Path("tt_metal", "impl", "dispatch", "topology.cpp") +DEFAULT_L1_SMALL_SIZE = 32768 # conv / pool config tensors (CNN demos use 24576-32768) +DEFAULT_TRACE_REGION_SIZE = 64 << 20 # DRAM bytes for the trace command buffers of all captured traces +P150_ETH_GRID = (12, 10) # p150b compute grid with ETH dispatch (research/DISPATCH.md) + +# id(device) -> (device, what open_device actually did: dispatch used, CQs, sizes); read by describe_device. The +# device itself is kept so that its id cannot be reused by another object while the entry exists (a device opened +# outside ttaw must never inherit a dead device's record); close_device removes the entry. +_OPEN_INFO: Dict[int, Tuple[Any, Dict[str, Any]]] = {} +# id(device) -> device for every device closed by close_device: a second close is a no-op. The reference keeps the +# closed object alive, so its id cannot be reused by a later device (ttnn device objects take no attributes). +_CLOSED: Dict[int, Any] = {} + + +def _normalize_dispatch(dispatch: Optional[str]) -> str: + mode = (dispatch or "eth").strip().lower() + if mode not in DISPATCH_MODES: + raise ValueError(f"dispatch={dispatch!r}: expected one of {DISPATCH_MODES}") + return mode + + +@dataclass(frozen=True) +class DeviceConfig: + """Everything :func:`open_device` needs, so a model can pin it, log it and A/B it from the environment.""" + + device_id: int = 0 + dispatch: str = "eth" + num_command_queues: int = 1 + l1_small_size: int = DEFAULT_L1_SMALL_SIZE + trace_region_size: int = DEFAULT_TRACE_REGION_SIZE + worker_l1_size: Optional[int] = None + allow_fallback: bool = True + + def __post_init__(self) -> None: + object.__setattr__(self, "dispatch", _normalize_dispatch(self.dispatch)) + if self.num_command_queues not in (1, 2): + raise ValueError(f"num_command_queues={self.num_command_queues}: expected 1 or 2") + if self.l1_small_size < 0 or self.trace_region_size < 0: + raise ValueError("l1_small_size and trace_region_size must be >= 0") + if self.worker_l1_size is not None and self.worker_l1_size <= 0: + raise ValueError("worker_l1_size must be a positive byte count (or None for tt-metal's default)") + + @classmethod + def from_env(cls, prefix: str, env: Optional[Mapping[str, str]] = None, **defaults: Any) -> "DeviceConfig": + """Model defaults overridden by ``_DISPATCH`` (eth|worker|auto), ``_NUM_CQS``, + ``_L1_SMALL``, ``_TRACE_REGION``, ``_WORKER_L1_SIZE`` and ``TT_DEVICE_ID`` + (BUNDLE_CONVENTIONS.md section 7.6). Empty or ``0`` values keep the default. Read once, at build.""" + env = os.environ if env is None else env + values = dict(defaults) + + def _int(name: str) -> Optional[int]: + raw = (env.get(name) or "").strip() + if not raw: + return None + try: + value = int(float(raw)) + except ValueError: + raise ValueError(f"{name}={raw!r} is not an integer") from None + return value or None + + dispatch = (env.get(f"{prefix}_DISPATCH") or "").strip() + if dispatch: + values["dispatch"] = dispatch + for key, name in (("num_command_queues", f"{prefix}_NUM_CQS"), ("l1_small_size", f"{prefix}_L1_SMALL"), + ("trace_region_size", f"{prefix}_TRACE_REGION"), + ("worker_l1_size", f"{prefix}_WORKER_L1_SIZE")): + value = _int(name) + if value is not None: + values[key] = value + device_id = (env.get("TT_DEVICE_ID") or "").strip() + if device_id: + values["device_id"] = int(device_id) + return cls(**values) + + def open(self): + """``open_device(**self)``.""" + return open_device(**asdict(self)) + + +def tt_metal_home() -> Optional[Path]: + """The tt-metal tree: ``$TT_METAL_HOME``, else ``$TT_METAL_RUNTIME_ROOT``, else the tree holding the ``ttnn`` + package (located with ``importlib.util.find_spec``, so ttnn is not imported).""" + for var in ("TT_METAL_HOME", "TT_METAL_RUNTIME_ROOT"): + value = os.environ.get(var) + if value and Path(value).is_dir(): + return Path(value) + try: + spec = importlib.util.find_spec("ttnn") + except (ImportError, ValueError): + spec = None + if spec is None or not spec.origin: + return None + for parent in Path(spec.origin).resolve().parents: + if (parent / "tt_metal").is_dir(): + return parent + return None + + +def eth_dispatch_patch_present(home: Optional[os.PathLike] = None) -> bool: + """True when the tt-metal tree carries the ETH-dispatch patch (its marker string is in ``topology.cpp``).""" + root = tt_metal_home() if home is None else Path(home) + if root is None: + return False + try: + return PATCH_MARKER in (Path(root) / PATCH_RELPATH).read_text(errors="ignore") + except OSError: + return False + + +def resolve_dispatch(dispatch: str = "auto", *, home: Optional[os.PathLike] = None) -> str: + """``"eth"`` / ``"worker"`` pass through; ``"auto"`` becomes ``"eth"`` when the patch marker is found, else + ``"worker"`` with a ``RuntimeWarning`` (the grid is then 11x10 and the published numbers do not apply).""" + mode = _normalize_dispatch(dispatch) + if mode != "auto": + return mode + if eth_dispatch_patch_present(home): + return "eth" + warnings.warn( + "the tt-metal ETH-dispatch patch was not found (marker " + f"{PATCH_MARKER!r} missing from {PATCH_RELPATH} under {tt_metal_home()}); using WORKER dispatch: the compute " + "grid is 11x10 on a p150 and the published numbers do not apply (apply patches/tt-metal-eth-dispatch.patch)", + RuntimeWarning, stacklevel=3) + return "worker" + + +def open_device(device_id: int = 0, *, dispatch: str = "eth", num_command_queues: int = 1, + l1_small_size: int = DEFAULT_L1_SMALL_SIZE, trace_region_size: int = DEFAULT_TRACE_REGION_SIZE, + worker_l1_size: Optional[int] = None, allow_fallback: bool = True): + """Open one chip and return the ttnn (1x1 mesh) device. + + Args: + device_id: chip id as ttnn sees it. + dispatch: ``"eth"`` (default; 12x10 on a p150b), ``"worker"`` (A/B only; 11x10) or ``"auto"`` (ETH when the + patch marker is present, else WORKER with a ``RuntimeWarning``). + num_command_queues: 1, or 2 for trace + 2CQ (input upload on CQ1). + l1_small_size: L1_SMALL bytes per core (conv / pool config tensors). + trace_region_size: DRAM bytes reserved for trace buffers (0 = dynamic top-down buffers). + worker_l1_size: allocatable L1 per core in bytes; smaller values grow the kernel-config ring buffer + (TT_PLATFORM.md section 1). ``None`` keeps tt-metal's default. + allow_fallback: when the ETH open fails, warn and open with WORKER dispatch (``True``) or re-raise. + """ + import ttnn + + cfg = DeviceConfig(device_id=device_id, dispatch=dispatch, num_command_queues=num_command_queues, + l1_small_size=l1_small_size, trace_region_size=trace_region_size, + worker_l1_size=worker_l1_size, allow_fallback=allow_fallback) + mode = resolve_dispatch(cfg.dispatch) + params: Dict[str, Any] = dict(l1_small_size=cfg.l1_small_size, trace_region_size=cfg.trace_region_size, + num_command_queues=cfg.num_command_queues) + if cfg.worker_l1_size is not None: + params["worker_l1_size"] = cfg.worker_l1_size + device = None + fallback: Optional[str] = None + if mode == "eth": + try: + device = ttnn.open_device(device_id=cfg.device_id, + dispatch_core_config=ttnn.DispatchCoreConfig(ttnn.DispatchCoreType.ETH), **params) + except Exception as exc: # noqa: BLE001 -- tt-metal without the ETH-dispatch patch, or no idle ETH cores + if not cfg.allow_fallback: + raise + fallback = f"{type(exc).__name__}: {exc}" + warnings.warn(f"ETH dispatch is not available ({fallback}); falling back to WORKER dispatch: the compute " + "grid is 11x10 on a p150 and the published numbers do not apply " + "(apply patches/tt-metal-eth-dispatch.patch)", RuntimeWarning, stacklevel=2) + mode = "worker" + if device is None: + device = ttnn.open_device(device_id=cfg.device_id, + dispatch_core_config=ttnn.DispatchCoreConfig(ttnn.DispatchCoreType.WORKER), **params) + _CLOSED.pop(id(device), None) + _OPEN_INFO[id(device)] = (device, { + "dispatch": mode, "dispatch_requested": cfg.dispatch, "fallback": fallback, "device_id": cfg.device_id, + "num_command_queues": cfg.num_command_queues, "l1_small_size": cfg.l1_small_size, + "trace_region_size": cfg.trace_region_size, "worker_l1_size": cfg.worker_l1_size, + }) + gx, gy = compute_grid(device) + if mode == "eth" and (gx, gy) != P150_ETH_GRID: + warnings.warn(f"compute grid is {gx}x{gy}, expected {P150_ETH_GRID[0]}x{P150_ETH_GRID[1]} with ETH dispatch " + "on a p150b", RuntimeWarning, stacklevel=2) + return device + + +def compute_grid(device) -> Tuple[int, int]: + """``(x, y)`` of ``device.compute_with_storage_grid_size()``: (12, 10) with ETH dispatch on a p150b.""" + g = device.compute_with_storage_grid_size() + return int(g.x), int(g.y) + + +def core_grid(device): + """``ttnn.CoreGrid`` covering the whole compute grid (for ``core_grid=`` / program configs).""" + import ttnn + + x, y = compute_grid(device) + return ttnn.CoreGrid(y=y, x=x) + + +def full_core_range_set(device): + """``ttnn.CoreRangeSet`` of one rectangle covering the whole compute grid (for ``generic_op`` descriptors).""" + import ttnn + + x, y = compute_grid(device) + return ttnn.CoreRangeSet([ttnn.CoreRange(ttnn.CoreCoord(0, 0), ttnn.CoreCoord(x - 1, y - 1))]) + + +def open_info(device) -> Dict[str, Any]: + """What :func:`open_device` recorded for ``device`` (dispatch used, CQs, sizes), ``{}`` for a device opened + elsewhere.""" + entry = _OPEN_INFO.get(id(device)) + return dict(entry[1]) if entry is not None and entry[0] is device else {} + + +def _arch_name(device) -> str: + try: + arch = device.arch() + except Exception: # noqa: BLE001 -- a fake or partially closed device + return "unknown" + name = getattr(arch, "name", None) or str(arch).rsplit(".", 1)[-1] + return name.lower() + + +def describe_device(device, device_id: Optional[int] = None) -> Dict[str, Any]: + """What ``/info`` and ``model.info`` report: dispatch actually used, CQs, grid, cores, arch and open parameters. + + A device opened outside :func:`open_device` reports ``dispatch="unknown"``.""" + gx, gy = compute_grid(device) + info: Dict[str, Any] = {"dispatch": "unknown", "num_command_queues": None} + info.update(open_info(device)) + info.update({"grid": f"{gx}x{gy}", "grid_x": gx, "grid_y": gy, "cores": gx * gy, "arch": _arch_name(device), + "eth_patch": eth_dispatch_patch_present()}) + if device_id is not None: + info["device_id"] = device_id + return info + + +def close_device(device) -> None: + """Close a device (synchronizes first, as ``ttnn.close_device`` does). Idempotent: closing a device this + function already closed does nothing.""" + import ttnn + + if _CLOSED.get(id(device)) is device: + return + if open_info(device): + del _OPEN_INFO[id(device)] + try: + ttnn.close_device(device) + finally: + _CLOSED[id(device)] = device + + +@contextlib.contextmanager +def device_session(device_id: int = 0, **kwargs: Any) -> Iterator[Any]: + """``with device_session(dispatch="eth", num_command_queues=2) as dev:`` -- opens with :func:`open_device` + (same keyword arguments) and always closes, also when the body raises.""" + device = open_device(device_id, **kwargs) + try: + yield device + finally: + close_device(device) diff --git a/code/tt_frnet/ttaw/geometry.py b/code/tt_frnet/ttaw/geometry.py new file mode 100644 index 0000000000000000000000000000000000000000..bee2099f90087e7c1d6f04da5b9e41a3ca7e6ae7 --- /dev/null +++ b/code/tt_frnet/ttaw/geometry.py @@ -0,0 +1,194 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C10 geometry (sweep and ego-motion parts): rigid transforms in float64, Autoware's float32 sweep transform, and +the yaw / quaternion conventions of the LiDAR detectors. + +Naming: ``T_a_from_b`` maps points of frame ``b`` into frame ``a`` (4x4, metres, row-major, translation in the last +column), so ``p_a = T_a_from_b @ p_b`` and ``T_a_from_c = T_a_from_b @ T_b_from_c``. + +Two precisions on purpose: + +- **float64** for composing poses (``compose``, ``invert_rigid``): what tf2 does, and what every port uses unless its + Autoware node does otherwise (S:streampetr:386; S:pointpainting:212-215; S:bevformer:437). +- **float32 as Autoware's lidar_centerpoint does it**: the node casts each TF to ``Eigen::Affine3f``, inverts and + multiplies in float32 (``pointcloud_densification.cpp:47-51,82-86``; ``voxel_generator.cpp:63``) and the CUDA + kernel ``generateSweepPoints_kernel`` applies the float32 affine as ``m0*x + m4*y + m8*z + m12`` (column-major, + ``preprocess_kernel.cu:58-90``). :func:`invert_affine_f32` and :func:`transform_points_f32` reproduce that + arithmetic in numpy float32 without fused multiply-adds, so they agree with the GPU to the last bit except where + nvcc contracts a multiply-add (an FMA rounds once): a sub-micrometre difference, irrelevant to 0.32 m pillars. + +Yaw conventions (S:centerpoint:305-307): the deployed CenterPoint ONNX regresses the "mmdet3d" yaw; Autoware +publishes ``yaw_ros = -yaw_net - pi/2`` counter-clockwise from +x of ``base_link`` (``ros_utils.cpp:55-60``). + +numpy only; no side effects on import. +""" +from __future__ import annotations + +import math +from typing import Any, Sequence + +import numpy as np + +from .io import parse_transform + +__all__ = [ + "as_transform", + "compose", + "invert_rigid", + "invert_affine_f32", + "compose_f32", + "transform_points", + "transform_points_f32", + "rotation_from_rpy", + "transform_from_xyz_rpy", + "quaternion_wxyz_from_yaw", + "yaw_from_quaternion_wxyz", + "yaw_from_rotation", + "wrap_angle", + "mmdet_yaw_to_ros", + "ros_yaw_to_mmdet", +] + + +# ------------------------------------------------------------------------------------------------- transforms + +def as_transform(obj: Any, *, field: str = "transform") -> np.ndarray: + """Any spelling :func:`ttaw.io.parse_transform` accepts (4x4 / 3x4 array, ``{matrix}``, + ``{translation, rotation_wxyz | rotation_xyzw | rotation}``, ``{x, y, z, roll, pitch, yaw}``) -> a validated + (4, 4) float64 rigid transform. A malformed value raises :class:`ttaw.io.InputError`.""" + return parse_transform(obj, field=field) + + +def compose(*transforms: np.ndarray) -> np.ndarray: + """``T_0 @ T_1 @ ... @ T_n`` in float64 (``compose(T_a_from_b, T_b_from_c) == T_a_from_c``).""" + if not transforms: + return np.eye(4) + out = np.asarray(transforms[0], dtype=np.float64) + for t in transforms[1:]: + out = out @ np.asarray(t, dtype=np.float64) + return out + + +def invert_rigid(T: np.ndarray) -> np.ndarray: + """Exact inverse of a rigid transform in float64: ``[R^T, -R^T t]``.""" + T = np.asarray(T, dtype=np.float64) + out = np.eye(4) + r = T[:3, :3] + out[:3, :3] = r.T + out[:3, 3] = -(r.T @ T[:3, 3]) + return out + + +def invert_affine_f32(T: np.ndarray) -> np.ndarray: + """``Eigen::Affine3f::inverse()`` (TransformTraits ``Affine``) in float32: the 3x3 linear part inverted through + its cofactors and the column-0 determinant (Eigen's ``compute_inverse<..., 3>``), translation ``-L^-1 t``, + every operation rounded to float32. Used by the Autoware densification emulation + (``pointcloud_densification.cpp:84``).""" + m = np.asarray(T, dtype=np.float32) + a = m[:3, :3] + f = np.float32 + + def cofactor(i: int, j: int) -> np.float32: # Eigen cofactor_3x3 + i1, i2, j1, j2 = (i + 1) % 3, (i + 2) % 3, (j + 1) % 3, (j + 2) % 3 + return f(f(a[i1, j1] * a[i2, j2]) - f(a[i1, j2] * a[i2, j1])) + + det = f(f(cofactor(0, 0) * a[0, 0]) + f(cofactor(1, 0) * a[1, 0])) + f(cofactor(2, 0) * a[2, 0]) + if det == 0 or not np.isfinite(det): + raise ValueError("singular affine transform") + inv_det = f(f(1.0) / det) + inv = np.empty((3, 3), np.float32) + for i in range(3): + for j in range(3): + inv[i, j] = f(cofactor(j, i) * inv_det) # adjugate / det + out = np.eye(4, dtype=np.float32) + out[:3, :3] = inv + t = m[:3, 3] + for i in range(3): + out[i, 3] = -(f(f(inv[i, 0] * t[0]) + f(inv[i, 1] * t[1])) + f(inv[i, 2] * t[2])) + return out + + +def compose_f32(A: np.ndarray, B: np.ndarray) -> np.ndarray: + """``A @ B`` of two float32 affine matrices with float32 rounding after every product and sum, summed in index + order (Eigen's lazy product of two ``Affine3f``).""" + a = np.asarray(A, dtype=np.float32) + b = np.asarray(B, dtype=np.float32) + out = np.zeros((4, 4), np.float32) + for k in range(4): + out = (out + a[:, k:k + 1] * b[k:k + 1, :]).astype(np.float32) + return out + + +def transform_points(points: np.ndarray, T: np.ndarray) -> np.ndarray: + """``T @ [x, y, z, 1]`` for (N, >=3) points in float64 (extra columns are passed through unchanged).""" + p = np.asarray(points, dtype=np.float64) + T = np.asarray(T, dtype=np.float64) + out = p.copy() + out[:, :3] = p[:, :3] @ T[:3, :3].T + T[:3, 3] + return out + + +def transform_points_f32(xyz: np.ndarray, T: np.ndarray) -> np.ndarray: + """Autoware ``generateSweepPoints_kernel`` (``preprocess_kernel.cu:58-90``): the affine is cast to float32 and + each output coordinate is ``((m[r,0]*x + m[r,1]*y) + m[r,2]*z) + m[r,3]`` in float32, left to right (no FMA). + ``xyz``: (N, >=3); returns (N, 3) float32.""" + p = np.asarray(xyz, dtype=np.float32) + m = np.asarray(T, dtype=np.float32) + x, y, z = p[:, 0], p[:, 1], p[:, 2] + out = np.empty((len(p), 3), np.float32) + for r in range(3): + out[:, r] = ((m[r, 0] * x + m[r, 1] * y) + m[r, 2] * z) + m[r, 3] + return out + + +# ----------------------------------------------------------------------------------------------- rotations + +def rotation_from_rpy(roll: float, pitch: float, yaw: float) -> np.ndarray: + """tf2 ``setRPY``: ``R = Rz(yaw) @ Ry(pitch) @ Rx(roll)`` (float64).""" + cr, sr, cp, sp, cy, sy = (math.cos(roll), math.sin(roll), math.cos(pitch), math.sin(pitch), math.cos(yaw), + math.sin(yaw)) + return np.array([[cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr], + [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr], + [-sp, cp * sr, cp * cr]], dtype=np.float64) + + +def transform_from_xyz_rpy(x: float, y: float, z: float, roll: float = 0.0, pitch: float = 0.0, + yaw: float = 0.0) -> np.ndarray: + """A (4, 4) float64 transform from a translation and tf2 RPY angles (Autoware's calibration yaml spelling).""" + out = np.eye(4) + out[:3, :3] = rotation_from_rpy(roll, pitch, yaw) + out[:3, 3] = (x, y, z) + return out + + +def quaternion_wxyz_from_yaw(yaw: float) -> np.ndarray: + """``autoware_utils::create_quaternion_from_yaw``: rotation about +z, as (w, x, y, z) float64.""" + return np.array([math.cos(yaw / 2.0), 0.0, 0.0, math.sin(yaw / 2.0)], dtype=np.float64) + + +def yaw_from_quaternion_wxyz(q: Sequence[float]) -> float: + """``tf2::getYaw`` of a (w, x, y, z) quaternion.""" + w, x, y, z = (float(v) for v in q) + return math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + + +def yaw_from_rotation(r: np.ndarray) -> float: + """Heading of a rotation matrix (rotation of +x about +z, ``atan2(R[1,0], R[0,0])``).""" + r = np.asarray(r, dtype=np.float64) + return math.atan2(r[1, 0], r[0, 0]) + + +def wrap_angle(a: Any) -> Any: + """Wrap angles to [-pi, pi).""" + return (np.asarray(a, dtype=np.float64) + math.pi) % (2.0 * math.pi) - math.pi + + +def mmdet_yaw_to_ros(yaw_net: Any) -> np.ndarray: + """``ros_utils.cpp:55``: ``const float yaw = -box3d.yaw - pi / 2`` (float input, double arithmetic, float + result). Returns float32 radians, not wrapped (as Autoware).""" + y = np.asarray(yaw_net, dtype=np.float32).astype(np.float64) + return (-y - math.pi / 2.0).astype(np.float32) + + +def ros_yaw_to_mmdet(yaw_ros: Any) -> np.ndarray: + """Inverse of :func:`mmdet_yaw_to_ros` (float64): ``yaw_net = -yaw_ros - pi/2``.""" + return -np.asarray(yaw_ros, dtype=np.float64) - math.pi / 2.0 diff --git a/code/tt_frnet/ttaw/golden.py b/code/tt_frnet/ttaw/golden.py new file mode 100644 index 0000000000000000000000000000000000000000..e00fb389b91d619962eb1fae59d72751ed7aec6a --- /dev/null +++ b/code/tt_frnet/ttaw/golden.py @@ -0,0 +1,488 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C06 goldens: golden tensor files, a tap registry, gates that are never loosened, JSON compare reports. + +- **Golden files** are ``.npz`` (loaded with ``allow_pickle=False``) holding named tensors ("taps") and an optional + JSON metadata record under the key ``__meta__`` (source file hashes, reference script, sample, date). +- **Taps** are named intermediates recorded by :class:`TapRegistry` from the CPU reference or from an *eager* + device run (never inside a trace capture: a traced intermediate is freed at the end of the capture). +- **Gates** (PLAN.md section 0.3): thresholds are declared in the test (from the spec targets) and frozen in a JSON + file next to the test at the first green run (``test_pcc_device.py`` -> ``test_pcc_device.gates.json``). From then + on a declaration looser than the frozen threshold raises :class:`GateLoosenedError`; tightening is allowed and + re-frozen. Changing a frozen gate means editing the JSON by hand and disclosing it (VERIFICATION_.md). +- **Reports**: :class:`CompareReport` collects per-tap statistics and gate verdicts and writes JSON. + +numpy only (device tensors are converted through :func:`.tensors.to_numpy`, which imports ttnn lazily). +""" +from __future__ import annotations + +import contextlib +import datetime as _dt +import fnmatch +import json +import math +import os +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Union + +import numpy as np + +from . import metrics as _m +from .weights import file_sha256 + +__all__ = [ + "save_goldens", + "load_goldens", + "Goldens", + "TapRegistry", + "Gate", + "GateResult", + "GateLoosenedError", + "GateRegistry", + "METRICS", + "DIRECTIONS", + "TapComparison", + "CompareReport", + "compare_taps", +] + +PathLike = Union[str, os.PathLike] +META_KEY = "__meta__" + + +def _now() -> str: + return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds") + + +def _host(value: Any) -> np.ndarray: + from .tensors import to_numpy + + return np.asarray(to_numpy(value)) + + +def _atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") + tmp.write_text(text) + os.replace(tmp, path) + + +# --------------------------------------------------------------------------------------------- golden files + +def save_goldens(path: PathLike, tensors: Mapping[str, Any], meta: Optional[Mapping[str, Any]] = None, *, + compress: bool = False) -> Path: + """Write ``tensors`` (numpy / torch / ttnn) and ``meta`` (JSON-able) to an ``.npz`` file.""" + path = Path(path) + arrays = {str(k): _host(v) for k, v in tensors.items()} + if META_KEY in arrays: + raise ValueError(f"{META_KEY!r} is reserved for metadata") + arrays[META_KEY] = np.array(json.dumps(dict(meta or {}), sort_keys=True, default=str)) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.stem}.{os.getpid()}.tmp.npz") + (np.savez_compressed if compress else np.savez)(tmp, **arrays) + os.replace(tmp, path) + return path + + +class Goldens(Mapping): + """Read-only, lazily loaded golden tensors of one ``.npz`` file (``with load_goldens(p) as g: g["neck"]``).""" + + def __init__(self, path: PathLike): + self.path = Path(path) + self._npz = np.load(self.path, allow_pickle=False) + self._cache: Dict[str, np.ndarray] = {} + self.meta: Dict[str, Any] = (json.loads(str(self._npz[META_KEY])) if META_KEY in self._npz.files else {}) + self._sha256: Optional[str] = None + + @property + def sha256(self) -> str: + if self._sha256 is None: + self._sha256 = file_sha256(self.path) + return self._sha256 + + def __getitem__(self, name: str) -> np.ndarray: + if name == META_KEY or name not in self._npz.files: + raise KeyError(f"{name!r} not in {self.path.name}; have {sorted(self)[:20]}") + if name not in self._cache: + self._cache[name] = self._npz[name] + return self._cache[name] + + def __iter__(self) -> Iterator[str]: + return (k for k in self._npz.files if k != META_KEY) + + def __len__(self) -> int: + return sum(1 for _ in self) + + def close(self) -> None: + self._npz.close() + + def __enter__(self) -> "Goldens": + return self + + def __exit__(self, *exc) -> None: + self.close() + + +def load_goldens(path: PathLike) -> Goldens: + return Goldens(path) + + +# ------------------------------------------------------------------------------------------------- taps + +class TapRegistry: + """Records named intermediates. ``tap(name, value)`` returns ``value`` unchanged, so it can be inlined:: + + taps = TapRegistry(include=["backbone.*", "neck"]) + x = taps.tap("backbone.layer1", layer1(x)) # stored as numpy (host read for device tensors) + with taps.scope("head"): + y = taps.tap("cls", cls_head(x)) # recorded as "head.cls" + taps.save("golden/sample0_taps.npz", meta={...}) + + A disabled registry (``TapRegistry(enabled=False)``, or :data:`NULL_TAPS`) does nothing, so production code can + keep its ``tap`` calls. Device tensors are read immediately (they may be deallocated later by the model); + calling ``tap`` on a device tensor while a trace capture is active raises.""" + + def __init__(self, enabled: bool = True, *, include: Sequence[str] = ("*",), exclude: Sequence[str] = ()): + self.enabled = enabled + self.include = tuple(include) + self.exclude = tuple(exclude) + self._prefix: List[str] = [] + self._values: Dict[str, np.ndarray] = {} + + def _full(self, name: str) -> str: + return ".".join(self._prefix + [name]) + + def wants(self, name: str) -> bool: + """Whether ``name`` (full dotted name) would be recorded.""" + return (self.enabled and any(fnmatch.fnmatchcase(name, p) for p in self.include) + and not any(fnmatch.fnmatchcase(name, p) for p in self.exclude)) + + def tap(self, name: str, value: Any) -> Any: + if not self.enabled: + return value + full = self._full(name) + if not self.wants(full): + return value + ttnn = sys.modules.get("ttnn") + if ttnn is not None and isinstance(value, getattr(ttnn, "Tensor", ())): + if value.storage_type() == ttnn.StorageType.DEVICE and ttnn.is_trace_capture_active(value.device()): + raise RuntimeError(f"tap {full!r} inside a trace capture: return it as a trace output instead") + self._values[full] = _host(value) + return value + + @contextlib.contextmanager + def scope(self, name: str) -> Iterator["TapRegistry"]: + self._prefix.append(name) + try: + yield self + finally: + self._prefix.pop() + + def names(self) -> List[str]: + return list(self._values) + + def __contains__(self, name: str) -> bool: + return name in self._values + + def __getitem__(self, name: str) -> np.ndarray: + return self._values[name] + + def items(self): + return self._values.items() + + def to_dict(self) -> Dict[str, np.ndarray]: + return dict(self._values) + + def clear(self) -> None: + self._values.clear() + + def save(self, path: PathLike, meta: Optional[Mapping[str, Any]] = None, *, compress: bool = False) -> Path: + return save_goldens(path, self._values, meta, compress=compress) + + +NULL_TAPS = TapRegistry(enabled=False) + +# ------------------------------------------------------------------------------------------------ gates + +# metric name -> (function(test, ref, **kw) -> float, direction): "min" = higher is better (value >= threshold) +METRICS: Dict[str, Any] = { + "pcc": (_m.pcc, "min"), + "masked_pcc": (_m.masked_pcc, "min"), + "argmax_agreement": (_m.argmax_agreement, "min"), + "label_agreement": (_m.label_agreement, "min"), + "mask_iou": (_m.mask_iou, "min"), + "max_abs": (lambda t, r: _m.error_stats(t, r)["max_abs"], "max"), + "mean_abs": (lambda t, r: _m.error_stats(t, r)["mean_abs"], "max"), + "rel_l2": (lambda t, r: _m.error_stats(t, r)["rel_l2"], "max"), +} + +# The direction of every metric name a gate may use without spelling it out (the METRICS above plus the scalars +# recorded with CompareReport.add_value). A bare-number gate on any other metric is refused rather than guessed: +# a guessed "min" on an error metric (ADE, max |error|) would pass exactly the results it is meant to fail. +DIRECTIONS: Dict[str, str] = { + **{name: direction for name, (_, direction) in METRICS.items()}, + **dict.fromkeys(("valid_row_pcc", "agreement", "iou", "miou", "mean_iou", "recall", "precision", "topk_overlap", + "topk_set_overlap"), "min"), + **dict.fromkeys(("max_abs_err", "ade", "fde", "center_err", "center_err_max", "center_err_mean", "score_err", + "score_err_max", "score_err_mean"), "max"), +} + + +@dataclass(frozen=True) +class Gate: + """``direction="min"``: pass when ``value >= threshold`` (PCC, agreement, IoU, recall); ``"max"``: pass when + ``value <= threshold`` (errors, ADE/FDE). NaN never passes.""" + + threshold: float + direction: str = "min" + metric: str = "pcc" + + def __post_init__(self) -> None: + if self.direction not in ("min", "max"): + raise ValueError(f"gate direction {self.direction!r}: expected 'min' or 'max'") + object.__setattr__(self, "threshold", float(self.threshold)) + + @classmethod + def of(cls, spec: Union["Gate", float, int, Sequence, Mapping], metric: Optional[str] = None) -> "Gate": + """``0.999`` | ``(0.999, "min")`` | ``(0.05, "max", "max_abs")`` | ``{"threshold": ..., ...}`` | Gate. + A bare number takes the direction of ``metric`` (default ``pcc``) from :data:`DIRECTIONS` and raises + ``ValueError`` for a metric whose direction is unknown; a direction that contradicts a known metric's + (e.g. a ``"max"`` PCC gate) raises too.""" + if isinstance(spec, Gate): + gate = spec + elif isinstance(spec, Mapping): + gate = cls(**spec) + elif isinstance(spec, (int, float)): + name = metric or "pcc" + if name not in DIRECTIONS: + raise ValueError(f"gate {spec!r} on metric {name!r}: its direction is unknown; give " + f"(threshold, 'min' | 'max', {name!r})") + gate = cls(float(spec), DIRECTIONS[name], name) + else: + parts = list(spec) + gate = cls(*parts) if len(parts) == 3 else cls(parts[0], parts[1], metric or "pcc") + known = DIRECTIONS.get(gate.metric) + if known is not None and gate.direction != known: + better = "higher" if known == "min" else "lower" + raise ValueError(f"gate {gate}: {gate.metric!r} is a {known!r} metric ({better} is better)") + return gate + + def passes(self, value: float) -> bool: + if value is None or (isinstance(value, float) and math.isnan(value)): + return False + return value >= self.threshold if self.direction == "min" else value <= self.threshold + + def looser_than(self, other: "Gate") -> bool: + """True if this gate accepts something ``other`` rejects (same direction), or the direction differs.""" + if self.direction != other.direction or self.metric != other.metric: + return True + return self.threshold < other.threshold if self.direction == "min" else self.threshold > other.threshold + + +@dataclass +class GateResult: + name: str + value: Optional[float] + gate: Gate + passed: bool + newly_frozen: bool = False + + def __str__(self) -> str: + op = ">=" if self.gate.direction == "min" else "<=" + v = "nan" if self.value is None else f"{self.value:.6g}" + return f"{'PASS' if self.passed else 'FAIL'} {self.name}: {self.gate.metric} {v} {op} {self.gate.threshold:g}" + + +class GateLoosenedError(AssertionError): + """A declared gate is looser than the threshold frozen at the first green run.""" + + +class GateRegistry: + """Declared gates + the JSON record of frozen gates next to the test file. + + Usage in a device test:: + + GATES = GateRegistry.for_test(__file__, {"backbone": 0.999, "head.reg": 0.99, "dets.recall": (0.95, "min")}) + + @pytest.mark.parametrize("stage", GATES.names()) + def test_stage(stage, stage_outputs): + GATES.require(stage, pcc(*stage_outputs[stage])) # AssertionError on failure; frozen on first pass + + ``write=False`` (or ``TTAW_GATES_READONLY=1``) never writes the JSON (CI / verification runs).""" + + VERSION = 1 + + def __init__(self, path: PathLike, gates: Optional[Mapping[str, Any]] = None, *, write: Optional[bool] = None): + self.path = Path(path) + if write is None: + write = os.environ.get("TTAW_GATES_READONLY", "0").strip() in ("", "0", "false", "no") + self.write = bool(write) + self.declared: Dict[str, Gate] = {k: Gate.of(v) for k, v in (gates or {}).items()} + self.frozen: Dict[str, Dict[str, Any]] = {} + if self.path.is_file(): + data = json.loads(self.path.read_text()) + self.frozen = dict(data.get("gates", {})) + self.results: List[GateResult] = [] + loosened = [] + for name, g in self.declared.items(): + if name in self.frozen and g.looser_than(self._frozen_gate(name)): + f = self._frozen_gate(name) + loosened.append(f"{name}: declared {g.direction} {g.threshold:g} ({g.metric}) vs frozen " + f"{f.direction} {f.threshold:g} ({f.metric})") + if loosened: + raise GateLoosenedError(f"gates in {self.path.name} may never be loosened once recorded: " + + "; ".join(loosened)) + + @classmethod + def for_test(cls, test_file: PathLike, gates: Optional[Mapping[str, Any]] = None, **kwargs: Any) -> "GateRegistry": + """Registry stored as ``.gates.json`` next to ``test_file`` (pass ``__file__``).""" + p = Path(test_file) + return cls(p.with_name(p.stem + ".gates.json"), gates, **kwargs) + + def _frozen_gate(self, name: str) -> Gate: + rec = self.frozen[name] + return Gate(rec["threshold"], rec["direction"], rec["metric"]) + + def names(self) -> List[str]: + return sorted(set(self.declared) | set(self.frozen)) + + def gate(self, name: str) -> Gate: + """The effective gate: the declared one (never looser than the frozen one), else the frozen one.""" + if name in self.declared: + return self.declared[name] + if name in self.frozen: + return self._frozen_gate(name) + raise KeyError(f"no gate {name!r} declared or frozen in {self.path.name}") + + def check(self, name: str, value: Optional[float], gate: Optional[Any] = None) -> GateResult: + """Evaluate ``value``; on a pass, freeze the gate if it is new or tighter than the frozen one.""" + g = Gate.of(gate) if gate is not None else self.gate(name) + if name in self.frozen and g.looser_than(self._frozen_gate(name)): + raise GateLoosenedError(f"gate {name!r} {g} is looser than the frozen {self.frozen[name]}") + v = None if value is None else float(value) + passed = g.passes(v) + newly = False + if passed and (name not in self.frozen or self._frozen_gate(name) != g): + self.frozen[name] = {**asdict(g), "value_at_freeze": v, "frozen_at": _now()} + newly = True + if self.write: + self.save() + result = GateResult(name, v, g, passed, newly) + self.results.append(result) + return result + + def require(self, name: str, value: Optional[float], gate: Optional[Any] = None) -> GateResult: + """:meth:`check`, raising ``AssertionError`` when the gate fails.""" + result = self.check(name, value, gate) + if not result.passed: + raise AssertionError(str(result)) + return result + + def save(self) -> None: + body = {"version": self.VERSION, "note": "Frozen at the first green run; never loosen (PLAN.md 0.3).", + "gates": dict(sorted(self.frozen.items()))} + _atomic_write_text(self.path, json.dumps(body, indent=1, sort_keys=False) + "\n") + + +# ----------------------------------------------------------------------------------------------- reports + +@dataclass +class TapComparison: + name: str + metric: str + value: Optional[float] + gate: Optional[Dict[str, Any]] + passed: Optional[bool] + stats: Dict[str, Any] = field(default_factory=dict) + note: str = "" + + +class CompareReport: + """Per-tap comparison records -> JSON (``logs//compare_.json``) and a text table.""" + + def __init__(self, title: str = "", meta: Optional[Mapping[str, Any]] = None, + gates: Optional[GateRegistry] = None): + self.title = title + self.meta = dict(meta or {}) + self.gates = gates + self.entries: List[TapComparison] = [] + + def _gate_for(self, name: str, gate: Any, metric: str) -> Optional[Gate]: + if gate is not None: + return Gate.of(gate, metric) + if self.gates is not None and name in self.gates.names(): + return self.gates.gate(name) + return None + + def add(self, name: str, test: Any, ref: Any, *, metric: str = "pcc", gate: Any = None, + **metric_kwargs: Any) -> TapComparison: + """Compare two tensors with ``metric`` (a :data:`METRICS` name); always records the error statistics.""" + t, r = _host(test), _host(ref) + stats: Dict[str, Any] = {"shape_test": list(t.shape), "shape_ref": list(r.shape), + "dtype_test": str(t.dtype), "dtype_ref": str(r.dtype)} + note = "" + if t.shape == r.shape: + stats.update(_m.error_stats(t, r)) + fn = METRICS[metric][0] + value: Optional[float] = float(fn(t, r, **metric_kwargs)) + else: + value, note = None, "shape mismatch" + return self._record(name, metric, value, gate, stats, note) + + def add_value(self, name: str, value: Optional[float], *, metric: str, gate: Any = None, + stats: Optional[Mapping[str, Any]] = None) -> TapComparison: + """Record an already computed scalar (detection recall, ADE, argmax agreement ...).""" + return self._record(name, metric, None if value is None else float(value), gate, dict(stats or {}), "") + + def _record(self, name: str, metric: str, value: Optional[float], gate: Any, stats: Dict[str, Any], + note: str) -> TapComparison: + g = self._gate_for(name, gate, metric) + passed: Optional[bool] = None + if g is not None: + passed = (self.gates.check(name, value, g).passed if self.gates is not None else g.passes(value)) + entry = TapComparison(name, metric, value, None if g is None else asdict(g), passed, stats, note) + self.entries.append(entry) + return entry + + @property + def passed(self) -> bool: + """All gated entries passed (ungated entries are informational).""" + return all(e.passed for e in self.entries if e.passed is not None) + + def to_dict(self) -> Dict[str, Any]: + return {"title": self.title, "created": _now(), "meta": self.meta, "passed": self.passed, + "entries": [asdict(e) for e in self.entries]} + + def save_json(self, path: PathLike) -> Path: + path = Path(path) + _atomic_write_text(path, json.dumps(self.to_dict(), indent=1, default=str) + "\n") + return path + + def table(self) -> str: + rows = [f"{self.title}".strip(), f"{'name':40s} {'metric':16s} {'value':>12s} {'gate':>14s} verdict"] + for e in self.entries: + gate = "" + if e.gate is not None: + gate = (">=" if e.gate["direction"] == "min" else "<=") + f"{e.gate['threshold']:g}" + val = "nan" if e.value is None else f"{e.value:.6g}" + verdict = {True: "PASS", False: "FAIL", None: "-"}[e.passed] + rows.append(f"{e.name:40s} {e.metric:16s} {val:>12s} {gate:>14s} {verdict} {e.note}".rstrip()) + return "\n".join(r for r in rows if r) + + +def compare_taps(test: Mapping[str, Any], golden: Mapping[str, Any], *, gates: Optional[Any] = None, + metric: str = "pcc", names: Optional[Sequence[str]] = None, title: str = "", + meta: Optional[Mapping[str, Any]] = None) -> CompareReport: + """Compare every tap present in both mappings (or ``names``). ``gates``: a :class:`GateRegistry` or a + ``{name: gate spec}`` mapping. Taps missing on either side are recorded as failures when gated.""" + registry = gates if isinstance(gates, GateRegistry) else None + plain = {} if registry is not None or gates is None else {k: Gate.of(v, metric) for k, v in gates.items()} + report = CompareReport(title, meta, registry) + for name in (names if names is not None else [n for n in golden if n in test]): + if name not in test or name not in golden: + report.add_value(name, None, metric=metric, gate=plain.get(name), + stats={"missing": "test" if name not in test else "golden"}) + continue + report.add(name, test[name], golden[name], metric=metric, gate=plain.get(name)) + return report diff --git a/code/tt_frnet/ttaw/image.py b/code/tt_frnet/ttaw/image.py new file mode 100644 index 0000000000000000000000000000000000000000..438db2933fdb08d3d61851c7a252b84c37093566 --- /dev/null +++ b/code/tt_frnet/ttaw/image.py @@ -0,0 +1,666 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C14 image pre-processing: bit-exact host emulations of the Autoware camera nodes' resize kernels, driven by +per-source-size lookup tables (LUTs) that are computed once and cached. + +Each preset reproduces one deployed CUDA / OpenCV routine exactly (integer outputs equal, not "close"), because a +standard resize in its place changes the network input enough to fail the PCC gates (YOLOX: ``cv2.resize`` drops the +detection-output PCC to 0.94, research/yolox/SPEC.md section 3). The tables hold everything that depends only on the +source and destination sizes (source indices, interpolation weights, letterbox geometry); applying them is a few +vectorised numpy passes over the image. numpy only; no ttnn, no torch, no OpenCV. + +Presets (PLAN.md C14; the other camera ports add theirs here): + +========================== ========================================================================================= +``yolox_letterbox`` autoware_tensorrt_yolox ``resize_bilinear_letterbox_nhwc_to_nchw32_batch_kernel`` + (autoware_universe @ 9ceaccf, ``perception/autoware_tensorrt_yolox/src/preprocess.cu:43-129``, + launched by ``tensorrt_yolox.cpp:243-332``): a "bilinear" resize with inverted, + half-magnitude weights, float -> int truncation between the horizontal and vertical passes, + ``lroundf``, top-left letterbox padded with 114, BGR kept, no normalisation. +``bevdet_nearest_crop`` the ``bevdet::Preprocess`` TensorRT plugin of autoware_tensorrt_bevdet (``bevdet_vendor`` + 0.2.1; research/bevdet/SPEC.md section 3.2): nearest resize by r = 0.44f + crop (140, 0) + of the 1600x900 image, ``roundf(i / r + crop_h / r)`` rows / columns in float32, planar + B, G, R uint8 (the normalisation ``bevdet_normalize`` is separate: device-side in the TT + port). The node's ``cv::resize`` of non-1600x900 images to 1600x900 stays with the caller. +``bevformer_preprocess`` autoware_tensorrt_bevformer's ``preprocessing_pipeline`` (autoware_universe @ 9ceaccf, + ``src/preprocessing/normalize_multiview_image.cpp:70-95`` + + ``augmentation_transforms.cpp:90-222``; research/bevformer/SPEC.md section 3.1): + normalise FIRST (BGR uint8 -> float, ``(x - mean_c) / std_c``, BGR mean, std 1), then + ``cv::resize`` x0.8 with INTER_LINEAR **on float data** (1600x900 -> 1280x720), then pad + bottom / right with 0 (normalised space) to a multiple of 32 (1280x736), CHW. The node's + ``cv::resize`` of non-1600x900 uint8 images to 1600x900 stays with the caller. +========================== ========================================================================================= + +Exactness notes for ``yolox_letterbox`` (all arithmetic is float32 in the kernel): + +- ``scale = min(dst_w / (float)src_w, dst_h / (float)src_h)``; ``r_h = (int)(scale * src_h)``, + ``r_w = (int)(scale * src_w)``; the kernel receives ``k = (float)(1.0 / (double)scale)`` (``preprocess.cu:121-128``). +- per output row ``h``: ``c = k * (float)(h + 0.5)``, ``src_row = clamp(lroundf(c) - 1, 0, src_h - 2)``, + weight ``t = (1 + lroundf(c) - c) / 2`` (float32), and the same per column (``preprocess.cu:48-59, 77-88``). +- ``lerp1d(a, b, t) = fma(t, b, fma(-t, a, a))`` with float operands (CUDA's float overload, i.e. ``fmaf``: + one rounding per call); the horizontal results are truncated to int before the vertical ``lerp1d`` + (``lerp1d`` takes ``int a, int b``), and the result is ``lroundf``-ed (``preprocess.cu:43-46, 57, 101-103``). +- ``t`` lies in [0.25, 0.75], so ``t`` is a multiple of 2**-25 and ``a - t*a`` / ``t*b + inner`` with integer + ``a, b`` in [0, 255] need at most 34 significant bits: float64 holds them exactly, and one ``astype(float32)`` is + exactly the single rounding of ``fmaf``. +- Rows ``h >= r_h`` and columns ``w >= r_w`` are 114 (``preprocess.cu:109-110``); the output values are integers in + [0, 255], so this module returns uint8 (the network input is ``float(value)``, ``norm_factor`` 1.0). + +Verified against two independent scalar transliterations of the CUDA source (``common/tests/host/test_image_host.py`` +and research/yolox/scripts/{check_preprocess,verify_preproc_scalar}.py: 0 mismatches). Residual uncertainty, from the +SPEC: if nvcc picked the double ``fma`` overload, 0-2 of 3000 samples would differ by 1 (not testable without CUDA). + +Exactness notes for ``bevformer_preprocess`` (float32 data; OpenCV's float INTER_LINEAR path): + +- normalisation: ``Mat - mean`` / ``std`` evaluates as ``convertTo(alpha = 1/std, beta = -mean/std)`` with float + ``alpha`` / ``beta`` (one fused multiply-add per value); with the deployed std 1 it is ``x - float(mean_c)``. +- resize coefficients (``resize.cpp``): ``scale = 1.0 / ((double)dst / src)``, ``f = (float)((d + 0.5) * scale - + 0.5)``, ``s = floor(f)``, ``w = f - s`` (float), clamped to the border (``s < 0`` -> ``s = 0, w = 0``; + ``s >= src - 1`` -> ``s = src - 1, w = 0``); the destination size is ``(int)(src * 0.8f)`` in float. +- each pass is ``fma(x1 - x0, w, x0)`` in float32 (the difference rounded once, then one rounding of the fused + product-sum), horizontal pass first, then vertical. This is what OpenCV 4.8.1 and 4.11.0 compute on this x86-64 + host (AVX2 + FMA) bit for bit for every x0.8 down-scale, on arbitrary float images (not just uint8 - mean): the + plain two-product form ``x0 * (1 - w) + x1 * w`` differs from it in about 10 % of the samples by 1 ulp + (``tests/host/test_image_bevformer_host.py`` holds both checks). The weights of a x0.8 resize are multiples of + 1/8, so float64 holds every fused product-sum exactly and one ``astype(float32)`` is the single rounding. OpenCV + computes the coefficients of other scales differently, so :func:`opencv_linear_lut` refuses them by default. +""" +from __future__ import annotations + +import threading +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Callable, Dict, Hashable, Optional, Tuple + +import numpy as np + +__all__ = [ + "LetterboxGeometry", + "YoloxLetterboxLUT", + "yolox_letterbox", + "yolox_letterbox_geometry", + "yolox_letterbox_lut", + "lroundf", + "f32", + "LUTCache", + "PRESETS", + "YOLOX_PAD_VALUE", + "NearestCropLUT", + "bevdet_nearest_lut", + "bevdet_nearest_crop", + "bevdet_normalize", + "BEVDET_MEAN", + "BEVDET_STD", + "LinearResizeLUT", + "opencv_linear_lut", + "opencv_resize_linear_f32", + "bevformer_input_geometry", + "bevformer_normalize", + "bevformer_preprocess", + "BEVFORMER_MEAN_BGR", + "BEVFORMER_STD", +] + +F32 = np.float32 +YOLOX_PAD_VALUE = 114 # preprocess.cu:109-110 (letterbox fill), the YOLOX training pad value + + +def f32(x: Any) -> np.ndarray: + """``x`` rounded once to float32 (round-to-nearest-even), as a numpy float32 scalar or array.""" + return np.asarray(x).astype(F32) + + +def lroundf(x: Any) -> np.ndarray: + """C ``lroundf``: round half away from zero, for float32 inputs (exact in float64). Returns int64.""" + x = np.asarray(x, dtype=np.float64) + return (np.sign(x) * np.floor(np.abs(x) + 0.5)).astype(np.int64) + + +class LUTCache: + """A small thread-safe LRU of tables keyed by the source / destination sizes (a camera stream keeps its size, + so the table is built once per stream; Autoware re-creates its buffers only when the source size changes, + ``tensorrt_yolox.cpp:253-291``).""" + + def __init__(self, maxsize: int = 8): + self.maxsize = int(maxsize) + self._items: "OrderedDict[Hashable, Any]" = OrderedDict() + self._lock = threading.Lock() + self.hits = 0 + self.misses = 0 + + def get(self, key: Hashable, make: Callable[[], Any]) -> Any: + with self._lock: + if key in self._items: + self._items.move_to_end(key) + self.hits += 1 + return self._items[key] + value = make() # outside the lock: building a table takes a few ms + with self._lock: + self.misses += 1 + self._items[key] = value + self._items.move_to_end(key) + while len(self._items) > self.maxsize: + self._items.popitem(last=False) + return value + + def clear(self) -> None: + with self._lock: + self._items.clear() + self.hits = self.misses = 0 + + def __len__(self) -> int: + return len(self._items) + + +# ------------------------------------------------------------------------------------------- YOLOX letterbox + + +@dataclass(frozen=True) +class LetterboxGeometry: + """Where the source image lands in the network input, and what the decoder and the mask crop use. + + ``scale`` is the float32 scale of ``preprocess.cu:121`` (also ``scales_`` of ``tensorrt_yolox.cpp:285``, used to + map boxes back, and the mask scale of ``tensorrt_yolox.cpp:528-536``); ``resized_hw`` = ``(r_h, r_w)``, the + un-letterboxed region at the top-left of the ``dst_hw`` canvas. Autoware's mask crop ``(out_h, out_w)`` = + ``((int)(src_h * scale), (int)(src_w * scale))`` is the same pair (float multiplication commutes).""" + + src_hw: Tuple[int, int] + dst_hw: Tuple[int, int] + scale: float # exact float32 value, stored as a Python float + resized_hw: Tuple[int, int] + pad_value: int = YOLOX_PAD_VALUE + + @property + def scale_f32(self) -> np.float32: + return F32(self.scale) + + @property + def mask_hw(self) -> Tuple[int, int]: + """``(out_h, out_w)`` of Autoware's mask (``getMaskImageGpu``): the un-letterboxed region.""" + return self.resized_hw + + def to_dict(self) -> Dict[str, Any]: + return {"src_hw": list(self.src_hw), "dst_hw": list(self.dst_hw), "scale": self.scale, + "scale_f32_hex": F32(self.scale).tobytes()[::-1].hex(), "resized_hw": list(self.resized_hw), + "mask_hw": list(self.mask_hw), "pad_value": self.pad_value, + "letterbox": "top-left (padding at the bottom / right)"} + + +def yolox_letterbox_geometry(src_h: int, src_w: int, dst_h: int = 960, dst_w: int = 960, + pad_value: int = YOLOX_PAD_VALUE) -> LetterboxGeometry: + """Letterbox geometry of ``preprocess.cu:121-123`` (float32 scale, int truncation).""" + src_h, src_w, dst_h, dst_w = int(src_h), int(src_w), int(dst_h), int(dst_w) + if src_h < 2 or src_w < 2: + raise ValueError(f"the Autoware resize needs a source of at least 2x2 pixels, got {src_w}x{src_h}") + if dst_h < 1 or dst_w < 1: + raise ValueError(f"bad destination size {dst_w}x{dst_h}") + scale = min(F32(dst_w) / F32(src_w), F32(dst_h) / F32(src_h)) # float32 divisions + scale = F32(scale) + r_h = int(F32(scale * F32(src_h))) + r_w = int(F32(scale * F32(src_w))) + return LetterboxGeometry((src_h, src_w), (dst_h, dst_w), float(scale), (min(r_h, dst_h), min(r_w, dst_w)), + int(pad_value)) + + +def _axis_table(n_dst: int, n_src: int, k: np.float32) -> Tuple[np.ndarray, np.ndarray]: + """Source index and weight of every destination row (or column): ``preprocess.cu:48-51, 77-88``.""" + c = (k * (np.arange(n_dst) + 0.5).astype(F32)).astype(F32) # centroid = scale * (float)(h + 0.5) + rc = lroundf(c) + idx = rc - 1 + idx = np.where(idx < 0, 0, idx) + idx = np.where(idx >= n_src - 1, n_src - 2, idx) + weight = ((F32(1) + rc.astype(F32)).astype(F32) - c).astype(F32) # 1 + lroundf(c) - c (float32) + weight = (weight / F32(2)).astype(F32) + return idx.astype(np.int64), weight + + +@dataclass(frozen=True) +class YoloxLetterboxLUT: + """The per-size tables of the YOLOX letterbox: source row / column of every destination row / column and the + float32 weights. Build with :func:`yolox_letterbox_lut` (cached); apply with :meth:`apply`.""" + + geometry: LetterboxGeometry + rows: np.ndarray # (r_h,) int64: source row hi (hi + 1 is the second tap) + row_w: np.ndarray # (r_h,) float32: vertical weight + cols: np.ndarray # (r_w,) int64: source column wi + col_w: np.ndarray # (r_w,) float32: horizontal weight + k: float # the kernel's float32 ``scale`` argument (1 / scale) + + def apply(self, image: np.ndarray, *, out: Optional[np.ndarray] = None, chunk_rows: int = 96) -> np.ndarray: + """Letterbox one BGR uint8 image ``(src_h, src_w, 3)`` (any channel count) -> uint8 ``(dst_h, dst_w, C)`` + HWC, channel order unchanged. ``out`` (uint8, that shape) is written in place when given.""" + g = self.geometry + img = np.asarray(image) + if img.ndim != 3 or img.dtype != np.uint8 or tuple(img.shape[:2]) != g.src_hw: + raise ValueError(f"expected a uint8 ({g.src_hw[0]}, {g.src_hw[1]}, C) image, got {img.dtype} {img.shape}") + dst_h, dst_w = g.dst_hw + r_h, r_w = g.resized_hw + shape = (dst_h, dst_w, img.shape[2]) + if out is None: + out = np.empty(shape, np.uint8) + elif out.shape != shape or out.dtype != np.uint8: + raise ValueError(f"out must be uint8 {shape}, got {out.dtype} {out.shape}") + out[r_h:] = g.pad_value + out[:r_h, r_w:] = g.pad_value + if r_h == 0 or r_w == 0: + return out + tw = self.col_w.astype(np.float64)[None, :, None] # exact float32 values + cols0, cols1 = self.cols, self.cols + 1 + for start in range(0, r_h, max(1, int(chunk_rows))): + stop = min(r_h, start + int(chunk_rows)) + hi = self.rows[start:stop] + # horizontal pass on the source rows this chunk needs (hi and hi + 1), each row once + need = np.unique(np.concatenate([hi, hi + 1])) + src = img[need] # (R, src_w, C) uint8 + a = src[:, cols0].astype(np.float64) # (R, r_w, C) + b = src[:, cols1].astype(np.float64) + inner = (a - tw * a).astype(F32) # fmaf(-t, a, a) + horiz = (tw * b + inner).astype(F32) # fmaf(t, b, inner) + horiz = np.trunc(horiz).astype(np.float64) # lerp1d(int a, int b, ...): truncation + pos0 = np.searchsorted(need, hi) + pos1 = np.searchsorted(need, hi + 1) + a1, b1 = horiz[pos0], horiz[pos1] # (rows, r_w, C) + th = self.row_w[start:stop].astype(np.float64)[:, None, None] + inner = (a1 - th * a1).astype(F32) + r = (th * b1 + inner).astype(F32) # second lerp1d, float32 + out[start:stop, :r_w] = np.floor(r.astype(np.float64) + 0.5) # lroundf (r >= 0) + return out + + +_YOLOX_LUTS = LUTCache(maxsize=8) + + +def yolox_letterbox_lut(src_h: int, src_w: int, dst_h: int = 960, dst_w: int = 960, + pad_value: int = YOLOX_PAD_VALUE) -> YoloxLetterboxLUT: + """The (cached) tables for one source size.""" + + def make() -> YoloxLetterboxLUT: + g = yolox_letterbox_geometry(src_h, src_w, dst_h, dst_w, pad_value) + k = F32(1.0 / np.float64(g.scale_f32)) # 1.0 / scale in double, passed as a float argument + rows, row_w = _axis_table(dst_h, g.src_hw[0], k) + cols, col_w = _axis_table(dst_w, g.src_hw[1], k) + r_h, r_w = g.resized_hw + return YoloxLetterboxLUT(g, rows[:r_h].copy(), row_w[:r_h].copy(), cols[:r_w].copy(), col_w[:r_w].copy(), + float(k)) + + return _YOLOX_LUTS.get(("yolox", int(src_h), int(src_w), int(dst_h), int(dst_w), int(pad_value)), make) + + +def yolox_letterbox(image: np.ndarray, dst_hw: Tuple[int, int] = (960, 960), *, pad_value: int = YOLOX_PAD_VALUE, + layout: str = "hwc", out: Optional[np.ndarray] = None) -> Tuple[np.ndarray, LetterboxGeometry]: + """Autoware YOLOX letterbox of one BGR8 image ``(H, W, 3)`` -> ``(uint8 image, geometry)``. + + ``layout``: ``"hwc"`` -> ``(dst_h, dst_w, 3)``; ``"nchw"`` -> ``(1, 3, dst_h, dst_w)`` (the ONNX ``images`` + layout; ``.astype(np.float32)`` is exactly the network input). The channel order is unchanged (Autoware feeds + BGR).""" + img = np.asarray(image) + lut = yolox_letterbox_lut(img.shape[0], img.shape[1], int(dst_hw[0]), int(dst_hw[1]), pad_value) + if layout == "hwc": + return lut.apply(img, out=out), lut.geometry + if layout == "nchw": + hwc = lut.apply(img) + res = np.ascontiguousarray(hwc.transpose(2, 0, 1)[None]) + if out is not None: + out[...] = res + return out, lut.geometry + return res, lut.geometry + raise ValueError(f"layout must be 'hwc' or 'nchw', got {layout!r}") + + +# ------------------------------------------------------------------------------------------- BEVDet nearest crop + +# Per-plane statistics of the BEVDet Preprocess plugin, applied to the B, G, R planes in this order: training loaded +# images as RGB (PIL) and its mmlabNormalize swapped them to BGR before applying the "RGB" ImageNet statistics, and +# the Autoware node feeds BGR planes with the same numbers (research/bevdet/SPEC.md section 3.1). +BEVDET_MEAN = (123.675, 116.28, 103.53) +BEVDET_STD = (58.395, 57.12, 57.375) + + +def _roundf(x: Any) -> np.ndarray: + """C ``roundf`` (half away from zero) of float32 values -> int64.""" + x = np.asarray(x, F32) + return (np.sign(x) * np.floor(np.abs(x) + F32(0.5))).astype(np.int64) + + +@dataclass(frozen=True) +class NearestCropLUT: + """The ``bevdet::Preprocess`` gather: output row ``i`` / column ``j`` reads source row ``rows[i]`` / column + ``cols[j]`` of the (already 1600x900) image. Build with :func:`bevdet_nearest_lut` (cached); apply with + :meth:`apply` (one HWC image) or :meth:`apply_planes` (planar ``[..., C, H, W]``).""" + + src_hw: Tuple[int, int] + dst_hw: Tuple[int, int] + crop_hw: Tuple[int, int] + resize: float # exact float32 ratio dst_w / src_w, stored as a Python float + rows: np.ndarray # (dst_h,) int64 + cols: np.ndarray # (dst_w,) int64 + + def apply(self, image: np.ndarray, *, channels: str = "rgb", out: Optional[np.ndarray] = None) -> np.ndarray: + """One ``(src_h, src_w, 3)`` uint8 image -> ``(3, dst_h, dst_w)`` uint8 B, G, R planes. ``channels`` is the + input's order: ``"rgb"`` (PIL / ``ttaw.io.load_image``) or ``"bgr"`` (OpenCV, cv_bridge ``bgr8``).""" + img = np.asarray(image) + if img.ndim != 3 or img.shape[2] != 3 or img.dtype != np.uint8 or tuple(img.shape[:2]) != self.src_hw: + raise ValueError(f"expected a uint8 ({self.src_hw[0]}, {self.src_hw[1]}, 3) image, got {img.dtype} " + f"{img.shape} (resize it to the source size first, like the node's cv::resize)") + if channels not in ("rgb", "bgr"): + raise ValueError(f"channels must be 'rgb' or 'bgr', got {channels!r}") + sub = img[self.rows][:, self.cols] # (dst_h, dst_w, 3) + order = [2, 1, 0] if channels == "rgb" else [0, 1, 2] # -> B, G, R + res = sub[:, :, order].transpose(2, 0, 1) + if out is None: + return np.ascontiguousarray(res) + shape = (3,) + tuple(self.dst_hw) + if out.shape != shape or out.dtype != np.uint8: + raise ValueError(f"out must be uint8 {shape}, got {out.dtype} {out.shape}") + out[...] = res + return out + + def apply_planes(self, planes: np.ndarray) -> np.ndarray: + """Planar ``[..., C, src_h, src_w]`` (any dtype) -> ``[..., C, dst_h, dst_w]``.""" + p = np.asarray(planes) + if tuple(p.shape[-2:]) != self.src_hw: + raise ValueError(f"expected planes [..., {self.src_hw[0]}, {self.src_hw[1]}], got {p.shape}") + return np.ascontiguousarray(p[..., self.rows, :][..., self.cols]) + + +_BEVDET_LUTS = LUTCache(maxsize=4) + + +def bevdet_nearest_lut(src_hw: Tuple[int, int] = (900, 1600), dst_hw: Tuple[int, int] = (256, 704), + crop_hw: Tuple[int, int] = (140, 0)) -> NearestCropLUT: + """The (cached) gather of the BEVDet Preprocess plugin: ``r = (float)dst_w / src_w`` (0.44f for the deployed + config, also the ONNX attribute ``resize_radio``), ``rows[i] = roundf(i / r + crop_h / r)``, + ``cols[j] = roundf(j / r + crop_w / r)``, every quantity float32 (deployed: rows 318, 320, 323, ..., 898 and + columns 0, 2, 5, 7, ..., 1598). ``crop_hw`` is the crop offset in RESIZED pixels (the yaml ``crop``, (h, w)).""" + src_h, src_w = int(src_hw[0]), int(src_hw[1]) + dst_h, dst_w = int(dst_hw[0]), int(dst_hw[1]) + crop_h, crop_w = int(crop_hw[0]), int(crop_hw[1]) + + def make() -> NearestCropLUT: + r = F32(F32(dst_w) / F32(src_w)) + off_h = F32(F32(crop_h) / r) + off_w = F32(F32(crop_w) / r) + rows = _roundf((np.arange(dst_h, dtype=F32) / r + off_h).astype(F32)) + cols = _roundf((np.arange(dst_w, dtype=F32) / r + off_w).astype(F32)) + if rows.min() < 0 or rows.max() >= src_h or cols.min() < 0 or cols.max() >= src_w: + raise ValueError(f"crop {crop_hw} with ratio {float(r)} reads outside the {src_w}x{src_h} source") + return NearestCropLUT((src_h, src_w), (dst_h, dst_w), (crop_h, crop_w), float(r), rows, cols) + + return _BEVDET_LUTS.get(("bevdet", src_h, src_w, dst_h, dst_w, crop_h, crop_w), make) + + +def bevdet_nearest_crop(image: np.ndarray, *, channels: str = "rgb", src_hw: Tuple[int, int] = (900, 1600), + dst_hw: Tuple[int, int] = (256, 704), crop_hw: Tuple[int, int] = (140, 0), + out: Optional[np.ndarray] = None) -> np.ndarray: + """One camera image at the source size (1600x900) -> its ``(3, 256, 704)`` uint8 B, G, R crop: exactly the + samples the BEVDet network sees before ``bevdet_normalize``.""" + return bevdet_nearest_lut(src_hw, dst_hw, crop_hw).apply(image, channels=channels, out=out) + + +def bevdet_normalize(crop: np.ndarray, mean: Any = BEVDET_MEAN, std: Any = BEVDET_STD) -> np.ndarray: + """``(x - mean[c]) / std[c]`` in float32 on B, G, R planes ``[..., 3, H, W]`` (the plugin's fp32 path; its fp16 + mode computes in ``__half``).""" + m = np.asarray(mean, F32).reshape(3, 1, 1) + s = np.asarray(std, F32).reshape(3, 1, 1) + return ((np.asarray(crop).astype(F32) - m) / s).astype(F32) + + +# ------------------------------------------------------------------------------------------- BEVFormer + +# autoware_tensorrt_bevformer config/bevformer.param.yaml data_params (= the BEVFormer training img_norm_cfg, +# DerryHub configs/bevformer/bevformer_small.py:19): caffe-style BGR means, std 1, to_rgb false +BEVFORMER_MEAN_BGR = (103.530, 116.280, 123.675) +BEVFORMER_STD = (1.0, 1.0, 1.0) + + +@dataclass(frozen=True) +class LinearResizeLUT: + """OpenCV ``cv::resize(..., INTER_LINEAR)`` of float32 data for one (source, destination) size pair: row / column + pairs and the float32 weight of the second tap (``resize.cpp`` coefficient loop; module docstring). Build with + :func:`opencv_linear_lut` (cached); apply with :meth:`apply` (HWC) or :meth:`apply_planes` (``[..., H, W]``).""" + + src_hw: Tuple[int, int] + dst_hw: Tuple[int, int] + rows0: np.ndarray # (dst_h,) int64 + rows1: np.ndarray # (dst_h,) int64 (clamped to the last row) + wy: np.ndarray # (dst_h,) float32 weight of rows1 + cols0: np.ndarray # (dst_w,) int64 + cols1: np.ndarray + wx: np.ndarray # (dst_w,) float32 weight of cols1 + + @staticmethod + def _lerp(x0: np.ndarray, x1: np.ndarray, w: Any, out: Optional[np.ndarray] = None) -> np.ndarray: + """``fma(x1 - x0, w, x0)`` in float32: the difference rounded once, the fused product-sum rounded once + (float64 holds ``d * w + x0`` exactly for weights that are multiples of 1/8). ``x1`` is overwritten.""" + d = np.subtract(x1, x0, out=x1) # float32: one rounding + t = d.astype(np.float64) + t *= w + t += x0 + if out is None: + return t.astype(F32) + out[...] = t # float64 -> float32: one rounding + return out + + @staticmethod + def _period(i0: np.ndarray, i1: np.ndarray, w: np.ndarray, n_src: int) -> Optional[Tuple[int, int]]: + """``(p_in, p_out)`` when the taps repeat every ``p_out`` outputs shifted by ``p_in`` inputs, both taps of + an output lie in one input block and no border clamp occurs (x0.8: 5 inputs -> 4 outputs), else None.""" + n_dst = len(i0) + for p_out in range(1, min(n_dst, 64) + 1): + if n_dst % p_out or (n_src * p_out) % n_dst: + continue + p_in = n_src * p_out // n_dst + base = (np.arange(n_dst) // p_out) * p_in + reps = n_dst // p_out + if (np.array_equal(i0 - base, np.tile(i0[:p_out], reps)) and np.array_equal(i1, i0 + 1) + and np.array_equal(w, np.tile(w[:p_out], reps)) and int(i1[:p_out].max()) < p_in): + return p_in, p_out + return None + + def _pass(self, p: np.ndarray, axis: int, i0: np.ndarray, i1: np.ndarray, w: np.ndarray) -> np.ndarray: + """One interpolation pass along ``axis`` (-1: columns, -2: rows) of a contiguous ``p`` [..., H, W]: strided + block slices when the taps are periodic (no transposes), else gathers.""" + period = self._period(i0, i1, w, p.shape[axis]) + if period is None: + wb = w.astype(np.float64) if axis == -1 else w.astype(np.float64)[:, None] + return self._lerp(np.take(p, i0, axis=axis), np.take(p, i1, axis=axis), wb) + p_in, p_out = period + nb = p.shape[axis] // p_in + taps0 = [int(t) for t in i0[:p_out]] + run = taps0 == list(range(taps0[0], taps0[0] + p_out)) # x0.8: taps (0..3) and (1..4) of 5 + wj = np.asarray(w[:p_out], np.float64) + if axis == -1: + blk = p.reshape(p.shape[:-1] + (nb, p_in)) # [..., H, nb, p_in] (a view) + if run: + a = taps0[0] + res = self._lerp(blk[..., a:a + p_out], np.array(blk[..., a + 1:a + 1 + p_out]), wj) + else: + res = np.empty(p.shape[:-1] + (nb, p_out), F32) + for j in range(p_out): + self._lerp(blk[..., taps0[j]], np.array(blk[..., taps0[j] + 1]), wj[j], out=res[..., j]) + return res.reshape(p.shape[:-1] + (nb * p_out,)) + blk = p.reshape(p.shape[:-2] + (nb, p_in, p.shape[-1])) # [..., nb, p_in, W] (a view) + if run: + a = taps0[0] + res = self._lerp(blk[..., a:a + p_out, :], np.array(blk[..., a + 1:a + 1 + p_out, :]), wj[:, None]) + else: + res = np.empty(p.shape[:-2] + (nb, p_out, p.shape[-1]), F32) + for j in range(p_out): + self._lerp(blk[..., taps0[j], :], np.array(blk[..., taps0[j] + 1, :]), wj[j], out=res[..., j, :]) + return res.reshape(p.shape[:-2] + (nb * p_out, p.shape[-1])) + + def apply_planes(self, planes: np.ndarray, *, out: Optional[np.ndarray] = None) -> np.ndarray: + """Planar float32 ``[..., src_h, src_w]`` -> ``[..., dst_h, dst_w]`` (horizontal pass first, as OpenCV).""" + p = np.ascontiguousarray(planes) + if p.dtype != F32 or tuple(p.shape[-2:]) != self.src_hw: + raise ValueError(f"expected float32 planes [..., {self.src_hw[0]}, {self.src_hw[1]}], got {p.dtype} " + f"{p.shape}") + shape = p.shape[:-2] + tuple(self.dst_hw) + if out is not None and (out.shape != shape or out.dtype != F32): + raise ValueError(f"out must be float32 {shape}, got {out.dtype} {out.shape}") + h = self._pass(p, -1, self.cols0, self.cols1, self.wx) # [..., src_h, dst_w] + v = self._pass(h, -2, self.rows0, self.rows1, self.wy) # [..., dst_h, dst_w] + if out is None: + return v + out[...] = v + return out + + def apply(self, image: np.ndarray, *, out: Optional[np.ndarray] = None) -> np.ndarray: + """``(src_h, src_w[, C])`` float32 -> ``(dst_h, dst_w[, C])`` float32 (horizontal pass first, as OpenCV).""" + img = np.asarray(image) + if img.dtype != F32 or tuple(img.shape[:2]) != self.src_hw: + raise ValueError(f"expected a float32 {self.src_hw} image, got {img.dtype} {img.shape}") + planes = np.moveaxis(img, (0, 1), (-2, -1)) # [C..., H, W] + res = np.moveaxis(self.apply_planes(planes), (-2, -1), (0, 1)) + if out is None: + return np.ascontiguousarray(res) + if out.shape != res.shape or out.dtype != F32: + raise ValueError(f"out must be float32 {res.shape}, got {out.dtype} {out.shape}") + out[...] = res + return out + + +def _linear_axis(n_src: int, n_dst: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """``resize.cpp`` INTER_LINEAR coefficients of one axis: (first tap, second tap, float32 weight of the second).""" + scale = 1.0 / (float(n_dst) / float(n_src)) # inv_scale = (double)dst / src; 1. / inv_scale + f = ((np.arange(n_dst, dtype=np.float64) + 0.5) * scale - 0.5).astype(F32) + s = np.floor(f).astype(np.int64) + w = (f - s.astype(F32)).astype(F32) + low = s < 0 + s[low], w[low] = 0, F32(0) + high = s >= n_src - 1 + s[high], w[high] = n_src - 1, F32(0) + return s, np.minimum(s + 1, n_src - 1), w + + +def _eighths(w: np.ndarray) -> bool: + x = np.asarray(w, np.float64) * 8.0 + return bool(np.all(x == np.round(x))) + + +_LINEAR_LUTS = LUTCache(maxsize=8) + + +def opencv_linear_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int], *, strict: bool = True) -> LinearResizeLUT: + """The (cached) float INTER_LINEAR tables for one size pair. + + Verified bit-exact against OpenCV 4.8.1 / 4.11.0 for every x0.8 down-scale (1600x900 -> 1280x720, the deployed + one; 1920x1080, 160x90, 80x45), whose weights are multiples of 1/8. OpenCV computes the coefficients of other + scales differently (up-scales and non-dyadic down-scales differ by up to ~1e-4 relative), so ``strict=True`` + refuses a geometry whose weights are not multiples of 1/8; ``strict=False`` gives the textbook coefficients for + those (an approximation, not an emulation). Exact 2x down-scales are always refused: OpenCV switches + INTER_LINEAR to INTER_AREA there (``resize.cpp``).""" + src_h, src_w = int(src_hw[0]), int(src_hw[1]) + dst_h, dst_w = int(dst_hw[0]), int(dst_hw[1]) + if min(src_h, src_w, dst_h, dst_w) < 1: + raise ValueError(f"empty resize {src_hw} -> {dst_hw}") + if src_h == 2 * dst_h and src_w == 2 * dst_w: + raise ValueError("an exact 2x down-scale runs OpenCV's INTER_AREA path, which is not emulated") + + def make() -> LinearResizeLUT: + r0, r1, wy = _linear_axis(src_h, dst_h) + c0, c1, wx = _linear_axis(src_w, dst_w) + return LinearResizeLUT((src_h, src_w), (dst_h, dst_w), r0, r1, wy, c0, c1, wx) + + lut = _LINEAR_LUTS.get(("linear_f32", src_h, src_w, dst_h, dst_w), make) + if strict and not (_eighths(lut.wx) and _eighths(lut.wy)): + raise ValueError(f"{src_hw} -> {dst_hw} is not a geometry this emulation reproduces bit for bit (weights must " + "be multiples of 1/8, e.g. a x0.8 down-scale); pass strict=False for an approximation") + return lut + + +def opencv_resize_linear_f32(image: np.ndarray, dst_hw: Tuple[int, int], *, strict: bool = True) -> np.ndarray: + """``cv2.resize(image, (dst_w, dst_h))`` (INTER_LINEAR) of a float32 ``(H, W[, C])`` image: bit-exact for the + geometries :func:`opencv_linear_lut` accepts with ``strict=True``.""" + img = np.asarray(image) + return opencv_linear_lut(img.shape[:2], dst_hw, strict=strict).apply(img) + + +def bevformer_input_geometry(src_hw: Tuple[int, int] = (900, 1600), scale: float = 0.8, + pad_divisor: int = 32) -> Dict[str, Tuple[int, int]]: + """Sizes of the node's pipeline for one source size: ``resized_hw`` = ``(int)(src * scale)`` in float32 + (``augmentation_transforms.cpp:104-105``) and ``padded_hw`` (``PadMultiViewImages``, size divisor). Deployed: + (900, 1600) -> (720, 1280) -> (736, 1280); the network normalises its UV by the padded size.""" + s = F32(scale) + h = int(F32(F32(int(src_hw[0])) * s)) + w = int(F32(F32(int(src_hw[1])) * s)) + d = int(pad_divisor) + if d < 1: + raise ValueError("pad_divisor must be >= 1") + return {"src_hw": (int(src_hw[0]), int(src_hw[1])), "resized_hw": (h, w), + "padded_hw": ((h + d - 1) // d * d, (w + d - 1) // d * d)} + + +def _normalize_planes(image: np.ndarray, mean: Any, std: Any) -> np.ndarray: + """BGR uint8 ``(H, W, 3)`` -> float32 planes ``(3, H, W)`` of ``(x - mean_c) / std_c`` as OpenCV evaluates it: + ``convertTo`` with float ``alpha = 1/std``, ``beta = -mean/std`` and one fused multiply-add (exact in float64: + an 8-bit value times a 24-bit alpha plus beta); for alpha == 1 that is the float addition ``x + beta``.""" + img = np.asarray(image) + if img.ndim != 3 or img.shape[2] != 3 or img.dtype != np.uint8: + raise ValueError(f"expected a uint8 (H, W, 3) BGR image, got {img.dtype} {img.shape}") + m = np.asarray(mean, np.float64).reshape(3) + sd = np.asarray(std, np.float64).reshape(3) + alpha = (1.0 / sd).astype(F32) + beta = (-m / sd).astype(F32) + planes = img.transpose(2, 0, 1).astype(F32) # exact: integers 0..255 + for c in range(3): + if alpha[c] == 1.0: + planes[c] += beta[c] # float32, one rounding + else: + planes[c] = planes[c].astype(np.float64) * float(alpha[c]) + float(beta[c]) + return planes + + +def bevformer_normalize(image: np.ndarray, mean: Any = BEVFORMER_MEAN_BGR, std: Any = BEVFORMER_STD) -> np.ndarray: + """One BGR uint8 ``(H, W, 3)`` image -> ``(x - mean_c) / std_c`` as float32 ``(H, W, 3)``, as OpenCV evaluates it + (``convertTo`` with float ``alpha = 1/std``, ``beta = -mean/std``, one fused multiply-add; std 1 deployed).""" + return np.ascontiguousarray(_normalize_planes(image, mean, std).transpose(1, 2, 0)) + + +def bevformer_preprocess(images: Any, *, mean: Any = BEVFORMER_MEAN_BGR, std: Any = BEVFORMER_STD, + scale: float = 0.8, pad_divisor: int = 32, out: Optional[np.ndarray] = None, + workers: int = 1) -> np.ndarray: + """N BGR uint8 camera images (a sequence of ``(H, W, 3)`` or an array ``[N, H, W, 3]``, one source size; the node + feeds 1600x900 after its own ``cv::resize``) -> the network input ``[N, 3, Hp, Wp]`` float32: normalise, resize + x``scale`` (OpenCV float INTER_LINEAR), zero-pad bottom / right to ``pad_divisor``, CHW (B, G, R planes, as + ``to_rgb`` is false). Deployed: ``[6, 3, 736, 1280]`` (the ONNX input ``image`` is this with a leading 1). + ``workers`` > 1 processes the cameras in that many threads (numpy releases the GIL in its loops; about 70 ms per + 1600x900 camera single-threaded on the shared host).""" + imgs = [np.asarray(im) for im in images] + if not imgs: + raise ValueError("no images") + hw = tuple(imgs[0].shape[:2]) + if any(tuple(im.shape[:2]) != hw for im in imgs): + raise ValueError("every camera image must have the same size (the node resizes them to 1600x900 first)") + geo = bevformer_input_geometry(hw, scale, pad_divisor) + (rh, rw), (ph, pw) = geo["resized_hw"], geo["padded_hw"] + lut = opencv_linear_lut(hw, (rh, rw)) # strict: the verified x0.8 geometries + shape = (len(imgs), 3, ph, pw) + if out is None: + out = np.zeros(shape, F32) + else: + if out.shape != shape or out.dtype != F32: + raise ValueError(f"out must be float32 {shape}, got {out.dtype} {out.shape}") + out[:, :, rh:, :] = 0.0 + out[:, :, :, rw:] = 0.0 + def one(i: int) -> None: + lut.apply_planes(_normalize_planes(imgs[i], mean, std), out=out[i, :, :rh, :rw]) + + if int(workers) > 1 and len(imgs) > 1: + from concurrent.futures import ThreadPoolExecutor + + with ThreadPoolExecutor(max_workers=min(int(workers), len(imgs))) as pool: + list(pool.map(one, range(len(imgs)))) + else: + for i in range(len(imgs)): + one(i) + return out + + +# name -> (function, one-line description): the presets this module implements +PRESETS: Dict[str, Tuple[Callable[..., Any], str]] = { + "yolox_letterbox": (yolox_letterbox, "autoware_tensorrt_yolox preprocess.cu:43-129 letterbox (pad 114, BGR)"), + "bevdet_nearest_crop": (bevdet_nearest_crop, "bevdet::Preprocess nearest resize 0.44 + crop (140, 0) of the " + "1600x900 image -> B, G, R uint8 planes"), + "bevformer_preprocess": (bevformer_preprocess, "autoware_tensorrt_bevformer normalise (BGR mean, std 1) -> " + "cv::resize x0.8 INTER_LINEAR on float -> zero pad to /32, CHW"), +} diff --git a/code/tt_frnet/ttaw/image_area.py b/code/tt_frnet/ttaw/image_area.py new file mode 100644 index 0000000000000000000000000000000000000000..a3e8385ae18e0a1bf7b9cdea0ddce4b660555a14 --- /dev/null +++ b/code/tt_frnet/ttaw/image_area.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C14 image pre-processing, METEOR preset: a bit-exact host emulation of OpenCV's ``cv::resize(..., INTER_AREA)`` on +uint8 images (down-scaling), driven by cached per-size tables. numpy only; no ttnn, no torch, no OpenCV. + +METEOR's runtimes feed every camera as RGB uint8 768x432 and resize larger frames with ``cv2.resize(img, (768, 432), +interpolation=cv2.INTER_AREA)`` (tier4/METEOR ``hf/onnx_smoke_test.py:42-44``; the training extraction +``bevlane/extract_gt.py:114-130`` resized the raw, unrectified JPEGs the same way; research/meteor/SPEC.md section 3), +anisotropically when the aspect ratio differs (2880x1860 -> 768x432). :func:`meteor_resize` reproduces it; the +intrinsics follow with :func:`scale_intrinsics` (row 0 x 768 / w0, row 1 x 432 / h0, ``extract_gt.py:128-130``). + +What OpenCV computes (``modules/imgproc/src/resize.cpp``, identical in 4.8.1 and 4.11.0; the IPP path declines +INTER_AREA because it is not bit-exact, ``ipp_resize``: ``ippSuper`` needs ``useIPP_NotExact``): + +- ``inv_scale = (double)dst / src`` per axis, ``scale = 1.0 / inv_scale`` (double). +- **Integer factors** (``|scale - (int)scale| < DBL_EPSILON`` on both axes; ``resizeAreaFast_``): every output pixel is + the integer sum ``s`` of its ``fx * fy`` source pixels, then ``saturate_cast(s * (1.f / area))``: one float32 + product, rounded half to even (``cvRound``). Exception, the 2x2 ``fast_mode`` of ``ResizeAreaFastVec`` for 1-, 3- + and 4-channel images: ``(s + 2) >> 2`` (half up). Measured on both OpenCV builds (2-channel images take the float + path; 3x3 and 5x5 factors the float path). +- **Other factors** (``resizeArea_``): ``computeResizeAreaTab`` lists, per output column (and row), the + covered source pixels with float32 weights computed in double (``(sx1 - fsx1) / cellWidth``, ``1.0 / cellWidth``, + ``min(min(fsx2 - sx2, 1), cellWidth) / cellWidth``; partial pixels below 1e-3 skipped). Each source row is first + reduced horizontally, ``buf[dx] += S[sx] * alpha`` in table order (float32 multiply, then float32 add: no FMA, the + generic code is compiled for the SSE baseline), then the rows are combined, ``sum[dx] += beta * buf[dx]`` in table + order, and ``saturate_cast(sum)`` rounds half to even and clamps to [0, 255]. + +The emulation replays exactly these float32 operations in the same order, vectorised over the image (zero-weight +padding of the per-pixel tap lists adds exact zeros). Verified against cv2 4.8.1 (tt-env) and 4.11.0 (research venv) +on random and real images of many sizes (``common/tests/host/test_image_area_host.py``): 0 mismatches. +Up-scaling (any axis with ``src < dst``) runs a different OpenCV path (linear-like fixed-point coefficients) and is +refused: METEOR's cameras are all larger than 768x432. +""" +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Any, Optional, Tuple + +import numpy as np + +from .image import LUTCache + +__all__ = ["AreaResizeLUT", "opencv_area_lut", "opencv_resize_area_u8", "meteor_resize", "scale_intrinsics", + "METEOR_INPUT_HW"] + +F32 = np.float32 +METEOR_INPUT_HW = (432, 768) # every METEOR camera slot (meteor_v157.param.yaml:11-12) +_DBL_EPSILON = sys.float_info.epsilon + + +def _area_axis(n_src: int, n_dst: int, scale: float) -> Tuple[np.ndarray, np.ndarray]: + """``computeResizeAreaTab`` of one axis as padded per-output tap lists: (src index [n_dst, T], weight [n_dst, T]) + in table order; unused taps have weight 0 and index 0.""" + taps = [] + for dx in range(n_dst): + fsx1 = dx * scale + fsx2 = fsx1 + scale + cell = min(scale, n_src - fsx1) + sx1 = int(np.ceil(fsx1)) + sx2 = int(np.floor(fsx2)) + sx2 = min(sx2, n_src - 1) + sx1 = min(sx1, sx2) + row = [] + if sx1 - fsx1 > 1e-3: + row.append((sx1 - 1, F32((sx1 - fsx1) / cell))) + for sx in range(sx1, sx2): + row.append((sx, F32(1.0 / cell))) + if fsx2 - sx2 > 1e-3: + row.append((sx2, F32(min(min(fsx2 - sx2, 1.0), cell) / cell))) + taps.append(row) + width = max(len(r) for r in taps) + idx = np.zeros((n_dst, width), np.int64) + w = np.zeros((n_dst, width), F32) + for i, row in enumerate(taps): + for k, (s, a) in enumerate(row): + idx[i, k] = s + w[i, k] = a + return idx, w + + +@dataclass(frozen=True) +class AreaResizeLUT: + """The cached tables of one (source size, destination size) pair; :meth:`apply` resizes one uint8 image.""" + + src_hw: Tuple[int, int] + dst_hw: Tuple[int, int] + fast: Optional[Tuple[int, int]] # integer factors (fy, fx) of the resizeAreaFast_ path, else None + rows: Optional[np.ndarray] # [dst_h, Ty] source rows (general path) + row_w: Optional[np.ndarray] # [dst_h, Ty] float32 beta + cols: Optional[np.ndarray] # [dst_w, Tx] source columns + col_w: Optional[np.ndarray] # [dst_w, Tx] float32 alpha + + def apply(self, image: np.ndarray, *, out: Optional[np.ndarray] = None) -> np.ndarray: + img = np.asarray(image) + if img.dtype != np.uint8 or img.ndim not in (2, 3) or tuple(img.shape[:2]) != self.src_hw: + raise ValueError(f"expected a uint8 {self.src_hw} image (HxW or HxWxC), got {img.dtype} {img.shape}") + squeeze = img.ndim == 2 + x = img[:, :, None] if squeeze else img + dh, dw = self.dst_hw + if self.fast is not None: + fy, fx = self.fast + s = x[:dh * fy, :dw * fx].astype(np.int64).reshape(dh, fy, dw, fx, x.shape[2]).sum(axis=(1, 3)) + if (fy, fx) == (2, 2) and x.shape[2] in (1, 3, 4): + res = (s + 2) >> 2 # ResizeAreaFastVec fast_mode: half up + else: + res = np.rint(s.astype(F32) * F32(1.0 / (fx * fy))) # float32 product, half to even + else: + # horizontal pass per source row: buf[dx] = sum_k S[cols[dx, k]] * col_w[dx, k] (float32, table order) + buf = np.zeros((x.shape[0], dw, x.shape[2]), F32) + src = x.astype(F32) + for k in range(self.cols.shape[1]): + buf += src[:, self.cols[:, k], :] * self.col_w[None, :, k, None] + # vertical pass: sum[dy] = sum_j beta[dy, j] * buf[rows[dy, j]] (float32, table order) + acc = np.zeros((dh, dw, x.shape[2]), F32) + for j in range(self.rows.shape[1]): + acc += self.row_w[:, j, None, None] * buf[self.rows[:, j]] + res = np.rint(acc) # cvRound: half to even + res = np.clip(res, 0, 255).astype(np.uint8) + if squeeze: + res = res[:, :, 0] + if out is not None: + out[...] = res + return out + return res + + +_AREA_LUTS = LUTCache(maxsize=16) # 8 cameras of up to two sizes each + + +def opencv_area_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int]) -> AreaResizeLUT: + """Tables of ``cv::resize(INTER_AREA)`` from ``src_hw`` down to ``dst_hw`` (cached per size pair).""" + src_hw = (int(src_hw[0]), int(src_hw[1])) + dst_hw = (int(dst_hw[0]), int(dst_hw[1])) + return _AREA_LUTS.get((src_hw, dst_hw), lambda: _build_area_lut(src_hw, dst_hw)) + + +def _build_area_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int]) -> AreaResizeLUT: + (sh, sw), (dh, dw) = src_hw, dst_hw + if min(sh, sw, dh, dw) < 1: + raise ValueError(f"bad sizes {src_hw} -> {dst_hw}") + if dh > sh or dw > sw: + raise ValueError(f"INTER_AREA up-scaling {src_hw} -> {dst_hw} is not emulated (OpenCV uses another path)") + scale_y = 1.0 / (float(dh) / sh) + scale_x = 1.0 / (float(dw) / sw) + iy, ix = int(round(scale_y)), int(round(scale_x)) + if abs(scale_x - ix) < _DBL_EPSILON and abs(scale_y - iy) < _DBL_EPSILON: + return AreaResizeLUT(src_hw, dst_hw, (iy, ix), None, None, None, None) + rows, row_w = _area_axis(sh, dh, scale_y) + cols, col_w = _area_axis(sw, dw, scale_x) + return AreaResizeLUT(src_hw, dst_hw, None, rows, row_w, cols, col_w) + + +def opencv_resize_area_u8(image: np.ndarray, dst_hw: Tuple[int, int], *, + out: Optional[np.ndarray] = None) -> np.ndarray: + """``cv2.resize(image, (dst_w, dst_h), interpolation=cv2.INTER_AREA)`` for uint8 HxW / HxWxC down-scaling; + the same size returns a copy (OpenCV's ``copyTo`` shortcut).""" + img = np.asarray(image) + if tuple(img.shape[:2]) == tuple(dst_hw): + res = np.array(img, dtype=np.uint8, copy=True) + if out is not None: + out[...] = res + return out + return res + return opencv_area_lut(img.shape[:2], dst_hw).apply(img, out=out) + + +def meteor_resize(image: np.ndarray, *, out: Optional[np.ndarray] = None) -> np.ndarray: + """One camera frame (uint8 HxWx3, any channel order: the resize is per channel) -> 432x768 as METEOR's runtimes + (``INTER_AREA`` when the size differs, a copy otherwise).""" + return opencv_resize_area_u8(image, METEOR_INPUT_HW, out=out) + + +def scale_intrinsics(K: Any, src_hw: Tuple[int, int], dst_hw: Tuple[int, int] = METEOR_INPUT_HW) -> np.ndarray: + """K of a ``src_hw`` image -> K of the resized ``dst_hw`` image: row 0 x dst_w / src_w, row 1 x dst_h / src_h + (``extract_gt.py:128-130``; skew is scaled with row 0 and ignored by the network). float64.""" + k = np.array(K, dtype=np.float64).reshape(3, 3).copy() + k[0] *= dst_hw[1] / float(src_hw[1]) + k[1] *= dst_hw[0] / float(src_hw[0]) + return k diff --git a/code/tt_frnet/ttaw/image_linear.py b/code/tt_frnet/ttaw/image_linear.py new file mode 100644 index 0000000000000000000000000000000000000000..d31a987cd97b797f19977aba4e3c4e95a0e6143e --- /dev/null +++ b/code/tt_frnet/ttaw/image_linear.py @@ -0,0 +1,292 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C14 image pre-processing, SceneSeg preset: bit-exact host emulations of OpenCV's ``cv::resize`` on uint8 images +with ``INTER_LINEAR`` (the default interpolation) and ``INTER_NEAREST``, driven by cached per-size tables, and the +pre-processing of Autoware VisionPilot's SceneSeg node built on them. numpy only; no ttnn, no torch, no OpenCV. + +The SceneSeg node (VisionPilot ``middleware_recipes/common/backends/onnx_runtime_backend.cpp:41-60`` and the identical +``tensorrt_backend.cpp:160-177`` at autowarefoundation/vision_pilot ``04fa3e80``; research/sceneseg/SPEC.md section 3) +takes the ``bgr8`` image of ``cv_bridge``, then:: + + cv::resize(input_image, resized_image, cv::Size(640, 320)); // INTER_LINEAR, aspect ratio not kept + resized_image.convertTo(float_image, CV_32FC3, 1.0 / 255.0); + cv::subtract(float_image, cv::Scalar(0.406, 0.456, 0.485), float_image); + cv::divide(float_image, cv::Scalar(0.225, 0.224, 0.229), float_image); + cv::split(float_image, channels); // planes B, G, R -> NCHW + +and publishes the argmax mask resized back to the source size with ``cv::resize(..., INTER_NEAREST)`` +(``run_model_node.cpp:117-188``). :func:`sceneseg_resize` / :func:`sceneseg_normalize` / :func:`sceneseg_preprocess` +reproduce the first part, :func:`opencv_resize_nearest` the mask resize. + +What OpenCV computes (``modules/imgproc/src/resize.cpp`` and ``core/src/arithm.cpp``; identical in 4.8.1 and 4.11.0 +on this x86-64 host, measured; the IPP path declines both resizes: uint8 ``ippLinear`` and ``ippNearest`` need +``useIPP_NotExact``): + +- ``cv::resize`` with ``dsize == ssize`` copies. ``inv_scale = (double)dst / src`` and ``scale = 1.0 / inv_scale`` + per axis. +- **INTER_LINEAR, exactly 2x down on both axes**: OpenCV switches to ``INTER_AREA`` (``resizeAreaFast_``), which + :func:`.image_area.opencv_resize_area_u8` emulates (2x2 ``fast_mode``: ``(s + 2) >> 2``). +- **INTER_LINEAR otherwise** (``resizeGeneric_`` with ``HResizeLinear`` and the uchar + specialisation of ``VResizeLinear``): per output column ``fx = (float)((dx + 0.5) * scale - 0.5)``, + ``sx = floor(fx)``, ``fx -= sx`` (float); at the borders ``sx < 0 -> sx = 0, fx = 0`` and ``sx >= W - 1 -> + sx = W - 1, fx = 0``; integer weights ``a0 = cvRound((1.f - fx) * 2048)``, ``a1 = cvRound(fx * 2048)`` (round half + to even). Rows take the same coefficients **without** the border fix (the row indices are clipped to [0, H - 1] + instead, ``clip(sy + k, 0, H)``). The horizontal pass is exact integer arithmetic, + ``h = S[sx] * a0 + S[sx + 1] * a1`` (int32), and the vertical pass is the fixed-point + ``(((b0 * (h0 >> 4)) >> 16) + ((b1 * (h1 >> 4)) >> 16) + 2) >> 2`` that the SIMD and the scalar code share. +- **INTER_NEAREST** (``resizeNN``): ``sx = min(floor(dx * (1.0 / inv_scale_x)), W - 1)`` in double, rows alike. +- **Normalisation**: ``convertTo(CV_32F, 1/255)`` is one float32 product ``x * (float)(1/255)``; ``cv::subtract`` + with a ``Scalar`` runs in float32 (``x - (float)mean_c``); ``cv::divide`` with a ``Scalar`` runs in double and + rounds once to float32 (``(float)((double)x / std_c)``), measured with OpenCV's own scalar arithmetic. A float32 + division instead differs by one ulp in about 27 % of the values (the research reference's arithmetic). + +Channel orders (SPEC section 3, PLAN.md D10): ``"bgr"`` is the deployed path: B, G, R planes normalised with the +BGR-ordered ImageNet constants, fed to a network trained on R, G, B. ``"rgb"`` is the training-consistent option: the +same arithmetic on R, G, B planes with the RGB constants (an exact channel permutation of the image first, as a +``cv_bridge`` ``rgb8`` conversion would give). Every physical colour gets the same mean and std in both orders, so +the two inputs are channel permutations of each other. + +Verified bit-exact against cv2 4.8.1 (tt-env) and 4.11.0 (research venv) on random and real images of many sizes +(down- and up-scaling, odd sizes, 1-4 channels), against a scalar transliteration of ``resize.cpp``, and against the +research golden inputs of the SceneSeg port (``common/tests/host/test_image_sceneseg_host.py``). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Tuple + +import numpy as np + +from .image import LUTCache +from .image_area import opencv_resize_area_u8 + +__all__ = [ + "LinearResizeU8LUT", + "opencv_linear_u8_lut", + "opencv_resize_linear_u8", + "NearestResizeLUT", + "opencv_nearest_lut", + "opencv_resize_nearest", + "sceneseg_resize", + "sceneseg_normalize", + "sceneseg_preprocess", + "SCENESEG_INPUT_HW", + "SCENESEG_MEAN_RGB", + "SCENESEG_STD_RGB", + "SCENESEG_CHANNEL_ORDERS", +] + +F32 = np.float32 +INTER_RESIZE_COEF_SCALE = 2048 # 1 << INTER_RESIZE_COEF_BITS (11), resize.cpp +SCENESEG_INPUT_HW = (320, 640) # (H, W) of the ONNX input; fixed by the SceneContext reshape (SPEC 4.2) +SCENESEG_MEAN_RGB = (0.485, 0.456, 0.406) # ImageNet, R G B (the C++ code lists them reversed for B G R planes) +SCENESEG_STD_RGB = (0.229, 0.224, 0.225) +SCENESEG_CHANNEL_ORDERS = ("bgr", "rgb") + + +def _check_hw(hw: Tuple[int, int], what: str) -> Tuple[int, int]: + h, w = (int(v) for v in hw) + if h < 1 or w < 1: + raise ValueError(f"{what} must be positive, got {hw}") + return h, w + + +def _as_image(image: Any, dtype: Optional[type] = np.uint8) -> np.ndarray: + a = np.asarray(image) + if a.ndim not in (2, 3) or a.shape[0] < 1 or a.shape[1] < 1: + raise ValueError(f"expected an HxW or HxWxC image, got shape {a.shape}") + if dtype is not None and a.dtype != dtype: + raise ValueError(f"expected {np.dtype(dtype).name} data, got {a.dtype}") + return a + + +# ----------------------------------------------------------------------------------------------- INTER_LINEAR + +def _linear_axis(n_src: int, n_dst: int, border_fix: bool) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """One axis of ``resize()``'s INTER_LINEAR tables for uint8: source indices (clipped) and integer weights.""" + scale = 1.0 / (n_dst / n_src) # scale = 1. / inv_scale, inv = (double)dst / src + f = ((np.arange(n_dst, dtype=np.float64) + 0.5) * scale - 0.5).astype(F32) + s = np.floor(f).astype(np.int64) # cvFloor(fx) + f = (f - s.astype(F32)).astype(F32) # fx -= sx (float; exact) + if border_fix: # x axis only (the y loop has no such fix) + lo = s < 0 + f[lo], s[lo] = F32(0), 0 + hi = s >= n_src - 1 + f[hi], s[hi] = F32(0), n_src - 1 + one = F32(INTER_RESIZE_COEF_SCALE) + w0 = np.rint((F32(1) - f).astype(F32) * one).astype(np.int32) # saturate_cast(cbuf[k] * 2048): cvRound + w1 = np.rint(f * one).astype(np.int32) + return np.clip(s, 0, n_src - 1), np.clip(s + 1, 0, n_src - 1), w0, w1 + + +@dataclass(frozen=True) +class LinearResizeU8LUT: + """The tables of one ``cv::resize(uint8, INTER_LINEAR)`` geometry. ``mode``: ``"copy"`` (same size), + ``"area2x"`` (exact 2x down on both axes: OpenCV's INTER_AREA fast path) or ``"linear"``.""" + + src_hw: Tuple[int, int] + dst_hw: Tuple[int, int] + mode: str + cols0: Optional[np.ndarray] = None # (w,) source column of the first tap (clipped) + cols1: Optional[np.ndarray] = None # (w,) second tap + a0: Optional[np.ndarray] = None # (w,) int32 weights, a0 + a1 == 2048 up to OpenCV's rounding + a1: Optional[np.ndarray] = None + rows: Optional[np.ndarray] = None # (R,) the source rows the vertical pass reads (sorted, unique) + r0: Optional[np.ndarray] = None # (h,) index into ``rows`` of the first row tap + r1: Optional[np.ndarray] = None # (h,) second row tap + b0: Optional[np.ndarray] = None # (h,) int32 row weights + b1: Optional[np.ndarray] = None + + def apply(self, image: Any, *, out: Optional[np.ndarray] = None) -> np.ndarray: + """Resize one HxW or HxWxC uint8 image of size ``src_hw`` -> ``dst_hw`` (same channel count).""" + img = _as_image(image) + if img.shape[:2] != self.src_hw: + raise ValueError(f"table built for {self.src_hw}, image is {img.shape[:2]}") + if self.mode == "copy": + res = img.copy() + elif self.mode == "area2x": + res = opencv_resize_area_u8(img, self.dst_hw) + else: + src = img if img.ndim == 3 else img[:, :, None] + sub = src[self.rows] # only the rows the vertical pass reads + h = sub[:, self.cols0, :].astype(np.int32) # horizontal pass, exact int32 (<= 255 * 2048) + h *= self.a0[None, :, None] + h1 = sub[:, self.cols1, :].astype(np.int32) + h1 *= self.a1[None, :, None] + h += h1 + h >>= 4 # S[x] >> 4 (h >= 0) + v = h[self.r0] # vertical pass, the uchar VResizeLinear + v *= self.b0[:, None, None] + v >>= 16 + v1 = h[self.r1] + v1 *= self.b1[:, None, None] + v1 >>= 16 + v += v1 + v += 2 + v >>= 2 + res = v.astype(np.uint8) # in [0, 255] (weights sum to 2048) + if img.ndim == 2: + res = res[:, :, 0] + if out is not None: + out[...] = res + return out + return res + + +def _build_linear_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int]) -> LinearResizeU8LUT: + (H, W), (h, w) = src_hw, dst_hw + if (h, w) == (H, W): + return LinearResizeU8LUT(src_hw, dst_hw, "copy") + if 2 * h == H and 2 * w == W: # is_area_fast && iscale_x == iscale_y == 2 + return LinearResizeU8LUT(src_hw, dst_hw, "area2x") + c0, c1, a0, a1 = _linear_axis(W, w, True) + y0, y1, b0, b1 = _linear_axis(H, h, False) + rows = np.unique(np.concatenate([y0, y1])) + r0, r1 = np.searchsorted(rows, y0), np.searchsorted(rows, y1) + return LinearResizeU8LUT(src_hw, dst_hw, "linear", c0, c1, a0, a1, rows, r0, r1, b0, b1) + + +_LINEAR_U8_LUTS = LUTCache(maxsize=8) + + +def opencv_linear_u8_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int]) -> LinearResizeU8LUT: + """The cached tables of ``cv::resize(uint8 src_hw -> dst_hw, INTER_LINEAR)`` (any scale, up or down).""" + key = (_check_hw(src_hw, "src_hw"), _check_hw(dst_hw, "dst_hw")) + return _LINEAR_U8_LUTS.get(key, lambda: _build_linear_lut(*key)) + + +def opencv_resize_linear_u8(image: Any, dst_hw: Tuple[int, int], *, out: Optional[np.ndarray] = None) -> np.ndarray: + """``cv::resize(image, dst, Size(w, h))`` (INTER_LINEAR) of an HxW or HxWxC uint8 image, bit-exact.""" + img = _as_image(image) + return opencv_linear_u8_lut(img.shape[:2], dst_hw).apply(img, out=out) + + +# ---------------------------------------------------------------------------------------------- INTER_NEAREST + +@dataclass(frozen=True) +class NearestResizeLUT: + """``resizeNN`` index tables: ``rows`` (h,), ``cols`` (w,); a copy when the sizes are equal.""" + + src_hw: Tuple[int, int] + dst_hw: Tuple[int, int] + rows: np.ndarray + cols: np.ndarray + + def apply(self, image: Any, *, out: Optional[np.ndarray] = None) -> np.ndarray: + """Resize one HxW or HxWxC image of any dtype (a gather: values are copied, never mixed).""" + img = _as_image(image, dtype=None) + if img.shape[:2] != self.src_hw: + raise ValueError(f"table built for {self.src_hw}, image is {img.shape[:2]}") + res = img.copy() if self.src_hw == self.dst_hw else img[self.rows][:, self.cols] + if out is not None: + out[...] = res + return out + return res + + +def _nearest_axis(n_src: int, n_dst: int) -> np.ndarray: + inv = 1.0 / (n_dst / n_src) # ifx = 1. / fx, fx = inv_scale_x + return np.minimum(np.floor(np.arange(n_dst, dtype=np.float64) * inv).astype(np.int64), n_src - 1) + + +_NEAREST_LUTS = LUTCache(maxsize=8) + + +def opencv_nearest_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int]) -> NearestResizeLUT: + """The cached index tables of ``cv::resize(src_hw -> dst_hw, INTER_NEAREST)``.""" + key = (_check_hw(src_hw, "src_hw"), _check_hw(dst_hw, "dst_hw")) + return _NEAREST_LUTS.get(key, lambda: NearestResizeLUT(key[0], key[1], _nearest_axis(key[0][0], key[1][0]), + _nearest_axis(key[0][1], key[1][1]))) + + +def opencv_resize_nearest(image: Any, dst_hw: Tuple[int, int], *, out: Optional[np.ndarray] = None) -> np.ndarray: + """``cv::resize(image, dst, Size(w, h), 0, 0, INTER_NEAREST)`` of an HxW or HxWxC image, bit-exact.""" + img = _as_image(image, dtype=None) + return opencv_nearest_lut(img.shape[:2], dst_hw).apply(img, out=out) + + +# ------------------------------------------------------------------------------------------- SceneSeg preset + +def _order(channel_order: str) -> str: + if channel_order not in SCENESEG_CHANNEL_ORDERS: + raise ValueError(f"channel_order must be one of {SCENESEG_CHANNEL_ORDERS}, got {channel_order!r}") + return channel_order + + +def sceneseg_resize(image_bgr: Any, *, dst_hw: Tuple[int, int] = SCENESEG_INPUT_HW, + out: Optional[np.ndarray] = None) -> np.ndarray: + """The node's squash, ``cv::resize(input_image, resized, Size(640, 320))`` (INTER_LINEAR, aspect ratio not + kept) of one HxWx3 uint8 image (channel order kept: give it B, G, R as ``cv_bridge`` ``bgr8`` does) -> + ``(320, 640, 3)`` uint8. This is the device input of the TT port (614 KB).""" + img = _as_image(image_bgr) + if img.ndim != 3 or img.shape[2] != 3: + raise ValueError(f"expected an HxWx3 BGR image, got shape {img.shape}") + return opencv_resize_linear_u8(img, dst_hw, out=out) + + +def sceneseg_normalize(resized_bgr: Any, *, channel_order: str = "bgr") -> np.ndarray: + """The node's normalisation of the resized B, G, R uint8 image -> the ONNX input ``float32 [1, 3, H, W]``. + + ``"bgr"`` (deployed): planes B, G, R; ``convertTo(CV_32F, 1/255)`` (float32 product), ``cv::subtract`` of + (0.406, 0.456, 0.485) in float32, ``cv::divide`` by (0.225, 0.224, 0.229) in double rounded to float32. + ``"rgb"`` (training order): the same arithmetic on planes R, G, B with (0.485, 0.456, 0.406) / + (0.229, 0.224, 0.225).""" + img = _as_image(resized_bgr) + if img.ndim != 3 or img.shape[2] != 3: + raise ValueError(f"expected an HxWx3 BGR image, got shape {img.shape}") + if _order(channel_order) == "rgb": + img = img[:, :, ::-1] + mean, std = SCENESEG_MEAN_RGB, SCENESEG_STD_RGB + else: + mean, std = SCENESEG_MEAN_RGB[::-1], SCENESEG_STD_RGB[::-1] + planes = np.ascontiguousarray(img.transpose(2, 0, 1)).astype(F32) # cv::split -> NCHW + x = planes * F32(1.0 / 255.0) # convertTo: float32 product + x = x - np.asarray(mean, F32)[:, None, None] # cv::subtract (float32) + x = (x.astype(np.float64) / np.asarray(std, np.float64)[:, None, None]).astype(F32) # cv::divide (double) + return x[None] + + +def sceneseg_preprocess(image_bgr: Any, *, channel_order: str = "bgr") -> np.ndarray: + """One HxWx3 B, G, R uint8 camera image -> the SceneSeg ONNX input ``float32 [1, 3, 320, 640]``, bit-exact with + the deployed C++ (``"bgr"``) or its training-order variant (``"rgb"``): :func:`sceneseg_normalize` of + :func:`sceneseg_resize`.""" + return sceneseg_normalize(sceneseg_resize(image_bgr), channel_order=channel_order) diff --git a/code/tt_frnet/ttaw/image_triangle.py b/code/tt_frnet/ttaw/image_triangle.py new file mode 100644 index 0000000000000000000000000000000000000000..842525442ba06b48d6909df3bf68f9026c6b9d89 --- /dev/null +++ b/code/tt_frnet/ttaw/image_triangle.py @@ -0,0 +1,344 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C14 image pre-processing, StreamPETR preset: a bit-exact host emulation of Autoware's fused anti-aliased resize + +crop + normalise CUDA kernel (a PIL-style triangle filter with adaptive support), driven by cached per-size tables. +numpy only; no ttnn, no torch, no OpenCV. + +Deployed code (autoware_universe main @ 9ceaccf, ``perception/autoware_camera_streampetr``; research/streampetr/SPEC.md +section 3.1): + +- ``calculate_image_processing_params`` (``lib/network/camera_data_store.cpp:287-316``), float32: + ``resize = max(480 / (float)H, 640 / (float)W)``; ``new_w = (int)(W * resize)``, ``new_h = (int)(H * resize)``; + ``crop_h = max(0, (int)(1.0f * new_h) - 480)`` (the top rows are cropped, the bottom kept: ``bottom_crop_portion`` + 0), ``crop_w = max(0, (new_w - 640) / 2)`` (centred) -> :func:`autoware_resize_geometry`. +- ``resize_and_extract_roi_kernel`` (``lib/network/preprocess_kernel.cu:73-176``; the 0.52.1 kernel + ``resizeAndExtractRoi_kernel`` has the same arithmetic), per output pixel, all float32: + ``r = (float)(out + roi_start)``, ``scale = (float)in / (float)resized``, ``centre = (r + 0.5f) * scale - 0.5f``, + ``support = scale > 1 ? scale : 1``, taps ``ceilf(centre - support) .. floorf(centre + support)`` clamped to the + image, ``w = max(0, 1 - |centre - i| / support)`` per axis, ``w = wx * wy``; the loop runs **y outer, x inner** + and skips ``w <= 0``: ``sum_c += pix_c * w``, ``sum_w += w``; then ``sum_c /= sum_w`` (when ``sum_w > 0``) and + ``out_c = (sum_c - mean[c]) / std[c]``, written planar (CHW). The channel order of the output is the byte order + of the buffer the kernel reads: RGB on main (a ``bgr8`` message is swapped in place first, + ``convert_bgr_to_rgb_kernel``), the message order on 0.52.1. + +The emulation replays exactly these float32 operations in the same order, vectorised over the output pixels: per +output row / column the source taps and the 1-D weights are tabulated once (:class:`TriangleResizeLUT`), and the 2-D +accumulation runs tap pair by tap pair in the kernel's (y outer, x inner) order. Taps outside a pixel's window carry +weight 0, which adds exact zeros (the kernel skips them). + +**Floating-point contraction** (``fma``). Written as C, every operation rounds once: ``fma=False`` (default) does +that, and equals the SPEC's numpy port (``research/streampetr/scripts/sp_common.py``) and the research goldens bit +for bit (their ``inputs_sha256.json``). The node's CMakeLists passes no ``--fmad`` / ``--use_fast_math`` flag, so +nvcc compiles with its default ``--fmad=true``, which lets the compiler fuse a multiply feeding an add into one +``fmaf``. ``fma=True`` models LLVM NVPTX's aggressive fusion: ``centre = fmaf(r + 0.5f, scale, -0.5f)``, +``sum_c = fmaf(pix_c, w, sum_c)`` and ``sum_w = fmaf(wx, wy, sum_w)`` (the rounded ``w`` still feeds the channel +products). Which form the deployed binary has cannot be checked without CUDA. The two differ in ~43 % of the +inputs by at most ~3e-5 in normalised units (one ulp of a ~1000 px centre moves every tap weight; measured in +``common/tests/host/test_image_triangle_host.py``), two orders of magnitude below the bf16 rounding of the device +input. + +Normalisation presets (:data:`STREAMPETR_PRESETS`, SPEC section 3.4; PLAN.md D7 picks ``autoware_main``): + +================== ====================== ================================================================== +preset planes (buffer order) statistics +================== ====================== ================================================================== +``autoware_main`` R, G, B mean (123.675, 116.28, 103.53), std (58.395, 57.12, 57.375) + (universe main / 0.53.0, ``camera_data_store.cpp:128-134``) +``autoware_0.52`` R, G, B (an ``rgb8`` mean (103.53, 116.28, 123.675), std (57.375, 57.12, 58.395) on the + camera, as TIER IV's) message bytes (0.52.1, ``camera_data_store.cpp:122-127``) +``awml_training`` B, G, R the 0.52 statistics (AWML t4base v2.5 test pipeline: cv2 decode, + ``to_rgb=False``; = 0.52.1 fed a ``bgr8`` camera) +================== ====================== ================================================================== +""" +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import Any, Dict, Optional, Sequence, Tuple + +import numpy as np + +from .image import LUTCache + +__all__ = [ + "TriangleGeometry", + "TriangleResizeLUT", + "StreamPETRPreset", + "autoware_resize_geometry", + "triangle_resize_lut", + "fma_f32", + "streampetr_preprocess", + "streampetr_preprocess_batch", + "STREAMPETR_PRESETS", + "STREAMPETR_INPUT_HW", + "STREAMPETR_MEAN_RGB", + "STREAMPETR_STD_RGB", + "STREAMPETR_MEAN_BGR", + "STREAMPETR_STD_BGR", +] + +F32 = np.float32 +F64 = np.float64 +STREAMPETR_INPUT_HW = (480, 640) # HF ml_package_camera_streampetr.param.yaml:7-8 (input_image_height / _width) +# camera_data_store.cpp:128-134 (main: RGB order) and AW052 camera_data_store.cpp:122-127 (0.52.1: BGR order) +STREAMPETR_MEAN_RGB = (123.675, 116.280, 103.530) +STREAMPETR_STD_RGB = (58.395, 57.120, 57.375) +STREAMPETR_MEAN_BGR = (103.530, 116.280, 123.675) +STREAMPETR_STD_BGR = (57.375, 57.120, 58.395) + + +@dataclass(frozen=True) +class StreamPETRPreset: + """One normalisation preset: the plane order the kernel sees (``"rgb"`` or ``"bgr"``) and the per-plane + statistics, as float32 values (the kernel reads them from a float device buffer).""" + + name: str + planes: str + mean: Tuple[float, float, float] + std: Tuple[float, float, float] + description: str = "" + + def mean_f32(self) -> np.ndarray: + return np.asarray(self.mean, dtype=F32) + + def std_f32(self) -> np.ndarray: + return np.asarray(self.std, dtype=F32) + + +STREAMPETR_PRESETS: Dict[str, StreamPETRPreset] = { + "autoware_main": StreamPETRPreset("autoware_main", "rgb", STREAMPETR_MEAN_RGB, STREAMPETR_STD_RGB, + "Autoware universe main / 0.53.0: RGB planes, RGB-ordered statistics"), + "autoware_0.52": StreamPETRPreset("autoware_0.52", "rgb", STREAMPETR_MEAN_BGR, STREAMPETR_STD_BGR, + "Autoware 0.52.1 fed an rgb8 camera: message-order (RGB) planes, " + "BGR-ordered statistics"), + "awml_training": StreamPETRPreset("awml_training", "bgr", STREAMPETR_MEAN_BGR, STREAMPETR_STD_BGR, + "AWML t4base v2.5 training / test pipeline (= 0.52.1 fed a bgr8 camera): " + "BGR planes, BGR statistics"), +} + + +# --------------------------------------------------------------------------------------------- geometry + + +@dataclass(frozen=True) +class TriangleGeometry: + """``calculate_image_processing_params`` of one source size: the virtual resized image and the ROI cut from it.""" + + src_hw: Tuple[int, int] + resized_hw: Tuple[int, int] # (new_h, new_w) + roi_hw: Tuple[int, int] # the network input (480, 640) + roi_start: Tuple[int, int] # (start_y, start_x) in the resized image + resize: float # the float32 resize factor, as an exact Python float + + @property + def resize_f32(self) -> np.float32: + return F32(self.resize) + + def to_dict(self) -> Dict[str, Any]: + return {"src_hw": list(self.src_hw), "resized_hw": list(self.resized_hw), "roi_hw": list(self.roi_hw), + "roi_start": list(self.roi_start), "resize": self.resize} + + +def autoware_resize_geometry(src_h: int, src_w: int, dst_h: int = STREAMPETR_INPUT_HW[0], + dst_w: int = STREAMPETR_INPUT_HW[1]) -> TriangleGeometry: + """``camera_data_store.cpp:287-316`` in float32 (module docstring).""" + src_h, src_w, dst_h, dst_w = int(src_h), int(src_w), int(dst_h), int(dst_w) + if min(src_h, src_w, dst_h, dst_w) <= 0: + raise ValueError(f"sizes must be positive: src {src_h}x{src_w}, dst {dst_h}x{dst_w}") + scale_h = F32(F32(dst_h) / F32(src_h)) + scale_w = F32(F32(dst_w) / F32(src_w)) + resize = scale_w if scale_h < scale_w else scale_h # std::max(scaleH, scaleW) + new_w = int(F32(F32(src_w) * resize)) # static_cast(int * float): truncation + new_h = int(F32(F32(src_h) * resize)) + crop_h = max(0, int(F32(F32(1.0) * F32(new_h))) - dst_h) # (1.0f - bottom_crop_portion) * newH, portion 0 + crop_w = max(0, int((new_w - dst_w) / 2)) if new_w > dst_w else 0 # C int division, then std::max(0, .) + return TriangleGeometry((src_h, src_w), (new_h, new_w), (dst_h, dst_w), (max(0, crop_h), max(0, crop_w)), + float(resize)) + + +# --------------------------------------------------------------------------------------------- arithmetic + + +def fma_f32(a: Any, b: Any, c: Any) -> np.ndarray: + """``fmaf(a, b, c)`` for float32 arrays: ``a * b + c`` rounded ONCE to float32 (round to nearest even), exactly. + + The float64 product of two float32 values is exact; the float64 sum may round, and a float64 result that lands + on a float32 tie the exact value does not sit on would round twice. The TwoSum error term detects that case and + the tie is broken towards the exact value.""" + a64 = np.asarray(a, dtype=F32).astype(F64) + b64 = np.asarray(b, dtype=F32).astype(F64) + c64 = np.asarray(c, dtype=F32).astype(F64) + p = a64 * b64 + s = p + c64 + r = np.asarray(s.astype(F32)) + bv = s - p + err = (p - (s - bv)) + (c64 - bv) # p + c == s + err exactly (Knuth's TwoSum) + bad = np.nonzero(np.broadcast_to(err, s.shape) != 0) + if bad[0].size: + sb, eb = s[bad], np.broadcast_to(err, s.shape)[bad] + rb = r[bad] + d = sb - rb.astype(F64) + nb = np.nextafter(rb, np.where(d > 0, np.inf, -np.inf).astype(F32)) + tie = (d != 0) & (sb == (rb.astype(F64) + nb.astype(F64)) * 0.5) + fix = tie & (np.sign(eb) == np.sign(d)) + rb = np.where(fix, nb, rb) + r = r.copy() + r[bad] = rb + return r + + +def _axis_taps(n_out: int, start: int, n_in: int, n_resized: int, fma: bool) -> Tuple[np.ndarray, np.ndarray]: + """Source index [n_out, T] and float32 weight [n_out, T] of every output row (or column), in the kernel's tap + order; taps past a pixel's window have weight 0 (index clamped into the image).""" + scale = F32(F32(n_in) / F32(n_resized)) + support = scale if scale > F32(1.0) else F32(1.0) + r = (np.arange(n_out, dtype=np.int64) + int(start)).astype(F32) # (float)(out + roi_start), exact + r5 = (r + F32(0.5)).astype(F32) + if fma: + centre = (r5.astype(F64) * F64(scale) - 0.5).astype(F32) # one rounding (exact in float64) + else: + centre = ((r5 * scale).astype(F32) - F32(0.5)).astype(F32) + lo = np.maximum(0, np.ceil((centre - support).astype(F32)).astype(np.int64)) + hi = np.minimum(n_in - 1, np.floor((centre + support).astype(F32)).astype(np.int64)) + n_taps = max(1, int((hi - lo).max()) + 1) + idx = lo[:, None] + np.arange(n_taps, dtype=np.int64)[None, :] + valid = idx <= hi[:, None] + idx = np.minimum(idx, n_in - 1) + d = (centre[:, None] - idx.astype(F32)).astype(F32) + w = (F32(1.0) - (np.abs(d) / support).astype(F32)).astype(F32) + w = np.maximum(F32(0.0), w).astype(F32) + w = np.where(valid, w, F32(0.0)).astype(F32) + return idx, w + + +@dataclass(frozen=True) +class TriangleResizeLUT: + """The per-size tables of ``resize_and_extract_roi_kernel``: build with :func:`triangle_resize_lut` (cached), + apply with :meth:`apply` (the per-pixel weighted mean, before the mean / std normalisation).""" + + geometry: TriangleGeometry + rows: np.ndarray # (roi_h, Ty) int64 source rows + row_w: np.ndarray # (roi_h, Ty) float32 wy (0 past the window) + cols: np.ndarray # (roi_w, Tx) int64 source columns + col_w: np.ndarray # (roi_w, Tx) float32 wx + fma: bool + + def apply(self, image: np.ndarray, *, out: Optional[np.ndarray] = None) -> np.ndarray: + """One uint8 image ``(src_h, src_w, C)`` (any channel count, channel order kept) -> float32 ``(C, roi_h, + roi_w)``: ``sum_c / sum_w`` (``sum_c`` where ``sum_w == 0``), exactly as the kernel before step 9.""" + g = self.geometry + img = np.asarray(image) + if img.ndim != 3 or img.dtype != np.uint8 or tuple(img.shape[:2]) != g.src_hw: + raise ValueError(f"expected a uint8 ({g.src_hw[0]}, {g.src_hw[1]}, C) image, got {img.dtype} {img.shape}") + roi_h, roi_w = g.roi_hw + cn = img.shape[2] + acc = np.zeros((cn, roi_h, roi_w), F32) + wsum = np.zeros((roi_h, roi_w), F32) + pix = np.empty((cn, roi_h, roi_w), F32) + planes = np.ascontiguousarray(img.transpose(2, 0, 1)) # (C, H, W) uint8 + # the source columns of every horizontal tap, once per image (float32 holds uint8 exactly) + cols = [planes[:, :, self.cols[:, tx]].astype(F32) for tx in range(self.cols.shape[1])] + for ty in range(self.rows.shape[1]): # y outer ... + rows = self.rows[:, ty] + wy = self.row_w[:, ty][:, None] + for tx in range(self.cols.shape[1]): # ... x inner, as the kernel + wx = self.col_w[:, tx][None, :] + w = (wx * wy).astype(F32) # w = wx * wy (rounded) + np.take(cols[tx], rows, axis=1, out=pix) # (C, roi_h, roi_w) pixels + if self.fma: + acc = fma_f32(pix, w, acc) # sum_c = fmaf(pix, w, sum_c) + wsum = fma_f32(np.broadcast_to(wx, w.shape), np.broadcast_to(wy, w.shape), wsum) + else: + np.multiply(pix, w, out=pix) # pix * w (rounded) + np.add(acc, pix, out=acc) # sum_c + . (rounded) + np.add(wsum, w, out=wsum) + pos = wsum > 0 + result = np.where(pos, acc / np.where(pos, wsum, F32(1.0)), acc).astype(F32) + if out is not None: + if out.shape != result.shape or out.dtype != F32: + raise ValueError(f"out must be float32 {result.shape}, got {out.dtype} {out.shape}") + out[...] = result + return out + return result + + +_TRIANGLE_LUTS = LUTCache(maxsize=16) + + +def triangle_resize_lut(src_hw: Tuple[int, int], dst_hw: Tuple[int, int] = STREAMPETR_INPUT_HW, *, + geometry: Optional[TriangleGeometry] = None, fma: bool = False) -> TriangleResizeLUT: + """The (cached) tables for one source size. ``geometry`` overrides the StreamPETR rule + (:func:`autoware_resize_geometry`) for other nodes of the same kernel family (resized size, ROI start).""" + g = geometry or autoware_resize_geometry(src_hw[0], src_hw[1], dst_hw[0], dst_hw[1]) + + def make() -> TriangleResizeLUT: + rows, row_w = _axis_taps(g.roi_hw[0], g.roi_start[0], g.src_hw[0], g.resized_hw[0], fma) + cols, col_w = _axis_taps(g.roi_hw[1], g.roi_start[1], g.src_hw[1], g.resized_hw[1], fma) + return TriangleResizeLUT(g, rows, row_w, cols, col_w, bool(fma)) + + key = ("triangle", g.src_hw, g.resized_hw, g.roi_hw, g.roi_start, bool(fma)) + return _TRIANGLE_LUTS.get(key, make) + + +# --------------------------------------------------------------------------------------------- the preset + + +def _preset(preset: Any) -> StreamPETRPreset: + if isinstance(preset, StreamPETRPreset): + return preset + try: + return STREAMPETR_PRESETS[str(preset)] + except KeyError: + raise ValueError(f"unknown normalisation preset {preset!r}; one of {sorted(STREAMPETR_PRESETS)}") from None + + +def streampetr_preprocess(image: np.ndarray, *, preset: Any = "autoware_main", channels: str = "rgb", + dst_hw: Tuple[int, int] = STREAMPETR_INPUT_HW, fma: bool = False, + out: Optional[np.ndarray] = None) -> np.ndarray: + """One camera image -> the float32 network input ``(3, 480, 640)`` of ``autoware_camera_streampetr``. + + ``image``: uint8 ``(H, W, 3)`` in the channel order ``channels`` (``"rgb"``: :func:`ttaw.io.load_image`, an + ``rgb8`` message; ``"bgr"``: OpenCV, a ``bgr8`` message). The preset fixes the plane order the kernel sees and + the statistics (:data:`STREAMPETR_PRESETS`); ``fma`` the contraction model (module docstring).""" + p = _preset(preset) + img = np.asarray(image) + if img.ndim != 3 or img.shape[2] != 3 or img.dtype != np.uint8: + raise ValueError(f"expected an (H, W, 3) uint8 image, got {img.dtype} {img.shape}") + if channels not in ("rgb", "bgr"): + raise ValueError("channels must be 'rgb' or 'bgr'") + if channels != p.planes: + img = img[:, :, ::-1] + lut = triangle_resize_lut(img.shape[:2], dst_hw, fma=fma) + v = lut.apply(img) # (3, H, W) float32 + mean = p.mean_f32()[:, None, None] + std = p.std_f32()[:, None, None] + res = ((v - mean).astype(F32) / std).astype(F32) # (sum_c - mean[c]) / std[c] + if out is not None: + if out.shape != res.shape or out.dtype != F32: + raise ValueError(f"out must be float32 {res.shape}, got {out.dtype} {out.shape}") + out[...] = res + return out + return res + + +def streampetr_preprocess_batch(images: Sequence[np.ndarray], *, preset: Any = "autoware_main", + channels: str = "rgb", dst_hw: Tuple[int, int] = STREAMPETR_INPUT_HW, + fma: bool = False, workers: int = 1, out: Optional[np.ndarray] = None) -> np.ndarray: + """The cameras of one frame -> float32 ``(N, 3, 480, 640)`` (each camera may have its own size). ``workers`` + threads over the cameras (numpy releases the GIL in the array passes).""" + imgs = list(images) + shape = (len(imgs), 3) + tuple(int(v) for v in dst_hw) + if out is None: + out = np.empty(shape, F32) + elif out.shape != shape or out.dtype != F32: + raise ValueError(f"out must be float32 {shape}, got {out.dtype} {out.shape}") + + def one(i: int) -> None: + streampetr_preprocess(imgs[i], preset=preset, channels=channels, dst_hw=dst_hw, fma=fma, out=out[i]) + + if workers > 1 and len(imgs) > 1: + with ThreadPoolExecutor(max_workers=min(int(workers), len(imgs))) as ex: + list(ex.map(one, range(len(imgs)))) + else: + for i in range(len(imgs)): + one(i) + return out diff --git a/code/tt_frnet/ttaw/io.py b/code/tt_frnet/ttaw/io.py new file mode 100644 index 0000000000000000000000000000000000000000..b017ba7d2ab8c1fc18de0fefc0b464b685825045 --- /dev/null +++ b/code/tt_frnet/ttaw/io.py @@ -0,0 +1,877 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C08 I/O: input decoding and output encoding shared by the Python API and the HTTP server of every bundle. + +One module, two callers: the bundle's ``api`` (Python objects: paths, bytes, numpy arrays) and its ``server.app`` +(JSON envelopes with base64 payloads). Both go through the same decoders, so ``model(...)`` and ``POST /predict`` +see bit-identical inputs (BUNDLE_CONVENTIONS.md section 7). + +Rules: + +- numpy only (PIL is imported lazily for images). No ttnn, no torch, no network: this module is imported by the + image's build-time ``verify:`` step and by host tests. +- Data parsing only: ``np.load(..., allow_pickle=False)``, no ``eval`` / ``pickle``. Every client mistake raises + :class:`InputError` (the server maps it to HTTP 400). + +Point-cloud envelope (``"points"`` in the request):: + + {"format": "bin" | "npy" | "npz" | "pcd" | "list", + "data": "", # all formats except "list" + "values": [[x, y, z, intensity], ...], # "list" only (small clouds) + "fields": ["x", "y", "z", "intensity"], # column names: required meaning for "bin" / "list" / plain arrays + "dtype": "float32", # "bin" only: float32 | float64 + "key": "points", # "npz" only (default: "points", else the first array) + "frame_id": "base_link"} + +Transforms (``T__from_``, 4x4, metres) accept several spellings, see :func:`parse_transform`. +""" +from __future__ import annotations + +import base64 +import binascii +import io +import json +import math +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, Mapping, Optional, Sequence, Union + +import numpy as np + +__all__ = [ + "InputError", "PointCloud", "CameraImage", "POINT_FORMATS", "DEFAULT_POINT_FIELDS", "MAX_POINTS_DEFAULT", + "b64decode", "decode_points", "load_points", "decode_image", "load_image", "decode_cameras", "load_camera", + "load_cameras", "decode_rois", "parse_transform", + "parse_intrinsics", "resolve_calibration", "check_named_arrays", "decode_named_arrays", "load_named_arrays", + "encode_array", "encode_png", "to_jsonable", +] + +POINT_FORMATS = ("bin", "npy", "npz", "pcd", "list") +DEFAULT_POINT_FIELDS = ("x", "y", "z", "intensity") # Autoware's common LiDAR input layout +MAX_POINTS_DEFAULT = 2_000_000 # hard cap per cloud (memory guard, not a model limit) + + +class InputError(ValueError): + """A client-side input problem. The HTTP server answers 400 with ``str(err)``.""" + + +# ----------------------------------------------------------------------------------------------- base64 + +_B64_JUNK = re.compile(r"\s+") + + +def b64decode(s: Any, *, field: str = "data", max_bytes: Optional[int] = None) -> bytes: + """Decode standard or URL-safe base64; tolerates a ``data:...;base64,`` prefix, whitespace and missing padding.""" + if not isinstance(s, str) or not s: + raise InputError(f"{field} must be a non-empty base64 string") + if s.startswith("data:"): + comma = s.find(",") + if comma < 0: + raise InputError(f"{field}: malformed data: URI") + s = s[comma + 1:] + s = _B64_JUNK.sub("", s).replace("-", "+").replace("_", "/") + s += "=" * (-len(s) % 4) + if max_bytes is not None and len(s) * 3 // 4 > max_bytes + 3: + raise InputError(f"{field} is larger than {max_bytes} bytes") + try: + raw = base64.b64decode(s, validate=True) + except (binascii.Error, ValueError) as e: + raise InputError(f"{field} is not valid base64: {e}") from None + if not raw: + raise InputError(f"{field} is empty") + return raw + + +# ---------------------------------------------------------------------------------------- point clouds + +@dataclass +class PointCloud: + """An (N, C) float32 point array plus its column names and coordinate frame. Rows with a non-finite ``x``, + ``y`` or ``z`` (the first three columns when the cloud has no such names) are dropped at construction (PCL + ``is_dense=false`` clouds); the remaining rows keep their input order.""" + + points: np.ndarray + fields: tuple + frame_id: str = "base_link" + + def __post_init__(self) -> None: + self.fields = tuple(self.fields) + if self.points.ndim != 2 or self.points.shape[1] != len(self.fields): + raise InputError(f"points have shape {tuple(self.points.shape)} but {len(self.fields)} fields " + f"{list(self.fields)}") + if self.points.dtype != np.float32: + self.points = self.points.astype(np.float32) + named = [self.fields.index(n) for n in ("x", "y", "z") if n in self.fields] + xyz = self.points[:, named] if named else self.points[:, :3] + if not np.isfinite(xyz).all(): + keep = np.isfinite(xyz).all(axis=1) + self.points = np.ascontiguousarray(self.points[keep]) + + def __len__(self) -> int: + return int(self.points.shape[0]) + + def select(self, names: Sequence[str], *, fill: Optional[Mapping[str, float]] = None) -> np.ndarray: + """Columns ``names`` in that order (C-contiguous float32). A missing column is an error unless ``fill`` + gives it a constant (e.g. ``{"time_lag": 0.0}`` for a single sweep).""" + cols = [] + index = {n: i for i, n in enumerate(self.fields)} + for n in names: + if n in index: + cols.append(self.points[:, index[n]]) + elif fill is not None and n in fill: + cols.append(np.full(len(self), fill[n], np.float32)) + else: + raise InputError(f"point field {n!r} is required; the cloud has {list(self.fields)}") + return np.ascontiguousarray(np.stack(cols, axis=1), dtype=np.float32) + + +_PCD_TYPES = {("F", 4): " bytes: + """liblzf decompression (the codec of PCD ``DATA binary_compressed``). Pure Python: fine for test clouds, + slow (~1 s per 10 MB) for big ones -- send ``binary`` PCD or ``.npy`` when latency matters.""" + out = bytearray(out_len) + ip = op = 0 + n = len(data) + while ip < n: + ctrl = data[ip] + ip += 1 + if ctrl < 32: # literal run of ctrl + 1 bytes + ln = ctrl + 1 + if ip + ln > n or op + ln > out_len: + raise InputError("pcd: corrupt LZF stream (literal run)") + out[op:op + ln] = data[ip:ip + ln] + ip += ln + op += ln + else: # back reference + ln = ctrl >> 5 + ref = op - ((ctrl & 0x1F) << 8) - 1 + if ln == 7: + if ip >= n: + raise InputError("pcd: corrupt LZF stream (length)") + ln += data[ip] + ip += 1 + if ip >= n: + raise InputError("pcd: corrupt LZF stream (offset)") + ref -= data[ip] + ip += 1 + ln += 2 + if ref < 0 or op + ln > out_len: + raise InputError("pcd: corrupt LZF stream (back reference)") + if ref + ln <= op: + out[op:op + ln] = out[ref:ref + ln] + else: # overlapping copy: byte by byte, as liblzf does + for k in range(ln): + out[op + k] = out[ref + k] + op += ln + if op != out_len: + raise InputError(f"pcd: LZF produced {op} bytes, header says {out_len}") + return bytes(out) + + +def _parse_pcd(raw: bytes) -> tuple: + """PCD v0.7 (ascii / binary / binary_compressed) -> (float32 (N, C) array, field names).""" + header: dict = {} + pos = 0 + while True: + end = raw.find(b"\n", pos) + if end < 0: + raise InputError("pcd: header has no DATA line") + line = raw[pos:end].decode("ascii", "replace").strip() + pos = end + 1 + if not line or line.startswith("#"): + continue + key, _, rest = line.partition(" ") + header[key.upper()] = rest.split() + if key.upper() == "DATA": + break + try: + names = header["FIELDS"] + sizes = [int(v) for v in header["SIZE"]] + types = [v.upper() for v in header["TYPE"]] + counts = [int(v) for v in header.get("COUNT", ["1"] * len(names))] + npts = int(header.get("POINTS", [0])[0]) or int(header["WIDTH"][0]) * int(header.get("HEIGHT", ["1"])[0]) + mode = header["DATA"][0].lower() + except (KeyError, ValueError, IndexError) as e: + raise InputError(f"pcd: incomplete header ({e})") from None + if not (len(names) == len(sizes) == len(types) == len(counts)): + raise InputError("pcd: FIELDS/SIZE/TYPE/COUNT lengths differ") + cols, dt = [], [] + for n, s, t, c in zip(names, sizes, types, counts): + npt = _PCD_TYPES.get((t, s)) + if npt is None: + raise InputError(f"pcd: unsupported field type {t}{s} for {n!r}") + fname = n if n != "_" else f"_pad{len(dt)}" + dt.append((fname, npt, (c,)) if c > 1 else (fname, npt)) + if n != "_": + cols += [n] if c == 1 else [f"{n}_{k}" for k in range(c)] + dtype = np.dtype(dt) + body = raw[pos:] + if mode == "ascii": + try: + arr = np.loadtxt(io.StringIO(body.decode("ascii")), dtype=np.float64, ndmin=2) + except ValueError as e: + raise InputError(f"pcd: bad ascii body ({e})") from None + flat_names = [n if c == 1 else f"{n}_{k}" for n, c in zip(names, counts) for k in range(c)] + keep = [i for i, n in enumerate(flat_names) if not n.startswith("_")] + if arr.shape[1] != len(flat_names): + raise InputError(f"pcd: ascii rows have {arr.shape[1]} values, header declares {len(flat_names)}") + return arr[:, keep].astype(np.float32), tuple(cols) + if mode == "binary": + need = dtype.itemsize * npts + if len(body) < need: + raise InputError(f"pcd: binary body has {len(body)} bytes, needs {need}") + rec = np.frombuffer(body, dtype=dtype, count=npts) + elif mode == "binary_compressed": + if len(body) < 8: + raise InputError("pcd: binary_compressed body too short") + csize, usize = np.frombuffer(body[:8], " tuple: + cols, names = [], [] + for name in rec.dtype.names: + if name.startswith("_pad"): + continue + v = np.asarray(rec[name], dtype=np.float32) + if v.ndim == 1: + cols.append(v) + names.append(name) + else: + for k in range(v.shape[1]): + cols.append(v[:, k]) + names.append(f"{name}_{k}") + return np.stack(cols, axis=1) if cols else np.zeros((len(rec), 0), np.float32), tuple(names) + + +def _array_to_cloud(arr: np.ndarray, fields: Optional[Sequence[str]], frame_id: str, + default_fields: Sequence[str]) -> PointCloud: + if arr.dtype.names: # structured array (npy / npz written from a ROS PointCloud2 or PCL) + pts, names = _structured_to_2d(arr.reshape(-1)) + return PointCloud(pts, names, frame_id) + arr = np.asarray(arr) + if arr.ndim != 2: + raise InputError(f"points must be a 2-D (N, C) array, got shape {tuple(arr.shape)}") + if not np.issubdtype(arr.dtype, np.number): + raise InputError(f"points must be numeric, got dtype {arr.dtype}") + names = tuple(fields) if fields else tuple(default_fields[: arr.shape[1]]) + if len(names) != arr.shape[1]: + raise InputError(f"points have {arr.shape[1]} columns; give 'fields' (default {list(default_fields)})") + return PointCloud(np.ascontiguousarray(arr, dtype=np.float32), names, frame_id) + + +def _points_from_bytes(raw: bytes, fmt: str, *, fields: Optional[Sequence[str]] = None, dtype: str = "float32", + key: Optional[str] = None, frame_id: str = "base_link", + default_fields: Sequence[str] = DEFAULT_POINT_FIELDS) -> PointCloud: + if fmt == "bin": # raw little-endian rows, KITTI / Autoware dump style: N x len(fields) + names = tuple(fields) if fields else tuple(default_fields) + if dtype not in ("float32", "float64"): + raise InputError("bin dtype must be float32 or float64") + item = 4 if dtype == "float32" else 8 + row = item * len(names) + if len(raw) % row: + raise InputError(f"bin payload of {len(raw)} bytes is not a multiple of {len(names)} x {dtype} " + f"({row} bytes per point); check 'fields' (got {list(names)})") + arr = np.frombuffer(raw, dtype=" PointCloud: + """The JSON point-cloud envelope (module docstring) -> :class:`PointCloud`.""" + if not isinstance(spec, Mapping): + raise InputError("points must be an object {format, data, fields, ...}") + fmt = str(spec.get("format") or "bin").lower() + frame_id = str(spec.get("frame_id") or "base_link") + fields = spec.get("fields") + if fields is not None and (not isinstance(fields, (list, tuple)) or not all(isinstance(f, str) for f in fields)): + raise InputError("points.fields must be a list of column names") + if fmt == "list": + values = spec.get("values") + if not isinstance(values, list) or not values: + raise InputError("points.values must be a non-empty list of rows for format 'list'") + try: + arr = np.asarray(values, dtype=np.float32) + except (TypeError, ValueError) as e: + raise InputError(f"points.values: {e}") from None + cloud = _array_to_cloud(arr, fields, frame_id, default_fields) + else: + raw = b64decode(spec.get("data"), field="points.data", max_bytes=max_bytes) + cloud = _points_from_bytes(raw, fmt, fields=fields, dtype=str(spec.get("dtype") or "float32"), + key=spec.get("key"), frame_id=frame_id, default_fields=default_fields) + if len(cloud) > max_points: + raise InputError(f"{len(cloud)} points exceed the limit of {max_points}") + if len(cloud) == 0: + raise InputError("the point cloud is empty") + return cloud + + +PointsLike = Union[str, Path, bytes, np.ndarray, Mapping[str, Any], PointCloud] + + +def load_points(source: PointsLike, *, fields: Optional[Sequence[str]] = None, fmt: Optional[str] = None, + frame_id: str = "base_link", default_fields: Sequence[str] = DEFAULT_POINT_FIELDS) -> PointCloud: + """Python-API input: a path (.bin/.npy/.npz/.pcd), raw file bytes (give ``fmt``), an (N, C) array (or torch + tensor), a structured array, the JSON envelope, or a :class:`PointCloud`.""" + if isinstance(source, PointCloud): + return source + if isinstance(source, Mapping): + return decode_points(source, default_fields=default_fields) + if isinstance(source, (str, Path)): + p = Path(source) + ext = (fmt or p.suffix.lstrip(".")).lower() + if p.name.endswith(".pcd.bin"): # nuScenes naming: raw float32 x5 + ext = "bin" + return _points_from_bytes(p.read_bytes(), ext, fields=fields, frame_id=frame_id, default_fields=default_fields) + if isinstance(source, (bytes, bytearray, memoryview)): + if not fmt: + raise InputError("raw bytes need fmt= ('bin' | 'npy' | 'npz' | 'pcd')") + return _points_from_bytes(bytes(source), fmt, fields=fields, frame_id=frame_id, default_fields=default_fields) + if hasattr(source, "detach") and hasattr(source, "cpu"): # torch tensor, without importing torch + source = source.detach().cpu().numpy() + return _array_to_cloud(np.asarray(source), fields, frame_id, default_fields) + + +# ---------------------------------------------------------------------------------------------- images + +def _pil_to_rgb(im) -> np.ndarray: + if im.mode != "RGB": + im = im.convert("RGB") + return np.asarray(im, dtype=np.uint8) + + +def decode_image(raw: bytes, *, fmt: str = "auto", max_side: int = 8192) -> np.ndarray: + """PNG / JPEG (or .npy uint8 HxWx3) bytes -> RGB uint8 (H, W, 3).""" + if fmt == "npy": + try: + arr = np.load(io.BytesIO(raw), allow_pickle=False) + except ValueError as e: + raise InputError(f"image npy: {e}") from None + return load_image(arr) + from PIL import Image, UnidentifiedImageError # lazy: the API may never see an image + + try: + with Image.open(io.BytesIO(raw)) as im: + if max(im.size) > max_side: + raise InputError(f"image size {im.size} exceeds max side {max_side}") + return _pil_to_rgb(im) + except (UnidentifiedImageError, OSError) as e: + raise InputError(f"image could not be decoded as PNG/JPEG: {e}") from None + + +def load_image(image: Any) -> np.ndarray: + """Python-API image input: path, bytes, PIL image, uint8 HxWx3 / 3xHxW array or tensor, float array in [0, 1] + -> RGB uint8 (H, W, 3).""" + if isinstance(image, (str, Path)): + return decode_image(Path(image).read_bytes()) + if isinstance(image, (bytes, bytearray, memoryview)): + return decode_image(bytes(image)) + if hasattr(image, "mode") and hasattr(image, "size") and hasattr(image, "convert"): # PIL + return _pil_to_rgb(image) + if hasattr(image, "detach") and hasattr(image, "cpu"): + image = image.detach().cpu().numpy() + a = np.asarray(image) + if a.ndim == 3 and a.shape[0] in (1, 3) and a.shape[2] not in (1, 3): + a = np.transpose(a, (1, 2, 0)) + if a.ndim == 2: + a = np.repeat(a[:, :, None], 3, axis=2) + if a.ndim != 3 or a.shape[2] not in (1, 3, 4): + raise InputError(f"image array must be HxWx3 (or 3xHxW / HxW), got {a.shape}") + a = a[:, :, :3] if a.shape[2] != 1 else np.repeat(a, 3, axis=2) + if np.issubdtype(a.dtype, np.floating): + a = np.clip(np.round(a * 255.0), 0, 255) + return np.ascontiguousarray(a, dtype=np.uint8) + + +@dataclass +class CameraImage: + """One camera of a (multi-)camera request: pixels + calibration, in the order the model expects.""" + + name: str + image: np.ndarray # RGB uint8 (H, W, 3) + intrinsics: Optional[np.ndarray] = None # (3, 3) float64, pixels + T_ref_from_camera: Optional[np.ndarray] = None # (4, 4) float64: camera -> reference frame (base_link / lidar) + distortion: Optional[dict] = None + timestamp_s: Optional[float] = None + extra: dict = field(default_factory=dict) + + +def decode_cameras(images: Iterable[Mapping[str, Any]], calibration: Optional[Mapping[str, Any]] = None, *, + order: Optional[Sequence[str]] = None, require_calibration: bool = True, + max_bytes: Optional[int] = None) -> list: + """``images[]`` (+ optional ``calibration.cameras``) -> list of :class:`CameraImage` in ``order``. + + Each image entry: ``{"camera": "CAM_FRONT", "data": , "format": "auto|png|jpeg|npy", + "intrinsics": ..., "T_ref_from_camera": ..., "distortion": {...}, "timestamp_s": ...}``. Inline calibration + wins over ``calibration.cameras[]``. ``order`` (the model's fixed camera order) reorders and checks + that every camera is present exactly once.""" + calib = dict((calibration or {}).get("cameras") or {}) + out = [] + for i, entry in enumerate(images or []): + if not isinstance(entry, Mapping): + raise InputError(f"images[{i}] must be an object") + name = str(entry.get("camera") or (order[i] if order and i < len(order) else f"cam{i}")) + raw = b64decode(entry.get("data"), field=f"images[{i}].data", max_bytes=max_bytes) + cam_cal = {**dict(calib.get(name) or {}), + **{k: v for k, v in entry.items() if k not in ("camera", "data", "format")}} + k = cam_cal.get("intrinsics") + t = cam_cal.get("T_ref_from_camera", cam_cal.get("extrinsics")) + if require_calibration and (k is None or t is None): + raise InputError(f"camera {name!r} needs 'intrinsics' and 'T_ref_from_camera' (inline or in calibration)") + out.append(CameraImage( + name=name, image=decode_image(raw, fmt=str(entry.get("format") or "auto")), + intrinsics=None if k is None else parse_intrinsics(k, field=f"{name}.intrinsics"), + T_ref_from_camera=None if t is None else parse_transform(t, field=f"{name}.T_ref_from_camera"), + distortion=cam_cal.get("distortion"), timestamp_s=cam_cal.get("timestamp_s"))) + if order: + by_name = {c.name: c for c in out} + if len(by_name) != len(out): + raise InputError("duplicate camera names in images[]") + missing = [n for n in order if n not in by_name] + extra = [n for n in by_name if n not in order] + if missing or extra: + raise InputError(f"cameras must be exactly {list(order)}; missing {missing}, unexpected {extra}") + out = [by_name[n] for n in order] + return out + + +_CAMERA_KEYS = ("intrinsics", "T_ref_from_camera", "extrinsics", "distortion", "timestamp_s") + + +def _with_calibration(cam: CameraImage, cal: Mapping[str, Any], require: bool) -> CameraImage: + """``cam`` with missing intrinsics / extrinsics / distortion / timestamp taken from ``cal`` (its calibration + entry), parsed and checked; ``require`` refuses a camera without K and T_ref_from_camera.""" + k = cam.intrinsics if cam.intrinsics is not None else cal.get("intrinsics") + t = cam.T_ref_from_camera if cam.T_ref_from_camera is not None else cal.get("T_ref_from_camera", + cal.get("extrinsics")) + if require and (k is None or t is None): + raise InputError(f"camera {cam.name!r} needs 'intrinsics' and 'T_ref_from_camera' (inline or in calibration)") + return CameraImage( + name=cam.name, image=cam.image, + intrinsics=None if k is None else parse_intrinsics(k, field=f"{cam.name}.intrinsics"), + T_ref_from_camera=None if t is None else parse_transform(t, field=f"{cam.name}.T_ref_from_camera"), + distortion=cam.distortion if cam.distortion is not None else cal.get("distortion"), + timestamp_s=cam.timestamp_s if cam.timestamp_s is not None else cal.get("timestamp_s"), extra=dict(cam.extra)) + + +def load_camera(source: Any, *, name: Optional[str] = None, calibration: Optional[Mapping[str, Any]] = None, + require_calibration: bool = False, max_bytes: Optional[int] = None) -> CameraImage: + """One camera of the Python API -> :class:`CameraImage` (RGB uint8 HxWx3, K / T parsed). ``source``: a + :class:`CameraImage` (what the server decodes ``/predict`` into), a mapping ``{"camera", "image" | "path", + "intrinsics", "T_ref_from_camera", "distortion", "timestamp_s"}`` or the ``/predict`` spelling ``{"camera", + "data": , ...}``, or any image :func:`load_image` accepts (then ``name`` names it). Calibration missing + inline comes from ``calibration["cameras"][]`` (inline wins, as in :func:`decode_cameras`).""" + cams = dict((calibration or {}).get("cameras") or {}) + if isinstance(source, CameraImage): + cam = source + elif isinstance(source, Mapping): + cam_name = str(source.get("camera") or name or "") + if not cam_name: + raise InputError("every camera needs a 'camera' name") + if "data" in source: + raw = b64decode(source.get("data"), field=f"{cam_name}.data", max_bytes=max_bytes) + image = decode_image(raw, fmt=str(source.get("format") or "auto")) + else: + src = source.get("image", source.get("path")) + if src is None: + raise InputError(f"camera {cam_name!r}: no 'image', 'path' or 'data'") + image = load_image(src) + unknown = sorted(set(source) - {"camera", "data", "format", "image", "path", *_CAMERA_KEYS}) + if unknown: + raise InputError(f"camera {cam_name!r}: unknown keys {unknown}") + cam = CameraImage(name=cam_name, image=image, intrinsics=source.get("intrinsics"), + T_ref_from_camera=source.get("T_ref_from_camera", source.get("extrinsics")), + distortion=source.get("distortion"), timestamp_s=source.get("timestamp_s")) + else: + if not name: + raise InputError("an image without a camera name: pass {'camera': name, 'image': ...} or a " + "{name: image} mapping") + cam = CameraImage(name=str(name), image=load_image(source)) + return _with_calibration(cam, dict(cams.get(cam.name) or {}), require_calibration) + + +def load_cameras(images: Any, calibration: Optional[Mapping[str, Any]] = None, *, + order: Optional[Sequence[str]] = None, require_calibration: bool = True, + calib_dir: Optional[Path] = None, max_bytes: Optional[int] = None) -> list: + """The Python-API twin of :func:`decode_cameras`: ``images`` (a list of :func:`load_camera` sources, or a + ``{camera name: image}`` mapping) + ``calibration`` (``{"cameras": {name: {...}}}``, or ``{"preset": name}`` + resolved in ``calib_dir``) -> :class:`CameraImage` list, reordered to ``order`` (every camera exactly once) when + given, else in input order. ``model(images=..., calibration=...)`` and ``POST /predict`` then see the same + cameras: the server passes the CameraImages it decoded, which come back unchanged.""" + if images is None: + raise InputError("this model needs 'images'" + (f": the cameras {list(order)}" if order else "")) + calib = resolve_calibration(calibration, calib_dir) if calibration is not None else None + if isinstance(images, Mapping) and not ({"camera", "image", "path", "data"} & set(images)): + entries = [(str(k), v) for k, v in images.items()] + else: + seq = list(images) if isinstance(images, (list, tuple)) else [images] + entries = [(None, v) for v in seq] + out = [load_camera(v, name=k, calibration=calib, require_calibration=require_calibration, max_bytes=max_bytes) + for k, v in entries] + names = [c.name for c in out] + if len(set(names)) != len(names): + raise InputError(f"duplicate camera names in images: {names}") + if order: + missing = [n for n in order if n not in names] + extra = [n for n in names if n not in order] + if missing or extra: + raise InputError(f"cameras must be exactly {list(order)}; missing {missing}, unexpected {extra}") + by_name = {c.name: c for c in out} + out = [by_name[n] for n in order] + return out + + +def decode_rois(rois: Any, *, cameras: Optional[Sequence[str]] = None, labels: Optional[Sequence[str]] = None, + max_rois: int = 4096) -> list: + """2-D detections given as an input (PointPainting's ``rois``, BUNDLE_CONVENTIONS.md 7.2): + ``[{"camera", "label", "score", "box_xyxy"}]`` -> the same list checked and normalised: ``camera`` a name (one of + ``cameras`` when given), ``label`` a name of ``labels`` or an index into it (returned as both ``label`` and + ``label_id`` when ``labels`` is given), ``score`` in [0, 1] (default 1.0), ``box_xyxy`` 4 finite pixels with + x0 <= x1 and y0 <= y1. ``None`` -> ``[]``.""" + if rois is None: + return [] + if not isinstance(rois, (list, tuple)): + raise InputError("rois must be a list of {camera, label, score, box_xyxy}") + if len(rois) > max_rois: + raise InputError(f"{len(rois)} rois exceed the limit of {max_rois}") + out = [] + for i, r in enumerate(rois): + where = f"rois[{i}]" + if not isinstance(r, Mapping): + raise InputError(f"{where} must be an object") + unknown = sorted(set(r) - {"camera", "label", "label_id", "score", "box_xyxy"}) + if unknown: + raise InputError(f"{where}: unknown keys {unknown}") + cam = r.get("camera") + if not isinstance(cam, str) or not cam: + raise InputError(f"{where}.camera must be a camera name") + if cameras is not None and cam not in cameras: + raise InputError(f"{where}.camera {cam!r} is not one of {list(cameras)}") + label = r.get("label", r.get("label_id")) + if isinstance(label, bool) or not isinstance(label, (str, int)): + raise InputError(f"{where}.label must be a class name or index") + entry: dict = {"camera": cam} + if labels is not None: + names = list(labels) + if isinstance(label, int): + if not 0 <= label < len(names): + raise InputError(f"{where}.label {label} is not an index of {names}") + entry.update(label=names[label], label_id=label) + elif label in names: + entry.update(label=label, label_id=names.index(label)) + else: + raise InputError(f"{where}.label {label!r} is not one of {names}") + else: + entry["label"] = label + score = r.get("score", 1.0) + try: + score = float(score) + except (TypeError, ValueError): + raise InputError(f"{where}.score must be a number") from None + if isinstance(r.get("score"), bool) or not 0.0 <= score <= 1.0: + raise InputError(f"{where}.score must be in [0, 1]") + try: + box = [float(v) for v in r.get("box_xyxy")] + except (TypeError, ValueError): + raise InputError(f"{where}.box_xyxy must be 4 numbers") from None + if len(box) != 4 or not all(math.isfinite(v) for v in box) or box[0] > box[2] or box[1] > box[3]: + raise InputError(f"{where}.box_xyxy must be 4 finite pixels with x0 <= x1 and y0 <= y1") + entry.update(score=score, box_xyxy=box) + out.append(entry) + return out + + +# ----------------------------------------------------------------------------------------- calibration + +def _rot_from_quat_wxyz(w: float, x: float, y: float, z: float) -> np.ndarray: + n = math.sqrt(w * w + x * x + y * y + z * z) + if n < 1e-9: + raise InputError("zero-length quaternion") + w, x, y, z = w / n, x / n, y / n, z / n + return np.array([[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], + [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], + [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]], dtype=np.float64) + + +def _rot_from_rpy(roll: float, pitch: float, yaw: float) -> np.ndarray: + """tf2 ``setRPY`` convention used by Autoware's sensor_kit_calibration.yaml: R = Rz(yaw) Ry(pitch) Rx(roll).""" + cr, sr, cp, sp, cy, sy = (math.cos(roll), math.sin(roll), math.cos(pitch), math.sin(pitch), math.cos(yaw), + math.sin(yaw)) + return np.array([[cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr], + [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr], + [-sp, cp * sr, cp * cr]], dtype=np.float64) + + +def parse_transform(obj: Any, *, field: str = "transform") -> np.ndarray: + """A rigid transform -> (4, 4) float64. Accepted spellings: + + - a 4x4 (or 3x4) nested list / array, row-major, translation in the last column; + - ``{"matrix": 4x4}``; + - ``{"translation": [x, y, z], "rotation_wxyz": [w, x, y, z]}`` (nuScenes / BEVDet sample yaml), or + ``rotation_xyzw``, or ``rotation`` (3x3); + - ``{"x", "y", "z", "roll", "pitch", "yaw"}`` in metres / radians (Autoware ``sensor_kit_calibration.yaml``). + """ + try: + if isinstance(obj, Mapping): + if "matrix" in obj: + return parse_transform(obj["matrix"], field=field) + if "translation" in obj: + t = np.asarray(obj["translation"], dtype=np.float64).reshape(3) + if "rotation_wxyz" in obj: + r = _rot_from_quat_wxyz(*np.asarray(obj["rotation_wxyz"], dtype=np.float64).reshape(4)) + elif "rotation_xyzw" in obj: + x, y, z, w = np.asarray(obj["rotation_xyzw"], dtype=np.float64).reshape(4) + r = _rot_from_quat_wxyz(w, x, y, z) + elif "rotation" in obj: + r = np.asarray(obj["rotation"], dtype=np.float64).reshape(3, 3) + else: + raise InputError(f"{field}: translation needs rotation_wxyz | rotation_xyzw | rotation (3x3)") + elif {"x", "y", "z", "roll", "pitch", "yaw"} <= set(obj): + t = np.array([obj["x"], obj["y"], obj["z"]], dtype=np.float64) + r = _rot_from_rpy(float(obj["roll"]), float(obj["pitch"]), float(obj["yaw"])) + else: + raise InputError(f"{field}: unrecognised transform keys {sorted(obj)}") + m = np.eye(4) + m[:3, :3], m[:3, 3] = r, t + else: + m = np.asarray(obj, dtype=np.float64) + if m.shape == (3, 4): + m = np.vstack([m, [0.0, 0.0, 0.0, 1.0]]) + if m.shape != (4, 4): + raise InputError(f"{field}: expected a 4x4 matrix, got shape {m.shape}") + except (TypeError, ValueError) as e: + if isinstance(e, InputError): + raise + raise InputError(f"{field}: {e}") from None + if not np.isfinite(m).all() or not np.allclose(m[3], [0, 0, 0, 1], atol=1e-6): + raise InputError(f"{field}: last row must be [0, 0, 0, 1]") + r = m[:3, :3] + if not np.allclose(r.T @ r, np.eye(3), atol=1e-3) or np.linalg.det(r) < 0: + raise InputError(f"{field}: rotation part is not a proper rotation") + return m + + +def parse_intrinsics(obj: Any, *, field: str = "intrinsics") -> np.ndarray: + """3x3 K, a 3x4 projection P (its left 3x3), or ``{"fx", "fy", "cx", "cy"[, "skew"]}`` -> (3, 3) float64.""" + try: + if isinstance(obj, Mapping): + k = np.array([[obj["fx"], obj.get("skew", 0.0), obj["cx"]], [0.0, obj["fy"], obj["cy"]], [0.0, 0.0, 1.0]], + dtype=np.float64) + else: + k = np.asarray(obj, dtype=np.float64) + if k.shape == (3, 4): + k = k[:, :3] + if k.shape != (3, 3): + raise InputError(f"{field}: expected 3x3, got shape {k.shape}") + except (KeyError, TypeError, ValueError) as e: + if isinstance(e, InputError): + raise + raise InputError(f"{field}: {e}") from None + if not np.isfinite(k).all() or k[0, 0] <= 0 or k[1, 1] <= 0: + raise InputError(f"{field}: fx and fy must be positive") + return k + + +_PRESET_NAME = re.compile(r"[A-Za-z0-9_.-]+") + + +def resolve_calibration(calibration: Optional[Mapping[str, Any]], calib_dir: Optional[Path]) -> Optional[dict]: + """``{"preset": "", ...}`` -> the preset file ``/.json`` merged with the other keys + (request keys win); any other mapping is returned as a dict; ``None`` stays ``None``.""" + if calibration is None: + return None + if "preset" not in calibration: + return dict(calibration) + name = str(calibration["preset"]) + if calib_dir is None or not _PRESET_NAME.fullmatch(name) or not (Path(calib_dir) / f"{name}.json").is_file(): + raise InputError(f"unknown calibration preset {name!r}; see /info input.calibration_presets") + base = json.loads((Path(calib_dir) / f"{name}.json").read_text()) + return {**base, **{k: v for k, v in calibration.items() if k != "preset"}} + + +# ------------------------------------------------------------------------------ named tensors (planner) + +def check_named_arrays(arrays: Mapping[str, Any], schema: Mapping[str, tuple]) -> dict: + """``{name: array}`` checked against ``schema`` (name -> (shape with ``None`` for free dims, dtype)): every listed + name is required, unlisted names are rejected, shapes must match and values must convert to the dtype without + loss of kind (numbers only; a float into an integer slot must be integral, values must be finite). Returns + C-contiguous arrays of the schema dtypes; every problem raises :class:`InputError`.""" + if not isinstance(arrays, Mapping): + raise InputError("inputs must be an object {name: array}") + unknown = sorted(set(arrays) - set(schema)) + missing = sorted(set(schema) - set(arrays)) + if unknown or missing: + raise InputError(f"inputs: missing {missing}, unexpected {unknown}") + out = {} + for name, (shape, dtype) in schema.items(): + try: + a = np.asarray(arrays[name]) + except (TypeError, ValueError) as e: + raise InputError(f"inputs[{name!r}]: {e}") from None + if len(a.shape) != len(shape) or any(s is not None and s != d for s, d in zip(shape, a.shape)): + raise InputError(f"inputs[{name!r}] has shape {tuple(a.shape)}, expected {tuple(shape)}") + if not (np.issubdtype(a.dtype, np.number) or a.dtype == np.bool_): + raise InputError(f"inputs[{name!r}] must be numeric, got dtype {a.dtype}") + target = np.dtype(dtype) + if a.size and np.issubdtype(a.dtype, np.inexact) and not np.isfinite(a).all(): + raise InputError(f"inputs[{name!r}] holds non-finite values") + if a.size and np.issubdtype(target, np.integer) and a.dtype != np.bool_: + if np.issubdtype(a.dtype, np.inexact) and not np.array_equal(a, np.round(a)): + raise InputError(f"inputs[{name!r}] must hold integers ({target})") + info = np.iinfo(target) + if a.min() < info.min or a.max() > info.max: + raise InputError(f"inputs[{name!r}] has values outside the {target} range") + out[name] = np.ascontiguousarray(a.astype(target, copy=False)) + return out + + +def decode_named_arrays(spec: Mapping[str, Any], schema: Optional[Mapping[str, tuple]] = None, *, + max_bytes: Optional[int] = None) -> dict: + """``{"format": "npz", "data": }`` or ``{"format": "json", "arrays": {name: nested list}}`` -> + ``{name: ndarray}``. With ``schema`` the arrays are checked and cast by :func:`check_named_arrays`.""" + if not isinstance(spec, Mapping): + raise InputError("inputs must be an object {format, data | arrays}") + fmt = str(spec.get("format") or "npz").lower() + if fmt == "npz": + raw = b64decode(spec.get("data"), field="inputs.data", max_bytes=max_bytes) + try: + z = np.load(io.BytesIO(raw), allow_pickle=False) + arrays = {k: z[k] for k in z.files} + except (ValueError, OSError) as e: + raise InputError(f"inputs npz: {e}") from None + elif fmt == "json": + src = spec.get("arrays") + if not isinstance(src, Mapping): + raise InputError("inputs.arrays must be an object {name: nested list}") + arrays = {} + for k, v in src.items(): + try: + arrays[k] = np.asarray(v) + except (TypeError, ValueError) as e: + raise InputError(f"inputs.arrays[{k!r}]: {e}") from None + else: + raise InputError(f"inputs.format {fmt!r} must be 'npz' or 'json'") + return arrays if schema is None else check_named_arrays(arrays, schema) + + +def load_named_arrays(source: Any, schema: Optional[Mapping[str, tuple]] = None) -> dict: + """Python-API named inputs (planner): a ``{name: array}`` mapping, the JSON envelope of + :func:`decode_named_arrays`, an ``.npz`` path or its bytes -> ``{name: ndarray}`` (checked against ``schema`` + when given, so ``model(inputs=...)`` and ``POST /predict`` accept and refuse the same inputs).""" + if isinstance(source, Mapping) and "format" in source and ("data" in source or "arrays" in source): + return decode_named_arrays(source, schema) + if isinstance(source, (str, Path, bytes, bytearray, memoryview)): + try: + payload = Path(source).read_bytes() if isinstance(source, (str, Path)) else bytes(source) + with np.load(io.BytesIO(payload), allow_pickle=False) as z: + arrays = {k: z[k] for k in z.files} + except (ValueError, OSError) as e: + raise InputError(f"inputs npz: {e}") from None + elif isinstance(source, Mapping): + arrays = {str(k): (v.detach().cpu().numpy() if hasattr(v, "detach") else v) for k, v in source.items()} + else: + raise InputError(f"inputs must be a mapping, an .npz path or bytes, not {type(source).__name__}") + return arrays if schema is None else check_named_arrays(arrays, schema) + + +# ------------------------------------------------------------------------------------------- outputs + +def encode_array(arr: np.ndarray, *, fmt: str = "npz", key: str = "array") -> dict: + """A dense output for the JSON envelope: ``{"format", "key", "dtype", "shape", "data"}``. + + ``npz`` (default): base64 of ``np.savez`` -> ``np.load(io.BytesIO(base64.b64decode(d["data"])))[d["key"]]``; + ``npz_compressed``: the same with ``savez_compressed`` (smaller, slower); ``raw``: base64 of the little-endian + bytes (``np.frombuffer(..., dtype).reshape(shape)``); ``list``: nested JSON lists (small arrays only).""" + arr = np.asarray(arr) + head = {"format": fmt, "key": key, "dtype": str(arr.dtype), "shape": list(arr.shape)} + if fmt in ("npz", "npz_compressed"): + buf = io.BytesIO() + (np.savez_compressed if fmt == "npz_compressed" else np.savez)(buf, **{key: arr}) + return {**head, "data": base64.b64encode(buf.getvalue()).decode("ascii")} + if fmt == "raw": + a = arr.astype(arr.dtype.newbyteorder("<"), copy=False) + return {**head, "data": base64.b64encode(np.ascontiguousarray(a).tobytes()).decode("ascii")} + if fmt == "list": + return {**head, "data": arr.tolist()} + raise ValueError(f"unknown array encoding {fmt!r}") + + +def encode_png(image: np.ndarray, *, key: str = "image") -> dict: + """A uint8 mask / label map (H, W) or RGB image (H, W, 3) as base64 PNG (lossless): + ``{"format": "png", "key", "dtype", "shape", "data"}``.""" + from PIL import Image + + a = np.asarray(image) + if a.dtype != np.uint8: + if a.size and (a.min() < 0 or a.max() > 255): + raise ValueError("encode_png needs values in 0..255 (use encode_array for wider label ranges)") + a = a.astype(np.uint8) + if a.ndim not in (2, 3): + raise ValueError(f"encode_png expects (H, W) or (H, W, 3), got {a.shape}") + buf = io.BytesIO() + Image.fromarray(a).save(buf, format="PNG") + return {"format": "png", "key": key, "dtype": "uint8", "shape": list(a.shape), + "data": base64.b64encode(buf.getvalue()).decode("ascii")} + + +def to_jsonable(obj: Any) -> Any: + """numpy scalars / arrays -> plain Python, recursively. float32 / float16 scalars are rounded to 6 significant + digits (their precision); float64 scalars and array elements are kept exactly (timestamps, poses).""" + if isinstance(obj, Mapping): + return {str(k): to_jsonable(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [to_jsonable(v) for v in obj] + if isinstance(obj, np.ndarray): + return to_jsonable(obj.tolist()) + if isinstance(obj, (np.float32, np.float16)): + return float(f"{float(obj):.6g}") + if isinstance(obj, np.floating): + return float(obj) + if isinstance(obj, np.integer): + return int(obj) + if isinstance(obj, np.bool_): + return bool(obj) + if isinstance(obj, Path): + return str(obj) + return obj diff --git a/code/tt_frnet/ttaw/knobs.py b/code/tt_frnet/ttaw/knobs.py new file mode 100644 index 0000000000000000000000000000000000000000..beb8c02586bbca51f9ca92bc16f6150348740e14 --- /dev/null +++ b/code/tt_frnet/ttaw/knobs.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Optimization knobs: declared once, read from the environment once at model build, each an A/B switch. + +The rule every port follows (PLAN.md section 1.1; RP section 1.2): a knob's default is the measured best, the +server pins the full set in ``tt-model.yaml serve.env`` (:meth:`Knobs.serve_env` renders it), and a host test can +check the pins against the Python defaults. Setting an ``experiment`` knob logs a warning, so an A/B run is never +mistaken for the published configuration. + +Example:: + + KNOBS = Knobs("CENTERPOINT", [ + Knob("FUSED_HEAD", True, "merged 64->384->15 head convs (False = one conv per head)"), + Knob("BFP8_WEIGHTS", False, "bfp8 backbone weights", experiment=True), + Knob("NUM_CQS", 1, "command queues", choices=(1, 2)), + ]) + knobs = KNOBS.read() # at build; KnobValues is immutable + if knobs.FUSED_HEAD: ... +""" +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from typing import Any, Dict, Iterable, Iterator, Mapping, Optional, Tuple + +__all__ = ["Knob", "Knobs", "KnobValues", "parse_bool"] + +log = logging.getLogger(__name__) + +_TRUE = {"1", "true", "yes", "on", "y"} +_FALSE = {"0", "false", "no", "off", "n", ""} + + +def parse_bool(text: str) -> bool: + value = str(text).strip().lower() + if value in _TRUE: + return True + if value in _FALSE: + return False + raise ValueError(f"{text!r} is not a boolean (1/0, true/false, on/off, yes/no)") + + +@dataclass(frozen=True) +class Knob: + """One switch. ``name`` is the suffix of the env variable ``_``; the type is the default's.""" + + name: str + default: Any + doc: str = "" + choices: Optional[Tuple[Any, ...]] = None + experiment: bool = False + + def __post_init__(self) -> None: + if not self.name or not self.name.replace("_", "").isalnum() or self.name.upper() != self.name: + raise ValueError(f"knob name {self.name!r} must be UPPER_SNAKE_CASE") + if self.choices is not None and self.default not in self.choices: + raise ValueError(f"knob {self.name}: default {self.default!r} not in {self.choices}") + + def parse(self, text: str) -> Any: + kind = type(self.default) + try: + if kind is bool: + value: Any = parse_bool(text) + elif kind is int: + value = int(text.strip()) + elif kind is float: + value = float(text.strip()) + else: + value = text.strip() + except ValueError as exc: + raise ValueError(f"{self.name}={text!r}: {exc}") from None + if self.choices is not None and value not in self.choices: + raise ValueError(f"{self.name}={value!r}: expected one of {self.choices}") + return value + + def render(self, value: Any) -> str: + if isinstance(value, bool): + return "1" if value else "0" + return str(value) + + +class KnobValues(Mapping): + """Immutable knob values with their source (``"default"`` / ``"env"`` / ``"override"``); attribute or item + access.""" + + def __init__(self, prefix: str, values: Dict[str, Any], sources: Dict[str, str]): + object.__setattr__(self, "_prefix", prefix) + object.__setattr__(self, "_values", dict(values)) + object.__setattr__(self, "_sources", dict(sources)) + + def __getattr__(self, name: str) -> Any: + if name.startswith("_"): + raise AttributeError(name) + try: + return self._values[name] + except KeyError: + raise AttributeError(f"no knob {name!r} (prefix {self._prefix})") from None + + def __setattr__(self, name: str, value: Any) -> None: + raise AttributeError("KnobValues is read-only (knobs are read once at build)") + + def __getitem__(self, name: str) -> Any: + return self._values[name] + + def __iter__(self) -> Iterator[str]: + return iter(self._values) + + def __len__(self) -> int: + return len(self._values) + + def source(self, name: str) -> str: + return self._sources[name] + + def overridden(self) -> Dict[str, Any]: + """Knobs not at their default source (set from the environment or by an explicit override).""" + return {k: v for k, v in self._values.items() if self._sources[k] != "default"} + + def as_dict(self) -> Dict[str, Any]: + return dict(self._values) + + def __repr__(self) -> str: + return f"KnobValues({self._prefix}: {self._values})" + + +class Knobs: + """The declared knob set of one model (env prefix = the bundle's ````).""" + + def __init__(self, prefix: str, knobs: Iterable[Knob]): + self.prefix = prefix.strip().upper() + self.knobs: Dict[str, Knob] = {} + for knob in knobs: + if knob.name in self.knobs: + raise ValueError(f"duplicate knob {knob.name}") + self.knobs[knob.name] = knob + + def env_name(self, name: str) -> str: + return f"{self.prefix}_{name}" + + def defaults(self) -> KnobValues: + return KnobValues(self.prefix, {n: k.default for n, k in self.knobs.items()}, + {n: "default" for n in self.knobs}) + + def read(self, env: Optional[Mapping[str, str]] = None, **overrides: Any) -> KnobValues: + """Defaults, then ``_`` environment values, then explicit ``overrides`` (e.g. from + ``from_pretrained`` keyword arguments). Raises on unparsable values; warns on experiment knobs.""" + env = os.environ if env is None else env + values, sources = {}, {} + for name, knob in self.knobs.items(): + raw = env.get(self.env_name(name)) + if raw is not None and raw.strip() != "": + values[name], sources[name] = knob.parse(raw), "env" + else: + values[name], sources[name] = knob.default, "default" + for name, value in overrides.items(): + if name not in self.knobs: + raise KeyError(f"unknown knob {name!r}; have {sorted(self.knobs)}") + knob = self.knobs[name] + values[name], sources[name] = knob.parse(knob.render(value)), "override" + experiments = [n for n, k in self.knobs.items() if k.experiment and values[n] != k.default] + if experiments: + log.warning("%s: experiment knobs set (%s); results are not the published configuration", self.prefix, + ", ".join(f"{self.env_name(n)}={self.knobs[n].render(values[n])}" for n in experiments)) + return KnobValues(self.prefix, values, sources) + + def serve_env(self, values: Optional[Mapping[str, Any]] = None) -> Dict[str, str]: + """``{"_": ""}`` for pinning in ``tt-model.yaml serve.env`` (defaults if no values).""" + vals = values if values is not None else self.defaults() + return {self.env_name(n): k.render(vals[n]) for n, k in self.knobs.items()} + + def doc_table(self) -> str: + """A Markdown table of the knobs (for SERVING.md / OPT_REPORT.md).""" + rows = ["| variable | default | meaning |", "|---|---|---|"] + for name, knob in self.knobs.items(): + extra = f" (one of {', '.join(map(str, knob.choices))})" if knob.choices else "" + tag = " **experiment**" if knob.experiment else "" + rows.append(f"| `{self.env_name(name)}` | `{knob.render(knob.default)}` | {knob.doc}{extra}{tag} |") + return "\n".join(rows) diff --git a/code/tt_frnet/ttaw/metrics.py b/code/tt_frnet/ttaw/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..c48539b2deb99b5631aa1e97baba586146bd8240 --- /dev/null +++ b/code/tt_frnet/ttaw/metrics.py @@ -0,0 +1,281 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C06 metrics: device-vs-reference agreement measures (host side, numpy only, float64 arithmetic). + +Inputs may be numpy arrays, torch tensors (bf16 included) or nested lists. Gates are applied by +:mod:`.golden` (``GateRegistry``); these functions only measure. + +========================== ===================================================================================== +function use +========================== ===================================================================================== +``pcc`` dense Pearson correlation (the default gate metric) +``masked_pcc`` PCC over the elements where a mask is true (valid pillars, in-range points) +``valid_row_pcc`` PCC over the first ``n_valid`` rows of a fixed-capacity buffer +``error_stats`` max / mean abs error, relative L2, PCC in one dict +``argmax_agreement`` fraction of positions whose argmax over ``axis`` agrees (segmentation logits) +``label_agreement`` fraction of equal labels (decided class maps) +``mask_iou`` ``mean_iou`` binary-mask IoU; per-class and mean IoU of label maps +``box_iou_xyxy`` pairwise IoU of 2-D boxes +``topk_overlap`` |A ∩ B| / k of two index sets (data-dependent selections) +``topk_set_overlap`` the same, computed from two score arrays +``match_detections`` greedy same-label matching by BEV-centre distance: recall, precision, errors +``ade_fde`` average / final displacement error of trajectories +========================== ===================================================================================== +""" +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +__all__ = [ + "as_array", + "pcc", + "masked_pcc", + "valid_row_pcc", + "error_stats", + "argmax_agreement", + "label_agreement", + "mask_iou", + "mean_iou", + "box_iou_xyxy", + "topk_overlap", + "topk_set_overlap", + "DetectionMatch", + "match_detections", + "ade_fde", +] + + +def as_array(x: Any, dtype: Optional[Any] = None) -> np.ndarray: + """numpy / torch (any dtype, bf16 widened to float32) / list -> numpy, without importing torch.""" + if hasattr(x, "detach") and hasattr(x, "cpu"): + t = x.detach().cpu() + if str(t.dtype) == "torch.bfloat16": + t = t.float() + x = t.numpy() + a = np.asarray(x) + return a if dtype is None else a.astype(dtype, copy=False) + + +def _pcc_flat(a: np.ndarray, b: np.ndarray) -> float: + if a.shape != b.shape: + raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}") + if a.size == 0: + return float("nan") + if not (np.isfinite(a).all() and np.isfinite(b).all()): + return float("nan") + # A constant side is detected exactly (max == min), never from the centred sum of squares: the mean of a + # constant array is not exact in floating point (np.full(1000, 0.1).mean() != 0.1), so the centred values hold + # rounding noise that would correlate perfectly with the other side's noise (two different constants gave +-1). + const_a, const_b = a.max() == a.min(), b.max() == b.min() + if const_a or const_b: # equal constants agree perfectly, anything else does not + return 1.0 if (const_a and const_b and np.allclose(a, b)) else 0.0 + da = a - a.mean() + db = b - b.mean() + sa = float(np.dot(da, da)) + sb = float(np.dot(db, db)) + return float(np.dot(da, db) / math.sqrt(sa * sb)) + + +def pcc(test: Any, ref: Any) -> float: + """Pearson correlation of two equally shaped tensors (float64). NaN if any value is non-finite or both are + empty; when a side is constant: 1.0 for two equal constants (``np.allclose``), else 0.0 (two different + constants included).""" + return _pcc_flat(as_array(test, np.float64).reshape(-1), as_array(ref, np.float64).reshape(-1)) + + +def masked_pcc(test: Any, ref: Any, mask: Any) -> float: + """PCC over ``mask`` (broadcastable to the tensors' shape).""" + a, b = as_array(test, np.float64), as_array(ref, np.float64) + if a.shape != b.shape: + raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}") + m = np.broadcast_to(as_array(mask).astype(bool), a.shape) + return _pcc_flat(a[m], b[m]) + + +def valid_row_pcc(test: Any, ref: Any, n_valid: Optional[int] = None, *, rows: Any = None, axis: int = 0) -> float: + """PCC over the valid rows of fixed-capacity buffers: the first ``n_valid`` along ``axis``, or the rows where + the boolean vector ``rows`` is true.""" + a, b = as_array(test, np.float64), as_array(ref, np.float64) + if (n_valid is None) == (rows is None): + raise ValueError("give exactly one of n_valid and rows") + if rows is None: + idx = np.arange(int(n_valid)) + else: + idx = np.flatnonzero(as_array(rows).astype(bool)) + return _pcc_flat(np.take(a, idx, axis=axis).reshape(-1), np.take(b, idx, axis=axis).reshape(-1)) + + +def error_stats(test: Any, ref: Any) -> Dict[str, float]: + """``{"pcc", "max_abs", "mean_abs", "rel_l2", "n"}`` (``rel_l2 = ||t - r|| / ||r||``).""" + a, b = as_array(test, np.float64), as_array(ref, np.float64) + if a.shape != b.shape: + raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}") + d = np.abs(a - b) + ref_norm = float(np.linalg.norm(b)) + return {"pcc": _pcc_flat(a.reshape(-1), b.reshape(-1)), + "max_abs": float(d.max()) if d.size else 0.0, + "mean_abs": float(d.mean()) if d.size else 0.0, + "rel_l2": float(np.linalg.norm(a - b) / ref_norm) if ref_norm > 0 else float(np.linalg.norm(a - b)), + "n": int(a.size)} + + +def argmax_agreement(test_logits: Any, ref_logits: Any, *, axis: int = -1, mask: Any = None) -> float: + """Fraction of positions where ``argmax`` over ``axis`` agrees (ties resolve to the lowest index, as numpy + and Autoware's decision rules do). ``mask`` selects positions (shape without ``axis``).""" + a = np.argmax(as_array(test_logits), axis=axis) + b = np.argmax(as_array(ref_logits), axis=axis) + return label_agreement(a, b, mask=mask) + + +def label_agreement(test_labels: Any, ref_labels: Any, *, mask: Any = None) -> float: + """Fraction of equal labels (optionally over ``mask``). NaN when nothing is selected.""" + a, b = as_array(test_labels), as_array(ref_labels) + if a.shape != b.shape: + raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}") + eq = a == b + if mask is not None: + eq = eq[np.broadcast_to(as_array(mask).astype(bool), eq.shape)] + return float(eq.mean()) if eq.size else float("nan") + + +def mask_iou(test_mask: Any, ref_mask: Any) -> float: + """IoU of two binary masks (1.0 when both are empty).""" + a, b = as_array(test_mask).astype(bool), as_array(ref_mask).astype(bool) + union = np.logical_or(a, b).sum() + return 1.0 if union == 0 else float(np.logical_and(a, b).sum() / union) + + +def mean_iou(test_labels: Any, ref_labels: Any, num_classes: int, *, + ignore_index: Optional[int] = None) -> Tuple[float, List[float]]: + """``(mIoU over classes present in either map, per-class IoU with NaN for absent classes)``.""" + a, b = as_array(test_labels).reshape(-1), as_array(ref_labels).reshape(-1) + if a.shape != b.shape: + raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}") + if ignore_index is not None: + keep = b != ignore_index + a, b = a[keep], b[keep] + per = [] + for c in range(num_classes): + inter = np.logical_and(a == c, b == c).sum() + union = np.logical_or(a == c, b == c).sum() + per.append(float(inter / union) if union else float("nan")) + valid = [v for v in per if not math.isnan(v)] + return (float(np.mean(valid)) if valid else float("nan")), per + + +def box_iou_xyxy(boxes_a: Any, boxes_b: Any) -> np.ndarray: + """Pairwise IoU ``[N, M]`` of axis-aligned boxes ``[x1, y1, x2, y2]`` (float64).""" + a = as_array(boxes_a, np.float64).reshape(-1, 4) + b = as_array(boxes_b, np.float64).reshape(-1, 4) + ix1 = np.maximum(a[:, None, 0], b[None, :, 0]) + iy1 = np.maximum(a[:, None, 1], b[None, :, 1]) + ix2 = np.minimum(a[:, None, 2], b[None, :, 2]) + iy2 = np.minimum(a[:, None, 3], b[None, :, 3]) + inter = np.clip(ix2 - ix1, 0, None) * np.clip(iy2 - iy1, 0, None) + area_a = np.clip(a[:, 2] - a[:, 0], 0, None) * np.clip(a[:, 3] - a[:, 1], 0, None) + area_b = np.clip(b[:, 2] - b[:, 0], 0, None) * np.clip(b[:, 3] - b[:, 1], 0, None) + union = area_a[:, None] + area_b[None, :] - inter + with np.errstate(invalid="ignore", divide="ignore"): + return np.where(union > 0, inter / union, 0.0) + + +def topk_overlap(test_indices: Any, ref_indices: Any, k: Optional[int] = None) -> float: + """``|set(test[:k]) ∩ set(ref[:k])| / k`` (``k`` defaults to ``len(ref)``).""" + a, b = as_array(test_indices).reshape(-1), as_array(ref_indices).reshape(-1) + k = len(b) if k is None else int(k) + if k <= 0: + return float("nan") + return len(set(a[:k].tolist()) & set(b[:k].tolist())) / k + + +def topk_set_overlap(test_scores: Any, ref_scores: Any, k: int) -> float: + """Overlap of the top-``k`` index sets of two score arrays (flattened; stable descending order).""" + a, b = as_array(test_scores, np.float64).reshape(-1), as_array(ref_scores, np.float64).reshape(-1) + ta = np.argsort(-a, kind="stable")[:k] + tb = np.argsort(-b, kind="stable")[:k] + return topk_overlap(ta, tb, k) + + +@dataclass +class DetectionMatch: + """Result of :func:`match_detections` (``pairs`` are ``(test_index, ref_index)``).""" + + pairs: List[Tuple[int, int]] + n_test: int + n_ref: int + center_errors: List[float] = field(default_factory=list) + score_errors: List[float] = field(default_factory=list) + + @property + def matched(self) -> int: + return len(self.pairs) + + @property + def recall(self) -> float: + return self.matched / self.n_ref if self.n_ref else 1.0 + + @property + def precision(self) -> float: + return self.matched / self.n_test if self.n_test else 1.0 + + def to_dict(self) -> Dict[str, Any]: + def stat(v: List[float], fn) -> Optional[float]: + return float(fn(v)) if v else None + + return {"n_test": self.n_test, "n_ref": self.n_ref, "matched": self.matched, "recall": self.recall, + "precision": self.precision, "center_err_mean": stat(self.center_errors, np.mean), + "center_err_max": stat(self.center_errors, np.max), "score_err_max": stat(self.score_errors, np.max), + "score_err_mean": stat(self.score_errors, np.mean)} + + +def _centers(x: Any, n: int, dims: int) -> np.ndarray: + a = as_array(x, np.float64) + if a.size == 0: + return np.zeros((n, dims)) + if a.size % max(n, 1) or n == 0: + raise ValueError(f"{a.shape[0] if a.ndim else 0} centres for {n} labels: lengths differ") + return a.reshape(n, -1)[:, :dims] + + +def match_detections(test_centers: Any, test_labels: Any, test_scores: Any, ref_centers: Any, ref_labels: Any, + ref_scores: Any, *, max_dist: float = 0.5, dims: int = 2, + same_label: bool = True) -> DetectionMatch: + """Greedy matching: reference boxes in descending score order each take the nearest unused test box (same + label unless ``same_label=False``) within ``max_dist`` of its centre, measured over the first ``dims`` + coordinates (2 = BEV x/y). ``*_centers`` are ``[N, >=dims]`` (box arrays ``[N, 7]`` work as-is). A box with a + non-finite centre never matches (it costs recall / precision; it does not block the other boxes).""" + tl, rl = as_array(test_labels).reshape(-1), as_array(ref_labels).reshape(-1) + ts, rs = as_array(test_scores, np.float64).reshape(-1), as_array(ref_scores, np.float64).reshape(-1) + if len(ts) != len(tl) or len(rs) != len(rl): + raise ValueError(f"labels / scores lengths differ: test {len(tl)}/{len(ts)}, ref {len(rl)}/{len(rs)}") + tc, rc = _centers(test_centers, len(tl), dims), _centers(ref_centers, len(rl), dims) + used = np.zeros(len(tl), bool) + result = DetectionMatch([], len(tl), len(rl)) + for i in np.argsort(-rs, kind="stable"): + if not len(tl): + break + d = np.linalg.norm(tc - rc[i], axis=1) + bad = used | ~np.isfinite(d) # np.argmin would pick a NaN distance before any real one + if same_label: + bad |= tl != rl[i] + d[bad] = np.inf + j = int(np.argmin(d)) + if d[j] <= max_dist: + used[j] = True + result.pairs.append((j, int(i))) + result.center_errors.append(float(d[j])) + result.score_errors.append(abs(float(ts[j] - rs[i]))) + return result + + +def ade_fde(pred: Any, ref: Any, *, dims: int = 2) -> Tuple[float, float]: + """Average and final displacement errors of trajectories ``[..., T, >=dims]`` (Euclidean over the first + ``dims`` coordinates; averaged over all leading dims).""" + p, r = as_array(pred, np.float64), as_array(ref, np.float64) + if p.shape[:-1] != r.shape[:-1]: + raise ValueError(f"trajectory shapes differ: {p.shape} vs {r.shape}") + disp = np.linalg.norm(p[..., :dims] - r[..., :dims], axis=-1) + return float(disp.mean()), float(disp[..., -1].mean()) diff --git a/code/tt_frnet/ttaw/models/__init__.py b/code/tt_frnet/ttaw/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2fd611f43dbd1e038726ea1e660cf4c4bcbe36b8 --- /dev/null +++ b/code/tt_frnet/ttaw/models/__init__.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Shared device sub-networks of the Autoware ports (PLAN.md section 1.2), built from host (numpy) weights with +BatchNorm already folded, on the C17 builders (``ttaw.ops.conv``: ``FeatureMap``, ``Conv2d``, ``KSplitConv``, +``ConvTranspose2d``); every op traceable, weights prepared by the first (eager) call. + +================== ====== ========================================================================= +module id what it provides +================== ====== ========================================================================= +``second`` C24 SECOND backbone and SECONDFPN neck (CenterPoint, PointPainting, TransFusion, + BEVFusion); ``RowLinear`` (1x1 convs as ``ttnn.linear`` on NHWC rows) +``centerhead`` C25 CenterPoint dense head: K-split shared conv, merged sibling heads, one packed + fp32 output +``pillars`` C24 PillarFeatureNet of the pillar detectors (CenterPoint, PointPainting, TransFusion): + identity-folded split PFN on 32-slot pillar tiles, input packing, ``FrameStaging`` +``resnet`` C27 ResNet bottleneck / basic-block builders from a parameter provider, cameras as + batch (BEVDet image backbone + CustomResNet; BEVFormer, METEOR) +``transfusion_head`` C26 TransFusion query head (TransFusion, BEVFusion, PTv3): C21 selection, query init + with QPE / KPE tables, one decoder layer on C20, merged prediction heads +================== ====== ========================================================================= + +Importing a module never imports ttnn / torch (rule of the package): device work happens in the layer calls. +""" + +__all__ = ["second", "centerhead", "pillars", "resnet", "transfusion_head"] diff --git a/code/tt_frnet/ttaw/models/centerhead.py b/code/tt_frnet/ttaw/models/centerhead.py new file mode 100644 index 0000000000000000000000000000000000000000..4e25b71096b502e02230aba8bfa2b9bc1522821e --- /dev/null +++ b/code/tt_frnet/ttaw/models/centerhead.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C25: the CenterPoint dense head on the device (PLAN.md section 1.2 row C25; CenterPoint, PointPainting). + +mmdet3d ``CenterHead`` (single task, as Autoware's exports): ``shared = relu(conv3x3(concat[d_0..d_n]))``, then per +output head ``out_h = conv1x1(relu(conv1x1(shared)))``. Two exact rewrites (each differs from the literal graph +only by summation order; probe P14 and research/centerpoint/SPEC.md 8.1): + +- **K-split shared conv** (default; C17 ``KSplitConv``): ``conv(concat[d_i], W) == sum_i conv(d_i, W_i)``, the bias + on the first partial conv and the ReLU fused into the last add. The concat (CenterPoint base: 177 MB) never + exists; P14: 3.6 ms against 14.8 ms for the full-K conv, and more accurate. ``accumulate_dtype="float32"`` keeps + the partials in fp32; +- **merged sibling heads**: the six 1x1 -> ReLU -> 1x1 heads become two matmuls on NHWC rows (:class:`..second. + RowLinear`): ``hidden = relu(x @ Wh + bh)`` with ``Wh = [Wh_1 | ... | Wh_6]`` (``64 -> 6*64``), then ``out = + hidden @ Wo + bo`` with the block-diagonal ``Wo`` (``6*64 -> sum C_h``, zero-padded to a multiple of 32 columns so + the output tensor is tile-exact). + +The output is ONE ``[1, 1, H*W, C_pad]`` tensor (fp32 by default: the logits feed host thresholds), i.e. one +readback; :meth:`CenterHead.unpack` splits the host rows into ``{name: (C, H, W)}``. Module names for the precision +policy: ``.shared``, ``.hidden``, ``.out``. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from ..ops.conv import Conv2d, FeatureMap, KSplitConv +from ..precision import PrecisionPolicy +from .second import ConvSpec, RowLinear, _precision, _to_dram, free_maps + +__all__ = ["CenterHead", "HeadSpec", "merge_heads", "centerhead_numpy"] + +HeadSpec = Tuple[str, ConvSpec, ConvSpec] # (output name, hidden 1x1 + ReLU, final 1x1) + + +def merge_heads(heads: Sequence[HeadSpec], *, pad_to: int = 32 + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, List[Tuple[str, int, int]]]: + """``(Wh [Cin, sum hidden], bh, Wo [sum hidden, C_pad], bo [C_pad], slices)`` of the merged heads; ``slices`` + lists ``(name, start, stop)`` of each head's output columns; columns past ``sum C_h`` are zero.""" + if not heads: + raise ValueError("no heads") + cin = heads[0][1].in_channels + wh, bh, rows, cols = [], [], 0, 0 + for name, hidden, final in heads: + if hidden.kind != "conv" or final.kind != "conv" or hidden.kernel != 1 or final.kernel != 1: + raise ValueError(f"head {name!r}: merged heads need 1x1 convs") + if hidden.in_channels != cin or final.in_channels != hidden.out_channels: + raise ValueError(f"head {name!r}: channel mismatch") + if not hidden.relu or final.relu: + raise ValueError(f"head {name!r}: expected a ReLU after the hidden conv only") + wh.append(np.asarray(hidden.weight, np.float32)[:, :, 0, 0].T) + bh.append(np.zeros(hidden.out_channels, np.float32) if hidden.bias is None + else np.asarray(hidden.bias, np.float32)) + rows += hidden.out_channels + cols += final.out_channels + cpad = -(-cols // pad_to) * pad_to + wo = np.zeros((rows, cpad), np.float32) + bo = np.zeros(cpad, np.float32) + slices, r, c = [], 0, 0 + for name, hidden, final in heads: + co = final.out_channels + wo[r:r + hidden.out_channels, c:c + co] = np.asarray(final.weight, np.float32)[:, :, 0, 0].T + if final.bias is not None: + bo[c:c + co] = np.asarray(final.bias, np.float32) + slices.append((name, c, c + co)) + r += hidden.out_channels + c += co + return (np.ascontiguousarray(np.concatenate(wh, axis=1)), np.concatenate(bh).astype(np.float32), wo, bo, + slices) + + +class CenterHead: + """Shared conv (K-split over ``split``) + merged heads; see the module docstring. + + ``shared``: the 3x3 :class:`ConvSpec` over the concatenated inputs (ReLU after); ``split``: channels of each + input, in concat order; ``heads``: ``(name, hidden, final)`` in output order. ``k_split=False`` runs one conv + on a device concat instead (A/B only). ``output_dtype``: of the packed head output (``"float32"``). The shared + conv's output dtype is its precision's ``activations`` (bf16 by default); ``accumulate_dtype`` (default: the same) + is the dtype of the K-split partial sums.""" + + def __init__(self, shared: ConvSpec, split: Sequence[int], heads: Sequence[HeadSpec], *, + policy: Optional[PrecisionPolicy] = None, prefix: str = "head", output_dtype: str = "float32", + k_split: bool = True, accumulate_dtype: Optional[str] = None): + if shared.kind != "conv" or shared.stride != 1 or not shared.relu: + raise ValueError("the shared conv must be a stride-1 conv followed by a ReLU") + self.prefix = prefix + self.split = [int(s) for s in split] + if sum(self.split) != shared.in_channels: + raise ValueError(f"split {self.split} does not cover the {shared.in_channels} shared-conv inputs") + self.k_split = bool(k_split) and len(self.split) > 1 + pad = shared.kernel // 2 if shared.padding is None else int(shared.padding) + prec = _precision(policy, f"{prefix}.shared") + if self.k_split: + self.shared = KSplitConv(shared.weight, shared.bias, self.split, activation="relu", padding=pad, + precision=prec, accumulate_dtype=accumulate_dtype or prec.activations, + output_dtype=prec.activations, name=f"{prefix}.shared") + else: + self.shared = Conv2d(shared.weight, shared.bias, padding=pad, activation="relu", precision=prec, + output_dtype=prec.activations, name=f"{prefix}.shared") + wh, bh, wo, bo, self.slices = merge_heads(heads) + self.out_channels = self.slices[-1][2] + self.padded_channels = int(wo.shape[1]) + hidden_prec = _precision(policy, f"{prefix}.hidden") + self.hidden = RowLinear(wh, bh, activation="relu", precision=hidden_prec, + output_dtype=hidden_prec.activations, name=f"{prefix}.hidden") + self.out = RowLinear(wo, bo, activation=None, precision=_precision(policy, f"{prefix}.out"), + output_dtype=output_dtype, name=f"{prefix}.out") + + def shared_forward(self, inputs: Sequence[FeatureMap]) -> FeatureMap: + """``relu(conv3x3(concat(inputs)))`` (inputs are kept), DRAM interleaved.""" + import ttnn + + if [x.channels for x in inputs] != self.split: + raise ValueError(f"{self.prefix}: inputs have {[x.channels for x in inputs]} channels, " + f"expected {self.split}") + if self.k_split: + return _to_dram(self.shared(inputs)) + cat = inputs[0].with_tensor(ttnn.concat([x.tensor for x in inputs], dim=-1), sum(self.split)) + out = _to_dram(self.shared(cat)) + free_maps(cat) + return out + + def heads_forward(self, shared: FeatureMap) -> FeatureMap: + """Merged heads on the shared map -> ``[1, 1, H*W, C_pad]`` (``channels`` = the padded width).""" + hidden = self.hidden(shared.tensor) + out = self.out(hidden) + free_maps(hidden) + return shared.with_tensor(out, self.padded_channels) + + def __call__(self, inputs: Sequence[FeatureMap]) -> FeatureMap: + shared = self.shared_forward(inputs) + out = self.heads_forward(shared) + free_maps(shared) + return out + + def unpack(self, rows: Any, height: int, width: int) -> Dict[str, np.ndarray]: + """Host rows ``[H*W, >= C]`` (a read-back of the head output) -> ``{name: (C_h, H, W)}`` float32.""" + a = np.asarray(rows, dtype=np.float32) + a = a.reshape(-1, a.shape[-1])[: height * width] + return {name: np.ascontiguousarray(a[:, s:e].T.reshape(e - s, height, width)) for name, s, e in self.slices} + + def release(self) -> None: + self.shared.release() + self.hidden.release() + self.out.release() + + def describe(self) -> Dict[str, Any]: + return {"shared": self.shared.describe(), "k_split": self.k_split, "hidden": self.hidden.describe(), + "out": self.out.describe(), "slices": [list(s) for s in self.slices]} + + +def centerhead_numpy(shared: ConvSpec, heads: Sequence[HeadSpec], inputs_nchw: Sequence[Any]) -> Dict[str, np.ndarray]: + """fp32 oracle (torch, the literal graph: concat, conv, six heads) -> ``{"shared": NCHW, name: NCHW}``.""" + import torch + + from .second import _apply_torch + + with torch.no_grad(): + x = torch.cat([torch.as_tensor(np.asarray(t, dtype=np.float32)) for t in inputs_nchw], dim=1) + s = _apply_torch(shared, x) + out = {"shared": s.numpy().copy()} + for name, hidden, final in heads: + out[name] = _apply_torch(final, _apply_torch(hidden, s)).numpy().copy() + return out diff --git a/code/tt_frnet/ttaw/models/pillars.py b/code/tt_frnet/ttaw/models/pillars.py new file mode 100644 index 0000000000000000000000000000000000000000..9be24d7a56b7b637f548b1700ea899de73ae656e --- /dev/null +++ b/code/tt_frnet/ttaw/models/pillars.py @@ -0,0 +1,297 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C24 companion: the PillarFeatureNet (PFN) of the pillar detectors on the device, and the host staging of its +inputs (PLAN.md section 1.2; CenterPoint, PointPainting, TransFusion). Promoted by the PointPainting port from the +CenterPoint bundle's ``tt/pfn.py`` + ``tt/model.py FrameStaging`` (device-verified there: PFN PCC 0.99997) with the +TransFusion bundle's generalisation (any hidden / output width, pillars of fewer than 32 points); those bundles keep +their copies until they re-vendor. + +The encoder (mmdet3d ``PFNLayer`` x 2 as Autoware exports it; BN folded into both MatMuls by the port's loader): + + h = relu(x @ W0 + b0) x: (P, K, F) decorated points, padded slots all zero h: (P, K, H) + m = max over the K slots of h padded slots included: their rows are relu(b0) + y = max over the slots of relu([h, m] @ W1 + b1) = relu(max_slots(h @ W1a) + m @ W1b + b1) y: (P, O) + +Device form. One pillar is one 32-row tile (its point slots), so a slot max is a tile-column reduction (``ttnn.max`` +over dim -2). Because ``relu(. + c)`` is monotonic, the per-pillar term ``c = m @ W1b + b1`` is added AFTER the max +(exact, also in floating point: rounding is monotonic), so no slot broadcast is needed; two identity blocks fold the +rest into the matmuls (exact: a value times 1.0 with HiFi4 and fp32 accumulation): + + x [P*32, 16] --linear W0 (16 x H_pad) + b0, ReLU--> h [P*32, H_pad] + h --linear Wz = [I_H | W1a] (H_pad x Z)--> z [P*32, Z] = [h | h @ W1a | 0] + z as [P, 32, Z] --max over dim -2--> mz [P, 1, Z] = [m | max(h @ W1a) | 0] + mz --linear Wy = [W1b ; I_O] (Z x O) + b1, ReLU--> y [P, 1, O] + y --untilize, view--> [1, 1, P, O] ROW_MAJOR bf16 rows + +(``H_pad`` = H rounded up to 32, ``Z`` = H + O rounded up to 32, ``O % 32 == 0``: the rows are a C19 gather table.) +The rows feed the gather-form scatter (C19 ``ops.gather.scatter_rows``, which appends the zero sentinel row). + +**Pillars of K < 32 points** (TransFusion: 20): slots K..31 repeat slot 0 (:func:`pack_features`; every kept +pillar has a point in slot 0), so both slot maxima are unchanged (max is idempotent), whereas zero rows there would +add ``relu(b0)`` to the maxima of full pillars. With K = 32 (CenterPoint, PointPainting) the padded slots are the +model's own zero slots. + +**Input**: the host's decorated features as bf16 ROW_MAJOR ``[1, 1, P, 32 * 16]``: one 1 KB row per pillar (32 +slots x F <= 16 features zero-padded to 16). One row per pillar keeps the host-to-device copy at P pages (CenterPoint: +5.1 ms for 40,000 pages of 1 KB vs 32 ms as 1.28 M pages of 32 B); the device reshapes it to ``[1, 1, P*32, 16]`` and +tilizes it. Every pillar row is computed, also past the frame's pillar count (stale or zero input): the scatter index +never points at those rows, so no pillar count reaches the device. :class:`FrameStaging` writes these inputs in place +into persistent host tensors (``tensors.HostStaging``): no per-frame ``from_torch`` of 20 M elements (CenterPoint: 188 +ms) and no allocation; only the kept pillars are converted (torch fp32 -> bf16, RNE). + +**Precision** (``ttaw.precision.Precision``): HiFi4 is required (the identity blocks are exact only with it); fp32 +accumulation; weights per ``Precision.weights`` (fp32: a device "fp32" matmul behaves like TF32, probe P12); +``Precision.activations`` is the dtype of the hidden tensors ``h`` and ``z`` (bf16 by default; fp32 keeps the PFN's +internal roundings off: PointPainting measured on the CPU that the bf16 rounding of ``h`` / ``z`` is the largest PFN +error term, rel. L2 0.0045 vs 0.0017 for the bf16 output rounding alone). The output rows are always bf16 (the +``ttnn.embedding`` table of C19 is bf16-only). + +numpy only at import; ttnn / torch are imported inside the device and staging functions. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import numpy as np + +from ..precision import Precision +from .second import RowLinear, free_maps + +__all__ = ["PFN_IN_FEATURES", "PILLAR_SLOTS", "PfnPlan", "TtPillarFeatureNet", "FrameStaging", "features_shape", + "pack_features", "pfn_reference_numpy", "pack_inputs", "DEFAULT_PRECISION"] + +PFN_IN_FEATURES = 16 # decorated features zero-padded to 16 bf16 = 32-byte slot rows +PILLAR_SLOTS = 32 # one 32-row tile per pillar +TILE = 32 +DEFAULT_PRECISION = "HiFi4+fp32:w=fp32" + + +def _round_up(n: int, m: int = TILE) -> int: + return -(-int(n) // m) * m + + +@dataclass(frozen=True) +class PfnPlan: + """The device matrices of the PFN (float32 host arrays; see the module docstring).""" + + w0: np.ndarray # (16, H_pad) + b0: np.ndarray # (H_pad,) + wz: np.ndarray # (H_pad, Z) = [I_H | W1a | 0] + wy: np.ndarray # (Z, O) = [W1b ; I_O ; 0] + b1: np.ndarray # (O,) + in_features: int # F + hidden: int # H (layer-0 width) + out_features: int # O + + @classmethod + def from_weights(cls, w0: Any, b0: Any, w1: Any, b1: Any, *, in_padded: int = PFN_IN_FEATURES) -> "PfnPlan": + """From the BN-folded encoder weights: ``w0`` (F, H) and ``w1`` (2H, O), ``[in, out]`` layout; layer 1 reads + ``concat[h, max_slots h]`` (rows 0..H-1 of ``w1`` act on ``h``, rows H..2H-1 on the max).""" + w0 = np.asarray(w0, np.float32) + w1 = np.asarray(w1, np.float32) + if w0.ndim != 2 or w1.ndim != 2: + raise ValueError(f"w0 {w0.shape} / w1 {w1.shape} must be [in, out] matrices") + f, hdim = w0.shape + out = int(w1.shape[1]) + if w1.shape[0] != 2 * hdim: + raise ValueError(f"w1 {w1.shape} does not take concat[h, max h] of width {2 * hdim}") + if f > in_padded: + raise ValueError(f"{f} features do not fit the {in_padded}-wide slot rows") + if out % TILE: + raise ValueError(f"PFN output width {out}: the C19 gather table needs a multiple of {TILE}") + b0 = np.asarray(b0, np.float32).reshape(-1) + b1 = np.asarray(b1, np.float32).reshape(-1) + if b0.shape != (hdim,) or b1.shape != (out,): + raise ValueError(f"biases {b0.shape} / {b1.shape} do not match widths {hdim} / {out}") + hp, zp = _round_up(hdim), _round_up(hdim + out) + W0 = np.zeros((in_padded, hp), np.float32) + W0[:f, :hdim] = w0 + B0 = np.zeros(hp, np.float32) + B0[:hdim] = b0 + Wz = np.zeros((hp, zp), np.float32) + Wz[:hdim, :hdim] = np.eye(hdim, dtype=np.float32) + Wz[:hdim, hdim:hdim + out] = w1[:hdim] + Wy = np.zeros((zp, out), np.float32) + Wy[:hdim] = w1[hdim:] + Wy[hdim:hdim + out] = np.eye(out, dtype=np.float32) + return cls(W0, B0, Wz, Wy, b1.copy(), int(f), int(hdim), out) + + def forward_numpy(self, features: Any, *, hidden_round: Any = None) -> np.ndarray: + """float32 host emulation of the device graph (``(P, S, F)`` slots -> ``(P, out_features)``); give it the + 32-slot layout of :func:`pack_features` to emulate the device's slots exactly. ``hidden_round``: a function + applied to ``h`` and ``z`` (e.g. a bf16 rounding) to model the hidden dtype.""" + r = hidden_round or (lambda a: a) + x = np.asarray(features, np.float32) + p, slots, f = x.shape + xp = np.zeros((p, slots, self.w0.shape[0]), np.float32) + xp[:, :, :f] = x + h = r(np.maximum(xp @ self.w0 + self.b0, 0.0).astype(np.float32)) + z = r((h @ self.wz).astype(np.float32)) + mz = z.max(axis=1) + return np.maximum(mz @ self.wy + self.b1, 0.0).astype(np.float32) + + +def pfn_reference_numpy(features: Any, w0: Any, b0: Any, w1: Any, b1: Any) -> np.ndarray: + """The literal encoder in float64 (concat form, max over the given slots, padded slots included) -> ``(P, O)`` + float32: the oracle of :class:`PfnPlan` and of the device module.""" + x = np.asarray(features, np.float64) + w0, w1 = np.asarray(w0, np.float64), np.asarray(w1, np.float64) + h = np.maximum(x @ w0 + np.asarray(b0, np.float64), 0.0) + m = h.max(axis=1, keepdims=True) + y = np.maximum(np.concatenate([h, np.broadcast_to(m, h.shape)], axis=2) @ w1 + np.asarray(b1, np.float64), 0.0) + return y.max(axis=1).astype(np.float32) + + +def features_shape(num_pillars: int): + """The device input layout of the PFN: ``(1, 1, num_pillars, 32 * 16)`` (one bf16 row per pillar).""" + return (1, 1, int(num_pillars), PILLAR_SLOTS * PFN_IN_FEATURES) + + +def pack_features(features: Any, num_pillars: int, *, out: Optional[np.ndarray] = None) -> np.ndarray: + """Decorated features ``(P_kept, K, F)`` (K <= 32 point slots, F <= 16; the model's padded slots zero) -> the + device input :func:`features_shape` as float32: features zero-padded to 16, slots K..31 copies of slot 0 when + K < 32 (module docstring), rows past ``P_kept`` zero. ``out`` (an array of that shape) is reused when given.""" + x = np.asarray(features, np.float32) + if x.ndim != 3: + raise ValueError(f"features must be (pillars, slots, features), got {x.shape}") + p, slots, f = x.shape + if slots > PILLAR_SLOTS or f > PFN_IN_FEATURES or p > num_pillars: + raise ValueError(f"features {x.shape} do not fit ({num_pillars}, {PILLAR_SLOTS}, <= {PFN_IN_FEATURES})") + shape = features_shape(num_pillars) + if out is None: + out = np.zeros(shape, np.float32) + elif out.shape != shape or out.dtype != np.float32: + raise ValueError(f"out must be float32 {shape}") + else: + out.fill(0.0) + v = out.reshape(int(num_pillars), PILLAR_SLOTS, PFN_IN_FEATURES) + v[:p, :slots, :f] = x + if 0 < slots < PILLAR_SLOTS: + v[:p, slots:, :f] = x[:, :1, :] + return out + + +def pack_inputs(features: Any, canvas_index: Any, num_pillars: int, *, capacity: int, + features_out: Optional[np.ndarray] = None) -> Dict[str, np.ndarray]: + """The two PFN-graph inputs as numpy (tests and tools; the API uses :class:`FrameStaging`): ``{"features": + (1, 1, capacity, 512) float32, "canvas_index": (1, N) uint32}`` from the kept pillars' features + ``features[:num_pillars]`` and a cell -> pillar-row index (``capacity`` = the zero sentinel row). The index is + range-checked: a bad index would read outside the gather table.""" + from ..ops.gather import check_index + + n = int(num_pillars) + feats = pack_features(np.asarray(features)[:n], capacity, out=features_out) + return {"features": feats, "canvas_index": check_index(canvas_index, num_rows=n, sentinel=capacity)} + + +class TtPillarFeatureNet: + """The PFN on ``num_pillars`` pillars: ``[1, 1, P, 32*16]`` bf16 ROW_MAJOR -> ``[1, 1, P, O]`` bf16 ROW_MAJOR + rows (module docstring). ``precision``: a :class:`..precision.Precision` or its spec; its ``activations`` dtype is + that of the hidden tensors ``h`` / ``z``.""" + + def __init__(self, plan: PfnPlan, *, num_pillars: int, precision: Any = DEFAULT_PRECISION, name: str = "pfn"): + self.plan = plan + self.name = name + self.num_pillars = int(num_pillars) + self.precision = Precision.parse(precision) + if self.precision.fidelity != "HiFi4": + raise ValueError(f"{name}: the PFN identity blocks are exact only with HiFi4 (x * 1.0)") + hidden = self.precision.activations + self.l0 = RowLinear(plan.w0, plan.b0, activation="relu", precision=self.precision, output_dtype=hidden, + name=f"{name}.l0") + self.lz = RowLinear(plan.wz, None, precision=self.precision, output_dtype=hidden, name=f"{name}.lz") + self.ly = RowLinear(plan.wy, plan.b1, activation="relu", precision=self.precision, output_dtype="bfloat16", + name=f"{name}.ly") + self.out_features = plan.out_features + + @property + def features_shape(self): + return features_shape(self.num_pillars) + + def __call__(self, features_rm: Any) -> Any: + import ttnn + + p = self.num_pillars + rows = ttnn.reshape(features_rm, (1, 1, p * PILLAR_SLOTS, PFN_IN_FEATURES)) # 1 KB rows -> 32 B slot rows + x = ttnn.to_layout(rows, ttnn.TILE_LAYOUT) # [1, 1, P*32, 16] + free_maps(rows) + h = self.l0(x) # [1, 1, P*32, H_pad] + free_maps(x) + z = self.lz(h) # [1, 1, P*32, Z] + free_maps(h) + z4 = ttnn.reshape(z, (1, p, PILLAR_SLOTS, int(z.shape[-1]))) # one pillar = one tile row block (a view) + mz = ttnn.max(z4, dim=2, keepdim=True) # [1, P, 1, Z] + free_maps(z) + y = self.ly(mz) # [1, P, 1, O] bf16 + free_maps(mz) + y_rm = ttnn.to_layout(y, ttnn.ROW_MAJOR_LAYOUT) + free_maps(y) + return ttnn.reshape(y_rm, (1, 1, p, self.out_features)) + + def release(self) -> None: + for layer in (self.l0, self.lz, self.ly): + layer.release() + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "num_pillars": self.num_pillars, "in_features": self.plan.in_features, + "hidden": self.plan.hidden, "out": self.plan.out_features, "z_width": int(self.plan.wz.shape[1]), + "slots": PILLAR_SLOTS, "precision": self.precision.label, + "hidden_dtype": self.precision.activations} + + +class FrameStaging: + """Persistent host buffers of the two PFN-graph inputs: bf16 ``features`` (:func:`features_shape`) and uint32 + ``canvas_index`` ``[1, num_cells]`` (``tensors.HostStaging``: the ttnn host tensor's own memory, written in + place). :meth:`write` converts only the kept pillars (torch fp32 -> bf16, RNE) into their rows (slots K..31 = + slot 0 when K < 32), zeroes the rows the previous frame used beyond them, copies the range-checked index, and + returns the ``TraceRunner`` inputs (host tensors, uploaded as they are). Without a zero-copy alias (another + tt-metal build) it falls back to :func:`pack_features` + ``HostStaging.write`` (same values).""" + + def __init__(self, num_pillars: int, num_cells: int): + from ..tensors import HostStaging + + self.num_pillars = int(num_pillars) + self.num_cells = int(num_cells) + self.features = HostStaging(features_shape(self.num_pillars), "bfloat16") + self.index = HostStaging((1, self.num_cells), "uint32") + self._used = self.num_pillars # rows that may hold data (all, until the first write) + + @property + def zero_copy(self) -> bool: + return bool(self.features.zero_copy and self.index.zero_copy) + + def write(self, features: Any, canvas_index: Any, num_pillars: Optional[int] = None) -> Dict[str, Any]: + """``features``: ``(P, K, F)`` float32 decorated pillars (only the first ``num_pillars`` rows are used; + default all); ``canvas_index``: ``num_cells`` entries in ``[0, num_pillars)`` or the sentinel + ``self.num_pillars``.""" + from ..ops.gather import check_index + + f_all = np.asarray(features) + p = int(f_all.shape[0] if num_pillars is None else num_pillars) + if p > self.num_pillars: + raise ValueError(f"{p} pillars exceed the staging capacity {self.num_pillars}") + feats = np.asarray(f_all[:p], np.float32) + index = check_index(canvas_index, num_rows=p, sentinel=self.num_pillars) + if index.size != self.num_cells: + raise ValueError(f"canvas index of {index.size} cells, expected {self.num_cells}") + if not self.zero_copy: # no host alias: plain writes + return {"features": self.features.write(pack_features(feats, self.num_pillars)), + "canvas_index": self.index.write(index)} + import torch + + if feats.ndim != 3 or feats.shape[1] > PILLAR_SLOTS or feats.shape[2] > PFN_IN_FEATURES: + raise ValueError(f"features {feats.shape} do not fit (P, <= {PILLAR_SLOTS}, <= {PFN_IN_FEATURES})") + view = self.features.view.reshape(self.num_pillars, PILLAR_SLOTS, PFN_IN_FEATURES).view(np.int16) + dst = torch.from_numpy(view) + slots, f = feats.shape[1], feats.shape[2] + src = torch.from_numpy(np.ascontiguousarray(feats)).to(torch.bfloat16).view(torch.int16) + dst[:p, :slots, :f] = src + if f < PFN_IN_FEATURES: + dst[:p, :, f:] = 0 + if 0 < slots < PILLAR_SLOTS: + dst[:p, slots:, :f] = src[:, :1, :] + if p < self._used: + dst[p:self._used] = 0 + self._used = p + self.index.view[...] = index + return {"features": self.features.tensor, "canvas_index": self.index.tensor} diff --git a/code/tt_frnet/ttaw/models/resnet.py b/code/tt_frnet/ttaw/models/resnet.py new file mode 100644 index 0000000000000000000000000000000000000000..c264e6bc87ad4ef00c0686b9bc925d5d283435a4 --- /dev/null +++ b/code/tt_frnet/ttaw/models/resnet.py @@ -0,0 +1,271 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C27: ResNet builders on device feature maps, cameras as batch (PLAN.md section 1.2 row C27; first built by the +BEVDet port for its ResNet-50 image backbone and CustomResNet BEV encoder; BEVFormer and METEOR reuse it). + +- :class:`Bottleneck`: ResNet-50/101, pytorch style (the stride sits on the 3x3 ``conv2``; 1x1 projection + ``downsample`` on the first block of a stage): ``relu(conv3(relu(conv2(relu(conv1(x))))) + idt)``; +- :class:`BasicBlock`: ResNet-18/34 and BEVDet's CustomResNet (two 3x3 convs; the projection may be any conv, + CustomResNet's is a 3x3 stride-2 one): ``relu(conv2(relu(conv1(x))) + idt)``; +- :class:`ResNetStem`: ``maxpool 3x3 s2 p1 (relu(conv1 7x7 s2 p3))`` (the max pool runs DRAM-sliced); +- :class:`ResNetStages`: ``len(layers)`` stages of one block kind; ``__call__(x, keep=...)`` returns the stage + outputs a caller wants (FPN levels, taps) and frees the others as soon as the next stage consumed them. + +**Weights** come from a parameter provider ``provider(module) -> ConvParams`` (BatchNorm folded, conv attributes as +the source graph defines them: BEVDet's reads ``OnnxWeights`` by consuming node, so no kernel size or stride is +re-typed here). **Names** come from a ``names(stage, block)`` callable: :func:`mmdet_names` (``layer1.0.conv1``, +``downsample.0``) and :func:`custom_resnet_names` (``layers.0.0.conv1``, ``downsample``). **Convs** come from a +``factory(module, params, activation)`` (default :func:`conv_factory`: one C17 ``Conv2d`` per module, with a +``ttaw.precision`` spec or a ``PrecisionPolicy`` resolved per module name); a port that needs deformable convs +(BEVFormer / METEOR DCN, PLAN.md C23 / C27 "DCN hook") returns its own callable for the modules it names. + +Placement: every activation is a DRAM-interleaved TILE ``[1, 1, N*H*W, C]`` map (C17's rule: DRAM inputs run ttnn's +automatic DRAM slicing, 1x1 convs are matmuls placed back in DRAM), so the 6 x 256 x 704 cameras' 34.6 MB stem and +layer1 maps fit; intermediates are freed as soon as they are consumed. Every op is traceable; a layer prepares its +weights on its first (eager) call. :func:`resnet_numpy` is the fp32 torch oracle of the same blocks. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union + +import numpy as np + +from ..ops.conv import CONV_PRECISION, Conv2d, FeatureMap, residual_add +from ..precision import Precision, PrecisionPolicy + +__all__ = ["ConvParams", "DEFAULT_PRECISION", "conv_factory", "mmdet_names", "custom_resnet_names", "Bottleneck", + "BasicBlock", "ResNetStem", "ResNetStages", "maxpool_out_hw", "resnet_numpy", "identity_like"] + +DEFAULT_PRECISION = CONV_PRECISION + + +@dataclass(frozen=True) +class ConvParams: + """One BN-folded convolution as a provider returns it (numpy float32).""" + + weight: np.ndarray # [Cout, Cin / groups, kh, kw] + bias: Optional[np.ndarray] = None # [Cout] + stride: Tuple[int, int] = (1, 1) + padding: Tuple[int, int, int, int] = (0, 0, 0, 0) # top, bottom, left, right + dilation: Tuple[int, int] = (1, 1) + groups: int = 1 + + +Provider = Callable[[str], ConvParams] +ConvFactory = Callable[[str, ConvParams, Optional[str]], Callable[[FeatureMap], FeatureMap]] +Names = Callable[[int, int], Dict[str, str]] + + +def conv_factory(precision: Union[str, Precision, PrecisionPolicy] = DEFAULT_PRECISION, **conv_kwargs: Any + ) -> ConvFactory: + """The standard factory: one C17 ``Conv2d`` per module. ``precision``: a spec / :class:`Precision` for every + conv, or a :class:`PrecisionPolicy` resolved per module name; other keywords go to every ``Conv2d``.""" + + def make(module: str, p: ConvParams, activation: Optional[str]) -> Conv2d: + prec = precision.resolve(module) if isinstance(precision, PrecisionPolicy) else precision + return Conv2d(p.weight, p.bias, stride=p.stride, padding=p.padding, dilation=p.dilation, groups=p.groups, + activation=activation, precision=prec, name=module, **conv_kwargs) + + return make + + +def mmdet_names(prefix: str) -> Names: + """mmdet / torchvision names: ``{prefix}.layer{stage + 1}.{block}.conv{1,2,3}`` and ``.downsample.0``.""" + + def names(stage: int, block: int) -> Dict[str, str]: + p = f"{prefix}.layer{stage + 1}.{block}" + return {"conv1": p + ".conv1", "conv2": p + ".conv2", "conv3": p + ".conv3", + "downsample": p + ".downsample.0", "block": p} + + return names + + +def custom_resnet_names(prefix: str) -> Names: + """BEVDet CustomResNet names: ``{prefix}.layers.{stage}.{block}.conv{1,2}`` and ``.downsample``.""" + + def names(stage: int, block: int) -> Dict[str, str]: + p = f"{prefix}.layers.{stage}.{block}" + return {"conv1": p + ".conv1", "conv2": p + ".conv2", "downsample": p + ".downsample", "block": p} + + return names + + +def identity_like(x: FeatureMap, like: FeatureMap) -> FeatureMap: + """The identity shortcut ``x`` in the tensor shape of the residual branch output ``like`` (same rows and + channels). ``ttnn.max_pool2d`` on a DRAM input (:class:`ResNetStem`) returns a 4-D ``[N, OH, OW, C]`` TILE map + (``generic_pools.cpp`` ``pool2d_DRAM``) while a conv returns the flattened ``[1, 1, N*OH*OW, C]`` rows, and + ``ttnn.add`` of the two raises ``Invalid subtile broadcast type``: the first block of a ResNet-18 / 34 stage 1 (no + projection) adds the stem output itself (METEOR port, ttaw 0.17.2). A no-op when the shapes already agree.""" + if tuple(x.tensor.shape) == tuple(like.tensor.shape): + return x + import ttnn + + return x.with_tensor(ttnn.reshape(x.tensor, tuple(like.tensor.shape))) + + +class Bottleneck: + """``relu(conv3(relu(conv2(relu(conv1(x))))) + idt)``; ``idt = downsample(x)`` when the block has a projection.""" + + def __init__(self, provider: Provider, names: Dict[str, str], *, has_downsample: bool, factory: ConvFactory): + self.name = names["block"] + self.conv1 = factory(names["conv1"], provider(names["conv1"]), "relu") + self.conv2 = factory(names["conv2"], provider(names["conv2"]), "relu") + self.conv3 = factory(names["conv3"], provider(names["conv3"]), None) + self.downsample = (factory(names["downsample"], provider(names["downsample"]), None) + if has_downsample else None) + + def __call__(self, x: FeatureMap) -> FeatureMap: + y = self.conv1(x) + z = self.conv2(y) + y.deallocate() + y = self.conv3(z) + z.deallocate() + if self.downsample is not None: + idt = self.downsample(x) + out = residual_add(y, idt, "relu") + idt.deallocate() + else: + out = residual_add(y, identity_like(x, y), "relu") + y.deallocate() + return out + + def convs(self) -> List[Any]: + return [c for c in (self.conv1, self.conv2, self.conv3, self.downsample) if c is not None] + + +class BasicBlock: + """``relu(conv2(relu(conv1(x))) + idt)``; ``idt = downsample(x)`` when the block has a projection.""" + + def __init__(self, provider: Provider, names: Dict[str, str], *, has_downsample: bool, factory: ConvFactory): + self.name = names["block"] + self.conv1 = factory(names["conv1"], provider(names["conv1"]), "relu") + self.conv2 = factory(names["conv2"], provider(names["conv2"]), None) + self.downsample = (factory(names["downsample"], provider(names["downsample"]), None) + if has_downsample else None) + + def __call__(self, x: FeatureMap) -> FeatureMap: + y = self.conv1(x) + z = self.conv2(y) + y.deallocate() + if self.downsample is not None: + idt = self.downsample(x) + out = residual_add(z, idt, "relu") + idt.deallocate() + else: + out = residual_add(z, identity_like(x, z), "relu") + z.deallocate() + return out + + def convs(self) -> List[Any]: + return [c for c in (self.conv1, self.conv2, self.downsample) if c is not None] + + +def maxpool_out_hw(h: int, w: int, k: int = 3, s: int = 2, p: int = 1) -> Tuple[int, int]: + return (h + 2 * p - k) // s + 1, (w + 2 * p - k) // s + 1 + + +class ResNetStem: + """``maxpool 3x3 s2 p1 (relu(conv1))`` (``conv_module`` names the stem conv; ``pool=False`` skips the pool). + The max pool reads the DRAM conv output with automatic DRAM slicing and writes a TILE map for the first block; + on that DRAM path its tensor is 4-D ``[N, OH, OW, C]`` (not the flattened rows of a conv output): convs take it + as is, and the blocks' identity shortcuts reshape it (:func:`identity_like`).""" + + def __init__(self, provider: Provider, conv_module: str, *, factory: Optional[ConvFactory] = None, + pool: bool = True): + factory = factory or conv_factory() + self.conv1 = factory(conv_module, provider(conv_module), "relu") + self.pool = bool(pool) + + def __call__(self, x: FeatureMap) -> FeatureMap: + import ttnn + + y = self.conv1(x) + if not self.pool: + return y + out = ttnn.max_pool2d(y.tensor, batch_size=y.batch, input_h=y.height, input_w=y.width, + channels=y.channels, kernel_size=[3, 3], stride=[2, 2], padding=[1, 1], + dilation=[1, 1], ceil_mode=False, memory_config=ttnn.DRAM_MEMORY_CONFIG, + output_layout=ttnn.TILE_LAYOUT) + y.deallocate() + oh, ow = maxpool_out_hw(y.height, y.width) + return FeatureMap(out, y.batch, oh, ow, y.channels) + + def convs(self) -> List[Any]: + return [self.conv1] + + +class ResNetStages: + """``len(layers)`` stages of ``block`` (``"bottleneck"`` or ``"basic"``); block 0 of stage s has the projection + when ``downsample_first[s]`` (default: every stage). ``__call__(x, keep)`` returns the outputs of the stages in + ``keep`` (others are freed once consumed; ``x`` itself is freed only with ``free_input=True``).""" + + def __init__(self, provider: Provider, names: Names, layers: Sequence[int], *, block: str = "bottleneck", + factory: Optional[ConvFactory] = None, downsample_first: Sequence[bool] = ()): + if block not in ("bottleneck", "basic"): + raise ValueError("block must be 'bottleneck' or 'basic'") + factory = factory or conv_factory() + cls = Bottleneck if block == "bottleneck" else BasicBlock + ds = list(downsample_first) or [True] * len(layers) + if len(ds) != len(layers): + raise ValueError("downsample_first needs one entry per stage") + self.block = block + self.stages: List[List[Any]] = [] + for s, n in enumerate(layers): + self.stages.append([cls(provider, names(s, b), has_downsample=(b == 0 and bool(ds[s])), factory=factory) + for b in range(int(n))]) + + def __call__(self, x: FeatureMap, keep: Sequence[int] = (), *, free_input: bool = False) -> List[FeatureMap]: + outs: List[FeatureMap] = [] + cur = x + for s, blocks in enumerate(self.stages): + for blk in blocks: + nxt = blk(cur) + if (cur is not x or free_input) and not any(cur is o for o in outs): + cur.deallocate() + cur = nxt + if s in keep: + outs.append(cur) + if cur is not x and not any(cur is o for o in outs): + cur.deallocate() + return outs + + def convs(self) -> List[Any]: + return [c for blocks in self.stages for blk in blocks for c in blk.convs()] + + +def resnet_numpy(x: np.ndarray, provider: Provider, names: Names, layers: Sequence[int], *, + block: str = "bottleneck", stem: Optional[str] = None, keep: Sequence[int] = (), + downsample_first: Sequence[bool] = ()) -> List[np.ndarray]: + """fp32 torch oracle of :class:`ResNetStem` (if ``stem`` names its conv) + :class:`ResNetStages` on an NCHW + array -> the kept stage outputs (NCHW float32).""" + import torch + import torch.nn.functional as F + + def conv(t, module, relu): + p = provider(module) + top, bottom, left, right = p.padding + t = F.pad(t, (left, right, top, bottom)) + y = F.conv2d(t, torch.from_numpy(np.asarray(p.weight, np.float32)), + None if p.bias is None else torch.from_numpy(np.asarray(p.bias, np.float32)), + stride=tuple(p.stride), dilation=tuple(p.dilation), groups=p.groups) + return torch.relu(y) if relu else y + + ds = list(downsample_first) or [True] * len(layers) + outs = [] + with torch.no_grad(): + t = torch.from_numpy(np.asarray(x, np.float32)) + if stem is not None: + t = F.max_pool2d(conv(t, stem, True), 3, 2, 1) + for s, n in enumerate(layers): + for b in range(int(n)): + nm = names(s, b) + idt = conv(t, nm["downsample"], False) if (b == 0 and ds[s]) else t + y = conv(t, nm["conv1"], True) + if block == "bottleneck": + y = conv(conv(y, nm["conv2"], True), nm["conv3"], False) + else: + y = conv(y, nm["conv2"], False) + t = torch.relu(y + idt) + if s in keep: + outs.append(t.numpy()) + return outs diff --git a/code/tt_frnet/ttaw/models/second.py b/code/tt_frnet/ttaw/models/second.py new file mode 100644 index 0000000000000000000000000000000000000000..3043a62778faa2925e4f44311e641f3ef7e738b5 --- /dev/null +++ b/code/tt_frnet/ttaw/models/second.py @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C24: SECOND backbone + SECONDFPN neck on the device (PLAN.md section 1.2 row C24; CenterPoint, PointPainting, +TransFusion and BEVFusion use them), built from BN-folded host weights on the C17 builders (``ttaw.ops.conv``). + +- :class:`Second`: blocks of 3x3 conv (zero padding 1) + ReLU; the first conv of a block carries the block stride + (mmdet3d ``SECOND``: ``layer_strides`` [1, 2, 2], CenterPoint-tiny [2, 2, 2]). Returns every block output. +- :class:`SecondFPN`: one deblock per block output, each up-sampling to the same grid, + ReLU (mmdet3d + ``SECONDFPN``): a ``"conv"`` deblock is a 1x1 stride-1 conv (a :class:`RowLinear`), a ``"conv_transpose"`` deblock + a ConvTranspose with kernel == stride (k = 1 a :class:`RowLinear`, k = 2 ``ttnn.conv_transpose2d``, k >= 3 + linear + depth-to-space: C17 ``ConvTranspose2d``, probe P5). The concat of the deblock outputs is NOT built: the + consumer takes the list (C25's K-split shared conv reads the parts, so CenterPoint's 177 MB concat never exists). + +Placement: every module output is a DRAM interleaved TILE ``[1, 1, N*H*W, C]`` map (the robust first-port layout: +3x3 convs on DRAM inputs run ttnn's automatic DRAM slicing, probe P14; L1 residency is optimization work). +Precision: :data:`DEFAULT_PRECISION` (C17's ``CONV_PRECISION``, HiFi4 + fp32 accumulation + packer L1 +accumulation: without it a conv keeps its partial sums in bf16 between inner blocks) unless a +``ttaw.precision.PrecisionPolicy`` says otherwise for ``.block.conv`` / ``.deblock``; a +precision's ``activations`` dtype (``:a=fp32``) is the dtype of that layer's OUTPUT (bf16 by default), the knob of a +deep plain conv stack whose bf16 activation rounding compounds (S:centerpoint:396-408). + +:class:`ConvSpec` records are plain numpy, so a port maps its own weight loader onto them; :func:`second_numpy` / +:func:`second_fpn_numpy` are the fp32 torch oracles of the device modules. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence + +import numpy as np + +from ..ops.conv import CONV_PRECISION, Conv2d, ConvTranspose2d, FeatureMap, is_dram +from ..precision import Precision, PrecisionPolicy + +__all__ = ["ConvSpec", "RowLinear", "Second", "SecondFPN", "DEFAULT_PRECISION", "default_policy", "second_numpy", + "second_fpn_numpy", "free_maps"] + +DEFAULT_PRECISION = CONV_PRECISION + + +@dataclass(frozen=True) +class ConvSpec: + """One conv layer with BN folded: ``y = relu?(conv(x, weight) + bias)``. + + ``kind="conv"``: weight ``[Cout, Cin, k, k]``; ``kind="conv_transpose"``: weight ``[Cin, Cout, s, s]`` (kernel == + stride, no padding). ``padding`` None = ``k // 2`` for convs.""" + + weight: np.ndarray + bias: Optional[np.ndarray] + stride: int = 1 + padding: Optional[int] = None + relu: bool = True + kind: str = "conv" + name: str = "" + + @property + def kernel(self) -> int: + return int(np.shape(self.weight)[-1]) + + @property + def in_channels(self) -> int: + return int(np.shape(self.weight)[1 if self.kind == "conv" else 0]) + + @property + def out_channels(self) -> int: + return int(np.shape(self.weight)[0 if self.kind == "conv" else 1]) + + +def default_policy() -> PrecisionPolicy: + """:data:`DEFAULT_PRECISION` everywhere (bf16 weights).""" + return PrecisionPolicy(default=DEFAULT_PRECISION) + + +def free_maps(*maps: Any) -> None: + """Deallocate device feature maps / tensors that still hold their buffer (``force=False``: memory shared with + a view or another owner is left alone; ``ttnn.deallocate`` forces by default, probe P2).""" + import ttnn + + for m in maps: + t = m.tensor if isinstance(m, FeatureMap) else m + if t is not None and t.is_allocated(): + ttnn.deallocate(t, False) + + +def _to_dram(fm: FeatureMap) -> FeatureMap: + """The map in DRAM (moved to DRAM interleaved when an op left it in L1, e.g. ``conv_transpose2d``'s sharded + output; a no-op for DRAM maps).""" + import ttnn + + if is_dram(fm.tensor): + return fm + out = fm.with_tensor(ttnn.to_memory_config(fm.tensor, ttnn.DRAM_MEMORY_CONFIG)) + free_maps(fm) + return out + + +class RowLinear: + """``y = act(x @ W + b)`` on the rows of a feature map (a 1x1 stride-1 conv): ``[1, 1, R, K]`` TILE -> + ``[1, 1, R, N]`` TILE in DRAM, through ``ttnn.linear`` with an explicit compute config and the device grid as + ``core_grid`` (which keeps the activation fused in the matmul). ``weight``: ``[K, N]`` (in, out) or a 1x1 conv + weight ``[N, K, 1, 1]``. Device weights are uploaded by the first call (outside any capture).""" + + def __init__(self, weight: Any, bias: Any = None, *, activation: Optional[str] = None, + precision: Any = DEFAULT_PRECISION, output_dtype: str = "bfloat16", name: str = "linear"): + w = np.asarray(weight, dtype=np.float32) + if w.ndim == 4: + if w.shape[2:] != (1, 1): + raise ValueError(f"{name}: a conv weight must be 1x1, got {w.shape}") + w = w[:, :, 0, 0].T + if w.ndim != 2: + raise ValueError(f"{name}: weight must be [K, N], got {w.shape}") + if activation not in (None, "relu"): + raise ValueError(f"{name}: activation {activation!r}: None or 'relu'") + self.name = name + self.weight_host = np.ascontiguousarray(w) + self.in_channels, self.out_channels = (int(s) for s in w.shape) + b = np.zeros(self.out_channels, np.float32) if bias is None else np.asarray(bias, np.float32) + if b.shape != (self.out_channels,): + raise ValueError(f"{name}: bias must be ({self.out_channels},), got {b.shape}") + if not (np.all(np.isfinite(w)) and np.all(np.isfinite(b))): + raise ValueError(f"{name}: non-finite weights") + self.bias_host = b + self.activation = activation + self.precision = Precision.parse(precision) + self.output_dtype = output_dtype + self._device: Optional[tuple] = None + + def _upload(self, device: Any): + import torch + import ttnn + + from ..tensors import ttnn_dtype + + wdt = ttnn_dtype(self.precision.weights) + bdt = ttnn.bfloat16 if wdt == ttnn.bfloat8_b else wdt + g = device.compute_with_storage_grid_size() + self._device = ( + ttnn.from_torch(torch.from_numpy(self.weight_host), dtype=wdt, layout=ttnn.TILE_LAYOUT, device=device, + memory_config=ttnn.DRAM_MEMORY_CONFIG), + ttnn.from_torch(torch.from_numpy(self.bias_host.reshape(1, -1)), dtype=bdt, layout=ttnn.TILE_LAYOUT, + device=device, memory_config=ttnn.DRAM_MEMORY_CONFIG), + ttnn.CoreGrid(y=int(g.y), x=int(g.x)), + self.precision.compute_kernel_config()) + return self._device + + def __call__(self, x: Any) -> Any: + """``x``: a TILE device tensor ``[..., R, K]`` or a :class:`FeatureMap` (returned as one).""" + import ttnn + + from ..tensors import ttnn_dtype + + t = x.tensor if isinstance(x, FeatureMap) else x + if int(t.shape[-1]) != self.in_channels: + raise ValueError(f"{self.name}: input has {int(t.shape[-1])} channels, expected {self.in_channels}") + weight, bias, grid, cfg = self._device or self._upload(t.device()) + y = ttnn.linear(t, weight, bias=bias, activation=self.activation, compute_kernel_config=cfg, core_grid=grid, + memory_config=ttnn.DRAM_MEMORY_CONFIG, dtype=ttnn_dtype(self.output_dtype)) + return x.with_tensor(y, self.out_channels) if isinstance(x, FeatureMap) else y + + def release(self) -> None: + if self._device is not None: + free_maps(self._device[0], self._device[1]) + self._device = None + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "cin": self.in_channels, "cout": self.out_channels, "activation": self.activation, + "precision": self.precision.label, "output_dtype": self.output_dtype} + + +def _precision(policy: Optional[PrecisionPolicy], name: str) -> Precision: + return policy.resolve(name) if policy is not None else Precision.parse(DEFAULT_PRECISION) + + +class Second: + """SECOND backbone: ``blocks[i]`` is the list of :class:`ConvSpec` of block ``i`` (k x k convs, BN folded).""" + + def __init__(self, blocks: Sequence[Sequence[ConvSpec]], *, policy: Optional[PrecisionPolicy] = None, + prefix: str = "backbone"): + self.prefix = prefix + self.blocks: List[List[Conv2d]] = [] + for i, block in enumerate(blocks): + layers = [] + for j, spec in enumerate(block): + if spec.kind != "conv": + raise ValueError(f"{prefix}.block{i}.conv{j}: SECOND layers are convs, got {spec.kind}") + name = f"{prefix}.block{i}.conv{j}" + pad = spec.kernel // 2 if spec.padding is None else int(spec.padding) + prec = _precision(policy, name) + layers.append(Conv2d(spec.weight, spec.bias, stride=spec.stride, padding=pad, + activation="relu" if spec.relu else None, precision=prec, + output_dtype=prec.activations, name=name)) + self.blocks.append(layers) + + def out_shapes(self, height: int, width: int) -> List[tuple]: + """``(H, W, C)`` of every block output for an input of ``height x width``.""" + shapes = [] + for block in self.blocks: + for layer in block: + height, width = layer.out_hw(height, width) + shapes.append((height, width, block[-1].out_channels)) + return shapes + + def __call__(self, x: FeatureMap) -> List[FeatureMap]: + """Run every block on ``x`` (kept); returns the block outputs (DRAM). Inner layer outputs are freed.""" + outs: List[FeatureMap] = [] + cur = x + for block in self.blocks: + for layer in block: + nxt = _to_dram(layer(cur)) + if cur is not x and all(cur is not o for o in outs): + free_maps(cur) + cur = nxt + outs.append(cur) + return outs + + def release(self) -> None: + for block in self.blocks: + for layer in block: + layer.release() + + def describe(self) -> List[List[Dict[str, Any]]]: + return [[layer.describe() for layer in block] for block in self.blocks] + + +class SecondFPN: + """SECONDFPN neck: ``deblocks[i]`` (a :class:`ConvSpec`, ReLU after) up-samples block output ``i``.""" + + def __init__(self, deblocks: Sequence[ConvSpec], *, policy: Optional[PrecisionPolicy] = None, + prefix: str = "neck"): + self.prefix = prefix + self.deblocks: List[Any] = [] + self.strides: List[int] = [] + for i, spec in enumerate(deblocks): + name = f"{prefix}.deblock{i}" + act = "relu" if spec.relu else None + prec = _precision(policy, name) + if spec.kind == "conv": + if spec.kernel != 1 or spec.stride != 1: + raise ValueError(f"{name}: a conv deblock must be 1x1 stride 1 (k={spec.kernel}, s={spec.stride})") + layer: Any = RowLinear(spec.weight, spec.bias, activation=act, precision=prec, + output_dtype=prec.activations, name=name) + self.strides.append(1) + elif spec.kind == "conv_transpose": + if spec.kernel != spec.stride: + raise ValueError(f"{name}: ConvTranspose deblocks need kernel == stride (k={spec.kernel}, " + f"s={spec.stride})") + if spec.stride == 1: + layer = RowLinear(np.asarray(spec.weight, np.float32)[:, :, 0, 0], spec.bias, activation=act, + precision=prec, output_dtype=prec.activations, name=name) + else: + layer = ConvTranspose2d(spec.weight, spec.bias, stride=spec.stride, activation=act, + precision=prec, output_dtype=prec.activations, name=name) + self.strides.append(spec.stride) + else: + raise ValueError(f"{name}: unknown kind {spec.kind!r}") + self.deblocks.append(layer) + + def __call__(self, blocks: Sequence[FeatureMap]) -> List[FeatureMap]: + """The deblock outputs (DRAM interleaved TILE maps of one grid); the inputs are kept.""" + if len(blocks) != len(self.deblocks): + raise ValueError(f"{self.prefix}: {len(blocks)} inputs for {len(self.deblocks)} deblocks") + outs = [] + for layer, x in zip(self.deblocks, blocks): + outs.append(_to_dram(layer(x))) + return outs + + def release(self) -> None: + for d in self.deblocks: + d.release() + + def describe(self) -> List[Dict[str, Any]]: + return [d.describe() for d in self.deblocks] + + +# ------------------------------------------------------------------------------------------- host oracles + +def _apply_torch(spec: ConvSpec, x): + """One layer in torch fp32 on an NCHW tensor.""" + import torch + import torch.nn.functional as F + + w = torch.from_numpy(np.ascontiguousarray(spec.weight, dtype=np.float32)) + b = None if spec.bias is None else torch.from_numpy(np.ascontiguousarray(spec.bias, dtype=np.float32)) + if spec.kind == "conv": + pad = spec.kernel // 2 if spec.padding is None else spec.padding + y = F.conv2d(x, w, b, stride=spec.stride, padding=pad) + else: + y = F.conv_transpose2d(x, w, b, stride=spec.stride) + return torch.relu(y) if spec.relu else y + + +def second_numpy(blocks: Sequence[Sequence[ConvSpec]], x_nchw: Any) -> List[np.ndarray]: + """fp32 oracle of :class:`Second` (torch): NCHW in, NCHW block outputs out.""" + import torch + + with torch.no_grad(): + x = torch.as_tensor(np.asarray(x_nchw, dtype=np.float32)) + outs = [] + for block in blocks: + for spec in block: + x = _apply_torch(spec, x) + outs.append(x.numpy().copy()) + return outs + + +def second_fpn_numpy(deblocks: Sequence[ConvSpec], blocks_nchw: Sequence[Any]) -> List[np.ndarray]: + """fp32 oracle of :class:`SecondFPN` (torch): NCHW in and out.""" + import torch + + with torch.no_grad(): + return [_apply_torch(s, torch.as_tensor(np.asarray(x, dtype=np.float32))).numpy().copy() + for s, x in zip(deblocks, blocks_nchw)] diff --git a/code/tt_frnet/ttaw/models/transfusion_head.py b/code/tt_frnet/ttaw/models/transfusion_head.py new file mode 100644 index 0000000000000000000000000000000000000000..1e7010f07ca83b5b39f9ff7159de3935a257c759 --- /dev/null +++ b/code/tt_frnet/ttaw/models/transfusion_head.py @@ -0,0 +1,584 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C26: the TransFusion query head on the device (PLAN.md section 1.2 row C26; TransFusion builds it, BEVFusion and +PTv3 reuse it; probes P4, P7, P8, P13). + +The dense part of a TransFusion head (shared conv, heatmap convs) is C17 / C24 work; this module is what follows the +local-max heatmap (C22): query selection (C21), query initialisation, ONE post-norm decoder layer and the prediction +heads, as one traceable device graph with one packed output. The model-agnostic weights record +:class:`QueryHeadWeights` (host numpy, BN folded) describes it: + +- ``class_table`` ``(C, d)``: the class encoding (``Conv1d(one_hot(c))`` = ``W[:, c] + b``, an exact row gather); +- ``bev_pos`` ``(cells, 2)``: the model's constant BEV position of every feature cell (TF / PT ``(x + 0.5, y + 0.5)`` + with ``k = y * W + x``; BF ``(i + 0.5, j + 0.5)`` with ``k = i * 180 + j``); a query's position is ``bev_pos[pos]``; +- ``self_posembed`` / ``cross_posembed``: the position MLPs ``Conv1d 2 -> d + ReLU, Conv1d d -> d`` (BN folded); +- the decoder layer: self-attention on ``s = x + pe(query_pos)`` (Q = K = V = s), add + LayerNorm, cross-attention + of ``x + pe(query_pos)`` over ``key = lidar_feat + pe_k(bev_pos)`` (K = V = key), add + LN, FFN (ReLU), add + LN; +- the prediction heads ``(name, Conv1d d -> h + ReLU, Conv1d h -> c)``. + +Device form (every rewrite exact in real arithmetic; the oracle :class:`QueryHeadOracle` runs the literal forms): + +| step | device ops | +|---|---| +| selection | C21 ``TopKSelect``: class-major bf16 row of the local-max heatmap, ``topk_large_indices`` k = ``padded_k(K)``, exact fp32 decode -> uint32 ``pos`` / ``cls`` rows (or given indices: teacher forcing) | +| query init | ``ttnn.embedding`` of the ``lidar_feat`` rows (bf16) by ``pos`` + the class-table row by ``cls``; the query position embedding is a function of ``pos`` only, so it is a constant **QPE table** ``self_posembed(bev_pos)`` (host float64 -> bf16) gathered by ``pos``: ``query_pos`` itself never passes through bf16 (S:transfusion:241) | +| keys | ``key = lidar_feat + KPE`` with the constant ``KPE = cross_posembed(bev_pos)`` table (the 0.6 GMAC position branch leaves the per-frame graph, S:transfusion:279) | +| attention | C20 ``sdpa`` (``is_causal=False``, ``scale`` from the weights, chunk table), the head dim zero-padded to 32 in the projection weights (``pad_head_columns`` / ``pad_head_rows``, exact); self-attention on the logical ``K`` queries with no mask, Q / K / V from one fused projection; cross-attention over every cell | +| LayerNorm | ``ttnn.layer_norm(o, residual_input_tensor=x, epsilon=)`` (never ttnn's 1e-12 default) | +| heads | merged: ``relu(x @ [Wh_1 ... Wh_n] + bh)`` then the block-diagonal ``@ Wo + bo`` (padded to 32), fp32 out | +| query heat | ``ttnn.embedding`` of the local-max heatmap rows (classes zero-padded to 32) by ``pos``: every class at the query's cell (the TF / mmdeploy score ``query_heat * sigmoid(heatmap)``; BF / PT one-hot it on the host) | + +Output (``__call__``): ``{"heads": fp32 [1, 1, K, 32], "query_heat": bf16 [1, 1, Kp, 32], "indices": fp32 [1, 1, 1, +Kp]}`` (``Kp = padded_k(K)``; the first ``K`` indices are the proposals, fp32 holds them exactly), ready for +``ttaw.trace.pack_outputs``. :func:`assemble_transfusion` turns the read-back arrays into the TF / mmdeploy graph +outputs ``cls_score0``, ``bbox_pred0``, ``dir_cls_pred0`` (``center + query_pos`` in float32 on the host). + +Limits: one decoder layer (the TF, BF and PT heads have one; a second layer would re-embed the predicted centres, +which no constant table can do); ``bev_pos`` must be finite. Precision (``ttaw.precision`` policy, module names +``.self_attn``, ``.cross_attn``, ``.ffn``, ``.heads``, ``.norm``): fp32 weights, HiFi4 + fp32 accumulation, +bf16 activations (SDPA takes bf16); the tables are bf16 (their rows are model activations, rounded once). + +numpy only at import; ttnn / torch are imported inside the functions that need them. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from ..ops.attention import pad_head_columns, pad_head_rows, padded_head_dim +from ..ops.topk import TopKSelect, padded_k +from ..precision import Precision, PrecisionPolicy +from .second import RowLinear, free_maps + +__all__ = ["MHAWeights", "PosEmbedMLP", "PredictionHead", "QueryHeadWeights", "TransFusionQueryHead", + "QueryHeadOracle", "assemble_transfusion", "DEFAULT_PRECISION", "default_policy", "HEAD_PAD"] + +HEAD_PAD = 32 +DEFAULT_PRECISION = "HiFi4+fp32:w=fp32" + + +def default_policy() -> PrecisionPolicy: + """fp32 weights, HiFi4 + fp32 accumulation, bf16 activations for every module of the head.""" + return PrecisionPolicy(default=DEFAULT_PRECISION) + + +def _f32(a: Any) -> np.ndarray: + """A writable float32 copy (ONNX-derived arrays may be read-only; torch warns about those).""" + return np.array(a, dtype=np.float32, copy=True, order="C") + + +# ------------------------------------------------------------------------------------------------- weights + +@dataclass(frozen=True) +class MHAWeights: + """``nn.MultiheadAttention`` as exported, ``[in, out]`` layout: ``q = x_q @ wq + bq`` (likewise k / v), per head + ``softmax(scale * q k^T) v``, ``out = concat_heads @ wo + bo``.""" + + wq: np.ndarray + bq: np.ndarray + wk: np.ndarray + bk: np.ndarray + wv: np.ndarray + bv: np.ndarray + wo: np.ndarray + bo: np.ndarray + num_heads: int + scale: float + + @property + def head_dim(self) -> int: + return int(np.shape(self.wq)[1]) // int(self.num_heads) + + @property + def dim(self) -> int: + return int(np.shape(self.wq)[0]) + + +@dataclass(frozen=True) +class PosEmbedMLP: + """``Conv1d 2 -> d`` (BN folded) + ReLU, ``Conv1d d -> d``; ``[in, out]`` weights.""" + + w0: np.ndarray + b0: np.ndarray + w1: np.ndarray + b1: np.ndarray + + def numpy(self, pos: Any) -> np.ndarray: + """``(P, 2)`` positions -> ``(P, d)`` float64.""" + p = np.asarray(pos, np.float64) + h = np.maximum(p @ np.asarray(self.w0, np.float64) + np.asarray(self.b0, np.float64), 0.0) + return h @ np.asarray(self.w1, np.float64) + np.asarray(self.b1, np.float64) + + +@dataclass(frozen=True) +class PredictionHead: + """``Conv1d d -> h`` + ReLU, ``Conv1d h -> c`` (``[in, out]`` weights).""" + + name: str + w_hidden: np.ndarray + b_hidden: np.ndarray + w_out: np.ndarray + b_out: np.ndarray + + @property + def channels(self) -> int: + return int(np.shape(self.w_out)[1]) + + +@dataclass(frozen=True) +class QueryHeadWeights: + """Everything the query head needs, model-agnostic (module docstring). ``height`` / ``width``: the feature grid + (``cells = height * width`` keys, row-major ``k = y * width + x`` as the dense maps are laid out).""" + + height: int + width: int + num_classes: int + num_proposals: int + class_table: np.ndarray # (C, d) + bev_pos: np.ndarray # (cells, 2) + self_posembed: PosEmbedMLP + cross_posembed: PosEmbedMLP + self_attn: MHAWeights + cross_attn: MHAWeights + norms: Tuple[Tuple[np.ndarray, np.ndarray, float], ...] # (gamma, beta, eps) x 3: after self, cross, FFN + ffn: Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] # w1 (d, f), b1, w2 (f, d), b2 + heads: Tuple[PredictionHead, ...] + + @property + def cells(self) -> int: + return int(self.height) * int(self.width) + + @property + def dim(self) -> int: + return int(np.shape(self.class_table)[1]) + + def validate(self) -> None: + d, c, cells = self.dim, int(self.num_classes), self.cells + if np.shape(self.class_table) != (c, d): + raise ValueError(f"class_table {np.shape(self.class_table)} != ({c}, {d})") + if np.shape(self.bev_pos) != (cells, 2) or not np.all(np.isfinite(self.bev_pos)): + raise ValueError(f"bev_pos must be finite ({cells}, 2), got {np.shape(self.bev_pos)}") + for a in (self.self_attn, self.cross_attn): + if a.dim != d or np.shape(a.wo) != (a.num_heads * a.head_dim, d): + raise ValueError("attention weights do not match the model width") + if not (np.isfinite(a.scale) and a.scale > 0): + raise ValueError(f"attention scale {a.scale}") + if len(self.norms) != 3: + raise ValueError("a decoder layer has three LayerNorms") + if np.shape(self.ffn[0])[0] != d or np.shape(self.ffn[2])[1] != d: + raise ValueError("FFN weights do not match the model width") + if not self.heads or any(np.shape(h.w_hidden)[0] != d for h in self.heads): + raise ValueError("prediction heads do not read the model width") + if sum(h.channels for h in self.heads) > HEAD_PAD: + raise ValueError(f"the merged head output is padded to {HEAD_PAD} channels") + if c > HEAD_PAD: + raise ValueError(f"the query heat rows hold at most {HEAD_PAD} classes") + padded_k(self.num_proposals) + if self.num_proposals > cells * c: + raise ValueError("more proposals than heatmap entries") + + # ---- host tables (float64, rounded once by the device upload) ------------------------------------------ + def query_pos_table(self) -> np.ndarray: + """``self_posembed(bev_pos)`` ``(cells, d)``: the query position embedding of every cell.""" + return self.self_posembed.numpy(self.bev_pos) + + def key_pos_table(self) -> np.ndarray: + """``cross_posembed(bev_pos)`` ``(cells, d)``: the input-independent key position embedding.""" + return self.cross_posembed.numpy(self.bev_pos) + + def merged_heads(self, pad_to: int = HEAD_PAD): + """``(Wh, bh, Wo, bo, slices)``: ``relu(x @ Wh + bh) @ Wo + bo`` with the block-diagonal ``Wo`` padded to + ``pad_to`` columns; ``slices`` = ``[(name, start, stop)]`` of each head's output channels (exact rewrite).""" + wh = np.concatenate([_f32(h.w_hidden) for h in self.heads], axis=1) + bh = np.concatenate([_f32(h.b_hidden) for h in self.heads]) + rows = wh.shape[1] + cols = sum(h.channels for h in self.heads) + width = max(pad_to, -(-cols // pad_to) * pad_to) + wo = np.zeros((rows, width), np.float32) + bo = np.zeros(width, np.float32) + slices, r, c = [], 0, 0 + for h in self.heads: + hr, hc = np.shape(h.w_hidden)[1], h.channels + wo[r:r + hr, c:c + hc] = _f32(h.w_out) + bo[c:c + hc] = _f32(h.b_out) + slices.append((h.name, c, c + hc)) + r, c = r + hr, c + hc + return wh, bh, wo, bo, slices + + +# ---------------------------------------------------------------------------------------------- device + +class _LayerNorm: + def __init__(self, gamma: Any, beta: Any, eps: float, precision: Precision, name: str): + self.gamma, self.beta, self.eps = _f32(gamma).reshape(1, -1).copy(), _f32(beta).reshape(1, -1).copy(), float(eps) + self.precision = precision + self.name = name + self._dev: Optional[Tuple[Any, Any, Any]] = None + + def upload(self, device: Any): + import torch + import ttnn + + def up(a): + return ttnn.from_torch(torch.from_numpy(a), dtype=ttnn.float32, layout=ttnn.TILE_LAYOUT, device=device, + memory_config=ttnn.DRAM_MEMORY_CONFIG) + + self._dev = (up(self.gamma), up(self.beta), self.precision.compute_kernel_config()) + return self._dev + + def __call__(self, x: Any, residual: Any): + """``LayerNorm(x + residual)`` (one ``ttnn.layer_norm`` with a fused residual add).""" + import ttnn + + g, b, cfg = self._dev or self.upload(x.device()) + return ttnn.layer_norm(x, residual_input_tensor=residual, weight=g, bias=b, epsilon=self.eps, + compute_kernel_config=cfg, memory_config=ttnn.DRAM_MEMORY_CONFIG) + + def release(self) -> None: + if self._dev is not None: + free_maps(self._dev[0], self._dev[1]) + self._dev = None + + +class TransFusionQueryHead: + """The query head of :class:`QueryHeadWeights` on the device (module docstring). Device weights and tables are + uploaded by :meth:`prepare` (or the first call), outside any capture; the forward is traceable.""" + + def __init__(self, weights: QueryHeadWeights, *, policy: Optional[PrecisionPolicy] = None, prefix: str = "head", + attn_fp32_acc: bool = False, name: str = "query_head"): + weights.validate() + self.weights = weights + self.prefix = prefix + self.name = name + self.policy = policy or default_policy() + self.attn_fp32_acc = bool(attn_fp32_acc) + self.dim = weights.dim + self.cells = weights.cells + self.classes = int(weights.num_classes) + self.k = int(weights.num_proposals) + self.select = TopKSelect(self.cells, self.classes, self.k, name=f"{prefix}.topk") + self.kp = self.select.kp + sa, ca = weights.self_attn, weights.cross_attn + self.heads_self, self.hd_self = int(sa.num_heads), sa.head_dim + self.heads_cross, self.hd_cross = int(ca.num_heads), ca.head_dim + self.scale_self, self.scale_cross = float(sa.scale), float(ca.scale) + + def prec(module: str) -> Precision: + return self.policy.resolve(f"{prefix}.{module}") + + def cols(w, b, a: MHAWeights): + return pad_head_columns(_f32(w), _f32(b), a.num_heads, a.head_dim) + + p_sa, p_ca, p_ffn, p_heads = prec("self_attn"), prec("cross_attn"), prec("ffn"), prec("heads") + q, bq = cols(sa.wq, sa.bq, sa) + k, bk = cols(sa.wk, sa.bk, sa) + v, bv = cols(sa.wv, sa.bv, sa) + self.self_qkv = RowLinear(np.concatenate([q, k, v], axis=1), np.concatenate([bq, bk, bv]), precision=p_sa, + name=f"{prefix}.self_attn.qkv") + self.self_out = RowLinear(pad_head_rows(_f32(sa.wo), sa.num_heads, sa.head_dim), _f32(sa.bo), + precision=p_sa, name=f"{prefix}.self_attn.out") + self.cross_q = RowLinear(*cols(ca.wq, ca.bq, ca), precision=p_ca, name=f"{prefix}.cross_attn.q") + self.cross_k = RowLinear(*cols(ca.wk, ca.bk, ca), precision=p_ca, name=f"{prefix}.cross_attn.k") + self.cross_v = RowLinear(*cols(ca.wv, ca.bv, ca), precision=p_ca, name=f"{prefix}.cross_attn.v") + self.cross_out = RowLinear(pad_head_rows(_f32(ca.wo), ca.num_heads, ca.head_dim), _f32(ca.bo), + precision=p_ca, name=f"{prefix}.cross_attn.out") + w1, b1, w2, b2 = weights.ffn + self.ffn1 = RowLinear(_f32(w1), _f32(b1), activation="relu", precision=p_ffn, name=f"{prefix}.ffn.0") + self.ffn2 = RowLinear(_f32(w2), _f32(b2), precision=p_ffn, name=f"{prefix}.ffn.1") + wh, bh, wo, bo, self.head_slices = weights.merged_heads() + self.heads_hidden = RowLinear(wh, bh, activation="relu", precision=p_heads, name=f"{prefix}.heads.hidden") + self.heads_out = RowLinear(wo, bo, precision=p_heads, output_dtype="float32", name=f"{prefix}.heads.out") + p_norm = prec("norm") + self.norms = [_LayerNorm(g, b, eps, p_norm, f"{prefix}.norm{i}") for i, (g, b, eps) in enumerate(weights.norms)] + self._tables: Optional[Dict[str, Any]] = None + + # ---- device constants -------------------------------------------------------------------------------- + def prepare(self, device: Any) -> None: + """Upload the tables (bf16: class table and QPE as ROW_MAJOR gather tables, KPE as a TILE map) and the + LayerNorm rows. The linears upload their weights on their first call (the warm-up).""" + import torch + import ttnn + + if self._tables is not None: + return + w = self.weights + + def up(a: np.ndarray, layout): + t = torch.from_numpy(_f32(a).reshape(1, 1, a.shape[0], a.shape[1])) + return ttnn.from_torch(t, dtype=ttnn.bfloat16, layout=layout, device=device, + memory_config=ttnn.DRAM_MEMORY_CONFIG) + + self._tables = {"class": up(_f32(w.class_table), ttnn.ROW_MAJOR_LAYOUT), + "qpe": up(w.query_pos_table(), ttnn.ROW_MAJOR_LAYOUT), + "kpe": up(w.key_pos_table(), ttnn.TILE_LAYOUT)} + for n in self.norms: + n.upload(device) + + def _t(self, device: Any) -> Dict[str, Any]: + if self._tables is None: + self.prepare(device) + return self._tables # type: ignore[return-value] + + # ---- forward pieces ------------------------------------------------------------------------------------ + @staticmethod + def _rows(t: Any, n: int): + """``[1, n, D]`` / ``[1, 1, n, D]`` -> ``[1, 1, n, D]`` (a view).""" + import ttnn + + return ttnn.reshape(t, (1, 1, int(n), int(t.shape[-1]))) + + def _gather(self, index: Any, table: Any): + import ttnn + + return self._rows(ttnn.embedding(index, table, layout=ttnn.TILE_LAYOUT), int(index.shape[-1])) + + def init_queries(self, lidar_feat: Any, pos: Any, cls: Any) -> Tuple[Any, Any]: + """``lidar_feat`` TILE ``[1, 1, cells, d]``, ``pos`` / ``cls`` uint32 ROW_MAJOR ``[1, Kp]`` -> ``(x, qpe)`` + TILE ``[1, 1, K, d]``: the initial queries ``lidar_feat[pos] + class_table[cls]`` and their position + embeddings ``QPE[pos]``.""" + import ttnn + + t = self._t(lidar_feat.device()) + table = ttnn.to_layout(lidar_feat, ttnn.ROW_MAJOR_LAYOUT) + feat = self._gather(pos, table) + free_maps(table) + cemb = self._gather(cls, t["class"]) + x_p = ttnn.add(feat, cemb) + free_maps(feat, cemb) + qpe_p = self._gather(pos, t["qpe"]) + if self.kp == self.k: + return x_p, qpe_p + x = ttnn.slice(x_p, [0, 0, 0, 0], [1, 1, self.k, self.dim]) + qpe = ttnn.slice(qpe_p, [0, 0, 0, 0], [1, 1, self.k, self.dim]) + free_maps(x_p, qpe_p) + return x, qpe + + def keys(self, lidar_feat: Any): + """``lidar_feat + KPE`` (TILE ``[1, 1, cells, d]``): the cross-attention key / value input.""" + import ttnn + + return ttnn.add(lidar_feat, self._t(lidar_feat.device())["kpe"]) + + def _sdpa(self, q, k, v, scale: float): + from ..ops.attention import sdpa + + return sdpa(q, k, v, scale=scale, fp32_acc=self.attn_fp32_acc, concat_heads=True) + + def decoder(self, x: Any, qpe: Any, key: Any, taps: Optional[Dict[str, Any]] = None): + """One post-norm decoder layer on ``x`` ``[1, 1, K, d]`` -> ``[1, 1, K, d]`` (``key``: :meth:`keys`).""" + import ttnn + + from ..ops.attention import split_heads, split_qkv + + s = ttnn.add(x, qpe) + qkv = self.self_qkv(s) + free_maps(s) + q, k, v = split_qkv(qkv, self.heads_self) + free_maps(qkv) + a = self._sdpa(q, k, v, self.scale_self) + free_maps(q, k, v) + o = self.self_out(a) + free_maps(a) + x1 = self.norms[0](o, x) + free_maps(o) + if taps is not None: + taps["decoder.self_attn"] = x1 + qc_in = ttnn.add(x1, qpe) + qc = self.cross_q(qc_in) + free_maps(qc_in) + kc, vc = self.cross_k(key), self.cross_v(key) + qh, kh, vh = (split_heads(t, self.heads_cross) for t in (qc, kc, vc)) + free_maps(qc, kc, vc) + a = self._sdpa(qh, kh, vh, self.scale_cross) + free_maps(qh, kh, vh) + o = self.cross_out(a) + free_maps(a) + x2 = self.norms[1](o, x1) + free_maps(o) + if taps is not None: + taps["decoder.cross_attn"] = x2 + else: + free_maps(x1) + h = self.ffn1(x2) + f = self.ffn2(h) + free_maps(h) + x3 = self.norms[2](f, x2) + free_maps(f) + if taps is None: + free_maps(x2) + return x3 + + def predict(self, x: Any): + """The merged prediction heads: ``[1, 1, K, d]`` -> fp32 ``[1, 1, K, 32]`` (``head_slices`` channels).""" + hid = self.heads_hidden(x) + out = self.heads_out(hid) + free_maps(hid) + return out + + def query_heat(self, heat_nms: Any, pos: Any): + """The local-max heatmap rows (every class, zero-padded to 32) at the proposals' cells: bf16 TILE + ``[1, 1, Kp, 32]``.""" + import ttnn + + padded = ttnn.pad(heat_nms, [(0, 0), (0, 0), (0, 0), (0, HEAD_PAD - self.classes)], 0.0) + table = ttnn.to_layout(padded, ttnn.ROW_MAJOR_LAYOUT) + free_maps(padded) + out = self._gather(pos, table) + free_maps(table) + return out + + def _indices_tile(self, indices: Any): + """uint32 ROW_MAJOR ``[1, Kp]`` -> fp32 ``[1, 1, 1, Kp]`` ROW_MAJOR-ready tensor (exact below 2**24).""" + import ttnn + + t = ttnn.to_layout(indices, ttnn.TILE_LAYOUT) + f = ttnn.typecast(t, ttnn.float32) + free_maps(t) + return ttnn.reshape(f, (1, 1, 1, int(indices.shape[-1]))) + + def __call__(self, lidar_feat: Any, heat_nms: Any, indices: Optional[Any] = None, + taps: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """``lidar_feat`` TILE ``[1, 1, cells, d]`` (bf16), ``heat_nms`` TILE ``[1, 1, cells, C]`` bf16 (C22's + output), ``indices``: None (device top-k) or a uint32 ROW_MAJOR ``[1, Kp]`` input (teacher forcing). Returns + ``{"heads", "query_heat", "indices"}`` (module docstring); ``taps`` (a dict) also collects ``query.feat``, + ``decoder.query_pos_embed``, ``decoder.self_attn``, ``decoder.cross_attn``, ``decoder.out``.""" + idx = self.select.indices(heat_nms) if indices is None else indices + pos, cls = self.select.decode(idx) + x, qpe = self.init_queries(lidar_feat, pos, cls) + free_maps(cls) + key = self.keys(lidar_feat) + y = self.decoder(x, qpe, key, taps) + free_maps(key) + heads = self.predict(y) + qheat = self.query_heat(heat_nms, pos) + free_maps(pos) + idx_f = self._indices_tile(idx) + if indices is None: + free_maps(idx) + if taps is not None: + taps.update({"query.feat": x, "decoder.query_pos_embed": qpe, "decoder.out": y}) + else: + free_maps(x, qpe, y) + return {"heads": heads, "query_heat": qheat, "indices": idx_f} + + # ---- host side ----------------------------------------------------------------------------------------- + def unpack_heads(self, heads: Any) -> Dict[str, np.ndarray]: + """The read-back ``heads`` array -> ``{name: (c, K)}`` float32.""" + a = np.asarray(heads, np.float32).reshape(-1, HEAD_PAD)[: self.k] + return {name: np.ascontiguousarray(a[:, s:e].T) for name, s, e in self.head_slices} + + def release(self) -> None: + for m in (self.self_qkv, self.self_out, self.cross_q, self.cross_k, self.cross_v, self.cross_out, + self.ffn1, self.ffn2, self.heads_hidden, self.heads_out, *self.norms): + m.release() + if self._tables is not None: + free_maps(*self._tables.values()) + self._tables = None + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "cells": self.cells, "classes": self.classes, "proposals": self.k, + "padded_k": self.kp, "dim": self.dim, "heads": [self.heads_self, self.heads_cross], + "head_dim": [self.hd_self, self.hd_cross], + "padded_head_dim": [padded_head_dim(self.hd_self), padded_head_dim(self.hd_cross)], + "scale": [self.scale_self, self.scale_cross], "attn_fp32_acc": self.attn_fp32_acc, + "head_slices": [list(s) for s in self.head_slices], "precision": self.policy.describe(), + "topk": self.select.describe()} + + +# ---------------------------------------------------------------------------------------------- oracle + +class QueryHeadOracle: + """fp32 (or fp64) torch oracle of the LITERAL head (no tables, no padding, no merged heads): the reference of + the device tests. Inputs NCHW ``lidar_feat (1, d, H, W)``, ``heat_nms (1, C, H, W)`` and the class-major + indices of the proposals.""" + + def __init__(self, weights: QueryHeadWeights, *, dtype: str = "float32"): + weights.validate() + self.w = weights + self.dtype = dtype + + def _t(self, a: Any): + import torch + + return torch.as_tensor(np.asarray(a, np.float64)).to(getattr(torch, self.dtype)) + + def _mha(self, a: MHAWeights, q_in, kv_in, v_in): + import torch + + t = self._t + q = q_in @ t(a.wq) + t(a.bq) + k = kv_in @ t(a.wk) + t(a.bk) + v = v_in @ t(a.wv) + t(a.bv) + h, d = int(a.num_heads), a.head_dim + q = q.reshape(-1, h, d).transpose(0, 1) + k = k.reshape(-1, h, d).transpose(0, 1) + v = v.reshape(-1, h, d).transpose(0, 1) + o = torch.softmax((q @ k.transpose(1, 2)) * float(a.scale), dim=-1) @ v + return o.transpose(0, 1).reshape(q_in.shape[0], h * d) @ t(a.wo) + t(a.bo) + + def _ln(self, i: int, x): + import torch + + g, b, eps = self.w.norms[i] + return torch.nn.functional.layer_norm(x, (x.shape[-1],), self._t(g), self._t(b), float(eps)) + + def _posembed(self, mlp: PosEmbedMLP, pos): + import torch + + t = self._t + return torch.relu(pos @ t(mlp.w0) + t(mlp.b0)) @ t(mlp.w1) + t(mlp.b1) + + def __call__(self, lidar_feat: Any, heat_nms: Any, indices: Any) -> Dict[str, np.ndarray]: + """-> ``{"query.feat" (K, d), "decoder.query_pos_embed", "decoder.self_attn", "decoder.cross_attn", + "decoder.out" (K, d), "heads" {name: (c, K)}, "query_heat" (C, K), "query_pos" (K, 2)}``.""" + import torch + + w = self.w + idx = np.asarray(indices).astype(np.int64).reshape(-1)[: w.num_proposals] + pos, cls = idx % w.cells, idx // w.cells + with torch.no_grad(): + feat = self._t(np.asarray(lidar_feat, np.float64).reshape(w.dim, w.cells).T) # (cells, d) + heat = np.asarray(heat_nms, np.float64).reshape(w.num_classes, w.cells) + x = feat[pos] + self._t(w.class_table)[cls] + qpos = self._t(np.asarray(w.bev_pos, np.float64)[pos]) + qpe = self._posembed(w.self_posembed, qpos) + key = feat + self._posembed(w.cross_posembed, self._t(w.bev_pos)) + s = x + qpe + x1 = self._ln(0, x + self._mha(w.self_attn, s, s, s)) + x2 = self._ln(1, x1 + self._mha(w.cross_attn, x1 + qpe, key, key)) + w1, b1, w2, b2 = (self._t(a) for a in w.ffn) + x3 = self._ln(2, x2 + torch.relu(x2 @ w1 + b1) @ w2 + b2) + heads = {} + for h in w.heads: + y = torch.relu(x3 @ self._t(h.w_hidden) + self._t(h.b_hidden)) @ self._t(h.w_out) + self._t(h.b_out) + heads[h.name] = y.T.numpy().astype(np.float32) + + def f(a): + return a.numpy().astype(np.float32) + + return {"query.feat": f(x), "decoder.query_pos_embed": f(qpe), "decoder.self_attn": f(x1), + "decoder.cross_attn": f(x2), "decoder.out": f(x3), "heads": heads, + "query_heat": heat[:, pos].astype(np.float32), "query_pos": f(qpos)} + + +# -------------------------------------------------------------------------------------------- assembly + +def assemble_transfusion(heads: Dict[str, np.ndarray], query_heat: Any, indices: Any, bev_pos: Any, *, + num_classes: int, num_proposals: int, cells: int) -> Dict[str, np.ndarray]: + """TransFusion's (mmdeploy-patched) graph outputs from the head's read-back arrays, in float32 on the host: + ``cls_score0 = query_heat * sigmoid(heads.heatmap)`` ``(1, C, K)``, ``bbox_pred0 = [center + query_pos, + height, dim, vel]`` ``(1, 8, K)``, ``dir_cls_pred0 = rot`` ``(1, 2, K)``; ``query_pos = bev_pos[pos]`` + (float32: never through bf16, S:transfusion:241). ``heads``: :meth:`TransFusionQueryHead.unpack_heads`; + ``query_heat``: the ``[.., Kp, 32]`` rows; ``indices``: the class-major proposal indices.""" + k = int(num_proposals) + idx = np.rint(np.asarray(indices, np.float64).reshape(-1)[:k]).astype(np.int64) + pos = idx % int(cells) + qpos = np.asarray(bev_pos, np.float32)[pos] # (K, 2) + qheat = np.asarray(query_heat, np.float32).reshape(-1, HEAD_PAD)[:k, :int(num_classes)].T + logits = np.asarray(heads["heatmap"], np.float32) + sig = (1.0 / (1.0 + np.exp(-logits.astype(np.float64)))).astype(np.float32) + cls = (qheat * sig).astype(np.float32)[None] + center = (np.asarray(heads["center"], np.float32) + qpos.T).astype(np.float32) + bbox = np.concatenate([center, heads["height"], heads["dim"], heads["vel"]], axis=0).astype(np.float32)[None] + return {"cls_score0": cls, "bbox_pred0": bbox, "dir_cls_pred0": np.asarray(heads["rot"], np.float32)[None]} diff --git a/code/tt_frnet/ttaw/nms.py b/code/tt_frnet/ttaw/nms.py new file mode 100644 index 0000000000000000000000000000000000000000..2cecbbdcd7971f95abcd11870f28f3654cd61308 --- /dev/null +++ b/code/tt_frnet/ttaw/nms.py @@ -0,0 +1,247 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C15 NMS family of the Autoware LiDAR detectors: circle NMS, rotated IoU-BEV NMS with perception_utils semantics, +and the area-based class remapper. + +- :func:`circle_nms` is ``circleNMS`` of ``autoware_lidar_centerpoint/lib/postprocess/circle_nms_kernel.cu:38-142``: + boxes in descending score order; a kept box suppresses every *later* box whose squared BEV centre distance is + strictly below ``threshold^2`` (float32: ``powf(dx, 2) + powf(dy, 2) < powf(thr, 2)``); greedy, class-agnostic. + A threshold <= 0 means 1.5 m in Autoware's config (``centerpoint_config.hpp:83-85,139``): the caller applies that. +- :func:`iou_bev_nms` is ``perception_utils::IouBevNms::apply`` (``perception_utils/src/iou_bev_nms.cpp:132-171``, + universe main 9ceaccf). Object ``t`` is compared with **every** earlier object ``s < t``, kept or suppressed + (S:centerpoint:291-295); pairs with different labels where one is PEDESTRIAN are skipped; pairs whose centre + distance squared exceeds ``search_distance_2d^2`` are skipped; the loop over ``s`` breaks at the first + ``iou > threshold``; ``t`` is kept iff the largest IoU seen is ``<= threshold``. ``sort=False`` (the CenterPoint + node's call) keeps the input order. IoU: the BEV rectangles of ``autoware_utils::toPolygon2d`` intersected + exactly (Sutherland-Hodgman on two convex quadrilaterals, float64), with the area guards 1e-6 (each box and the + intersection) and 0.01 (union). +- :class:`ClassRemapper` is ``perception_utils::DetectionClassRemapper::mapClasses`` + (``perception_utils/src/detection_class_remapper.cpp:54-70``): with ``bev_area = float(length * width)``, the first + destination label (in label order) whose matrix entry allows it and whose ``[min, max]`` contains the area wins. + +numpy only; no side effects on import. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, List, Mapping, Optional, Sequence + +import numpy as np + +__all__ = [ + "PEDESTRIAN_LABEL", + "circle_nms", + "bev_box_corners", + "polygon_area", + "clip_convex", + "iou_bev", + "iou_bev_nms", + "ClassRemapper", +] + +PEDESTRIAN_LABEL = 7 # autoware_perception_msgs/ObjectClassification PEDESTRIAN + +_MIN_AREA = 1.0e-6 # iou_bev_nms.cpp compute2dIoU kMinArea +_MIN_UNION_AREA = 0.01 # kMinUnionArea + + +def circle_nms(x: Any, y: Any, dist_threshold: float) -> np.ndarray: + """Indices (ascending) of the boxes kept by Autoware's circle NMS. The input must already be in descending + score order (``thrust::sort``); see the module docstring.""" + xs = np.asarray(x, dtype=np.float32) + ys = np.asarray(y, dtype=np.float32) + n = len(xs) + thr2 = np.float32(dist_threshold) * np.float32(dist_threshold) + removed = np.zeros(n, dtype=bool) + keep: List[int] = [] + for i in range(n): + if removed[i]: + continue + keep.append(i) + dx = xs[i + 1:] - xs[i] + dy = ys[i + 1:] - ys[i] + removed[i + 1:] |= (dx * dx + dy * dy) < thr2 + return np.asarray(keep, dtype=np.int64) + + +def bev_box_corners(x: float, y: float, length: float, width: float, yaw: float) -> np.ndarray: + """(4, 2) float64 corners in ``toPolygon2d`` order: (+l/2, +w/2), (-l/2, +w/2), (-l/2, -w/2), (+l/2, -w/2) in + the object frame, rotated by ``yaw`` about the centre (counter-clockwise for positive sizes).""" + c, s = math.cos(yaw), math.sin(yaw) + hl, hw = length / 2.0, width / 2.0 + local = ((hl, hw), (-hl, hw), (-hl, -hw), (hl, -hw)) + return np.array([(x + c * px - s * py, y + s * px + c * py) for px, py in local], dtype=np.float64) + + +def polygon_area(poly: Any) -> float: + """Absolute shoelace area of a polygon (k, 2).""" + p = np.asarray(poly, dtype=np.float64) + if len(p) < 3: + return 0.0 + x, y = p[:, 0], p[:, 1] + return abs(float(np.dot(x, np.roll(y, -1)) - np.dot(np.roll(x, -1), y))) / 2.0 + + +def _signed_area(p: np.ndarray) -> float: + x, y = p[:, 0], p[:, 1] + return float(np.dot(x, np.roll(y, -1)) - np.dot(np.roll(x, -1), y)) / 2.0 + + +def clip_convex(subject: Any, clipper: Any) -> np.ndarray: + """Sutherland-Hodgman: ``subject`` polygon clipped by the convex polygon ``clipper`` (either orientation) -> + (k, 2) float64 intersection polygon (k may be 0).""" + clip = np.asarray(clipper, dtype=np.float64) + if _signed_area(clip) < 0: + clip = clip[::-1] + out = [tuple(p) for p in np.asarray(subject, dtype=np.float64)] + for i in range(len(clip)): + if not out: + break + ax, ay = clip[i] + bx, by = clip[(i + 1) % len(clip)] + inp, out = out, [] + + def inside(p): + return (bx - ax) * (p[1] - ay) - (by - ay) * (p[0] - ax) >= 0.0 + + def cross_point(p, q): + x1, y1, x2, y2 = p[0], p[1], q[0], q[1] + den = (x1 - x2) * (ay - by) - (y1 - y2) * (ax - bx) + if den == 0.0: + return q + t = ((x1 - ax) * (ay - by) - (y1 - ay) * (ax - bx)) / den + return (x1 + t * (x2 - x1), y1 + t * (y2 - y1)) + + prev = inp[-1] + for cur in inp: + if inside(cur): + if not inside(prev): + out.append(cross_point(prev, cur)) + out.append(cur) + elif inside(prev): + out.append(cross_point(prev, cur)) + prev = cur + return np.asarray(out, dtype=np.float64).reshape(-1, 2) + + +def _iou_from(pa: np.ndarray, area_a: float, env_a: np.ndarray, pb: np.ndarray, area_b: float, + env_b: np.ndarray) -> float: + if area_a < _MIN_AREA or area_b < _MIN_AREA: + return 0.0 + if env_a[0] > env_b[2] or env_b[0] > env_a[2] or env_a[1] > env_b[3] or env_b[1] > env_a[3]: + return 0.0 # disjoint envelopes (a speed guard in perception_utils; the intersection would be empty) + inter = polygon_area(clip_convex(pa, pb)) + if inter < _MIN_AREA: + return 0.0 + union = area_a + area_b - inter + if union < _MIN_UNION_AREA: + return 0.0 + return min(1.0, inter / union) + + +def _envelope(p: np.ndarray) -> np.ndarray: + return np.array([p[:, 0].min(), p[:, 1].min(), p[:, 0].max(), p[:, 1].max()]) + + +def iou_bev(a: Sequence[float], b: Sequence[float]) -> float: + """Rotated BEV IoU of two boxes ``(x, y, length, width, yaw)`` with perception_utils' ``compute2dIoU`` guards.""" + pa, pb = bev_box_corners(*a), bev_box_corners(*b) + return _iou_from(pa, polygon_area(pa), _envelope(pa), pb, polygon_area(pb), _envelope(pb)) + + +def iou_bev_nms(x: Any, y: Any, length: Any, width: Any, yaw: Any, labels: Any, *, search_distance_2d: float = 10.0, + iou_threshold: float = 0.1, scores: Optional[Any] = None, sort: bool = False, + pedestrian_label: int = PEDESTRIAN_LABEL) -> np.ndarray: + """Indices of the objects kept by ``IouBevNms::apply`` (module docstring), in processing order. ``labels`` are + ObjectClassification labels; ``yaw`` is the ROS yaw. ``sort=True`` first orders by descending ``scores`` + (``std::stable_sort``).""" + if not math.isfinite(search_distance_2d) or search_distance_2d < 0.0: + raise ValueError("search_distance_2d must be a finite non-negative value") + if not math.isfinite(iou_threshold) or not 0.0 <= iou_threshold <= 1.0: + raise ValueError("iou_threshold must be a finite value between 0 and 1") + xs = np.asarray(x, dtype=np.float64) + n = len(xs) + order = np.arange(n) + if sort: + if scores is None: + raise ValueError("sort=True needs scores") + order = np.argsort(-np.asarray(scores, dtype=np.float64), kind="stable") + ys = np.asarray(y, dtype=np.float64)[order] + xs = xs[order] + ls = np.asarray(length, dtype=np.float64)[order] + ws = np.asarray(width, dtype=np.float64)[order] + yw = np.asarray(yaw, dtype=np.float64)[order] + lab = np.asarray(labels, dtype=np.int64)[order] + polys = [bev_box_corners(xs[i], ys[i], ls[i], ws[i], yw[i]) for i in range(n)] + areas = [polygon_area(p) for p in polys] + envs = [_envelope(p) for p in polys] + sd2 = float(search_distance_2d) * float(search_distance_2d) + keep: List[int] = [] + for t in range(n): + max_iou = 0.0 + for s in range(t): + if lab[t] != lab[s] and (lab[t] == pedestrian_label or lab[s] == pedestrian_label): + continue + dx, dy = xs[t] - xs[s], ys[t] - ys[s] + if not dx * dx + dy * dy <= sd2: + continue + iou = _iou_from(polys[t], areas[t], envs[t], polys[s], areas[s], envs[s]) + max_iou = max(max_iou, iou) + if iou > iou_threshold: + break + if max_iou <= iou_threshold: + keep.append(int(order[t])) + return np.asarray(keep, dtype=np.int64) + + +@dataclass(frozen=True) +class ClassRemapper: + """``DetectionClassRemapper`` with square ``allow`` (bool), ``min_area`` and ``max_area`` matrices indexed + ``[source_label, destination_label]`` over the ObjectClassification labels. Non-finite bounds become + ``DBL_MAX`` (``detection_class_remapper.cpp:43-48``).""" + + allow: np.ndarray + min_area: np.ndarray + max_area: np.ndarray + + @classmethod + def from_lists(cls, allow_remapping_by_area_matrix: Sequence[int], min_area_matrix: Sequence[float], + max_area_matrix: Sequence[float]) -> "ClassRemapper": + """The three flat lists of ``detection_class_remapper.param.yaml`` (row = source, column = destination).""" + if not (len(allow_remapping_by_area_matrix) == len(min_area_matrix) == len(max_area_matrix)): + raise ValueError("Class remapping matrices must have equal sizes.") + n = int(math.isqrt(len(min_area_matrix))) + if n == 0 or n * n != len(min_area_matrix): + raise ValueError("Class remapping matrices must be non-empty and square.") + + def finite(v: Sequence[float]) -> np.ndarray: + a = np.asarray(v, dtype=np.float64) + return np.where(np.isfinite(a), a, np.finfo(np.float64).max).reshape(n, n) + + return cls(np.asarray(allow_remapping_by_area_matrix, dtype=np.int64).reshape(n, n) != 0, + finite(min_area_matrix), finite(max_area_matrix)) + + @classmethod + def from_params(cls, params: Mapping[str, Any]) -> "ClassRemapper": + """From the ``ros__parameters`` mapping of ``detection_class_remapper.param.yaml``.""" + return cls.from_lists(params["allow_remapping_by_area_matrix"], params["min_area_matrix"], + params["max_area_matrix"]) + + @property + def num_labels(self) -> int: + return int(self.allow.shape[0]) + + def apply(self, labels: Any, length: Any, width: Any) -> np.ndarray: + """New labels (uint8) for objects with ``labels`` and BEV ``length`` x ``width``.""" + lab = np.asarray(labels, dtype=np.int64).copy() + area = (np.asarray(length, dtype=np.float64) * np.asarray(width, dtype=np.float64)).astype(np.float32) + area = area.astype(np.float64) + for i in range(len(lab)): + src = int(lab[i]) + if src < 0 or src >= self.num_labels: + continue + for dst in range(self.num_labels): + if self.allow[src, dst] and self.min_area[src, dst] <= area[i] <= self.max_area[src, dst]: + lab[i] = dst + break + return lab.astype(np.uint8) diff --git a/code/tt_frnet/ttaw/ops/__init__.py b/code/tt_frnet/ttaw/ops/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0e5e3c82a25bd65d8a03b4e4354929c839d9e545 --- /dev/null +++ b/code/tt_frnet/ttaw/ops/__init__.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Custom-op helpers. Kernel ``.cpp`` / ``.hpp`` files are package data under ``ops/kernels/`` (shipped through +``source.extra_code`` and ``[tool.setuptools.package-data]``), and are always referenced by absolute path, because +tt-metal resolves a relative kernel path against the CWD, ``TT_METAL_KERNEL_PATH`` and ``TT_METAL_HOME`` first +(TT_PLATFORM.md section 5.1). The C17-C23 op modules and the K1-K11 kernels of PLAN.md section 1.3 are added here +by the first port that needs them, with a numpy oracle test and the hang protocol of PLAN.md section 4.4. + +Op modules (import them explicitly; this package imports none of them): ``conv`` (C17 conv builders), ``upsample`` +(C18 up-sampling and interpolation), ``attention`` (C20 SDPA wrapper), ``gather`` (C19 row gathers), ``topk`` (C21 +top-k selection), ``heatmap`` (C22 heatmap local max), ``deform`` (C23 grid_sample helpers), ``segment`` (K1 +``segment_reduce``: the first ``generic_op`` kernel, ``kernels/segment_reduce_dm.cpp``). +""" +from __future__ import annotations + +import os + +__all__ = ["KERNEL_DIR", "kernel_path"] + +KERNEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "kernels") + + +def kernel_path(name: str, base: str = KERNEL_DIR) -> str: + """Absolute path of a kernel source shipped with the package (``kernel_path("segment_max_reader.cpp")``). + Raises ``FileNotFoundError`` early instead of tt-metal's "doesn't exist in any of the searched paths".""" + path = os.path.join(base, name) + if not os.path.isfile(path): + raise FileNotFoundError(f"kernel source {path} is missing (not shipped as package data?)") + return path diff --git a/code/tt_frnet/ttaw/ops/attention.py b/code/tt_frnet/ttaw/ops/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..7776d9ba671a7afd7ccc787152e7bb95e43dc9ec --- /dev/null +++ b/code/tt_frnet/ttaw/ops/attention.py @@ -0,0 +1,386 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C20: attention on the p150 through ``ttnn.transformer.scaled_dot_product_attention`` (PLAN.md 1.2 C20, probe P7). + +SDPA has three silent traps (common/PROBES.md P7; ``probes/g4-attention-gridsample-msda.md``): + +- ``is_causal`` defaults to True: with Sq == Sk it silently applies a causal mask, with Sq != Sk it is rejected; +- the default ``scale`` is ``1/sqrt(padded head_dim)``: after a 16 -> 32 head padding it is wrong (PCC 0.979); +- the default program config (32/32 chunks) is 2.5-7x slower everywhere and inaccurate over >= 32k keys. + +:func:`sdpa` is the only way the ports call the op: ``is_causal=False`` always, ``scale`` is a required keyword, +and a ``program_config`` is always passed (chunk sizes from :func:`chunk_config`, the P7 table). Without a mask, +pass the logical, unaligned Sq / Sk: the op pads to its chunks and masks its own padding element by element. A user +mask is an additive bf16 TILE tensor **in DRAM**, ``[1|B, 1|H, Sq, Sk]``; ``-inf`` is safe, also when a whole first +key chunk is masked (P7). + +**A user mask needs a tile-aligned Sk** (found by the Diffusion Planner port, 2026-10-07): with a mask, the reader +fills only the key *tiles* past ``ceil(Sk / 32)`` with ``-inf`` and reads the last partial tile from the mask +itself, whose tile padding is 0 after a host tilize (``reader_interleaved.cpp``, "Mask read"). The up to 31 padded +keys then join the softmax with score ``q . k_pad`` and value ``v_pad``: rel-L2 0.18 instead of 0.025 at 321 x 321 +with 89 valid keys, 0.06 at P7's masked 564 x 564. :func:`sdpa` therefore refuses a mask with ``Sk % 32 != 0``: +give K / V a tile-aligned key count (:func:`aligned_keys`; the extra keys are masked with ``-inf`` in the mask, +``key_bias(padded_validity, sq)``), which costs nothing because the tiles are padded anyway. + +Head layout. The op wants ``[B, H, S, D]`` bf16 TILE interleaved tensors with ``D`` a multiple of 32 and no padding on +B / H / D. :func:`split_heads` / :func:`split_qkv` / :func:`split_q_kv` turn projection outputs ``[B, 1, S, H*D]`` +into heads (``ttnn.experimental.nlp_create_qkv_heads``, one program, logical S kept), and ``sdpa(..., +concat_heads=True)`` (the op's ``output_concat_heads``) or :func:`merge_heads` go back to ``[B, 1, S, H*D]``. +A head dim below 32 (16 in TransFusion / BEVFusion / PTv3) is zero-padded *in the weights* +(:func:`pad_head_columns` on the Q / K / V projections, :func:`pad_head_rows` on the output projection): exact +(padded output columns are 0, P7) and free at run time; pass ``scale=1/sqrt(logical head dim)``. + +Masks. :func:`key_bias` builds ``[1, 1, Sq, Sk]`` (0 for valid keys, ``-inf`` otherwise) on the host; inside a +trace :func:`expand_key_bias` repeats a persistent ``[1, 1, 1, Sk]`` bias row (:func:`key_bias_row`) to +``[1, 1, Sq, Sk]`` in DRAM (one program, the upload shrinks from Sq * Sk to Sk values). + +Fallback. :func:`attention_matmul` is plain ``softmax(scale Q K^T + bias) V`` with matmuls (for dtypes or layouts the +op rejects, e.g. fp32 attention); it needs a tile-aligned Sk (pad the keys and mask them with ``-inf``). +:func:`attention_reference` is the float64 numpy oracle of both. + +Importing this module never imports ttnn (ttaw rule); ttnn is imported inside the functions that need it. +""" +from __future__ import annotations + +import math +from typing import Any, Dict, Optional, Sequence, Tuple + +import numpy as np + +__all__ = [ + "aligned_keys", + "HEAD_DIM_MULTIPLE", + "PROGRAM_CONFIG_MIN_SK", + "CHUNK_TABLE", + "padded_head_dim", + "chunk_config", + "sdpa_program_config", + "sdpa_compute_config", + "sdpa", + "split_heads", + "split_qkv", + "split_q_kv", + "merge_heads", + "key_bias", + "key_bias_row", + "expand_key_bias", + "pad_head_dim", + "pad_head_columns", + "pad_head_rows", + "attention_reference", + "attention_matmul", +] + +HEAD_DIM_MULTIPLE = 32 # SDPA rejects a padded head dim ("Padding is not supported on the head_dim dimension") +TILE = 32 +# P7: from about 1,000 keys on, the default 32/32 chunks are 2.5-7x slower; this wrapper passes a config always. +PROGRAM_CONFIG_MIN_SK = 1000 + +# Exact (Sq, Sk) shapes measured on the p150b (ETH, 12x10): (q_chunk, k_chunk) and the evidence. Shapes not listed +# use the Sk-range rules of :func:`chunk_config`. Ports add their measured shapes here (with the log). +CHUNK_TABLE: Dict[Tuple[int, int], Tuple[int, int, str]] = { + (500, 500): (128, 128, "P7 tf_self 0.029 ms (default 0.047)"), + (512, 512): (512, 512, "P7 pt_patch_s0/s4 0.172-0.222 ms (q128 k128 0.413-0.571)"), + (564, 564): (64, 64, "P7 dp_fusion_masked 0.058 ms (default 0.094, q128 k128 0.158)"), + (321, 564): (64, 64, "P7 dp_cross 0.029 ms (default 0.030)"), + (576, 576): (64, 64, "DP fusion, masked: 0.061-0.092 ms (q32 k64 0.117, q32 k32 0.129, q128 k128 0.275)"), + (352, 352): (64, 64, "DP DiT self, masked: 0.063 ms (q64 k128 0.059, q32 k32 0.080, q128 k128 0.071)"), + (352, 564): (64, 64, "DP DiT cross: 0.033-0.043 ms (q32 k32 0.035, q128 k128 0.052, q64 k128 0.077)"), + (900, 1668): (64, 128, "P7 sp_self 0.052 ms (default 0.139)"), + (900, 6000): (64, 512, "P7 sp_cross 0.106 ms (default 0.469)"), +} + + +def padded_head_dim(head_dim: int, multiple: int = HEAD_DIM_MULTIPLE) -> int: + """The head dim SDPA sees: ``head_dim`` rounded up to a multiple of 32.""" + if head_dim <= 0: + raise ValueError(f"head_dim must be positive, got {head_dim}") + return -(-int(head_dim) // multiple) * multiple + + +def aligned_keys(n: int, multiple: int = TILE) -> int: + """The tile-aligned key count a masked attention over ``n`` real keys runs on (the extra keys are masked).""" + if n <= 0: + raise ValueError(f"key count must be positive, got {n}") + return -(-int(n) // multiple) * multiple + + +def chunk_config(sq: int, sk: int) -> Tuple[int, int]: + """``(q_chunk, k_chunk)`` for logical ``Sq`` x ``Sk`` (multiples of 32, as the op requires). + + Exact shapes come from :data:`CHUNK_TABLE`. Otherwise, by key length (P7: chunk sizes are what matters for + long keys; q64 keeps ``B * H * Sq / 64`` work units near the 120 cores at Sq ~ 500-900): + Sk >= 16,384 -> q64 k1024 (P7 tf / bf / pt cross 0.45-0.88 ms; q64 k512 + ``fp32_acc`` halves the max error at + ~1.4x the time); Sk >= 4,096 -> q64 k512; Sk >= 1,000 -> q64 k128; shorter keys -> q64 k64 (32 when the + sequence fits one tile). + """ + sq, sk = int(sq), int(sk) + if sq <= 0 or sk <= 0: + raise ValueError(f"sequence lengths must be positive, got Sq={sq}, Sk={sk}") + if (sq, sk) in CHUNK_TABLE: + q, k, _ = CHUNK_TABLE[(sq, sk)] + return q, k + q = 32 if sq <= TILE else 64 + if sk >= 16384: + return q, 1024 + if sk >= 4096: + return q, 512 + if sk >= PROGRAM_CONFIG_MIN_SK: + return q, 128 + return q, (32 if sk <= TILE else 64) + + +def sdpa_program_config(device: Any, sq: int, sk: int, *, q_chunk: Optional[int] = None, k_chunk: Optional[int] = None, + exp_approx_mode: Optional[bool] = None): + """``ttnn.SDPAProgramConfig`` over the device's whole compute grid (read from the device, never hard-coded) + with the :func:`chunk_config` chunks unless ``q_chunk`` / ``k_chunk`` are given.""" + import ttnn + + q_default, k_default = chunk_config(sq, sk) + q_chunk = int(q_chunk or q_default) + k_chunk = int(k_chunk or k_default) + for name, value in (("q_chunk", q_chunk), ("k_chunk", k_chunk)): + if value <= 0 or value % TILE: + raise ValueError(f"{name}={value}: must be a positive multiple of {TILE}") + return ttnn.SDPAProgramConfig(compute_with_storage_grid_size=device.compute_with_storage_grid_size(), + q_chunk_size=q_chunk, k_chunk_size=k_chunk, exp_approx_mode=exp_approx_mode) + + +def sdpa_compute_config(*, fp32_acc: bool = False, fidelity: str = "HiFi2", approx: bool = True): + """Explicit SDPA compute config. The default equals the op's own (HiFi2, bf16 DST, approximate exp): P7 found + neither HiFi4 nor exact exp more accurate; ``fp32_acc=True`` (the op's standard instead of its streaming kernel) + halves the max error (0.11 -> 0.056 over 36k keys) at 1.35-1.45x the time.""" + from ..precision import compute_kernel_config + + return compute_kernel_config(fidelity, fp32_acc=fp32_acc, approx=approx) + + +def _seq(t: Any) -> int: + return int(t.shape[-2]) + + +def sdpa(q: Any, k: Any, v: Any, *, scale: float, attn_mask: Any = None, program_config: Any = "auto", + compute_kernel_config: Any = None, fp32_acc: bool = False, concat_heads: bool = False, + memory_config: Any = None): + """Non-causal scaled dot-product attention ``softmax(scale * Q K^T + attn_mask) V``. + + ``q`` ``[B, H, Sq, D]``, ``k`` / ``v`` ``[B, Hkv, Sk, D]`` (bf16 / bfp8 / bfp4, TILE, interleaved, D a multiple of + 32); ``scale`` is required: ``1/sqrt(logical head dim)`` (never the op's padded-head default). ``attn_mask``: + additive ``[1|B, 1|H, Sq, Sk]`` bf16 TILE tensor in DRAM, or None; with a mask ``Sk`` must be a multiple of 32 + (module docstring). ``program_config``: ``"auto"`` (the + :func:`chunk_config` chunks over the whole grid), an ``SDPAProgramConfig``, or None (op default; refused once + Sk >= :data:`PROGRAM_CONFIG_MIN_SK`, P7). ``compute_kernel_config`` overrides :func:`sdpa_compute_config` + (``fp32_acc``). ``concat_heads=True`` returns ``[B, 1, Sq, H*D]`` instead of ``[B, H, Sq, D]`` (no separate + concat program). Returns the device tensor. + """ + import ttnn + + scale = float(scale) + if not math.isfinite(scale) or scale <= 0.0: + raise ValueError(f"scale must be a positive finite float, got {scale!r}") + if len(q.shape) != 4 or len(k.shape) != 4 or len(v.shape) != 4: + raise ValueError(f"sdpa expects [B, H, S, D] tensors, got q{tuple(q.shape)} k{tuple(k.shape)} " + f"v{tuple(v.shape)}") + d = int(q.shape[-1]) + if d % HEAD_DIM_MULTIPLE: + raise ValueError(f"head dim {d} is not a multiple of {HEAD_DIM_MULTIPLE}: zero-pad it in the projection " + "weights (pad_head_columns / pad_head_rows) and pass scale=1/sqrt(logical head dim)") + sq, sk = _seq(q), _seq(k) + if attn_mask is not None and sk % TILE: + raise ValueError(f"a user mask needs a tile-aligned key count (Sk={sk}): the op masks the padded keys of the " + "last partial tile only through the mask's own tile padding (0 after a host tilize), so " + f"they would join the softmax. Run on aligned_keys({sk}) = {aligned_keys(sk)} keys and mask " + "the extra ones with -inf (key_bias on the padded validity)") + if isinstance(program_config, str): + if program_config != "auto": + raise ValueError(f"program_config={program_config!r}: expected 'auto', a config object or None") + program_config = sdpa_program_config(q.device(), sq, sk) + elif program_config is None and sk >= PROGRAM_CONFIG_MIN_SK: + raise ValueError(f"Sk={sk} >= {PROGRAM_CONFIG_MIN_SK}: the op's default 32/32 chunks are slow and inaccurate " + "for long keys (probe P7); pass program_config='auto' or an SDPAProgramConfig") + if compute_kernel_config is None: + compute_kernel_config = sdpa_compute_config(fp32_acc=fp32_acc) + kwargs: Dict[str, Any] = {} + if concat_heads: + kwargs["output_concat_heads"] = True + return ttnn.transformer.scaled_dot_product_attention( + q, k, v, attn_mask=attn_mask, is_causal=False, scale=scale, memory_config=memory_config, + program_config=program_config, compute_kernel_config=compute_kernel_config, **kwargs) + + +# ---------------------------------------------------------------------------------------------------- heads + +def _check_4d_heads(x: Any, what: str) -> None: + if len(x.shape) != 4 or int(x.shape[1]) != 1: + raise ValueError(f"{what} must be [B, 1, S, width] (a 4-D projection output), got {tuple(x.shape)}") + + +def split_heads(x: Any, num_heads: int, *, memory_config: Any = None): + """``[B, 1, S, H*D]`` -> ``[B, H, S, D]`` (one ``nlp_create_qkv_heads`` program with no K / V heads; the logical + S is kept, so unaligned sequences stay unaligned for :func:`sdpa`).""" + import ttnn + + _check_4d_heads(x, "split_heads input") + width = int(x.shape[-1]) + if width % num_heads or (width // num_heads) % HEAD_DIM_MULTIPLE: + raise ValueError(f"width {width} does not split into {num_heads} heads of a multiple of {HEAD_DIM_MULTIPLE}") + out, _, _ = ttnn.experimental.nlp_create_qkv_heads(x, num_heads=num_heads, num_kv_heads=0, + transpose_k_heads=False, memory_config=memory_config) + return out + + +def split_qkv(qkv: Any, num_heads: int, *, num_kv_heads: Optional[int] = None, memory_config: Any = None): + """Fused ``[B, 1, S, (H + 2 Hkv) * D]`` (Q | K | V column blocks, heads contiguous) -> ``(q, k, v)`` + ``[B, H|Hkv, S, D]`` in one program (K not transposed).""" + import ttnn + + _check_4d_heads(qkv, "split_qkv input") + kv_heads = num_heads if num_kv_heads is None else int(num_kv_heads) + width = int(qkv.shape[-1]) + if width % (num_heads + 2 * kv_heads) or (width // (num_heads + 2 * kv_heads)) % HEAD_DIM_MULTIPLE: + raise ValueError(f"width {width} does not split into {num_heads} + 2 x {kv_heads} heads of a multiple of " + f"{HEAD_DIM_MULTIPLE}") + return ttnn.experimental.nlp_create_qkv_heads(qkv, num_heads=num_heads, num_kv_heads=kv_heads, + transpose_k_heads=False, memory_config=memory_config) + + +def split_q_kv(q: Any, kv: Any, num_heads: int, *, num_kv_heads: Optional[int] = None, memory_config: Any = None): + """Separate ``q`` ``[B, 1, S, H*D]`` and ``kv`` ``[B, 1, S, 2*Hkv*D]`` (K | V column blocks) with the SAME S + (self-attention with Q and K / V from different inputs, e.g. a pre-LN Q next to an un-normalised K / V) + -> ``(q, k, v)`` heads in one program. Cross-attention with another key length: :func:`split_heads` each.""" + import ttnn + + _check_4d_heads(q, "split_q_kv q") + _check_4d_heads(kv, "split_q_kv kv") + if int(q.shape[-2]) != int(kv.shape[-2]): + raise ValueError(f"split_q_kv needs the same sequence length (Q {q.shape[-2]}, K/V {kv.shape[-2]})") + kv_heads = num_heads if num_kv_heads is None else int(num_kv_heads) + return ttnn.experimental.nlp_create_qkv_heads(q, kv, num_heads=num_heads, num_kv_heads=kv_heads, + transpose_k_heads=False, memory_config=memory_config) + + +def merge_heads(x: Any, *, memory_config: Any = None): + """``[B, H, S, D]`` -> ``[B, 1, S, H*D]`` (``nlp_concat_heads``). Prefer ``sdpa(..., concat_heads=True)``.""" + import ttnn + + if len(x.shape) != 4: + raise ValueError(f"merge_heads expects [B, H, S, D], got {tuple(x.shape)}") + return ttnn.experimental.nlp_concat_heads(x, memory_config=memory_config) + + +# ---------------------------------------------------------------------------------------------------- masks + +def key_bias_row(valid: Sequence[bool], *, dtype: Any = np.float32) -> np.ndarray: + """``[1, 1, 1, Sk]`` additive key bias: 0 for valid keys, ``-inf`` for masked ones (bf16-exact values).""" + v = np.asarray(valid, dtype=bool).reshape(-1) + if v.size == 0: + raise ValueError("key_bias_row: empty validity vector") + if not v.any(): + raise ValueError("key_bias_row: every key is masked (the softmax of a fully masked row is undefined)") + return np.where(v, 0.0, -np.inf).astype(dtype).reshape(1, 1, 1, -1) + + +def key_bias(valid: Sequence[bool], sq: int, *, dtype: Any = np.float32) -> np.ndarray: + """``[1, 1, Sq, Sk]`` additive key mask for :func:`sdpa` (identical rows), built on the host. Upload it as a + bf16 TILE tensor in DRAM (the op's requirement), or keep a :func:`key_bias_row` input and expand it on the + device with :func:`expand_key_bias`.""" + row = key_bias_row(valid, dtype=dtype) + return np.ascontiguousarray(np.broadcast_to(row, (1, 1, int(sq), row.shape[-1]))) + + +def expand_key_bias(row: Any, sq: int, *, memory_config: Any = None): + """Device-side :func:`key_bias`: repeat a ``[1, 1, 1, Sk]`` bf16 TILE bias row to ``[1, 1, Sq, Sk]`` in DRAM + (one program; traceable).""" + import ttnn + + if len(row.shape) != 4 or int(row.shape[-2]) != 1: + raise ValueError(f"expand_key_bias expects a [1, 1, 1, Sk] row, got {tuple(row.shape)}") + return ttnn.repeat(row, [1, 1, int(sq), 1], + memory_config=ttnn.DRAM_MEMORY_CONFIG if memory_config is None else memory_config) + + +# ---------------------------------------------------------------------------------------------------- head padding + +def pad_head_dim(x: Any, padded: Optional[int] = None) -> np.ndarray: + """Zero-pad the last (head) dim of a host array ``[..., D]`` to ``padded`` (default :func:`padded_head_dim`).""" + a = np.asarray(x) + d = a.shape[-1] + p = padded_head_dim(d) if padded is None else int(padded) + if p < d: + raise ValueError(f"padded head dim {p} < head dim {d}") + if p == d: + return a + return np.pad(a, [(0, 0)] * (a.ndim - 1) + [(0, p - d)]) + + +def pad_head_columns(w: Any, b: Any, num_heads: int, head_dim: int, + padded: Optional[int] = None) -> Tuple[np.ndarray, Optional[np.ndarray]]: + """Projection ``[in, H*D]`` (``y = x @ w + b``) -> ``[in, H*Dp]`` with zero columns after each head's D (and the + bias likewise), so the projection itself emits padded heads (exact: the padded columns are 0).""" + w = np.asarray(w) + p = padded_head_dim(head_dim) if padded is None else int(padded) + if w.shape[-1] != num_heads * head_dim: + raise ValueError(f"weight has {w.shape[-1]} columns, expected {num_heads} x {head_dim}") + wh = w.reshape(w.shape[:-1] + (num_heads, head_dim)) + wp = np.zeros(w.shape[:-1] + (num_heads, p), w.dtype) + wp[..., :head_dim] = wh + bp = None + if b is not None: + b = np.asarray(b).reshape(num_heads, head_dim) + bp = np.zeros((num_heads, p), b.dtype) + bp[:, :head_dim] = b + bp = bp.reshape(-1) + return wp.reshape(w.shape[:-1] + (num_heads * p,)), bp + + +def pad_head_rows(w: Any, num_heads: int, head_dim: int, padded: Optional[int] = None) -> np.ndarray: + """Output projection ``[H*D, out]`` -> ``[H*Dp, out]`` with zero rows at each head's padded positions, matching + the ``[.., H*Dp]`` concatenated-heads output of a padded attention.""" + w = np.asarray(w) + p = padded_head_dim(head_dim) if padded is None else int(padded) + if w.shape[0] != num_heads * head_dim: + raise ValueError(f"weight has {w.shape[0]} rows, expected {num_heads} x {head_dim}") + wp = np.zeros((num_heads, p) + w.shape[1:], w.dtype) + wp[:, :head_dim] = w.reshape((num_heads, head_dim) + w.shape[1:]) + return wp.reshape((num_heads * p,) + w.shape[1:]) + + +# ---------------------------------------------------------------------------------------------------- oracle / fallback + +def attention_reference(q: Any, k: Any, v: Any, *, scale: float, bias: Any = None) -> np.ndarray: + """float64 numpy ``softmax(scale Q K^T + bias) V`` over ``[B, H, S, D]`` arrays (K / V heads broadcast when + ``Hkv`` divides ``H``); ``bias`` broadcasts to ``[B, H, Sq, Sk]`` (``-inf`` allowed).""" + q = np.asarray(q, np.float64) + k = np.asarray(k, np.float64) + v = np.asarray(v, np.float64) + if k.shape[1] != q.shape[1]: + rep = q.shape[1] // k.shape[1] + k, v = np.repeat(k, rep, axis=1), np.repeat(v, rep, axis=1) + s = np.einsum("bhqd,bhkd->bhqk", q, k) * float(scale) + if bias is not None: + s = s + np.asarray(bias, np.float64) + s = s - s.max(axis=-1, keepdims=True) + p = np.exp(s) + p /= p.sum(axis=-1, keepdims=True) + return np.einsum("bhqk,bhkd->bhqd", p, v) + + +def attention_matmul(q: Any, k: Any, v: Any, *, scale: float, attn_mask: Any = None, + compute_kernel_config: Any = None): + """Small-MHA fallback for what :func:`sdpa` rejects (e.g. fp32 Q / K / V): ``softmax(scale Q K^T + mask) V`` + with two matmuls. ``q`` ``[B, H, Sq, D]``, ``k`` / ``v`` ``[B, H, Sk, D]`` with a tile-aligned ``Sk`` (pad the + keys and mask the padding with ``-inf``: a softmax over tile padding would include it). Materialises the + ``[B, H, Sq, Sk]`` scores, so keep it for small shapes.""" + import ttnn + + from ..precision import compute_kernel_config as ckc + + sk = int(k.shape[-2]) + if sk % TILE: + raise ValueError(f"attention_matmul needs a tile-aligned Sk (got {sk}): pad the keys and mask them") + cfg = compute_kernel_config if compute_kernel_config is not None else ckc("HiFi4", fp32_acc=True) + scores = ttnn.matmul(q, k, transpose_b=True, compute_kernel_config=cfg) + scores = ttnn.multiply(scores, float(scale)) + if attn_mask is not None: + scores = ttnn.add(scores, attn_mask) + probs = ttnn.softmax(scores, dim=-1, numeric_stable=True, compute_kernel_config=cfg) + return ttnn.matmul(probs, v, compute_kernel_config=cfg) diff --git a/code/tt_frnet/ttaw/ops/conv.py b/code/tt_frnet/ttaw/ops/conv.py new file mode 100644 index 0000000000000000000000000000000000000000..4ee4ffd6c87b24912b49ce7006353cd787869932 --- /dev/null +++ b/code/tt_frnet/ttaw/ops/conv.py @@ -0,0 +1,771 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C17: conv2d builders for NHWC feature maps (PLAN.md section 1.2 row C17; probes P5 and P14, common/PROBES.md). + +A conv layer of a port is built once from host weights and called on device feature maps, eagerly for the warm-up +and then inside trace captures. What every builder here guarantees: + +- **Weights prepared once.** The first call hands the host weights to ``ttnn.conv2d`` (which prepares them for the + chosen sharding and moves them to the device, a host write); the prepared device tensors are kept per input spec + and reused, so a traced call never writes from the host ("Writes are not supported during trace capture", + RP section 1.4). Call each layer once eagerly before capturing (``TraceRunner`` warm-up does). +- **Explicit precision.** ``compute_config`` comes from :mod:`..precision` (default :data:`CONV_PRECISION`: HiFi4 + + fp32 accumulation + packer L1 accumulation, exact SFPU activations; never ttnn's implicit defaults; without the L1 + accumulation the partial sums of wide convs round to bf16 between inner blocks). Weights default to bf16 + (``Precision.weights`` selects bfp8); ``output_dtype`` may be float32 (e.g. heads whose logits feed thresholds). +- **Fused bias + activation** in the conv epilogue: ``relu`` (packer), ``relu6`` / ``silu`` / ``gelu`` / + ``sigmoid`` (SFPU). RELU6 is verified on the device (P5: output in [0, 6], PCC >= 0.99997). HARDSWISH is refused: + the fused form is silently skipped on this tree (P5; apply ``ttnn.hardswish`` as a separate op). +- **Grid from the device.** Nothing here hard-codes 12x10 / 11x10: ttnn's auto-sharding reads + ``device.compute_with_storage_grid_size()``; explicit shard overrides go through ``conv_config``. +- **Memory placement.** ``slicing="auto"`` keeps ttnn's rule: a DRAM input runs the DRAM-sliced path with automatic + slice counts (P14: every accepted config captures and replays bit-exactly; explicit counts are often rejected, so + never hard-code them) and writes a DRAM-interleaved output; an L1 input runs the L1 path. 1x1 stride-1 convs are + matmuls and always run the L1 path; ``output_memory_config`` (default ``"dram"``) brings their output back to DRAM + so a graph can keep every activation in DRAM between ops (the robust layout of a first port; L1 residency is an + optimization). Feed large-kernel convs TILE tensors (``[1, 1, N*H*W, C]``, what a producing conv writes): from + row-major DRAM they cost 2x (P5: 926 vs 482 us for YOLOX's 12 large-kernel convs). + +Feature maps travel as :class:`FeatureMap` (device tensor + logical N, H, W, C), because ttnn's flattened +``[1, 1, N*H*W, C]`` layout no longer carries the spatial size. + +Builders: :class:`Conv2d` (any kernel / stride / padding / dilation / groups), :class:`KSplitConv` +(``conv(concat(x_1..x_n))`` as ``sum_i conv_i(x_i)`` without building the concat; P14: 1.7-4.1x faster and more +accurate for CenterPoint / PointPainting / FRNet's wide concats), :class:`ConvTranspose2d` for kernel == stride +(P5: k = 2 -> ``ttnn.conv_transpose2d``; k >= 3 -> ``ttnn.linear`` + depth-to-space, 5x faster at k = 4), and the +host helpers :func:`merge_sibling_convs` (one conv for sibling heads that read the same input) / +:func:`split_k_weights`. Glue: :func:`concat_channels`, :func:`split_channels`, :func:`residual_add`, +:func:`apply_activation`, :func:`fmap_from_numpy` / :func:`fmap_to_numpy` (NCHW <-> device NHWC). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + +import numpy as np + +from ..precision import Precision +from ..tensors import dtype_name, round_to_bf16, ttnn_dtype + +__all__ = [ + "CONV_PRECISION", + "FUSED_ACTIVATIONS", + "BINARY_FUSED_ACTIVATIONS", + "FeatureMap", + "Conv2d", + "KSplitConv", + "ConvTranspose2d", + "normalize_activation", + "unary_with_param", + "apply_activation", + "conv_padding", + "conv_out_hw", + "merge_sibling_convs", + "split_k_weights", + "concat_channels", + "split_channels", + "residual_add", + "fmap_from_numpy", + "fmap_to_numpy", + "is_dram", +] + +# Default precision of every builder here: HiFi4 + fp32 accumulation + packer L1 accumulation. Without +# ``packer_l1_acc`` a conv whose inner dim spans several blocks keeps its partial sums between blocks in the OUTPUT +# dtype (bf16) (conv2d_op_program_factory_common.cpp:175-178: partials CB = output dtype unless packer_l1_acc), which +# costs accuracy on wide convs; with it the partials stay fp32 (C17 device test, logs/ttaw/c17_c18_device_results.json). +CONV_PRECISION = "HiFi4+fp32+l1acc" + +# Activations a conv epilogue applies correctly on this tree (P5 / P14 on the device). HARDSWISH is NOT among them: +# a fused HARDSWISH compiles to nothing (unary_op_utils.cpp:964-965, probe P5) and returns the un-activated result. +FUSED_ACTIVATIONS = ("relu", "relu6", "silu", "gelu", "sigmoid") +# Activations fused into ``ttnn.add(..., activations=[...])`` verified on the device (P5: RELU6 on the clip6 add). +BINARY_FUSED_ACTIVATIONS = ("relu", "relu6") +_REFUSED = { + "hardswish": "a fused HARDSWISH is silently skipped by ttnn on this tree (probe P5): use activation=None and " + "ttnn.hardswish / ttnn.unary_chain([HARDSWISH]) as a separate op", +} + +ActivationSpec = Optional[str] + + +def normalize_activation(activation: Any) -> ActivationSpec: + """``None`` / ``"none"`` / ``"linear"`` / ``"identity"`` -> None; a fused activation name -> its lower-case name. + Raises ``ValueError`` for HARDSWISH (silently skipped when fused, P5) and for unknown names.""" + if activation is None: + return None + name = str(activation).strip().lower() + if name in ("", "none", "linear", "identity"): + return None + if name in _REFUSED: + raise ValueError(f"activation {activation!r}: {_REFUSED[name]}") + if name not in FUSED_ACTIVATIONS: + raise ValueError(f"activation {activation!r}: expected None or one of {FUSED_ACTIVATIONS}") + return name + + +def unary_with_param(activation: Any): + """The ``ttnn.UnaryWithParam`` of a fused activation (None for no activation). GELU is the exact (erf) form.""" + import ttnn + + name = normalize_activation(activation) + if name is None: + return None + if name == "gelu": + return ttnn.UnaryWithParam(ttnn.UnaryOpType.GELU, 0.0) + return ttnn.UnaryWithParam(getattr(ttnn.UnaryOpType, name.upper())) + + +def apply_activation(tensor: Any, activation: Any): + """The activation as a separate (unfused) ttnn op; identity for None.""" + import ttnn + + name = normalize_activation(activation) + if name is None: + return tensor + if name == "relu6": + return ttnn.relu6(tensor) + if name == "gelu": + return ttnn.gelu(tensor, fast_and_approximate_mode=False) + return getattr(ttnn, name)(tensor) + + +def _pair(value: Union[int, Sequence[int]], what: str) -> Tuple[int, int]: + if isinstance(value, (int, np.integer)): + return int(value), int(value) + v = tuple(int(x) for x in value) + if len(v) != 2: + raise ValueError(f"{what} must be an int or a pair, got {value!r}") + return v + + +def conv_padding(kernel: Tuple[int, int], padding: Any = "same", dilation: Tuple[int, int] = (1, 1) + ) -> Tuple[int, int, int, int]: + """Padding as (top, bottom, left, right). ``"same"``: ``dilation * (k // 2)`` on every side (odd kernels; the + "same" padding of stride-1 convs in every model here). An int, a pair (h, w) or a 4-tuple pass through.""" + if isinstance(padding, str): + if padding != "same": + raise ValueError(f"padding {padding!r}: expected 'same', an int, a pair or (top, bottom, left, right)") + if kernel[0] % 2 == 0 or kernel[1] % 2 == 0: + raise ValueError(f"'same' padding needs odd kernels, got {kernel}") + ph, pw = dilation[0] * (kernel[0] // 2), dilation[1] * (kernel[1] // 2) + return ph, ph, pw, pw + if isinstance(padding, (int, np.integer)): + p = int(padding) + return p, p, p, p + p = tuple(int(x) for x in padding) + if len(p) == 2: + return p[0], p[0], p[1], p[1] + if len(p) == 4: + return p # type: ignore[return-value] + raise ValueError(f"padding {padding!r}: expected 'same', an int, a pair or (top, bottom, left, right)") + + +def conv_out_hw(hw: Tuple[int, int], kernel: Tuple[int, int], stride: Tuple[int, int], + padding: Tuple[int, int, int, int], dilation: Tuple[int, int] = (1, 1)) -> Tuple[int, int]: + """Output size of a convolution (floor), as torch / ttnn compute it.""" + h = (hw[0] + padding[0] + padding[1] - dilation[0] * (kernel[0] - 1) - 1) // stride[0] + 1 + w = (hw[1] + padding[2] + padding[3] - dilation[1] * (kernel[1] - 1) - 1) // stride[1] + 1 + if h <= 0 or w <= 0: + raise ValueError(f"conv of a {hw} input with kernel {kernel}, stride {stride}, padding {padding} is empty") + return h, w + + +def is_dram(tensor_or_config: Any) -> bool: + """True when a device tensor (or a memory config) lives in DRAM.""" + import ttnn + + mc = tensor_or_config.memory_config() if hasattr(tensor_or_config, "memory_config") else tensor_or_config + bt = getattr(mc, "buffer_type", None) + if bt is not None and hasattr(ttnn, "BufferType"): + return bt == ttnn.BufferType.DRAM + return mc == ttnn.DRAM_MEMORY_CONFIG + + +def _memory_config(spec: Any): + """``"dram"`` / ``"l1"`` / None / a ttnn.MemoryConfig -> a memory config (or None).""" + import ttnn + + if spec is None: + return None + if isinstance(spec, str): + key = spec.lower() + if key == "dram": + return ttnn.DRAM_MEMORY_CONFIG + if key == "l1": + return ttnn.L1_MEMORY_CONFIG + raise ValueError(f"memory config {spec!r}: expected 'dram', 'l1', None or a ttnn.MemoryConfig") + return spec + + +def _layout(spec: Any): + import ttnn + + if isinstance(spec, str): + key = spec.lower().replace("_layout", "") + if key == "tile": + return ttnn.TILE_LAYOUT + if key in ("row_major", "rm"): + return ttnn.ROW_MAJOR_LAYOUT + raise ValueError(f"layout {spec!r}: expected 'tile' or 'row_major'") + return spec + + +def _layout_name(spec: Any) -> str: + """``"tile"`` / ``"row_major"`` of a layout spec (string or ttnn layout), without importing ttnn for strings.""" + if isinstance(spec, str): + key = spec.lower().replace("_layout", "") + return "row_major" if key in ("row_major", "rm") else key + return "tile" if "TILE" in str(spec).upper() else "row_major" + + +def _as_float32(a: Any, what: str) -> np.ndarray: + if hasattr(a, "detach"): + a = a.detach().cpu().float().numpy() + arr = np.asarray(a, dtype=np.float32) + if not np.all(np.isfinite(arr)): + raise ValueError(f"{what} holds non-finite values") + return np.ascontiguousarray(arr) + + +def _host_tensor(a: np.ndarray, dtype: str = "bfloat16"): + """A ROW_MAJOR ttnn *host* tensor (no device involved: only TILE host tensors open the device, probe G4-X). + The data are copied (ONNX initializers are read-only arrays, which torch warns about).""" + import torch + import ttnn + + return ttnn.from_torch(torch.from_numpy(np.array(a, dtype=np.float32, copy=True)), dtype=ttnn_dtype(dtype), + layout=ttnn.ROW_MAJOR_LAYOUT) + + +# --------------------------------------------------------------------------------------------- feature maps + +@dataclass(frozen=True) +class FeatureMap: + """A device feature map: ``tensor`` holds N*H*W rows of C channels (ttnn's flattened ``[1, 1, N*H*W, C]``, + TILE or ROW_MAJOR; a ``[N, H, W, C]`` ROW_MAJOR tensor is accepted too), plus the logical sizes ttnn's layout + does not keep.""" + + tensor: Any + batch: int + height: int + width: int + channels: int + + @property + def rows(self) -> int: + return self.batch * self.height * self.width + + @property + def hw(self) -> Tuple[int, int]: + return self.height, self.width + + def with_tensor(self, tensor: Any, channels: Optional[int] = None) -> "FeatureMap": + """The same spatial map with another tensor (and channel count).""" + return FeatureMap(tensor, self.batch, self.height, self.width, + self.channels if channels is None else int(channels)) + + def deallocate(self) -> None: + """Free the tensor's device memory (``force=False``: a view's base is left to its owner, probe P2).""" + import ttnn + + if self.tensor is not None and self.tensor.is_allocated(): + ttnn.deallocate(self.tensor, False) + + +def fmap_from_numpy(nchw: Any, device: Any, *, dtype: Any = "bfloat16", layout: Any = "tile", + memory_config: Any = "dram") -> FeatureMap: + """Upload an NCHW array as a device feature map ``[1, 1, N*H*W, C]`` (TILE by default). A TILE host tensor + touches the device: run under ``bin/devrun`` (probe G4-X).""" + import torch + import ttnn + + a = _as_float32(nchw, "feature map") if np.asarray(nchw).dtype != np.uint8 else np.asarray(nchw) + if a.ndim != 4: + raise ValueError(f"expected NCHW, got shape {a.shape}") + n, c, h, w = a.shape + flat = np.ascontiguousarray(a.transpose(0, 2, 3, 1).reshape(1, 1, n * h * w, c)) + t = ttnn.from_torch(torch.from_numpy(flat), dtype=ttnn_dtype(dtype), layout=_layout(layout), device=device, + memory_config=_memory_config(memory_config)) + return FeatureMap(t, n, h, w, c) + + +def fmap_to_numpy(fm: FeatureMap, dtype: Any = np.float32) -> np.ndarray: + """Read a device feature map back as an NCHW numpy array (blocking; never inside a capture).""" + import ttnn + + a = ttnn.to_torch(fm.tensor) + a = a.float().numpy() if a.is_floating_point() else a.numpy() + a = a.reshape(-1, a.shape[-1])[: fm.rows, : fm.channels] + return np.ascontiguousarray(a.reshape(fm.batch, fm.height, fm.width, fm.channels).transpose(0, 3, 1, 2), + dtype=dtype) + + +# ------------------------------------------------------------------------------------------------- Conv2d + +class Conv2d: + """``ttnn.conv2d`` with weights prepared by the first call and reused afterwards (see the module docstring). + + Args: + weight: float array ``[Cout, Cin / groups, kh, kw]`` (OIHW, numpy or torch; BN already folded). + bias: float array ``[Cout]`` or None. + stride, dilation: int or pair. ``padding``: ``"same"`` (default), int, pair or (top, bottom, left, right). + groups: 1 for dense convs; ``groups == Cin`` is a depthwise conv (correct but expanded to dense weights by + ttnn: P5, 311 MiB for SceneSeg). + activation: None or one of :data:`FUSED_ACTIVATIONS` (fused in the epilogue). + precision: a :class:`..precision.Precision` or its spec (default :data:`CONV_PRECISION` = HiFi4 + fp32 + accumulation + packer L1 accumulation; ``Precision.weights`` is the device weight dtype: bf16 or bfp8). + output_dtype: dtype of the output (``"bfloat16"``; ``"float32"`` for logits that feed thresholds). + output_layout: ``"tile"`` (default) or ``"row_major"``. + slicing: ``"auto"`` (ttnn's rule: DRAM input -> automatic DRAM slicing, L1 input -> L1 path), ``"l1"`` + (``Conv2dL1FullSliceConfig``: the op moves the whole input to L1) or ``("height" | "width", n)`` + (explicit DRAM slices; P14 found most explicit counts rejected or slower: diagnostics only). + output_memory_config: where an L1-path output (1x1 matmul convs, L1 inputs) is placed: ``"dram"`` + (default), ``"l1"``, None (the op's sharded output) or a ttnn.MemoryConfig. DRAM-sliced convs always + write DRAM interleaved; ``"l1"`` then adds a move. + conv_config: extra ``ttnn.Conv2dConfig`` fields (``act_block_h_override``, ``shard_layout``, + ``enable_act_double_buffer``, ``deallocate_activation``, ...): optimization knobs. + weight_terms: 1 (default) or 2. With 2 the weight and the bias are split into two bf16 terms + (``hi = bf16(w)``, ``lo = bf16(w - hi)``: about 16 significant bits) run as two convs into float32 + outputs, summed in fp32 with the activation fused into the add, then cast to ``output_dtype``: the conv + then carries ~fp32 weights and bias for twice the MACs and 3 more programs. The device's own float32 + weight path is no substitute (1.5x less error than bf16, vs 3.5x for the split; YOLOX port, + logs/yolox/m3_weight_precision_probe.log). Use it where the bf16 rounding of weights and biases, which + does not average out over a deep network (a per-channel bias error is the same at every pixel), + decides an output: YOLOX's 16-class mask on public frames (97.5 % -> 99.3 % agreement in emulation). + name: label for messages and :meth:`describe`. + """ + + def __init__(self, weight: Any, bias: Any = None, *, stride: Any = 1, padding: Any = "same", + dilation: Any = 1, groups: int = 1, activation: Any = None, precision: Any = CONV_PRECISION, + output_dtype: Any = "bfloat16", output_layout: Any = "tile", slicing: Any = "auto", + output_memory_config: Any = "dram", conv_config: Optional[Dict[str, Any]] = None, + weight_terms: int = 1, name: str = "conv"): + w = _as_float32(weight, f"{name}: weight") + if w.ndim != 4: + raise ValueError(f"{name}: weight must be [Cout, Cin/groups, kh, kw], got {w.shape}") + self.name = name + self.groups = int(groups) + self.out_channels, cin_g, kh, kw = (int(s) for s in w.shape) + self.in_channels = cin_g * self.groups + if self.groups < 1 or self.out_channels % self.groups: + raise ValueError(f"{name}: groups={groups} does not divide Cout={self.out_channels}") + self.kernel = (kh, kw) + self.stride = _pair(stride, "stride") + self.dilation = _pair(dilation, "dilation") + self.padding = conv_padding(self.kernel, padding, self.dilation) + self.activation = normalize_activation(activation) + self.precision = Precision.parse(precision) + self.output_dtype = dtype_name(output_dtype) + self.output_layout = output_layout + if not (slicing == "auto" or slicing == "l1" or (isinstance(slicing, (tuple, list)) and len(slicing) == 2 + and slicing[0] in ("height", "width"))): + raise ValueError(f"{name}: slicing {slicing!r}: expected 'auto', 'l1' or ('height' | 'width', n)") + self.slicing = slicing if isinstance(slicing, str) else (str(slicing[0]), int(slicing[1])) + self.output_memory_config = output_memory_config + self.conv_config_overrides = dict(conv_config or {}) + if "activation" in self.conv_config_overrides or "weights_dtype" in self.conv_config_overrides: + raise ValueError(f"{name}: pass activation / weights dtype as arguments, not in conv_config") + b = np.zeros(self.out_channels, np.float32) if bias is None else _as_float32(bias, f"{name}: bias") + if b.shape != (self.out_channels,): + raise ValueError(f"{name}: bias must have shape ({self.out_channels},), got {b.shape}") + if int(weight_terms) not in (1, 2): + raise ValueError(f"{name}: weight_terms must be 1 or 2, got {weight_terms!r}") + self.weight_terms = int(weight_terms) + if self.weight_terms == 2 and str(_layout_name(output_layout)) != "tile": + raise ValueError(f"{name}: weight_terms=2 writes TILE outputs (the fp32 sum is cast in TILE)") + self._weight = w + self._bias = b + self._host: Optional[List[Tuple[Any, Any]]] = None + self._prepared: Dict[Tuple, Tuple[Any, Any]] = {} + + # ---- geometry + @property + def is_matmul(self) -> bool: + """1x1, stride 1, no padding, no dilation, dense: ttnn runs it as a matmul on the L1 path.""" + return (self.kernel == (1, 1) and self.stride == (1, 1) and self.padding == (0, 0, 0, 0) + and self.dilation == (1, 1) and self.groups == 1) + + def out_hw(self, height: int, width: int) -> Tuple[int, int]: + return conv_out_hw((height, width), self.kernel, self.stride, self.padding, self.dilation) + + @property + def weight(self) -> np.ndarray: + """The float32 host weight (OIHW).""" + return self._weight + + @property + def bias(self) -> np.ndarray: + return self._bias + + # ---- ttnn arguments + def host_terms(self) -> List[Tuple[np.ndarray, np.ndarray]]: + """The float32 (weight, bias) of each term: one term, or ``hi = bf16(x)`` and ``lo = bf16(x - hi)`` (both + exactly representable in bf16, so the bf16 upload is exact).""" + if self.weight_terms == 1: + return [(self._weight, self._bias)] + hi_w, hi_b = round_to_bf16(self._weight), round_to_bf16(self._bias) + return [(hi_w, hi_b), (round_to_bf16(self._weight - hi_w), round_to_bf16(self._bias - hi_b))] + + def _host_tensors(self, term: int = 0) -> Tuple[Any, Any]: + if self._host is None: + # host tensors in the device weight dtype's host precision: bf16 for bf16 / bfp8 weights (ttnn rounds + # to bfp8 itself), float32 for float32 weights (a bf16 host tensor would round them first) + host_dtype = "float32" if self.precision.weights == "float32" else "bfloat16" + self._host = [(_host_tensor(w, host_dtype), _host_tensor(b.reshape(1, 1, 1, -1), host_dtype)) + for w, b in self.host_terms()] + return self._host[term] + + def _conv_config(self, fused: bool = True): + import ttnn + + kw = dict(self.conv_config_overrides) + kw["weights_dtype"] = ttnn_dtype(self.precision.weights) + kw["activation"] = unary_with_param(self.activation) if fused else None + kw.setdefault("output_layout", _layout(self.output_layout)) + return ttnn.Conv2dConfig(**kw) + + def _slice_config(self): + import ttnn + + if self.slicing == "auto": + return None + if self.slicing == "l1": + return ttnn.Conv2dL1FullSliceConfig + kind, num = self.slicing + slice_type = ttnn.Conv2dDRAMSliceHeight if kind == "height" else ttnn.Conv2dDRAMSliceWidth + return ttnn.Conv2dSliceConfig(slice_type=slice_type, num_slices=num) + + def _spec_key(self, fm: FeatureMap) -> Tuple: + t = fm.tensor + return (fm.batch, fm.height, fm.width, str(t.dtype), str(t.layout), str(t.memory_config()), + tuple(int(s) for s in t.shape)) + + def takes_l1_path(self, x: Any) -> bool: + """Whether a call on ``x`` (a device tensor) runs ttnn's L1 path (else the DRAM-sliced path).""" + if self.slicing == "l1" or self.is_matmul: + return True + return self.slicing == "auto" and not is_dram(x) + + def __call__(self, x: Union[FeatureMap, Any], *, batch: Optional[int] = None, height: Optional[int] = None, + width: Optional[int] = None) -> FeatureMap: + """Run the conv on a :class:`FeatureMap` (or a raw device tensor plus ``batch`` / ``height`` / ``width``) + and return the output :class:`FeatureMap` (``[1, 1, N*OH*OW, Cout]``).""" + import ttnn + + fm = x if isinstance(x, FeatureMap) else FeatureMap(x, int(batch), int(height), int(width), self.in_channels) + if fm.channels != self.in_channels: + raise ValueError(f"{self.name}: input has {fm.channels} channels, the conv takes {self.in_channels}") + key = self._spec_key(fm) + l1_path = self.takes_l1_path(fm.tensor) + out_mc = _memory_config(self.output_memory_config) + split = self.weight_terms == 2 + outs = [] + for term in range(self.weight_terms): + tkey = key + (term,) + weight, bias = self._prepared.get(tkey) or self._host_tensors(term) + out, (oh, ow), (weight, bias) = ttnn.conv2d( + input_tensor=fm.tensor, weight_tensor=weight, device=fm.tensor.device(), + in_channels=self.in_channels, out_channels=self.out_channels, batch_size=fm.batch, + input_height=fm.height, input_width=fm.width, kernel_size=self.kernel, stride=self.stride, + padding=self.padding, dilation=self.dilation, groups=self.groups, bias_tensor=bias, + conv_config=self._conv_config(fused=not split), compute_config=self.precision.compute_kernel_config(), + slice_config=self._slice_config(), memory_config=out_mc if l1_path else None, + dtype=ttnn.float32 if split else ttnn_dtype(self.output_dtype), return_output_dim=True, + return_weights_and_bias=True) + self._prepared[tkey] = (weight, bias) + outs.append(out) + if (int(oh), int(ow)) != self.out_hw(fm.height, fm.width): + raise RuntimeError(f"{self.name}: ttnn returned a {oh}x{ow} output, expected " + f"{self.out_hw(fm.height, fm.width)}") + out = outs[0] + if split: + fuse = self.activation in BINARY_FUSED_ACTIVATIONS + out = ttnn.add(outs[0], outs[1], activations=[unary_with_param(self.activation)] if fuse else []) + for t in outs: + ttnn.deallocate(t, False) + if not fuse: + out = apply_activation(out, self.activation) + if self.output_dtype != "float32": + out = ttnn.typecast(out, ttnn_dtype(self.output_dtype)) + if not l1_path and out_mc is not None and not is_dram(out_mc): + out = ttnn.to_memory_config(out, out_mc) + return FeatureMap(out, fm.batch, int(oh), int(ow), self.out_channels) + + @property + def prepared_specs(self) -> int: + """Number of input specs this layer holds prepared device weights for.""" + return len({k[:-1] for k in self._prepared}) + + def release(self) -> None: + """Free the prepared device weights (the host weights stay; the next call prepares them again).""" + import ttnn + + for w, b in self._prepared.values(): + for t in (w, b): + if t is not None and t.is_allocated(): + ttnn.deallocate(t) + self._prepared.clear() + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "cin": self.in_channels, "cout": self.out_channels, "kernel": list(self.kernel), + "stride": list(self.stride), "padding": list(self.padding), "dilation": list(self.dilation), + "groups": self.groups, "activation": self.activation, "precision": self.precision.label, + "output_dtype": self.output_dtype, "slicing": self.slicing if isinstance(self.slicing, str) + else list(self.slicing), "matmul": self.is_matmul, "weight_terms": self.weight_terms} + + +# ----------------------------------------------------------------------------------- K-split (concat-free) + +def split_k_weights(weight: Any, parts: Sequence[int]) -> List[np.ndarray]: + """Split a conv weight ``[Cout, sum(parts), kh, kw]`` along its input channels: ``conv(concat(x_i), W) == + sum_i conv(x_i, W_i)``.""" + w = _as_float32(weight, "weight") + if w.ndim != 4 or w.shape[1] != sum(int(p) for p in parts): + raise ValueError(f"weight {w.shape} does not have sum(parts) = {sum(parts)} input channels") + out, start = [], 0 + for p in parts: + out.append(np.ascontiguousarray(w[:, start:start + int(p)])) + start += int(p) + return out + + +class KSplitConv: + """``conv(concat([x_1, ..., x_n], channels))`` computed as ``sum_i conv_i(x_i)`` without building the concat + (S:centerpoint:448; P14: CenterPoint's shared conv 3.6 vs 14.8 ms and PCC 0.999992 vs 0.999961). + + The bias rides on the first partial conv, the partial sums are added in order, and the activation is fused into + the last add (RELU / RELU6, P5) or applied after it. ``accumulate_dtype="float32"`` keeps the partial outputs and + their sums in fp32 and casts to ``output_dtype`` once (more accurate, more traffic); the default ``"bfloat16"`` + rounds each partial (what P14 measured). Every keyword except ``activation`` / ``output_dtype`` / + ``accumulate_dtype`` goes to the partial :class:`Conv2d` layers.""" + + def __init__(self, weight: Any, bias: Any, parts: Sequence[int], *, activation: Any = None, + output_dtype: Any = "bfloat16", accumulate_dtype: Any = "bfloat16", name: str = "ksplit", + **conv_kwargs: Any): + if len(parts) < 2: + raise ValueError(f"{name}: a K-split needs at least two parts") + self.name = name + self.parts = tuple(int(p) for p in parts) + self.activation = normalize_activation(activation) + self.output_dtype = dtype_name(output_dtype) + self.accumulate_dtype = dtype_name(accumulate_dtype) + weights = split_k_weights(weight, self.parts) + cout = weights[0].shape[0] + b = np.zeros(cout, np.float32) if bias is None else _as_float32(bias, f"{name}: bias") + self.convs = [Conv2d(w, b if i == 0 else None, activation=None, output_dtype=self.accumulate_dtype, + name=f"{name}.{i}", **conv_kwargs) for i, w in enumerate(weights)] + self.out_channels = cout + + def __call__(self, inputs: Sequence[FeatureMap]) -> FeatureMap: + import ttnn + + if len(inputs) != len(self.convs): + raise ValueError(f"{self.name}: expected {len(self.convs)} inputs, got {len(inputs)}") + total: Optional[FeatureMap] = None + n = len(inputs) + for i, (conv, x) in enumerate(zip(self.convs, inputs)): + part = conv(x) + if total is None: + total = part + continue + last = i == n - 1 + fuse = last and self.activation in BINARY_FUSED_ACTIVATIONS + acts = [unary_with_param(self.activation)] if fuse else [] + summed = ttnn.add(total.tensor, part.tensor, activations=acts) + for t in (total, part): + t.deallocate() + total = total.with_tensor(summed) + if last and not fuse: + total = total.with_tensor(apply_activation(total.tensor, self.activation)) + assert total is not None + if self.accumulate_dtype != self.output_dtype: + total = total.with_tensor(ttnn.typecast(total.tensor, ttnn_dtype(self.output_dtype))) + return total + + def release(self) -> None: + for c in self.convs: + c.release() + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "parts": list(self.parts), "activation": self.activation, + "accumulate_dtype": self.accumulate_dtype, "convs": [c.describe() for c in self.convs]} + + +# ----------------------------------------------------------------------------------- sibling merge / glue + +def merge_sibling_convs(weights: Sequence[Any], biases: Sequence[Any]) -> Tuple[np.ndarray, np.ndarray, List[int]]: + """One conv for sibling convs reading the same input (S:centerpoint:450; S:yolox section 8.5 #1): output + channels concatenated in order -> ``(weight, bias, out_channels_per_sibling)``; split the output with + :func:`split_channels`. Exact (each output channel keeps its own weights).""" + ws = [_as_float32(w, "weight") for w in weights] + bs = [np.zeros(w.shape[0], np.float32) if b is None else _as_float32(b, "bias") for w, b in zip(ws, biases)] + shapes = {w.shape[1:] for w in ws} + if len(shapes) != 1: + raise ValueError(f"sibling convs must share Cin and kernel, got {[w.shape for w in ws]}") + return (np.ascontiguousarray(np.concatenate(ws, axis=0)), np.ascontiguousarray(np.concatenate(bs)), + [int(w.shape[0]) for w in ws]) + + +def concat_channels(maps: Sequence[FeatureMap], *, memory_config: Any = "dram") -> FeatureMap: + """Concatenate feature maps of one spatial size along channels (``ttnn.concat(dim=3)``); every input must have + the same layout and dtype. TILE inputs with channel counts that are multiples of 32 avoid any re-tiling.""" + import ttnn + + if not maps: + raise ValueError("concat_channels needs at least one map") + first = maps[0] + for m in maps[1:]: + if (m.batch, m.height, m.width) != (first.batch, first.height, first.width): + raise ValueError(f"concat of maps of different sizes: {[(m.batch, m.height, m.width) for m in maps]}") + if len(maps) == 1: + return first + t = ttnn.concat([m.tensor for m in maps], dim=3, memory_config=_memory_config(memory_config)) + return first.with_tensor(t, sum(m.channels for m in maps)) + + +def split_channels(fm: FeatureMap, sizes: Sequence[int]) -> List[FeatureMap]: + """Slice a map's channels into consecutive groups of ``sizes`` (``ttnn.slice`` on the last dim).""" + import ttnn + + if sum(int(s) for s in sizes) != fm.channels: + raise ValueError(f"split sizes {list(sizes)} do not add up to {fm.channels} channels") + shape = [int(s) for s in fm.tensor.shape] + out, start = [], 0 + for s in sizes: + begin = [0] * len(shape) + end = list(shape) + begin[-1], end[-1] = start, start + int(s) + out.append(fm.with_tensor(ttnn.slice(fm.tensor, begin, end), int(s))) + start += int(s) + return out + + +def residual_add(a: FeatureMap, b: FeatureMap, activation: Any = None) -> FeatureMap: + """``a + b`` (same spec), with RELU / RELU6 fused into the add (P5: the clip6 residual of YOLOX) or another + activation applied after it.""" + import ttnn + + if (a.rows, a.channels) != (b.rows, b.channels): + raise ValueError(f"residual add of {a.rows}x{a.channels} and {b.rows}x{b.channels}") + act = normalize_activation(activation) + if act in BINARY_FUSED_ACTIVATIONS: + return a.with_tensor(ttnn.add(a.tensor, b.tensor, activations=[unary_with_param(act)])) + return a.with_tensor(apply_activation(ttnn.add(a.tensor, b.tensor), act)) + + +# ----------------------------------------------------------------------------- ConvTranspose (kernel == stride) + +class ConvTranspose2d: + """ConvTranspose2d with kernel == stride and no padding (the BEV deblocks of CenterPoint / PointPainting / + TransFusion and SceneSeg's up-sampling layers), weights prepared by the first call. + + ``method="auto"`` follows probe P5: k = 2 -> ``ttnn.conv_transpose2d`` (CP deblock1 0.64 ms vs 2.08 ms for the + rewrite), k >= 3 -> ``"linear_d2s"`` = ``ttnn.linear(Cin -> k*k*Cout)`` with the bias and activation fused, then + depth-to-space (``to_layout(ROW_MAJOR)``, ``reshape [N*h, w, k, k, C]``, ``permute (0, 2, 1, 3, 4)``, ``reshape``; + CP deblock2 k = 4: 1.97 vs 10.27 ms). The linear output channel order is (i, j, c): + ``W2[cin, (i*k + j)*Cout + c] = W[cin, c, i, j]``. Output: a ROW_MAJOR ``[1, 1, N*h*k*w*k, Cout]`` map for + ``linear_d2s`` (``output_layout="tile"`` re-tiles it), the conv's TILE output for ``conv_transpose2d``. + + ``weight``: ``[Cin, Cout, k, k]`` (torch ConvTranspose2d layout); ``bias``: ``[Cout]`` or None.""" + + METHODS = ("auto", "conv_transpose2d", "linear_d2s") + + def __init__(self, weight: Any, bias: Any = None, *, stride: Optional[int] = None, activation: Any = None, + precision: Any = CONV_PRECISION, output_dtype: Any = "bfloat16", method: str = "auto", + output_layout: Any = "tile", memory_config: Any = "dram", name: str = "deconv"): + w = _as_float32(weight, f"{name}: weight") + if w.ndim != 4 or w.shape[2] != w.shape[3]: + raise ValueError(f"{name}: weight must be [Cin, Cout, k, k], got {w.shape}") + self.name = name + self.in_channels, self.out_channels, self.k = int(w.shape[0]), int(w.shape[1]), int(w.shape[2]) + if stride is not None and int(stride) != self.k: + raise ValueError(f"{name}: only kernel == stride is supported (k={self.k}, stride={stride})") + if method not in self.METHODS: + raise ValueError(f"{name}: method {method!r}: expected one of {self.METHODS}") + self.method = ("conv_transpose2d" if self.k == 2 else "linear_d2s") if method == "auto" else method + self.activation = normalize_activation(activation) + self.precision = Precision.parse(precision) + self.output_dtype = dtype_name(output_dtype) + self.output_layout = output_layout + self.memory_config = memory_config + b = np.zeros(self.out_channels, np.float32) if bias is None else _as_float32(bias, f"{name}: bias") + if b.shape != (self.out_channels,): + raise ValueError(f"{name}: bias must have shape ({self.out_channels},), got {b.shape}") + self._weight, self._bias = w, b + self._prepared: Dict[Tuple, Tuple[Any, Any]] = {} + self._linear: Optional[Tuple[Any, Any]] = None + + def _spec_key(self, fm: FeatureMap) -> Tuple: + t = fm.tensor + return (fm.batch, fm.height, fm.width, str(t.dtype), str(t.layout), str(t.memory_config())) + + def linear_weights(self) -> Tuple[np.ndarray, np.ndarray]: + """Host form of the ``linear_d2s`` weights: ``W2 [Cin, k*k*Cout]`` in (i, j, c) order, bias repeated.""" + k = self.k + w2 = self._weight.transpose(0, 2, 3, 1).reshape(self.in_channels, k * k * self.out_channels) + return np.ascontiguousarray(w2), np.ascontiguousarray(np.tile(self._bias, k * k)) + + def __call__(self, x: FeatureMap) -> FeatureMap: + import ttnn + + if x.channels != self.in_channels: + raise ValueError(f"{self.name}: input has {x.channels} channels, the layer takes {self.in_channels}") + k = self.k + if self.method == "conv_transpose2d": + key = self._spec_key(x) + # float32 host tensors for float32 weights, as Conv2d (a bf16 host tensor would round them first) + host_dtype = "float32" if self.precision.weights == "float32" else "bfloat16" + weight, bias = self._prepared.get(key) or (_host_tensor(self._weight, host_dtype), + _host_tensor(self._bias.reshape(1, 1, 1, -1), host_dtype)) + conv_config = ttnn.Conv2dConfig(weights_dtype=ttnn_dtype(self.precision.weights), + activation=unary_with_param(self.activation), + output_layout=_layout(self.output_layout)) + out, (weight, bias) = ttnn.conv_transpose2d( + input_tensor=x.tensor, weight_tensor=weight, device=x.tensor.device(), + in_channels=self.in_channels, out_channels=self.out_channels, batch_size=x.batch, + input_height=x.height, input_width=x.width, kernel_size=(k, k), stride=(k, k), padding=(0, 0), + output_padding=(0, 0), dilation=(1, 1), groups=1, bias_tensor=bias, conv_config=conv_config, + compute_config=self.precision.compute_kernel_config(), dtype=ttnn_dtype(self.output_dtype), + mirror_kernel=True, return_weights_and_bias=True) + self._prepared[key] = (weight, bias) + return FeatureMap(out, x.batch, x.height * k, x.width * k, self.out_channels) + # linear + depth-to-space + import torch + + device = x.tensor.device() + if self._linear is None: + w2, b2 = self.linear_weights() + wdt = ttnn_dtype(self.precision.weights) + self._linear = ( + ttnn.from_torch(torch.from_numpy(w2), dtype=wdt, layout=ttnn.TILE_LAYOUT, device=device), + ttnn.from_torch(torch.from_numpy(b2.reshape(1, -1)), dtype=wdt, layout=ttnn.TILE_LAYOUT, + device=device)) + weight, bias = self._linear + t = x.tensor if x.tensor.layout == ttnn.TILE_LAYOUT else ttnn.to_layout(x.tensor, ttnn.TILE_LAYOUT) + y = ttnn.linear(t, weight, bias=bias, activation=unary_with_param(self.activation), + compute_kernel_config=self.precision.compute_kernel_config(), + memory_config=_memory_config(self.memory_config), dtype=ttnn_dtype(self.output_dtype)) + y_rm = ttnn.to_layout(y, ttnn.ROW_MAJOR_LAYOUT) + ttnn.deallocate(y, False) + c = self.out_channels + y5 = ttnn.reshape(y_rm, (x.batch * x.height, x.width, k, k, c)) + y5 = ttnn.permute(y5, (0, 2, 1, 3, 4)) + out = ttnn.reshape(y5, (1, 1, x.batch * x.height * k * x.width * k, c)) + if _layout(self.output_layout) == ttnn.TILE_LAYOUT: + out = ttnn.to_layout(out, ttnn.TILE_LAYOUT) + return FeatureMap(out, x.batch, x.height * k, x.width * k, c) + + def release(self) -> None: + import ttnn + + tensors = [t for pair in self._prepared.values() for t in pair] + list(self._linear or ()) + for t in tensors: + if t is not None and t.is_allocated(): + ttnn.deallocate(t) + self._prepared.clear() + self._linear = None + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "cin": self.in_channels, "cout": self.out_channels, "k": self.k, + "method": self.method, "activation": self.activation, "precision": self.precision.label, + "output_dtype": self.output_dtype} diff --git a/code/tt_frnet/ttaw/ops/deform.py b/code/tt_frnet/ttaw/ops/deform.py new file mode 100644 index 0000000000000000000000000000000000000000..ecd110e51e6c380f500c86e381b93364e94ae3e4 --- /dev/null +++ b/code/tt_frnet/ttaw/ops/deform.py @@ -0,0 +1,182 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C23: ``ttnn.grid_sample`` helpers (PLAN.md section 1.2 row C23; probe P9, common/PROBES.md; first built by the +BEVDet port for AlignBEV and FPN_LSS). + +Rules from probe P9 (``common/probes/g4-attention-gridsample-msda.md``), enforced or encoded here: + +- **fp32 grids** for every data-dependent grid (bf16 grids cost PCC 0.9988 at 53 px and 0.992 at 128 px); +- **input C % 32 == 0** (C = 80 is rejected; zero pad channels stay exactly 0): :func:`padded_channels`, + :func:`pad_channels`; +- **per-batch grids**: grid N must equal input N (no broadcast over the batch); +- **ROW_MAJOR, interleaved (or height-sharded)** input and grid, ``padding_mode="zeros"`` only; +- **never a FLOAT32 input together with ``fp32_dest_acc_en``**: wrong, non-deterministic output (strict xfail in P9); + :func:`grid_sample` refuses it; +- **exact integer coordinates**: the kernel recovers pixel coordinates as ``x = g * W/2 + (W - 1)/2`` in fp32 + (``grid_sample_reader_common.hpp``, align_corners=False), so the align_corners=False normalisation + ``g = (2x + 1) / W - 1`` returns integer x exactly whenever ``(2x + 1) / W`` is exact in fp32 (``W`` a power of + two): an identity warp is then a bit-exact copy (P9: the literal align_corners=True normalisation leaves 2.8-7.6 % + of values off by up to 1/32). Zero padding acts in pixel space, so the normalisation choice does not change what + is sampled: callers state pixel coordinates (:func:`affine_grid`, :func:`resize_grid`) and always call the op with + ``align_corners=False``; +- the four bilinear weights are computed in fp32 and **truncated** (not rounded) to bf16 by the kernel: a 0.33-0.6 % + rrmse floor for fractional coordinates (plan risk R3; K5 with fp32 weights removes it). :func:`emulate_grid_sample` + reproduces that arithmetic on the host (the device test's oracle). + +Host functions are numpy only (grids are built on the host, per frame or once, and uploaded as fp32 ROW_MAJOR +tensors); :func:`grid_sample` is the device call with the checks above. DCNv2 as one grid_sample with K = 9 +(``batch_output_channels=True``, BEVFormer / METEOR) and the fused MSDA wrapper are added by their ports. +""" +from __future__ import annotations + +from typing import Any, Optional, Sequence, Tuple + +import numpy as np + +__all__ = ["OUTSIDE", "padded_channels", "pad_channels", "pixel_to_grid", "kernel_pixels", "affine_pixel_coords", + "affine_grid", "resize_pixel_coords", "resize_grid", "emulate_grid_sample", "grid_sample"] + +F32 = np.float32 +# A normalised coordinate whose four taps fall outside any input (pixel -1.5 W - 0.5): the sample is exactly 0. +OUTSIDE = F32(-4.0) + + +def padded_channels(c: int, multiple: int = 32) -> int: + """The channel count ``grid_sample`` accepts for ``c`` real channels (the next multiple of 32).""" + return -(-int(c) // int(multiple)) * int(multiple) + + +def pad_channels(x: np.ndarray, multiple: int = 32) -> np.ndarray: + """Zero-pad the last (channel) dimension of an NHWC array to a multiple of ``multiple`` (no copy when aligned).""" + x = np.asarray(x) + c = x.shape[-1] + cp = padded_channels(c, multiple) + if cp == c: + return x + out = np.zeros(x.shape[:-1] + (cp,), x.dtype) + out[..., :c] = x + return out + + +def pixel_to_grid(px: Any, size: int) -> np.ndarray: + """Pixel coordinate (0 = centre of the first pixel) -> normalised coordinate for ``align_corners=False``: + ``(2 px + 1) / size - 1`` in float32 (exact for integer ``px`` when ``size`` is a power of two).""" + p = np.asarray(px, F32) + return (F32(2.0) * p + F32(1.0)) / F32(size) - F32(1.0) + + +def kernel_pixels(g: Any, size: int) -> np.ndarray: + """The pixel coordinate the device kernel recovers from a normalised coordinate (align_corners=False): + ``g * size/2 + (size - 1)/2`` in float32.""" + g = np.asarray(g, F32) + return g * F32(size / 2.0) + F32((size - 1) / 2.0) + + +def affine_pixel_coords(transforms: np.ndarray, out_hw: Tuple[int, int]) -> Tuple[np.ndarray, np.ndarray]: + """Per batch n and output pixel (h, w): ``ix = a w + b h + c``, ``iy = d w + e h + f`` in float32 with one + rounding per operation (``transforms`` [N, 6] = (a, b, c, d, e, f), the BEVDet AlignBEV / BEVFormer-style pixel + affine) -> (ix, iy) [N, H, W].""" + t = np.asarray(transforms, F32).reshape(-1, 6) + h, w = out_hw + hh, ww = np.meshgrid(np.arange(h, dtype=F32), np.arange(w, dtype=F32), indexing="ij") + a, b, c, d, e, f = (t[:, k, None, None] for k in range(6)) + ix = (a * ww + b * hh) + c + iy = (d * ww + e * hh) + f + return ix.astype(F32), iy.astype(F32) + + +def affine_grid(transforms: np.ndarray, in_hw: Tuple[int, int], out_hw: Optional[Tuple[int, int]] = None, *, + valid: Optional[Sequence[bool]] = None) -> np.ndarray: + """fp32 grid [N, H, W, 2] (x, y) sampling an ``in_hw`` input at :func:`affine_pixel_coords` (align_corners=False + normalisation: identity transforms are bit-exact copies for power-of-two input sizes). Batches with + ``valid[n] == False`` sample :data:`OUTSIDE` (exact zeros, e.g. a history slot that holds no frame yet).""" + out_hw = tuple(in_hw) if out_hw is None else tuple(out_hw) + ix, iy = affine_pixel_coords(transforms, out_hw) + grid = np.stack([pixel_to_grid(ix, in_hw[1]), pixel_to_grid(iy, in_hw[0])], axis=-1).astype(F32) + if valid is not None: + v = np.asarray(valid, bool).reshape(-1) + if v.size != grid.shape[0]: + raise ValueError(f"valid has {v.size} entries for {grid.shape[0]} batches") + grid[~v] = OUTSIDE + return np.ascontiguousarray(grid) + + +def resize_pixel_coords(in_size: int, out_size: int, align_corners: bool = True) -> np.ndarray: + """Source pixel of every output pixel of a 1-D bilinear resize (float64), torch's ``F.interpolate`` rules: + align_corners=True ``i (in - 1) / (out - 1)``; False ``(i + 0.5) in / out - 0.5`` (not clamped: the zero padding + of grid_sample differs from torch's edge clamp there, so prefer C18 ``Resize2d`` for align_corners=False).""" + i = np.arange(out_size, dtype=np.float64) + if align_corners: + return i * (in_size - 1) / max(out_size - 1, 1) + return (i + 0.5) * in_size / out_size - 0.5 + + +def resize_grid(in_hw: Tuple[int, int], out_hw: Tuple[int, int], *, align_corners: bool = True, + batch: int = 1) -> np.ndarray: + """Static fp32 grid [batch, Ho, Wo, 2] of a bilinear resize (source pixels in float64, normalised with the + align_corners=False formula; call :func:`grid_sample`). For align_corners=True every source pixel lies inside + the input, so the result equals ``F.interpolate(..., align_corners=True)`` up to the bf16 weight truncation.""" + sy = resize_pixel_coords(in_hw[0], out_hw[0], align_corners) + sx = resize_pixel_coords(in_hw[1], out_hw[1], align_corners) + gx = ((2.0 * sx + 1.0) / in_hw[1] - 1.0).astype(F32) + gy = ((2.0 * sy + 1.0) / in_hw[0] - 1.0).astype(F32) + grid = np.empty((out_hw[0], out_hw[1], 2), F32) + grid[..., 0] = gx[None, :] + grid[..., 1] = gy[:, None] + return np.ascontiguousarray(np.broadcast_to(grid, (int(batch),) + grid.shape)) + + +def _truncate_bf16(a: np.ndarray) -> np.ndarray: + bits = np.ascontiguousarray(a, F32).view(np.uint32) & np.uint32(0xFFFF0000) + return bits.view(F32) + + +def emulate_grid_sample(x: np.ndarray, grid: np.ndarray) -> np.ndarray: + """Host emulation of the device's bilinear ``grid_sample`` (align_corners=False, zero padding): pixel recovery + as :func:`kernel_pixels`, ``floor``, fp32 fractions and weights truncated to bf16, taps outside the input skipped. + ``x`` [N, H, W, C], ``grid`` [N, Ho, Wo, 2] -> [N, Ho, Wo, C] float64 (the device sums the four bf16 products + in its own order, so compare with a tolerance; an exact identity / outside sample is exact on both).""" + x = np.asarray(x, np.float64) + g = np.asarray(grid, F32) + n, h, w, _ = x.shape + px = kernel_pixels(g[..., 0], w) + py = kernel_pixels(g[..., 1], h) + x0 = np.floor(px).astype(np.int64) + y0 = np.floor(py).astype(np.int64) + fx = (px - x0.astype(F32)).astype(F32) + fy = (py - y0.astype(F32)).astype(F32) + one = F32(1.0) + out = np.zeros(g.shape[:-1] + (x.shape[-1],), np.float64) + bidx = np.arange(n)[:, None, None] + for dy, wy in ((0, one - fy), (1, fy)): + for dx, wx in ((0, one - fx), (1, fx)): + yy, xx = y0 + dy, x0 + dx + ok = (yy >= 0) & (yy < h) & (xx >= 0) & (xx < w) + wgt = _truncate_bf16((wy * wx).astype(F32)).astype(np.float64) * ok + vals = x[bidx, np.clip(yy, 0, h - 1), np.clip(xx, 0, w - 1)] + out += vals * wgt[..., None] + return out + + +def grid_sample(x: Any, grid: Any, *, compute_kernel_config: Any = None, memory_config: Any = None, + batch_output_channels: bool = False): + """``ttnn.grid_sample(x, grid)``: bilinear, zero padding, ``align_corners=False`` (pair it with + :func:`pixel_to_grid` / :func:`affine_grid` / :func:`resize_grid`), with the P9 rules checked before the call. + The output goes to DRAM by default.""" + import ttnn + + if x.layout != ttnn.ROW_MAJOR_LAYOUT or grid.layout != ttnn.ROW_MAJOR_LAYOUT: + raise ValueError("grid_sample needs ROW_MAJOR input and grid") + if int(x.padded_shape[-1]) % 32: + raise ValueError(f"grid_sample input channels must be a multiple of 32, got {x.padded_shape[-1]} " + "(pad them: deform.pad_channels)") + if int(x.shape[0]) != int(grid.shape[0]): + raise ValueError(f"grid batch {grid.shape[0]} != input batch {x.shape[0]} (expand the grid per batch)") + if grid.dtype != ttnn.float32: + raise ValueError("use an fp32 grid for data-dependent sampling (probe P9: bf16 grids lose PCC)") + if x.dtype == ttnn.float32 and compute_kernel_config is not None and \ + getattr(compute_kernel_config, "fp32_dest_acc_en", False): + raise ValueError("a FLOAT32 input with fp32_dest_acc_en returns garbage (probe P9 defect)") + return ttnn.grid_sample(x, grid, mode="bilinear", padding_mode="zeros", align_corners=False, + use_precomputed_grid=False, batch_output_channels=batch_output_channels, + memory_config=ttnn.DRAM_MEMORY_CONFIG if memory_config is None else memory_config, + compute_kernel_config=compute_kernel_config) diff --git a/code/tt_frnet/ttaw/ops/gather.py b/code/tt_frnet/ttaw/ops/gather.py new file mode 100644 index 0000000000000000000000000000000000000000..6f32a6abca945e987405f2407350cc44764887b3 --- /dev/null +++ b/code/tt_frnet/ttaw/ops/gather.py @@ -0,0 +1,133 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C19: row gathers with ``ttnn.embedding`` and the gather form of scatter (PLAN.md section 1.2 row C19; probe P4). + +A scatter of ``P`` source rows into an ``N``-row destination (a BEV canvas, a to-dense map) is written as a +gather: the host builds ``index[n]`` = the source row of destination row ``n``, or ``sentinel`` (an explicit +all-zero row appended to the table) where nothing lands. Every shape is static, so it traces, and nothing has to +clear the destination first. Probe P4 (common/PROBES.md) measured it bit-exact at the real shapes, with: + +- the table ROW_MAJOR bf16 ``[1, 1, V, D]`` (``D % 32 == 0`` for the fused tilized output), UINT32 indices + ``[1, N]`` (ROW_MAJOR); +- **the default call** ``ttnn.embedding(index, table, layout=TILE, padding_idx=sentinel, + embeddings_type=PADDED)``: 0.19-0.54 ms per canvas (CenterPoint 480 x 480: 0.21 ms); GENERIC is 5.4-14.6x slower + on canvases (most indices hit the sentinel); +- **PADDED returns the table's own pad row, not zeros** (S:ptv3:304): the zero row must really be in the table + (:func:`with_zero_rows`). + +``ttnn.embedding`` is bf16-only; fp32 row gathers are ``ttnn.gather`` (bit-exact but ~1 us per index, probe P13), +not covered here. Indices must lie in ``[0, V)``: an out-of-range index reads outside the table (and can hang the +chip), so :func:`check_index` validates host index arrays before they are uploaded. + +numpy only at import; ttnn is imported inside the device functions. +""" +from __future__ import annotations + +from typing import Any, Optional, Tuple + +import numpy as np + +__all__ = [ + "check_index", + "gather_rows", + "gather_rows_numpy", + "scatter_rows", + "scatter_rows_numpy", + "with_zero_rows", +] + + +def check_index(index: Any, *, num_rows: int, sentinel: Optional[int] = None) -> np.ndarray: + """Validate a host gather index and return it as a contiguous ``uint32`` array of shape ``[1, N]``. + + ``num_rows`` is the number of real source rows; valid entries are ``0 <= i < num_rows`` or ``i == sentinel`` + (the zero row, conventionally ``num_rows``). Raises ``ValueError`` for anything else (negative, too large, + non-integral, non-finite), because an out-of-range index makes the device read outside the table.""" + a = np.asarray(index) + if a.dtype.kind == "f": + if not np.all(np.isfinite(a)) or np.any(a != np.floor(a)): + raise ValueError("gather index holds non-integral or non-finite values") + elif a.dtype.kind not in "iu": + raise ValueError(f"gather index must be integral, got dtype {a.dtype}") + flat = a.reshape(-1).astype(np.int64) + valid = (flat >= 0) & (flat < int(num_rows)) + if sentinel is not None: + valid |= flat == int(sentinel) + bad = ~valid + if np.any(bad): + i = int(np.flatnonzero(bad)[0]) + raise ValueError(f"gather index [{i}] = {int(flat[i])} is outside [0, {num_rows})" + + ("" if sentinel is None else f" and is not the sentinel {sentinel}")) + return np.ascontiguousarray(flat.astype(np.uint32).reshape(1, -1)) + + +def gather_rows_numpy(index: Any, table: Any) -> np.ndarray: + """Host oracle: ``out[n] = table[index[n]]`` (``table`` ``[V, D]``).""" + t = np.asarray(table) + return t.reshape(-1, t.shape[-1])[np.asarray(index).reshape(-1).astype(np.int64)] + + +def scatter_rows_numpy(rows: Any, index: Any, *, sentinel: int) -> np.ndarray: + """Host oracle of :func:`scatter_rows`: ``rows`` ``[P, D]`` with a zero row appended at ``sentinel`` + (``sentinel >= P``; rows between ``P`` and ``sentinel`` are zero too), gathered by ``index``.""" + r = np.asarray(rows, dtype=np.float32) + r = r.reshape(-1, r.shape[-1]) + table = np.zeros((max(int(sentinel) + 1, r.shape[0]), r.shape[1]), np.float32) + table[: r.shape[0]] = r + return gather_rows_numpy(index, table) + + +def _rm(t: Any): + import ttnn + + return ttnn.to_layout(t, ttnn.ROW_MAJOR_LAYOUT) if t.layout != ttnn.ROW_MAJOR_LAYOUT else t + + +def with_zero_rows(rows: Any, count: int = 1): + """A ROW_MAJOR gather table ``[1, 1, P + count, D]``: the device rows ``rows`` (``[1, 1, P, D]``, TILE or + ROW_MAJOR, bf16) followed by ``count`` zero rows (the sentinel rows of :func:`scatter_rows`). One ``ttnn.pad`` + on the device (plus an untilize for a TILE input); traceable.""" + import ttnn + + if count < 1: + raise ValueError("count must be >= 1") + shape = tuple(int(s) for s in rows.shape) + if len(shape) != 4 or shape[0] != 1 or shape[1] != 1: + raise ValueError(f"rows must be [1, 1, P, D], got {shape}") + rm = _rm(rows) + return ttnn.pad(rm, [(0, 0), (0, 0), (0, int(count)), (0, 0)], 0.0) + + +def gather_rows(index: Any, table: Any, *, sentinel: Optional[int] = None, layout: str = "tile", + memory_config: Any = None): + """``out[0, 0, n] = table[0, 0, index[0, n]]`` on the device -> ``[1, 1, N, D]``. + + ``index``: UINT32 ROW_MAJOR ``[1, N]`` device tensor; ``table``: bf16 ROW_MAJOR ``[1, 1, V, D]`` (a TILE table + is untilized by ttnn first: one extra program). ``sentinel``: the index of an all-zero table row; given, the + call is P4's fast PADDED form (the table must really hold zeros there). ``layout="tile"`` (default) writes + the tilized output directly when ``N % 32 == 0`` and ``D % 32 == 0``.""" + import ttnn + + lay = ttnn.TILE_LAYOUT if layout == "tile" else ttnn.ROW_MAJOR_LAYOUT + kw = {"layout": lay} + if sentinel is not None: + kw.update(padding_idx=int(sentinel), embeddings_type=ttnn.EmbeddingsType.PADDED) + if memory_config is not None: + kw["memory_config"] = memory_config + out = ttnn.embedding(index, table, **kw) # [1, N, D] + n, d = int(out.shape[-2]), int(out.shape[-1]) + return ttnn.reshape(out, (1, 1, n, d)) + + +def scatter_rows(rows: Any, index: Any, *, sentinel: Optional[int] = None, layout: str = "tile", + memory_config: Any = None) -> Tuple[Any, Any]: + """Gather-form scatter: ``canvas[n] = rows[index[n]]``, or zeros where ``index[n] == sentinel``. + + ``rows``: ``[1, 1, P, D]`` device rows (TILE or ROW_MAJOR bf16); ``sentinel`` defaults to ``P`` (the zero + row appended by :func:`with_zero_rows`). Returns ``(canvas, table)``: the ``[1, 1, N, D]`` canvas (TILE by + default, e.g. the NHWC input of a conv) and the table, which the caller may deallocate.""" + p = int(rows.shape[-2]) + sentinel = p if sentinel is None else int(sentinel) + if sentinel < p: + raise ValueError(f"sentinel {sentinel} must be >= the number of source rows {p}") + table = with_zero_rows(rows, sentinel - p + 1) + return gather_rows(index, table, sentinel=sentinel, layout=layout, memory_config=memory_config), table diff --git a/code/tt_frnet/ttaw/ops/heatmap.py b/code/tt_frnet/ttaw/ops/heatmap.py new file mode 100644 index 0000000000000000000000000000000000000000..c331ea000033e57e8be553c508de5b46478193f2 --- /dev/null +++ b/code/tt_frnet/ttaw/ops/heatmap.py @@ -0,0 +1,245 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C22: heatmap peaks of the query-based LiDAR detectors -- sigmoid and the 3x3 local maximum (PLAN.md section 1.2 +row C22; probe P6, common/PROBES.md). + +TransFusion, BEVFusion and PTv3 keep a heatmap cell only where it is the maximum of its 3x3 neighbourhood, with +model-specific border and class rules: + +- ``"transfusion"`` (TF, S:transfusion:169): ``MaxPool 3x3 s1 p0`` padded back with a ZERO border, every class: the + border cells are always suppressed; +- ``"bevfusion"`` (BF, S:bevfusion:258): classes ``pooled_classes`` (default 0-3) as TF; the other classes are not + pooled (``local_max = heat``): every cell passes; +- ``"ptv3"`` (PT, S:ptv3:217): ``local_max`` starts as the heat itself and only the interior of the pooled classes is + replaced by the pool: border cells and the other classes pass. + +All three are one exact masked chain (P6, bit-exact on the device, TF 95 / BF 85 / PT 145 us): + + keep = m_pass + (heat == max_pool2d(heat, 3x3, stride 1, padding 1)) * m_eq out = heat * keep + +with two constant masks ``m_eq`` / ``m_pass`` (disjoint, values 0 / 1, laid out as the NHWC rows ``[H*W, C]`` of the +heatmap). The pool's padding never matters: a padded window only covers border cells, where ``m_eq`` is 0. Not the +literal ``p0`` pool + pad form (222 us for TF and two more programs). ``ttnn.max_pool2d`` is bf16 only (FLOAT32 is +rejected, ``pool_op.cpp:30``), so the heat is bf16: :func:`sigmoid_heat` takes fp32 logits through an fp32 sigmoid and +rounds once. + +Device layout: the heatmap is a TILE ``[1, 1, H*W, C]`` tensor (what a conv head writes); the result has the same +layout (the input of C21's class-major top-k row and of the query-heat gather of C26). + +numpy only at import; ttnn is imported inside the device functions. +""" +from __future__ import annotations + +from typing import Any, Dict, Optional, Sequence, Tuple + +import numpy as np + +__all__ = ["RULES", "DEFAULT_POOLED_CLASSES", "local_max_masks", "local_max_numpy", "local_max_literal_numpy", + "sigmoid_heat", "sigmoid_heat_numpy", "LocalMax"] + +RULES = ("transfusion", "bevfusion", "ptv3") +DEFAULT_POOLED_CLASSES = 4 # BF / PT: classes 0-3 pooled (the last class(es) pass) + + +def _pooled(classes: int, rule: str, pooled_classes: Optional[Sequence[int]]) -> np.ndarray: + if rule not in RULES: + raise ValueError(f"rule {rule!r}: expected one of {RULES}") + if pooled_classes is None: + pooled_classes = range(classes) if rule == "transfusion" else range(min(DEFAULT_POOLED_CLASSES, classes)) + p = np.zeros(classes, np.float32) + for c in pooled_classes: + if not 0 <= int(c) < classes: + raise ValueError(f"pooled class {c} outside [0, {classes})") + p[int(c)] = 1.0 + if rule == "transfusion" and not p.all(): + raise ValueError("the transfusion rule pools every class") + return p + + +def local_max_masks(height: int, width: int, classes: int, *, rule: str = "transfusion", + pooled_classes: Optional[Sequence[int]] = None, kernel: int = 3) -> Tuple[np.ndarray, np.ndarray]: + """The constant masks ``(m_pass, m_eq)`` of the chain for ``rule`` (module docstring), float32 NHWC rows + ``[height * width, classes]``. ``kernel`` (odd): the pool size; the border is ``kernel // 2`` cells wide.""" + if kernel < 1 or kernel % 2 == 0: + raise ValueError(f"kernel must be odd and positive, got {kernel}") + r = kernel // 2 + if height <= 2 * r or width <= 2 * r: + raise ValueError(f"a {height}x{width} map has no interior for a {kernel}x{kernel} pool") + pooled = _pooled(classes, rule, pooled_classes) + interior = np.zeros((height, width, 1), np.float32) + interior[r:height - r, r:width - r] = 1.0 + m_eq = interior * pooled[None, None, :] + if rule == "transfusion": + m_pass = np.zeros_like(m_eq) + elif rule == "bevfusion": + m_pass = np.broadcast_to(1.0 - pooled[None, None, :], m_eq.shape).astype(np.float32) + else: + m_pass = 1.0 - m_eq + return (np.ascontiguousarray(m_pass.reshape(height * width, classes)), + np.ascontiguousarray(m_eq.reshape(height * width, classes))) + + +def _pool_numpy(heat_nhwc: np.ndarray, height: int, width: int, kernel: int) -> np.ndarray: + """3x3 (k x k) stride-1 max pool with ``-inf`` padding of NHWC rows ``[H*W, C]`` (exact on any float data).""" + c = heat_nhwc.shape[-1] + x = np.asarray(heat_nhwc, np.float64).reshape(height, width, c) + r = kernel // 2 + p = np.full((height + 2 * r, width + 2 * r, c), -np.inf) + p[r:r + height, r:r + width] = x + out = np.full_like(x, -np.inf) + for dy in range(kernel): + for dx in range(kernel): + np.maximum(out, p[dy:dy + height, dx:dx + width], out=out) + return out.reshape(height * width, c) + + +def local_max_numpy(heat_nhwc: Any, height: int, width: int, m_pass: Any, m_eq: Any, kernel: int = 3) -> np.ndarray: + """Host oracle of the masked chain on NHWC rows ``[H*W, C]`` (float32 out; exact on bf16-valued input).""" + h = np.asarray(heat_nhwc, np.float32).reshape(height * width, -1) + pooled = _pool_numpy(h, height, width, kernel) + keep = np.asarray(m_pass, np.float32) + (h == pooled).astype(np.float32) * np.asarray(m_eq, np.float32) + return (h * keep).astype(np.float32) + + +def local_max_literal_numpy(heat_nchw: Any, rule: str = "transfusion", *, + pooled_classes: Optional[Sequence[int]] = None, kernel: int = 3) -> np.ndarray: + """The models' literal forms (``heat * (heat == local_max)``, NCHW ``(C, H, W)`` or ``(1, C, H, W)``), the oracle + the masked chain is proven against: TF ``max_pool p0`` + zero border (all classes); BF pooled classes as TF and + the others ``local_max = heat``; PT ``local_max = heat`` with the interior of the pooled classes pooled.""" + x = np.asarray(heat_nchw, np.float32) + squeeze = x.ndim == 4 + if squeeze: + x = x[0] + c, h, w = x.shape + pooled = _pooled(c, rule, pooled_classes).astype(bool) + r = kernel // 2 + full = _pool_numpy(x.reshape(c, h * w).T, h, w, kernel).T.reshape(c, h, w) + interior = np.zeros((h, w), bool) + interior[r:h - r, r:w - r] = True + if rule == "transfusion": + local = np.where(interior[None], full, 0.0) + elif rule == "bevfusion": + local = np.where(pooled[:, None, None], np.where(interior[None], full, 0.0), x) + else: + local = np.where(pooled[:, None, None] & interior[None], full, x) + out = (x * (x == local)).astype(np.float32) + return out[None] if squeeze else out + + +def sigmoid_heat_numpy(logits: Any, dtype: str = "bfloat16") -> np.ndarray: + """Host model of :func:`sigmoid_heat`: float64 sigmoid of the float32 logits, rounded once to ``dtype`` (bf16 RNE).""" + s = (1.0 / (1.0 + np.exp(-np.asarray(logits, np.float64)))).astype(np.float32) + if dtype in ("bfloat16", "bf16"): + from ..tensors import round_to_bf16 + + return round_to_bf16(s) + return s + + +def sigmoid_heat(logits: Any, *, dtype: str = "bfloat16", memory_config: Any = None): + """Device ``sigmoid(logits)`` computed in the logits' dtype (fp32 logits: an fp32 sigmoid) and cast once to + ``dtype`` (bf16: what ``max_pool2d`` and C21's ``topk_large_indices`` take). Returns ``(heat, sig)``: the cast + heat and the sigmoid before the cast (the same tensor when no cast is needed).""" + import ttnn + + from ..tensors import ttnn_dtype + + kw = {} if memory_config is None else {"memory_config": memory_config} + sig = ttnn.sigmoid(logits, **kw) + want = ttnn_dtype(dtype) + heat = sig if sig.dtype == want else ttnn.typecast(sig, want) + return heat, sig + + +def _mc(spec: Any): + import ttnn + + if spec is None or spec == "dram": + return ttnn.DRAM_MEMORY_CONFIG + if spec == "l1": + return ttnn.L1_MEMORY_CONFIG + return spec + + +class LocalMax: + """The device chain for one heatmap geometry: ``heat`` (bf16 TILE ``[1, 1, H*W, C]``) -> ``heat * keep``. + + The masks are uploaded (bf16 TILE, exact 0 / 1) by the first call, outside any capture; with the TF rule + (``m_pass`` = 0) the chain is ``max_pool2d``, ``eq``, ``multiply`` (keep = eq * m_eq), ``multiply``; otherwise + ``addcmul(m_pass, eq, m_eq)`` replaces the first multiply. ``memory_config``: where the pool and the masks live + (``"dram"`` default; ``"l1"`` is P6's 95 us form for maps that fit).""" + + def __init__(self, height: int, width: int, classes: int, *, rule: str = "transfusion", + pooled_classes: Optional[Sequence[int]] = None, kernel: int = 3, memory_config: Any = "dram", + name: str = "local_max"): + self.height, self.width, self.classes = int(height), int(width), int(classes) + self.rule = rule + self.kernel = int(kernel) + self.name = name + self.m_pass, self.m_eq = local_max_masks(self.height, self.width, self.classes, rule=rule, + pooled_classes=pooled_classes, kernel=self.kernel) + self.passes = bool(self.m_pass.any()) + self.memory_config = memory_config + self._dev: Optional[Dict[str, Any]] = None + + @property + def cells(self) -> int: + return self.height * self.width + + def _upload(self, device: Any) -> Dict[str, Any]: + import torch + import ttnn + + def up(a: np.ndarray): + t = torch.from_numpy(np.ascontiguousarray(a.reshape(1, 1, self.cells, self.classes))) + return ttnn.from_torch(t, dtype=ttnn.bfloat16, layout=ttnn.TILE_LAYOUT, device=device, + memory_config=_mc(self.memory_config)) + + self._dev = {"m_eq": up(self.m_eq), "m_pass": up(self.m_pass) if self.passes else None} + return self._dev + + def __call__(self, heat: Any): + import ttnn + + shape = tuple(int(s) for s in heat.shape) + if shape != (1, 1, self.cells, self.classes): + raise ValueError(f"{self.name}: heat must be [1, 1, {self.cells}, {self.classes}], got {shape}") + if heat.dtype != ttnn.bfloat16: + raise ValueError(f"{self.name}: max_pool2d takes bf16 only (got {heat.dtype}): use sigmoid_heat") + dev = self._dev or self._upload(heat.device()) + r = self.kernel // 2 + pooled = ttnn.max_pool2d(input_tensor=heat, batch_size=1, input_h=self.height, input_w=self.width, + channels=self.classes, kernel_size=[self.kernel, self.kernel], stride=[1, 1], + padding=[r, r], dilation=[1, 1], ceil_mode=False, + memory_config=_mc(self.memory_config), output_layout=ttnn.TILE_LAYOUT) + if tuple(int(s) for s in pooled.shape) != shape: + full = pooled + pooled = ttnn.slice(full, [0, 0, 0, 0], list(shape)) + ttnn.deallocate(full, False) + eq = ttnn.eq(heat, pooled) + ttnn.deallocate(pooled, False) + if self.passes: + keep = ttnn.addcmul(dev["m_pass"], eq, dev["m_eq"]) + else: + keep = ttnn.multiply(eq, dev["m_eq"]) + ttnn.deallocate(eq, False) + out = ttnn.multiply(heat, keep) + ttnn.deallocate(keep, False) + return out + + def numpy(self, heat_nhwc: Any) -> np.ndarray: + """Host oracle of this chain on NHWC rows (bf16-valued input gives the device result bit for bit).""" + return local_max_numpy(heat_nhwc, self.height, self.width, self.m_pass, self.m_eq, self.kernel) + + def release(self) -> None: + import ttnn + + if self._dev is not None: + for t in self._dev.values(): + if t is not None and t.is_allocated(): + ttnn.deallocate(t) + self._dev = None + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "grid": [self.height, self.width], "classes": self.classes, "rule": self.rule, + "kernel": self.kernel, "pooled_classes": [int(c) for c in np.flatnonzero(self.m_eq.max(axis=0))], + "passes": self.passes, "memory_config": str(self.memory_config)} diff --git a/code/tt_frnet/ttaw/ops/kernels/segment_reduce_dm.cpp b/code/tt_frnet/ttaw/ops/kernels/segment_reduce_dm.cpp new file mode 100644 index 0000000000000000000000000000000000000000..92ac7c19248c60c37e73cd3b3a193196485e45f4 --- /dev/null +++ b/code/tt_frnet/ttaw/ops/kernels/segment_reduce_dm.cpp @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: Apache-2.0 +// K1 segment_reduce (ttaw.ops.segment, PLAN.md 1.3): per-segment MAX over the rows of a tensor sorted by segment. +// +// out[s, :] = max(x[off[s] : off[s + 1], :]) for s in [0, NUM_SEG); empty segments -> FILL_BITS +// +// x: [1, 1, NUM_ROWS, C] interleaved DRAM, TILE (IN_TILE = 1) or ROW_MAJOR, bf16 or fp32; off: uint32 ROW_MAJOR +// [1, >= NUM_SEG + 1] interleaved DRAM (one page; CSR offsets, non-decreasing); out: [1, 1, NUM_SEG, C] ROW_MAJOR +// interleaved DRAM, bf16 (round to nearest even) or fp32. Exact: the maximum is taken on integer keys of the +// float bit patterns (key = bits ^ ((bits >> 31) & 0x7fffffff), signed order == float order, -0 < +0), so no float +// arithmetic happens on the RISC-V (no F extension) and bf16 / fp32 inputs come out bit-exact (fp32 -> bf16 is a +// monotonic rounding, so max-then-round == round-then-max). NaN is not supported (not produced by the models). +// +// Work split (deterministic, data-dependent, no per-core runtime args): NUM_WORKERS workers = SLOTS data-movement +// RISC-Vs per core on a GW-wide core rectangle starting at (X0, Y0); worker w = core_index * SLOTS + SLOT. The cost +// of segment s is off[s] + s (its rows plus one output row; strictly increasing in s), T = off[S] + S. Worker w owns +// the segments whose cost lies in [w T / W, (w + 1) T / W): it finds them by two binary searches over off (64-byte +// probes from DRAM), then streams its contiguous row range in units of 32 rows (one tile-row in TILE mode) with +// double buffering, reduces each segment into an L1 accumulator and writes one output row per segment (a ring of +// OUT_RING staging rows). Every segment is written by exactly one worker; nothing is shared between workers. +// +// Runtime args: COMMON only [x_addr, off_addr, out_addr] (P3 rule: per-core work derives from the core coordinates; +// the arg layout is fixed by the compile-time args). Hang-protocol notes (PLAN.md 4.4): no CB is pushed or popped +// (CBs are plain L1 scratch: data 2 units, offsets window, probe block, accumulator, output ring; sizes checked by +// the host builder), no multicast, no semaphores; every DRAM read has matching 64-byte alignment of source and +// destination (Blackhole NOC_DRAM_READ_ALIGNMENT_BYTES), every read is waited on by noc_async_read_barrier (which +// also invalidates the Blackhole L1 data cache) before the data are used, staging rows are reused only after +// noc_async_writes_flushed, and the kernel ends with noc_async_write_barrier. Offsets are clamped to [0, NUM_ROWS] +// and to non-decreasing order, so a corrupt table can never make the kernel read outside x. +#include + +#include "api/dataflow/dataflow_api.h" + +constexpr uint32_t IN_TILE = get_compile_time_arg_val(0); // 1: TILE input, 0: ROW_MAJOR input +constexpr uint32_t IN_BYTES = get_compile_time_arg_val(1); // input element bytes: 2 (bf16) or 4 (fp32) +constexpr uint32_t OUT_BYTES = get_compile_time_arg_val(2); // output element bytes: 2 (bf16, RNE) or 4 (fp32) +constexpr uint32_t C = get_compile_time_arg_val(3); // channels (multiple of 32) +constexpr uint32_t NUM_ROWS = get_compile_time_arg_val(4); // input rows (multiple of 32): the read bound +constexpr uint32_t NUM_SEG = get_compile_time_arg_val(5); // output rows (segments) +constexpr uint32_t NUM_WORKERS = get_compile_time_arg_val(6); // workers of the program +constexpr uint32_t SLOTS = get_compile_time_arg_val(7); // workers per core (1 or 2) +constexpr uint32_t SLOT = get_compile_time_arg_val(8); // this kernel's slot on its core +constexpr uint32_t X0 = get_compile_time_arg_val(9); // core rectangle: first logical x +constexpr uint32_t Y0 = get_compile_time_arg_val(10); // first logical y +constexpr uint32_t GW = get_compile_time_arg_val(11); // width in cores +constexpr uint32_t CB_DATA = get_compile_time_arg_val(12); // 2 units of input rows (+ 64 B alignment slack) +constexpr uint32_t CB_OFF = get_compile_time_arg_val(13); // offsets window: OFF_CHUNK uint32 (+ 64 B) +constexpr uint32_t CB_PROBE = get_compile_time_arg_val(14); // one 64-byte probe block (+ 64 B) +constexpr uint32_t CB_ACC = get_compile_time_arg_val(15); // C int32 keys +constexpr uint32_t CB_OUT = get_compile_time_arg_val(16); // OUT_RING output rows (+ 64 B) +constexpr uint32_t OUT_RING = get_compile_time_arg_val(17); // staging rows in flight +constexpr uint32_t OFF_CHUNK = get_compile_time_arg_val(18); // offsets per window (multiple of 16) +constexpr uint32_t FILL_BITS = get_compile_time_arg_val(19); // fp32 bits written for an empty segment +constexpr uint32_t OFF_PAGE_BYTES = get_compile_time_arg_val(20); // page (= tensor) size of off, bytes +constexpr auto x_args = TensorAccessorArgs<21>(); +constexpr auto off_args = TensorAccessorArgs(); +constexpr auto out_args = TensorAccessorArgs(); + +constexpr uint32_t UNIT_ROWS = 32; // rows per streamed unit (one tile-row) +constexpr uint32_t CT = C / 32; // 32-column tiles per row +constexpr uint32_t TILE_ELEMS = 1024; +constexpr uint32_t IN_PAGE_BYTES = IN_TILE ? TILE_ELEMS * IN_BYTES : C * IN_BYTES; +constexpr uint32_t PAGES_PER_UNIT = IN_TILE ? CT : UNIT_ROWS; +constexpr uint32_t UNIT_BYTES = PAGES_PER_UNIT * IN_PAGE_BYTES; +constexpr uint32_t OUT_ROW_BYTES = C * OUT_BYTES; +constexpr uint32_t OFF_ALIGNED_BYTES = (OFF_PAGE_BYTES + 63u) & ~63u; +constexpr uint32_t NUM_UNITS = NUM_ROWS / UNIT_ROWS; + +static_assert(C % 32 == 0, "C must be a multiple of 32"); +static_assert(NUM_ROWS % UNIT_ROWS == 0, "NUM_ROWS must be a multiple of 32"); +static_assert(IN_BYTES == 2 || IN_BYTES == 4, "bf16 or fp32 input"); +static_assert(OUT_BYTES == 2 || OUT_BYTES == 4, "bf16 or fp32 output"); +static_assert(OFF_CHUNK % 16 == 0 && OFF_CHUNK >= 16, "OFF_CHUNK must be a multiple of 16"); +static_assert(OUT_RING >= 1, "OUT_RING >= 1"); +static_assert(IN_PAGE_BYTES % 64 == 0, "input pages must be 64-byte multiples (DRAM read alignment)"); +static_assert(OUT_ROW_BYTES % 16 == 0, "output rows must be 16-byte multiples (DRAM write alignment)"); + +#define COMPILER_BARRIER() asm volatile("" ::: "memory") + +static inline uint32_t align64(uint32_t a) { return (a + 63u) & ~63u; } + +// float bits <-> signed key (an involution); signed comparison of keys == float comparison (no NaN) +static inline int32_t to_key(uint32_t bits) { + const int32_t b = static_cast(bits); + return b ^ ((b >> 31) & 0x7fffffff); +} +static inline uint32_t from_key(int32_t k) { return static_cast(k ^ ((k >> 31) & 0x7fffffff)); } + +static inline uint16_t to_bf16_rne(uint32_t bits) { + return static_cast((bits + 0x7fffu + ((bits >> 16) & 1u)) >> 16); +} + +void kernel_main() { + const uint32_t x_addr = get_common_arg_val(0); + const uint32_t off_addr = get_common_arg_val(1); + const uint32_t out_addr = get_common_arg_val(2); + + const uint32_t core = (get_absolute_logical_y() - Y0) * GW + (get_absolute_logical_x() - X0); + const uint32_t w = core * SLOTS + SLOT; + if (w >= NUM_WORKERS) { + return; + } + + const auto xa = TensorAccessor(x_args, x_addr, IN_PAGE_BYTES); + const auto oa = TensorAccessor(off_args, off_addr, OFF_PAGE_BYTES); + const auto ya = TensorAccessor(out_args, out_addr, OUT_ROW_BYTES); + + const uint32_t data_l1 = align64(get_write_ptr(CB_DATA)); + const uint32_t off_l1 = align64(get_write_ptr(CB_OFF)); + const uint32_t probe_l1 = align64(get_write_ptr(CB_PROBE)); + const uint32_t out_l1 = align64(get_write_ptr(CB_OUT)); + int32_t* acc = reinterpret_cast(get_write_ptr(CB_ACC)); + + // ---- offsets: single 64-byte probes (binary search) and a streamed window (the worker's segments) + auto probe = [&](uint32_t s) -> uint32_t { + const uint32_t byte = s * 4u; + noc_async_read(oa.get_noc_addr(0, byte & ~63u), probe_l1, 64); + noc_async_read_barrier(); + COMPILER_BARRIER(); + const uint32_t v = reinterpret_cast(probe_l1)[(byte & 63u) >> 2]; + return v < NUM_ROWS ? v : NUM_ROWS; + }; + uint32_t win0 = 0xffffffffu; // first offset index held in the window (multiple of 16) + auto off_at = [&](uint32_t s) -> uint32_t { + if (win0 == 0xffffffffu || s < win0 || s >= win0 + OFF_CHUNK) { + win0 = s & ~15u; + const uint32_t start = win0 * 4u; + uint32_t bytes = OFF_CHUNK * 4u; + if (start + bytes > OFF_ALIGNED_BYTES) { + bytes = OFF_ALIGNED_BYTES - start; + } + noc_async_read(oa.get_noc_addr(0, start), off_l1, bytes); + noc_async_read_barrier(); + COMPILER_BARRIER(); + } + const uint32_t v = reinterpret_cast(off_l1)[s - win0]; + return v < NUM_ROWS ? v : NUM_ROWS; + }; + + // ---- this worker's segments [s_lo, s_hi): costs off[s] + s in [w T / W, (w + 1) T / W) + const uint32_t total = probe(NUM_SEG) + NUM_SEG; + auto search = [&](uint32_t target) -> uint32_t { // smallest s in [0, NUM_SEG] with off[s] + s >= target + uint32_t lo = 0, hi = NUM_SEG; + while (lo < hi) { + const uint32_t mid = (lo + hi) >> 1; + if (probe(mid) + mid >= target) { + hi = mid; + } else { + lo = mid + 1; + } + } + return lo; + }; + const uint32_t t0 = static_cast((static_cast(w) * total) / NUM_WORKERS); + const uint32_t t1 = static_cast((static_cast(w + 1) * total) / NUM_WORKERS); + const uint32_t s_lo = (w == 0) ? 0 : search(t0); + const uint32_t s_hi = (w + 1 == NUM_WORKERS) ? NUM_SEG : search(t1); + if (s_lo >= s_hi) { + return; + } + + // ---- input rows: units of 32 rows, two L1 slots, the next unit prefetched while the current one is reduced + uint32_t row_last = off_at(s_lo); // largest row this worker reads (+1), monotone over its segments + { + const uint32_t r_end = off_at(s_hi); + if (r_end > row_last) { + row_last = r_end; + } + } + const uint32_t unit_last = row_last > 0 ? (row_last - 1) / UNIT_ROWS : 0; + auto issue_unit = [&](uint32_t u, uint32_t slot) { + const uint32_t dst = data_l1 + slot * UNIT_BYTES; + const uint32_t first_page = IN_TILE ? u * CT : u * UNIT_ROWS; + for (uint32_t p = 0; p < PAGES_PER_UNIT; ++p) { + noc_async_read(xa.get_noc_addr(first_page + p), dst + p * IN_PAGE_BYTES, IN_PAGE_BYTES); + } + }; + uint32_t cur_unit = 0xffffffffu, cur_slot = 0; + bool next_ready = false; // the unit cur_unit + 1 is in flight into slot cur_slot ^ 1 + auto ensure_unit = [&](uint32_t u) { + if (u == cur_unit) { + return; + } + if (next_ready && u == cur_unit + 1) { + noc_async_read_barrier(); + cur_slot ^= 1u; + } else { + noc_async_read_barrier(); // drain a stale prefetch before its slot is reused + issue_unit(u, cur_slot); + noc_async_read_barrier(); + } + COMPILER_BARRIER(); + cur_unit = u; + next_ready = false; + if (u + 1 <= unit_last && u + 1 < NUM_UNITS) { + issue_unit(u + 1, cur_slot ^ 1u); + next_ready = true; + } + }; + + auto reset_acc = [&]() { + for (uint32_t c = 0; c < C; ++c) { + acc[c] = static_cast(0x80000000u); + } + }; + auto accumulate_row = [&](uint32_t r) { // acc = max(acc, x[r, :]) on keys + const uint32_t i = r - cur_unit * UNIT_ROWS; + const uint32_t base = data_l1 + cur_slot * UNIT_BYTES; + if constexpr (IN_TILE) { + const uint32_t face_row = (i >> 4) << 1; // faces 0/1 (rows 0-15) or 2/3 (rows 16-31) + for (uint32_t tc = 0; tc < CT; ++tc) { + for (uint32_t h = 0; h < 2; ++h) { + const uint32_t e0 = tc * TILE_ELEMS + (face_row + h) * 256 + (i & 15u) * 16; + int32_t* a = acc + tc * 32 + h * 16; + if constexpr (IN_BYTES == 4) { + volatile tt_l1_ptr uint32_t* src = reinterpret_cast(base) + e0; + for (uint32_t k = 0; k < 16; ++k) { + const int32_t key = to_key(src[k]); + a[k] = key > a[k] ? key : a[k]; + } + } else { + volatile tt_l1_ptr uint16_t* src = reinterpret_cast(base) + e0; + for (uint32_t k = 0; k < 16; ++k) { + const int32_t key = to_key(static_cast(src[k]) << 16); + a[k] = key > a[k] ? key : a[k]; + } + } + } + } + } else { + if constexpr (IN_BYTES == 4) { + volatile tt_l1_ptr uint32_t* src = reinterpret_cast(base) + i * C; + for (uint32_t c = 0; c < C; ++c) { + const int32_t key = to_key(src[c]); + acc[c] = key > acc[c] ? key : acc[c]; + } + } else { + volatile tt_l1_ptr uint16_t* src = reinterpret_cast(base) + i * C; + for (uint32_t c = 0; c < C; ++c) { + const int32_t key = to_key(static_cast(src[c]) << 16); + acc[c] = key > acc[c] ? key : acc[c]; + } + } + } + }; + + // ---- output rows: a ring of OUT_RING staging rows, reused after noc_async_writes_flushed + uint32_t emitted = 0; + auto emit = [&](uint32_t s, bool empty) { + const uint32_t ring = emitted % OUT_RING; + if (ring == 0 && emitted > 0) { + noc_async_writes_flushed(); + } + const uint32_t dst = out_l1 + ring * OUT_ROW_BYTES; + if constexpr (OUT_BYTES == 4) { + volatile tt_l1_ptr uint32_t* o = reinterpret_cast(dst); + for (uint32_t c = 0; c < C; ++c) { + o[c] = empty ? FILL_BITS : from_key(acc[c]); + } + } else { + volatile tt_l1_ptr uint16_t* o = reinterpret_cast(dst); + const uint16_t fill = to_bf16_rne(FILL_BITS); + for (uint32_t c = 0; c < C; ++c) { + o[c] = empty ? fill : to_bf16_rne(from_key(acc[c])); + } + } + COMPILER_BARRIER(); + noc_async_write(dst, ya.get_noc_addr(s), OUT_ROW_BYTES); + ++emitted; + }; + + // ---- the reduction + uint32_t seg_begin = off_at(s_lo); + for (uint32_t s = s_lo; s < s_hi; ++s) { + uint32_t seg_end = off_at(s + 1); + if (seg_end < seg_begin) { + seg_end = seg_begin; // non-decreasing offsets (a corrupt table reads nothing extra) + } + if (seg_end == seg_begin) { + emit(s, true); + } else { + reset_acc(); + for (uint32_t r = seg_begin; r < seg_end; ++r) { + ensure_unit(r / UNIT_ROWS); + accumulate_row(r); + } + emit(s, false); + } + seg_begin = seg_end; + } + noc_async_read_barrier(); // a prefetch past the last row may still be in flight + noc_async_write_barrier(); +} diff --git a/code/tt_frnet/ttaw/ops/segment.py b/code/tt_frnet/ttaw/ops/segment.py new file mode 100644 index 0000000000000000000000000000000000000000..3cded44cf024976b18f83d5b5e9d106e0d93a072 --- /dev/null +++ b/code/tt_frnet/ttaw/ops/segment.py @@ -0,0 +1,447 @@ +# SPDX-License-Identifier: Apache-2.0 +"""K1 ``segment_reduce``: per-segment reductions over rows sorted by segment (PLAN.md section 1.3 row K1; owned by the +FRNet port), with the exact stock-op fallback (the log-step segmented max) and their host twins. + +A *segmented* tensor holds rows sorted by segment id: segment ``s`` is the contiguous row range +``[offsets[s], offsets[s + 1])`` (CSR offsets, non-decreasing; rows past ``offsets[-1]`` are padding that nothing +reads). FRNet's six ``ScatterElements(max)`` sites (points sorted by frustum cell, PLAN.md 2.8), PTv3 pooling and +BEVPool are of this form once the host sorts the rows. + +- **K1 on the device** (:class:`SegmentReduce`, kernel ``kernels/segment_reduce_dm.cpp``): ``out[s] = + max(x[offsets[s]:offsets[s + 1]])`` for ``s < num_segments``, empty segments -> ``empty_value`` (FRNet: the zero + row of the frustum2pixel gather table comes out of the kernel itself). Input ``[1, 1, R, C]`` TILE or ROW_MAJOR, + bf16 or fp32 (``R % 32 == 0``, ``C % 32 == 0``); offsets uint32 ``[1, K]`` ROW_MAJOR (``K >= num_segments + 1``, + an RT-dev tensor: rewritten per frame, no recapture); output ``[1, 1, num_segments, C]`` ROW_MAJOR, bf16 (round to + nearest even) or fp32 -- the layout ``ttnn.embedding`` takes as a table. **Exact**: the maximum is taken on + integer keys of the float bit patterns (signed order == float order, -0 < +0), so the result is bit-exact for any + input dtype, and fp32 -> bf16 output equals rounding first (a monotonic rounding commutes with max). One program, + common runtime args only (probe P3 rule), deterministic, no atomics, traceable. Work split: 2 data-movement + RISC-Vs per core, each a contiguous run of segments balanced on rows + 1 per segment, found on the device by binary + search over the offsets (no per-frame host split, nothing grid-dependent in the host tables). +- **max only on the device for now.** ``sum`` / ``mean`` need float adds, which the data-movement RISC-Vs do not have + (no F extension); they are host-oracle only here (:func:`segment_reduce_numpy`) and come with their consumers + (PTv3 / BEVPool, optimization phase). +- **Stock-op fallback** (:func:`logstep_segment_max`, PLAN.md 1.3): ``R`` Hillis-Steele rounds ``x = max(x, x[idx_r])`` + with host shift tables ``idx_r[i] = i - 2^r`` when row ``i - 2^r`` is in the same segment, else ``i`` (max is + idempotent), then one gather of each segment's last row: exact for segments of up to ``2^R`` rows, ``3R + 1`` + programs (untilize, ``ttnn.embedding``, ``ttnn.maximum`` per round). ``ttnn.embedding`` is bf16-only, so the + fallback is bf16 in and out. ``R`` is COMPILE (it sets the op count): bucket it per frame (:func:`segment_rounds`). + +Host side (numpy only): :func:`segment_offsets` (CSR offsets from sorted segment ids), :func:`segment_rounds`, +:func:`segment_shift_tables`, :func:`segment_last_rows`, oracles :func:`segment_reduce_numpy` and +:func:`logstep_segment_max_numpy`, and :func:`worker_segments` (the kernel's work split, for tests and diagnostics). +numpy only at import; ttnn is imported inside the device functions. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from . import kernel_path + +__all__ = [ + "OPS", + "DEVICE_OPS", + "segment_offsets", + "check_offsets", + "segment_rounds", + "segment_shift_tables", + "segment_last_rows", + "segment_reduce_numpy", + "logstep_segment_max_numpy", + "worker_segments", + "SegmentReduceSpec", + "SegmentReduce", + "segment_reduce", + "logstep_segment_max", + "KERNEL", +] + +OPS = ("max", "min", "sum", "mean") +DEVICE_OPS = ("max",) +KERNEL = "segment_reduce_dm.cpp" +UNIT_ROWS = 32 # rows per streamed unit (one tile-row): the input rows must be a multiple of it +_OFF_CHUNK = 256 # offsets per L1 window (multiple of 16) +_OUT_RING = 8 # output staging rows in flight per worker + + +# ------------------------------------------------------------------------------------------------ host tables + +def segment_offsets(seg_id: Any, num_segments: int, *, num_rows: Optional[int] = None) -> np.ndarray: + """CSR offsets ``uint32 [num_segments + 1]`` of rows sorted by segment id: segment ``s`` = rows + ``[off[s], off[s + 1])``. ``seg_id`` (``int [R]``, non-decreasing) may contain ids ``>= num_segments`` (padding + rows, e.g. FRNet's trailing sentinel segment): they must come last and belong to no segment. ``num_rows`` + (default ``len(seg_id)``) bounds the offsets.""" + ids = np.asarray(seg_id).reshape(-1).astype(np.int64) + if ids.size and (np.any(np.diff(ids) < 0) or ids[0] < 0): + raise ValueError("segment ids must be non-negative and sorted (non-decreasing)") + counts = np.bincount(ids[ids < num_segments], minlength=num_segments).astype(np.int64) + off = np.zeros(num_segments + 1, np.int64) + np.cumsum(counts, out=off[1:]) + rows = len(ids) if num_rows is None else int(num_rows) + if off[-1] > rows: + raise ValueError(f"segments hold {off[-1]} rows, more than num_rows={rows}") + return off.astype(np.uint32) + + +def check_offsets(offsets: Any, *, num_segments: int, num_rows: int, length: Optional[int] = None) -> np.ndarray: + """Validate CSR offsets for the device and return them as a contiguous ``uint32 [1, length]`` row (``length`` + defaults to ``num_segments + 1``; extra entries repeat the last offset). Raises ``ValueError`` unless they start + at 0, never decrease and end at most at ``num_rows`` (the kernel clamps anyway; a bad table is a host bug).""" + a = np.asarray(offsets).reshape(-1) + if a.dtype.kind not in "iu": + raise ValueError(f"offsets must be integral, got dtype {a.dtype}") + a = a.astype(np.int64) + if a.size < num_segments + 1: + raise ValueError(f"{a.size} offsets for {num_segments} segments (need num_segments + 1)") + a = a[: num_segments + 1] + if a[0] != 0 or np.any(np.diff(a) < 0) or a[-1] > num_rows: + raise ValueError(f"offsets must start at 0, be non-decreasing and end <= num_rows={num_rows}") + n = num_segments + 1 if length is None else int(length) + if n < num_segments + 1: + raise ValueError(f"length {n} < num_segments + 1") + out = np.full((1, n), a[-1], np.uint32) + out[0, : a.size] = a + return out + + +def segment_rounds(max_segment_length: int) -> int: + """Log-step rounds that cover segments of up to ``max_segment_length`` rows: ``ceil(log2(L))`` (0 for L <= 1).""" + L = int(max_segment_length) + return int(np.ceil(np.log2(L))) if L > 1 else 0 + + +def segment_shift_tables(seg_id: Any, rounds: int) -> List[np.ndarray]: + """The gather tables of the log-step segmented max: round ``r`` uses ``idx[i] = i - 2^r`` if row ``i - 2^r`` has + the same segment id, else ``i`` -> ``rounds`` x ``uint32 [R]``. After ``rounds`` rounds the last row of every + segment of at most ``2^rounds`` rows holds the segment max (padding rows only ever see padding rows).""" + seg = np.asarray(seg_id).reshape(-1) + i = np.arange(len(seg), dtype=np.int64) + out = [] + for r in range(int(rounds)): + d = 1 << r + same = np.zeros(len(seg), bool) + same[d:] = seg[d:] == seg[:-d] + out.append(np.where(same, i - d, i).astype(np.uint32)) + return out + + +def segment_last_rows(offsets: Any, *, empty_row: int) -> np.ndarray: + """``uint32 [S]``: the last row of each segment of ``offsets`` (``S + 1`` entries), ``empty_row`` for an empty + segment. For :func:`logstep_segment_max` pass the number of input rows: the fallback appends one zero row there + (padding rows themselves are reduced too, so they do not stay zero).""" + off = np.asarray(offsets).reshape(-1).astype(np.int64) + last = off[1:] - 1 + return np.where(off[1:] > off[:-1], last, int(empty_row)).astype(np.uint32) + + +# ------------------------------------------------------------------------------------------------- oracles + +def _float_keys(a: np.ndarray) -> np.ndarray: + """float32 -> int32 keys whose signed order is the float order with -0 < +0 (an involution on the bits).""" + b = np.ascontiguousarray(a, np.float32).view(np.int32) + return b ^ ((b >> 31) & np.int32(0x7FFFFFFF)) + + +def _from_keys(k: np.ndarray) -> np.ndarray: + return (k ^ ((k >> 31) & np.int32(0x7FFFFFFF))).astype(np.int32).view(np.float32) + + +def segment_reduce_numpy(x: Any, offsets: Any, num_segments: Optional[int] = None, *, op: str = "max", + empty_value: float = 0.0) -> np.ndarray: + """Host oracle: ``out[s] = op(x[off[s]:off[s + 1]], axis=0)`` (``x`` ``[R, C]`` or ``[..., R, C]``), empty + segments -> ``empty_value``. ``max`` / ``min`` keep the input dtype (exact) and, for float32 / bf16-valued + inputs, order the floats as the K1 kernel does (signed zeros: ``-0 < +0``; numpy's ``maximum`` would keep + whichever zero comes first); ``sum`` / ``mean`` accumulate in float64 and return float32.""" + if op not in OPS: + raise ValueError(f"op {op!r}: one of {OPS}") + a = np.asarray(x) + a = a.reshape(-1, a.shape[-1]) + off = np.asarray(offsets).reshape(-1).astype(np.int64) + S = len(off) - 1 if num_segments is None else int(num_segments) + off = off[: S + 1] + out_dtype = a.dtype if op in ("max", "min") else np.float32 + out = np.full((S, a.shape[1]), empty_value, dtype=out_dtype) + nonempty = np.flatnonzero(off[1:] > off[:-1]) + if nonempty.size: + starts = off[:-1][nonempty] + if op in ("max", "min") and a.dtype == np.float32: + keys = _float_keys(a[: off[-1]]) + red = _from_keys((np.maximum if op == "max" else np.minimum).reduceat(keys, starts, axis=0)) + elif op in ("max", "min"): + red = (np.maximum if op == "max" else np.minimum).reduceat(a[: off[-1]], starts, axis=0) + else: + red = np.add.reduceat(a[: off[-1]].astype(np.float64), starts, axis=0) + if op == "mean": + red = red / (off[1:][nonempty] - starts)[:, None] + out[nonempty] = red.astype(out_dtype) + return out + + +def logstep_segment_max_numpy(x: Any, shift_tables: Sequence[Any], last_rows: Any) -> np.ndarray: + """Host twin of :func:`logstep_segment_max` (any dtype; exact): the rounds, then a gather of ``last_rows`` from + the result with one zero row appended (index ``R``: the empty segments).""" + a = np.asarray(x) + a = a.reshape(-1, a.shape[-1]) + for idx in shift_tables: + a = np.maximum(a, a[np.asarray(idx).reshape(-1).astype(np.int64)]) + a = np.concatenate([a, np.zeros((1, a.shape[1]), a.dtype)], axis=0) + return a[np.asarray(last_rows).reshape(-1).astype(np.int64)] + + +def worker_segments(offsets: Any, num_segments: int, num_workers: int, *, num_rows: Optional[int] = None + ) -> List[Tuple[int, int]]: + """The kernel's work split (host twin of ``segment_reduce_dm.cpp``): worker ``w`` owns the segments whose cost + ``off[s] + s`` lies in ``[w T / W, (w + 1) T / W)`` with ``T = off[S] + S`` -> ``[(s_lo, s_hi)]`` per worker. + Offsets are clamped to ``num_rows`` as the kernel clamps them.""" + off = np.asarray(offsets).reshape(-1).astype(np.int64)[: num_segments + 1] + if num_rows is not None: + off = np.minimum(off, int(num_rows)) + cost = off + np.arange(num_segments + 1) + total = int(cost[-1]) + out = [] + for w in range(num_workers): + t0 = (w * total) // num_workers + t1 = ((w + 1) * total) // num_workers + lo = 0 if w == 0 else int(np.searchsorted(cost, t0, side="left")) + hi = num_segments if w + 1 == num_workers else int(np.searchsorted(cost, t1, side="left")) + out.append((lo, max(lo, hi))) + return out + + +# ------------------------------------------------------------------------------------------------- device K1 + +def _f32_bits(value: float) -> int: + return int(np.array([value], np.float32).view(np.uint32)[0]) + + +@dataclass(frozen=True) +class SegmentReduceSpec: + """The static shape of one K1 call site (COMPILE): ``num_rows`` x ``channels`` input, ``num_segments`` outputs, + dtypes / layout, the empty value, and how many workers (data-movement RISC-Vs) per core run it.""" + + num_rows: int + channels: int + num_segments: int + input_dtype: str = "float32" # "float32" | "bfloat16" + input_layout: str = "tile" # "tile" | "row_major" + output_dtype: str = "float32" # "float32" | "bfloat16" (RNE) + op: str = "max" + empty_value: float = 0.0 + workers_per_core: int = 2 + + def __post_init__(self) -> None: + if self.op not in DEVICE_OPS: + raise NotImplementedError(f"K1 runs {DEVICE_OPS} on the device; {self.op!r} is host-oracle only " + "(segment_reduce_numpy): sum / mean need float adds the data-movement RISC-Vs " + "do not have") + if self.num_rows <= 0 or self.num_rows % UNIT_ROWS: + raise ValueError(f"num_rows must be a positive multiple of {UNIT_ROWS}, got {self.num_rows}") + if self.channels <= 0 or self.channels % 32: + raise ValueError(f"channels must be a positive multiple of 32, got {self.channels}") + if self.num_segments <= 0: + raise ValueError("num_segments must be positive") + for name, value in (("input_dtype", self.input_dtype), ("output_dtype", self.output_dtype)): + if value not in ("float32", "bfloat16"): + raise ValueError(f"{name} {value!r}: 'float32' or 'bfloat16'") + if self.input_layout not in ("tile", "row_major"): + raise ValueError(f"input_layout {self.input_layout!r}: 'tile' or 'row_major'") + if self.workers_per_core not in (1, 2): + raise ValueError("workers_per_core must be 1 or 2") + if not np.isfinite(self.empty_value): + raise ValueError("empty_value must be finite") + + @property + def in_bytes(self) -> int: + return 4 if self.input_dtype == "float32" else 2 + + @property + def out_bytes(self) -> int: + return 4 if self.output_dtype == "float32" else 2 + + @property + def in_page_bytes(self) -> int: + return (1024 if self.input_layout == "tile" else self.channels) * self.in_bytes + + @property + def unit_bytes(self) -> int: + pages = self.channels // 32 if self.input_layout == "tile" else UNIT_ROWS + return pages * self.in_page_bytes + + @property + def out_row_bytes(self) -> int: + return self.channels * self.out_bytes + + def l1_bytes_per_worker(self) -> Dict[str, int]: + """The L1 scratch (CB) sizes of one worker: 2 data units, offsets window, probe block, accumulator, output + ring (each + 64 B so the kernel can align to the 64-byte DRAM read alignment).""" + return {"data": 2 * self.unit_bytes + 64, "offsets": _OFF_CHUNK * 4 + 64, "probe": 128, + "acc": self.channels * 4, "out": _OUT_RING * self.out_row_bytes + 64} + + def check_l1(self, budget: int = 600 * 1024) -> None: + """Refuse configurations whose per-core scratch exceeds ``budget`` (default 600 KiB of ~1.4 MiB).""" + per_core = self.workers_per_core * sum(self.l1_bytes_per_worker().values()) + if per_core > budget: + raise ValueError(f"K1 scratch {per_core} B per core exceeds {budget} B") + + +class SegmentReduce: + """K1 for one call site (static :class:`SegmentReduceSpec`). ``__call__(x, offsets, *, output=None)`` runs one + ``ttnn.generic_op`` (eagerly or inside a trace capture) and returns the ``[1, 1, num_segments, C]`` ROW_MAJOR + DRAM output (allocated per call unless ``output`` is given: a persistent buffer of that spec). + + ``x``: ``[1, 1, num_rows, C]`` interleaved DRAM tensor of the spec's dtype / layout, rows sorted by segment; + ``offsets``: uint32 ROW_MAJOR ``[1, K]`` DRAM (``K >= num_segments + 1``; :func:`check_offsets`), e.g. a + ``TraceRunner`` input rewritten per frame. The core grid is read from the device (never hard-coded); the split + across cores happens on the device, so ETH (12x10) and WORKER (11x10) runs give identical outputs.""" + + def __init__(self, spec: SegmentReduceSpec, *, name: str = "segment_reduce", grid: Optional[Tuple[int, int]] = None): + self.spec = spec + self.name = name + self.grid = grid # (x, y) cores; None = the device's compute grid + spec.check_l1() + + # ---- descriptor + def _grid(self, device: Any) -> Tuple[int, int]: + if self.grid is not None: + return int(self.grid[0]), int(self.grid[1]) + g = device.compute_with_storage_grid_size() + return int(g.x), int(g.y) + + def program(self, x: Any, offsets: Any, out: Any): + """The ``ttnn.ProgramDescriptor`` of one call (buffer addresses in common runtime args).""" + import ttnn + + spec = self.spec + gx, gy = self._grid(x.device()) + rect = ttnn.CoreRange(ttnn.CoreCoord(0, 0), ttnn.CoreCoord(gx - 1, gy - 1)) + crs = ttnn.CoreRangeSet([rect]) + slots = spec.workers_per_core + workers = gx * gy * slots + off_len = int(offsets.shape[-1]) + sizes = spec.l1_bytes_per_worker() + cbs, kernels = [], [] + configs = [ttnn.WriterConfigDescriptor(), ttnn.ReaderConfigDescriptor()][:slots] + accessors = (list(ttnn.TensorAccessorArgs(x).get_compile_time_args()) + + list(ttnn.TensorAccessorArgs(offsets).get_compile_time_args()) + + list(ttnn.TensorAccessorArgs(out).get_compile_time_args())) + for slot, config in enumerate(configs): + ids = [5 * slot + k for k in range(5)] # data, offsets, probe, acc, out + for cb_id, size in zip(ids, (sizes["data"], sizes["offsets"], sizes["probe"], sizes["acc"], + sizes["out"])): + cbs.append(ttnn.CBDescriptor(total_size=size, core_ranges=crs, format_descriptors=[ + ttnn.CBFormatDescriptor(buffer_index=cb_id, data_format=ttnn.uint32, page_size=size)])) + ct = [1 if spec.input_layout == "tile" else 0, spec.in_bytes, spec.out_bytes, spec.channels, + spec.num_rows, spec.num_segments, workers, slots, slot, 0, 0, gx, *ids, _OUT_RING, _OFF_CHUNK, + _f32_bits(spec.empty_value), off_len * 4] + accessors + kernels.append(ttnn.KernelDescriptor( + kernel_source=kernel_path(KERNEL), source_type=ttnn.KernelDescriptor.SourceType.FILE_PATH, + core_ranges=crs, compile_time_args=ct, defines=[], runtime_args=ttnn.RuntimeArgs(), + common_runtime_args=[x.buffer_address(), offsets.buffer_address(), out.buffer_address()], + config=config)) + return ttnn.ProgramDescriptor(kernels=kernels, semaphores=[], cbs=cbs) + + # ---- checks + def _check_inputs(self, x: Any, offsets: Any) -> None: + import ttnn + + spec = self.spec + shape = tuple(int(s) for s in x.shape) + if shape != (1, 1, spec.num_rows, spec.channels): + raise ValueError(f"{self.name}: x is {shape}, expected (1, 1, {spec.num_rows}, {spec.channels})") + want_dtype = ttnn.float32 if spec.input_dtype == "float32" else ttnn.bfloat16 + if x.dtype != want_dtype: + raise ValueError(f"{self.name}: x dtype {x.dtype}, expected {spec.input_dtype}") + want_layout = ttnn.TILE_LAYOUT if spec.input_layout == "tile" else ttnn.ROW_MAJOR_LAYOUT + if x.layout != want_layout: + raise ValueError(f"{self.name}: x layout {x.layout}, expected {spec.input_layout}") + oshape = tuple(int(s) for s in offsets.shape) + if len(oshape) != 2 or oshape[0] != 1 or oshape[1] < spec.num_segments + 1: + raise ValueError(f"{self.name}: offsets must be [1, K >= {spec.num_segments + 1}], got {oshape}") + if offsets.dtype != ttnn.uint32 or offsets.layout != ttnn.ROW_MAJOR_LAYOUT: + raise ValueError(f"{self.name}: offsets must be uint32 ROW_MAJOR") + for t, what in ((x, "x"), (offsets, "offsets")): + mc = t.memory_config() + if getattr(mc, "buffer_type", ttnn.BufferType.DRAM) != ttnn.BufferType.DRAM or \ + getattr(mc, "memory_layout", ttnn.TensorMemoryLayout.INTERLEAVED) != \ + ttnn.TensorMemoryLayout.INTERLEAVED: + raise ValueError(f"{self.name}: {what} must be DRAM interleaved") + + def output_spec(self): + """``(shape, ttnn dtype, layout)`` of the output.""" + import ttnn + + dt = ttnn.float32 if self.spec.output_dtype == "float32" else ttnn.bfloat16 + return (1, 1, self.spec.num_segments, self.spec.channels), dt, ttnn.ROW_MAJOR_LAYOUT + + def allocate_output(self, device: Any): + import ttnn + + shape, dt, layout = self.output_spec() + return ttnn.allocate_tensor_on_device(ttnn.Shape(list(shape)), dt, layout, device, ttnn.DRAM_MEMORY_CONFIG) + + def __call__(self, x: Any, offsets: Any, *, output: Any = None): + import ttnn + + self._check_inputs(x, offsets) + out = self.allocate_output(x.device()) if output is None else output + shape, dt, layout = self.output_spec() + if tuple(int(s) for s in out.shape) != shape or out.dtype != dt or out.layout != layout: + raise ValueError(f"{self.name}: output must be {shape} {dt} ROW_MAJOR") + return ttnn.generic_op([x, offsets, out], self.program(x, offsets, out)) + + def describe(self) -> Dict[str, Any]: + s = self.spec + return {"name": self.name, "kernel": KERNEL, "op": s.op, "num_rows": s.num_rows, "channels": s.channels, + "num_segments": s.num_segments, "input": f"{s.input_dtype}/{s.input_layout}", + "output": f"{s.output_dtype}/row_major", "empty_value": s.empty_value, + "workers_per_core": s.workers_per_core, "grid": list(self.grid) if self.grid else "device", + "l1_bytes_per_worker": s.l1_bytes_per_worker()} + + +def segment_reduce(x: Any, offsets: Any, *, num_segments: int, op: str = "max", output_dtype: Optional[str] = None, + empty_value: float = 0.0, output: Any = None): + """One-shot K1 (builds a :class:`SegmentReduce` from the tensors; ports build it once per call site).""" + import ttnn + + in_dtype = "float32" if x.dtype == ttnn.float32 else "bfloat16" + spec = SegmentReduceSpec(num_rows=int(x.shape[-2]), channels=int(x.shape[-1]), num_segments=int(num_segments), + input_dtype=in_dtype, input_layout="tile" if x.layout == ttnn.TILE_LAYOUT else + "row_major", output_dtype=output_dtype or in_dtype, op=op, empty_value=empty_value) + return SegmentReduce(spec)(x, offsets, output=output) + + +# -------------------------------------------------------------------------------------- stock-op fallback + +def logstep_segment_max(x: Any, shift_tables: Sequence[Any], last_rows: Any, *, layout: str = "tile"): + """The exact log-step segmented max with stock ops (PLAN.md 1.3; the K1 fallback): ``x`` bf16 ``[1, 1, R, C]`` + (TILE or ROW_MAJOR, ``C % 32 == 0``), ``shift_tables`` = ``rounds`` uint32 ``[1, R]`` device tensors + (:func:`segment_shift_tables`), ``last_rows`` uint32 ``[1, S]`` (:func:`segment_last_rows` with ``empty_row=R``) + -> bf16 ``[1, 1, S, C]`` (``layout``). Per round: untilize, ``ttnn.embedding`` gather, ``ttnn.maximum`` (3 + programs); then one PADDED gather from the result with a zero row appended at ``R`` (empty segments -> 0). + bf16 only (``ttnn.embedding`` tables are bf16); exact for segments of up to ``2^rounds`` rows.""" + import ttnn + + from .gather import gather_rows, with_zero_rows + + if x.dtype != ttnn.bfloat16: + raise ValueError("logstep_segment_max is bf16-only (ttnn.embedding); use K1 (SegmentReduce) for fp32") + cur = x if x.layout == ttnn.TILE_LAYOUT else ttnn.to_layout(x, ttnn.TILE_LAYOUT) + for idx in shift_tables: + table = ttnn.to_layout(cur, ttnn.ROW_MAJOR_LAYOUT) + shifted = gather_rows(idx, table, layout="tile") + nxt = ttnn.maximum(cur, shifted) + for t in (table, shifted): + ttnn.deallocate(t, False) + if cur is not x: + ttnn.deallocate(cur, False) + cur = nxt + rows = int(cur.shape[-2]) + table = with_zero_rows(cur) # [1, 1, R + 1, C] ROW_MAJOR, row R = 0 + out = gather_rows(last_rows, table, sentinel=rows, layout=layout) + ttnn.deallocate(table, False) + if cur is not x: + ttnn.deallocate(cur, False) + return out diff --git a/code/tt_frnet/ttaw/ops/topk.py b/code/tt_frnet/ttaw/ops/topk.py new file mode 100644 index 0000000000000000000000000000000000000000..689dfe4cf7f1103a07b8dd95575abde0f817be31 --- /dev/null +++ b/code/tt_frnet/ttaw/ops/topk.py @@ -0,0 +1,272 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C21: top-k query selection on the device (PLAN.md section 1.2 row C21; probe P8, common/PROBES.md). + +The query-based LiDAR detectors (TransFusion, BEVFusion, PTv3) pick the ``K`` best (class, cell) proposals of a +local-max heatmap ``[C, H*W]``. On the p150 that is ``ttnn.experimental.topk_large_indices`` (Blackhole only): one +bf16 ROW_MAJOR row, ``k`` a multiple of 16 in [16, 2048], uint32 indices in descending value order, ~4.3 ns per +element on one core (0.71 / 0.80 / 1.98 ms over 162k / 184k / 459k values, P8). What the helpers here guarantee: + +- **The row is class-major** (:func:`class_major_row`): index ``c * cells + p`` for class ``c`` and cell ``p`` -- + the ONNX ``TopK`` order of a ``[1, C, H*W]`` reshape, so reference (teacher-forcing) indices need no conversion. + The NHWC heatmap ``[1, 1, cells, C]`` (TILE) is transposed (one program), reshaped to one row while still tiled, + and untilized: no ROW_MAJOR reshape of wide pages, whose L1 staging (2 x the destination page, API.md 13) would + not fit PT's 0.9 MB row (TF's is 0.37 MB). +- **Exact decode** (:func:`decode_class_major`): the uint32 indices are cast to fp32 (exact below 2**24) and decoded + with comparisons only, ``cls = sum_c (idx >= c * cells)``, ``pos = idx - cls * cells`` -- no division, so no + device rounding can move a boundary -- clamped into range (a 0xFFFFFFFF sentinel can never become an + out-of-range gather index) and cast back to uint32 ROW_MAJOR ``[1, K]`` rows (``ttnn.embedding`` indices). +- **Ties are unspecified** (P8: the device does not break ties by lowest index), so every gate after the selection + is set-based (:func:`selection_metrics`) or teacher-forced; the host oracle :func:`topk_numpy` has ONNX semantics + (stable descending: ties to the lower index). +- **Never ``-inf`` masks**: surviving ``-inf`` values come back as index 0xFFFFFFFF (P8); mask with + :data:`MASK_VALUE` (-1e30, finite in bf16) instead. +- ``k`` is padded to a multiple of 16 (:func:`padded_k`; 500 -> 512); keep the first ``K`` of the descending output. + +The bf16 ``ttnn.topk`` composite also returns the values (P8: +8-98 us, exact values): :func:`topk_with_values`. An +fp32 value re-gather costs ~1 us per index through ``ttnn.gather`` (P13); a two-stage top-k (row-split first stage) +is optimization work (PLAN.md 5.3). + +numpy only at import; ttnn is imported inside the device functions. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +__all__ = ["K_MULTIPLE", "K_MIN", "K_MAX", "MASK_VALUE", "SENTINEL", "padded_k", "class_major_row", "topk_indices", + "decode_class_major", "topk_with_values", "TopKSelect", "topk_numpy", "class_major_numpy", + "decode_class_major_numpy", "selection_metrics", "near_tie_report"] + +K_MULTIPLE = 16 +K_MIN = 16 +K_MAX = 2048 +MASK_VALUE = -1.0e30 # masking value for scores (never -inf: P8 sentinel) +SENTINEL = 0xFFFFFFFF # index returned for a surviving -inf +FP32_EXACT = 1 << 24 # integers below are exact in fp32 (the decode's range) + + +def padded_k(k: int) -> int: + """``k`` rounded up to the multiple of 16 ``topk_large_indices`` takes (500 -> 512); 16 <= result <= 2048.""" + k = int(k) + if k <= 0: + raise ValueError(f"k must be positive, got {k}") + kp = max(K_MIN, -(-k // K_MULTIPLE) * K_MULTIPLE) + if kp > K_MAX: + raise ValueError(f"k={k} exceeds topk_large_indices' maximum {K_MAX}") + return kp + + +# ------------------------------------------------------------------------------------------------- device + +def class_major_row(heat_nhwc: Any, classes: int, cells: int, *, memory_config: Any = None): + """NHWC heatmap ``[1, 1, cells, classes]`` (TILE, bf16) -> the class-major bf16 ROW_MAJOR row + ``[1, classes * cells]`` of the top-k (transpose, TILE reshape, untilize: traceable).""" + import ttnn + + shape = tuple(int(s) for s in heat_nhwc.shape) + if shape != (1, 1, int(cells), int(classes)): + raise ValueError(f"heat must be [1, 1, {cells}, {classes}], got {shape}") + if heat_nhwc.dtype != ttnn.bfloat16: + raise ValueError(f"topk_large_indices takes bf16 scores, got {heat_nhwc.dtype}") + kw = {} if memory_config is None else {"memory_config": memory_config} + n = int(classes) * int(cells) + t = ttnn.transpose(heat_nhwc, 2, 3, **kw) # [1, 1, classes, cells] TILE + flat = ttnn.reshape(t, (1, 1, 1, n)) # the row while still tiled (TILE reshape) + ttnn.deallocate(t, False) + rm = ttnn.to_layout(flat, ttnn.ROW_MAJOR_LAYOUT) # one row, untilized + ttnn.deallocate(flat, False) + return ttnn.reshape(rm, (1, n)) + + +def topk_indices(row: Any, k: int): + """``topk_large_indices`` of a bf16 ROW_MAJOR ``[rows, n]`` tensor -> uint32 ROW_MAJOR ``[rows, padded_k(k)]``, + descending (ties unspecified). Refuses an ``n`` beyond fp32's exact integers (the decode's range).""" + import ttnn + + n = int(row.shape[-1]) + kp = padded_k(k) + if n < kp: + raise ValueError(f"the row has {n} values, fewer than k={kp}") + if n > FP32_EXACT: + raise ValueError(f"row of {n} values: indices above 2**24 are not exact in the fp32 decode") + if row.dtype != ttnn.bfloat16 or row.layout != ttnn.ROW_MAJOR_LAYOUT: + raise ValueError("topk_large_indices takes a bf16 ROW_MAJOR row") + return ttnn.experimental.topk_large_indices(row, k=kp) + + +def decode_class_major(indices: Any, cells: int, classes: int, *, clamp: bool = True) -> Tuple[Any, Any]: + """Class-major flat indices (uint32, ROW_MAJOR ``[1, K]`` or TILE) -> ``(pos, cls)`` uint32 ROW_MAJOR ``[1, K]`` + with ``idx = cls * cells + pos`` (module docstring: fp32 comparisons, exact; ``clamp`` keeps them in range).""" + import ttnn + + cells, classes = int(cells), int(classes) + if cells * classes > FP32_EXACT: + raise ValueError("cells * classes exceeds fp32's exact integers") + it = indices if indices.layout == ttnn.TILE_LAYOUT else ttnn.to_layout(indices, ttnn.TILE_LAYOUT) + f = ttnn.typecast(it, ttnn.float32) + if it is not indices: + ttnn.deallocate(it, False) + cls = None + for c in range(1, classes): + ge = ttnn.ge(f, float(c * cells)) + if cls is None: + cls = ge + else: + s = ttnn.add(cls, ge) + ttnn.deallocate(cls, False) + ttnn.deallocate(ge, False) + cls = s + if cls is None: # one class: cls = 0, pos = idx + pos = f + cls = ttnn.multiply(f, 0.0) + else: + off = ttnn.multiply(cls, float(cells)) + pos = ttnn.subtract(f, off) + ttnn.deallocate(off, False) + ttnn.deallocate(f, False) + if clamp: + p2 = ttnn.clamp(pos, 0.0, float(cells - 1)) + c2 = ttnn.clamp(cls, 0.0, float(classes - 1)) + ttnn.deallocate(pos, False) + ttnn.deallocate(cls, False) + pos, cls = p2, c2 + out = [] + for t in (pos, cls): + u = ttnn.typecast(t, ttnn.uint32) + ttnn.deallocate(t, False) + r = ttnn.to_layout(u, ttnn.ROW_MAJOR_LAYOUT) + ttnn.deallocate(u, False) + out.append(r) + return out[0], out[1] + + +def topk_with_values(scores_tile: Any, k: int): + """The bf16 ``ttnn.topk`` composite on a TILE ``[.., n]`` row: ``(values bf16, indices)`` of the top ``k`` + (P8: exact values, in-range indices even with ``-inf``; ties unspecified).""" + import ttnn + + return ttnn.topk(scores_tile, k=int(k), dim=-1, largest=True, sorted=True) + + +class TopKSelect: + """Top-``k`` of a ``[1, 1, cells, classes]`` bf16 TILE heatmap -> uint32 class-major indices ``[1, padded_k]`` + (descending) plus the decoded ``(pos, cls)`` rows: :func:`class_major_row`, :func:`topk_indices`, + :func:`decode_class_major`. ``keep`` (default ``k``) proposals are meaningful; the rows hold ``padded_k(k)``.""" + + def __init__(self, cells: int, classes: int, k: int, *, name: str = "topk"): + self.cells, self.classes, self.k = int(cells), int(classes), int(k) + self.kp = padded_k(self.k) + if self.cells * self.classes < self.kp: + raise ValueError(f"{name}: {self.cells * self.classes} scores for k={self.kp}") + self.name = name + + def indices(self, heat_nhwc: Any): + row = class_major_row(heat_nhwc, self.classes, self.cells) + idx = topk_indices(row, self.kp) + import ttnn + + ttnn.deallocate(row, False) + return idx + + def decode(self, indices: Any) -> Tuple[Any, Any]: + return decode_class_major(indices, self.cells, self.classes) + + def __call__(self, heat_nhwc: Any) -> Tuple[Any, Any, Any]: + """-> ``(indices, pos, cls)``, each uint32 ROW_MAJOR ``[1, padded_k]``.""" + idx = self.indices(heat_nhwc) + pos, cls = self.decode(idx) + return idx, pos, cls + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "cells": self.cells, "classes": self.classes, "k": self.k, "padded_k": self.kp, + "order": "class-major (idx = cls * cells + pos)", "ties": "unspecified (P8)"} + + +# --------------------------------------------------------------------------------------------------- host + +def topk_numpy(scores: Any, k: int) -> Tuple[np.ndarray, np.ndarray]: + """ONNX ``TopK(largest=1, sorted=1)`` of a 1-D score vector: ``(values, indices)``, values descending, ties in + ascending index order (a stable descending sort).""" + s = np.asarray(scores).reshape(-1) + order = np.argsort(-s.astype(np.float64), kind="stable")[: int(k)] + return s[order], order.astype(np.int64) + + +def class_major_numpy(heat_nhwc: Any) -> np.ndarray: + """NHWC rows ``[cells, classes]`` -> the class-major flat vector of :func:`class_major_row`.""" + h = np.asarray(heat_nhwc) + return np.ascontiguousarray(h.reshape(-1, h.shape[-1]).T).reshape(-1) + + +def decode_class_major_numpy(indices: Any, cells: int) -> Tuple[np.ndarray, np.ndarray]: + """Host decode: ``(pos, cls)`` int64 of class-major flat indices.""" + i = np.asarray(indices).astype(np.int64).reshape(-1) + return i % int(cells), i // int(cells) + + +def selection_metrics(device_indices: Any, reference_indices: Any, *, reference_scores: Optional[Any] = None, + flat_scores: Optional[Any] = None, strong: float = 0.1, k: Optional[int] = None + ) -> Dict[str, Any]: + """Set-based agreement of a device top-k with the reference's (PLAN.md 2.5 / 0.3 item 1). + + ``device_indices`` / ``reference_indices``: flat indices (the first ``k`` of each are compared, default the + reference length). ``reference_scores``: the reference values of ``reference_indices`` (same order) or + ``flat_scores``: the reference score of every flat index. Returns ``shared`` (count) / ``shared_frac``, and with + scores the strong proposals (reference score >= ``strong``): ``strong_total``, ``strong_kept``, + ``strong_overlap`` (fraction; 1.0 when there is none), ``strong_missed`` (indices).""" + d = np.asarray(device_indices).astype(np.int64).reshape(-1) + r = np.asarray(reference_indices).astype(np.int64).reshape(-1) + k = len(r) if k is None else int(k) + d, r = d[:k], r[:k] + ds = set(d.tolist()) + out: Dict[str, Any] = {"k": k, "shared": len(ds & set(r.tolist())), "unique": len(ds) == len(d)} + out["device_only"] = sorted(ds - set(r.tolist())) + out["shared_frac"] = out["shared"] / max(k, 1) + if reference_scores is not None or flat_scores is not None: + if reference_scores is not None: + vals = np.asarray(reference_scores, np.float64).reshape(-1)[:k] + strong_set = set(r[vals >= strong].tolist()) + else: + fs = np.asarray(flat_scores, np.float64).reshape(-1) + strong_set = set(np.flatnonzero(fs >= strong).tolist()) + missed = sorted(strong_set - ds) + out.update({"strong_threshold": float(strong), "strong_total": len(strong_set), + "strong_kept": len(strong_set) - len(missed), + "strong_overlap": 1.0 if not strong_set else (len(strong_set) - len(missed)) / len(strong_set), + "strong_missed": missed}) + return out + + +def near_tie_report(missed: Sequence[int], device_indices: Any, reference_heat: Any, height: int, width: int, + *, kernel: int = 3) -> List[Dict[str, Any]]: + """Why a reference proposal is missing from a device selection (diagnostics of :func:`selection_metrics`). + + ``reference_heat``: the reference's class-major sigmoid heat BEFORE the local max (``[C * H * W]`` or + ``[C, H, W]``). For each missed class-major index: its heat, the strongest same-class cell of its ``kernel x + kernel`` window that the DEVICE selected, and the relative gap ``(heat_missed - heat_neighbour) / heat_missed`` + (a few 0.1 % = a local-max near-tie: the neighbour won the device's 3x3 comparison; a bf16 heat holds 3 + significant digits). ``neighbour`` is None when no same-class neighbour was selected.""" + heat = np.asarray(reference_heat, np.float64).reshape(-1) + cells = int(height) * int(width) + sel = set(np.asarray(device_indices).astype(np.int64).reshape(-1).tolist()) + r = int(kernel) // 2 + out: List[Dict[str, Any]] = [] + for idx in missed: + c, p = divmod(int(idx), cells) + y, x = divmod(p, int(width)) + best = None + for dy in range(-r, r + 1): + for dx in range(-r, r + 1): + yy, xx = y + dy, x + dx + if (dy or dx) and 0 <= yy < height and 0 <= xx < width: + q = c * cells + yy * int(width) + xx + if q in sel and (best is None or heat[q] > heat[best]): + best = q + rec: Dict[str, Any] = {"index": int(idx), "class": c, "cell": [y, x], "heat": float(heat[idx]), + "neighbour": None if best is None else int(best)} + if best is not None: + rec["neighbour_heat"] = float(heat[best]) + rec["relative_gap"] = float((heat[idx] - heat[best]) / max(heat[idx], 1e-30)) + out.append(rec) + return out diff --git a/code/tt_frnet/ttaw/ops/upsample.py b/code/tt_frnet/ttaw/ops/upsample.py new file mode 100644 index 0000000000000000000000000000000000000000..9ebdc8b605c9a576df24b4a1df76ff72f757ac37 --- /dev/null +++ b/code/tt_frnet/ttaw/ops/upsample.py @@ -0,0 +1,203 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C18: up-sampling and interpolation helpers for NHWC feature maps (PLAN.md section 1.2 row C18). + +- :func:`upsample_nearest`: integer-factor nearest up-sampling through ``ttnn.upsample`` (floor indexing, exactly + ``F.interpolate(mode="nearest")`` and ONNX Resize nearest / asymmetric / floor: S:yolox section 4.3). ttnn's + nearest path takes ROW_MAJOR (interleaved or height / block sharded) or a TILE input whose W and C are multiples + of 32 (``upsample_common.cpp:25-65``) and always writes ROW_MAJOR, so the map goes TILE -> ROW_MAJOR -> + ``[N, H, W, C]`` -> upsample -> ``[1, 1, N*sH*H*sW*W, C]`` -> TILE (``output_layout``). Bit-exact (a data move). +- :func:`interp_matrix`: the 1-D interpolation matrix ``A [out, in]`` of ``F.interpolate`` (``"nearest"`` floor + rule, ``"linear"`` with ``align_corners`` True / False, the half-pixel rule clamped at 0 like torch), host numpy + float64. A 2-D resize is ``Y_c = A_h X_c A_w^T`` per channel (S:frnet:449, S:bevdet:488, S:meteor:497). +- :class:`Resize2d`: that separable resize on the device for any sizes (integer or not), e.g. the + ``align_corners=True`` bilinear of FRNet / BEVDet's FPN_LSS, which ``ttnn.upsample`` cannot do (its bilinear path + is half-pixel only, integer scales, sharded bf16 input). The W pass is a matmul over the transposed rows + (``[N*H, C, W] @ A_w^T``), the H pass ``A_h @ [H, W'*C]`` per image (``kron(I_N, A_h)`` for N > 1). Default + operands are float32 with HiFi4 + fp32 accumulation, because bf16 cannot hold weights such as 511/1023 (S:frnet:449) + and a device fp32 matmul is TF32-like (~1.5e-3 relative, probe P12); ``dtype="bfloat16"`` halves the traffic. + Static non-integer resizes may also use a precomputed ``grid_sample`` grid (probe P9: int16 corner indices exact, + bf16 weights; 1.7x faster than fp32 grids) through C23. +""" +from __future__ import annotations + +from typing import Any, Dict, Optional, Sequence, Tuple, Union + +import numpy as np + +from ..precision import Precision +from ..tensors import dtype_name, ttnn_dtype +from .conv import FeatureMap, _layout, _memory_config + +__all__ = ["upsample_nearest", "interp_matrix", "resize_matrices", "Resize2d"] + + +def _pair(value: Union[int, Sequence[int]]) -> Tuple[int, int]: + if isinstance(value, (int, np.integer)): + return int(value), int(value) + a, b = (int(x) for x in value) + return a, b + + +def upsample_nearest(fm: FeatureMap, scale: Union[int, Sequence[int]] = 2, *, output_layout: Any = "tile", + memory_config: Any = "dram") -> FeatureMap: + """Nearest up-sampling by integer factors (bit-exact). Returns ``[1, 1, N*sH*H*sW*W, C]`` in + ``output_layout`` (TILE by default, what a following conv / concat wants).""" + import ttnn + + sh, sw = _pair(scale) + if sh < 1 or sw < 1: + raise ValueError(f"scale must be positive integers, got {scale!r}") + t = fm.tensor + if t.layout != ttnn.ROW_MAJOR_LAYOUT: + t = ttnn.to_layout(t, ttnn.ROW_MAJOR_LAYOUT) + t = ttnn.reshape(t, (fm.batch, fm.height, fm.width, fm.channels)) + up = ttnn.upsample(t, (sh, sw), mode="nearest", memory_config=_memory_config(memory_config)) + out = ttnn.reshape(up, (1, 1, fm.batch * fm.height * sh * fm.width * sw, fm.channels)) + if _layout(output_layout) == ttnn.TILE_LAYOUT: + out = ttnn.to_layout(out, ttnn.TILE_LAYOUT) + return FeatureMap(out, fm.batch, fm.height * sh, fm.width * sw, fm.channels) + + +def interp_matrix(in_size: int, out_size: int, *, mode: str = "linear", align_corners: bool = False, + scale: Optional[float] = None) -> np.ndarray: + """``A`` (float64, ``[out_size, in_size]``) with ``y = A @ x`` equal to 1-D ``F.interpolate`` of ``x``. + + ``mode``: ``"nearest"`` (``src = min(floor(dst * s), in - 1)``) or ``"linear"`` (``align_corners=True``: + ``src = dst * (in - 1) / (out - 1)``; False: ``src = max((dst + 0.5) * s - 0.5, 0)``). ``s`` is ``in / out``, + or ``1 / scale`` when the caller passed ``scale_factor=scale`` to torch (its default + ``recompute_scale_factor=None`` keeps the given factor).""" + if in_size < 1 or out_size < 1: + raise ValueError(f"sizes must be positive, got {in_size} -> {out_size}") + s = (1.0 / float(scale)) if scale is not None else in_size / out_size + dst = np.arange(out_size, dtype=np.float64) + a = np.zeros((out_size, in_size), np.float64) + rows = np.arange(out_size) + if mode == "nearest": + if align_corners: + raise ValueError("align_corners applies to linear modes only") + # torch's nearest_idx: floorf(dst * (float)scale) in float32 (the exact 2x case equals dst >> 1) + prod = (dst.astype(np.float32) * np.float32(s)).astype(np.float32) + src = np.minimum(np.floor(prod).astype(np.int64), in_size - 1) + a[rows, src] = 1.0 + return a + if mode != "linear": + raise ValueError(f"mode {mode!r}: expected 'nearest' or 'linear'") + if align_corners: + src = dst * ((in_size - 1) / (out_size - 1)) if out_size > 1 else np.zeros(out_size) + else: + src = np.maximum((dst + 0.5) * s - 0.5, 0.0) + i0 = np.minimum(np.floor(src).astype(np.int64), in_size - 1) + i1 = np.minimum(i0 + 1, in_size - 1) + lam = src - i0 + np.add.at(a, (rows, i0), 1.0 - lam) + np.add.at(a, (rows, i1), lam) + return a + + +def resize_matrices(in_hw: Tuple[int, int], out_hw: Tuple[int, int], *, mode: str = "linear", + align_corners: bool = False, scale: Optional[Union[float, Sequence[float]]] = None + ) -> Tuple[np.ndarray, np.ndarray]: + """``(A_h [H', H], A_w [W', W])`` of a 2-D resize: ``Y_c = A_h @ X_c @ A_w.T``.""" + sh, sw = (None, None) if scale is None else (_pair_f(scale)) + return (interp_matrix(in_hw[0], out_hw[0], mode=mode, align_corners=align_corners, scale=sh), + interp_matrix(in_hw[1], out_hw[1], mode=mode, align_corners=align_corners, scale=sw)) + + +def _pair_f(value: Union[float, Sequence[float]]) -> Tuple[float, float]: + if isinstance(value, (int, float, np.floating, np.integer)): + return float(value), float(value) + a, b = (float(x) for x in value) + return a, b + + +class Resize2d: + """Separable 2-D resize of an NHWC feature map on the device (see the module docstring). + + Args: + in_hw, out_hw: input / output spatial size. ``channels``, ``batch``: map size (static: one instance per + call site). + mode, align_corners, scale: as :func:`interp_matrix`. + dtype: operand dtype of the two matmuls (``"float32"`` default; the input is typecast, the output cast + back to ``output_dtype``). + precision: compute config of the matmuls (``"accurate"``: HiFi4 + fp32 accumulation; never LoFi for fp32 + operands, probe P12). + Matrices are uploaded on the first call (warm-up), never inside a capture.""" + + def __init__(self, in_hw: Tuple[int, int], out_hw: Tuple[int, int], *, channels: int, batch: int = 1, + mode: str = "linear", align_corners: bool = False, scale: Optional[Any] = None, + dtype: Any = "float32", output_dtype: Any = "bfloat16", precision: Any = "accurate", + output_layout: Any = "tile", name: str = "resize"): + self.name = name + self.in_hw = (int(in_hw[0]), int(in_hw[1])) + self.out_hw = (int(out_hw[0]), int(out_hw[1])) + self.channels, self.batch = int(channels), int(batch) + self.mode, self.align_corners = mode, bool(align_corners) + self.a_h, self.a_w = resize_matrices(self.in_hw, self.out_hw, mode=mode, align_corners=align_corners, + scale=scale) + self.dtype = dtype_name(dtype) + self.output_dtype = dtype_name(output_dtype) + self.precision = Precision.parse(precision) + self.output_layout = output_layout + self._dev: Optional[Tuple[Any, Any]] = None + + def host_matrices(self) -> Tuple[np.ndarray, np.ndarray]: + """The device operands: ``A_w^T [W, W']`` and ``kron(I_N, A_h) [N*H', N*H]`` (float32).""" + aw_t = np.ascontiguousarray(self.a_w.T, np.float32) + ah = self.a_h if self.batch == 1 else np.kron(np.eye(self.batch), self.a_h) + return aw_t, np.ascontiguousarray(ah, np.float32) + + def reference(self, nchw: np.ndarray) -> np.ndarray: + """Host float64 result of the same matrices (tests).""" + x = np.asarray(nchw, np.float64) + return np.einsum("ph,nchw,qw->ncpq", self.a_h, x, self.a_w) + + def __call__(self, fm: FeatureMap) -> FeatureMap: + import torch + import ttnn + + n, (h, w), c = self.batch, self.in_hw, self.channels + (h2, w2) = self.out_hw + if (fm.batch, fm.height, fm.width, fm.channels) != (n, h, w, c): + got = (fm.batch, fm.height, fm.width, fm.channels) + raise ValueError(f"{self.name}: built for {(n, h, w, c)}, got {got}") + dt = ttnn_dtype(self.dtype) + cfg = self.precision.compute_kernel_config() + if self._dev is None: + aw_t, ah = self.host_matrices() + dev = fm.tensor.device() + self._dev = (ttnn.from_torch(torch.from_numpy(aw_t), dtype=dt, layout=ttnn.TILE_LAYOUT, device=dev), + ttnn.from_torch(torch.from_numpy(ah), dtype=dt, layout=ttnn.TILE_LAYOUT, device=dev)) + aw_t_dev, ah_dev = self._dev + x = fm.tensor + if x.layout != ttnn.ROW_MAJOR_LAYOUT: + x = ttnn.to_layout(x, ttnn.ROW_MAJOR_LAYOUT) + x = ttnn.reshape(x, (1, n * h, w, c)) + x = ttnn.to_layout(x, ttnn.TILE_LAYOUT) + if x.dtype != dt: + x = ttnn.typecast(x, dt) + xt = ttnn.transpose(x, -2, -1) # [1, N*H, C, W] + y = ttnn.matmul(xt, aw_t_dev, compute_kernel_config=cfg) # [1, N*H, C, W'] + y = ttnn.transpose(y, -2, -1) # [1, N*H, W', C] + y = ttnn.to_layout(y, ttnn.ROW_MAJOR_LAYOUT) + y = ttnn.reshape(y, (1, 1, n * h, w2 * c)) + y = ttnn.to_layout(y, ttnn.TILE_LAYOUT) + z = ttnn.matmul(ah_dev, y, compute_kernel_config=cfg) # [1, 1, N*H', W'*C] + if z.dtype != ttnn_dtype(self.output_dtype): + z = ttnn.typecast(z, ttnn_dtype(self.output_dtype)) # TILE typecast (every dtype pair) + z = ttnn.to_layout(z, ttnn.ROW_MAJOR_LAYOUT) + z = ttnn.reshape(z, (1, 1, n * h2 * w2, c)) + if _layout(self.output_layout) == ttnn.TILE_LAYOUT: + z = ttnn.to_layout(z, ttnn.TILE_LAYOUT) + return FeatureMap(z, n, h2, w2, c) + + def release(self) -> None: + import ttnn + + for t in self._dev or (): + if t.is_allocated(): + ttnn.deallocate(t) + self._dev = None + + def describe(self) -> Dict[str, Any]: + return {"name": self.name, "in_hw": list(self.in_hw), "out_hw": list(self.out_hw), "channels": self.channels, + "batch": self.batch, "mode": self.mode, "align_corners": self.align_corners, "dtype": self.dtype} diff --git a/code/tt_frnet/ttaw/outputs.py b/code/tt_frnet/ttaw/outputs.py new file mode 100644 index 0000000000000000000000000000000000000000..34e3c034edce2da9e38c88516edce8994ac222c8 --- /dev/null +++ b/code/tt_frnet/ttaw/outputs.py @@ -0,0 +1,283 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C08 outputs: the result classes of the 13 bundles and their ``POST /predict`` JSON (BUNDLE_CONVENTIONS.md 7.4). + +Every class has ``to_dicts()`` (one plain dict per detection / pose / class) and ``to_dict(output_format)`` (the +whole response body: ``model``, ``frame_id``, the payload, ``meta``, ``timing_ms``). The Python API returns these +objects and the server returns ``to_dict(...)`` of the same object, so both agree bit for bit. + +================== ================================= ================================================== +class families payload +================== ================================= ================================================== +``Detections3D`` CenterPoint, TransFusion, BEVFusion, ``detections`` [label, label_id, score, center, size, + PointPainting, StreamPETR, BEVDet, yaw, velocity], ``num_detections``; rows sorted by + BEVFormer, PTv3-det descending score at construction +``Detections2D`` YOLOX ``detections`` [label, label_id, score, box_xyxy] + + ``extras`` (e.g. ``{"semseg": Mask2D}``) +``Segmentation3D`` FRNet, PTv3-seg ``labels`` (npz uint8/uint16 [N]), ``scores``, + ``class_names``, ``class_counts`` +``Mask2D`` SceneSeg (YOLOX semseg extra) ``mask`` (png or npz), ``class_names``, ``class_counts`` +``Trajectory`` Diffusion Planner ``trajectory`` [[x, y, yaw, ...] x T], ``columns``, + ``turn_indicator``, ``predicted_agents`` +================== ================================= ================================================== +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence + +import numpy as np + +from .io import encode_array, encode_png, to_jsonable + +__all__ = ["Detections3D", "Detections2D", "Segmentation3D", "Mask2D", "Trajectory", "label_name"] + + +def label_name(labels: Sequence[str], label_id: int) -> str: + """``labels[label_id]``, or the id as text when it is out of range.""" + return labels[label_id] if 0 <= label_id < len(labels) else str(label_id) + + +def _envelope(model: str, frame_id: str, meta: dict, timing_ms: dict) -> Dict[str, Any]: + return {"model": model, "frame_id": frame_id, "meta": to_jsonable(meta), + "timing_ms": {k: round(float(v), 3) for k, v in timing_ms.items()}} + + +def _sorted_by_score(scores: np.ndarray) -> np.ndarray: + return np.argsort(-scores, kind="stable") + + +@dataclass +class Detections3D: + """3D boxes in ``frame_id`` (Autoware ``base_link``: x forward, y left, z up; metres, radians). + + ``boxes`` float32 ``[N, 7]`` = x, y, z (box centre), length, width, height, yaw; ``scores`` ``[N]``; + ``label_ids`` int32 ``[N]`` (index into ``labels``); ``velocities`` ``[N, 2]`` (vx, vy) m/s or None. + Rows are sorted by descending score (stable) at construction.""" + + boxes: np.ndarray + scores: np.ndarray + label_ids: np.ndarray + velocities: Optional[np.ndarray] = None + labels: Sequence[str] = () + model: str = "" + frame_id: str = "base_link" + timing_ms: dict = field(default_factory=dict) + meta: dict = field(default_factory=dict) + + def __post_init__(self) -> None: + self.boxes = np.asarray(self.boxes, np.float32).reshape(-1, 7) + self.scores = np.asarray(self.scores, np.float32).reshape(-1) + self.label_ids = np.asarray(self.label_ids, np.int32).reshape(-1) + n = len(self.scores) + if len(self.boxes) != n or len(self.label_ids) != n: + raise ValueError(f"boxes {self.boxes.shape}, scores {self.scores.shape}, labels {self.label_ids.shape}") + if self.velocities is not None: + self.velocities = np.asarray(self.velocities, np.float32).reshape(n, 2) + order = _sorted_by_score(self.scores) + self.boxes, self.scores, self.label_ids = self.boxes[order], self.scores[order], self.label_ids[order] + if self.velocities is not None: + self.velocities = self.velocities[order] + + def __len__(self) -> int: + return int(self.scores.shape[0]) + + @property + def label_names(self) -> List[str]: + return [label_name(self.labels, i) for i in self.label_ids.tolist()] + + def to_dicts(self) -> List[Dict[str, Any]]: + out = [] + for i, name in enumerate(self.label_names): + x, y, z, length, width, height, yaw = (float(v) for v in self.boxes[i]) + d = {"label": name, "label_id": int(self.label_ids[i]), "score": round(float(self.scores[i]), 4), + "center": [round(x, 3), round(y, 3), round(z, 3)], + "size": [round(length, 3), round(width, 3), round(height, 3)], "yaw": round(yaw, 4)} + if self.velocities is not None: + d["velocity"] = [round(float(v), 3) for v in self.velocities[i]] + out.append(d) + return out + + def to_dict(self, output_format: str = "json") -> Dict[str, Any]: + body = _envelope(self.model, self.frame_id, self.meta, self.timing_ms) + body.update(num_detections=len(self), detections=self.to_dicts()) + if output_format == "npz": # lossless arrays for programmatic clients + body["arrays"] = {"boxes": encode_array(self.boxes, key="boxes"), + "scores": encode_array(self.scores, key="scores"), + "label_ids": encode_array(self.label_ids, key="label_ids")} + if self.velocities is not None: + body["arrays"]["velocities"] = encode_array(self.velocities, key="velocities") + return body + + +@dataclass +class Mask2D: + """A per-pixel class map (H, W) (uint8 for <= 256 classes) in the source image's pixel grid.""" + + mask: np.ndarray + class_names: Sequence[str] = () + model: str = "" + frame_id: str = "camera" + encoding: str = "png" + timing_ms: dict = field(default_factory=dict) + meta: dict = field(default_factory=dict) + + def __post_init__(self) -> None: + self.mask = np.asarray(self.mask) + if self.mask.ndim != 2: + raise ValueError(f"mask must be (H, W), got {self.mask.shape}") + if self.encoding not in ("png", "npz"): + raise ValueError("encoding must be 'png' or 'npz'") + + def class_counts(self) -> Dict[str, int]: + ids, counts = np.unique(self.mask, return_counts=True) + return {label_name(self.class_names, int(i)): int(c) for i, c in zip(ids, counts)} + + def to_dicts(self) -> List[Dict[str, Any]]: + ids, counts = np.unique(self.mask, return_counts=True) + return [{"label": label_name(self.class_names, int(i)), "label_id": int(i), "pixels": int(c)} + for i, c in zip(ids, counts)] + + def payload(self, output_format: str = "json") -> Dict[str, Any]: + """The encoded mask alone (also used when the mask is an extra of another output).""" + if self.encoding == "png" and output_format != "npz" and self.mask.dtype == np.uint8: + return encode_png(self.mask, key="mask") + return encode_array(self.mask, key="mask") + + def to_dict(self, output_format: str = "json") -> Dict[str, Any]: + body = _envelope(self.model, self.frame_id, self.meta, self.timing_ms) + body.update(mask=self.payload(output_format), class_names=list(self.class_names), + class_counts=self.class_counts()) + return body + + +@dataclass +class Detections2D: + """2-D boxes in original image pixels: ``boxes_xyxy`` float32 ``[N, 4]``, ``scores``, ``label_ids``. + ``extras`` holds companion outputs encoded into the body by name (``{"semseg": Mask2D(...)}``). + Rows are sorted by descending score (stable) at construction.""" + + boxes_xyxy: np.ndarray + scores: np.ndarray + label_ids: np.ndarray + labels: Sequence[str] = () + extras: Dict[str, Any] = field(default_factory=dict) + model: str = "" + frame_id: str = "camera" + timing_ms: dict = field(default_factory=dict) + meta: dict = field(default_factory=dict) + + def __post_init__(self) -> None: + self.boxes_xyxy = np.asarray(self.boxes_xyxy, np.float32).reshape(-1, 4) + self.scores = np.asarray(self.scores, np.float32).reshape(-1) + self.label_ids = np.asarray(self.label_ids, np.int32).reshape(-1) + if not (len(self.boxes_xyxy) == len(self.scores) == len(self.label_ids)): + raise ValueError("boxes_xyxy, scores and label_ids lengths differ") + order = _sorted_by_score(self.scores) + self.boxes_xyxy, self.scores, self.label_ids = self.boxes_xyxy[order], self.scores[order], self.label_ids[order] + + def __len__(self) -> int: + return int(self.scores.shape[0]) + + def to_dicts(self) -> List[Dict[str, Any]]: + return [{"label": label_name(self.labels, int(self.label_ids[i])), "label_id": int(self.label_ids[i]), + "score": round(float(self.scores[i]), 4), + "box_xyxy": [round(float(v), 2) for v in self.boxes_xyxy[i]]} for i in range(len(self))] + + def to_dict(self, output_format: str = "json") -> Dict[str, Any]: + body = _envelope(self.model, self.frame_id, self.meta, self.timing_ms) + body.update(num_detections=len(self), detections=self.to_dicts()) + for name, extra in self.extras.items(): + if hasattr(extra, "payload"): + body[name] = extra.payload(output_format) + elif isinstance(extra, np.ndarray): + body[name] = encode_array(extra, key=name) + else: + body[name] = to_jsonable(extra) + if output_format == "npz": + body["arrays"] = {"boxes_xyxy": encode_array(self.boxes_xyxy, key="boxes_xyxy"), + "scores": encode_array(self.scores, key="scores"), + "label_ids": encode_array(self.label_ids, key="label_ids")} + return body + + +@dataclass +class Segmentation3D: + """Per-point classes, in input point order after NaN removal (stated in SERVING.md). ``label_ids`` ``[N]``; + ``scores`` optional ``[N]`` (winning-class probability) or ``[N, C]``.""" + + label_ids: np.ndarray + class_names: Sequence[str] = () + scores: Optional[np.ndarray] = None + model: str = "" + frame_id: str = "base_link" + timing_ms: dict = field(default_factory=dict) + meta: dict = field(default_factory=dict) + + def __post_init__(self) -> None: + ids = np.asarray(self.label_ids).reshape(-1) + if ids.size and ids.min() < 0: + raise ValueError("label ids must be >= 0") + self.label_ids = ids.astype(np.uint8 if (not ids.size or ids.max() < 256) else np.uint16) + if self.scores is not None: + self.scores = np.asarray(self.scores, np.float32) + if self.scores.shape[0] != ids.shape[0]: + raise ValueError("scores and label_ids lengths differ") + + def __len__(self) -> int: + return int(self.label_ids.shape[0]) + + def class_counts(self) -> Dict[str, int]: + counts = np.bincount(self.label_ids.astype(np.int64), minlength=len(self.class_names)) + return {label_name(self.class_names, i): int(c) for i, c in enumerate(counts) if c or i < len(self.class_names)} + + def to_dicts(self) -> List[Dict[str, Any]]: + counts = np.bincount(self.label_ids.astype(np.int64), minlength=len(self.class_names)) + return [{"label": label_name(self.class_names, i), "label_id": i, "points": int(c)} + for i, c in enumerate(counts)] + + def to_dict(self, output_format: str = "json") -> Dict[str, Any]: + body = _envelope(self.model, self.frame_id, self.meta, self.timing_ms) + body.update(num_points=len(self), labels=encode_array(self.label_ids, key="labels"), + class_names=list(self.class_names), class_counts=self.class_counts()) + if self.scores is not None: + body["scores"] = encode_array(self.scores, key="scores") + return body + + +@dataclass +class Trajectory: + """A planned trajectory ``poses`` ``[T, D]`` whose columns are named by ``columns`` (default x, y, yaw) in + ``frame_id``; optional ``turn_indicator`` and ``predicted_agents`` ``[A, T, D']``.""" + + poses: np.ndarray + columns: Sequence[str] = ("x", "y", "yaw") + turn_indicator: Any = None + predicted_agents: Optional[np.ndarray] = None + model: str = "" + frame_id: str = "base_link" + timing_ms: dict = field(default_factory=dict) + meta: dict = field(default_factory=dict) + + def __post_init__(self) -> None: + self.poses = np.asarray(self.poses, np.float32) + if self.poses.ndim != 2 or self.poses.shape[1] != len(self.columns): + raise ValueError(f"poses {self.poses.shape} do not match columns {list(self.columns)}") + if self.predicted_agents is not None: + self.predicted_agents = np.asarray(self.predicted_agents, np.float32) + + def __len__(self) -> int: + return int(self.poses.shape[0]) + + def to_dicts(self) -> List[Dict[str, Any]]: + return [{c: round(float(v), 4) for c, v in zip(self.columns, row)} for row in self.poses] + + def to_dict(self, output_format: str = "json") -> Dict[str, Any]: + body = _envelope(self.model, self.frame_id, self.meta, self.timing_ms) + body.update(num_poses=len(self), columns=list(self.columns), + trajectory=[[round(float(v), 4) for v in row] for row in self.poses], + turn_indicator=to_jsonable(self.turn_indicator)) + if self.predicted_agents is not None: + body["predicted_agents"] = encode_array(self.predicted_agents, key="predicted_agents") + if output_format == "npz": + body["arrays"] = {"poses": encode_array(self.poses, key="poses")} + return body diff --git a/code/tt_frnet/ttaw/pointcloud.py b/code/tt_frnet/ttaw/pointcloud.py new file mode 100644 index 0000000000000000000000000000000000000000..9d94aa12a3a017517b2a49cd4c7c6183cae1bd45 --- /dev/null +++ b/code/tt_frnet/ttaw/pointcloud.py @@ -0,0 +1,282 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C11 point clouds: Autoware's multi-sweep densification state machine, input hygiene and the seeded permutation. + +**Densification** (``PointCloudDensification`` + ``VoxelGenerator::generateSweepPoints`` of +``autoware_lidar_centerpoint``; the same code shape is in lidar_transfusion, bevfusion and +image_projection_based_fusion: S:centerpoint:164-176; S:transfusion:99-103; S:pointpainting:216-221): + +- a cache holds the current cloud and ``num_past_frames`` previous ones, newest first (``push_front`` / + ``pop_back``, ``pointcloud_densification.cpp:79-95``); +- with a cache size > 1 every enqueue needs the TF ``T_sensor_from_world`` at the cloud's stamp; when it is missing + the frame is **skipped and not cached** (``:66-71``). The node keeps the world->current transform and each entry's + past->world transform as ``Eigen::Affine3f`` (float32, inverted in float32: ``:47-51,82-86``); +- at the end of each frame every cached cloud is re-transformed with ``world2current(now) @ past2world(entry)`` + (float32 product) by the float32 CUDA kernel, and gets ``time_lag = float(t_now - t_entry)`` in seconds + (``voxel_generator.cpp:56-93``). There is no maximum-age check: after dropped frames the past sweep can be old; +- points are written current sweep first; a sweep that would exceed ``cloud_capacity`` is dropped **with every older + sweep** (``voxel_generator.cpp:70-76``, a ``break``); +- cold start: the first frame has a single sweep (all ``time_lag = 0``); ``num_past_frames = 0`` needs no TF at all. + +:class:`SweepDensifier` is that state machine for one stream, :class:`StreamDensifiers` a per-``stream.id`` dict with +LRU eviction (PLAN.md D16: host sweep caches are per id), and :func:`densify_sweeps` the stateless variant for a +client that sends its past sweeps with ``T_current_from_sweep`` and ``time_lag_s`` itself. + +**Hygiene.** Autoware's range test is written as a rejection (``x < min || x >= max || ...``), so a NaN coordinate +passes it (S:centerpoint:179); the ports drop non-finite rows explicitly (a documented deviation; ``ttaw.io.PointCloud`` +already does it at decode time). Organized clouds mark missing returns as (0, 0, 0): :func:`nonzero_rows` drops them, +as the research references did (``centerpoint_ref.py``), before any transform. + +**Seeded permutation** (S:centerpoint:381-389; PLAN.md D1): Autoware reads its points through a fixed +``std::shuffle`` permutation at a time-seeded random offset, so which <= 32 points an over-full pillar keeps is random. +The ports keep the statistics but are deterministic: ``numpy.random.default_rng(seed).permutation(n)`` before the +first-K-points rule (:func:`seeded_permutation`). + +numpy only; no side effects on import. +""" +from __future__ import annotations + +import collections +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from .geometry import compose_f32, invert_affine_f32, invert_rigid, transform_points_f32 + +__all__ = [ + "DEFAULT_CLOUD_CAPACITY", + "SweepDensifier", + "StreamDensifiers", + "DensifyInfo", + "densify_sweeps", + "finite_rows", + "nonzero_rows", + "range_mask", + "seeded_permutation", +] + +DEFAULT_CLOUD_CAPACITY = 2_000_000 # centerpoint_common.param.yaml:3 (cloud_capacity) + + +@dataclass +class DensifyInfo: + """What one densification produced: per used sweep its point count and time lag, plus the dropped sweeps.""" + + num_points: int = 0 + sweeps: List[Dict[str, Any]] = field(default_factory=list) # [{"points", "time_lag_s", "age_index"}] + dropped_sweeps: int = 0 # cached sweeps dropped by the capacity rule + + def to_dict(self) -> Dict[str, Any]: + return {"num_points": self.num_points, "sweeps": list(self.sweeps), "dropped_sweeps": self.dropped_sweeps} + + +def _sweep_rows(xyz: np.ndarray, time_lag: np.float32, extra: Optional[np.ndarray], point_feature_size: int, + affine: Optional[np.ndarray]) -> np.ndarray: + """Rows of one sweep in the network layout: (x, y, z, time_lag) or (x, y, z, intensity, time_lag).""" + out = np.empty((len(xyz), point_feature_size), np.float32) + out[:, :3] = xyz if affine is None else transform_points_f32(xyz, affine) + if point_feature_size == 4: + out[:, 3] = time_lag + elif point_feature_size == 5: + out[:, 3] = 0.0 if extra is None else extra + out[:, 4] = time_lag + else: + raise ValueError(f"point_feature_size must be 4 or 5, got {point_feature_size}") + return out + + +@dataclass +class _Cached: + xyz: np.ndarray # (N, 3) float32 in the frame of its own stamp + extra: Optional[np.ndarray] # (N,) float32 intensity (point_feature_size 5) or None + stamp_s: float + past2world: np.ndarray # (4, 4) float32 = Affine3f inverse of world2current at its stamp + + +class SweepDensifier: + """One stream's Autoware densification cache (module docstring). ``num_past_frames`` is + ``densification_params.num_past_frames`` (1 in Autoware's CenterPoint / TransFusion configs).""" + + def __init__(self, num_past_frames: int = 1, cloud_capacity: int = DEFAULT_CLOUD_CAPACITY): + if num_past_frames < 0: + raise ValueError("num_past_frames must be >= 0") + self.num_past_frames = int(num_past_frames) + self.cloud_capacity = int(cloud_capacity) + self._cache: List[_Cached] = [] + self._world2current = np.eye(4, dtype=np.float32) + self._stamp: Optional[float] = None + + @property + def cache_size(self) -> int: + """``pointcloud_cache_size()`` = past frames + the current one.""" + return self.num_past_frames + 1 + + @property + def needs_pose(self) -> bool: + """True when enqueue needs ``T_world_from_sensor`` (Autoware looks up a TF only for cache size > 1).""" + return self.cache_size > 1 + + def __len__(self) -> int: + return len(self._cache) + + @property + def stamps(self) -> List[float]: + """Stamps of the cached sweeps, newest first.""" + return [c.stamp_s for c in self._cache] + + def reset(self) -> None: + self._cache.clear() + self._world2current = np.eye(4, dtype=np.float32) + self._stamp = None + + def enqueue(self, xyz: np.ndarray, stamp_s: float, T_world_from_sensor: Optional[np.ndarray] = None, *, + intensity: Optional[np.ndarray] = None) -> bool: + """``PointCloudDensification::enqueuePointCloud``: returns False, leaving the cache unchanged, when a pose is + needed and missing (Autoware's TF failure: the frame is skipped). ``xyz`` (N, >=3) is in the sensor frame + of ``stamp_s`` (base_link for Autoware's concatenated cloud); ``T_world_from_sensor`` is that frame's pose + in the world (``map``) frame, float64.""" + pts = np.ascontiguousarray(np.asarray(xyz, dtype=np.float32)[:, :3]) + extra = None if intensity is None else np.asarray(intensity, dtype=np.float32).reshape(-1) + if extra is not None and len(extra) != len(pts): + raise ValueError("intensity length differs from the point count") + if self.needs_pose: + if T_world_from_sensor is None: + return False + # tf2 lookupTransform(frame <- world) in double, then .cast() (pointcloud_densification.cpp:47-51) + world2current = invert_rigid(np.asarray(T_world_from_sensor, dtype=np.float64)).astype(np.float32) + else: + world2current = np.eye(4, dtype=np.float32) + self._world2current = world2current + self._stamp = float(stamp_s) + self._cache.insert(0, _Cached(pts, extra, float(stamp_s), invert_affine_f32(world2current))) + if len(self._cache) > self.cache_size: + self._cache.pop() + return True + + def sweep_points(self, *, point_feature_size: int = 4) -> Tuple[np.ndarray, DensifyInfo]: + """``VoxelGenerator::generateSweepPoints``: every cached sweep in the current frame, newest first, as + (N, point_feature_size) float32 rows (x, y, z[, intensity], time_lag).""" + info = DensifyInfo() + if not self._cache: + return np.zeros((0, point_feature_size), np.float32), info + parts: List[np.ndarray] = [] + n = 0 + for k, c in enumerate(self._cache): + if n + len(c.xyz) > self.cloud_capacity: + info.dropped_sweeps = len(self._cache) - k + break + affine = compose_f32(self._world2current, c.past2world) + time_lag = np.float32(self._stamp - c.stamp_s) # double difference cast to float + parts.append(_sweep_rows(c.xyz, time_lag, c.extra, point_feature_size, affine)) + info.sweeps.append({"points": int(len(c.xyz)), "time_lag_s": float(time_lag), "age_index": k}) + n += len(c.xyz) + info.num_points = n + out = np.concatenate(parts, axis=0) if parts else np.zeros((0, point_feature_size), np.float32) + return out, info + + def describe(self) -> Dict[str, Any]: + return {"num_past_frames": self.num_past_frames, "cloud_capacity": self.cloud_capacity, + "cached": len(self._cache), "stamps": self.stamps} + + +class StreamDensifiers: + """Per-stream densification caches (PLAN.md D16: host sweep caches are a per-``stream.id`` dict). A new id beyond + ``max_streams`` evicts the least recently used stream; ``get(id, reset=True)`` restarts one (cold start).""" + + def __init__(self, num_past_frames: int = 1, cloud_capacity: int = DEFAULT_CLOUD_CAPACITY, + max_streams: int = 16): + if max_streams < 1: + raise ValueError("max_streams must be >= 1") + self.num_past_frames = int(num_past_frames) + self.cloud_capacity = int(cloud_capacity) + self.max_streams = int(max_streams) + self._streams: "collections.OrderedDict[str, SweepDensifier]" = collections.OrderedDict() + + def get(self, stream_id: str = "default", *, reset: bool = False) -> SweepDensifier: + sid = str(stream_id) + d = self._streams.get(sid) + if d is None: + d = SweepDensifier(self.num_past_frames, self.cloud_capacity) + self._streams[sid] = d + while len(self._streams) > self.max_streams: + self._streams.popitem(last=False) + elif reset: + d.reset() + self._streams.move_to_end(sid) + return d + + def forget(self, stream_id: str) -> None: + self._streams.pop(str(stream_id), None) + + @property + def streams(self) -> List[str]: + """Stream ids, most recently used first.""" + return list(reversed(self._streams)) + + def describe(self) -> Dict[str, Any]: + return {"max_streams": self.max_streams, "num_past_frames": self.num_past_frames, + "streams": {k: v.describe() for k, v in reversed(self._streams.items())}} + + +def densify_sweeps(current_xyz: np.ndarray, sweeps: Sequence[Tuple[np.ndarray, float, Optional[np.ndarray]]] = (), + *, point_feature_size: int = 4, cloud_capacity: int = DEFAULT_CLOUD_CAPACITY, + current_intensity: Optional[np.ndarray] = None, + sweep_intensity: Optional[Sequence[Optional[np.ndarray]]] = None) -> Tuple[np.ndarray, DensifyInfo]: + """Stateless densification for clients that hold the past sweeps: the current sweep (identity, ``time_lag`` + 0) then each ``(xyz, time_lag_s, T_current_from_sweep)`` in the given order (newest first, as Autoware's cache), + transformed by the float32 sweep kernel (``T`` None = already in the current frame) with + ``time_lag = float32(time_lag_s)``; the capacity rule drops a sweep and every later one.""" + info = DensifyInfo() + cur = np.asarray(current_xyz, dtype=np.float32)[:, :3] + rows = [(cur, np.float32(0.0), current_intensity, None)] + for i, (xyz, lag, T) in enumerate(sweeps): + extra = None if sweep_intensity is None else sweep_intensity[i] + rows.append((np.asarray(xyz, dtype=np.float32)[:, :3], np.float32(lag), extra, + None if T is None else np.asarray(T, dtype=np.float64))) + parts = [] + n = 0 + for k, (xyz, lag, extra, T) in enumerate(rows): + if n + len(xyz) > cloud_capacity: + info.dropped_sweeps = len(rows) - k + break + parts.append(_sweep_rows(xyz, lag, extra, point_feature_size, None if T is None else T.astype(np.float32))) + info.sweeps.append({"points": int(len(xyz)), "time_lag_s": float(lag), "age_index": k}) + n += len(xyz) + info.num_points = n + out = np.concatenate(parts, axis=0) if parts else np.zeros((0, point_feature_size), np.float32) + return out, info + + +# -------------------------------------------------------------------------------------------------- hygiene + +def finite_rows(points: np.ndarray, columns: Sequence[int] = (0, 1, 2)) -> np.ndarray: + """Mask of rows whose ``columns`` are all finite.""" + p = np.asarray(points) + return np.isfinite(p[:, list(columns)]).all(axis=1) + + +def nonzero_rows(points: np.ndarray) -> np.ndarray: + """Mask of rows whose x, y, z are not all exactly zero (organized clouds mark missing returns as (0, 0, 0); the + research references drop them with ``|x| + |y| + |z| > 0``, which also drops non-finite rows).""" + p = np.asarray(points, dtype=np.float32) + return np.abs(p[:, :3]).sum(axis=1) > 0 + + +def range_mask(points: np.ndarray, range_min: Sequence[float], range_max: Sequence[float]) -> np.ndarray: + """Autoware's half-open box test ``min <= v < max`` per axis on x, y, z (float32 comparisons). Unlike Autoware's + rejection form, a NaN coordinate fails it.""" + p = np.asarray(points, dtype=np.float32) + lo = np.asarray(range_min, dtype=np.float32) + hi = np.asarray(range_max, dtype=np.float32) + keep = np.ones(len(p), dtype=bool) + for a in range(3): + keep &= (p[:, a] >= lo[a]) & (p[:, a] < hi[a]) + return keep + + +def seeded_permutation(n: int, seed: Optional[int]) -> Optional[np.ndarray]: + """The deterministic stand-in for Autoware's random point shuffle: ``default_rng(seed).permutation(n)`` (PCG64, + numpy >= 1.17), or None when ``seed`` is None (input order).""" + if seed is None: + return None + return np.random.default_rng(int(seed)).permutation(int(n)) diff --git a/code/tt_frnet/ttaw/precision.py b/code/tt_frnet/ttaw/precision.py new file mode 100644 index 0000000000000000000000000000000000000000..ec51f4900813c43baf473b99287b37f18f8e6b0f --- /dev/null +++ b/code/tt_frnet/ttaw/precision.py @@ -0,0 +1,191 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C05: explicit compute-kernel configs and per-module precision policies. + +Silent defaults this module exists to avoid (TT_PLATFORM.md section 0 item 7, PLAN.md section 0.2): + +- ``ttnn.matmul`` / ``ttnn.linear`` fall back to **LoFi** when a ``program_config`` or ``core_grid`` is given without + a ``compute_kernel_config``; +- ``ttnn.WormholeComputeKernelConfig()`` built without ``math_fidelity`` carries ``MathFidelity.Invalid``. + +So every op that takes a compute config gets one built here, with the fidelity spelled out. The default precision +is HiFi2 + fp32 accumulation, no approximations (the reference bundles' safe start; LoFi failed their gates almost +everywhere, RP section 2.9). A :class:`PrecisionPolicy` maps module names (globs, first match wins) to a +:class:`Precision`; ``_PRECISION`` overrides rules at build time (the A/B switch), e.g. +``CENTERPOINT_PRECISION="backbone.*=HiFi4+fp32;head.*=LoFi:w=bfp8"``. + +Other silent defaults to override by hand (not compute configs): ``ttnn.layer_norm`` epsilon 1e-12, SDPA +``is_causal=True``, ``ttnn.embedding`` PADDED returning the cached pad row, fused HARDSWISH skipped in conv2d. +""" +from __future__ import annotations + +import fnmatch +import os +from dataclasses import asdict, dataclass, replace +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union + +from .tensors import dtype_name + +__all__ = ["FIDELITIES", "Precision", "PRESETS", "compute_kernel_config", "PrecisionPolicy"] + +FIDELITIES = ("LoFi", "HiFi2", "HiFi3", "HiFi4") +_FIDELITY_KEY = {f.lower(): f for f in FIDELITIES} + + +def _fidelity(name: str) -> str: + key = _FIDELITY_KEY.get(str(name).strip().lower()) + if key is None: + raise ValueError(f"math fidelity {name!r}: expected one of {FIDELITIES}") + return key + + +def compute_kernel_config(fidelity: str = "HiFi2", *, fp32_acc: bool = True, approx: bool = False, + packer_l1_acc: bool = False, dst_full_sync: bool = False): + """A ``ttnn.WormholeComputeKernelConfig`` (the same class as ``BlackholeComputeKernelConfig``) with every + field explicit. ``fp32_acc`` = ``fp32_dest_acc_en`` (halves DST capacity: 4 tiles in half-sync).""" + import ttnn + + return ttnn.WormholeComputeKernelConfig(math_fidelity=getattr(ttnn.MathFidelity, _fidelity(fidelity)), + math_approx_mode=bool(approx), fp32_dest_acc_en=bool(fp32_acc), + packer_l1_acc=bool(packer_l1_acc), dst_full_sync_en=bool(dst_full_sync)) + + +@dataclass(frozen=True) +class Precision: + """Fidelity / accumulation / dtype choice of one module (an op or a group of ops).""" + + fidelity: str = "HiFi2" + fp32_acc: bool = True + approx: bool = False + packer_l1_acc: bool = False + dst_full_sync: bool = False + weights: str = "bfloat16" + activations: str = "bfloat16" + + def __post_init__(self) -> None: + object.__setattr__(self, "fidelity", _fidelity(self.fidelity)) + object.__setattr__(self, "weights", dtype_name(self.weights)) + object.__setattr__(self, "activations", dtype_name(self.activations)) + + @classmethod + def parse(cls, spec: Union[str, "Precision"]) -> "Precision": + """``"HiFi4+fp32"``, ``"LoFi"``, ``"HiFi2+fp32+approx+l1acc:w=bfp8:a=bf16"`` or a preset name + (``accurate`` / ``balanced`` / ``fast``). Flags: ``fp32`` (fp32 accumulation; absent = bf16 DST), + ``approx``, ``l1acc``, ``fullsync``; ``w=`` / ``a=`` set weight / activation dtypes.""" + if isinstance(spec, Precision): + return spec + text = spec.strip() + if text.lower() in PRESETS: + return PRESETS[text.lower()] + head, *opts = text.split(":") + fid, *flags = [p.strip() for p in head.split("+")] + kw: Dict[str, Any] = {"fidelity": fid, "fp32_acc": False} + for flag in (f.lower() for f in flags): + if flag == "fp32": + kw["fp32_acc"] = True + elif flag == "approx": + kw["approx"] = True + elif flag == "l1acc": + kw["packer_l1_acc"] = True + elif flag == "fullsync": + kw["dst_full_sync"] = True + else: + raise ValueError(f"precision {spec!r}: unknown flag {flag!r}") + for opt in opts: + k, _, v = opt.partition("=") + k = k.strip().lower() + if k in ("w", "weights"): + kw["weights"] = v.strip() + elif k in ("a", "act", "activations"): + kw["activations"] = v.strip() + else: + raise ValueError(f"precision {spec!r}: unknown option {k!r}") + return cls(**kw) + + @property + def label(self) -> str: + """Round-trips through :meth:`parse`.""" + flags = "".join(f"+{f}" for f, on in (("fp32", self.fp32_acc), ("approx", self.approx), + ("l1acc", self.packer_l1_acc), ("fullsync", self.dst_full_sync)) if on) + return f"{self.fidelity}{flags}:w={self.weights}:a={self.activations}" + + def with_(self, **changes: Any) -> "Precision": + return replace(self, **changes) + + def compute_kernel_config(self): + return compute_kernel_config(self.fidelity, fp32_acc=self.fp32_acc, approx=self.approx, + packer_l1_acc=self.packer_l1_acc, dst_full_sync=self.dst_full_sync) + + def weights_dtype(self): + from .tensors import ttnn_dtype + + return ttnn_dtype(self.weights) + + def activations_dtype(self): + from .tensors import ttnn_dtype + + return ttnn_dtype(self.activations) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +PRESETS: Dict[str, Precision] = { + "accurate": Precision("HiFi4", fp32_acc=True), # norms, softmax logits, box regression, grid sampling + "balanced": Precision("HiFi2", fp32_acc=True), # default for big matmuls / convs + "fast": Precision("LoFi", fp32_acc=False, weights="bfloat8_b"), # only after the gates pass with it +} + +RuleSpec = Union[Mapping[str, Union[str, Precision]], Sequence[Tuple[str, Union[str, Precision]]]] + + +class PrecisionPolicy: + """Ordered ``pattern -> Precision`` rules (``fnmatch`` globs on dotted module names, first match wins) plus a + default. Typical use, once at model build:: + + POLICY = PrecisionPolicy({"backbone.*": "balanced", "head.reg*": "accurate"}, default="balanced") + policy = POLICY.with_env("CENTERPOINT") # _PRECISION overrides, read once + cfg = policy.compute_kernel_config("backbone.block3.conv2") + w_dtype = policy.resolve("backbone.block3.conv2").weights_dtype() + + ``resolve`` records each module it answered for, so ``describe()`` shows the policy that reached the ops.""" + + def __init__(self, rules: Optional[RuleSpec] = None, default: Union[str, Precision] = "balanced"): + items = rules.items() if isinstance(rules, Mapping) else (rules or ()) + self.rules: List[Tuple[str, Precision]] = [(str(p), Precision.parse(v)) for p, v in items] + self.default = Precision.parse(default) + self.used: Dict[str, str] = {} + self._configs: Dict[Precision, Any] = {} + + def resolve(self, module: str) -> Precision: + for pattern, prec in self.rules: + if fnmatch.fnmatchcase(module, pattern): + self.used[module] = prec.label + return prec + self.used[module] = self.default.label + return self.default + + def compute_kernel_config(self, module: str): + prec = self.resolve(module) + if prec not in self._configs: + self._configs[prec] = prec.compute_kernel_config() + return self._configs[prec] + + def override(self, spec: str) -> "PrecisionPolicy": + """A new policy with ``"pattern=precision;pattern=precision"`` rules placed before the existing ones + (``*=...`` effectively replaces the default for unmatched modules).""" + extra = [] + for part in filter(None, (p.strip() for p in spec.split(";"))): + pattern, sep, prec = part.partition("=") + if not sep: + raise ValueError(f"precision override {part!r}: expected pattern=precision") + extra.append((pattern.strip(), Precision.parse(prec))) + return PrecisionPolicy(extra + self.rules, self.default) + + def with_env(self, prefix: str, env: Optional[Mapping[str, str]] = None) -> "PrecisionPolicy": + """Apply ``_PRECISION`` if set (else return ``self``). Call once at build.""" + spec = (os.environ if env is None else env).get(f"{prefix}_PRECISION", "").strip() + return self.override(spec) if spec else self + + def describe(self) -> Dict[str, Any]: + return {"default": self.default.label, "rules": [[p, prec.label] for p, prec in self.rules], + "used": dict(sorted(self.used.items()))} diff --git a/code/tt_frnet/ttaw/profiling.py b/code/tt_frnet/ttaw/profiling.py new file mode 100644 index 0000000000000000000000000000000000000000..66b2a2fef780aeccf1a151e36ae1ed4789e81c68 --- /dev/null +++ b/code/tt_frnet/ttaw/profiling.py @@ -0,0 +1,440 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C07: measurement helpers -- the numbers OPT_BASELINE.md / OPT_REPORT.md / the model card quote. + +- :class:`StageBench` collects wall times per stage (``host_in``, ``h2d``, ``trace``, ``b2b``, ``d2h``, ``post``, + ``e2e``) and reports p50 / p99 / mean / min / max in ms (RP section 3, rf-detr ``bench_breakdown``). +- :func:`bench_trace_runner` measures those stages on a :class:`.trace.TraceRunner` variant. +- :func:`signpost` / :func:`signposted` mark Tracy ranges (no-ops when the ``tracy`` module is absent), so + ``tt-perf-report --start-signpost trace --end-signpost trace_end`` and :func:`summarize_ops` can cut the CSV. +- :func:`summarize_ops` reads a device-profiler ``ops_perf_results*.csv`` (Tracy ``-r``) or the C++ + ``cpp_device_perf_report.csv`` and returns op count, kernel sum, op-to-op gaps, span, per-op-code totals and the + math-fidelity histogram. CLI: ``python -m .ttaw.profiling [--start trace] [--json out.json]``. +- :class:`AiclkSampler` samples the chip clock (sysfs ``tt_aiclk``) and hwmon power / temperature in a background + thread during a bench: AICLK sags from 1350 MHz under load (RP section 3), which explains span-vs-wall gaps. + +Profiling runs go through ``bin/devrun`` like any device job, with an absolute output directory under +``generated/profiler/_`` (PLAN.md section 5.1). +""" +from __future__ import annotations + +import argparse +import contextlib +import csv +import glob +import json +import logging +import os +import statistics +import sys +import threading +import time +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Union + +import numpy as np + +__all__ = [ + "STAGES", + "StageBench", + "time_b2b", + "bench_trace_runner", + "signpost", + "signposted", + "read_device_profiler", + "find_ops_csv", + "OpsSummary", + "summarize_ops", + "AiclkSampler", +] + +log = logging.getLogger(__name__) +STAGES = ("host_in", "h2d", "trace", "b2b", "d2h", "post", "e2e") +DEFAULT_AICLK_MHZ = 1350.0 + + +# ------------------------------------------------------------------------------------------- stage bench + +def _stats(samples: Sequence[float]) -> Dict[str, float]: + a = np.asarray(samples, np.float64) + return {"n": int(a.size), "p50": float(np.percentile(a, 50)), "p99": float(np.percentile(a, 99)), + "mean": float(a.mean()), "min": float(a.min()), "max": float(a.max())} + + +class StageBench: + """Per-stage wall-time samples in milliseconds. + + :: + + bench = StageBench("centerpoint eth/1cq") + for _ in range(100): + with bench.stage("e2e"): + with bench.stage("host_in"): host = prepare(points) + ... + print(bench.table()); bench.save_json("logs/centerpoint/bench_baseline.json", config=device_info) + """ + + def __init__(self, name: str = ""): + self.name = name + self.samples: Dict[str, List[float]] = {} + + @contextlib.contextmanager + def stage(self, name: str) -> Iterator[None]: + t0 = time.perf_counter() + try: + yield + finally: + self.add(name, (time.perf_counter() - t0) * 1e3) + + def add(self, name: str, ms: float) -> None: + self.samples.setdefault(name, []).append(float(ms)) + + def summary(self) -> Dict[str, Dict[str, float]]: + """``{stage: {n, p50, p99, mean, min, max}}`` in ms; known stages first, in pipeline order.""" + order = [s for s in STAGES if s in self.samples] + [s for s in self.samples if s not in STAGES] + return {s: _stats(self.samples[s]) for s in order if self.samples[s]} + + def table(self) -> str: + rows = [f"| stage | n | p50 ms | p99 ms | mean ms | min ms |", "|---|---:|---:|---:|---:|---:|"] + for s, st in self.summary().items(): + rows.append(f"| {s} | {st['n']} | {st['p50']:.3f} | {st['p99']:.3f} | {st['mean']:.3f} | {st['min']:.3f} |") + return (f"**{self.name}**\n\n" if self.name else "") + "\n".join(rows) + + def to_dict(self, **extra: Any) -> Dict[str, Any]: + return {"name": self.name, "stages_ms": self.summary(), **extra} + + def save_json(self, path: Union[str, os.PathLike], **extra: Any) -> Path: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self.to_dict(**extra), indent=1, default=str) + "\n") + return path + + +def time_b2b(enqueue: Callable[[], Any], sync: Callable[[], Any], n: int = 100, warmup: int = 5) -> float: + """Back-to-back device time per iteration (ms): ``warmup`` + ``n`` non-blocking ``enqueue()`` calls, one + ``sync()`` each round. With a traced ``enqueue`` this is the device time per forward.""" + for _ in range(warmup): + enqueue() + sync() + t0 = time.perf_counter() + for _ in range(n): + enqueue() + sync() + return (time.perf_counter() - t0) * 1e3 / n + + +def bench_trace_runner(runner, variant: Optional[str], inputs: Mapping[str, Any], *, + params: Optional[Mapping[str, Any]] = None, iters: int = 100, warmup: int = 10, + post: Optional[Callable[[Any], Any]] = None, b2b_iters: Optional[int] = None, + name: str = "") -> StageBench: + """Stage breakdown of one :class:`.trace.TraceRunner` variant (medians are the headline numbers): + + ``host_in`` host tensor conversion, ``h2d`` upload + sync, ``trace`` one replay + sync, ``d2h`` read, + ``post`` the optional ``post(outputs)`` callback, ``e2e`` ``runner(variant, inputs)`` + ``post``, and ``b2b`` + back-to-back replays (device time per forward).""" + import ttnn + + from .tensors import to_host_tensor + + dev = runner.device + bench = StageBench(name or f"{runner.name}/{variant}") + slots = {k: runner._slot(k, "input") for k in inputs} + for _ in range(warmup): + out = runner(variant, inputs, params) + if post is not None: + post(out) + for _ in range(iters): + t0 = time.perf_counter() + host = {k: to_host_tensor(v, slots[k].dtype, slots[k].layout, shape=slots[k].shape) for k, v in inputs.items()} + t1 = time.perf_counter() + runner.upload(host, params) + ttnn.synchronize_device(dev) + t2 = time.perf_counter() + runner.replay(variant) + ttnn.synchronize_device(dev) + t3 = time.perf_counter() + out = runner.read(variant) + t4 = time.perf_counter() + if post is not None: + post(out) + t5 = time.perf_counter() + for stage, a, b in (("host_in", t0, t1), ("h2d", t1, t2), ("trace", t2, t3), ("d2h", t3, t4)): + bench.add(stage, (b - a) * 1e3) + if post is not None: + bench.add("post", (t5 - t4) * 1e3) + for _ in range(iters): + with bench.stage("e2e"): + out = runner(variant, inputs, params) + if post is not None: + post(out) + bench.add("b2b", time_b2b(lambda: runner.replay(variant), lambda: ttnn.synchronize_device(dev), + n=b2b_iters or iters)) + return bench + + +# --------------------------------------------------------------------------------------------- signposts + +def signpost(name: str, message: Optional[str] = None) -> bool: + """Emit a Tracy signpost (a row in the ops CSV); returns False (no-op) when ``tracy`` is not importable.""" + try: + from tracy import signpost as _signpost + except ImportError: + return False + _signpost(header=name, message=message) + return True + + +@contextlib.contextmanager +def signposted(name: str) -> Iterator[None]: + """``signpost(name)`` ... ``signpost(name + "_end")`` around the block.""" + signpost(name) + try: + yield + finally: + signpost(f"{name}_end") + + +def read_device_profiler(device) -> bool: + """Flush the device profiler buffer (``ttnn.ReadDeviceProfiler``); call it per trace segment, the buffer holds + about 1000 ops. Returns False when the API is absent.""" + import ttnn + + fn = getattr(ttnn, "ReadDeviceProfiler", None) + if fn is None: + return False + fn(device) + return True + + +# ------------------------------------------------------------------------------------- ops CSV summary + +def find_ops_csv(path: Union[str, os.PathLike]) -> Path: + """A CSV file as-is, or the newest ``ops_perf_results*.csv`` (else ``cpp_device_perf_report.csv``) below a dir.""" + p = Path(path) + if p.is_file(): + return p + for pattern in ("ops_perf_results*.csv", "cpp_device_perf_report.csv"): + found = sorted(glob.glob(str(p / "**" / pattern), recursive=True), key=os.path.getmtime) + if found: + return Path(found[-1]) + raise FileNotFoundError(f"no ops_perf_results*.csv or cpp_device_perf_report.csv under {p}") + + +def _num(row: Mapping[str, str], key: str) -> float: + value = (row.get(key) or "").strip() + try: + return float(value) if value else 0.0 + except ValueError: + return 0.0 + + +def _chip_freq_mhz(csv_path: Path) -> Optional[float]: + """CHIP_FREQ[MHz] from a ``profile_log_device.csv`` header near the ops CSV, if any.""" + for cand in [csv_path.parent / "profile_log_device.csv", csv_path.parent.parent / "profile_log_device.csv", + csv_path.parent / ".logs" / "profile_log_device.csv"]: + try: + head = cand.read_text(errors="ignore").splitlines()[0] + except (OSError, IndexError): + continue + for part in head.split(","): + if "CHIP_FREQ" in part and ":" in part: + try: + return float(part.split(":")[1].strip()) + except ValueError: + pass + return None + + +@dataclass +class OpsSummary: + """Summary of one profiled section (times in microseconds).""" + + source: str + section: str + ops: int + kernel_sum_us: float + fw_sum_us: float + op2op_sum_us: float + span_us: Optional[float] + freq_mhz: float + by_op: List[Dict[str, Any]] = field(default_factory=list) + fidelity: Dict[str, int] = field(default_factory=dict) + top_gaps: List[Dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return {k: getattr(self, k) for k in self.__dataclass_fields__} + + def table(self, top: int = 20) -> str: + span = "n/a" if self.span_us is None else f"{self.span_us:.1f}" + lines = [f"{self.source} [{self.section}]", + f"ops {self.ops} kernel_sum {self.kernel_sum_us:.1f} us op2op_sum {self.op2op_sum_us:.1f} us " + f"span {span} us (@{self.freq_mhz:.0f} MHz) fidelity {self.fidelity}", + f"{'op code':55s} {'count':>6s} {'kernel us':>11s} {'share':>7s}"] + for row in self.by_op[:top]: + lines.append(f"{row['op'][:55]:55s} {row['count']:6d} {row['kernel_us']:11.1f} {row['share']:6.1%}") + return "\n".join(lines) + + +def summarize_ops(source: Union[str, os.PathLike, Sequence[Mapping[str, str]]], *, start: Optional[str] = "trace", + end: Optional[str] = None, last_replay_session: Optional[bool] = None, + freq_mhz: Optional[float] = None, top_gaps: int = 10) -> OpsSummary: + """Summarize device ops between signposts ``start`` and ``end`` (default: the next signpost). + + ``source``: an ``ops_perf_results*.csv`` / ``cpp_device_perf_report.csv`` path, a directory holding one, or + already-parsed rows. ``start=None`` takes every op. ``last_replay_session`` (default: on when the rows carry + ``METAL TRACE REPLAY SESSION ID`` values) keeps only the last trace replay session, the way ``prof_cpp.py`` + did. Span uses the FW start/end cycles at ``freq_mhz`` (default: the CHIP_FREQ of ``profile_log_device.csv``, + else 1350 MHz; AICLK sags under load, so treat span as approximate).""" + if isinstance(source, (str, os.PathLike)): + path = find_ops_csv(source) + with open(path, newline="") as f: + rows = list(csv.DictReader(f)) + label = str(path) + freq = freq_mhz or _chip_freq_mhz(path) or DEFAULT_AICLK_MHZ + else: + rows, label, freq = list(source), "", freq_mhz or DEFAULT_AICLK_MHZ + name_key = "OP CODE" if rows and "OP CODE" in rows[0] else "OP NAME" + section = "all" + if start is not None and any((r.get("OP TYPE") or "") == "signpost" for r in rows): + selected, on = [], False + for r in rows: + if (r.get("OP TYPE") or "") == "signpost": + code = r.get(name_key, "") + if on and (end is None or code == end): + break + on = on or code == start + continue + if on: + selected.append(r) + rows, section = selected, f"{start}..{end or 'next signpost'}" + else: + rows = [r for r in rows if (r.get("OP TYPE") or "") != "signpost"] + sessions = [r.get("METAL TRACE REPLAY SESSION ID", "") for r in rows] + if last_replay_session is None: + last_replay_session = any(s.strip() for s in sessions) + if last_replay_session and rows: + traced = [r for r in rows if (r.get("METAL TRACE ID") or "").strip()] + if traced: + last = traced[-1].get("METAL TRACE REPLAY SESSION ID") + rows = [r for r in traced if r.get("METAL TRACE REPLAY SESSION ID") == last] + section += f" (replay session {last})" + kernel = [_num(r, "DEVICE KERNEL DURATION [ns]") / 1e3 for r in rows] + fw = [_num(r, "DEVICE FW DURATION [ns]") / 1e3 for r in rows] + gaps = [_num(r, "OP TO OP LATENCY [ns]") / 1e3 for r in rows] + starts = [_num(r, "DEVICE FW START CYCLE") for r in rows] + ends = [_num(r, "DEVICE FW END CYCLE") for r in rows] + span = (max(ends) - min(s for s in starts if s > 0)) / freq if rows and any(starts) and any(ends) else None + agg: Dict[str, List[float]] = defaultdict(lambda: [0, 0.0]) + fidelity: Dict[str, int] = defaultdict(int) + for r, k in zip(rows, kernel): + agg[r.get(name_key, "?")][0] += 1 + agg[r.get(name_key, "?")][1] += k + fid = (r.get("MATH FIDELITY") or "").strip() + if fid: + fidelity[fid] += 1 + total = sum(kernel) or 1.0 + by_op = [{"op": op, "count": int(c), "kernel_us": t, "share": t / total} + for op, (c, t) in sorted(agg.items(), key=lambda kv: -kv[1][1])] + order = sorted(range(1, len(rows)), key=lambda i: -gaps[i])[:top_gaps] + worst = [{"index": i, "op": rows[i].get(name_key, "?"), "gap_us": gaps[i], "after": rows[i - 1].get(name_key, "?")} + for i in order] + return OpsSummary(label, section, len(rows), sum(kernel), sum(fw), sum(gaps[1:]), span, freq, by_op, + dict(fidelity), worst) + + +# ------------------------------------------------------------------------------------------------ AICLK + +class AiclkSampler: + """Background sampling of ``tt_aiclk`` (MHz) and hwmon power (W) / temperature (C) of one chip. + + :: + + with AiclkSampler(chip=0, interval_s=0.05) as clk: + run_bench() + print(clk.summary()) # {"aiclk_mhz": {"median": 1302, "min": 1206, ...}, "power_w": {...}, ...} + + Reads sysfs only (read-only, works while another process holds the device); ``available`` is False on hosts + without the Tenstorrent KMD.""" + + def __init__(self, chip: int = 0, interval_s: float = 0.05, *, root: str = "/sys/class/tenstorrent"): + self.interval_s = float(interval_s) + base = Path(root) / f"tenstorrent!{chip}" + self._aiclk = base / "tt_aiclk" + hwmon = sorted(glob.glob(str(base / "device" / "hwmon" / "hwmon*"))) + self._hwmon = Path(hwmon[0]) if hwmon else None + self.available = self._aiclk.is_file() + self.samples: Dict[str, List[float]] = {"aiclk_mhz": [], "power_w": [], "temp_c": []} + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + + @staticmethod + def _read(path: Optional[Path], scale: float) -> Optional[float]: + if path is None: + return None + try: + return float(path.read_text().split()[0]) * scale + except (OSError, ValueError, IndexError): + return None + + def sample_once(self) -> Dict[str, Optional[float]]: + hw = self._hwmon + values = {"aiclk_mhz": self._read(self._aiclk, 1.0), + "power_w": self._read(hw / "power1_input" if hw else None, 1e-6), + "temp_c": self._read(hw / "temp1_input" if hw else None, 1e-3)} + for k, v in values.items(): + if v is not None: + self.samples[k].append(v) + return values + + def _loop(self) -> None: + while not self._stop.is_set(): + self.sample_once() + self._stop.wait(self.interval_s) + + def start(self) -> "AiclkSampler": + if self.available and self._thread is None: + self._stop.clear() + self._thread = threading.Thread(target=self._loop, name="aiclk-sampler", daemon=True) + self._thread.start() + return self + + def stop(self) -> None: + if self._thread is not None: + self._stop.set() + self._thread.join(timeout=5) + self._thread = None + + def __enter__(self) -> "AiclkSampler": + return self.start() + + def __exit__(self, *exc) -> None: + self.stop() + + def summary(self) -> Dict[str, Any]: + out: Dict[str, Any] = {"available": self.available} + for k, v in self.samples.items(): + if v: + out[k] = {"median": statistics.median(v), "min": min(v), "max": max(v), "n": len(v)} + return out + + +def main(argv: Optional[Sequence[str]] = None) -> int: + """``python -m .ttaw.profiling [--start trace] [--end trace_end] [--json out] [--top 25]``.""" + ap = argparse.ArgumentParser(description="Summarize a device-profiler ops CSV") + ap.add_argument("source") + ap.add_argument("--start", default="trace", help="signpost that opens the section ('' = all ops)") + ap.add_argument("--end", default=None) + ap.add_argument("--freq-mhz", type=float, default=None) + ap.add_argument("--top", type=int, default=25) + ap.add_argument("--json", default=None) + a = ap.parse_args(argv) + summary = summarize_ops(a.source, start=a.start or None, end=a.end, freq_mhz=a.freq_mhz) + print(summary.table(a.top)) + if a.json: + Path(a.json).write_text(json.dumps(summary.to_dict(), indent=1) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/tt_frnet/ttaw/server/__init__.py b/code/tt_frnet/ttaw/server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..387839e47a6170bb57a15d7c4517389dfc34b02f --- /dev/null +++ b/code/tt_frnet/ttaw/server/__init__.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C08 HTTP serving shared by every bundle. + +- ``server.app``: :func:`create_app` builds the FastAPI (ASGI) app of a bundle from a :class:`ServerSpec` + (routes ``/``, ``/health``, ``/v1/health``, ``/info``, ``/v1/models``, ``/predict``; lifespan = weights -> device -> + graph -> trace capture; errors 400 / 422 / 503 / 500). Imports fastapi / pydantic. +- ``server.client``: standard-library-only request builder, HTTP helpers and smoke checks (``/info`` must report + the pinned dispatch and grid). +- ``server.smoke``: standard-library-only agreement of a served body with a stored CPU-reference body (every output + family, numpy-free npz / png decoding) and the serve-profile pins of a staged tt-model manifest. + +Neither imports anything from ttaw, so a bundle's ``smoke_test.py`` can load them by path and run under any +Python 3.9+ outside the container. This ``__init__`` imports nothing, so ``import .ttaw.server.client`` never +pulls in fastapi. +""" diff --git a/code/tt_frnet/ttaw/server/app.py b/code/tt_frnet/ttaw/server/app.py new file mode 100644 index 0000000000000000000000000000000000000000..e8dc225a5ec6631cff0cc02651aae8ae23ad2665 --- /dev/null +++ b/code/tt_frnet/ttaw/server/app.py @@ -0,0 +1,401 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C08 server: the FastAPI app factory implementing the HTTP contract of all 13 bundles (BUNDLE_CONVENTIONS.md 7). + +A bundle's ``server/app.py`` is a few lines:: + + from ..api import CenterPoint + from ..ttaw.server.app import ServerSpec, create_app, parse_mesh_shape # noqa: F401 (verify: line) + + SPEC = ServerSpec(model_name=CenterPoint.MODEL_NAME, env_prefix="CENTERPOINT", model_cls=CenterPoint, + task="LiDAR 3D object detection", default_weights=CenterPoint.DEFAULT_REPO, + io="a LiDAR point cloud in, 3D boxes out", + autoware={"package": "autoware_lidar_centerpoint", + "path": "perception/autoware_lidar_centerpoint", + "autoware_universe": "9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd"}, + source={"repo": "https://huggingface.co/changh95/centerpoint-p150", "license": "Apache-2.0"}, + calib_dir=Path(__file__).resolve().parents[1] / "calib") + app = create_app(SPEC) + +Served by tt-model-manager as ``kind: tt-dit-server`` (``python -m uvicorn --lifespan on .server.app:app``). +Everything device-touching happens in the lifespan: weights -> device -> graph -> trace capture of every warm-up +variant; uvicorn logs ``Application startup complete`` only after that, so READY means warm. Shutdown (SIGTERM, +120 s budget) closes the model under the lock. Importing the module has no side effects beyond importing +fastapi / pydantic (no device, no download, no environment read). + +Tests replace the model with a stub: ``app.state.ttaw.model_factory = StubModel`` before ``TestClient(app)``. +``app.state.ttaw.predict(request)`` is the route handler itself (for API == server checks). + +Environment (read in the lifespan): ``HF_MODEL``, ``TT_MODEL_WEIGHTS_REVISION`` (then ``TT_WEIGHTS_REVISION``), +``_WEIGHTS_DIR``, ``TT_MESH_SHAPE`` (only 1x1), ``TT_DEVICE_ID``, ``_DISPATCH``, ``_NUM_CQS``, +``_VARIANT``, ``_WARMUP`` (JSON list, ``default`` or ``none``), ``_MAX_BODY_MB`` (default 256), +``_TORCH_THREADS``. +""" +import json +import logging +import math +import os +import re +import threading +import time +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Type + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict, Field + +from .. import io as tio + +__all__ = [ + "HARDWARE", "PointsSpec", "SweepSpec", "ImageSpec", "StreamSpec", "ArraysSpec", "PredictRequest", "ServerSpec", + "ServerState", "parse_mesh_shape", "config_from_env", "default_model_factory", "decode_request", "create_app", +] + +HARDWARE = "Tenstorrent Blackhole p150 (single chip) via tt-nn" +log = logging.getLogger("ttaw.server") + + +# ------------------------------------------------------------------------------------------- request schema + +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid") # a misspelled field is a 422, not a silently ignored option + + +class PointsSpec(_Strict): + format: Literal["bin", "npy", "npz", "pcd", "list"] = "bin" + data: Optional[str] = None # base64 of the file bytes (all formats except "list") + values: Optional[List[List[float]]] = None # "list" only + fields: Optional[List[str]] = None # column names (required meaning for "bin" / "list") + dtype: Literal["float32", "float64"] = "float32" + key: Optional[str] = None # "npz" only + frame_id: Optional[str] = None + + +class SweepSpec(_Strict): + points: PointsSpec + time_lag_s: float = 0.0 # age of this sweep relative to the current one + T_current_from_sweep: Optional[Any] = None # 4x4 / {translation, rotation_wxyz} / {x, y, z, roll, pitch, yaw} + + +class ImageSpec(_Strict): + camera: str + data: str # base64 PNG / JPEG (or .npy uint8 HxWx3) + format: Literal["auto", "png", "jpeg", "npy"] = "auto" + intrinsics: Optional[Any] = None # 3x3 / 3x4 / {fx, fy, cx, cy} + T_ref_from_camera: Optional[Any] = None + distortion: Optional[Dict[str, Any]] = None + timestamp_s: Optional[float] = None + + +class StreamSpec(_Strict): + id: str = "default" + reset: bool = False + timestamp_s: Optional[float] = None + T_world_from_ego: Optional[Any] = None + + +class ArraysSpec(_Strict): + format: Literal["npz", "json"] = "npz" + data: Optional[str] = None + arrays: Optional[Dict[str, Any]] = None + + +class PredictRequest(_Strict): + """The common ``POST /predict`` envelope. Bundles with extra inputs subclass it (e.g. PointPainting ``rois``) + and decode the extra fields in ``ServerSpec.decode_extra``.""" + + points: Optional[PointsSpec] = None + sweeps: Optional[List[SweepSpec]] = None + images: Optional[List[ImageSpec]] = None + calibration: Optional[Dict[str, Any]] = None + stream: Optional[StreamSpec] = None + inputs: Optional[ArraysSpec] = None + params: Dict[str, Any] = Field(default_factory=dict) + output_format: Literal["json", "npz"] = "json" + + +# ----------------------------------------------------------------------------------------------- config + +@dataclass +class ServerSpec: + """Identity and hooks of one bundle's server. ``model_cls`` is the bundle's ``ModelBase`` subclass (used by the + default factory and for ``CAMERA_ORDER`` / calibration rules).""" + + model_name: str + env_prefix: str + model_cls: Any = None + task: str = "" + default_weights: str = "" + owner: str = "changh95" + hardware: str = HARDWARE + io: str = "" + autoware: Mapping[str, Any] = field(default_factory=dict) + source: Mapping[str, Any] = field(default_factory=dict) + calib_dir: Optional[Path] = None + default_variant: Optional[str] = None + version: str = "0.1.0" + description: str = "" + request_model: Type[PredictRequest] = PredictRequest + # decode_extra(request, kwargs, max_bytes): add model-specific call kwargs from extra request fields + decode_extra: Optional[Callable[[Any, Dict[str, Any], int], None]] = None + # info_extra(model) -> dict merged into /info + info_extra: Optional[Callable[[Any], Dict[str, Any]]] = None + + +_SHAPE_RE = re.compile(r"^\s*[\(\[]?\s*(\d+)\s*[xX,]\s*(\d+)\s*[\)\]]?\s*$") + + +def parse_mesh_shape(value: Optional[str]) -> tuple: + """``"1x1"`` / ``"(1, 1)"`` / ``"1,1"`` -> ``(1, 1)``; unset -> ``(1, 1)``.""" + if value is None or not value.strip(): + return (1, 1) + m = _SHAPE_RE.match(value) + if not m: + raise RuntimeError(f"TT_MESH_SHAPE={value!r} is not a mesh shape; expected '1x1', '(1, 1)' or '1,1'") + return (int(m.group(1)), int(m.group(2))) + + +def config_from_env(spec: ServerSpec, env: Optional[Mapping[str, str]] = None) -> Dict[str, Any]: + """The server configuration from the environment (read once, in the lifespan).""" + env = os.environ if env is None else env + p = spec.env_prefix + warm = (env.get(f"{p}_WARMUP") or "default").strip() or "default" + default_variant = spec.default_variant or getattr(spec.model_cls, "DEFAULT_VARIANT", None) or "default" + return { + "model_id": env.get("HF_MODEL") or spec.default_weights, + "revision": env.get("TT_MODEL_WEIGHTS_REVISION") or env.get("TT_WEIGHTS_REVISION") or None, + "weights_dir": env.get(f"{p}_WEIGHTS_DIR") or None, + "mesh_shape": parse_mesh_shape(env.get("TT_MESH_SHAPE")), + "device_id": int(env.get("TT_DEVICE_ID") or 0), + "dispatch": (env.get(f"{p}_DISPATCH") or "eth").strip().lower(), + "num_command_queues": int(env.get(f"{p}_NUM_CQS") or 0) or None, + "variant": env.get(f"{p}_VARIANT") or default_variant, + "warmup_variants": warm if warm in ("default", "none") else json.loads(warm), + "max_body_bytes": int(float(env.get(f"{p}_MAX_BODY_MB") or 256) * (1 << 20)), + "torch_threads": env.get(f"{p}_TORCH_THREADS") or None, + } + + +def default_model_factory(spec: ServerSpec) -> Callable[[Dict[str, Any]], Any]: + """``cfg -> spec.model_cls.from_pretrained(...)``.""" + + def factory(cfg: Dict[str, Any]) -> Any: + if spec.model_cls is None: + raise RuntimeError(f"{spec.model_name}: ServerSpec.model_cls is not set and no model_factory was given") + return spec.model_cls.from_pretrained(cfg["model_id"] or None, revision=cfg["revision"], + weights_dir=cfg["weights_dir"], variant=cfg["variant"], + device_id=cfg["device_id"], dispatch=cfg["dispatch"], + num_command_queues=cfg["num_command_queues"], + warmup_variants=cfg["warmup_variants"]) + + return factory + + +@dataclass +class ServerState: + """Per-app state (``app.state.ttaw``).""" + + spec: ServerSpec + model_factory: Callable[[Dict[str, Any]], Any] + ready: bool = False + error: Optional[str] = None + model: Any = None + config: Dict[str, Any] = field(default_factory=dict) + boot_ms: Optional[float] = None + lock: Any = field(default_factory=threading.Lock) # one chip, batch 1: device calls are serialized + predict: Optional[Callable[[Any], Dict[str, Any]]] = None + + def status(self) -> str: + return "ok" if self.ready else ("error" if self.error else "starting") + + def device_desc(self) -> Optional[Dict[str, Any]]: + return getattr(self.model, "device_info", None) if self.model is not None else None + + +def _non_finite_path(obj: Any, path: str = "body") -> Optional[str]: + """Location of the first NaN / infinite float in a JSON-able body (None if there is none).""" + if isinstance(obj, float): + return None if math.isfinite(obj) else path + if isinstance(obj, Mapping): + for k, v in obj.items(): + found = _non_finite_path(v, f"{path}.{k}") + if found is not None: + return found + elif isinstance(obj, (list, tuple)): + for i, v in enumerate(obj): + found = _non_finite_path(v, f"{path}[{i}]") + if found is not None: + return found + return None + + +def decode_request(req: PredictRequest, spec: ServerSpec, max_bytes: int) -> Dict[str, Any]: + """JSON envelope -> the Python-API call kwargs (all client errors raise ``io.InputError``).""" + cls = spec.model_cls + fields = tuple(getattr(cls, "POINT_FIELDS", tio.DEFAULT_POINT_FIELDS)) + kw: Dict[str, Any] = {} + if req.points is not None: + kw["points"] = tio.decode_points(req.points.model_dump(exclude_none=True), max_bytes=max_bytes, + default_fields=fields) + if req.sweeps: + kw["sweeps"] = [{"points": tio.decode_points(s.points.model_dump(exclude_none=True), max_bytes=max_bytes, + default_fields=fields), + "time_lag_s": s.time_lag_s, + "T_current_from_sweep": None if s.T_current_from_sweep is None else + tio.parse_transform(s.T_current_from_sweep, field="sweeps[].T_current_from_sweep")} + for s in req.sweeps] + calib = tio.resolve_calibration(req.calibration, spec.calib_dir) + if req.images: + order = tuple(getattr(cls, "CAMERA_ORDER", ()) or ()) or None + require = cls.requires_calibration() if hasattr(cls, "requires_calibration") else False + kw["images"] = tio.decode_cameras([i.model_dump(exclude_none=True) for i in req.images], calib, order=order, + require_calibration=require, max_bytes=max_bytes) + if calib is not None: + kw["calibration"] = calib + if req.stream is not None: + s = req.stream.model_dump(exclude_none=True) + if "T_world_from_ego" in s: + s["T_world_from_ego"] = tio.parse_transform(s["T_world_from_ego"], field="stream.T_world_from_ego") + kw["stream"] = s + if req.inputs is not None: + kw["inputs"] = tio.decode_named_arrays(req.inputs.model_dump(exclude_none=True), max_bytes=max_bytes) + if spec.decode_extra is not None: + spec.decode_extra(req, kw, max_bytes) + return kw + + +def create_app(spec: ServerSpec, *, model_factory: Optional[Callable[[Dict[str, Any]], Any]] = None) -> FastAPI: + """The ASGI app of one bundle (see the module docstring).""" + state = ServerState(spec, model_factory or default_model_factory(spec)) + RequestModel = spec.request_model + + @asynccontextmanager + async def lifespan(_app: FastAPI): + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + cfg = config_from_env(spec) + if cfg["mesh_shape"] != (1, 1): + raise RuntimeError(f"TT_MESH_SHAPE={cfg['mesh_shape']} is a multi-chip mesh; {spec.model_name} runs on " + "exactly one Blackhole chip (mesh_device P150, shape 1x1)") + if cfg["torch_threads"]: + import torch + + torch.set_num_threads(int(cfg["torch_threads"])) + state.config, state.ready, state.error = cfg, False, None + t0 = time.perf_counter() + try: + state.model = state.model_factory(cfg) # logs: Loading weights / Opening device / Warming up / complete + except Exception as e: # startup failures exit uvicorn non-zero: no silent CPU fallback + state.error = f"{type(e).__name__}: {e}" + log.exception("startup failed") + raise + state.boot_ms = (time.perf_counter() - t0) * 1e3 + state.ready = True + log.info("%s ready in %.1f s (%s)", spec.model_name, state.boot_ms / 1e3, state.device_desc()) + try: + yield + finally: + state.ready = False + model, state.model = state.model, None + if model is not None: + log.info("Closing the model and the device") + with state.lock: + model.close() + + app = FastAPI(title=spec.model_name, version=spec.version, lifespan=lifespan, + description=spec.description or f"{spec.task} on one Tenstorrent Blackhole p150. " + "Not an OpenAI-compatible API.") + app.state.ttaw = state + + @app.middleware("http") + async def _body_limit(request: Request, call_next): + limit = (state.config or {}).get("max_body_bytes") + length = request.headers.get("content-length") + if limit and length and length.isdigit() and int(length) > limit * 4 // 3 + 4096: + return JSONResponse(status_code=400, content={"detail": f"request body of {length} bytes exceeds " + f"{spec.env_prefix}_MAX_BODY_MB"}) + return await call_next(request) + + @app.get("/") + def root() -> dict: + return {"model": spec.model_name, "routes": ["/health", "/v1/health", "/info", "/v1/models", "/predict"], + "docs": "/docs"} + + @app.get("/health") + def health() -> dict: + """Always 200: ``ok`` after warm-up, ``starting`` before, ``error`` if the boot failed.""" + return {"status": state.status(), "model": spec.model_name, "device": state.device_desc(), + "error": state.error} + + @app.get("/v1/health") + def v1_health() -> dict: # tt-model's hint for non-chat packages points here + return health() + + @app.get("/v1/models") + def v1_models() -> dict: + """OpenAI-shaped stub so tt-model's ready card / probes do not 404. This is NOT a chat API.""" + return {"object": "list", "data": [{"id": state.config.get("model_id") or spec.default_weights, + "object": "model", "owned_by": spec.owner}]} + + @app.get("/info") + def info() -> dict: + m = state.model + minfo = dict(getattr(m, "info", {}) or {}) if m is not None else {} + calib = sorted(p.stem for p in Path(spec.calib_dir).glob("*.json")) if spec.calib_dir else [] + body = { + "model": spec.model_name, "task": spec.task, "status": state.status(), "hardware": spec.hardware, + "io": spec.io, "autoware": dict(spec.autoware), + "weights": minfo.get("weights") or {"repo": state.config.get("model_id") or spec.default_weights}, + "device": state.device_desc(), "mesh_shape": os.environ.get("TT_MESH_SHAPE", "1x1"), + "input": {"kind": minfo.get("input_kind"), "point_fields": minfo.get("point_fields"), + "camera_order": minfo.get("camera_order"), "batch_size": 1, + "point_formats": list(tio.POINT_FORMATS), "max_body_bytes": state.config.get("max_body_bytes"), + "calibration_presets": calib, "extra_inputs": minfo.get("extra_inputs"), + "input_schema": minfo.get("input_schema")}, + "output": {"labels": minfo.get("labels")}, + "variant": minfo.get("variant"), "warm_variants": minfo.get("warm_variants"), + "runtime_params": minfo.get("runtime_params"), "compile_params": minfo.get("compile_params"), + "warmup_ms": minfo.get("warmup_ms"), "boot_ms": state.boot_ms, "source": dict(spec.source), + } + if spec.info_extra is not None and m is not None: + body.update(spec.info_extra(m)) + return body + + @app.post("/predict") + def predict(req: RequestModel) -> dict: + """One frame -> the model output's ``to_dict`` + ``timing_ms``. Sync handler: uvicorn runs it in a worker + thread; the lock serializes the device.""" + if not state.ready: + raise HTTPException(status_code=503, detail="model is still starting" if not state.error + else f"model failed to start: {state.error}") + model = state.model + t0 = time.perf_counter() + try: + kw = decode_request(req, spec, state.config.get("max_body_bytes") or (256 << 20)) + params = model.validate_params(req.params) + except tio.InputError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + t1 = time.perf_counter() + try: + with state.lock: + out = model(**kw, **params) + except tio.InputError as e: # raised by the model's own pre-processing (shape / range checks) + raise HTTPException(status_code=400, detail=str(e)) from None + except Exception as e: # noqa: BLE001 -- a device / runtime failure + log.exception("inference failed") + raise HTTPException(status_code=500, detail=f"inference failed: {type(e).__name__}: {e}") from None + t2 = time.perf_counter() + body = out.to_dict(req.output_format) + bad = _non_finite_path(body) + if bad is not None: # strict JSON has no NaN / Infinity: say where, instead of a bare 500 from the encoder + log.error("inference produced a non-finite value at %s", bad) + raise HTTPException(status_code=500, detail=f"inference failed: non-finite value at {bad}") + timing = dict(body.get("timing_ms") or {}) + timing.update({"decode": round((t1 - t0) * 1e3, 3), "model_call": round((t2 - t1) * 1e3, 3), + "total": round((time.perf_counter() - t0) * 1e3, 3)}) + body["timing_ms"] = timing + return body + + state.predict = predict + return app diff --git a/code/tt_frnet/ttaw/server/client.py b/code/tt_frnet/ttaw/server/client.py new file mode 100644 index 0000000000000000000000000000000000000000..dbea7d078fcbe83c23ab89c714b62ede9eb19e92 --- /dev/null +++ b/code/tt_frnet/ttaw/server/client.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""C08 client: build / send ``POST /predict`` requests and run smoke checks. Standard library only. + +This module imports nothing from ttaw (no relative imports), so it works three ways: as ``.ttaw.server.client``, +as a script, and loaded by path from a bundle's ``smoke_test.py`` running under the host's system ``python3`` +(``container_smoke.sh``):: + + HERE = Path(__file__).resolve().parent # code//server + sys.path.insert(0, str(HERE.parent / "ttaw" / "server")) + import client as ttaw_client + +CLI:: + + python client.py --points sample.npz --out req.json # write the request + python client.py --points sample.npz --url http://127.0.0.1:20000 # send it, print the response + python client.py --image CAM_FRONT=f.jpg --calib-preset rig --url ... # cameras + python client.py --inputs scene.npz --url ... # planner +""" +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +__all__ = ["POINT_SUFFIXES", "b64file", "build_request", "post", "get_json", "wait_ready", "check_service", + "check_bad_request", "smoke_line", "main"] + +POINT_SUFFIXES = {".bin": "bin", ".npy": "npy", ".npz": "npz", ".pcd": "pcd"} + + +def b64file(path: Any) -> str: + return base64.b64encode(Path(path).read_bytes()).decode("ascii") + + +def build_request(points: Optional[str] = None, fields: Optional[Sequence[str]] = None, images: Iterable[str] = (), + calib: Optional[str] = None, calib_preset: Optional[str] = None, inputs: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, stream_id: Optional[str] = None, reset: bool = False, + output_format: str = "json") -> Dict[str, Any]: + """A ``/predict`` body from files: ``points`` path (.bin needs ``fields``), ``images`` as ``NAME=PATH``, + ``calib`` JSON file or ``calib_preset`` name, ``inputs`` .npz (planner), ``params``, stream id / reset.""" + req: Dict[str, Any] = {} + if points: + p = Path(points) + fmt = POINT_SUFFIXES.get(p.suffix.lower()) + if fmt is None: + raise ValueError(f"points: unknown suffix {p.suffix!r} (use .bin / .npy / .npz / .pcd)") + req["points"] = {"format": fmt, "data": b64file(p)} + if fields: + req["points"]["fields"] = list(fields) + images = list(images) + if images: + req["images"] = [] + for spec in images: + name, _, path = spec.partition("=") + if not path: + raise ValueError(f"image expects NAME=PATH, got {spec!r}") + req["images"].append({"camera": name, "data": b64file(path)}) + if calib: + req["calibration"] = json.loads(Path(calib).read_text()) + elif calib_preset: + req["calibration"] = {"preset": calib_preset} + if inputs: + req["inputs"] = {"format": "npz", "data": b64file(inputs)} + if params: + req["params"] = dict(params) + if stream_id is not None or reset: + req["stream"] = {"id": stream_id or "default", "reset": bool(reset)} + if output_format != "json": + req["output_format"] = output_format + return req + + +def post(url: str, payload: Dict[str, Any], timeout: float = 120.0) -> Tuple[int, Dict[str, Any]]: + """POST ``payload`` to ``/predict`` -> ``(status, json body)`` (HTTP errors are returned, not raised).""" + data = json.dumps(payload).encode("utf-8") + r = urllib.request.Request(url.rstrip("/") + "/predict", data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(r, timeout=timeout) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "replace") + try: + return e.code, json.loads(body) + except ValueError: + return e.code, {"detail": body} + + +def get_json(url: str, timeout: float = 10.0) -> Dict[str, Any]: + with urllib.request.urlopen(url, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8")) + + +def wait_ready(base: str, wait_s: float = 0.0, poll_s: float = 5.0) -> Dict[str, Any]: + """Poll ``/health`` until ``ok`` / ``error`` or ``wait_s`` elapses; returns the last health body.""" + deadline = time.time() + wait_s + last: Dict[str, Any] = {} + while True: + try: + last = get_json(base.rstrip("/") + "/health") + if last.get("status") in ("ok", "error"): + return last + except (urllib.error.URLError, OSError, ValueError) as e: + last = {"status": f"unreachable ({e})"} + if time.time() >= deadline: + return last + time.sleep(poll_s) + + +def check_service(base: str, *, expect_dispatch: Optional[str] = "eth", + expect_grid: Optional[str] = "12x10") -> Tuple[Dict[str, Any], List[str]]: + """``/info`` and ``/v1/models`` answer; ``/info`` reports the pinned dispatch and grid (PLAN.md 0.3 item 6: + the smoke must assert ETH dispatch and 12x10, not just print them). Returns ``(info, failures)``.""" + fails: List[str] = [] + try: + info = get_json(base.rstrip("/") + "/info") + models = get_json(base.rstrip("/") + "/v1/models") + except (urllib.error.URLError, OSError, ValueError) as e: + return {}, [f"/info or /v1/models: {e}"] + if not models.get("data"): + fails.append("/v1/models is empty") + device = info.get("device") or {} + if expect_dispatch and device.get("dispatch") != expect_dispatch: + fails.append(f"dispatch is {device.get('dispatch')!r}, expected {expect_dispatch!r}") + if expect_grid and device.get("grid") != expect_grid: + fails.append(f"grid is {device.get('grid')!r}, expected {expect_grid!r}") + return info, fails + + +def check_bad_request(base: str, payload: Optional[Dict[str, Any]] = None) -> Optional[str]: + """A malformed request must answer 400 (not 500). Returns a failure message or None.""" + code, _ = post(base, payload or {"points": {"format": "npy", "data": "not base64!"}}) + return None if code == 400 else f"malformed request answered {code}, expected 400" + + +def smoke_line(model: str, summary: str, failures: Sequence[str]) -> str: + """The one-line verdict container_smoke.sh greps: ``PASS : ...`` / ``FAIL : ... :: reasons``.""" + if failures: + return f"FAIL {model}: {summary} :: " + "; ".join(list(failures)[:5]) + return f"PASS {model}: {summary}" + + +def _param(text: str) -> Tuple[str, Any]: + k, _, v = text.partition("=") + try: + return k, json.loads(v) + except ValueError: + return k, v + + +def main(argv: Optional[Sequence[str]] = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--points") + ap.add_argument("--fields", help="comma list, e.g. x,y,z,intensity (needed for .bin)") + ap.add_argument("--image", action="append", default=[], help="NAME=PATH, repeat per camera") + ap.add_argument("--calib", help="calibration JSON file") + ap.add_argument("--calib-preset", help="a preset name listed by /info") + ap.add_argument("--inputs", help=".npz of named input tensors (planner)") + ap.add_argument("--param", action="append", default=[], help="k=v (JSON value), repeat") + ap.add_argument("--stream-id") + ap.add_argument("--reset", action="store_true") + ap.add_argument("--output-format", default="json", choices=["json", "npz"]) + ap.add_argument("--out", help="write the request JSON here") + ap.add_argument("--url", help="POST it to this server and print the response") + a = ap.parse_args(argv) + req = build_request(a.points, a.fields.split(",") if a.fields else None, a.image, a.calib, a.calib_preset, + a.inputs, dict(_param(p) for p in a.param), a.stream_id, a.reset, a.output_format) + if a.out: + Path(a.out).write_text(json.dumps(req)) + if a.url: + code, body = post(a.url, req) + print(json.dumps(body, indent=1)[:20000]) + return 0 if code == 200 else 1 + if not a.out: + json.dump(req, sys.stdout) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/tt_frnet/ttaw/server/smoke.py b/code/tt_frnet/ttaw/server/smoke.py new file mode 100644 index 0000000000000000000000000000000000000000..ba75158cec2288b7f9ed9555a9d206da625bee6b --- /dev/null +++ b/code/tt_frnet/ttaw/server/smoke.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""C08 smoke checks beyond HTTP: does a served ``/predict`` body agree with the stored CPU-reference body of the +same sample, and does ``/info`` report what the staged tt-model package pins for the served profile. Standard +library only. + +Like :mod:`client`, this module imports nothing from ttaw, so a bundle's ``smoke_test.py`` (run by +``container_smoke.sh`` with the host's ``python3``, which has neither numpy nor the bundle) loads it by path:: + + sys.path.insert(0, str(HERE.parent / "ttaw" / "server")) # HERE = code//server + import client as ttaw_client + import smoke as ttaw_smoke + + info, fails = ttaw_client.check_service(url, expect_dispatch="eth", expect_grid="12x10") + pinned = ttaw_smoke.pinned_config(f"{staged}/tt_kernel_manifest.json", profile) + fails += ttaw_smoke.check_pinned(info, pinned, "CENTERPOINT") # /info runs what serve.env pins + ref = ttaw_smoke.find_reference(SAMPLE, pinned["profile"], info.get("variant")) + metrics, more = ttaw_smoke.compare_with_reference(body, ttaw_smoke.load_json(ref), gates={"min_recall": 0.9}) + +The stored reference is the ``to_dict()`` body of the fp32 CPU reference on the shipped sample, saved next to it as +``.reference.json``, or ``..reference.json`` / ``..reference.json`` +when the output depends on the serve profile or the model variant. Synthetic samples may give no detections, so +agreement with the reference, not a detection count, is their smoke gate (PLAN.md section 6.3). + +What is compared, by the keys of the reference body (BUNDLE_CONVENTIONS.md section 7.4): + +- ``detections``: greedy same-label matching, reference rows in descending score order; 3-D rows (``center``) + match by BEV centre distance, 2-D rows (``box_xyxy``) by IoU; gates on recall, precision and the largest score + difference of a matched pair; +- ``trajectory``: average / final displacement over the ``x`` and ``y`` columns; +- every encoded array (``ttaw.io.encode_array`` npz / npz_compressed / raw / list, ``encode_png``), e.g. + per-point ``labels``, a ``mask`` or a ``semseg`` extra: integer arrays by the fraction of equal elements, float + arrays by the largest absolute difference; +- ``model`` and ``frame_id`` must be equal. +""" +from __future__ import annotations + +import ast +import base64 +import io +import json +import math +import re +import struct +import sys +import zipfile +import zlib +from pathlib import Path +from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Sequence, Tuple, Union + +__all__ = ["DEFAULT_GATES", "ARRAY_FORMATS", "Array", "load_json", "decode_array", "find_reference", + "compare_with_reference", "describe_metrics", "pinned_config", "check_pinned"] + +ARRAY_FORMATS = ("npz", "npz_compressed", "raw", "list", "png") +DEFAULT_GATES: Dict[str, Optional[float]] = { + "max_center_dist": 0.5, # 3-D detections: same-label BEV centre distance of a match (m) + "min_iou": 0.5, # 2-D detections: same-label box IoU of a match + "min_recall": 0.95, # matched / reference detections + "min_precision": 0.95, # matched / served detections + "max_score_err": 0.05, # largest |score difference| of a matched pair + "min_label_agreement": 0.99, # integer arrays (per-point labels, masks): fraction of equal elements + "max_abs_err": None, # float arrays: largest |difference| (reported only, unless set) + "max_ade": 0.5, # trajectories: average displacement over x, y (m) + "max_fde": 1.0, # trajectories: final displacement (m) +} + +PathLike = Union[str, Path] +# numpy type code (without the byte-order character) -> (struct code, numpy dtype name) +_TYPES = {"b1": ("?", "bool"), "u1": ("B", "uint8"), "u2": ("H", "uint16"), "u4": ("I", "uint32"), + "u8": ("Q", "uint64"), "i1": ("b", "int8"), "i2": ("h", "int16"), "i4": ("i", "int32"), + "i8": ("q", "int64"), "f2": ("e", "float16"), "f4": ("f", "float32"), "f8": ("d", "float64")} +_BY_NAME = {name: code for code, name in _TYPES.values()} +_SAFE_KEY = re.compile(r"[A-Za-z0-9_.-]+") + + +class Array(NamedTuple): + """A decoded array: ``values`` are the elements in C order (flattened).""" + + shape: Tuple[int, ...] + dtype: str + values: List[Any] + + @property + def is_float(self) -> bool: + return self.dtype.startswith("float") + + +def load_json(path: PathLike) -> Dict[str, Any]: + return json.loads(Path(path).read_text()) + + +# ------------------------------------------------------------------------------------------------ decoding + +def _count(shape: Sequence[int]) -> int: + n = 1 + for d in shape: + n *= int(d) + return n + + +def _fortran_to_c(values: List[Any], shape: Sequence[int]) -> List[Any]: + """Reorder column-major elements into row-major order.""" + strides, step = [], 1 + for d in shape: + strides.append(step) + step *= d + out, index = [], [0] * len(shape) + for _ in range(len(values)): + out.append(values[sum(i * s for i, s in zip(index, strides))]) + for k in range(len(shape) - 1, -1, -1): + index[k] += 1 + if index[k] < shape[k]: + break + index[k] = 0 + return out + + +def _npy(raw: bytes) -> Array: + """One ``.npy`` file (format 1.0 / 2.0 / 3.0, plain numeric dtypes).""" + if raw[:6] != b"\x93NUMPY": + raise ValueError("not an .npy file") + major = raw[6] + if major == 1: + (hlen,), start = struct.unpack("": ">", "=": "<" if sys.byteorder == "little" else ">"}.get(descr[0], "<") + code, name = _TYPES[descr[1:]] + n = _count(shape) + data = raw[start + hlen:] + size = n * struct.calcsize("<" + code) + if len(data) < size: + raise ValueError(f".npy data holds {len(data)} bytes, {size} expected") + values = list(struct.unpack(f"{order}{n}{code}", data[:size])) + if header.get("fortran_order") and len(shape) > 1: + values = _fortran_to_c(values, shape) + return Array(shape, name, values) + + +def _paeth(a: int, b: int, c: int) -> int: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + if pa <= pb and pa <= pc: + return a + return b if pb <= pc else c + + +def _png(raw: bytes) -> Array: + """An 8-bit, non-interlaced grey / grey+alpha / RGB / RGBA PNG (what ``ttaw.io.encode_png`` writes).""" + if raw[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a PNG file") + pos, idat, head = 8, [], None + while pos + 8 <= len(raw): + length, kind = struct.unpack(">I4s", raw[pos:pos + 8]) + chunk = raw[pos + 8:pos + 8 + length] + pos += 12 + length + if kind == b"IHDR": + head = struct.unpack(">IIBBBBB", chunk) + elif kind == b"IDAT": + idat.append(chunk) + elif kind == b"IEND": + break + if head is None: + raise ValueError("PNG without IHDR") + width, height, depth, color, _, _, interlace = head + channels = {0: 1, 2: 3, 4: 2, 6: 4}.get(color) + if depth != 8 or channels is None or interlace: + raise ValueError(f"PNG bit depth {depth}, colour type {color}, interlace {interlace} is not supported") + data = zlib.decompress(b"".join(idat)) + stride, bpp = width * channels, channels + if len(data) < height * (stride + 1): + raise ValueError("truncated PNG image data") + out, prev = bytearray(), bytearray(stride) + for y in range(height): + base = y * (stride + 1) + ftype, row = data[base], bytearray(data[base + 1:base + 1 + stride]) + if ftype == 1: + for i in range(bpp, stride): + row[i] = (row[i] + row[i - bpp]) & 0xFF + elif ftype == 2: + for i in range(stride): + row[i] = (row[i] + prev[i]) & 0xFF + elif ftype == 3: + for i in range(stride): + left = row[i - bpp] if i >= bpp else 0 + row[i] = (row[i] + ((left + prev[i]) >> 1)) & 0xFF + elif ftype == 4: + for i in range(stride): + left, upper_left = (row[i - bpp], prev[i - bpp]) if i >= bpp else (0, 0) + row[i] = (row[i] + _paeth(left, prev[i], upper_left)) & 0xFF + elif ftype != 0: + raise ValueError(f"PNG filter type {ftype} is invalid") + out += row + prev = row + shape = (height, width) if channels == 1 else (height, width, channels) + return Array(shape, "uint8", list(out)) + + +def _flatten(value: Any, out: List[Any]) -> List[Any]: + if isinstance(value, list): + for v in value: + _flatten(v, out) + else: + out.append(value) + return out + + +def decode_array(spec: Mapping[str, Any]) -> Array: + """Decode an encoded output array ``{"format", "key", "dtype", "shape", "data"}`` (``ttaw.io.encode_array`` / + ``encode_png``) without numpy. Raises ``ValueError`` for anything it cannot decode.""" + fmt = spec.get("format") + if fmt not in ARRAY_FORMATS: + raise ValueError(f"array format {fmt!r} is not one of {ARRAY_FORMATS}") + if fmt == "list": + dtype = str(spec.get("dtype") or "float64") + arr = Array(tuple(int(d) for d in spec.get("shape") or ()), dtype, _flatten(spec.get("data"), [])) + else: + raw = base64.b64decode(spec.get("data") or "") + if fmt == "png": + arr = _png(raw) + elif fmt == "raw": + dtype = str(spec.get("dtype")) + if dtype not in _BY_NAME: + raise ValueError(f"raw array dtype {dtype!r} is not supported") + shape = tuple(int(d) for d in spec.get("shape") or ()) + n, code = _count(shape), _BY_NAME[dtype] + if len(raw) != n * struct.calcsize("<" + code): + raise ValueError(f"raw array holds {len(raw)} bytes, shape {list(shape)} {dtype} needs " + f"{n * struct.calcsize('<' + code)}") + arr = Array(shape, dtype, list(struct.unpack(f"<{n}{code}", raw))) + else: + with zipfile.ZipFile(io.BytesIO(raw)) as z: + names = z.namelist() + member = f"{spec.get('key')}.npy" + if member not in names: + if len(names) != 1: + raise ValueError(f"npz has no member {member!r} (members {names})") + member = names[0] + arr = _npy(z.read(member)) + if "shape" in spec and list(arr.shape) != [int(d) for d in spec["shape"]]: + raise ValueError(f"decoded shape {list(arr.shape)} differs from the declared {list(spec['shape'])}") + if len(arr.values) != _count(arr.shape): + raise ValueError(f"{len(arr.values)} elements do not fill shape {list(arr.shape)}") + return arr + + +def _is_encoded_array(value: Any) -> bool: + return isinstance(value, Mapping) and value.get("format") in ARRAY_FORMATS and "data" in value + + +# ---------------------------------------------------------------------------------------------- comparison + +def find_reference(sample: PathLike, *keys: Optional[str]) -> Optional[Path]: + """The stored CPU-reference body of ``sample``: the first existing ``..reference.json`` for the + given keys (e.g. the serve profile, then the model variant), else ``.reference.json``; None if absent.""" + p = Path(sample) + names = [f"{p.stem}.{k}.reference.json" for k in keys if k and _SAFE_KEY.fullmatch(str(k))] + for name in names + [f"{p.stem}.reference.json"]: + if (p.parent / name).is_file(): + return p.parent / name + return None + + +def _label(row: Mapping[str, Any]) -> Any: + return row.get("label_id", row.get("label")) + + +def _iou(a: Sequence[float], b: Sequence[float]) -> float: + ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0])) + iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1])) + inter = ix * iy + union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter + return inter / union if union > 0 else 0.0 + + +def _greedy_match(test: List[Mapping[str, Any]], ref: List[Mapping[str, Any]], + cost: Callable[[Mapping[str, Any], Mapping[str, Any]], float], + accept: Callable[[float], bool]) -> List[Tuple[int, int, float]]: + """Reference rows in descending score order (stable) each take the cheapest unused same-label test row; + returns ``(test index, reference index, cost)`` of the accepted pairs.""" + used = [False] * len(test) + pairs = [] + for i in sorted(range(len(ref)), key=lambda k: -float(ref[k].get("score", 0.0))): + best, best_cost = -1, math.inf + for j, row in enumerate(test): + if not used[j] and _label(row) == _label(ref[i]): + c = cost(row, ref[i]) + if c < best_cost: + best, best_cost = j, c + if best >= 0 and accept(best_cost): + used[best] = True + pairs.append((best, i, best_cost)) + return pairs + + +def _compare_detections(test: Any, ref: List[Mapping[str, Any]], + g: Mapping[str, Any]) -> Tuple[Dict[str, Any], List[str]]: + if not isinstance(test, list): + return {}, ["the response has no 'detections' list"] + rows = ref + test + if any("center" in d for d in rows): + kind = "3d" + pairs = _greedy_match(test, ref, lambda t, r: math.hypot(t["center"][0] - r["center"][0], + t["center"][1] - r["center"][1]), + lambda c: c <= g["max_center_dist"]) + elif any("box_xyxy" in d for d in rows): + kind = "2d" + pairs = _greedy_match(test, ref, lambda t, r: 1.0 - _iou(t["box_xyxy"], r["box_xyxy"]), + lambda c: 1.0 - c >= g["min_iou"]) + elif not rows: # nothing detected on either side (e.g. a synthetic sample): agreement + kind, pairs = "none", [] + else: + return {}, ["detections carry neither 'center' nor 'box_xyxy'"] + score_err = max((abs(float(test[j]["score"]) - float(ref[i]["score"])) for j, i, _ in pairs), default=0.0) + m: Dict[str, Any] = {"kind": kind, "n": len(test), "n_ref": len(ref), "matched": len(pairs), + "recall": len(pairs) / len(ref) if ref else 1.0, + "precision": len(pairs) / len(test) if test else 1.0, "score_err_max": score_err} + if kind == "3d": + m["center_err_max"] = max((c for _, _, c in pairs), default=0.0) + elif kind == "2d": + m["iou_min"] = min((1.0 - c for _, _, c in pairs), default=1.0) + fails = [] + if m["recall"] < g["min_recall"]: + fails.append(f"detections recall {m['recall']:.3f} < {g['min_recall']} ({len(pairs)}/{len(ref)} reference " + "detections matched)") + if m["precision"] < g["min_precision"]: + fails.append(f"detections precision {m['precision']:.3f} < {g['min_precision']} ({len(pairs)}/{len(test)} " + "served detections matched)") + if score_err > g["max_score_err"]: + fails.append(f"detections max |score difference| {score_err:.4f} > {g['max_score_err']}") + return m, fails + + +def _compare_trajectory(body: Mapping[str, Any], ref: Mapping[str, Any], + g: Mapping[str, Any]) -> Tuple[Dict[str, Any], List[str]]: + test, want = body.get("trajectory"), ref["trajectory"] + if not isinstance(test, list): + return {}, ["the response has no 'trajectory'"] + if len(test) != len(want): + return {"n": len(test), "n_ref": len(want)}, [f"trajectory has {len(test)} poses, the reference {len(want)}"] + columns = list(ref.get("columns") or ["x", "y"]) + if body.get("columns", columns) != columns: + return {}, [f"trajectory columns {body.get('columns')} differ from the reference {columns}"] + ix, iy = (columns.index("x"), columns.index("y")) if {"x", "y"} <= set(columns) else (0, 1) + disp = [math.hypot(a[ix] - b[ix], a[iy] - b[iy]) for a, b in zip(test, want)] + m = {"n": len(test), "ade": sum(disp) / len(disp) if disp else 0.0, "fde": disp[-1] if disp else 0.0} + fails = [] + if not m["ade"] <= g["max_ade"]: + fails.append(f"trajectory ADE {m['ade']:.4f} > {g['max_ade']}") + if not m["fde"] <= g["max_fde"]: + fails.append(f"trajectory FDE {m['fde']:.4f} > {g['max_fde']}") + return m, fails + + +def _compare_array(name: str, test: Any, ref: Mapping[str, Any], + g: Mapping[str, Any]) -> Tuple[Dict[str, Any], List[str]]: + if not _is_encoded_array(test): + return {}, [f"the response has no encoded array {name!r}"] + a, b = decode_array(test), decode_array(ref) + if a.shape != b.shape: + return {"shape": list(a.shape), "shape_ref": list(b.shape)}, [ + f"{name}: shape {list(a.shape)} differs from the reference {list(b.shape)}"] + if a.is_float or b.is_float: + err = 0.0 + for x, y in zip(a.values, b.values): + if x == y: # equal values, equal infinities included + continue + d = abs(float(x) - float(y)) + if d != d: # NaN on either side + err = math.nan + break + err = max(err, d) + limit = g["max_abs_err"] + fails = [f"{name}: max |difference| {err:.6g} > {limit}"] if limit is not None and not err <= limit else [] + return {"max_abs_err": err, "n": len(b.values)}, fails + equal = sum(1 for x, y in zip(a.values, b.values) if x == y) + agreement = equal / len(b.values) if b.values else 1.0 + fails = [] + if agreement < g["min_label_agreement"]: + fails.append(f"{name}: agreement {agreement:.4f} < {g['min_label_agreement']} " + f"({len(b.values) - equal} of {len(b.values)} elements differ)") + return {"agreement": agreement, "n": len(b.values)}, fails + + +def compare_with_reference(body: Mapping[str, Any], reference: Mapping[str, Any], + gates: Optional[Mapping[str, Optional[float]]] = None + ) -> Tuple[Dict[str, Dict[str, Any]], List[str]]: + """Compare a served ``/predict`` body with the stored CPU-reference body of the same input (module docstring). + ``gates`` override :data:`DEFAULT_GATES` (unknown names raise ``ValueError``). Returns ``(metrics per compared + key, failures)``; a body that cannot be decoded is a failure, not an exception.""" + unknown = sorted(set(gates or {}) - set(DEFAULT_GATES)) + if unknown: + raise ValueError(f"unknown reference gates {unknown}; known: {sorted(DEFAULT_GATES)}") + g = {**DEFAULT_GATES, **dict(gates or {})} + metrics: Dict[str, Dict[str, Any]] = {} + fails = [f"{k} is {body.get(k)!r}, the reference has {reference[k]!r}" for k in ("model", "frame_id") + if k in reference and body.get(k) != reference[k]] + checks: List[Tuple[str, Callable[[], Tuple[Dict[str, Any], List[str]]]]] = [] + if "detections" in reference: + checks.append(("detections", lambda: _compare_detections(body.get("detections"), reference["detections"], g))) + if "trajectory" in reference: + checks.append(("trajectory", lambda: _compare_trajectory(body, reference, g))) + for key, value in reference.items(): + if key != "arrays" and _is_encoded_array(value): + checks.append((key, lambda k=key, v=value: _compare_array(k, body.get(k), v, g))) + if not checks: + fails.append("the reference holds nothing to compare (no detections, trajectory or encoded arrays)") + for key, check in checks: + try: + metrics[key], more = check() + except (ValueError, KeyError, TypeError, IndexError, struct.error, zlib.error, zipfile.BadZipFile) as e: + metrics[key], more = {}, [f"{key}: cannot compare ({type(e).__name__}: {e})"] + fails += more + return metrics, fails + + +def describe_metrics(metrics: Mapping[str, Mapping[str, Any]]) -> str: + """One compact line for the smoke verdict, e.g. ``detections 5/5 recall=1.000 precision=1.000 dscore=0.0040``.""" + parts = [] + for key, m in metrics.items(): + if "recall" in m: + parts.append(f"{key} {m['matched']}/{m['n_ref']} recall={m['recall']:.3f} " + f"precision={m['precision']:.3f} dscore={m['score_err_max']:.4f}") + elif "ade" in m: + parts.append(f"{key} ade={m['ade']:.4f} fde={m['fde']:.4f}") + elif "agreement" in m: + parts.append(f"{key} agree={m['agreement']:.4f}") + elif "max_abs_err" in m: + parts.append(f"{key} max_abs={m['max_abs_err']:.4g}") + else: + parts.append(f"{key} -") + return "; ".join(parts) or "-" + + +# ----------------------------------------------------------------------------------------- package pins + +def pinned_config(manifest: PathLike, profile: Optional[str] = None) -> Dict[str, Any]: + """What a staged package (``//tt_kernel_manifest.json``) pins for one serve profile (default: the + package's default profile): ``{"profile": name, "env": serve.env with the profile's env on top (tt-model's + merge rule), "weights": {"repo", "revision"}}``.""" + m = load_json(manifest) + c = m.get("container") or {} + profiles = c.get("serve_profiles") or [{"name": "default", "env": {}}] + name = profile or c.get("default_profile") or profiles[0].get("name") + chosen = [p for p in profiles if p.get("name") == name] + if not chosen: + raise ValueError(f"no serve profile {name!r} in {manifest}; available: {[p.get('name') for p in profiles]}") + env = dict((c.get("serve") or {}).get("env") or {}) + env.update({k: v for k, v in (chosen[0].get("env") or {}).items() if v is not None}) + weights = m.get("weights") or {} + return {"profile": name, "env": {k: str(v) for k, v in env.items()}, + "weights": {"repo": weights.get("repo_id"), "revision": weights.get("revision")}} + + +def check_pinned(info: Mapping[str, Any], pinned: Mapping[str, Any], prefix: str) -> List[str]: + """``/info`` runs what the package pins: ``_DISPATCH`` (eth | worker), ``_NUM_CQS``, + ``_VARIANT`` and the weights revision. Pins that are absent are not checked. Returns failures.""" + env = pinned.get("env") or {} + device = info.get("device") or {} + profile = pinned.get("profile") + fails = [] + dispatch = (env.get(f"{prefix}_DISPATCH") or "").strip().lower() + if dispatch in ("eth", "worker") and device.get("dispatch") != dispatch: + fails.append(f"dispatch is {device.get('dispatch')!r}, profile {profile!r} pins {prefix}_DISPATCH={dispatch}") + cqs = (env.get(f"{prefix}_NUM_CQS") or "").strip() + if cqs and str(device.get("num_command_queues")) != cqs: + fails.append(f"num_command_queues is {device.get('num_command_queues')!r}, profile {profile!r} pins " + f"{prefix}_NUM_CQS={cqs}") + variant = (env.get(f"{prefix}_VARIANT") or "").strip() + if variant and info.get("variant") != variant: + fails.append(f"variant is {info.get('variant')!r}, profile {profile!r} pins {prefix}_VARIANT={variant}") + revision = (pinned.get("weights") or {}).get("revision") + served = (info.get("weights") or {}).get("revision") + if revision and served != revision: + fails.append(f"weights revision is {served!r}, the package pins {revision!r}") + return fails diff --git a/code/tt_frnet/ttaw/tensors.py b/code/tt_frnet/ttaw/tensors.py new file mode 100644 index 0000000000000000000000000000000000000000..34b69ea1da9e97a8851236edf35cf171f1cef0ff --- /dev/null +++ b/code/tt_frnet/ttaw/tensors.py @@ -0,0 +1,363 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C03: tensor layout and dtype helpers shared by every port. + +Host side (numpy only, no ttnn): tile padding, fixed-capacity padding with a valid count, bf16 bit conversion. +Device side (ttnn imported inside each function): exact host-tensor construction for every dtype (integer arrays are +never routed through a float intermediate), uploads, layout and dtype changes that are no-ops when nothing changes, +fp32 islands (typecast in and out), uint8 uploads with an on-device typecast, readback to numpy, and +:class:`HostStaging`: a persistent host tensor whose buffer is written in place (no ``from_torch`` per frame). + +Measured on this p150b (``logs/ttaw/api_probe_20261007.log``): numpy uint32 arrays round-trip exactly (including +values >= 2**31); uint8 -> bf16 ``ttnn.typecast`` is exact in ROW_MAJOR and TILE layout; ``torch.from_dlpack`` of a +host buffer is a writable alias (``numpy.from_dlpack`` is read-only, ``numpy.asarray`` fails). +""" +from __future__ import annotations + +import math +from typing import Any, Callable, Optional, Sequence, Tuple, Union + +import numpy as np + +__all__ = [ + "TILE", + "round_up", + "tile_padded_shape", + "pad_to_tile", + "pad_to_capacity", + "as_4d", + "float32_to_bf16_bits", + "bf16_bits_to_float32", + "round_to_bf16", + "ttnn_dtype", + "dtype_name", + "to_host_tensor", + "to_device", + "to_numpy", + "to_layout", + "typecast", + "to_fp32", + "to_bf16", + "fp32_island", + "upload_u8", + "HostStaging", +] + +TILE = 32 + +ArrayLike = Union[np.ndarray, Sequence, Any] + + +# ------------------------------------------------------------------------------------------------- host side + +def round_up(n: int, multiple: int = TILE) -> int: + """Smallest multiple of ``multiple`` that is >= ``n``.""" + if multiple <= 0: + raise ValueError("multiple must be positive") + return -(-int(n) // multiple) * multiple + + +def tile_padded_shape(shape: Sequence[int]) -> Tuple[int, ...]: + """``shape`` with its last two dims rounded up to the 32x32 tile (what a TILE tensor occupies).""" + shape = tuple(int(s) for s in shape) + if len(shape) < 2: + raise ValueError(f"need at least 2 dims, got {shape}") + return shape[:-2] + (round_up(shape[-2]), round_up(shape[-1])) + + +def pad_to_tile(array: np.ndarray, value: float = 0.0) -> np.ndarray: + """Pad the last two dims of a host array up to multiples of 32 with ``value`` (copy only when needed).""" + a = np.asarray(array) + target = tile_padded_shape(a.shape) + if target == a.shape: + return a + pad = [(0, 0)] * (a.ndim - 2) + [(0, target[-2] - a.shape[-2]), (0, target[-1] - a.shape[-1])] + return np.pad(a, pad, mode="constant", constant_values=value) + + +def pad_to_capacity(array: np.ndarray, capacity: int, *, axis: int = 0, value: float = 0.0, + truncate: bool = False) -> Tuple[np.ndarray, int]: + """Fixed-capacity buffer for a traced graph: pad ``axis`` to ``capacity`` rows with ``value``. + + Returns ``(padded, n_valid)``. More than ``capacity`` rows raise ``ValueError`` unless ``truncate`` keeps the + first ``capacity`` (the caller must report the overflow). Capacities are grid-independent constants + (PLAN.md section 0.2).""" + a = np.asarray(array) + n = a.shape[axis] + if n > capacity: + if not truncate: + raise ValueError(f"{n} rows exceed the capacity {capacity}") + return np.take(a, np.arange(capacity), axis=axis), capacity + if n == capacity: + return a, n + pad = [(0, 0)] * a.ndim + pad[axis] = (0, capacity - n) + return np.pad(a, pad, mode="constant", constant_values=value), n + + +def as_4d(array: np.ndarray) -> np.ndarray: + """Prepend unit dims up to rank 4 (the ``[N, C, H, W]`` / ``[1, 1, rows, cols]`` convention of ttnn).""" + a = np.asarray(array) + if a.ndim > 4: + raise ValueError(f"rank {a.ndim} > 4") + return a.reshape((1,) * (4 - a.ndim) + a.shape) + + +def float32_to_bf16_bits(array: ArrayLike) -> np.ndarray: + """float32 -> bfloat16 bit patterns (uint16), round-to-nearest-even like ``torch.Tensor.to(torch.bfloat16)``. + NaN stays NaN (quiet bit forced).""" + f = np.ascontiguousarray(array, dtype=np.float32) + bits = f.view(np.uint32).astype(np.uint64) + rounded = (bits + 0x7FFF + ((bits >> 16) & 1)) >> 16 + out = rounded.astype(np.uint16) + nan = np.isnan(f) + if nan.any(): + out[nan] = ((bits[nan] >> 16) | 0x0040).astype(np.uint16) + return out + + +def bf16_bits_to_float32(bits: ArrayLike) -> np.ndarray: + """bfloat16 bit patterns (uint16) -> float32 (exact).""" + b = np.ascontiguousarray(bits, dtype=np.uint16) + return (b.astype(np.uint32) << 16).view(np.float32) + + +def round_to_bf16(array: ArrayLike) -> np.ndarray: + """float32 values rounded to the nearest bfloat16 (RNE), returned as float32: the host emulation of a bf16 + upload, for building exact expectations in tests.""" + return bf16_bits_to_float32(float32_to_bf16_bits(array)) + + +# ----------------------------------------------------------------------------------------------- dtype maps + +_DTYPE_ALIASES = { + "bf16": "bfloat16", "bfloat16": "bfloat16", "fp32": "float32", "float32": "float32", "f32": "float32", + "bfp8": "bfloat8_b", "bfloat8_b": "bfloat8_b", "bf8": "bfloat8_b", "bfp4": "bfloat4_b", "bfloat4_b": "bfloat4_b", + "uint32": "uint32", "u32": "uint32", "int32": "int32", "i32": "int32", "uint16": "uint16", "u16": "uint16", + "uint8": "uint8", "u8": "uint8", +} + +# canonical numpy dtype used to build the host tensor for each ttnn dtype (bf16 / bfp go through float32) +_HOST_NUMPY = {"float32": np.float32, "bfloat16": np.float32, "bfloat8_b": np.float32, "bfloat4_b": np.float32, + "uint32": np.uint32, "int32": np.int32, "uint16": np.uint16, "uint8": np.uint8} + + +def ttnn_dtype(name_or_dtype: Any): + """``"bf16"`` / ``"fp32"`` / ``"bfp8"`` / ``"uint32"`` ... (or a ttnn dtype) -> ``ttnn.DataType``.""" + import ttnn + + if not isinstance(name_or_dtype, str): + return name_or_dtype + key = _DTYPE_ALIASES.get(name_or_dtype.strip().lower()) + if key is None: + raise ValueError(f"unknown dtype {name_or_dtype!r}; expected one of {sorted(_DTYPE_ALIASES)}") + return getattr(ttnn, key) + + +def dtype_name(dtype: Any) -> str: + """Canonical lower-case name of a ttnn dtype (``ttnn.bfloat16`` -> ``"bfloat16"``), or of an alias string.""" + if isinstance(dtype, str): + key = _DTYPE_ALIASES.get(dtype.strip().lower()) + if key is None: + raise ValueError(f"unknown dtype {dtype!r}") + return key + name = getattr(dtype, "name", None) or str(dtype).rsplit(".", 1)[-1] + name = name.lower() + return {"bfloat8_b": "bfloat8_b", "bfloat4_b": "bfloat4_b"}.get(name, name) + + +def _exact_torch(value: ArrayLike, dtype: Any): + """A torch tensor whose dtype matches ``dtype`` exactly for integers (ttnn would otherwise route int64 through + bf16) and is float32 / bf16 for float targets.""" + import torch + + name = dtype_name(dtype) + if isinstance(value, torch.Tensor): + value = value.detach().cpu() + if name == "bfloat16": + return value.to(torch.bfloat16) if value.dtype != torch.bfloat16 else value.contiguous() + if name in ("float32", "bfloat8_b", "bfloat4_b"): + return value.to(torch.float32).contiguous() + value = value.numpy() if value.dtype not in (torch.bfloat16,) else value.float().numpy() + a = np.asarray(value) + target = _HOST_NUMPY[name] + if np.issubdtype(np.dtype(target), np.integer): + if not (np.issubdtype(a.dtype, np.integer) or a.dtype == np.bool_): + raise TypeError(f"{name} tensor needs integer data, got {a.dtype}") + if a.size and np.issubdtype(a.dtype, np.signedinteger) and np.dtype(target).kind == "u" and a.min() < 0: + raise ValueError(f"negative values cannot be stored as {name}") + a = np.ascontiguousarray(a, dtype=target) + if not a.flags.writeable: # broadcast views are read-only; torch.from_numpy wants writable memory + a = a.copy() + t = torch.from_numpy(a) + return t.to(torch.bfloat16) if name == "bfloat16" else t + + +# ------------------------------------------------------------------------------------------- device side + +def to_host_tensor(value: ArrayLike, dtype: Any, layout: Any = None, *, shape: Optional[Sequence[int]] = None): + """numpy / torch / ttnn host tensor -> ttnn host tensor of ``dtype`` and ``layout`` (default ROW_MAJOR). + + A ttnn host tensor is checked (dtype, layout, optional shape) and returned unchanged. TILE layout tilizes on the + host: prefer ROW_MAJOR inputs plus an on-device ``ttnn.to_layout`` for large per-frame tensors.""" + import ttnn + + dtype = ttnn_dtype(dtype) + layout = ttnn.ROW_MAJOR_LAYOUT if layout is None else layout + if isinstance(value, ttnn.Tensor): + if value.storage_type() == ttnn.StorageType.DEVICE: + raise ValueError("expected a host tensor, got a device tensor") + if value.dtype != dtype or value.layout != layout: + raise ValueError(f"host tensor is {value.dtype}/{value.layout}, expected {dtype}/{layout}") + if shape is not None and tuple(value.shape) != tuple(shape): + raise ValueError(f"host tensor has shape {tuple(value.shape)}, expected {tuple(shape)}") + return value + t = _exact_torch(value, dtype) + if shape is not None and tuple(t.shape) != tuple(int(s) for s in shape): + raise ValueError(f"value has shape {tuple(t.shape)}, expected {tuple(shape)}") + return ttnn.from_torch(t, dtype=dtype, layout=layout) + + +def to_device(value: ArrayLike, device, dtype: Any = "bf16", layout: Any = None, memory_config: Any = None): + """Host data -> device tensor (``layout`` default TILE, ``memory_config`` default DRAM interleaved).""" + import ttnn + + layout = ttnn.TILE_LAYOUT if layout is None else layout + host = to_host_tensor(value, dtype, layout) + return ttnn.to_device(host, device, memory_config=memory_config or ttnn.DRAM_MEMORY_CONFIG) + + +def to_numpy(tensor: Any) -> np.ndarray: + """ttnn tensor (device or host) / torch tensor / array -> numpy. bf16 becomes float32 (exact); unsigned torch + dtypes without numpy support are converted through int64.""" + if isinstance(tensor, np.ndarray): + return tensor + try: + import ttnn + except ImportError: + ttnn = None + if ttnn is not None and isinstance(tensor, ttnn.Tensor): + tensor = ttnn.to_torch(tensor) + if hasattr(tensor, "detach") and hasattr(tensor, "cpu"): + import torch + + t = tensor.detach().cpu() + if t.dtype == torch.bfloat16: + return t.float().numpy() + try: + return t.numpy() + except TypeError: # torch.uint16 / uint32 on older numpy bridges + target = {torch.uint16: np.uint16, torch.uint32: np.uint32}.get(t.dtype, np.int64) + return t.to(torch.int64).numpy().astype(target) + return np.asarray(tensor) + + +def to_layout(tensor, layout): + """``ttnn.to_layout`` that returns the input unchanged when it already has ``layout`` (no extra program).""" + import ttnn + + return tensor if tensor.layout == layout else ttnn.to_layout(tensor, layout) + + +def typecast(tensor, dtype): + """``ttnn.typecast`` that is a no-op when ``tensor`` already has ``dtype``.""" + import ttnn + + dtype = ttnn_dtype(dtype) + return tensor if tensor.dtype == dtype else ttnn.typecast(tensor, dtype) + + +def to_fp32(tensor): + """Enter an fp32 island (typecast to FLOAT32 if needed).""" + return typecast(tensor, "float32") + + +def to_bf16(tensor): + """Leave an fp32 island (typecast to BFLOAT16 if needed).""" + return typecast(tensor, "bfloat16") + + +def fp32_island(fn: Callable[..., Any], *tensors, out_dtype: Any = "bfloat16"): + """Run ``fn`` on fp32 copies of ``tensors`` and cast its tensor output(s) to ``out_dtype`` (``None`` keeps + fp32). For numerically sensitive glue (box coding, softmax logits, geometry) between bf16 stages.""" + out = fn(*(to_fp32(t) for t in tensors)) + if out_dtype is None: + return out + if isinstance(out, (list, tuple)): + return type(out)(typecast(t, out_dtype) for t in out) + return typecast(out, out_dtype) + + +def upload_u8(array: ArrayLike, device, *, out_dtype: Any = "bfloat16", layout: Any = None, memory_config: Any = None): + """Upload uint8 data (4x less PCIe traffic than fp32) and typecast it on the device (exact for 0..255). + + In a traced model keep the uint8 tensor as the persistent trace input and make ``ttnn.typecast`` the first + traced op; this helper is the eager form.""" + import ttnn + + layout = ttnn.ROW_MAJOR_LAYOUT if layout is None else layout + u8 = ttnn.to_device(to_host_tensor(array, "uint8", layout), device, + memory_config=memory_config or ttnn.DRAM_MEMORY_CONFIG) + return typecast(u8, out_dtype) + + +class HostStaging: + """A persistent ROW_MAJOR host tensor written in place for every frame (RP section 2.11, rf-detr R3c). + + ``write(array)`` copies (and casts) into the tensor's own host buffer through a writable ``torch.from_dlpack`` + alias, so no ``ttnn.from_torch`` allocation happens per frame; ``.tensor`` is what you pass to + ``ttnn.copy_host_to_device_tensor`` (or ``TraceRunner.run(inputs=...)``). bf16 is written as RNE-rounded bit + patterns. If the alias check fails on some tt-metal build, ``zero_copy`` is ``False`` and ``write`` rebuilds the + host tensor with ``from_torch`` (same result, slower).""" + + _VIEW = {"float32": np.float32, "bfloat16": np.uint16, "uint32": np.uint32, "int32": np.int32, + "uint16": np.uint16, "uint8": np.uint8} + + def __init__(self, shape: Sequence[int], dtype: Any = "float32"): + import torch + import ttnn + + self.shape = tuple(int(s) for s in shape) + self.dtype = ttnn_dtype(dtype) + self._name = dtype_name(self.dtype) + if self._name not in self._VIEW: + raise ValueError(f"HostStaging supports {sorted(self._VIEW)}, not {self._name}") + zeros = np.zeros(self.shape, _HOST_NUMPY[self._name]) + self.tensor = ttnn.from_torch(_exact_torch(zeros, self.dtype), dtype=self.dtype, layout=ttnn.ROW_MAJOR_LAYOUT) + self.zero_copy = False + self._keep: tuple = () + try: + shard = self.tensor.host_buffer().get_shard(ttnn.MeshCoordinate(0, 0)) + raw = torch.from_dlpack(shard) + view = raw.numpy().view(self._VIEW[self._name]).reshape(self.shape) + probe = (np.arange(view.size) % 251 + 1).astype(view.dtype).reshape(self.shape) + view[...] = probe + back = to_numpy(self.tensor) + expect = bf16_bits_to_float32(probe) if self._name == "bfloat16" else probe + if np.array_equal(back.reshape(self.shape), expect): + self.zero_copy = True + self._keep = (shard, raw) + self.view = view + view[...] = 0 + except Exception: # noqa: BLE001 -- API drift: fall back to from_torch per write + self.zero_copy = False + if not self.zero_copy: + self.view = np.zeros(self.shape, self._VIEW[self._name]) + + def write(self, array: ArrayLike): + """Copy ``array`` (broadcastable to ``shape``) into the host buffer and return the host tensor.""" + a = np.asarray(array) + if self._name == "bfloat16": + self.view[...] = float32_to_bf16_bits(np.broadcast_to(a, self.shape)) + else: + self.view[...] = np.broadcast_to(a, self.shape).astype(self.view.dtype, copy=False) + if not self.zero_copy: + import ttnn + + data = bf16_bits_to_float32(self.view) if self._name == "bfloat16" else self.view + self.tensor = ttnn.from_torch(_exact_torch(data, self.dtype), dtype=self.dtype, + layout=ttnn.ROW_MAJOR_LAYOUT) + return self.tensor + + @property + def nbytes(self) -> int: + return int(math.prod(self.shape) * self.view.dtype.itemsize) diff --git a/code/tt_frnet/ttaw/trace.py b/code/tt_frnet/ttaw/trace.py new file mode 100644 index 0000000000000000000000000000000000000000..13f473e2297a2e232f5e83786d8e6df7b0d881ad --- /dev/null +++ b/code/tt_frnet/ttaw/trace.py @@ -0,0 +1,1317 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C02: ``TraceRunner`` -- every device stage of a port runs inside metal traces. + +What it enforces (TT_PLATFORM.md section 3, REFERENCE_PATTERNS.md section 1.4): + +1. **Persistent I/O before any capture.** Inputs, RT-dev parameters, state buffers and (optionally) output buffers + are allocated (and given defined initial values: never uninitialised index buffers) when they are added, i.e. + before the first capture. A variant returns either the tensors its last ops produce (allocated during capture, + valid while the trace lives) or persistent outputs written with ``ctx.write_output`` (stable addresses shared by + several variants, never overwritten by another variant's replay). +2. **Warm-up, then capture.** Every variant runs eagerly ``warmup_runs`` times (kernel JIT, program cache, prepared + conv weights) before *any* trace is captured. Adding a variant after a capture releases all traces, warms the new + one and recaptures everything, because a warm-up after a capture could allocate into a trace's freed + intermediates. +3. **Strict capture.** ``device.set_program_cache_misses_allowed(False)`` during capture (a miss raises with the op + name instead of aborting with "Writes are not supported during trace capture"); ``end_trace_capture`` runs in a + ``finally`` block and a failed capture is released (an open capture left the process spinning in close_device: + RP section 1.4); the program-cache entry count must not change during capture. +4. **Variants.** Several traces keyed by name (shape buckets, modes, segments), chosen by the caller per run. +5. **RT-dev parameters.** Per-frame values that must not change the program (thresholds, timesteps, poses) live + in persistent device tensors refreshed before ``execute_trace``; unchanged values are not re-uploaded. +6. **1CQ / 2CQ.** With 2 CQs inputs are uploaded on CQ1 and ordered with events, exactly like + ``common/tools/check_dispatch.py`` (CQ1 waits for the last trace, uploads, records; CQ0 waits, replays, records). + ``stage_inputs=True`` uses the ``tt_cnn`` executor pattern instead: CQ1 writes a DRAM staging copy while the + previous trace still runs, and an eager copy on CQ0 moves it into the trace input before the replay. Every CQ0 + write into a trace input outside the protocol (:meth:`TraceRunner.write_input`) re-records the event CQ1 waits for. +7. **State inside the trace.** ``ctx.write_state(name, value)`` is ``ttnn.copy(value, buffer)`` into a persistent + buffer (FLOAT32 / UINT32 / BFLOAT16 / ... all supported), or no op at all when ``value`` was computed straight + into ``ctx.write_target(name)`` (``output_tensor=``). ``pingpong=True`` states use two buffers and two traces + per variant (phase 0 reads A writes B, phase 1 reads B writes A); the phase flips after every run of a variant + that writes the ping-pong states. Per-stream state (PLAN.md D16): ``add_state(..., banks=n)`` + + :meth:`TraceRunner.save_state` / :meth:`TraceRunner.load_state`, with the policy in :class:`StreamBanks`. +8. **Readback.** One packed output (:func:`pack_outputs`) gives one D2H; reads go into preallocated host tensors, + on CQ0 or (segmented pipelines, 2CQ) on CQ1 after a host-side event wait. +9. **Alloc tracking.** With ``TT_METAL_TRACE_ALLOC_TRACKING=1`` set before ``import ttnn``, ``ttnn.execute_trace`` + refuses to replay over live unsafe buffers; the runner acknowledges the outputs of traces captured after the + first one (they may be overwritten by an earlier trace's replay: read outputs before running another variant). + +Example:: + + runner = TraceRunner(device, num_command_queues=2) + runner.add_input("x", shape=(1, 1, 64, 64), dtype="bfloat16", layout=ttnn.TILE_LAYOUT) + runner.add_param("scale", 1.0) # fp32 [1,1,1,1], TILE + runner.add_variant("default", lambda ctx: ttnn.relu(ttnn.multiply(ctx["x"], ctx["scale"]))) + runner.capture() # warm-up, then capture + y = runner("default", inputs={"x": x_np}, params={"scale": 0.5}) # upload, replay, read -> numpy +""" +from __future__ import annotations + +import contextlib +import math +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple + +import numpy as np + +from .io import InputError +from .tensors import TILE, dtype_name, round_up, to_host_tensor, to_numpy, ttnn_dtype + +__all__ = [ + "CQ_COMPUTE", + "CQ_INPUT", + "PackEntry", + "PackLayout", + "Packed", + "pack_outputs", + "SINGLE_ROW_MAX_ELEMS", + "PACK_ROW_ELEMS", + "TraceContext", + "TraceRunner", + "StreamBanks", + "alloc_tracking_enabled", +] + +CQ_COMPUTE = 0 # programs, traces and (by default) readback +CQ_INPUT = 1 # host -> device uploads when the device has 2 command queues + + +def alloc_tracking_enabled() -> bool: + """True when ``TT_METAL_TRACE_ALLOC_TRACKING=1`` was set before ttnn was imported (read from tt-metal).""" + try: + from ttnn.tools import trace_allocation_tracker as tracker + except ImportError: + return False + return bool(getattr(tracker, "TRACE_ALLOC_TRACKING", False)) + + +# ----------------------------------------------------------------------------------------------- packing + +# Packed layouts (PORT_LOG Q9 of the YOLOX port). A ROW_MAJOR tensor is stored page by page, one page per row, and +# the RM reshape / concat programs stage whole pages in L1 (reshape_rm_program_factory.cpp: 2 x the destination page +# per kernel copy when the pages are not 16-byte aligned), so a single-row pack of more than ~0.6 MB fails with +# "RM reshape dest staging does not fit in L1". Above SINGLE_ROW_MAX_ELEMS the pack is a [1, 1, rows, R] tensor of +# R = PACK_ROW_ELEMS elements per row: every RM page the packing programs touch stays below ROW_PAGE_MAX_BYTES, +# whatever the size of the outputs (tens of MB), and the readback is still ONE device-to-host copy. +SINGLE_ROW_MAX_ELEMS = 131072 # one [1, 1, 1, total] row up to 512 KiB of float32 (the 0.1.0 - 0.14.0 layout) +PACK_ROW_ELEMS = 1024 # elements per row of the multi-row layout (4 KiB float32 pages) +FLAT_MAX_BYTES = 64 << 10 # multi-row: tensors up to this size are flattened to one row, then cut into rows +ROW_PAGE_MAX_BYTES = 128 << 10 # multi-row: largest RM page (last dim x element size) a packed tensor may have +_ELEM_BYTES = {"float32": 4, "uint32": 4, "int32": 4, "bfloat16": 2, "uint16": 2, "uint8": 1} + + +@dataclass(frozen=True) +class PackEntry: + """One tensor inside a packed readback: elements ``[offset, offset + numel)`` reshaped to ``shape``. + + ``pitch > 0`` (multi-row layout only): the tensor's rows (its last dim, ``shape[-1]`` elements) + are stored ``pitch`` elements apart, zero-padded, i.e. elements ``[offset, offset + numel // shape[-1] * pitch)`` + viewed as ``(rows, pitch)`` hold it in their first ``shape[-1]`` columns. ``0``: contiguous.""" + + name: str + offset: int + numel: int + shape: Tuple[int, ...] + pitch: int = 0 + + def view(self, flat: np.ndarray) -> np.ndarray: + """This tensor inside the flat readback ``flat`` (a view, strided when ``pitch`` is set).""" + if not self.pitch: + return flat[self.offset:self.offset + self.numel].reshape(self.shape) + cols = int(self.shape[-1]) if self.shape else 1 + rows = self.numel // cols + return flat[self.offset:self.offset + rows * self.pitch].reshape(rows, self.pitch)[:, :cols].reshape( + self.shape) + + +@dataclass(frozen=True) +class PackLayout: + """Host-side description of a packed output (built at capture time, applied at every read). + + ``entries`` index the packed tensor's elements in row-major order, so :meth:`unpack` does not depend on the + device shape ``(1, 1, rows, row_elems)``: one row of ``total`` elements (``rows == 1``) or rows of + ``row_elems`` elements, every tensor starting on a row boundary (multi-row layout; an entry with a ``pitch`` + stores its rows zero-padded to that many elements, see :class:`PackEntry`).""" + + entries: Tuple[PackEntry, ...] + total: int + rows: int = 1 + row_elems: int = 0 # 0: one row of ``total`` elements + + @property + def shape(self) -> Tuple[int, int, int, int]: + """Shape of the packed device tensor.""" + return (1, 1, self.rows, self.row_elems or self.total) + + def unpack(self, flat: Any) -> Dict[str, np.ndarray]: + """Flat readback (any shape with ``total`` elements) -> ``{name: array}`` (views, no copies; the view of an + entry with a ``pitch`` is strided, not C-contiguous).""" + a = np.asarray(flat).reshape(-1) + if a.size != self.total: + raise ValueError(f"packed readback has {a.size} elements, layout expects {self.total}") + return {e.name: e.view(a) for e in self.entries} + + +@dataclass +class Packed: + """A packed device tensor plus its layout; return it from a variant function to get ``{name: array}`` back.""" + + tensor: Any + layout: PackLayout + + +def _elem_bytes(dtype: Any) -> int: + return _ELEM_BYTES.get(dtype_name(dtype), 4) + + +def _row_major(ttnn: Any, t: Any, dtype: Any) -> Any: + if t.dtype != dtype: + t = ttnn.typecast(t, dtype) + if t.layout != ttnn.ROW_MAJOR_LAYOUT: + t = ttnn.to_layout(t, ttnn.ROW_MAJOR_LAYOUT) + return t + + +def _flat_rows(ttnn: Any, t: Any, shape: Tuple[int, ...], numel: int, row_elems: int) -> Tuple[Any, int]: + """A small RM tensor -> one row -> zero-padded to whole rows -> ``[1, 1, k, row_elems]``.""" + padded = round_up(numel, row_elems) + if shape != (1, 1, 1, numel): + t = ttnn.reshape(t, (1, 1, 1, numel)) + if padded != numel: + t = ttnn.pad(t, [(0, 0), (0, 0), (0, 0), (0, padded - numel)], 0.0) + if padded != row_elems: + t = ttnn.reshape(t, (1, 1, padded // row_elems, row_elems)) + return t, padded + + +def _fill_rows(rows: int, cols: int, row_elems: int) -> int: + """Rows of ``cols`` elements a ``[rows, cols]`` tensor is zero-padded to so that it fills whole packed rows.""" + return round_up(rows, row_elems // math.gcd(cols, row_elems)) + + +def _row_pitches(cols: int, row_elems: int, elem: int) -> List[int]: + """Row pitches > ``cols`` worth trying: ``cols`` rounded up to every power of two dividing ``row_elems`` (rows of + that pitch fill a packed row every ``row_elems / gcd`` rows) and to ``row_elems`` (every row fills whole packed + rows), within the page budget.""" + steps, m = {row_elems}, 2 + while row_elems % m == 0: + steps.add(m) + m *= 2 + return sorted({p for p in (round_up(cols, m) for m in steps) if p != cols and p * elem <= ROW_PAGE_MAX_BYTES}) + + +def _pack_rows_segment(ttnn: Any, name: str, t: Any, shape: Tuple[int, ...], numel: int, row_elems: int, + elem: int) -> Tuple[Any, int, int]: + """One ROW_MAJOR tensor -> ``[1, 1, k, row_elems]`` holding its elements in order, zero-padded to whole rows. + Returns ``(segment, k * row_elems, pitch)`` (``pitch``: see :class:`PackEntry`). No RM page above + ``ROW_PAGE_MAX_BYTES`` is created or reshaped.""" + cols = int(shape[-1]) if shape else 1 + rows = numel // cols + if cols * elem <= ROW_PAGE_MAX_BYTES: + # [rows, cols] is a view of the RM tensor; rows * cols fills whole packed rows when rows % unit == 0 + rows_p = _fill_rows(rows, cols, row_elems) + if rows_p != rows and numel * elem <= FLAT_MAX_BYTES: + return (*_flat_rows(ttnn, t, shape, numel, row_elems), 0) # small: pad < row_elems elements, not rows + if rows_p != rows: + # Appending zero rows costs up to row_elems / gcd(cols, row_elems) - 1 rows: 80 MB for a [1, 1, 2, 20001] + # fp32 tensor. Take one flat row (when it fits the page budget) or rows zero-padded to a pitch instead + # when that is smaller by more than 1/8 of the tensor (typical shapes keep the contiguous layout). + options = [(p * _fill_rows(rows, p, row_elems), p) for p in _row_pitches(cols, row_elems, elem)] + if round_up(numel, row_elems) * elem <= ROW_PAGE_MAX_BYTES: + options.append((round_up(numel, row_elems), 0)) + if options and rows_p * cols - min(options)[0] > numel // 8: + size, pitch = min(options) # ties: the flat row, then the narrower pitch + if not pitch: + return (*_flat_rows(ttnn, t, shape, numel, row_elems), 0) + prows = size // pitch + if shape != (1, 1, rows, cols): + t = ttnn.reshape(t, (1, 1, rows, cols)) + t = ttnn.pad(t, [(0, 0), (0, 0), (0, prows - rows), (0, pitch - cols)], 0.0) + if pitch != row_elems: + t = ttnn.reshape(t, (1, 1, size // row_elems, row_elems)) + return t, size, pitch + # zero rows appended, then one RM reshape into rows of row_elems (source pages of cols elements, destination + # pages of row_elems elements: both small) + if shape != (1, 1, rows, cols): + t = ttnn.reshape(t, (1, 1, rows, cols)) + if rows_p != rows: + t = ttnn.pad(t, [(0, 0), (0, 0), (0, rows_p - rows), (0, 0)], 0.0) + if cols != row_elems: + t = ttnn.reshape(t, (1, 1, rows_p * cols // row_elems, row_elems)) + return t, rows_p * cols, 0 + if rows != 1: + raise ValueError(f"pack_outputs: {name!r} {shape} has rows of {cols * elem} B; the multi-row layout reads " + f"RM rows of at most {ROW_PAGE_MAX_BYTES} B: give it a narrower last dim (e.g. " + f"[1, 1, -1, {row_elems}]) before packing") + # one wide row (a flat vector): cut it into chunks of whole packed rows, each well inside the L1 budget + if shape != (1, 1, 1, numel): + t = ttnn.reshape(t, (1, 1, 1, numel)) + width = max(row_elems, (ROW_PAGE_MAX_BYTES // elem) // row_elems * row_elems) + parts, total = [], 0 + for start in range(0, numel, width): + stop = min(start + width, numel) + chunk = ttnn.slice(t, [0, 0, 0, start], [1, 1, 1, stop]) + chunk, padded = _flat_rows(ttnn, chunk, (1, 1, 1, stop - start), stop - start, row_elems) + parts.append(chunk) + total += padded + return (parts[0] if len(parts) == 1 else ttnn.concat(parts, dim=2)), total, 0 + + +def pack_outputs(tensors: Mapping[str, Any], *, dtype: Any = "float32", align: int = 32, + row_elems: Optional[int] = None) -> Packed: + """Pack several device tensors into ONE ROW_MAJOR tensor so the host reads them with one device-to-host copy. + + Each tensor is typecast to ``dtype`` (float32 default: exact for bf16 and for integers below 2**24) and converted + to ROW_MAJOR; call this inside the variant function (the ops become part of the trace) and return the result. + :meth:`PackLayout.unpack` (done by ``TraceRunner.read``) gives ``{name: array}`` back in the original shapes. + + Device layout: while the packed total is at most :data:`SINGLE_ROW_MAX_ELEMS`, one ``[1, 1, 1, total]`` row (each + tensor flattened and zero-padded to ``align`` elements, then concatenated): a ``[1, 1, N, 1]`` RM readback is N + pages and costs milliseconds, one row is one page (RP section 1.4). Above it (or with ``row_elems=R``) a + ``[1, 1, rows, R]`` tensor (default R = :data:`PACK_ROW_ELEMS`): each tensor is zero-padded to whole rows of R + elements and the segments are concatenated on the row dim, so no RM page exceeds :data:`ROW_PAGE_MAX_BYTES` + and outputs of tens of MB pack (a single row of more than ~0.6 MB does not fit the RM reshape's L1 staging: + YOLOX PORT_LOG Q9). A tensor is padded with zero rows (contiguous), or, when that wastes more than 1/8 of it + (an awkward last dim with few rows: e.g. ``[1, 1, 2, 20001]`` would take 1024 rows), flattened (up to the page + budget) or stored with its rows zero-padded to a wider pitch (:class:`PackEntry` ``pitch``). A last dim wider + than ``ROW_PAGE_MAX_BYTES`` is accepted for a flat vector (``[1, 1, 1, N]``, cut into chunks with + ``ttnn.slice``); any other tensor needs a narrower last dim (``ValueError``).""" + import ttnn + + if not tensors: + raise ValueError("pack_outputs needs at least one tensor") + if align <= 0: + raise ValueError("align must be positive") + if row_elems is not None and (int(row_elems) <= 0 or int(row_elems) % TILE): + raise ValueError(f"row_elems={row_elems}: expected a positive multiple of {TILE}") + dtype = ttnn_dtype(dtype) + elem = _elem_bytes(dtype) + items = [(str(name), t, tuple(int(s) for s in t.shape)) for name, t in tensors.items()] + empty = [name for name, _, shape in items if math.prod(shape) == 0] + if empty: + raise ValueError(f"pack_outputs: {empty} have no elements") + single_total = sum(round_up(math.prod(shape), align) for _, _, shape in items) + if row_elems is None and single_total <= SINGLE_ROW_MAX_ELEMS: + segments, entries, offset = [], [], 0 + for name, t, shape in items: # the single-row layout of ttaw 0.1.0 - 0.14.0 + numel = math.prod(shape) + t = _row_major(ttnn, t, dtype) + if shape != (1, 1, 1, numel): + t = ttnn.reshape(t, (1, 1, 1, numel)) + padded = round_up(numel, align) + if padded != numel: + t = ttnn.pad(t, [(0, 0), (0, 0), (0, 0), (0, padded - numel)], 0.0) + segments.append(t) + entries.append(PackEntry(name, offset, numel, shape)) + offset += padded + packed = segments[0] if len(segments) == 1 else ttnn.concat(segments, dim=-1) + return Packed(packed, PackLayout(tuple(entries), offset)) + r = int(row_elems or PACK_ROW_ELEMS) + segments, entries, offset = [], [], 0 + for name, t, shape in items: + numel = math.prod(shape) + t, padded, pitch = _pack_rows_segment(ttnn, name, _row_major(ttnn, t, dtype), shape, numel, r, elem) + segments.append(t) + entries.append(PackEntry(name, offset, numel, shape, pitch)) + offset += padded + packed = segments[0] if len(segments) == 1 else ttnn.concat(segments, dim=2) + return Packed(packed, PackLayout(tuple(entries), offset, rows=offset // r, row_elems=r)) + + +# --------------------------------------------------------------------------------------------- internals + +@dataclass +class _Slot: + """A persistent device tensor (input, parameter or state) allocated before any capture.""" + + name: str + kind: str # "input" | "param" | "state" | "output" + shape: Tuple[int, ...] + dtype: Any + layout: Any + memory_config: Any + buffers: List[Any] # 1 buffer, or 2 for a ping-pong state + init: Any # host tensor holding the initial value (states, params) / warm-up value (inputs) + staging: Any = None # DRAM staging copy (2CQ stage_inputs mode) + stage_fn: Optional[Callable[[Any, Any], Any]] = None + last_value: Optional[bytes] = None # params: bytes of the last uploaded value (skip unchanged uploads) + banks: List[Any] = field(default_factory=list) # states: save / load buffers (D16 per-stream state banks) + + @property + def pingpong(self) -> bool: + return len(self.buffers) == 2 + + def tensors(self) -> List[Any]: + """Every device tensor the slot owns.""" + return self.buffers + ([self.staging] if self.staging is not None else []) + self.banks + + +@dataclass +class _Variant: + name: str + fn: Callable[["TraceContext"], Any] + warmup_runs: int + + +@dataclass +class _Trace: + variant: str + phase: int + trace_id: Any + outputs: Any + leaves: List[Tuple[Tuple, Any, Optional[PackLayout]]] # (path, device tensor, pack layout or None) + steps: bool # writes the ping-pong states (flips the phase) + capture_ms: float + host_buffers: Optional[List[Any]] = None + done_event: Any = None + + +def _flatten(obj: Any, path: Tuple = ()) -> Iterator[Tuple[Tuple, Any]]: + """Leaves of a tensor / Packed / list / tuple / dict structure with their paths.""" + if isinstance(obj, Mapping): + for k, v in obj.items(): + yield from _flatten(v, path + (k,)) + elif isinstance(obj, (list, tuple)): + for i, v in enumerate(obj): + yield from _flatten(v, path + (i,)) + else: + yield path, obj + + +def _rebuild(obj: Any, values: Dict[Tuple, Any], path: Tuple = ()) -> Any: + if isinstance(obj, Mapping): + return {k: _rebuild(v, values, path + (k,)) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return type(obj)(_rebuild(v, values, path + (i,)) for i, v in enumerate(obj)) + return values[path] + + +def _buffer_address(t: Any) -> Optional[int]: + try: + return int(t.buffer_address()) + except Exception: # noqa: BLE001 -- host tensor, deallocated tensor or a fake + return None + + +def _same_buffer(a: Any, b: Any) -> bool: + """``a`` is ``b`` or a tensor over the same device buffer.""" + if a is b: + return True + addr = _buffer_address(a) + return addr is not None and addr == _buffer_address(b) + + +def _check_copy(what: str, value: Any, slot: "_Slot") -> None: + """The preconditions of ``ttnn.copy(value, )``, checked in Python so a mistake is a clear error + instead of a TT_FATAL inside an open capture: same logical shape and layout; a dtype change only in TILE.""" + import ttnn + + shape = tuple(int(s) for s in value.shape) + if shape != slot.shape: + raise ValueError(f"{what}: value shape {shape} != {slot.shape}") + if value.layout != slot.layout: + raise ValueError(f"{what}: value layout {value.layout} != {slot.layout} (ttnn.copy keeps the layout)") + if value.dtype != slot.dtype and slot.layout != ttnn.TILE_LAYOUT: + raise ValueError(f"{what}: dtype {value.dtype} -> {slot.dtype} needs TILE layout (ttnn.copy)") + + +class TraceContext(Mapping): + """What a variant function receives: persistent tensors by name (``ctx["x"]``), and state writes. + + ``ctx[name]`` returns an input, a parameter, a persistent output buffer, or the buffer a state is *read* from in + this phase. ``ctx.write_state(name, value)`` copies ``value`` into the buffer the state is *written* to (in-place + states: the same buffer, so read everything you need from it before writing); ``ctx.write_output(name, value)`` + copies into a persistent output and returns it.""" + + def __init__(self, runner: "TraceRunner", variant: str, phase: int, capturing: bool): + self._runner = runner + self.variant = variant + self.phase = phase + self.capturing = capturing + self.writes: set = set() + + @property + def device(self): + return self._runner.device + + def __getitem__(self, name: str): + slot = self._runner._slot(name) + if slot.kind == "state": + return self.state(name) + return slot.buffers[0] + + def __iter__(self) -> Iterator[str]: + return iter(self._runner._slots) + + def __len__(self) -> int: + return len(self._runner._slots) + + def state(self, name: str): + """The buffer state ``name`` is read from in this phase.""" + slot = self._runner._slot(name, "state") + return slot.buffers[self.phase] if slot.pingpong else slot.buffers[0] + + def write_target(self, name: str): + """The buffer state ``name`` is written to in this phase (ping-pong: the other buffer; in place: the same + one). Pass it as ``output_tensor=`` of the op that produces the new state and then call + ``write_state(name, it)``: no copy program is traced (probe P13: saves one program per state per frame).""" + slot = self._runner._slot(name, "state") + return slot.buffers[1 - self.phase] if slot.pingpong else slot.buffers[0] + + def write_state(self, name: str, value) -> None: + """``ttnn.copy(value, )``: same logical shape and layout as the state (dtype may differ in + TILE layout). The copy is part of the trace; it is skipped when ``value`` already is the write buffer + (:meth:`write_target`).""" + import ttnn + + slot = self._runner._slot(name, "state") + target = self.write_target(name) + _check_copy(f"state {name!r}", value, slot) + if not _same_buffer(value, target): + ttnn.copy(value, target) + self.writes.add(name) + + def write_output(self, name: str, value): + """``ttnn.copy(value, )`` (part of the trace); returns the output buffer, which the + variant can return as (part of) its outputs. Skipped when ``value`` already is that buffer.""" + import ttnn + + slot = self._runner._slot(name, "output") + _check_copy(f"output {name!r}", value, slot) + if not _same_buffer(value, slot.buffers[0]): + ttnn.copy(value, slot.buffers[0]) + return slot.buffers[0] + + +class TraceRunner: + """Persistent device I/O + warm-up + capture + replay of one model's traced stages (see the module docstring). + + Args: + device: an open ttnn device. + num_command_queues: 1 or 2. ``None`` uses what :func:`.device.open_device` recorded (1 if unknown). + warmup_runs: eager runs of each variant (and phase) before any capture. + stage_inputs: 2CQ only: upload into DRAM staging buffers and copy them into the trace inputs with an eager + op on CQ0 (the upload of frame k+1 then overlaps the replay of frame k). + forbid_cache_misses: call ``device.set_program_cache_misses_allowed(False)`` during capture. + alloc_tracking: ``True`` raises unless the process tracks trace allocations + (``TT_METAL_TRACE_ALLOC_TRACKING=1`` set before ``import ttnn``). Whenever tracking is active, the outputs + of traces captured after the first are acknowledged as corruptible (see the module docstring). + name: label used in messages and ``describe()``. + """ + + def __init__(self, device, *, num_command_queues: Optional[int] = None, warmup_runs: int = 1, + stage_inputs: bool = False, forbid_cache_misses: bool = True, alloc_tracking: Optional[bool] = None, + name: str = "model"): + from .device import open_info + + opened = open_info(device).get("num_command_queues") + if num_command_queues is None: + num_command_queues = opened or 1 + if num_command_queues not in (1, 2): + raise ValueError(f"num_command_queues={num_command_queues}: expected 1 or 2") + if opened is not None and num_command_queues > opened: + raise ValueError(f"the device was opened with {opened} command queue(s); cannot run {num_command_queues}") + if stage_inputs and num_command_queues != 2: + raise ValueError("stage_inputs=True needs num_command_queues=2") + if warmup_runs < 1: + raise ValueError("warmup_runs must be >= 1 (capture needs a warm program cache)") + tracking = alloc_tracking_enabled() + if alloc_tracking and not tracking: + raise RuntimeError("alloc_tracking=True but trace allocation tracking is off: export " + "TT_METAL_TRACE_ALLOC_TRACKING=1 (optionally TT_METAL_TRACE_ALLOC_TRACEBACKS=1) " + "before Python imports ttnn") + self.device = device + self.name = name + self.num_command_queues = int(num_command_queues) + self.warmup_runs = int(warmup_runs) + self.stage_inputs = bool(stage_inputs) + self.forbid_cache_misses = bool(forbid_cache_misses) + self.alloc_tracking = bool(tracking) + self._slots: Dict[str, _Slot] = {} + self._variants: Dict[str, _Variant] = {} + self._traces: Dict[Tuple[str, int], _Trace] = {} + self._phase = 0 + self._last_phase: Dict[str, int] = {} + self._last_variant: Optional[str] = None + self._pending_params: Dict[str, Any] = {} + self._op_event = None + self._stage_free_event = None + self._captures = 0 + self._capturing = False + self._eager_warmups: List[Callable[[], Any]] = [] + self._eager_warmed = False + self._closed = False + self.timings_ms: Dict[str, Dict[str, float]] = {} + if hasattr(device, "enable_program_cache"): + device.enable_program_cache() + + # ------------------------------------------------------------------------------------- registration + def _check_registration(self, what: str) -> None: + self._check_open() + if self._capturing: + raise RuntimeError(f"cannot add {what} while a variant is being warmed up or captured: register " + "inputs, params, states, outputs and variants before capture()") + + def _new_slot(self, name: str, kind: str, init: Any, shape: Optional[Sequence[int]], dtype: Any, layout: Any, + memory_config: Any, n_buffers: int, stage_fn: Optional[Callable], n_banks: int = 0) -> _Slot: + import ttnn + + self._check_registration(f"{kind} {name!r}") + if name in self._slots: + raise ValueError(f"{name!r} is already registered (as {self._slots[name].kind})") + if self._traces: + raise RuntimeError(f"cannot add {kind} {name!r} after capture: persistent tensors must exist before the " + "first capture (release() and rebuild)") + layout = ttnn.ROW_MAJOR_LAYOUT if layout is None else layout + memory_config = ttnn.DRAM_MEMORY_CONFIG if memory_config is None else memory_config + dtype = ttnn_dtype(dtype) + if init is None: + if shape is None: + raise ValueError(f"{kind} {name!r}: give init= or shape=") + init = np.zeros(tuple(shape), np.float32 if dtype_name(dtype) in ("float32", "bfloat16", "bfloat8_b", + "bfloat4_b") else np.int64) + if isinstance(init, ttnn.Tensor): + host = to_host_tensor(init, dtype, layout, shape=shape) + else: + arr = to_numpy(init) if hasattr(init, "detach") else np.asarray(init) + if shape is not None: + arr = np.broadcast_to(arr, tuple(shape)) + host = to_host_tensor(arr, dtype, layout) + shape_t = tuple(int(s) for s in host.shape) + + def allocate(config: Any): + buf = ttnn.allocate_tensor_on_device(ttnn.Shape(list(shape_t)), dtype, layout, self.device, config) + ttnn.copy_host_to_device_tensor(host, buf, cq_id=CQ_COMPUTE) # defined contents, never garbage + return buf + + buffers = [allocate(memory_config) for _ in range(n_buffers)] + staging = allocate(ttnn.DRAM_MEMORY_CONFIG) if self.stage_inputs and kind in ("input", "param") else None + banks = [allocate(ttnn.DRAM_MEMORY_CONFIG) for _ in range(n_banks)] + slot = _Slot(name, kind, shape_t, dtype, layout, memory_config, buffers, host, staging, + stage_fn or (lambda src, dst: ttnn.copy(src, dst)), banks=banks) + self._slots[name] = slot + return slot + + def add_input(self, name: str, init: Any = None, *, shape: Optional[Sequence[int]] = None, + dtype: Any = "bfloat16", layout: Any = None, memory_config: Any = None, + stage_fn: Optional[Callable[[Any, Any], Any]] = None): + """A persistent trace input (default ROW_MAJOR in DRAM). ``init`` (array / torch / ttnn host tensor) is the + initial content and the warm-up input; zeros of ``shape`` otherwise -- give real data when the graph + gathers with these values (garbage indices can hang the chip). ``stage_fn(staging, persistent)`` replaces + the eager ``ttnn.copy`` in ``stage_inputs`` mode (e.g. a reshard into a sharded L1 input). Returns the + device tensor.""" + return self._new_slot(name, "input", init, shape, dtype, layout, memory_config, 1, stage_fn).buffers[0] + + def add_param(self, name: str, value: Any = 0.0, *, shape: Sequence[int] = (1, 1, 1, 1), dtype: Any = "float32", + layout: Any = None, memory_config: Any = None): + """An RT-dev parameter: a persistent device tensor (default fp32 ``[1, 1, 1, 1]`` TILE, which broadcasts in + ttnn binary ops) refreshed before a replay when ``run(params=...)`` / :meth:`set_params` changes it.""" + import ttnn + + layout = ttnn.TILE_LAYOUT if layout is None else layout + slot = self._new_slot(name, "param", np.broadcast_to(np.asarray(value), tuple(shape)), tuple(shape), dtype, + layout, memory_config, 1, None) + slot.last_value = self._param_bytes(slot, value) + return slot.buffers[0] + + def add_state(self, name: str, init: Any = None, *, shape: Optional[Sequence[int]] = None, dtype: Any = "float32", + layout: Any = None, memory_config: Any = None, pingpong: bool = False, banks: int = 0): + """Temporal state kept on the device across replays (memory queues, previous BEV, ring buffers). + + In-place (default): one buffer, read via ``ctx[name]`` and written via ``ctx.write_state``. ``pingpong``: + two buffers and two traces per variant. ``init`` is restored by :meth:`reset_state`. ``banks``: extra DRAM + buffers of the same spec for :meth:`save_state` / :meth:`load_state` (one per stream id of a + :class:`StreamBanks`, PLAN.md D16), allocated now because nothing may be allocated after a capture. + Returns the buffer(s).""" + import ttnn + + if banks < 0: + raise ValueError("banks must be >= 0") + layout = ttnn.TILE_LAYOUT if layout is None else layout + slot = self._new_slot(name, "state", init, shape, dtype, layout, memory_config, 2 if pingpong else 1, None, + n_banks=int(banks)) + return tuple(slot.buffers) if pingpong else slot.buffers[0] + + def add_output(self, name: str, *, shape: Sequence[int], dtype: Any = "float32", layout: Any = None, + memory_config: Any = None): + """A persistent output buffer (default TILE in DRAM, zeros) allocated before any capture. Variants write it + with ``ctx.write_output(name, value)`` (one traced ``ttnn.copy``) and return it; its address survives + recaptures and is shared by every variant (e.g. shape buckets with one readback). Returns the buffer.""" + import ttnn + + layout = ttnn.TILE_LAYOUT if layout is None else layout + return self._new_slot(name, "output", None, shape, dtype, layout, memory_config, 1, None).buffers[0] + + def add_variant(self, name: str, fn: Callable[[TraceContext], Any], *, warmup_runs: Optional[int] = None) -> None: + """Register a traced function. ``fn(ctx)`` runs ttnn ops on ``ctx[...]`` tensors and returns a device tensor, + a :class:`Packed`, or a list / tuple / dict of them (the persistent trace outputs). No host I/O, no + ``synchronize``, no torch ops inside ``fn``: it is called for warm-up and then recorded.""" + self._check_registration(f"variant {name!r}") + if name in self._variants: + raise ValueError(f"variant {name!r} already exists") + self._variants[name] = _Variant(name, fn, self.warmup_runs if warmup_runs is None else int(warmup_runs)) + + def add_eager_warmup(self, fn: Callable[[], Any]) -> None: + """Register eager device work that the model runs *between* replays (host-fallback glue, an eager layout + change of a read-back tensor, ...): ``fn()`` runs once in the first :meth:`capture`, before any trace is + captured, so its programs are compiled -- and their kernel binaries allocated in DRAM -- before the first + capture. A program compiled after a capture shares the address space of the traces' freed intermediates and + a replay can overwrite its binaries (tt-metal ``tech_reports/.../TraceCorrectness.md``: corruption or a + hang); ``TT_METAL_TRACE_ALLOC_TRACKING=1`` reports it.""" + self._check_registration("an eager warm-up") + if self._traces or self._eager_warmed: + raise RuntimeError("add eager warm-ups before the first capture (release() and rebuild)") + self._eager_warmups.append(fn) + + # ------------------------------------------------------------------------------------------ capture + @property + def phases(self) -> int: + """2 when a ping-pong state exists (two traces per variant), else 1.""" + return 2 if any(s.pingpong for s in self._slots.values()) else 1 + + @property + def captured(self) -> bool: + return bool(self._variants) and all((v, p) in self._traces for v in self._variants for p in range(self.phases)) + + def capture(self) -> None: + """Warm up and capture every registered variant that has no trace yet (idempotent). If traces already exist + and a new variant was added, all traces are released, the new variants warmed up, and all recaptured.""" + import ttnn + + self._check_open() + if not self._variants: + raise RuntimeError("no variant registered (add_variant)") + pending = [v for v in self._variants if any((v, p) not in self._traces for p in range(self.phases))] + if not pending: + return + if self._traces: + ttnn.synchronize_device(self.device) # no replay of a trace being released is still in flight + self._release_traces() + warm, pending = pending, list(self._variants) + else: + warm = pending + self._capturing = True + try: + if not self._eager_warmed: + self._warm_eager_programs() + for vname in warm: + t0 = time.perf_counter() + variant = self._variants[vname] + for phase in range(self.phases): + for _ in range(variant.warmup_runs): + ctx = TraceContext(self, vname, phase, capturing=False) + outputs = variant.fn(ctx) + ttnn.synchronize_device(self.device) + self._free_transient(outputs) + self.timings_ms.setdefault(vname, {})["warmup"] = (time.perf_counter() - t0) * 1e3 + self._phase = 0 + self.reset_state() + for vname in pending: + self.timings_ms.setdefault(vname, {})["capture"] = 0.0 + for phase in range(self.phases): + self._capture_one(vname, phase) + finally: + self._capturing = False + ttnn.synchronize_device(self.device) + if self.num_command_queues == 2: + self._op_event = ttnn.record_event(self.device, CQ_COMPUTE) + self._stage_free_event = self._op_event + + def _warm_eager_programs(self) -> None: + """Compile the runner's own eager programs before the first capture: the staging copies (``stage_inputs``), + the state <-> bank copies (``banks``) and the registered eager warm-ups. Contents are kept: a staging buffer + mirrors its input (:meth:`write_input` writes both), and a state and its first bank hold the same value + before the states are reset ahead of the captures.""" + import ttnn + + for slot in self._slots.values(): + if slot.staging is not None: + slot.stage_fn(slot.staging, slot.buffers[0]) + if slot.banks: + ttnn.copy(slot.buffers[0], slot.banks[0]) + ttnn.copy(slot.banks[0], slot.buffers[0]) + for fn in self._eager_warmups: + fn() + ttnn.synchronize_device(self.device) + self._eager_warmed = True + + @contextlib.contextmanager + def _eager(self, what: str) -> Iterator[None]: + """Eager runner work after a capture must not compile a program (see :meth:`add_eager_warmup`).""" + dev = self.device + count = getattr(dev, "num_program_cache_entries", None) + before = count() if (self._traces and count is not None) else None + yield + if before is not None and count() != before: + raise RuntimeError(f"{self.name}: {what} compiled a new program after capture; its kernel binaries share " + "DRAM with the traces' freed intermediates, so a replay can overwrite them. Run it " + "before the first capture (add_eager_warmup) or keep the tensor specs of the warmed " + "program") + + def _capture_one(self, vname: str, phase: int) -> None: + import ttnn + + dev = self.device + variant = self._variants[vname] + ctx = TraceContext(self, vname, phase, capturing=True) + entries_before = dev.num_program_cache_entries() if hasattr(dev, "num_program_cache_entries") else None + forbid = self.forbid_cache_misses and hasattr(dev, "set_program_cache_misses_allowed") + t0 = time.perf_counter() + if forbid: + dev.set_program_cache_misses_allowed(False) + trace_id = ttnn.begin_trace_capture(dev, cq_id=CQ_COMPUTE) + ok = ended = False + try: + outputs = variant.fn(ctx) + ok = True + finally: + try: + ttnn.end_trace_capture(dev, trace_id, cq_id=CQ_COMPUTE) + ended = True + finally: + if forbid: + dev.set_program_cache_misses_allowed(True) + if not (ok and ended): + self._safe_release(trace_id) + try: + entries_after = dev.num_program_cache_entries() if entries_before is not None else None + if entries_before is not None and entries_after != entries_before: + raise RuntimeError(f"{self.name}/{vname}: the program cache grew during capture ({entries_before} -> " + f"{entries_after}); warm-up does not cover the traced graph") + leaves = [] + for path, leaf in _flatten(outputs): + if isinstance(leaf, Packed): + leaves.append((path, leaf.tensor, leaf.layout)) + elif isinstance(leaf, ttnn.Tensor): + leaves.append((path, leaf, None)) + else: + raise TypeError(f"{self.name}/{vname}: output {path} is {type(leaf).__name__}, expected a device " + "tensor or Packed") + if not leaves: + raise ValueError(f"{self.name}/{vname}: the variant returned no output tensor") + pingpong = {s.name for s in self._slots.values() if s.pingpong} + written = ctx.writes & pingpong + if written and written != pingpong: + raise RuntimeError(f"{self.name}/{vname}: writes ping-pong states {sorted(written)} but not " + f"{sorted(pingpong - written)}; a stepping variant must write all of them") + except BaseException: + self._safe_release(trace_id) + raise + if self.alloc_tracking and self._captures > 0: + from ttnn.tools import trace_allocation_tracker as tracker + + keep = self._persistent_addresses() + for _, tensor, _ in leaves: + if _buffer_address(tensor) not in keep: + tracker.acknowledge_corruptible(tensor) + self._captures += 1 + ms = (time.perf_counter() - t0) * 1e3 + self._traces[(vname, phase)] = _Trace(vname, phase, trace_id, outputs, leaves, bool(written), ms) + self.timings_ms[vname]["capture"] += ms + + def _persistent_addresses(self) -> set: + addrs = set() + for slot in self._slots.values(): + for t in slot.tensors(): + a = _buffer_address(t) + if a is not None: + addrs.add(a) + return addrs + + def _deallocate_outputs(self, tensors: Iterator[Any]) -> None: + """Deallocate op-produced output tensors, never a persistent buffer or a view of one. ``force=False``: a + tensor sharing its device memory with another owner (a view of a model weight returned as an output) is + left to its owners -- ``ttnn.deallocate`` forces by default and would free the weight.""" + import ttnn + + keep = self._persistent_addresses() + seen = set() + for tensor in tensors: + if not isinstance(tensor, ttnn.Tensor) or id(tensor) in seen: + continue + seen.add(id(tensor)) + if tensor.is_allocated() and _buffer_address(tensor) not in keep: + ttnn.deallocate(tensor, False) + + def _free_transient(self, outputs: Any) -> None: + """Deallocate warm-up / eager outputs (see :meth:`_deallocate_outputs`).""" + self._deallocate_outputs(leaf.tensor if isinstance(leaf, Packed) else leaf for _, leaf in _flatten(outputs)) + + def _safe_release(self, trace_id) -> None: + import ttnn + + try: + ttnn.release_trace(self.device, trace_id) + except Exception: # noqa: BLE001 -- best effort on an error path; the original error is re-raised + pass + + # -------------------------------------------------------------------------------------------- inputs + def _slot(self, name: str, kind: Optional[str] = None) -> _Slot: + slot = self._slots.get(name) + if slot is None: + raise KeyError(f"{self.name}: no input / param / state named {name!r}") + if kind is not None and slot.kind != kind: + raise KeyError(f"{self.name}: {name!r} is a {slot.kind}, not a {kind}") + return slot + + @staticmethod + def _param_array(slot: _Slot, value: Any) -> np.ndarray: + dt = np.float32 if dtype_name(slot.dtype) in ("float32", "bfloat16", "bfloat8_b", "bfloat4_b") else np.int64 + return np.ascontiguousarray(np.broadcast_to(np.asarray(value, dtype=dt), slot.shape)) + + def _param_bytes(self, slot: _Slot, value: Any) -> bytes: + return self._param_array(slot, value).tobytes() + + def set_params(self, **values: Any) -> None: + """Queue RT-dev parameter values for the next run (uploaded only if they changed).""" + for name in values: + self._slot(name, "param") + self._pending_params.update(values) + + def _collect_uploads(self, inputs: Optional[Mapping[str, Any]], + params: Optional[Mapping[str, Any]]) -> List[Tuple[_Slot, Any, Optional[bytes]]]: + uploads = [] + for name, value in (inputs or {}).items(): + slot = self._slot(name, "input") + uploads.append((slot, to_host_tensor(value, slot.dtype, slot.layout, shape=slot.shape), None)) + merged = dict(self._pending_params) + merged.update(params or {}) + for name, value in merged.items(): + slot = self._slot(name, "param") + arr = self._param_array(slot, value) + key = arr.tobytes() + if key != slot.last_value: + uploads.append((slot, to_host_tensor(arr, slot.dtype, slot.layout), key)) + return uploads + + def _ensure_events(self) -> None: + """2CQ: the CQ0 events CQ1 waits for exist (``capture()`` records them; a partially failed capture, which + leaves earlier variants runnable, does not get that far).""" + import ttnn + + if self._op_event is None: + self._op_event = ttnn.record_event(self.device, CQ_COMPUTE) + if self._stage_free_event is None: + self._stage_free_event = self._op_event + + def _enqueue_uploads(self, uploads: List[Tuple[_Slot, Any, Optional[bytes]]]) -> None: + import ttnn + + if uploads: + if self.num_command_queues == 1: + for slot, host, _ in uploads: + ttnn.copy_host_to_device_tensor(host, slot.buffers[0], cq_id=CQ_COMPUTE) + elif self.stage_inputs: + self._ensure_events() + ttnn.wait_for_event(CQ_INPUT, self._stage_free_event) + for slot, host, _ in uploads: + ttnn.copy_host_to_device_tensor(host, slot.staging, cq_id=CQ_INPUT) + written = ttnn.record_event(self.device, CQ_INPUT) + ttnn.wait_for_event(CQ_COMPUTE, written) + with self._eager("a stage_fn copy"): + for slot, _, _ in uploads: + slot.stage_fn(slot.staging, slot.buffers[0]) + self._stage_free_event = ttnn.record_event(self.device, CQ_COMPUTE) + else: + self._ensure_events() + ttnn.wait_for_event(CQ_INPUT, self._op_event) + for slot, host, _ in uploads: + ttnn.copy_host_to_device_tensor(host, slot.buffers[0], cq_id=CQ_INPUT) + written = ttnn.record_event(self.device, CQ_INPUT) + ttnn.wait_for_event(CQ_COMPUTE, written) + for slot, _, key in uploads: + if key is not None: + slot.last_value = key + self._pending_params.clear() + + def write_input(self, name: str, value: Any) -> None: + """Upload ``value`` into input ``name`` now (CQ0, outside any trace): e.g. a realistic warm-up sample. + With 2 CQs the event that later CQ1 uploads wait for is re-recorded after this write, so an upload of the + next frame can never land before it (both write the same buffer from different queues).""" + import ttnn + + self._check_open() + slot = self._slot(name, "input") + host = to_host_tensor(value, slot.dtype, slot.layout, shape=slot.shape) + ttnn.copy_host_to_device_tensor(host, slot.buffers[0], cq_id=CQ_COMPUTE) + if slot.staging is not None: # the staging buffer mirrors the input (the stage-copy warm-up keeps it) + ttnn.copy_host_to_device_tensor(host, slot.staging, cq_id=CQ_COMPUTE) + if self.num_command_queues == 2 and self._op_event is not None: + self._op_event = ttnn.record_event(self.device, CQ_COMPUTE) + if self.stage_inputs: + self._stage_free_event = self._op_event + + # --------------------------------------------------------------------------------------------- run + def _trace_for(self, variant: Optional[str], phase: Optional[int] = None) -> _Trace: + self._check_open() + name = variant or self._last_variant or (next(iter(self._variants)) if len(self._variants) == 1 else None) + if name is None: + raise ValueError("variant name required") + if name not in self._variants: + raise KeyError(f"{self.name}: unknown variant {name!r}; have {sorted(self._variants)}") + p = self._phase if phase is None else phase + trace = self._traces.get((name, p)) + if trace is None: + raise RuntimeError(f"{self.name}: variant {name!r} is not captured; call capture() first") + return trace + + def _execute(self, trace: _Trace) -> None: + import ttnn + + ttnn.execute_trace(self.device, trace.trace_id, cq_id=CQ_COMPUTE, blocking=False) + if self.num_command_queues == 2: + self._op_event = ttnn.record_event(self.device, CQ_COMPUTE) + trace.done_event = self._op_event + self._last_variant = trace.variant + self._last_phase[trace.variant] = trace.phase + if trace.steps: + self._phase ^= 1 + + def upload(self, inputs: Optional[Mapping[str, Any]] = None, params: Optional[Mapping[str, Any]] = None) -> int: + """Enqueue the uploads of ``inputs`` and changed ``params`` (CQ0, or CQ1 + events with 2 CQs) without + replaying anything; the next :meth:`run` / :meth:`replay` consumes them. Returns the number of tensors + uploaded.""" + self._check_open() + if not self.captured: + raise RuntimeError(f"{self.name}: capture() before uploading") + uploads = self._collect_uploads(inputs, params) + self._enqueue_uploads(uploads) + return len(uploads) + + def run(self, variant: Optional[str] = None, inputs: Optional[Mapping[str, Any]] = None, + params: Optional[Mapping[str, Any]] = None): + """Upload ``inputs`` (name -> numpy / torch / ttnn host tensor) and changed ``params``, then replay the + variant's trace without blocking. Returns its device outputs (valid once the replay finished).""" + trace = self._trace_for(variant) + self._enqueue_uploads(self._collect_uploads(inputs, params)) + self._execute(trace) + return trace.outputs + + def replay(self, variant: Optional[str] = None, n: int = 1) -> None: + """Replay ``n`` times with no uploads (back-to-back device timing); honours ping-pong phases.""" + for _ in range(n): + self._execute(self._trace_for(variant)) + + def outputs(self, variant: Optional[str] = None, phase: Optional[int] = None): + """Device outputs of a variant's trace (default: the phase that ran last for it, else phase 0).""" + name = variant or self._last_variant + if phase is None and name is not None: + phase = self._last_phase.get(name, 0) + return self._trace_for(name, phase if phase is not None else 0).outputs + + def read(self, variant: Optional[str] = None, *, cq_id: int = CQ_COMPUTE, as_torch: bool = False): + """Read the outputs of the last run of ``variant`` into preallocated host tensors (blocking). + + Returns the output structure with numpy arrays (``as_torch=True``: torch tensors); a :class:`Packed` leaf + becomes ``{name: array}``. ``cq_id=1`` (2CQ) waits on the host for the trace's completion event and reads on + CQ1, so CQ0 can already replay the next segment (segmented D2H).""" + import ttnn + + name = variant or self._last_variant + if name is None or name not in self._last_phase: + raise RuntimeError(f"{self.name}: variant {name!r} has not run yet") + trace = self._trace_for(name, self._last_phase[name]) + if cq_id not in (CQ_COMPUTE, CQ_INPUT): + raise ValueError(f"cq_id={cq_id}: expected 0 or 1") + if cq_id == CQ_INPUT: + if self.num_command_queues != 2: + raise ValueError("cq_id=1 needs a device opened with 2 command queues") + ttnn.event_synchronize(trace.done_event) + if trace.host_buffers is None: + trace.host_buffers = [ttnn.allocate_tensor_on_host(t.spec, self.device) for _, t, _ in trace.leaves] + values: Dict[Tuple, Any] = {} + for (path, tensor, layout), host in zip(trace.leaves, trace.host_buffers): + ttnn.copy_device_to_host_tensor(tensor, host, blocking=True, cq_id=cq_id) + arr = to_numpy(host) + value: Any = layout.unpack(arr) if layout is not None else arr + if as_torch: + import torch + + value = ({k: torch.from_numpy(np.ascontiguousarray(v)) for k, v in value.items()} + if isinstance(value, dict) else torch.from_numpy(np.ascontiguousarray(value))) + values[path] = value + structure = trace.outputs + if isinstance(structure, Packed) or not isinstance(structure, (Mapping, list, tuple)): + return values[()] + return _rebuild(structure, values) + + def __call__(self, variant: Optional[str] = None, inputs: Optional[Mapping[str, Any]] = None, + params: Optional[Mapping[str, Any]] = None, *, as_torch: bool = False): + """``run`` + ``read`` (the common synchronous path).""" + self.run(variant, inputs, params) + return self.read(variant, as_torch=as_torch) + + def run_eager(self, variant: Optional[str] = None, inputs: Optional[Mapping[str, Any]] = None, + params: Optional[Mapping[str, Any]] = None): + """Upload, run the variant's function *eagerly* (no trace) and return its outputs read to numpy, freeing + every eager buffer before returning (safe next to captured traces). State writes happen as in a replay. + Use it for replay-vs-eager bit checks.""" + import ttnn + + self._check_open() + name = variant or self._last_variant or (next(iter(self._variants)) if self._variants else None) + if name not in self._variants: + raise KeyError(f"{self.name}: unknown variant {name!r}; have {sorted(self._variants)}") + variant_obj = self._variants[name] + uploads = self._collect_uploads(inputs, params) + for slot, host, key in uploads: + ttnn.copy_host_to_device_tensor(host, slot.buffers[0], cq_id=CQ_COMPUTE) + if key is not None: + slot.last_value = key + self._pending_params.clear() + ctx = TraceContext(self, name, self._phase, capturing=False) + with self._eager(f"run_eager({name!r})"): + outputs = variant_obj.fn(ctx) + values = {} + for path, leaf in _flatten(outputs): + if isinstance(leaf, Packed): + values[path] = leaf.layout.unpack(to_numpy(leaf.tensor)) + else: + values[path] = to_numpy(leaf) + self._free_transient(outputs) + pingpong = {s.name for s in self._slots.values() if s.pingpong} + if pingpong and (ctx.writes & pingpong) == pingpong: + self._phase ^= 1 + if isinstance(outputs, Packed) or not isinstance(outputs, (Mapping, list, tuple)): + return values[()] + return _rebuild(outputs, values) + + # ------------------------------------------------------------------------------------------- state + @property + def phase(self) -> int: + """Which buffer of each ping-pong state the next run reads (0 = the first buffer).""" + return self._phase + + def state_buffer(self, name: str): + """The device buffer the next run reads for state ``name`` (ping-pong: the buffer of the current + :attr:`phase`).""" + slot = self._slot(name, "state") + return slot.buffers[self._phase] if slot.pingpong else slot.buffers[0] + + def reset_state(self, name: Optional[str] = None, value: Any = None) -> None: + """Write the initial value (or ``value``) of state ``name`` (all states when ``None``) into the buffer the + next run reads. ``value``: numpy / scalar / torch / ttnn host tensor (uploaded) or a ttnn *device* tensor of + the same shape (``ttnn.copy`` on the device). Enqueued on CQ0, so it lands after any replay already + enqueued.""" + import ttnn + + self._check_open() + names = [name] if name is not None else [s.name for s in self._slots.values() if s.kind == "state"] + for n in names: + slot = self._slot(n, "state") + target = self.state_buffer(n) + if isinstance(value, ttnn.Tensor) and value.storage_type() == ttnn.StorageType.DEVICE: + _check_copy(f"reset_state({n!r})", value, slot) + with self._eager(f"reset_state({n!r}) from a device tensor"): + ttnn.copy(value, target) + continue + if value is None: + host = slot.init + elif isinstance(value, ttnn.Tensor): + host = to_host_tensor(value, slot.dtype, slot.layout, shape=slot.shape) + else: + arr = to_numpy(value) if hasattr(value, "detach") else np.asarray(value) + host = to_host_tensor(np.broadcast_to(arr, slot.shape), slot.dtype, slot.layout) + ttnn.copy_host_to_device_tensor(host, target, cq_id=CQ_COMPUTE) + + def read_state(self, name: str) -> np.ndarray: + """The value the next run will read for state ``name`` (blocking read on CQ0).""" + return to_numpy(self.state_buffer(name)) + + def _bank(self, name: str, bank: int): + slot = self._slot(name, "state") + if not 0 <= bank < len(slot.banks): + raise IndexError(f"{self.name}: state {name!r} has {len(slot.banks)} bank(s) (add_state(banks=...)), " + f"not bank {bank}") + return slot.banks[bank] + + def save_state(self, name: str, bank: int) -> None: + """Copy the current value of state ``name`` into its ``bank`` (``ttnn.copy`` on CQ0, eager, after any + replay already enqueued): the first half of a stream switch (PLAN.md D16; :class:`StreamBanks`).""" + import ttnn + + self._check_open() + with self._eager(f"save_state({name!r})"): + ttnn.copy(self.state_buffer(name), self._bank(name, bank)) + + def load_state(self, name: str, bank: int) -> None: + """Copy ``bank`` back into the buffer the next run reads for state ``name`` (``ttnn.copy`` on CQ0).""" + import ttnn + + self._check_open() + with self._eager(f"load_state({name!r})"): + ttnn.copy(self._bank(name, bank), self.state_buffer(name)) + + # ------------------------------------------------------------------------------------------- misc + def trace_ids(self) -> Dict[Tuple[str, int], Any]: + """``{(variant, phase): trace_id}`` for profiling scripts.""" + return {k: t.trace_id for k, t in self._traces.items()} + + def describe(self) -> Dict[str, Any]: + """A JSON-able summary for ``model.info`` / OPT_BASELINE.""" + def spec(s: _Slot) -> Dict[str, Any]: + return {"shape": list(s.shape), "dtype": dtype_name(s.dtype), "layout": str(s.layout).rsplit(".", 1)[-1], + **({"pingpong": s.pingpong, "banks": len(s.banks)} if s.kind == "state" else {})} + + entries = None + if hasattr(self.device, "num_program_cache_entries"): + entries = int(self.device.num_program_cache_entries()) + return { + "name": self.name, "num_command_queues": self.num_command_queues, "stage_inputs": self.stage_inputs, + "warmup_runs": self.warmup_runs, "phases": self.phases, "alloc_tracking": self.alloc_tracking, + "variants": sorted(self._variants), "traces": len(self._traces), + "inputs": {s.name: spec(s) for s in self._slots.values() if s.kind == "input"}, + "params": {s.name: spec(s) for s in self._slots.values() if s.kind == "param"}, + "states": {s.name: spec(s) for s in self._slots.values() if s.kind == "state"}, + "outputs": {s.name: spec(s) for s in self._slots.values() if s.kind == "output"}, + "timings_ms": {k: {kk: round(vv, 3) for kk, vv in v.items()} for k, v in self.timings_ms.items()}, + "program_cache_entries": entries, + } + + def _release_traces(self) -> None: + traces, self._traces = self._traces, {} + for trace in traces.values(): + self._safe_release(trace.trace_id) + self._last_phase.clear() + self._last_variant = None + self._captures = 0 + self._deallocate_outputs(tensor for trace in traces.values() for _, tensor, _ in trace.leaves) + + def release(self) -> None: + """Release every trace and deallocate the persistent tensors. Idempotent; the persistent tensors are freed + even when the device sync or a trace release raises (the error propagates afterwards).""" + import ttnn + + if self._closed: + return + self._closed = True + try: + try: + ttnn.synchronize_device(self.device) + finally: + self._release_traces() + finally: + slots, self._slots = list(self._slots.values()), {} + for slot in slots: + for t in slot.tensors(): + if t.is_allocated(): + ttnn.deallocate(t) + + def _check_open(self) -> None: + if self._closed: + raise RuntimeError(f"{self.name}: the TraceRunner was released") + + def __enter__(self) -> "TraceRunner": + return self + + def __exit__(self, *exc) -> None: + self.release() + + +class StreamBanks: + """Per-stream device state of a temporal model (PLAN.md D16) on top of the states of a :class:`TraceRunner`. + + The traces read and write one set of state buffers: the *active* stream's. With ``max_streams > 1`` every known + stream id owns one bank of each state (``add_state(..., banks=max_streams)``) and a switch saves the active + stream into its bank and loads the selected one (``ttnn.copy`` on CQ0, after the replays already enqueued; + probe P13 measured ~80 us eager for a StreamPETR-size state). A new id beyond ``max_streams`` is refused + (``on_full="reject"``: :class:`~.io.InputError`, HTTP 400) or takes over the least recently used stream + (``"evict"``; with ``max_streams=1`` that simply restarts the one state). A stream starts fresh -- its states + reset to their ``init`` values -- when it is new, when ``reset=True``, or when its timestamp goes backwards or + jumps by more than ``max_gap_s``; :meth:`select` returns True then, so the model can also set its first-frame + RT-dev params (BEVFormer ``use_prev_bev=0``, BEVDet ``flag``, ...). Create it after the last ``capture()`` (a + capture resets every state) and call :meth:`select` under the model lock, before the run of each frame:: + + S_MAX = 1 # first publish (D16) + runner.add_state("prev_bev", shape=(1, 1, 22500, 256), dtype="bfloat16", pingpong=True, + banks=S_MAX if S_MAX > 1 else 0) + streams = StreamBanks(runner, ["prev_bev"], max_streams=S_MAX, on_full="evict", max_gap_s=2.0) + ... + fresh = streams.select(stream.get("id", "default"), reset=stream.get("reset", False), + timestamp_s=stream.get("timestamp_s")) + out = runner("frame", inputs=..., params={"use_prev_bev": 0.0 if fresh else 1.0}) + """ + + def __init__(self, runner: TraceRunner, states: Sequence[str], *, max_streams: int = 1, on_full: str = "reject", + max_gap_s: Optional[float] = None): + if int(max_streams) < 1: + raise ValueError("max_streams must be >= 1") + if on_full not in ("reject", "evict"): + raise ValueError(f"on_full={on_full!r}: expected 'reject' or 'evict'") + self.runner = runner + self.states = tuple(states) + if not self.states: + raise ValueError("StreamBanks needs at least one state") + self.max_streams = int(max_streams) + for name in self.states: + banks = len(runner._slot(name, "state").banks) + if self.max_streams > 1 and banks < self.max_streams: + raise ValueError(f"state {name!r} has {banks} bank(s); StreamBanks(max_streams={self.max_streams}) " + f"needs add_state(..., banks={self.max_streams})") + self.on_full = on_full + self.max_gap_s = None if max_gap_s is None else float(max_gap_s) + self.active: Optional[str] = None + self._bank: Dict[str, int] = {} # stream id -> bank index (max_streams > 1) + self._last_t: Dict[str, float] = {} # stream id -> timestamp of its last frame + self._used: Dict[str, int] = {} # stream id -> last use (LRU clock) + self._clock = 0 + + @property + def streams(self) -> List[str]: + """Known stream ids, most recently used first.""" + return sorted(self._used, key=lambda s: -self._used[s]) + + def _save_active(self) -> None: + if self.active is not None and self.active in self._bank: + for name in self.states: + self.runner.save_state(name, self._bank[self.active]) + + def forget(self, stream_id: str) -> None: + """Drop a stream (its bank becomes free; if it was active, the next :meth:`select` starts fresh).""" + sid = str(stream_id) + self._used.pop(sid, None) + self._bank.pop(sid, None) + self._last_t.pop(sid, None) + if self.active == sid: + self.active = None + + def select(self, stream_id: Any = "default", *, reset: bool = False, timestamp_s: Optional[float] = None) -> bool: + """Make ``stream_id`` the active stream for the next run; returns True when its state starts fresh.""" + sid = str(stream_id) + fresh = bool(reset) + if sid != self.active: + if sid in self._used: # a known stream parked in its bank + self._save_active() + if not fresh: + for name in self.states: + self.runner.load_state(name, self._bank[sid]) + else: # a new stream + if len(self._used) >= self.max_streams: + if self.on_full == "reject": + raise InputError(f"stream {sid!r}: this model keeps device state for {self.max_streams} " + f"stream(s), in use by {self.streams}; reuse an id") + self.forget(min(self._used, key=self._used.__getitem__)) + self._save_active() + if self.max_streams > 1: + self._bank[sid] = min(set(range(self.max_streams)) - set(self._bank.values())) + fresh = True + self.active = sid + if not fresh and timestamp_s is not None and self.max_gap_s is not None and sid in self._last_t: + dt = float(timestamp_s) - self._last_t[sid] + fresh = dt < 0 or dt > self.max_gap_s + if fresh: + for name in self.states: + self.runner.reset_state(name) + if timestamp_s is not None: + self._last_t[sid] = float(timestamp_s) + elif fresh: + self._last_t.pop(sid, None) + self._clock += 1 + self._used[sid] = self._clock + return fresh + + def describe(self) -> Dict[str, Any]: + """JSON-able summary for ``model.info``.""" + return {"max_streams": self.max_streams, "on_full": self.on_full, "max_gap_s": self.max_gap_s, + "states": list(self.states), "active": self.active, "streams": self.streams} diff --git a/code/tt_frnet/ttaw/voxelize.py b/code/tt_frnet/ttaw/voxelize.py new file mode 100644 index 0000000000000000000000000000000000000000..755df00522b42786343b105b06cce7f76defea92 --- /dev/null +++ b/code/tt_frnet/ttaw/voxelize.py @@ -0,0 +1,260 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C12 voxelization: deterministic pillar builders (vectorised numpy), Autoware's pillar feature decoration, and the +two canvas forms of the PointPillars scatter (dense scatter for references, gather index for the device). + +Autoware's CUDA voxelizer (``autoware_lidar_centerpoint/lib/preprocess/preprocess_kernel.cu``) is +non-deterministic: slots inside a pillar come from ``atomicAdd`` over a randomly shuffled point buffer and pillar +ids from ``atomicAdd`` over a 2-D thread grid (S:centerpoint:177-186). The ports replace it by a deterministic +policy with the same statistics (PLAN.md D1, S:centerpoint:381-389): + +1. optional seeded permutation of the input rows (:func:`ttaw.pointcloud.seeded_permutation`); +2. the half-open range test and the cell index ``floor((v - min) / voxel)`` (a float32 *division*, clamped; the + kernel's ``:135-145``); +3. the first ``max_points_per_pillar`` points of every cell in (permuted) input order; +4. pillar ids in ascending cell order, keeping the first ``max_pillars``. The cell order is ``"flipped_x"`` + (CenterPoint: ``cell = (grid_x - 1 - ix) * grid_y + iy``, front rows first, ``:144``) or ``"raster"`` + (PointPainting: ``cell = iy * grid_x + ix``, S:pointpainting:229-232). + +The result (:class:`PillarSet`) holds the raw rows of every kept slot (zero-padded), the point counts and the +``(z, y, x)`` = ``(0, iy, ix)`` coordinates of mmdet3d / the CUDA ``uint3 {0, idy, idx}`` (``:189-190``). + +:func:`decorate_pillar_features` is ``generateFeatures_kernel`` (``:211-343``) for 9 / 10 / 11 encoder features +(the cluster mean is summed slot by slot in float32, a fixed order standing in for the kernel's float atomics; the +pillar centre is ``voxel / 2 + coord * voxel + min`` in float32; padded slots are all zero). + +Canvas: :func:`scatter_canvas` is ``scatterFeatures_kernel`` (``scatter_kernel.cu:25-48``: ``canvas[c, iy, ix]``). +The device builds the same canvas as a *gather* (C19, probe P4): :func:`canvas_gather_index` gives, for every cell +``iy * grid_x + ix`` of the NHWC canvas, the row of its pillar or a sentinel row (an explicit zero row of the +feature table), so the canvas is ``table[index]`` with no scatter, no clear and a static shape. + +numpy only; no side effects on import. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Sequence, Tuple + +import numpy as np + +from .pointcloud import seeded_permutation + +__all__ = [ + "PillarGridSpec", + "PillarSet", + "assign_pillars", + "decorate_pillar_features", + "scatter_canvas", + "canvas_gather_index", + "pillar_cell_index", +] + +_ORDERS = ("flipped_x", "raster") + + +@dataclass(frozen=True) +class PillarGridSpec: + """A pillar grid and its capacities. Ranges and sizes are float32, as the CUDA config stores them + (``centerpoint_config.hpp:41-56``); ``grid_x = int((max_x - min_x) / voxel_x)`` in float32 (``:95-97``).""" + + range_min: Tuple[float, float, float] + range_max: Tuple[float, float, float] + voxel_size: Tuple[float, float, float] + max_points_per_pillar: int = 32 + max_pillars: int = 40000 + order: str = "flipped_x" + + def __post_init__(self) -> None: + if self.order not in _ORDERS: + raise ValueError(f"order must be one of {_ORDERS}, got {self.order!r}") + if self.max_points_per_pillar < 1 or self.max_pillars < 1: + raise ValueError("capacities must be >= 1") + + @property + def min_f32(self) -> np.ndarray: + return np.asarray(self.range_min, dtype=np.float32) + + @property + def max_f32(self) -> np.ndarray: + return np.asarray(self.range_max, dtype=np.float32) + + @property + def voxel_f32(self) -> np.ndarray: + return np.asarray(self.voxel_size, dtype=np.float32) + + def _grid(self, axis: int) -> int: + return int(np.float32(self.max_f32[axis] - self.min_f32[axis]) / self.voxel_f32[axis]) + + @property + def grid_x(self) -> int: + return self._grid(0) + + @property + def grid_y(self) -> int: + return self._grid(1) + + @property + def grid_z(self) -> int: + return self._grid(2) + + @property + def num_cells(self) -> int: + return self.grid_x * self.grid_y + + +@dataclass +class PillarSet: + """The deterministic voxelization of one cloud (fixed capacity ``max_pillars``; rows past ``num_pillars`` are + zero).""" + + points: np.ndarray # (max_pillars, K, F) float32: raw rows of the kept slots, zero-padded + num_points: np.ndarray # (max_pillars,) int32: min(count, K), 0 for unused rows + coords: np.ndarray # (max_pillars, 3) int32: (0, iy, ix) + num_pillars: int # kept pillars (<= max_pillars) + num_pillars_total: int # non-empty cells before the cap (Autoware's num_voxels counter) + num_points_in_range: int + max_points_in_pillar: int # largest per-cell count before the slot cap + pillars_over_cap: int # cells holding more than K points + num_points_input: int + + @property + def overflow(self) -> bool: + """More non-empty cells than ``max_pillars``: the farthest-back pillars were dropped.""" + return self.num_pillars_total > self.points.shape[0] + + def stats(self) -> Dict[str, Any]: + return {"num_points": self.num_points_input, "num_points_in_range": self.num_points_in_range, + "num_pillars": self.num_pillars, "num_pillars_total": self.num_pillars_total, + "max_points_in_pillar": self.max_points_in_pillar, "pillars_over_cap": self.pillars_over_cap, + "pillar_overflow": self.overflow} + + +def pillar_cell_index(points: np.ndarray, spec: PillarGridSpec) -> Tuple[np.ndarray, np.ndarray]: + """``(ix, iy)`` int64 of each row: ``floor((v - min) / voxel)`` evaluated in float32, clamped to the grid + (``preprocess_kernel.cu:140-143``). The caller has already applied the range test.""" + p = np.asarray(points, dtype=np.float32) + mn, vs = spec.min_f32, spec.voxel_f32 + ix = np.floor((p[:, 0] - mn[0]) / vs[0]).astype(np.int64) + iy = np.floor((p[:, 1] - mn[1]) / vs[1]).astype(np.int64) + return np.clip(ix, 0, spec.grid_x - 1), np.clip(iy, 0, spec.grid_y - 1) + + +def _cell_key(ix: np.ndarray, iy: np.ndarray, spec: PillarGridSpec) -> np.ndarray: + if spec.order == "flipped_x": + return (spec.grid_x - 1 - ix) * spec.grid_y + iy + return iy * spec.grid_x + ix + + +def _key_to_ixy(key: np.ndarray, spec: PillarGridSpec) -> Tuple[np.ndarray, np.ndarray]: + if spec.order == "flipped_x": + return (spec.grid_x - 1) - key // spec.grid_y, key % spec.grid_y + return key % spec.grid_x, key // spec.grid_x + + +def assign_pillars(points: np.ndarray, spec: PillarGridSpec, *, shuffle_seed: Optional[int] = None) -> PillarSet: + """Deterministic voxelization (module docstring). ``points``: (N, F) float32 in the model frame, x, y, z in + columns 0-2 (the remaining columns, e.g. time_lag, are copied into the slots). Rows with a non-finite x, y or z + never enter a pillar.""" + pts = np.ascontiguousarray(np.asarray(points, dtype=np.float32)) + if pts.ndim != 2 or pts.shape[1] < 3: + raise ValueError(f"points must be (N, >=3), got {pts.shape}") + n_in = len(pts) + perm = seeded_permutation(n_in, shuffle_seed) + if perm is not None: + pts = pts[perm] + mn, mx = spec.min_f32, spec.max_f32 + keep = np.ones(len(pts), dtype=bool) + for a in range(3): # Autoware: reject x < min || x >= max (NaN would pass there; here it fails) + keep &= (pts[:, a] >= mn[a]) & (pts[:, a] < mx[a]) + pts = pts[keep] + ix, iy = pillar_cell_index(pts, spec) + key = _cell_key(ix, iy, spec) + order = np.argsort(key, kind="stable") # stable: input order inside each cell + key_sorted = key[order] + uniq, first, counts = np.unique(key_sorted, return_index=True, return_counts=True) + slot = np.arange(len(key_sorted)) - np.repeat(first, counts) + K, P = spec.max_points_per_pillar, spec.max_pillars + n_total = int(len(uniq)) + n_keep = min(n_total, P) + pid = np.repeat(np.arange(n_total), counts) # pillar id of every sorted row (ascending cell order) + sel = (slot < K) & (pid < n_keep) + F = pts.shape[1] + raw = np.zeros((P, K, F), dtype=np.float32) + raw[pid[sel], slot[sel]] = pts[order][sel] + num = np.zeros(P, dtype=np.int32) + num[:n_keep] = np.minimum(counts[:n_keep], K) + cx, cy = _key_to_ixy(uniq[:n_keep], spec) + coords = np.zeros((P, 3), dtype=np.int32) + coords[:n_keep, 1] = cy + coords[:n_keep, 2] = cx + return PillarSet(points=raw, num_points=num, coords=coords, num_pillars=n_keep, num_pillars_total=n_total, + num_points_in_range=int(keep.sum()), max_points_in_pillar=int(counts.max()) if n_total else 0, + pillars_over_cap=int((counts > K).sum()), num_points_input=n_in) + + +def decorate_pillar_features(pillars: PillarSet, spec: PillarGridSpec, *, encoder_in_feature_size: int = 9, + point_dim: Optional[int] = None) -> np.ndarray: + """``generateFeatures_kernel`` (``preprocess_kernel.cu:211-343``) -> (max_pillars, K, encoder_in_feature_size) + float32. ``point_dim`` (columns copied from the raw rows) is 5 (x, y, z, intensity, time_lag) for 11 features + and 4 (x, y, z, time_lag) otherwise, as the kernel's ``point_dim``. Layouts: + + - 9: x, y, z, t, x-x̄, y-ȳ, z-z̄, x-x_c, y-y_c + - 10: x, y, z, t, x-x̄, y-ȳ, z-z̄, x-x_c, y-y_c, z-z_c + - 11: x, y, z, i, t, x-x̄, y-ȳ, z-z̄, x-x_c, y-y_c, z-z_c + + x̄ is the mean of the pillar's kept points (summed slot by slot in float32, divided by the float count); + ``x_c = voxel_x / 2 + ix * voxel_x + min_x`` in float32 (``:269-276``). Padded slots and unused pillars are 0.""" + F_enc = int(encoder_in_feature_size) + if F_enc not in (9, 10, 11): + raise ValueError("encoder_in_feature_size must be 9, 10 or 11") + pd = (5 if F_enc >= 11 else 4) if point_dim is None else int(point_dim) + raw = pillars.points + P, K, F = raw.shape + if F < pd: + raise ValueError(f"pillar rows have {F} columns, decoration needs {pd}") + n = pillars.num_pillars + out = np.zeros((P, K, F_enc), dtype=np.float32) + if n == 0: + return out + r = raw[:n] + cnt = pillars.num_points[:n].astype(np.float32) + s = np.zeros((n, 3), dtype=np.float32) + for k in range(K): # fixed slot order (the kernel's float atomicAdd order is unspecified) + s += r[:, k, :3] + mean = s / cnt[:, None] + vs, mn = spec.voxel_f32, spec.min_f32 + half = vs / np.float32(2) + xo = (half[0] + pillars.coords[:n, 2].astype(np.float32) * vs[0]) + mn[0] + yo = (half[1] + pillars.coords[:n, 1].astype(np.float32) * vs[1]) + mn[1] + zo = (half[2] + pillars.coords[:n, 0].astype(np.float32) * vs[2]) + mn[2] + f = out[:n] + f[:, :, :pd] = r[:, :, :pd] + f[:, :, pd:pd + 3] = r[:, :, :3] - mean[:, None, :] + f[:, :, pd + 3] = r[:, :, 0] - xo[:, None] + f[:, :, pd + 4] = r[:, :, 1] - yo[:, None] + if F_enc in (10, 11): + f[:, :, pd + 5] = r[:, :, 2] - zo[:, None] + valid = np.arange(K)[None, :] < pillars.num_points[:n, None] + f[~valid] = 0.0 + return out + + +def scatter_canvas(features: np.ndarray, coords: np.ndarray, num_pillars: int, spec: PillarGridSpec) -> np.ndarray: + """``scatterFeatures_kernel``: ``canvas[c, iy, ix] = features[p, c]`` for the first ``num_pillars`` rows; the + canvas is (C, grid_y, grid_x) float32 and zero elsewhere (NCHW without the batch axis).""" + f = np.asarray(features, dtype=np.float32).reshape(np.shape(features)[0], -1) + C = f.shape[1] + canvas = np.zeros((C, spec.grid_y, spec.grid_x), dtype=np.float32) + c = np.asarray(coords)[:num_pillars] + canvas[:, c[:, 1], c[:, 2]] = f[:num_pillars].T + return canvas + + +def canvas_gather_index(coords: np.ndarray, num_pillars: int, spec: PillarGridSpec, *, sentinel: int, + dtype: Any = np.uint32) -> np.ndarray: + """Gather form of the scatter (C19): for every cell ``iy * grid_x + ix`` of the row-major (NHWC) canvas, the row + of its pillar, or ``sentinel`` (an all-zero row of the feature table) for an empty cell. Then + ``canvas_nhwc = table[index]`` equals :func:`scatter_canvas` transposed to (grid_y, grid_x, C).""" + idx = np.full(spec.num_cells, sentinel, dtype=np.int64) + c = np.asarray(coords)[:num_pillars] + idx[c[:, 1].astype(np.int64) * spec.grid_x + c[:, 2].astype(np.int64)] = np.arange(num_pillars) + return idx.astype(dtype) diff --git a/code/tt_frnet/ttaw/weights.py b/code/tt_frnet/ttaw/weights.py new file mode 100644 index 0000000000000000000000000000000000000000..4f7dd87a2d28408db9bb3d8b0cf377343b2bcd03 --- /dev/null +++ b/code/tt_frnet/ttaw/weights.py @@ -0,0 +1,632 @@ +# SPDX-License-Identifier: Apache-2.0 +"""C04: weights -- ONNX initializers addressed by the node that consumes them, BN folding in float64, safetensors / +``.pth`` loading, and an on-disk cache of prepared (tilized, converted) device tensors. + +Why by consuming node: initializer names drift between exports of the same network (FRNet), and MatMul weights are +often anonymous (``onnx::MatMul_1234`` in Diffusion Planner), while node names (``/backbone/conv1/Conv``) are stable. +``OnnxWeights`` parses the graph as data only (no onnxruntime, no execution); external-data paths must stay inside +the model's directory. + +BN folding: every fold is computed in float64 and rounded once at the end (default float32; pass ``dtype=None`` to +keep float64 and round straight to the device dtype later). + +The device cache stores ``ttnn`` host tensors (``.tensorbin``) under ``$TT_CACHE_PATH`` (the tt-model container +mounts it at ``/tensor-cache``) or ``~/.cache/ttaw``, keyed by a digest of the source weights, so a new weights +revision never reuses stale tensors. ``TTAW_WEIGHT_CACHE=0`` disables it (A/B switch, read when the cache is built). + +Only ``numpy`` is imported at module level; ``onnx``, ``safetensors``, ``torch`` and ``ttnn`` are imported by the +functions that need them. +""" +from __future__ import annotations + +import fnmatch +import hashlib +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple, Union + +import numpy as np + +__all__ = [ + "BatchNorm", + "fold_bn", + "fold_bn_conv", + "fold_bn_linear", + "fold_bn_conv_transpose", + "OnnxNode", + "ConvParams", + "GemmParams", + "OnnxWeights", + "StateDict", + "load_safetensors", + "load_torch_checkpoint", + "load_state_dict", + "file_sha256", + "cache_root", + "WeightCache", +] + +PathLike = Union[str, os.PathLike] + + +# ---------------------------------------------------------------------------------------------- BN folding + +@dataclass(frozen=True) +class BatchNorm: + """Inference BatchNorm parameters (any of BN1d / BN2d / ONNX BatchNormalization), stored in float64.""" + + gamma: np.ndarray + beta: np.ndarray + mean: np.ndarray + var: np.ndarray + eps: float = 1e-5 + + def __post_init__(self) -> None: + for name in ("gamma", "beta", "mean", "var"): + object.__setattr__(self, name, np.asarray(getattr(self, name), dtype=np.float64).reshape(-1)) + n = self.gamma.shape[0] + if not (self.beta.shape[0] == self.mean.shape[0] == self.var.shape[0] == n): + raise ValueError("BatchNorm parameter lengths differ") + + @property + def channels(self) -> int: + return int(self.gamma.shape[0]) + + @property + def scale(self) -> np.ndarray: + """``gamma / sqrt(var + eps)`` (float64).""" + return self.gamma / np.sqrt(self.var + self.eps) + + @property + def shift(self) -> np.ndarray: + """``beta - mean * scale`` (float64): ``bn(x) == x * scale + shift``.""" + return self.beta - self.mean * self.scale + + @classmethod + def from_state_dict(cls, sd: Mapping[str, Any], prefix: str, eps: float = 1e-5) -> "BatchNorm": + """torch naming: ``.weight``, ``.bias``, ``.running_mean``, ``.running_var``.""" + p = prefix.rstrip(".") + "." if prefix else "" + return cls(np.asarray(sd[p + "weight"]), np.asarray(sd[p + "bias"]), np.asarray(sd[p + "running_mean"]), + np.asarray(sd[p + "running_var"]), eps) + + def apply(self, x: np.ndarray, axis: int = 1) -> np.ndarray: + """Reference application in float64 (``axis`` = channel axis), for tests.""" + shape = [1] * np.ndim(x) + shape[axis] = -1 + return np.asarray(x, np.float64) * self.scale.reshape(shape) + self.shift.reshape(shape) + + +def _finish(w: np.ndarray, b: np.ndarray, dtype: Optional[Any]) -> Tuple[np.ndarray, np.ndarray]: + if dtype is None: + return w, b + return w.astype(dtype), b.astype(dtype) + + +def fold_bn(weight: np.ndarray, bias: Optional[np.ndarray], bn: BatchNorm, *, axis: int = 0, + dtype: Optional[Any] = np.float32) -> Tuple[np.ndarray, np.ndarray]: + """Fold ``bn`` (applied to the layer's output) into ``weight`` / ``bias``: ``W' = W * s`` along the output axis + ``axis`` and ``b' = (b - mean) * s + beta``, all in float64, one final rounding to ``dtype``.""" + w = np.asarray(weight, np.float64) + if w.shape[axis] != bn.channels: + raise ValueError(f"weight axis {axis} has {w.shape[axis]} channels, BatchNorm has {bn.channels}") + shape = [1] * w.ndim + shape[axis] = -1 + s = bn.scale + b = np.zeros(bn.channels, np.float64) if bias is None else np.asarray(bias, np.float64).reshape(-1) + return _finish(w * s.reshape(shape), (b - bn.mean) * s + bn.beta, dtype) + + +def fold_bn_conv(weight: np.ndarray, bias: Optional[np.ndarray], bn: BatchNorm, *, + dtype: Optional[Any] = np.float32) -> Tuple[np.ndarray, np.ndarray]: + """Conv1d/2d/3d weight ``[Cout, Cin/groups, k...]`` followed by BN (any ``groups``).""" + return fold_bn(weight, bias, bn, axis=0, dtype=dtype) + + +def fold_bn_linear(weight: np.ndarray, bias: Optional[np.ndarray], bn: BatchNorm, *, layout: str = "out_in", + dtype: Optional[Any] = np.float32) -> Tuple[np.ndarray, np.ndarray]: + """Linear / Gemm / MatMul followed by BN1d. ``layout="out_in"``: torch ``Linear`` and ONNX Gemm with + ``transB=1`` (``[out, in]``); ``"in_out"``: ONNX MatMul / Gemm ``transB=0`` (``[in, out]``).""" + if layout not in ("out_in", "in_out"): + raise ValueError("layout must be 'out_in' or 'in_out'") + return fold_bn(weight, bias, bn, axis=0 if layout == "out_in" else -1, dtype=dtype) + + +def fold_bn_conv_transpose(weight: np.ndarray, bias: Optional[np.ndarray], bn: BatchNorm, *, groups: int = 1, + dtype: Optional[Any] = np.float32) -> Tuple[np.ndarray, np.ndarray]: + """ConvTranspose weight ``[Cin, Cout/groups, k...]`` followed by BN: output channel of ``W[i, j]`` is + ``(i // (Cin/groups)) * (Cout/groups) + j``.""" + w = np.asarray(weight, np.float64) + cin, cout_g = w.shape[0], w.shape[1] + if cin % groups: + raise ValueError(f"Cin={cin} is not divisible by groups={groups}") + if cout_g * groups != bn.channels: + raise ValueError(f"ConvTranspose has {cout_g * groups} output channels, BatchNorm has {bn.channels}") + out_idx = (np.arange(cin)[:, None] // (cin // groups)) * cout_g + np.arange(cout_g)[None, :] + s = bn.scale[out_idx].reshape((cin, cout_g) + (1,) * (w.ndim - 2)) + b = np.zeros(bn.channels, np.float64) if bias is None else np.asarray(bias, np.float64).reshape(-1) + return _finish(w * s, (b - bn.mean) * bn.scale + bn.beta, dtype) + + +# ----------------------------------------------------------------------------------------------- ONNX + +@dataclass(frozen=True) +class OnnxNode: + """A graph node: name, op type, input / output tensor names and decoded attributes.""" + + name: str + op_type: str + inputs: Tuple[str, ...] + outputs: Tuple[str, ...] + attrs: Dict[str, Any] = field(default_factory=dict) + index: int = 0 + + +@dataclass(frozen=True) +class ConvParams: + """Weights and attributes of a Conv / ConvTranspose node. ``pads`` is the explicit attribute (zeros when + absent); with ``auto_pad`` ``SAME_UPPER`` / ``SAME_LOWER`` the real padding depends on the input size and must + be derived from it (PyTorch exports always write explicit pads and ``NOTSET``).""" + + weight: np.ndarray + bias: Optional[np.ndarray] + strides: Tuple[int, ...] + pads: Tuple[int, ...] + dilations: Tuple[int, ...] + group: int + kernel_shape: Tuple[int, ...] + output_padding: Tuple[int, ...] = () + auto_pad: str = "NOTSET" + output_shape: Tuple[int, ...] = () + + +@dataclass(frozen=True) +class GemmParams: + """Weights and attributes of a Gemm node (``weight`` exactly as stored; see ``trans_b``).""" + + weight: np.ndarray + bias: Optional[np.ndarray] + trans_a: bool + trans_b: bool + alpha: float + beta: float + + +def _attr_value(attr) -> Any: + from onnx import helper + + value = helper.get_attribute_value(attr) + if isinstance(value, bytes): + return value.decode("utf-8", "replace") + if isinstance(value, list) and value and isinstance(value[0], bytes): + return [v.decode("utf-8", "replace") for v in value] + return value + + +class OnnxWeights: + """Initializers and Constant tensors of an ONNX file, addressable by tensor name or by consuming node. + + Example:: + + w = OnnxWeights("pts_backbone_neck_head_centerpoint.onnx") + conv = w.conv("/backbone/blocks.0/blocks.0.0/Conv") # ConvParams(weight, bias, strides, ...) + wf, bf = w.fold_conv_bn("/backbone/blocks.0/blocks.0.0/Conv") # conv + its BN consumer, fp64 fold + k = w.param("/dit/blocks.0/attn/MatMul", 1) # anonymous MatMul weight by slot + for node in w.nodes("Conv", "/backbone/*"): # graph order, glob on node names + print(node.name, w.conv(node.name).weight.shape) + """ + + def __init__(self, path: PathLike, *, load_external_data: bool = True): + import onnx + + self.path = Path(path) + model = onnx.load(str(self.path), load_external_data=False) + if load_external_data: + self._load_external(model) + self._sha256: Optional[str] = None + graph = model.graph + self._tensors: Dict[str, Any] = {t.name: t for t in graph.initializer} + self._cache: Dict[str, np.ndarray] = {} + self._nodes: Dict[str, OnnxNode] = {} + self._order: List[str] = [] + self._producer: Dict[str, str] = {} + self._consumers: Dict[str, List[str]] = {} + self._alias: Dict[str, str] = {} + for i, n in enumerate(graph.node): + name = n.name or f"{n.op_type}_{i}" + if name in self._nodes: + name = f"{name}#{i}" + node = OnnxNode(name, n.op_type, tuple(n.input), tuple(n.output), + {a.name: _attr_value(a) for a in n.attribute}, i) + self._nodes[name] = node + self._order.append(name) + for out in n.output: + self._producer[out] = name + for inp in n.input: + if inp: + self._consumers.setdefault(inp, []).append(name) + if n.op_type == "Constant" and n.output: + self._add_constant(n.output[0], n) + for name in self._order: # Identity of a constant tensor is that tensor + node = self._nodes[name] + if node.op_type == "Identity" and node.inputs and node.outputs: + self._alias[node.outputs[0]] = node.inputs[0] + self.input_names = tuple(i.name for i in graph.input if i.name not in self._tensors) + self.output_names = tuple(o.name for o in graph.output) + self.opset = {o.domain or "ai.onnx": int(o.version) for o in model.opset_import} + + def _load_external(self, model) -> None: + from onnx import TensorProto + from onnx import external_data_helper as ext + + base = self.path.parent.resolve() + for tensor in model.graph.initializer: + if tensor.data_location != TensorProto.EXTERNAL: + continue + location = {e.key: e.value for e in tensor.external_data}.get("location", "") + target = (base / location).resolve() + if base not in target.parents: + raise ValueError(f"external data of {tensor.name!r} points outside {base}: {location!r}") + ext.load_external_data_for_tensor(tensor, str(base)) + tensor.data_location = TensorProto.DEFAULT + del tensor.external_data[:] + + def _add_constant(self, output: str, node) -> None: + from onnx import numpy_helper + + for attr in node.attribute: + if attr.name == "value": + self._tensors[output] = attr.t + elif attr.name in ("value_float", "value_floats", "value_int", "value_ints"): + dt = np.float32 if "float" in attr.name else np.int64 + self._cache[output] = np.asarray(_attr_value(attr), dtype=dt) + self._tensors[output] = numpy_helper.from_array(self._cache[output], output) + + # ---- tensors --------------------------------------------------------------------------------- + @property + def sha256(self) -> str: + """sha256 of the ``.onnx`` file (computed on first use; a cache-key ingredient).""" + if self._sha256 is None: + self._sha256 = file_sha256(self.path) + return self._sha256 + + def _resolve(self, name: str) -> str: + seen = set() + while name in self._alias and name not in self._tensors: + if name in seen: + raise ValueError(f"Identity cycle at {name!r}") + seen.add(name) + name = self._alias[name] + return name + + def has(self, name: str) -> bool: + """True if ``name`` is an initializer / Constant output (or an Identity of one).""" + return self._resolve(name) in self._tensors + + def array(self, name: str) -> np.ndarray: + """The constant tensor ``name`` as numpy (cached; treat it as read-only).""" + from onnx import numpy_helper + + key = self._resolve(name) + if key not in self._tensors: + raise KeyError(f"{name!r} is not an initializer or Constant output of {self.path.name}") + if key not in self._cache: + self._cache[key] = numpy_helper.to_array(self._tensors[key]) + return self._cache[key] + + def initializer_names(self) -> List[str]: + return sorted(self._tensors) + + def state_dict(self) -> Dict[str, np.ndarray]: + """Every constant tensor by name (the export's names; prefer node addressing for stability).""" + return {name: self.array(name) for name in self._tensors} + + # ---- nodes ----------------------------------------------------------------------------------- + def node(self, name: str) -> OnnxNode: + try: + return self._nodes[name] + except KeyError: + close = [n for n in self._order if name in n][:5] + raise KeyError(f"no node {name!r} in {self.path.name}" + (f"; similar: {close}" if close else "")) from None + + def nodes(self, op_type: Optional[str] = None, pattern: Optional[str] = None, *, + regex: bool = False) -> List[OnnxNode]: + """Nodes in graph order, filtered by op type and a glob (or ``regex=True``) on the node name.""" + rx = re.compile(pattern) if (pattern and regex) else None + out = [] + for name in self._order: + node = self._nodes[name] + if op_type is not None and node.op_type != op_type: + continue + if pattern is not None and not (rx.search(name) if rx else fnmatch.fnmatchcase(name, pattern)): + continue + out.append(node) + return out + + def find_node(self, pattern: str, op_type: Optional[str] = None, *, regex: bool = False) -> OnnxNode: + """Exactly one node matching ``pattern`` (and ``op_type``), else ``KeyError``.""" + found = self.nodes(op_type, pattern, regex=regex) + if len(found) != 1: + raise KeyError(f"{len(found)} nodes match {pattern!r} (op_type={op_type}): {[n.name for n in found[:8]]}") + return found[0] + + def producer(self, tensor: str) -> Optional[OnnxNode]: + name = self._producer.get(tensor) + return self._nodes[name] if name else None + + def consumers(self, tensor: str) -> List[OnnxNode]: + """Nodes reading tensor ``tensor`` (directly; Identity nodes are listed as consumers).""" + return [self._nodes[n] for n in self._consumers.get(tensor, [])] + + def consumer_of(self, tensor: str, op_type: Optional[str] = None) -> OnnxNode: + """The single consumer of ``tensor`` (optionally of ``op_type``), else ``KeyError``.""" + found = [n for n in self.consumers(tensor) if op_type is None or n.op_type == op_type] + if len(found) != 1: + raise KeyError(f"{tensor!r} has {len(found)} consumers of type {op_type}: {[n.name for n in found]}") + return found[0] + + def param(self, node_name: str, index: int) -> np.ndarray: + """The constant feeding input slot ``index`` of node ``node_name``.""" + node = self.node(node_name) + if index >= len(node.inputs) or not node.inputs[index]: + raise KeyError(f"node {node_name!r} ({node.op_type}) has no input {index}") + name = node.inputs[index] + if not self.has(name): + raise KeyError(f"input {index} of {node_name!r} is {name!r}, a runtime tensor, not a constant") + return self.array(name) + + def params(self, node_name: str) -> Dict[int, np.ndarray]: + """``{slot: array}`` for every constant input of the node.""" + node = self.node(node_name) + return {i: self.array(t) for i, t in enumerate(node.inputs) if t and self.has(t)} + + def conv(self, node_name: str) -> ConvParams: + """Conv / ConvTranspose weights (+ bias) and attributes with ONNX defaults filled in.""" + node = self.node(node_name) + if node.op_type not in ("Conv", "ConvTranspose"): + raise ValueError(f"{node_name!r} is {node.op_type}, not Conv/ConvTranspose") + w = self.param(node_name, 1) + b = self.param(node_name, 2) if len(node.inputs) > 2 and node.inputs[2] else None + k = tuple(node.attrs.get("kernel_shape", w.shape[2:])) + nd = len(k) + attrs = node.attrs + return ConvParams(w, b, tuple(attrs.get("strides", (1,) * nd)), tuple(attrs.get("pads", (0,) * 2 * nd)), + tuple(attrs.get("dilations", (1,) * nd)), int(attrs.get("group", 1)), k, + tuple(attrs.get("output_padding", ())), str(attrs.get("auto_pad", "NOTSET")), + tuple(attrs.get("output_shape", ()))) + + def gemm(self, node_name: str) -> GemmParams: + node = self.node(node_name) + if node.op_type != "Gemm": + raise ValueError(f"{node_name!r} is {node.op_type}, not Gemm") + b = self.param(node_name, 2) if len(node.inputs) > 2 and node.inputs[2] else None + return GemmParams(self.param(node_name, 1), b, bool(node.attrs.get("transA", 0)), + bool(node.attrs.get("transB", 0)), float(node.attrs.get("alpha", 1.0)), + float(node.attrs.get("beta", 1.0))) + + def matmul_weight(self, node_name: str) -> np.ndarray: + """The constant operand of a MatMul (``[in, out]`` when it is the right operand).""" + node = self.node(node_name) + consts = [i for i, t in enumerate(node.inputs[:2]) if t and self.has(t)] + if len(consts) != 1: + raise ValueError(f"MatMul {node_name!r} has {len(consts)} constant operands") + return self.array(node.inputs[consts[0]]) + + def batchnorm(self, node_name: str) -> BatchNorm: + node = self.node(node_name) + if node.op_type != "BatchNormalization": + raise ValueError(f"{node_name!r} is {node.op_type}, not BatchNormalization") + return BatchNorm(self.param(node_name, 1), self.param(node_name, 2), self.param(node_name, 3), + self.param(node_name, 4), float(node.attrs.get("epsilon", 1e-5))) + + def fold_conv_bn(self, conv_node: str, bn_node: Optional[str] = None, *, + dtype: Optional[Any] = np.float32) -> Tuple[np.ndarray, np.ndarray]: + """Conv / ConvTranspose weights with the following BatchNormalization folded in (float64, one rounding). + ``bn_node`` defaults to the single BatchNormalization consuming the conv output.""" + conv = self.conv(conv_node) + if bn_node is None: + bn_node = self.consumer_of(self.node(conv_node).outputs[0], "BatchNormalization").name + bn = self.batchnorm(bn_node) + if self.node(conv_node).op_type == "ConvTranspose": + return fold_bn_conv_transpose(conv.weight, conv.bias, bn, groups=conv.group, dtype=dtype) + return fold_bn_conv(conv.weight, conv.bias, bn, dtype=dtype) + + def __repr__(self) -> str: + return f"OnnxWeights({self.path.name}: {len(self._nodes)} nodes, {len(self._tensors)} constants)" + + +# ------------------------------------------------------------------------------------- state dicts + +def _torch_to_numpy(t: Any) -> np.ndarray: + t = t.detach().cpu() + if str(t.dtype) == "torch.bfloat16": + t = t.float() + return t.numpy() + + +def load_safetensors(path: PathLike) -> Dict[str, np.ndarray]: + """All tensors of a ``.safetensors`` file as numpy (bf16 tensors are widened to float32 through torch).""" + from safetensors import safe_open + + out: Dict[str, np.ndarray] = {} + try: + with safe_open(str(path), framework="numpy") as f: + for k in f.keys(): + out[k] = f.get_tensor(k) + return out + except (TypeError, ValueError): # bf16 has no numpy dtype + out.clear() + with safe_open(str(path), framework="pt") as f: + for k in f.keys(): + out[k] = _torch_to_numpy(f.get_tensor(k)) + return out + + +_CHECKPOINT_KEYS = ("state_dict", "model", "model_state_dict", "module", "net", "params") + + +def load_torch_checkpoint(path: PathLike, *, key: Optional[str] = None) -> Dict[str, np.ndarray]: + """A ``.pth`` / ``.pt`` / ``.ckpt`` checkpoint loaded with ``torch.load(weights_only=True)`` (data only: no + pickled code runs) -> ``{name: numpy}``. ``key`` selects a nested dict; otherwise the first of + ``state_dict`` / ``model`` / ``model_state_dict`` / ``module`` / ``net`` / ``params`` that holds tensors.""" + import torch + + obj = torch.load(str(path), map_location="cpu", weights_only=True) + if key is not None: + obj = obj[key] + elif isinstance(obj, Mapping) and not any(hasattr(v, "detach") for v in obj.values()): + for k in _CHECKPOINT_KEYS: + if isinstance(obj.get(k), Mapping): + obj = obj[k] + break + if not isinstance(obj, Mapping): + raise ValueError(f"{path}: no tensor dict found (pass key=)") + return {str(k): _torch_to_numpy(v) for k, v in obj.items() if hasattr(v, "detach")} + + +def load_state_dict(path: PathLike, **kwargs: Any) -> "StateDict": + """``.safetensors`` / ``.pth`` / ``.pt`` / ``.ckpt`` / ``.npz`` -> :class:`StateDict` (by file suffix).""" + p = Path(path) + suffix = p.suffix.lower() + if suffix == ".safetensors": + tensors = load_safetensors(p) + elif suffix in (".pth", ".pt", ".ckpt", ".bin"): + tensors = load_torch_checkpoint(p, **kwargs) + elif suffix == ".npz": + with np.load(p, allow_pickle=False) as z: + tensors = {k: z[k] for k in z.files} + else: + raise ValueError(f"unsupported weights file {p.name}") + return StateDict(tensors) + + +class StateDict(Mapping): + """A read-only ``{name: numpy}`` view with prefix scoping: ``sd.sub("backbone.layer1.")["0.conv1.weight"]``.""" + + def __init__(self, tensors: Mapping[str, np.ndarray], prefix: str = ""): + self._tensors = tensors + self.prefix = prefix + + def __getitem__(self, name: str) -> np.ndarray: + try: + return self._tensors[self.prefix + name] + except KeyError: + raise KeyError(f"{self.prefix + name!r} not in the state dict") from None + + def __iter__(self) -> Iterator[str]: + n = len(self.prefix) + return (k[n:] for k in self._tensors if k.startswith(self.prefix)) + + def __len__(self) -> int: + return sum(1 for _ in self) + + def sub(self, prefix: str) -> "StateDict": + return StateDict(self._tensors, self.prefix + prefix) + + def strip(self, prefix: str) -> "StateDict": + """Drop a leading ``prefix`` (e.g. ``module.``) from every key that has it.""" + return StateDict({(k[len(prefix):] if k.startswith(prefix) else k): v for k, v in self._tensors.items()}, + self.prefix) + + def bn(self, name: str, eps: float = 1e-5) -> BatchNorm: + """BatchNorm ``.{weight,bias,running_mean,running_var}``.""" + return BatchNorm.from_state_dict(self, name, eps) + + +# ----------------------------------------------------------------------------------------- device cache + +def file_sha256(path: PathLike, chunk: int = 1 << 20) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(chunk), b""): + h.update(block) + return h.hexdigest() + + +def cache_root() -> Path: + """``$TT_CACHE_PATH`` (tt-model containers mount ``/tensor-cache`` there), else ``~/.cache/ttaw``.""" + env = os.environ.get("TT_CACHE_PATH") + return Path(env) if env else Path.home() / ".cache" / "ttaw" + + +_SAFE = re.compile(r"[^A-Za-z0-9_.-]+") + + +class WeightCache: + """Prepared device tensors cached on disk as ttnn ``.tensorbin`` files. + + ``namespace`` names the model (``"tt_centerpoint/base"``); ``source_digest`` identifies the source weights + (e.g. ``OnnxWeights.sha256``) so a new revision gets a fresh directory. ``version`` names the preparation + recipe and is part of the directory too: the cache outlives images (tt-model mounts the host's + ``~/.cache/tt-model//tensors`` at ``/tensor-cache``), so an entry built by older preparation code from the + same source weights would otherwise be served to newer code; bump it (e.g. the bundle ``__version__`` plus a + prep revision) whenever the code that builds the tensors changes. Each entry is keyed by name, dtype and layout; + a loaded entry whose dtype, layout or (with ``shape=``) logical shape differs from the request is rebuilt. + ``enabled=None`` reads ``TTAW_WEIGHT_CACHE`` once (``0`` disables: every call rebuilds).""" + + def __init__(self, namespace: str, source_digest: str = "", *, version: str = "", + root: Optional[PathLike] = None, enabled: Optional[bool] = None): + if enabled is None: + enabled = os.environ.get("TTAW_WEIGHT_CACHE", "1").strip() not in ("0", "false", "off", "no") + self.enabled = bool(enabled) + parts = [p for p in namespace.strip("/").split("/") if p] + if not parts or any(p in (".", "..") for p in parts): + raise ValueError(f"bad cache namespace {namespace!r}") + leaf = (source_digest or "nodigest")[:16] + (f"-{_SAFE.sub('_', str(version))}" if version else "") + self.version = str(version) + self.dir = Path(root) if root is not None else cache_root() + self.dir = self.dir.joinpath(*(_SAFE.sub("_", p) for p in parts), leaf) + self.hits = 0 + self.misses = 0 + + def path(self, key: str, dtype: Any, layout: Any) -> Path: + tag = f"{key}|{dtype}|{layout}" + short = hashlib.sha1(tag.encode()).hexdigest()[:8] + dname = getattr(dtype, "name", str(dtype)).lower() + lname = getattr(layout, "name", str(layout)).lower() + return self.dir / f"{_SAFE.sub('_', key)[:120]}.{dname}.{lname}.{short}.tensorbin" + + def get(self, key: str, make: Callable[[], Any], *, dtype: Any, layout: Any = None, device: Any = None, + memory_config: Any = None, shape: Optional[Tuple[int, ...]] = None): + """The cached host tensor for ``key`` (built with ``make()`` -> numpy / torch on a miss), moved to + ``device`` with ``memory_config`` when given. ``shape``: the expected logical shape (an entry of another + shape is rebuilt).""" + import ttnn + + from .tensors import to_host_tensor, ttnn_dtype + + dtype = ttnn_dtype(dtype) + layout = ttnn.TILE_LAYOUT if layout is None else layout + host = None + p = self.path(key, dtype, layout) + if self.enabled and p.is_file(): + try: + host = ttnn.load_tensor(p) + except Exception: # noqa: BLE001 -- unreadable / old format: rebuild below + host = None + if host is not None and (host.dtype != dtype or host.layout != layout + or (shape is not None and tuple(host.shape) != tuple(shape))): + host = None # an entry of another recipe: rebuild + if host is not None: + self.hits += 1 + if host is None: + self.misses += 1 + host = to_host_tensor(make(), dtype, layout, shape=shape) + if self.enabled: + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_name(f".{p.stem}.{os.getpid()}.tmp.tensorbin") + ttnn.dump_tensor(tmp, host) + os.replace(tmp, p) + if device is None: + return host + return ttnn.to_device(host, device, memory_config=memory_config or ttnn.DRAM_MEMORY_CONFIG) + + def clear(self) -> int: + """Delete this namespace/digest directory's entries; returns how many files were removed.""" + n = 0 + if self.dir.is_dir(): + for f in self.dir.glob("*.tensorbin"): + f.unlink() + n += 1 + return n diff --git a/code/tt_frnet/viz.py b/code/tt_frnet/viz.py new file mode 100644 index 0000000000000000000000000000000000000000..6ea818beae9c850f0bd53eb07c0c24f031cceda9 --- /dev/null +++ b/code/tt_frnet/viz.py @@ -0,0 +1,207 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Bird's-eye-view renders of FRNet outputs (numpy + Pillow only; no device, no ttnn). + + from tt_frnet import viz + img = viz.render_bev(out.points, out.label_ids, title="FRNet OT128 on p150") # one panel + img = viz.render_compare(out.points, out.label_ids, ref_labels, # p150 | reference | diff + titles=("p150", "CPU reference")) + img.save("bev.png") + +Colours are the Autoware FRNet palette (``ml_package_frnet_*.param.yaml`` ``palette``, what the node's +``~/output/pointcloud/visualization`` topic shows in rviz); pass ``palette=model.config.palette`` to use the loaded +yaml, else :data:`PALETTE` (the same 27 colours). Forward (+x) is up, left (+y) is left; points are drawn from low to +high z so that objects sit on top of the ground. Points are in the frame of the output (the sensor frame). +""" +from __future__ import annotations + +from typing import Optional, Sequence, Tuple + +import numpy as np + +__all__ = ["PALETTE", "class_colors", "render_bev", "render_compare", "legend_rows"] + +# ml_package_frnet_{ot128,qt128}.param.yaml palette (27 x RGB, identical in both variants), Autoware's class order. +PALETTE = np.array([ + [255, 0, 255], [139, 137, 137], [75, 0, 75], [230, 230, 250], [0, 175, 0], [0, 150, 245], [255, 255, 0], + [199, 21, 133], [0, 139, 139], [160, 32, 240], [184, 134, 11], [135, 60, 0], [0, 255, 255], [128, 128, 0], + [124, 252, 0], [255, 127, 0], [255, 192, 203], [255, 0, 0], [210, 105, 30], [189, 183, 107], [153, 50, 204], + [255, 240, 150], [70, 130, 180], [210, 180, 140], [188, 143, 143], [64, 224, 208], [0, 0, 0]], np.uint8) + +BG = (24, 25, 28) +PANEL = (38, 40, 44) +INK = (232, 232, 232) +INK2 = (165, 168, 173) +RING = (70, 72, 78) +DIFF = (255, 64, 64) +SAME = (90, 92, 98) +_FONTS = ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "DejaVuSans.ttf") + + +def _font(size: int): + from PIL import ImageFont + + for name in _FONTS: + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default() + + +def _palette(palette: Optional[Sequence[int]]) -> np.ndarray: + if palette is None: + return PALETTE + arr = np.asarray(palette, np.int64).reshape(-1, 3) + return np.clip(arr, 0, 255).astype(np.uint8) + + +def class_colors(label_ids: np.ndarray, palette: Optional[Sequence[int]] = None) -> np.ndarray: + """``(N, 3)`` uint8 RGB of each point's class (``palette``: 3 x num_classes ints, or None for :data:`PALETTE`).""" + pal = _palette(palette) + ids = np.asarray(label_ids, np.int64).reshape(-1) + return pal[np.clip(ids, 0, len(pal) - 1)] + + +def _bev_array(xyz: np.ndarray, rgb: np.ndarray, size: int, x_range: Tuple[float, float], y_half: float, + dot: int) -> np.ndarray: + img = np.empty((size, size, 3), np.uint8) + img[:] = PANEL + sx = size / (x_range[1] - x_range[0]) + sy = size / (2 * y_half) + order = np.argsort(xyz[:, 2], kind="stable") + r = ((x_range[1] - xyz[order, 0]) * sx).astype(np.int64) + c = ((y_half - xyz[order, 1]) * sy).astype(np.int64) + m = (r >= 0) & (r < size - dot + 1) & (c >= 0) & (c < size - dot + 1) + col = rgb[order][m] + for dr in range(dot): + for dc in range(dot): + img[r[m] + dr, c[m] + dc] = col + return img + + +def _decorate(im, x_range: Tuple[float, float], y_half: float, title: Optional[str], rings_m: int = 10): + from PIL import ImageDraw + + size = im.width + d = ImageDraw.Draw(im) + sx = size / (x_range[1] - x_range[0]) + sy = size / (2 * y_half) + r0, c0 = x_range[1] * sx, y_half * sy + for k in range(1, 20): + rad = k * rings_m + if rad > max(abs(x_range[0]), abs(x_range[1]), y_half) * 1.5: + break + d.ellipse([c0 - rad * sy, r0 - rad * sx, c0 + rad * sy, r0 + rad * sx], outline=RING) + d.polygon([(c0, r0 - 8), (c0 - 5, r0 + 5), (c0 + 5, r0 + 5)], fill=(250, 250, 250)) + d.text((6, size - 17), f"{rings_m} m rings", font=_font(11), fill=INK2) + if title: + d.rectangle([0, 0, size, 22], fill=BG) + d.text((6, 3), title, font=_font(13), fill=INK) + return im + + +def render_bev(points: np.ndarray, label_ids: np.ndarray, *, palette: Optional[Sequence[int]] = None, + size: int = 480, x_range: Tuple[float, float] = (-40.0, 60.0), y_half: float = 50.0, dot: int = 2, + title: Optional[str] = None, rgb: Optional[np.ndarray] = None): + """One bird's-eye-view panel (a ``PIL.Image``): ``points`` ``(N, >=3)`` coloured by ``label_ids`` (or ``rgb``).""" + from PIL import Image + + xyz = np.asarray(points, np.float32)[:, :3] + colors = class_colors(label_ids, palette) if rgb is None else np.asarray(rgb, np.uint8) + im = Image.fromarray(_bev_array(xyz, colors, size, x_range, y_half, dot)) + return _decorate(im, x_range, y_half, title) + + +def legend_rows(label_ids: np.ndarray, class_names: Sequence[str], *, min_share: float = 0.0005, + limit: int = 16): + """``[(class id, name, share)]`` of the classes present, most frequent first (share of the points).""" + ids = np.asarray(label_ids, np.int64).reshape(-1) + cnt = np.bincount(ids, minlength=len(class_names)) + tot = max(int(cnt.sum()), 1) + rows = [(int(c), class_names[c], cnt[c] / tot) for c in np.argsort(cnt)[::-1] if cnt[c] / tot >= min_share] + return rows[:limit] + + +def _legend_image(rows, palette, width: int): + from PIL import Image, ImageDraw + + pal = _palette(palette) + per_row = max(1, width // 235) + h = 8 + ((len(rows) + per_row - 1) // per_row) * 20 + im = Image.new("RGB", (width, h), BG) + d = ImageDraw.Draw(im) + colw = (width - 16) // per_row + for i, (c, name, share) in enumerate(rows): + x = 8 + (i % per_row) * colw + y = 4 + (i // per_row) * 20 + d.rectangle([x, y + 2, x + 12, y + 14], fill=tuple(int(v) for v in pal[c]), outline=(200, 200, 200)) + d.text((x + 18, y), f"{name} {share * 100:.1f}%", font=_font(12), fill=INK) + return im + + +def render_compare(points: np.ndarray, label_ids: np.ndarray, ref_label_ids: np.ndarray, *, + class_names: Optional[Sequence[str]] = None, palette: Optional[Sequence[int]] = None, + titles: Tuple[str, str] = ("p150", "CPU reference"), header: Optional[str] = None, + footer: Optional[str] = None, size: int = 480, x_range: Tuple[float, float] = (-40.0, 60.0), + y_half: float = 50.0, dot: int = 2, panels: int = 3): + """``panels`` = 3: [``label_ids`` | ``ref_label_ids`` | the points whose labels differ, in red over grey]; 2: the + first two only. ``header`` / ``footer`` are wrapped text lines above / below; a legend of the classes present + (``class_names``) is added. Returns a ``PIL.Image``.""" + from PIL import Image, ImageDraw + + a = np.asarray(label_ids).reshape(-1) + b = np.asarray(ref_label_ids).reshape(-1) + if a.shape != b.shape or len(a) != len(points): + raise ValueError(f"label arrays {a.shape} / {b.shape} do not match {len(points)} points") + diff = a != b + agree = 1.0 - float(diff.mean()) if len(a) else 1.0 + ims = [render_bev(points, a, palette=palette, size=size, x_range=x_range, y_half=y_half, dot=dot, + title=titles[0]), + render_bev(points, b, palette=palette, size=size, x_range=x_range, y_half=y_half, dot=dot, + title=titles[1])] + if panels >= 3: + rgb = np.where(diff[:, None], np.array(DIFF, np.uint8), np.array(SAME, np.uint8)) + xyz = np.asarray(points, np.float32)[:, :3] + order = np.argsort(diff, kind="stable") # differing points drawn last (on top) + ims.append(render_bev(xyz[order], a[order], rgb=rgb[order], size=size, x_range=x_range, y_half=y_half, + dot=dot + 1, title=f"labels differ: {int(diff.sum()):,} of {len(a):,} points " + f"({agree * 100:.2f} % agree)")) + width = sum(im.width for im in ims) + 8 * (len(ims) - 1) + pieces = [] + f = _font(13) + + def text_block(text: str, color) -> "Image.Image": + tmp = ImageDraw.Draw(Image.new("RGB", (1, 1))) + lines, cur = [], "" + for word in text.split(" "): + t = (cur + " " + word).strip() + if tmp.textlength(t, font=f) <= width - 16: + cur = t + else: + lines.append(cur) + cur = word + lines.append(cur) + im = Image.new("RGB", (width, 8 + 18 * len(lines)), BG) + d = ImageDraw.Draw(im) + for i, line in enumerate(lines): + d.text((8, 4 + 18 * i), line, font=f, fill=color) + return im + + if header: + pieces.append(text_block(header, INK)) + row = Image.new("RGB", (width, size), BG) + x = 0 + for im in ims: + row.paste(im, (x, 0)) + x += im.width + 8 + pieces.append(row) + if class_names is not None: + pieces.append(_legend_image(legend_rows(a, class_names), palette, width)) + if footer: + pieces.append(text_block(footer, INK2)) + out = Image.new("RGB", (width, sum(p.height for p in pieces)), BG) + y = 0 + for p in pieces: + out.paste(p, (0, y)) + y += p.height + return out diff --git a/examples/quickstart.py b/examples/quickstart.py new file mode 100644 index 0000000000000000000000000000000000000000..0b3ed47e3eb879893c81f51474694e0c142d2c2a --- /dev/null +++ b/examples/quickstart.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Quickstart: the model card's Python snippet on the shipped sample, on one Blackhole p150. + + pip install -e . # once, from the repo root, on top of an environment that has ttnn (tt-metal) + python examples/quickstart.py [input] [--variant ot128|qt128] [--out-dir examples/output] + +Writes /quickstart.json (the same JSON as POST /predict) and /quickstart_bev.png: a bird's-eye view +of the output points coloured by the p150 classes, next to the stored fp32 CPU reference of the shipped sample and +the points where the two differ (for another input: the p150 panel only). The default input is the shipped +synthetic sample of the variant, found relative to this file (runs from any directory); an input given on the +command line is relative to the current directory. +""" +import argparse +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SAMPLES = REPO / "code" / "tt_frnet" / "samples" +ap = argparse.ArgumentParser() +ap.add_argument("input", nargs="?", default=None, help="sensor-frame cloud (default: the shipped synthetic sample)") +ap.add_argument("--variant", default="ot128", choices=["ot128", "qt128"]) +ap.add_argument("--out-dir", default=str(REPO / "examples" / "output")) +ap.add_argument("--device-id", type=int, default=0) +args = ap.parse_args() +src = args.input or str(SAMPLES / f"synthetic_{args.variant}.npz") +out_dir = Path(args.out_dir) +out_dir.mkdir(parents=True, exist_ok=True) + +# --- the model card snippet -------------------------------------------------------------------------------------- +from tt_frnet import FRNet + +# the sample's sensor -> base_link transform (calib/.json) feeds the node's ego crop box; for another cloud +# pass its own {"T_base_link_from_sensor": ...}, or {"ego_crop_box": None} to disable the box +stem = Path(src).name.split(".")[0] +calib_dir = REPO / "code" / "tt_frnet" / "calib" +calibration = {"preset": stem} if (calib_dir / f"{stem}.json").is_file() else {"ego_crop_box": None} + +with FRNet.from_pretrained(device_id=args.device_id, variant=args.variant) as model: # weights, traces captured + out = model(src, calibration=calibration) # sensor-frame cloud + the ego crop transform + palette = model.config.palette + +print(out.class_counts()) # points per class (raw + interpolated) +# ------------------------------------------------------------------------------------------------------------------ + +(out_dir / "quickstart.json").write_text(json.dumps(out.to_dict(), indent=1)) + +import numpy as np # noqa: E402 + +from tt_frnet import viz # noqa: E402 +from tt_frnet.ttaw.server.smoke import decode_array # noqa: E402 + +ref_path = Path(src).with_name(f"{stem}.{args.variant}.reference.json") +title = f"FRNet {args.variant.upper()} on p150" +if ref_path.is_file(): + ref = json.loads(ref_path.read_text()) + ref_labels = np.asarray(decode_array(ref["labels"]).values, np.int64) + agree = float(np.mean(ref_labels == out.label_ids)) if len(ref_labels) == len(out) else float("nan") + img = viz.render_compare(out.points, out.label_ids, ref_labels, class_names=out.class_names, palette=palette, + titles=(title, "fp32 CPU reference (stored)"), + header=f"{Path(src).name}: {len(out):,} output points ({out.num_points_raw:,} " + f"raw + Autoware's interpolated points), label agreement with the stored CPU " + f"reference {agree * 100:.2f} %") + print(f"label agreement with {ref_path.name}: {agree * 100:.3f} %") +else: + img = viz.render_bev(out.points, out.label_ids, palette=palette, title=title) +img.save(out_dir / "quickstart_bev.png") +print(f"{len(out)} points -> {out_dir / 'quickstart.json'}, {out_dir / 'quickstart_bev.png'} " + f"timing_ms={ {k: round(v, 2) for k, v in out.timing_ms.items()} }") diff --git a/image/blobs/sha256/142bd320bcbb0cdaf3efc2dc0326f53982177f5c6f35e15e751b0cc34cf5c5eb b/image/blobs/sha256/142bd320bcbb0cdaf3efc2dc0326f53982177f5c6f35e15e751b0cc34cf5c5eb new file mode 100644 index 0000000000000000000000000000000000000000..491571d860b5363b2eae061767cb28975bd4a98a Binary files /dev/null and b/image/blobs/sha256/142bd320bcbb0cdaf3efc2dc0326f53982177f5c6f35e15e751b0cc34cf5c5eb differ diff --git a/image/blobs/sha256/263c3393dbadf7dc0379d0fc3f574de2e1bf1f288d4cf6d258c862eb309fed88 b/image/blobs/sha256/263c3393dbadf7dc0379d0fc3f574de2e1bf1f288d4cf6d258c862eb309fed88 new file mode 100644 index 0000000000000000000000000000000000000000..50f5b4395097eeafadd637704af4f8bb5fa5691b --- /dev/null +++ b/image/blobs/sha256/263c3393dbadf7dc0379d0fc3f574de2e1bf1f288d4cf6d258c862eb309fed88 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:263c3393dbadf7dc0379d0fc3f574de2e1bf1f288d4cf6d258c862eb309fed88 +size 41058775 diff --git a/image/blobs/sha256/284bfa7f0620b820de681db358912a4a9007e5c469e301488d1d3d01cb2d43ca b/image/blobs/sha256/284bfa7f0620b820de681db358912a4a9007e5c469e301488d1d3d01cb2d43ca new file mode 100644 index 0000000000000000000000000000000000000000..605b7da34e875feed895feb1a79915268923a187 Binary files /dev/null and b/image/blobs/sha256/284bfa7f0620b820de681db358912a4a9007e5c469e301488d1d3d01cb2d43ca differ diff --git a/image/blobs/sha256/2b82228add13960b6b983a2af3eb786615be8d6ce92943c019be7879f6e37314 b/image/blobs/sha256/2b82228add13960b6b983a2af3eb786615be8d6ce92943c019be7879f6e37314 new file mode 100644 index 0000000000000000000000000000000000000000..b4a71f41e44dd3bd9d72ba1a7b68989e8234a8f7 --- /dev/null +++ b/image/blobs/sha256/2b82228add13960b6b983a2af3eb786615be8d6ce92943c019be7879f6e37314 @@ -0,0 +1,26 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.docker.attestation.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.empty.v1+json", + "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "size": 2, + "data": "e30=" + }, + "layers": [ + { + "mediaType": "application/vnd.in-toto+json", + "digest": "sha256:566cb345b441aff0c558583ffe3d6a98cbad151c82c0511cc1474d9e65880970", + "size": 1891, + "annotations": { + "in-toto.io/predicate-type": "https://slsa.dev/provenance/v1" + } + } + ], + "subject": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:599271c63656161c5bc6995288693bf9a634b29ae9765882fcb5d5a6bd501fde", + "size": 4685 + } +} \ No newline at end of file diff --git a/image/blobs/sha256/2d8b1b67a1bfa51712efa01fd56489f97766c9d4439440d132b211edfefea304 b/image/blobs/sha256/2d8b1b67a1bfa51712efa01fd56489f97766c9d4439440d132b211edfefea304 new file mode 100644 index 0000000000000000000000000000000000000000..e0d1af267d0babaf16baca9a071438f09a3ac03b --- /dev/null +++ b/image/blobs/sha256/2d8b1b67a1bfa51712efa01fd56489f97766c9d4439440d132b211edfefea304 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d8b1b67a1bfa51712efa01fd56489f97766c9d4439440d132b211edfefea304 +size 110567 diff --git a/image/blobs/sha256/2fcc025d0fe89554778cf89bcb74f1084552925ef59ab8520a3e20034f91cfe7 b/image/blobs/sha256/2fcc025d0fe89554778cf89bcb74f1084552925ef59ab8520a3e20034f91cfe7 new file mode 100644 index 0000000000000000000000000000000000000000..4936cb60394750affb028a18a6ecbe9be83af1bd --- /dev/null +++ b/image/blobs/sha256/2fcc025d0fe89554778cf89bcb74f1084552925ef59ab8520a3e20034f91cfe7 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fcc025d0fe89554778cf89bcb74f1084552925ef59ab8520a3e20034f91cfe7 +size 63868829 diff --git a/image/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a b/image/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a new file mode 100644 index 0000000000000000000000000000000000000000..9e26dfeeb6e641a33dae4961196235bdb965b21b --- /dev/null +++ b/image/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/image/blobs/sha256/48aa862fa10a5598113fa2d7bdef781c73416bc5693d271c4d6076ff9a751342 b/image/blobs/sha256/48aa862fa10a5598113fa2d7bdef781c73416bc5693d271c4d6076ff9a751342 new file mode 100644 index 0000000000000000000000000000000000000000..cce3a4da39e15090212abdefb0d37e1e1b96f981 Binary files /dev/null and b/image/blobs/sha256/48aa862fa10a5598113fa2d7bdef781c73416bc5693d271c4d6076ff9a751342 differ diff --git a/image/blobs/sha256/4d1d911054857424c0bbb8ffeac532a517210d4ce9dd8b458c995a934dc16c95 b/image/blobs/sha256/4d1d911054857424c0bbb8ffeac532a517210d4ce9dd8b458c995a934dc16c95 new file mode 100644 index 0000000000000000000000000000000000000000..042727a5885425595f371c4708397a1b2cf582dc --- /dev/null +++ b/image/blobs/sha256/4d1d911054857424c0bbb8ffeac532a517210d4ce9dd8b458c995a934dc16c95 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d1d911054857424c0bbb8ffeac532a517210d4ce9dd8b458c995a934dc16c95 +size 138968698 diff --git a/image/blobs/sha256/4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 b/image/blobs/sha256/4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 new file mode 100644 index 0000000000000000000000000000000000000000..8de868223f695a02a4da72b1e79a6545868048c5 Binary files /dev/null and b/image/blobs/sha256/4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 differ diff --git a/image/blobs/sha256/566cb345b441aff0c558583ffe3d6a98cbad151c82c0511cc1474d9e65880970 b/image/blobs/sha256/566cb345b441aff0c558583ffe3d6a98cbad151c82c0511cc1474d9e65880970 new file mode 100644 index 0000000000000000000000000000000000000000..2ee590d30475b99bf83375ac0ccc814ad82363a3 --- /dev/null +++ b/image/blobs/sha256/566cb345b441aff0c558583ffe3d6a98cbad151c82c0511cc1474d9e65880970 @@ -0,0 +1 @@ +{"_type":"https://in-toto.io/Statement/v1","predicateType":"https://slsa.dev/provenance/v1","subject":[{"name":"pkg:docker/tt-model/frnet-p150@build-44d665005?platform=linux%2Famd64","digest":{"sha256":"599271c63656161c5bc6995288693bf9a634b29ae9765882fcb5d5a6bd501fde"}}],"predicate":{"buildDefinition":{"buildType":"https://github.com/moby/buildkit/blob/master/docs/attestations/slsa-definitions.md","resolvedDependencies":[{"uri":"pkg:docker/docker/dockerfile@1.7-labs","digest":{"sha256":"b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894"}},{"uri":"pkg:docker/ubuntu@22.04?platform=linux%2Famd64","digest":{"sha256":"5ec03bb3441e8b0bf3b4f9cd4629a1ae763010dc3035bb8da3ae6cf026486401"}},{"uri":"pkg:docker/ghcr.io/tenstorrent/tt-metal/tt-metalium/ubuntu-22.04-dev-amd64@latest?platform=linux%2Famd64","digest":{"sha256":"df9d279c7f85c17c6fad982d196802682d669cca1b7ced9cbaad8181339cd5fc"}}],"externalParameters":{"configSource":{"path":"Dockerfile"},"request":{"frontend":"gateway.v0","args":{"cmdline":"docker/dockerfile:1.7-labs","context:metalsrc":"local:metalsrc","frontend.caps":"moby.buildkit.frontend.contexts+forward","sharedkey:localdir:metalsrc":"metalsrc:4e7665ce329afc22","source":"docker/dockerfile:1.7-labs"},"locals":[{"name":"context"},{"name":"dockerfile"},{"name":"metalsrc"}],"root":{"configSource":{"path":"Dockerfile"},"request":{"args":{"context:metalsrc":"local:metalsrc","frontend.caps":"moby.buildkit.frontend.contexts+forward","sharedkey:localdir:metalsrc":"metalsrc:4e7665ce329afc22"}}},"compatibilityVersion":30}},"internalParameters":{"builderPlatform":"linux/amd64"}},"runDetails":{"builder":{"id":""},"metadata":{"invocationId":"y3a0ee8jzw13u96m1beamg5f1","startedOn":"2026-10-09T17:04:34.651660455Z","finishedOn":"2026-10-09T17:14:06.854480912Z","buildkit_metadata":{},"buildkit_completeness":{"request":false,"resolvedDependencies":false}}}}} \ No newline at end of file diff --git a/image/blobs/sha256/599271c63656161c5bc6995288693bf9a634b29ae9765882fcb5d5a6bd501fde b/image/blobs/sha256/599271c63656161c5bc6995288693bf9a634b29ae9765882fcb5d5a6bd501fde new file mode 100644 index 0000000000000000000000000000000000000000..a01de11c58ce9a8320d32fe5ea14f5e5f280e011 --- /dev/null +++ b/image/blobs/sha256/599271c63656161c5bc6995288693bf9a634b29ae9765882fcb5d5a6bd501fde @@ -0,0 +1,126 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:59e41087cc222b19fc1decaa4e85803ca1f9e1577c2d7697976f673cbc5d4c00", + "size": 14919 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:98c4455a98982b35380ec62f90d5cf0edff02eeb4b3bb18595d871fdeb0c0c67", + "size": 29751627 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:60918f793df50b25c4d69e6063db590866f48304ceb84738f3588a4ea3af1d39", + "size": 69483773 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:85d518dbcb97d42216e3d5c61a4a34c68eb34a0b875174f0c3f4203187aa84f2", + "size": 4389 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:c241aa41a5566f12cb0d8c8d32dc73b0580c202137bdd5e6dd934fa7c51a13a4", + "size": 9565631 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:4d1d911054857424c0bbb8ffeac532a517210d4ce9dd8b458c995a934dc16c95", + "size": 138968698 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:d325fca6b89f9da7c9aec617b11eeae4e04520f0fb771d27e965e27ac7b660fb", + "size": 66025113 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:bffe9990635d2390c86310d6135a1f2fb109a288c2678108e6981ee0997f8d71", + "size": 288193825 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:ed6ef5b098fe3f3b6ceae619ba841b756c5ce8ee97f2333b1b36140f7fe1d0c4", + "size": 116 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:9e742f886b889d3b324cb9e64252ac7b15c41dc59b900afea699edefda392ddc", + "size": 139083841 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:7f8e824c081d26d72fc21097753f339930fd6fa33ee4f6e4f79ebdcfae6bb4a9", + "size": 41058907 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:263c3393dbadf7dc0379d0fc3f574de2e1bf1f288d4cf6d258c862eb309fed88", + "size": 41058775 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:2fcc025d0fe89554778cf89bcb74f1084552925ef59ab8520a3e20034f91cfe7", + "size": 63868829 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:8a364a911fa5ef61e168db9466a9c1916109d07dd609a66102a077600a7928ce", + "size": 13066393 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:ac4a0c0abcd00417126e1fdeb179cd923c8bd292a1c7a8b3091cdbfd79cf1a59", + "size": 1949608 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:142bd320bcbb0cdaf3efc2dc0326f53982177f5c6f35e15e751b0cc34cf5c5eb", + "size": 6882 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:955c696fdd4076cc41ef9b404f0f248af208a7058c33f685360e8ad5a8a7e96f", + "size": 2114153 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1", + "size": 32 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:7f1d8564c41b1dd6cd5de2b6df1f0b4893e4fc08bd273f87e970eaf18a37540d", + "size": 1076 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:de380e733b2bb5a318a186b76aaec434aa7c9780aea98c97a570a30cbcfb1339", + "size": 1097 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1", + "size": 32 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:48aa862fa10a5598113fa2d7bdef781c73416bc5693d271c4d6076ff9a751342", + "size": 991 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:2d8b1b67a1bfa51712efa01fd56489f97766c9d4439440d132b211edfefea304", + "size": 110567 + }, + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:284bfa7f0620b820de681db358912a4a9007e5c469e301488d1d3d01cb2d43ca", + "size": 4014 + } + ] +} \ No newline at end of file diff --git a/image/blobs/sha256/59e41087cc222b19fc1decaa4e85803ca1f9e1577c2d7697976f673cbc5d4c00 b/image/blobs/sha256/59e41087cc222b19fc1decaa4e85803ca1f9e1577c2d7697976f673cbc5d4c00 new file mode 100644 index 0000000000000000000000000000000000000000..325adac8b0c3e921d872cfd780dc7633235d6850 --- /dev/null +++ b/image/blobs/sha256/59e41087cc222b19fc1decaa4e85803ca1f9e1577c2d7697976f673cbc5d4c00 @@ -0,0 +1 @@ +{"architecture":"amd64","config":{"User":"tt","Env":["PATH=/opt/tt-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","VENV=/opt/tt-venv","VIRTUAL_ENV=/opt/tt-venv","TT_METAL_RUNTIME_ROOT=/opt/tt-metal","TT_METAL_HOME=/opt/tt-metal","PYTHONPATH=/opt/tt-metal","LD_LIBRARY_PATH=/opt/tt-metal/build/lib:/opt/openmpi-v5.0.7-ulfm/lib","EXTRA_MODELS_DIR=","TT_VLLM_BUILTIN_MODELS=","TT_MODEL_KIND=tt-dit-server","HF_HOME=/hf","TT_METAL_CACHE=/cache","HOME=/home/tt","USER=tt","LOGNAME=tt"],"Entrypoint":["/usr/local/bin/entrypoint.sh"],"Cmd":["/usr/local/bin/serve-default.sh"],"WorkingDir":"/home/tt/work","Labels":{"org.opencontainers.image.revision":"44d66500520fda9f2c7060c0f6b41ec48f7ab37e","org.opencontainers.image.version":"22.04","org.tenstorrent.tt-model":"frnet-p150","org.tenstorrent.tt-model.arch":"blackhole","org.tenstorrent.tt-model.kind":"tt-dit-server","org.tenstorrent.tt-model.plugin":"","org.tenstorrent.tt-model.profiles":"ot128,qt128","org.tenstorrent.tt-model.repo":"changh95/frnet-p150","org.tenstorrent.tt-model.tt-metal":"v0.80.0-dev20261006-78-g44d6650052-dirty","org.tenstorrent.tt-model.weights":"AutowareFoundation/lidar_frnet"},"ArgsEscaped":true},"created":"2026-10-09T17:13:14.493362845Z","history":[{"created":"2026-09-24T22:24:04.688908979Z","created_by":"/bin/sh -c #(nop) ARG RELEASE","empty_layer":true},{"created":"2026-09-24T22:24:04.723527376Z","created_by":"/bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH","empty_layer":true},{"created":"2026-09-24T22:24:04.751455651Z","created_by":"/bin/sh -c #(nop) LABEL org.opencontainers.image.version=22.04","empty_layer":true},{"created":"2026-09-24T22:24:06.837737736Z","created_by":"/bin/sh -c #(nop) ADD file:b0bf3f64519bf10a51e00d4f8ab9c8693620659a0c4a4a5090a9765e754e1d1c in / "},{"created":"2026-09-24T22:24:07.239071539Z","created_by":"/bin/sh -c #(nop) CMD [\"/bin/bash\"]","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG OMPI_DIR=/opt/openmpi-v5.0.7-ulfm","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG EXTRA_MODELS_DIR=","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG TT_MODEL_KIND","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG MODEL_NAME","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG MODEL_REPO","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG MODEL_WEIGHTS","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG MODEL_ARCH","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG MODEL_PROFILES","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG MODEL_TT_METAL_SHA","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG MODEL_TT_METAL_DESCRIBE","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"ARG MODEL_PLUGIN_SHA","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:05:09.065072421Z","created_by":"RUN |11 OMPI_DIR=/opt/openmpi-v5.0.7-ulfm EXTRA_MODELS_DIR= TT_MODEL_KIND=tt-dit-server MODEL_NAME=frnet-p150 MODEL_REPO=changh95/frnet-p150 MODEL_WEIGHTS=AutowareFoundation/lidar_frnet MODEL_ARCH=blackhole MODEL_PROFILES=ot128,qt128 MODEL_TT_METAL_SHA=44d66500520fda9f2c7060c0f6b41ec48f7ab37e MODEL_TT_METAL_DESCRIBE=v0.80.0-dev20261006-78-g44d6650052-dirty MODEL_PLUGIN_SHA= /bin/sh -c apt-get update \u0026\u0026 apt-get install -y --no-install-recommends libhwloc15 libnuma1 libatomic1 libudev1 libcap2 zlib1g libmpc3 libmpfr6 libgmp10 libzstd1 libevent-core-2.1-7 libevent-pthreads-2.1-7 libgl1 libsndfile1 ca-certificates \u0026\u0026 apt-get clean \u0026\u0026 rm -rf /var/lib/apt/lists/* # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:05:10.772357751Z","created_by":"RUN |11 OMPI_DIR=/opt/openmpi-v5.0.7-ulfm EXTRA_MODELS_DIR= TT_MODEL_KIND=tt-dit-server MODEL_NAME=frnet-p150 MODEL_REPO=changh95/frnet-p150 MODEL_WEIGHTS=AutowareFoundation/lidar_frnet MODEL_ARCH=blackhole MODEL_PROFILES=ot128,qt128 MODEL_TT_METAL_SHA=44d66500520fda9f2c7060c0f6b41ec48f7ab37e MODEL_TT_METAL_DESCRIBE=v0.80.0-dev20261006-78-g44d6650052-dirty MODEL_PLUGIN_SHA= /bin/sh -c existing=\"$(getent passwd 1000 | cut -d: -f1)\" \u0026\u0026 if [ -n \"$existing\" ]; then userdel -r \"$existing\" 2\u003e/dev/null || userdel \"$existing\"; fi \u0026\u0026 useradd --uid 1000 --create-home --home-dir /home/tt --shell /bin/bash tt \u0026\u0026 mkdir -p /home/tt/work/logs /cache /opt/tt-metal \u0026\u0026 chown -R tt:tt /home/tt /cache /opt/tt-metal \u0026\u0026 chmod 1777 /home/tt /home/tt/work /home/tt/work/logs /cache # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:28.171193277Z","created_by":"COPY /opt/openmpi-v5.0.7-ulfm /opt/openmpi-v5.0.7-ulfm # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:31.489277432Z","created_by":"COPY /opt/tenstorrent /opt/tenstorrent # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:34.149556196Z","created_by":"COPY /usr/local/share/uv /usr/local/share/uv # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:43.088231427Z","created_by":"COPY /opt/tt-venv /opt/tt-venv # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:43.356958678Z","created_by":"COPY /opt/vllm /opt/vllm # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:45.45889498Z","created_by":"COPY --chown=tt:tt /opt/tt-metal/runtime /opt/tt-metal/runtime # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:46.119916721Z","created_by":"COPY --chown=tt:tt /opt/tt-metal/build_Release /opt/tt-metal/build_Release # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:46.767518511Z","created_by":"COPY --chown=tt:tt /opt/tt-metal/build /opt/tt-metal/build # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:49.081355953Z","created_by":"COPY --chown=tt:tt /opt/tt-metal/tt_metal /opt/tt-metal/tt_metal # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:50.7696459Z","created_by":"COPY --chown=tt:tt /opt/tt-metal/ttnn /opt/tt-metal/ttnn # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:51.030840397Z","created_by":"COPY --chown=tt:tt /opt/tt-metal/tools /opt/tt-metal/tools # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:51.077015309Z","created_by":"COPY --chown=tt:tt /opt/tt-metal/setup.py /opt/tt-metal/pyproject.toml /opt/tt-metal/ # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:51.144342066Z","created_by":"COPY --chown=tt:tt code/ /opt/tt-metal/ # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:51.687229927Z","created_by":"RUN |11 OMPI_DIR=/opt/openmpi-v5.0.7-ulfm EXTRA_MODELS_DIR= TT_MODEL_KIND=tt-dit-server MODEL_NAME=frnet-p150 MODEL_REPO=changh95/frnet-p150 MODEL_WEIGHTS=AutowareFoundation/lidar_frnet MODEL_ARCH=blackhole MODEL_PROFILES=ot128,qt128 MODEL_TT_METAL_SHA=44d66500520fda9f2c7060c0f6b41ec48f7ab37e MODEL_TT_METAL_DESCRIBE=v0.80.0-dev20261006-78-g44d6650052-dirty MODEL_PLUGIN_SHA= /bin/sh -c find /opt/tt-metal \\( -type f -o -type d \\) \\( ! -perm -o+r -o \\( -type d ! -perm -o+x \\) \\) -exec chmod a+rX {} + # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:51.716542025Z","created_by":"COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"COPY --chmod=0755 serve-default.sh /usr/local/bin/serve-default.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV VENV=/opt/tt-venv","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV VIRTUAL_ENV=/opt/tt-venv","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV PATH=/opt/tt-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV TT_METAL_RUNTIME_ROOT=/opt/tt-metal","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV TT_METAL_HOME=/opt/tt-metal","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV PYTHONPATH=/opt/tt-metal","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV LD_LIBRARY_PATH=/opt/tt-metal/build/lib:/opt/openmpi-v5.0.7-ulfm/lib","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV EXTRA_MODELS_DIR=","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ARG TT_VLLM_BUILTIN_MODELS=","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV TT_VLLM_BUILTIN_MODELS=","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV TT_MODEL_KIND=tt-dit-server","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV HF_HOME=/hf","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV TT_METAL_CACHE=/cache","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV HOME=/home/tt","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV USER=tt","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"ENV LOGNAME=tt","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.743793517Z","created_by":"USER tt","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:12:51.769957112Z","created_by":"WORKDIR /home/tt/work","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:12:51.792174225Z","created_by":"COPY --chmod=0755 verify.sh /ctx/verify.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:13:14.259552051Z","created_by":"RUN |12 OMPI_DIR=/opt/openmpi-v5.0.7-ulfm EXTRA_MODELS_DIR= TT_MODEL_KIND=tt-dit-server MODEL_NAME=frnet-p150 MODEL_REPO=changh95/frnet-p150 MODEL_WEIGHTS=AutowareFoundation/lidar_frnet MODEL_ARCH=blackhole MODEL_PROFILES=ot128,qt128 MODEL_TT_METAL_SHA=44d66500520fda9f2c7060c0f6b41ec48f7ab37e MODEL_TT_METAL_DESCRIBE=v0.80.0-dev20261006-78-g44d6650052-dirty MODEL_PLUGIN_SHA= TT_VLLM_BUILTIN_MODELS= /bin/sh -c bash /ctx/verify.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:13:14.259552051Z","created_by":"USER root","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:13:14.493362845Z","created_by":"RUN |12 OMPI_DIR=/opt/openmpi-v5.0.7-ulfm EXTRA_MODELS_DIR= TT_MODEL_KIND=tt-dit-server MODEL_NAME=frnet-p150 MODEL_REPO=changh95/frnet-p150 MODEL_WEIGHTS=AutowareFoundation/lidar_frnet MODEL_ARCH=blackhole MODEL_PROFILES=ot128,qt128 MODEL_TT_METAL_SHA=44d66500520fda9f2c7060c0f6b41ec48f7ab37e MODEL_TT_METAL_DESCRIBE=v0.80.0-dev20261006-78-g44d6650052-dirty MODEL_PLUGIN_SHA= TT_VLLM_BUILTIN_MODELS= /bin/sh -c chmod -R a+rwX /home/tt # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-09T17:13:14.493362845Z","created_by":"USER tt","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:13:14.493362845Z","created_by":"LABEL org.tenstorrent.tt-model=frnet-p150 org.tenstorrent.tt-model.repo=changh95/frnet-p150 org.tenstorrent.tt-model.weights=AutowareFoundation/lidar_frnet org.tenstorrent.tt-model.arch=blackhole org.tenstorrent.tt-model.kind=tt-dit-server org.tenstorrent.tt-model.profiles=ot128,qt128 org.opencontainers.image.revision=44d66500520fda9f2c7060c0f6b41ec48f7ab37e org.tenstorrent.tt-model.tt-metal=v0.80.0-dev20261006-78-g44d6650052-dirty org.tenstorrent.tt-model.plugin=","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:13:14.493362845Z","created_by":"ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-09T17:13:14.493362845Z","created_by":"CMD [\"/usr/local/bin/serve-default.sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:cbaf391e670933f026c05c7dedec3faf8f194b6c2d83b0d5694ad68323c8077c","sha256:5b40ed82fb12cefe27dd9c06e0ed6c495b7968b8fa0c6bc1fbead4700ef13bff","sha256:f74c0336d1b19b92efa4f5f61ac9f0f32e8693ee525f699cec1d770d4e680342","sha256:715a8f2d2b39aefb4641d0d3371713da4d7be9b24ede7dc0652b41e851006962","sha256:d5a0004f2478c5285cd691a174ece4833625093fd6542b45dc4d587874631091","sha256:907e631e5140cb0c78122cbec2b076e88e47c42427c3885f1354be5b2f6229ac","sha256:167ecbfa9d8ebc5e83685757e8467ca990d3ecc77f4db21e62c2818b86454ca1","sha256:89f04446d520f76ef51d71f05b94347f2e081846c7e94fe5c9962778a87152d1","sha256:aa1a830f2a3b04c00294e7976390bd525461dc6847b7385f140b66ab77edf96e","sha256:e9ea785a73a0960ede3252dce7373ad64e6f6537f7a5ca08264c4fb2a2be41af","sha256:e3bc05441491d33addd7665d9d33abdd73b7d7c10895f9a4fa3a7db124c471c8","sha256:1010dbf465eb92904ba2f3390d8a842c4b3171ca5f709a0fe3e9be03118dd2ac","sha256:d110aa5f5b40b588663a249bd33a399a92e97ed15b443afcbc7135b32bafe2d6","sha256:e00f7a02785570a84b63e6fd5c784bad981ff25f7b879c3a5a584259b3d619bd","sha256:430f04735c61b824210887a8db2983ae22290525b21af36cb42233f0b17d56e5","sha256:82aeda36184e8fb981b7215c454f18186da79881fb89f57a32927f5192c11bc0","sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef","sha256:5f4a63df14fe2d9b700fed2f0ce8347b528c438360f25064aa2882b7b5753ec3","sha256:1fa549d215b058405672a74c017dc1e040ca4067a6ddaef250fc9185e720207c","sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef","sha256:089dce5b987cd5522600f98fcdf2e9670b6f4b1b79cc094bb4ea0d91b315f56d","sha256:0d4dda5dbbe19a65a3c73411f94058adc52b787bab1af5d2b8d8206b927c1f21","sha256:17e70998455a530ffe9d058af126b42e8dc00c547d8ef084d802c4a626f735bb"]}} \ No newline at end of file diff --git a/image/blobs/sha256/60918f793df50b25c4d69e6063db590866f48304ceb84738f3588a4ea3af1d39 b/image/blobs/sha256/60918f793df50b25c4d69e6063db590866f48304ceb84738f3588a4ea3af1d39 new file mode 100644 index 0000000000000000000000000000000000000000..e95634848673a616bc5b58c026cadeb73f879800 --- /dev/null +++ b/image/blobs/sha256/60918f793df50b25c4d69e6063db590866f48304ceb84738f3588a4ea3af1d39 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60918f793df50b25c4d69e6063db590866f48304ceb84738f3588a4ea3af1d39 +size 69483773 diff --git a/image/blobs/sha256/7f1d8564c41b1dd6cd5de2b6df1f0b4893e4fc08bd273f87e970eaf18a37540d b/image/blobs/sha256/7f1d8564c41b1dd6cd5de2b6df1f0b4893e4fc08bd273f87e970eaf18a37540d new file mode 100644 index 0000000000000000000000000000000000000000..dca3b20d1887fb02345209c2c21021f32105bb73 Binary files /dev/null and b/image/blobs/sha256/7f1d8564c41b1dd6cd5de2b6df1f0b4893e4fc08bd273f87e970eaf18a37540d differ diff --git a/image/blobs/sha256/7f8e824c081d26d72fc21097753f339930fd6fa33ee4f6e4f79ebdcfae6bb4a9 b/image/blobs/sha256/7f8e824c081d26d72fc21097753f339930fd6fa33ee4f6e4f79ebdcfae6bb4a9 new file mode 100644 index 0000000000000000000000000000000000000000..a4fe1b726645fcc19690ed597155c7b66431c5c9 --- /dev/null +++ b/image/blobs/sha256/7f8e824c081d26d72fc21097753f339930fd6fa33ee4f6e4f79ebdcfae6bb4a9 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f8e824c081d26d72fc21097753f339930fd6fa33ee4f6e4f79ebdcfae6bb4a9 +size 41058907 diff --git a/image/blobs/sha256/85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c b/image/blobs/sha256/85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c new file mode 100644 index 0000000000000000000000000000000000000000..ba4e5a9a1fbf255bdae3afb091d6ab8ac096ba49 --- /dev/null +++ b/image/blobs/sha256/85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c @@ -0,0 +1,28 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:599271c63656161c5bc6995288693bf9a634b29ae9765882fcb5d5a6bd501fde", + "size": 4685, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:2b82228add13960b6b983a2af3eb786615be8d6ce92943c019be7879f6e37314", + "size": 837, + "annotations": { + "vnd.docker.reference.digest": "sha256:599271c63656161c5bc6995288693bf9a634b29ae9765882fcb5d5a6bd501fde", + "vnd.docker.reference.type": "attestation-manifest" + }, + "platform": { + "architecture": "unknown", + "os": "unknown" + } + } + ] +} \ No newline at end of file diff --git a/image/blobs/sha256/85d518dbcb97d42216e3d5c61a4a34c68eb34a0b875174f0c3f4203187aa84f2 b/image/blobs/sha256/85d518dbcb97d42216e3d5c61a4a34c68eb34a0b875174f0c3f4203187aa84f2 new file mode 100644 index 0000000000000000000000000000000000000000..cd605c0782511be165b7b1b93099e945f71e3cc3 Binary files /dev/null and b/image/blobs/sha256/85d518dbcb97d42216e3d5c61a4a34c68eb34a0b875174f0c3f4203187aa84f2 differ diff --git a/image/blobs/sha256/8a364a911fa5ef61e168db9466a9c1916109d07dd609a66102a077600a7928ce b/image/blobs/sha256/8a364a911fa5ef61e168db9466a9c1916109d07dd609a66102a077600a7928ce new file mode 100644 index 0000000000000000000000000000000000000000..e2837e7ea7d3aed5045ba0d2ea41e59e75b0822f --- /dev/null +++ b/image/blobs/sha256/8a364a911fa5ef61e168db9466a9c1916109d07dd609a66102a077600a7928ce @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a364a911fa5ef61e168db9466a9c1916109d07dd609a66102a077600a7928ce +size 13066393 diff --git a/image/blobs/sha256/955c696fdd4076cc41ef9b404f0f248af208a7058c33f685360e8ad5a8a7e96f b/image/blobs/sha256/955c696fdd4076cc41ef9b404f0f248af208a7058c33f685360e8ad5a8a7e96f new file mode 100644 index 0000000000000000000000000000000000000000..adca82001e5175cb04ee296fa30f732fd153ccc6 --- /dev/null +++ b/image/blobs/sha256/955c696fdd4076cc41ef9b404f0f248af208a7058c33f685360e8ad5a8a7e96f @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:955c696fdd4076cc41ef9b404f0f248af208a7058c33f685360e8ad5a8a7e96f +size 2114153 diff --git a/image/blobs/sha256/98c4455a98982b35380ec62f90d5cf0edff02eeb4b3bb18595d871fdeb0c0c67 b/image/blobs/sha256/98c4455a98982b35380ec62f90d5cf0edff02eeb4b3bb18595d871fdeb0c0c67 new file mode 100644 index 0000000000000000000000000000000000000000..e6398658b5944b13a89f9749941a4db58c6f9d46 --- /dev/null +++ b/image/blobs/sha256/98c4455a98982b35380ec62f90d5cf0edff02eeb4b3bb18595d871fdeb0c0c67 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98c4455a98982b35380ec62f90d5cf0edff02eeb4b3bb18595d871fdeb0c0c67 +size 29751627 diff --git a/image/blobs/sha256/9e742f886b889d3b324cb9e64252ac7b15c41dc59b900afea699edefda392ddc b/image/blobs/sha256/9e742f886b889d3b324cb9e64252ac7b15c41dc59b900afea699edefda392ddc new file mode 100644 index 0000000000000000000000000000000000000000..e3af54744928a0058ad9b877aa3891d04034da2c --- /dev/null +++ b/image/blobs/sha256/9e742f886b889d3b324cb9e64252ac7b15c41dc59b900afea699edefda392ddc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e742f886b889d3b324cb9e64252ac7b15c41dc59b900afea699edefda392ddc +size 139083841 diff --git a/image/blobs/sha256/ac4a0c0abcd00417126e1fdeb179cd923c8bd292a1c7a8b3091cdbfd79cf1a59 b/image/blobs/sha256/ac4a0c0abcd00417126e1fdeb179cd923c8bd292a1c7a8b3091cdbfd79cf1a59 new file mode 100644 index 0000000000000000000000000000000000000000..7e21ab396ce95db5b51246d793cb6c617512e682 --- /dev/null +++ b/image/blobs/sha256/ac4a0c0abcd00417126e1fdeb179cd923c8bd292a1c7a8b3091cdbfd79cf1a59 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac4a0c0abcd00417126e1fdeb179cd923c8bd292a1c7a8b3091cdbfd79cf1a59 +size 1949608 diff --git a/image/blobs/sha256/bffe9990635d2390c86310d6135a1f2fb109a288c2678108e6981ee0997f8d71 b/image/blobs/sha256/bffe9990635d2390c86310d6135a1f2fb109a288c2678108e6981ee0997f8d71 new file mode 100644 index 0000000000000000000000000000000000000000..700497554c6b1d169305496eac5779401e4a7e17 --- /dev/null +++ b/image/blobs/sha256/bffe9990635d2390c86310d6135a1f2fb109a288c2678108e6981ee0997f8d71 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bffe9990635d2390c86310d6135a1f2fb109a288c2678108e6981ee0997f8d71 +size 288193825 diff --git a/image/blobs/sha256/c241aa41a5566f12cb0d8c8d32dc73b0580c202137bdd5e6dd934fa7c51a13a4 b/image/blobs/sha256/c241aa41a5566f12cb0d8c8d32dc73b0580c202137bdd5e6dd934fa7c51a13a4 new file mode 100644 index 0000000000000000000000000000000000000000..aae7625896153d45b9b669f59aed62376132c60f --- /dev/null +++ b/image/blobs/sha256/c241aa41a5566f12cb0d8c8d32dc73b0580c202137bdd5e6dd934fa7c51a13a4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c241aa41a5566f12cb0d8c8d32dc73b0580c202137bdd5e6dd934fa7c51a13a4 +size 9565631 diff --git a/image/blobs/sha256/d325fca6b89f9da7c9aec617b11eeae4e04520f0fb771d27e965e27ac7b660fb b/image/blobs/sha256/d325fca6b89f9da7c9aec617b11eeae4e04520f0fb771d27e965e27ac7b660fb new file mode 100644 index 0000000000000000000000000000000000000000..07426c324879f8a691a2668f8772f9dac5066893 --- /dev/null +++ b/image/blobs/sha256/d325fca6b89f9da7c9aec617b11eeae4e04520f0fb771d27e965e27ac7b660fb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d325fca6b89f9da7c9aec617b11eeae4e04520f0fb771d27e965e27ac7b660fb +size 66025113 diff --git a/image/blobs/sha256/de380e733b2bb5a318a186b76aaec434aa7c9780aea98c97a570a30cbcfb1339 b/image/blobs/sha256/de380e733b2bb5a318a186b76aaec434aa7c9780aea98c97a570a30cbcfb1339 new file mode 100644 index 0000000000000000000000000000000000000000..df1180d67449b13c718646051ee95bfdeb01405d Binary files /dev/null and b/image/blobs/sha256/de380e733b2bb5a318a186b76aaec434aa7c9780aea98c97a570a30cbcfb1339 differ diff --git a/image/blobs/sha256/ed6ef5b098fe3f3b6ceae619ba841b756c5ce8ee97f2333b1b36140f7fe1d0c4 b/image/blobs/sha256/ed6ef5b098fe3f3b6ceae619ba841b756c5ce8ee97f2333b1b36140f7fe1d0c4 new file mode 100644 index 0000000000000000000000000000000000000000..f615802cf536b9f68e723a3da3abf8619675d782 Binary files /dev/null and b/image/blobs/sha256/ed6ef5b098fe3f3b6ceae619ba841b756c5ce8ee97f2333b1b36140f7fe1d0c4 differ diff --git a/image/index.json b/image/index.json new file mode 100644 index 0000000000000000000000000000000000000000..e8b92a218fd1574f6974758f737abda9b0750c23 --- /dev/null +++ b/image/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c","size":856,"annotations":{"io.containerd.image.name":"docker.io/tt-model/frnet-p150:85cbf6cc72b9","org.opencontainers.image.ref.name":"85cbf6cc72b9"}}]} \ No newline at end of file diff --git a/image/manifest.json b/image/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..d37757b8fa5878cf96a23631bb153bfd60ba2fb8 --- /dev/null +++ b/image/manifest.json @@ -0,0 +1 @@ +[{"Config":"blobs/sha256/59e41087cc222b19fc1decaa4e85803ca1f9e1577c2d7697976f673cbc5d4c00","RepoTags":["tt-model/frnet-p150:85cbf6cc72b9"],"Layers":["blobs/sha256/98c4455a98982b35380ec62f90d5cf0edff02eeb4b3bb18595d871fdeb0c0c67","blobs/sha256/60918f793df50b25c4d69e6063db590866f48304ceb84738f3588a4ea3af1d39","blobs/sha256/85d518dbcb97d42216e3d5c61a4a34c68eb34a0b875174f0c3f4203187aa84f2","blobs/sha256/c241aa41a5566f12cb0d8c8d32dc73b0580c202137bdd5e6dd934fa7c51a13a4","blobs/sha256/4d1d911054857424c0bbb8ffeac532a517210d4ce9dd8b458c995a934dc16c95","blobs/sha256/d325fca6b89f9da7c9aec617b11eeae4e04520f0fb771d27e965e27ac7b660fb","blobs/sha256/bffe9990635d2390c86310d6135a1f2fb109a288c2678108e6981ee0997f8d71","blobs/sha256/ed6ef5b098fe3f3b6ceae619ba841b756c5ce8ee97f2333b1b36140f7fe1d0c4","blobs/sha256/9e742f886b889d3b324cb9e64252ac7b15c41dc59b900afea699edefda392ddc","blobs/sha256/7f8e824c081d26d72fc21097753f339930fd6fa33ee4f6e4f79ebdcfae6bb4a9","blobs/sha256/263c3393dbadf7dc0379d0fc3f574de2e1bf1f288d4cf6d258c862eb309fed88","blobs/sha256/2fcc025d0fe89554778cf89bcb74f1084552925ef59ab8520a3e20034f91cfe7","blobs/sha256/8a364a911fa5ef61e168db9466a9c1916109d07dd609a66102a077600a7928ce","blobs/sha256/ac4a0c0abcd00417126e1fdeb179cd923c8bd292a1c7a8b3091cdbfd79cf1a59","blobs/sha256/142bd320bcbb0cdaf3efc2dc0326f53982177f5c6f35e15e751b0cc34cf5c5eb","blobs/sha256/955c696fdd4076cc41ef9b404f0f248af208a7058c33f685360e8ad5a8a7e96f","blobs/sha256/4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1","blobs/sha256/7f1d8564c41b1dd6cd5de2b6df1f0b4893e4fc08bd273f87e970eaf18a37540d","blobs/sha256/de380e733b2bb5a318a186b76aaec434aa7c9780aea98c97a570a30cbcfb1339","blobs/sha256/4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1","blobs/sha256/48aa862fa10a5598113fa2d7bdef781c73416bc5693d271c4d6076ff9a751342","blobs/sha256/2d8b1b67a1bfa51712efa01fd56489f97766c9d4439440d132b211edfefea304","blobs/sha256/284bfa7f0620b820de681db358912a4a9007e5c469e301488d1d3d01cb2d43ca"]}] \ No newline at end of file diff --git a/image/oci-layout b/image/oci-layout new file mode 100644 index 0000000000000000000000000000000000000000..1343d370fa7b18a594705346b415d647f611a1d1 --- /dev/null +++ b/image/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file diff --git a/media/ATTRIBUTION.md b/media/ATTRIBUTION.md new file mode 100644 index 0000000000000000000000000000000000000000..1cd8dcb320072a34689664d265880a5a79804279 --- /dev/null +++ b/media/ATTRIBUTION.md @@ -0,0 +1,61 @@ +# Media attribution: frnet-p150 demo renders + +Every render shows the **p150 output**: the port running on one Tenstorrent Blackhole p150 (tt-nn; ETH dispatch, 1 +command queue, 12x10 grid; the serve.env precision knobs), called through the Python API +(`FRNet.from_pretrained(variant=...)`, then `model(points, calibration=...)`), which returns exactly what +`POST /predict` returns: Autoware's per-point `class_id` after Autoware's pre-processing (ego crop box, projection, +interpolation) and post-processing (first-max argmax). Only the raw points the node scores are drawn (Autoware's +interpolated points are not in the dataset views). The caption inside each image gives the frame's agreement with the +fp32 CPU reference of the same Autoware network (the deployed ONNX through ONNX Runtime with the port's bit-exact +Autoware pre- and post-processing; `research/frnet/public_data/runs`). The `*_tt_vs_cpu*` views show the p150 classes, +the reference's classes and the points whose class differs. The dataset views compare the p150 classes with the +dataset's own labels mapped to the FRNet classes (dark grey: no T4 counterpart); the weights were not trained on +these datasets or sensors. + +Colours: the Autoware FRNet palette of `ml_package_frnet_ot128.param.yaml` (27 classes). Renderers: +`code/scripts/make_demo.py` (`tt_frnet.viz` for the BEV comparisons) and, for the camera views, the research renderer +of the public-data set (`research/frnet/public_data/scripts/frnet_public_render.py`: same frames, panels and camera +projections as the CPU-reference renders), workspace code that is not shipped, because the datasets are not. + +| file | source | license | +|---|---|---| +| `frnet_synthetic_ot128_tt_vs_cpu.png`, `frnet_synthetic_qt128_tt_vs_cpu.png` | the shipped samples `code/tt_frnet/samples/synthetic_.npz`, generated by `code/scripts/make_sample.py` (a ray cast against boxes; no dataset) | Apache-2.0 | +| `frnet_ot128_pandaset_019_40_tt.png` | PandaSet seq 019 (San Francisco, Embarcadero), frame 40: Pandar64 sweep, `front_camera/40.jpg`, semseg labels | CC BY 4.0 + PandaSet Dataset Terms | +| `frnet_ot128_pandaset_029_40_tt.png` | PandaSet seq 029 (San Francisco, residential hill and intersection), frame 40: same members | CC BY 4.0 + PandaSet Dataset Terms | +| `frnet_ot128_pandaset_019_40_tt_vs_cpu.png` | PandaSet seq 019, frame 40: Pandar64 sweep (bird's-eye view only) | CC BY 4.0 + PandaSet Dataset Terms | +| `frnet_ot128_pandaset_019_bev_tt.gif` | PandaSet seq 019, Pandar64 sweeps 00-75, every 5th frame (bird's-eye view only) | CC BY 4.0 + PandaSet Dataset Terms | +| `frnet_ot128_nuscenes_scene-0103_20_tt_NC.png` | nuScenes v1.0-mini, mini_val scene-0103 (Boston Seaport), key-frame 20: LIDAR_TOP, CAM_FRONT, nuScenes-lidarseg labels | **CC BY-NC-SA 4.0** + nuScenes Terms of Use (non-commercial; the render carries the label) | +| `frnet_ot128_nuscenes_scene-0916_40_tt_vs_cpu_NC.png` | nuScenes v1.0-mini, mini_val scene-0916 (Singapore), key-frame 40: LIDAR_TOP (bird's-eye view only) | **CC BY-NC-SA 4.0** + nuScenes Terms of Use (non-commercial; the render carries the label) | + +What was changed in every dataset render: the PandaSet Pandar64 sweep re-expressed in an estimated sensor frame (its +per-point motion compensation undone), the nuScenes LIDAR_TOP sweep rotated to x forward; bird's-eye views rendered; +camera images downscaled to at most 960 px wide and dimmed; the points projected into the camera and coloured by +class; caption and attribution bars added. Nothing is cropped or zoomed in on people or license plates. + +## PandaSet + +> Contains data from PandaSet (Scale AI and Hesai), https://pandaset.org, licensed under CC BY 4.0 and the PandaSet +> Dataset Terms. Changes: the Pandar64 sweep was re-expressed in an estimated sensor frame, the front-camera image was +> downscaled and dimmed, and LiDAR points and labels were overlaid; bird's-eye views rendered. Scale AI and Hesai do +> not endorse this work. Cite: P. Xiao et al., *PandaSet: Advanced Sensor Suite Dataset for Autonomous Driving*, +> ITSC 2021. + +The Dataset Terms forbid using the data to identify people and using Scale AI's or Hesai's names or logos beyond the +attribution. No PandaSet data (points, images) is in this repository; only these renders are. A PandaSet sample is +prepared but not shipped until its redistribution is approved. + +## nuScenes (non-commercial) + +> Rendered from the nuScenes dataset, © Motional AD Inc., CC BY-NC-SA 4.0 and the nuScenes Terms of Use +> (https://www.nuscenes.org/terms-of-use). Non-commercial use only; adaptations under the same license. Motional does +> not endorse this work. Cite: H. Caesar et al., *nuScenes: A Multimodal Dataset for Autonomous Driving*, CVPR 2020; +> W. K. Fong et al., *Panoptic nuScenes: A Large-Scale Benchmark for LiDAR Panoptic Segmentation and Tracking*, +> RA-L 2022 (lidarseg labels). + +The two `_NC` renders inherit CC BY-NC-SA 4.0: they are labelled "non-commercial, CC BY-NC-SA 4.0" wherever they +appear (the images carry the label). No nuScenes data (points, images, tensors) is in this repository. + +## Synthetic samples + +`code/tt_frnet/samples/synthetic_.npz` and the two `frnet_synthetic_*` renders are generated by this +repository (`code/scripts/make_sample.py`, deterministic, seed 0): Apache-2.0. diff --git a/media/README.md b/media/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e8707fa21eeb478d9acc32ccbe72e755e4a4ef13 --- /dev/null +++ b/media/README.md @@ -0,0 +1,8 @@ +# media/ + +Images the model card embeds (`README.md` "Demo"): the p150 output (the port on one Tenstorrent Blackhole p150) on +the shipped synthetic samples and on public-dataset frames, rendered by `code/scripts/make_demo.py` (`run` on the +device, `render` on the CPU). Sources, licences and attributions of every file: [`ATTRIBUTION.md`](ATTRIBUTION.md); +files ending in `_NC` are non-commercial (CC BY-NC-SA 4.0) and labelled so in the image and on the card. This +directory is uploaded with the top-level docs and survives `tt-model push` (only `code/` and `image/` are replaced by +a push). diff --git a/media/frnet_ot128_nuscenes_scene-0103_20_tt_NC.png b/media/frnet_ot128_nuscenes_scene-0103_20_tt_NC.png new file mode 100644 index 0000000000000000000000000000000000000000..541202b1d5db795ab66c70992f9be09e50e5c62a --- /dev/null +++ b/media/frnet_ot128_nuscenes_scene-0103_20_tt_NC.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9604240896abf6886064e58410a70f96fdc49155021ad2b9dbdb8b4fe3d92130 +size 559945 diff --git a/media/frnet_ot128_nuscenes_scene-0916_40_tt_vs_cpu_NC.png b/media/frnet_ot128_nuscenes_scene-0916_40_tt_vs_cpu_NC.png new file mode 100644 index 0000000000000000000000000000000000000000..0e3c8d1fdb19395f165f402730dfb65867004837 Binary files /dev/null and b/media/frnet_ot128_nuscenes_scene-0916_40_tt_vs_cpu_NC.png differ diff --git a/media/frnet_ot128_pandaset_019_40_tt.png b/media/frnet_ot128_pandaset_019_40_tt.png new file mode 100644 index 0000000000000000000000000000000000000000..0411925eebcd31501550a7fbd1ff75aadc2afd9d --- /dev/null +++ b/media/frnet_ot128_pandaset_019_40_tt.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcfa5163a9e51fb138332e7a2a32af455cbda249e688936c68144defb5298fc3 +size 618848 diff --git a/media/frnet_ot128_pandaset_019_40_tt_vs_cpu.png b/media/frnet_ot128_pandaset_019_40_tt_vs_cpu.png new file mode 100644 index 0000000000000000000000000000000000000000..1babb8b78ed2387d9e49f86979a80ec3efc7614b --- /dev/null +++ b/media/frnet_ot128_pandaset_019_40_tt_vs_cpu.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d63d07071974a8564e4de032ea9b5e8cfb3137090d9f713537d581d00145f127 +size 107529 diff --git a/media/frnet_ot128_pandaset_019_bev_tt.gif b/media/frnet_ot128_pandaset_019_bev_tt.gif new file mode 100644 index 0000000000000000000000000000000000000000..d3eb1b016d63ccc3dd0c501700b86dd358aea720 --- /dev/null +++ b/media/frnet_ot128_pandaset_019_bev_tt.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81b9d32c37c2e4e18d8cc9db32d815e79350795b1e164f2ead79a7936316fe79 +size 533102 diff --git a/media/frnet_ot128_pandaset_029_40_tt.png b/media/frnet_ot128_pandaset_029_40_tt.png new file mode 100644 index 0000000000000000000000000000000000000000..0f5ca89a33b94801f38eebc6f4aead31fd22d873 --- /dev/null +++ b/media/frnet_ot128_pandaset_029_40_tt.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45521ec343792133ab5ad1ec69f7edc78e67f43e0e698218d39e929bf33b2c1d +size 569418 diff --git a/media/frnet_synthetic_ot128_tt_vs_cpu.png b/media/frnet_synthetic_ot128_tt_vs_cpu.png new file mode 100644 index 0000000000000000000000000000000000000000..b45f837f980abaa21ca30ad3a1c15aba77c54113 Binary files /dev/null and b/media/frnet_synthetic_ot128_tt_vs_cpu.png differ diff --git a/media/frnet_synthetic_qt128_tt_vs_cpu.png b/media/frnet_synthetic_qt128_tt_vs_cpu.png new file mode 100644 index 0000000000000000000000000000000000000000..2f64c7d45761be5b2c734c70d0c59e12388d3986 Binary files /dev/null and b/media/frnet_synthetic_qt128_tt_vs_cpu.png differ diff --git a/patches/tt-metal-eth-dispatch.patch b/patches/tt-metal-eth-dispatch.patch new file mode 100644 index 0000000000000000000000000000000000000000..61fd39d1858b3e345c201c2a498423efd79dd753 --- /dev/null +++ b/patches/tt-metal-eth-dispatch.patch @@ -0,0 +1,84 @@ +diff --git a/tt_metal/hw/toolchain/main.ld b/tt_metal/hw/toolchain/main.ld +index 8cee67693d8..03732862192 100644 +--- a/tt_metal/hw/toolchain/main.ld ++++ b/tt_metal/hw/toolchain/main.ld +@@ -306,7 +306,8 @@ SECTIONS + LONG(TEXT_SIZE + #if defined(TYPE_KERNEL) && !(defined(COMPILE_FOR_NCRISC) && defined(ARCH_WORMHOLE)) \ + && !defined(COMPILE_FOR_AERISC) && !defined(COMPILE_FOR_SUBORDINATE_AERISC) \ +- && !defined(COMPILE_FOR_MAIN_AERISC) ++ && !defined(COMPILE_FOR_MAIN_AERISC) \ ++ && !(defined(ARCH_BLACKHOLE) && (defined(COMPILE_FOR_IERISC) || defined(COMPILE_FOR_SUBORDINATE_IERISC))) + - (__fw_export_text_end - TEXT_START) + #endif + ) +diff --git a/tt_metal/impl/dispatch/dispatch_query_manager.cpp b/tt_metal/impl/dispatch/dispatch_query_manager.cpp +index 38c31376178..fef7bafe6cd 100644 +--- a/tt_metal/impl/dispatch/dispatch_query_manager.cpp ++++ b/tt_metal/impl/dispatch/dispatch_query_manager.cpp +@@ -148,6 +148,15 @@ void DispatchQueryManager::reset(DispatchCoreConfig& dispatch_core_config, uint8 + (num_hw_cqs == 1 or dispatch_core_config_.get_dispatch_core_type() == DispatchCoreType::WORKER); + distributed_dispatcher_ = + (num_hw_cqs == 1 and dispatch_core_config_.get_dispatch_core_type() == DispatchCoreType::ETH); ++ // Blackhole ETH dispatch with 1 CQ needs 3 idle ETH cores (prefetch, dispatch, dispatch_s). ++ // Galaxy BH chips only have 2 idle ETH cores (the rest are trained links), so fall back to ++ // running without dispatch_s (dispatch_d sends go signals), which needs only 2 cores. ++ if (arch == tt::ARCH::BLACKHOLE and num_hw_cqs == 1 and ++ dispatch_core_config_.get_dispatch_core_type() == DispatchCoreType::ETH and ++ populate_all_logical_dispatch_cores(env_, num_hw_cqs_, dispatch_core_config_).size() < 3) { ++ dispatch_s_enabled_ = false; ++ distributed_dispatcher_ = false; ++ } + } + + go_signal_noc_ = (dispatch_s_enabled_ and arch != tt::ARCH::QUASAR) ? NOC::NOC_1 : NOC::NOC_0; +diff --git a/tt_metal/impl/dispatch/topology.cpp b/tt_metal/impl/dispatch/topology.cpp +index 3dc6377b652..813787a2bf5 100644 +--- a/tt_metal/impl/dispatch/topology.cpp ++++ b/tt_metal/impl/dispatch/topology.cpp +@@ -130,6 +130,12 @@ static const std::vector single_chip_arch_1cq = { + {2, 0, 0, 0, DISPATCH_S, {0, x, x, x}, {1, x, x, x}, k_dispatcher_s_noc}, + }; + ++// 1 CQ without dispatch_s (used for Blackhole ETH dispatch when only 2 idle ETH cores exist) ++static const std::vector single_chip_arch_1cq_no_dispatch_s = { ++ {0, 0, 0, 0, PREFETCH_HD, {x, x, x, x}, {1, x, x, x}, k_prefetcher_noc}, ++ {1, 0, 0, 0, DISPATCH_HD, {0, x, x, x}, {x, x, x, x}, k_dispatcher_noc}, ++}; ++ + static const std::vector single_chip_arch_2cq = { + {0, 0, 0, 0, PREFETCH_HD, {x, x, x, x}, {2, x, x, x}, k_prefetcher_noc}, + {1, 0, 0, 1, PREFETCH_HD, {x, x, x, x}, {3, x, x, x}, k_prefetcher_noc}, +@@ -464,7 +470,13 @@ std::vector DispatchTopology::generate_nodes( + auto populate_single_device = [&]() { + const bool is_quasar = this->descriptor_.cluster().arch() == tt::ARCH::QUASAR; + if (num_hw_cqs == 1) { +- return is_quasar ? quasar_single_chip_1cq : single_chip_arch_1cq; ++ if (is_quasar) { ++ return quasar_single_chip_1cq; ++ } ++ if (!this->get_dispatch_query_manager_().dispatch_s_enabled()) { ++ return single_chip_arch_1cq_no_dispatch_s; ++ } ++ return single_chip_arch_1cq; + } + if (is_quasar) { + return quasar_single_chip_2cq; +diff --git a/tt_metal/llrt/core_descriptor.cpp b/tt_metal/llrt/core_descriptor.cpp +index 0ada4eff2f0..1a2358d6cf7 100644 +--- a/tt_metal/llrt/core_descriptor.cpp ++++ b/tt_metal/llrt/core_descriptor.cpp +@@ -290,6 +290,13 @@ const tt::core_descriptor_t& MetalEnvImpl::get_core_descriptor_config( + coord = RelativeCoreCoord({.x = core_node[0].as(), .y = core_node[1].as()}); + if (get_core_type_from_config(dispatch_core_config) == CoreType::ETH) { + auto logical_coord = get_core_coord_from_relative(coord, grid_size); ++ // Skip ETH dispatch entries that do not exist on this chip (harvested ETH cores, e.g. ++ // Blackhole Galaxy chips expose fewer than the 14 ETH cores listed in the YAML). ++ const size_t num_logical_eth_cores = ++ get_cluster().get_soc_desc(device_id).get_cores(CoreType::ETH, CoordSystem::LOGICAL).size(); ++ if (logical_coord.y >= num_logical_eth_cores) { ++ continue; ++ } + if (logical_active_eth_cores.contains(logical_coord)) { + continue; + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..a7c65942cd07d6b0ed05e781428fb0efd6074e0d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: Apache-2.0 +# Python package of frnet-p150, from the ROOT of the model repo: +# pip install -e . # the Python API +# pip install -e ".[server,test]" # + the HTTP server and the tests +# +# Install it into an environment that already has tt-metal's ttnn (and its torch 2.11.0+cpu): ttnn and torch are +# NOT declared, so pip never replaces the tt-metal build's own torch. The container image installs the same set +# through tt-model.yaml runtime.packages. +# +# This file lives at the repo root ON PURPOSE and must never move into code/: tt-model copies code/ over /opt/tt-metal +# right before `uv pip install -e /opt/tt-metal` in the image build, so a code/pyproject.toml would replace tt-metal's +# own (project ttnn) and break `import ttnn` in the image (research/PACKAGING_PILOT.md problem 1; +# research/packaging/scripts/check_bundle.py refuses it). It is not in tt-model.yaml extra_code: it reaches the Hub as +# an overlay file next to README.md (SERVING.md section 2), like the other top-level docs. +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "tt-frnet" +dynamic = ["version"] # tt_frnet.__version__, the version /openapi.json reports +description = "FRNet (Autoware lidar_frnet) on Tenstorrent Blackhole p150 (tt-nn), with a Python API and an HTTP server" +readme = "code/PYTHON.md" +license = "Apache-2.0" +requires-python = ">=3.10" +dependencies = [ + "numpy>=1.24.4,<2", # ttnn needs numpy 1.x + "pillow", + "pyyaml", # Autoware *.param.yaml + "huggingface_hub", # weights pointer: AutowareFoundation/lidar_frnet@a53a1c11b0b28d31fd50a03abef5db0e830a5b3c + "safetensors", + "onnx>=1.17,<2", # reads the ONNX initializers (data only) +] + +[project.optional-dependencies] +server = ["fastapi", "uvicorn", "pydantic>=2"] +test = ["pytest", "httpx"] +reference = ["onnxruntime"] # ONNX Runtime golden (research / tests only, not in the image) + +[project.urls] +"Model card" = "https://huggingface.co/changh95/frnet-p150" +"Weights" = "https://huggingface.co/AutowareFoundation/lidar_frnet" +"Autoware package" = "https://github.com/autowarefoundation/autoware_universe/tree/9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd/perception/autoware_lidar_frnet" + +[tool.setuptools.dynamic] +version = { attr = "tt_frnet.__version__" } + +[tool.setuptools.packages.find] +where = ["code"] # import root (code/conftest.py and code/scripts/ are not packaged) +include = ["tt_frnet*"] # with the vendored tt_frnet.ttaw (common/tools/vendor.py; never edit it) +exclude = ["tt_frnet.tests*"] + +[tool.setuptools.package-data] +"tt_frnet" = ["samples/*", "calib/*.json"] +"tt_frnet.tt" = ["kernels/*.cpp", "kernels/*.hpp", "kernels/*.h"] +"tt_frnet.ttaw" = ["API.md", "VENDORED.json"] +"tt_frnet.ttaw.ops" = ["kernels/*.cpp", "kernels/*.hpp", "kernels/*.h"] + +[tool.pytest.ini_options] +markers = ["device: needs a Tenstorrent chip (skipped on host-only runs)"] diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000000000000000000000000000000000000..7bbc9545180e97f96733fea535131952f67d6890 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,80 @@ +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +certifi==2026.7.22 +cfgv==3.5.0 +charset-normalizer==3.5.2 +click==8.5.0 +contourpy==1.3.3 +cycler==0.12.1 +distlib==0.4.3 +distro==1.9.0 +elastic-transport==9.4.2 +elasticsearch==9.5.1 +fastapi==0.143.0 +filelock==4.0.12 +fonttools==4.66.1 +fsspec==2026.9.0 +graphviz==0.21 +h11==0.16.0 +hf-xet==1.7.0 +httpcore2==2.13.1 +httpx2==2.13.1 +huggingface_hub==2.2.0 +identify==2.6.20 +idna==3.20 +Jinja2==3.1.6 +kiwisolver==1.5.1 +linkify-it-py==2.2.0 +loguru==0.7.3 +markdown-it-py==4.2.0 +MarkupSafe==3.0.4 +matplotlib==3.11.2 +mdit-py-plugins==0.6.1 +mdurl==0.1.2 +ml_dtypes==0.5.4 +mpmath==1.3.0 +networkx==3.7 +nodeenv==1.11.0 +numpy==1.26.4 +onnx==1.23.2 +opentelemetry-api==1.45.1 +packaging==26.3 +pandas==3.0.6 +pillow==12.3.0 +platformdirs==4.12.4 +pre_commit==4.6.2 +protobuf==7.36.2 +psutil==7.2.2 +pydantic==2.14.0 +pydantic_core==2.50.0 +Pygments==2.21.0 +pyluwen==0.10.0 +pyparsing==3.3.3 +python-dateutil==2.9.0.post0 +python-discovery==1.6.1 +PyYAML==6.0.3 +requests==2.34.2 +rich==15.0.0 +safetensors==0.8.0 +seaborn==0.13.2 +setuptools==80.10.2 +setuptools-scm==8.1.0 +six==1.17.0 +sniffio==1.3.1 +starlette==1.7.0 +sympy==1.14.0 +textual==8.2.8 +tomli==2.5.0 +torch==2.11.0+cpu +tqdm==4.70.1 +truststore==0.10.4 +tt-smi==6.7.0 +tt-tools-common==1.6.0 +tt-umd==0.9.12 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +urllib3==2.8.0 +uvicorn==0.54.0 +virtualenv==21.14.6 +wheel==0.48.0 diff --git a/tt-model.yaml b/tt-model.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bbd2a05a6f36909e678cce9cae03419589203620 --- /dev/null +++ b/tt-model.yaml @@ -0,0 +1,181 @@ +# SPDX-License-Identifier: Apache-2.0 +# tt-model-manager container manifest (schema 5.1): FRNet (Autoware lidar_frnet) on one Blackhole p150. +# Conventions: research/BUNDLE_CONVENTIONS.md (workspace) -- section 3 explains every field. +# +# Run EVERY tt-model command FROM THIS DIRECTORY: source.tt_metal and extra_code[].root resolve against the +# process CWD, not this file; --out must point OUTSIDE this directory (package deletes / first). +# python -c "from tt_kernel.container_manifest import load_container_manifest as L; L('tt-model.yaml', check_sources=True)" +# sg docker -c "tt-model package --container tt-model.yaml --out /home/ubuntu/experiments/tt-models/build" +# (serve + smoke + stop under bin/devrun, once per serve profile: code/scripts/container_smoke.sh, SERVING.md 2) +# HF_TOKEN= tt-model push /home/ubuntu/experiments/tt-models/build/frnet-p150 --publish +schema: "5.1" + +repo: "changh95/frnet-p150" +name: "frnet-p150" + +# A POINTER to the network Autoware deploys (ansible artifacts role: lidar_frnet @ v2.0), never baked into +# the image. Pinned to the COMMIT the tag points to -- HfApi().model_info(repo, revision="v2.0").sha, not +# list_repo_refs().tags[].target_commit (that is the annotated-tag object) -- because tags can move and `package` +# does not resolve weights refs. `serve` / `pull --with-weights` pre-download exactly these files at this sha; the +# launcher exports the sha as TT_MODEL_WEIGHTS_REVISION (and serve.env repeats it as TT_WEIGHTS_REVISION). +weights: + repo: "AutowareFoundation/lidar_frnet" + revision: "a53a1c11b0b28d31fd50a03abef5db0e830a5b3c" + allow_patterns: ["frnet_*.onnx", "ml_package_frnet_*.param.yaml", "deploy_metadata.yaml"] + +kind: tt-dit-server # the model's own FastAPI app under uvicorn (no vLLM); see the conventions doc +arch: blackhole + +source: + # tt-metal main 44d6650 (v0.80.0-dev20261006) WITH patches/tt-metal-eth-dispatch.patch applied: a dirty tree, + # packaged as-is (the image is built from it, so it carries ETH dispatch). torch 2.11.0 pin read from it. + tt_metal: "/home/ubuntu/experiments/tt-models/tt-metal" + # Schema minimum (>= 1 path relative to the tt-metal tree). The port imports nothing from tt-metal's models/. + code: + - models/common/lightweightmodule.py + # The port itself. code/ on the Hub becomes EXACTLY this set (+ the file above) at every push, and it is + # COPY'd over /opt/tt-metal/ in the image (PYTHONPATH=/opt/tt-metal) BEFORE tt-metal's C++ build and its + # `uv pip install -e /opt/tt-metal`. So never list a file that tt-metal's root also has and its build reads + # (pyproject.toml, setup.py, setup.cfg, MANIFEST.in, README.md, uv.toml, CMakeLists.txt, build_metal.sh, ...) or + # anything under its build trees (tt_metal/, ttnn/, cmake/, tools/, ...): check_bundle.py refuses them. The pip + # project is the repo-ROOT pyproject.toml (`pip install -e .`), published as an overlay file, never from here. + # conftest.py is deliberate: it replaces tt-metal's root conftest.py in the builder stage, which no build step + # reads, and the runtime stage never copies tt-metal's, so in the image it is the only root conftest. + extra_code: + - root: code + paths: + - "tt_frnet" + - scripts + - conftest.py + - PYTHON.md + ubuntu: "22.04" + python: "3.12" + +runtime: + app: "tt_frnet.server.app:app" + mesh_shape_env: TT_MESH_SHAPE + # On top of the kind defaults (fastapi, uvicorn, pydantic>=2, pillow) and the auto-pinned torch==2.11.0+cpu. + # numpy stays < 2 (ttnn). Add per model: scipy, opencv-python-headless>=4.9,<4.12, ... + packages: + - "numpy>=1.24.4,<2" + - huggingface_hub + - safetensors + - pyyaml + - "onnx>=1.17,<2" + # After the first green build: copy /frnet-p150/requirements.lock next to this file, check it, and + # uncomment (resolved relative to THIS file): + # lock: requirements.lock + +serve: + port: 20000 + hardware: p150 + mesh_device: P150 + env: + TT_WEIGHTS_REVISION: "a53a1c11b0b28d31fd50a03abef5db0e830a5b3c" # older tt-model exports no revision; the app also reads TT_MODEL_WEIGHTS_REVISION + TT_METAL_VISIBLE_DEVICES: "0" + # The weights repos are public and ungated: never send a token. serve mounts the host HF cache (which may hold a + # stored token file) at /hf, so tell huggingface_hub not to pick one up implicitly (PACKAGING_PILOT.md problem 10). + HF_HUB_DISABLE_IMPLICIT_TOKEN: "1" + # Pinned dispatch: ETH, the 12x10 grid every published number uses (D14). Profiles must not override it: + # check_bundle.py refuses any *_DISPATCH other than eth, and the container smoke fails unless /info reports + # dispatch=eth, grid=12x10 and these pins. + "FRNET_DISPATCH": "eth" + "FRNET_NUM_CQS": "1" + "FRNET_VARIANT": "ot128" + # Knob defaults of tt_frnet.tt.model.KNOBS (asserted by tests/test_bundle_host.py): the two-term bf16 point island + # (FRNET_POINT_TERMS=1, fp32 x fp32, misses the OT128 e2e gates on nuScenes frames: PORT_LOG.md "M1 device results") + "FRNET_POINT_TERMS": "2" + # fp32 image-branch maps (conv operands bf16): with bf16 maps every conv / HardSwish / add output is rounded and + # OT128 misses the e2e argmax gate on nuScenes 0916 kf40 (98.28 %; PORT_LOG.md "M3 precision follow-up") + "FRNET_MAP_DTYPE": "float32" + "FRNET_CONV_TERMS": "1" + +# OPTIONAL: load-time variants of ONE image as serve profiles (`tt-model serve --profile `), e.g. the +# CenterPoint base / tiny pair. Each profile deep-merges over serve: (hardware / mesh_device are inherited) and is +# smoke-tested on its own (`container_smoke.sh --profile `). +serve_profiles: + - name: ot128 + description: FRNet for the Hesai OT128 (Autoware's default sensor_model; 1024x128 frustum grid) + env: + "FRNET_VARIANT": ot128 + - name: qt128 + description: FRNet for the Hesai QT128 (256x128 frustum grid, +-52.6 deg FOV) + env: + "FRNET_VARIANT": qt128 +default_profile: ot128 + +# Build-time assertions run INSIDE the finished image as uid 1000, no device, no weights. +verify: + # the image must carry the ETH-dispatch patch (12x10 grid): fail the build otherwise (PLAN.md 6.2 step 1) + - "assert 'single_chip_arch_1cq_no_dispatch_s' in open('/opt/tt-metal/tt_metal/impl/dispatch/topology.cpp').read()" + - "from tt_frnet.ttaw.device import eth_dispatch_patch_present as p; assert p(), 'ttaw does not see the ETH patch'" + - "import tt_frnet.server.app as a; assert a.app" + - "from tt_frnet.server.app import parse_mesh_shape as p; assert p('1x1') == p('(1, 1)') == p('1,1') == (1, 1)" + - "import numpy; assert int(numpy.__version__.split('.')[0]) < 2, numpy.__version__" + - "import onnx, yaml, huggingface_hub, safetensors, PIL" + - "from tt_frnet.api import FRNet; assert FRNet.DEFAULT_REVISION == 'a53a1c11b0b28d31fd50a03abef5db0e830a5b3c'" + - "import ttnn; assert hasattr(ttnn, 'DispatchCoreConfig') and hasattr(ttnn, 'begin_trace_capture') and hasattr(ttnn, 'execute_trace') and hasattr(ttnn, 'release_trace')" + # the shipped samples (synthetic scans, code/scripts/make_sample.py), their calibration presets and stored CPU + # references: the container smoke posts the sample of the served profile and compares with its reference + - "from pathlib import Path; d = Path('/opt/tt-metal/tt_frnet'); assert all((d / 'samples' / f'synthetic_{v}.npz').is_file() and (d / 'samples' / f'synthetic_{v}.{v}.reference.json').is_file() and (d / 'calib' / f'synthetic_{v}.json').is_file() for v in ('ot128', 'qt128'))" + # the K1 segment_reduce kernel source (ttaw.ops.segment; JIT-compiled at run time) + - "from pathlib import Path; assert Path('/opt/tt-metal/tt_frnet/ttaw/ops/kernels/segment_reduce_dm.cpp').is_file()" + - "from tt_frnet.ttaw.ops import kernel_path; kernel_path('segment_reduce_dm.cpp')" + +card: + # Lede under the title of the GENERATED card (tt-model package). The published README.md is the hand-written + # card of this directory (overlaid before push); keep the facts here and there identical. + description: > + FRNet (Autoware lidar_frnet): the network Autoware deploys (autoware_lidar_frnet, weights AutowareFoundation/lidar_frnet + v2.0) running on one Tenstorrent Blackhole p150 via tt-nn: one LiDAR sweep in the sensor frame (x, y, z, raw 0-255 intensity) in, a class id and probability for every point (27 T4 classes; Autoware's interpolated points included) out. + architecture: "FRNet: frustum feature encoder (point MLP 8->64->128->256->256, per-cell max, 256->16) + frustum-range 2D CNN (stem, 4 residual stages with point-pixel fusion and attention, align-corners decoder) + per-point head (27-class softmax); one ONNX graph per sensor profile" + status: Community port (first release, baseline; optimization pending), validated against the fp32 CPU reference on public driving data + intended_use: > + LiDAR 3D semantic segmentation for Autoware-style sensor setups (one Hesai OT128 or QT128 sweep in the sensor frame) on one Blackhole p150, as a drop-in for the TensorRT node's network. + out_of_scope_use: > + Safety-critical driving decisions (this is not a certified Autoware component); multi-chip meshes; batch > 1; + inputs outside the Autoware sensor configuration the weights were trained for. + quickstart: | + The first start compiles the kernels and captures the traces (minutes on a cold cache, seconds later); the + server is ready when its log shows `Application startup complete`. Build a request from a file with + `python3 code/tt_frnet/server/client.py --points code/tt_frnet/samples/synthetic_ot128.npz --calib-preset synthetic_ot128 --out req.json` (your own cloud: `--calib my_calib.json`); the QT128 network is the `qt128` profile (`tt-model serve --profile qt128 `), then + `curl -s localhost:20000/predict -H 'Content-Type: application/json' -d @req.json`. + usage: | + `POST /predict` (JSON): `points` (base64 `.bin` / `.npy` / `.npz` / `.pcd`, sensor frame, fields x, y, z, intensity 0-255); `calibration` (`T_base_link_from_sensor` for the ego crop box, or `{"preset": name}`; optional `ego_crop_box`, `null` disables the box); optional `params` (`class_probability_threshold` 0.05, `filter_classes` "drivable_surface"), `output_format`. `GET /health`, `GET /info`, `GET /v1/models` (stub). + Full request / response contract: `SERVING.md` section 3. + performance: | + | Metric | Performance | + |---|---:| + | Class agreement with the fp32 CPU reference, shipped sample | OT128 99.55 % · QT128 99.62 % of the points | + | Class agreement, 113 public frames per profile (PandaSet 32, nuScenes mini_val 81) | OT128 99.66 % / 99.19 % pooled (worst frame 98.37 %) · QT128 99.29 % / 99.44 % (worst 98.10 %) | + | Frozen device gates (17 OT128 / 5 QT128 frames; PCC >= 0.998, agreement >= 98.5 %) | all pass; OT128 min agreement 98.907 % (nuScenes 0916 kf40, 0.41 pt margin) | + | Python `model()` call, shipped sample | OT128 292.6 ms · QT128 221.1 ms p50 | + | Served `/predict` `timing_ms.total`, shipped sample | OT128 310.4 ms · QT128 228.9 ms median | + | Device trace, one forward / back-to-back | OT128 210.02 / 209.95 ms · QT128 133.53 / 133.43 ms | + | `from_pretrained` load, empty / warm JIT cache | OT128 156.6 / 7.5 s · QT128 151.9 / 6.6 s | + + ETH dispatch, 1 CQ, 12x10 grid, batch 1, 2026-10-09; details in README.md "Demo & Performances". + limitations: | + - First release: baseline port, optimization pending (OPT_REPORT.md); the point-wise layers kept at two-term bf16 precision are 63 % (OT128) / 75 % (QT128) of the device time. + - Narrow accuracy margin: 98.907 % agreement on the hardest gated frame (gate 98.5 %); 3 of 226 frame-profile pairs of the wider public set fall to 98.10-98.499 % (nuScenes scene-0916, out of domain). + - FRNet is an opt-in, standalone Autoware node; this bundle is not a ROS 2 node and not a certified Autoware component. + - Trained on T4 data from Hesai OT128 / QT128 sensors (not public); other sensors (PandaSet Pandar64, nuScenes HDL-32E) show a domain gap in the reference itself. + - Batch 1, one sweep per request, one variant per process; fixed device shapes per profile (160,000 / 120,000 points, 60,000 cells). + - Single p150 only (ETH dispatch patch; no multi-chip mesh); dispatch=worker is an A/B opt-in the numbers do not cover. + - Not an OpenAI-compatible API; p150 power not measured. + risks: > + Device numerics: per-point probabilities differ slightly from the fp32 reference (see the agreement figures); + points near a class boundary can change class. Validated against the fp32 reference on public driving data + (no public OT128 / QT128 data exists); the mapped-label mIoU is a sanity check, not an accuracy claim. + licensing: | + - Weights: [AutowareFoundation/lidar_frnet](https://huggingface.co/AutowareFoundation/lidar_frnet) at tag `v2.0` (commit `a53a1c11b0b28d31fd50a03abef5db0e830a5b3c`), Apache-2.0 per its model card; not redistributed here. Trained by TIER IV with AWML on about 16,000 frames of the T4Dataset (Hesai OT128 / QT128 sensors; not public). + - Pre-/post-processing ported from autoware_universe `perception/autoware_lidar_frnet` @ `9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd` (Apache-2.0). + - Port and serving code (`code/`): Apache-2.0. Shipped samples: synthetic scans generated by `code/scripts/make_sample.py` (Apache-2.0). + - Demo media: PandaSet renders (CC BY 4.0 + PandaSet Dataset Terms, attribution in media/ATTRIBUTION.md) and nuScenes renders (`*_NC`, CC BY-NC-SA 4.0, non-commercial). + related: | + Other Autoware models on Blackhole: the "Autoware" collection of https://huggingface.co/changh95 + license: + id: apache-2.0 + pipeline_tag: "image-segmentation" + base_model: + - "AutowareFoundation/lidar_frnet" diff --git a/tt_kernel_manifest.json b/tt_kernel_manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..29fd4c79838193563e9dae525083c9126c7156c2 --- /dev/null +++ b/tt_kernel_manifest.json @@ -0,0 +1,166 @@ +{ + "schema_version": "5.1", + "name": "frnet-p150", + "tt_metal_version": "0.65.2.dev11169+g44d66500520", + "arch": "blackhole", + "device_count": 1, + "producer": { + "tt_kernel_version": "0.1.0", + "created_at": "2026-10-09T17:15:00.727135+00:00", + "hostname": "hchang-bh" + }, + "weights": { + "repo_id": "AutowareFoundation/lidar_frnet", + "revision": "a53a1c11b0b28d31fd50a03abef5db0e830a5b3c", + "allow_patterns": [ + "frnet_*.onnx", + "ml_package_frnet_*.param.yaml", + "deploy_metadata.yaml" + ], + "ignore_patterns": null, + "repo_type": "model" + }, + "mesh": null, + "entrypoint": null, + "resources": null, + "capabilities": null, + "env": {}, + "bundled": null, + "deps": null, + "container": { + "image": { + "registry": "hf", + "repository": "frnet-p150", + "tag": "tt-model/frnet-p150:85cbf6cc72b9", + "digest": "sha256:85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c" + }, + "kind": "tt-dit-server", + "runtime": { + "app": "tt_frnet.server.app:app", + "mesh_shape_env": "TT_MESH_SHAPE", + "packages": [ + "numpy>=1.24.4,<2", + "huggingface_hub", + "safetensors", + "pyyaml", + "onnx>=1.17,<2" + ], + "lock": "requirements.lock" + }, + "serve": { + "hardware": "p150", + "mesh_device": "P150", + "port": 20000, + "max_model_len": null, + "max_num_seqs": null, + "block_size": null, + "server_timeout": null, + "capabilities": null, + "additional_config": {}, + "args": [], + "env": { + "TT_WEIGHTS_REVISION": "a53a1c11b0b28d31fd50a03abef5db0e830a5b3c", + "TT_METAL_VISIBLE_DEVICES": "0", + "HF_HUB_DISABLE_IMPLICIT_TOKEN": "1", + "FRNET_DISPATCH": "eth", + "FRNET_NUM_CQS": "1", + "FRNET_VARIANT": "ot128", + "FRNET_POINT_TERMS": "2", + "FRNET_MAP_DTYPE": "float32", + "FRNET_CONV_TERMS": "1" + } + }, + "serve_profiles": [ + { + "hardware": null, + "mesh_device": null, + "port": null, + "max_model_len": null, + "max_num_seqs": null, + "block_size": null, + "server_timeout": null, + "capabilities": null, + "additional_config": {}, + "args": [], + "env": { + "FRNET_VARIANT": "ot128" + }, + "name": "ot128", + "description": "FRNet for the Hesai OT128 (Autoware's default sensor_model; 1024x128 frustum grid)" + }, + { + "hardware": null, + "mesh_device": null, + "port": null, + "max_model_len": null, + "max_num_seqs": null, + "block_size": null, + "server_timeout": null, + "capabilities": null, + "additional_config": {}, + "args": [], + "env": { + "FRNET_VARIANT": "qt128" + }, + "name": "qt128", + "description": "FRNet for the Hesai QT128 (256x128 frustum grid, +-52.6 deg FOV)" + } + ], + "default_profile": "ot128", + "code_dir": "code", + "verify": [ + "assert 'single_chip_arch_1cq_no_dispatch_s' in open('/opt/tt-metal/tt_metal/impl/dispatch/topology.cpp').read()", + "from tt_frnet.ttaw.device import eth_dispatch_patch_present as p; assert p(), 'ttaw does not see the ETH patch'", + "import tt_frnet.server.app as a; assert a.app", + "from tt_frnet.server.app import parse_mesh_shape as p; assert p('1x1') == p('(1, 1)') == p('1,1') == (1, 1)", + "import numpy; assert int(numpy.__version__.split('.')[0]) < 2, numpy.__version__", + "import onnx, yaml, huggingface_hub, safetensors, PIL", + "from tt_frnet.api import FRNet; assert FRNet.DEFAULT_REVISION == 'a53a1c11b0b28d31fd50a03abef5db0e830a5b3c'", + "import ttnn; assert hasattr(ttnn, 'DispatchCoreConfig') and hasattr(ttnn, 'begin_trace_capture') and hasattr(ttnn, 'execute_trace') and hasattr(ttnn, 'release_trace')", + "from pathlib import Path; d = Path('/opt/tt-metal/tt_frnet'); assert all((d / 'samples' / f'synthetic_{v}.npz').is_file() and (d / 'samples' / f'synthetic_{v}.{v}.reference.json').is_file() and (d / 'calib' / f'synthetic_{v}.json').is_file() for v in ('ot128', 'qt128'))", + "from pathlib import Path; assert Path('/opt/tt-metal/tt_frnet/ttaw/ops/kernels/segment_reduce_dm.cpp').is_file()", + "from tt_frnet.ttaw.ops import kernel_path; kernel_path('segment_reduce_dm.cpp')" + ], + "built": { + "image": "tt-model/frnet-p150:85cbf6cc72b9", + "repo": "changh95/frnet-p150", + "tt_model_version": "0.1.0", + "created_at": "2026-10-09T17:04:34+00:00", + "tt_metal": { + "sha": "44d66500520fda9f2c7060c0f6b41ec48f7ab37e", + "describe": "v0.80.0-dev20261006-78-g44d6650052-dirty", + "dirty": true, + "scm_version": "0.65.2.dev11169+g44d66500520", + "mode": "local", + "remote": "https://github.com/tenstorrent/tt-metal.git", + "branch": "main", + "pushed": true + }, + "code_sha256": "f0b62790446a51a86fe941bb4367561fecd9c560adc4d02b9b25b1e364358ede", + "image_digest": "sha256:85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c" + }, + "card": { + "description": "FRNet (Autoware lidar_frnet): the network Autoware deploys (autoware_lidar_frnet, weights AutowareFoundation/lidar_frnet v2.0) running on one Tenstorrent Blackhole p150 via tt-nn: one LiDAR sweep in the sensor frame (x, y, z, raw 0-255 intensity) in, a class id and probability for every point (27 T4 classes; Autoware's interpolated points included) out.\n", + "quickstart": "The first start compiles the kernels and captures the traces (minutes on a cold cache, seconds later); the\nserver is ready when its log shows `Application startup complete`. Build a request from a file with\n`python3 code/tt_frnet/server/client.py --points code/tt_frnet/samples/synthetic_ot128.npz --calib-preset synthetic_ot128 --out req.json` (your own cloud: `--calib my_calib.json`); the QT128 network is the `qt128` profile (`tt-model serve --profile qt128 `), then\n`curl -s localhost:20000/predict -H 'Content-Type: application/json' -d @req.json`.\n", + "architecture": "FRNet: frustum feature encoder (point MLP 8->64->128->256->256, per-cell max, 256->16) + frustum-range 2D CNN (stem, 4 residual stages with point-pixel fusion and attention, align-corners decoder) + per-point head (27-class softmax); one ONNX graph per sensor profile", + "status": "Community port (first release, baseline; optimization pending), validated against the fp32 CPU reference on public driving data", + "intended_use": "LiDAR 3D semantic segmentation for Autoware-style sensor setups (one Hesai OT128 or QT128 sweep in the sensor frame) on one Blackhole p150, as a drop-in for the TensorRT node's network.\n", + "out_of_scope_use": "Safety-critical driving decisions (this is not a certified Autoware component); multi-chip meshes; batch > 1; inputs outside the Autoware sensor configuration the weights were trained for. \n", + "usage": "`POST /predict` (JSON): `points` (base64 `.bin` / `.npy` / `.npz` / `.pcd`, sensor frame, fields x, y, z, intensity 0-255); `calibration` (`T_base_link_from_sensor` for the ego crop box, or `{\"preset\": name}`; optional `ego_crop_box`, `null` disables the box); optional `params` (`class_probability_threshold` 0.05, `filter_classes` \"drivable_surface\"), `output_format`. `GET /health`, `GET /info`, `GET /v1/models` (stub).\nFull request / response contract: `SERVING.md` section 3.\n", + "performance": "| Metric | Performance |\n|---|---:|\n| Class agreement with the fp32 CPU reference, shipped sample | OT128 99.55 % · QT128 99.62 % of the points |\n| Class agreement, 113 public frames per profile (PandaSet 32, nuScenes mini_val 81) | OT128 99.66 % / 99.19 % pooled (worst frame 98.37 %) · QT128 99.29 % / 99.44 % (worst 98.10 %) |\n| Frozen device gates (17 OT128 / 5 QT128 frames; PCC >= 0.998, agreement >= 98.5 %) | all pass; OT128 min agreement 98.907 % (nuScenes 0916 kf40, 0.41 pt margin) |\n| Python `model()` call, shipped sample | OT128 292.6 ms · QT128 221.1 ms p50 |\n| Served `/predict` `timing_ms.total`, shipped sample | OT128 310.4 ms · QT128 228.9 ms median |\n| Device trace, one forward / back-to-back | OT128 210.02 / 209.95 ms · QT128 133.53 / 133.43 ms |\n| `from_pretrained` load, empty / warm JIT cache | OT128 156.6 / 7.5 s · QT128 151.9 / 6.6 s |\n\nETH dispatch, 1 CQ, 12x10 grid, batch 1, 2026-10-09; details in README.md \"Demo & Performances\".\n", + "limitations": "- First release: baseline port, optimization pending (OPT_REPORT.md); the point-wise layers kept at two-term bf16 precision are 63 % (OT128) / 75 % (QT128) of the device time.\n- Narrow accuracy margin: 98.907 % agreement on the hardest gated frame (gate 98.5 %); 3 of 226 frame-profile pairs of the wider public set fall to 98.10-98.499 % (nuScenes scene-0916, out of domain).\n- FRNet is an opt-in, standalone Autoware node; this bundle is not a ROS 2 node and not a certified Autoware component.\n- Trained on T4 data from Hesai OT128 / QT128 sensors (not public); other sensors (PandaSet Pandar64, nuScenes HDL-32E) show a domain gap in the reference itself.\n- Batch 1, one sweep per request, one variant per process; fixed device shapes per profile (160,000 / 120,000 points, 60,000 cells).\n- Single p150 only (ETH dispatch patch; no multi-chip mesh); dispatch=worker is an A/B opt-in the numbers do not cover.\n- Not an OpenAI-compatible API; p150 power not measured.\n", + "risks": "Device numerics: per-point probabilities differ slightly from the fp32 reference (see the agreement figures); points near a class boundary can change class. Validated against the fp32 reference on public driving data (no public OT128 / QT128 data exists); the mapped-label mIoU is a sanity check, not an accuracy claim.\n", + "licensing": "- Weights: [AutowareFoundation/lidar_frnet](https://huggingface.co/AutowareFoundation/lidar_frnet) at tag `v2.0` (commit `a53a1c11b0b28d31fd50a03abef5db0e830a5b3c`), Apache-2.0 per its model card; not redistributed here. Trained by TIER IV with AWML on about 16,000 frames of the T4Dataset (Hesai OT128 / QT128 sensors; not public).\n- Pre-/post-processing ported from autoware_universe `perception/autoware_lidar_frnet` @ `9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd` (Apache-2.0).\n- Port and serving code (`code/`): Apache-2.0. Shipped samples: synthetic scans generated by `code/scripts/make_sample.py` (Apache-2.0).\n- Demo media: PandaSet renders (CC BY 4.0 + PandaSet Dataset Terms, attribution in media/ATTRIBUTION.md) and nuScenes renders (`*_NC`, CC BY-NC-SA 4.0, non-commercial).\n", + "related": "Other Autoware models on Blackhole: the \"Autoware\" collection of https://huggingface.co/changh95\n", + "license": { + "id": "apache-2.0", + "name": null, + "link": null + }, + "pipeline_tag": "image-segmentation", + "base_model": [ + "AutowareFoundation/lidar_frnet" + ] + } + } +} \ No newline at end of file