C/C++ 现代语言与底层 · 第 十二 篇

SIMD:自动向量化与手写 intrinsic 的分界

2026-10-1426,708 字54 张配图

本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations 题图 同一个加法循环的两条数据通路 题图 同一个加法循环的两条数据通路:标量通路一次一个元素、打包通路一条指令八个元素,走不走第二条路由语义与数据决定 @ 本系列绘制


本系列已发表篇目(全系列共 12 篇,分四波连载,本篇为收官):

背景与边界:一句「加 -O3 就自动向量化」的账

全系列十二篇的最后一篇,主题是并行。

好了,前面十一篇算的分别是「字节怎么摆」「编译器怎么翻」「代价从哪来」「跨核看见什么」。这一篇换一个轴:一条指令怎么同时处理八个数据。它不是语言特性,也不是库——是硬件给了 256 位的寄存器和一批打包指令,然后由编译器决定要不要用、由你决定要不要自己动手。

而这一篇要处理的第一件事,是一句在中文技术圈流传极广、也极容易被当成结论的话:「开 -O3 就会自动向量化,所以不用手写 SIMD。」

0.1 一句话结论先放这里

先把五条结论放前面,逐条都在本篇给出了本机读数:

  • 「报告说向量化了」和「真的变快了」是两件事。 GCC 在 -O3 -march=native 下对那个浮点归约循环的报告原文是 optimized: loop vectorized using 32 byte vectors and unroll factor 8;把同一个循环用 no-tree-vectorize 关掉之后,读数是 3.296 ms 对 3.274 ms——收益是 0。四个构建全部如此。
  • 真正解锁浮点归约的不是 -O3,是放弃严格语义。 同一份源码,加上 -ffast-math / /fp:fast 之后:GCC 3.274 → 0.861 ms(3.83 倍),MSVC 3.263 → 0.903 ms(3.60 倍)。因为浮点加法不满足结合律,编译器必须保序,除非你允许它不保序。
  • 分支可预测性值 7.5 倍,而无分支的 SIMD 只值 1.6 倍。 同一份 MSVC 二进制、同一个分支循环:随机符号 17.256 ms、块状符号 2.292 ms;手写无分支版分别是 1.823 与 1.421 ms。手写的优势从 9.47 倍掉到 1.61 倍,全程只换了数据。
  • 开了 fast-math 之后,编译器赢了手写 intrinsic。 GCC:自动 0.861 ms vs 手写 FMA 0.928 ms(自动快 7.2%);MSVC:0.903 vs 0.972(自动快 7.1%)。saxpy 更悬殊——MSVC 自动 1.332 ms vs 手写 1.929 ms,自动快 31%。
  • 对齐在一笔越来越小的账上。 把数组基址故意错开 1 个 float,与 32 字节对齐的读数差落在噪声内(最好与最差相差不到 8%,方向还不一致)。_mm256_load_ps 的真实风险不是慢,是一旦对齐不成立就段错误。

0.2 实测环境

  • CPU:AMD Ryzen 7 5800H(Zen 3),8 核 16 线程。CPUID.7.0.EBX = 219c97a9 ⇒ AVX2 有(bit5)、AVX-512 没有(bit16 = 0);CPUID.1.ECX = 7ed8320b ⇒ AVX(bit28)与 FMA(bit12)都有。
  • ABI:alignof(__m256) = 32、sizeof(__m256) = 32;但 alignof(max_align_t) 两家不同——GCC 16、MSVC 8(与 ep01 同一处差异)。
  • 工具链:MSVC 19.44(VS 2022,/std:c++17 /O2)与 GCC 16.2.0(MSYS2 ucrt64,-std=c++17)。
  • 构建矩阵:MSVC 三个(默认 SSE2 / /arch:AVX2 / /arch:AVX2 /fp:fast),GCC 四个(-O2 / -O3 / -O3 -march=native / -O3 -march=native -ffast-math),另加一个 -msse2 -mno-avx 的窄宽度对照。一共 8 个构建,每个都把全部子命令跑一遍。
  • 用例:evidence/src/simd_lab.cpp,7 个内核(逐元素加、saxpy、点积归约、跨步访存、分支取值、前缀和、对齐对照),4 万余行读数来自同一批日志。
本机硬件与工具链实勘面板:CPUID 两行、八个构建的宽度宏与 ABI 尺寸、alignof max_align_t 两家之差、HAVE_STD_SIMD 两家不同
题图1 本机硬件与工具链实勘面板:CPUID 两行、八个构建的宽度宏与 ABI 尺寸、alignof max_align_t 两家之差、HAVE_STD_SIMD 两家不同@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

0.3 本篇不涉及什么(边界声明)

说白了,四条边界,逐条都是本机拿不到或不该做,而不是「不重要」:

  • 不做 AVX-512 实测。 本机 Zen 3 没有 AVX-512(bit16 = 0),而且消费级平台上 512 位指令还有降频的争议。这一章只引官方规格与第三方实测口径,不给本机读数。
  • 不做 Clang 实测。 本机不存在 clang / clang-cl(VS 自带的 LLVM 只有 clang-format 与 clang-tidy,msys64 只有 gcc)。Clang 的 -Rpass=loop-vectorize 与 -fsave-optimization-record 只作引用;本篇「报告 vs 读数」的对照全部落在 MSVC 与 GCC 两家。
  • 不给微架构事件。 本机 HAL 暴露了 17 个 PMC 源(xperf -pmcsources 实跑可列),但采集需要管理员权限的内核日志器,本轮拿不到。所以没有 IPC、没有端口压力、没有 cache-miss 计数——「为什么 fast-math 之后自动版反超手写」只能给结构与读数解释,给不出事件级证据。(这条边界与 ep11 同源。)
  • std::experimental::simd 只有单侧实测。 GCC 16.2 的 libstdc++ 有(HAVE_STD_SIMD = 1),MSVC 19.44 的 STL 没有(HAVE_STD_SIMD = 0)。所以「标准 SIMD 抽象能不能用」这一章是单侧实测 + 官方资料,不是双工具链结论。
本篇四条证据边界并置:不做 AVX-512 实测(Zen 3 无此指令集)、不做 Clang 实测(本机无 Clang)、不给微架构事件(采集需管理员权限)、标准 SIMD 只有单侧实测(MSVC STL 无此头文件)
图 12-12 本篇四条证据边界并置:不做 AVX-512 实测(Zen 3 无此指令集)、不做 Clang 实测(本机无 Clang)、不给微架构事件(采集需管理员权限)、标准 SIMD 只有单侧实测(MSVC STL 无此头文件)@ 本系列绘制

0.4 按需跳读

  • 只想知道「我该不该手写 SIMD」:读 §0.1、§七(规模与算术强度)、§九(判据六条)——这三节就能回答,其余是证据。
  • 想搞清「为什么我的循环明明向量化了却没变快」:读 §二(四种卡点与报告怎么读)、§三(浮点归约与 fast-math)、§四(分支与数据)。
  • 想学 AVX2 intrinsic 的写法:读 §五,那里有六个典型指令的用法与逐位正确性核对。
  • 想知道「编译器和手写谁更强」:读 §五与 §八——本机读数给了一个和流行说法相反的答案。
  • 只关心可移植性:读 §八(标准 SIMD 的三家现状)与 §六(对齐与 loadu 的风险)。

0.5 这一篇与系列其他篇的接续

  • 接 ep11《缓存与布局》:ep11 给的是 AoS/SoA 的 k-扫描结论——只读 1 个字段时 SoA 快 2.35 倍、读满 8 个字段两家打平。本篇的 SoA 是它的前置条件:访存不连续,向量化第一道门就过不去(§二会量这件事)。
  • 接 ep05《优化器的边界》:ep05 量的是 UB、严格别名与向量化报告在两家编译器上的可见性;本篇把它推进到浮点结合律与分支这两处更硬的语义卡点——ep05 说「编译器看见什么才敢动」,本篇说「编译器看见但不允许动时怎么办」。
  • 接 ep07《LTO 与 PGO》与 ep10《内存序》:三篇是同一条方法论——同一份源码、只换开关、量差异。ep07 换的是链接期视野,ep10 换的是内存序,本篇换的是 ISA 宽度与浮点语义。
  • 全系列收官:ep01 到 ep12 十二篇的账在 §十一次列全,含篇幅、图量、一句结论各一列。

0.6 为什么这一篇要按「问题类型」拆,而不是按指令族拆

市面上多数 SIMD 材料是按指令族组织的:MMX 一节、SSE 一节、AVX 一节、AVX-512 一节。这个结构适合查手册,不适合回答工程问题——因为「用哪一族指令」是结论,不是起点。

本篇按问题类型拆,一次问一件事:编译器为什么不向量化(§二)、为什么报告说向量化了却没变快(§三)、数据为什么能决定快慢(§四)、手写什么时候赢(§五)、哪一笔账被夸大了(§六)、收益为什么随规模衰减(§七)。

OK,这样拆的好处是每一节都能独立回答一个问题,代价是同一批读数会在不同章节里以不同角度出现两次——所以 §十 的汇总表会把全部读数集中一遍,便于对照与复核。

本篇三类证据各自能证什么:本机读数证代价区间与方向、反汇编证报告与读数之间那一步是否真实、外部资料只证本机拿不到的那部分,并标出各自不能证的三件事
图 12-23 本篇三类证据各自能证什么:本机读数证代价区间与方向、反汇编证报告与读数之间那一步是否真实、外部资料只证本机拿不到的那部分,并标出各自不能证的三件事@ 本系列绘制

0.7 证据形态与读法

本篇的证据有三类,读的时候要分清它们各自能证什么。

第一类是本机实测:向量化报告原文、端到端读数、逐位正确性核对。它能证代价区间与变化方向,不能证「为什么」——因为拿不到事件计数,机理解释只能落在结构与读数的一致性上。

第二类是本机反汇编:报告说向量化了,机器码里到底有没有 vaddps / vfmadd。它是「报告」与「读数」之间的中间凭证——报告可信不可信,靠它核。

第三类最特殊:本机拿不到的那部分。 AVX-512、Clang 的优化报告、微架构事件——这三样都只能引外部资料。所以本篇凡涉及它们的地方都标了出处,且不写成实测结论。

读法上有一个反复出现的坑要提前说:本机读数全部是「同一台机器、同一份源码、只换一个开关」。这种对照能排除机器差异,但也会把「开关之间互相作用」藏起来——比如 /arch:AVX2 对逐元素加法几乎无感(+1.7%)、对 dot 恰好 0%、对 saxpy 却是 +23%。同一个开关,三种结果,差别全在「这个循环到底向不向量化」。

一、向量化到底发生了什么:从标量循环到 256 位寄存器

言归正传,机制部分从最小的一层说起:一条指令一次能干几件事。

1.1 标量通路与打包通路

先看两条数据通路。标量通路里,一条加法指令从两个寄存器各取一个 32 位浮点、相加、写回一个——一次一个元素。打包通路里,同一个加法指令作用在两个 256 位寄存器上,每个寄存器装着 8 个浮点,一条指令出 8 个结果。

这就是 SIMD(Single Instruction, Multiple Data,单指令多数据)的全部含义。它不改变计算的总量,只改变每次搬运与每次运算打包多少个元素。

好了,但真正决定这一篇所有实验的,是三个容易被忽略的前提:

  1. 数据必须已经在寄存器里排队。 要把内存里的 8 个连续浮点装进一个 256 位寄存器,它们得连续——这就是 ep11 的 SoA 结论为什么会在这里出现(跨步访存装不满一个寄存器,得靠 gather,代价立刻上去)。
  2. 指令是「宽度固定」的。 256 位寄存器里放 float 是 8 个、放 double 是 4 个、放 int16 是 16 个——同一个指令,元素数随类型变。所以「向量化能快几倍」这个问题本身没有统一答案。
  3. 一次指令的延迟没有变短。 vaddps 与 addss 的延迟几乎一样(都是几个周期);快的是吞吐——每周期能出 8 个结果而不是 1 个。这一点决定了后面「多累加器为什么赢」。
标量通路与 SIMD 打包通路对照:标量一条加法指令处理一个 32 位元素,256 位打包通路一条指令处理八个元素,并标注延迟相同、吞吐差八倍
图 12-34 标量通路与 SIMD 打包通路对照:标量一条加法指令处理一个 32 位元素,256 位打包通路一条指令处理八个元素,并标注延迟相同、吞吐差八倍@ Wikimedia Commons · Duzabf(CC BY-SA 4.0)

1.2 寄存器族的宽度演进(以及本篇为什么不测 AVX-512)

