# Serving FRNet (Autoware lidar_frnet) on Blackhole with tt-model-manager This repo is the authoring source of the **tt-model container package** `changh95/frnet-p150` (`kind: tt-dit-server`, schema 5.1): `tt-model.yaml` is the manifest, `code/` the port, and `code/tt_frnet/server/app.py` the ASGI app uvicorn runs. Weights are a pinned pointer, never baked into the image. The device open, the decoders, the Python-API contract and the HTTP contract come from `code/tt_frnet/ttaw/`, the shared package of the Autoware ports vendored into this repo (`ttaw/VENDORED.json` records its version and file hashes; it is not edited here). | item | value | |---|---| | tt-metal tree | `/home/ubuntu/experiments/tt-models/tt-metal` (main `44d66500520`, `v0.80.0-dev20261006-78-g44d66500520`, + `patches/tt-metal-eth-dispatch.patch`; torch 2.11.0+cpu) | | weights | `AutowareFoundation/lidar_frnet` @ `a53a1c11b0b28d31fd50a03abef5db0e830a5b3c` (tag `v2.0`; files `frnet_*.onnx, ml_package_frnet_*.param.yaml, deploy_metadata.yaml`; 85 MB (frnet_ot128.onnx + frnet_qt128.onnx, 42.4 MB each); Apache-2.0; public, ungated) | | Autoware reference | `perception/autoware_lidar_frnet` @ autoware_universe `9ceaccf026c31ffc5319bc9eeb4bd7bede0af3fd` | | app | `tt_frnet.server.app:app` (uvicorn; the ASGI lifespan does weights -> device -> graph -> trace capture) | | device recipe | `ttnn.open_device(device_id, dispatch_core_config=DispatchCoreConfig(ETH), l1_small_size=65536, trace_region_size=256 MiB, num_command_queues=1)` (`DEVICE_DEFAULTS` in `code/tt_frnet/device.py`; the trace uses 8.7 MiB (ot128) / 5.6 MiB (qt128)) | | hardware | one Blackhole p150 (`hardware: p150`, `mesh_device: P150`, `TT_MESH_SHAPE=1x1`), 12×10 compute grid | | serve profiles | `ot128` (default; Hesai OT128, `FRNET_VARIANT=ot128`) and `qt128` (Hesai QT128); one variant per process. Both pin `FRNET_DISPATCH=eth`, `FRNET_NUM_CQS=1` and the precision knobs `FRNET_POINT_TERMS=2`, `FRNET_MAP_DTYPE=float32`, `FRNET_CONV_TERMS=1` | ## 1. Run on the HOST (hardware validation, no Docker) The tt-metal `python_env` has ttnn and torch; put the HTTP stack in a side directory instead of installing into it. On the shared workspace box every command that opens the chip goes through the device lock (`bin/devrun`). ```bash ROOT=/home/ubuntu/experiments/tt-models source $ROOT/bin/tt-env.sh # TT_METAL_HOME, PYTHONPATH, tt-metal python_env mkdir -p /tmp/frnet-http && uv pip install --python $TT_METAL_HOME/python_env/bin/python \ --target /tmp/frnet-http fastapi uvicorn onnx cd $ROOT/bundles/frnet-p150 export PYTHONPATH=$PWD/code:$TT_METAL_HOME:$TT_METAL_HOME/ttnn:/tmp/frnet-http export HF_MODEL=AutowareFoundation/lidar_frnet TT_MODEL_WEIGHTS_REVISION=a53a1c11b0b28d31fd50a03abef5db0e830a5b3c TT_MESH_SHAPE=1x1 # host tests (no device; the device tests are skipped; ttnn is the fake of common/tests/host: a real ttnn host # tensor opens the chip, so only devrun jobs may build one) PYTHONPATH=$ROOT/research/packaging/scripts:$PYTHONPATH TT_VISIBLE_DEVICES=none \ python -m pytest -q -p no:cacheprovider -p fake_ttnn_plugin code/tt_frnet/tests # device tests and the server, under the lock $ROOT/bin/devrun -t 1800 -- python -m pytest -q -s code/tt_frnet/tests/test_pcc_device.py code/tt_frnet/tests/test_e2e_device.py # (FRNET_VARIANT=qt128 for the second profile; the smoke test posts the shipped sample of the served variant) $ROOT/bin/devrun -t 1800 -- bash -c 'python -m uvicorn --host 127.0.0.1 --port 20000 --lifespan on tt_frnet.server.app:app & \ S=$!; python code/tt_frnet/server/smoke_test.py --url http://127.0.0.1:20000 --wait 1200; R=$?; kill -TERM $S; wait $S; exit $R' ``` Boot log landmarks (they drive `tt-model serve`'s progress view, `boot_progress.py` TT_DIT_PHASES): `Loading weights` -> `Opening device` -> `Warming up: capturing trace ...` -> `Warmup complete` -> uvicorn `Application startup complete`. A cold JIT cache compiles kernels for minutes; later boots take seconds. Startup failures raise and uvicorn exits non-zero (no CPU fallback). SIGTERM: the lifespan releases the traces and closes the chip (within `tt-model stop`'s 120 s budget). Offline override (no Hub): `FRNET_WEIGHTS_DIR=`. ## 2. Package, serve, push (Docker) Docker 29 on this box is **rootful** (`/var/lib/docker`), and the user's session may not carry the `docker` group: wrap every docker-using command in `sg docker -c "..."` (no `docker-env.sh` is needed here; the reference bundles' `docker-env.sh` was the authors' rootless-Docker box). Run every `tt-model` command **from this directory** (`source.tt_metal` and `extra_code[].root: code` are CWD-relative) and point `--out` outside it. ```bash ROOT=/home/ubuntu/experiments/tt-models; cd $ROOT/bundles/frnet-p150 TTM=$HOME/.local/share/uv/tools/tt-model/bin/python # offline validation (seconds; no docker, no device) -- must print VALID $TTM -c "from tt_kernel.container_manifest import load_container_manifest as L; m=L('tt-model.yaml', check_sources=True); p=m.resolve_profile(); print('VALID', m.name, m.kind, p.hardware, p.mesh_device, m.weights_ref)" # build (one at a time on the box: the absolute lock file): ~35 min cold, ~12 min after a code/ change (the C++ # build re-runs, ccache-warm, whenever code/ changes), ~1 min for a manifest- or lock-only change flock $ROOT/.package.lock sg docker -c "tt-model package --container tt-model.yaml --out $ROOT/build" # log: ~/.cache/tt-model/build/frnet-p150.log # provenance: the base-image digests this build resolved (tt-model's FROM tags float) -> build_info.json (published) sg docker -c "python3 $ROOT/research/packaging/scripts/record_build_info.py --staged $ROOT/build/frnet-p150 --bundle ." # serve + smoke + stop, all inside ONE device-lock window, once per serve profile (`tt-model profiles # $ROOT/build/frnet-p150/tt_kernel_manifest.json` lists them; serve returns once READY and leaves the container # running; the script's trap saves the container log and always runs `tt-model stop`, also when devrun's timeout # fires: -k 150 > the 120 s grace). No token reaches the public weights download. Evidence: logs/smoke/ (--log-dir). for prof in ot128 qt128; do $ROOT/bin/devrun -t 3600 -k 150 -- env -u HF_TOKEN -u HUGGING_FACE_HUB_TOKEN \ sg docker -c "bash code/scripts/container_smoke.sh $ROOT/build/frnet-p150 20000 --profile $prof" done # publish: overlay the hand-written docs, the pip project and the build record onto the staged dir, then push # (HF_TOKEN only from the environment) rsync -a README.md SERVING.md OPT_BASELINE.md OPT_REPORT.md VERIFICATION_*.md tt-model.yaml pyproject.toml build_info.json \ examples media patches $ROOT/build/frnet-p150/ cp code/PYTHON.md $ROOT/build/frnet-p150/PYTHON.md 2>/dev/null || true tt-model push $ROOT/build/frnet-p150 --publish ``` The smoke test fails unless `/info` reports ETH dispatch, the 12x10 grid and what the staged package pins for the profile (`FRNET_DISPATCH`, `FRNET_NUM_CQS`, `FRNET_VARIANT`, the weights revision), and it compares the served output of the shipped sample of the profile (`samples/synthetic_.npz`) with its stored CPU-reference output (`samples/synthetic_..reference.json`; >= 98.5 % label and filter agreement). `serve` publishes the first free port from 20000 and exports into the container: `HF_MODEL=AutowareFoundation/lidar_frnet`, `MESH_DEVICE=P150`, `TT_MESH_SHAPE=1x1`, `TT_MODEL_WEIGHTS_REVISION=a53a1c11b0b28d31fd50a03abef5db0e830a5b3c`, then `serve.env`. The HF cache is mounted at `/hf` (rw), the JIT cache at `/cache` (host `~/.cache/tt-model/frnet-p150/cache`), and the container sees only its own chip (`--device /dev/tenstorrent/`). tt-cli users: `tt serve changh95/frnet-p150` / `tt model stop changh95/frnet-p150` (point tt at this tt-model with `tt config set tools.override.tt-model ~/.local/bin/tt-model` or `TT_TOOL_BIN_TT_MODEL`). What `push` does to the repo: `code/` and `image/` on the Hub become exactly the staged trees (`extra_code.paths` + `models/common/lightweightmodule.py`); every top-level file of the staged dir is uploaded (the overlaid README replaces the generated card; `pyproject.toml` and `build_info.json` arrive the same way, never through `code/`); top-level files already on the Hub and not in the staged dir are kept. ## 3. The request / response contract | route | purpose | |---|---| | `GET /health` | `{"status": "ok" \| "starting" \| "error", "model", "device": {"dispatch", "grid", "cores", "device_id"}, "error"}`, always 200; `ok` only after warm-up | | `GET /v1/health` | same as `/health` (tt-model's hint for non-chat packages points here) | | `GET /info` | model, task, io, Autoware package + commit, weights {repo, tag, revision, path, license}, device (dispatch, grid), input limits (point formats, fields, camera order, calibration presets), labels, variant, warm variants, runtime params + defaults, compile params, warm-up and boot times | | `GET /v1/models` | `{"object": "list", "data": [{"id": "AutowareFoundation/lidar_frnet", "object": "model", "owned_by": "changh95"}]}` -- a stub so OpenAI-shaped probes do not 404; NOT a chat API | | `POST /predict` | one frame -> the model output (below) | ### 3.1 Request (`application/json`; unknown fields are a 422) | field | type | meaning | |---|---|---| | `points` | object | `{"format": "bin" \| "npy" \| "npz" \| "pcd" \| "list", "data": , "fields": [...], "dtype", "key", "frame_id"}`. `bin` = raw little-endian float32 rows, N × len(`fields`) (default fields `x, y, z, intensity`); `npy` / `npz` = (N, C) float or structured arrays; `pcd` = PCD v0.7 ascii / binary / binary_compressed; `list` = `"values": [[x, y, z, i], ...]` (small clouds). ONE sweep in the **sensor frame** (x forward, y left, z up, metres), raw intensity (0-255 for uint8 drivers), not motion-compensated: Autoware's `~/input/pointcloud`. Rows with a non-finite x / y / z are dropped | | `calibration` | object | `{"T_base_link_from_sensor": }` (the static transform the node looks up once for its ego crop box) or `{"preset": ""}` (presets in `/info`: `synthetic_ot128`, `synthetic_qt128`, the shipped samples' transforms); optional `"ego_crop_box"`: `[min_x, min_y, min_z, max_x, max_y, max_z]` in `base_link` (default Autoware's `(-1.53, -1.15, -0.1)` .. `(6.1, 1.15, 3.1)`) or `null` to disable the box (then no transform is needed). Required while the box is on: without it the node would skip every frame, here it is a 400 | | `params` | object | per-request knobs (host post-processing only; they change `filter_keep`, never the classes): `class_probability_threshold` (float in [0, 1], default 0.05), `filter_classes` (comma-separated class names, `""` = none, default `"drivable_surface"`, at most 16) | | `output_format` | str | `json` (default) or `npz` (adds lossless base64 NPZ arrays) | | `sweeps`, `images`, `stream`, `inputs` | – | fields of the collection's common envelope that FRNet does not take: a request with any of them is a 400 | Transforms (`T__from_`): a 4×4 (or 3×4) row-major matrix, `{"translation": [x, y, z], "rotation_wxyz": [w, x, y, z]}` (or `rotation_xyzw`), or `{"x", "y", "z", "roll", "pitch", "yaw"}` (Autoware `sensor_kit_calibration.yaml`, tf2 RPY). Intrinsics: 3×3 K, 3×4 P, or `{"fx", "fy", "cx", "cy"}`. Base64 may be standard or URL-safe, wrapped, with or without a `data:` prefix. ### 3.2 Response (200) The shipped OT128 sample (`synthetic_ot128.npz` with the preset `synthetic_ot128`), abridged (`data` fields cut): ```json {"model": "frnet-p150", "frame_id": "sensor", "num_points": 52613, "num_points_raw": 52613, "labels": {"format": "npz", "key": "labels", "dtype": "uint8", "shape": [52613], "data": "..."}, "scores": {"format": "npz", "key": "scores", "dtype": "float32", "shape": [52613], "data": "..."}, "points": {"format": "npz", "key": "points", "dtype": "float32", "shape": [52613, 4], "data": "..."}, "filter_keep": {"format": "npz", "key": "filter_keep", "dtype": "bool", "shape": [52613], "data": "..."}, "input_index": {"format": "npz", "key": "input_index", "dtype": "int32", "shape": [52613], "data": "..."}, "class_names": ["drivable_surface", "other_flat_surface", "sidewalk", "manmade", "vegetation", "car", "..."], "class_counts": {"drivable_surface": 2913, "other_flat_surface": 20118, "sidewalk": 5, "manmade": 22898, "vegetation": 6526, "car": 13, "semi_trailer": 140, "...": 0}, "meta": {"variant": "ot128", "num_points_input": 53411, "cropped_by_ego_box": 798, "num_points_interpolated": 0, "num_unique_coors": 52613, "ego_crop": {"enabled": true, "bounds": [-1.53, -1.15, -0.1, 6.1, 1.15, 3.1], "reference_frame": "base_link", "...": "..."}, "plan": {"n": 52613, "m": 52613, "n_cap": 160000, "m_cap": 60000, "...": "..."}, "filter": {"classes": ["drivable_surface"], "class_probability_threshold": 0.05, "kept": 44747}, "...": "..."}, "timing_ms": {"decode": 24.711, "preprocess": 47.664, "device": 229.164, "postprocess": 5.018, "model_call": 281.879, "total": 310.689}} ``` - Output point order (the node's): the input points that survive the ego crop box, in input order (after non-finite rows are dropped), then the interpolated points Autoware appends; the first `num_points_raw` rows are raw points and `input_index` maps them back to the decoded input rows. The interpolated points are published, as Autoware does. - `labels` is Autoware's `class_id`, `scores` its `probability` (the softmax probability of that class), `points` the x, y, z, intensity of every output point in the sensor frame, `filter_keep` the membership of Autoware's `~/output/pointcloud/filtered` cloud. - An out-of-profile frame (input points, points after interpolation or unique cells outside the variant's ranges: ot128 5,000-160,000 points, qt128 5,000-120,000, both 3,000-60,000 cells) answers 200 with `num_points: 0` and the node's message in `meta.skipped`: the node publishes nothing for such a frame. Nothing runs on the device. - `meta` also has the crop and network-input counts (`num_points_input`, `cropped_by_ego_box`, `num_points_interpolated`, `num_unique_coors`, ...), the device plan sizes and the applied filter. `timing_ms`: `decode` (base64 + parsing), `preprocess`, `device` (staging + H2D + trace + D2H + un-permute), `postprocess`, `model_call`, `total` (server side, after the body arrived). ### 3.3 Errors **400** undecodable / malformed input (bad base64, wrong row size, no calibration while the ego crop box is on, unknown or out-of-range `params` such as an unknown filter class, `sweeps` / `images` / `stream` / `inputs`, no `points`), **422** schema violation (unknown field, wrong type), **503** while starting (or when the boot failed), **500** `inference failed: : ` if the device call raises. ### 3.4 Environment the app reads (lifespan only, never at import) | var | set by | meaning / default | |---|---|---| | `HF_MODEL` | launcher (`weights.repo`) | weights repo id; default `AutowareFoundation/lidar_frnet` | | `TT_MODEL_WEIGHTS_REVISION` | launcher (`weights.revision`) | pinned commit; `TT_WEIGHTS_REVISION` (`serve.env`) is the same for older tt-model | | `FRNET_WEIGHTS_DIR` | you (offline) | local weights directory; overrides the Hub | | `TT_MESH_SHAPE` | launcher (`runtime.mesh_shape_env`) | `1x1`; anything else -> RuntimeError at startup | | `TT_DEVICE_ID` | you | chip to open, default 0 | | `FRNET_DISPATCH` | `serve.env` | `eth` (default) \| `worker` (A/B only) \| `auto` (ETH if the patch is present) | | `FRNET_NUM_CQS` | `serve.env` | `1` | | `FRNET_VARIANT` | `serve.env` / profile | `ot128` (profile `ot128`) or `qt128` (profile `qt128`) | | `FRNET_POINT_TERMS`, `FRNET_MAP_DTYPE`, `FRNET_CONV_TERMS` | `serve.env` | the precision knobs: `2`, `float32`, `1` (the validated configuration; other values are optimization-phase A/B switches that fail the frozen accuracy gates) | | `FRNET_WARMUP` | you | JSON list of warm-up variants, `default` or `none` | | `FRNET_TRACE_REGION`, `FRNET_L1_SMALL`, `FRNET_WORKER_L1_SIZE` | you | device-open overrides (validated values: `DEVICE_DEFAULTS` in `device.py`) | | `FRNET_MAX_BODY_MB` | you | request size guard, default 256 | | `TT_METAL_VISIBLE_DEVICES`, `MESH_DEVICE` | `serve.env` / launcher | `0`, `P150` (informational) | Server-side pipeline: JSON -> `tt_frnet.io` decoders -> `FRNet.__call__` under one lock: host pre-processing ported from `perception/autoware_lidar_frnet` (ego crop, float32 projection, fp16 interpolation grid, unique cells, profile checks, the device plan: sorted points, CSR offsets, gather tables, 8-d features) -> 9 H2D copies into persistent device inputs -> `execute_trace` (point MLP, K1 segment max, frustum CNN, point head, softmax) -> one D2H of the fp32 probabilities -> un-permute -> host post-processing (first-max argmax, probability, class filter) -> `Output.to_dict()`. ## 4. Caveats - Batch 1, one sweep per request; concurrent clients queue on the lock. - Fixed device shapes per profile: `N_cap` 160,000 (ot128) / 120,000 (qt128) points and `M_cap` 60,000 cells; every point-wise op runs on all `N_cap` rows, so the device time hardly depends on the frame. Frames outside Autoware's profile ranges are skipped as the node skips them (200, empty output, `meta.skipped`). - One variant per process: serve `--profile qt128` for the Hesai QT128 network. The profiles share the image. - Warm-up captures every trace variant inside the lifespan, so READY means warm; the first cold boot pays the ttnn JIT. - Weights are pinned by sha in `weights.revision`, exported by the launcher and repeated in `serve.env`; `snapshot_download(..., revision=)` is a cache hit after `serve`'s pre-download and falls back to `local_files_only=True` if the Hub is unreachable. - The runtime image has no host C/C++ compiler: device kernels JIT-compile (sfpi ships in the image), host-side C helpers must have a numpy fallback. - `tt-model curl` and the ready card's `/v1/models` hint are OpenAI-shaped and are not this API; use the routes above.