关于 SIMD,或者说 NEON,我已经发布了几篇文章来介绍它了,如果你看过了这些内容,相信你对于 NEON 有了一定的了解。在此之前,我们更多停留在理论阶段:介绍了 NEON 的 API,举了几个简单的例子。
今天,我们将通过一些练习,这些任务在实际开发中你也可能会遇到,它们足够简单,作为 NEON 入门教学示例非常合适。我们将向你演示,如何使用 NEON 来优化现有代码,以及通过 Benchmark 来测试优化前与优化后的性能差异。
令人遗憾的是,本以为掌握的 SIMD 可以让你算法性能得到成倍的提升,但实际测试下来却发现编译器实在太聪明了,对于一些简单的任务,编译器优化后的代码比你手写的 SIMD 更快更好。说实话,这让我有些沮丧,让我学习 SIMD 的动力降低了不少,但又觉得庆幸,作为程序员可以比较放心的将一部分工作交给编译器了,无需再卷。
本来我预计的博客内容流程应该是这样的:
但实际上优化后的性能基本是负优化(吐血~),而且不同编译器表现不同,同样一份代码在 A 平台下性能提升,但换到 B 平台下可能就是负优化了。这就导致「优化」这项工作甚至要与编译器版本、操作系统绑定,事情就变得越来越复杂了,不单单是代码层面上的事情了。当然,发生这一切的原因可能是因为我给的示例任务太过简单,简单到聪明的编译器一眼看透了。
无论如何,我还是决定将整个过程整理出来,给各位看官一个参考。以下示例运行将运行在笔者的 Mac M1 和 Android 荣耀 50 上,所有代码你可以在 neon_intrinsics_exercise - github 找到。
当想要对某个算法做性能优化时,首先考虑有没有更优的实现方式,例如将冒泡排序改为快排,算法复杂度从 O ( N 2 ) O(N^2) O(N2) 降到了 O ( N log N ) O(N\log N) O(NlogN)。
当算法实现已经固定,实现上没有更优的方式后,这时候就考虑使用 SIMD 技术进行性能优化了。假设现在有个一个算法 A 要进行优化,那么整体流程大致为:
总而言之,我们既要保证优化后的结果是正确的,又要保证性能的的确确得到了提升。
任务描述:实现一个函数,使用 NEON 指令集,对一个数组中的所有数字求和,并返回结果。
这个任务非常简单,聪明的你可能脑海中已经有了 NEON 的实现思路,但请停一停。饭一口一口的吃,我们先从最简单的开始,使用 C/C++ 实现一个最简单的实现,与 NEON 无关。代码如下:
float sum(float* array, size_t size)
{
float s = 0.0f;
for(int i = 0; i < size; ++i){
s += array[i];
}
return s;
}
先对 baseline 代码做循环展开:
float sum_expand(float* array, size_t size)
{
float s = 0.0f;
int i = 0;
for(; i < size; i += 4){
s += array[i];
s += array[i + 1];
s += array[i + 2];
s += array[i + 3];
}
for(; i < size; ++i) {
s += array[i];
}
return s;
}
其中循环展开部分,可以使用 SIMD 向量操作来完成:
float sum_neon(float* array, size_t size)
{
int i = 0;
float32x4_t out_chunk{0.0f,0.0f,0.0f,0.0f};
for(; i < size; i+=4){
float32x4_t chunk = vld1q_f32(array + i);
out_chunk = vaddq_f32(out_chunk, chunk);
}
float x = out_chunk[0] + out_chunk[1] + out_chunk[2] + out_chunk[3];
for(;i < size; ++i){
x += array[i];
}
return x;
}
其中:
vld1q_f32(array + i)
从内存中加载数据到向量 chunk
中vaddq_f32
,进行向量加法for
循环对剩下的数据进行累加Mac M1 | Android 荣耀 50 | |
---|---|---|
baseline | 16 ns | 3167 us |
neon | 16 ns | 2445 us |
在 Android 性能优化了 23% 左右;在 Mac M1 下没有性能上的提升。
任务描述:给你左右声道的数据和两个声道的音量,分别是两个 float
的数组和两个 float
值,将左右声道进行 mix,输出 mix 后的数据
void mix(float *left, float left_volume,
float *right, float right_volume,
float *output, size_t size) {
for (int i = 0; i < size; ++i) {
output[i] = left[i] * left_volume + right[i] * right_volume;
}
}
同样的,先做循环展开
void mix_expand(float *left, float left_volume,
float *right, float right_volume,
float *output, size_t size) {
int i = 0;
for (; i < size; i += 4) {
output[i] = left[i] * left_volume + right[i] * right_volume;
output[i + 1] = left[i + 1] * left_volume + right[i + 1] * right_volume;
output[i + 2] = left[i + 2] * left_volume + right[i + 2] * right_volume;
output[i + 3] = left[i + 3] * left_volume + right[i + 3] * right_volume;
}
for (; i < size; ++i) {
output[i] = left[i] * left_volume + right[i] * right_volume;
}
}
根据循环展开,大致可以知道有三个向量,分别是左声道数据、右声道数据、以及输出数据:
void mix_neon(float *left, float left_volume,
float *right, float right_volume,
float *output, size_t size) {
int i = 0;
for (; i < size; i += 4) {
float32x4_t left_chunk = vld1q_f32(left + i);
float32x4_t right_chunk = vld1q_f32(right + i);
left_chunk = vmulq_n_f32(left_chunk, left_volume);
right_chunk = vmulq_n_f32(right_chunk, right_volume);
float32x4_t output_chunk = vaddq_f32(left_chunk, right_chunk);
vst1q_f32(output + i, output_chunk);
}
for (; i < size; ++i) {
output[i] = left[i] * left_volume + right[i] * right_volume;
}
}
其中:
vld1q_f32
从内存中导入左右声道数据vmulq_n_f32
即向量乘上一个常数vaddq_f32
使用向量加法将左右声道数据相加Mac M1 | Android 荣耀 50 | |
---|---|---|
baseline | 136 us | 3329 us |
neon | 227 us | 5401 us |
在这个 case 下,M1 和 Android 下都是负优化
关于 FIR 滤波器和 SIMD 实现请参考 用 NEON 实现高效的 FIR 滤波器,细节不再赘述。
float* applyFirFilterSingle(FilterInput& input) {
const auto* x = input.x;
const auto* c = input.c;
auto* y = input.y;
for (auto i = 0u; i < input.outputLength; ++i) {
y[i] = 0.f;
for (auto j = 0u; j < input.filterLength; ++j) {
y[i] += x[i + j] * c[j];
}
}
return y;
}
float* applyFirFilterInnerLoopVectorizationARM(FilterInput& input) {
const auto* x = input.x;
const auto* c = input.c;
auto* y = input.y;
for (auto i = 0u; i < input.outputLength; ++i) {
y[i] = 0.f;
float32x4_t outChunk = vdupq_n_f32(0.0f);
for (auto j = 0u; j < input.filterLength; j += 4) {
float32x4_t xChunk = vld1q_f32(x + i + j);
float32x4_t cChunk = vld1q_f32(c + j);
float32x4_t temp = vmulq_f32(xChunk, cChunk);
outChunk = vaddq_f32(outChunk, temp);
}
y[i] = vaddvq_f32(outChunk);
}
return y;
}
float* applyFirFilterOuterLoopVectorizationARM(FilterInput& input) {
const auto* x = input.x;
const auto* c = input.c;
auto* y = input.y;
// Note the increment by 4
for (auto i = 0u; i < input.outputLength; i += 4) {
float32x4_t yChunk{0.0f, 0.0f, 0.0f, 0.0f};
for (auto j = 0u; j < input.filterLength; ++j) {
float32x4_t xChunk = vld1q_f32(x + i + j);
float32x4_t temp = vmulq_n_f32(xChunk, c[j]);
yChunk = vaddq_f32(yChunk, temp);
}
// store to memory
vst1q_f32(y + i, yChunk);
}
return y;
}
float* applyFirFilterOuterInnerLoopVectorizationARM(FilterInput& input)
{
const auto* x = input.x;
const auto* c = input.c;
auto* y = input.y;
const int K = 4;
std::array<float32x4_t, K> outChunk{};
for (auto i = 0u; i < input.outputLength; i += K) {
for(auto k = 0; k < K; ++k){
outChunk[k] = vdupq_n_f32(0.0f);
}
for (auto j = 0u; j < input.filterLength; j += 4) {
float32x4_t cChunk = vld1q_f32(c + j);
for(auto k = 0; k < K; ++k)
{
float32x4_t xChunk = vld1q_f32(x + i + j +k);
float32x4_t temp = vmulq_f32(cChunk, xChunk);
outChunk[k] = vaddq_f32(temp, outChunk[k]);
}
}
for(auto k = 0; k < K; ++k){
y[i + k] = vaddvq_f32(outChunk[k]);
}
}
return input.y;
}
Mac M1 | Android 荣耀 50 | |
---|---|---|
baseline | 10420 us | 51119 us |
VIL | 2297 us | 55947 us |
VOL | 2524 us | 54134 us |
VOIL | 689 us | 69341 us |
在 Mac M1 下 SIMD 取得了不错的优化,但在 Android 下却都是负优化。
由于现代编译器过于牛逼,一些不复杂的任务编译器已经能够自动识别并进行向量化,导致 SIMD 优化技巧需要斟酌使用,我们在做优化前要确定好基线,优化后要确保算法输出与原来一致,且与基线性能做对比,确保做了正向的优化。本来我还从 webrtc 中找了几个 NEON 实现的算法,但测试下来仍然是负优化,就不放上来了。