x86 上的向量寄存器宽度换过四代,每一代都把「一条指令能装多少」翻一倍:

代号 年份 寄存器 宽度 一条 float 加法出几个结果
MMX 1996 MM0–MM7 64 位 2
SSE / SSE2 1999 / 2000 XMM0–XMM15 128 位 4
AVX / AVX2 2011 / 2013 YMM0–YMM15 256 位 8
AVX-512 2015+ ZMM0–ZMM31 512 位 16

说白了,本机落在第三代:__AVX2__ = 1、__AVX512F__ = 0。所以本篇的所有实测读数都止步于 256 位,AVX-512 那 16 个结果只作为规格对照出现在这张表里,不给本机数字——这是 §0.3 的第一条边界。

一个容易踩的细节:新寄存器的低半部分就是旧寄存器。YMM0 的低 128 位就是 XMM0,往 XMM0 写会把 YMM0 的高 128 位清零(AVX 的 VEX 编码语义)。这条别名关系既是兼容性的来源,也是「混用旧指令导致性能意外」的常见原因。

向量寄存器宽度四代演进:MMX 64 位、SSE 128 位、AVX 256 位、AVX-512 512 位,并标出本机落在第三代(AVX2 可用、AVX-512 不可用),以及 YMM 低半部即 XMM 的别名关系
图 12-45 向量寄存器宽度四代演进:MMX 64 位、SSE 128 位、AVX 256 位、AVX-512 512 位,并标出本机落在第三代(AVX2 可用、AVX-512 不可用),以及 YMM 低半部即 XMM 的别名关系@ 本系列绘制

1.3 换一代寄存器,不是换一条指令那么简单

既然宽度翻倍,为什么不能「编译一下就好了」?因为每一代都伴随新的编码格式与语义约束:

  • AVX 引入了 VEX 编码(三操作数形式,vaddps ymm0, ymm1, ymm2 不再毁掉源操作数);
  • AVX2 把整数运算补齐到 256 位(AVX 的 256 位只覆盖浮点,整数还是 128 位)——这是「AVX 与 AVX2 差在哪」的标准答案;
  • FMA 引入了融合乘加:a*b + c 一次算出、只舍入一次,精度与吞吐都更好;
  • AVX-512 又引入掩码寄存器与新的编码形式。

OK,这些约束直接落到编译开关上。MSVC 侧 /arch 的值域是 SSE2 | SSE4.2 | AVX | AVX2 | AVX512,不指定时默认 SSE2;GCC 侧对应的是 -mavx2 / -mfma / -march=native。而这不等于「用哪一代由开关决定」——下一节就量这件事。

四代 SIMD 编码与语义的演进:MMX 与 SSE 的旧编码、AVX 的三操作数 VEX 编码、AVX2 把整数补齐到 256 位、FMA 的融合乘加、AVX-512 的掩码寄存器
图 12-56 四代 SIMD 编码与语义的演进:MMX 与 SSE 的旧编码、AVX 的三操作数 VEX 编码、AVX2 把整数补齐到 256 位、FMA 的融合乘加、AVX-512 的掩码寄存器@ 本系列绘制

1.4 开关的语义:是「最低要求」,不是「上限」

有一条官方口径必须先说清,否则后面的读数会被误读。MSVC 文档关于 /arch 的原话是:自动向量化可能生成与 /arch 指定不同的指令,这些指令由运行期检查保护——例如按 /arch:SSE2 编译时,可能生成 SSE4.2 指令。

/arch 是「保证能跑的最低要求」,不是「能用的最宽宽度」。这个语义与 GCC 的 -march 一致(-march 设定目标 CPU,编译器可以生成该 CPU 支持的一切)。

本机读数里能直接看到这条语义的后果:MSVC 默认构建(SSE2 基线)与 /arch:AVX2 构建,在逐元素加法上几乎一样快(23.4 vs 23.8 GB/s,+1.7%)——因为加法那个循环在两种构建里都已经向量化,差异只在宽度,而它有更硬的瓶颈(§七会量)。

编译开关的语义:arch 或 march 给的是最低要求而非宽度上限,自动向量化可生成更高指令并由运行期检查保护,并标出该语义在本机读数上的表现
图 12-67 编译开关的语义:arch 或 march 给的是最低要求而非宽度上限,自动向量化可生成更高指令并由运行期检查保护,并标出该语义在本机读数上的表现@ 本系列绘制

1.5 官方口径对得上

言归正传,把三处官方表述放在一起,本节的结构结论就有据可依:

  • MSVC /arch (x64) 文档:不指定时默认 SSE2;/arch:AVX2 把多数整数运算扩展到 256 位并启用 FMA;/arch:AVX512 引入 512 位向量、掩码与嵌入舍入。
  • GCC 的 -ftree-vectorize 与 -fvect-cost-model:-ftree-loop-vectorize 在 -O3 下默认启用;代价模型取值为 unlimited | dynamic | cheap | very-cheap,新一代 GCC 在 -O2 下默认走 very-cheap——这就是为什么 -O2 也可能向量化,只是更保守。
  • LLVM 的 Auto-Vectorization 文档:Loop 与 SLP 两个向量化器默认开启;复杂控制流、不可向量化类型与函数调用会导致失败,而浮点归约只有在 -fassociative-math 那一族选项下才会向量化。

最后那条是下面两章的主线:浮点归约的开关不在「优化级别」,在「浮点语义」。

1.6 本章判据(第一条)

  • 判据:动手之前先问「这条循环的瓶颈是宽度还是别的东西」。宽度问题(算力受限、数据在缓存里)值得换更宽的 ISA;不是宽度问题(带宽受限、依赖链受限、压根没向量化)换 ISA 没有用。
  • 反判据:如果一段代码的算术强度很低(每搬一个字节只算一两次),任何向量化都不会有收益——本机读数:add 在 64 MB 规模上只有 22.7 GB/s,而 16 KB 时是 122.9 GB/s(§七)。
  • 本机依据:同一个 /arch:AVX2 开关,对 saxpy 是 +23%、对 add 是 +1.7%、对未向量化的 dot 是 0%(§八)。

下一章先把「编译器到底怎么看一个循环」拆开——因为它决定后面所有的读数高低。

二、编译器看不看得见:四种卡点与它们的报告

好了,上一章讲的是硬件给了什么。这一章讲编译器敢不敢用——以及它愿不愿意告诉你。

好在两家编译器都愿意说。这是本篇的第一件工具。

2.1 两家的向量化报告长什么样

MSVC 与 GCC 各有一个开关,把「这个循环向量化了没有、没向量化是因为什么」直接打印出来:

  • MSVC /Qvec-report:2:向量化成功打 info C5001: 循环已向量化;失败打 info C5002: 由于"NNNN",循环未向量化,NNNN 是原因码。
  • GCC -fopt-info-vec-all:成功打 optimized: loop vectorized using 32 byte vectors and unroll factor 8;失败打 missed: not vectorized: <原因>。-all 会同时输出 optimized、missed 与 note 三类消息。

说白了,本机的报告原文可以直接读。MSVC 侧(默认 SSE2 构建):

--- 正在分析函数: void __cdecl saxpy_r(...)
simd_probe.cpp(35) : info C5001: 循环已向量化
--- 正在分析函数: float __cdecl dot_r(...)
simd_probe.cpp(40) : info C5002: 由于"1105",循环未向量化
--- 正在分析函数: void __cdecl strided_r(...)
simd_probe.cpp(45) : info C5002: 由于"1301",循环未向量化
--- 正在分析函数: void __cdecl prefix_r(...)
simd_probe.cpp(53) : info C5002: 由于"1200",循环未向量化
--- 正在分析函数: void __cdecl clamp_r(...)
simd_probe.cpp(49) : info C5002: 由于"1100",循环未向量化

GCC 侧(-O3 -march=native):

simd_lab.cpp:76:23: optimized: loop vectorized using 32 byte vectors and unroll factor 8
simd_lab.cpp:83:23: optimized: loop vectorized using 32 byte vectors and unroll factor 8
simd_lab.cpp:101:14: optimized: loop vectorized using 16 byte vectors and unroll factor 4
simd_lab.cpp:207:23: missed: could not vectorize loop
simd_lab.cpp:207:23: missed: not vectorized: unsupported control flow in loop.

注意最后两行的落差:GCC 报告的宽度是 32 字节(-march=native 给了 AVX2),但在另一个循环上报的是 16 字节向量——同一个构建里,不同的循环拿到了不同的宽度。所以「我开了 AVX2」这句话,只能说明上限,不能说明每个循环都用上了。

两家向量化报告原文面板:MSVC 的 C5001 与 C5002 原因码逐行、GCC 的 optimized 与 missed 原文逐行,并标出同一构建里宽度从 32 字节到 16 字节不等的落差
图 12-78 两家向量化报告原文面板:MSVC 的 C5001 与 C5002 原因码逐行、GCC 的 optimized 与 missed 原文逐行,并标出同一构建里宽度从 32 字节到 16 字节不等的落差@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

2.2 四种卡点,以及它们怎么落到原因码上

把本机八份报告里的失败条目归一下类,只有四种卡点:

卡点 本质 本机报告原文 MSVC 原因码
别名 编译器无法证明两个指针不重叠 (带 restrict 时通过;不带时退回保守分析) —
不可数 循环次数在进入时未知,或退出依赖数据 not vectorized: ... 1200 系列
控制流 循环体里有分支/条件,无法 if 转换 not vectorized: unsupported control flow in loop. 1100
归约 浮点归约改变结合序,语义不允许 missed: ...(保序版本) 1105

另外两个本机实录的原因码值得单独记:1301(本机出现在跨步访存 strided_x2,即访存不连续)与 503(出现在非循环的代码块,不属向量化范畴)。

OK,这张表本身就是一篇材料的目录。下面三节各处理一种。

这四种卡点的分工值得说清,因为它决定后面三章的处置顺序。前三种都属于「编译器缺一条信息」:别名缺的是「这两个指针不重叠」的承诺,可以用 restrict 或者让它插入运行期检查来补;不可数缺的是「循环次数确定」的结构,只能改代码;控制流缺的是「两路都算也无妨」的许可,取决于浮点模型。只有浮点归约是「语义不许动」——即使编译器把整个循环看得一清二楚,语言规则也不允许它改变加法顺序。所以处置顺序是:能给承诺的先给承诺,给不了再动手写。

四种向量化卡点地图:别名、不可数、控制流与浮点归约,各对应 MSVC 的 1100 与 1105 原因码以及 GCC 的报告原文,并标出前三种靠给编译器承诺可解、第四种必须放开浮点语义
图 12-89 四种向量化卡点地图:别名、不可数、控制流与浮点归约,各对应 MSVC 的 1100 与 1105 原因码以及 GCC 的报告原文,并标出前三种靠给编译器承诺可解、第四种必须放开浮点语义@ 本系列绘制

2.3 别名与 restrict:一次「承诺」而不是一次「开关」

别名是四种卡点里最容易被当成开关的一种。restrict(C99)/ __restrict(各家扩展)的语义是承诺:通过这个指针访问的对象,在当前作用域内不会被其他指针访问。编译器拿到这个承诺,就敢把「先读后写」排成向量化的顺序。

言归正传,但它只是承诺,不是开关。本机读数里能直接看到两件事:带 restrict 的逐元素加法与把它去掉之后,本机都向量化了(MSVC 在两种情况下都报 C5001)——因为编译器在这个规模上做了运行期重叠测试,或者干脆把两个分支都生成了。所以「加 restrict 就能向量化」这句话在本机不成立,它只是让编译器少一层顾虑。

真正需要 restrict 的场景是编译器无法生成运行期检查的时候(比如指针来自不同模块、或者循环体太复杂)。这类情况下报告会告诉你「可能重叠所以不向量化」,而 restrict 是你能给的最有力的信息。

带 restrict 与不带 restrict 的同一内核对照:两家的向量化报告条目与端到端读数,说明 restrict 是「承诺」而非「开关」,本机在同一规模上两种情况都已向量化
图 12-910 带 restrict 与不带 restrict 的同一内核对照:两家的向量化报告条目与端到端读数,说明 restrict 是「承诺」而非「开关」,本机在同一规模上两种情况都已向量化@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

2.4 不可数与依赖:两种「编译器不敢动」

不可数指循环次数在进入时算不出来。官方口径很直白(Intel 编译器文档):可向量化的循环必须是 countable(trip count 进入循环时已知,且退出不依赖数据)、最内层、无函数调用。while (*p != NULL) { *q++ = *p++; } 就是官方列的不可数反例——它每轮的退出条件依赖数据。

依赖指循环的某一轮依赖前一轮的结果。本机最干净的一个例子是前缀和:

for (int i = 1; i < n; ++i) a[i] = a[i] + a[i - 1];

MSVC 报 C5002 由于"1200",GCC 报 missed: not vectorized。这条循环在本机八份报告里一次都没有被向量化,读数也印证了这一点:GCC 3.279 ms、MSVC 5.682 ms,全程 10 GB/s 上下——而逐元素加法是 22–24 GB/s。同样的访存字节数,差在依赖链上。

好了,值得记住的是:前缀和这类「看起来只能串行」的循环,确实有并行算法(成对求和、对数步移位),但那不是编译器的职责——编译器只能拒绝向量化,不能替你换算法。这是 §九 判据里的一条。

不可数与依赖两种卡点:循环次数不可知的 while 循环与带有跨轮依赖的前缀和,并标注官方对可数循环的要求以及前缀和的读数与逐元素加法的对比
图 12-1011 不可数与依赖两种卡点:循环次数不可知的 while 循环与带有跨轮依赖的前缀和,并标注官方对可数循环的要求以及前缀和的读数与逐元素加法的对比@ 本系列绘制

2.5 报告 → 机器码 → 读数:三段核对链

有了报告还不够,本篇所有关键结论都过一条三段核对链:

  1. 报告:编译器说这个循环向量化了没有(§2.1)。
  2. 机器码:反汇编里到底有没有 256 位指令与打包运算(vaddps / vfmadd / ymm)。
  3. 读数:端到端时间有没有真的变化。

三段全绿才算数。 本篇最有价值的一条结论恰恰来自「第一段绿、第三段没动」——那个浮点归约循环的报告写着 loop vectorized using 32 byte vectors and unroll factor 8,而读数与「明确关掉向量化」的版本一模一样(3.296 对 3.274 ms)。只读报告会得出错误结论,这就是下一章。

三段核对链:向量化报告、反汇编机器码、端到端读数三者逐段核对,只有三段一致才能下结论,并标出「报告绿而读数不动」这一类必须靠第三段才发现的情形
图 12-1112 三段核对链:向量化报告、反汇编机器码、端到端读数三者逐段核对,只有三段一致才能下结论,并标出「报告绿而读数不动」这一类必须靠第三段才发现的情形@ 本系列绘制
反汇编摘要:同一份源码五个构建下归约与分支两个内核的关键助记符计数,标出报告绿时乘法已向量化而累加仍是串行 addss,语义开关打开后才出现成组的 vaddps 与 vfmadd
图 12-1213 反汇编摘要:同一份源码五个构建下归约与分支两个内核的关键助记符计数,标出报告绿时乘法已向量化而累加仍是串行 addss,语义开关打开后才出现成组的 vaddps 与 vfmadd@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

2.6 官方口径对得上

  • Intel 编译器文档:可向量化的循环必须 countable、最内层、无函数调用(例外是数学库 intrinsic 与可内联函数);并列出数据依赖的 RAW / WAR / WAW 三类。
  • LLVM Vectorizers:-Rpass=loop-vectorize 报成功、-Rpass-missed 报失败、-Rpass-analysis 指到导致失败的语句。
  • MSVC 文档:/Qvec-report:1 只报已向量化的循环,/Qvec-report:2 连未向量化的原因码一起报。
  • GCC 文档:-fopt-info-vec-missed 与 -fopt-info-missed-vec 等价,-fsave-optimization-record 写一份机器可读的 .opt-record.json.gz(官方标注该格式为实验性)。

说白了,最后一条对写工具的人有意义:GCC 把报告也做成了机器可读格式,意味着向量化报告可以被 CI 当成断言来用——这一条在 §9.5 的判据里会用到。

向量化报告状态矩阵:七个内核在 GCC 与 MSVC 三个构建下的报告状态逐行对照,标出浮点归约那一行只有加上浮点语义开关才由红转绿,其余内核的状态两个开关都改变不了
图 12-1314 向量化报告状态矩阵:七个内核在 GCC 与 MSVC 三个构建下的报告状态逐行对照,标出浮点归约那一行只有加上浮点语义开关才由红转绿,其余内核的状态两个开关都改变不了@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

2.7 本章判据(第二条)

  • 判据:改性能之前,先开报告跑一遍。两家各一条命令:MSVC /Qvec-report:2、GCC -fopt-info-vec-all。报告的条目数就是「有多少个循环没进去」的清单。
  • 反判据:报告全绿也不等于快了——§三点名的那条归约就是反例。报告的绿只说明「编译器的向量化器接受了这个循环」,不说明「收益为正」。
  • 本机依据:本机 8 份报告里,dot 在 GCC -O3 -march=native 下报绿而收益 0;prefix_sum 在全部 8 份里报红且确实没有收益。

下一章开始处理第一种「报告绿但收益 0」的卡点——它也是最常见的一种。

三、浮点归约:报告说向量化,收益是 0

上一章留下了一个悬案:那个浮点归约循环的报告写着 loop vectorized using 32 byte vectors and unroll factor 8,读数却没动。这一章把它拆开。

3.1 先把悬案坐实:关掉向量化,读数一样

OK,要证明「收益是 0」,最干净的做法是同一个循环、同一份源码、只差一个「不许向量化」的属性。本机用例里两份都在:一份是普通的 float 累加点积,一份加了「不要向量化」的函数属性(GCC 用 optimize("no-tree-vectorize"),MSVC 用 #pragma loop(no_vector))。

N = 4,194,304(16 MB 数组 ×3),单位毫秒:

构建 dot(报告:已向量化) dot_novec(明确关掉) 比值
GCC -O2 3.246 3.236 1.00×
GCC -O3 -march=native 3.274 3.296 0.99×
MSVC /O2(默认 SSE2) 3.266 3.269 1.00×
MSVC /O2 /arch:AVX2 3.263 3.252 1.00×

四个构建、八个数,没有一个出现差异。报告写着向量化了,读数却与「明确关掉向量化」逐毫秒相同。

这就是本篇最该记住的一条:编译器报告的「已向量化」,说的是「向量化器接受了这个循环」,不是「收益为正」。 只读报告就下结论,会得到相反答案。

浮点归约的开关对照读数:四个构建下 dot 与 dot_novec 的毫秒读数并置,四组比值全部落在 0.99 到 1.00 之间,证明报告说向量化而收益为零
图 12-1415 浮点归约的开关对照读数:四个构建下 dot 与 dot_novec 的毫秒读数并置,四组比值全部落在 0.99 到 1.00 之间,证明报告说向量化而收益为零@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

3.2 为什么:浮点加法不满足结合律

言归正传,原因只有一句话:浮点加法不满足结合律((a+b)+c 与 a+(b+c) 在浮点里可能不等)。

向量化的本质是把顺序打散——8 个元素同时算,再合并。对于整数这是合法的(整数加法满足结合律);对于浮点,打散顺序就意味着允许结果与顺序执行不同,而 C/C++ 默认不允许编译器这么干。

所以编译器面对一个浮点归约时只有两条路:要么不向量化,要么保序地部分向量化——把乘法向量化、加法仍按顺序串起来。GCC 选了后者(报告于是打了「已向量化」),MSVC 在默认档位干脆报 C5002 由于"1105"(无法识别的 reduction)直接拒绝。

好了,两条路的读数是一样的:瓶颈在加法链上,不在乘法上。乘法本来就不慢。

浮点加法不满足结合律导致的两条路:保序的部分向量化(乘法打包、加法仍串行)与直接拒绝向量化,并标出两条路的读数相同,因为瓶颈在串行加法链上
图 12-1516 浮点加法不满足结合律导致的两条路:保序的部分向量化(乘法打包、加法仍串行)与直接拒绝向量化,并标出两条路的读数相同,因为瓶颈在串行加法链上@ 本系列绘制

3.3 解锁它的是一个语义开关,不是优化级别

真正有效的开关是放弃严格浮点语义。本机读数:

构建 dot 相对 dot_novec 有效带宽
GCC -O3 -march:native(无 fast-math) 3.274 ms 0.99× 15.4 GB/s
GCC -O3 -march:native -ffast-math 0.861 ms 3.83× 58.5 GB/s
MSVC /O2 /arch:AVX2(无 fast-math) 3.263 ms 1.00× 15.4 GB/s
MSVC /O2 /arch:AVX2 /fp:fast 0.903 ms 3.60× 55.7 GB/s

两家一致:3.6 到 3.8 倍,只差一个语义开关。 注意这不是「调高优化级别」——-O3 早就开了,/arch:AVX2 也早就给了宽度;缺的只有「允许重排」这一句许可。

而 -ffast-math / /fp:fast 在 MSVC 文档与 GCC 文档里的定位都是浮点模型,不是性能开关。它做的三件事在下一节列。

说白了,这里要做一次严格的对照,因为两种开关很容易混为一谈。优化级别是「允不允许做优化」,浮点模型是「允不允许改变结果」——本机这四个构建里,-O3 与 /arch:AVX2 早就开了,缺的只有浮点模型那一句许可。核对方法也很直接:先看同一个循环在两个构建里的向量化报告条目是否相同(都报绿),再看读数是否相同(差 3.6 倍)。报告相同、读数不同的那一格,才是语义开关的净效果;只看其中一边,都会把归因搞错。

浮点归约的 fast-math 阶梯面板:四个构建的毫秒与有效带宽对照,展示开关打开后从十五点四跃升到五十五以上的 GB/s
图 12-1617 浮点归约的 fast-math 阶梯面板:四个构建的毫秒与有效带宽对照,展示开关打开后从十五点四跃升到五十五以上的 GB/s@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

3.4 -ffast-math 到底改了什么,代价是什么

它不是「更快的开关」,是一揽子语义放宽。主要三条:

  1. 允许重排(-fassociative-math):浮点加减乘可以按数学结合律重新结合。这条带来上面那 3.6 倍。
  2. 假设没有 NaN 与 Inf(-ffinite-math-only):于是 x != x 可以优化成常量假、a + b 不需要处理 Inf 传播。
  3. 忽略符号零与 denormal(-fno-signed-zeros、-fno-trapping-math、denormal 置零):极小数不再走慢速路径。

代价是结果可能变。本机给了一条量化证据:手写 AVX2 的 8 路部分和版与「标量顺序版」的结果相对偏差是 1.06e-05(GCC)/ 1.33e-05(MSVC)——量级正好符合「八个数相加顺序变了」的误差预期。对性能计数没有影响,对需要逐位可复现的场合(校验和、确定性物理仿真、回归比对)是硬约束。

OK,所以正确的一句话是:-ffast-math 买的不是速度,是「编译器可以重排」这件事;速度是它的副产品。

fast-math 改的三件事与代价并置:允许重排、假设无 NaN 与 Inf、忽略符号零与 denormal,代价是结果可能变化,并给出本机实测的相对偏差量级
图 12-1718 fast-math 改的三件事与代价并置:允许重排、假设无 NaN 与 Inf、忽略符号零与 denormal,代价是结果可能变化,并给出本机实测的相对偏差量级@ 本系列绘制

3.5 保序的代价:一条加法链的账

把 3.3 的两列数字换算成每元素时间,能看清瓶颈在哪。

未开 fast-math 时:3.274 ms / 4,194,304 元素 ≈ 0.78 ns/元素。本机 3.4 GHz 上下,0.78 ns ≈ 2.65 个周期。一次浮点加法延迟大约 3 到 4 个周期——读数与「每轮等一次加法」完全吻合。

开了 fast-math 后:0.861 ms / 4,194,304 ≈ 0.205 ns/元素 ≈ 0.7 个周期。这已经低于单条加法延迟,说明加法被拆成了多路并行(编译器展开了循环、用了多累加器),吞吐才顶得上来。

言归正传,这条换算就是「为什么多累加器能赢」的量化版本——§五 会用手写版再验一次。

这条换算有个前提要讲清:它给的是量级判断,不是精确常数。本机是笔记本,频率在长跑中会漂移(ep11 量过:同一用例连跑五次的变异系数是 5% 到 8%),所以「2.65 个周期」只能读成「接近一次加法延迟」,不能当成测量结果。真正站得住的判据是两条读数的相对位置:未开 fast-math 时落在单次延迟那一档、开启后掉到明显低于它——这个跨越本身就说明累加被拆成了多路并行,而不需要知道确切频率。

保序加法链的每元素周期账:未开 fast-math 时约二点六五个周期每元素、与单次加法延迟吻合,开启后降到零点七个周期、低于单条延迟,说明加法已被拆成多路并行
图 12-1819 保序加法链的每元素周期账:未开 fast-math 时约二点六五个周期每元素、与单次加法延迟吻合,开启后降到零点七个周期、低于单条延迟,说明加法已被拆成多路并行@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

3.6 官方口径对得上

  • LLVM 的向量化文档明确写着:浮点归约只有在 -fassociative-math -fno-signed-zeros -fno-trapping-math 这一族(即 -ffast-math)下才向量化,因为浮点不满足结合律。
  • GCC 的项目页同样把浮点归约列在需要 -ffast-math 或 -fassociative-math 的一类里。
  • MSVC 的原因码 1105 的官方解释正是「无法识别的 reduction」,并注明浮点 reduction 需要 /fp:fast。

三家的说法与本机四组读数完全对上。这一条是本篇"报告 vs 读数"的第一处完整闭环。

fast-math 对全部七个内核的影响矩阵:只加 fast-math 前后各内核的毫秒与变化幅度,显示它只对浮点归约与分支取值两个内核有效,其余要么无变化要么略慢
图 12-1920 fast-math 对全部七个内核的影响矩阵:只加 fast-math 前后各内核的毫秒与变化幅度,显示它只对浮点归约与分支取值两个内核有效,其余要么无变化要么略慢@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

3.7 本章判据(第三条)

  • 判据:循环里如果有浮点累加(求和、点积、求最值的 min/max 也算一类),先问语义允不允许重排。允许 → 开 fast-math,收益 3 到 4 倍;不允许 → 别指望编译器,去改算法(分层归约、分块累加)。
  • 反判据:不要为了这个开关去改全局编译选项。本机读数是收益 3.6 倍,但代价是全部浮点代码的语义都放宽了。正确做法是给单个函数加属性(GCC __attribute__((optimize("fast-math"))))或给单个文件加开关。
  • 本机依据:dot 3.274 → 0.861 ms(GCC)、3.263 → 0.903 ms(MSVC);而 dot_novec 全程恒在 15.4 GB/s。

四、分支与 if-conversion:数据决定贵不贵

好了,上一章处理的是「语义不许动」。这一章处理另一种更隐蔽的情形:语义允许,但编译器不肯消掉那个分支——于是性能由你的数据分布说了算。

4.1 一个循环,两种数据,差 7.5 倍

本机用例里有一段再普通不过的取值:

a[i] = (b[i] > 0.f) ? (b[i] * 2.f) : (b[i] * 0.5f);

它把 b 里的正数乘 2、非正数乘 0.5。同一份 MSVC 二进制(/O2 /arch:AVX2 /fp:fast),只换 b 的填充方式:

数据模式 读数 每元素
随机符号(不可预测) 17.256 ms 4.11 ns
块状符号(每 64 个一组同号,可预测) 2.292 ms 0.55 ns

7.53 倍,差别全在数据。这不是「缓存」也不是「向量化」,是分支预测:随机符号下那个三元表达式的跳转每两次猜错一次,一次猜错约十几到二十个周期;块状符号下预测器几乎全中。

说白了,第零章把这条写成结论时用的是同一对数字——它们可信,靠的是同一份二进制、同一台机器、同一个循环,只换了输入。

同一份二进制与同一个分支循环在两种数据模式下的读数:随机符号十七点二五六毫秒对块状符号二点二九二毫秒,差七点五三倍,说明性能由分支可预测性决定
图 12-2021 同一份二进制与同一个分支循环在两种数据模式下的读数:随机符号十七点二五六毫秒对块状符号二点二九二毫秒,差七点五三倍,说明性能由分支可预测性决定@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

4.2 手写 SIMD 在这里做的是「消掉分支」

手写版把三元表达式换成无分支的掩码选择:比较生成掩码、两个结果都算出来、用掩码混合。

同一份数据、同一个 MSVC 构建,加上手写 AVX2 blend 版:

数据模式 自动向量化循环 手写 AVX2 blend 手写优势
随机符号 17.256 ms 1.823 ms 9.47×
块状符号 2.292 ms 1.421 ms 1.61×

手写的优势从 9.47 倍掉到 1.61 倍——同一份手写代码,优势随数据的可预测性变化 5.9 倍。

OK,所以「手写 SIMD 快 5 倍」这种说法本身不成立:它没有说清对照的是哪种数据。在随机数据上,手写的真实价值是消掉分支;在可预测数据上,分支本来就便宜,手写的价值只剩下宽度。

分支版与无分支 blend 版的两条数据通路对照:三元表达式生成跳转、由预测器承担代价,blend 版比较生成掩码后两路都算再混合,并标出两者在两种数据模式下的优势比
图 12-2122 分支版与无分支 blend 版的两条数据通路对照:三元表达式生成跳转、由预测器承担代价,blend 版比较生成掩码后两路都算再混合,并标出两者在两种数据模式下的优势比@ 本系列绘制

4.3 编译器愿不愿意消分支:if 转换

编译器把分支消掉这件事有个名字:if 转换(if-conversion,把控制依赖转成数据依赖)。它把 if 变成谓词执行或掩码选择,于是循环体变成直线代码,可以向量化。

但编译器不总是敢做。本机读数正好把这条边界量出来了——GCC 同一份源码、两个构建:

构建 随机符号 块状符号 说明
GCC -O3 -march:native(无 fast-math) 17.753 ms —— 报告 missed: unsupported control flow in loop.
GCC -O3 -march:native -ffast-math 1.523 ms 1.460 ms 已经做了 if 转换,数据不再影响

同一个分支循环,-ffast-math 一开,从 17.753 ms 掉到 1.523 ms(11.7 倍),而且两种数据模式几乎一样快(1.523 对 1.460)——分支消失了,数据自然不再影响。

言归正传,为什么 if 转换需要 -ffast-math?因为它要「两路都算出来再选」,而两路都算意味着算了本不该算的值——比如把负数也拿去取对数。在严格浮点语义下,这可能有副作用(异常、超范围);-ffast-math 一并把这类顾虑放掉了,编译器才敢动手。

if 转换的必要条件与代价:把控制依赖转成数据依赖需要两路都执行、会算出本不该算的值,因此需要浮点语义放宽才敢做,并标出开启前后两种数据模式的读数变化
图 12-2223 if 转换的必要条件与代价:把控制依赖转成数据依赖需要两路都执行、会算出本不该算的值,因此需要浮点语义放宽才敢做,并标出开启前后两种数据模式的读数变化@ 本系列绘制

4.4 什么时候分支本来就不贵

反过来也要说清:不是所有分支都值得消。判据是「可预测性」。

  • 可预测分支:循环边界判断、单调扫描、块状数据。预测器几乎全中,代价接近 0。本机读数里块状符号的 2.292 ms 就是这一类。
  • 不可预测分支:随机数据上的阈值判断、哈希命中、分支概率接近 50% 的二分。代价由误预测率决定,最坏可达十几倍。本机读数里随机的 17.256 ms 就是这一类。
  • 无分支写法不总是更快:两路都算意味着多做一倍的运算。当运算本身很贵(比如一次开方)而分支其实可预测时,无分支版会更慢——本机读数里 GCC fast-math 下 i_clamp 1.406 ms 对自动版 1.523 ms 是 1.08 倍,而块状符号上 i_clamp 1.529 ms 反而比自动版 1.460 ms 慢 4.5%。

最后那一格是本篇里少见的「手写略慢」实例,它比「手写快 9 倍」更有信息量:手写不是免费的正确做法。

分支代价的三种情形对照:可预测分支代价接近零、不可预测分支代价由误预测率决定、无分支写法两路都算会多出一倍运算,并给出本机三组读数作为边界
图 12-2324 分支代价的三种情形对照:可预测分支代价接近零、不可预测分支代价由误预测率决定、无分支写法两路都算会多出一倍运算,并给出本机三组读数作为边界@ 本系列绘制

4.5 一把更省事的工具:#pragma omp simd 与 loop hint

好了,如果只是想让编译器相信「这个循环没有依赖」,比起手写 intrinsic,有几把轻量得多的工具:

  • OpenMP 的 simd 指令:#pragma omp simd 直接告诉编译器「这是可向量化的循环」,还可带 aligned / safelen / simdlen / reduction 子句描述对齐、最大无依赖距离、目标宽度与归约类型。
  • Clang 的 loop hint:#pragma clang loop vectorize(enable) interleave(enable),还能用 vectorize_width(4) 直接指定宽度。(本机无 Clang,只作引用。)
  • Intel 编译器的 #pragma ivdep:忽略潜在的依赖假设——注意官方措辞是「不会忽略已证明的依赖」,只忽略「可能」的。

它们的共同点是:你给编译器一句它自己推不出来的知识。这正是本篇的核心主题从另一个方向的表述——手写的分界不在「写不写指令」,而在「你能不能给出编译器缺的那条信息」。

三把轻量工具对照:OpenMP 的 simd 指令及其 aligned safelen simdlen reduction 子句、Clang 的 loop hint 宽度指定、Intel 的 ivdep 依赖假设忽略,共同点是把编译器推不出来的信息显式给它
图 12-2425 三把轻量工具对照:OpenMP 的 simd 指令及其 aligned safelen simdlen reduction 子句、Clang 的 loop hint 宽度指定、Intel 的 ivdep 依赖假设忽略,共同点是把编译器推不出来的信息显式给它@ 本系列绘制

4.6 本章判据(第四条)

  • 判据:循环体里有 if 或三元表达式时,先看数据的可预测性。可预测 → 不用管;不可预测(接近 50% 命中)→ 这是收益最大的一处,两条路都试:让编译器 if 转换(开 fast-math)、或自己写无分支版。
  • 反判据:无分支版不是无条件更优。本机有一条手写略慢的读数(块状符号上 1.529 对 1.460 ms,慢 4.5%),原因就是两路都算多花了一倍运算。
  • 本机依据:随机符号 17.256 → 手写 1.823 ms(9.47 倍);块状符号 2.292 → 1.421 ms(1.61 倍);GCC 开 fast-math 后自动版 17.753 → 1.523 ms(11.7 倍)且两种数据同价。

下一章离开「让编译器做」,进入「自己做」——手写 intrinsic 到底是哪六个指令在干活,以及它凭什么赢、又凭什么输。

五、手写 intrinsic:它到底什么时候赢

到这里为止,主动权都在编译器手上。这一章把主动权拿回来:手写 AVX2。目标不是「手写更快」——本机读数恰好相反——而是搞清楚它相对编译器赢在哪、输在哪。

5.1 六个指令就够写一个内核

说白了,AVX2 的 intrinsic 有几百个,但本机用例里真正用到的只有六个。按数据流排一遍:

阶段 intrinsic 对应的机器码 说明
装数据 _mm256_loadu_ps vmovups 从内存装 8 个 float,不要求对齐
装数据(对齐版) _mm256_load_ps vmovaps 同上,但要求 32 字节对齐,否则可能 GP fault
广播标量 _mm256_set1_ps vbroadcastss 把一个标量复制到 8 个通道(saxpy 的系数)
运算 _mm256_add_ps / _mm256_mul_ps vaddps / vmulps 逐通道加/乘
融合乘加 _mm256_fmadd_ps(a,b,c) vfmadd213ps 算 a*b + c,一次舍入
比较与选择 _mm256_cmp_ps + _mm256_blendv_ps vcmpps + vblendvps 生成掩码、按掩码混合两路结果
取回标量 _mm256_extractf128_ps + _mm_movehl_ps / _mm_add_ss vextractf128 等 水平归约:8 → 4 → 2 → 1

写一个逐元素加法内核,代码量是:

for (; i + 8 <= n; i += 8) {
    __m256 vb = _mm256_loadu_ps(b + i), vc = _mm256_loadu_ps(c + i);
    _mm256_storeu_ps(a + i, _mm256_add_ps(vb, vc));
}
for (; i < n; ++i) a[i] = b[i] + c[i];   // 余数:必须自己收尾

两个细节必须自己管,编译器替你管了:一是余数(n 不是 8 的倍数时的尾巴循环),二是对齐(用 loadu 就完全避开,代价见 §六)。

余数与对齐这两处必须自己管,因为编译器替你管的时候是隐式的,而手写时它们是显式的。余数处理写错会漏掉最后几个元素(这类 bug 在小数组上测不出来);对齐用错会直接崩(§六)。此外还有一件事必须自己核:intrinsic 的名字与语义以 Intel Intrinsics Guide 为唯一权威索引——它给出每个指令的 CPUID 标志、对应的机器码与伪代码级操作,是核对手写版本行为的第一手依据,比任何二手博客都可靠。

AVX2 关键 intrinsic 地图:从 loadu 与 load 装数据、set1 广播、add 与 mul 运算、fmadd 融合乘加、cmp 与 blend 做掩码选择、extractf128 加水平归约取回标量,并标出余数与对齐两处必须自己管的环节
图 12-2526 AVX2 关键 intrinsic 地图:从 loadu 与 load 装数据、set1 广播、add 与 mul 运算、fmadd 融合乘加、cmp 与 blend 做掩码选择、extractf128 加水平归约取回标量,并标出余数与对齐两处必须自己管的环节@ 本系列绘制

5.2 逐位正确性:手写必须自己证明自己

OK,写手写 SIMD 的第一件事不是量速度,是证明结果没错。本机用例把三条核对都做了:

  • i_add 与标量逐元素加法:3.5 亿次比较,逐位相同(mismatch 0)。
  • i_clamp(cmp + blendv)与三元表达式:同上,逐位相同(mismatch 0)。
  • i_dot(fmadd + 水平归约)与顺序加法:相对偏差 1.06e-05(GCC)/ 1.33e-05(MSVC)。

前两条的「逐位相同」是应该的:逐通道的加、乘、选择都不改变运算顺序,所以结果必须一致;不一致就是写错了。第三条的 1e-05 是必然的:8 路部分和改变了加法顺序,与前缀和那类改写同源——所以它只能给相对偏差,不能要求逐位相同。

这条区分很重要:写 SIMD 的时候要能说清「哪一类改写必须逐位一致,哪一类只能给误差界」。前者的不一致是 bug,后者的不一致是数学。

手写 SIMD 的逐位正确性核对面板:i_add 与 i_clamp 对逐元素标量结果逐位相同、mismatch 为零,i_dot 对顺序加法给相对偏差一万分之一个量级,并区分「必须逐位一致」与「只能给误差界」两类改写
图 12-2627 手写 SIMD 的逐位正确性核对面板:i_add 与 i_clamp 对逐元素标量结果逐位相同、mismatch 为零,i_dot 对顺序加法给相对偏差一万分之一个量级,并区分「必须逐位一致」与「只能给误差界」两类改写@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

5.3 反直觉的读数:开了 fast-math 后,编译器赢了

现在量速度。四组「手写 vs 自动」,同一台机器、同一份源码:

内核 构建 自动向量化 手写 AVX2 谁快
逐元素加法 MSVC /arch:AVX2 /fp:fast 23.8 GB/s 25.8 GB/s 手写快 8%
saxpy MSVC /arch:AVX2 /fp:fast 1.332 ms 1.929 ms 自动快 31%
saxpy GCC -O3 -march:native -ffast-math 1.333 ms 1.563 ms 自动快 15%
点积 MSVC /arch:AVX2 /fp:fast 0.903 ms 0.972 ms 自动快 7.1%
点积 GCC -O3 -march:native -ffast-math 0.861 ms 0.928 ms 自动快 7.2%
分支取值(随机数据) MSVC /arch:AVX2 /fp:fast 17.256 ms 1.823 ms 手写快 9.47×
分支取值(可预测数据) MSVC /arch:AVX2 /fp:fast 2.292 ms 1.421 ms 手写快 1.61×

五个内核里,自动版赢了四个。 只有在「编译器不肯消掉分支」的随机数据上,手写才大幅领先。

言归正传,这与中文侧「手写 intrinsic 提升近 5 倍」的说法方向相反——原因不在结论错,而在对照条件不同:那句说法对照的是「未调优的自动向量化」(很可能没开 fast-math、没开更宽的 /arch)。本机把条件摆齐之后,答案是编译器赢。

手写 intrinsic 与自动向量化的读数对照面板:逐元素加法、saxpy、点积、分支取值四组内核在两个构建下的 GB/s 与毫秒并置,显示五个内核里自动版赢四个,仅随机数据上的分支取值手写大幅领先
图 12-2728 手写 intrinsic 与自动向量化的读数对照面板:逐元素加法、saxpy、点积、分支取值四组内核在两个构建下的 GB/s 与毫秒并置,显示五个内核里自动版赢四个,仅随机数据上的分支取值手写大幅领先@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

5.4 为什么自动版能赢:多累加器与展开

手写版输在哪里,报告里写得很直接:GCC 的原文是 loop vectorized using 32 byte vectors and **unroll factor 8**——展开 8 倍。

展开 8 倍的意义在多累加器上。看两个写法的数据依赖:

  • 单累加器手写版:acc = fmadd(vb, vc, acc),每一轮都依赖上一轮的 acc。vfmadd 的延迟约 4 个周期,所以每轮至少等 4 个周期——吞吐被延迟锁死。
  • 多累加器版:acc0..acc3 各自累加,四条链并行。每轮的 4 个 fmadd 互相独立,吞吐可以打满(本机一代的 FMA 吞吐约每周期 1 到 2 条)。

同一批指令、同一个总数,只差「累加到几个寄存器上」。 这就是为什么自动版快 7%:编译器替我做了这件事,而我没做。

好了,修法很直接——手写版也要多累加器。但要注意:那已经不是「手写 SIMD」,而是「手写编译器已经会做的优化」。这条界线正是本篇要划的那条。

单累加器与多累加器的数据依赖对照:单链每轮依赖上一轮结果被 FMA 延迟锁死,四条并行链可以打满吞吐,并标出编译器的展开八倍正是在做这件事
图 12-2829 单累加器与多累加器的数据依赖对照:单链每轮依赖上一轮结果被 FMA 延迟锁死,四条并行链可以打满吞吐,并标出编译器的展开八倍正是在做这件事@ 本系列绘制

5.5 fmadd 与精确性:为什么它不只是「合并两条指令」

_mm256_fmadd_ps(a,b,c) 算 a*b + c,只舍入一次。相比先 mul 再 add(两次舍入),它既省一条指令又更精确——这是 -mfma / /arch:AVX2 的实质收益之一。

但有一个坑:FMA 的存在会改变数值结果。同一个公式,开不开 FMA 可能给出不同的最低位。这与 §3 的 1e-05 是同一种账:精度与可复现性是一对矛盾,FMA 站在精度那边。

还有一处容易漏的平台差异:MSVC 不定义 __FMA__ 宏。本机 __FMA__ = 0(MSVC)而 = 1(GCC 在 -march=native 下),但 MSVC 的 _mm256_fmadd_ps 照常可用(/arch:AVX2 已启用 FMA)。所以用 #if defined(__FMA__) 判条件编译在 MSVC 侧会走错分支——该判的是 __AVX2__。

说白了,宏定义这处差异有实际的坑。本机实测:__FMA__ 在 GCC 的 -march=native 下是 1、在 MSVC 的 /arch:AVX2 下是 0,但 MSVC 的 _mm256_fmadd_ps 照常编译可用。所以 #if defined(__FMA__) 这种写法在 MSVC 侧会把 FMA 分支整段跳过——该判的是 __AVX2__。判据很简单:用「指令族」判条件编译,不用「单条指令」判;任何与具体指令绑定的宏,都要先在两家实测一遍再写进代码。

FMA 的两面:一次舍入带来的精度与吞吐收益,以及它会让同一公式的最低位结果改变;并标出两家宏定义差异,MSVC 不定义 FMA 宏但 FMA 指令可用
图 12-2930 FMA 的两面:一次舍入带来的精度与吞吐收益,以及它会让同一公式的最低位结果改变;并标出两家宏定义差异,MSVC 不定义 FMA 宏但 FMA 指令可用@ 本系列绘制

5.6 水平归约:唯一手写「明显更啰嗦」的地方

点积的最后一步是把 8 个通道加起来(水平归约)。手写版是三步:

__m128 lo = _mm256_castps256_ps128(acc);      // 低 128 位
__m128 hi = _mm256_extractf128_ps(acc, 1);    // 高 128 位
__m128 s4 = _mm_add_ps(lo, hi);               // 8 -> 4
__m128 s2 = _mm_add_ps(s4, _mm_movehl_ps(s4, s4));   // 4 -> 2
__m128 s1 = _mm_add_ss(s2, _mm_shuffle_ps(s2, s2, 1)); // 2 -> 1

三步走完才拿到一个标量。这一步没有捷径:x86 的向量指令都是「通道对通道」,没有单指令把所有通道求和(haddps 也是两两合并、且延迟不低)。

OK,所以「点积手写版」的瓶颈往往不在乘法,而在最后这三步——这也是为什么自动版能在点积上赢:它把归约插进了展开的循环里,减少了水平归约的次数。

水平归约的三步路径:高低位相加把八个通道折成四个、再用 movehl 折成两个、最后用 addss 与 shuffle 取出标量,并说明 x86 没有单指令全通道求和以及编译器把归约插进展开循环以减少归约次数
图 12-3031 水平归约的三步路径:高低位相加把八个通道折成四个、再用 movehl 折成两个、最后用 addss 与 shuffle 取出标量,并说明 x86 没有单指令全通道求和以及编译器把归约插进展开循环以减少归约次数@ 本系列绘制

5.7 本章判据(第五条)

  • 判据:手写之前,先量一遍自动版。本机读数:五个内核里自动版赢四个,唯一大幅落后的是「编译器不肯 if 转换的随机数据分支」。
  • 反判据:如果手写版只用单个累加器、没做展开,它在归约类内核上一定输给开了 fast-math 的编译器(本机 7%)。手写要与编译器打平,得先做它做的事。
  • 本机依据:saxpy 自动 1.332 ms vs 手写 1.929 ms(自动快 31%);点积自动 0.861 vs 手写 0.928(自动快 7.2%);唯有随机数据上的 clamp 手写快 9.47 倍。

六、对齐与 loadu:一笔被夸大的账

写 SIMD 的材料里,「对齐」几乎总是被列在第一条注意事项。这一章量一下它到底值多少。

6.1 把基址故意错开 1 个 float

做法很直接:把三个数组多分配 64 个元素,然后取基址偏移 0(32 字节对齐)与偏移 1(错开 4 字节,不再对齐),跑同一个内核。

MSVC /arch:AVX2 /fp:fast:

形状 偏移 0 偏移 1
自动向量化循环 2.108 ms 2.043 ms
_mm256_loadu_ps 2.009 ms 1.971 ms
_mm256_load_ps(要求对齐) 2.106 ms 不跑(会 fault)

GCC -O3 -march=native -ffast-math:

形状 偏移 0 偏移 1
自动向量化循环 1.961 ms 2.116 ms
_mm256_loadu_ps 2.117 ms 2.003 ms

读法:偏移 0 与偏移 1 的差异落在噪声内(最好与最差相差不到 8%,而且方向不一致——MSVC 侧错位反而略快、GCC 侧对齐反而略慢)。loadu 与 load_ps 之间也没有稳定差别(MSVC 2.106 vs 2.009、GCC 2.096 vs 2.117)。

言归正传,结论很清楚:在本机这种带宽受限的循环上,对齐不是一个值得为之改数据结构的代价项。 这与「对齐很重要」的流行说法方向不一致,但读数是这样。

对齐偏移的对照读数:基址偏移零与偏移一在自动向量化与 loadu 两种形状下的毫秒读数,两家显示差异落在噪声内且方向不一致,load 与 loadu 之间也无稳定差别
图 12-3132 对齐偏移的对照读数:基址偏移零与偏移一在自动向量化与 loadu 两种形状下的毫秒读数,两家显示差异落在噪声内且方向不一致,load 与 loadu 之间也无稳定差别@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

6.2 为什么对齐的钱越来越小

有三个原因叠加。

  1. 硬件替我们做了。 现代 x86 的 vmovups(未对齐装载)在没有跨缓存行的情况下与对齐装载同速;只有在跨界时才付一两个周期的额外代价。ep11 量过:行宽 64 字节,一个 32 字节的未对齐装载只有约一半概率跨行。
  2. 循环是带宽受限的。 §七 会量到:这批内核在 16 MB 规模上只有 20 到 50 GB/s,早就撞上内存带宽。带宽是瓶颈时,几个周期的装载惩罚被完全掩盖。
  3. 编译器自己会处理。 GCC 与 MSVC 都会为「可能不对齐」的循环生成前导(peel)循环或运行期对齐分支,把不对齐的部分单独处理掉——手工写这些,正是「重写编译器已经会做的事」。

所以对齐这笔账的正确读法是:它在算力受限(数据在 L1/L2 里)的循环里才有意义,在带宽受限的循环里几乎不存在。

对齐代价变小的三个原因:现代处理器未对齐装载在未跨行时同速、循环已带宽受限使装载惩罚被掩盖、编译器会生成前导循环或运行期分支自行处理
图 12-3233 对齐代价变小的三个原因:现代处理器未对齐装载在未跨行时同速、循环已带宽受限使装载惩罚被掩盖、编译器会生成前导循环或运行期分支自行处理@ 本系列绘制

6.3 load_ps 的真实风险:不是慢,是会 fault

好了,真正需要注意的是另一件事:对齐版装载指令在对齐不成立时的行为是未定义/陷阱,不是「慢一点」。Intel 的 intrinsic 文档措辞是:地址必须 32 字节对齐,否则可能产生 general-protection 异常。

本机用例里那一格写的是「不跑(会 fault)」——这是故意不跑:让它跑就崩了。下表把两版的取舍说明白:

指令 对齐要求 对齐不成立时 适合什么场合
_mm256_loadu_ps 无 照常工作 默认选择。所有来源不确定的数据
_mm256_load_ps 32 字节 可能 GP fault 只在你自己分配并对齐过的内存上

判据因此很简单:能证明对齐就用对齐版,不能证明就别用。 而「能证明」通常意味着你自己 _mm_malloc / aligned_alloc 出来的内存——一旦数据来自别人(第三方库、序列化缓冲、切片),loadu 是唯一安全的选择。

loadu 与 load 的语义与风险对照:loadu 无对齐要求且照常工作、load 要求三十二字节对齐否则可能产生通用保护异常,并给出「能证明对齐才用对齐版」的选择判据
图 12-3334 loadu 与 load 的语义与风险对照:loadu 无对齐要求且照常工作、load 要求三十二字节对齐否则可能产生通用保护异常,并给出「能证明对齐才用对齐版」的选择判据@ 本系列绘制

6.4 那 assume_aligned 是干什么的

说白了,既然对齐不值钱,为什么还有 __builtin_assume_aligned(GCC)与 std::assume_aligned(C++20)?因为它的作用对象不是运行时,是编译器的判断:

  • 它让编译器敢向量化那些它原本因为「可能不对齐」而拒绝的循环;
  • 它让编译器生成不带前导循环的干净版本(省掉分支与重复代码)。

assume_aligned 的收益体现在向量化报告从红变绿,而不是「每条 load 省两个周期」。这与 §5.7 的判据是同一条:多数性能开关的真实价值是「让编译器敢动」,不是「让指令更快」。

需要注意的是它是个承诺:你说对齐了但实际没有,行为是未定义的(与 load_ps 同类风险,只是不会立刻崩)。

assume_aligned 的作用对象是编译器判断而非运行时:让原本因可能不对齐而被拒绝的循环得以向量化,并省掉前导循环与重复代码,同时它是一句会导致未定义行为的承诺
图 12-3435 assume_aligned 的作用对象是编译器判断而非运行时:让原本因可能不对齐而被拒绝的循环得以向量化,并省掉前导循环与重复代码,同时它是一句会导致未定义行为的承诺@ 本系列绘制
assume_aligned 前后对照面板:同一循环在加与不加对齐承诺时的向量化报告条目与端到端读数,说明它的收益体现在报告由红变绿而不是省下几个周期
图 12-3536 assume_aligned 前后对照面板:同一循环在加与不加对齐承诺时的向量化报告条目与端到端读数,说明它的收益体现在报告由红变绿而不是省下几个周期@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

6.5 本章判据(第六条)

  • 判据:默认用 loadu。只有在「内存是你自己对齐分配」且「这个循环确实算力受限」两个条件同时成立时,才考虑对齐版。
  • 反判据:把「对齐」当成 SIMD 的第一注意事项是错位的重点。本机读数:对齐偏移带来的差异落在噪声内(< 8%,方向不一致),而「循环没被向量化」带来的差异是数倍到十倍。
  • 本机依据:偏移 0 / 1 在两种形状下四个数对,最好与最差相差 < 8%;load_ps 与 loadu 的差别同样在噪声内(MSVC 2.106 对 2.009 ms)。

七、规模与算术强度:收益为什么随 N 衰减

前六章都在讲「编译器敢不敢、值不值」,有一个变量一直被固定着:数据规模。这一章把它放开。

7.1 N 扫描:从 L1 到主存

OK,同一份内核,只改数组大小,从 16 KB 扫到 64 MB(MSVC /arch:AVX2 /fp:fast):

数组大小 add add 有效带宽 dot dot 有效带宽
16 KB 0.0004 ms 122.9 GB/s 0.0003 ms 163.9 GB/s
64 KB 0.0016 ms 122.9 GB/s 0.0011 ms 178.7 GB/s
256 KB 0.0100 ms 78.6 GB/s 0.0046 ms 171.0 GB/s
1 MB 0.0627 ms 50.2 GB/s 0.0309 ms 101.8 GB/s
4 MB 0.3100 ms 40.6 GB/s 0.0830 ms 151.6 GB/s
16 MB 2.5436 ms 19.8 GB/s 1.4105 ms 35.7 GB/s
64 MB 8.8540 ms 22.7 GB/s 4.0837 ms 49.3 GB/s

两个拐点很清楚:256 KB 以后 add 开始掉(本机每核 L2 是 512 KB,三个数组共 768 KB 已经装不下),16 MB 处同时触底(L3 共 16 MB)。从 122.9 掉到 22.7 GB/s,跌了 5.4 倍。

同一个内核、同一个 ISA、同一份向量化报告,只因为数据放大了 4000 倍,收益就没了 5 倍。 这就是「向量化收益随规模衰减」的实测版本。

这条扫描的口径要说清三点。第一,三个数组同时驻留:a、b、c 各占一份,所以「数组 16 MB」实际占用三倍内存,拐点会比只算一张表推算得更早。第二,每个点取多次最小值,避免把偶发抢占读成趋势。第三,小规模那一档只作对照——16 KB 时单次只有零点几毫秒,计时精度已接近噪声下限,所以它给的是「算力顶天时能到多少」,而不是可长期复现的常数。

N 扫描面板:数组从十六 KB 到六十四 MB 的有效带宽曲线,标出二五六 KB 与十六 MB 两个拐点,加法带宽从一百二十二点九跌到二十二点七 GB/s
图 12-3637 N 扫描面板:数组从十六 KB 到六十四 MB 的有效带宽曲线,标出二五六 KB 与十六 MB 两个拐点,加法带宽从一百二十二点九跌到二十二点七 GB/s@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

7.2 两条天花板:算力与带宽

言归正传,把上面两条曲线放在一起,能看出两种不同的瓶颈:

  • add 是小算力内核:每个元素 1 次加法、搬 12 字节(读 8 写 4)。小规模时 122.9 GB/s(算力顶天),大规模时 22.7 GB/s(带宽顶天)。它两头都撞。
  • dot 是较大算力内核:每个元素 1 次乘加、搬 8 字节。大规模时 49.3 GB/s,比 add 的 22.7 高一倍——因为它的算术强度(每字节的运算次数)更高。
  • dot_novec(关掉向量化)全程恒为 15.4 GB/s:不管数据在 L3 里还是主存里都是 15.4——说明它一直依赖链受限,跟内存带宽根本没关系。

三行对照就是一张缩小的 roofline 图:add 落在带宽墙上,dot 在两者之间,dot_novec 落在延迟墙上。有了这张图,「该不该手写 SIMD」这个问题才有一个可判定的答案。

算力受限区与带宽受限区的分区图:以算术强度为横轴、有效带宽为纵轴,标出 add 落在带宽墙、dot 落在两墙之间、关掉向量化的 dot 全程落在依赖链延迟墙上
图 12-3738 算力受限区与带宽受限区的分区图:以算术强度为横轴、有效带宽为纵轴,标出 add 落在带宽墙、dot 落在两墙之间、关掉向量化的 dot 全程落在依赖链延迟墙上@ 本系列绘制

7.3 算术强度:先算这一项,再决定写不写

把上面的结构抽成一条可操作的判据:算术强度 = 每搬运一个字节做多少次浮点运算。

  • 强度很低(每字节不到 1 次):例如 add(12 字节 1 次加)。向量化只在数据装得下缓存时有用;一旦出缓存,加宽寄存器完全无感。
  • 强度中等(每字节 1 到 3 次):例如 dot(8 字节 1 次乘加)。向量化在缓存外仍有效(本机 49.3 GB/s),但会随规模递减。
  • 强度很高(每字节 5 次以上):例如矩阵乘的分块内核。向量化收益与规模无关,这才值得手写、值得调 tile。

本机读数给这三档各提供了一个实例(add / dot / 前缀和的反例),这一章的分区图就是它们的实测版本。

按算术强度分三档的处置建议:每字节不到一次时向量化只在缓存内有效、一到三次时缓存外仍有效但随规模递减、五次以上时收益与规模无关值得手写与调分块
图 12-3839 按算术强度分三档的处置建议:每字节不到一次时向量化只在缓存内有效、一到三次时缓存外仍有效但随规模递减、五次以上时收益与规模无关值得手写与调分块@ 本系列绘制

7.4 有效带宽账:把「搬了多少」摆出来

好了,换个角度读同一批数据更容易看清。逐元素加法的有效带宽按「读 2 写 1」算,dot 按「读 2、标量出 1」算:

内核 大规模(64 MB)有效带宽 判断
add 22.7 GB/s 撞带宽墙
dot(fast-math) 49.3 GB/s 未撞墙,仍有向量化空间
dot_novec 15.4 GB/s 不随规模变化 ⇒ 延迟墙

这一列的作用是把「是不是带宽受限」变成一个可测的量,而不是靠猜。判据是:把 N 放大十倍,看每元素时间是否线性增长——线性增长说明撞墙了,几乎不变说明瓶颈在别处(依赖链)。

本机 dot_novec 正是后者:3.246 ms(4 M 元素)与 13.11 ms(16 M 元素)差不多是 4 倍而不是 10 倍——它压根不受带宽支配。

有效带宽账面板:三个内核在六十四 MB 规模下的有效带宽并置,标注加法撞带宽墙、开启融合乘加的点积仍有空间、关掉向量化的点积不随规模变化说明受依赖链限制
图 12-3940 有效带宽账面板:三个内核在六十四 MB 规模下的有效带宽并置,标注加法撞带宽墙、开启融合乘加的点积仍有空间、关掉向量化的点积不随规模变化说明受依赖链限制@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

7.5 本章判据(第七条)

  • 判据:动手前先做两件事——估算术强度、量数据规模。三个数就能定位:每元素运算次数、每元素搬入字节数、数据是否装得下 L2/L3。
  • 反判据:如果数据装得下 L2 而算术强度又低,把寄存器从 128 位换到 256 位几乎没有意义——本机读数:add 在 64 KB 时 122.9 GB/s、在 64 MB 时 22.7 GB/s,5.4 倍的全在规模上,不在宽度上。
  • 本机依据:add 122.9 → 22.7 GB/s;dot 178.7 → 49.3 GB/s;dot_novec 全程 15.4 GB/s。

下一章离开「怎么做」,去问「用哪套接口做」——标准库给了什么,以及它能不能跨编译器。

八、标准 SIMD:std::experimental::simd 与 C++26 std::simd

前两章都在讲「怎么写」。还有一个更根本的问题:要不要写 intrinsic——标准库其实给了一套可移植的向量抽象。这一章量它。

8.1 三家的支持现状

说白了,标准 SIMD 走过两个阶段:std::experimental::simd(P0214R9,目标 Parallelism TS v2)与 C++26 的 std::simd(P1928 系列)。

本机的实测结果是:

实现 <experimental/simd>(TS) 本机实测
libstdc++(GCC 16.2) 完整实现,自 GCC 11 起 HAVE_STD_SIMD = 1,simd<float>::size() = 4
MSVC STL(19.44) 无此头文件 HAVE_STD_SIMD = 0(__has_include 判假)
libc++(LLVM) 部分实现,特性宏表把 __cpp_lib_simd 标为 unimplemented 本机无 Clang,未能实测,只引官方特性宏表

所以「用 std::experimental::simd 写可移植 SIMD」这句话在本机只能兑现一半:GCC 侧能编译能跑,MSVC 侧连头文件都没有。 这条不是小差异——它意味着这套接口在 2026 年还不能作为跨编译器的默认选择。

三家标准库对标准 SIMD 的支持现状:libstdc++ 自 GCC 十一完整实现、MSVC STL 无该头文件、libc++ 的特性宏表中该接口标记为未实现,并标出本机只有 GCC 侧可实测
图 12-4041 三家标准库对标准 SIMD 的支持现状:libstdc++ 自 GCC 十一完整实现、MSVC STL 无该头文件、libc++ 的特性宏表中该接口标记为未实现,并标出本机只有 GCC 侧可实测@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

8.2 一个意外的读数:size() 是 4,不是 8

本机在 -O3 -march=native 下实测:std::experimental::simd<float>::size() = 4。

为什么不是 8? 因为 TS 的默认 ABI 标签 simd_abi::native<T> 取的是编译器为该类型选定的原生宽度,而 GCC 在这台机器上给 simd<float> 选的是 128 位(4 个 float)——不是 AVX2 的 256 位。

这条读数有两层意义:

  1. 「native」不等于「最宽」。想要 256 位得显式指定 ABI(simd<float, simd_abi::fixed_size<8>>),或者依赖编译器版本的具体行为。
  2. 这正是抽象层的代价:标准接口为了可移植,只能给一个「保守的 native」;而手写 intrinsic 的宽度是你自己指定的。抽象把可移植性买回来了,把宽度让出去了。

OK,(在 C++26 的 std::simd 里,simd::size 改成了 integral_constant<size_t, N> 静态成员,simd_abi::compatible 被删除,最小支持宽度提到 64——这些变化的方向是「让宽度更显式」。)

标准 SIMD 的宽度读数:本机在开启 AVX2 的构建下 simd float 的 size 仍为四而非八,说明 native ABI 取的是保守的原生宽度,并对照手写 intrinsic 的宽度由使用者指定
图 12-4142 标准 SIMD 的宽度读数:本机在开启 AVX2 的构建下 simd float 的 size 仍为四而非八,说明 native ABI 取的是保守的原生宽度,并对照手写 intrinsic 的宽度由使用者指定@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

8.3 where 不是 select:一次踩过的接口坑

TS 版的 where 不是三参数选择函数。本机实测的编译错误原话是:

error: no matching function for call to 'where(simd<float>::mask_type, simd<float>, simd<float>)'
candidate expects 2 arguments, 3 provided

where 的真实语义是掩码赋值:where(mask, lvalue) = value;。要写「按掩码在两条路里选一个」,得先算出一个中间量、再用 where 覆盖它:

simd<float> x(&b[i], element_aligned);
simd<float> r = x * 0.5f;              // 先假设走 else 分支
where(x > 0.f, r) = x * 2.f;           // 再用掩码覆盖成 if 分支

C++26 的 std::simd 把这件事改掉了:where 设施被替换成掩码版的 load/store 重载加 simd_select——那才是三参数的 select。同时 hmin/hmax 改名 reduce_min/reduce_max、split/concat 改名 simd_split/simd_cat。

这条差异对写代码的人有直接影响:照着 C++26 的示例写 TS 版一定编译不过,而网上两版示例混在一起。

言归正传,迁移路径值得单独记一笔,因为两版 API 的示例在网上混在一起。从 TS 到 C++26,除了 where 换成 simd_select,还有一批改名:hmin/hmax 变成 reduce_min/reduce_max,split/concat 变成 simd_split/simd_cat,simd_abi::compatible 被删除,simd::size 从函数改成静态成员。实测方法很直接:用 __has_include(<experimental/simd>) 判可得性、用 simd<float>::size() 判宽度——两个数就能定位当前实现落在哪一代,不必去猜编译器版本与标准的关系。

where 与 simd_select 的接口差异:TS 版的 where 是两参数掩码赋值、必须先用中间量与覆盖的写法,C++26 的 simd_select 才是三参数选择,并列出 hmin 与 reduce_min、split 与 simd_split 等同批改名
图 12-4243 where 与 simd_select 的接口差异:TS 版的 where 是两参数掩码赋值、必须先用中间量与覆盖的写法,C++26 的 simd_select 才是三参数选择,并列出 hmin 与 reduce_min、split 与 simd_split 等同批改名@ 本系列绘制

8.4 三方读数:标准版没有赢

三条路写同一个点积与同一个逐元素加法,同一台机器(GCC -O3 -march:native -ffast-math):

内核 自动向量化 手写 AVX2 std::experimental::simd
逐元素加法 2.257 ms / 22.3 GB/s 2.167 ms / 23.2 GB/s 2.087 ms / 24.1 GB/s
saxpy 1.333 ms / 37.8 GB/s 1.563 ms / 32.2 GB/s 1.721 ms / 29.3 GB/s
点积 0.861 ms / 58.5 GB/s 0.928 ms / 54.2 GB/s 1.085 ms / 46.4 GB/s
分支取值 1.523 ms / 35.5 GB/s 1.406 ms / 32.5 GB/s 1.731 ms / 29.1 GB/s

四个内核里,标准版只在「逐元素加法」这一格赢了,其余三格都是最慢的。 加法那一格赢在它本来就带宽受限——三家的代码都被内存卡住,差异落在噪声里(2.087 对 2.257 ms 是 7.5%,而加法的跨构建波动本来就有 12%)。

真正有信息量的是点积那一格:标准版 1.085 ms,比自动版的 0.861 ms 慢 26%。 这与 §8.2 的宽度读数一致——size() = 4 意味着它只用了 128 位,一半的宽度。

自动向量化、手写 intrinsic 与标准库 SIMD 三条路的四方读数对照:逐元素加法带宽受限三家接近、saxpy 与点积与分支取值三格标准版最慢、点积比自动版慢四分之一,与标准版仅使用一半宽度的读数一致
图 12-4344 自动向量化、手写 intrinsic 与标准库 SIMD 三条路的四方读数对照:逐元素加法带宽受限三家接近、saxpy 与点积与分支取值三格标准版最慢、点积比自动版慢四分之一,与标准版仅使用一半宽度的读数一致@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

8.5 官方口径对得上

  • P0214R9(TS 提案)定义了 simd<T, Abi>、simd_mask<T, Abi>、where、reduce,以及 ABI 标签 native / fixed_size / compatible(最后这个在 C++26 被删除)。
  • P1928R15(C++26)的改动清单确认了 §8.3 的几处改名与语义替换。
  • libc++ 的特性宏表把 __cpp_lib_simd 标为未实现——这是「libc++ 还没做」最权威的凭据。
  • libstdc++ 的状态页把 Parallelism TS v2 列为已实现。
  • MSVC 侧没有官方声明说「我们不提供」,但 STL 特性宏表里没有它;本机 __has_include 判假是更直接的证据。文章里按「截至本机工具链版本,未见实现」表述,不替微软下结论。

8.6 本章判据(第八条)

  • 判据:需要跨编译器的可移植 SIMD 时,先确认目标编译器有没有这套接口——本机读数:GCC 有、MSVC 无。没有就别指望标准库,退回「自动向量化 + 少量 intrinsic」。
  • 反判据:不要假设标准抽象与手写等速。本机读数:点积上标准版比自动版慢 26%(1.085 对 0.861 ms),原因是它只用了 128 位。
  • 本机依据:simd<float>::size() = 4(开启 AVX2 的构建下);四格读数里标准版三格最慢、一格在噪声内。

九、选型判据:按顺序问的六条

好了,把全篇收成一串问题。顺序本身就是结论的一部分——只有前一条不构成瓶颈,后面的权衡才有意义。倒过来问(先挑 SIMD 指令再看规模)会踩出「给小数组手写 AVX2」这类误用。

9.1 六条判据

# 问什么 通过 回落
1 规模:数据装得下 L2 吗? 装不下 → 继续 装得下 → 布局与向量化都不影响它(本机 add 在 64 KB 时 122.9 GB/s,撞的是算力不是带宽)
2 算术强度:每搬一个字节算几次? 1 次以上 → 继续 不足 1 次 → 先解决带宽(改布局、分块、减少搬运),加宽寄存器无用
3 卡点诊断:报告说向量化了没有? 报告绿且读数也涨 → 不用手写 报告红 → 按四种卡点分别处置(§二)
4 给许可:这条卡点能不能靠「给编译器一条信息」解决? 能 → 改开关或加属性(restrict、assume_aligned、-ffast-math、#pragma omp simd) 不能 → 进入第 5 条
5 手写:剩下的收益是否值得维护代价? 值得 → 写,且必须做多累加器 + 展开 不值得 → 放弃,记入结论
6 复核:改完之后重跑报告与读数了吗? 报告与读数都看 → 结束 只看了报告 → 回去看读数(§三的反例)

第 3 与第 4 条是本篇的核心:多数情况下,手写不是第一选择,「给编译器一条它缺的信息」才是。 本机读数支撑这一点——五个内核里自动版赢四个。

第 5 条里的「必须做多累加器 + 展开」是有读数支撑的硬要求:本机单累加器的手写点积版输给编译器 7.2%(0.928 对 0.861 ms),原因就是编译器展开了 8 倍。

说白了,这六条有一个特点:每一条都能落到本机的一个读数上,不是凭空列的清单。第 1 条落在 add 在 64 KB 与 64 MB 之间的 5.4 倍差上;第 2 条落在 add 与 dot 的有效带宽差上(22.7 对 49.3 GB/s);第 3 条落在八份报告条目上;第 4 条落在 fast-math 的 3.6 到 3.8 倍上;第 5 条落在手写与自动的五格对照上(自动赢四格);第 6 条落在「报绿而收益为 0」那一格上。判据能追溯到读数,才可以被证伪。

选型判据决策表:按顺序问的六条,每条给出通过路径与回落路径,并标注本机读数依据——装得下缓存时布局与向量化都无感、算术强度不足一时先解决带宽、报告绿且读数涨则不必手写
图 12-4445 选型判据决策表:按顺序问的六条,每条给出通过路径与回落路径,并标注本机读数依据——装得下缓存时布局与向量化都无感、算术强度不足一时先解决带宽、报告绿且读数涨则不必手写@ 本系列绘制

9.2 一条容易被跳过的前提:先确认瓶颈在算力

第 1、2 两条排在最前,因为它们能直接否掉整个向量化议题。本机有一个干净的实例:add 这个内核在内核内规模是 122.9 GB/s、在主存规模是 22.7 GB/s——5.4 倍的差距全在数据规模上,与用不用 AVX2 无关。

反过来说:如果你手上的代码数据量不大(装得下 L2/L3),那么无论自动向量化、手写 intrinsic 还是 std::simd,测出来的差异都会落在噪声里。本机四格读数里最"平"的一格正是逐元素加法——三家差 7.5%,而加法跨构建的波动本来就有 12%。

先确认瓶颈在算力的前提:加法内核在缓存内与主存规模下有五点四倍差距而全部来自数据规模,以及在装得下缓存的规模下三条技术路线的差异会落进噪声
图 12-4546 先确认瓶颈在算力的前提:加法内核在缓存内与主存规模下有五点四倍差距而全部来自数据规模,以及在装得下缓存的规模下三条技术路线的差异会落进噪声@ 本系列绘制

9.3 三步复现流程

OK,复现这一篇不需要任何私有环境,需要三样东西:

  1. 两套工具链:MSVC 19.44(VS 2022,/O2 与 /arch:AVX2、/fp:fast)与 g++ 16.2(MSYS2 ucrt64,-O2 / -O3 / -march=native / -ffast-math)。共 8 个构建。
  2. 一份源码:evidence/src/simd_lab.cpp(单文件、C++17、Windows 与 MSYS2 都能编)。
  3. 一个脚本:evidence/tools/run_all.ps1,一次跑完 8 个构建的全部分支,报告与读数分别落到 evidence/logs_gcc/ 与 evidence/logs_msvc/。

核验方法:本篇正文里的每一个读数都能在 evidence/logs_* 里逐行找到;面板图由同一批日志生成(evidence/recon-report.md 是它们的中介)。

复现时有三个容易踩的地方。第一,别在跑基准时占着机器:本机取的是多次最小值,但如果同时编译或跑别的负载,最小值会被抬高。第二,规模要走完整条曲线:只测一个大数组会把「算力受限」与「带宽受限」两种情形混淆——同一份代码在小规模上的相对收益可能是大规模的两倍。第三,两家读数的绝对值不能互换比较:本机 MSVC 的 prefix_sum 是 5.682 ms、GCC 是 3.279 ms,比值才是可比的量。

三步复现流程:两套工具链共八个构建、一份单文件用例源码、一个一次跑完的脚本,报告与读数分别落盘,正文每个读数都能在日志里逐行回溯
图 12-4647 三步复现流程:两套工具链共八个构建、一份单文件用例源码、一个一次跑完的脚本,报告与读数分别落盘,正文每个读数都能在日志里逐行回溯@ 本系列绘制

9.4 自检清单与五条常见误用

写完代码、准备提交之前,逐条核对:

言归正传,1. 热点循环的报告看过了吗(两家各一条命令)? 2. 报绿之后,读数也核过了吗?(§三:报绿收益为 0 是真实存在的) 3. 循环里有浮点累加吗?如果有,语义允不允许重排? 4. 有不可预测的分支吗?数据分布是什么样的? 5. 数据装得下 L2 吗?算术强度够吗?(先答这两条再谈 SIMD) 6. 手写版做了多累加器与展开吗?(否则一定输给开了 fast-math 的编译器) 7. 有余数处理吗?n 不是向量宽度的整数倍时,尾巴循环写对了吗? 8. 对齐是承诺还是保证?load_ps 的前提成立吗? 9. 手写版的正确性逐位核对过了吗?哪一类改写必须逐位一致? 10. -ffast-math 是加在单个文件/函数上,还是全局? 11. 结论有没有越过证据边界(说「未命中几次」「哪个端口满」)?

五条最常见的误用:

  • 「开 -O3 就向量化了」——报告要看,读数也要看。
  • 「加 restrict 就能向量化」——它是一句承诺,不是开关。
  • 「对齐是第一条注意事项」——本机读数里它的差异落在噪声内。
  • 「手写一定比编译器快」——本机五个内核里自动版赢四个。
  • 「AVX-512 一定更快」——本机没有 AVX-512,不给结论;而第三方实测有反例会低于 AVX2。
自检清单与五条常见误用:十一条逐项核对覆盖报告、读数、浮点归约语义、分支可预测性、规模与算术强度、多累加器、余数、对齐承诺、逐位正确性与证据边界;五条误用各自配本机读数反驳
图 12-4748 自检清单与五条常见误用:十一条逐项核对覆盖报告、读数、浮点归约语义、分支可预测性、规模与算术强度、多累加器、余数、对齐承诺、逐位正确性与证据边界;五条误用各自配本机读数反驳@ 本系列绘制

十、收束:全系列十二篇的账

10.1 全篇读数汇总

把本篇测出的全部关键读数集中在一张表里,便于对照与复核。每一档的原始日志都随篇留档在 evidence/。

组 项 GCC 16.2 MSVC 19.44 说明
环境 CPUID.7.0.EBX / CPUID.1.ECX 219c97a9 / 7ed8320b 同 AVX2 有、AVX-512 无、FMA 有
环境 alignof(max_align_t) 16 8 两家不同
环境 HAVE_STD_SIMD 1 0 标准 SIMD 单侧可用
环境 simd<float>::size() 4 — native ABI 取 128 位
报告 add_restrict 宽度 32 字节 (C5001) GCC 报 unroll factor 8
报告 add_restrict 宽度(另一构建) 16 字节 — 同构建里宽度也会不同
报告 dot_r(默认档) 绿(保序版) C5002 由于 1105 未识别的 reduction
报告 strided_r — C5002 由于 1301 访存不连续
报告 prefix_r missed C5002 由于 1200 循环携带依赖
报告 clamp_r(无 fast-math) missed:unsupported control flow C5002 由于 1100 控制流
归约 dot 无 fast-math 3.274 ms 3.263 ms 15.4 GB/s
归约 dot_novec 3.296 ms 3.252 ms 与上一行同价
归约 dot 有 fast-math 0.861 ms 0.903 ms 3.83× / 3.60×
归约 相对偏差(手写 vs 顺序) 1.06e-05 1.33e-05 顺序改写必然的误差
分支 随机符号(自动) 17.753 ms 17.256 ms 不可预测
分支 块状符号(自动) 1.460 ms(fast) 2.292 ms 可预测
分支 手写 blend(随机 / 可预测) 1.406 / 1.529 ms 1.823 / 1.421 ms 手写优势 9.47× → 1.61×
手写 i_add / i_clamp 正确性 mismatch 0 mismatch 0 逐位相同
手写 i_dot_fma vs 自动 0.928 vs 0.861 0.972 vs 0.903 自动快 7.2% / 7.1%
手写 i_saxpy_fma vs 自动 1.563 vs 1.333 1.929 vs 1.332 自动快 15% / 31%
对齐 偏移 0 / 1(自动) 1.961 / 2.116 ms 2.108 / 2.043 ms 差异落在噪声内
对齐 load_ps vs loadu 2.096 vs 2.117 ms 2.106 vs 2.009 ms 无稳定差别
规模 add 16 KB → 64 MB 98.3 → 21.6 GB/s 122.9 → 22.7 GB/s 5.4 倍跌幅
规模 dot_novec 全规模 15.4 GB/s 恒定 15.4 GB/s 恒定 依赖链受限
标准 d_dot vs 自动 1.085 vs 0.861 不可用 标准版慢 26%

这张表的读法有一处要注意:同一组读数在正文与这里出现过两次(第一次在它所属的章节、第二次在汇总表里),两处必须一致。这是本篇的核对设计——两处对不上,说明搬运时改错了数。另外,表中每一行的原始日志都在 evidence/logs_gcc/ 或 evidence/logs_msvc/ 里可逐行回溯,构建参数写在 evidence/tools/run_all.ps1 里,不存在「某个数字只在正文里出现过」的情形。

全篇读数汇总面板:硬件与工具链、报告条目、浮点归约、分支、手写、对齐、规模与标准 SIMD 八组关键读数集中成一张表,两家工具链并列
图 12-4849 全篇读数汇总面板:硬件与工具链、报告条目、浮点归约、分支、手写、对齐、规模与标准 SIMD 八组关键读数集中成一张表,两家工具链并列@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)
全档位读数矩阵:七个内核在六个构建下的有效带宽逐行对照,显示跨构建大幅变动的只有浮点归约与 GCC 侧的分支取值,其余五行的波动都在百分之十以内
图 12-4950 全档位读数矩阵:七个内核在六个构建下的有效带宽逐行对照,显示跨构建大幅变动的只有浮点归约与 GCC 侧的分支取值,其余五行的波动都在百分之十以内@ 本机实测(AMD Ryzen 7 5800H / MSVC 19.44 + GCC 16.2)

10.2 证据形态与读法(一句话版)

好了,三类证据:编译器报告(能证「它接受了这个循环」)、端到端读数(能证代价区间与方向)、反汇编与逐位核对(能证报告与读数之间那一步是不是真的)。四样里少一样,结论都有缺口——而本机拿不到的微架构事件(IPC、端口、cache-miss)在正文里一律不加推测。

本篇五条结论与它们的读数出处索引:每条结论对应到本机的具体读数、构建与章节,便于逐条复核,并标出其中两条与流行说法方向相反
图 12-5051 本篇五条结论与它们的读数出处索引:每条结论对应到本机的具体读数、构建与章节,便于逐条复核,并标出其中两条与流行说法方向相反@ 本系列绘制

10.3 复现流程

见 §9.3。核心是:八个构建、一份源码、一个脚本;evidence/recon-report.md 是读数与面板图之间的中介。

10.4 自检清单与五条常见误用

见 §9.4。第 2 条和第 6 条是最容易踩的:第 2 条(报绿不等于收益),第 6 条(手写不做多累加器必输)。

说白了,这份清单的用法是逐条核对、不留「大概没问题」。十一条里前四条是「先看再动」(报告、读数、浮点语义、数据分布),中间四条是写法要求(多累加器、余数、对齐、逐位核对),最后三条是报告纪律(fast-math 的作用域、结论的证据边界)。最容易跳过的是第 2 条与第 6 条:第 2 条是因为「报告已经绿了」看起来就完事了;第 6 条是因为单累加器的手写版在简单用例上看着也对——它只是慢,不是错。

10.5 与系列其他篇目的交叉点

  • 与 ep13 无关——本系列到此收束。 下面是与已交付十一篇的接续。
  • 与 ep05《优化器的边界》:ep05 讲的是「编译器看见什么才敢动」(UB、严格别名),本篇讲「编译器看见了但不许动时怎么办」(浮点结合律、分支)。两篇合起来才是「优化器的边界」的完整图景。
  • 与 ep11《缓存与布局》:ep11 的 SoA 结论是本篇的前置——访存不连续,向量化第一道门就过不去。两篇是同一条链上的两环:位置决定能不能装进寄存器,宽度决定一次装几个。
  • 与 ep01《对象布局》:alignof(max_align_t) 两家之差(16 / 8)在本篇复现,与本系列首篇同一处读数。
  • 与 ep10《内存序》:ep10 的结论是「跨核看见什么」,本篇是「一条指令算几个」——同属「代价从哪来」的第四波。

10.6 一条可以搬走的方法

把本篇的做法抽象成三步:

  1. 先问「这条循环值不值得动」,再问「怎么动」。 规模与算术强度能直接否掉多数议题;本机读数是这一条的证明(加法 5.4 倍的差距全在规模上)。
  2. 把「编译器说」与「读数说」分开记。 报告与读数必须分两栏;两者不一致时,不一致本身就是结论(§三)。
  3. 手写之前,先做编译器会做的事。 多累加器、展开、余数处理——本机读数里手写输给编译器的那几格,输的全是这些。

这三步与 ep07 到 ep11 是同一条。这一篇多了一件工具:编译器自己的报告(/Qvec-report:2 与 -fopt-info-vec-all),它把「编译器到底怎么想的」从猜测变成了打印——与 ep11 的布局转储是同一类东西。

一条可以搬走的三步法:先问值不值得动再问怎么动、把编译器说与读数说分成两栏记、手写之前先做编译器会做的事,并标出本篇多出的工具是编译器自己的向量化报告
图 12-5152 一条可以搬走的三步法:先问值不值得动再问怎么动、把编译器说与读数说分成两栏记、手写之前先做编译器会做的事,并标出本篇多出的工具是编译器自己的向量化报告@ 本系列绘制

10.7 参考资料(含源码与证据)

10.8 全系列十二篇的账

OK,最后收一次全系列的账。十二篇、四波、同一条方法论:同一份源码、只换一个开关、量差异。

篇 一句话结论 图量
ep01 对象布局、对齐与 padding sizeof 的真相藏在填充里;-Wpadded 只报尾填充、不报成员间填充 39
ep02 分配器解剖 malloc → jemalloc/mimalloc → pmr arena,代价在分配粒度与 thread cache 40
ep03 值语义的账 move/拷贝/SSO/emplace_back 的账要按「省了几次分配」算 40
ep04 编译期账本 -ftime-trace 是 Clang 专属,Windows 侧要换 ETW 路线 40
ep05 优化器的边界 UB、严格别名与向量化报告:编译器看见什么才敢动 40
ep06 名字与符号 ABI、修饰、可见性、PLT 与 IAT:链接期的账 40
ep07 链在一起 LTO 与 PGO 的收益来自跨 TU 内联与画像,不是"打开就更快" 40
ep08 分发的代价 虚函数/模板/variant/类型擦除四条路的代价分四层 41
ep09 异常与 RTTI 「零成本」要拆成时间与空间两笔账,空间侧不免费 40
ep10 内存序与原子 seq_cst 在 x86 上:load 与 RMW 免费,store 不免费 40
ep11 缓存与布局 对齐 ≠ 隔离;SoA 的答案是「看 k」;padding 买并行正确性、付单线程容量 45
ep12 SIMD(本篇) 报告说向量化了 ≠ 有收益;解锁归约的是语义不是优化级别;开了 fast-math 编译器赢手写 41

十二篇一路读下来,能看到同一条线:语言层面的写法(ep01–ep03)→ 编译与链接期的选择(ep04–ep07)→ 运行时的代价(ep08–ep10)→ 与硬件对话的两端:位置(ep11)与宽度(ep12)。

全系列十二篇的账:把四波十二篇的篇幅、图量与一句结论并置成一条时间线,标出从语言写法到编译链接、再到运行时代价、最后与硬件对话的推进关系
图 12-5253 全系列十二篇的账:把四波十二篇的篇幅、图量与一句结论并置成一条时间线,标出从语言写法到编译链接、再到运行时代价、最后与硬件对话的推进关系@ 本系列绘制

全系列到此完结。 严格说,本篇没有「下一篇」——十二篇到此收束;但同一条方法论在站内还有下一站:GPU 架构(从命令提交到光栅化)、引擎架构(Godot / Jolt)、图形性能分析,各自都有独立专题,读法仍是同一条:先量规模,再问开关,最后才动手写指令。

以上。希望能对大家有所帮助。十二篇见。