Skip redundant present_key/value copy when aliased to external KV cache (follow-up to #29715) - #31150
Skip redundant present_key/value copy when aliased to external KV cache (follow-up to #29715)#31150Ti-Tai Wang (titaiwangms) wants to merge 7 commits into
Conversation
There was a problem hiding this comment.
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 inattention.ccthat skips the present-cache D2D copy whensrcanddstalias, and logs a greppablepresent_copy_skippedtag. - 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. |
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
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 == nullptrearly return inCopyKVToPresentis dead code — all 8 call sites already gate onpresent_key != nullptr/present_value != nullptr. - Pointer equality only detects exact aliasing; a same-size, partially-overlapping
dstwould still reachcudaMemcpyAsyncwith 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.
finallyrestoresset_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).
From tianleiwu's review plus a follow-up internal review pass: - Fix the test's non-aliased-branch assertion, which read io_binding.get_outputs()[1]/[2] (TensorScatter's updated_key_cache/ updated_value_cache due to IOBinding's bind-order semantics, not present_key/present_value), making the D2D-copy-still-runs check pass vacuously. Read from present_k_ort/present_v_ort directly instead. - Replace ORT_ENFORCE with ORT_RETURN_IF_NOT in CopyKVToPresent, which returns Status. - Move the present_copy_skipped log off the process-global default logger onto the session logger via a new KernelSessionLogger helper (OpKernelContext::Logger() isn't reachable from the CUDA EP's shared-provider bridge), and anchor the test's log assertions on session_options.logid + the log line's delimited logger_id field instead of a bare substring match. - Force ORT_DISABLE_FLASH_ATTENTION/ORT_DISABLE_MEMORY_EFFICIENT_ATTENTION off before the flash/efficient cases so an observed MATH fallback in _check_dispatched_tier can only mean "not compiled into this build", not "disabled via env var" — narrowing (though not eliminating) the chance a real dispatch regression is mistaken for a skip. - Trim the Phase 2/3 investigation notes in ComputeInternal to a few lines each, pointing to issue #29714 for the full writeup. - Document a known limitation of KernelSessionLogger: it assumes the EP instance is exclusively owned by one session, which holds for the standard CUDA EP registration path but not for a manually shared IExecutionProvider (as XNNPACK's EP supports). Rebuilt incrementally and reran the full test_onnx_attention suite (TestAttentionPresentKVCopySkip: 8/8; whole file: 121/121). clang-format and ruff clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d745b4ab-b999-4523-b05c-7c4f463a0cab
|
Pushed a follow-up commit (f5ba8ea) addressing Tianlei Wu (@tianleiwu)'s review plus findings from an additional internal review pass: Highest-priority fix (confirmed real bug)
Other findings addressed
Validation: incremental rebuild; |
From tianleiwu's review plus a follow-up internal review pass: - Fix the test's non-aliased-branch assertion, which read io_binding.get_outputs()[1]/[2] (TensorScatter's updated_key_cache/ updated_value_cache due to IOBinding's bind-order semantics, not present_key/present_value), making the D2D-copy-still-runs check pass vacuously. Read from present_k_ort/present_v_ort directly instead. - Replace ORT_ENFORCE with ORT_RETURN_IF_NOT in CopyKVToPresent, which returns Status. - Move the present_copy_skipped log off the process-global default logger onto the session logger via a new KernelSessionLogger helper (OpKernelContext::Logger() isn't reachable from the CUDA EP's shared-provider bridge), and anchor the test's log assertions on session_options.logid + the log line's delimited logger_id field instead of a bare substring match. - Force ORT_DISABLE_FLASH_ATTENTION/ORT_DISABLE_MEMORY_EFFICIENT_ATTENTION off before the flash/efficient cases so an observed MATH fallback in _check_dispatched_tier can only mean "not compiled into this build", not "disabled via env var" — narrowing (though not eliminating) the chance a real dispatch regression is mistaken for a skip. - Trim the Phase 2/3 investigation notes in ComputeInternal to a few lines each, pointing to issue #29714 for the full writeup. - Document a known limitation of KernelSessionLogger: it assumes the EP instance is exclusively owned by one session, which holds for the standard CUDA EP registration path but not for a manually shared IExecutionProvider (as XNNPACK's EP supports). Rebuilt incrementally and reran the full test_onnx_attention suite (TestAttentionPresentKVCopySkip: 8/8; whole file: 121/121). clang-format and ruff clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d745b4ab-b999-4523-b05c-7c4f463a0cab
f5ba8ea to
bdc6871
Compare
…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
From tianleiwu's review plus a follow-up internal review pass: - Fix the test's non-aliased-branch assertion, which read io_binding.get_outputs()[1]/[2] (TensorScatter's updated_key_cache/ updated_value_cache due to IOBinding's bind-order semantics, not present_key/present_value), making the D2D-copy-still-runs check pass vacuously. Read from present_k_ort/present_v_ort directly instead. - Replace ORT_ENFORCE with ORT_RETURN_IF_NOT in CopyKVToPresent, which returns Status. - Move the present_copy_skipped log off the process-global default logger onto the session logger via a new KernelSessionLogger helper (OpKernelContext::Logger() isn't reachable from the CUDA EP's shared-provider bridge), and anchor the test's log assertions on session_options.logid + the log line's delimited logger_id field instead of a bare substring match. - Force ORT_DISABLE_FLASH_ATTENTION/ORT_DISABLE_MEMORY_EFFICIENT_ATTENTION off before the flash/efficient cases so an observed MATH fallback in _check_dispatched_tier can only mean "not compiled into this build", not "disabled via env var" — narrowing (though not eliminating) the chance a real dispatch regression is mistaken for a skip. - Trim the Phase 2/3 investigation notes in ComputeInternal to a few lines each, pointing to issue #29714 for the full writeup. - Document a known limitation of KernelSessionLogger: it assumes the EP instance is exclusively owned by one session, which holds for the standard CUDA EP registration path but not for a manually shared IExecutionProvider (as XNNPACK's EP supports). Rebuilt incrementally and reran the full test_onnx_attention suite (TestAttentionPresentKVCopySkip: 8/8; whole file: 121/121). clang-format and ruff clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d745b4ab-b999-4523-b05c-7c4f463a0cab
bdc6871 to
b75d33f
Compare
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Overall
The exact-alias copy skip is correct at all eight 4-D BNSH call sites, and the latest revision addresses the earlier separate-buffer, error-path, session-attribution, and backend-routing issues. One supported configuration still needs correction: in the CUDA Plugin EP path, the new accessor does not reach the session's logger, so the tag falls back to the plugin-wide logger and the session-scoped observability contract does not hold. I also left a focused test-coverage suggestion for independently proving both the K and V skips.
Verdict: REQUEST CHANGES because the logger path should work consistently in both legacy/shared-provider and CUDA Plugin EP builds.
| // pointer that each RegisterExecutionProvider call overwrites. Closing that gap would require | ||
| // exposing OpKernelContext::Logger() through the shared-library provider bridge. | ||
| inline const logging::Logger& KernelSessionLogger(const OpKernelInfo& info) { | ||
| const logging::Logger* logger = info.GetExecutionProvider()->GetLogger(); |
There was a problem hiding this comment.
This does not retrieve the session logger in a CUDA Plugin EP build. In that path, the adapter's OpKernelInfo::GetExecutionProvider() returns KernelInfoCache::ep_impl_ (include/onnxruntime/ep/adapter/op_kernel_info.h:51,114), which is the internal CUDA shim created by CreateCudaPluginProvider. The session OrtLogger is instead stored on CudaEp::logger_ (cuda_ep.cc:120-127), and no SetLogger call copies it onto the shim. Consequently GetLogger() is null here and the fallback is the plugin-wide default logger: SessionOptions.log_severity_level = VERBOSE will not enable this tag, and the session logid will not identify it, so the new aliased tests fail if run against the plugin EP and users cannot control the message per session.
Please use the plugin's KernelInfo_GetLogger/KernelContext_GetLogger path (the adapter exposes the former through Info().GetKernelInfo().GetLogger()), while retaining an appropriate legacy/shared-provider path, or add one common accessor that returns the actual session logger in both build modes.
There was a problem hiding this comment.
Fixed in d296155. CUDA Plugin EP builds now retrieve the session logger through KernelInfo_GetLogger rather than the internal CUDA shim. Retrieval failures suppress only this diagnostic, and emission uses ORT_CXX_LOGF_NOEXCEPT; legacy/shared-provider builds retain the EP session-logger path.
| ) | ||
|
|
||
| self._check_dispatched_tier(name, expected_kernel, sdpa_kernel, is_supported) | ||
| self.assertRegex( |
There was a problem hiding this comment.
Each run calls CopyKVToPresent twice (K and V), but assertRegex passes after only one matching tag. If a later change restores the unconditional self-copy at just the value call site, the key call still emits this tag and every numeric assertion remains green because a self-copy is value-preserving. That leaves half of the eight claimed backend/copy call sites unproven. Please count the session-anchored matches and require exactly two, or label the helper calls as key/value and assert that both labels appear.
There was a problem hiding this comment.
Fixed in d296155. The aliased test now counts session-logid-anchored matches and requires exactly two records (K and V); the non-aliased test explicitly requires zero.
Use the kernel-info logger in CUDA plugin builds without allowing diagnostic logging failures to affect inference. Strengthen the copy-skip tests to require both key and value log records. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The hot path adds uncached logging API overhead, while backend tests can skip genuine routing regressions.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
onnxruntime/core/providers/cuda/llm/attention.cc:1766
- This NOTE narrates the abandoned prototype and profiling history, which repository guidance assigns to the issue or PR (
AGENTS.md:57-61). Retain the durable decode-only rationale without embedding the experiment record in the dispatch implementation.
// NOTE (Phase 3 — cuDNN SDPA prefill via fixed-size query-row chunking — investigated and
// abandoned): A chunked cuDNN prefill dispatch (fixed-size, left-padded query-row chunks, needed
// to keep cuDNN's graph-plan cache key stable) was prototyped and found correct, but measured only
// ~0.94x-1.15x versus the existing MATH fallback on A100, and the auto-enable gate only fires on
// SM>=90 hardware unavailable for this investigation — not worth the complexity. See issue #29714
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Balanced
Cache plugin logging state outside the decode hot path, make backend routing checks distinguish unavailable kernels from regressions, and retain only durable dispatch rationale in source comments.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Follow-up to #29715 (cuDNN SDPA decode tier for the ONNX standard
AttentionCUDA 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/Vcache into
present_key/present_value, even when the caller IOBindspresent_key/present_valueto the SAME device buffer as the K/V cache input — the documented
TensorScatter+ IOBindingproduction pattern (mirroring
TensorScatter's own.MayInplace(0, 0)self-copy skip andGroupQueryAttention'spast_key==present_keyaliasing). This contradicted the file's ownPERFORMANCE NOTE and was technically undefined behavior (
cudaMemcpyAsyncrequires non-overlappingsrc/dst; a full self-copy is maximal overlap).
What changed
llm_attention_detail::CopyKVToPresent(src, dst, stream), a shared helper that skips theD2D copy via a pointer-equality check when
present_*aliases the K/V cache buffer, with agreppable VERBOSE log tag (
present_copy_skipped) for test observability. Applied at all 8 callsites (K+V × 4 backends), only in the 4-D BNSH branches (3-D BSNH always needs a layout-changing
transpose and can never alias).
ORT_ENFORCEinside the helper: proven safe today (presentshape only equals K/V's shape when
past_sequence_length == 0), but guards against a futurecaller 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
test_onnx_attentionsuite: 342/342 passed (334 pre-existing + 8 new).-v; confirmed via dispatch-log scraping thatflash/efficient/cudnn/math parameterizations each hit their intended backend on an A100 (SM80),
not a MATH fallback.
lintrunnerclean.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.