Skip to content

Skip redundant present_key/value copy when aliased to external KV cache (follow-up to #29715) - #31150

Open
titaiwangms wants to merge 4 commits into
mainfrom
copilot/cudnn-sdpa-decode-phase3
Open

Skip redundant present_key/value copy when aliased to external KV cache (follow-up to #29715)#31150
titaiwangms wants to merge 4 commits into
mainfrom
copilot/cudnn-sdpa-decode-phase3

Conversation

@titaiwangms

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #29715 (cuDNN SDPA decode tier for the ONNX standard Attention CUDA kernel).

On the external-KV-cache path (4-D BNSH, nonpad_kv_seqlen), all four CUDA attention backends
(Flash, cuDNN SDPA, Memory-Efficient, unfused) unconditionally cudaMemcpyAsync'd the entire K/V
cache into present_key/present_value, even when the caller IOBinds present_key/present_value
to the SAME device buffer as the K/V cache input — the documented TensorScatter + IOBinding
production pattern (mirroring TensorScatter's own .MayInplace(0, 0) self-copy skip and
GroupQueryAttention's past_key==present_key aliasing). This contradicted the file's own
PERFORMANCE NOTE and was technically undefined behavior (cudaMemcpyAsync requires non-overlapping
src/dst; a full self-copy is maximal overlap).

What changed

  • Added llm_attention_detail::CopyKVToPresent(src, dst, stream), a shared helper that skips the
    D2D copy via a pointer-equality check when present_* aliases the K/V cache buffer, with a
    greppable VERBOSE log tag (present_copy_skipped) for test observability. Applied at all 8 call
    sites (K+V × 4 backends), only in the 4-D BNSH branches (3-D BSNH always needs a layout-changing
    transpose and can never alias).
  • Added a defensive size-equality ORT_ENFORCE inside the helper: proven safe today (present
    shape only equals K/V's shape when past_sequence_length == 0), but guards against a future
    caller reusing this helper outside that precondition.
  • TestAttentionPresentKVCopySkip (8 parameterized tests) covers all 4 backends × aliased/
    non-aliased, asserting both the copy-skip fires (or doesn't) via the log tag AND that the
    intended backend actually dispatched (not a silent MATH fallback), plus output/present_key/
    present_value correctness in both cases.

Notes

Also includes (unpushed until now) a NOTE documenting the Phase 3 (cuDNN SDPA prefill chunking)
investigation and its abandonment — chunking added complexity without a clear latency win at the
shapes profiled, so it was not pursued further.

Testing

  • Full test_onnx_attention suite: 342/342 passed (334 pre-existing + 8 new).
  • New test class re-verified individually with -v; confirmed via dispatch-log scraping that
    flash/efficient/cudnn/math parameterizations each hit their intended backend on an A100 (SM80),
    not a MATH fallback.
  • lintrunner clean.

This PR went through an internal multi-agent review pass (readability, correctness, adversarial,
spec/invariant, cross-module integration, and QA execution) before being opened; findings from
that pass are already incorporated.

Copilot AI and others added 4 commits July 29, 2026 18:25
…ion and abandonment

Phase 2 prototyped cuDNN SDPA dispatch for the ONNX Attention op's internal
past_key/present_key cache contract, but was abandoned after review: the
op's present_key/value shape grows by exactly one token per decode step
(no capacity concept), which defeats cuDNN's graph-cache (frontend build
once, run many times) model and causes a full graph rebuild on every
decode step. Measured on A100/cuDNN 9.8: ~260-330ms/step vs ~165-175us/step
for Flash/MEA in a realistic single-session growing-cache decode loop, a
~1600x regression, in a tier dispatched above Flash/MEA in the cascade.

See issue #29714 for the full investigation writeup and benchmark data.
…ion and abandonment

Phase 3 prototyped cuDNN SDPA dispatch for prefill via fixed-size, left-padded
query-row chunking to preserve cuDNN's graph-plan-cache-key stability. The
design passed two full independent review rounds with zero correctness
defects (incl. a 2246-configuration sweep of the causal-frontier algebra),
but was not merged for a value-proposition reason: measured speedup over the
existing MATH fallback was only ~0.94x-1.15x on A100 (worse than MATH for
short prompts), roughly at parity with Flash Attention on symmetric shapes,
and the auto-enable gate (mirroring GroupQueryAttention's own cuDNN prefill
path) only activates on SM>=90 (Hopper/Blackwell) - hardware this
investigation had no access to, so the benefit case where the feature would
actually run by default was never validated.

See issue #29714 for the full investigation writeup, chunk-size sweep, and
benchmark data before reviving this path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a1011908-d09e-411a-8979-fcf0dc18e20e
On the external-KV-cache (4-D BNSH, nonpad_kv_seqlen) path, all four
attention backends (Flash, cuDNN SDPA, Memory-Efficient, unfused)
unconditionally cudaMemcpyAsync'd the entire K/V cache into
present_key/present_value, even when the caller binds present_key/
present_value to the SAME device buffer as the K/V cache input (the
documented TensorScatter + IOBinding production pattern, mirroring
TensorScatter's own .MayInplace(0, 0) self-copy skip and GQA's
past_key==present_key aliasing). This contradicted the file's own
PERFORMANCE NOTE claiming the copy overhead is eliminated on this path.

Add llm_attention_detail::CopyKVToPresent(src, dst, stream), a small
shared helper performing a pointer-equality check before the D2D copy,
with a greppable VERBOSE log tag (present_copy_skipped) for test
observability. Apply it at all 8 call sites (K+V x 4 backends) in the
4-D BNSH (!is_bsnh) branches only; the 3-D BSNH branches always need a
layout-changing transpose and can never alias, so they are unchanged.
Update the stale PERFORMANCE NOTE and RunCudnnSdpaAttention comments
that claimed present_key/value are "not aliases".

Add TestAttentionPresentKVCopySkip covering all four backends: the
skip fires (log tag observed) and output/present_key/present_value
stay correct when present_* aliases the cache buffer, and the copy
still runs (no tag, still correct) when it does not.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a1011908-d09e-411a-8979-fcf0dc18e20e
From a parallel 6-agent review round on f7a4542:

- CopyKVToPresent: add a defensive ORT_ENFORCE that src/dst byte sizes
  match before the pointer-equality check. The invariant is proven safe
  today (present_key/value's shape only equals K/V's shape when
  past_sequence_length == 0, per ComputeOutputShapeForAttention), but
  the helper itself asserted nothing, so a future caller (e.g. a
  revived internal-cache/Phase-2 cuDNN path) could silently under-copy
  or wrongly skip if it reused this helper outside that precondition.
  Expanded the doc comment to state the precondition and cite the ONNX
  reference semantics (present_key == Identity(K) when there is no
  past) that make the skip a faithful identity, not just an
  optimization.
- Consolidated the four per-call-site comments (three verbose/stale,
  one missing entirely in RunUnfusedAttention) into one consistent
  one-line pointer at all 8 sites, and removed a stale hardcoded
  cross-file line-number reference.
- TestAttentionPresentKVCopySkip: the test only asserted the
  present_copy_skipped tag and output correctness, never which backend
  actually dispatched. Since three of the four provider_options OR in
  a MATH fallback bit, a regression that broke the Flash/cuDNN/MEA
  call sites while leaving the unfused ones intact would still have
  passed all 8 cases. Route _run_tensorscatter_attention_4d through
  the same ORT_ENABLE_ATTENTION_KERNEL_DEBUG_INFO capture the rest of
  this file uses, and assert the expected SdpaKernel=... tier per case
  (gated the same way the existing cuDNN decode tests are, so this
  degrades to a no-op assertion rather than a hard failure on HW/builds
  where a given backend is unsupported).
- Fixed a real global-state leak: set_default_logger_severity(VERBOSE)
  and the ORT_ENABLE_ATTENTION_KERNEL_DEBUG_INFO env var were set
  before session creation but only reset in a try/finally that started
  after it, so a session-creation failure would leak VERBOSE severity
  (and the debug-info env var) into unrelated later tests. Both
  mutations and their restoration now share one try/finally that spans
  session creation through the run.
- Moved the new sdpa_kernel bitmask constants next to the existing
  bitmask block so the whole AttentionBackend mapping stays in one
  place instead of split ~1200 lines apart.

Rebuilt, reran the full test_onnx_attention suite (342/342 passed) and
the new class individually with -v (8/8, confirmed via dispatch log
scraping that flash/efficient/cudnn/math all hit their intended
backend on this A100 box, not a MATH fallback). lintrunner clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a1011908-d09e-411a-8979-fcf0dc18e20e

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the CUDA implementation of the ONNX-domain Attention op (opset 24 external KV-cache path) by avoiding a redundant—and in the aliased case undefined—device-to-device cudaMemcpyAsync when present_key/present_value are bound to the same device buffer as the K/V cache. It also adds Python coverage that attempts to prove the copy-skip behavior via a stable log tag plus backend-dispatch observability.

Changes:

  • Add llm_attention_detail::CopyKVToPresent() helper in attention.cc that skips the present-cache D2D copy when src and dst alias, and logs a greppable present_copy_skipped tag.
  • Apply the helper at all 4 backends’ 4-D BNSH present-cache population sites (Flash, cuDNN SDPA, MEA, unfused).
  • Add parameterized Python tests to validate both aliased vs non-aliased behavior across the 4 backends and to assert the intended backend dispatch via attention debug info.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
onnxruntime/core/providers/cuda/llm/attention.cc Introduces CopyKVToPresent and routes 4-D BNSH present population through it across all CUDA backends; updates performance/behavior notes.
onnxruntime/test/python/transformers/test_onnx_attention/test_tensorscatter_attention.py Adds new parameterized tests that exercise alias vs non-alias present-cache binding and assert copy-skip via logs + backend dispatch via debug output.

Comment on lines +2349 to +2355
output = output_ort.numpy()
if alias_present:
present_k = key_cache_ort.numpy()
present_v = value_cache_ort.numpy()
else:
present_k = io_binding.get_outputs()[1].numpy()
present_v = io_binding.get_outputs()[2].numpy()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirming this — and the root cause is a bit different from the ordering described above.

IOBinding::GetOutputs() returns outputs_, which is populated in bind order, not graph-output order (onnxruntime/core/session/IOBinding.cc:90-96):

auto it = mapped_output_names_.emplace(name, output_names_.size());
if (/* new */) {
  output_names_.push_back(name);
  outputs_.push_back(ml_value);

_run_tensorscatter_attention_4d binds in this order: output(0), updated_key_cache(1), updated_value_cache(2), present_key(3), present_value(4). So indices 1/2 are the TensorScatter outputs regardless of the graph declaration order.

Why this matters more than "weakened coverage": updated_key_cache holds the in-place scattered cache, which is exactly ref_present_k. So both assert_allclose calls pass vacuously, and test_copy_still_runs_when_present_not_aliased never validates the D2D copy result — the single thing that test exists to check. A regression that skipped the copy unconditionally would still be green there.

present_k_ort / present_v_ort are already in scope in that branch, so the fix is to read from them directly, matching how the other helpers in this file do it (lines 804-806):

present_k = present_k_ort.numpy()
present_v = present_v_ort.numpy()

@titaiwangms
titaiwangms marked this pull request as ready for review July 30, 2026 16:03
@titaiwangms
titaiwangms requested a review from tianleiwu July 30, 2026 20:43
@titaiwangms titaiwangms added the ep:CUDA issues related to the CUDA execution provider label Jul 30, 2026

@tianleiwu tianleiwu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall

Nice, well-scoped optimization. I verified the past_sequence_length == 0 precondition the helper depends on actually holds at all 8 call sites (Flash/MEA via present_kv_already_populated == false, cuDNN via the explicit ORT_ENFORCE at the top of RunCudnnSdpaAttention, unfused via present_already_populated == false) — so present_* really is shape-identical to K/V there and the skip is a faithful identity, not just a size coincidence. Restricting to the !is_bsnh branches is right. Collapsing 8 duplicated cudaMemcpyAsync blocks into one helper is a readability win on its own.

Also a genuine plus on the test side: asserting the dispatched tier via SdpaKernel=... instead of trusting numeric correctness alone. Every non-MATH parameterization ORs in a MATH fallback bit, so without that assertion three of the four cases could silently degrade and still pass green.

Verdict: COMMENT. One issue that makes half the new coverage vacuous, plus a few suggestions.

Highest-priority item

The non-aliased branch reads io_binding.get_outputs()[1]/[2]. IOBinding::GetOutputs() returns outputs_, which is populated in bind order (onnxruntime/core/session/IOBinding.cc:90-96), not graph-output order — and this helper binds output, updated_key_cache, updated_value_cache, present_key, present_value. So indices 1/2 are the TensorScatter outputs. Since updated_key_cache holds the scattered cache (== ref_present_k), the assertions pass vacuously and test_copy_still_runs_when_present_not_aliased never validates the D2D copy result — the one thing it exists to check. present_k_ort/present_v_ort are already in scope; read from them directly the way the other helpers in this file do (lines 804-806). Replied with detail on the existing thread rather than opening a duplicate.

Inline suggestions

Left five inline comments covering: the LOGS_DEFAULT vs. session-logger choice (which also unblocks a non-flaky test assertion), ORT_ENFORCE in a Status-returning helper, the volume of Phase 2/3 retrospective prose in ComputeInternal, the process-global negative log assertion, and the backend-availability predicates.

Nitpicks (no action required)

  • The dst == nullptr early return in CopyKVToPresent is dead code — all 8 call sites already gate on present_key != nullptr / present_value != nullptr.
  • Pointer equality only detects exact aliasing; a same-size, partially-overlapping dst would still reach cudaMemcpyAsync with overlapping ranges. Essentially unreachable through IOBinding, and the size check narrows it further — worth one honest clause in the comment rather than a code change.
  • ~30 lines of comment preamble on a 15-line helper is disproportionate; the argument compresses to ~5 lines plus a link to this PR.
  • The VERBOSE log fires twice per decode step (K and V) on the steady-state path.
  • finally restores set_default_logger_severity(_ORT_LOG_SEVERITY_WARNING) — a hardcoded constant rather than the severity in effect on entry. Moot if the global mutation goes away (see the inline comment on the negative assertion).

"CopyKVToPresent requires identical src/dst sizes; this only holds when "
"past_sequence_length == 0 (see callers' gating).");
if (src->DataRaw() == dst->MutableDataRaw()) {
LOGS_DEFAULT(VERBOSE) << "Attention: " << kPresentCopySkippedTag

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOGS_DEFAULT routes to the process-wide default logger rather than the session logger. Two consequences:

  1. A user setting session_options.log_severity_level cannot control this message; conversely, raising the default severity floods every session in the process. The new test has to work around this by mutating global logger state.
  2. The tag cannot be attributed to a specific session, which makes the test's negative assertion inherently racy (see my comment on assertNotIn).

OpKernelContext::Logger() is available at every call site — all four Run*Attention methods already take context. Threading the logger into the helper and using LOGS(logger, VERBOSE) matches the ORT kernel convention and lets the test anchor on session_options.logid, exactly the way the CUDA-graph capture test in this same file already does.

return Status::OK();
}
ORT_ENFORCE(src->SizeInBytes() == dst->SizeInBytes(),
"CopyKVToPresent requires identical src/dst sizes; this only holds when "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function returns Status, but ORT_ENFORCE throws instead of returning one. ORT_RETURN_IF_NOT keeps the failure on the Status path that every caller already propagates via ORT_RETURN_IF_ERROR:

ORT_RETURN_IF_NOT(src->SizeInBytes() == dst->SizeInBytes(),
                  "CopyKVToPresent requires identical src/dst sizes; this only holds when "
                  "past_sequence_length == 0 (see callers' gating).");

Purely defensive here — I confirmed the precondition holds at all 8 call sites — so this is consistency, not correctness.

// spec-sized region back out) — a nontrivial redesign, not attempted. See issue #29714 for the
// full writeup and benchmark data before reviving this path.

// NOTE (Phase 3 — cuDNN SDPA prefill via fixed-size query-row chunking — investigated and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two NOTE (Phase N — investigated and abandoned) blocks add ~60 lines of design-retrospective narrative to the body of ComputeInternal. Both already close with "See issue #29714 for the full writeup and benchmark data" — which is the right home for the chunk-size sweep, the A100/cuDNN 9.8 timings, and the SM90 auto-enable caveat.

Two reasons to trim to 2-3 lines each (what was tried, why rejected, link to #29714):

  • The numbers are version- and hardware-specific and will rot silently as cuDNN and hardware move, whereas an issue is expected to be a point-in-time record.
  • 60 lines of prose between the dispatch gate and the USE_FLASH_ATTENTION block pushes the actual control flow apart for every future reader of this function.

The Phase 3 note is also outside this PR's stated scope, as the description acknowledges.

f"[{name}] expected the {expected_kernel} tier to dispatch on this HW/build, "
f"got {sdpa_kernel} — the per-backend coverage this test claims is not real.",
)
self.assertNotIn(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This negative assertion scans captured fd 2, which is process-global, while the helper has just raised the default logger severity to VERBOSE process-wide. Any concurrently running session that hits CopyKVToPresent with an aliased buffer emits the tag into this capture and fails the assertion — a false failure under parallel test execution.

This file already documents and solves exactly this hazard for the CUDA-graph capture test:

"fd 2 is a process-global descriptor, so a bare substring match could false-pass on a capture line logged by an unrelated concurrently-running session (e.g. under parallel test execution). The logid anchors the match to this test's own session."

The same mitigation is unavailable here only because the message goes through LOGS_DEFAULT. Switching the kernel to context->Logger() (see my comment on that line) unlocks it: set session_options.logid, then regex-match logid + tag on the same line. That also removes the need for set_default_logger_severity() entirely, since session_options.log_severity_level is already VERBOSE — and with it the hardcoded _ORT_LOG_SEVERITY_WARNING restore in the finally.

"efficient",
{"sdpa_kernel": str(_SDPA_KERNEL_EFFICIENT_ATTENTION | _SDPA_KERNEL_MATH)},
"EFFICIENT_ATTENTION",
lambda: True, # cutlass memory-efficient attention supports this class's SM53+ floor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gate for the memory-efficient backend is not just SM level — it is the onnxruntime_USE_MEMORY_EFFICIENT_ATTENTION build flag plus the ORT_DISABLE_MEMORY_EFFICIENT_ATTENTION env override (both already used elsewhere in this suite, e.g. test_gqa.py:1427, test_mha.py:1381). On a build without MEA compiled in, assertEqual("EFFICIENT_ATTENTION", sdpa_kernel) hard-fails instead of skipping.

The flash case has the same gap in the other direction: has_flash_attention() is just has_cuda_device(80) and says nothing about USE_FLASH_ATTENTION / ORT_DISABLE_FLASH_ATTENTION.

Since the aliasing logic under test is backend-independent, the cheapest robust fix is to treat an unexpected MATH result as a skipTest for the non-MATH parameterizations, or probe availability once in setUpClass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ep:CUDA issues related to the CUDA execution provider

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants