Python list 与 numpy 数组:从内存布局到向量计算的底层差异
做 Embedding 相关的开发时,一个 768 维的向量用 Python 原生 list 存储还是用 numpy 数组存储,表面上看只是数据类型的选择,但实际上两者从内存布局到运算路径完全是两套东西。这篇笔记从 CPython 的list实现和 numpy 的ndarray实现入手,把两者的底层差异梳理清楚。
一、Python list 的内存模型:指针数组
CPython 中list的定义可以在Include/cpython/listobject.h中找到:
// CPython 源码(简化)typedefstruct{PyObject_VAR_HEAD PyObject**ob_item;// 指向 PyObject* 数组的指针Py_ssize_t allocated;// 已分配的容量}PyListObject;关键字段是ob_item——它是一个指向PyObject*数组的指针。也就是说,list 内部存储的并不是数据本身,而是一组指针,每个指针指向堆上的一个 Python 对象。
假设你写了这样一行代码:
a=[1,2,3,4]内存中的实际布局是这样的:
PyListObject ┌──────────────────┐ │ ob_item ─────────┼──→ [ptr_A, ptr_B, ptr_C, ptr_D] │ ob_size = 4 │ │ │ │ │ │ allocated = 4 │ ▼ ▼ ▼ ▼ └──────────────────┘ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │obj│ │obj│ │obj│ │obj│ └───┘ └───┘ └───┘ └───┘ 堆上分散的 4 个 PyLongObject每个整数1、2、3、4在 CPython 中是一个PyLongObject结构体(至少 28 字节),而ob_item数组中存的是指向这些结构体的指针(每个指针 8 字节,64 位系统下)。
这种设计有两个直接后果:
第一,存储密度低。存 4 个整数,ob_item指针数组占 32 字节,4 个PyLongObject各占约 28 字节,总共约 144 字节。如果用 C 语言的int数组存同样的 4 个整数,只需要 16 字节(4 × 4 字节)。
第二,缓存不友好。ob_item数组本身是连续的,但它指向的PyLongObject散落在堆的各个位置。遍历 list 时,CPU 每访问一个元素就要做一次指针解引用,目标数据很可能不在 L1/L2 cache 中,导致 cache miss。在数据量小的时候看不出区别,但遍历一个百万级 list 时,cache miss 造成的延迟会叠加到很可观的程度。
list 的动态扩容
list 的allocated字段通常大于等于ob_size(实际元素个数)。当 list 通过append增长到超过allocated时,CPython 会按以下公式重新分配内存:
// CPython listobject.c(简化)new_allocated=((size_t)newsize+(newsize>>3)+6)&~3;大致是以约 1.125 倍的增长率扩容,并做 4 字节对齐(不同 CPython 版本略有微调,3.9 之后会在末尾多加 3 再对齐)。这种 over-allocation 策略使得append的平均时间复杂度为 O(1),但代价是 list 总会多占一些空闲容量。
二、numpy ndarray 的内存模型:连续缓冲区
numpy 的ndarray(N-dimensional array)采用完全不同的内存策略。它的核心定义在numpy/core/include/numpy/ndarraytypes.h中:
// numpy 源码(简化)typedefstructPyArrayObject{PyObject_HEADchar*data;// 指向连续数据缓冲区的指针intnd;// 维度数npy_intp*dimensions;// 各维度大小npy_intp*strides;// 各维度的步长(字节数)PyArray_Descr*descr;// 元素类型描述符intflags;// 标志位(C_CONTIGUOUS, F_CONTIGUOUS 等)}PyArrayObject;关键字段是data——它直接指向一块连续的内存缓冲区,里面紧凑地排列着原始数据,没有中间的指针层。
importnumpyasnp a=np.array([1,2,3,4],dtype=np.int64)内存布局:
PyArrayObject ┌──────────────────┐ │ data ────────────┼──→ [8字节][8字节][8字节][8字节] │ nd = 1 │ 1 2 3 4 │ dimensions = [4] │ │ strides = [8] │ │ descr = int64 │ └──────────────────┘ 一块连续内存,4 个 int64 紧挨着,没有指针,没有对象头4 个 int64 占 32 字节,没有额外的对象头开销。数据紧凑排列,CPU 读取时可以利用** SIMD 指令**(单指令多数据流)一次从内存中加载多个元素到寄存器,也可以充分利用 CPU cache line(通常 64 字节,正好装 8 个 int64)的预取机制。
dtype:类型统一是前提
ndarray要求所有元素类型一致(由descr字段指定)。这是它能够使用连续内存的前提——如果每个元素大小不同,就没法用固定步长来寻址了。
Python list 没有这个约束,可以存任意类型的对象:[1, "hello", [2, 3], None]。这种灵活性恰恰是 list 无法做到内存紧凑的根本原因——它必须存指针,通过指针来引用不同大小的对象。
strides:多维数组的寻址机制
strides数组定义了沿每个维度前进一个元素需要的字节数。对于一个 3×4 的 int64 二维数组(C 顺序,行优先):
a=np.arange(12).reshape(3,4)# a = [[ 0 1 2 3]# [ 4 5 6 7]# [ 8 9 10 11]]print(a.strides)# (32, 8)strides = (32, 8)表示:沿第 0 维(行)前进一个元素需要跳过 32 字节(4 个 int64 × 8 字节),沿第 1 维(列)前进一个元素跳过 8 字节(1 个 int64)。
通过strides,numpy 可以在不拷贝数据的情况下实现切片、转置、广播等操作。例如a.T(转置)只需要交换dimensions和strides的顺序,data指针不变——这种操作叫视图(view),开销几乎为零。
三、运算路径:解释器循环 vs C 层向量化
存储结构的差异直接决定了运算方式。
Python list 的逐元素运算
用 list 做向量点积:
a=list(range(1000))b=list(range(1000))# 点积dot=sum(a[i]*b[i]foriinrange(1000))这段代码在 CPython 中的执行路径:
for i in range(1000):每次迭代创建一个int对象(range 产出),解释器执行一次循环调度a[i]:从ob_item数组取指针,解引用到PyLongObject,取出整数值b[i]:同上a[i] * b[i]:调用PyNumber_Multiply,这是一个通用函数,内部检查类型、做类型转换、创建新的PyLongObject存放结果sum(...):每步调用PyNumber_Add做一次累加(C 层的builtin_sum对整数和浮点数有 fast path 优化,但生成器表达式(a[i]*b[i] ...)本身的解释器调度开销仍是瓶颈)
1000 次乘法 + 999 次累加,总共约 2000 次 Python 层函数调用,每次调用都经过解释器的字节码分派循环。每次运算产生的中间PyLongObject还要被垃圾回收。
numpy 的向量化运算
importnumpyasnp a=np.arange(1000,dtype=np.int64)b=np.arange(1000,dtype=np.int64)# 点积dot=np.dot(a,b)numpy 的执行路径:
np.dot(a, b)调用 C 层函数,类型检查只做一次(确认a和b都是int64一维数组)- 底层直接调用 C 的循环(或 BLAS 库的
ddot),在一个紧凑的连续内存块上做乘加运算 - 循环体中没有 Python 对象的创建和销毁,没有解释器调度开销
- 如果 CPU 支持 AVX2 或 AVX-512,编译器会自动生成 SIMD 指令,一条指令同时处理 4 个或 8 个 int64
同样的 1000 元素点积,numpy 的实际执行时间通常只有 list 版本的 1/30 到 1/100。
实测对比
用一段简单的 benchmark 来量化差异:
importnumpyasnpimporttime n=1_000_000# --- list 版本 ---a_list=list(range(n))b_list=list(range(n))start=time.perf_counter()dot_list=sum(a_list[i]*b_list[i]foriinrange(n))t_list=time.perf_counter()-start# --- numpy 版本 ---a_np=np.arange(n,dtype=np.int64)b_np=np.arange(n,dtype=np.int64)start=time.perf_counter()dot_np=np.dot(a_np,b_np)t_np=time.perf_counter()-startprint(f"list 点积:{t_list:.4f}s")print(f"numpy 点积:{t_np:.6f}s")print(f"加速比:{t_list/t_np:.1f}x")在一台普通笔记本(i7-12700H, 32GB RAM)上的运行结果:
list 点积: 0.0834s numpy 点积: 0.000487s 加速比: 171.3x百万级向量的一次点积,numpy 比 list 快了约 170 倍。如果换成更复杂的矩阵乘法或批量余弦相似度计算,差距还会拉大。
四、内存占用对比
用sys.getsizeof和ndarray.nbytes做一个直观的对比:
importsysimportnumpyasnp n=1_000_000# Python list 存 100 万个整数lst=list(range(n))list_mem=sys.getsizeof(lst)+sum(sys.getsizeof(x)forxinlst[:1000])*(n/1000)# getsizeof(lst) 只算 ob_item 数组的指针开销# 每个元素的 PyLongObject 单独计算# numpy 数组存 100 万个 int64arr=np.arange(n,dtype=np.int64)np_mem=arr.nbytesprint(f"list 估算内存:{list_mem/1024/1024:.1f}MB")print(f"numpy 内存:{np_mem/1024/1024:.1f}MB")print(f"比率:{list_mem/np_mem:.1f}x")运行结果:
list 估算内存: 36.0 MB numpy 内存: 7.6 MB 比率: 4.7xlist 的内存开销是 numpy 的约 4.7 倍。来源有两个:一是每个PyLongObject自带的对象头(ob_refcnt、ob_type等字段,约 28 字节),二是指针数组本身的开销(每元素 8 字节)。numpy 的int64每个元素只占 8 字节,没有任何额外开销。
在 RAG 场景下,一个中等规模的知识库可能有 100 万条文档,每条文档一个 768 维的 Embedding 向量。如果用float32存储并用 numpy 管理这些向量,总内存约 2.87 GB(100 万 × 768 × 4 字节)。
如果用 Python list 嵌套 list 的方式存储同样的数据,内存膨胀会大得多。100 万个文档 → 外层 list 有 100 万个指针(8 MB)。每个文档是一个 768 元素的子 list:子 list 结构体约 56 字节 + 768 个内部指针(768 × 8 = 6144 字节),100 万个子 list 共约 6.2 GB。每个浮点数是一个PyFloatObject(24 字节),7.68 亿个浮点数共约 18.4 GB。三者相加总计约24.6 GB。即使把所有向量拍扁成一个一维大 list,也是 7.68 亿 × 32 字节 ≈ 22.8 GB。和 numpy 的 2.87 GB 相比,内存膨胀了约 8.5 倍。这对于在单机上跑向量检索服务来说,是能不能跑起来的区别。
五、为什么 numpy 能做到 SIMD 加速
前面提到 numpy 在底层可以利用 CPU 的 SIMD 指令。这里展开讲一下机制。
SIMD(Single Instruction, Multiple Data)允许 CPU 用一条指令同时处理多个数据。以 AVX2 为例,它有 256 位宽的寄存器,可以一次性处理 8 个 32 位浮点数或 4 个 64 位整数。
但要利用 SIMD,有两个前提条件:
- 数据连续排列:SIMD 指令从内存中加载数据时,期望数据是连续紧凑的。numpy 的
ndarray天然满足这个条件,data指针指向一块连续的缓冲区 - 类型统一:SIMD 指令需要知道每个元素的类型和大小(比如
int32还是float64),才能选择正确的指令。numpy 的dtype保证了这一点
Python list 的ob_item数组虽然指针是连续的,但指针指向的对象散落在堆上,无法用 SIMD 批量加载。即使勉强加载了指针,还要逐个解引用才能拿到实际数据,SIMD 的并行优势完全丧失。
numpy 的底层 C 代码(以及它调用的 BLAS/OpenBLAS/MKL 库)在编译时就会生成 SIMD 指令。如果你用的 numpy 是通过pip install numpy安装的,它很可能已经链接了 Intel MKL 或 OpenBLAS,这两个库在编译时针对不同 CPU 架构做了高度优化。
可以用np.show_config()查看你本地 numpy 链接的底层库:
np.show_config()# 输出示例(Windows + MKL):# blas_mkl_info:# libraries = ['mkl_rt']# library_dirs = ['C:/Users/.../numpy.libs']# define_macros = [('SCIPY_MKL_H', None), ('HAVE_CBLAS', None)]# include_dirs = ['C:/Users/.../include']如果看到mkl_rt或openblas,说明你的 numpy 已经链接了优化数学库,矩阵运算会走高度优化的 C/Fortran 路径。
六、numpy 的广播机制
numpy 的另一个核心优势是广播(broadcasting)——在不拷贝数据的前提下,让不同形状的数组进行算术运算。
importnumpyasnp# 一个 768 维向量query=np.random.randn(768)# 1000 个 768 维向量组成的矩阵corpus=np.random.randn(1000,768)# 计算 query 和 corpus 中每个向量的余弦相似度# 这里用到的就是广播机制similarities=corpus @ query# shape: (1000,)corpus的形状是(1000, 768),query的形状是(768,)。numpy 的@运算符在处理 1D 数组时,会自动将它视为列向量——query隐式提升为(768, 1),然后执行标准矩阵乘法:(1000, 768) @ (768, 1)得到(1000, 1),最后将结果压平为(1000,)。整个过程在 C 层面调用 BLAS 库完成,没有 Python 级别的循环。
用 list 做同样的事情,需要手动写循环:
# list 版本(仅做对比,不推荐实际使用)similarities=[sum(c[i]*query[i]foriinrange(768))forcincorpus]这段代码的执行路径是 1000 × 768 = 768,000 次 Python 层乘法 + 加法,加上 768,000 次列表索引和对象创建。和 numpy 的corpus @ query(一次 C 层矩阵乘法调用)相比,慢了两个数量级以上。
广播的底层实现也依赖strides。当 numpy 需要广播一个维度时,它将该维度的stride设为 0——这样无论索引怎么变,实际读取的内存位置不变,相当于"虚拟复制"了一份数据,但没有实际拷贝。
七、numpy 的局限
numpy 不是万能的。以下场景中 list 或其他数据结构可能更合适:
异构数据存储。list 可以存不同类型的对象,numpy 不行。如果你的数据是["apple", 42, {"key": "value"}],只能用 list。
频繁的前端插入/删除。numpy 数组是固定大小的,插入或删除元素需要创建新数组并拷贝数据,时间复杂度 O(n)。list 的insert(0, x)虽然也是 O(n),但实际实现是memmove,常数因子比 numpy 的全量拷贝小。
小数据量。几十个元素的运算,list 和 numpy 的差异可以忽略。numpy 的ndarray创建本身有固定开销(分配PyArrayObject结构、初始化dimensions和strides等),在极小数据量下反而比 list 慢。
需要和其他 Python 对象深度交互。numpy 数组的元素不是 Python 对象,不能直接作为字典的 key(需要先.item()转换),也不能和某些只接受原生类型的库直接配合。
八、在向量计算中的实践建议
在大模型 Embedding 和向量检索的工程实践中,几乎不会用 list 来存储和计算向量。以下是几个常见的实践模式:
向量存储统一用np.float32。Embedding 模型输出的向量通常是float32或float16,用np.float32存储可以在精度和内存之间取得平衡。float64在向量检索中几乎没有必要,且内存翻倍。
embeddings=np.load("embeddings.npy")# shape: (N, 768), dtype: float32批量计算用矩阵乘法。单条 query 和整个语料库的相似度计算,用一次矩阵乘法替代循环:
# query: (768,), corpus: (N, 768)# 归一化后,余弦相似度 = 内积query_norm=query/np.linalg.norm(query)corpus_norm=corpus/np.linalg.norm(corpus,axis=1,keepdims=True)scores=corpus_norm @ query_norm# (N,)大规模向量检索交给专用库。当 N 超过百万时,即使用 numpy 的矩阵乘法也不够快。这时候应该用 FAISS、Milvus 等专用向量检索库,它们在 numpy 的基础上进一步利用了 GPU、量化压缩和近似最近邻算法。
importfaiss# numpy 数组可以无缝传入 FAISSindex=faiss.IndexFlatIP(768)index.add(corpus.astype('float32'))scores,indices=index.search(query.astype('float32').reshape(1,-1),k=10)FAISS 接受numpy.ndarray作为输入,内部会做进一步优化。numpy 在这里充当了 Python 和底层 C/C++ 库之间的桥梁——这也是为什么几乎所有 Python 生态的机器学习库(PyTorch、TensorFlow、FAISS、scikit-learn)都以 numpy 数组作为基础数据格式。
九、总结
| 维度 | Python list | numpy ndarray |
|---|---|---|
| 内存模型 | 指针数组,元素散落在堆上 | 连续内存缓冲区,元素紧凑排列 |
| 元素类型 | 任意,可混合 | 统一,由 dtype 指定 |
| 单元素开销 | PyLongObject 约 28 字节 + 指针 8 字节 | 仅 dtype 大小(int64 = 8 字节) |
| 遍历性能 | 指针解引用 + cache miss | 连续访问 + cache line 预取 |
| 运算路径 | Python 解释器逐元素调度 | C 层向量化循环 / BLAS 调用 |
| SIMD 支持 | 无法利用 | 自动利用 AVX2/AVX-512 |
| 多维支持 | 嵌套 list,无统一接口 | 原生多维数组 + strides 寻址 |
| 广播机制 | 无 | 原生支持 |
| 适用场景 | 异构数据、小数据量、频繁增删 | 数值计算、向量运算、大规模数据处理 |
list 和 numpy 的差异不是"谁更好"的问题,而是它们解决的是不同层次的问题。list 是 Python 的通用容器,为了灵活性牺牲了数值计算的效率;numpy 是面向数值计算的专用数据结构,通过类型约束和连续内存换来了数量级的性能提升。在 Embedding 向量计算这个特定场景下,这种性能差异决定了系统能不能在可接受的延迟内完成检索——这就是向量计算优先用 numpy 的原因。