Aphasia

Cogito ergo sum.

NumPy 是 Python 数值计算生态的地基:pandas、SciPy、scikit-learn 构建在它之上,PyTorch、JAX 的张量语义也直接继承了它。即使日常主要使用 PyTorch、TensorRT 或 vLLM,理解 NumPy 仍然是在理解数值计算世界的“通用语言”。

NumPy 更重要的地位,是为 Python 数组生态提供了一套共同的心智模型。pandas 在数组之上增加标签、缺失值和异构列;SciPy 增加稀疏结构与科学算法;PyTorch 增加设备、自动微分和神经网络;JAX 增加函数变换、JIT 与多设备执行;CuPy 把大量 NumPy API 搬到 GPU;Arrow 则专注跨语言列式交换。它们共享 shape、dtype、广播和向量化语义,却在可变性、设备、执行时机与内存所有权上做出了不同选择。

因此,我更愿意把 NumPy 看成数组计算世界的“基准实现”:它足够简单,能让每次 view、copy、广播和类型提升都被看见;又足够接近硬件,可以作为理解更复杂张量框架和数据工具的起点。本文不只讲 API,也会沿着这条生态脉络回答:哪些 NumPy 经验可以迁移,哪些相似接口背后其实有不同执行语义。

PyTorch、TensorRT、vLLM 与 CUDA 算子的很多设计哲学,如连续性、广播、步长和 kernel 调度,本质上与 NumPy 的底层原理高度同构:torch.Tensor.stride() / contiguous() / view() 几乎就是 NumPy strides / flags / reshape 的另一套表达;GPU coalesced access 关注的仍是相邻线程能否访问连续地址;PagedAttention 解决的也是逻辑连续视图与物理分页之间的映射问题。

所以这篇文章不以罗列 API 为目标,而是试图回答三个问题:

  1. 原理: ndarray 到底是一块什么样的内存?为什么切片和转置通常不拷贝?
  2. 接口: NumPy 的类型系统、数组创建与操作函数,如何围绕这套内存模型设计?
  3. 应用: 用 NumPy 写有限差分求解器和 Transformer Decoder,在工程上说明了什么?

先给出一张贯穿全文的生态定位表:

生态在 NumPy 数组模型上增加什么与 NumPy 最关键的差异
pandas标签索引、异构列、缺失值、表格操作运算会按标签对齐,不只是按位置广播
SciPy稀疏数组、优化、积分、信号、统计算法与数据结构更专业,仍大量返回 ndarray
PyTorchGPU、自动微分、神经网络与分布式训练Tensor 带 device、梯度历史和训练语义
JAXjitgradvmap、XLA 与多设备Array 不可变,执行可能编译且异步
CuPyCUDA 数组、GPU kernel 和 NumPy 兼容 API数据位于显存,kernel launch 与传输成本显著
Arrow跨语言列式格式、NULL、变长与嵌套类型面向交换的不可变 Buffer,不是通用数值计算引擎

NumPy 核心

内存模型

参考 NumPy 官方文档:The N-dimensional array

一个 ndarray 可以理解为两部分:一段一维数据缓冲区,以及描述这段缓冲区的元数据。

  • Data Buffer: 原始内存,固定宽度元素通常紧凑排列。
  • Shape: 每个维度的长度,例如 (3, 4)
  • Dtype: 单个元素的类型、字节宽度与解释方式。
  • Strides: 沿每个维度前进一步,需要跨过多少字节。
  • Offset / data pointer: 当前视图第一个元素相对底层缓冲区的位置。

例如:

1import numpy as np
2
3# arange(stop, dtype) 生成 [0, stop) 的等差序列;reshape(3, 4) 将 12 个元素组织为 3 行 4 列。
4# reshape 在 strides 兼容时返回视图,否则可能复制或报错。
5a = np.arange(12, dtype=np.int64).reshape(3, 4)
6
7print(a.shape)    # (3, 4)
8print(a.strides)  # (32, 8)

int64 每个元素占 8 字节,因此逻辑下标 (i, j) 对应的物理偏移是:

1offset(i, j) = i * strides[0] + j * strides[1]
2             = i * 32 + j * 8

这里没有 Python 对象逐个装箱,也没有哈希查找;定位一个元素只是整数乘加。连续区间上的循环还可以继续被 C 编译器、SIMD 或 BLAS 优化。这就是 NumPy 能接近内存带宽上限的基础。

 1ndarray object
 2┌───────────────────────────┐
 3│ Python Object Header      │
 4├───────────────────────────┤
 5│ Array Metadata            │
 6│   dtype   : float32       │
 7│   shape   : (B,H,S,D)     │
 8│   strides : (s0,s1,s2,s3)│
 9│   data ptr / offset       │
10└──────────────┬────────────┘
111213Data Buffer ──► [0.12 | -0.45 | 0.88 | 1.02 | ...]

视图与零拷贝

理解 strides 后,很多“魔法”就只是修改元数据:

1a.T          # shape (4, 3), strides 从 (32, 8) 变为 (8, 32)
2a[:, 1]      # shape (3,), strides (32,), 起点偏移 8 字节
3a[::2]       # 第 0 维 stride 翻倍
4a[::-1]      # 第 0 维使用负 stride

这些操作通常返回视图,与原数组共享缓冲区。修改视图可能反映到原数组:

1# arange(6) 生成整数 0 到 5;未显式 dtype 时使用平台默认整数类型。
2x = np.arange(6)
3y = x[1:4]
4y[0] = 100
5print(x)  # [  0 100   2   3   4   5]

需要独立所有权时应显式调用 copy()。更重要的是,不要把“API 看起来没复制”直接等同于“整个表达式没有分配”:视图可能零拷贝,但后续加法、乘法、类型转换仍然通常会生成输出数组。

reshape 也不是无条件零拷贝。数据布局与目标形状兼容时,它可以只修改元数据;不兼容时可能复制,或者直接报错。判断时可以检查:

1print(a.flags["C_CONTIGUOUS"])
2print(a.flags["F_CONTIGUOUS"])
3# shares_memory(a, b) 判断两个数组是否可能引用同一底层内存,而不是比较数值是否相等。
4print(np.shares_memory(a, a.T))

np.lib.stride_tricks.as_strided 可以直接操纵 strides,构造滑动窗口等视图,但它绕过了很多安全检查。生产代码优先使用边界更清晰的 sliding_window_view

连续性与数据搬运

多头注意力经常执行:

1(B, S, H, D) -> transpose -> (B, H, S, D)

转置后的逻辑维度发生变化,底层数据却没有重排,因此数组往往不再 C-contiguous。NumPy 的通用算子可以按 stride 读取它,但底层矩阵库或 GPU kernel 可能要求连续输入,于是框架会显式或隐式地产生一次 contiguous copy。

这提示我们:零拷贝不天然等于高性能。 一个跨大步长访问的视图虽然没有复制,却可能带来更多 cache miss、TLB miss 和无效内存事务;有时先付出一次线性重排,反而能让后续多个算子更快。PyTorch/CuPy 的底层 kernel 同样偏好连续或规则布局;JAX 则可能在编译阶段消除中间转置,也可能为了目标设备生成完全不同的物理布局,因此不能把 NumPy 中的 view 成本机械套用到编译型框架。

NumPy 默认使用 C order,也支持 Fortran order。线性代数例程可能对接 BLAS/LAPACK;如果输入布局与底层库期望不匹配,就可能发生隐式转换。因此性能分析不能只看 Python 代码中有没有 copy(),还要结合 flags、峰值内存和 profiler 判断真正的物化位置。

生态视角:相似的 Array,不同的执行契约

ndarray 是一个驻留在 CPU 内存、可变、支持任意 strides 的稠密数组。其他生态保留了相似接口,却改变了其中至少一个前提:

  • pandas 的 Series/DataFrame 增加 Index、缺失值和多种 ExtensionArray,to_numpy() 可能因为类型统一或缺失值而复制;
  • SciPy sparse 只存非零值和索引,节省稀疏数据的内存,但切片、reshape 与赋值不再像稠密数组一样自由;
  • PyTorch Tensor 增加 device 与 autograd;CPU Tensor 可以和 NumPy 共享内存,GPU Tensor 则不能直接变成 NumPy view;
  • JAX Array 不可变,reshape/transpose 的中间副本可能由 XLA 在 jit 后消除;
  • CuPy ndarray 位于 GPU 显存,API 相似不代表 dtype 转换、越界索引和连续性细节完全一致;
  • Arrow Array 由 values、validity bitmap、offsets 等 Buffer 组成,优先保证跨语言交换与不可变共享。

