[DSv4][P5-3] shared_grouped_lora_delta: CUDA + Triton, both batch-invariant - #416
KJLdefeated merged 3 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
f43f341 to
197c908
Compare
197c908 to
b259aba
Compare
Implements the LoRA delta on the routed expert path (RL-Align#62): U = X @ A^T FP32 accumulate, round to BF16 dY = (U @ B^T) * a FP32 accumulate, FP32 output Backward follows the same graph and returns (dX, dA, dB); there is no dW, since the base weights are frozen. dY*alpha and dU round to BF16 between the GEMMs (contract D4) and the association order is frozen as written. Three providers, each overriding only this operator so the other eight stay on the oracle and the acceptance command runs end to end: LoRADeltaProvider torch-native benchmark baseline; not invariant LoRADeltaCudaProvider composed from _C.det_gemm_* LoRADeltaTritonProvider composed from TritonDetGemmOp Neither deterministic path adds a GEMM -- both compose the repo's existing fixed-K primitives, per the guidance that GEMMs route through the shared batch-invariant originals. The two differ in how close they stay to the oracle, and neither declares ORACLE_PROFILE. The Triton path holds one FP32 accumulator across the whole K loop and rounds once at the store, which is what _serial_dot does -- but the oracle adds one product at a time while this path adds one BLOCK_K=32 tile at a time, and the two parenthesisations agree only while nothing rounds at all. BF16 carries 8 mantissa bits, so a BF16xBF16 product needs at most 16 and lands in FP32's 24 exactly; while the partial sums stay inside that headroom the accumulation order cannot change a single bit. As K grows the accumulator climbs into the remaining bits and starts dropping low ones, so the agreement degrades rather than breaking at a threshold -- over 12 seeds at M=24 N=2048, byte-equal holds 12/12 up to K=512, then 11/12 at 1024, 9/12 at 2048 and 7/12 at 4096. The P5 fixture geometry is K=128 for fc1 and K=64 for fc2, comfortably inside the headroom, which is why check_p5.py passes. The gap at production width is FP32-epsilon sized, ~1e-6 relative on a handful of elements. The CUDA path reuses det_gemm's mid-split tree, which rounds to BF16 at every 32-wide leaf and so sits ~5e-3 from the oracle at all K. It buys something the Triton path lacks: a contiguous half-K shard is one child of the tree, so TP=2 reproduces TP=1 bitwise, which is likely what P5-8 needs. Batch invariance is a separate property -- it compares a backend against itself -- and holds for both at every probed shape, production width included. Batch invariance did not require avoiding tensor cores, which contradicts a reasonable first guess. tl.dot is deterministic once the tile shape stops depending on M; what matters is pinned BLOCK sizes (autotune would pick per-shape configs), no split-K, and allow_tf32=False. provenance() reports the reduction strategy per P5-6's fail-closed contract. Signed-off-by: Loring Ni <93922846+ScottNi0809@users.noreply.github.com>
Follows benchmark_det_gemm.py: reports overhead against a fair baseline (cuBLAS with TF32 disabled) rather than a speedup, since both deterministic paths disable TF32 by construction. Forward on RTX A2000 (sm_86), median of 5x100 iterations: shape M native fp32 Triton CUDA Triton ovh CUDA ovh decode_m1 1 0.200 0.248 0.205 1.2x 1.0x small_m8 8 0.176 0.233 0.201 1.3x 1.1x batch_m64 64 0.193 0.344 0.352 1.8x 1.8x batch_m256 256 0.214 0.240 0.718 1.1x 3.4x The Triton path stays within 1.1-1.8x of cuBLAS while being byte-equal to the oracle. The CUDA path degrades with M because det_gemm falls back to its naive scalar kernel below SM90; an SM90 runner would take the TMA path instead. Profiling explains the absolute numbers: at M=64 the two BF16 GEMMs cost 0.070 ms but the whole native forward costs 0.222 ms, so 68% of the wall clock is the elementwise .float() / *alpha / BF16-cast traffic rather than the matmuls. Traffic analysis agrees -- at X=[M,4096], N=2048, r=8 the arithmetic intensity is about r = 8 FLOP/byte and the split is ~66% reading X, ~33% writing Y, and only 0.26% for the U round trip. So fusing away the intermediate U is not where the headroom is; folding the scale and the cast into the GEMM2 epilogue is, and that is RL-Align#58 (Step 10), deliberately out of scope here. It also means small-M rows are launch-bound, which is why the TF32 column can land below the FP32 one -- the table should be read as relative overhead, not GEMM throughput. Signed-off-by: Loring Ni <93922846+ScottNi0809@users.noreply.github.com>
tests/test_p5_lora_delta.py -- 43 cases parametrised over the CUDA and Triton providers, each validated independently. The torch-native provider is excluded on purpose: it is the benchmark baseline and fails batch-invariance by design. Skips cleanly on CPU-only runners. Covers the five acceptance criteria from the P5-3 brief: golden vector base-only / LoRA-only / base+LoRA, via the oracle tamper fixture flipping a packed base byte must reach the output no dW frozen weights untouched, backward returns exactly 3 grads finite/nonzero the adapters actually receive signal fc1 and fc2 both insertion points, whose shapes differ plus return order and dtypes, chunked-prefill and padding invariance, run-to-run stability, and provenance naming the backend that really ran. Two tests bracket the Triton path's agreement with the oracle instead of claiming it outright: one pins the byte-equality at fixture width, the other pins the bounded divergence at production width and fails if that divergence ever disappears -- which would mean the docstring and the profile have gone stale. The second sweeps seeds rather than pinning one, since whether a given input diverges depends both on where its values fall relative to a rounding boundary and on which instruction tl.dot lowers to (MMA on Ampere, WGMMA on Hopper). scripts/check_p5_lora_delta_invariance.py -- 100 probes per backend (5 seeds x 5 sub-batch sizes x 4 shapes), reporting per-shape and per-boundary counts so a real pass can be told apart from a lucky one: backend invariant u y dX rerun torch-native 47/100 100 87 47 ok cuda det_gemm 100/100 100 100 100 ok triton det_gemm 100/100 100 100 100 ok Three findings shaped both files. Mutating four real bugs (swapped return order, dropped alpha, transposed dX, a GEMM replaced by torch.matmul) showed the invariance checks were running at the fixture's 128x64 geometry, where cuBLAS keeps one strategy across all M and a non-deterministic implementation passes unnoticed; production geometry (4096x2048) is now probed too. GEMM1 cannot carry an invariance check at all -- u is [M, 8], too narrow for cuBLAS to switch strategy. And dA/dB reduce over every row, so a sub-batch result is a different quantity rather than a slice; they are covered by the rerun check. The tamper test carries a control assertion that an untampered rerun is bitwise identical, so the not-equal check cannot pass on incidental noise. One flipped byte moves 494/3072 output elements by up to 2.2e-2. Signed-off-by: Loring Ni <93922846+ScottNi0809@users.noreply.github.com>
b259aba to
be2eef8
Compare
KJLdefeated
left a comment
There was a problem hiding this comment.
Well-developed PR, reusing current det_gemm operator is good. Overall clean and clear, just left some comment on minor issues. Happy to merge after resolving the requests! Good Work!
| y = _C.det_gemm_fwd_rhs_transposed(u_bf16, b).float() * float(alpha) | ||
| return y, u_bf16 |
There was a problem hiding this comment.
Here y become fp32, but we can can keep it in bf16. In real system, we would prefer bf16 input and output.
There was a problem hiding this comment.
Hi KJ, thanks for the review!
Makes sense as a production shape — the FP32 output doubles the bytes on a [M, 2*ffn] tensor, and it pairs with your fusion comment below.
The blocker is that I can't do it inside this PR alone: oracle.shared_grouped_lora_delta_fwd returns (y_fp32, u_bf16), and check_p5.py compares sha256 with zero tolerance, so a BF16 return diverges from the golden bytes. I measured the blast radius — switching it changes 7 of the 11 recorded boundaries on base_plus_lora, including swiglu_h and fc2_base, so it moves the golden bytes for P5-2 and P5-4 as well and the manifest needs regenerating.
Two things worth weighing if you do want it:
- The pipeline already casts at the end: y = (y_base + y_lora).to(torch.bfloat16) after summing base and LoRA. Casting inside the operator adds a rounding step before that sum — on base_plus_lora, 276/3072 elements move by up to 3.9e-3 relative.
- The pipeline itself won't break either way; z_base + z_lora promotes to FP32 regardless. Only the byte comparison changes.
Since it touches oracle.py and every sub-issue's golden bytes, it felt like your call rather than something to slip into P5-3.
Happy to send a follow-up switching all three providers plus the oracle in one go if you want the contract changed.
| u_bf16 = _C.det_gemm_fwd_rhs_transposed(x, a) | ||
| y = _C.det_gemm_fwd_rhs_transposed(u_bf16, b).float() * float(alpha) | ||
| return y, u_bf16 |
There was a problem hiding this comment.
For speed issue, I think we can fuse two gemm together because LoRA operation have little r rank (little K dimension in gemm), so it can fuse into one kernel to boost the performance. But this is non-blocking optimization, if you think it is hard to do it. It is totally OK.
There was a problem hiding this comment.
You're right that r=8 makes this a natural fusion candidate, and the profile backs it up: at these shapes everything is launch-bound — reading a [1,8] tensor costs about as much as reading [M,4096] — so the win would come from removing a kernel launch rather than from memory traffic. (The U round trip itself is only ~0.26% of the bytes moved.)
One constraint worth noting: u_bf16 is an input to the backward, so a fused kernel still has to write it out — it can save the read-back but not the store.
I did try it. The fused kernel is in my tree but not in this PR, because it lost: 0.79 ms vs 0.25 ms native at M=256, same run. My version was naive (one thread per output element, scalar K loop), so the saved launch was swamped by GEMM1 losing to cuBLAS tensor cores. A competitive version needs tensor cores for the [M,4096] read while keeping the pinned-tile / no-split-K schedule that batch invariance depends on, plus reproducing the oracle's rounding point for u.
Where I think it pays off most is when combined with the deferred epilogue-fusion step (Step 10 in the P5 sequencing): the same kernel would fold * alpha and the BF16 cast into the GEMM2 epilogue, and that is where the real headroom is — at M=64, 68% of the wall clock is elementwise dtype/scale traffic rather than the matmuls.
Happy to take that on as a follow-up if it's useful.
There was a problem hiding this comment.
OK I see, let's keep current state as it now. The fused kernel would be future features.
[DSv4][P5-3] shared_grouped_lora_delta — CUDA + Triton, both batch-invariant
Summary
Implements P5-3 (#62), the LoRA delta reused at both fc1 and fc2:
No
dW— base weights are frozen.dY*αanddUround to BF16 between theGEMMs (D4); every accumulation is FP32 and the association order is frozen.
LoRADeltaProvider(torch-native)LoRADeltaCudaProvider_C.det_gemm_*. Batch-invariant. New.LoRADeltaTritonProviderTritonDetGemmOp. Batch-invariant. New.tests/test_p5_lora_delta.pybenchmarks/benchmark_lora_delta.pyscripts/check_p5_lora_delta_invariance.pyEach provider overrides only this operator; the other eight stay on the oracle,
so the acceptance command runs end to end. Neither deterministic path adds a
GEMM — both compose the repo's existing fixed-K primitives.
Batch invariance did not require avoiding tensor cores.
tl.dotisdeterministic once the tile shape stops depending on M; what matters is pinned
BLOCK sizes (autotune picks per-shape configs), no split-K, and
allow_tf32=False.Acceptance criteria
check_p5.pyPASS, 7/7 casestest_tampering_packed_base_bytes_changes_the_outputdX/dA/dBmatch the FP32 reference; no base gradtest_matches_oracle_within_bf16_tolerance,test_base_weights_get_no_gradienttest_lora_grads_are_finite_and_nonzerotest_both_insertion_points[fc1/fc2]On (2): the LoRA path cannot unpack the base weight — its signature never
receives it,
shared_grouped_lora_delta_fwd(x, a, b, alpha), and the testasserts that. Behaviourally it flips one byte of
w1.codesand requires theoutput to move (494/3072 elements, up to 2.2e-2), with a control assertion that
an untampered rerun is bitwise identical so the check cannot pass on noise.
Batch invariance
scripts/check_p5_lora_delta_invariance.py -q— 100 probes per backend(5 seeds × 5 sub-batch sizes × 4 shapes, fixture 128×64 up to production
4096×2048), checking every boundary rather than only the final output:
Two things worth knowing about how this probe had to be built:
uis[M, 8]— too narrow forcuBLAS to switch strategy, so even
torch.matmulis invariant there. Onlyyand the backwarddXdiverge.the matrix is worth splitting, so mutating a deterministic backend back to
torch.matmulwent undetected at 128×64. Production geometry is now swepttoo. (
dA/dBreduce over every row, so a sub-batch result is a differentquantity rather than a slice; they are covered by the rerun column.)
Agreement with the oracle
Neither backend declares
ORACLE_PROFILE:The Triton agreement degrades with K rather than breaking at a threshold —
byte-equal runs out of 12 seeds, at M=24 N=2048:
This is a precision-headroom effect. BF16 carries 8 mantissa bits, so a
BF16×BF16 product needs at most 16 and lands in FP32's 24 exactly. While the
partial sums still fit inside that headroom nothing rounds at all, so the
oracle's term-by-term order and the op's
BLOCK_K=32tile order cannotproduce different bits. As K grows the accumulator climbs into the remaining
bits and starts dropping low ones — at which point the parenthesisation
matters, and whether a particular input crosses that line depends on its
values. The gap stays FP32-epsilon sized (~1e-6 relative, 1 element in 192 for
u), never a correctness error.check_p5.pypasses because it only exercises the fixture geometry(K=128 for fc1, K=64 for fc2). Two tests bracket this instead of overclaiming:
one pins the byte-equality at fixture width, the other pins the bounded
divergence at production width and fails if that divergence ever disappears.
The CUDA path's larger gap buys something the Triton path lacks: a contiguous
half-K shard is one child of det_gemm's tree, so TP=2 reproduces TP=1 bitwise.
That is likely what P5-8 needs, so it is left as is deliberately.
Benchmark
python benchmarks/benchmark_lora_delta.py --direction fwd(alsobwd,both). Median of 5×100 iterations after 20 warmups, ms:Overhead is against
native fp32(cuBLAS with TF32 disabled) — the fairbaseline, since both deterministic paths disable TF32. As in
benchmark_det_gemm.py, this is overhead, not speedup.The CUDA path degrades with M because det_gemm falls back to its naive scalar
kernel below SM90; an SM90 runner would take the TMA path.
Where the time actually goes: at M=64 the two BF16 GEMMs cost 0.070 ms but
the whole native forward costs 0.222 ms — 68% of the wall clock is the
elementwise
.float()/*α/ BF16-cast traffic, not the matmuls. Trafficanalysis agrees: at X=[M,4096], N=2048, r=8 the split is ~66% reading X, ~33%
writing Y, and only 0.26% for the U round trip. So fusing away U is not where
the headroom is; folding the scale and cast into the GEMM2 epilogue is, and
that is #58 (Step 10), out of scope here. It also means small-M rows are
launch-bound, which is why the TF32 column can land below FP32 — read the table
as relative overhead, not GEMM throughput.
Provenance
{ "requested_backend": "p5-3-lora-delta-triton", "actual_backend": "triton-det_gemm-composed", "numeric_profile": "triton-det-gemm-fp32-accum", "reduction": "pinned BLOCK 64/64/32, no split-K, no autotune, allow_tf32=False", "oracle_agreement": "bitwise at K<=512; ~1e-6 relative by K=4096" } { "requested_backend": "p5-3-lora-delta-detgemm", "actual_backend": "cuda-det_gemm-composed", "numeric_profile": "det-gemm-midsplit-tree", "reduction": "det_gemm mid-split k-tree, leaf=32, no split-K, no atomics" }Validation environment
RTX A2000 8GB Laptop (Ampere, SM86, cc 8.6) · driver 596.08 · nvcc 12.4.131
(conda
nvidia) · PyTorch 2.5.1+cu124 · Triton 3.1.0 · Python 3.10.21 ·gcc 9.4.0 · Ubuntu 20.04 (WSL2) ·
rl_engine._Cbuilt forsm_86Not SM90, so the CUDA column reflects det_gemm's scalar fallback rather than
its TMA path.
One question
numeric_profilehas no enforcement behind it: the start kit says a backendwithout equivalent capability "must register its own profile with an explicit
tolerance", but
_compareincheck_p5.pyonly diffs sha256 digests and neverreads the field. P5-3 lands exactly in that gap — neither backend can promise
byte equality at arbitrary K, so both declare their own profile and record the
observed bound in
provenance(). Fine as is if the field stays declarative;happy to propose a tolerance path in
_compareif it should be machine-checked.