When a coordinate axis is fingerprinted#

fingerprint is a small hashable stand-in for an axis’s contents. It is derived from the data rather than assigned, computed on first access, and cached on the axis object – so nothing has to remember to bump it, and the cost is paid once per axis rather than once per consumer per message.

Transformers that cache anything resolved from coordinate values – channel labels into array indices (Slicer), or into output labels (Flatten, AffineTransform) – fold it into their state hash. Without it, a source that renames or reorders channels at a fixed channel count keeps getting the previously resolved answer, and the operation silently emits one channel’s samples under another channel’s label.

The diagram below traces a serial graph split across two processes. The middle unit on the left is a filter that needs the channel count to size its state but never looks at the axis values, so it never triggers a fingerprint.

        sequenceDiagram
    autonumber
    box transparent Process A
    participant SRC as Source<br/>builds ch axis
    participant BW as Butterworth<br/>reads shape only
    participant SL as Slicer<br/>rewrites ch axis
    end
    box transparent Process B
    participant RR as CommonRereference<br/>reads ch values
    participant FL as Flatten<br/>reads ch values
    end

    Note over SRC: build ch axis A once, keep as template<br/>A._fingerprint: absent

    rect rgba(128, 128, 128, 0.12)
    Note over SRC,FL: message 1
    SRC->>BW: msg(ch: A)
    Note over BW: _hash_message = (key, sample_shape)<br/>never touches axes, so never reads a fingerprint
    BW->>SL: msg(ch: A) — replace(msg, data=...)<br/>keeps the same axes dict
    Note over SL: _hash_message reads A.fingerprint<br/>COMPUTE, approx 1.06 us
    Note over SRC,SL: A._fingerprint is now cached. A *is* Source's<br/>template object, so the cache lands upstream.
    Note over SL: _reset_state builds B = replace(A, data=A.data[sel])<br/>fast_replace drops _fingerprint, so B is cold
    SL->>RR: serialize, then msg(ch: B)
    Note over RR: B' deserializes fresh and arrives cold<br/>reads B'.fingerprint, COMPUTE approx 1.06 us
    RR->>FL: msg(ch: B') in-process, same object
    Note over FL: reads B'.fingerprint, cached, approx 0.05 us
    end

    rect rgba(128, 128, 128, 0.12)
    Note over SRC,FL: messages 2..N, steady state
    SRC->>BW: msg(ch: A)
    BW->>SL: msg(ch: A)
    Note over SL: A.fingerprint cached, approx 0.05 us<br/>hash unchanged, no reset, re-emits the same B
    SL->>RR: serialize, then msg(ch: B)
    Note over RR: B is still cold in Process A, so every message<br/>deserializes cold and COMPUTEs again
    RR->>FL: msg(ch: B'')
    Note over FL: cached on B'', approx 0.05 us
    end
    

What the diagram is there to show#

A downstream read mutates the upstream object. Slicer reading A.fingerprint populates the cache on the very object Source holds as its template, because replace(msg, data=...) passes axes along by reference. That is the intended sharing: every later consumer of A in this process gets the answer for free.

A newly built axis crosses a process boundary cold. Slicer creates B and only ever reads A’s fingerprint, so B is serialized without one and each message deserializes cold in Process B – one digest per message there, shared between its consumers but not free.

Touching the fingerprint once in whichever unit builds the axis fixes that. Because the axis is a reused template, every subsequent serialization then carries the cached value and the downstream process pays nothing:

def _reset_state(self, message: AxisArray) -> None:
    ...
    self._state.new_axis = replace(message.axes[axis], data=out_data)
    _ = self._state.new_axis.fingerprint  # so it rides the wire precomputed

Forgetting it costs a microsecond, not correctness – which is the difference between this and a hand-maintained generation counter.

Is it worth pinning axes across the boundary?#

Measured on a 30x256x2 float32 message with a 256-channel ChannelMap axis (27 kB) and a feature axis:

per message off the boundary

cost

vs. the hop

core @ 1 kHz

the hop itself (serialize + deserialize)

27.06 us

re-fingerprint every message

1.77 us

6.6%

0.18%

sender warms the template (one line, above)

0.31 us

1.1%

0.03%

staging area with pinned template axes

0.97 us

3.6%

0.10%

Re-fingerprinting costs 6.6% of a boundary crossing that already costs 27 us, so the do-nothing case is affordable. A receive-side staging area that compared each arriving axis against a pinned template cannot beat the one-line sender warm, because it still has to read the fingerprints in order to compare them; it only adds back object identity, worth about 0.03 us per consumer.

Why fast_replace drops the cache#

fast_replace is arr.__class__(**{**arr.__dict__, **kwargs}). It is called on axes, not just on messages – replace(message.axes[axis], data=...) appears in slicer, affinetransform, butterworthzerophase and window. Once _fingerprint is in __dict__, that call becomes CoordinateAxis(data=..., dims=..., unit=..., _fingerprint=...) and raises TypeError: unexpected keyword argument '_fingerprint'.

So the drop is first of all what keeps replace() working, and only secondly a correctness measure – forwarding a digest of the old values onto a copy that changes them would be silently wrong. replace() on an AxisArray is unaffected, since only CoordinateAxis ever gains the attribute.