Back to writing

/ AI Infra

[Infra-11] Stride: From Tensor Views to Kernels, KV Cache, and Communication Layouts

A systems-level guide to tensor strides: address calculation, zero-copy views, kernel layout contracts, memory coalescing, attention and paged KV cache layouts, quantization, communication packing, compilation, and debugging.

6 minInfra · Tensor · Stride · CUDA

很多人第一次接触 stride,是在 transpose() 之后调用 view(),然后遇到一句:

view size is not compatible with input tensor's size and stride

于是 stride 很容易被理解成一个 PyTorch API 细节,或者一串只有写自定义算子时才需要关心的数字。

在 AI Infra 中,这个认识远远不够。Stride 描述的是逻辑索引如何映射到存储地址。它向上决定一次 permute 能否只修改 metadata,向下决定 Kernel 读哪个地址、相邻线程能否合并访存;再往系统侧延伸,它还会影响 KV Cache 的页内布局、通信前是否需要 pack、offload 后能否正确恢复数据,以及编译器是否为同一个 shape 重新生成图。

如果 shape 是 Tensor 对外展示的逻辑结构,那么 stride 就是贯穿框架、Kernel 与存储系统的数据布局协议。

1. 先从一条地址公式理解 stride#

对普通的 strided Tensor,可以把它概念化为:

Tensor = storage + storage_offset + shape + stride + dtype + device

其中:

  • storage 是底层的一维存储;
  • storage_offset 是 Tensor 第一个逻辑元素相对 storage 起点的偏移;
  • shape[k] 表示第 k 维有多少个逻辑元素;
  • stride[k] 表示第 k 维索引增加 1 时,要在 storage 中跨过多少个元素;
  • dtype 决定每个存储元素占多少字节。

对逻辑索引 (i0,i1,,in1)(i_0,i_1,\ldots,i_{n-1}),元素偏移是:

offset=storage_offset+k=0n1ikstridek\operatorname{offset} = \operatorname{storage\_offset} + \sum_{k=0}^{n-1}i_k\operatorname{stride}_k

如果 base 指向 storage 起点,那么字节地址是:

address=base+offset×sizeof(dtype)\operatorname{address} = \operatorname{base} + \operatorname{offset}\times\operatorname{sizeof(dtype)}

例如一个连续的 Tensor:

x = torch.arange(24).reshape(2, 3, 4)
 
print(x.shape)   # torch.Size([2, 3, 4])
print(x.stride())  # (12, 4, 1)

访问 x[b, i, j] 时,元素偏移就是:

b * 12 + i * 4 + j

这里有一个经常跨语言、跨设备踩坑的细节:PyTorch 的 stride 以元素为单位,不一定以字节为单位。如果一个 BF16 Tensor 的 stride(0) == 1024,对应的字节跨度是 1024 * 2 = 2048。而某些 C/C++、DMA 或通信接口接收的 pitch/stride 却可能是字节数。接口边界上必须明确单位。

1.1 Shape 相同,不代表布局相同#

下面两个 Tensor 的 shape 和数值完全相同,stride 却不同:

x = torch.arange(6).reshape(2, 3)
y = x.t().contiguous().t()
 
print(x.shape, x.stride())  # (2, 3), (3, 1)
print(y.shape, y.stride())  # (2, 3), (1, 2)
print(torch.equal(x, y))    # True

如果下游只检查:

assert x.shape == y.shape

它只验证了逻辑接口,没有验证地址解释是否一致。这正是许多自定义 Kernel 和 KV Cache 问题的根源。

1.2 Stride 也不是所有物理布局的完整描述#

“stride 是物理布局描述”适用于规则的 strided view,但它不是任意布局的万能编码。

例如 4-bit 权重可能把两个逻辑元素打包进一个 byte,scale 可能按 group 存放,硬件专用 GEMM 还可能采用 tile、swizzle 或 interleave。此时除了普通 stride,还要明确:

pack factor
group size
scale stride
tile layout
swizzle rule
alignment

因此更精确的说法是:stride 是规则多维 Tensor 最核心的地址映射参数,也是更复杂 layout contract 的基础组成部分。

2. 为什么 transpose 可以是零拷贝#

考虑:

x = torch.arange(24).reshape(2, 3, 4)
y = x.transpose(1, 2)
 
print(x.shape, x.stride())  # (2, 3, 4), (12, 4, 1)
print(y.shape, y.stride())  # (2, 4, 3), (12, 1, 4)

transpose 不需要移动 24 个元素。它只是交换 shape 和 stride 中对应维度的解释,让 y 继续共享 x 的 storage。

这类 view 操作的价值,是把布局变换从“搬运数据”降成“修改 metadata”:

storage 不变
data copy = 0
shape / stride / offset 改变

推理路径中经常出现:

[B, S, hidden]
-> [B, S, num_heads, head_dim]
-> [B, num_heads, S, head_dim]
-> packed token layout
-> paged KV layout

如果每一步都物化为新的连续 Tensor,代价不仅是一个 API 调用,还包括 D2D copy、临时 buffer、额外 HBM 流量和新的同步依赖。

2.1 viewreshapepermutecontiguous#

这几个 API 可以这样区分:

操作主要行为是否可能复制
transpose / permute改变维度与 stride 的解释通常不复制
view在当前 stride 可兼容时重解释 shape不复制;不兼容时报错
reshape尽量返回 view,否则创建副本可能复制
contiguous按当前逻辑顺序生成 canonical layout非连续时会复制
expand用 stride 0 表示广播维不复制

PyTorch 对 view() 的兼容条件有明确约束;而 reshape() 是否复制属于实现结果,性能敏感代码不应凭感觉判断。可以比较 storage 指针,或在 profiler 中检查 clonecopy_contiguous

2.2 Stride 0:读起来方便,写起来危险#

x = torch.randn(1, 128)
y = x.expand(32, 128)
 
print(y.stride())  # (0, 1)

y[b, :] 的不同 batch 行实际都指向同一段 storage。作为只读输入,这是一种高效广播;作为输出或 in-place 写入目标,多个逻辑位置可能映射到同一个物理地址。

所以“支持 arbitrary-stride input”与“支持 arbitrary-stride output”不是同一份承诺。Kernel 的输出合同通常还要禁止 overlapping storage。

3. 到了 Kernel 层,stride 就是地址生成代码#

一个二维 Kernel 最朴素的寻址方式是:

offset = row * stride_row + col * stride_col;
value = input[offset];

而只支持 row-major 连续输入的 Kernel 可能直接写成:

offset = row * num_cols + col;

后者隐含了:

stride_row == num_cols
stride_col == 1

两种设计都合理,关键是不能让接口和实现说两套话。

3.1 Canonical-layout Kernel#

只支持少数固定布局的优点是:

  • 地址表达式更简单;
  • stride、tile size 等信息可以编译期固化;
  • 寄存器和整数运算更少;
  • 更容易使用向量 load/store、TMA 等硬件路径;
  • 编译变体与测试空间更可控。

代价是调用方必须满足布局约束,不满足时需要拒绝、dispatch 到其他 Kernel,或先做 layout conversion。

3.2 Stride-aware Kernel#

真正按运行时 stride 寻址,可以直接消费 transpose、padded slice 或跨层 view,避免一次完整 copy。但通用性不是免费的:动态地址计算、更多寄存器、难以证明的对齐条件,以及更复杂的 fast-path dispatch 都可能降低性能。

实践中更常见的结构是三级路径:

if is_canonical_layout(x):
    return fast_kernel(x)
 
if x.stride(-1) == 1:
    return outer_stride_kernel(x)
 
return generic_or_contiguous_fallback(x)

Triton 的 make_block_ptr 也要求显式给出 baseshapestridesoffsetsblock_shapeorder。这说明即使使用更高级的块级抽象,编译器仍然需要完整的地址映射信息。

3.3 最危险的是“半通用”#

下面这种写法尤其危险:

offset =
    page_id  * stride_page +
    token_id * num_heads * head_dim +
    head_id  * head_dim +
    dim_id;

page_id 使用了真实 stride,后面三维却偷偷假设连续。它可能在 canonical 输入上通过所有测试,在 padded 或跨层 view 上静默读错。

正确选择只有两个:

// 方案一:所有维度都使用真实 stride
offset =
    page_id  * stride_page +
    token_id * stride_token +
    head_id  * stride_head +
    dim_id   * stride_dim;

或者:

// 方案二:明确验证 canonical layout
TORCH_CHECK(stride_dim == 1);
TORCH_CHECK(stride_head == head_dim);
TORCH_CHECK(stride_token == num_heads * head_dim);

一半接收 stride、一半硬编码连续假设,是 stride Bug 最常见也最隐蔽的形态。

4. Stride 为什么会直接影响 GPU 性能#

“能正确寻址”只解决 correctness;地址在一个 warp 内如何分布,才决定访存效率。

假设 lane t 访问:

address(t) = base + t * stride_lane * element_size

stride_lane == 1 时,相邻 lane 通常访问相邻元素,硬件更容易把请求合并成少量内存事务。当 stride_lane == 128 时,同一 warp 的地址会分散到更多 segment,造成 over-fetch、cache line 利用率下降和更多内存事务。

这里真正重要的不是“Tensor 最后一维必须连续”这句口诀,而是:

Kernel 中映射到相邻线程的那个逻辑维度,应该尽量对应小 stride,最常见是 stride 1。

Attention、RMSNorm、Softmax 经常让 lane 沿 head_dim 或 hidden dimension 工作,所以它们常要求最后一维连续。FlashInfer 的 TRT-LLM decode 接口就明确要求 head_dim 维 stride 为 1,同时允许 head 与 page 维使用任意 stride;这是一个很典型的“内层严格、外层通用”的 layout contract。

4.1 Stride 还影响向量化和对齐#

即使 stride(-1) == 1,也不代表一定能安全使用 16-byte vector load。还要检查:

storage_offset 对齐
base address 对齐
行首地址对齐
元素数量是否满足向量宽度
边界是否需要 mask

因此 Kernel 合同通常不应只写:

input: [B, S, H]

而应该写成:

Input:
  shape: [B, S, H]
  dtype: fp16 / bf16
  required:
    stride_H == 1
    row address aligned to 16 bytes
  supported:
    arbitrary stride_B
    arbitrary stride_S
  unsupported:
    overlapping storage
    stride_H != 1

5. 在常见推理算子中怎么看 stride#

5.1 GEMM:leading dimension 本质上就是 stride#

对:

Y=XWTY=XW^T

逻辑 shape 可能是:

X: [M, K]
W: [N, K]
Y: [M, N]

但 GEMM 库还需要 layout、transpose flag、leading dimension 与 batch stride。权重究竟是 row-major、column-major、转置 view、TP shard,还是量化后的 packed tile,仅看 [N, K] 无法判断。

因此看到转置权重时,不应该立刻在每次 forward 中调用:

w = w.transpose(0, 1).contiguous()

更合理的顺序是:

  1. 让 GEMM 通过 transpose/layout 参数直接消费;
  2. 若硬件 Kernel 需要专用布局,在模型加载阶段转换一次;
  3. 如果权重长期固定,直接以 backend 偏好的格式保存;
  4. 最后才考虑在热路径中反复 materialize。

5.2 QKV、RoPE、RMSNorm 与 Softmax#

QKV 常见逻辑布局包括:

BSHD = [batch, seq, head, dim]
BHSD = [batch, head, seq, dim]
THD  = [total_tokens, head, dim]

若它们都是普通连续布局:

BSHD stride = [S*H*D, H*D, D, 1]
BHSD stride = [H*S*D, S*D, D, 1]

二者的 dim 都连续,但 token 与 head 的局部性不同。prefill 需要处理大量 query token,decode 需要扫描历史 KV;同一个布局不一定同时对两条路径最优。

RoPE 成对读取 hidden 元素,RMSNorm 和 Softmax 沿归约维计算 max/sum/square sum。若归约维 stride 不是 1,Kernel 可能需要 gather 式访问,或先做一次转换。是否值得支持通用 stride,应比较:

Tstrided accessvs.Tlayout copy+Tfast kernelT_{\text{strided access}} \quad\text{vs.}\quad T_{\text{layout copy}} + T_{\text{fast kernel}}

只使用一次的大 Tensor,直接 strided 访问可能更省;被多次复用的权重或 cache,预先转换往往更划算。

5.3 量化:逻辑 stride 与物理 packing 必须分开#

逻辑权重是 [N, K],物理存储可能是:

packed_weight: [N, K / pack_factor]
scales:        [N, K / group_size]
zero_points:   [...]

此时“第 k 个权重”不再等于“从 base 跨过 k * sizeof(dtype)”。Kernel 需要同时理解逻辑索引、打包单元、group 和 scale 布局。把普通 Tensor 的 stride 公式直接套到 W4、FP4 或 swizzled scale 上,往往会得到合法地址中的错误数据。

6. Paged KV Cache:page table 与 stride 各管一层#

以 NHD Paged KV Cache 为例:

[num_pages, page_size, num_kv_heads, head_dim]

连续情况下:

stride_dim   = 1
stride_head  = head_dim
stride_token = num_kv_heads * head_dim
stride_page  = page_size * num_kv_heads * head_dim

访问请求中第 t 个历史 token 时,系统先通过 page table 得到物理页:

logical token t
-> logical_page, token_in_page
-> physical_page = page_table[request, logical_page]

然后再用 stride 定位页内元素:

offset =
    physical_page * stride_page
  + token_in_page * stride_token
  + kv_head       * stride_head
  + dim           * stride_dim

所以二者解决的是不同问题:

Page table 负责“逻辑页映射到哪个物理页”,stride 负责“物理页内的 token、head 与 dim 怎样排布”。

FlashInfer 同时支持 NHD:

[page, token, head, dim]

与 HND:

[page, head, token, dim]

它们拥有相同的逻辑信息,却让 token stride 与 head stride 交换。选哪一个要看 Attention Kernel 的线程映射、prefill/decode 访问模式与后端支持,而不是只看名字。

6.1 Layer-major 与 block-major 的系统取舍#

把 layer 维加入 KV Cache 后,常见选择是:

layer-major: [num_layers, num_blocks, ...]
block-major: [num_blocks, num_layers, ...]

Layer-major 容易给每层 Attention 提供连续 slice;block-major 则把同一个 block 的多层 KV 放得更近,更利于 PD 分离、offload 或 connector 按 block 跨层搬运。

这不是单个 Kernel 能独立决定的布局。它同时连接:

KV allocator
-> cache write kernel
-> attention backend
-> prefix cache
-> offload / KV connector
-> RDMA or storage backend

2026 年 vLLM 的一个 RFC 就在讨论这种张力:block-major 能让 connector 聚合跨层 block,却可能让 per-layer view 变成 non-contiguous;替代方案是使用一个 layer-major 大 allocation,让每层保持连续,同时让层间地址维持固定 stride,便于 cudaMemcpy2D 或 RDMA 注册利用。

7. 通信系统为什么也必须理解 stride#

NCCL、RDMA 和常见 DMA 接口最喜欢的输入是:

base address + num_bytes

但一个真实 KV view 可能是从以下 Tensor 切出来的:

[num_blocks, num_layers, page_size, num_heads, head_dim]

当取某一层时,逻辑元素是完整的,物理地址却可能分散在每个 block 中。系统通常有三种选择。

7.1 Pack,再发送,再 unpack#

strided source
-> pack kernel
-> contiguous communication buffer
-> NCCL / RDMA
-> unpack
-> destination layout

优点是协议简单,代价是至少增加一次读写往返、临时 buffer 和同步点。

7.2 让通信层接收布局描述#

通信组件接收:

base
shape
stride or pitch
block size
offset
layout enum

然后使用 scatter-gather、2D copy 或专用 pack Kernel。它能消除部分中间 materialization,但协议、注册和边界检查更复杂。

7.3 从 allocator 开始统一布局#

