feat: --trim-loss-positions (compute teacher/logits/loss only at supe… - #705
feat: --trim-loss-positions (compute teacher/logits/loss only at supe…#705julyanghar wants to merge 2 commits into
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces the --trim-loss-positions feature to optimize memory and compute by calculating the teacher target_p, draft logits, and loss only at supervised positions for batch size 1. It also includes an equivalence test to verify that this optimization does not alter the training loss. The review feedback suggests several performance and idiomatic improvements in PyTorch, such as using F.softmax instead of instantiating nn.Softmax in a loop, vectorizing tensor operations via broadcasting and unbind(1), utilizing logits.new_tensor for cleaner device allocation, and ensuring temporary test directories are properly cleaned up.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| tm = t2d[ids][..., None].int() | ||
| pms.append(tm * loss_mask[:, s : s + row_chunk]) | ||
| dth = t[..., t2d] | ||
| tps.append(nn.Softmax(dim=2)(dth).detach()) |
| shifted = torch.cat( | ||
| [sup + j for j in range(length + 1)] | ||
| ) # steps j=0..k (k included to align with pad length) |
| full_map = torch.full( | ||
| (L + length + 1,), n_real, dtype=torch.long, device=sup.device | ||
| ) | ||
| full_map[real] = torch.arange(n_real, device=sup.device) | ||
| idx_steps = [full_map[sup + j] for j in range(length)] |
There was a problem hiding this comment.
Instead of performing length separate indexing operations in a loop (which launches multiple GPU kernels), you can perform a single 2D advanced indexing operation and then split the result using unbind(1). This is significantly more efficient.
| full_map = torch.full( | |
| (L + length + 1,), n_real, dtype=torch.long, device=sup.device | |
| ) | |
| full_map[real] = torch.arange(n_real, device=sup.device) | |
| idx_steps = [full_map[sup + j] for j in range(length)] | |
| full_map = torch.full( | |
| (L + length + 1,), n_real, dtype=torch.long, device=sup.device | |
| ) | |
| full_map[real] = torch.arange(n_real, device=sup.device) | |
| idx_steps = list(full_map[sup.unsqueeze(1) + torch.arange(length, device=sup.device)].unbind(1)) |
| loss_denom = torch.tensor( | ||
| logits.shape[0] * logits.shape[1], | ||
| logits.shape[0] | ||
| * (full_positions if full_positions is not None else logits.shape[1]), | ||
| device=logits.device, | ||
| dtype=torch.float32, | ||
| ) |
There was a problem hiding this comment.
Using logits.new_tensor is more idiomatic and efficient than torch.tensor(..., device=logits.device) as it automatically inherits the device and other properties of the existing tensor.
loss_denom = logits.new_tensor(
logits.shape[0]
* (full_positions if full_positions is not None else logits.shape[1]),
dtype=torch.float32,
)| ) | ||
|
|
||
| H, V, SEQ, TTT = fx.H, fx.V, 16, 3 | ||
| workdir = tempfile.mkdtemp(prefix="equiv_trim_") |
There was a problem hiding this comment.
The temporary directory created by tempfile.mkdtemp is never cleaned up, which can leak disk space. Use self.addCleanup with shutil.rmtree to ensure it is automatically deleted after the test runs.
| workdir = tempfile.mkdtemp(prefix="equiv_trim_") | |
| import shutil | |
| workdir = tempfile.mkdtemp(prefix="equiv_trim_") | |
| self.addCleanup(shutil.rmtree, workdir) |
|
Addressed 4 of the 5 in f31dce1:
I did not apply the
Re-ran the GPU equivalence test after the index-math vectorization — per-step loss |
|
Rebased onto current The rebaseThe unified-runtime refactor moved both files this PR touched: The bug I found while portingThe original patch assumed the supervised-row set is shared across all TTT steps, with the teacher sliding (row The loop applies The rows shift down by one per step; the teacher positions stay put. My original code had it inverted (rows fixed, teacher sliding), which is correct at step 0 and wrong from step 1 onward. Measured on a small fixture, old code vs the full-length path:
fp32 and bf16 give the same error, so it was a semantic bug, not numerical noise. (Step 0 matched exactly in every case, which is the signature of the per-step shift.) Why my earlier checks missed it: my equivalence self-check compared the trimmed path against a reference that used the unshifted full-length mask — the reference shared the same wrong assumption, so both sides agreed. A self-check whose reference shares the bug can't detect it. The end-to-end test I had did compare against the real full path, but on data where the shift only moved a contiguous block's boundary by one position, so the difference stayed under the tolerance. After the fixRows now follow
Plus 27 existing eagle3 runtime tests and 50 config tests pass; the off path (flag defaults to off) is unchanged. On the memory numbers I quoted earlier: those were measured on the pre-refactor code path, with the old row selection. The fix changes which rows are selected, not how many — the old version used Sorry for shipping that first version. Flagging it now rather than after you'd spent review time on it. |
|
It's a good idea to reduce VRAM usage in Long context training of eagle3. But now the long context eagle3 training needs sequence parallel so It is important to verify the compatibility of the current method and sequence parallel. You can examine the compatibility with sequence parallelism and the corresponding gains. For instance, the performance boost observed with a ring size of 4 at a sequence length of 64k. |
…vised positions
On prompt-heavy training data (agent / RAG traces, long-context SFT) most positions
are masked out of the loss, yet the teacher target_p, the draft logits and the loss
are still materialized over the full sequence at every TTT step. This adds
`training.trim_loss_positions`, which restricts that work to the rows that can
actually carry loss and rescales the mean denominator, so it is mathematically
equivalent to the full-length path.
Per-step row set. The loop shifts position_mask / loss_mask by one position per TTT
step (padding(..., left=False)), while step_view slices the padded teacher so row p
is supervised by the teacher at p + j. A row therefore contributes at step j iff
p + j is supervised, i.e. rows = {s - j : s in sup, s >= j}, with the teacher and
mask pinned at the supervised positions s. The teacher is consequently evaluated
once at sup — no sliding window needed.
- Off by default; falls back to the full-length path for batch > 1, an lk_loss
objective, or when there are no supervised positions. The off path is unchanged.
- Adds a GPU equivalence test asserting per-step loss with trimming on == off.
Verified: with an all-supervised mask and with a half-masked (prompt-heavy) mask,
in both bf16 and fp32, the per-step losses match the full-length path to <= 1.1e-7
relative. 27 existing eagle3 runtime tests and 50 config tests still pass.
Trim previously bypassed the USP adapter's step_view: the backbone got the
whole local buffer (chunk + ttt overlap tail) while position_ids stayed at
usp_chunk_size (shape error / corrupted ring attention), overlap-tail rows
emitted loss on two ranks, and the loss denominator used local_len instead
of usp_chunk_size. Nothing guarded the combination.
Now the backbone runs exactly the full path's inputs under USP; trimming
applies only at the teacher/logits/loss stage: tail positions may act as
teachers for own-chunk rows at deeper TTT steps but never emit loss rows
(rows_j = {s-j : s in sup, j <= s, s-j < usp_chunk_size}), the denominator
is usp_chunk_size, and a step with no reachable rows contributes exactly
zero loss via one zero-masked dummy row so kernel launches and collectives
stay aligned across ranks (including mixed trim/full ranks). chunk_len
derives from the hidden-state length (the same source the full path uses),
not loss_mask, which offline pipelines pad by one.
Verified: per-step loss parity vs the full path at 4 and 8 ranks
(ulysses2xring2, ring4, ring8, ulysses2xring4) across adversarial masks
including all-supervised, rank-boundary-straddling, and tail-only (dead
steps + mixed-path ranks); plus an oracle suite with hand-derived row
tables, an analytic closed-form loss, and NumPy re-derivation from raw
feature files. tests/test_runtime/test_equiv_trim_usp.py keeps a CPU-only
golden layer running even without GPUs. On 48GB cards at Llama3-8B dims,
ring4/64k and ring8/128k OOM on the full path but run under trim at
~40GB/rank; at smaller lengths trim saves 0.7-2.9GB/rank and is 6-13%
faster per step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Great call asking about sequence parallelism — I dug in, and the honest answer is: as previously written, trim was NOT compatible with USP (it crashed, and in configurations where it didn't crash it would have been silently wrong). I've now made it SP-native, verified equivalence on 4-rank USP including pure ring-4, and measured the gains. Details below. What was wrongThe trim branch bypassed the USP adapter's
And nothing guarded the combination — The fix (SP-native trim)The backbone now runs exactly the full path's inputs under USP (chunk-sliced hidden/input_ids/attention-mask, adapter-owned position_ids); trimming applies only at the teacher/logits/loss stage:
Each (teacher-position, TTT-depth) loss term is computed exactly once globally. Equivalence evidence (4 ranks, real offline pipeline)Per-step losses, trim ON vs OFF on identical inputs, via the actual offline strategy path (
Backward is covered too: total grad-norm trim vs full agrees within 5e-3 (calibrated against flash-attn's own backward nondeterminism floor). As a fixture sanity check, the mean over ranks of full-path per-step losses reproduces a single-GPU full-sequence reference on the un-sharded sample. A negative control run — the same experiment against the previous trim code — fails immediately (the shape error above), so the test discriminates. Gains at ring 4 (4× RTX 6000 Ada 48 GB, Llama3-8B-shaped: hidden 4096, target vocab 128256, draft vocab 32000, TTT 7, ~30% supervised)
And the same sweep at ring 8 (8 GPUs):
Takeaways:
Equivalence was verified at world size 8 as well (pure ring-8 and ulysses2×ring4, same four masks — all pass), and beyond the differential tests the branch carries an oracle suite where expected outputs are derived independently of the implementation: hand-written literal row/keep tables for the per-step selection (including the overlap-tail bound and dead steps), an analytic closed form for the loss (zero logits + normalized teachers ⇒ each masked row contributes exactly Caveats, stated plainly: these are 48 GB cards (not 80 GB), the benchmark is the raw module without FSDP (the delta we measure — vocab-sized loss-stage activations — is not sharded by FSDP anyway), and features are synthetic (memory/latency don't depend on tensor contents). The saving scales with The branch also picks up two robustness fixes that fell out of the verification: a dead-step NaN edge on the single-GPU path (a mask whose supervision is unreachable at deep TTT steps used to produce a mean over zero rows), and Happy to adjust scope if you'd rather land the single-GPU part first and the USP part as a follow-up. |
|
The CI failure looks unrelated to this PR: both errors come from tests/test_offline_capture/test_sglang_backend.py (its dense and MoE cases — the only 2 errors in the run), which dies in SGLang's model-runner init with ValueError: The memory capacity is unbalanced. Some GPUs may be occupied by other processes. min_per_gpu_memory=1.71..., local_gpu_memory=17.13... — i.e. the self-hosted runner appears to have leftover processes holding GPU memory. Recent runs on other branches show the same failure pattern since Aug 1 (last green was Jul 31). This PR's own tests (test_equiv_trim_loss_positions, test_equiv_trim_usp) pass both in this run and locally under the same unittest discover command. Could you re-trigger the workflow once the runner is freed up? |
Motivation
On prompt-heavy training data (agent traces, RAG, long-context SFT), most positions are masked out of the loss — only the assistant/supervised positions contribute. Yet online EAGLE3 training still materializes the vocab-wide teacher
target_p, the draftlogits, and the per-position loss over the full sequence at every TTT step, so the majority of that compute and memory is spent on positions that are multiplied by zero.--trim-loss-positionscomputes the teachertarget_p, the draftlogits, and the loss only at supervised (loss-masked) positions. It is mathematically equivalent to the full-length path — the mean loss denominator is rescaled fromn_supback to the full length — and it saves memory and compute proportional to the masked fraction.This is a port of the loss-end position-trimming idea (already present in TorchSpec) to SpecForge's TTT loop + precomputed teacher
target_ppipeline. The genuinely SpecForge-specific parts are the sliding-window teacher-table compaction (_build_trim_pack) and the mean-denominator rescale. It only touches the loss end; the draft backbone still runs full-length here (backbone-internal trimming is future work, best discussed as an RFC first).Modifications
specforge/core/eagle3.py:OnlineEagle3Modelgains atrim_loss_positionsflag. When active (and eligible), it builds a compact teacher table over supervised rows + their k-step sliding window (_build_trim_pack), runs the backbone full-length, then gathers only supervised rows intocompute_logitsand_acc_and_loss._acc_and_lossgainsloss_scale/full_positionsso the trimmed mean is rescaled to the full-length semantics (loss_scale = n_sup / full_len)._build_trim_pack,_compute_target_p_eager,_compute_position_mask_at.batch==1,lk_loss_type is None, at least one supervised position, and non-VLM; otherwise it falls back to the existing full-length path (byte-identical when off).scripts/train_eagle3.py:--trim-loss-positionsCLI flag, wired into theOnlineEagle3Modelconstruction.tests/test_runtime/test_equiv_trim_loss_positions.py: GPU equivalence test — the same online forward with trimming off vs. on must produce matching per-step losses (bf16 tolerance). GPU-only, matching the other online EAGLE3 equivalence tests in that directory.No change to
llama3_eagle.py/flex_attention.py(the backbone runs full-length under trimming).Related Issues
None known.
Accuracy Test
This is an equivalence-preserving change, so it does not affect trained-model accuracy. Correctness is verified by
test_equiv_trim_loss_positions: with a prompt-heavy loss mask, the per-step training losses with--trim-loss-positionson vs. off match within bf16 tolerance (relative error ≤ 5e-3). The off-path is byte-identical tomain(the flag defaults to off).Benchmark & Profiling
The teacher
target_pand draftlogits/loss tensors are the largest per-step activations, all∝ sequence_length × vocab. Trimming makes them∝ n_sup × vocab, so the saving scales with the unsupervised fraction.Measured on Qwen3-32B (TP4,
max_length=8192,flex_attention, TTT=3) on prompt-heavy data where ~92% of positions are masked out of the loss (supervised fraction ≈ 3–18% per sample):max_memory_allocated, per card)--trim-loss-positionsThe saving is larger on more prompt-heavy data and at longer
max_length(it is∝ masked_positions), and smaller on evenly-supervised data. Because it is a strict activation/compute reduction with equivalent output, it does not change inference latency/throughput. (This is a specific-config data point; the effect scales with the masked fraction, so a standard ShareGPT-shaped workload would show a proportionally smaller saving.)Checklist