端侧AI突破性能瓶颈:MNN 基于 SME2 指令集的大模型高效推理实现

本文介绍了Arm架构下的SME2(Scalable Matrix Extension 2)指令集在 MNN 推理引擎的 CPU 后端实现矩阵乘算子的关键技术。针对生成式AI对算力与能效的严苛需求,从SME2的多向量指令、二维矩阵寄存器(ZA Array)、查表指令等核心架构特性出发,利用SME2的谓词寄存器控制、多向量数据流处理及查表加速机制,MNN CPU后端基于SME2指令实现了动态量化矩阵乘算子和浮点矩阵乘算子。通过矩阵乘法分块优化、INT8/FP16混合精度计算及外积累加(Outer Product and Accumulate)指令的高效应用,结合Mac M4 Pro平台的实测数据,验证了SME2在LLM推理中的性能优势。
实验显示,其在预填充(Prefill)阶段实现单线程性能翻倍,在解码(Decode)阶段性能提升20%-30%。实验表明,在动态量化4bit权重场景下,SME2指令集在LLM模型(如Qwen2.5-1.5B/7B)中实现内存带宽节省与能效比显著提升,为端侧AI推理提供了兼具高性能与低功耗的技术方案。同时,针对计算机视觉(CV)任务,验证了浮点矩阵运算性能提升达200%-300%的技术可行性。截止 2025 年 6 月,支持 SME 指令集的手机只有苹果的 iPhone, A17Pro 芯片及后续 A 系列芯片的 iPhone 手机( iPhone15 Pro 系列和 iPhone16 全系)都支持 SME2 指令集。支持 SME2 指令集的安卓手机预计将于 2025 年下半年上市,例如联发科的天玑 9500 芯片将会使用 Arm 新一代 Cortex-X9 超大核,该核心支持 SME2 指令集。

SME2 指令集特点
▐ 1.1 SVE、SVE2、SME 和 SME2 指令集的联系与区别
SVE(Scalable Vector Extension)是Arm AArch64架构中的一种新型SIMD指令集扩展,相比 NEON 指令集架构,SVE 架构下向量寄存器的长度是可变的(128bit, 256bit, 512bit, 1024bit, 2048bit),并且引入了谓语寄存器。SVE2 指令是 SVE 指令的超集,并且 SVE2 增加了对 NEON 指令的兼容。SVE 和 SVE2 不是 NEON 指令集的扩展,而是一种全新的指令架构,比 NEON 指令提供了更好的数据并行性。支持 SVE 和 SVE2 指令集的硬件逻辑覆盖了 NEON 硬件实现。当微架构支持 SVE 或 SVE2 时,它也支持 Neon。
Armv9-A 架构开始支持 SME(Scalable Matrix Extension)指令集,SME 引入了新的架构状态:ZA 存储和一种新的执行模式:Streaming SVE mode. 在 Streaming SVE mode 下可以执行新的 SME 指令和部分 SVE2 指令。ZA 存储提供了一个二维数组寄存器,SME 指令由此提供了外积操作指令和对数组的切片分块(Tile),并且可以对该二维数组寄存器的切片进行数据加载和存储。SME2 指令集在 SME 的基础上引入了查表寄存器,并增加了同时对多个向量进行操作的指令。SME2指令集支持多个向量寄存器和二维数组寄存器的乘加计算(C=C+A*B),实现矩阵乘法的高效流水线处理;并且支持二维数组寄存器和多个向量寄存器之间的数据传输。本文会详细介绍 MNN CPU 后端如何使用 SME2 指令进行矩阵乘法加速。虽然 SVE 中支持可变长度的向量寄存器,但是芯片厂商将SME2 指令架构下的向量寄存器(z0-z31)、数组寄存器 za 和查表寄存器zt0长度均设计为 512bit. 下面结合代码解释每一类寄存器的用法,关于指令的用法和原理都会在代码的注释中提及。
▐ 1.2 基础知识
-
在 SME2 指令中 VL 和 VG 的含义:VL 是 Vector Length 的缩写,表示向量寄存器的长度。在 SME 中向量寄存器的长度都是 512bit,所以 VL 是 64 字节。VG 是 Vector Group 的缩写,表示向量寄存器的个数。通常 VG 会和 SME 中的数组寄存器一起使用,表示数组寄存器中含有的向量个数。在 2.4 节数组寄存器中会介绍 VG 的计算方式。
-
SME2 的指令中用来表示数据类型(数据字节数)的只有以下几类:
|
元素 bit 数 |
8 bit |
16 bit |
32 bit |
64 bit |
128 bit |
|
符号 |
b |
h |
s |
d |
q |
▐ 1.3 谓语寄存器
SME2 中的谓语寄存器(p0-p15)不仅仅使用“mask”的方式决定源寄存器中的哪些位置上的元素参与计算,还能使用“counter”的方式决定源寄存器中参与计算的元素个数,Arm 官方文档中称这一类谓语寄存器为"predicate-as-counter". 后文用 “mask 谓语寄存器”指代前者,用 “counter 谓语寄存器”指代后者。
谓语寄存器一共有 16 个,mask 谓语寄存器是 p0-p7,counter 谓语寄存器是 p8-p15. 写汇编代码时需要将 p8-p15 写成 pn8-pn15,否则会导致编译不通过。谓语寄存器中每一位(bit)控制操作指令中的每一个字节(bytes),所以谓语寄存器的长度是向量寄存器长度的 1/8, 即 64bit。
如果 mask 谓语寄存器中的 bit 是 1,表示对应的源向量寄存器中的元素会参与计算;如果是 0,则不参与计算。
下面用代码分别说明 mask 谓语寄存器和 counter 谓语寄存器的原理和用法。
谓语寄存器用法示例
// 谓语寄存器的初始化方法ptrue p0.b // 把p0谓语寄存器中的全部64 bit置为1,即p0寄存器中含有8个uint8_t类型的数据255ptrue p1.s // 把p2谓语寄存器看作一个含有16个int32_t类型数据的向量,向量中的每一个数据都是整型1ptrue p2.s, vl4 // 把p2谓语寄存器看作一个含有16个int32_t类型数据的向量,向量中前4个数据是整型1,后12个数据是0ptrue pn8.b // 初始化counter谓语寄存器pn8,pn8不是mask谓语寄存器,
// 使用whilelt/whilegt等指令初始化谓语寄存器,适用更复杂的场景// whilelt的原理:// for i in range(0, 64):// if i+xzr < x22:// p3[i] = 1 // 先从低位,第0位开始// else:// p3[i] = 0mov x22, #7whilelt p3.b, xzr, x22 // xzr表示该寄存器是一个64位通用寄存器,并且值是0;这条指令的结果是:把p3寄存器看作含有64个int8_t数据的向量,前7个int8_t数据是1,后面所有的57个数据全部是0.
// whilegt的原理:// for i in range(0, 64):// if x0-i > x2:// p3[63-i] = 1 // 先从最高位,第63位开始// else:// p3[63-i] = 0 mov x0, #4 mov x2, #0 whilegt p5.b, x0, x2 // whilegt是从高位往低位设置每一位的数值, 查看p5寄存器的内容发现,他可看作有8个uint8_t数据的向量,前7个元素是0,最后一个元素是240. 因为p5寄存器一共64bit,高4位是1,其他60位全是0,那么最后一个uint8_t元素的二进制表示是:11110000,转成十进制的uint8_t数据是240.
// 使用 whilelt指令初始化counter谓语寄存器// counter谓语寄存器控制参与计算的元素数量mov x22, #18whilelt pn9.s, xzr, x22, vlx2ptrue pn8.sld1w {z0.s-z1.s}, pn8/z, [x1] // ld1表示load,w表示按照32bit来读取,w需要和z0.s中的s对应,否则编译失败st1w {z0.s-z1.s}, pn9, [x0] // st1表示store// 逐行解释代码// 30行,xzr表示值为0的64位通用寄存器,vlx2表示2个vector length. sme2中固定了向量寄存器的长度是512bit,// 最多能容纳16个int32_t/float32_t的数据,所以当使用pn9.s时,vl是16;当使用pn9.b时,vl是64.// 30行whilelt指令原理如下:// for i in range(0, 16*2):// if xzr+i < x22:// pn9[i] = 1// else:// pn9[i] = 0// 30行指令执行完,表示pn9会处理18个int32_t/float32_t类型的数据。// 31行指令执行完,表示pn8会处理所有的元素。// 32行,从x1地址中读取32个int32_t/float32_t类型数据,分别加载到z0寄存器和z1寄存器中,每个寄存器加载16个。// 33行,从z0和z1寄存器中连续地存储18个int32_t/float32_t类型数据到地址x1中,先存储z0寄存器中的16个数据,// 再存储z1寄存器中的2个数据。
▐ 1.4 向量寄存器和数组寄存器
SME2 中一共有 32 个向量寄存器(z0-z31),每个向量寄存器的长度都是 512bit. SME2 指令架构相较于 SVE/SVE2 指令架构的最大特点是新增了数组寄存器 za. za 寄存器也可看作一个向量寄存器,特殊的是该向量中的所有元素都是 512bit 的向量寄存器. 所以 za 寄存器也可以看作是一个二维矩阵(数组),宽度是 512bit,za 寄存器的形状是 64x64 bytes. 下图展示的是一个宽度为 256 bit 的 za 寄存器,有 32 行 32 列。

图1. za寄存器中的数据排列方式
-
za 寄存器切片(Tiles)
1. SME2 支持把 za 寄存器分成多个 za 子块进行使用。每一个 za 子块都是一个正方形的矩阵。每一个 za 子块的宽度和 za 寄存器的宽度一致,都是 512bit. 每一个 za 寄存器的高度根据对 za 寄存器的划分方式而不同,例如以把 za 寄存器中的元素看作 int32_t 数据类型,za 寄存器的宽度是 16(512bit/32bit,每一行最多有 16 个 int32_t 数据),因为 za 子块一定是正方形的,那么 za 子块的高度也是 16. 又结合 za 寄存器的形状是 64 x 64bytes,此时 za 寄存器一共能分成 4(64/16)个子块,分别用 za0, za1, za2, za3 来表示。所以 za 寄存器可划分的子块个数是由元素类型决定的,下表总结了各类型数据下 za 寄存器的子块数量。
|
元素数据类型 size |
子块个数 |
子块名称 |
每个子块大小 |
|
8 bit |
1 |
za0.b |
64 x 64bytes |
|
16 bit |
2 |
za0.h, za1.h |
32 x 64bytes |
|
32 bit |
4 |
za0.s-za3.s |
16 x 64bytes |
|
64 bit |
8 |
za0.d-za7.d |
8 x 64bytes |
|
128 bit |
16 |
za0.q-za15.q |
4 x 64bytes |
2. 对 za 寄存器切片访问的两种方式
-
通过子块访问: 如果访问 za 寄存器是通过子块的方式,那么可以通过水平和垂直两个方向来访问。za0h.s[1]中的'.s'表示把 za 寄存器中的元素类型看作 32bit,za0表示要访问的第 0 号子块,[1]表示第二个向量,h表示从行(水平)方向访问。即 za0h.s[1]表示 za0 子块的第二行数据。同理,za1v.h[2]中的v表示从列(垂直)方向访问 za1 子块,把 za 寄存器中的元素看作 16bit。
za 切片和向量寄存器之间的数据传输
// tile to vectormov w14, #0 // 行号的初始位置mov w15, #4mova {z0.s-z3.s}, za1v.s[w14, 0:3] // 把za1的0,1,2,3行数据赋值给z0.s-z3.s;0:3表示读取四行,每一行的偏移量加上初始位置值mova {z4.s-z7.s}, za1v.s[w15, 0:3] // 把za1的4,5,6,7行数据赋值给z4.s-z7.s// vector to tilemov w14, #2mova za0h.b[w14, 0], p3/m, z0.b // 把z0寄存器中的元素赋值给za0寄存器的第2(w14+0)行,只赋值p3寄存器中对应位置是1的元素
-
通过 za 寄存器整体访问:如果不通过子块的方式访问,也可以直接使用 za 数组寄存器的初始位置加偏移的方式来访问 za 寄存器中的元素。下面的代码就是直接访问 za 寄存器的例子。先介绍一下 VG 符号的含义:VG 表示的是 za 寄存器中的向量个数。VL 是固定的,但 VG 是根据元素类型不同而变化的。例如 za 寄存器中的元素被看作 int32_t 类型,那么 VG=512bit/32bit=16.
-
VG(vg): Vector Group 的缩写,大小根据元素类型大小决定。VG=64bytes/sizeof(element)
|
元素数据类型 size |
VG 值 |
|
8 bit |
64 |
|
16 bit |
32 |
|
32 bit |
16 |
|
64 bit |
8 |
|
128 bit |
4 |
za 数组和向量寄存器之间的数据传输
mov w8, #0 // 起始位置// VG:vector group// x4: 读取四个向量的值// 以za.s表示按照32bit读取// 行号计算:w8+4+i*VG, i = 0,1,2,3// 四行的行号分别是4,20,36,52mova {z20.s-z23.s}, za.s[w8, 4, VGx4]// 参考图1,得知该指令读到的是za0.s[1], za0.s[5], za0.s[9], za0.s[13]
▐ 1.5 查表寄存器
SME2 中提供查表寄存器zt0和查表指令 luti4, luti2. 查表指令形如:luti4 {z2.b-z3.b}, zt0, z1[0],以下关于查表指令中各参数的解释都依托于该指令。
-
查表指令需要表寄存器和索引寄存器,根据索引寄存器的值去查表中对应的值,最后将该值填写到目标寄存器中。查表指令中zt0是表寄存器,z1是索引寄存器。 luti4表示查表时所需的各索引(index) 仅占 4bit 表示,luti2表示用 2bit 表示每一个索引(index)。因为luti4的索引位(index)有4位,而 4位能表示16个数字,说明查表只能查到表中索引号小于 16 的元素。同理,luti2 指令查表时只能查到表中索引号小于 4 的元素。
-
zt0寄存器的内容是表,长度也是 512bit. zt0寄存器会被识别成含有 16 个 int32_t 类型数据的向量,即 zt0 寄存器最多提供 16 个表项。这也与上一段我解释的 luti4 指令查表时索引号不超过 16 相对应。虽然zt0寄存器中 16 个表项是用 int32_t 类型表示,但使用查表指令可以将 int32_t 类型数据转成 int8_t 类型和 int16_t 类型,目标数据类型由查表指令中目标寄存器的数据类型决定。例如查表指令中 z2.b和z3.b表示 z1 中的 int32_t 类型数据会被转成 int8_t 类型存储在z2和z3寄存器中。如果是 z2.h则表示转成 int16_t 类型。
-
z1[0]中的 0 表示段索引: z1是生成索引的寄存器,[0]表示段索引。段的定义:根据目标寄存器中每个元素的 bit 数、查表索引占的 bit 数和目标寄存器的个数,把z1寄存器分成若干段(segment). 段数=目标元素 bit 数/(查表索引 bit 数 * 目标寄存器个数)。段长度=向量寄存器长度/段数,段长度决定需要查询的元素个数。在该例中,z1可以被分成 1 段(8/(4*2)),每段长度就是 512bit,因为 luti4每次取 4bit 作为查询索引,所以该例需要查询 128 个数据。若查表指令是luti2 z4.h, zt0, z1[2],那么z1寄存器被分成 8 段(16/(2 * 1))。因为z1寄存器的长度是 512bit,所以每一段有 64bit. 又结合指令luti2得知查表索引的 bit 数是 2,那么每一段包含 32 个索引,即产生 32 个查表结果。z1[2]表示用第 2 段(从 0 开始)包含的 32 个索引值进行查表,最后把查询到的 32 个元素转成 int16_t 类型后存储到 z4 寄存器中。上面介绍过luti2指令只会查询索引小于 4 的元素,所以查询的 32 个结果一定来自于zt0寄存器的前 4 项。
举例介绍luti4查表指令的一种应用场景:假设有 128 个 int4 的整型数据在内存中连续排列,我们需要把每一个 int4 的数据高四位补 0 转成 int8 数据。用代码结合注释的方式解释查表指令的原理和用法。
luti4指令查表原理和用法
// 从x8寄存器中加载表存储到zt0,x8地址中存储的是16个int32_t数据,数组记作int32_t vector[16]// z1向量中存储64个int8_t数据(实际存储128个int4_t数据),用luti4查表后得到128个int8_t数据,分别存储在z2和z3寄存器中// 假设z1向量中第一个元素是uint8_t类型的20,二进制表示是00010100,那么该元素会产生两个index// 第一个index是0100,第二个index是0001.第一个index是4,第二个index是1// 按照产生的index去查找数组中的元素得到的是vector[4]和vector[1]// [0]表示段索引,段数量=元素bit数/(索引bit数*要加载到的目标寄存器数量)// 此时段数量=8/(4*2)=1,那么直接在[]中填写0即可ldr zt0, [x8]luti4 {z2.b-z3.b}, zt0, z1[0]
▐ 1.6 操作指令
本节介绍 SME2 中使用频率较高的几类操作指令,通过举例每一类操作指令的写法,重点解释操作指令中各元素/参数的含义,以此类推该类操作指令其他用法。
-
1.6.1数据的读取和存储指令
-
SME 指令集中数据加载指令ld1b {z0.b-z3.b}, pn8/z, [x0] :ld1b中的b表示以 8bit 来读取数据,读取的数据放置在 z0,z1,z2,z3寄存器中;[x0]表示源地址;此时涉及的寄存器超过 1 个,所以需要用“counter 谓语寄存器”控制读取的数量,就是 pn8谓语寄存器寄存器,实际就是 p8 寄存器。但是这里不能写成 p8,否则会编译失败。pn8/z中的z表示把不参与操作的目标寄存器中的元素设置为 0. 如果是pn8/m,则表示不参与操作的目标寄存器中的元素保持不变。例如,pn8寄存器控制读取的元素数量是 240,z0.b-z3.b一共可以容纳 256 个 int8_t,那么 z3.b寄存器的最后 16 个元素会被设置成 0. 如果是 pn8/m,那 z3.b寄存器的最后 16 个元素保持不变。
-
SME 指令集中数据存储指令st1w {z0.s-z3.s}, pn8, [x1],st1w中的w表示以 32bit 来存储数据,与之对应的是 z0.s-z3.s中的s. 这里pn8后面不需要写/z或/m.
-
1.6.2 外积指令

图2. 两个向量外积的计算原理
-
整型数据外积可以用 8bit、16bit 和 32bit 类型的数据作为源数据,累加和都用 int32_t 类型表示:smopa za0.s, p0/m, p1/m, z1.b, z2.b smopa za0.s, p0/m, p1/m, z1.h, z2.h smopa za0.s, p0/m, p1/m, z1.s, z2.s
-
下面两张图片分别展示了用 8bit、16bit 外积时, smopa指令的计算原理:

图3. int8_t类型数据做外积,累加到int32_t类型

图4. int16_t类型数据做外积,累加到int32_t类型
-
浮点数据外积可以用 float32 和 float16 类型数据作为源数据 fmopa:fmopa za0.s, p3/m, p2/m, z0.s, z1.s,fmopa za0.s, p3/m, p2/m, z0.h, z1.h
float32 类型数据外积fmopa指令的原理和 int32_t 类型数据外积指令smopa相同,float16 类型数据外积指令的原理则和 int16_t 类型数据外积指令原理相同。

图5. 浮点类型数据做外积,累加到float类型
-
1.6.3 内积指令
sdot za.s[w8, 0, VGx4], {z0.b-z3.b}, z4.b z4 寄存器中的 64 个 int8_t 类型数据分别和 z0-z3 寄存器中的 64 个 int8_t 类型数据做内积,得到 4 个分别有 16 个 int32_t 类型数据的向量,把这 4 个向量分别写入 za 寄存器的指定 4 行。行号的计算是:w8+0+VG*i, i=0,1,2,3. 因为za后面是.s,所以这里的 VG=16. 见图 6.
sdot za.s[w8, 0, VGx4], {z0.b-z3.b}, z4.b[2]和上一条指令不同的是,z4寄存器以 128bit 分成 4 等份,每份再以 32bit 分成 4 等份,每 128bit 内部的第 2 份 32bit 和z0-z3寄存器做内积,得到 64 个数据,分作 4 行写入za中。特别注意:此时要求每 128bit 内部的第二份所包含的 32bit 内容必须完全相同。见图 7.
以上两条内积指令的计算差异可以通过下图来表示:


-
1.6.4 向量转置
SME2 提供了更加灵活的向量转置指令,方便我们直接用汇编代码实现数据重排。如果有 64 个 int32_t 类型的数据在内存中的排列是行优先的, 我们需要把它变成在内存中排列是列优先的,可以直接调用zip指令:
zip {z0.s-z3.s}, {z4.s-z7.s}
原数据排列:(0,0), (0,1) ... (0,15), (0,0), (0,1) ... (0,15)

执行zip指令后的数据排列:(0,0), (0,0), (0,0), (0,0),(0,1), (0,1), (0,1), (0,1),(0,2), (0,2), (0,2), (0,2), ..., (0,15), (0,15), (0,15), (0,15)

-
1.6.5 和向量长度有关的指令操作
cnth x24 cnt 表示 count,h表示 16bit,cnth表示以 16bit 为元素的单位长度,向量寄存器能容纳的元素个数。在 SME2 中,得到 x24=32(512bit/16bit). 同理 cntb, cntd, cntq分别表示元素的单位长度是 8bit,64bit,128bit。
addvl x2, x2, #2 add 表示加法,vl表示加的单位是 VL(Vector Length),#2表示加的量。该指令表示把x2的地址增加 2*VL 个字节,即 x2 = x2 + 2 * VL。
st1b {z18.b}, p3, [x0, #1, MUL VL]存储 z18 寄存器中元素的地址是:x0 + 1 * VL,但是x0的地址不会在该指令执行完成后增加 VL 个字节,x0 保持不变。
▐ 1.7 寄存器的使用和恢复方法
SME 中有两条特殊的指令,SMSTART 和 SMSTOP. SMSTART 表示进入 Streaming SVE mode,SMSTOP 表示退出 Streaming SVE mode. Streaming SVE mode 开启后,SME 指令和部分 SVE2 指令能够访问向量寄存器 z0-z31, 谓语寄存器 p0-p15 和 SME 的架构状态。
-
SMSTART
启用 Streaming SVE 模式和 SME 架构状态,允许使用与流式模式相关的向量扩展功能。它是对 MSR (immediate) 指令的别名。在函数调用或代码段切换时,若目标状态为流模式(Streaming Mode),需插入 SMSTART 以切换至该模式。例如,从非流模式(Normal Mode)进入流模式时需执行此指令。在汇编代码中,直接通过 SMSTART进入流模式。
-
SMSTOP
禁用 Streaming SVE 模式和 SME 架构状态,将系统切换回非流模式。它同样是对 MSR (immediate) 的别名。当退出流模式时需执行此指令。例如,从流模式返回非流模式时需插入 SMSTOP. 在汇编代码中,通过 SMSTOP返回非流模式。
-
状态切换规则:不同状态(Normal, Streaming, Streaming Compatible)之间的切换需遵循特定规则。例如,从流模式切换到非流模式需执行 SMSTOP,从非流模式切换到流模式需要执行 SMSTART。
-
寄存器清零:切换模式时(通过修改 PSTATE.SM),浮点(FP)和向量寄存器会被清零。因此,编译器需在寄存器分配前插入 SMSTART/SMSTOP 指令,保证正确地保存和恢复寄存器的状态。
stp x29, x30, [sp, #-320]!mov x29, spstp x19, x20, [sp, #224]stp x21, x22, [sp, #208]stp x23, x24, [sp, #192]stp x25, x26, [sp, #176]stp x27, x28, [sp, #160]stp d8, d9, [sp, #80]stp d10, d11, [sp, #64]stp d12, d13, [sp, #48]stp d14, d15, [sp, #32]
smstart
/* CODE */
smstop
ldp x19, x20, [sp, #224]ldp x21, x22, [sp, #208]ldp x23, x24, [sp, #192]ldp x25, x26, [sp, #176]ldp x27, x28, [sp, #160]ldp d8, d9, [sp, #80]ldp d10, d11, [sp, #64]ldp d12, d13, [sp, #48]ldp d14, d15, [sp, #32]ldp x29, x30, [sp], #320ret
同样功能的指令,SME2 指令和 NEON 指令的使用区别
SME2 和 NEON 架构有很多指令的功能是相同的,但他们的计算原理有很大差别。我在本节中列举开发过程中遇到的一些差别较大的指令,介绍他们的使用方法,进一步地我们可以在写汇编时利用 SME2 指令的这些特点,提高算子效率。
▐ 2.1 fcvt 和 fcvtn 指令
SME2 指令集中把 float32 类型数据转成 float16 类型数据可以使用fcvt指令或fcvtn指令,例如:fcvt z4.h, {z16.s-z17.s}或 fcvtn z4.h, {z16.s-z17.s}.这两条指令的结果都是 32 个 float16 类型的数据,但数据排列有较大的区别。把 z16 和 z17 中的 float32 类型元素按照在寄存器内部的顺序分别表示成 0, 1, 2, ..., 15 和 16, 17, 18, ..., 31. 用fcvt指令后,z4 寄存器中 32 个 float16 类型数据在寄存器中的排序是 0, 1, 2, ..., 15, 16, 17, 18, ..., 31. 而用fcvtn指令后,z4 寄存器中 32 个 float16 类型数据在寄存器中的排序是 0,16,1,17,2,18,...,15,31.fcvtn指令对两个源寄存器中的 32 个数据做了交错排列的处理。这一特点可以被用到我们需要对数据结果重排的场景,节约一次重排指令的编写。
▐ 2.2 fcvt 和 fcvtlt 指令
把 float16 类型数据转成 float32 类型数据可以使用fcvt z0.s, p3/m, z4.h或fcvtlt z0.s, p3/m, z4.h. fcvt指令把z4寄存器的偶数序号的 float16 数据转成 float32 数据后存储在z0寄存器上,而fcvtlt指令则是把 z4 寄存器的奇数序号的 float16 数据转成 float32 数据后存储在z0寄存器。两张图就可以表示他们的区别。


图9. fcvtlt指令的原理
▐ 2.3 fmul(indexed) 指令
fmul v0.s, v1.s, v2.s[2]需要保证 v2 向量寄存器中每 128bit 中,第 2 个 float32 类型数据都相同。计算原理如下图:

SME2 指令集架构中,凡是涉及到 indexed 指令都要考虑是否需要保证每 128bit 的同样 index 数据是相同的。
▐ 2.4 zip 指令
使用 NEON 指令时,我们会用zip1和zip2来分别处理源寄存器的低 64 位和高 64 位。在 SME2 指令架构集中使用一条指令完成 zip1 和 zip2 的操作:zip {z24.s-z25.s}, {z16.s-z17.s}

使用 SME2 汇编指令编写矩阵乘算子
矩阵乘法加速的关键方法是通过矩阵分块降低 CPU 读取数据时的 cache miss,从而提高算子性能。两个二维矩阵做乘法涉及三个维度:行、列、乘加维度,下文用符号 E、H、L 分别表示这三个维度。MNN CPU 后端会根据各后端的指令集特点设计这三个维度的分块大小,用符号 EP、HP、LP 分别表示对应的分块大小。
▐ 3.1 动态量化的矩阵乘算子
动态量化:大模型在端侧CPU快速推理方案介绍了MNN CPU 后端使用动态量化的方法推理 LLM 模型,本文不再介绍动态量化的原理。下面重点介绍利用 SME2 指令集提高 LLM 模型的推理性能。动态量化公式:
,其中
-
使用整型外积指令
smopa和查表指令luti4加速 int8_t 类型的矩阵乘法: x\cdot y是将 int8_t 类型的输入和权重乘加到 int32_t 类型数据。根据上文介绍的smopa指令计算原理,该 指令每次计算一个 int32_t 类型数据时是把 4 个 int8_t 类型数据做乘累加,所以矩阵分块中 LP=4;又因为一个za寄存器的子块最多容纳 16x16 个 int32_t 类型数据,所以我们设计 EP=16, HP=16.smopa指令执行的结果是得到一个[EP, HP]形状的矩阵,矩阵内元素类型是 int32_t.
SME2 指令实现int8_t类型矩阵乘
LoopSz_TILE16: ld1b {z0.b}, p3/z, [x11] // src ld1b {z1.b}, p2/z, [x12] // weight // int4->int8 luti4 {z2.b-z3.b}, zt0, z1[0] // matmul smopa za0.s, p3/m, p3/m, z0.b, z2.b subs x10, x10, #1 add x11, x11, x22 add x12, x12, #32
bne LoopSz_TILE16
-
使用
fmopa指令计算 其他项的计算:
和
的共同特点是乘法的第一项形状都是[EP, 1],乘法的第二项形状都是[1, HP]. 我们可以使用fmopa指令完成两个浮点矩阵乘法,得到[EP, HP]形状的矩阵,矩阵内元素的数据类型都是 float.
// inputKernelSum x weightBias -> [16,16]fmopa za1.s, p3/m, p3/m, z2.s, z1.s// inputScale x weightScale -> [16,16]fmopa za2.s, p3/m, p3/m, z3.s, z0.s
-
使用
fclamp指令比较向量元素大小
fclamp {z24.h-z27.h}, z29.h, z30.h // z29.h min value, z30.h max value
▐ 3.2 浮点矩阵乘算子
浮点矩阵乘算子的开发逻辑比动态量化矩阵乘简单很多。同样的根据fmopa指令计算原理,当输入和权重是 float32 类型时,我们设计 EP=16,LP=1,HP=64. 当输入和权重是 float16 类型时,EP=16,LP=2,HP=64. 这里我们把 HP 设计成 64 是为了充分使用所有的寄存器。
SME2实现float16类型输入数据的矩阵乘
E16LoopL:ld1b {z4.b}, p3/z, [x8] // Ald1b {z0.b-z3.b}, pn8/z, [x2] // B// [EP,LP] x [HP,LP] -> [EP,HP]fmopa za0.s, p3/m, p3/m, z4.h, z0.hfmopa za1.s, p3/m, p3/m, z4.h, z1.hfmopa za2.s, p3/m, p3/m, z4.h, z2.hfmopa za3.s, p3/m, p3/m, z4.h, z3.h
subs x21, x21, #1addvl x8, x8, #1addvl x2, x2, #4bne E16LoopL
SME2实现float32类型输入数据的矩阵乘
E16LoopL:ld1b {z4.b}, p3/z, [x8] // Ald1b {z0.b-z3.b}, pn8/z, [x2] // B// [EP,LP] x [HP,LP] -> [EP,HP]fmopa za0.s, p3/m, p3/m, z4.s, z0.sfmopa za1.s, p3/m, p3/m, z4.s, z1.sfmopa za2.s, p3/m, p3/m, z4.s, z2.sfmopa za3.s, p3/m, p3/m, z4.s, z3.s
subs x21, x21, #1addvl x8, x8, #1addvl x2, x2, #4bne E16LoopL

矩阵乘加速效果
经过对动态量化矩阵乘法和浮点矩阵乘法的性能测试,在单线程推理时,基于 SME2 指令实现的矩阵乘 kernel 比基于 Arm86 指令实现的 Kernel 性能最高提升 200%.
|
动态量化矩阵乘性能测试,测试平台:Mac M4 Pro,单位:ms |
||||
|
量化 Bits |
E |
{ L , H } |
MNN Arm86 |
MNN Sme2 |
|
4 |
1 |
{1536,1536} |
0.081700 |
0.055850 |
|
4 |
512 |
5.457050 |
1.796600 |
|
|
4 |
1 |
{1536,256} |
0.009050 |
0.009050 |
|
4 |
512 |
1.101450 |
0.581200 |
|
|
4 |
1 |
{1536,8960} |
0.268800 |
0.217850 |
|
4 |
512 |
31.072101 |
9.711550 |
|
|
4 |
1 |
{8960,1536} |
0.292000 |
0.229400 |
|
4 |
512 |
30.937051 |
10.165400 |
|
|
4 |
1 |
{1536,151936} |
4.682150 |
3.715250 |
|
4 |
512 |
552.223096 |
195.792297 |
|
|
4 |
1 |
{3584,3584} |
0.265050 |
0.213650 |
|
4 |
512 |
28.382349 |
8.012650 |
|
|
4 |
1 |
{3584,512} |
0.039850 |
0.036800 |
|
4 |
512 |
4.552700 |
1.756150 |
|
|
4 |
1 |
{3584,18944} |
1.386000 |
1.086950 |
|
4 |
512 |
150.726245 |
47.240601 |
|
|
4 |
1 |
{4096, 8192} |
0.689380 |
0.537300 |
|
4 |
512 |
73.933379 |
20.895181 |
|
|
4 |
1 |
{10240, 8192} |
1.748760 |
1.357990 |
|
4 |
512 |
183.784043 |
54.027949 |
|
|
浮点矩阵乘法性能测试,测试平台:Mac M4 Pro,单位:ms |
||
|
E=512 |
MNN SME2 |
MNN Arm86 |
|
L = 1536, H = 1536 |
1.966850 |
6.128300 |
|
L = 1536, H = 256 |
0.593850 |
1.179800 |
|
L = 1536, H = 8960 |
10.644050 |
33.169550 |
|
L = 8960, H = 1536 |
10.922850 |
33.843799 |
|
L = 1536, H = 151936 |
202.478296 |
591.445117 |

LLM 模型加速效果
▐ 5.1 Mac M4 Pro 测试
我们在 Mac M4 Pro 上测试了使用 SME2 指令后,LLM 模型的推理型。因为该平台仅有 2 个核支持 SME2 指令,所以在测试 SME2 指令的多线程时,仅使用 2 线程,Arm86 指令的多线程测试使用 4 线程。测试结果显示单线程推理时, LLM 模型在 Prefill 阶段的推理性能提升了一倍左右,Decode 阶段的性能提升了 20%~ 30%。
|
model |
modelSize |
threads |
precision |
test |
MNN SME2 |
MNN Arm86 |
|
Qwen2.5-1.5B-Instruct 4bit 量化 blocksize=128 |
828.26 MiB |
1 |
Fp32 |
pp512 |
292.16 ± 3.10 |
112.99 ± 0.51 |
|
tg128 |
51.43 ± 0.13 |
40.56 ± 0.09 |
||||
|
Fp16 |
pp512 |
336.08 ± 0.17 |
121.87 ± 0.11 |
|||
|
tg128 |
52.28 ± 0.22 |
40.94 ± 0.12 |
||||
|
2(sme2) 4(arm86) |
Fp32 |
pp512 |
489.09 ± 0.21 |
385.01 ± 21.50 |
||
|
tg128 |
86.67 ± 0.17 |
123.56 ± 0.37 |
||||
|
Fp16 |
pp512 |
606.02 ± 0.39 |
409.73 ± 0.99 |
|||
|
tg128 |
91.05 ± 0.67 |
129.06 ± 0.08 |
||||
|
Qwen2.5-7B-Instruct 4bit 量化 blocksize=128 |
3.71 GiB |
1 |
Fp32 |
pp512 |
68.82 ± 0.19 |
30.27 ± 0.31 |
|
tg128 |
12.12 ± 0.04 |
9.13 ± 0.02 |
||||
|
Fp16 |
pp512 |
80.94 ± 0.05 |
26.41 ± 0.05 |
|||
|
tg128 |
12.37 ± 0.05 |
9.18 ± 0.03 |
||||
|
2(sme2) 4(arm86) |
Fp32 |
pp512 |
134.18 ± 0.22 |
84.35 ± 5.17 |
||
|
tg128 |
22.10 ± 0.04 |
31.80 ± 0.06 |
||||
|
Fp16 |
pp512 |
157.19 ± 0.21 |
91.09 ± 6.71 |
|||
|
tg128 |
23.04 ± 0.14 |
32.66 ± 0.02 |
▐ 5.2 iPhone 16 Pro Max 测试
-
测试说明: 4bit 量化,blocksize=128. 多线程测试时,Arm86 后端用 4 线程,Sme2 后端用 2 线程。
-
性能指标: Prefill - Decode (tokens/s)
-
结论: 1. 在单线程模式下Prefill阶段,SME2后端相比Arm86后端带来了100%的性能提升;2. SME2后端使用2线程时性能超越了Arm86后端使用4线程。


CV 模型加速效果
针对浮点的 CV 模型,我们也在 Mac M4 Pro 上测试了 SME2 指令使用后的性能表现,单线程的性能提升有 200%~ 300%.


结语
我们在 MNN 推理引擎中实现了对 Arm SME2 指令集的高效利用,突破了端侧部署大模型和 CV 模型的性能瓶颈。核心技术包括:
-
深入分析 SME2 的指令特性,并基于此设计和实现了两种关键的矩阵乘算子。我们将外积指令 (smopa) 用于核心矩阵运算,并巧妙地结合查表指令 (luti4) 完成了 4-bit 权重的实时高效解压,同时利用浮点外积指令 (fmopa) 处理动态量化中对浮点类型量化参数的计算,最终形成了一套高度优化的基于SME2指令集的动态量化矩阵乘法方案。
-
通过为 SME2 架构定制的矩阵分块策略 (Tiling),我们最大化了 ZA 二维数组寄存器和多向量操作的并行计算能力,显著降低了对内存带宽的依赖。
在 Mac M4 Pro 和iPhone 16 Pro Max等平台上的实测数据充分验证了SME2指令集用于卷积计算的优越性。在 LLM 场景下,我们实现了 Prefill 阶段性能翻倍,Decode 阶段性能提升 20%-30% 的卓越表现;在传统 CV 任务中,浮点矩阵运算的性能更是获得了高达 200%-300% 的巨大提升。通过在 MNN 中的工程实践, SME2 指令集展示了在 AI 计算中的巨大潜力,也助力MNN引擎突破在端侧部署LLM模型的性能瓶颈,为下一代智能手机、笔记本电脑等边缘设备上部署更复杂、更强大的 AI 应用提供了技术方案。

参考文档
-
SME和SME2的区别与联系:https://developer.arm.com/documentation/109246/0100/SME-Overview/SME-and-SME2
-
SVE2介绍:https://developer.arm.com/documentation/102340/0100/Introducing-SVE2
-
SVE指令集的新特性:https://developer.arm.com/documentation/102476/0101/SVE-architecture-fundamentals/SVE-architecture-features
-
SVE架构基础:https://developer.arm.com/documentation/102476/0101/SVE-architecture-fundamentals
-
SME2指令集官方文档:https://developer.arm.com/documentation/ddi0602/2025-03/SME-Instructions?lang=en
-
Arm极术社区关于SME指令的介绍:https://aijishu.com/a/1060000000471832

团队介绍
本文作者酒七,来自淘天集团-Meta技术团队。本团队目前负责面向消费场景的3D/XR基础技术建设和创新应用探索,创造以手机及XR 新设备为载体的消费购物新体验。团队在端智能、端云协同、商品三维重建、真人三维重建、3D引擎、XR引擎等方面有着深厚的技术积累,先后发布深度学习引擎MNN、商品三维重建工具Object Drawer、3D真人数字人TaoAvatar、端云协同系统Walle等。团队在OSDI、MLSys、CVPR、ICCV、NeurIPS、TPAMI等顶级学术会议和期刊上发表多篇论文。欢迎视觉算法、3D/XR引擎、深度学习引擎研发、终端研发等领域的优秀人才加入,共同走进3D数字新时代。
¤ 拓展阅读 ¤
更多推荐



所有评论(0)