这也是阅读“NumPy-compatible”时最重要的判断:兼容的往往是表层 API 和数学语义,不一定包括可变性、同步行为、异常、内存位置和零拷贝条件。NumPy 官方的互操作指南把 buffer protocol、__array__、Array API 和 DLPack 串成了更完整的边界。

广播机制

如果说 strides 解释了“一个数组如何拥有不同视图”,那么 broadcasting 解释的就是“不同形状的数组如何参加同一个逐元素运算”。官方规则见 Broadcasting:从尾部维度向前比较,两维相等或其中一维为 1 时兼容;缺失维度视为 1。

1a: (3, 1)          b: (1, 4)          a + b: (3, 4)
2┌───┐              ┌───┬───┬───┬───┐  ┌───┬───┬───┬───┐
3│ a │              │ b │ b │ b │ b │  │a+b│a+b│a+b│a+b│
4├───┤              └───┴───┴───┴───┘  ├───┼───┼───┼───┤
5│ a │                                  │a+b│a+b│a+b│a+b│
6├───┤                                  ├───┼───┼───┼───┤
7│ a │                                  │a+b│a+b│a+b│a+b│
8└───┘                                  └───┴───┴───┴───┘

所谓“拉伸”是逻辑上的。实现上可以把长度为 1 的维度视为 stride 0,重复读取同一值,而不是先复制成完整数组。

 1# default_rng(seed) 创建独立的 Generator;固定 seed=42 便于复现实验。
 2rng = np.random.default_rng(42)
 3# normal(size=...) 从标准正态分布采样;astype(float32) 把默认 float64 结果转换为 float32。
 4x = rng.normal(size=(1000, 64)).astype(np.float32)
 5
 6# mean(axis=0) 沿样本维求每一列均值;keepdims=True 保留为 (1, 64),便于广播。
 7mean = x.mean(axis=0, keepdims=True)  # (1, 64)
 8# std 的 axis/keepdims 语义与 mean 相同,返回每一列的总体标准差(默认 ddof=0)。
 9std = x.std(axis=0, keepdims=True)    # (1, 64)
10x_norm = (x - mean) / std
11
12# arange(4, dtype=float32) 生成 [0,1,2,3];[:, None] 增加长度为 1 的轴,得到 (4, 1)。
13row_weight = np.arange(4, dtype=np.float32)[:, None]  # (4, 1)

keepdims=True 是广播的最佳搭档:归约后保留长度为 1 的维度,结果就能直接与原数组运算。

广播需要警惕三类问题:

  1. 形状正确但语义错误。 (B, S, D)(S,) 有时能广播,有时直接报错;即使通过,也未必沿着预期维度计算。
  2. 广播本身不复制,结果仍要物化。 (B, H, S, D) 的乘法输出仍然占完整大小。
  3. 巨型中间结果。 两个小数组可能广播成远大于输入的输出,内存与计算量由结果形状决定。

PyTorch、JAX 和 CuPy 大体沿用 NumPy 从尾部维度匹配的广播规则,所以 shape 推理经验可以迁移。但执行成本不能直接迁移:NumPy/CuPy 的逐个 eager 表达式通常各自产生输出或启动 kernel;JAX 在 jit 中可能把多步逐元素计算融合为一个程序。pandas 更特殊——Series/DataFrame 会先按标签对齐,再做逐元素运算;两个 shape 相同但 Index 不同的对象,也可能产生并集后的缺失值。接口看起来都是 a + b,真正决定结果的可能是 shape,也可能是 label、device 或编译边界。

BLAS/LAPACK:NumPy 线性代数的底层引擎

NumPy 的数组遍历、ufunc 与部分归约由自身的 C 实现完成,但矩阵乘、线性方程、特征值和奇异值分解等重型线性代数通常不会从头实现,而是下沉到 BLAS/LAPACK。理解这层边界,才能解释为什么同一段 A @ B 在不同机器、不同 NumPy 安装方式下,性能可能相差明显。

 1Python: np.matmul / np.linalg.solve / np.linalg.svd
 2 3 4NumPy C 层:校验 shape、dtype、广播、布局,准备输入输出 Buffer
 5 6 7BLAS/LAPACK 接口:GEMM、GESV、GESDD 等标准例程
 8 910具体实现:OpenBLAS / MKL / Accelerate / BLIS / Netlib ...
111213CPU kernel:SIMD、cache blocking、packing 与多线程

这里要区分“接口标准”和“实现库”:BLAS/LAPACK 定义函数做什么、参数如何传递;OpenBLAS、Intel MKL、Apple Accelerate 等负责针对具体 CPU、cache 和 SIMD 指令实现这些函数。NumPy wheel 通常已经链接了其中一个后端,用户调用的仍然是同一套 np.linalg API。

BLAS:把基础线性代数分成三个层级

BLAS(Basic Linear Algebra Subprograms)按运算粒度分为三个 Level:

层级典型运算计算量常见瓶颈
Level 1vector-vector:dot、axpy、norm\(O(n)\)内存带宽与调用开销
Level 2matrix-vector:gemv\(O(n^2)\)数据复用有限,通常仍偏带宽受限
Level 3matrix-matrix:gemm\(O(n^3)\)数据复用高,最容易利用 SIMD、多核和 cache blocking

例如:

 1import numpy as np
 2
 3# arange(stop, dtype) 创建连续向量;这里显式使用 float64 与后续矩阵 dtype 保持一致。
 4x = np.arange(4, dtype=np.float64)
 5# 第二次 arange 使用相同 stop/dtype,得到与 x shape 和类型一致的向量。
 6y = np.arange(4, dtype=np.float64)
 7# dot(a, b) 对两个一维数组计算内积,对应 BLAS Level 1 的 dot 语义。
 8inner = np.dot(x, y)
 9
10# array(object, dtype) 创建 2×2 稠密矩阵;二维矩阵默认使用 C order。
11A = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64)
12# array 为 B 创建同样的 2×2 float64 稠密矩阵,满足 matmul 的内维匹配要求。
13B = np.array([[2.0, 0.0], [1.0, 2.0]], dtype=np.float64)
14# matmul(A, B) 对二维输入执行矩阵乘,底层通常进入 BLAS Level 3 GEMM。
15C = np.matmul(A, B)
16# A @ x 是矩阵-向量乘,数学语义对应 BLAS Level 2 GEMV。
17z = A @ x[:2]

为什么 GEMM 是高性能计算的核心?朴素矩阵乘中,每加载一块数据可以参与多次乘加;优化实现会先把矩阵分块和 packing,再让小块停留在 cache/寄存器中反复使用。相比之下,向量加法通常是“读两个值、写一个值、只做一次加法”,算术强度低,更容易被内存带宽限制。因此,“用了 BLAS”不等于每个线性代数操作都能达到峰值 FLOPS,问题规模和 BLAS Level 同样重要。

LAPACK:用分解组织完整的数值算法

LAPACK(Linear Algebra PACKage)建立在 BLAS 之上,提供线性方程、最小二乘、特征值、SVD 等更完整的算法。以:

1# array([1,0], dtype) 创建 float64 右端向量;linalg.solve(a, b) 求解 a @ x = b。
2# a 必须是方阵,b 可以是一组或多组右端项。
3solution = np.linalg.solve(A, np.array([1.0, 0.0], dtype=np.float64))

为例,float64 的一般方阵通常进入 LAPACK dgesv 家族,其核心不是直接计算逆矩阵,而是:

  1. 使用带部分主元选择的 LU 分解,将 \(A\) 写成 \(P A = L U\);
  2. 先解下三角系统 \(L y = P b\);
  3. 再解上三角系统 \(U x = y\)。

例程名前缀也携带 dtype:s/d 通常表示 float32/float64c/z 表示 complex64/complex128。NumPy 根据输入 dtype 选择对应路径,所以 dtype 不只决定精度和内存,也决定调用哪个底层 kernel。

