Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions onnxruntime/contrib_ops/cpu/bert/gqa_attention_base.h
Original file line number Diff line number Diff line change
Expand Up @@ -429,13 +429,19 @@ class GQAAttentionBase {
for (size_t seq = 0; seq < static_cast<size_t>(sequence_length); seq++) {
size_t seq_causal_length = causal_past_seqlen + seq + 1;

// Cap effective causal length at total_seqlen so the softmax window stays within
// the region filled by the QK GEMM. For right-padded batched prompts, padding
// positions have seq_causal_length > total_seqlen; without this cap the softmax
// would read uninitialized memory and produce NaN.
const size_t effective_causal_length = std::min(seq_causal_length, total_seqlen);

const bool apply_local = local_window_size_ >= 0 &&
seq_causal_length > static_cast<size_t>(local_window_size_);
const size_t start_off = apply_local ? seq_causal_length - local_window_size_ : 0;
const size_t win_size = apply_local ? local_window_size_ : seq_causal_length;
effective_causal_length > static_cast<size_t>(local_window_size_);
const size_t start_off = apply_local ? effective_causal_length - local_window_size_ : 0;
const size_t win_size = apply_local ? local_window_size_ : effective_causal_length;

if (apply_local) {
for (size_t t = 0; t < seq_causal_length - local_window_size_; t++) {
for (size_t t = 0; t < effective_causal_length - local_window_size_; t++) {
sm[t] = 0.f;
}
}
Expand All @@ -448,7 +454,7 @@ class GQAAttentionBase {
ApplyAttentionBias(sm + start_off, attn_bias + start_off, static_cast<int>(win_size));
}

for (size_t t = seq_causal_length; t < total_seqlen; t++) {
for (size_t t = effective_causal_length; t < total_seqlen; t++) {
sm[t] = 0.f;
}

Expand Down Expand Up @@ -1084,15 +1090,21 @@ class GQAAttentionBase {
for (size_t seq = 0; seq < sequence_length; seq++) {
size_t seq_causal_length = causal_past_seqlen + seq + 1;

// For right-padded batched prompts, padding positions have seq_causal_length > total_seqlen.
// The GEMM only fills columns [0, total_seqlen); beyond that the buffer is uninitialized.
// Cap the effective causal length so the softmax window stays within the filled region,
// preventing NaN from uninitialized memory propagating into the output.
const size_t effective_causal_length = std::min(seq_causal_length, total_seqlen);

const bool should_apply_local_window = local_window_size_ >= 0 &&
seq_causal_length > static_cast<size_t>(local_window_size_);
effective_causal_length > static_cast<size_t>(local_window_size_);

const size_t start_offset = should_apply_local_window ? seq_causal_length - local_window_size_ : 0;
const size_t window_size = should_apply_local_window ? local_window_size_ : seq_causal_length;
const size_t start_offset = should_apply_local_window ? effective_causal_length - local_window_size_ : 0;
const size_t window_size = should_apply_local_window ? local_window_size_ : effective_causal_length;

// Mask everything before local window, if local window should be applied
if (should_apply_local_window) {
for (size_t total_seq_id = 0; total_seq_id < seq_causal_length - local_window_size_; total_seq_id++) {
for (size_t total_seq_id = 0; total_seq_id < effective_causal_length - local_window_size_; total_seq_id++) {
if constexpr (std::is_same<U, float>::value) {
output_softmax[total_seq_id] = 0.f;
} else {
Expand Down Expand Up @@ -1120,8 +1132,8 @@ class GQAAttentionBase {
}
}

// set causal [seq_causal_length, total_seqlen) to 0.f
for (size_t total_seq_id = seq_causal_length; total_seq_id < total_seqlen; total_seq_id++) {
// set causal [effective_causal_length, total_seqlen) to 0.f
for (size_t total_seq_id = effective_causal_length; total_seq_id < total_seqlen; total_seq_id++) {
if constexpr (std::is_same<U, float>::value) {
output_softmax[total_seq_id] = 0.f;
} else {
Expand Down
212 changes: 212 additions & 0 deletions onnxruntime/test/contrib_ops/group_query_attention_op_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -2392,5 +2392,217 @@ TEST(GroupQueryAttentionTest, WebGPU_SharedKV_SlidingWindow) {
tester.Run(OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &execution_providers);
}

// ---------------------------------------------------------------------------
// Batched right-padded packed-QKV prefill with do_rotary.
//
// In a multi-batch prefill where individual prompts have different real lengths,
// GenAI right-pads short prompts up to the max sequence_length and reports each
// batch's real length via seqlens_k[b] = real_len[b] - 1. The property under
// test: each batch's real-last-token output (the one used to predict the next
// token) must equal what we get from running that prompt singly as a batch=1
// prefill. This is a generic correctness check that any GQA-supporting EP
// should satisfy.
// ---------------------------------------------------------------------------

// Builds a packed QKV tensor with deterministic values at real positions and
// zeros at right-padded positions. Layout per token: [Q(hidden), K(kv), V(kv)].
// Uses values of order ~1.0 (well above the 5e-3 mismatch tolerance) so the
// rotated-vs-unrotated divergence is unambiguously detectable.
static void FillBatchedRightPaddedPackedQKV(int batch_size,
int sequence_length,
int num_heads,
int kv_num_heads,
int head_size,
const std::vector<int>& real_lens,
std::vector<float>& packed_out) {
const int hidden_size = num_heads * head_size;
const int kv_hidden_size = kv_num_heads * head_size;
const int token_size = hidden_size + 2 * kv_hidden_size;
packed_out.assign(batch_size * sequence_length * token_size, 0.0f);
for (int b = 0; b < batch_size; ++b) {
const int real_len = real_lens[b];
for (int s = 0; s < real_len; ++s) {
float* token = &packed_out[(b * sequence_length + s) * token_size];
for (int c = 0; c < hidden_size; ++c) {
token[c] = 0.1f + 0.3f * static_cast<float>(((b * 7 + s * 3 + c) % 13) + 1);
}
for (int c = 0; c < kv_hidden_size; ++c) {
token[hidden_size + c] =
0.1f + 0.25f * static_cast<float>(((b * 5 + s * 2 + c) % 11) + 1);
token[hidden_size + kv_hidden_size + c] =
0.1f + 0.2f * static_cast<float>(((b * 3 + s + c) % 9) + 1);
}
}
}
}

// Runs a packed-QKV GQA prefill with do_rotary=1 and the given per-batch
// seqlens_k. Returns the output tensor [batch_size, sequence_length, hidden_size].
static std::vector<float> RunGQAPackedQKVRotaryPrefill(
int batch_size,
int sequence_length,
int num_heads,
int kv_num_heads,
int head_size,
const std::vector<int32_t>& seqlens_k_data,
const std::vector<float>& packed_qkv_data,
bool use_cuda = false,
bool use_webgpu = false) {
const int hidden_size = num_heads * head_size;
const int kv_hidden_size = kv_num_heads * head_size;
const int qkv_hidden = hidden_size + 2 * kv_hidden_size;
const int total_sequence_length = sequence_length; // prefill: no past
const int half_rotary = head_size / 2;
const int max_seq_len = sequence_length + 8;

OpTester tester("GroupQueryAttention", 1, onnxruntime::kMSDomain);
tester.AddAttribute<int64_t>("num_heads", static_cast<int64_t>(num_heads));
tester.AddAttribute<int64_t>("kv_num_heads", static_cast<int64_t>(kv_num_heads));
tester.AddAttribute<int64_t>("do_rotary", static_cast<int64_t>(1));

// Packed QKV: pass through `query` input, leave key/value as optional edges.
tester.AddInput<float>("query", {batch_size, sequence_length, qkv_hidden}, packed_qkv_data);
tester.AddOptionalInputEdge<float>(); // key (signals packed)
tester.AddOptionalInputEdge<float>(); // value (signals packed)

tester.AddOptionalInputEdge<float>(); // past_key
tester.AddOptionalInputEdge<float>(); // past_value

tester.AddInput<int32_t>("seqlens_k", {batch_size}, seqlens_k_data);
tester.AddInput<int32_t>("total_sequence_length", {1}, {total_sequence_length},
/*is_initializer=*/true);

std::vector<float> cos_cache(max_seq_len * half_rotary);
std::vector<float> sin_cache(max_seq_len * half_rotary);
for (int pos = 0; pos < max_seq_len; ++pos) {
for (int d = 0; d < half_rotary; ++d) {
const float freq = 1.0f / std::pow(10000.0f, 2.0f * static_cast<float>(d) /
static_cast<float>(head_size));
cos_cache[pos * half_rotary + d] = std::cos(static_cast<float>(pos) * freq);
sin_cache[pos * half_rotary + d] = std::sin(static_cast<float>(pos) * freq);
}
}
tester.AddInput<float>("cos_cache", {max_seq_len, half_rotary}, cos_cache);
tester.AddInput<float>("sin_cache", {max_seq_len, half_rotary}, sin_cache);

tester.AddOptionalInputEdge<int64_t>(); // position_ids
tester.AddOptionalInputEdge<float>(); // attention_bias
tester.AddOptionalInputEdge<float>(); // head_sink

const int output_size = batch_size * sequence_length * hidden_size;
tester.AddOutput<float>("output", {batch_size, sequence_length, hidden_size},
std::vector<float>(output_size, 0.0f));
const int present_size = batch_size * kv_num_heads * total_sequence_length * head_size;
tester.AddOutput<float>("present_key", {batch_size, kv_num_heads, total_sequence_length, head_size},
std::vector<float>(present_size, 0.0f));
tester.AddOutput<float>("present_value", {batch_size, kv_num_heads, total_sequence_length, head_size},
std::vector<float>(present_size, 0.0f));

tester.SetOutputTolerance(1e6f); // We fetch and compare outputs ourselves.

std::vector<std::unique_ptr<IExecutionProvider>> execution_providers;
if (use_cuda) {
execution_providers.push_back(DefaultCudaExecutionProvider());
} else if (use_webgpu) {
execution_providers.push_back(DefaultWebGpuExecutionProvider());
} else {
execution_providers.push_back(DefaultCpuExecutionProvider());
}
tester.Run(OpTester::ExpectResult::kExpectSuccess, "", {}, nullptr, &execution_providers);

auto fetches = tester.GetFetches();
const float* out_data = fetches[0].Get<Tensor>().Data<float>();
return std::vector<float>(out_data, out_data + output_size);
}

// Inner helper: builds packed-QKV inputs, computes per-prompt references, runs
// the right-padded batched prefill, and asserts each batch's real-last-token
// output matches its single-prompt reference. Both reference and batched runs
// go through the same EP, so this validates per-batch consistency within each
// EP rather than cross-EP equivalence.
static void RunBatchedRightPaddedRotaryPrefillForEP(bool use_cuda, bool use_webgpu) {
constexpr int batch_size = 3;
constexpr int num_heads = 4;
constexpr int kv_num_heads = 2;
constexpr int head_size = 16; // multiple of 4 for FlashAttention gate; rotary half = 8
constexpr int hidden_size = num_heads * head_size;
constexpr int kv_hidden_size = kv_num_heads * head_size;
constexpr int qkv_hidden = hidden_size + 2 * kv_hidden_size;

// Real prompt lengths per batch; max = sequence_length (right-padding extends
// shorter batches up to this length). The bug only manifests when at least
// one batch is shorter than sequence_length.
const std::vector<int> real_lens = {4, 2, 6};
const int sequence_length = *std::max_element(real_lens.begin(), real_lens.end());

std::vector<float> packed_batched;
FillBatchedRightPaddedPackedQKV(batch_size, sequence_length, num_heads, kv_num_heads,
head_size, real_lens, packed_batched);

// Build single-prompt references by extracting each batch's real-len slice
// and running it as a batch_size=1 prefill (which is known correct).
std::vector<std::vector<float>> ref_outputs(batch_size);
for (int b = 0; b < batch_size; ++b) {
const int real_len = real_lens[b];
std::vector<float> packed_single(real_len * qkv_hidden);
for (int s = 0; s < real_len; ++s) {
std::copy_n(&packed_batched[(b * sequence_length + s) * qkv_hidden], qkv_hidden,
&packed_single[s * qkv_hidden]);
}
ref_outputs[b] = RunGQAPackedQKVRotaryPrefill(
/*batch_size=*/1, /*sequence_length=*/real_len,
num_heads, kv_num_heads, head_size,
/*seqlens_k_data=*/{static_cast<int32_t>(real_len - 1)},
packed_single, use_cuda, use_webgpu);
}

// Now run all batches together with right-padding.
std::vector<int32_t> seqlens_k_data(batch_size);
for (int b = 0; b < batch_size; ++b) {
seqlens_k_data[b] = static_cast<int32_t>(real_lens[b] - 1);
}
const auto batched_output = RunGQAPackedQKVRotaryPrefill(
batch_size, sequence_length, num_heads, kv_num_heads, head_size,
seqlens_k_data, packed_batched, use_cuda, use_webgpu);

// Each batch's real-last-token output (used to predict next token) must match
// its single-prompt reference. The tolerance is loose enough for fp16 rounding
// while still catching the underflow bug (which produces values that differ
// by orders of magnitude or are NaN/Inf).
Comment thread
qjia7 marked this conversation as resolved.
Outdated
constexpr float tolerance = 5e-3f;
for (int b = 0; b < batch_size; ++b) {
const int real_len = real_lens[b];
const int q_last = real_len - 1;
const float* batched_last =
batched_output.data() + (b * sequence_length + q_last) * hidden_size;
const float* ref_last = ref_outputs[b].data() + q_last * hidden_size;
for (int c = 0; c < hidden_size; ++c) {
EXPECT_NEAR(batched_last[c], ref_last[c], tolerance)
<< "batch " << b << " real_len=" << real_len
<< " channel " << c << " mismatch";
}
}
}

TEST(GroupQueryAttentionTest, BatchedRightPaddedRotaryPrefill_CPU) {
RunBatchedRightPaddedRotaryPrefillForEP(/*use_cuda=*/false, /*use_webgpu=*/false);
}

TEST(GroupQueryAttentionTest, BatchedRightPaddedRotaryPrefill_CUDA) {
auto cuda_ep = DefaultCudaExecutionProvider();
if (!cuda_ep) {
GTEST_SKIP() << "CUDA EP not available";
}
RunBatchedRightPaddedRotaryPrefillForEP(/*use_cuda=*/true, /*use_webgpu=*/false);
}

TEST(GroupQueryAttentionTest, BatchedRightPaddedRotaryPrefill_WebGPU) {
auto webgpu_ep = DefaultWebGpuExecutionProvider();
if (!webgpu_ep) {
GTEST_SKIP() << "WebGPU EP not available";
}
RunBatchedRightPaddedRotaryPrefillForEP(/*use_cuda=*/false, /*use_webgpu=*/true);
}
Comment thread
qjia7 marked this conversation as resolved.

} // namespace test
} // namespace onnxruntime