内联汇编与 intrinsic:从底层到向量化的进阶之路

说实话,很多 C++ 开发者一听到「内联汇编」四个字就头大。我当年刚接触时也一样,觉得这东西又难写又容易出 bug。但后来在做一个视频编解码项目时,发现纯 C++ 代码怎么优化都差一口气,最后靠内联汇编和 intrinsic 把性能提了 3 倍多。嗯,从那以后我就再也不敢小看这些底层技术了。

今天咱们聊聊内联汇编、SSE/AVX intrinsic,以及 SIMD 编程。这些东西说白了,就是让你直接跟 CPU 的寄存器打交道,绕过编译器自己做优化。

内联汇编基础

内联汇编,就是在 C++ 代码里直接嵌入汇编指令。GCC 和 MSVC 的语法不太一样,我主要用 GCC 风格,因为 Linux 下开发多。

先看个最简单的例子:

// GCC 风格内联汇编
int add(int a, int b) {
    int result;
    __asm__ volatile (
        "addl %1, %0"
        : "=r"(result)   // 输出操作数
        : "r"(a), "0"(b) // 输入操作数
        : "cc"           // 破坏列表
    );
    return result;
}

这里有几个关键点:

  • __asm__ 是 GCC 的关键字,volatile 告诉编译器不要优化这段汇编
  • 冒号分隔的三部分:输出、输入、破坏列表
  • "=r" 表示输出到寄存器,"r" 表示从寄存器读
  • "0" 表示复用第 0 个操作数(即 result 的寄存器)
注意:内联汇编的约束条件写错了,轻则编译报错,重则运行时崩溃。我曾经在一个多线程程序里写错了破坏列表,导致寄存器被意外修改,查了三天才找到 bug。

为什么要有破坏列表?因为编译器不知道你的汇编指令会修改哪些寄存器。你告诉它 "cc" 表示修改了标志寄存器,它就会在汇编前后做保存恢复。

SSE/AVX intrinsic:更安全的底层操作

说实话,纯内联汇编写起来太痛苦了。每个指令都要手动分配寄存器,还要操心破坏列表。后来 Intel 推出了 intrinsic,就是 C++ 函数形式的 SIMD 指令封装。

SSE(Streaming SIMD Extensions)和 AVX(Advanced Vector Extensions)是 x86 平台上的 SIMD 指令集。SSE 用 128 位寄存器,AVX 用 256 位,AVX-512 用 512 位。

看个 SSE 的例子:

#include <xmmintrin.h>  // SSE intrinsic

void add_arrays_sse(const float* a, const float* b, float* c, int n) {
    for (int i = 0; i < n; i += 4) {
        __m128 va = _mm_loadu_ps(a + i);   // 加载 4 个 float
        __m128 vb = _mm_loadu_ps(b + i);
        __m128 vc = _mm_add_ps(va, vb);    // 向量加法
        _mm_storeu_ps(c + i, vc);          // 存储结果
    }
}

这里 __m128 是 128 位向量类型,可以装 4 个 float。一次 _mm_add_ps 相当于 4 次普通加法。你想想看,循环次数直接减到四分之一。

小技巧:我习惯用 _mm_loadu_ps 而不是 _mm_load_ps,因为前者不要求内存对齐。虽然性能差一点点,但省去了对齐的麻烦。在大多数场景下,这点性能损失可以忽略。

AVX 的用法类似,只是类型变成了 __m256,一次处理 8 个 float:

#include <immintrin.h>  // AVX intrinsic

void add_arrays_avx(const float* a, const float* b, float* c, int n) {
    for (int i = 0; i < n; i += 8) {
        __m256 va = _mm256_loadu_ps(a + i);
        __m256 vb = _mm256_loadu_ps(b + i);
        __m256 vc = _mm256_add_ps(va, vb);
        _mm256_storeu_ps(c + i, vc);
    }
}

SIMD 编程入门

SIMD(Single Instruction Multiple Data)的核心思想,就是一条指令处理多个数据。你想想看,CPU 里有那么多寄存器,一次只算一个数太浪费了。

SIMD 编程有几个基本操作:

  • 加载/存储:从内存到寄存器,或者反过来
  • 算术运算:加、减、乘、除、开方等
  • 混洗/排列:重新排列向量里的元素
  • 比较/选择:向量比较,生成掩码