更彻底的方式,是在分配阶段就选择 Attention、KV Connector、offload 和 prefix cache 都能接受的 canonical layout。这样优化的不只是一次 copy,而是模块之间的整个数据平面。

MoE 也有同样问题。All-to-All 希望每个目标 rank 对应一段连续 buffer,grouped GEMM 希望同一 Expert 的 token 连续。于是 token permutation、send buffer 布局和 Expert 输入 stride,本质上是同一个端到端布局问题。

8. Stride Bug 为什么经常不崩溃,只输出乱码#

越界访问容易被 sanitizer 或运行时捕获;stride 错误更危险,因为错误地址可能仍然落在已分配的合法 storage 内。

典型场景是:

生产者:按真实 stride 写入
消费者:按 canonical stride 读取

block 0 的 base 恰好相同,所以测试正常;从 block 1 开始,消费者按更短的逻辑 block size 递增,而生产者按带 padding 或跨组件的真实 stride(0) 递增。程序不越界、不报错,Attention 却读到了另一个合法区域。

vLLM issue #47783 记录了一个非常典型的案例:cache-store 遵循 packed KV view 的 stride(0),某条 FlashInfer sparse decode 路径却没有消费这个 block stride。写入和读取只在 block 0 内一致,后续 block 读错位置,最终表现为退化文本与评测精度接近归零。

这类 Bug 常在以下变化后出现:

  • 开启 KV offload 或 prefix cache;
  • 从普通 prefill 切到 chunked prefill;
  • 更换 Attention backend;
  • 合并多层或多组件 KV allocation;
  • 从 NHD 切换到 HND;
  • 加入 padding、对齐或量化 scale;
  • 只恢复了 shape,没有恢复 offset 与 stride。

8.1 .contiguous() 修好结果,不等于修好系统#

如果加上一句:

x = x.contiguous()

输出恢复正常,只能证明下游不支持当前布局。接下来仍需回答:

  • 这个 copy 是初始化一次,还是每层、每请求、每个 decode token 一次?
  • Tensor 多大,产生多少额外 HBM 流量?
  • 是否增加峰值显存和 allocator 压力?
  • 是否引入新的 stream 同步?
  • 是否破坏 CUDA Graph 的地址稳定性?
  • 能否由 Kernel 支持常见 outer stride,或在更早阶段一次性转换?

.contiguous() 是合理的边界适配工具,但不应该成为热路径中的默认“消毒剂”。

9. Torch.compile 与 Graph 也会观察布局#

同一个逻辑 shape 可能出现不同 stride:

shape  = (32, 128, 4096)
stride = (524288, 4096, 1)
 
shape  = (32, 128, 4096)
stride = (524288, 1, 128)

对 eager Python 代码而言,它们可能都能正确运行;对编译系统而言,它们可能需要不同的地址生成和 Kernel。torch.compile 的 Tensor guard 可以包含 stride,布局变化可能触发 guard failure 和 recompile。

所以当服务出现:

recompile
graph break
cache miss
latency 抖动

除了检查 shape、dtype 与动态 batch,还应该检查 stride 是否在不同请求路径中漂移。

CUDA Graph 或其他设备 Graph 还需要关注地址稳定性。一个隐式 reshape copy 或 contiguous fallback 可能产生新 allocation,让原本可 capture 的稳定地址路径失效。

10. 一套可复用的 stride 调试方法#

排查前先把“Tensor 看起来是什么”升级成“Tensor 实际如何寻址”。可以准备统一的描述函数:

from __future__ import annotations
 
import torch
 
 
def describe_tensor(name: str, x: torch.Tensor) -> None:
    element_size = x.element_size()
    storage_ptr = x.untyped_storage().data_ptr()
 
    print(f"{name}:")
    print(f"  shape            = {tuple(x.shape)}")
    print(f"  stride(elements) = {tuple(x.stride())}")
    print(
        "  stride(bytes)    = "
        f"{tuple(s * element_size for s in x.stride())}"
    )
    print(f"  storage_offset   = {x.storage_offset()}")
    print(f"  dtype            = {x.dtype}")
    print(f"  device           = {x.device}")
    print(f"  element_size     = {element_size} bytes")
    print(f"  contiguous       = {x.is_contiguous()}")
    print(f"  storage_ptr      = {hex(storage_ptr)}")
    print(f"  first_element_ptr= {hex(x.data_ptr())}")

