Remove the explicit-sharding LM head transpose via lm_head_kernel_transposed - #5091
Remove the explicit-sharding LM head transpose via lm_head_kernel_transposed#5091NuojCheng wants to merge 1 commit into
Conversation
…nspose Under `shard_mode: explicit`, JAX's `lower_with_sharding_in_types` annotates every sharding-in-types primitive's output with a `custom_call_target="Sharding"` op. One of those lands between the LM head weight-gradient `dot` and the `transpose` that follows it, which acts as an optimization barrier: XLA's algebraic simplifier can no longer fold `transpose(dot(A, B))` into `dot(B, A)`. The fold window closes for good at the partitioner, so the gradient materializes in the wrong orientation and XLA pays for it twice -- a lost all-reduce -> reduce-scatter fusion, and relayout copies inserted on the entry parameters. Fix the orientation at the source instead of relying on the fold. `DenseGeneral` gains a `kernel_transposed` option that stores the kernel as `out_features + in_features` and contracts on its trailing axes. The forward matmul is mathematically unchanged, `kernel_axes` is permuted alongside the shape so callers keep passing logical axes in the usual `in..., out...` order, and autodiff now produces the weight gradient already in the kernel's own orientation -- no transpose to fold. The untied LM head opts in via `lm_head_kernel_transposed` (default false), on both the linen and nnx decoder paths. Measured on v5p-8, 2 reps per configuration: config auto explicit explicit+flag penalty -> penalty dp4 4430.2us 4844.0us 4420.1us +9.34% -> -0.23% emb4096 72200.8us 73330.0us 72601.8us +1.56% -> +0.56% The acceptance test passes on both: the `f32[emb/n, 32000]` relayout copies drop from 6 to 0, matching auto, and the entry layout triple flips from `f32[1024,32000]` to `f32[32000,1024]`. It defaults off because it also changes the on-disk checkpoint orientation, so `auto` gets the transposed kernel too, where it regresses (+3.96% llama2-fsdp4, +1.91% mixtral, +0.98% qwen3). A follow-up should keep the checkpoint at `[embed, vocab]` and transpose at restore time only under explicit sharding, at which point this can default on. `quant`, `slice_bounds` and `logits_via_embedding` are rejected rather than silently mishandled. The Tunix vLLM sharding map is updated for the flipped kernel because both specs are rank 2 and would otherwise mis-shard silently; the static vLLM converter rule table has no config to key on, so that combination is documented as unsupported. Analysis behind the change is in docs/guides/optimization/shard_mode_performance.md.
There was a problem hiding this comment.
Code Review
This pull request introduces a detailed performance analysis guide comparing explicit and automatic sharding modes, and implements a performance optimization via the lm_head_kernel_transposed configuration. This option allows storing the untied LM head kernel as [vocab, embed] instead of [embed, vocab], which prevents an expensive transpose operation under explicit sharding. The changes update DenseGeneral to support the transposed kernel layout, add corresponding configuration validation, adjust integration mappings for Tunix and vLLM, and include comprehensive unit tests. The single review comment regarding a date typo in the documentation has been filtered out, leaving no further feedback to provide.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Description
Adds
lm_head_kernel_transposed(defaultfalse), a config flag that stores the untied LM head kernel as[vocab, embed]instead of[embed, vocab]. This removes the single largest source of theshard_mode: explicitperformance penalty. On a 4-way-DP v5p-8 configuration it takes explicit mode from +9.34% slower than auto to −0.23% — i.e. slightly faster than auto — with the same numerics.Why
shard_mode: explicitis measurably slower thanshard_mode: autoacross the onboarded models. Tracing it through the HLO, the root cause is a missed algebraic simplification, not a communication-schedule difference.On an all-
AxisType.Explicitmesh, JAX'slower_with_sharding_in_types(jax/_src/interpreters/mlir.py) annotates the output of every sharding-in-types primitive (~60 of them) with acustom_call_target="Sharding"op. It has no equality short-circuit, so it emits the annotation even when the sharding is unchanged. This is a JAX lowering behaviour — it is not caused by anywith_sharding_constraint/reshard/out_sharding=pin in MaxText. (I verified this: removing theout_sharding=pin on the logits produces a 0-line HLO diff, and 0 of the 1,276Shardingcustom calls in the module originate from it.)One of those
Shardingcalls lands between the LM head weight-gradientdotand thetransposethat follows it. XLA's algebraic simplifier normally foldstranspose(dot(A, B)) → dot(B, A), but the custom call sits in the middle and acts as an optimization barrier, so the fold never fires. The window matters:algsimpruns insimplification-1(passes 0008–0009), while the partitioner runs at 0012–0016 — once the module is partitioned the fold is permanently unavailable.The unfolded transpose costs two things:
emb=512configurations.[V, embed]while the parameter is[embed, V], so layout assignment inserts relayout copies on the entry parameters. Sixf32[emb/n, 32000]copies, on every configuration measured. This is the dominant, universal cost.The fix
Rather than trying to make the fold fire through the barrier, store the kernel in the orientation the gradient naturally comes out in.
DenseGeneralgains a general-purposekernel_transposedoption:out_features + in_featuresanddot_generalcontracts on its trailing axes.dot_generalemitsbatch + lhs-free + rhs-freedims, so the output shape and order are unchanged.kernel_axesis permuted alongside the shape, so callers keep passing logical axes in the usualin..., out...order andsharding=keeps describing the same axis it did before.The untied LM head opts in via
lm_head_kernel_transposed, wired through both the linen (decoders.apply_output_head) and nnx (nnx_decoders) paths.Results
v5p-8, 2 reps per configuration, step time in µs:
[E,V][V,E]That is −8.75% and −0.99% off explicit-mode step time, recovering 102% and 64% of the gap respectively.
The structural acceptance test passes on both: the
f32[emb/n, 32000]relayout copies drop from 6 to 0 (matching auto), and the entry layout triple flips fromf32[1024,32000]tof32[32000,1024].Why it defaults to
falseThe flag changes the on-disk checkpoint orientation, which means
automode gets the transposed kernel too — and there it regresses, because in auto mode the fold does fire and the original orientation was already optimal: +3.96% llama2-fsdp4, +1.91% mixtral, +0.98% qwen3, +0.46%/+0.35% at emb 2048/4096. Undermin(auto, explicit)that is a net loss in 5 of 7 configurations, so it would be wrong to flip the default now.Follow-up: a restore-time transform that keeps the checkpoint canonically at
[embed, vocab]and transposes on load only whenshard_mode: explicit. That decouples the two modes, at which point this can default on for explicit without touching auto. I'd like to land the mechanism first so the follow-up is a small change.Shortcomings / known limitations
params-decoder-logits_dense-kernel, so it cannot be flipped on an existing run without transposing that array. Called out inbase.yml, in thetypes.pyfield description, and in theDenseGeneraldocstring.quant(the LM head matmul is never quantized anyway),slice_bounds(it slices the trailing output-feature axis, which a transposed kernel keeps at the front), andlogits_via_embedding: true(there is no untied head to transpose) — the last enforced by a pydanticmodel_validator.(None, "model")and new("model", None)specs are rank 2, so getting it wrong would mis-shard silently instead of raising.integration/vllm/weight_converter.py):MODEL_TO_CONVERSION_RULESis looked up without a config at all four call sites, so it cannot be made config-aware cheaply. The combination is documented as unsupported in a comment rather than silently producing wrong weights.Analysis doc
The full investigation — HLO pass-pipeline walkthrough, xprof attribution, the AR→DS fusion threshold, per-model measurements, and the alternatives considered — is added as
docs/guides/optimization/shard_mode_performance.md, linked from the optimization guide grid and toctree.Tests
Unit tests — 7 new tests in
tests/unit/linears_test.pycovering thekernel_transposedmechanism:test_kernel_transposed_shape_and_axes— kernel shape andkernel_axesare both permutedtest_kernel_transposed_forward_matches— forward output is bit-identical to the default orientation given transposed weights (subtests over scalar and(2, 8)output shapes)test_kernel_transposed_gradient_matches— gradient is bit-identical modulo the transposetest_kernel_transposed_bias— bias shape/axes track the flipped kerneltest_kernel_transposed_init_statistics_match—fan_inis computed over the same axes, so init std is unchanged (0.24941 both ways)test_kernel_transposed_rejects_quantization,test_kernel_transposed_rejects_slice_boundsEnd-to-end — v5p-8, the numbers in the table above, plus the HLO acceptance check on relayout-copy count and the entry layout triple. Config validation verified in both directions (
logits_via_embedding=True+ the flag raises; the flag alone flips the kernel to(256, 128)through the real model init).Lint —
pyink --check --pyink-indentation=2 --line-length=122clean on all touched Python;pylint --disable=R0401,R0917,W0201,W0613clean (the two remainingtypes.pyfindings are pre-existing onmain);mdformat --check --numberclean on both docs.Checklist
gemini-reviewlabel.