本文翻译自 What is SIMD in Digital Signal Processing?,希望通过对这篇文章的翻译了解 SIMD,并为后续学习 SIMD 做铺垫。强烈建议有余力的同学可以阅读原文。
图像或声音的数字信号处理需要对大量的数据进行复杂的操作。例如,为了缩放(改变音量)一秒钟的音频数据,我们可能要进行44100次乘法运算。
如果我们实时地进行操作,且整个渲染过程在 10ms 内完成操作,事情就变得更加困难了。
值得庆幸的是,有一些编程工具可以让我们更高效地处理这些情况,其中之一就是本文的主题 — SIMD
Single instruction, multiple data(SIMD),即「单指令多数据」,它是一种特殊的处理器指令,一次对 1 个以上的变量进行操作。
用数学术语来说,我们可以说 SIMD 对变量的向量(数组)进行操作,就像正常代码对单个变量进行操作一样。
想象一下,我们有两个数组 v 1 v_1 v1 和 v 2 v_2 v2,每个数组有 8 个 32 位的浮点数变量,假设 v 12 = v 1 + v 2 v_{12}=v_1 + v_2 v12=v1+v2。如何计算 v 12 v_{12} v12?在 C 代码中,你可以这么写:
for(auto i = 0; i < 8; ++i)
v12[i] = v1[i] + v2[i]
上述代码中,有 8 次加法操作。如果我们可以简单地这么写:
v12 = vector8x32_add(v1, v2);
这样就只会产生一个加法操作?从理论上讲,这样的程序会快 8 倍。实际上,这与事实相差无几! 虽然执行速度的提高不一定与多线程的使用成正比,但SIMD的速度往往与数据向量中可容纳的变量数量成正比。
在AVX-512指令中,人们可以用一条指令对16个浮点数进行操作。你能想象你的代码的处理时间减少16倍吗?而如果我们使用16位整数,我们乐观地得到…没错,是32倍的提速。这就是 SIMID 的力量。
注意:使用SIMD的实现的性能必须在每个处理器上单独测量。此外,在第一次尝试时,线性提速是非常不可能的。但这是另一篇文章的主题。
SIMD 是又微处理器产商提供的。
一般来说,有几个处理器架构系列,其中主要是 x86 和 ARM。
每个架构系列都有一套的核心寄存器和相关指令,可以在该系列的任何处理器上运行。而新一代处理器都在上一代处理器的指令集上进行扩展。此外,每个处理器可以有一些列指令,这些指令是特定处理器型号特有的。
特定世代的指令集和特定处理器的指令通常利用额外的架构元素,如额外的寄存器或专用算术单元。
你可能会问自己:当每个处理器接受不同的指令时,怎么可能编写一个版本的源代码?
这就是为什么像 C 这样的高级编程语言的存在是一个福音。 🙂
编译器的作用是把你写的源代码,翻译成处理器专用的汇编语言。编译器对于哪些处理器包含哪些指令非常明智。他们也很善于确定调用这些指令中的哪些指令,并以何种顺序来使软件尽可能地高效。事实上,它们比至少95%的程序员(包括我)更擅长于此。
不幸的是,他们不知道我们想通过代码达到什么目的。例如,他们不知道这种大量的乘法和加法实际上是有限脉冲响应(FIR)过滤。因此,他们往往无法利用底层硬件的全部潜力。
这就是你需要你参与的地方:一个程序员,他知道如何用处理器特定的指令来编码 DSP 算法。
让我们再次声明。SIMD指令是简单的特殊处理器指令。这意味着它们可以在汇编代码中找到,也就是直接在寄存器和内存上操作的代码。
SIMD通常使用专用寄存器。这意味着,有三种指令必须是肯定可以使用的:
例如,x86 处理器的 AVX 指令集有 vmovups
指令,它将 8 个 32位浮点数(ps 表示"单精度")的向量(v)加载(mov)到 AVX寄存器中。指令中的 u 代表 “未对齐”,这意味着我们加载的内存位置不必在32字节边界上对齐。
一旦数据进入专用寄存器,我们就可以对它们进行一些操作:
正如你所看到的,可用操作的数量是巨大的。
为了在任何给定的硬件上有效地利用 SIMD 的力量,我们可以使用处理器制造商提供的文档。
例如,AVX指令集的所有可用指令的列表可以在英特尔的网站上找到。
我们知道 SIMD 指令是什么,也知道了它能做什么,作为软件开发者,我们如何才能访问它们呢?我见过 4 中不同的方法可以将 SIMD 指令嵌入到你的代码中。
asm
块/arch:AVX
以及 gcc 和clang上的 -mavx
(以便能够使用AVX指令)。问题是,运行该软件的处理器必须有这些指令。这是开发人员的一个额外责任。你可能遇到过许多代表 SIMD 指令集系列的缩写。下面我列出了对数字信号处理最重要的指令以及简短的描述。
简称 | 全名 | 架构 | 可用寄存器 | 备注 |
---|---|---|---|---|
Intel® MMX™ | x86 | 8 个 64-bit 的寄存器 | 只支持整数操作 | |
Intel® SSE | Streaming SIMD Extensions | x86 | 8 个 128-bit 的寄存器 | 在 MMX 上的扩展,支持浮点操作 |
Intel® AVX | Advanced Vector Extensions | x86 | 8 个 256-bit 的寄存器(AVX-512 则是 512-bit) | SSE 的扩展 |
Arm Neon | ARM | 32 个 128-bit 的寄存器 | Android 6.0 以上设备全部支持;苹果设备也支持:iPhone、iPad、一些型号的 Mac;可用在 x86 架构上使用 ARM_NEON_2_x86_SSE |
由于有很多指令集可供选择,你可能会发问:如何选择合适的指令集?答案总是:看情况。
首先要考虑你的软件在在什么平台上运行。例如如果是你为 Android 设备编写软件,你可能会使用 Neon。但并不总是如此,有一些 Android 驱动的设备也运行在 X86 架构上。
如果你只针对x86架构,并想尽可能地利用SIMD,你应该为所有的x86 SIMD指令集提供实现,并给软件提供后备可能性;例如,如果AVX-512不存在,也许可以使用SSE。请记住,有SSE、SSE2、SSE3、SSE4…还有一些小版本。
如果你不知道你的软件将在哪个平台上运行,你就会面临实现每个可用指令集支持的可能性。
这就是为什么编译器是如此的幸运:它们可以为我们做这些繁重的工作。而且在这个过程中犯的错误要少得多。 🙂
SIMD在数字信号处理应用中特别有优势。为什么呢?
不幸的是,SIMD 并不全是美好的。下面是SIMD在DSP方面的一些缺点(但不限于此)。
vrecpeq_f32
或 _mm256_testnzc_ps
这样的函数,当你看代码或与同事交谈时,不容易阅读和理解,也不容易发音。另一方面,一旦你对内在函数有了一些了解,这些名字就会成为非常好的记忆法。为了完成这篇文章,我们将用C++语言编写一个小的例子,它使用了内置函数。
具体来说,我们将使用英特尔处理器上的AVX指令集。
这个小程序的目标是计算两个浮点数向量的内积。AVX寄存器有256位,所以每个寄存器应该正好可以容纳8个32位浮点数。
#include
#include
#include
#include
#include
#include
#include
#include
using Vector = std::vector<float>;
Vector scalarAdd(const Vector& a, const Vector& b) {
assert(a.size() == b.size());
Vector result(a.size());
for (auto i = 0u; i < a.size(); ++i) {
result[i] = a[i] + b[i];
}
return result;
}
Vector simdAdd(const Vector& a, const Vector& b) {
assert(a.size() == b.size());
Vector result(a.size());
constexpr auto FLOATS_IN_AVX_REGISTER = 8u;
const auto vectorizableSamples = (a.size() / FLOATS_IN_AVX_REGISTER)
* FLOATS_IN_AVX_REGISTER;
auto i = 0u;
for (; i < vectorizableSamples; i += FLOATS_IN_AVX_REGISTER) {
// load unaligned data to SIMD registers
auto aRegister = _mm256_loadu_ps(a.data() + i);
auto bRegister = _mm256_loadu_ps(b.data() + i);
// perform the addition
auto intermediateSum = _mm256_add_ps(aRegister, bRegister);
// store data back in the data vector
_mm256_storeu_ps(result.data() + i, intermediateSum);
}
// process the remaining (unvectorized) samples
for (; i < a.size(); ++i) {
result[i] = a[i] + b[i];
}
return result;
}
Vector randomVector(Vector::size_type size) {
Vector v(size);
auto randomEngine = std::default_random_engine();
std::uniform_real_distribution<float> uniformDistribution(-1.f, 1.f);
auto generator = [&]() { return uniformDistribution(randomEngine); };
std::generate(v.begin(), v.end(), generator);
return v;
}
int main() {
constexpr auto TEST_VECTOR_SIZE = 1000001;
const auto a = randomVector(TEST_VECTOR_SIZE);
const auto b = randomVector(TEST_VECTOR_SIZE);
assert(scalarAdd(a, b) == simdAdd(a, b));
constexpr auto TEST_RUN_COUNT = 1000;
using namespace std::chrono;
milliseconds totalScalarTime{};
for (auto i = 0u; i < TEST_RUN_COUNT; ++i) {
auto start = high_resolution_clock::now();
auto result = scalarAdd(a, b);
auto end = high_resolution_clock::now();
totalScalarTime += duration_cast<milliseconds>(end - start);
}
std::cout << "Average scalarAdd() execution time: "
<< totalScalarTime.count() / static_cast<float>(TEST_RUN_COUNT)
<< " ms." << std::endl;
milliseconds totalSimdTime{};
for (auto i = 0u; i < TEST_RUN_COUNT; ++i) {
auto start = high_resolution_clock::now();
auto result = simdAdd(a, b);
auto end = high_resolution_clock::now();
totalSimdTime += duration_cast<milliseconds>(end - start);
}
std::cout << "Average simdAdd() execution time: "
<< totalSimdTime.count() / static_cast<float>(TEST_RUN_COUNT)
<< " ms." << std::endl;
}
输出:
.\InnerProductSIMD.exe
Average scalarAdd() execution time: 11.695 ms.
Average simdAdd() execution time: 4.113 ms.
在这篇文章中,我们讨论了 SIMD 指令在数字信号处理中的用处。
SIMD指令让我们使用专用的处理器寄存器同时对一个以上的变量进行操作。
不同的处理器架构和型号有不同的SIMD指令可用。
主要的收获应该是。SIMD指令可以使你的DSP代码大大加快,但代价是