我刚开始学 SIMD 时,最头疼的就是数据对齐问题。SSE 要求 16 字节对齐,AVX 要求 32 字节对齐。不对齐的话,某些指令会崩溃。

核心原则:SIMD 编程的难点不在于指令本身,而在于数据布局。如果你的数据是 AoS(Array of Structures)格式,转成 SoA(Structure of Arrays)格式后,向量化效率会高很多。

举个例子,处理 RGB 像素:

// AoS 格式:RGBRGBRGB...
struct Pixel { float r, g, b; };
Pixel pixels[N];

// SoA 格式:RRR...GGG...BBB...
float r_channel[N], g_channel[N], b_channel[N];

SoA 格式下,你可以一次加载 8 个红色分量,做向量化运算。AoS 格式的话,你得先解包再打包,效率低很多。

向量化实战:矩阵乘法

咱们来点实战的。矩阵乘法是 SIMD 的经典应用场景。先看普通版本:

void matmul_naive(const float* A, const float* B, float* C, int N) {
    for (int i = 0; i < N; ++i) {
        for (int j = 0; j < N; ++j) {
            float sum = 0.0f;
            for (int k = 0; k < N; ++k) {
                sum += A[i * N + k] * B[k * N + j];
            }
            C[i * N + j] = sum;
        }
    }
}

这个版本的问题在于内存访问不连续。B 是按列访问的,缓存命中率很低。咱们改成按行访问,再用 SIMD:

void matmul_sse(const float* A, const float* B, float* C, int N) {
    for (int i = 0; i < N; ++i) {
        for (int j = 0; j < N; j += 4) {
            __m128 c = _mm_setzero_ps();
            for (int k = 0; k < N; ++k) {
                __m128 a = _mm_set1_ps(A[i * N + k]);  // 广播
                __m128 b = _mm_loadu_ps(&B[k * N + j]); // 连续加载
                c = _mm_add_ps(c, _mm_mul_ps(a, b));
            }
            _mm_storeu_ps(&C[i * N + j], c);
        }
    }
}

这里的关键是 _mm_set1_ps 把 A 的一个元素复制到向量的四个位置,然后跟 B 的连续四个元素做乘加。这样 B 的访问是连续的,缓存友好。

性能对比:在我的测试中(N=1024,i7-10750H),朴素版本跑了 120ms,SSE 版本跑了 35ms,加速比约 3.4 倍。如果换成 AVX,还能再快一倍左右。

为什么会这样?因为 SIMD 不仅减少了指令数,还提高了内存带宽利用率。你想想看,一次加载 4 个 float,比一次加载 1 个 float 的带宽利用率高多了。

SVG 知识体系图

内联汇编与 SIMD 知识体系 内联汇编 GCC 语法 MSVC 语法 约束条件 破坏列表 SSE/AVX Intrinsic SSE (128位) AVX (256位) 加载/存储 算术运算 SIMD 编程 数据布局 内存对齐 AoS vs SoA 向量化实战 核心目标:极致性能 + 可维护性 内联汇编适合精细控制,intrinsic 更安全易用 SIMD 编程的关键:数据布局 > 指令选择

避坑指南与经验总结

最后分享几个我踩过的坑:

  • 不要过度使用内联汇编:编译器优化越来越强,很多时候你手写的汇编还不如编译器生成的。我建议只在关键热点函数里用,而且一定要做性能对比。
  • 注意 CPU 兼容性:SSE2 是 x86-64 的基础指令集,但 AVX2、AVX-512 不是所有 CPU 都支持。发布前要检查 __builtin_cpu_supportscpuid
  • 小心数据依赖:SIMD 指令有延迟,如果下一条指令依赖上一条的结果,流水线会 stall。可以尝试展开循环,让多条指令并行执行。
  • 内存对齐不是玄学:不对齐的加载在某些 CPU 上会慢 2-3 倍。用 alignas(32)_mm_malloc 来保证对齐。

我的建议:先从 intrinsic 入手,别一上来就写内联汇编。等你对 SIMD 指令熟悉了,再考虑用内联汇编做更精细的控制。而且,现在很多编译器(比如 Clang)的自动向量化能力很强,有时候你什么都不用做,编译器就帮你向量化了。

嗯,说到底,性能优化是个权衡的过程。代码可读性、可维护性、移植性,跟性能之间要找到平衡点。别为了 5% 的性能提升,写出只有你能看懂的代码。

公众号:蓝海资料掘金营,微信 deep3321