File size: 18,221 Bytes
fe51a63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# Serving FRNet (Autoware lidar_frnet) on Blackhole with tt-model-manager

This repo is the authoring source of the **tt-model container package** `changh95/frnet-p150`
(`kind: tt-dit-server`, schema 5.1): `tt-model.yaml` is the manifest, `code/` the port, and
`code/tt_frnet/server/app.py` the ASGI app uvicorn runs. Weights are a pinned pointer, never baked into the image.
The device open, the decoders, the Python-API contract and the HTTP contract come from `code/tt_frnet/ttaw/`, the
shared package of the Autoware ports vendored into this repo (`ttaw/VENDORED.json` records its version and file
hashes; it is not edited here).

| item | value |
|---|---|
| tt-metal tree | `/home/ubuntu/experiments/tt-models/tt-metal` (main `44d66500520`, `v0.80.0-dev20261006-78-g44d66500520`, + `patches/tt-metal-eth-dispatch.patch`; torch 2.11.0+cpu) |
| weights | `AutowareFoundation/lidar_frnet` @ `a53a1c11b0b28d31fd50a03abef5db0e830a5b3c` (tag `v2.0`; files `frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml`; 85 MB (frnet_ot128.onnx + frnet_qt128.onnx, 42.4 MB each); Apache-2.0; public, ungated) |
| Autoware reference | `perception/autoware_lidar_frnet` @ autoware_universe `9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd` |
| app | `tt_frnet.server.app:app` (uvicorn; the ASGI lifespan does weights -> device -> graph -> trace capture) |
| device recipe | `ttnn.open_device(device_id, dispatch_core_config=DispatchCoreConfig(ETH), l1_small_size=65536, trace_region_size=256 MiB, num_command_queues=1)` (`DEVICE_DEFAULTS` in `code/tt_frnet/device.py`; the trace uses 8.7 MiB (ot128) / 5.6 MiB (qt128)) |
| hardware | one Blackhole p150 (`hardware: p150`, `mesh_device: P150`, `TT_MESH_SHAPE=1x1`), 12×10 compute grid |
| serve profiles | `ot128` (default; Hesai OT128, `FRNET_VARIANT=ot128`) and `qt128` (Hesai QT128); one variant per process. Both pin `FRNET_DISPATCH=eth`, `FRNET_NUM_CQS=1` and the precision knobs `FRNET_POINT_TERMS=2`, `FRNET_MAP_DTYPE=float32`, `FRNET_CONV_TERMS=1` |

## 1. Run on the HOST (hardware validation, no Docker)

The tt-metal `python_env` has ttnn and torch; put the HTTP stack in a side directory instead of installing into it.
On the shared workspace box every command that opens the chip goes through the device lock (`bin/devrun`).

```bash
ROOT=/home/ubuntu/experiments/tt-models
source $ROOT/bin/tt-env.sh                     # TT_METAL_HOME, PYTHONPATH, tt-metal python_env
mkdir -p /tmp/frnet-http && uv pip install --python $TT_METAL_HOME/python_env/bin/python \
    --target /tmp/frnet-http fastapi uvicorn onnx
cd $ROOT/bundles/frnet-p150
export PYTHONPATH=$PWD/code:$TT_METAL_HOME:$TT_METAL_HOME/ttnn:/tmp/frnet-http
export HF_MODEL=AutowareFoundation/lidar_frnet TT_MODEL_WEIGHTS_REVISION=a53a1c11b0b28d31fd50a03abef5db0e830a5b3c TT_MESH_SHAPE=1x1

# host tests (no device; the device tests are skipped; ttnn is the fake of common/tests/host: a real ttnn host
# tensor opens the chip, so only devrun jobs may build one)
PYTHONPATH=$ROOT/research/packaging/scripts:$PYTHONPATH TT_VISIBLE_DEVICES=none \
    python -m pytest -q -p no:cacheprovider -p fake_ttnn_plugin code/tt_frnet/tests
# device tests and the server, under the lock
$ROOT/bin/devrun -t 1800 -- python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py code/tt_frnet/tests/test_e2e_device.py
# (FRNET_VARIANT=qt128 for the second profile; the smoke test posts the shipped sample of the served variant)
$ROOT/bin/devrun -t 1800 -- bash -c 'python -m uvicorn --host 127.0.0.1 --port 20000 --lifespan on tt_frnet.server.app:app & \
    S=$!; python code/tt_frnet/server/smoke_test.py --url http://127.0.0.1:20000 --wait 1200; R=$?; kill -TERM $S; wait $S; exit $R'
```