NumPy API典型数学路径LAPACK 例程家族示例
np.linalg.solveLU 分解 + 三角求解*gesv
np.linalg.qrHouseholder QR*geqrf + *orgqr/*ungqr
np.linalg.svd奇异值分解*gesdd
np.linalg.eigh对称/Hermitian 特征分解*syevd/*heevd
np.linalg.choleskyCholesky 分解*potrf
np.linalg.lstsq最小二乘/秩判定*gelsd

这也解释了一个重要实践:需要解 \(A x=b\) 时优先使用 solve(A, b),不要先写 inv(A) @ b。显式求逆通常需要更多工作和中间结果,也更容易放大数值误差;专业求解器会直接利用分解完成目标任务。

Shape、strides 与隐式复制

经典 BLAS/LAPACK 源于 Fortran,天然偏好列主序;现代 C 接口可以通过 leading dimension 和 transpose flag 适配部分 C/F order 布局。但任意切片、负 stride、错位 dtype 或不满足例程约束的数组,仍可能在 NumPy 包装层被复制到临时连续 Buffer。

1# array(..., order="C") 创建行主序矩阵。
2A_c = np.array([[1.0, 2.0], [3.0, 4.0]], order="C")
3# asfortranarray(a) 在必要时复制为列主序;输入已是 F-contiguous 时直接复用。
4A_f = np.asfortranarray(A_c)
5
6print(A_c.flags["C_CONTIGUOUS"])  # True
7print(A_f.flags["F_CONTIGUOUS"])  # True

不要因此机械地把所有输入都转成 Fortran order:转换本身也要读写整块内存,是否值得取决于后续会复用多少次、底层例程是否能直接处理当前布局。更稳妥的做法是结合 flags、峰值内存和基准测试,判断复制发生在哪里。

后端、线程与可复现的性能测试

NumPy 构建系统会自动探测 BLAS/LAPACK,常见实现包括 OpenBLAS、MKL、Accelerate、FlexiBLAS、BLIS 和参考 Netlib 实现。可以从当前环境直接检查:

1# show_config() 打印 NumPy 构建期依赖,可查看链接的 BLAS/LAPACK 名称与编译信息。
2np.show_config()
3# show_runtime() 打印运行时 SIMD 与 BLAS 线程后端信息;具体字段随构建方式而异。
4np.show_runtime()

做性能分析时还要注意:

  1. 线程不由 NumPy 单独决定。 OpenBLAS/MKL/Accelerate 可能自行启动线程;如果外层 Python、joblib 或 PyTorch 也并行,容易出现线程过量竞争。
  2. 小矩阵不一定受益。 参数检查、函数分派、线程启动和 packing 都有固定成本,小问题可能由开销主导。
  3. 布局与 dtype 必须一致。 隐式转换和临时连续化可能比计算本身更贵。
  4. 基准要固定后端和线程数。 否则比较的可能是 OpenBLAS 与 MKL、单线程与多线程,而不是两种 NumPy 写法。
  5. 批量小矩阵要单独评估。 np.linalg 支持在前导维度广播,但大量小矩阵与一个大 GEMM 的硬件利用率完全不同。

在其他生态中,PyTorch CPU 也常链接 BLAS/MKL,CuPy/PyTorch CUDA 通常依赖 cuBLAS/cuSOLVER,JAX/XLA 则可能把线性代数降到目标设备库并与周边算子融合。数学算法可以相近,dispatch、布局、融合和同步模型却不同。因此 NumPy 最适合建立算法与数值基线,跨框架性能结论仍需要在各自执行后端上重新测量。


NumPy API

NumPy 的 API 很多,但可以沿着内存模型划分为四组:

  1. Data Type: 每个元素如何解释、占多少字节。
  2. Array Creation: 缓冲区从哪里来、由谁拥有。
  3. Array Manipulation: 只改元数据,还是搬运数据。
  4. Functions: 如何在连续或带 stride 的数组上批量执行 kernel。

完整入口可参考 NumPy User Guide。下面不追求 API 穷举,而是抓住最影响工程判断的部分。

Data Type

NumPy 的基础 dtype 接近 C 类型系统:同一个数组中的元素通常具有固定宽度,因此可在缓冲区中紧凑排列。

类别常见 dtype字节数典型用途
布尔bool_1mask;注意并非 bit-packed
有符号整数int8/16/32/641/2/4/8ID、计数、量化值
无符号整数uint8/16/32/641/2/4/8字节、位图、图像
浮点float16/32/642/4/8数值计算、模型权重
复数complex64/1288/16FFT、波动、RoPE 原型
时间datetime64/timedelta648时间点与时间差
字符串SUStringDType固定或变长文本处理
对象object_指针宽度Python 对象;失去纯连续值语义

几个对工程实践影响很大的事实:

  • Python float 构造数组时通常得到 float64;整数默认宽度取决于平台。跨库和跨节点协议应显式声明 dtype。
  • NumPy 2.x 按 NEP 50 调整了标量类型提升规则。不要把隐式 promotion 当成稳定的业务协议。
  • 定宽整数会溢出。聚合、平方和或时间差运算要主动评估中间类型宽度。
  • float64 精度高但占用两倍于 float32 的内存带宽;在许多分析和推理负载里,瓶颈首先是 bandwidth,而不是 FLOPS。
  • arr.nbytes == arr.size * arr.itemsize,它是核对数组真实数据体积的第一工具。

生态之间的 dtype 也不是无损同构。pandas/Arrow 需要 nullable integer、字符串、时区和 decimal 等更丰富的逻辑类型;PyTorch/CuPy 更关注适合计算设备的数值 dtype;JAX 的类型提升和 64 位配置与 NumPy 并不完全相同。跨库传递数组时,至少要核对 dtype、device、nullable、字节序和写权限,不能只看 shape 与打印值。

complex64 与 RoPE

complex64 在内存中由两个相邻的 float32 组成。可以用视图在“实部/虚部交错”与复数之间切换:

1import numpy as np
2
3# array(..., dtype=complex64) 从 Python 复数创建数组,每个元素由两个 float32 组成。
4z = np.array([1.0 + 2.0j, 3.0 + 4.0j], dtype=np.complex64)
5
6real_view = z.real               # stride 8
7imag_view = z.imag               # stride 8,起点多 4 字节
8# view(float32) 只重解释同一缓冲区,不做数值转换;每个 complex64 展开为两个 float32。
9float_view = z.view(np.float32)  # [1., 2., 3., 4.]

RoPE 把相邻两个维度看成二维平面旋转,用复数表达非常自然:

 1def apply_rotary_emb_complex(
 2    x: np.ndarray,
 3    base: float = 10000.0,
 4) -> np.ndarray:
 5    """x: (seq_len, head_dim), float32,且 head_dim 为偶数。"""
 6    # asarray(x, dtype) 尽量复用输入;仅当类型不匹配时转换为 float32。
 7    x = np.asarray(x, dtype=np.float32)
 8    seq_len, head_dim = x.shape
 9
10    if head_dim % 2 != 0:
11        raise ValueError("head_dim must be even")
12    if x.strides[-1] != x.itemsize:
13        # ascontiguousarray 仅在布局不连续时复制,确保最后一维可按复数对读取。
14        x = np.ascontiguousarray(x)
15
16    # arange(start, stop, step, dtype) 取得偶数维索引 0,2,...,每两个实数维度组成一个复数维度。
17    dim = np.arange(0, head_dim, 2, dtype=np.float32)
18    inv_freq = 1.0 / (base ** (dim / head_dim))
19    # arange(seq_len) 生成 token 位置;显式 float32 避免后续提升为 float64。
20    positions = np.arange(seq_len, dtype=np.float32)
21    # outer(a, b) 计算外积,得到 (seq_len, head_dim/2) 的旋转角矩阵。
22    angles = np.outer(positions, inv_freq)
23    # exp(1j*angles) 计算单位复数旋转因子;astype(complex64) 压缩到两个 float32。
24    factors = np.exp(1j * angles).astype(np.complex64)
25
26    # view(complex64) 将最后一维相邻两个 float32 零拷贝解释成一个复数。
27    x_complex = x.view(np.complex64)
28    rotated_complex = x_complex * factors
29    # view(float32) 再把复数结果零拷贝展开回实数表示,输出 shape 与 x 相同。
30    return rotated_complex.view(np.float32)

这里要精确区分三件事:

  • x.view(np.complex64) 只是重解释已有字节,可以零拷贝;
  • 复数乘法会创建 rotated_complex,不是零分配;
  • 如果 x 的最后一维不连续,ascontiguousarray 会先复制。

这比简单地宣称“RoPE 是零拷贝”更接近真实系统。类型视图节省了重排代码,但算子输出、连续化和缓存仍然需要内存。

Array Creation

数组创建函数可以按数据来源分成四类。

从 Python 对象创建

1np.array([1, 2, 3], dtype=np.int32)  # array(object, dtype):复制/转换 Python 序列为 int32 数组。
2np.asarray(existing_array, dtype=np.float32)  # asarray:类型已匹配时尽量复用,否则转换为 float32。
3np.fromiter((i * i for i in range(10)), dtype=np.int64)  # fromiter(iterable, dtype):从迭代器单遍构造数组。

np.array 默认倾向创建独立数组;np.asarray 在输入已经满足 dtype/layout 时可以直接复用。接口选型本质上是在表达“我是否要求新的所有权”。

按规则生成

1np.eye(3)  # eye(N):创建 3×3 单位矩阵;可用 k 参数选择偏移对角线。
2np.diag(np.array([1, 2, 3]))  # array 创建向量;diag(v) 把向量放到主对角线。
3np.arange(0, 10, 2)  # arange(start, stop, step):生成 [0,10) 内步长为 2 的整数。
4np.linspace(2.0, 3.0, 5)  # linspace(start, stop, num):含两端点地生成 5 个等距值。
5np.logspace(2.0, 3.0, 5)  # logspace(start, stop, num):生成 10**2 到 10**3 的 5 个对数等距值。

整数索引使用 arange,浮点区间采样优先使用 linspace,避免浮点步长累积误差和端点歧义。

分配并填充

1np.zeros((1024, 64), dtype=np.float32)  # zeros(shape, dtype):分配并以 0 初始化。
2np.ones_like(x)  # ones_like(a):创建与 x 的 shape、dtype 默认相同且全为 1 的数组。
3np.full((4, 4), fill_value=-np.inf, dtype=np.float32)  # full:用 -inf 填满指定 shape。
4np.empty((4096,), dtype=np.int64)  # empty:只分配、不初始化;使用前必须完整覆盖。

empty 只分配、不清零,适合随后必定完整覆盖的输出缓冲;绝不能依赖其中的初始值。PyTorch 和 CuPy 也提供同名接口,但返回 Buffer 所在设备不同;JAX 的函数式语义则更鼓励让编译器规划中间 Buffer,而不是在 Python 层手工复用可变数组。

从缓冲区或文件创建

 1raw = bytearray(4 * 1024)
 2# frombuffer(buffer, dtype) 直接把可缓冲对象解释为数组;默认 count=-1 表示读取全部内容。
 3view = np.frombuffer(raw, dtype=np.float32)
 4
 5# memmap(filename, dtype, mode, shape) 建立文件映射数组;mode="r" 表示只读且不把全文件载入内存。
 6mapped = np.memmap(
 7    "vectors.bin",
 8    dtype=np.float32,
 9    mode="r",
10    shape=(1000, 128),
11)

frombuffermemmap 是 NumPy 接入共享内存、文件格式和其他语言运行时的关键入口。它们可以避免一次用户态复制,但仍要处理生命周期、字节序、对齐与 schema。底层 owner 一旦释放或内容被修改,视图也会受到影响。

在主要生态之间,互操作大致有三条路径:pandas 通过 to_numpy()/np.asarray() 转换;PyTorch CPU Tensor 可通过 torch.from_numpy() 与 ndarray 共享内存;PyTorch、CuPy、JAX 等设备数组可使用 DLPack 交换 Buffer。Arrow 到 NumPy 只有在物理类型兼容、无 NULL、通常还是单一连续 chunk 时才可能零拷贝,而且得到的 view 往往不可写。真正的“零拷贝”不是某一个 API 的标签,而是 dtype、device、布局、所有权和生命周期共同满足约束。

Array Manipulation

数组操作首先要问:它只修改元数据,还是必须搬数据?

视图优先的操作

1a.reshape(3, 4)  # reshape(*shape):改变逻辑形状;布局兼容时返回视图。
2a.transpose(1, 0)  # transpose(*axes):按给定轴顺序置换维度,通常只修改 strides。
3a[None, ...]
4a.swapaxes(-1, -2)  # swapaxes(axis1, axis2):交换最后两个轴,负数轴从末尾计数。
5a.view(np.float32)  # view(dtype):重解释底层字节,不执行数值类型转换。
6a.ravel()  # ravel(order="C"):拉平成一维并尽量返回视图。

这些操作不保证永远零拷贝,但都有机会复用原缓冲区。

明确物化的操作

1np.concatenate([x, y], axis=0)  # concatenate:沿已有第 0 轴拼接,其他轴必须兼容;总会物化输出。
2np.stack([x, y], axis=0)  # stack:新增第 0 轴后堆叠,输入 shape 必须完全一致。
3np.tile(x, (2, 3))  # tile(a, reps):沿两个维度分别重复 2、3 次,会真实复制数据。
4np.repeat(x, 2, axis=0)  # repeat(a, repeats, axis):把第 0 轴的每个元素重复两次。
5np.pad(x, 1, mode="constant")  # pad(a, pad_width, mode):每个轴两端各补 1 个常量值。
6a.flatten()  # flatten(order="C"):拉平成一维且总是返回副本。

concatenate 是最容易被低估的操作:输出需要一块新的连续缓冲区,再把所有输入复制进去。在循环中不断追加数组会反复搬运历史数据,复杂度可能从线性退化成二次。这个问题在 PyTorch/CuPy 中还会叠加显存带宽与 kernel 调度成本;JAX 则通常要求提前确定 shape,让编译器一次规划结果 Buffer。

索引与 selection

基本切片通常返回视图;整数数组索引和布尔索引通常返回副本:

1a[1:10:2]       # basic slicing,通常是 view
2a[[0, 2, 5]]    # fancy indexing,通常是 copy
3a[a > 0]        # boolean indexing,通常是 copy
4
5a[a < 0] = 0
6positive = np.where(a > 0, a, 0)  # where(condition, x, y):逐元素按条件选择,支持广播。
7indices = np.nonzero(a > 0)  # nonzero(condition):返回每个维度的命中下标数组元组。

pandas 的布尔过滤会保留标签语义,NumPy/CuPy/PyTorch 的布尔索引更接近按位置 gather,并通常物化新数组。JAX 还需要面对编译期 shape:nonzerounique 等数据相关输出在 jit 中往往需要显式给出静态 size。因此“同一个过滤表达式”跨生态迁移时,不仅要问 view 还是 copy,也要问结果 shape 能否在编译期确定。

归约与张量表达式

1x.sum(axis=0, keepdims=True)  # sum(axis=0):沿第 0 轴归约;keepdims 保留长度为 1 的轴。
2x.argmax(axis=-1)  # argmax(axis=-1):返回最后一维最大值的位置,而不是最大值本身。
3np.cumsum(x, axis=0)  # cumsum(a, axis):沿第 0 轴计算包含当前位置的累计和。
4
5np.einsum("bij,bjk->bik", A, B)  # einsum:按下标规则执行批量矩阵乘,j 为归约维。
6np.einsum("bi,bi->b", x, y)  # 对每个 batch 的 i 维做点积,输出 shape 为 (b,)。
7np.einsum("bhsd,bhtd->bhst", q, k)  # 对 d 维缩并,生成注意力分数 (b,h,s,t)。

einsum 可以表达转置、外积、批量矩阵乘与缩并,但表达能力强不代表一定选择到最优 kernel。NumPy 会调用自身与 BLAS 路径,PyTorch/CuPy 面向 CPU/GPU kernel,JAX/XLA 还可能跨表达式融合和重写。声明式下标只是第一步,最终性能仍取决于 contraction 顺序、布局、设备和中间结果大小。

Other Functions

NumPy 的 routines 大多是 ufunc 或建立在 ufunc 之上的批量算子。它们也是其他数组库最常复刻的一层 API,但“函数同名”只说明入口相似,不能保证边界行为、异步性和性能模型相同。

Bit-wise

位运算适合整数位图、特征开关、二值向量与布隆过滤器原型:

 1# array(..., dtype=bool) 创建布尔 mask;普通 bool_ 每个元素占 1 字节,并非位压缩。
 2mask = np.array([True, False, True, True, False, True, False, False])
 3# packbits(a) 按每 8 个布尔值压成一个 uint8;默认沿扁平化数组打包。
 4packed = np.packbits(mask)
 5# unpackbits(a) 将字节展开为 bit;切片去掉最后一个字节中可能存在的补齐位。
 6restored = np.unpackbits(packed)[: mask.size]
 7
 8# array(..., dtype=uint8) 明确用单字节无符号整数保存位模式。
 9a = np.array([0b11010100], dtype=np.uint8)
10# 第二个 array 使用相同 dtype,保证 xor 两侧具有一致的字节宽度和位语义。
11b = np.array([0b10110111], dtype=np.uint8)
12# bitwise_xor 逐位异或;bitwise_count 再统计每个整数中置 1 的 bit 数,即汉明距离。
13distance = np.bitwise_count(np.bitwise_xor(a, b))

packbits 可以把布尔值压缩 8 倍,但 NumPy 普通 bool_ 数组本身仍是一字节一个值。Arrow BooleanArray 的 values 和 validity 则天然使用 bit-packed Buffer;pandas 的 nullable boolean 还需要同时表达 true、false 与缺失。相同的“布尔数组”,物理表示可能是一字节值、一个位图,或者 values + validity 两个位图。

String

NumPy 2.x 提供 np.strings 批量字符串函数:

1# array 从字符串序列推断字符串 dtype;显式 dtype 可控制固定宽度或使用 StringDType。
2names = np.array(["  Alice ", "BOB", "Carol"])
3# strings.lower 逐元素转小写;strings.strip 再移除每个字符串两端空白。
4clean = np.strings.strip(np.strings.lower(names))
5# strings.startswith(a, prefix) 逐元素判断是否以 "a" 开头,返回布尔数组。
6starts = np.strings.startswith(clean, "a")

字符串也揭示了 ndarray 模型的边界:U8 以固定宽度存储,可能浪费空间;StringDType 是变长类型,数据不再简单地完全内联在主缓冲区。pandas 的 StringDtype 和 Arrow string 更强调缺失值、offsets + bytes 与跨系统交换;PyTorch/JAX/CuPy 则主要面向数值张量,并不把通用字符串计算作为核心能力。字符串生态的分界远比数值 dtype 明显。

Datetime

datetime64timedelta64 本质上是带单位的 64 位整数:

1# arange(start, stop, dtype=datetime64[D]) 按天生成左闭右开的日期序列。
2days = np.arange("2026-01-01", "2026-02-01", dtype="datetime64[D]")
3weekly = days[::7]
4delta = days - days[0]
5# astype(datetime64[M]) 把日期转换到月粒度;这不是用固定秒数除法计算月份。
6months = days.astype("datetime64[M]")

单位决定精度和范围,月份也不是固定秒数。pandas 在 datetime64 基础上增加 timezone-aware Timestamp、缺失时间和更完整的时间序列索引;Arrow 则把 timestamp unit 与 timezone 放入逻辑类型元数据。转成纯 NumPy 时,时区信息可能被转为对象,也可能统一到 UTC 后丢弃原时区表达。

Logic

1np.logical_and(a > 0, a < 10)  # logical_and(x1, x2):逐元素逻辑与,两个条件可广播。
2np.isfinite(scores)  # isfinite:标记既不是 NaN、也不是正负无穷的元素。
3np.isnan(scores)  # isnan:逐元素检测 IEEE NaN,返回布尔数组。
4np.allclose(actual, expected, rtol=1e-5, atol=1e-7)  # 判断所有元素是否满足绝对/相对误差阈值。

浮点近似判断应使用 isclose/allclose,而不是直接 ==。同时要记住 NumPy NaN、pandas NA/NaT、Arrow validity bitmap 和 Python None 是不同的缺失语义;聚合是否跳过缺失值也因 API 而异。跨生态核对结果时,缺失值与容差策略往往比公式本身更容易出错。

Random

新代码应使用 Generator API:

1# default_rng(seed) 使用推荐的 Generator API;相同 seed 与调用顺序可复现。
2rng = np.random.default_rng(42)
3# sqrt(64) 给出缩放分母;normal(loc, scale, size) 采样 64×64 权重;astype 转成 float32。
4w = rng.normal(0.0, 1.0 / np.sqrt(64), size=(64, 64)).astype(np.float32)
5# choice(a, size, replace=False) 从 [0,a) 无放回抽取 128 个整数。
6idx = rng.choice(50_257, size=128, replace=False)
7
8# spawn(n) 从当前 Generator 派生 n 个统计独立的子随机流,适合并行 worker。
9worker_rngs = rng.spawn(4)

NumPy 官方提供 Generator.spawn 为并行任务派生独立随机流。PyTorch 也使用显式 Generator 管理设备侧状态;JAX 则采用函数式 key,调用方必须显式 split 并传递子 key,更适合并行变换。只有 seed 还不够:调用顺序、设备、算法实现和子随机流分配都会影响能否复现。

Mathematical

1np.exp(x)  # exp:逐元素计算 e**x。
2np.log1p(x)  # log1p:计算 log(1+x),x 接近 0 时比直接写 log(1+x) 更稳定。
3np.expm1(x)  # expm1:计算 exp(x)-1,小 x 时减少相消误差。
4np.clip(x, lo, hi)  # clip(a, min, max):把值截断到闭区间 [lo, hi]。
5np.gradient(x)  # gradient:用有限差分估计梯度;默认样本间距为 1。
6
7np.exp(x, out=x)  # out=x 指定写回原数组,避免额外输出分配;会覆盖旧值。

out= 允许调用方管理输出 Buffer,减少临时数组。PyTorch/CuPy 的许多数值算子也提供 out 或原地版本;JAX Array 不可变,通常由 XLA 在编译后进行 Buffer donation、融合和复用。两种路线的目标相同——控制中间分配——但一个把责任交给调用方,另一个更多交给编译器。

Set

1# unique(a, return_counts=True) 返回排序后的唯一值以及每个值的出现次数。
2values, counts = np.unique(labels, return_counts=True)
3# isin(element, test_elements) 逐元素判断 labels 是否属于 allowed_classes。
4keep = np.isin(labels, allowed_classes)
5clean = X[keep]

NumPy 的集合例程通常面向一维稠密数组并返回排序后的结果。pandas 的 Index/Series 集合操作还要保留标签和缺失值语义;JAX 的 unique 为了兼容 jit 增加了静态 size 等约束;CuPy 虽提供相似 API,成本则由 GPU 排序、同步和数据传输共同决定。当集合很大时,算法、设备与输出 shape 比函数名更重要。

Sorting

1order = np.argsort(scores)  # argsort:返回完整升序排列对应的原始下标。
2topk = np.argpartition(scores, -k)[-k:]  # argpartition(kth=-k):只保证最大的 k 项位于末尾,内部未排序。
3bucket = np.searchsorted(edges, values)  # searchsorted:在有序 edges 中二分查找每个 value 的插入位置。
4multi_key = np.lexsort((secondary_key, primary_key))  # lexsort(keys):稳定多键排序,最后一个 key 是主键。

Top-K 不必完整排序,argpartition 可以先做选择。PyTorch 提供直接返回 values/indices 的 topk,JAX 有适合编译路径的 lax.top_k,CuPy 则在 GPU 上执行对应选择或排序。比较性能时要同时核对结果是否有序、稳定性、轴语义,以及 GPU 调用是否包含了同步时间。

Window

1# hanning(M) 生成长度 256 的 Hann 窗,端点趋近于 0,用于减轻频谱泄漏。
2w = np.hanning(256)
3# fft.rfft(a) 对实数输入做一维 FFT,只返回非负频率的 N/2+1 个复数结果。
4spectrum = np.fft.rfft(signal[:256] * w)
5
6# sliding_window_view(x, window_shape=5) 用 strides 创建长度 5 的重叠窗口视图。
7windows = np.lib.stride_tricks.sliding_window_view(x, 5)
8# mean(axis=1) 对每个窗口归约,得到移动平均;窗口视图本身不复制,结果会分配。
9moving_avg = windows.mean(axis=1)

sliding_window_view 通过 strides 生成重叠视图,不复制窗口内容。但窗口数乘窗口宽度仍决定后续算子的逻辑工作量。pandas rolling 更适合带索引的时间窗口与缺失值处理;SciPy signal 提供卷积、滤波和频谱算法;PyTorch/JAX 则常把滑窗降为 convolution 或编译后的 gather。选择哪一层 API,取决于你需要的是数组视图、时间序列语义,还是设备侧高吞吐 kernel。


NumPy 应用

NumPy 的价值不仅是“能算”,更在于把数学公式快速翻译成可执行、可验证的 reference implementation。它可以生成测试向量、验证 PyTorch/JAX/CuPy 实现、定义边界语义,也能在引入自动微分、JIT 或 GPU 前暴露 shape、dtype 与算法复杂度。

一维泊松方程的数值求解

考虑一维泊松方程:

$$ -u''(x) = f(x), \qquad u(0)=u(1)=0 $$

中心差分近似为:

$$ u''(x_i) \approx \frac{u_{i-1} - 2u_i + u_{i+1}}{h^2} $$

令解析解为 \(u(x)=\sin(\pi x)\),则 \(f(x)=\pi^2\sin(\pi x)\)。离散后得到三对角线性方程组:

 1import numpy as np
 2
 3N = 100
 4h = 1.0 / N
 5# linspace(start, stop, num) 在闭区间 [0,1] 生成 N+1 个等距网格点,包含两个边界。
 6x = np.linspace(0.0, 1.0, N + 1)
 7x_inner = x[1:-1]
 8n = N - 1
 9
10A = (
11    # ones(n) 生成主对角线系数;diag(v, k=0) 把它放到主对角线。
12    np.diag(2.0 * np.ones(n))
13    # diag(v, k=1) 构造上偏移一格的对角线;ones(n-1) 与其长度匹配。
14    + np.diag(-1.0 * np.ones(n - 1), k=1)
15    # k=-1 表示下偏移一格的对角线,从而组成三对角矩阵。
16    + np.diag(-1.0 * np.ones(n - 1), k=-1)
17)
18
19# sin 对 x_inner 逐元素计算正弦;np.pi 提供双精度圆周率常量。
20f = (np.pi ** 2) * np.sin(np.pi * x_inner)
21b = (h ** 2) * f
22
23# linalg.solve(A, b) 求解 A @ u = b;要求 A 为方阵且与 b 维度兼容。
24u_inner = np.linalg.solve(A, b)
25# concatenate(seq, axis=0) 把左边界、内部解和右边界拼成完整解,会分配新数组。
26u_numerical = np.concatenate(([0.0], u_inner, [0.0]))
27# sin 逐元素计算解析解,用于和数值结果核对。
28u_exact = np.sin(np.pi * x)
29
30# abs 先计算逐元素绝对误差;max 再归约得到无穷范数误差。
31max_error = np.max(np.abs(u_numerical - u_exact))
32print(f"N={N}, L_inf error={max_error:.3e}")

np.linalg.solve 会调用底层 LAPACK _gesv 路径,而不是在 Python 中逐行做高斯消元。这个例子有三层价值:

  1. 数学可验证。 N 增大时可以直接观察中心差分的二阶收敛。
  2. 实现透明。 每段代码都能对应到离散方程。
  3. 复杂度暴露。 np.diag 构造稠密矩阵需要 \(O(N^2)\) 内存,通用稠密求解约为 \(O(N^3)\);真正的大规模三对角系统应使用 \(O(N)\) 专用算法或稀疏矩阵库。

这也说明 NumPy 与 SciPy 的典型分工:NumPy 稠密实现便于直接对应公式、验证误差;规模扩大后,应改用 scipy.sparse.diags 表达三对角结构,并交给 scipy.sparse.linalg 的稀疏求解器。NumPy reference code 负责定义正确性,SciPy 的专业数据结构与算法负责兑现复杂度。

LLM 应用

下面用 NumPy 实现一层简化 Transformer Decoder,覆盖 RMSNorm、RoPE、KV Cache、因果注意力与 SwiGLU。权重是随机的,它的目标不是生成有意义的文本,而是展示 shape、stride、广播和 buffer management 如何共同组成推理过程。

更准确地说,这段代码提取了 LLaMA 2QwenMistral 7B 等 Decoder-only LLM 共享的教学骨架,而不是对任一生产模型的逐参数、逐算子复刻。我们暂时拿掉 tokenizer、embedding、几十层堆叠、LM Head 和采样,把注意力集中在一个 token 如何穿过一层 Decoder,以及推理状态如何被保存

先把单层 Decoder 放回完整 LLM

一个 Decoder-only LLM 的推理主链路可以先压缩成:

 1文本
 2  ↓ tokenizer
 3token ids
 4  ↓ embedding
 5隐藏状态 x: (B, S, D)
 6  ↓ Decoder Layer × N      ← 本节只展开其中一层
 7最终 RMSNorm
 8  ↓ LM Head
 9下一 token 的 logits
10  ↓ sampling / argmax
11新的 token

因此,下面的 TransformerDecoderLayer.forward 接收的已经不是 token id,而是 embedding 或上一层输出的隐藏状态 x;它输出相同的 (B, S, D) shape,以便继续送入下一层。代码中的随机输入和随机权重只能验证数学、shape 与缓存语义,不能生成有意义的语言。

这一层采用现代 LLM 常见的 Pre-Norm + Residual 结构:

$$ x_{mid} = x + \operatorname{Attention}(\operatorname{RMSNorm}(x)) $$$$ x_{out} = x_{mid} + \operatorname{SwiGLU}(\operatorname{RMSNorm}(x_{mid})) $$

两条残差路径让信息可以绕过注意力和 FFN 直接向后传播;归一化放在子层之前,则让每个子层看到尺度更稳定的输入。这里的 RMSNorm 只根据均方根缩放,不像 LayerNorm 那样先减均值:

$$ \operatorname{RMSNorm}(x) = \frac{x}{\sqrt{\operatorname{mean}(x^2)+\epsilon}} \odot w $$

它的意义不是一句笼统的“更快”,而是在保留重缩放能力的同时省掉 re-centering;实际收益还取决于 dtype、张量大小与是否使用 fused kernel。

一层里究竟发生了什么

可以把 forward 理解为两条数据通路:一条负责在 token 之间交换信息,另一条负责对每个 token 独立做非线性变换。

  1. RMSNorm:在最后一个维度 D 上归一化每个 token。
  2. Q/K/V 投影:把同一个隐藏状态投影为 Query、Key、Value,并把 D 拆成 H × HD
  3. RoPE:把位置信息编码成 Q、K 各二维分量上的旋转。代码用复数乘法直观表达 RoPE;生产 kernel 通常直接融合实数域的 sin/cos 计算,并不要求真的创建复数 Tensor。
  4. KV Cache:把本次生成的 K、V 追加到历史缓存;Q 只属于当前查询,不需要跨步保存。
  5. 因果注意力:计算 $\operatorname{softmax}(QK^T/\sqrt{HD})V$,保证 token 只能读取自己及之前的位置。
  6. 输出投影与第一条残差:合并所有 head,映射回 D 后与输入相加。
  7. SwiGLU FFN 与第二条残差:用一条分支产生门控值,另一条分支产生内容,再逐元素相乘并投影回 D。这种门控 FFN 来自 GLU Variants Improve Transformer 中讨论的 SwiGLU 变体。

用代码中的符号跟踪 shape,比只记公式更容易发现错误:

阶段shape含义
输入隐藏状态(B, S, D)batch、当前输入长度、模型维度
拆分多头后的 Q/K/V(B, H, S, HD)D = H × HD
累积后的 K/V Cache(B, H, T, HD)T 是包含历史在内的总长度
注意力分数(B, H, S, T)当前 S 个 Query 分别读取 T 个位置
注意力输出(B, H, S, HD)每个 head 聚合自己的 Value
合并多头(B, S, D)回到残差连接要求的 shape

这里最值得盯住的是 ST:Prefill 时通常 S = T > 1;逐 token Decode 时 S = 1,而 T 会随生成不断增长。

Prefill、Decode 与 KV Cache

同一个 forward 在推理中承担两种不同工作负载:

1Prefill(一次输入整个 prompt)
2S = 5, T = 5     Q @ Kᵀ → (5 × 5),需要因果 mask
3
4Decode(追加一个新 token)
5S = 1, T = 6     Q @ Kᵀ → (1 × 6),读取已有 K/V
6下一步
7S = 1, T = 7     Q @ Kᵀ → (1 × 7),继续复用历史 K/V

Prefill 有较大的矩阵乘,通常更容易利用并行算力;Decode 每步只有一个或少量 Query,却要反复读取模型权重和不断增长的 KV Cache,因此更容易受到内存带宽与调度延迟限制。这也是为什么真实推理系统会分别优化 prefill throughput 与 decode latency。

KV Cache 的核心收益是不再为历史 token 重算 K、V,也不再让历史 token 重新穿过整层网络。但它不会把全部注意力计算神奇地变成 $O(N)$:固定 hidden size 时,第 $t$ 个新 Query 仍需读取并匹配 $t$ 个历史 Key,因此带 Cache 的自回归注意力累计仍是 $O(N^2)$;没有 Cache、每一步都重跑完整前缀时,注意力部分累计可达到 $O(N^3)$。与此同时,缓存容量会线性增长:

$$ \text{KV bytes} = 2 \times L \times B \times H_{kv} \times T \times HD \times \text{bytes(dtype)} $$

其中 2 代表 K 与 V,L 是层数。下面的教学代码只有一层,并令 H_kv = H

它与 LLaMA、Qwen、Mistral 的边界

这些模型共享 RMSNorm、RoPE、门控 FFN、因果自注意力等思想,但具体实现并不相同:

能力本节 NumPy 实现生产模型中的常见变化
注意力 headQ/K/V 都有 H 个 head,即标准 MHA不少模型使用 GQA/MQA,让多个 Query head 共享较少的 K/V head
注意力窗口读取全部历史位置Mistral 7B 的论文重点之一是 GQA 与 Sliding Window Attention
RoPE固定 base、复数视图实现不同模型可能改变 RoPE base、缩放策略、最大上下文和 kernel 实现
KV Cache单层、单 batch、预分配连续数组真实服务还要处理多层、多请求、分页、量化、淘汰与调度
数值与执行CPU float32、逐算子 eager 执行常见 BF16/FP16/FP8/INT8/INT4、算子融合、GPU/NPU 与张量并行

因此,读这份代码时应把它当成 reference implementation:它足够小,可以检查每个数组;又足够完整,可以建立通往真实 LLM 推理引擎的概念地图。Tokenizer、embedding、多层堆叠、最终归一化、LM Head、采样、反向传播、批处理调度和量化并没有在这里实现。

推荐按下面的顺序阅读代码:

  1. 先只看 forward,找到两次 RMSNorm、两条残差以及 Attention/FFN 两个子层。
  2. 沿注释记录每一步 shape,特别检查 transpose 后的轴顺序和广播维度。
  3. 运行一次 PREFILL_LEN = 5,确认输出仍是 (1, 5, 64),Cache 长度变为 5。
  4. 再跟踪三次 S = 1 的 Decode,观察 Cache 长度递增为 6、7、8,而当前输出始终是 (1, 1, 64)
  5. 最后再分别进入 RMSNorm、RoPE、稳定 softmax 与 SwiGLU,理解它们如何由基础 NumPy 操作拼起来。
  1import numpy as np
  2
  3
  4def rms_norm(x: np.ndarray, weight: np.ndarray, eps: float = 1e-6) -> np.ndarray:
  5    # mean(..., axis=-1, keepdims=True) 计算每个 token 最后一维的均方,并保留归约轴用于广播。
  6    variance = np.mean(x * x, axis=-1, keepdims=True)
  7    # sqrt 逐元素开方;eps 防止方差为 0 时除零。
  8    return x * (1.0 / np.sqrt(variance + eps)) * weight
  9
 10
 11def silu(x: np.ndarray) -> np.ndarray:
 12    # exp(-x) 逐元素计算指数,组成 SiLU:x / (1 + exp(-x))。
 13    return x / (1.0 + np.exp(-x))
 14
 15
 16def precompute_freqs_cis(
 17    head_dim: int,
 18    max_seq_len: int,
 19    base: float = 10000.0,
 20) -> np.ndarray:
 21    # arange(start, stop, step, dtype) 取得偶数维索引;两个实数维组成一个复数旋转维。
 22    dim = np.arange(0, head_dim, 2, dtype=np.float32)
 23    inv_freq = 1.0 / (base ** (dim / head_dim))
 24    # arange(max_seq_len) 生成全部 token 位置,显式 float32 控制内存和运算类型。
 25    positions = np.arange(max_seq_len, dtype=np.float32)
 26    # outer(positions, inv_freq) 计算外积,输出 (max_seq_len, head_dim/2) 角度矩阵。
 27    angles = np.outer(positions, inv_freq)
 28    # exp(1j*angles) 生成单位复数;astype(complex64) 把实部、虚部限制为 float32 精度。
 29    return np.exp(1j * angles).astype(np.complex64)
 30
 31
 32def apply_rotary_emb(x: np.ndarray, freqs_cis: np.ndarray) -> np.ndarray:
 33    """x: (B, H, S, HD), float32,且最后一维连续。"""
 34    # asarray(x, dtype) 在输入已经是 float32 ndarray 时复用,否则执行类型转换。
 35    x = np.asarray(x, dtype=np.float32)
 36    if x.shape[-1] % 2 != 0:
 37        raise ValueError("head_dim must be even")
 38    if x.strides[-1] != x.itemsize:
 39        # ascontiguousarray 只在需要时复制,保证相邻两个 float32 在物理上连续。
 40        x = np.ascontiguousarray(x)
 41
 42    # view(complex64) 将最后一维的 float32 两两零拷贝重解释为复数。
 43    x_complex = x.view(np.complex64)
 44    factors = freqs_cis[None, None, :, :]
 45    # 复数乘法完成旋转;view(float32) 再零拷贝展开为原来的实数 shape。
 46    return (x_complex * factors).view(np.float32)
 47
 48
 49class KVCache:
 50    def __init__(
 51        self,
 52        batch_size: int,
 53        num_heads: int,
 54        max_seq_len: int,
 55        head_dim: int,
 56    ) -> None:
 57        shape = (batch_size, num_heads, max_seq_len, head_dim)
 58        # empty(shape, dtype) 仅分配 K Cache,不初始化;后续 append 会覆盖有效区间。
 59        self.k = np.empty(shape, dtype=np.float32)
 60        # V Cache 与 K Cache 使用相同 shape 和 float32 物理类型。
 61        self.v = np.empty(shape, dtype=np.float32)
 62        self.length = 0
 63
 64    def append(
 65        self,
 66        k: np.ndarray,
 67        v: np.ndarray,
 68    ) -> tuple[np.ndarray, np.ndarray]:
 69        step = k.shape[2]
 70        end = self.length + step
 71        if end > self.k.shape[2]:
 72            raise ValueError("KV cache capacity exceeded")
 73
 74        self.k[:, :, self.length:end, :] = k
 75        self.v[:, :, self.length:end, :] = v
 76        self.length = end
 77        return self.k[:, :, :end, :], self.v[:, :, :end, :]
 78
 79
 80class TransformerDecoderLayer:
 81    def __init__(
 82        self,
 83        d_model: int,
 84        num_heads: int,
 85        d_ff: int,
 86        max_seq_len: int,
 87        rng: np.random.Generator,
 88    ) -> None:
 89        if d_model % num_heads != 0:
 90            raise ValueError("d_model must be divisible by num_heads")
 91
 92        self.d_model = d_model
 93        self.num_heads = num_heads
 94        self.head_dim = d_model // num_heads
 95        self.freqs = precompute_freqs_cis(self.head_dim, max_seq_len)
 96
 97        # sqrt(d_model) 计算 Xavier 风格缩放;float32(...) 把标量显式收窄,避免类型提升。
 98        scale = np.float32(1.0 / np.sqrt(d_model))
 99
100        def weight(*shape: int, s: np.float32 = scale) -> np.ndarray:
101            # normal(loc, scale, size) 按指定 shape 采样权重;astype(float32) 转换默认 float64 输出。
102            return rng.normal(0.0, float(s), size=shape).astype(np.float32)
103
104        self.wq = weight(d_model, d_model)
105        self.wk = weight(d_model, d_model)
106        self.wv = weight(d_model, d_model)
107        self.wo = weight(d_model, d_model)
108
109        self.w_gate = weight(d_model, d_ff)
110        self.w_up = weight(d_model, d_ff)
111        # sqrt(d_ff) 生成 FFN 输出层缩放,float32 保持与权重 dtype 一致。
112        self.w_down = weight(d_ff, d_model, s=np.float32(1.0 / np.sqrt(d_ff)))
113
114        # ones(shape, dtype) 初始化 RMSNorm 权重为 1,使初始缩放保持恒等。
115        self.attn_norm = np.ones(d_model, dtype=np.float32)
116        # FFN 前的 RMSNorm 同样用 ones(d_model, float32) 初始化可学习缩放向量。
117        self.ffn_norm = np.ones(d_model, dtype=np.float32)
118
119    def forward(self, x: np.ndarray, cache: KVCache, start_pos: int) -> np.ndarray:
120        B, S, D = x.shape
121        H, HD = self.num_heads, self.head_dim
122
123        residual = x
124        h = rms_norm(x, self.attn_norm)
125
126        # Q:reshape(B,S,H,HD) 拆分 head;transpose(0,2,1,3) 调整为 (B,H,S,HD)。
127        q = (h @ self.wq).reshape(B, S, H, HD).transpose(0, 2, 1, 3)
128        # K:使用相同的 reshape/transpose 参数,使序列维与 head 维交换位置。
129        k = (h @ self.wk).reshape(B, S, H, HD).transpose(0, 2, 1, 3)
130        # V:同样返回 (B,H,S,HD),与后续注意力矩阵乘的维度约定一致。
131        v = (h @ self.wv).reshape(B, S, H, HD).transpose(0, 2, 1, 3)
132
133        current_freqs = self.freqs[start_pos : start_pos + S]
134        q = apply_rotary_emb(q, current_freqs)
135        k = apply_rotary_emb(k, current_freqs)
136
137        keys, values = cache.append(k, v)
138        total_s = keys.shape[2]
139
140        # swapaxes(-1,-2) 交换 K 的最后两轴得到 K^T;sqrt(float32(HD)) 计算注意力缩放因子。
141        scores = (q @ keys.swapaxes(-1, -2)) / np.sqrt(np.float32(HD))
142
143        if S > 1:
144            # full(shape, fill_value, dtype) 创建全为 -inf 的二维 mask。
145            mask = np.full((S, total_s), -np.inf, dtype=np.float32)
146            # triu(mask, k) 保留第 k 条及以上对角线,其余置 0,形成因果遮罩。
147            mask = np.triu(mask, k=total_s - S + 1)
148            scores += mask[None, None, :, :]
149
150        # max(axis=-1, keepdims=True) 取每行最大值并保留轴,用于稳定 softmax。
151        scores -= np.max(scores, axis=-1, keepdims=True)
152        # exp(..., out=scores) 原地指数化,复用 scores 缓冲区。
153        np.exp(scores, out=scores)
154        # sum(axis=-1, keepdims=True) 计算每行归一化分母,保留轴以便广播除法。
155        scores /= np.sum(scores, axis=-1, keepdims=True)
156
157        attn = scores @ values
158        # transpose 恢复 (B,S,H,HD),reshape 再合并 H 与 HD 为模型维 D。
159        attn = attn.transpose(0, 2, 1, 3).reshape(B, S, D)
160        h = residual + attn @ self.wo
161
162        residual = h
163        h = rms_norm(h, self.ffn_norm)
164        ffn = (silu(h @ self.w_gate) * (h @ self.w_up)) @ self.w_down
165        return residual + ffn
166
167
168# default_rng(42) 创建可复现、与全局随机状态隔离的 Generator。
169rng = np.random.default_rng(42)
170
171B, D, H, FF = 1, 64, 4, 128
172MAX_SEQ_LEN = 32
173PREFILL_LEN = 5
174
175layer = TransformerDecoderLayer(D, H, FF, MAX_SEQ_LEN, rng)
176cache = KVCache(B, H, MAX_SEQ_LEN, D // H)
177
178# normal(size) 生成标准正态 prompt;astype(float32) 与模型权重类型保持一致。
179prompt = rng.normal(size=(B, PREFILL_LEN, D)).astype(np.float32)
180prefill_out = layer.forward(prompt, cache, start_pos=0)
181print("prefill:", prefill_out.shape, "cache_len:", cache.length)
182
183# size=(B,1,D) 模拟单 token decode 输入;astype 避免默认 float64 扩大带宽。
184current = rng.normal(size=(B, 1, D)).astype(np.float32)
185for step in range(3):
186    current = layer.forward(current, cache, start_pos=cache.length)
187    print("decode:", step, current.shape, "cache_len:", cache.length)

这段代码与前面的原理逐一对应:

  • 多头切分通过 reshape + transpose 完成,逻辑视图不等于连续布局。
  • RoPE 因子从 (S, D/2) 广播到 (B, H, S, D/2)
  • 因果 mask 从 (S, Total_S) 广播到整个 batch 和所有 head。
  • softmax 使用 keepdims=True 保持可广播形状,并通过 out= 原地复用 scores 缓冲区。
  • KV Cache 预分配后使用 slice 写入,每个 token 不再复制全部历史。

从 NumPy 原型走向张量框架

这段 Decoder 能运行,但它更适合作为 reference implementation,而不是生产推理引擎:

  1. NumPy 的优势是透明。 eager、同步、CPU 执行让每个 shape、临时数组和数值步骤都容易检查,适合验证 RMSNorm、RoPE、mask 与 KV Cache 语义。
  2. PyTorch 增加训练与设备语义。 torch.Tensor 在相似数组接口上增加 autograd、CPU/GPU device、混合精度、模块系统和成熟 kernel;生产模型通常首先需要这些能力。
  3. JAX 增加可组合函数变换。 同一数学函数可以被 jitgradvmap 和多设备变换,但代码必须适应 Array 不可变、静态 shape 与异步 dispatch。
  4. CuPy 是更直接的 GPU 数组迁移路径。numpy 替换为 cupy 就能迁移大量数组代码,但真正性能仍取决于 kernel fusion、host-device 传输和同步;它也不自动提供完整训练框架。
  5. 框架性能来自执行方式,而不只是 API。 Prefill 偏向大矩阵吞吐,Decode 更受 KV Cache 与内存带宽影响;PyTorch compilation、JAX/XLA、CuPy kernel fusion 都试图减少 Python dispatch、临时 Buffer 和设备往返。

NumPy 与主要生态的边界

“像 NumPy”描述的是接口亲缘关系,不代表这些工具可以相互替代:

生态最适合解决的问题相比 NumPy 新增的能力与代价
NumPyCPU 稠密数组、数值原型、通用互操作eager 且透明;缺少 GPU、自动微分和 JIT
pandas带标签的表格与时间序列分析Index、异构列、缺失值;转换 ndarray 可能复制或统一 dtype
SciPy科学算法、稀疏线性代数、优化与信号处理专业数据结构/算法;不是 NumPy API 的简单镜像
PyTorch深度学习训练与推理autograd、device、模块与 kernel;状态和梯度生命周期更复杂
JAX可微分科学计算、编译与多设备变换纯函数、不可变 Array、JIT;需要静态 shape 思维并正确测量异步执行
CuPy将 NumPy 风格数值代码迁移到 NVIDIA GPU高 API 相似度;受显存、传输、kernel launch 与部分边界差异约束
ArrowNumPy/pandas 与跨语言系统之间的数据交换NULL、变长、嵌套和不可变 Buffer;不负责通用数值计算

实际选择可以遵循一条简单路径:

  • 先用 NumPy 写清数学语义、shape 契约与 reference result;
  • 需要标签、缺失值和表格操作时进入 pandas;
  • 需要稀疏结构或成熟科学算法时进入 SciPy;
  • 需要训练、模型生态和通用 accelerator 支持时进入 PyTorch;
  • 需要函数变换、XLA 编译和多设备 SPMD 时考虑 JAX;
  • 已有 NumPy 数值代码、目标明确是 NVIDIA GPU 时评估 CuPy;
  • 需要跨语言或跨进程传递数据时使用 Arrow/DLPack,并显式核对零拷贝条件。

总结

NumPy 的核心可以浓缩为三层:

  1. buffer + shape + strides + dtype 描述多维数组;
  2. broadcasting + ufunc 把循环下沉到高效内核;
  3. BLAS/LAPACK 与可复用 buffer 承接工业级计算。

围绕这三层核心,主要生态选择了不同扩展方向:pandas 增加标签和表格语义,SciPy 增加专业算法与稀疏结构,PyTorch 增加自动微分和设备,JAX 增加编译与函数变换,CuPy 增加 CUDA 执行,Arrow 增加跨语言 Buffer 协议。它们底层反复面对的仍是同一组问题:

  • 数据在物理上如何排列?
  • 什么时候可以只改视图,什么时候必须物化?
  • 数据位于 CPU、GPU 还是其他设备?
  • 算力和内存带宽谁才是瓶颈?
  • 中间结果由谁分配、复用和回收?
  • eager、lazy 还是 compiled execution 更适合当前负载?

掌握 NumPy,不只是学会一个 Python 库。它提供了一块足够小、又足够接近真实硬件的实验场:可以从一条 strides 出发,一路理解 pandas 的数据转换、SciPy 的稀疏结构、PyTorch/CuPy 的设备 Tensor、JAX 的编译语义,以及 Arrow/DLPack 的 Buffer 互操作。这条可迁移的数组思维,才是 NumPy 最持久的价值。

参考资料