Skip to content

fix(cpu-moe): honor padded fp8 scale strides, add avx512f tier and float64 parity (builds on #36) - #399

Open
ChenyuHeee wants to merge 4 commits into
FlashML-org:mainfrom
ChenyuHeee:fp8-block-cpu-moe-padded-strides
Open

fix(cpu-moe): honor padded fp8 scale strides, add avx512f tier and float64 parity (builds on #36)#399
ChenyuHeee wants to merge 4 commits into
FlashML-org:mainfrom
ChenyuHeee:fp8-block-cpu-moe-padded-strides

Conversation

@ChenyuHeee

Copy link
Copy Markdown

Relationship to #36

This builds directly on @gdevenyi's #36 and includes his commit unchanged (4a04c9b, +360 lines — the format definition, bank plumbing, ABI probe and benchbw integration). My three commits on top are +262 lines. If you would rather land #36 first, these three cherry-pick cleanly onto it and I am happy to send them there instead — I opened this separately only because the stride bug below is a correctness issue that would otherwise ship silently.

Credit for the feature is his. What follows is a bug fix and test hardening.

1. The scale bank's trailing dimension is padded (correctness)

kernel/aot_models.py pads the scale bank's last dimension so per-expert rows land on a 16-byte boundary for the fused copy:

def fp8_block_scale_pad(rows: int, cols: int) -> int:
    """Trailing scale-bank dim padded so per-expert row bytes are 16B-aligned (fused copy)."""
    while (rows * cols * 2) % 16:
        cols += 1
    return cols

So the row stride is not ceil(K/128). #36 recomputes it from the unpadded block count in two places (cpu_moe_ext.cpp and the shape assert in cpu_executor.py), which reads scales at the wrong offset whenever padding applies.

Qwen3.8 is the case already documented in the treemodels/qwen3_5_moe/weight.py records down_scale as 20x5 bf16 = 200 B, and 200 % 16 != 0 pads cols 5 → 6. The assert demands (E, 20, 5) and fails; with asserts off the kernel silently produces wrong numbers.

Neither the benchbw fp8 workload (H=2048, I=512) nor DSV4-Flash (H=7168, I=2048) trips it — both happen to give rows*cols divisible by 8, which is why #36's own tests are green.

The fix reads the stride off the tensor and threads it through to the kernel. Reverting just the C++ half turns 7 tests red (4 on a CPU-only host).

Worth noting: the Triton kernel was already stride-aware. Feeding it padded banks keeps the GPU cross-check green, so the CPU path was the only one getting this wrong.

2. The parity test could not see accumulator precision loss

The existing check compares two fp32 accumulations (CPU vs Triton) at rel < 2e-2, so common-mode error cancels and a kernel with ~1e-3 error passes unnoticed.

Added a float64 reference — torch.float8_e4m3fn dequant with the bf16 block scales, GEMV in float64 — over mixed-sign full-range e4m3 weights, zero-mean activations, and ragged K. Measured error is 2.674e-3 against a 5e-3 bound (the executor stores bf16, so the floor is not tighter than that).

No existing assertion was weakened; the 2e-2 check is untouched.

Also addressed the two Copilot comments on #36: the ISA-tier test now actually sets FREETOKEN_CPU_MOE_ISA, and test_subnormal_and_sign_decode_exactly's docstring no longer claims to validate the C++ decoder when it only exercises a PyTorch round-trip.

3. An avx512f tier for CPUs without AVX512-BF16

#36's fast path is _mm512_dpbf16_ps, so a CPU with AVX-512 but no BF16 extension falls all the way to the AVX2 tier — 8-wide fp32, 512-bit registers unused. That is Skylake-SP, Cascade Lake and Ice Lake-SP.

Added an avx512f tier between them, following the existing per-function __attribute__((target(...))) + runtime dispatch structure. All three reachable tiers are bit-identical across all 256 e4m3 codes.

Measured on Xeon Platinum 8368 (Ice Lake-SP, no avx512_bf16), three repetitions each:

avx2 avx512f
single-thread, cache-resident 3.31 GB/s 3.94 GB/s +19%, intervals disjoint
ft bench bw --dtype fp8, 75 threads 60.4 GB/s 62.7 GB/s +4%, intervals overlap

At 75 threads the kernel is memory-bound and the tier is largely washed out — the project's own benchmark cannot resolve it. The single-thread number is the honest figure.

4. Tests now cover the code they ship

Before this, deleting the entire avx512f branch from select_fp8dot left every test passing, because isa_name() reports select_dot() (the bf16 chain) rather than the fp8 dispatch — and no test ran numerics under a forced tier. Injecting a ragged-tail bug into dot_fp8_block_avx2 was invisible for the same reason.

Each reachable tier now runs the float64 parity GEMV, and the fp8 dispatch is observable. Verified by mutation:

mutation before after
drop the avx512f dispatch branch all green assert 'avx2' == 'avx512f'
ragged-tail scale bug in dot_fp8_block_avx2 all green rel = 0.257
kernel ignores the passed stride 7 failed (4 CPU-only)
break only scalar caught, that tier only
break only avx512f caught, that tier only

The module-level CUDA guard also moved onto the tests that need a GPU — all ten were skipping on a CPU-only host, which is exactly where a CPU MoE kernel matters most.

Known limits, stated up front

  • The avx512bf16 fp8 tier has never executed here. This host lacks the ISA and I have no SDE, so that path is unvalidated on hardware — and the code comment says it is the tier that matters. A mutation that halves its output goes undetected on this machine. Someone with Emerald Rapids or Sapphire Rapids should confirm it; @gdevenyi's 6526Y would do.
  • Tier verification is name-mediated. A tier that is numerically correct but silently does not use AVX-512 would pass. Correctness tests cannot distinguish that; only the throughput number argues the tier earns its place.
  • CPU-only runs exercise num_threads=1 only. Row splitting is format-independent, so the risk is low, but multi-threaded fp8 is covered solely by the GPU cross-check.

Test results

CUDA:      12 passed, 1 skipped     (avx512bf16 tier skipped — host lacks the ISA)
CPU-only:   9 passed, 4 skipped
tests/moe: 130 passed, 7 skipped, 1 failed

The one failure is test_cpu_moe_q4_0.py::test_cpu_decode_q4_0_matches_ggml_mmvq, a GGUF JIT compile error under gcc 15 (need 'typename' before ... in torch headers). It fails identically on main — unrelated to this PR.

Built with gcc 15.2.1 / binutils 2.44. Function-level AVX-512 target attributes need binutils >= 2.36; the gcc 11 / binutils 2.35 on RHEL 9 cannot assemble them.

gdevenyi and others added 4 commits September 4, 2026 14:34
Block-fp8 checkpoints (Qwen3.5/3.6-FP8, DeepSeek-V3-style, GLM) had no CPU expert
path at all:

    NotImplementedError: --moe-backend cpu/hybrid ... supports
    ['bf16', 'ds_fp4', 'mxfp4_triton', 'nvfp4', 'q4_0'] formats,
    but this checkpoint's experts are 'fp8_block'

so every expert miss had to stream over PCIe at ~25 GB/s while the CPU sat idle.

The bank is fp8-e4m3 row-major [rows, K] with one bf16 scale per 128x128 block --
K contiguous per output row, unlike mxfp4's transposed bank, so it maps straight
onto a bf16 dot product. e4m3 -> bf16 is *exact* (3 mantissa bits into 7, and
bf16's exponent range covers e4m3's whole 2^-9..448 span), so the AVX-512 path
widens weights in-register and feeds `_mm512_dpbf16_ps` against the bf16
activations: identical products to the reference's fp32 math, differing only in
summation order -- the same latitude the bf16 dot already takes.

The normal range rebases the exponent with a shift and a constant +120; exp == 0
is subnormal (m * 2^-9) and does not obey that, so those lanes blend in from a
small table. AVX2 does the same in fp32; scalar goes through the existing 256-entry
e4m3 LUT.

`ft bench bw` gains an fp8_block CPU measurement (it could already build the banks).
Measured on main with this commit alone, `--dtype fp8_block --reps 5`, 2x Xeon Gold
6526Y:

                        CPU MoE      PCIe    ratio   backend picked
    main             not measurable  25.1     --     offload
    main + this      53.7 GB/s       25.1    2.14x   hybrid

Before this commit the format reports no CPU number, so the backend picker has
nothing to weigh and selects offload. The ratio is what decides the backend, so
adding the kernel is what lets a block-fp8 deployment use the CPU at all.

End to end on Qwen/Qwen3.6-35B-A3B-FP8, median of 3 generations at matched GPU
expert residency:

    residency   offload   hybrid    delta
    10%           43.94    50.94    +15.9%
    25%           82.65    55.12    -33%

The win is real but conditional: hybrid pays off once the miss rate is high enough
that PCIe is the bottleneck, and costs throughput when enough experts are resident
that the CPU becomes the limiter. See the PR discussion -- the ratio makes
`--moe-backend auto` select hybrid for every block-fp8 checkpoint, which is right
for the oversubscribed case this project targets and wrong for a model that nearly
fits, and the bench models bandwidth but not miss rate.

Correctness is checked against the Triton block-fp8 decode kernel on identical
banks and routing (bs 1/2/5), plus an exactness check on the e4m3 -> bf16 widening
over all 256 codes. A weight-format ABI probe (`max_weight_format_id`) makes a
stale prebuilt `.so` fail loudly instead of falling through to the wrong dequant
branch and returning silently wrong numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
The scale banks' trailing dimension is padded so per-expert rows land on
a 16-byte boundary for the fused copy (kernel/aot_models.py:
fp8_block_scale_pad), so the row stride is not ceil(K/128). Recomputing
it from the unpadded block count reads scales at the wrong offset.

Qwen3.8 is the case in the tree: models/qwen3_5_moe/weight.py records
down_scale as 20x5 bf16 = 200 B, and 200 % 16 != 0 pads cols 5 -> 6.
The benchbw workload (H=2048, I=512) and DSV4-Flash (H=7168, I=2048)
both happen to give rows*cols divisible by 8, so neither trips it.

Read the stride off the tensor and pass it through to the kernel, and
let the shape check assert the unpadded extent is present rather than an
exact shape. The fixture now allocates padded banks, so the existing GPU
cross-check covers this too; the GPU kernel was already stride-aware.
The existing GPU-vs-CPU check compares two fp32 accumulations at a 2e-2
tolerance, which cannot see accumulator precision loss. Add a float64
reference over mixed-sign, full-range e4m3 weights and zero-mean
activations, and force each ISA tier explicitly so the sweep is real.

Also add an avx512f tier between avx512bf16 and avx2, so CPUs with
AVX-512 but no BF16 extension (Skylake-SP through Ice Lake-SP) stop
falling back to 8-wide fp32.

Measured on Xeon Platinum 8368 (Ice Lake-SP, no avx512_bf16), three
repetitions each: +19% single-thread cache-resident (3.31 -> 3.94 GB/s,
disjoint intervals), but only +4% under `ft bench bw --dtype fp8` at 75
threads (60.4 -> 62.7 GB/s, overlapping intervals). At that thread count
the kernel is memory-bound, so the project's own benchmark cannot
resolve the tier; the single-thread gain is the honest figure.
The new tier shipped without test coverage: deleting the avx512f branch
from select_fp8dot left every test green, because isa_name() reports
select_dot() (the bf16 chain) rather than the fp8 dispatch. Injecting a
ragged-tail bug into dot_fp8_block_avx2 was invisible for the same
reason -- no test ran numerics under a forced tier.

Force each available fp8 tier through the float64 parity GEMV, expose
the selected fp8 tier so the assertion can see it, and make the padded
bank test run a GEMV instead of only constructing an executor.

Also move the module-level CUDA guard onto the tests that actually need
a GPU. A CPU MoE kernel's most relevant hosts are the ones without a
usable GPU, and all ten tests were skipping there.
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