5.4 — as-if 规则和编译时优化

优化简介

在编程中,优化是修改软件使其更高效(例如运行更快或使用更少资源)的过程。优化可以对应用程序的整体性能水平产生巨大影响。

有些类型的优化通常是手动完成的。可以使用一个名为分析器的程序来查看程序各个部分的运行时间,以及哪些部分影响了整体性能。然后程序员可以寻找方法来缓解这些性能问题。由于手动优化很慢,程序员通常专注于进行高层次的改进,这些改进将产生很大的影响(例如选择性能更高的算法、优化数据存储和访问、减少资源利用、并行化任务等…)

其他类型的优化可以自动执行。一个优化另一个程序的程序被称为优化器。优化器通常在低级别工作,寻找通过重写、重新排序或消除语句或表达式来改进它们的方法。例如,当你编写i = i * 2;时,优化器可能会将其重写为i *= 2;i += i;i <<= 1;。对于整数值,所有这些都产生相同的结果,但其中一个可能在给定架构上比其他更快。程序员可能不知道哪种是性能最佳的选择(答案可能因架构而异),但给定系统的优化器会知道。单个低级别优化可能只产生小的性能提升,但它们的累积效应可能导致整体性能的显著提高。

现代 C++ 编译器是优化编译器,这意味着它们能够在编译过程中自动优化您的程序。就像预处理器一样,这些优化不会修改您的源代码文件——相反,它们在编译过程中透明地应用。

关键见解

优化编译器让程序员可以专注于编写可读和可维护的代码,而无需牺牲性能。

因为优化涉及一些权衡(我们将在本课的底部讨论),编译器通常支持多个优化级别,这些级别决定了它们是否优化、优化程度如何以及它们优先考虑哪种优化(例如速度与大小)。

大多数编译器默认不进行优化,因此如果您使用命令行编译器,则需要自行启用优化。如果您使用 IDE,IDE 可能会自动将发布版本配置为启用优化,将调试版本配置为禁用优化。

对于 gcc 和 Clang 用户

有关如何启用优化的信息,请参见0.9 -- 配置您的编译器:构建配置

“好像”规则

在 C++ 中,编译器在优化程序方面有很大的自由度。“好像”规则规定编译器可以随意修改程序以生成更优化的代码,只要这些修改不影响程序的“可观察行为”。

致进阶读者

“好像”规则有一个值得注意的例外:不必要的拷贝(或移动)构造函数调用可以被省略(elided),即使这些构造函数具有可观察行为。我们将在14.15 -- 类初始化和拷贝省略课中讨论这个主题。

现代编译器采用各种不同的技术来有效地优化程序。可以应用哪些技术取决于程序以及编译器和优化器的质量。

相关内容

维基百科列出了编译器使用的具体技术。

一个优化机会

考虑下面的简短程序

#include <iostream>

int main()
{
	int x { 3 + 4 };
	std::cout << x << '\n';

	return 0;
}

输出结果很简单

7

然而,其中隐藏着一个有趣的优化可能性。

如果这个程序完全按照编写的方式编译(没有优化),编译器将生成一个在运行时(程序运行时)计算 3 + 4 结果的可执行文件。如果程序执行一百万次,3 + 4 将被计算一百万次,并产生一百万次结果值 7

因为 3 + 4 的结果永不改变(它总是 7),所以在每次程序运行时重新计算这个结果是浪费的。

编译时求值

现代 C++ 编译器能够完全或部分地在编译时(而不是运行时)评估某些表达式。当编译器在编译时完全或部分评估一个表达式时,这被称为编译时求值

关键见解

编译时求值允许编译器在编译时完成否则将在运行时完成的工作。由于这些表达式不再需要在运行时求值,因此生成的可执行文件更快、更小(代价是编译时间略慢)。

为了说明目的,在本课中,我们将探讨一些利用编译时求值的简单优化技术。然后,我们将在后续课程中继续讨论编译时求值。

常量折叠

编译时求值的原始形式之一被称为“常量折叠”。常量折叠是一种优化技术,编译器将具有字面量操作数的表达式替换为表达式的结果。使用常量折叠,编译器将识别表达式3 + 4具有常量操作数,然后将表达式替换为结果7

结果将等同于以下内容

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << x << '\n';

	return 0;
}

这个程序产生与之前版本相同的输出(7),但生成的可执行文件不再需要在运行时花费 CPU 周期计算 3 + 4

常量折叠也可以应用于子表达式,即使完整表达式必须在运行时执行。

#include <iostream>

int main()
{
	std::cout << 3 + 4 << '\n';

	return 0;
}

在上面的例子中,`3 + 4` 是完整表达式 `std::cout << 3 + 4 << '\n';` 的一个子表达式。编译器可以将其优化为 `std::cout << 7 << '\n';`。

常量传播

以下程序包含另一个优化机会

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << x << '\n';

	return 0;
}

当 `x` 初始化时,值 `7` 将存储在为 `x` 分配的内存中。然后在下一行,程序将再次访问内存以获取值 `7`,以便可以打印它。这需要两次内存访问操作(一次存储值,一次获取值)。

常量传播是一种优化技术,编译器将已知具有常量值的变量替换为它们的值。使用常量传播,编译器会意识到 x 总是具有常量值 7,并将任何使用变量 x 的地方替换为值 7

结果将等同于以下内容

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << 7 << '\n';

	return 0;
}

这消除了程序访问内存以获取 `x` 值的需求。

常量传播可能会产生可以被常量折叠优化的结果

#include <iostream>

int main()
{
	int x { 7 };
	int y { 3 };
	std::cout << x + y << '\n';

	return 0;
}

在这个例子中,常量传播会将 `x + y` 转换为 `7 + 3`,然后可以将其常量折叠为值 `10`。

死代码消除

死代码消除是一种优化技术,编译器会删除可能执行但对程序行为没有影响的代码。

回到之前的例子

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << 7 << '\n';

	return 0;
}

在这个程序中,变量 `x` 被定义并初始化,但它从未使用过,因此它对程序的行为没有影响。死代码消除将删除 `x` 的定义。

结果将等同于以下内容

#include <iostream>

int main()
{
	std::cout << 7 << '\n';

	return 0;
}

当一个变量因不再需要而被从程序中移除时,我们称该变量已被优化掉(或优化消失)。

与原始版本相比,这个优化版本不再需要运行时计算表达式 3 + 4,也不再需要两次内存访问操作(一次是初始化变量 x,一次是从 x 中读取值)。这意味着程序将更小且更快。

const 变量更容易优化

在某些情况下,我们可以做一些简单的事情来帮助编译器更有效地进行优化。

常量传播对编译器来说可能具有挑战性。在常量传播部分,我们提供了这个例子

#include <iostream>

int main()
{
	int x { 7 };
	std::cout << x << '\n';

	return 0;
}

由于 `x` 被定义为非 `const` 变量,为了应用此优化,编译器必须意识到 `x` 的值实际上并未改变(尽管它可能改变)。编译器是否能够做到这一点取决于程序的复杂性和编译器优化例程的复杂程度。

我们可以通过尽可能使用常量变量来帮助编译器更有效地优化。例如

#include <iostream>

int main()
{
	const int x { 7 }; // x is now const
	std::cout << x << '\n';

	return 0;
}

因为 `x` 现在是 `const`,所以编译器有保证 `x` 在初始化后不能被更改。这使得编译器更有可能应用常量传播,然后完全优化掉该变量。

关键见解

使用 const 变量可以帮助编译器更有效地优化。

优化会使程序更难调试

如果优化能让我们的程序更快,那为什么默认不开启呢?

当编译器优化程序时,结果是变量、表达式、语句和函数调用可能会被重新排列、修改、替换或完全移除。这些更改会使程序的有效调试变得困难。

在运行时,调试已编译的代码可能很困难,因为这些代码与原始源代码不再很好地关联。例如,如果您尝试监视一个已被优化掉的变量,调试器将无法找到该变量。如果您尝试单步进入一个已被优化掉的函数,调试器将简单地跳过它。因此,如果您正在调试代码并且调试器行为异常,这很可能是原因。

在编译时,我们几乎没有可见性和工具来帮助我们理解编译器正在做什么。如果变量或表达式被替换为错误的值,我们又该如何调试这个问题呢?这是一个持续的挑战。

为了尽量减少此类问题,调试版本通常会关闭优化,以便编译后的代码能更接近源代码。

作者注

编译时调试是一个不发达的领域。截至 C++23,有一些提案正在考虑用于未来的语言标准(例如这一个),如果获得批准,将为语言添加功能,从而有所帮助。

术语:编译时常量 vs 运行时常量

C++ 中的常量有时分为两个非正式类别。

编译时常量是在编译时已知其值的常量。示例包括

  • 字面量。
  • 其初始化器为编译时常量的常量对象。

运行时常量是在运行时上下文中确定其值的常量。示例包括

  • 常量函数参数。
  • 其初始化器为非常量或运行时常量的常量对象。

例如

#include <iostream>

int five()
{
    return 5;
}

int pass(const int x) // x is a runtime constant
{
    return x;
}

int main()
{
    // The following are non-constants:
    [[maybe_unused]] int a { 5 };

    // The following are compile-time constants:
    [[maybe_unused]] const int b { 5 };
    [[maybe_unused]] const double c { 1.2 };
    [[maybe_unused]] const int d { b };       // b is a compile-time constant

    // The following are runtime constants:
    [[maybe_unused]] const int e { a };       // a is non-const
    [[maybe_unused]] const int f { e };       // e is a runtime constant
    [[maybe_unused]] const int g { five() };  // return value isn't known until runtime
    [[maybe_unused]] const int h { pass(5) }; // return value isn't known until runtime

    return 0;
}

尽管您会在实际中遇到这些术语,但在 C++ 中,这些定义并不那么有用

  • 某些运行时常量(甚至是非常量)可以在编译时出于优化目的进行评估(根据“好像”规则)。
  • 一些编译时常量(例如 const double d { 1.2 };)不能在编译时特性中使用(根据语言标准定义)。我们将在5.5 -- 常量表达式课中更详细地讨论这一点。

因此,我们建议避免使用这些术语。我们将在下一课中讨论您应该改用哪些术语。

作者注

我们正在逐步淘汰未来文章中对这些术语的使用。

guest
您的电子邮箱地址将不会被显示
发现错误?请在上方留言!
与勘误相关的评论在处理后将被删除,以帮助减少混乱。感谢您帮助使网站对每个人都更好!
来自 https://gravatar.com/ 的头像与您提供的电子邮箱地址相关联。
有回复时通知我:  
23 条评论
最新
最早 最多投票
内联反馈
查看所有评论