14.1 Use lookup tables
如果一个常量表被缓存,那么从这个表中读取一个值是非常快的。通常从一级缓存中读取表只需要几个时钟周期。我们可以利用这个事实,如果一个函数的输入只有有限数量的情况,那么可以通过表查找来代替函数调用。
以整数阶乘函数 (n!) 为例。它只允许输入区间 [0, 12] 中的整数。更大的输入会导致溢出,负数输入则产生无穷大。阶乘的典型实现如下:
// Example 14.1a
int factorial (int n) { // n!
int i, f = 1;
for (i = 2; i <= n; i++) f *= i;
return f;
}
这个计算需要进行 n-1 次乘法运算,可能需要相当长的时间。使用查找表更加高效:
// Example 14.1b
int factorial (int n) { // n!
// Table of factorials:
const int FactorialTable[13] = {1, 1, 2, 6, 24, 120, 720,
5040, 40320, 362880, 3628800, 39916800, 479001600};
if ((unsigned int)n < 13) { // Bounds checking (see page 147)
return FactorialTable[n]; // Table lookup
}
else {
return 0; // return 0 if out of range
}
}
这个实现使用了一个查找表,而不是每次调用函数时计算该值。在这里,我添加了对 n 的边界检查,因为当 n 是数组索引时,超出范围的后果可能比 n 是循环计数时更严重。边界检查的方法将在下一页的第 147 页解释。
为了启用常量传播和其他优化,应将该表声明为 const。您可以将函数声明为内联函数。
在大多数情况下,如果可能的输入数量有限且没有缓存问题,则用查找表替换函数是有利的。如果您预期在每次调用之间将表从缓存中清除,并且计算函数所需的时间小于重新加载内存中的值以及占用缓存行对程序其他部分的开销的时间,则使用查找表是不划算的。
当前的指令集无法对查找表进行向量化。如果这会阻止更快的向量化代码,请不要使用查找表。
将某些内容存储在静态内存中可能会导致缓存问题,因为静态数据可能分散在不同的内存地址上。如果缓存是一个问题,那么将表从静态内存复制到内层循环之外的栈内存中可能是有用的。通过在函数内但内层循环之外声明表并且不使用 static 关键字来实现:
// Example 14.1c
void CriticalInnerFunction () {
// Table of factorials:
const int FactorialTable[13] = {1, 1, 2, 6, 24, 120, 720,
5040, 40320, 362880, 3628800, 39916800, 479001600};
...
int i, a, b;
// Critical innermost loop:
for (i = 0; i < 1000; i++) {
...
a = FactorialTable[b];
...
}
}
在示例 14.1c 中,当调用 CriticalInnerFunction 时,FactorialTable 会从静态内存复制到栈内存。编译器将把表存储在静态内存中,并在函数开始时插入一个将表复制到栈内存的代码。当位于关键的内层循环之外时,复制表需要额外的时间,但是这是允许的。循环将使用存储在栈内存中的表的副本,它与其他局部变量连续存放,因此比静态内存更有可能被高效地缓存。
如果您不想手动计算表格值并在代码中插入这些值,则可以让程序进行计算。只要计算表只需进行一次,计算所需的时间就不重要了。有人可能会认为,在程序中计算表格比手写值更安全,因为手写表格中的错误可能会未被发现。
表查找的原理可以在任何程序在两个或多个常量之间进行选择的情况下使用。例如,选择两个常量的分支可以用具有两个条目的表来替换。如果分支预测性差,这可能会提高性能。例如:
// Example 14.2a
float a; int b;
a = (b == 0) ? 1.0f : 2.5f;
如果我们假设 b 始终为0或1,并且该值预测性差,那么用表查找来替换分支是有优势的:
// Example 14.2b
float a; int b;
const float OneOrTwo5[2] = {1.0f, 2.5f};
a = OneOrTwo5[b & 1];
在这里,我将 b 与 1 进行了 AND 操作,这是出于安全考虑。b & 1 只能取0或1(参见第147页)。当然,如果可以保证 b 的值始终为0或1,那么可以省略对 b 的这个额外检查。写成 a = OneOrTwo5[b!=0]; 也可以工作,尽管效率稍差一些。然而,当 b 是浮点数时,这种方法效率较低,因为我测试过的所有编译器在这种情况下都将 OneOrTwo5[b!=0] 实现为 OneOrTwo5[(b!=0) ? 1 : 0],因此我们并没有摆脱分支。可能看起来不合逻辑的是,编译器在 b 是浮点数时使用了不同的实现方式。我猜想的原因是编译器制造商认为浮点数比较比整数比较更可预测。解决方案 a = 1.0f + b * 1.5f; 在 b 是浮点数时效率很高,但如果 b 是整数,则不是有效的,因为整数到浮点数的转换比表查找花费的时间更多。
查找表作为 switch 语句的替代品特别有优势,因为 switch 语句通常受到分支预测性差的影响。示例:
// Example 14.3a
int n;
switch (n) {
case 0:
printf("Alpha"); break;
case 1:
printf("Beta"); break;
case 2:
printf("Gamma"); break;
case 3:
printf("Delta"); break;
}
This can be improved by using a lookup table:
// Example 14.3b
int n;
char const * const Greek[4] = {
"Alpha", "Beta", "Gamma", "Delta"
};
if ((unsigned int)n < 4) { // Check that index is not out of range
printf(Greek[n]);
}
表的声明中使用了两次 const,因为指针以及它们所指向的文本都是常量。
14.2 Bounds checking
在C++中,通常需要检查数组索引是否越界。典型的情况可能如下所示:
// Example 14.4a
const int size = 16; int i;
float list[size];
...
if (i < 0 || i >= size) {
cout << "Error: Index out of range";
}
else {
list[i] += 1.0f;
}
i < 0和i >= size这两个比较可以用单个比较来替代:
// Example 14.4b
if ((unsigned int)i >= (unsigned int)size) {
cout << "Error: Index out of range";
}
else {
list[i] += 1.0f;
}
// Example 14.5a
const int min = 100, max = 110; int i;
...
if (i >= min && i <= max) { ...
can be changed to:
// Example 14.5b
if ((unsigned int)i - (unsigned int)min <= (unsigned int)(max - min))
{ ...
如果期望区间的长度是2的幂次方,还有一种更快的方法来限制整数的范围。例如:
// Example 14.6
float list[16]; int i;
...
list[i & 15] += 1.0f;
这需要一点解释。i&15的值保证在0到15的区间内。如果i超出了该区间,例如i等于18,那么位运算符&(按位与)将把i的二进制值截断为四位,结果将是2。这个结果相当于i对16取模的结果。这种方法对于防止程序错误非常有用,如果数组索引越界并且我们不需要错误消息,它可以起到作用。重要的是要注意,这种方法只适用于2的幂次方(即2、4、8、16、32、64...)。我们可以通过与2^n-1进行按位与操作来确保一个值小于2^n且不为负数。按位与操作会隔离出数字中最低有效的n位,并将所有其他位设置为零。
14.3 Use bitwise operators for checking multiple values at once
位运算符&、|、^、~、<<、>>可以在一次操作中测试或操作整数的所有位。例如,如果32位整数的每个位都有特定的含义,那么你可以使用|运算符在一次操作中设置多个位;你可以使用&运算符清除或屏蔽多个位;你可以使用^运算符切换多个位。
&运算符还可以在一次操作中测试多个条件,这非常有用。例如:
// Example 14.7a. Testing multiple conditions
enum Weekdays {
Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday
};
Weekdays Day;
if (Day == Tuesday || Day == Wednesday || Day == Friday) {
DoThisThreeTimesAWeek();
}
在这个示例中,if语句有三个条件,它们被实现为三个分支。如果常量Sunday、Monday等被定义为2的幂次方,它们可以合并为一个分支:
// Example 14.7b. Testing multiple conditions using &
enum Weekdays {
Sunday = 1, Monday = 2, Tuesday = 4, Wednesday = 8,
Thursday = 0x10, Friday = 0x20, Saturday = 0x40
};
Weekdays Day;
if (Day & (Tuesday | Wednesday | Friday)) {
DoThisThreeTimesAWeek();
}
在示例14.7b中,给每个常量赋一个2的幂次方,实际上使用了Day中的每个位来表示一周中的一天。我们可以用这种方式定义的常量数量最大值等于一个整数中的位数,通常为32位。在64位系统中,我们可以使用64位整数而不会丧失效率。
示例14.7b中的表达式(Tuesday | Wednesday | Friday)会被编译器转换为值0x2C,以便if条件可以通过单个&操作计算,这个操作非常快。如果变量Day中设置了Tuesday、Wednesday或Friday之一的位,则&操作的结果将为非零,因此计算为true。
请注意布尔运算符&&、||、!和相应的按位运算符&、|、~之间的区别。布尔运算符产生单个结果true(1)或false(0);当需要时才评估第二个操作数。按位运算符应用于32位整数时,会产生32个结果,它们总是计算两个操作数。尽管如此,按位运算符比布尔运算符计算得快得多,因为它们不使用分支机构,只要操作数是整数表达式而不是布尔表达式。
使用整数作为布尔向量,可以使用位运算符执行许多操作,这些操作非常快。这在具有许多布尔表达式的程序中非常有用。无论是使用enum、const还是#define定义常量对性能都没有影响。
14.4 Integer multiplication
整数乘法比加减法需要更长的时间(3-10个时钟周期,取决于处理器)。优化编译器通常会将整数乘以常数替换为加法和移位操作的组合。乘以2的幂次方比乘以其他常数要快,因为它可以作为一个移位操作来完成。例如,a * 16可以计算为a << 4,而a * 17可以计算为(a << 4) + a。
在乘以常数时,建议采用2的幂次方,这样可以利用这一点。编译器还具有快速的乘3、5和9的方式。
在计算数组元素的地址时,乘法是隐式完成的。在某些情况下,如果因子是2的幂次方,则此乘法速度更快。例如:
// Example 14.8
const int rows = 10, columns = 8;
float matrix[rows][columns];
int i, j;
int order(int x);
...
for (i = 0; i < rows; i++) {
j = order(i);
matrix[j][0] = i;
}
在这里,矩阵的地址matrix[j][0]在内部计算为(int)&matrix[0][0] + j * (columns * sizeof(float))。
现在,将j乘以的因子是(columns * sizeof(float)) = 8 * 4 = 32。这是2的幂次方,所以编译器可以将j * 32替换为j << 5。如果columns不是2的幂次方, 那么乘法的时间将会更长。因此,如果以非顺序方式访问行,则使矩阵的列数为2的幂次方可能会有优势。
对于结构体或类元素的数组也适用相同的原则。如果以非顺序方式访问对象,则每个对象的大小最好是2的幂次方。例如:
// Example 14.9
struct S1 {
int a;
int b;
int c;
int UnusedFiller;
};
int order(int x);
const int size = 100;
S1 list[size]; int i, j;
...
for (i = 0; i < size; i++) {
j = order(i);
list[j].a = list[j].b + list[j].c;
}
在这里,我们在结构体中插入了UnusedFiller,以确保其大小为2的幂次方,以便加快地址计算速度。
使用2的幂次方的优势仅适用于以非顺序方式访问元素的情况。如果将示例14.8和14.9中的代码更改为使用i而不是j作为索引,则编译器可以看到地址按顺序访问,它可以通过将常数添加到前一个地址来计算每个地址(参见第72页)。在这种情况下,大小是否为2的幂次方并不重要。
关于使用2的幂次方的建议不适用于非常大的数据结构。相反,如果矩阵太大以至于缓存成为问题,请务必避免使用2的幂次方。如果矩阵的列数是2的幂次方且矩阵比缓存大,则可能会出现非常昂贵的缓存争用,如第106页所述。
14.5 Integer division
整数除法比加法、减法和乘法要花费更长时间(32位整数的时钟周期数量在27-80个之间,具体取决于处理器)。
通过2的幂次方进行整数除法可以使用移位操作来完成,这要快得多。
常数除以变量比变量除以常数更快,因为优化编译器可以计算a / b为a * (2^n / b)>>n,其中n选择适当。常数(2^n / b)是预先计算的,乘法使用扩展的位数进行。该方法稍微有点复杂,因为必须添加各种修正符号和舍入误差。该方法在手册2:“汇编语言优化子程序”中有更详细的描述。如果分子未签名,则该方法更快。
以下准则可用于改进包含整数除法的代码:
• 整数常数除以变量比变量除以常数更快。确保除数的值在编译时已知。
• 整数常数除以变量如果常数是2的幂次方则更快
• 如果被除数未签名,则通过整数常数除法更快
例如:
// Example 14.10
int a, b, c;
a = b / c; // This is slow
a = b / 10; // Division by a constant is faster
a = (unsigned int)b / 10; // Still faster if unsigned
a = b / 16; // Faster if divisor is a power of 2
a = (unsigned int)b / 16; // Still faster if unsigned
同样的规则也适用于取模运算:
// Example 14.11
int a, b, c;
a = b % c; // This is slow
a = b % 10; // Modulo by a constant is faster
a = (unsigned int)b % 10; // Still faster if unsigned
a = b % 16; // Faster if divisor is a power of 2
a = (unsigned int)b % 16; // Still faster if unsigned
如果可能的话,您可以利用这些准则通过使用一个是2的幂次方的常数除数,并且如果您确定被除数不会为负数,则将被除数更改为无符号数。
如果在编译时不知道除数的值,但程序一直用相同的除数进行重复除法,则仍然可以使用上述方法。在这种情况下,您必须在编译时进行(2^n / b)等必要的计算。函数库www.agner.org/optimize/asmlib.zip包含各种用于这些计算的函数。
通过常数对循环计数器进行除法可以通过使用相同的常数展开循环来避免。例如:
// Example 14.12a
int list[300];
int i;
for (i = 0; i < 300; i++) {
list[i] += i / 3;
}
This can be replaced with:
// Example 14.12b
int list[300];
int i, i_div_3;
for (i = i_div_3 = 0; i < 300; i += 3, i_div_3++) {
list[i] += i_div_3;
list[i+1] += i_div_3;
list[i+2] += i_div_3;
}
类似的方法也可以用于避免取模操作:
// Example 14.13a
int list[300];
int i;
for (i = 0; i < 300; i++) {
list[i] = i % 3;
}
This can be replaced with:
// Example 14.13b
int list[300];
int i;
for (i = 0; i < 300; i += 3) {
list[i] = 0;
list[i+1] = 1;
list[i+2] = 2;
}
在示例14.12b和14.13b中,循环展开只适用于循环计数能够被展开因子整除的情况。如果不能整除,则必须在循环外执行额外的操作:
// Example 14.13c
int list[301];
int i;
for (i = 0; i < 300; i += 3) {
list[i] = 0;
list[i+1] = 1;
list[i+2] = 2;
}
list[300] = 0;
14.6 Floating point division
浮点数除法比加法、减法和乘法花费更长时间(20-45时钟周期)。
浮点数除以一个常数应该通过乘以倒数来进行计算:
// Example 14.14a
double a, b;
a = b / 1.2345;
Change this to:
// Example 14.14b
double a, b;
a = b * (1. / 1.2345);
编译器会在编译时计算(1.0/1.2345),并将倒数插入到代码中,因此您不需要花费时间执行除法运算。一些编译器会自动将示例14.14a的代码替换为14.14b,但只有在某些选项被设置为降低浮点精度的情况下才会这样(参见第75页)。因此,明确地进行这种优化是更安全的做法。
有时可以完全消除除法运算。例如:
// Example 14.15a
if (a > b / c)
can sometimes be replaced by
// Example 14.15b
if (a * c > b)
但要注意这里的陷阱:如果c < 0,则不等号必须反转。如果b和c是整数,则除法是不精确的,而乘法是精确的。
多个除法可以合并。例如:
// Example 14.16a
double y, a1, a2, b1, b2;
y = a1/b1 + a2/b2;
在这里,我们可以通过使用公共分母来消除一个除法:
// Example 14.16b
double y, a1, a2, b1, b2;
y = (a1*b2 + a2*b1) / (b1*b2);
使用公共分母的技巧甚至可以用于完全独立的除法。例如:
// Example 14.17a
double a1, a2, b1, b2, y1, y2;
y1 = a1 / b1;
y2 = a2 / b2;
This can be changed to:
// Example 14.17b
double a1, a2, b1, b2, y1, y2, reciprocal_divisor;
reciprocal_divisor = 1. / (b1 * b2);
y1 = a1 * b2 * reciprocal_divisor;
y2 = a2 * b1 * reciprocal_divisor;
14.7 Do not mix float and double
无论您使用单精度还是双精度,浮点计算通常花费相同的时间。但是,在编译为64位操作系统和编译为SSE2或更高版本指令集的程序中,混合使用单精度和双精度会带来一定的性能损失。示例:
// Example 14.18a
float a, b;
a = b * 1.2; // Mixing float and double is bad
C/C++标准规定,默认情况下所有浮点数常量均为双精度,因此在此示例中,1.2是一个双精度常量。因此,在与双精度常量相乘之前,需要将b从单精度转换为双精度,并将结果再次转换回单精度。这些转换需要很多时间。您可以通过将常量设置为单精度或将a和b设置为双精度来避免这些转换,并使代码运行速度提高最多5倍:
// Example 14.18b
float a, b;
a = b * 1.2f; // everything is float
// Example 14.18c
double a, b;
a = b * 1.2; // everything is double
当代码编译为没有SSE2指令集的旧处理器时,混合不同浮点精度不会造成性能损失。但是,如果将来将代码移植到其他平台上,最好保持所有操作数的相同精度。
14.8 Conversions between floating point numbers and integers
Conversion from floating point to integer
根据C++语言标准,所有从浮点数到整数的转换都使用朝零方向截断而非四舍五入,这是不幸的,因为相比于四舍五入,截断需要更长的时间,除非使用SSE2指令集,尽可能启用SSE2指令集是推荐的。64位模式下始终启用SSE2。
没有SSE2的情况下从浮点数到整数的转换通常需要40个时钟周期。如果无法避免在关键部分中进行从float或double到int的转换,则可以通过使用四舍五入而不是截断来提高效率,这样大约快三倍。程序的逻辑可能需要修改以弥补四舍五入和截断之间的差异。
可以使用lrintf和lrint函数有效地将float或double转换为整数。不幸的是,由于对C99标准存在争议,这些函数在许多商业编译器中缺失。下面给出一个lrint函数的实现示例14.19,该函数将浮点数舍入到最近的整数,如果有两个整数距离相等,则返回偶数整数。此函数适用于带有Microsoft、Intel和GNU编译器的32位Windows和32位Linux。
// Example 14.19
static inline int lrint (double const x) { // Round to nearest integer
int n;
#if defined(__unix__) || defined(__GNUC__)
// 32-bit Linux, Gnu/AT&T syntax:
__asm ("fldl %1 \n fistpl %0 " : "=m"(n) : "m"(x) : "memory" );
#else
// 32-bit Windows, Intel/MASM syntax:
__asm fld qword ptr x;
__asm fistp dword ptr n;
#endif
return n;}
这段代码只能在Intel/x86兼容微处理器上运行。该函数还可以在www.agner.org/optimize/asmlib.zip的函数库中找到。
以下示例展示了如何使用lrint函数:
// Example 14.20
double d = 1.6;
int a, b;
a = (int)d; // Truncation is slow. Value of a will be 1
b = lrint(d); // Rounding is fast. Value of b will be 2
在64位模式或启用了SSE2指令集时,四舍五入和截断之间的速度没有差异。在64位模式或启用了SSE2指令集时,可以按以下方式实现缺失的函数:
// Example 14.21. // Only for SSE2 or x64
#include <emmintrin.h>
static inline int lrintf (float const x) {
return _mm_cvtss_si32(_mm_load_ss(&x));}
static inline int lrint (double const x) {
return _mm_cvtsd_si32(_mm_load_sd(&x));}
当启用SSE2指令集时,示例14.21中的代码比其他舍入方法更快,但与截断速度相同,也不会更慢。
Conversion from integer to floating point
只有在启用SSE2指令集时,有符号整数到浮点数的转换才会很快。只有在启用AVX512指令集时,无符号整数到浮点数的转换才会更快。请参考第41页。
14.9 Using integer operations for manipulating floating point variables
根据IEEE 754(1985)标准,浮点数以二进制表示进行存储。几乎所有现代微处理器和操作系统都使用该标准(但在一些非常老旧的DOS编译器中可能不适用)。
float、double和long double的表示反映了浮点值写成±2^eee · 1.fffff的形式,其中±表示符号,eee表示指数,而fffff表示小数部分的二进制位。符号位仅占用一个比特,正数为0,负数为1。指数以有偏移的二进制整数形式存储,而小数部分以二进制位存储。指数始终被归一化,如果可能的话,使得小数点前的值是1。这个“1”在表示中不会包含,除非是在long double格式中。可以将这些格式表示如下:
struct Sfloat {
unsigned int fraction : 23; // fractional part
unsigned int exponent : 8; // exponent + 0x7F
unsigned int sign : 1; // sign bit
};
struct Sdouble {
unsigned int fraction : 52; // fractional part
unsigned int exponent : 11; // exponent + 0x3FF
unsigned int sign : 1; // sign bit
};
struct Slongdouble {
unsigned int fraction : 63; // fractional part
unsigned int one : 1; // always 1 if nonzero and normal
unsigned int exponent : 15; // exponent + 0x3FFF
unsigned int sign : 1; // sign bit
};
非零浮点数的值可以按以下方式计算:
如果除了符号位以外的所有位都为零,则值为零。零可以带有或不带有符号位进行表示。
浮点数格式被标准化使得我们能够直接使用整数操作来操作浮点数表示的不同部分,这是一个优势,因为整数操作比浮点操作更快。只有在确保知道自己在做什么的情况下才应该使用这样的方法。请参考本节末尾的一些注意事项。
我们可以通过反转符号位来改变浮点数的符号:
// Example 14.22
union {
float f;
int i;
} u;
u.i ^= 0x80000000; // flip sign bit of u.f
我们可以通过将符号位设置为零来取绝对值:
// Example 14.23
union {
float f;
int i;
} u;
u.i &= 0x7FFFFFFF; // set sign bit to zero
我们可以通过测试除符号位以外的所有位是否为零来检查浮点数是否为零:
// Example 14.24
union {
float f;
int i;
} u;
if (u.i & 0x7FFFFFFF) { // test bits 0 - 30
// f is nonzero
}
else {
// f is zero
}
我们可以通过将指数增加n来将非零浮点数乘以2的n次方:
// Example 14.25
union {
float f;
int i;
} u;
int n;
if (u.i & 0x7FFFFFFF) { // check if nonzero
u.i += n << 23; // add n to exponent
}
示例14.25并未检查溢出情况,而且仅适用于正数n。如果没有下溢的风险,您可以通过从指数中减去n来进行2的n次方除法。
指数表示是有偏移的事实使得我们可以将两个正浮点数简单地作为整数进行比较:
// Example 14.26
union {
float f;
int i;
} u, v;
if (u.i > v.i) {
// u.f > v.f if both positive
}
示例14.26假设我们知道u.f和v.f都为正数。如果两者都是负数或者一个是0,另一个是-0(带有符号位的零),它将失败。
我们可以移位来比较绝对值:
// Example 14.27
union {
float f;
unsigned int i;
} u, v;
if (u.i * 2 > v.i * 2) {
// abs(u.f) > abs(v.f)
}
在示例14.27中,乘以2会移出符号位,从而其余位表示浮点数绝对值的单调递增函数。
我们可以通过设置分数位来将0 <= n < 223区间内的整数转换为区间[1.0,2.0)内的浮点数:
// Example 14.28
union {
float f;
int i;
} u;
int n;
u.i = (n & 0x7FFFFF) | 0x3F800000; // Now 1.0 <= u.f < 2.0
这种方法对于随机数生成器非常有用。
一般来说,如果浮点变量存储在内存中,则将其作为整数访问速度更快,但如果它是一个寄存器变量则不然。联合体强制变量至少暂时存储在内存中。使用上述示例中的方法将会有一个劣势,如果代码中其他附近部分可以从使用寄存器来处理相同变量中受益的话。
在这些示例中,我们使用联合体而不是指针类型转换,因为这种方法更安全。指针类型转换可能不适用于依赖于C标准的严格别名规则的编译器,该规则指定不同类型的指针不能指向同一对象,除了char指针之外。
上述示例都使用单精度。在32位系统中使用双精度会引起一些额外的复杂性。双精度用64位表示,但是32位系统对64位整数没有固有的支持。许多32位系统允许您定义64位整数,但实际上它们被表示为两个32位整数,这样效率较低。您可以使用双精度的高32位来访问符号位、指数和最高有效位。例如,要测试双精度数的符号:
// Example 14.22b
union {
double d;
int i[2];
} u;
if (u.i[1] < 0) { // test sign bit
// u.d is negative or -0
}
不建议仅修改双精度数的一半来修改它,例如在上面的示例中通过 u.i[1] ^= 0x80000000 来翻转符号位,因为这可能会在CPU中产生存储转发延迟(参见手册3:“Intel、AMD和VIA CPU的微体系结构”)。在64位系统中可以通过使用64位整数而不是两个32位整数来别名处理双精度数来避免这个问题。
访问64位双精度数的32位部分还存在另一个问题,它不能在大端存储的系统上进行移植。因此,在其他具有大端存储的平台上实现示例14.22b和14.29时需要进行修改。所有x86平台(Windows、Linux、BSD、基于Intel的Mac OS等)都具有小端存储,但其他系统可能具有大端存储(例如,PowerPC)。
我们可以通过比较第32至62位的位来对双精度数进行近似比较。这在查找矩阵中作为高斯消元中的主元的数值最大元素时很有用。在主元搜索中,可以像示例14.27中那样实现这种方法:
// Example 14.29
const int size = 100;
// Array of 100 doubles:
union {double d; unsigned int u[2]} a[size];
unsigned int absvalue, largest_abs = 0;
int i, largest_index = 0;
for (i = 0; i < size; i++) {
// Get upper 32 bits of a[i] and shift out sign bit:
absvalue = a[i].u[1] * 2;
// Find numerically largest element (approximately):
if (absvalue > largest_abs) {
largest_abs = absvalue;
largest_index = i;
}
}
示例14.29在数组中查找数值最大的元素,或者近似地查找。它可能无法区分相对差异小于2^-20的元素,但这对于查找合适的主元元素足够精确。整数比较可能比浮点数比较更快。在大端存储系统上,必须将 u[1] 替换为 u[0]。
14.10 Mathematical functions
在x86 CPU中,最常见的数学函数,如对数、指数函数、三角函数等,都是通过硬件实现的。然而,在大多数情况下,当SSE2指令集可用时,软件实现比硬件实现更快。大多数编译器在启用了SSE2或更高版本指令集时会使用软件实现。
与硬件实现相比,使用软件实现而不是硬件实现这些函数的优势在于单精度上更高。但是在大多数情况下,即使对于双精度,软件实现也比硬件实现更快。
您可以通过包含Intel C++编译器附带的库libmmt.lib和头文件mathimf.h,在不同的编译器中使用Intel数学函数库。该库包含许多有用的数学函数。Intel的数学核心库(Math Kernel Library)提供了许多高级数学函数,可从www.intel.com获取。Intel的函数库经过优化,适用于Intel处理器,但它们在AMD和其他处理器上通常也能提供合理的性能(参见第143页)。
矢量类库包括针对矢量输入进行优化的数学函数(https://github.com/vectorclass)。有关矢量函数的进一步讨论,请参阅第131页。
14.11 Static versus dynamic libraries
函数库可以以静态链接库(*.lib,*.a)或动态链接库(也称为共享对象,*.dll,*.so)的形式实现。静态链接的机制是链接器从库文件中提取所需的函数,并将它们复制到可执行文件中。只需要分发可执行文件给最终用户。
动态链接的工作方式不同。在加载或运行时解析动态库中的函数链接。因此,当程序运行时,可执行文件和一个或多个动态库都会加载到内存中。必须将可执行文件和所有动态库都分发给最终用户。
相对于动态链接,使用静态链接的优点有:
- 静态链接仅包含应用程序实际需要的库部分,而动态链接会使整个库(或至少很大一部分)在只需要库代码的一小部分时也加载到内存中。
- 在使用静态链接时,所有代码都包含在一个单独的可执行文件中。而动态链接需要在程序启动时加载多个文件。
- 调用动态库中的函数比调用静态链接库中的函数需要更长的时间,因为它需要通过导入表中的指针进行额外的跳转,可能还需要在过程链接表(PLT)中进行查找。
- 当代码分布在多个动态库之间时,内存空间变得更加碎片化。动态库加载在内存页大小(4096)的圆整地址上。这会使所有动态库争夺相同的高速缓存行,从而降低代码缓存和数据缓存的效率。
- 由于需要使用位置无关代码,某些系统上动态库的效率较低,请参阅下文。
- 如果使用动态链接,安装使用新版本相同动态库的第二个应用程序可能会更改第一个应用程序的行为,而静态链接则不会受到影响。
动态链接的优点包括:
- 同时运行的多个应用程序可以共享相同的动态库,而无需将库的多个实例加载到内存中。这在同时运行多个进程的服务器上非常有用。实际上,只有代码段和只读数据段可以共享。任何可写数据段需要每个进程的一个实例。
- 可以更新动态库到新版本而无需更新调用它的程序。
- 动态库可以从不支持静态链接的编程语言中调用。
- 动态库对于创建添加功能到现有程序的插件非常有用。
权衡了上述每种方法的优点后,显然静态链接更适用于速度关键的函数。许多函数库都提供静态和动态版本。如果速度很重要,建议使用静态版本。
某些系统允许延迟绑定函数调用。延迟绑定的原则是,在程序加载时不解析链接函数的地址,而是等到第一次调用函数时再解析。延迟绑定对于大型库来说很有用,因为在单个会话中只有少数函数会被实际调用。但是,延迟绑定明显降低了被调用的函数的性能。当首次调用函数时,会出现相当的延迟,因为需要加载动态链接器。
延迟绑定的延迟导致交互式程序出现可用性问题,因为例如菜单点击的响应时间变得不一致,有时过长无法接受。因此,延迟绑定应仅用于非常大的库。
不能提前确定动态库加载的内存地址,因为固定地址可能与需要相同地址的其他动态库冲突。解决这个问题有两种常用方法:
1. 重定位。如果需要,代码中的所有指针和地址都被修改以适应实际的加载地址。重定位由链接器和加载器完成。
2. 位置无关代码。代码中的所有地址都相对于当前位置。地址计算在运行时进行。
Windows DLL使用重定位。DLL由链接器重定位到特定的加载地址。如果该地址已被占用,则加载器将DLL重新定位(rebasing)到另一个地址。主可执行文件对DLL中的函数的调用通过导入表或指针进行。通过导入的指针,主可执行文件可以访问DLL中的变量,但这种特性很少使用。更常见的是通过函数调用交换数据或数据指针。DLL内部对数据的引用在32位模式下使用绝对引用,在64位模式下主要使用相对引用。后者略微更高效,因为相对引用不需要在加载时进行重定位。
Unix-like系统中的共享对象默认使用位置无关代码。这比重定位效率低,特别是在32位模式下。下一章将介绍这是如何工作的,并提出避免位置无关代码开销的方法。
14.12 Position-independent code
Linux、BSD和Mac系统中的共享对象通常使用所谓的位置无关代码。实际上,“位置无关代码”这个名称暗示了比其所说更多的东西。编译为位置无关的代码具有以下特征:
• 代码段不包含需要重定位的绝对地址,而只包含自相对地址。因此,代码段可以在任意内存地址加载并在多个进程之间共享。
• 数据段不会在多个进程之间共享,因为它经常包含可写数据。因此,数据段可能包含需要重定位的指针或地址。
• 在Linux和BSD中,所有公共函数和公共数据都可以被覆盖。如果主执行文件中的一个函数和共享对象中的一个函数名称相同,则主函数将优先于共享对象调用,不仅在从主方调用时,而且在从共享对象调用时也是如此。同样,当主程序中一个全局变量和共享对象中一个全局变量名称相同时,将使用主程序中的实例,即使从共享对象中访问也是如此。这种所谓的符号重定位旨在模拟静态库的行为。共享对象具有指向其函数的指针表,称为过程链接表(PLT),以及指向其变量的指针表,称为全局偏移表(GOT)以实现这种“覆盖”功能。所有对函数和公共变量的访问都通过PLT和GOT进行。
允许在Linux和BSD中覆盖公共函数和数据的符号重定位功能价格高昂,在大多数库中从未使用过。每当调用共享对象中的函数时,必须在过程链接表(PLT)中查找函数地址。每当访问共享对象中的公共变量时,必须先在全局偏移表(GOT)中查找变量的地址。即使从同一共享对象中访问函数或变量,也需要进行这些表查找操作。显然,所有这些表查找操作都会显著减缓执行速度。更详细的讨论可以在http://www.macieira.org/blog/2012/01/sorry-state-of-dynamic-libraries-on-linux/中找到。
另一个严重的负担是在32位模式下计算自相对引用。32位x86指令集没有用于自相对寻址数据的指令。访问公共数据对象的代码经过以下步骤:(1)通过函数调用获得其自己的地址。 (2)通过自相对地址找到全局偏移表(GOT)。 (3)在GOT中查找数据对象的地址,最后(4)通过该地址访问数据对象。在64位模式下不需要步骤(1),因为x86-64指令集支持自相对寻址。
在32位Linux和BSD中,所有静态数据都使用较慢的GOT查找过程,包括不需要“覆盖”功能的本地数据。这包括静态变量、浮点常量、字符串常量和初始化数组。我无法解释为什么在不需要时会使用这种延迟过程。
显然,避免繁重的位置无关代码和表查找的最佳方法是使用静态链接,如前一章节所述(第158页)。在无法避免动态链接的情况下,有各种方法可以避免耗时的位置无关代码特性。这些解决方法根据系统的不同而异,如下所述。
Shared objects in 32 bit Linux
通常,共享对象编译时会使用"-fpic"选项,根据GNU编译器手册的说明。该选项使得代码段具有位置无关性,并为所有函数生成PLT(过程链接表),以及为所有公共和静态数据生成GOT(全局偏移表)。
也可以在不使用"-fpic"选项的情况下编译共享对象。这样可以摆脱上述提到的所有问题。现在,代码将运行更快,因为我们可以在一个步骤中访问内部变量和内部函数,而不是通过复杂的地址计算和表查找机制。没有使用"-fpic"编译的共享对象速度更快,除非是非常大的共享对象,其中大多数函数从不被调用。在32位Linux中,没有使用"-fpic"编译的缺点是加载器将有更多的引用需要重定位,但这些地址计算只需要进行一次,而运行时的地址计算必须在每次访问时进行。当没有使用"-fpic"编译时,代码段对于每个进程都需要一个实例,因为代码段中的重定位对于每个进程都是不同的。显然,我们失去了覆盖公共符号的能力,但这个功能通常很少使用。
为了便于迁移到64位模式,最好避免使用全局变量或隐藏它们,如下所述。
Shared objects in 64 bit Linux
在64位模式下,计算自相对地址的过程要简单得多,因为64位指令集支持对数据进行相对寻址。在64位代码中,默认情况下经常使用相对地址,因此对特殊的位置无关代码的需求较小。然而,我们仍然希望消除本地引用中GOT和PLT的查询。
如果我们在64位模式下没有使用-fpic选项编译共享对象,会遇到另一个问题。编译器有时会使用32位绝对地址,主要用于静态数组。这在主可执行文件中可以正常工作,因为它肯定会被加载到2 GB以下的地址,但在共享对象中不行,因为共享对象通常加载在较高的地址上,32位(有符号)地址无法达到该地址。链接器将会生成错误消息。最好的解决方案是使用-fpie选项而不是-fpic进行编译。这将在代码段中生成相对地址,但不会对内部引用使用GOT和PLT。因此,与使用-fpic编译相比,它将运行更快,并且不会有上述32位情况的缺点。在32位模式下,-fpie选项不那么有用,因为它仍然使用GOT。
另一种可能性是使用-mcmodel=large进行编译,但这将对所有内容使用完整的64位地址,这是相当低效的,并且会在代码段中生成重定位,因此无法共享。
如果使用-fpie选项创建64位共享对象,则不能有公共变量,因为当链接器看到对公共变量的相对引用时(它期望GOT条目),它会生成一个错误消息。可以通过避免任何公共变量来避免此错误,所有全局变量(即在任何函数外定义的变量)应使用"static"或"__attribute__((visibility("hidden")))"声明进行隐藏。
GNU编译器版本5.1及更高版本具有-fno-semantic-interposition选项,它使其避免使用PLT和GOT查找,但仅适用于同一文件内的引用。也可以使用内联汇编代码为变量提供两个名称(一个全局名称和一个局部名称),并对局部引用使用局部名称,以达到相同的效果。
尽管有这些技巧,当从一个模块(源文件)制作共享对象并且存在从一个模块调用另一个模块时,仍然可能收到以下错误消息:"在生成共享对象时,关于符号`functionname'的重定位R_X86_64_PC32无法使用;请重新使用-fPIC进行编译"。目前我还没有找到解决这个问题的方法。
Shared objects in BSD
BSD中的共享对象与Linux中的工作方式相同。
32-bit Mac OS X
在32位Mac OS X中,即使不使用共享对象,编译器默认也会生成位置无关代码和延迟绑定。目前用于在32位Mac代码中计算自相对地址的方法使用了一种不幸的方法,它通过使返回地址错误预测来延迟执行(有关返回预测的解释,请参见手册3:“英特尔、AMD和威盛CPU的微架构”)。
除共享对象外的所有代码都可以通过关闭编译器的位置无关代码标志来显著加快速度。因此,请始终在为32位Mac OS X编译时指定编译器选项-fno-pic,除非您正在制作共享对象。
使用选项-fno-pic进行编译并使用选项-read_only_relocs suppress进行链接时,可以制作不带位置无关代码的共享对象。
对于内部引用,不使用GOT和PLT表。
64-bit Mac OS X
代码段始终是位置无关的,因为这是在此处使用的内存模型中最有效的解决方案。编译器选项-fno-pic显然没有影响。
对于内部引用,不使用GOT和PLT表。
在Mac OS X中加速64位共享对象无需采取特殊预防措施。
14.13 System programming
设备驱动程序、中断服务例程、系统核心和高优先级线程是特别关键的速度区域。系统代码或高优先级线程中非常耗时的函数可能会阻止执行所有其他操作。
系统代码必须遵守有关寄存器使用的特定规则,如手册5中的“不同C++编译器和操作系统的调用约定”章节所述。因此,您只能使用用于系统代码的编译器和函数库。系统代码应该用C、C++或汇编语言编写。
在系统代码中节省资源使用非常重要。动态内存分配尤其有风险,因为它涉及在不方便的时间激活非常耗时的垃圾回收器的风险。队列应该实现为固定大小的循环缓冲区,而不是作为链接列表。不要使用标准的C++容器(请参见第95页)。