这些问题可以从同一套底层模型出发理解:张量如何解释存储、操作是否共享数据,以及执行发生在哪个设备上。本篇以普通 dense strided tensor 为例建立这个模型,再讨论它在 KV cache 读写和设备搬运中的应用;稀疏、量化等特殊张量需要另外检查其存储格式。

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

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

取元素 x[i, j] 不是“查表”,而是一次地址算术:storage[offset + i*stride[0] + j*stride[1]]。这件事是理解后面一切的支点——因为“换一副透镜”(改 shape/stride/offset)几乎免费,而“重排 storage 里的字节”必须真刀真枪地搬数据。

张量的解剖

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

混淆点一:view 还是 copy

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

view vs copy

切片、转置、permute、unsqueeze/squeeze/expand 通常产生共享 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。

同样,.to() 在 device、dtype 等条件已经满足且不要求 copy 时可以返回原张量;.contiguous() 在目标布局已经满足时也返回自身。需要独立副本应明确使用 clone()。detach() 只断开 autograd 关系,仍共享 storage。

混淆点二:连续 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。

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

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

切片 vs 花式索引

以普通 KV buffer 的 K = k_buffer[layer_id][kv_indices] 为例:若 kv_indices 是一维整数 tensor,高级索引读取会按槽位 gather 出独立结果。具体结果布局和下游 kernel 的要求仍需确认,不能从“高级索引”直接推断任意接口都可接受。另一个容易混淆的区别是 buffer[indices] = values 会写回原 buffer,而 buffer[indices].copy_(values) 修改的是读出的临时副本,详见 广播与索引。

混淆点四: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 推理知识的其他层次

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

主题 需要回答的问题
广播与索引 shape 会怎样扩展?读取副本与写回原存储有什么区别?
CUDA 异步执行 CPU 什么时候等待 GPU?如何建立依赖和正确计时?
显存管理与生命周期 谁仍持有 storage?活跃分配、缓存与峰值分别是多少?
dtype 与数值精度 存储、计算和累加是否使用相同精度?误差如何验证?
推理模式与状态 eval、梯度 context 和可变 cache 各由谁管理?
kernel 与编译执行 实际调用哪些 kernel?融合、编译和 graph replay 分别优化什么?

小结

把 dense strided tensor 理解成“一块 storage + 一组解释存储的元数据”,就能区分布局变换、共享存储和实际复制。但完整推理成本还包括搬运、同步、分配以及 kernel 执行。读代码时依次问:数据在哪里、谁共享它、有没有复制或分配、CPU 是否在等 GPU、最终执行什么 kernel;再结合具体输入和 profiler 验证。