File size: 13,450 Bytes
4d9b003 be62f78 4d9b003 be62f78 4d9b003 be62f78 4d9b003 be62f78 4d9b003 be62f78 4d9b003 be62f78 4d9b003 be62f78 4d9b003 be62f78 4d9b003 | 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 | # Python API: Diffusion Planner v5.0 (Autoware diffusion_planner) on Blackhole
Use this API from Python code (a pipeline, a notebook, a ROS 2 node wrapper). You do not need the HTTP server: the
API and the server share the decoders, the device trace and the post-processing, so the outputs and the speed are
the same.
## Install
Install the package on top of an environment that already has `ttnn` (a tt-metal `python_env` at `44d66500520`
with `patches/tt-metal-eth-dispatch.patch`, or the tt-model container). From the root of the model repository (the
directory that holds `pyproject.toml`, `README.md` and `code/`):
```bash
pip install -e . # the Python API (numpy<2, pillow, pyyaml, onnx, huggingface_hub)
pip install -e ".[server,test]" # + the HTTP server and the tests
```
The pip project is the repository's top-level `pyproject.toml`; it installs the package from
`code/tt_diffusion_planner` (there is no `pyproject.toml` inside `code/`, because the container build copies `code/`
over the tt-metal tree). ttnn and torch come from tt-metal and are not declared.
The package carries `tt_diffusion_planner.ttaw`, the shared code of the Autoware ports to Blackhole (device open, trace
runner, decoders, model base class, HTTP app), vendored at the version recorded in
`code/tt_diffusion_planner/ttaw/VENDORED.json`.
| You want to run | Extras |
|---|---|
| the Python API | none |
| the HTTP server (`tt_diffusion_planner.server.app`, see `SERVING.md`) | `server` |
| host tests (no device; device tests are skipped): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_diffusion_planner/tests` | `server,test` |
| device tests: `python -m pytest -q -s code/tt_diffusion_planner/tests/test_pcc_device.py code/tt_diffusion_planner/tests/test_e2e_device.py` | `test` |
## Quickstart
```python
from tt_diffusion_planner import DiffusionPlanner
with DiffusionPlanner.from_pretrained(device_id=0) as model:
out = model(inputs="code/tt_diffusion_planner/samples/kashiwanoha_dense.npz")
print(out.to_dict()) # the POST /predict body
```
`examples/quickstart.py` runs the same snippet, writes `quickstart.json` and a bird's-eye view of the input and the
plan (`quickstart_bev.png`).
## `DiffusionPlanner.from_pretrained(...)`
```python
DiffusionPlanner.from_pretrained(
model_id=None, # HF repo or a local directory with the weights files; default AutowareFoundation/diffusion_planner
*,
revision=None, # default for the default repo: the validated commit 423efde67f5 (tag v5.0)
variant=None, # "default" (the only v5.0 graph); default $DIFFUSION_PLANNER_VARIANT or "default"
device_id=None, # chip to open; default $TT_DEVICE_ID or 0
device=None, # an already-opened ttnn device (tt_diffusion_planner.device.open_device); close() does not close it
dispatch=None, # "eth" (p150 target, 12x10 grid) | "worker" (A/B only, 11x10) | "auto"; default $DIFFUSION_PLANNER_DISPATCH or "eth"
num_command_queues=None, # default $DIFFUSION_PLANNER_NUM_CQS or 1
weights_dir=None, # explicit local weights directory; no Hub access
warmup_variants="default", # trace variants to capture now; see "Warm-up"
verbose=False,
precision=None, # the only compile parameter: extra precision-policy rules, e.g. "dec.*=HiFi2+fp32" (experiments only)
) -> DiffusionPlanner
```
What it does: resolves the weights first, so a Hub problem never claims the chip (`weights_dir` >
`$DIFFUSION_PLANNER_WEIGHTS_DIR` > a local `model_id` directory > the HF snapshot at the pinned revision, restricted to
the three v5.0 ONNX files and `diffusion_planner.param.json`, with an offline fallback to the cache; the sha256 of every
file and the weights' `major_version == 5` are checked), opens the chip (ETH dispatch, 12×10, 1 CQ; the other open
parameters are `DEVICE_DEFAULTS` in `tt_diffusion_planner/device.py`, overridable with `DIFFUSION_PLANNER_*`), reads the
ONNX initializers as data, uploads the weights and constants (48.1 MB), builds the graph, then compiles and captures
the metal traces. If ETH dispatch cannot open (tt-metal without the patch), it warns and falls back to WORKER dispatch
(`model.info["device"]["fallback"]` names it). Any other keyword argument is a `TypeError`.
The numerics are not arguments: the published configuration is the default of the `DIFFUSION_PLANNER_LN_FP32`,
`_SPLIT_MATMUL`, `_ATTN_MATMUL`, `_HIDDEN_FP32` and `_ATTN_FP32_ACC` knobs (`tt_diffusion_planner.tt.config.KNOBS`,
pinned in `tt-model.yaml` `serve.env`). Setting one of them in the environment changes the graph and invalidates the
accuracy figures of the card until the gates are re-run.
## Warm-up
`from_pretrained` returns a warm model: it builds the graph, runs the plan once eagerly (this first run compiles every kernel into the JIT cache), then captures the whole plan as metal traces (`warmup_variants="default"`: the full-capacity variant `plan` and one variant per agent bucket, `plan_r32`, `plan_r64`, `plan_r96`, `plan_r128`, `plan_r192`; each call replays the smallest that holds the scene, exact) with program-cache misses forbidden, so no later call compiles anything. `model.warmup()` is idempotent; `warmup_variants="none"` defers the capture to `model.warmup()`.
Measured on the shipped sample (`model.info["warmup_ms"]`, 2026-10-11): the load takes 352 s with an empty JIT cache and 12.9 s with a warm one (build 0.76 s: the ONNX initializers read and 118.9 MB of weights and constants uploaded; warm-up and capture of the 6 traces 7.7 s; the rest is the device open). The first call then takes 37 ms and the second 32 ms (an `.npz` path; the stage bench's steady state: 27 ms p50 for decoded arrays, 32 ms for an `.npz` path). The 6 traces hold 60.3 MB of DRAM (`trace_region_size` 192 MiB).
## Call: `model(...)`
| Argument | Type | Description |
|---|---|---|
| `inputs` | mapping / `.npz` path / bytes / JSON envelope | the 15 raw planner tensors (see "Input types") |
| `velocity_smoothing_window` | int, 1..79, default 8 | forward moving average of the trajectory velocity, in points |
| `stopping_threshold` | float >= 0, default 0.3 | force stop below this smoothed speed (m/s), when the ego moves |
| `turn_indicator_keep_offset` | float, default -1.25 | added to the KEEP logit before the turn-indicator decision |
| `return_denoising_steps` | bool, default False | add the ego row of the 11 solver iterates (`out.meta["denoising_steps"]`, `[11, 81, 4]`, the node's `~/debug/denoising_steps`) |
### Input types
- `inputs=`: the 15 raw tensors of the Autoware node's `DiffusionPlannerCore::create_input_data()` (batch 1,
float32, ego `base_link` frame, BEFORE normalization; names and shapes in `tt_diffusion_planner.INPUT_SCHEMA`):
a `{name: array}` mapping (numpy or torch), an `.npz` path or its bytes, or the `/predict` envelope
`{"format": "npz", "data": <base64>}` / `{"format": "json", "arrays": {...}}`. Names, shapes and finite values are
checked (`InputError`). `tt_diffusion_planner.load_inputs(source)` is the same decoder.
- Any other input (`points`, `images`, `calibration`, ...) is refused (`InputError`).
### What the caller keeps (the API is stateless)
One call is one independent plan. The Autoware node keeps state between plans; to reproduce it over a sequence of
plans, the caller keeps the same state (SERVING.md 3.5 has the details):
- **The tensors.** The node's pre-processing from ROS messages and the Lanelet2 map (per-UUID agent buffers and their
0.1 s resampling, the ego history, lane / route / polygon / line-string selection and encoding, traffic lights,
speed limits, goal, turn-indicator report history) is not part of the bundle.
- **The turn-indicator hold window** (`turn_indicator_hold_duration`, 1.0 s in the node's YAML). Each call decides
with a fresh manager; apply the node's hold across calls with the node's own manager:
```python
from tt_diffusion_planner.host.postprocess import TurnIndicatorManager
manager = TurnIndicatorManager() # hold 1.0 s, KEEP offset -1.25 (the node's YAML)
out = model(inputs=tensors)
decision = manager.evaluate(out.turn_indicator["logits"], stamp_s=now_s, prev_report=int(tensors["turn_indicators"][0, 30]))
command = decision.command # the held command while less than 1.0 s has passed
```
- **The initial solver state** `sampled_trajectories` (`x_T`, normalised space): zeros is the node's default
(`temperature: [0.0]`); for a temperature > 0 send N(0, 1) x temperature; for the RTC prefix (`delay_step` > 0) put
the previous plan into the ego row, slots t = 0 .. delay_step (x as (x - 10) / 20, y as y / 20, cos / sin as they are,
in the current ego frame). `delay` is accepted and ignored (the node's multi-step mode never reads it).
- **The map frame.** Outputs are in `base_link`; the node transforms them with the current ego pose to `map`.
## Output
`model(...)` returns a `tt_diffusion_planner.Output` (= `ttaw.outputs.Trajectory`); `out.to_dict()` is exactly the `POST /predict` body (SERVING.md section 3.2).
| field | type | meaning |
|---|---|---|
| `poses` | float32 `[80, 7]` | the ego trajectory at 0.1-8.0 s in `base_link`: x, y, yaw, cos, sin, velocity, acceleration (`out.columns`), post-processed like the node's `~/output/trajectory` |
| `turn_indicator` | dict | `command` (0 NO_COMMAND, 1 DISABLE, 2 ENABLE_LEFT, 3 ENABLE_RIGHT), `command_name`, `keep_selected`, `held` (always false: no hold window), the 5 raw `logits` (NONE, DISABLE, LEFT, RIGHT, KEEP), the decision's `probabilities` |
| `predicted_agents` | float32 `[N, 80, 5]` | x, y, yaw, cos, sin of each non-empty neighbour row, in input order |
| `meta` | dict | `predicted_agent_rows` (the rows of `predicted_agents`), `predicted_agent_columns`, `force_stop`, `time_from_start_s`, `valid_counts` (the entities the encoder saw), and with `return_denoising_steps` the encoded `denoising_steps` |
| `timing_ms` | dict | `preprocess`, `device` (host tensors + H2D + replay + D2H), `postprocess`, `total` |
`out.to_dicts()` gives one `{x, y, yaw, cos, sin, velocity, acceleration}` dict per trajectory point; `out.to_dict("npz")` adds the poses as a lossless base64 NPZ array.
## Lifetime and information
- `model.close()` releases the traces and the persistent device tensors and closes the chip if the model opened it;
idempotent. `with` calls it for you; an unclosed model is closed when Python exits.
- `model.info`: weights (repo, tag, revision, path), device (dispatch, grid, CQs, fallback), variant, warm variants,
warm-up times, runtime parameter defaults, the input schema, the numerics options and precision policy in effect, the
trace (variants, persistent inputs, trace buffers in MB).
- Calls from several threads are safe: the device calls are serialised. One model per process per chip.
## Speed
Warm calls, batch 1, ETH dispatch, 1 CQ, 12×10, the pinned numerics (`code/scripts/bench.py`, 100 iterations, 2026-10-11, the optimized release, on a shared host; p50, with p99 in brackets):
| stage | shipped sample `kashiwanoha_dense` (88 neighbours: bucket r96) |
|---|---:|
| `.npz` decode + schema check (path inputs only) | 5.76 (7.33) ms |
| host pre-processing (the node's normalization, the encoder's host features, masks, `x_T`) | 2.45 (3.16) ms |
| packing the persistent trace inputs · ttnn host tensors · H2D | 0.52 · 1.57 · 0.76 ms |
| **device trace, one blocking plan** | **20.05** (20.08) ms |
| D2H (one packed read) | 0.22 ms |
| host post-processing (trajectory, predicted paths, turn decision) | 1.39 (1.84) ms |
| **`model(inputs=arrays)` end to end** | **26.73** (27.48) ms |
| `model(inputs=<.npz path>)` | 32.27 (33.61) ms |
| back-to-back replays (device time per plan) | 20.00 ms = 50.0 plans/s |
The device time depends on the agent bucket the scene needs (exact compaction: the smallest of 32 / 64 / 96 / 128 / 192 decoder rows that holds the ego and every valid neighbour row, else the full capacity). Back to back: r32 (≤ 31 neighbours, e.g. `straight_road`) 17.44 ms, r64 (a nuScenes instant with 42 neighbours) 19.25 ms, r96 20.00 ms, r128 21.11 ms, r192 22.30 ms, full capacity (> 191) 26.65 ms (the last three: `OPT_REPORT.md` round 5). The map entities are computed at full capacity in every plan. The first release took 102.04 ms for every scene (`OPT_BASELINE.md`). 2 CQs were not re-measured on this release (at the first release they did not help a synchronous request: `OPT_BASELINE.md`). Where the time goes and what comes next: `OPT_REPORT.md`.
## Limits
- Batch 1 on the chip; one model per process.
- Fixed shapes of the v5.0 export (320 neighbours, 140 lanes, 25 route lanes, 10 polygons, 60 line strings, 31 history
and 80 future steps) and 10 DPM-Solver steps (11 decoder evaluations) are compiled into the traces; the neighbour trunk
and the decoder run on the smallest agent bucket that holds the scene (exact), so the device time depends on the
neighbour count (17.4-26.7 ms); the map entities are computed at full capacity.
- The node's guidance services (start / stop / centerline guidance) are not available (the node's default is off).
- Accuracy is agreement with the fp32 CPU reference of the same network (README "Demo & Performances"); the planner's
driving quality is the weights' (trained by TIER IV on data that is not public).
|