跳转到内容

优化 C++/代码优化/更快的操作

来自 Wikibooks,开放世界的开放书籍

一些基本操作,即使在概念上与其他操作一样简单,对处理器来说也要快得多。聪明的程序员可以为任务选择更快的指令。

不过,每个优化编译器都能够为目标处理器选择最快的指令,因此一些技术在某些编译器中毫无用处。

此外,一些技术甚至可能会在某些处理器上降低性能。

在本节中,介绍了一些可能在某些编译器/处理器组合上提高性能的技术。

结构体字段顺序

[编辑 | 编辑源代码]

以这样的方式排列类和结构体的成员变量:最常用的变量位于前 128 个字节中,然后按从最长的对象到最短的对象排序。

如果在以下结构体中,msg 成员仅用于错误消息,而其他成员用于计算

struct {
    char msg[400];
    double d;
    int i;
};

可以通过用以下结构体替换结构体来加快计算速度

struct {
    double d;
    int i;
    char msg[400];
};

在某些处理器上,如果成员距离结构体开头的距离小于 128 个字节,则成员的寻址效率更高。

在第一个示例中,要使用指向结构体开头的指针来寻址 di 字段,需要至少 400 个字节的偏移量。

相反,在第二个示例中,包含以不同顺序排列的相同字段,寻址 di 的偏移量只有几个字节,这允许使用更紧凑的指令。

现在,假设您编写了以下结构体

struct {
    bool b;
    double d;
    short s;
    int i;
};

由于字段对齐,它通常占用 1 (bool) + 7 (填充) + 8 (double) + 2 (short) + 2 (填充) + 4 (int) = 24 个字节。

以下结构体是通过按从最长到最短的顺序对字段进行排序,从上一个结构体中获得的

struct {
    double d;
    int i;
    short s;
    bool b;
};

它通常占用 8 (double) + 4 (int) + 2 (short) + 1 (bool) + 1 (填充) = 16 个字节。排序最小化了对齐要求导致的填充(或空洞),因此生成更紧凑的结构体。

浮点数到整数转换

[编辑 | 编辑源代码]

利用非标准例程将浮点数四舍五入为整数。

C++ 语言没有提供将浮点数四舍五入为整数的原始操作。将浮点数 x 转换为最接近的整数 n 的最简单技术是以下语句

n = int(floor(x + 0.5f));

使用这种技术,如果 x 恰好位于两个整数之间,则 n 将是较大的整数(例如,0.5 生成 1,1.5 生成 2,-0.5 生成 0,-1.5 生成 -1)。

不幸的是,在某些处理器(特别是 Pentium 系列)上,这种表达式在非常慢的机器代码中编译。有些处理器有专门的指令来对数字进行四舍五入。

特别是,Pentium 系列有 fistp 指令,在以下代码中使用时,会生成更快但并不完全等效的代码

#if defined(__unix__) || defined(__GNUC__)
    // For 32-bit Linux, with Gnu/AT&T syntax
    __asm ("fldl %1 \n fistpl %0 " : "=m"(n) : "m"(x) : "memory" );
#else
    // For 32-bit Windows, with Intel/MASM syntax
    __asm fld qword ptr x;
    __asm fistp dword ptr n;
#endif

上面的代码将 x 四舍五入到最接近的整数,但如果 x 恰好位于两个整数之间,则 n 将是最接近的偶数整数(例如,0.5 生成 0,1.5 生成 2,-0.5 生成 0,-1.5 生成 -2)。

如果这个结果是可以容忍的甚至想要的,并且您被允许使用嵌入式汇编,那么请使用这段代码。显然,它不能移植到其他处理器系列。

整数的位操作

[编辑 | 编辑源代码]

利用您对其表示形式的了解来对整数的位进行操作。

您可以在这里找到这类技巧的集合:http://www.graphics.stanford.edu/~seander/bithacks.html。其中一些技巧实际上已经被一些编译器使用,另一些技巧用于解决罕见的问题,另一些技巧仅在某些平台上才有用。

数组单元大小

[编辑 | 编辑源代码]

确保数组或 vector 的非大型单元的大小(由 sizeof 运算符得出)是 2 的幂,而数组或 vector 的大型单元的大小不是 2 的幂。

直接访问数组单元是通过将索引乘以单元大小(即一个常数)来执行的。如果此乘法的第二个因子是 2 的幂,则此操作会快得多,因为它被执行为位移。类似地,在多维数组中,除第一个以外的所有大小都应该是 2 的幂。

这种大小是通过在结构体中添加未使用的字段,并在数组中添加未使用的单元来获得的。例如,如果每个单元都是 float 对象的 3 元组,则只需在每个单元中添加第四个虚拟 float 对象即可。

但是,当访问多维数组的单元时,如果最后一维是足够大的 2 的幂,您可能会遇到数据缓存竞争现象(也称为数据缓存冲突),这可能会使计算速度降低 10 倍或更多。这种现象仅在数组单元超过一定大小(取决于数据缓存)时才会发生,但大约是 1 到 8 KB。因此,如果算法必须处理一个单元大小为 2 的幂,并且大小大于或等于 1024 个字节的数组,首先,您应该检测数据缓存竞争是否发生,在这种情况下,您应该避免这种现象。

