Skip to content

Linkable runtime: fold a raised kernel's lane indices before the pattern list - #3434

Closed
wsmoses wants to merge 7 commits into
mainfrom
pb/xla-exec-folder
Closed

wsmoses wants to merge 7 commits into
mainfrom
pb/xla-exec-folder

Conversation

@wsmoses

@wsmoses wsmoses commented Oct 9, 2026

Copy link
Copy Markdown
Member

Stacked on #3429. Needs Enzyme-JAX with gather_scatter_constant_forward (EnzymeAD/Enzyme-JAX#3444) and gather_of_masked_scatter (EnzymeAD/Enzyme-JAX#3445) — the pattern list names them, so this waits for those and the bump.

A raised kernel's lane index arithmetic is over constant tensors (or/add/pad/select of constants, broadcasts), which the exec pattern list does not fold (its simplifications only drop zeros and ones) and StableHLO's folder only partly (no pad, splat broadcasts only) — and neither touches a constant or an op the raising marked (enzymexla.non_negative and the like). So a lane's scratch, stored and loaded through those indices, stayed a scatter and a gather on a tensor the lane owns, and once the list batched the lanes each access was a pass over memory: in mfem's 3D PA diffusion apply, 18 scatters and 63 gathers per element — most of fichera's GPU time.

Before the list, the exec pipeline now

  • strips the raising's marks from constants and from ops over constants only (stripMarksOnConstants),
  • runs three rounds of the list's constant propagation alternating with stablehlo-aggressive-folder, which also run gather_scatter_constant_forward, gather_of_masked_scatter and scatter_of_scatter_simplify: with the indices constant, the scratch is forwarded while the lanes are still a loop.