然后按生产者—消费者链路记录:

谁分配?
谁创建 view?
谁写入?
谁读取?
谁传输或 offload?
谁要求 canonical layout?

真正要验证的是:

write_address(i)=read_address(i)\operatorname{write\_address}(i) = \operatorname{read\_address}(i)

而不是两端恰好拥有相同 shape。

10.1 不要只测 torch.randn(shape)#

直接创建的随机 Tensor 几乎总是标准连续布局。自定义算子至少应覆盖:

布局构造方式主要覆盖点
Contiguoustorch.randn(...)canonical fast path
Transposex.transpose(...)维度互换
Permutex.permute(...)一般非连续布局
Narrowx[:, 1:-1]非零 offset
Padded slice从更大 Tensor 切片outer stride 大于逻辑行宽
Layer viewcache[:, layer]跨层 KV stride
Broadcastx.expand(...)stride 0 与重叠输入
Canonical copyx.contiguous()对照基线

Paged KV Cache 还必须覆盖 block_id > 0。只测 block 0,会漏掉最典型的 block-stride 错误。

10.2 输出错误时,找第一个发生差异的位置#

不要只看最终生成文本。按数据流逐层放置数值探针:

Q / K / V
-> KV write
-> 按 page table read back
-> attention output
-> norm output
-> logits

对 KV Cache,最有效的测试之一是写入一个可识别模式,再用消费者的真实寻址路径读回:

expected = k[token_idx, head_idx, :].clone()
 
write_to_paged_cache(...)
actual = read_back_from_cache(...)
 
torch.testing.assert_close(actual, expected)

同时扫描 page、token、head、dim、offset 与不同 layout,不要只做完整模型的端到端回归。

10.3 正确之后,再查隐藏 copy#

一次 100 微秒 copy:

模型加载时一次:通常可以摊销
每个请求一次:需要评估
每层一次:可能明显
每个 decode token、每层一次:通常不可接受

Profiler 中重点搜索:

contiguous
clone
copy_
layout conversion
pack / unpack
stream synchronization

正确性基线与性能基线必须同时建立,否则很容易用一份隐藏的全量 copy 换来“看起来已经修好”。

11. 设计新算子时,怎样写 Layout Contract#

每个自定义算子都应该明确四件事:

1. 必须满足什么布局?
2. 哪些 outer stride 可以变化?
3. 不满足时是报错、dispatch,还是 materialize?
4. fast path 与 fallback 分别由哪些测试覆盖?

一个简单的 Python 边界可以是:

def my_rms_norm(x: torch.Tensor, weight: torch.Tensor) -> torch.Tensor:
    if x.ndim != 3:
        raise ValueError(f"expected 3D input, got shape={tuple(x.shape)}")
 
    if x.stride(-1) != 1:
        raise ValueError(
            "hidden dimension must be contiguous: "
            f"shape={tuple(x.shape)}, stride={tuple(x.stride())}"
        )
 
    if not weight.is_contiguous():
        weight = weight.contiguous()
 
    return torch.ops.my_backend.rms_norm(x, weight)

这段代码不一定是最终最快实现,但它把约束从隐含假设变成了可观测合同。随后可以为常见的 outer-stride layout 增加专用路径,而不是让错误输入进入 Kernel 后静默产生乱码。

12. 怎样真正“利用”stride#

理解 stride 的目标不是把所有 Tensor 强制变成 contiguous,而是让布局变化发生在收益最高、代价最低的位置。

12.1 让热访问维度对应 stride 1#

先从 Kernel 的线程映射出发:

RMSNorm / Softmax: lane 沿 hidden dimension
Attention:         lane 沿 head_dim
Token scan:        lane 沿 token dimension
Grouped GEMM:      同一 Expert 的 token 与 hidden tile

