Skip to content

[All] Refactor fused attention APIs with cuDNN-frontend support checks and opaque config/params handles - #2964

Open
cyanguwa wants to merge 95 commits into
NVIDIA:mainfrom
cyanguwa:fe_check_support
Open

[All] Refactor fused attention APIs with cuDNN-frontend support checks and opaque config/params handles#2964
cyanguwa wants to merge 95 commits into
NVIDIA:mainfrom
cyanguwa:fe_check_support

Conversation

@cyanguwa

@cyanguwa cyanguwa commented May 6, 2026

Copy link
Copy Markdown
Collaborator

Description

TE currently hand-maintains the fused-attention backend-selection logic in nvte_get_fused_attn_backend, duplicating cuDNN's support rules. This list drifts out of sync as cuDNN evolves, and the support check can disagree with what actually runs.

This PR replaces that logic with cuDNN-frontend's production-grade support checks. The new nvte_get_fused_attn_backend_v2 builds the same graph cuDNN executes at runtime, so the probe and execution can no longer diverge. It caches the graph on success and returns a diagnostic message on failure, giving users actionable guidance (e.g. adjust the config, GPU architecture, or cuDNN version).

This PR also reworks nvte_fused_attn_fwd / nvte_fused_attn_bwd into nvte_fused_attn_fwd_v2 / nvte_fused_attn_bwd_v2, which take opaque, attribute-based config/params handles instead of long flat argument lists — improving TE's API and ABI stability.

Legacy APIs are retained as deprecated shims that route through the v2 APIs, so existing callers keep working.

Type of change

  • Documentation change (change only to the documentation, either a fix or a new content)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Infra/Build change
  • Code refactoring

Changes

API rework (opaque config/params + v2 entry points)

  • Opaque config/params handles (common/fused_attn/config_and_params.{h,cpp}, common/include/transformer_engine/fused_attn.h): new NVTEFusedAttnConfig / NVTEFusedAttnFwdParams / NVTEFusedAttnBwdParams with create/destroy/get/set attribute accessors, for better API/ABI stability. The cache key, probe, and execution now all originate from one place via make_config / derive / make_cache_key.
  • v2 APIs (common/fused_attn/fused_attn*.{cpp,cu}): nvte_get_fused_attn_backend_v2, nvte_fused_attn_fwd_v2, and nvte_fused_attn_bwd_v2. The F16 and FP8 is_supported_* probes copy the config, set direction, derive(), and attempt a null-pointer graph build via check_support — i.e. the same graph cuDNN builds at runtime, so probe and execution can't diverge.
  • Deprecated shims: legacy nvte_get_fused_attn_backend / nvte_fused_attn_fwd / nvte_fused_attn_bwd are retained, routed through the v2 APIs.
  • Bindings updated to v2: PyTorch (csrc/extensions/attention.cpp) and JAX (jax/csrc/extensions/attention.cpp).

Correctness & backend selection

  • Process-wide graph cache: cache is now process-wide (was thread-local) and guarded by a mutex, so a compiled graph is reused across threads instead of rebuilt per thread (still thread-safe).
  • Bias-shape handling fix: applied consistently across common, PyTorch, and JAX.
  • Per-step CP config checks: cp_per_step_configs probes each context-parallel step instead of only the global, non-CP config.
  • log2(0) guard: avoids UB when casting -inf to size_t in get_max_batch_size / get_max_tokens.

Diagnostics

  • NVTE_DEBUG / NVTE_DEBUG_LEVEL for JAX (parity with PyTorch): level 1 reports the selected backend; level 2 adds a diagnostic message explaining why fused attention was rejected.
  • Fused attention graph cache debug NVTE_FUSED_ATTN_CACHE_DEBUG: opt-in instrumentation that reports cuDNN graph build-vs-execution counts and per-stage cudnn-frontend build timings, so cache hit/miss/build/exec behaviors and graph build time can be inspected. Off by default; available for both PyTorch and Jax.

Cleanup / removals

  • Removed NVTE_FUSED_ATTN_BACKEND — the two remaining backends (F16, FP8) are mutually exclusive now that max512 is gone.
  • Removed dead Q_ID/.../MASK_VAL_ID macros (used only by the max512 backend).
  • Removed dead cudnn_frontend::xxx utility functions (used only by fp8_impl_v0 and max512).
  • Unified include-guard names across fused_attn/ headers.

Tests

  • Enabled previously skipped tests: padding + post_scale_bias in both PyTorch and Jax, D256 bprop in PyTorch, and SWA + dropout/post_scale_bias in Jax.
  • Curated the L0 sweeps to keep CI time in check: deduplicated PyTorch tests, and tiered the newly enabled JAX tests across L0/L1/L2.

Checklist:

  • I have read and followed the contributing guidelines
  • The functionality is complete
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

