Skip to content

Make the evaluation barrier a graph decision (Materialize modes) - #220

Merged
cboulay merged 1 commit into
devfrom
feat/materialize-eval-mode
Aug 22, 2026
Merged

Make the evaluation barrier a graph decision (Materialize modes)#220
cboulay merged 1 commit into
devfrom
feat/materialize-eval-mode

Conversation

@cboulay

@cboulay cboulay commented Aug 22, 2026

Copy link
Copy Markdown
Member

Why

Materialize forced a full device sync unconditionally. That is the strongest of three useful guarantees and the most expensive: mx.eval blocks the calling thread for a device round-trip, measured at ~0.1–0.15 ms per call on an M4 Pro regardless of array size — it is pure latency, not work.

Often the guarantee actually wanted is weaker: "the lazy graph must not accumulate across calls." mx.async_eval provides exactly that — it detaches the pending graph just as eval does, but schedules rather than blocks, leaving the CPU free to build the next message. Sometimes it is weaker still, because something downstream (a conversion back to NumPy, an outlet, a serializer) already forces evaluation every cycle, and the right answer is to do nothing.

A lazy backend only pays off while the graph stays lazy, so where to force evaluation is a property of the graph, not of any one node. This makes that choice expressible.

What

  • MaterializeModesync / async / off, each documented with what it guarantees and what it costs.
  • materialize_array(data, mode) — applies a mode to one array. A no-op on non-lazy backends. Exposed so other nodes can share the policy instead of re-implementing the try/except ImportError dance.
  • MaterializeSettings / Materialize.SETTINGS / a materialize() factory, matching the convention in clip.py etc.
  • Default stays SYNC — this node exists only to be a barrier, so wiring one and getting something weaker would be surprising.

Backward compatible: MaterializeTransformer() and a bare Materialize unit behave exactly as before.

Test

New regression test for the property the node exists for. A recurrence that feeds its output back as state retains every input it has ever seen under off (52 MB over 100 iterations) and exactly one under sync or async (0.53 MB).

Full suite: 4063 passed, 6 skipped.

Docs

Adds a "Choosing where to evaluate" section covering the two facts that decide placement:

  • Evaluating an output also evaluates state emitted by the same kernel. MLX evaluates a multi-output primitive once and materializes all outputs. ewma_mlx_metal and sosfilt_mlx_metal emit filter state as a second output of the same kernel as the signal, so one barrier on the output covers both — which is why the stateful transformers need no internal mx.eval, and deliberately have none.
  • Evaluating an empty message forces nothing. A branch that withholds output on some cycles (a binning stage fed sub-bin chunks) leaves its upstream graph un-evaluated on exactly those cycles. The barrier has to sit upstream of any stage that can emit zero-length output.

Materialize forced a full device sync unconditionally. That is the
strongest of three useful guarantees and the most expensive: mx.eval
blocks the calling thread for a device round-trip, measured at ~0.1-0.15
ms per call on an M4 Pro regardless of array size.

Often the guarantee actually wanted is weaker -- "the lazy graph must not
accumulate across calls" -- and mx.async_eval provides exactly that,
detaching the pending graph without the stall. Sometimes it is weaker
still, because something downstream (a conversion back to NumPy, an
outlet) already forces evaluation every cycle, and the right answer is to
do nothing at all.

MaterializeMode names all three so a graph can pick one per site, and
materialize_array applies it to a single array so other nodes can share
the policy rather than re-implementing the try/except ImportError dance.
Default stays SYNC: this node exists only to be a barrier, so wiring one
and getting something weaker would be surprising.

Adds a regression test for the property the node exists for -- with OFF,
a recurrence retains every input it has ever seen (52 MB over 100
iterations); with SYNC or ASYNC it retains one (0.53 MB).

Documents the two things that decide where the barrier goes: evaluating
an output also evaluates state emitted by the same multi-output kernel
(so stateful transformers need no internal eval of their own), and
evaluating an empty message forces nothing (so the barrier must sit
upstream of any stage that can withhold output).
@cboulay
cboulay merged commit 753c0e5 into dev Aug 22, 2026
14 checks passed
@cboulay
cboulay deleted the feat/materialize-eval-mode branch August 22, 2026 15:52
@cboulay

cboulay commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Worth flagging that this PR fixes a latent bug beyond the stated scope: Materialize as a Unit does not work in the released package at all.

On 3.2.0 it declares no SETTINGS, and its transformer is BaseTransformer[None, ...], so create_processor() asserts on every graph that wires one:

declared SETTINGS attr: ABSENT
instance SETTINGS     : Settings()
create_processor: AssertionError: Settings must be of type <class 'NoneType'>

Found it wiring Materialize into a real pipeline for the first time — the graph refuses to build. MaterializeTransformer is fine and presumably the only way it has been exercised; the Unit is not.

This PR incidentally fixes it by giving the Unit SETTINGS = MaterializeSettings. Same defect class as BlackrockNeurotech/intent-pipelines#91, which found six units in that repo with the attribute missing — worth a sweep here too, since the failure is invisible until someone puts the unit on a graph and the assert names only the type it wanted, never the unit.

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