1 SIMD 解决什么数据并行
服务器收到来自网卡的数据,程序把数据放进内存,再由 CPU 检查、转换或计算。假设监测程序保存了 1000000 个温度采样值,需要给每个值加上相同的校准偏移量。普通循环每次读取 1 个值、执行加法、写回结果。每个采样的计算互不依赖,执行的操作却相同。这种“许多数据接受相同处理”的工作,就是 SIMD 适合尝试的数据并行。
SIMD 是 Single Instruction, Multiple Data,即单指令多数据。它让 1 条向量指令对若干个数据元素执行相同操作。例如,1 条 256 位向量加法指令可以处理 8 个 32 位浮点数。这里的“同时”描述指令的语义:多个元素属于同次向量操作,并不承诺它们在芯片内部必定于同个时钟周期完成。入门阶段先理解可见的输入和输出,不必追踪执行单元内部结构。
SIMD 与增加 CPU 核心数量是不同维度。多线程可让不同核心处理不同区间;每个核心上的线程还可以使用 SIMD。反过来,只有 1 个线程也可能完成向量运算。看见程序只占用 1 个核心,不能判断它正在逐个计算;看见 CPU 使用率达到 100%,也不能判断向量车道是否充分利用。线程数量、向量化程度和内存带宽需要分别观察。
适合 SIMD 的例子包括像素亮度调整、音频采样缩放、传感器数组阈值判断,以及连续数组的加减乘。链表遍历则很不同:下个节点的位置依赖当前节点,地址也可能散落在内存里。即使最终只是给节点里的数加 1,寻找数据的过程仍可能限制向量化。判断时要同时看“计算是否重复”和“数据是否容易成批取得”。
真实机器还有输入来源的约束。1 块通过 PCIe 连接的网卡可能持续向主机内存传入数据,NVMe 固态盘也可能通过 PCIe 提供文件。SIMD 加速的是 CPU 内核中的处理环节,不会直接提高网卡链路速率或磁盘读速。若总耗时主要花在等待输入,局部计算变快,用户看到的端到端收益可能很小。先圈出数据路径,再找确实重复且占时间的计算,才有明确的优化对象。

2 向量宽度与车道直觉
可以把向量寄存器想成固定总宽度的容器,把其中用于保存独立元素的位置叫作车道。总宽度为 128 位时,可容纳 4 个 32 位元素或 8 个 16 位元素;总宽度为 256 位时,可容纳 8 个 32 位元素或 4 个 64 位元素。车道数等于向量位宽除以元素位宽。选择数据类型既影响数值范围和精度,也会影响同次操作能够处理的元素数量。
假设输入数组有 19 个 32 位浮点数,每组处理 8 个。第 1 组覆盖索引 0–7,第 2 组覆盖索引 8–15,最后还剩索引 16–18。向量化没有让这些剩余元素消失,程序必须明确处理它们。也不能为了凑齐 8 个元素而读取数组边界之外的数据:即使额外位置“看上去就在旁边”,也可能越过有效对象或触及不可访问页面。
以数组相加为例,程序先把 a 的 8 个元素装入向量寄存器,再装入 b 的 8 个元素,逐车道相加,最后存出 8 个结果。第 0 条车道只计算 a[0]+b[0],第 1 条车道只计算 a[1]+b[1]。它们不会自动互相求和。如果任务是把整段数组累加成单个总和,还需要额外的归约步骤;这种跨车道合并可能改变浮点加法顺序,也增加额外工作。
向量寄存器宽度不是内存总线宽度,也不是 PCIe 的通道数量。标成“256 位向量”的 CPU 功能,描述的是指令处理数据的宽度;双通道内存描述内存控制器与内存模块的连接配置;PCIe x8 描述设备链路使用的通道数量。这些规格都与数据流动有关,却不能直接相乘来计算程序速度。主板插槽、CPU 指令支持和内存配置需要分别确认。
车道更多只代表每条指令可能覆盖更多元素。装载、存储、地址计算、循环控制仍然需要时间;有些芯片执行较宽指令的代价也不同。当工作集超过缓存,限制还可能转移到内存带宽。因此,“8 条车道”适合用来解释覆盖范围,不应直接写成“整体快 8 倍”。实际收益必须针对指定机器、数据规模和程序版本测量。
3 自动向量化与手写 intrinsic 边界
最常用的起点是编写清楚的标量循环,让编译器尝试自动向量化。编译器需要证明成批执行不会改变程序允许观察到的行为,再结合目标指令集和成本估计决定是否转换。连续数组、简单索引、彼此独立的迭代通常更容易成功。复杂函数调用、不可预测的访问位置和无法排除的指针重叠,则可能让转换失败,或者需要生成额外检查。
例如 out[i] = a[i] + b[i] 看起来很简单,但如果 out 指向 a 后面的相邻元素,前次写入就可能影响后次读取。编译器有时会先检查地址范围,确认没有危险重叠后走向量路径,否则走标量路径。C 语言的 restrict 可以表达特定的不别名约定,但它是调用者必须兑现的契约。把它加到本来允许重叠的接口上,会让程序失去正确性保证。
intrinsic 是编译器提供的、通常接近特定指令语义的函数接口。它允许开发者明确表达装载、向量加法或掩码操作,但仍由编译器负责寄存器安排等工作。手写 intrinsic 适合热点稳定、自动向量化效果不足、团队又能维护多个目标版本的场合。它不会自动解决不连续访问、低带宽或错误算法,也不意味着最终汇编必然与源码逐项对应。
跨机器部署尤其需要边界。常见 x86-64 CPU 可能提供 SSE、AVX2 等不同能力,常见 AArch64 平台也有自己的向量指令接口。即使服务器外形相同,里面的 CPU 世代也可能不同。只为本机生成的可执行文件,搬到另台机器后可能出现非法指令异常。生产程序应明确最低指令集,或通过可靠的运行时分派选择实现,并保留基线版本。
自动向量化与 intrinsic 的选择还包含维护成本。标量源码通常更方便编译器针对不同平台优化;intrinsic 可以控制关键细节,却增加平台分支、测试组合和代码审查负担。建议先测量、再查看优化报告,最后才考虑手写。若自动向量化已经达到内存带宽上限,改写指令形式可能只增加复杂度。高级的指令调度、可伸缩向量接口等内容可留待后续,不必作为入门实验的前提。

