Python list 与 numpy 数组:从内存布局到向量计算的底层差异
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(newsize3)6)~3;大致是以约 1.125 倍的增长率扩容并做 4 字节对齐不同 CPython 版本略有微调3.9 之后会在末尾多加 3 再对齐。这种 over-allocation 策略使得append的平均时间复杂度为 O(1)但代价是 list 总会多占一些空闲容量。二、numpy ndarray 的内存模型连续缓冲区numpy 的ndarrayN-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 anp.array([1,2,3,4],dtypenp.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 顺序行优先anp.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。通过stridesnumpy 可以在不拷贝数据的情况下实现切片、转置、广播等操作。例如a.T转置只需要交换dimensions和strides的顺序data指针不变——这种操作叫视图view开销几乎为零。三、运算路径解释器循环 vs C 层向量化存储结构的差异直接决定了运算方式。Python list 的逐元素运算用 list 做向量点积alist(range(1000))blist(range(1000))# 点积dotsum(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 anp.arange(1000,dtypenp.int64)bnp.arange(1000,dtypenp.int64)# 点积dotnp.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 n1_000_000# --- list 版本 ---a_listlist(range(n))b_listlist(range(n))starttime.perf_counter()dot_listsum(a_list[i]*b_list[i]foriinrange(n))t_listtime.perf_counter()-start# --- numpy 版本 ---a_npnp.arange(n,dtypenp.int64)b_npnp.arange(n,dtypenp.int64)starttime.perf_counter()dot_npnp.dot(a_np,b_np)t_nptime.perf_counter()-startprint(flist 点积:{t_list:.4f}s)print(fnumpy 点积:{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 n1_000_000# Python list 存 100 万个整数lstlist(range(n))list_memsys.getsizeof(lst)sum(sys.getsizeof(x)forxinlst[:1000])*(n/1000)# getsizeof(lst) 只算 ob_item 数组的指针开销# 每个元素的 PyLongObject 单独计算# numpy 数组存 100 万个 int64arrnp.arange(n,dtypenp.int64)np_memarr.nbytesprint(flist 估算内存:{list_mem/1024/1024:.1f}MB)print(fnumpy 内存:{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 GB100 万 × 768 × 4 字节。如果用 Python list 嵌套 list 的方式存储同样的数据内存膨胀会大得多。100 万个文档 → 外层 list 有 100 万个指针8 MB。每个文档是一个 768 元素的子 list子 list 结构体约 56 字节 768 个内部指针768 × 8 6144 字节100 万个子 list 共约 6.2 GB。每个浮点数是一个PyFloatObject24 字节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 指令。这里展开讲一下机制。SIMDSingle 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 维向量querynp.random.randn(768)# 1000 个 768 维向量组成的矩阵corpusnp.random.randn(1000,768)# 计算 query 和 corpus 中每个向量的余弦相似度# 这里用到的就是广播机制similaritiescorpus 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在向量检索中几乎没有必要且内存翻倍。embeddingsnp.load(embeddings.npy)# shape: (N, 768), dtype: float32批量计算用矩阵乘法。单条 query 和整个语料库的相似度计算用一次矩阵乘法替代循环# query: (768,), corpus: (N, 768)# 归一化后余弦相似度 内积query_normquery/np.linalg.norm(query)corpus_normcorpus/np.linalg.norm(corpus,axis1,keepdimsTrue)scorescorpus_norm query_norm# (N,)大规模向量检索交给专用库。当 N 超过百万时即使用 numpy 的矩阵乘法也不够快。这时候应该用 FAISS、Milvus 等专用向量检索库它们在 numpy 的基础上进一步利用了 GPU、量化压缩和近似最近邻算法。importfaiss# numpy 数组可以无缝传入 FAISSindexfaiss.IndexFlatIP(768)index.add(corpus.astype(float32))scores,indicesindex.search(query.astype(float32).reshape(1,-1),k10)FAISS 接受numpy.ndarray作为输入内部会做进一步优化。numpy 在这里充当了 Python 和底层 C/C 库之间的桥梁——这也是为什么几乎所有 Python 生态的机器学习库PyTorch、TensorFlow、FAISS、scikit-learn都以 numpy 数组作为基础数据格式。九、总结维度Python listnumpy 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 的原因。