苹果神经引擎的带宽陷阱:如何夺回 50 GB/s 性能
Apple M3 Neural Engine 存在一个 RTL 性能缺陷:只要权重总大小是 1 MiB 的整数倍,DRAM 权重读取吞吐就会从标称的 45–60 GB/s 骤降至 17–19 GB/s,目前 ANEMLL 的 15 个模型中有 7 个受此影响。在 kernel DMA 引擎的推测性预取环路中绕开这条有问题的路径后,Llama 3.2 1B 的 token 吞吐从 10.0 提升到 24.3 tokens/s(DRAM 占用从 24.7 提升到 60.0 GB/s),Qwen3-8B 从 1.36 提升到 2.97 tokens/s(DRAM 占用从 22.4 提升到 48.7 GB/s)。
发现过程
我在测量单 token 解码时 Neural Engine 的 DRAM 权重读取吞吐(GB/s):
\[ X[1,D] \times W[D,N] = Y[1,N]. \]当 \(N=4096\) 时,我发现 \(D=1536\) 的速度比 Llama 3.2 默认的 \(D=2048\) 快了近 3 倍。
STATIC 模式(纯 KernelDMA)下每次重复的中位数耗时(µs),N=4096:
| D | 576 | 768 | 1024 | 1280 | 1536 | 2048 |
|---|---|---|---|---|---|---|
| rep a | 150.4 | 196.8 | 238.1 | 293.8 | 310.9 | 997.6 |
| rep b | 157.8 | 190.2 | 250.1 | 288.3 | 326.8 | 995.0 |
| rep c | 148.7 | 189.6 | 249.4 | 275.2 | 316.5 | 995.4 |
在 D = 2048 附近扫描 D 的取值:
怎么回事?
D=2048 时吞吐是 16.93 GB/s,D=2016 时是 44.5 GB/s,也就是说
44.505062 − 16.930761 = 27.574301 GB/s(下降 61.96%)。
吞吐从 44.5 骤降到 16.93 GB/s,整整少了 27.57 GB/s。这组扫描数据是在 M3 Air 上采集的,重复 40 次取结果,全部在同一次运行、相同的热负载条件下完成。 我还确保 ANE 寄存器文件中只有 DMA 的 size 和 address 是唯一在变化的变量:
D=2044 D=2048 D=2052
TD+0x004 estimated cycles 0x000001ea 0x000001eb 0x000001ec
TD+0x078 core 1 base 0x000ff800 0x00100000 0x00100800
TD+0x07c core 2 base 0x001ff000 0x00200000 0x00201000
...
TD+0x0b0 core 15 base 0x00ef8800 0x00f00000 0x00f07800
TD+0x0b4–0x0f0 core sizes ×16 0x000ff800 0x00100000 0x00100800
TD+0x134 Common.Cin 0x000007fc 0x00000800 0x00000804
TD+0x1f0 L2 source stride 0x00007fc0 0x00008000 0x00008040
TD+0x1f4 unknown stride mirror 0x00007fc0 0x00008000 0x00008040
TD+0x214 L2 result base 0x00008fc0 0x00009000 0x00009050
随后,我遍历了 D 的整个孔径范围。
这个思路很奏效,我发现 D = 2048 处存在一个谐振峰。没想到我会对吞吐量(GB/s)关于张量维度(D)做 FFT,但结果就摆在这里:
显然,内存控制器在张量维度空间中存在一个波长为 2048 的主导谐波。遗憾的是,这是一个谷值 :(。
D = 2048 的所有倍数都受到同样的限制,带宽被卡在 17-19 GB/s 的固定下限:
在 D = 2048 的倍数处,吞吐量从名义上的 45–60 GB/s 急剧下降至 17–19 GB/s,而在距离约 256 行之后便恢复到正常水平。这并非 RTL 正确性 bug,因为 kernel DMA 仍能正确完成传输。但在 2048 附近的请求被强制进入一种缺乏信用的独立发射机制,导致吞吐量非合理地损失了 28–43 GB/s(最差情况 60→17),而这恰恰发生在非常常见的传输尺寸上。
假设 1 - DRAM 空间相关性
16 个核心是否在以 2 的幂次步长时映射到了同一个 DRAM bank?
DRAM 是一种并行数据接口:DRAM 带宽等于 DQ(数据)引脚数量乘以每个引脚的数据速率,
\[ \text{DRAM BW} = N \times R = 128\ \text{bit} \times 6.4\ \text{GT/s} = 102.4\ \text{GB/s} \]M3 的 LPDDR-6400 理论带宽为 102.4 GB/s,这与标称的 100 GB/s 相符。持续的 DRAM 带宽严格取决于 DQ 引脚的利用率;距离 102.4 GB/s 理论上限的每一分带宽缺失,都对应着 DQ 总线空闲的额外周期。 DRAM 要点:DRAM 内存控制器通过并行访问经由高速 DQ 引脚传输数据位;大容量 DRAM 阵列被划分为多个 bank,带宽(大致)取决于将并行请求分散到不同 bank 的能力。
如果资源独立,并行性就能提升吞吐量。若并行请求者争用同一资源,其请求将逐一串行执行,实际上会被单通道速率限制。例如,~17–19 GB/s 的受限下限(而相邻任务运行在 45–60 GB/s)可由在 2 的幂边界处发生的性能塌缩来解释。从底层分析也很有意义:如果额外的 AXI 请求针对同一物理 bank,则毫无增益。
核心争用
神经引擎具备多个并行维度,首要维度即核心级并行。ANE 拥有 16 个并行核心。核心通过均分缓冲区划分为 N 个切片,并各自处理不同切片来分摊工作量。已知每个核心负责获取权重缓冲区的不同切片,但 ANE 仍会有 16 个核心在同一周期内并行从 DRAM 请求各自切片。
若每个核心从 DRAM 获取独立切片,则无论启用 1 个还是全部 16 个核心,流式传输延迟应大致相同,因为请求是被并行处理的。然而,若核心争用导致带宽受限,则减少核心数反而可能提升 D=2048 受限场景下的吞吐量。针对 D=2048 和 D=2016 扫描活跃核心数:
对于 D=2016 和 D=2048,延迟在 1 到 16 个活跃核心之间保持恒定,说明即使 core=1 时也存在受限。问题位于单核心层面,并在各核心间复制放大。
地址争用
即便排除了核心层面的争用,我仍然怀疑存在 DRAM 争用,原因就是这个 2 的幂周期。像 2048 这样的 2 的幂步长,每次旋转相当于加上 \(2^k\),也就是低位 \([0..k-1]\) 保持不变。 DRAM 会对物理地址做哈希,让步长访问模式在各个 bank 之间空间去相关。如果哈希恰好让低位折叠,或者让高位 \(2^k\) 位混叠,就能解释这种 2 的幂周期性。
为了验证 DRAM 空间相关性是不是罪魁祸首,我们对读取权重的地址做了随机打乱。地址被随机打乱后分散在整个约 64 MiB 的 IOVA 区域内(跨度 59.90 MiB),因此既在页内打乱,也在页间打乱。为了排除无风扇 M3 Air 上的热漂移影响,基线样本和打乱样本在每轮运行中交替执行,这样热累积对两种条件的影响是均等的。
基线的吞吐量中位数为 31.37 GB/s,随机打乱地址后的吞吐量中位数为 32.29 GB/s。随机打乱平均只带来约 1 GB/s 的轻微提升,说明这一轮里打乱可能确实破坏了某些空间相关性,但 (1) 这并未在所有情况下得到证明,(2) 打乱也远不足以挽回解释性能崩塌所需的约 +200% 吞吐量损失。
假设 2 - RTL 整数回绕
回顾一下,崩塌在 D = 2048 的每个整数倍处重复出现:
问:什么东西会在恰好 2 的幂的整数边界上重复?
答:固定位宽数字逻辑里的整数溢出。
module line_counter (
input wire clk,
input wire reset,
input wire advance,
output reg [13:0] line_count
);
always @(posedge clk) begin
if (reset)
line_count <= 14'h0000;
else if (advance)
line_count <= line_count + 1'b1; // wrap at 0x3fff + 1 -> 0x0000
end
endmodule
Kernel 维度
\[ X[1,D] \times W[D,N] = Y[1,N]. \]其中
- \(D\)(Cin):每个 kernel 的长度,即 \(D\) 个 FP16(2 字节)权重,共 \(2D\) 字节。
- \(N\)(Cout):kernel 数量。每个核心处理 \(N/16\) 个 kernel。
最初的图表在固定 N = 4096 的情况下扫描 D,因此我们尚未真正查明那个“缺口”究竟是由 \(D\) 本身引起,还是由 \(D\) 与 \(N\) 的乘积引起——后者决定了每个核心在整个任务中必须处理的内核总字节数。
\[ \text{bytes/core} = \underbrace{\frac{N}{16}}_{\text{kernels/core}} \times \underbrace{D}_{\text{weights/kernel}} \times \underbrace{2}_{\text{bytes/weight}}. \]假如 \(D\) 和 \(N\) 会影响每次切片传输的耗时,为了分离这些未知变量,我们反向扫描 \(D\) 和 \(N\),使编译后的任务中每个核心的静态内核数据量都保持为 1 MiB。通过对比已执行寄存器文件的差异,可以看到只有相关字段(地址、大小)发生了变化:
任何 \(D\) 和 \(N\) 的组合,只要使每核心的内核总字节数达到 1 MiB,都会导致核心吞吐量跌落到我们观测到的 17 GB/s。既然驻留的“L1” KMem 每核心仅有 64 KiB,我们现在明白了,在 1 MiB 数据量上确实存在某种推测性预取/信用机制在起作用。
反过来说,这也意味着只要传输量不是 1 MiB 的整数倍,我们就能避免这种性能坍塌。编译器可以通过拆分任务来规避此问题——只要某个任务编译后每核心的内核 DMA 恰好为 1 MiB,就将其拆分。
Speculative Prefetch
高带宽内存控制器基于多种理由,会按最小传输行粒度而非单字节进行操作(https://www.goodreads.com/quotes/11711388-of-course-i-d-also-suggest-that-whoever-was-the-genius))。
逆向工程是一门艺术。如果所有传输都基于某种行粒度(例如内核 DMA 的 64 字节行粒度),那么内核 DMA 控制器和任何预取逻辑也都会以行为逻辑单位而非字节来编写。现在让我们转变思维,以“行”为单位来思考:
当 N=4096 时,每次 \(D\) 增加 2048,请求的总字节数就会增加 1 MiB:
\[ 256\ \text{kernels/core}\times4\ \text{KiB/kernel} =1\ \text{MiB/core}. \]如果内核 DMA 的行粒度是 64 字节(我们从 \(2^6\) 字节对齐的地址可知这一点),那么 1 MiB 的传输将总共请求 \(\texttt{0x4000}\) 行 64 字节的数据:
\[ 1\ \text{MiB/core}\div64\ \text{B/line} = 16{,}384\ \text{lines/core} = \texttt{0x4000}\ \text{lines/core}. \]这表明计数器在 \(\texttt{0x4000}\) 行处回绕。
always @(posedge clk) begin
if (reset)
line_count <= 14'h0000;
else if (advance)
line_count <= line_count + 1'b1; // wraparound at 0x3fff + 1 -> 0x0000
end
在 \(\texttt{0x4000}\) 或 \(2^{14}\) 处发生的回绕恰好对应 14 位存储宽度。那还有什么也等于 \(2^{14}\)?Apple 芯片上使用的 16 KiB 虚拟内存页大小。在使用 16 KiB 页时,地址位(\([13:0]\))即为页内偏移量,且不受虚拟到物理地址转换的影响。若预取算术操作仅针对低地址位(addr & 0x3fff),则会产生 14 位的回绕。
定义 \(k\) 为传输跨越的 \(\texttt{0x4000}\) 行周期的数量,我们称这种周期间隔为一圈(lap):
\[ k \equiv \frac{D}{\texttt{0x4000}}, \]定义 \(x\) 为距离第 \(k\) 个刻度的行数:
\[ \text{lines/core}= k\cdot\texttt{0x4000}+x \]以 \(x\) 为归一化变量,对于所有围绕 \(\texttt{0x4000}\) 的 \(k\) 圈,V 形凹陷均在刻度附近恰好 \(x = \pm256\) 行处恢复。
64 B/line * 256 lines = 16 KiB = one page.
凹陷恰好出现在相当于一个虚拟内存页大小的 DMA 行数区间内。这表明存在一个深度为一页的预取前瞻窗口。
手动将 \(D=2048\)(\(k=1\))和 \(D=4096\)(\(k=2\))的带宽曲线重叠对比,并重新校准至凹陷中心,两条曲线几乎完全一致:
绘制每个 \(k\) 在 \(x = 0, 32, 64, 128, 256\) 各采样点上的带宽(左图);注意各 \(k\) 曲线向内收拢,并在 \(x \to 0\) 处收敛。
一个重要发现是:每条 lap-\(k\) 的时间曲线其实就是 lap-1 曲线在纵轴上放大 \(k\) 倍的结果——lap 6 比 lap 1 陡峭约 6 倍。 把每个凹口的中心重新对齐到 \(k\cdot\texttt{0x4000}\) 之后,\(k\) 条带宽曲线完全重合为同一条线。实测数据中每圈的斜率确实与 \(k\) 呈线性关系(各自 R² = 0.96–0.99),斜率 \(= 3.18 \cdot k\) µs/line(右图)。
综合这些观察可以得出:
(1) 限速传输的"曲线"每 \(k\) 圈重复一次。如果每个周期都经历同样的限速带宽曲线,那么 \(k\) 个周期就有 \(k\) 倍的字节经过同一条曲线,时间曲线自然陡峭 \(k\) 倍。
(2) 每个周期内的带宽曲线主要由相对中心点的位移 \(x\) 决定。也就是说,同样的 \(x\) 每隔 \(\texttt{0x4000}\) 行就会复现同样的带宽状态。因此内部状态只知道当前 1 MiB 圈内的位置,并不知道传输处于第几圈、还剩多少圈。\(k=6\) 的传输中经历同样 \(x\) 依赖速率的字节约为 6 倍,所以其额外延迟也约为 \(k=1\) 的 6 倍。
(3) 在 \(x=0\) 处,零乘零等于零,每个周期都落在完全相同的病态状态上。因此无论 \(k\) 取多少,传输速率都坍缩到同一个 \(B(0)\)。真正随 \(k\) 缩放的不是带宽坍缩本身,而是以这个坍缩速率传输的数据量:
换句话说,\(x\) 决定带宽状态,\(k\) 决定这个状态重复多少次。
预取环的 lookahead 一次请求一个 1 MiB 的环。 环把总传输量看作 \(k\) 个重复的 1 MiB 池:
\[ S(k,x) = 64\,(k \cdot \texttt{0x4000} + x) = k \cdot 1\ \text{MiB} + 64x \quad \text{bytes/core}. \]如果每次 1 MiB 预取的带宽曲线为 \(B(x)\),那么总传输时间为:
\[ t(k,x) = \frac{S(k,x)}{B(x)} = \frac{k\cdot1\ \text{MiB}+64x}{B(x)}. \]对 \(x\) 求斜率:
\[ \boxed{ \frac{\partial t(k,x)}{\partial x} \approx -k\cdot1\ \text{MiB}\, \frac{B'(x)}{B(x)^2} } \]在 \(x\) 较小(<256)时,结果由 \(k\) 主导:
\[ \boxed{ \frac{\partial t}{\partial x}\propto k. } ]基准吞吐 18 GB/s 对应每 16 MiB 一轮(lap),折合 900 µs/lap(与 median_us(x=0)/k = 900 吻合)。从基准值(约 18 GB/s)恢复至肩部区间(约 45 GB/s)横跨 256 行,平均每行耗时减半,即 (900−380)/256 = 2 µs/line。若在边界附近斜率更陡(约 3.2 倍),则 3.18 µs/line/lap 的量级是合理的。
疑似 RTL 缺陷
最可能是内核 DMA 中投机预取环(speculative prefetch ring)的 14 位头/尾地址运算遗漏了回绕/纪元位(wrap/epoch bit)。于是当传输长度恰好为 \(2^{14} = \texttt{0x4000}\) 的整数倍时,“还剩一整圈”与“空”发生别名冲突,导致自身预取请求流水线饿死。投机路径无法在消费之前发出足够多的请求,因此数据仍会被取回(这不是正确性 bug),但本应带宽受限的流式传输被退化为走走停停。
localparam int RING_LINES = 1 << 14; // 0x4000 lines
localparam int PREFETCH_MAX = 256; // 256 lines
logic [31:0] transfer_lines; // full DMA length
logic [13:0] rd_ptr, [13:0] end_ptr, [13:0] distance; // 14-bit prefetch ring
logic [8:0] prefetch_credit; // 0..256 lines
// Only the low 14 bits enter the prefetch ring.
assign rd_ptr = start_line[13:0];
assign end_ptr = (start_line + transfer_lines)[13:0];
// Distance in the 14-bit ring.
assign distance = end_ptr - rd_ptr;
// Prefetch at most 256 lines = 16 KiB ahead.
assign prefetch_credit = (distance > PREFETCH_MAX) ? PREFETCH_MAX : distance;
Apple Silicon 使用 16 KiB 页(\(2^{14}\) 字节),使其倾向于按 14 位地址操作,因为 \(\text{addr}[13:0]\) 即同一连续页内的页偏移,在虚拟到物理地址转换中保持不变。然而 14 位运算对间隔 \(k\cdot\texttt{0x4000}\) 的所有分隔产生别名效应,即间隔 0x4000 的传输会复现相同的内部环状态。
// Only the low 14 bits enter the prefetch ring.
assign rd_ptr = start_line[13:0];
assign end_ptr = (start_line + transfer_lines)[13:0];
实际上,0x4000 行的周期性可由 14 位行指针的回绕运算解释:
由于缺少标记“完整环绕”的 epoch 位,预取器在整个 0x4000 行的传输过程中均未发起任何预取请求,导致整个传输被限制在缓慢的无推测路径上,带宽卡在 17–19 GB/s,这很可能就是序列化路径的表现。对至多一个页面执行预取 lookahead 是合理的。一个 64-granule 行指针的 256 行正好映射到一个 16-KiB 页面:
// 预取至多 256 行 = 16 KiB = 1 个页面
assign prefetch_credit = (distance >= 256) ? 256 : {1'b0, distance[7:0]};
这解释了为何带宽缺口在 256 行或一个页面的窗口内能完全恢复。 更准确地说,怀疑该环形实现利用环形区距离 \(x\) 来限制预取器可发起的额外 lookahead 请求数量:
// 14 位环形区中的距离
assign distance = end_ptr - rd_ptr;
// 预取至多 256 行 = 16 KiB
assign prefetch_credit = (distance > PREFETCH_MAX) ? PREFETCH_MAX : distance;
\[
\text{credit}(x) = \min(x, 256).
\]每越过边界移动 \(x\) 行就会返还 \(x\) 个信用额度(每 64 字节行对应一个 refill 信用额度),上限为 256 行。这与恢复趋势在单个页面内近似线性的观察相吻合。此计算过程不会涉及 \(k\);当传输 \(k\cdot\texttt{0x4000}\) 行后,环形指针会在完成 k 次完整环绕后落在相同的指针值上,且每一圈对应的曲线 \(B(x)\) 都相同。
内核 DMA 以 64 字节行宽为粒度操作,因此 14 位行指针实际覆盖 2^14 * 2^4 = 1 MiB。一种合理的解释是,预取器遍历页面的 256 行,然后递增一个 6 位页槽:
\[ \text{prefetch ptr}[13:0] = \underbrace{\text{page ptr}[5:0]}_{64\ \text{pages}} \;\Vert\; \underbrace{\text{line ptr}[7:0]}_{256\ \text{lines/page}} \]页 0: 行 0 ... 255
页 1: 行 0 ... 255
...
页 63: 行 0 ... 255
页 0: 行 0 ... 255 // 环绕
在这种模式下,256 行即为 lookahead 深度,而 0x4000 行等于 64 个页面的完整环形环绕一周。这同时解释了 0x4000 的周期性以及 256 行的线性缺口窗口。
根本原因似乎是预取距离是在方便的 14 位地址域中计算的。
// BUG:
distance = end_ptr - rd_ptr; // modulo 0x4000
// FIX:
distance = transfer_lines - issued_lines; // compute with 32 bit
软件修复
修复方法:不要请求 1 MiB 的 kernel 传输。最简单的软件规避方案是找出所有总量恰好落在 1 MiB 上的 kernelDMA 任务,把 1 MiB 拆成非 1 MiB 整数倍的块,比如两次 512 KiB 传输。派发 N 个任务带来的亚毫秒级延迟开销,相对于灾难性的 30 GB/s–50 GB/s 预取限速来说微不足道。第二种方案是给传输加填充,使其偏离 1 MiB 约 ±8 KiB(256 行),但这改动更大,因为它会改变计算图。
把每核 1 MiB 的传输拆分后,带宽恢复正常:
- 一个
0x4000行的任务:17.25 GB/s。 - 两个
0x2000行的任务:45.52 GB/s,快 2.66 倍。 - 四个
0x1000行的任务:44.83 GB/s,快 2.60 倍。
对照组证实,2.6 倍的提速确实来自规避了预取 bug:对原本就不是 1 MiB 整数倍的传输(每核 1 MiB−16 KiB)做拆分——它本来就不会触发预取 bug——完全没有提速。而对于恰好是 1 MiB 整数倍、因而受预取 bug 影响的传输,拆分后获得了 2.6 倍提速。收益正是来自避开这个有问题的预取 bug。
结果
DRAM 吞吐量
原始版本在所有 1 MiB 整数倍传输下吞吐量都卡在 17–19 GB/s;而用 512k 拆分的版本则干净利落地攀升到标称的 ~60 GB/s。
| D | 传输大小 | 原始(未拆分) | 修复后(分块) | 提速 |
|---|---|---|---|---|
| 2048 | 1 MiB | 17.3 GB/s | 43.5 GB/s | 2.51× |
| 4096 | 2 MiB | 18.4 GB/s | 52.1 GB/s | 2.84× |
| 8192 | 4 MiB | 18.8 GB/s | 57.8 GB/s | 3.07× |
| 12288 | 6 MiB | 19.0 GB/s | 59.8 GB/s | 3.15× |
| 16384 | 8 MiB | 19.1 GB/s | 60.5 GB/s | 3.16× |
LLM 性能
受影响项目 https://github.com/anemll/anemll:
| 模型 | 投影层 | Cin×Cout | k (MiB/lane) | 分片 S | chunk MiB/lane |
|---|---|---|---|---|---|
| Llama 3.2 1B | gate/up/down | 2048×8192 | 2 | 4 | 0.5 |
| Llama 3.1 8B / DeepSeek / DeepHermes 8B | q, o | 4096² | 2 | 4 | 0.5 |
| 同上 | gate/up/down | 4096×14336 | 7 | 2 | 3.5 |
| DeepHermes 3B | gate/up/down | 3072×8192 | 3 | 2 | 1.5 |
| Qwen3-8B | q, o | 4096² | 2 | 4 | 0.5 |
| 同上 | gate/up/down | 4096×12288 | 6 | 4 | 1.5 |
| Gemma 3 4B | lm_head 分片 | 2560×16384 | 5 | 2 | 2.5 |
Llama 3.2 1B
10 tok/s -> 24 tok/s
我将其拆分为两个部分约减(partial reductions),使编译器生成两个 0x2000 行的 KernelDMA 任务,而不是将它们合并回一个 0x4000 行的任务。例如,Llama 的补丁将 MLP 的三个 1×1 卷积拆分处理。
Qwen3-8B
1.36 tok/s -> 2.97 tok/s