例如,一个 100 x 512 float 对象的矩阵是一个包含 100 个 512 float 数组的数组。一级数组的每个单元的大小为 512 x 4 = 2048 个字节,因此它有数据缓存竞争的风险。

要检测竞争,只需在每个最后一级数组中添加一个基本单元(一个 float),但继续处理与以前相同的单元,并测量处理时间是否大幅降低(至少 20%)。在这种情况下,您必须确保这种改进是稳定的。为了实现这个目标,您可以使用以下技术之一

  • 在每个最后一级数组的末尾添加一个或多个未使用的单元。例如,数组 double a[100][1024] 可以变成 double a[100][1026],即使计算将处理该数组直到前一个大小。
  • 保持数组大小,但将其划分为矩形块,并一次处理一个块中的所有单元。

前缀运算符与后缀运算符

[编辑 | 编辑源代码]

优先使用前缀运算符而不是后缀运算符。

在处理基本类型时,前缀和后缀算术运算的性能可能相同。但是,对于对象,后缀运算符会导致对象创建自己的副本以保留其初始状态(作为操作结果返回),以及导致操作的副作用。请考虑以下示例

class IntegerIncreaser
{
    int m_Value;

public:
    /* Postfix operator. */
    IntegerIncreaser operator++ (int) {
        IntegerIncreaser tmp (*this);

        ++m_Value;
        return tmp;
    };

    /* Prefix operator. */
    IntegerIncreaser operator++ () {
        ++m_Value;
        return *this;
    };
};

由于后缀运算符需要返回被递增(或递减)的值的未更改版本——无论结果是否实际被使用——它们很可能会进行复制。STL 迭代器(例如)在使用前缀运算符进行修改时效率更高。

显式内联

[编辑 | 编辑源代码]

如果您不使用整个程序优化的编译器选项,并且不允许编译器内联任何函数,请尝试将瓶颈中调用的函数移至头文件,并将其声明为 inline

如第 3.1 节中“内联函数”准则所述,每个内联函数都更快,但许多内联函数会减慢整个程序的速度。

尝试一次声明几个 inline 函数,只要您在单个命令中获得显著的速度提升(至少 10%)。

与二的幂进行运算

[编辑 | 编辑源代码]

如果必须选择一个经常用于乘除的整数常量,请选择二的幂。

如果第二个操作数是二的常数幂,则整数之间的乘法、除法和模运算会快得多,因为在这种情况下它们被实现为位移或位屏蔽。

整数除以常数

[编辑 | 编辑源代码]

当您将一个整数(已知为正或零)除以一个常数时,将该整数转换为unsigned

如果s是一个signed整数,u是一个unsigned整数,C是一个常数整型表达式(正或负),则运算s / Cu / C慢,s % Cu % C慢。当C是二的幂时,这种情况最为显著,但在所有情况下,在除法过程中都必须考虑符号。

但是,从signedunsigned的转换是免费的,因为它只是对相同位的重新解释。因此,如果s是一个您知道为正或零的signed整数,您可以使用以下(等效)表达式来加速其除法:(unsigned)s / C(unsigned)s % C

具有缩减数据总线的处理器

[编辑 | 编辑源代码]

如果目标处理器的​​数据总线小于处理器寄存器,如果可能,对除函数参数和最常用的局部变量以外的所有变量使用不超过数据总线大小的整数类型。

类型intunsigned int是最有效的,在它们被加载到处理器寄存器中之后。但是,对于某些处理器系列,它们可能不是在内存中访问的最有效类型。

例如,有一些处理器具有 16 位寄存器,但具有 8 位数据总线,而其他处理器具有 32 位寄存器,但具有 16 位数据总线。对于数据总线小于内部寄存器的处理器,通常类型intunsigned int与寄存器的大小相匹配。

对于这样的系统,在内存中加载和存储一个int对象所需的时间比不超过数据总线大小的整数所需的时间更长。

函数参数和最常用的局部变量通常分配在寄存器中,因此不会导致内存访问。

将结构数组重新排列为多个数组

[编辑 | 编辑源代码]

与其处理单个聚合对象数组,不如并行处理两个或多个具有相同长度的数组。

例如,代替以下代码

const int n = 10000;
struct { double a, b, c; } s[n];
for (int i = 0; i < n; ++i) {
    s[i].a = s[i].b + s[i].c;
}

以下代码可能更快

const int n = 10000;
double a[n], b[n], c[n];
for (int i = 0; i < n; ++i) {
    a[i] = b[i] + c[i];
}

使用这种重新排列,“a”、“b”和“c”可以通过数组处理指令进行处理,这些指令比标量指令快得多。这种优化可能会对某些(更简单的)架构产生无效或不利的结果。

更好[需要引证]是交错数组

const int n = 10000;
double interleaved[n * 3];
for (int i = 0; i < n; ++i) {
    const size_t idx = i * 3;
    interleaved[idx] = interleaved[idx + 1] + interleaved[idx + 2];
}

记住要测试所有内容!并且不要过早优化。

华夏公益教科书