Skip to content

lax.scan with stacked outputs: per-iteration cost grows linearly with T (quadratic total) #215

Description

@michaelellis003

Follow-up in the same area as #134 and #193/#196. The WhileLoopPrimitive from #134 fixed the graph-growth crash, but a scan that stacks outputs — which is how JAX collects MCMC samples, RNN hidden states, filter histories — now pays a per-iteration cost that grows with T, so total time is quadratic. A scan that carries the same state without stacking is flat.

MWE:

import time
import jax
import jax.numpy as jnp

def bench(f, *args):
    o = f(*args); jax.block_until_ready(o)
    t0 = time.perf_counter()
    o = f(*args); jax.block_until_ready(o)
    return time.perf_counter() - t0

x0 = jnp.linspace(-1.0, 1.0, 1_000_000)
for T in (25, 50, 100, 200):
    def no_stack(x, T=T):
        def step(c, _):
            return jnp.tanh(0.99 * c + 0.01), None
        c, _ = jax.lax.scan(step, x, None, length=T)
        return c
    def stack(x, T=T):
        def step(c, _):
            c2 = jnp.tanh(0.99 * c + 0.01)
            return c2, c2
        _, ys = jax.lax.scan(step, x, None, length=T)
        return ys[-1]
    a = bench(jax.jit(no_stack), x0)
    b = bench(jax.jit(stack), x0)
    print(f"T={T:4d}  no-stack {a*1e3:7.1f} ms   stacked {b*1e3:7.1f} ms"
          f"   stack cost/step {(b-a)/T*1e3:6.2f} ms")

Results (M3 Pro, jax-mps 0.10.9, safe dispatch):

T=  25  no-stack     1.6 ms   stacked   139.1 ms   stack cost/step   5.50 ms
T=  50  no-stack     2.0 ms   stacked   507.5 ms   stack cost/step  10.11 ms
T= 100  no-stack     3.4 ms   stacked  1986.7 ms   stack cost/step  19.83 ms
T= 200  no-stack     6.4 ms   stacked  7809.1 ms   stack cost/step  39.01 ms

Per-step cost doubles every time T doubles, so the total is ~T². The stacked output here is 100 × 1e6 × 4 B = 0.4 GB at T=100; ~2 s to produce it implies roughly 20 GB of traffic. On CPU the same MWE is linear.

Working hypothesis: the scan's output accumulator is a loop-carried value of the while body, and each iteration's stablehlo.dynamic_update_slice materializes a fresh copy of the whole (T, N) buffer instead of updating in place — MLX arrays are immutable and the primitive holds a live reference to the previous iteration's accumulator, so MLX's donation/in-place path can't engage. If that's right, the fix is buffer aliasing for loop-carried accumulators inside WhileLoopPrimitive, which is executor work rather than a lowering change. For MCMC specifically this compounds #196: chains of realistic length are currently dominated by this rather than by RNG.

Happy to help test a fix at large N/T; I have an M3 Pro doing particle-filtering workloads where this is the binding constraint.

jax:     0.10.2
jaxlib:  0.10.2
jax-mps: 0.10.9
Python:  3.13.9
macOS:   26.2 (25C56)
Chip:    Apple M3 Pro
Memory:  36 GB
Backend: mps

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions