跳转至

cpp of HPC

# HPC 中的 C/C++

约 4973 个字 200 行代码 预计阅读时间 19 分钟

在这一部分中,我们将介绍一些在 HPC 中常用的 C/C++ 编程技巧和实践。这些技巧通常与底层硬件平台高度相关,使用时应注意平台特性。

内存对齐

内存对齐是指将数据对齐至缓存行(通常为 64B)或更大尺度(如 4K 内存页),以提高访问速度和效率。在 HPC 中,内存对齐可以显著提高程序进行内存访问的速度,进而提升程序性能。以下是一些常见的内存对齐技巧:

默认情况,基本数据类型(如 intdouble 等)通常会自动对齐到其大小,而结构体(不论是 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;
}

ab 指向不同对象时,函数返回 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,因此可以将其作为立即数参与加法;但在无法排除 ab 存在别名关系时,仍需重新读取 *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 + 5func1 首先令 a[5] = 0,前五个元素累加得到 15;处理 a[5] 时,*suma[5] 是同一个对象,因此累加值变成 15 + 15 = 30;再加上 78,最终得到 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
epi8epi16epi32epi64 packed integer,以相应位宽解释;操作是否区分符号取决于 intrinsic
epu8epu16epu32epu64 明确表示无符号整数语义,常见于需要区分符号的操作
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 个 floatu 表示地址不要求按 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_tfloat32x4_t 64、128 bit 2、4 个 float
float64x1_tfloat64x2_t 64、128 bit 1、2 个 double,仅适用于支持相应接口的架构
int8x16_tuint8x16_t 128 bit 16 个有符号或无符号 8 bit 整数
int16x8_tuint16x8_t 128 bit 8 个有符号或无符号 16 bit 整数
int32x4_tuint32x4_t 128 bit 4 个有符号或无符号 32 bit 整数
int64x2_tuint64x2_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 浮点元素。

常见后缀包括 s8s16s32s64u8u16u32u64,以及 f16f32f64,分别表示有符号整数、无符号整数和浮点元素。操作名还可能包含额外修饰,例如 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