Skip to content

feat: lower while and runtime calls#123

Merged
polvalente merged 9 commits into
mainfrom
pv-feat/lower-while
Jul 6, 2026
Merged

feat: lower while and runtime calls#123
polvalente merged 9 commits into
mainfrom
pv-feat/lower-while

Conversation

@polvalente

@polvalente polvalente commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

This PR removes the need for graph splitting on runtime calls and while loops.

Benchmarks now stand as follows:

each entry in the table is row / col. > 1 means row is faster than col

Qwen3-0.6B (dense bf16)

row ∖ col (row / col) bb base bb+rewrite native emily-eager emily-native
bb base 0.7× 0.64× 5.12× 0.77×
bb+rewrite 1.43× 0.91× 7.34× 1.1×
native 1.57× 1.1× 8.04× 1.21×
emily-eager 0.2× 0.14× 0.12× 0.15×
emily-native 1.3× 0.91× 0.83× 6.65×
  • bb base: median=54.8 mean=55.0±1.9 min/max=52.3/57.6 tok/s
  • bb+rewrite: median=78.5 mean=78.9±2.2 min/max=76.5/82.6 tok/s
  • native: median=86.0 mean=85.9±0.2 min/max=85.7/86.1 tok/s
  • emily-eager: median=10.7 mean=10.7±0.1 min/max=10.5/10.8 tok/s
  • emily-native: median=71.2 mean=69.6±5.7 min/max=59.4/76.3 tok/s

Qwen3-0.6B-MLX-4bit

row ∖ col (row / col) bb base bb+rewrite native emily-quantized
bb base 0.68× 0.36× 1.49×
native 2.79× 1.89× 4.16×
emily-quantized 0.67× 0.46× 0.24×
  • bb base: median=52.2 mean=52.7±0.9 min/max=51.9/54.4 tok/s
  • bb+rewrite: median=76.9 mean=76.6±1.1 min/max=74.6/77.6 tok/s
  • native: median=145.6 mean=150.7±10.3 min/max=141.8/169.5 tok/s
  • emily-quantized: median=35.0 mean=34.9±0.1 min/max=34.7/35.0 tok/s

De-risks native `:while` lowering (see EMLX.Native.Expr's `:while`
limitation): a genuine mlx::core::Primitive (EMLXWhileSpike), pinned to
the CPU stream like the existing linalg/runtime_call primitives, can
call mlx::core::eval() in a loop from inside its own eval_cpu -- itself
reached via eval_program's outer mlx::core::eval(outputs) on the
worker's dedicated OS thread -- without deadlocking, even when the
outer program's default device/stream is GPU. This validates the core
mechanism a real EMLXWhile primitive needs: read back a loop condition
scalar and re-invoke sub-computation natively in C++, once per
eval_program call, instead of round-tripping to BEAM per iteration.

Temporary: EMLXWhileSpike/:while_spike is not reachable from any real
Elixir lowering path yet, only from the accompanying hand-built-Program
test. Will be replaced by the real EMLXWhile primitive.
Add RefKind::Carry and an Instruction.subprograms field (decoded into a
new SubProgram struct) so a future native :while instruction can carry
self-contained cond/body instruction lists instead of splicing them into
the parent program. Round-trip validated by a NIF-level test asserting
the nested wire format decodes cleanly (reaching the "unknown op" check
for the not-yet-implemented :while op registry entry).

Checkpoint b of native while-loop lowering (see pv-feat/lower-while).
Add the EMLXWhile mlx::core::Primitive: interprets a :while instruction's
cond/body SubPrograms directly in C++, iterating until cond is false,
all inside a single eval_cpu call. Pinned to the CPU stream, matching
the pattern validated by the earlier de-risking spike. Each iteration's
carry is forced with mlx::core::eval() before feeding it back in, to
avoid unbounded graph growth across a data-dependent iteration count.

Refactors the flat instruction-list interpreter previously inlined in
compile_program_impl's lambda into a shared interpret_instructions
helper, reused for both a Program's top-level instructions and, via
EMLXWhile, a :while instruction's nested cond/body sub-programs.

Also extends compile_program_impl's op-name validation to recurse into
:while sub-programs so unknown ops there are caught at compile time
rather than eval time, and removes the now-superseded EMLXWhileSpike
de-risking spike (its class, op_registry entry, and test) per its own
"remove once the real EMLXWhile primitive lands" note.

Validated by two new NIF-level tests: a single-carry-slot counting loop
and a two-independent-carry-slot loop, both driving the primitive via a
hand-built EMLX.Native.Program (no Elixir lowering path emits :while
yet — that's checkpoint d).

Checkpoint c of native while-loop lowering (see pv-feat/lower-while).
expr.ex: when a :while's trip count can't be statically unrolled,
lower it directly to a native :while wire instruction (EMLXWhile,
checkpoint c) instead of raising, provided native_while_eligible?/2
holds -- no :runtime_call, hook, or nested :while anywhere in the
condition/body (side effects and untested nested-eval reentrancy are
out of scope for now; such loops still fall back to the host-driven
path). Condition/body lower into isolated internal sub-programs
(lower_while_subprogram/4) with fresh carry refs standing in for the
while's own parameters; to_native/1 converts them into wire
EMLX.Native.SubPrograms with their own local {:result, i} numbering
and {:carry, i} refs, seeded only from the outer program's shared
capture/const tables (never its inputs or results) -- structurally
enforcing Nx.Defn.while's closure rules rather than just trusting them.
The shared per-instruction wire conversion is factored out of to_native/1
into to_native_instructions/4, reused recursively for sub-programs.

emlx.ex: split_point?/1 now only treats a while as a graph-split point
when it's native-ineligible; build_eval_fn's bare-while branch is
gated the same way, so an eligible bare tail while also takes the flat
native path instead of the host-driven one. Ineligible loops (including
nested whiles, whose *inner* while becomes eligible once the outer
loop's host-driven re-entry compiles its body standalone) keep using
Nx.Defn.Graph.split + run_while_loop exactly as before.

Also fixes a pre-existing inefficiency where a top-level while with a
static trip count always went through the split/host-driven path
instead of expr.ex's existing unroller, since detect_static_while_trip_count
now gets a chance to run before falling through to the native/fallback
decision.

Validated by new unit tests (native_while_eligible?/2 true/false cases,
a direct assertion that a dynamic while lowers to one :while wire
instruction) plus the existing while/hook/runtime_call/nested-while
end-to-end suites, none of which needed changes to keep passing.

Checkpoint d (final) of native while-loop lowering (see pv-feat/lower-while).
The wire format, Elixir lowering, and C++ interpreter were already
written generically with no single-level-only assumption -- the only
thing blocking a while-inside-while from lowering to a nested
EMLXWhile primitive was the eligibility check's blanket exclusion.

Validated safe (2-, 3-, and 20-level nesting; default GPU device;
full existing suite) against checkpoint (a)'s reentrant-eval() spike,
which generalizes here since native call-stack depth scales with
lexical nesting depth, not iteration count. Added a defensive
64-level depth cap (advisor-recommended) purely to turn pathological/
generated deep nesting into a clean Elixir error instead of a native
stack overflow.
@polvalente polvalente self-assigned this Jul 6, 2026
@polvalente polvalente merged commit 695a92a into main Jul 6, 2026
5 of 6 checks passed
@polvalente polvalente deleted the pv-feat/lower-while branch July 6, 2026 14:27
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