MSVC 构建工具 v14.51 中的 C++ 性能改进

微软技术分享
优质创作者: 编程框架技术领域
领域专家: 操作系统技术领域
2026-03-30 21:01:41

微软 C++ 团队对优化质量进行了重大改进,我们很自豪能在微软 C++(MSVC)构建工具 v14.51 版本中与大家分享这些成果。我们将通过两个基准测试来展示相较于 MSVC 构建工具 v14.50,这些改进所带来的效果。

我们的首个基准测试是SPEC CPU® 2017,它涵盖了各类软件,在整个计算行业中得到广泛认可。该基准常被用于评估计算机硬件,但我们此次并未进行这方面的评估。我们关注x64和arm64架构的性能表现,但只会分享两种编译器版本之间的相对性能。我们在两种配置下评估编译器的性能:一是微软Visual Studio(VS)默认的构建选项(主要为/O2 /GL),二是启用了配置文件引导优化(PGO)的配置。总体而言,两个目标架构搭配两种配置,意味着需要追踪四项结果:{x64、arm64} × {VS默认配置、PGO配置}。下表展示了MSVC Build Tools v14.51相较于v14.50的性能提升。由于结果仅包含C和C++基准测试,未涵盖Fortran基准测试,因此这些结果并未完全符合SPEC CPU® 2017的运行与报告规则,应将其视为估算值

测试套件MSVC 配置x64Arm64
SPECSpeed®2017_int_base(est.)峰值(PGO)快5.0%快6.5%
SPECSpeed®2017_int_base(est.)VS 默认设置快4.3%快4.4%

 

SPEC®、SPEC CPU® 和 SPECspeed® 是标准性能评估公司(SPEC)的商标。

我们的第二个基准测试是CitySample,这是一个基于虚幻引擎的游戏演示程序。CitySample 会记录帧率、游戏线程时间以及渲染线程时间的统计数据(最小值、最大值、平均值)。这些数值以毫秒为单位,且波动较大,因此我们展示的是在 Xbox Series X 上连续运行十次后得出的最小值与最大值。编译器和链接器的选项保持与 CitySample 的默认设置一致,数值越低表现越好。

编译器帧时间范围(毫秒)渲染线程时间范围(毫秒)游戏线程时间范围(毫秒)
MSVC v14.4434.40-34.498.17-8.5317.68-18.29
MSVC v14.5034.37-34.498.00-8.3917.44-18.18
MSVC v14.5134.30-34.357.73-7.9517.34-18.03

你可能有一些对你而言很重要的不同基准,我们很乐意了解。如果你有关于实际应用的性能反馈,我们会在开发者社区接受建议和错误报告。

结合这些背景,我们来看看我们所做优化的具体示例。其中部分优化已在 14.50 版本的编译器中上线,但所有优化均已包含在 14.51 版本中。

新型SSA循环优化器

总体而言,编译器执行两类循环优化:一类是修改循环控制流结构的优化,如循环展开、循环剥离、循环解开关;另一类是修改循环内数据操作的优化,如不变量外提、强度削弱、标量替换。在14.50课程中,我们已将后一类循环优化替换为以静态单赋值(SSA)形式为核心的新型优化。静态单赋值(SSA)是许多编译器采用的一种表示形式,因为它能简化编译器传递的编写工作。

替换循环优化器是一项耗时数年的大型工程,但出于以下原因此举十分必要:

  • 可测试性:传统循环优化器是数十年前作为一个整体转换功能实现的,这使得它难以理解、修改、调试和测试。其子阶段代码未进行合理组织,无法支持单独运行。
  • 吞吐量:它不像其他新的 MSVC 优化那样使用 SSA。相反,它基于表达式的哈希值以文本方式识别它们。这种较老的方法需要重新计算各种数据结构和位向量,这通常会占编译时间的 3% 至 5%。
  • 质量问题:多年来,我们收到了多份可追溯至传统循环优化器的缺陷报告。修复这些问题需要耗费大量时间,而且有时由于没有更好的选择,只能采用次优的方式进行修复。例如,有时我们会在早期的编译器转换中加入一种临时解决方案,以确保传统循环优化器永远不会遇到它认为有问题的输入。
  • 完整性:传统循环优化器无法处理某些循环形式。例如,它可以处理递增计数的循环,但无法处理递减计数的循环。

基于这些局限性,新循环优化器的目标如下:

  • 采用静态单赋值(SSA)形式,与大多数现代编译器以及微软Visual C++(MSVC)的其他较新优化方案保持一致。
  • 提供一个可扩展的优化框架,以便后续可以轻松添加新的循环优化功能。
  • 提供运行各个子阶段的能力,从而实现对这些子阶段的单独测试。

基本设计是处理所有循环,从最内层到最外层,并执行转换,直到达到固定点或最大迭代次数。这些转换仅包含传统循环优化器中已有的功能;替换项目本身就足够复杂,无需引入新功能导致的功能蔓延。考虑到可扩展性,新项目完成后可以再添加新功能。

主要挑战在于循环优化器是编译器中最复杂且对性能敏感的部分之一。由于存在一个对整个函数进行操作的地址模式构建子阶段,它会影响所有循环,甚至循环外的部分代码。鉴于其对代码结构的重大影响,修改循环优化器可能会在编译器内部的下游环节引发不同的、有时是意想不到的行为。再加上 MSVC 所经历的严格测试——包括数千个回归测试、大型真实代码项目、行业标准基准测试以及微软自研软件,这就形成了一种需要进行大量调试的情况。试想一下,某个故障仅在 Windows 驱动程序的运行时才会出现。

另一个复杂情况是替换必须一次性完成。虽然新循环优化器的各个子步骤都已单独搭建并测试,但旧循环优化器只能一次性禁用。因此,即便新优化器完成后,我们仍面临新优化器启用、旧优化器禁用这一全新测试场景。为完善这一测试场景所做的最终工作量十分巨大,尤其是在性能调优方面。

截至14点50分,新的循环优化器已为所有目标启用。启用该优化器修复了23个独特的编译器漏洞,涵盖崩溃到静默生成错误代码等问题。5750行新循环优化器代码(其中750行与编译器其他模块共享)替换了15500行旧版循环优化器代码。按预期,为避免进一步增加项目复杂度,此次更新未带来性能提升也未出现性能倒退。编译吞吐量提升了2.5%,这一结果在预期之中。

新的 SLP 向量化器

我们还继续扩展了新的SLP向量化传递。SLP是**超字级并行**(Superword-Level Parallelism)的缩写,有时也被称为**块级向量化**。SLP将相似的独立标量指令打包成一条SIMD指令。SLP通常与**循环向量化**形成对比,后者是编译器对整个循环体进行向量化。而通过SLP,向量化可以在任意位置发生,无需位于循环内部。以下是一个基于Arm64架构的示例:

// Compile with /O2 /Qvec-report:2 and look for "block vectorized"
void slp(int* a, const int* b, const int* c) {
    // Order doesn't matter as long as there are no data dependencies
    // between these statements. If all loads happen before any store,
    // then there is no need to worry about pointer aliasing.

    const int a0 = b[0] + c[0];
    const int a2 = b[2] + c[2];
    const int a1 = b[1] + c[1];
    const int a3 = b[3] + c[3];

    a[0] = a0;
    a[2] = a2;
    a[1] = a1;
    a[3] = a3;
}

向量化后,会生成如下代码:

    ldr    q17,[x1]
    ldr    q16,[x2]
    add    v16.4s,v17.4s,v16.4s
    str    q16,[x0]

MSVC 有一个传统的 SLP 向量化通道,它是作为循环向量化器的一部分实现的。这在当时是一种权宜的实现选择,便于重复使用向量化基础设施,但从长期来看并不合理。我们借助 SSA 工具将新的 SLP 向量化通道与循环向量化器分开实现。我们最初的目标并非替换旧通道,而是优先填补旧通道的不足,因为两个通道可以共存。长期目标是移除旧通道,但目前它仍需处理一些新通道尚未覆盖的场景。

新的补丁弥补了与小于或大于目标宽度的向量大小相关的空白。我们先解释较大的情况。例如,如果目标架构仅支持128位向量,那么为该目标编译时,MSVC 内部无法表示大于128位的向量。SLP 向量化在处理更多值时效果更佳,因此允许为128位目标临时创建超大型的256位或512位向量,之后再转换为128位向量,这可能会更具优势。举个具体例子,MSVC 最初无法在 Arm64 上表示 i32x8 向量,但具备该能力后,它就能处理这个示例了:

// Compile with /O2 /Qvec-report:2 and look for "block vectorized"
#include <cstdint>

void oversized(uint16_t* __restrict a, uint16_t* __restrict b) {
    a[0] = static_cast<uint32_t>(b[0]) * 0x7F123456 >> 2;
    a[2] = static_cast<uint32_t>(b[2]) * 0x7F123456 >> 2;
    a[1] = static_cast<uint32_t>(b[1]) * 0x7F123456 >> 2;
    a[4] = static_cast<uint32_t>(b[4]) * 0x7F123456 >> 2;
    a[3] = static_cast<uint32_t>(b[3]) * 0x7F123456 >> 2;
    a[6] = static_cast<uint32_t>(b[6]) * 0x7F123456 >> 2;
    a[7] = static_cast<uint32_t>(b[7]) * 0x7F123456 >> 2;
    a[5] = static_cast<uint32_t>(b[5]) * 0x7F123456 >> 2;
}

这会在编译器内部生成以下中间表示(IR),且是经过 SLP 处理之后的结果:

tv1.i16x8 = IV_VECT_DUP 0x7F123456
tv2.i16x8 = IV_VECT_LOAD b
tv3.i32x8 = IV_VECT_CONVERT tv2
tv4.i32x8 = IV_VECT_MUL tv3, tv1
tv5.i32x8 = IV_VECT_SHRIMM tv4, 0x2
tv6.i16x8 = IV_VECT_CONVERT tv5
            IV_VECT_STORE a, tv6

随后新的合法化阶段会将该中间表示(IR)转换为目标架构支持的实际单指令多数据(SIMD)指令。Arm64 架构已支持超大向量,其他架构仍在开发中。

超大向量是优化 SLP 中选择操作所必需的。请看这个示例:

void oversized_select(int* __restrict a, const int* __restrict b) {
    a[0] = b[0] + b[5];
    a[1] = b[1] - b[4];
    a[2] = b[2] + b[7];
    a[3] = b[3] - b[6];
    a[4] = b[4] + b[1];
    a[5] = b[5] - b[0];
    a[6] = b[6] + b[3];
    a[7] = b[7] - b[2];
}

如果不进行选择优化,SLP 优化后的中间表示大致如下:

tv1.i32x8 = IV_VECT_LOAD b
tv2.i32x8 = IV_VECT_PERMUTE tv1, 5, 4, 7, 6, 1, 0, 3, 2
tv3.i32x8 = IV_VECT_ADD tv1, tv2
tv4.i32x8 = IV_VECT_SUB tv1, tv2
tv5.i32x8 = IV_VECT_SELECT tv3, tv4, 0, 1, 0, 1, 0, 1, 0, 1
            IV_VECT_STORE a, tv5

请注意,我们计算了一个 i32x8 加法和一个 i32x8 减法。按照现有方式,i32x8 加法会拆分为两个 i32x4 加法,i32x8 减法会拆分为两个 i32x4 减法。最终的汇编代码如下所示:

    ldp         q18,q20,[x1]
    rev64       v17.4s,v20.4s
    rev64       v19.4s,v18.4s
    add         v16.4s,v18.4s,v17.4s
    sub         v17.4s,v18.4s,v17.4s
    ext8        v16.16b,v16.16b,v16.16b,#0xC
    trn2        v18.4s,v16.4s,v17.4s
    add         v16.4s,v20.4s,v19.4s
    sub         v17.4s,v20.4s,v19.4s
    ext8        v16.16b,v16.16b,v16.16b,#0xC
    trn2        v16.4s,v16.4s,v17.4s
    stp         q18,q16,[x0]
    ret

额外的加法、减法和洗牌指令会向向量化代码增加标量代码中不存在的开销。不过,如果我们仔细重新排列数值,使 IV_VECT_SELECT 成为无操作指令,就能从最终的二进制代码中移除一次 i32x4 加法和一次 i32x4 减法。

tv1.i32x8 = IV_VECT_LOAD b
tv2.i32x8 = IV_VECT_PERMUTE b, 0, 2, 4, 6, 1, 3, 5, 7
tv3.i32x8 = IV_VECT_PERMUTE b, 5, 7, 1, 3, 4, 6, 0, 2
tv4.i32x8 = IV_VECT_ADD tv2, tv3
tv5.i32x8 = IV_VECT_SUB tv2, tv3
tv6.i32x8 = IV_VECT_SELECT tv4, tv5, 0, 0, 0, 0, 1, 1, 1, 1
tv7.i32x8 = IV_VECT_PERMUTE tv6, 0, 4, 1, 5, 2, 6, 3, 7
            IV_VECT_STORE a, tv7

这看起来是更多的中间表示(IR),因为现在出现了 IV_VECT_PERMUTE 指令,但最终的二进制文件实际上更小且运行速度更快:

    ldp         q20,q16,[x1]
    uzp2        v17.4s,v16.4s,v20.4s
    uzp1        v18.4s,v20.4s,v16.4s
    uzp2        v19.4s,v20.4s,v16.4s
    uzp1        v16.4s,v16.4s,v20.4s
    add         v18.4s,v18.4s,v17.4s
    sub         v16.4s,v19.4s,v16.4s
    zip1        v17.4s,v18.4s,v16.4s
    zip2        v16.4s,v18.4s,v16.4s
    stp         q17,q16,[x0]
    ret

现在回到更小的案例。此前,SLP 仅在序列中的最后几条指令(通常是一系列存储指令)以完整向量宽度起始时才考虑向量化(之后若后续发现更多指令,可缩小向量宽度)。作为超大向量的补充,SLP 现在还支持在 x64 架构上以更小的向量尺寸进行向量化。例如,以下这个 i16x4 加载-移位-存储操作现已实现向量化:

#include <cstdint>

void test_halfvec_1(int16_t *s) {
    s[0] <<= 1;
    s[1] <<= 1;
    s[2] <<= 1;
    s[3] <<= 1;
}
    movq    xmm0, QWORD PTR [rcx]
    psllw   xmm0, 1
    movq    QWORD PTR [rcx], xmm0

此外,SLP 在所有目标平台上查找相似指令序列的表现更优。请看这个示例:

#include <cstdint>

void test_halfvec_2(int16_t *s) {
    s[0] += 1; // this initial op prevented SLP vectorization

    // this block should be SLP vectorized even though it doesn't fill an entire vector
    s[1] <<= 1;
    s[2] <<= 1;
    s[3] <<= 1;
    s[4] <<= 1;

    s[5] += 1; // trailing op should not prevent SLP vectorization
}

此前,SLP 会尝试对由 s[0]、s[1]、s[2] 和 s[3] 组成的序列进行向量化,若失败,重要的是,它在后续的 SLP 迭代中就不再考虑这些元素了。现在,SLP 会改用 s[1]、s[2]、s[3] 和 s[4] 再次尝试。

    movq    xmm0, QWORD PTR [rcx+2]
    inc WORD PTR [rcx]
    inc WORD PTR [rcx+10]
    psllw   xmm0, 1
    movq    QWORD PTR [rcx+2], xmm0

SROA 改进

聚合体的标量替换(SROA)是一种经典的编译器优化技术,它会将未取地址的结构体和类的字段替换为标量变量。这些标量变量随后会成为寄存器分配的候选对象,同时也适用于所有标量相关的优化,包括常量传播、复制传播、死代码消除等。

我们对SROA进行了重大改进。其中一个SROA步骤是决定将哪些结构体赋值替换为逐字段赋值。我们将这一步骤称为解包。下面我们来看看解包功能的多项改进。

通过间接访问进行赋值

最重要的改进是支持对更多涉及间接寻址的结构体赋值进行解包。在做出这一修改之前,只有当结构体包含两个浮点数或两个双精度浮点数时,间接结构体赋值才会被解包。本质上,此前的解包功能仅针对用于复数的结构体。移除这一限制后,以下示例得到了优化:

struct S {
    int i;
    int j;
    float f;
};

int test1(S* inS) {
   S localS = *inS;
   return localS.i;
}