再确定 canonical layout,而不是先选一个“看起来顺眼”的 shape 顺序。

12.2 用 view 消除只改解释的 copy#

当多个连续维满足兼容关系时,合并或拆分维度可以只改 metadata。若一次 permute 破坏了后续 view 条件,可以尝试调整算子与 reshape 的顺序,而不是默认走:

permute -> contiguous -> view

很多算子融合的收益,本质就是让中间布局不再落盘。

12.3 把一次性转换移出热路径#

权重长期只读、重复复用,适合:

checkpoint layout
-> load-time pack / transpose / shard reorder
-> backend canonical layout
-> repeated inference

KV Cache 动态读写,无法用同样方式完全摊销,所以更需要在 allocator、Attention 与 connector 之间共同设计布局。

12.4 为常见布局专门化,而不是追求无限通用#

完全 arbitrary-stride 的单一 Kernel 往往不是最佳工程答案。更实用的是覆盖少数高频布局:

if canonical_nhd:
    return nhd_fast_kernel(...)
 
if canonical_hnd:
    return hnd_fast_kernel(...)
 
if last_dim_contiguous:
    return general_outer_stride_kernel(...)
 
return explicit_fallback(...)

这样既保留常见 view 的零拷贝能力,也能让关键参数在编译期 specialization。

13. 一份用于代码审查的 stride 清单#

最后把最常用的问题压缩成一份 checklist。

正确性#

Tensor 的 shape、stride、storage_offset 分别是什么?
stride 的单位是元素还是字节?
生产者与消费者的完整地址公式是否一致?
是否存在 padding、切片、跨层 view 或 stride 0?
输出是否可能写入 overlapping storage?
Paged Cache 是否测试了 block 1 及之后的位置?

兼容性#

Kernel 支持哪些 canonical layout?
哪些维度必须 stride 1?
是否支持 arbitrary outer stride?
不支持时会明确报错,还是静默 fallback?
backend 切换时是否重新验证 layout contract?

性能#

相邻线程对应的逻辑维度是什么?
访问是否合并,是否满足向量化与对齐?
是否新增 contiguous、pack 或 unpack?
转换是在初始化阶段,还是 decode 热路径?
同一 shape 的多种 stride 是否导致 recompile?
新的 allocation 是否影响 Graph capture?

14. 总结#

Stride 不只是 transpose() 后打印出来的一组数字。它连接了 AI Infra 中几乎所有数据移动与地址生成问题:

逻辑索引
-> Tensor view
-> Kernel 地址计算
-> warp 内存访问
-> Attention / GEMM 布局
-> Paged KV Cache
-> pack / communication / offload
-> compile guard 与 Graph

真正成熟的布局设计,不是让所有 Tensor 都 contiguous,而是做到:

  1. 热路径使用与线程映射匹配的 canonical layout;
  2. 只改变逻辑解释时,尽量使用零拷贝 view;
  3. Kernel 明确声明并验证 stride、offset、对齐与 overlap 合同;
  4. 跨模块传递完整布局信息,而不只传 shape;
  5. 只在必要边界做可解释、可测量的布局转换;
  6. 为 NHD、HND、last-dim-contiguous 等常见布局提供专用路径;
  7. 用非连续、padded、非零 offset 与 block 1+ 的测试守住正确性。

可以用一句话记住本文:

Shape 定义 Tensor“看起来是什么”,stride 定义系统“应该去哪里读写它”。

延伸阅读#

  1. PyTorch Tensor Views
  2. PyTorch torch.Tensor.view
  3. PyTorch torch.Tensor.expand
  4. PyTorch torch.compile Troubleshooting
  5. Triton make_block_ptr
  6. NVIDIA CUDA Programming Guide: Coalesced Global Memory Access
  7. FlashInfer: Append Paged KV Cache
  8. FlashInfer: TRT-LLM Batch Decode with KV Cache
  9. vLLM #47783: Packed KV Layout Stride Mismatch
  10. vLLM #45997: Constant-stride Layer-major KV Cache Layout