cyanguwa and others added 4 commits May 5, 2026 18:55
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
@cyanguwa cyanguwa changed the title [Common] Refactor nvte_get_fused_attn_backend with cudnn-frontend calls [All] Refactor nvte_get_fused_attn_backend with cudnn-frontend calls May 8, 2026
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
@cyanguwa
cyanguwa marked this pull request as ready for review May 8, 2026 00:10
@greptile-apps

greptile-apps Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR refactors the fused attention APIs across PyTorch and JAX backends to introduce opaque config/params handles (NVTEFusedAttnConfig, NVTEFusedAttnFwdParams, NVTEFusedAttnBwdParams), a new process-wide cuDNN-frontend graph cache keyed on FusedAttnConfig, and v2 API entry points (nvte_get_fused_attn_backend_v2, nvte_fused_attn_fwd_v2, nvte_fused_attn_bwd_v2) while keeping deprecated shims for backward compatibility.

  • New graph cache (graph_cache.h): mutex-protected, concurrent-build capable, with lazy kernel compilation (std::call_once) and a negative cache for permanently unsupported configs; device_id is correctly included in the cache key.
  • FusedAttnConfig (config_and_params.h/cpp): derive() computes bucketed sizes and format fields; make_cache_key() normalizes attn_scale to 1.0f (bound as a runtime tensor at execution), retains dropout_probability since it structurally changes the cuDNN graph topology.
  • Bug fixes included: log2(0) UB guards in utils.cu, input_Bias/input_SoftmaxOffset initialized to nullptr, deprecated wrapper seeds batch_size=1 to avoid a prior zero-batch issue.

