三年前,作者停止了逆向工程苹果神经网络引擎(Apple Neural Engine, ANE)驱动的工作,因意识到该模块用处不大而转去向上游提交其他模块。ANE 架构过于专断,Linux 驱动也无法拓宽负载类型,macOS 仅在 Finder 中用其生成预览图。M5(2025)将 ANE 核心并入 GPU 核心,独立 NPU 似走向终点,于是决定回头完成苹果 M1 芯片(M1)上 ANE 的逆向工程。
正文
三年前我停止维护逆向工程出的 Apple Neural Engine(ANE)驱动,因为一个有点难过的微小醒悟:ANE 这个块就是没那么有用,我可以去做更有用的事,于是转去上游化其他更有用的块。ANE 的架构太有主见,没法围绕它搭通用加速器平台,一个打开 ANE 硬件 API 访问的 Linux 驱动也无法拓宽它能做的工作负载类别。即便 macOS 也主要只用自家 ANE 在 Finder 里生成放大预览图。

M5(2025)的主打功能是“LLM 性能”,而且顺手把 ANE 核心折进 GPU 核心里——我知道这会来,但官方感觉就是独立 NPU 终结的开始。所以,为了纪念 ANE 的显然死亡,我们做件更没用的事:回头逆向 M1 上的 ANE,把当初开始的事做完。三年了(见鬼),我应该比第一次搞它时懂得更多。
如果三年前的目标是让 ANE 跑算子变得有用;这次更多是画出完整内部架构——计算、数据通路、调度器、内存、执行模型——因为这些内部设计决策暴露了 Apple 在 A11 Bionic(2017)愿意先烧进硅片的 ML 工作负载假设,也说明从 CNN 时代 NPU 到今日跑 transformer 的 GPU 的转变。
1. 计算
16 个计算核心大概是 ANE 最没意思的部分。Apple 最初瞄准密集图像处理 CNN 工作负载,也就是有可预测复用模式的密集张量归约。M1 ANE 计算核心是一大块并行 multiply-accumulate(MAC)阵列,但这本身几乎说明不了它为何种工作负载设计、又擅长什么。

卷积层在激活窗口和学到的卷积核权重之间做点积,attention 在 query 和 key 向量之间做点积。点积就是点积,MAC 就干这个。让 ANE 专门适配 2017 年 CNN 模型的不是 MAC,而是 MAC 周围的数据流:MAC 输入输出何时、何地进入、停留、移动。transformer 打破的假设,尤其是自回归 decode,是可预测复用模式,而 ANE 正是利用它做出足够高效、能跑在手机上的数据流。M5 的决定确认 ANE 的计算核心对 transformer 仍有用,只是处在不同数据流里。
每个计算核心内部的数据通路是:256× MACs,MAD→加→累加器→激活。
MAC:ANE 有 16 个并行计算核心。每个核心有 128 条 FP16(或 256 条 INT8)并行 MAC 通道。每条 MAC 通道做递推:把两个操作数相乘,再把乘积加到运行和(累加器)上。在 T 个周期上重复 MAC 就计算 T 项点积。因此一条 MAC 通道在时间上做标量归约。16 核 ANE 有 2048 条并行 MAC 通道,所以每个周期在空间上做 2048 个并行归约,时间成为唯一归约轴。单条 MAC 通道不知道自己在归约矩阵或张量的哪个维度。点积、矩阵乘法还是卷积,来自操作数如何映射、调度到核心上。除后面讨论的 kernel memory 外,ANE 核心并不把 4 通道 CNN 层编码进硬件。
MAC 数据通路内部有乘法器、加法器、32 位累加寄存器。每个周期,加法器把新乘法输出与上一个和相加,成为新的运行和。这个反馈路径把部分和留在 MAC 通道本地,避免在 MAC 周期之间从远处外部内存取。
精度上,它做定点归约,读出为 FP16。乘法器 16 位,累加在 32 位寄存器中作为 Q16.16,再通过符号扩展等读成 FP16。用整数(十六进制)FP16 表示探测累加器范围:构建一个 CoreML ANE 程序,计算与全 1 向量做点积,每个乘法结果有界 v,但累加器里的运行和不断增长。
| v | CPU 十六进制 | CPU 值 | ANE 十六进制 | CoreML 值 | | --- | --- | --- | --- | --- | | 127.9375 | 0x77ff | 32752 | 0x77ff | 32752 | | 128 | 0x7800 | 32768 | 0x7c00 | +∞ | | −128 | 0xf800 | −32768 | 0xf800 | −32768 | | −128.125 | 0xf801 | −32800 | 0xfc00 | −∞ |
因为 32768 本身是合法 FP16 字(0x7800),ANE 的 0x7c00 不可能是 FP16 输出溢出,钳位发生在累加器内部。累加器在 Q16.16 有符号 32 位定点范围内饱和,即约 ±32768。
非线性激活:对于融合层,ANE 计算 MAC 后直接接激活。重要的是,完成的 MAC 和直接送入 MAC 后激活块,避免中间内存往返。这能成立是因为激活是逐点的:一个标量归约完成后,激活只依赖这个标量,可以立即应用。
为确定 ANE 如何实现 tanh(),编译一个只含 TANH 激活层的 CoreML 模型,检查编译出的硬件寄存器文件(hwx)。系数区域从 0x4288 开始有 33 个连续 FP16 字,它们匹配 tanh 的 33 个 IEEE 小端 FP16 量化采样。

