# Serving METEOR (TIER IV, AutowareFoundation/meteor) on Blackhole with tt-model-manager
This repo is the authoring source of the **tt-model container package** `changh95/meteor-p150`
(`kind: tt-dit-server`, schema 5.1): `tt-model.yaml` is the manifest, `code/` the port, and
`code/tt_meteor/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_meteor/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` + `patches/tt-metal-reshape-rm-sys1419.patch`; torch 2.11.0+cpu) |
| weights | `AutowareFoundation/meteor` @ `01a5f6d71df5ecbbb5853ec600825481d57b9c6b` (tag `v1.0`; files `meteor_v157c3Z.onnx, meteor_v157.param.yaml, LICENSE, SHA256SUMS`; Apache-2.0; public, ungated) |
| pre- / post-processing reference | **no Autoware package exists for METEOR**: METEOR's own runtimes define them, [tier4/METEOR](https://github.com/tier4/METEOR) @ `dc193a81e9959de57b751e269127ae9b67e3f300` (`deploy/cpp`: `realtime_main.cpp`, `decode.cpp`, `render.cpp`; `hf/onnx_smoke_test.py`) |
| app | `tt_meteor.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=32768, trace_region_size=256 MiB, num_command_queues=1)` (`DEVICE_DEFAULTS` in `code/tt_meteor/device.py`) |
| hardware | one Blackhole p150 (`hardware: p150`, `mesh_device: P150`, `TT_MESH_SHAPE=1x1`), 12×10 compute grid |
## 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/meteor-http && uv pip install --python $TT_METAL_HOME/python_env/bin/python \
--target /tmp/meteor-http fastapi uvicorn onnx
cd $ROOT/bundles/meteor-p150
export PYTHONPATH=$PWD/code:$TT_METAL_HOME:$TT_METAL_HOME/ttnn:/tmp/meteor-http
export HF_MODEL=AutowareFoundation/meteor TT_MODEL_WEIGHTS_REVISION=01a5f6d71df5ecbbb5853ec600825481d57b9c6b TT_MESH_SHAPE=1x1
# (on the shared workspace box every METEOR device job also needs METEOR_DEVICE_OK=1 from the orchestrator)
# 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_meteor/tests
# device tests and the server, under the lock
$ROOT/bin/devrun -t 2400 -- env TTAW_GATES_READONLY=1 python -m pytest -q -s code/tt_meteor/tests/test_pcc_device.py \
code/tt_meteor/tests/test_e2e_device.py code/tt_meteor/tests/test_variants_device.py
$ROOT/bin/devrun -t 1800 -- bash -c 'python -m uvicorn --host 127.0.0.1 --port 20000 --lifespan on tt_meteor.server.app:app & \
S=$!; python code/tt_meteor/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`. The warm-up runs one eager frame of the whole graph and captures it as one trace:
about 45-50 s with a warm JIT cache (most of it the eager frame of 4,678 programs); a cold JIT cache compiles kernels
for minutes (the first container boot on an empty JIT cache was ready after 799 s, 2026-10-10). 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): `METEOR_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/meteor-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/meteor-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/meteor-p150 --bundle ."
# serve + smoke + stop, all inside ONE device-lock window, once per serve profile (`tt-model profiles
# $ROOT/build/meteor-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).
$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/meteor-p150 20000 [--profile NAME]"
# 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/meteor-p150/
cp PYTHON.md $ROOT/build/meteor-p150/PYTHON.md # the top-level copy (its links are relative to the repo root)
tt-model push $ROOT/build/meteor-p150 --publish
```
The smoke test fails unless `/info` reports ETH dispatch, the 12x10 grid and what the staged package pins for the
profile (`METEOR_DISPATCH`, `METEOR_NUM_CQS`, `METEOR_VARIANT`, the weights revision), and it compares the
served output of the shipped sample (`code/tt_meteor/samples/synthetic_8cam.json`) with its stored CPU-reference
output (`synthetic_8cam.reference.json`: 3D boxes within 0.5 m and 0.05 of score, recall and precision >= 0.95; lane
map agreement >= 0.99; selected ego path ADE <= 0.3 m, FDE <= 0.6 m) and checks the expected boxes per label.
For the first release of this bundle the container check also runs the rig-switch stress (the ETH-dispatch hang
history of PORT_LOG.md section 12): `code/scripts/stress_frames.py --mode rigs --rigs shipped` for >= 200 frames
inside the image, with `tt-model serve --print`'s device, cache and env arguments (no port) plus
`METEOR_WEIGHTS_DIR=/hf/hub/models--AutowareFoundation--meteor/snapshots/` (the script reads the ONNX
directly, without the server's HF resolve; `serve` has pre-fetched the snapshot):
`python -u /opt/tt-metal/scripts/stress_frames.py --mode rigs --rigs shipped --frames 200 --jsonl /stress.jsonl`.
`serve` publishes the first free port from 20000 and exports into the container: `HF_MODEL=AutowareFoundation/meteor`,
`MESH_DEVICE=P150`, `TT_MESH_SHAPE=1x1`, `TT_MODEL_WEIGHTS_REVISION=01a5f6d71df5ecbbb5853ec600825481d57b9c6b`, then `serve.env`. The HF cache is
mounted at `/hf` (rw), the JIT cache at `/cache` (host `~/.cache/tt-model/meteor-p150/cache`), and the container
sees only its own chip (`--device /dev/tenstorrent/`). tt-cli users: `tt serve changh95/meteor-p150` /
`tt model stop changh95/meteor-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/meteor", "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 |
|---|---|---|
| `images` | list | **all eight cameras** `[{"camera": "CAM_FRONT_WIDE", "data": , "intrinsics", "T_ref_from_camera"}, ...]`, any order (reordered to `CAM_FRONT_WIDE, CAM_FRONT_LEFT, CAM_FRONT_RIGHT, CAM_BACK_WIDE, CAM_BACK_LEFT, CAM_BACK_RIGHT, CAM_FRONT_NARROW, CAM_BACK_NARROW`); RGB, raw and unrectified, at least 768×432 (larger frames are resized with OpenCV INTER_AREA semantics and K is scaled per axis). An absent narrow camera is sent as an **all-zero image**: the host gives it a zero input and its donor's K and pose (`CAM_FRONT_WIDE` / `CAM_BACK_WIDE`), the trained 7-camera configuration |
| `calibration` | object | `{"frame_id", "cameras": {name: {"intrinsics", "T_ref_from_camera", "image_size"}}}` (K of the image as sent; `T_ref_from_camera` = camera optical frame -> base_link: x forward, y left, z up, origin on the road) or `{"preset": ""}` (presets in `/info`: `synthetic_8cam`, the rig of the shipped sample). Inline image calibration wins |
| `ego_speed` | number | **required**: the ego speed in m/s (the graph's `v0`) |
| `stream` | object | optional: `{"id": "default", "reset": false, "timestamp_s", "T_world_from_ego"}`: METEOR's host temporal post-processing per stream id (the BEV seg fusion needs `T_world_from_ego`; yaw smoothing; plan-mode hysteresis); a new id or `reset` starts fresh. Without `stream`, every request uses the stream `default` |
| `params` | object | per-request knobs (host post-processing only; METEOR's C++ renderer defaults): 3D `det3d_threshold` (0.15), `det3d_topk` (64), `vehicle_threshold` (0.35), `vru_threshold` (0.15), `bev_nms_iou` (0.3), `bev_nms_containment` (0.6), `stationary_logit_threshold` (0.0); 2D `det2d_threshold` (0.30), `det2d_topk` (48), `det2d_hide` ("7": road paint); unknown obstacles `unk2d` (true), `unk2d_threshold` (= det2d), `ground_z` (0.0); plan `mode_hysteresis` (0.35), `straight_margin` (1.0); `seg_fuse` (true), `thin_road_edge` (true), `yaw_smoothing` (true); `heads` (false: with `output_format: "npz"`, adds the dense heads) |
| `output_format` | str | `json` (default) or `npz` (adds lossless base64 NPZ arrays) |
`points`, `sweeps` and `inputs` of the common envelope are not used by this model (a request with `points` is a 400).
`server/client.py` builds a request: `--sample ` (the shipped sample: images, preset, ego speed, stream),
or `--image NAME=PATH` (×8) + `--calib ` / `--calib-preset ` + `--ego-speed ` [+ `--stream-id`,
`--param k=v`].
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)
```json
{"model": "meteor-p150", "frame_id": "base_link", "num_detections": 8,
"detections": [
{"label": "VEHICLE", "label_id": 0, "score": 0.9932, "center": [-12.86, 0.095], "size": [4.399, 1.742], "yaw": -0.0687, "stationary": false, "future_mode": 0, "future": [[-11.92, -0.09], [-10.74, -0.15], ...]},
{"label": "VEHICLE", "label_id": 0, "score": 0.9872, "center": [-7.176, -3.215], "size": [3.965, 1.731], "yaw": 0.0183, "stationary": false, ...},
{"label": "VEHICLE", "label_id": 0, "score": 0.9624, "center": [10.84, 0.019], "size": [4.334, 1.761], "yaw": -0.0189, "stationary": false, ...},
...],
"trajectory": [[0.74, 0.91], [0.85, 1.54], [0.41, 2.34], [0.31, 3.08], [0.62, 3.82], [0.3, 4.45]], "columns": ["x", "y"],
"plan": {"mode": 0, "mode_probs": [0.233, 0.296, 0.471], "mode_logits": [...], "paths": [...], "steer": 0.0026, "accel": 0.021, "brake_prob": 0.131, "dt": 0.5},
"detections_2d": {"CAM_FRONT_WIDE": [{"label": "car", "label_id": 1, "score": 0.5239, "box_xyxy": [412.76, 211.02, 438.5, 230.29]}, ...], ...},
"unknown_obstacles": [],
"traffic_light": {"state": "red", "state_id": 3, "probs": [0.363, 0.140, 0.031, 0.465]},
"lane": {"format": "png", "key": "lane", "dtype": "uint8", "shape": [800, 500], "data": "iVBORw0KGgo..."},
"lane_classes": ["bg", "road", "sidewalk", "crosswalk", "laneline", "stopline", "road_edge", "marking", "parking"],
"stationary_head_healthy": true,
"meta": {"present": [true, true, true, true, true, true, true, true], "v0": 8.0, "stream_id": "synthetic_8cam"},
"timing_ms": {"decode": 83.95, "preprocess": 10.69, "device": 722.42, "postprocess": 86.08, "model_call": 820.65, "total": 908.67}}
```
(The shipped synthetic sample, a request after the warm-up, host server, 2026-10-10; trimmed.)
| key | content |
|---|---|
| `detections` | 3D BEV boxes sorted by score: `label` (VEHICLE / VRU), `label_id`, `score`, `center` [x, y] and `size` [length, width] in metres (base_link: x forward, y left; METEOR predicts no z, height or velocity), `yaw` (rad, CCW from +x), `stationary`, `future` (6 × [x, y] at 0.5-3 s, the agent's best mode; METEOR's renderer draws vehicle futures only), `future_mode` |
| `trajectory`, `columns` | the selected ego path, 6 × [x, y] at 0.5 s (straight preference and per-stream mode hysteresis, METEOR's `render.cpp`) |
| `plan` | `mode`, `mode_probs` (softmax of the hysteresis-adjusted logits), `mode_logits`, `paths` (3 × 6 × [x, y]), `steer` (rad), `accel` (m/s²), `brake_prob`, `dt` |
| `detections_2d` | per camera: `label` (10 classes; road paint hidden by default), `label_id`, `score`, `box_xyxy` (pixels at 768×432) |
| `unknown_obstacles` | 2D "obstacle" boxes placed on the ground plane (`unk2d`) |
| `traffic_light` | the ego-relevant light: `state` (none / green / yellow / red), `state_id`, `probs` |
| `lane`, `lane_classes` | the BEV lane map as PNG (uint8 [800, 500], 0.2 m cells, row 0 = 80 m ahead, column 0 = 50 m to the left), after the seg fusion (with a stream pose) and the road-edge thinning |
| `stationary_head_healthy`, `meta` | METEOR's stationary-head health check; the present cameras, `v0`, the stream id |
| `heads` | only with `output_format: "npz"` and `params.heads: true`: seg2d, depth, depth_mean, occupancy, risk, stationary |
The synthetic sample is a test pattern (a red light and a car 11 m ahead): its plan barely moves forward (under 1 m in 3 s,
drifting 4.5 m to the left), not meaningful driving; the stored CPU reference has the same path.
`timing_ms`: `decode` (base64 + parsing), `preprocess`, `device` (H2D + trace + D2H), `postprocess`, `model_call`,
`total` (server side, after the body arrived).
### 3.3 Errors
**400** undecodable / malformed input (bad base64, wrong row size, missing calibration, unknown or out-of-range
`params`, a missing required input), **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/meteor` |
| `TT_MODEL_WEIGHTS_REVISION` | launcher (`weights.revision`) | pinned commit; `TT_WEIGHTS_REVISION` (`serve.env`) is the same for older tt-model |
| `METEOR_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 |
| `METEOR_DISPATCH` | `serve.env` | `eth` (default) \| `worker` (A/B only) \| `auto` (ETH if the patch is present) |
| `METEOR_NUM_CQS` | `serve.env` | `1` |
| `METEOR_VARIANT` | `serve.env` / profile | `default` |
| `METEOR_WARMUP` | you | JSON list of warm-up variants, `default` or `none` |
| `METEOR_TRACE_REGION`, `METEOR_L1_SMALL`, `METEOR_WORKER_L1_SIZE` | you | device-open overrides (validated values: `DEVICE_DEFAULTS` in `device.py`) |
| `METEOR_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_meteor.io` decoders -> `METEOR.__call__` (host pre-processing of METEOR's runtime:
INTER_AREA resize, K scaling, extrinsics inversion, the absent-camera donor rule; the lift tables of a new
calibration, cached per rig -> H2D of the eight cameras and `v0` into persistent device inputs -> `execute_trace` of
the `frame` trace -> segmented D2H -> host post-processing ported from `deploy/cpp`: 3D peaks + rotated BEV NMS,
stationary flags, agent futures, 2D boxes, unknown obstacles, the plan with hysteresis, seg fusion) under one lock
-> `Output.to_dict()`.
## 4. Caveats
- Batch 1, one frame per request; concurrent clients queue on the lock.
- Fixed shapes of the released graph: eight 768×432 camera slots in METEOR's order, a 400×250 lift grid at 0.4 m,
an 800×500 BEV at 0.2 m; at most 3 cameras per lift cell on the rigs validated. A new calibration costs about
0.2 s once (the lift tables), then it is cached per rig.
- The request bodies are large (eight images as base64): about 1-4 MB for PNG / JPEG at 768×432.
- 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.