Repository navigation
feat(deepseek-v4): packed-sequence (THD) SFT — full model at 4k and 128k on 3 nodes - #1062
Merged
Merged
Conversation
aaab8b
requested review from
Xiaoming-AMD,
limou102 and
wenxie-amd
as code owners
August 31, 2026 12:10
| fh.write(json.dumps({"input_ids": ids, "labels": labels, "cu_seqlens": cu}) + "\n") | ||
| written += 1 | ||
|
|
||
| first = json.loads(open(args.out, encoding="utf-8").readline()) |
| global_start=gstart, | ||
| boundary_hidden=boundary, | ||
| ) | ||
| P = pooled.shape[1] |
| # and deepseek_v4_attention <-> deepseek_v4_block is a real import cycle: whichever is | ||
| # entered first wins, so triggering it from inside a method fails when this file happens | ||
| # to be the first V4 test collected. Importing the package here resolves the cycle once. | ||
| import primus.backends.megatron.core.models.deepseek_v4 # noqa: E402,F401 |
Adds a 3-layer DeepSeek-V4 SFT example that trains on PACKED sequences at 128k
context under CP=8, exercising dense+SWA, CSA and HCA. Alpaca's runtime segments
have a median of 84 tokens, so an unpacked 128k row is almost entirely padding;
packing is what makes the context length mean something.
Packing puts sequence boundaries at arbitrary offsets, and the V4 compressor pools
fixed windows anchored at each sequence's start, so under context parallelism a
window can straddle a shard boundary. Two mechanisms handle that without requiring
the sequence alignment the earlier baseline used:
* a left-boundary exchange ships the neighbour's trailing rows;
* strict window ownership (a window belongs to the rank holding its LAST row)
plus a fixed per-rank capacity, which the pool all-gather needs because it
sizes receive buffers from the local width.
Dropping alignment raises supervised tokens from 34.2% to 56.1%, measured over
every pack the runtime packer produces.
Fixes found while getting this to run, each of which produced a plausible-looking
run rather than an error:
* CSA's overlap stitch took window i's predecessor from the preceding compact
slot, so the first owned window on each rank was stitched to hardcoded zeros
-- a ~100% relative error on one compressed key per rank per layer. The
predecessor is now pooled locally as a shadow slot and dropped before the
pool is emitted.
* An empty compressed pool returned a detached zeros tensor, severing the
compressor's parameters from the graph so they never trained. HCA has no
window at all on 22 of 39 packs, so this was the common case, not an edge
case. The pool is now built through the projections and multiplied by zero.
* The packed indexer chunked its scoring but concatenated the chunks back into
a full [B, S, P] row, which scales with the global pool. It now carries a
running top-K, and defaults to a narrower chunk: the sizing transient is the
4-D dot product before the head axis is reduced away (4.0 GiB at chunk=2048
against 1.0 GiB at 512).
* The contiguous streaming top-K ran ahead of the packed branch, so enabling
chunking on packed input silently scored every query against the whole pack's
pool instead of its own sequence's slice.
* MAX_SEGMENTS_PER_PACK was sized for an 8k window; at 128k it became the
binding constraint instead of max_seq_length, filling ~19% of the window with
real tokens while reporting a full pack. It now scales with the window.
* segment_align was missing from the pack cache digest, so flipping it silently
reloaded the other setting's packs -- an aligned-vs-unaligned A/B on a warm
cache compared identical data.
Verified on 8x MI308X: 10 steps, loss 11.899 -> 11.71954 reproducible to five
significant figures across runs, 0 nan, exit 0; and bit-identical from a pristine
rocm/primus:v26.5-pytorch2.12-te2.15 container with empty data and cache dirs.
CP=1 vs CP=2 and CP=4 agree on all three branches over ragged lengths.
Adds 8 test files covering window ownership against upstream's rule, packed vs
unpacked equivalence, the CP overlap predecessor, empty-pool gradients, streaming
top-K exactness, and the fused-gather backend contract.
Co-Authored-By: Claude <noreply@anthropic.com>
…single- and multi-node
Adds the two multi-node packed recipes and consolidates the THD documentation. Builds on
the existing single-node script; all three delegate to run_full_4k_multinode.sh for
networking, parallelism and offload, so the BSHD and THD paths cannot drift apart.
run_128k_thd_packed.sh 3 layers, 128k, 1 node (existing)
run_full_4k_thd_multinode.sh 43L + MTP, 4k, 3 nodes (new)
run_full_128k_thd_multinode.sh 43L + MTP, 128k, 3 nodes (new)
Full model, three nodes, ten steps, natural-length Alpaca packed at runtime:
4k 128k
parallelism TP1 PP3 CP1 EP8, DP8 TP1 PP3 CP8 EP8, DP1
loss 11.889 -> 10.896 11.879 -> 10.949
peak memory 149.84 GB (78%) 163.60 GB (85%)
nan 0 0
At 128k, CP=8 is not a choice: PP=3 is forced by parameter memory, TP cannot shard V4's
dominant tensors (no head axis), so CP takes the remaining 8 and DP falls out as 1.
Memory was measured rather than extrapolated -- 149.84 / 150.41 / 156.03 / 166.06 GB at
4k / 16k / 32k / 64k, which predicted 186 GB at 128k against 186.32 GB observed. That is
97% of the card, so the 128k recipe defaults optimizer offload to 0.90 instead: 163.60 GB,
28 GB of headroom, no measurable cost with ~2.9 TB of host memory free. A device-wide
reading at 97% is not something a run can rely on.
FUSED_LINEAR_CE is required at 128k rather than an optimisation -- the LM head's logits are
31.6 GB before the loss upcasts -- and forces PRIMUS_OVERLAP_PARAM_GATHER=false, which
DeepseekV4Model already asserts.
Fix included: DeepseekV4MTPLayer declared packed_seq_params and did not pass it on. The
inner layer then saw None, _thd_seq_starts returned None, and that also switched off the
packing guard, so the MTP depth attended across every sample boundary in the pack -- no
error, no NaN, finite loss. It only appears with mtp_num_layers=1, which the full-model
recipe enables by default, and a PP=2 probe cannot reach it because MTP lives on the last
stage. Verified by removing the fix and watching four ranks report a null descriptor again.
Also here:
* cu_seqlens is keyword-only on Indexer.forward. Merging the packed entry point with
main's rotating one left position_ids and cu_seqlens adjacent and both tensor-shaped,
so indexer(hidden, cu) bound cu_seqlens to position_ids and was accepted. Here it
happened to raise; the general case rotates queries by cu_seqlens values in silence.
* run_full_4k_multinode.sh no longer depends on ambient environment: it probes for the
tokenizer directory (renamed at least once) and supplies DOCKER_IMAGE, which
run_deepseek_v4.sh hard-requires via ${VAR:?} even when the direct launcher never
starts a container.
* TURBO_USE_GROUPED_MLP and PRIMUS_OPT_MEGA_MOE default off: main's grad-accum fusion and
MegaMoE both call primus_turbo APIs that the build in rocm/primus:v26.5 does not have
(one renamed upstream). Note the effective switch is TURBO_USE_GROUPED_MLP, not the
yaml key -- run_deepseek_v4.sh passes it on the command line and overrides the config.
* pack cache and its filelock are node-local. Sharing the cache over NFS collides on the
writer's fixed <final>.tmp name; sharing the lock blocks 23 ranks past Megatron's
10-minute collective timeout.
The three THD READMEs are now one (README_thd_packed.md), covering all three recipes plus
the token-budget comparison: on the same corpus, BSHD fills 2.8% of each window with real
tokens against THD's 100%, while step time is indistinguishable (30,445 vs 30,930 ms,
inside each configuration's own run-to-run spread), giving 44.6x supervised tokens/s. That
figure is NOT a claim that the existing BSHD recipe wastes compute -- it defaults to a
corpus of deliberately over-long rows measured at 99.3% supervised.
Co-Authored-By: Claude <noreply@anthropic.com>
aaab8b
force-pushed
the
zhaobing/dev/deepseek-v4-thd-packed
branch
from
September 1, 2026 03:21
6a6a9ce to
27a5860
Compare
create_sft_datasets_provider passes segment_align to build_train_valid_test_datasets,
which did not name it. It therefore landed in **kwargs and was forwarded to the dataset
class. PackedSFTDataset declares segment_align, so the packed path was fine; SFTDataset
does not, so on the unpacked path it fell through that class's own **kwargs into
load_dataset(dataset_name, split=split, **kwargs)
where HuggingFace rejects unknown builder-config keys. train_ds is not wrapped in the
try/except that guards the validation and test splits, so this is a hard failure at
train-set construction -- for every unpacked SFT run against a Hub dataset, which is the
default and has nothing to do with packing or DeepSeek-V4.
The local-.jsonl branch hides it: that path builds HFDataset.from_list(data) and never
looks at kwargs, and all three THD recipes use local jsonl, so no run here ever reached
it. CI found it because the fixture dataset name is "my-org/my-sft-dataset".
Fixed the way bridge_compat_inline_bos already was -- name the parameter so the builder
consumes it -- and forward it only when packing is on, since only PackedSFTDataset
accepts it. The provider still passes it unconditionally: a call shape that changes with
a value is harder to debug than an extra key.
Two tests pin both halves of the contract, and the leak one fails without the fix:
segment_align must not reach SFTDataset unpacked, and must reach PackedSFTDataset packed.
Co-Authored-By: Claude <noreply@anthropic.com>
wenxie-amd
approved these changes
Sep 1, 2026
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.
Sequence packing (THD) for DeepSeek-V4: many real SFT samples in one window, delimited by
cu_seqlens, with attention strictly isolated so no query sees another sample.Recipes
run_128k_thd_packed.shrun_full_4k_thd_multinode.shrun_full_128k_thd_multinode.shAll three delegate to
run_full_4k_multinode.shfor networking, parallelism and offload —the 128k one delegates further to the 4k one — so the BSHD and THD recipes cannot drift
apart. Documentation is consolidated into a single
README_thd_packed.md.Results
Full model, three nodes, ten steps, natural-length Alpaca packed at runtime:
The two curves have the same shape, which is why the long one was worth running: 128k
introduces no divergence that 4k does not have.
At 128k the parallelism is not a choice. PP=3 is forced by parameter memory; TP cannot
shard V4's dominant tensors because they have no head axis (the indexer's
scoresis[B, S, P]with heads summed out, the KV is a single MQA latent); so CP takes theremaining 8 and DP falls out as 1.
Memory was measured, not extrapolated
The ladder predicted 186 GB at 128k against 186.32 observed. That is 97% of a 192 GB card,
so the 128k recipe defaults optimizer offload to 0.90 instead — 163.60 GB, 28 GB of
headroom, no measurable cost with ~2.9 TB of host memory free. A device-wide reading at 97%
is not something a run can rely on: another process taking 6 GB kills it.
FUSED_LINEAR_CEis required at 128k rather than an optimisation — the LM head's logits are31.6 GB before the loss upcasts — and forces
PRIMUS_OVERLAP_PARAM_GATHER=false, whichDeepseekV4Modelalready asserts.Fix: MTP ran dense-causal across the whole pack
DeepseekV4MTPLayerdeclaredpacked_seq_paramsand did not pass it on. The inner layerthen saw
None,_thd_seq_startsreturnedNone, and that also switched off the packingguard — so the MTP depth attended across every sample boundary, with no error, no NaN and
a finite loss.
It only appears with
mtp_num_layers=1, which the full-model recipe enables by default, anda PP=2 probe cannot reach it because MTP lives on the last pipeline stage. Verified by
removing the fix and watching four ranks report a null descriptor again.
Also here
cu_seqlensis keyword-only onIndexer.forward. Merging the packed entry point withmain's rotating one leftposition_idsandcu_seqlensadjacent and bothtensor-shaped, so
indexer(hidden, cu)boundcu_seqlenstoposition_idsand wasaccepted. Here it happened to raise; the general case rotates queries by
cu_seqlensvalues in silence.
run_full_4k_multinode.shno longer depends on ambient environment. It probes for thetokenizer directory (renamed at least once) and supplies
DOCKER_IMAGE, whichrun_deepseek_v4.shhard-requires via${VAR:?}even though the direct launcher neverstarts a container. The BSHD recipe "worked" before only because that variable happened to
be set in some shells.
TURBO_USE_GROUPED_MLPandPRIMUS_OPT_MEGA_MOEdefault off.main's grad-accumfusion and MegaMoE both call
primus_turboAPIs the build inrocm/primus:v26.5does nothave (one renamed upstream). Note the effective switch is
TURBO_USE_GROUPED_MLP, not theyaml key —
run_deepseek_v4.shpasses it on the command line and overrides the config.Set both back on an image with a newer
primus_turbo; CI builds it from source.writer's fixed
<final>.tmpname; sharing the lock blocks 23 ranks past Megatron's10-minute collective timeout.
What packing buys, on the same corpus
Both layouts consume identical total tokens, so step time is directly comparable:
The 485 ms difference is smaller than either configuration's own run-to-run spread, so step
time is indistinguishable and useful throughput tracks the token budget: 35.2× real
tokens/s, 44.6× supervised tokens/s.
That is not a claim that the existing BSHD recipe wastes compute.
run_full_4k_multinode.shdefaults tosft_4096.jsonl, whose rows are deliberately builtlonger than the window so truncation rather than padding sets the length — measured at 99.3%
supervised. The comparison above forces both layouts onto the same natural-length corpus,
which is the only way to isolate what packing does.
Verification
PRIMUS_V4_UT_ALLOW_NON_MI355X=1(without it the whole suite skips here):this branch 602 passed / 38 failed, plain
main548 passed / 38 failed. The 38failures are identical by name on both sides — pre-existing gfx950 kernel numerics,
not regressions. The branch adds 54 passing tests and introduces zero new failures.
pre-commit runover every changed file: clean.main.Scope
Random init — no DeepSeek-V4 Megatron checkpoint exists — so this is SFT-shaped training
and no number here says anything about model quality. At 4k, ~40% of packs contain no
complete ratio-128 window, so the HCA branch carries almost no signal; 128k is where it
becomes meaningful. Step time at 128k is not a performance figure:
num_microbatches = GBS = 3 = PPleaves the pipeline idle roughly two thirds of the time by construction.