Skip to content

[XPU] Enable XPU blockfp8 for DSv3 - #49596

Closed
frost-intel wants to merge 4 commits into
vllm-project:mainfrom
frost-intel:xpu_dsv3_block_support
Closed

[XPU] Enable XPU blockfp8 for DSv3#49596
frost-intel wants to merge 4 commits into
vllm-project:mainfrom
frost-intel:xpu_dsv3_block_support

Conversation

@frost-intel

Copy link
Copy Markdown
Contributor

Purpose

Enable DeepSeek-V3 on Intel XPU through the oneDNN block-scaled FP8 GEMM (XPUFp8BlockScaledMMKernel). Three issues blocked correct execution:

  1. oneDNN requires N to be a multiple of block_n. Some DeepSeek-V3 block-FP8 projections have a per-partition N that isn't 128-aligned. We now pad the weight's N up to the next multiple of block_n in process_weights_after_loading, record the unpadded size, and drop the padded output columns after the GEMM in apply_block_scaled_mm.

  2. Block-scale layout mismatch during weight dequant. The XPU kernel transposes the block scale to [K/block_k, N/block_n] layout at load time. get_and_maybe_dequant_weights (used by MLA to recover kv_b_proj for weight absorption) assumes the checkpoint [N/block_n, K/block_k] layout and asserts on the mismatch. We record layer.weight_scale_transposed = True when the kernel repacks the scale and undo the transpose in the dequant helper before scaled_dequantize.

  3. Make the shared block base padding-agnostic. Fp8BlockScaledMMLinearKernel.apply_weights previously computed the output shape from weight.shape[0], which is the padded N on XPU. It now derives the output shape from the actual GEMM output width (output.shape[-1]) after the matmul. This is a no-op for all non-padding backends (Triton / DeepGEMM / Cutlass / FlashInfer / Aiter-ROCm / CPU), where the returned width already equals weight.shape[0], and it lets the XPU kernel return a sliced result that reshapes correctly.

Test Plan

Run a block-FP8 DeepSeek-V3 checkpoint on Intel XPU Max 1550 and confirm it (a) loads without the oneDNN N-divisibility failure and without the scaled_dequantize shape assertion in the MLA process_weights_after_loading, and (b) produces coherent generations.

This did require using two workarounds which were outside the scope of this PR

  1. Replace torch.ops._C_cache_ops.getMemoryInfo(device) with torch.xpu.get_mem_info(device) due to outdated L0 drivers on Max 1550 installation.
  2. Topk workaround as detailed in upgrade to oneapi 2026.0 vllm-xpu-kernels#451 (comment)

Test Result

Before: DeepSeek-V3 block-FP8 failed to run on XPU.

After: The model loads and generates on XPU (TP×PP×EP, --enforce-eager); the padded kv_a_proj layers run through oneDNN and the MLA kv_b_proj dequant succeeds.

A partial run of gsm8k (limit: 40) shows high accuracy:

vllm ({'pretrained': 'deepseek-ai/DeepSeek-V3', 'tensor_parallel_size': 4, 'pipeline_parallel_size': 3, 'enable_expert_parallel': True, 'enforce_eager': True, 'max_model_len': 2048, 'max_num_seqs': 32, 'gpu_memory_utilization': 0.95, 'kv_cache_memory_bytes': 1487067239, 'dtype': 'bfloat16'}), gen_kwargs: ({'max_gen_toks': 320}), limit: 40.0, num_fewshot: 5, batch_size: auto
|Tasks|Version|     Filter     |n-shot|  Metric   |   |Value|   |Stderr|
|-----|------:|----------------|-----:|-----------|---|----:|---|-----:|
|gsm8k|      3|flexible-extract|     5|exact_match|↑  | 0.95|±  |0.0349|
|     |       |strict-match    |     5|exact_match|↑  | 0.95|±  |0.0349|

Signed-off-by: frost-intel <frost.mitchell@intel.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@frost-intel

Copy link
Copy Markdown
Contributor Author

@jikunshang could you please review and label for ci?

@jikunshang

Copy link
Copy Markdown
Member

@frost-intel can you verify whether this PR resolved your issue?
#50349

@mergify

mergify Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @frost-intel.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Jul 31, 2026
Signed-off-by: Frost Mitchell <frost.mitchell@intel.com>
Signed-off-by: frost-intel <frost.mitchell@intel.com>
@frost-intel

