📑 本页目录(点开跳转)
05 · 内存布局与 stride
⏱ 50 分钟 | ⭐ 数组在内存里永远是一条直线,形状只是「怎么读这条直线」的说明书
🎯 一句话
ndarray = 一块连续的字节 + 一份说明书(shape / strides / dtype / offset),转置、切片、reshape 绝大多数时候只改说明书、一个字节都不搬。
上一章你已经知道「视图会跟着变、拷贝不会」。这一章回答为什么 —— 以及为什么有些 reshape 白拿,有些要掏钱。
⚠️ 先消歧,不然你会记混。 站内别处出现的「stride」是卷积的滑动步长(
Conv2d(stride=2),隔一格取一次)。 这一章说的 stride 是内存步长:沿某个轴走一格,地址要挪多少字节。 两者同名不同物,唯一的共同点是「跨着走」这个字面意思。
🧩 一、说明书里有什么
数组本体只是一段字节。剩下的全在说明书里:
import numpy as np
base = np.arange(12)
a = base.reshape(3, 4)
print(a.shape) # (3, 4)
print(a.strides) # (32, 8) ⭐ 单位是【字节】,不是元素
print(a.itemsize) # 8 int64 每个占 8 字节
print(a.dtype) # int64
strides = (32, 8) 读作:
| 动作 | 地址挪多少 |
|---|---|
| 行号 +1(往下走一行) | 32 字节 = 4 个元素 = 一整行 |
| 列号 +1(往右走一格) | 8 字节 = 1 个元素 |
⭐ 有了这两个数,任意下标都能直接算出地址,不需要真的「一行一行找过去」:
import numpy as np
base = np.arange(12)
a = base.reshape(3, 4)
print(a[2, 1]) # 9
off = 2 * a.strides[0] + 1 * a.strides[1] # 2*32 + 1*8
print(off) # 72 ← 字节偏移
print(off // a.itemsize) # 9 ← 第 9 个元素
print(a.ravel()[9]) # 9 ✅ 对上了
这就是索引 O(1) 的全部秘密:一次乘加,不是查表也不是遍历。
🧩 二、转置为什么是免费的
既然地址是算出来的,那么把 strides 换个顺序,就等于换了一种读法:
import numpy as np
a = np.arange(12).reshape(3, 4)
print(a.shape, a.strides) # (3, 4) (32, 8)
print(a.T.shape, a.T.strides) # (4, 3) (8, 32) ⭐ 反过来了
print(np.shares_memory(a, a.T)) # True ⭐ 同一块内存
转置没有搬动任何一个字节,它只是把说明书里的 shape 和 strides 同时倒了个个儿。 原来「往下走 32 字节」,现在改成「往右走 32 字节」—— 同一块内存,换了个读法。
⭐ 这解释了一个常见困惑:为什么
a.T对 100 万行的矩阵也是瞬间返回? 因为它根本没干活。干活的是后面真正读数据的那一步。
🚦 三、⚠️ .base 不是「上一层」,是最终持有内存的那个
上一章教了用 .base 判断是不是视图。这里有个会让你判断错的细节:
import numpy as np
base = np.arange(12)
a = base.reshape(3, 4)
print(a.base is base) # True
print(a.T.base is a) # ⚠️ False —— 直觉会以为 True
print(a.T.base is base) # ⭐ True —— 它一路指回真正持有内存的那个
.base 不是一层一层往回指的。视图的视图,.base 直接指向最终持有那块内存的数组,中间层被跳过了。
⚠️ 所以上一章那个 c.base is b 的写法,只在 b 自己就是内存持有者时才成立。
一旦 b 本身也是个视图,c.base is b 就会给你 False,但它们确实共享内存。
⚠️⚠️ 而且它还会朝另一个方向骗你 —— .base 不是 None,却根本不共享内存:
import numpy as np
base = np.arange(12)
a = base.reshape(3, 4)
for name, x in [("a.T.reshape(12)", a.T.reshape(12)),
("a[1:3, [0,2]]", a[1:3, [0,2]]),
("a[[0,2]]", a[[0,2]])]:
print(f"{name:18} .base is None = {x.base is None:<5} 共享内存 = {np.shares_memory(a, x)}")
# a.T.reshape(12) .base is None = False 共享内存 = False ⚠️
# a[1:3, [0,2]] .base is None = False 共享内存 = False ⚠️
# a[[0,2]] .base is None = True 共享内存 = False
前两个明明是拷贝,.base 却不是 None —— 因为求值过程中产生了一个中间临时数组,
拷贝结果挂在那个临时数组上,而那个临时数组和 a 无关。
⭐ 所以 .base 两个方向都会判错:
is None 不代表拷贝,is not None 也不代表共享。它根本不是用来做这个判断的。
⭐ 稳妥的判据永远是这个:
import numpy as np
base = np.arange(12)
a = base.reshape(3, 4)
print(np.shares_memory(a, a.T)) # True ⭐ 这个不会被中间层骗到
⚠️
np.shares_memory默认做的是精确判断,对巨大数组可能偏慢; 赶时间可以用np.may_share_memory(保守估计,可能误报「共享」但不会漏报)。
🧩 四、C 序和 F 序:同一张表,两种铺法
同样一个 2×3 的表,在内存里可以按行铺,也可以按列铺:
import numpy as np
c = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.int8)
f = np.asfortranarray(c)
print(np.array_equal(c, f)) # True 内容完全一样
print(c.strides, list(c.tobytes("A"))) # (3, 1) [1, 2, 3, 4, 5, 6]
print(f.strides, list(f.tobytes("A"))) # (1, 2) [1, 4, 2, 5, 3, 6]
| strides | 内存里的顺序 | 谁在用 | |
|---|---|---|---|
| C 序(行优先,NumPy 默认) | (3, 1) |
1 2 3 4 5 6 |
C、Python、PyTorch |
| F 序(列优先) | (1, 2) |
1 4 2 5 3 6 |
Fortran、MATLAB、R、BLAS 内部 |
⚠️ 用 dtype=np.int8 是为了让 tobytes 看得清。换成默认的 int64,每个数占 8 字节、前 7 个都是 0,你会看到一串没有区别的零 —— 这也是我第一次跑这个例子时踩的坑。
⭐ 你平时不需要关心序,除非:把数组交给某个 Fortran 血统的库(SciPy 的部分 LAPACK 封装),或者你发现某个操作莫名其妙慢了一截(见第六节)。
🛑 读到这里可以停 —— 前半章讲完了(约 20 分钟)。 你已经知道 shape/strides 是什么、转置为什么免费、
.base会骗人、C 序 F 序的区别。 后半章还有:reshape 什么时候要掏钱 ·.contiguous()到底在修什么 · 连续性对速度的真实影响 ·as_strided手搓滑动窗口。 回来的时候不用重读,直接从下一节接着看就行。
🧩 五、reshape 什么时候白拿,什么时候掏钱
reshape 的规则只有一条:能不能用「一组新的 shape + strides」把同一块内存重新读出来。
- 能 → 返回视图,零成本
- 不能 → 默默拷贝一份,然后在拷贝上 reshape
import numpy as np
a = np.arange(12).reshape(3, 4)
r1 = a.reshape(4, 3)
print(np.shares_memory(a, r1)) # True ⭐ 白拿
r2 = a.T.reshape(12)
print(a.T.flags["C_CONTIGUOUS"]) # False 转置之后不连续了
print(np.shares_memory(a, r2)) # False ⚠️ 悄悄拷了一份
⚠️ 注意 reshape 不报错。它拷贝的时候一声不吭 —— 对 10 个元素无所谓,对 10 GB 的数组就是一次内存翻倍。
⭐ 想要「不许拷贝,要拷就报错给我看」,有个专门的写法:
import numpy as np
a = np.arange(12).reshape(3, 4)
a.shape = (4, 3) # ✅ 直接赋值 shape:不能零拷贝就抛 AttributeError
print(a.shape) # (4, 3)
🧩 六、.contiguous() 到底在修什么
⚠️ 这个方法名是 PyTorch 的(NumPy 里对应的是 np.ascontiguousarray),
但两边修的是同一件事:把一个「strides 乱了」的数组,重新拷贝成一块连续内存。
它存在的唯一原因是:有些操作只写了针对连续内存的快路径,遇到不连续的就罢工或绕远路。
import numpy as np
a = np.arange(12).reshape(3, 4)
try:
a.T.view(np.int8)
except ValueError as e:
print("ValueError:", e)
# To change to a dtype of a different size, the last axis must be contiguous
b = np.ascontiguousarray(a.T) # ⭐ 拷一份连续的
print(b.view(np.int8).shape) # (4, 24) ✅ 通了
⭐ 看清楚报错说的是什么:不是「不能转置」,是「最后一个轴必须连续」。
view 要重新解释字节,前提是那些字节真的挨在一起 —— 转置之后它们不挨着了。
⭐ 三者的分工:
reshape尽量给你视图、不行就偷偷拷贝;view(NumPy 的.view())只改 dtype 解释方式,做不到就报错;ascontiguousarray/.contiguous()明码标价地拷一份,专为消除不连续。
📗 PyTorch 里 .contiguous() 的急救写法(什么时候要在 .transpose() 后面补一个)
在 ML 基础 · 附录C 手撕代码速查 里已经给了,
这一章只解释它为什么必须存在。
🧩 七、连续性对速度的影响有多大
⚠️ 先说一个我自己踩的坑,因为它太容易得出错误结论:
❌ 这个对比是【无效】的:big.sum(axis=1) 对 big.T.sum(axis=0)。
这两句是同一次内存遍历 —— big.T 只换了说明书,axis 也跟着换了,走的字节顺序一模一样。
我第一版就是这么测的,结果「不连续」反而更快,因为测的根本不是连续性。
⭐ 公平的对比要拿同样的数据、同样的元素个数,只让内存布局不同:
import numpy as np, timeit
n = 4000
full = np.ones((n, n))
strided = full[:, ::2] # 隔列取,真正不连续
packed = np.ascontiguousarray(strided) # 同样的数据,连续存一份
print(strided.shape, strided.flags["C_CONTIGUOUS"]) # (4000, 2000) False
print(packed.shape, packed.flags["C_CONTIGUOUS"]) # (4000, 2000) True
t_s = min(timeit.repeat(lambda: strided.sum(), number=3, repeat=5)) / 3
t_p = min(timeit.repeat(lambda: packed.sum(), number=3, repeat=5)) / 3
print(f"不连续 {t_s*1000:.2f} ms 连续 {t_p*1000:.2f} ms 快 {t_s/t_p:.2f}x")
本机(numpy 2.4.6)实测:不连续 10.46 ms,连续 5.98 ms —— 快 1.75 倍,元素个数完全一样。
⚠️ 你的绝对数值会不同(取决于 CPU 缓存大小),但量级差异是结构性的: CPU 一次从内存搬的是一整条 cache line(通常 64 字节), 隔列取意味着搬进来的一半直接扔掉。
⭐ 什么时候值得先
ascontiguousarray一下: 一个不连续的数组要被反复扫描很多次时,先花一次拷贝的钱把它压实,通常划算。 只用一次就不必了 —— 拷贝本身也要遍历一遍。
🧩 八、as_strided:直接手写说明书
既然形状只是说明书,那能不能自己编一份?能,而且这是滑动窗口的经典实现:
import numpy as np
from numpy.lib.stride_tricks import as_strided
v = np.arange(8)
w = as_strided(v, shape=(6, 3), strides=(v.strides[0], v.strides[0]))
print(v)
# [0 1 2 3 4 5 6 7]
print(w)
# [[0 1 2]
# [1 2 3]
# [2 3 4]
# [3 4 5]
# [4 5 6]
# [5 6 7]]
print(np.shares_memory(v, w)) # True ⭐ 零拷贝
6×3 = 18 个数字,背后只有 8 个元素。两个 strides 都等于 8 字节,意思是「行号 +1 和列号 +1 都只挪一格」—— 于是每行自然比上一行错开一位。
💀 但这是本章最危险的一个 API:as_strided 不做任何边界检查。
shape 写大一格,它就去读数组后面那块不属于你的内存 —— 不报错,读出垃圾数,
运气不好直接段错误。
⭐ 实际工作中请用 np.lib.stride_tricks.sliding_window_view,它做同样的事但有边界检查:
import numpy as np
v = np.arange(8)
w = np.lib.stride_tricks.sliding_window_view(v, 3)
print(w.shape) # (6, 3)
print(np.shares_memory(v, w)) # True
as_strided 值得知道,是因为它把「形状只是说明书」这件事演示到了极致 —— 但别在生产代码里用它。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 04 · 视图还是拷贝 | 那一章给你判据(改了会不会串),这一章给你机制(为什么)。⚠️ 回去把 .base 那条改用 np.shares_memory 判,原因见本章第三节 |
| 06 · 索引的四种形态 | 为什么基础切片是视图、花式索引不是 —— 答案就是「能不能用一组 strides 表示」 |
| 11 · 从 NumPy 到 PyTorch | PyTorch 的张量是同一套说明书(storage + shape + stride + offset),.contiguous() 是同一件事 |
| ML 基础 · 附录C 手撕代码速查 | .contiguous() 的急救写法在那里:什么时候要在 .transpose() 后面补一个 |
| 框架底下是 C++ · 03 · cache 与数据布局 | 本章第七节那个 1.75 倍差在硬件上是怎么来的:cache line、预取、AoS/SoA |
✅ 检查点
a = np.arange(12).reshape(3, 4)的strides是多少?单位是什么?- 为什么
a.T对一个巨大的矩阵也是瞬间返回? a[2, 1]的字节偏移怎么算出来?算给我看。a.T.base is a是True还是False?为什么?那该用什么判断共享内存?- C 序和 F 序存
[[1,2,3],[4,5,6]],内存里的字节顺序分别是什么? a.T.reshape(12)会不会拷贝?怎么让「要拷贝就报错」而不是偷偷拷?a.T.view(np.int8)报的错原文是什么?它抱怨的到底是哪件事?- 拿
big.sum(axis=1)和big.T.sum(axis=0)比较连续性的影响,错在哪? as_strided为什么危险?该用什么替代?
👀 答案
(32, 8),单位是字节。行 +1 挪 32 字节(4 个 int64 = 一整行),列 +1 挪 8 字节(1 个 int64)。- 因为它一个字节都没搬,只是把 shape 和 strides 同时倒过来 ——
(3,4)/(32,8)变成(4,3)/(8,32)。干活的是后面真正读数据的那一步。 2 * 32 + 1 * 8 = 72字节,72 // 8 = 9,即第 9 个元素,a.ravel()[9] == 9✅。False。.base不是「上一层」,而是最终持有那块内存的数组 ——a.T.base is base才是True(base是原始的arange(12))。判断共享内存要用np.shares_memory(a, a.T),它不会被中间层骗到。- C 序
[1, 2, 3, 4, 5, 6](按行铺),F 序[1, 4, 2, 5, 3, 6](按列铺)。strides 分别是(3,1)和(1,2)。⚠️ 要用dtype=np.int8才看得出来,int64 前 7 个字节全是 0。 - 会拷贝,因为
a.T不连续(a.T.flags["C_CONTIGUOUS"]是False),而且它不报错、悄悄拷。想要拷贝就报错,用直接给.shape赋值:a.shape = (4, 3),做不到零拷贝时会抛AttributeError。 To change to a dtype of a different size, the last axis must be contiguous。它抱怨的不是转置,而是「最后一个轴必须连续」——view要重新解释字节,前提是那些字节真的挨在一起。- 错在那两句是同一次内存遍历:
big.T只换了说明书、axis也跟着换了,走过的字节顺序一模一样,测的根本不是连续性。公平做法是同样的数据、同样的元素个数,只让布局不同(full[:, ::2]vsnp.ascontiguousarray(...)),实测 10.46 ms vs 5.98 ms,快 1.75 倍。 - 因为它不做任何边界检查 —— shape 写大一格就会去读不属于你的内存,不报错、读出垃圾数,运气不好直接段错误。生产代码用
np.lib.stride_tricks.sliding_window_view,同样零拷贝但有边界检查。
🛑 可以停在这里
⚡ 走神救援
⭐ 数组在内存里永远是一条直线,形状只是一份「怎么读这条直线」的说明书:shape + strides + dtype + offset。
np.arange(12).reshape(3, 4)的 strides 是(32, 8),单位是字节:行号 +1 挪 32 字节(一整行),列号 +1 挪 8 字节(一个元素)。任意下标一次乘加就出地址 ——a[2,1]=2*32 + 1*8 = 72字节 = 第 9 个元素。这就是索引 O(1) 的全部秘密。转置因此是免费的:a.T只把 shape 和 strides 同时倒过来,一个字节都没搬。⚠️ 判断共不共享内存永远用
np.shares_memory,别用.base—— 它两个方向都骗人:a.T.base is a是False(它指向最终持有内存的那个,不是上一层);而a.T.reshape(12)明明是拷贝,.base却不是None(挂在中间临时数组上)。C 序按行铺、F 序按列铺。
reshape的规则只有一条:能不能用一组新的 shape+strides 读同一块内存 —— 能就白拿视图,不能就一声不吭地拷贝一份。想要「要拷贝就报错」,用a.shape = (4,3)这种直接赋值的写法。连续性的真实代价实测 1.75 倍(4000×2000,不连续 10.46 ms vs 连续 5.98 ms)—— CPU 一次搬一整条 cache line,隔列取等于搬进来一半就扔。
as_strided能手写说明书做出零拷贝滑动窗口,但它不做边界检查,生产代码用sliding_window_view。
下一节 👉 06-索引的四种形态.md