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 9713d6ee95 [Fix][Relax] Synchronize merge_state_inplace before 
overwriting the LSE (#20449)
9713d6ee95 is described below

commit 9713d6ee95e64c063328b6d7747521b0fdf401be
Author: Akaash Parthasarathy <[email protected]>
AuthorDate: Sat Sep 26 08:12:07 2026 -0700

    [Fix][Relax] Synchronize merge_state_inplace before overwriting the LSE 
(#20449)
    
    Add a barrier between reading and overwriting the shared log-sum-exp in
    `merge_state_inplace`, and
    let one thread store it.
    
    Every `threadIdx.x` thread of a block reads `S[bx, ty + by * bdy]`,
    derives both rescaling factors
    from it, rescales its own `VEC_SIZE` columns of `V`, then writes the
    merged value back to that same
    element. Nothing separates the read from the write, so this holds only
    while the threads advance in
    lockstep. `bdx` is `head_dim // VEC_SIZE` with `VEC_SIZE` capped at 4,
    giving one warp per block at
    head_dim 128, two at 256 and four at 512. Past one warp a thread that
    finishes early overwrites `S`
    while another has yet to read it, and the late thread then rescales its
    slice of `V` with an already
    merged LSE and stores an LSE merged twice.
    
    The result is a large error rather than a rounding difference. On Metal
    at head_dim 512 with 8 heads
    and identical inputs on every iteration, 22 of 25 runs at N=512 and 24
    of 25 at N=4096 disagreed
    with the first run, while head_dim 128 never did. Checked against a
    NumPy reference, one affected
    row had 365 of its 512 columns wrong, confined to warp spans 0, 2 and 3,
    and the returned LSE was
    off by 0.74. Gemma 4 E2B, whose head_dim is 512, hit this in roughly 3%
    of chunked prefills, moving
    logits by up to 1.7 without changing the sampled tokens; a device sync
    after every VM call did not
    remove it, which is what placed it inside a single kernel.
    
    `merge_state_inplace_cpu` walks rows serially and is unaffected, and the
    measurements above are from
    Metal, though the same argument applies to any backend whose blocks span
    more than one warp or
    subgroup. The new case in
    `tests/python/relax/test_frontend_nn_llm_kernel_config.py` builds the
    kernel for Metal and checks the generated source for a barrier between
    the last read of the LSE and
    the first store to it, plus the single-thread guard; it fails at
    head_dim 128, 256 and 512 without
    the fix.
---
 .../tvm/relax/frontend/nn/llm/_decode_kernels.py   |  8 +++++-
 .../relax/test_frontend_nn_llm_kernel_config.py    | 31 ++++++++++++++++++++++
 2 files changed, 38 insertions(+), 1 deletion(-)

diff --git a/python/tvm/relax/frontend/nn/llm/_decode_kernels.py 
b/python/tvm/relax/frontend/nn/llm/_decode_kernels.py
index fc966e7e54..d5098a35c5 100644
--- a/python/tvm/relax/frontend/nn/llm/_decode_kernels.py
+++ b/python/tvm/relax/frontend/nn/llm/_decode_kernels.py
@@ -490,7 +490,13 @@ def _merge_state_inplace(num_heads, head_dim, v_dtype, 
target: Target, global_sy
                                 V[bx, ty + by * bdy, tx * VEC_SIZE + vec] = 
v_vec[vec]
 
                             # store s
-                            S[bx, ty + by * bdy] = T.log2(s_val[0] + 
s_other_val[0]) + s_max[0]
+                            # Every threadIdx.x thread of this block reads the 
same S element
+                            # above, so none may overwrite it until all of 
them have read it.
+                            # Without this barrier the threads only stay in 
step while bdx fits
+                            # in one warp, and a head_dim above 128 makes bdx 
wider than that.
+                            T.tvm_storage_sync("shared")
+                            if tx == 0:
+                                S[bx, ty + by * bdy] = T.log2(s_val[0] + 
s_other_val[0]) + s_max[0]
 
     func = merge_state_inplace
     if global_symbol:
diff --git a/tests/python/relax/test_frontend_nn_llm_kernel_config.py 
b/tests/python/relax/test_frontend_nn_llm_kernel_config.py
index b0561dffc6..0183481693 100644
--- a/tests/python/relax/test_frontend_nn_llm_kernel_config.py
+++ b/tests/python/relax/test_frontend_nn_llm_kernel_config.py
@@ -19,6 +19,7 @@ import pytest
 
 import tvm
 import tvm.testing
+from tvm.relax.frontend.nn.llm._decode_kernels import _merge_state_inplace
 from tvm.relax.frontend.nn.llm._kernel_common import (
     _get_prefill_kernel_config,
     _get_prefill_shared_memory_usage,
@@ -240,5 +241,35 @@ def 
test_wide_head_tree_attention_has_legal_metal_schedule(kernel):
     assert 24_928 <= int(target.attrs["max_shared_memory_per_block"])
 
 
[email protected]("head_dim", [128, 256, 512])
+def test_merge_state_waits_before_overwriting_the_shared_lse(head_dim):
+    """merge_state_inplace must synchronize between reading and overwriting S.
+
+    Every threadIdx.x thread of a block reads the same S element and later 
writes it back. The
+    threads only advance in step while bdx fits in one warp, and bdx is 
head_dim // VEC_SIZE, so a
+    head_dim above 128 spreads them over several warps. Without a barrier a 
warp that finishes
+    early overwrites S while another warp has yet to read it, and that warp 
then rescales its slice
+    of V with an already merged LSE, corrupting whole warp-sized spans of the 
output.
+    """
+    target = tvm.target.Target("metal")
+    func = _merge_state_inplace(8, head_dim, "float16", target, 
global_symbol="merge")
+    lines = [
+        line.strip()
+        for line in tvm.tirx.build(func, 
target=target).imports[0].inspect_source().splitlines()
+    ]
+
+    loads = [i for i, line in enumerate(lines) if "= S_ptr[" in line]
+    stores = [i for i, line in enumerate(lines) if line.startswith("S_ptr[")]
+    assert loads and stores, "expected the kernel to both read and write the 
shared LSE"
+
+    last_load, first_store = max(loads), min(stores)
+    assert any(
+        last_load < i < first_store for i, line in enumerate(lines) if 
"threadgroup_barrier" in line
+    ), "no barrier between reading the shared LSE and overwriting it"
+    assert any("threadIdx.x) == 0" in line for line in 
lines[last_load:first_store]), (
+        "the shared LSE should be stored by a single thread"
+    )
+
+
 if __name__ == "__main__":
     tvm.testing.main()

Reply via email to