Confidence Score: 3/5

  • The refactor is architecturally solid and fixes several pre-existing bugs, but two issues should be addressed before merging: transient errors being permanently cached as unsupported graphs, and #include &lt;cudnn.h&gt; leaking into the public header.
  • The core design — opaque handles, device_id in cache key, attn_scale normalized at cache level but bound at runtime, separate fwd/bwd static caches — is sound and well-executed. However, validate_and_check_support() catching std::bad_alloc and transient CUDA errors as permanent UnsupportedGraph entries is a correctness bug that can silently degrade to slower kernels after any OOM spike. Additionally, exposing #include &lt;cudnn.h&gt; in the public fused_attn.h header breaks ABI cleanliness for consumers that don't depend on cuDNN directly. Both issues have straightforward fixes.
  • transformer_engine/common/fused_attn/graph_cache.h (transient-error caching) and transformer_engine/common/include/transformer_engine/fused_attn.h (#include &lt;cudnn.h&gt; in public header).

Important Files Changed

Filename Overview
transformer_engine/common/fused_attn/config_and_params.cpp 1174-line implementation of FusedAttnConfig::derive(), make_cache_key(), and full attribute get/set for opaque handles; attn_scale normalized to 1.0f in cache key, dropout retained, MXFP8 V-shape override in make_config() are all correctly handled.
transformer_engine/common/fused_attn/fused_attn.cpp Heavily refactored to introduce v2 APIs (nvte_get_fused_attn_backend_v2, nvte_fused_attn_fwd_v2/bwd_v2); legacy shims seed batch_size=1 to avoid previous zero-batch issue; thread-local message buffer correctly owned.
transformer_engine/common/fused_attn/graph_cache.h New process-wide cuDNN graph cache with mutex-protected supported/unsupported maps, lazy plan compilation via std::call_once, and concurrent build support; critical issue: validate_and_check_support() catches ALL exceptions (including OOM/transient CUDA errors) as UnsupportedGraph, permanently caching transient failures.
transformer_engine/common/include/transformer_engine/fused_attn.h Public header now adds #include <cudnn.h>, new opaque handle typedefs, full attribute enums, and v2 API declarations; the cudnn.h inclusion breaks downstream consumers that don't link against cuDNN.
transformer_engine/jax/cpp_extensions/attention.py FusedAttnHelper always passes JAXX_Scaling_Mode.NO_SCALING regardless of actual FP8 recipe, so the backend capability check doesn't exercise the FP8 code path — a pre-existing limitation that this PR does not address.
transformer_engine/common/fused_attn/fused_attn_f16_arbitrary_seqlen.cu is_supported_f16_fwd/bwd() now probe the graph cache via the new API; attn_scale bound as runtime tensor (not graph constant) confirming make_cache_key() normalization to 1.0f is correct; dropout structurally changes graph topology justifying its presence in the cache key.

Sequence Diagram

sequenceDiagram
    participant C as Caller (PyTorch/JAX)
    participant API as fused_attn.cpp
    participant Cfg as FusedAttnConfig
    participant Cache as GraphCache
    participant CDNN as cuDNN Frontend

    Note over C,CDNN: Backend Query Phase
    C->>API: nvte_get_fused_attn_backend_v2(config_handle)
    API->>Cfg: FusedAttnConfig::derive()
    API->>Cache: get_or_build_cached_graph(fwd_key)
    Cache->>CDNN: validate_and_check_support()
    CDNN-->>Cache: GraphAndTensors or UnsupportedGraph
    Cache-->>API: "CachedGraph* or nullptr"
    API->>Cache: get_or_build_cached_graph(bwd_key) [training only]
    Cache-->>API: "CachedGraph* or nullptr"
    API-->>C: "NVTE_FUSED_ATTN_* backend + message"

    Note over C,CDNN: Forward Execution Phase
    C->>API: nvte_fused_attn_fwd_v2(params_handle)
    API->>Cfg: FusedAttnFwdParams::make_config()
    API->>Cfg: FusedAttnConfig::derive()
    API->>Cfg: FusedAttnConfig::make_cache_key()
    API->>Cache: get_or_build_cached_graph()
    Cache-->>API: "CachedGraph* (cache hit)"
    API->>CDNN: ensure_plans_built() [lazy compile]
    API->>CDNN: execute(variant_pack)
    CDNN-->>C: output tensors

    Note over C,CDNN: Backward Execution Phase
    C->>API: nvte_fused_attn_bwd_v2(params_handle)
    API->>Cfg: FusedAttnBwdParams::make_config()
    API->>Cfg: FusedAttnConfig::derive()
    API->>Cache: get_or_build_cached_graph()
    Cache-->>API: "CachedGraph* (cache hit)"
    API->>CDNN: ensure_plans_built()
    API->>CDNN: execute(variant_pack)
    CDNN-->>C: grad tensors
Loading

Reviews (41): Last reviewed commit: "address review comments" | Re-trigger Greptile

Comment thread transformer_engine/common/fused_attn/fused_attn_fp8.cu Outdated
Comment thread transformer_engine/common/fused_attn/fused_attn_fp8.cu Outdated
Comment thread transformer_engine/common/fused_attn/fused_attn.cpp Outdated
Comment thread transformer_engine/common/fused_attn/fused_attn.cpp
Comment thread transformer_engine/common/include/transformer_engine/fused_attn.h Outdated
cyanguwa and others added 2 commits May 7, 2026 17:22
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Comment thread transformer_engine/common/fused_attn/fused_attn.cpp Outdated
cyanguwa and others added 3 commits May 7, 2026 18:30
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
@cyanguwa

cyanguwa commented May 8, 2026

Copy link
Copy Markdown
Collaborator Author

/te-ci L1

Comment thread transformer_engine/common/fused_attn/fused_attn_f16_arbitrary_seqlen.cu Outdated
Comment thread transformer_engine/common/fused_attn/fused_attn_f16_arbitrary_seqlen.cu Outdated
Comment thread transformer_engine/common/fused_attn/fused_attn.cpp
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Comment thread transformer_engine/common/fused_attn/fused_attn_f16_arbitrary_seqlen.cu Outdated
Comment thread transformer_engine/common/fused_attn/fused_attn_f16_arbitrary_seqlen.cu Outdated
cyanguwa and others added 3 commits May 7, 2026 22:28
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Comment thread transformer_engine/common/fused_attn/fused_attn.cpp Outdated
Comment thread transformer_engine/jax/cpp_extensions/attention.py Outdated
cyanguwa and others added 2 commits May 8, 2026 12:19
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Comment thread transformer_engine/common/fused_attn/fused_attn.cpp Outdated
cyanguwa and others added 5 commits July 28, 2026 13:28
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
@cyanguwa

Copy link
Copy Markdown
Collaborator Author

/te-ci L1

t_q,
num_tokens_kv * cp_size * s_kv // max_seqlen_kv if max_seqlen_kv else 0,
)
for s_kv in dict.fromkeys([s_kv_chunk, max_seqlen_kv])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this varies for every rank, would it make a difference?

@KshitijLakhani

KshitijLakhani commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator
  • The addition of NVTE_DEBUG / NVTE_DEBUG_LEVEL for JAX (parity with PyTorch) bridges a long time gap. Thank you !

  • I am very curious if you've run any experiments with NVTE_FUSED_ATTN_CACHE_DEBUG - what sort of numbers/metrics have you seen ?

    • For e.g. with the new API, when we query backend support, we try to build a graph which in theory seems more expensive as compared to the C++ decision tree query we had in the older API, however, we'd expect the first fused attn execution call made to have a cache hit and hence in theory be faster than the older API.
      I'd expect fused attn backend supported cases (where a graph has to be built anyways) to get faster, but the unsupported cases to get slower. Would be interesting to know by how much though.

    • Another interesting case would be if we have THD segments with a very small standard deviation packed together thereby resulting in them being in the same "bucket" hence triggering a cache hit ad no new graph creation. Comparing this to a larger deviation in the THD packed segment sizes which would trigger repetitive graph creation.

    • It seems like in the instrumentation code there's no way for the user to know if the defensive call for getting the backend in the fwd pass fused attn in the execution phase of the model has a cache miss resulting in creating a new graph and new cache entry thereby losing any benefits of the graph created in the query phase. It could be useful information in the future, so that if the user is doing something incorrectly (inadvertently), maybe changing/passing any tensor params between querying and fused attn execution at least we can inform them via debug logging as I'd image that penalty won't be inexpensive

    • Do we log the device ID when NVTE_FUSED_ATTN_CACHE_DEBUG is enabled ? Maybe I missed i but if not, maybe we should so that the user knows which device the debug info corresponds to ?

@cyanguwa cyanguwa added 2.19 and removed 2.18 labels Jul 29, 2026
"<b>Note</b>\n",
" \n",
"Environment variables <code>NVTE_FLASH_ATTN</code>, <code>NVTE_UNFUSED_ATTN</code>, <code>NVTE_FUSED_ATTN_BACKEND</code>, and <code>NVTE_FUSED_ATTN_USE_FAv2_BWD</code> are supported in PyTorch. <code>NVTE_FUSED_ATTN</code> and <code>NVTE_ALLOW_NONDETERMINISTIC_ALGO</code> are supported in both PyTorch and JAX.\n",
"Environment variables <code>NVTE_FLASH_ATTN</code>, <code>NVTE_UNFUSED_ATTN</code>, and <code>NVTE_FUSED_ATTN_USE_FAv2_BWD</code> are supported in PyTorch. <code>NVTE_FUSED_ATTN</code> and <code>NVTE_ALLOW_NONDETERMINISTIC_ALGO</code> are supported in both PyTorch and JAX.\n",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No NVTE_FUSED_ATTN_USE_FAv2_BWD support in JAX ?

Comment on lines +134 to +135
// Restrict each direction's key to the fields its graph actually consumes, so
// no redundant graphs are built and no cache misses either

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

That's a good change to avoid redundant graphs if fwd only

" bytes)");
NVTE_CHECK(buf != nullptr, "Invalid buffer (got NULL)");

auto &cfg = *get_fused_attn_config_mutable(config);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: for the setter and getter (nvte_set_fused_attn_config_attribute and nvte_get_fused_attn_config_attribute) - do you think in the future it might make sense to make this a bulk API in which one can request to set/get multiple attributes in in one call? Which would then reduce multiple calls to get_fused_attn_config_mutable() and get_fused_attn_config ?

int64_t window_size_right, bool return_max_logit, bool cuda_graph, bool deterministic) {
namespace {

// The per-thread storage for the diagnostic string; it's re-used (cleared + re-populated)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit:

Suggested change
// The per-thread storage for the diagnostic string; it's re-used (cleared + re-populated)
// The per-thread storage for the diagnostic string; it is re-used (cleared + re-populated)

// Only used when THD format is requested.
cudnnHandle_t handle = cudnnExecutionPlanManager::Instance().GetHandle();
const NVTE_QKV_Format qkv_format = nvte_get_qkv_format(cfg.qkv_layout);
const NVTE_QKV_Layout_Group layout_group = nvte_get_qkv_layout_group(cfg.qkv_layout);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: Although having the explicit type like NVTE_QKV_Format and NVTE_QKV_Layout_Group is almost always the better option, consider using auto instead ? Especially since the var names are descriptive enough to understand the data type ?
It could help reduce code verbosity

cache_hit = (it != cache.end());
if (cache_hit) cached_graph = it->second;
}
graph_cache_debug::record_cache_lookup("fwd", cache_hit, cfg);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think it makes sense to have the recording outside the mutex's scope so that the mutex is not held when performing (slow) I/o ops, however, this would mean that the recorded logs for the cache ops may not reflect exact wall clock ordering. I think it is vital to mention this in the docs/code if not already so that the users are aware of this

bool is_ragged_q = (q_format == NVTE_QKV_Format::NVTE_THD);
bool is_ragged_kv = (kv_format == NVTE_QKV_Format::NVTE_THD);
bool is_ragged_q = cfg.is_ragged_q;
bool is_ragged_kv = cfg.is_ragged_kv;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

const ?

Comment on lines +654 to +655
bool is_causal_bottom_right = cfg.is_causal_bottom_right;
bool is_padding = cfg.is_padding;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

const ?

graph_cfg.derive();

size_t workspace_size = 0;
try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for adding the try catch in here. I was hoping for it while reviewing the code for fused_attn_arbitrary_seqlen_fwd_impl()

static thread_local CacheType sdpa_f16_bprop_cache;
using CacheType = std::map<FusedAttnConfig, graph_and_tensors>;
static CacheType sdpa_f16_bprop_cache;
static std::mutex sdpa_f16_bprop_cache_mutex;

@KshitijLakhani KshitijLakhani Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

maybe nit and overthinking on my part but would it better to reverse the order of creation ?
Because the order of destruction will be reverse of creation, we'd like to first destroy the resource (cache) and then the mutex guarding it, right ?

It may also be beneficial (to make it mistake proof) if we tie these together in a struct with the suggested new ordering above so that if ever anyone else touches the cache and mutex code in the future they do not need to worry about the individual object ordering (destroying the struct object is all they'd care about and we can take care of the reordering in the struct object)

@cyanguwa

cyanguwa commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Note to self: integrate these changes to this PR, thanks to @sudhakarsingh27.
https://github.com/cyanguwa/TransformerEngine/pull/5/changes

cc #3092

PR 2964 addresses the following points from the above PR:
Mixed THD incorrectly passes the C++ OR condition
Unsupported SM80 + old cuDNN reaches runtime and crashes
Clear fallback/debug reason

These points aren't fully addressed in 2964:
Enable valid SM80 THD execution with cuDNN 9.18.1+
Correct SM8x Stats/LSE/Max shapes
Mixed-layout Python prefilters

@KshitijLakhani
KshitijLakhani self-requested a review August 1, 2026 00:17
void* devActualSeqlenKV = static_cast<int8_t*>(devActualSeqlenQ) + b * sizeof(int32_t);
cu_seqlens_to_actual_seqlens<<<grid, nthreads_per_block, 0, stream>>>(
b, b, static_cast<const int32_t*>(devPtrcuSeqlensQ), // TODO(pass max_b)
b, b, static_cast<const int32_t*>(devPtrcuSeqlensQ), // TODO(pass bucketed_batch_size)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this TODO for the future ? if yes,

Suggested change
b, b, static_cast<const int32_t*>(devPtrcuSeqlensQ), // TODO(pass bucketed_batch_size)
b, b, static_cast<const int32_t*>(devPtrcuSeqlensQ), // TODO(<GH username>): pass bucketed_batch_size

Comment on lines 552 to 559
bool is_bias = (bias_type == NVTE_Bias_Type::NVTE_POST_SCALE_BIAS);
bool is_alibi = (bias_type == NVTE_Bias_Type::NVTE_ALIBI);
bool is_causal = ((mask_type == NVTE_Mask_Type::NVTE_CAUSAL_MASK) ||
(mask_type == NVTE_Mask_Type::NVTE_PADDING_CAUSAL_MASK));
bool is_padding = ((mask_type == NVTE_Mask_Type::NVTE_PADDING_MASK) ||
(mask_type == NVTE_Mask_Type::NVTE_PADDING_CAUSAL_MASK));
bool is_causal_bottom_right = cfg.is_causal_bottom_right;
bool is_padding = cfg.is_padding;
bool is_dropout = (dropout_probability != 0.0f);
bool is_softmax_offset = (softmax_type != NVTE_Softmax_Type::NVTE_VANILLA_SOFTMAX);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

const ?

Comment on lines 562 to 569

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

outide this PR's scope but if possible: const ?

Comment on lines 739 to 745

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

const ?

graph_cache_debug::record_build("bwd");
// Lock the insert. If another thread inserted a graph for the same key while we were building,
// use their graph (it's the same as ours) and discard our graph.
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe we already have this, but if not, it might be useful to add a cache specific test especially since that's a pretty imp component of graphing in TE attention.

Add a single-thread test that queries one config twice, then executes fused attention with matching params.

Assert the first query causes one miss/build, while the second query and execution are hits with no additional build. Maybe then modify one graph-defining field and assert exactly one new miss/build—this directly catches broken key normalization and unintended recompilation.

try {
fused_attn::fused_attn_fp8_fwd_impl(
graph_cfg,
/*devPtrQ=*/nullptr, /*devPtrK=*/nullptr, /*devPtrV=*/nullptr,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks for adding the args as comments here


// More readable, shorter thread IDs (0, 1, 2, ...).
inline unsigned thread_seq_id() {
static std::atomic<unsigned> next{0};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This got me thinking about logging device id a bit more

IIUC, the cache key includes device ID, but the debug events omit it right ?.
Could we log descriptor.device_id and pass the normalized descriptor to the recorder? This would make multi-GPU cache behavior diagnosable.

} // namespace fused_attn
} // namespace transformer_engine

#endif // TRANSFORMER_ENGINE_COMMON_FUSED_ATTN_GRAPH_CACHE_DEBUG_H_

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If it is not too much effort, would be nice to see an example of what this logs looks like for the different diagnostics enabled. This is a good to have only so feel free to skip over

template <typename T>
FusedAttnFwdParamsWrapper &set_attr(NVTEFusedAttnFwdParamsAttribute attr, T val) noexcept {
nvte_set_fused_attn_fwd_params_attribute(params_, attr, &val, sizeof(val));
return *this;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Chaining is a good addition to this 👍

Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
@cyanguwa

cyanguwa commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

/te-ci L0 L1 L2 L3

Comment on lines +229 to +241
namespace {

// The per-thread storage for the diagnostic string; it's re-used (cleared + re-populated)
// on every call to nvte_get_fused_attn_backend_v2 on the same thread.
thread_local std::string fused_attn_backend_message_buffer;

// Stash `reason` in the thread-local buffer and, if the caller asked for a diagnostic,
// publish a NUL-terminated pointer to it via `*message`. Safe to call with `message == nullptr`.
void set_message(const char **message, std::string reason) {
if (message == nullptr) return;
fused_attn_backend_message_buffer = std::move(reason);
*message = fused_attn_backend_message_buffer.c_str();
}

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.

P1 Returned message pointer is invalidated by any subsequent same-thread backend call

*message is set to .c_str() of a thread_local std::string. Any call to nvte_get_fused_attn_backend_v2 on the same thread (including the internal calls from nvte_fused_attn_fwd_v2 and nvte_fused_attn_bwd_v2) will std::move() a new string into fused_attn_backend_message_buffer, destroying the previous string object and making the pointer dangle. The internal probe calls currently pass nullptr so the buffer isn't clobbered by them, but any caller that stores the returned const char* and then makes a subsequent backend call will read freed memory. The API contract (e.g. "copy this string before calling anything else") should be clearly documented, or the v2 signature should return std::string instead of const char**.

Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
…run, still build plans in probes

Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
…be, dry-run, still build plans in probes"

This reverts commit 8fdd81d.

Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
…ad and not modify cfg

Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
Signed-off-by: Charlene Yang <8636796+cyanguwa@users.noreply.github.com>
@cyanguwa

Copy link
Copy Markdown
Collaborator Author

/te-ci L0 L1 L2 L3

Comment on lines +1 to +251
/*************************************************************************
* Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
*
* See LICENSE for license information.
************************************************************************/

// ============================================================================
// The fused-attention graph cache: what a cache entry is, how one is looked up
// or built, and the frontend calls that make a constructed graph usable.
//
// Each of the four build sites (f16 and fp8, forward and backward) differs only
// in how it constructs its graph and which tensors it hands back. Everything
// after that -- the lookup, the locking, the once-per-entry plan build, the
// support check, and the remembering of what cuDNN refused -- is the same at all
// four, and lives here so it has one definition rather than four copies to keep
// in step.
//
// This header is deliberately not part of utils.h: it needs the cuDNN frontend,
// and utils.h is included by translation units (utils.cu) that otherwise do not.
// ============================================================================

#ifndef TRANSFORMER_ENGINE_COMMON_FUSED_ATTN_GRAPH_CACHE_H_
#define TRANSFORMER_ENGINE_COMMON_FUSED_ATTN_GRAPH_CACHE_H_

#include <exception>
#include <map>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <tuple>
#include <utility>

#include "../common.h"
#include "../cudnn_utils.h"
#include "config_and_params.h"
#include "graph_cache_debug.h"

namespace transformer_engine {
namespace fused_attn {

// cuDNN's refusal to run a graph, as opposed to a failure to try. The distinction is what makes
// the negative cache in get_or_build_cached_graph() safe: a refusal is a verdict on the
// configuration and reproducible for a given key, so it can be remembered and replayed, whereas
// a failure that came from the machine's state at that moment (an allocation that did not fit, a
// CUDA error left behind by unrelated work) could well succeed on the next attempt and must not
// be turned into a permanent answer. Only the four adjudicating frontend calls in
// validate_and_check_support() raise this; every other failure keeps its ordinary type and is
// re-attempted the next time the key comes around.
struct UnsupportedGraph : public std::runtime_error {
explicit UnsupportedGraph(const std::string &reason) : std::runtime_error(reason) {}
};

// The reason string an is_supported_* helper reports for `e`: its message, or `fallback` if it
// has none. Those helpers signal support by returning the empty string, so a refusal that
// arrives without an explanation would be read as an endorsement and the caller would go on to
// run a graph cuDNN has just declined. Nothing raised through NVTE_ERROR can be empty, since it
// prefixes file and line, but that is a property of our macros rather than of every exception
// that can reach a catch clause, and it is not what the contract should rest on.
inline std::string refusal_reason(const std::exception &e, const char *fallback) {
const char *what = e.what();
return (what != nullptr && what[0] != '\0') ? std::string(what) : std::string(fallback);
}

// A graph in the cache, plus the tensor attributes needed to bind runtime pointers to it.
//
// Entries are built only as far as check_support(), which is all it takes to decide whether
// a configuration is supported. build_plans() is the kernel-compilation step and the most
// expensive of the five frontend calls, so a support query stops short of it: the query never
// executes the graph, and many of the keys it builds are never executed by anything. The
// execution path finishes the build instead, the first time the graph is needed to run.
//
// plans_built guards that completion. It has to happen exactly once per entry, because the
// cached graph is shared across threads and build_plans() mutates it in place -- two threads
// reaching the same unfinished entry must not both build it. Keeping the flag inside the entry
// keeps it from drifting away from the graph it describes, and leaves unrelated keys free to
// build concurrently. A build that throws leaves the flag unset, so a later call retries
// rather than executing a graph with no plans.
template <typename GraphAndTensors>
struct CachedGraph {
explicit CachedGraph(GraphAndTensors tensors) : tensors(std::move(tensors)) {}

GraphAndTensors tensors;
std::once_flag plans_built;
};

// One build site's cache. Process-wide rather than per-thread so that a graph is reused
// across threads instead of rebuilt by each: cuDNN >= 9.0 allows concurrent execution of a
// shared plan, and cudnn-frontend >= 1.25.0 has a thread-safe execute().
//
// Refusals are cached alongside the graphs, under the same keys and the same lock. A support
// query for an unsupported configuration is otherwise the most expensive thing this cache sees:
// it builds the whole graph, spends the four frontend calls, and throws the result away, and it
// does so again on every query, because a rejection left nothing behind to find. `unsupported`
// is what it leaves behind -- cuDNN's own account of the refusal, which is the entire useful
// output of a failed query, so nothing is lost by answering from it. Reasons are short strings
// and there is one per refused key, so this grows far slower than the graphs beside it.
// Holding the lock and the maps together is also what fixes their relative lifetimes. Members
// are destroyed in reverse declaration order, so the mutex is declared first to be destroyed
// last: the maps go while their guard is still valid, rather than the other way round. Declaring
// a cache and its lock as two separate objects leaves that ordering to whoever writes the next
// one; declaring them here settles it once.
template <typename GraphAndTensors>
struct GraphCache {
std::mutex mutex; // guards both maps below
std::map<FusedAttnConfig, std::shared_ptr<CachedGraph<GraphAndTensors>>> supported;
std::map<FusedAttnConfig, std::string> unsupported;
};

// Takes a constructed graph through the frontend calls that decide whether cuDNN can run it:
// validate, build_operation_graph, create_execution_plans, check_support. The sequence is
// identical for both passes and both backends, so it is defined once here; `pass` only selects
// which set of stage timers the calls are attributed to.
//
// Support is reported by throwing rather than by a return value. NVTE_CHECK_CUDNN_FE raises
// an exception carrying cuDNN's own explanation of the rejection, and that text is what the
// is_supported_* helpers return as the reason a backend was refused -- so a bool here would
// discard the one thing a support probe exists to produce. Callers that are about to execute
// the graph want the throw as well, since there is nothing useful to do with an unsupported
// graph but fail.
//
// The throw is re-raised as UnsupportedGraph, which is what marks it cacheable. These four calls
// are cuDNN adjudicating a graph it has been handed, so a failure among them is a statement about
// the graph rather than about the moment -- which is the property the negative cache needs, and
// the reason the boundary is drawn here rather than around a wider region. build_plans() and
// execute() sit outside it: they commit real resources and can fail for reasons that have nothing
// to do with the configuration.
//
// build_plans() is left out for a second reason as well: it belongs to whoever executes the graph,
// once, the first time it is needed. See CachedGraph.
inline void validate_and_check_support(const char *pass, cudnn_frontend::graph::Graph &graph,
cudnnHandle_t handle) {
try {
graph_cache_debug::timer(pass, graph_cache_debug::BuildStage::Validate,
[&] { NVTE_CHECK_CUDNN_FE(graph.validate()); });
graph_cache_debug::timer(pass, graph_cache_debug::BuildStage::BuildOpGraph,
[&] { NVTE_CHECK_CUDNN_FE(graph.build_operation_graph(handle)); });
graph_cache_debug::timer(pass, graph_cache_debug::BuildStage::CreatePlans, [&] {
NVTE_CHECK_CUDNN_FE(graph.create_execution_plans({cudnn_frontend::HeurMode_t::A}));
});
graph_cache_debug::timer(pass, graph_cache_debug::BuildStage::CheckSupport,
[&] { NVTE_CHECK_CUDNN_FE(graph.check_support()); });
} catch (const std::exception &e) {
throw UnsupportedGraph(e.what());
}
}

// The cached entry for `key`, building and inserting it via `build` if absent. Throws
// UnsupportedGraph if cuDNN refuses the graph -- this time or on an earlier call, the two being
// indistinguishable to the caller by design.
//
// `build` only constructs a graph; this is what puts it through validate_and_check_support(), so
// the entries in the cache are exactly the graphs cuDNN has agreed to run. Those four calls sit
// on the miss path because they are part of building an entry rather than reading one: repeating
// them on a hit would redo the operation graph and the plan search for a graph that has already
// been through both.
//
// `key` must be a normalized key -- FusedAttnConfig::make_cache_key()'s output -- and not a
// raw execution config. Two configs that differ only in a field no graph reads (attn_scale,
// say) have to reach the same entry, which is what normalization is for; passing the raw
// config instead silently multiplies the cache by fields the graph never consumes.
//
// Only the map operations are locked, not `build`. A graph build is the expensive part and
// holding the lock across it would serialize builds of unrelated keys, so two threads racing
// on the same key may both build. That is a wasted build, not a correctness problem: the
// loser drops its own graph and takes the winner's, so every caller of a given key gets one
// shared entry and the once-flag inside it still governs the plan build. The wasted build is
// visible in diagnostics as a BUILD with no matching MISS of its own. The same race on a
// refused key is equally harmless, both threads storing the same reason.
template <typename GraphAndTensors, typename BuildFn>
std::shared_ptr<CachedGraph<GraphAndTensors>> get_or_build_cached_graph(
GraphCache<GraphAndTensors> &cache, const FusedAttnConfig &key, const char *pass,
cudnnHandle_t handle, BuildFn &&build) {
using Entry = CachedGraph<GraphAndTensors>;

std::shared_ptr<Entry> cached;
bool refused = false;
std::string reason;
{
std::lock_guard<std::mutex> lock(cache.mutex);
auto it = cache.supported.find(key);
if (it != cache.supported.end()) {
cached = it->second;
} else {
auto refusal = cache.unsupported.find(key);
refused = (refusal != cache.unsupported.end());
if (refused) reason = refusal->second;
}
}
using graph_cache_debug::LookupResult;
LookupResult outcome = LookupResult::Miss;
if (cached != nullptr) {
outcome = LookupResult::Hit;
} else if (refused) {
outcome = LookupResult::Unsupported;
}
// Recorded after the lock is dropped, so that writing a trace line cannot hold up threads
// querying other keys. The counters are exact, but two lookups that raced on the lock can be
// recorded in the opposite order, so read a level-2 trace as the set of lookups that happened
// rather than as the sequence they happened in.
graph_cache_debug::record_cache_lookup(pass, outcome, key);

if (cached != nullptr) return cached;
// Raised rather than returned so that a replayed refusal is the same event as a fresh one:
// every caller already has to handle the build refusing, and none of them would have anything
// else to do with a second, quieter way of saying so.
if (refused) throw UnsupportedGraph(reason);

std::shared_ptr<Entry> entry;
try {
entry = std::make_shared<Entry>(build());
// Every site's tensor tuple leads with its graph, which is the one thing all four have in
// common and the only element this needs. A tuple that stopped leading with it would fail to
// compile here rather than quietly validate the wrong object.
validate_and_check_support(pass, *std::get<0>(entry->tensors), handle);
} catch (const UnsupportedGraph &e) {
{
std::lock_guard<std::mutex> lock(cache.mutex);
cache.unsupported.insert({key, e.what()});
}
graph_cache_debug::record_unsupported(pass);
throw;
}
graph_cache_debug::record_build(pass);
{
std::lock_guard<std::mutex> lock(cache.mutex);
return cache.supported.insert({key, std::move(entry)}).first->second;
}
}

// Runs the plan build that get_or_build_cached_graph() left undone, once per entry.
//
// Call this only when the graph is about to be executed, which is why it is a separate step
// rather than the tail of the lookup: a support query builds entries that nothing ever runs, and
// kernel compilation is the most expensive of the five frontend calls, so a query that paid for
// it would be paying for nothing. See CachedGraph for why the flag lives inside the entry and
// what a throw here leaves behind.
template <typename GraphAndTensors>
void ensure_plans_built(const char *pass, CachedGraph<GraphAndTensors> &entry) {
std::call_once(entry.plans_built, [&] {
cudnn_frontend::graph::Graph &graph = *std::get<0>(entry.tensors);
graph_cache_debug::timer(pass, graph_cache_debug::BuildStage::BuildPlans,
[&] { NVTE_CHECK_CUDNN_FE(graph.build_plans()); });
graph_cache_debug::record_plans_built(pass);
});
}

} // namespace fused_attn
} // namespace transformer_engine

#endif // TRANSFORMER_ENGINE_COMMON_FUSED_ATTN_GRAPH_CACHE_H_

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.

P1 Transient errors permanently cached as unsupported

validate_and_check_support() wraps every thrown exception — including std::bad_alloc, CUDA OOM, or transient driver errors — as UnsupportedGraph and inserts it into the negative (unsupported) map. Once a config is stored there, no future call will ever retry it. A single low-memory spike during build_operation_graph will permanently mark a valid, supported config as unsupported for the lifetime of the process, silently falling back to a slower kernel without any indication of the root cause.

The fix is to distinguish truly-unsupported graphs (cuDNN returning CUDNN_STATUS_NOT_SUPPORTED or equivalent) from transient resource failures, and only cache the former.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants