← 文章 / 芯片硬件
Hacker News 5小时前 · 2026-09-13 18:43:16 · 4 阅读

苹果神经引擎的带宽陷阱:如何夺回 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:

D5767681024128015362048
rep a150.4196.8238.1293.8310.9997.6
rep b157.8190.2250.1288.3326.8995.0
rep c148.7189.6249.4275.2316.5995.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传输大小原始(未拆分)修复后(分块)提速
20481 MiB17.3 GB/s43.5 GB/s2.51×
40962 MiB18.4 GB/s52.1 GB/s2.84×
81924 MiB18.8 GB/s57.8 GB/s3.07×
122886 MiB19.0 GB/s59.8 GB/s3.15×
163848 MiB19.1 GB/s60.5 GB/s3.16×

LLM 性能

受影响项目 https://github.com/anemll/anemll

模型投影层Cin×Coutk (MiB/lane)分片 Schunk MiB/lane
Llama 3.2 1Bgate/up/down2048×8192240.5
Llama 3.1 8B / DeepSeek / DeepHermes 8Bq, o4096²240.5
同上gate/up/down4096×14336723.5
DeepHermes 3Bgate/up/down3072×8192321.5
Qwen3-8Bq, o4096²240.5
同上gate/up/down4096×12288641.5
Gemma 3 4Blm_head 分片2560×16384522.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

原始来源: Hacker News

评论 (0)