Files
StarPilot/jetlink_repo/jetlink/spec.py
T
Zeph Leggett de40ad2ff1 Jetlink d24328d
Vendor only the comma payload jetlink commits to (jetlink/, LICENSE,
pyproject.toml, README.md) with an include list, so the exclude list goes
and upstream's README stays as published; StarPilot's notes live in
JETLINK.md. The root helper now ships inside the package.

The adapter moves to API 3 in the next commit; until then Jetlink stays
off.

d24328d stands in for the jetlink release tag commit: re-run
tools/vendor/sync.py jetlink_repo <tag commit> --apply before merging and
update the revision in CREDITS.md and JETLINK.md with it.
2026-10-18 16:18:37 -05:00

247 lines
9.0 KiB
Python

"""
Copyright (c) 2026-, Zeph Leggett.
This file is part of jetlink and is licensed under the MIT License.
See the LICENSE file in the root directory for more details.
Every size on the wire, derived from the model's ONNX metadata.
Two graph layouts are in the wild. The queued one takes its history as
stacked inputs (img, features_buffer, desire_pulse) and the server keeps the
queues; see queues.PolicyQueues. It mirrors get_policy_npy_shapes /
make_input_queues in openpilot's selfdrive/modeld/compile_modeld.py up to
openpilot #38916. feat_dim is prod(fb[2:]); older forks use fb[2], which gives
32 rather than 16384 for the big model's (1,32,32,512) features_buffer.
tests/test_queues.py catches the drift.
The stateful one (openpilot #38916; Cinque Terre V3 onwards)
carries its history in the graph: the newest warped frame goes in as new_img,
each queue goes in as state_<q> and comes back advanced as next_state_<q>, and
the hidden state never leaves the graph.
Either way the wire carries the newest frame and three scalars, and the reply
the outputs less hidden_state (protocol 3): the queued graph's server feeds its
own hidden state back, where an older modeld sends prev_feat.
"""
from __future__ import annotations
import hashlib
import json
import math
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from jetlink.protocol import INFER_REQ_SIZE, INFER_RESP_SIZE
# openpilot ModelConstants; duplicated so the server needs no openpilot import
MODEL_RUN_FREQ = 20
MODEL_CONTEXT_FREQ = 5
DEFAULT_FRAME_SKIP = MODEL_RUN_FREQ // MODEL_CONTEXT_FREQ # 4
CHUNK = 4 << 20 # model upload chunk
# the driving output every layout has, 18452 floats in openpilot's layout
DRIVING_OUTPUT = 'outputs'
# the slice of it that stays on the server; 16384 of the 18452
HIDDEN_STATE = 'hidden_state'
# the input only a stateful graph has; see the module docstring
STATEFUL_FRAME = 'new_img'
STATE_OUTPUT_PREFIX = 'next_'
@dataclass(frozen=True)
class ModelSpec:
"""Everything both ends need to agree on, derived from the ONNX."""
sha256: str
nbytes: int
frame_skip: int
input_shapes: dict[str, tuple[int, ...]]
output_shapes: dict[str, tuple[int, ...]]
output_slices: dict[str, slice]
checkpoint: str | None
# --- layout ---
@property
def stateful(self) -> bool:
"""The graph keeps its own history (openpilot #38916)."""
return STATEFUL_FRAME in self.input_shapes
@property
def state_pairs(self) -> dict[str, str]:
"""state_<q> input -> the next_state_<q> output that feeds it next frame.
Empty for a queued graph. Same rule as openpilot's ModelState."""
return {n: STATE_OUTPUT_PREFIX + n for n in self.input_shapes
if STATE_OUTPUT_PREFIX + n in self.output_shapes}
# --- vision ---
@property
def img_shape(self) -> tuple[int, ...]:
return self.input_shapes['img'] # (1, 12, H, W); queued graphs only
@property
def n_frames(self) -> int:
return self.img_shape[1] // 6
@property
def model_hw(self) -> tuple[int, int]:
# new_img is (2, 6, H, W) and img (1, 12, H, W): the last two either way
shape = self.input_shapes[STATEFUL_FRAME] if self.stateful else self.img_shape
return shape[-2], shape[-1]
@property
def img_buf_shape(self) -> tuple[int, int, int, int]:
h, w = self.model_hw
return (self.frame_skip * (self.n_frames - 1) + 1, 6, h, w)
@property
def warped_shape(self) -> tuple[int, int, int, int]:
"""What `warp` on the comma produces: narrow and wide stacked."""
h, w = self.model_hw
return (2, 6, h, w)
@property
def warped_nbytes(self) -> int:
return math.prod(self.warped_shape) # uint8
# --- recurrent / scalar inputs ---
@property
def feat_dim(self) -> int:
"""Flattened per-frame feature size. (1,32,32,512) -> 16384."""
fb = self.input_shapes['features_buffer']
return math.prod(fb[2:])
@property
def packed_shapes(self) -> dict[str, tuple[int, ...]]:
"""The floats a frame sends after the image: compile_modeld's
packed_npy_inputs less prev_feat, which the server keeps."""
scalars = {'traffic_convention': tuple(self.input_shapes['traffic_convention']),
'action_t': tuple(self.input_shapes['action_t'])}
if self.stateful:
# the pulse is the graph's own input
return {'desire': (math.prod(self.input_shapes['desire']),), **scalars}
return {'desire': (self.input_shapes['desire_pulse'][2],), **scalars}
@property
def prev_feat_shape(self) -> tuple[int, int]:
"""The hidden state a queued graph's server feeds back each frame."""
return (self.input_shapes['features_buffer'][0], self.feat_dim)
@property
def packed_layout(self) -> dict[str, tuple[slice, tuple[int, ...]]]:
"""Where each of packed_shapes sits in the flat floats: name -> (slice, shape)."""
out, offset = {}, 0
for name, shape in self.packed_shapes.items():
out[name] = (slice(offset, offset + math.prod(shape)), shape)
offset += math.prod(shape)
return out
@property
def packed_nelem(self) -> int:
return sum(math.prod(s) for s in self.packed_shapes.values())
@property
def packed_nbytes(self) -> int:
return self.packed_nelem * 4 # float32
@property
def feat_q_shape(self) -> tuple[int, int, int]:
fb = self.input_shapes['features_buffer']
return (self.frame_skip * fb[1], fb[0], self.feat_dim)
@property
def desire_q_shape(self) -> tuple[int, int, int]:
dp = self.input_shapes['desire_pulse']
return (self.frame_skip * dp[1], dp[0], dp[2])
# --- output ---
@property
def output_nelem(self) -> int:
return math.prod(self.output_shapes[DRIVING_OUTPUT])
@property
def output_nbytes(self) -> int:
return self.output_nelem * 4 # we return float32, as openpilot's JIT does
# cached: the client reads these on every frame, and a frozen dataclass
# without slots has a __dict__ to keep them in
@cached_property
def hidden_range(self) -> tuple[int, int] | None:
"""[start, stop) of hidden_state in the output: what the reply leaves out.
Its ends resolve as Python slicing resolves them, an open end or one
counted from the back included, which is also how the Swift server reads
them (NamedSlice.range(in:)). None when the model names no such slice or
it takes nothing, and then the reply is whole."""
s = self.output_slices.get(HIDDEN_STATE)
if s is None or s.step not in (None, 1):
return None
start, stop, _ = s.indices(self.output_nelem)
return (start, stop) if start < stop else None
@cached_property
def reply_nelem(self) -> int:
"""The floats an INFER_RESP carries: the output less hidden_state."""
h = self.hidden_range
return self.output_nelem - (h[1] - h[0] if h else 0)
# --- wire sizes ---
@property
def infer_req_nbytes(self) -> int:
return INFER_REQ_SIZE + self.warped_nbytes + self.packed_nbytes
@property
def infer_resp_nbytes(self) -> int:
"""Without telemetry; with Flag.WANT_HIDDEN it is INFER_RESP_SIZE +
output_nbytes."""
return INFER_RESP_SIZE + self.reply_nelem * 4
# -- the wire form of a spec ---------------------------------------------
# One encoder and one decoder: both ends and the bench need this, and copies
# would drift the moment a field is added.
def to_dict(self) -> dict:
return {
'sha256': self.sha256,
'nbytes': self.nbytes,
'frame_skip': self.frame_skip,
'checkpoint': self.checkpoint,
'input_shapes': {k: list(v) for k, v in self.input_shapes.items()},
'output_shapes': {k: list(v) for k, v in self.output_shapes.items()},
'output_slices': {k: [v.start, v.stop] for k, v in self.output_slices.items()},
}
@classmethod
def from_dict(cls, d: dict) -> ModelSpec:
return cls(
sha256=d['sha256'], nbytes=d['nbytes'],
frame_skip=d.get('frame_skip', DEFAULT_FRAME_SKIP),
input_shapes={k: tuple(v) for k, v in d['input_shapes'].items()},
output_shapes={k: tuple(v) for k, v in d['output_shapes'].items()},
output_slices={k: slice(*v) for k, v in d['output_slices'].items()},
checkpoint=d.get('checkpoint'))
@classmethod
def load(cls, path: str | Path) -> ModelSpec:
"""A spec saved as to_dict's JSON."""
return cls.from_dict(json.loads(Path(path).read_text()))
def sha256_file(path: str, bufsize: int = 1 << 20) -> tuple[str, int]:
h = hashlib.sha256()
n = 0
with open(path, 'rb') as f:
while chunk := f.read(bufsize):
h.update(chunk)
n += len(chunk)
return h.hexdigest(), n
def spec_from_onnx(path: str, frame_skip: int = DEFAULT_FRAME_SKIP,
sha256: str | None = None, nbytes: int | None = None) -> ModelSpec:
from jetlink.onnx_meta import parse_file # the scripts' and fixtures': modeld and the owner never parse a graph
meta = parse_file(path)
if sha256 is None or nbytes is None:
sha256, nbytes = sha256_file(path)
return ModelSpec(sha256=sha256, nbytes=nbytes, frame_skip=frame_skip, input_shapes=dict(meta.inputs),
output_shapes=dict(meta.outputs), output_slices=meta.output_slices, checkpoint=meta.model_checkpoint)