Measured at 96ed8346f, with #1393's
kernel guard read at 8902b6693.
CORRECTION, 2026-08-19. The first version of this issue named 9 sibling
sites. The real count is 19. The fresh reviewer of
#1407 found the undercount and
I re-derived it independently; the full enumeration and the reason the other
ten looked absent are below.
The .agents/issue-index.md row for this issue carries the wrong count of 9
and cannot be edited, because that file is append-only and the row is already
on main. This issue body is the authority. A reader who scopes the fix
from the index row will stop at nine and leave ten live.
SECOND CORRECTION, 2026-08-19, and it raises the count again — to 27. The
revision above counted CUDA and ROCm and stopped, while its own registrar table
named six backends. The fresh reviewer of
#1393 found the identical read
in Tenstorrent, Vulkan and Metal as well, and the implementer re-derived
every site independently with grep -c on the branch tree. Metal alone carries
5. The enumeration below is now complete across all six registrars, and it
also names the two sites that are correctly EXCLUDED because they are already
bounded — which the previous revisions did not distinguish.
Nine, then nineteen, then twenty-seven. The lesson is not "grep harder": each
undercount came from stopping at the first surface that answered. Nine
stopped at a truncated pipe, nineteen stopped at the two backends whose read
spells block_table[ in C++, and the shaders spell it btab[ and BT.v[ in
MSL and GLSL. Count per registrar, and make the per-file counts sum.
What
#1394 fixes the unbounded
block-table read by refusing it inside src/vt/cpu/cpu_paged_attn.cpp. That is
the right home for the CPU bound: the check sits beside the read it protects, it
walks only the requests the token loop visits, and it needs no host-readability
assumption because the CPU kernel already has one.
It is CPU-only, and the same read exists in every other backend. Six
backends register OpId::kPagedAttention:
| Backend |
Registrar |
| CPU |
src/vt/cpu/cpu_paged_attn.cpp:271 |
| CUDA |
src/vt/cuda/cuda_paged_attn.cu:2890 |
| ROCm |
src/vt/rocm/rocm_ops.hip:148 |
| Vulkan |
src/vt/vulkan/vulkan_ops.cpp:1604 |
| Metal |
src/vt/metal/metal_ops.mm:1119 |
| Tenstorrent |
src/vt/tenstorrent/tenstorrent_ops.cpp:3188 |
The 27 sites, exhaustively, per registrar
Counted with grep -c per file on row/ENG-CUDAGRAPH-SPEC-CAPTURE at
197958e42, never through a head. The per-file counts SUM to the total, which
is the check the earlier revisions could not have passed.
| Backend |
Registrar |
Sites |
Where |
| CPU |
src/vt/cpu/cpu_paged_attn.cpp:297 |
2 |
BOUNDED by #1394's guard at :145-155 |
| CUDA |
src/vt/cuda/cuda_paged_attn.cu:2890 |
12 |
:230,385,521,676,837,1067,1310,1387,1583,1663,1830,1912 |
| ROCm |
src/vt/rocm/rocm_ops.hip:148 |
7 |
src/vt/rocm/rocm_paged_attn.hip:226,424,557,765,1064,1277,1530 |
| Metal |
src/vt/metal/metal_ops.mm:1119 |
5 |
src/vt/metal/metal_msl.h:1054,1142,1293,1341,1591 |
| Tenstorrent |
src/vt/tenstorrent/tenstorrent_ops.cpp:3188 |
2 |
:3070,3100, the HOST fallback |
| Vulkan |
src/vt/vulkan/vulkan_ops.cpp:1604 |
1 |
src/vt/vulkan/shaders/vt_paged_attn.comp:110 |
27 unbounded sites, CPU excluded because it is fixed. Every one carries the
same index with j bounded only by seq_lens[r], in one of three spellings:
block_table[r * bt_row + (j / block_size) * bt_col] // CUDA, ROCm
btab[r * p.bt_row + (j / int(p.block_size)) * p.bt_col] // Metal (MSL)
BT.v[(p.bt_off >> 2) + uint(r) * p.bt_row + (uint(j) / p.block_size) * p.bt_col] // Vulkan (GLSL)
Two sites are EXCLUDED because they are already bounded
Saying which is excluded matters as much as the count, because an implementer
who "fixes" a bounded site has done nothing and thinks otherwise.
- Tenstorrent's device-staging path,
src/vt/tenstorrent/tenstorrent_ops.cpp:2258-2267. It reads the table to build
the device page_table, and its loop is for (c = 0; c < max_blocks; ++c) with
max_blocks = block_table.shape[1] at :2258. It cannot leave the table. Only
the HOST fallback at :3070,3100 is unbounded, and it indexes by
j / block_size with j <= jmax derived from seq_lens.
The CUDA/ROCm bounds, verified through their definitions
block_jmax is causal ? context + last_row : seqlen - 1, byte-identical at all
six CUDA definitions; context = seqlen - qlen and
last_row = min(local0 + BM, qlen) - 1 <= qlen - 1, so the causal arm is also
<= seqlen - 1. s_jmax is a max-reduction over per-row jx, each clamped to seqlen - 1
(rocm:702,1007,1455 clamp jx; rocm:1241 clamps jmax directly — a
difference the earlier revision's single quoted line elided).
WindowKeyMax (cuda:144-152) returns upper < seqlen ? upper : seqlen - 1, so
the windowed arms are clamped too — a window narrows only the LOW end. seqlen
is seq_lens[r] at cuda:209,366,494,620,778,1007,1246,1521,1766 and
rocm:193,396,522,678,984,1208,1430.
The Vulkan and Metal bounds
Vulkan's shader computes jmax = min(jmax, seqlen - 1) at
vt_paged_attn.comp:97-99 and then reads the table for every j <= jmax — the
same defect, one indirection through the shader instead of through a bound name.
Vulkan and Metal could not hold the bound today even if their shaders wanted
to. Both hosts pass only block_table.stride[0] and stride[1] into the push
constants / params struct (src/vt/vulkan/vulkan_ops.cpp:994-995,
src/vt/metal/metal_ops.mm:940-941) and never the COLUMN COUNT. Fixing those two
backends therefore means widening a shader ABI, not adding a compare — which is a
real argument for resolution 2 below and was not visible while the enumeration
stopped at CUDA and ROCm.
Why the obvious seam-level fix is NOT correct as written
vt::PagedAttention (src/vt/ops.cpp:3524-3600) is the one function every
backend routes through, so it looks like the place to put one bound for all six.
It cannot use seq_lens there, because on a device path that tensor is device
memory the host must not dereference — and the fresh reviewer of #1407
established the stronger form of this: that function today performs zero
content dereferences. Every check in it is rank, shape, dtype, stride,
contiguity or device. A seq_lens-value bound would be its first content read.
The only host-readable quantity is PagedAttentionArgs::max_seq_len, and its
own documentation (include/vt/ops.h:806-812) says "an upper bound is safe —
it only sizes grids/rounded dims". A refusal keyed on it therefore rejects a
call the contract permits. That was measured rather than reasoned:
row/ENG-CUDAGRAPH-SEAM-SIGSEGV carried exactly that check and withdrew it.
So this needs a decision, not a one-liner. Candidates:
- Bound the read inside each device kernel, mirroring the CPU fix per backend.
27 sites across five backends, and the table above is the map for it. Note
that two of the five need a shader-ABI change first, because the column count
never reaches the shader.
- Tighten
max_seq_len from "an upper bound is safe" to "exact", which is what
the production caller already passes (v1/attention/backend.cpp:303 assigns
metadata.max_seq_len, itself *max_element(seq_lens) at :57-58), and then
the seam bound becomes sound for all six backends at one line. This is an ABI
contract change and owes an audit of every caller.
- Leave the device paths unguarded and record that the bound is CPU-only.
Option 2 is the smallest and the one that reaches every backend, and it is the
one that needs a spec because it narrows a documented-permissive field.
Gate this owes
A red-first case per chosen option. The CPU half already has one:
tests/vt/test_ops_paged_attn.cpp's paged_attention validates shapes/args
case gained the over-long seq_lens refusal and its two negative controls in
#1407; it is RED (exit 139) with
no guard in the tree and GREEN (14 cases, 1646 assertions, exit 0) with #1394's
kernel guard applied. No equivalent exists for the 27 device sites.
Found while fixing #1390.
Owner: row ENG-CUDAGRAPH-BREAK.
Measured at
96ed8346f, with #1393'skernel guard read at
8902b6693.What
#1394 fixes the unbounded
block-table read by refusing it inside
src/vt/cpu/cpu_paged_attn.cpp. That isthe right home for the CPU bound: the check sits beside the read it protects, it
walks only the requests the token loop visits, and it needs no host-readability
assumption because the CPU kernel already has one.
It is CPU-only, and the same read exists in every other backend. Six
backends register
OpId::kPagedAttention:src/vt/cpu/cpu_paged_attn.cpp:271src/vt/cuda/cuda_paged_attn.cu:2890src/vt/rocm/rocm_ops.hip:148src/vt/vulkan/vulkan_ops.cpp:1604src/vt/metal/metal_ops.mm:1119src/vt/tenstorrent/tenstorrent_ops.cpp:3188The 27 sites, exhaustively, per registrar
Counted with
grep -cper file onrow/ENG-CUDAGRAPH-SPEC-CAPTUREat197958e42, never through ahead. The per-file counts SUM to the total, whichis the check the earlier revisions could not have passed.
src/vt/cpu/cpu_paged_attn.cpp:297:145-155src/vt/cuda/cuda_paged_attn.cu:2890:230,385,521,676,837,1067,1310,1387,1583,1663,1830,1912src/vt/rocm/rocm_ops.hip:148src/vt/rocm/rocm_paged_attn.hip:226,424,557,765,1064,1277,1530src/vt/metal/metal_ops.mm:1119src/vt/metal/metal_msl.h:1054,1142,1293,1341,1591src/vt/tenstorrent/tenstorrent_ops.cpp:3188:3070,3100, the HOST fallbacksrc/vt/vulkan/vulkan_ops.cpp:1604src/vt/vulkan/shaders/vt_paged_attn.comp:11027 unbounded sites, CPU excluded because it is fixed. Every one carries the
same index with
jbounded only byseq_lens[r], in one of three spellings:Two sites are EXCLUDED because they are already bounded
Saying which is excluded matters as much as the count, because an implementer
who "fixes" a bounded site has done nothing and thinks otherwise.
src/vt/tenstorrent/tenstorrent_ops.cpp:2258-2267. It reads the table to buildthe device
page_table, and its loop isfor (c = 0; c < max_blocks; ++c)withmax_blocks = block_table.shape[1]at:2258. It cannot leave the table. Onlythe HOST fallback at
:3070,3100is unbounded, and it indexes byj / block_sizewithj <= jmaxderived fromseq_lens.The CUDA/ROCm bounds, verified through their definitions
block_jmaxiscausal ? context + last_row : seqlen - 1, byte-identical at allsix CUDA definitions;
context = seqlen - qlenandlast_row = min(local0 + BM, qlen) - 1 <= qlen - 1, so the causal arm is also<= seqlen - 1.s_jmaxis a max-reduction over per-rowjx, each clamped toseqlen - 1(
rocm:702,1007,1455clampjx;rocm:1241clampsjmaxdirectly — adifference the earlier revision's single quoted line elided).
WindowKeyMax(cuda:144-152) returnsupper < seqlen ? upper : seqlen - 1, sothe windowed arms are clamped too — a window narrows only the LOW end.
seqlenis
seq_lens[r]atcuda:209,366,494,620,778,1007,1246,1521,1766androcm:193,396,522,678,984,1208,1430.The Vulkan and Metal bounds
Vulkan's shader computes
jmax = min(jmax, seqlen - 1)atvt_paged_attn.comp:97-99and then reads the table for everyj <= jmax— thesame defect, one indirection through the shader instead of through a bound name.
Vulkan and Metal could not hold the bound today even if their shaders wanted
to. Both hosts pass only
block_table.stride[0]andstride[1]into the pushconstants / params struct (
src/vt/vulkan/vulkan_ops.cpp:994-995,src/vt/metal/metal_ops.mm:940-941) and never the COLUMN COUNT. Fixing those twobackends therefore means widening a shader ABI, not adding a compare — which is a
real argument for resolution 2 below and was not visible while the enumeration
stopped at CUDA and ROCm.
Why the obvious seam-level fix is NOT correct as written
vt::PagedAttention(src/vt/ops.cpp:3524-3600) is the one function everybackend routes through, so it looks like the place to put one bound for all six.
It cannot use
seq_lensthere, because on a device path that tensor is devicememory the host must not dereference — and the fresh reviewer of #1407
established the stronger form of this: that function today performs zero
content dereferences. Every check in it is rank, shape, dtype, stride,
contiguity or device. A
seq_lens-value bound would be its first content read.The only host-readable quantity is
PagedAttentionArgs::max_seq_len, and itsown documentation (
include/vt/ops.h:806-812) says "an upper bound is safe —it only sizes grids/rounded dims". A refusal keyed on it therefore rejects a
call the contract permits. That was measured rather than reasoned:
row/ENG-CUDAGRAPH-SEAM-SIGSEGVcarried exactly that check and withdrew it.So this needs a decision, not a one-liner. Candidates:
27 sites across five backends, and the table above is the map for it. Note
that two of the five need a shader-ABI change first, because the column count
never reaches the shader.
max_seq_lenfrom "an upper bound is safe" to "exact", which is whatthe production caller already passes (
v1/attention/backend.cpp:303assignsmetadata.max_seq_len, itself*max_element(seq_lens)at:57-58), and thenthe seam bound becomes sound for all six backends at one line. This is an ABI
contract change and owes an audit of every caller.
Option 2 is the smallest and the one that reaches every backend, and it is the
one that needs a spec because it narrows a documented-permissive field.
Gate this owes
A red-first case per chosen option. The CPU half already has one:
tests/vt/test_ops_paged_attn.cpp'spaged_attention validates shapes/argscase gained the over-long
seq_lensrefusal and its two negative controls in#1407; it is RED (exit 139) with
no guard in the tree and GREEN (14 cases, 1646 assertions, exit 0) with #1394's
kernel guard applied. No equivalent exists for the 27 device sites.
Found while fixing #1390.
Owner: row
ENG-CUDAGRAPH-BREAK.