Skip to content

Commit 89a23d8

Browse files
authored
[CUDA] Optimize FlashDecode split planning for local-window GQA (#29161)
## Description When `GroupQueryAttention` runs the CUDA FlashDecode fast-decode path with a sliding/local attention window (`local_window_size > 0`), the split-K planning was sized using the full `total_sequence_length` even though only the last `local_window_size` KV positions can contribute to the output. This caused local-window decode layers to over-split and run an unnecessary split-K combine pass. This PR clamps the sequence length used for split planning to the local window size, so local-window decode no longer pays for splits/combine work it does not need. This is motivated by models that use local attention windows for GQA (e.g. gpt-oss-style decode with a small sliding window over a large KV cache). ## Summary of Changes ### Kernel dispatch | File | Change | |------|--------| | `onnxruntime/contrib_ops/cuda/bert/group_query_attention.cc` | In the FlashDecode fast-decode path, clamp the sequence length passed to `get_num_splits_and_buffer_sizes` to `local_window_size` when `local_window_size > 0`, so split-K planning reflects only the windowed KV range. | ### Tests | File | Change | |------|--------| | `onnxruntime/test/python/transformers/test_gqa.py` | Add `test_gqa_local_window_large_context_decode` regression test: decode step (q_len=1) with a large past context (4096) and a small local window (128), verifying parity of the narrowed split planning. Skips when Flash Attention is unavailable. | ### Profiling helpers | File | Change | |------|--------| | `onnxruntime/test/python/transformers/profile_gqa.py` | New nsys profiling helper for the GQA decode path, with a `--local-window-size` option and NVTX range markers. | | `onnxruntime/test/python/transformers/profile_gqa.sh` | New shell wrapper that runs `nsys` profiling per precision mode and parses results with the shared `parse_nsys.py`; checks `nsys`/`nvtx` availability instead of mutating the environment. | ## Testing - Unit test: ```bash cd onnxruntime/test/python/transformers PIPELINE_MODE=1 python test_gqa.py -k test_gqa_local_window_large_context_decode -v ``` - Existing FlashDecode parity coverage: ```bash PIPELINE_MODE=1 python test_gqa.py -k test_gqa_past_flash_attention -v ``` - Profiling (optional, requires an NVIDIA GPU + Nsight Systems): ```bash cd onnxruntime/test/python/transformers ./profile_gqa.sh --fp16 --past-sequence-length 4096 --local-window-size 128 ``` Observed on H200 (SM90, fp16, batch=2, num_heads=64, kv_num_heads=8, head_size=64): the split-K combine pass is eliminated for the local-window case and the main decode kernel time drops significantly versus the unclamped (full-context) split planning. - Backward compatibility: behavior is unchanged when `local_window_size <= 0`; the clamp only applies on the FlashDecode fast-decode path with a positive local window. ## Motivation and Context Local-window GQA decode layers only attend to the most recent `local_window_size` KV positions, so splitting and combining across the entire KV cache wastes split-K combine work. Clamping the split planning sequence length to the window size keeps the fast path correct while removing the redundant combine pass for windowed decode layers. ## Checklist - [x] Tests added/updated - [x] No breaking changes (behavior unchanged when `local_window_size <= 0`) - [ ] Documentation updated (if applicable)
1 parent 88468c2 commit 89a23d8

4 files changed

Lines changed: 453 additions & 1 deletion

File tree

onnxruntime/contrib_ops/cuda/bert/group_query_attention.cc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,8 +452,13 @@ Status GroupQueryAttention<T, U>::ComputeInternal(OpKernelContext* context) cons
452452
size_t softmax_lse_bytes = onnxruntime::flash::get_softmax_lse_size(parameters.sequence_length, parameters.batch_size, parameters.num_heads);
453453

454454
int num_heads_for_split = data.use_flash_attention_fast_decode ? parameters.kv_num_heads : parameters.num_heads;
455+
size_t sequence_length_for_split = static_cast<size_t>(parameters.total_sequence_length);
456+
if (data.use_flash_attention_fast_decode && parameters.local_window_size > 0) {
457+
sequence_length_for_split = std::min(sequence_length_for_split, static_cast<size_t>(parameters.local_window_size));
458+
}
459+
455460
auto [num_splits, softmax_lse_accum_bytes, out_accum_bytes] = onnxruntime::flash::get_num_splits_and_buffer_sizes(
456-
parameters.batch_size, parameters.sequence_length, parameters.total_sequence_length, num_heads_for_split,
461+
parameters.batch_size, parameters.sequence_length, sequence_length_for_split, num_heads_for_split,
457462
parameters.head_size, device_prop.multiProcessorCount);
458463

459464
parameters.num_splits = static_cast<int>(num_splits);
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
# --------------------------------------------------------------------------
5+
6+
"""
7+
Simple profiling script for GroupQueryAttention with quantized KV cache.
8+
9+
Usage:
10+
cd /onnxruntime/test/python/transformers
11+
python profile_gqa.py
12+
13+
# Profile with Nsight Compute (kernel-level analysis)
14+
ncu --set full -o gqa_fp16 python profile_gqa.py --mode fp16 --warmup 5 --repeat 1
15+
ncu --set full -o gqa_int8 python profile_gqa.py --mode int8 --warmup 5 --repeat 1
16+
17+
# Profile with Nsight Systems (timeline analysis) and extract kernel timings
18+
nsys profile -o gqa_int8 --export=sqlite python profile_gqa.py --mode int8 --warmup 5 --repeat 10
19+
python parse_nsys.py gqa_int8.sqlite
20+
"""
21+
22+
import argparse
23+
import time
24+
25+
import torch
26+
from test_sparse_attention import GroupQueryAttentionConfig, OrtGroupQueryAttention
27+
28+
# Optional NVTX support for nsys range markers
29+
try:
30+
import nvtx
31+
32+
HAS_NVTX = True
33+
except ImportError:
34+
HAS_NVTX = False
35+
36+
# Dummy context manager when NVTX is not available
37+
class DummyNvtxRange:
38+
def __init__(self, name):
39+
pass
40+
41+
def __enter__(self):
42+
return self
43+
44+
def __exit__(self, *args):
45+
pass
46+
47+
class nvtx: # noqa: N801
48+
@staticmethod
49+
def annotate(name, color=None):
50+
return DummyNvtxRange(name)
51+
52+
53+
def create_gqa_config(
54+
mode: str = "fp16",
55+
batch_size: int = 1,
56+
sequence_length: int = 1,
57+
past_sequence_length: int = 2048,
58+
max_sequence_length: int = 4096,
59+
num_heads: int = 32,
60+
kv_num_heads: int = 8,
61+
head_size: int = 128,
62+
local_window_size: int = -1,
63+
is_packed_qkv: bool = False,
64+
do_rotary: bool = True,
65+
device: str = "cuda",
66+
share_kv_scale: bool = False,
67+
) -> GroupQueryAttentionConfig:
68+
"""Create a GQA config based on the mode."""
69+
if mode == "fp16":
70+
k_quant_type = "NONE"
71+
v_quant_type = "NONE"
72+
kv_cache_type = "float16"
73+
dtype = torch.float16
74+
elif mode == "bf16":
75+
k_quant_type = "NONE"
76+
v_quant_type = "NONE"
77+
kv_cache_type = "bfloat16"
78+
dtype = torch.bfloat16
79+
elif mode == "int8":
80+
k_quant_type = "PER_TENSOR"
81+
v_quant_type = "PER_TENSOR"
82+
kv_cache_type = "int8"
83+
dtype = torch.float16
84+
elif mode == "int4":
85+
k_quant_type = "PER_CHANNEL"
86+
v_quant_type = "PER_CHANNEL"
87+
kv_cache_type = "int4"
88+
dtype = torch.float16
89+
else:
90+
raise ValueError(f"Unknown mode: {mode}")
91+
92+
config = GroupQueryAttentionConfig(
93+
batch_size=batch_size,
94+
sequence_length=sequence_length,
95+
max_sequence_length=max_sequence_length,
96+
past_sequence_length=past_sequence_length,
97+
num_heads=num_heads,
98+
kv_num_heads=kv_num_heads,
99+
head_size=head_size,
100+
local_window_size=local_window_size,
101+
do_rotary=do_rotary,
102+
rotary_interleaved=False,
103+
dtype=dtype,
104+
is_packed_qkv=is_packed_qkv,
105+
use_smooth_softmax=False,
106+
device=device,
107+
k_quant_type=k_quant_type,
108+
v_quant_type=v_quant_type,
109+
kv_cache_type=kv_cache_type,
110+
share_kv_scale=share_kv_scale,
111+
)
112+
return config
113+
114+
115+
def benchmark_gqa(config: GroupQueryAttentionConfig, warmup: int = 50, repeat: int = 100, mode: str = ""):
116+
"""Run benchmark and return average time in ms."""
117+
obj = OrtGroupQueryAttention(config)
118+
119+
# Warmup phase with NVTX annotation
120+
with nvtx.annotate(f"warmup_{mode}", color="yellow"):
121+
for _ in range(warmup):
122+
obj.infer()
123+
torch.cuda.synchronize()
124+
125+
# Benchmark phase with NVTX annotation
126+
with nvtx.annotate(f"benchmark_{mode}", color="green"):
127+
start = time.perf_counter()
128+
for _ in range(repeat):
129+
obj.infer()
130+
torch.cuda.synchronize()
131+
end = time.perf_counter()
132+
133+
avg_ms = (end - start) * 1000 / repeat
134+
return avg_ms
135+
136+
137+
def run_comparison(args):
138+
"""Compare FP16/BF16 vs quantized performance."""
139+
# Auto-adjust max_sequence_length to be at least total_sequence_length
140+
total_sequence_length = args.past_sequence_length + args.sequence_length
141+
if args.max_sequence_length < total_sequence_length:
142+
args.max_sequence_length = total_sequence_length
143+
print(f"Note: max_sequence_length auto-adjusted to {args.max_sequence_length}")
144+
145+
print(f"\n{'=' * 70}")
146+
print("GQA Performance Comparison")
147+
print(f"{'=' * 70}")
148+
print(f"Config: batch={args.batch_size}, seq_len={args.sequence_length}, past_seq={args.past_sequence_length}")
149+
print(f" num_heads={args.num_heads}, kv_heads={args.kv_num_heads}, head_size={args.head_size}")
150+
print(f" warmup={args.warmup}, repeat={args.repeat}")
151+
print(f"{'=' * 70}\n")
152+
153+
modes = ["fp16", "bf16", "int8", "int4"] if args.mode == "all" else [args.mode]
154+
results = {}
155+
156+
for mode in modes:
157+
config = create_gqa_config(
158+
mode=mode,
159+
batch_size=args.batch_size,
160+
sequence_length=args.sequence_length,
161+
past_sequence_length=args.past_sequence_length,
162+
max_sequence_length=args.max_sequence_length,
163+
num_heads=args.num_heads,
164+
kv_num_heads=args.kv_num_heads,
165+
head_size=args.head_size,
166+
local_window_size=args.local_window_size,
167+
is_packed_qkv=args.is_packed_qkv,
168+
do_rotary=not args.no_rotary,
169+
share_kv_scale=args.share_kv_scale,
170+
)
171+
avg_ms = benchmark_gqa(config, warmup=args.warmup, repeat=args.repeat, mode=mode)
172+
results[mode] = avg_ms
173+
print(f" {mode.upper():6s} (dtype={config.dtype}): {avg_ms:.4f} ms")
174+
175+
# Print comparison if we have baseline
176+
baseline = "fp16" if "fp16" in results else ("bf16" if "bf16" in results else None)
177+
if baseline and len(results) > 1:
178+
print(f"\n Relative to {baseline.upper()}:")
179+
for mode, ms in results.items():
180+
if mode != baseline:
181+
ratio = ms / results[baseline]
182+
print(f" {mode.upper()}: {ratio:.2f}x slower")
183+
184+
185+
def main():
186+
parser = argparse.ArgumentParser(description="Profile GQA with quantized KV cache")
187+
parser.add_argument(
188+
"--mode", choices=["fp16", "bf16", "int8", "int4", "all"], default="all", help="Quantization mode to test"
189+
)
190+
parser.add_argument("--batch-size", type=int, default=1, help="Batch size")
191+
parser.add_argument("--sequence-length", type=int, default=1, help="Query sequence length (1 for token generation)")
192+
parser.add_argument("--past-sequence-length", type=int, default=2048, help="Past KV cache sequence length")
193+
parser.add_argument("--max-sequence-length", type=int, default=4096, help="Max sequence length for KV cache buffer")
194+
parser.add_argument("--num-heads", type=int, default=32, help="Number of query heads")
195+
parser.add_argument("--kv-num-heads", type=int, default=8, help="Number of KV heads")
196+
parser.add_argument("--head-size", type=int, default=128, help="Head dimension")
197+
parser.add_argument(
198+
"--local-window-size",
199+
type=int,
200+
default=-1,
201+
help="Local attention window size (-1 disables sliding window, e.g. gpt-oss uses 128)",
202+
)
203+
parser.add_argument("--warmup", type=int, default=50, help="Warmup iterations")
204+
parser.add_argument("--repeat", type=int, default=100, help="Benchmark iterations")
205+
parser.add_argument("--is-packed-qkv", action="store_true", help="Use packed QKV")
206+
207+
parser.add_argument("--no-rotary", action="store_true", help="Disable rotary embeddings")
208+
parser.add_argument("--share-kv-scale", action="store_true", help="Share KV scale tensor for XQA")
209+
210+
args = parser.parse_args()
211+
212+
# Check CUDA
213+
if not torch.cuda.is_available():
214+
print("CUDA not available!")
215+
return
216+
217+
major, minor = torch.cuda.get_device_capability()
218+
print(f"GPU: {torch.cuda.get_device_name()} (SM{major}{minor})")
219+
220+
with torch.cuda.stream(torch.cuda.Stream()), torch.no_grad():
221+
run_comparison(args)
222+
223+
224+
if __name__ == "__main__":
225+
main()

0 commit comments

Comments
 (0)