前言

指令级并行( ILP, Instruction Level Parallelism)是指利用流水级并行和多指令发射等方式提高程序执行的并行度;

数据级并行(DLP, Data Level Parallelism)是指处理器能够同时处理多条数据的并行方式,即SIMD。

本文将对上述几种程序优化方式实现简单的测试样例进行性能提升的验证。

理论基础

周期

指令周期(Instruction Cycle):完成一条指令的时间;

机器周期(Machine Cycle,又称CPU周期):完成一条指令中单个基本操作(取指,译码,执行等)的时间;

时钟周期(Clock Cycle):主频的倒数;

三者之间的关系大致如下:

1

经典5级流水线

经典的5级流水线(现代CPU不止5级流水线,随着技术发展到今天,你日常用的手机 ARM 的 CPU 或者 Intel Core的CPU,流水线的深度是 14 级(存疑,暂无数据支撑找到了,看这里))如下图所示:

1

  • 指令提取周期(IF):送出PC(程序计数器),并将指令从存储器提取到指令寄存器中(IR);将PC递增4,以完成下一顺序指令的寻址。

  • 指令译码/寄存器提取周期(ID):对指令进行译码;并访问通用寄存器组(寄存器堆),读出所需操作数,放入临时寄存器;

  • 执行/实际地址周期(EX):不同指令所进行的操作不同。

    • load和store指令:ALU把指令中所指定的寄存器的内容与偏移量相加,形成访存有效地址。

    • 寄存器-寄存器ALU指令:ALU按照操作码指定的操作对从通用寄存器组中读出的数据进行运算。

    • 寄存器-立即数ALU指令:ALU按照操作码指定的操作对从通用寄存器组中读出的操作数和指令中给出的立即数进行运算。

    • 分支指令:ALU把指令中给出的偏移量与PC值相加,形成转移目标的地址。同时,对在前一个周期读出的操作数进行判断,确定分支是否成功。

  • 寄存器访问/分支完成计算(MEM):不同指令所进行的操作不同。该周期处理的指令只有load、store和分支指令。其它类型的指令在此周期不做任何操作。

    • load指令:用上一个周期计算出的有效地址从存储器中读出相应的数据;

    • store指令:把指定的数据写入这个有效地址所指出的存储器单元。

    • 分支指令:分支“成功”,就把转移目标地址送入PC,分支指令执行完成。

  • 写回周期(WB):不同指令所进行的操作不同。ALU运算指令和load指令在这个周期把结果数据写入通用寄存器组。

    • ALU运算指令:结果数据来自ALU。
    • load指令:结果数据来自存储器。

流水线为什么不是越长越好?

因为增加流水线深度是有性能代价的。

2

在流水线中,我们用来同步时钟周期的,是流水线级而不再是整条指令。所以每个流水线级的输出都要放到流水线寄存器(Pipeline Register)中,然后下个时钟周期,交于下一级流水线级进行处理。

所以每增加一级流水线级,就会多一次写入/读取流水线寄存器的操作,尽管这个过程相比流水线级本身的操作时间要快的多,但无脑的增加流水线的深度,会导致这一过程在整条指令时间消耗中所占的比例越来越大。其次,流水线深度的增加,还会导致冒险问题更难解决,从而导致吞吐量(IPC,Instruction Per Cycle,为CPI,Cycle Per Instruction的倒数)很难达到设计的最大值。因此,应当合理的设计流水线级数,在流水线深度和流水线寄存器overhead间做一定的trade-off。

测试用例

测试环境

平台:NVIDIA TX2,CPU Cortex A57,ArmV8架构,支持NEON Advanced SIMD,支持NEON Intrinsics,支持SuperScalar(超标量,又称指令多发射)

线程:单线程,不涉及线程级并行(TLP, Thread Level Parallelism)

编译器优化:O3

测试运算量

测试用例将会计算如下公式所示的操作:

ft(x)={1t=0ft1(x)+ft1(x)g(x)t>0f_t(x) = \begin{cases} 1 \quad t = 0 \\ f_{t-1}(x) + f_{t-1}(x) * g(x) \quad t > 0 \end{cases}

在本例中,x[0,107)x \in [0, 10^7)t[0,102)t \in [0, 10^2),即对两个长度为10710^7的buffer执行乘累加运算,每个元素循环10210^2次,运算量为10910^9(1G)MACs

基础实现

无任何优化的C++代码:

void OptLevel0(const float *input, 
float *output,
const int size,
const int loop_cnt) {
for (int i = 0; i < size; ++i) {
output[i] = 1.0f;
for (int k = 0; k < loop_cnt; ++k) {
output[i] += output[i] * input[i];
}
}
}

通过objdump -d得到反汇编代码如下:

0000000000000d08 <_Z9OptLevel0PKfPfii>:
d08: 7100005f cmp w2, #0x0
d0c: 5400024d b.le d54 <_Z9OptLevel0PKfPfii+0x4c>
d10: d2800005 mov x5, #0x0 // #0
d14: 1e2e1002 fmov s2, #1.000000000000000000e+00
d18: bc257822 str s2, [x1, x5, lsl #2]
d1c: 7100007f cmp w3, #0x0
d20: 5400014d b.le d48 <_Z9OptLevel0PKfPfii+0x40>
d24: 1e2e1000 fmov s0, #1.000000000000000000e+00
d28: 52800004 mov w4, #0x0 // #0
d2c: d503201f nop
d30: bc657801 ldr s1, [x0, x5, lsl #2]
d34: 11000484 add w4, w4, #0x1
d38: 6b04007f cmp w3, w4
d3c: 1f000020 fmadd s0, s1, s0, s0
d40: bc257820 str s0, [x1, x5, lsl #2]
d44: 54ffff61 b.ne d30 <_Z9OptLevel0PKfPfii+0x28> // b.any
d48: 910004a5 add x5, x5, #0x1
d4c: 6b05005f cmp w2, w5
d50: 54fffe4c b.gt d18 <_Z9OptLevel0PKfPfii+0x10>
d54: d65f03c0 ret

其中d30d44为最内层循环代码的汇编代码,可以看到编译器优化并没有将乘加运算进行向量化,而是只采用了标量的融合乘加计算指令fmadd,一条指令只进行一次浮点乘加运算,基本没有加速。

实测性能:4275ms

NEON向量化并行(SIMD)

使用NEON指令集对乘加运算向量化,这里没有写内联汇编,而是使用了NEON Intrinsics,ARMV8的向量寄存器为128位宽,一条指令可以处理4个浮点数据。

 void OptLevel1(const float *input, 
float *output,
const int size,
const int loop_cnt) {
int size_div = 4;
for (int i = 0; i < size / size_div; ++i) {
float32x4_t a = vld1q_f32(input + i * size_div);
float32x4_t c = vmovq_n_f32(1.0f);

for (int k = 0; k < loop_cnt; ++k) {
c = vfmaq_f32(c, c, a);
}
vst1q_f32(output + i * size_div, c);
}
}

同样查看反汇编有以下:

0000000000000d58 <_Z9OptLevel1PKfPfii>:
d58: 7100005f cmp w2, #0x0
d5c: 11000c44 add w4, w2, #0x3
d60: 1a82b082 csel w2, w4, w2, lt // lt = tstop
d64: 13027c42 asr w2, w2, #2
d68: 7100005f cmp w2, #0x0
d6c: 5400026d b.le db8 <_Z9OptLevel1PKfPfii+0x60>
d70: 51000442 sub w2, w2, #0x1
d74: 91004004 add x4, x0, #0x10
d78: 8b225084 add x4, x4, w2, uxtw #4
d7c: d503201f nop
d80: 3dc00001 ldr q1, [x0]
d84: 7100007f cmp w3, #0x0
d88: 4f03f600 fmov v0.4s, #1.000000000000000000e+00
d8c: 540000ed b.le da8 <_Z9OptLevel1PKfPfii+0x50>
d90: 52800002 mov w2, #0x0 // #0
d94: d503201f nop
d98: 4e21cc00 fmla v0.4s, v0.4s, v1.4s
d9c: 11000442 add w2, w2, #0x1
da0: 6b02007f cmp w3, w2
da4: 54ffffa1 b.ne d98 <_Z9OptLevel1PKfPfii+0x40> // b.any
da8: 91004000 add x0, x0, #0x10
dac: 3c810420 str q0, [x1], #16
db0: eb04001f cmp x0, x4
db4: 54fffe61 b.ne d80 <_Z9OptLevel1PKfPfii+0x28> // b.any
db8: d65f03c0 ret
dbc: d503201f nop

其中,vfmaq_f32对应的汇编指令为fmla,使用了v0v1两个向量寄存器,一条指令进行四次浮点乘加运算。

实测性能:1179ms,提升72.4%,接近理论值75%。

超标量(指令多发射)

将一条指令从指令译码级(ID)移入此流水线的执行级(EX)的过程称为指令发射(Issue)。

Cortex A57支持超标量(Superscalar)又称多发射(Multiple Issue),即存在多条执行pipeline,一个时钟周期内可以发射多条指令,即指令的吞吐量(throughput)> 1,或者称CPI(Cycle Per Instruction)< 1(这里吐槽一下intel的表示方法,其认为Throughput和CPI是同一个东西,对于双发射的指令,其标注为Throughput=CPI=0.5;我觉得应该是倒数的关系,即Throughput=ICP(Instruction Per Cycle)=2,CPI=0.5)。一个多发射的近似示意图如下所示:

2

而超标量的硬件实现则是(像乱序执行时给EX阶段增加不同的FU一样)给IF和ID阶段也增加硬件并行支持,可以一次性从内存里面取出多条指令,然后分发给多个并行的指令译码器,进行译码,然后对应交给不同的功能单元(FU)去执行。如下图所示:

2

Cortex A57 Software Optimization Guide external中关于多发射的示意图如下:

4

其中Fetch和Decode是同时对多条指令进行读取和译码,如果指令(们)满足多发射条件(不存在数据冒险),就会在一个时钟周期内发射多条指令到不同的执行pipeline。

查询手册可知fmla指令的吞吐量为2,即一个时钟周期内可以同时发射两条指令到两条pipeline,并行计算。

3

这里官方手册中有一点很奇怪,FMLAD-form(Double Word,双字)指令(对应Intrinsics为vfma_f32)确实是双发射,但Q-form(Quad word,四字)指令(对应Intrinsics为vfmaq_f32)是单发射。虽然看到有论文写到,在Cortex A57中,有两个64位宽的浮点pipeline,Q-form指令会拆成两个D-form指令发射到这两个pipeline中,但这也不是我们通常认为的那种双发射啊(按这样理解,并行度还是4而不是8)。且后面的实验也能看到,用10条vfmaq_f32指令填满2条流水线的5个流水级是性能最优的(耗时刚好是用5条指令的一半),这也侧面印证了vfmaq_f32也是双发射的。搞不懂了,姑且就认为vfmaq_f32的吞吐量就是2了。这里留个坑,回头弄明白了再来填吧

来填坑了,仔细看表格,发现vfmaq_f32的延迟是10,即可以理解为单发射,10个流水级。所以也刚好是10条vfmaq_f32指令可以把流水级填满。

双发射要求两条指令之间没有数据依赖(不会因数据冒险而产生发射停顿),因此修改代码,外层循环一次加载8个float数据到两个向量寄存器,最内层循环每次发射两条FMLA进行计算。

void OptLevel2(const float *input, 
float *output,
const int size,
const int loop_cnt) {
int size_div = 8;
for (int i = 0; i < size / size_div; ++i) {
float32x4_t a0 = vld1q_f32(input + i * size_div + 0);
float32x4_t a1 = vld1q_f32(input + i * size_div + 4);

float32x4_t c0 = vmovq_n_f32(1.0f);
float32x4_t c1 = vmovq_n_f32(1.0f);

for (int k = 0; k < loop_cnt; k++) {
c0 = vfmaq_f32(c0, c0, a0);
c1 = vfmaq_f32(c1, c1, a1);
}
vst1q_f32(output + i * size_div + 0, c0);
vst1q_f32(output + i * size_div + 4, c1);
}
}

查看反汇编如下:

0000000000000dc0 <_Z9OptLevel2PKfPfii>:
dc0: 7100005f cmp w2, #0x0
dc4: 11001c44 add w4, w2, #0x7
dc8: 1a82b082 csel w2, w4, w2, lt // lt = tstop
dcc: 13037c42 asr w2, w2, #3
dd0: 7100005f cmp w2, #0x0
dd4: 5400032d b.le e38 <_Z9OptLevel2PKfPfii+0x78>
dd8: 51000444 sub w4, w2, #0x1
ddc: 91008005 add x5, x0, #0x20
de0: 52800406 mov w6, #0x20 // #32
de4: 91004022 add x2, x1, #0x10
de8: 9ba61484 umaddl x4, w4, w6, x5
dec: d503201f nop
df0: 3dc00003 ldr q3, [x0]
df4: 7100007f cmp w3, #0x0
df8: 3dc00402 ldr q2, [x0, #16]
dfc: 4f03f600 fmov v0.4s, #1.000000000000000000e+00
e00: 540001ed b.le e3c <_Z9OptLevel2PKfPfii+0x7c>
e04: 4ea01c01 mov v1.16b, v0.16b
e08: 52800001 mov w1, #0x0 // #0
e0c: d503201f nop
e10: 4e23cc21 fmla v1.4s, v1.4s, v3.4s
e14: 11000421 add w1, w1, #0x1
e18: 4e22cc00 fmla v0.4s, v0.4s, v2.4s
e1c: 6b01007f cmp w3, w1
e20: 54ffff81 b.ne e10 <_Z9OptLevel2PKfPfii+0x50> // b.any
e24: 3c9f0041 stur q1, [x2, #-16]
e28: 91008000 add x0, x0, #0x20
e2c: eb04001f cmp x0, x4
e30: 3c820440 str q0, [x2], #32
e34: 54fffde1 b.ne df0 <_Z9OptLevel2PKfPfii+0x30> // b.any
e38: d65f03c0 ret
e3c: 4ea01c01 mov v1.16b, v0.16b
e40: 91008000 add x0, x0, #0x20
e44: eb04001f cmp x0, x4
e48: 3c9f0041 stur q1, [x2, #-16]
e4c: 3c820440 str q0, [x2], #32
e50: 54fffd01 b.ne df0 <_Z9OptLevel2PKfPfii+0x30> // b.any
e54: 17fffff9 b e38 <_Z9OptLevel2PKfPfii+0x78>

其中包含了两条fmla指令,分别使用了两个向量寄存器v1v3v0v2。两条指令数据互不依赖,可以进行双发射。

实测性能:621ms,(相比上一优化)提升47.3%,接近理论值50%。

填坑之后,可以看到这里并不是真正意义的双发射,而是做了2x的流水级并行,本节和下一节中关于双发射的描述并不符合实际情况,起码在A57上并不准确。

流水级并行

5级流水线的不同流水级(Pipeline Stage)在同一时钟周期内也可以并行(比如IF取完上一条指令后就空闲出来了,自然就可以取下一条指令,此时上一条指令在ID级),因此可以在一个指令周期内(不同的时钟周期)发射多条指令,保证一个时钟周期内流水线的各个阶段都有任务在执行。只要执行时间足够长(计算量足够大)的话,除了开始和结束流水线的部分,流水线可以近似5x并行。示意图如下:

1

其中纵轴S0到S5表示流水线的5个流水级,Port0和Port1表示有两个发射端口,即双发射。第一个时钟周期,有两条FMA指令(红色)被发射到Port0和Port1,并执行流水线的第一流水级IF(其实按照更狭义的理解,这里不能称为发射,而是IF取两条指令,后续ID级检查冒险后,能流入EX后才叫发射。但是由于IF和ID可以处理多条指令,不存在数据冒险的前提下,与EX级一起看成一整条pipeline,也没毛病,且更容易理解);而后到了第二个时钟周期,这两条指令进入第二流水级ID,同时新的两条FMA指令(蓝色)进入第一流水级IF;依此类推,周而复始,完美衔接,不存在任何停顿。

如果是单发射的CPU,需要连续5条(不存在数据冒险的)指令才能填满流水线各个阶段。如果是双发射,则需要至少10条指令。

同样,流水级并行的关键还是在于指令间不存在数据依赖,不产生数据冒险而发生发射停顿。

因此修改代码,外层循环一次加载40个float数据到10个向量寄存器,最内层循环每次发射(广义)10条FMLA进行计算。

void OptLevel3(const float *input, 
float *output,
const int size,
const int loop_cnt) {
int size_div = 40;
for (int i = 0; i < size / size_div; ++i) {
float32x4_t a0 = vld1q_f32(input + i * size_div + 0);
float32x4_t a1 = vld1q_f32(input + i * size_div + 4);
float32x4_t a2 = vld1q_f32(input + i * size_div + 8);
float32x4_t a3 = vld1q_f32(input + i * size_div + 12);
float32x4_t a4 = vld1q_f32(input + i * size_div + 16);
float32x4_t a5 = vld1q_f32(input + i * size_div + 20);
float32x4_t a6 = vld1q_f32(input + i * size_div + 24);
float32x4_t a7 = vld1q_f32(input + i * size_div + 28);
float32x4_t a8 = vld1q_f32(input + i * size_div + 32);
float32x4_t a9 = vld1q_f32(input + i * size_div + 36);

float32x4_t c0 = vmovq_n_f32(1.0f);
float32x4_t c1 = vmovq_n_f32(1.0f);
float32x4_t c2 = vmovq_n_f32(1.0f);
float32x4_t c3 = vmovq_n_f32(1.0f);
float32x4_t c4 = vmovq_n_f32(1.0f);
float32x4_t c5 = vmovq_n_f32(1.0f);
float32x4_t c6 = vmovq_n_f32(1.0f);
float32x4_t c7 = vmovq_n_f32(1.0f);
float32x4_t c8 = vmovq_n_f32(1.0f);
float32x4_t c9 = vmovq_n_f32(1.0f);

// method 0
for (int k = 0; k < loop_cnt; k++) {
c0 = vfmaq_f32(c0, c0, a0);
c1 = vfmaq_f32(c1, c1, a1);

c2 = vfmaq_f32(c2, c2, a2);
c3 = vfmaq_f32(c3, c3, a3);

c4 = vfmaq_f32(c4, c4, a4);
c5 = vfmaq_f32(c5, c5, a5);

c6 = vfmaq_f32(c6, c6, a6);
c7 = vfmaq_f32(c7, c7, a7);

c8 = vfmaq_f32(c8, c8, a8);
c9 = vfmaq_f32(c9, c9, a9);
}

vst1q_f32(output + i * size_div + 0, c0);
vst1q_f32(output + i * size_div + 4, c1);
vst1q_f32(output + i * size_div + 8, c2);
vst1q_f32(output + i * size_div + 12, c3);
vst1q_f32(output + i * size_div + 16, c4);
vst1q_f32(output + i * size_div + 20, c5);
vst1q_f32(output + i * size_div + 24, c6);
vst1q_f32(output + i * size_div + 28, c7);
vst1q_f32(output + i * size_div + 32, c8);
vst1q_f32(output + i * size_div + 36, c9);
}
}

对应汇编代码如下:

0000000000000e58 <_Z9OptLevel3PKfPfii>:
e58: 528ccce4 mov w4, #0x6667 // #26215
e5c: 72acccc4 movk w4, #0x6666, lsl #16
e60: 9b247c44 smull x4, w2, w4
e64: 9364fc84 asr x4, x4, #36
e68: 4b827c82 sub w2, w4, w2, asr #31
e6c: 7100005f cmp w2, #0x0
e70: 54000b8d b.le fe0 <_Z9OptLevel3PKfPfii+0x188>
e74: 51000442 sub w2, w2, #0x1
e78: a9bd7bfd stp x29, x30, [sp, #-48]!
e7c: 91000445 add x5, x2, #0x1
e80: 9101401e add x30, x0, #0x50
e84: 910003fd mov x29, sp
e88: 8b0508a5 add x5, x5, x5, lsl #2
e8c: a90153f3 stp x19, x20, [sp, #16]
e90: a9025bf5 stp x21, x22, [sp, #32]
e94: 9100c014 add x20, x0, #0x30
e98: 91004016 add x22, x0, #0x10
e9c: 91008015 add x21, x0, #0x20
ea0: 91010013 add x19, x0, #0x40
ea4: 91018012 add x18, x0, #0x60
ea8: 9101c011 add x17, x0, #0x70
eac: d37be8a5 lsl x5, x5, #5
eb0: 91020010 add x16, x0, #0x80
eb4: 9102400f add x15, x0, #0x90
eb8: 9100402e add x14, x1, #0x10
ebc: 9100802d add x13, x1, #0x20
ec0: 9100c02c add x12, x1, #0x30
ec4: 9101002b add x11, x1, #0x40
ec8: 9101402a add x10, x1, #0x50
ecc: 91018029 add x9, x1, #0x60
ed0: 9101c028 add x8, x1, #0x70
ed4: 91020027 add x7, x1, #0x80
ed8: 91024026 add x6, x1, #0x90
edc: d2800002 mov x2, #0x0 // #0
ee0: 3ce2681b ldr q27, [x0, x2]
ee4: 7100007f cmp w3, #0x0
ee8: 3ce26ada ldr q26, [x22, x2]
eec: 3ce26ab9 ldr q25, [x21, x2]
ef0: 3ce26a98 ldr q24, [x20, x2]
ef4: 3ce26a77 ldr q23, [x19, x2]
ef8: 3ce26bd6 ldr q22, [x30, x2]
efc: 3ce26a55 ldr q21, [x18, x2]
f00: 3ce26a34 ldr q20, [x17, x2]
f04: 3ce26a13 ldr q19, [x16, x2]
f08: 3ce269f2 ldr q18, [x15, x2]
f0c: 4f03f600 fmov v0.4s, #1.000000000000000000e+00
f10: 5400054d b.le fb8 <_Z9OptLevel3PKfPfii+0x160>
f14: 4ea01c01 mov v1.16b, v0.16b
f18: 52800004 mov w4, #0x0 // #0
f1c: 4ea01c02 mov v2.16b, v0.16b
f20: 4ea01c03 mov v3.16b, v0.16b
f24: 4ea01c04 mov v4.16b, v0.16b
f28: 4ea01c05 mov v5.16b, v0.16b
f2c: 4ea01c06 mov v6.16b, v0.16b
f30: 4ea01c07 mov v7.16b, v0.16b
f34: 4ea01c10 mov v16.16b, v0.16b
f38: 4ea01c11 mov v17.16b, v0.16b
f3c: d503201f nop
f40: 4e3bce31 fmla v17.4s, v17.4s, v27.4s
f44: 11000484 add w4, w4, #0x1
f48: 4e3ace10 fmla v16.4s, v16.4s, v26.4s
f4c: 6b04007f cmp w3, w4
f50: 4e39cce7 fmla v7.4s, v7.4s, v25.4s
f54: 4e38ccc6 fmla v6.4s, v6.4s, v24.4s
f58: 4e37cca5 fmla v5.4s, v5.4s, v23.4s
f5c: 4e36cc84 fmla v4.4s, v4.4s, v22.4s
f60: 4e35cc63 fmla v3.4s, v3.4s, v21.4s
f64: 4e34cc42 fmla v2.4s, v2.4s, v20.4s
f68: 4e33cc21 fmla v1.4s, v1.4s, v19.4s
f6c: 4e32cc00 fmla v0.4s, v0.4s, v18.4s
f70: 54fffe81 b.ne f40 <_Z9OptLevel3PKfPfii+0xe8> // b.any
f74: 3ca16851 str q17, [x2, x1]
f78: 3ca269d0 str q16, [x14, x2]
f7c: 3ca269a7 str q7, [x13, x2]
f80: 3ca26986 str q6, [x12, x2]
f84: 3ca26965 str q5, [x11, x2]
f88: 3ca26944 str q4, [x10, x2]
f8c: 3ca26923 str q3, [x9, x2]
f90: 3ca26902 str q2, [x8, x2]
f94: 3ca268e1 str q1, [x7, x2]
f98: 3ca268c0 str q0, [x6, x2]
f9c: 91028042 add x2, x2, #0xa0
fa0: eb05005f cmp x2, x5
fa4: 54fff9e1 b.ne ee0 <_Z9OptLevel3PKfPfii+0x88> // b.any
fa8: a94153f3 ldp x19, x20, [sp, #16]
fac: a9425bf5 ldp x21, x22, [sp, #32]
fb0: a8c37bfd ldp x29, x30, [sp], #48
fb4: d65f03c0 ret
fb8: 4ea01c01 mov v1.16b, v0.16b
fbc: 4ea01c02 mov v2.16b, v0.16b
fc0: 4ea01c03 mov v3.16b, v0.16b
fc4: 4ea01c04 mov v4.16b, v0.16b
fc8: 4ea01c05 mov v5.16b, v0.16b
fcc: 4ea01c06 mov v6.16b, v0.16b
fd0: 4ea01c07 mov v7.16b, v0.16b
fd4: 4ea01c10 mov v16.16b, v0.16b
fd8: 4ea01c11 mov v17.16b, v0.16b
fdc: 17ffffe6 b f74 <_Z9OptLevel3PKfPfii+0x11c>
fe0: d65f03c0 ret
fe4: 00000000 .inst 0x00000000 ; undefined

可以看到最内层循环中使用了10条fmla指令,20个向量寄存器v0-v7v16-v27

实测性能:139ms,(相比上一优化)提升77.6%,接近理论值80%。

多平台验证

除TX2外,数据来源自东哥分享,未自测

平台 架构 L0/ms L1/ms L2/ms L3/ms 备注
TX2 Cortex A57 AARCH64 2GHz主频 4275 1179 621 139
Apple M1 AARCH64 3.14Ghz主频 1023 220 130 43
AX630A Cortex A53 AARCH64 1.3Ghz主频 1256 317 161 44 buffer size调整为2×1062 \times 10^6
Amba H22 Cortex A53 AARCH32 1Ghz主频 2569 474 240 68 buffer size调整为2×1062 \times 10^6;L3优化中外层循环一次加载32个浮点数据

上面Amba H22的实验设计中,L3优化中外层循环改为一次加载32个浮点数据的原因是,AARCH32只有16个128bit的向量寄存器,8个用于加载input数据,8个用于累加output,只能(非要超量加载也不是不行,只不过需要用到超量部分的数据时,已加载的数据会被压入栈,腾出寄存器给新的数据,这样会增加访存量,且流水线也不能完美衔接,导致性能下降)一次加载32个浮点数据。

总结

  1. 这里是从运算量角度分析性能提升的原因,其实由于一条指令load/store多个数据,访存量也会减少。
  2. 本例中的计算过程比较简单,能够比较接近理论的性能峰值;当计算变复杂时,计算中的数据依赖关系增多,并行将会更难实现,也许更加巧妙的利用流水线可以优化性能,也许根本就无解。

参考

[1] 计算机体系结构:量化研究方法(第5版)

[2] https://zhuanlan.zhihu.com/p/426127316

致谢

依旧感谢东哥的分享~