Skip to content

fine-grained kernels: monolith PR - #49658

Draft
IlyasMoutawwakil wants to merge 222 commits into
mainfrom
finegrained-quantizer-redesign
Draft

IlyasMoutawwakil wants to merge 222 commits into
mainfrom
finegrained-quantizer-redesign

Conversation

@IlyasMoutawwakil

@IlyasMoutawwakil IlyasMoutawwakil commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

🤗 Transformers CI

Integrates finegrained-kernels for state-of-the-art quantized dense and MoE inference, and redesigns quantization around fine-grained, per-module control. The kernel bundle covers full precision, FP8, MXFP8, MXFP4, and NVFP4 with fused MoE execution, activation quantization, and hardware-specific dispatch. Benchmarks and reproduction instructions compare real model shapes on B200 against existing kernel backends.

  • Make fine-grained quantization the shared entrypoint. FineGrainedConfig maps module patterns to format configs, so experts, dense layers, and other supported modules can use different weight and activation formats in one model. This includes DeepSeek-V4’s MXFP4 experts alongside block-FP8 linears.
  • Route supported compressed-tensors and ModelOpt exports through the new integration. Read their metadata into individual format configs or a per-module plan, including ModelOpt MIXED_PRECISION assignments. Map supported FP8, MXFP8, MXFP4, and NVFP4 variants; reject unsupported runtime transforms and scale encodings rather than silently treating them as NVFP4.
  • Keep format-specific rules in their quantizers. Reuse the base companion-conversion pass; FP8 owns scalar-scale adjustments and NVFP4 owns its embedding validation and scale placements.
  • Redesign the format quantizers. Give block FP8, MXFP8, MXFP4, and NVFP4 dedicated configs over shared conversion, loading, and parallelism machinery. Separate block FP8 from the legacy FineGrainedFP8Config integration, which remains available explicitly.
  • Support the complete model lifecycle. Load pre-quantized checkpoints, quantize on the fly, load dequantized weights, save/reload, and run with device maps, tensor parallelism, or expert parallelism. Normalize FP32-stored UE8M0 scales, including DeepSeek-V4-base’s, to native UE8M0 on save.
  • Temporarily use masked expert parallelism until the EP implementation PR is merged. Quantizers switch the default EP plan to replicated routing, local expert masking, and all-reduce. DeepGEMM MegaMoE retains its own routing.
  • Add shared coverage and documentation: kernel dispatch and conversion contracts, all mixed-format pairings, tiny-checkpoint accuracy and serialization, generation, and distributed load-path equivalence with full-graph static-cache decoding. Correctness tests set a one-trial autotuning budget and explicitly disable basin probes, refinement, and winner remeasurement; they can reuse an explicit Triton cache.

Validation: 278 CPU tests passed; 248 GPU integration/kernel tests passed with two skips; all 16 mixed-format load/save/reload cases passed against the updated local weight-only kernel bundle; 10 GPU generation/distributed tests passed with one expected DeepSeek-V4 compile skip. Static-cache decode compilation runs under distributed EP/TP. The older large-checkpoint Llama/GPT-OSS test classes were excluded from the GPU integration run. make typing and make style pass.

Kernel follow-up: explicit autotuning phase controls, native-UE8M0-optional imports and weight-only block-FP8 require updated finegrained kernel bundles. The embedding API is required; kernels release before or alongside this PR. CSF-compressed scales remain unsupported. NVFP4 PLE embeddings now load packed and decode only selected rows, with byte-exact sharded save/reload; full-table embedding dequantization is explicitly unsupported. Fused FP8/NVFP4 embedding lookup is integrated and requires the accompanying kernel release; compiled FP8 keeps its faster PyTorch expression. The Muse-Spark tiny fixture was rebuilt in the ModelOpt layout with the original NVFP4 exclusion policy.

