Skip to content

Key the default state hash on the message layout - #13

Merged
cboulay merged 6 commits into
devfrom
cboulay/axis-aware-hash-default
Sep 3, 2026
Merged

Key the default state hash on the message layout#13
cboulay merged 6 commits into
devfrom
cboulay/axis-aware-hash-default

Conversation

@cboulay

@cboulay cboulay commented Sep 3, 2026

Copy link
Copy Markdown
Member

Makes the default _hash_message key 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.fingerprint and AxisArray.chunk_dim (ezmsg-org/ezmsg#265).

The problem

_hash_message returned 0, 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:

first 4 samples of the NEW channel 'armB-1':
  filter state carried from armA: [-5.919  -4.772  -0.5114  1.064]
  with a correct reset:           [ 0.     -0.0013 -0.0023 -0.0026]
  max |difference| = 11.12   vs new-data amplitude 0.024

~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:

  • The chunk dimension's length and offset, since both change with every message by definition. If it carries a CoordinateAxis (irregular event times) its values do too, so it is excluded entirely.
  • offset only there. Elsewhere it locates the axis: a spectrum whose freq axis 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_dim when the producer declares it — the producer renamed the dims, so it is the only party that reliably knows. STREAMING_DIMS is 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) and win is what grows:

post-Window (win, time, ch)   0=first  1,2=win-count jitter  3=relabel  4=same  5=window-len change
  fallback ("time",):       resets at [0, 1, 2, 3]     thrashes, and misses [5]
  declared chunk_dim="win": resets at [0, 3, 5]        correct

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_hash is 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_aggregate and downsample would otherwise restart their BinSchedule and 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_key and extra. exclude_dims is additive to the chunk dimension, so naming one cannot accidentally un-exclude the other.

Cost

per node 30-node graph core @ 1 kHz
old default (return 0) 0.025 µs 0.75 µs 0.07%
typical hand-written override 0.165 µs 4.99 µs 0.50%
new default 0.414 µs 12.44 µs 1.24%

About 2.5x a typical override, +0.25 µs per node. The coordinate digest itself is near-free in the common case: fingerprint is 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_hash runs on every message of every stream, so the third commit trims it: hoist dims / axes / data.shape out 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 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.

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-AxisArray axes and every combination of the keyword arguments.

Three further candidates were measured and rejected:

candidate result
isinstance dispatch instead of getattr 0.96x — two isinstance checks cost more than the getattrs they replace
list comprehension / genexp instead of the for-loop 0.65-0.99x at 2, 3 and 5 dimensions
memoising on the identity of the axes mapping 0.84x — see below

On the comprehension: the usual win is avoiding a LOAD_METHOD append per 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, which AxisArray messages do not reach. Binding axis once via the for axis in (get(dim),) idiom is the best of them at 0.98x. Hoisting parts.append is 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.axes was the same object as last time. That looked like a 3.3x win, but the benchmark shared one axes dict across its messages, which real streams do not:

source: consecutive messages share the mapping?   False
replace(msg, data=...)  preserves the mapping?    True
chain source->A->B->C, mapping same as previous message: 0/3 at every node

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 fingerprint property call with a dict lookup, about 0.03 µs per coordinate axis. Not worth the state.

Compatibility

Non-AxisArray messages still hash to a constant, so producers and processors on other message types are unaffected — clockdriven's CounterProducer and friends keep their existing behaviour.

Every processor that currently overrides _hash_message is 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-AxisArray messages, 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

…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.
@cboulay
cboulay merged commit 86a1dcf into dev Sep 3, 2026
14 checks passed
@cboulay
cboulay deleted the cboulay/axis-aware-hash-default branch September 3, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant