This is an automated email from the ASF dual-hosted git repository.

tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git


The following commit(s) were added to refs/heads/main by this push:
     new 5dbf7e05c9 [Relax][cuDNN] Do not offload causal / non-fp16 attention, 
and fix the default softmax scale (#20078)
5dbf7e05c9 is described below

commit 5dbf7e05c97ff1eae71eb74d3341b6fc681340e9
Author: Yang Xu <[email protected]>
AuthorDate: Sun Sep 13 20:45:49 2026 -0700

    [Relax][cuDNN] Do not offload causal / non-fp16 attention, and fix the 
default softmax scale (#20078)
    
    # [Relax][cuDNN] Do not offload causal / non-fp16 attention, and fix the
    default softmax scale
    
    ## Summary
    
    The cuDNN BYOC backend offloads any `relax.nn.attention` that matches
    the stacked-QKV pattern,
    without ever looking at the op's attributes. A model that asks for
    causal attention is therefore
    compiled into a cuDNN SDPA graph that computes **full bidirectional
    attention** — silently, with
    no error and no warning. The same blind spot lets fp32/bf16 attention be
    partitioned to a runtime
    that only builds a half-precision graph, and makes the documented
    `1/sqrt(head_dim)` default
    softmax scale unreachable. This PR rejects the workloads cuDNN is not
    actually being asked to
    compute, fixes the default-scale path, and adds partition-level
    regression tests.
    
    ### 1. Causal attention is offloaded and computed non-causally
    
    `make_stacked_attention_pattern`
    (python/tvm/relax/backend/patterns.py:338) matches the attention
    op node only, with no constraint on its attributes, and
    `_check_stacked_attention`
    (python/tvm/relax/backend/cuda/cudnn.py:69-89) validates only ndim and
    the split axis. The runtime
    then builds the SDPA graph with `.set_causal_mask(false)`
    (src/runtime/extra/contrib/cudnn/cudnn_frontend/attention.cc:88-93).
    
    On the cuDNN side that call is a no-op: in the cuDNN frontend,
    `SDPA_attributes::set_causal_mask(bool value)` is guarded by `if
    (value)` and only ever *adds*
    `set_diagonal_alignment(TOP_LEFT)` + `set_diagonal_band_right_bound(0)`
    (graph_properties.h,
    `set_causal_mask`). Passing `false` leaves the graph in its default
    state — no diagonal band bound
    at all, i.e. every query attends to every key.
    
    Failure scenario: `R.nn.attention(q, k, v, causal_mask="TopLeft")`
    inside the stacked-QKV pattern
    is partitioned into `fused_..._cudnn`, and the compiled model returns
    bidirectional attention
    output. Decoder models produce wrong (future-leaking) results with no
    diagnostic.
    
    Fix (conservative option): reject the offload at partition time.
    `_check_stacked_attention` now
    returns `False` when the attention op carries `causal_mask` or
    `window_size`. To make the op's
    attributes visible to the check, the attention call is added to the
    pattern annotations
    (`annotations["attention"]`), the same idiom already used for `split` /
    `q_transpose` and for
    `root` in `make_conv2d_pattern`. A defensive `ICHECK` was also added in
    the JSON runtime so that
    any future pattern cannot re-introduce the silent-wrong-answer path.
    
    I deliberately did **not** plumb the attribute through to
    `attention.cc`. Doing it correctly means
    mapping `"TopLeft"`/`"BottomRight"` onto
    `set_diagonal_alignment(DiagonalAlignment_t::TOP_LEFT /
    BOTTOM_RIGHT)` + `set_diagonal_band_right_bound(0)`, and — for
    `window_size` — onto
    `set_diagonal_band_left_bound()`; note `set_sliding_window_length()` in
    the cuDNN frontend is a
    pure alias for `set_diagonal_band_left_bound()` and sets only the *left*
    bound, so a sliding-window
    implementation must set both bounds explicitly. That is a feature
    addition that needs numerical
    validation on a GPU, which this audit could not run. Rejecting the
    pattern falls back to the
    non-offloaded path, which is correct.
    
    ### 2. The default softmax scale never reaches the runtime
    
    `AttentionAttrs::scale` is `Optional<FloatImm>`. When it is `None` the
    JSON serializer writes an
    empty **string** for the attribute
    (src/relax/backend/contrib/codegen_json/codegen_json.h:188-191,
    and the `Optional` overloads at :76-90). The runtime then does
    (src/runtime/extra/contrib/cudnn/cudnn_json_runtime.cc:218-221):
    
    ```cpp
    double scale = 1 / std::sqrt(head_size);
    if (node.HasAttr("scale")) {
      scale = node.GetAttr<double>("scale");
    }
    ```
    
    `HasAttr("scale")` is therefore *always* true — the `1/sqrt(head_size)`
    default is dead code — and
    `GetAttr<double>` (src/runtime/extra/contrib/json/json_node.h:254-258)
    casts an `ffi::String` to
    `double`, which fails. Either way the documented default scale never
    reaches cuDNN.
    
    Failure scenario: any `R.nn.attention(q, k, v)` written without an
    explicit `scale` — the common
    case, and two of the four parametrizations of the existing
    `test_stacked_attention_split_offload`.
    
    Fix: added `JSONGraphNode::HasAttrValue()`, which reports whether an
    attribute is present and is
    not the empty-string `None` sentinel, and paired it with the existing
    `GetAttr<T>()` for `scale`.
    `GetAttr<T>` throws on a type mismatch, so an attribute we cannot read
    is loud rather than
    silently replaced by the default -- which would be the same class of
    silent wrong answer this PR
    exists to fix. This keeps the "`None` is an empty string" serialization
    convention that every
    other BYOC runtime already relies on, instead of changing the shared
    serializer.
    
    ### 3. No dtype guard at partition time
    
    `_check_stacked_attention` (python/tvm/relax/backend/cuda/cudnn.py:69)
    has no dtype check, but the
    runtime only ever builds a half-precision graph and hard-fails on
    `TVM_FFI_ICHECK(data_type.code == kDLFloat && data_type.bits == 16) <<
    "Only float16 is supported"`
    (src/runtime/extra/contrib/cudnn/cudnn_frontend/attention.cc:42-43).
    
    Failure scenario: an fp32 (or bf16) attention module is happily
    partitioned to cuDNN and then
    aborts at module init with "Only float16 is supported", instead of
    falling back to a working
    non-offloaded implementation.
    
    Fix: require `float16` on the stacked QKV input, following the
    `_is_supported_dtype` idiom already
    used by `_check_conv2d`.
    
    ## Evidence
    
    Confirmed by reading the full chain (pattern -> offload check ->
    runtime). No runtime repro was
    performed (TVM was not built), so no measured numbers are claimed for
    this repo.
    
    ## Testing
    
    Added to `tests/python/relax/test_codegen_cudnn.py`:
    
    * `test_stacked_attention_partition` — positive control: fp16 unmasked
    attention is still
      partitioned to cuDNN (guards against over-rejection).
    * `test_stacked_attention_causal_not_partitioned[TopLeft|BottomRight]` —
    fails before this change
      (the causal graph is offloaded), passes after.
    * `test_stacked_attention_fp32_not_partitioned` — fails before this
    change, passes after.
    
    These are partition-only tests and need no GPU, but note that
    `test_codegen_cudnn.py` sets a
    module-level `pytestmark = [pytest.mark.gpu, skipif(not
    env.has_cudnn())]`, so they are skipped in
    environments without cuDNN, as is the existing
    `test_cudnn_partition_conv2d_without_bias`.
    `get_relax_stacked_attention_module` gained an optional `causal_mask`
    argument to build the
    negative cases.
    
    **None of this was run locally**: TVM is not built in the audit
    environment, so the new tests are
    unverified. The changed Python files were byte-compiled and checked with
    the repo's formatting
    settings (100-column), and the changed C++ files are clean under the
    repo `.clang-format`.
    
    Also worth flagging for maintainers: the only end-to-end cuDNN SDPA
    test,
    `test_stacked_attention_split_offload`, is unconditionally skipped
    (`@pytest.mark.skip(reason="require cudnn frontend")`,
    tests/python/relax/test_codegen_cudnn.py:302).
    Nothing in CI exercises this runtime, which is how findings 1 and 2
    survived.
    
    Found by an integration audit of cuDNN SDPA consumers by the NVIDIA
    cuDNN team.
---
 python/tvm/relax/backend/cuda/cudnn.py             |  9 +++++++
 python/tvm/relax/backend/patterns.py               |  1 +
 python/tvm/relax/testing/attention.py              |  3 ++-
 .../extra/contrib/cudnn/cudnn_json_runtime.cc      | 11 +++++++-
 src/runtime/extra/contrib/json/json_node.h         | 17 ++++++++++++
 tests/python/relax/test_codegen_cudnn.py           | 31 ++++++++++++++++++++++
 6 files changed, 70 insertions(+), 2 deletions(-)

diff --git a/python/tvm/relax/backend/cuda/cudnn.py 
b/python/tvm/relax/backend/cuda/cudnn.py
index 2df31ca62f..8036719259 100644
--- a/python/tvm/relax/backend/cuda/cudnn.py
+++ b/python/tvm/relax/backend/cuda/cudnn.py
@@ -86,6 +86,15 @@ def _check_stacked_attention(context: PatternCheckContext, 
layout: str) -> bool:
                 return False
     else:
         raise NotImplementedError(f"Unsupported layout: {layout}")
+    if not context.annotated_expr["stacked_qkv"].ty.dtype == "float16":
+        return False
+    # The cuDNN runtime builds an unmasked SDPA graph, so offloading a masked 
attention would
+    # silently compute bidirectional attention.
+    attention = context.annotated_expr["attention"]
+    if attention.attrs.causal_mask is not None:
+        return False
+    if attention.attrs.window_size is not None:
+        return False
     return True
 
 
diff --git a/python/tvm/relax/backend/patterns.py 
b/python/tvm/relax/backend/patterns.py
index 06011d6f6e..a669545819 100644
--- a/python/tvm/relax/backend/patterns.py
+++ b/python/tvm/relax/backend/patterns.py
@@ -336,6 +336,7 @@ def make_stacked_attention_pattern(start_op: str, 
with_bias: bool = False, layou
         out = is_op("relax.nn.attention_bias")(query, key, value, bias)
     else:
         out = is_op("relax.nn.attention")(query, key, value)
+    annotations["attention"] = out
 
     if layout == "SBN3H":
         out = is_op("relax.permute_dims")(out)
diff --git a/python/tvm/relax/testing/attention.py 
b/python/tvm/relax/testing/attention.py
index 449902425d..3ea7d50228 100644
--- a/python/tvm/relax/testing/attention.py
+++ b/python/tvm/relax/testing/attention.py
@@ -75,6 +75,7 @@ def get_relax_stacked_attention_module(
     qk_scale=None,
     single_shape=False,
     layout="BS3NH",
+    causal_mask=None,
 ):  # pylint: disable=too-many-arguments, too-many-locals, too-many-branches, 
invalid-name
     # pylint: disable=too-many-statements
     """Get a relax module for stacked attention."""
@@ -139,7 +140,7 @@ def get_relax_stacked_attention_module(
                     q = R.permute_dims(q, [1, 0, 2, 3])
                     k = R.permute_dims(k, [1, 0, 2, 3])
                     v = R.permute_dims(v, [1, 0, 2, 3])
-                result = R.emit(R.nn.attention(q, k, v, bias, qk_scale))
+                result = R.emit(R.nn.attention(q, k, v, bias, qk_scale, 
causal_mask))
                 if layout == "SBN3H":
                     result = R.emit(R.permute_dims(result, [1, 0, 2, 3]))
                 R.output(result)
diff --git a/src/runtime/extra/contrib/cudnn/cudnn_json_runtime.cc 
b/src/runtime/extra/contrib/cudnn/cudnn_json_runtime.cc
index 5b3756fd79..7246983fcd 100644
--- a/src/runtime/extra/contrib/cudnn/cudnn_json_runtime.cc
+++ b/src/runtime/extra/contrib/cudnn/cudnn_json_runtime.cc
@@ -215,11 +215,20 @@ class cuDNNJSONRuntime : public JSONRuntimeBase {
     } else {
       TVM_FFI_THROW(InternalError) << "Unsupported layout: " << layout;
     }
+    // A `None` scale is serialized as an empty string, so HasAttrValue (not 
HasAttr) decides
+    // whether the default applies. GetAttr throws on a type mismatch, so a 
scale we cannot
+    // read is loud rather than silently replaced by the default.
     double scale = 1 / std::sqrt(head_size);
-    if (node.HasAttr("scale")) {
+    if (node.HasAttrValue("scale")) {
       scale = node.GetAttr<double>("scale");
     }
 
+    // The SDPA graph below is built without any mask, so masked attention 
must not reach here.
+    TVM_FFI_ICHECK(!node.HasAttrValue("causal_mask"))
+        << "cuDNN attention does not support causal_mask yet";
+    TVM_FFI_ICHECK(!node.HasAttrValue("window_size"))
+        << "cuDNN attention does not support window_size yet";
+
     auto runner = tvm::contrib::CuDNNSDPARunner::Create();
     runner->Init(batch, seq_len, num_heads, num_kv_heads, head_size, 
head_size_v, scale, dtype,
                  layout);
diff --git a/src/runtime/extra/contrib/json/json_node.h 
b/src/runtime/extra/contrib/json/json_node.h
index 40c96d8269..da51bb45c6 100644
--- a/src/runtime/extra/contrib/json/json_node.h
+++ b/src/runtime/extra/contrib/json/json_node.h
@@ -257,6 +257,23 @@ class JSONGraphNode {
     return attrs_[key].cast<T>();
   }
 
+  /*!
+   * \brief Check whether an optional attribute carries a value.
+   *
+   * The JSON serializer stores a `None` attribute as an empty string, so 
`HasAttr` alone
+   * cannot distinguish an unset attribute from one that was set. Callers pair 
this with
+   * `GetAttr<T>`, which throws on a type mismatch rather than silently 
falling back.
+   *
+   * \param key The key for lookup.
+   *
+   * \return Whether the attribute is present and is not the empty-string 
`None` sentinel.
+   */
+  bool HasAttrValue(const std::string& key) const {
+    if (attrs_.count(key) == 0) return false;
+    auto sentinel = attrs_[key].try_cast<ffi::String>();
+    return !(sentinel.has_value() && sentinel.value().empty());
+  }
+
   /*!
    * \brief Set an attribute for the node.
    *
diff --git a/tests/python/relax/test_codegen_cudnn.py 
b/tests/python/relax/test_codegen_cudnn.py
index 419c805fdc..b934cf9967 100644
--- a/tests/python/relax/test_codegen_cudnn.py
+++ b/tests/python/relax/test_codegen_cudnn.py
@@ -299,6 +299,37 @@ def stacked_attention_size(request):
     return request.param
 
 
+def _is_offloaded_to_cudnn(mod):
+    return any("cudnn" in gv.name_hint for gv, _ in mod.functions_items())
+
+
+def _get_stacked_attention_module(dtype, causal_mask=None):
+    b, s, n, h, h_v = 4, 8, 32, 64, 64
+    qkv = np.random.randn(b, s, n * h * 2 + n * h_v).astype(dtype)
+    return get_relax_stacked_attention_module(
+        qkv, b, s, n, h, h_v, "split", causal_mask=causal_mask
+    )
+
+
+def test_stacked_attention_partition():
+    mod = _get_stacked_attention_module("float16")
+    assert _is_offloaded_to_cudnn(partition_for_cudnn(mod))
+
+
[email protected]("causal_mask", ["TopLeft", "BottomRight"])
+def test_stacked_attention_causal_not_partitioned(causal_mask):
+    # The cuDNN runtime builds an unmasked SDPA graph, offloading here would 
silently drop
+    # the causal mask.
+    mod = _get_stacked_attention_module("float16", causal_mask=causal_mask)
+    assert not _is_offloaded_to_cudnn(partition_for_cudnn(mod))
+
+
+def test_stacked_attention_fp32_not_partitioned():
+    # attention.cc only builds a half-precision graph and ICHECKs at module 
init otherwise.
+    mod = _get_stacked_attention_module("float32")
+    assert not _is_offloaded_to_cudnn(partition_for_cudnn(mod))
+
+
 @pytest.mark.skip(reason="require cudnn frontend")
 def test_stacked_attention_split_offload(stacked_attention_size):
     b, s, n, (h, h_v), bias_shape, scale, single_shape, layout = 
stacked_attention_size

Reply via email to