🏠 总目录📚 本教程 05 · 内存布局与 stride ← →
📑 本页目录(点开跳转)

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 个元素
同一块连续内存(base)每格 1 个 int64 = 8 Bashape (3, 4) · strides (32, 8)a.Tshape (4, 3) · strides (8, 32)00 B18 B216 B324 B432 B540 B648 B756 B864 B972 B1080 B1188 B行 +1跨 4 格 = +32 B列 +1:跨 1 格 = +8 B行 +1:跨 1 格 = +8 B列 +1跨 4 格 = +32 Ba[2, 1]2×32 + 1×8 = 72 B → 第 9 格转置前后:中间 12 格一字未动,只有两组 strides 互换
看中间那条内存:转置前后它一个字节都没动,动的只是两组箭头的跨度。shape / strides 是说明书,不是另一份数据。

⭐ 有了这两个数,任意下标都能直接算出地址,不需要真的「一行一行找过去」:

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」把同一块内存重新读出来。

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

✅ 检查点

  1. a = np.arange(12).reshape(3, 4) 的 strides 是多少?单位是什么?
  2. 为什么 a.T 对一个巨大的矩阵也是瞬间返回?
  3. a[2, 1] 的字节偏移怎么算出来?算给我看。
  4. a.T.base is a 是 True 还是 False?为什么?那该用什么判断共享内存?
  5. C 序和 F 序存 [[1,2,3],[4,5,6]],内存里的字节顺序分别是什么?
  6. a.T.reshape(12) 会不会拷贝?怎么让「要拷贝就报错」而不是偷偷拷?
  7. a.T.view(np.int8) 报的错原文是什么?它抱怨的到底是哪件事?
  8. 拿 big.sum(axis=1) 和 big.T.sum(axis=0) 比较连续性的影响,错在哪?
  9. as_strided 为什么危险?该用什么替代?
👀 答案
  1. (32, 8),单位是字节。行 +1 挪 32 字节(4 个 int64 = 一整行),列 +1 挪 8 字节(1 个 int64)。
  2. 因为它一个字节都没搬,只是把 shape 和 strides 同时倒过来 —— (3,4)/(32,8) 变成 (4,3)/(8,32)。干活的是后面真正读数据的那一步。
  3. 2 * 32 + 1 * 8 = 72 字节,72 // 8 = 9,即第 9 个元素,a.ravel()[9] == 9 ✅。
  4. False。.base 不是「上一层」,而是最终持有那块内存的数组 —— a.T.base is base 才是 True(base 是原始的 arange(12))。判断共享内存要用 np.shares_memory(a, a.T),它不会被中间层骗到。
  5. 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。
  6. 会拷贝,因为 a.T 不连续(a.T.flags["C_CONTIGUOUS"] 是 False),而且它不报错、悄悄拷。想要拷贝就报错,用直接给 .shape 赋值:a.shape = (4, 3),做不到零拷贝时会抛 AttributeError。
  7. To change to a dtype of a different size, the last axis must be contiguous。它抱怨的不是转置,而是「最后一个轴必须连续」—— view 要重新解释字节,前提是那些字节真的挨在一起。
  8. 错在那两句是同一次内存遍历:big.T 只换了说明书、axis 也跟着换了,走过的字节顺序一模一样,测的根本不是连续性。公平做法是同样的数据、同样的元素个数,只让布局不同(full[:, ::2] vs np.ascontiguousarray(...)),实测 10.46 ms vs 5.98 ms,快 1.75 倍。
  9. 因为它不做任何边界检查 —— 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

打卡记录保存在你的浏览器里,首页能看到总进度