HPC 中的 C/C++¶
约 4973 个字 200 行代码 预计阅读时间 19 分钟
在这一部分中,我们将介绍一些在 HPC 中常用的 C/C++ 编程技巧和实践。这些技巧通常与底层硬件平台高度相关,使用时应注意平台特性。
内存对齐¶
内存对齐是指将数据对齐至缓存行(通常为 64B)或更大尺度(如 4K 内存页),以提高访问速度和效率。在 HPC 中,内存对齐可以显著提高程序进行内存访问的速度,进而提升程序性能。以下是一些常见的内存对齐技巧:
默认情况,基本数据类型(如 int、double 等)通常会自动对齐到其大小,而结构体(不论是 struct 还是 class)的对齐方式取决于其成员对齐方式的最大值,因此结构体的大小往往大于其成员大小之和。
struct S1 { // 整体对齐到 8 字节, sizeof(S1) = 16
char c; // `c` 会对齐到 1 字节。
int i; // `i` 会对齐到 4 字节。
double d; // `d` 会对齐到 8 字节。
};
对于需要手动指定变量对齐的情况,可以使用 alignas 关键字(C++11 起)或 __attribute__((aligned(64)))(仅限与 GCC 兼容的编译器)来指定变量或类型的对齐方式。具体用法详见 cppreference,以下是一些示例:
alignas(64) int x; // `x` 在内存中对齐到64字节。
struct alignas(64) S2 { // `S` 整体在内存中对齐到64字节。
double x;
double y;
};
S2 s[2]; // `s` 数组中的每个元素都对齐到64字节,因此实际占用的内存为 128 字节。
alignas(64) int y[2]; // `y` 数组中的首个元素对齐到64字节,因此占用空间仍然为8字节
对于在堆上动态分配内存的情况,可以使用 aligned_alloc (C++17 起,C11 起) 来分配对齐的内存。
#include <cstdlib> // C++17 起
#include <stdlib.h> // C11 起
int* p = aligned_alloc(64, sizeof(int) * 100);
assert(reinterpret_cast<uintptr_t>(p) % 64 == 0); // p 保证对齐到 64 字节
数据局部性优化¶
数据局部性优化的原理是 CPU 在程序访问内存的时候,往往会自动将临近的内存数据加载到缓存中,从而如果接下来访问这些数据就会变得更快。为了充分利用 CPU 的这一特性,我们可以通过以下方式优化数据局部性:
数据存储方式优化¶
根据运算场景不同,我们可以灵活选取使用结构体数组 (AoS) 或 数组结构体 (SoA)
struct Point1 {
float x, y, z;
};
Point1 points1[1000]; // 结构体数组 (AoS)
struct Point2 {
float x[1000];
float y[1000];
float z[1000];
};
Point2 points2; // 数组结构体 (SoA)
通常而言,如果需要频繁访问同一结构体的所有成员,使用结构体数组 (AoS) 更加高效;如果仅访问某一特定成员,则使用数组结构体 (SoA) 更加高效。
数据访问模式优化¶
对于固定存储方式的数据,我们还可以通过改变访问顺序来提高数据局部性,常见的一个手法便是循环分块(Loop Blocking)。
// 原始循环
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
A[i][j] = B[i][j] * C[i][j];
// 分块优化(块大小BLOCK)
constexpr int BLOCK = 64; // 假设取块大小 64
for (int ii = 0; ii < N; ii += BLOCK)
for (int jj = 0; jj < N; jj += BLOCK)
for (int i = ii; i < ii + BLOCK; i++)
for (int j = jj; j < jj + BLOCK; j++)
A[i][j] = B[i][j] * C[i][j];
数据访问模式有时候也有利于编译器进行自动向量化(Auto-vectorization),从而进一步提高性能。
if constexpr:编译期分支¶
if constexpr 从 C++17 起引入,用于根据编译期条件选择代码。它的条件必须是能够在编译期求值的布尔表达式;对于某个模板实例,编译器只选择条件成立的分支,另一个分支称为丢弃语句(discarded statement)。
基本语义¶
下面的函数根据类型是否为无符号整数选择不同实现:
#include <type_traits>
template <typename T>
constexpr auto absolute_value(T value) {
if constexpr (std::is_unsigned_v<T>) {
return value;
} else {
return value < 0 ? -value : value;
}
}
static_assert(absolute_value(-5) == 5);
static_assert(absolute_value(5u) == 5u);
实例化 absolute_value<unsigned int> 时,编译器选择第一个分支;实例化 absolute_value<int> 时,编译器选择第二个分支。这个选择发生在模板实例化阶段,不需要在程序运行时检查类型。
if constexpr 与普通 if
普通 if 的两个分支都属于当前程序,由运行时条件决定执行哪一个;模板中的 if constexpr 则为当前模板实例选择一个分支,被丢弃的分支不会参与该实例的代码生成。
以上示例只用于说明分支选择。与常见的有符号整数绝对值实现相同,当 value 是该类型的最小值时,取负结果无法由原类型表示,不应使用这个实现处理该边界值。
泛型编程中的作用¶
if constexpr 不仅可以消除运行时判断,还允许不同类型使用语法完全不同的实现。在模板中,被丢弃分支内依赖模板参数的代码不会为当前实例进行实例化,因此不会仅仅因为它不适用于当前类型而使实例化失败。
下面的函数对整数调用 std::to_string,对其他类型则调用对象的 to_string() 成员函数:
#include <concepts>
#include <string>
template <typename T>
std::string stringify(const T& value) {
if constexpr (std::integral<T>) {
return std::to_string(value);
} else {
return value.to_string();
}
}
std::string number = stringify(42);
实例化 stringify<int> 时,int 没有 to_string() 成员并不构成错误,因为对应分支已被丢弃。若改用普通 if,两个分支通常都必须对当前实例有效,此时 value.to_string() 会导致实例化失败。
丢弃不等于忽略语法错误
被丢弃的分支仍然必须符合 C++ 的基本语法和语义规则。不依赖模板参数、对所有可能实例都必然错误的代码仍可能被编译器诊断;if constexpr 主要避免实例化未选择分支中依赖模板参数的代码。
性能提升的来源¶
if constexpr 的运行时性能价值来自提前专门化:编译器为每组模板参数生成实现时,条件已经确定,因此最终代码通常不需要保留条件判断和条件跳转。
下面对比编译期配置和运行时配置。EnableCounting 是模板参数,因此每个实例是否计数在编译期已经确定:
template <bool EnableCounting>
int process(int value, int& counter) {
if constexpr (EnableCounting) {
++counter;
}
return value * 2;
}
int process_runtime(int value, int& counter, bool enable_counting) {
if (enable_counting) {
++counter;
}
return value * 2;
}
对于 process<false>,计数分支被丢弃,生成代码通常只剩下乘以 2;对于 process<true>,生成代码直接执行计数,不需要先判断开关。process_runtime 必须在同一函数中表达两种行为;如果编译器无法从调用处推断 enable_counting 的值,运行时通常需要进行一次判断。
在 x86-64 平台上,优化后的关键指令可能近似如下(具体结果取决于编译器、优化选项和调用上下文):
process<false>:
lea eax, [rdi+rdi] # 直接计算 value * 2
ret
process<true>:
add DWORD PTR [rsi], 1
lea eax, [rdi+rdi]
ret
process_runtime:
test dl, dl # 运行时检查 enable_counting
je skip_count
add DWORD PTR [rsi], 1
skip_count:
lea eax, [rdi+rdi]
ret
这种编译期分支可能带来以下收益:
- 消除运行时条件判断和条件跳转,避免分支预测失败可能造成的流水线清空。
- 删除未选择分支中的函数调用、数据访问和其他操作。
- 缩短单个模板实例的执行路径,为内联、常量传播和自动向量化创造更有利的条件。
- 在需要静态配置的泛型接口中,避免引入函数指针、虚函数调用或额外的运行时状态。
当分支位于热点循环内、调用频率很高,或者未选择分支包含昂贵操作时,这些收益更加明显。如果判断只执行一次、分支预测十分稳定,或者编译器能够通过内联和常量传播消除普通 if,两者的实际性能差异可能很小。此外,不同模板参数会产生不同实例,过度使用也可能增大二进制体积和指令缓存压力,因此应结合性能分析结果使用。
restrict:指针别名约束¶
在 C 语言中,restrict 是 C99 引入的指针类型限定符。程序员通过它向编译器承诺:在受限指针生效的执行期间,对相关对象的访问会遵守 restrict 的别名规则。编译器据此可以排除某些指针别名(pointer aliasing)的可能,进行更积极的加载消除、寄存器分配和自动向量化。
C++ 标准没有 restrict 关键字,但 GCC 和 Clang 等编译器提供了语义相近的 __restrict 或 __restrict__ 扩展。本节使用适用于 GCC 和 Clang 的 __restrict;可移植程序应通过构建系统或宏针对不同编译器封装该扩展。
Tip
__restrict 本质上是向编译器作出的指针别名承诺:在相关作用域的执行期间,通过该受限指针访问的对象,不会同时通过其他不符合约束的指针路径被访问或修改。编译器因此可以排除潜在的指针别名影响,将中间结果保存在寄存器中,从而避免了赋值必须写回内存,读取需要重新从内存中读取的额外性能开销。
指针别名如何限制优化¶
考虑下面的加法函数:
int add1(int* a, int* b) {
*a = 10;
*b = 12;
return *a + *b;
}
当 a 和 b 指向不同对象时,函数返回 22;当二者指向同一个对象时,第二次写入会覆盖第一次写入,函数返回 12 + 12 = 24。因此,编译器不能在写入 *b 后继续假定 *a 等于 10。
在 x86-64 平台上,-O3 优化后的关键指令可能近似如下:
add1:
mov DWORD PTR [rdi], 10
mov DWORD PTR [rsi], 12
mov eax, DWORD PTR [rdi] # *b 可能修改了 *a,必须重新读取
add eax, 12
ret
编译器知道写入后通过 b 读取的值是 12,因此可以将其作为立即数参与加法;但在无法排除 a 与 b 存在别名关系时,仍需重新读取 *a。
为两个参数添加 __restrict 后,可以提供更强的别名信息:
int add2(int* __restrict a, int* __restrict b) {
*a = 10;
*b = 12;
return *a + *b;
}
对于遵守约束的调用,编译器可以确定对 *b 的写入不会改变通过 a 访问的对象,因此返回值恒为 22:
add2:
mov DWORD PTR [rdi], 10
mov eax, 22 # 无需重新读取 *a 或 *b,减少了一次内存读取
mov DWORD PTR [rsi], 12
ret
具体汇编取决于编译器、优化选项和目标架构,但关键区别在于 add2 的返回值计算不再依赖写入后的内存读取。
违反别名约束¶
如果使用相同地址调用 add2,就违反了 __restrict 所表达的约束:
int value;
int result = add2(&value, &value); // 行为未定义
不能认为该调用一定会像 add1 一样返回 24。编译器已经有权根据 __restrict 的承诺,将返回值优化为常量 22。
不只是指针地址不同
将 restrict 简单理解为“两个指针的地址不相等”并不完整。它约束的是在特定执行期间,对相关对象进行访问所使用的指针表达式及其派生指针。即使两个指针当前数值不同,它们所覆盖的数组区间也可能重叠;反之,仅用于读取的数据在规则中也存在更细致的情况。使用时应保证接口约定的读写区域互不冲突,而不是只比较首地址。
对循环优化的影响¶
下面两个函数都对数组求和,但保存累加结果的位置不同:
#include <cstddef>
#include <cstdint>
void func1(const std::int32_t* a, std::size_t n, std::int32_t* sum) {
*sum = 0;
for (std::size_t i = 0; i < n; ++i) {
*sum += a[i];
}
}
void func2(const std::int32_t* a, std::size_t n, std::int32_t* sum) {
std::int32_t tmp = 0;
for (std::size_t i = 0; i < n; ++i) {
tmp += a[i];
}
*sum = tmp;
}
func1 直接在 *sum 中累加。由于 sum 可能指向数组 a 的某个元素,每次写入 *sum 都可能改变后续迭代将要读取的数据。在不能排除这种别名关系时,编译器通常必须保留循环内的内存写入:
.Lfunc1_loop:
add eax, DWORD PTR [rdi]
add rdi, 4
mov DWORD PTR [rdx], eax # 每次迭代都写回 *sum
cmp rdi, rcx
jne .Lfunc1_loop
func2 则显式使用局部变量 tmp。编译器通常可以将它保存在寄存器中,仅在循环结束后写回一次:
.Lfunc2_loop:
add eax, DWORD PTR [rdi]
add rdi, 4
cmp rdi, rcx
jne .Lfunc2_loop
mov DWORD PTR [rdx], eax # 循环结束后写回一次
一般情况下,编译器不能直接将 func1 转换为 func2,因为二者在输入和输出区域重叠时具有不同的可观察行为。例如:
std::int32_t a[] = {1, 2, 3, 4, 5, 6, 7, 8};
func1(a, 8, &a[5]);
此时 sum == a + 5。func1 首先令 a[5] = 0,前五个元素累加得到 15;处理 a[5] 时,*sum 与 a[5] 是同一个对象,因此累加值变成 15 + 15 = 30;再加上 7 和 8,最终得到 45。如果调用 func2(a, 8, &a[5]),循环会先读取原数组的所有元素,得到 36 后才写入 a[5]。
func1 的结果:45
func2 的结果:36
这说明将 func1 的累加值直接提升到寄存器会改变合法程序的行为。若接口能够保证 a[0, n) 与 sum 指向的对象不重叠,可以使用 __restrict 明确表达这一前置条件:
void sum_array(const std::int32_t* __restrict a, std::size_t n, std::int32_t* __restrict sum) {
*sum = 0;
for (std::size_t i = 0; i < n; ++i) {
*sum += a[i];
}
}
在调用者遵守约束的前提下,编译器可以将累加值保存在寄存器中,并可能进一步对循环进行展开或向量化。此时调用 sum_array(a, 8, &a[5]) 会违反别名约束,不能用于该接口。
Intrinsic Functions:显式使用处理器指令¶
Intrinsic function(内在函数)是编译器提供的特殊接口。它采用类似普通函数调用的语法,表达向量运算、位操作或缓存控制等底层操作;编译器通常会将其内联为一条或少量目标处理器指令,而不产生真正的函数调用。
Intrinsic 介于普通 C++ 与内联汇编之间:
- 与内联汇编相比,不需要手动分配物理寄存器,编译器仍可进行寄存器分配、指令调度和常量传播。
- 与普通标量代码相比,可以更明确地指定数据宽度和操作语义,例如饱和加法、元素重排和掩码访存。
- 代价是代码通常依赖具体的架构和指令集,移植性和可维护性较弱。
Intrinsic 不一定对应一条指令
Intrinsic 描述的是编译器支持的底层操作,而不是函数调用开销的另一种写法。许多 intrinsic 可以直接映射到一条机器指令,但有些会展开为多条指令;最终结果仍取决于编译器、目标架构和上下文。
本节以 x86 的 AVX-512 和 Arm 的 NEON 为例介绍 intrinsic 的类型、命名和基本使用方法。有关 SIMD 的原理、自动向量化与进一步练习,可参考向量化。
x86 架构下的 AVX-512 指令集¶
AVX-512 是 x86 架构的一组 SIMD 指令集扩展,其主要特征包括 512 bit 的 ZMM 向量寄存器和掩码寄存器。AVX-512 由多个功能子集组成,处理器支持部分 AVX-512 功能并不意味着支持所有 AVX-512 指令;下面的基础浮点数组加法只需要 AVX-512 Foundation(AVX-512F)。
x86 SIMD intrinsic 通常通过 <immintrin.h> 引入。常见向量类型如下:
| 类型 | 宽度 | 常见解释 |
|---|---|---|
__m128、__m256、__m512 |
128、256、512 bit | 分别包含 4、8、16 个 float |
__m128d、__m256d、__m512d |
128、256、512 bit | 分别包含 2、4、8 个 double |
__m128i、__m256i、__m512i |
128、256、512 bit | 整数向量,元素宽度由所用 intrinsic 决定 |
__mmask8、__mmask16 等 |
8、16 bit 等 | AVX-512 掩码,每个有效位控制一个 lane |
AVX-512 intrinsic 的名称通常可以拆分为“向量宽度、操作和元素类型”。以 _mm512_add_ps 为例:
_mm512表示操作 512 bit 向量。add表示逐元素加法。ps表示 packed single-precision,即打包的单精度浮点数。
常见类型后缀包括:
| 后缀 | 含义 |
|---|---|
ps |
packed float |
pd |
packed double |
epi8、epi16、epi32、epi64 |
packed integer,以相应位宽解释;操作是否区分符号取决于 intrinsic |
epu8、epu16、epu32、epu64 |
明确表示无符号整数语义,常见于需要区分符号的操作 |
si512 |
作为不指定元素类型的 512 bit 整数数据处理 |
其中,__m512i 只表示一个 512 bit 整数向量,并不记录每个 lane 的类型。相同数据可以由 _mm512_add_epi32 解释为 16 个 32 bit 整数,也可以由其他 intrinsic 解释为不同宽度的元素,因此必须选择与数据布局一致的后缀。
AVX-512 数组加法¶
下面的函数计算 result[i] = a[i] + b[i]。一个 __m512 包含 16 个 float,因此向量循环每次处理 16 个元素:
#include <cstddef>
#include <immintrin.h>
void add_avx512(const float* a, const float* b,
float* result, std::size_t n) {
std::size_t i = 0;
for (; i + 16 <= n; i += 16) {
__m512 va = _mm512_loadu_ps(a + i);
__m512 vb = _mm512_loadu_ps(b + i);
__m512 vr = _mm512_add_ps(va, vb);
_mm512_storeu_ps(result + i, vr);
}
for (; i < n; ++i) {
result[i] = a[i] + b[i];
}
}
这段代码遵循 intrinsic 程序中常见的“加载、计算、存储”结构:
_mm512_loadu_ps从内存加载 16 个float,u表示地址不要求按 64 字节对齐。_mm512_add_ps对 16 对元素分别执行加法。_mm512_storeu_ps将 16 个结果写回内存。- 最后的标量循环处理不足 16 个元素的尾部,防止向量加载越过数组边界。
如果地址可以保证按 64 字节对齐,也可使用 _mm512_load_ps 和 _mm512_store_ps。在无法证明对齐时不能使用对齐版本;地址未满足其要求会导致未定义行为,而不仅仅是性能下降。AVX-512 还可以使用掩码加载和存储处理尾部,但标量尾循环更容易理解,也适用于其他 SIMD 指令集。
使用 GCC 或 Clang 编译时,需要允许编译器生成相应指令,例如:
g++ -O3 -mavx512f example.cpp
-march=native 也可以启用当前编译机器支持的指令集,但生成的程序可能无法在较旧的 x86 处理器上运行。面向不同机器发布程序时,应保留基线实现,并通过运行时分派选择 AVX-512 版本。
Arm 架构下的 NEON 指令集¶
NEON 是 Arm 架构的 SIMD 扩展,在 AArch64 中也称为 Advanced SIMD。NEON 使用 128 bit 向量寄存器;同一寄存器可以按照不同的元素类型和 lane 数量解释。通常通过 <arm_neon.h> 引入。
NEON 的向量类型将元素类型、元素数量和向量宽度直接写入名称:
| 类型 | 向量宽度 | 包含的元素 |
|---|---|---|
float32x2_t、float32x4_t |
64、128 bit | 2、4 个 float |
float64x1_t、float64x2_t |
64、128 bit | 1、2 个 double,仅适用于支持相应接口的架构 |
int8x16_t、uint8x16_t |
128 bit | 16 个有符号或无符号 8 bit 整数 |
int16x8_t、uint16x8_t |
128 bit | 8 个有符号或无符号 16 bit 整数 |
int32x4_t、uint32x4_t |
128 bit | 4 个有符号或无符号 32 bit 整数 |
int64x2_t、uint64x2_t |
128 bit | 2 个有符号或无符号 64 bit 整数 |
例如,float32x4_t 表示由 4 个 32 bit 浮点数组成的向量,总宽度为 128 bit。与 __m512i 不同,NEON 类型名本身明确包含了 lane 的元素类型和数量。
NEON intrinsic 的名称通常采用 v<operation>[q]_<type> 形式。以 vaddq_f32 为例:
v表示向量操作。add表示逐元素加法。q表示 128 bit 的 quadword 向量形式;许多不带q的对应接口操作 64 bit 向量。f32表示 32 bit 浮点元素。
常见后缀包括 s8、s16、s32、s64,u8、u16、u32、u64,以及 f16、f32、f64,分别表示有符号整数、无符号整数和浮点元素。操作名还可能包含额外修饰,例如 l 常用于 widening/long 操作,n 常用于 narrowing 操作,lane 表示使用指定 lane。
NEON 数组加法¶
使用 NEON 实现相同的数组加法时,每个 float32x4_t 向量包含 4 个 float:
#include <arm_neon.h>
#include <cstddef>
void add_neon(const float* a, const float* b,
float* result, std::size_t n) {
std::size_t i = 0;
for (; i + 4 <= n; i += 4) {
float32x4_t va = vld1q_f32(a + i);
float32x4_t vb = vld1q_f32(b + i);
float32x4_t vr = vaddq_f32(va, vb);
vst1q_f32(result + i, vr);
}
for (; i < n; ++i) {
result[i] = a[i] + b[i];
}
}
vld1q_f32加载 4 个float到一个 128 bit 向量。vaddq_f32对 4 对 32 bit 浮点元素分别执行加法。vst1q_f32将 4 个结果写回内存。- 标量循环处理不足 4 个元素的尾部。
与 x86 将 aligned 和 unaligned load 区分为不同 intrinsic 不同,vld1q_f32 不要求传入地址天然按 16 字节对齐;不过实际访存性能仍可能受到地址对齐和缓存边界的影响。
在 AArch64 上,Advanced SIMD 是基础架构的一部分,常规 AArch64 编译目标通常可以直接使用上述基础 NEON intrinsic。交叉编译时可以明确指定目标架构:
aarch64-linux-gnu-g++ -O3 -march=armv8-a example.cpp
32 bit Arm 目标以及 dot product、FP16、BF16 等扩展接口具有额外的架构要求,不能因为基础 NEON 可用就假定所有 <arm_neon.h> 接口都可用。
Intrinsic 的优势¶
上述两个实现具有相同结构,但类型、函数名和每次处理的元素数都依赖目标架构。这也反映了 intrinsic 的主要优势和代价:
- 可以明确表达向量宽度、lane 类型以及普通 C++ 难以表达的饱和、重排、掩码等操作。
- 编译器仍负责物理寄存器分配和指令调度,通常比内联汇编更容易维护和优化。
- 可以在自动向量化失败时直接使用目标指令集提供的能力。
对于数组加法这样的简单循环,现代编译器配合 -O3、合适的目标架构选项和可靠的别名信息,通常能够自动向量化。实际项目应优先编写清晰的标量实现,并检查编译器优化报告;只有在性能分析确认热点、自动向量化结果不理想,或算法确实需要特殊指令时,才应维护手写 intrinsic 版本。
使用 Intrinsic 时必须满足的条件
程序必须正确处理尾部元素、对齐要求和指针别名,并确保运行处理器支持相应指令集(可以通过lscpu命令查看Flags中显示的支持的指令集类型)。
x86 可参考 Intel Intrinsics Guide
Arm NEON 可参考 Arm NEON Intrinsics Reference。