changh95 commited on
Commit
fe51a63
·
verified ·
1 Parent(s): 83a7904

tt-model push frnet-p150 (container)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +19 -0
  2. OPT_BASELINE.md +557 -0
  3. OPT_REPORT.md +157 -0
  4. PYTHON.md +220 -0
  5. README.md +202 -0
  6. SERVING.md +211 -0
  7. VERIFICATION_2026-10-09.md +166 -0
  8. build_info.json +48 -0
  9. code/PYTHON.md +220 -0
  10. code/conftest.py +59 -0
  11. code/models/common/lightweightmodule.py +12 -0
  12. code/scripts/README.md +19 -0
  13. code/scripts/bench.py +261 -0
  14. code/scripts/container_smoke.sh +137 -0
  15. code/scripts/diag_conv_precision.py +224 -0
  16. code/scripts/diag_frame_taps.py +131 -0
  17. code/scripts/fetch_samples.sh +24 -0
  18. code/scripts/make_demo.py +322 -0
  19. code/scripts/make_goldens.py +198 -0
  20. code/scripts/make_sample.py +169 -0
  21. code/scripts/make_samples.py +114 -0
  22. code/scripts/precision_study.py +120 -0
  23. code/scripts/profile_ops.py +185 -0
  24. code/scripts/profile_summary.py +251 -0
  25. code/scripts/visualizer_report.py +116 -0
  26. code/tt_frnet/__init__.py +39 -0
  27. code/tt_frnet/api.py +189 -0
  28. code/tt_frnet/calib/README.md +18 -0
  29. code/tt_frnet/calib/synthetic_ot128.json +31 -0
  30. code/tt_frnet/calib/synthetic_qt128.json +31 -0
  31. code/tt_frnet/device.py +59 -0
  32. code/tt_frnet/host/__init__.py +16 -0
  33. code/tt_frnet/host/frame.py +138 -0
  34. code/tt_frnet/host/postprocess.py +159 -0
  35. code/tt_frnet/host/preprocess.py +367 -0
  36. code/tt_frnet/io.py +34 -0
  37. code/tt_frnet/reference/__init__.py +26 -0
  38. code/tt_frnet/reference/config.py +284 -0
  39. code/tt_frnet/reference/model.py +179 -0
  40. code/tt_frnet/reference/ort.py +91 -0
  41. code/tt_frnet/reference/pipeline.py +95 -0
  42. code/tt_frnet/reference/rewrites.py +275 -0
  43. code/tt_frnet/reference/weights.py +437 -0
  44. code/tt_frnet/samples/README.md +27 -0
  45. code/tt_frnet/samples/synthetic_ot128.npz +3 -0
  46. code/tt_frnet/samples/synthetic_ot128.ot128.reference.json +0 -0
  47. code/tt_frnet/samples/synthetic_qt128.npz +3 -0
  48. code/tt_frnet/samples/synthetic_qt128.qt128.reference.json +0 -0
  49. code/tt_frnet/server/__init__.py +4 -0
  50. code/tt_frnet/server/app.py +48 -0
.gitattributes CHANGED
@@ -33,3 +33,22 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ image/blobs/sha256/263c3393dbadf7dc0379d0fc3f574de2e1bf1f288d4cf6d258c862eb309fed88 filter=lfs diff=lfs merge=lfs -text
37
+ image/blobs/sha256/2d8b1b67a1bfa51712efa01fd56489f97766c9d4439440d132b211edfefea304 filter=lfs diff=lfs merge=lfs -text
38
+ image/blobs/sha256/2fcc025d0fe89554778cf89bcb74f1084552925ef59ab8520a3e20034f91cfe7 filter=lfs diff=lfs merge=lfs -text
39
+ image/blobs/sha256/4d1d911054857424c0bbb8ffeac532a517210d4ce9dd8b458c995a934dc16c95 filter=lfs diff=lfs merge=lfs -text
40
+ image/blobs/sha256/60918f793df50b25c4d69e6063db590866f48304ceb84738f3588a4ea3af1d39 filter=lfs diff=lfs merge=lfs -text
41
+ image/blobs/sha256/7f8e824c081d26d72fc21097753f339930fd6fa33ee4f6e4f79ebdcfae6bb4a9 filter=lfs diff=lfs merge=lfs -text
42
+ image/blobs/sha256/8a364a911fa5ef61e168db9466a9c1916109d07dd609a66102a077600a7928ce filter=lfs diff=lfs merge=lfs -text
43
+ image/blobs/sha256/955c696fdd4076cc41ef9b404f0f248af208a7058c33f685360e8ad5a8a7e96f filter=lfs diff=lfs merge=lfs -text
44
+ image/blobs/sha256/98c4455a98982b35380ec62f90d5cf0edff02eeb4b3bb18595d871fdeb0c0c67 filter=lfs diff=lfs merge=lfs -text
45
+ image/blobs/sha256/9e742f886b889d3b324cb9e64252ac7b15c41dc59b900afea699edefda392ddc filter=lfs diff=lfs merge=lfs -text
46
+ image/blobs/sha256/ac4a0c0abcd00417126e1fdeb179cd923c8bd292a1c7a8b3091cdbfd79cf1a59 filter=lfs diff=lfs merge=lfs -text
47
+ image/blobs/sha256/bffe9990635d2390c86310d6135a1f2fb109a288c2678108e6981ee0997f8d71 filter=lfs diff=lfs merge=lfs -text
48
+ image/blobs/sha256/c241aa41a5566f12cb0d8c8d32dc73b0580c202137bdd5e6dd934fa7c51a13a4 filter=lfs diff=lfs merge=lfs -text
49
+ image/blobs/sha256/d325fca6b89f9da7c9aec617b11eeae4e04520f0fb771d27e965e27ac7b660fb filter=lfs diff=lfs merge=lfs -text
50
+ media/frnet_ot128_nuscenes_scene-0103_20_tt_NC.png filter=lfs diff=lfs merge=lfs -text
51
+ media/frnet_ot128_pandaset_019_40_tt.png filter=lfs diff=lfs merge=lfs -text
52
+ media/frnet_ot128_pandaset_019_40_tt_vs_cpu.png filter=lfs diff=lfs merge=lfs -text
53
+ media/frnet_ot128_pandaset_019_bev_tt.gif filter=lfs diff=lfs merge=lfs -text
54
+ media/frnet_ot128_pandaset_029_40_tt.png filter=lfs diff=lfs merge=lfs -text
OPT_BASELINE.md ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frnet-p150 baseline on the p150 (before optimization)
2
+
3
+ Date 2026-10-09. The model code is the port's final commit `14fa968` (M2-M4; VERIFY_PORT round 1: PASS), unchanged.
4
+ The baseline commits add only the measurement scripts (`c246f92`: `code/scripts/bench.py`, `profile_ops.py`,
5
+ `profile_summary.py`, `visualizer_report.py`; this commit: `visualizer_report.py --json-dir`) and this file.
6
+ tt-metal `44d66500520` + `patches/tt-metal-eth-dispatch.patch`. Weights `AutowareFoundation/lidar_frnet` @
7
+ `a53a1c11b0b` (tag `v2.0`), read from the sha256-pinned local copy through `FRNET_WEIGHTS_DIR`.
8
+
9
+ Both serve profiles are measured: **`ot128`** (default; Hesai OT128, 128 × 1024 frustum grid, `N_cap` 160,000
10
+ points, `M_cap` 60,000 cells) and **`qt128`** (Hesai QT128, 128 × 256 grid, `N_cap` 120,000, `M_cap` 60,000). The
11
+ device graph has fixed shapes per profile (`N_cap`, `M_cap`). So the point-wise device work does not depend on the
12
+ frame's point count, while the host rows and the data-dependent K1 segment reduction do.
13
+
14
+ Inputs, batch 1:
15
+ - **Synthetic scan** (generated by us, no dataset, Apache-2.0):
16
+ - made by `research/frnet/scripts/frnet_preproc_ref.py synthetic_scan` (ray cast against ground, cars, facades,
17
+ poles and pedestrians; seed 0; sensor 2 m above the ground) through
18
+ `logs/frnet/m5_baseline/scripts/make_bench_inputs.py`. Its fp32 CPU reference (ONNX Runtime on the pinned
19
+ deployed ONNX) sits next to it;
20
+ - OT128: 512 columns, 53,411 points, 52,613 after the ego crop, no interpolated point, N = M = 52,613;
21
+ - QT128: 600 columns, 45,783 points, 41,806 after the crop, N = 69,553 after interpolation, M = 18,926;
22
+ - it stands in for the release's synthetic shipped sample (the same generator), which is not built yet. Update at
23
+ the release: the shipped `code/tt_frnet/samples/synthetic_<variant>.npz` (`code/scripts/make_sample.py`) are
24
+ byte-identical to these bench inputs, so the synthetic rows below are the shipped sample's.
25
+ - **PandaSet 019 frame 40** (CC BY 4.0 + PandaSet terms), today's `code/tt_frnet/samples/pandaset_019_40.npz`:
26
+ - 107,146 points, 106,501 after the ego crop;
27
+ - OT128: N = 145,961 after interpolation, M = 40,691; QT128: N = 106,585, M = 6,255.
28
+
29
+ The first release moved it to the git-ignored `staging_samples_pandaset/samples/` (not shipped until PandaSet
30
+ samples are approved); the commands below name its old path.
31
+
32
+ Configuration of every number, unless a row says otherwise:
33
+ - ETH dispatch, 1 CQ, 12×10 grid (`device.compute_with_storage_grid_size()`, printed in each log);
34
+ - the shipped precision knobs of `tt-model.yaml` serve.env: `FRNET_POINT_TERMS=2` (two-term bf16 point island),
35
+ `FRNET_MAP_DTYPE=float32` (fp32 image-branch maps), `FRNET_CONV_TERMS=1`. The job unsets every other `FRNET_*`, so
36
+ the code defaults (= the serve.env pins) apply.
37
+
38
+ ## Environment
39
+
40
+ | item | value |
41
+ |---|---|
42
+ | tt-metal | `44d66500520` (main) + `patches/tt-metal-eth-dispatch.patch` (sha256 `08d0ddf6…`, the same file as the workspace copy) |
43
+ | 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 |
44
+ | 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) |
45
+ | 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 |
46
+ | 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 |
47
+ | 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) |
48
+
49
+ ## How to run
50
+
51
+ ```bash
52
+ ROOT=/home/ubuntu/experiments/tt-models; cd $ROOT/bundles/frnet-p150; source $ROOT/bin/tt-env.sh
53
+ export PYTHONPATH=$PWD/code:$PYTHONPATH HF_HUB_OFFLINE=1 OMP_NUM_THREADS=4 \
54
+ FRNET_WEIGHTS_DIR=$ROOT/assets/frnet/hf_lidar_frnet # v2.0, sha256-pinned
55
+ # accuracy gates (per-module PCC + end-to-end agreement vs the fp32 CPU reference, both profiles), one job
56
+ $ROOT/bin/devrun -t 1800 -- env TTAW_GATES_READONLY=1 python -m pytest -p no:cacheprovider -q -s \
57
+ code/tt_frnet/tests/test_pcc_device.py code/tt_frnet/tests/test_e2e_device.py
58
+ # stage breakdown (pre split, host staging, H2D split, trace, b2b, D2H split, unsort, post, e2e p50/p99), with a check
59
+ # of each served output against its stored CPU reference (<stem>.<variant>.reference.json next to the input)
60
+ $ROOT/bin/devrun -t 900 -- python code/scripts/bench.py --variant ot128 --iters 100 --warmup 10 --b2b-iters 50 \
61
+ --b2b-rounds 5 --input <synthetic_ot128.npz> --input code/tt_frnet/samples/pandaset_019_40.npz --json <out>.json
62
+ # dispatch / CQ matrix: one process per configuration
63
+ $ROOT/bin/devrun -t 900 -- python code/scripts/bench.py --variant qt128 --iters 50 --input <npz> --dispatch worker --cqs 2 --json <out>.json
64
+ # device profile: one eager forward (module / layer signposts) + one traced replay between signposts (1,104 programs:
65
+ # above the profiler's default 1,000-op buffer, hence --op-support-count; each section is flushed on its own)
66
+ $ROOT/bin/devrun -t 3600 -- python -m tracy -r -p -v --no-web-server --op-support-count 4000 \
67
+ -o $ROOT/generated/profiler/frnet_baseline code/scripts/profile_ops.py --variant ot128 --input <npz>
68
+ tt-perf-report <ops_perf_results_*.csv> --start-signpost trace --end-signpost trace_end --tracing-mode
69
+ python code/scripts/profile_summary.py <ops_perf_results_*.csv> profile_summary.json --md profile_summary.md
70
+ # ttnn-visualizer memory + graph report of one eager forward (slow: never used for timing; the 7.5 GB graph JSON goes to tmpfs)
71
+ $ROOT/bin/devrun -t 2400 -- python code/scripts/visualizer_report.py $ROOT/generated/ttnn_visualizer/frnet_baseline \
72
+ --variant ot128 --input code/tt_frnet/samples/pandaset_019_40.npz --json-dir /dev/shm/frnet_visualizer
73
+ ttnn-visualizer --profiler-path $ROOT/generated/ttnn_visualizer/frnet_baseline \
74
+ --performance-path $ROOT/generated/profiler/frnet_baseline/reports/2026_10_09_13_56_53
75
+ ```
76
+
77
+ - All device measurements ran in two `bin/devrun` windows on 2026-10-09, every step rc 0 (job script
78
+ `logs/frnet/m5_baseline/scripts/job1_all.sh`, environment `env.sh`):
79
+ - 13:08:29–13:10:07 UTC: the device suite (step 1);
80
+ - 13:26:44–14:04:39 UTC: stage benches, matrix, both profiles and the visualizer report (steps 2–5).
81
+
82
+ The first window also started steps 2–5, which failed in 1 s each: the HF cache holds no snapshot of the pinned
83
+ revision, so `env.sh` now sets `FRNET_WEIGHTS_DIR` (logs of that attempt: `logs/frnet/m5_baseline/try0/`).
84
+ - The OT128 replay is **1,104 programs**, above the device profiler's default buffer of 1,000. The run raises the
85
+ buffer with `--op-support-count 4000`, and `profile_ops.py` flushes each section with `ttnn.ReadDeviceProfiler`.
86
+ 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
87
+ dropped.
88
+
89
+ ## Accuracy (baseline)
90
+
91
+ The device suite ran read-only (`TTAW_GATES_READONLY=1`, reports tagged `_m5`: `logs/frnet/{pcc,e2e,latency}_{ot128,qt128}_m5.json`,
92
+ `logs/frnet/m5_baseline/try0/suite_r1.log`): **12 passed** in 94.5 s. The values are bit-identical to PORT_LOG M4 (j6)
93
+ and VERIFY_PORT round 1 (the graph is deterministic). The gate JSONs are unchanged (`git status` clean).
94
+
95
+ | check | gate | OT128 | QT128 |
96
+ |---|---|---|---|
97
+ | every gated module tap (PCC vs the fp32 reference, sample, replay outputs) | ≥ 0.999 | min 0.999729 (`point_fusion1`) | min 0.999649 (`attention4`) |
98
+ | `pred_probs` PCC / raw-point argmax agreement, sample | ≥ 0.998 / ≥ 98.5 % | 0.999942 / 99.581 % | 0.999844 / 99.295 % |
99
+ | public frames (OT128 17, QT128 5): min `pred_probs` PCC | ≥ 0.998 | 0.999688 | 0.999805 |
100
+ | public frames: min raw-point argmax agreement | ≥ 98.5 % | **98.907 %** (nuScenes 0916 kf40; margin 0.41 pt) | 99.158 % (PandaSet 029 f40) |
101
+ | replay == eager | bit-exact | 45 / 45 taps | 45 / 45 taps |
102
+ | 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 |
103
+
104
+ - **Every bench run also checks its served output** against the stored fp32 CPU reference with the container-smoke
105
+ gates (`server/smoke_test.py` `REFERENCE_GATES`); all passed:
106
+ - OT128: synthetic labels 99.55 % / filter 99.77 % (max |Δscore| 0.072); PandaSet 99.59 % / 99.76 % (0.109);
107
+ - QT128: synthetic 99.62 % / 99.87 % (0.144); PandaSet 99.29 % / 99.62 % (0.150);
108
+ - every matrix run (ETH / WORKER × 1 / 2 CQs, both profiles) passes the same check.
109
+ - **The binding constraint of the optimization phase is nuScenes 0916 kf40 (OT128): 98.907 % against the 98.5 %
110
+ gate.** Every precision-reducing change measured in the port failed it: bf16 maps 98.28 %, an fp32 × fp32 point
111
+ island 97.50 % (PORT_LOG M3). See "The precision cost of the policy" below.
112
+ - Host suite on the fake ttnn with the new scripts: **112 passed, 13 skipped** (the device files and the
113
+ onnxruntime-only test). Log: `logs/frnet/m5_baseline/host_suite.log`.
114
+
115
+ ## Performance (warm, batch 1, median of 100 iterations)
116
+
117
+ 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
118
+ the median of 5 rounds of 50 back-to-back replays. `pre` includes the file decode (`pre.load`); an HTTP request
119
+ decodes its points envelope inside the call.
120
+
121
+ | stage | OT128 synthetic | OT128 PandaSet 019 f40 | QT128 synthetic | QT128 PandaSet 019 f40 |
122
+ |---|---:|---:|---:|---:|
123
+ | host preprocess as served (`model._prepare`) | 55.3 (59.1) | 107.2 (120.0) | 54.2 (69.7) | 83.5 (106.1) |
124
+ | … decode (`load_points`) | 6.9 | 12.9 | 6.2 | 13.1 |
125
+ | … 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 |
126
+ | … fp16 interpolation grid + interpolated points | 18.4 | 37.4 | 17.2 | 29.3 |
127
+ | … device plan (`build_plan`: sort, CSR offsets, gather tables, 8-d features) | 22.5 | 41.8 | 23.3 | 31.4 |
128
+ | `FrameStaging.write` (range checks + persistent host buffers) | 3.3 | 3.2 | 2.4 | 2.3 |
129
+ | 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) |
130
+ | … of which `features` fp32 `[N_cap, 8]` | 4.13 | 4.13 | 3.14 | 3.13 |
131
+ | device trace, one blocking forward | 210.02 (210.15) | 211.72 (211.89) | 133.53 (134.01) | 134.91 (135.12) |
132
+ | **back-to-back trace replays, per forward** | **209.948** | **211.643** | **133.434** | **134.866** |
133
+ | 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) |
134
+ | … copy / host untilize (to numpy) | 2.25 / 4.95 | 2.29 / 5.01 | 1.77 / 4.15 | 1.78 / 3.75 |
135
+ | 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) |
136
+ | host postprocess (first-max argmax, filter, output assembly) | 5.0 (6.8) | 14.8 (19.3) | 6.8 (9.8) | 10.5 (15.9) |
137
+ | **e2e `model()`** | **292.6 (299.4)** | **355.7 (367.0)** | **221.1 (233.5)** | **244.3 (258.7)** |
138
+
139
+ - **Throughput.**
140
+ - Back-to-back replays give **4.72 frames/s** of device throughput on OT128 (PandaSet) and **7.41 frames/s** on
141
+ QT128.
142
+ - A synchronous `model()` call gives 2.81 frames/s (OT128 PandaSet), 3.42 (OT128 synthetic), 4.09 (QT128
143
+ PandaSet) and 4.52 (QT128 synthetic).
144
+ - **The API's own `timing_ms` medians** (OT128 PandaSet): preprocess 105.4 ms, device 235.7 ms, postprocess 14.6 ms,
145
+ total 355.7 ms. The API's `device` stage is staging + H2D + trace + D2H + unsort: 24 of its 236 ms are outside the replay.
146
+ - **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
147
+ host preprocess is 30 % (OT128) and 34 % (QT128).
148
+ - **The device time barely depends on the frame** (fixed `N_cap` / `M_cap`): the synthetic OT128 scan with 36 % of
149
+ PandaSet's points replays only 1.7 ms faster (209.95 vs 211.64 ms). The difference is in K1 and the gathers.
150
+ - **Load** (warm JIT cache): `from_pretrained` 6.5 s OT128 / 6.9 s QT128 in the stage bench (build 0.28 s,
151
+ warm-up + capture 2.0 s). WORKER (11×10) compiles its own program set: 137 s OT128, 82 s QT128. A process with the
152
+ device profiler compiles its own kernels (146 s).
153
+
154
+ ## Dispatch / CQ matrix (D14)
155
+
156
+ Setup:
157
+ - synthetic scan, one process per configuration, all in one devrun job (13:34–13:53 UTC);
158
+ - 50 iterations plus 5 × 30 back-to-back replays each;
159
+ - host load 4.1–5.8 throughout.
160
+
161
+ | profile | configuration | grid | programs | b2b replay ms | trace p50 ms | H2D p50 / min ms | D2H p50 / min ms | e2e p50 / min ms |
162
+ |---|---|---|---:|---:|---:|---:|---:|---:|
163
+ | OT128 | **ETH-1CQ** (shipped) | 12×10 | 227 | **209.948** | 210.00 | 5.39 / 5.35 | 7.61 / 7.33 | 292.9 / 291.1 |
164
+ | OT128 | ETH-2CQ | 12×10 | 227 | 211.317 | 211.45 | 5.49 / 5.46 | 7.65 / 7.30 | 290.5 / 289.7 |
165
+ | OT128 | WORKER-2CQ | 11×10 | 231 | 211.567 | 211.73 | 4.57 / 4.55 | 7.56 / 7.19 | 293.4 / 291.7 |
166
+ | OT128 | WORKER-1CQ | 11×10 | 231 | 211.563 | 211.63 | 4.49 / 4.47 | 7.48 / 7.32 | 291.7 / 291.2 |
167
+ | 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 |
168
+ | QT128 | **ETH-1CQ** (shipped) | 12×10 | 174 | **133.437** | 133.51 | 4.02 / 3.98 | 5.51 / 5.33 | 208.8 / 207.5 |
169
+ | QT128 | ETH-2CQ | 12×10 | 174 | 134.250 | 134.38 | 4.11 / 4.08 | 5.50 / 5.28 | 210.0 / 209.1 |
170
+ | QT128 | WORKER-2CQ | 11×10 | 174 | 153.721 | 153.82 | 3.45 / 3.42 | 5.53 / 5.31 | 229.3 / 227.7 |
171
+ | QT128 | WORKER-1CQ | 11×10 | 174 | 153.709 | 153.77 | 3.39 / 3.37 | 5.43 / 5.30 | 226.0 / 225.3 |
172
+ | 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 |
173
+
174
+ - **ETH stays the default: it is better on the device in both profiles.**
175
+ - OT128: ETH replays 1.6 ms faster than WORKER (209.95 vs 211.56 ms, −0.8 %).
176
+ - QT128: ETH replays **20.3 ms faster** (133.44 vs 153.71 ms, −13.2 %). On the 128 × 256 grid the 11×10
177
+ partitioning of the point matmuls and convs is clearly worse. This is the largest ETH/WORKER gap measured so
178
+ far in this workspace.
179
+ - WORKER's H2D is 0.6–0.9 ms faster, as in the other matrices. It does not offset the replay.
180
+ - **1 CQ.** ETH with 2 CQs replays 0.8–1.4 ms slower (ETH-2CQ runs without dispatch_s, PLAN §0.2). A synchronous
181
+ request gains nothing from the second queue (e2e within host noise). Uploads are 5.4 ms and readbacks 7.5 ms per
182
+ frame, so pipelined 2CQ staging could hide at most about 13 ms of a 212 ms frame (−6 %).
183
+ - **Decision: ETH, 1 CQ stays pinned** (`serve.env FRNET_DISPATCH=eth`, `FRNET_NUM_CQS=1`, D14).
184
+ - Re-measure a pipelined 2CQ mode once the replay is no longer 15–30× the transfers.
185
+ - The drift checks repeat to 0.01 ms (OT128) and 0.004 ms (QT128). One OT128 ETH-1CQ b2b round read 211.76 ms
186
+ (the other four 209.94–209.95), and the median is unaffected.
187
+
188
+ ## Host vs device, and every host<->device transfer per forward
189
+
190
+ **Host.** Only the documented fallbacks of PLAN §2.8 run on the host; there is no host round trip and no host
191
+ fallback op inside the device graph.
192
+
193
+ Before the device (`host/preprocess.py`, numpy), in the node's order:
194
+ - the ego crop (float32 `R p + t`);
195
+ - the float32 projection;
196
+ - the fp16 farthest-point interpolation grid and the interpolated points;
197
+ - the unique cells and the profile checks;
198
+ - the device plan: the sort by cell, CSR offsets, `point2pix_s` / `pix2vox_s` for s = 1, 2, 4, 8, and the 8-d
199
+ features with `pre_norm` folded.
200
+
201
+ After the readback:
202
+ - the un-permute (`plan.unsort`) of the first N rows / 27 columns;
203
+ - the first-max argmax with the −1e9 start (never a device last-dim argmax, P10), the score, the class filter
204
+ (`prob ≥ 0.05` on `drivable_surface`) and the output assembly.
205
+
206
+ **H2D, 9 per frame.** `copy_host_to_device_tensor` from the persistent `HostStaging` buffers into the trace inputs,
207
+ all ROW_MAJOR in DRAM (OT128 bytes; QT128 in brackets):
208
+
209
+ | input | shape, dtype | bytes | ms |
210
+ |---|---|---:|---:|
211
+ | `features` | `[1, 1, N_cap, 8]` fp32 (32 B pages) | 5,120,000 (3,840,000) | 4.13 (3.13) |
212
+ | `offsets` (K1 CSR) | `[1, 60002]` uint32 | 240,008 | 0.17 |
213
+ | `p2p_1/2/4/8` (pixel2point) | 4 × `[1, N_cap]` uint32 | 4 × 640,000 (480,000) | 0.27–0.31 each |
214
+ | `v2p_1/2/4/8` (frustum2pixel) | `[1, (H/s)(W/s)]` uint32 | 524,288 + 131,072 + 32,768 + 8,192 | 0.06–0.26 |
215
+ | **total** | | **8,616,328** (6,174,088) | **5.41** (4.00) |
216
+
217
+ `features` moves at 1.24 GB/s: 160,000 pages of 32 bytes. The other inputs reach 1.4–2.1 GB/s.
218
+
219
+ **D2H, 1 per frame.** The fp32 `pred_probs` TILE `[N_cap, 32]`:
220
+ - 20,480,000 B (OT128) / 15,360,000 B (QT128);
221
+ - of the 32 columns, 27 hold classes and 5 are padding (bias −1e30: probability 0);
222
+ - rows beyond N are padding (PandaSet OT128 uses 145,961 of 160,000, the nuScenes frames 20–28 k);
223
+ - copy 2.29 ms (8.9 GB/s), then host untilize 5.0 ms.
224
+
225
+ **The served device path** (the API's `device` stage) is 236 ms on OT128 PandaSet. 212 ms of it run on the device.
226
+
227
+ ## Grid usage
228
+
229
+ - **Nothing hard-codes 11×10 or 12×10** (VERIFY_PORT §3(b): the grid comes from `device.compute_with_storage_grid_size()`;
230
+ K1 splits its work on the device by two binary searches, so ETH and WORKER give the same outputs). On 11×10, WORKER
231
+ builds 231 instead of 227 programs (OT128).
232
+ - **OT128: 456 of the 1,104 programs per frame run on all 120 cores, 648 on fewer** (26.3 ms of kernel time):
233
+ - every point-island op on `N_cap` rows, the K1 kernels and the softmax run on 120 cores. The `M_cap`-row
234
+ compression matmuls run on 118;
235
+ - the image-branch convs run on 96 (28 conv programs), 108 (22) and 40 cores (stage 4, `pixel_fusion4`,
236
+ `attention4`: the 16 × 128 stride-8 map). Their glue (halo, move, padded slice, slice write, resharding) runs on
237
+ 40–114 cores. The height-sharded configurations use whole row groups;
238
+ - the convs reach a weighted 59.7 % of the HiFi4 FLOPs (tt-perf-report). The ten block-sharded conv programs reach
239
+ 26.1 %, but take only 0.42 ms.
240
+ - **QT128: the point matmuls run on 118 cores** (3,750 tile rows of 120,000 points). The convs of the narrow
241
+ 128 × 256 grid use 32–108 cores.
242
+ - **L1 is empty between ops.** The ttnn-visualizer report shows no L1 buffer at any op boundary.
243
+ - Every activation round-trips DRAM interleaved.
244
+ - DRAM peaks at **218.6 MB per bank (1.75 GB in all, weights included)** at the first partial-sum add of
245
+ `decoder.point_fuse1`. At that point the fp32 point features p0–p4 (`[160000, 128]`, 82 MB each) are still alive
246
+ next to the fp32 `[160000, 256]` partials (164 MB each).
247
+ - L1 for tensors is 167.5 MB (120 × 1,395,712 B, the visualizer's device record).
248
+ - **What would fit in L1:**
249
+ - a 128 × 1024 × 128 image map: 33.5 MB in bf16, 67 MB in fp32;
250
+ - every point-island weight: the largest, `point_fuse1` 640 × 256, is 655 KB as two bf16 terms.
251
+
252
+ No fp32 point tensor at `N_cap` fits (82–164 MB each), so the point island needs row-block streaming.
253
+
254
+ ## What is already fused / traced
255
+
256
+ **Done:**
257
+ - **One trace per process** covers the whole device graph: encoder → K1 → compression → frustum2pixel → stem →
258
+ point stem → fusion stem → 4 stages → decoder → point fuse → head → softmax → one packed output.
259
+ - Capture takes 2.6 s (OT128) / 2.0 s (QT128).
260
+ - No eager op runs between replays; replay == eager bit for bit (a gate).
261
+ - Eagerly, under the profiler, the same 1,104 programs span 260.2 ms (op-to-op gaps 47.6 ms of host dispatch),
262
+ against 213.3 ms traced.
263
+ - **K1 `segment_reduce`** (owned `ttnn.generic_op`, bit-exact). It replaces the log-step stock-op fallback, which
264
+ would take 3R + 1 programs per site, with one program per segmented-max site (6 per frame). It reads the
265
+ matmul's TILE output directly (no untilize). Sites 1–5 write the bf16 frustum2pixel gather table, zero row
266
+ included.
267
+ - **Exact rewrites** (all from the port, each tested against ORT or an fp32 emulation):
268
+ - BN folded into the 14 point linears and the 53 convs;
269
+ - `pre_norm` folded into the host features;
270
+ - frustum2pixel / pixel2point as TILE + PADDED `ttnn.embedding` gathers with a zero sentinel row (C19);
271
+ - concat-free point layers (K-split linears) and K-split fusion convs. `fuse_layer1` is a 5 × 128 split-K
272
+ `KSplitConv` (P14), so the 160 MiB decoder concat never exists;
273
+ - align-corners resizes as interpolation matmuls (C18);
274
+ - the 27 logits padded to 32 with a −1e30 bias, so the softmax is exact;
275
+ - fused SIGMOID in the attention convs, ReLU in the point linears;
276
+ - a bf16 input of a two-term linear (the gathered map rows) takes two matmuls instead of three (`lo = 0`).
277
+ - **Host side:** `HostStaging` writes the plan into persistent zero-copy host tensors (no per-frame `from_torch`),
278
+ and out-of-profile frames return before touching the device.
279
+
280
+ **Not fused / not resident:**
281
+ - **the two-term point island:**
282
+ - each point linear runs one split per fp32 input: typecast to bf16, typecast back, subtract, typecast. So
283
+ 4 programs per input;
284
+ - then 2–3 bf16 matmuls per part, each writing a full fp32 `[N_cap, C_out]` partial to DRAM;
285
+ - then one fp32 add per partial.
286
+
287
+ The 5-part `point_fuse1` alone is 49 programs and 39.9 ms;
288
+ - every activation in DRAM interleaved between ops. The 128 × 1024 convs run ttnn's automatic DRAM slicing, each
289
+ slice with its own padded slice, halo, move and slice write;
290
+ - every HardSwish is its own fp32 `ttnn.hardswish` (P5: a fused HardSwish is silently skipped), 46 per frame;
291
+ - the resizes (C18) run as transpose + matmul + reshape + tilize / untilize chains with bf16 outputs (C18 cannot
292
+ write fp32 rows at 1024 × 128);
293
+ - pixel2point: untilize of the map, then the gather, then a typecast to fp32;
294
+ - every point-wise op runs on all `N_cap` rows and K1 writes all `M_cap` segments, whatever the frame's N and M;
295
+ - no megakernel. Projection, interpolation, the plan, the argmax and the filter run on the host.
296
+
297
+ ## Device profile (traced replay; kernel sum vs span, op-to-op gaps)
298
+
299
+ One traced replay, ETH-1CQ, PandaSet 019 f40. Reports: OT128 `generated/profiler/frnet_baseline/reports/2026_10_09_13_56_53/`,
300
+ QT128 `generated/profiler/frnet_baseline_qt128/reports/2026_10_09_13_59_21/`.
301
+
302
+ | | OT128 | QT128 |
303
+ |---|---:|---:|
304
+ | programs per frame | **1,104** | **759** |
305
+ | distinct programs (program cache) | 227 | 174 |
306
+ | 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 | – |
307
+ | device kernel sum | **212.64 ms** | **134.81 ms** |
308
+ | span (first FW start → last FW end at 1350 MHz) | 213.27 ms (b2b 211.64) | n/a (see below; b2b 134.87) |
309
+ | op-to-op gaps | 0.628 ms in all, median 0.55 µs | 0.443 ms, median 0.57 µs |
310
+ | conv + matmul kernels | 76.70 ms (36 %) | 51.62 ms (38 %) |
311
+ | math fidelity | HiFi4 on all 677 compute ops | HiFi4 on all 539 |
312
+
313
+ - **The trace is kernel-time bound: dispatch is not a sink** (0.3 % of the span). Cutting programs pays only through
314
+ the kernel time of the programs it removes. A megakernel's dispatch saving is at most 0.6 ms.
315
+ - QT128's span is not usable: one replayed conv (row 3631 of the CSV, `backbone.layer4`) reports a FW duration of
316
+ 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
317
+ such stall. The kernel sum plus gaps (135.25 ms) is the QT128 reference.
318
+
319
+ **By part of the graph** (`logs/frnet/m5_baseline/scripts/group_profile.py`; ops attributed through the eager
320
+ module / layer signposts):
321
+
322
+ | part | OT128 ops | OT128 kernel ms | share | QT128 ms | share | of which (OT128) |
323
+ |---|---:|---:|---:|---:|---:|---|
324
+ | **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 |
325
+ | … 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) |
326
+ | … 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 |
327
+ | … 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 |
328
+ | … head (4 × linear + skips, logits, softmax) | 44 | 23.89 | 11.2 % | 17.95 | 13.3 % | |
329
+ | … compression (256 → 16, on `M_cap` rows) | 12 | 2.35 | 1.1 % | 2.33 | 1.7 % | |
330
+ | **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) |
331
+ | pixel2point gathers (6 × untilize + embedding + typecast) | 18 | 6.15 | 2.9 % | 3.50 | 2.6 % | embedding 3.47 |
332
+ | frustum2pixel gathers (6 embeddings) | 6 | 0.65 | 0.3 % | 0.15 | 0.1 % | |
333
+ | decoder resizes (C18, 3 maps) | 39 | 9.03 | 4.2 % | 2.24 | 1.7 % | matmul 2.89, reshape 2.62, (un)tilize 2.30 |
334
+ | 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 |
335
+ | 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 |
336
+ | **total** | **1,104** | **212.64** | | **134.81** | | |
337
+
338
+ | sink (op type, OT128) | µs | % | where |
339
+ |---|---:|---:|---|
340
+ | 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) |
341
+ | 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) |
342
+ | 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) |
343
+ | 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 |
344
+ | Conv2d (138) | 12,582 | 5.9 | 53 convs in 2–4 DRAM slices each, weighted 59.7 % of the HiFi4 FLOPs |
345
+ | SliceWrite + PaddedSlice (194) | 9,073 | 4.3 | ttnn's DRAM slicing of the 128 × 1024 convs |
346
+ | Unary (46) | 6,269 | 2.9 | the 46 fp32 HardSwish |
347
+ | Embedding (12) | 4,118 | 1.9 | pixel2point (3.47 ms) and frustum2pixel (0.65 ms) gathers |
348
+ | Halo + Move + resharding (358) | 4,816 | 2.3 | conv glue |
349
+ | Reshape + (Un)Tilize + Transpose (39) | 7,340 | 3.5 | resize chains, pixel2point untilize, the input tilize |
350
+ | Ternary `addcmul` (4) | 852 | 0.4 | attention gating |
351
+
352
+ Top 10 device ops of the OT128 replay:
353
+
354
+ | # | op | where | µs | cores |
355
+ |---:|---|---|---:|---:|
356
+ | 1 | K1 `segment_reduce` | `segmax.encoder`: `[160000, 256]` fp32 → `[60000, 256]` fp32 (voxel max) | 4,928 | 120 |
357
+ | 2–6 | K1 `segment_reduce` | `segmax.points` sites 1–5: `[160000, 128]` → the bf16 gather table `[60001, 128]` | 2,394–2,396 each | 120 |
358
+ | 7–9 | Matmul | `encoder.ffe3` (256 → 256), the three two-term products `[160000, 256] × [256, 256]` → fp32 | 1,641 / 1,599 / 1,598 | 120 |
359
+ | 10 | Matmul | `head.mlp2` part `[160000, 256] × [256, 128]` → fp32 | 1,221 | 120 |
360
+
361
+ Next come the 15 `point_fuse1` matmuls (1.15–1.20 ms each), the 25 fp32 adds at `[160000, 256]` (1.08–1.11
362
+ ms each) and the `point_stem` part matmuls (1.19 ms).
363
+
364
+ **Most expensive layers:**
365
+ - `decoder.point_fuse1`: 39.85 ms (18.7 % of the replay), of which 17.41 ms matmul;
366
+ - `decoder.fuse1`: 12.55 ms in 157 programs, of which 3.92 ms conv;
367
+ - K1 `segmax.points` (5 sites): 11.98 ms;
368
+ - `backbone.point_stem`: 9.96 ms;
369
+ - `encoder.ffe3`: 9.88 ms;
370
+ - `head.mlp1` / `head.mlp2`: 8.23 / 8.09 ms;
371
+ - each point fusion: 6.97–6.99 ms;
372
+ - 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
373
+ output).
374
+
375
+ `logs/frnet/m5_baseline/profile_summary_{ot128,qt128}.{json,md}` have the per-module, per-layer and per-op tables,
376
+ `tt_perf_report_{ot128,qt128}.txt` the tt-perf-report output (with its per-op advice), and
377
+ `grp_{ot128,qt128}.txt` the part breakdown above.
378
+
379
+ **The precision cost of the policy.** These are the port's same-session device A/Bs on the OT128 frames (PORT_LOG
380
+ M1–M3, jobs j2–j5, gates read-only). They were not re-run for this baseline.
381
+
382
+ | policy | replay ms | Δ vs shipped | OT128 frames min argmax (nuScenes 0916 kf40) | gate |
383
+ |---|---:|---:|---|---|
384
+ | fp32 × fp32 point island (`POINT_TERMS=1`), bf16 maps (j2) | 107 | −105 ms (−50 %) | **97.50 %** | fails |
385
+ | two-term point island, bf16 maps (j3) | 201 | −11 ms (−5 %) | **98.28 %** | fails |
386
+ | two-term point island, bf16 maps, `CONV_TERMS=2` (j4) | 244 | +32 ms | **98.35 %** | fails |
387
+ | **two-term point island, fp32 maps (shipped; j5 / this baseline)** | **212** | – | **98.91 %** | passes, 0.41 pt margin |
388
+ | two-term point island, fp32 maps fed to the convs directly (j5) | 217 | +5 ms | **98.13 %** | fails |
389
+ | two-term point island, fp32 maps, `CONV_TERMS=2` (j5) | 249 | +37 ms | 99.00 % | passes, 0.50 pt margin |
390
+
391
+ - **The two-term point island costs about 94 ms per OT128 frame** (j2 → j3: 107 → 201 ms, +88 %). That is 44 % of
392
+ today's replay, and it is how the port reaches the gate: the device's fp32 matmul is TF32-like (P12). The
393
+ profile shows that the cost is not the extra products (bf16 HiFi4 is exact) but the fp32 partial tensors, their
394
+ DRAM adds and the split typecasts. Item 1 below targets exactly that, with the same products.
395
+ - **The fp32 maps cost about 11 ms** (201 → 212 ms): fp32 HardSwish, residual adds and `addcmul`, and a cast to bf16
396
+ before every conv. They buy 0.63 pt on the binding frame.
397
+ - **Cheaper precision fails.** Every cheaper policy measured fails the binding nuScenes frame. HiFi2 / LoFi or
398
+ bfp8 were not measured: with a 0.41 pt margin and 98.28 % for bf16 maps, they need fresh device evidence, and the
399
+ FPU is not the bottleneck (the matmuls run at 1.4–7.9 % of the FLOPs).
400
+ - **More margin costs time.** `CONV_TERMS=2` raises the margin to 0.50 pt for +37 ms. fp32 resize outputs (blocked
401
+ by C18 at 1024 × 128 rows) and fp32 gathers for the 128-channel maps are unmeasured options.
402
+
403
+ ## Ranked optimization opportunities
404
+
405
+ How to read the estimates:
406
+ - Gains are on the OT128 trace (211.6 ms, PandaSet) unless marked e2e (OT128 PandaSet e2e 355.7 ms p50). QT128
407
+ follows the same ranking (the point island is 75 % of its 134.9 ms).
408
+ - Each estimate is the measured kernel or host time of the work the item removes, with a range for what replaces
409
+ it. Items overlap, so the gains do not add up.
410
+ - Every item keeps the frozen gates and the precision policy (PLAN §9.3), and lands as one commit with one
411
+ `FRNET_*` A/B knob (PLAN §5.1).
412
+ - **Judge numerics changes on the binding frame first: nuScenes 0916 kf40, OT128, 98.907 % against the 98.5 % gate.**
413
+ A change in the order of fp32 accumulation is not bit-identical to today, so items 1–2 must re-run the full
414
+ device suite.
415
+
416
+ 1. **Two-term point linears as one K-concatenated matmul per layer (trace −60..−80 ms, −28..−38 %; the largest
417
+ item).**
418
+ - Today each point linear writes one fp32 `[N_cap, C_out]` partial per product: 2–3 per input part, up to 15 for
419
+ `point_fuse1`. One fp32 add per partial then sums them. Matmuls take 61.2 ms and the adds 52.6 ms, at
420
+ 130–450 GB/s: DRAM traffic, not FLOPs (1.4–7.9 % of HiFi4).
421
+ - The fix: one matmul per layer over the K-concatenated bf16 operands `[x_hi | x_hi | x_lo]` (all parts) ×
422
+ `[W_hi; W_lo; W_hi]`, with fp32 dest accumulation and packer L1 accumulation.
423
+ - The products are the same exact bf16 products. Only the fp32 summation order changes, so the gates must be
424
+ re-run.
425
+ - The fp32 output is written once. The bf16 inputs are a quarter of an fp32 partial's bytes.
426
+ - The concat of the bf16 operands costs about 0.3–1 ms per layer, unless the split kernel (item 3) writes them
427
+ straight into the K-slices of one tensor.
428
+ - Expected result: matmul about 61 → 25–35 ms, adds 52.6 → about 5 ms (the residual / skip adds stay).
429
+ 2. **MK-PointMLP: a row-block-streamed point MLP megakernel (trace −100..−115 ms beyond today, overlapping 1;
430
+ D19).**
431
+ - The point island is 133.7 ms (OT128) and 101.1 ms (QT128): 63–75 % of the replay. Its work is row-local
432
+ except the gathers and K1.
433
+ - A kernel per row block (for example 32–64 points per core) chains the linears of one segment in L1:
434
+ - the encoder `ffe0..ffe3`;
435
+ - point fuse `640 → 256 → 128`;
436
+ - the head's 4 layers + skips + logits + softmax.
437
+
438
+ It does the two-term splits on the fly, holds the bf16 weight terms in L1 (at most 655 KB for `point_fuse1`)
439
+ and writes only the segment's outputs. The head segment writes `pred_probs` directly.
440
+ - 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
441
+ roughly 15–30 ms for the whole island.
442
+ - Stepping stones:
443
+ - item 1;
444
+ - then fuse each linear's split into the producer's epilogue (item 3);
445
+ - then one segment at a time, the head first (23.9 ms, no gather inside).
446
+ 3. **A fused two-term split (trace −10..−14 ms; on the way to 1–2).**
447
+ - Each fp32 input today runs 4 programs: typecast, typecast back, subtract, typecast. That is 19.5 ms for 61
448
+ casts, plus 20 subtracts (in the BinaryNg row), at `N_cap`.
449
+ - One `generic_op` (or SFPU eltwise) pass can read fp32 and write hi and lo bf16, about 0.3–0.5 ms per
450
+ `[160000, 256]` input. It can write into the K-slices of item 1's operand. Exact: the same RNE casts.
451
+ 4. **N_cap / M_cap buckets (trace −100..−160 ms on frames with N ≤ 32–64 k; e.g. every nuScenes frame).**
452
+ - Every point-wise op runs on all 160,000 rows (OT128) or 120,000 (QT128), and K1 writes all 60,000
453
+ segments. That covers the point island, K1, the gathers and the readback: about 157 ms of the OT128 replay.
454
+ - The catalogued nuScenes frames have N = 20–28 k (13–17 % of `N_cap`) and M = 17–25 k. The QT128 PandaSet
455
+ frame has M = 6,255 against `M_cap` = 60,000.
456
+ - The fix: two or three trace variants per profile (for example `N_cap` 32 k / 64 k / 160 k, `M_cap` 16 k /
457
+ 60 k), sharing weights. The host picks the smallest bucket that covers the frame. Each variant costs about
458
+ 6–9 MiB of trace region (256 MiB available) plus its activations.
459
+ - PandaSet-like frames (N = 146 k) gain nothing. The readback shrinks with N too (item 9).
460
+ 5. **K1 throughput (trace −10..−14 ms; owned shared kernel).**
461
+ - 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
462
+ independent of the row count. So per-segment output overhead dominates: 60,000 segments of one row each
463
+ through an 8-row staging ring.
464
+ - Fixes, in order:
465
+ - batch output rows per worker (contiguous segments → one NOC write per block);
466
+ - move the accumulator from L1 into the RISC-V's local memory (PORT_LOG known issue);
467
+ - with item 4, write only M_cap-bucket segments;
468
+ - a compute-engine reduction is the long form.
469
+ - Bit-exactness must stay (max of integer keys). The shared-kernel rule (PLAN §5.1.5) applies: re-vendor and
470
+ re-run K1's host / device tests.
471
+ 6. **Image branch DRAM slicing and glue (trace −8..−14 ms; MK-ImageStage).**
472
+ - The 128 × 1024 convs spend 12.6 ms in conv kernels but 9.1 ms in DRAM slicing (padded slice + slice write) and
473
+ 4.8 ms in halo / move / resharding. The fp32-map glue adds the following:
474
+ - 46 fp32 HardSwish: 6.3 ms;
475
+ - the casts to bf16 before each conv: 5.2 ms;
476
+ - fp32 residual adds and K-split sums: 6.8 ms.
477
+ - 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
478
+ can stay height-sharded in L1 (C17 `slicing="l1"`), which drops the slicing.
479
+ - Fold the bf16 cast of the conv input and the HardSwish into one eltwise pass (exact: the same ops).
480
+ - The longer form is a BasicBlock megakernel with halo exchange (MK-ImageStage).
481
+ 7. **Decoder resizes and `fuse1` (trace −5..−8 ms).**
482
+ - Resizes take 9.0 ms: reshape 2.6, tilize / untilize 2.3, transpose 0.4 and matmul 2.9.
483
+ - `decoder.fuse1` takes 12.5 ms in 157 programs: a 5-part K-split over five 128 × 1024 maps, with 3.6 ms of
484
+ K-split adds and 3.9 ms of slicing.
485
+ - Fix: run the resize matmuls in TILE layout end to end, so no reshape / (un)tilize is needed. That also lifts
486
+ the C18 fp32 limitation, a precision-margin option. Accumulate the K-split parts in the conv (dest / L1
487
+ accumulation) instead of fp32 adds.
488
+ 8. **pixel2point gathers (trace −2..−4 ms).**
489
+ - Today: untilize of each map (1.2 ms), the bf16 embedding (3.5 ms), a typecast to fp32 (1.5 ms).
490
+ - The two-term linear already treats a bf16 input as exact (`lo = 0`). So feed the gather output as bf16 straight
491
+ into item 1's operand (no cast). Exact.
492
+ - Later: gather from the TILE map in the MK-PointMLP reader.
493
+ 9. **Host preprocess (e2e −60..−85 ms OT128).**
494
+ - Today 107 ms on PandaSet:
495
+ - plan 41.8 ms: sort, CSR, gather tables, features;
496
+ - fp16 interpolation 37.4 ms;
497
+ - decode 12.9 ms;
498
+ - projection 8.0 ms;
499
+ - crop and unique cells 5.6 ms.
500
+ - The fixes:
501
+ - a C++ / numba implementation of the node's CUDA kernels (projection + interpolation grid in one pass,
502
+ counting-sort plan): about 10–20 ms in all;
503
+ - keep the decode outside the timed request path.
504
+ - The numerics must stay bit-identical to today's host code (float32 / fp16 policies, the documented race
505
+ policies). The host tests and the research goldens check that bit for bit.
506
+ 10. **Host after the device (e2e −15..−25 ms OT128).**
507
+ - Today: unsort 12.8 ms + post 14.8 ms + host untilize 5.0 ms.
508
+ - Fixes: read back row-major (device untilize, or a ROW_MAJOR output), with only N rows (item 4). Fuse the
509
+ un-permute into the argmax pass: take the argmax over the 27 columns on the permuted rows, then scatter
510
+ int8 labels and float scores instead of the 27-column float rows.
511
+ - Exact: the same first-max rule on the same fp32 values.
512
+ 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
513
+ it as `[N_cap / 4, 32]` rows (128 B pages) and view or reshape on the device. A TILE upload would also drop the
514
+ input tilize (0.36 ms).
515
+ 12. **Throughput by pipelining (frames/s; latency unchanged).**
516
+ - Device b2b allows 4.72 frames/s (OT128) and 7.41 (QT128). A synchronous request gives 2.81 / 4.09 frames/s on
517
+ PandaSet.
518
+ - Overlap the host pre / post of frames k ± 1 with the device work of frame k in the server.
519
+ - 2CQ staging can hide the 5.4 ms upload (and, with double-buffered outputs, the 7.4 ms readback). It is worth
520
+ measuring only after items 1–2 shrink the replay.
521
+ 13. **Megakernel candidates (D19, PLAN §5.2 / §5.3).**
522
+ - The §5.2 gate is met through DRAM round trips and layout glue, not dispatch. 64 % of the replay is not conv /
523
+ matmul compute, while op-to-op gaps are 0.3 %.
524
+ - Candidates, by measured block time:
525
+ - **MK-PointMLP** (point island, 133.7 ms OT128 / 101.1 ms QT128; items 1–3, 8): first, by far. It is
526
+ row-local, and its weights fit L1;
527
+ - **MK-SegGather** (K1 + the frustum2pixel / pixel2point gathers, 24 ms; items 5, 8): fold the gather tables
528
+ into K1's writer and the pixel2point gather into the point reader;
529
+ - **MK-ImageStage** (stem + stages 1–4 + decoder convs, 46 ms, of which 12.6 ms conv compute; items 6–7):
530
+ band-fused BasicBlocks with halo exchange. The 128 × 1024 maps fit L1 in bf16.
531
+ - A single whole-model kernel is not practical: the fp32 point tensors at `N_cap` (82–164 MB each) do not fit L1.
532
+ MK-PointMLP's row-block streaming is the practical "single" form for 63–75 % of the work.
533
+ 14. **Precision margin (accuracy, not speed; optimization-phase precision item).**
534
+ - The margin on nuScenes 0916 kf40 is 0.41 pt. Items 1–2 change the fp32 accumulation order, so re-check the
535
+ margin after each step.
536
+ - Options to buy margin, measured: `CONV_TERMS=2` +0.09 pt for +37 ms. Unmeasured: fp32 resize outputs (after
537
+ item 7) and fp32 gathers of the 128-channel maps (`ttnn.embedding` is bf16-only; a K1-style generic_op gather).
538
+ - No precision relaxation (bf16 maps, a TF32 point island, HiFi2) is a candidate without new device evidence on
539
+ that frame: each one measured so far fails it.
540
+
541
+ ## Raw logs and files
542
+
543
+ All under `/home/ubuntu/experiments/tt-models/`:
544
+ - `logs/frnet/m5_baseline/job1.log` (+ `job1.rcs`): the device job (steps 2–5). `try0/`: the first attempt (suite log
545
+ `suite_r1.log`; the other steps failed on the weights path). `job1_prereboot.log`: the job queued before the server
546
+ reboot, which never started;
547
+ - `logs/frnet/m5_baseline/bench_eth1cq_{ot128,qt128}.{json,log}`: the stage benches;
548
+ - `logs/frnet/m5_baseline/matrix_{ot128,qt128}_{eth-1cq,eth-2cq,worker-2cq,worker-1cq,eth-1cq-repeat}.{json,log}`;
549
+ - `logs/frnet/m5_baseline/profile_{ot128,qt128}.log`, `profile_summary_{ot128,qt128}.{json,md}`,
550
+ `tt_perf_report_{ot128,qt128}.txt`, `grp_{ot128,qt128}.txt`;
551
+ - `logs/frnet/m5_baseline/visualizer.log`, `host_suite.log`; `logs/frnet/{pcc,pcc_small,e2e,latency}_{ot128,qt128}_m5.json`;
552
+ - `logs/frnet/m5_baseline/inputs/`: the synthetic scans, their calibrations and CPU references;
553
+ - device profiles: `generated/profiler/frnet_baseline/reports/2026_10_09_13_56_53/ops_perf_results_2026_10_09_13_56_53.csv`
554
+ (OT128) and `generated/profiler/frnet_baseline_qt128/reports/2026_10_09_13_59_21/ops_perf_results_2026_10_09_13_59_21.csv`
555
+ (QT128);
556
+ - ttnn-visualizer report: `generated/ttnn_visualizer/frnet_baseline/` (`db.sqlite` 26 MB, `config.json`; OT128 PandaSet,
557
+ one eager forward. The 7.5 GB graph JSON went to `/dev/shm` and was deleted after the import).
OPT_REPORT.md ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frnet-p150 optimization report (p150, ETH dispatch, 12×10 grid)
2
+
3
+ **Status: baseline port; optimization pending.** This is the first public release: the functional port, measured
4
+ once (`OPT_BASELINE.md`, baseline commit `0dd52b2`, 2026-10-09) and not optimized yet. No optimization round has run,
5
+ so there is no step, no rejected attempt and no hang to report, and every number below is the baseline. The device
6
+ graph runs as one metal trace per serve profile: 1,104 programs per frame for `ot128` (Autoware's default sensor
7
+ 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
8
+ device throughput). A synchronous `model()` call takes 292.6 ms (ot128) / 221.1 ms (qt128) on the shipped synthetic
9
+ sample and 355.7 / 244.3 ms on the PandaSet frame; the device is 60 % (ot128) / 55 % (qt128) of it. The ranked
10
+ backlog at the end says where the time goes. The largest item is the precision island of the point-wise layers
11
+ (63 % of the ot128 replay, 75 % of qt128), whose cost is DRAM traffic of fp32 partial sums, not compute; then the
12
+ image-branch convs and glue (46 ms) and the custom K1 segment-max kernel (16.9 ms). The binding accuracy constraint
13
+ of every step is the nuScenes 0916 key-frame 40 frame on ot128: 98.907 % argmax agreement against the 98.5 % gate,
14
+ a 0.41-point margin.
15
+
16
+ All numbers: `code/scripts/bench.py` (median of 100 warm iterations, batch 1, the shipped sample
17
+ `code/tt_frnet/samples/synthetic_<variant>.npz` and the staged PandaSet 019 frame 40), ETH dispatch, 1 CQ, 12×10 grid,
18
+ the serve.env precision knobs (`FRNET_POINT_TERMS=2`: two-term bf16 operands with fp32 accumulation in the point-wise
19
+ layers; `FRNET_MAP_DTYPE=float32`: fp32 image-branch maps with bf16 conv operands; `FRNET_CONV_TERMS=1`), HiFi4 with
20
+ fp32 accumulation everywhere, AICLK 1350 MHz, unless marked otherwise. Device profile: `code/scripts/profile_ops.py`
21
+ under the device profiler (one traced replay between signposts). Accuracy gates: `OPT_BASELINE.md` "How to run".
22
+
23
+ ## Summary
24
+
25
+ | | baseline `0dd52b2` (2026-10-09) | **final (= baseline: no round yet)** |
26
+ |---|---|---|
27
+ | device trace, one blocking forward (ot128 / qt128; shipped sample) | 210.02 / 133.53 ms | same |
28
+ | back-to-back traces (ot128 / qt128; PandaSet 019 f40) | 211.64 / 134.87 ms (4.72 / 7.41 frames/s) | same |
29
+ | e2e `model()` p50 (p99), shipped sample: ot128 / qt128 | 292.6 (299.4) / 221.1 (233.5) ms | same |
30
+ | e2e `model()` p50 (p99), PandaSet 019 f40 (107,146 points): ot128 / qt128 | 355.7 (367.0) / 244.3 (258.7) ms | same |
31
+ | host preprocess / H2D / D2H / un-permute / postprocess, ot128, PandaSet | 107.2 / 5.41 / 7.43 / 12.8 / 14.8 ms | same |
32
+ | device programs per frame (distinct programs in the trace): ot128 / qt128 | 1,104 (227) / 759 (174) | same |
33
+ | kernel sum / span / op-to-op gaps, ot128 | 212.64 / 213.27 / 0.63 ms | same |
34
+ | kernel sum / gaps, qt128 (span unusable: a profiler artefact, see `OPT_BASELINE.md`) | 134.81 / 0.44 ms | same |
35
+ | 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 |
36
+ | `from_pretrained` load, warm JIT cache | 6.5 / 6.9 s (capture 2.6 / 2.0 s) | same |
37
+
38
+ Host-side rows move with the load of the shared host (load average 4.1–6.6 during the baseline job); the device rows
39
+ repeat to 0.01 ms. The release re-check (`VERIFICATION_2026-10-09.md`) reproduced the device rows on the release
40
+ commit.
41
+
42
+ ## Steps (chronological; each row is one commit)
43
+
44
+ | step | commit | what | trace / b2b ms (ot128; qt128) | e2e ms (shipped sample: ot128; qt128) | accuracy | revert switch |
45
+ |---|---|---|---|---|---|---|
46
+ | 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) | – |
47
+
48
+ The release commit after the baseline changed no model numerics and no device code: the shipped sample became a
49
+ synthetic scan per serve profile (generated by `code/scripts/make_sample.py`, byte-identical to the baseline's
50
+ synthetic bench inputs), the PandaSet sample moved out of the shipped `code/` (staged until PandaSet samples are
51
+ approved), test hardening (`test_public_frames` fails on a missing golden and pins the frame counts; a new
52
+ `test_shipped_sample` gate), docs and demo media. The device suite was re-run on it with identical values for every
53
+ existing gate (`VERIFICATION_2026-10-09.md`).
54
+
55
+ ## Round 1
56
+
57
+ Not started. The optimization phase follows the publication of every model of the collection (`research/PLAN.md`
58
+ §4.1, §5): each step is one commit with one `FRNET_*` A/B knob, keeps the frozen gates, and the precision policy
59
+ (PLAN.md §9.3) may be relaxed per module only with gate evidence. **Every numerics change is judged on the binding
60
+ frame first: nuScenes 0916 kf40, ot128, 98.907 % against the 98.5 % gate.** A change of the fp32 accumulation order
61
+ is not bit-identical to today, so it re-runs the full device suite.
62
+
63
+ ### Findings (measured)
64
+
65
+ The baseline profile (`OPT_BASELINE.md` "Device profile") is the starting point:
66
+
67
+ - 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 %).
68
+ Cutting programs pays only through the kernel time of the programs removed; a megakernel's dispatch saving is at
69
+ most 0.6 ms.
70
+ - **The point-wise layers kept at higher precision are 63 % of ot128 (133.7 ms) and 75 % of qt128 (101.1 ms).** Each
71
+ linear splits its fp32 input into two bf16 terms and runs 2–3 bf16 matmuls whose fp32 partials are summed in fp32.
72
+ 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
73
+ partials, 19.5 ms of typecasts for the splits. `decoder.point_fuse1` alone is 39.9 ms.
74
+ - **K1 `segment_reduce`: 16.9 ms** (8 % of ot128, 11 % of qt128): 2.39 ms per 128-channel site almost independent of
75
+ the row count, so per-segment output overhead dominates.
76
+ - **Image-branch convs and glue: 46 ms on ot128**, of which 12.6 ms conv kernels; the rest is ttnn's DRAM slicing of
77
+ the 128 × 1024 convs (9.1 ms), halo / move / resharding (4.8 ms), 46 fp32 HardSwish (6.3 ms), bf16 casts before
78
+ every conv (5.2 ms), fp32 residual and K-split adds (6.8 ms), resizes (9.0 ms).
79
+ - Every point-wise op runs on the profile's `N_cap` rows (160,000 / 120,000) and K1 writes all 60,000 segments,
80
+ whatever the frame: the nuScenes frames (N = 20–28 k) cost the same as PandaSet (N = 146 k).
81
+ - L1 holds nothing between ops; DRAM use peaks at 218.6 MB per bank at the first partial-sum add of
82
+ `decoder.point_fuse1`.
83
+ - Dispatch / CQ matrix (back-to-back): ot128 ETH-1CQ 209.95, ETH-2CQ 211.32, WORKER 211.56 ms; qt128 133.44, 134.25,
84
+ 153.71 ms. ETH and 1 CQ stay pinned (D14): on qt128's narrow grid WORKER's 11×10 partitioning costs 20.3 ms.
85
+ - The precision policy is how the port reaches the gate. Measured in the port (PORT_LOG M1–M3): an fp32 × fp32 point
86
+ island (the device's TF32-like matmul) replays in 107 ms but gives 97.50 % on the binding frame; bf16 image maps
87
+ 98.28 %; `CONV_TERMS=2` buys +0.09 pt for +37 ms.
88
+
89
+ ### Megakernel / fusion work
90
+
91
+ None yet. The candidates by measured block time are listed in the backlog (item 13).
92
+
93
+ ## Known hangs (all rounds)
94
+
95
+ | when (UTC) | command | cause | status |
96
+ |---|---|---|---|
97
+ | – | – | 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) | – |
98
+
99
+ ## Rejected / not kept
100
+
101
+ None yet in the optimization phase. Configurations measured during the port that are **not** the default, for
102
+ accuracy (`PORT_LOG.md` M1–M3; `OPT_BASELINE.md` "The precision cost of the policy"; ot128, binding frame nuScenes
103
+ 0916 kf40):
104
+
105
+ | configuration | replay | binding frame | why not |
106
+ |---|---:|---:|---|
107
+ | fp32 × fp32 point island (`POINT_TERMS=1`), bf16 maps | 107 ms | 97.50 % | fails the 98.5 % gate |
108
+ | two-term point island, bf16 maps (`MAP_DTYPE=bfloat16`) | 201 ms | 98.28 % | fails |
109
+ | two-term point island, bf16 maps, `CONV_TERMS=2` | 244 ms | 98.35 % | fails, slower |
110
+ | two-term point island, fp32 maps fed to the convs directly (`MAP_DTYPE=float32_direct`) | 217 ms | 98.13 % | fails |
111
+ | two-term point island, fp32 maps, `CONV_TERMS=2` | 249 ms | 99.00 % | passes, +37 ms for +0.09 pt of margin |
112
+
113
+ ## Profile at the end (trace replay)
114
+
115
+ The baseline profile (`OPT_BASELINE.md`): ot128 1,104 programs, kernel sum 212.64 ms, span 213.27 ms; qt128 759
116
+ programs, 134.81 ms. Top sinks by op type (ot128): Matmul 64.1 ms (30 %), BinaryNg 59.4 ms (28 %), Typecast 26.8 ms
117
+ (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.
118
+
119
+ ## Remaining backlog (gains on the ot128 trace of 211.6 ms, PandaSet, unless marked e2e; estimates, not measurements)
120
+
121
+ Each gain is the measured kernel or host time of the work an item removes, with a range for what replaces it. Items
122
+ overlap, so the gains do not add up. qt128 follows the same ranking. From `OPT_BASELINE.md` "Ranked optimization
123
+ opportunities":
124
+
125
+ 1. **Two-term point linears as one K-concatenated matmul per layer (trace −60..−80 ms).** One matmul over
126
+ `[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
127
+ and their fp32 partial adds. Same exact bf16 products; only the fp32 summation order changes (re-run the gates).
128
+ 2. **MK-PointMLP, a row-block-streamed point MLP megakernel (trace −100..−115 ms beyond today, overlapping 1; D19).**
129
+ The point layers are row-local except the gathers and K1; their bf16 weight terms fit L1 (at most 655 KB). Chain
130
+ the encoder, the point fuse and the head per row block in L1, with the two-term split on the fly.
131
+ 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
132
+ terms (exact: the same RNE casts) instead of 4 programs per input.
133
+ 4. **`N_cap` / `M_cap` buckets (trace −100..−160 ms on frames with N ≤ 32–64 k, e.g. every nuScenes frame).** Two or
134
+ three trace variants per profile sharing weights; the host picks the smallest that covers the frame. PandaSet-like
135
+ frames (N = 146 k) gain nothing.
136
+ 5. **K1 throughput (trace −10..−14 ms; owned shared kernel).** Batch output rows per worker, accumulator in the
137
+ RISC-V local memory, then a compute-engine reduction; bit-exactness stays.
138
+ 6. **Image-branch DRAM slicing and glue (trace −8..−14 ms; MK-ImageStage).** Keep the stride-1 stage chain
139
+ 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.
140
+ 7. **Decoder resizes and `fuse1` (trace −5..−8 ms).** Resize matmuls in TILE layout end to end (also lifts the C18 fp32
141
+ limitation), K-split accumulation inside the conv.
142
+ 8. **pixel2point gathers (trace −2..−4 ms).** Feed the bf16 gather output straight into item 1's operand (exact).
143
+ 9. **Host pre-processing (e2e −60..−85 ms).** 107 ms on PandaSet: a C++ / numba implementation of the node's CUDA
144
+ kernels (projection + interpolation grid in one pass, counting-sort plan), bit-identical to today's host code.
145
+ 10. **Host after the device (e2e −15..−25 ms).** Row-major readback of only N rows; argmax over the permuted rows, then
146
+ scatter labels and scores instead of 27-column float rows (exact: the same first-max rule).
147
+ 11. **H2D `features` layout (e2e −3.5 ms).** 160,000 pages of 32 bytes at 1.24 GB/s: upload as 128-byte rows.
148
+ 12. **Throughput by pipelining (frames/s; latency unchanged).** Overlap the host stages of neighbouring frames with the
149
+ device work; 2 CQ staging once items 1–2 shrink the replay.
150
+ 13. **Megakernel candidates (D19, PLAN §5.2 / §5.3)** by measured block time: MK-PointMLP (133.7 / 101.1 ms; first),
151
+ MK-SegGather (K1 + gathers, 24 ms), MK-ImageStage (46 ms). A whole-model kernel is not practical: the fp32 point
152
+ tensors at `N_cap` (82–164 MB each) do not fit L1.
153
+ 14. **Precision margin (accuracy, an optimization-phase precision item).** The 0.41-point margin on nuScenes 0916 kf40
154
+ (ot128) is the narrowest of the release. Items 1–2 change the accumulation order, so re-check it after each step.
155
+ Measured option: `CONV_TERMS=2` (+0.09 pt, +37 ms). Unmeasured: fp32 resize outputs (after item 7), fp32 gathers of
156
+ the 128-channel maps. No relaxation (bf16 maps, a TF32 point island, HiFi2, bfp8) is a candidate without new device
157
+ evidence on that frame.
PYTHON.md ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python API: FRNet (Autoware lidar_frnet) on Blackhole
2
+
3
+ Use this API from Python code (a pipeline, a notebook, a ROS 2 node wrapper). You do not need the HTTP server: the
4
+ API and the server share the decoders, the device graph and the post-processing, so the outputs and the speed are
5
+ the same.
6
+
7
+ ## Install
8
+
9
+ Install the package on top of an environment that already has `ttnn` (a tt-metal `python_env` at `44d66500520`
10
+ with `patches/tt-metal-eth-dispatch.patch`, or the tt-model container). From the root of the model repository (the
11
+ directory that holds `pyproject.toml`, `README.md` and `code/`):
12
+
13
+ ```bash
14
+ pip install -e . # the Python API
15
+ pip install -e ".[server,test]" # + the HTTP server and the tests
16
+ ```
17
+
18
+ The pip project is the repository's top-level `pyproject.toml`; it installs the package from `code/tt_frnet` (there is no
19
+ `pyproject.toml` inside `code/`, because the container build copies `code/` over the tt-metal tree).
20
+
21
+ The package carries `tt_frnet.ttaw`, the shared code of the Autoware ports to Blackhole (device open, trace runner,
22
+ decoders, model base class, HTTP app, the K1 `segment_reduce` kernel), vendored at the version recorded in
23
+ `code/tt_frnet/ttaw/VENDORED.json` (0.20.0).
24
+
25
+ | You want to run | Extras |
26
+ |---|---|
27
+ | the Python API | none |
28
+ | the HTTP server (`tt_frnet.server.app`, see `SERVING.md`) | `server` |
29
+ | host tests (no device; device tests are skipped): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests` | `server,test` |
30
+ | device tests: `python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py code/tt_frnet/tests/test_e2e_device.py` | `test` |
31
+
32
+ ## Quickstart
33
+
34
+ Run from the repository root (the sample paths are relative to it):
35
+
36
+ ```python
37
+ from tt_frnet import FRNet
38
+
39
+ with FRNet.from_pretrained(device_id=0) as model: # variant="qt128" for the Hesai QT128 profile
40
+ out = model("code/tt_frnet/samples/synthetic_ot128.npz", # one sweep in the sensor frame
41
+ calibration={"preset": "synthetic_ot128"}) # its sensor -> base_link transform (ego crop box)
42
+
43
+ print(out.class_counts()) # points per class (raw + interpolated)
44
+ labels, scores, xyz = out.label_ids, out.scores, out.points[:, :3]
45
+ ```
46
+
47
+ `examples/quickstart.py` runs this snippet and writes `quickstart.json` (the `/predict` body) and `quickstart_bev.png`
48
+ (a bird's-eye view of the p150 classes next to the stored CPU reference of the shipped sample).
49
+
50
+ ## `FRNet.from_pretrained(...)`
51
+
52
+ ```python
53
+ FRNet.from_pretrained(
54
+ model_id=None, # HF repo or a local directory with the weights files; default AutowareFoundation/lidar_frnet
55
+ *,
56
+ revision=None, # default for the default repo: the validated commit a53a1c11b0b (tag v2.0)
57
+ variant=None, # load-time variant: "ot128" (Hesai OT128, Autoware's default) | "qt128"; default $FRNET_VARIANT or "ot128"
58
+ device_id=None, # chip to open; default $TT_DEVICE_ID or 0
59
+ device=None, # an already-opened ttnn device (tt_frnet.device.open_device); close() does not close it
60
+ dispatch=None, # "eth" (p150 target, 12x10 grid) | "worker" (A/B only, 11x10) | "auto"; default $FRNET_DISPATCH or "eth"
61
+ num_command_queues=None, # default $FRNET_NUM_CQS or 1
62
+ weights_dir=None, # explicit local weights directory; no Hub access
63
+ warmup_variants="default", # trace variants to capture now; see "Warm-up"
64
+ verbose=False,
65
+ ) -> FRNet
66
+ ```
67
+
68
+ What it does: resolves the weights first, so a Hub problem never claims the chip (`weights_dir` >
69
+ `$FRNET_WEIGHTS_DIR` > a local `model_id` directory > the HF snapshot at the pinned revision, restricted to
70
+ `frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml`, with an offline fallback to the cache), checks
71
+ the sha256 of the variant's ONNX against the pinned one, reads the variant's `ml_package_frnet_<variant>.param.yaml`
72
+ (FOV, frustum and interpolation grids, the 27 class names, the palette, the point / cell profiles), opens the chip
73
+ (ETH dispatch, 12×10, 1 CQ, `l1_small_size` 64 KiB, `trace_region_size` 256 MiB: `DEVICE_DEFAULTS` in
74
+ `tt_frnet/device.py`, overridable with `FRNET_*`), builds the graph, then compiles and captures the metal trace. If
75
+ ETH dispatch cannot open (tt-metal without the patch), it warns and falls back to WORKER dispatch.
76
+
77
+ `variant` picks one of the two networks of the weights repo. Each is tied to its sensor (Autoware's `sensor_model`):
78
+
79
+ | variant | sensor | FOV up / down | frustum grid (H × W) | interpolation grid | points (min / max) | unique cells (min / max) |
80
+ |---|---|---|---|---|---|---|
81
+ | `ot128` (default) | Hesai OT128 | +15° / −25° | 128 × 1024 | 128 × 4096 | 5,000 / 160,000 | 3,000 / 60,000 |
82
+ | `qt128` | Hesai QT128 | +52.6° / −52.6° | 128 × 256 | 128 × 1024 | 5,000 / 120,000 | 3,000 / 60,000 |
83
+
84
+ Precision knobs (read once at load from the environment, pinned in the package's `serve.env`; the defaults are the
85
+ validated configuration and every number of the model card uses them): `FRNET_POINT_TERMS=2` (each point-wise linear
86
+ as two-term bf16 operands, about 16 mantissa bits, fp32 accumulation), `FRNET_MAP_DTYPE=float32` (fp32 image-branch
87
+ maps, bf16 conv operands), `FRNET_CONV_TERMS=1`. Other values are A/B switches for the optimization phase; they fail
88
+ the frozen accuracy gates (`OPT_BASELINE.md`, "The precision cost of the policy").
89
+
90
+ ## Warm-up
91
+
92
+ `from_pretrained` returns a warm model: it builds the graph of the variant, runs it once eagerly on an empty frame
93
+ (this first run compiles the kernels and uploads the weights) and captures it as ONE metal trace, so no call compiles
94
+ anything. There are no shape buckets: every frame is padded to the profile's fixed device shapes (`N_cap` points =
95
+ 160,000 / 120,000, `M_cap` cells = 60,000), so one trace serves every input of the profile.
96
+
97
+ - Measured (2026-10-09, the shipped sample): with an empty JIT cache the load took 156.6 s for ot128 and
98
+ 151.9 s for qt128 (firmware and every kernel compiled); with that cache warm, in a new process,
99
+ 7.5 s and 6.6 s. The trace is captured during the load, so the first call costs about as
100
+ 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).
101
+ - `warmup_variants="none"` (server: `FRNET_WARMUP=none`) skips the capture at load; call `model.warmup()` before the
102
+ first frame (it is idempotent).
103
+ - The JIT cache is tt-metal's (`~/.cache/tt-metal-cache`, or `TT_METAL_CACHE`); inside the container it is
104
+ `~/.cache/tt-model/frnet-p150/cache` of the host.
105
+
106
+ ## Call: `model(points, calibration=..., **kwargs)`
107
+
108
+ | Argument | Type | Description |
109
+ |---|---|---|
110
+ | `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" |
111
+ | `calibration` | `{"T_base_link_from_sensor": <transform>}`, `{"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 |
112
+ | `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`) |
113
+ | `filter_classes` | comma-separated class names, `""` = none, default `"drivable_surface"` | Autoware's `filter.classes` (at most 16); an unknown name is refused |
114
+
115
+ Both knobs only change `filter_keep`; the class decision (`label_ids`, `scores`) never depends on them. They are
116
+ host-only (no device shape changes), the defaults reproduce Autoware's `frnet.param.yaml`, and a bad value raises
117
+ `tt_frnet.io.InputError` (HTTP 400) before the device runs. `model.info["runtime_params"]` (and the server's `/info`)
118
+ lists them with their defaults.
119
+
120
+ ### Input types
121
+
122
+ - Point clouds: a path (`.bin` raw float32 rows -- pass `fields=` if not `x, y, z, intensity`; `.npy`; `.npz`;
123
+ `.pcd` ascii / binary / binary_compressed), raw bytes with `fmt=`, an (N, C) numpy array or torch tensor, a
124
+ structured array, the JSON envelope of `/predict`, or `tt_frnet.io.PointCloud`. The frame is the **sensor** frame
125
+ (Autoware's `~/input/pointcloud` before any transform), one sweep, not motion-compensated.
126
+ - Rows with a non-finite x / y / z are dropped by the decoder; `input_index` refers to the decoded rows.
127
+ - Calibration transforms: a 4×4 (or 3×4) row-major matrix, `{"translation": [x, y, z], "rotation_wxyz": [w, x, y, z]}`,
128
+ or Autoware's `{x, y, z, roll, pitch, yaw}`. With the crop box on and no transform the call is refused (the node would
129
+ skip every frame because its TF lookup fails); the error names both fixes.
130
+ - `sweeps`, `images`, `stream` and `inputs` are refused: FRNet takes one sweep and its calibration only.
131
+ - A frame outside the variant's profile (input points, points after interpolation, or unique cells out of the table
132
+ above) is not an error: as the node, which logs and publishes nothing, the call returns an empty output whose
133
+ `meta["skipped"]` holds the node's message, and nothing runs on the device.
134
+
135
+ ## Output
136
+
137
+ `model(...)` returns a `tt_frnet.FRNetSegmentation` (a `ttaw.outputs.Segmentation3D`); `out.to_dict()` is exactly the
138
+ `POST /predict` body (SERVING.md section 3.2). The point order is the node's: the input points that survive the ego
139
+ crop box, in input order, then the points Autoware's interpolation appends (D4: published as Autoware does).
140
+
141
+ | field | type | meaning |
142
+ |---|---|---|
143
+ | `label_ids` | uint8 `[N]` | Autoware's `class_id` (first maximum of the 27 probabilities, as `fill_cloud_kernel`); names in `class_names` |
144
+ | `scores` | float32 `[N]` | Autoware's `probability`: the softmax probability of that class |
145
+ | `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) |
146
+ | `filter_keep` | bool `[N]` | membership of Autoware's `~/output/pointcloud/filtered` cloud (False = removed by the class filter) |
147
+ | `num_points_raw` | int | the first `num_points_raw` rows are input points, the rest are interpolated |
148
+ | `input_index` | int32 `[num_points_raw]` | the decoded input row of every raw output point |
149
+ | `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) |
150
+ | `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 |
151
+ | `timing_ms` | dict | `preprocess`, `device` (staging + H2D + trace + D2H + un-permute), `postprocess`, `total` of this call |
152
+
153
+ `out.class_counts()` gives the points per class (raw + interpolated); `out.to_dicts()` one row per class
154
+ (`label`, `label_id`, `points`); `len(out)` the number of output points. The node's visualization cloud is
155
+ `model.config.palette` indexed by `label_ids` (`tt_frnet.host.postprocess.visualization_rgb`).
156
+
157
+ ## Lifetime and information
158
+
159
+ - `model.close()` releases the trace and the device tensors and closes the chip if the model opened it; idempotent.
160
+ `with` calls it for you; an unclosed model is closed when Python exits.
161
+ - `model.info`: weights (repo, tag, revision, path), device (`dispatch`, `grid`, `cores`, `num_command_queues`,
162
+ `fallback`), variant, warm variants, warm-up times, runtime parameter defaults, the variant config (`frnet`), the
163
+ trace and graph descriptions.
164
+ - Calls from several threads are safe: the device calls are serialised by a lock. One model per process per chip.
165
+ - `tt_frnet.viz.render_bev(points, label_ids, palette=...)` and `render_compare(...)` draw bird's-eye views (PIL, no
166
+ OpenCV).
167
+ - `model.device_probs(plan)` returns the device's `pred_probs` `(N, 27)` of a prepared frame (tests and tools; call it
168
+ under `model._lock`).
169
+
170
+ ## Speed
171
+
172
+ Warm calls, batch 1, ETH dispatch, 1 CQ, 12x10, the serve.env precision knobs (`code/scripts/bench.py`, 100
173
+ iterations; the numbers of `OPT_BASELINE.md`, 2026-10-09, on a shared host whose load moves the host stages):
174
+
175
+ | stage, p50 | ot128, shipped sample | ot128, PandaSet 019 f40 | qt128, shipped sample | qt128, PandaSet 019 f40 |
176
+ |---|---:|---:|---:|---:|
177
+ | `model(path)` end to end | 292.6 ms | 355.7 ms | 221.1 ms | 244.3 ms |
178
+ | host pre-processing (decode, crop, projection, fp16 interpolation, unique cells, device plan) | 55.3 ms | 107.2 ms | 54.2 ms | 83.5 ms |
179
+ | H2D (9 inputs, 8.6 / 6.2 MB) | 5.42 ms | 5.41 ms | 4.04 ms | 4.00 ms |
180
+ | device trace, one forward | 210.02 ms | 211.72 ms | 133.53 ms | 134.91 ms |
181
+ | D2H (fp32 `pred_probs`) | 7.65 ms | 7.43 ms | 5.80 ms | 5.55 ms |
182
+ | 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 |
183
+
184
+ - 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
185
+ device throughput. A synchronous call gives 2.81 / 4.09 frames/s on that frame.
186
+ - The device time barely depends on the frame: every point-wise op runs on the profile's `N_cap` rows. The host
187
+ stages grow with the cloud.
188
+ - The shipped samples are synthetic scans: ot128 53,411 points (N = M = 52,613 after the crop, no interpolated point),
189
+ qt128 45,783 points (N = 69,553 after interpolation, M = 18,926). PandaSet 019 frame 40 is a 107,146-point Pandar64
190
+ sweep (N = 145,961 / M = 40,691 on ot128); it is not shipped.
191
+ - `OPT_REPORT.md` ranks what the optimization phase will change (the two-term point layers, K1, the image-branch glue,
192
+ shape buckets, the host pre-processing).
193
+
194
+ ## Tests
195
+
196
+ From the repository root, with `code/` on `PYTHONPATH` (or the package installed):
197
+
198
+ - Host (no device, no ttnn call): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests`. Inside the
199
+ tt-models workspace the suite runs on its fake ttnn (`-p fake_ttnn_plugin`), which also enables `test_tt_host.py`;
200
+ tests that need workspace-only oracles or data skip elsewhere, and FAIL inside the workspace when that data is
201
+ missing. `test_reference_cpu.py` needs onnxruntime.
202
+ - Device (one p150): `python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py
203
+ code/tt_frnet/tests/test_e2e_device.py` (per-module PCC against the fp32 CPU reference on replay outputs, the K1
204
+ segment-max sites included; end-to-end agreement with ONNX Runtime on 17 OT128 / 5 QT128 frames, the shipped samples
205
+ against their stored references, replay == eager, API == `/predict`). The public-frame gates run only inside the
206
+ tt-models workspace, where their data lives: PandaSet, nuScenes and Autoware's sample rosbag, of which only PandaSet
207
+ may be redistributed.
208
+ - The gates are frozen in `tests/*.gates.json` at the first green run and are never loosened.
209
+
210
+ ## Limits
211
+
212
+ - Batch 1 on the chip; one model per process; one frame (one sweep) per call; one variant per model.
213
+ - The device input has a fixed size per profile (`N_cap` 160,000 / 120,000 points, `M_cap` 60,000 cells): a frame
214
+ outside Autoware's profile ranges is skipped exactly as the node skips it.
215
+ - Label names 22 / 23: Autoware's `class_names` read `debris` / `stroller`, but the weights were trained with
216
+ stroller = 22 and debris = 23 (AWML config). The port keeps Autoware's names for parity.
217
+ - Numerics: the device's per-point probabilities differ slightly from the fp32 reference, so points near a class
218
+ boundary can take the other class (98.9-99.6 % of the points agree on the public frames, see the model card).
219
+ - Not ported: Autoware's `PointCloud2` layouts and the filtered cloud's `output_format` conversion; the API takes
220
+ arrays or files and returns arrays.
README.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ tags:
3
+ - blackhole
4
+ - p150
5
+ - tt-dit-server
6
+ - tt-model-cache
7
+ - tt-model-container
8
+ - tenstorrent
9
+ - ttnn
10
+ - tt-metal
11
+ - tt-nn
12
+ - autoware
13
+ - autonomous-driving
14
+ - lidar
15
+ - point-cloud
16
+ - semantic-segmentation
17
+ - frnet
18
+ - range-view
19
+ - tt-model-catalog
20
+ pipeline_tag: image-segmentation
21
+ license: apache-2.0
22
+ license_link: https://huggingface.co/AutowareFoundation/lidar_frnet
23
+ base_model:
24
+ - AutowareFoundation/lidar_frnet
25
+ ---
26
+
27
+ # frnet-p150
28
+
29
+ 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).
30
+ 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)
31
+
32
+ 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.
33
+
34
+ Packaged and published with [tt-model-manager](https://github.com/tenstorrent/tt-model-manager) 0.1.0 (manifest schema 5.1).
35
+
36
+ ## Quickstart (Python)
37
+
38
+ 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.
39
+
40
+ ```bash
41
+ hf download changh95/frnet-p150 --exclude "image/*" --local-dir frnet-p150 && cd frnet-p150
42
+ pip install -e . # adds numpy<2, pillow, pyyaml, onnx, huggingface_hub; ttnn and torch come from tt-metal
43
+ pip install -e ".[server,test]" # optional: the HTTP server and the tests
44
+ ```
45
+
46
+ Run the snippet from the model repo root: the sample and its calibration preset are paths relative to it.
47
+
48
+ ```python
49
+ from tt_frnet import FRNet
50
+
51
+ with FRNet.from_pretrained(device_id=0) as model: # variant="qt128" for the Hesai QT128 profile
52
+ 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
53
+ calibration={"preset": "synthetic_ot128"}) # its sensor -> base_link transform, for the node's ego crop box
54
+
55
+ print(out.class_counts()) # points per class (raw + interpolated)
56
+ labels, scores, xyz = out.label_ids, out.scores, out.points[:, :3]
57
+ ```
58
+
59
+ - `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.
60
+ - 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.
61
+ - The `with` block releases the trace and closes the chip. Without `with`, call `model.close()`.
62
+ - 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.
63
+
64
+ | | |
65
+ |---|---|
66
+ | **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). |
67
+ | **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)`. |
68
+ | **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. |
69
+ | **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. |
70
+
71
+ - 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).
72
+ - `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.
73
+ - 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.
74
+ - One model uses one chip; calls from several threads are serialised.
75
+ - 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).
76
+
77
+ ## Serving (HTTP)
78
+
79
+ ```bash
80
+ tt-model pull changh95/frnet-p150 --with-weights
81
+ 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
82
+ python3 code/tt_frnet/server/client.py --points code/tt_frnet/samples/synthetic_ot128.npz --calib-preset synthetic_ot128 --out req.json
83
+ curl -s localhost:20000/predict -H 'Content-Type: application/json' -d @req.json
84
+ tt model stop changh95/frnet-p150
85
+ ```
86
+
87
+ - The image does not contain the weights. `--with-weights` puts them in your HF cache.
88
+ - The server uses port 20000 (or the next free port). It is ready when the log shows `Application startup complete`.
89
+ - Two serve profiles (`tt-model profiles changh95/frnet-p150`): `ot128` (the default) and `qt128`. Each serves one variant.
90
+ - `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.
91
+ - `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.
92
+
93
+ ```json
94
+ {"model": "frnet-p150", "frame_id": "sensor", "num_points": 52613, "num_points_raw": 52613,
95
+ "labels": {"format": "npz", "key": "labels", "dtype": "uint8", "shape": [52613], "data": "..."},
96
+ "scores": {"format": "npz", "key": "scores", "dtype": "float32", "shape": [52613], "data": "..."},
97
+ "points": {"format": "npz", "key": "points", "dtype": "float32", "shape": [52613, 4], "data": "..."},
98
+ "filter_keep": {"format": "npz", "key": "filter_keep", "dtype": "bool", "shape": [52613], "data": "..."},
99
+ "input_index": {"format": "npz", "key": "input_index", "dtype": "int32", "shape": [52613], "data": "..."},
100
+ "class_names": ["drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "..."],
101
+ "class_counts": {"drivable_surface": 2913, "other_flat_surface": 20118, "sidewalk": 5, "manmade": 22898, "vegetation": 6526, "car": 13, "semi_trailer": 140, "...": 0},
102
+ "meta": {"variant": "ot128", "num_points_input": 53411, "cropped_by_ego_box": 798, "num_points_interpolated": 0, "num_unique_coors": 52613,
103
+ "ego_crop": {"enabled": true, "bounds": [-1.53, -1.15, -0.1, 6.1, 1.15, 3.1], "reference_frame": "base_link", "...": "..."},
104
+ "plan": {"n": 52613, "m": 52613, "n_cap": 160000, "m_cap": 60000, "...": "..."},
105
+ "filter": {"classes": ["drivable_surface"], "class_probability_threshold": 0.05, "kept": 44747}, "...": "..."},
106
+ "timing_ms": {"decode": 24.711, "preprocess": 47.664, "device": 229.164, "postprocess": 5.018, "model_call": 281.879, "total": 310.689}}
107
+ ```
108
+
109
+ - `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.
110
+ - 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.
111
+
112
+ ## Demo
113
+
114
+ 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.
115
+
116
+ | OT128 profile: p150 vs the CPU reference | QT128 profile: p150 vs the CPU reference |
117
+ |:---:|:---:|
118
+ | ![](media/frnet_synthetic_ot128_tt_vs_cpu.png) | ![](media/frnet_synthetic_qt128_tt_vs_cpu.png) |
119
+
120
+ 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.
121
+
122
+ | PandaSet 019, frame 40: front camera and bird's-eye view, p150 classes vs the mapped dataset labels | PandaSet 029, frame 40 |
123
+ |:---:|:---:|
124
+ | ![](media/frnet_ot128_pandaset_019_40_tt.png) | ![](media/frnet_ot128_pandaset_029_40_tt.png) |
125
+ | **PandaSet 019, frame 40: p150 vs the CPU reference** | **PandaSet 019, frames 00-75 (every 5th frame)** |
126
+ | ![](media/frnet_ot128_pandaset_019_40_tt_vs_cpu.png) | ![](media/frnet_ot128_pandaset_019_bev_tt.gif) |
127
+ | **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** |
128
+ | ![](media/frnet_ot128_nuscenes_scene-0103_20_tt_NC.png) | ![](media/frnet_ot128_nuscenes_scene-0916_40_tt_vs_cpu_NC.png) |
129
+
130
+ 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).
131
+
132
+ ## Demo & Performances
133
+
134
+ 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.
135
+
136
+ | Metric | Performance |
137
+ |---|---:|
138
+ | 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 %) |
139
+ | 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 %) |
140
+ | 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 % |
141
+ | 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) |
142
+ | 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 |
143
+ | 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) |
144
+ | Python `model()` call, PandaSet frame (107,146 points; not shipped) | OT128 355.7 ms · QT128 244.3 ms p50 |
145
+ | 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) |
146
+ | Served client round trip, loopback | OT128 328.1 ms · QT128 247.3 ms median |
147
+ | Device trace, one blocking forward (shipped sample) | OT128 **210.02 ms** · QT128 **133.53 ms** |
148
+ | 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) |
149
+ | 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 |
150
+ | `from_pretrained` load: empty JIT cache / warm cache | OT128 156.6 s / 7.5 s · QT128 151.9 s / 6.6 s |
151
+
152
+ 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).
153
+
154
+ 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.
155
+
156
+ ## Caveats
157
+
158
+ - 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.
159
+ - 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.
160
+ - **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.
161
+ - 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.
162
+ - Documented deviations from the node:
163
+ - 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;
164
+ - 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;
165
+ - the pre-processing, the argmax and the class filter run on the host (bit-identical to the CPU reference); the network on the device;
166
+ - 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`;
167
+ - 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.
168
+ - 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`).
169
+ - 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.
170
+ - 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.
171
+ - 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.
172
+ - `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.
173
+ - 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.
174
+ - Not an OpenAI-compatible API; `GET /v1/models` is a stub so the tt-model ready card does not 404.
175
+ - p150 power was not measured, so no efficiency comparison is made.
176
+
177
+ ## Licensing
178
+
179
+ - 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.
180
+ - 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.
181
+ - Port and serving code (`code/`): Apache-2.0. `patches/tt-metal-eth-dispatch.patch` modifies tt-metal (Apache-2.0).
182
+ - 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.
183
+ - Demo media (`media/`, sources and changes in [`media/ATTRIBUTION.md`](media/ATTRIBUTION.md)):
184
+ - 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.
185
+ - 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).
186
+ - `frnet_synthetic_*` renders: Apache-2.0.
187
+ - 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.
188
+
189
+ ## Provenance
190
+
191
+ These are the exact sources the container image was built from:
192
+
193
+ | component | built from |
194
+ | --- | --- |
195
+ | 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) |
196
+ | 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…`) |
197
+ | Autoware reference | autoware_universe [`9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd`](https://github.com/autowarefoundation/autoware_universe/tree/9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd) (`perception/autoware_lidar_frnet`, package 0.53.0) |
198
+ | 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 |
199
+ | `code/` digest (image) | `f0b62790446a51a8` (sha256, first 16 hex digits; `built.code_sha256` of `tt_kernel_manifest.json`) |
200
+ | image | `tt-model/frnet-p150:85cbf6cc72b9` (`sha256:85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c`) |
201
+ | 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)) |
202
+ | built | 2026-10-09T17:04:34+00:00 by tt-model 0.1.0 |
SERVING.md ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Serving FRNet (Autoware lidar_frnet) on Blackhole with tt-model-manager
2
+
3
+ This repo is the authoring source of the **tt-model container package** `changh95/frnet-p150`
4
+ (`kind: tt-dit-server`, schema 5.1): `tt-model.yaml` is the manifest, `code/` the port, and
5
+ `code/tt_frnet/server/app.py` the ASGI app uvicorn runs. Weights are a pinned pointer, never baked into the image.
6
+ The device open, the decoders, the Python-API contract and the HTTP contract come from `code/tt_frnet/ttaw/`, the
7
+ shared package of the Autoware ports vendored into this repo (`ttaw/VENDORED.json` records its version and file
8
+ hashes; it is not edited here).
9
+
10
+ | item | value |
11
+ |---|---|
12
+ | 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) |
13
+ | 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) |
14
+ | Autoware reference | `perception/autoware_lidar_frnet` @ autoware_universe `9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd` |
15
+ | app | `tt_frnet.server.app:app` (uvicorn; the ASGI lifespan does weights -> device -> graph -> trace capture) |
16
+ | 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)) |
17
+ | hardware | one Blackhole p150 (`hardware: p150`, `mesh_device: P150`, `TT_MESH_SHAPE=1x1`), 12×10 compute grid |
18
+ | 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` |
19
+
20
+ ## 1. Run on the HOST (hardware validation, no Docker)
21
+
22
+ The tt-metal `python_env` has ttnn and torch; put the HTTP stack in a side directory instead of installing into it.
23
+ On the shared workspace box every command that opens the chip goes through the device lock (`bin/devrun`).
24
+
25
+ ```bash
26
+ ROOT=/home/ubuntu/experiments/tt-models
27
+ source $ROOT/bin/tt-env.sh # TT_METAL_HOME, PYTHONPATH, tt-metal python_env
28
+ mkdir -p /tmp/frnet-http && uv pip install --python $TT_METAL_HOME/python_env/bin/python \
29
+ --target /tmp/frnet-http fastapi uvicorn onnx
30
+ cd $ROOT/bundles/frnet-p150
31
+ export PYTHONPATH=$PWD/code:$TT_METAL_HOME:$TT_METAL_HOME/ttnn:/tmp/frnet-http
32
+ export HF_MODEL=AutowareFoundation/lidar_frnet TT_MODEL_WEIGHTS_REVISION=a53a1c11b0b28d31fd50a03abef5db0e830a5b3c TT_MESH_SHAPE=1x1
33
+
34
+ # host tests (no device; the device tests are skipped; ttnn is the fake of common/tests/host: a real ttnn host
35
+ # tensor opens the chip, so only devrun jobs may build one)
36
+ PYTHONPATH=$ROOT/research/packaging/scripts:$PYTHONPATH TT_VISIBLE_DEVICES=none \
37
+ python -m pytest -q -p no:cacheprovider -p fake_ttnn_plugin code/tt_frnet/tests
38
+ # device tests and the server, under the lock
39
+ $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
40
+ # (FRNET_VARIANT=qt128 for the second profile; the smoke test posts the shipped sample of the served variant)
41
+ $ROOT/bin/devrun -t 1800 -- bash -c 'python -m uvicorn --host 127.0.0.1 --port 20000 --lifespan on tt_frnet.server.app:app & \
42
+ 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'
43
+ ```
44
+
45
+ Boot log landmarks (they drive `tt-model serve`'s progress view, `boot_progress.py` TT_DIT_PHASES):
46
+ `Loading weights` -> `Opening device` -> `Warming up: capturing trace ...` -> `Warmup complete` -> uvicorn
47
+ `Application startup complete`. A cold JIT cache compiles kernels for minutes; later boots take seconds. Startup
48
+ failures raise and uvicorn exits non-zero (no CPU fallback). SIGTERM: the lifespan releases the traces and closes the
49
+ chip (within `tt-model stop`'s 120 s budget).
50
+
51
+ Offline override (no Hub): `FRNET_WEIGHTS_DIR=<dir holding frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml>`.
52
+
53
+ ## 2. Package, serve, push (Docker)
54
+
55
+ Docker 29 on this box is **rootful** (`/var/lib/docker`), and the user's session may not carry the `docker` group:
56
+ wrap every docker-using command in `sg docker -c "..."` (no `docker-env.sh` is needed here; the reference bundles'
57
+ `docker-env.sh` was the authors' rootless-Docker box). Run every `tt-model` command **from this directory**
58
+ (`source.tt_metal` and `extra_code[].root: code` are CWD-relative) and point `--out` outside it.
59
+
60
+ ```bash
61
+ ROOT=/home/ubuntu/experiments/tt-models; cd $ROOT/bundles/frnet-p150
62
+ TTM=$HOME/.local/share/uv/tools/tt-model/bin/python
63
+
64
+ # offline validation (seconds; no docker, no device) -- must print VALID
65
+ $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)"
66
+
67
+ # build (one at a time on the box: the absolute lock file): ~35 min cold, ~12 min after a code/ change (the C++
68
+ # build re-runs, ccache-warm, whenever code/ changes), ~1 min for a manifest- or lock-only change
69
+ 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
70
+ # provenance: the base-image digests this build resolved (tt-model's FROM tags float) -> build_info.json (published)
71
+ sg docker -c "python3 $ROOT/research/packaging/scripts/record_build_info.py --staged $ROOT/build/frnet-p150 --bundle ."
72
+
73
+ # serve + smoke + stop, all inside ONE device-lock window, once per serve profile (`tt-model profiles
74
+ # $ROOT/build/frnet-p150/tt_kernel_manifest.json` lists them; serve returns once READY and leaves the container
75
+ # running; the script's trap saves the container log and always runs `tt-model stop`, also when devrun's timeout
76
+ # fires: -k 150 > the 120 s grace). No token reaches the public weights download. Evidence: logs/smoke/ (--log-dir).
77
+ for prof in ot128 qt128; do
78
+ $ROOT/bin/devrun -t 3600 -k 150 -- env -u HF_TOKEN -u HUGGING_FACE_HUB_TOKEN \
79
+ sg docker -c "bash code/scripts/container_smoke.sh $ROOT/build/frnet-p150 20000 --profile $prof"
80
+ done
81
+
82
+ # publish: overlay the hand-written docs, the pip project and the build record onto the staged dir, then push
83
+ # (HF_TOKEN only from the environment)
84
+ rsync -a README.md SERVING.md OPT_BASELINE.md OPT_REPORT.md VERIFICATION_*.md tt-model.yaml pyproject.toml build_info.json \
85
+ examples media patches $ROOT/build/frnet-p150/
86
+ cp code/PYTHON.md $ROOT/build/frnet-p150/PYTHON.md 2>/dev/null || true
87
+ tt-model push $ROOT/build/frnet-p150 --publish
88
+ ```
89
+
90
+ The smoke test fails unless `/info` reports ETH dispatch, the 12x10 grid and what the staged package pins for the
91
+ profile (`FRNET_DISPATCH`, `FRNET_NUM_CQS`, `FRNET_VARIANT`, the weights revision), and it compares the
92
+ served output of the shipped sample of the profile (`samples/synthetic_<variant>.npz`) with its stored CPU-reference
93
+ output (`samples/synthetic_<variant>.<variant>.reference.json`; >= 98.5 % label and filter agreement).
94
+
95
+ `serve` publishes the first free port from 20000 and exports into the container: `HF_MODEL=AutowareFoundation/lidar_frnet`,
96
+ `MESH_DEVICE=P150`, `TT_MESH_SHAPE=1x1`, `TT_MODEL_WEIGHTS_REVISION=a53a1c11b0b28d31fd50a03abef5db0e830a5b3c`, then `serve.env`. The HF cache is
97
+ mounted at `/hf` (rw), the JIT cache at `/cache` (host `~/.cache/tt-model/frnet-p150/cache`), and the container
98
+ sees only its own chip (`--device /dev/tenstorrent/<n>`). tt-cli users: `tt serve changh95/frnet-p150` /
99
+ `tt model stop changh95/frnet-p150` (point tt at this tt-model with
100
+ `tt config set tools.override.tt-model ~/.local/bin/tt-model` or `TT_TOOL_BIN_TT_MODEL`).
101
+
102
+ What `push` does to the repo: `code/` and `image/` on the Hub become exactly the staged trees (`extra_code.paths` +
103
+ `models/common/lightweightmodule.py`); every top-level file of the staged dir is uploaded (the overlaid README
104
+ replaces the generated card; `pyproject.toml` and `build_info.json` arrive the same way, never through `code/`);
105
+ top-level files already on the Hub and not in the staged dir are kept.
106
+
107
+ ## 3. The request / response contract
108
+
109
+ | route | purpose |
110
+ |---|---|
111
+ | `GET /health` | `{"status": "ok" \| "starting" \| "error", "model", "device": {"dispatch", "grid", "cores", "device_id"}, "error"}`, always 200; `ok` only after warm-up |
112
+ | `GET /v1/health` | same as `/health` (tt-model's hint for non-chat packages points here) |
113
+ | `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 |
114
+ | `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 |
115
+ | `POST /predict` | one frame -> the model output (below) |
116
+
117
+ ### 3.1 Request (`application/json`; unknown fields are a 422)
118
+
119
+ | field | type | meaning |
120
+ |---|---|---|
121
+ | `points` | object | `{"format": "bin" \| "npy" \| "npz" \| "pcd" \| "list", "data": <base64 file bytes>, "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 |
122
+ | `calibration` | object | `{"T_base_link_from_sensor": <transform>}` (the static transform the node looks up once for its ego crop box) or `{"preset": "<name>"}` (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 |
123
+ | `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) |
124
+ | `output_format` | str | `json` (default) or `npz` (adds lossless base64 NPZ arrays) |
125
+ | `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 |
126
+
127
+ Transforms (`T_<to>_from_<from>`): a 4×4 (or 3×4) row-major matrix, `{"translation": [x, y, z], "rotation_wxyz": [w, x, y, z]}`
128
+ (or `rotation_xyzw`), or `{"x", "y", "z", "roll", "pitch", "yaw"}` (Autoware `sensor_kit_calibration.yaml`, tf2 RPY).
129
+ Intrinsics: 3×3 K, 3×4 P, or `{"fx", "fy", "cx", "cy"}`. Base64 may be standard or URL-safe, wrapped, with or without a
130
+ `data:` prefix.
131
+
132
+ ### 3.2 Response (200)
133
+
134
+ The shipped OT128 sample (`synthetic_ot128.npz` with the preset `synthetic_ot128`), abridged (`data` fields cut):
135
+
136
+ ```json
137
+ {"model": "frnet-p150", "frame_id": "sensor", "num_points": 52613, "num_points_raw": 52613,
138
+ "labels": {"format": "npz", "key": "labels", "dtype": "uint8", "shape": [52613], "data": "..."},
139
+ "scores": {"format": "npz", "key": "scores", "dtype": "float32", "shape": [52613], "data": "..."},
140
+ "points": {"format": "npz", "key": "points", "dtype": "float32", "shape": [52613, 4], "data": "..."},
141
+ "filter_keep": {"format": "npz", "key": "filter_keep", "dtype": "bool", "shape": [52613], "data": "..."},
142
+ "input_index": {"format": "npz", "key": "input_index", "dtype": "int32", "shape": [52613], "data": "..."},
143
+ "class_names": ["drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "..."],
144
+ "class_counts": {"drivable_surface": 2913, "other_flat_surface": 20118, "sidewalk": 5, "manmade": 22898, "vegetation": 6526, "car": 13, "semi_trailer": 140, "...": 0},
145
+ "meta": {"variant": "ot128", "num_points_input": 53411, "cropped_by_ego_box": 798, "num_points_interpolated": 0, "num_unique_coors": 52613,
146
+ "ego_crop": {"enabled": true, "bounds": [-1.53, -1.15, -0.1, 6.1, 1.15, 3.1], "reference_frame": "base_link", "...": "..."},
147
+ "plan": {"n": 52613, "m": 52613, "n_cap": 160000, "m_cap": 60000, "...": "..."},
148
+ "filter": {"classes": ["drivable_surface"], "class_probability_threshold": 0.05, "kept": 44747}, "...": "..."},
149
+ "timing_ms": {"decode": 24.711, "preprocess": 47.664, "device": 229.164, "postprocess": 5.018, "model_call": 281.879, "total": 310.689}}
150
+ ```
151
+
152
+ - Output point order (the node's): the input points that survive the ego crop box, in input order (after non-finite
153
+ rows are dropped), then the interpolated points Autoware appends; the first `num_points_raw` rows are raw points and
154
+ `input_index` maps them back to the decoded input rows. The interpolated points are published, as Autoware does.
155
+ - `labels` is Autoware's `class_id`, `scores` its `probability` (the softmax probability of that class), `points` the
156
+ x, y, z, intensity of every output point in the sensor frame, `filter_keep` the membership of Autoware's
157
+ `~/output/pointcloud/filtered` cloud.
158
+ - An out-of-profile frame (input points, points after interpolation or unique cells outside the variant's ranges:
159
+ 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
160
+ node's message in `meta.skipped`: the node publishes nothing for such a frame. Nothing runs on the device.
161
+ - `meta` also has the crop and network-input counts (`num_points_input`, `cropped_by_ego_box`, `num_points_interpolated`,
162
+ `num_unique_coors`, ...), the device plan sizes and the applied filter.
163
+
164
+ `timing_ms`: `decode` (base64 + parsing), `preprocess`, `device` (staging + H2D + trace + D2H + un-permute),
165
+ `postprocess`, `model_call`, `total` (server side, after the body arrived).
166
+
167
+ ### 3.3 Errors
168
+
169
+ **400** undecodable / malformed input (bad base64, wrong row size, no calibration while the ego crop box is on, unknown
170
+ 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
171
+ (or when the boot failed), **500** `inference failed: <ExceptionType>: <message>` if the device call raises.
172
+
173
+ ### 3.4 Environment the app reads (lifespan only, never at import)
174
+
175
+ | var | set by | meaning / default |
176
+ |---|---|---|
177
+ | `HF_MODEL` | launcher (`weights.repo`) | weights repo id; default `AutowareFoundation/lidar_frnet` |
178
+ | `TT_MODEL_WEIGHTS_REVISION` | launcher (`weights.revision`) | pinned commit; `TT_WEIGHTS_REVISION` (`serve.env`) is the same for older tt-model |
179
+ | `FRNET_WEIGHTS_DIR` | you (offline) | local weights directory; overrides the Hub |
180
+ | `TT_MESH_SHAPE` | launcher (`runtime.mesh_shape_env`) | `1x1`; anything else -> RuntimeError at startup |
181
+ | `TT_DEVICE_ID` | you | chip to open, default 0 |
182
+ | `FRNET_DISPATCH` | `serve.env` | `eth` (default) \| `worker` (A/B only) \| `auto` (ETH if the patch is present) |
183
+ | `FRNET_NUM_CQS` | `serve.env` | `1` |
184
+ | `FRNET_VARIANT` | `serve.env` / profile | `ot128` (profile `ot128`) or `qt128` (profile `qt128`) |
185
+ | `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) |
186
+ | `FRNET_WARMUP` | you | JSON list of warm-up variants, `default` or `none` |
187
+ | `FRNET_TRACE_REGION`, `FRNET_L1_SMALL`, `FRNET_WORKER_L1_SIZE` | you | device-open overrides (validated values: `DEVICE_DEFAULTS` in `device.py`) |
188
+ | `FRNET_MAX_BODY_MB` | you | request size guard, default 256 |
189
+ | `TT_METAL_VISIBLE_DEVICES`, `MESH_DEVICE` | `serve.env` / launcher | `0`, `P150` (informational) |
190
+
191
+ Server-side pipeline: JSON -> `tt_frnet.io` decoders -> `FRNet.__call__` under one lock: host pre-processing ported
192
+ from `perception/autoware_lidar_frnet` (ego crop, float32 projection, fp16 interpolation grid, unique cells, profile
193
+ checks, the device plan: sorted points, CSR offsets, gather tables, 8-d features) -> 9 H2D copies into persistent
194
+ device inputs -> `execute_trace` (point MLP, K1 segment max, frustum CNN, point head, softmax) -> one D2H of the fp32
195
+ probabilities -> un-permute -> host post-processing (first-max argmax, probability, class filter) ->
196
+ `Output.to_dict()`.
197
+
198
+ ## 4. Caveats
199
+
200
+ - Batch 1, one sweep per request; concurrent clients queue on the lock.
201
+ - Fixed device shapes per profile: `N_cap` 160,000 (ot128) / 120,000 (qt128) points and `M_cap` 60,000 cells; every
202
+ point-wise op runs on all `N_cap` rows, so the device time hardly depends on the frame. Frames outside Autoware's
203
+ profile ranges are skipped as the node skips them (200, empty output, `meta.skipped`).
204
+ - One variant per process: serve `--profile qt128` for the Hesai QT128 network. The profiles share the image.
205
+ - Warm-up captures every trace variant inside the lifespan, so READY means warm; the first cold boot pays the ttnn JIT.
206
+ - Weights are pinned by sha in `weights.revision`, exported by the launcher and repeated in `serve.env`;
207
+ `snapshot_download(..., revision=<sha>)` is a cache hit after `serve`'s pre-download and falls back to
208
+ `local_files_only=True` if the Hub is unreachable.
209
+ - The runtime image has no host C/C++ compiler: device kernels JIT-compile (sfpi ships in the image), host-side C
210
+ helpers must have a numpy fallback.
211
+ - `tt-model curl` and the ready card's `/v1/models` hint are OpenAI-shaped and are not this API; use the routes above.
VERIFICATION_2026-10-09.md ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frnet-p150: independent verification, 2026-10-09
2
+
3
+ ## Verdict
4
+
5
+ **PASS.** This is the light verification pass of the first publish (`research/PLAN.md` §6.3): the frozen gates
6
+ re-run, the baseline numbers re-checked, and every device-open path audited for ETH dispatch. It was made by the agent
7
+ that wrote the release docs, which is neither the porter, nor the adversarial verifier of `VERIFY_PORT.md`, nor the
8
+ baseline agent, on the release-code commit `04c39b5` (the model code of the baseline `0dd52b2`; the shipped sample
9
+ became a synthetic scan per serve profile, the PandaSet sample is staged and not shipped, test hardening; the docs
10
+ commit that follows changes `make_demo.py`'s render captions and the docs only). The device runs ran on 2026-10-09
11
+ 15:53-16:10 UTC in one `bin/devrun` window (a first queueing attempt died while waiting for the lock, when the
12
+ workspace's `bin/devrun` was edited under it; it had not touched the device). Against the baseline commit `0dd52b2`:
13
+
14
+ - **Frozen gates:** the device suite (`test_pcc_device.py` + `test_e2e_device.py`, one pytest) passed **14 / 14**
15
+ tests. Every existing gate passes with values **bit-identical** to the baseline run (the `_m5` and `_m5docs` reports:
16
+ 2,144 leaves compared, 0 differ; timings excluded), including the binding frame: OT128 min agreement 98.907 %
17
+ (nuScenes 0916 kf40) against the 98.5 % gate. The two new tests (`test_shipped_sample`, and `test_public_frames`
18
+ now requiring all 17 / 5 frames) passed; the four new shipped-sample gates were frozen at this first green run
19
+ (label agreement OT128 0.99550, QT128 0.99623; filtered-cloud agreement 0.99772 / 0.99872; threshold 0.985).
20
+ - **Baseline re-check:** the device rows of `OPT_BASELINE.md` repeat to within 0.03 ms (OT128 210.02 / 209.95 ms
21
+ 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
22
+ rows within the load noise (OT128 `model()` 292.6 -> 289.3 ms p50 on the synthetic sample, 355.7 -> 360.1 ms on
23
+ PandaSet, at load average 4.1).
24
+ - **Shipped samples:** the shipped `synthetic_<variant>.npz` are byte-identical to the baseline's synthetic bench
25
+ inputs (`cmp`), so the baseline's synthetic rows are the shipped sample's. p150 vs the stored CPU reference:
26
+ OT128 99.550 % of 52,613 points, QT128 99.623 % of 69,553, the same through `bench.py`, the device test, the
27
+ quickstart, `make_demo.py` and the served smoke test.
28
+ - **Public data (113 frames per profile, production API):** pooled class agreement OT128 PandaSet 99.66 % / nuScenes
29
+ 99.19 %, QT128 99.29 % / 99.44 %; identical scored rows to the CPU reference on all 226 frame-profile pairs, no
30
+ profile skip. 3 pairs fall below 98.5 % (not gated; see "Review findings"). Mapped-label mIoU on the p150 equals the
31
+ CPU reference's within 0.002.
32
+ - **ETH dispatch:** every device-open path opens ETH / 1 CQ / 12×10 by default; every run of this pass printed
33
+ `dispatch eth`, `grid 12x10`, `cores 120`, `fallback null`, `eth_patch true` (both serve profiles, API and server).
34
+ - **No numerics change** and no new bug: nothing in the model code was changed by this pass.
35
+
36
+ ## Setup
37
+
38
+ | item | value |
39
+ |---|---|
40
+ | Hardware | one Blackhole p150b, ETH dispatch, 1 CQ, 12×10 compute grid (120 cores), AICLK 1350 MHz median (min 1343) during the benches |
41
+ | tt-metal | `44d66500520` with `patches/tt-metal-eth-dispatch.patch` (sha256 `08d0ddf6…`, byte-identical to the workspace copy) |
42
+ | 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…`) |
43
+ | Verified commit | `04c39b5` (ttaw 0.20.0, `common` `89dec49`, `source_dirty: false`) |
44
+ | Baseline commit | `0dd52b2` (`OPT_BASELINE.md`) |
45
+ | 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) |
46
+ | 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 |
47
+ | 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 |
48
+ | 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`) |
49
+ | Host | AMD EPYC VM, 8 vCPUs, shared with other agents' jobs (load average 4.1-6.1 during the runs) |
50
+ | Logs | `logs/frnet/m5_docs/` of the workspace (job scripts in `scripts/`) |
51
+
52
+ ## Measured baseline vs re-check
53
+
54
+ All times in ms, p50 of 100 iterations (b2b: 5 rounds of 50 replays). Baseline: `OPT_BASELINE.md` (`0dd52b2`);
55
+ re-check: `code/scripts/bench.py` with the baseline's settings on `04c39b5`
56
+ (`logs/frnet/m5_docs/bench_recheck_{ot128,qt128}.json`).
57
+
58
+ | row | baseline | re-check |
59
+ |---|---:|---:|
60
+ | OT128, shipped sample: trace, one replay / back-to-back | 210.02 / 209.948 | 210.00 / 209.946 |
61
+ | OT128, PandaSet 019 f40: trace / back-to-back | 211.72 / 211.643 | 211.73 / 211.639 |
62
+ | QT128, shipped sample: trace / back-to-back | 133.53 / 133.434 | 133.50 / 133.429 |
63
+ | QT128, PandaSet 019 f40: trace / back-to-back | 134.91 / 134.866 | 134.92 / 134.856 |
64
+ | OT128 H2D / D2H, shipped sample | 5.42 / 7.65 | 5.42 / 7.56 |
65
+ | OT128 host pre-processing, shipped sample / PandaSet | 55.3 / 107.2 | 54.7 / 107.4 |
66
+ | OT128 `model()` p50, shipped sample / PandaSet | 292.6 / 355.7 | 289.3 / 360.1 |
67
+ | QT128 `model()` p50, shipped sample / PandaSet | 221.1 / 244.3 | 205.4 / 241.5 |
68
+ | served `/predict` `timing_ms.total`, OT128 / QT128 (median of 50) | – | 310.4 / 228.9 |
69
+ | served client round trip, loopback | – | 328.1 / 247.3 |
70
+ | `from_pretrained`, empty JIT cache / warm cache, OT128 | – / 6.5 s | 156.6 / 7.5 s |
71
+ | `from_pretrained`, empty JIT cache / warm cache, QT128 | – / 6.9 s | 151.9 / 6.6 s |
72
+
73
+ The served and cold-load rows are new for the card (the baseline measured neither). The first two calls after a load
74
+ 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
75
+ holds 682 MB (OT128) / 644 MB (QT128) after the load.
76
+
77
+ ## Accuracy gate results
78
+
79
+ `logs/frnet/m5_docs/suite.log`; reports `logs/frnet/{pcc,pcc_small,e2e,latency}_{ot128,qt128}_m5docs.json`.
80
+
81
+ | check | gate | OT128 | QT128 |
82
+ |---|---|---|---|
83
+ | every gated module tap (PCC vs the fp32 reference, PandaSet sample, replay outputs) | ≥ 0.999 | min 0.999729 (`point_fusion1`) | min 0.999649 (`attention4`) |
84
+ | `pred_probs` PCC / raw-point agreement, PandaSet sample (staged) | ≥ 0.998 / ≥ 98.5 % | 0.999942 / 99.581 % | 0.999844 / 99.295 % |
85
+ | public frames (17 / 5): min `pred_probs` PCC | ≥ 0.998 | 0.999688 | 0.999805 |
86
+ | public frames: min raw-point agreement | ≥ 98.5 % | **98.907 %** (nuScenes 0916 kf40) | 99.158 % (PandaSet 029 f40) |
87
+ | shipped sample vs its stored reference body: labels / filtered-cloud mask (new) | ≥ 98.5 % | 99.550 % / 99.772 % | 99.623 % / 99.872 % |
88
+ | replay == eager | bit-exact | 45 / 45 taps | 45 / 45 taps |
89
+ | API == `/predict` handler | identical | yes | yes |
90
+
91
+ Host suite (fake ttnn, gates read-only): **117 passed, 15 skipped** (the 14 device tests and the onnxruntime-only
92
+ test), `logs/frnet/m5_docs/host_suite.log`. Research venv (ONNX Runtime + torch): **85 passed, 2 skipped** (by design),
93
+ `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
94
+ changed (release hygiene of the shipped samples, the top-level `PYTHON.md` copy, the shipped-sample device test).
95
+
96
+ ### Public-frame agreement beyond the gates
97
+
98
+ `code/scripts/make_demo.py run` (the production API, `model(points, calibration=...)`) on every converted public frame
99
+ of `research/frnet/public_data/inputs`, both profiles, against the research ORT runs
100
+ (`logs/frnet/m5_docs/demo/agreement.json`):
101
+
102
+ | set | frames | scored raw points | OT128 pooled (worst frame) | QT128 pooled (worst frame) |
103
+ |---|---:|---:|---:|---:|
104
+ | PandaSet 019 + 029, every 5th frame | 32 | 3,442,737 | 99.66 % (99.35 %) | 99.29 % (98.69 %) |
105
+ | nuScenes v1.0-mini mini_val, all key-frames | 81 | 2,067,589 | 99.19 % (98.37 %) | 99.44 % (98.10 %) |
106
+
107
+ Scored rows equal the reference's on all 226 frame-profile pairs (same crop, same profile decisions); no frame was
108
+ skipped. Mapped-label mIoU (`research/frnet/public_data/scripts/frnet_public_eval.py`, not a paper metric;
109
+ `logs/frnet/m5_docs/miou/`): OT128 PandaSet coarse 0.7136 (p150) vs 0.7146 (CPU), fine 0.4471 vs 0.4479; nuScenes
110
+ coarse 0.4827 vs 0.4831, fine 0.4515 vs 0.4531 (fine = mean over the classes with at least 1,000 labelled points,
111
+ `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.
112
+
113
+ ## Disclosed numerics changes
114
+
115
+ None: the model code, the precision knobs and the kernels are those of the baseline, and every existing gate value is
116
+ bit-identical to it. The release changed what ships (samples), the tests (stricter) and the docs.
117
+
118
+ ## Review findings
119
+
120
+ - **VERIFY_PORT round 1, low findings** (all resolved or disclosed):
121
+ 1. stale PORT_LOG header and latency: fixed (ttaw 0.20.0 @ `89dec49`; the superseded first-cut latency replaced by
122
+ the current numbers; the duplicated known-issue paragraphs removed);
123
+ 2. `test_public_frames` skipped missing goldens silently: inside the workspace a missing golden, research frame or
124
+ staged sample now FAILS (`tests/_data.py need()`), and the test asserts 17 / 5 frames;
125
+ 3. narrow precision margin (0.41 pt on nuScenes 0916 kf40): disclosed on the card (Caveats) and in `OPT_REPORT.md`
126
+ as the binding constraint and backlog item 14;
127
+ 4. end-to-end latency depends on the host load: the card quotes the stage bench with its load conditions and the
128
+ device rows separately;
129
+ 5. `test_stage_pcc` replays on its capture inputs: unchanged (hardening note); input rewriting is covered by
130
+ `test_small_graph`, `test_public_frames` (17 / 5 frames through one trace per profile) and the new `test_shipped_sample`.
131
+ - **New, low:** on the wider (ungated) public set, 3 of 226 frame-profile pairs agree below 98.5 %: nuScenes
132
+ 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
133
+ out-of-domain scene (32-beam HDL-32E, Singapore) as the binding gated frame, are consistent with its 0.41-pt margin,
134
+ and are disclosed on the card. They do not touch a frozen gate. The optimization phase should add them to the
135
+ frames it judges precision changes on.
136
+ - **Gates:** no threshold was changed; the gate JSON gained only the four shipped-sample entries (0.985, the PLAN.md 2.8
137
+ argmax gate). No test compares TT with TT except replay == eager.
138
+ - **Samples and data:** `code/` ships only the two synthetic scans with their presets and references
139
+ (`test_only_redistributable_samples_ship`); no PandaSet, nuScenes or rosbag data is tracked; the PandaSet sample
140
+ lives in the git-ignored `staging_samples_pandaset/`. nuScenes appears only as two renders labelled non-commercial.
141
+
142
+ ## p150 ETH-dispatch compliance
143
+
144
+ Every path that opens the device goes through `ttaw.device.open_device`, whose default is
145
+ `DispatchCoreConfig(ETH)` (falling back to WORKER only with a `RuntimeWarning`, or never with `allow_fallback=False`):
146
+
147
+ | path | how it opens | dispatch | evidence |
148
+ |---|---|---|---|
149
+ | `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` |
150
+ | 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 |
151
+ | device tests (`conftest.py` `device` fixture) | `tt_frnet.device.open_device(allow_fallback=False)` | `eth`, no fallback | suite log |
152
+ | `code/scripts/visualizer_report.py` | `open_device(allow_fallback=False)` | `eth` | baseline log |
153
+ | `code/scripts/diag_conv_precision.py`, `diag_frame_taps.py` | `tt_frnet.device.device_session()` | `eth` default | code read |
154
+ | `tests/test_tt_host.py` | `ttaw.device.open_device(0, dispatch="eth")` on the fake ttnn only | – (no device) | host suite |
155
+
156
+ The grid is never hard-coded: it comes from `device.compute_with_storage_grid_size()` (`tt/layers.py`, K1
157
+ `segment.py`); the only 12×10 literal is the expected-grid warning in `ttaw/device.py` and the smoke test's assertion.
158
+ The image's `verify:` line asserts the patch marker in `tt_metal/impl/dispatch/topology.cpp`.
159
+
160
+ ## Not covered by this pass
161
+
162
+ - The container image: it is built and smoke-tested per serve profile (`ot128`, `qt128`) at packaging
163
+ (`code/scripts/container_smoke.sh`), after this pass.
164
+ - A full adversarial verification of the release (that is `VERIFY_PORT.md` for the port; the next full verification
165
+ follows the first optimization round).
166
+ - TensorRT parity: the reference is the fp32 ONNX Runtime run of the deployed ONNX, not Autoware's fp16 TensorRT engine.
build_info.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema": "ttaw-build-info/1",
3
+ "bundle": "frnet-p150",
4
+ "recorded_at": "2026-10-09T17:15:08Z",
5
+ "image": {
6
+ "tag": "tt-model/frnet-p150:85cbf6cc72b9",
7
+ "digest": "sha256:85cbf6cc72b9c535c2f44cec2b2145c8fb940d214874c23c918adfc66a51769c",
8
+ "built_at": "2026-10-09T17:04:34+00:00",
9
+ "code_sha256": "f0b62790446a51a86fe941bb4367561fecd9c560adc4d02b9b25b1e364358ede",
10
+ "size_bytes": 4071619125,
11
+ "layers": 23
12
+ },
13
+ "base_images": {
14
+ "build": {
15
+ "stage": "prep",
16
+ "ref": "ghcr.io/tenstorrent/tt-metal/tt-metalium/ubuntu-22.04-dev-amd64:latest",
17
+ "digest": "sha256:df9d279c7f85c17c6fad982d196802682d669cca1b7ced9cbaad8181339cd5fc",
18
+ "source": "docker build log",
19
+ "local_tag_matches": false,
20
+ "local_id": "sha256:3fd1e6013e658c65df1bc6b084543e7174d854c15af97373e493df0c1787c393",
21
+ "created": "2026-10-07T01:17:29.186036377Z",
22
+ "note": "the local tag moved after this build; the digest above is the one the image was built from"
23
+ },
24
+ "runtime": {
25
+ "stage": "runtime",
26
+ "ref": "docker.io/library/ubuntu:22.04",
27
+ "digest": "sha256:5ec03bb3441e8b0bf3b4f9cd4629a1ae763010dc3035bb8da3ae6cf026486401",
28
+ "source": "docker build log",
29
+ "local_tag_matches": null,
30
+ "note": "not in the local image store (BuildKit pulled it into its cache)"
31
+ }
32
+ },
33
+ "tt_metal": {
34
+ "sha": "44d66500520fda9f2c7060c0f6b41ec48f7ab37e",
35
+ "describe": "v0.80.0-dev20261006-78-g44d6650052-dirty",
36
+ "dirty": true,
37
+ "scm_version": "0.65.2.dev11169+g44d66500520",
38
+ "mode": "local",
39
+ "remote": "https://github.com/tenstorrent/tt-metal.git",
40
+ "branch": "main",
41
+ "pushed": true
42
+ },
43
+ "tools": {
44
+ "tt_model": "0.1.0",
45
+ "docker": "29.8.2"
46
+ },
47
+ "notes": "tt-model's FROM tags float (build: tt-metalium dev image :latest, runtime: ubuntu:<version>); these are the digests this image was built from. A moved build base costs a cold C++ build."
48
+ }
code/PYTHON.md ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python API: FRNet (Autoware lidar_frnet) on Blackhole
2
+
3
+ Use this API from Python code (a pipeline, a notebook, a ROS 2 node wrapper). You do not need the HTTP server: the
4
+ API and the server share the decoders, the device graph and the post-processing, so the outputs and the speed are
5
+ the same.
6
+
7
+ ## Install
8
+
9
+ Install the package on top of an environment that already has `ttnn` (a tt-metal `python_env` at `44d66500520`
10
+ with `patches/tt-metal-eth-dispatch.patch`, or the tt-model container). From the root of the model repository (the
11
+ directory that holds `pyproject.toml`, `README.md` and `code/`):
12
+
13
+ ```bash
14
+ pip install -e . # the Python API
15
+ pip install -e ".[server,test]" # + the HTTP server and the tests
16
+ ```
17
+
18
+ The pip project is the repository's top-level `pyproject.toml`; it installs the package from `code/tt_frnet` (there is no
19
+ `pyproject.toml` inside `code/`, because the container build copies `code/` over the tt-metal tree).
20
+
21
+ The package carries `tt_frnet.ttaw`, the shared code of the Autoware ports to Blackhole (device open, trace runner,
22
+ decoders, model base class, HTTP app, the K1 `segment_reduce` kernel), vendored at the version recorded in
23
+ `code/tt_frnet/ttaw/VENDORED.json` (0.20.0).
24
+
25
+ | You want to run | Extras |
26
+ |---|---|
27
+ | the Python API | none |
28
+ | the HTTP server (`tt_frnet.server.app`, see `SERVING.md`) | `server` |
29
+ | host tests (no device; device tests are skipped): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests` | `server,test` |
30
+ | device tests: `python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py code/tt_frnet/tests/test_e2e_device.py` | `test` |
31
+
32
+ ## Quickstart
33
+
34
+ Run from the repository root (the sample paths are relative to it):
35
+
36
+ ```python
37
+ from tt_frnet import FRNet
38
+
39
+ with FRNet.from_pretrained(device_id=0) as model: # variant="qt128" for the Hesai QT128 profile
40
+ out = model("code/tt_frnet/samples/synthetic_ot128.npz", # one sweep in the sensor frame
41
+ calibration={"preset": "synthetic_ot128"}) # its sensor -> base_link transform (ego crop box)
42
+
43
+ print(out.class_counts()) # points per class (raw + interpolated)
44
+ labels, scores, xyz = out.label_ids, out.scores, out.points[:, :3]
45
+ ```
46
+
47
+ `examples/quickstart.py` runs this snippet and writes `quickstart.json` (the `/predict` body) and `quickstart_bev.png`
48
+ (a bird's-eye view of the p150 classes next to the stored CPU reference of the shipped sample).
49
+
50
+ ## `FRNet.from_pretrained(...)`
51
+
52
+ ```python
53
+ FRNet.from_pretrained(
54
+ model_id=None, # HF repo or a local directory with the weights files; default AutowareFoundation/lidar_frnet
55
+ *,
56
+ revision=None, # default for the default repo: the validated commit a53a1c11b0b (tag v2.0)
57
+ variant=None, # load-time variant: "ot128" (Hesai OT128, Autoware's default) | "qt128"; default $FRNET_VARIANT or "ot128"
58
+ device_id=None, # chip to open; default $TT_DEVICE_ID or 0
59
+ device=None, # an already-opened ttnn device (tt_frnet.device.open_device); close() does not close it
60
+ dispatch=None, # "eth" (p150 target, 12x10 grid) | "worker" (A/B only, 11x10) | "auto"; default $FRNET_DISPATCH or "eth"
61
+ num_command_queues=None, # default $FRNET_NUM_CQS or 1
62
+ weights_dir=None, # explicit local weights directory; no Hub access
63
+ warmup_variants="default", # trace variants to capture now; see "Warm-up"
64
+ verbose=False,
65
+ ) -> FRNet
66
+ ```
67
+
68
+ What it does: resolves the weights first, so a Hub problem never claims the chip (`weights_dir` >
69
+ `$FRNET_WEIGHTS_DIR` > a local `model_id` directory > the HF snapshot at the pinned revision, restricted to
70
+ `frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml`, with an offline fallback to the cache), checks
71
+ the sha256 of the variant's ONNX against the pinned one, reads the variant's `ml_package_frnet_<variant>.param.yaml`
72
+ (FOV, frustum and interpolation grids, the 27 class names, the palette, the point / cell profiles), opens the chip
73
+ (ETH dispatch, 12×10, 1 CQ, `l1_small_size` 64 KiB, `trace_region_size` 256 MiB: `DEVICE_DEFAULTS` in
74
+ `tt_frnet/device.py`, overridable with `FRNET_*`), builds the graph, then compiles and captures the metal trace. If
75
+ ETH dispatch cannot open (tt-metal without the patch), it warns and falls back to WORKER dispatch.
76
+
77
+ `variant` picks one of the two networks of the weights repo. Each is tied to its sensor (Autoware's `sensor_model`):
78
+
79
+ | variant | sensor | FOV up / down | frustum grid (H × W) | interpolation grid | points (min / max) | unique cells (min / max) |
80
+ |---|---|---|---|---|---|---|
81
+ | `ot128` (default) | Hesai OT128 | +15° / −25° | 128 × 1024 | 128 × 4096 | 5,000 / 160,000 | 3,000 / 60,000 |
82
+ | `qt128` | Hesai QT128 | +52.6° / −52.6° | 128 × 256 | 128 × 1024 | 5,000 / 120,000 | 3,000 / 60,000 |
83
+
84
+ Precision knobs (read once at load from the environment, pinned in the package's `serve.env`; the defaults are the
85
+ validated configuration and every number of the model card uses them): `FRNET_POINT_TERMS=2` (each point-wise linear
86
+ as two-term bf16 operands, about 16 mantissa bits, fp32 accumulation), `FRNET_MAP_DTYPE=float32` (fp32 image-branch
87
+ maps, bf16 conv operands), `FRNET_CONV_TERMS=1`. Other values are A/B switches for the optimization phase; they fail
88
+ the frozen accuracy gates (`OPT_BASELINE.md`, "The precision cost of the policy").
89
+
90
+ ## Warm-up
91
+
92
+ `from_pretrained` returns a warm model: it builds the graph of the variant, runs it once eagerly on an empty frame
93
+ (this first run compiles the kernels and uploads the weights) and captures it as ONE metal trace, so no call compiles
94
+ anything. There are no shape buckets: every frame is padded to the profile's fixed device shapes (`N_cap` points =
95
+ 160,000 / 120,000, `M_cap` cells = 60,000), so one trace serves every input of the profile.
96
+
97
+ - Measured (2026-10-09, the shipped sample): with an empty JIT cache the load took 156.6 s for ot128 and
98
+ 151.9 s for qt128 (firmware and every kernel compiled); with that cache warm, in a new process,
99
+ 7.5 s and 6.6 s. The trace is captured during the load, so the first call costs about as
100
+ 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).
101
+ - `warmup_variants="none"` (server: `FRNET_WARMUP=none`) skips the capture at load; call `model.warmup()` before the
102
+ first frame (it is idempotent).
103
+ - The JIT cache is tt-metal's (`~/.cache/tt-metal-cache`, or `TT_METAL_CACHE`); inside the container it is
104
+ `~/.cache/tt-model/frnet-p150/cache` of the host.
105
+
106
+ ## Call: `model(points, calibration=..., **kwargs)`
107
+
108
+ | Argument | Type | Description |
109
+ |---|---|---|
110
+ | `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" |
111
+ | `calibration` | `{"T_base_link_from_sensor": <transform>}`, `{"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 |
112
+ | `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`) |
113
+ | `filter_classes` | comma-separated class names, `""` = none, default `"drivable_surface"` | Autoware's `filter.classes` (at most 16); an unknown name is refused |
114
+
115
+ Both knobs only change `filter_keep`; the class decision (`label_ids`, `scores`) never depends on them. They are
116
+ host-only (no device shape changes), the defaults reproduce Autoware's `frnet.param.yaml`, and a bad value raises
117
+ `tt_frnet.io.InputError` (HTTP 400) before the device runs. `model.info["runtime_params"]` (and the server's `/info`)
118
+ lists them with their defaults.
119
+
120
+ ### Input types
121
+
122
+ - Point clouds: a path (`.bin` raw float32 rows -- pass `fields=` if not `x, y, z, intensity`; `.npy`; `.npz`;
123
+ `.pcd` ascii / binary / binary_compressed), raw bytes with `fmt=`, an (N, C) numpy array or torch tensor, a
124
+ structured array, the JSON envelope of `/predict`, or `tt_frnet.io.PointCloud`. The frame is the **sensor** frame
125
+ (Autoware's `~/input/pointcloud` before any transform), one sweep, not motion-compensated.
126
+ - Rows with a non-finite x / y / z are dropped by the decoder; `input_index` refers to the decoded rows.
127
+ - Calibration transforms: a 4×4 (or 3×4) row-major matrix, `{"translation": [x, y, z], "rotation_wxyz": [w, x, y, z]}`,
128
+ or Autoware's `{x, y, z, roll, pitch, yaw}`. With the crop box on and no transform the call is refused (the node would
129
+ skip every frame because its TF lookup fails); the error names both fixes.
130
+ - `sweeps`, `images`, `stream` and `inputs` are refused: FRNet takes one sweep and its calibration only.
131
+ - A frame outside the variant's profile (input points, points after interpolation, or unique cells out of the table
132
+ above) is not an error: as the node, which logs and publishes nothing, the call returns an empty output whose
133
+ `meta["skipped"]` holds the node's message, and nothing runs on the device.
134
+
135
+ ## Output
136
+
137
+ `model(...)` returns a `tt_frnet.FRNetSegmentation` (a `ttaw.outputs.Segmentation3D`); `out.to_dict()` is exactly the
138
+ `POST /predict` body (SERVING.md section 3.2). The point order is the node's: the input points that survive the ego
139
+ crop box, in input order, then the points Autoware's interpolation appends (D4: published as Autoware does).
140
+
141
+ | field | type | meaning |
142
+ |---|---|---|
143
+ | `label_ids` | uint8 `[N]` | Autoware's `class_id` (first maximum of the 27 probabilities, as `fill_cloud_kernel`); names in `class_names` |
144
+ | `scores` | float32 `[N]` | Autoware's `probability`: the softmax probability of that class |
145
+ | `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) |
146
+ | `filter_keep` | bool `[N]` | membership of Autoware's `~/output/pointcloud/filtered` cloud (False = removed by the class filter) |
147
+ | `num_points_raw` | int | the first `num_points_raw` rows are input points, the rest are interpolated |
148
+ | `input_index` | int32 `[num_points_raw]` | the decoded input row of every raw output point |
149
+ | `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) |
150
+ | `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 |
151
+ | `timing_ms` | dict | `preprocess`, `device` (staging + H2D + trace + D2H + un-permute), `postprocess`, `total` of this call |
152
+
153
+ `out.class_counts()` gives the points per class (raw + interpolated); `out.to_dicts()` one row per class
154
+ (`label`, `label_id`, `points`); `len(out)` the number of output points. The node's visualization cloud is
155
+ `model.config.palette` indexed by `label_ids` (`tt_frnet.host.postprocess.visualization_rgb`).
156
+
157
+ ## Lifetime and information
158
+
159
+ - `model.close()` releases the trace and the device tensors and closes the chip if the model opened it; idempotent.
160
+ `with` calls it for you; an unclosed model is closed when Python exits.
161
+ - `model.info`: weights (repo, tag, revision, path), device (`dispatch`, `grid`, `cores`, `num_command_queues`,
162
+ `fallback`), variant, warm variants, warm-up times, runtime parameter defaults, the variant config (`frnet`), the
163
+ trace and graph descriptions.
164
+ - Calls from several threads are safe: the device calls are serialised by a lock. One model per process per chip.
165
+ - `tt_frnet.viz.render_bev(points, label_ids, palette=...)` and `render_compare(...)` draw bird's-eye views (PIL, no
166
+ OpenCV).
167
+ - `model.device_probs(plan)` returns the device's `pred_probs` `(N, 27)` of a prepared frame (tests and tools; call it
168
+ under `model._lock`).
169
+
170
+ ## Speed
171
+
172
+ Warm calls, batch 1, ETH dispatch, 1 CQ, 12x10, the serve.env precision knobs (`code/scripts/bench.py`, 100
173
+ iterations; the numbers of `OPT_BASELINE.md`, 2026-10-09, on a shared host whose load moves the host stages):
174
+
175
+ | stage, p50 | ot128, shipped sample | ot128, PandaSet 019 f40 | qt128, shipped sample | qt128, PandaSet 019 f40 |
176
+ |---|---:|---:|---:|---:|
177
+ | `model(path)` end to end | 292.6 ms | 355.7 ms | 221.1 ms | 244.3 ms |
178
+ | host pre-processing (decode, crop, projection, fp16 interpolation, unique cells, device plan) | 55.3 ms | 107.2 ms | 54.2 ms | 83.5 ms |
179
+ | H2D (9 inputs, 8.6 / 6.2 MB) | 5.42 ms | 5.41 ms | 4.04 ms | 4.00 ms |
180
+ | device trace, one forward | 210.02 ms | 211.72 ms | 133.53 ms | 134.91 ms |
181
+ | D2H (fp32 `pred_probs`) | 7.65 ms | 7.43 ms | 5.80 ms | 5.55 ms |
182
+ | 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 |
183
+
184
+ - 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
185
+ device throughput. A synchronous call gives 2.81 / 4.09 frames/s on that frame.
186
+ - The device time barely depends on the frame: every point-wise op runs on the profile's `N_cap` rows. The host
187
+ stages grow with the cloud.
188
+ - The shipped samples are synthetic scans: ot128 53,411 points (N = M = 52,613 after the crop, no interpolated point),
189
+ qt128 45,783 points (N = 69,553 after interpolation, M = 18,926). PandaSet 019 frame 40 is a 107,146-point Pandar64
190
+ sweep (N = 145,961 / M = 40,691 on ot128); it is not shipped.
191
+ - `OPT_REPORT.md` ranks what the optimization phase will change (the two-term point layers, K1, the image-branch glue,
192
+ shape buckets, the host pre-processing).
193
+
194
+ ## Tests
195
+
196
+ From the repository root, with `code/` on `PYTHONPATH` (or the package installed):
197
+
198
+ - Host (no device, no ttnn call): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_frnet/tests`. Inside the
199
+ tt-models workspace the suite runs on its fake ttnn (`-p fake_ttnn_plugin`), which also enables `test_tt_host.py`;
200
+ tests that need workspace-only oracles or data skip elsewhere, and FAIL inside the workspace when that data is
201
+ missing. `test_reference_cpu.py` needs onnxruntime.
202
+ - Device (one p150): `python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py
203
+ code/tt_frnet/tests/test_e2e_device.py` (per-module PCC against the fp32 CPU reference on replay outputs, the K1
204
+ segment-max sites included; end-to-end agreement with ONNX Runtime on 17 OT128 / 5 QT128 frames, the shipped samples
205
+ against their stored references, replay == eager, API == `/predict`). The public-frame gates run only inside the
206
+ tt-models workspace, where their data lives: PandaSet, nuScenes and Autoware's sample rosbag, of which only PandaSet
207
+ may be redistributed.
208
+ - The gates are frozen in `tests/*.gates.json` at the first green run and are never loosened.
209
+
210
+ ## Limits
211
+
212
+ - Batch 1 on the chip; one model per process; one frame (one sweep) per call; one variant per model.
213
+ - The device input has a fixed size per profile (`N_cap` 160,000 / 120,000 points, `M_cap` 60,000 cells): a frame
214
+ outside Autoware's profile ranges is skipped exactly as the node skips it.
215
+ - Label names 22 / 23: Autoware's `class_names` read `debris` / `stroller`, but the weights were trained with
216
+ stroller = 22 and debris = 23 (AWML config). The port keeps Autoware's names for parity.
217
+ - Numerics: the device's per-point probabilities differ slightly from the fp32 reference, so points near a class
218
+ boundary can take the other class (98.9-99.6 % of the points agree on the public frames, see the model card).
219
+ - Not ported: Autoware's `PointCloud2` layouts and the filtered cloud's `output_format` conversion; the API takes
220
+ arrays or files and returns arrays.
code/conftest.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """pytest fixtures of frnet-p150 (no dependency on tt-metal's own conftest).
3
+
4
+ - ``device`` (session): one chip opened like the published numbers (``tt_frnet.device.open_device``:
5
+ ETH dispatch, 12x10 grid, the port's validated sizes and CQs). ``--device-id N`` or ``TT_DEVICE_ID`` selects
6
+ the chip (default 0); ``FRNET_DISPATCH=worker`` is the A/B opt-in, and the other ``FRNET_*``
7
+ variables apply as for the server. A failing ETH open is an error here, never a silent WORKER fallback: gates
8
+ are only valid on the published setup.
9
+ - Tests marked ``device`` are skipped when ttnn is missing or ``TT_VISIBLE_DEVICES=none`` (host-only runs).
10
+ On the shared workspace box run them through the lock and name the test files:
11
+ ``bin/devrun -t 1800 -- python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import gc
16
+ import importlib.util
17
+ import os
18
+
19
+ import pytest
20
+
21
+
22
+ def pytest_addoption(parser):
23
+ parser.addoption("--device-id", action="store", default=None, help="chip id (default $TT_DEVICE_ID or 0)")
24
+
25
+
26
+ def pytest_configure(config):
27
+ config.addinivalue_line("markers", "device: needs a Tenstorrent chip (skipped on host-only runs)")
28
+
29
+
30
+ def _no_device_reason():
31
+ if os.environ.get("TT_VISIBLE_DEVICES", "").lower() == "none":
32
+ return "TT_VISIBLE_DEVICES=none (host-only run)"
33
+ if importlib.util.find_spec("ttnn") is None:
34
+ return "ttnn is not installed"
35
+ return None
36
+
37
+
38
+ def pytest_collection_modifyitems(config, items):
39
+ reason = _no_device_reason()
40
+ if reason:
41
+ skip = pytest.mark.skip(reason=reason)
42
+ for item in items:
43
+ if "device" in item.keywords:
44
+ item.add_marker(skip)
45
+
46
+
47
+ @pytest.fixture(autouse=True)
48
+ def _gc_between_tests():
49
+ gc.collect()
50
+
51
+
52
+ @pytest.fixture(scope="session")
53
+ def device(request):
54
+ from tt_frnet.device import close_device, open_device
55
+
56
+ cli = request.config.getoption("--device-id")
57
+ dev = open_device(int(cli) if cli is not None else None, allow_fallback=False)
58
+ yield dev
59
+ close_device(dev)
code/models/common/lightweightmodule.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: © 2023 Tenstorrent USA, Inc.
2
+
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+
6
+ class LightweightModule:
7
+ """Torch modules add a surprising amount of host overhead for attribute
8
+ access and method calls. This class is a lightweight alternative that
9
+ just wraps a forward function for now."""
10
+
11
+ def __call__(self, *args, **kwargs):
12
+ return self.forward(*args, **kwargs)
code/scripts/README.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # code/scripts
2
+
3
+ | script | purpose | device |
4
+ |---|---|---|
5
+ | `make_sample.py` | generates the SHIPPED samples (`samples/synthetic_<variant>.npz`, a deterministic synthetic scan per serve profile; no dataset involved), their calibration presets and their stored fp32 CPU-reference outputs (ONNX Runtime) | no |
6
+ | `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` |
7
+ | `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 |
8
+ | `profile_summary.py` | per-module / per-layer / per-op tables from the profiler CSV (`OPT_BASELINE.md`) | no |
9
+ | `visualizer_report.py` | ttnn-visualizer memory + graph report of one eager forward (not for timing) | yes |
10
+ | `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 |
11
+ | `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 |
12
+ | `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 |
13
+ | `make_goldens.py` | workspace tool: the large module-tap goldens and the host pre-processing hashes of the tests | no |
14
+ | `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 |
15
+ | `fetch_samples.sh` | downloads public sample data that may not be redistributed (sha256-checked); no entry is active in this release | no |
16
+
17
+ Everything here ships in `code/` on the Hub and inside the image (`source.extra_code` lists `scripts`). The workspace
18
+ tools need the tt-models research tree (`research/frnet/`) and the raw datasets; they document how the card's
19
+ numbers and renders were made but do not run from a Hub clone.
code/scripts/bench.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Stage breakdown of warm forwards: the numbers OPT_BASELINE.md / OPT_REPORT.md / the card quote.
4
+
5
+ bin/devrun -t 900 -- python code/scripts/bench.py --iters 100 --json logs/baseline_bench.json
6
+ bin/devrun -t 900 -- python code/scripts/bench.py --variant qt128 --input a.npz --input b.npz --dispatch worker --cqs 2
7
+
8
+ For every ``--input`` cloud (default: the shipped synthetic sample of the variant), batch 1, after ``--warmup`` calls: ``--iters``
9
+ iterations of the served path cut into stages, each synchronised on its own (p50 / p99 / mean / min in ms, so the
10
+ rows do not add up to ``e2e`` exactly), then ``--iters`` plain ``model(points, calibration=...)`` calls, then
11
+ back-to-back replays:
12
+
13
+ ===================== ================================================================================================
14
+ ``pre`` host pre-processing as served (``model._prepare``): decode, ego crop, float32 projection, fp16
15
+ interpolation, unique cells, profile checks, the device plan (sort, CSR offsets, gather tables,
16
+ 8-d features with ``pre_norm`` folded)
17
+ ``pre.load`` the file decode alone (``ttaw.io.load_points``)
18
+ ``pre.crop`` the ego crop box (``crop_keep_mask``: float32 ``R p + t``)
19
+ ``pre.interp`` the fp16 interpolation grid and the interpolated points (``interpolation_grid`` +
20
+ ``interpolate_points``)
21
+ ``pre.project`` float32 projection of every point to the frustum grid (``project2d``)
22
+ ``pre.unique`` unique cells and the inverse map (``unique_coors``)
23
+ ``pre.plan`` ``build_plan``: the sort, CSR offsets, ``point2pix_s`` / ``pix2vox_s`` and the 8-d features
24
+ ``host_in`` ``FrameStaging.write``: range checks + the plan written into the persistent host buffers
25
+ ``h2d`` upload of the 9 trace inputs + device synchronise (``TraceRunner.upload``)
26
+ ``h2d.<input>`` the upload split per input (``features`` fp32, ``offsets``, ``p2p_<s>``, ``v2p_<s>`` uint32)
27
+ ``trace`` one blocking replay of the whole-graph trace
28
+ ``d2h`` ``TraceRunner.read`` as served: the fp32 ``pred_probs`` ``[N_cap, 32]`` -> numpy
29
+ ``d2h.copy`` the readback split: the device-to-host copy into a preallocated host tensor ...
30
+ ``d2h.to_numpy`` ... and its host conversion to numpy (host untilize of the TILE rows)
31
+ ``unsort`` the first N rows / 27 columns back in the network-input order (``plan.unsort``)
32
+ ``post`` host post-processing (first-max argmax, probability, class filter, output assembly)
33
+ ``e2e`` ``model(points, calibration=...)`` as served (its ``timing_ms`` medians are reported too)
34
+ ``b2b`` back-to-back replays, one synchronise per round of ``--b2b-iters``: device time per forward
35
+ ===================== ================================================================================================
36
+
37
+ AICLK / power / temperature are sampled from sysfs during the run (``ttaw.profiling.AiclkSampler``), and the host
38
+ load average is recorded (the host stages move with it). ``--check`` (default) compares the served output of each
39
+ input with a stored fp32 CPU-reference ``/predict`` body (``--reference PATH`` per input, ``-`` for none; default:
40
+ ``<stem>.<variant>.reference.json`` next to the input) with the container-smoke gates (``server/smoke_test.py``
41
+ ``REFERENCE_GATES``). The calibration of an input (``--calib`` per input): ``preset:<name>`` (``calib/``), a JSON
42
+ file holding the calibration dict, ``none`` (crop disabled); default: the preset named like the input's stem, else
43
+ ``<stem>.calib.json`` next to the input, else the crop disabled. Always quote the configuration line (dispatch /
44
+ CQs / grid) with the numbers.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import json
50
+ import os
51
+ import platform
52
+ import statistics
53
+ import time
54
+ from pathlib import Path
55
+ from typing import Any, Dict, List, Optional
56
+
57
+ import numpy as np
58
+
59
+ from tt_frnet import FRNet
60
+ from tt_frnet.ttaw.profiling import AiclkSampler, StageBench, time_b2b
61
+
62
+ CODE = Path(__file__).resolve().parents[1]
63
+ SAMPLES = CODE / "tt_frnet" / "samples" # the shipped synthetic_<variant>.npz
64
+ CALIB_DIR = CODE / "tt_frnet" / "calib"
65
+
66
+
67
+ def calibration_for(path: Path, spec: Optional[str] = None) -> Dict[str, Any]:
68
+ """The ``calibration`` argument for ``path`` (module docstring)."""
69
+ if spec:
70
+ if spec == "none":
71
+ return {"ego_crop_box": None}
72
+ if spec.startswith("preset:"):
73
+ return {"preset": spec[len("preset:"):]}
74
+ return json.loads(Path(spec).read_text())
75
+ stem = path.name.split(".")[0]
76
+ if (CALIB_DIR / f"{stem}.json").is_file():
77
+ return {"preset": stem}
78
+ side = path.parent / f"{stem}.calib.json"
79
+ if side.is_file():
80
+ return json.loads(side.read_text())
81
+ return {"ego_crop_box": None}
82
+
83
+
84
+ def check_output(model, path: Path, calib: Dict[str, Any], reference: Optional[Path]) -> Dict[str, Any]:
85
+ """Served output vs the stored fp32 CPU-reference ``/predict`` body (the container-smoke comparison)."""
86
+ from tt_frnet.server import smoke_test as st
87
+ from tt_frnet.ttaw.server import smoke
88
+
89
+ out = model(str(path), calibration=calib).to_dict()
90
+ res: Dict[str, Any] = {"num_points": out.get("num_points"), "num_points_raw": out.get("num_points_raw"),
91
+ "skipped": (out.get("meta") or {}).get("skipped")}
92
+ if reference is not None and reference.is_file():
93
+ metrics, fails = smoke.compare_with_reference(out, smoke.load_json(reference), gates=st.REFERENCE_GATES)
94
+ res["reference"] = {"file": str(reference), "metrics": metrics, "failures": fails}
95
+ return res
96
+
97
+
98
+ def bench_input(model, path: Path, calib: Dict[str, Any], *, iters: int, warmup: int, b2b_iters: int,
99
+ b2b_rounds: int, name: str) -> Dict[str, Any]:
100
+ import ttnn
101
+
102
+ from tt_frnet.host.preprocess import (build_plan, crop_keep_mask, interpolate_points, interpolation_grid,
103
+ project2d, unique_coors)
104
+ from tt_frnet.host.frame import POINT_FIELDS
105
+ from tt_frnet.ttaw import io as tio
106
+ from tt_frnet.ttaw.tensors import to_numpy
107
+
108
+ tr = model.runner # ttaw.trace.TraceRunner, variant "default"
109
+ dev, pre, cfg = model.device, model.pre, model.config
110
+ params = model.validate_params({})
111
+ src = str(path)
112
+ bench = StageBench(name)
113
+ for _ in range(warmup):
114
+ model(src, calibration=calib)
115
+ out_dev = tr.outputs("default") # the persistent trace output (device)
116
+ host_out = ttnn.allocate_tensor_on_host(out_dev.spec, dev)
117
+ names = list(model.graph.input_specs())
118
+ slots = {k: tr._slot(k, "input") for k in names}
119
+ T12, bounds, _ = pre.crop_setup(calib)
120
+ prepared = None
121
+ for _ in range(iters):
122
+ t0 = time.perf_counter()
123
+ prepared = model._prepare(points=src, calibration=calib)
124
+ t1 = time.perf_counter()
125
+ plan = prepared.plan
126
+ inputs = model.staging.write(plan)
127
+ t2 = time.perf_counter()
128
+ tr.upload(inputs)
129
+ ttnn.synchronize_device(dev)
130
+ t3 = time.perf_counter()
131
+ tr.replay("default")
132
+ ttnn.synchronize_device(dev)
133
+ t4 = time.perf_counter()
134
+ rows = tr.read("default")
135
+ t5 = time.perf_counter()
136
+ probs = np.asarray(rows, np.float32).reshape(-1, rows.shape[-1])
137
+ raw = plan.unsort(probs[:, : cfg.num_classes])
138
+ t6 = time.perf_counter()
139
+ model._postprocess(raw, prepared, params)
140
+ t7 = time.perf_counter()
141
+ for stage, a, b in (("pre", t0, t1), ("host_in", t1, t2), ("h2d", t2, t3), ("trace", t3, t4),
142
+ ("d2h", t4, t5), ("unsort", t5, t6), ("post", t6, t7)):
143
+ bench.add(stage, (b - a) * 1e3)
144
+ # the split rows (outside the served path; the same data and functions as autoware_preprocess)
145
+ with bench.stage("pre.load"):
146
+ xyzi = np.ascontiguousarray(tio.load_points(src).select(POINT_FIELDS), np.float32)
147
+ with bench.stage("pre.crop"):
148
+ keep = (crop_keep_mask(xyzi[:, :3], T12, np.asarray(bounds, np.float64).astype(np.float32))
149
+ if T12 is not None else np.ones(len(xyzi), bool))
150
+ rawp = xyzi[keep]
151
+ with bench.stage("pre.interp"):
152
+ grid, filled = interpolation_grid(rawp, cfg)
153
+ interp = interpolate_points(grid, filled, cfg)
154
+ allpts = np.concatenate([rawp, interp], axis=0) if len(interp) else rawp
155
+ with bench.stage("pre.project"):
156
+ fx, fy = project2d(allpts[:, :3], cfg.frustum_w, cfg.frustum_h, cfg.fov)
157
+ coors = np.stack([np.zeros_like(fy), fy, fx], axis=1).astype(np.int64)
158
+ with bench.stage("pre.unique"):
159
+ unique_coors(coors, cfg.frustum_w, cfg.frustum_h)
160
+ with bench.stage("pre.plan"):
161
+ build_plan(prepared.inputs, cfg, pre_norm=pre.pre_norm)
162
+ for k, slot in slots.items():
163
+ with bench.stage(f"h2d.{k}"):
164
+ ttnn.copy_host_to_device_tensor(inputs[k], slot.buffers[0], cq_id=0)
165
+ ttnn.synchronize_device(dev)
166
+ with bench.stage("d2h.copy"):
167
+ ttnn.copy_device_to_host_tensor(out_dev, host_out, blocking=True)
168
+ with bench.stage("d2h.to_numpy"):
169
+ to_numpy(host_out)
170
+ timing: List[Dict[str, float]] = []
171
+ for _ in range(iters):
172
+ t0 = time.perf_counter()
173
+ out = model(src, calibration=calib)
174
+ bench.add("e2e", (time.perf_counter() - t0) * 1e3)
175
+ timing.append(dict(out.timing_ms))
176
+ rounds = [time_b2b(lambda: tr.replay("default"), lambda: ttnn.synchronize_device(dev), n=b2b_iters)
177
+ for _ in range(b2b_rounds)]
178
+ for r in rounds:
179
+ bench.add("b2b", r)
180
+ b2b = statistics.median(rounds)
181
+ elem = {"float32": 4, "uint32": 4, "bfloat16": 2}
182
+ in_bytes = {k: int(np.prod(shape)) * elem[dtype] for k, (shape, dtype) in model.graph.input_specs().items()}
183
+ return {"stages_ms": bench.summary(), "table": bench.table(),
184
+ "timing_ms_median": {k: statistics.median(t[k] for t in timing) for k in timing[0]},
185
+ "b2b_rounds_ms": rounds, "b2b_iters": b2b_iters, "device_fps_b2b": 1e3 / b2b,
186
+ "e2e_fps_sync": 1e3 / bench.summary()["e2e"]["p50"],
187
+ "frame": {k: v for k, v in prepared.stats().items() if k != "ego_crop"},
188
+ "bytes": {**{f"h2d.{k}": v for k, v in in_bytes.items()}, "h2d": sum(in_bytes.values()),
189
+ "d2h": int(np.prod(out_dev.shape)) * 4}}
190
+
191
+
192
+ def main() -> None:
193
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
194
+ ap.add_argument("--input", action="append", default=None, help="cloud .npz / .pcd / .bin; repeatable; "
195
+ "default: the shipped synthetic sample of the variant")
196
+ ap.add_argument("--calib", action="append", default=None,
197
+ help="per --input: preset:<name> | <calibration .json> | none (default: see the docstring)")
198
+ ap.add_argument("--reference", action="append", default=None,
199
+ help="fp32 CPU-reference /predict body per --input ('-' for none; default <stem>.<variant>"
200
+ ".reference.json next to the input)")
201
+ ap.add_argument("--iters", type=int, default=100)
202
+ ap.add_argument("--warmup", type=int, default=10)
203
+ ap.add_argument("--b2b-iters", type=int, default=50)
204
+ ap.add_argument("--b2b-rounds", type=int, default=5)
205
+ ap.add_argument("--variant", default=None, help="ot128 | qt128 (default: FRNET_VARIANT or ot128)")
206
+ ap.add_argument("--dispatch", default=None, choices=["eth", "worker"], help="default: FRNET_DISPATCH or eth")
207
+ ap.add_argument("--cqs", type=int, default=None, choices=[1, 2], help="command queues (default: FRNET_NUM_CQS or 1)")
208
+ ap.add_argument("--allow-fallback", action="store_true", help="accept a WORKER fallback of an ETH open")
209
+ ap.add_argument("--no-check", dest="check", action="store_false")
210
+ ap.add_argument("--tag", default="", help="label of this configuration in the output")
211
+ ap.add_argument("--json")
212
+ a = ap.parse_args()
213
+ inputs = [Path(p) for p in (a.input or [str(SAMPLES / f"synthetic_{a.variant or os.environ.get('FRNET_VARIANT', 'ot128')}.npz")])]
214
+ calibs = list(a.calib or []) + [None] * len(inputs)
215
+ refs = [None if r in (None, "-") else Path(r) for r in (a.reference or [])] + [None] * len(inputs)
216
+ load0 = os.getloadavg()
217
+ t0 = time.perf_counter()
218
+ with FRNet.from_pretrained(variant=a.variant, dispatch=a.dispatch, num_command_queues=a.cqs) as model:
219
+ load_s = time.perf_counter() - t0
220
+ info = dict(model.device_info)
221
+ want = a.dispatch or os.environ.get("FRNET_DISPATCH", "eth")
222
+ if info.get("dispatch") != want and not a.allow_fallback:
223
+ raise SystemExit(f"device opened with dispatch {info.get('dispatch')!r}, asked for {want!r}")
224
+ tag = a.tag or f"{info.get('dispatch')}-{info.get('num_command_queues')}cq {info.get('grid')}"
225
+ res: Dict[str, Any] = {"tag": tag, "config": info, "variant": model.variant,
226
+ "graph": model.graph.describe(), "trace": model.runner.describe(),
227
+ "load_s": load_s, "warmup_ms": dict(model.warmup_ms), "iters": a.iters,
228
+ "host": {"cpus": os.cpu_count(), "machine": platform.processor() or platform.machine(),
229
+ "loadavg_start": load0}, "inputs": {}}
230
+ with AiclkSampler(interval_s=0.05) as clk:
231
+ for k, (path, cspec, ref) in enumerate(zip(inputs, calibs, refs)):
232
+ calib = calibration_for(path, cspec)
233
+ if ref is None and a.reference is None:
234
+ cand = path.parent / f"{path.name.split('.')[0]}.{model.variant}.reference.json"
235
+ ref = cand if cand.is_file() else None
236
+ r = bench_input(model, path, calib, iters=a.iters, warmup=a.warmup, b2b_iters=a.b2b_iters,
237
+ b2b_rounds=a.b2b_rounds, name=f"{path.name} | {tag} | {model.variant}")
238
+ r["input"], r["calibration"] = str(path), calib
239
+ if a.check:
240
+ r["check"] = check_output(model, path, calib, ref)
241
+ res["inputs"][f"{k}:{path.name}"] = r
242
+ print(r["table"], flush=True)
243
+ print(f"b2b rounds ms: {[round(x, 3) for x in r['b2b_rounds_ms']]} "
244
+ f"timing_ms medians: { {k2: round(v, 2) for k2, v in r['timing_ms_median'].items()} } "
245
+ f"frame: {r['frame']} bytes: {r['bytes']}", flush=True)
246
+ if a.check:
247
+ print("check:", json.dumps(r["check"], default=str), flush=True)
248
+ res["aiclk"] = clk.summary()
249
+ res["trace_after"] = model.runner.describe()
250
+ res["host"]["loadavg_end"] = os.getloadavg()
251
+ print(json.dumps({"tag": tag, "variant": res["variant"], "config": info, "aiclk": res["aiclk"],
252
+ "load_s": round(load_s, 2), "program_cache_entries":
253
+ res["trace_after"].get("program_cache_entries"),
254
+ "loadavg": [load0, res["host"]["loadavg_end"]]}, default=str), flush=True)
255
+ if a.json:
256
+ Path(a.json).parent.mkdir(parents=True, exist_ok=True)
257
+ Path(a.json).write_text(json.dumps(res, indent=1, default=str) + "\n")
258
+
259
+
260
+ if __name__ == "__main__":
261
+ main()
code/scripts/container_smoke.sh ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Serve ONE serve profile of the BUILT container package, run the smoke test against it, keep the evidence, and always
4
+ # stop it -- in one device-lock window. Run it once per serve profile (`tt-model profiles
5
+ # <staged dir>/tt_kernel_manifest.json` lists them; without --profile the package's default profile is served):
6
+ #
7
+ # ROOT=/home/ubuntu/experiments/tt-models
8
+ # $ROOT/bin/devrun -t 3600 -k 150 -- env -u HF_TOKEN -u HUGGING_FACE_HUB_TOKEN sg docker -c \
9
+ # "bash code/scripts/container_smoke.sh $ROOT/build/frnet-p150 [port] [--profile NAME] [--log-dir DIR]"
10
+ #
11
+ # The smoke test FAILS unless /info reports ETH dispatch and the 12x10 grid and runs what the package pins for the
12
+ # profile (dispatch, CQs, variant, weights revision); it also compares the output with the stored CPU reference of the
13
+ # sample when one exists (code/tt_frnet/server/smoke_test.py). Exit code: the smoke test's (0 = PASS); 1 when
14
+ # serve fails, 2 on a usage error.
15
+ #
16
+ # Evidence, kept whatever the outcome (--log-dir, default: logs/smoke/ of this repo, next to code/), named
17
+ # <name>[-<profile>]-<UTC time>.*:
18
+ # .container.log the container's whole log (boot, requests, shutdown), followed while the container stops
19
+ # .info.json GET /info as soon as the server is READY (device: dispatch, grid, cores; pins; versions)
20
+ # .smoke.json the smoke test's /predict output (the SMOKE_OUT environment variable overrides this path)
21
+ # .result.json profile, port, exit code, start / end times
22
+ #
23
+ # `tt-model serve` returns once the server is READY and leaves the container running, so the stop must happen before
24
+ # the lock is released, also when devrun's timeout TERMs this script: the EXIT trap saves the log, then stops the
25
+ # container cleanly with SIGTERM (120 s grace, hence devrun -k 150), never `docker kill`, which would leave the chip
26
+ # dirty. Needs docker access (sg docker) and python3.
27
+ set -u
28
+ usage() { echo "usage: container_smoke.sh <staged package dir> [port] [--profile NAME] [--log-dir DIR]" >&2; }
29
+ PROFILE=""
30
+ LOG_DIR=""
31
+ POSITIONAL=()
32
+ while [ $# -gt 0 ]; do
33
+ case "$1" in
34
+ --profile) [ $# -ge 2 ] || { usage; exit 2; }; PROFILE="$2"; shift 2 ;;
35
+ --profile=*) PROFILE="${1#--profile=}"; shift ;;
36
+ --log-dir) [ $# -ge 2 ] || { usage; exit 2; }; LOG_DIR="$2"; shift 2 ;;
37
+ --log-dir=*) LOG_DIR="${1#--log-dir=}"; shift ;;
38
+ -h|--help) usage; exit 0 ;;
39
+ -*) echo "container_smoke.sh: unknown option $1" >&2; usage; exit 2 ;;
40
+ *) POSITIONAL+=("$1"); shift ;;
41
+ esac
42
+ done
43
+ [ ${#POSITIONAL[@]} -ge 1 ] && [ ${#POSITIONAL[@]} -le 2 ] || { usage; exit 2; }
44
+ STAGED="${POSITIONAL[0]}"
45
+ PORT="${POSITIONAL[1]:-20000}"
46
+ MANIFEST="$STAGED/tt_kernel_manifest.json"
47
+ [ -f "$MANIFEST" ] || { echo "container_smoke.sh: $MANIFEST not found (run tt-model package first)" >&2; exit 2; }
48
+ HERE="$(cd "$(dirname "$0")" && pwd)"
49
+ PROFILE_ARGS=()
50
+ [ -n "$PROFILE" ] && PROFILE_ARGS=(--profile "$PROFILE")
51
+
52
+ # The package name and the served profile (tt-model's rule: --profile, else default_profile, else the first serve
53
+ # profile), hence the container name tt-model gives it: tt-model-<name>-<profile> (tt_kernel/container.py).
54
+ read -r NAME PROFILE_NAME < <(python3 - "$MANIFEST" "$PROFILE" <<'EOF'
55
+ import json, sys
56
+ m = json.load(open(sys.argv[1]))
57
+ c = m.get("container") or {}
58
+ profiles = c.get("serve_profiles") or [{}]
59
+ print(m.get("name") or "model", sys.argv[2] or c.get("default_profile") or profiles[0].get("name") or "default")
60
+ EOF
61
+ )
62
+ [ -n "${NAME:-}" ] || { echo "container_smoke.sh: cannot read the package name from $MANIFEST" >&2; exit 2; }
63
+ CONTAINER="tt-model-$NAME-$PROFILE_NAME"
64
+
65
+ [ -n "$LOG_DIR" ] || LOG_DIR="$(cd "$HERE/../.." && pwd)/logs/smoke"
66
+ if ! mkdir -p "$LOG_DIR" 2>/dev/null || [ ! -w "$LOG_DIR" ]; then
67
+ echo "container_smoke.sh: cannot write $LOG_DIR; keeping the evidence in ${TMPDIR:-/tmp}" >&2
68
+ LOG_DIR="${TMPDIR:-/tmp}"
69
+ fi
70
+ LOG_DIR="$(cd "$LOG_DIR" && pwd)"
71
+ STARTED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
72
+ STEM="$LOG_DIR/$NAME${PROFILE:+-$PROFILE}-$(date -u +%Y%m%dT%H%M%SZ)"
73
+ SMOKE_JSON="${SMOKE_OUT:-$STEM.smoke.json}"
74
+
75
+ fetch() { # fetch URL FILE: one GET with a 30 s timeout; the body goes to FILE
76
+ python3 - "$1" "$2" <<'EOF'
77
+ import sys, urllib.request
78
+ try:
79
+ with urllib.request.urlopen(sys.argv[1], timeout=30) as r:
80
+ body = r.read()
81
+ except Exception as e: # noqa: BLE001 -- best effort: the smoke test reports the server's state
82
+ sys.exit(f"container_smoke.sh: GET {sys.argv[1]} failed: {e}")
83
+ open(sys.argv[2], "wb").write(body)
84
+ EOF
85
+ }
86
+
87
+ CHILD=""
88
+ STOPPED=0
89
+ cleanup() {
90
+ local rc=$?
91
+ [ "$STOPPED" = 1 ] && return
92
+ STOPPED=1
93
+ if [ -n "$CHILD" ]; then kill -TERM "$CHILD" 2>/dev/null; wait "$CHILD" 2>/dev/null; fi
94
+ # The log is lost with the container: follow it (whole history, then the shutdown lines) while it stops.
95
+ local logger=""
96
+ if docker inspect "$CONTAINER" >/dev/null 2>&1; then
97
+ docker logs --follow "$CONTAINER" > "$STEM.container.log" 2>&1 &
98
+ logger=$!
99
+ else
100
+ tt-model logs "${PROFILE_ARGS[@]}" "$MANIFEST" > "$STEM.container.log" 2>&1 || true
101
+ fi
102
+ tt-model stop "${PROFILE_ARGS[@]}" "$MANIFEST" || true
103
+ if [ -n "$logger" ]; then
104
+ for _ in $(seq 1 30); do kill -0 "$logger" 2>/dev/null || break; sleep 1; done
105
+ kill "$logger" 2>/dev/null
106
+ wait "$logger" 2>/dev/null
107
+ fi
108
+ python3 - "$STEM.result.json" "$NAME" "$PROFILE_NAME" "$PORT" "$rc" "$STARTED" "$CONTAINER" "$MANIFEST" <<'EOF'
109
+ import datetime, json, sys
110
+ out, name, profile, port, rc, started, container, manifest = sys.argv[1:]
111
+ ended = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
112
+ json.dump({"bundle": name, "profile": profile, "port": int(port) if port.isdigit() else port, "rc": int(rc),
113
+ "result": "PASS" if rc == "0" else "FAIL",
114
+ "started": started, "ended": ended, "container": container, "manifest": manifest}, open(out, "w"), indent=1)
115
+ EOF
116
+ echo "container_smoke.sh: rc=$rc; evidence in $STEM.*"
117
+ }
118
+ trap cleanup EXIT
119
+ trap 'exit 143' TERM
120
+ trap 'exit 130' INT
121
+
122
+ # Each step runs in the background and is waited for: bash defers a trap while a FOREGROUND command runs, so a TERM
123
+ # sent to this script alone (devrun's timeout signals the whole process group) would otherwise wait for the step.
124
+ step() {
125
+ "$@" &
126
+ CHILD=$!
127
+ wait "$CHILD"
128
+ local rc=$?
129
+ CHILD=""
130
+ return "$rc"
131
+ }
132
+
133
+ # --port and --profile BEFORE the target: options after it are passed through to the container (tt-model cli rule)
134
+ step tt-model serve --port "$PORT" "${PROFILE_ARGS[@]}" "$MANIFEST" || exit 1
135
+ step fetch "http://127.0.0.1:$PORT/info" "$STEM.info.json"
136
+ step python3 "$HERE/../tt_frnet/server/smoke_test.py" --url "http://127.0.0.1:$PORT" --wait 600 \
137
+ --manifest "$MANIFEST" "${PROFILE_ARGS[@]}" --out "$SMOKE_JSON"
code/scripts/diag_conv_precision.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Device diagnostic (M1b follow-up): where does the device lose accuracy outside the point island?
4
+
5
+ cd bundles/frnet-p150 && bin/devrun -t 1200 -- env PYTHONPATH=code python code/scripts/diag_conv_precision.py \\
6
+ --out /home/ubuntu/experiments/tt-models/logs/frnet/m1b/diag_conv_precision.json
7
+
8
+ Teacher-forced units on the OT128 sample's golden taps (torch fp32 reference): each unit's device output is compared
9
+ with the golden output (``dev``) and with a CPU emulation of the device's rounding points with exact fp32 weights
10
+ (``emu``: bf16 inputs, conv outputs rounded to bf16 before / after each activation, bf16 adds); ``dev_vs_emu``
11
+ rel-L2 measures the device's arithmetic error beyond the emulated bf16 roundings (0 for a perfect fp32 conv engine).
12
+ Units: the stem (3 convs + HardSwish), every stage's BasicBlocks (stage input -> ``layer<l>``), every attention
13
+ (``pixel_fusion<l>`` -> ``attention<l>``), the ``addcmul`` (-> ``stage<l>``), ``fuse2``, the softmax, the first conv
14
+ of each stage alone (no activation), and the element-wise ops (HardSwish, sigmoid) on bf16 inputs. Configurations:
15
+ ``FRNET_CONV_TERMS`` 1 (float32 conv weights: the device's TF32-like path) and 2 (C17 two-term bf16 weights).
16
+ Eager runs (no trace): a diagnostic, not a gate.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+
28
+ HERE = Path(__file__).resolve()
29
+ sys.path.insert(0, str(HERE.parents[1]))
30
+
31
+
32
+ def main() -> int:
33
+ ap = argparse.ArgumentParser()
34
+ ap.add_argument("--out", type=Path, required=True)
35
+ ap.add_argument("--terms", nargs="*", type=int, default=[1, 2])
36
+ args = ap.parse_args()
37
+
38
+ import torch
39
+ import torch.nn.functional as F
40
+
41
+ torch.set_num_threads(4)
42
+ from tt_frnet.device import device_session
43
+ from tt_frnet.reference.config import FRNetConfig
44
+ from tt_frnet.reference.weights import load_weights
45
+ from tt_frnet.tests._data import golden_dir, weights_dir
46
+ from tt_frnet.tt.layers import free
47
+ from tt_frnet.tt.model import KNOBS, LOGIT_PAD, TtFRNet
48
+ from tt_frnet.ttaw.metrics import error_stats, pcc
49
+ from tt_frnet.ttaw.ops.conv import FeatureMap, fmap_from_numpy, fmap_to_numpy
50
+ from tt_frnet.ttaw.tensors import round_to_bf16
51
+
52
+ variant = "ot128"
53
+ wdir = weights_dir()
54
+ cfg = FRNetConfig.from_model_dir(wdir, variant)
55
+ weights = load_weights(wdir, variant)
56
+ taps = np.load(golden_dir() / variant / "pandaset_019_40.taps.npz", mmap_mode="r")
57
+
58
+ def g(name):
59
+ return np.asarray(taps[name], np.float32)
60
+
61
+ def bf(a):
62
+ return round_to_bf16(np.asarray(a, np.float32))
63
+
64
+ def t(a):
65
+ return torch.from_numpy(np.ascontiguousarray(a, np.float32))
66
+
67
+ # ---- CPU emulation of the device rounding points (exact fp32 weights)
68
+ def emu_conv(x, layer, act=True):
69
+ y = F.conv2d(t(x), t(layer.weight), t(layer.bias), stride=layer.stride, padding=layer.padding).numpy()
70
+ y = bf(y)
71
+ if act and layer.activation == "hardswish":
72
+ y = bf(F.hardswish(t(y)).numpy())
73
+ elif act and layer.activation == "sigmoid":
74
+ y = bf(torch.sigmoid(t(y)).numpy())
75
+ return y
76
+
77
+ def emu_blocks(x, st):
78
+ for c1, c2, ds in st.blocks:
79
+ y = emu_conv(emu_conv(x, c1), c2)
80
+ idt = emu_conv(x, ds) if ds is not None else x
81
+ x = bf(F.hardswish(t(bf(y + idt))).numpy())
82
+ return x
83
+
84
+ def stats(dev, emu, gold):
85
+ d = error_stats(dev, gold)
86
+ e = error_stats(emu, gold)
87
+ de = error_stats(dev, emu)
88
+ return {"pcc_dev": d["pcc"], "pcc_emu": e["pcc"], "rel_l2_dev": d["rel_l2"], "rel_l2_emu": e["rel_l2"],
89
+ "rel_l2_dev_vs_emu": de["rel_l2"], "max_abs_dev_vs_emu": de["max_abs"]}
90
+
91
+ results = {"variant": variant, "frame": "pandaset_019_40", "configs": {}}
92
+ stage_inputs = ["backbone.fusion_stem", "backbone.stage1", "backbone.stage2", "backbone.stage3"]
93
+ emu_cache = {}
94
+ with device_session() as device:
95
+ for terms in args.terms:
96
+ t0 = time.time()
97
+ knobs = KNOBS.read(CONV_TERMS=terms)
98
+ net = TtFRNet(device, weights, cfg, knobs=knobs)
99
+ res = {}
100
+
101
+ def up(nchw):
102
+ return fmap_from_numpy(bf(nchw), device, dtype="bfloat16")
103
+
104
+ def report(name, dev, emu, gold):
105
+ if callable(emu): # the CPU emulation is the same for every configuration
106
+ if name not in emu_cache:
107
+ emu_cache[name] = emu()
108
+ emu = emu_cache[name]
109
+ res[name] = stats(dev, emu, gold)
110
+ r = res[name]
111
+ print(f"[terms={terms}] {name:28s} pcc dev {r['pcc_dev']:.6f} emu {r['pcc_emu']:.6f} | rel_l2 dev "
112
+ f"{r['rel_l2_dev']:.3e} emu {r['rel_l2_emu']:.3e} dev-vs-emu {r['rel_l2_dev_vs_emu']:.3e}",
113
+ flush=True)
114
+
115
+ # stem
116
+ fr = g("backbone.frustum")
117
+ fr32 = np.concatenate([fr, np.zeros_like(fr)], 1)
118
+ x = up(fr32)
119
+ for conv in net.stem:
120
+ y = conv(x)
121
+ free(x)
122
+ x = y
123
+ dev = fmap_to_numpy(x)
124
+ free(x)
125
+ def emu_stem():
126
+ e = bf(fr)
127
+ for c in weights.stem:
128
+ e = emu_conv(e, c)
129
+ return e
130
+ report("stem", dev, emu_stem, g("backbone.stem"))
131
+ # stages
132
+ for st_w, st, src in zip(weights.stages, net.stages, stage_inputs):
133
+ l = st["index"]
134
+ xin = g(src)
135
+ # first conv alone (pre-activation)
136
+ c1 = st["blocks"][0][0]
137
+ x = up(xin)
138
+ y = c1.conv(x)
139
+ dev = fmap_to_numpy(y)
140
+ free(y)
141
+ lay = st_w.blocks[0][0]
142
+ exact = F.conv2d(t(bf(xin)), t(lay.weight), t(lay.bias), stride=lay.stride,
143
+ padding=lay.padding).numpy()
144
+ report(f"layer{l}.block0.conv1(noact)", dev, bf(exact), exact)
145
+ # blocks
146
+ for c1_, c2_, ds_ in st["blocks"]:
147
+ y1 = c1_(x)
148
+ y = c2_(y1)
149
+ free(y1)
150
+ idt = ds_(x) if ds_ is not None else x
151
+ import ttnn
152
+
153
+ s = ttnn.add(y.tensor, idt.tensor, memory_config=ttnn.DRAM_MEMORY_CONFIG)
154
+ out = y.with_tensor(ttnn.hardswish(s))
155
+ free(s, y)
156
+ if ds_ is not None:
157
+ free(idt)
158
+ free(x)
159
+ x = out
160
+ dev = fmap_to_numpy(x)
161
+ free(x)
162
+ report(f"layer{l}", dev, lambda: emu_blocks(bf(xin), st_w), g(f"backbone.layer{l}"))
163
+ # attention
164
+ pfz = g(f"backbone.pixel_fusion{l}")
165
+ x = up(pfz)
166
+ a0 = st["attention"][0](x)
167
+ a1 = st["attention"][1](a0)
168
+ free(a0)
169
+ dev = fmap_to_numpy(a1)
170
+ free(a1)
171
+ report(f"attention{l}", dev,
172
+ lambda: emu_conv(emu_conv(bf(pfz), st_w.attention[0]), st_w.attention[1]),
173
+ g(f"backbone.attention{l}"))
174
+ # addcmul
175
+ lay_g, att_g = g(f"backbone.layer{l}"), g(f"backbone.attention{l}")
176
+ xl, att = up(lay_g), up(att_g)
177
+ out = ttnn.addcmul(xl.tensor, x.tensor, att.tensor, memory_config=ttnn.DRAM_MEMORY_CONFIG)
178
+ dev = fmap_to_numpy(x.with_tensor(out))
179
+ free(out, xl, att, x)
180
+ e = bf(bf(lay_g) + bf(pfz) * bf(att_g))
181
+ report(f"stage{l}(addcmul)", dev, e, g(f"backbone.stage{l}"))
182
+ # fuse2
183
+ f1 = g("decoder.fuse1")
184
+ x = up(f1)
185
+ y = net.fuse2(x)
186
+ dev = fmap_to_numpy(y)
187
+ free(x, y)
188
+ report("fuse2", dev, lambda: emu_conv(bf(f1), weights.fuse_layer2), g("decoder.fuse2"))
189
+ # element-wise ops on bf16 inputs (synthetic: the stage-1 map scaled to a wide range)
190
+ import torch as _t
191
+ import ttnn
192
+
193
+ src = g("backbone.layer1")[:, :, :32, :] * 3.0
194
+ xin = up(src)
195
+ for opname, fn, ref in (("hardswish", ttnn.hardswish, F.hardswish), ("sigmoid", ttnn.sigmoid,
196
+ torch.sigmoid)):
197
+ yo = xin.with_tensor(fn(xin.tensor))
198
+ dev = fmap_to_numpy(yo)
199
+ free(yo)
200
+ exact = ref(t(bf(src))).numpy()
201
+ report(f"op.{opname}(bf16)", dev, bf(exact), exact)
202
+ free(xin)
203
+ # softmax (fp32 logits, padded with -1e30)
204
+ lg = g("head.logits")[:65536]
205
+ pad = np.full((lg.shape[0], LOGIT_PAD), -1e30, np.float32)
206
+ pad[:, : lg.shape[1]] = lg
207
+ lt = ttnn.from_torch(_t.from_numpy(pad.reshape(1, 1, *pad.shape)), dtype=ttnn.float32,
208
+ layout=ttnn.TILE_LAYOUT, device=device, memory_config=ttnn.DRAM_MEMORY_CONFIG)
209
+ pr = ttnn.softmax(lt, dim=-1, compute_kernel_config=net._softmax_cfg, numeric_stable=True)
210
+ dev = ttnn.to_torch(pr).float().numpy().reshape(-1, LOGIT_PAD)[:, : lg.shape[1]]
211
+ free(lt, pr)
212
+ exact = torch.softmax(t(lg), -1).numpy()
213
+ report("softmax(fp32)", dev, exact, g("pred_probs")[:65536])
214
+ net.release()
215
+ res["seconds"] = round(time.time() - t0, 1)
216
+ results["configs"][f"conv_terms={terms}"] = res
217
+ args.out.parent.mkdir(parents=True, exist_ok=True)
218
+ args.out.write_text(json.dumps(results, indent=1))
219
+ print("wrote", args.out)
220
+ return 0
221
+
222
+
223
+ if __name__ == "__main__":
224
+ sys.exit(main())
code/scripts/diag_frame_taps.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Device diagnostic: per-module error of the device graph on one catalogued frame, next to the CPU emulation.
4
+
5
+ cd bundles/frnet-p150 && bin/devrun -t 1200 -- env PYTHONPATH=code python code/scripts/diag_frame_taps.py \\
6
+ --frame nuscenes_scene-0916_40.ot128 --out /home/ubuntu/experiments/tt-models/logs/frnet/m1b/diag_0916_40.json
7
+
8
+ The exact fp32 TT dataflow (``reference/rewrites.py FRNetPlanTorch``) is the reference of every tap; the CPU
9
+ emulation (``--emulation``, default the ``plan_exact_points`` row of ``code/scripts/precision_study.py``: bf16 conv
10
+ operands, gathers and segment tables, exact point layers) and the device ``taps`` variant (one replay; knobs from the
11
+ ``FRNET_*`` environment) are compared with it tap by tap: where the device's ``1 - PCC`` grows faster than the
12
+ emulation's is where the device loses accuracy that the emulation does not model. Also reported: ``pred_probs`` PCC
13
+ and raw-point argmax agreement vs ONNX Runtime for the device and the emulation. The CPU work runs first (in the
14
+ device lock: ~1-2 min at OT128).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+
26
+ HERE = Path(__file__).resolve()
27
+ sys.path.insert(0, str(HERE.parents[1]))
28
+ sys.path.insert(0, str(HERE.parent))
29
+
30
+ TAPS = ("encoder.pf3", "encoder.voxel_feats", "backbone.stem", "backbone.point_stem", "backbone.fusion_stem",
31
+ *(f"backbone.{k}{i}" for i in range(1, 5) for k in ("layer", "point_fusion", "pixel_fusion", "attention",
32
+ "stage")),
33
+ "decoder.fuse1", "decoder.fuse2", "decoder.point_fuse1", "decoder.point_fuse2",
34
+ *(f"head.mlp{i}" for i in range(4)), "head.logits", "pred_probs")
35
+
36
+
37
+ def main() -> int:
38
+ ap = argparse.ArgumentParser()
39
+ ap.add_argument("--frame", required=True)
40
+ ap.add_argument("--out", type=Path, required=True)
41
+ ap.add_argument("--emulation", default="plan_exact_points")
42
+ ap.add_argument("--threads", type=int, default=4)
43
+ ap.add_argument("--cpu-only", action="store_true", help="stop after the CPU part (a dry run without a device)")
44
+ args = ap.parse_args()
45
+
46
+ import torch
47
+
48
+ torch.set_num_threads(args.threads)
49
+ from precision_study import CONFIGS
50
+ from tt_frnet.host.preprocess import build_plan
51
+ from tt_frnet.reference.config import FRNetConfig
52
+ from tt_frnet.reference.rewrites import Emulation, FRNetPlanTorch
53
+ from tt_frnet.reference.weights import load_weights
54
+ from tt_frnet.tests import _data
55
+ from tt_frnet.ttaw.golden import TapRegistry
56
+ from tt_frnet.ttaw.metrics import argmax_agreement, pcc
57
+
58
+ variant = args.frame.rpartition(".")[2]
59
+ wdir = _data.weights_dir()
60
+ cfg = FRNetConfig.from_model_dir(wdir, variant)
61
+ w = load_weights(wdir, variant)
62
+ inputs, ort_probs, _ = _data.load_frame(args.frame)
63
+ plan = build_plan(inputs, cfg, pre_norm=(w.pre_norm_scale, w.pre_norm_shift))
64
+ n, n_raw = plan.n, inputs.num_points_raw
65
+ H, W = plan.frustum_hw
66
+ t0 = time.time()
67
+ exact = TapRegistry(include=list(TAPS))
68
+ FRNetPlanTorch(w)(plan, taps=exact)
69
+ emu = TapRegistry(include=list(TAPS))
70
+ emu_probs = plan.unsort(FRNetPlanTorch(w, emulation=Emulation.parse(CONFIGS[args.emulation]))(plan, taps=emu))
71
+ cpu_s = time.time() - t0
72
+
73
+ def rows(name, a):
74
+ """point taps: the first N sorted rows; voxel taps: the first M rows."""
75
+ a = np.asarray(a)
76
+ if a.shape[0] == plan.n_cap:
77
+ return a[:n]
78
+ return a[: plan.m] if a.shape[0] in (cfg.m_cap, cfg.m_cap + 1) else a
79
+
80
+ if args.cpu_only:
81
+ ort = np.asarray(ort_probs, np.float32)
82
+ print(f"cpu {cpu_s:.0f} s; emulation pred_probs pcc {pcc(emu_probs, ort):.6f} argmax "
83
+ f"{argmax_agreement(emu_probs[:n_raw], ort[:n_raw]):.5f}")
84
+ return 0
85
+ from tt_frnet.device import device_session
86
+ from tt_frnet.tt.model import TtFRNet, build_runner, frame_arrays
87
+
88
+ report = {"frame": args.frame, "N": n, "M": plan.m, "emulation": args.emulation, "cpu_s": round(cpu_s, 1),
89
+ "taps": {}}
90
+ with device_session() as device:
91
+ graph = TtFRNet(device, w, cfg)
92
+ arrays = frame_arrays(plan, cfg.m_cap)
93
+ runner = build_runner(graph, variants=("taps",), name="frnet_diag", init=arrays)
94
+ try:
95
+ runner.capture()
96
+ dev = runner("taps", inputs=arrays)
97
+ report["knobs"] = graph.knobs.as_dict()
98
+ for name in TAPS:
99
+ if name not in dev or name not in exact:
100
+ continue
101
+ ref = np.asarray(exact[name], np.float32)
102
+ d = np.asarray(dev[name], np.float32)
103
+ if ref.ndim == 4:
104
+ _, c, h, ww = ref.shape
105
+ d = d.reshape(-1, d.shape[-1])[: h * ww, :c].reshape(1, h, ww, c).transpose(0, 3, 1, 2)
106
+ e = np.asarray(emu[name], np.float32)
107
+ else:
108
+ ref = rows(name, ref)[:, : ref.shape[-1]]
109
+ d = rows(name, d.reshape(-1, d.shape[-1]))[:, : ref.shape[-1]]
110
+ e = rows(name, np.asarray(emu[name], np.float32))[:, : ref.shape[-1]]
111
+ p_dev, p_emu = float(pcc(d, ref)), float(pcc(e, ref))
112
+ ratio = (1 - p_dev) / max(1 - p_emu, 1e-12)
113
+ report["taps"][name] = {"pcc_dev": p_dev, "pcc_emu": p_emu, "err_ratio": ratio}
114
+ print(f"{name:26s} dev {p_dev:.6f} emu {p_emu:.6f} (1-pcc) ratio {ratio:6.2f}", flush=True)
115
+ probs = plan.unsort(np.asarray(dev["pred_probs"], np.float32).reshape(-1, 32)[:, :27])
116
+ finally:
117
+ runner.release()
118
+ graph.release()
119
+ ort = np.asarray(ort_probs, np.float32)
120
+ report["device"] = {"pred_probs_pcc": float(pcc(probs, ort)),
121
+ "argmax_agreement_raw": float(argmax_agreement(probs[:n_raw], ort[:n_raw]))}
122
+ report["emulation_result"] = {"pred_probs_pcc": float(pcc(emu_probs, ort)),
123
+ "argmax_agreement_raw": float(argmax_agreement(emu_probs[:n_raw], ort[:n_raw]))}
124
+ print("device", report["device"], "emulation", report["emulation_result"])
125
+ args.out.parent.mkdir(parents=True, exist_ok=True)
126
+ args.out.write_text(json.dumps(report, indent=1))
127
+ return 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())
code/scripts/fetch_samples.sh ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Download the public sample data that frnet-p150 may NOT redistribute (license unstated or non-commercial)
4
+ # into ${FRNET_SAMPLES:-$HOME/.cache/tt_frnet/samples}, sha256-checked. Tests and benchmarks read it from there.
5
+ # Nothing downloaded here is executed; parse it with numpy / sqlite3 only.
6
+ set -euo pipefail
7
+ DEST="${FRNET_SAMPLES:-$HOME/.cache/tt_frnet/samples}"
8
+ mkdir -p "$DEST"
9
+
10
+ fetch() { # fetch <url> <sha256> <file name>
11
+ local url="$1" sum="$2" name="$3"
12
+ if [ -f "$DEST/$name" ] && echo "$sum $DEST/$name" | sha256sum -c --status; then
13
+ echo "ok $name"; return
14
+ fi
15
+ curl -fL --retry 3 -o "$DEST/$name.part" "$url"
16
+ echo "$sum $DEST/$name.part" | sha256sum -c --status || { echo "sha256 mismatch: $name" >&2; exit 1; }
17
+ mv "$DEST/$name.part" "$DEST/$name"; echo "fetched $name"
18
+ }
19
+
20
+ # Example (Autoware demo rosbag; pinned in autoware/ansible/roles/demo_artifacts/tasks/main.yaml:48-53):
21
+ # fetch https://autoware-files.s3.us-west-2.amazonaws.com/recordings/bags/demos/sample-rosbag.zip \
22
+ # 5f9d36353393b3d249212153c19049822b1298db56512aa045b4f7f6fc37cf88 sample-rosbag.zip
23
+
24
+ echo "samples in $DEST"
code/scripts/make_demo.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Demo media of the model card and the wider TT-vs-CPU agreement check on public frames (workspace tool: it reads
4
+ the public-dataset inputs and CPU runs of ``research/frnet/public_data`` and the raw datasets for the camera images;
5
+ none of them ship).
6
+
7
+ Two steps, so the device is held only for inference:
8
+
9
+ # 1. device (tt-env, under bin/devrun): the production API on the shipped samples, the demo frames and every
10
+ # converted public frame (PandaSet 019 / 029 every 5th frame, all 81 nuScenes mini_val key-frames), both
11
+ # profiles; per-frame p150 classes + agreement with the CPU reference (ONNX Runtime fp32 runs of the research
12
+ # pipeline: research/frnet/public_data/runs)
13
+ bin/devrun -t 1800 -- python code/scripts/make_demo.py run --out logs/frnet/m5_docs/demo
14
+ # 2. CPU (research venv: OpenCV for the camera JPEGs): the renders into media/
15
+ tools/research-venv/bin/python code/scripts/make_demo.py render --out logs/frnet/m5_docs/demo --media media
16
+
17
+ ``run`` writes ``<out>/run/<frame>_<variant>.npz`` (``pred``: the p150 class of every input row, 255 = not scored:
18
+ ego crop box or profile skip; ``cpu``: the reference's, same layout) and ``<out>/agreement.json`` (per frame and per
19
+ set: raw-point argmax agreement of p150 vs CPU, N / M, timing). Agreement here is on the class decision of every raw
20
+ point the node scores; the frozen device gates (``tests/test_e2e_device.py``) additionally compare the probabilities.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ import numpy as np
31
+
32
+ HERE = Path(__file__).resolve()
33
+ CODE = HERE.parents[1]
34
+ sys.path.insert(0, str(CODE))
35
+
36
+ WS = Path("/home/ubuntu/experiments/tt-models")
37
+ PUB = WS / "research" / "frnet" / "public_data"
38
+ IGNORE = 255
39
+ VARIANTS = ("ot128", "qt128")
40
+ # camera + BEV renders (PandaSet front camera, nuScenes CAM_FRONT); TT vs CPU compares; the PandaSet BEV sequence
41
+ CAMERA_FRAMES = ["pandaset_019_40", "pandaset_029_40", "nuscenes_scene-0103_20"]
42
+ COMPARE_FRAMES = ["pandaset_019_40", "nuscenes_scene-0916_40"]
43
+ GIF_FRAMES = [f"pandaset_019_{k:02d}" for k in range(0, 80, 5)]
44
+
45
+
46
+ def public_frames():
47
+ return sorted(p.stem for p in (PUB / "inputs").glob("*.npz"))
48
+
49
+
50
+ # ------------------------------------------------------------------------------------------------ run (device)
51
+ def cmd_run(a) -> int:
52
+ from tt_frnet import FRNet
53
+ from tt_frnet.ttaw.server.smoke import decode_array
54
+
55
+ out = Path(a.out)
56
+ (out / "run").mkdir(parents=True, exist_ok=True)
57
+ frames = public_frames() if not a.frames else a.frames
58
+ report = {"frames": {}, "sets": {}, "shipped": {}}
59
+ for variant in a.variants:
60
+ t0 = time.perf_counter()
61
+ with FRNet.from_pretrained(variant=variant, weights_dir=a.weights_dir) as model:
62
+ load_s = time.perf_counter() - t0
63
+ dev = model.info.get("device")
64
+ print(f"[{variant}] loaded in {load_s:.1f} s, device {dev}", flush=True)
65
+ report.setdefault("device", {})[variant] = dev
66
+ # the shipped synthetic sample vs its stored reference body
67
+ sample = CODE / "tt_frnet" / "samples" / f"synthetic_{variant}.npz"
68
+ res = model(sample, calibration={"preset": f"synthetic_{variant}"})
69
+ ref = json.loads(sample.with_name(f"synthetic_{variant}.{variant}.reference.json").read_text())
70
+ ref_labels = np.asarray(decode_array(ref["labels"]).values, np.uint8)
71
+ np.savez_compressed(out / "run" / f"synthetic_{variant}.npz", points=res.points, pred=res.label_ids,
72
+ cpu=ref_labels, num_points_raw=res.num_points_raw)
73
+ report["shipped"][variant] = {"agreement": float(np.mean(res.label_ids == ref_labels)),
74
+ "n": len(res), "n_raw": int(res.num_points_raw),
75
+ "timing_ms": res.timing_ms}
76
+ print(f"[{variant}] shipped: {report['shipped'][variant]}", flush=True)
77
+ agree_rows = {"pandaset": [0, 0], "nuscenes": [0, 0]}
78
+ per_set = {"pandaset": [], "nuscenes": []}
79
+ for name in frames:
80
+ z = np.load(PUB / "inputs" / f"{name}.npz", allow_pickle=False)
81
+ xyzi, T = z["xyzi"], z["T_base_link_sensor"]
82
+ run = np.load(PUB / "runs" / f"{name}_{variant}.npz", allow_pickle=False)
83
+ cpu = np.full(len(xyzi), IGNORE, np.uint8)
84
+ cpu[run["input_rows"]] = run["class_id"]
85
+ t1 = time.perf_counter()
86
+ res = model(xyzi, calibration={"T_base_link_from_sensor": T.tolist()})
87
+ ms = (time.perf_counter() - t1) * 1e3
88
+ pred = np.full(len(xyzi), IGNORE, np.uint8)
89
+ skipped = res.meta.get("skipped")
90
+ if not skipped:
91
+ pred[res.input_index] = res.label_ids[: res.num_points_raw]
92
+ same_rows = bool(np.array_equal(pred != IGNORE, cpu != IGNORE))
93
+ scored = cpu != IGNORE
94
+ n_eq = int(np.sum(pred[scored] == cpu[scored]))
95
+ n_sc = int(scored.sum())
96
+ rec = {"variant": variant, "skipped": skipped, "same_rows": same_rows, "n_raw": n_sc,
97
+ "agree": n_eq / n_sc if n_sc else None, "n": len(res), "ms": round(ms, 1)}
98
+ report["frames"][f"{name}.{variant}"] = rec
99
+ kind = name.split("_")[0]
100
+ if n_sc:
101
+ agree_rows[kind][0] += n_eq
102
+ agree_rows[kind][1] += n_sc
103
+ per_set[kind].append(rec["agree"])
104
+ np.savez_compressed(out / "run" / f"{name}_{variant}.npz", pred=pred, cpu=cpu)
105
+ print(f"[{variant}] {name}: agree {rec['agree']} rows_equal {same_rows} skipped {skipped} "
106
+ f"{ms:.0f} ms", flush=True)
107
+ for kind, (eq, n) in agree_rows.items():
108
+ if n:
109
+ report["sets"][f"{kind}.{variant}"] = {
110
+ "frames": len(per_set[kind]), "points": n, "pooled": eq / n,
111
+ "min_frame": min(per_set[kind]), "median_frame": float(np.median(per_set[kind]))}
112
+ print(f"[{variant}] sets: { {k: v for k, v in report['sets'].items() if k.endswith(variant)} }",
113
+ flush=True)
114
+ (out / "agreement.json").write_text(json.dumps(report, indent=1, default=float))
115
+ bad = [k for k, r in report["frames"].items() if not r["same_rows"]]
116
+ if bad:
117
+ print(f"scored rows differ from the CPU reference on {bad}", flush=True)
118
+ return 1
119
+ return 0
120
+
121
+
122
+ # ------------------------------------------------------------------------------------------------ render (CPU)
123
+ ATTR_PANDASET = ("Contains data from PandaSet (Scale AI and Hesai), https://pandaset.org, licensed under CC BY 4.0 and "
124
+ "the PandaSet Dataset Terms. Changes: Pandar64 sweep re-expressed in an estimated sensor frame, "
125
+ "front-camera image downscaled and dimmed, points and labels overlaid. Scale AI and Hesai do not "
126
+ "endorse this work. Cite: P. Xiao et al., PandaSet: Advanced Sensor Suite Dataset for Autonomous "
127
+ "Driving, ITSC 2021.")
128
+ ATTR_NUSCENES = ("Rendered from the nuScenes dataset, (c) Motional AD Inc., CC BY-NC-SA 4.0 and the nuScenes Terms of "
129
+ "Use (https://www.nuscenes.org/terms-of-use). Non-commercial use only; adaptations under the same "
130
+ "license. Motional does not endorse this work. Changes: camera image downscaled and dimmed, LiDAR "
131
+ "points and labels overlaid. Cite: H. Caesar et al., nuScenes: A Multimodal Dataset for Autonomous "
132
+ "Driving, CVPR 2020; W. K. Fong et al., Panoptic nuScenes, RA-L 2022 (lidarseg labels).")
133
+ NC_BADGE = "CC BY-NC-SA 4.0 · non-commercial"
134
+
135
+
136
+ def _research_render():
137
+ sys.path.insert(0, str(PUB / "scripts"))
138
+ sys.path.insert(0, str(WS / "datasets" / "tools"))
139
+ import frnet_public_common as C # noqa: E402 (workspace code)
140
+ import frnet_public_render as R # noqa: E402
141
+
142
+ return C, R
143
+
144
+
145
+ def _agree_text(rep, key):
146
+ r = rep["frames"].get(key)
147
+ if not r or r["agree"] is None:
148
+ return ""
149
+ return f"p150 vs fp32 CPU reference: {r['agree'] * 100:.2f} % of the {r['n_raw']:,} scored raw points agree"
150
+
151
+
152
+ def render_camera(name: str, run_dir: Path, rep: dict, out_path: Path) -> None:
153
+ import cv2 # noqa: F401 (read_jpeg)
154
+
155
+ C, R = _research_render()
156
+ inp = np.load(PUB / "inputs" / f"{name}.npz", allow_pickle=False)
157
+ meta = json.loads(str(inp["meta_json"]))
158
+ pred = np.load(run_dir / f"{name}_ot128.npz", allow_pickle=False)["pred"]
159
+ gjson = json.loads((PUB / "runs" / f"{name}_ot128.json").read_text())
160
+ proc = pred != IGNORE
161
+ xyz = inp["xyzi"][:, :3]
162
+ agree = _agree_text(rep, f"{name}.ot128")
163
+ if name.startswith("pandaset"):
164
+ from pandaset_meta_loader import project
165
+ from pandaset_seq_loader import load_frame, load_sequence, read_jpeg
166
+
167
+ seq, k = meta["sequence"], meta["frame"]
168
+ m = load_sequence(C.PANDASET_ROOT, seq)
169
+ fr = load_frame(C.PANDASET_ROOT, seq, k, m)
170
+ world = fr["lidar"][inp["src_index"], :3]
171
+ cam = fr["cameras"]["front_camera"]
172
+ img = read_jpeg(cam["path"])
173
+ uv, idx = project(world[proc], cam["pose"], cam["K"], img.shape[1], img.shape[0])
174
+ sel = np.flatnonzero(proc)[idx]
175
+ depth = np.linalg.norm(inp["xyzi"][sel, :3], axis=1)
176
+ cam_im = R.camera_overlay(img, uv, depth, C.PALETTE_RGB[pred[sel]], R.W / img.shape[1])
177
+ bl, br = R.bev(xyz, R.colors_for(pred, ignore_rgb=R.UNPROC)), R.bev(xyz, R.colors_for(inp["gt27"]))
178
+ subtitle = (f"PandaSet sequence {seq}, frame {k:02d} · Hesai Pandar64, one sweep in the sensor frame · Autoware "
179
+ f"lidar_frnet v2.0 weights, Autoware pre- and post-processing, network on one Tenstorrent "
180
+ f"Blackhole p150 (N={gjson['N']:,}, M={gjson['M']:,}). {agree}.")
181
+ cam_caption = "front_camera (52° HFOV), points coloured by the p150 class"
182
+ cap_r = "PandaSet semseg mapped to FRNet classes (dark grey: no T4 counterpart, e.g. Ground, Pickup)"
183
+ footer, badge = ATTR_PANDASET, None
184
+ else:
185
+ import nuscenes_mini as nm
186
+ from pandaset_seq_loader import read_jpeg
187
+
188
+ ds = nm.NuScenesMini(C.NUSC_ROOT)
189
+ s = ds.get("sample", meta["sample_token"])
190
+ sd = ds.sensor_data(s, "LIDAR_TOP")
191
+ pts = ds.lidar(s)
192
+ T_lidar = ds.T_global_ego(sd) @ ds.T_ego_sensor(sd)
193
+ c = ds.cameras(s, ["CAM_FRONT"])["CAM_FRONT"]
194
+ T_cam_lidar = np.linalg.inv(c["T_global_ego"] @ c["T_ego_sensor"]) @ T_lidar
195
+ img = read_jpeg(c["path"])
196
+ xyz_c = nm.transform_points(T_cam_lidar, pts[:, :3].astype(np.float64))
197
+ ok = proc & (xyz_c[:, 2] > 1.0)
198
+ uvw = xyz_c[ok] @ c["K"].T
199
+ uv = uvw[:, :2] / uvw[:, 2:3]
200
+ ins = (uv[:, 0] >= 0) & (uv[:, 0] < img.shape[1]) & (uv[:, 1] >= 0) & (uv[:, 1] < img.shape[0])
201
+ sel = np.flatnonzero(ok)[ins]
202
+ cam_im = R.camera_overlay(img, uv[ins], xyz_c[sel, 2], C.PALETTE_RGB[pred[sel]], R.W / img.shape[1],
203
+ radius=2)
204
+ bl = R.bev(xyz, R.colors_for(pred, ignore_rgb=R.UNPROC), x_range=(-30.0, 50.0), y_half=40.0)
205
+ br = R.bev(xyz, R.colors_for(inp["gt27"]), x_range=(-30.0, 50.0), y_half=40.0)
206
+ subtitle = (f"nuScenes {meta['scene']}, key-frame {meta['keyframe_index']} (mini_val) · Velodyne HDL-32E "
207
+ f"LIDAR_TOP rotated to x forward · Autoware lidar_frnet v2.0, network on one Tenstorrent Blackhole "
208
+ f"p150 (N={gjson['N']:,}, M={gjson['M']:,}). Domain gap: a 32-beam sensor (+10/-30°) vs the "
209
+ f"128-beam OT128 of the training data. {agree}.")
210
+ cam_caption = "CAM_FRONT, points coloured by the p150 class"
211
+ cap_r = "nuScenes-lidarseg mapped to FRNet classes (dark grey: no T4 counterpart, e.g. terrain, barrier)"
212
+ footer, badge = ATTR_NUSCENES, NC_BADGE
213
+ R.compose("FRNet OT128 · Tenstorrent Blackhole p150 (tt-nn)", subtitle, badge, cam_im, cam_caption, bl, br,
214
+ "p150 prediction (dark grey: not scored, ego crop box)", cap_r, pred[proc], footer, str(out_path))
215
+
216
+
217
+ def render_compare_frame(name: str, run_dir: Path, rep: dict, out_path: Path, variant: str = "ot128") -> None:
218
+ from tt_frnet import viz
219
+ from tt_frnet.api import LABELS
220
+
221
+ inp = np.load(PUB / "inputs" / f"{name}.npz", allow_pickle=False)
222
+ r = np.load(run_dir / f"{name}_{variant}.npz", allow_pickle=False)
223
+ pred, cpu = r["pred"], r["cpu"]
224
+ scored = cpu != IGNORE
225
+ xyz = inp["xyzi"][scored, :3]
226
+ meta = json.loads(str(inp["meta_json"]))
227
+ nusc = name.startswith("nuscenes")
228
+ where = (f"nuScenes {meta['scene']}, key-frame {meta['keyframe_index']} (mini_val)" if nusc else
229
+ f"PandaSet sequence {meta['sequence']}, frame {meta['frame']:02d}")
230
+ header = (f"FRNet {variant.upper()} on {where}: every raw point the node scores, coloured by the p150 class (left) "
231
+ f"and by the fp32 CPU reference's (ONNX Runtime on the deployed ONNX, middle); right: the points whose "
232
+ f"class differs. {_agree_text(rep, f'{name}.{variant}')}.")
233
+ if nusc:
234
+ header += " Non-commercial render (CC BY-NC-SA 4.0)."
235
+ rng = ((-30.0, 50.0), 40.0) if nusc else ((-40.0, 60.0), 50.0)
236
+ img = viz.render_compare(xyz, pred[scored], cpu[scored], class_names=LABELS,
237
+ titles=(f"p150 ({variant.upper()})", "fp32 CPU reference"), header=header,
238
+ footer=(ATTR_NUSCENES.replace("camera image downscaled and dimmed, LiDAR points and "
239
+ "labels overlaid", "LiDAR points rendered from above")
240
+ if nusc else ATTR_PANDASET.replace("front-camera image downscaled and dimmed, "
241
+ "points and labels overlaid",
242
+ "points rendered from above")),
243
+ x_range=rng[0], y_half=rng[1], size=420)
244
+ img.save(out_path, optimize=True)
245
+ print("wrote", out_path, img.size)
246
+
247
+
248
+ def render_shipped(run_dir: Path, rep: dict, media: Path) -> None:
249
+ from tt_frnet import viz
250
+ from tt_frnet.api import LABELS
251
+
252
+ for variant in VARIANTS:
253
+ r = np.load(run_dir / f"synthetic_{variant}.npz", allow_pickle=False)
254
+ s = rep["shipped"][variant]
255
+ header = (f"Shipped sample synthetic_{variant}.npz (generated by code/scripts/make_sample.py, no dataset): "
256
+ f"{s['n']:,} output points ({s['n_raw']:,} raw + Autoware's interpolated points). Left: the p150 "
257
+ f"classes ({variant.upper()} profile); middle: the stored fp32 CPU reference; right: the points "
258
+ f"whose class differs ({s['agreement'] * 100:.2f} % agree).")
259
+ img = viz.render_compare(r["points"], r["pred"], r["cpu"], class_names=LABELS,
260
+ titles=(f"p150 ({variant.upper()})", "fp32 CPU reference (stored)"), header=header,
261
+ x_range=(-60.0, 60.0), y_half=60.0, size=420)
262
+ out = media / f"frnet_synthetic_{variant}_tt_vs_cpu.png"
263
+ img.save(out, optimize=True)
264
+ print("wrote", out, img.size)
265
+
266
+
267
+ def render_gif(run_dir: Path, rep: dict, out_path: Path) -> None:
268
+ from PIL import Image, ImageDraw
269
+
270
+ C, R = _research_render()
271
+ frames = []
272
+ for name in GIF_FRAMES:
273
+ inp = np.load(PUB / "inputs" / f"{name}.npz", allow_pickle=False)
274
+ pred = np.load(run_dir / f"{name}_ot128.npz", allow_pickle=False)["pred"]
275
+ im = R.bev(inp["xyzi"][:, :3], R.colors_for(pred, ignore_rgb=R.UNPROC), size=480)
276
+ canvas = Image.new("RGB", (480, 480 + 44), R.BG)
277
+ canvas.paste(im, (0, 22))
278
+ d = ImageDraw.Draw(canvas)
279
+ k = int(name.split("_")[-1])
280
+ a = rep["frames"][f"{name}.ot128"]["agree"]
281
+ d.text((6, 3), f"FRNet OT128 on p150 · PandaSet 019, frame {k:02d} (every 5th: 2 Hz)", font=R.font(13),
282
+ fill=R.INK)
283
+ d.text((6, 504), f"agreement with the fp32 CPU reference {a * 100:.2f} % · CC BY 4.0, PandaSet (Scale AI, "
284
+ f"Hesai)", font=R.font(11), fill=R.INK2)
285
+ frames.append(canvas.convert("P", palette=Image.ADAPTIVE, colors=128))
286
+ frames[0].save(out_path, save_all=True, append_images=frames[1:], duration=500, loop=0, optimize=True)
287
+ print("wrote", out_path, f"{out_path.stat().st_size / 1e6:.2f} MB")
288
+
289
+
290
+ def cmd_render(a) -> int:
291
+ out, media = Path(a.out), Path(a.media)
292
+ media.mkdir(parents=True, exist_ok=True)
293
+ run_dir = out / "run"
294
+ rep = json.loads((out / "agreement.json").read_text())
295
+ render_shipped(run_dir, rep, media)
296
+ for name in CAMERA_FRAMES:
297
+ nc = "_NC" if name.startswith("nuscenes") else ""
298
+ render_camera(name, run_dir, rep, media / f"frnet_ot128_{name}_tt{nc}.png")
299
+ for name in COMPARE_FRAMES:
300
+ nc = "_NC" if name.startswith("nuscenes") else ""
301
+ render_compare_frame(name, run_dir, rep, media / f"frnet_ot128_{name}_tt_vs_cpu{nc}.png")
302
+ render_gif(run_dir, rep, media / "frnet_ot128_pandaset_019_bev_tt.gif")
303
+ return 0
304
+
305
+
306
+ def main() -> int:
307
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
308
+ sub = ap.add_subparsers(dest="cmd", required=True)
309
+ r = sub.add_parser("run")
310
+ r.add_argument("--out", required=True)
311
+ r.add_argument("--variants", nargs="*", default=list(VARIANTS))
312
+ r.add_argument("--frames", nargs="*", help="input stems (default: every converted public frame)")
313
+ r.add_argument("--weights-dir", default=None)
314
+ d = sub.add_parser("render")
315
+ d.add_argument("--out", required=True)
316
+ d.add_argument("--media", required=True)
317
+ a = ap.parse_args()
318
+ return cmd_run(a) if a.cmd == "run" else cmd_render(a)
319
+
320
+
321
+ if __name__ == "__main__":
322
+ sys.exit(main())
code/scripts/make_goldens.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Goldens of frnet-p150 from the independent fp32 CPU reference (research venv: torch, onnx, onnxruntime; CPU only).
4
+
5
+ cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\
6
+ /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python code/scripts/make_goldens.py --set all
7
+
8
+ Sets (output under ``research/frnet/goldens`` unless ``--out``; never shipped, except ``host``):
9
+
10
+ - ``taps``: the PandaSet 019 f40 sample (staged) in both variants through the bundle's host pre-processing and the
11
+ torch fp32 reference with every module tap (``reference/weights.py`` ``MODULE_TAPS`` + the per-voxel max of every
12
+ point branch, ``backbone.segmax0-4``) -> ``<variant>/pandaset_019_40.taps.npz`` (+ the four network inputs as
13
+ ``input.*`` and ONNX Runtime's ``pred_probs`` as ``ort.pred_probs``) and a ``.json`` with each tap's shape,
14
+ sha256 and its PCC / max |diff| against the same tensor of the deployed graph under ONNX Runtime (intermediates);
15
+ - ``frames``: every catalogued frame (``tests/_data.py`` ``FRAMES``: 16 OT128 + 4 QT128 public goldens, the VLS-128
16
+ x > 0 halves) through the torch reference vs the stored ORT ``pred_probs``: PCC, max |diff|, argmax agreement (raw
17
+ rows and all rows) and the ORT top-2 margin of every disagreeing point -> ``manifest.json``;
18
+ - ``host``: ``code/tt_frnet/tests/goldens/preprocess.json`` -- sha256 of the four network inputs that the RESEARCH
19
+ pre-processing reference (``research/frnet/scripts/frnet_preproc_ref.py``) produces for the PandaSet 019 f40 sample (with
20
+ its crop transform) and for the research synthetic scans, so the host tests can check the bundle's
21
+ pre-processing where the research tree is absent.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import hashlib
27
+ import json
28
+ import sys
29
+ import time
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+
34
+ HERE = Path(__file__).resolve()
35
+ CODE = HERE.parents[1]
36
+ sys.path.insert(0, str(CODE))
37
+
38
+ WS = Path("/home/ubuntu/experiments/tt-models")
39
+ RES = WS / "research" / "frnet"
40
+ STEM = "pandaset_019_40"
41
+
42
+
43
+ def sha(a: np.ndarray) -> str:
44
+ return hashlib.sha256(np.ascontiguousarray(a).tobytes()).hexdigest()
45
+
46
+
47
+ def pcc64(a: np.ndarray, b: np.ndarray) -> float:
48
+ from tt_frnet.ttaw.metrics import pcc
49
+
50
+ return float(pcc(a, b))
51
+
52
+
53
+ def make_taps(out: Path, weights_dir: str, threads: int) -> None:
54
+ import torch
55
+
56
+ from tt_frnet.reference.ort import OrtFRNet
57
+ from tt_frnet.reference.pipeline import FRNetReference
58
+ from tt_frnet.reference.weights import MODULE_TAPS
59
+ from tt_frnet.ttaw.golden import TapRegistry
60
+
61
+ torch.set_num_threads(threads)
62
+ patterns = list(MODULE_TAPS) + ["backbone.segmax*"]
63
+ for variant in ("ot128", "qt128"):
64
+ t0 = time.time()
65
+ ref = FRNetReference(weights_dir, variant, backend="torch", threads=threads)
66
+ taps = TapRegistry(include=patterns)
67
+ staged = CODE.parent / "staging_samples_pandaset" # git-ignored (tests/_data.py)
68
+ T = json.loads((staged / "calib" / f"{STEM}.json").read_text())["T_base_link_from_sensor"]
69
+ run = ref.run(staged / "samples" / f"{STEM}.npz", calibration={"T_base_link_from_sensor": T}, taps=taps)
70
+ t_torch = time.time() - t0
71
+ inp = run.prepared.inputs
72
+ ort_taps = TapRegistry(include=patterns)
73
+ ort = OrtFRNet(weights_dir, variant, threads=threads, intermediates=True, tap_patterns=patterns,
74
+ weights=ref.weights)
75
+ probs_ort = ort(inp.points, inp.coors, inp.voxel_coors, inp.inverse_map, taps=ort_taps)
76
+ del ort
77
+ gold = np.load(RES / "public_data" / "golden" / f"{STEM}_{variant}.npz", allow_pickle=False)
78
+ if not np.array_equal(probs_ort, gold["autoware_pred_probs"]):
79
+ sys.exit(f"{variant}: ORT pred_probs differ from the research golden (onnxruntime version?)")
80
+ meta = {"frame": STEM, "variant": variant, "reference": "tt_frnet.reference.model (torch fp32)",
81
+ "onnx_sha256": ref.weights.onnx_sha256, "torch": torch.__version__, "threads": threads,
82
+ "N": inp.num_points, "N_raw": inp.num_points_raw, "M": inp.num_voxels,
83
+ "torch_s": round(t_torch, 1), "taps": {}}
84
+ arrays = {f"input.{k}": v for k, v in inp.onnx_feeds().items()}
85
+ arrays["ort.pred_probs"] = probs_ort
86
+ for name in taps.names():
87
+ v = np.ascontiguousarray(taps[name], np.float32)
88
+ arrays[name] = v
89
+ entry = {"shape": list(v.shape), "sha256": sha(v)}
90
+ if name in ort_taps:
91
+ o = np.asarray(ort_taps[name], np.float32)
92
+ entry.update(pcc_vs_ort=pcc64(v, o), max_abs_vs_ort=float(np.abs(v - o).max()))
93
+ meta["taps"][name] = entry
94
+ worst = min((e["pcc_vs_ort"], k) for k, e in meta["taps"].items() if "pcc_vs_ort" in e)
95
+ meta["worst_tap_vs_ort"] = {"tap": worst[1], "pcc": worst[0]}
96
+ d = out / variant
97
+ d.mkdir(parents=True, exist_ok=True)
98
+ path = d / f"{STEM}.taps.npz"
99
+ np.savez(path, **arrays)
100
+ meta["file_sha256"] = hashlib.sha256(path.read_bytes()).hexdigest()
101
+ meta["bytes"] = path.stat().st_size
102
+ (d / f"{STEM}.taps.json").write_text(json.dumps(meta, indent=1) + "\n")
103
+ print(f"taps {variant}: {len(taps.names())} taps, {path.stat().st_size / 1e9:.2f} GB, worst vs ORT "
104
+ f"{worst[1]} {worst[0]:.12f}, {time.time() - t0:.0f} s", flush=True)
105
+ del taps, ort_taps, arrays
106
+
107
+
108
+ def make_frames(out: Path, weights_dir: str, threads: int) -> None:
109
+ import torch
110
+
111
+ from tt_frnet.reference.model import FRNetTorch
112
+ from tt_frnet.reference.weights import load_weights
113
+ from tt_frnet.tests import _data
114
+
115
+ torch.set_num_threads(threads)
116
+ nets = {}
117
+ manifest = {"what": "torch fp32 reference vs the ORT pred_probs stored in each research golden",
118
+ "torch": torch.__version__, "threads": threads, "frames": {}}
119
+ for name, (rel, prefix, variant) in _data.FRAMES.items():
120
+ path = RES / rel
121
+ if not path.is_file():
122
+ print(f"{name}: missing {rel}", flush=True)
123
+ continue
124
+ if variant not in nets:
125
+ nets[variant] = FRNetTorch(load_weights(weights_dir, variant))
126
+ inputs, ort_probs, g = _data.load_frame(name)
127
+ t0 = time.time()
128
+ probs = nets[variant](inputs.points, inputs.coors, inputs.voxel_coors, inputs.inverse_map)
129
+ dt = time.time() - t0
130
+ n_raw = inputs.num_points_raw
131
+ a, b = probs.argmax(1), ort_probs.argmax(1)
132
+ diff = np.flatnonzero(a != b)
133
+ top2 = np.sort(ort_probs[diff], axis=1)[:, -2:] if len(diff) else np.zeros((0, 2), np.float32)
134
+ counts = np.bincount(inputs.inverse_map)
135
+ manifest["frames"][name] = {
136
+ "golden": f"research/frnet/{rel}", "variant": variant, "N": inputs.num_points, "N_raw": n_raw,
137
+ "M": inputs.num_voxels, "max_points_per_cell": int(counts.max()),
138
+ "pcc": pcc64(probs, ort_probs), "max_abs": float(np.abs(probs - ort_probs).max()),
139
+ "argmax_agreement": float((a == b).mean()), "argmax_agreement_raw": float((a[:n_raw] == b[:n_raw]).mean()),
140
+ "disagreements": int(len(diff)),
141
+ "disagreement_ort_margin_max": float((top2[:, 1] - top2[:, 0]).max()) if len(diff) else 0.0,
142
+ "torch_s": round(dt, 1)}
143
+ print(f"{name}: N {inputs.num_points} M {inputs.num_voxels} pcc {manifest['frames'][name]['pcc']:.12f} "
144
+ f"max|d| {manifest['frames'][name]['max_abs']:.2e} argmax {manifest['frames'][name]['argmax_agreement']:.6f}"
145
+ f" ({len(diff)} flips) {dt:.1f} s", flush=True)
146
+ out.mkdir(parents=True, exist_ok=True)
147
+ (out / "manifest.json").write_text(json.dumps(manifest, indent=1) + "\n")
148
+
149
+
150
+ def make_host(weights_dir: str) -> None:
151
+ from tt_frnet.reference.config import FRNetConfig
152
+
153
+ sys.path.insert(0, str(RES / "scripts"))
154
+ import frnet_preproc_ref as R # noqa: E402 (workspace research code, numpy only)
155
+
156
+ out = {"what": "sha256 of the four network inputs produced by research/frnet/scripts/frnet_preproc_ref.py "
157
+ "(variant 'autoware'); the bundle's host pre-processing must reproduce them",
158
+ "cases": {}}
159
+ pub = RES / "public_data"
160
+ for variant in ("ot128", "qt128"):
161
+ cfg = FRNetConfig.from_model_dir(weights_dir, variant)
162
+ z = np.load(pub / "inputs" / f"{STEM}.npz", allow_pickle=False)
163
+ T = z["T_base_link_sensor"]
164
+ crop12 = np.concatenate([T[:3, :3].reshape(-1), T[:3, 3]]).astype(np.float32)
165
+ cases = {f"{STEM}.{variant}": (z["xyzi"], crop12, cfg.crop_bounds)}
166
+ pts, _ = R.synthetic_scan(R.CFGS[variant], n_cols=600 if variant == "qt128" else 1200, seed=0)
167
+ cases[f"synthetic_seed0.{variant}"] = (pts, None, None)
168
+ for name, (xyzi, crop, bounds) in cases.items():
169
+ pre = R.autoware_preprocess(xyzi, R.CFGS[variant], sensor_to_ref_12=crop, crop_bounds=bounds,
170
+ variant="autoware")
171
+ out["cases"][name] = {"N0": int(len(xyzi)), "N_raw": int(pre["num_points_raw"]),
172
+ "N": int(pre["num_points"]), "M": int(pre["num_unique"]),
173
+ "input_sha256": sha(np.ascontiguousarray(xyzi, np.float32)),
174
+ **{k: sha(pre[k]) for k in ("points", "coors", "voxel_coors", "inverse_map")}}
175
+ print(f"host {name}: N {pre['num_points']} M {pre['num_unique']}", flush=True)
176
+ path = CODE / "tt_frnet" / "tests" / "goldens" / "preprocess.json"
177
+ path.parent.mkdir(parents=True, exist_ok=True)
178
+ path.write_text(json.dumps(out, indent=1) + "\n")
179
+
180
+
181
+ def main() -> int:
182
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
183
+ ap.add_argument("--set", choices=("taps", "frames", "host", "all"), default="all")
184
+ ap.add_argument("--out", type=Path, default=RES / "goldens")
185
+ ap.add_argument("--weights-dir", default=str(WS / "assets" / "frnet" / "hf_lidar_frnet"))
186
+ ap.add_argument("--threads", type=int, default=4)
187
+ a = ap.parse_args()
188
+ if a.set in ("host", "all"):
189
+ make_host(a.weights_dir)
190
+ if a.set in ("frames", "all"):
191
+ make_frames(a.out, a.weights_dir, a.threads)
192
+ if a.set in ("taps", "all"):
193
+ make_taps(a.out, a.weights_dir, a.threads)
194
+ return 0
195
+
196
+
197
+ if __name__ == "__main__":
198
+ sys.exit(main())
code/scripts/make_sample.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Build the SHIPPED samples of frnet-p150: one synthetic LiDAR sweep per serve profile, generated here (no dataset
4
+ involved, Apache-2.0), and their stored fp32 CPU-reference outputs (the container smoke test's reference). CPU only,
5
+ no device; the reference needs ONNX Runtime (research venv).
6
+
7
+ cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\
8
+ /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python code/scripts/make_sample.py \\
9
+ [--weights-dir <lidar_frnet v2.0 files>] [--check-against <dir with the same files>]
10
+
11
+ The scan is a deterministic ray cast from a sensor 2 m above a flat road (seed 0) against 25 cars, 16 building
12
+ facades, 30 poles / trees and 10 pedestrians (axis-aligned boxes), with 5 % beam dropout, 2 cm range noise and
13
+ random 0-255 intensities, in the **sensor frame** (x forward, y left, z up). The beam layout follows the profile's
14
+ own sensor: 128 rows evenly spread over the vertical field of view (OT128 +15 / -25 deg, QT128 +-52.6 deg) and a
15
+ fixed number of columns over 360 deg, chosen so that the frame lies inside Autoware's profile (the node skips a frame
16
+ with more than 60,000 unique cells): OT128 512 columns, QT128 600 columns. The generator is a copy of the research
17
+ reference ``research/frnet/scripts/frnet_preproc_ref.py synthetic_scan`` (``--check-against`` compares the arrays
18
+ with an earlier output, e.g. the M5 bench inputs ``logs/frnet/m5_baseline/inputs``).
19
+
20
+ Writes, per profile ``<v>`` (``ot128``, ``qt128``):
21
+
22
+ - ``code/tt_frnet/samples/synthetic_<v>.npz``: ``points``, a structured (x, y, z, intensity) float32 array;
23
+ - ``code/tt_frnet/calib/synthetic_<v>.json``: the calibration preset of the same name (sensor -> base_link = a pure
24
+ 2 m lift: base_link on the road under the sensor), for the ego crop box;
25
+ - ``code/tt_frnet/samples/synthetic_<v>.<v>.reference.json``: the ``/predict`` body of the fp32 CPU reference
26
+ (``tt_frnet.reference.pipeline`` with ONNX Runtime on the deployed ONNX, the reference of record), reduced to what
27
+ the container smoke compares (``labels``, ``scores``, ``filter_keep``, npz_compressed) plus the counts.
28
+
29
+ Regenerate the references whenever the reference, the weights or the post-processing changes.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import sys
36
+ from pathlib import Path
37
+
38
+ import numpy as np
39
+
40
+ HERE = Path(__file__).resolve()
41
+ CODE = HERE.parents[1]
42
+ sys.path.insert(0, str(CODE))
43
+
44
+ WS = Path("/home/ubuntu/experiments/tt-models")
45
+ KEEP = ("model", "frame_id", "num_points", "num_points_raw", "class_names", "class_counts")
46
+ # per profile: the sensor's vertical field of view (deg) and rows (the ml_package frustum height) and the columns
47
+ SENSORS = {"ot128": dict(fov_up_deg=15.0, fov_down_deg=-25.0, rows=128, cols=512),
48
+ "qt128": dict(fov_up_deg=52.6, fov_down_deg=-52.6, rows=128, cols=600)}
49
+ SENSOR_HEIGHT = 2.0
50
+ SEED = 0
51
+
52
+
53
+ def synthetic_scan(fov_up_deg: float, fov_down_deg: float, rows: int, n_cols: int, seed: int = 0,
54
+ sensor_height: float = 2.0, max_range: float = 200.0):
55
+ """Ray cast against a ground plane and boxes; returns ``(points (n, 4) float32 x, y, z, intensity, labels)`` in
56
+ a seeded random order (the order a driver would not guarantee either). Same arithmetic and random stream as
57
+ ``research/frnet/scripts/frnet_preproc_ref.py synthetic_scan`` (cfg = the profile's FOV, ``interp_h`` rows)."""
58
+ rng = np.random.default_rng(seed)
59
+ elev = np.deg2rad(np.linspace(fov_down_deg + 0.05, fov_up_deg - 0.05, rows))
60
+ azim = np.deg2rad(np.arange(n_cols) * (360.0 / n_cols) - 180.0)
61
+ E, A = np.meshgrid(elev, azim, indexing="ij")
62
+ d = np.stack([np.cos(E) * np.cos(A), np.cos(E) * np.sin(A), np.sin(E)], axis=-1).reshape(-1, 3)
63
+ t_best = np.full(len(d), np.inf)
64
+ label = np.full(len(d), -1)
65
+ with np.errstate(divide="ignore", invalid="ignore"): # ground plane z = -sensor_height
66
+ tg = np.where(d[:, 2] < -1e-6, -sensor_height / d[:, 2], np.inf)
67
+ upd = tg < t_best
68
+ t_best[upd] = tg[upd]
69
+ label[upd] = 0
70
+ boxes = [] # (cx, cy, cz_bottom, lx, ly, lz, class)
71
+ for _ in range(25): # cars
72
+ cx, cy = rng.uniform(-60, 60), rng.choice([-1, 1]) * rng.uniform(3, 12)
73
+ boxes.append((cx, cy, -sensor_height, 4.5, 1.9, 1.6, 5))
74
+ for side in (-1, 1): # building facades
75
+ for k in range(8):
76
+ boxes.append((-80 + 20 * k, side * rng.uniform(18, 25), -sensor_height, 15, 3, rng.uniform(6, 20), 3))
77
+ for _ in range(30): # poles / trees
78
+ boxes.append((rng.uniform(-70, 70), rng.choice([-1, 1]) * rng.uniform(13, 16), -sensor_height, .4, .4, 6, 4))
79
+ for _ in range(10): # pedestrians
80
+ boxes.append((rng.uniform(-30, 30), rng.choice([-1, 1]) * rng.uniform(12, 14), -sensor_height, .6, .6, 1.7,
81
+ 17))
82
+ for (cx, cy, cz, lx, ly, lz, cls) in boxes:
83
+ lo = np.array([cx - lx / 2, cy - ly / 2, cz])
84
+ hi = np.array([cx + lx / 2, cy + ly / 2, cz + lz])
85
+ with np.errstate(divide="ignore", invalid="ignore"):
86
+ inv = 1.0 / d
87
+ t1 = lo * inv
88
+ t2 = hi * inv
89
+ tmin = np.nanmax(np.minimum(t1, t2), axis=1)
90
+ tmax = np.nanmin(np.maximum(t1, t2), axis=1)
91
+ hit = (tmax >= np.maximum(tmin, 0)) & (tmin > 0.5)
92
+ upd = hit & (tmin < t_best)
93
+ t_best[upd] = tmin[upd]
94
+ label[upd] = cls
95
+ ok = np.isfinite(t_best) & (t_best < max_range)
96
+ ok &= rng.random(len(d)) > 0.05 # beam dropout
97
+ xyz = d[ok] * (t_best[ok, None] + rng.normal(0, 0.02, size=(ok.sum(), 1))) # range noise
98
+ inten = np.clip(rng.normal(40, 25, size=ok.sum()), 0, 255).round() # 0..255 intensity
99
+ pts = np.concatenate([xyz, inten[:, None]], axis=1).astype(np.float32)
100
+ perm = rng.permutation(len(pts))
101
+ return pts[perm], label[ok][perm]
102
+
103
+
104
+ def structured(xyzi: np.ndarray) -> np.ndarray:
105
+ pts = np.zeros(len(xyzi), dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4")])
106
+ for i, name in enumerate(("x", "y", "z", "intensity")):
107
+ pts[name] = xyzi[:, i]
108
+ return pts
109
+
110
+
111
+ def main() -> int:
112
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
113
+ ap.add_argument("--weights-dir", default=str(WS / "assets" / "frnet" / "hf_lidar_frnet"))
114
+ ap.add_argument("--threads", type=int, default=4)
115
+ ap.add_argument("--variants", nargs="*", default=list(SENSORS))
116
+ ap.add_argument("--check-against", type=Path, help="directory with synthetic_<v>.npz to compare the points with")
117
+ ap.add_argument("--no-reference", action="store_true", help="write the scans and presets only")
118
+ a = ap.parse_args()
119
+ samples, calib = CODE / "tt_frnet" / "samples", CODE / "tt_frnet" / "calib"
120
+ T = np.eye(4)
121
+ T[2, 3] = SENSOR_HEIGHT
122
+ for variant in a.variants:
123
+ s = SENSORS[variant]
124
+ xyzi, _ = synthetic_scan(s["fov_up_deg"], s["fov_down_deg"], s["rows"], s["cols"], seed=SEED,
125
+ sensor_height=SENSOR_HEIGHT)
126
+ pts = structured(xyzi)
127
+ stem = f"synthetic_{variant}"
128
+ if a.check_against is not None:
129
+ old = np.load(a.check_against / f"{stem}.npz", allow_pickle=False)["points"]
130
+ if not (old.dtype == pts.dtype and np.array_equal(old, pts)):
131
+ sys.exit(f"{stem}: the points differ from {a.check_against}")
132
+ print(f"{stem}: identical to {a.check_against / (stem + '.npz')}", flush=True)
133
+ np.savez_compressed(samples / f"{stem}.npz", points=pts)
134
+ preset = {"name": stem,
135
+ "description": f"synthetic {variant.upper()}-like scan (code/scripts/make_sample.py): the sensor "
136
+ f"{SENSOR_HEIGHT:g} m above a flat road, base_link on the road under it (a pure lift)",
137
+ "source": "code/scripts/make_sample.py (generated, no dataset; Apache-2.0)",
138
+ "T_base_link_from_sensor": T.tolist()}
139
+ (calib / f"{stem}.json").write_text(json.dumps(preset, indent=1) + "\n")
140
+ print(f"{stem}: {len(pts)} points ({s['rows']} rows x {s['cols']} columns, seed {SEED})", flush=True)
141
+ if a.no_reference:
142
+ continue
143
+ from tt_frnet.io import encode_array
144
+ from tt_frnet.reference.pipeline import FRNetReference
145
+
146
+ ref = FRNetReference(a.weights_dir, variant, backend="ort", threads=a.threads)
147
+ run = ref.run(samples / f"{stem}.npz", calibration={"preset": stem})
148
+ if run.prepared.skipped is not None:
149
+ sys.exit(f"{stem}: outside the {variant} profile: {run.prepared.skipped}")
150
+ out = run.output
151
+ body = {k: v for k, v in out.to_dict().items() if k in KEEP}
152
+ body["labels"] = encode_array(out.label_ids, fmt="npz_compressed", key="labels")
153
+ body["scores"] = encode_array(out.scores, fmt="npz_compressed", key="scores")
154
+ body["filter_keep"] = encode_array(out.filter_keep, fmt="npz_compressed", key="filter_keep")
155
+ inp = run.prepared.inputs
156
+ body["meta"] = {"variant": variant, "reference": "onnxruntime fp32 (deployed ONNX)",
157
+ "onnx_sha256": ref.weights.onnx_sha256, "num_unique_coors": inp.num_voxels,
158
+ "cropped_by_ego_box": int((~inp.keep_mask).sum()),
159
+ "generator": f"code/scripts/make_sample.py synthetic_scan({s['rows']} rows, {s['cols']} "
160
+ f"columns, seed {SEED})"}
161
+ path = samples / f"{stem}.{variant}.reference.json"
162
+ path.write_text(json.dumps(body) + "\n")
163
+ print(f"{stem}: N {inp.num_points} (raw {inp.num_points_raw}) M {inp.num_voxels} -> {path.name} "
164
+ f"({path.stat().st_size / 1e6:.2f} MB)", flush=True)
165
+ return 0
166
+
167
+
168
+ if __name__ == "__main__":
169
+ sys.exit(main())
code/scripts/make_samples.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Build the STAGED PandaSet sample of frnet-p150 and its stored CPU-reference outputs (research venv: numpy, onnx,
4
+ onnxruntime; CPU only, no device). It does not ship until the user approves PandaSet-derived samples
5
+ (research/DATASETS.md N5): it goes to the git-ignored ``staging_samples_pandaset/`` of the bundle checkout (laid out
6
+ like the package: ``samples/``, ``calib/``), where the tests find it; the frozen ``sample`` gates and the PCC frame
7
+ still use it. The shipped samples are the synthetic scans of ``code/scripts/make_sample.py``.
8
+
9
+ cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\
10
+ /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python code/scripts/make_samples.py
11
+
12
+ Writes (from research/frnet/public_data, PandaSet 019 frame 40, CC BY 4.0 + PandaSet Dataset Terms; D18), under
13
+ ``staging_samples_pandaset/``:
14
+
15
+ - ``samples/pandaset_019_40.npz``: ``points``, a structured (x, y, z, intensity) float32 array of the
16
+ sensor-frame Pandar64 sweep, byte-identical values to ``shippable/pandaset_019_40_pandar64_xyzi.npy`` (sha256
17
+ checked against its meta);
18
+ - ``samples/PANDASET_LICENSE.txt``: the PandaSet Dataset Terms of Use (verbatim, sha256 checked);
19
+ - ``calib/pandaset_019_40.json``: the sensor -> base_link transform at full float64 precision (the
20
+ converted input's ``T_base_link_sensor``: its float32 cast is the research golden's crop transform bit for bit;
21
+ the 6-decimal copy in the sample meta is not);
22
+ - ``samples/pandaset_019_40.<variant>.reference.json`` (ot128, qt128): the ``to_dict()`` of the
23
+ reference pipeline with ONNX Runtime on the deployed ONNX (the reference of record, S:frnet:361-365), reduced to
24
+ what the container smoke compares (``labels``, ``scores``, ``filter_keep``, npz_compressed) plus counts. The class
25
+ ids are checked against the research golden ``public_data/golden/pandaset_019_40_<variant>.npz`` (equal).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import hashlib
31
+ import json
32
+ import shutil
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+
38
+ HERE = Path(__file__).resolve()
39
+ CODE = HERE.parents[1]
40
+ sys.path.insert(0, str(CODE))
41
+
42
+ from tt_frnet.io import encode_array # noqa: E402
43
+ from tt_frnet.reference.config import VARIANTS # noqa: E402
44
+
45
+ WS = Path("/home/ubuntu/experiments/tt-models")
46
+ PUB = WS / "research" / "frnet" / "public_data"
47
+ STEM = "pandaset_019_40"
48
+ KEEP = ("model", "frame_id", "num_points", "num_points_raw", "class_names", "class_counts")
49
+
50
+
51
+ def sha256(path: Path) -> str:
52
+ return hashlib.sha256(path.read_bytes()).hexdigest()
53
+
54
+
55
+ def main() -> int:
56
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
57
+ ap.add_argument("--weights-dir", default=str(WS / "assets" / "frnet" / "hf_lidar_frnet"))
58
+ ap.add_argument("--threads", type=int, default=4)
59
+ ap.add_argument("--variants", nargs="*", default=list(VARIANTS))
60
+ a = ap.parse_args()
61
+ staging = CODE.parent / "staging_samples_pandaset"
62
+ samples, calib = staging / "samples", staging / "calib"
63
+ samples.mkdir(parents=True, exist_ok=True)
64
+ calib.mkdir(parents=True, exist_ok=True)
65
+ meta = json.loads((PUB / "shippable" / f"{STEM}_meta.json").read_text())
66
+ src = PUB / "shippable" / f"{STEM}_pandar64_xyzi.npy"
67
+ lic = PUB / "shippable" / "LICENSE.txt"
68
+ for path in (src, lic):
69
+ if sha256(path) != meta["files"][path.name]:
70
+ sys.exit(f"{path}: sha256 differs from {STEM}_meta.json")
71
+ xyzi = np.load(src, allow_pickle=False)
72
+ pts = np.zeros(len(xyzi), dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4")])
73
+ for i, name in enumerate(("x", "y", "z", "intensity")):
74
+ pts[name] = xyzi[:, i]
75
+ np.savez_compressed(samples / f"{STEM}.npz", points=pts)
76
+ shutil.copyfile(lic, samples / "PANDASET_LICENSE.txt")
77
+ T = np.load(PUB / "inputs" / f"{STEM}.npz", allow_pickle=False)["T_base_link_sensor"].astype(np.float64)
78
+ preset = {"name": STEM,
79
+ "description": "PandaSet 019 frame 40: static Pandar64 sensor -> base_link transform (fitted mount; "
80
+ "base_link = the lidar-pose origin projected to the road, x forward) for the ego crop box",
81
+ "source": "research/frnet/public_data/inputs/pandaset_019_40.npz T_base_link_sensor "
82
+ "(calib/pandaset_pandar64_mount.json fit)",
83
+ "T_base_link_from_sensor": [[float(v) for v in row] for row in T]}
84
+ (calib / f"{STEM}.json").write_text(json.dumps(preset, indent=1) + "\n")
85
+ print(f"sample {len(pts)} points, calibration preset {STEM}", flush=True)
86
+
87
+ from tt_frnet.reference.pipeline import FRNetReference
88
+
89
+ for variant in a.variants:
90
+ ref = FRNetReference(a.weights_dir, variant, backend="ort", threads=a.threads)
91
+ run = ref.run(samples / f"{STEM}.npz",
92
+ calibration={"T_base_link_from_sensor": preset["T_base_link_from_sensor"]})
93
+ gold = np.load(PUB / "golden" / f"{STEM}_{variant}.npz", allow_pickle=False)
94
+ out = run.output
95
+ if not (np.array_equal(run.prepared.inputs.points, gold["autoware_points"]) and
96
+ np.array_equal(out.label_ids, gold["autoware_class_id"]) and
97
+ np.array_equal(out.filter_keep, gold["autoware_filter_keep"])):
98
+ sys.exit(f"{variant}: the reference output differs from the research golden")
99
+ body = {k: v for k, v in out.to_dict().items() if k in KEEP}
100
+ body["labels"] = encode_array(out.label_ids, fmt="npz_compressed", key="labels")
101
+ body["scores"] = encode_array(out.scores, fmt="npz_compressed", key="scores")
102
+ body["filter_keep"] = encode_array(out.filter_keep, fmt="npz_compressed", key="filter_keep")
103
+ body["meta"] = {"variant": variant, "reference": "onnxruntime fp32 (deployed ONNX)",
104
+ "onnx_sha256": ref.weights.onnx_sha256, "num_unique_coors": run.prepared.inputs.num_voxels,
105
+ "cropped_by_ego_box": int((~run.prepared.inputs.keep_mask).sum())}
106
+ path = samples / f"{STEM}.{variant}.reference.json"
107
+ path.write_text(json.dumps(body) + "\n")
108
+ print(f"{variant}: N {out.num_points if hasattr(out, 'num_points') else len(out)} "
109
+ f"M {run.prepared.inputs.num_voxels} -> {path.name} ({path.stat().st_size / 1e6:.2f} MB)", flush=True)
110
+ return 0
111
+
112
+
113
+ if __name__ == "__main__":
114
+ sys.exit(main())
code/scripts/precision_study.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """First-order CPU emulation of the TT numerics of FRNet, to size the fp32 islands of the port before the device runs
4
+ (research venv: torch + onnx; CPU only, no device).
5
+
6
+ cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\
7
+ /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python code/scripts/precision_study.py \\
8
+ --out /home/ubuntu/experiments/tt-models/logs/frnet/m0/precision_study.json
9
+
10
+ The TT dataflow (``reference/rewrites.py`` ``FRNetPlanTorch``) runs with operand rounding per site
11
+ (``Emulation``): ``bf16`` (RNE), ``tf32`` (truncation to 10 mantissa bits: probe P12's model of the device's fp32
12
+ matmul, whose measured error is 1.4-1.75x that model's, so ``tf32`` rows are optimistic). Configurations:
13
+
14
+ - ``bf16_all``: bf16 everywhere (weights and activations): the research simulation's worst case;
15
+ - ``plan``: the planned functional numerics -- conv activations bf16 with fp32 weights (PLAN.md 9.3 precision policy),
16
+ the point / voxel layers as an fp32 island (device fp32 matmul ~ tf32), bf16 rows out of the ``ttnn.embedding``
17
+ gathers (bf16-only tables), fp32 interpolation matmuls (~ tf32);
18
+ - ``plan_bf16_points``: ``plan`` with bf16 activations and fp32 weights in the point layers (the fast path);
19
+ - ``plan_fp32_gather``: ``plan`` with exact fp32 gathers (what a fp32 row-gather kernel, K3, would buy);
20
+ - ``plan_fp32_convs`` / ``plan_exact_points``: ``plan`` with fp32 conv activations, resp. exact fp32 point layers
21
+ (e.g. two-term bf16 operands): which island to deepen first when a frame misses a gate.
22
+
23
+ Metrics per frame against the stored ONNX Runtime ``pred_probs`` (the reference of record): PCC over all points and
24
+ argmax agreement on the raw points (the PLAN.md 2.8 gates: >= 0.998 and >= 98.5 %), and the module PCC of the device
25
+ modules' outputs against the exact fp32 dataflow (gate >= 0.999). Indicative only: no math-fidelity phases, no tile
26
+ layouts, no on-device accumulation order.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+
38
+ HERE = Path(__file__).resolve()
39
+ sys.path.insert(0, str(HERE.parents[1]))
40
+
41
+ WS = Path("/home/ubuntu/experiments/tt-models")
42
+ CONFIGS = {
43
+ "bf16_all": "conv_act=bf16,conv_weight=bf16,point_act=bf16,point_weight=bf16,gather=bf16,upsample=bf16",
44
+ "plan": "conv_act=bf16,point_act=tf32,point_weight=tf32,gather=bf16,upsample=tf32",
45
+ "plan_bf16_points": "conv_act=bf16,point_act=bf16,gather=bf16,upsample=tf32",
46
+ "plan_fp32_gather": "conv_act=bf16,point_act=tf32,point_weight=tf32,upsample=tf32",
47
+ "plan_fp32_convs": "point_act=tf32,point_weight=tf32,gather=bf16,upsample=tf32",
48
+ "plan_exact_points": "conv_act=bf16,gather=bf16,upsample=tf32",
49
+ # M1b device follow-up (PORT_LOG "M1 device results"): the device's float32 conv-weight path is not exact
50
+ # (C17 / YOLOX: TF32-like, close to bf16 weights); the point island exact (two-term operands)
51
+ "exact_points_tf32_conv_w": "conv_act=bf16,conv_weight=tf32,gather=bf16,upsample=tf32",
52
+ "exact_points_bf16_conv_w": "conv_act=bf16,conv_weight=bf16,gather=bf16,upsample=tf32",
53
+ }
54
+ FRAMES = ["pandaset_019_40.ot128", "pandaset_029_40.ot128", "nuscenes_scene-0103_20.ot128",
55
+ "nuscenes_scene-0916_20.ot128", "vls128_frame000_fronthalf.ot128", "pandaset_019_40.qt128",
56
+ "vls128_frame000_fronthalf.qt128"]
57
+ MODULES = ("encoder.pf3", "encoder.voxel_feats", "backbone.stem", "backbone.point_stem", "backbone.fusion_stem",
58
+ "backbone.stage1", "backbone.stage2", "backbone.stage3", "backbone.stage4", "decoder.fuse2",
59
+ "decoder.point_fuse2", "head.logits")
60
+
61
+
62
+ def main() -> int:
63
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
64
+ ap.add_argument("--out", type=Path, required=True)
65
+ ap.add_argument("--frames", nargs="*", default=FRAMES)
66
+ ap.add_argument("--configs", nargs="*", default=list(CONFIGS)[:4])
67
+ ap.add_argument("--threads", type=int, default=4)
68
+ ap.add_argument("--weights-dir", default=str(WS / "assets" / "frnet" / "hf_lidar_frnet"))
69
+ a = ap.parse_args()
70
+
71
+ import torch
72
+
73
+ from tt_frnet.host.preprocess import build_plan
74
+ from tt_frnet.reference.config import FRNetConfig
75
+ from tt_frnet.reference.rewrites import Emulation, FRNetPlanTorch
76
+ from tt_frnet.reference.weights import load_weights
77
+ from tt_frnet.tests import _data
78
+ from tt_frnet.ttaw.golden import TapRegistry
79
+ from tt_frnet.ttaw.metrics import pcc
80
+
81
+ torch.set_num_threads(a.threads)
82
+ weights, configs = {}, {}
83
+ result = {"configs": {k: CONFIGS[k] for k in a.configs}, "frames": {}, "note": __doc__.split("\n\n")[2]}
84
+ for frame in a.frames:
85
+ variant = frame.rpartition(".")[2]
86
+ if variant not in weights:
87
+ weights[variant] = load_weights(a.weights_dir, variant)
88
+ configs[variant] = FRNetConfig.from_model_dir(a.weights_dir, variant)
89
+ w = weights[variant]
90
+ inputs, ort_probs, _ = _data.load_frame(frame)
91
+ plan = build_plan(inputs, configs[variant], pre_norm=(w.pre_norm_scale, w.pre_norm_shift))
92
+ n_raw = inputs.num_points_raw
93
+ ref_label = ort_probs.argmax(1)
94
+ t0 = time.time()
95
+ exact = TapRegistry(include=list(MODULES))
96
+ FRNetPlanTorch(w)(plan, taps=exact)
97
+ rows = {}
98
+ for name in a.configs:
99
+ taps = TapRegistry(include=list(MODULES))
100
+ probs = plan.unsort(FRNetPlanTorch(w, emulation=Emulation.parse(CONFIGS[name]))(plan, taps=taps))
101
+ label = probs.argmax(1)
102
+ rows[name] = {"pred_probs_pcc": float(pcc(probs, ort_probs)),
103
+ "argmax_agreement_raw": float((label[:n_raw] == ref_label[:n_raw]).mean()),
104
+ "argmax_agreement_all": float((label == ref_label).mean()),
105
+ "modules": {m: float(pcc(taps[m][: plan.n] if taps[m].shape[0] == plan.n_cap else taps[m],
106
+ exact[m][: plan.n] if exact[m].shape[0] == plan.n_cap else exact[m]))
107
+ for m in MODULES}}
108
+ r = rows[name]
109
+ worst = min(r["modules"].items(), key=lambda kv: kv[1])
110
+ print(f"{frame} {name}: pcc {r['pred_probs_pcc']:.6f} argmax(raw) {r['argmax_agreement_raw']:.4%} "
111
+ f"worst module {worst[0]} {worst[1]:.6f}", flush=True)
112
+ result["frames"][frame] = {"N": inputs.num_points, "N_raw": n_raw, "M": inputs.num_voxels,
113
+ "seconds": round(time.time() - t0, 1), "results": rows}
114
+ a.out.parent.mkdir(parents=True, exist_ok=True)
115
+ a.out.write_text(json.dumps(result, indent=1) + "\n")
116
+ return 0
117
+
118
+
119
+ if __name__ == "__main__":
120
+ sys.exit(main())
code/scripts/profile_ops.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Device profile of frnet-p150: one eager forward and one traced replay of the whole graph between Tracy
4
+ signposts, for one serve profile (``--variant ot128 | qt128``).
5
+
6
+ ROOT=/home/ubuntu/experiments/tt-models
7
+ $ROOT/bin/devrun -t 3600 -- python -m tracy -r -p -v --no-web-server --op-support-count 4000 \\
8
+ -o $ROOT/generated/profiler/frnet_baseline code/scripts/profile_ops.py --variant ot128
9
+ tt-perf-report <ops_perf_results_*.csv> --start-signpost trace --end-signpost trace_end --tracing-mode
10
+ python code/scripts/profile_summary.py <the same csv> profile_summary.json --md profile_summary.md
11
+
12
+ Sections (signposts): ``eager`` .. ``eager_end``, one eager forward after the warm-up (``TraceRunner.run_eager``:
13
+ the same graph and programs as the trace, nothing compiled), and ``trace`` .. ``trace_end``, ``--replays`` replays
14
+ of the captured ``default`` trace (the served device path; the uploaded inputs are the plan of ``--input``). The
15
+ device profiler buffer is flushed (``ttnn.ReadDeviceProfiler``) before and after each section. Inside the eager
16
+ section every module of the graph opens a signpost (``m:<module>``: ``encoder``, ``segmax``, ``frustum2pixel``,
17
+ ``pixel2point``, ``stem``, ``point_stem``, ``fusion_stem``, ``layer<l>``, ``point_fusion<l>``, ``pixel_fusion<l>``,
18
+ ``attention<l>``, ``decoder.up``, ``decoder.fuse1``, ``decoder.fuse2``, ``point_fuse``, ``head``) and every layer a
19
+ layer signpost (``c:<layer>``; ``--no-layer-signposts`` turns them off). Ops that the graph issues inline after a
20
+ module (the residual ``add`` + ``hardswish`` of a BasicBlock, ``addcmul`` after the attention, the softmax after the
21
+ logits) belong to the module signposted last. Through the identical op order the ops of the replay inherit the
22
+ eager attribution (``profile_summary.py``). The output folder must be absolute and outside the bundle (PLAN.md 5.1).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import time
30
+ from pathlib import Path
31
+ from typing import Callable, List, Sequence
32
+
33
+ from tt_frnet import FRNet
34
+ from tt_frnet.ttaw.profiling import read_device_profiler, signpost, signposted
35
+
36
+ CODE = Path(__file__).resolve().parents[1]
37
+ SAMPLES = CODE / "tt_frnet" / "samples" # the shipped synthetic_<variant>.npz
38
+
39
+
40
+ class _Signposted:
41
+ """A callable stand-in for a module that emits its signposts, then calls the module (attributes forward)."""
42
+
43
+ def __init__(self, inner, labels: Sequence[str]):
44
+ self.inner, self._labels = inner, list(labels)
45
+
46
+ def __call__(self, *args, **kwargs):
47
+ for label in self._labels:
48
+ signpost(label)
49
+ return self.inner(*args, **kwargs)
50
+
51
+ def __getattr__(self, name):
52
+ return getattr(self.inner, name)
53
+
54
+
55
+ def module_signposts(graph, layers: bool = True) -> Callable[[], None]:
56
+ """Wrap the modules of a ``tt.model.TtFRNet`` (and the C19 gathers its forward calls) with signposting
57
+ stand-ins; returns the function that restores them."""
58
+ from tt_frnet.tt import model as tt_model
59
+
60
+ undo: List[Callable[[], None]] = []
61
+
62
+ def labels(module: str, layer: str) -> List[str]:
63
+ return [f"m:{module}"] + ([f"c:{layer}"] if layers else [])
64
+
65
+ def swap(obj, attr, lab):
66
+ inner = getattr(obj, attr)
67
+ setattr(obj, attr, _Signposted(inner, lab))
68
+ undo.append(lambda: setattr(obj, attr, inner))
69
+
70
+ def swap_item(seq, key, lab):
71
+ inner = seq[key]
72
+ seq[key] = _Signposted(inner, lab)
73
+ undo.append(lambda: seq.__setitem__(key, inner))
74
+
75
+ for fn_name in ("frustum2pixel", "pixel2point"):
76
+ orig = getattr(tt_model, fn_name)
77
+
78
+ def wrapped(*args, _orig=orig, _name=fn_name, **kwargs):
79
+ signpost(f"m:{_name}")
80
+ return _orig(*args, **kwargs)
81
+
82
+ setattr(tt_model, fn_name, wrapped)
83
+ undo.append(lambda _n=fn_name, _o=orig: setattr(tt_model, _n, _o))
84
+ for i in range(len(graph.ffe)):
85
+ swap_item(graph.ffe, i, labels("encoder", f"encoder.ffe{i}"))
86
+ swap(graph, "compression", labels("encoder", "encoder.compression"))
87
+ swap(graph, "segmax_encoder", labels("segmax", "segmax.encoder"))
88
+ swap(graph, "segmax_points", labels("segmax", "segmax.points"))
89
+ for i in range(len(graph.stem)):
90
+ swap_item(graph.stem, i, labels("stem", f"backbone.stem{i}"))
91
+ swap(graph, "point_stem", labels("point_stem", "backbone.point_stem"))
92
+ swap(graph, "fusion_stem", labels("fusion_stem", "backbone.fusion_stem"))
93
+ for st in graph.stages:
94
+ l = st["index"]
95
+ blocks = st["blocks"]
96
+ for b, (c1, c2, ds) in enumerate(blocks):
97
+ pre = f"backbone.layer{l}.block{b}"
98
+ new = (_Signposted(c1, labels(f"layer{l}", f"{pre}.conv1")),
99
+ _Signposted(c2, labels(f"layer{l}", f"{pre}.conv2")),
100
+ None if ds is None else _Signposted(ds, labels(f"layer{l}", f"{pre}.downsample")))
101
+ blocks[b] = new
102
+ undo.append(lambda _bl=blocks, _b=b, _o=(c1, c2, ds): _bl.__setitem__(_b, _o))
103
+ swap_item(st, "point_fusion", labels(f"point_fusion{l}", f"backbone.point_fusion{l}"))
104
+ swap_item(st, "pixel_fusion", labels(f"pixel_fusion{l}", f"backbone.pixel_fusion{l}"))
105
+ for j in range(len(st["attention"])):
106
+ swap_item(st["attention"], j, labels(f"attention{l}", f"backbone.attention{l}.conv{j}"))
107
+ for s in list(graph.ups):
108
+ swap_item(graph.ups, s, labels("decoder.up", f"decoder.up{s}"))
109
+ swap(graph, "fuse1", labels("decoder.fuse1", "decoder.fuse1"))
110
+ swap(graph, "fuse2", labels("decoder.fuse2", "decoder.fuse2"))
111
+ swap(graph, "point_fuse1", labels("point_fuse", "decoder.point_fuse1"))
112
+ swap(graph, "point_fuse2", labels("point_fuse", "decoder.point_fuse2"))
113
+ for i in range(len(graph.mlps)):
114
+ swap_item(graph.mlps, i, labels("head", f"head.mlp{i}"))
115
+ swap(graph, "logits", labels("head", "head.logits"))
116
+
117
+ def restore() -> None:
118
+ for fn in reversed(undo):
119
+ fn()
120
+
121
+ return restore
122
+
123
+
124
+ def calibration_for(path: Path, spec=None) -> dict:
125
+ """The ``calibration`` of ``path`` (the rules of ``bench.py calibration_for``)."""
126
+ if spec:
127
+ if spec == "none":
128
+ return {"ego_crop_box": None}
129
+ if spec.startswith("preset:"):
130
+ return {"preset": spec[len("preset:"):]}
131
+ return json.loads(Path(spec).read_text())
132
+ stem = path.name.split(".")[0]
133
+ if (CODE / "tt_frnet" / "calib" / f"{stem}.json").is_file():
134
+ return {"preset": stem}
135
+ side = path.parent / f"{stem}.calib.json"
136
+ return json.loads(side.read_text()) if side.is_file() else {"ego_crop_box": None}
137
+
138
+
139
+ def main() -> None:
140
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
141
+ ap.add_argument("--input", default=None, help="default: the shipped synthetic sample of the variant")
142
+ ap.add_argument("--calib", default=None, help="preset:<name> | <calibration .json> | none (bench.py rules)")
143
+ ap.add_argument("--variant", default=None, help="ot128 | qt128 (default: FRNET_VARIANT or ot128)")
144
+ ap.add_argument("--dispatch", default=None, choices=["eth", "worker"])
145
+ ap.add_argument("--cqs", type=int, default=None, choices=[1, 2])
146
+ ap.add_argument("--replays", type=int, default=1, help="replays inside the trace section")
147
+ ap.add_argument("--no-eager", dest="eager", action="store_false")
148
+ ap.add_argument("--no-layer-signposts", dest="layers", action="store_false")
149
+ a = ap.parse_args()
150
+ import ttnn
151
+
152
+ src = Path(a.input or SAMPLES / f"synthetic_{a.variant or os.environ.get('FRNET_VARIANT', 'ot128')}.npz")
153
+ calib = calibration_for(src, a.calib)
154
+ t0 = time.perf_counter()
155
+ with FRNet.from_pretrained(variant=a.variant, dispatch=a.dispatch, num_command_queues=a.cqs) as model:
156
+ tr, dev = model.runner, model.device
157
+ print("loaded in %.1f s:" % (time.perf_counter() - t0), json.dumps(model.device_info), flush=True)
158
+ prepared = model._prepare(points=str(src), calibration=calib)
159
+ out = model(str(src), calibration=calib) # one served frame: upload + replay + readback
160
+ print(f"variant {model.variant}: N {prepared.plan.n} M {prepared.plan.m} (N_cap {prepared.plan.n_cap}, "
161
+ f"M_cap {prepared.plan.m_cap}); {len(out.label_ids)} labelled points", flush=True)
162
+ ttnn.synchronize_device(dev)
163
+ read_device_profiler(dev) # warm-up / capture / first frame out of the buffer
164
+ inputs = model.staging.write(prepared.plan)
165
+ if a.eager:
166
+ restore = module_signposts(model.graph, layers=a.layers)
167
+ try:
168
+ with signposted("eager"):
169
+ tr.run_eager("default", inputs=inputs)
170
+ ttnn.synchronize_device(dev)
171
+ finally:
172
+ restore()
173
+ read_device_profiler(dev)
174
+ tr.upload(inputs)
175
+ ttnn.synchronize_device(dev)
176
+ read_device_profiler(dev)
177
+ with signposted("trace"):
178
+ tr.replay("default", n=a.replays)
179
+ ttnn.synchronize_device(dev)
180
+ read_device_profiler(dev)
181
+ print("trace:", json.dumps(tr.describe()), flush=True)
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
code/scripts/profile_summary.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Summary of a device profile of ``profile_ops.py`` (CPU only, standard library): the OPT_BASELINE.md tables.
4
+
5
+ python code/scripts/profile_summary.py <ops_perf_results_*.csv> <out.json> [--md out.md]
6
+
7
+ Reads the Tracy ops CSV (``python -m tracy -r ...``) and its sections ``eager`` .. ``eager_end`` and ``trace`` ..
8
+ ``trace_end``. The eager section carries per-module (``m:<module>``) and per-conv (``c:<layer>``) signposts; the
9
+ replay has the same op sequence (checked op code by op code), so every replayed op inherits the module / layer of
10
+ the eager op at the same position. Per section: program count, device kernel sum, FW sum, op-to-op gap sum and
11
+ median, span (first FW start -> last FW end at the CSV's chip frequency), cores per op, math fidelities; tables per
12
+ category, module, op code and layer; the top single ops; the largest gaps. If the trace section holds several
13
+ replays (``profile_ops.py --replays N``), the last replay session is summarised. Input shapes of the CSV are those of
14
+ the first op that built a cached program (``--no-op-info-cache`` in tracy shows every op's own), so read shapes of
15
+ later ops of the same program with care; durations are per op.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import csv
21
+ import json
22
+ import re
23
+ import statistics
24
+ from collections import OrderedDict, defaultdict
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ KERNEL = "DEVICE KERNEL DURATION [ns]"
29
+ FW = "DEVICE FW DURATION [ns]"
30
+ GAP = "OP TO OP LATENCY [ns]"
31
+ START = "DEVICE FW START CYCLE"
32
+ END = "DEVICE FW END CYCLE"
33
+
34
+ CATEGORIES = [ # (category, regex on the op code), first match wins
35
+ ("segment_reduce (K1 generic_op)", r"GenericOp|generic_op|Generic"),
36
+ ("conv2d", r"Conv2d|conv2d|Conv2D"),
37
+ ("halo", r"Halo|halo"),
38
+ ("matmul (point linears, 1x1 convs, resize matmuls)", r"Matmul|matmul|Linear"),
39
+ ("slice / DRAM-slicing copies", r"Slice|slice"),
40
+ ("ternary (addcmul)", r"Ternary|ternary|Addcmul|addcmul"),
41
+ ("binary (adds, two-term / K-split sums)", r"Binary|binary"),
42
+ ("softmax", r"Softmax|softmax"),
43
+ ("reduction", r"Reduce|reduce|Max\b|MaxDevice"),
44
+ ("embedding (gathers)", r"Embedding|embedding"),
45
+ ("tilize / untilize / layout", r"Tilize|tilize|Untilize|untilize|ToLayout|Layout"),
46
+ ("reshape / transpose / permute / concat", r"Reshape|reshape|Transpose|transpose|Permute|permute|View|Concat|concat"),
47
+ ("move / copy / reshard", r"Move|move|Copy|copy|Shard|shard|Interleaved|interleaved|Reallocate"),
48
+ ("pad / fill", r"Pad|pad|Fill|fill"),
49
+ ("unary / typecast (hardswish, relu, sigmoid)", r"Unary|unary|Typecast|typecast"),
50
+ ]
51
+ COMPUTE = ("Conv2dDeviceOperation", "MatmulDeviceOperation")
52
+
53
+
54
+ def category(op: str) -> str:
55
+ for name, pat in CATEGORIES:
56
+ if re.search(pat, op):
57
+ return name
58
+ return "other"
59
+
60
+
61
+ def num(row: Dict[str, str], key: str) -> float:
62
+ v = (row.get(key) or "").strip()
63
+ try:
64
+ return float(v) if v else 0.0
65
+ except ValueError:
66
+ return 0.0
67
+
68
+
69
+ def logical(v: str) -> str:
70
+ """'32[16]' (padded[logical]) -> '16'; '7' -> '7'."""
71
+ m = re.match(r"\s*([0-9]+)(?:\[([0-9]+)\])?", v or "")
72
+ return (m.group(2) or m.group(1)) if m else "?"
73
+
74
+
75
+ def chip_freq_mhz(csv_path: Path) -> Optional[float]:
76
+ for cand in [csv_path.parent / "profile_log_device.csv", csv_path.parent.parent / "profile_log_device.csv",
77
+ csv_path.parent / ".logs" / "profile_log_device.csv",
78
+ csv_path.parent.parent / ".logs" / "profile_log_device.csv"]:
79
+ try:
80
+ head = cand.read_text(errors="ignore").splitlines()[0]
81
+ except (OSError, IndexError):
82
+ continue
83
+ m = re.search(r"CHIP_FREQ\[MHz\]:\s*([0-9.]+)", head)
84
+ if m:
85
+ return float(m.group(1))
86
+ return None
87
+
88
+
89
+ def sections(rows: List[Dict[str, str]], name_key: str) -> Dict[str, List[Tuple[Dict[str, str], str, str]]]:
90
+ """{"eager": [(row, module, layer)], "trace": [...]} from the signposts."""
91
+ out: Dict[str, List[Tuple[Dict[str, str], str, str]]] = {"eager": [], "trace": []}
92
+ cur: Optional[str] = None
93
+ module, layer = "", ""
94
+ for r in rows:
95
+ if (r.get("OP TYPE") or "") == "signpost":
96
+ code = r.get(name_key, "")
97
+ if code in ("eager", "trace"):
98
+ cur, module, layer = code, "", ""
99
+ elif code in ("eager_end", "trace_end"):
100
+ cur = None
101
+ elif code.startswith("m:"):
102
+ module, layer = code[2:], ""
103
+ elif code.startswith("c:"):
104
+ layer = code[2:]
105
+ continue
106
+ if cur is not None:
107
+ out[cur].append((r, module, layer))
108
+ return out
109
+
110
+
111
+ def last_replay(sec: List[Tuple[Dict[str, str], str, str]]) -> List[Tuple[Dict[str, str], str, str]]:
112
+ """The ops of the last trace replay session (all of them when the CSV has no session ids)."""
113
+ ids = [(r.get("METAL TRACE REPLAY SESSION ID") or "").strip() for r, _, _ in sec]
114
+ if not any(ids):
115
+ return sec
116
+ last = [i for i in ids if i][-1]
117
+ return [x for x, i in zip(sec, ids) if i == last]
118
+
119
+
120
+ def summarize(sec: List[Tuple[Dict[str, str], str, str]], name_key: str, freq: float, top: int = 10) -> Dict[str, Any]:
121
+ rows = [r for r, _, _ in sec]
122
+ kern = [num(r, KERNEL) / 1e3 for r in rows]
123
+ fw = [num(r, FW) / 1e3 for r in rows]
124
+ gaps = [num(r, GAP) / 1e3 for r in rows]
125
+ if gaps:
126
+ gaps[0] = 0.0 # the first op's gap reaches back across the section boundary
127
+ starts = [num(r, START) for r in rows if num(r, START) > 0]
128
+ ends = [num(r, END) for r in rows if num(r, END) > 0]
129
+ span = (max(ends) - min(starts)) / freq if starts and ends else None
130
+ total = sum(kern) or 1.0
131
+
132
+ def group(keyfn) -> List[Dict[str, Any]]:
133
+ agg: Dict[str, List[float]] = OrderedDict()
134
+ for (r, m, l), k, g in zip(sec, kern, gaps):
135
+ key = keyfn(r, m, l)
136
+ a = agg.setdefault(key, [0, 0.0, 0.0, 0.0])
137
+ a[0] += 1
138
+ a[1] += k
139
+ a[2] += g
140
+ if r.get(name_key, "") in COMPUTE:
141
+ a[3] += k
142
+ return [{"key": key, "ops": int(c), "kernel_us": round(t, 1), "share": round(t / total, 4),
143
+ "gap_us": round(gs, 1), "compute_us": round(cu, 1)}
144
+ for key, (c, t, gs, cu) in sorted(agg.items(), key=lambda kv: -kv[1][1])]
145
+
146
+ fid: Dict[str, int] = defaultdict(int)
147
+ cores = []
148
+ for r in rows:
149
+ f = (r.get("MATH FIDELITY") or "").strip()
150
+ if f:
151
+ fid[f] += 1
152
+ cores.append(num(r, "CORE COUNT"))
153
+ order = sorted(range(len(rows)), key=lambda i: -kern[i])[:top]
154
+ top_ops = []
155
+ for i in order:
156
+ r, m, l = sec[i]
157
+ shape = "x".join(logical(r.get(f"INPUT_0_{d}_PAD[LOGICAL]") or r.get(f"INPUT_0_{d}") or "") for d in "WZYX")
158
+ oshape = "x".join(logical(r.get(f"OUTPUT_0_{d}_PAD[LOGICAL]") or r.get(f"OUTPUT_0_{d}") or "") for d in "WZYX")
159
+ top_ops.append({"index": i, "op": r.get(name_key), "module": m, "layer": l, "kernel_us": round(kern[i], 1),
160
+ "fw_us": round(fw[i], 1), "cores": int(num(r, "CORE COUNT")),
161
+ "fidelity": (r.get("MATH FIDELITY") or "").strip(), "input0": shape, "output0": oshape,
162
+ "in0_dtype": (r.get("INPUT_0_DATATYPE") or "").strip(),
163
+ "in0_mem": (r.get("INPUT_0_MEMORY") or "").strip(),
164
+ "out0_mem": (r.get("OUTPUT_0_MEMORY") or "").strip()})
165
+ gap_order = sorted(range(1, len(rows)), key=lambda i: -gaps[i])[:top]
166
+ top_gaps = [{"index": i, "op": rows[i].get(name_key), "after": rows[i - 1].get(name_key),
167
+ "gap_us": round(gaps[i], 1), "module": sec[i][1], "layer": sec[i][2]} for i in gap_order]
168
+ nz_gaps = [g for g in gaps[1:] if g > 0]
169
+ ops_list = [{"index": i, "op": r.get(name_key), "module": m, "layer": l, "kernel_us": round(k, 2),
170
+ "gap_us": round(g, 2), "cores": int(num(r, "CORE COUNT")),
171
+ "fidelity": (r.get("MATH FIDELITY") or "").strip(),
172
+ "input0": "x".join(logical(r.get(f"INPUT_0_{d}_PAD[LOGICAL]") or r.get(f"INPUT_0_{d}") or "")
173
+ for d in "WZYX"),
174
+ "in0_mem": (r.get("INPUT_0_MEMORY") or "").strip(),
175
+ "out0_mem": (r.get("OUTPUT_0_MEMORY") or "").strip()}
176
+ for i, ((r, m, l), k, g) in enumerate(zip(sec, kern, gaps))]
177
+ return {
178
+ "ops": len(rows), "kernel_sum_us": round(sum(kern), 1), "fw_sum_us": round(sum(fw), 1),
179
+ "op2op_sum_us": round(sum(gaps), 1),
180
+ "op2op_median_us": round(statistics.median(nz_gaps), 2) if nz_gaps else None,
181
+ "span_us": round(span, 1) if span else None, "freq_mhz": freq,
182
+ "cores_median": statistics.median(cores) if cores else None,
183
+ "ops_below_120_cores": sum(1 for c in cores if 0 < c < 120),
184
+ "compute_us": round(sum(k for r, k in zip(rows, kern) if r.get(name_key, "") in COMPUTE), 1),
185
+ "fidelity": dict(fid),
186
+ "by_op": group(lambda r, m, l: r.get(name_key, "?")),
187
+ "by_category": group(lambda r, m, l: category(r.get(name_key, "?"))),
188
+ "by_module": group(lambda r, m, l: m or "?"),
189
+ "by_layer": group(lambda r, m, l: l or f"[{m or '?'}]"),
190
+ "top_ops": top_ops, "top_gaps": top_gaps, "ops_list": ops_list,
191
+ }
192
+
193
+
194
+ def md_table(rows: List[Dict[str, Any]], n: int, label: str) -> str:
195
+ out = [f"| {label} | ops | kernel us | share | compute us | op-to-op us |", "|---|---:|---:|---:|---:|---:|"]
196
+ for r in rows[:n]:
197
+ out.append(f"| {r['key']} | {r['ops']} | {r['kernel_us']:.1f} | {100 * r['share']:.1f} % | "
198
+ f"{r['compute_us']:.1f} | {r['gap_us']:.1f} |")
199
+ return "\n".join(out)
200
+
201
+
202
+ def main() -> None:
203
+ ap = argparse.ArgumentParser()
204
+ ap.add_argument("csv")
205
+ ap.add_argument("out_json")
206
+ ap.add_argument("--md")
207
+ ap.add_argument("--freq-mhz", type=float, default=None)
208
+ a = ap.parse_args()
209
+ path = Path(a.csv)
210
+ with open(path, newline="") as f:
211
+ rows = list(csv.DictReader(f))
212
+ name_key = "OP CODE" if rows and "OP CODE" in rows[0] else "OP NAME"
213
+ freq = a.freq_mhz or chip_freq_mhz(path) or 1350.0
214
+ secs = sections(rows, name_key)
215
+ eager, trace_all = secs["eager"], secs["trace"]
216
+ trace = last_replay(trace_all)
217
+ # the replay inherits the module / layer of the eager op at the same position (identical op sequence)
218
+ same = len(eager) == len(trace) and all(e[0].get(name_key) == t[0].get(name_key) for e, t in zip(eager, trace))
219
+ if same:
220
+ trace = [(r, e[1], e[2]) for (r, _, _), e in zip(trace, eager)]
221
+ res = {"csv": str(path), "columns": list(rows[0].keys()) if rows else [], "freq_mhz": freq,
222
+ "trace_matches_eager_op_sequence": same, "trace_section_ops": len(trace_all),
223
+ "trace_ids": sorted({(r.get("METAL TRACE ID") or "").strip() for r, _, _ in trace_all}),
224
+ "replay_sessions": sorted({(r.get("METAL TRACE REPLAY SESSION ID") or "").strip() for r, _, _ in trace_all}),
225
+ "eager": summarize(eager, name_key, freq), "trace": summarize(trace, name_key, freq)}
226
+ Path(a.out_json).write_text(json.dumps(res, indent=1) + "\n")
227
+ t = res["trace"]
228
+ lines = [f"csv: {path}", f"freq {freq} MHz; trace op sequence == eager: {same}; trace ids {res['trace_ids']}; "
229
+ f"replay sessions {res['replay_sessions']} ({len(trace_all)} ops in the trace section)"]
230
+ for s in ("eager", "trace"):
231
+ x = res[s]
232
+ lines.append(f"{s}: ops {x['ops']} kernel {x['kernel_sum_us']:.1f} us (conv/matmul {x['compute_us']:.1f}) "
233
+ f"fw {x['fw_sum_us']:.1f} us op2op {x['op2op_sum_us']:.1f} us (median {x['op2op_median_us']}) "
234
+ f"span {x['span_us']} us cores median {x['cores_median']} <120 cores: "
235
+ f"{x['ops_below_120_cores']} fidelity {x['fidelity']}")
236
+ lines += ["", md_table(t["by_category"], 20, "category (trace)"), "", md_table(t["by_module"], 20, "module (trace)"),
237
+ "", md_table(t["by_op"], 25, "op code (trace)"), "", md_table(t["by_layer"], 40, "layer (trace)"), "",
238
+ "| # | op | module | layer | kernel us | cores | fidelity | input0 | output0 | in mem | out mem |",
239
+ "|---:|---|---|---|---:|---:|---|---|---|---|---|"]
240
+ for k, o in enumerate(t["top_ops"], 1):
241
+ lines.append(f"| {k} | {o['op']} | {o['module']} | {o['layer']} | {o['kernel_us']:.1f} | {o['cores']} | "
242
+ f"{o['fidelity']} | {o['input0']} | {o['output0']} | {o['in0_mem']} | {o['out0_mem']} |")
243
+ lines += ["", "largest op-to-op gaps (trace): " + json.dumps(t["top_gaps"])]
244
+ text = "\n".join(lines)
245
+ print(text)
246
+ if a.md:
247
+ Path(a.md).write_text(text + "\n")
248
+
249
+
250
+ if __name__ == "__main__":
251
+ main()
code/scripts/visualizer_report.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """ttnn-visualizer memory + graph report of ONE eager forward of the served graph (default precision policy and
4
+ knobs, ETH dispatch, the device grid). Never use it for timing: graph capture and detailed buffer tracing
5
+ synchronise after every op.
6
+
7
+ ROOT=/home/ubuntu/experiments/tt-models; cd $ROOT/bundles/frnet-p150
8
+ $ROOT/bin/devrun -t 2400 -- env PYTHONPATH=$PWD/code:$PYTHONPATH HF_HUB_OFFLINE=1 \\
9
+ python code/scripts/visualizer_report.py $ROOT/generated/ttnn_visualizer/frnet_baseline [--variant ot128]
10
+ ttnn-visualizer --profiler-path $ROOT/generated/ttnn_visualizer/frnet_baseline \\
11
+ --performance-path $ROOT/generated/profiler/frnet_baseline/reports/<ts>
12
+
13
+ What the pytest fixture of ttnn does (ttnn/graph_report.py ``_run_pytest_graph_report_fixture``), in a script:
14
+ warm-up forward (JIT, weight upload), then the second forward inside ``ttnn.graph.full_graph_capture`` (Python I/O,
15
+ stack traces, detailed buffer tracing), ``ttnn.graph_report.import_report`` -> ``db.sqlite``, and ``config.json``
16
+ (the settings the capture ran with). The intermediate graph JSON is deleted after the import. The inputs are the
17
+ device plan of ``--input`` (default: the shipped synthetic sample of the variant with its calibration preset).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import time
24
+ from pathlib import Path
25
+
26
+ CODE = Path(__file__).resolve().parents[1]
27
+ SAMPLES = CODE / "tt_frnet" / "samples" # the shipped synthetic_<variant>.npz
28
+
29
+
30
+ def main() -> None:
31
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
32
+ ap.add_argument("out_dir")
33
+ ap.add_argument("--variant", default="ot128")
34
+ ap.add_argument("--input", default=None, help="default: the shipped synthetic sample of the variant")
35
+ ap.add_argument("--calib", default=None, help="preset:<name> | <calibration .json> | none (default: the preset "
36
+ "named like the input, else crop disabled)")
37
+ ap.add_argument("--json-dir", default=None, help="where the intermediate graph JSON goes (default: out_dir; "
38
+ "it can be several GB, e.g. /dev/shm/<name>); deleted after the import")
39
+ a = ap.parse_args()
40
+ out_dir = Path(a.out_dir).resolve()
41
+ out_dir.mkdir(parents=True, exist_ok=True)
42
+ import ttnn
43
+
44
+ from tt_frnet.api import FRNet
45
+ from tt_frnet.device import close_device, describe_device, open_device
46
+ from tt_frnet.host.frame import FRNetPreprocessor
47
+ from tt_frnet.reference.config import FRNetConfig
48
+ from tt_frnet.reference.weights import load_weights
49
+ from tt_frnet.tt.layers import free
50
+ from tt_frnet.tt.model import TtFRNet, default_policy, frame_arrays
51
+ from tt_frnet.ttaw.tensors import to_host_tensor
52
+
53
+ src = Path(a.input or SAMPLES / f"synthetic_{a.variant}.npz")
54
+ if a.calib == "none":
55
+ calib = {"ego_crop_box": None}
56
+ elif a.calib and a.calib.startswith("preset:"):
57
+ calib = {"preset": a.calib[len("preset:"):]}
58
+ elif a.calib:
59
+ calib = json.loads(Path(a.calib).read_text())
60
+ else:
61
+ stem = src.name.split(".")[0]
62
+ calib = {"preset": stem} if (CODE / "tt_frnet" / "calib" / f"{stem}.json").is_file() else {"ego_crop_box": None}
63
+ weights_path = FRNet.resolve_weights()
64
+ config = FRNetConfig.from_model_dir(weights_path, a.variant)
65
+ weights = load_weights(weights_path, a.variant)
66
+ pre = FRNetPreprocessor(config, pre_norm=(weights.pre_norm_scale, weights.pre_norm_shift))
67
+ prepared = pre(str(src), calib)
68
+ if prepared.plan is None:
69
+ raise SystemExit(f"the frame is skipped: {prepared.skipped}")
70
+ dev = open_device(allow_fallback=False)
71
+ try:
72
+ print("device:", json.dumps(describe_device(dev, 0)), flush=True)
73
+ graph = TtFRNet(dev, weights, config, policy=default_policy())
74
+ arrays = frame_arrays(prepared.plan, graph.m_cap)
75
+ inp = {}
76
+ for key, (shape, dtype) in graph.input_specs().items():
77
+ tdt = ttnn.float32 if dtype == "float32" else ttnn.uint32
78
+ t = ttnn.allocate_tensor_on_device(ttnn.Shape(list(shape)), tdt, ttnn.ROW_MAJOR_LAYOUT, dev,
79
+ ttnn.DRAM_MEMORY_CONFIG)
80
+ ttnn.copy_host_to_device_tensor(to_host_tensor(arrays[key].reshape(shape), dtype, ttnn.ROW_MAJOR_LAYOUT), t)
81
+ inp[key] = t
82
+
83
+ def forward():
84
+ out = graph.forward(inp)
85
+ ttnn.synchronize_device(dev)
86
+ free(out)
87
+
88
+ t0 = time.perf_counter()
89
+ forward() # warm-up: JIT + weight upload (host writes)
90
+ print(f"warm-up forward {time.perf_counter() - t0:.1f} s", flush=True)
91
+ json_dir = Path(a.json_dir).resolve() if a.json_dir else out_dir
92
+ json_dir.mkdir(parents=True, exist_ok=True)
93
+ json_path = json_dir / "graph_capture.json"
94
+ t0 = time.perf_counter()
95
+ with ttnn.graph.full_graph_capture(str(json_path)):
96
+ forward()
97
+ print(f"captured forward {time.perf_counter() - t0:.1f} s -> {json_path} "
98
+ f"({json_path.stat().st_size / 1e6:.1f} MB)", flush=True)
99
+ free(*inp.values())
100
+ graph.release()
101
+ finally:
102
+ close_device(dev)
103
+ t0 = time.perf_counter()
104
+ db = ttnn.graph_report.import_report(json_path, out_dir)
105
+ ttnn.save_config_to_json_file(out_dir / "config.json")
106
+ cfg = json.loads((out_dir / "config.json").read_text()) # record what the capture ran with
107
+ cfg.update({"enable_fast_runtime_mode": False, "enable_graph_report": True, "enable_graph_python_stack_traces": True,
108
+ "enable_detailed_buffer_report": True, "root_report_path": str(out_dir.parent),
109
+ "report_name": out_dir.name})
110
+ (out_dir / "config.json").write_text(json.dumps(cfg, indent=4) + "\n")
111
+ print(f"imported in {time.perf_counter() - t0:.1f} s -> {db} ({Path(db).stat().st_size / 1e6:.1f} MB)", flush=True)
112
+ json_path.unlink(missing_ok=True)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
code/tt_frnet/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """FRNet (Autoware lidar_frnet) on a Tenstorrent Blackhole p150 (tt-nn), packaged as changh95/frnet-p150.
3
+
4
+ Python API (see PYTHON.md)::
5
+
6
+ from tt_frnet import FRNet
7
+
8
+ with FRNet.from_pretrained(device_id=0) as model:
9
+ out = model("code/tt_frnet/samples/synthetic_ot128.npz", calibration={"preset": "synthetic_ot128"})
10
+
11
+ Importing the package has no side effects (no device, no ttnn / torch import, no network); the names below load
12
+ on first use. The HTTP server is ``tt_frnet.server.app:app`` (SERVING.md). ``tt_frnet.ttaw`` is the
13
+ vendored shared package of the Autoware ports (``ttaw/VENDORED.json`` records its version and file hashes; never
14
+ edit it here).
15
+ """
16
+
17
+ __version__ = "0.1.0"
18
+ __all__ = ["FRNet", "Output", "open_device", "load_points", "load_image", "__version__"]
19
+
20
+ _LAZY = {
21
+ "FRNet": (".api", "FRNet"),
22
+ "Output": (".api", "Output"),
23
+ "open_device": (".device", "open_device"),
24
+ "load_points": (".io", "load_points"),
25
+ "load_image": (".io", "load_image"),
26
+ }
27
+
28
+
29
+ def __getattr__(name):
30
+ if name in _LAZY:
31
+ import importlib
32
+
33
+ module, attr = _LAZY[name]
34
+ return getattr(importlib.import_module(module, __name__), attr)
35
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
36
+
37
+
38
+ def __dir__():
39
+ return sorted(list(globals()) + __all__)
code/tt_frnet/api.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Python API: FRNet (Autoware lidar_frnet) on one Tenstorrent Blackhole p150.
3
+
4
+ from tt_frnet import FRNet
5
+
6
+ with FRNet.from_pretrained(device_id=0) as model: # weights -> HF cache, device open, traces captured
7
+ out = model("code/tt_frnet/samples/synthetic_ot128.npz", calibration={"preset": "synthetic_ot128"})
8
+ print(out.to_dict()) # the same JSON as POST /predict
9
+
10
+ The contract shared by every bundle of the Autoware collection is the vendored ``ttaw.api_base.ModelBase``
11
+ (BUNDLE_CONVENTIONS.md section 8): ``from_pretrained`` resolves the pinned weights before it claims the chip, opens it
12
+ (ETH dispatch, 12x10), builds the graph and captures every trace variant in ``warmup_variants``, so the first call is
13
+ as fast as the later ones; calls are serialised by a lock (one chip, batch 1) and fill ``timing_ms``; ``close()`` is
14
+ idempotent, also runs at interpreter exit, and closes the chip only if the model opened it. The HTTP server
15
+ (``tt_frnet.server.app``) calls this class, so ``/predict`` and ``model(...)`` agree bit for bit.
16
+
17
+ This module holds the identity of the port and its hooks: host pre- and post-processing ported from Autoware live in
18
+ ``tt_frnet.host`` (shared with the CPU reference ``tt_frnet.reference``, so device and reference see identical
19
+ network inputs), the ttnn graph in ``tt_frnet.tt`` (run by a ``ttaw.trace.TraceRunner``; milestones M1-M3 of
20
+ PORT_LOG.md). Importing it has no side effects: ttnn / torch are imported inside the hooks.
21
+
22
+ Inputs (``host/frame.py`` has the full contract): ``points`` -- ONE sweep in the **sensor frame**, fields x, y, z,
23
+ intensity (raw 0-255); ``calibration`` -- ``{"T_base_link_from_sensor": ...}`` (or ``{"preset": name}`` of
24
+ ``calib/``) for the node's ego crop box, ``ego_crop_box`` to override or disable the box (``None``).
25
+ ``variant``: ``ot128`` (Autoware's default sensor model) or ``qt128``, a load-time choice (weights + grid).
26
+
27
+ Output: :class:`FRNetSegmentation` (a ``ttaw.outputs.Segmentation3D``) in the node's output order: the kept raw
28
+ points (input order) then Autoware's interpolated points; ``label_ids`` = class_id, ``scores`` = probability,
29
+ ``points``, ``filter_keep`` (the filtered cloud), ``input_index``. A frame outside the node's profiles gives an empty
30
+ output whose ``meta["skipped"]`` is the node's message (the node publishes nothing).
31
+ """
32
+ from __future__ import annotations
33
+
34
+ from typing import Any, Dict, Optional
35
+
36
+ from . import io as tio
37
+ from .device import DEVICE_DEFAULTS
38
+ from .host.postprocess import RUNTIME_PARAMS, FRNetSegmentation
39
+ from .reference.config import DEFAULT_VARIANT, VARIANTS
40
+ from .ttaw.api_base import ModelBase
41
+
42
+ __all__ = ["FRNet", "Output", "FRNetSegmentation", "LABELS"]
43
+
44
+ # The result class of this model (ttaw.outputs: Detections3D, Detections2D, Segmentation3D, Mask2D, Trajectory).
45
+ Output = FRNetSegmentation
46
+
47
+ # ml_package_frnet_{ot128,qt128}.param.yaml class_names (identical in both variants; _build_host checks the yaml).
48
+ # Indices 22 / 23 read "debris" / "stroller" here, but the weights were trained with stroller = 22, debris = 23
49
+ # (AWML frnet_1xb8_t4dataset-ot128-seg.py:60-61; S:frnet:314-317): Autoware's names are kept for parity.
50
+ LABELS = ("drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "bus",
51
+ "emergency_vehicle", "train", "truck", "tractor_unit", "semi_trailer", "construction_vehicle", "forklift",
52
+ "kart", "motorcycle", "bicycle", "pedestrian", "personal_mobility", "animal", "pushable_pullable",
53
+ "traffic_cone", "debris", "stroller", "other_stuff", "noise+ghost_point", "unknown")
54
+
55
+
56
+ def _given(value: Any) -> bool:
57
+ """A real input (``None`` and empty containers are 'not given'); never the truth value of an array."""
58
+ if value is None:
59
+ return False
60
+ if isinstance(value, (list, tuple, dict, str)):
61
+ return len(value) > 0
62
+ return True
63
+
64
+
65
+ class FRNet(ModelBase):
66
+ """FRNet (Autoware lidar_frnet) on one Blackhole p150. Create it with :meth:`from_pretrained`."""
67
+
68
+ MODEL_NAME = "frnet-p150"
69
+ ENV_PREFIX = "FRNET" # prefix of the environment knobs (SERVING.md section 3.4)
70
+ DEFAULT_REPO = "AutowareFoundation/lidar_frnet"
71
+ DEFAULT_TAG = "v2.0" # the Autoware ansible artifacts pin
72
+ # the commit DEFAULT_TAG points to (pinned: tags can move)
73
+ DEFAULT_REVISION = "a53a1c11b0b28d31fd50a03abef5db0e830a5b3c"
74
+ ALLOW_PATTERNS = ["frnet_*.onnx", "ml_package_frnet_*.param.yaml", "deploy_metadata.yaml"]
75
+ VARIANTS = list(VARIANTS) # load-time: selects the ONNX, the frustum grid and the trace shapes
76
+ DEFAULT_VARIANT = DEFAULT_VARIANT
77
+ INPUT_KIND = "lidar" # lidar | camera | multicam | lidar+multicam | planner
78
+ CAMERA_ORDER = () # multicam models: the fixed camera order of the network
79
+ POINT_FIELDS = tio.DEFAULT_POINT_FIELDS # columns the network consumes, in order (x, y, z, intensity)
80
+ LABELS = list(LABELS)
81
+ # Per-request knobs (host post-processing only; defaults are Autoware's): host/postprocess.py
82
+ RUNTIME_PARAMS = RUNTIME_PARAMS
83
+ EXTRA_INPUTS = ()
84
+ DEVICE_DEFAULTS = DEVICE_DEFAULTS # validated open parameters (device.py)
85
+
86
+ # ---- port-specific hooks (called by ModelBase; keep host work out of _forward) ---------------------------
87
+ def _build(self) -> None:
88
+ """Host side (config, weights, pre- / post-processors), then the ttnn graph of ``tt_frnet.tt``
89
+ (``TtFRNet``: point encoder, K1 segmented max, gathers, convs, decoder, head) as the ``"default"`` variant of
90
+ a ``ttaw.trace.TraceRunner`` with the per-frame tables as persistent inputs (no capture here; ``_warm_one``
91
+ captures). Warm-up runs an empty frame (every index valid)."""
92
+ from .tt.model import FrameStaging, TtFRNet, build_runner, default_policy
93
+
94
+ self._build_host()
95
+ self.graph = TtFRNet(self.device, self.frnet_weights, self.config, policy=default_policy())
96
+ self.runner = build_runner(self.graph, variants=("default",), name=f"{self.MODEL_NAME}/{self.variant}")
97
+ self.staging = FrameStaging(self.graph) # persistent host buffers of the inputs (written in place)
98
+
99
+ def _build_host(self) -> None:
100
+ """The host half of ``_build`` (no device): usable alone by host tests and tools."""
101
+ from .host.frame import FRNetPreprocessor
102
+ from .host.postprocess import FRNetPostprocessor
103
+ from .reference.config import FRNetConfig
104
+ from .reference.weights import check_variant_grid, load_weights
105
+
106
+ self.config = FRNetConfig.from_model_dir(self.weights_path, self.variant)
107
+ if tuple(self.config.class_names) != tuple(self.LABELS):
108
+ raise ValueError(f"ml_package class_names differ from FRNet.LABELS: {self.config.class_names}")
109
+ self.frnet_weights = load_weights(self.weights_path, self.variant)
110
+ check_variant_grid(self.frnet_weights, self.config.frustum_h, self.config.frustum_w)
111
+ self.pre = FRNetPreprocessor(self.config, pre_norm=(self.frnet_weights.pre_norm_scale,
112
+ self.frnet_weights.pre_norm_shift))
113
+ self.post = FRNetPostprocessor(self.config)
114
+
115
+ def _warm_one(self, variant: Dict[str, Any]) -> None:
116
+ """Warm up and capture the traces of one entry of ``default_warmup_variants()``. ``TraceRunner.capture``
117
+ runs every pending variant eagerly (kernel JIT, program cache) before any capture, then captures with
118
+ program-cache misses forbidden; it is idempotent."""
119
+ self.runner.capture()
120
+
121
+ def _prepare(self, points: Any = None, *, sweeps: Optional[Any] = None, images: Any = None,
122
+ calibration: Any = None, stream: Any = None, inputs: Any = None, **_: Any):
123
+ """Host pre-processing (``host/frame.py``): decoding, the ego crop, float32 projection, fp16 interpolation,
124
+ unique cells, the node's profile checks and the device plan. Client mistakes raise ``tio.InputError``
125
+ (HTTP 400); an out-of-profile frame is ``prepared.skipped`` (no device run, empty output)."""
126
+ if points is None:
127
+ raise tio.InputError("this model needs 'points'")
128
+ extra = [name for name, value in (("sweeps", sweeps), ("images", images), ("inputs", inputs),
129
+ ("stream", stream)) if _given(value)]
130
+ if extra:
131
+ raise tio.InputError(f"FRNet takes one sensor-frame sweep ('points') and its 'calibration' only; got "
132
+ f"{extra}")
133
+ return self.pre(points, calibration)
134
+
135
+ def _forward(self, prepared: Any) -> Any:
136
+ """Upload the frame plan (features, CSR offsets, gather tables) into the persistent device inputs, replay
137
+ the trace, read ``pred_probs`` back (one D2H) and return it as ``(N, 27)`` float32 in the network-input order
138
+ (``plan.unsort``). A skipped frame runs nothing. Runs under the model lock."""
139
+ if prepared.skipped is not None:
140
+ return None
141
+ return self.device_probs(prepared.plan)
142
+
143
+ def device_probs(self, plan: Any) -> Any:
144
+ """``pred_probs`` ``(N, 27)`` float32 (network-input order) of one ``host.preprocess.FramePlan`` (the device
145
+ half of ``_forward``; tests and tools feed stored network inputs through it). Call it under the model
146
+ lock (``model(...)`` does)."""
147
+ import numpy as np
148
+
149
+ raw = self.runner("default", inputs=self.staging.write(plan))
150
+ probs = np.asarray(raw, np.float32).reshape(-1, raw.shape[-1])
151
+ return plan.unsort(probs[:, : self.config.num_classes])
152
+
153
+ def _postprocess(self, raw: Any, prepared: Any, params: Dict[str, Any]) -> Output:
154
+ """Host post-processing (``host/postprocess.py``, ``fill_cloud_kernel``): first-max argmax, probability,
155
+ the class-probability filter -> ``FRNetSegmentation``; ``raw`` = ``pred_probs`` or None (skipped)."""
156
+ meta = {"variant": self.variant, **prepared.stats()}
157
+ self.post.filter_indices(params["filter_classes"]) # an unknown class name is a 400 either way
158
+ if raw is None:
159
+ return self.post.empty(model=self.MODEL_NAME, frame_id=prepared.frame_id, meta=meta)
160
+ inp = prepared.inputs
161
+ return self.post(raw, points=inp.points, num_points_raw=inp.num_points_raw,
162
+ input_index=prepared.input_index, params=params, model=self.MODEL_NAME,
163
+ frame_id=prepared.frame_id, meta=meta)
164
+
165
+ def _release(self) -> None:
166
+ """Release the traces, persistent tensors and device weights; also called when ``from_pretrained`` fails
167
+ half-way (tolerates a partial build)."""
168
+ runner = getattr(self, "runner", None)
169
+ if runner is not None:
170
+ runner.release()
171
+ self.runner = None
172
+ graph = getattr(self, "graph", None)
173
+ if graph is not None:
174
+ graph.release()
175
+ self.graph = None
176
+
177
+ def extra_info(self) -> Dict[str, Any]:
178
+ """Additions to ``model.info`` and ``/info``: the node parameters and the runner's traces."""
179
+ out: Dict[str, Any] = {}
180
+ config = getattr(self, "config", None)
181
+ if config is not None:
182
+ out["frnet"] = config.describe()
183
+ runner = getattr(self, "runner", None)
184
+ if runner is not None:
185
+ out["trace"] = runner.describe()
186
+ graph = getattr(self, "graph", None)
187
+ if graph is not None:
188
+ out["graph"] = graph.describe()
189
+ return out
code/tt_frnet/calib/README.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Calibration presets
2
+
3
+ FRNet takes one LiDAR sweep in the **sensor frame**; the only calibration it uses is the static
4
+ `sensor -> base_link` transform of the node's ego crop box (Autoware looks it up once from TF,
5
+ `lidar_frnet_node.cpp:154-182`). A preset file holds `{"name", "description", "source", "T_base_link_from_sensor":
6
+ 4x4 row-major}` (any `ttaw.io.parse_transform` spelling works, also `{translation, rotation_wxyz}` or
7
+ `{x, y, z, roll, pitch, yaw}`), optionally `"ego_crop_box": [min_x, min_y, min_z, max_x, max_y, max_z]` to replace
8
+ 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.
9
+ The server lists the presets in `/info` (`calibration_presets`); a request may say `"calibration": {"preset":
10
+ "<name>"}` (the Python API too) instead of sending the matrix.
11
+
12
+ | preset | content | source |
13
+ |---|---|---|
14
+ | `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` |
15
+
16
+ The PandaSet 019 frame 40 preset (`pandaset_019_40.json`, the fitted Pandar64 mount) belongs to the staged PandaSet
17
+ sample and moves here with it once PandaSet-derived samples are approved (`staging_samples_pandaset/` of the bundle
18
+ checkout).
code/tt_frnet/calib/synthetic_ot128.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "synthetic_ot128",
3
+ "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)",
4
+ "source": "code/scripts/make_sample.py (generated, no dataset; Apache-2.0)",
5
+ "T_base_link_from_sensor": [
6
+ [
7
+ 1.0,
8
+ 0.0,
9
+ 0.0,
10
+ 0.0
11
+ ],
12
+ [
13
+ 0.0,
14
+ 1.0,
15
+ 0.0,
16
+ 0.0
17
+ ],
18
+ [
19
+ 0.0,
20
+ 0.0,
21
+ 1.0,
22
+ 2.0
23
+ ],
24
+ [
25
+ 0.0,
26
+ 0.0,
27
+ 0.0,
28
+ 1.0
29
+ ]
30
+ ]
31
+ }
code/tt_frnet/calib/synthetic_qt128.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "synthetic_qt128",
3
+ "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)",
4
+ "source": "code/scripts/make_sample.py (generated, no dataset; Apache-2.0)",
5
+ "T_base_link_from_sensor": [
6
+ [
7
+ 1.0,
8
+ 0.0,
9
+ 0.0,
10
+ 0.0
11
+ ],
12
+ [
13
+ 0.0,
14
+ 1.0,
15
+ 0.0,
16
+ 0.0
17
+ ],
18
+ [
19
+ 0.0,
20
+ 0.0,
21
+ 1.0,
22
+ 2.0
23
+ ],
24
+ [
25
+ 0.0,
26
+ 0.0,
27
+ 0.0,
28
+ 1.0
29
+ ]
30
+ ]
31
+ }
code/tt_frnet/device.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Opening the Blackhole p150 the way every published number of frnet-p150 was measured.
3
+
4
+ The implementation is the vendored ``ttaw.device`` (C01): dispatch on the idle ETH cores
5
+ (``ttnn.DispatchCoreConfig(ttnn.DispatchCoreType.ETH)``), which gives a 12x10 = 120-core compute grid on a p150
6
+ (11x10 with WORKER dispatch, the A/B switch). ETH dispatch needs ``patches/tt-metal-eth-dispatch.patch`` on tt-metal
7
+ 44d6650 (the container image is built from a patched tree); if the ETH open fails, a ``RuntimeWarning`` is issued and
8
+ WORKER dispatch is used, unless ``allow_fallback=False``. Never hard-code the grid: use :func:`compute_grid` or
9
+ ``device.compute_with_storage_grid_size()``.
10
+
11
+ This module binds it to the validated open parameters of this port, :data:`DEVICE_DEFAULTS` (the model class uses
12
+ the same dict), which the ``FRNET_DISPATCH``, ``_NUM_CQS``, ``_L1_SMALL``, ``_TRACE_REGION`` and
13
+ ``_WORKER_L1_SIZE`` variables and ``TT_DEVICE_ID`` override per process (SERVING.md section 3.4). Importing it has
14
+ no side effects (``ttnn`` is imported when a device is opened).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import dataclasses
20
+ from typing import Any, Dict, Iterator, Optional
21
+
22
+ from .ttaw.device import DeviceConfig, close_device, compute_grid, core_grid, describe_device, full_core_range_set
23
+
24
+ __all__ = ["ENV_PREFIX", "DEVICE_DEFAULTS", "DeviceConfig", "device_config", "open_device", "device_session",
25
+ "close_device", "describe_device", "compute_grid", "core_grid", "full_core_range_set"]
26
+
27
+ ENV_PREFIX = "FRNET"
28
+
29
+ # Validated device-open parameters of this port (fill per model; the card's numbers are measured with them).
30
+ DEVICE_DEFAULTS: Dict[str, Any] = {
31
+ "num_command_queues": 1, # 1, or 2 when the input upload (CQ1) overlaps the trace (CQ0)
32
+ "l1_small_size": 65536, # L1_SMALL bytes per core (conv config tensors; P5 / P14 conv values, measured at M2)
33
+ "trace_region_size": 256 << 20, # DRAM bytes for the traces of all warm-up variants (measured at M2)
34
+ }
35
+
36
+
37
+ def device_config(**overrides: Any) -> DeviceConfig:
38
+ """:data:`DEVICE_DEFAULTS` < the ``FRNET_*`` / ``TT_DEVICE_ID`` environment < explicit non-None ``overrides``
39
+ (``device_id``, ``dispatch``, ``num_command_queues``, ``l1_small_size``, ``trace_region_size``,
40
+ ``worker_l1_size``, ``allow_fallback``): the same resolution as ``FRNet.from_pretrained``."""
41
+ config = DeviceConfig.from_env(ENV_PREFIX, **DEVICE_DEFAULTS)
42
+ return dataclasses.replace(config, **{k: v for k, v in overrides.items() if v is not None})
43
+
44
+
45
+ def open_device(device_id: Optional[int] = None, *, dispatch: Optional[str] = None, **overrides: Any):
46
+ """Open one chip like the published numbers: ETH dispatch (``dispatch="worker"`` is the A/B opt-in) and this
47
+ port's sizes. Close it with :func:`close_device`, or use :func:`device_session`."""
48
+ return device_config(device_id=device_id, dispatch=dispatch, **overrides).open()
49
+
50
+
51
+ @contextlib.contextmanager
52
+ def device_session(device_id: Optional[int] = None, **overrides: Any) -> Iterator[Any]:
53
+ """``with device_session() as dev:`` opens with :func:`open_device` and always closes, also when the body
54
+ raises."""
55
+ device = open_device(device_id, **overrides)
56
+ try:
57
+ yield device
58
+ finally:
59
+ close_device(device)
code/tt_frnet/host/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Host pre- and post-processing of FRNet with the exact ``autoware_lidar_frnet`` semantics (numpy only), shared by
3
+ the device path (``api.py``) and the CPU reference (``reference/pipeline.py``), so TT and CPU always see identical
4
+ network inputs:
5
+
6
+ - ``preprocess.py`` the node's pre-processing (ego crop, float32 projection, fp16 farthest-point interpolation,
7
+ counting-sort unique cells, profile checks) -> the four ONNX inputs; and the TT device plan
8
+ (points sorted by cell and padded to N_cap, per-cell segments, the fp32 8-d features with
9
+ ``pre_norm`` folded, the gather tables that replace every scatter of the graph);
10
+ - ``frame.py`` one request -> a prepared frame (decoding, calibration / crop transform, profile verdict);
11
+ - ``postprocess.py`` ``fill_cloud_kernel``: first-max argmax, probability, palette, class-probability filter, and
12
+ the ``FRNetSegmentation`` output.
13
+
14
+ Every function is tested against the research reference scripts (research/frnet/scripts/frnet_preproc_ref.py and
15
+ the independent verify_preproc_ort_independent.py) and the stored research goldens (``tests/test_host_vs_research.py``).
16
+ """
code/tt_frnet/host/frame.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """One request -> one prepared frame (the host half of ``FRNet._prepare``), numpy only.
3
+
4
+ Input contract (research/frnet/SPEC.md section 2; the node's ``~/input/pointcloud``):
5
+
6
+ - ``points``: ONE LiDAR sweep in the **sensor frame** (x forward, y left, z up), fields ``x, y, z, intensity``
7
+ (intensity raw, as the node casts it: 0-255 for uint8 drivers, floats passed through); any ``ttaw.io.load_points``
8
+ form. Rows with a non-finite x / y / z are dropped by the decoder (PCL ``is_dense=false`` clouds); the output's
9
+ ``input_index`` refers to the decoded rows;
10
+ - ``calibration`` (optional mapping, or ``{"preset": name}`` of ``calib/<name>.json``):
11
+ ``T_base_link_from_sensor`` (any ``ttaw.io.parse_transform`` spelling; alias ``T_ref_from_sensor``) -- the static
12
+ transform the node looks up once for its ego crop box (``lidar_frnet_node.cpp:154-182``); ``ego_crop_box`` --
13
+ ``[min_x, min_y, min_z, max_x, max_y, max_z]`` in base_link or a mapping of those keys (default: Autoware's
14
+ ``frnet.param.yaml`` box), ``null`` / all zeros = crop disabled (the node's own switch).
15
+
16
+ With the crop enabled and no transform, the node skips every frame (its TF lookup fails); here that is a client error
17
+ (``InputError``, HTTP 400) naming both fixes. A frame outside the node's profiles is not an error: the node logs and
18
+ publishes nothing, so :class:`Prepared` carries ``skipped`` (the node's message), no device run happens and the
19
+ output is empty with that reason.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any, Dict, Mapping, Optional, Tuple
26
+
27
+ import numpy as np
28
+
29
+ from .. import io as tio
30
+ from ..reference.config import FRNetConfig, parse_crop_box
31
+ from .preprocess import FramePlan, NetworkInputs, autoware_preprocess, build_plan, crop_transform_12, profile_errors
32
+
33
+ __all__ = ["Prepared", "FRNetPreprocessor", "CALIB_DIR", "TRANSFORM_KEYS"]
34
+
35
+ CALIB_DIR = Path(__file__).resolve().parents[1] / "calib"
36
+ TRANSFORM_KEYS = ("T_base_link_from_sensor", "T_ref_from_sensor")
37
+ POINT_FIELDS = ("x", "y", "z", "intensity")
38
+
39
+
40
+ @dataclass
41
+ class Prepared:
42
+ """A prepared frame. ``skipped`` set: the node would publish nothing (no device run)."""
43
+
44
+ num_input_points: int
45
+ inputs: Optional[NetworkInputs]
46
+ plan: Optional[FramePlan]
47
+ skipped: Optional[str]
48
+ crop: Dict[str, Any] = field(default_factory=dict)
49
+ frame_id: str = "sensor"
50
+
51
+ @property
52
+ def input_index(self) -> np.ndarray:
53
+ """(num_points_raw,) int64: decoded input row of every raw output point."""
54
+ if self.inputs is None:
55
+ return np.zeros(0, np.int64)
56
+ return np.flatnonzero(self.inputs.keep_mask).astype(np.int64)
57
+
58
+ def stats(self) -> Dict[str, Any]:
59
+ out: Dict[str, Any] = {"num_points_input": self.num_input_points, "ego_crop": dict(self.crop)}
60
+ if self.inputs is not None:
61
+ out.update(self.inputs.stats())
62
+ if self.plan is not None:
63
+ out["plan"] = self.plan.stats()
64
+ if self.skipped is not None:
65
+ out["skipped"] = self.skipped
66
+ return out
67
+
68
+
69
+ class FRNetPreprocessor:
70
+ """``FRNetPreprocessor(config, pre_norm=(scale, shift))(points, calibration)`` -> :class:`Prepared`.
71
+ ``plan=False`` stops after the four network inputs (the CPU reference needs nothing else)."""
72
+
73
+ def __init__(self, config: FRNetConfig, *, pre_norm: Optional[Tuple[np.ndarray, np.ndarray]] = None,
74
+ calib_dir: Optional[Path] = CALIB_DIR, plan: bool = True):
75
+ self.config = config
76
+ self.pre_norm = pre_norm
77
+ self.calib_dir = calib_dir
78
+ self.plan = plan
79
+
80
+ # ---- calibration -------------------------------------------------------------------------------------------
81
+ def crop_setup(self, calibration: Optional[Mapping[str, Any]]) -> Tuple[Optional[np.ndarray], Tuple[float, ...],
82
+ Dict[str, Any]]:
83
+ """``calibration`` -> ``(transform 12 float32 or None, bounds (6,), description)``; client mistakes raise
84
+ ``InputError``."""
85
+ if calibration is not None and not isinstance(calibration, Mapping):
86
+ raise tio.InputError("calibration must be an object")
87
+ calib = tio.resolve_calibration(calibration, self.calib_dir) if calibration is not None else {}
88
+ unknown = sorted(set(calib) - set(TRANSFORM_KEYS) - {"ego_crop_box", "name", "description", "source"})
89
+ if unknown:
90
+ raise tio.InputError(f"calibration: unknown keys {unknown}; FRNet takes {list(TRANSFORM_KEYS)[:1]} "
91
+ "and ego_crop_box")
92
+ bounds = tuple(self.config.crop_bounds)
93
+ if "ego_crop_box" in calib:
94
+ try:
95
+ bounds = parse_crop_box(calib["ego_crop_box"])
96
+ except ValueError as e:
97
+ raise tio.InputError(f"calibration.{e}") from None
98
+ given = [k for k in TRANSFORM_KEYS if k in calib and calib[k] is not None]
99
+ if len(given) > 1:
100
+ raise tio.InputError(f"calibration: give one of {list(TRANSFORM_KEYS)}, not both")
101
+ T12 = None
102
+ if given:
103
+ T = tio.parse_transform(calib[given[0]], field=f"calibration.{given[0]}")
104
+ R = T[:3, :3]
105
+ if not (np.allclose(R @ R.T, np.eye(3), atol=1e-4) and abs(np.linalg.det(R) - 1.0) < 1e-4):
106
+ raise tio.InputError(f"calibration.{given[0]}: the rotation is not a proper rotation matrix")
107
+ T12 = crop_transform_12(T)
108
+ enabled = bool(np.any(np.asarray(bounds, np.float64).astype(np.float32) != 0.0))
109
+ if enabled and T12 is None:
110
+ raise tio.InputError(
111
+ "the ego crop box (Autoware frnet.param.yaml filter.ego_crop_box) needs the static sensor -> base_link "
112
+ "transform: pass calibration={'T_base_link_from_sensor': <4x4 | {translation, rotation_*} | "
113
+ "{x, y, z, roll, pitch, yaw}>} (or a calib preset), or disable the crop with "
114
+ "calibration={'ego_crop_box': None} (the node skips every frame when its TF lookup fails)")
115
+ desc = {"enabled": enabled, "bounds": [float(b) for b in bounds],
116
+ "reference_frame": self.config.crop_reference_frame}
117
+ if T12 is not None:
118
+ desc["sensor_to_ref_12"] = [float(v) for v in T12]
119
+ return T12, bounds, desc
120
+
121
+ # ---- one frame ---------------------------------------------------------------------------------------------
122
+ def __call__(self, points: Any, calibration: Optional[Mapping[str, Any]] = None) -> Prepared:
123
+ if points is None:
124
+ raise tio.InputError("this model needs 'points'")
125
+ T12, bounds, crop_desc = self.crop_setup(calibration)
126
+ cloud = tio.load_points(points)
127
+ xyzi = cloud.select(POINT_FIELDS)
128
+ frame_id = cloud.frame_id if cloud.frame_id not in ("", "base_link") else "sensor"
129
+ n0 = int(len(xyzi))
130
+ errs = profile_errors(n0, None, self.config)
131
+ if errs:
132
+ return Prepared(n0, None, None, errs[0], crop_desc, frame_id)
133
+ inputs = autoware_preprocess(xyzi, self.config, sensor_to_ref_12=T12, crop_bounds=bounds)
134
+ errs = profile_errors(n0, inputs, self.config)
135
+ if errs:
136
+ return Prepared(n0, inputs, None, errs[0], crop_desc, frame_id)
137
+ plan = build_plan(inputs, self.config, pre_norm=self.pre_norm) if self.plan else None
138
+ return Prepared(n0, inputs, plan, None, crop_desc, frame_id)
code/tt_frnet/host/postprocess.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Host post-processing of ``autoware_lidar_frnet`` (research/frnet/SPEC.md section 5), numpy only.
3
+
4
+ ``fill_cloud_kernel`` (``PKG/lib/postprocess/postprocess_kernel.cu:200-264`` @ universe ``9ceaccf``), one thread per
5
+ point over all ``N`` points (interpolated ones included):
6
+
7
+ - ``class_id``: ``best = -1e9f``, ``class_id = num_classes - 1``, then ``if (prob > best)`` over the classes in
8
+ order -- the **first** maximum wins and a NaN never wins (:func:`autoware_argmax`); ``probability = best``;
9
+ - segmentation cloud: (x, y, z of the network input points, class_id uint8, probability float32);
10
+ - visualization cloud: (x, y, z, ``palette[class_id]``: ``(r << 16) | (g << 8) | b`` as a float32);
11
+ - filtered cloud: a point is dropped iff **any** filter class has ``prob >= threshold`` (float32 compare against
12
+ ``static_cast<float>(0.05)``); kept raw points are the original records, kept interpolated points are synthesized
13
+ from x, y, z, intensity with the other fields 0. The CUDA kernel appends them with ``atomicAdd`` (any order);
14
+ here the filtered cloud is the mask :attr:`FRNetSegmentation.filter_keep` over the output points.
15
+
16
+ AWML's offline max-probability threshold (``AWML/deploy/postprocessing.py``) is not used by Autoware and not here.
17
+ The output class adds the point arrays the node publishes to ``ttaw.outputs.Segmentation3D``.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
23
+
24
+ import numpy as np
25
+
26
+ from ..reference.config import NODE_PARAMS, FRNetConfig, parse_filter_classes
27
+ from ..ttaw.io import InputError, encode_array
28
+ from ..ttaw.outputs import Segmentation3D
29
+
30
+ __all__ = ["RUNTIME_PARAMS", "autoware_argmax", "filter_keep_mask", "visualization_rgb", "FRNetSegmentation",
31
+ "FRNetPostprocessor"]
32
+
33
+ # Per-request knobs (RT-host), Autoware's defaults (frnet.param.yaml:6-7). ``filter_classes``: comma-separated
34
+ # class names, "" = none; the node allows at most 16.
35
+ RUNTIME_PARAMS: Dict[str, Tuple[Any, Any, Any, Any]] = {
36
+ "class_probability_threshold": (float, 0.0, 1.0, NODE_PARAMS["class_probability_threshold"]),
37
+ "filter_classes": (str, None, None, ",".join(NODE_PARAMS["filter_classes"])),
38
+ }
39
+
40
+ ARGMAX_INIT = np.float32(-1e9)
41
+
42
+
43
+ def autoware_argmax(pred_probs: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
44
+ """``fill_cloud_kernel`` L218-228 -> ``(class_id uint8 (N,), probability float32 (N,))``."""
45
+ p = np.asarray(pred_probs, np.float32)
46
+ if p.ndim != 2:
47
+ raise ValueError(f"pred_probs must be (N, C), got {p.shape}")
48
+ n, c = p.shape
49
+ if n == 0:
50
+ return np.zeros(0, np.uint8), np.zeros(0, np.float32)
51
+ q = np.where(np.isnan(p), np.float32(-np.inf), p)
52
+ idx = np.argmax(q, axis=1) # first maximum (NaN treated as never greater)
53
+ best = q[np.arange(n), idx]
54
+ none = ~(best > ARGMAX_INIT) # no class beat the -1e9 start: class C - 1, prob -1e9
55
+ if none.any():
56
+ idx = idx.copy()
57
+ best = best.copy()
58
+ idx[none] = c - 1
59
+ best[none] = ARGMAX_INIT
60
+ return idx.astype(np.uint8), best.astype(np.float32)
61
+
62
+
63
+ def filter_keep_mask(pred_probs: np.ndarray, class_indices: Sequence[int], threshold: float) -> np.ndarray:
64
+ """``fill_cloud_kernel`` L245-254: True = the point stays in the filtered cloud (no filter class reaches the
65
+ float32 threshold)."""
66
+ p = np.asarray(pred_probs, np.float32)
67
+ thr = np.float32(threshold)
68
+ hit = np.zeros(p.shape[0], bool)
69
+ for c in class_indices:
70
+ hit |= p[:, int(c)] >= thr
71
+ return ~hit
72
+
73
+
74
+ def visualization_rgb(class_id: np.ndarray, config: FRNetConfig) -> np.ndarray:
75
+ """The visualization cloud's packed ``rgb`` float32 per point (``const_palette[class_id]``)."""
76
+ return config.palette_packed[np.asarray(class_id, np.int64)]
77
+
78
+
79
+ @dataclass
80
+ class FRNetSegmentation(Segmentation3D):
81
+ """Per-point classes of one frame in the node's output order: the input points that survive the ego crop box
82
+ (input order, after non-finite rows are dropped), then the interpolated points (``num_points_raw`` raw rows
83
+ first). ``label_ids`` = Autoware ``class_id``, ``scores`` = its ``probability``; ``points`` (N, 4) the network
84
+ input x, y, z, intensity (the segmentation cloud's coordinates); ``filter_keep`` (N,) the filtered cloud's
85
+ membership; ``input_index`` (num_points_raw,) int32 the input row of every raw output point."""
86
+
87
+ points: Optional[np.ndarray] = None
88
+ filter_keep: Optional[np.ndarray] = None
89
+ input_index: Optional[np.ndarray] = None
90
+ num_points_raw: int = 0
91
+ extras: Dict[str, Any] = field(default_factory=dict)
92
+
93
+ def __post_init__(self) -> None:
94
+ super().__post_init__()
95
+ n = len(self)
96
+ if self.points is not None:
97
+ self.points = np.asarray(self.points, np.float32)
98
+ if self.points.ndim != 2 or self.points.shape[0] != n:
99
+ raise ValueError(f"points must be ({n}, C), got {self.points.shape}")
100
+ if self.filter_keep is not None:
101
+ self.filter_keep = np.asarray(self.filter_keep, bool).reshape(n)
102
+ if self.input_index is not None:
103
+ self.input_index = np.asarray(self.input_index, np.int32).reshape(-1) # decoded rows < 2**31
104
+ if self.input_index.shape[0] != self.num_points_raw:
105
+ raise ValueError("input_index must have num_points_raw entries")
106
+ if self.num_points_raw > n:
107
+ raise ValueError("num_points_raw exceeds the number of points")
108
+
109
+ def to_dict(self, output_format: str = "json") -> Dict[str, Any]:
110
+ body = super().to_dict(output_format)
111
+ body["num_points_raw"] = int(self.num_points_raw)
112
+ if self.points is not None:
113
+ body["points"] = encode_array(self.points, key="points")
114
+ if self.filter_keep is not None:
115
+ body["filter_keep"] = encode_array(self.filter_keep, key="filter_keep")
116
+ if self.input_index is not None:
117
+ body["input_index"] = encode_array(self.input_index, key="input_index")
118
+ for k, v in self.extras.items():
119
+ body[k] = encode_array(np.asarray(v), key=k)
120
+ return body
121
+
122
+
123
+ class FRNetPostprocessor:
124
+ """``pred_probs`` (N, 27) of the network input points -> :class:`FRNetSegmentation`."""
125
+
126
+ def __init__(self, config: FRNetConfig):
127
+ self.config = config
128
+
129
+ def filter_indices(self, filter_classes: Any) -> Tuple[int, ...]:
130
+ """Request ``filter_classes`` (comma-separated names) -> class indices; unknown names are a client error
131
+ (the node throws at start-up)."""
132
+ try:
133
+ return self.config.filter_class_indices(parse_filter_classes(filter_classes))
134
+ except ValueError as e:
135
+ raise InputError(f"filter_classes: {e}") from None
136
+
137
+ def __call__(self, pred_probs: np.ndarray, *, points: np.ndarray, num_points_raw: int,
138
+ input_index: Optional[np.ndarray], params: Mapping[str, Any], model: str = "",
139
+ frame_id: str = "sensor", meta: Optional[Dict[str, Any]] = None) -> FRNetSegmentation:
140
+ p = {k: v[3] for k, v in RUNTIME_PARAMS.items()}
141
+ p.update(params or {})
142
+ indices = self.filter_indices(p["filter_classes"])
143
+ class_id, prob = autoware_argmax(pred_probs)
144
+ keep = filter_keep_mask(pred_probs, indices, p["class_probability_threshold"])
145
+ meta = dict(meta or {})
146
+ meta.update(filter={"classes": [self.config.class_names[i] for i in indices],
147
+ "class_probability_threshold": float(p["class_probability_threshold"]),
148
+ "kept": int(keep.sum())})
149
+ return FRNetSegmentation(class_id, class_names=self.config.class_names, scores=prob, model=model,
150
+ frame_id=frame_id, meta=meta, points=points, filter_keep=keep,
151
+ input_index=input_index, num_points_raw=int(num_points_raw))
152
+
153
+ def empty(self, *, model: str = "", frame_id: str = "sensor",
154
+ meta: Optional[Dict[str, Any]] = None) -> FRNetSegmentation:
155
+ """No output (a frame the node refuses: profile checks), with the reason in ``meta``."""
156
+ return FRNetSegmentation(np.zeros(0, np.uint8), class_names=self.config.class_names,
157
+ scores=np.zeros(0, np.float32), model=model, frame_id=frame_id,
158
+ meta=dict(meta or {}), points=np.zeros((0, 4), np.float32),
159
+ filter_keep=np.zeros(0, bool), input_index=np.zeros(0, np.int32), num_points_raw=0)
code/tt_frnet/host/preprocess.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Host pre-processing of ``autoware_lidar_frnet`` (research/frnet/SPEC.md section 3; PLAN.md 2.8), numpy only.
3
+
4
+ Two halves, both deterministic:
5
+
6
+ 1. :func:`autoware_preprocess` -- the node's pre-processing, ``PKG/lib/preprocess/preprocess_kernel.cu`` +
7
+ ``PKG/lib/lidar_frnet.cpp:196-262`` @ universe ``9ceaccf``, producing the four network inputs exactly as
8
+ the deployed ONNX takes them (unpadded: the graph sizes its voxel tensors as ``max(inverse_map) + 1``,
9
+ S:frnet:255-262, so the ONNX Runtime / torch goldens must never see padding):
10
+
11
+ - **ego crop** (``projectPoints_kernel`` L211-228): with the cached static ``sensor -> base_link`` transform as 12
12
+ float32 (row-major R, then t), a point is dropped iff it lies *strictly* inside the box on all three axes; the
13
+ reference-frame coordinates are evaluated in float32 as the kernel writes them
14
+ (``((r0 * x + r1 * y) + r2 * z) + t``; FMA contraction by nvcc could move a point within one ulp of a face);
15
+ - **projection** (``project2d`` L151-163) in float32: ``depth = sqrtf(x^2+y^2+z^2)``, ``yaw = -atan2f(y, x)``,
16
+ ``pitch = asinf(z / depth)``, ``u = 0.5 (yaw / pi + 1) W``, ``v = (1 - (pitch + |down|) / total) H``, NaN -> 0
17
+ (``fmaxf``), clamp to ``[0, W - 1]`` / ``[0, H - 1]``, truncate. float64 math moves 0.33 % of the points to
18
+ another cell (S:frnet:190-193); numpy's float32 ``arctan2`` / ``arcsin`` can still differ from CUDA's
19
+ ``atan2f`` / ``asinf`` by an ulp (unavoidable, documented);
20
+ - **interpolation grid** (L232-258): one point per pixel stored as 4 x fp16 (x, y, z, intensity); the
21
+ **farthest** point wins, by ``depth^2`` computed in float32 from the fp16-rounded x, y, z (exact: products of
22
+ fp16 values are exact in float32, so nvcc's FMA contraction cannot change it). The CUDA race between the
23
+ first plain store and the CAS loop, and the order among equal depths, are fixed here: the farthest point
24
+ wins, ties go to the later input point (the research reference's policy);
25
+ - **interpolation** (``interpolatePoints_kernel`` L332-379): an empty pixel with ``1 <= x <= W - 2`` whose left
26
+ and right neighbours are filled gets ``float(left + right) * 0.5`` (fp16 sum, no azimuth wrap-around), appended
27
+ after the raw points in row-major pixel order (CUDA appends with ``atomicAdd``: non-deterministic there);
28
+ - **frustum cells and unique** (``generateUniqueCoors`` L403-468): ``coors = (0, row, col)``, key
29
+ ``row * W + col``; ``voxel_coors`` = the occupied cells in ascending key order, ``inverse_map`` = the rank of
30
+ each point's cell -- here a counting sort over the ``H * W`` cells (S:frnet:460-466);
31
+ - **profile checks** (``lidar_frnet.cpp:138-149, 227-254``): the input count, ``N`` after interpolation and ``M``
32
+ must lie in the ``num_points`` / ``num_unique_coors`` profiles. The CUDA appends are not bounds-checked (an
33
+ over-profile frame overflows the device buffers before the ``N`` check, S:frnet:201-205); here every count is
34
+ checked after it is known and an out-of-profile frame is refused (:func:`profile_errors`).
35
+
36
+ 2. :func:`build_plan` -- the tables of the TT device graph (PLAN.md 2.8; PORT_LOG.md "trace layout"): the points
37
+ **sorted by cell** (stable, so a cell's points keep their input order) and padded to the capacity ``N_cap`` =
38
+ ``num_points.max``, the per-cell segments (CSR offsets, last row) of the sorted points, the fp32 8-d point
39
+ features with ``pre_norm`` folded in, and the gather indices that replace every scatter of the graph:
40
+ ``point2pix[s]`` (pixel of each sorted point at stride s: pixel2point) and ``pix2vox[s]`` (cell -> voxel row
41
+ or the zero sentinel row ``M_cap``: frustum2pixel; at s > 1 several cells fall on one pixel and the **last
42
+ voxel in voxel order** wins, which is what ORT's sequential ScatterND does, S:frnet:285-287). The network is
43
+ permutation-equivariant in the points, so the host un-permutes the device output with ``plan.order``.
44
+
45
+ Nothing here imports torch, onnx or ttnn.
46
+ """
47
+ from __future__ import annotations
48
+
49
+ from dataclasses import dataclass, field
50
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
51
+
52
+ import numpy as np
53
+
54
+ from ..reference.config import STRIDES, FRNetConfig
55
+
56
+ __all__ = ["NetworkInputs", "FramePlan", "crop_transform_12", "crop_keep_mask", "project2d", "interpolation_grid",
57
+ "interpolate_points", "unique_coors", "autoware_preprocess", "profile_errors", "decorate_points",
58
+ "build_plan", "segmax_rounds", "segmax_shift_tables"]
59
+
60
+ PI_F32 = np.float32(np.pi) # M_PIf
61
+
62
+
63
+ # ------------------------------------------------------------------------------------------------- the node
64
+
65
+ @dataclass
66
+ class NetworkInputs:
67
+ """The four ONNX inputs of one frame, unpadded, plus the bookkeeping of how they were made."""
68
+
69
+ points: np.ndarray # (N, 4) float32: kept raw points (input order), then interpolated points
70
+ coors: np.ndarray # (N, 3) int64 (0, row, col) frustum cell of every point
71
+ voxel_coors: np.ndarray # (M, 3) int64 occupied cells in ascending key order
72
+ inverse_map: np.ndarray # (N,) int64 voxel row of every point
73
+ num_points_raw: int # rows [0, num_points_raw) of `points` are raw input points
74
+ keep_mask: np.ndarray # (N0,) bool: input rows that survived the ego crop box
75
+ crop_applied: bool = True
76
+
77
+ @property
78
+ def num_points(self) -> int:
79
+ return int(self.points.shape[0])
80
+
81
+ @property
82
+ def num_voxels(self) -> int:
83
+ return int(self.voxel_coors.shape[0])
84
+
85
+ @property
86
+ def num_interpolated(self) -> int:
87
+ return self.num_points - self.num_points_raw
88
+
89
+ def onnx_feeds(self) -> Dict[str, np.ndarray]:
90
+ """``{points, coors, voxel_coors, inverse_map}`` as the deployed ONNX binds them (float32 / int64)."""
91
+ return {"points": np.ascontiguousarray(self.points, np.float32),
92
+ "coors": np.ascontiguousarray(self.coors, np.int64),
93
+ "voxel_coors": np.ascontiguousarray(self.voxel_coors, np.int64),
94
+ "inverse_map": np.ascontiguousarray(self.inverse_map, np.int64)}
95
+
96
+ def stats(self) -> Dict[str, Any]:
97
+ counts = np.bincount(self.inverse_map) if self.num_points else np.zeros(1, np.int64)
98
+ return {"num_points_input": int(self.keep_mask.shape[0]), "num_points_raw": self.num_points_raw,
99
+ "num_points_interpolated": self.num_interpolated, "num_points": self.num_points,
100
+ "num_unique_coors": self.num_voxels, "cropped_by_ego_box": int((~self.keep_mask).sum()),
101
+ "crop_applied": self.crop_applied, "max_points_per_cell": int(counts.max()) if counts.size else 0}
102
+
103
+
104
+ def crop_transform_12(T_ref_from_sensor: Any) -> np.ndarray:
105
+ """The node's cached crop transform (``lidar_frnet_node.cpp:160-180``): ``static_cast<float>`` of the 3x3
106
+ rotation (row-major) then the translation of the double ``base_link <- sensor`` transform -> (12,) float32."""
107
+ T = np.asarray(T_ref_from_sensor, np.float64)
108
+ if T.shape not in ((4, 4), (3, 4)):
109
+ raise ValueError(f"crop transform must be 4x4 or 3x4, got {T.shape}")
110
+ return np.concatenate([T[:3, :3].reshape(-1), T[:3, 3]]).astype(np.float32)
111
+
112
+
113
+ def crop_keep_mask(xyz: np.ndarray, sensor_to_ref_12: np.ndarray, bounds_f32: np.ndarray) -> np.ndarray:
114
+ """``projectPoints_kernel`` L211-228: True = keep (outside the open box ``min < p < max`` on some axis)."""
115
+ T = np.asarray(sensor_to_ref_12, np.float32).reshape(12)
116
+ b = np.asarray(bounds_f32, np.float32).reshape(6)
117
+ x, y, z = (np.asarray(xyz[:, i], np.float32) for i in range(3))
118
+ px = ((T[0] * x + T[1] * y) + T[2] * z) + T[9]
119
+ py = ((T[3] * x + T[4] * y) + T[5] * z) + T[10]
120
+ pz = ((T[6] * x + T[7] * y) + T[8] * z) + T[11]
121
+ inside = (px > b[0]) & (px < b[3]) & (py > b[1]) & (py < b[4]) & (pz > b[2]) & (pz < b[5])
122
+ return ~inside
123
+
124
+
125
+ def project2d(xyz: np.ndarray, scale_x: int, scale_y: int, fov: Tuple[np.float32, np.float32, np.float32]
126
+ ) -> Tuple[np.ndarray, np.ndarray]:
127
+ """``project2d`` (preprocess_kernel.cu L151-163) in float32 -> (col, row) int64, clamped then truncated."""
128
+ _, down, total = fov
129
+ x = np.asarray(xyz[:, 0], np.float32)
130
+ y = np.asarray(xyz[:, 1], np.float32)
131
+ z = np.asarray(xyz[:, 2], np.float32)
132
+ depth = np.sqrt(x * x + y * y + z * z, dtype=np.float32)
133
+ yaw = -np.arctan2(y, x).astype(np.float32)
134
+ with np.errstate(invalid="ignore", divide="ignore"):
135
+ pitch = np.arcsin(z / depth).astype(np.float32)
136
+ proj_x = np.float32(0.5) * (yaw / PI_F32 + np.float32(1.0))
137
+ proj_y = np.float32(1.0) - (pitch + np.float32(abs(down))) / np.float32(total)
138
+ proj_x = proj_x * np.float32(scale_x)
139
+ proj_y = proj_y * np.float32(scale_y)
140
+ proj_x = np.where(np.isnan(proj_x), np.float32(0.0), proj_x) # fmaxf(NaN, 0) == 0
141
+ proj_y = np.where(np.isnan(proj_y), np.float32(0.0), proj_y)
142
+ px = np.minimum(np.maximum(proj_x, np.float32(0.0)), np.float32(scale_x) - np.float32(1.0))
143
+ py = np.minimum(np.maximum(proj_y, np.float32(0.0)), np.float32(scale_y) - np.float32(1.0))
144
+ return px.astype(np.int64), py.astype(np.int64)
145
+
146
+
147
+ def interpolation_grid(raw: np.ndarray, config: FRNetConfig) -> Tuple[np.ndarray, np.ndarray]:
148
+ """The interpolation grid of ``projectPoints_kernel`` (L232-258) -> ``(grid (IH * IW, 4) float16, filled
149
+ (IH * IW,) bool)``: per pixel the farthest point by fp16 ``depth^2``, ties to the later input point."""
150
+ iw, ih = config.interp_w, config.interp_h
151
+ grid = np.zeros((iw * ih, 4), np.float16)
152
+ filled = np.zeros(iw * ih, bool)
153
+ if not len(raw):
154
+ return grid, filled
155
+ u, v = project2d(raw[:, :3], iw, ih, config.fov)
156
+ pix = v * iw + u
157
+ h = raw.astype(np.float16) # half4(x, y, z, intensity): __float2half (RNE)
158
+ hf = h[:, :3].astype(np.float32)
159
+ depth2 = hf[:, 0] * hf[:, 0] + hf[:, 1] * hf[:, 1] + hf[:, 2] * hf[:, 2] # exact products (fp16 operands)
160
+ order = np.lexsort((np.arange(len(raw)), depth2, pix)) # by pixel, then depth, then input index
161
+ ps = pix[order]
162
+ last = np.r_[ps[1:] != ps[:-1], True] # last of each pixel group = farthest, latest on ties
163
+ grid[ps[last]] = h[order[last]]
164
+ filled[ps[last]] = True
165
+ return grid, filled
166
+
167
+
168
+ def interpolate_points(grid: np.ndarray, filled: np.ndarray, config: FRNetConfig) -> np.ndarray:
169
+ """``interpolatePoints_kernel`` (L332-379) -> (K, 4) float32 points in row-major pixel order."""
170
+ iw, ih = config.interp_w, config.interp_h
171
+ f2 = filled.reshape(ih, iw)
172
+ cand = np.zeros_like(f2)
173
+ cand[:, 1:-1] = ~f2[:, 1:-1] & f2[:, :-2] & f2[:, 2:]
174
+ ys, xs = np.nonzero(cand)
175
+ g2 = grid.reshape(ih, iw, 4)
176
+ with np.errstate(over="ignore"): # an fp16 overflow is inf, as __hadd gives
177
+ s16 = (g2[ys, xs - 1] + g2[ys, xs + 1]).astype(np.float16) # __half operator+ (RNE)
178
+ return s16.astype(np.float32) * np.float32(0.5)
179
+
180
+
181
+ def unique_coors(coors: np.ndarray, frustum_w: int, frustum_h: int) -> Tuple[np.ndarray, np.ndarray]:
182
+ """``generateUniqueCoors`` (L403-468) as a counting sort over the frustum cells -> ``(voxel_coors (M, 3) int64,
183
+ inverse_map (N,) int64)``: the occupied cells in ascending key order and each point's cell rank."""
184
+ keys = coors[:, 1] * frustum_w + coors[:, 2]
185
+ counts = np.bincount(keys, minlength=frustum_w * frustum_h)
186
+ occupied = counts > 0
187
+ cell_rank = np.cumsum(occupied) - 1
188
+ cells = np.flatnonzero(occupied)
189
+ voxel_coors = np.stack([np.zeros_like(cells), cells // frustum_w, cells % frustum_w], axis=1).astype(np.int64)
190
+ return voxel_coors, cell_rank[keys].astype(np.int64)
191
+
192
+
193
+ def autoware_preprocess(points_xyzi: np.ndarray, config: FRNetConfig, *,
194
+ sensor_to_ref_12: Optional[np.ndarray] = None,
195
+ crop_bounds: Optional[Sequence[float]] = None) -> NetworkInputs:
196
+ """The node's pre-processing of one sensor-frame cloud ``(N0, 4)`` x, y, z, intensity (raw; float32) ->
197
+ :class:`NetworkInputs`. ``crop_bounds`` (6 numbers) overrides the config's box; the crop runs iff a bound is
198
+ non-zero, and then needs ``sensor_to_ref_12`` (:func:`crop_transform_12`): the node skips the frame when its TF
199
+ lookup fails, so a missing transform raises ``ValueError`` here. Profile checks are separate
200
+ (:func:`profile_errors`)."""
201
+ pts = np.ascontiguousarray(points_xyzi, np.float32)
202
+ if pts.ndim != 2 or pts.shape[1] != 4:
203
+ raise ValueError(f"points must be (N, 4) x, y, z, intensity, got {pts.shape}")
204
+ bounds = config.crop_bounds_f32 if crop_bounds is None else np.asarray(crop_bounds, np.float64).astype(np.float32)
205
+ crop = bool(np.any(bounds != 0.0))
206
+ if crop:
207
+ if sensor_to_ref_12 is None:
208
+ raise ValueError("the ego crop box is enabled but no sensor -> base_link transform was given")
209
+ keep = crop_keep_mask(pts[:, :3], sensor_to_ref_12, bounds)
210
+ else:
211
+ keep = np.ones(len(pts), bool)
212
+ raw = pts[keep]
213
+ grid, filled = interpolation_grid(raw, config)
214
+ interp = interpolate_points(grid, filled, config)
215
+ allpts = np.concatenate([raw, interp], axis=0) if len(interp) else raw.copy()
216
+ fx, fy = project2d(allpts[:, :3], config.frustum_w, config.frustum_h, config.fov)
217
+ coors = np.stack([np.zeros_like(fy), fy, fx], axis=1).astype(np.int64)
218
+ voxel_coors, inverse_map = unique_coors(coors, config.frustum_w, config.frustum_h)
219
+ return NetworkInputs(points=np.ascontiguousarray(allpts, np.float32), coors=coors, voxel_coors=voxel_coors,
220
+ inverse_map=inverse_map, num_points_raw=int(len(raw)), keep_mask=keep, crop_applied=crop)
221
+
222
+
223
+ def profile_errors(num_input_points: int, inputs: Optional[NetworkInputs], config: FRNetConfig) -> List[str]:
224
+ """The node's three profile checks, in its order (``lidar_frnet.cpp:138-149`` input count before the crop,
225
+ L227-237 ``N`` after interpolation, L243-254 ``M``); empty when the frame is accepted."""
226
+ lo, opt, hi = config.num_points
227
+ if not lo <= num_input_points <= hi:
228
+ return [f"Number of points ({num_input_points}) in input pointcloud does not match the profile "
229
+ f"[min: {lo}, opt: {opt}, max: {hi}]"]
230
+ if inputs is None:
231
+ return []
232
+ if not lo <= inputs.num_points <= hi:
233
+ return [f"Number of points ({inputs.num_points}) after interpolation does not match the profile "
234
+ f"[min: {lo}, opt: {opt}, max: {hi}]"]
235
+ ulo, uopt, uhi = config.num_unique
236
+ if not ulo <= inputs.num_voxels <= uhi:
237
+ return [f"Number of unique coors ({inputs.num_voxels}) does not match the profile "
238
+ f"[min: {ulo}, opt: {uopt}, max: {uhi}]"]
239
+ return []
240
+
241
+
242
+ # ------------------------------------------------------------------------------------------- the TT device plan
243
+
244
+ def decorate_points(points: np.ndarray, inverse_map: np.ndarray, num_voxels: int, *,
245
+ pre_norm: Optional[Tuple[np.ndarray, np.ndarray]] = None) -> np.ndarray:
246
+ """The frustum encoder's 8-d point features ``[x, y, z, i, |xyz|, x - mx, y - my, z - mz]`` (``m`` = the
247
+ cell mean of x, y, z; AWML ``frustum_encoder.py:79-100``), computed in float64. With ``pre_norm = (scale,
248
+ shift)`` the ``pre_norm`` BatchNorm1d is applied (``x * scale + shift``, float64) -- the port's host fold
249
+ (PLAN.md 2.8: xyz up to +-200 m minus a cell mean needs fp32 or better, never bf16). One rounding to float32."""
250
+ p = np.asarray(points, np.float64)
251
+ n = len(p)
252
+ out = np.empty((n, 8), np.float64)
253
+ out[:, :4] = p
254
+ out[:, 4] = np.sqrt((p[:, :3] ** 2).sum(axis=1))
255
+ if n:
256
+ counts = np.bincount(inverse_map, minlength=num_voxels).astype(np.float64)
257
+ for c in range(3):
258
+ mean = np.bincount(inverse_map, weights=p[:, c], minlength=num_voxels) / np.maximum(counts, 1.0)
259
+ out[:, 5 + c] = p[:, c] - mean[inverse_map]
260
+ if pre_norm is not None:
261
+ scale, shift = (np.asarray(a, np.float64).reshape(8) for a in pre_norm)
262
+ out = out * scale + shift
263
+ return out.astype(np.float32)
264
+
265
+
266
+ def segmax_rounds(max_segment_length: int) -> int:
267
+ """Rounds of the log-step segmented max (Hillis-Steele) that cover segments up to that length:
268
+ ``ceil(log2(L))`` (0 for L <= 1). PLAN.md 1.3: 5 for OT128 (<= 17 points per cell seen), 7 for QT128 (<= 111)."""
269
+ return int(np.ceil(np.log2(max_segment_length))) if max_segment_length > 1 else 0
270
+
271
+
272
+ @dataclass
273
+ class FramePlan:
274
+ """The per-frame host tables of the device graph (module docstring, part 2). Row ``i`` of every per-point table
275
+ is the ``i``-th point in **sorted** order; rows ``N .. N_cap - 1`` are padding (one trailing sentinel segment:
276
+ their voxel id is ``M_cap``, their pixel 0, their features 0, and nothing the output keeps reads them)."""
277
+
278
+ n: int # real points
279
+ m: int # real voxels (occupied cells)
280
+ n_raw: int
281
+ n_cap: int
282
+ m_cap: int
283
+ frustum_hw: Tuple[int, int]
284
+ order: np.ndarray # (N,) int64: sorted row -> network-input point index
285
+ seg_id: np.ndarray # (N_cap,) int64 voxel id of each sorted row (padding: M_cap)
286
+ seg_offsets: np.ndarray # (M + 1,) int64 CSR offsets of the voxels' sorted rows
287
+ seg_last: np.ndarray # (M_cap,) uint32 last sorted row of each voxel (unused voxels: N_cap - 1)
288
+ point2pix: Dict[int, np.ndarray] # stride -> (N_cap,) uint32 pixel of each sorted row (padding: 0)
289
+ pix2vox: Dict[int, np.ndarray] # stride -> ((H/s) * (W/s),) uint32 voxel row or M_cap (zero row)
290
+ features: np.ndarray # (N_cap, 8) float32 decorated (+ pre_norm) features
291
+ max_segment_length: int = 0
292
+ strides: Tuple[int, ...] = field(default=STRIDES)
293
+
294
+ @property
295
+ def rank(self) -> np.ndarray:
296
+ """(N,) int64: network-input point index -> sorted row (the inverse of ``order``)."""
297
+ r = np.empty(self.n, np.int64)
298
+ r[self.order] = np.arange(self.n)
299
+ return r
300
+
301
+ def unsort(self, sorted_rows: np.ndarray) -> np.ndarray:
302
+ """Device rows (sorted, padded) -> the network-input point order (the first ``N`` sorted rows only)."""
303
+ return np.asarray(sorted_rows)[: self.n][self.rank]
304
+
305
+ def stats(self) -> Dict[str, Any]:
306
+ return {"n": self.n, "m": self.m, "n_cap": self.n_cap, "m_cap": self.m_cap,
307
+ "max_points_per_cell": self.max_segment_length,
308
+ "segmax_rounds": segmax_rounds(self.max_segment_length)}
309
+
310
+
311
+ def build_plan(inputs: NetworkInputs, config: FRNetConfig, *,
312
+ pre_norm: Optional[Tuple[np.ndarray, np.ndarray]] = None,
313
+ n_cap: Optional[int] = None, m_cap: Optional[int] = None,
314
+ strides: Sequence[int] = STRIDES) -> FramePlan:
315
+ """The device tables of one accepted frame (:class:`FramePlan`). ``n_cap`` / ``m_cap`` default to the
316
+ config's capacities (COMPILE: the profile maxima); a frame above them raises ``ValueError`` (the profile check
317
+ refuses it first)."""
318
+ n_cap = config.n_cap if n_cap is None else int(n_cap)
319
+ m_cap = config.m_cap if m_cap is None else int(m_cap)
320
+ n, m = inputs.num_points, inputs.num_voxels
321
+ if n > n_cap or m > m_cap:
322
+ raise ValueError(f"frame N={n} / M={m} exceeds the capacity N_cap={n_cap} / M_cap={m_cap}")
323
+ H, W = config.frustum_h, config.frustum_w
324
+ inv = inputs.inverse_map
325
+ order = np.argsort(inv, kind="stable") # voxel ids are key ranks: == the stable sort by cell key
326
+ counts = np.bincount(inv, minlength=m).astype(np.int64)
327
+ offsets = np.zeros(m + 1, np.int64)
328
+ np.cumsum(counts, out=offsets[1:])
329
+ seg_id = np.full(n_cap, m_cap, np.int64)
330
+ seg_id[:n] = inv[order]
331
+ seg_last = np.full(m_cap, n_cap - 1, np.uint32)
332
+ seg_last[:m] = (offsets[1:] - 1).astype(np.uint32)
333
+ coors_sorted = inputs.coors[order]
334
+ vox = inputs.voxel_coors
335
+ point2pix: Dict[int, np.ndarray] = {}
336
+ pix2vox: Dict[int, np.ndarray] = {}
337
+ for s in strides:
338
+ hs, ws = H // s, W // s
339
+ p2p = np.zeros(n_cap, np.uint32)
340
+ p2p[:n] = ((coors_sorted[:, 1] // s) * ws + coors_sorted[:, 2] // s).astype(np.uint32)
341
+ point2pix[s] = p2p
342
+ last = np.full(hs * ws, -1, np.int64)
343
+ np.maximum.at(last, (vox[:, 1] // s) * ws + vox[:, 2] // s, np.arange(m, dtype=np.int64))
344
+ pix2vox[s] = np.where(last < 0, m_cap, last).astype(np.uint32)
345
+ feats = np.zeros((n_cap, 8), np.float32)
346
+ feats[:n] = decorate_points(inputs.points[order], inv[order], m, pre_norm=pre_norm)
347
+ return FramePlan(n=n, m=m, n_raw=inputs.num_points_raw, n_cap=n_cap, m_cap=m_cap, frustum_hw=(H, W),
348
+ order=order, seg_id=seg_id, seg_offsets=offsets, seg_last=seg_last, point2pix=point2pix,
349
+ pix2vox=pix2vox, features=feats, max_segment_length=int(counts.max()) if m else 0,
350
+ strides=tuple(strides))
351
+
352
+
353
+ def segmax_shift_tables(seg_id: np.ndarray, rounds: int) -> List[np.ndarray]:
354
+ """The gather tables of the exact log-step segmented max (PLAN.md 1.3, the K1 stock-op fallback): round
355
+ ``r`` uses ``idx[i] = i - 2^r`` if row ``i - 2^r`` is in the same segment, else ``i`` (max is idempotent);
356
+ after ``rounds`` rounds each segment's last row holds the segment max when its length is <= ``2^rounds``.
357
+ -> ``rounds`` x (N_cap,) uint32."""
358
+ seg = np.asarray(seg_id)
359
+ i = np.arange(len(seg), dtype=np.int64)
360
+ out = []
361
+ for r in range(rounds):
362
+ d = 1 << r
363
+ j = i - d
364
+ same = np.zeros(len(seg), bool)
365
+ same[d:] = seg[d:] == seg[:-d]
366
+ out.append(np.where(same, j, i).astype(np.uint32))
367
+ return out
code/tt_frnet/io.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Input decoding and output encoding shared by the Python API and the HTTP server of frnet-p150.
3
+
4
+ The implementation is the vendored ``ttaw.io`` (C08): point clouds (``bin`` / ``npy`` / ``npz`` / ``pcd`` /
5
+ ``list``), images, cameras + calibration, transforms, named arrays and the output encoders; numpy only, data
6
+ parsing only, and every client mistake raises :class:`InputError`, which the server maps to HTTP 400. This module
7
+ re-exports it with the Autoware input layout of this model bound as the default point fields, so ``model(points)``
8
+ and ``POST /predict`` decode the same bytes into the same array.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Mapping, Optional, Sequence
13
+
14
+ from .ttaw import io as _io
15
+ from .ttaw.io import * # noqa: F401,F403 (the decoders and encoders: ttaw/API.md section 9)
16
+ from .ttaw.io import MAX_POINTS_DEFAULT, PointCloud
17
+
18
+ __all__ = list(_io.__all__) # the same public names; the three below are rebound to this model's layout
19
+
20
+ DEFAULT_POINT_FIELDS = tuple(["x", "y", "z", "intensity"]) # the Autoware input layout of this model
21
+
22
+
23
+ def decode_points(spec: Mapping[str, Any], *, max_points: int = MAX_POINTS_DEFAULT, max_bytes: Optional[int] = None,
24
+ default_fields: Sequence[str] = DEFAULT_POINT_FIELDS) -> PointCloud:
25
+ """The ``points`` envelope of ``/predict`` -> :class:`PointCloud` (``ttaw.io.decode_points`` with this model's
26
+ default fields)."""
27
+ return _io.decode_points(spec, max_points=max_points, max_bytes=max_bytes, default_fields=default_fields)
28
+
29
+
30
+ def load_points(source: Any, *, fields: Optional[Sequence[str]] = None, fmt: Optional[str] = None,
31
+ frame_id: str = "base_link", default_fields: Sequence[str] = DEFAULT_POINT_FIELDS) -> PointCloud:
32
+ """Python-API point input (path, bytes + ``fmt``, (N, C) array or tensor, structured array, envelope,
33
+ :class:`PointCloud`) -> :class:`PointCloud` (``ttaw.io.load_points`` with this model's default fields)."""
34
+ return _io.load_points(source, fields=fields, fmt=fmt, frame_id=frame_id, default_fields=default_fields)
code/tt_frnet/reference/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CPU reference of FRNet (Autoware lidar_frnet) -- the ground truth every PCC / output-agreement gate compares
3
+ against (PLAN.md 0.3 item 1). It runs where ttnn is absent (the research venv) and never imports ttnn.
4
+
5
+ - ``config.py`` the Autoware parameters this port reproduces (``ml_package_frnet_<variant>.param.yaml`` of the
6
+ weights, ``frnet.param.yaml`` node parameters) and the COMPILE constants, each with its source line;
7
+ - ``weights.py`` the deployed ONNX read as data with the vendored ``ttaw.weights.OnnxWeights`` (initializers by
8
+ consuming node; structure and every wiring assumption checked on the graph; BN folded in float64
9
+ into the point layers, ``pre_norm`` kept as the host's scale / shift) -- the loader the TT port
10
+ uses too;
11
+ - ``model.py`` fp32 torch rebuild of the deployed graph with per-module taps (``ttaw.golden.TapRegistry``; names
12
+ in ``weights.MODULE_TAPS`` / ``OPT_IN_TAPS``), ported from research/frnet/scripts/frnet_torch_ref.py;
13
+ - ``rewrites.py`` the TT device dataflow (sorted + padded points, gather-form scatters with a zero sentinel row, the
14
+ exact log-step segmented max, K-split convs and point layers, interpolation-matrix up-sampling)
15
+ proven equal to ``model.py`` on the CPU, and a first-order precision emulation;
16
+ - ``ort.py`` ONNX Runtime golden of the deployed ONNX with matching intermediates (research / tests only:
17
+ onnxruntime is not in the image);
18
+ - ``pipeline.py`` the reference end to end: ``host/`` pre-processing -> torch / ORT / plan network -> ``host/``
19
+ post-processing -> ``FRNetSegmentation``.
20
+
21
+ Pre- and post-processing are not duplicated here: they live in ``tt_frnet.host`` (shared with the device path, so
22
+ TT and CPU see identical network inputs) and are tested against the research scripts.
23
+
24
+ The reduced ``to_dict()`` of the ORT reference on a sample is stored per variant next to it as
25
+ ``samples/synthetic_<variant>.<variant>.reference.json``: ``server/smoke_test.py`` compares the served output with it.
26
+ """
code/tt_frnet/reference/config.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The Autoware ``lidar_frnet`` parameters this port reproduces, and the port's compile-time constants.
3
+
4
+ Sources (autoware_universe main ``9ceaccf``, ``perception/autoware_lidar_frnet`` = ``PKG/``; weights
5
+ ``AutowareFoundation/lidar_frnet@v2.0`` = commit ``a53a1c11``; research/frnet/SPEC.md sections 1-3):
6
+
7
+ - per variant, from the weights repo: ``ml_package_frnet_<variant>.param.yaml`` (``fov_up_deg`` / ``fov_down_deg``,
8
+ ``frustum_width`` / ``frustum_height`` (the network grid), ``interpolation_width`` / ``interpolation_height`` (the
9
+ pre-processing grid), the 27 ``class_names``, the 81-int ``palette``, the ``num_points`` / ``num_unique_coors``
10
+ [min, opt, max] profiles) and ``deploy_metadata.yaml`` (``version: v2.0``);
11
+ - node-wide, :data:`NODE_PARAMS` = ``PKG/config/frnet.param.yaml:3-16`` (filter threshold and classes, the filtered
12
+ cloud's output format, the ego crop box).
13
+
14
+ Every validation the node does on these values is repeated here: palette size and 0-255 range
15
+ (``PKG/include/.../utils.hpp`` ``make_palette``), filter classes must be class names (``make_filter_class_indices``),
16
+ the crop box is enabled iff any float32 bound is non-zero (``PKG/src/lidar_frnet_node.cpp:51-66``). ``yaml`` is
17
+ imported when a file is read, never at module import; nothing here imports torch / onnx / ttnn.
18
+
19
+ The two variants are an inseparable tuple (weights, frustum H x W baked into the ONNX, FOV, interpolation grid:
20
+ S:frnet:535-538): a user selects ``ot128`` / ``qt128`` only (LOAD), every number below follows from it.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from dataclasses import dataclass, field, replace
26
+ from pathlib import Path
27
+ from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
28
+
29
+ import numpy as np
30
+
31
+ __all__ = [
32
+ "VARIANTS", "DEFAULT_VARIANT", "DEPLOY_VERSION", "ONNX_SHA256", "NODE_PARAMS", "NUM_CLASSES", "STRIDES",
33
+ "BLOCKS", "CROP_BOUND_KEYS", "FRNetConfig", "variant_files", "parse_crop_box", "parse_filter_classes",
34
+ ]
35
+
36
+ VARIANTS = ("ot128", "qt128") # PKG/launch/lidar_frnet.launch.xml:9-12 (sensor_model choices)
37
+ DEFAULT_VARIANT = "ot128" # the launch default (Hesai OT128)
38
+ DEPLOY_VERSION = "v2.0" # deploy_metadata.yaml of the pinned weights; Autoware's ansible pin (S:frnet:75-80)
39
+
40
+ # sha256 of the deployed graphs at the pinned revision (S:frnet:84-93): a different file is refused.
41
+ ONNX_SHA256 = {
42
+ "ot128": "d29bf5950432360d800c22ff86a590d4d9d006acc501563ac611cfa33747c11f",
43
+ "qt128": "647af92790e3d591a19d065fe94d3ded797916ac9ffe5bf96b5e8b8324b31ad2",
44
+ }
45
+
46
+ # PKG/config/frnet.param.yaml:3-16 (node parameters; RT-host in this port)
47
+ NODE_PARAMS: Dict[str, Any] = {
48
+ "class_probability_threshold": 0.05,
49
+ "filter_classes": ("drivable_surface",),
50
+ "output_format": "",
51
+ "ego_crop_box": {"reference_frame": "base_link", "min_x": -1.53, "min_y": -1.15, "min_z": -0.1,
52
+ "max_x": 6.1, "max_y": 1.15, "max_z": 3.1},
53
+ }
54
+ CROP_BOUND_KEYS = ("min_x", "min_y", "min_z", "max_x", "max_y", "max_z")
55
+
56
+ NUM_CLASSES = 27 # conv_seg Gemm 64 -> 27; ml_package class_names (COMPILE)
57
+ STRIDES = (1, 2, 4, 8) # AWML configs/model/frnet.py: backbone strides of layer1..4 (checked on the graph)
58
+ BLOCKS = (3, 4, 6, 3) # BasicBlocks per stage (checked on the graph)
59
+ POSTPROCESS_MAX_CLASSES = 64 # PKG/lib/postprocess/postprocess_kernel.cu:32 (const_palette[64])
60
+ POSTPROCESS_MAX_FILTER_CLASSES = 16 # postprocess_kernel.cu:30 (const_filter_class_indices[16])
61
+
62
+
63
+ def variant_files(weights_dir: Any, variant: str) -> Dict[str, Path]:
64
+ """The files of one variant in a weights directory (the flat HF repo layout)."""
65
+ if variant not in VARIANTS:
66
+ raise ValueError(f"unknown variant {variant!r}; choose one of {VARIANTS}")
67
+ root = Path(weights_dir)
68
+ return {"onnx": root / f"frnet_{variant}.onnx", "ml_package": root / f"ml_package_frnet_{variant}.param.yaml",
69
+ "deploy_metadata": root / "deploy_metadata.yaml"}
70
+
71
+
72
+ def _ros_params(path: Path) -> Dict[str, Any]:
73
+ import yaml
74
+
75
+ with open(path) as f:
76
+ doc = yaml.safe_load(f)
77
+ try:
78
+ return doc["/**"]["ros__parameters"]
79
+ except (KeyError, TypeError):
80
+ raise ValueError(f"{path}: not a ROS 2 parameter file (no /**: ros__parameters)") from None
81
+
82
+
83
+ def _profile(values: Sequence[Any], name: str) -> Tuple[int, int, int]:
84
+ v = tuple(int(x) for x in values)
85
+ if len(v) != 3 or not (0 < v[0] <= v[1] <= v[2]):
86
+ raise ValueError(f"{name} must be [min, opt, max] with 0 < min <= opt <= max, got {list(values)}")
87
+ return v # type: ignore[return-value]
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class FRNetConfig:
92
+ """One variant's parameters. COMPILE values (device shapes): ``frustum_h`` x ``frustum_w``, ``n_cap``,
93
+ ``m_cap``, ``num_classes``; LOAD: the variant itself; RT-host: the node parameters (filter, crop box)."""
94
+
95
+ variant: str
96
+ version: str
97
+ fov_up_deg: float
98
+ fov_down_deg: float
99
+ frustum_w: int
100
+ frustum_h: int
101
+ interp_w: int
102
+ interp_h: int
103
+ class_names: Tuple[str, ...]
104
+ palette: Tuple[int, ...] # 3 x num_classes ints, RGB per class
105
+ num_points: Tuple[int, int, int] # [min, opt, max] after interpolation (and of the raw input)
106
+ num_unique: Tuple[int, int, int] # [min, opt, max] unique frustum cells
107
+ class_probability_threshold: float = NODE_PARAMS["class_probability_threshold"]
108
+ filter_classes: Tuple[str, ...] = NODE_PARAMS["filter_classes"]
109
+ crop_bounds: Tuple[float, ...] = tuple(NODE_PARAMS["ego_crop_box"][k] for k in CROP_BOUND_KEYS)
110
+ crop_reference_frame: str = NODE_PARAMS["ego_crop_box"]["reference_frame"]
111
+ model_dir: Optional[str] = field(default=None, compare=False)
112
+
113
+ # ---- construction --------------------------------------------------------------------------------------
114
+ @classmethod
115
+ def from_model_dir(cls, model_dir: Any, variant: str = DEFAULT_VARIANT, *,
116
+ check_version: bool = True) -> "FRNetConfig":
117
+ """Read ``ml_package_frnet_<variant>.param.yaml`` (and check ``deploy_metadata.yaml``) of a weights
118
+ directory laid out as the HF repo."""
119
+ files = variant_files(model_dir, variant)
120
+ p = _ros_params(files["ml_package"])
121
+ version = DEPLOY_VERSION
122
+ if files["deploy_metadata"].is_file():
123
+ import yaml
124
+
125
+ meta = yaml.safe_load(files["deploy_metadata"].read_text()) or {}
126
+ version = str(meta.get("version", ""))
127
+ if check_version and version != DEPLOY_VERSION:
128
+ raise ValueError(f"deploy_metadata.yaml version {version!r} != the pinned {DEPLOY_VERSION!r}")
129
+ cfg = cls(variant=variant, version=version, fov_up_deg=float(p["fov_up_deg"]),
130
+ fov_down_deg=float(p["fov_down_deg"]), frustum_w=int(p["frustum_width"]),
131
+ frustum_h=int(p["frustum_height"]), interp_w=int(p["interpolation_width"]),
132
+ interp_h=int(p["interpolation_height"]), class_names=tuple(str(c) for c in p["class_names"]),
133
+ palette=tuple(int(v) for v in p["palette"]), num_points=_profile(p["num_points"], "num_points"),
134
+ num_unique=_profile(p["num_unique_coors"], "num_unique_coors"), model_dir=str(model_dir))
135
+ cfg.validate()
136
+ return cfg
137
+
138
+ def validate(self) -> None:
139
+ """The node's own checks (utils.hpp ``Dims2d``, ``make_palette``, ``make_filter_class_indices``)."""
140
+ for name in ("frustum_w", "frustum_h", "interp_w", "interp_h"):
141
+ if getattr(self, name) <= 0:
142
+ raise ValueError(f"{name} must be positive")
143
+ n = len(self.class_names)
144
+ if n == 0 or n > POSTPROCESS_MAX_CLASSES:
145
+ raise ValueError(f"{n} classes; the node supports 1..{POSTPROCESS_MAX_CLASSES}")
146
+ if len(self.palette) % 3:
147
+ raise ValueError("Palette size must be a multiple of 3.")
148
+ if len(self.palette) != 3 * n:
149
+ raise ValueError("Palette size does not match class names size.")
150
+ if any(v < 0 or v > 255 for v in self.palette):
151
+ raise ValueError("Color values must be within 0-255 range.")
152
+ self.filter_class_indices()
153
+ if len(self.crop_bounds) != 6:
154
+ raise ValueError("ego crop box needs 6 bounds (min_x, min_y, min_z, max_x, max_y, max_z)")
155
+
156
+ def with_node_params(self, **overrides: Any) -> "FRNetConfig":
157
+ """A copy with other node parameters (``class_probability_threshold``, ``filter_classes``,
158
+ ``crop_bounds``); re-validated."""
159
+ cfg = replace(self, **overrides)
160
+ cfg.validate()
161
+ return cfg
162
+
163
+ # ---- derived values (each computed as the node computes it) ---------------------------------------------
164
+ @property
165
+ def num_classes(self) -> int:
166
+ return len(self.class_names)
167
+
168
+ @property
169
+ def fov(self) -> Tuple[np.float32, np.float32, np.float32]:
170
+ """utils.hpp ``FieldOfView``: ``(up, down, total)`` radians, computed in double, stored as float32."""
171
+ up = np.float32(np.pi * self.fov_up_deg / 180.0)
172
+ down = np.float32(np.pi * self.fov_down_deg / 180.0)
173
+ total = np.float32(np.pi * abs(self.fov_up_deg - self.fov_down_deg) / 180.0)
174
+ return up, down, total
175
+
176
+ @property
177
+ def crop_bounds_f32(self) -> np.ndarray:
178
+ """``static_cast<float>`` of the six bounds (lidar_frnet_node.cpp:53-58)."""
179
+ return np.asarray(self.crop_bounds, np.float64).astype(np.float32)
180
+
181
+ @property
182
+ def crop_enabled(self) -> bool:
183
+ """Enabled iff any float32 bound is non-zero (lidar_frnet_node.cpp:59-66)."""
184
+ return bool(np.any(self.crop_bounds_f32 != 0.0))
185
+
186
+ def filter_class_indices(self, names: Optional[Sequence[str]] = None) -> Tuple[int, ...]:
187
+ """Indices of ``names`` (default: the configured filter classes) in ``class_names``; an unknown name raises
188
+ ``ValueError`` (the node throws ``Filter class '<name>' not found in class names.``)."""
189
+ names = self.filter_classes if names is None else tuple(names)
190
+ if len(names) > POSTPROCESS_MAX_FILTER_CLASSES:
191
+ raise ValueError(f"at most {POSTPROCESS_MAX_FILTER_CLASSES} filter classes")
192
+ out = []
193
+ for name in names:
194
+ if name not in self.class_names:
195
+ raise ValueError(f"Filter class '{name}' not found in class names.")
196
+ out.append(self.class_names.index(name))
197
+ return tuple(out)
198
+
199
+ @property
200
+ def palette_rgb(self) -> np.ndarray:
201
+ """``(num_classes, 3)`` uint8."""
202
+ return np.asarray(self.palette, np.int64).reshape(-1, 3).astype(np.uint8)
203
+
204
+ @property
205
+ def palette_packed(self) -> np.ndarray:
206
+ """utils.hpp ``make_palette``: ``(r << 16) | (g << 8) | b`` reinterpreted as float32 (the visualization
207
+ cloud's ``rgb`` field), ``(num_classes,)`` float32."""
208
+ rgb = self.palette_rgb.astype(np.uint32)
209
+ return ((rgb[:, 0] << 16) | (rgb[:, 1] << 8) | rgb[:, 2]).astype(np.uint32).view(np.float32)
210
+
211
+ @property
212
+ def n_cap(self) -> int:
213
+ """Point capacity of the device graph (COMPILE): the profile maximum, so every frame Autoware accepts fits
214
+ (functional port; smaller buckets are optimization work)."""
215
+ return self.num_points[2]
216
+
217
+ @property
218
+ def m_cap(self) -> int:
219
+ """Frustum-cell (voxel) capacity of the device graph (COMPILE); the gather tables add one zero row."""
220
+ return self.num_unique[2]
221
+
222
+ @property
223
+ def frustum_cells(self) -> int:
224
+ return self.frustum_h * self.frustum_w
225
+
226
+ def research_cfg(self) -> Dict[str, Any]:
227
+ """The same values in the layout of research/frnet/scripts/frnet_preproc_ref.py ``CFGS[variant]``."""
228
+ return dict(name=self.variant, fov_up_deg=self.fov_up_deg, fov_down_deg=self.fov_down_deg,
229
+ frustum_w=self.frustum_w, frustum_h=self.frustum_h, interp_w=self.interp_w,
230
+ interp_h=self.interp_h, num_points=self.num_points, num_unique=self.num_unique)
231
+
232
+ def describe(self) -> Dict[str, Any]:
233
+ return {"variant": self.variant, "version": self.version, "fov_deg": [self.fov_up_deg, self.fov_down_deg],
234
+ "frustum_hw": [self.frustum_h, self.frustum_w], "interpolation_hw": [self.interp_h, self.interp_w],
235
+ "num_points": list(self.num_points), "num_unique_coors": list(self.num_unique),
236
+ "num_classes": self.num_classes, "class_probability_threshold": self.class_probability_threshold,
237
+ "filter_classes": list(self.filter_classes), "ego_crop_box": dict(zip(CROP_BOUND_KEYS,
238
+ self.crop_bounds)),
239
+ "ego_crop_reference_frame": self.crop_reference_frame}
240
+
241
+
242
+ def parse_crop_box(value: Any) -> Optional[Tuple[float, ...]]:
243
+ """An ego crop box override: ``None`` / ``False`` (disabled = all-zero bounds, the node's own switch), six
244
+ numbers ``[min_x, min_y, min_z, max_x, max_y, max_z]``, or a mapping with those keys (missing keys keep
245
+ Autoware's defaults). Raises ``ValueError`` on anything else."""
246
+ if value is None or value is False:
247
+ return (0.0,) * 6
248
+ if isinstance(value, Mapping):
249
+ unknown = sorted(set(value) - set(CROP_BOUND_KEYS) - {"reference_frame"})
250
+ if unknown:
251
+ raise ValueError(f"ego_crop_box: unknown keys {unknown}")
252
+ if value.get("reference_frame", "base_link") != "base_link":
253
+ raise ValueError("ego_crop_box.reference_frame must be base_link (the transform's target frame)")
254
+ base = NODE_PARAMS["ego_crop_box"]
255
+ vals = [value.get(k, base[k]) for k in CROP_BOUND_KEYS]
256
+ else:
257
+ vals = list(value)
258
+ if len(vals) != 6:
259
+ raise ValueError("ego_crop_box needs 6 numbers [min_x, min_y, min_z, max_x, max_y, max_z]")
260
+ try:
261
+ out = tuple(float(v) for v in vals)
262
+ except (TypeError, ValueError):
263
+ raise ValueError("ego_crop_box bounds must be numbers") from None
264
+ if not all(np.isfinite(out)):
265
+ raise ValueError("ego_crop_box bounds must be finite")
266
+ return out
267
+
268
+
269
+ _IDENT = re.compile(r"^[A-Za-z0-9_+\-]+$")
270
+
271
+
272
+ def parse_filter_classes(value: Any) -> Tuple[str, ...]:
273
+ """``filter_classes`` as a request gives it: a comma-separated string (``""`` = no filter class) or a list of
274
+ names. Names are checked against the class list by :meth:`FRNetConfig.filter_class_indices`."""
275
+ if value is None:
276
+ return ()
277
+ if isinstance(value, str):
278
+ names = [v.strip() for v in value.split(",") if v.strip()]
279
+ else:
280
+ names = [str(v).strip() for v in value]
281
+ bad = [n for n in names if not _IDENT.match(n)]
282
+ if bad:
283
+ raise ValueError(f"invalid filter class names {bad}")
284
+ return tuple(names)
code/tt_frnet/reference/model.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """fp32 torch rebuild of the deployed FRNet graph (AWML ``frustum_encoder.py:76-137``, ``frnet_backbone.py:252-356``,
3
+ ``frnet_head.py:52-70`` in deploy mode), with per-module taps. Ported from the verified research reference
4
+ ``research/frnet/scripts/frnet_torch_ref.py`` (max |diff| vs ORT 1.6e-5 .. 3.0e-5 on the public goldens), on the
5
+ weights as the TT port holds them (``reference/weights.py``: point-layer BatchNorms folded, ``pre_norm`` as x * scale
6
+ + shift).
7
+
8
+ Input: the four ONNX inputs of one frame, **unpadded** (``host/preprocess.py`` ``NetworkInputs``); the graph sizes
9
+ its voxel tensors as ``max(inverse_map) + 1`` (S:frnet:255-262). Every sparse op is deterministic and follows ORT:
10
+
11
+ - cell mean: index-add of the points / of ones, divided (``ScatterElements(add)`` x2 + ``Div``);
12
+ - per-cell max (``ScatterElements(max)``, init ``-FLT_MAX``): ``scatter_reduce(amax, include_self=True)``;
13
+ - frustum2pixel (``ScatterND`` into zeros, NHWC -> NCHW): at strides 2 / 4 / 8 several cells fall on one pixel
14
+ and ORT applies the updates sequentially, so the **last voxel row** wins (S:frnet:285-287);
15
+ - pixel2point (``Gather``): row ``(row // s) * (W // s) + col // s`` of the NHWC map;
16
+ - decoder: bilinear ``align_corners=True`` resize of the five maps to H x W, channel concat.
17
+
18
+ Taps (``ttaw.golden.TapRegistry``; names in ``weights.MODULE_TAPS`` / ``OPT_IN_TAPS``): point tensors ``(N, C)``,
19
+ voxel tensors ``(M, C)``, maps ``(1, C, h, w)`` NCHW, all float32 numpy. ``forward`` returns ``pred_probs``
20
+ ``(N, 27)`` float32. torch is imported here (research venv / tt-env), never by ``api.py`` at module level.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Optional
25
+
26
+ import numpy as np
27
+ import torch
28
+ import torch.nn.functional as F
29
+
30
+ from ..ttaw.golden import NULL_TAPS, TapRegistry
31
+ from .weights import Conv, FRNetWeights, Linear
32
+
33
+ __all__ = ["FRNetTorch", "scatter_max", "frustum2pixel", "pixel2point", "TorchLayers"]
34
+
35
+
36
+ def _t(a: np.ndarray) -> torch.Tensor:
37
+ a = np.ascontiguousarray(a)
38
+ return torch.from_numpy(a if a.flags.writeable else a.copy())
39
+
40
+
41
+ class TorchLayers:
42
+ """The weights as torch tensors (shared by the literal model and the rewrites)."""
43
+
44
+ def __init__(self, weights: FRNetWeights):
45
+ self.w = weights
46
+ self.lin = {lin.name: (_t(lin.weight), _t(lin.bias), lin.relu) for lin in weights.linears}
47
+ self.conv = {c.name: (_t(c.weight), _t(c.bias), c.stride, c.padding, c.activation) for c in weights.convs}
48
+ self.pre_scale = _t(weights.pre_norm_scale)
49
+ self.pre_shift = _t(weights.pre_norm_shift)
50
+
51
+ def linear(self, x: torch.Tensor, layer: Linear) -> torch.Tensor:
52
+ w, b, relu = self.lin[layer.name]
53
+ y = torch.addmm(b, x, w)
54
+ return F.relu(y) if relu else y
55
+
56
+ def conv2d(self, x: torch.Tensor, layer: Conv, *, activation: bool = True) -> torch.Tensor:
57
+ w, b, stride, pad, act = self.conv[layer.name]
58
+ y = F.conv2d(x, w, b, stride=stride, padding=pad)
59
+ if activation and act == "hardswish":
60
+ y = F.hardswish(y)
61
+ elif activation and act == "sigmoid":
62
+ y = torch.sigmoid(y)
63
+ return y
64
+
65
+
66
+ def scatter_max(src: torch.Tensor, inverse_map: torch.Tensor, m: int) -> torch.Tensor:
67
+ """``ScatterElements(reduction=max)`` into ``-FLT_MAX`` rows (every row of a frame owns >= 1 point)."""
68
+ out = torch.full((m, src.shape[1]), torch.finfo(src.dtype).min, dtype=src.dtype)
69
+ return out.scatter_reduce(0, inverse_map.unsqueeze(-1).expand(-1, src.shape[1]), src, reduce="amax",
70
+ include_self=True)
71
+
72
+
73
+ def frustum2pixel(feats: torch.Tensor, voxel_coors: np.ndarray, hw: tuple, stride: int) -> torch.Tensor:
74
+ """``ScatterND`` of voxel rows into a zero ``(1, h, w, C)`` image at ``voxel_coors // stride`` (last voxel wins
75
+ on duplicates), returned NCHW."""
76
+ H, W = hw
77
+ h, w = H // stride, W // stride
78
+ lin = (voxel_coors[:, 1] // stride) * w + voxel_coors[:, 2] // stride
79
+ last = np.full(h * w, -1, np.int64)
80
+ np.maximum.at(last, lin, np.arange(len(lin), dtype=np.int64))
81
+ pix = np.flatnonzero(last >= 0)
82
+ img = torch.zeros(h * w, feats.shape[1], dtype=feats.dtype)
83
+ img[_t(pix)] = feats[_t(last[pix])]
84
+ return img.reshape(1, h, w, -1).permute(0, 3, 1, 2).contiguous()
85
+
86
+
87
+ def pixel2point(x: torch.Tensor, coors: np.ndarray, stride: int) -> torch.Tensor:
88
+ """``Gather`` of the NHWC rows of ``x`` (NCHW) at each point's cell ``// stride``."""
89
+ _, c, h, w = x.shape
90
+ flat = x.permute(0, 2, 3, 1).reshape(h * w, c)
91
+ return flat[_t((coors[:, 1] // stride) * w + coors[:, 2] // stride)]
92
+
93
+
94
+ class FRNetTorch:
95
+ """``FRNetTorch(weights)(points, coors, voxel_coors, inverse_map, taps=TapRegistry())`` -> pred_probs."""
96
+
97
+ def __init__(self, weights: FRNetWeights, *, threads: Optional[int] = None):
98
+ if threads:
99
+ torch.set_num_threads(int(threads))
100
+ self.weights = weights
101
+ self.layers = TorchLayers(weights)
102
+
103
+ def __call__(self, *args: Any, **kwargs: Any) -> np.ndarray:
104
+ return self.forward(*args, **kwargs)
105
+
106
+ @torch.no_grad()
107
+ def forward(self, points: np.ndarray, coors: np.ndarray, voxel_coors: np.ndarray, inverse_map: np.ndarray,
108
+ taps: TapRegistry = NULL_TAPS) -> np.ndarray:
109
+ W, L = self.weights, self.layers
110
+ hw = W.frustum_hw
111
+ m = int(inverse_map.max()) + 1
112
+ voxel_coors = np.asarray(voxel_coors)[:m]
113
+ coors = np.asarray(coors)
114
+ pts = _t(np.asarray(points, np.float32))
115
+ inv = _t(np.asarray(inverse_map, np.int64))
116
+
117
+ def tap(name: str, value: torch.Tensor) -> torch.Tensor:
118
+ if taps.wants(name):
119
+ taps.tap(name, value.numpy())
120
+ return value
121
+
122
+ # ---- FrustumFeatureEncoder
123
+ dist = torch.linalg.norm(pts[:, :3], dim=1, keepdim=True)
124
+ vsum = torch.zeros(m, 4).index_add_(0, inv, pts)
125
+ vcnt = torch.zeros(m, 4).index_add_(0, inv, torch.ones_like(pts))
126
+ f_cluster = pts[:, :3] - (vsum / vcnt)[inv][:, :3]
127
+ x = torch.cat([pts, dist, f_cluster], 1) * L.pre_scale + L.pre_shift
128
+ tap("encoder.input", x)
129
+ pf = []
130
+ for i, lin in enumerate(W.ffe):
131
+ x = L.linear(x, lin)
132
+ pf.append(tap(f"encoder.pf{i}", x))
133
+ vmax = tap("encoder.voxel_max", scatter_max(x, inv, m))
134
+ vfeat = tap("encoder.voxel_feats", L.linear(vmax, W.compression))
135
+
136
+ # ---- backbone: stem, point stem, fusion stem
137
+ img = tap("backbone.frustum", frustum2pixel(vfeat, voxel_coors, hw, 1))
138
+ for c in W.stem:
139
+ img = L.conv2d(img, c)
140
+ tap("backbone.stem", img)
141
+ p = L.linear(torch.cat([pixel2point(img, coors, 1), pf[-1]], 1), W.point_stem)
142
+ tap("backbone.point_stem", p)
143
+ seg = tap("backbone.segmax0", scatter_max(p, inv, m))
144
+ x = L.conv2d(torch.cat([frustum2pixel(seg, voxel_coors, hw, 1), img], 1), W.fusion_stem)
145
+ tap("backbone.fusion_stem", x)
146
+ maps, pts_out = [x], [p]
147
+
148
+ # ---- four stages: BasicBlocks, point fusion, pixel fusion, attention
149
+ for st in W.stages:
150
+ li, s = st.index, st.stride
151
+ for bi, (c1, c2, ds) in enumerate(st.blocks):
152
+ y = L.conv2d(L.conv2d(x, c1), c2)
153
+ idt = L.conv2d(x, ds) if ds is not None else x
154
+ x = tap(f"backbone.layer{li}.block{bi}", F.hardswish(y + idt))
155
+ tap(f"backbone.layer{li}", x)
156
+ p = L.linear(torch.cat([pixel2point(x, coors, s), p], 1), st.point_fusion)
157
+ tap(f"backbone.point_fusion{li}", p)
158
+ seg = tap(f"backbone.segmax{li}", scatter_max(p, inv, m))
159
+ fuse = L.conv2d(torch.cat([frustum2pixel(seg, voxel_coors, hw, s), x], 1), st.pixel_fusion)
160
+ tap(f"backbone.pixel_fusion{li}", fuse)
161
+ att = tap(f"backbone.attention{li}", L.conv2d(L.conv2d(fuse, st.attention[0]), st.attention[1]))
162
+ x = tap(f"backbone.stage{li}", fuse * att + x)
163
+ maps.append(x)
164
+ pts_out.append(p)
165
+
166
+ # ---- decoder (align-corners bilinear up-sampling to H x W, fuse convs) and the point branch
167
+ ups = [tap(f"decoder.up{k}", F.interpolate(mp, size=hw, mode="bilinear", align_corners=True))
168
+ for k, mp in enumerate(maps)]
169
+ y = tap("decoder.fuse1", L.conv2d(torch.cat(ups, 1), W.fuse_layer1))
170
+ y = tap("decoder.fuse2", L.conv2d(y, W.fuse_layer2))
171
+ q = tap("decoder.point_fuse1", L.linear(torch.cat(pts_out, 1), W.point_fuse[0]))
172
+ q = tap("decoder.point_fuse2", L.linear(q, W.point_fuse[1]))
173
+
174
+ # ---- FRHead
175
+ h = pixel2point(y, coors, 1)
176
+ for i, (lin, skip) in enumerate(zip(W.head_mlps, (q, pf[2], pf[1], pf[0]))):
177
+ h = tap(f"head.mlp{i}", L.linear(h, lin) + skip)
178
+ logits = tap("head.logits", L.linear(h, W.conv_seg))
179
+ return tap("pred_probs", torch.softmax(logits, -1)).numpy()
code/tt_frnet/reference/ort.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """ONNX Runtime golden: the deployed ``frnet_<variant>.onnx`` of AutowareFoundation/lidar_frnet@v2.0 run as-is (CPU
3
+ execution provider; re-runs are bit-identical, S:frnet:332) on the four unpadded network inputs, exactly as
4
+ the node binds them (``points`` float32 (N, 4), ``coors`` int64 (N, 3), ``voxel_coors`` int64 (M, 3),
5
+ ``inverse_map`` int64 (N,)).
6
+
7
+ With ``intermediates=True`` the in-memory graph also exposes the ONNX tensors that hold the same values as the torch
8
+ reference's taps (the mapping is ``FRNetWeights.onnx_taps``, recovered from the graph by ``reference/weights.py``),
9
+ so ``tests/test_reference_cpu.py`` compares module by module. Adding graph outputs changes nothing that is
10
+ computed. Choose which taps to expose with ``tap_patterns`` (fnmatch); OT128 module taps at 146k points are ~1 GB.
11
+
12
+ onnxruntime is a research / test dependency only (not in the image): it is imported lazily, and the tests skip
13
+ where it is absent.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import fnmatch
18
+ from typing import Any, Dict, Optional, Sequence
19
+
20
+ import numpy as np
21
+
22
+ from ..ttaw.golden import NULL_TAPS, TapRegistry
23
+ from .config import variant_files
24
+ from .weights import MODULE_TAPS, FRNetWeights, load_weights
25
+
26
+ __all__ = ["OrtFRNet", "onnxruntime_available"]
27
+
28
+
29
+ def onnxruntime_available() -> bool:
30
+ import importlib.util
31
+
32
+ return importlib.util.find_spec("onnxruntime") is not None and importlib.util.find_spec("onnx") is not None
33
+
34
+
35
+ def _session(model_bytes: bytes, threads: int):
36
+ import onnxruntime as ort
37
+
38
+ so = ort.SessionOptions()
39
+ so.intra_op_num_threads = int(threads)
40
+ so.inter_op_num_threads = 1
41
+ so.log_severity_level = 3
42
+ return ort.InferenceSession(model_bytes, so, providers=["CPUExecutionProvider"])
43
+
44
+
45
+ def _with_outputs(path: Any, tensors: Sequence[str]) -> bytes:
46
+ """The ONNX model at ``path`` with ``tensors`` added as graph outputs (in memory)."""
47
+ import onnx
48
+ from onnx import TensorProto, helper
49
+
50
+ model = onnx.load(str(path))
51
+ existing = {o.name for o in model.graph.output}
52
+ for t in tensors:
53
+ if t not in existing:
54
+ model.graph.output.append(helper.make_tensor_value_info(t, TensorProto.FLOAT, None))
55
+ existing.add(t)
56
+ return model.SerializeToString()
57
+
58
+
59
+ class OrtFRNet:
60
+ """The deployed graph of one variant under ONNX Runtime (module docstring)."""
61
+
62
+ def __init__(self, weights_dir: Any, variant: str, *, threads: int = 2, intermediates: bool = False,
63
+ tap_patterns: Sequence[str] = MODULE_TAPS, weights: Optional[FRNetWeights] = None):
64
+ self.variant = variant
65
+ self.path = variant_files(weights_dir, variant)["onnx"]
66
+ self.weights = weights or load_weights(weights_dir, variant)
67
+ self.taps: Dict[str, str] = {}
68
+ if intermediates:
69
+ self.taps = {name: tensor for name, tensor in self.weights.onnx_taps.items()
70
+ if name != "pred_probs" and any(fnmatch.fnmatchcase(name, p) for p in tap_patterns)}
71
+ model_bytes = _with_outputs(self.path, list(self.taps.values()))
72
+ else:
73
+ model_bytes = self.path.read_bytes()
74
+ self.session = _session(model_bytes, threads)
75
+ self.output_names = [o.name for o in self.session.get_outputs()]
76
+
77
+ def __call__(self, *args: Any, **kwargs: Any) -> np.ndarray:
78
+ return self.forward(*args, **kwargs)
79
+
80
+ def forward(self, points: np.ndarray, coors: np.ndarray, voxel_coors: np.ndarray, inverse_map: np.ndarray,
81
+ taps: TapRegistry = NULL_TAPS) -> np.ndarray:
82
+ feeds = {"points": np.ascontiguousarray(points, np.float32), "coors": np.ascontiguousarray(coors, np.int64),
83
+ "voxel_coors": np.ascontiguousarray(voxel_coors, np.int64),
84
+ "inverse_map": np.ascontiguousarray(inverse_map, np.int64)}
85
+ outs = dict(zip(self.output_names, self.session.run(None, feeds)))
86
+ for name, tensor in self.taps.items():
87
+ if taps.wants(name):
88
+ taps.tap(name, outs[tensor])
89
+ probs = outs["pred_probs"]
90
+ taps.tap("pred_probs", probs)
91
+ return probs
code/tt_frnet/reference/pipeline.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The CPU reference end to end: ``host/`` pre-processing -> network (torch fp32, ONNX Runtime, or the TT dataflow
3
+ of ``rewrites.py``) -> ``host/`` post-processing -> ``FRNetSegmentation``. The same host code feeds the device path
4
+ (``api.py``), so CPU and TT always see identical network inputs.
5
+
6
+ ref = FRNetReference(weights_dir, "ot128", backend="torch", threads=4)
7
+ run = ref.run("samples/synthetic_ot128.npz", calibration={"preset": "synthetic_ot128"})
8
+ run.output.to_dict() # the stored smoke reference (samples/<stem>.<variant>.reference.json)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, Mapping, Optional, Sequence
14
+
15
+ import numpy as np
16
+
17
+ from ..host.frame import FRNetPreprocessor, Prepared
18
+ from ..host.postprocess import FRNetPostprocessor, FRNetSegmentation
19
+ from ..host.preprocess import NetworkInputs, build_plan
20
+ from ..ttaw.golden import NULL_TAPS, TapRegistry
21
+ from .config import FRNetConfig
22
+ from .weights import MODULE_TAPS, FRNetWeights, check_variant_grid, load_weights
23
+
24
+ __all__ = ["FRNetReference", "ReferenceRun", "BACKENDS"]
25
+
26
+ BACKENDS = ("torch", "ort", "plan")
27
+ MODEL_NAME = "frnet-p150"
28
+
29
+
30
+ @dataclass
31
+ class ReferenceRun:
32
+ prepared: Prepared
33
+ pred_probs: Optional[np.ndarray] # (N, 27) float32, network-input order; None for a skipped frame
34
+ output: FRNetSegmentation
35
+
36
+
37
+ class FRNetReference:
38
+ """One variant's CPU reference (module docstring). ``backend``: ``torch`` (``model.py``, literal graph),
39
+ ``ort`` (the deployed ONNX; research / tests only), ``plan`` (``rewrites.py``, the TT dataflow; optionally with
40
+ an ``emulation``)."""
41
+
42
+ def __init__(self, weights_dir: Any, variant: str, *, backend: str = "torch", threads: int = 4,
43
+ intermediates: bool = False, tap_patterns: Sequence[str] = MODULE_TAPS,
44
+ weights: Optional[FRNetWeights] = None, emulation: Any = None):
45
+ if backend not in BACKENDS:
46
+ raise ValueError(f"backend must be one of {BACKENDS}")
47
+ self.variant = variant
48
+ self.backend = backend
49
+ self.config = FRNetConfig.from_model_dir(weights_dir, variant)
50
+ self.weights = weights or load_weights(weights_dir, variant)
51
+ check_variant_grid(self.weights, self.config.frustum_h, self.config.frustum_w)
52
+ pre_norm = (self.weights.pre_norm_scale, self.weights.pre_norm_shift)
53
+ self.pre = FRNetPreprocessor(self.config, pre_norm=pre_norm, plan=backend == "plan")
54
+ self.post = FRNetPostprocessor(self.config)
55
+ if backend == "torch":
56
+ import torch
57
+
58
+ from .model import FRNetTorch
59
+
60
+ torch.set_num_threads(int(threads))
61
+ self.net = FRNetTorch(self.weights)
62
+ elif backend == "ort":
63
+ from .ort import OrtFRNet
64
+
65
+ self.net = OrtFRNet(weights_dir, variant, threads=threads, intermediates=intermediates,
66
+ tap_patterns=tap_patterns, weights=self.weights)
67
+ else:
68
+ import torch
69
+
70
+ from .rewrites import FRNetPlanTorch
71
+
72
+ torch.set_num_threads(int(threads))
73
+ self.net = FRNetPlanTorch(self.weights, emulation=emulation)
74
+
75
+ def network(self, inputs: NetworkInputs, *, plan: Any = None, taps: TapRegistry = NULL_TAPS) -> np.ndarray:
76
+ """``pred_probs`` (N, 27) of the network inputs (network-input point order)."""
77
+ if self.backend == "plan":
78
+ plan = plan if plan is not None else build_plan(
79
+ inputs, self.config, pre_norm=(self.weights.pre_norm_scale, self.weights.pre_norm_shift))
80
+ return plan.unsort(self.net(plan, taps))
81
+ return self.net(inputs.points, inputs.coors, inputs.voxel_coors, inputs.inverse_map, taps=taps)
82
+
83
+ def run(self, points: Any, calibration: Optional[Mapping[str, Any]] = None, *,
84
+ params: Optional[Mapping[str, Any]] = None, taps: TapRegistry = NULL_TAPS,
85
+ model_name: str = MODEL_NAME) -> ReferenceRun:
86
+ prep = self.pre(points, calibration)
87
+ meta = {"variant": self.variant, "reference": self.backend, **prep.stats()}
88
+ if prep.skipped is not None:
89
+ out = self.post.empty(model=model_name, frame_id=prep.frame_id, meta=meta)
90
+ return ReferenceRun(prep, None, out)
91
+ probs = self.network(prep.inputs, plan=prep.plan, taps=taps)
92
+ out = self.post(probs, points=prep.inputs.points, num_points_raw=prep.inputs.num_points_raw,
93
+ input_index=prep.input_index, params=dict(params or {}), model=model_name,
94
+ frame_id=prep.frame_id, meta=meta)
95
+ return ReferenceRun(prep, probs, out)
code/tt_frnet/reference/rewrites.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The TT device dataflow of FRNet (PORT_LOG.md "trace layout"), run on the CPU in fp32 and proven equal to the
3
+ literal graph (``reference/model.py``) by ``tests/test_reference_rewrites_host.py``. Each rewrite is exact in real
4
+ arithmetic; in float32 they differ only by summation order:
5
+
6
+ - **points sorted by cell and padded** to ``N_cap`` (``host/preprocess.py`` ``FramePlan``): the network is
7
+ permutation-equivariant in the points; padded rows form one trailing sentinel segment whose results nothing reads;
8
+ - **host features**: the 8-d decoration and ``pre_norm`` in float64, one rounding (``decorate_points``);
9
+ - **segmented max** (6 sites: ``ScatterElements(max)``) as the exact **log-step** form of PLAN.md 1.3 (the K1
10
+ kernel's stock-op fallback): round ``r`` takes ``x = max(x, x[idx_r])`` with the host shift table ``idx_r``,
11
+ then one gather of each segment's last row (``seg_last``) -> ``(M_cap, C)``; :func:`segmax_logstep`;
12
+ - **frustum2pixel** (6 ``ScatterND``) as a gather: ``image[pixel] = table[pix2vox_s[pixel]]`` from the voxel table
13
+ with one appended **zero row** (index ``M_cap``) for empty pixels -- ``ttaw.ops.gather.scatter_rows`` semantics
14
+ (C19); duplicates at s > 1 resolved on the host (last voxel wins);
15
+ - **pixel2point** as a gather of NHWC rows (``point2pix_s``);
16
+ - **concat-free convs**: ``conv(concat(a, b))`` = ``conv_a(a) + conv_b(b)`` for the fusion convs (256 = 128 + 128)
17
+ and the 5-way K-split ``fuse_layer1`` (640 = 5 x 128, probe P14: 8.3 vs 14.2 ms);
18
+ - **concat-free point layers**: ``linear(concat(a, b))`` = ``a @ W_a + b @ W_b + bias`` (point stem, point fusion,
19
+ ``point_fuse_layer1``), which also lets the two halves keep different dtypes;
20
+ - **align-corners bilinear** as interpolation matrices ``A_h X A_w^T`` (C18 ``interp_matrix``, float64 -> float32).
21
+
22
+ :class:`Emulation` optionally rounds layer operands the way the device stores them (bf16, or the TF32-like
23
+ mantissa of the device's fp32 matmul, probe P12) -- a first-order CPU model to size the fp32 islands of the
24
+ port before the device runs (``code/scripts/precision_study.py``); it models no math-fidelity phases or tile
25
+ layouts, so its numbers are indicative only.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from typing import Dict, List, Optional, Sequence
31
+
32
+ import numpy as np
33
+ import torch
34
+ import torch.nn.functional as F
35
+
36
+ from ..host.preprocess import FramePlan, segmax_rounds, segmax_shift_tables
37
+ from ..ttaw.golden import NULL_TAPS, TapRegistry
38
+ from ..ttaw.ops.upsample import interp_matrix
39
+ from .model import TorchLayers, _t
40
+ from .weights import Conv, FRNetWeights, Linear
41
+
42
+ __all__ = ["Emulation", "round_mode", "segmax_logstep", "FRNetPlanTorch", "plan_taps_to_literal"]
43
+
44
+ MODES = ("fp32", "bf16", "tf32")
45
+
46
+
47
+ def round_mode(x: torch.Tensor, mode: str) -> torch.Tensor:
48
+ """``fp32``: unchanged; ``bf16``: round to nearest even bfloat16; ``tf32``: truncate to 10 mantissa bits (the
49
+ probe P12 truncation model of the device's fp32 matmul operands; the device measured 1.4-1.75x its error)."""
50
+ if mode == "fp32":
51
+ return x
52
+ if mode == "bf16":
53
+ return x.to(torch.bfloat16).to(torch.float32)
54
+ if mode == "tf32":
55
+ return (x.contiguous().view(torch.int32) & ~0x1FFF).view(torch.float32)
56
+ raise ValueError(f"unknown rounding mode {mode!r}; one of {MODES}")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Emulation:
61
+ """Operand rounding per site (``fp32`` = exact float32). ``conv_act`` / ``conv_weight``: inputs and weights
62
+ (+ bias) of the 53 convs; ``point_act`` / ``point_weight``: inputs and weights of the 17 point / voxel linears;
63
+ ``gather``: rows produced by the embedding gathers (frustum2pixel, pixel2point: ``ttnn.embedding`` tables are
64
+ bf16-only, so ``bf16`` is the stock-op device); ``upsample``: the decoder's interpolation operands. ``overrides``:
65
+ per-layer modes by layer name (e.g. ``{"head.logits": ("tf32", "fp32")}``: (act, weight))."""
66
+
67
+ conv_act: str = "fp32"
68
+ conv_weight: str = "fp32"
69
+ point_act: str = "fp32"
70
+ point_weight: str = "fp32"
71
+ gather: str = "fp32"
72
+ upsample: str = "fp32"
73
+ overrides: Dict[str, tuple] = field(default_factory=dict)
74
+
75
+ @classmethod
76
+ def parse(cls, spec: str) -> "Emulation":
77
+ """``"conv_act=bf16,point_act=tf32,gather=bf16"`` (unset sites stay fp32)."""
78
+ kw = {}
79
+ for part in filter(None, (p.strip() for p in spec.split(","))):
80
+ k, _, v = part.partition("=")
81
+ if k not in cls.__dataclass_fields__ or k == "overrides" or v not in MODES:
82
+ raise ValueError(f"bad emulation item {part!r}")
83
+ kw[k] = v
84
+ return cls(**kw)
85
+
86
+ @property
87
+ def exact(self) -> bool:
88
+ return all(getattr(self, k) == "fp32" for k in ("conv_act", "conv_weight", "point_act", "point_weight",
89
+ "gather", "upsample")) and not self.overrides
90
+
91
+ def label(self) -> str:
92
+ items = [f"{k}={getattr(self, k)}" for k in ("conv_act", "conv_weight", "point_act", "point_weight",
93
+ "gather", "upsample") if getattr(self, k) != "fp32"]
94
+ items += [f"{k}:{v}" for k, v in sorted(self.overrides.items())]
95
+ return ",".join(items) or "fp32"
96
+
97
+
98
+ def segmax_logstep(x: torch.Tensor, plan: FramePlan, rounds: Optional[int] = None) -> torch.Tensor:
99
+ """Per-voxel max of the sorted, padded rows ``x`` ``(N_cap, C)`` -> ``(M_cap, C)``: ``rounds`` (default: just
100
+ enough for the frame's longest segment) log-step rounds over the host shift tables, then a gather of every
101
+ segment's last row (PLAN.md 1.3). Exact (max is order-independent)."""
102
+ rounds = segmax_rounds(plan.max_segment_length) if rounds is None else int(rounds)
103
+ for idx in segmax_shift_tables(plan.seg_id, rounds):
104
+ x = torch.maximum(x, x[_t(idx.astype(np.int64))])
105
+ return x[_t(plan.seg_last.astype(np.int64))]
106
+
107
+
108
+ def _with_zero_row(table: torch.Tensor) -> torch.Tensor:
109
+ return torch.cat([table, table.new_zeros(1, table.shape[1])], 0)
110
+
111
+
112
+ def _nhwc(x: torch.Tensor) -> torch.Tensor:
113
+ _, c, h, w = x.shape
114
+ return x.permute(0, 2, 3, 1).reshape(h * w, c)
115
+
116
+
117
+ def _nchw(rows: torch.Tensor, h: int, w: int) -> torch.Tensor:
118
+ return rows.reshape(1, h, w, -1).permute(0, 3, 1, 2).contiguous()
119
+
120
+
121
+ class FRNetPlanTorch:
122
+ """``FRNetPlanTorch(weights)(plan, taps)`` -> ``pred_probs`` of the sorted, padded rows ``(N_cap, 27)``
123
+ (``plan.unsort`` gives the network-input order). Taps use the names of ``reference/model.py``; point taps are in
124
+ sorted padded order, voxel taps have ``M_cap`` rows (:func:`plan_taps_to_literal` aligns them)."""
125
+
126
+ def __init__(self, weights: FRNetWeights, *, emulation: Optional[Emulation] = None,
127
+ rounds: Optional[int] = None):
128
+ self.weights = weights
129
+ self.layers = TorchLayers(weights)
130
+ self.emu = emulation or Emulation()
131
+ self.rounds = rounds
132
+ H, W = weights.frustum_hw
133
+ self._interp = {}
134
+ for st in [1] + [s.stride for s in weights.stages]:
135
+ ah = interp_matrix(H // st, H, mode="linear", align_corners=True)
136
+ aw = interp_matrix(W // st, W, mode="linear", align_corners=True)
137
+ self._interp[st] = (_t(ah.astype(np.float32)), _t(aw.astype(np.float32)))
138
+ # K-split weights of the concat-consuming layers
139
+ self._split_lin = {}
140
+ for lin, parts in ((weights.point_stem, (128, 256)), (weights.point_fuse[0], (128,) * 5),
141
+ *((st.point_fusion, (128, 128)) for st in weights.stages)):
142
+ w_t, b_t, _ = self.layers.lin[lin.name]
143
+ if w_t.shape[0] != sum(parts):
144
+ raise ValueError(f"{lin.name}: {w_t.shape[0]} inputs, expected {sum(parts)}")
145
+ self._split_lin[lin.name] = (list(torch.split(w_t, list(parts), 0)), b_t)
146
+ self._split_conv = {}
147
+ for conv, parts in ((weights.fusion_stem, (128, 128)), (weights.fuse_layer1, (128,) * 5),
148
+ *((st.pixel_fusion, (128, 128)) for st in weights.stages)):
149
+ w_t, b_t, stride, pad, act = self.layers.conv[conv.name]
150
+ if w_t.shape[1] != sum(parts):
151
+ raise ValueError(f"{conv.name}: {w_t.shape[1]} inputs, expected {sum(parts)}")
152
+ self._split_conv[conv.name] = (list(torch.split(w_t, list(parts), 1)), b_t, stride, pad, act)
153
+
154
+ # ---- rounded layer applications ---------------------------------------------------------------------------
155
+ def _modes(self, name: str, act: str, weight: str) -> tuple:
156
+ return self.emu.overrides.get(name, (act, weight))
157
+
158
+ def linear(self, parts: Sequence[torch.Tensor], layer: Linear) -> torch.Tensor:
159
+ act_m, w_m = self._modes(layer.name, self.emu.point_act, self.emu.point_weight)
160
+ if layer.name in self._split_lin:
161
+ ws, b = self._split_lin[layer.name]
162
+ else:
163
+ w, b, _ = self.layers.lin[layer.name]
164
+ ws = [w]
165
+ if len(parts) != len(ws):
166
+ raise ValueError(f"{layer.name}: {len(parts)} input parts for {len(ws)} weight parts")
167
+ y = round_mode(b, w_m).expand(parts[0].shape[0], -1).clone()
168
+ for x, w in zip(parts, ws):
169
+ y = y + round_mode(x, act_m) @ round_mode(w, w_m)
170
+ return F.relu(y) if layer.relu else y
171
+
172
+ def conv(self, parts: Sequence[torch.Tensor], layer: Conv) -> torch.Tensor:
173
+ act_m, w_m = self._modes(layer.name, self.emu.conv_act, self.emu.conv_weight)
174
+ if layer.name in self._split_conv:
175
+ ws, b, stride, pad, act = self._split_conv[layer.name]
176
+ else:
177
+ w, b, stride, pad, act = self.layers.conv[layer.name]
178
+ ws = [w]
179
+ if len(parts) != len(ws):
180
+ raise ValueError(f"{layer.name}: {len(parts)} input parts for {len(ws)} weight parts")
181
+ y = None
182
+ for i, (x, w) in enumerate(zip(parts, ws)):
183
+ part = F.conv2d(round_mode(x, act_m), round_mode(w, w_m), round_mode(b, w_m) if i == 0 else None,
184
+ stride=stride, padding=pad)
185
+ y = part if y is None else y + part
186
+ if act == "hardswish":
187
+ y = F.hardswish(y)
188
+ elif act == "sigmoid":
189
+ y = torch.sigmoid(y)
190
+ return y
191
+
192
+ def gather(self, table: torch.Tensor, index: np.ndarray) -> torch.Tensor:
193
+ return round_mode(table, self.emu.gather)[_t(index.astype(np.int64))]
194
+
195
+ def upsample(self, x: torch.Tensor, stride: int) -> torch.Tensor:
196
+ if stride == 1:
197
+ return x
198
+ ah, aw = self._interp[stride]
199
+ m = self.emu.upsample
200
+ x = round_mode(x, m)
201
+ return torch.einsum("hi,bcij,wj->bchw", round_mode(ah, m), x, round_mode(aw, m))
202
+
203
+ # ---- the device graph -------------------------------------------------------------------------------------
204
+ def __call__(self, plan: FramePlan, taps: TapRegistry = NULL_TAPS) -> np.ndarray:
205
+ return self.forward(plan, taps)
206
+
207
+ @torch.no_grad()
208
+ def forward(self, plan: FramePlan, taps: TapRegistry = NULL_TAPS) -> np.ndarray:
209
+ Wt = self.weights
210
+ H, W = plan.frustum_hw
211
+ if (H, W) != Wt.frustum_hw:
212
+ raise ValueError(f"plan grid {plan.frustum_hw} != weights grid {Wt.frustum_hw}")
213
+
214
+ def tap(name: str, value: torch.Tensor) -> torch.Tensor:
215
+ if taps.wants(name):
216
+ taps.tap(name, value.numpy())
217
+ return value
218
+
219
+ def to_image(table: torch.Tensor, s: int) -> torch.Tensor:
220
+ return _nchw(self.gather(_with_zero_row(table), plan.pix2vox[s]), H // s, W // s)
221
+
222
+ def to_points(img: torch.Tensor, s: int) -> torch.Tensor:
223
+ return self.gather(_nhwc(img), plan.point2pix[s])
224
+
225
+ x = tap("encoder.input", _t(plan.features))
226
+ pf: List[torch.Tensor] = []
227
+ for i, lin in enumerate(Wt.ffe):
228
+ x = self.linear([x], lin)
229
+ pf.append(tap(f"encoder.pf{i}", x))
230
+ vmax = tap("encoder.voxel_max", segmax_logstep(x, plan, self.rounds))
231
+ vfeat = tap("encoder.voxel_feats", self.linear([vmax], Wt.compression))
232
+ img = tap("backbone.frustum", to_image(vfeat, 1))
233
+ for c in Wt.stem:
234
+ img = self.conv([img], c)
235
+ tap("backbone.stem", img)
236
+ p = tap("backbone.point_stem", self.linear([to_points(img, 1), pf[-1]], Wt.point_stem))
237
+ seg = tap("backbone.segmax0", segmax_logstep(p, plan, self.rounds))
238
+ x = tap("backbone.fusion_stem", self.conv([to_image(seg, 1), img], Wt.fusion_stem))
239
+ maps, pts_out = [x], [p]
240
+ for st in Wt.stages:
241
+ li, s = st.index, st.stride
242
+ for bi, (c1, c2, ds) in enumerate(st.blocks):
243
+ y = self.conv([self.conv([x], c1)], c2)
244
+ idt = self.conv([x], ds) if ds is not None else x
245
+ x = tap(f"backbone.layer{li}.block{bi}", F.hardswish(y + idt))
246
+ tap(f"backbone.layer{li}", x)
247
+ p = tap(f"backbone.point_fusion{li}", self.linear([to_points(x, s), p], st.point_fusion))
248
+ seg = tap(f"backbone.segmax{li}", segmax_logstep(p, plan, self.rounds))
249
+ fuse = tap(f"backbone.pixel_fusion{li}", self.conv([to_image(seg, s), x], st.pixel_fusion))
250
+ att = tap(f"backbone.attention{li}", self.conv([self.conv([fuse], st.attention[0])], st.attention[1]))
251
+ x = tap(f"backbone.stage{li}", fuse * att + x)
252
+ maps.append(x)
253
+ pts_out.append(p)
254
+ strides = [1] + [st.stride for st in Wt.stages]
255
+ ups = [tap(f"decoder.up{k}", self.upsample(mp, s)) for k, (mp, s) in enumerate(zip(maps, strides))]
256
+ y = tap("decoder.fuse1", self.conv(ups, Wt.fuse_layer1))
257
+ y = tap("decoder.fuse2", self.conv([y], Wt.fuse_layer2))
258
+ q = tap("decoder.point_fuse1", self.linear(pts_out, Wt.point_fuse[0]))
259
+ q = tap("decoder.point_fuse2", self.linear([q], Wt.point_fuse[1]))
260
+ h = to_points(y, 1)
261
+ for i, (lin, skip) in enumerate(zip(Wt.head_mlps, (q, pf[2], pf[1], pf[0]))):
262
+ h = tap(f"head.mlp{i}", self.linear([h], lin) + skip)
263
+ logits = tap("head.logits", self.linear([h], Wt.conv_seg))
264
+ return tap("pred_probs", torch.softmax(logits, -1)).numpy()
265
+
266
+
267
+ def plan_taps_to_literal(name: str, value: np.ndarray, plan: FramePlan) -> np.ndarray:
268
+ """A tap of :class:`FRNetPlanTorch` in the layout of ``reference/model.py``: point tensors (``N_cap`` rows)
269
+ unsorted to the network-input order, voxel tensors (``M_cap`` rows) cut to ``M``, maps unchanged."""
270
+ v = np.asarray(value)
271
+ if v.ndim == 2 and v.shape[0] == plan.n_cap:
272
+ return plan.unsort(v)
273
+ if v.ndim == 2 and v.shape[0] == plan.m_cap:
274
+ return v[: plan.m]
275
+ return v
code/tt_frnet/reference/weights.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The deployed ``frnet_<variant>.onnx`` of AutowareFoundation/lidar_frnet@v2.0 read as DATA, the way the TT port
3
+ reads it: the vendored ``ttaw.weights.OnnxWeights`` (initializers addressed by the node that consumes them; nothing
4
+ is executed). 120 of the 186 initializer *names* differ between the OT128 and QT128 exports (``onnx::Conv_1487`` vs
5
+ ``onnx::Conv_1485``, S:frnet:98-101), node names do not, so every weight is found from its node.
6
+
7
+ Where BatchNorm is folded (float64, one final rounding to float32; ``ttaw.weights.fold_bn_linear``):
8
+
9
+ - every point layer ``MatMul (no bias) -> BatchNormalization(eps 1e-3) -> Relu`` (``ffe_layers.0-2``,
10
+ ``point_stem``, ``point_fusion_layers.0-3``, ``point_fuse_layer1-2``, ``mlps.0-3``) becomes one
11
+ ``y = relu(x @ W' + b')`` -- exactly the port's ``ttnn.linear(..., bias, activation="relu")``;
12
+ - ``pre_norm`` (BatchNorm1d over the 8 decorated features) is kept as ``(scale, shift)`` float32 (``x * scale +
13
+ shift``): the port applies it on the host with the feature decoration (PLAN.md 2.8), so the first device layer is
14
+ a plain linear;
15
+ - the 53 convs already carry the exporter's BN fold (Conv with bias, S:frnet:245); the three Gemms
16
+ (``ffe_layers.3``, ``compression_layers.0``, ``conv_seg``) have their own bias.
17
+
18
+ The structure is recovered from the graph, never re-typed: stage count, BasicBlocks per stage, strides, the
19
+ frustum grid of every frustum2pixel (the ``ConstantOfShape`` shape of its ``ScatterND``), every activation (the
20
+ conv's consumer), and every wiring assumption of the reference model (concat orders, skip connections, the
21
+ decoder's resize inputs, the head's residual skips, the Softmax axis) is asserted on the graph
22
+ (:func:`load_weights` raises ``ValueError`` when one does not hold). Each layer records the ONNX tensor that holds
23
+ its output, so ``reference/ort.py`` exposes the same taps from ONNX Runtime.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
30
+
31
+ import numpy as np
32
+
33
+ from ..ttaw.weights import BatchNorm, OnnxWeights, file_sha256, fold_bn_linear
34
+ from .config import BLOCKS, NUM_CLASSES, ONNX_SHA256, STRIDES, variant_files
35
+
36
+ __all__ = ["Linear", "Conv", "Stage", "FRNetWeights", "load_weights", "check_variant_grid", "MODULE_TAPS",
37
+ "OPT_IN_TAPS"]
38
+
39
+ VE = "/model/voxel_encoder"
40
+ BB = "/model/backbone"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Linear:
45
+ """``y = act(x @ weight + bias)``; ``weight`` (in, out) float32 (BN folded where the graph has one)."""
46
+
47
+ name: str
48
+ weight: np.ndarray
49
+ bias: np.ndarray
50
+ relu: bool
51
+ onnx_node: str
52
+ onnx_output: str # ONNX tensor equal to this layer's output (after the ReLU when there is one)
53
+ bn_node: Optional[str] = None
54
+
55
+ @property
56
+ def in_features(self) -> int:
57
+ return int(self.weight.shape[0])
58
+
59
+ @property
60
+ def out_features(self) -> int:
61
+ return int(self.weight.shape[1])
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Conv:
66
+ """``y = act(conv2d(x, weight, bias, stride, padding))`` (NCHW, BN already folded by the exporter)."""
67
+
68
+ name: str
69
+ weight: np.ndarray # (Cout, Cin, k, k) float32
70
+ bias: np.ndarray # (Cout,) float32
71
+ stride: int
72
+ padding: int
73
+ activation: Optional[str] # "hardswish" | "sigmoid" | None (a residual add follows)
74
+ onnx_node: str
75
+ onnx_output: str # ONNX tensor equal to the output (after the activation when there is one)
76
+
77
+ @property
78
+ def kernel(self) -> int:
79
+ return int(self.weight.shape[-1])
80
+
81
+ @property
82
+ def in_channels(self) -> int:
83
+ return int(self.weight.shape[1])
84
+
85
+ @property
86
+ def out_channels(self) -> int:
87
+ return int(self.weight.shape[0])
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Stage:
92
+ """One backbone stage ``layer<index>`` (1-based) plus its point / pixel fusion (``*_layers.<index - 1>``)."""
93
+
94
+ index: int
95
+ stride: int # cumulative stride of the stage output (1, 2, 4, 8)
96
+ blocks: Tuple[Tuple[Conv, Conv, Optional[Conv]], ...] # (conv1 + HardSwish, conv2, downsample) per BasicBlock
97
+ block_outputs: Tuple[str, ...] # ONNX tensor of each block output (after add + HardSwish)
98
+ point_fusion: Linear # [pixel2point(x_s), p] 256 -> 128 + BN + ReLU
99
+ pixel_fusion: Conv # [frustum2pixel(segmax(p)), x_s] 256 -> 128 + HardSwish
100
+ attention: Tuple[Conv, Conv] # 128 -> 128 + HardSwish, 128 -> 128 + Sigmoid
101
+ segmax_output: str # ONNX tensor: per-voxel max of point_fusion's output (M, 128)
102
+ output: str # ONNX tensor: x = pixel_fusion * attention + x_s
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class FRNetWeights:
107
+ variant: str
108
+ frustum_hw: Tuple[int, int]
109
+ strides: Tuple[int, ...]
110
+ pre_norm: BatchNorm
111
+ pre_norm_scale: np.ndarray # (8,) float32: pre_norm(x) == x * scale + shift
112
+ pre_norm_shift: np.ndarray
113
+ ffe: Tuple[Linear, ...] # 8 -> 64 -> 128 -> 256 (ReLU, BN folded), 256 -> 256 (Gemm, no activation)
114
+ compression: Linear # 256 -> 16 + ReLU (on the per-voxel max)
115
+ stem: Tuple[Conv, ...] # 16 -> 64 -> 128 -> 128, each + HardSwish
116
+ point_stem: Linear # [pixel2point(stem), pf3] 384 -> 128 + BN + ReLU
117
+ point_stem_segmax: str # ONNX tensor of the per-voxel max of point_stem (M, 128)
118
+ fusion_stem: Conv # [frustum2pixel(segmax(p0)), stem] 256 -> 128 + HardSwish
119
+ stages: Tuple[Stage, ...]
120
+ fuse_layer1: Conv # concat of the 5 up-sampled maps 640 -> 256 + HardSwish
121
+ fuse_layer2: Conv # 256 -> 128 + HardSwish
122
+ point_fuse: Tuple[Linear, Linear] # 640 -> 256, 256 -> 128 (+ BN + ReLU)
123
+ head_mlps: Tuple[Linear, ...] # 128 -> 128, 128 -> 256, 256 -> 128, 128 -> 64 (+ BN + ReLU), then + skip
124
+ conv_seg: Linear # 64 -> 27
125
+ onnx_taps: Dict[str, str] # tap name -> ONNX tensor (module and opt-in taps)
126
+ onnx_path: str = ""
127
+ onnx_sha256: str = ""
128
+ raw_parameters: int = 0 # learned values in the ONNX initializers
129
+ folded_parameters: int = field(default=0) # values after the BN folds (what the device holds)
130
+
131
+ @property
132
+ def convs(self) -> List[Conv]:
133
+ out = list(self.stem) + [self.fusion_stem]
134
+ for st in self.stages:
135
+ for c1, c2, ds in st.blocks:
136
+ out += [c1, c2] + ([ds] if ds is not None else [])
137
+ out += [st.pixel_fusion, *st.attention]
138
+ return out + [self.fuse_layer1, self.fuse_layer2]
139
+
140
+ @property
141
+ def linears(self) -> List[Linear]:
142
+ return [*self.ffe, self.compression, self.point_stem, *(s.point_fusion for s in self.stages),
143
+ *self.point_fuse, *self.head_mlps, self.conv_seg]
144
+
145
+ @property
146
+ def num_classes(self) -> int:
147
+ return self.conv_seg.out_features
148
+
149
+
150
+ # Module taps of the reference (tests/test_reference_cpu.py compares every one with ORT; the TT port's per-module
151
+ # gates use the same names). 1-based stage numbers: stage l = ONNX layer<l> + *_layers.<l - 1>.
152
+ MODULE_TAPS: Tuple[str, ...] = (
153
+ "encoder.input", "encoder.pf0", "encoder.pf1", "encoder.pf2", "encoder.pf3", "encoder.voxel_max",
154
+ "encoder.voxel_feats", "backbone.frustum", "backbone.stem", "backbone.point_stem", "backbone.fusion_stem",
155
+ *(f"backbone.{kind}{i}" for i in range(1, 5)
156
+ for kind in ("layer", "point_fusion", "pixel_fusion", "attention", "stage")),
157
+ "decoder.fuse1", "decoder.fuse2", "decoder.point_fuse1", "decoder.point_fuse2",
158
+ *(f"head.mlp{i}" for i in range(4)), "head.logits", "pred_probs",
159
+ )
160
+ # Opt-in taps (TapRegistry include patterns): every BasicBlock output, the per-voxel max of every point branch
161
+ # (the K1 / segmented-max sites), the five up-sampled decoder inputs.
162
+ OPT_IN_TAPS: Tuple[str, ...] = (
163
+ "backbone.layer*.block*", "backbone.segmax*", "decoder.up*",
164
+ )
165
+
166
+
167
+ def _fail(msg: str) -> None:
168
+ raise ValueError(f"frnet ONNX structure: {msg}")
169
+
170
+
171
+ def _single(w: OnnxWeights, tensor: str, op_type: str) -> Any:
172
+ try:
173
+ return w.consumer_of(tensor, op_type)
174
+ except KeyError as e:
175
+ _fail(str(e))
176
+
177
+
178
+ def _point_layer(w: OnnxWeights, matmul: str, name: str) -> Linear:
179
+ mm = w.node(matmul)
180
+ if mm.op_type != "MatMul" or not w.has(mm.inputs[1]) or w.has(mm.inputs[0]):
181
+ _fail(f"{matmul} is not x @ W with a constant W")
182
+ bn = _single(w, mm.outputs[0], "BatchNormalization")
183
+ relu = _single(w, bn.outputs[0], "Relu")
184
+ wf, bf = fold_bn_linear(w.matmul_weight(matmul), None, w.batchnorm(bn.name), layout="in_out")
185
+ return Linear(name, wf, bf, True, mm.name, relu.outputs[0], bn.name)
186
+
187
+
188
+ def _gemm(w: OnnxWeights, node: str, name: str) -> Linear:
189
+ g = w.gemm(node)
190
+ if g.trans_a or g.alpha != 1.0 or g.beta != 1.0 or g.bias is None:
191
+ _fail(f"{node}: unexpected Gemm attributes")
192
+ weight = np.asarray(g.weight, np.float32)
193
+ weight = weight.T if g.trans_b else weight
194
+ out = w.node(node).outputs[0]
195
+ relus = [c for c in w.consumers(out) if c.op_type == "Relu"]
196
+ relu = len(relus) == 1 and len(w.consumers(out)) == 1
197
+ return Linear(name, np.ascontiguousarray(weight), np.asarray(g.bias, np.float32), relu, node,
198
+ relus[0].outputs[0] if relu else out)
199
+
200
+
201
+ def _conv(w: OnnxWeights, node: str, name: str) -> Conv:
202
+ c = w.conv(node)
203
+ k = c.kernel_shape
204
+ if (c.group != 1 or set(c.dilations) != {1} or len(k) != 2 or k[0] != k[1] or len(set(c.strides)) != 1
205
+ or len(set(c.pads)) != 1 or c.bias is None or c.auto_pad not in ("NOTSET", "")):
206
+ _fail(f"{node}: unexpected Conv attributes {c.kernel_shape} {c.strides} {c.pads} {c.dilations} {c.group}")
207
+ out = w.node(node).outputs[0]
208
+ cons = w.consumers(out)
209
+ act, tensor = None, out
210
+ if len(cons) == 1 and cons[0].op_type in ("HardSwish", "Sigmoid"):
211
+ act, tensor = cons[0].op_type.lower(), cons[0].outputs[0]
212
+ return Conv(name, np.asarray(c.weight, np.float32), np.asarray(c.bias, np.float32), int(c.strides[0]),
213
+ int(c.pads[0]), act, node, tensor)
214
+
215
+
216
+ def _producer_op(w: OnnxWeights, tensor: str) -> str:
217
+ p = w.producer(tensor)
218
+ return p.op_type if p is not None else ""
219
+
220
+
221
+ def _scatter_max_of(w: OnnxWeights, tensor: str) -> str:
222
+ found = [c for c in w.consumers(tensor) if c.op_type == "ScatterElements" and c.attrs.get("reduction") == "max"]
223
+ if len(found) != 1:
224
+ _fail(f"{tensor} does not feed exactly one ScatterElements(max)")
225
+ return found[0].outputs[0]
226
+
227
+
228
+ def load_weights(model_dir: Any, variant: str, *, check_sha256: bool = True) -> FRNetWeights:
229
+ """Parse ``frnet_<variant>.onnx`` of a weights directory (module docstring)."""
230
+ path = variant_files(model_dir, variant)["onnx"]
231
+ digest = file_sha256(path)
232
+ if check_sha256 and digest != ONNX_SHA256[variant]: # refused before it is parsed
233
+ raise ValueError(f"{path.name}: sha256 {digest} is not the pinned v2.0 file {ONNX_SHA256[variant]}")
234
+ w = OnnxWeights(path)
235
+ if w.input_names != ("points", "coors", "voxel_coors", "inverse_map") or w.output_names != ("pred_probs",):
236
+ _fail(f"inputs {w.input_names} / outputs {w.output_names}")
237
+ taps: Dict[str, str] = {}
238
+
239
+ # ---- frustum grids of the six frustum2pixel (ScatterND) sites, in graph order
240
+ grids = []
241
+ for sc in w.nodes("ScatterND"):
242
+ shape_node = w.producer(w.producer(sc.inputs[0]).inputs[0])
243
+ dims = [w.array(t).tolist() for t in shape_node.inputs[:3] if w.has(t)]
244
+ if len(dims) != 3 or dims[0] != [1]:
245
+ _fail(f"{sc.name}: cannot read its (1, H, W, C) image shape")
246
+ grids.append((int(dims[1][0]), int(dims[2][0])))
247
+ if len(grids) != 2 + len(STRIDES):
248
+ _fail(f"{len(grids)} ScatterND sites, expected {2 + len(STRIDES)}")
249
+ H, W = grids[0]
250
+ strides = tuple(H // h for h, _ in grids[2:])
251
+ if grids[1] != (H, W) or strides != STRIDES or any(W // s != g[1] or H // s != g[0]
252
+ for s, g in zip(strides, grids[2:])):
253
+ _fail(f"frustum grids {grids} do not follow strides {STRIDES}")
254
+
255
+ # ---- encoder
256
+ bn_pre = w.find_node(f"{VE}/pre_norm/*", "BatchNormalization")
257
+ pre = w.batchnorm(bn_pre.name)
258
+ taps["encoder.input"] = bn_pre.outputs[0]
259
+ ffe = [_point_layer(w, f"{VE}/ffe_layers.{i}/ffe_layers.{i}.0/MatMul", f"encoder.ffe{i}") for i in range(3)]
260
+ for i in range(1, 3):
261
+ if w.node(ffe[i].onnx_node).inputs[0] != ffe[i - 1].onnx_output:
262
+ _fail(f"ffe_layers.{i} does not consume ffe_layers.{i - 1}")
263
+ if w.node(ffe[0].onnx_node).inputs[0] != bn_pre.outputs[0]:
264
+ _fail("ffe_layers.0 does not consume pre_norm")
265
+ ffe3 = _gemm(w, f"{VE}/ffe_layers.3/Gemm", "encoder.ffe3")
266
+ if ffe3.relu or w.node(ffe3.onnx_node).inputs[0] != ffe[2].onnx_output:
267
+ _fail("ffe_layers.3 is not a plain Gemm on ffe_layers.2")
268
+ ffe.append(ffe3)
269
+ for i, lin in enumerate(ffe):
270
+ taps[f"encoder.pf{i}"] = lin.onnx_output
271
+ taps["encoder.voxel_max"] = _scatter_max_of(w, ffe3.onnx_output)
272
+ compression = _gemm(w, f"{VE}/compression_layers/compression_layers.0/Gemm", "encoder.compression")
273
+ if not compression.relu or w.node(compression.onnx_node).inputs[0] != taps["encoder.voxel_max"]:
274
+ _fail("compression_layers is not Gemm + ReLU on the per-voxel max")
275
+ taps["encoder.voxel_feats"] = compression.onnx_output
276
+
277
+ # ---- stem and point stem
278
+ stem = tuple(_conv(w, f"{BB}/stem/stem.{i}/Conv", f"backbone.stem{j}") for j, i in enumerate((0, 3, 6)))
279
+ if any(c.activation != "hardswish" or c.stride != 1 for c in stem):
280
+ _fail("stem convs must be stride 1 + HardSwish")
281
+ taps["backbone.frustum"] = w.node(stem[0].onnx_node).inputs[0]
282
+ if _producer_op(w, taps["backbone.frustum"]) != "Transpose":
283
+ _fail("the stem does not read the transposed frustum image")
284
+ taps["backbone.stem"] = stem[-1].onnx_output
285
+ point_stem = _point_layer(w, f"{BB}/point_stem/point_stem.0/MatMul", "backbone.point_stem")
286
+ cat = w.producer(w.node(point_stem.onnx_node).inputs[0])
287
+ if cat.op_type != "Concat" or len(cat.inputs) != 2 or cat.inputs[1] != ffe3.onnx_output:
288
+ _fail("point_stem input is not concat[pixel2point(stem), ffe_layers.3]")
289
+ taps["backbone.point_stem"] = point_stem.onnx_output
290
+ point_stem_segmax = _scatter_max_of(w, point_stem.onnx_output)
291
+ taps["backbone.segmax0"] = point_stem_segmax
292
+ fusion_stem = _conv(w, f"{BB}/fusion_stem/fusion_stem.0/Conv", "backbone.fusion_stem")
293
+ cat = w.producer(w.node(fusion_stem.onnx_node).inputs[0])
294
+ if fusion_stem.activation != "hardswish" or cat.op_type != "Concat" or cat.inputs[1] != stem[-1].onnx_output:
295
+ _fail("fusion_stem is not HardSwish(conv(concat[frustum2pixel(p0), stem]))")
296
+ taps["backbone.fusion_stem"] = fusion_stem.onnx_output
297
+
298
+ # ---- stages
299
+ stages: List[Stage] = []
300
+ x_in, p_prev = fusion_stem.onnx_output, point_stem.onnx_output
301
+ for li in range(1, 5):
302
+ conv1s = w.nodes("Conv", f"{BB}/layer{li}/layer{li}.*/conv1/Conv")
303
+ if len(conv1s) != BLOCKS[li - 1]:
304
+ _fail(f"layer{li} has {len(conv1s)} BasicBlocks, expected {BLOCKS[li - 1]}")
305
+ blocks, outs = [], []
306
+ for bi in range(len(conv1s)):
307
+ pre_b = f"{BB}/layer{li}/layer{li}.{bi}"
308
+ c1 = _conv(w, f"{pre_b}/conv1/Conv", f"backbone.layer{li}.block{bi}.conv1")
309
+ c2 = _conv(w, f"{pre_b}/conv2/Conv", f"backbone.layer{li}.block{bi}.conv2")
310
+ ds = None
311
+ if w.nodes("Conv", f"{pre_b}/downsample/*"):
312
+ ds = _conv(w, f"{pre_b}/downsample/downsample.0/Conv", f"backbone.layer{li}.block{bi}.downsample")
313
+ if c1.activation != "hardswish" or c2.activation is not None or (ds is not None and ds.activation):
314
+ _fail(f"{pre_b}: unexpected activations")
315
+ 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):
316
+ _fail(f"{pre_b} does not read the previous block output")
317
+ add = _single(w, c2.onnx_output, "Add")
318
+ idt = ds.onnx_output if ds is not None else x_in
319
+ if set(add.inputs) != {c2.onnx_output, idt}:
320
+ _fail(f"{pre_b}: the residual add does not take conv2 + identity")
321
+ hs = _single(w, add.outputs[0], "HardSwish")
322
+ blocks.append((c1, c2, ds))
323
+ outs.append(hs.outputs[0])
324
+ taps[f"backbone.layer{li}.block{bi}"] = hs.outputs[0]
325
+ x_in = hs.outputs[0]
326
+ x_s = outs[-1]
327
+ taps[f"backbone.layer{li}"] = x_s
328
+ pf = _point_layer(w, f"{BB}/point_fusion_layers.{li - 1}/point_fusion_layers.{li - 1}.0/MatMul",
329
+ f"backbone.point_fusion{li}")
330
+ cat = w.producer(w.node(pf.onnx_node).inputs[0])
331
+ if cat.op_type != "Concat" or len(cat.inputs) != 2 or cat.inputs[1] != p_prev:
332
+ _fail(f"point_fusion_layers.{li - 1} input is not concat[pixel2point(x), p]")
333
+ seg = _scatter_max_of(w, pf.onnx_output)
334
+ taps[f"backbone.point_fusion{li}"] = pf.onnx_output
335
+ taps[f"backbone.segmax{li}"] = seg
336
+ pix = _conv(w, f"{BB}/pixel_fusion_layers.{li - 1}/pixel_fusion_layers.{li - 1}.0/Conv",
337
+ f"backbone.pixel_fusion{li}")
338
+ cat = w.producer(w.node(pix.onnx_node).inputs[0])
339
+ if pix.activation != "hardswish" or cat.op_type != "Concat" or cat.inputs[1] != x_s:
340
+ _fail(f"pixel_fusion_layers.{li - 1} is not HardSwish(conv(concat[frustum2pixel(p), x]))")
341
+ a0 = _conv(w, f"{BB}/attention_layers.{li - 1}/attention_layers.{li - 1}.0/Conv",
342
+ f"backbone.attention{li}.conv0")
343
+ a1 = _conv(w, f"{BB}/attention_layers.{li - 1}/attention_layers.{li - 1}.3/Conv",
344
+ f"backbone.attention{li}.conv1")
345
+ if (a0.activation != "hardswish" or a1.activation != "sigmoid" or w.node(a0.onnx_node).inputs[0] !=
346
+ pix.onnx_output or w.node(a1.onnx_node).inputs[0] != a0.onnx_output):
347
+ _fail(f"attention_layers.{li - 1} is not sigmoid(conv(hswish(conv(pixel_fusion))))")
348
+ mul = _single(w, a1.onnx_output, "Mul")
349
+ if set(mul.inputs) != {pix.onnx_output, a1.onnx_output}:
350
+ _fail(f"stage {li}: the attention product is not pixel_fusion * attention")
351
+ add = _single(w, mul.outputs[0], "Add")
352
+ if set(add.inputs) != {mul.outputs[0], x_s}:
353
+ _fail(f"stage {li}: the output is not pixel_fusion * attention + x")
354
+ taps[f"backbone.pixel_fusion{li}"] = pix.onnx_output
355
+ taps[f"backbone.attention{li}"] = a1.onnx_output
356
+ taps[f"backbone.stage{li}"] = add.outputs[0]
357
+ stride = STRIDES[li - 1]
358
+ stages.append(Stage(li, stride, tuple(blocks), tuple(outs), pf, pix, (a0, a1), seg, add.outputs[0]))
359
+ x_in, p_prev = add.outputs[0], pf.onnx_output
360
+ got = [1] + [st.blocks[0][0].stride for st in stages[1:]]
361
+ if tuple(int(np.prod(got[: i + 1])) for i in range(4)) != STRIDES:
362
+ _fail(f"stage strides {got} do not give {STRIDES}")
363
+
364
+ # ---- decoder
365
+ fuse1 = _conv(w, f"{BB}/fuse_layer1/conv/Conv", "decoder.fuse1")
366
+ fuse2 = _conv(w, f"{BB}/fuse_layer2/conv/Conv", "decoder.fuse2")
367
+ cat = w.producer(w.node(fuse1.onnx_node).inputs[0])
368
+ sources = [fusion_stem.onnx_output] + [st.output for st in stages]
369
+ if cat.op_type != "Concat" or len(cat.inputs) != 5:
370
+ _fail("fuse_layer1 does not read a 5-way concat")
371
+ for k, (t, src) in enumerate(zip(cat.inputs, sources)):
372
+ rs = w.producer(t)
373
+ if rs.op_type != "Resize" or rs.inputs[0] != src or rs.attrs.get("mode") != "linear" or \
374
+ rs.attrs.get("coordinate_transformation_mode") != "align_corners":
375
+ _fail(f"decoder input {k} is not an align_corners bilinear Resize of {src}")
376
+ taps[f"decoder.up{k}"] = t
377
+ if fuse1.activation != "hardswish" or fuse2.activation != "hardswish" or \
378
+ w.node(fuse2.onnx_node).inputs[0] != fuse1.onnx_output:
379
+ _fail("fuse_layer1 / fuse_layer2 are not HardSwish convs in sequence")
380
+ taps["decoder.fuse1"] = fuse1.onnx_output
381
+ taps["decoder.fuse2"] = fuse2.onnx_output
382
+ pf1 = _point_layer(w, f"{BB}/point_fuse_layer1/point_fuse_layer1.0/MatMul", "decoder.point_fuse1")
383
+ pf2 = _point_layer(w, f"{BB}/point_fuse_layer2/point_fuse_layer2.0/MatMul", "decoder.point_fuse2")
384
+ cat = w.producer(w.node(pf1.onnx_node).inputs[0])
385
+ if cat.op_type != "Concat" or list(cat.inputs) != [point_stem.onnx_output] + [s.point_fusion.onnx_output
386
+ for s in stages]:
387
+ _fail("point_fuse_layer1 input is not concat[p0, p1, p2, p3, p4]")
388
+ if w.node(pf2.onnx_node).inputs[0] != pf1.onnx_output:
389
+ _fail("point_fuse_layer2 does not consume point_fuse_layer1")
390
+ taps["decoder.point_fuse1"] = pf1.onnx_output
391
+ taps["decoder.point_fuse2"] = pf2.onnx_output
392
+
393
+ # ---- head: h = pixel2point(fuse2); h = mlp_i(h) + skip_i; logits; softmax
394
+ mlps = [_point_layer(w, f"/model/mlps.{i}/mlps.{i}.0/MatMul", f"head.mlp{i}") for i in range(4)]
395
+ skips = [pf2.onnx_output, ffe[2].onnx_output, ffe[1].onnx_output, ffe[0].onnx_output]
396
+ prev = None
397
+ for i, (lin, skip) in enumerate(zip(mlps, skips)):
398
+ add = _single(w, lin.onnx_output, "Add")
399
+ if set(add.inputs) != {lin.onnx_output, skip}:
400
+ _fail(f"head mlps.{i} skip is not {skip}")
401
+ if prev is not None and w.node(lin.onnx_node).inputs[0] != prev:
402
+ _fail(f"head mlps.{i} does not consume the previous residual sum")
403
+ taps[f"head.mlp{i}"] = add.outputs[0]
404
+ prev = add.outputs[0]
405
+ conv_seg = _gemm(w, "/model/conv_seg/Gemm", "head.logits")
406
+ if conv_seg.relu or w.node(conv_seg.onnx_node).inputs[0] != prev or conv_seg.out_features != NUM_CLASSES:
407
+ _fail("conv_seg is not the final 64 -> 27 Gemm on the last residual sum")
408
+ sm = _single(w, conv_seg.onnx_output, "Softmax")
409
+ if sm.attrs.get("axis", -1) not in (-1, 1) or sm.outputs[0] != "pred_probs":
410
+ _fail("the output is not Softmax(axis=-1) of conv_seg")
411
+ taps["head.logits"] = conv_seg.onnx_output
412
+ taps["pred_probs"] = "pred_probs"
413
+
414
+ scale = pre.scale.astype(np.float32)
415
+ shift = pre.shift.astype(np.float32)
416
+ raw = int(sum(int(np.asarray(w.array(t)).size) for t in w.initializer_names()
417
+ if np.issubdtype(np.asarray(w.array(t)).dtype, np.floating)))
418
+ out = FRNetWeights(variant=variant, frustum_hw=(H, W), strides=strides, pre_norm=pre, pre_norm_scale=scale,
419
+ pre_norm_shift=shift, ffe=tuple(ffe), compression=compression, stem=stem,
420
+ point_stem=point_stem, point_stem_segmax=point_stem_segmax, fusion_stem=fusion_stem,
421
+ stages=tuple(stages), fuse_layer1=fuse1, fuse_layer2=fuse2, point_fuse=(pf1, pf2),
422
+ head_mlps=tuple(mlps), conv_seg=conv_seg, onnx_taps=taps, onnx_path=str(path),
423
+ onnx_sha256=digest, raw_parameters=raw)
424
+ folded = sum(c.weight.size + c.bias.size for c in out.convs) + sum(
425
+ lin.weight.size + lin.bias.size for lin in out.linears) + 16
426
+ object.__setattr__(out, "folded_parameters", int(folded))
427
+ missing = [t for t in MODULE_TAPS if t not in taps]
428
+ if missing:
429
+ _fail(f"no ONNX tensor for taps {missing}")
430
+ return out
431
+
432
+
433
+ def check_variant_grid(weights: FRNetWeights, frustum_h: int, frustum_w: int) -> None:
434
+ """The ml_package grid must equal the grid baked into the ONNX (S:frnet:95-98: 1024 OT128 / 256 QT128)."""
435
+ if weights.frustum_hw != (frustum_h, frustum_w):
436
+ raise ValueError(f"ml_package frustum {frustum_h}x{frustum_w} != the ONNX's {weights.frustum_hw}")
437
+
code/tt_frnet/samples/README.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sample inputs shipped with frnet-p150
2
+
3
+ Only data generated by this bundle ships here: one **synthetic** LiDAR sweep per serve profile, made by
4
+ `code/scripts/make_sample.py` (no dataset involved; Apache-2.0, like the code). Public-dataset frames (PandaSet,
5
+ nuScenes) are used for validation and demo renders but are not in this repository: PandaSet-derived samples ship only
6
+ once they are approved, and non-commercial data (nuScenes) and the Autoware demo rosbag's VLS-128 frames (licence
7
+ unstated) never ship.
8
+
9
+ Never use the `.bin` suffix here (or `.pt`, `.pth`, `.ckpt`, `.safetensors`): tt-model's staging silently drops
10
+ those suffixes from `code/` (`CODE_IGNORE`, tt-model-manager `src/tt_kernel/build.py:328-332`). Store point clouds as
11
+ `.npy` / `.npz` / `.pcd`.
12
+
13
+ | file | content |
14
+ |---|---|
15
+ | `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 |
16
+ | `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 |
17
+ | `synthetic_<v>.<v>.reference.json` | the stored fp32 CPU-reference output of the sample for profile `<v>`: 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 |
18
+
19
+ `server/smoke_test.py` posts the sample of the served variant with its preset and requires >= 98.5 % label agreement
20
+ with the stored reference (PLAN.md 2.8 argmax gate); `tests/test_e2e_device.py::test_shipped_sample` gates the same
21
+ on the device. Regenerate the references with `code/scripts/make_sample.py` whenever the reference, the weights or
22
+ the post-processing changes.
23
+
24
+ The scans are geometric toys: the network has never seen such scenes (it labels most of the synthetic road
25
+ `other_flat_surface` on the OT128 profile and finds few car points). They exercise the full input contract and the
26
+ device path, and they are the smoke test's input; they say nothing about accuracy on real data. The model card's
27
+ accuracy rows come from public datasets.
code/tt_frnet/samples/synthetic_ot128.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f95d27f004dc7e8ed4f14e9a14da38d07775657e3f92f615fdd429e69eaa5e49
3
+ size 679575
code/tt_frnet/samples/synthetic_ot128.ot128.reference.json ADDED
The diff for this file is too large to render. See raw diff
 
code/tt_frnet/samples/synthetic_qt128.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:01c8aa6c2740b903e9133942e12cb9a091a05ea11b9cf6ed03fe1af61aef13c7
3
+ size 579254
code/tt_frnet/samples/synthetic_qt128.qt128.reference.json ADDED
The diff for this file is too large to render. See raw diff
 
code/tt_frnet/server/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """HTTP serving of frnet-p150, thin bindings of the vendored ``ttaw.server``: ``app.py`` (the ASGI app tt-model
3
+ runs with uvicorn), ``client.py`` (stdlib request builder, runnable as a script), ``smoke_test.py`` (stdlib PASS/FAIL
4
+ check of a running server: ETH dispatch and the 12x10 grid asserted, agreement with the stored CPU reference)."""
code/tt_frnet/server/app.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """ASGI serving app of frnet-p150 on one Tenstorrent Blackhole p150: the HTTP contract of the Autoware
3
+ collection (vendored ``ttaw.server.app``, BUNDLE_CONVENTIONS.md section 7) bound to :class:`FRNet`.
4
+
5
+ Served by tt-model-manager as ``kind: tt-dit-server``::
6
+
7
+ python -m uvicorn --host 0.0.0.0 --port <p> --lifespan on tt_frnet.server.app:app
8
+
9
+ Routes: ``GET /``, ``/health`` and ``/v1/health`` (always 200: ``ok`` / ``starting`` / ``error``), ``/info``,
10
+ ``/v1/models`` (stub), ``POST /predict``; errors 400 / 422 / 503 / 500 (SERVING.md section 3). Everything that
11
+ touches the device happens in the lifespan: weights -> device (ETH dispatch, 12x10) -> graph -> trace capture of
12
+ every warm-up variant, so uvicorn's ``Application startup complete`` (the line ``tt-model serve`` waits for) means
13
+ warm; SIGTERM (``tt-model stop``, 120 s) closes the model under the lock. ``/predict`` calls the Python API, so it
14
+ returns exactly what ``model(...)`` returns. Importing this module has no side effects beyond importing fastapi and
15
+ pydantic (the image's ``verify:`` imports it without a device); the environment is read in the lifespan only.
16
+
17
+ Model-specific request fields (e.g. PointPainting ``rois``): subclass ``PredictRequest``, pass ``request_model=`` and
18
+ ``decode_extra=`` (which adds the decoded field to the call kwargs) to :class:`ServerSpec`, and list the keyword in
19
+ ``EXTRA_INPUTS`` of the model class. Host tests swap the model before starting the app:
20
+ ``app.state.ttaw.model_factory = Stub``; ``app.state.ttaw.predict(request)`` is the route handler itself.
21
+ """
22
+ from pathlib import Path
23
+
24
+ from .. import __version__
25
+ from ..api import FRNet
26
+ from ..ttaw.server.app import PredictRequest, ServerSpec, create_app, parse_mesh_shape
27
+
28
+ __all__ = ["SPEC", "app", "PredictRequest", "parse_mesh_shape"]
29
+
30
+ SPEC = ServerSpec(
31
+ model_name=FRNet.MODEL_NAME,
32
+ env_prefix=FRNet.ENV_PREFIX,
33
+ model_cls=FRNet,
34
+ task="LiDAR 3D semantic segmentation",
35
+ default_weights=FRNet.DEFAULT_REPO,
36
+ owner="changh95",
37
+ 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",
38
+ autoware={"package": "autoware_lidar_frnet",
39
+ "path": "perception/autoware_lidar_frnet",
40
+ "autoware_universe": "9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd"},
41
+ source={"repo": "https://huggingface.co/changh95/frnet-p150", "license": "Apache-2.0"},
42
+ calib_dir=Path(__file__).resolve().parents[1] / "calib",
43
+ version=__version__,
44
+ description="FRNet (Autoware lidar_frnet) on one Tenstorrent Blackhole p150. "
45
+ "Not an OpenAI-compatible API.",
46
+ )
47
+
48
+ app = create_app(SPEC)