换 RELU 激活层:identity 模式 0 无查找系数;ReLU 模式 1 无;tanh 模式 2 有 33 个 FP16 字。因此模式 2 选择一个自定义 33 项查找表。33 点定义 32 个区间,从样本看节点覆盖 0 到 4、间距 0.125。输入按 x*scale 映射进表,scale 决定节点间距。分辨率比 33 个 bin 看起来更平滑,我怀疑相邻项之间线性插值。测试:构建只有一个尖峰的单脉冲 LUT。

然后把输入扫过某个节点两侧的两个格子。测得输出形成三角形:幅度从某点线性升到峰值,再线性降到另一点。因此模式 2 实现 33 项分段线性 LUT。scale 把输入映射到 LUT 坐标,决定节点间距;相邻项和插值权重用于输出。
CoreML 还支持线性缩放和偏置变换。我怀疑它可以共用模式 2 的线性插值硬件。为确认,构造一个带常量 scale 和 offset 的 ReLU 的 CoreML 模型。如果编译器把常量 scale 和 offset 折进卷积:解码 model.espresso.weights 证明确实对 ReLU 做了这种折叠:作者卷积 W=4, b=-2;激活 affine s=0.5, c=1;编译后卷积 W'=2, b'=0。

寄存器文件 hexdiff 显示 bias 和激活在编译时融合进同一条 MAC 后路径:普通卷积 BiasMode=0、PostScaleMode=0、NonlinearMode=0;显式 Core ML Bias:1、0、0;Bias+ReLU:1、0、1;Bias+tanh:1、0、2。
一个极其邪门的想法:用非线性插值计算额外一趟 kernel,或把 int8 量化成 int4 权重。
2. 调度器
ane 驱动源码无聊得令人失望。驱动从不给 ANE 一个 CONV、MATMUL 或 RELU 操作码去跑。所有神经操作都已经编译成任务描述符(TD)的命令流,驱动软件只是把任务载入内存,通过 TMADDR、TMSIZE 设置指向不透明任务 blob 的指针,然后敲门铃(TM_PUSH)提交已暂存的任务。硬件随后拥有提交直到完成,完成后向 ARM64 核发中断。
这个无聊的命令提交前端类似 GPU:想想 NVIDIA 的 pushbuffer/PBDMA。软件提交驻留内存的命令流,GPU 的命令处理器遍历命令流并分派命令,却不知道命令执行什么。TMADDR 和 TMINFO 是全局暂存寄存器,TMPUSH 原子地提交暂存启动状态,因为写 TMPUSH 之前什么都不会发生。TMINFO 存描述符总数:TMINFO[31:16]=descriptordwords-1,TMINFO[15:0]=descriptor_count。它自然映射到硬件计数器。为什么减 1?编码 length-1 是 RTL 友好的方式,让基于零的计数器在关键路径外终止。但注意,相比 GPU 命令在 ringbuffer 里解析变长描述符流,ANE 只收到总数,说明描述符是固定大小。
任务队列:任务管理器从什么里选择?有 8 份相同寄存器块(按 qid 0…7 索引),结构为:STATUS、PRIORITY、VACANT、INFO;BAR1[0..31]、NID1、SIZE2、ADDR2;BAR2[0..31]、NID2、SIZE1、ADDR1。每个 TQ 保存:每 TQ 调度状态(status、priority、vacancy);以及每 TQ 两组命令流描述符(ADDR1/ADDR2、SIZE1/SIZE2、NID1/NID2 和 32 个 BAR)。两个槽肯定是 ping-pong 暂存方案,让一个槽执行,软件修改另一个槽。
TMPUSH 通过附带 qid 执行 TMADDR/TMSIZE 引用的任务。自然解释是:描述符流指定跑什么任务,qid 选择描述符运行在哪个启动上下文。常驻 TQ 上下文(BAR、NID)很像 GPU 硬件通道。驱动填充单个 TQ 来启动它:写 TQSTATUS,写 32 个 BAR,写 SIZE1((td_size>>2)-1 左移 0x10),写 ADDR1,写 NID1。这里唯一重要的是 32 项 BAR(基地址寄存器)表。编译出的 ANE 命令流只通过相对偏移引用虚拟地址,BAR 提供基 IOVA(IOMMU 外设虚拟地址)重定位地址。一次 ANE 虚拟地址访问需要在编译时提供硬编码的 BAR 基址偏移,这意味着它缺少 GPU 式从虚拟地址动态发出 load/store 的指令。
任务描述符:任务管理器遍历并执行固定大小的 TD 链。每个 TD 里有什么?以最简单的 1x1 卷积 TD 为例。重要的是,TD 不是可执行指令流。ANE 没有 ISA。TD 是一串“ControlDMA”(我编的名字)突发写包,写到 ANE 的硬件配置寄存器,比如输入维度、输入/输出地址、激活函数。每个 ControlDMA 包由一个 32 位传输字后跟 N 个连续 32 位寄存器值组成。传输字中,31..26 位是寄存器数量减一,25..2 位是首个寄存器基址索引,最低两位为 00。又是减 1 终止计数。ControlDMA 是一个灵活的单向 DMA 引擎,把 N 个 32 位字从 IOMMU 虚拟 DRAM 复制到 ANE 的物理寄存器空间。例如 TD 里 KernelDMASrc 的包头 0xf401f800:count=(0xf401f800>>26)+1=62 words,register base=0xf401f800 & 0x03fffffc=0x1f800。这不是 LOAD_WEIGHTS 指令;它把 62 个连续字复制到从 0x1f800 开始的 KernelDMA 寄存器偏移。那些 KernelDMA 配置值可以告诉 KernelDMA 从哪加载权重。
| TD 起始字节 | 区段 | 信息 | | --- | --- | --- | | 0x000 | Header | 依赖、链式、BAR 选择 | | 0x028 | KernelDMASrc | 0xf401f800;16 条系数 DMA 通道 | | 0x124 | Common | 0x3c000000;张量与卷积几何 | | 0x168 | TileDMASrc | 0x6c013800;激活源 DMA | | 0x1dc | L2 | 0x44004800;本地源/结果配置 | | 0x228 | 处理引擎 | 0x0c008800;PE 配置 | | 0x23c | 神经引擎 | 0x1000c800;MAC 与后处理配置 | | 0x254 | TileDMADst | 0x18017800;结果目标 DMA | | 0x274 | End | 总计 628 字节 |
因为每个区段写一个 MMIO 寄存器块,TD 干净地划分成 ANE 的数据通路区段:
| 起始地址 | 大小 | 块名 | 内容 | | --- | --- | --- | --- | | 0x26bc00000 | 0x4000 | Common | 广播配置选择器;推断 | | 0x26bc04000 | 0x4000 | L2 | L2 后备/寄存器 aperture | | 0x26bc08000 | 0x4000 | PE | 处理单元配置 | | 0x26bc0c000 | 0x4000 | NE / MAC | kernel 格式、MAC、bias、缩放、非线性控制 | | 0x26bc10000 | 0x3000 | 未知 | 未识别寄存器组 | | 0x26bc13000 | 0x4000 | Tile DMA 源 | 输入 tile 地址、stride、格式、DMA 控制 | | 0x26bc17000 | 0x4000 | Tile DMA 目标 | 输出 tile 地址、stride、格式、DMA 控制 | | 0x26bc1b000 | 0x4000 | 未知/可调 | 未识别配置与可调寄存器 | | 0x26bc1f000 | 0x4000 | Kernel | kernel 后备 / kernel DMA 源 aperture | | 0x26bc23000 | 0x1000 | 未知 | 未识别寄存器组 | | 0x26bc24000 | 0x1000 | 任务管理器 | 任务提交、执行状态、事件、完成 | | 0x26bc25000 | 0x1000 | 任务队列 | 8 个队列,含 TD 栈、NID、优先级、请求指针 |
TD 实际上就是 ANE 数据通路寄存器的序列化寄存器文件转储。每个“ANE 程序”只是一趟数据通路的配置。我们可以配置固定数据通路如何运行(在它暴露的旋钮范围内),但不能配置数据通路能执行什么操作,或这些操作如何排序。
当把“魔法”原子字写到任务管理器执行一个 TD 时,大致发生:1. ControlDMA 把 TD 复制进配置寄存器。2. KernelDMA 把 kernel W 复制到 kernel memory(KMem)。3. TileDMA 把输入从 DRAM 复制到 L2。4. 每个 MAC 核心用权重归约一行,产生一行输出。5. 步骤 2–3 对所有输出行重复。6. 应用后处理,完成的结果存到 L2。7. TileDMADst 把结果从 L2 复制回 DRAM。
ANE 是固定功能数据流引擎,不是执行任意指令的 GPU。TD 配置一个领域专用数据通路。约束硬件接口通常意味着更小面积、确定性搬运、更低延迟、更少功耗。ANE 的编译器可以显式调度张量做什么,但这也意味着编译器必须显式调度张量做什么。这是权衡,但有理由:编译时通常知道模型长什么样。动态执行不是限制 ANE 的东西。ANE 的处理器接口相对通用,它只是启动任务,而任务可以描述 transformer。
例如,编译时固定张量大小并不意味着不能处理变长张量:增长的 KV cache 可以通过按大小循环遍历,而 dispatch 开销相对这里真正的大象——内存流带宽——可以忽略。真正让 ANE 为 CNN 而非 transformer 塑形的是内存搬运。
3. 内存
Roofline:先找出当前最慢的环节总是好的,这样才知道优化什么才重要。Apple 的统一内存让 ANE 能访问 CPU 和 GPU 可访问的系统 DRAM 池中的缓冲区。但这不意味着 ANE 零拷贝直接从那个 DRAM 池流式读取。ANE 必须先把任何内存复制进它本地的“ANE memory”或 SRAM。任何带宽受限任务都会被 ANE 本地内存流吞吐限制。
M1 ANE 额定 11 TOPS,系统 DRAM 带宽 68 GB/s。一个 MAC 执行两次运算,但消耗两个 FP16 操作数,即 4 字节。如果每个 MAC 操作数都从 DRAM 流式读取,维持 11 TOPS 需要流式传输约 22 TB/s,是报告系统 DRAM 容量的 300 多倍。因此峰值 ANE MAC 吞吐只能靠从某种本地 ANE 内存水库取数并复用来达到。
换句话说,M1 ANE 的 11 TOPS / 68 GB/s 设定 roofline 脊点:从 DRAM 取的每一字节平均必须支持至少 162 次运算,DRAM 带宽才不会成为限制器。等价地,工作负载必须提供足够片上复用,达到至少 162 OP/byte DRAM 流量的算术强度。低于 162:1,加速计算不会提高解码 token/s。
内存层级:即便平均 DRAM 带宽足够,ANE 也不会直接读 DRAM,原因很多,包括 DRAM 确定性时序、物理布线、共享流量等。如果 ANE 的流量在 AXI 上与 CPU、GPU、显示等竞争,它就无法给 MAC 提供确定性时序。而且如果 16 个核心消费某个输入 tile,我们不想发起 16 次相同 DRAM 传输。“ANE local memory”允许一个操作产生的中间激活由下一个操作消费,而不是去 DRAM 再回来。
Apple 有几种方式组织本地内存层级。MAC 专利描述了阵列周围的数据缓冲路径。层级是:KMem:16 个每核心 64 KiB 的 kernel “L1” SRAM,总共 1 MiB。L1:16 个每核心 MAC 输入“L1”暂存区。L2:1 个所有核心共享的 2 MiB L2。
我不会假装自己没反编译过东西。ANE ARM64 固件的任务调试例程:先转储 KMem 索引 0 到 15 各 0x10000 字节,然后单独转储一个 0x200000 字节的 L2。这确认了 64 KiB KMem 和 2 MiB L2。
DMA 引擎:有三个进出 MAC 的 DMA 引擎:KernelDMASrc[0..15]:16 条逻辑系数通道把权重从 DRAM 复制到匹配的每核心 KMem bank。TileDMASrc:把 tile 从 DRAM 复制到 L2。TileDMADst:把 tile 从 L2 复制到 DRAM。
16 个 KernelDMASrc 寄存器通道本身不能证明有 16 个物理独立 DMA 前端。一个通道缩放实验确实显示逻辑通道能并发推进:32 个有效任务、每个启用通道 1 MiB 聚合系数,在 1 条和 16 条通道下耗时基本相同。它们仍可能合并进共享的请求生成、crossbar、cache 和 DRAM 仲裁。
三个 tile/kernel DMA 引擎很随意。你给它任意 src、dst、size,它就传输那块内存。但三个 DMA 引擎的存在暴露了一些有趣的假设:
- kernel 有一条专用路径。把 kernel 与 tile 分开,是承诺 kernel 是一类不同操作数,生命周期不同。
- KernelDMA 是单向只加载。他们认为权重不会被写回。
- TileDMA 是双向且共享的。中间 tile 输出可以喂回去,不用绕 DRAM。
- kernel memory 每核心私有(复制 16 份)。他们认为权重会在每核心被多次复用。
- 新 kernel 必须从 DRAM 加载,而不是 L2。他们大概没想到要流式加载超过 64 KiB × 16 的大权重。
Kernel L1:卷积不是 a 和 b 的对称乘加。卷积让同一个 kernel 在输入上滑动。两抽头 kernel w0、w1 可以加载一次,然后在整个输出期间复用:shift 0: a0,a1→y0;shift 1: a1,a2→y1;shift 2: a2,a3→y2。所以 kernel 可以驻留本地内存并复用,同时新输入移入;这就是 Apple 数据通路专利描述的。

