Decide remove_broadcast_tiles per axis so dynamic shapes keep working - #91
Decide remove_broadcast_tiles per axis so dynamic shapes keep working#91kasper0406 wants to merge 2 commits into
Conversation
`remove_broadcast_tiles` gives up on nearly every broadcast tile in a dynamically shaped graph. Under symbolic shapes MIL mints a *fresh* symbol for a dynamic dimension at almost every op -- `fill(shape=concat(...))` in particular -- so the two operands of one elementwise op routinely carry different symbols (`is4` vs `dim_0`) for one and the same runtime dimension. `_consumer_output_is_unchanged` answered "does the consumer keep its output shape?" by re-broadcasting the operand shapes with `broadcast_shapes`, and a symbolic dimension is only ever provably equal to the identical symbol, so the re-broadcast returned `None` and the tile stayed. Concretely, `jnp.mean(x, -1, keepdims=True) * x` over a `(b, 8)` input keeps `tile(x=%real_div_0, reps=[1, 8])` -- a full `(b, 8)` materialisation of a `(b, 1)` tensor -- all the way into the exported model, and the same happens in a symbolic RMSNorm, LayerNorm and softmax. The re-broadcast was answering a harder question than the pass needs. Bypassing a tile only changes an operand on the axes the tile actually replicated, so the decision splits per axis: * `reps[axis] == 1`: the tile passes the axis through, so the operand's dimension there is literally the tile input's -- whatever either is called. No comparison needed, and this is where all the renamed symbols live. * `reps[axis] > 1`: the tile replicated a size-1 axis and bypassing it takes the operand back down to 1 there. The consumer's output only survives that if the other operand already carries the full size on the axis. `is_broadcast_tile` guarantees the replicated dimension is `1 * reps[axis]`, a literal int, so this comparison never involves a symbol on the tile's side either. That is strictly per-operand reasoning, so it does not care what the consumer's output shape is called. Static graphs are unaffected: re-running the flax/equinox LayerNorm, RMSNorm, GroupNorm, Linear and MultiHeadAttention probes gives byte-identical final op counts. Tests: a hand-built `mul` whose operands carry two different symbols for the same dimension, a lower-rank other operand, and two negatives (both operands size 1 on the replicated axis; the tile feeding both operands of one `mul`). End-to-end, `test_symbolic_broadcast_leaves_no_tile` converts the JAX spelling with `jax.export.symbolic_shape` and checks numerics at two concrete batch sizes. Claude-Session: https://claude.ai/code/session_01Q4T3UHepKPR65o5aiXEw5E Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`_BROADCAST_OPS` lists only elementwise ops, so a tile feeding a `matmul` is always kept. MIL's `matmul` does broadcast its batch dimensions natively -- `matmul((1, 4, 8), (2, 8, 4))` type-infers to `(2, 4, 4)` and predicts bit-identically to numpy on the runtime -- so `jnp.broadcast_to(x, (B, ...)) @ y` materialises the full batch for nothing. Supporting it is not a matter of adding one name to the set: unlike the elementwise ops, only a `matmul`'s leading axes broadcast while the trailing two are contracted, so it needs an axis rule of its own. None of the flax or equinox layers probed (`MultiHeadAttention` included) produce the pattern, so this is left as a strict xfail rather than fixed here. Claude-Session: https://claude.ai/code/session_01Q4T3UHepKPR65o5aiXEw5E Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
This lines up with something I hit downstream, and I think there is a complementary half worth folding in while this file is open.
Concretely, a whole-tensor cache write E5RT then fails to load the model: which is the same message the Two things I verified while chasing it, which may save you time:
I wrote that as a small pass ( It feels like it belongs next to this pass rather than downstream, since the two are the same problem from opposite ends: this PR stops removing tiles a One caveat on my end: I did not merge it downstream, because symbolic shapes turned out ~5.8x slower than our bucketed export (symbolic global caches cannot be Core ML states, so they revert to I/O). So the value is "RangeDim exports load again", not performance. |
remove_broadcast_tilesfires on static graphs but gives up on nearly every broadcast tile in a dynamically shaped one, leaving full-size materialisations of(b, 1)tensors in the exported model.Root cause
Under symbolic shapes MIL mints a fresh symbol for a dynamic dimension at almost every op --
fill(shape=concat(...))in particular -- so the two operands of one elementwise op routinely carry different symbols for the same runtime dimension. Converter output forx * rsqrt(mean(x*x, -1, keepdims=True) + 1e-6)over a(b, 8)input, with the symbols MIL assigned:dim_0,is1,is2,is4,is6are all the same dimension at runtime._consumer_output_is_unchangeddecided the tile by re-broadcasting the operand shapes:and
_broadcast_dimswill only equate a symbolic dimension with the identical symbol, sobroadcastcomes backNoneand the tile stays. Instrumented trace of the pass on that program:Final models on
main, runningbuild_pass_pipeline()over a(b, 8)input:mean(x, -1, keepdims=True) * xkeeps 1 tile, symbolic RMSNorm 1, symbolic LayerNorm 2, symbolic softmax 1. Each one is a full(b, 8)materialisation of a(b, 1)tensor.Fix
The re-broadcast was answering a harder question than the pass needs. Bypassing a tile only changes an operand on the axes the tile actually replicated, so the decision splits per axis:
reps[axis] == 1-- the tile passes the axis through, so the operand's dimension there is literally the tile input's, whatever either is called. No comparison needed, and this is exactly where all the renamed symbols live.reps[axis] > 1-- the tile replicated a size-1 axis, and bypassing it takes the operand back down to 1 there. The consumer's output only stays the same if the other operand already carries the full size on that axis.is_broadcast_tileguarantees a replicated dimension is1 * reps[axis], i.e. a literal int, so this comparison never involves a symbol on the tile's side either.That is per-operand reasoning, so it never has to name the consumer's output shape. The soundness argument is unchanged from before: on untouched axes the operand is identical, and on replicated axes
broadcast(other, reps[axis])andbroadcast(other, 1)both come out asother == reps[axis].After the fix the symbolic-shape finals carry no tiles at all (
mean * x, RMSNorm, LayerNorm, softmax: 1, 1, 2, 1 tiles -> 0).Static graphs are unaffected. Re-running the flax/equinox probes (
LayerNorm,RMSNorm,GroupNorm,Linear,MultiHeadAttention,jnp.broadcast_to, implicit rank-broadcasting,jnp.wheremasks) gives identical final op counts before and after -- includingflax_groupnorm's two surviving tiles, which feed areshaperather than an elementwise op and are correctly still kept.Known gap left as an xfail
_BROADCAST_OPSlists only elementwise ops, so a tile feeding amatmulis always kept. MIL'smatmuldoes broadcast its batch dimensions natively --matmul((1, 4, 8), (2, 8, 4))type-infers to(2, 4, 4)and predicts bit-identically to numpy on the runtime -- sojnp.broadcast_to(x, (B, ...)) @ ymaterialises the full batch for nothing. Supporting it is not a matter of adding one name to the set: unlike the elementwise ops, only amatmul's leading axes broadcast while the trailing two are contracted, so it needs an axis rule of its own. None of the flax or equinox layers probed produce the pattern, so it is astrict=Truexfail here rather than a fix.Tests
Unit (hand-built MIL):
test_removed_when_the_operands_carry_different_symbols-- amulwhose operands are(batch, 8)andtile((renamed_batch, 1), reps=[1, 8]), the shape the converter actually produces. Fails onmain.test_removed_when_the_other_operand_has_a_lower_rank.test_not_removed_when_the_other_operand_lacks_the_replicated_size(both operands size 1 on the replicated axis, so the output would shrink) andtest_not_removed_when_the_tile_is_both_operands.test_batch_broadcast_ahead_of_matmul_is_removed-- strict xfail, documenting the gap above.End-to-end:
test_symbolic_broadcast_leaves_no_tile--jnp.mean(x, -1, keepdims=True) * xexported withjax.export.symbolic_shape("(b, 8)"), numerics checked against JAX at two concrete batch sizes, asserts notilesurvives. Fails onmain.Verification
Full suite on this branch:
430 passed, 1 skipped, 1 xfailed(python -m pytest tests/).ruff check .clean.Also confirmed while auditing: the pass does sit before the first
common::const_eliminationin the assembled pipeline (indices 10 vs 14), so the module docstring's claim about tiled scalar constants holds; and re-running the pass at the end of the pipeline on the flax/equinox models changes nothing, so its position is not starving it either.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q4T3UHepKPR65o5aiXEw5E