DLRMv4: compute the last HSTU layer only for the rows the loss reads - #903
Open
jiaweche wants to merge 1 commit into
Open
DLRMv4: compute the last HSTU layer only for the rows the loss reads#903jiaweche wants to merge 1 commit into
jiaweche wants to merge 1 commit into
Conversation
The loss consumes one candidate row per sequence, so in the final HSTU layer every other row's output is discarded. This computes U and Q for that row alone against full-length K/V -- K and V stay full length because the candidate attends over the whole history -- which drops roughly 28% of three-layer FLOPs without changing what is computed. Exact in math, reordered in arithmetic: the surviving row is the same function of the same inputs, but its reductions run in a different order, so gradients are close rather than bit-identical. The dropout mask is drawn with the candidate's own RNG row indices, so the mask stream matches the full-length path rather than merely resembling it. Gated rather than assumed. Nine static conditions raise and name the blocker, since the knob is default-on and a silent fallback reads as "the optimization didn't help". Conditions that vary per batch -- a batch that is not one target per sequence, or eval when its own knob is off -- fall back and log once per distinct reason. Hardware without the separated-RNG dropout path also falls back rather than raising: below sm_100 the indexed mask does not exist, and that is not something a config change can fix. supports_indexed_output_dropout() is now the single predicate for that, used by both the gate and the kernel. The FLOPs counter needed splitting to report this honestly. It charged every layer at full length, which inflated HFU by ~28% exactly when the work was being removed. `fill` keeps its original ragged-vs-padded meaning; a new `exec` carries the discount and is what HFU divides by. MFU deliberately keeps the dense yardstick, so it RISES when this is on and must not be read as better utilization. Both knobs default on in yambda_5b.gin and are overridable by env var.
|
Thanks for proposing this optimization! I think it is legitimate and mathematically equivalent. It skips unnecessary computation for activations that would be discarded anyway. One minor note: I noticed a small tweak to the dropout implementation intended to preserve the mask as if dropout were applied to the full activation. This would be useful if the other skipped operations could also be made bit-matching, so that the entire optimization would be bit-matching. Otherwise, I do not see this as particularly useful. Under the MLPerf rules, optimizations should preserve high-level mathematical equivalence; bit matching is not required. |
Contributor
|
WG meeting discussion 9/3/26:
|
|
Looks good to me |
LinjianMa
approved these changes
Sep 3, 2026
jiaweche
marked this pull request as ready for review
September 3, 2026 17:12
pavanky
approved these changes
Sep 4, 2026
Contributor
|
@jiaweche can you please fix the CLA? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In the DLRMv4 HSTU stack the loss reads one candidate row per sequence, so in the final
layer every other row's output is computed and then thrown away. This PR makes that last
layer compute
UandQfor the candidate row alone against full-lengthK/V—KandVstay full length, because the candidate still attends over the whole history. Nothing theloss consumes changes.
On one MI350X at the production layer shape (512-dim transducer, 4 heads, qk and linear dim
128, bf16, batch 1024, 2.727 M tokens), the layer's forward+backward drops from
160.31 ms to 30.56 ms.
Opening as a draft: correctness and layer-level speedup are measured and reported below,
but a convergence run on
yambda-5bis still outstanding. Details inWhat is not yet validated.
Read First
The surviving row is the same function of the same inputs. Its reductions run in a different
order, so gradients are close rather than bitwise equal — the usual consequence of changing
tile shapes, not an approximation.
Dropout gets explicit care. The candidate row's mask is drawn using its own RNG row indices,
so the mask stream matches what the full-length path would have produced rather than merely
resembling it.
stu_targets_only_test.pyasserts the post-forward RNG state is exactlyequal between the two paths, alongside output and gradient agreement.
Gating
The rewrite is only valid under specific conditions, so it is checked rather than assumed,
and the failure mode is chosen per condition:
not causal, not target-aware, windowed attention, contextual prefix, recompute flags off,
inference). The knob is on for
yambda-5b, so a config that can never satisfy it is amistake worth surfacing — a silent fallback reads as "the optimization didn't help", and
nobody re-checks a knob that appears to be working.
is not exactly one target per sequence, or eval when its own knob is off.
the separated-RNG path (sm_100+ / MI350+). Below that it cannot be built, and no config
change can fix it, so raising would turn a default-on knob into a hard failure on those
GPUs.
supports_indexed_output_dropout()is the single predicate for this, used by both thegate and the kernel.
Off by default in code;
yambda_5b.ginturns it on for that benchmark. Both knobs areoverridable per-run by environment variable:
FLOPs accounting had to change
The counter charged every layer at full length. Left alone it would have inflated HFU by
roughly the size of the saving, exactly when the work was being removed. The metric is now
split:
fillkeeps its original meaning — ragged tokens over padded budget.execis new, and carries the targets-only discount. HFU divides byexec.and must not be read as improved utilization. Use HFU or ms/step for that.
Evidence
Parity —
modules/tests/stu_targets_only_test.py, 6 tests, all pass on one MI350X. Theload-bearing one checks forward output at the candidate row, every parameter gradient, and
exact RNG-stream alignment against the full path. The hardware-decline test asserts by
substitution, since that condition cannot be reached on hardware that has the separated-RNG
path.
No regressions —
layer_norm_test,fake_signature_test,hstu_compute_test, andstu_testgive 5 failed, 10 passed on this branch and 5 failed, 10 passed on pristinemaster: same five tests, same counts. Both pre-existing failures are unrelated to thischange (
stu_testhits a missingtorch.ops.fbgemm.asynchronous_complete_cumsum;hstu_compute_testfails a float32 tolerance on atraining=Falseexample, a path this PRdoes not touch).
Layer speedup — one MI350X, bf16, production layer config. The full arm backpropagates
from the candidate rows only, so both arms produce exactly the rows the loss reads; the full
arm's forward and backward still run at full length, which is the cost being removed.
The gain grows with sequence length because the removed work is the O(L²) attention term
while what remains is linear in tokens. At equal token count the ragged case costs the full
arm 17% more than uniform (sum of L² rises with length variance) while the targets-only arm
does not move.
End-to-end in the trainer — the
debugsynthetic dataset exercises the transducer wiring,the compact postprocess, and the accounting. All three arms train to completion (rc=0):
fill=87.3% exec=87.3%fill=81.3% exec=65.9%fill=83.7% exec=83.7%execdrops belowfillonly when the rewrite actually runs, so the discount cannot beclaimed spuriously.
What is not yet validated
Everything above is single-GPU, and either synthetic or layer-level. Two things are still
open, both needing the real
yambda-5bon 8 GPUs:last layer's share of total step time sets the actual number.
for a reference change, and it is why this is a draft.
I am running these now and will post results here. Review of the approach and the gating
design is welcome in the meantime.
Provenance
Ported from an AMD fork where this has been running as part of MI350X/MI355X benchmarking.
A companion optimization in that fork (
FAST_INTERIOR, an interior-tile maskingspecialization) is deliberately excluded here: it measured +0.02% end-to-end, which does
not justify its complexity in a reference implementation.
One difference from the fork is worth flagging for anyone comparing numbers. The fork reports
118.01 → 27.35 ms at the same 2.727 M tokens (4.31×). The targets-only arms agree closely
(30.56 vs 27.35 ms) since that path is Triton in both trees, but the full arms do not
(160.31 vs 118.01 ms) because the fork's baseline uses its own hand-scheduled backward. The
ratio here is larger than the fork's for an uninteresting reason: this baseline is slower, not
this optimized path faster.