[KMCompiler][MetaX][Hygon][Mthreads][T-Head][Ascend] fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert backends, plus test and benchmark fixes - #684
Draft
cheersluvs wants to merge 38 commits into
Conversation
…v_rope_quant_insert, plus test and benchmark fixes
Token-tiled backend overrides for MetaX C550 and Hygon BW1000, and five fixes to
this operator's test and benchmark that any card other than an 80GiB H800
exposes -- including one that made every vendor override for this operator
unreachable from its own test and benchmark.
MetaX C550 131072x64 1164 -> 1339 GB/s (83.9% -> 96.5% of achievable)
32768x128 1224 -> 1354 GB/s (88.2% -> 97.5%)
Hygon BW1000 32768x64 604.8 -> 1182.9 GB/s (45.1% -> 88.3%)
32768x128 609.2 -> 1201.4 GB/s (45.5% -> 89.6%)
cheersluvs
marked this pull request as draft
August 14, 2026 08:18
…acle depending on collection order The benchmark now resolves torch.ops._C the way the three sibling benchmarks that use it already do -- a single 'import vllm._custom_ops' guarded by try/except, then hasattr -- instead of the multi-library loader with its own logging. The mcoplib fallback was unnecessary: vllm._custom_ops is what works on MetaX (vllm._C is the one that fails on libcudart). _skip_if_unrunnable stays, now documented as the one deliberate deviation, because MetaX C550 registers the op and cannot launch it, which no sibling has hit. In the test, ref_impl silently ran vLLM's C++ kernel instead of the torch reference whenever torch.ops._C carried the op. Nothing in this module imports vLLM, but test_cp_gather_indexer_k_quant_cache and test_cutlass_scaled_mm do so at module level and both sort earlier, so the oracle differed between running this file alone and running the suite. ref_impl is now always the torch reference; the vLLM cross-check is its own skipif-gated test, as in test_persistent_topk.
…pe_quant_insert at 64 heads Tiling pays on S5000 only at 64 heads and only above 512 tokens, and the file says so rather than reusing the MetaX or Hygon rules -- those parts have 64-lane warps, so their tuning does not transfer to a 32-lane one where the generic launch already gives 16 elements per lane. At 32768 tokens against a 1332 GB/s ceiling the generic kernel reaches 87.4% at 64 heads and 93.6% at 128; a full TPP x num_warps sweep at 128 heads finds nothing above 0.94x. Tests and benchmark use 64 and 128 heads only, so this is an exhaustive two-case rule. Below 192 tokens three round-robin repetitions spread 12-34%, so the crossover was taken from the stable region: 256 is 1.00x, 512 is 1.03x, and the threshold is 512. The gain is ~1.04-1.05x at 64 heads on large shapes and nothing at 128 -- far from Hygon's 2x, because there the generic kernel starts at 45% of achievable and here at 87%. Inert on a stock install: the tiled kernel does not compile on the FlagTree that backends.yaml pins, in any configuration, so the dispatch routes to the generic kernel until a release bundles a working llc.
…d "cuda" Four sites hardcoded device = "cuda". MACA aliases cuda so this is invisible on MetaX, but on MUSA every case dies at the first allocation with NotImplementedError: Could not run 'aten::empty.memory_format' with arguments from the 'CUDA' backend -- before reaching the operator at all. Same family as the fp8 capability gate: both silently exclude non-NVIDIA backends. flaggems_vllm.device is what 60 of the 69 test files already use.
S5000 reports capability (3, 1), so the NVIDIA-scale >= 89 threshold skipped this operator's entire test file and benchmark there -- 108 skipped, which is how the hardcoded device = "cuda" underneath went unnoticed for so long. tl.float8e4nv is bit-identical to torch.float8_e4m3fn on that card. With that and the device fix in place the suite is 92 passed / 16 skipped on S5000 (the 16 are the vLLM cross-check; no vLLM is installed on that box).
… build tl.float8e4nv cannot be compiled for PPU-ZW810E. The card reports capability (8, 0) and its Triton target is ppu:80, and the fp8 policy is inherited from the NVIDIA backend twice over: FlagTree's PPU compiler.py adds fp8e4nv only when capability >= 89, and overriding that option to clear the frontend then fails inside ConvertTritonGPUToLLVMPPU with 'Conversion from/to f8e4m3nv is only supported on compute capability >= 89' -- an NVIDIA threshold enforced in a PPU-specific pass. The kernel body is the generic one; the seven unrolled quantisation groups call _f32_to_e4m3_bits instead. Verified bit-identical to torch.float8_e4m3fn over 65536 values, half drawn from the subnormal range, which is where a naive encoder goes wrong.
PPU-ZW810E reports capability (8, 0), so the NVIDIA-scale >= 89 threshold skipped this operator's test file and benchmark there. With the integer E4M3 encoder in _thead/fused/ the operator builds and is correct on that card: 92 passed, 16 skipped (the 16 are the vLLM cross-check; the vLLM installed there is 0.19.0, which predates this operator).
…it instead The reference and the assert_close comparison were evaluated in token slices so that large shapes would fit in 64 GiB. That bounded the temporaries, but it also changed what the oracle computes, and an oracle is the wrong place to economise: if the whole-tensor path is correct and the kernel has a size-dependent fault, a sliced reference agrees with it and hides exactly the bug worth finding. The second justification for slicing was that torch returns wrong results past ~3e9 elements on one backend. That claim is not established -- five minimal per-row reductions agree exactly whole versus sliced at that size, and the original observation has not been narrowed to an operation. It should not be load-bearing for a change to the verification logic. _skip_if_reference_wont_fit stays: a shape that does not fit is now skipped rather than evaluated differently. That still fixes the hardcoded H800 list it replaced, which was a bare return reporting those cases as passed.
A shape that cannot be verified on a given card should say so, not disappear. The guard replaced a hardcoded H800 exclusion list -- a bare return that reported four cases as passed without running them -- and removing the guard does not bring that back: those cases now run, and fail visibly if the card cannot hold the reference. Removes _free_device_memory, REF_BYTES_PER_ELEM and the gc import with it.
…o deepseek-v4-quant-insert-metax-hygon
… build tl.float8e4nv cannot be compiled for Ascend 910B4. Unlike T-Head there is no capability gate to override: BiShengIR does not recognise the type at all, with 'hivm.hir.vcast op currently don't support cast float_to_UNKNOWN_rintmode' and 'unrecognized float type: f8E4M3FN'. The kernel body is the generic one; the seven unrolled quantisation groups call _f32_to_e4m3_bits instead. Verified bit-identical to torch.float8_e4m3fn on the card at block widths 256 through 2048 (the operator uses 512). The encoder keeps ~15 live intermediates, so it is bounded by the Unified Buffer: a 4096-wide block asks for 240 KB against 192 KB and fails to compile. Loud rather than silent, but it caps how far this kernel can be widened.
… a pointer cast Ascend's Triton rejects the uint8* -> bfloat16* cast the generic kernel uses for the cache's RoPE region: 'Casting pointers with unmatched bitwidth!'. The wrapper now passes k_cache.view(torch.bfloat16) alongside the uint8 pointer and the kernel indexes it in bf16 units. Every byte offset involved is even -- block stride 37376, 576 bytes per token, 448 NoPE bytes -- so halving them is exact.
…split/join BiShengIR cannot turn [ROPE_DIM] into [HALF_ROPE_DIM, 2] here: 'cannot align 0 axis' on the expand_shape, and 'collapsing non-contiguous dims' on the way back. The pairs are now loaded and stored directly at stride 2, which needs no shape change at all. The arithmetic is unchanged.
The stride-2 pair access it introduced aborts the compiler: FlagTree's InterleaveOptimization.cpp:113 recognises the `2*i` / `2*i+1` pattern, then asserts that the addi constant is 1 -- which it is not, because `NOPE_DIM + 1` folds to 449. The process dies on SIGABRT inside a TBE subprocess, so the Python caller sees a success exit code and no traceback. An isolated probe of six formulations found the same abort for the named-offset form, for `oe | 1` (canonicalised back to addi), and for a runtime `+one` argument (Triton specialises integer arguments whose value is 1). This restores the generic RoPE block as the starting point; whether it survives in the full kernel is the next question.
The pairs are loaded and stored as [HALF_ROPE_DIM, 2] directly, so no shape
change is needed anywhere. This is the only formulation of the three tried that
the toolchain accepts:
- reshape [ROPE_DIM] -> [HALF_ROPE_DIM, 2] is rejected by BiShengIR here
(`cannot align 0 axis`, `collapsing non-contiguous dims`);
- 1-D stride-2 offsets abort the compiler in InterleaveOptimization.cpp,
which asserts the addi constant is 1 while `NOPE_DIM + 1` folds to 449.
Also whitelist ascend for the fp8 tests. The card reports capability (0, 0), so
without it the whole file skips; the override never uses tl.float8e4nv, it
encodes E4M3 with integer arithmetic exactly as the T-Head override does.
The scale byte is a per-block scalar, and AICore's scalar unit has no
float-to-integer instruction, so `encoded_scale.to(tl.uint8)` failed instruction
selection in bisheng:
fatal error: error in backend: Cannot select: i64 = fp_to_uint
wrapped in the NaN and lower-bound selects Triton emits for a saturating
conversion. bisheng exited 70, hivmc swallowed that and exited 0, and Triton
surfaced it as `No such file or directory: kernel.o` -- so the stage that
actually failed was not named anywhere in the error.
UE8M0 scales are powers of two, so the stored byte is exactly the biased
exponent of the scale, and both it and the scale itself can be built from the
exponent field with no float-to-integer step. Checked against the float formula
over 200k random magnitudes plus exact powers of two, one ULP either side of
them, the 1e-4 floor and the top of the bf16 range: identical scale and byte
everywhere.
A backend can carry the float8_e4m3fn dtype and still be unable to cast to it. torch_npu raises `Float8_e4m3fn has not been supported`, which failed 84 cases in this file on Ascend -- inside the torch reference, before the kernel under test was ever compared against anything. The eight that passed were the Q-path cases, which touch no FP8 at all. The cast is elementwise and device-independent, so doing it on CPU when the device refuses changes where it runs and nothing else. Everything else in the reference, including the float-to-uint8 scale conversion, stays on device. Note that `hasattr(torch, "float8_e4m3fn")` does not answer this question: the dtype exists on that backend, only the conversion is missing.
torch.exp2 does not return an exact power of two on every backend. On Ascend it is one ULP low for 492 of 512 integer arguments, and torch.pow(2.0, e) is no better. The oracle divides by that value, so every quantised element came out a ULP high, and the ones sitting exactly between two E4M3 codes rounded the other way -- 16 of 448 bytes per token, each off by a single LSB, which is what the FP8 region's rtol=0/atol=0 comparison caught. Those ties are common rather than exotic here: the operator feeds the encoder `kv / scale` with scale a power of two, so the values keep bfloat16's 8 significand bits while E4M3 has 4. Measured before changing anything, since the failure direction was not obvious: the kernel's scale and its stored byte are exact for all 512 magnitudes tried, torch.exp2 on CPU is exact, torch.exp2 on the device is not. The kernel was the correct side and the reference was wrong. Assembling the float from its exponent field is exact by construction; verified against 2**e for every exponent from -126 to 127. The operator can only reach -22..120, so the clamp never engages.
The runtime refuses a wider launch:
KernelLaunch failed because value 532480 for parameter coreDim is invalid.
Expected value: less than or equal to 65535.
One program per (token, head) means 8192 tokens at 64 heads already asks for
eight times the cap, and the suite goes to 131072 tokens at 128 heads. Because
the launch is asynchronous the failure surfaced later, inside an unrelated
comparison, and left the device in a state that failed every subsequent case in
the process -- which is why all 32 cases of both multi-head tests failed while
the single-head KV tests passed.
Each chunk passes its own program-id offset. Verified the chunks cover the grid
exactly with no overlap for shapes from 65 to 16.9M programs.
This trades one launch for many at large shapes -- 259 of them at the largest --
which is a real cost to revisit once there are performance numbers. Correctness
first; the alternative, a grid-stride loop inside the kernel, puts the whole
body under a `for` with nested conditionals, and in-loop control flow is exactly
what breaks the UB allocator on this backend.
One unit per program is the generic shape, and on this backend it is the whole runtime: 76 ns per unit, constant from 66K units to 4.2M and indifferent to the byte count, which put the operator at 26.8 GB/s against a 1433 GB/s device-copy ceiling. A unit moves about a kilobyte -- far too little to cover the fixed per-program cost. Measured on the card with an isolated copy, rows per program vs achieved bandwidth: 1 -> 51 GB/s, 2 -> 104, 4 -> 203, 8 -> 288, 16 -> 290, 32 -> 299, 64 -> 299. The curve turns over at 8. num_warps is not a lever here; 1, 2 and 4 are within noise of each other. The body is untouched. It moves into a `_process_one` device function, keeping its early returns -- a `return` there leaves the unit, not the program -- so nothing has to be restructured into nested conditionals, which is what breaks the UB allocator on this backend. Also cuts the launch count: 259 launches at the largest shape become 33. Verified the launcher covers every unit exactly once for shapes from 65 to 16.9M units. Note for anyone comparing against the 1433 GB/s copy ceiling: a plain Triton copy on this card tops out near 300 GB/s, so that ceiling is the hardware's, not Triton's.
The tiled launcher does not compile: ttir_to_linalg aborts on SIGABRT with no message at all. Measured one value per process, it is the shape and not the size -- UNITS_PER_PROGRAM of 1, 2, 4 and 8 all abort identically, and 1 is the same structure with the smallest possible body. Calling a device function that contains early returns from inside a static_range loop is what the pass cannot handle. Restores the last state verified on the card: 52 passed / 8 skipped under --quick, 81 passed with the full shape set. The operator is correct here and unoptimised -- 26.8 GB/s against a 1229-1433 GB/s device copy ceiling, with a plain Triton copy on this card topping out near 300 GB/s.
The operator ran at 26.8 GB/s here, constant across every shape and indifferent
to the byte count: 76 ns per (token, head) unit, and a unit moves about a
kilobyte -- far too little to cover this backend's per-program cost.
The Q path is 98% of those units (one in num_heads + 1 is the KV one) and is the
half with no gather, no quantisation and no cache write, so it is both where a
wider tile pays and where it is safe to take one. It moves into
q_norm_rope_kernel, which handles 16 units as a [16, 512] tile. Measured on the
card, ns per unit: 1 -> 590, 2 -> 64.8, 4 -> 48.7, 8 -> 31.1, 16 -> 24.9.
The KV path keeps the body that is already verified here, unchanged except for
one line: its programs now map to the KV unit of each token rather than to a
flat unit index.
Two things constrained how the new kernel could be written, both found by
measurement after the obvious forms aborted the compiler:
- no loop, no device function, no early returns. A device function with early
returns called from a static_range loop aborts ttir_to_linalg on SIGABRT
with no message, for 1, 2, 4 and 8 units per program alike -- it is the
shape, not the size. The tile has none of those constructs.
- on the unstructured pointer path, taken here because position_id is a
vector, an addptr result may have exactly one user
(`Invalid: tt.addptr has multiple users`, BlockPtrAnalysis.cpp:2120). cos
and sin get separate chains with offsets that cannot be merged.
Correctness: bit-identical to a torch reference computed on the device over
16.7M elements. Against the same reference computed on CPU, 63 elements differ,
each by a single bfloat16 ULP -- that is rsqrt, which disagrees between CPU and
device for 8086 of 32768 inputs, not the kernel.
A partial tile addresses past the end of q, and this backend faults on that rather than honouring the mask: `aicpu exception`, runtime result 507018, which surfaces at the next synchronize. The Q-path probe missed it because its shape, 512 tokens by 64 heads, divides exactly by the tile width; the suite's test_q_path uses 8 heads and one token, so a 16-wide tile ran 8 lanes off the end of the buffer. Clamping the out-of-range lanes to the last row would be worse than the crash: it makes several lanes share an address, and a masked store with duplicate lane addresses is silently dropped on this card. That trades a fault for a wrong answer. So only whole tiles are issued, and the remaining units -- fewer than the tile width -- go through the same kernel at one unit per program. Every lane is then a real unit and every mask is trivially true. Checked over shapes from 1 unit to 1M that the two launches cover each unit exactly once with no lane out of range.
Swept against the shipped kernel at 4096 tokens by 64 heads, including shapes that do not divide by the tile width and a check that nothing is written past the end: 8 units per program gives 19.0 ns per unit, 16 gives 14.4, 32 gives 11.5. 64 does not run -- a [64, 512] float32 tile is 128 KB against a usable Unified Buffer nearer 36 KB than the nominal 192 KB -- so 32 is both the widest that fits and the fastest.
The previous comment inferred a UB overflow and guessed the numbers. Measured:
ub overflow, requires 3215360 bits while 1572864 bits available
392.5 KB wanted against 192 KB. Worth writing down that this is three times
what the tile itself occupies -- [64, 512] in float32 is 128 KB -- because
several intermediates are live at once and multi-buffering asks for more again,
as the compiler's own message says. Sizing a tile by multiplying its dimensions
understates it badly here.
…fting The subnormal path used three shifts by a per-lane amount, and on this backend that was half the operator's KV time -- there is evidently no vector variable-shift instruction, so it gets scalarised. An E4M3 subnormal is m * 2^-9 for m in 1..7, so its mantissa is |x| * 512 rounded to nearest even. Adding 2^23 and subtracting it again forces exactly that rounding, with no intrinsic and no shift. Measured on the card, quantisation per token: 1.276 -> 0.627 us, which is the same as deleting the subnormal path outright (0.632) and therefore all of what that path was costing -- at no loss of accuracy. Bit-exact against the previous encoder over 16384 x 448 values on device, and the replacement rounding checked separately against the shifting form over 539 values including every exact tie. How this was found is worth recording, because four targeted guesses missed first: tiling the KV path (25%, flat by 8 tokens), arithmetic masks in place of tl.where (nothing), multiply-by-reciprocal in place of the divide (nothing), and per-program cost (it is compute). Subtraction with the store left on -- so dead code elimination could not flatter the result -- put 92% of the quantisation in the encoder, and deleting just the subnormal path found the rest.
Every head of a token shares one position, but the flat (token, head) tile made
`position_id` a vector, so each unit gathered its own 256 bytes of cos/sin --
the same bytes, once per head -- on the unstructured pointer path. Tiling H
heads of one token instead makes the position, and cos and sin, scalar: read
once per program and broadcast.
Measured at 4096 tokens, ns per (token, head) unit and the bandwidth implied:
64 heads 128 heads
flat 11.43 (179 GB/s) 16.81 (122 GB/s)
H = 16 5.24 (391) 5.14 (398)
H = 32 4.15 (493) 3.99 (513)
2.75x and 4.21x, bit-identical to the flat form.
This also settles something left unexplained: the flat kernel was markedly
slower at 128 heads than at 64, which had no obvious cause. It was this -- more
heads means more repetitions of the same gather. The two are now within 4% of
each other.
And it retires a figure quoted several times in these notes: a Triton copy on
this card reaching ~300 GB/s was taken as what Triton can do here. This kernel
does 493-513, so that number described the copy probe, not the platform.
H is the largest power of two up to 32 that divides num_heads, so every tile is
full: no masks, and nothing can address past the end of q, which this backend
faults on rather than masking. Checked that the launcher covers each unit
exactly once for head counts of 1, 5, 8, 48, 64 and 128.
…three dead branches
The KV path is 77% of the operator at 64 heads and 64% at 128, and almost all of
it is the quantisation. Two changes, together 1.96x on that path: 0.923 -> 0.470
us per token, bit-exact throughout.
**One [8, 64] tile instead of seven 64-wide groups.** The generic implementation
unrolls the groups, its comment citing load co-issue -- an NVIDIA
consideration. On this vector unit 64 lanes do not fill the machine and 448 do:
one load, one axis=1 reduction giving all seven scales, one encoder pass over
448 lanes. Worth 1.34x by itself. Triton wants a power-of-two block so the tile
is eight rows; the eighth covers the token's RoPE segment, which is real memory
rather than an overrun, and is discarded. Both stores mask it off with an upper
bound over an affine address -- the safe side of this backend's mask defect.
**Three branches that cannot fire.** Each is dead by construction, not by
appeal to the input distribution:
- the clamp to +/-448: the scale is the smallest power of two with
block_max / scale <= 448, and dividing by a power of two is exact here;
- the encoder's saturation: with |x| <= 448, e_n reaches 15 only with
mantissa 6, never the 15/7 NaN encoding;
- the encoder's zero case: x == 0 takes the subnormal path, where |x| * 512
rounds to zero anyway.
Removing them is worth 1.43x on its own. The encoder is no longer a general
E4M3 converter and its docstring now says so at the top.
Two techniques from the Ascend optimisation notes were tried here and did not
help, recorded so they are not tried again: capping the grid at the vector core
count (grid=40 measured 0.907 us/token against 0.909 for one program per token,
with every other cap worse), and replacing the divide with a multiply by an
exact reciprocal (within 1%). Both are sound advice for other operators; this
one is compute-bound in the encoder, so neither touches it.
…he test's The benchmark file keeps its own copy of `_FP8E4NV_CAPABLE_VENDORS`, and adding `ascend` to the test's left the two disagreeing. It is invisible today because the benchmark's other gate, `VLLM_REF_AVAILABLE`, skips first on a card with no vLLM — but the moment a baseline is present there the benchmark would skip for the wrong reason, reporting a capability problem where there is none. The card reports (0, 0) and never uses tl.float8e4nv anyway; it encodes E4M3 with integer arithmetic, as T-Head does.
`torch.arange(num_tokens_insert)` lands on CPU, and three lines later it is
indexed with a device-side mask. That only executes when slot_mapping holds
SOME negative entries, and no test constructs that: the suite covers all-valid
slots and an all -1 case, and the all -1 case returns early at `num == 0`. So
the mixed case -- which is what DP padding actually looks like, some tokens
inserted and some skipped -- has never run, and it fails immediately when it
does:
RuntimeError: Expected all tensors to be on the same device.
Found by driving the oracle directly from a probe with two slots set to -1.
Note this is not Ascend-specific; the same line would fail on any accelerator.
The kernel handles the case correctly, so only the reference was blocking it
from being tested.
A launch costs ~450 us on the 910B. The Q and KV kernels were issued
separately, which put a ~0.95 ms floor under every shape: at 1 token the
operator spent all of its time in dispatch, and below a few hundred tokens the
kernels finished long before the floor. One grid now covers both, Q tiles first
and one program per inserted token after, each program classifying itself from
its global id so a chunk boundary may fall anywhere.
Measured against the same code in two launches, output bit-identical either way
including the mixed-negative-slot case:
1 token, 64 heads 0.990 -> 0.510 ms 1.94x
64 tokens, 64 heads 0.971 -> 0.495 1.96x
256 tokens, 64 heads 0.983 -> 0.510 1.93x
1024 tokens, 128 heads 1.046 -> 1.028 1.02x
2048 tokens, 64 heads 1.546 -> 1.522 1.02x
The gain stops once the work outgrows the dispatch, because dispatch and
execution overlap -- one launch removes only the part that could not be hidden.
Two things the merge required, both recorded in the kernel docstring:
* the KV arm cannot use an early `return`. `if kv_slot < 0: return` inside the
else aborts the compiler with an MLIR use-list assertion that never mentions
control flow, at every tile width and every num_stages. The positive nested
form compiles and is bit-exact. Plain early returns elsewhere are fine; what
breaks is a return nested inside an arm of a two-armed if.
* the two arms share no variable name, since Triton folds a name assigned in
both arms into one SSA value and demands a single type at the join.
This also drops the dead per-unit Q branch the KV kernel still carried, and the
two bounds guards an exact grid makes unreachable. num_tokens and
num_tokens_insert are no longer constexpr, so shapes no longer force a recompile.
…ot two" This reverts 4ec6ce4 while its regression is diagnosed. Measured on the 910B, same suite and same box minutes apart: 5f165b4 gives 12 failed / 80 passed, 4ec6ce4 gives 24 failed / 68 passed. The merged kernel is not computing the wrong thing. All 32 parameters of test_backend_override_matches_reference pass when that function is run on its own, and the probe that motivated the merge was bit-exact on every shape it covered including the mixed-negative-slot case. The extra failures appear only in a full-suite run, so they depend on what ran before -- memory state, or the process poisoning that an async launch failure causes on this backend. Reverted rather than fixed forward because the branch is a live PR and the regression is confirmed, not suspected. 4ec6ce4 stays in history to cherry-pick back once the cause is known.
…not two" This reverts ca010cf. The regression it was reverted for is not one. With the shapes whose torch oracle cannot fit on this card excluded -- 65536, 98304 and 131072 tokens, where the reference builds a 32 GiB float32 copy of a 16 GiB q -- the two versions are identical: 80 passed, 16 skipped, 0 failed, both. The merged version runs that suite in 5.15s against 13.53s. What the revert was based on: full-suite failure counts of 13 (two launches) against 20 (one launch). Those extra failures are downstream of the oversized cases exhausting the allocator, not caused by the operator. Three things now say so. The operator's memory is byte-identical between the versions at every shape measured -- inputs, peak, reserved and allocated after the tensors are freed, with nothing left behind by either -- while the merged version issues FEWER launches. --quick is 52 passed / 8 skipped on both. And every failure seen was an OOM inside the oracle's `xf.pow(2).mean(...)`, with the operator not on the stack. The first comparison that produced the revert differed in Triton cache state as well as in code, and the second attributed a real difference to a memory mechanism that measurement then refuted.
cheersluvs
force-pushed
the
deepseek-v4-quant-insert-metax-hygon
branch
from
August 24, 2026 00:52
908e98a to
c685548
Compare
cheersluvs
pushed a commit
to cheersluvs/FlagGems-vllm
that referenced
this pull request
Aug 24, 2026
Carrier branch for moving benchmark and probe scripts to the 910B box, which has no SSH but can reach GitHub. Deliberately not tagged [KMCompiler]: that prefix marks PR flagos-ai#684 commits, and labelling this one that way would make a branch that must never be merged look like PR work. Nothing here is imported by the operator, the tests or the benchmark, and none of it belongs on deepseek-v4-quant-insert-metax-hygon.
Upstream converted the vendor fused/__init__.py files from relative to absolute imports; this branch had added a new op to them. Resolved by taking upstream's style and re-adding the op's import and __all__ entry in it.
… files The merge with main brought in tools/test_ci_absolute_imports.py, which requires production operator imports to be package-qualified. _metax and _mthreads were converted while resolving the merge conflict; _ascend, _hygon and _thead are new files on this branch, so they never conflicted and kept their relative imports. All three CI import checks pass now.
… vLLM abort collection
The tiled Moore Threads path is no longer blocked. flagtree 0.6.1+mthreads3.6
bundles the fixed llc at triton/backends/mthreads/bin/llc (md5
cec9ff66714e311670b9412ec760e4aa, byte-identical to the build the kernel was
validated against), so the docstring's claim that the file is inert on a stock
install is now false. It now names the version that carries it and keeps the
description of the older toolchain's instruction-selection defect as the reason
the version floor exists.
The vLLM import guard caught only ImportError and AttributeError, so a vLLM that
is installed but misconfigured raised through it and aborted collection of the
whole file rather than degrading to "no baseline": with two platform plugins
registered the import raises RuntimeError("Only one platform plugin can be
activated, but got: ['fl', 'musa']"), which is what happens on the MTT box.
cheersluvs
force-pushed
the
deepseek-v4-quant-insert-metax-hygon
branch
from
August 25, 2026 01:07
135494c to
a6bdd8e
Compare
…when it goes The comment said "drop this wrapper once MetaX-MACA/mcoplib#59 is fixed". MetaX has fixed it -- 0.4.9 drops the cudaLaunchKernelEx path that 0.4.6 calls with an uninitialised cudaLaunchConfig_t -- and the wrapper still has to stay, so the old note now points the reader at the wrong conclusion. Two reasons, both verified rather than assumed: * No wheel with the fix is reachable. Every MetaX-MACA/mcoplib GitHub release carries zero assets, and the C550 image installed mcoplib from a local file (direct_url.json shows file:///mnt/wheel/..., a path that no longer exists), with no vendor index configured. Anyone running this still has 0.4.6. * 0.4.9 is a different operator. Upstream vLLM changed the schema at v0.22.0 -- q read-only, a q_head_padded argument, the result returned rather than written in place -- and 0.4.9 follows it. This file targets v0.21.0, which matches the vLLM version the repo pins, so 0.4.9 would not be comparable even if a wheel existed. The kernel is not the problem: rebuilt from MetaX's own 0.4.6 source with their own 0.4.9 fix it runs on C550 at 96.3% of the copy ceiling, the same figure it scored under the LD_PRELOAD shim. Only the published binary is unusable.
Removes src/flaggems_vllm/runtime/backend/_thead/fused/ entirely -- both the override and the fused/__init__.py that exists only to export it. Both were added by this PR (4fa1a09) and nothing else in the tree imports them: _thead/__init__.py pulls in VendorDescriptor only, and backend/__init__.py loads a vendor's `fused` module through _import_module_safe, so its absence is the normal case -- ten of the sixteen vendor directories have no fused/ at all. `thead` also comes out of _FP8E4NV_CAPABLE_VENDORS in both the test and the benchmark, and that part is required rather than tidying. PPU-ZW810E reports capability (8, 0), so without the whitelist entry `80 < 89` makes is_support_fp8e4nv() false and the file skips cleanly. Leaving the entry while removing the override would instead route PPU to the generic kernel, whose `tl.float8e4nv` that card refuses to compile -- turning a clean skip into a build failure. Everything else under _thead/ predates this PR (__init__.py, tune_configs.yaml, heuristics_config_utils.py from flagos-ai#36) and is untouched, as are device_finder.py's vendor detection and tools/run_tests.py's env list.
cheersluvs
added a commit
to cheersluvs/FlagGems-vllm
that referenced
this pull request
Aug 26, 2026
Replaces both test files with the FlagGems versions, which are the reference
suite for this operator and cover cases this repo had no equivalent for:
prefill radix_final_config_prefers_large_vocab dispatch-tier boundary
large_vocab_partial_nonzero_range per-row differing [start,end)
vs_vllm this repo had none at all
topk_greater_than_row_len top_k=512 > vocab=257
decode topk_greater_than_row_len
logits_diff_in_8LSBits vocab 262144, 256 distinct
values -> radix tie-breaking
Shapes also widen: num_rows to 16383 (was 2048), vocab 4095/8193 (odd, chosen
to straddle the radix-final threshold) and 262144, and decode gains a batch
dimension it previously did not exercise at all.
Re-applies on top the four fixes the FlagGems versions do not carry, so this is
an upgrade rather than a regression:
1. call sites route through the top-level binding, not the ops submodule, so
a vendor override is reachable at all (the PR flagos-ai#684 trap);
2. device gate is torch_device_fn.is_available(), not torch.cuda, which is
false on MTT/Ascend/T-Head;
3. the vLLM import guard catches RuntimeError, so the MTT two-plugin
collision cannot abort collection (a6bdd8e, for these files);
4. the decode oracle is pinned to the torch reference instead of drifting to
vLLM when importable. Since FlagGems' decode had no separate vLLM test,
one is added, matching what FlagGems' own prefill already does.
_OVERRIDE_ACTIVE guard tests are carried over for both ops.
Known wart, ported faithfully rather than silently changed: decode zips
BATCH_SIZE_LIST (2) with VOCAB_SIZE_LIST (6) and TOP_K_LIST (5), so zip
truncates to 2 cases and vocab 262144 / top_k 1024 never run in non-quick mode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cheersluvs
added a commit
to cheersluvs/FlagGems-vllm
that referenced
this pull request
Aug 28, 2026
…ess defects FlagGems' tests and benchmarks for this operator pair are the reference suite and are strictly stronger than this repo's. Adopting them adds cases with no equivalent here: topk_greater_than_row_len (top_k=512 against vocab=257), logits_diff_in_8LSBits (vocab 262144 with only 256 distinct values, which hammers the radix tie-breaking), large_vocab_partial_nonzero_range (per-row differing [start,end)), radix_final_config_prefers_large_vocab, and a prefill-vs-vLLM comparison this repo lacked entirely. Shapes widen to num_rows=16383, vocab 4095/8193/262144, and decode gains a batch dimension. Four defects are fixed on top, each of which let a vendor run look green while proving nothing: 1. Call sites imported the op from flaggems_vllm.ops, which binds the submodule's function object. The registrar rewrites the package globals and never touches that, so a vendor override could not be measured at all -- the same trap PR flagos-ai#684 fixed for DeepSeek-V4. Calls now go through the top-level binding, with the generic kept as _generic_impl for an _OVERRIDE_ACTIVE guard test that passes only when an override is genuinely registered. 2. The decode test and benchmark gated the whole file on tl.histogram having a mask parameter, but the decode op never calls tl.histogram -- it builds its histogram with tl.atomic_add, and s_histogram_ptr is just a buffer name. On any vendor Triton lacking that feature the entire decode suite skipped silently. The prefill test gated on torch.cuda.is_available(), false on several non-NVIDIA backends. Both now use runtime.torch_device_fn.is_available(). 3. The decode oracle switched to vLLM's kernel whenever vLLM was importable, so a full-suite run and a single-file run could disagree about what "correct" means depending on collection order. Pinned to the torch reference; the vLLM comparison keeps its own skipif-gated test. 4. HAS_VLLM was set from the import alone. A non-CUDA build imports fine while exposing no top_k_per_row_* at all, so benchmarks reported a SpeedUp against a baseline that did not exist. Now checked with hasattr on the symbol after the import; dir() cannot be used, since torch.ops._C resolves lazily. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cheersluvs
added a commit
to cheersluvs/FlagGems-vllm
that referenced
this pull request
Aug 28, 2026
…ess defects FlagGems' tests and benchmarks for this operator pair are the reference suite and are strictly stronger than this repo's. Adopting them adds cases with no equivalent here: topk_greater_than_row_len (top_k=512 against vocab=257), logits_diff_in_8LSBits (vocab 262144 with only 256 distinct values, which hammers the radix tie-breaking), large_vocab_partial_nonzero_range (per-row differing [start,end)), radix_final_config_prefers_large_vocab, and a prefill-vs-vLLM comparison this repo lacked entirely. Shapes widen to num_rows=16383, vocab 4095/8193/262144, and decode gains a batch dimension. Four defects are fixed on top, each of which let a vendor run look green while proving nothing: 1. Call sites imported the op from flaggems_vllm.ops, which binds the submodule's function object. The registrar rewrites the package globals and never touches that, so a vendor override could not be measured at all -- the same trap PR flagos-ai#684 fixed for DeepSeek-V4. Calls now go through the top-level binding, with the generic kept as _generic_impl for an _OVERRIDE_ACTIVE guard test that passes only when an override is genuinely registered. 2. The decode test and benchmark gated the whole file on tl.histogram having a mask parameter, but the decode op never calls tl.histogram -- it builds its histogram with tl.atomic_add, and s_histogram_ptr is just a buffer name. On any vendor Triton lacking that feature the entire decode suite skipped silently. The prefill test gated on torch.cuda.is_available(), false on several non-NVIDIA backends. Both now use runtime.torch_device_fn.is_available(). 3. The decode oracle switched to vLLM's kernel whenever vLLM was importable, so a full-suite run and a single-file run could disagree about what "correct" means depending on collection order. Pinned to the torch reference; the vLLM comparison keeps its own skipif-gated test. 4. HAS_VLLM was set from the import alone. A non-CUDA build imports fine while exposing no top_k_per_row_* at all, so benchmarks reported a SpeedUp against a baseline that did not exist. Now checked with hasattr on the symbol after the import; dir() cannot be used, since torch.ops._C resolves lazily.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backend overrides of
fused_deepseek_v4_qnorm_rope_kv_rope_quant_insertforMetaX C550, Hygon BW1000, Moore Threads S5000, T-Head PPU-ZW810E and Ascend
910B, plus seven fixes to this operator's test and benchmark that a card other
than an 80 GiB H800 exposes. Twelve files, all belonging to this operator — no
shared module added or changed.
The five overrides are not the same change. Three are token tiling (C550,
BW1000, S5000) with per-card launch constants and dispatch rules; one is an
enablement, not a tuning (ZW810E, where the operator could not be built at all);
and one is both (910B, which needed eight compiler workarounds before it ran and
then eight tuning steps).
One of the seven fixes is a prerequisite for all of it: as the file stands, a
vendor override of this operator can never be reached from its own test or
benchmark. Details in the last section.
Performance against achievable bandwidth
The ceiling is taken warm, which matters more than it sounds. On C550 a
512 MiB device-to-device copy reads 1209.7 GB/s with three warmup iterations and
1396.6 / 1397.7 / 1397.9 once the clocks have ramped; measuring it cold at the
top of a script inflates every percentage computed against it — enough, in one
draft of this table, to put the operator above 100% of its own ceiling. Only
MetaX ramps: the same probe on BW1000 gives 1343.9 cold against 1350.1 / 1349.9
/ 1349.9 warm, and on S5000 the ceiling taken after a full load is within 0.3% of
the one taken before it. Every card here uses the warm figure.
Ascend is the outlier and the percentage is the honest number, not a typo. That
card has no
tl.float8e4nv, no pointer bitcast, no scalar float-to-int and a65535-program launch cap, so there is no generic build to compare against; what
the override buys is measured below as a 6.7x / 9.9x improvement over the
first version that ran at all. The 1229 GB/s is the low end of a 1229-1433 GB/s
copy ceiling. Do not read the 14.6% as headroom that a day of tuning would take
— the closing ledger below lists what was tried.
Performance against a baseline
Through this repo's benchmark,
--mode kernel --level core, 22 shapes, exceptwhere noted. Which baseline is available differs per card and each one's
provenance is stated after the table.
mcoplib, 8192-131072mcoplib, 1-64 tokenstorch_npucomposition, 32768-131072torch_npucomposition, 1-64 tokensThe C550 rows need a disclosure, because the baseline cannot run unaided.
MetaX's kernel returns
mcErrorInvalidValuefrom every launch of this op on C550— every shape, both dtypes, with a valid configuration — so with a stock
environment this benchmark reports
1 skipped, which is what the wrapperdescribed further down is for. The numbers above were taken with an
LD_PRELOADshim that forwards
wcudaLaunchKernelExCto the classicmcLaunchKernel. Thatforwarding is semantically exact here:
numAttrs == 0on these launches, so theextended call carries nothing the classic one lacks — grid, block, shared memory
and stream. And the forwarded baseline was checked rather than assumed: running
test_matches_vllm_referenceunder the same shim on the same card passes 16/16 atthe accuracy suite's own tolerances, which rules out the specific failure mode
worth worrying about (MetaX's launcher computes
kWarpsPerBlock = kBlockSize / 32while C550 warps are 64 wide, so a half-covered grid would have looked
impressively fast). Reported as
MetaX-MACA/mcoplib#59. Both
halves of the C550 row come from the same card, branch, shim and harness — the
only difference is whether the override is registered.
The BW1000 and S5000 baselines need their provenance stated: they are the same
code, patched, and it is local-only. Neither card has a vendor kernel for this
operator. On BW1000 the installed vLLM (
0.18.1+das.fa71803.dtk2604) does notship the operator in any form — searching its tree for
deepseek_v4orxpu_qnorm_ropereturns nothing; on S5000 the installed vLLM is an.emptybuild registering 0 deepseek ops, and
vllm-musa 0.1.1is a platform plugin,not an operator library. Run as-is this benchmark reports
1 skippedon both.The baseline above is vLLM's own portable XPU Triton implementation taken from
v0.27.1, checked function by function against upstream rather than trusted:
quantize_and_insert_k_kernelis byte-identical, and_xpu_qnorm_rope_kerneldiffers by exactly six lines, which are two one-line fixes —
tl.program_idcastto int64, and a
mask=offs < NOPE_DIMon the KV bulk store. The host wrappersadditionally drop vLLM-internal imports and the FNUZ branch (it needs
vllm.platforms) and gain anum_warpsargument. None of this is in thediff — it is wired in by hand for the measurement and reverted afterwards.
Both fixes are needed to quote anything at these sizes: without the cast the
address computation wraps past 2^31 q elements, which 65536 x 64 already reaches;
without the mask the bulk store writes the unrotated RoPE region and a later store
overwrites it with no ordering, corrupting ~0.7% of tokens nondeterministically.
Both also make the baseline faster — the mask cuts total stores per token from
576 to 512 elements — so patching it makes this comparison harder on us, not
easier. Reported upstream as
vllm-project/vllm#52415 and
#52416.
The baseline runs at its own measured optimum on each card, not at a value
carried over. On BW1000 that is
num_warps=1(590.6 versus 572.9 / 461.8 / 241.6GB/s at 2 / 4 / 8); on S5000 it is also
num_warps=1, at 982.5 GB/s against445.6 at
num_warps=4. That second figure is worth stating because an earlierS5000 table quoted the
num_warps=4number: the baseline was crippled 2.2x —worse than this operator was under the same compiler defect — so the old table
understated this operator, and the apparent 64-head "parity" in it was the
coincidence of both implementations landing near 446 GB/s. A benchmark harness
will not tell you a baseline is handicapped; back-compute GB/s from its own
reported latency and compare against a known bandwidth before believing any ratio.
What "as shipped" would give, and why this PR does not quote it
Upstream passes no
num_warps— v0.27.1 andmainboth, atvllm/models/deepseek_v4/xpu/xpu_qnorm_rope_kv_fp8_insert.py:135, with no@triton.autotune— so running the baseline verbatim takes Triton's default of4. Same harness, same 22 shapes, baseline rebuilt from upstream with both file
sha256 verified and only the two portability patches above applied:
num_warpsabsent → 4)On BW1000 the launch configuration only matters at 64 heads. There the
default takes the baseline from 45% to 34.5% of achievable and the ratio from
2.02 to 2.62. At 128 heads the two columns overlap and the baseline sits at
~30% of achievable either way: it is already degraded there for some reason that
is not the launch configuration, and this PR does not explain that — it is the
same unexplained 128-head effect noted in the decomposition above, now with one
more property rather than a mechanism.
On S5000 the default costs the baseline 982.5 → 445.6 GB/s and inflates the
ratio 2.3x across both head counts.
Two checks on the as-shipped run itself. It reproduces the
num_warps=4pointof the BW1000 sweep quoted above independently — 461.4 GB/s measured at
32768 x 64 against 461.8 in that sweep — and on both cards the as-shipped
baseline lands at 33-34% of a ceiling it otherwise reaches 45% of, which is the
signature of a handicapped launch rather than a slow kernel.
This PR quotes the at-optimum column. A gap that one launch argument closes
is not this operator's contribution. Rows below ~192 tokens are omitted from the
as-shipped table: that region spreads 12-34% across repetitions on these parts,
and the as-shipped run was single-pass.
The BW1000 45% → 90% figures were measured separately on this branch by comparing
flaggems_vllm.ops.<op>againstflaggems_vllm.<op>in one process — the genericimplementation and whatever the vendor registrar bound — so there is no file
editing between the two halves and no chance of drift.
On BW1000, that there is no vendor kernel was established by symbol scan rather
than by absence of an import:
vllm._custom_opsregisters 49 ops and none is thisone, and 2505 shared objects export no matching symbol. The vendor's
lightopdoes cover this stage —
fused_rms_norm_rope_contiguousandfuse_rmsnorm_rope_quant_qkvboth takeslot_mappingandkv_cache— but onlyfor a scalar-scale FP8 convention:
kv_cache_scaleis a singlefloat, whichcannot carry seven per-block exponents per token, so that interface cannot express
this operator's UE8M0 blockwise layout whatever the kernel does internally.
The 910B row is not from this benchmark. The harness gates on
VLLM_REF_AVAILABLEand self-skips there, because no baseline of any kind existson that card — not a vendor kernel, and not even vLLM's portable Triton fallback,
which does not compile (details in the Ascend section). The eager
torch_npucomposition it is measured against is described there too. Those figures come
from a standalone script, and saying so is the point: they are not the same
measurement as the rows above.
What the token tiling does
This part applies to C550, BW1000 and S5000. The generic kernel runs one program
per
(token, head)slot atnum_warps=1— 64 threads for 512 elements on a64-lane warp, which is 8.5M programs of 64 threads at 131072 x 64. Two costs
follow: the KV slot does seven quantisation blocks against a Q slot's
normalise-and-rotate, so one slot per program makes it a straggler; and 64-thread
blocks under-fill the SM. The straggler is 1-in-65 at 64 heads but 1-in-129 at
128, matching the measured shortfall being worse at the lower head count.
Each program here handles
TPPtokens of one slot, so every program isuniformly all-Q or all-KV with no divergence. On C550 and BW1000 that is
TPP=8/num_warps=4: 16 elements per lane instead of 8, in a 256-thread blockinstead of 64.
Two axes matter and neither is visible on its own, which is worth recording
because tuning them separately gives the wrong answer twice over. A full
TPP x num_warpssweep on BW1000 puts every optimum atTPP / num_warps = 2—two tokens per warp, so 16 elements per lane. Bandwidth by elements per lane is
237 / 408 / 604 / 906 / 1183 / 1077 / 1050 GB/s for 1 / 2 / 4 / 8 / 16 / 32 /
64. But elements per lane does not explain everything:
TPP=1/warps=1andTPP=2/warps=2are both 8 elements per lane and differ by 50% (604 vs 906),because the second has a wider program. Meanwhile raising
num_warpsalone atTPP=1measurably hurts on C550 (1134 → 691 → 398 → 199 GB/s) and shows 8 and 4elements per lane tied at ~605 on BW1000 — which invites the conclusion that
access width does not matter. At
TPP=1the block is only 512 elements and thereis nothing to widen into.
Do not read a
(TPP, num_warps)pair across cards. S5000 has a 32-lanewarp where the other two have 64, so its stock
num_warps=1launch alreadydelivers 16 elements per lane — the value C550 and BW1000 need
TPP=8/warps=4toreach. Read elements per lane and program width, not the constants.
Moore Threads S5000 is a two-case rule, and a toolchain floor
Tiling pays at 64 heads only. Against a 1332 GB/s ceiling at 32768 tokens the
generic kernel reaches 87.4% at 64 heads but 93.6% at 128, and a full
TPP x num_warpssweep at 128 heads finds nothing above 0.94x — there is noheadroom left there. The tests and benchmark use 64 and 128 heads only, so this
is an exhaustive two-case rule rather than a fitted threshold. At 64 heads the
best cell is
TPP=4/num_warps=4, 91.6% of ceiling.The 64-head dip is real and was checked before being built on: 48 heads 90.8%,
64 heads 87.4%, 96 heads 90.8%, 128 heads 93.6%, with 0.1-0.6% spread over
three round-robin rounds and -0.3% ceiling drift. It is not monotonic, so "the KV
straggler is amortised as head count rises" is wrong, and it is not a
power-of-two stride effect either — 64 is the worst and 128 the best. The
mechanism is unexplained; it is also not load-bearing, since only 64 and 128
occur.
The token threshold is 512, chosen from where the measurement stops being noise:
below 192 tokens three round-robin repetitions spread 12-34%, so the apparent
0.82x at 64 tokens and 1.02x at 96 are both unusable. From 192 up the spread falls
to <= 3%, and the crossover sits between 256 (1.00x) and 512 (1.03x).
This override requires
flagtree >= 0.6.1+mthreads3.6, and that is a realfloor, not a precaution. On older builds the tiled kernel does not compile in
any configuration — five were tried, including
TPP=1/num_warps=1, whose 16elements per lane match the generic launch that works fine, and all five abort
with
llccode -6. The defect is an instruction-selection failure in thellcshipped with MUSA toolkit 4.3.5 (
MTGPU DAG->DAG Pattern Instruction Selection,SelectionDAGISel::CannotYetSelecton a v8bf16LSU_LD_CACHE_HINT), reachedbecause the older FlagTree wheels ship no
bin/and Triton falls back to thesystem binary.
0.6.1+mthreads3.6bundles a workingllcattriton/backends/mthreads/bin/llc, md5cec9ff66714e311670b9412ec760e4aa—byte-identical to the build the kernel was validated against, which is how the
version floor was confirmed rather than assumed. The two binaries both
self-report LLVM 14.0.0, so a version string cannot tell them apart; compare md5.
On a build without it the dispatch simply routes to the generic kernel, so
nothing here is reached and nothing breaks.
This repository pins no compiler — there is no
backends.yamland no flagtreedependency — so which Triton runs is the installation's choice. That is also why
mthreadsis added to the fp8 vendor whitelist here: I argued against it twice onthe grounds that opening the gate would turn a clean skip into compile failures
on the pinned FlagTree, which was importing a constraint from FlagGems that this
repo does not have.
Measured on the card with
0.6.1+mthreads3.6, two independent benchmark runsagreeing within 0.5% on every shape:
The 128-head column is a built-in control and it holds: it takes the generic
path either way and is unchanged cell by cell, so the 64-head movement is the
tiling and not environment drift. Large shapes gain 3.4-5.3%, consistent with the
sweep's
TPP=4/warps=4figure of 1.06x against generic. The 2048 cell goes theother way by 2.5%, which is inside this card's noise band but is not hidden here.
mthreadsis added to the fp8 vendor whitelist, so the suite now runs on S5000instead of skipping 108 tests. That gate is what hid the hardcoded
device = "cuda"underneath it: nothing on that card ever reached the operator, so neitherdefect was visible.
Accuracy on the card, excluding the four largest token counts: 76 passed, 16
skipped, 0 failed — every 64-head case at 512 tokens and above, which is the
set that takes the tiled path, so the new
llcproduces a kernel that is not onlybuildable but numerically right. What happens at the four excluded shapes is in
the last section, under the memory-guard fix; it is not a property of this
override.
T-Head PPU-ZW810E: the operator could not be built at all
Not a tuning problem.
tl.float8e4nvis refused for this card, so the generickernel does not compile — there is no "before" to compare against.
The card reports capability
(8, 0)and its Triton target isppu:80, and thefp8 policy is inherited from the NVIDIA backend at two levels. FlagTree's
third_party/ppu/backend/compiler.pyaddsfp8e4nvtosupported_fp8_dtypesonly when
capability >= 89; that option is overridable, and overriding it movesthe failure into the PPU's own lowering pass, which fails with
`LLVM ERROR: Conversion from/to f8e4m3nv is only supported on compute capability
The override changes one thing: the seven unrolled quantisation groups call
_f32_to_e4m3_bitsinstead of.to(tl.float8e4nv). Everything else is thegeneric kernel. The encoder is integer arithmetic — which is what a software fp8
conversion does anyway below sm_89; torch's own cast on this card takes such a
path. It is bit-identical to
torch.float8_e4m3fnover 65536 values, half ofthem drawn from the subnormal range below 2^-6, which is the part a naive encoder
gets wrong: a first version flushed subnormals to zero and also mis-saturated
exponent 15 with mantissa 0, which is the legal value 256 rather than an overflow.
Accuracy suite on the card: 92 passed, 16 skipped (the 16 are the vLLM
cross-check; the vLLM installed there is 0.19.0, which predates this operator).
That run predates the memory-guard removal described in the last section, so on
this card as on the others, whether the four largest shapes fit is a separate
question from whether the override is correct.
Performance is not measurably hurt by the software conversion — unsurprising for a
memory-bound operator, but measured rather than assumed: 96.0% of a warm
2097 GB/s ceiling at 32768 and 131072 tokens, ramping from 63.9% at 1024. Small
shapes need a cache flush between iterations to measure at all: this part has
roughly 32-64 MiB of cache (a 512 MiB copy reads 2097 GB/s, a 32 MiB copy 7140),
and the operator rewrites
qin place, so at 1024 tokens its 67 MB footprint sitsright at the boundary and an unflushed measurement reads high.
The ZW810E baseline is T-Head's own vLLM port, used unmodified. That card's
pip index serves only
+ppu*builds, and the newest,vllm==0.20.1+v0.1.0.ppu2.1.0, registers_C::fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert— a vendor C++implementation, which does its FP8 conversion outside Triton and is therefore
untouched by the
fp8e4nvgate that blocks the Triton path on the same card. Itlaunches, and it agrees with this operator under the accuracy suite's own
k_cache_compareat 64x64, 777x64 and 4096x128.This is the only one of the baselines that needs no local patch at all: with
the vendor wheel on
PYTHONPATHthe benchmark resolvestorch.ops._Cunmodified. Figures are from two independent runs agreeing within ~1% on every one
of the 22 shapes, with a known-answer device check run clean before and after each
— that check is there because this card intermittently enters a state where it
returns wrong answers, cause unidentified, so every measurement on it is
repeated rather than trusted once.
The box also carries a stock
vllm 0.19.0+cu130, which predates the operator; anearlier revision of this description reported ZW810E against vLLM's portable XPU
Triton implementation carrying our own E4M3 encoder, because that stock build was
mistaken for the only vLLM available. Those numbers are withdrawn in favour of
these.
Delete
_thead/fused/once the PPU backend either lowersfp8e4nvor reports atarget that permits it. Nothing else in it differs from the generic
implementation.
Ascend 910B: eight compiler limits, then 6.7x / 9.9x
Also an enablement before it is an optimisation, and for more reasons than one.
Four separate constructs the generic kernel relies on are unavailable here, and
they are the same four that stop vLLM's portable Triton fallback from
compiling on this card — taken verbatim from upstream and tried at this
operator's own shapes:
with
encoded_scale.to(tl.uint8)from a float and an inexacttl.exp2waitingbehind them. So 910B has no baseline of any kind — no vendor kernel and no
portable fallback — which is a stronger statement than the "no vendor op" that
applies to BW1000 and S5000, and is exactly why this file exists.
The override therefore carries: an integer E4M3 encoder (same reason as ZW810E),
the UE8M0 scale built from exponent bits rather than a float-to-int, a 2-D offset
for the RoPE pairs instead of a pointer cast, and a launch issued in chunks of
65535 programs because the runtime refuses anything wider.
Tuned from 26.8 GB/s — the first version that ran — in four steps, each one a
measurement correcting a guess:
6.7x and 9.9x, confirmed two ways: a private loop timing both versions
alternately on the same tensors, and the repo's own
Benchmark.run()with theold operator bound into the empty
torch_opslot. The two agree at every shapefrom 1024 tokens up.
Two of those steps are worth stating because the mechanism was not the obvious
one. Step 2 looked like an encoder rewrite; the actual cost was three shifts by
a per-lane amount in the subnormal path, which this vector unit has no
instruction for and so scalarises — an E4M3 subnormal is
m * 2^-9, so adding2^23 and subtracting it again forces the same rounding with no shift at all, and
quantisation went 1.276 → 0.627 us/token. Step 3 looked like a tiling win and was
really a gather removed: every head of a token shares one position, but a flat
(token, head) tile makes
position_ida vector, so each unit re-gathered the same256 bytes of cos/sin once per head. Tiling heads of one token makes it scalar,
11.43 → 4.15 ns/unit at 64 heads and 16.81 → 3.99 at 128 — which also explains
what had looked unexplainable, the same kernel being 48% slower at 128 heads than
at 64.
Then the Q and KV halves were merged into one launch branching on the program
id, rather than two. A launch costs ~450 us on this card, so this is 1.94x below
~256 tokens and 1.02x at 1024 and above, bit-exact including mixed negative
slots. It also stops
num_tokensbeing aconstexpr, so shapes no longer forcerecompiles — the suite runs in 5.15s against 13.53s.
That merge exists because of a lesson about A/B measurement, not because of a
kernel insight: the first before/after sampled only the shapes the tiling
targeted, all ≥1024 tokens. Run against the benchmark's own shape list, the tuned
version was ~1.9x slower below 64 tokens — two launches where the untuned one
had issued a single fused launch. Those are the decode-side shapes. A before/after
that samples only the shapes the optimisation targeted will not show what it cost
elsewhere.
The baseline here is an eager
torch_npucomposition, since neither of theusual two exists:
npu_rms_normfor the weightless RMSNorm (not a naive five-opversion), elementwise RoPE, the same integer FP8 encoder the kernel uses
(
torch_npucannot cast tofloat8_e4m3fnat all, so there is no alternative),and an advanced-indexing scatter into the paged cache. It is validated against
the test file's own oracle with the operator taken out of the middle, so the
argument is not circular —
k_cachebit-identical on every shape checked, mixednegative slots included. It settles at ~11x on large shapes; the 7.6-9.9x at small
shapes is dozens of framework launches against one.
It cannot run 98304x128 or 131072x128, and not because of any single
allocation: the composition holds
q.float(),npu_rms_norm's fp32 output, thefp32 rope intermediate and the bf16 write-back live at the same time, about six
times q's own size, so the peak is 75 and 100 GiB against 60.96 available. Those
two are skipped by predicting the footprint, not by catching the failure — on
this backend an allocation error does not reliably land on the call that caused
it, and one left in flight poisons the rest of the process, which is how a stray
aicpu timeoutcorrupted an earlier sweep of this same baseline.Two of 22 shapes, and not the large end. 131072 tokens at 64 heads runs and
is reported; only its 128-head partner is missing, which is why the 128-head
column above is labelled
to 65536rather than averaging over the survivors andcalling the range
32768-131072. The operator does both missing shapes in 97 and129 ms, so the card is not the limit here — the composition is.
Two ways to make them fit were considered. Chunking over tokens multiplies the
framework launch count by the chunk count; the overhead looks small at these
sizes but it moves the ratio in our favour, and it was not measured, so it is not
relied on. Feeding
npu_rms_normbfloat16 would fit in 36 GiB and moves theratio against us, which is the safe direction, but it rounds the normalised
value before the rotation and stops matching the kernel's arithmetic. Neither is
needed to support a claim made here, so the two cells stay empty and say why.
ascendis in the fp8 vendor whitelist for the same reason asmthreadsandmetax: the card reports capability(0, 0), so the NVIDIA rule skips the wholefile.
Two things this card taught that are worth a reviewer's time. First,
torch_npucannot cast to
float8_e4m3fn, so the test's torch oracle needs a CPU fallbackfor that one step — one of the seven fixes below. Second, twice during this work
the operator was right and the oracle was wrong (
exp2,rsqrt); the onlything that ever settled such a case was dumping intermediates and diffing
bit-for-bit against an exact reference until the first divergence appeared.
Everything else tried on this card, and rejected on measurement: packing four
FP8 bytes into an int32 store (0.19x — five times slower, and bit-exact, so
the packing was right);
num_warps2 or 4 (identical to three decimals, here ason every kernel in this PR); tiling KV over tokens as well (1.05-1.06x, ~3% of the
operator, against having to solve the scattered cache store); a 16-bit encoder
(0.99x, after a synthetic microbenchmark promised 1.33x — its op mix was not
the encoder's); capping the grid at the vector core count and looping inside
(nothing, and 20/32/40/48/64/128/256 were all measured); an exact reciprocal
multiply instead of the divide (nothing, within 1%); and TLE
dsa.alloc/dsa.copystaging the Q tile through UB (1.00x / 0.99x,bit-exact — on this card the vector unit can only reach UB, so
tl.loadalreadystages through it and the explicit copy restates what the compiler does anyway).
Nothing on that list is untried.
Dispatch thresholds
Below a threshold the generic kernel wins, because a wider block costs more to
launch and
TPPmasks off most of every program when there are fewer tokens thanthat to fill it. The three tiling cards want three different rules and all three
were measured:
num_tokens < 512falls through, on both head counts (256 tokens:0.96x/0.99x; 512: 1.03x/1.04x). The launch floor moves ~26 us → ~32 us.
16640 and 16512 programs respectively, so the quantity the two agree on is
num_tokens * (num_heads + 1), and the threshold is a program count, 16384.A flat token threshold would forfeit the 1.16x-1.32x available between 128 and
256 tokens at 128 heads. The launch floor here is much higher in absolute terms:
80.5 us → 90.2 us at one token.
num_heads > 64 or num_tokens < 512falls through — the two-casehead rule above, plus the token threshold from where the measurement stops being
noise.
Correctness
Each override is verified against the torch reference, not against the generic
kernel, and that distinction turned out to matter. On C550 the tiled and generic
kernels are bit-identical. On BW1000 they are bit-identical under torch 2.10.0 /
FlagTree 0.6.1a1 — 32 shapes, 10 repetitions each, zero differing bytes — but
under the older combination that card's pinned stack installs (torch 2.9.0 /
FlagTree 0.5.1),
qdiffers on 5 of 16744448 elements by up to 2 bf16 ULP at 511tokens and one FP8 cache byte differs at 1000 tokens. The same appears on 910B
between the pre- and post-tiling versions: 66 of 33.5M elements at 2-3 bf16 ULP,
at magnitudes near 1e-6.
That is expected once stated precisely: a 2-D
[TPP, 512]tile assigns lanesdifferently than a 1-D 512 block, so the RMSNorm
variancerounds differently, andhow that lowers is the compiler's choice. Near zero, bf16 is sparse enough that
"3 ULP" is 1.2% of a value whose absolute error is 3e-8. So
test_backend_override_matches_referencecompares each implementation against thereference at the tolerances the other tests use, rather than requiring two Triton
implementations to agree more closely than either agrees with the oracle. It runs
on shapes the other tests do not reach — 511, 512, 777, 1000, 4096 tokens, chosen
for not being multiples of a plausible tiling — and skips where no override is
registered.
On BW1000 both suites are green: 60 passed / 0 failed / 0 skipped with the
override active, and the same with the dispatch threshold temporarily set to 0,
which is the only way to exercise the
tok_okmasking at 1, 4 and 17 tokens,where 7 or more of each program's 8 slots are empty. At the production threshold
those shapes dispatch to the generic kernel and the masking is never tested.
On S5000, each of the four dispatch branches was checked directly: 777x64 and
32768x64 take the tiled path and match the generic kernel byte-for-byte in the FP8
cache with
qwithin one bf16 ULP, while 511x64 (below the token threshold) and777x128 (above the head threshold) are bit-identical, which is what confirms
the fallback really calls the generic implementation.
Verification
Every figure and pass/fail above was produced by running this branch's code on the
physical cards, not inferred. Where a measurement is not from this repo's
benchmark — the 910B rows, the BW1000 generic-vs-override bandwidth pair — that is
said at the point it appears.
select_backends.pypicks non-NVIDIA backends from the changed paths undersrc/flaggems_vllm/runtime/backend/_<vendor>/, so this PR should select metax,hygon, mthreads, thead and ascend on its own, provided each is marked
enabledinthe CI registry — I cannot see
.ci/flaggems-registry/.github/backends.jsonfromhere to confirm which are. The run is currently
action_requiredbecause thiscomes from a fork, so it needs a maintainer to approve it before anything
executes. Happy to attach raw logs from the cards in the meantime.
The seven test and benchmark fixes
A vendor override of this operator was unreachable from its own test and
benchmark.
SpecOpRegistrarwrites vendor implementations intoflaggems_vllm's module globals, but this operator's test calledflaggems_vllm.ops.<op>and its benchmark didset_gems(flaggems_vllm.ops.<op>)—attributes of the
opssubmodule, which the registrar never touches. So bothalways ran the generic kernel, silently, and no override for this operator could
ever have been measured or validated. Both now use the top-level binding
(
flaggems_vllm.ops_experts_impl,flaggems_vllm.weight_norm,flaggems_vllm.sparse_attn_triton,flaggems_vllm.w8a8_block_fp8_matmulare theexisting examples). Of the 29
set_gemscall sites inbenchmark/, 28 use thetop-level binding; the only other
.ops.one istest_flash_attn_varlen_opt_init_func.py, whose operator has no vendor override,so it is unaffected. This is a prerequisite for the five overrides above to have
any effect.
device = "cuda"was hardcoded in four places. MACA aliasescuda, so thisis invisible on MetaX; on MUSA every case dies at its first allocation with
NotImplementedError: Could not run 'aten::empty.memory_format' with arguments from the 'CUDA' backend, before reaching the operator at all — 60 of 92 tests,none of them for a reason related to the operator. Now
flaggems_vllm.device,which 60 of the 69 test files already use. Same family as the capability gate
below: both silently exclude non-NVIDIA backends, and neither can be noticed from
an NVIDIA machine.
The correctness oracle depended on test collection order.
ref_implranvLLM's C++ kernel instead of this file's torch reference whenever
torch.ops._Chappened to carry the op. Nothing in this module imports vLLM — buttest_cp_gather_indexer_k_quant_cache.pyandtest_cutlass_scaled_mm.pydo so atmodule level and both sort earlier, and pytest imports every collected module
before running any test. So the oracle was vLLM's kernel when the suite ran and
the torch reference when the file ran alone, with nothing reporting the switch.
Worse, the vLLM path cannot execute on MetaX C550, so simply adding the missing
import — the obvious fix — would have broken the suite there.
ref_implis nowalways the torch reference, and the vLLM cross-check is its own
skipif-gatedtest, which is how
test_persistent_topkandtest_top_k_per_row_decodearealready structured.
is_support_fp8e4nv()gated onget_device_capability() >= (8, 9). Thatthreshold means "Ada or newer" on NVIDIA only; other vendors report their own
major/minor on a different scale — C550 reports
(8, 0), S5000(3, 1), ZW810E(8, 0)and 910B(0, 0), and all four convert fp8e4nv correctly (two of them viathe integer encoder in this PR). The check now consults an explicit vendor list
before falling back to the NVIDIA capability rule, and stays local to these two
files.
The torch oracle's FP8 cast cannot run on every backend.
torch_npuhas nocast to
float8_e4m3fnat all, so on 910B the reference itself raises before theoperator is reached. That one step now falls back to CPU, and the oracle's
power-of-two scale is built exactly rather than through
exp2, which is off by oneULP for integer arguments there — in both
tlandtorch. Both are oracle fixesand neither touches the operator.
The shape exclusion was hardcoded and silent.
if (num_tokens == 98304 or num_tokens == 131072) and n_heads == 128: return— a barereturn, so those fourof 60 cases were reported as passed, and having been added in the commit that
introduced the operator, they had never actually run on any card. The list is also
wrong in both directions on a 64 GiB part: it excludes the 128-head shapes and does
not exclude
64 heads x 131072, which OOMs there. Removed outright, with nothingin its place: those cases now run, and on a card that cannot hold the reference
they fail visibly rather than reporting success.
Two softer treatments were tried on this branch and both are reverted. Evaluating
the reference and the comparison in token slices let the large shapes fit, but it
changed what the oracle computes — and an oracle is the wrong place to economise:
if the whole-tensor path is right and the kernel has a size-dependent fault, a
sliced reference agrees with it and hides the one bug worth finding. A memory
budget check that skipped shapes instead was closer to honest, but a shape that
cannot be verified on a given card should say so rather than disappear.
What that costs, stated plainly, because it is more than "those shapes go red".
On 910B it is 11 OOM cases out of 92, with the operator not on the stack — the
oracle's
xf.pow(2).mean(...)builds a 32 GiB float32 copy of a 16 GiBq.On S5000 it is worse than a per-case failure: MUSA errors are sticky, so the
first failure poisons the context and every later GPU call in the same process
raises too. The same command run twice gave 12 failed / 80 passed and 55 failed /
37 passed — the difference being only where in the collection order the first
failure landed. Exclude the four largest token counts and it is 76 passed / 16
skipped / 0 failed. The first failure there is itself a known vendor defect
(torch_musa#146): boolean
masked assignment fails past 2^31 elements, and it sits directly on
assert_close's diff-reporting path, so the failure surfaces asRuntimeError: Comparingwith the actual difference never printed. Neither ofthose is caused by this change; both are made visible by it, which was the point.
The benchmark probed
torch.ops._Cwithout importing the library thatregisters the op, and
torch.ops._Cgives no hint that nothing did. Where thereference is installed the benchmark reported it missing and skipped, so no
comparison ran at all. It now does exactly what the three sibling benchmarks that
use
torch.ops._Cdo —top_k_per_row_decode,persistent_topkandcutlass_scaled_mm— a singleimport vllm._custom_opsundertry/except, thenhasattr. On MetaX C550 that entry point is also the one that works:import vllm._Cfails onlibcudart.so.13, whilevllm._custom_opsregisters all 57 opsthrough
mcoplib.That guard catches
RuntimeErroras well asImportError/AttributeError, whichis a deviation from the three siblings and is deliberate: on a box with two vLLM
platform plugins registered, the import raises
RuntimeError: Only one platform plugin can be activated, but got: ['fl', 'musa'], and with the narrower guard thataborts collection of the entire file rather than degrading to "no baseline".
A misconfigured optional dependency should not be able to do that. It is not
widened to bare
Exception, which would swallow real problems.A reference can also be registered and still not run, so the benchmark
distinguishes the two. MetaX's build of this op returns
mcErrorInvalidValuefromevery launch on C550, and finding it that way would turn the benchmark red on the
vendor's defect, while skipping as "not installed" hides it. The reference is
wrapped so the first call reports the launch error as a skip reason. Making that
work took measuring how the failure propagates, and the result is worth recording:
on that backend a failed launch is not surfaced by
synchronize(), nor by adevice-to-host copy, nor by a small allocation — it is surfaced by the next kernel
launch, which is why it otherwise lands on
do_bench's 256 MB L2-flushallocation, several frames from the cause.
Two things reviewers should know
Vendor discovery is a no-op where
mcoplibis absent, so the benchmark skipsexactly as before on such machines. For anyone running it on a machine that does
have it: on C550 that kernel cannot launch (reported as
MetaX-MACA/mcoplib#59), and the
benchmark will now say so rather than silently comparing against something else.
The ~140-line tiled kernel now exists in three copies —
_metax/fused/,_hygon/fused/and_mthreads/fused/— with identical kernel bodies; only thelaunch constants and the dispatch rule differ per card. (
_theadand_ascendare not copies of it: one is the generic kernel with a different FP8 conversion,
the other a differently-shaped kernel built around this card's compiler limits.)
Three copies will stay in sync by accident rather than by construction, and the
case for factoring it out is stronger than it was with two. The alternatives are a
shared module or one backend importing another's, and neither looked obviously
right in this layout, so raising it rather than deciding it unilaterally — happy to
factor it out whichever way you prefer.