frost-intel commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@jikunshang That PR fixed one of the 3 issues described above (#2). The fact that DeepSeek-V3 uses weights which aren't evenly divided by the block size is still an issue. I've adjusted my PR to account for the changes in #50349

This may have been missed previously because oneDNN does allow for some mismatch between the tile and quantization. It picks an m-tile using an internal heuristic then checks against the scale group in the JIT tile selector. https://github.com/uxlfoundation/oneDNN/blob/ac1678f3b0a18fe3426450fb4bd9114fe75cfa63/src/gpu/intel/gemm/jit/generator/pieces/quantization.cxx#L133). This fails for DSv3 but may work for other sizes.

However, my testing showed that for these cases where oneDNN proceeds, it's actually silently an accuracy error. See comment below, the error is actually in vllm-xpu-kernels.

I've also added a test to demonstrate the fix. However, I don't see many XPU-specific tests in the UTs, so let me know if you'd rather I remove that.

@mergify mergify Bot removed the needs-rebase label Jul 31, 2026
@xwu-intel

Copy link
Copy Markdown
Contributor

@frost-intel for accuracy, do you mean the micro test shows oneDNN's result accuracy comparing to the ref result? Are you able to run full model?

@frost-intel

Copy link
Copy Markdown
Contributor Author

@xwu-intel The regular DeepSeek-V3 input for fused_qkv_a_proj with N=2112 and block=128 fails with a primitive descriptor error:

  File "/mnt/scratch/fmitchel/repro_onednn_block_n.py", line 50, in gemm
    return torch.ops._xpu_C.fp8_gemm(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/mnt/scratch/fmitchel/miniforge3/envs/mnt_ptpy_nightly/lib/python3.12/site-packages/torch/_ops.py", line 1350, in __call__
    return self._op(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
RuntimeError: could not create a primitive descriptor for the matmul primitive. Run workload with environment variable ONEDNN_VERBOSE=all to get additional diagnostic information.

But in debugging I found that oneDNN does allow this op with some other input sizes where block doesn't evenly divide N. For example, if you use N=448 there's no crash, but the result is incorrect. My testing shows what seems to be a stride compute error where vllm-xpu-kernels computes the group size as N/n_blocks = 112 instead of using block=128, so the error looks like

  cols [   0: 112]  used scale block 0, wanted 0   ok
  cols [ 112: 128]  used scale block 1, wanted 0   WRONG
  cols [ 128: 224]  used scale block 1, wanted 1   ok
  cols [ 224: 256]  used scale block 2, wanted 1   WRONG
  cols [ 256: 336]  used scale block 2, wanted 2   ok
  cols [ 336: 384]  used scale block 3, wanted 2   WRONG
  cols [ 384: 448]  used scale block 3, wanted 3   ok

The problem is that since block isn't passed to the dnnl_matmul_w8a8_fp8 there's no way to determine the scale size and validate if the input is valid. Padding in the caller (as this PR does) seems like the right approach.

Additionally, trying to move padding lower in the stack instead of in vLLM would require padding to be done at runtime wasting additional memory.

Here's a reproducer:

repro_onednn_block_n.py

"""Minimal repro: oneDNN's fp8 block GEMM applies the wrong block scale along N
when N is not a multiple of block_n, silently.

Everything is picked so the right answer is obvious by inspection:

  * activation      = all ones
  * weight block b  = the constant 2**b, held flat across all K
  * K               = 512 = 4 * 128, so K is not the problem

Because each 128-row weight block is a single constant, block quantization
writes the *same* fp8 byte (448 = fp8 max) into every element of the weight --
all of the information ends up in the scale. So

    out[:, n] == K * 2**(n // 128)

and every column in a block must show one flat value. Anything else is the
scale grid landing on the wrong rows.

What the output shows: oneDNN sizes the scale group as N / n_blocks rather than
using block_n, so at N=448 it switches scales every 112 rows instead of every
128. Padding N up to n_blocks * block_n makes the two agree.

    python repro_onednn_block_n.py
"""

import torch
import vllm_xpu_kernels._xpu_C  # noqa: F401  registers torch.ops._xpu_C

FP8 = torch.float8_e4m3fn
FMAX = torch.finfo(FP8).max  # 448
BLOCK = 128
M, K, N = 16, 512, 448  # N = 3*128 + 64, deliberately not a multiple of BLOCK
#M, K, N = 16, 512, 2112  # N = 3*128 + 64, deliberately not a multiple of BLOCK
n_blk, k_blk = -(-N // BLOCK), K // BLOCK  # 4 scale blocks along N
n_pad = n_blk * BLOCK  # 512 rows of weight the scale grid describes

# Activation: all ones. amax = 1, so the scale is 1/FMAX and every byte is FMAX.
xq = torch.full((M, K), FMAX).to(FP8).xpu()
xs = torch.full((M, k_blk), 1.0 / FMAX).xpu()

# Weight: block b is the constant 2**b. Same story -- every byte is FMAX and
# the constant lives entirely in that block's scale.
vals = torch.tensor([2.0**b for b in range(n_blk)])
wq_pad = torch.full((n_pad, K), FMAX).to(FP8).xpu()  # padded to the scale grid
wq = wq_pad[:N].contiguous()  # what a real checkpoint hands you
ws = (vals / FMAX).view(n_blk, 1).repeat(1, k_blk).xpu()  # [n_blocks, k_blocks]


def gemm(weight: torch.Tensor) -> torch.Tensor:
    return torch.ops._xpu_C.fp8_gemm(
        xq,
        weight.t().contiguous(),
        torch.bfloat16,
        xs,
        ws.t().contiguous(),
        torch.Tensor(),
    )[..., :N].float()


def show(t: torch.Tensor) -> str:
    lo, hi = t.min().item(), t.max().item()
    return f"{lo:.0f}" if lo == hi else f"{lo:.0f}..{hi:.0f}"


# out[:, n] / K identifies which block's scale oneDNN actually applied.
LUT = {K * v: i for i, v in enumerate(vals.tolist())}


def scale_blocks(out: torch.Tensor) -> list[int]:
    return [LUT.get(out[0, n].item(), -1) for n in range(N)]


def runs(seq: list[int]):
    lo = 0
    for n in range(1, N + 1):
        if n == N or (seq[n], n // BLOCK) != (seq[lo], lo // BLOCK):
            yield lo, n, seq[lo]
            lo = n


print(f"M={M} K={K} N={N}  block_n={BLOCK}  N % block_n = {N % BLOCK}")
print(f"scale has {n_blk} blocks along N -> oneDNN reads {n_pad} weight rows\n")

bad, good = gemm(wq), gemm(wq_pad)
print(f"{'cols':>12}{'want':>8}{'unpadded':>12}{'padded':>10}   diagnosis")
for b in range(n_blk):
    lo, hi = b * BLOCK, min((b + 1) * BLOCK, N)
    want = K * vals[b].item()
    tail = "   <- ragged block" if hi - lo != BLOCK else ""
    got = show(bad[:, lo:hi])
    ok = "ok" if got == f"{want:.0f}" else "not even constant within the block"
    print(f"  [{lo:4d}:{hi:4d}]{want:8.0f}{got:>12}{show(good[:, lo:hi]):>10}   {ok}{tail}")

print("\nWhich scale block oneDNN actually applied, per column (unpadded):\n")
for lo, hi, got in runs(scale_blocks(bad)):
    want = lo // BLOCK
    print(
        f"  cols [{lo:4d}:{hi:4d}]  used scale block {got}, wanted {want}"
        f"   {'ok' if got == want else 'WRONG'}"
    )

assert scale_blocks(good) == [n // BLOCK for n in range(N)]
print(f"\n  (padded: all {N} columns get the right scale block)")
print(
    f"\nThe switch points are multiples of {N // n_blk} = N / n_blocks, not of"
    f" block_n = {BLOCK}.\nThe call does not raise -- it just returns wrong values."
)

@frost-intel

frost-intel commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

In vllm-xpu-kernels, the block scale is computed as wei_group_k and wei_group_n

  bool is_block_quant = (m1_sc.dim() == 2) && (m1_sc.size(1) > 1);

  int64_t wei_group_k = -1;
  int64_t wei_group_n = -1;
  if (is_block_quant) {
    TORCH_CHECK(
        m1_sc.size(1) == m2_sc.size(0),
        "Mismatch group size in input and weight.",
        m1_sc.size(1),
        " vs ",
        m2_sc.size(0));
    wei_group_k = k / m2_sc.size(0);
    wei_group_n = n / m2_sc.size(1);
  }

As long as n % m2_sc.size(1) == 0 then this is a valid tiling for oneDNN, and the computation proceeds. However, if the input is not evenly divisible by the block size, then this means the calculated block size is not the same as the true block size. In the example above, n / m2_sc.size(1) == 112 but the actual block size is 128. This means the incorrect block size is passed to oneDNN, which manifests as something like a stride compute error in the result from oneDNN. However, it's not actually a problem in oneDNN, but in the primitive_attr settings provided by vllm-xpu-kernels.

Currently, there's no mechanism to fix this in vllm-xpu-kernels, except for if we added block_size as an optional argument to any function supporting block quantization.

However, this is fixed by this PR, where we add padding to weights to ensure they are evenly divisible by the block size.

@xwu-intel

xwu-intel commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@zufangzhu help to check this. looks good to me. Besides this PR, maybe add some checks in TORCH_CHECK to ensure it's divisible and easy to detect incorrect block.

@frost-intel

Copy link
Copy Markdown
Contributor Author

Besides this PR, maybe add some checks in TORCH_CHECK to ensure it's divisible and easy to detect incorrect block.

Done in vllm-project/vllm-xpu-kernels#508

@xwu-intel

Copy link
Copy Markdown
Contributor

#52066

@frost-intel

Copy link
Copy Markdown
Contributor Author

Fixed by #52118 and #50349

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

intel-gpu Related to Intel GPU quantization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants