哪些优化应该留给编译器?

What optimizations should be left for compiler?

假设您选择了最有效的算法来解决性能是第一要务的问题,现在您正在实施它,您必须决定如下细节:

v[i*3+0]v[i*3+1]v[i*3+2]包含粒子速度的分量i,我们要计算总动能。鉴于所有粒子的质量相同,可以这样写:

inline double sqr(double x)
{
    return x*x;
}

double get_kinetic_energy(double v[], int n)
{
    double sum = 0.0;

    for (int i=0; i < n; i++)
        sum += sqr(v[i*3+0]) + sqr(v[i*3+1]) + sqr(v[i*3+2]);

    return 0.5 * mass * sum;
}

为了减少乘法次数,可以写成:

double get_kinetic_energy(double v[], int n)
{
    double sum = 0.0;

    for (int i=0; i < n; i++)
    {
        double *w = v + i*3;
        sum += sqr(w[0]) + sqr(w[1]) + sqr(w[2]);
    }

    return 0.5 * mass * sum;
}

(有人可能会写一个乘法次数更少的函数,但这不是本题的重点)

现在我的问题是:由于许多 C 编译器可以自动进行这种优化,开发人员应该在哪些地方依赖编译器,在哪些地方应该 she/he 尝试手动进行一些优化?

有一件事看起来有点像过早的优化,但可能只是对语言能力的无知,那就是你拥有所有的信息来描述被压平成 double 值数组的粒子。

我建议您将其分解,通过创建一个结构来保存每个粒子上的三个数据点,从而使您的代码更易于阅读。到那时,您可以创建接受单个粒子或多个粒子并对它们进行计算的函数。

这比将三倍数量的粒子参数传递给函数或尝试“切片”数组要容易得多。如果你更容易推理,你就不太可能生成 warnings/errors.

where should the developer rely on the compiler and where should she/he try to do some optimization manually?

  1. 我是否相当 in-depth 了解目标硬件以及 C 代码如何转换为汇编程序?如果不是,请忘记手动优化。

  2. 此代码中是否存在任何明显的瓶颈 - 我如何首先知道它需要优化?明显的罪魁祸首是 I/O、复杂循环、busy-wait 循环、朴素算法等

  3. 当我发现这个瓶颈时,我究竟是如何对它进行基准测试的,我确​​定问题不在于基准测试方法本身吗? SO 的经验表明,10 个奇怪的性能问题中有 9 个可以用不正确的基准测试来解释。包括:禁用编译器优化的基准测试...

从那里开始,您可以开始查看 system-specific 事物以及算法本身 - SO 答案中要涵盖的内容太多了。为 low-end 微控制器和 64 位台式电脑(以及介于两者之间的所有东西)优化代码之间存在巨大差异。

看看 gccclang 如何处理您的代码,您考虑的微优化是徒劳的。编译器已经应用了标准的通用子表达式消除技术,可以消除您要消除的开销。

事实上,生成的代码使用 XMM 寄存器一次处理 2 个组件。

如果性能是必须的,那么以下步骤可以挽救局面:

  • 真正的判断者是挂钟。使用真实数据编写基准并衡量性能,直到获得一致的结果。

  • 如果您有分析器,请使用它来确定瓶颈在哪里(如果有的话)。更改那些似乎会影响性能的部分的算法是一种有效的方法。

  • 尝试从编译器中获得最佳效果:研究优化选项并尝试让编译器使用适合目标系统的更积极的技术。例如 -mavx512f -mavx512cdgcc 使用 512 位 ZMM 寄存器生成一次处理 8 个组件的代码。

    这是一种非侵入式技术,因为代码不会更改,因此您不必冒险通过手动优化代码来引入新错误。

优化是一门困难的艺术。根据我的经验,与添加额外的细微内容以牺牲可读性和正确性为代价来尝试提高性能相比,简化代码可以获得更好的结果和更少的错误。

查看代码,明显的简化似乎会产生相同的结果,并且可能有助于优化器的工作(但同样,让挂钟来判断):

double get_kinetic_energy(const double v[], int n, double mass)
{
    double sum = 0.0;

    for (int i = 0; i < 3 * n; i++)
        sum += v[i] * v[i];

    return 0.5 * mass * sum;
}

像 clang 和 gcc 这样的编译器同时比许多人认为的更强大和更不强大。

他们有非常广泛的模式,他们可以将代码转换为可能更高效且仍按要求运行的替代形式。

但是,两者都不擅长判断优化何时有用。两者都倾向于做出一些几乎荒谬可笑的“优化”决定。

例如给定

void test(char *p)
{
    char *e = p+5;
    do
    {
        p[0] = p[1];
        p++;
    }while(p < e);
}

当针对优化级别低于 -O2 的 Cortex-M0 时,gcc 10.2.1 将生成等同于调用 memmove(p, p+1, 7); 的代码。虽然理论上 memmove 的库实现可能会优化 n==7 的情况,从而优于 five-instruction byte-based 在 -Og 处生成的循环(甚至 - O0),似乎更有可能的是,任何合理的实现都会花一些时间分析需要做什么,然后在完成之后花费与使用 -O0 生成的代码一样长的时间来执行循环。

本质上,发生的事情是 gcc 分析循环,弄清楚它试图做什么,然后使用它自己的方法以可能比也可能不会比程序员首先尝试做。