mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-10 06:23:43 +08:00
388 lines
18 KiB
Python
388 lines
18 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.
|
|
|
|
A ModelState whose policy runs on the Jetson.
|
|
|
|
Everything that touches the car stays on the comma: cameras, calibration, the
|
|
warp, the parser, controlsd, panda, CAN. The Jetson is a pure function, warped
|
|
frames and context in, 18452 floats out, and holds no control state.
|
|
|
|
The split is at `run_policy`. The warp stays on the comma's GPU: its input is a
|
|
2 MB camera buffer already there and its output the 393 KB the link carries
|
|
anyway. The history queues live on the Jetson, shipping them would cost ~10 MB
|
|
a frame instead of ~0.5 MB. modeld runs its warp as a JIT of its own, and
|
|
the link runs the same one; see warp.
|
|
|
|
A model that keeps its own history (openpilot #38916, Cinque Terre V3 on)
|
|
takes the same warped frame and the same scalars; its hidden state never
|
|
leaves the Jetson, so there is no prev_feat to send back. The spec says which.
|
|
|
|
What openpilot's modeld reads off a ModelState comes from the fork's adapter
|
|
(ModelFace): comma's constants, parser, smoothing and action function.
|
|
tinygrad is warp.Warp's to import: only modeld has it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from collections import deque
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
|
|
import numpy as np
|
|
|
|
from jetlink.protocol import NotFinite
|
|
|
|
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
|
|
SLOW_FRAME = 0.05 # the full 20 Hz budget, not just the largest outliers
|
|
# How far into a frame end() waits for the reply before publishing the
|
|
# previous frame's output again (a held frame). Past this the camera frame
|
|
# would be dropped: modeld's own work around run() takes the rest of the 50 ms.
|
|
# A held frame is a plan one frame old, which the 10 s plan and the actuator
|
|
# delay both dwarf, where a dropped frame is the same stale plan plus a count
|
|
# toward selfdrived's modeldLagging. None waits the client's full deadline
|
|
HOLD_FRAME = 0.046
|
|
# Held frames the large model may make before it is behind rather than
|
|
# tailing: this many in a row, or more than HOLDS_ALLOWED within HOLD_WINDOW.
|
|
# A host at 40 ms a frame with a tail past 46 (an iPhone) holds a few frames
|
|
# in a hundred, each a plan one frame old; one at 60 ms holds every frame.
|
|
# More in a row than the client's deadline (link.INFERENCE_TIMEOUT, 0.2 s)
|
|
# lets a quiet host run to, so a run of holds is always a host that answers,
|
|
# late; one that has stopped answering fails the link first
|
|
# (JetlinkClient.infer_begin), as a lost link
|
|
HOLDS_IN_A_ROW = 5
|
|
HOLDS_ALLOWED = 20
|
|
HOLD_WINDOW = 10.0
|
|
# The first frames after a swap, which the fork holds engagement off for (a
|
|
# second at 20 Hz): a single held frame among them is behind. A host cold from
|
|
# a reconnect proves itself here, before anyone can engage, rather than at the
|
|
# wheel, where a slow host's return is a soft disable within a second
|
|
PROVING_FRAMES = 20
|
|
# the first of them never hand back, held or not: the first after a join
|
|
# carries the history reset, and a Mac's is ~100 ms of CoreML warm-up. A
|
|
# single slow frame later is the comma's own stall, a warp or a send, which
|
|
# handing back would not fix; only held frames say the host is behind
|
|
SETTLING_FRAMES = 3
|
|
# frames between asks for the server's telemetry, which rides on the response:
|
|
# every second one, as modeld sent a chestnut's state (20 Hz over 10 Hz)
|
|
TELEMETRY_EVERY = 2
|
|
# telemetry goes to the log at most this often
|
|
TELEMETRY_PERIOD = 1.0
|
|
# frames Trips keeps the timings of: a minute at 20 Hz
|
|
TRIPS_KEPT = 1200
|
|
|
|
|
|
class Trips:
|
|
"""What the comma measures of its frames over the link: the whole run, warp
|
|
to parsed output, which is the frame as modeld waits on it, against the
|
|
server's own total, which is all the server can see. Summarised for the
|
|
leave (JetlinkClient.leave), so a phone's log carries the half of the frame
|
|
budget it cannot measure. Bounded to the last TRIPS_KEPT frames."""
|
|
|
|
def __init__(self):
|
|
self.frames = 0 # frames waited for and published
|
|
self.over = 0 # frames past SLOW_FRAME, the whole 20 Hz budget
|
|
self.held = 0 # frames whose reply was late and published the previous one
|
|
self.started = time.monotonic()
|
|
self._whole_ms: deque[float] = deque(maxlen=TRIPS_KEPT)
|
|
self._server_ms: deque[float] = deque(maxlen=TRIPS_KEPT)
|
|
|
|
def record(self, whole_s: float, server_us: int) -> None:
|
|
self.frames += 1
|
|
if whole_s > SLOW_FRAME:
|
|
self.over += 1
|
|
self._whole_ms.append(whole_s * 1e3)
|
|
self._server_ms.append(server_us / 1e3)
|
|
|
|
def summary(self) -> dict:
|
|
"""frames, over, held, span_s, and over the frames kept: p50_ms, p99_ms,
|
|
max_ms of the whole frame and server_ms, the server's mean total."""
|
|
out = {'frames': self.frames, 'over': self.over, 'held': self.held,
|
|
'span_s': round(time.monotonic() - self.started, 1)}
|
|
if self._whole_ms:
|
|
whole = sorted(self._whole_ms)
|
|
out.update(p50_ms=round(whole[len(whole) // 2], 1), p99_ms=round(whole[int(0.99 * (len(whole) - 1))], 1),
|
|
max_ms=round(whole[-1], 1), server_ms=round(sum(self._server_ms) / len(self._server_ms), 1))
|
|
return out
|
|
|
|
|
|
@dataclass
|
|
class Frame:
|
|
"""One frame on its way to the host: when its warp started, was launched
|
|
and finished, then its seq and send time once sent. The bytes are the
|
|
warp's output (Warp.output)."""
|
|
t0: float
|
|
t1: float
|
|
t2: float
|
|
seq: int | None = None
|
|
sent: float = 0.0
|
|
telemetry: bool = False
|
|
|
|
|
|
class JetlinkModelState:
|
|
"""Duck-types openpilot's modeld ModelState, and modeld_v2's where it differs.
|
|
|
|
The small model is whatever bundle the user picked, on whichever modeld that
|
|
bundle needs. sunnypilot's modeld_v2 reads constants, smoothing and the
|
|
action function off the ModelState; stock modeld has them as module
|
|
constants. This is comma's large model, so they are comma's.
|
|
|
|
A frame is prepare(), send() and end(); run() is all three, as modeld calls
|
|
it. The joining model (joining.JoiningModelState) reads `behind` after each
|
|
frame.
|
|
"""
|
|
|
|
prev_desire: np.ndarray # for tracking the rising edge of the pulse
|
|
|
|
def __init__(self, client, spec, warp, *, face, log, event):
|
|
self._log = log
|
|
# (name, **fields): a structured log line, cloudlog.event on a comma
|
|
self._event = event
|
|
self.face = face
|
|
# the joining model sets it from the small model before the first frame,
|
|
# and modeld writes it every frame after
|
|
self.lat_delay = 0.0
|
|
self.constants = face.constants
|
|
self.LAT_SMOOTH_SECONDS = face.lat_smooth_seconds
|
|
self.LONG_SMOOTH_SECONDS = face.long_smooth_seconds
|
|
self.PLANPLUS_CONTROL = 1.0
|
|
self.get_action_from_model = face.get_action_from_model
|
|
|
|
self.client = client
|
|
self.spec = spec
|
|
# not chestnut hardware, but the same role: modelV2.big, the UI and the
|
|
# model manager key off this flag
|
|
self.chestnut = True
|
|
|
|
# a warp.Warp or the adapter's model_warp, loaded and warmed ahead: its
|
|
# first call links the JIT, and this runs on modeld's frame thread. Frames
|
|
# go out of its output as they are, over either transport
|
|
self.warp = warp
|
|
|
|
self.input_shapes = spec.input_shapes
|
|
self.output_slices = spec.output_slices
|
|
# from the spec, not ModelConstants: the server derives its history stride
|
|
# from the same field
|
|
self.frame_skip = spec.frame_skip
|
|
|
|
# compile_modeld.make_input_queues' packed_npy_inputs, minus the warp's
|
|
# inputs and the GPU queues the server owns; for a stateful graph, minus
|
|
# prev_feat too
|
|
self.packed = np.zeros(spec.packed_nelem, dtype=np.float32)
|
|
self.npy = {k: self.packed[at].reshape(shape) for k, (at, shape) in spec.packed_layout.items()}
|
|
|
|
self.prev_desire = np.zeros(face.desire_len, dtype=np.float32)
|
|
self.parser = face.parser()
|
|
# the camera buffers modeld hands over, whatever the graph calls its inputs
|
|
self.vision_input_names = ['img', 'big_img']
|
|
self._need_reset = True
|
|
self._frame_id = 0
|
|
self._last_logged = 0.0
|
|
self._last_hold_logged = 0.0
|
|
self.trips = Trips()
|
|
# the frame prepared and not yet ended, if any
|
|
self._frame: Frame | None = None
|
|
# the newest output parsed, with what it parsed to: a held frame
|
|
# publishes the dict again rather than parsing the array a second time,
|
|
# which it must not, the parser's softmax working in place
|
|
self._parsed: tuple[np.ndarray, dict] | None = None
|
|
# when the large model held a frame, within HOLD_WINDOW, and how many in
|
|
# a row; `behind` says why it should hand back after the last frame
|
|
self._holds: deque[float] = deque()
|
|
self._holds_in_a_row = 0
|
|
self.behind: str | None = None
|
|
# whether the last frame published the one before it: the runtime record's
|
|
self.held = False
|
|
|
|
def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]:
|
|
return {k: model_outputs[np.newaxis, v] for k, v in output_slices.items()}
|
|
|
|
def log_telemetry(self) -> None:
|
|
"""The server's health, piggybacked on the previous response, to the log
|
|
at 1 Hz. chestnutState is comma's board on the wire and a Jetson's
|
|
telemetry has no message of its own yet."""
|
|
telemetry = self.client.last_state
|
|
if not telemetry:
|
|
return
|
|
now = time.monotonic()
|
|
if now - self._last_logged < TELEMETRY_PERIOD:
|
|
return
|
|
self._last_logged = now
|
|
self._event("jetlinkTelemetry", dead=bool(self.client.dead), **telemetry)
|
|
send = getattr(self.client.t, 'last_send', None)
|
|
if send:
|
|
self._event("jetlinkSend", nonce=self.client.nonce, frame=self._frame_id, totals=self.client.t.send_totals.copy(), **send)
|
|
|
|
def prepare(self, bufs: dict, transforms: dict[str, np.ndarray], inputs: dict[str, np.ndarray]) -> None:
|
|
"""Take this frame up: start the warp, and while the GPU runs it, read
|
|
what the host has answered so far (a held frame's late reply) and pack
|
|
the rest of the inputs. send() puts it on the link and end() waits for
|
|
its answer."""
|
|
t0 = time.perf_counter()
|
|
self.warp.start(_address(bufs['img']), _address(bufs['big_img']), transforms['img'], transforms['big_img'])
|
|
t1 = time.perf_counter()
|
|
# the GPU is warping; nothing below is an input to it
|
|
self.client.drain()
|
|
self.log_telemetry()
|
|
|
|
# Model decides when action is completed, so desire input is just a pulse triggered on rising edge.
|
|
# Under whichever name the loop keyed it: stock modeld's desire_pulse, or a modeld_v2 bundle's own
|
|
desire = inputs[next(k for k in inputs if k.startswith('desire'))]
|
|
desire[0] = 0
|
|
self.npy['desire'][:] = np.where(desire - self.prev_desire > .99, desire, 0)
|
|
self.prev_desire[:] = desire
|
|
self.npy['traffic_convention'][:] = inputs['traffic_convention']
|
|
self.npy['action_t'][:] = inputs['action_t']
|
|
|
|
# free again once infer_begin returns: the gadget's io_submit and the
|
|
# cable's sendmsg have copied it by then, before the next warp writes it
|
|
self.warp.wait()
|
|
self._frame = Frame(t0, t1, time.perf_counter())
|
|
|
|
def send(self, want_telemetry: bool = False) -> None:
|
|
"""The prepared frame to the host. It asks for the server's telemetry
|
|
when the log is due for it, or when told to: modeld asks on the frames it
|
|
would have sent a chestnut's state on.
|
|
|
|
With an output to hold, a frame the link cannot take without waiting for
|
|
the host to drain earlier ones is not sent, and end() holds it: the same
|
|
plan one frame old a late reply publishes, where waiting stalls the frame
|
|
loop on a host that is behind."""
|
|
frame = self._frame
|
|
self._frame_id += 1
|
|
frame.telemetry = want_telemetry or time.monotonic() - self._last_logged >= TELEMETRY_PERIOD
|
|
try:
|
|
frame.seq = self.client.infer_begin(self.warp.output, self.packed, self._frame_id, reset=self._need_reset,
|
|
want_state=frame.telemetry, skip_if_busy=self._can_hold)
|
|
except Exception:
|
|
self._log.warning("jetlink: frame %d send failed: %s", self._frame_id, getattr(self.client.t, 'last_send', {}))
|
|
raise
|
|
frame.sent = time.perf_counter()
|
|
if frame.seq is not None:
|
|
self._need_reset = False
|
|
|
|
def end(self, after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]:
|
|
"""The sent frame's output, as modeld blocks on a chestnut's, but not past
|
|
HOLD_FRAME once there is an output to publish again: a frame that long
|
|
is a dropped camera frame. Only a stall past the client's deadline, or an
|
|
output that is not finite, raises, into the joining state's demotion to
|
|
the small model."""
|
|
frame, self._frame = self._frame, None
|
|
# publish health while the Jetson works
|
|
if after_enqueue is not None:
|
|
after_enqueue()
|
|
waiting_from = time.perf_counter()
|
|
if frame.seq is None:
|
|
model_output = None # not sent (see send): held
|
|
else:
|
|
try:
|
|
model_output = self.client.infer_end(frame.seq, hold=self._hold_left(frame) if self._can_hold else None)
|
|
except NotFinite:
|
|
# answered, so the leave's summary counts it
|
|
self.trips.record(time.perf_counter() - frame.t0, self.client.last_timings[2])
|
|
raise
|
|
t4 = time.perf_counter()
|
|
held = self.held = model_output is None
|
|
self.behind = self._note_hold(held)
|
|
self.trips.record(t4 - frame.t0, self.client.last_timings[2])
|
|
outputs = self._outputs(self.client.last_output if held else model_output)
|
|
# a frame past the budget is a dropped camera frame; send against reply
|
|
# says which end it was. Held frames are a steady few on a marginal host,
|
|
# so after the first they go to the log at TELEMETRY_PERIOD
|
|
log_hold = held and (self.trips.held <= 3 or t4 - self._last_hold_logged >= TELEMETRY_PERIOD)
|
|
if self._frame_id <= 3 or t4 - frame.t0 > SLOW_FRAME or log_hold:
|
|
note = ''
|
|
if held:
|
|
self._last_hold_logged = t4
|
|
note = f', held ({self.trips.held} so far{", not sent" if frame.seq is None else ""})'
|
|
# persisted on the comma so a drive can separate server execution from
|
|
# receive stalls once the Jetson is offline; server total excludes USB
|
|
gpu_us, queue_us, total_us = self.client.last_timings
|
|
receive = getattr(self.client.t, 'last_receive', {})
|
|
# over the cable: how frames go, how many never came back, and what the
|
|
# kernel dropped on the way in (softnet), since the link came up
|
|
drops = getattr(self.client.t, 'net_drops', lambda: None)()
|
|
cable = (f"; frames {'as datagrams' if self.client.t.datagrams else 'on the stream'}, " +
|
|
f"{self.client.frames_lost} lost, net drops {drops}" if drops is not None else '')
|
|
self._log.warning("jetlink: frame %d warp %.1f data %.1f send %.1f wait %.1f ms%s; " +
|
|
"server gpu %.1f queue %.1f total %.1f ms; ffs maxima prepare %.1f read_wait %.1f handoff %.1f ms%s",
|
|
self._frame_id, (frame.t1 - frame.t0) * 1e3, (frame.t2 - frame.t1) * 1e3,
|
|
(frame.sent - frame.t2) * 1e3, (t4 - waiting_from) * 1e3,
|
|
note,
|
|
gpu_us / 1e3, queue_us / 1e3, total_us / 1e3, receive.get('prepare', 0.0) * 1e3,
|
|
receive.get('read_wait', 0.0) * 1e3, receive.get('handoff', 0.0) * 1e3, cable)
|
|
return outputs
|
|
|
|
def run(self, bufs: dict, transforms: dict[str, np.ndarray],
|
|
inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]:
|
|
self.prepare(bufs, transforms, inputs)
|
|
self.send(after_enqueue is not None)
|
|
return self.end(after_enqueue)
|
|
|
|
@property
|
|
def _can_hold(self) -> bool:
|
|
"""Is there an output to publish again instead of waiting (HOLD_FRAME)?"""
|
|
return bool(HOLD_FRAME) and self.client.last_output is not None
|
|
|
|
@staticmethod
|
|
def _hold_left(frame) -> float:
|
|
"""What is left of the hold for `frame`, from its warp's start."""
|
|
return HOLD_FRAME - (time.perf_counter() - frame.t0)
|
|
|
|
def _note_hold(self, held: bool) -> str | None:
|
|
"""Why the large model should hand back after this frame, if it should:
|
|
any held frame among its first PROVING_FRAMES past SETTLING_FRAMES,
|
|
HOLDS_IN_A_ROW held frames running, or more than HOLDS_ALLOWED in
|
|
HOLD_WINDOW. None while it is keeping up."""
|
|
if not held:
|
|
self._holds_in_a_row = 0
|
|
return None
|
|
self.trips.held += 1
|
|
now = time.monotonic()
|
|
self._holds.append(now)
|
|
while now - self._holds[0] > HOLD_WINDOW:
|
|
self._holds.popleft()
|
|
self._holds_in_a_row += 1
|
|
if self._frame_id <= SETTLING_FRAMES:
|
|
return None
|
|
if self._frame_id <= PROVING_FRAMES:
|
|
return f'held frame {self._frame_id} of the first {PROVING_FRAMES}'
|
|
if self._holds_in_a_row >= HOLDS_IN_A_ROW:
|
|
return f'held {self._holds_in_a_row} frames in a row'
|
|
if len(self._holds) > HOLDS_ALLOWED:
|
|
return f'held {len(self._holds)} frames in {HOLD_WINDOW:.0f} s'
|
|
return None
|
|
|
|
def _outputs(self, model_output: np.ndarray) -> dict[str, np.ndarray]:
|
|
"""The output as modeld reads it. An array parsed before (a held frame's)
|
|
gives the same dict again: the parser's softmax works in place on it."""
|
|
if self._parsed is not None and self._parsed[0] is model_output:
|
|
return self._parsed[1]
|
|
# the server checks first (Status.NOT_FINITE, which the client raises as
|
|
# this). This is for one that did not, or bytes the link changed: a raw
|
|
# inf is a corrupt output even where the parser would clip it finite. A
|
|
# sum allocates nothing, and any NaN or inf makes it one; these outputs
|
|
# are far from overflow
|
|
if not np.isfinite(model_output.sum()):
|
|
raise NotFinite(f"frame {self._frame_id}: the output is not finite")
|
|
outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices))
|
|
# a finite output can still parse to inf, by a parser that does not clip
|
|
if not np.isfinite(sum(float(value.sum()) for value in outputs_dict.values())):
|
|
raise NotFinite(f"frame {self._frame_id}: the parsed output is not finite")
|
|
if SEND_RAW_PRED:
|
|
outputs_dict['raw_pred'] = model_output.copy()
|
|
self._parsed = (model_output, outputs_dict)
|
|
return outputs_dict
|
|
|
|
def close(self) -> None:
|
|
"""Let go of the link; the joining state calls this on a model it retires."""
|
|
self.client.close()
|
|
|
|
|
|
def _address(buf) -> int:
|
|
"""Where a camera buffer modeld hands over (a VisionBuf) keeps its bytes."""
|
|
return np.frombuffer(buf.data, dtype=np.uint8).ctypes.data
|