0%

NumPy 核心原理

NumPy 核心原理:搞懂内存结构和广播,性能问题不用猜

用 NumPy 的人很多,但遇到性能问题或奇怪报错的时候,大多数人是靠试——换个写法试试,不行再换。靠猜也能解决问题,但下次遇到类似问题还是懵。

搞懂 ndarray 的内存结构和广播机制,能让你从”靠猜”变成”靠判断”。

  • 为什么 a[1:3, 2:5] 这种切片几乎不花时间?
  • 为什么修改切片有时候会影响原数组,有时候不影响?
  • 为什么同样的运算,某些写法快几十倍?

答案都在 ndarray 的内存模型里。

ndarray 长什么样?数据 + 说明书

一个 ndarray 对象在内存里就两样东西:

  1. 实际数据:一块连续的内存
  2. 元数据:描述怎么解读这块内存
1
2
3
4
5
6
7
8
┌─────────────────────────────────────────┐
│ ndarray 对象 │
├─────────────────────────────────────────┤
│ data → 数值数据(连续内存) │
│ dtype → 元素类型(int32 / float64) │
│ shape → 各轴长度 (3, 4) │
│ strides → 各轴步长 (16, 4) │
└─────────────────────────────────────────┘

关键认知:数据只存一份。 切片、转置、大部分 reshape 都是只改说明书(元数据),不改数据本身。

dtype:每个元素占多少字节

dtype 告诉你每个元素占几个字节、怎么解释这串字节。

1
2
3
4
import numpy as np

np.dtype('int32').itemsize # 4 字节
np.dtype('float64').itemsize # 8 字节

当你从二进制文件(WAV 音频、BMP 图片)读数据时,dtype 必须跟文件格式匹配,否则读出来的数据完全错乱。这不是”报错”,而是”数据对不上但程序不吭声”,更难排查。

strides:步长,理解 NumPy 性能的钥匙

strides 记录了在某个轴上前进 1 步,内存里要跳过多少字节

1
2
3
4
a = np.array([[1, 2, 3, 4],
[5, 6, 7, 8]], dtype=np.float64)

print(a.strides) # (32, 8)
  • strides[0] = 32:行前进 1 步 = 4 个元素 × 8 字节 = 32 字节
  • strides[1] = 8:列前进 1 步 = 1 个元素 × 8 字节 = 8 字节

切片 a[1:3, 1:3] 几乎不花时间——它只是新建了一个 ndarray 对象,改了 shapestrides数据没动

1
2
b = a[1:3, 1:3]
b.base is a # True —— 共享同一块数据

转置也是改步长:

1
2
c = a.T
c.strides # (8, 32) —— 步长交换了

reshape 不一定”免费”:

1
2
3
4
5
#  免费:数据在内存里是连续的
a.reshape(2, 6)

# 可能触发复制:转置后数据不连续
a.T.reshape(2, 6) # 可能产生副本

因为 a.T 的数据在内存里不连续,没法只靠改元数据来 reshape。

内存布局:C 风格 vs Fortran 风格

1
2
3
4
5
# C 风格(行优先):最后一轴变化最快
c_style = np.arange(12).reshape(3, 4)

# Fortran 风格(列优先):第一轴变化最快
f_style = np.array(c_style, order='F')

调用底层库(特别是 LAPACK 这种 Fortran 写的)时,内存布局不匹配会触发隐式复制,拖慢性能。知道这个,就能在创建数组时直接指定 order,避免后面被动复制。

广播机制:不同形状也能运算

广播允许不同形状的数组做算术运算,不复制数据

1
2
3
4
5
6
a = np.array([[1, 2, 3],
[4, 5, 6]]) # shape (2, 3)
b = np.array([10, 20, 30]) # shape (3,)

c = a + b # 结果 (2, 3)
# b 被"广播"到每一行

广播的两条规则(背下来):

  1. 从最后一个维度开始往前比对
  2. 两个维度要么相等,要么其中一个是 1,要么不存在
1
2
3
4
5
6
7
# 可以广播
a.shape: (2, 3, 4)
b.shape: (3, 4) # 4=4✓, 3=3✓, a 多出一个2 → 广播

# 无法广播
a.shape: (2, 3)
b.shape: (4) # 3≠4 ✗ → ValueError

广播的实际场景

场景1:标准化数据

1
2
3
4
data = np.random.rand(100, 50)
mean = data.mean(axis=0) # shape (50,)
std = data.std(axis=0) # shape (50,)
normalized = (data - mean) / std # 广播自动处理

场景2:外积

1
2
3
x = np.array([1, 2, 3])   # (3,)
y = np.array([4, 5, 6]) # (3,)
outer = x[:, np.newaxis] * y # (3,1) × (3,) → (3,3)

场景3:生成网格(画 3D 图常用)

1
2
3
4
5
X, Y = np.meshgrid(
np.linspace(-1, 1, 100),
np.linspace(-1, 1, 100)
)
Z = np.sin(X**2 + Y**2)

广播 vs 手动扩展:内存差异巨大

1
2
3
4
5
6
7
8
9
a = np.random.rand(10000, 100)
b = np.random.rand(100)

# 广播:不复制数据
result1 = a + b

# 手动扩展:多占了 10000*100*8 字节
b_expanded = np.tile(b, (10000, 1))
result2 = a + b_expanded

两者结果一样,但广播的内存效率高得多。数据量小看不出来,数据量大的时候直接决定程序能不能跑得动。

欢迎关注我的其它发布渠道