4 动手:对比标量与 SIMD 内核
实验使用连续浮点数组相加,目标是区分“源码写成循环”和“机器实际逐个执行”。准备 1 台支持 AVX2 的 x86-64 机器和支持相应 intrinsic 的 C 编译器。先通过系统提供的 CPU 能力信息确认环境;不支持 AVX2 时仍可做自动向量化实验,但不能运行下面的专用版本。实验记录应包括 CPU 型号、编译器版本、内存配置和编译参数,便于解释不同机器的差异。
下面给出核心函数。接口约定 3 段数组互不重叠,且都至少包含 n 个 float。为便于观察,代码保留简单的标量实现;是否真的生成标量指令,由编译方式决定。AVX2 版本使用允许未对齐地址的装载和存储,不要求调用者先满足 32 字节对齐。
#include <stddef.h>
#include <immintrin.h>
void add_scalar(float *out, const float *a,
const float *b, size_t n) {
for (size_t i = 0; i < n; ++i)
out[i] = a[i] + b[i];
}
/* 仅在支持 AVX2 的目标上编译和运行此函数。 */
void add_avx2(float *out, const float *a,
const float *b, size_t n) {
size_t i = 0;
for (; n - i >= 8; i += 8) {
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
_mm256_storeu_ps(out + i, _mm256_add_ps(va, vb));
}
for (; i < n; ++i)
out[i] = a[i] + b[i];
}
构建测试驱动时,在计时区间外分配数组、填入确定的数据,并准备独立输出。先调用各实现检查结果,再计时。可使用小整数转换成 float 的输入,逐元素验证加法结果;扩展到其他浮点内核时,则应依据数值需求制定误差标准。计时后让驱动读取并报告结果校验值,避免编译器把无人使用的运算整体删除。分配内存、读取文件和打印数组都不应混入内核计时。
要得到有意义的基线,可把标量函数单独放在编译单元中。使用 GCC 时,可用 -O3 -fno-tree-vectorize 编译该单元,用 -O3 -mavx2 编译 intrinsic 单元,再正常链接;此实验关闭链接时优化,避免基线在链接阶段被重新转换。另建自动向量化版本,使用 -O3 -mavx2 -fopt-info-vec-all 查看报告。这样比较的是明确区分的标量、自动和手写路径,而非把已经向量化的循环误叫标量。
建议覆盖 n 为 0、1、7、8、9、19 的正确性测试,再测 4096 和 16777216 等不同规模。大规模时,每个数组约 64 MiB,3 个数组合计约 192 MiB,可能超过机器末级缓存,但应根据实际配置判断。每组先预热,再重复测量,报告多次用时的中位数及波动。多次重复小数组会形成缓存内实验,不能把结果直接解释为从主内存持续读取的速度。

