Skip to content

[diffusion] Add the dynamic LoRA delta inside the second GEMM - #43501

Merged
mickqian merged 3 commits into
mainfrom
mick/diffusion-lora-dynamic-addmm
Oct 11, 2026
Merged

mickqian merged 3 commits into
mainfrom
mick/diffusion-lora-dynamic-addmm

Conversation

@mickqian

@mickqian mickqian commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

A dynamic (unmerged) LoRA computes its update as a full [tokens, out] tensor and then handles it in separate passes:

  • scales it by alpha / rank, by the adapter strength, and by the request's LoRA scale, one pass each (two of them multiply by 1.0 in the usual case);
  • adds it to the base output in a fourth pass;
  • for row-parallel layers, adds the bias in another pass.

On a fast GPU these memory passes cost more than the base GEMM next to them. At real layer shapes on B300, the adapters CI and MiniMax-H3 use add the following on top of the base linears:

  • Wan2.1 1.3B: +147%
  • Z-Image-Turbo: +107%
  • H3: +38%

This cost matters more now that --lora-merge-mode auto keeps adapters dynamic when a BF16 merge would round most of their update away (#43385), as with the H3 Turbo LoRA.

Modifications

  • quality="lossless" (the default). The second LoRA GEMM accumulates the update straight onto the base output (addmm, beta = 1), so the [tokens, out] delta is never materialized. Before that, the combined scale (alpha/rank × strength × request scale) multiplies the rank-wide intermediate x @ Aᵀ as a tensor op, not as addmm's alpha.
    • Why not alpha: the compiled forwards read the request scale as a symbolic float, which a keyword argument would freeze at its first value. CI caught exactly that in test_request_lora_scale_preserves_delta_offset_and_base.
    • Stacked q/k/v (or gate/up) adapters take one addmm per group, on column views of the output.
    • A row-parallel layer's bias goes into the base GEMM's epilogue, on rank 0, as RowParallelLinear does without LoRA.
    • An FP32 adapter on BF16 activations keeps FP32 GEMMs and adds once.
  • quality="exact". The reference chain is unchanged except that multiplications by a unit scale are skipped. x * 1.0 is exact, so the output stays the same bit for bit.
  • The four dynamic forwards (replicated, column-, row-parallel, nn.Linear) now share one _add_lora_delta, so the two tiers are implemented once.
  • The fused-kernels page lists the new request-gated fusion.

Accuracy Tests

  • Layer level, at the real shapes. Each layer kind the Cseti Wan, elusarca Z-Image and Larry H3 Turbo adapters touch, with their ranks, on B300:

    • At exact, every layer is torch.equal to main.
    • At lossless, every layer passes the lossless-tier admission gate (quality_tier_admission.assert_error_no_worse_than_reference) against the FP64 math, with main as the baseline.
    • The error never grows. Where the separate bias pass goes away it shrinks: Wan's output projection goes from RMS 2.34e-3 to 1.93e-3 and its FFN down-projection from 5.70e-3 to 4.67e-3.
  • New unit test. test_lora_dynamic_delta_in_gemm.py (CPU) checks:

    • the exact tier against the reference chain bit for bit, across unit and non-unit alpha, strength and request scale, for 2D and stacked adapters;
    • the lossless tier against the admission gate for 2D, stacked and FP32 adapters;
    • the row-parallel bias for both tiers;
    • that the default quality takes the lossless path.

    With it, the LoRA unit tests pass on B300 (91 passed).

  • End to end. MiniMax-H3 T2VA at Ulysses 2 with the Larry Turbo v4 LoRA, --lora-merge-mode dynamic, 9 steps, same seed:

    • The MP4 at quality="exact" is byte-identical to main (2fa46923e49d).
    • At the default quality the video is a different but equally sharp sample (33d18ed0e431, main gives 266717abee3e), as any lossless-tier change gives on H3.
    • CI's consistency cases pin exact, so their goldens are unaffected.

Speed Tests and Profiling

LoRA cost per DiT forward, B300. Each value is the sum over the layers the adapter touches of layer(x) minus the base linear alone, isolated CUDA-event timings:

Model (adapter rank, tokens) main this PR, exact this PR, lossless (default)
Wan2.1 1.3B 480p (128, 32760) +76.3 ms (+143%) +51.6 ms (+96%) +29.6 ms (+55%)
Z-Image-Turbo 1024² (32, 4352) +28.8 ms (+99%) +22.0 ms (+76%) +7.2 ms (+25%)
MiniMax-H3 T2VA, Ulysses 2 (64, 18871 per rank) +174.4 ms (+38%) +105.5 ms (+22%) +71.1 ms (+15%)

What remains in the lossless column is reading x again for the first LoRA GEMM and one read-modify-write of the output in the second. That is the floor for an update applied next to a cuBLAS GEMM.

End to end, 2×B300. MiniMax-H3 T2VA at Ulysses 2 with the Larry Turbo v4 LoRA, 9 steps, client latency of the two warm requests per arm:

--lora-merge-mode / quality main this PR
merge 13.40 / 13.39 s —
dynamic, default quality 14.59 / 14.62 s 13.99 / 13.79 s
dynamic, quality="exact" 14.62 / 14.60 s 14.40 / 14.19 s

At the default quality, a dynamic adapter now costs 0.5 s (+3.7%) over merged weights instead of 1.2 s (+9.0%).

The follow-up commit (scale on the rank-wide rows instead of alpha) leaves this adapter's output unchanged: its combined scale is 1.0, so the multiplication is skipped. A rerun after it gave the same MP4s at both qualities (33d18ed0e431, 2fa46923e49d) and 13.78 / 13.78 s at the default quality.

Checklist

🤖 Generated with Claude Code


CI States

Latest PR Test (Base): ✅ Run #38055503503
Latest PR Test (Extra): ❌ Run #38055503258
Latest PR Test (AMD ROCm 10): ❌ Run #38055503360

An unmerged adapter's update used to be materialized as a full
[tokens, out] tensor, scaled in up to three separate passes (alpha / rank,
strength, request scale) and added to the base output in a fourth, so on a
fast GPU the dynamic path cost more than the base GEMM it sits next to.

At the default quality ("lossless") the second LoRA GEMM now accumulates the
scaled update straight onto the base output (addmm, beta = 1) and rounds
once, and a row-parallel layer's bias moves into the base GEMM's epilogue as
RowParallelLinear already does. At "exact" the reference chain stays,
minus the multiplications by a unit scale, which are exact; its output is
unchanged bit for bit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mintlify

mintlify Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
sglang-doc 🟢 Ready View Preview Oct 10, 2026, 6:18 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@mickqian

Copy link
Copy Markdown
Collaborator Author

/tag-and-rerun-ci

@github-actions github-actions Bot added the run-ci CI: run the baseline test suite on this PR label Oct 10, 2026
mickqian and others added 2 commits October 10, 2026 15:44
Under torch.compile, the replicated and nn.Linear LoRA forwards read the
request's LoRA scale as a symbolic float. Passed as addmm's alpha keyword it
was frozen at its first value: on CI, after a call at scale 1.0, a request at
0.5 got the full update (test_request_lora_scale_preserves_delta_offset_and_base).
The scale now multiplies the rank-wide intermediate as a tensor op, which
compiled code keeps per call, as the reference path's `delta * scale` does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The in-GEMM delta read the request's quality from the forward context and
took a missing request for the default. Many stages set that context without
forward_batch, LTX-2's denoising among them, so LTX-2.3's two-stage HQ
pipeline ran its stage-2 distilled LoRA on the lossless path even for a
quality="exact" request, and CI's bit-exact golden for ltx_2_3_hq_pipeline
failed (37.5 dB). Such a forward now keeps the exact chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mickqian
mickqian merged commit 65386cb into main Oct 11, 2026
137 of 150 checks passed
@mickqian
mickqian deleted the mick/diffusion-lora-dynamic-addmm branch October 11, 2026 00:50
detain added a commit to detain/sglang that referenced this pull request Oct 15, 2026
…y-gated bias path

Merge sgl-project#4 kept our stack's pre-sgl-project#43501 class body and dropped upstream's new
forward override (split through the generic _forward_with_delta
decomposition, which always fuses the bias in the GEMM). That silently
changed exact-tier numerics (bias added after reduce, bit-for-bit reference
chain) and broke upstream's own test_row_parallel_bias_moves_into_the_gemm_
only_above_exact (new in sgl-project#43028) on every host, including GPU CI. Restores
the override verbatim (imports split_tensor_along_last_dim and
tensor_model_parallel_all_reduce) while keeping the stack's __init__,
rank-0-gated _add_lora_output_offset, and slice hooks. Test file now matches
origin/main and passes 25P/20S CPU.

(cherry picked from commit a600fb88f46b88cd599f6dd7925891ba1842a28a)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

diffusion SGLang Diffusion documentation Improvements or additions to documentation lora run-ci CI: run the baseline test suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant