On Intel Arc Pro B70 (BMG-G31, 608 GB/s), single-request decode of Qwen3.5-MoE hybrid models is limited by a fixed per-step cost of roughly 5-6.6 ms that is independent of how many bytes the model reads. A dense model of the same architecture family, on the same card and the same build, shows no such fixed cost and runs at the memory-bandwidth roofline.
Removing this overhead would take these models from ~71-89 tok/s to ~118-217 tok/s at batch 1.
Environment
|
-- | --
GPU | Intel Arc Pro B70 32GB (BMG-G31), 608 GB/s
CPU / RAM | Core Ultra 9 285H, 96 GB DDR5-5600
vLLM | 0.26.1.dev0+g568afb3a1
vllm-xpu-kernels | 0.1.12.1
torch | 2.13.0+xpu
Env | VLLM_XPU_ENABLE_XPU_GRAPH=1, VLLM_USE_V2_MODEL_RUNNER=1, async scheduling on
Models | Qwen3.5-MoE hybrid (40 layers: 30 GatedDeltaNet + 10 full attn, 256 experts, top-8 + 1 shared, moe_intermediate 512, hidden 2048)
8x the work for 1.21x the time. Consistent with a large fixed per-step cost that amortizes across the batch.
What was ruled out
Each of these was measured, not assumed.
Quantization format / coverage. MXFP4, GPTQ-Int4 and AutoRound-int4 all land in the 71-89 tok/s band despite an 85% spread in bytes read.
Level Zero runtime configuration. 14 configurations swept — UR_L0_USE_IMMEDIATE_COMMANDLISTS=0, UR_L0_BATCH_SIZE 16/64/128, UR_L0_REUSE_DISCARDED_EVENTS, UR_L0_DISABLE_EVENTS_CACHING,UR_L0_SERIALIZE, UR_L0_IN_ORDER_BARRIER_BY_SIGNAL, legacy SYCL_PI_* equivalents, and both Level Zero adapter versions. All results 12.56-12.67 ms except SYCL_UR_USE_LEVEL_ZERO_V2=0 at 13.33 ms. No knob moved it.
Kernel launch batching. XPU graphs work correctly. 200 sequential torch.relu calls on a 256-element tensor:
eager 0.862 ms (4.3 us per launch)
replay 0.181 ms (0.9 us per launch) -> 4.75x
Graph capture also demonstrably matters end to end: enforce_eager=True gives 41.09 ms/step vs 12.6 ms with graphs.
vLLM Python-side work. In-process cProfile (VLLM_ENABLE_V1_MULTIPROCESSING=0), 64 decode steps, sorted by tottime:
63 calls 0.618 s torch/xpu/graphs.py:105(replay)
64 calls 0.090 s Event.synchronize
...
64 calls 0.002 s gpu/model_runner.py:874(prepare_inputs)
64 calls 0.002 s gpu/sample/sampler.py:70(__call__)
64 calls 0.002 s gpu/attn_utils.py:566(build_attn_metadata)
65 calls 0.001 s v1/core/sched/scheduler.py:425(schedule)
Scheduler, input preparation, attention metadata and sampling together account for roughly 0.15 ms/step. vLLM's Python layer is not the cost.
Empty expert group dispatch. XpuFusedMoe._apply_kernel calls cutlass_grouped_gemm_interface with num_experts=self.num_experts (256) on every token, though only 9 experts have rows. In MoEGEMM(grouped_gemm_xe2.hpp) every work-group serially scans all 256 entries of rows_per_expert to build a prefix sum before locating its tile.
An isolated microbenchmark suggested this was expensive (232 us for 256 groups vs 94 us for 8, identical work). It is not the cause. We patched cutlass_grouped_gemm_interface to accept an optional expert_ids tensor so group ireads ptr_B[expert_ids[i]], allowing the caller to pass only active experts with no weight copying, and verified the compaction engages (8 groups instead of 256). End-to-end gain at batch 1: zero (72.6 vs 74.5 tok/s, within noise).
Reporting it anyway since the 256-group dispatch is real and may matter at other shapes — happy to open a separate PR if useful.
Where the time appears
All remaining time is inside torch.xpu.XPUGraph.replay() — 9.8 ms/step by tottime. Since replay() blocks until completion, this is submit-plus-wait and cannot by itself distinguish genuine GPU execution from replay overhead.
Note that torch.profiler reports only 2.38 ms/step of XPU kernel time (152.4 ms over 64 steps), but it does not appear to capture kernels executed inside graph replays, so we do not rely on that figure.
The fixed-cost fit above is independent of any profiler and holds across four models and three quantization formats.
What would help
- Confirmation of whether ~5-6.6 ms/step of MoE-specific fixed cost is expected on this path, or a known gap.
- Guidance on measuring GPU time inside XPU graph replays — the profiler undercount makes attribution difficult for anyone hitting this.
- If the 256-group grouped-GEMM dispatch is considered worth fixing regardless, we have a working
expert_idspatch against v0.1.12.1.
Reproduction
bash
# byte counts from safetensors headers + config
# batch sweep, prefill subtracted, median of 3
BATCHES=1,2,4,8 NGEN=64 python3 batchsweep.py /models/<model>
Scripts used for all measurements above can be attached on request.
On Intel Arc Pro B70 (BMG-G31, 608 GB/s), single-request decode of Qwen3.5-MoE hybrid models is limited by a fixed per-step cost of roughly 5-6.6 ms that is independent of how many bytes the model reads. A dense model of the same architecture family, on the same card and the same build, shows no such fixed cost and runs at the memory-bandwidth roofline.
Removing this overhead would take these models from ~71-89 tok/s to ~118-217 tok/s at batch 1.
Environment
8x the work for 1.21x the time. Consistent with a large fixed per-step cost that amortizes across the batch.
What was ruled out
Each of these was measured, not assumed.
Quantization format / coverage. MXFP4, GPTQ-Int4 and AutoRound-int4 all land in the 71-89 tok/s band despite an 85% spread in bytes read.
Level Zero runtime configuration. 14 configurations swept —
UR_L0_USE_IMMEDIATE_COMMANDLISTS=0,UR_L0_BATCH_SIZE16/64/128,UR_L0_REUSE_DISCARDED_EVENTS,UR_L0_DISABLE_EVENTS_CACHING,UR_L0_SERIALIZE,UR_L0_IN_ORDER_BARRIER_BY_SIGNAL, legacySYCL_PI_*equivalents, and both Level Zero adapter versions. All results 12.56-12.67 ms exceptSYCL_UR_USE_LEVEL_ZERO_V2=0at 13.33 ms. No knob moved it.Kernel launch batching. XPU graphs work correctly. 200 sequential
torch.relucalls on a 256-element tensor:Graph capture also demonstrably matters end to end:
enforce_eager=Truegives 41.09 ms/step vs 12.6 ms with graphs.vLLM Python-side work. In-process cProfile (
VLLM_ENABLE_V1_MULTIPROCESSING=0), 64 decode steps, sorted bytottime:Scheduler, input preparation, attention metadata and sampling together account for roughly 0.15 ms/step. vLLM's Python layer is not the cost.
Empty expert group dispatch.
XpuFusedMoe._apply_kernelcallscutlass_grouped_gemm_interfacewithnum_experts=self.num_experts(256) on every token, though only 9 experts have rows. InMoEGEMM(grouped_gemm_xe2.hpp) every work-group serially scans all 256 entries ofrows_per_expertto build a prefix sum before locating its tile.An isolated microbenchmark suggested this was expensive (232 us for 256 groups vs 94 us for 8, identical work). It is not the cause. We patched
cutlass_grouped_gemm_interfaceto accept an optionalexpert_idstensor so groupireadsptr_B[expert_ids[i]], allowing the caller to pass only active experts with no weight copying, and verified the compaction engages (8 groups instead of 256). End-to-end gain at batch 1: zero (72.6 vs 74.5 tok/s, within noise).Reporting it anyway since the 256-group dispatch is real and may matter at other shapes — happy to open a separate PR if useful.
Where the time appears
All remaining time is inside
torch.xpu.XPUGraph.replay()— 9.8 ms/step bytottime. Sincereplay()blocks until completion, this is submit-plus-wait and cannot by itself distinguish genuine GPU execution from replay overhead.Note that
torch.profilerreports only 2.38 ms/step of XPU kernel time (152.4 ms over 64 steps), but it does not appear to capture kernels executed inside graph replays, so we do not rely on that figure.The fixed-cost fit above is independent of any profiler and holds across four models and three quantization formats.
What would help
expert_idspatch against v0.1.12.1.Reproduction
Scripts used for all measurements above can be attached on request.