Skip to content

[TRTLLM-16460][feat] modeling_v2: DeepSeek-V4.1-Flash-NVFP4 target (sm_103, unified parallelism) - #20192

Draft
Wanli-Jiang wants to merge 53 commits into
NVIDIA:mainfrom
Wanli-Jiang:user/williamj/dsv41-mv2-pr
Draft

Wanli-Jiang wants to merge 53 commits into
NVIDIA:mainfrom
Wanli-Jiang:user/williamj/dsv41-mv2-pr

Conversation

@Wanli-Jiang

Copy link
Copy Markdown
Collaborator

@coderabbitai summary

Depends on #19977. This branch sits on #19977's head (8e5a1368d9) and uses its modeling_v2 conventions:

  • OpWrapper catalog entries;
  • a target's own ops/;
  • Prefill/DecodeTarget over a ModelingV2Core;
  • ModelWeights.

Review the 8 [TRTLLM-16460] commits on top. Once #19977 merges, this branch is rebased onto main.

Description

This adds a modeling_v2 target for DeepSeek-V4.1-Flash-NVFP4 on GB300 (sm_103), with unified parallelism: tep4, dep4, tp4, plus tep8/dep8 and PP2 members.

It is opt-in. With TRTLLM_MODELING_V2=off (the default) nothing changes, and DeepSeek-V4.1 runs on the in-tree implementation from #19727. With TRTLLM_MODELING_V2=require|auto, a DeepseekV41ForConditionalGeneration checkpoint routes to this target:

  • The target runs the vision tower.
  • It supports DSpark speculative decoding, the Engram host table, bounded encoder/decoder replay, native/FP4/FP8 KV storage, contexts up to 1M tokens, and disaggregated serving.
  • Text-only DeepseekV41ForCausalLM stays on the built-in model.

The target was developed and gated on GB300 before #19977 and #19727 landed. This PR ports it onto #19977's layout and onto main as it is after #19727.

What changed (one commit per area)

# Area Main points
1 Indexer K-cache int64 (fix) indexerKCacheGather/Scatter compute the contiguous-side offset in 64 bits. Also proposed alone against main: #20190.
2 deepseek_v4 tokenizer media (fix) Image requests through trtllm-serve no longer fail with HTTP 400. Also proposed alone against main: #20191.
3 gate_forward routing kernel Top-k becomes a template parameter, adding (128, 3), on top of the image-routing kernel.
4 Runtime
  • A DeepSeek-V4.1 layout mode of the DeepSeek-V4 sparse backend, entered only through v41_layout.use_deepseek_v41_layout_mode(model_config): 128-token pages, an Engram token role, replay cursors, native/FP4/FP8 KV, and the long-decode pool constraint.
  • Triton custom ops for NVFP4/MXFP8 KV.
  • trtllm::dsv4_local_to_global_indices.
  • Small hooks: autotuner fallback_tactic(), per-request last-chunk/context-logits flags, admission refusal, profiling of an in-forward multimodal encoder, a lazy-safetensors opt-in.
5 Shared catalog
  • bmm_out, fp4_block_scale_moe_runner, fp4_quantize, mxe4m3_mxe2m1_block_scale_moe_runner and mxfp8_quantize move back to the shared catalog, since a second target calls them.
  • The cells this target calls shared entries at.
  • The claims gates now scan every target's ops/.
6 The target's ops 37 OpWrapper entries only this target calls, each with a test that drives every cell: sparse MLA (bf16 and FP8 KV), compressor, indexer, DeepGEMM FP8×FP4 MQA logits, Engram, mHC, MXFP8 GEMM/quantization, NVFP4/MXFP8 KV conversions, gate_forward, allreduce, PP send/recv.
7 The target
  • models/deepseek_v41/deepseek_v4_1_flash_nvfp4__sm_103__unified/: core, PrefillTarget/DecodeTarget, ModelWeights tables, the Engram host table, and its own input processor.
  • Routing and _router_index entries.
  • create_input_processor asks the modeling_v2 router first. With off, it returns nothing.
  • Under auto, a configuration no target serves is refused instead of pairing this target's input processor with the built-in model.
8 CI
  • GSM8K/MMMU gates and replay bitwise gates, keyed apart from the in-tree V4.1 references.
  • test-db entries sized from measured durations.
  • CODEOWNERS.
  • quickstart_advanced.py --spec_decode_algo DSPARK.

In-tree behaviour is unchanged.

  • DeepSeek-V4 keeps its disaggregated synthetic layer ids.
  • Its cache config keeps upstream's constraints.
  • Metadata built without __post_init__ takes the V4 path.
  • The in-tree DeepSeek-V4/V4.1 test suites pass (below).

Test Coverage

All results are on GB300 (4× GB300 per node, sm_103), with a native build of this branch (103-real) in the image pinned by jenkins/current_image_tags.properties. They were measured on the integration branch, whose final tree equals this PR's. Multi-rank runs use mpirun … trtllm-llmapi-launch, because the container forbids MPI_Comm_spawn.

Model level (TRTLLM_MODELING_V2=require):

  • Boot: tep4 with DSpark (draft length 5); the target is active on all 4 ranks.
  • GSM8K: tep4 passes its threshold, dep4 scores 92.381 and tp4 92.684, against a reference of 92.300.
  • MMMU: tep4 passes.
  • Encoder replay: bitwise on tep4 and dep4.
  • 1M context: tep4 at max_seq_len=1048576 with chunked prefill. The pass key is exact at 30,680, 245,720 and 998,360 prompt tokens; the ~1M prompt takes 35 s.
  • TRTLLM_MODELING_V2=auto: tp2, which no target serves, is refused with the =off message; tep4 selects the target.