数组相加每个元素显式读取 8 字节并写入 4 字节,可用 12×n÷耗时 计算有效数据吞吐量,但它不是内存控制器真实流量;缓存和写分配等机制可能增加或减少到达主内存的数据。若 SIMD 与标量的时间接近,先检查报告、实际调用路径和数据规模,再判断是否遇到带宽限制。不要因为单次结果不理想,就认定向量指令没有执行。
5 对齐、尾循环与掩码
对齐表示地址是某个字节数的整数倍。例如地址满足 32 字节对齐,意味着地址除以 32 的余数为 0。即使数组起点对齐,a+1 对 float 来说也只前进 4 字节,通常不再满足 32 字节对齐。结构体内的字段、切片起点和网络数据中的负载偏移,都可能改变实际访问地址。应检查传给内核的指针,而不能只检查最初分配的缓冲区。
某些装载接口要求特定对齐条件,调用者违反条件可能导致故障;允许未对齐的接口则放宽这项要求。本课示例选择后者以简化接口。未对齐访问在现代机器上的代价因跨缓存行、跨页面及芯片实现而异,不能笼统断言每次都很慢。更不能把“允许未对齐”理解为“允许访问数组之外”:地址对齐和地址有效是不同条件。
尾循环是最直接的收尾办法。处理 19 个元素时,先完成 16 个,再用普通循环完成 3 个。代码中的 n-i >= 8 在 i 不超过 n 的循环不变量下,避免通过 i+8 判断时可能出现的无符号整数溢出。对初学者而言,这类边界比少写几条指令更重要。长度为 0、刚好整组以及多出 1 个元素,都是应覆盖的测试情形。
支持相应功能的指令集也可以通过掩码表达哪些车道有效。剩余 3 个元素时,只启用对应车道,并使用具有正确内存访问语义的掩码装载、计算或存储。掩码不是给无效结果涂上颜色:如果程序先执行普通的越界装载,再把结果遮住,非法访问已经发生。也不能假设所有向量指令集或所有指令都提供相同的掩码能力。
还有一种办法是在数据结构中明确预留填充空间,但必须同时保证额外元素可读、可写,并且写入不会破坏其他对象。输出缓冲区常常比输入更敏感:多写几个元素可能直接损坏相邻记录。在接口没有明确授权时,优先采用安全的尾循环。对齐、填充和掩码都是可选的实现手段,应让正确的数据范围成为不可破坏的前提。
6 常见坑:以为编译器总会向量化
开启较高优化级别,只是允许编译器尝试更多转换,并不保证每个循环向量化。编译器可能发现循环存在真实依赖,也可能无法证明访问安全,还可能判断循环太短,准备向量路径的成本高于收益。优化报告中的“没有向量化”需要结合原因理解。它可能是正确性限制,也可能只是当前编译器和目标平台上的成本选择。
例如 a[i] = a[i-1] + x[i] 的当前结果依赖前次迭代,不能直接把连续 8 次迭代当成互不相关的加法。把它改写成别的并行算法需要重新证明数学和数值语义,已经超出简单替换循环的范围。又如循环里调用外部函数,编译器若不知道函数是否修改内存,就难以安全重排。先识别依赖,再讨论指令宽度,能避免用错误承诺强迫优化。
浮点归约也是常见边界。由于舍入,(a+b)+c 与 a+(b+c) 的结果可能不同。向量累加常会把数据分组求和,因而改变加法顺序。放宽浮点规则的编译参数可能允许更多变换,同时改变对特殊值、异常或精度的处理。科研结果、财务统计和传感器阈值等场景应先明确允许的误差,不能只为获得向量化报告就全局打开宽松选项。
另个误区是把 -march=native 当作通用加速开关。它适合某些本机实验,却可能让产物依赖构建机才有的指令。机架内更新批次不同的服务器、笔记本与开发板,都可能不满足这些条件。应把“构建成功”“测试机执行成功”和“部署范围兼容”分开记录。容器通常共享宿主 CPU 能力,也不能凭镜像相同就推断指令集相同。
排查时先问结果是否正确,再确认热点循环是否生成向量代码,随后观察是否真的调用该路径,最后解释性能。看见汇编里的向量寄存器也不足以证明目标循环成功向量化,它可能来自初始化或库函数。报告定位到源码行、反汇编定位到函数、计时定位到实际输入,证据相互对应才有用。更深的计数器分析留到后续性能专题,本课先建立可复核的基本链路。

