这些问题可以从同一套底层模型出发理解:张量如何解释存储、操作是否共享数据,以及执行发生在哪个设备上。本篇以普通 dense strided tensor 为例建立这个模型,再把它落到 LLM 推理里最常见的两处:QKV 的维度变换,以及 KV cache 的读写。稀疏、量化等特殊张量需要另外检查其存储格式。

抽象:张量 = 一块扁平 storage + 一副“透镜”

核心的一句话:张量本身不存数据,它只是一副解读内存的“透镜”。 数据躺在一块一维、连续的 storage(显存或主机内存上的裸 buffer)里;张量对象另外携带一组元数据,决定“怎么把这块一维内存看成多维数组”:

取元素 x[i, j] 不是“查表”,而是一次地址算术:

addr(x[i0, i1, ..., ik]) = storage_offset + Σ i_d × stride[d]

这件事是理解后面一切的支点——因为“换一副透镜”(改 shape/stride/offset)几乎免费,而“重排 storage 里的字节”必须真刀真枪地搬数据。

张量的解剖

用一个具体算例感受三者如何配合。设 a = torch.arange(12).view(3, 4),stride 为 (4, 1)、offset 为 0:

表达式 shape stride offset 取 [1, 1] 落在 storage 的位置
a (3, 4) (4, 1) 0 0 + 1×4 + 1×1 = 5
a[1:, 2:] (2, 2) (4, 1) 6 6 + 1×4 + 1×1 = 11
a.T (4, 3) (1, 4) 0 0 + 1×1 + 1×4 = 5
a[:, ::2] (3, 2) (4, 2) 0 0 + 1×4 + 1×2 = 6

四个张量共享同一块 12 个元素的 storage,没有一个字节被搬动。切片改的是 offset 和 shape,转置交换 stride,步长切片放大 stride。

有了这个模型,“这个操作要不要拷贝”就有了统一判据:它能不能只靠调整元数据来表达? 能,就是 view;不能,就必须新开一块 storage 把数据搬过去。

混淆点一:view 还是 copy,以及 aliasing

view 新建一组元数据、但和原张量共享同一块 storage——所以改一个,另一个跟着变(别名 aliasing),这是 bug 的常见来源,也是零拷贝的来源。copy 则另开 storage,两者内存独立。

view vs copy

切片、转置、permute、unsqueeze/squeeze/expand、split/chunk/unbind 通常产生共享 storage 的 view;高级索引读取 a[idx_tensor]、.clone()、需要重排的 .contiguous()、实际跨设备的 .to() 则会分配新存储。普通计算算子也通常生成独立输出,但不宜把“新输出”全部称为输入数据的 copy。

这里必须区分 view() 与 reshape():view() 只在 shape 与 stride 兼容时成功,否则报错,不会隐式复制;reshape() 则可能返回 view,也可能复制。非连续张量也可能满足某些 view() 的 stride 条件,因此不能仅用 is_contiguous() 判断是否可 view。.view(dtype) 是受元素大小和 stride 约束的位重解释,并不是 .to(dtype) 那样的数值转换。见 Tensor.view 和 Tensor Views。

aliasing 让 in-place 写入有了连带效果。 对 view 执行 add_、copy_、x[...] = v,写的是共享 storage,所有指向这段字节的张量都会“看到”变化:

buf = torch.zeros(8, 4)
row = buf[3]          # view:offset=12, stride=(1,)
row.fill_(1.0)        # buf[3] 也变成全 1——这正是“往预分配 buffer 里写”的方式
tmp = buf[[3]]        # 高级索引读取:新 storage
tmp.fill_(2.0)        # buf 不受影响

推理代码大量依赖前一种行为(向常驻 buffer 原地写入),也常被后一种行为坑到(以为写进了 cache,其实写进了临时副本)。反过来,如果一个张量后面还要被读,就不要对它的 view 做 in-place 修改——别名关系在 Python 代码里是不可见的。

混淆点二:零步长与重叠内存

stride 不一定是正数的乘积,它可以是 0。expand() 正是这么实现广播的:被扩展的维度 stride 设为 0,于是该维每走一步,地址都不动。

x = torch.randn(1024, 1)
y = x.expand(1024, 4096)   # stride (1, 0),逻辑上 4M 个元素,storage 仍是 1024 个

零步长意味着“多个逻辑位置指向同一个物理元素”。读没有问题,写就有歧义:y.add_(1) 会直接报错 more than one element of the written-to tensor refers to a single memory location。需要可写的独立副本时用 repeat() 或 expand(...).clone(),代价是真实分配。广播规则本身见 广播与索引。

as_strided(size, stride, storage_offset) 是最底层的透镜:直接手写元数据,PyTorch 不检查它是否合理。它能造出相互重叠的窗口,例如 torch.arange(6.).as_strided((3, 3), (1, 1)) 得到 [[0,1,2],[1,2,3],[2,3,4]]——三行共享四个元素。这在实现滑动窗口、自定义布局时有用,但写入重叠视图的结果不可依赖,越界的 offset/stride 也只会在访问时出问题。业务代码里遇到 as_strided,要把它当作“这里有一个手工维护的内存布局约定”来读。

混淆点三:连续 vs 非连续

默认 torch.contiguous_format 下,“连续”表示张量能按逻辑顺序紧密遍历,通常最后一维变化最快;大小为 1 等退化维度有特殊判断规则。常规 torch.empty(shape) 创建的张量通常是这种布局,但 empty_strided()、指定 memory format 或保留源布局的创建方式不能一概而论。转置或 permute 只改变 stride,往往得到非连续张量;退化维度或未改变有效顺序时,也可能仍然连续。连续性还与 memory format 有关,例如 channels_last 有自己的判断标准。

连续 vs 非连续

.contiguous() 在目标布局不满足时,新开内存并按当前逻辑顺序重新打包;满足时返回自身。下游 kernel 有的支持任意 stride,有的要求最后一维连续,有的要求整体连续,也有的内部选择复制或不同算法。因此非连续不一定慢,连续也不保证最快。显式添加 .contiguous() 前,应确认 kernel 的真实约束,以及这次搬运是否值得。见 Tensor.contiguous。

几个“复制类”方法经常被混用,按“是否新 storage、是否保留 autograd 关系”区分最清楚:

方法 新 storage? autograd 典型用途
x.detach() 否,共享 断开 拿一个不追踪梯度的别名
x.contiguous() 仅在布局不满足时 保留 满足 kernel 的布局要求
x.clone() 总是 保留 需要独立副本、避免别名写入
x.detach().clone() 总是 断开 快照一份数据

同理,.to() 在 device、dtype 等条件已经满足且不要求 copy 时可以返回原张量本身。需要独立副本应明确使用 clone(),而不是依赖 contiguous() 或 to() 的“副作用”。

混淆点四:切片索引 vs 花式索引

两种“索引”长得像,底层却是两回事。基础切片 a[2:5]、a[:, 0] 取的是连续、等间隔的区间,用 offset + shape + stride 就能表达,所以是 view,零拷贝、与原张量共享内存。花式索引(高级索引)a[idx_tensor] 用一个下标张量去取,下标可以乱序、可以重复,没法用一组规则 stride 表达,于是 torch 只能 gather:按下标把元素一个个挑出来,塞进一块新的、连续的 storage。

切片 vs 花式索引

写方向则相反:buffer[indices] = values 是 index_put_,直接写回原 buffer;而 buffer[indices].copy_(values) 先 gather 出临时副本、再修改这份副本,原 buffer 不变。这一读一写的区别在 KV cache 上体现得最明显,见下文与 广播与索引。

在 LLM 推理中:QKV reshape 里哪一步付出拷贝

Attention 的输入准备是“透镜”模型最密集的使用场景。现代实现把 Q、K、V 三个投影合成一个 GEMM(fused QKV),输出一个 [T, (H + 2·KV)·D] 的连续张量,之后全靠元数据变换拆出各自的视图。下图用 T=4、H=8、KV=2(GQA)、D=4 的小例子跟踪每一步的 shape/stride/offset:

fused QKV 的 split 与 view

GQA 下 K/V 的 head 维度更小,这既是显存收益的来源,也是需要留意的布局差异。Q 有 H 个 head,K/V 只有 KV 个;朴素实现用 repeat_interleave 把 K/V 扩到 H 个 head,就是一次真实复制。更好的做法是让 kernel 直接理解“每 H/KV 个 query head 共用一个 kv head”的映射,K/V 保持原样。