Unit and op tests:

  • modeling_v2 contract tests, including auto_fallback: 136 passed. The target's own tests: 95 passed.
  • The target's ops: every file passes on 1 GPU.
    • Sparse MLA: 2930 passed.
    • Top-k ragged: 5681 passed, and its 2 cross-process tests pass on 2 GPUs.
    • Engram gather: 372 passed and 5 skipped on 1 GPU; its 4-GPU cases pass on 4 GPUs.
    • Engram hash: 72 passed. gate_forward: 212 passed. The envelope cases: 268 passed.
    • Target comm (4 GPUs): 5 passed.
  • Shared catalog entries, with the deepseek_v3 and gpt_oss targets' ops: 614 passed. Shared comm: 2 passed.
  • Runtime:
    • DeepSeek-V4 sparse backend (in-tree V4 regression): 606 passed.
    • test_modeling_deepseekv4.py: 46 passed.
    • DeepSeek-V4/V4.1 KV transfer: 66 passed. Disaggregated tests: 155 passed.
    • Tokenizer and input processor: 47 passed. MoE routing: 517 passed. py_executor: 226 passed.
    • In-tree DeepSeek-V4.1 ([TRTLLM-16621][feat] Support DeepSeek-V4.1-Flash #19727): csa2 plus test_modeling_deepseekv41.py give 747 passed, plus 218 with the release checkpoint.

Not run here:

  • tep8/dep8 and the two-tray ws8/PP checks, which need two nodes. They were gated on the onboarding branch before the port: GSM8K tep8 92.65, dep8 92.49, pp2×tp4 93.14.
  • The 4-GPU test_moe_routing cases and beam-search multi_process, which need MPI_Comm_spawn.
  • csa2 test_two_rank_module_projection_parity fails here with an NCCL "invalid usage" in getComm. No file on its path differs from the base, and it was not re-run on a base build.

Lint: the repo's pre-commit, run twice over every changed file; pass 2 rewrites nothing. The one failing hook, validate-test-lists, reports the three stale waives.txt entries (lines 258–260) that #19977 leaves behind. This PR moves fp4_quantize's and mxfp8_quantize's tests back to the paths those waives name, but the waived functions no longer exist.

Known limitations and follow-ups

  • DSpark acceptance gate against the in-tree leg. [TRTLLM-16304][feat] Upgrade the infrastructure of modeling V2 #19977's README asks for one, but it is not written yet. The old branch measured acceptance length 2.68 (tep4) and 2.73 (tp4); the in-tree gate needs ≥ 3.0 under its own setup.
  • Pipeline parallelism. It is certified only with MPI. The mpi_disabled path (PPCommTorch) is not.
  • CI cost.
    • The target's ops tests take about 4.3 GPU-hours. They are listed post-merge on B300, so a change to one of these ops is not gated pre-merge.
    • B300-PyTorch-Post-Merge-1 is a single split, and it may need another.
    • The shared modeling_v2 pre-merge entry grows by about 37 min (its timeout goes from 30 to 90) because of this target's cells on shared entries.
  • Host memory. The Engram host table is 202.76 GB. Tests that build the real table run on GB300 only and skip where a node lacks the GPUs or sockets.
  • DeepGEMM head counts. The DeepGEMM pin in main now accepts head counts that the old pin refused. New tests hold 24 and 128 heads to the entry gate.
  • MMMU tokenizer. The MMMU gate uses the deepseek_v4 tokenizer that its reference was measured with.

PR Checklist

Please review the following before submitting your PR:

  • PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.

  • PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.

  • Test cases are provided for new code paths (see test instructions)

  • If PR introduces API changes, an appropriate PR label is added - either api-compatible or api-breaking. For api-breaking, include BREAKING in the PR title.

  • Any new dependencies have been scanned for license and vulnerabilities

  • CODEOWNERS updated if ownership changes

  • Documentation updated as needed

  • Update tava architecture diagram if there is a significant design change in PR.

  • The reviewers assigned automatically/manually are appropriate for the PR.

  • Please check this after reviewing the above items as appropriate for this PR.

…Wrapper

Each catalog entry becomes an OpWrapper: raw_call is the certified surface
(takes everything explicitly, reads nothing off self), beside the entry's
reference, is_valid, compare and CELLS. The declared name lists go, the step
contract is gated behind TRTLLM_MODELING_V2_VALIDATE, and torch is called
directly rather than through mirror modules.
…rapper

The nineteen entries, converted in families: the first seven, four more, the
two collectives, the two paged-cache MLA entries (with a gate for cells),
thop_attention, and the last three. The unused_ argument prefix goes; each op
keeps its own names.
A validating() fixture that rejects a cell outside the entry's CELLS, and
reducescatter's dtypes stated as an explicit allowlist.
A core is what a model statically is; a target is one way of running a step
over it. The predicate is num_contexts == 0 and nothing else, so a mixed
batch routes to prefill and decode may assume it has no context rows.

The base class holds two rules the tree has already paid for: a target reads
the core rather than snapshotting it, and the phase is read per routable unit
rather than cached.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…de target

The core's forward becomes a dispatcher: it runs the phase-independent
contract check -- which is also what regrows the rope table -- and hands the
step to one of two targets built after weights load.

gpt_oss has no phase branch to divide, so the two targets share the step body
and differ only where they genuinely can: decode states the two counts its
routing guarantees instead of reading them back off the metadata.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
Two checks, read off disk like the rest of this file. A decode target may not
read back the count it was routed on -- inside a captured graph that value is
frozen at whatever the capturing batch carried. Prefill stays exempt: it holds
the mixed batch and needs the split.

The second pins the class names the first depends on, with an explicit list of
cores not yet migrated so that finishing the migration is a visible diff.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
… runs

test_a_decode_target_never_reads_the_phase_back keyed on the DecodeTarget
class name. Decode's body is inherited from _GptOssTarget (where decode
actually executes) and its step_args delegates to the module-level
_build_step_args -- neither is a DecodeTarget node, so the gate guarded
exactly one line: the literal call in DecodeTarget.step_args.

Invert the rule instead of enumerating more class names, since enumeration
goes stale the same way: an attribute read of num_contexts/num_ctx_tokens
anywhere in a core's module is now a violation unless it falls inside
PrefillTarget's body (it holds the mixed batch and needs the split) or
_check_step_contract's body (phase-independent metadata validation that
legitimately mirrors the same projection). Verified the widened gate
catches the newly-covered case: inserting a read into _GptOssTarget.forward
turns it red naming that line, and reverting turns it green again.

Also drop test_gpt_oss_core_declares_both_phases, which hardcoded the
gpt_oss path; test_every_core_ships_both_targets derives the same paths
from the routing tables and already covers it.

Correct two docstrings that claimed this tree regrows `_rope` on an
admitted longer max_seq_len -- gpt_oss has no `_rope` attribute at all;
that claim describes deepseek and was copied in by mistake. State the
reason that actually applies here: the contract check is phase-independent,
so it belongs in the dispatcher rather than duplicated into both targets.

Name the attribute each load-order assert actually guards (_targets vs
_layers/_call_tensors) instead of a shared, ambiguous message, and drop
the redundant attn_metadata type assert from the shared step body -- the
dispatcher is the only way in and has already checked it.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…nd a binding layer

An entry's arguments arrive at three different times -- engine constants and
op-owned fixtures at construction, the batch state once a forward, the tensors
at the call -- but the signature admitted only the last. This adds the two
earlier stages and makes raw_call the surface underneath them.

raw_call takes everything explicitly and reads no bound state, so CELLS keeps
certifying the whole input space whatever a target binds, and reference and
is_valid go on mirroring it. The validation harness patches raw_call for the
same reason: is_valid still sees the merged argument set.

