写代码的人都知道,矩阵乘法看起来简单,真正跑起来却能把内存撑爆。尤其是你尝试用分治算法去优化它的时候,内存占用往往会比普通三重循环还要夸张。这篇文章想用大白话聊聊:为什么分治会让内存失控,以及在生产环境里我们有哪些可行的优化路数。
一、一个反直觉的现象:分治算法让内存“爆表”
先简单回忆一下什么是分治。矩阵乘法中,把一个大矩阵切分成四块,每一块再切分成四块,直到只剩下一个数字,然后一层层把乘法和加法的结果拼回去。理论上这种做法可以把一个大问题拆成几个小问题,方便并行,也符合“大事化小”的思维。听起来好像很省内存,但实际上,如果你按照最天然的方式写出来,内存峰值会非常高。
为什么?因为每切一次矩阵,就要把属于子矩阵的那些数字复制到新的地方。递归下去,同一个数字会被复制很多遍。在递归没有返回之前,每一层复制出来的临时矩阵全都活着。这就好比搬家:本来只要把家具从旧家搬到新家,结果一个柜子拆成几块,每搬一块还专门派人守着,搬另一块时守着的人也不解散,最后人越来越多,整个屋子都站不下。
我们来看一个非常直观的例子。这里用嵌套列表模拟矩阵,用标准库的 tracemalloc 统计内存分配峰值。代码很简单:先把矩阵切成四块,再递归算乘法,最后把结果拼起来。为了看得清楚,矩阵尺寸故意设成 16。
# 技术栈:Python 3.9+
import tracemalloc
def mat_add(A, B):
"""矩阵加法,返回一个新的矩阵。"""
n = len(A)
return [[A[i][j] + B[i][j] for j in range(n)] for i in range(n)]
def mat_mul_dc(A, B):
"""朴素的递归分治乘法,每次切分都创建副本。"""
n = len(A)
if n == 1:
return [[A[0][0] * B[0][0]]]
mid = n // 2
# 注意:这里的列表推导式会开辟全新的内存
A11 = [row[:mid] for row in A[:mid]]
A12 = [row[mid:] for row in A[:mid]]
A21 = [row[:mid] for row in A[mid:]]
A22 = [row[mid:] for row in A[mid:]]
B11 = [row[:mid] for row in B[:mid]]
B12 = [row[mid:] for row in B[:mid]]
B21 = [row[:mid] for row in B[mid:]]
B22 = [row[mid:] for row in B[mid:]]
# 递归算四块乘积,再拼回去
C11 = mat_add(mat_mul_dc(A11, B11), mat_mul_dc(A12, B21))
C12 = mat_add(mat_mul_dc(A11, B12), mat_mul_dc(A12, B22))
C21 = mat_add(mat_mul_dc(A21, B11), mat_mul_dc(A22, B21))
C22 = mat_add(mat_mul_dc(A21, B12), mat_mul_dc(A22, B22))
top = [C11[i] + C12[i] for i in range(mid)]
bottom = [C21[i] + C22[i] for i in range(mid)]
return top + bottom
def mat_mul_simple(A, B):
"""普通的三重循环乘法,内存开销相对可控。"""
n = len(A)
C = [[0.0] * n for _ in range(n)]
for i in range(n):
for j in range(n):
for k in range(n):
C[i][j] += A[i][k] * B[k][j]
return C
# 造一个 16x16 的矩阵,里面全是 1.0
n = 16
A = [[1.0] * n for _ in range(n)]
B = [[1.0] * n for _ in range(n)]
tracemalloc.start()
C = mat_mul_dc(A, B) # 换成 mat_mul_simple 可以看到不同的结果
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"分治实现的内存峰值: {peak / 1024:.1f} KB")
tracemalloc.start()
C = mat_mul_simple(A, B)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"普通三循环的内存峰值: {peak / 1024:.1f} KB")
运行之后你会发现,分治版本的内存峰值比普通三循环高很多,而矩阵只有 16 乘 16。这还不算最狠的情况:如果用 numpy 并且故意到处用 copy(),内存能飙到几百兆。但这并不是说分治算法注定费内存,而是“实现方式”决定了开销。接下来我们把问题拆开看。
二、根源剖析:分治实现里的隐形开销
分治实现的内存膨胀,通常来自四个地方。
第一,切分时复制矩阵。第一层把两个矩阵切成 8 块,第二层每个块又切成 8 块。虽然每一层都会产生临时子矩阵,但是在外层递归返回之前,内层递归还在运行,所以所有这些临时矩阵都不能被释放。
第二,递归调用栈越来越深。每深入一层,都会保留上一层函数的所有局部变量。也就是说,内存占用和递归深度是同时增加的。
第三,合并结果时又要新建一块内存。C11、C12 这些临时结果都还不是最终矩阵的一部分,得先拼成上下的半块,再拼成一个整体。每一步都在用新列表。相当于先把零件做好,又建了个仓库放零件,最后再租一个大仓库做成品。
第四,表达式执行顺序会放大问题。比如“a+b+c”会先算 a+b,得到一个临时结果,再和 c 相加,又得一个临时结果。如果你在一行代码里写很长的矩阵加法,峰值内存会更高。所以生产环境里尽量不要用 Python 列表做矩阵运算,更不要随便在递归里做拷贝。
一句话概括:分治本身不会多产生多少数据,但如果你每切一刀就复制一次,整个计算过程中就有大量重复数据同时存在。
问题清楚了,那么怎么改?下面从三个层面给出优化策略,它们在生产环境里都很实用。
三、优化策略一:用视图代替拷贝,别让数据“四处搬家”
numpy 的切片有个特性:它返回的是原矩阵的视图,不是副本。也就是说,切出来的 A11 和原来的 A 共享同一块底层数据,只是“视角”不同。利用这一点,我们在分治时就不必把数据复制到新的地方,只需要记录一下从哪里看到哪里。这一下子省掉了所有切割时的复制开销。
下面的例子用 numpy 重写分治乘法。为了对比,先写一个故意在切片后用 copy() 的版本,让人一眼看出为什么费内存。然后写一个用视图的版本。
# 技术栈:Python 3.9+ / numpy
import numpy as np
def bad_slice(A, B):
"""每次切片都 copy,内存会迅速膨胀。"""
n = A.shape[0]
if n == 1:
return A * B
mid = n // 2
# 关键问题:每个子矩阵都复制了一份完整数据
A11 = A[:mid, :mid].copy()
A12 = A[:mid, mid:].copy()
A21 = A[mid:, :mid].copy()
A22 = A[mid:, mid:].copy()
B11 = B[:mid, :mid].copy()
B12 = B[:mid, mid:].copy()
B21 = B[mid:, :mid].copy()
B22 = B[mid:, mid:].copy()
C11 = bad_slice(A11, B11) + bad_slice(A12, B21)
C12 = bad_slice(A11, B12) + bad_slice(A12, B22)
C21 = bad_slice(A21, B11) + bad_slice(A22, B21)
C22 = bad_slice(A21, B12) + bad_slice(A22, B22)
top = np.hstack([C11, C12])
bottom = np.hstack([C21, C22])
return np.vstack([top, bottom])
def view_slice(A, B):
"""使用 numpy 的视图,切割时零拷贝。"""
n = A.shape[0]
if n == 1:
return A * B
mid = n // 2
# 这里没有 .copy(),只是视图
A11 = A[:mid, :mid]
A12 = A[:mid, mid:]
A21 = A[mid:, :mid]
A22 = A[mid:, mid:]
B11 = B[:mid, :mid]
B12 = B[:mid, mid:]
B21 = B[mid:, :mid]
B22 = B[mid:, mid:]
C11 = view_slice(A11, B11) + view_slice(A12, B21)
C12 = view_slice(A11, B12) + view_slice(A12, B22)
C21 = view_slice(A21, B11) + view_slice(A22, B21)
C22 = view_slice(A21, B12) + view_slice(A22, B22)
top = np.hstack([C11, C12])
bottom = np.hstack([C21, C22])
return np.vstack([top, bottom])
注意,view_slice 避免了矩阵复制,但 C11 这些结果数组依然是递归新建的。它已经比 bad_slice 省了一大块内存,但还不是最优。视图的价值主要在于“切割时零成本”,后续计算时依然要谨慎。如果矩阵是不连续的内存,某些运算反而会变慢,因为 CPU 缓存命中率会下降。所以视图是一种有效但有限的手段。
四、优化策略二:预分配缓冲区,自己当内存管家
生产环境里,最稳的办法之一是一开始就申请一大块内存,然后在整个计算过程中反复使用同一块内存。这种思路叫内存池或者缓冲区复用。分治递归时,最怕的就是每次递归都分配一块新内存。我们可以提前创建好结果矩阵,然后在递归的每一层往对应的位置写入,不再生成五花八门的中间结果。
下面是一个简化但有代表性的做法:在进入递归之前,先给 C 分配一块固定大小的内存,然后编写一个内部函数,它知道当前应该计算 C 的哪个区域。为了不引入太多临时矩阵,我们让递归函数直接把 A@B 的结果累加到 C 的对应块上。这个函数使用 numpy 的切片视图去引用 C 的各个子块,所以整个计算过程中只有一份结果矩阵。
# 技术栈:Python 3.9+ / numpy
import numpy as np
def dc_mul_add(A, B, C):
"""把 A@B 累加到 C 上。C 的大小必须和 A、B 一致。"""
n = A.shape[0]
if n == 1:
C[0, 0] += A[0, 0] * B[0, 0]
return
mid = n // 2
# 取出 C 的四块视图,修改它们等于修改外部传入的 C
C00 = C[:mid, :mid]
C01 = C[:mid, mid:]
C10 = C[mid:, :mid]
C11 = C[mid:, mid:]
# C00 = A00*B00 + A01*B10
dc_mul_add(A[:mid, :mid], B[:mid, :mid], C00)
dc_mul_add(A[:mid, mid:], B[mid:, :mid], C00)
# C01 = A00*B01 + A01*B11
dc_mul_add(A[:mid, :mid], B[:mid, mid:], C01)
dc_mul_add(A[:mid, mid:], B[mid:, mid:], C01)
# C10 = A10*B00 + A11*B10
dc_mul_add(A[mid:, :mid], B[:mid, :mid], C10)
dc_mul_add(A[mid:, mid:], B[mid:, :mid], C10)
# C11 = A10*B01 + A11*B11
dc_mul_add(A[mid:, :mid], B[:mid, mid:], C11)
dc_mul_add(A[mid:, mid:], B[mid:, mid:], C11)
# 使用示例
n = 16 # 为了快速演示,实际生产可以换成更大的 2 的幂
A = np.random.random((n, n))
B = np.random.random((n, n))
C = np.zeros((n, n)) # 只分配一次结果内存
# 递归分治乘法,结果直接累加到 C
dc_mul_add(A, B, C)
# 验证结果是否正确
print("误差:", np.max(np.abs(C - A @ B)))
这段代码里,递归过程中虽然创建了很多视图,但视图本身几乎不占数据内存。C 永远是同一块内存,A 和 B 的切片也是视图。相比前面每一步都新建数组的做法,内存峰值一下就降下来了。不过,真实的内存池实现还会包含更复杂的“空闲块回收”机制,这里展示的核心思想是:能复用就不要新造。
五、优化策略三:用迭代分块代替递归分治(生产最实用)
虽然上面的 dc_mul_add 已经让内存可控,但递归仍然有函数调用开销,并且容易在一些异常维度上出问题。生产环境中的矩阵乘法库,比如 OpenBLAS、MKL,内部做的其实是“把矩阵切割成一个个小块,按顺序反复调度”,而不是递归嵌套。这种思想叫循环分块,也叫 tiling。
循环分块的好处很明显:你控制一个小方块的大小,让它正好能被 CPU 缓存装下。每次计算都只在这一小块范围里操作,内存占用固定,还不容易出 bug。下面是简化版的分块乘法:
# 技术栈:Python 3.9+ / numpy
import numpy as np
def matmul_tiled(A, B, block_size=4):
"""把矩阵按 block_size 切块,每个块内用 numpy 的 @ 完成乘法。"""
n = A.shape[0] # 只处理方阵,方便演示
C = np.zeros((n, n))
# 二重循环遍历 C 的每一个目标块
for i in range(0, n, block_size):
for j in range(0, n, block_size):
# 第三个循环负责累加 A 的第 k 个块与 B 的对应块的乘积
for k in range(0, n, block_size):
# 注意 numpy 切片会自动处理边界,即使不是整数倍也不会越界
C[i:i+block_size, j:j+block_size] += (
A[i:i+block_size, k:k+block_size] @
B[k:k+block_size, j:j+block_size]
)
return C
# 验证正确性
n = 16
A = np.random.random((n, n))
B = np.random.random((n, n))
C = matmul_tiled(A, B, block_size=4)
print("误差:", np.max(np.abs(C - A @ B)))
这个写法没有递归,没有大量临时矩阵,内存几乎只有 C、A、B 和一个小到可以忽略的块结果。块大小可以根据 CPU 缓存来调整,通常选 32 到 256 之间。如果块太小,调用 @ 的固定开销占比变高;如果块太大,缓存放不下,性能又会退化。实际项目中可以多试几个值。
相比递归分治,这种“切蛋糕”式的循环分块更像生产环境真正会用的方案。如果你直接用 A @ B,底层库内部本质上也在做类似的事情,只是它还会做内存打包、多线程调度、指令集优化等更复杂的操作。
六、应用场景、优缺点和注意事项
我们需要客观看待这些策略。
应用场景:
在嵌入式设备、移动端这些内存很珍贵的地方,不能随手分配大量临时矩阵,用“预分配 + 视图”这种模式特别有用。在需要并行计算的时候,迭代分块比递归更容易分配任务,每个核分到一块固定的计算区域,不会因为递归产生负载不均。在科学计算平台或推荐系统里,矩阵规模很大,只要算法能固定内存占用量,就能避免因峰值内存导致进程被杀。
技术优缺点:
分治思想的优点是代码结构清晰,容易和并行框架结合;缺点是如果实现得不好,内存会翻倍再翻倍,而且递归深度可能导致栈溢出。视图的优点是零拷贝,速度提升明显;缺点是它只能减少“搬运”,不能减少计算结果本身占用的内存。内存池的优点是内存开销可控,适合长期运行的服务;缺点是管理起来麻烦,必须注意谁使用哪块内存,不然容易出现数据覆盖,递归里手动回收也容易出错。迭代分块的优点是内存占用稳定,容易调优,不太可能写出灾难性代码;缺点是需要自己选择块大小,且块内运算效率依赖底层库。
注意事项:
分治实现必须处理矩阵维度不是 2 的幂的情况。最简单的做法是先在外部补零,把尺寸补到 2 的幂,算完后再裁掉多余部分。补零会多占一点内存,但比递归乱拷贝少得多。另外,不要在一行代码里写长串矩阵加法,每个加号都会产生一个临时矩阵。如果一定要算三个以上矩阵的和,尽量用循环或者原地加法,比如写 C += temp,不要写 C = temp1 + temp2 + temp3。使用 numpy 视图时,要区分“视图”和“副本”。如果你发现修改视图影响了原矩阵,恭喜你;如果无论如何都改变不了原矩阵,可能之前某一步不小心做了 copy。多线程并行时,预分配缓冲要特别注意冲突。如果多个线程同时使用同一个临时缓冲区,结果会被污染。建议每个线程单独分一块,或者使用线程局部存储。
七、总结
这一轮折腾下来,我们看到了一个很常见的技术现象:算法的数学表达很简洁,但落地到真实机器上时,内存和缓存的行为往往比计算本身更重要。分治矩阵乘法本身并不坏,坏的是我们习惯性的写码方式——每切一刀就复制一份,递归不结束就不释放。
在处理这类问题时,请记住一条简单原则:能复用就不要新造,能视图就不要拷贝,能顺序分块就不要递归嵌套。先用朴素的 A @ B 跑通业务,再考虑自己写优化版本。如果真到了需要手写那一层,记得用今天的思路去减少临时数据,尤其是“预分配结果矩阵 + 原地累加”和“迭代分块”这两个招,基本能应付生产环境里的绝大多数内存压力。
矩阵乘法是很多系统的地基。地基不牢,上层业务随时会塌。希望这篇文章能让你在写分治代码时多留个心眼,别再让内存峰值把服务拖垮。
评论
围绕“矩阵乘法分治实现的内存占用问题及生产环境的优化策略”参与讨论