Skip to content

fix(exl3): give the MoE down projection its own Hadamard scratch - #1775

Open
Andybui1012 wants to merge 1 commit into
dphnAI:mainfrom
Andybui1012:fix/exl3-moe-mgemm-a-had-scratch
Open

fix(exl3): give the MoE down projection its own Hadamard scratch#1775
Andybui1012 wants to merge 1 commit into
dphnAI:mainfrom
Andybui1012:fix/exl3-moe-mgemm-a-had-scratch

Conversation

@Andybui1012

Copy link
Copy Markdown

Problem

exl3_mgemm reads A and writes the Hadamard-transformed input to A_had. The cooperative-kernel autotuner (CoopKernelAutotuner::launch) re-launches the kernel on the same arguments while it times candidates, so when A_had aliases A, every launch after the first transforms the previous launch's output.

Exl3MoEMethod._apply_single_token and _apply_small_batch passed layer.exl3_small_interm_a as both A and A_had for the down projection. Result: the first call for a given expert shape returns garbage (normalized max error ~1.0 against a dense reference), and every later call, served from the autotune cache, is correct. In serving that is the first decode step for each MoE expert shape, or for every shape whose autotune record is not already in ~/.cache/exllamav3/autotune.

Fix

Allocate a dedicated exl3_small_interm_a_had buffer next to exl3_small_interm_a in process_weights_after_loading and pass it as A_had at both call sites. ExLlamaV3 uses a separate buffer for this (block_sparse_mlp.py: "A_had must not alias A (the autotuner relaunches on the first call)"). The gate/up projections already use exl3_small_yh / exl3_small_yh_gu as separate scratch.

Test

tests/kernels/quantization/test_exl3_moe_small_batch.py builds a small synthetic EXL3 MoE layer through Exl3MoEMethod.process_weights_after_loading, uses intermediate sizes no other test autotunes, and checks the first apply() against a per-expert fp32 dense reference (reconstructed weights with the 128-Hadamard and sign flips folded in), for both the single-token (rows=1) and small-batch (rows=3) paths. It also checks that the second call equals the first.

On the previous code: 2 failed (normalized error 1.22 and 0.97). With the fix: 2 passed. Verified in a CUDA 13 container on a GB10 (sm_121); the test needs a CUDA device and about 100 MiB of GPU memory.

ruff check and ruff format --check pass on both files.

exl3_mgemm reads A and writes the Hadamard-transformed input to A_had.
The cooperative-kernel autotuner re-launches the kernel on the same
arguments while it times candidates, so when A_had aliases A every
launch after the first transforms the previous launch's output. The
single-token and small-batch MoE paths passed exl3_small_interm_a for
both, so the first call for a given shape returned garbage (normalized
error ~1.0 vs a dense reference) and every later call, served from the
autotune cache, was correct. In serving this is the first decode step
for each expert shape, or for every shape whose autotune record is not
in ~/.cache/exllamav3.

Allocate a dedicated exl3_small_interm_a_had buffer next to interm_a
and pass it as A_had at both call sites. ExLlamaV3 uses a separate
buffer here as well (block_sparse_mlp.py, "A_had must not alias A").

The new test builds a small synthetic EXL3 MoE layer through
Exl3MoEMethod, uses intermediate sizes no other test autotunes, and
checks the first apply() against a per-expert fp32 dense reference for
both paths. It fails on the previous code (1.22 and 0.97) and passes
with the fix.


@pytest.mark.parametrize(("rows", "intermediate"), [(1, 384), (3, 640)])
def test_moe_small_batch_first_call_matches_dense_reference(rows: int, intermediate: int, monkeypatch, tmp_path):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def test_moe_small_batch_first_call_matches_dense_reference(rows: int, intermediate: int, monkeypatch, tmp_path):
def test_moe_first_call_matches_dense_reference(rows: int, intermediate: int, monkeypatch, tmp_path):

Still not happy with it, but this name is a lot better.

environment override only helps when this test runs first, and is set so
that running the file on its own never sees a pre-seeded shape.
"""
monkeypatch.setenv("EXLLAMAV3_TUNE_CACHE", str(tmp_path / "coop_autotune_v1.bin"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would make the test pass unconditionally with a warm cache, but I'd say it's mostly a nit.

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