File size: 15,901 Bytes
fe51a63 7a24154 fe51a63 7a24154 fe51a63 7a24154 fe51a63 7a24154 fe51a63 7a24154 fe51a63 7a24154 fe51a63 7a24154 fe51a63 7a24154 fe51a63 7a24154 fe51a63 7a24154 fe51a63 7a24154 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 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | # SPDX-License-Identifier: Apache-2.0
"""Python API: FRNet (Autoware lidar_frnet) on one Tenstorrent Blackhole p150.
from tt_frnet import FRNet
with FRNet.from_pretrained(device_id=0) as model: # weights -> HF cache, device open, traces captured
out = model("code/tt_frnet/samples/synthetic_ot128.npz", calibration={"preset": "synthetic_ot128"})
print(out.to_dict()) # the same JSON as POST /predict
The contract shared by every bundle of the Autoware collection is the vendored ``ttaw.api_base.ModelBase``
(BUNDLE_CONVENTIONS.md section 8): ``from_pretrained`` resolves the pinned weights before it claims the chip, opens it
(ETH dispatch, 12x10), builds the graph and captures every trace variant in ``warmup_variants``, so the first call is
as fast as the later ones; calls are serialised by a lock (one chip, batch 1) and fill ``timing_ms``; ``close()`` is
idempotent, also runs at interpreter exit, and closes the chip only if the model opened it. The HTTP server
(``tt_frnet.server.app``) calls this class, so ``/predict`` and ``model(...)`` agree bit for bit.
This module holds the identity of the port and its hooks: host pre- and post-processing ported from Autoware live in
``tt_frnet.host`` (shared with the CPU reference ``tt_frnet.reference``, so device and reference see identical
network inputs), the ttnn graph in ``tt_frnet.tt`` (run by a ``ttaw.trace.TraceRunner``; milestones M1-M3 of
PORT_LOG.md). Importing it has no side effects: ttnn / torch are imported inside the hooks.
Inputs (``host/frame.py`` has the full contract): ``points`` -- ONE sweep in the **sensor frame**, fields x, y, z,
intensity (raw 0-255); ``calibration`` -- ``{"T_base_link_from_sensor": ...}`` (or ``{"preset": name}`` of
``calib/``) for the node's ego crop box, ``ego_crop_box`` to override or disable the box (``None``).
``variant``: ``ot128`` (Autoware's default sensor model) or ``qt128``, a load-time choice (weights + grid).
Output: :class:`FRNetSegmentation` (a ``ttaw.outputs.Segmentation3D``) in the node's output order: the kept raw
points (input order) then Autoware's interpolated points; ``label_ids`` = class_id, ``scores`` = probability,
``points``, ``filter_keep`` (the filtered cloud), ``input_index``. A frame outside the node's profiles gives an empty
output whose ``meta["skipped"]`` is the node's message (the node publishes nothing).
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from . import io as tio
from .device import DEVICE_DEFAULTS
from .host.postprocess import RUNTIME_PARAMS, FRNetSegmentation
from .reference.config import DEFAULT_VARIANT, VARIANTS
from .ttaw.api_base import ModelBase
__all__ = ["FRNet", "Output", "FRNetSegmentation", "LABELS"]
# The result class of this model (ttaw.outputs: Detections3D, Detections2D, Segmentation3D, Mask2D, Trajectory).
Output = FRNetSegmentation
# ml_package_frnet_{ot128,qt128}.param.yaml class_names (identical in both variants; _build_host checks the yaml).
# Indices 22 / 23 read "debris" / "stroller" here, but the weights were trained with stroller = 22, debris = 23
# (AWML frnet_1xb8_t4dataset-ot128-seg.py:60-61; S:frnet:314-317): Autoware's names are kept for parity.
LABELS = ("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")
def _given(value: Any) -> bool:
"""A real input (``None`` and empty containers are 'not given'); never the truth value of an array."""
if value is None:
return False
if isinstance(value, (list, tuple, dict, str)):
return len(value) > 0
return True
class _DeviceRows:
"""``_forward``'s result with ``FRNET_HOST_POST=tiles`` / ``numba``: the device's ``pred_probs`` in sorted order
(padded; raw TILE-layout elements with ``width``, else untilized rows) and the frame plan; ``_postprocess``
un-permutes them in its compiled pass."""
__slots__ = ("rows", "plan", "width")
def __init__(self, rows: Any, plan: Any, width: Any = None):
import numpy as np
r = np.asarray(rows, np.float32)
self.width = width # None: untilized rows; else the raw TILE elements of this width
self.rows = r.reshape(-1) if width is not None else r.reshape(-1, r.shape[-1])
self.plan = plan
class FRNet(ModelBase):
"""FRNet (Autoware lidar_frnet) on one Blackhole p150. Create it with :meth:`from_pretrained`."""
MODEL_NAME = "frnet-p150"
ENV_PREFIX = "FRNET" # prefix of the environment knobs (SERVING.md section 3.4)
DEFAULT_REPO = "AutowareFoundation/lidar_frnet"
DEFAULT_TAG = "v2.0" # the Autoware ansible artifacts pin
# the commit DEFAULT_TAG points to (pinned: tags can move)
DEFAULT_REVISION = "a53a1c11b0b28d31fd50a03abef5db0e830a5b3c"
ALLOW_PATTERNS = ["frnet_*.onnx", "ml_package_frnet_*.param.yaml", "deploy_metadata.yaml"]
VARIANTS = list(VARIANTS) # load-time: selects the ONNX, the frustum grid and the trace shapes
DEFAULT_VARIANT = DEFAULT_VARIANT
INPUT_KIND = "lidar" # lidar | camera | multicam | lidar+multicam | planner
CAMERA_ORDER = () # multicam models: the fixed camera order of the network
POINT_FIELDS = tio.DEFAULT_POINT_FIELDS # columns the network consumes, in order (x, y, z, intensity)
LABELS = list(LABELS)
# Per-request knobs (host post-processing only; defaults are Autoware's): host/postprocess.py
RUNTIME_PARAMS = RUNTIME_PARAMS
EXTRA_INPUTS = ()
DEVICE_DEFAULTS = DEVICE_DEFAULTS # validated open parameters (device.py)
# ---- port-specific hooks (called by ModelBase; keep host work out of _forward) ---------------------------
def _build(self) -> None:
"""Host side (config, weights, pre- / post-processors), then the ttnn graph of ``tt_frnet.tt``
(``TtFRNet``: point encoder, K1 segmented max, gathers, convs, decoder, head) as the ``"default"`` variant of
a ``ttaw.trace.TraceRunner`` with the per-frame tables as persistent inputs (no capture here; ``_warm_one``
captures). Warm-up runs an empty frame (every index valid)."""
from .tt.model import FrameStaging, TtFRNet, bucket_name, build_runner, default_policy
self._build_host()
self.graph = TtFRNet(self.device, self.frnet_weights, self.config, policy=default_policy())
# BUCKETS (OPT round 3): the same graph at smaller N_cap / M_cap, one trace each; a frame runs in the
# smallest one that holds it (select())
self.buckets = [self.graph.view(n, m) for n, m in self.graph.bucket_caps()]
self.runner = build_runner(self.graph, variants=("default",), name=f"{self.MODEL_NAME}/{self.variant}",
buckets=self.buckets)
self.staging = FrameStaging(self.graph) # persistent host buffers of the inputs (written in place)
self.stagings = [(bucket_name(v), FrameStaging(v, suffix="@" + bucket_name(v))) for v in self.buckets]
self.stagings.append(("default", self.staging))
self._host_outs: Dict[str, Any] = {}
def select(self, plan: Any):
"""``(trace variant, FrameStaging)`` of the smallest capacity that holds the frame (``"default"``: N_cap)."""
for name, st in getattr(self, "stagings", [("default", getattr(self, "staging", None))]):
if name == "default" or st.fits(plan):
return name, st
return "default", self.staging
def _build_host(self) -> None:
"""The host half of ``_build`` (no device): usable alone by host tests and tools."""
from .host.frame import FRNetPreprocessor
from .host.postprocess import FRNetPostprocessor
from .reference.config import FRNetConfig
from .reference.weights import check_variant_grid, load_weights
self.config = FRNetConfig.from_model_dir(self.weights_path, self.variant)
if tuple(self.config.class_names) != tuple(self.LABELS):
raise ValueError(f"ml_package class_names differ from FRNet.LABELS: {self.config.class_names}")
self.frnet_weights = load_weights(self.weights_path, self.variant)
check_variant_grid(self.frnet_weights, self.config.frustum_h, self.config.frustum_w)
from .tt.model import KNOBS
knobs = KNOBS.read()
self.pre = FRNetPreprocessor(self.config, pre_norm=(self.frnet_weights.pre_norm_scale,
self.frnet_weights.pre_norm_shift),
backend=str(knobs.HOST_PRE))
self.post = FRNetPostprocessor(self.config)
self.host_post = str(knobs.HOST_POST)
self._host_outs = {} # HOST_POST=tiles: the persistent host tensor of each variant's output
if self.host_post in ("tiles", "numba"):
from .host import fast
if not fast.warm_up(): # numba missing: plan.unsort + the numpy post-processing
self.host_post = "numpy"
def _warm_one(self, variant: Dict[str, Any]) -> None:
"""Warm up and capture the traces of one entry of ``default_warmup_variants()``. ``TraceRunner.capture``
runs every pending variant eagerly (kernel JIT, program cache) before any capture, then captures with
program-cache misses forbidden; it is idempotent."""
self.runner.capture()
def _prepare(self, points: Any = None, *, sweeps: Optional[Any] = None, images: Any = None,
calibration: Any = None, stream: Any = None, inputs: Any = None, **_: Any):
"""Host pre-processing (``host/frame.py``): decoding, the ego crop, float32 projection, fp16 interpolation,
unique cells, the node's profile checks and the device plan. Client mistakes raise ``tio.InputError``
(HTTP 400); an out-of-profile frame is ``prepared.skipped`` (no device run, empty output)."""
if points is None:
raise tio.InputError("this model needs 'points'")
extra = [name for name, value in (("sweeps", sweeps), ("images", images), ("inputs", inputs),
("stream", stream)) if _given(value)]
if extra:
raise tio.InputError(f"FRNet takes one sensor-frame sweep ('points') and its 'calibration' only; got "
f"{extra}")
return self.pre(points, calibration)
def _forward(self, prepared: Any) -> Any:
"""Upload the frame plan (features, CSR offsets, gather tables) into the persistent device inputs, replay
the trace, read ``pred_probs`` back (one D2H) and return it as ``(N, 27)`` float32 in the network-input order
(``plan.unsort``). A skipped frame runs nothing. Runs under the model lock."""
if prepared.skipped is not None:
return None
mode = getattr(self, "host_post", "numpy")
if mode == "tiles": # the raw TILE bytes of the output; un-permuted in _postprocess
flat = self.device_tiles(prepared.plan)
if flat is not None:
return _DeviceRows(flat, prepared.plan, width=self._tile_width)
mode = self.host_post = "numba" # no raw host buffer access on this ttnn: the untilized rows
if mode == "numba": # sorted device rows; un-permuted in _postprocess
name, st = self.select(prepared.plan)
raw = self.runner(name, inputs=st.write(prepared.plan))
return _DeviceRows(raw, prepared.plan)
return self.device_probs(prepared.plan)
def device_tiles(self, plan: Any) -> Any:
"""Run one frame and return the output ``pred_probs`` ``[1, 1, N_cap, 32]`` as its raw TILE-layout float32
elements (a view of a persistent host tensor, valid until the next call; None when this ttnn exposes no
host buffer). Call it under the model lock."""
import numpy as np
import ttnn
name, st = self.select(plan)
self.runner.run(name, st.write(plan))
dev_out = self.runner.outputs(name)
if dev_out.layout != ttnn.TILE_LAYOUT or dev_out.dtype != ttnn.float32:
return None
host_out = self._host_outs.get(name)
if host_out is None:
host_out = self._host_outs[name] = ttnn.allocate_tensor_on_host(dev_out.spec, self.device)
self._tile_width = int(dev_out.padded_shape[-1])
ttnn.copy_device_to_host_tensor(dev_out, host_out, blocking=True)
try:
buf = host_out.host_buffer().get_shard(ttnn.MeshCoordinate(0, 0))
return np.from_dlpack(buf).view(np.float32)
except Exception: # noqa: BLE001 - another ttnn build: fall back to the untilized rows
return None
def device_probs(self, plan: Any) -> Any:
"""``pred_probs`` ``(N, 27)`` float32 (network-input order) of one ``host.preprocess.FramePlan`` (the device
half of ``_forward``; tests and tools feed stored network inputs through it). Call it under the model
lock (``model(...)`` does)."""
import numpy as np
name, st = self.select(plan)
raw = self.runner(name, inputs=st.write(plan))
probs = np.asarray(raw, np.float32).reshape(-1, raw.shape[-1])
return plan.unsort(probs[:, : self.config.num_classes])
def _postprocess(self, raw: Any, prepared: Any, params: Dict[str, Any]) -> Output:
"""Host post-processing (``host/postprocess.py``, ``fill_cloud_kernel``): first-max argmax, probability,
the class-probability filter -> ``FRNetSegmentation``; ``raw`` = ``pred_probs`` or None (skipped)."""
meta = {"variant": self.variant, **prepared.stats()}
self.post.filter_indices(params["filter_classes"]) # an unknown class name is a 400 either way
if raw is None:
return self.post.empty(model=self.MODEL_NAME, frame_id=prepared.frame_id, meta=meta)
inp = prepared.inputs
if isinstance(raw, _DeviceRows):
return self.post.from_rows(raw.rows, raw.plan, points=inp.points, num_points_raw=inp.num_points_raw,
input_index=prepared.input_index, params=params, model=self.MODEL_NAME,
frame_id=prepared.frame_id, meta=meta, tile_width=raw.width)
return self.post(raw, points=inp.points, num_points_raw=inp.num_points_raw,
input_index=prepared.input_index, params=params, model=self.MODEL_NAME,
frame_id=prepared.frame_id, meta=meta)
def _release(self) -> None:
"""Release the traces, persistent tensors and device weights; also called when ``from_pretrained`` fails
half-way (tolerates a partial build)."""
runner = getattr(self, "runner", None)
if runner is not None:
runner.release()
self.runner = None
graph = getattr(self, "graph", None)
if graph is not None:
graph.release()
self.graph = None
def extra_info(self) -> Dict[str, Any]:
"""Additions to ``model.info`` and ``/info``: the node parameters and the runner's traces."""
out: Dict[str, Any] = {}
config = getattr(self, "config", None)
if config is not None:
out["frnet"] = config.describe()
runner = getattr(self, "runner", None)
if runner is not None:
out["trace"] = runner.describe()
graph = getattr(self, "graph", None)
if graph is not None:
out["graph"] = graph.describe()
return out
|