Boot log landmarks (they drive `tt-model serve`'s progress view, `boot_progress.py` TT_DIT_PHASES):
`Loading weights` -> `Opening device` -> `Warming up: capturing trace ...` -> `Warmup complete` -> uvicorn
`Application startup complete`. A cold JIT cache compiles kernels for minutes; later boots take seconds. Startup
failures raise and uvicorn exits non-zero (no CPU fallback). SIGTERM: the lifespan releases the traces and closes the
chip (within `tt-model stop`'s 120 s budget).

Offline override (no Hub): `FRNET_WEIGHTS_DIR=<dir holding frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml>`.

## 2. Package, serve, push (Docker)

Docker 29 on this box is **rootful** (`/var/lib/docker`), and the user's session may not carry the `docker` group:
wrap every docker-using command in `sg docker -c "..."` (no `docker-env.sh` is needed here; the reference bundles'
`docker-env.sh` was the authors' rootless-Docker box). Run every `tt-model` command **from this directory**
(`source.tt_metal` and `extra_code[].root: code` are CWD-relative) and point `--out` outside it.

```bash
ROOT=/home/ubuntu/experiments/tt-models; cd $ROOT/bundles/frnet-p150
TTM=$HOME/.local/share/uv/tools/tt-model/bin/python

# offline validation (seconds; no docker, no device) -- must print VALID
$TTM -c "from tt_kernel.container_manifest import load_container_manifest as L; m=L('tt-model.yaml', check_sources=True); p=m.resolve_profile(); print('VALID', m.name, m.kind, p.hardware, p.mesh_device, m.weights_ref)"

# build (one at a time on the box: the absolute lock file): ~35 min cold, ~12 min after a code/ change (the C++
# build re-runs, ccache-warm, whenever code/ changes), ~1 min for a manifest- or lock-only change
flock $ROOT/.package.lock sg docker -c "tt-model package --container tt-model.yaml --out $ROOT/build"   # log: ~/.cache/tt-model/build/frnet-p150.log
# provenance: the base-image digests this build resolved (tt-model's FROM tags float) -> build_info.json (published)
sg docker -c "python3 $ROOT/research/packaging/scripts/record_build_info.py --staged $ROOT/build/frnet-p150 --bundle ."

# serve + smoke + stop, all inside ONE device-lock window, once per serve profile (`tt-model profiles
# $ROOT/build/frnet-p150/tt_kernel_manifest.json` lists them; serve returns once READY and leaves the container
# running; the script's trap saves the container log and always runs `tt-model stop`, also when devrun's timeout
# fires: -k 150 > the 120 s grace). No token reaches the public weights download. Evidence: logs/smoke/ (--log-dir).
for prof in ot128 qt128; do
  $ROOT/bin/devrun -t 3600 -k 150 -- env -u HF_TOKEN -u HUGGING_FACE_HUB_TOKEN \
      sg docker -c "bash code/scripts/container_smoke.sh $ROOT/build/frnet-p150 20000 --profile $prof"
done

# publish: overlay the hand-written docs, the pip project and the build record onto the staged dir, then push
# (HF_TOKEN only from the environment)
rsync -a README.md SERVING.md OPT_BASELINE.md OPT_REPORT.md VERIFICATION_*.md tt-model.yaml pyproject.toml build_info.json \
    examples media patches $ROOT/build/frnet-p150/
cp code/PYTHON.md $ROOT/build/frnet-p150/PYTHON.md 2>/dev/null || true
tt-model push $ROOT/build/frnet-p150 --publish
```

The smoke test fails unless `/info` reports ETH dispatch, the 12x10 grid and what the staged package pins for the
profile (`FRNET_DISPATCH`, `FRNET_NUM_CQS`, `FRNET_VARIANT`, the weights revision), and it compares the
served output of the shipped sample of the profile (`samples/synthetic_<variant>.npz`) with its stored CPU-reference
output (`samples/synthetic_<variant>.<variant>.reference.json`; >= 98.5 % label and filter agreement).

`serve` publishes the first free port from 20000 and exports into the container: `HF_MODEL=AutowareFoundation/lidar_frnet`,
`MESH_DEVICE=P150`, `TT_MESH_SHAPE=1x1`, `TT_MODEL_WEIGHTS_REVISION=a53a1c11b0b28d31fd50a03abef5db0e830a5b3c`, then `serve.env`. The HF cache is
mounted at `/hf` (rw), the JIT cache at `/cache` (host `~/.cache/tt-model/frnet-p150/cache`), and the container
sees only its own chip (`--device /dev/tenstorrent/<n>`). tt-cli users: `tt serve changh95/frnet-p150` /
`tt model stop changh95/frnet-p150` (point tt at this tt-model with
`tt config set tools.override.tt-model ~/.local/bin/tt-model` or `TT_TOOL_BIN_TT_MODEL`).

What `push` does to the repo: `code/` and `image/` on the Hub become exactly the staged trees (`extra_code.paths` +
`models/common/lightweightmodule.py`); every top-level file of the staged dir is uploaded (the overlaid README
replaces the generated card; `pyproject.toml` and `build_info.json` arrive the same way, never through `code/`);
top-level files already on the Hub and not in the staged dir are kept.

## 3. The request / response contract

| route | purpose |
|---|---|
| `GET /health` | `{"status": "ok" \| "starting" \| "error", "model", "device": {"dispatch", "grid", "cores", "device_id"}, "error"}`, always 200; `ok` only after warm-up |
| `GET /v1/health` | same as `/health` (tt-model's hint for non-chat packages points here) |
| `GET /info` | model, task, io, Autoware package + commit, weights {repo, tag, revision, path, license}, device (dispatch, grid), input limits (point formats, fields, camera order, calibration presets), labels, variant, warm variants, runtime params + defaults, compile params, warm-up and boot times |
| `GET /v1/models` | `{"object": "list", "data": [{"id": "AutowareFoundation/lidar_frnet", "object": "model", "owned_by": "changh95"}]}` -- a stub so OpenAI-shaped probes do not 404; NOT a chat API |
| `POST /predict` | one frame -> the model output (below) |

### 3.1 Request (`application/json`; unknown fields are a 422)

| field | type | meaning |
|---|---|---|
| `points` | object | `{"format": "bin" \| "npy" \| "npz" \| "pcd" \| "list", "data": <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 |
| `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 |
| `params` | object | per-request knobs (host post-processing only; they change `filter_keep`, never the classes): `class_probability_threshold` (float in [0, 1], default 0.05), `filter_classes` (comma-separated class names, `""` = none, default `"drivable_surface"`, at most 16) |
| `output_format` | str | `json` (default) or `npz` (adds lossless base64 NPZ arrays) |
| `sweeps`, `images`, `stream`, `inputs` | – | fields of the collection's common envelope that FRNet does not take: a request with any of them is a 400 |

Transforms (`T_<to>_from_<from>`): a 4×4 (or 3×4) row-major matrix, `{"translation": [x, y, z], "rotation_wxyz": [w, x, y, z]}`
(or `rotation_xyzw`), or `{"x", "y", "z", "roll", "pitch", "yaw"}` (Autoware `sensor_kit_calibration.yaml`, tf2 RPY).
Intrinsics: 3×3 K, 3×4 P, or `{"fx", "fy", "cx", "cy"}`. Base64 may be standard or URL-safe, wrapped, with or without a
`data:` prefix.

### 3.2 Response (200)

The shipped OT128 sample (`synthetic_ot128.npz` with the preset `synthetic_ot128`), abridged (`data` fields cut):

```json
{"model": "frnet-p150", "frame_id": "sensor", "num_points": 52613, "num_points_raw": 52613,
 "labels": {"format": "npz", "key": "labels", "dtype": "uint8", "shape": [52613], "data": "..."},
 "scores": {"format": "npz", "key": "scores", "dtype": "float32", "shape": [52613], "data": "..."},
 "points": {"format": "npz", "key": "points", "dtype": "float32", "shape": [52613, 4], "data": "..."},
 "filter_keep": {"format": "npz", "key": "filter_keep", "dtype": "bool", "shape": [52613], "data": "..."},
 "input_index": {"format": "npz", "key": "input_index", "dtype": "int32", "shape": [52613], "data": "..."},
 "class_names": ["drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "..."],
 "class_counts": {"drivable_surface": 2913, "other_flat_surface": 20118, "sidewalk": 5, "manmade": 22898, "vegetation": 6526, "car": 13, "semi_trailer": 140, "...": 0},
 "meta": {"variant": "ot128", "num_points_input": 53411, "cropped_by_ego_box": 798, "num_points_interpolated": 0, "num_unique_coors": 52613,
          "ego_crop": {"enabled": true, "bounds": [-1.53, -1.15, -0.1, 6.1, 1.15, 3.1], "reference_frame": "base_link", "...": "..."},
          "plan": {"n": 52613, "m": 52613, "n_cap": 160000, "m_cap": 60000, "...": "..."},
          "filter": {"classes": ["drivable_surface"], "class_probability_threshold": 0.05, "kept": 44747}, "...": "..."},
 "timing_ms": {"decode": 24.711, "preprocess": 47.664, "device": 229.164, "postprocess": 5.018, "model_call": 281.879, "total": 310.689}}
```

- Output point order (the node's): the input points that survive the ego crop box, in input order (after non-finite
  rows are dropped), then the interpolated points Autoware appends; the first `num_points_raw` rows are raw points and
  `input_index` maps them back to the decoded input rows. The interpolated points are published, as Autoware does.
- `labels` is Autoware's `class_id`, `scores` its `probability` (the softmax probability of that class), `points` the
  x, y, z, intensity of every output point in the sensor frame, `filter_keep` the membership of Autoware's
  `~/output/pointcloud/filtered` cloud.
- An out-of-profile frame (input points, points after interpolation or unique cells outside the variant's ranges:
  ot128 5,000-160,000 points, qt128 5,000-120,000, both 3,000-60,000 cells) answers 200 with `num_points: 0` and the
  node's message in `meta.skipped`: the node publishes nothing for such a frame. Nothing runs on the device.
- `meta` also has the crop and network-input counts (`num_points_input`, `cropped_by_ego_box`, `num_points_interpolated`,
  `num_unique_coors`, ...), the device plan sizes and the applied filter.

`timing_ms`: `decode` (base64 + parsing), `preprocess`, `device` (staging + H2D + trace + D2H + un-permute),
`postprocess`, `model_call`, `total` (server side, after the body arrived).

### 3.3 Errors

**400** undecodable / malformed input (bad base64, wrong row size, no calibration while the ego crop box is on, unknown
or out-of-range `params` such as an unknown filter class, `sweeps` / `images` / `stream` / `inputs`, no `points`), **422** schema violation (unknown field, wrong type), **503** while starting
(or when the boot failed), **500** `inference failed: <ExceptionType>: <message>` if the device call raises.

### 3.4 Environment the app reads (lifespan only, never at import)

| var | set by | meaning / default |
|---|---|---|
| `HF_MODEL` | launcher (`weights.repo`) | weights repo id; default `AutowareFoundation/lidar_frnet` |
| `TT_MODEL_WEIGHTS_REVISION` | launcher (`weights.revision`) | pinned commit; `TT_WEIGHTS_REVISION` (`serve.env`) is the same for older tt-model |
| `FRNET_WEIGHTS_DIR` | you (offline) | local weights directory; overrides the Hub |
| `TT_MESH_SHAPE` | launcher (`runtime.mesh_shape_env`) | `1x1`; anything else -> RuntimeError at startup |
| `TT_DEVICE_ID` | you | chip to open, default 0 |
| `FRNET_DISPATCH` | `serve.env` | `eth` (default) \| `worker` (A/B only) \| `auto` (ETH if the patch is present) |
| `FRNET_NUM_CQS` | `serve.env` | `1` |
| `FRNET_VARIANT` | `serve.env` / profile | `ot128` (profile `ot128`) or `qt128` (profile `qt128`) |
| `FRNET_POINT_TERMS`, `FRNET_MAP_DTYPE`, `FRNET_CONV_TERMS` | `serve.env` | the precision knobs: `2`, `float32`, `1` (the validated configuration; other values are optimization-phase A/B switches that fail the frozen accuracy gates) |
| `FRNET_WARMUP` | you | JSON list of warm-up variants, `default` or `none` |
| `FRNET_TRACE_REGION`, `FRNET_L1_SMALL`, `FRNET_WORKER_L1_SIZE` | you | device-open overrides (validated values: `DEVICE_DEFAULTS` in `device.py`) |
| `FRNET_MAX_BODY_MB` | you | request size guard, default 256 |
| `TT_METAL_VISIBLE_DEVICES`, `MESH_DEVICE` | `serve.env` / launcher | `0`, `P150` (informational) |

Server-side pipeline: JSON -> `tt_frnet.io` decoders -> `FRNet.__call__` under one lock: host pre-processing ported
from `perception/autoware_lidar_frnet` (ego crop, float32 projection, fp16 interpolation grid, unique cells, profile
checks, the device plan: sorted points, CSR offsets, gather tables, 8-d features) -> 9 H2D copies into persistent
device inputs -> `execute_trace` (point MLP, K1 segment max, frustum CNN, point head, softmax) -> one D2H of the fp32
probabilities -> un-permute -> host post-processing (first-max argmax, probability, class filter) ->
`Output.to_dict()`.

## 4. Caveats

- Batch 1, one sweep per request; concurrent clients queue on the lock.
- Fixed device shapes per profile: `N_cap` 160,000 (ot128) / 120,000 (qt128) points and `M_cap` 60,000 cells; every
  point-wise op runs on all `N_cap` rows, so the device time hardly depends on the frame. Frames outside Autoware's
  profile ranges are skipped as the node skips them (200, empty output, `meta.skipped`).
- One variant per process: serve `--profile qt128` for the Hesai QT128 network. The profiles share the image.
- Warm-up captures every trace variant inside the lifespan, so READY means warm; the first cold boot pays the ttnn JIT.
- Weights are pinned by sha in `weights.revision`, exported by the launcher and repeated in `serve.env`;
  `snapshot_download(..., revision=<sha>)` is a cache hit after `serve`'s pre-download and falls back to
  `local_files_only=True` if the Hub is unreachable.
- The runtime image has no host C/C++ compiler: device kernels JIT-compile (sfpi ships in the image), host-side C
  helpers must have a numpy fallback.
- `tt-model curl` and the ready card's `/v1/models` hint are OpenAI-shaped and are not this API; use the routes above.