Key the default state hash on the message layout - #13
Merged
Conversation
…hunk_dim The axis-aware state hash in the next commit needs both. Without them it degrades silently rather than failing: a CoordinateAxis has no `fingerprint` attribute on 3.9, so the hash falls back to the axis's length and stops distinguishing a channel relabel from the channels it replaced -- exactly the case the hash exists to catch. A lower bound rather than an exact pin: this is a library, and pinning `==3.10.0b1` would forbid every consumer from moving to 3.10.0 final without a release here. PEP 440 admits pre-releases for a specifier that names one, so `>=3.10.0b1` resolves to the beta today and to the final release later without further changes.
`_hash_message` returned 0, so a processor that did not override it reset once
and never again. That is right for something operating elementwise, and wrong
for anything that caches state derived from the stream's shape -- which it
cannot detect, because the base class never asked.
The default now folds in the message key, its dims, the length of every
dimension except the one it is a chunk along, the coordinate values on those
dimensions, and the gain and offset of any linear axis among them.
The coordinate values are the point. Per-channel state -- a filter's `zi`, a
scaler's running mean -- is only valid for the channels it was built for, and
shape alone cannot see a source that swaps channels without changing how many
it sends. Reconfiguring a device at a fixed channel count then leaves the new
channel's first samples dominated by the old channel's filter history, off by
several hundred times the new signal's amplitude and decaying over the filter's
settling time. Silently.
Two things are deliberately excluded. The chunk dimension's length and offset,
since both change with every message by definition; and `offset` only there --
elsewhere it locates the axis, and a spectrum moving from 5-25 Hz to 70-90 Hz
keeps the same gain and length and differs only in its offset.
Which dimension is the chunk dimension comes from `AxisArray.chunk_dim` when
the producer declares it. STREAMING_DIMS is the fallback for producers that do
not, defaulting to ("time",) -- right for a raw signal, wrong downstream of a
windowing stage where the message is (win, time, ch) and `win` is what grows.
A wrong answer there is not a small error in either direction: name a stable
dimension and the processor stops noticing real changes to it, name a growing
one and it resets on every message.
`_message_hash` is exposed separately so an override can extend the default
rather than replace it. Auditing ezmsg-sigproc's 46 stateful processors found
both directions needed: some must add a dtype or a value from their own state,
and five must *narrow* it -- `binned_aggregate` and `downsample` would
otherwise restart their bin schedule and decimation phase on a channel change
they do not care about. Hence `exclude_dims`, `include_key` and `extra`, with
`exclude_dims` additive to the chunk dimension so naming one cannot accidentally
un-exclude the other.
Non-AxisArray messages still hash to a constant, so producers and processors on
other message types keep their previous behaviour.
This runs on every message of every stream, so the constant factor matters. Two changes, together 1.14x, measured on a (time, ch, feature) message with a 256-channel ChannelMap axis: 0.60 -> 0.53 us. Hoist `dims`, `axes` and `data.shape` out of the loop, and build the exclusion as a tuple rather than a set -- it holds one or two entries in practice, where a linear scan beats the ~0.05 us of constructing a set. The loop previously fetched `gain` for every dimension before testing exclusion, so a coordinate axis paid a failed lookup it never used and then a second one for its fingerprint. Ask for the attribute each branch actually needs, fingerprint first, and a coordinate axis costs one lookup instead of two. Two other candidates were measured and rejected. Dispatching on isinstance instead of getattr was 0.96x -- two isinstance checks cost more than the getattrs they replace. Memoising the result on the identity of the axes mapping is 3.3x while a stream keeps handing over the same axis objects, but 0.93x once it does not, which is precisely what a stream arriving over a process boundary does: fresh objects every message, so the memo never hits and only adds bookkeeping. Worth revisiting behind that measurement, not before it. Hash values are unchanged; verified identical to the previous implementation across coordinate, linear, absent and non-AxisArray axes, and with each combination of the exclude_dims / include_key / extra arguments.
`test_clock_producer_sync[inf]` and its async twin assert that 100 iterations finish inside 10 ms. That measures the machine, not the producer: a developer box runs the loop at ~0.4 us per iteration, some 300x inside the budget, while a shared CI runner has been observed at 130-160 us per iteration -- 16.1 ms on one run and 13.0 ms on a rerun of the same commit. Skip just that assertion when CI is set. Everything else in both tests still runs there: the returned axes are still checked for type, for gain, and for monotonically increasing offsets. The throttled parametrisations keep their timing assertions, which allow 200 ms of slack and have not been seen to fail. The 400x gap between a developer box and the runner is unexplained. It does not track this branch's code: ClockProducer.__call__ is a plain synchronous method that never reaches the message hashing this branch changes, and the failure reproduces on the branch while the same source without this branch's added tests passes three times out of three. Skipping is a stopgap for an assertion that has no headroom on the machines that run it, not a diagnosis.
`_message_hash` walks the dims, reaches into the axes and builds a tuple to
hash, once per message per processor. In a steady stream the answer is the
same every time, and so is the work to prove it.
Each result now records a witness: the objects it was derived from, and a
validator closure compiled for that layout. All the layout-dependent
decisions -- which dimensions to skip, where the kept lengths sit, whether
the key matters -- are made once at build time and baked into the closure's
defaults rather than re-derived per message. A tuple-based version measured
0.236 us against the closure's 0.140; unpacking and branching cost more than
the comparisons they guarded.
Each kept dimension is recorded twice, as the axis object and as its value.
Identity settles it in one pointer comparison when the producer reuses its
per-stream axes, which is what the template idiom every ezmsg source uses
guarantees. The value fallback covers the case identity cannot: unpickling
hands out a new axis object per message, so without it every processor
downstream of a process boundary would miss on every message. The
fingerprint rides along already computed, so comparing values is cheap
there.
Measured on one call, against recomputing:
before after
in-process (same axis object) 0.419 0.201 us
post-transport (new object, same value) 0.429 0.319 us
Both at a 100% hit rate, and both flat in channel count -- 0.203 us at 256
channels versus 0.201 at 16 -- because the witness never touches axis data.
At 100 nodes that is ~42 us per message of hashing down to ~20.
The witness is dropped when state is restored through `stateful_op` and on
`_request_reset`: it describes the message the previous state was built
from, and matching against it would return a hash for state that is gone.
Layouts whose excluded dimensions are not at one end are declined outright
rather than paying a per-message comprehension to reproduce `shape`.
The risk here is returning a stale hash, which would leave a processor
silently holding state for a configuration that is gone, so the tests are a
differential fuzz: randomised streams through every mutation a real one can
undergo -- relabels, channel counts, sample rates, keys, dim renames, chunk
jitter, axis-type swaps, varying call kwargs, mid-stream state restores --
asserting the witnessed hash equals the recomputed one on every message. It
found three bugs, each of which now has a named regression test:
* a witness blind to `exclude_dims`, answering for an exclusion set it was
not built for
* a specialised validator that assumed the chunk axis stayed linear, raising
when an irregular-rate stream swapped its TimeAxis for a CoordinateAxis
* a generic validator that skipped an excluded dimension whose axis had
disappeared, when absence drops a term from the hash
67,200 messages across four seeds now agree, and ezmsg-sigproc's suite is
unchanged at 4233 passed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes the default
_hash_messagekey on the message layout instead of returning a constant, so a processor that doesn't override it resets when the stream actually changes.Requires ezmsg 3.10.0b1 for
CoordinateAxis.fingerprintandAxisArray.chunk_dim(ezmsg-org/ezmsg#265).The problem
_hash_messagereturned0, so a processor that didn't override it reset once and never again. That's correct for something operating elementwise, and wrong for anything caching state derived from the stream's shape — which the base class could never detect, because it never asked.Worse is what overriding processors were missing. Per-channel state — a filter's
zi, a scaler's running mean — is only valid for the channels it was built for, and shape alone cannot see a source that swaps channels without changing how many it sends. Reconfiguring a device at a fixed channel count leaves the new channel's first samples dominated by the old channel's filter history:~470x the new signal's amplitude, decaying over the filter's settling time, with nothing raised.
What the default folds in
The message key, its dims, the length of every dimension except the one it is a chunk along, the coordinate values on those dimensions (via
fingerprint), and the gain and offset of any linear axis among them.Two deliberate exclusions:
CoordinateAxis(irregular event times) its values do too, so it is excluded entirely.offsetonly there. Elsewhere it locates the axis: a spectrum whosefreqaxis moves from 5-25 Hz to 70-90 Hz keeps the same gain and the same length and differs only in its offset.Which dimension is the chunk dimension comes from
AxisArray.chunk_dimwhen the producer declares it — the producer renamed the dims, so it is the only party that reliably knows.STREAMING_DIMSis the fallback for producers that don't, defaulting to("time",).That fallback is a guess, and documented as one. It is right for a raw
(time, ch)signal and wrong downstream of a windowing stage, where the message is(win, time, ch)andwinis what grows:A wrong answer is not a small error in either direction: name a stable dimension and the processor stops noticing real changes to it; name a growing one and it resets on every message.
Extending rather than replacing
_message_hashis exposed separately so an override can build on the default instead of rebuilding the hash and silently losing the axis coverage.Auditing ezmsg-sigproc's 46 stateful processors showed both directions are needed. Some must add something the base cannot know — a dtype the state depends on, a value derived from the processor's own state. Five must narrow it:
binned_aggregateanddownsamplewould otherwise restart theirBinScheduleand decimation phase (s_idx = 0) on a channel change they genuinely don't care about, shifting bin boundaries and sample phase.Hence
exclude_dims,include_keyandextra.exclude_dimsis additive to the chunk dimension, so naming one cannot accidentally un-exclude the other.Cost
return 0)About 2.5x a typical override, +0.25 µs per node. The coordinate digest itself is near-free in the common case:
fingerprintis computed once per axis object and cached on it, and rides the pickle across a process boundary, so consumers downstream of one hop read it rather than recompute it.Constant factor
_message_hashruns on every message of every stream, so the third commit trims it: hoistdims/axes/data.shapeout of the loop, build the exclusion as a tuple rather than a set (it holds one or two entries, where a linear scan beats the ~0.05 µs of building a set), and ask for only the attribute each branch uses. The loop previously fetchedgainfor every dimension before testing exclusion, so a coordinate axis paid a failed lookup it never used and then a second one for its fingerprint.0.60 -> 0.53 µs, 1.14x, measured on a
(time, ch, feature)message with a 256-channel ChannelMap axis. Hash values are unchanged, verified identical to the previous implementation across coordinate / linear / absent / non-AxisArrayaxes and every combination of the keyword arguments.Three further candidates were measured and rejected:
isinstancedispatch instead ofgetattraxesmappingOn the comprehension: the usual win is avoiding a
LOAD_METHOD appendper iteration, but this loop runs 2-5 times and the fixed setup does not amortise. Every variant improved steadily from 2 to 5 dimensions, so the break-even is somewhere past ~8 dimensions, whichAxisArraymessages do not reach. Bindingaxisonce via thefor axis in (get(dim),)idiom is the best of them at 0.98x. Hoistingparts.appendis also a loss (0.94x) — creating the bound method costs more than the repeated lookup at these iteration counts.On the memo: an earlier draft cached the result and short-circuited when
message.axeswas the same object as last time. That looked like a 3.3x win, but the benchmark shared oneaxesdict across its messages, which real streams do not:replace(msg, data=...)preserves the mapping along a single message's journey through the graph, but the memo compares successive messages at one node, and each message begins at a source that builds a fresh mapping. The identity check therefore never fires and the memo is a flat 16% loss (0.554 vs 0.476 µs). Dropped.Per-axis identity does hold — a source rebuilds the dict but reuses the axis objects inside it — but a memo keyed that way still has to walk the dimensions, so it would only replace the
fingerprintproperty call with a dict lookup, about 0.03 µs per coordinate axis. Not worth the state.Compatibility
Non-
AxisArraymessages still hash to a constant, so producers and processors on other message types are unaffected —clockdriven'sCounterProducerand friends keep their existing behaviour.Every processor that currently overrides
_hash_messageis untouched; this only changes the default.Testing
13 new tests covering chunk-size jitter, relabel at fixed channel count, channel-count / sample-rate / key changes, time offset advancing, a coordinate-valued streaming axis, declared vs fallback chunk dimension, a fallback naming an absent dimension, non-
AxisArraymessages, and each of the three escape hatches.192 passed, 1 skipped (MLX not installed) against the published 3.10.0b1 wheel. Each commit is independently green — the pin bump alone is 178 passed.
Verified downstream: ezmsg-sigproc's full suite is 4171 passed with no regressions.
🤖 Generated with Claude Code