SGLang 中这条链路的对应位置(基于 python/sglang/srt/ 当前代码):

这也说明了一个常见优化方向:与其在 Python 侧补 .contiguous(),不如让 kernel 接受 stride 参数。 能接受 stride 的 kernel,读写的字节数不变,却省掉一次完整的中间复制。

在 LLM 推理中:KV cache 上的 view、gather 与写回

KV cache 是另一个把三种访问方式放在一起的地方:它是一块启动时预分配、整个服务期间常驻的大 buffer,每一步只读写其中一小部分行。

KV cache 上的三种访问

以 mem_cache/memory_pool.py 的 MHATokenToKVPool 为例(默认 NHD 布局):

同一块字节上还有两副更“底层”的透镜:

这就是“透镜”模型在工程上的分量:同一块 KV storage,换不同的元数据就能支持不同的布局与 dtype;而每一个只接受特定 stride 的 kernel,都是对这副透镜的隐含约束。

混淆点五:CPU 与 GPU,以及隐形的同步

在常见独立 NVIDIA GPU 的 PyTorch 使用方式下,CPU 张量位于主机内存,CUDA 张量位于设备内存;普通 CUDA 算子通常要求输入位于兼容的设备上。实际跨设备的 .cuda()/.cpu() 需要传输数据,具体互连取决于硬件。映射主机内存、统一内存和共享内存硬件是另外的机制,不能把“GPU 永远不能访问主机内存”当成硬件定律。这里有两个容易踩的点:

CPU vs GPU
  1. pinned vs pageable:pageable 内存的传输通常涉及 staging 到锁页缓冲区。pinned memory 配合 non_blocking=True 有助于异步 H2D,但计算重叠还需要 copy engine、不同 stream 和独立工作;同 stream 上的计算仍排在拷贝之后。临时 pin_memory() 也有成本,异步 H2D 完成前不能覆盖 pinned 源数据。见 官方搬运教程。
  2. 异步与同步点:kernel 通常异步提交,但 CUDA 张量的 .item()、.tolist() 或打印具体值,需要等待设备结果。默认 GPU 到 CPU 拷贝也会等待;使用异步 D2H 时,CPU 必须在读取前确认完成。默认 .numpy() 不能直接用于 CUDA 张量,通常是在 .cpu() 后转换;CPU 张量的 .numpy() 本身不能一概称为 GPU 同步点。细节与计时方法见 CUDA 异步执行。

Torch 推理知识的其他层次

内存布局之外,还需要分别理解以下问题:

主题 需要回答的问题
数据类型与低精度表示 每个元素占几个字节?.to(dtype) 与 .view(dtype) 有何不同?低精度数据的 scale 存在哪?
广播与索引 shape 会怎样扩展?读取副本与写回原存储有什么区别?
CUDA 异步执行 CPU 什么时候等待 GPU?如何建立依赖和正确计时?
显存管理与生命周期 谁仍持有 storage?活跃分配、缓存与峰值分别是多少?
dtype 与数值精度 存储、计算和累加是否使用相同精度?误差如何验证?
推理模式与状态 eval、梯度 context 和可变 cache 各由谁管理?
算子调度 一次 Python 调用经过哪些 dispatch 层?自定义算子如何注册?
kernel 与编译执行 实际调用哪些 kernel?融合、编译和 graph replay 分别优化什么?
Cuda Graph 图捕获了什么?为什么需要静态地址和私有内存池?

小结

把 dense strided tensor 理解成“一块 storage + 一组解释存储的元数据(shape、stride、storage_offset、dtype)”,就能用一个判据区分布局变换、共享存储和实际复制:能否只改元数据来表达。split、拆最后一维、转置、零步长 expand、view(dtype)、as_strided 都只是换透镜;高级索引读取、合并乱序维度、clone 才搬数据;而 aliasing 让 in-place 写入波及所有共享 storage 的张量——KV cache 的原地写入依赖它,误写临时副本也源于它。QKV reshape 与 KV cache 读写正是这套模型最密集的落点。读代码时依次问:数据在哪里、谁共享它、有没有复制或分配、哪个 kernel 对 stride 有约束、CPU 是否在等 GPU;再结合具体输入和 profiler 验证。