在最近的更改之前,我们生成的代码是:

    sub     rsp, 24
    movsd   xmm0, QWORD PTR [rcx]
    movsd   QWORD PTR localS$[rsp], xmm0
    mov     eax, DWORD PTR localS$[rsp]
    add     rsp, 24
    ret     0

现在,解包操作和后续的优化将其简化为:

    mov     eax, DWORD PTR [rcx]
    ret     0

更大的结构体

我们将解包限制从 32 字节提高到了 64 字节。这里有一个简单的例子可以说明这一点的作用:

bool flag;

struct S {
    int i1;
    int i2;
    int i3;
    int i4;
    int i5;
    int i6;
    int i7;
    int i8;
    int i9;
};

int test2() {
   S localS1;
   S localS2;
   localS1.i1 = 1;
   localS2.i1 = 1;

   S localS3 = localS1;
   if (flag) localS3 = localS2;

   return localS3.i1;
}

当结构体解包的限制为32字节时,我们为 test2 生成了以下代码:

    sub     rsp, 136
    cmp     BYTE PTR ?flag@@3_NA, 0
    mov     DWORD PTR localS1$[rsp], 1
    movups  xmm1, XMMWORD PTR localS1$[rsp]
    mov     DWORD PTR localS2$[rsp], 1
    mov     eax, DWORD PTR localS2$[rsp]
    jne     SHORT $LN2@test2
    movd    eax, xmm1
$LN2@test2:
    add     rsp, 136
    ret     0

将限制提高到 64 字节后,结构体解包及后续优化后的结果如下:

    mov     eax, 1
    ret     0

联合体

现在,当仅使用联合中某一个重叠字段时,结构体解包功能可对联合进行处理。例如:

bool flag;

struct S {
    int i1;
    int i2;
    int i3;
    int i4;
    int i5;
};

union U {
    float f;
    S s;
};

int test3() {
   U localU1;
   U localU2;

   float f1 = localU1.f;
   float f2 = localU2.f;

   localU1.s.i1 = 1;
   localU2.s.i1 = 1;

   U localU3 = localU1;
   if (flag) localU3 = localU2;

   return localU3.s.i1;
}

联合体的浮点字段在源代码中被使用,但对 f1 和 f2 的赋值是无效代码,会在解包之前被消除。现在解包过程会识别出由于字段 f 未被使用,联合体的其余部分可以像普通结构体一样进行解包。而之前生成的代码为:

    sub     rsp, 56                                 ; 00000038H
    cmp     BYTE PTR ?flag@@3_NA, 0                 ; flag
    mov     DWORD PTR localU1$[rsp], 1
    movups  xmm0, XMMWORD PTR localU1$[rsp]
    mov     DWORD PTR localU2$[rsp], 1
    mov     eax, DWORD PTR localU2$[rsp]
    jne     SHORT $LN2@test3
    movd    eax, xmm0
$LN2@test3:
    add     rsp, 56                                 ; 00000038H
    ret     0

现在它被简化为:

    mov     eax, 1
    ret     0

放宽地址取址限制

如果结构体的任意一个地址被获取,我们就不会对结构体赋值进行解包。请看以下示例:

struct S {
    int i1;
    int i2;
};

int bar(S* s);

int foo() {
    S s1;
    S s2;

    s1.i1 = 5;
    s1.i2 = 6;
    s2 = s1;

    int result = s2.i1;

    bar(&s2);

    return result;
}

在最近的更改之前,我们没有对 s2 = s1 结构体赋值进行解包,因为 s2 被取了地址。我们生成了以下代码:

    sub     rsp, 40
    mov     DWORD PTR s1$[rsp], 5
    mov     DWORD PTR s1$[rsp+4], 6
    mov     rcx, QWORD PTR s1$[rsp]
    mov     QWORD PTR s2$[rsp], rcx
    lea     rcx, QWORD PTR s2$[rsp]
    call    ?bar@@YAHPEAUS@@@Z
    mov     eax, 5
    add     rsp, 40
    ret     0

借助改进的解包功能,我们生成了这段避免结构体复制的代码:

    sub     rsp, 40
    lea     rcx, QWORD PTR s2$[rsp]
    mov     DWORD PTR s2$[rsp], 5
    mov     DWORD PTR s2$[rsp+4], 6
    call    ?bar@@YAHPEAUS@@@Z
    mov     eax, 5
    add     rsp, 40
    ret     0

带强制类型转换字段的结构体赋值解包

如果某个字段通过强制类型转换被用作另一种类型,我们就不会进行解包操作。例如:

struct S {
    long long l1;
    long long l2;
};

void bar (int i);

long long foo(S *s1) {
    S s2 = *s1;
    bar((int)s2.l1);
    return s2.l1 + s2.l2;
}

未进行解包,因为 s2.l1 同时被用作 int 类型和 long long 类型。此限制已被移除。之前生成的代码如下:

    push    rbx
    sub     rsp, 48
    movaps  XMMWORD PTR [rsp+32], xmm6
    movups  xmm6, XMMWORD PTR [rcx]
    movq    rbx, xmm6
    mov     ecx, ebx
    call    ?bar@@YAXH@Z
    psrldq  xmm6, 8
    movq    rax, xmm6
    movaps  xmm6, XMMWORD PTR [rsp+32]
    add     rax, rbx
    add     rsp, 48
    pop     rbx
    ret     0

现在我们能够消除结构体拷贝了:

    mov     QWORD PTR [rsp+8], rbx
    push    rdi
    sub     rsp, 32
    mov     rdi, QWORD PTR [rcx]
    mov     rbx, QWORD PTR [rcx+8]
    mov     ecx, edi
    call    ?bar@@YAXH@Z
    lea     rax, QWORD PTR [rbx+rdi]
    mov     rbx, QWORD PTR [rsp+48]
    add     rsp, 32
    pop     rdi
    ret     0

源结构体偏移量非零时的结构体赋值解包

在本示例中,s2 = t.s 结构体赋值的源是类型为 S 的结构体,该结构体在结构体 T 中以非零偏移量封闭:

struct S {
    int i;
    int j;
    int k;
};

struct T {
    int l;
    S s;
};

int foo(S* s1) {
    T t;
    t.s.i = 1;
    t.s.j = 2;
    t.s.k = 3;

    S s2 = t.s;
    *s1 = s2;

    return s1->i + s1->j + s1->k;
}

此前不允许对这种情况进行解包,因此我们生成了以下代码:

    sub     rsp, 24
    mov     DWORD PTR t$[rsp+4], 1
    mov     DWORD PTR t$[rsp+8], 2
    movsd   xmm0, QWORD PTR t$[rsp+4]
    movsd   QWORD PTR [rcx], xmm0
    mov     DWORD PTR [rcx+8], 3
    mov     eax, DWORD PTR [rcx+8]
    add     eax, DWORD PTR [rcx+4]
    add     eax, DWORD PTR [rcx]
    add     rsp, 24
    ret     0

启用解包后,我们可以消除结构体复制,并通过常量传播计算出结果:

    mov     DWORD PTR [rcx], 1
    mov     eax, 6
    mov     DWORD PTR [rcx+4], 2
    mov     DWORD PTR [rcx+8], 3
    ret     0

带间接访问的结构体赋值重打包

解包的对偶操作是打包。上述示例展示了解包,但有时解包并不会简化代码。为解决这一问题,我们设置了打包阶段,该阶段可移除由解包生成的字段赋值,或移除源代码中原本就存在的字段赋值。我们对打包功能进行了改进,使其能在源对象或目标对象通过指针间接访问的情况下正常工作。例如:

struct S {
    int i1;
    int i2;
    int i3;
    int i4;
};

void bar(S* s);

void foo(S* s1) {
    S s2;

    s2.i1 = s1->i1;
    s2.i2 = s1->i2;
    s2.i3 = s1->i3;
    s2.i4 = s1->i4;

    bar (&s2);
}

在我们生成这段代码之前:

    sub     rsp, 56
    mov     eax, DWORD PTR [rcx]
    mov     DWORD PTR s2$[rsp], eax
    mov     eax, DWORD PTR [rcx+4]
    mov     DWORD PTR s2$[rsp+4], eax
    mov     eax, DWORD PTR [rcx+8]
    mov     DWORD PTR s2$[rsp+8], eax
    mov     eax, DWORD PTR [rcx+12]
    lea     rcx, QWORD PTR s2$[rsp]
    mov     DWORD PTR s2$[rsp+12], eax
    call    ?bar@@YAXPEAUS@@@Z
    add     rsp, 56
    ret     0

现在我们可以重新打包(使用 /GS- 选项)并生成体积小得多的代码了:

    sub     rsp, 56
    movups  xmm0, XMMWORD PTR [rcx]
    lea     rcx, QWORD PTR s2$[rsp]
    movups  XMMWORD PTR s2$[rsp], xmm0
    call    ?bar@@YAXPEAUS@@@Z
    add     rsp, 56
    ret     0

我们从上述 SROA 优化中看到了全面的性能提升,包括 CitySample 渲染线程耗时减少 1.9%、CitySample 游戏线程耗时减少 1.27%,同时 gsl::span 也得到了更优的优化。

提升向量化器的指针重叠检查

向量化循环有时会包含编译器插入的指针重叠检查,以确保从潜在别名内存区域加载时的正确性。如果运行时检查检测到指针重叠,则会使用标量代码;如果不存在重叠,则使用向量代码。若这些检查位于内层循环中,可能会产生较高开销。我们新增了一项功能,在合法的情况下将内层循环的指针重叠检查提升到外层循环。这种提升减少了每次迭代的开销,从而提高了向量化循环的性能。

即使对于内层循环中的单次重叠检查,优化也必须考虑到该检查在循环迭代过程中的多个动态实例。提升后的检查必须在内层循环开始前覆盖所有这些实例,方法是要么计算所有实例,要么保守地测试原始范围的超集。

逻辑或转按位或

由于 C++ 语言的短路求值规则,逻辑或表达式 A || B 通常会被转换为两个条件分支,而不会生成实际的或运算指令。一种优化方法是通过或运算指令合并 A 和 B 的真值来避免分支。但前提是原表达式的正确性不能依赖于短路行为。例如,(a == 0 || (b/a > 5)) 就依赖短路行为来避免出现错误。

考虑:

return A || B;

未进行优化时,编译器生成的代码本质上是:

temp = false;
if (A) {
    temp = true;
} else if (B) {  // not evaluated if A is true
    temp = true;
}
return temp;

但经过优化后,编译器会输出:

return (A | B) != 0;

移位-比较折叠

针对以下代码片段:

void foo(int input) {
    int a = input >> 3;

    if (a >= 1) {
        foo2();
    } else {
        foo3();
    }
}

a 的值在比较之外未被使用,因此我们可以将比较右移 3 位,把 a 融入到比较中:

void foo(int input) {
    if (input >= 8) {
        foo2();
    } else {
        foo3();
    }
}

我们无法对每个比较都进行这种优化。对于右移运算,我们仅能对“小于”和“大于等于”的情况进行优化;对于左移运算,则仅能对“大于”和“小于等于”的情况进行优化。

Neon 代码生成优化

请看这段 C 代码片段:

uint32_t a0 = (pix1[0] - pix2[0]) + ((pix1[4] - pix2[4]) << 16);
uint32_t a1 = (pix1[1] - pix2[1]) + ((pix1[5] - pix2[5]) << 16);
uint32_t a2 = (pix1[2] - pix2[2]) + ((pix1[6] - pix2[6]) << 16);
uint32_t a3 = (pix1[3] - pix2[3]) + ((pix1[7] - pix2[7]) << 16);

在 Arm64 架构上,我们最初通过以下一系列 ARM NEON 指令对其进行了向量化处理:

    ldp         s16,s19,[x0]
    ushll       v18.8h,v16.8b,#0
    ldp         s17,s16,[x2]
    usubl       v16.8h,v19.8b,v16.8b
    ushll       v17.8h,v17.8b,#0
    shll        v16.4s,v16.4h,#0x10
    usubw       v16.4s,v16.4s,v17.4h
    uaddw       v18.4s,v16.4s,v18.4h

ARM NEON 具备可同时扩展并提取源向量寄存器低半部分或高半部分的指令,随后执行算术运算。在这段特定的代码片段中,我们可以将 8 次标量减法运算合并为一条 USUBL 指令,然后对结果的高半部分使用 SHLL2 指令。这一优化后全新的 NEON 指令序列更简短、执行速度更快:

    ldr         d16,[x2]
    ldr         d17,[x0]
    usubl       v17.8h,v17.8b,v16.8b
    shll2       v16.4s,v17.8h,#0x10
    saddw       v18.4s,v16.4s,v17.4h

无条件存储执行

请看这个示例:

int test_1(int a, int i) {
    int mem[4]{0};

    if (mem[i] < a) {
        mem[i] = a;
    }

    return mem[0];
}

通常情况下,这会在存储操作周围生成一个条件分支,但借助无条件存储执行机制,编译器会转而生成一条 CMOV 指令以及一条无条件执行的存储操作:

    cmovge  ecx, DWORD PTR [rdx]
    mov     DWORD PTR [rdx], ecx

在这种特定情况下,该存储操作仅在该函数的部分执行路径上执行,而非所有路径,因此编译器必须谨慎操作以避免引入错误。编译器仅会考虑对以下内存应用该转换:1)编译器可证明未被共享的内存,这可避免引入数据竞争;2)此前已被支配指令访问过的内存,例如if条件中的加载操作,这可避免在原本不存在访问违规的路径上引入访问违规。

无条件存储执行在编译器中已经存在一段时间了。最近的更改是为了保持编译器的支配信息处于最新状态。

AVX 优化改进

我们最近对AVX优化进行了改进。来看这个示例,它原本是从多层通用库代码精简而来,经过大量内联操作后最终形成了这种模式。

// Compile with /O2 /arch:AVX

#include <immintrin.h>

__m256d test(double *a, double *b, double *c, double *d) {
    __m256d temp;

    temp.m256d_f64[0] = *a;
    temp.m256d_f64[1] = *b;
    temp.m256d_f64[2] = *c;
    temp.m256d_f64[3] = *d;

    return _mm256_movedup_pd(_mm256_permute2f128_pd(temp, temp, 0x20));
}

最初,这会生成以下代码

    vmovsd  xmm0, QWORD PTR [rcx]
    vmovsd  xmm1, QWORD PTR [rdx]
    vmovsd  QWORD PTR temp$[rbp], xmm0
    vmovsd  xmm0, QWORD PTR [r8]
    vmovsd  QWORD PTR temp$[rbp+8], xmm1
    vmovsd  xmm1, QWORD PTR [r9]
    vmovsd  QWORD PTR temp$[rbp+16], xmm0
    vmovsd  QWORD PTR temp$[rbp+24], xmm1
    vmovupd ymm0, YMMWORD PTR temp$[rbp]
    vperm2f128 ymm2, ymm0, ymm0, 32
    vmovddup ymm0, ymm2

这里有两处改进。首先,消除了栈往返操作(即使用 temp$ 作为缓冲区的存-取序列)。其次,请注意最终返回值只是将 *a 广播到向量的所有四个通道。通过跟踪向量值在混洗指令序列中的移动方式,编译器现在能够识别出这一情况。此示例的最终结果仅为一条指令:

    vbroadcastsd ymm0, QWORD PTR [rcx]

这项优化让 CitySample 在 Xbox Series X 上的渲染线程耗时缩短了 0.23 毫秒。

单一调用点内联与有限调用点内联

MSVC 传统上对内联采取保守的做法,对更激进的内联方式可能导致的代码体积增加和编译时间延长问题保持谨慎。该编译器提供了 /Ob3 选项以启用更激进的内联,但在使用 /O2 优化选项时默认不开启此选项。我们寻求了一些策略性的改进方案,希望在默认情况下提升程序性能,同时避免代码体积大幅膨胀或编译吞吐量降低。

对性能产生最积极影响且负面影响最小的策略是单调用点内联。编译器会进行全程序分析(/GL),若一个函数仅在一处被调用,则会对其进行内联。由于原始独立函数可被舍弃,函数体仍只有一个实例,因此代码大小的差异可以忽略不计。针对内联次数增加导致构建吞吐量下降的情况,我们实施了构建吞吐量优化。起初,在代码总大小未发生变化的情况下出现吞吐量下降,这或许令人意外。内联会移除一个函数,同时导致另一个函数变大,因此这有可能会加剧任何与函数大小呈非线性关系的编译器算法问题。

