You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Make the SM90 fused linear_logp CUDA operator the single deterministic owner of the selected-token log-probability computation, so that training-side scoring and rollout-side sampling record bitwise-identical FP32 selected-token logprobs at the same weight version.
Today the logp stack is aligned after the language-model head has already produced logits through two different projections:
training: Megatron materializes FP32 logits over its padded vocab shard ([rows, 76032], padded vocab 152064, real vocab 151936),
rollout: vLLM produces BF16 logits over the real vocab ([1, 151936]) and processed_logprobs are derived from them.
No amount of alignment downstream of those two projections can make the final logp bytes agree. The operator boundary must move before the projection: both engines should call the same linear_logp(hidden, lm_head_weight, target_ids, ...) CUDA op, which fuses the projection GEMM and the streaming vocabulary LSE and never materializes the [N, V] logits tensor on the training path.
This is the CUDA-side closure of the logp contract tracked by #241 and the drift reported in #329.
Background
#329 records the current failure mode for Qwen3-8B TP=2 CP=2 BF16 on 8xH100 (validation artifacts live on that machine; numbers below are from the linked report):
Readbacks show the RL-Kernel integration is active with no fallback, but both sides run backend = pytorch-vocab-parallel-logp-ws2 on top of different logits:
training : logits [256, 76032] float32 (padded vocab 152064, TP=2 local shard)
rollout : source_logits [1, 151936] bfloat16 (vLLM native)
and the rollout-side native-vs-RL-Kernel selected-logprob deltas are already nonzero before any training-side comparison:
The conclusion in #329 is correct: the selected-logp math is aligned, but the lm_head projection feeding it is not. This issue specifies what the deterministic projection+LSE operator must look like, and what has to change in the existing SM90 kernel to earn a strict bitwise contract.
Why the current operators do not qualify
The repo already has three linear_logp implementations and a batch-invariant logp over materialized logits:
Path
Files
Status
PyTorch reference
rl_engine/kernels/ops/pytorch/loss/linear_logp.py
F.linear (cuBLAS) + torch.log_softmax. cuBLAS is shape-keyed: a different M can select a different kernel, split-K count, and K-accumulation order. Cannot be made cross-shape deterministic without replacing the GEMM. Must be barred from strict mode.
Triton
rl_engine/kernels/ops/triton/loss/linear_logp.py
Streaming online-softmax over fixed vocab tiles with input_precision="ieee"; row-local, so batch-invariant in structure. Uses tl.sum in bit-relevant positions (compiler chooses the tree) and an implicit serial rescale chain across tiles. Not the performance path for SM90.
WGMMA/TMA fused projection + streaming softmax, FP32 accumulators, split-V partials + combine kernel. Closest to the goal, but the reduction-shape parameters are not pinned (details below), so its merge tree changes with batch size and it cannot yet carry a strict contract.
Operates on already-materialized logits, so it inherits whatever projection produced them. Solves a different boundary.
Specific defects in the SM90 kernel
n_split is batch- and occupancy-dependent.csrc/cuda/fused_linear_logp_sm90.cu (~line 1825):
int n_split = std::max(1, std::min(target_ctas / std::max(row_blocks, 1), total_vtiles));
The combine kernel then folds n_split partial (max, sum) states. When M changes, row_blocks changes, n_split changes, and the number of rescaled additions in the merge changes with it. This is a reduction whose shape depends on the batch — exactly the property a bitwise contract cannot tolerate.
In-tile sum reduction is an unpinned serial chain. The online-softmax epilogue accumulates tsum += __expf(val - tmax) in a plain loop and folds tiles with a running rescale (sSum * __expf(old_max - new_max) + tsum * __expf(tmax - new_max)). Serial-in-index-order is a legitimate choice, but it is currently an accident of the code, not a documented contract constant, and the transcendental choice (__expf vs expf) is likewise unpinned.
TP merge defers to torch.logsumexp / .sum(dim=0)._merge_tp_local_logp (pytorch/loss/linear_logp.py:266-293) gathers rank-local stats in rank order (good) and then reduces with torch built-ins whose internal tree is an implementation detail. The merge tree must be owned by the contract, not by the framework.
No temperature inside the op. Callers scale logits after materialization (ViME divides by rollout_temperature on the training side), while the rollout applies temperature inside its sampler. Two different rounding points.
No dual entry for serving. The rollout needs the FP32 logits row for sampling; the trainer needs only (logp, lse). Today these are different code paths with different materialization boundaries.
Design: the numeric contract
The operator computes, for each row n with final hidden state h ∈ R^D, vocab-parallel weight shard W ∈ R^{V_local × D}, optional bias b, and target id t:
z_v = <h, W_v> + b_v (FP32 accumulator, fixed K order)
m = max over vocabulary of z_v (order-free)
S = Σ_v exp(z_v − m) (explicit frozen tree)
lse = m + log(S)
z_t = selected logit (single owner)
logp = min(z_t − lse, 0) (boundary clamp)
Contract constants (bit-relevant; changes require a version bump)
Constant
Value
Rationale
VOCAB_TILE (vocab tile width)
64 (current BN)
Fixes which vocab columns share a tile summary and the in-tile tree width.
K_SLAB (K-direction slab)
32 (current BK)
Fixes the GEMM K accumulation order: K is walked left-to-right, one FP32 accumulator, no split-K, no atomics.
n_split policy
function of V only
Decouples the merge-tree shape from M, batch, padding, and occupancy.
in-tile sum tree
adjacent-pairwise balanced binary tree over the tile, then a sequential scalar chain across tiles in ascending index order
Every bit-relevant addition has exactly one association; nothing is left to the compiler or launch geometry.
cross-split merge
fixed-order fold over splits in ascending index order; global max first (order-free), then rescaled S and single-owner z_t
Deterministic combine independent of batch.
transcendental
one pinned choice (expf/logf) used by every entry point
__expf vs expf round differently; the contract picks one and both sides use it.
padded-vocab lanes
contribute exactly 0.0 to S and −inf to m; targets are masked to the real vocab
Padded 152064 vs real 151936 boundary stays bit-neutral.
temperature
applied in the epilogue to m/S/z_t (and thus logp), not to stored logits
Same rounding point on both sides; serving logits stay unscaled for the sampler.
output clamp
logp = min(z_t − lse, 0)
Keeps the p ≈ 1 boundary stable.
Contract identity: cuda-fused-linear-logp-sm90-contract-v1 (see provenance below).
Bit-relevant vs bit-neutral launch axes
Not every launch parameter must be frozen — only those that change the shape of a reduction:
Frozen (contract): vocab tile width, K slab width, n_split policy, reduction trees, transcendental choice, bias add point, temperature application point, padding semantics.
Free (performance): row-block size / BLOCK_M, CTA count along M, warp count, pipeline stages, shared-memory staging, L2 group swizzle — provided every candidate walks K identically and produces identical per-row bytes.
An M-bucketed launch table is expected (decode rows vs prefill batches), but it may only vary the free axes, and every bucket must pass the same golden gates. OOM fallbacks follow the same rule.
The sampler consumes the stored (unscaled) FP32 logits; the trainer consumes logp/lse directly. Memory and bandwidth for [N, V] are paid only where the row is actually needed.
Tensor parallelism: rank order + owned merge
Each rank computes rank-local tile summaries over its vocab shard with the same contract constants (the existing fused_linear_logp_sm90_global_target entry is the right place to grow this).
Partials are exchanged without in-transit reduction (all-gather), placed in rank order.
The global merge runs the same frozen tree over rank-ordered partials — owned by the CUDA op or a pinned helper, nottorch.logsumexp/.sum.
CP remains row ownership only. A vocabulary LSE must never be folded into a CP reduction (the provider boundary in the ViME integration already states this invariant; keep it).
Implementation plan
Kernel (csrc/cuda/fused_linear_logp_sm90.cu)
Replace the occupancy-derived n_split with a V-only policy; document it as a contract constant and assert it at launch.
Rewrite the in-tile sum as an explicit adjacent-pairwise tree (fixed width = VOCAB_TILE), keep the cross-tile fold as an ascending-index scalar chain; remove accidental reliance on thread-stride order.
Pin the transcendental (expf/logf) at every bit-relevant site (currently __expf in the streaming fold and combine).
Add the epilogue temperature path (scale stats + selected logit; leave stored logits unscaled).
Add return_logits dual entry; guarantee identical logp/lse bytes with the flag either way.
Extend the global-target/TP path with the rank-ordered owned merge kernel.
Embed the contract version in the extension API (argument or returned provenance struct).
Keep backward correctness (existing chunked/fused backward variants) — bitwise backward is explicitly out of scope.
Wrapper and registry (rl_engine/kernels/ops/cuda/loss/linear_logp.py, rl_engine/kernels/registry.py)
Backend id cuda-fused-linear-logp-sm90-contract-v1, reported with (arch, cc, VOCAB_TILE, K_SLAB, n_split_policy, tree_id, transcendental).
Replace _merge_tp_local_logp's torch.logsumexp/.sum with the owned merge.
Env knobs only for bit-neutral axes; any attempt to override a contract constant is rejected.
Integration (tracked here, landed with the ViME-side work)
Training: feed final hidden states + lm_head shard + targets to the op; stop materializing [T, V] logits on the logp path.
Rollout: compute selected logp through the same op at the same weight version (weight sync must guarantee byte-identical lm_head shards and record a weight-version fingerprint).
Relay sampling transforms (temperature → top-k → top-p → min-p) as a replayed program with pinned order on both sides — replay the transform program, not a mask.
Validation ladder
Each level must pass before the next; all comparisons are byte-exact on FP32 outputs.
Level
Check
Existing tests to extend
L1
Same program, same inputs, repeated calls → identical bytes
tests/test_deterministic_logp.py
L2
Vary M, batch position, padding, mixed batches, launch bucket → same rows byte-identical
Full training run: K3 = ρ − 1 − log ρ remains exactly zero for every token
validation report exact-zero metrics
Golden-value gates: per (arch, cc, contract version), freeze reference fingerprints produced under both engines' environments. Agreement is established by behavior (the golden replay), not by source identity, so the two sides may keep separate builds of the same arithmetic.
Acceptance criteria
Strict mode reports cuda-fused-linear-logp-sm90-contract-v1 active on both training and rollout, with no fallback observed.
L1–L3 byte-exact in CI; L4 reproduced on the 8xH100 validation topology (TP=2 CP=2, Qwen3-8B, BF16 hidden/weights, FP32 logp).
Pinning n_split removes an occupancy-adaptive knob; small-M decode may lose some parallelism. Mitigate with bit-neutral M-bucketed launches (more CTAs along M, unchanged V/K/trees).
The training path stops paying [N, V] materialization (for Qwen3-8B: ~152k floats/row) — expected net win on the trainer side.
Report decode tok/s and trainer scoring time against the current non-contract path; keep the non-strict lane available for throughput-critical, non-bitwise use.
Non-goals
Bitwise backward pass (forward logp bytes are what ρ and K3 consume; backward stays correct, not bit-frozen).
Summary
Make the SM90 fused
linear_logpCUDA operator the single deterministic owner of the selected-token log-probability computation, so that training-side scoring and rollout-side sampling record bitwise-identical FP32 selected-token logprobs at the same weight version.Today the logp stack is aligned after the language-model head has already produced logits through two different projections:
[rows, 76032], padded vocab152064, real vocab151936),[1, 151936]) andprocessed_logprobsare derived from them.No amount of alignment downstream of those two projections can make the final logp bytes agree. The operator boundary must move before the projection: both engines should call the same
linear_logp(hidden, lm_head_weight, target_ids, ...)CUDA op, which fuses the projection GEMM and the streaming vocabulary LSE and never materializes the[N, V]logits tensor on the training path.This is the CUDA-side closure of the logp contract tracked by #241 and the drift reported in #329.
Background
#329 records the current failure mode for Qwen3-8B TP=2 CP=2 BF16 on 8xH100 (validation artifacts live on that machine; numbers below are from the linked report):
Readbacks show the RL-Kernel integration is active with no fallback, but both sides run
backend = pytorch-vocab-parallel-logp-ws2on top of different logits:and the rollout-side native-vs-RL-Kernel selected-logprob deltas are already nonzero before any training-side comparison:
The conclusion in #329 is correct: the selected-logp math is aligned, but the lm_head projection feeding it is not. This issue specifies what the deterministic projection+LSE operator must look like, and what has to change in the existing SM90 kernel to earn a strict bitwise contract.
Why the current operators do not qualify
The repo already has three
linear_logpimplementations and a batch-invariant logp over materialized logits:rl_engine/kernels/ops/pytorch/loss/linear_logp.pyF.linear(cuBLAS) +torch.log_softmax. cuBLAS is shape-keyed: a differentMcan select a different kernel, split-K count, and K-accumulation order. Cannot be made cross-shape deterministic without replacing the GEMM. Must be barred from strict mode.rl_engine/kernels/ops/triton/loss/linear_logp.pyinput_precision="ieee"; row-local, so batch-invariant in structure. Usestl.sumin bit-relevant positions (compiler chooses the tree) and an implicit serial rescale chain across tiles. Not the performance path for SM90.csrc/cuda/fused_linear_logp_sm90.cu,rl_engine/kernels/ops/cuda/loss/linear_logp.pyrl_engine/kernels/ops/pytorch/loss/batch_invariant_logp.py,batch_invariant_logp_sm90Specific defects in the SM90 kernel
n_splitis batch- and occupancy-dependent.csrc/cuda/fused_linear_logp_sm90.cu(~line 1825):The combine kernel then folds
n_splitpartial(max, sum)states. WhenMchanges,row_blockschanges,n_splitchanges, and the number of rescaled additions in the merge changes with it. This is a reduction whose shape depends on the batch — exactly the property a bitwise contract cannot tolerate.In-tile sum reduction is an unpinned serial chain. The online-softmax epilogue accumulates
tsum += __expf(val - tmax)in a plain loop and folds tiles with a running rescale (sSum * __expf(old_max - new_max) + tsum * __expf(tmax - new_max)). Serial-in-index-order is a legitimate choice, but it is currently an accident of the code, not a documented contract constant, and the transcendental choice (__expfvsexpf) is likewise unpinned.TP merge defers to
torch.logsumexp/.sum(dim=0)._merge_tp_local_logp(pytorch/loss/linear_logp.py:266-293) gathers rank-local stats in rank order (good) and then reduces with torch built-ins whose internal tree is an implementation detail. The merge tree must be owned by the contract, not by the framework.No temperature inside the op. Callers scale logits after materialization (ViME divides by
rollout_temperatureon the training side), while the rollout applies temperature inside its sampler. Two different rounding points.No dual entry for serving. The rollout needs the FP32 logits row for sampling; the trainer needs only
(logp, lse). Today these are different code paths with different materialization boundaries.Design: the numeric contract
The operator computes, for each row
nwith final hidden stateh ∈ R^D, vocab-parallel weight shardW ∈ R^{V_local × D}, optional biasb, and target idt:Contract constants (bit-relevant; changes require a version bump)
VOCAB_TILE(vocab tile width)64(currentBN)K_SLAB(K-direction slab)32(currentBK)n_splitpolicyVonlyM, batch, padding, and occupancy.Sand single-ownerz_texpf/logf) used by every entry point__expfvsexpfround differently; the contract picks one and both sides use it.0.0toSand−inftom; targets are masked to the real vocab152064vs real151936boundary stays bit-neutral.m/S/z_t(and thuslogp), not to stored logitslogp = min(z_t − lse, 0)p ≈ 1boundary stable.Contract identity:
cuda-fused-linear-logp-sm90-contract-v1(see provenance below).Bit-relevant vs bit-neutral launch axes
Not every launch parameter must be frozen — only those that change the shape of a reduction:
n_splitpolicy, reduction trees, transcendental choice, bias add point, temperature application point, padding semantics.BLOCK_M, CTA count alongM, warp count, pipeline stages, shared-memory staging, L2 group swizzle — provided every candidate walks K identically and produces identical per-row bytes.An M-bucketed launch table is expected (decode rows vs prefill batches), but it may only vary the free axes, and every bucket must pass the same golden gates. OOM fallbacks follow the same rule.
Dual entry points, one arithmetic
The sampler consumes the stored (unscaled) FP32 logits; the trainer consumes
logp/lsedirectly. Memory and bandwidth for[N, V]are paid only where the row is actually needed.Tensor parallelism: rank order + owned merge
fused_linear_logp_sm90_global_targetentry is the right place to grow this).torch.logsumexp/.sum.Implementation plan
Kernel (
csrc/cuda/fused_linear_logp_sm90.cu)n_splitwith aV-only policy; document it as a contract constant and assert it at launch.VOCAB_TILE), keep the cross-tile fold as an ascending-index scalar chain; remove accidental reliance on thread-stride order.expf/logf) at every bit-relevant site (currently__expfin the streaming fold and combine).return_logitsdual entry; guarantee identicallogp/lsebytes with the flag either way.Wrapper and registry (
rl_engine/kernels/ops/cuda/loss/linear_logp.py,rl_engine/kernels/registry.py)cuda-fused-linear-logp-sm90-contract-v1, reported with(arch, cc, VOCAB_TILE, K_SLAB, n_split_policy, tree_id, transcendental).pytorch-vocab-parallel-logp-ws2pairing in [Bugfix] Close the Logp contract for Qwen3-8B TP=2 CP=2 strict bitwise consistency #329 slips through)._merge_tp_local_logp'storch.logsumexp/.sumwith the owned merge.Integration (tracked here, landed with the ViME-side work)
[T, V]logits on the logp path.Validation ladder
Each level must pass before the next; all comparisons are byte-exact on FP32 outputs.
tests/test_deterministic_logp.pyM, batch position, padding, mixed batches, launch bucket → same rows byte-identicaltests/test_batch_invariant_logp.py,tests/test_linear_logp.pylogp/lsetests/test_logprob_comparison.py,tests/test_alignment_model_wrappers.pyK3 = ρ − 1 − log ρremains exactly zero for every tokenGolden-value gates: per
(arch, cc, contract version), freeze reference fingerprints produced under both engines' environments. Agreement is established by behavior (the golden replay), not by source identity, so the two sides may keep separate builds of the same arithmetic.Acceptance criteria
cuda-fused-linear-logp-sm90-contract-v1active on both training and rollout, with no fallback observed.mismatch_count = 0,max_abs_diff = 0) at matched weight versions.n_split, tile trees, and transcendental choice are assert-pinned at launch; overriding them is impossible without a version bump.unclaimedto a readback-backed exact-zero claim, without weakening the attention/FFN boundaries ([Bugfix] Define Attention/FFN strict-contract acceptance for Qwen3-8B TP=2 CP=2 #330, [Perf] Add an operator drift/performance trace report for Attention, FFN, Logp, AG, and RS #331).Performance expectations
n_splitremoves an occupancy-adaptive knob; small-Mdecode may lose some parallelism. Mitigate with bit-neutral M-bucketed launches (more CTAs alongM, unchanged V/K/trees).[N, V]materialization (for Qwen3-8B:~152kfloats/row) — expected net win on the trainer side.Non-goals
Related