b = a.T到底有没有搬数据?a[1:]和a[idx]看着都是索引,为什么一个共享内存、一个却复制了一份?- 一个张量明明 shape 对得上,喂给 kernel 却报 “not
contiguous”,
.contiguous()加在哪、为什么能省掉? .cpu()、.item()这种“只是取个值”的调用,怎么就成了性能瓶颈?
这些问题可以从同一套底层模型出发理解:张量如何解释存储、操作是否共享数据,以及执行发生在哪个设备上。本篇以普通 dense strided tensor 为例建立这个模型,再讨论它在 KV cache 读写和设备搬运中的应用;稀疏、量化等特殊张量需要另外检查其存储格式。
抽象:张量 = 一块扁平 storage + 一副“透镜”
核心的一句话:张量本身不存数据,它只是一副解读内存的“透镜”。
数据躺在一块一维、连续的
storage(显存或主机内存上的裸
buffer)里;张量对象另外携带一组元数据,决定“怎么把这块一维内存看成多维数组”:
shape:每一维有多长;stride:每一维走一步,在 storage 里要跳过几个元素;offset:从 storage 的第几个元素开始看;dtype/device:按什么类型、在哪块设备上解读。
取元素 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,两者内存独立。
切片、转置、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
有自己的判断标准。
.contiguous()
在目标布局不满足时,新开内存并按当前逻辑顺序重新打包;满足时返回自身。下游
kernel 有的支持任意
stride,有的要求最后一维连续,有的要求整体连续,也有的内部选择复制或不同算法。因此非连续不一定慢,连续也不保证最快。显式添加
.contiguous() 前,应确认 kernel
的真实约束,以及这次搬运是否值得。见 Tensor.contiguous。
混淆点三:切片索引 vs 花式索引
两种“索引”长得像,底层却是两回事。基础切片
a[2:5]、a[:, 0]
取的是连续、等间隔的区间,用 offset + shape + stride
就能表达,所以是
view,零拷贝、与原张量共享内存。花式索引(高级索引)a[idx_tensor]
用一个下标张量去取,下标可以乱序、可以重复,没法用一组规则
stride 表达,于是 torch 只能
gather:按下标把元素一个个挑出来,塞进一块新的、连续的
storage。
以普通 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
永远不能访问主机内存”当成硬件定律。这里有两个容易踩的点:
- pinned vs pageable:pageable
内存的传输通常涉及 staging 到锁页缓冲区。pinned memory 配合
non_blocking=True有助于异步 H2D,但计算重叠还需要 copy engine、不同 stream 和独立工作;同 stream 上的计算仍排在拷贝之后。临时pin_memory()也有成本,异步 H2D 完成前不能覆盖 pinned 源数据。见 官方搬运教程。 - 异步与同步点: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 验证。