通过启用单调用点内联,我们整体提升了性能,且不会对代码大小产生影响,同时构建吞吐量的损耗低于5%。

后来,我们将这一思路扩展到了有限调用点内联,该方法适用于在整个应用程序中仅被调用数次的函数。这种方法需要更谨慎地考虑函数大小以避免代码体积增加。平均来看代码体积会增加2%,但早期的吞吐量优化足以应对这一问题。

分支消除

多年来,我们一直采用一种优化手段,将执行单条简单指令的分支转换为使用“条件移动(cmov)”等指令的无分支代码。这种转换能够提升不可预测分支的性能,对于堆排序和二分查找这类算法而言尤为重要。

我们对MSVC进行了增强,使其能够通过更多级别的嵌套条件实现这一优化。具体而言,我们现在对常见的“堆化”例程进行了优化。这些例程通常包含一个如下所示的分支:

  if (c < end && arr[c-1] < arr[c]) {
    c++;
  }

此前,我们会为上述第二个分支生成如下形式的汇编代码:

    mov   ecx, DWORD PTR [r9+r8*4]
    cmp   DWORD PTR [r9+r8*4-4], ecx
    jge   SHORT $LN4@downheap_p
    inc   edx
$LN4@downheap_p:

我们现在始终执行增量指令,但有条件地存储结果:

    mov     r8d, DWORD PTR [r10+rcx*4-4]
    mov     edx, DWORD PTR [r10+rcx*4]
    cmp     r8d, edx
    lea     ecx, DWORD PTR [r9+1]
    cmovge  ecx, r9d

编译器分析转换分支的可行性,并在有分析信息时参考该信息。

循环解开关联

解循环提取将一个条件从循环中提升出来,这可以实现进一步的优化。例如:

for (int *p = arr; p < arr + N; ++p) {
     if (doWork) {
         rv += *p;
     }
}

可转换为:

if (doWork) {
    for (int *p = arr; p < arr + N; ++p) {
        rv += *p;
    }
} else {
    for (int *p = arr; p < arr + N; ++p) {}
}

可优化为仅:

if (doWork) {
    for (int *p = arr; p < arr + N; ++p) {
        rv += *p;
    }
}

新的变更在于迭代器循环现已取消切换。请看以下示例:

for (auto it = arr.begin(); it != arr.end(); ++it) {
     if (doWork) {
         rv += *it;
     }
}

而现在可以被转换为:

if (doWork) {
    for (auto it = arr.begin(); it != arr.end(); ++it) {
         rv += *it;
    }
}

Memset 和 Memcpy 优化

我们优化了向前传播 memset 值的方式。请参考:

struct S {
    int a;
    int b;
    char data[0x100];
};

S s;

memset(&s, 0, sizeof(s));

s.a = 1;

// use s.b

对 s.a 的写入与 memset 写入了部分相同的内存,这使得编译器无法识别 s.b 的使用可以被 memset 中的零值替代。借助近期的修改,我们能够将 memset 的值向前传播到未被修改的字段上,即便其他字段已被修改。

此外,我们对 memsets 和 memcpy 的内联展开做了两项改进。第一项改进是,当复制的大小不是可用寄存器大小的整数倍时,对尾部位使用重叠复制。例如,在采用此内联代码之前:

    movups      xmm0,xmmword ptr [rdx]
    movups      xmmword ptr [rcx],xmm0
    movsd       xmm1,mmword ptr [rdx+10h]
    movsd       mmword ptr [rcx+10h],xmm1
    mov         eax,dword ptr [rdx+18h]
    mov         dword ptr [rcx+18h],eax
    movzx       eax,word ptr [rdx+1Ch]
    mov         word ptr [rcx+1Ch],ax
    movzx       eax,byte ptr [rdx+1Eh]
    mov         byte ptr [rcx+1Eh],al

但之后我们可以使用:

    movups      xmm0,xmmword ptr [rdx]
    movups      xmmword ptr [rcx],xmm0
    movups      xmm1,xmmword ptr [rdx+0Fh]
    movups      xmmword ptr [rcx+0Fh],xmm1  ; overlaps previous store

第二项改进是在 `arch:AVX` 或更高版本的扩展中直接使用 YMM 寄存器。此前,我们会通过 XMM 拷贝来扩展 memset 和 memcpy 函数,随后的优化步骤又需将这些拷贝合并为 YMM 拷贝。旧方法的弊端在于,若扩展操作发生在循环内部,每次迭代拷贝的字节数会减半,而迭代次数则会翻倍。若直接以 YMM 拷贝的形式进行扩展,循环的迭代次数就能减少;如果迭代次数仅为一次,甚至还可以直接移除循环。

此前,未完成合并的形式如下:

    mov         ecx,4
label:
    lea         rdx,[rdx+80h]
    vmovups     ymm0,ymmword ptr [rax]
    vmovups     xmm1,xmmword ptr [rax+70h]
    lea         rax,[rax+80h]
    vmovups     ymmword ptr [rdx-80h],ymm0
    vmovups     ymm0,ymmword ptr [rax-60h]
    vmovups     ymmword ptr [rdx-60h],ymm0
    vmovups     ymm0,ymmword ptr [rax-40h]
    vmovups     ymmword ptr [rdx-40h],ymm0
    vmovups     xmm0,xmmword ptr [rax-20h]
    vmovups     xmmword ptr [rdx-20h],xmm0
    vmovups     xmmword ptr [rdx-10h],xmm1  ; incomplete YMM merging
    sub         rcx,1
    jne         label