IlyasMoutawwakil and others added 30 commits August 13, 2026 12:54
The kernels now store gate|up row-interleaved (gate j at row 2j, up j at 2j+1)
rather than as two N-apart halves, so the integration follows:

  - GPT-OSS already ships this order on disk, so its de-interleave converters go
    away entirely: _deinterleave_gate_up_rows, its use in FineGrainedMxfp4Deserialize,
    and the whole FineGrainedGateUpBiasDeinterleave op plus its registration.
  - Checkpoints that ship gate_up stacked are interleaved once post-load by
    interleave_gate_up_after_loading, ordered BEFORE swizzle_scales_after_loading
    (the swizzle packs whatever row order it finds) and skipped for mxfp4. This
    cannot live in conversion_mapping's MergeModulelist+Concatenate chain — that is
    shared with non-finegrained MoE models.
  - _apply_gate de-interleaves with a stride-2 split, mirroring the fused epilogue's
    split_gate_up; the two must agree or fused and unfused stop being comparable.
  - swizzle_scales_after_loading drops gate= and gates on the doubled extent
    (n_rows % 128, i.e. N % 64), so GPT-OSS N=2880 now pre-swizzles instead of
    falling back to an affine scale read.

Adds tests/kernels/test_finegrained.py (24 tests), covering the post-load interleave,
that mxfp4 is left untouched, dispatch/swizzle gating, and the MergeModulelist path.

tests/kernels/test_finegrained.py: 24 passed.
The kernels store gate|up row-interleaved, but ``transform_weights_for_mega_moe``
does its OWN gate/up interleave and so takes the stacked form. Interleaving those
modules at load and undoing it at the boundary is a pointless round trip — and
interleaving twice is not the identity, it is a different permutation, i.e. silent
garbage rather than a crash.

So modules bound for megamoe are simply never interleaved: the post-load pass skips
them and records what the module actually holds in ``_gate_up_interleaved`` (the
bytes cannot be inspected to tell, and ``set_experts_implementation`` can switch
backends after load). ``setup_megamoe_weights`` reads that flag and raises if it is
ever handed an interleaved module, rather than re-permuting on an assumption.

The other DeepGEMM expert paths need no change: their grouped GEMM is layout-agnostic
and the gate split happens in ``_apply_gate``, which already de-interleaves stride-2.

Verified: triton backend interleaves and flags; megamoe backend is left untouched with
no round trip. The raise itself is unverified here — DeepGEMM's JIT needs a CUDA
toolkit >= 12.9 and CUDA_HOME is unset on this box, so no megamoe path executes.
# Conflicts:
#	src/transformers/integrations/hub_kernels.py
#	src/transformers/quantizers/auto.py
The memoized `(epilogue, gate_up_quantization, down_quantization)` triple bought
nothing where it matters. Constructing the three dataclasses measured ~1.4us per
layer (~85us per step at 61 layers), and only in EAGER decode — under cudagraphs
the host path does not run on replay, which is the mode this integration deploys
in. Against that it held hidden mutable state on the module, and cached a gate_up
quantization carrying an `output_recipe` that silently mismatches any caller
asking for the unfused form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kernels now take a per-expert bias as a `bias=` operand, so a biased model
no longer falls back to the unfused two-GEMM form just for having a bias.
`_kernel_epilogue` stops bailing on `has_bias`, and both host-side adds go away:
`_apply_unfused_gate_up` keeps only the activation, `_finish_down` only the
routing-weighted reduce. That also drops the `torch.sort` the grouped path paid
to index the gate_up bias by expert-sorted row -- the kernel indexes by the tile's
own expert id instead.

An unsupported act_fn no longer costs the bias its fusion either: the bias is an
operand beside the epilogue rather than part of it, so it fuses whether or not
the GLU does, and `epilogue is None` goes back to meaning exactly one thing.

The bias rides the same output-row axis as the weight and scale grid, so
`interleave_gate_up_after_loading` already delivers it in the kernels'
interleaved order -- no reordering here.

Tests assert the operand reaches both GEMMs, plus a new case pinning that an
unfusable activation still fuses its bias.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict resolutions:
- quantizers/auto.py: keep the finegrained claims on fp8/nvfp4/modelopt
  (pre-quantized + MoE experts; NVFP4HfQuantizer only quantizes on the fly),
  take main's new gguf entry.