预期稳态是:激活从 DRAM→L2→MAC,输出回到 DRAM;系数从 DRAM→KMem。如果设计 ASIC 来大量做卷积——kernel 不是频繁动态流入的东西(咳 KV)——利用操作数不对称、减少 kernel 内存搬运是显然的,因为我们已经证明 ANE 在 MAC 饱和之前仍深陷内存吞吐受限区(162:1)。
但为什么不允许 L2→KMem?统一 tile 和 kernel 路径似乎更容易。(1) 这样 kernel 流量就不会和 tile 争 L2 带宽?我认为 L2 争用不是 Apple 省略 L2→KMem 路径的原因:常驻 KMem 存在,是因为预期 kernel 加载不频繁。如果稳态 KMem 流量可忽略,可以简单给它比 tile L2 访问更低的优先级。(2) 因为 kernel L2 分发增加复杂度?Apple 已经把 L2 端点分发给每个核心;我认为 kernel 路径搭同一 tile 路径没那么糟。
ANE 物理布局以(字面意义上的)中心 L2 SRAM 矩形为中心,矩形每侧有 7+7 个核心,顶边 2 个核心,底边共享控制逻辑。侧边 7+7 核心从青色垂直主干水平取 L2 入口;但顶部两个核心需要同样的水平宽接口旋转并垂直引出,这很可能产生顶部入口显眼的垂直梳。我承认这是纯纸上谈兵,但加 KMem 的 L2 mux 我认为真的没那么糟。如果 kernel 取数回到 DRAM,ANE decode 性能不会那么惨。这让我觉得 Apple 根本没预料到 L2 驻留张量会变成 kernel,这在 2017 年是有效假设。Apple 也喜欢开发隔离的模块化模块,可能更容易把 1 MiB KMem(只读,能省一点 SRAM 布线面积)和 2 MiB tile L2 的开发完全隔离。
4. 结束了吗?
DRAM 吞吐:GPU 比 ANE 快吗?Transformer 单 token decode 是权重复用和每内存计算比的最坏情况,因为生成一个 token 需要流式读取整个模型的权重。但如果 ANE 和 GPU 都受读带宽限制,谁有更高读带宽,谁就能解码更多 token/s,不管峰值计算能力。既然 ANE 和 GPU 共享同一 DRAM,这就是公平比赛:ANE 在 DRAM 访问上不一定比 GPU 差。如果 ANE 在单 token decode 上比 GPU 慢,那是因为它的 DMA 控制器无法维持足够并行请求来打满 DQ。
测量 ANE 对 GPU 的 DRAM 读吞吐:生成一个读带宽受限工作负载(缓冲区远大于 cache,填入真正的伪随机数据,只消费一次),测量执行时间,对不同读大小重复。拟合斜率回答:多一次 DRAM 读需要多少额外执行时间?其倒数是设备持续 DRAM 读带宽。

ANE KernelDMA:37.99 GB/s:CoreML kernel 大小 / 执行时间。ANE TileDMA:59.08 GB/s:CoreML tile src 大小 / 执行时间。GPU:77.70 GB/s:Metal buffer 读取 / 执行时间,通过一个 shader,它把每个 private uint4 读一次,并写一个依赖数据的校验和。
ANE 的 kernel(操作数 A)最高 38 GB/s,tile(操作数 B)最高 60 GB/s。我们能不能发出 38+60=98 GB/s 来打到 M3 的 100 GB/s DRAM 上限?

不能。实验显示 kernel+tile 合并运行时间等于各自隔离运行时间之和。如果请求有重叠,较短路径贡献会很小或没有额外时间,但执行时间(不是吞吐)严格单调。

因此,ANE 的 kernel 和 tile DMA 请求是串行发送的(一次一个),意味着 ANE DRAM 吞吐双重受限:隔离的 kernel 和 tile DMA 都低于 GPU 的读 GB/s;kernel 和 tile DMA 时间还是相加的。
除非……?如果 ANE decode 被钉在 DRAM roofline 上,像 10 -> 25 tok/s 这种 2.5 倍的大改善只能来自约 2.5 倍更高的内存流带宽。但……如果我们能给 kernelDMA 吞吐再加 50 GB/s 呢?
评论
社区肯定文章质量但指出其误将ANE与M5+ GPU中的NAX混为一谈,讨论主轴围绕ANE实为CNN而非Transformer设计展开,并补充了部署技巧与未来规划。
认为ANE实际是为CNN而非Transformer设计
- 文章令读者意识到ANE及数据管道针对卷积神经网络优化,而非当前主流的Transformer,这解释了其实际算力贡献不如预期的现象。(较多人)
- > 我原本不知道ANE是为CNN而非Transformer设计的,这解开了它为何不像我以为的那样有影响力的困惑(@CraigJPerry)
指出文章混淆了ANE与神经加速器NAX
- 有读者纠正文章引言将ANE等同于M5以后GPU中的NAX,强调两者架构迥异,且苹果仍在独立研发ANE。(少数人)
- > 文章开头似乎把ANE和M5+ GPU里的NAX搞混了,它们是完全不同的东西(@zozbot234)
有价值的补充
- 将Transformer移植到ANE时可伪装成CNN:使用4D张量且序列置于末轴,以1x1卷积替代矩阵乘法(@hn9zmdcaou)。
- 苹果后续芯片将继续强化ANE,传闻M6与A20将配备加倍的ANE模块(@zozbot234)。
- 关于M4及后续ANE是否仅性能迭代还是暴露新能力,社区指向第三方研究但暂未达成共识。