Skip to content

feat(deepseek-v4): packed-sequence (THD) SFT — full model at 4k and 128k on 3 nodes - #1062

Merged
wenxie-amd merged 4 commits into
mainfrom
zhaobing/dev/deepseek-v4-thd-packed
Sep 1, 2026
Merged

wenxie-amd merged 4 commits into
mainfrom
zhaobing/dev/deepseek-v4-thd-packed

Conversation

@aaab8b

@aaab8b aaab8b commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

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.

Previously stacked on #1047. That PR has since merged (as 1f1a8a7), so this branch was
rebased onto main and the two duplicated commits dropped. All commits here are now this
PR's own.

Recipes

script model context nodes
run_128k_thd_packed.sh 3 layers (dense / CSA / HCA) 128k 1
run_full_4k_thd_multinode.sh 43 layers + MTP, 256 experts 4k 3
run_full_128k_thd_multinode.sh 43 layers + MTP, 256 experts 128k 3

All three delegate to run_full_4k_multinode.sh for 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:

4k 128k
parallelism TP=1 PP=3 CP=1 EP=8, DP=8 TP=1 PP=3 CP=8 EP=8, DP=1
loss 11.889 → 10.896 11.879 → 10.949
grad norm 30.7 → 20.6 31.2 → 19.6
peak memory 149.84 GB (78%) 163.60 GB (85%)
nan iterations 0 0

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 scores is
[B, S, P] with heads summed out, the KV is a single MQA latent); so CP takes the
remaining 8 and DP falls out as 1.

Memory was measured, not extrapolated

seq 4k 16k 32k 64k 128k
peak 149.84 150.41 156.03 166.06 186.32 GB

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_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: MTP ran dense-causal across the whole pack

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, 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, and
a 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_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 though the direct launcher never
    starts a container. The BSHD recipe "worked" before only because that variable happened to
    be set in some shells.
  • TURBO_USE_GROUPED_MLP and PRIMUS_OPT_MEGA_MOE default off. main's grad-accum
    fusion and MegaMoE both call primus_turbo APIs 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.
    Set both back on an image with a newer primus_turbo; CI builds it from source.
  • 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.

What packing buys, on the same corpus

Both layouts consume identical total tokens, so step time is directly comparable:

total real supervised ms/step spread
BSHD 163,840 2.8% 1.9% 30,445 4.8%
THD 163,840 100.0% 86.1% 30,930 3.2%

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.sh defaults to sft_4096.jsonl, whose rows are deliberately built
longer 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

  • V4 unit suite on a gfx942 host, with the MI355X gate bypassed via
    PRIMUS_V4_UT_ALLOW_NON_MI355X=1 (without it the whole suite skips here):
    this branch 602 passed / 38 failed, plain main 548 passed / 38 failed. The 38
    failures 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.
  • CP=1 vs CP=2 and CP=4 over ragged lengths: dense / CSA / HCA all match.
  • pre-commit run over every changed file: clean.
  • Merges cleanly into current 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 = PP leaves the pipeline idle roughly two thirds of the time by construction.

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())
Comment thread examples/deepseek-v4/gfx942/prepare_sft_data.py Fixed
Comment thread examples/deepseek-v4/gfx942/prepare_sft_data.py Fixed
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
Comment thread examples/deepseek-v4/gfx942/prepare_sft_data.py Fixed
zhaobing and others added 2 commits September 1, 2026 03:11
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
aaab8b force-pushed the zhaobing/dev/deepseek-v4-thd-packed branch from 6a6a9ce to 27a5860 Compare September 1, 2026 03:21
Xiaoming-AMD and others added 2 commits September 1, 2026 13:00
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
wenxie-amd merged commit 01246df into main Sep 1, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants