NEON Intrinsics 练习题

系列文章目录

  • 数字信号处理中的 SIMD
  • Neon intrinsics 简明教程
  • 用 NEON 实现高效的 FIR 滤波器

前言

关于 SIMD,或者说 NEON,我已经发布了几篇文章来介绍它了,如果你看过了这些内容,相信你对于 NEON 有了一定的了解。在此之前,我们更多停留在理论阶段:介绍了 NEON 的 API,举了几个简单的例子。

今天,我们将通过一些练习,这些任务在实际开发中你也可能会遇到,它们足够简单,作为 NEON 入门教学示例非常合适。我们将向你演示,如何使用 NEON 来优化现有代码,以及通过 Benchmark 来测试优化前与优化后的性能差异。

令人遗憾的是,本以为掌握的 SIMD 可以让你算法性能得到成倍的提升,但实际测试下来却发现编译器实在太聪明了,对于一些简单的任务,编译器优化后的代码比你手写的 SIMD 更快更好。说实话,这让我有些沮丧,让我学习 SIMD 的动力降低了不少,但又觉得庆幸,作为程序员可以比较放心的将一部分工作交给编译器了,无需再卷。

本来我预计的博客内容流程应该是这样的:

  1. 提出一个问题,用基础的实现给出 baseline
  2. 使用 NEON 进行优化
  3. 哇塞,优化后的性能得到了成倍的提升,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 要进行优化,那么整体流程大致为:

  1. 对 A 算法进行 profiling,将结果作为优化的基线
  2. 对 A 算法进行 SIMD 优化,得到优化后的算法 A_SIMD
  3. 将 A_SIMD 与 A 算法输出进行对比,确保 A_SIMD 结果与优化前结果保持一致
  4. 对 A_SIMD 进行 profiling,与基线做对比,确保做了正向的优化

总而言之,我们既要保证优化后的结果是正确的,又要保证性能的的确确得到了提升。

二、一些示例

1. 向量累加和

任务描述:实现一个函数,使用 NEON 指令集,对一个数组中的所有数字求和,并返回结果。

1.1 baseline

这个任务非常简单,聪明的你可能脑海中已经有了 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;
}

1.2 NEON 实现

先对 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;
}

其中:

  1. vld1q_f32(array + i) 从内存中加载数据到向量 chunk
  2. vaddq_f32,进行向量加法
  3. 最后用一个 for 循环对剩下的数据进行累加

1.3 性能对比

Mac M1 Android 荣耀 50
baseline 16 ns 3167 us
neon 16 ns 2445 us

在 Android 性能优化了 23% 左右;在 Mac M1 下没有性能上的提升。

2. 左右声道混音

任务描述:给你左右声道的数据和两个声道的音量,分别是两个 float 的数组和两个 float 值,将左右声道进行 mix,输出 mix 后的数据

2.1 baseline

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;
    }
}

2.2 NEON 实现

同样的,先做循环展开

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;
    }
}

其中:

  1. vld1q_f32 从内存中导入左右声道数据
  2. vmulq_n_f32 即向量乘上一个常数
  3. vaddq_f32 使用向量加法将左右声道数据相加

2.3 性能对比

Mac M1 Android 荣耀 50
baseline 136 us 3329 us
neon 227 us 5401 us

在这个 case 下,M1 和 Android 下都是负优化

3. FIR 滤波器

关于 FIR 滤波器和 SIMD 实现请参考 用 NEON 实现高效的 FIR 滤波器,细节不再赘述。

3.1 baseline

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;
}

3.1 VIL

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;
}

3.2 VOL

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;
}

3.3 VOIL

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;
}

3.4 性能对比

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 实现的算法,但测试下来仍然是负优化,就不放上来了。

你可能感兴趣的:(SIMD,SIMD,NEON)