Skip to content

Commit 4e7b7a9

Browse files
Add vllm qdq benchmark plugin (#2523)
* add qdq Signed-off-by: yiliu30 <yi4.liu@intel.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Signed-off-by: yiliu30 <yi4.liu@intel.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent e530349 commit 4e7b7a9

47 files changed

Lines changed: 7723 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# vllm-qdq-plugin
2+
3+
Out-of-tree [vLLM](https://github.com/vllm-project/vllm) plugin that simulates activation quant-dequant (QDQ) before quantized GEMM kernels. Useful for studying the accuracy impact of "real" quantized compute vs weight-only dequant approaches.
4+
5+
## How It Works
6+
7+
The plugin registers as a `vllm.general_plugins` entry point, which vLLM loads automatically in **all processes** (main + workers). It monkey-patches the low-level op wrappers in `vllm._custom_ops` to inject QDQ on input activations before the actual kernel call. This means:
8+
9+
- Zero vLLM source modifications
10+
- Works with both `LLM()` Python API and `vllm serve`
11+
- Covers all call sites automatically (dense linear, MoE gate+up, MoE down)
12+
13+
## Installation
14+
15+
```bash
16+
pip install git+https://github.com/yiliu30/vllm-qdq-plugin.git
17+
18+
# Or for development:
19+
git clone https://github.com/yiliu30/vllm-qdq-plugin.git
20+
pip install -e vllm-qdq-plugin/
21+
```
22+
23+
## Usage
24+
25+
```bash
26+
# Enable QDQ
27+
VLLM_QDQ=1 python my_script.py
28+
29+
# With vllm serve
30+
VLLM_QDQ=1 vllm serve /path/to/model --tensor-parallel-size 2
31+
32+
# Enable trace logging (prints shape/dtype for each QDQ call)
33+
VLLM_QDQ=1 VLLM_QDQ_TRACE=1 vllm serve /path/to/model
34+
35+
# Force MXFP4 QDQ on Marlin MoE when dtype-based detection is not enough
36+
VLLM_QDQ=1 VLLM_MARLIN_MOE_QDQ_MODE=FORCE_MXFP4 vllm serve /path/to/model
37+
```
38+
39+
### Environment Variables
40+
41+
| Variable | Default | Description |
42+
|---|---|---|
43+
| `VLLM_QDQ` | `0` | Set to `1` to enable QDQ |
44+
| `VLLM_QDQ_TRACE` | `0` | Set to `1` to print trace lines (up to 200) |
45+
| `VLLM_MARLIN_MOE_QDQ_MODE` | `0` | Set to `FORCE_MXFP4` to apply MXFP4 QDQ in `moe_wna16_marlin_gemm` when dtype-based routing is not sufficient. Matching is case-insensitive. |
46+
47+
## Support Status
48+
49+
| Dtype | Op | Status | Notes |
50+
|---|---|---|---|
51+
| **MXFP4** (E2M1 + E8M0 scales) | `marlin_gemm` | ✅ Supported | Dense quantized linear (MXFP4 via Marlin) |
52+
| **MXFP4** (E2M1 + E8M0 scales) | `moe_wna16_marlin_gemm` | ✅ Supported | MoE quantized linear (MXFP4 via Marlin) |
53+
54+
### How QDQ Works
55+
56+
For MXFP4, the QDQ simulates:
57+
1. **Quantize**: Scale activations per group of 32 using E8M0 (power-of-2) scales, then round to nearest FP4 E2M1 value `{0, 0.5, 1, 1.5, 2, 3, 4, 6}`
58+
2. **Dequantize**: Multiply back by the scale to restore the original dtype
59+
60+
This introduces the same quantization noise that a "real" MXFP4 GEMM would produce on the input side, while keeping the actual computation in bf16 via Marlin's weight-only dequant kernel.
61+
62+
## Adding New Dtypes
63+
64+
1. Create a new QDQ implementation in `src/vllm_qdq_plugin/qdq/` (e.g., `fp8.py`)
65+
2. Add an `elif` branch in `patch.py` where the dtype check happens
66+
3. The QDQ function signature: `(x: Tensor, **config) -> Tensor` — same shape and dtype in/out
67+
68+
## License
69+
70+
Apache-2.0
71+
72+
---
73+
74+
## Sage3 Triton Attention Backend (vllm-omni)
75+
76+
This plugin also provides an **out-of-tree diffusion attention backend** for [vllm-omni](https://github.com/vllm-project/vllm-omni), using the [SageAttention3](https://github.com/thu-ml/SageAttention) standalone Triton kernel.
77+
78+
### How It Works
79+
80+
Registers via the `vllm_omni.general_plugins` entry_point. When `VLLM_SAGE3_TRITON=1`, overrides the `SAGE_ATTN` diffusion attention backend with the sage3 Triton implementation. When disabled (default), the original in-tree backend is used unchanged.
81+
82+
- Zero vllm-omni source modifications
83+
- Conditional activation — doesn't affect normal operation when off
84+
- Falls back to torch SDPA for cross-attention (different Q/K sequence lengths)
85+
86+
### Usage
87+
88+
```bash
89+
# Enable sage3 Triton attention for diffusion models
90+
VLLM_SAGE3_TRITON=1 \
91+
SAGE3_QUANT_FORMAT=mxfp4 \
92+
DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN \
93+
python examples/offline_inference/text_to_image/text_to_image.py \
94+
--model /path/to/model ...
95+
96+
# Use original in-tree sage_attn (sageattention v2) — default
97+
DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN python ...
98+
99+
# Use torch SDPA (no sage at all)
100+
DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA python ...
101+
```
102+
103+
### Environment Variables
104+
105+
| Variable | Default | Description |
106+
|---|---|---|
107+
| `VLLM_SAGE3_TRITON` | `0` | Set to `1` to enable sage3 Triton backend override |
108+
| `SAGE3_QUANT_FORMAT` | `mxfp4` | Quantization config for K/V (`mxfp4`, `nvfp4`, `mxfp8_s1`, `mxfp4_s1`) |
109+
| `SAGE3_ACC_DTYPE` | `fp32` | Accumulator dtype (`fp32`, `bf16_both_dot`, `bf16_pv_only`, etc.) |
110+
111+
### Notes
112+
113+
- **Shared memory requirement**: The sage3 fp32 kernel needs ~192KB shared memory per SM. On GPUs with less (e.g., RTX 6000D with 100KB), use `SAGE3_ACC_DTYPE=bf16_both_dot` or switch to TORCH_SDPA.
114+
- **Cross-attention**: sage3 requires Q and K to have the same sequence length. Cross-attention calls automatically fall back to torch SDPA.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## Goal
2+
3+
Review the current local changes for forced MXFP4 QDQ support and the move from ad-hoc `os.getenv` reads to a shared `envs` module, then fix any correctness issues.
4+
5+
## Finish Criteria
6+
7+
- Forced MXFP4 mode cannot silently fail because of env parsing or case handling.
8+
- The package consistently reads public runtime switches through `vllm_qdq_plugin.envs`.
9+
- The updated behavior is documented where users would look for it.
10+
- Focused verification passes for import/syntax and env parsing behavior.
11+
12+
## Plan
13+
14+
1. Read the local diff and surrounding files to find correctness gaps.
15+
2. Patch code paths that mishandle or incompletely apply the new env-based controls.
16+
3. Add or update documentation for the new env knob if needed.
17+
4. Run targeted verification and only stop once the new behavior is confirmed.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# sage3 attention route example
2+
3+
`sage3_route.json` is an example route file for per-(layer, denoising-step)
4+
attention quant-config routing. Point the plugin at it with:
5+
6+
```bash
7+
SAGE3_ROUTE_FILE=examples/sage3_route.json \
8+
SAGE3_ROUTE_DEBUG=1 \
9+
VLLM_SAGE3_TRITON=1 \
10+
DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN \
11+
python ... # your Wan2.2 T2V run
12+
```
13+
14+
## What this example does
15+
16+
For a **40-step, 40-layer** Wan2.2 run:
17+
18+
| Region | Config | Meaning |
19+
| --- | --- | --- |
20+
| steps `0-7` (all layers) | `sdpa` | first 20% of steps run full precision |
21+
| layer `0` (all steps) | `sdpa` | first layer stays full precision |
22+
| everything else | `mxfp4` | cheap quant attention |
23+
24+
## Schema
25+
26+
```json
27+
{
28+
"default": "mxfp4",
29+
"rules": [
30+
{"steps": "0-7", "config": "sdpa"},
31+
{"layers": "0", "config": "sdpa"}
32+
]
33+
}
34+
```
35+
36+
- Each rule has optional `layers` and/or `steps` (inclusive range strings like
37+
`"0-7,9,12-15"`) and a required `config`.
38+
- `config` is any key in the sage3 `ATTENTION_CONFIGS` (e.g. `mxfp4`,
39+
`mxfp8_s1`) or the literal `"sdpa"` (full precision).
40+
- Rules are **first-match-wins**. No matching rule falls back to `default`.
41+
With no `default`, the impl's own config (`SAGE3_QUANT_FORMAT`) is left alone.
42+
- `SAGE3_ROUTE_DEBUG=1` logs each unique `(layer, step) -> config` once.
43+
44+
## Indices are absolute — rescale for your step count
45+
46+
`steps` and `layers` are absolute indices, not percentages. `"steps": "0-7"`
47+
equals "first 20%" **only at 40 steps**. If you change the step count, rescale
48+
the step rule to keep the same window:
49+
50+
| Steps | First 20% |
51+
| --- | --- |
52+
| 20 | `"0-3"` |
53+
| 40 | `"0-7"` |
54+
| 50 | `"0-9"` |
55+
56+
The layer rule never changes — Wan2.2 always has 40 layers.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"default": "mxfp4",
3+
"rules": [
4+
{"steps": "0-7", "config": "sdpa"},
5+
{"layers": "0", "config": "sdpa"}
6+
]
7+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[project]
2+
name = "vllm-qdq-plugin"
3+
version = "0.1.0"
4+
description = "Out-of-tree QDQ (quant-dequant) simulation plugin for vLLM"
5+
requires-python = ">=3.10"
6+
dependencies = ["torch"]
7+
8+
[project.entry-points."vllm.general_plugins"]
9+
qdq = "vllm_qdq_plugin:register"
10+
11+
[project.entry-points."vllm_omni.general_plugins"]
12+
sage3_attn = "vllm_qdq_plugin:register_omni"
13+
14+
[build-system]
15+
requires = ["setuptools>=64"]
16+
build-backend = "setuptools.build_meta"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env bash
2+
# Wan2.2 T2V using SpargeAttn (block-sparse + quantized attention) as the backend.
3+
#
4+
# SpargeAttn replaces self-attention via the plugin's SpargeAttnImpl. The plugin
5+
# overrides the SAGE_ATTN backend slot when VLLM_SPARGE_ATTN=1, so the host's
6+
# DIFFUSION_ATTENTION_BACKEND stays "SAGE_ATTN".
7+
#
8+
# SPARGE_TOPK=1.0 keeps ALL KV blocks (dense). This isolates SpargeAttn's
9+
# quantization error for a correctness-first run (no sparsity speedup yet).
10+
# To enable real block-sparsity later, lower SPARGE_TOPK (e.g. 0.8, 0.5) — but
11+
# note SpargeAttn is UNtuned on Wan2.2, so video quality at topk<1.0 is unverified.
12+
set -euo pipefail
13+
cd /home/yiliu7/workspace/vllm-omni
14+
15+
source /home/yiliu7/workspace/venvs/omni/bin/activate
16+
export C_INCLUDE_PATH=$HOME/.local/include:$HOME/.local/include/python3.12${C_INCLUDE_PATH:+:$C_INCLUDE_PATH}
17+
18+
# The omni venv has a stale vllm 0.20.0 wheel, but the editable vllm-omni (rebased
19+
# to v0.22.0) needs symbols only present in the newer vllm source checkout at
20+
# /home/yiliu7/workspace/vllm (0.21.1rc1, already compiled). Prepend it on
21+
# PYTHONPATH so `import vllm` resolves to that tree. Non-invasive: no venv mutation.
22+
# export PYTHONPATH=/home/yiliu7/workspace/vllm${PYTHONPATH:+:$PYTHONPATH}
23+
24+
PY=/home/yiliu7/workspace/venvs/omni/bin/python
25+
MODEL=/storage/yiliu7/Wan-AI/Wan2.2-T2V-A14B-Diffusers
26+
27+
# height=420
28+
# width=280
29+
# num_frames=81
30+
# num_inference_steps=40
31+
height=720
32+
width=1280
33+
num_frames=81
34+
num_inference_steps=40
35+
seed=42
36+
SPARGE_TOPK=0.5
37+
38+
out_dir=/home/yiliu7/workspace/wan-res
39+
out_file=t2v_sparge_h${height}_w${width}_f${num_frames}_s${num_inference_steps}_topk${SPARGE_TOPK}_2
40+
mkdir -p "${out_dir}"
41+
42+
# VLLM_SPARGE_ATTN=1 activates the plugin's SpargeAttn backend (overriding SAGE_ATTN).
43+
# SPARGE_ATTN_REPO is injected on sys.path so the prebuilt spas_sage_attn package
44+
# (built in the `sparge` venv) is importable from the `omni` venv — identical
45+
# torch/CUDA/python ABI, so no rebuild is needed.
46+
# NOTE: linear/MoE quantization is intentionally OFF (bf16). The host's
47+
# --quantization mxfp8 path is gated to NPU/XPU only (mxfp8_config.py:144) and
48+
# raises on CUDA — and it is orthogonal to attention. Leaving it off isolates the
49+
# SpargeAttn attention integration, which is what this demo exercises.
50+
VLLM_SPARGE_ATTN=1 \
51+
SPARGE_ATTN_REPO=/home/yiliu7/workspace/SpargeAttn \
52+
SPARGE_MODE=topk \
53+
SPARGE_TOPK=${SPARGE_TOPK} \
54+
DIFFUSION_ATTENTION_BACKEND=SAGE_ATTN \
55+
CUDA_VISIBLE_DEVICES=6,7 \
56+
PYTHONUNBUFFERED=1 \
57+
${PY} -u examples/offline_inference/text_to_video/text_to_video.py \
58+
--model ${MODEL} \
59+
--prompt "Two anthropomorphic cats in comfy boxing gear and bright gloves fight intensely on a spotlighted stage." \
60+
--negative-prompt "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走" \
61+
--height ${height} \
62+
--width ${width} \
63+
--num-frames ${num_frames} \
64+
--guidance-scale 4.0 \
65+
--guidance-scale-high 3.0 \
66+
--num-inference-steps ${num_inference_steps} \
67+
--fps 16 \
68+
--tensor-parallel-size 2 \
69+
--output ${out_dir}/${out_file}.mp4 2>&1 | tee ${out_file}.log
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Benchmark: SAGE3 HW kernels vs BF16 SDPA on [1, 40, 75600, 128]."""
2+
3+
import json
4+
import os
5+
import sys
6+
7+
import torch
8+
import torch.nn.functional as F
9+
10+
# Resolve paths relative to this script to avoid hardcoded user-specific paths
11+
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
12+
sys.path.insert(0, os.path.join(REPO_ROOT, "src"))
13+
# Bypass top-level vllm dependency by importing the sage3 subpackage directly
14+
sys.path.insert(0, os.path.join(REPO_ROOT, "src", "vllm_qdq_plugin", "sage3_attn"))
15+
from sage3.api import sageattn3_standalone
16+
17+
# ── Config ──
18+
B, H, N, D = 1, 40, 75600, 128
19+
WARMUP = 3
20+
ITERS = 10
21+
DEVICE = "cuda"
22+
DTYPE = torch.bfloat16
23+
24+
25+
def bench(fn, warmup=WARMUP, iters=ITERS):
26+
"""Returns mean latency in ms."""
27+
for _ in range(warmup):
28+
fn()
29+
torch.cuda.synchronize()
30+
start = torch.cuda.Event(enable_timing=True)
31+
end = torch.cuda.Event(enable_timing=True)
32+
start.record()
33+
for _ in range(iters):
34+
fn()
35+
end.record()
36+
torch.cuda.synchronize()
37+
return start.elapsed_time(end) / iters
38+
39+
40+
def compute_attn_flops(B, H, N, D):
41+
"""FLOPs for standard attention: 2*B*H*N*N*D (QK) + 2*B*H*N*N*D (PV) = 4*B*H*N^2*D."""
42+
return 4 * B * H * N * N * D
43+
44+
45+
def main():
46+
print(f"Shape: [{B}, {H}, {N}, {D}] | dtype: {DTYPE} | warmup: {WARMUP} | iters: {ITERS}")
47+
print(f"Device: {torch.cuda.get_device_name()}")
48+
print("-" * 70)
49+
total_flops = compute_attn_flops(B, H, N, D)
50+
51+
q = torch.randn(B, H, N, D, device=DEVICE, dtype=DTYPE)
52+
k = torch.randn(B, H, N, D, device=DEVICE, dtype=DTYPE)
53+
v = torch.randn(B, H, N, D, device=DEVICE, dtype=DTYPE)
54+
55+
configs = [
56+
("bf16_sdpa", lambda: F.scaled_dot_product_attention(q, k, v)),
57+
("mxfp8_hw", lambda: sageattn3_standalone(q, k, v, config="mxfp8_hw")),
58+
("mxfp4_hw", lambda: sageattn3_standalone(q, k, v, config="mxfp4_hw")),
59+
("mixed_mxfp8qk_mxfp4pv_hw", lambda: sageattn3_standalone(q, k, v, config="mixed_mxfp8qk_mxfp4pv_hw")),
60+
("mixed_mxfp4qk_mxfp8pv_hw", lambda: sageattn3_standalone(q, k, v, config="mixed_mxfp4qk_mxfp8pv_hw")),
61+
]
62+
63+
results = {}
64+
baseline_ms = None
65+
66+
for name, fn in configs:
67+
try:
68+
# Verify it runs
69+
out = fn()
70+
assert out.shape == (B, H, N, D), f"Bad shape: {out.shape}"
71+
del out
72+
torch.cuda.empty_cache()
73+
74+
ms = bench(fn)
75+
results[name] = {"ms": ms, "status": "ok"}
76+
if baseline_ms is None:
77+
baseline_ms = ms
78+
except Exception as e:
79+
results[name] = {"ms": None, "status": f"FAIL: {e}"}
80+
print(f" {name}: FAILED - {e}")
81+
82+
# Print table
83+
print(f"{'Config':<30} {'Latency (ms)':>12} {'TFLOPS':>8} {'Speedup':>8}")
84+
print("-" * 62)
85+
for name, r in results.items():
86+
if r["ms"] is not None:
87+
speedup = baseline_ms / r["ms"] if baseline_ms else 0
88+
tflops = total_flops / (r["ms"] * 1e-3) / 1e12
89+
print(f"{name:<30} {r['ms']:>12.2f} {tflops:>7.1f} {speedup:>7.2f}x")
90+
r["speedup"] = round(speedup, 3)
91+
r["tflops"] = round(tflops, 2)
92+
else:
93+
print(f"{name:<30} {'FAIL':>12} {'N/A':>8}")
94+
95+
# Save raw results
96+
out_path = os.path.join(REPO_ROOT, "bench_hw_vs_sdpa_results.json")
97+
meta = {
98+
"shape": [B, H, N, D],
99+
"dtype": str(DTYPE),
100+
"warmup": WARMUP,
101+
"iters": ITERS,
102+
"device": torch.cuda.get_device_name(),
103+
"total_flops": total_flops,
104+
}
105+
with open(out_path, "w") as f:
106+
json.dump({"meta": meta, "results": results}, f, indent=2)
107+
print(f"\nRaw results saved to: {out_path}")
108+
109+
110+
if __name__ == "__main__":
111+
main()

0 commit comments

Comments
 (0)