arXiv 上前两天挂了篇论文(8 月 18 号,Barba Roque、Cruz、Panichella),核心结论一句话:FLOPs 不是个可靠的能耗指标。学生模型推理能耗最多降 90%、内存降 86%——这些数字都挺好,但整篇里最值钱的是开头那句大实话:大家拿 FLOPs 报成本报了这么多年,原来这东西和真实能耗对不上。
我要借这个题发挥一下,因为"FLOPs 当能耗替身"和"调库的人把库当机制本身"是同一种病:拿一个顺手可算的代理量,冒充你真正关心的那个量。而戳破一个代理量,最好的办法不是读论文,是自己量一遍。今天我们就搓一个最小版的"FLOPs vs 真实能耗"对照实验,不求严谨,就求你亲眼看到两个数各奔东西。
先搓个最糙的:数乘加次数
FLOPs 的直觉很简单:一次乘一次加算两个浮点操作,前向跑下来总共做了多少次乘加,就是多少 FLOPs。精确公式先不管,搓个最糙的:两个矩阵乘一下,数它的 FLOPs。
import numpy as np
def flops_of_matmul(A, B):
# 这一行干这件事:每个输出元素贡献 m*k 次乘加,按 2 个浮点操作算
m, k = A.shape
k2, n = B.shape
return 2 * m * n * k
A = np.random.randn(512, 512).astype(np.float32)
B = np.random.randn(512, 512).astype(np.float32)
print(f"{flops_of_matmul(A, B):,}")
跑一下,看看出来啥:
268,435,456
两亿七。听着不少。现在问题来了:这个数和"这次矩阵乘烧了多少电"是什么关系?大家默认正比——FLOPs 翻倍能耗翻倍。行,那就直接量。
量能耗的工具不用搞复杂。Linux 上 /proc 和 perf 都能凑合,macOS 有 powermetrics。最省事的糙版本:CPU 那边更准的读数在 RAPL 接口里(/sys/class/powercap/intel-rapl:0/energy_uj),Intel 的 CPU 大多能直接读,单位微焦耳。我们直接上这个:
import os, time
import numpy as np
def read_energy_uj():
# 这一行干这件事:读 CPU 封装累计能耗,单位微焦耳
with open("/sys/class/powercap/intel-rapl:0/energy_uj") as f:
return int(f.read())
E0 = read_energy_uj()
t0 = time.perf_counter()
for _ in range(200):
A @ B
t1 = time.perf_counter()
E1 = read_energy_uj()
print(f"耗时 {t1-t0:.3f}s, 能耗约 {(E1-E0)/1e6:.2f} J")
跑一下(Linux + Intel CPU 才有那个文件,macOS 的朋友用 powermetrics 凑合看):
耗时 1.842s, 能耗约 52.30 J
好,对照来了。同样的 FLOPs,我换一种干法:循环次数砍半、矩阵换大一号,让总 FLOPs 不变。200 次 512×512 换成 50 次 1024×1024——总乘加一样,2 * 50 * 1024 * 1024 * 1024 恰好等于 2 * 200 * 512^3,你可以自己验。跑之前我赌五毛钱能耗差不多,FLOPs 一样嘛。
跑出来:能耗差了不是几个百分点,是一截。而且方向都未必如你想——大矩阵在 cache 里的表现、BLAS 走哪条内核路径、内存带宽怎么吃,全变了。FLOPs 一模一样,焦耳不一样。
那篇论文用一整套蒸馏框架、两个任务、正经的统计检验说这件事,我们用二十行 Python 摸到了同一个门把手。
为什么 FLOPs 会骗人
FLOPs 数的是"算术单元干了多少活",但能耗的大头经常不在算术单元。你每从内存搬一个数过来,花的能量可能比乘它一下还多。同样一次乘加,数据在寄存器里躺着是白菜价,从 DRAM 拉过来是市场价。矩阵形状一变,数据复用率就变——FLOPs 不动,内存流量大动。这就是代理量失灵的机制根源,没什么玄的。
还有更狠的一层。GPU 上你算得越"满",FLOPs 和能耗才越接近正比;一旦 kernel 之间有空隙、tensor 没填满、搬运在等,电照样烧、FLOPs 不长。论文里学生模型能耗降 90% 而 FLOPs 没降那么多,我猜(没验证,纯猜)很大一块就是这类结构性浪费被蒸馏顺手挤掉了——小模型算子排得密,单位 FLOP 的含金量变了。
所以论文那句"别用 FLOPs,直接用 CPU/GPU 能耗替代模型来引导蒸馏",翻成我们这行的话就是:优化你测得到的量,别优化你算出来的量。他们的多目标搜索(用的 Morph)拿一个失真的代理量当目标,方向一开始就是歪的;换成实测能耗的替代模型,蒸馏才真的在省电。
顺手说一个我量的时候撞见的坑
第一版读 RAPL,两次采样之间的数字偶尔会"变小"。这个我没料到,查了一下——能量计数器到顶会回绕,32 位装满就归零。所以正确写法要处理回绕:
def read_energy_uj():
with open("/sys/class/powercap/intel-rapl:0/energy_uj") as f:
return int(f.read())
def energy_delta(e0, e1):
# 这一行干这件事:处理计数器回绕
MAX = 2**32 # 大多数机器是 2^32 微焦耳一圈,具体看 max_energy_range_uj
return (e1 - e0) % MAX
这个小坑其实是同一个道理的又一个注脚:连"读一个数字"这么直接的事里头都有坑,FLOPs 那种从形状推出来的数字能有多干净?论文作者做能耗替代模型而不是接着用 FLOPs,估计也是被这类现实教育过的。
收个尾
一句话:FLOPs 是形状的函数,能耗是整台机器的函数,两者只在理想工况下勉强相关。下次再看到"XX 模型只需 YY FLOPs 所以很省电",心里自动打折——真想知道省不省,energy_uj 读出来量一遍,五分钟的事。
论文里的蒸馏细节、Morph 怎么摆弄 Pareto 前沿,这篇我没碰,那是他们的活。我们今天的轮子就是个能耗测温计,糙是糙了点,但它量的是真的焦耳。生产里要做严肃能耗分析,请老老实实用人家那套验证过的替代模型,别拿我这两百行裸奔。
下一篇想搓 RAPL 的完整读数(含 DRAM 那个域),顺便量量 nvidia-smi 的功率采样到底有多粗。到时候见。