Repository navigation
[GPU] SDPA: use key-partitioned single-token kernels for up to 4 query rows #38996
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d974607
30e2bf6
8d7b5d1
063433c
8b2ad1c
228ee98
8d8db38
3db4909
49d071b
175b7b0
5e0bf5a
055e02a
5cfec25
b6a57b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,34 @@ using namespace cldnn; | |
|
|
||
| namespace ov::intel_gpu::ocl { | ||
|
|
||
| namespace { | ||
|
|
||
| // Decode with grouped-query heads stacked as query rows has 3-4 rows per KV head on common models. | ||
| // Single-token kernel reads the keys once per row (Arc B390, 4K keys: +0.3% at 6 rows, -9% at 8, -26% at 16). | ||
| constexpr int64_t max_single_token_rows = 4; | ||
|
|
||
| // Several query rows stay on the multi-token kernels where the single-token stages are not exact for them: | ||
| // per-channel compressed KV needs the micro kernel, every key partition adds the sink to its softmax sum, | ||
| // the finalization stage merges at most get_max_partitions_num() partitions, and the query's local memory is K wide | ||
| // while its loads and the output reduction use the V width. | ||
| bool single_token_supports_query_rows(const RuntimeParams& params) { | ||
| return !SDPAOpt::has_per_channel_compressed_kv(params) && !params.typed_desc<scaled_dot_product_attention>()->has_sink_input && | ||
| get_partitions_num(params, SDPAStage::SINGLE_TOKEN) <= get_max_partitions_num() && | ||
| SDPABase::get_effective_v_head_size(params) <= SDPABase::get_effective_k_head_size(params); | ||
| } | ||
|
|
||
| bool use_multi_token_stage(const RuntimeParams& params) { | ||
| const auto query_rows = get_target_seq_len(params); | ||
| return query_rows > max_single_token_rows || (query_rows > 1 && !single_token_supports_query_rows(params)); | ||
| } | ||
|
|
||
| // Micro single-token kernel has no key partitions, so it is used for one query row only. | ||
| bool single_query_row(const RuntimeParams& params) { | ||
| return get_target_seq_len(params) <= 1; | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| bool SDPAOpt::has_per_channel_compressed_kv(const kernel_impl_params& params) { | ||
| const auto desc = params.typed_desc<scaled_dot_product_attention>(); | ||
| if (!desc->is_kv_compressed) { | ||
|
|
@@ -90,7 +118,7 @@ class SDPAOptImpl : public SDPAImplBase { | |
| } else { | ||
| auto is_indirect = params.typed_desc<scaled_dot_product_attention>()->indirect_axis != -1; | ||
| GPU_DEBUG_TRACE_DETAIL << "add stage for non-dynamic, is_indirect = " << is_indirect << "\n"; | ||
| if (is_prefill_stage(params) || unaligned_head_size(params)) { | ||
| if (use_multi_token_stage(params) || unaligned_head_size(params)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finalization allocates
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed for the new route in 3db4909: 2-4 query rows with more than 128 key partitions keep the multi-token kernels. The limit now comes from one host constant ( The single-row path has the same 128-partition limit today (decode above 32K keys when micro SDPA is not used); I'd rather fix the finalization kernel for any partition count in a separate PR than grow this one.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Each single-token partition adds the sink contribution to its local softmax denominator. Finalization then combines all partition denominators, counting the same virtual key once per partition. With P partitions, the global denominator contains P sink contributions instead of one, incorrectly reducing the attention output. Include the sink exactly once in the global softmax normalization, with the correct maximum-logit adjustment, or retain the multi-token route for sink inputs.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, each partition adds the sink to its exp_sum. Fixed for the new route in 3db4909: with a sink input, 2-4 query rows keep the multi-token route. As with the previous comment, the single-row path already behaves this way when it has more than one partition; I can fix the finalization (add the sink once, after the global max) in a follow-up PR.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Q=2–4 threshold replaces supported micro prefill with the regular single-token path for all eligible inputs. This path reads K/V separately for each query row; with multiple key partitions it also needs scratch buffers and an additional finalization launch. Query length alone therefore discards the faster implementation for some supported shapes. Keep micro prefill eligible and restrict the partitioned route to shapes where its additional work is justified.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The description's numbers compare against master's multi-token selection on Arc B390: decode with 3-4 query rows gets faster as the context grows (+2.6% to +9.6% at depth 4096-8192 on Llama-3.2 1B, Phi-4 mini and Qwen3.5 4B) and is within run-to-run noise at depth 0. If you prefer, I can keep micro prefill when the keys fit in one partition, since that is where the partitioned kernel has no work-group advantage.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new few-row route returns NaN for a fully masked key partition. For an FP32 SDPA primitive with Q=3, KV=512 and D=64, leave the first 256 keys unmasked and set the remaining mask entries to -Inf: all 384 output elements become NaN, while the multi-token base produces finite output. The empty partition divides by zero in sdpa_opt.cl:756, and finalization preserves NaN when multiplying its partial output by zero. Handle zero-sum partitions before normalization/finalization, or exclude this precision from the new route. This is confirmed at primitive level; the standard FP32 frontend currently decomposes SDPA.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed and fixed in “clamp the attention mask for every mask type”. The clamp to the lowest finite value was skipped for f32 masks, so -Inf reached the softmax. It now always applies. The same NaN also hit f16 SDPA with an f32 mask, and one query row too, since that path is the same kernel. sdpa_few_rows_grid covers fully masked partitions and rows with f16/f16, f16/f32 and f32/f32 data and masks, and these cases fail without the fix. |
||
| if (is_indirect) { | ||
| GPU_DEBUG_TRACE_DETAIL << "add stage for indirect non-dynamic with prefill_stage \n"; | ||
| add_stage(indirect_multi_tokens, params); | ||
|
|
@@ -115,7 +143,7 @@ class SDPAOptImpl : public SDPAImplBase { | |
| #ifdef ENABLE_ONEDNN_FOR_GPU | ||
| const auto& gfx_ver = params.get_program().get_engine().get_device_info().gfx_ver; | ||
| bool is_ARL_H = (gfx_ver.major == 12 && gfx_ver.minor == 74); | ||
| bool can_use_micro_sdpa = SDPAOpt::supports_micro_sdpa(params) && !is_ARL_H && !is_indirect; | ||
| bool can_use_micro_sdpa = SDPAOpt::supports_micro_sdpa(params) && !is_ARL_H && !is_indirect && single_query_row(params); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q=2–4 now skips micro multi-token execution, while micro single-token is restricted to Q=1. However, validation still accepts per-channel compressed KV based on micro support. The selected regular kernel indexes scales by token Y with X=0, whereas
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, thanks. Fixed in 3db4909: with per-channel compressed KV, 2-4 query rows now stay on the multi-token route (micro), in stage construction, execution and buffer sizing alike, through one predicate ( |
||
| if (can_use_micro_sdpa) { | ||
| add_stage(regular_micro_single_token, params); | ||
| } | ||
|
|
@@ -131,10 +159,10 @@ class SDPAOptImpl : public SDPAImplBase { | |
| [[nodiscard]] event::ptr execute(const std::vector<event::ptr>& events, primitive_inst& instance) override { | ||
| const auto& params = *instance.get_impl_params(); | ||
| auto new_params = SDPABase::requires_shape_canonicalization(params) ? SDPABase::static_canonicalize_shapes(params) : params; | ||
| bool is_prefill = is_prefill_stage(new_params); | ||
| const bool use_multi_tokens = is_prefill || unaligned_head_size(new_params); | ||
| const bool multi_token_rows = use_multi_token_stage(new_params); | ||
| const bool use_multi_tokens = multi_token_rows || unaligned_head_size(new_params); | ||
| bool is_indirect = need_indirect_load(static_cast<scaled_dot_product_attention_inst&>(instance)); | ||
| GPU_DEBUG_TRACE_DETAIL << "execute indirect = " << is_indirect << ", prefill = " << is_prefill << "\n"; | ||
| GPU_DEBUG_TRACE_DETAIL << "execute indirect = " << is_indirect << ", multi_token_rows = " << multi_token_rows << "\n"; | ||
| update_rt_params(instance); | ||
|
|
||
| kernel_dump_info.clear_entries(); | ||
|
|
@@ -160,7 +188,7 @@ class SDPAOptImpl : public SDPAImplBase { | |
| return execute_stage(events, instance, is_indirect ? indirect_multi_tokens : regular_multi_tokens); | ||
| } | ||
| #ifdef ENABLE_ONEDNN_FOR_GPU | ||
| if (has_stage(regular_micro_single_token) && !is_indirect) { | ||
| if (has_stage(regular_micro_single_token) && !is_indirect && single_query_row(new_params)) { | ||
| return execute_stage(events, instance, regular_micro_single_token); | ||
| } | ||
| #endif | ||
|
|
@@ -176,15 +204,14 @@ class SDPAOptImpl : public SDPAImplBase { | |
|
|
||
| [[nodiscard]] std::vector<BufferDescriptor> get_internal_buffer_descs(const RuntimeParams& params) const override { | ||
| std::vector<BufferDescriptor> internal_buffers; | ||
| auto desc = params.typed_desc<scaled_dot_product_attention>(); | ||
| auto extended_input_q_transpose_order = extend_order_in_num_heads_dim(desc->input_q_transpose_order); | ||
| auto params_canonicalization = SDPABase::requires_shape_canonicalization(params) ? SDPABase::static_canonicalize_shapes(params) : params; | ||
| const auto head_size = get_head_size(params_canonicalization.get_input_layout(0), extended_input_q_transpose_order); | ||
| // exp_sums and max_logits hold one value per output row and partition; the output is V_HEAD_SIZE wide | ||
| const auto v_head_size = SDPABase::get_effective_v_head_size(params_canonicalization); | ||
|
|
||
| const auto num_of_partitions = get_partitions_num(params_canonicalization, SDPAStage::SINGLE_TOKEN); | ||
| const auto is_prefill = is_prefill_stage(params_canonicalization); | ||
| const size_t buf_elements_count = (num_of_partitions == 1 || is_prefill) ? 1 : params.output_layouts[0].count() / head_size * num_of_partitions; | ||
| const size_t tmp_out_elements_count = (num_of_partitions == 1 || is_prefill) ? 2 : params.output_layouts[0].count() * num_of_partitions; | ||
| const auto multi_token_rows = use_multi_token_stage(params_canonicalization); | ||
| const size_t buf_elements_count = (num_of_partitions == 1 || multi_token_rows) ? 1 : params.output_layouts[0].count() / v_head_size * num_of_partitions; | ||
| const size_t tmp_out_elements_count = (num_of_partitions == 1 || multi_token_rows) ? 2 : params.output_layouts[0].count() * num_of_partitions; | ||
|
|
||
| internal_buffers.emplace_back(buf_elements_count, ov::element::f32); | ||
| internal_buffers.emplace_back(buf_elements_count, ov::element::f32); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
V_HEAD_SIZE > K_HEAD_SIZE also needs a fallback here. With Q=3, Q/K width 64 and V width 256, SG_SCALE_FACTOR=1 allocates query_local[64], but the unconditional query loads/stores address indices 0–255. For V width 128, the factor-2 output reduction reuses that array across indices 0–127. These accesses exceed the declared local array, although the numeric repro passes on the tested runtimes. Keep the multi-token route for V>K, or bound every query load and size scratch for both query storage and V-wide reduction; cover asymmetric widths with static and dynamic tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, fixed in “keep V wider than K on the multi-token kernels”: single_token_supports_query_rows() now also requires V ≤ K. As you said, the numbers don’t show this, so the tests check which kernel ran, for V = 128 and 256 with K = 64, static and dynamic. I also ran the grid with temporary bounds checks on the single-token kernel’s local and internal buffers. They were clean with the fix, and the V>K cases failed with it removed.