No behaviour change. Every entry is constructed with nothing bound, so the
default __call__ is a passthrough and all 56 call sites are untouched.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
d95f77e renamed each catalog entry's __call__ to raw_call and made
__call__ a new base-class merge layer, but left the prose describing
the old shape. Two module docstrings had come to state the opposite
of the code directly beneath them: _op.py's contract table still
listed __call__ as "the op call a target makes" and said the entry's
test drives cells through __call__, and _validating.py's module
docstring said the wrapper's __call__ goes straight to the kernel and
is what gets patched -- both now true of raw_call instead. About a
dozen subclass docstrings across catalog/*/*.py that said reference
or is_valid "mirrors __call__" get the same correction, including the
collectives' documented exception to mirroring, which stays an
exception, now to raw_call. The base class's own new docstrings for
__call__ as the three-stage merge layer are accurate and untouched.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
The binding layer is only safe because raw_call takes every argument
explicitly. One gate reads every entry's raw_call and refuses any read of
self; the other pins is_valid to accepting everything raw_call accepts --
either by naming the same parameters or by absorbing the remainder with
**kwargs -- rather than demanding a parameter-for-parameter mirror, which
several entries never had and do not need (reference is cell-driven, not
forward-driven, and is exempt). Both gates assert the first parameter is
self, which a previous mirror check assumed rather than checked, and so
missed an entry where both signatures had dropped it. Also adds a per-step
staleness check, live only inside the validation harness, that catches a
forward which binds step state without rebinding it on a later step.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
A target is not a second line of defence against routing: the router
guarantees it picked the right target from the architecture and key
parameters, so re-validating a checkpoint's full configuration inside
every target does not scale. Remove all 35 assert statements from the
gpt-oss-120b / sm_103 / tp1 target, along with the expressions and
comments whose sole purpose was to feed them.

Two of the removed asserts guarded runtime inputs this target does not
implement (lora_params, spec_metadata) rather than routing decisions;
those inputs are now silently ignored rather than rejected.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…class

An entry's __init__ now also accepts layered=, a {argument: per-layer
sequence} mapping distinct from the flat bound= kwargs. __call__ gains a
layer= keyword that, when given, projects one row of each layered table
onto the merged call after bound and step -- so a target can bind a
per-layer weight table once, at construction, and index it with layer=i
at each call instead of unpacking a parallel tuple itself.

An entry that never binds layered= has an empty table, so a call that
never passes layer= runs the exact same three-stage merge it always did;
this is what keeps every existing call site -- including deepseek's,
which binds nothing -- byte-for-byte unchanged.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…rote

thop_attention, fused_qk_norm_rope and flashinfer_fused_add_rmsnorm wrote
their output by mutating an argument and returned None, which hid what a
call site produced. Each raw_call now returns the tensor(s) it wrote --
output, qkv, and (x, residual) respectively -- so a caller can rebind to
the result instead of reading it back off the argument it passed in.

The mutation is unchanged: none of these allocate, so a caller that
reuses a preallocated buffer across many calls (gpt_oss's attn_out, one
per layer for 36 layers) still gets the same object back, just with a
name attached to it at the call site. reference already mirrors this
shape for all three, so no reference changes. deepseek's call sites
(thop_attention x4, flashinfer_fused_add_rmsnorm x3) ignore the return
value, as they did before; the two catalog tests that asserted "is None"
now assert identity against the arguments instead.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…talog entries

_GptOssTarget gains an __init__: it builds one bound instance per op this
target calls, with per-layer weight tables (w_qkv/b_qkv, w_o/b_o, w_n2,
w_rt/b_rt, the MoE expert operands, attention_sinks, the next-norm table)
in layered=, and config scalars (eps, heads_q, heads_kv, head_dim,
rotary_dim, theta, the four yarn_*, topk, num_experts, hidden, inter,
inter_pad, _CALL_CONSTANTS) in bound=. fused_qk_norm_rope's inert q/k
norm weight and the MoE runner's per-expert alpha/beta/limit vectors move
from core._call_tensors (now gone) into this same construction, as the
op-owned fixtures they are. forward's per-layer body collapses from eight
positional op calls carrying ~90 lines of arguments to ten bound-instance
calls indexed by layer=i.

attention_window_size stays a per-forward, per-call computation rather
than joining attention_sinks in the layered table: its full-attention
value is attn_metadata.max_seq_len, which resolves through the KV cache
manager's *actual* sized max_seq_len -- not known until after weight
load, when _GptOssTarget.__init__ runs, and not guaranteed to equal the
engine's requested max_seq_len (resource_manager.py's window validation
can clamp it down under memory pressure). Baking a construction-time
value in would silently diverge from the real one on a memory-constrained
run.

core._layers and core._call_tensors are deleted outright -- nothing reads
per-layer state through the core anymore. The eleven cfg-derived scalars
core copied for forward to read (eps, heads_q, heads_kv, head_dim,
rotary_dim, theta, the four yarn_*, topk, num_experts, hidden, inter,
inter_pad) stay on core: weights.py's load() reads several of them
directly, and build_layer_views (via _GptOssTarget.__init__) now binds
all of them into the op instances it constructs -- forward itself is
simply no longer one of their readers. core.window and core.sliding are
the one exception that still reads from forward, for the reason above.

advance_step_generation() plus self._attn.bind_step(**step) now run once
per forward, before the loop, replacing the **step unpacked at every
thop_attention call site.

Unit suite: 281 passed, 2 failed (known MPI-slot comm tests), 1 skipped
-- no regressions. gpt_oss gsm8k accuracy: 89.992 (reference 90.300),
matching the pre-refactor spread on this cluster.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…all sites

Four follow-ups from review of the previous commit, none changing the
computed values:

- eps, topk and the GLU clamp limit were core attributes whose only
  remaining reader was _GptOssTarget.__init__ itself -- weights.py never
  touches them, unlike heads_q/heads_kv/head_dim/hidden/inter/num_experts/
  the three padded widths, which stay attributes because weights.py reads
  them directly, at a separate time, through core.X. Deleted; the target
  now reads cfg.rms_norm_eps / cfg.num_experts_per_tok / cfg.swiglu_limit
  straight off core.model_config.pretrained_config. theta and the four
  yarn_* stay attributes even though the target is their only other
  reader too: recomputing them means re-deriving ~20 lines of YaRN math
  with real failure modes (KeyError, math.log domain errors) a second
  time, either duplicated or deferred past a multi-GB weight load: worse
  on both counts than one cached, early-validated copy. rotary_dim is
  gone outright -- it was already just self.head_dim under a second name.

- self._moe's call site passed routing_bias as a bare positional None.
  It is now bound to None at construction (next to the comment explaining
  why: the router bias rides the router GEMM's own epilogue instead), and
  the call site names every argument.

- self._attn's call site passed local_layer_idx=i beside layer=i, the
  same loop variable under two names. local_layer_idx is now a layered
  table (a trivial range(num_layers) one, since the op's own argument and
  the binding index happen to coincide for every call this target makes)
  so the call site states i once.

- core._layers and core._call_tensors are confirmed fully dissolved: zero
  dotted attribute accesses remain, grep-verified separately from the
  `num_layers` substring collisions that made a cruder grep overcount
  them. The two mentions left are build_layer_views' and __init__'s own
  docstrings explaining that they used to exist and don't anymore; reworded
  away from the attribute spelling so they stop looking like hits.

Re-verified unchanged: unit suite 281 passed / 2 known failures / 1
skipped (job 948502), gpt_oss gsm8k accuracy 89.992 (job 948586) -- both
identical to the pre-fix run, as expected since no bound value changed.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…nd_layered

A catalog entry's binding stages used to encode when a value became known
(construction vs. per-step) rather than whether it varies by layer, which
is the axis the entry actually cares about. OpWrapper now exposes two
methods instead of three construction-time styles: bind_const(**values)
for a value that is the same for every layer, and bind_layered(**tables)
for one value per layer selected by layer= at the call. Both are callable
from anywhere a caller finds a value knowable, and both update rather than
replace prior bindings for other keys. __init__ no longer takes binding
arguments; it only sets up empty state.

Dropping the construction/step split means the base class can no longer
tell a construction-time constant from a volatile per-forward value by
name. The staleness check that used to compare self._step_generation now
stamps each bound key with the step generation in effect when it was last
bound, in a per-key self._generation dict, and flags under validating()
only a key whose stamp is >= 1 (bound during some forward) and behind the
engine's current generation -- a key that was once bound mid-forward and
was not rebound this one. A key bound before the first forward keeps
generation 0 forever and can never be flagged.

gpt_oss's call sites are mechanically converted to the new methods with no
behavioral change in this commit; binding attention_window_size through
bind_layered follows separately. deepseek is unaffected: its module-level
singletons never call bind_const or bind_layered, so their _const and
_layered stay empty and __call__ remains a pure passthrough.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
… bind_layered

attention_window_size used to be the one argument thop_attention's binding
couldn't absorb: it is per-layer, but not knowable at construction (it
derives from attn_metadata.max_seq_len, which the KV cache manager does not
produce until after weights load), and not per-step either since it never
changes again once known. With bind_const/bind_layered callable from
anywhere, it now has a home: bound once with bind_layered on the target's
first forward, guarded by a one-shot _window_pending flag (mirroring
ModelingV2Core._contract_pending) so the check costs one branch per
target's lifetime rather than one per layer per step.

attn_metadata.max_seq_len is a plain field (interface.py), no attention
backend reassigns it per step, and the only writes are in
KVCacheManager.__init__ and engine construction -- both already done by
the time a target's first forward runs, so the value read there is final.

The per-layer attention call drops the attention_window_size kwarg
entirely: self._attn(q=qkv, output=attn_out, layer=i).

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
A target is not a second line of defence against routing. The router picks a
target from the architecture and key parameters, and re-validating a
checkpoint's whole configuration inside every target does not scale -- this
file carried 93 such assertions.

Removes all 93, along with the expressions left dead behind them. Among the
93 were guards on runtime inputs this target does not implement rather than
on configuration, so those inputs are now silently ignored rather than
rejected.

Verified on the rebuilt 26.08 container: three dep4 gsm8k cases pass
(95.148 / 95.072 / 94.845 / 94.238) and the unit suite is unchanged at 283
passed. Note there is no verified pre-rebuild baseline to compare those
numbers against; the argument that this is safe is that a passing assert
cannot alter a computation.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…eek accuracy gate

mv2_accuracy_deepseek_19345.sbatch is a copy of
modeling_v2_accuracy_deepseek.sbatch repointed at the 26.08/19345
container (the tree this branch is built against), used to verify the
assert-removal change against a real dep4 gsm8k run. Running it
surfaced two pre-existing script defects, independent of that change:

- Each leg trusted pytest's raw exit code. pytest can exit 4
  (USAGE_ERROR) on an all-pass run because of a pre-existing
  nvidia_cutlass_dsl sys.path guard that fires during collection
  teardown; the script already greps the summary line for the
  expected pass count to detect a real failure, but then still
  reported the step as failed via the untouched raw code. Under
  `srun --mpi=pmix --ntasks=4`, a rank exiting nonzero also makes srun
  force-terminate the other three ranks, so a run observed one leg's
  reported exit flip to 143/SIGTERM after every test had already
  printed "passed" -- a symptom of this bug, not a wall-clock timeout
  (that run used 23m30s of its 4-hour budget). Fixed by pinning rc to
  exactly 0 or 1 off the grep result instead of the raw exit code.
- /tmp/mv2_require.out and /tmp/mv2_off.out were written inside the
  container by the pytest step, but read by the summary section after
  the container had exited -- different filesystem namespaces, so
  both greps silently matched nothing. Moved both files under $REPO,
  which is bind-mounted the same way inside and outside the
  container.

--time is left at the existing 04:00:00: the run that hit the 143
symptom used well under a tenth of that budget, so the time limit was
never the actual constraint.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
… own its fixtures

A target that needed a second instance of an entry had to reach its type
through the singleton -- `type(flashinfer_rmsnorm)()` -- because each module
exported only the instance and kept the class private. The class is now the
exported name; the singleton stays, since deepseek and the entry tests still
call it.

bind_layered now takes the layer it is binding rather than a whole table. A
model whose layers are not uniform can then bind only the layers that have a
given operand, which deepseek will need: its first three layers are dense and
the rest are MoE.

The mx MoE runner builds its own alpha and beta from the expert count and a
device, instead of receiving tensors the model manufactured from two constants
that only this op ever read.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
The core carried ten `self.X = cfg.Y` assignments that existed only so the
forward could read them. The values are bound into the entries now, so the
readers take them from `cfg` directly and the attributes are gone. What
remains is derived rather than copied -- the padded widths, the YaRN
quantities -- plus `window`, which the forward pairs with the derived
`sliding` list per layer.

attention_window_size is rebound every forward rather than once behind a
pending flag. It cannot be known at construction, since the KV cache manager
does not exist until after weights load, and it does not change afterwards --
so rebuilding a 36-entry table each step buys nothing except the removal of a
state bit and a branch, which is the trade taken here.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
The core carried 19 `self.X = cfg.Y` assignments -- num_layers, hidden, eps,
vocab, heads, nope, rope, v_dim, kv_lora, q_lora, max_pos, dense_layers,
num_experts, topk, moe_inter, dense_inter, n_group, topk_group, routed_scale
-- that existed only so derive_after_load(), forward() and weights.py could
read them back. Every reader now takes the value from
`core.model_config.pretrained_config` directly, mirroring gpt_oss's
7c18232: a local alias at the top of __init__/derive_after_load/forward
(and in MTPLayer's methods, off `core.model_config.pretrained_config`), not a
second name stored on the core. weights.py's six functions that read the
removed `core.X` attributes do the same.

qk_dim and lat_dim (`self.nope + self.rope`, `self.kv_lora + self.rope`) and
shared_inter (`moe_intermediate_size * n_shared_experts`) are not `cfg.Y`
copies, but the same reasoning applies -- they are recomputed locally at each
of their few call sites rather than kept as a third attribute.

What stays: theta, rope_factor, rope_orig_max, beta_fast, beta_slow,
rope_amplitude and q_scaling (the YaRN derivation has real failure modes --
KeyError, math.log domain errors -- worth catching once, before the weight
load, rather than duplicating); local_experts and expert_offset (combine cfg
with the parallel mapping, and weights.py reads them at a separate time);
mtp_layers and mtp_enabled (derived with real fallback/resolution logic, also
read by weights.py); quant_mode and the parallelism fields (rank, ep_rank,
ep_size, dp_group, dp_size) are not cfg copies at all and are untouched.

No call-site shapes change in this commit -- every op still takes the same
arguments it did before, just read from a local instead of an attribute.
Binding those call sites through the catalog entries' bind_const/bind_layered
is the next two commits (trunk, then MTPLayer).

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
Targets in this tree share no code with each other. _GptOssTarget existed
only because prefill's body and decode's happened to coincide -- gpt_oss is
not MLA, so there is no phase branch to divide -- and a base class justified
by a coincidence is one that breaks the moment the coincidence ends.

PrefillTarget and DecodeTarget now derive from Target directly, each with its
own __init__, step_args and forward. The one real difference stays where it
was: prefill reads both counts off the metadata because it holds the mixed
batch, decode states them as zero because its routing guarantees that.

This costs about 230 duplicated lines. It buys each target being readable and
changeable without reference to the other.

The phase gate gets simpler as a result. It had been widened to follow into
the shared base and the module-level helpers, because scanning DecodeTarget
alone reached a single line; with the body in its own class the direct form
suffices. Verified by planting a read inside DecodeTarget.forward and watching
the gate name it.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…he manifest from it

A weight's shape, its checkpoint source and its load-time transform lived in
three places: the model's declaration block, the manifest, and a function far
from either. The contents were different facts, but the seventeen key names
were written twice, and an assert in load() existed only to police that.

WEIGHTS states each weight once -- shape, dtype, source, transform -- and the
manifest unrolls it rather than repeating the keys. shape is a callable
because every shape here is derived: fc1_k_pad is a padded width, q_width a
product. Sources that assemble one parameter from several checkpoint tensors,
as qkv does from q/k/v, carry a slice per source.

The declaration block in modeling.py still stands; this does not switch over.
A test drives the table and a transcription of that block and asserts they
produce identical keys, shapes and dtypes, which is what the switchover will
stand on.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…alog entries

Fifty-six call sites across three forwards carried shape scalars, call
constants and per-layer weights that never change once the engine exists.
Those move into the entries -- bind_const for what every layer shares,
bind_layered for what each layer owns -- and the calls keep the tensors and
the layer index.

Instances live on the core rather than on a target, because this model has
not been split into prefill and decode targets yet. The phase branch inside
the layer body is untouched for the same reason: dividing it is the Target
layer's job, and the two paths run genuinely different algorithms.

bind_layered taking a layer id is what makes this model work at all -- its
first three layers are dense and the rest are MoE, so only the layers that
have a given operand are bound.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
Three things, all at the same entry point.

The contract check's position_ids parameter went unused once the dtype assert
it guarded was removed; the gpt_oss call site reached it through
kwargs.get("position_ids"), which would have silently passed None had a
caller ever switched to positional arguments. Both are gone.

The pending flag moves inside the check, so the caller writes one line rather
than a branch around a call. That costs a Python call per forward even when
validation is off -- per forward, not per layer, against a step measured in
milliseconds.

advance_step_generation moves to the dispatcher. "A new engine step has begun"
is the dispatcher's knowledge, not a target's, and keeping it there had left
two copies in gpt_oss after the targets stopped sharing a body. It is
deliberately not folded into bind_const: it must fire once per forward while
bind_const fires once per op, and deepseek binds step state to two entries in
one forward. The same placement is what keeps deepseek's MTPLayer correct
without anyone remembering to exclude it -- the layer runs inside one engine
forward and must not start a generation of its own.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…ss model

The model allocated the parameters and the loader said where their bytes came
from, naming all seventeen keys twice. The table names them once and the model
asks it for the dict; the declaration block and its local P() helper are gone.

The drift assert goes with the duplication it policed -- the manifest and the
parameters now unroll the same tuple, so a key in one and not the other is
unreachable. The coverage assert stays: that every checkpoint key is consumed
is a separate claim and still worth making.

The equivalence test keeps its transcription of the deleted block rather than
importing the model. That was the reason for transcribing it: the table stays
held to what this target used to declare, where an import would now have the
test agree with itself.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
gpt_oss and deepseek each defined their own class literally named
ModelingV2Core, deriving straight from DecoderModel. The only thing
genuinely common between the two -- the opt-in step-contract check
(_check_step_contract plus the _probe_step_surface/_after_contract_check
hooks it calls) and the _contract_pending flag's initialisation -- now
lives in a real shared base, tensorrt_llm/_torch/_experimental/modeling_v2/_core.py.
Everything model-specific (__init__, forward, build_layer_views,
derive_after_load) stays on each model's own class, renamed to
GptOssModelingV2 and DeepseekV3ModelingV2 respectively, so the two still
share no code with each other.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
… phase-keyed dict

self._targets: dict[Phase, Target] baked the reason a target is chosen
into the data structure. Replace it with named self._prefill/self._decode
attributes and a _select_target(attn_metadata) method, so a future
selection criterion beyond phase has one place to grow into instead of
the shape of a lookup table. _select_target delegates to _target.py's
phase_of rather than restating num_contexts == 0 inline, keeping that
predicate in the one place that already states it in full.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…check into its targets

_build_step_args was a module-level function both targets called -- the same
shared logic the deleted _GptOssTarget base had held, in another form. Mixins,
helpers and partials were ruled out when that base went; a module-level
function was not, so it survived. Each target now writes out its own
projection. Prefill reads both counts off the metadata because it holds the
mixed batch; decode states them as zero because its routing guarantees that.

The contract check moves with it, into Target: it probes the metadata surface
step_args reads, so it belongs beside it. Both targets running it is correct
rather than wasteful -- their projections differ, so probing one proves
nothing about the other.

deepseek has no targets yet, so its check stays on its core and _core.py keeps
the skeleton for it. The two models are in different states until deepseek is
split; there is a comment saying so, because the alternative is someone
tidying away code deepseek still needs.

The four yarn_* values become locals passed to both target constructors. They
were computed in __init__ and read only when the targets bound
fused_qk_norm_rope -- nothing in between touched them.

Verified on 4xGB300: modeling_v2 unit suite 287 passed / 1 skipped with the two
pre-existing comm op-matrix failures unchanged, and gpt_oss accuracy 89.765.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
WeiHaocheng and others added 23 commits October 11, 2026 20:37
Comments and docstrings were 38% of gpt_oss's modeling.py and 33% of
deepseek's. Most of that was the refactor narrating itself -- why a value is a
local and not an attribute, why a name is not duplicated, why a helper was not
extracted. AGENTS.md puts that in the PR description, not in the code, and a
reader a year from now wants the code explained as it stands rather than a
record of how it got here. The rest restated the line below it.

What survives is one category: a trap where getting the line wrong produces a
wrong answer or a crash the reader could not have inferred. Most of them are
silent -- the MoE op ignoring routing_bias on this routing method, a swizzled
scale layout whose byte count happens to match whenever num_tokens is a
multiple of 128 (every decode CUDA-graph batch), a kv_norm weight that folds a
normalization this target already applied. Each is now one or two lines
stating the trap and nothing else. gpt_oss keeps 5, deepseek 14.

Docstrings keep their first sentence; the later sentences stay only where they
state a contract a caller must obey that the signature does not show.

gpt_oss 1006 -> 658 lines, deepseek 1917 -> 1381.

Proved comment-only by mv2_comments_gate.py, which parses each file before and
after, strips docstrings from both, and compares the executable AST. It is
red-green tested: unchanged file OK, deleted comment block OK, a one-token edit
to routing_bias FAIL. Both files report OK, so no test run is needed to know
the behaviour is untouched.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…it build the manifest

ModelWeights is to weights.py what ModelingV2Core is to modeling.py: the
table's three passes -- declare, manifest, load -- are the same loop over the
same table for any model, so they live on a base and each model supplies only
its WEIGHTS and its dims().

The manifest was the point. It did not unroll WEIGHTS; it wrote the checkpoint
key, destination index and transform of all seventeen roles out a second time
by hand, which is exactly what the table exists to stop. A comment above the
loader claimed both sides unrolled the table and used that to justify dropping
the drift check between them -- the claim was not true of the code it sat in.
manifest() now generates those rows, so a role has one source of truth again.

Two more copies go with it. dims() is now the only place a derived width or
padded size is worked out: q_width and kv_width used to be computed in the
core's __init__ and again in _manifest, and the three padded sizes in __init__
while the transforms read them back off the core. And _FC1_K_ALIGN was a
literal in modeling.py, for the alignment the quantizer pads to, while the
table independently decided the declared width it has to match; it is one
constant in weights.py now, imported by modeling.py.

Dropped on the way: _pad_up and the dtype local in modeling.py, both left
without readers once the padding moved.

_manifest stays, dead, for one commit. The equivalence test compares the
generated manifest against the real function rather than a transcription of
it, and that is only possible while both exist. It is already green.

That test also found a fixture bug it was not looking for: _DIMS said
fc1_k_pad was 2944, padded up to 128 like its neighbours, where the model pads
to FC1_K_ALIGN and gets 3072. The declaration tests feed both sides the same
bundle, so the wrong number agreed with itself and had been proving the table
at a shape -- fc1_w 1472 columns wide instead of 1536 -- that the model never
allocates. Corrected here.

Not yet verified: the unit suite and the gpt_oss accuracy run are in flight as
job 1009393. The weight-table tests passed; declare, dims and manifest
equivalence are green.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…, and drop the probe indirection

build_layer_views said what it did on its first day and has not since: it
builds the targets, derives the GEMM-side views, and computes the YaRN ramp.
What it actually is is the model's post-load hook, and DecoderModel already
has one by that name -- so this was a second name for an existing concept,
and the shell had to know the private one to call it. The core now overrides
post_load_weights, calls super(), and the shell's line reads
self.model.post_load_weights().

Checked before renaming: the base's post_load_weights runs setup_aliases,
transform_weights and cache_derived_state, which are empty on this path and do
not descend into submodules, so calling super() here adds three no-ops rather
than re-running a transform over the core's subtree.

_probe_step_surface was a one-line method that existed only so a generic
_check_step_contract could reach a model-specific projection. The check now
calls the projection directly. For gpt_oss that was already true -- Target
calls its own step_args -- so this only concerned deepseek, whose check moves
onto its core beside the _build_step_args it probes.

That empties ModelingV2Core: gpt_oss set _contract_pending there and never
read it (its targets own one each), and deepseek now owns both the flag and
the check. A base class holding nothing is worse than no base class, so
_core.py is deleted and both cores derive from DecoderModel. deepseek keeps
_after_contract_check as a named method -- it has a real body, growing the
rope table to the engine's resolved max_seq_len.

_build_step_args stays a module-level function: MTPLayer.forward calls it too,
so it is not the core's method to own.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>

Folds in the follow-up fix: the core's post_load_weights is not an override
(DecoderModel has no such method, so there is no super() to call), and the
weight table is a class attribute of its ModelWeights subclass.
…decode target

deepseek is the model the target layer was built for. MLA's TrtllmAttention
serves context_only or generation_only and refuses a mixed batch, so the phase
split already existed -- as two attention instances and an `if tc:` / `if gen:`
branch inside one 233-line forward. This moves it up to where gpt_oss keeps it.

DecodeTarget binds 23 ops against the prefill target's 27: the context
attention instance, the kv_b projection and the two paged-KV helpers all serve
a path its routing guarantees never runs. Its forward loses the context block
and, per layer, the two torch.split calls that separated rows it cannot have --
61 layers' worth. forward 233 -> 177 lines, __init__ 289 -> 236.

The two targets share no code, including their copy of _dense_mlp, which binds
target-owned ops and so cannot stay on the core.

Both bodies were cut from the original source rather than retyped, and the
result is checked structurally: PrefillTarget constructs the same 27 ops and
the same 24 layered bindings the core did, and DecodeTarget's set differs by
exactly those four names.

Separately, a bug this exposed. _after_contract_check hung off the contract
probe, which is gated on TRTLLM_MODELING_V2_VALIDATE -- set by both accuracy
tests and by nothing in a served engine. But it does not check anything: it
grows the rope table to the engine's resolved max_seq_len and decides
_cached_ctx. With the flag off, production kept a table sized to
max_position_embeddings -- an unchecked out-of-bounds read once
speculative_config inflates max_seq_len past it -- and _cached_ctx stuck at
False, making the cached-context path dead code. It is now _derive_first_step
on the core, unconditional. The probe itself stays opt-in, on each target,
where step_args is what it calls.

The rope tables, MTP operands and side stream stay on the core: MTPLayer reads
the first two, and a CUDA stream made inside a graph capture is not capturable.

derive_after_load becomes post_load_weights, matching gpt_oss.

Not yet verified: deepseek accuracy is in flight.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…ore class name

DeepseekV3ModelingV2 said which checkpoint family it served and nothing about
how it is parallelised, but the parallelism is not incidental here: the forward
allgathers before the router and reduce-scatters after it, the expert window is
this rank's slice of 256, and the MoE is the only thing that needs a collective.
A second strategy over the same checkpoint is a different core, not a flag on
this one, so the name carries the tag: DeepseekV3Dep4ModelingV2.

dep4, not tep4: this repo treats them as different strategies -- deepseek_v3's
routing.py even has a "tep4_to_dep4" case -- and this target's config is
tensor_parallel_size=4, moe_expert_parallel_size=4, enable_attention_dp=True,
which is attention DP, not attention TP.

The shell already carried the full tag (ModelingV2DeepseekR10528Nvfp4Sm103Dep4);
this brings the core into line with it. gpt_oss has the same gap -- its core is
GptOssModelingV2 against a ModelingV2GptOss120bSm103Tp1 shell -- and is left
alone here rather than folded into a deepseek change.

Name-only: no behaviour, and the accuracy run that cleared the target split
(gsm8k 94.69 require / 93.93 off, acceptance 2.920 / 3.037) stands.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
MTPLayer never got the op-wrapper conversion the trunk had. It made 28
module-level calls -- thop_attention(...), mla_rope_generation(...),
cublas_mm(...) -- each passing every argument explicitly, including forty on
one of them, while the trunk next to it called 30 bound instances. That is
why "move MTP into the targets" is not a relocation: a target holds bound ops,
and these were not ops yet.

Now 30 bound instances and zero raw calls. The module is a single layer, so
every binding is bind_const and there is no layered table.

The positional calls were converted against each op's raw_call signature rather
than by eye: the three MLA entries take 21, 16 and 47 parameters and the call
sites passed 21, 16 and 40 in order, the last seven taking their defaults. Each
argument kept its value and gained its name.

Binding happens in a new MTPLayer.post_load_weights, driven by the shell after
the core's own hook: this module is built in the shell's __init__, while every
parameter is still meta, and its operands come from core._mtp, which that hook
derives.

The step projection and the rope tables are rebound on every call rather than
once. The worker rewrites attn_metadata in place between draft step 0 and step
1+, so a value read on the first of the N calls in one engine forward is wrong
on the rest -- and frozen wrong under capture. core._rope can also have grown
on the engine's first step.

This is D2a. D2b splits the forward by phase into the two targets; it needs the
ops to exist first, which is what this does.

Not yet verified: deepseek mtp3 accuracy is in flight.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…to carry it

The deepseek target now states each weight once, as a W, like gpt_oss: a
75-line hand declaration block in the core's __init__ becomes one declare()
call, the hand-written _manifest goes, and __init__ loses the sixteen local
dims whose only reader was that block -- dims() derives them now, once.

W keeps its field count; two existing fields widen instead. `src` gains a
callable form `(d, i) -> [(key, index), ...]`, whose two old forms are special
cases: it exists for sources that are not a fixed template -- this rank's
expert window (the checkpoint key contains expert_offset), a prefix that
differs between layer families, and multi-key rows whose tensors feed one
transform together. `per_layer` becomes `layers`: ALL_LAYERS, None, or a
callable saying which layers carry the weight, because deepseek's dense and
MoE layers do not carry the same set. declare and manifest now share
_targets(), so they cannot disagree about whether a weight exists.

Two findings the table forced into the open:

The MTP module is layer 61. Eleven of its weights match a trunk layer's
exactly -- shape, key template, transform -- and the checkpoint names them
model.layers.61.*. The trunk entries now extend one layer when MTP is live,
and the mtp_* parameter keys become l61_*; only enorm/hnorm/eh/head_norm and
the bf16 expert family are the module's own.

The dense MLP and the shared expert share a parameter key and nothing else:
different intermediate width, different checkpoint prefix. Two table entries
over disjoint layer ranges, same name, rather than one entry pretending they
are one weight.

deepseek overrides load: multi-key rows hand their transform several tensors,
the 0-dim NVFP4 scalars reject `[:]`, lm_head is asserted whole (attention DP
replicates it), and coverage is asserted in both directions, with the expected
leftover -- this rank's off-window experts plus the MTP layers -- supplied
through the expected_unconsumed hook.

Proven before the hand-written code went: with both still present, the
generated manifest was compared against _manifest per key and per source, MTP
on and off, and declare against a transcription of the init block -- 12 passed
(SLURM 1081689), after which _manifest, its helpers and the comparison tests
were deleted together and the suite rerun green (1081889, 293 passed with the
two pre-existing comm failures). The declare transcriptions stay in the tests.

gpt_oss: per_layer=False becomes layers=None on two entries; its own dead
_manifest and the test that had proven it go the same way.

Not yet verified: both models' accuracy runs are in flight.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
The MTP module's forward carried the same `if tc:` / `if gen:` branch the trunk
had before its target split, and the same reason to lose it: MLA serves
context_only or generation_only, never mixed. Each target now carries its own
mtp_forward and binds the module's ops in its own __init__, from core._mtp,
under the same _mtp_-prefixed names on both.

DecodeTarget's copy binds 23 of the prefill side's 27 -- the context attention
instance, the kv_b projection and the two paged-KV helpers serve a path its
routing guarantees never runs -- and its body loses the context block, the
three per-call splits and the attn_ctx rebind: 118 lines against prefill's
174. Every draft step past the first in an engine forward takes this shorter
path.

MTPLayer stays, as the object the spec worker holds across calls, and shrinks
to the two things that are genuinely its own: shared_head with its norm --
that entry point is called directly by the worker and reads no phase -- and a
forward that is now one routing line. The routing runs per call, not per
engine forward, because the worker rewrites attn_metadata in place between
draft step 0 and step 1+: one engine step's N draft calls can cross from
prefill to decode, and each one asks _select_target again.

Both bodies were cut from the D2a source rather than retyped, with the same
statement-level tooling as the trunk split, and checked the same way: the
prefill op set equals D2a's bindings minus the head norm, decode's differs by
exactly the four context names, and each tc-elimination rewrite asserted a hit.

Verified on 4xGB300: modeling_v2 suite 293 passed with the two pre-existing
comm op-matrix failures unchanged; deepseek accuracy require leg 2 passed
(gsm8k 95.07, acceptance 2.912 -- the mtp3 case drives both targets'
mtp_forward, draft step 0 through prefill and steps 1+ through decode), stock
leg 1 passed (95.07, acceptance 3.018).

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…re class name too

The same change deepseek's core already had: tp1 is not incidental to this
target -- every expert is local, the quantizer pads for a single rank's
operands, and there is no collective anywhere in its forward. A second
strategy over the same checkpoint would be a different core, so the name
carries the tag, matching the shell (ModelingV2GptOss120bSm103Tp1) and the
directory (gpt_oss_120b__sm_103__tp1).

Name-only; five references, one file.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
3fafe99 committed the deepseek accuracy gate's SLURM script to the repo
root while fixing its exit-code handling. It is developer scaffolding -- the
in-tree gates are the tests it drives -- and does not belong in the package.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
The target split removed deepseek's phase branch by routing: DecodeTarget's
body lost `if tc:` because its routing guarantees no context rows. The prefill
side kept its body verbatim -- including the now always-true guard. A target is
not a second line of defence against its own routing: `phase_of` sends a batch
here exactly when num_contexts >= 1, so tc >= 1, and the guard goes, along
with the two `if tc else 0` expressions it justified. Both forward and
mtp_forward, since the draft step routes the same way per call.

`if gen:` stays in both, and is not a phase branch: prefill is the general
case -- context rows and possibly generation rows beside them -- so gen may be
zero, and that is batch composition, not routing.

Verified on 4xGB300: modeling_v2 suite 295 passed / 0 failed, deepseek gsm8k
identity 94.69 / mtp3 95.00 (reference 94.24), acceptance 2.954 against stock
2.998.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…he layer loop

The trunk forwards passed seven metadata projections to _mla_gen at every
layer -- sequence lengths, host lengths, the three KV-pool handles,
num_contexts -- values that cannot change within a step. They join the
per-step bind_const at the top of forward, as an explicit subset rather than
**step because the MLA helpers' raw_calls do not take the projection's
engine-bookkeeping keys. Prefill's _mla_append and _mla_load get the same
treatment, guarded by the cached-ctx flavor whose ctx_* fields are the only
place those metadata attributes exist.

The attention scratch moves out of the loop: eight tensors (nine on the
prefill side, which also owns the context K buffer) were allocated per layer,
488 empties per decode step for buffers whose sizes depend only on step
constants. One set now serves all layers -- safe because each layer's producer
fully rewrites a buffer before that layer's consumer reads it and the whole
attention path runs on the main stream, so layer i+1 cannot overwrite what
layer i still reads. Six of them ride the per-step bind of _mla_gen and
_attn_gen together with predicted_tokens_per_seq.

The call sites keep only the data flow:
_mla_gen(fused_q=..., q_pe=..., latent_cache=..., layer=i).

The two mtp_forwards are deliberately untouched: the draft step runs a single
layer per call, so there is no repetition to hoist, and the same change there
would scatter one call across three binding sites for no reuse.

Verified on 4xGB300: modeling_v2 suite 295 passed / 0 failed; deepseek gsm8k
identity 94.69 / mtp3 94.69 (reference 94.24), acceptance 2.942 against stock
3.018.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…ut = op(in)

gpt_oss's targets follow one form -- a call site always binds what the op
wrote, even when it also passes the buffer (attn_out = self._attn(q=...,
output=attn_out)). deepseek still had op(in, out) statements, which read as if
their tensors were only consumed when some were being overwritten.

The root cause sat in the catalog, not the call sites: three deepseek-only
in-place entries -- bmm_out, mla_rope_generation, mla_rope_append_paged_kv_
assign_q -- had raw_calls that returned nothing, so there was nothing to bind.
They now return what they write (out, fused_q, q), the same move the three
norm entries got when gpt_oss adopted the form.

All four forwards convert, both trunk and draft:

    fq_nope_t  = self._absorb(a=torch.transpose(q_nope, 0, 1), out=fq_nope_t, layer=i)
    fused_q    = self._mla_gen(fused_q=fused_q, q_pe=q_pe, latent_cache=latent_gen, layer=i)
    lat_out    = self._attn_gen(..., output=lat_out, layer=i)
    attn_gen_t = self._expand(a=lat_out_t, out=attn_gen_t, layer=i)
    q_ctx      = self._mla_append(q=q_ctx, latent_cache=latent_ctx, layer=i)
    attn_ctx   = self._attn_ctx(..., output=attn_ctx, layer=i)
    o, residual       = self._norm2(o, residual, layer=i)
    mlp_out, residual = self._next_norm(mlp_out, residual, layer=i)

The transposed scratch views (fq_nope_t, lat_out_t, attn_gen_t) become named
step constants beside the buffers they view, which also collapses the absorb
and expand call sites from nested-transpose blocks to one line each. Checked
structurally: zero bare-expression op calls remain in any target forward of
either model.

bmm_out's unit test pinned the old contract -- `assert op(a, w, out) is None,
"the op writes in place"`. It now asserts the stronger version of the same
fact: `is out` proves the op wrote this very buffer and returned it, not a
copy that happens to match.

Verified on 4xGB300: modeling_v2 suite 295 passed / 0 failed; deepseek gsm8k
identity 94.77 / mtp3 94.77 (reference 94.24), acceptance 2.930 against stock
3.019.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
…log into the model

The catalog is meant to be the shared vocabulary; an op only one target ever
calls does not belong there. Eleven move into an `ops/` subtree under their
owning model, keeping the category layout (attention/moe/gemm/quantization):

  deepseek (8): load_paged_kv_cache_for_mla, mla_rope_append_paged_kv_assign_q,
    mla_rope_generation, bmm_out, fp4_block_scale_moe_runner, fused_moe,
    noaux_tc_op, fp4_quantize
  gpt_oss (3): fused_qk_norm_rope, mxe4m3_mxe2m1_block_scale_moe_runner,
    mxfp8_quantize

Eight stay shared: thop_attention, cublas_mm, flashinfer_rmsnorm,
flashinfer_fused_add_rmsnorm (multi-model), plus flashinfer_silu_and_mul,
allgather, reducescatter, nvfp4_gemm (deepseek-only today but kept in catalog
for generality -- SiLU is a ubiquitous activation, the two collectives are
distributed infrastructure any TP/EP model needs, and nvfp4_gemm is the generic
NVFP4 GEMM beside cublas_mm).

Each moved op is wrapper + GPU test (no .md contracts exist despite the index's
aspiration). Both move via git mv, preserving history. The wrapper's one import
-- `from .._op import ...` -- becomes the absolute catalog path, since _op
stays in the catalog; modeling.py imports the op through a relative `.ops.<cat>`
path. The test keeps its `__extra_import_path__` mechanism, widened to reach
_validating from two directories deeper.

index.yaml drops the eleven moved entries (now 8). The CI lists collect
`unittest/_torch/modeling_v2` by directory, and the moved tests stay under that
tree (models/<model>/<cat>/), so they are still collected without a list change.

Import closure checked statically across all model files; ruff clean. Accuracy
verification in flight.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
The allgather and reducescatter op matrices spawned their own 4-rank mpirun,
which inside a SLURM-launched container picks the slurm launcher (shells out to
srun, absent in the container) and sizes its slot budget from the outer
allocation (refuses -n 4). The tests were correct -- CI runs them -- but they
could not run anywhere else without three OMPI env vars, and they looked like
known failures for weeks.

They now spawn one torch.multiprocessing worker per device and rendezvous
through torch.distributed's env://, so they run on any 4-GPU box with a plain
pytest and no environment at all. Each worker scrubs the inherited PMIx/PMI/
OMPI namespace from its children, or the first hard exit trips srun's pmix
errhandler and SIGKILLs the whole step.

Running under torch.distributed means the ops take the ProcessGroup path the
product uses when mpi_disabled(): the entries gain a `process_group` argument
that selects the `_pg` op symbol (None keeps the MPI-session symbol), and the
matrices bind a boxed c10d group once, so every call site keeps its certified
shape. A rank subset is now a real sub-ProcessGroup, not just a shorter group
list. The host-side engine-sync check uses a gloo group rather than
constructing the engine's TorchDist, which needs ray importable.

Three kinds of check changed because the PG path's behaviour differs from the
MPI path's, none of them the wrapper's own contract:
- the wrapper guards (non-contiguous, 0-d, wrong-length sizes, uncovered split,
  float8) drop the "the bare op silently corrupts" demonstration and keep only
  the assertion that the wrapper rejects the input -- the PG op raises instead
  of corrupting, and the guard is what the test protects.
- the cold-first-capture check inverts: the PG communicator exists before any
  op runs, so a group's first call captures into a graph straight away rather
  than failing; replay correctness is what it now certifies.
- the two checks that measured the MPI op's corruption-on-misuse (byte-pattern
  float8 sum and poison / mispaired) are removed: they asserted MPI
  implementation detail, the PG path does not reproduce it, and neither carries
  a wrapper contract. The launcher's wedge sub-job and its helpers go with them.

The per-batch-size graph capture copies each op output into a persistent buffer
outside the shared graph pool: the ops' pooled outputs alias across graphs, so
a burst of replays with no sync between them left them all holding the last
writer's result. The copy, ordered after the op on the one stream, reads the
right value before the next graph overwrites the pool, which is what lets the
burst pass test the collective's ordering rather than die on pool aliasing.

Verified on 4xGB300: allgather 21 checks, reducescatter 28 checks, all ranks,
with no mpirun and no OMPI env; the modeling_v2 unit suite is green.

Signed-off-by: Fred Wei <20514172+WeiHaocheng@users.noreply.github.com>
… contiguous-side offset in 64 bits

token_idx * head_dim was evaluated in 32 bits and wrapped once token_idx * 64 reached
2^31, so the gather wrote rows at index >= 2^25 out of place without an error. The same
patch is proposed separately against main.

Signed-off-by: Wanli Jiang <35160485+Wanli-Jiang@users.noreply.github.com>
…he OpenAI server's pending media loads

A message parsed by the OpenAI server carries its pending media loads (coroutines) under
"media"; deep-copying it raised and every image request through trtllm-serve returned
HTTP 400. The renderer never reads them, so they are left out of the copy. The same patch
is proposed separately against main.

Signed-off-by: Wanli Jiang <35160485+Wanli-Jiang@users.noreply.github.com>
…s a template parameter and adds (128, 3)

DeepSeek-V4.1-Flash routes 128 experts with top-3. The custom MoE routing kernel and its
thop binding take top-k as a template parameter, on top of the image-routing variant
already in the tree.

Signed-off-by: Wanli Jiang <35160485+Wanli-Jiang@users.noreply.github.com>
…-V4 sparse backend and the runtime hooks the modeling_v2 target uses

The in-tree DeepSeek-V4.1 runs on the CSA2 sparse backend and is unchanged. The modeling_v2
target keeps the DeepSeek-V4 backend in a V4.1 layout mode, entered only through
v41_layout.use_deepseek_v41_layout_mode(model_config): 128-token pages, the Engram token
role (DeepSeek-V4's disaggregated layer ids are unchanged), encoder/decoder replay and
native / FP4 / FP8 KV storage. Adds Triton custom ops for NVFP4/MXFP8 KV fake
quantization, read-back and E4M3 transcode, trtllm::dsv4_local_to_global_indices, and
small hooks: autotuner fallback_tactic(), per-request last-chunk/context-logits flags in
attention metadata, admission refusal in resource managers, profiling of a multimodal
encoder that runs inside the forward, and a model-class opt-in for lazy safetensors.

Signed-off-by: Wanli Jiang <35160485+Wanli-Jiang@users.noreply.github.com>
…target calls, and its certified cells on them

Promotes bmm_out, fp4_block_scale_moe_runner and fp4_quantize (from deepseek_v3's ops/)
and mxe4m3_mxe2m1_block_scale_moe_runner and mxfp8_quantize (from gpt_oss's ops/) back
to the shared catalog, since a second target now calls them; the owning targets import
them from there. Adds the cells the DeepSeek-V4.1 target calls shared entries at, and
makes the claims gates scan every target's ops/ as well as the catalog.

Signed-off-by: Wanli Jiang <35160485+Wanli-Jiang@users.noreply.github.com>
…rapper entries

The kernels only the DeepSeek-V4.1 target calls, each an OpWrapper whose CELLS, ARCHS
and note are its certified contract, with a test that drives every cell: sparse MLA
(bf16 and FP8 KV), the compressor and indexer kernels, DeepGEMM FP8xFP4 MQA logits,
Engram hash/gather/gate, mHC fusions, MXFP8 GEMM and quantization, NVFP4/MXFP8 KV
conversions, gate_forward, allreduce and pipeline send/receive.

Signed-off-by: Wanli Jiang <35160485+Wanli-Jiang@users.noreply.github.com>
…get (sm_103, unified parallelism)

A ModelingV2Core with PrefillTarget and DecodeTarget, ModelWeights tables, the Engram
host table and the target's own input processor, routed by TRTLLM_MODELING_V2 for
DeepseekV41ForConditionalGeneration checkpoints. Text-only DeepseekV41ForCausalLM and
TRTLLM_MODELING_V2=off stay on the built-in model. create_input_processor asks the
modeling_v2 router first; under auto, a configuration no target serves is refused
rather than pairing the target's processor with the built-in model.

Signed-off-by: Wanli Jiang <35160485+Wanli-Jiang@users.noreply.github.com>
…s and quickstart support for the modeling_v2 DeepSeek-V4.1 target

GSM8K and MMMU gates and encoder/decoder replay bitwise gates for the target on GB300,
keyed apart from the in-tree DeepSeek-V4.1's references; test-db entries sized from
measured durations (the target's ops tests post-merge, its multi-GPU tests on GB300);
CODEOWNERS; quickstart_advanced accepts --spec_decode_algo DSPARK.

Signed-off-by: Wanli Jiang <35160485+Wanli-Jiang@users.noreply.github.com>

This branch has not been deployed

No deployments
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.

2 participants