之后,直接展开的结果为:

    mov         ecx,2  ; half as many iterations
label:
    lea         rdx,[rdx+100h]
    vmovups     ymm0,ymmword ptr [rax]
    vmovups     ymm1,ymmword ptr [rax+20h]
    lea         rax,[rax+100h]
    vmovups     ymmword ptr [rdx-100h],ymm0
    vmovups     ymm0,ymmword ptr [rax-0C0h]
    vmovups     ymmword ptr [rdx-0E0h],ymm1
    vmovups     ymm1,ymmword ptr [rax-0A0h]
    vmovups     ymmword ptr [rdx-0C0h],ymm0
    vmovups     ymm0,ymmword ptr [rax-80h]
    vmovups     ymmword ptr [rdx-0A0h],ymm1
    vmovups     ymm1,ymmword ptr [rax-60h]
    vmovups     ymmword ptr [rdx-80h],ymm0
    vmovups     ymm0,ymmword ptr [rax-40h]
    vmovups     ymmword ptr [rdx-60h],ymm1
    vmovups     ymm1,ymmword ptr [rax-20h]
    vmovups     ymmword ptr [rdx-40h],ymm0
    vmovups     ymmword ptr [rdx-20h],ymm1
    sub         rcx,1
    jne         label

带移位寄存器的Arm64位运算

Arm64 指令集包含按位运算(AND、BIC、EON、EOR、ORN、ORR),这些运算可将移位寄存器作为源操作数。此前,我们并非总能利用这一选项,在本可以只生成一条指令的情况下,却输出了两条。例如,而非:

    ror     x8,x8,#5
    eor     x0,x8,x0

我们现在生成:

    eor     x0, x0, x1, ror 5

三元运算符优化

我们针对以下两种情况优化了 C++ 三元运算符。第一种情况的形式如下:

void foo(unsigned x, unsigned y) {
    unsigned a = x < 0x10000 ? 0 : 1;
    unsigned b = y < 0x10000 ? 0 : 1;

    if (a | b) {
        bar();
    }
}

编译器过去会生成:

    xor     r8d, r8d
    cmp     ecx, 65536
    mov     eax, r8d
    setae   al
    cmp     edx, 65536
    setae   r8b
    or      eax, r8d
    jne     void bar(void)

通过先合并 x 和 y,编译器现在会输出:

    or      edi, esi
    cmp     edi, 65536
    jae     void bar(void)

第二种情况的形式如下:

void foo(int size) {
    if (!size) size = 1;
    bar(size ? size : 1);
}

在内部,编译器将其转换为:

void foo(int size) {
    size = size ? size : 1;
    bar(size ? size : 1);
}

但编译器没有移除对大小的冗余检查。问题在于编译器无法识别这些表达式的结果必定非零:

    x != 0 ? x : NonZeroExpression

    x == 0 ? NonZeroExpression : x

我们实现了这些简化,冗余的检查现已被移除。

优化后的复制传播

复制传播可消除不必要的赋值。例如:

    a = ...;
    b = a;
    use(b);

变为:

    a = ...
    use(a);

编译器在优化过程中会频繁插入副本。这些副本大多会在后续被优化掉,但在此期间它们可能会阻碍其他优化的进行。

我们最近扩展了在 SSA 优化器中执行的副本传播功能,以移除跨越控制流边界的副本:

a = ...;
b = a;

if (x) {
    use(b);
}

变为:

a = ...;

if (x) {
    use(a);
}

尽早消除这些额外的副本,能让其他优化措施发挥更大的效果。

循环展开

循环展开可减少循环开销。这会生成体积更大但运行速度更快的代码。在某些情况下,循环可被完全展开,从而不再保留循环结构。我们移除了对完全展开的部分限制,例如要求循环必须是带有单一(自然)退出点的最内层循环。也就是说,MSVC 现在可以对带有多个退出点的循环(也称为跳出循环或搜索循环)以及外层循环进行完全展开。

结论

微软 C++ 团队在 MSVC 构建工具 v14.51 中推出了多项新优化。我们将在开发 14.52 版本的过程中继续聚焦性能提升。特别感谢参与实现这些优化以及撰写本文部分内容早期版本的编译器工程师,他们是亚历克斯·王、阿曼·阿罗拉、克里斯·普利多、艾米丽·鲍、尤金·罗森菲尔德、马特·加德纳、塞巴斯蒂安·佩里特、斯沃鲁普·斯里德哈和特里·马费伊。

MSVC 构建工具 v14.51 目前处于预览阶段,已在Visual Studio 2026 内部测试版中提供。立即试用并在Visual Studio 开发者社区分享你的反馈。

翻译于作者 | Troy Johnson Principal C++ Compiler Engineering Lead

...全文
103 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

6,682

社区成员

发帖
与我相关
我的任务
社区描述
微软技术社区为中国的开发者们提供一个技术干货传播平台,传递微软全球的技术和产品最新动态,分享各大技术方向的学习资源,同时也涵盖针对不同行业和场景的实践案例,希望可以全方位地帮助你获取更多知识和技能。
windowsmicrosoft 企业社区
社区管理员
  • 微软技术分享
  • 郑子铭
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告

微软技术社区为中国的开发者们提供一个技术干货传播平台,传递微软全球的技术和产品最新动态,分享各大技术方向的学习资源,同时也涵盖针对不同行业和场景的实践案例,希望可以全方位地帮助你获取更多知识和技能。

予力众生,成就不凡!微软致力于用技术改变世界,助力企业实现数字化转型。

试试用AI创作助手写篇文章吧