The loop-bound specialization (#3428) re-runs the same. REACTANT_EXEC_DUMP also prints each module after the pipeline now.

ex1 (p=3, PA) warm solve on an RTX 5090: fichera 0.46 s, from 0.60 s (native CUDA 0.13 s; the apply module 2.0 → 1.5 ms per call by NVTX); star unchanged. Residuals identical on star; on fichera they differ only past iteration ~230, at residuals ~1e-13, the same round-off profile the XLA build already has against native (fusion order). MFEM GPU suite with this (+ #3432 and the two Enzyme-JAX PRs): 74/74 (sweep final64, 2026-10-09).

🤖 Generated with Claude Code

https://claude.ai/code/session_016zErYp7upmqr4NHfhod9UD

wsmoses and others added 7 commits October 8, 2026 15:26
PJRT exposes no raw buffer-to-buffer copy, so cudaMemcpyDeviceToDevice
staged every copy through the host: a read back, a host buffer and a
write, which MFEM's conjugate gradient does several times an iteration.
A copy on the raw device pointers would not see the uses PJRT still has
in flight, so the copy is an executable through the exec path, which
orders it with the rest and addresses views and shared buffers as for any
kernel: one per size, cached under a stable module string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zErYp7upmqr4NHfhod9UD
…to it

A kernel's scalar argument reached the device as an allocation, a
synchronous copy of four or eight bytes (a full stream synchronization,
~50 us) and a free, several times per kernel. A small allocation now waits
for its first use: the host-to-device copy of the whole of it makes the
buffer in one call that does not wait (BufferFromHostBuffer, with the
copy as the buffer's definition event, so what runs after it waits), and
any other first use makes it uninitialized as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zErYp7upmqr4NHfhod9UD
…small host writes as bytes

Each kernel launch of the exec path ran its executable at once and took a
future for it, which PJRT fills through a callback on the compute stream;
the solver's dozen launches per CG iteration each paid that, and the
host's scalar writes (a kernel's coefficient) each became a device buffer
at once.

The launches now queue (up to 64, or until the host reads or writes a
buffer, which runs what is pending first), and run through
ExecuteSharded with fill_future=false: what runs on the device after an
executable is ordered by its buffers' events, and a copy to the host waits
for the buffer it reads, so nothing on the host waits for an executable.
An allocation freed while an executable using it is pending is kept, whole,
until that has run.

A small allocation (64 bytes or less) the host fills whole keeps its bytes
instead of making a buffer: the buffer is made when a queued executable is
run with it, and a read of it back comes from the bytes with no device
round trip (mfem's Vector::operator=(real_t) on a scalar, read back by the
host as a norm or a coefficient). An in-place write into part of an
allocation stays a staged buffer plus the copy executable.

ex1 (star, p=3, PA, 400 PCG iterations) warm solve: 0.37-0.41 s with
REACTANT_EXEC_UNROLL=8, from 0.45-0.5 s; the residuals are identical to the
synchronous runtime's on star and fichera.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zErYp7upmqr4NHfhod9UD
…tream executor client

On the GPU the exec path's kernels went through PJRT buffers: each launch
an ExecuteSharded with its argument holds, events and host callbacks on
the stream, each host write a buffer made from host memory, each read a
literal copy, each free a deferred release. A PJRT execute of a trivial
executable costs 17 us of host time against 5 us for the LocalExecutable
it wraps run directly (and the solver's kernels, with their arguments,
cost 40 us each), and the GPU ran 54 us of kernels per CG iteration of a
millisecond.

On a stream executor client the runtime now keeps raw device memory: an
allocation is the device allocator's memory, an executable is compiled
with the LocalClient and run with RunAsync on the compute stream over that
memory (a written argument donated, so its result is in place; should XLA
not keep it there, the allocation takes the result's memory), a
host-to-device copy goes through a pinned ring on the stream, a
device-to-host copy waits for the stream, a device-to-device copy is a
stream memcpy. The stream orders everything, so freed memory returns to
the allocator at once. No PJRT buffer, event or host callback per call.
Other clients (the CPU one) keep the PJRT buffer path;
REACTANT_EXEC_PJRT_BUFFERS=1 keeps it on the GPU too.

ex1 (star, p=3, PA, 400 PCG iterations) warm solve: 0.14 s, from 0.39 s
(native CUDA on the same device: 0.037 s); fichera likewise; the residuals
are identical to the PJRT path's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zErYp7upmqr4NHfhod9UD
…utes

A while loop whose bound the kernel computes from its inputs (the longest
row of a CSR structure, for mfem's ElementRestriction::MultTranspose) has
a trip count XLA does not know, so XLA reads the loop's predicate back to
the host every step: six synchronizations per CG iteration, and six runs
of the loop's body, three kernels each.

The generic executable now returns such bounds after its results (a
scalar integer the condition compares the induction variable against,
neither constant nor loop-carried, computed at the function's top level).
Once read, a specialization is compiled with the bounds as constants, the
exec path's optimization run again on it (REACTANT_EXEC_UNROLL unrolls
the loop, and the unrolled gathers fuse), and it runs instead for as long
as the inputs the bounds derive from are the allocations observed,
unwritten since (each allocation counts its writes); another observation
compiles another specialization, four at most. REACTANT_EXEC_NO_SPECIALIZE=1
turns it off.

Also, a small raw allocation freed (256 KB or less) is kept, by size, for
the next of that size: a solver allocates and frees the same scalars and
workspaces every iteration.

ex1 (star, p=3, PA, 400 PCG iterations) warm solve: 0.096 s, from 0.14 s
(native CUDA on the same device: 0.037 s); 11 kernel launches per
iteration, from 28, and no stream synchronization but the two dot
products'; the residuals are identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zErYp7upmqr4NHfhod9UD
Each allocation's handle was an address range mapped inaccessible for it
and unmapped at its free: a system call each, and the unmapping a TLB
shootdown across the compiler's threads -- the solver's dozen allocations
an iteration cost it a fifth of its time.

One range (1 TB, inaccessible, reserved at the start) is carved by size
class instead, a power of two of 4 KB at least, each class keeping the
handles freed for the next allocations of its size; a range of its own
only once the reservation is spent. A stray dereference still faults, and
pointer arithmetic on a handle still stays within its own range.

ex1 (star, p=3, PA, 400 PCG iterations) warm solve: 0.077 s, from 0.096 s
(native CUDA on the same device: 0.037 s); the residuals are identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zErYp7upmqr4NHfhod9UD
…ern list

A raised kernel's lane index arithmetic is over constant tensors
(or/add/pad/select of constants, broadcast), which the exec pattern list
does not fold (its simplifications only drop zeros and ones) and
StableHLO's folder only partly (no pad, splat broadcasts only) -- and
neither touches a constant or an op the raising marked
(enzymexla.non_negative and the like). So a lane's scratch, stored and
loaded through those indices, stayed a scatter and a gather on a tensor
the lane owns, and once the list batched the lanes each access was a pass
over memory: in mfem's 3D PA diffusion apply, 18 scatters and 63 gathers
per element, most of fichera's GPU time.

Before the list, the exec pipeline now strips the raising's marks from
constants and ops over constants only, and runs three rounds of the
list's constant propagation alternating with StableHLO's folder; with the
indices constant, gather_scatter_constant_forward, gather_of_masked_scatter
and scatter_of_scatter_simplify forward the scratch while the lanes are
still a loop. The specialization of loop bounds runs the same. Also,
REACTANT_EXEC_DUMP prints each module after the pipeline as well.

Needs Enzyme-JAX with gather_scatter_constant_forward (#3444) and
gather_of_masked_scatter (#3445).

ex1 (p=3, PA) warm solve: fichera 0.46 s, from 0.60 s (native CUDA:
0.13 s; the apply module 2.0 -> 1.5 ms per call); star unchanged at
0.076 s; the residuals are identical on star, and on fichera differ only
past iteration ~230, at residuals ~1e-13, as the XLA build's already do
from native's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zErYp7upmqr4NHfhod9UD
@wsmoses

wsmoses commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Closing: no separate pattern list in the runtime. The folding rounds this ran before the main list (constant propagation of lane index arithmetic, then gather_scatter_constant_forward / gather_of_masked_scatter / scatter_of_scatter_simplify) need to happen within enzymexlaGetTransformPassesList instead. Anything the main list lacks for that (e.g. folding add/and/or/pad of constants beyond the zero/one simplifications, or forwarding before batching) is a follow-up there.

@wsmoses wsmoses closed this Oct 12, 2026
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