7 案例:数据布局阻止向量化
设想 1 台机架式采集服务器,网卡收到设备记录后,把它们保存在主内存。每条记录有温度、湿度、时间戳和状态位。后续程序只需要对温度数组进行校准并统计越界数量。若内存中采用结构体数组,布局类似“记录 0 的全部字段,记录 1 的全部字段”。温度之间隔着其他字段,CPU 无法用最简单的连续装载直接得到连续的温度元素。
假设每条记录固定占 32 字节,其中温度字段占 4 字节。扫描 1000000 条记录时,有效温度数据约 4 MB,但记录跨度达到约 32 MB。具体主内存流量取决于缓存行和缓存命中,不能简单等同于记录总字节数;不过,同条缓存行中许多被带入的字段可能没有参与计算。若系统正受到读取带宽限制,这种低利用率比单次加法的耗时更值得关注。
将布局改成字段数组,即分别保存温度、湿度、时间戳和状态位,温度就形成连续的 float 数组。编译器更容易生成连续向量装载,缓存也更集中地服务目标字段。支持离散读取的指令有时可以处理原布局,但地址生成和访存成本仍存在,不能把它当作免费转换。数据布局改变的是计算取得操作数的方式,不只是代码的美观程度。

布局转换也要付费。如果网卡输入采用固定记录格式,程序可能先解析并转入字段数组;若整批数据只用 1 次,转换成本可能抵消后续收益。如果同批温度要经过校准、过滤、统计等多个阶段,转换成本则可能被多次复用摊薄。不要直接把 C 结构体内存映射成外部报文格式,还需明确字段偏移、字节序、填充和长度校验。
评估时应保留 3 组结果:原布局的完整处理时间、布局转换加新内核的完整时间,以及新内核单独用时。若只报告第 3 组,会掩盖额外复制和分配。还要观察峰值内存占用:原记录和字段数组同时存在时,内存需求会增加。服务器可能因此挤压文件缓存或引发内存压力,最终让更快的计算路径拖慢整条数据链。
因此,案例中的选型不是“字段数组永远更好”。需要整条记录一起消费的解析环节,可能更适合原布局;大量只访问少数字段的计算环节,可能适合字段数组或分块布局。入门阶段先通过字段大小、访问跨度、复用次数和转换开销解释选择,再考虑更复杂的混合布局。数据如何到达 CPU,常常决定向量指令有没有机会发挥作用。
8 把向量化可行性写进实现选型
实现选型应先写工作负载,而非先写某个指令集。记录元素类型、数组长度分布、读写字节数、分支比例、数据来源和复用次数。对实时传感器批处理,还应记录凑成大批次所增加的等待时间。批次扩大可能提高向量循环占比,却也可能让首个样本等待更久。吞吐量改善不能自动等同于延迟改善。
接着写机器边界。例如目标是具备 AVX2 的 x86-64 服务器,还是同时覆盖 AArch64 开发板;内存容量是否允许额外布局转换;网卡和 NVMe 是否共享紧张的 PCIe 资源。对于不受支持的机器,规定基线实现和分派方式。CPU 标称频率、内存标称速率只能作为配置记录,无法替代应用实测结果。
可以用简单模型检查收益是否合理。设原程序耗时中可被优化的计算比例为 p,该部分局部加速倍数为 s,忽略新增开销时,总加速比为 1/((1-p)+p/s)。如果 p=0.4、s=4,总加速比约为 1.43。若还增加布局转换和分派检查,收益会进一步下降。这个模型用于建立上限直觉,不能把车道数直接代入 s,也不能预测带宽已经饱和后的真实性能。
选型文档还应写明通过条件。正确性涵盖尾部、重叠契约和数值误差;兼容性涵盖指令支持与回退路径;性能涵盖典型规模、极端小批次和完整数据链。若热点并未占据足够时间,保留简单实现可能更合适。若优化明显有效,则记录编译器版本、布局约束和验证输入,使后续维护者知道哪些改动可能破坏收益。
9 从 SIMD 走向调度深入(S23)
到这里,向量化解决的是某个执行线程如何成批处理数据。但真实服务器上还运行采集线程、计算线程、日志进程和后台任务。即使内核已经使用 SIMD,线程何时获得 CPU、是否被中断或迁移、其他任务是否竞争缓存和带宽,仍会影响用时。微基准的稳定中位数不能直接代表生产请求的尾延迟。
把实验中的 CPU 使用率与调度现象分开理解很有帮助。线程等待网卡输入时,向量单元无事可做;线程处于可运行状态却没有得到 CPU 时,再宽的向量也不会执行。多个计算线程同时扫描大数组,可能更快耗尽共享内存带宽。此时增加线程数量或继续加宽向量,未必能提高吞吐量,还可能扩大用时波动。
例如,把大数组相加任务平均分给 1、2、4 个工作线程,每个线程仍使用相同的 SIMD 内核,并负责互不重叠的连续区间。如果从 2 个线程增加到 4 个线程后,总吞吐量几乎不变,而每个线程处理同样元素的时间变长,就应检查共享带宽是否饱和。向量装载需要等待数据到达,增加计算车道不能消除这个等待。比较时应固定总元素数,记录总完成时间;若同时把数据量扩大 4 倍,就无法直接据此判断并行加速比。
区间划分还可能引入伪共享。假设机器的缓存行为 64 字节,1 行能容纳 16 个 float;若两个线程的输出分界落在同一行内,它们虽然写不同元素,仍可能争夺该行的写权限。反复处理短区间时,这种缓存一致性开销可能掩盖 SIMD 收益。可让输出基址与分界共同满足缓存行边界,或把不足整行的末尾交给单个线程,再比较用时。边界按实际缓存行大小设计,不能把向量的 32 字节宽度当成缓存行大小。
在双路服务器上,线程运行位置与数据所在内存节点也可能影响访问代价;在有功耗约束的小型开发板上,持续高负载可能改变稳定频率。这些都提醒我们记录机器形态和运行环境,但本课不展开节点分配策略、调度算法或功耗管理内部机制。先确认向量化本身正确有效,再把仍未解释的等待和波动交给更合适的分析层。
进入 S23 时,可携带这样的观察记录:固定输入下内核耗时是多少,完整任务中等待占多少,开启多个工作线程后吞吐量如何变化,以及高分位延迟是否变差。调度深入将继续研究就绪任务怎样获得处理器时间、抢占怎样影响执行,以及如何解释竞争。SIMD 的结论仍然保留:把每次获准运行的时间用得更有效,但不替代系统对运行机会的安排。
放进方案里
为采集服务器设计温度校准模块时,可把向量化写成有条件的实现选项。输入为连续 float 温度数组,输出为独立数组,典型批次为 65536 个元素;接口要求输入输出有效且互不重叠,长度允许为 0,尾部采用标量循环。部署清单明确 CPU 指令能力,提供通用基线,并在支持的机器上选择向量版本。不要把主板形态相同当成指令兼容的依据。
证据栏记录编译器版本、编译参数、CPU 型号、内存配置和数组规模。分别测试小批次、典型批次与超过缓存的大批次,报告正确性、内核中位用时、波动及完整处理链用时。有效吞吐量按显式读写字节计算,并注明它不是实测主内存流量。若输入来自 PCIe 网卡,还要记录接收等待和丢包情况,避免把输入受限误判为计算不足。
布局栏说明原始记录是否先转换成字段数组。需要转换时,把解析、分配、复制和峰值内存一起计入,比较单次使用与多阶段复用。数值栏规定浮点结果允许的误差,未经确认不放宽运算规则。兼容性测试覆盖基线与向量路径,边界输入覆盖空数组、整组长度和剩余元素,部署前验证能力检测不会选择不支持的指令。
验收标准由实际业务预算决定,例如完整链路处理时间至少下降 15%,同时满足既定延迟和内存上限;这里的比例是项目门槛,不是 SIMD 的承诺。若只改善内核却达不到完整链路门槛,就记录瓶颈证据并暂缓增加实现分支。保留回退开关和可重复测量方法,让后续 CPU、编译器或数据布局变化时能够重新评估。
10 小结:下一步进程调度深入(S23)
SIMD 将相同操作作用于多个元素,收益取决于独立性、数据布局、指令支持和供数速度。自动向量化值得先试,intrinsic 适合有证据支持的热点;对齐、尾部和浮点规则属于正确性条件。比较实现时,既要看内核,也要看包含输入与转换的完整路径。
自测:256 位向量能容纳多少个 32 位元素?长度为 19 时如何安全收尾?为什么字段数组可能改善向量化?为什么内核快 4 倍,程序却只快约 1.43 倍?能解释这些问题,就可以带着“计算已加速,等待在哪里”的问题进入 S23,继续学习进程调度深入。