- integrations/__init__.py: union — the finegrained module plus main's
  FP8Embedding exports.
- integrations/deepgemm.py: ours (to_local unwrap + the stacked-gate guard);
  re-add the to_local import main's shim conversion dropped.
- integrations/tensor_parallel.py: main's shim, with to_local restored on it —
  the kernel integrations still import it from this path.
- distributed/sharding_utils.py: re-apply the 0-dim scalar guard (ModelOpt
  per-projection weight_scale_2/input_scale) inside the rewritten
  DtensorShardOperation.shard_tensor: replicated on the dense path,
  expert-ownership-filtered on the MoE path.
- tests/tensor_parallel: main's rewrite, with the 0-dim sharding test re-added
  against the new engine (passes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e kernels' MoE chain

The experts forwards are adapters over the kernels' `moe_fused_batched/grouped`: `_moe_operands`
hands them the module's tensors and the activation — a `get_supported_act_fns()` name the gate_up
epilogue fuses, else the module's own GLU as a callable the kernels run on the host, so any
activation works without a kernel release. The kernel bundle is loaded by symbol name
(`matmul_2d/batched/grouped`, the two forwards, swizzle/unswizzle, `get_supported_act_fns`).

Every layout difference between a checkpoint and what the modules hold is a `ConversionOps` with a
reverse, attached by the quantizer, so `save_pretrained` restores the checkpoint bitwise:
- `FineGrainedInterleaveGateUp`: stacked [gate; up] rows -> the kernels' [g0, u0, ...] order,
  skipped when the model declares `is_concatenated=False` (GPT-OSS; the flag travels through
  `use_experts_implementation`, which stamps its defaults after `__init__`) or the backend packs
  gate|up itself (DeepGEMM Mega MoE);
- `FineGrainedScaleContainer`: a scale into the dtype its module holds — the same bytes for a
  uint8 UE8M0 container (MiniMax), an exact cast for float32 values (dsv4-flash-base) — and the
  module records the shipped container so the reverse restores it;
- `FineGrainedSwizzleScales`: the SWIZZLE_32_4_4 artifact for modules that hold 5-D scales
  (`FineGrainedExperts.__init__` allocates that shape for every non-DeepGEMM backend on SM100
  with a group-scaled format and activation quant; whole 128-row/4-col blocks only);
- `FineGrainedPackedBlocks` regroups GPT-OSS `{proj}_blocks` into the packed weight, so the
  blocks/scales format is two one-to-one converters and round-trips too.
Catch-all converters cover keys that already arrive under the fused names and dense scale keys.
The post-load hooks, `_save_to_state_dict` override and post-load dtype cast are gone.

Also: the eager per-expert loop reads a swizzled stack's `[e:e+1]` slice and passes bias, NVFP4
global and activation format (it dropped all three); expert biases take the model dtype; the
`mxfp4` format pins UE8M0 scales; `FineGrainedEmbedding` (FP8 table, per-tensor scale) for
`modules_to_convert`; DeepGEMM backends refuse modules holding swizzled scales or interleaved rows
before loading the kernel; `disable_deepgemm_on_multi_device` is public.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…heckpoint precision for quantized params

`FineGrainedQuantize` reads the module's weight format: block-FP8 in torch (per-block E4M3, fp32 or
power-of-two UE8M0 inverse scales, from the module's block and held scale dtype), MXFP8 / MXFP4 /
NVFP4 through the kernels' row-wise quantizers in one launch per tensor (NVFP4 normalized by the
per-tensor / per-expert global `amax / (6 * 448)`), and emits the scale (and global) in the layout
the module holds — the container dtype, the swizzled artifact — since the loader runs the
quantization op after the converter ops.

Core loader: a parameter the quantizer's op is about to quantize is materialized in the checkpoint's
precision instead of the empty parameter's storage dtype (int8 storage zeroed FP4 weights; float8
double-rounded the existing FP8 path).

Also: `auto.py` groups the finegrained keys under one comment and drops unused NVFP4 imports; the
quantizer's config type hint names `FineGrainedConfig`; DeepGEMM guards (`_assert_affine_scales`,
`_assert_stacked_gate_up`, group-32 divisibility) run before the kernel load; expert biases take
the model dtype; comment trims for the noisy-comments check; tests for every path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion/Epilogue bundle fields

