tt-model push frnet-p150 (container)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +19 -0
- OPT_BASELINE.md +557 -0
- OPT_REPORT.md +157 -0
- PYTHON.md +220 -0
- README.md +202 -0
- SERVING.md +211 -0
- VERIFICATION_2026-10-09.md +166 -0
- build_info.json +48 -0
- code/PYTHON.md +220 -0
- code/conftest.py +59 -0
- code/models/common/lightweightmodule.py +12 -0
- code/scripts/README.md +19 -0
- code/scripts/bench.py +261 -0
- code/scripts/container_smoke.sh +137 -0
- code/scripts/diag_conv_precision.py +224 -0
- code/scripts/diag_frame_taps.py +131 -0
- code/scripts/fetch_samples.sh +24 -0
- code/scripts/make_demo.py +322 -0
- code/scripts/make_goldens.py +198 -0
- code/scripts/make_sample.py +169 -0
- code/scripts/make_samples.py +114 -0
- code/scripts/precision_study.py +120 -0
- code/scripts/profile_ops.py +185 -0
- code/scripts/profile_summary.py +251 -0
- code/scripts/visualizer_report.py +116 -0
- code/tt_frnet/__init__.py +39 -0
- code/tt_frnet/api.py +189 -0
- code/tt_frnet/calib/README.md +18 -0
- code/tt_frnet/calib/synthetic_ot128.json +31 -0
- code/tt_frnet/calib/synthetic_qt128.json +31 -0
- code/tt_frnet/device.py +59 -0
- code/tt_frnet/host/__init__.py +16 -0
- code/tt_frnet/host/frame.py +138 -0
- code/tt_frnet/host/postprocess.py +159 -0
- code/tt_frnet/host/preprocess.py +367 -0
- code/tt_frnet/io.py +34 -0
- code/tt_frnet/reference/__init__.py +26 -0
- code/tt_frnet/reference/config.py +284 -0
- code/tt_frnet/reference/model.py +179 -0
- code/tt_frnet/reference/ort.py +91 -0
- code/tt_frnet/reference/pipeline.py +95 -0
- code/tt_frnet/reference/rewrites.py +275 -0
- code/tt_frnet/reference/weights.py +437 -0
- code/tt_frnet/samples/README.md +27 -0
- code/tt_frnet/samples/synthetic_ot128.npz +3 -0
- code/tt_frnet/samples/synthetic_ot128.ot128.reference.json +0 -0
- code/tt_frnet/samples/synthetic_qt128.npz +3 -0
- code/tt_frnet/samples/synthetic_qt128.qt128.reference.json +0 -0
- code/tt_frnet/server/__init__.py +4 -0
- 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 |
+
|  |  |
|
| 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 |
+
|  |  |
|
| 125 |
+
| **PandaSet 019, frame 40: p150 vs the CPU reference** | **PandaSet 019, frames 00-75 (every 5th frame)** |
|
| 126 |
+
|  |  |
|
| 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 |
+
|  |  |
|
| 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)
|