Serial Studio — data hotpath
You are touching the highest-risk code in the repo. Read the target file in full first
(doc/claude/architecture/dataflow.md has the full data-flow and threading model). These rules are
non-negotiable; violating them causes silent frame drops, not compile errors.
Verbalize before the first edit (J-space discipline 1+3, doc/claude/j-space.md): state
in chat, in your own words, the 3-5 hard rules below that this specific change is exposed
to — e.g. "this adds a hop after onFrameReady, so it must be DirectConnection and
allocation-free". Hotpath edits look like familiar Qt code, which is exactly the automatic
mode where these rules get violated silently; naming the binding rules at the point of
action is the deliberate-mode interrupt. Do not proceed straight from pattern-match to Edit.
Data flow
Driver → FrameReader::processData (pipeline thread) → PipelineHost::routeFrames → FrameBuilder parse → BlockStager::stage (pooled DataBlock) → BlockPublisher::publish → PipelineHost block ring → Dashboard::onDisplayTick (GUI) | CSV / MDF4 / API / Sessions (ONE clone_block_trimmed copy, only when a sink is on)
Dense typed sources (audio) skip all of that: driver SampleBlock → per-source
IO::StreamWorker thread → bounded display update / export block / latest values, all per
block. Never per-sample across a thread boundary.
Hard rules
- The frame pipeline lives on
IO::PipelineHost's thread, not the GUI thread (spec 0051).
GUI→pipeline calls self-marshal via PipelineHost::runOnObjectThread (event-loop backed);
pipeline→GUI reads via runOnGuiThreadBlocking. A plain BlockingQueuedConnection from the
GUI into the pipeline deadlocks against a script's apiCall — never add one.
FrameReader and CircularBuffer are pipeline-thread / SPSC. Never add a mutex. Reconfigure
by recreating via resetFrameReader() / reconfigure(), never by locking.
- Hotpath signal hops must be
Qt::DirectConnection. A queued connection between two
pipeline-thread objects fills the slot queue at 10+ kHz and drops frames. GUI↔pipeline
traffic must stay chunk/command/tick rate.
- No allocation and no block copy on the dashboard path. Draw blocks from
the pooled slots (
DataModel::BlockStager on the frame lane, StreamProcessor::claimBlockSlot()
on the stream lane) — never make_shared<DataModel::DataBlock>
directly. The one detached clone_block_trimmed copy in the async-sink fan-out is
intentional (slow export path, gated on a sink being on, keeps a backlog from pinning the
pool) — not a violation.
- The hotpath reads cached flags (
m_operationMode, m_playerOpen, m_anyAsyncSink,
m_captureLatestFrame, m_changeDriven, Dashboard m_streamAvailable). A new input to any
of them must wire its change signal to the cache refresh, or frames/exports silently stop
(mechanics in doc/claude/architecture/dataflow.md "Cached Hotpath Flags"; see also
doc/claude/common-mistakes.md). streamAvailable() also reads the spec-0040 mirror flag —
a plain module-static bool via API::MirrorSession::mirroring(), never a construction
(doc/claude/architecture/mirror.md).
- Native + PlainText parses through the span fast lane (
trySpanLane → parseUtf8Spans →
applyDatasetValuesSpans): byte views + in-place QString writes (assign_utf8_in_place /
assign_string_in_place, never implicit-share assignment — a share-assign re-links buffers
and degrades back to per-frame mallocs), zero steady-state allocation. Keep anything you add
to that lane allocation-free.
- Every dashboard publish site stamps
structureGeneration = m_framePoolGeneration —
pool slot and heap fallback alike. The dashboard skips per-frame compare_frames()
revalidation when the cached per-source generation matches; a frame left at the default 0
(or stale) makes Dashboard::hotpathRxFrame reconfigure every frame or never reconfigure
after a real layout change. The generation only advances via invalidateFramePool().
m_captureLatestFrame (control script running or API server on) gates the latest-frame
capture behind io.getLatestFrame: one retained CapturedDataPtr per source (the pool probe
skips pinned slots) plus the channel tokens. Keep it gated and allocation-free.
- Source owns time. Stamp at the driver boundary; never re-stamp in export/report workers
(
monotonicFrameNs(...) is the safety net only).
- Optimization macros come from
core/Core/HotpathOptimization.h (SS_FORCE_INLINE,
SS_FLATTEN, SS_HOT/SS_COLD, SS_RESTRICT, SS_ASSUME, SS_NO_UNROLL, ...). Annotate
the .h declaration and .cpp definition in lockstep. Never add a fast-math / no-unwind /
GCC optimize("...") macro (breaks the IEEE-stable math + Lua-unwind invariants). SS_ASSUME
must restate a guard that already ran, never a precondition on a parsed frame.
- Fixed loop bounds + assertion density ≥ 2 per function (NASA Power of Ten). The frame
extractors cap iterations at
kMaxFramesPerCall; keep any new loop bounded the same way.
- Asserts on the per-frame/per-cell kernels are
SS_ASSERT_HOTPATH(cond) (debug-only,
compiles out of release): even the evaluated pass-path branch of SS_ASSERT is measurable
at rate (the 2026-07 sweep cost ~5% throughput). Admissible only where the condition
restates a guard that provably already ran — a condition derived from device bytes keeps
SS_ASSERT and its release recovery. The hotpath-assert-scope lint pins the macro to
the hotpath TUs.
Block publication, time rings & kernels — the newest invariants
- One publication payload, one ingestion path (spec 0055). Nothing publishes a
TimestampedFrame any more: FrameBuilder stages parsed rows into a pooled
DataModel::DataBlock and flushes on the display tick or a sample cap. Two caps, not one --
kFrameBlockSampleCap (64) because frame-lane columns carry a display string per sample,
kStreamBlockSampleCap (4096) for the numeric-only dense lane. Dense sources (Audio, or
streamLane on) do all per-sample work on their own IO::StreamWorker thread but emit
blockReady queued to the pipeline thread, the SINGLE producer for every sink. Sinks
share ONE clone_block_trimmed copy. Structure travels separately as a StructureSnapshot
on pool-generation bumps. Never add a rate cap or a per-view reduction.
Detail: doc/claude/architecture/dataflow.md.
- Time rings are sized from a rate, never a sample count alone (
kMaxRateSizedRingSamples
ceiling): stream lane sizes at build from the real rate (streamRingCapacity); the frame lane
re-sizes a saturated ring once from the plot clock's smoothed period (growTimeRing, upward
only). m_plotClocks and m_plotDisplayTimeSec are ONE state — cleared, saved and restored
together via Dashboard::resetPlotClocks(), never one without the other. appendDecimated
clamps sub-cell backward jitter forward; a jump back over a whole cell drops the retained span
(clamping it wedges the ring shut). Detail: doc/claude/architecture/dashboard.md.
- Kernels and macros: portable SIMD lives in
core/Core/DSPSimd.h (spec 0021, per-lane
bit-exact vs scalar) — reuse it, never inline intrinsics at a call site. Optimizer macros live
in core/Core/HotpathOptimization.h; annotate .h and .cpp in lockstep, never add a
fast-math / no-unwind / GCC optimize("...") macro, and use SS_ASSERT_HOTPATH (not
SS_ASSERT) on per-frame/per-cell kernels. Detail: doc/claude/architecture/kernels.md.
Measure, don't guess
The documented "256 kHz data rate" is a CI gate, not a slogan. To check throughput after a
change, build the app and run the in-process end-to-end benchmark:
serial-studio-pro --headless --benchmark-hotpath --min-fps 256000
It loads a project via ProjectModel::loadFromJsonDocument and drives the real pipeline —
FrameReader extraction → FrameBuilder → frame parser → per-dataset transforms. The exit
code (the release gate) fails if any gated tier misses.
Nine gated runs, all tiered off --min-fps (so a --min-fps 1 PGO training run stays
effectively ungated). The seven parser gates disable the parse-budget guard (the spec-0051
fair-share governor, which a 100%-duty benchmark would engage) and run no exporters/dashboard, so
they measure pure parse capacity; the two Lua reference floors run with consumers on and
exist to catch a consumer-path collapse, not to measure parsing:
| Run |
Tier |
Default gate |
data-pipeline (FrameReader extraction only, no parse; HOTPATH_DATA_FPS) |
4x |
1.024 MHz |
| native(numeric) |
4x |
1.024 MHz |
| native(mixed) |
2x |
512 kHz |
| lua(numeric) |
1x |
256 kHz |
| js(numeric), lua(mixed) |
0.5x |
128 kHz |
| js(mixed) |
0.25x |
64 kHz |
| lua+exporters, lua+dashboard (floors) |
0.5x |
128 kHz |
| native(numeric), native(mixed) allocations per frame (spec 0084) |
0 |
fires in the -DSS_ALLOC_STATS=ON build only (CI: the PGO training run); every other build prints n/a |
Mechanics and readouts:
- Throughput =
FrameBuilder::parsedFrameCount() / elapsed. The synthetic chunk — string
columns included — is built once before the timed loop, so chunk/string construction never
sits in the measurement.
- A Native stage breakdown prints as
hotpath-stage[native] (extract / tokenize /
datasets+publish). datasets+publish is ~70-80% of per-frame time — gate any change there
with this benchmark.
- Three Lua reference rows follow:
lua+exporters (CSV/MDF4/Sessions/API/gRPC, mixed
workload; prints hotpath: exporters cost N.NNx throughput), lua+dashboard (loads an
all-widget-types project, flips HotpathBenchmark::active() so Dashboard::streamAvailable()
accepts headless frames, arms every plot/FFT/multiplot/waterfall/GPS/3D widget; prints
hotpath: dashboard costs N.NNx), and lua+dashboard(off) (same project, dashboard ingest
off; prints the ingest on-vs-off cost). Exporter/dashboard workers can't keep up with a
flat-out producer, so the pool exhausts into heap fallback — that penalty is the readout.
The first two carry the 0.5x floor gates; lua+dashboard(off) stays ungated.
- An ungated engine × {numeric, mixed} × {exporters, dashboard} coverage matrix runs last so
CI and PGO training exercise every consumer/engine combination.
--benchmark-frames N sets the minimum workload; --benchmark-seconds N the minimum
wall-clock window (default 10) — each run lasts until both floors are met.
--benchmark-output FILE mirrors the report to a file (default: stdout only).
--benchmark-channels N (default 8) widens the synthetic project for the parser tiers and the
exporter floor; throughput tiers are printed but not gated at a non-default width. Above
FrameBuilder::kMaxSpanFields (128) the Native rows take the list lane and the report says so
(native lane: list). Every row prints Alloc/frame, counted only in a
-DSS_ALLOC_STATS=ON build (n/a otherwise); the Native rows must read 0 there.
Source: app/src/Benchmark/HotpathBenchmark.cpp. CI (ci.yml, the only workflow) runs it on
every push/PR as a hard gate on the PGO-optimized binary — the same binary that ships (PGO
GENERATE → --min-fps 1 training run → PGO USE → gated --min-fps 256000). The same engine backs the in-app
About → Benchmark dialog (Benchmark::BenchmarkRunner, exposed as Cpp_Benchmark_Runner).
Do not regress the parse hotpath.
After any change here, re-read the diff against these rules before handing off, and run
python scripts/code-verify.py --check <files> (hotpath violations are blockers, not advisories).
1---2name: ss-hotpath3description: Serial Studio data-hotpath rules and the 256 kHz throughput gate. Use BEFORE editing or reviewing FrameReader, CircularBuffer, FrameBuilder, ConnectionManager, DeviceManager, or Dashboard frame-draw code — anything on the Driver → FrameReader → FrameBuilder → Dashboard path. Covers SPSC/pipeline-thread rules, DirectConnection requirement, the no-alloc/no-copy slot pool, source-owns-time, and how to measure throughput with --benchmark-hotpath. Invoke it explicitly: it is never loaded for you.4---56# Serial Studio — data hotpath78You are touching the highest-risk code in the repo. Read the target file **in full** first9(`doc/claude/architecture/dataflow.md` has the full data-flow and threading model). These rules are10non-negotiable; violating them causes silent frame drops, not compile errors.1112**Verbalize before the first edit** (J-space discipline 1+3, `doc/claude/j-space.md`): state13in chat, in your own words, the 3-5 hard rules below that *this specific change* is exposed14to — e.g. "this adds a hop after `onFrameReady`, so it must be `DirectConnection` and15allocation-free". Hotpath edits look like familiar Qt code, which is exactly the automatic16mode where these rules get violated silently; naming the binding rules at the point of17action is the deliberate-mode interrupt. Do not proceed straight from pattern-match to `Edit`.1819## Data flow2021`Driver → FrameReader::processData (pipeline thread) → PipelineHost::routeFrames →22FrameBuilder parse → BlockStager::stage (pooled DataBlock) → BlockPublisher::publish →23PipelineHost block ring → Dashboard::onDisplayTick (GUI) | CSV / MDF4 / API / Sessions24(ONE clone_block_trimmed copy, only when a sink is on)`2526Dense typed sources (audio) skip all of that: driver `SampleBlock` → per-source27`IO::StreamWorker` thread → bounded display update / export block / latest values, all per28block. Never per-sample across a thread boundary.2930## Hard rules3132- **The frame pipeline lives on `IO::PipelineHost`'s thread, not the GUI thread** (spec 0051).33 GUI→pipeline calls self-marshal via `PipelineHost::runOnObjectThread` (event-loop backed);34 pipeline→GUI reads via `runOnGuiThreadBlocking`. A plain `BlockingQueuedConnection` from the35 GUI into the pipeline deadlocks against a script's apiCall — never add one.36- **`FrameReader` and `CircularBuffer` are pipeline-thread / SPSC. Never add a mutex.** Reconfigure37 by recreating via `resetFrameReader()` / `reconfigure()`, never by locking.38- **Hotpath signal hops must be `Qt::DirectConnection`.** A queued connection between two39 pipeline-thread objects fills the slot queue at 10+ kHz and drops frames. GUI↔pipeline40 traffic must stay chunk/command/tick rate.41- **No allocation and no block copy on the dashboard path.** Draw blocks from42 the pooled slots (`DataModel::BlockStager` on the frame lane, `StreamProcessor::claimBlockSlot()`43 on the stream lane) — never `make_shared<DataModel::DataBlock>`44 directly. The one detached `clone_block_trimmed` copy in the async-sink fan-out is45 intentional (slow export path, gated on a sink being on, keeps a backlog from pinning the46 pool) — not a violation.47- **The hotpath reads cached flags** (`m_operationMode`, `m_playerOpen`, `m_anyAsyncSink`,48 `m_captureLatestFrame`, `m_changeDriven`, Dashboard `m_streamAvailable`). A new input to any49 of them must wire its change signal to the cache refresh, or frames/exports silently stop50 (mechanics in `doc/claude/architecture/dataflow.md` "Cached Hotpath Flags"; see also51 `doc/claude/common-mistakes.md`). `streamAvailable()` also reads the spec-0040 mirror flag —52 a plain module-static bool via `API::MirrorSession::mirroring()`, never a construction53 (`doc/claude/architecture/mirror.md`).54- **Native + PlainText parses through the span fast lane** (`trySpanLane` → `parseUtf8Spans` →55 `applyDatasetValuesSpans`): byte views + in-place QString writes (`assign_utf8_in_place` /56 `assign_string_in_place`, never implicit-share assignment — a share-assign re-links buffers57 and degrades back to per-frame mallocs), zero steady-state allocation. Keep anything you add58 to that lane allocation-free.59- **Every dashboard publish site stamps `structureGeneration = m_framePoolGeneration`** —60 pool slot and heap fallback alike. The dashboard skips per-frame `compare_frames()`61 revalidation when the cached per-source generation matches; a frame left at the default `0`62 (or stale) makes `Dashboard::hotpathRxFrame` reconfigure every frame or never reconfigure63 after a real layout change. The generation only advances via `invalidateFramePool()`.64- **`m_captureLatestFrame`** (control script running or API server on) gates the latest-frame65 capture behind `io.getLatestFrame`: one retained `CapturedDataPtr` per source (the pool probe66 skips pinned slots) plus the channel tokens. Keep it gated and allocation-free.67- **Source owns time.** Stamp at the driver boundary; never re-stamp in export/report workers68 (`monotonicFrameNs(...)` is the safety net only).69- **Optimization macros come from `core/Core/HotpathOptimization.h`** (`SS_FORCE_INLINE`,70 `SS_FLATTEN`, `SS_HOT`/`SS_COLD`, `SS_RESTRICT`, `SS_ASSUME`, `SS_NO_UNROLL`, ...). Annotate71 the `.h` declaration and `.cpp` definition in lockstep. Never add a fast-math / no-unwind /72 GCC `optimize("...")` macro (breaks the IEEE-stable math + Lua-unwind invariants). `SS_ASSUME`73 must restate a guard that already ran, never a precondition on a parsed frame.74- **Fixed loop bounds + assertion density ≥ 2 per function** (NASA Power of Ten). The frame75 extractors cap iterations at `kMaxFramesPerCall`; keep any new loop bounded the same way.76- **Asserts on the per-frame/per-cell kernels are `SS_ASSERT_HOTPATH(cond)`** (debug-only,77 compiles out of release): even the evaluated pass-path branch of `SS_ASSERT` is measurable78 at rate (the 2026-07 sweep cost ~5% throughput). Admissible only where the condition79 restates a guard that provably already ran — a condition derived from device bytes keeps80 `SS_ASSERT` and its release recovery. The `hotpath-assert-scope` lint pins the macro to81 the hotpath TUs.8283## Block publication, time rings & kernels — the newest invariants8485- **One publication payload, one ingestion path (spec 0055).** Nothing publishes a86 `TimestampedFrame` any more: `FrameBuilder` stages parsed rows into a pooled87 `DataModel::DataBlock` and flushes on the display tick or a sample cap. Two caps, not one --88 `kFrameBlockSampleCap` (64) because frame-lane columns carry a display string per sample,89 `kStreamBlockSampleCap` (4096) for the numeric-only dense lane. Dense sources (Audio, or90 `streamLane` on) do all per-sample work on their own `IO::StreamWorker` thread but emit91 `blockReady` **queued to the pipeline thread**, the SINGLE producer for every sink. Sinks92 share ONE `clone_block_trimmed` copy. Structure travels separately as a `StructureSnapshot`93 on pool-generation bumps. Never add a rate cap or a per-view reduction.94 Detail: `doc/claude/architecture/dataflow.md`.95- **Time rings are sized from a rate, never a sample count alone** (`kMaxRateSizedRingSamples`96 ceiling): stream lane sizes at build from the real rate (`streamRingCapacity`); the frame lane97 re-sizes a *saturated* ring once from the plot clock's smoothed period (`growTimeRing`, upward98 only). `m_plotClocks` and `m_plotDisplayTimeSec` are ONE state — cleared, saved and restored99 together via `Dashboard::resetPlotClocks()`, never one without the other. `appendDecimated`100 clamps sub-cell backward jitter forward; a jump back over a whole cell drops the retained span101 (clamping it wedges the ring shut). Detail: `doc/claude/architecture/dashboard.md`.102- **Kernels and macros:** portable SIMD lives in `core/Core/DSPSimd.h` (spec 0021, per-lane103 bit-exact vs scalar) — reuse it, never inline intrinsics at a call site. Optimizer macros live104 in `core/Core/HotpathOptimization.h`; annotate `.h` and `.cpp` in lockstep, never add a105 fast-math / no-unwind / GCC `optimize("...")` macro, and use `SS_ASSERT_HOTPATH` (not106 `SS_ASSERT`) on per-frame/per-cell kernels. Detail: `doc/claude/architecture/kernels.md`.107108## Measure, don't guess109110The documented "256 kHz data rate" is a CI gate, not a slogan. To check throughput after a111change, build the app and run the in-process end-to-end benchmark:112113```114serial-studio-pro --headless --benchmark-hotpath --min-fps 256000115```116117It loads a project via `ProjectModel::loadFromJsonDocument` and drives the real pipeline —118`FrameReader` extraction → `FrameBuilder` → frame parser → per-dataset transforms. The exit119code (the release gate) fails if any gated tier misses.120121**Nine gated runs**, all tiered off `--min-fps` (so a `--min-fps 1` PGO training run stays122effectively ungated). The seven parser gates disable the parse-budget guard (the spec-0051123fair-share governor, which a 100%-duty benchmark would engage) and run no exporters/dashboard, so124they measure pure parse capacity; the two Lua reference floors run with consumers on and125exist to catch a consumer-path collapse, not to measure parsing:126127| Run | Tier | Default gate |128|-----|------|--------------|129| data-pipeline (`FrameReader` extraction only, no parse; `HOTPATH_DATA_FPS`) | 4x | 1.024 MHz |130| native(numeric) | 4x | 1.024 MHz |131| native(mixed) | 2x | 512 kHz |132| lua(numeric) | 1x | 256 kHz |133| js(numeric), lua(mixed) | 0.5x | 128 kHz |134| js(mixed) | 0.25x | 64 kHz |135| lua+exporters, lua+dashboard (floors) | 0.5x | 128 kHz |136| native(numeric), native(mixed) allocations per frame (spec 0084) | 0 | fires in the `-DSS_ALLOC_STATS=ON` build only (CI: the PGO training run); every other build prints `n/a` |137138Mechanics and readouts:139140- Throughput = `FrameBuilder::parsedFrameCount()` / elapsed. The synthetic chunk — string141 columns included — is built once before the timed loop, so chunk/string construction never142 sits in the measurement.143- A Native stage breakdown prints as `hotpath-stage[native]` (extract / tokenize /144 datasets+publish). `datasets+publish` is ~70-80% of per-frame time — gate any change there145 with this benchmark.146- Three Lua reference rows follow: `lua+exporters` (CSV/MDF4/Sessions/API/gRPC, mixed147 workload; prints `hotpath: exporters cost N.NNx throughput`), `lua+dashboard` (loads an148 all-widget-types project, flips `HotpathBenchmark::active()` so `Dashboard::streamAvailable()`149 accepts headless frames, arms every plot/FFT/multiplot/waterfall/GPS/3D widget; prints150 `hotpath: dashboard costs N.NNx`), and `lua+dashboard(off)` (same project, dashboard ingest151 off; prints the ingest on-vs-off cost). Exporter/dashboard workers can't keep up with a152 flat-out producer, so the pool exhausts into heap fallback — that penalty is the readout.153 The first two carry the 0.5x floor gates; `lua+dashboard(off)` stays ungated.154- An ungated engine × {numeric, mixed} × {exporters, dashboard} coverage matrix runs last so155 CI and PGO training exercise every consumer/engine combination.156- `--benchmark-frames N` sets the minimum workload; `--benchmark-seconds N` the minimum157 wall-clock window (default 10) — each run lasts until both floors are met.158 `--benchmark-output FILE` mirrors the report to a file (default: stdout only).159- `--benchmark-channels N` (default 8) widens the synthetic project for the parser tiers and the160 exporter floor; throughput tiers are printed but not gated at a non-default width. Above161 `FrameBuilder::kMaxSpanFields` (128) the Native rows take the list lane and the report says so162 (`native lane: list`). Every row prints `Alloc/frame`, counted only in a163 `-DSS_ALLOC_STATS=ON` build (`n/a` otherwise); the Native rows must read 0 there.164165Source: `app/src/Benchmark/HotpathBenchmark.cpp`. CI (`ci.yml`, the only workflow) runs it on166every push/PR as a hard gate on the PGO-optimized binary — the same binary that ships (PGO167GENERATE → `--min-fps 1` training run → PGO USE → gated `--min-fps 256000`). The same engine backs the in-app168About → Benchmark dialog (`Benchmark::BenchmarkRunner`, exposed as `Cpp_Benchmark_Runner`).169**Do not regress the parse hotpath.**170171After any change here, re-read the diff against these rules before handing off, and run172`python scripts/code-verify.py --check <files>` (hotpath violations are blockers, not advisories).