The kernels' dispatchers now take input_recipe/output_recipe strings, so the
bundle no longer needs the Quantization and Epilogue config classes; the
linears hand the module-level activation_format straight through as
input_recipe and the _kernel_quantization adapter is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…uantization/Epilogue bundle fields"

This reverts commit 07dae50. The kernels keep
the Quantization/Epilogue op-boundary configs as the dispatcher API, so the
bundle carries them and the linears build kernel.Quantization again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…kip the kernels arch check

The kernels now speak the module's vocabulary: activation_format (None = the weights'
format, "bf16" = weight-only) goes straight to matmul_2d / matmul_grouped / the MoE
forwards, so the Quantization/Epilogue bundle fields and both adapter helpers are gone.

hub_kernels: kernels >= 0.16 checks a build's declared archs against the device; the
deep-gemm build declares only 9.0a although it JIT-compiles per device, so the mapping
opts out via a new check_arch passthrough (only forwarded when False, so older kernels
releases without the keyword keep working).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…(pr-1018)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s per-expert output norm

modelopt NVFP4 checkpoints ship a second-level global per projection and
a calibrated `input_scale`; both now reach the kernels, in either of the
two layouts such a checkpoint uses — one tensor per expert per projection
(GLM-5.2) or one stacked tensor per layer (the fused vLLM layout).

A gate|up stack arrives with two weight globals per expert, calibrated
separately. One conversion op owns every global of a layer and merges
them to the one-per-expert the kernels take: the stack keeps the gate's,
and the up half's folds onto the down projection, whose weight global
scales the expert output back up and whose calibrated input global moves
the other way, keeping the requantized intermediate on the range the
checkpoint calibrated. That is exact arithmetic on fp32 scalars, unlike
rescaling the up half's e4m3 block scales. Many-to-many converters are
now opt-in per op (`ConversionOps.supports_many_to_many`) rather than a
private whitelist.

A model states its per-expert output norm the way it states its
activation: `use_experts_implementation(post_expert_norm="<name>")` plus
an `_apply_post_norm` the class must define. A name the kernels implement
rides as that name and is folded into their reduce; anything else is the
module, called on the routed rows. The DeepGEMM experts paths, which have
no slot for such a norm, refuse instead of dropping it.

Weight-only modules hold no activation global, since nothing there
quantizes activations: the parameters are not allocated, the converters
for them are not emitted, and a calibrated checkpoint's `input_scale`
keys are ignored for that run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One guard decorator on the DeepGEMM experts forwards instead of four statements
per body, and that file's comments cut to what is not already in the code.
`prefers_deepgemm_linear` moves to `deepgemm.py`, where its measured verdicts
belong, so the dispatcher just asks. `_FineGrainedModule.local(name)` is the one
accessor every forward reads operands through, so the DTensor unwrap appears
once rather than at seven call sites.

