> ## Content Index
> Fetch the complete content index at: https://blog.hacking.pub/llms.txt
> Use this file to discover other available public pages before exploring further.

# Kapitsa 源码拆解 03：FFN 到底怎样分给 GPU 和 ANE？
- URL: https://blog.hacking.pub/2026/10/26/kapitsa-919b1b2-3/
- Published: 2026-10-26T17:43:44.000Z
- Updated: 2026-10-26T17:43:44.000Z
- Description: 先分清 token 行、隐藏维度和 FFN 中间通道，再看分工公式、W8A8 与 IOSurface。
- Author: chainhelen
- Tags: Kapitsa, ANE, FFN

“GPU 和 ANE 一起跑 FFN”最容易引起的误解，是把一句话的一半交给 GPU、另一半交给 ANE。这个快照的 dense FFN split，首先切的是**中间特征通道**。token 行怎么排队，是另一个问题。

**版本范围：**本系列固定阅读 [Kapitsa 提交 919b1b2a6782ec205e23725dee76ee5f57de2857](https://github.com/nace-ai/kapitsa/tree/919b1b2a6782ec205e23725dee76ee5f57de2857?ref=blog.hacking.pub)；该快照的 [CMakeLists.txt](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/CMakeLists.txt?ref=blog.hacking.pub#L1-L8) 标注版本为 **0.2.17**。以下是源码拆解与教学算例，不是本机性能实测；其他版本的默认参数和实现可能不同。

## 先把 FFN 的输入和中间结果命名

这里的 FFN（前馈网络）采用 SwiGLU 门控形式。把进入 FFN 的归一化输入叫 U，形状为 `[T,D]`：T 行 token，每行 D 个特征。门分支与 up 分支把它投影到 I 个中间特征，得到 G 和 P。数学上可以写成：

\\\[ F=\\operatorname{FFN}(U)=\\bigl\[\\operatorname{SiLU}(UW\_g)\\odot(UW\_u)\\bigr\]W\_d \\\] \\\[ \\begin{aligned} G &= UW\_g \\\\ P &= UW\_u \\\\ H &= \\operatorname{SiLU}(G)\\odot P \\\\ F &= HW\_d \\end{aligned} \\\] 

**G：门分支的投影结果。**形状 \[T,I\]，经 SiLU 后用于调节 up 分支各个特征的贡献。

**P：升维分支（up）的投影结果。**形状 \[T,I\]，保存待门控的中间特征。

**H：门控后的中间特征。**形状 \[T,I\]，将 SiLU(G) 与 P 同一行、同一列的数逐元素相乘；⊙ 就是这个操作。

**F：FFN 的输出。**形状 \[T,D\]，通过降维投影回到主干宽度。Wg、Wu、Wd 分别是门投影、升维投影和降维投影的权重。

\\\[\\operatorname{SiLU}(x)=\\frac{x}{1+e^{-x}}\\\]

这里为了便于读公式，Wg/Wu 用 `[D,I]`、Wd 用 `[I,D]` 的数学约定；实际存储方向可以不同。F 是 FFN 贡献，之后加到残差主干。它不是原始 token ID，也不是最终生成的文字。

![H的前g列由GPU计算，其余I减g列由ANE计算；各自经过对应down权重得到T×D结果，最后相加。](https://blog.hacking.pub/qiniu/content/images/2026/10/draft-series-20261026-k3-channel-split.png)

图 1：两边算不同的中间通道，最后汇总对同一个输出空间的贡献。

## 为什么中间通道可以拆？

先忽略量化和浮点误差，把 H 沿列分成 `[H_GPU | H_ANE]`，同时把 Wd 沿输入轴分成对应两块，那么完整结果等于两个部分结果之和。SiLU 与逐元素门控也能在各自的中间通道内独立完成。

因此 GPU 不需要再把 ANE 那些通道完整计算一次。它负责前 g 个通道，ANE 负责剩余部分，各自 down 回 D 个特征，最后相加。这就是 [FfnSplit 设计说明](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.h?ref=blog.hacking.pub#L1-L14) 与 [GPU 份额计算](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/model/qwen35%5Fane.cpp?ref=blog.hacking.pub#L292-L335) 对应的数学结构。

注意，**数学上的可拆分不代表实现逐位等价**。ANE 路径涉及额外量化、旋转和不同算术顺序，它是在数值误差与性能之间做工程取舍。

## 三个配置字段

![T是token行数，D是主干特征数，I是FFN中间特征数；512行阈值、2560输入分段、512通道分配单位分别作用于不同轴。](https://blog.hacking.pub/qiniu/content/images/2026/10/draft-series-20261026-k3-dimensions.png)

**kUnit = 512：ANE 中间通道的分配单位。**分给 ANE 的 FFN 中间特征按每组 512 个通道计算，例如分配 2 组就是 1024 个通道。

**kSeg = 2560：输入特征的分段大小。**一个 token 若有 5120 个特征，就按两段各 2560 个处理，再合并计算结果；token 数量不变。

**min\_rows：启用 ANE 路径的最低输入行数。**每行对应一个 token。输入行数不足时走 GPU；达到门槛后，还要满足其他条件才能使用 ANE。该结构体的回退默认值为 512，实际值可能由校准或配置调整。

源码对应：[FfnConfig 与常量](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.h?ref=blog.hacking.pub#L39-L53)，以及 [形状检查与分段](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.cpp?ref=blog.hacking.pub#L83-L100)。

## GPU 与 ANE 怎样交换 FFN 数据

进入 FFN 的 `xh_` 是归一化后的 token 特征，按 \[token, feature\] 保存为 FP16。ANE 路径需要 int8、通道在前的输入，因此 GPU 先转换数值和排列，再写入共享的 IOSurface；ANE 读取它，算完后写回另一块 IOSurface。

![CPU 创建和绑定共享 IOSurface、编排 GPU 命令并提交 ANE 请求；GPU 与 ANE 通过共享内存交换输入和输出，以 ready 和 done 事件控制读写顺序。](https://blog.hacking.pub/qiniu/content/images/2026/10/ffn-data-transfer-with-cpu-20261027.png)

图 3：橙色箭头表示 CPU 控制；绿色、紫色实线表示数据读写；虚线表示 ready / done 同步。GPU Buffer 与 ANE 句柄引用同一块 IOSurface 内存。

### 同一块 IOSurface，GPU 写，ANE 读

IOSurface 是 macOS 可供不同进程和硬件访问的共享内存对象。[make\_surface()](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ane.mm?ref=blog.hacking.pub#L200-L220) 创建它，再把底层地址包装成 GPU 的 Buffer。以下摘取关键调用，省略属性定义与错误检查：

```cpp
IOSurfaceRef ref = IOSurfaceCreate((__bridge CFDictionaryRef)props);
s.ref = ref;
s.buf = Gpu::get().wrap(IOSurfaceGetBaseAddress(ref), s.bytes,
                        "ane surface", /*tracked=*/true);
```

GPU 通过 s.buf 写入；[Program::bind()](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ane.mm?ref=blog.hacking.pub#L428-L449) 则用同一个 IOSurface 的 ref 构造 ANE 输入、输出对象。传给 ANE 的是这些对象的引用，应用代码没有先把数值逐个读回 CPU、再上传一份。

### pack()：旋转、量化，再调整排列

[ane\_rotate](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/kernels/ane%5Fffn.metal?ref=blog.hacking.pub#L55-L99) 对每个 token 的特征做分块 Hadamard 变换，将少数特别大的值分散到多个维度，再按该行的最大绝对值缩放，并把恢复数值大小所需的输入缩放记在 tx 中。对应权重也要做相同方向的变换：[stage\_layer()](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.cpp?ref=blog.hacking.pub#L548-L591) 把 ANE 份额的权重旋转、量化，写入权重 IOSurface。忽略量化和浮点误差时，匹配的正交变换保持点积。

随后 [ane\_pack](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/kernels/ane%5Fffn.metal?ref=blog.hacking.pub#L101-L119) 把转换后的值取整成 int8，并从 \[token, feature\] 转置成 \[feature, token\]，写入输入 IOSurface x\[k\]。因此，“打包”在这里确实改了数值表示和内存排列，换一个指针无法完成这些工作。

### launch() 与 join()：用事件控制先写后读

[encode\_layer()](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.cpp?ref=blog.hacking.pub#L640-L650) 的实际调用顺序如下。权重先准备当前层，再预取下一层；本层的激活走 pack、launch、join：

```cpp
if (stage && li == 0) stage_layer(e, 0, 0);
pack(e, 0, xh, T_);
if (ane) launch(e, 0, li, T_);
gpu_part(e);
if (stage && li + 1 < int(layers_.size()))
  stage_layer(e, li + 1, (li + 1) & 1);
join(e, 0, x, T_);
```

[launch()](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.cpp?ref=blog.hacking.pub#L613-L627) 在 GPU 命令流中发出 ready 事件；[Program::enqueue()](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ane.mm?ref=blog.hacking.pub#L487-L518) 将等待 ready、完成后发出 done 的事件绑定到 ANE 请求。ANE 等输入和权重写好后才读取，GPU 此时可以计算自己的通道份额。

ANE 把 down 投影的部分结果与每个 token 的中间缩放写进输出 y。[join()](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.cpp?ref=blog.hacking.pub#L629-L639) 等待 done，再调用 [ane\_join](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/kernels/ane%5Fffn.metal?ref=blog.hacking.pub#L260-L292)：用 y 中的中间缩放与 tx 恢复数值大小，并以 FP32 加回主干 x。共享内存解决“读写哪块数据”，ready / done 解决“什么时候能读写”。

这条路径的 W8A8 表示并不意味着全部运算都是 int8。[MIL 计算图](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.cpp?ref=blog.hacking.pub#L361-L454) 还包含反量化、FP16 计算、SiLU，以及中间特征的再次旋转和量化。这里使用的是 [私有 ANE 接口](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ane.h?ref=blog.hacking.pub#L1-L9)；权重交换区采用两组轮换，提前准备下一层，并非为所有层永久保存完整的 ANE 权重副本。

## 这条路径的适用边界

本篇讲的是 [Qwen35 的 dense prefill FFN 路径](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/model/qwen35%5Fane.cpp?ref=blog.hacking.pub#L51-L92)：它检查是否为 dense 模型、行数和格式是否合适，并不把 Attention、所有 MoE 专家或 decode 一概送到 ANE。其他后端的 ANE 任务也可能不同。

启动时还会做误差检查；具体判定来自内部校验指标，不能解读成“整模型准确率最多损失某个百分比”。硬件接口、内存预算、数值误差和任务大小都可能让它退回 GPU。下一篇看它如何在可用时重叠执行、在失败时安全收场。

## 对照源码阅读

- [分工、常量与接口](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.h?ref=blog.hacking.pub#L1-L94)
- [维度约束](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.cpp?ref=blog.hacking.pub#L83-L100)
- [MIL 程序生成](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/ane/ffn%5Fsplit.cpp?ref=blog.hacking.pub#L367-L453)
- [启动检查与 GPU 回退](https://github.com/nace-ai/kapitsa/blob/919b1b2a6782ec205e23725dee76ee5f57de2857/src/model/qwen35%5Fane.cpp?ref=blog.hacking.pub#L206-L240)