`_WeightFormat` is public (it is in `resolve_weight_format`'s signature), the
`TRANSFORMERS_FINEGRAINED_NO_SWIZZLE` debug hatch is gone (tests patch the
predicate), and `_set_optional_parameter` replaces six register-or-assign
blocks. `check_arch` is passed straight through: `kernels` is pinned >= 0.16,
where it landed, so the compatibility shim was dead.

The scalar-shard branch becomes `_owns_expert`, stated as the general fact it is
rather than as an NVFP4 anecdote. `FineGrainedConfig`'s docstring opens with a
format table. The frozen fp8 modules' `stacklevel=2` is dropped: at module scope
it named importlib rather than the module being deprecated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…they served

Every checkpoint `quant_method` the two answer to — `fp8`, `mxfp8`, `mxfp4`,
`nvfp4`, `modelopt` — already routes to `FineGrainedHfQuantizer`, so both carry
the same DeprecationWarning the fp8 pair does.

Auditing that supersession found one real gap. `dequantize=True` on a GPT-OSS
MXFP4 checkpoint produced no converter at all: the `_blocks`/`_scales` keys were
wired only for the quantized path, so they went unmatched, where
`Mxfp4Config(dequantize=True)` had handled them. The finegrained chain now
dequantizes them, bit-equal to `mxfp4.convert_moe_packed_tensors` — the
implementation it replaces — which the new test compares against directly. Two
shared ops needed repair to get there: `FineGrainedPackedBlocks` regrouped a
sibling `_scales` entry as if it were blocks, and `FineGrainedDequantize` left
the scale it had consumed in the chain.

The same path on NVFP4 or modelopt would have folded one block scale and
dropped the second-level global, handing back a weight scaled by `1 / global`.
Neither frozen integration supported that either, but it failed quietly —
including where `dequantize` turns itself on (no GPU, compute capability < 8.9).
It raises now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	src/transformers/integrations/deepgemm.py
#	src/transformers/integrations/moe.py
It had become `FineGrainedFP8Config = FineGrainedConfig`, so the name still
imported but the class was gone. That makes "frozen for backward compatibility"
a fiction — the old name silently gained the new behaviour (`activation_format`,
the modelopt payload parsing, the wider `post_init`) — and it rendered the same
class under two `[[autodoc]]` headings. Restored verbatim, with a docstring line
pointing at the config that serves the whole family.

Splitting them means the two `isinstance` sites in `auto.py` no longer cover the
new config by accident. `LOADING_ATTRIBUTES_CONFIG_TYPES` is the load-bearing
one: it is what carries `dequantize` / `modules_to_not_convert` from a config
passed to `from_pretrained` onto the checkpoint's own, so leaving it alone would
have quietly stopped `FineGrainedConfig(dequantize=True)` from taking effect on
every fine-grained checkpoint. Both configs are in it now.

`quant_method="fp8"` still builds `FineGrainedConfig`: the frozen class is what
you get by naming it, not what a checkpoint resolves to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`c66564416e` restored the class and added a line saying it "configures the
frozen `finegrained_fp8` integration". That is not true: it sets
`quant_method="fp8"`, which `AUTO_QUANTIZER_MAPPING` routes to
`FineGrainedHfQuantizer` — the frozen quantizer is not reachable through
`from_pretrained` at all.

Removing it also leaves the class byte-identical to main, which is what a class
frozen for backward compatibility should look like in a diff. Which config
serves which family is already stated where a reader meets it, in
`FineGrainedConfig`'s own docstring on the same docs page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three CI failures on a CPU-only runner, three causes:

`post_expert_norm_name` is set by `use_experts_implementation`, but the shared
forwards are the ones every backend adapts onto — including duck-typed stand-ins
built without the decorator. The norm is optional, so read it as one.

`_assert_stacked_gate_up` already read its second attribute defensively; `has_gate`
now matches.

`prefers_deepgemm_linear` calls `is_sm100()`, and `get_device_capability()` asserts
rather than answering False on a CPU-only build. Caught there rather than gated on
`is_available()`, so that a test faking the capability alone still answers — which
is the convention the existing deepgemm tests are written to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A converter whose target no module holds is a load failure, and the activation
global is exactly that under a weight-only run: the experts allocate one only when
they quantize activations, so the declarations are the only thing standing between
the two runs. Checked against a real module's parameter names in both.

Verified it catches the failure by removing the conditional: the weight-only run
then converts `gate_up_proj_input_global_scale` onto a module without that slot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integration reads better as `is_available() and capability >= 10`; the reason it
wasn't was that three test sites faked the capability alone, which that form ignores.
That is the tests constraining the integration, so the tests move instead: they now
patch availability alongside the capability, and the module docstring says so.

Replaces the try/except that was catching what the availability check now prevents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… name

A sharded parameter is already local by the time a forward runs, so the unwrap was
doing nothing: FSDP2 unshards in its pre-forward hook, and a TP/EP plan swaps each
DTensor for its local shard around the call (`MoeExpertsParallel` ->
`_use_local_dtensor_params`). Verified both: under `fully_shard` alone a param reads
as a plain Parameter inside forward, not a DTensor. So the invariant belongs to the
distributed layer that wrapped the parameter, not to every backend that reads one.

That removes `to_local` and the `local()` accessor. Absent slots still read back as
None because `_set_optional_parameter` registers them as None parameters, which is
what the kernels take for a missing bias or global.

`_moe_operands` also stops building its dict through a `both(suffix)` helper: the
kernels' arguments are now written out, so `down_proj_scale_inv=` can be found by
grep. The five `getattr`s that remain are load-bearing — the kernels always say
`gate_up_proj`, the module says `up_proj` when the model has no gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IlyasMoutawwakil and others added 22 commits October 20, 2026 07:38
…ense layer

A near-tie in the router scores flipped under sharded rounding, so the test compared routing, not sharding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A linear whose shape does not tile the block became an FP8Linear but kept
its uninitialized scale, which made the ue8m0 round-trip test flaky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wizzled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rnel

The Hub kernel now reads the right scale for interleaved gate|up rows
(kernels-community/finegrained-kernels#1253), so the old slice recorded the bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…zzle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…useSpark plan change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…teps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sync with fg-split/8-mxfp8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mm_linear

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… guard decorators, quality fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ename

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…split

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on dispatch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ries resolved

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IlyasMoutawwakil IlyasMoutawwakil changed the title Integrate finegrained kernels: SOTA performance and per-module quantization fine-grained kernels: monolith PR Oct 21, 2026
Members are scoped through their own skip-list field (skip_list_attribute). The plan resolves in one pass over each module's checkpoint names, fused pieces included. A passed config merges into the plan in FineGrainedConfig: a format config lends to its entries, a passed plan adds None entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

[For maintainers] Suggested jobs to run (before merge)

run-slow: deepseek_v4, glm_moe_dsa, minimax_m3_vl, ministral3, mistral4, muse_spark, qwen4_exp, finegrained, finegrained_fp8, fp8, mxfp4, mxfp8, nvfp4

A ModelOpt MIXED_PRECISION export reads as one exact entry per format. Plan resolution picks each entry from the patterns that matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IlyasMoutawwakil added a commit to huggingface/kernels-community that referenced this pull request Nov 5, 2026
#1246)

Tune forward GEMMs and benchmark implementations with cold weights under
CUDA graph replay.

## Why

- A small weight timed in a loop stays in L2, so the timing hides the
HBM reads a model pays every layer.
- Timing eager launches with CUDA events adds CPU launch gaps, which
dominate memory-bound decode kernels.

## How

Each timed call rotates through banks whose active weights exceed twice
L2. The whole cycle is one CUDA graph, and its elapsed time is divided
by the number of calls, so cache eviction is never timed.

| Weight | Banks | Extra memory |
|---|---|---|
| Routed expert stack | Same rows routed to experts the call does not
use, in the same stack | Small index tensors only |
| Stack with too few disjoint expert sets | Rerouted banks repeated over
copies of the stack | About 2×L2 + one stack |
| Dense weight (no routing) | Copies of `B` and its TMA descriptor |
About 2×L2; none for a weight above 2×L2 |

A kernel opts in with `cold_banks`, a function returning the
launch-argument sets to cycle through. The GEMMs pass
`bench.gemm_cold_banks`; the tuner itself stays kernel-agnostic. Banks
keep strides and scale-descriptor aliases, are reused across candidates
and freed after the tune.

| Tuner case | Timing | Crown cached |
|---|---|---|
| GEMM, full rotation fits | Cold graph replay, 5 ms target per sample |
Yes |
| GEMM, memory cuts the rotation short | Same timing over the banks that
fit, with a warning | No |
| Kernel without `cold_banks`, user timing hooks, non-CUDA | Triton's
stock timing | Yes |

No GEMM arithmetic changes. The shared `bench.py` serves both the tuner
and `bench_moe.py`.

## Benchmark

| Mode | Timer | L2 state |
|---|---|---|
| eager, compile | Triton's flushed timer over all banks | Cold weights
and cold activations |
| cudagraph | Graph replay over rotating banks | Cold weights, warm
activations and routing |

- Every arm in a row (ours and each baseline) reads the same banks,
prepared once per bank with its own weight prep.
- MoE banks permute the routing (indices, and logits for arms that route
themselves) over one checkpoint.
- Logs show the working set; CSVs record the cache policy.

## Figure

![MoE bench: finegrained-kernels vs references, cold weights,
B200](https://huggingface.co/datasets/IlyasMoutawwakil/benchmarks/resolve/main/finegrained-kernels/cold-weight-timing/bench_moe.png)

Regenerated on B200. The repo keeps no figures: `bench/bench_moe.png` is
removed, and the package README embeds this Hub copy. Decode cells are
CUDA-graph timings; prefill cells are eager. It is not comparable
bar-for-bar with the previous figure, which timed L2-resident weights
(e.g. DeepSeek-V4 linear decode: ours 14.6 → 18.4 µs, DeepGEMM 41 → 56
µs).

A ratio below is **our latency ÷ the rival's latency** in that cell:
under 1.00× we are faster, over 1.00× slower.

Every row where we lost by less than ~10% was rerun three times per arm
(each run on one GPU with its own CPU cores), and the figure shows the
median of the three. Two cells flipped from a loss to a win:

| Cell | Rival | First run | Median of 3 (ours vs rival) |
|---|---|---|---|
| Mistral-4 FP8 MoE decode, `torch.compile` | vLLM fused_moe | 1.08×
(slower) | **0.77×** (42.0 vs 54.4 µs) |
| GLM-5.2 NVFP4 MoE prefill | TRT-LLM | 1.08× (slower) | **0.99×** (2461
vs 2479 µs) |

The other close losses held up on rerun:

| Cell | Rival | Ours ÷ rival |
|---|---|---|
| GPT-OSS MXFP4 MoE decode | TRT-LLM | 1.02× |
| GPT-OSS MXFP4 MoE prefill | TRT-LLM | 1.06× |
| Mistral-4 FP8 MoE prefill | TRT-LLM | 1.07× |
| DeepSeek-V3 BF16 MoE prefill | SonicMoE | 1.08× |

The DeepSeek-V3 BF16 loss comes from SonicMoE's newer Hub build: our
latency there is the same on main and on this PR.

Empty cells are compile/cudagraph crashes of external arms (TRT-LLM,
megablocks, `torch_mm`, triton_kernels, torch) and `finegrained-fp8@v4`
compile.

## Tuning cost

B200, fresh tune with compiled kernels cached:

| GEMM | main | this PR |
|---|---|---|
| Batched MXFP4 decode gate_up | 26.4 s | 7.1 s (same 2.4 GiB peak) |
| Grouped MXFP4 prefill down | 134 s | 108 s |
| 2D MXFP8 decode | 56 s | 47 s |

Across six B200 workloads, 54 normal-budget searches showed the 5 ms
target preserves median selected-kernel latency versus 20 ms.

## Validation

- 17 tuner tests pass on B200 through `get_kernel`: weight-bank
reuse/cleanup, routed-bank disjointness, rerouting inside copies, the
incomplete-rotation case, anchor, runoff, coupled axes. Ruff passes.
- Rerouted and copied banks time the same: 34.17 vs 34.05 µs per
DeepSeek-V4 MXFP4 decode MoE call, alternating in one process.
- The upstream `finegrained-fp8@v4` MX grouped prefill arms (fused,
unfused and `transformers@main`) all ran on the >2 GiB DeepSeek-V4 MXFP4
and MiniMax-M3 MXFP8 stacks, matched ours within 3e-3 to 1.8e-2, and
left the CUDA context healthy.

These are operator measurements, not model throughput claims.

**Known limit:** baseline arms in the benchmark still run their own
weight prep once per bank (decode rows use about four banks).

**Stack:** based on current `main` (merged after #1266); #1240 builds on
this.

## Related issue

Split from #1237, requested by a maintainer for
huggingface/transformers#49658.

## Checklist

- [ ] Linked to a discussed issue
- [x] Tested locally
- [x] New/changed functionality has test coverage
- [x] Mostly or completely generated with an LLM

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.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