Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

C++和标准库速成

基础知识

类型

枚举类型只是一个整数值,如果试图对枚举类型进行算术操作,编译器会给出警告或错误信息。如果没有给出一个枚举成员的整型值,编译器会将上一个枚举成员的值递增1,再赋予当前的枚举成员。如果没有给第一个枚举成员赋值,编译器就给它赋值0。

强类型枚举

上面给出的枚举并不是强类型的,这意味着其并非类型安全的。它们总被解释为整型数据,因此可以比较完全不同的枚举类型中的枚举值。强类型的enum class枚举解决了这些问题,例如,下面定义前述PieceType枚举的类型安全版本:

1
2
3
4
5
6
enum class PieceType 
King = 1,
Queen,
Rook = 10,
Pawn
};

对于enumclass,枚举值名不会自动超出封闭的作用域,这表示总要使用作用域解析操作符:
1
PieceType piece = PieceType::King;

这也意味着给枚举值指定了更简短的名称,例如,用King替代PieceTypeKing。另外,枚举值不会自动转换为整数。因此,下面的代码是不合法的:
1
if (PieceType: :Queen == 2) {...}

默认情况下,枚举值的基本类型是整型,但可采用以下方式加以改变:
1
2
3
4
5
6
enum Class PieceType : unsigned long
King = 1,
Queen,
Rook = 10,
Pawn
};

if/else

C++17允许在if中包含一个初始化器:

1
if (<initializer>; <conditional_expression>) {<body>}

switch

一旦找到与switch条件匹配的case表达式,就执行其后的所有语句,直至遇到break语句为止。即使遇到另一个case表达式,执行也会继续,这称为fallthrough。下例有一组语句,会为不同的case执行:

1
2
3
4
5
6
7
8
9
switch (backgroundColor) {
case Color::DarkBlue:
case Color::Black:
// Code to execute for both a dark blue or black background color
break;
case Color::Red:
// Code to execute for a red background color
break;
}

如果你无意间忘掉了break语句,fllthrough 将成为bug的来源。因此,如果在switch语句中检测到fallthrough,编译器将生成警告信息,除非像上例那样case 为空。从C++17开始,你可以使用allthrough]特性,告诉编译器某个fallthrough 是有意为之,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
switch (backgroundColor) {
case Color::DarkBlue:
doSomethingForDarkBlue () ;
[[fallthrough]] ;
case Color::Black:
// Code is executed for both a dark blue or black background color
doSome thingForBlackOrDarkBlue() ;
break;
case Color::Red:
case Color::Green:
// Code to execute for a red or green background color
break;
}

逻辑符

C++对表达式求值时会采用短路逻辑。这意味着一旦最终结果可确定,就不对表达式的剩余部分求值。例如,当执行如下所示的多个布尔表达式的逻辑或操作时,如果发现其中一个表达式的值为true,立刻可判定其结果为true,就不再检测剩余部分。

1
bool result = bool1 || bool2 || (i > 7) || (27 / 13 % i + 1)<2;

在此例中,如果bool1的值是true,整个表达式的值必然为true,因此不会对其他部分求值。这种方法可阻止代码执行多余操作。然而,如果后面的表达式以某种方式影响程序的状态,就会带来难以发现的bug。

短路做法对性能有好处。在使用逻辑短路时,可将代价更低的测试放在前面,以避免执行代价更高的测试。在指针上下文中,它也可避免指针无效时执行表达式的一部分的情况。本章后面将讨论指针以及包含短路的指针。

函数

函数返回类型的推断

C++14允许要求编译器自动推断出函数的返回类型。要使用这个功能,需要把auto指定为返回类型:

1
2
auto addNumbers (int number1, int number2)
return number1 + number2;

编译器根据return语句使用的表达式推断返回类型。函数中可有多个return语句,但它们应解析为相同的类型。这种函数甚至可包含递归调用(调用自身),但函数中的第一个return语句必须是非递归调用。

当前函数的名称

每个函数都有一个预定义的局部变量__func__, 其中包含当前函数的名称。这个变量的一个用途是用于日志记录:

1
2
3
4
int addNumbers(int number1, int number2) {
std::cout << "Entering function " << func << std::endl;
return numberl + number2 ;
}

std::array

上一节讨论的数组来自C,仍能在C++中使用。但C++有一种大小固定的特殊容器std:array,这种容器在<array>头文件中定义。它基本上是对C风格的数组进行了简单包装。用std:array替代C风格的数组会带来很多好处。它总是知道自身大小,不会自动转换为指针,从而避免了某些类型的bug;具有迭代器,可方便地遍历元素。

下例演示了array 容器的用法,必须在尖括号中指定两个参数。第一个参数表示数组中元素的类型,第二个参数表示数组的大小。

1
2
3
array<int, 3> arr = {987};
cout << "Array size = " << arr.size() << endl;
cout << "2nd element = " << arr[1] << endl ;

C风格的数组和std:array都具有固定的大小,在编译时必须知道这一点。在运行时数组不会增大或缩小。

std::vector

标准库提供了多个不同的非固定大小容器,可用于存储信息。std:vector就是此类容器的一个示例,它在<vector>中声明,用一种更灵活和安全的机制取代C中数组的概念。用户不需要担心内存的管理,因为vector将自动分配足够的内存来存放其元素。vector 是动态的,意味着可在运行时添加和删除元素。下面的示例演示了vector的基本功能。

1
2
3
4
5
// Create a vector of integers
vector<int> myVector = { 11, 22 };
// Add some more integers to the vector using push_ back()
myVector.push_back(33);
myVector.push_back(44);

深入研究C++

C++中的字符串

在C++中使用字符串有三种方法。一种是C风格,将字符串看成字符数组;一种是C++风格,将字符串封装到一种易于使用的string类型中;还有一种是非标准的普通类。

与I/0流一样,string类型位于std名称空间中。下面的示例说明了string 如何像字符数组那样使用:

1
2
3
string myString = "He1lo, world";
cout << "The value of myString is" << myString << endl;
cout << "The second letter is " << myString[1] << endl;

指针和动态内存

动态内存允许所创建的程序具有在编译时大小可变的数据,大多数复杂程序都会以某种方式使用动态内存。

堆栈和堆

C++程序中的内存分为两个部分——堆栈和堆。当前函数中声明的所有变量将占用顶部堆栈帧的内存。如果当前函数调用了另一个函数bar()bar()就会拥有自己的堆栈帧供其运行。任何从foo()传递给bar()的参数都会从foo()堆栈帧复制到bar()堆栈帧。

堆栈帧很好,因为它为每个函数提供了独立的内存空间。如果在foo()堆栈帧中声明了一个变量,那么除非专门要求,否则调用bar()函数不会更改该变量。此外,foo()函数执行完毕时,堆栈帧就会消失,该函数中声明的所有变量都不会再占用内存。在堆栈上分配内存的变量不需要由程序员释放内存,这个过程是自动完成的。

堆是与当前函数或堆栈帧完全没有关系的内存区域。如果想在函数调用结束之后仍然保存其中声明的变量,可以将变量放到堆中。程序可在任何时候向堆中添加新位或修改堆中已有的位。必须确保释放在堆上分配的任何内存,这个过程不会自动完成。

动态分配的数组

堆也可以用于动态分配数组。使用new[]操作符可给数组分配内存:

1
2
int arraySize = 8;
int* myVariableSizedArray = new int[arraySize] ;

这条语句分配的内存用于存储8个整数,内存的大小与arraySize变量对应。图1-3显示了执行这条语句后堆栈和堆的情况。可以看到,指针变量仍在堆栈中,但动态创建的数组在堆中。

现在已经分配了内存,可将myVariableSizedArray当作基于堆栈的普通数组使用

在C++中,每次调用new时,都必须相应地调用delete;每次调用new[]时,都必须相应地调用delete[],以避免内存泄漏。如果未调用deletedelete[],或调用不匹配,会导致内存泄漏。

空指针常量

在C++11之前,常量NULL用于表示空指针。将NULL定义为常量0会导致一些问题。分析下面的例子:

1
2
3
4
5
6
void func(char* str) {cout << "char* version" << end1; }
void func(int i) {cout << "int version" << endl; }
int main() {
func (NULL);
return 0;
}

main()函数通过参数NULL调用func(), NULL是一个空指针常量。换言之,该例要用空指针作为实参,调用func()char*版本。但是,NULL不是指针,而等价于整数0,所以实际调用的是func()的整数版本。

可引入真正的空指针常量nullptr解决这个问题。

统一初始化

C++11之前,Struct变量和Class变量的初始化是不同的:

1
2
CircleStruct myCircle1 = {10, 10, 2.5};
CircleClass myCircle2(10, 10, 2.5);

对于结构版本,可使用{…}语法。然而,对于类版本,需要使用函数符号(..)调用构造函数。自C++11以后,允许使用{…}语法初始化类型,如下所示:
1
2
CircleStruct myCircle3 = {10102.5};
CircleClass myCircle4 = {10102.5};

定义myCircle4时将自动调用CircleClass的构造函数。甚至等号也是可选的,因此下面的代码与前面的代码等价:
1
2
CircleStruct myCircle5{10102.5};
CircleClass myCircle6{1010, 2.5};

统一初始化并不局限于结构和类,它还可用于初始化C++中的任何内容。例如,下面的代码把所有4个变量都初始化为3:

1
2
3
4
int a = 3;
int b(3) ;
int c = {3}; // Uniform initial ization
int d{3};

统一初始化还可用于将变量初始化为0;使用默认构造函数构造对象,将基本整数类型(如char和int等)初始化为0,将浮点类型初始化为0.0,将指针类型初始化为nullptr。例如:

1
2
int e{};
// Uniform initialization, e will be 0

使用统一初始化还可以阻止窄化。C++隐式地执行窄化,例如:

1
2
3
4
5
void func(int i) { /*... */ }
int main() {
int x = 3.14;
func(3.14) ;
}

这两种情况下,C++在对x赋值或调用func()之前,会自动将3.14截断为3。注意有些编译器会针对窄化给出警告信息,而另一些编译器则不会。使用统一初始化,如果编译器完全支持C++11标准,x的赋值和func()的调用都会生成编译错误:
1
2
3
4
5
6
7
void func(int i) { /* ... */ }
int main() {
int x = {3.14};
// Error because narrowing
func({3.14});
// Error because narrowing
}

统一初始化还可用来初始化动态分配的数组:
1
int* pArray = new int[4]{0, 1, 23};

统一初始化还可在构造函数初始化器中初始化类成员数组:
1
2
3
4
5
6
7
class MyClass
{
public:
MyClass() : mArray{0, 1, 2, 3} ()
private:
int mArray[4];
};

直接列表初始化与复制列表初始化

有两种初始化类型使用包含在大括号中的初始化列表:

  • 复制列表初始化:T obj = {argl, arg2, ...};
  • 直接列表初始化:T obj {argl, arg2, ...};

在C++17中,与auto类型推断相结合,直接列表初始化与复制列表初始化存在重要区别。从C++17开始,可得到以下结果:

1
2
3
4
5
6
7
// Copy list initialization
auto a = {11}; // initializer_list<int>
auto b = {11, 22}; // initializer_list<int>

// Direct list initialization
auto c {11}; // int
auto d {11, 22}; // Error, too many elements.

注意,对于复制列表初始化,放在大括号中的初始化器的所有元素都必须使用相同的类型。例如,以下代码无法编译:
1
auto b = {11, 22.33}; // Compilation error

在早期标准版本(C++11/14)中,复制列表初始化和直接列表初始化会推导出initializer_ list<>
1
2
3
4
5
6
7
// Copy list initialization
auto a = {11}; // initializer_list<int>
auto b = {11, 22}; // initializer_list<int>

// Direct list initialization
auto c {11}; // initializer_list<int>
auto d {11, 22}; // initializer_list<int>

使用string和string_view

动态字符串

C语言中并没有真正好用的string数据类型,只有固定的字节数组。“字符串库”只不过是一组非常原始的函数,甚至没有边界检查的功能。C++提供了string 类型作为数据类型。

C风格的字符串

在C语言中,字符串表示为字符的数组。字符串中的最后-一个字符是null字符(‘\0’),目前,程序员使用C字符串时最常犯的错误是忘记为’\0’字符分配空间。

C++包含一些来自C语言的字符串操作函数,它们在<string>头文件中定义。为字符串分配内存的正确方式是在实际字符所需的空间加1。所以在使用C风格的字符串时要记住这一点。正确的实现代码如下:

1
2
3
4
5
char* copyString (const char* str) {
char* result = new char [strlen(str) + 1] ;
strcpy(result, str);
return result;
}

C和C++中的sizeof()操作符可用于获得给定数据类型或变量的大小。例如,sizeof(char)返回1,因为字符的大小是1字节。但在C风格的字符串中,sizeof()strlen()是不同的。绝对不要通过sizeof()获得字符串的大小。它根据C风格的字符串的存储方式来返回不同大小。如果C风格的字符串存储为char[],则sizeof()返回字符串使用的实际内存,包括’\0’字符。例如:

1
2
3
char text1[] = "abcdef";
size_t s1 = sizeof(text1); // is 7
size_t s2 = strlen(text1); // is 6

但是,如果C风格的字符串存储为char*sizeof()就返回指针的大小。
1
2
3
const char* text2 = "abcdef";
size_t s3 = sizeof(text2); // is platform-dependent
size_t s4 = strlen(text2); // is 6

在32位模式下编译时,s3的值为4;而在64位模式下编译时,s3的值为8,因为这返回的是指针const char*的大小。

字符串字面量

与字符串字面量关联的真正内存位于内存的只读部分。通过这种方式,编译器可重用等价字符串字面量的引用,从而优化内存的使用。也就是说,即使一个程序使用了500次”hello”字符串字面量,编译器也只在内存中创建一个 hello 实例。这种技术称为字面量池(literal pooling)。

字符串字面量可赋值给变量,但因为字符串字面量位于内存的只读部分,且使用了字面量池,所以这样做会产生风险。C++标准正式指出:字符串字面量的类型为“n个const char 的数组”,然而为了向后兼容较老的不支持const的代码,大部分编译器不会强制程序将字符串字面量赋值给const char*类型的变量。这些编译器允许将字符串字面量赋值给不带有const的char*,而且整个程序可正常运行,除非试图修改字符串。一般情况下,试图修改字符串字面量的行为是没有定义的。可能会导致程序崩溃;可能使程序继续执行,看起来却有莫名其妙的副作用:可能不加通告地忽略修改行为;可能修改行为是有效的,这完全取决于编译器。

还可将字符串字面量用作字符数组(char[])的初始值。这种情况下,编译器会创建一个足以放下这个字符串的数组,然后将字符串复制到这个数组。因此,编译器不会将字面量放在只读的内存中,也不会进行字面量的
池操作。

原始字符串字面量(raw string literal)是可横跨多行代码的字符串字面量,不需要转义嵌入的双引号,像\t和\n这种转义序列不按照转义序列的方式处理,而是按照普通文本的方式处理。

1
const char* str = R"(Hello "World"!)";

C++ std::string类

在C++的string 类中,operator==operator!=operator<等运算符都被重载了,这些运算符可以操作真正的字符串字符。单独的字符可通过运算符operator[]访问。如下面的代码所示,当string操作需要扩展string时,string 类能够自动处理内存需求,因此不会再出现内存溢出的情况了:

1
2
3
4
5
6
7
8
string myString = "hello";
myString += ", there";
string myOtherString = myString;

if (myString == myOtherString)
myOtherString[0] = 'H';
cout << myString << endl ;
cout << myOtherString << endl;

在这个例子中有几点需要注意。一是要注意即使字符串被分配和调整大小,也不会出现内存泄漏的情况。所有这些string对象都创建为堆栈变量。尽管string类肯定需要完成大量分配内存和调整大小的工作,但是string
类的析构函数会在string对象离开作用域时清理内存。另外需要注意的是,运算符以预期的方式工作。例如,=运算符复制字符串,这是最有可能预期的操作。

为达到兼容的目的,还可应用string类的c_str()方法获得一个表示C风格字符串的const字符指针。不过,一旦string执行任何内存重分配或string对象被销毁了,返回的这个const指针就失效了。应该在使用结果之前调用这个方法,以便它准确反映string当前的内容。永远不要从函数中返回在基于堆栈的string上调用c_str()的结果。

还有一个data()方法,在C++14及更早的版本中,始终与c_str()一样返回const char*。 从C++17开始,在非const字符上调用时,data()返回char*

std:string字面量

源代码中的字符串字面量通常解释为const char*。 使用用户定义的标准字面量s可以把字符串字面量解释为std:string。例如:

1
2
3
4
auto string1 = "Hello World";
// string1 is a const char*
auto string2 = "Hello World"s;
// string2 is an std::string

用户定义的标准字面量s需要using namespace std:string_literals;using namespace std;

高级数值转换

std名称空间包含很多辅助函数,以便完成数值和字符串之间的转换。下面的函数可用于将数值转换为字符串。所有这些函数都负责内存分配,它们会创建一个新的string对象并返回。

  • string to_string(int val);
  • string to_string(unsigned val);
  • string to_string(long val);
  • string to_string(unsigned long val);
  • string to_string(long long val);
  • string to string(unsigned long long val);
  • string to_string(float val);
  • string to_string(double val);
  • string to_string(long double val);

通过下面这组也在std名称空间中定义的函数将字符串转换为数值。在这些函数原型中,str表示要转换的字符串,idx是一个指针,这个指针将接收第一个未转换的字符的索引,base表示转换过程中使用的进制。idx指针可以是空指针,如果是空指针,则被忽略。如果不能执行任何转换,这些函数会抛出invalid_argument异常,如果转换的值超出返回类型的范围,则抛出out_of_range异常。

  • int stoi(const string& str, size_t *idx=0, int base= 10);
  • long stol(const string& str, size_t *idx=0, int base=10);
  • unsigned long stoul(const string& str, size_t *idx=0, int base=10);
  • long long stol(const string& str, size_t *idx=0, int base=10);
  • unsigned long long stoul(const string& str, size_t *idx=0, int base= 10);
  • float stof(const string& str, size_t *idx=0);
  • double stod(const string& str, size_t *idx=0);
  • long double stold(const string& str, size_t *idx=0);

std::string_view类

在C++17中,引入std:string_view类解决了开销和易用性的问题,std:string_view类是std:basic_string_view类模板的实例化,在<string_view>头文件中定义。string_view基本上就是const string&的简单替代品,但不会产生开销。它从不复制字符串,string_view支持与std:string类似的接口。一个例外是缺少c_str(),但data()是可用的。另外,string_view确实添加了remove_prefix(size_t)remove sufix(size_t)方法;前者将起始指针前移给定的偏移量来收缩字符串,后者则将结尾指针倒退给定的偏移量来收缩字符串。

注意,无法连接一个string和一个string_view。下面的代码将无法编译:

1
2
3
string str = "Hello";
string_view sv = "world";
auto result = str + sv;

为进行编译,必须将最后一行替代为:
1
auto result = str + sv.data() ;

内存管理

使用动态内存

这个例子展示了指针既可在堆栈中,也可在堆中。

1
2
3
int** handle = nullptr;
handle = new int*;
*handle = new int;

上面的代码首先声明一个指向整数指针的指针变量handle。然后,动态分配足够的内存来保存一个指向整数的指针,并将指向这个新内存的指针保存在handle中。接下来,将另一块足以保存整数的动态内存的指针保存在*handle的内存位置。一个指针保存在堆栈中(handle),另一个指针保存在堆中(*handle)。

分配和释放

要为变量创建空间,可使用new关键字。要释放这个空间给程序中的其他部分使用,可使用delete关键字。

使用new和delete

要分配一块内存,可调用new,并提供需要空间的变量的类型。new 返回指向那个内存的指针,但程序员应将这个指针保存在变量中。如果忽略了new的返回值,或这个指针变量离开了作用域,那么这块内存就被孤立了,因为无法再访问这块内存。这也称为内存泄漏

除非计算机能提供无限制的高速内存,否则就需要告诉编译器,对象关联的内存什么时候可以释放,用作他用。为释放堆中的内存,只需要使用delete关键字,并提供指向那块内存的指针,如下所示:

1
2
3
int* ptr = new int;
delete ptr
ptr = nullptr;

建议在释放指针的内存后,将指针重新设置为nullptr. 这样就不会在无意中使用一个指向已释放内存的指针。

在C++中不应该使用malloc()free()函数。只使用new和delete运算符。malloc()free()函数不会调用构造函数和析构函数。

在C++中有一个继承自C语言的函数realloc()。不要使用它!在C中,reallo()用 于改变数组的大小,采取的方法是分配新大小的新内存块,然后将所有旧数据复制到新位置,再删除旧内存块。在C++中这种做法是极其危险的,因为用户定义的对象不能很好地适应按位复制。

当内存分配失败时

默认情况下,如果new失败了,程序会终止。当new因为没有足以满足请求的内存而抛出异常失败时,程序退出。也有不抛出异常的new版本。相反,它会返回nullptr,这类似于C语言中malloc()的行为。使用这个版本的语法如下所示:

1
int* ptr = new (nothrow) int;

数组

对象的数组

对象的数组和简单类型的数组没有区别。通过new[N]分配N个对象的数组时,实际上分配了N个连续的内存块,每一块足以容纳单个对象。使用new[]时,每个对象的无参构造函数=default会自动调用。这样,通过new[]分配对象数组时,会返回一个指向数组的指针,这个数组中的所有对象都被初始化了。

1
2
3
4
5
class Simple {
public:
Simple() { cout << "Simple constructor called!" << endl; }
~Simple() { cout << "Simple destructor called!" << endl; }
};

如果要分配包含4个Simple对象的数组,那么Simple构造函数会被调用4次。

1
Simple* mySimpleArray = new Simple[4];

删除数组

如前所述,通过数组版本的new(new[])分配内存时,必须通过数组版本的delete(delete[])释放相应的内存。这个版本的delete会自动析构数组中的对象,并释放这些对象的内存。

1
2
3
4
Simple* mySimpleArray = new Simple[4];
// Use mySimpleArray
delete [] mySimpleArray;
mySimpleArray = nullptr;

如果不使用数组版本的delete, 程序就可能出现异常行为。在一些编译器中,可能只会调用数组中第1个元素的析构函数,因为编译器只知道要删除指向一个对象的指针,而数组中的其他所有元素都变成了孤立对象。在其他编译器中,可能出现内存崩溃的情况,因为newnew[]可能采用完全不同的内存分配方案。

数组-指针的对偶性

在堆上分配的数组通过指向该数组中第一个元素的指针来引用。基于堆栈的数组通过数组语法([])和普通的变量声明来引用。

数组就是指针

通过指针不仅能指向基于堆的数组,也可以通过指针语法来访问基于堆栈的数组的元素。数组的地址就是第1个元素(索引0)的地址。编译器知道,通过变量名引用整个数组时,实际上引用的是第1个元素的地址。从这个角度看,指针用起来就像基于堆的数组。下面的代码创建了一个堆栈上的数组,数组元素初始化为0,但通过一个指针来访问这个数组:

1
2
3
4
int myIntArray[10] = {};
int* myIntPtr = myIntArray;

myIntPtr[4] = 5;

向函数传递数组时,通过指针引用基于堆栈的数组的能力非常有用。下面的函数以指针的方式接收一个整数数组。请注意,调用者需要显式地传入数组的大小,因为指针没有包含任何与大小有关的信息。事实上,任何形式的C++数组,不论是不是指针,都没有内含大小信息。这是应使用现代容器(例如标准库提供的容器)的另一个原因。
1
2
3
4
5
void doubleInts (int* theArray, size_ _t size) {
for (size_t i=0; i < size; i++){
theArray[i] *= 2;
}
}

这个函数的调用者可以传入基于堆栈或堆的数组。在传入基于堆的数组时,指针已经存在了,且按值传入函数。在传入基于堆栈的数组时,调用者可以传入一个数组变量,编译器会自动把这个数组变量当作指向数组的指针处理,还可以显式地传入第一个元素的地址。这里展示了所有三种形式:
1
2
3
4
5
6
7
8
9
10
size_t arrsize = 4;
int* heapArray = new int[arrSize]{ 1, 5, 3, 4 };
doubleInts(heapArray, arrSize) ;
delete [] heapArray;
heapArray = nullptr;

int stackArray[] = { 5, 79, 11 };
arrSize = std::size(stackArray);
doubleInts(stackArray, arrSize) ;
doubleInts(&stackArray[0],arrSize) ;

数组参数传递的语义和指针参数传递的语义十分相似,因为当把数组传递给函数时,编译器将数组视为指针。函数如果接收数组作为参数,并修改数组中元素的值,实际上修改的是原始数组而不是副本。与指针一样,传递数组实际上模仿的是按引用传递的功能,因为真正传入函数的是原始数组的地址而不是副本。

为什么在函数定义中使用数组语法时编译器不复制数组?这样做是为了提高效率——复制数组中的元素需要时间,而且数组可能占用大量的内存。总是传递指针,编译器就不需要包括复制数组的代码。

可“按引用”给函数传递长度已知的基于堆栈的数组,但其语法并不明显。它不适用于基于堆的数组。例如,下面的doubleIntsStack0仅接收大小为4的基于堆栈的数组:

1
void doubleIntsStack(int (&theArray) [4]);

低级内存操作

如果代码使用了对象,只需要确保每个类都妥善管理自己的内存。通过构造和析构,编译器可提示什么时候管理内存。将内存管理隐藏在类中可以极大地改变可用性。

指针运算

C++编译器通过声明的指针类型允许执行指针运算。如果声明一个指向int的指针,然后将这个指针递增1,那么这个指针在内存中向前移动1个int 的大小,而不是1个字节。此类操作对数组最有用,因为数组在内存中包含同构的数据序列。例如,假设在堆中声明一个整数数组:

1
int* myArray = new int[8];

下面的语法给该数组中位置2的元素设置值:
1
myArray[2] = 33;

使用指针运算可等价地使用下面的语法,这个语法获得myArray数组中“向前2个int”位置的内存地址,然后解除引用来设置值:

1
*(myArray + 2) = 33;

作为访问单个元素的替代语法,指针运算似乎没有太大吸引力。其真正的作用在于以下事实:像myArray+2这样的表达式仍是一个指向int的指针,因而可以表示一个更小的整数数组。

自定义内存管理

在99%的情况下,C++中内置的内存分配设施是足够使用的。new和delete在后台完成了所有相关工作:分配正确大小的内存块、管理可用的内存区域列表以及释放内存时将内存块释放回可用内存列表。

自行管理内存可能减少开销。当使用new分配内存时,程序还需要预留少量的空间来记录分配了多少内存。这样,当调用delete时,可以释放正确数量的内存。对于大多数对象,这个开销比实际分配的内存小得多,所以差别不大。然而,对于很小的对象或分配了大量对象的程序来说,这个开销的影响可能会很大。

当自行管理内存时,可事先了解每个对象的大小,因此可避免每个对象的开销。

垃圾回收

内存清理的另一个方面是垃圾回收。在支持垃圾回收的环境中,程序员几乎不必显式地释放与对象关联的内存。运行时库会在某时刻自动清理没有任何引用的对象。在现代C++中,使用智能指针管理内存,在旧代码中,则在对象层次通过new和delete管理内存。

标记(mark)和清扫(sweep)是一种垃圾回收的方法。使用这种方法的垃圾回收器定期检查程序中的每个指针,并将指针引用的内存标记为仍在使用。在每一轮周期结束时,未标记的内存视为没有在使用,因而被释放。

如果愿意执行以下操作,那么可以在C++中实现标记和清扫算法:

  1. 在垃圾回收器中注册所有指针,这样垃圾回收器可轻松遍历所有指针。
  2. 让所有对象都从一个混入类中派生,这个混入类可能是GartbageCollectible,允许垃圾回收器将对象标记为正在使用中。
  3. 确保在垃圾回收器运行时不能修改指针,从而保护对象的并发访问。

垃圾回收存在以下缺点:

  • 当垃圾回收器正在运行时,程序可能停止响应。
  • 使用垃圾回收器时,析构函数具有不确定性。由于对象在被垃圾回收之前不会销毁,因此对象离开作用域时不会立即执行析构函数。这意味着,由析构函数完成的资源清理操作要在将来某个不确定的时刻进行。

智能指针

智能指针可帮助管理动态分配的内存,这是避免内存泄漏建议采用的技术。这样,智能指针可保存动态分配的资源,如内存。当堆栈变量离开作用域或被重置时,会自动释放所占用的资源。智能指针可用于管理在函数作用域内(或作为类的数据成员)动态分配的资源。也可通过函数实参来传递动态分配的资源的所有权。

C++提供的一些语言特性使智能指针具有吸引力。首先,可通过模板为任何指针类型编写类型安全的智能指针类。其次,可使用运算符重载为智能指针对象提供一个接口,使智能指针对象的使用和普通指针一样。确切地讲,可重载*->运算符,使客户代码解除对智能指针对象的引用的方式和解除对普通指针的引用相同。

智能指针有多种类型。最简单的智能指针类型对资源有唯一的所有权, 当智能指针离开作用域或被重置时,会释放所引用的内存。标准库提供了std::unique_ptr,这是一个具有“唯一所有权” 语义的智能指针。

然而,指针的管理不仅是在指针离开作用域时释放它们。有时,多个对象或代码段包含同一个指针的多个副本。这个问题称为别名。为正确释放所有内存,使用这个资源的最后一个代码块应该释放该指针指向的资源,一种更成熟的智能指针类型实现了“引用计数”来跟踪指针的所有者。每次复制这个“引用计数”智能指针时,都会创建一个指向同一资源的新实例,将引用计数增加1。当这样的一个智能指针实例离开作用域或被重置时,引用计数会减1。当引用计数降为0时,则资源不再有所有者,因此智能指针释放资源。标准库提供了stl:shared_ptr,这是一个使用引用计数且具有“共享所有权”语义的智能指针。标准的shared_ptr是线程安全的,但这不意味着所指向的资源是线程安全的。

unique_ _ptr

作为经验法则,总将动态分配的对象保存在堆栈的unique_ptr实例中。

创建unique_ptrs

考虑下面的函数,这个函数在堆上分配了一个Simple对象,但是不释放这个对象,故意产生内存泄漏。

1
2
3
4
void leaky() {
Simple* mySimplePtr = new Simple(); // BUG! Memory is never released!
mySimplePtr->go() ;
}

实例unique_ptr离开作用域时(在函数的末尾,或者因为抛出了异常),就会在其析构函数中自动释放Simple对象:

1
2
3
4
void notLeaky() {
auto mySimpleSmartPtr = make_unique<Simple>() ;
mySimpleSmartPtr->go();
}

这段代码使用C++14中的make_unique()和auto关键字,所以只需要指定指针的类型,本例中是Simple。如果Simple构造函数需要参数,就把它们放在make_unique()调用的圆括号中。

如果编译器不支持make_unique(), 可创建自己的unique_ptr,如下所示,注意Simple必须写两次:

1
unique_ptr<Simple> mySimpleSmartPtr (new Simple());

在C++17之前,必须使用make_unique(),一是因为只能将类型指定一次, 二是出于安全考虑!考虑下面对foo()函数的调用:

1
foo(unique_ptr<simple> (new Simple()), unique_ptr<Bar>(new Bar (data())));

如果Simple、Bar 或data()函数的构造函数抛出异常(具体取决于编译器的优化设置),很可能是Simple 或Bar对象出现了内存泄漏。而使用make_unique(),则不会发生内存泄漏:
1
foo(make_unique<Simple>(), make_unique<Bar> (data()));

使用unique_ptrs

这个标准智能指针最大的一个亮点是:用户不需要学习大量的新语法,就可以获得巨大好处。与标准指针一样,也可将其写作:

1
(*mySimpleSmartPtr).go();

get()方法可用于直接访问底层指针。这可将指针传递给需要普通指针的函数

1
2
auto mySimpleSmartPtr = make_unique<Simple>() ;
processData(mySimpleSmartPtr.get());

可释放unique_ptr的底层指针,并使用reset()根据需要将其改成另一个指针。例如:

1
2
3
4
mySimpleSmartPtr.reset();
// Free resource and set to nullptr
mySimpleSmartPtr.reset (new Simple()); // Free resource and set to a new
// Simple instance

可使用release()断开unique_ptr与底层指针的连接。release()方法返回资源的底层指针,然后将智能指针设置为nullptr。实际上,智能指针失去对资源的所有权,负责在你用完资源时释放资源。例如:

1
2
3
4
Simple* simple = mySimpleSmartPtr.release(); // Release ownership
// Use the simple pointer...
delete simple;
simple = nullptr;

由于unique_ptr代表唯一拥有权,因此无法复制它!使用std:move()实用工具,可使用移动语义将一个unique_ptr移到另一个。这用于显式移动所有权,如下所示:
1
2
3
4
5
6
7
8
9
10
class Foo
{
public:
Foo (unique_ ptr<int> data) : mData (move (data)) { }
private:
unique_ptr<int> mData;
};

auto myIntSmartPtr = make_unique<int>(42);
Foo f(move (myIntSmartPtr));

unique_ptr和C风格数组

unique_ptr适用于存储动态分配的旧式C风格数组。下例创建了一个unique_ptr来保存动态分配的、包含10个整数的C风格数组:

1
auto myVariableSizedArray = make_unique<int[]>(10) ;

即使可使用unique_ptr存储动态分配的C风格数组,也建议改用标准库容器,例如std:arraystd:vector等。

自定义deleter

默认情况下,unique_ ptr使用标准的new和delete运算符来分配和释放内存。可将此行为改成:

1
2
3
4
5
6
7
8
9
10
int* malloc_int(int value) {
int* p = (int*)malloc(sizeof(int));
*p = value;
return p;
}

int main() {
unique_ptr<int, decltype(free)*> myIntSmartPtr(malloc_int(42), free);
return 0;
}

这段代码使用malloc_int()给整数分配内存。unique_ptr调用标准的free()函数来释放内存。如前所述,在C++中不应该使用malloc(),而应改用new。然而,unique_ ptr的这项特性是很有用的,因为还可管理其他类型的资源而不仅是内存。例如,当unique_ptr离开作用域时,可自动关闭文件或网络套接字以及其他任何资源。

但是,unique_ptr的自定义deleter的语法有些费解。需要将自定义deleter的类型指定为模板类型参数。在本例中,dcltype(free)用于返回free()类型。 模板类型参数应当是函数指针的类型,因此另外附加一个,如`decltype(free)`。

shared_ptr

shared_ptr的用法与unique_ptr类似。要创建shared_ptr,可使用make_shared(),它比直接创建shared_ptr更高效。例如:

1
auto mySimpleSmartPtr = make_shared<Simple>();

从C++17开始,shared_ptr可用于存储动态分配的旧式C风格数组的指针。这在C++17之前是无法实现的。但是,尽管这在C++17中是可能的,仍建议使用标准库容器而非C风格数组。与unique_ptr一样,shared_ptr也支持get()和reset()方法。

unique_ptr类似,shared_ptr默认情况下使用标准的new和delete运算符来分配和释放内存:在C++17中存储C风格数组时,使用new[]delete[]。可更改此行为,如下所示:

1
2
// Implementation of malloc_ int() as before.
shared_ptr<int> myIntSmartPtr (malloc_int(42), free) ;

可以看到,不必将自定义deleter的类型指定为模板类型参数,这比unique_ptr的自定义deleter更简便。

强制转换shared_ptr

可用于强制转换shared_ptrs的函数是const_pointer_cast()dynamic_pointer_cast()static_pointer_cast()。C++17又添加了reinterpret_pointer_cast()。它们的行为和工作方式类似于非智能指针转换函数const_cast()dynamic_cast()static_cast()reinterpret_cast()

引用计数的必要性

作为一般概念, 引用计数(reference counting)用于跟踪正在使用的某个类的实例或特定对象的个数。引用计数的智能指针跟踪为引用一个真实指针(或某个对象)而建立的智能指针的数目。通过这种方式,智能指针可以避免双重删除。

别名

shared_ptr支持所谓的别名。这允许一个shared_ptr与另一个shared_ptr共享一个指针(拥有的指针), 但指向不同的对象(存储的指针)。例如,这可用于使用一个shared_ptr指向一个对象的成员,同时拥有该对象本身,例如:

1
2
3
4
5
6
7
8
class Foo {
public:
Foo(int value) : mData (value) { }
int mData;
};

auto foo = make_shared<Foo> (42) ;
auto aliasing = shared_ptr<int>(foo,&foo->mData) ;

仅当两个shared_ptrs(foo和aliasing)都销毁时,才销毁Foo对象。

“拥有的指针”用于引用计数;当对指针解引用或调用它的get()时,将返回“存储的指针”。存储的指针用于大多数操作,如比较运算符。可以使用owner_before()方法或std:owner_less类,基于拥有的指针执行比较。

在某些情况下(例如在std::set中存储shared_ptrs),这很有用。

weak_ptr

在C++中还有一个类与shared_ptr模板有关,那就是weak_ptrweak_ptr可包含由shared_ptr管理的资源的引用。weak_ptr不拥有这个资源,所以不能阻止shared_ptr释放资源。weak_ptr 销毁时(例如离开作用域时)不会销毁它指向的资源:然而,它可用于判断资源是否已经被关联的shared_ptr释放了。weak_ptr的构造函数要求将一个shared_ptr或另一个weak_ptr作为参数。为了访问weak_ptr中保存的指针,需要将weak_ptr转换为shared_ptr。这有两种方法:

  • 使用weak_ptr实例的lock()方法, 这个方法返回一个shared_ptr。如果同时释放了与weak_ptr关联的shared_ptr, 返回的shared_ptr是nullptr。
  • 创建一个新的shared_ptr实例,将weak_ptr 作为shared_ptr构造函数的参数。如果释放了与weak_ptr关联的shared_ptr,将抛出std::bad_weak_ptr异常。

下例演示了weak_ptr的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void useResource (weak_ptr<Simple>& weakSimple) {
auto resource = weakSimple.lock();
if (resource)
cout << "Resource still alive." << endl;
else
cout << "Resource has been freed!" << endl;
}
int main() {
auto sharedSimple = make_shared<Simple>() ;
weak_ptr<Simple> weakSimple(sharedSimple) ;
// Try to use the `weak_ptr`.
useResource(weakSimple) ;
// Reset the shared_ptr.
// Since there is only 1 `shared_ptr`to the Simple resource, this will
// free the resource, even though there is still a `weak_ptr` alive.
sharedSimple.reset();
// Try to use the `weak_ptr` a second time.
useResource (weakSimple);
return 0;
}

上述代码的输出如下:
1
2
3
4
Simple constructor called!
Resource still alive.
Simple destructor called!
Resource has been freed!

从C++17开始,shared_ptr支持C风格的数组;与此类似,weak_ptr 也支持C风格的数组。

常见的内存陷阱

分配不足的字符串

大多数情况下,都是因为程序员没有分配尾部的\0终止字符。当程序员假设某个固定的最大大小时,也会发生字符串分配不足的情况。基本的内置C风格字符串函数不会针对固定的大小操作——而是有 多少写多少,如果超出字符串的末尾,就写入未分配的内存。

有三种方法用于解决可能的分配不足问题。按照优先级降序排列,这三种方法为:

  1. 使用C++风格的字符串,它可自动处理与连接字符串关联的内存。
  2. 不要将缓冲区分配为全局变量或分配在堆栈上,而是分配在堆上。当剩余空间不足时,分配一个新缓冲区,它大到至少能保存当前内容加上新内存块的内容,将原来缓冲区的内容复制到新缓冲区,将新内容追加到后面,然后删除原来的缓冲区。
  3. 创建另一个版本的getMoreData(),这个版本接收一个最大计数值(包括\0字符),返回的字符数不多于这个值;然后跟踪剩余的空间数以及缓冲区中当前的位置。

访问内存越界

本章前面提到,指针只不过是一个内存地址,因此指针可能指向内存中的任何一个位置。这种情况很容易出现。例如,考虑一个C风格的字符串,它不小心丢失了’\0’终止字符。下面这个函数试图将字符串填满m字符,但实际上可能会继续在字符串后面填充m:

1
2
3
4
5
6
void fillWithM(char* inStr) {
int i= 0;
while (inStr[i] != '\0') {
inStr [i] = 'm';
}
}

如果把不正确的终止字符串传入这个函数,那么内存的重要部分被改写而导致程序崩溃只是时间问题。许多内存检测工具也能检测缓冲区溢出。使用像C++ string 和vector这样的高级结构有助于避免产生一些和C风格字符串和数组相关的bug。

内存泄漏

随着程序的运行,吞掉的内存越来越多。这是因为程序有内存泄漏。通过智能指针避免内存泄漏是解决这个问题的首选方法。分配了内存,但没有释放,就会发生内存泄漏。

双重删除和无效指针

通过delete 释放某个指针关联的内存时,这个内存就可以由程序的其他部分使用了。然而,无法禁止再次使用这个指针,这个指针成为悬挂指针(dangling pointer)。双重删除也是一个问题。如果第二次在同一个指针上执行delete操作,程序可能会释放重新分配给另一个对象的内存。双重删除和使用已释放的内存都是很难追查的问题,因为症状可能不会立即显现。

如果双重删除在较短的时间内发生,程序可能产生未定义的行为,因为关联的内存可能不会那么快重用。同样,如果删除的对象在删除后立即使用,这个对象很有可能仍然完好无缺。当然,无法保证这种行为会继续出现。一旦删除对象,内存分配器就没有义务保存任何对象。

熟悉类和对象

编写类

类中每个成员和方法都可用三种访问说明符之一来说明:public、protected或private。类的默认访问说明符是private:在第一个访问说明符之前声明的所有成员的访问都是私有的。类似的C++中的struct也可以拥有方法,不过struct默认的访问说明符是public。

::称为作用域解析运算符。每个普通的方法调用都会传递一个指向对象的指针,这是称为“隐藏”参数的this指针。使用这个参数可以访问数据成员或调用方法,也可将其传递给其他方法或函数。

在堆中创建对象时,通过“->”访问其成员,如同必须释放堆中的其他内存一样,也必须在对象上调用delete,释放堆中为对象分配的内存。为了避免发生内存错误,建议使用智能指针。使用智能指针不需要手动释放内存,内存会自动释放。

1
2
auto myCellp = make_unique<SpreadsheetCell>();
myCellp->setValue(3.7);

对象的生命周期

创建对象

声明对象或使用new显式分配空间时,就会创建对象。当创建对象时,会同时创建内嵌的对象。声明并编写一个构造函数可以初始化对象。从语法上讲,构造函数是与类同名的方法。构造函数没有返回类型,可以有也可以没有参数,没有参数的构造函数称为默认构造函数。可以是无参构造函数,也可以让所有参数都使用默认值。许多情况下,都必须提供默认构造函数,如果不提供,就会导致编译器错误。

构造函数用来创建对象并初始化其值。在基于堆栈和堆进行分配时可以使用构造函数。在堆栈中分配SpreadsheetCell对象时,可这样使用构造函数:

1
2
3
SpreadsheetCell myCe11(5), anotherCe1l(4);
cout << "cell 1:"<< myCell.getValue() << endl;
cout << "cell 2:"<< anotherCell.getValue() << endl;

当动态分配SpreadsheetCell对象时,可这样使用构造函数:

1
2
3
4
5
6
7
8
9
10
11
auto smartCellp = make_unique<SpreadsheetCell>(4);
//... do something with the cell, no need to delete the smart pointer
// Or with raw pointers, without smart pointers (not recommended)
SpreadsheetCell* myCellp = new SpreadsheetCell(5);
SpreadsheetCe11* anotherCellp = nullptr;
anotherCellp = new SpreadsheetCell (4) ;
// ... do something with the cells
delete myCellp;
myCellp = nullptr;
delete anotherCellp;
anotherCellp = nullptr;

注意可以声明一个指向SpreadsheetCell对象的指针,而不立即调用构造函数。堆栈中的对象在声明时会调用构造函数。

无论在堆栈中(在函数中)还是在类中(作为类的数据成员)声明指针,如果没有立即初始化指针,都应该像前面声明anotherCellp那样将指针初始化为nullptr。

在一个类中可提供多个构造函数。所有构造函数的名称相同(类名),但不同的构造函数具有不同数量的参数或者不同的参数类型。当具有多个构造函数时,在一个构造函数中执行另一个构造函数的想法很诱人。例如,以下面的方式让string构造函数调用double构造函数:

1
2
3
SpreadsheetCell::SpreadsheetCell (string_view initialValue) {
SpreadsheetCell(stringToDouble(initialValue));
}

显式调用SpreadsheetCell构造函数实际上新建了一个SpreadsheetCell类型的临时未命名对象,而并不是像预期的那样调用构造函数以初始化对象。然而,C++支持委托构造函数(delegating constructors), 允许构造函数初始化器调用同一个类的其他构造函数。

默认构造函数没有参数,也称为无参构造函数。使用默认构造函数可以在客户不指定值的情况下初始化数据成员。C++没有提供任何语法,让创建数组的代码直接调用不同的构造函数。如果想创建某个类的对象数组,最好还是定义类的默认构造函数。如果没有定义自己的构造函数,编译器会自动创建默认构造函数。如果想在标准库容器(例如stl::vector)中存储类,也需要默认构造函数。

与基于堆栈的对象的其他构造函数不同,调用默认构造函数不需要使用函数调用的语法。根据其他构造函数的语法,用户或许会试着这样调用默认构造函数:

1
2
3
4
SpreadsheetCell myCell(); // WRONG, but will compile.
myCell.setValue (6);
// However, this line will not compile.
cout << "cell 1:"<< myCell.getValue() << endl;

试图调用默认构造函数的行可以编译,但是后面的行无法编译。问题在于常说的most vexing parse,编译器实际上将第一行当作函数声明,函数名为myCell,没有参数,返回值为SpreadsheetCell对象。当编译第二行时,编译器认为用户将函数名用作对象!

对于堆中的对象,可以这样使用默认构造函数:

1
2
3
4
auto smartcellp = make_unique<SpreadsheetCell>();
// Or with a raw pointer (not recommended)
SpreadsheetCell* myCellp = new SpreadsheetCell ();
// SpreadsheetCell* myCellp = new SpreadsheetCell;

编译器生成的默认构造函数

本章的第一个SpreadsheetCell类定义如下所示:

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
void setvalue(double invalue);
double getValue() const;
private:
double mValue;
};

这个类定义没有声明任何默认构造函数,但以下代码仍然可以正常运行:
1
2
SpreadsheetCell myCell;
myCell.setValue(6) ;

下面的定义与前面的定义相同,只是添加了一个显式的构造函数,用一个double值作为参数。这个定义仍然没有显式声明默认构造函数:
1
2
3
4
class SpreadsheetCell {
public:
SpreadsheetCell (double initialValue); // No default constructor
};

使用这个定义,下面的代码将无法编译:
1
2
SpreadsheetCell myCell;
myCell.setValue(6);

原因在于如果没有指定任何构造函数,编译器将自动生成无参构造函数。类所有的对象成员都可以调用编译器生成的默认构造函数,但不会初始化语言的原始类型,例如int 和double。 尽管如此,也可用它来创建类的对象。然而,如果声明了默认构造函数或其他构造函数,编译器就不会再自动生成默认构造函数。

默认构造函数与无参构造函数是一回事。术语“默认构造函数”并不仅仅是说如果没有声明任何构造函数,就会自动生成一个构造函数;而且指如果没有参数,构造函数就采用默认值。

显式的默认构造函数

在C++03或更早版本中,必须显式地编写空的默认构造函数,为了避免手动编写空的默认构造函数,C++现在支持显式的默认构造函数(explicitly defaulted constnuctor)。可按如下方法编写类的定义,而不需要在实现文件中实现默认构造函数:

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
SpreadsheetCell() = default;
SpreadsheetCell (double initialValue) ;
SpreadsheetCell (std::string_view initialValue) ;
};
// Remainder of the class definition omitted for brevity

SpreadsheetCell定义了两个定制的构造函数。然而,由于使用了default 关键字,编译器仍然会生成一个标准的由编译器生成的默认构造函数。

C++还支持显式删除构造函数(explicitly deleted constructors)。例如,可定义一个只有静态方法的类,这个类没有任何构造函数,也不想让编译器生成默认构造函数。在此情况下可以显式删除默认构造函数:

1
2
3
class MyClass {
public:
MyClass() = delete;

构造函数初始化器

本章到现在为止,都是在构造函数体内初始化数据成员,例如:

1
2
3
SpreadsheetCell::SpreadsheetCell (double initialValue) {
setValue (initialValue);
}

C++提供了另一种在构造函数中初始化数据成员的方法,叫作构造函数初始化器或ctor-initializer。 下面的代码使用ctor-initializer语法重写了没有参数的SpreadsheetCell构造函数:
1
SpreadsheetCell::SpreadsheetCell (double initialValue) : mValue (initialValue) {}

可以看出,ctor-initializer 出现在构造函数参数列表和构造函数体的左大括号之间。这个列表以冒号开始,由逗号分隔。列表中的每个元素都使用函数符号、统一的初始化语法、调用基类构造函数,或者调用委托构造函数以初始化某个数据成员。

使用ctor-initializer初始化数据成员与在构造函数体内初始化数据成员不同。当C++创建某个对象时,必须在调用构造函数前创建对象的所有数据成员。如果数据成员本身就是对象,那么在创建这些数据成员时,必须为其调用构造函数。在构造函数体内给某个对象赋值时,并没有真正创建这个对象,而只是改变对象的值。

ctor-initializer允许在创建数据成员时赋初值,这样做比在后面赋值效率高。对于类型,它少了一次调用构造函数的过程,而在函数体中赋值则会多一次调用。而对于内置数据类型则没有差别。编译器会一一操作初始化列表,以适当的顺序在构造函数之内安插初始化操作,并且在任何显示用户代码之前;list中的项目顺序是由类中的成员声明顺序决定的,不是由初始化列表的顺序决定的;

如果类的数据成员是具有默认构造函数的类的对象,则不必在ctor-initializer中显式初始化对象。例如,如果有一个std::string数据成员,其默认构造函数将字符串初始化为空字符串,那么在ctor initializer中将其初始化为””是多余的。

而如果类的数据成员是没有默认构造函数的类的对象,则必须在ctor-initializer 中显式初始化对象。例如,考虑下面的SpreadsheetCell类:

1
2
3
4
class SpreadsheetCell {
public:
SpreadsheetCell (double d);
};

这个类只有一个采用double 值作为参数的显式构造函数,而没有默认构造函数。可在另一个类中将这个类用作数据成员,如下所示:
1
2
3
4
5
6
class SomeClass {
public:
SomeClass();
private:
SpreadsheetCell mCell;
};

在ctor-initializer中初始化mCell数据成员,如下所示:

1
SomeClass::SomeClass() : mCell(1.0) { }

赋值初始化,通过在函数体内进行赋值初始化;列表初始化,在冒号后使用初始化列表进行初始化。这两种方式的主要区别在于:

  • 对于在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。
  • 列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。

2) 一个派生类构造函数的执行顺序如下:

  • 虚拟基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)。
  • 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。
  • 类类型的成员对象的构造函数(按照初始化顺序)
  • 派生类自己的构造函数。

必须使用成员初始化的四种情况

  • 当初始化一个引用成员时;
  • 当初始化一个常量成员时;
  • 当调用一个基类的构造函数,而它拥有一组参数时;
  • 当调用一个成员类的构造函数,而它拥有一组参数时;

复制构造函数

复制构造函数(copy constructor)允许所创建的对象是另一个对象的精确副本。如果没有编写复制构造函数,C++会自动生成一个,用源对象中相应数据成员的值初始化新对象的每个数据成员。如果数据成员是对象,初始化意味着调用它们的复制构造函数。下面是SpreadsheetCell类中复制构造函数的声明:

1
2
3
4
5
class SpreadsheetCell {
public:
SpreadsheetCell (const SpreadsheetCell& src) ;
// Remainder of the class definition omitted for brevity
};

复制构造函数采用源对象的const引用作为参数。与其他构造函数类似,它也没有返回值。在复制构造函数内部,应该复制源对象的所有数据成员。当然,从技术角度看,可在复制构造函数内完成任何操作,但最好按照预期的行为将新对象初始化为已有对象的副本。下面是SpreadsheetCell复制构造函数的示例实现,注意ctor-initializer的用法。
1
SpreadsheetCell::SpreadsheetCell (const SpreadsheetCell& src) : mValue (src.mValue)

假定有一组成员变量,名为m1、m2、…. mn,编译器生成的复制构造函数为:

1
classname::classname (const classname& src) : m1(src.m1), m2(src.m2), ... mn(src.mn) { }

因此多数情况下,不需要亲自编写复制构造函数!

C++中传递函数参数的默认方式是值传递,这意味着函数或方法接收某个值或对象的副本。因此,无论什么时候给函数或方法传递一个对象,编译器都会调用新对象的复制构造函数进行初始化

当调用setString()并传递一个string参数时,这个string参数会调用复制构造函数进行初始化。为初始化printString()中的inString对象,会调用string复制构造函数,其参数为name:

1
2
string name = "heading one";
printString(name) ; // Copies name

printString()方法结束时,inString 被销毁,因为它只是name的一个副本, 所以name完好无缺。当然,可通过将参数作为const引用来传递,从而避免复制构造函数的开销。

显式调用复制构造函数

也可显式地使用复制构造函数,从而将某个对象作为另一个对象的精确副本。例如,可这样创建SpreadsheetCell对象的副本:

1
2
SpreadsheetCell myCelll (4) ;
SpreadsheetCell myCe112 (myCe111); // myCe112 has the same values as myCe111

按引用传递对象

向函数或方法传递对象时,为避免复制对象,可让函数或方法采用对象的引用作为参数。按引用传递对象通常比按值传递对象的效率更高,因为只需要复制对象的地址,而不需要复制对象的全部内容。此外,按引用传递可避免对象动态内存分配的问题。

按引用传递某个对象时,使用对象引用的函数或方法可修改原始对象。如果只是为了提高效率才按引用传递,可将对象声明为const以排除这种可能。这称为按const引用传递对象。

为了提高性能,最好按const引用而不是按值传递对象。但是诸如int和double等基本类型应当按值传递。按const引用传递这些类型什么也得不到。

初始化列表构造函数

初始化列表构造函数(nitializer-list constructors)将std:initializer_list<T>作为第一个参数, 并且没有任何其他参数。下面的类演示了这种用法。该类只接收initializer_list<T>,元素个数应为偶数,否则将抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class EvenSequence {
public:
EvenSequence (initializer_ list<double> args) {
if (args.size() % 2 != 0)
throw invalid_ argument ("initializer_ list should contain even number of elements.");
mSequence. reserve(args.size());
for (const auto& value : args)
mSequence.push_back (value);
}
void dump() const {
for (const auto& value : mSequence)
cout << value << ", ";
cout << endl;
private:
vector<double> mSequence;
};

在初始化列表构造函数的内部,可使用基于区间的for循环来访问初始化列表的元素。使用size()方法可获取初始化列表中元素的数目。

EvenSequence初始化列表构造函数使用基于区间的for循环来复制给定initializer_list 中的元素。也可以使用vector 的assign()方法。

标准库完全支持初始化列表构造函数。例如,可使用初始化列表初始化stl::vector容器。

1
std::vector<std::string> myVec = {"String 1", "String 2", "String 3"};

如果不使用初始化列表构造函数,可通过一些push_back()调用来初始化vector:
1
2
3
4
std::vector<std::string> myVec;
myVec.push_back("String 1");
myVec.push_back("String 2");
myVec.push_back("String 3");

初始化列表并不限于构造函数,还可以用于普通函数。

委托构造函数

委托构造函数(delegating constructors)允许构造函数调用同一个类的其他构造函数。然而,这个调用不能放在构造函数体内,而必须放在构造函数初始化器中,且必须是列表中唯一的成员初始化器。下面给出了一个示例:

1
2
3
SpreadsheetCell::SpreadsheetCell (string_view initialvalue)
: SpreadsheetCell (str ingToDouble (ini tialValue) )
{ }

当调用这个string_view构造函数(委托构造函数)时,首先将调用委托给目标构造函数,也就是double构造函数。当目标构造函数返回时,再执行委托构造函数。当使用委托构造函数时,要注意避免出现构造函数的递归。例如:

1
2
3
4
class MyClass {
MyClass (char c) : MyClass(1.2) { }
MyClass (double d) : MyClass('m') { }
};

第一个构造函数委托第二个构造函数,第二个构造函数又委托第一个构造函数。C++标准没有定义此类代码的行为,这取决于编译器。

总结编译器生成的构造函数

编译器为每个类自动生成没有参数的构造函数和复制构造函数。然而,编译器自动生成的构造函数取决于你自己定义的构造函数,对应的规则如表8-3所示。

销毁对象

当销毁对象时,会发生两件事:调用对象的析构函数,释放对象占用的内存。在析构函数中可以执行对象的清理,例如释放动态分配的内存或者关闭文件句柄。如果没有声明析构函数,编译器将自动生成一个,析构函数会逐一销毁成员,然后删除对象。

当堆栈中的对象超出作用域时,意味着当前的函数、方法或其他执行代码块结束,对象会被销毁。换句话说,当代码遇到结束大括号时,这个大括号中所有创建在堆栈中的对象都会被销毁。下面的程序显示了这一行为:

1
2
3
4
5
6
7
8
int main() {
SpreadsheetCell myCell (5) ;
if (myCell .getValue() == 5)
SpreadsheetCell anotherCell(6) ;
} // anotherCell is destroyed as this block ends.
cout << "myCell: " << myCell.getValue() << endl;
return 0;
} // myCell is destroyed as this block ends .

堆栈中对象的销毁顺序与声明顺序(和构建顺序)相反。如果某个对象是其他对象的数据成员,这顺序也适用。数据成员的初始化顺序是它们在类中声明的顺序。因此,按对象的销毁顺序与创建顺序相反这一规则, 数据成员对象的销毁顺序与其在类中声明的顺序相反。

没有智能指针的帮助,在堆中分配的对象不会自动销毁。必须使用delete 删除对象指针,从而调用析构函数并释放内存。下面的程序显示了这一行为:

1
2
3
4
5
6
7
8
int main()
SpreadsheetCell* cellPtrl = new SpreadsheetCell (5) ;
SpreadsheetCell* cellPtr2 = new SpreadsheetCell (6) ;
cout << "cellPtr1: "<< cellPtr1->getvalue() << endl;
delete cellPtrl; // Destroys cellPtrl
cel1Ptrl = nullptr;
return 0;
} // cellPtr2 is NOT destroyed because delete was not called on it.

对象赋值

就像可将一个int变量的值赋给另一个int变量一样, 在C++中也可将一个对象的值赋给另一个对象。例如,下面的代码将myCell的值赋给anotherCell:

1
2
SpreadsheetCell myCell(5), anotherCell;
anotherCell = myCell;

在C++中,“复制”只在初始化对象时发生。如果一个已经具有值的对象被改写,更精确的术语是“赋值”。注意C++提供的复制工具是复制构造函数。因为这是一个构造函数,所以只能用在创建对象时,而不能用于对象的赋值。

因此,C++为所有的类提供了执行赋值的方法。这个方法叫作赋值运算符(assignment operator), 名称是operator=,因为实际上是为类重载了=运算符。在上例中,调用了anotherCell的赋值运算符,参数为myCell。

如果没有编写自己的赋值运算符,C++将自动生成一个,从而允许将对象赋给另一个对象。默认的C++赋值行为几乎与默认的复制行为相同:以递归方式用源对象的每个数据成员并赋值给目标对象。

声明赋值运算符

下面是SpreadsheetCell类的赋值运算符:

1
2
3
4
5
class SpreadsheetCell {
public:
SpreadsheetCell& operator= (const SpreadsheetCell& rhs) ;
// Remainder of the class definition omitted for brevity
}

赋值运算符与复制构造函数类似,采用了源对象的const引用。在此情况下,将源对象称为rths,代表等号的“右边”(可为其指定其他任何名称),调用赋值运算符的对象在等号的左边。与复制构造函数不同的是,赋值运算符返回SpreadsheetCell对象的引用。原因是赋值可以链接在一起。

定义赋值运算符

赋值运算符的实现与复制构造函数类似,但存在一些重要的区别。首先,复制构造函数只有在初始化时才调用,此时目标对象还没有有效的值。赋值运算符可以改写对象的当前值。其次,在C++中允许将对象的值赋给自身。例如,下面的代码可以编译并运行:

1
2
SpreadsheetCell cell(4);
cell = cell;

赋值运算符不应该阻止自赋值。在SpreadsheetCell类中,这并不重要,因为它的唯一数据成员是基本类型double。但当类具有动态分配的内存或其他资源时,必须将自赋值考虑在内,为阻止此类情况下的问题发生,赋值运算符通常在方法开始时检测自赋值,如果发现自赋值,则立刻返回。下面是SpreadsheetCell类的赋值运算符的定义:

1
2
SpreadsheetCell& SpreadsheetCell::operator= (const SpreadsheetCell& rhs) {
if (this == &rhs) {

第一行检测自赋值,但有一个神秘之处。当等号的左边和右边相同时,就是自赋值。判断两个对象是否相同的方法之一是检查它们在内存中的位置是否相同,更明确地说,是检查指向它们的指针是否相等。由于返回类型是SpreadsheeCell&,因此必须返回一个正确的值。所有赋值运算符都返回*this,自赋值情况也不例外:
1
2
    return *this;
}

this指针指向执行方法的对象,因此*this就是对象本身。编译器将返回一个对象的引用,从而与声明的返回值匹配。如果不是自赋值,就必须对每个成员赋值:
1
2
mValue = rhs.mValue;
return *this;

这个方法在这里复制了值。最后返回*this

显式地默认或删除赋值运算符

可显式地默认或删除编译器生成的赋值运算符,如下所示:

1
SpreadsheetCell& operator= (const SpreadsheetCell& rhs) = default;

或者
1
SpreadsheetCell& operator= (const SpreadsheetCell& rhs) = delete;

编译器生成的复制构造函数和复制赋值运算符

在C++11中,如果类具有用户声明的复制赋值构造函数或析构函数,那么已经不赞成生成复制构造函数(可能是编译器觉得需要特殊处理,不能简单地直接复制么)。如果在此类情况下仍然需要编译器生成的复制构造函数,可以显式指定default:

1
MyClass (const MyClass& src) = default;

同样,在C++11中,如果类具有用户声明的复制赋值构造函数或析构函数,也不赞成生成复制赋值运算符。如果在此类情况下仍然需要编译器生成的复制赋值运算符,可以显式指定default:
1
MyClass& operator= (const MyClass& rhs) = default;

复制和赋值的区别

有时很难区分对象什么时候用复制构造函数初始化,什么时候用赋值运算符赋值。基本上,声明时会使用复制构造函数,赋值语句会使用赋值运算符。考虑下面的代码:

1
2
SpreadsheetCell myCe11(5);
SpreadsheetCell anotherCell (myCell);

AnotherCell由复制构造函数创建。
1
SpreadsheetCell aThirdCell = myCell;

aThirdCell也是由复制构造函数创建的,因为这条语句是一个声明。这行代码不会调用operator=。不过,考虑以下代码:
1
anotherCell = myCell; // Calls operator= for anotherCell

此处,anotherCell 已经构建,因此编译器会调用operator=。

按值返回对象

当函数或方法返回对象时,有时很难看出究竟执行了什么样的复制和赋值。例如,SpreadseetCel:etString0的实现如下所示:

1
2
3
string SpreadsheetCell::getString() const {
return doubleToString (mValue);
}

现在考虑下面的代码:
1
2
3
SpreadsheetCell myCe112(5);
string s1;
s1 = myCell2.getString();

getString()返回mString时,编译器实际上调用string复制构造函数,创建一个未命名的临时字符串对象。将结果赋给s1时,会调用s1的赋值运算符,将这个临时字符串作为参数。然后,这个临时的字符串对象被销毁。因此,这行简单的代码调用了复制构造函数和赋值运算符(针对两个不同的对象)。然而,编译器可实现(有时需要实现)返回值优化(Returm Value Optimization, RVO),在返回值时优化掉成本高昂的复制构造函数。RVO
也称为复制省略(copy elision)。

了解到上面的内容后,考虑下面的代码:

1
2
SpreadsheetCell myCell3(5) ;
string s2 = myCell3.getString() ;

在此情况下,getString()返回时创建了一个临时的未命名字符串对象。但现在s2调用的是复制构造函数,而不是赋值运算符。通过移动语义(move semantics),编译器可使用移动构造函数而不是复制构造函数,从getString()返回该字符串,这样做效率更高。

复制构造函数和对象成员

还应注意构造函数中赋值和调用复制构造函数的不同之处。如果某个对象包含其他对象,编译器生成的复制构造函数会递归调用每个被包含对象的复制构造函数。当编写自己的复制构造函数时,可使用前面所示的ctor initializer提供相同的语义。如果在ctor initializer中省略某个数据成员,在执行构造函数体内的代码之前,编译器将对该成员执行默认的初始化(为对象调用默认构造函数)。这样,在执行构造函数体时,所有数据成员都已经初始化。

例如,可这样编写复制构造函数:

1
2
SpreadsheetCell::SpreadsheetCell (const SpreadsheetCell& src)
mValue = src.mValue;

然而,在复制构造函数的函数体内对数据成员赋值时,使用的是赋值运算符而不是复制构造函数,因为它们已经初始化。

精通类与对象

友元

C++运行某个类将其他类、其他类的成员函数或非成员函数声明为友元,友元可访问类的protected、private数据成员和方法。可将Bar类或或其中的一个方法、独立函数设置为Foo类的友元;

1
2
3
4
5
class Foo {
friend void Bar::processFoo(const Foo& foo);
friend class Bar;
friend void dumpFoo(const Foo& foo);
}

对象的动态分配

使用移动语义处理移动

对象的移动语义(movesemantics)需要实现移动构造函数(move constructor)和移动赋值运算符(move assignment operator)。如果源对象是操作结束后被销毁的临时对象,编译器就会使用这两个方法。移动构造函数和移动赋值运算符将数据成员从源对象移动到新对象,然后使源对象处于有效但不确定的状态。通常会将源代码的数据成员重置为空值。这样做实际上将内存和其他资源的所有权从一个对象移动到另一个对象。这两个方法基本上只对成员变量进行表层复制(shallow copy),然后转换已分配内存和其他资源的所有权,从而阻止悬挂指针和内存泄漏。

在实现移动语义前,你需要学习右值(rvalue)和右值引用(rvalue reference)。

右值引用

在C++中,左值(value)是可获取其地址的一个量,例如一个有名称的变量。由于经常出现在赋值语句的左边,因此将其称作左值。另外,所有不是左值的量都是右值(rvalue),例如字面量、临时对象或临时值。通常右值位于赋值运算符的右边。例如,考虑下面的语句:

1
int a=4*2;

在这条语句中,a是左值,它具有名称,它的地址为&a。右侧表达式4 * 2的结果是右值。它是一个临时值,将在语句执行完毕时销毁。在本例中,将这个临时副本存储在变量a中。右值引用是一个对右值(rvalue)的引用。特别地,这是一个当右值是临时对象时才适用的概念。右值引用的目的是在涉及临时对象时提供可选用的特定函数。由于知道临时对象会被销毁,通过右值引用,某些涉及复制大量值的操作可通过简单地复制指向这些值的指针来实现。

函数可将&&作为参数说明的一部分(例如type &&name),以指定右值引用参数。通常,临时对象被当作const type&,但当函数重载使用了右值引用时,可以解析临时对象,用于该函数重载。下面的示例说明了这一点。代码首先定义了两个handleMessage()函数,一个接收左值引用,另一个接收右值引用:

1
2
3
4
5
6
7
8
// lvalue reference parameter
void handleMessage(std::string& message) {
cout << "handleMessage with lvalue reference: " << message << endl;
}
// rvalue reference parameter
void handleMessage (std::string&& message) {
cout << "handleMessage with rvalue reference: " << message << endl;
}

可使用具有名称的变量作为参数调用handleMessage()函数:
1
2
3
4
std::string a = "Hello ";
std::string b = "World";
handleMessage(a);
// Calls handleMessage (string& value)

由于a是一个命名变量,调用handleMessage()函数时,该函数接收一个左值引用。handleMessage()函数通过其引用参数所执行的任何更改来更改a的值。还可用表达式作为参数来调用handleMessage()函数:

1
2
handleMessage(a + b);
// Calls handleMessage (string&& value)

此时无法使用接收左值引用作为参数的handleMessage()函数,因为表达式a+b的结果是临时的,这不是一个左值。在此情况下,会调用右值引用版本。由于参数是一个临时值,handleMessage()函数调用结束后,会丢失通过引用参数所做的任何更改。

字面量也可作为handleMessage()调用的参数,此时同样会调用右值引用版本,因为字面量不能作为左值(但字面量可作为const引用形参的对应实参传递)。

1
handleMessage("Hello World"); // Calls handldMessage (string&& value)

如果删除接收左值引用的handleMessage()函数,使用有名称的变量调用handleMessage(),会导致编译错误,因为右值引用参数(string&& message)永远不会与左值(b)绑定。如下所示,可使用std:move()将左值转换为右值,强迫编译器调用handleMessage()函数的右值引用版本:
1
handleMessage (std::move(b)); // Calls handleMessage (string&& value)

重申一次,有名称的变量是左值。因此,在handleMessage()函数中,右值引用参数message本身是一个左值,原因是它具有名称!如果希望将这个左值引用参数,作为右值传递给另一个函数,则需要使用std:move(),将左值转换为右值。例如,假设要添加以下函数,使用右值引用参数:

1
void helper (std::string&& message)

如果按如下方式调用,则无法编译:
1
2
3
void handleMessage (std::string&& message) {
helper (message);
}

helper()函数需要右值引用,而handleMessage()函数传递messagemessage具有名称,因此是左值,导致编译错误。正确的方式是使用std:move()
1
2
3
void handleMessage (std::string&& message) {
helper (std::move(message));
}

有名称的右值引用,如右值引用参数,本身就是左值,因为它具有名称!

右值引用并不局限于函数的参数。可以声明右值引用类型的变量,并对其赋值,尽管这一用法并不常见。

考虑下面的代码,在C++中这是不合法的:

1
2
3
int& i = 2;// Invalid: reference to a constant
int a = 2, b = 3;
int& j = a + b; // Invalid: reference to a temporary

使用右值引用后,下面的代码完全合法:

1
2
3
int&& i = 2;
int a = 2, b = 3;
int&& j = a + b;

前面示例中单独使用右值引用的情况很少见。

实现移动语义

移动语义是通过右值引用实现的。为了对类增加移动语义,需要实现移动构造函数和移动赋值运算符。移动构造函数和移动赋值运算符应使用noexcept限定符标记,这告诉编译器,它们不会抛出任何异常。这对于与标准库兼容非常重要,因为如果实现了移动语义,与标准库的完全兼容只会移动存储的对象,且确保不抛出异常。

下面的Spreadsheet类定义包含一个移动构造函数和一个移动赋值运算符。也引入了两个辅助方法cleanup()moveFrom()。前者在析构函数和移动赋值运算符中调用。后者用于把成员变量从源对象移动到目标对象,接着重置源对象。

1
2
3
4
5
6
7
8
9
10
class Spreadsheet {
public:
Spreadsheet(Spreadsheet&& src) noexcept; // Move constructor
Spreadsheet& operator= (Spreadsheet&& rhs) noexcept; // Move assign
// Remaining code omitted for brevity
private:
void cleanup() noexcept;
void moveFrom (Spreadsheet& src) noexcept;
// Remaining code omitted for brevity
};

实现代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void Spreadsheet::cleanup() noexcept {
for(size_t i=0; i < mWidth; i ++)
delete[] mCells[i];
delete[] mCells;
mCells = nullptr;
mWidth = 0;
mHeight = 0;
}

void Spreadsheet::moveFrom (Spreadsheet& src) noexcept {
// Shallow copy of data
mWidth = src.mWidth; .
mHeight = src.mHeight;
mCells = src.mCells;
// Reset the source object, because ownership has been moved !
src.mWidth = 0;
src.mHeight = 0;
src.mCells = nullptr;
}

// Move constructor
Spreadsheet::Spreadsheet (Spreadsheet&& src) noexcept {
moveFrom(src);
}
// Move assignment operator
Spreadsheet& Spreadsheet::operator= (Spreadsheet&& rhs) noexcept {
// check for self-assignment
if (this = &rhs)
return *this;
// free the old memory
cleanup() ;
moveFrom(rhs);
return *this;
}

移动构造函数和移动赋值运算符都将mCells的内存所有权从源对象移动到新对象,这两个方法将源对象的mCells指针设置为空指针,以防源对象的析构函数释放这块内存,因为新的对象现在拥有了这块内存。很明显,只有你知道将销毁源对象时,移动语义才有用。例如,就像普通的构造函数或复制赋值运算符一样,可显式将移动构造函数和/或移动赋值运算符设置为默认或将其删除。

仅当类没有用户声明的复制构造函数、复制赋值运算符、移动赋值运算符或析构函数时,编译器才会为类自动生成默认的移动构造函数。仅当类没有用户声明的复制构造函数、移动构造函数、复制赋值运算符或析构函数时,才会为类生成默认的移动赋值运算符。

移动对象数据成员

moveFrom()方法对三个数据成员直接赋值,因为这些成员都是基本类型。如果对象还将其他对象作为数据成员,则应当使用std:move()移动这些对象。假设Spreadsheet类有一个名为mName的std::string数据成员。接着采用以下方式实现moveFrom()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
void Spreadsheet::moveFrom (Spreadsheet& src) noexcept {
// Move object data members
mName = std::move(src.mName) ;
// Move primitives :
// Shallow copy of data
mwidth = src.mWidth;
mHeight = src.mHeight;
mCells = src.mCells;
// Reset the source object, because ownership has been moved!
src.mWidth = 0;
src.mHeight = 0
src.mCells = nullptr;
}

前面的移动构造函数和移动赋值运算符的实现都使用了moveFrom()辅助方法,该辅助方法通过执行浅表复制来移动所有数据成员。

零规则

前面的讨论解释如何编写以下5个特殊的成员函数:析构函数、复制构造函数、移动构造函数、复制赋值运算符和移动赋值运算符。但在现代C++中,你需要接受零规则(rule of zero)。

“零规则”指出,在设计类时,应当使其不需要上述5个特殊成员函数。如何做到这一点?基本上,应当避免拥有任何旧式的、动态分配的内存。而改用现代结构,如标准库容器。例如,在Spreadsheet 类中,用vector<vector<SpreadsheetCell>>替代SpreadsheetCell**数据成员。该vector自动处理内存,因此不需要上述5个特殊成员函数。

与方法有关的更多内容

静态方法

与数据成员类似,方法有时会应用于全部类对象而不是单个对象,此时可以像静态数据成员那样编写静态方法。以第8章的SpreadsheetCell类为例,这个类有两个辅助方法:stringToDouble()doubleToString()。这两个方法没有访问特定对象的信息,因此可以是静态的。下面的类定义将这些方法设置为静态的:

1
2
3
4
5
6
class SpreadsheetCell {
// Omitted for brevity
private:
static std::string doubleToString (double inValue) ;
static double stringToDouble(std::string_view inString);
};

这两个方法的实现与前面的实现相同,在方法定义前不需要重复static关键字。然而,注意静态方法不属于特定对象,因此没有this指针,当用某个特定对象调用静态方法时,静态方法不会访问这个对象的非静态数据成员。实际上,静态方法就像普通函数,唯一区别在于静态方法可以访问类的private和protected静态数据成员。如果同一类型的其他对象对于静态方法可见,那么静态方法也可访问其他对象的private和protected非静态数据成员。

类中的任何方法都可像调用普通函数那样调用静态方法,因此SpreadsheetCell类中所有方法的实现都没有改变。如果要在类的外面调用静态方法,需要用类名和作用域解析运算符来限定方法的名称(就像静态数据成员那样),静态方法的访问控制与普通方法一样。

stringToDouble()doubleTostring()设置为public,这样类外面的代码也可以使用它们。此时,可在任意位置这样调用这两个方法:

1
string str = SpreadsheetCell::doubleToString(5.0);

const 方法

const(常量)对象的值不能改变。如果使用常量对象、常量对象的引用和指向常量对象的指针,编译器将不允许调用对象的任何方法,除非这些方法承诺不改变任何数据成员。为了保证方法不改变数据成员,可以用const关键字标记方法本身。下面的SpreadsheetCell类包含了用const标记的不改变任何数据成员的方法。

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
// Omitted for brevity
double getValue() const ;
std::string getstring() const;
// Omitted for brevity
};

const规范是方法原型的一部分,必须放在方法的定义中:
1
2
3
4
5
6
double SpreadsheetCell::getValue() const {
return mValue;
}
std::string SpreadsheetCell::getString() const {
return doubleToString (mValue);
}

将方法标记为const,就是与客户代码立下了契约,承诺不会在方法内改变对象内部的值。如果将实际上修改了数据成员的方法声明为const,编译器将会报错。不能将静态方法声明为const,因为这是多余的。静态方法没有类的实例,因此不可能改变内部的值。

const 的工作原理是将方法内用到的数据成员都标记为const引用,因此如果试图修改数据成员,编译器会报错。非const对象可调用const方法和非const方法。然而,const 对象只能调用const方法,下面是一些示例:

1
2
3
4
5
6
7
8
9
SpreadsheetCell myCell (5) ;
cout. << myCell.getValue() << endl;
// OK
myCell.setString("6");
// OK
const SpreadsheetCell& myCellConstRef = myCell;
cout << myCellConstRef.getValue() << endl; // OK
myCellConstRef.setString("6");
// Compilation Error!

应该养成习惯,将不修改对象的所有方法声明为const,这样就可在程序中引用const对象。注意const对象也会被销毁,它们的析构函数也会被调用,因此不应该将析构函数标记为const。

mutable数据成员

有时编写的方法“逻辑上”是const方法,但是碰巧改变了对象的数据成员。这个改动对于用户可见的数据没有任何影响,但在技术上确实做了改动,因此编译器不允许将这个方法声明为const。解决方法是将变量设置为mutable,告诉编译器在const方法中允许改变这个值。

方法重载

注意,在类中可编写多个构造函数,所有这些构造函数的名称都相同。这些构造函数只是参数数量或类型不同。在C++中,可对任何方法或函数做同样的事情。具体来讲,可重载函数或方法,具体做法是将函数或方法的名称用于多个函数,但是参数的类型或数目不同。例如在SpreadsheetCell类中,可将setString()setValue()全部重命名为set()。类定义如下所示:

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
// Omitted for brevity
void set(double inValue) ;
void set(std::string_view inString) ;
// Omitted for brevity
};

set()方法的实现保持不变。当编写调用set()方法的代码时,编译器根据传递的参数判断调用哪个实例,这称为重载解析。

基于const的重载

还要注意,可根据const来重载方法。也就是说,可以编写两个名称相同、参数也相同的方法,其中一个是const,另一个不是。如果是const对象,就调用const方法;如果是非const对象,就调用非const方法。

通常情况下,const版本和非const版本的实现是一样的。为避免代码重复,可使用const_cast()模式。你可像往常一样实现const版本,此后通过适当转换,传递对const版本的调用,以实现非const版本。基本上,你使用std:as_const()(在<utility>中定义)将*this转换为const Spreadsheet&,调用getCellAt()的const版本,然后使用const_cast(),从结果中删除const:

1
2
3
4
5
6
7
8
const SpreadsheetCell& Spreadsheet::getCellAt(size_t x,size_t y) const {
verifyCoordinate(x,y);
return mCel1s[x][y];
}

SpreadsheetCell& Spreadsheet::getCe1lAt(size_t x, size_t y) {
return const_cast<SpreadsheetCell&> (std::as_const(*this).getCellAt(x, y));
}

自C++17起,st::as_const()函数可供使用。如果你的编译器还不支持该函数,可改用以下static_cast()
1
return const_cast<SpreadsheetCell&> ( static_cast<const Spreadsheet&>(*this).getCellAt(x, y));

有了这两个重载的getCellAt(),现在可在const和非const的Spreadsheet对象上调用getCellAt()

1
2
3
4
5
Spreadsheet sheet1 (56).
SpreadsheetCell& cel11 = sheet1.getCel1At(1, 1);

const Spreadsheet sheet2(56);
const SpreadsheetCell& cell2 = sheet2.getCellAt(1, 1);

这里,getCellAt()的const版本做的事情不多,因此使用const_cast()模式的优势不明显。

显式删除重载

重载方法可被显式删除,可以用这种方法禁止调用具有特定参数的成员函数。例如,考虑下面的类:

1
2
3
4
class MyClass {
public:
void foo(int i);
};

可以用下面的方式调用foo()方法:
1
2
3
MyClass c;
c.foo(123);
c.foo(1.23);

在第三行,编译器将double值(1.23)转换为整型值(1),然后调用foo(int i)。 编译器可能会给出警告,但是仍然会执行这一隐式转换。显式删除foo()的double实例,可以禁止编译器执行这一转换:
1
2
3
4
5
class MyClass {
public:
void foo(int i);
void foo(double d) = delete;
};

通过这一改动, 以double为参数调用foo()时,编译器会给出错误提示,而不是将其转换为整数。

内联方法

编译器可以将方法体或函数体直接插入到调用方法或函数的位置。这个过程称为内联(inline)。内联比使用#define安全。inline 关键字只是提示编译器。如果编译器认为这会降低性能,就会忽略该关键字。

如果编写了内联函数或内联方法,应该将定义与原型一起放在头文件中。

高级C++编译器不要求把内联方法的定义放在头文件中。例如,Microsoft Visual C++支持连接时代码生成(LTCG),会自动将较小的函数内联,哪怕这些函数没有声明为内联的或者没有在头文件中定义,也同样如此。

C++提供了另一种声明内联方法的语法,这种语法根本不使用inline关键字,而是直接将方法定义放在类定义中。下面是使用了这种语法的SpreadsheetCell类定义:

1
2
3
4
5
6
7
8
9
class SpreadsheetCell {
public:
double getValue() const { mNumAccesses++; return mValue; }
std::string getString() const {
mNumAccesses++;
return doubleToString (mValue) ;
// Omitted for brevity
}
};

编译器只会内联最简单的方法和函数,如果将编译器不想内联的方法定义为内联方法,编译器会自动忽略这个指令。现代编译器在内联方法或函数之前,会考虑代码膨胀等指标,因此不会内联任何没有效益的方法。

默认参数

C++中,默认参数(default arguments)与方法重载类似。在原型中可为函数或方法的参数指定默认值。如果用户指定了这些参数,默认值会被忽略;如果用户忽略了这些参数,将会使用默认值。但是存在一个限制:能从最右边的参数开始提供连续的默认参数列表,否则编译器将无法用默认参数匹配缺失的参数。默认参数可用于函数、方法和构造函数。例如,可在Spreadsheet构造函数中设置宽度和高度的默认值:

1
2
3
4
5
class Spreadsheet {
public:
Spreadsheet(size_t width = 100size_t height = 100);
// Omitted for brevity
};

现在可以用0个、1个或2个参数调用Spreadsheet构造函数,尽管只有一个非复制构造函数:

1
2
3
Spreadsheet s1;
Spreadsheet s2(5);
Spreadsheet s3(5, 6);

所有参数都有默认值的构造函数等同于默认构造函数。也就是说,可构建类的对象而不指定任何参数。如果试图同时声明默认构造函数,以及具有多个参数并且所有参数都有默认值的构造函数,编译器会报错。因为如果不指定任何参数,编译器不知道该调用哪个构造函数。

不同的数据成员类型

C++为数据成员提供了多种选择。除了在类中简单地声明数据成员外,还可创建静态数据成员(类的所有对象共享)、静态常量数据成员、引用数据成员、常量引用数据成员和其他成员。

静态数据成员

静态数据成员属于类但不是对象的数据成员,可将静态数据成员当作类的全局变量。下面是Spreadsheet类的定义,其中包含了新的静态数据成员sCounter:

1
2
3
4
5
class Spreadsheet {
// Omitted for brevity
private:
static size_t sCounter;
};

不仅要在类定义中列出static类成员,还需要在源文件中为其分配内存,通常是定义类方法的那个源文件。在此还可初始化静态成员,但注意与普通的变量和数据成员不同,默认情况下它们会初始化为0。static 指针会初始化为nullptr。

内联变量

从C++17开始,可将静态数据成员声明为inline。这样做的好处是不必在源文件中为它们分配空间。下面是一个实例:

1
2
3
4
5
class Spreadsheet {
// Omitted for brevity
private:
static inline size_t sCounter = 0;
};

注意其中的inline关键字。有了这个类定义,可从源文件中删除下面的代码行:
1
size_t Spreadsheet::sCounter; 

在类方法内访问静态数据成员

在类方法内部,可以像使用普通数据成员那样使用静态数据成员。例如,为Spreadsheet类创建一个mId成员,并在Spreadsheet构造函数中用sCounter成员初始化它。下面是包含了mId成员的Spreadsheet 类定义:

1
2
3
4
5
6
7
class Spreadsheet {
public:
size_t getId() const;
private:
static size_t sCounter;
size_t mId = 0;
};

下面是Spreadsheet构造函数的实现,在此赋予初始ID:
1
2
3
4
5
6
7
Spreadsheet::Spreadsheet (size_t width, size_t height) 
: mId (sCounter++), mwidth (width), mHeight (height) {
mCells = new SpreadsheetCell* [mWidth] ;
for (size_t i = 0; i < mwidth; i++) {
mCells[i] = new SpreadsheetCell [mHeight] ;
}
}

可以看出,构造函数可访问sCounter,就像这是一个普通成员。在复制构造函数中,也要指定新的ID。由于Spreadsheet复制构造函数委托给非复制构造函数(会自动创建新的ID),因此这可以自动进行处理。在赋值运算符中不应该复制ID。一旦给某个对象指定ID,就不应该再改变。建议把mId设置为const数据成员。

在方法外访问静态数据成员

访问控制限定符适用于静态数据成员:sCounter 是私有的,因此不能在类方法之外访问。如果sCounter是公有的,就可在类方法外访问,具体方法是用::作用域解析运算符指出这个变量是Spreadsheet类的一部分:

1
int c = Spreadsheet::sCounter;

静态常量数据成员

类中的数据成员可声明为const,意味着在创建并初始化后,数据成员的值不能再改变。如果某个常量只适用于类,应该使用静态常量(static const或const static)数据成员,而不是全局常量。可在类定义中定义和初始化整型和枚举类型的静态常量数据成员,而不需要将其指定为内联变量。Spreadsheet类的static const成员:

1
2
3
4
5
class Spreadsheet {
public:
static const size_t kMaxHeight = 100;
static const size_t kMaxWidth = 100;
};

非静态数据成员也可声明为const。例如,mId数据成员就可声明为const。因为不能给const数据成员赋值,所以需要在类内初始化器或ctor initializer中初始化它们。这意味着根据使用情形,可能无法为具有非静态常量数据成员的类提供赋值运算符。如果属于这种情况,通常将赋值运算符标记为deleted。

kMaxHeight和kMaxWidth是公有的,因此可在程序的任何位置访问它们,就像它们是全局变量一样:

1
cout << "Maximum height is: " << Spreadsheet::kMaxHeight << endl;

引用数据成员

Spreadsheets和SpreadsheetCells可一起放入SpreadsheetApplication类。Spreadsheet类必须知道SpreadsheetApplication 类,SpreadsheetApplication类也必须知道Spreadsheet类。这是一个循环引用问题,无法用普通的#include解决。解决方案是在其中一个头文件中使用前置声明。下面是新的使用了前置声明的Spreadsheet类定义,用来通知编译器关于SpreadsheetApplication类的信息。

1
2
3
4
5
6
7
class SpreadsheetApplication; // forward declaration
class Spreadsheet {
public:
Spreadsheet(size_t width, size_t height, SpreadsheetApplication& theApp);
private:
SpreadsheetApplicatoin& mTheApp;
};

这个定义将一个SpreadsheetApplication引用作为数据成员添加进来。在此情况下建议使用引用而不是指针,因为Spreadsheet总要引用一个 SpreadsheetApplication,而指针则无法保证这一点。

常量引用数据成员

就像普通引用可引用常量对象一样,引用成员也可引用常量对象。例如,为让Spreadsheet只包含应用程序对象的常量引用,只需要在类定义中将mTheApp声明为常量引用:

1
2
3
4
5
6
class Spreadsheet {
public:
Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
private:
const SpreadsheetApplication& mTheApp;
};

常量引用和非常量引用之间存在一个重要差别。常量引用SpreadsheetApplication 数据成员只能用于调用SpreadsheetApplication 对象上的常量方法。如果试图通过常量引用调用非常量方法,编译器会报错。还可创建静态引用成员或静态常量引用成员,但一般不需要这么做。

嵌套类

类定义不仅可包含成员函数和数据成员,还可编写嵌套类和嵌套结构、声明typedef或者创建枚举类型。类中声明的一切内容都具有类作用域。如果声明的内容是公有的,那么可在类外使用ClassName::作用域解析语法访问。

可在类的定义中提供另一个类定义。例如,假定SpreadsheetCell类实际上是Spreadsheet类的一部分,因此不妨将SpreadsheetCell重命名为Cell。可将二者定义为:

1
2
3
4
5
6
7
8
9
10
class Spreadsheet {
public:
class Cell{
public:
Cell() = default;
Cell(double initialValue);
};

Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp);
};

现在Cell类定义位于Spreadsheet类内部,因此在Spreasheet类外引用Cell必须用Spreadsheet::作用域限定名称,即使在方法定义时也是如此。例如,Cell的double构造函数应如下所示:

1
Spreadsheet::Cell::Cell (double initialValue) : mValue (initialValue) { }

甚至在Spreadsheet类中方法的返回类型(不是参数)也必须使用这一语法:
1
2
3
4
Spreadsheet::Cell& Spreadsheet::getCellAt(size_t x, size_t y) {
verifyCoordinate(x, y)
return mCells[x][y];
}

如果在Spreadsheet类中直接完整定义嵌套的Cell类,将使Spreadsheet类的定义略显臃肿。为缓解这一点,只需要在Spreadsheet中为Cell添加前置声明,然后独立地定义Cell类,如下所示:
1
2
3
4
5
6
7
8
9
10
class Spreadsheet
public:
class Cell ;
};

class Spreadsheet::Cell {
public:
Cell() = default;
Cell (double initialValue);
};

普通的访问控制也适用于嵌套类定义。如果声明了一个private或protected嵌套类,这个类只能在外围类(outer class,即包含它的类)中使用。嵌套的类有权访问外围类中的所有private或protected成员;而外围类却只能访问嵌套类中的public成员。

类内的枚举类型

如果想在类内定义许多常量,应该使用枚举类型而不是#define。例如,可在SpreadsheetCell类中支持单元格颜色,如下所示:

1
2
3
4
5
6
7
8
class SpreadsheetCell {
public:
enum class Color { Red = 1; Green, Blue, Yel1ow };
void setColor (Color color);
Color getColor() const;
private:
Color mColor = Color::Red;
};

setColor()getColor()方法的实现简单明了:
1
2
void SpreadsheetCell::setColor (Color color) { mColor = color; }
SpreadsheetCell::Color SpreadsheetCell::getColor() const { return mColor; }

运算符重载

C++允许编写自己的加号版本,为此可编写一个名为operator+的方法:

1
2
3
4
5
6
class SpreadsheetCell {
public:
SpreadsheetCell operator+(const SpreadsheetCell& cell) const {
return SpreadsheetCell(getValue() + cell.getValue());
}
}

当C++编译器分析一个程序,遇到运算符(例如,+、-、=或<<)时,就会试着查找名为operator+operator-operator=operator<<,且具有适当参数的函数或方法。例如,当编译器看到下面这行时,就会试着查找SpreadsheetCell类中名为operator+并将另一个SpreadsheetCell对象作为参数的方法,或者查找用两个SpreadsheetCell对象作为参数、名为operator+的全局函数:

1
SpreadsheetCell aThirdCell = myCell + anotherCell;

如果SpreadsheetCell类包含operator+方法,上述代码就 会转换为:
1
SpreadsheetCell aThirdCell = myCell.operator+ (anotherCell);

注意,用作operator+参数的对象类型并不一定要与编写operator+的类相同。

此外还要注意,可任意指定operator+的返回值类型。运算符重载是函数重载的一种形式,函数重载对函数的返回类型并没有要求。

隐式转换

令人惊讶的是,一旦编写前面所示的operator+,不仅可将两个单元格相加,还可将单元格与string_view、double或int 值相加。

1
2
3
4
SpreadsheetCell myCell(4), aThirdCell;
string str = "hello";
aThirdCell = myCell + string_view(str);
aThirdCell = myCell + 5.6;

上面的代码之所以可运行,是因为编译器会试着查找合适的operator+,而不是只查找指定类型的那个operator+。为找到operator+,编译器还试图查找合适的类型转换,构造函数会对有问题的类型进行适当的转换。

隐式转换通常会带来便利。但在上例中,将SpreadsheetCell与string_view相加并没有意义。可使用explicit关键字标记构造函数,禁止将string_view隐式地转换为SpreadsheetCell:

1
2
3
4
5
6
class SpreadsheetCell { 
public:
SpreadsheetCell() = default;
SpreadsheetCell (double initialValue);
explicit SpreadsheetCe1l (std::string_view initialvalue);
};

explicit关键字只在类定义内使用,只适用于只有一个参数的构造函数,例如单参构造函数或为参数提供默认值的多参构造函数。由于必须创建临时对象,隐式使用构造函数的效率不高。为避免与double值相加时隐式地使用构造函数,可编写第二个operator+,如下所示:
1
2
3
SpreadsheetCell SpreadsheetCell::operator+(double rhs) const {
return SpreadsheetCell(getValue() + rhs);
}

第三次尝试:全局operator+

隐式转换允许使用operator+方法将SpreadsheetCell对象与int和double值相加。然而,这个运算符不具有互换性,如下所示:

1
2
aThirdcell = myCell + 4; // Works fine.
aThirdCell = 4 + myCell; // FAILS TO COMPILE!

当SpreadsheetCell对象在运算符的左边时,隐式转换正常运行,但在右边时无法运行。加法是可互换的,因此这里存在错误。问题在于必须在SpreadsheetCell对象上调用operator+ 方法,对象必须在operator+的左边。这是C++语言定义的方式,因此使用operator+方法无法让上面的代码运行。

然而,如果用不局限于某个特定对象的全局operator+函数替换类内的operator+方法,上面的代码就可以运行,需要在头文件中声明运算符:

1
2
3
4
class SpreadsheetCell {
};

SpreadsheetCell operator+ (const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

那么,如果编写以下代码,会发生什么情况呢?

1
aThirdCell = 4.5 + 5.5;

这段代码可编译并运行,但并没有调用前面编写的operator+。这段代码将普通的double型数值4.5和5.5相加,得到了下面所示的中间语句:
1
aThirdCell = 10;

为了让赋值操作继续,运算符右边应该是SpreadsheetCell对象。编译器找到并非显式由用户定义的用double值作为参数的构造函数,然后用这个构造函数隐式地将double值转换为一个临时SpreadsheeCell对象,最后调用赋值运算符。

在C++中,不能更改运算符的优先级。例如,*和/始终在+和一之前计算。对于用户定义的运算符,唯一能做的只是在确定运算的优先级后指定实现。C++也不允许发明新的运算符号,不允许更改运算符的实参个数。

重载算术运算符

必须显式地重载简写算术运算符(Arithmetic Shorthand Operators)。这些运算符与基本算术运算符不同,它们会改变运算符左边的对象,而不是创建一个新对象。此外还有一个微妙差别,它们生成的结果是对被修改对象的引用,这一点与赋值运算符类似。简写算术运算符的左边总要有一个对象,因此应该将其作为方法而不是全局函数。下面是SpreadsheetCell类的声明:

1
2
3
4
5
6
7
class SpreadsheetCell {
public:
SpreadsheetCell& operator+= (const SpreadsheetCell& rhs);
SpreadsheetCell& operator-= (const SpreadsheetCe1l& rhs);
SpreadsheetCell& operator*= (const SpreadsheetCell& rhs);
SpreadsheetCell& operator/= (const SpreadsheetCell& rhs);
};

下面是operator+=的实现,其他的与此类似。
1
2
3
4
SpreadsheetCell& SpreadsheetCell::operator+= (const SpreadsheetCell& rhs) {
set(getValue() + rhs.getValue());
return *this;
}

简写算术运算符是对基本算术运算符和赋值运算符的结合。根据上面的定义,可编写如下代码:
1
2
3
SpreadsheetCell myCell(4), aThirdCell(2);
aThirdCell -= myCell;
aThirdCell += 5.4;

然而不能编写这样的代码

1
5.4 += aThirdCell;

如果既有某个运算符的普通版本,又有简写版本,建议你基于简写版本实现普通版本,以避免代码重复。

1
2
3
4
5
SpreadsheetCell operator+ (const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
auto result(lhs); // Local copy
result += rhs;
return result;
}

重载比较运算符

与基本的算术运算符类似,它们也应该是全局函数,这样就可在运算符的左边和右边使用隐式转换。所有比较运算符的返回值都是布尔值。当然,可改变返回类型,但并不建议这么做。下面是比较运算符的声明;

1
bool operator <op> (const SpreadsheetCell& lhs,const SpreadsheetCell& rhs) ;

下面是operator==的定义,其他的与此类似:

1
2
3
bool operator== (const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
return (lhs.getValue() == rhs.getValue());
}

当类中的数据成员较多时,比较每个数据成员可能比较痛苦。然而,当实现了==和<之后,可以根据这两个运算符编写其他比较运算符。例如,下面的operator>=定义使用了operator<

1
2
3
bool operator>= (const SpreadsheetCell& lhs, const SpreadsheetCell& rhs) {
return !(lhs < rhs);
}

可使用这些运算符将某个SpreadsheetCell与其他SpreadsheetCell进行比较,也可与double和int值进行比较:

1
2
3
if (myCell > aThirdCell || myCell < 10) {
cout << myCell.getValue() << endl;
}

揭秘继承技术

使用继承构建类

扩展类

当使用C++编写类定义时,可以告诉编译器,该类继承(或扩展)了一个已有的类。通过这种方式,该类将自动包含原始类的数据成员和方法;原始类称为父类(parent class)、基类或超类(superclass)。扩展已有类可以使该类(现在称为派生类或子类)只描述与父类不同的那部分内容。

在C++中,为扩展一个类,可在定义类时指定要扩展的类。为说明继承的语法,此处使用了名为Base和Derived的类。首先考虑Base类的定义:

1
2
3
4
5
6
7
8
class Base {
public:
void someMethod();
protected:
int mProtectedInt;
private:
int mPrivateInt;
};

如果要构建一个从Base类继承的新类Derived,应该使用下面的语法告诉编译器:Derived类派生自Base类:
1
2
3
4
class Derived : public Base {
public:
void someOtherMethod();
};

Derived本身就是一个完整的类,这个类只是刚好共享了Base类的特性而已。Derived不一定是Base唯一的派生类。其他类也可是Base的派生类,这些类是Derived的同级类(sibling)。

客户对继承的看法

对于客户或代码的其他部分而言,Derived类型的对象仍然是Base对象,因为Derived类从Base类继承。这意味着Base类的所有public方法和数据成员,以及Derived类的所有public方法和数据成员都是可供使用的。

在调用某个方法时,使用派生类的代码不需要知道是继承链中的哪个类定义了这个方法。例如,下面的代码调用了Derived对象的两个方法,而其中一个方法是在Base类中定义的:

1
2
3
Derived myDerived;
myDerived.someMethod() ;
myDerived.someOtherMethod() ;

指向某个对象的指针或引用可以指向声明类的对象,也可以指向其任意派生类的对象。此时需要理解的概念是,指向Base对象的指针可以指向Derived对象,对于引用也是如此。客户仍然只能访问Base类的方法和数据成员,但是通过这种机制,任何操作Base对象的代码都可以操作Derived对象。

例如,下面的代码可以正常编译并运行,尽管看上去好像类型并不匹配:

1
Base* base = new Derived() ; // Create Derived, store it in Base pointer .

然而,不能通过Base指针调用Derived类的方法。下面的代码无法运行:
1
base->someOtherMethod();

从派生类的角度分析继承

派生类可访问基类中声明的public、 protected 方法和数据成员,就好像这些方法和数据成员是派生类自己的,因为从技术上讲,它们属于派生类。例如,Derived 类中someOtherMethod()的实现可以使用在Base类中声明的数据成员mProtectedInt。下面的代码显示了这一实现,访问基类的数据成员和方法与访问派生类中的数据成员和方法并无不同之处。

1
2
3
4
void Derived::someOtherMethod() {
cout << "I can access base class data member mProtectedInt." << endl;
cout << "Its value is "<< mProtectedInt << endl;
}

如果类将数据成员和方法声明为protected,派生类就可以访问它们;如果声明为private,派生类就不能访问。

private访问说明符可控制派生类与基类的交互方式。建议将所有数据成员都默认声明为private,如果希望任何代码都可以访问这些数据成员,就可以提供public的获取器和设置器。如果仅希望派生类访问它们,就可以提供受保护的获取器和设置器。把数据成员默认设置为private的原因是,这会提供最高级别的封装,这意味着可改变数据的表示方式,而public或protected接口保持不变。不直接访问数据成员,也可在public或protected设置其中方便地添加对数据的检查。方法也应默认设置为private,只有需要公开的方法才设置为public,只有派生类需要访问的方法才设置为protected。

禁用继承

C++允许将类标记为final,这意味着继承这个类会导致编译错误。将类标记为final的方法是直接在类名的后面使用final关键字。例如,下面的Base类被标记为final:

1
class Base final { };

下面的Derived类试图从Base类继承,但是这会导致编译错误,因为Base类被标记为final。

1
class Derived : public Base { };

重写方法

从某个类继承的主要原因是为了添加或替换功能。Derived类定义在父类的基础上添加了功能。在许多情况下,可能需要替换或重写某个方法来修改类的行为。

将所有方法都设置为virtual,以防万一

在C++中,重写(verride)方法有一点别扭,因为必须使用关键字vitual。只有在基类中声明为virtual的方法才能被派生类正确地重写。virtual关键字出现在方法声明的开头,下面显示了Base类的修改版本:

1
2
3
4
5
6
7
8
class Base {
public:
virtual void someMethod() ;
protected:
int mProtectedInt;
private:
int mPrivateInt;
};

virtual关键字有些微妙之处,常被当作语言的设计不当部分。经验表明,最好将所有方法都设置为virtual。即使Derived类不大可能扩展,也最好还是将这个类的方法设置为virtual。
1
2
3
4
class Derived : public Base { 
public:
virtual void someOtherMethod() ;
};

为避免因为遗漏virtual关键字引发的问题,可将所有方法设置为virtual(包括析构函数,但不包括构造函数)。注意,由编译器生成的析构函数不是virtual!

重写方法的语法

为了重写某个方法,需要在派生类的定义中重新声明这个方法,就像在基类中声明的那样,并在派生类的实现文件中提供新的定义。例如,Base类包含了一个someMethod()方法,在Base.cpp中提供的someMethod()方法定义如下:

1
2
3
void Base::someMethod() {
cout << "This is Base's version of someMethod() ."<< endl;
}

注意在方法定义中不需要重复使用virtual关键字。

如果希望在Derived类中提供someMethod()的新定义,首先应该在Derived类定义中添加这个方法,如下所示:

1
2
3
4
5
class Derived : public Base {
public:
virtual void someMethod() override;
virtual void someOtherMethod();
};

建议在重写方法的声明末尾添加override关键字。

一旦将方法或析构函数标记为virtual,它们在所有派生类中就一直是 virtual,即使在派生类中删除了virtual关键字,也同样如此。例如,Derived类中,someMethod()仍然是virtual,可以被Derived的派生类重写,因为在Base类中将其标记为virtual。

客户对重写方法的看法

现在someMethod()的行为将根据对象所属类的不同而变化。例如,下面的代码与先前一样可以运行,调用Base版本的someMethod()

1
2
Base myBase;
myBase.someMethod();

如果声明一个Derived类对象,将自动调用派生类版本的someMethod()

1
2
Derived myDerived;
myDerived.someMethod();

Derived类对象的其他方面维持不变。从Base 类继承的其他方法仍然保持Base类提供的定义,除非在Derived类中显式地重写这些方法

如前所述,指针或引用可指向某个类或其派生类的对象。对象本身“知道”自己所属的类,因此只要这个方法声明为vitual,就会自动调用对应的方法。例如,如果一个对Base对象的引用实际引用的是Derived对象,调用someMethod()实际上会调用派生类版本,如下所示。如果在基类中省略了virtual 关键字,重写功能将无法正确运行。

1
2
3
Derived myDerived;
Base& ref = myDerived;
ref.someMethod(); // Calls Derived's version

记住,即使基类的引用或指针知道这实际上是一个派生类,也无法访问没有在基类中定义的派生类方法或成员。下面的代码无法编译,因为Base引用没有someOtherMethod()方法:

1
2
3
4
Derived myDerived;
Base& ref = myDerived;
myDerived.someOtherMethod(); // This is fine.
ref.someOtherMethod(); // Error

非指针或非引用对象无法正确处理派生类的特征信息。可将Derived对象转换为Base对象,或将Derived对象赋值给Base对象,因为Derived对象也是Base对象。然而,此时这个对象将遗失派生类的所有信息:

1
2
3
Derived myDerived;
Base assignedobject = myDerived; //Assigns a Derived to a Base .
assignedObject.someMethod(); // Calls Base's version of someMethod()

为记住这个看上去有点奇怪的行为,可考虑对象在内存中的状态。将Base对象当作占据内存的盒子。Derived对象是稍微大一点的盒子,因为它拥有Base对象的一切,还添加了一点内容。对于指向Derived对象的引用或指针,这个盒子并没有变,只是可以用新的方法访问它。然而,如果将Derived对象转换为Base对象,就会为了适应较小的盒子而扔掉Derived类全部的“独有特征”。

基类的指针或引用指向派生类对象时,派生类保留其重写方法。但是通过类型转换将派生类对象转换为基类对象时,就会丢失其独有特征。重写方法和派生类数据的丢失称为截断(slicing)。

override关键字

如果修改了Base类但忘记更新所有派生类,就会发生重载失败的问题。实际上就是创建了一个新的虚方法,而不是正确的重写这个方法。可用override关键字避免这种情况,如下所示:

1
2
3
4
class Derived : public Base {
public:
virtual void someMethod(int i) override ;
};

Derived类的定义将导致编译错误,因为override关键字表明,重写Base类的someMethod()方法,但Base类中的someMethod()方法只接收双精度数,而不接收整数。重命名基类中的某个方法,但忘记重命名派生类中的重写方法时,就会出现上述“不小心创建了新方法,而不是正确重写方法”的问题。

要想重写基类方法,始终在方法上使用override关键字。

virtual的真相

如果方法不是virtual,也可以试着重写这个方法,但是这样做会导致微妙的错误。

隐藏而不是重写

下面的代码显示了一个基类和一个派生类,每个类都有一个方法。派生类试图重写基类的方法,但是在基类中没有将这个方法声明为virtual。

1
2
3
4
5
6
7
8
class Base {
public:
void go() { cout << "go() called on Base" << endl; }
};
class Derived : public Base {
public:
void go() { cout << "go() called on Derived" << endl; }
};

试着用Derived对象调用go()方法好像没有问题。

1
2
Derived myDerived;
myDerived.go();

正如预期的那样,这个调用的结果是“go() called on Derived”。然而,由于这个方法不是virtual,因此实际上没有被重写。相反,Derived类创建了一个新的方法,名称也是go(),这个方法与Base类的go()方法完全没有关系。为证实这一点,只需要用Base指针或引用调用这个方法:

1
2
3
Derived myDerived;
Base& ref = myDerived;
ref.go();

你可能希望输出是“go() called on Derived”,但实际上,输出是“go() called on Base”。这是因为ref变量是一个Base引用,并省略了virtual关键字。当调用go()方法时,只是执行了Base类的go()方法。由于不是虛方法,不需要考虑派生类是否重写了这个方法。

如何实现virtual

为理解如何避免隐藏方法,需要了解virtual关键字的真正作用。C++在编译类时,会创建一个包含类中所有方法的二进制对象。在非虚情况下,将控制交给正确方法的代码是硬编码,此时会根据编译时的类型调用方法。这称为静态绑定(static binding),也称为早绑定(early binding)。

如果方法声明为vitual, 会使用名为虚表(vtable)的特定内存区域调用正确的实现。每个具有一个或多个虚方法的类都有一张虚表,这种类的每个对象都包含指向虚表的指针,这个虚表包含指向虛方法实现的指针。通过这种方法,当使用某个对象调用方法时,指针也进入虚表,然后根据实际的对象类型执行正确版本的方法。

这称为动态绑定(dynamic binding)或晚绑定(late binding)。

为更好地理解虚表是如何实现方法的重写的,考虑下面的Base和Derived类:

1
2
3
4
5
6
7
8
9
10
11
class Base {
public:
virtual void func1() { }
virtual void func2 () { }
void nonVirtualFunc() { }
};
class Derived : public Base {
public:
virtual void func2() override { }
void nonVirtualFunc { }
};

对于这个示例,考虑下面的两个实例:

1
2
Base myBase;
Derived myDerived;

图10-4 显示了这两个实例虚表的高级视图。myBase对象包含了指向虚表的一个指针,虚表有两项,一项是func1(),另一项是func2()。这两项指向Base::func1()Base::func2()的实现。

myDerived也包含指向虚表的一个指针,这个虚表也包含两项,一项是func1(),另一项是func2()。myDerived虚表的func1()项指向Base::func1(),因为Derived类没有重写func1();但是myDerived虛表的func2()项指向Derived::func2()

使用virtual的理由

首先创建virtual的原因,与虚表的开销有关。要调用虚方法,程序需要执行一项附加操作,即对指向要执行的适当代码的指针解除应用。在多数情况下,这样做会轻微地影响性能。如果方法永远不会重写,就没必要将其声明为virtual,从而影响性能。在多数应用程序中,无法察觉到使用虛方法和不使用虛方法带来的性能差别,因此应该遵循建议,将所有方法声明为virtual,包括析构函数。

但在某些情况下,性能开销确实不小,需要避免。例如,假设Point类有一个虚方法。 如果另一个数据结构存储着数百万个甚至数十亿个Point对象,在每个Point 对象上调用虚方法将带来极大的开销。此时,最好避免在Point类中使用虚方法。

virtual对于每个对象的内存使用也有轻微影响。除了方法的实现之外,每个对象还需要一个指向虚表的指针,这个指针会占用一点空间。

虚析构函数的需求

即使认为不应将所有方法都声明为virtual的程序员,也坚持认为应该将析构函数声明为virtual。原因是,如果析构函数未声明为virtual,很容易在销毁对象时不释放内存。唯一允许不把析构函数声明为virtual的例外情况是,类被标记为final。

例如,派生类使用的内存在构造函数中动态分配,在析构函数中释放。如果不调用析构函数,这块内存将无法释放。类似地,如果派生类具有一些成员,这些成员在类的实例销毁时自动删除,如stl:unique_ptrs,那么如果从未调用析构函数,将不会删除这些成员。

如果在析构函数中什么都不做,只想把它设置为virtual, 可显式地设置“default”,例如:

1
2
3
4
class Base {
public:
virtual ~Base() = default;
};

除非有特别原因,或者类被标记为final,否则强烈建议将所有方法(包括析构函数,构造函数除外)声明为virtual,构造函数不需要,也无法声明为virtual,因为在创建对象时,总会明确地指定类。

禁用重写

C++允许将方法标记为final,这意味着无法在派生类中重写这个方法。考虑下面的Base类:

1
2
3
4
5
class Base { 
public:
virtual ~Base() = default;
virtual void someMethod() final;
};

在下面的Derived类中重写someMethod()会导致编译错误,因为someMethod()在Base类中标记为final。

1
2
3
4
class Derived : public Base { 
public:
virtual void someMethod() override; // Error
};

利用父类

编写派生类时,需要知道父类和派生类之间的交互方式。创建顺序、构造函数链和类型转换都是潜在的bug来源。

父类构造函数

创建对象时必须同时创建父类和包含于其中的对象。C++定义了如下创建顺序:

  1. 如果某个类具有基类,执行基类的默认构造函数。除非在ctor-initializer中调用了基类构造函数,否则此时调用这个构造函数而不是默认构造函数。
  2. 类的非静态数据成员按照声明的顺序创建。
  3. 执行该类的构造函数。

下面的代码显示了创建顺序。代码正确执行时输出结果为123。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Something {
public:
Something() { cout << "2"; }
};

class Base {
public:
Base() { cout << "1"; }
};

class Derived : public Base {
public:
Derived() { cout << "3"; }
private:
Something mDataMember;
};

int main() {
Derived myDerived;
return 0;
}

创建myDerived对象时,首先调用Base构造函数,输出字符串“1”。 随后,初始化mDataMember,调用Something构造函数,输出字符串“2”。最后调用Derived构造函数,输出“3”。

注意Base构造函数是自动调用的。C++将自动调用父类的默认构造函数(如果存在的话)。如果父类的默认构造函数不存在,或者存在默认构造函数但希望使用其他构造函数,可在构造函数初始化器(constructor initializer)中像初始化数据成员那样链接构造函数。例如,下面的代码显示了没有默认构造函数的Base版本。相关版本的Derived必须显式地告诉编译器如何调用Base构造函数,否则代码将无法编译。

1
2
3
4
5
6
7
8
9
10
class Base {
public:
Base(int i);
};
class Derived : public Base
public:
Derived();
};

Derived::Derived() : Base(7) { }

在前面的代码中,Derived 构造函数向Base构造函数传递了固定值(7)。如果Derived构造函数需要一个参数,也可以传递变量:

1
Derived::Derived(int i) : Base(i) {}

从派生类向基类传递构造函数的参数很正常,毫无问题,但是无法传递数据成员。如果这么做,代码可以编译,但是记住在调用基类构造函数之后才会初始化数据成员。如果将数据成员作为参数传递给父类构造函数,数据成员不会初始化。

父类的析构函数

由于析构函数没有参数,因此始终可自动调用父类的析构函数。析构函数的调用顺序刚好与构造函数相反:

  1. 调用类的析构函数。
  2. 销毁类的数据成员,与创建的顺序相反。
  3. 如果有父类,调用父类的析构函数。

也可递归使用这些规则。链的最底层成员总是第一个被销毁。下面的代码在前面的示例中加入了析构函数。所有析构函数都声明为virtual。执行时代码将输出“123321”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Something {
public:
virtual ~Something() { cout << "2"; }
};
class Base {
public:
virtual ~Base() { cout << "1"; }
};
class Derived : public Base
public:
Derived() { cout << "3"; }
virtual ~Derived() { cout < < "3"; }
private:
Something mDataMember;
};

即使前面的析构函数没有声明为vitual,代码也可以继续运行。然而,如果代码使用delete删除一个实际指向派生类的基类指针,析构函数调用链将被破坏。例如,下面的代码与前面示例类似,但析构函数不是virtual。当使用指向Base对象的指针访问Derived对象并删除对象时,就会出问题。

1
2
Base* ptr = new Derived() ;
delete ptr;

代码的输出很短,是“1231”。当删除ptr 变量时,只调用了Base析构函数,因为析构函数没有声明为virtual。结果是没有调用Derived析构函数,也没有调用其数据成员的析构函数。

从技术角度看,将Base 析构函数声明为virtual,可纠正上面的问题。派生类将自动“虚化”。然而,建议显式地将所有析构函数声明为virtual,这样就不必担心这个问题。

将所有析构函数声明为virtual!编译器生成的默认析构函数不是virtual,因此应该定义自己(或显式设置为默认)的虚析构函数,至少在父类中应该这么做。

与构造函数一样,在析构函数中调用虚方法时,虚方法的行为将有所不同。如果派生类重写了基类中的虚方法,在基类的析构函数中调用该方法,会执行该方法的基类实现,而不是派生类的重写版本。

使用父类方法

在派生类中重写方法时,将有效地替换原始方法。然而,方法的父类版本仍然存在,仍然可以使用这些方法。考虑WeatherPrediction类中的getTemperature()方法,这个方法返回当前温度的字符串表示:

1
2
3
4
class WeatherPrediction {
public:
virtual std::string getTemperature() const;
};

在MyWeatherPrediction类中,可按如下方式重写这个方法:
1
2
3
4
class MyWeatherPrediction : public WeatherPrediction
public:
virtual std::string getTemperature() const override;
};

假定派生类要先调用基类的getTemperature()方法,然后将“°F”添加到string。为此,编写如下代码:

1
2
3
string MyWeatherPrediction::getTemperature() const {
return getTemperature() + "\u00B0E"; // BUG
}

然而,上述代码无法运行,根据C++的名称解析规则,首先解析的是局部作用域,然后是类作用域,根据这个顺序,函数中调用的是MyWeatherPrediction::getTemperature()。其结果是无限递归,直到耗尽堆栈空间。

为让代码运行,需要使用作用域解析运算符,如下所示:

1
2
3
string MyWeatherPrediction::getTemperature() const { 
return WeatherPrediction::getTemperature() + " \u00B0F" ;
}

在C++中,调用当前方法的父类版本是一种常见操作。如果存在派生类链,每个派生类都可能想执行基类中已经定义的操作,同时添加自己的附加功能。如果父类没有重写祖父类中的函数,C++会沿着类层次结构向上寻找实现了这个函数的类。

向上转型和向下转型

如前所述,对象可转换为父类对象,或者赋值给父类。如果类型转换或赋值是对某个普通对象执行,会产生截断:

1
Base myBase = myDerived; // Slicing!

这种情况下会导致截断,因为赋值结果是Base对象,而Base对象缺少Derived类中定义的附加功能。然而,如果用派生类对基类的指针或引用赋值,则不会产生截断:
1
Base& myBase = myDerived; // No slicing!

这是通过基类使用派生类的正确途径,也叫作向上转型(upcating)。这也是让方法和函数使用类的引用而不是直接使用类对象的原因。使用引用时,派生类在传递时没有截断。

当向上转型时,使用基类指针或引用以避免截断。

将基类转换为其派生类也叫作向下转型(downcasting),专业的C++程序员通常不赞成这种转换,因为无法保证对象实际上属于派生类,也因为向下转型是不好的设计。如果打算进行向下转型,应该使用dynamic_cast(),以使用对象内建的类型信息,拒绝没有意义的类型转换。这种内建信息通常驻留在虚表中,这意味着dynamic_cast只能用于具有虚表的对象,即至少有一个虚编号的对象。如果针对某个指针的dynamic_cast()失败,这个指针的值就是nullptr,而不是指向某个无意义的数据。如果针对对象引用的dynamic_cast()失败,将抛出stl::bad_cast异常。

继承与多态性

回到电子表格

下面给出了简化的SpreadsheetCell类定义。注意单元格可以是双精度值或字符串,然而这个示例中单元格的当前值总以字符串的形式返回。

1
2
3
4
5
6
7
8
9
10
11
class SpreadsheetCell {
public:
public:
virtual void set(double inDouble);
virtual void set(std::string_view inString);
virtual std::string getString() const;
private:
static std::string doubleTostring(double inValue);
static double stringToDouble(std::string_view inString);
double mValue;
};

设计多态性的电子表格单元格

SpreadsheeCell类急需改变层次结构。一种合理方法是让SpreadsheetCell只包含字符串,从而限制其范围,在此过程中或许将其重命名为StringSpreadsheetCell。为处理双精度值,可使用第二个类DoubleSpreadsheetCell。因为包含字符串的单元格与包含双精度值的单元格存在明显的关系。让这两个类地位同等,并有共同的父类SpreadsheetCell

  • 两个派生类都支持由基类定义的同一接口(方法集)。
  • 使用SpreadsheetCell对象的代码可调用接口中的任何方法,而不需要知道这个单元格是StringSpreadsheetCell还是DoubleSpreadsheetCell。
  • 由于虚方法的特殊能力,会根据对象所属的类调用接口中每个方法的正确实例。
  • 其他数据结构可通过引用父类类型,包含一组多类型的单元格。

SpreadsheetCell基类

初次尝试

SpreadsheeCell基类负责定义所有派生类支持的行为。在本例中,所有单元格都需要将值设置为字符串。此外,所有单元格都需要将当前值返回为字符串。基类定义中声明了这些方法,以及显式设置为默认的虚析构函数,但没有数据成员:

1
2
3
4
5
6
class SpreadsheetCell {
public:
virtual ~SpreadsheetCell() = default;
virtual void set (std::string_view inString) ;
virtual std::string getString() const;
};

纯虚方法和抽象基类

纯虚方法(pure virtual methods)在类定义中显式说明该方法不需要定义。如果将某个方法设置为纯虚方法,就是告诉编译器当前类中不存在这个方法的定义。具有至少一个纯虚方法的类称为抽象类,因为这个类没有实例。编译器会强制接受这个事实:如果某个类包含一个或多个纯虚方法,就无法构建这种类型的对象。

采用专门的语法指定纯虚方法:方法声明后紧接着=0。不需要编写任何代码。

1
2
3
4
5
6
class SpreadsheetCell {
public:
virtual ~Spreadsheetcell() = default;
virtual void set (std::string_view inString) = 0;
virtual std::string getString() const = 0;
};

一旦实现了StringSpreadsheetCell 类,下面的代码就可成功编译,原因在于实例化了抽象基类的派生类:

1
std::unique_ptr<SpreadsheetCell> cell (new StringSpreadsheetCell());

抽象类提供了一种禁止其他代码直接实例化对象的方法,而它的派生类可以实例化对象。

独立的派生类

StringSpreadsheetCell类定义

编写StringSpreadsheetCell类定义的第一步是从SpreadsheetCell类继承。第二步是重写继承的纯虚方法,此次不将其设置为0。最后一步是为字符串单元格添加一个私有数据成员mValue,在其中存储实际单元格数据。这个数据成员是stl::optional,从C++17开始定义在<optional>头文件中。optional类型是一个类模板,因此必须在尖括号之间指定所需的实际类型,如optional<string>

1
2
3
4
5
6
7
class StringSpreadsheetCell : public SpreadsheetCell {
public:
virtual void set(std::string_view inString) override;
virtual std: :string getString() const override;
private:
std::optional<std::string> mValue;
};

StringSpreadsheetCell的实现

StringSpreadsheetCell 的源文件包含方法的实现。set()方法十分简单,因为内部表示已经是一个字符串。如果mValue不具有值,getString()将返回一个空字符串。可使用std:optionalvalue_or()方法对此进行简化。使用mValue.value_or(" ")

1
2
3
4
5
6
void StringSpreadsheetCell::set(string_view inString) {
mValue = inString;
}
string StringSpreadsheetCell::getString() const {
return mValue.value_or("");
}

DoubleSpreadsheetCell 类的定义和实现

与StringSpreadsheetCell相同,这个类也有一个mValue数据成员,此时这个成员的类型是optional<double>

1
2
3
4
5
6
7
8
9
10
class DoubleSpreadsheetCell : public SpreadsheetCell {
public:
virtual void set (double inDouble);
virtual void set (std::string_view inString) override;
virtual std::string getString() const override;
private:
static std::string doubleToString (double inValue) ;
static double stringToDouble(std::string_view inValue);
std::optional<double> mValue;
};

1
2
3
4
5
6
7
8
9
void DoubleSpreadsheetCell::set (double inDouble) {
mValue = inDouble;
}
void DoubleSpreadsheetCell::set(string_view inString) {
mValue = stringToDouble (inString);
}
string DoubleSpreadsheetCell::getString() const {
return (mValue.has_value() ? doubleToString(mValue.value()) : "");
}

考虑将来

首先,即使不考虑改进设计,现在仍然缺少一个功能: 将某个单元格类型转换为其他类型。应添加一个转换构造函数(或类型构造函数),这个构造函数类似于复制构造函数,但参数不是对同类对象的引用,而是对同级类对象的引用。另外注意,现在必须声明一个默认构造函数,可将其显式设置为默认,因为一旦自行声明任何构造函数,编译器将停止生成:

1
2
3
4
5
class StringSpreadsheetCell : public SpreadsheetCell {
public:
StringSpreadsheetCell() = default;
StringSpreadsheetCell (const DoubleSpreadsheetCell& inDoubleCell) ;
};

将转换构造函数实现为如下形式:

1
2
3
4
StringSpreadsheetCell::StringSpreadsheetCell (
const DoubleSpreadsheetCell& inDoubleCell) {
mValue = inDoubleCell.getString();
}

通过转换构造函数,可很方便地用DoubleSpreadsheetCell创建StringSpreadsheetCell。然而不要将其与指针或引用的类型转换混淆,类型转换无法将一个指针或引用转换为同级的另一个指针或引用。

其次,如何为单元格实现运算符重载是一个很有趣的问题,一种方案是给出一种通用表示,前面的实现已将字符串作为标准化的通用类型表示。通过这种通用表示,一个operator+函数就可以处理所有情况。假定两个单元格相加的结果始终是字符串单元格,那么一个可能的实现如下所示:

1
2
3
4
5
StringSpreadsheetCell operator+ (const StringSpreadsheetCell& lhs, const StringSpreadsheetCell& rhs) {
StringSpreadsheetCell newCell;
newCell.set(lhs.getString() + rhs.getString()) ;
return newCell;
}

多重继承

从多个类继承

从语法角度看,定义具有多个父类的类很简单。为此,只需要在声明类名时分别列出基类:

1
2
class Baz : public Foo, public Bar
{ };

由于列出了多个父类,Baz 对象具有如下特性:

  • Baz对象支持Foo和Bar类的public方法,并且包含这两个类的数据成员。
  • Baz类的方法有权访问Foo和Bar类的protected数据成员和方法。
  • Baz对象可以向上转型为Foo或Bar对象。
  • 创建新的Baz对象将自动调用Foo和Bar类的默认构造函数,并按照类定义中列出的类顺序进行。
  • 删除Baz对象将自动调用Foo和Bar类的析构函数,调用顺序与类在类定义中的顺序相反。

名称冲突和歧义基类

多重继承崩溃的场景并不难想象,下面的示例显示了一些必须考虑的边缘情况。

名称歧义

如果两个类都有一个eat()方法,会发生什么?eat()方法的一个版本无法重写另一个版本——在派生类中这两个方法都存在。如果客户代码试图调用派生类的eat()方法,编译器将报错,指出对eat()方法的调用有歧义。

为了消除歧义,可使用dynamic_cast()显式地将对象向上转型(本质上是向编译器隐藏多余的方法版本),也可以使用歧义消除语法。下面的代码显示了调用eat()方法的Dog版本的两种方案:

1
2
dynamic_cast<Dog&> (myConfusedAnimal).eat(); // Calls Dog::eat()
myConfusedAnimal.Dog::eat();

使用与访问父类方法相同的语法(::运算符),派生类的方法本身可以显式地为同名的不同方法消除歧义。例如,派生类可以定义自己的eat()方法,从而消除其他代码中的歧义错误。在方法内部,可以判断调用哪个父类版本:

1
2
3
4
5
6
7
8
class DogBird : public Dog,public Bird {
public:
void eat() override;
};

void DogBird::eat() {
Dog::eat();
}

另一种防止歧义错误的方式是使用using 语句显式指定,在派生类中应继承哪个版本的eat()方法,如下面的DogBird类定义所示:

1
2
3
4
class DogBird : public Dog, public Bird
public:
using Dog::eat; // Explicitly inherit Dog's version of eat()
};

歧义基类

另一种引起歧义的情况是从同一个类继承两次。例如,如果出于某种原因Bird类从Dog类继承,DogBird类的代码将无法编译,因为Dog变成了歧义基类。

1
2
3
class Dog {};
class Bird : public Dog {};
class DogBird : public Bird, public Dog {}; // Error!

数据成员也可以引起歧义。如果Dog和Bird类具有同名的数据成员,当客户代码试图访问这个成员时,就会发生歧义错误。

多个父类本身也可能有共同的父类。例如,Bird和Dog类可能都是Animal类的派生类(菱形类结构)。

使用“菱形”类层次结构的最佳方法是将最顶部的类设置为抽象类,将所有方法都设置为纯虚方法。由于类只声明方法而不提供定义,在基类中没有方法可以调用,因此在这个层次上就没有歧义。

有趣而晦涩的继承问题

修改重写方法的特征

重写某个方法的主要原因是为了修改方法的实现。然而,有时是为了修改方法的其他特征。

修改方法的返回类型

重写方法要使用与基类一致的方法声明(或方法原型)。实现可以改变,但原型保持不变。然而事实未必总是如此,在C++中,如果原始的返回类型是某个类的指针或引用,重写的方法可将返回类型改为派生类的指针或引用。这种类型称为协变返回类型(covariant return types)。如果基类和派生类处于平行层次结构(parallel hierarchy)中,使用这个特性可以带来便利。平行层次结构是指,一个类层次结构与另一个类层次结构没有相交,但是存在联系。

修改方法的参数

如果在派生类的定义中使用父类中虚方法的名称,但参数与父类中同名方法的参数不同,那么这不是重写父类的方法,而是创建一个新方法。回到本章前面的Base 和Derived类示例,可试着在Derived类中使用新的参数列表重写someMethod()方法,如下所示:

1
2
3
4
5
6
7
8
9
class Base {
public:
virtual void someMethod() ;
};
class Derived : public Base {
public:
virtual void someMethod(int i); // Compiles, but doesn't override
virtual void someOtherMethod();
};

这个方法的实现如下所示:

1
2
3
void Derived::someMethod(int i) {
cout << "This is Derived's version of someMethod with argument "<< i << "." << endl;
}

实际上,C++标准指出,当Derived 类定义了这个方法时,原始的方法被隐藏。下面的代码无法编译,因为没有参数的someMethod()方法不再存在。

1
2
Derived myDerived;
myDerived.someMethod(); // Error! Won't compile because original method is hidden.

如果希望重写基类中的someMethod()方法,就应该像前面建议的那样使用override关键字。如果在重写方法时发生错误,编译器会报错。

可使用一种较晦涩的技术兼顾二者。也就是说,可使用这一技术在派生类中有效地用新的原型“重写”某个方法,并继承该方法的基类版本。这一技术使用using关键字显式地在派生类中包含这个方法的基类定义:

1
2
3
4
5
6
7
8
9
10
class Base { 
public:
virtual void someMethod();
};
class Derived : public Base {
public:
using Base::someMethod;
virtual void someMethod(int i);
virtual void someOtherMethod();
};

继承的构造 函数

允许在派生类中继承基类的构造函数。考虑下面的Base和Derived类定义:

1
2
3
4
5
6
7
8
9
class Base {
virtual ~Base() = default;
Base() = default;
Base(std::string_view str);
};
class Derived : public Base {
public:
Derived(int i);
};

只能用提供的Base构造函数构建Base对象,要么是默认构造函数,要么是包含string_view 参数的构造函数。另外,只能用Derived构造函数创建Derived 对象,这个构造函数需要一个整数作为参数。不能使用Base类中使用接收string_view 的构造函数来创建Derived对象。例如:

1
2
3
Base base ("Hello");  // OK, calls string_view Base ctor
Derived derived1 (1); // OK, calls integer Derived ctor
Derived derived2 ("Hel1o"); // Error, Derived does not inherit string_view ctor

如果喜欢使用基于string_view的Base构造函数构建Derived对象,可在Derived 类中显式地继承Base构造函数,如下所示:

1
2
3
4
5
class Derived : public Base {
public:
using Base::Base;
Derived(int i);
};

using语句从父类继承除默认构造函数外的其他所有构造函数,现在可通过两种方法构建Derived对象:

Derived类定义的构造函数可与从Base类继承的构造函数有相同的参数列表。与所有的重写一样,此时Derived类的构造函数的优先级高于继承的构造函数。

使用using子句从基类继承构造函数有一些限制。当从基类继承构造函数时,会继承除默认构造函数外的其他全部构造函数,不能只是继承基类构造函数的一个子集。第二个限制与多重继承有关。如果一个基类的某个构造函数与另一个基类的构造函数具有相同的参数列表,就不可能从基类继承构造函数,因为那样会导致歧义。为解决这个问题,Derived类必须显式地定义冲突的构造函数。例如,下面的Derived类试图继承Base1和Base2基类的所有构造函数,这会产生编译错误,因为使用浮点数作为参数的构造函数存在歧义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base1 {
public:
virtual ~Base1() = default;
Base1() = default;
Base1(float f);
};
class Base2 {
public:
virtual ~Base2() = default;
Base2() = default;
Base2 (std::string_view str);
Base2(float f);
};
class Derived : public Base1, public Base2 {
public:
using Base1::Base1;
using Base2::Base2;
Derived(char c);
};

Derived类定义中的第一条using 语句继承了Base1类的构造函数。这意味着Derived类具有如下构造函数:

1
Derived(float f); // Inherited from Base1

Derived类定义中的第二条using子句试图继承Base2类的全部构造函数。然而,这会导致编译错误,因为这意味着Derived类拥有第二个Derived(float f)构造函数。为解决这个问题,可在Derived类中显式声明冲突的构造函数,如下所示:

1
2
3
4
5
6
7
class Derived : public Base1, public Base2 {
public:
using Base1::Basel;
using Base2::Base2;
Derived(char c);
Derived(float f);
};

现在,Derived 类显式地声明了一个采用浮点数作为参数的构造函数,从而解决了歧义问题。如果愿意,在Derived 类中显式声明的使用浮点数作为参数的构造函数仍然可以在ctor-initializer中调用Base1和Base2构造函数,如下所示:

1
Derived::Derived(float f) : Base1(f), Base2(f) {}

重写方法时的特殊情况

当重写方法时,需要注意几种特殊情况。本节将列出可能遇到的一些情况。

静态基类方法

在C++中,不能重写静态方法。对于多数情况而言,知道这一点就足够了。然而,在此需要了解一些推论。首先,方法不可能既是静态的又是虚的。出于这个原因,试图重写一个静态方法并不能得到预期的结果。如果派生类中存在的静态方法与基类中的静态方法同名,实际上这是两个独立的方法。下面的代码显示了两个类,这两个类都有一个名为beStatic()的静态方法。这两个方法毫无关系。

1
2
3
4
5
6
7
8
class BaseStatic {
public:
static void beStatic() { cout << "BaseStatic being static." << endl;}
};
class DerivedStatic : public BaseStatic {
public:
static void beStatic() { cout << "Derivedstatic keepin' it static." << endl; }
};

由于静态方法属于类,调用两个类的同名方法时,将调用各自的方法。

1
2
BaseStatic::beStatic();
DerivedStatic::beStatic();

输出为:

1
2
BaseStatic being static.
DerivedStatic keepin' it static.

用类名访问这些方法时一切都很正常。当涉及对象时,这一行为就不是那么明显。在C++中,可以使用对象调用静态方法,但由于方法是静态的,因此没有this指针,也无法访问对象本身,使用对象调用静态方法,等价于使用classname:method()调用静态方法。回到前面的示例,可以编写如下代码,但是结果令人惊讶:

1
2
3
4
DerivedStatic myDerivedStatic;
BaseStatic& ref = myDerivedStatic;
myDerivedStatic.beStatic();
ref.beStatic();

beStatic()的第一次调用显然调用了DerivedStatic版本,因为调用它的对象被显式地声明为DerivedStatic。第二次调用的对象是一个BaseStatic引用,但指向的是一个DerivedStatic对象。在此情况下,会调用BaseStatic版本的beStatic()。 原因是当调用静态方法时,C++不关心对象实际上是什么,只关心编译时的类型。在此情况下,该类型为指向BaseStatic对象的引用。

静态方法属于定义它的类,而不属于特定的对象。当类中的方法调用静态方法时,所调用的版本是通过正常的名称解析来决定的。当使用对象调用时,对象实际上并不涉及调用,只是用来判断编译时的类型。

重载基类方法

当指定名称和一组参数以重写某个方法时,编译器隐式地隐藏基类中同名方法的所有其他实例。考虑下面的Derived类,它重写了一个方法,而没有重写相关的同级重载方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
virtual ~Base() = default;
virtual void overload() { cout << "Base's overload()" << endl;
virtual void overload(int i) {
cout << "Base's overload(int i)" << endl;
}
};
class Derived : public Base {
public:
virtual void overload() override {
cout << "Derived's overload()" << endl;
}
};

如果试图用Derived对象调用以int值作为参数的overload()版本,代码将无法编译,因为没有显式地重写这个方法。然而,使用Derived对象访问该版本的方法是可行的。只需要使用指向Base对象的指针或引用:

1
2
3
Derived myDerived;
Base& ref = myDerived;
ref.overload(7) ;

在C++中,隐藏未实现的重载方法只是表象。显式声明为子类型实例的对象无法使用这些方法,但可将其转换为基类类型,以使用这些方法。

如果只想改变一个方法,可以使用using关键字避免重载该方法的所有版本。在下面的代码中,Derived类定义中使用了从Base类继承的一个overload()版本,并显式地重写了另一个版本:

1
2
3
4
5
class Derived : public Base {
public:
using Base::overload;
virtual void overload() override
};

基类方法具有默认参数

派生类与基类可具有不同的默认参数,但使用的参数取决于声明的变量类型,而不是底层的对象。下面是一个简单的派生类示例,派生类在重写的方法中提供了不同的默认参数:

1
2
3
4
5
6
7
8
class Base {
public:
virtual void go(int i = 2);
};
class Derived : public Base
public:
virtual void go(int i = 7) override;
};

如果调用Derived对象的go(),将执行Derived版本的go(),默认参数为7。如果调用Base对象的go(),将执行Base版本的go(),默认参数为2。然而(有些怪异),如果使用实际指向Derived对象的Base指针或Base引用调用go(),将调用Derived版本的go(),但使用Base版本的默认参数2。

产生这种行为的原因是C++根据表达式的编译时类型(而非运行时类型)绑定默认参数。在C++中,默认参数不会被“继承”。如果上面的Derived类没有像父类那样提供默认参数,就用新的非0参数版本重载go()方法。

当重写具有默认参数的方法时,也应该提供默认参数,这个参数的值应该与基类版本相同。建议使用符号常量作为默认值,这样可在派生类中使用同一个符号常量。

派生类中的复制构造函数和赋值运算符

当定义派生类时,必须注意复制构造函数和operator=。如果派生类没有任何需要使用非默认复制构造函数或operator=的特殊数据(通常是指针),无论基类是否有这类数据,都不需要它们。如果派生类省略了复制构造函数或operator=,派生类中指定的数据成员就使用默认的复制构造函数或operator=,基类中的数据成员使用基类的复制构造函数或operator=。

另外,如果在派生类中指定了复制构造函数,就需要显式地链接到父类的复制构造函数,下面的代码演示了这一内容。如果不这么做,将使用默认构造函数(不是复制构造函数!)初始化对象的父类部分。

1
2
3
4
5
6
7
class Derived : public Base {
public:
Derived() = default;
Derived (const Derived& src);
};

Derived::Derived(const Derived& src) : Base (src);

与此类似,如果派生类重写了operator=, 则几乎总是需要调用父类版本的operator=。唯一的例外是因为某些奇怪的原因,在赋值时只想给对象的一部分赋值。下面的代码显示了如何在派生类中调用父类的赋值运算符。

1
2
3
4
5
6
7
Derived& Derived: :operator= (const Derived& rhs) {
if (&rhs == this) {
return *this;
}
Base::operator=(rhs); // calls parent's operator=.
return *this;
}

如果派生类不指定自己的复制构造函数或operator=,基类的功能将继续运行。否则,就需要显式引用基类版本。

运行时类型工具

在C++中,有些特性提供了对象的运行时视角。这些特性通常归属于一个名为运行时类型信息(RunTime Type Information, RTTI)的特性集。RTTI的一个特性是typeid运算符,这个运算符可在运行时查询对象,从而判别对象的类型。大多数情况下,不应该使用typeid,因为最好用虚方法处理基于对象类型运行的代码。下面的代码使用了typeid,根据对象的类型输出消息:

1
2
3
4
5
6
7
8
#include <typeinfo>
void speak(const Animal& animal) {
if (typeid(animal) == typeid(Dog)) {
cout << "Woof!" << endl;
} else if (typeid(animal) == typeid(Bird)) {
cout << "Chirp!" << endl;
}
}

一旦看到这样的代码,就应该立即考虑用虚方法重新实现该功能。

类至少有一个虚方法,typeid 运算符才能正常运行。如果在没有虚方法的类上使用dynamic_cast(),会导致编译错误。typeid 运算符也会从实参中去除引用和const限定符。

typeid运算符的主要价值之一在于日志记录和调试。

非public继承

将父类的关系声明为protected,意味着在派生类中,基类所有的public方法和数据成员都成为受保护的。与此类似,指定private继承意味着基类所有的public、protected方法和数据成员在派生类中都成为私有的。使用这种方法统一降低父类的访问级别有许多原因,但多数原因都是层次结构的设计缺陷。

虚基类

如果希望被共享的父类拥有自己的功能,C++提供了另一种机制来解决这个问题。如果被共享的基类是一个虚基类(virtual base class),就不存在歧义。

理解灵活而奇特的C++

引用

在C++中,引用是另一个变量的别名。对引用的所有修改都会改变被引用的变量的值。可将引用当作隐式指针,这个指针没有取变量地址和解除引用的麻烦。也可将引用当作原始变量的另一个名称。

引用变量

引用变量在创建时必须初始化,如下所示:

1
2
int x = 3;
int& xRef = x;

使用xRef就是使用x的当前值。对xRef赋值会改变x的值。

创建引用时必须总是初始化它。通常会在声明引用时对其进行初始化,但是对于包含类而言,需要在构造函数初始化器中初始化引用数据成员。

不能创建对未命名值(例如一个整数字面量)的引用,除非这个引用是一个const值。

1
2
int& unnamedRef1 = 5; // DOES NOT COMPILE
const int& unnamedRef2 = 5; // Works as expected

临时对象同样如此。不能具有临时对象的非const引用,但可具有const引用。例如:

1
std::string getString() { return "Hello world!"; }

对于调用getString()的结果,可以有一个const引用;在该const引用超出作用域之前,将使std::string对象一直处于活动状态:

1
2
std::string& string1 = getString();    // DOES NOT COMPILE
const std::string& string2 = getString(); // Works as expected

修改引用

引用总是引用初始化的那个变量,且无法修改。如果在声明引用时用一个变量“赋值”,那么这个引用就指向这个变量。然而,如果在此后使用变量对引用赋值,被引用变量的值就变为被赋值变量的值。引用不会更新为指向这个变量。下面是示例代码:

1
2
3
int x = 3, y = 4;
int& xRef = x;
xRef = y; // Changes value of x to 4. Doesn't make xRef refer to y.

将一个引用赋值给另一个引用会让第一个引用指向第二个引用所指的变量吗?

1
2
3
4
int x = 3, z = 5;
int& xRef = x;
int& zRef = z;
zRef = xRef; // Assigns values, not references

最后一行代码没有改变zRef,只是将z的值设置为3,因为xRef指向x,x的值是3。

在初始化引用之后无法改变引用所指的变量,而只能改变该变量的值。

指向指针的引用和指向引用的指针

可创建任何类型的引用,包括指针类型。

1
2
3
4
int* intP;
int*& ptrRef = intP;
ptrRef = new int;
*ptrRef = 5;

语义实际上很简单:ptrRef是一个指向intP的引用,intP是一个指向int值的指针。修改ptrRef会更改intP

注意,对引用取地址的结果与对被引用变量取地址的结果相同。例如:

1
2
3
int x = 3;
int* xPtr = &xRef; // Address of a reference is pointer to value
*xPtr = 100;

上述代码通过取x引用的地址,使xPtr指向x。将*xPtr赋值为100,x的值也变为100。比较表达式xPtr==xRef将无法编译,因为类型不匹配;

引用数据成员

如果不指向其他变量,引用就无法存在。因此,必须在构造函数初始化器(constructor initializer)中初始化引用数据成员,而不是在构造函数体内。下面列举一个简单示例:

1
2
3
4
5
class MyClass {
public:
MyClass(int& ref) : mRef(ref) { }
private:
int& mRef;

引用参数

引用经常用作函数或方法的参数。当使用引用参数时,函数将引用作为参数。如果引用被修改,最初的参数变量也会被修改。

1
2
3
4
5
void swap(int& first, int& second) {
int temp = first;
first = second;
second = temp;
}

就像无法用常量初始化普通引用变量一样, 不能将常量作为参数传递给“按非const引用传递”的函数:

使用“按const引用传递”或“按右值引用传递”,可将常量作为参数传递给函数。

将指针转换为引用

某个函数或方法需要以一个引用作为参数,而你拥有一个指向被传递值的指针,在此情况下,可对指针解除引用(dereferencing),将指针“转换”为引用。这一行为会给出指针所指的值,随后编译器用这个值初始化引用参数。例如:

1
2
3
int x = 5, y = 6;
int* xp = &x, *yp = &y;
swap(*xp, *yp);

按引用传递与按值传递

按引用传递不需要将参数的副本复制到函数,在有些情况下这会带来两方面的好处。

  1. 效率:复制较大的对象或结构需要较长时间。按引用传递只是把指向对象或结构的指针传递给函数。
  2. 正确性:并非所有对象都允许按值传递,即使允许按值传递的对象,也可能不支持正确的深度复制(deep copying)。第9章提到,为支持深度复制,动态分配内存的对象必须提供自定义复制构造函数或复制赋值运算符。

如果要利用这些好处,但不想修改原始对象,可将参数标记为const,从而实现按常量引用传递参数。

将引用作为返回值

还可让函数或方法返回一个引用。这样做的主要原因是为了提高效率。返回对象的引用而不是返回整个对象可避免不必要的复制。当然,只有涉及的对象在函数终止之后仍然存在的情况下才能使用。

如果变量的作用域局限于函数或方法(例如堆栈中自动分配的变量,在函数结束时会被销毁),绝不能返回这个变量的引用。

如果从函数返回的类型支持移动语义,按值返回就几乎与返回引用一样高效。

返回引用的另一个原因是希望将返回值直接赋为左值(lvalue)(赋值语句的左边)。一些重载的运算符通常会返回引用。

右值引用

右值(rvalue)就是非左值(Ivalue),例如常量值、临时对象或值。通常而言,右值位于赋值运算符的右侧:

1
2
3
4
// lvalue reference parameter
void handleMessage(std::string& message) {
cout << "handleMessage with lvalue reference: "<< message << endl;
}

对于这个handleMessage()版本,不能采用如下方式调用它:
1
2
3
4
handleMessage("Hello World"); // A literal is not an lvalue.
std::string a = "Hello ";
std::string b = "World";
handleMessage(a + b); // A temporary is not an 1value .

要支持此类调用,需要一个接收右值引用的版本:

1
2
3
4
// rvalue reference parameter
void handleMessage (std::string&& message ) {
cout << "handleMessage with rvalue reference: " << message << endl;
}

使用引用还是指针

引用比指针安全,不可能存在无效引用,也不需要显式地解除引用,因此不会遇到像指针那样的解除引用问题。

大多数情况下,应该使用引用而不是指针。对象的引用甚至可像指向对象的指针那样支持多态性。但也有一些情况要求使用指针,一个例子是更改指向的位置,因为无法改变引用所指的变量。例如,动态分配内存时,应该将结果存储在指针而不是引用中。

需要使用指针的另一种情况是可选参数,即指针参数可以定义为带默认值nullptr的可选参数,而引用参数不能这样定义。还有一种情况是要在容器中存储多态类型。

有一种方法可以判断使用指针还是引用作为参数和返回类型:考虑谁拥有内存。如果接收变量的代码负责释放相关对象的内存,那么必须使用指向对象的指针,最好是智能指针,这是传递拥有权的推荐方式。如果接收变量的代码不需要释放内存,那么应该使用引用。

考虑将一个int数组分割为两个数组的函数:一个是偶数数组;另一个是奇数数组。这个函数并不知道源数组中有多少奇数和偶数,因此只有在检测完源数组后,才能为目标数组动态分配内存,此外还需要返回这两个新数组的大小。因此总共需要返回4项:指向两个新数组的指针和两个新数组的大小。显然必须使用按引用传递,用规范的C语言方式编写的这个函数如下所示:

1
void separateOddsAndEvens(const int arr[], size_t size, int** odds, size_t* numOdds, int** evens, size_t* numEvens)

如果要调用separateOddsAndEvens(),就必须传递两个指针的地址,这样函数才能修改实际的指针,还必须传递两个int值的地址,这样函数才能修改实际的int值。另外注意,主调方负责删除由separateOddsAndEvens()创建的两个数组!

如果觉得这种语法很难理解(应该是这样的),可以用引用实现真正的按引用传递,如下所示:

1
void separateOddsAndEvens(const int arr[], size_t size, int*& odds, size_t& numOdds, int*& evens, size_t& numEvens)

在此情况下, adds和evens参数是指向int*的引用。separateOddsAndEvens()可以修改用作函数参数的int*(通过引用),而不需要显式地解除引用。使用这个版本的函数时,不再需要传递指针或int值的地址,引用参数会自动进行处理:

1
separateOddsAndEvens(unSplit, std::size(unSplit), oddNums,numOdds, evenNums, numEvens);

关键字的疑问

const 关键字

const是constant的缩写,指保持不变的量。任何尝试改变常量的行为都会被当作错误处理。此外,当启用优化时,编译器可利用此信息生成更好的代码。关键字const有两种相关的用法。可以用这个关键字标记变量或参数,也可以用其标记方法。

const 变量和参数

可使用const来“保护”变量不被修改。这个关键字的一个重要用法是替换#define来定义常量,这是const最直接的应用。例如,可以这样声明常量PI:

1
const double PI = 3.141592653589793238462;

可将任何变量标记为const,包括全局变量和类数据成员。

还可使用const指定函数或方法的参数保持不变。例如,下面的函数接收一个const参数。在函数体内,不能修改整数param。如果试图修改这个变量,编译器将生成错误。

1
void func (const int param)

下面详细讨论两种特殊的const变量或参数:const指针和const引用。

const指针

当变量通过指针包含一层或多层间接取值时,const的应用将变得十分微妙。考虑下面的代码行:

1
2
3
int* ip;
ip = new int[10];
ip[4] = 5;

为阻止修改所指的值,可在ip的声明中这样添加关键字const:

1
2
3
const int* ip;
ip = new int[10];
ip[4] = 5;

下面是在语义上等价的另一种方法,将const放在int的前面还是后面并不影响其功能。

1
2
int const* ip;
ip[4] = 5; // DOES NOT COMPILE!

如果要将ip本身标记为const(而不是ip所指的值),可以这样做:

1
2
3
int* const ip = nullptr;
ip = new int[10]; // DOES NOT COMPILE !
ip[4] = 5; // Error: dereferencing a null pointer

现在ip本身无法修改,编译器要求在声明ip时就执行初始化,可以使用前面代码中的nullptr,也可以使用新分配的内存,如下所示:

1
2
int* const ip = new int[10];
ip[4] = 5;

还可将指针和所指的值全部标记为const,如下所示:

1
2
int const* const ip = nullptr;
const int* const ip = nullptr;

尽管这些语法看上去有点混乱,但规则实际上非常简单:将const 关键字应用于直接位于它左边的任何内容。再次考虑这一行:

1
int const* const ip = nullptr;

从左到右,第一个const直接位于int的右边,因此将const应用到ip所指的int,从而指定无法修改ip所指的值。第二个const直接位于*的右边,因此将const应用于指向int变量的指针,也就是ip变量。因此,无法修改ip(指针)本身。

还有一种易于记忆的、用于指出复杂变量声明的规则:从右向左读。考虑示例int* const ip。从右向左读这条语句,就可以知道ip是一个指向int值的const指针。另外,int const* ip读作ip 是一个指向const int的指针

const引用

将const应用于引用通常比应用于指针更简单,原因有两个。首先,引用默认为const,无法改变引用所指的对象。因此,不必显式地将引用标记为const。其次,无法创建指向引用的引用,所以引用通常只有一层间接取值。获取多层间接取值的唯一方法是创建指向指针的引用。

因此,C++程序员提到“const 引用”时,含义如下所示:

1
2
3
int z;
const int& zRef = z;
zRef = 4; // DOES NOT COMPILE

由于将const应用到int,因此无法对zRef赋值,如前所示。与指针类似,const int& zRef等价于int const& zRef。然而要注意,将zRef标记为const对z没有影响。仍然可以修改z的值,具体做法是直接改变z,而不是通过引用。

const引用经常用作参数,这非常有用。如果为了提高效率,想按引用传递某个值,但不想修改这个值,可将其标记为const引用。例如:

1
2
void doSomething (const BigClass& arg) 
// Implementation here

将对象作为参数传递时,默认选择是const引用。只有在明确需要修改对象时,才能忽略const。

const方法

可将类方法标记为const,以禁止方法修改类的任何非可变(non-mutable)数据成员。

constexpr关键字

在某些情况下需要常量表达式。例如当定义数组时,数组的大小就必须是一个常量表达式。由于这一限制,下面的代码在C++中是无效的:

1
2
3
4
5
const int getArraySize() { return 32; }
int main() {
int myArray [getArraySize()]; // Invalid in C++
return 0 ;
}

可使用constexpr关键字重新定义getAraySize()函数,把它变成常量表达式。常量表达式在编译时计算

将函数声明为constexpr会对函数的行为施加一些限制, 因为编译器必须在编译期间对constexpr函数求值,函数也不允许有任何副作用。下面是几个限制:

  • 函数体不包含goto语句、try catch块、未初始化的变量、非字面量类型的变量定义,也不抛出异常,但可调用其他constexpr 函数。
    • “字面量类型”(literal type)是constexpr变量的类型,可从constexpr函数返回。
    • 字面量类型可以是void(可能有const、volatile限定符)、标量类型(整型和浮点类型、枚举类型、指针类型、成员指针类型,这些类型有const/volatile 限定符)、引用类型、字面量数组类型或类类型。
    • 类类型可能也有const、volatile限定符,具有普通的(即非用户提供的)析构函数,至少有一个constexpr构造函数,所有非静态数据成员和基类都是字面量类型。
  • 函数的返回类型应该是字面量类型。
  • 如果constexpr函数是类的一个成员,那么这个函数不能是虚函数。
  • 函数所有的参数都应该是字面量类型。
  • 在编译单元(ranslation unit)中定义了constexpr函数后,才能调用这个函数,因为编译器需要知道完整的定义。
  • 不允许使用dynamic_cast()reinterpret_cast()
  • 不允许使用new和delete表达式。

通过定义constexpr构造函数,可创建用户自定义类型的常量表达式变量。constexpr构造函数具有很多限制,其中的一些限制如下所示:

  • 类不能具有任何虚基类。
  • 构造函数的所有参数都应该是字面量类型。
  • 构造函数体不应该是function-try-block。
  • 构造函数体应该满足与constexpr函数体相同的要求,并显式设置为默认(=default)。
  • 所有数据成员都应该用常量表达式初始化。

static关键字

静态数据成员和方法

可声明类的静态数据成员和方法。静态数据成员不是对象的一部分,这个数据成员只有一个副本,这个副本存在于类的任何对象之外。静态方法与此类似,位于类层次(而不是对象层次)。静态方法不会在某个特定对象环境中执行。

静态链接(static linkage)

默认情况下,函数和全局变量都拥有外部链接。然而,可在声明的前面使用关键字static指定内部(或静态)链接。如果f()函数具有内部(静态)链接,另一个文件无法使用这个函数。如果在源文件中定义了静态方法但是没有使用它,有些编译器会给出警告(指出这些方法不应该是静态的,因为其他文件可能会用到它们)。

将static用于内部链接的另一种方式是使 用匿名名称空间(anonymous namespaces)。可将变量或函数封装到一个没有名字的名称空间,而不是使用static,如下所示:

1
2
3
4
5
6
7
#include <iostream>
namespace {
void f();
void f() {
std::cout << "f\n";
}
}

在同一源文件中,可在声明匿名名称空间之后的任何位置访问名称空间中的项,但不能在其他源文件中访问。这一语义与static关键字相同。

extern关键字

extern关键字将它后面的名称指定为外部链接。某些情况下可使用这种方法。例如,const和typedef在默认情况下是内部链接,可使用extern使其变为外部链接。然而,extern有一点复杂。当指定某个名称为extern时,编译器将这条语句当作声明而不是定义。对于变量而言,这意味着编译器不会为这个变量分配空间。必须为这个变量提供单独的、不使用extern关键字的定义行。例如,下面是AnotherFile.cpp的内容:

1
2
extern int x;
int x = 3;

也可在extern行初始化x,这一行既是声明又是定义:

1
extern int x = 3;

这种情形下的extern并不是非常有用,因为x默认具有外部链接。当另一个源文件FirstFile.cpp使用x时,才会真正用到extern:

1
2
3
4
5
#include <iostream>
extern int x;
int main() {
std::cout << x << std::endl;
}

FirstFile.cpp 使用了extern 声明,因此可使用x。编译器需要知道x的声明,才能在main()函数中使用这个变量。然而,如果声明x时未使用extern关键字,编译器会认为这是定义,因而会为x分配空间,导致链接步
骤失败(因为有两个全局作用域的x变量)。使用extern,就可在多个源文件中全局访问这个变量。

函数中的静态变量

C++中static关键字的最终目的是创建离开和进入作用域时都可保留值的局部变量。函数中的静态变量就像只能在函数内部访问的全局变量。静态变量最常见的用法是“记住”某个函数是否执行了特定的初始化操作。

非局部变量的初始化顺序

程序中所有的全局变量和类的静态数据成员都会在main()函数开始之前初始化。给定源文件中的变量以在源文件中出现的顺序初始化。例如,在下面的文件中,Demo::x一定会在y之前初始化:

1
2
3
4
5
6
class Demo {
public:
static int x;
};
int Demo::x = 3;
int y = 34;

然而,C++没有提供规范,用以说明在不同源文件中初始化非局部变量的顺序。如果在某个源文件中有一个全局变量x,在另一个源文件中有一个全局变量y,无法知道哪个变量先初始化。如果某个全局变量或静态变量依赖于另一个变量,这两个全局对象在不同的源文件中声明,就不能指望一个全局对象在另一个全局对象之前构建,也无法控制它们的初始化顺序。不同编译器可能有不同的初始化顺序,即使同一编译器的不同版本也可能如此。

不同源文件中非局部变量的初始化顺序是不确定的。

非局部变量的销毁顺序

非局部变量按初始化的逆序进行销毁。不同源文件中非局部变量的初始化顺序是不确定的,所以销毁顺序也是不确定的。

类型和类型转换

类型别名

类型别名为现有的类型声明提供了新名称。下面为int*类型声明指定新名称IntPtr:

1
using IntPtr = int*;

类型别名最常见的用法是当实际类型的声明过于笨拙时,提供易于管理的名称。标准库广泛使用类型别名来提供类型的简短名称。例如,std:string实际上就是这样一个类型别名:

1
using string = basic_string<char>;

函数指针的类型别名

在C++中,可使用函数的地址,就像使用变量那样。函数指针的类型取决于兼容函数的参数类型的返回类型。处理函数指针的一种方式是使用类型别名。类型别名允许将一个类型名指定给具有指定特征的一系列函数。 例如,下面的代码行定义了MatchFunction类型,该类型表示一个指针,这个指针指向具有两个int参数并返回布尔值的任何函数:

1
using MatchFunction = bool(*) (int, int);

有了这个新类型,可编写将MatchFunction作为参数的函数。

1
2
3
4
5
6
7
void findMatches(int values1[], int values2[], size_t numValues, MatchFunction matcher) {
for (size_t i = 0; i < numValues; i++) {
if (matcher (values1[i],values2[i]) {
// ......
}
}
}

使用函数指针,可根据matcher参数自定义单个findMatches()函数的功能。

如果不使用这些旧式的函数指针,还可以使用stl::function

方法和数据成员的指针的类型别名

在C++中,取得类成员和方法的地址,获得指向它们的指针是完全合法的。但不能访问非静态成员,也不能在没有对象的情况下调用非静态方法。类数据成员和方法完全依赖于对象的存在。因此,通过指针调用方法或访问数据成员时,一定要在对象的上下文中解除对指针的引用。

1
2
3
Employee employee;
int (Employee::*methodPtr) () const = &Employee::getSalary;
cout << (employee.*methodPtr) () << endl;

不必担心上述语法。第二行声明了一个指针类型的变量methodPtr,该指针指向Employee类的一个非静态const方法,这个方法不接收参数并返回一个int值。同时,这行代码将这个变量初始化为指向Employee类的getSalary()方法。这种语法和声明简单函数指针的语法非常类似,只不过在*methodPtr的前面添加了Employee::。还要注意,在这种情况下需要使用&。

第3行代码调用employee对象的getSalary()方法(通过methodPtr指针)。注意在employlee.*methodPtr的周围使用了括号。

可通过类型别名简化第二行代码:

1
2
3
4
Employee employee;
using PtrToGet = int (Employee::*) () const;
PtrToGet methodPtr = &Employee::getSalary;
cout << (employee.*methodPtr) () << endl;

使用auto可进一步简化:

1
2
3
Employee employee;
auto methodPtr = &Employee::getSalary;
cout << (employee.*methodPtr)() << endl;

方法和数据成员的指针通常不会出现在程序中。然而,要记住,不能在没有对象的情况下解除对非静态方法或数据成员的指针的引用。C++允许在没有对象的情况下解除对静态方法或静态数据成员的指针的引用。

typedef

与类型别名一样,typedef为已有的类型声明提供新名称。例如,使用以下类型别名:

1
using IntPtr = int*;

如果不使用类型别名,就必须使用如下typedef:

1
typedef int* IntPtr;

在引入类型别名之前,必须为函数指针使用typedef,这更复杂。例如,对于以下类型别名:

1
using FunctionType = int (*) (char, double);

如果用typedef定义相同的FunctionType, 形式将如下:

1
typedef int (*FunctionType) (char, double);

类型别名和typedef并非完全等效。与typedef相比,类型别名与模板一起使用时功能更强大。

类型转换

C++还提供了4种类型转换:const_cast()static_cast()reinterpret_cast()dynamic_cast()。使用()的C风格类型转换在C++中仍然有效。

const_cast()

const_cast()最直接,可用于给变量添加常量特性,或去掉变量的常量特性。这是上述4种类型转换中唯可舍弃常量特性的类型转换。当然从理论上讲,并不需要const类型转换。如果某个变量是const,那么应该一直是const。然而实际中,有时某个函数需要采用const变量,但必须将这个变量传递给采用非const变量作为参数的函数。因此,有时需要舍弃变量的常量特性,但只有在确保调用的函数不修改对象的情况下才能这么做,否则就只能重新构建程序。下面是一个示例:

1
2
3
extern void ThirdPartyLibraryMethod(char* str);
void f(const char* str)
ThirdPartyLibraryMethod(const_cast<char*>(str));

从C++17开始,<utility>中定义了一个辅助方法std:as_const(),该方法返回引用参数的const引用版本。as_const(obj)基本上等同于const_cast<const T&>(obj),其中,T的类型为obj。可以看到,与使用const_cast()相比,使用as_const()更简短。示例如下:

1
2
std::string str = "C++";
const std::string& constStr = std::as_const(str);

as_const()auto一起使用时要保持警惕。auto将去除引用和const限定符!因此,下面的result变量具有类型std::string而非const std:string&

1
auto result = std::as_const(str);

static_cast()

可使用static_cast()显式地执行C++语言直接支持的转换。例如,如果编写了一个算术表达式,其中需要将int转换为double以避免整除,可以使用static_cast()

如果用户定义了相关的构造函数或转换例程,也可使用static_cast()执行显式转换。例如,如果类A的构造函数将类B的对象作为参数,就可使用static_cast()将B对象转换为A对象。许多情况下都需要这一行为,然而编译器会自动执行这个转换。

static_cast()的另一种用法是在继承层次结构中执行向下转换。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Base {
public:
virtual ~Base() = default;
};

class Derived : public Base {
public:
virtual ~Derived() = default;
};

int main() {
Base* b;
Derived* d = new Derived();
b = d; // Don't need a cast to go up the inheritance hierarchy
d = static_cast<Derived*>(b); // Need a cast to go down the hierarchy

Base base;
Derived derived;
Base& br = derived;
Derived& dr = static_cast<Derived&> (br) ;
return 0;
}

这种类型转换可以用于指针和引用,而不适用于对象本身。

注意static_cast()类型转换不执行运行期间的类型检测。它允许将任何Base指针转换为Derived指针,或将Base引用转换为Derived引用,哪怕在运行时Base对象实际上并不是Derived对象,也是如此。例如,下面的代码可以编译并执行,但使用指针d可能导致灾难性结果,包括内存重写超出对象的边界。

1
2
Base* b = new Base() ;
Derived* d = static_cast<Derived*>(b);

使用static_cast()无法将某种类型的指针转换为不相关的其他类型的指针。如果没有可用的转换构造函数,static_cast()无法将某种类型的对象直接转换为另一种类型的对象。

reinterpret_cast()

reinterpret_cast()的功能比static_cast()更强大,同时安全性更差。这种用法经常用于将指针转换为void*;这可隐式完成,不需要进行显式转换。但将void*转换为正确类型的指针需要reinterpret_cast()void*指针指向内存的某个位置。void*指针没有相关的类型信息。下面是一些示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class X {};
class Y {};

int main() {
X x;
Y y;
X* xp = &x;
Y* yp = &y;
// Need reinterpret cast for pointer conversion from unrelated classes
// static_cast doesn't work.
xp = reinterpret_cast<X*>(yp);
// No cast required for conversion from pointer to void*
void* p = xp;
// Need reinterpret cast for pointer conversion from void*
xp = reinterpret_cast<X*>(p) ;
// Need reinterpret cast for reference conversion from unrelated classes
// static_cast doesn't work.
X& xr = x;
Y& yr = reinterpret_cast<Y&>(x);
return 0;
}

reinterpret_cast()的一种用法是与普通可复制类型的二进制I/O起使用。所谓普通可复制类型,是指构成对象的基础字节的类型可复制到数组中。如果此后要将数组的数据复制回对象,对象将保持其原始值。例如,可将这种类型的单独字节写入文件中。将文件读入内存时,可使用reinterpret_cast()来正确地解释从文件读入的字节。

dynamic_cast()

dynamic_cast()为继承层次结构内的类型转换提供运行时检测。可用它转换指针或引用。dynamic_cast()在运行时检测底层对象的类型信息。如果类型转换没有意义,dynamic_cast()将返回一个空指针(用于指针)或抛出一个stl::bad_cast异常(用于引用)。例如,下面用于引用的dynamic_cast()将拋出一个异常:

1
2
3
4
5
6
7
8
Base base;
Derived derived;
Base& br = base;
try {
Derived& dr = dynamic_cast<Derived&>(br);
} catch (const bad_cast&) {
cout << "Bad cast!" << endl;
}

注意可使用static_cast()reinterpret_cast()沿着继承层次结构向下执行同样的类型转换。dynamic_cast()的不同之处在于它会执行运行时(动态)类型检测,而static_cast()reinterpret_cast()甚至会执行不正确的类型转换。因此,为使用dynamic_cast(),类至少要有一个虚方法。如果类不具有虚表,尝试使用dynamic_cast()将导致编译错误。

类型转换总结

表11-1总结了不同情形下应该使用的类型转换。

作用域解析

可使用名称空间、函数定义、花括号界定的块和类定义创建作用域。在一个for循环的初始化语句中,初始化的变量的作用域仅限于这个for循环,在这个for循环之外不可见。当试图访问某个变量、函数或类时,首先在最近的作用域内查找这个名称,然后查找相邻的作用域,以此类推,直到全局作用域。任何不在名称空间、函数、花括号界定的块和类中的名称都被认为在全局作用域内。如果在全局作用域内也找不到这个名称,编译器会给出未定义符号错误。

有时某个作用域内的名称会隐藏其他作用域内的同一名称。在另一些情况下,程序的特定行中的默认作用域解析并不包含需要的作用域。如果不想用默认的作用域解析某个名称,就可以使用作用域解析运算符::和特定的作用域限定这个名称。例如,为访问类的静态方法,第一种方法是将类名(方法的作用域)和作用域解析运算符放在方法名的前面,第二种方法是通过类的对象访问这个静态方法。下例演示了这两种方法。这个示例定义了一个具有静态方法get()的Demo类、一个具有全局作用域的get()函数以及一个位于NS名称空间的get()函数。

1
2
3
4
5
6
7
8
9
10
class Demo {
public:
static int get() { return 5; }
};

int get() { return 10; }

namespace NS {
int get() { return 20; }
}

全局作用域没有名称,但可使用作用域解析运算符本身(没有名称前缀)来访问。可采用以下方式调用不同的get()函数。在这个示例中,代码本身在main()函数中,main()函数总是位于全局作用域内:

1
2
3
4
5
6
7
8
9
10
11
int main() {
auto pd = std::make_unique<Demo>();
Demo d;
std::cout << pd->get() << std::endl; // prints 5
std::cout << d.get() << std::endl; // prints 5
std::cout << NS::get() << std::endl; // prints 20
std::cout << Demo::get() << std::endl; // prints 5
std::cout << ::get() << std::endl; // prints 10
std::cout << get() << std::endl; // prints 10
return 0;
}

注意,如果NS名称空间是一个匿名名称空间,下面的行将导致名称解析歧义错误,因为在全局作用域内定义了一个get()函数,在匿名名称空间中也定义了一个get()函数。

1
std::cout << get() << std::endl;

如果在main()函数之前使用using子句,也会发生同样的错误:

1
using namespace NS;

特性

特性(ttribute)是在源代码中添加可选信息(或者供应商指定的信息)的一种机制。 在C++11之前,供应商决定如何指定这些信息,例如__atribute____declspec等。自C++11以后,使用两个方括号语法[[attribute]]支持特性。C++标准只定义了6个标准特性。

[[noreturn]]特性

[[noreturm]]意味着函数永远不会将控制交还调用点。典型情况是函数导致某种终止(进程终止或线程终止)或者抛出异常。使用该特性,编译器可避免给出某种警告或错误,因为它现在对函数的意图了解更多。下面是
一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[[noreturn]] void forceProgramTermination() {
std::exit(1);
}

bool isDongleAvailable () {
bool isAvailable = false;
// Check whether a licensing dongle is available...
return isAvailable;
}

bool isFeatureLicensed(int featureId) {
if (!isDongleAvailable())
// No licensing dongle found, abort program execution!
forceProgramTermination() ;
} else
bool isLicensed = false;
// Dongle available, perform license check of the given feature...
}
return isLicensed;
}

这个代码片段可正常编译,不会发出任何警告或错误。但如果删除[[noretum]]特性,编译器将生成以下警告消息:

1
warning C4715: ' isFeatureLicensed': not all control paths return a value

[[deprecated]]特性

[[deprecated]]特性可用于把某个对象标记为废弃,表示仍可以使用,但不鼓励使用。这个特性接收一个可选参数,可用于解释废弃的原因,例如:

1
[[deprecated ("Unsafe method, please use xyz")]] void func();

如果使用这个特性,将看到编译错误或警告。例如,GCC会给出以下警告消息:

1
warning: 'void func()' is deprecated: Unsafe method, please use xyz

[[allthrough]]特性

从C++17开始,可使用[[allthrough]]特性告诉编译器:在switch语句中,fall through是有意安排的。如果没有指定该特性,用以说明这是有意为之的,编译器将给出警告消息。不需要为空的case分支指定这个特性。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
switch (backgroundColor) {
case Color::DarkBlue:
doSomethingForDarBlue();
[[fallthrough]];
case Color::Black:
// Code is executed for both a dar blue or black background color
doSomethingForBlackOrDarkBlue();
break;
case Color::Red:
case Color::Green:
// Code to execute for a red or green background color
break;
}

[[nodiscard]]特性

[[nodiscard]]特性可用于返回值的函数,如果函数什么也没做,还返回值,编译器将发出警告消息。下面是一个示例:

1
2
3
4
5
6
7
8
[[nodiscard]] int func() {
return 42;
}

int main() {
func();
return 0;
}

编译器将给出如下警告消息:

1
warning C4834: discarding return value of function with 'nodiscard' attribute

例如,可将这个特性用于返回错误代码的函数。通过给此类函数添加[[nodiscard]]特性,将无法忽略错误代码。

[[maybe_unused]]特性

如果未使用某项,[[maybe_unused]]特性可用于阻止编译器发出警告消息:

1
2
3
int func (int param1, [[maybe_unused]] int param2) {
return 42;
}

这里给第二个参数标记了[[maybe__unused]]特性。编译器将只为param1显示警告消息:

1
warning C4100: 'param1': unreferenced formal parameter

用户定义的字面量

C++有许多可在代码中使用的标准字面量(iteral),如下所示。

  • ‘a’: 字符
  • “character array”:以0结尾的字符数组(C风格的字符串)
  • 3.14f: 浮点数
  • 0xabc: 十六进制值

C++11允许定义自己的字面量。用户定义的字面量应该以下划线开头,下划线之后的第一个字符必须小写,例如_i_s_kmmiles等。可通过编写字面量运算符(literal operators)来实现。 字面量运算符能以生(raw)模式或熟(cooked)模式运行。在生模式中,字面量运算符接收一个字符序列;在熟模式中,字面量运算符接收一种经过解释的特定类型。例如,考虑C++字面量123。生模式字面量运算符会将其作为字符123,而熟模式字面量运算符会将其作为整数123

另一个示例:考虑C++字面量0x23。生模式字面量运算符将接收字符0x23,而熟模式字面量运算符将接收整数35

最后一个示例: 考虑C++字面量3.14,生模式字面量运算符将接收字符3.14,而熟模式字面量运算符将接收浮点值3.14

熟模式字面量运算符应该具有:

  • 一个unsigned long long、long double、char、wchar_t、char16_t或char32_t类型的参数,用来处理数值。
  • 或者两个参数,第一个参数是字符数组,第二个参数是字符数组的长度,用来处理字符串,例如const char* strsize_t len

下例使用熟模式字面量运算符实现了用户定义的字面量_i_i用来定义一个复数字面量。

1
2
3
std::complex<long double> operator"" _i (long double d) {
return std::complex<long double>(0, d) ;
}

_i字面量可这样使用:

1
2
3
std::complex<long double> c1 = 9.634_i;
auto c2 = 1.23_i;
// c2 has as type std::complex<long double>

另一个示例用熟模式字面量运算符实现了用户定义的字面量s,用于定义std::string字面量:

1
2
3
std::string operator""_s(const char* str, size_t len) {
return std::string(str, len);
}

这一字面量可这样使用:

1
2
3
std::string str1 = "Hello World"_s;
auto str2 = "Hello World"_s;
// str2 has as type std::string

如果没有_s字面量,自动推导的类型将是const char*
1
2
auto str3 = "Hello World";
// str has as type const char*

生模式字面量运算符需要一个const char*类型的参数,这是一个以0结尾的C风格字符串。下面的示例定义了字面量_i,此时使用的是生模式字面量运算符:

1
2
3
std::complex<long double> operator""_i (const char* p)
// Implementation omitted; it requires parsing the C-style
// string and converting it to a complex number

生模式字面量运算符的用法与熟模式字面量运算符的用法相同。

标准的用户定义字面量

C++定义了如下标准的用户定义字面量。注意,这些标准的用户定义字面量并非以下画线开头:

  • “s”用于创建std::string
    • 例如: auto myString = "Hello World"s;
    • 需要using namespace std::string_literals;
  • “sv”用于创建std::string_views
    • 例如:auto myStringView = "Hello world"sv;
    • 需要using namespace std::string_view_literals;
  • “h” “min” “s” “ms” “us” “ns”用于创建stl::chrono::duration时间段
    • 例如:auto myDuration = 42min;
    • 需要using namespace std::chrono_literals;
  • “i”、“il”、“if”分别用于创建复数complex<double>complex<long double>complex<float>
    • 例如:auto myComplexNumber = 1.3i;
    • 需要using namespace std::complex_literals;

头文件

头文件是为子系统或代码段提供抽象接口的一种机制。使用头文件需要注意的一点是:要避免循环引用或多次包含同一个头文件。可使用文件保护机制(include guards)来避免重复定义。在每个头文件的开头,用#ifndef指令检测是否还没有定义某个键值。如果这个键值已经定义,编译器
将跳到对应的#endif,这个指令通常位于文件的结尾。

1
2
3
4
5
6
#ifndef LOGGER_H
#define LOGGER_H
class Logger {
// ...
};
#endif // LOGGER_H

如今,几乎所有编译器都支持#pragma once指令(该指令可替代前面的文件保护机制)。例如:

1
2
3
#pragma once
class Logger {
};

前置声明是另一个避免产生头文件问题的工具。如果需要使用某个类,但是无法包含它的头文件,就可告诉编译器存在这么一个类,但是无法使用#include机制提供正式的定义,可在代码中使用这个类的指针或引用。也可声明函数,使其按值返回这种前置声明类,或将这种前置声明类作为按值传递的函数参数。当然,定义函数的代码以及调用函数的任何代码都需要添加正确的头文件,在头文件中要正确定义前置声明类。

建议尽可能在头文件中使用前置声明,而不是包含其他头文件。这可减少编译和重编译时间,因为破坏了一个头文件对其他头文件的依赖。当然,实现文件需要包含前置声明类的正确头文件,否则就不能编译。

为了查询是否存在某个头文件,C++17添加了__has_include("flename")__has_include(<filename>)预处理器常量。如果头文件存在,这些常量的结果就是1;如果头文件不存在,常量的结果就是0。

1
2
3
4
5
#if __has_include(<optional>)
#include <optional>
#elif __has_include(<experimental/optional>)
#include <experimental/optional>
#endif

C的实用工具

变长参数列表

C/C++可以编写参数数目可变的自定义函数。例如,假定要编写一个快速调试函数,这个函数应能接收任意数目和类型的参数并输出字符串。

1
2
3
4
5
6
7
8
#include <cstdio>
#include <cstdarg>
void debugOut(const char* str, ...) {
va_list ap
va_start(ap, str) ;
vfprintf(stderr, str, ap);
va_end(ap);
}

首先,注意debugOut()函数的原型包含一个具有类型和名称的参数str,之后...代表任意数目和类型的参数。声明一个va_list类型的变量,并调用va_start()来初始化它。va_start()的第二个参数必须是参数列表中最右边的已命名变量。所有具有变长参数列表的函数都至少应该有一个已命名参数。当vfprintf()返回时,debugOut()调用va_end()来终止对变长参数列表的访问。在调用va_start()之后必须调用va_end(),以确保函数结束后,堆栈处于稳定状态。

访问参数

如果要自行访问实参,可使用va_arg();它的第一个实参是va_list,接收要截获的实参类型。遗憾的是,如果不提供显式的方法,就无法知道参数列表的结尾是什么。例如,可以让第一个参数计算参数的数目,或者当参数是一组指针时,可以要求最后一个指针是nullptr。

下例演示了这种技术,其中调用者在第一个已命名参数中指定了所提供参数的数目。函数接收任意数目的int参数,并将其输出。

1
2
3
4
5
6
7
8
9
10
void printInts(size_t num, ...) {
int temp;
va_list ap
va_start(ap, num) ;
for (size_t i = 0; i < num; ++i) {
temp = va_arg(ap, int);
}
va_end(ap);
cout << endl;
}

访问C风格的变长参数列表并不十分安全,这种方法存在以下风险:

  • 不知道参数的数目。
  • 不知道参数的类型。va_arg()接收一种类型,用来解释当前的值。然而,可让va_arg()将这个值解释为任意类型,无法验证正确的类型。

预处理器宏

可使用C++预处理器编写宏,这与函数有点相似。下面是一个示例:

1
2
#define SQUARE(x) ((x) * (x)) // No semicolon after the macro definition!
int main() {cout << SQUARE(5) << endl;}

宏是C遗留下来的特性,非常类似于内联函数,但不执行类型检测。在调用宏时,预处理器会自动用扩展式替换。预处理器并不会真正地应用函数调用语义,这一行为可能导致无法预测的结果。

宏还会影响性能,假定按如下方式调用SQUARE宏:

1
cout << SQUARE (veryExpensiveFunctionCallToComputeNumber()) << endl;

预处理器把它替换为:

1
cout << ( (veryExpensiveFunctionCallToComputeNumber()) * (veryExpensiveFunctionCallToComputeNumber())) << endl;

现在,这个开销很大的函数调用了两次。这是避免使用宏的另一个原因。

宏还会导致调试问题,因为编写的代码并非编译器看到的代码或者调试工具中显示的代码(因为预处理器的查找和替换功能)。为此,应该全部用内联函数替代宏。

利用模板编写泛型代码

类模板

类模板定义了一个类,其中,将一些变量的类型、方法的返回类型和或方法的参数类型指定为参数。

编写类模板

最好编写一个通用的Grid类,该类可用于存储多种类型,编写类模板可避免编写需要指定一种或多种类型的类。客户通过指定要使用的类型对模板进行实例化。这称为泛型编程,其最大的优点是类型安全。

下例展示了如何得到模板化的Grid类。这里选用不带多态性的值语义来实现这个解决方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T>
class Grid {
public:
explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator.
Grid(const Grid& src) = default;
Grid<T>& operator=(const Grid& rhs) = default;
// Explicitly default a move constructor and assignment operator.
Grid(Grid&& src) = default;
Grid<T>& operator=(Grid&& rhs) = default;

std::optional<T>& at(size_t x,size_t y) ;
const std::optional<T>& at(size_t x,size_t y) const;
size_t getHeight() const { return mHeight; }
size_t getWidth() const { return mWidth; }
static const size_t kDefaultWidth = 10;
static const size_t kDefaultHeight = 10;
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::optional<T>>> mCells;
size_t mWidth, mHeight;
};

template <typename T>:第一行表示,下面的类定义是基于模板。templatetypename都是C++中的关键字。在模板中使用模板参数名称(例如T)表示调用者要指定的类型。基于历史原因,指定模板类型参数时,可用关键字class替代typename

在Grid类中,mCells是可选值的矢量的矢量,所以编译器生成的复制构造函数和赋值运算符可以运行得很好。一旦有了用户声明的析构函数,建议不要使用编译器隐式生成复制构造函数或赋值运算符,因此Grid类模板将其显式设置为默认,并且将移动构造函数和赋值运算符显式设置为默认。下面将复制赋值运算符显式设置为默认:

1
Grid<T>& operator=(const Grid& rhs) = default;

从中可以看出,rths参数的类型是const Grid&,还可将其指定为const Grid<T>&。在类定义中,编译器根据需要将Grid解释为Grid<T>。但在类定义之外,需要使用Grid<T>。在编写类模板时,以前的类名(Grid)现在实际上是模板名称。at()方法现在返回optional<T>&const optional<T>&,而不是返回unique_ptr

1
2
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;

Grid类的方法定义

template <typename T>访问说明符必须在Grid模板的每一个方法定义的前面。构造函数如下所示:

1
2
3
4
5
6
template <typename T>
Grid<T>::Grid(size_t width, size_t height) : mWidth(width), mHeight(height) {
mCells.resize(mWidth);
for (auto& column : mCells)
column.resize(mHeight);
}

模板要求将方法的实现也放在头文件中,因为编译器在创建模板的实例之前,需要知道完整的定义,包括方法的定义。

注意::之前的类名是Grid<T>。必须在所有的方法和静态数据成员定义中将Grid<T>指定为类名:

1
2
3
4
5
template <typename T>
void Grid<T>::verifyCoordinate(size_t x, size_t y) const {
if (x >= mWidth || y >= mHeight) {
throw std::out_of_range("");
}

如果类模板方法的实现需要特定模板类型参数(例如T)的默认值,可使用T()语法。如果T是类类型,T()调用对象的默认构造函数,或者如果T是简单类型,则生成0。这称为“初始化为0”语法。最好为类型尚不确定的变量提供合理的默认值。

使用Grid模板

创建网格对象时,不能单独使用Grid作为类型;必须指定这个网格保存的元素类型。为某种类型创建一个模板类对象的过程称为模板的实例化

1
2
3
4
5
6
7
8
9
Grid<int> myIntGrid; // declares a grid that stores ints,
Grid<double> myDoubleGrid(11, 11); // declares an 11x11 Grid of doubles
myIntGrid.at(0, 0) = 10;
int x = myIntGrid.at(0, 0).value_or(0);

Grid<int> grid2 (myIntGrid); // Copy constructor
Grid<int> anotherIntGrid;
anotherIntGrid = grid2;
// Assignment operator

这里使用了value_or()at()方法返回stl::optional引用。optional可包含值,也可不包含值。如果optional包含值,value_or()方法返回这个值;否则返回给value_or()提供的实参。

可在堆上动态分配Grid模板实例:

1
2
3
auto myGridOnHeap = make_unique<Grid<int>>(2, 2); // 2x2 Grid on the heap
myGridOnHeap->at(0, 0) = 10;
int x = myGridOnHeap->at(0, 0).value_or(0);

尖括号

本书的一些示例使用带双尖括号的模板,例如:

1
std::vector<std::vector<T>> mCells;

自C++11以来,上述语法都是正确的。但在C++11之前,双尖括号>>只表示>>运算符。根据所涉及的类型,这个>>运算符可以是右移位运算符或流提取运算符。这与模板代码相左,因为必须在双尖括号之间放置一个空格。前面的声明可以写为:

1
std::vector<std::vector<T> > mCells;

编译器处理模板的原理

编译器遇到模板方法定义时,会进行语法检查,但是并不编译模板。编译器无法编译模板定义,因为它不知道要使用什么类型。编译器遇到一个实例化的模板时,例如Grid<int> myIntGrid,就会将模板类定义中的每一个T替换为int,从而生成Grid模板的int版本代码。编译器生成代码的方式就好像语言不支持模板时程序员编写代码的方式:为每种元素类型编写一个不同的类。

选择性实例化

编译器总为泛型类的所有虚方法生成代码。但对于非虚方法,编译器只会为那些实际为某种类型调用的非虚方法生成代码。例如,给定前面定义的Grid模板类,假设在main()中编写这段代码:

1
2
Grid<int> myIntGrid;
myIntGrid.at(0, 0) = 10;

编译器只会为int版本的Grid类生成无参构造函数、析构函数和非常量at()方法的代码,不会为其他方法生成代码,例如复制构造函数、赋值运算符或getHeight()

模板对类型的要求

编写与类型无关的代码时,肯定对这些类型有一些假设。如果在程序中试图用一种不支持模板使用的所有操作的类型对模板进行实例化,那么这段代码无法编译,而且错误消息几乎总是晦涩难懂。然而,就算要使用的类型不支持所有模板代码所需的操作,也仍然可以利用选择性实例化使用某些方法,而避免使用另一些方法。

将模板代码分布在多个文件中

在任何使用了模板的源代码文件中,编译器都应该能同时访问模板类定义和方法定义。

将模板定义放在头文件中

方法定义可与类定义直接放在同一个头文件中。当使用了这个模板的源文件通过include包含这个文件时,编译器就能访问需要的所有代码。该机制用于前面的Grid实现。此外,还可将模板方法定义放在另一个头文件中,然后在类定义的头文件中通过#include包含这个头文件。一定要保证方法定义的#include在类定义之后,否则代码无法编译。例如:

1
2
3
4
template <typename T>
class Grid { };

#include "GridDefinitions.h"

任何需要使用Grid模板的客户只需要包含Grid.h头文件即可。这种分离方式有助于分开类定义和方法定义。

将模板定义放在源文件中

将方法实现放在头文件中看上去很奇怪。如果不喜欢这种语法,可将方法定义放在一个源代码文件中。然而,仍然需要让使用模板的代码能访问到定义,因此可在模板类定义头文件中通过#include包含类方法实现的源文件。尽管如果之前没有看过这种方式,会感到有点奇怪,但是这在C++中是合法的。头文件如下所示:

1
2
3
4
5
template <typename T>
class Grid {
};
// Class definition omitted for brevity
#include "Grid.cpp"

使用这种技术时,一定不要把Grid.cpp文件添加到项目中,因为这个文件本不应在项目中,而且无法单独编译;这个文件只能通过#include包含在一个头文件中。

实际上,可任意命名包含方法实现的文件。有些程序员喜欢给包含的源代码文件添加.inl后缀,例如Grid.inl。

限制模板类的实例化

如果希望模板类仅用于某些已知的类型,就可使用下面的技术。

1
2
3
4
5
6
7
8
#include "Grid.h"
#include <utility>
template <typename T>
Grid<T>::Grid(size_t width, size_t height) : mWidth(width), mHeight (height) {
mCells.resize(mWidth);
for (auto& column : mCells)
column.resize(mHeight);
}

为使这个方法能运行,需要给允许客户使用的类型显式实例化模板。这个文件的末尾应如下所示:

1
2
3
4
// Explicit instantiations for the types you want to allow.
template class Grid<int>;
template class Grid<double>;
template class Grid<std::vector<int>>;

有了这些显式的实例化,就不允许客户代码给其他类型使用Grid类模板。

使用显式类模板实例化,无论是否调用方法,编译器都会为类模板的所有方法生成代码。

模板参数

template <typename T>这个参数列表类似于函数或方法中的参数列表。与函数或方法一样,可使用任意多个模板参数来编写类。此外,这些参数未必是类型,而且可以有默认值。

非类型的模板参数

非类型的模板参数只能是整数类型(char、int、 long 等)、枚举类型、指针、引用和std:nullptr_t。从C++17开始,可指定autoauto&auto*等作为非类型模板参数的类型。此时,编译器会自动推导类型。在模板列表中指定非类型参数而不是在构造函数中指定的主要好处是:在编译代码之前就知道这些参数的值了。下面是新的类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T, size_t WIDTH, size_t HEIGHT>
class Grid {
public:
Grid() = default;
virtual ~Grid() = default;
// Explicitly default a copy constructor and assignment operator.
Grid(const Grid& src) = default;
Grid<T, WIDTH, HEIGHT>& operator= (const Grid& rhs) = default;
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at (size_t x, size_t y) const;
size_t getHeight() const { return HEIGHT; }
size_t getwidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t X, size_t y) const;
std::optional<T> mCells [WIDTH] [HEIGHT];
};

这个类没有显式地将移动构造函数和移动赋值运算符设置为默认,原因是C风格的数组不支持移动语义。注意,模板参数列表需要3个参数:网格中保存的对象类型以及网格的宽度和高度。宽度和高度用于创建保存对象的二维数组。下面是类方法定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T, size_t WIDTH, size_t HEIGHT>
void Grid<T, WIDTH, HEIGHT>::verifyCoordinate(size_t x, size_t y) const {
if (x >= WIDTH || y >= HEIGHT) {
throw std::out_of_range ("") ;
}
}

template <typename T, size_t WIDTH, size_t HEIGHT>
const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) const {
verifyCoordinate(x, y);
return mCells[x] [y];
}

template <typename T, size_t WIDTH, size_t HEIGHT>
std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) {
return const_cast<std::optional<T>&> (std::as_const(*this).at(x, y));
}

注意之前所有指定Grid<T>的地方,现在都必须指定Grid<T, WIDTH, HEIGHT>来表示这3个模板参数。可通过以下方式实例化这个模板:

1
2
3
4
5
Grid<int, 10, 10> myGrid;
Grid<int, 10, 10> anotherGrid;
myGrid.at(2, 3) = 42;
anotherGrid = myGrid;
cout << anotherGrid.at(2, 3).value_or(0);

这段代码看上去很棒。遗憾的是,实际中的限制比想象中的要多。首先,不能通过非常量的整数指定高度或宽度。下面的代码无法编译:

1
2
size_t height = 10;
Grid<int, 10, height> testGrid; // DOES NOT COMPILE

然而,如果把height声明为const,这段代码就可以编译了:
1
2
const size_t height = 10;
Grid<int, 10, height> testGrid; // Compiles and works

带有正确返回类型的constexpr函数也可以编译。例如,如果有一个返回size_t的constexpr函数,就可以使用它初始化height模板参数:

1
2
constexpr size_t getHeight() { return 10; }
Grid<double2getHeight()> myDoubleGrid;

另一个限制可能更明显。既然宽度和高度都是模板参数,那么它们也是每种网格类型的一部分。这意味着Grid<int, 10, 10>Grid<int, 10, 11>是两种不同类型。不能将一种类型的对象赋给另一种类型的对象,而且一种类型的变量不能传递给接收另一种类型的变量的函数或方法。

非类型模板参数是实例化的对象的类型规范中的一部分。

类型参数的默认值

如果继续采用将高度和宽度作为模板参数的方式,就可能需要为高度和宽度(它们是非类型模板参数)提供默认值,就像之前Grid<T>类的构造函数一样。C++允许使用类似的语法向模板参数提供默认值。在这里也可
以给T类型参数提供默认值。下面是类定义:

1
2
3
4
template <typename T = int, size_t WIDTH = 10, size_t HEIGHT = 10>
class Grid
// Remainder is identical to the previous version .
};

不需要在方法定义的模板规范中指定T、WIDTH和HEIGHT的默认值。例如,下面是at()方法的实现:

1
2
3
4
5
template <typename T, size_t WIDTH, size_t HEIGHT>
const std::optional<T>& Grid<T, WIDTH, HEIGHT>::at(size_t x, size_t y) const {
verifyCoordinate(x, y);
return mCells[x][y];
}

现在,实例化Grid时,可不指定模板参数,只指定元素类型,或者指定元素类型和宽度,或者指定元素类型、宽度和高度:

1
2
3
4
Grid<> myIntGrid;
Grid<int> myGrid;
Grid<int, 5> anotherGrid;
Grid<int, 5, 5> aFourthGrid;

构造函数的模板参数推导

C++17添加了一些功能,支持通过传递给类模板构造函数的实参自动推导模板参数。在C++17之前,必须显式地为类模板指定所有模板参数。函数模板始终支持基于传递给函数模板的实参自动推导模板参数。因此,make_pair()能根据传递给它的值自动推导模板类型参数。例如,编译器为以下调用推导pair<int, double>

1
auto pair2 = std::make_pair(1, 2.3);

在C++17中,不再需要这样的辅助函数模板。现在,编译器可以根据传递给构造函数的实参自动推导模板类型参数。对于pair类模板,只需要编写以下代码:

1
std::pair pair3(1, 2.3);

当然,推导的前提是类模板的所有模板参数要么有默认值,要么用作构造函数中的参数。

用户定义的推导原则

也可编写自己的推导原则,即用户定义的推导原则。这允许你编写如何推导模板参数的规则。这是一个高级主题,这里不对其进行详细讨论,但会举一个例子来演示其功能。假设具有以下SpreadsheetCell类模板:

1
2
3
4
5
6
7
8
template<typename T>
class SpreadsheetCell {
public:
SpreadsheetCell (const T& t) : mContent(t) { }
const T& getContent() const { return mContent; }
private:
T mContent;
};

通过自动推导模板参数,可使用std::string类型创建SpreadsheetCell:

1
2
std::string myString = "Hello world!";
SpreadsheetCell cell (myString) ;

但是,如果给SpreadsheetCell构造函数传递const char*,那么会将类型T推导为const char*,这不是需要的结果。可创建以下用户定义的推导原则,在将const char*作为实参传递给构造函数时,将T推导为std::string

1
SpreadsheetCell (const char*) -> SpreadsheetCell<std::string>;

方法模板

C++允许模板化类中的单个方法。这些方法可以在类模板中,也可以在非模板化的类中。但是不能用方法模板编写虚方法和析构函数。

在Grid类中添加模板化的复制构造函数和赋值运算符,可生成将一种网格类型转换为另一种网格类型的方法。下面是新的Grid类定义:

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
class Grid {
public:
template < typename E>
Grid(const Grid<E>& src);

template <typename E>
Grid<T>& operator= (const Grid<E>& rhs);

void swap (Grid& other) noexcept;
};

首先检查新的模板化的复制构造函数:

1
Grid(const Grid<E>& src);

可看到另一个具有不同类型名称E(Element的简写)的模板声明。这个类在类型T上被模板化,这个新的复制构造函数又在另一个不同的类型E上被模板化。通过这种双重模板化可将一种类型的网格复制到另一种类型
的网格。下面是新的复制构造函数的定义:

1
2
3
4
5
6
7
template <typename T>
template <typename E>
Grid<T>::Grid(const Grid<E>& src) : Grid (src.getwidth(), src.getHeight()) {
for (size_t i = 0; i < mWidth; i++)
for (size_t j = 0; j < mHeight; j++)
mCells[i][j] = src.at(i, j);
}

可以看出,必须将声明类模板的那一行(带有T参数)放在成员模板的那一行声明(带有E参数)的前面。不能像下面这样合并两者:

1
2
template <typename T, typename E> // Wrong for nested template constructor!
Grid<T>::Grid(const Grid<E>& src)

除了构造函数定义之前的额外模板参数行之外,注意必须通过公共的访问方法getWidth()getHeight()at()访问src中的元素。这是因为复制目标对象的类型为Grid<T>,而复制来源对象的类型为Grid<E>。 这两者不是同一类型,因此必须使用公共方法。

模板化的赋值运算符接收const Grid<E>&作为参数,但返回Grid<T>&

1
2
3
4
5
6
7
template <typename T>
template <typename E>
Grid<T>& Grid<T>::operator= (const Grid<E>& rhs) {
Grid<T> temp(rhs); // Do all the work in a temporary instance
swap(temp) ; // Commit the work with only non-throwing operations
return *this;
}

带有非类型参数的方法模板

有了赋值运算符和复制构造函数的方法模板后,完全可实现对不同大小的网格进行赋值和复制。下面是类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T, size_t WIDTH = 10, size_t HEIGHT = 10>
class Grid {
public:
Grid(const Grid& src) = default;
Grid<T, WIDTH, HEIGHT>& operator=(const Grid& rhs) = default;
template <typename E, size_t WIDTH2, size_t HEIGHT2>
Grid(const Grid<E, WIDTH2, HEIGHT2>& src) ;
template <typename E, size_t WIDTH2, size_t HEIGHT2>
Grid<T, WIDTH, HEIGHT>& operator= (const Grid<E, WIDTH2, HEIGHT2>& rhs);

void swap (Grid& other) noexcept;
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;
size_t getHeight() const { return HEIGHT; }
size_t getwidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t x, size_t y) const; .
std::optional<T> mCells [WIDTH] [HEIGHT];
};

这个新定义包含复制构造函数和赋值运算符的方法模板,还包含辅助方法swap()。注意,将非模板化的复制构造函数和赋值运算符显式设置为默认(原因在于用户声明的析构函数)。这些方法只是将mCells从源对象复制或赋值到目标对象,语义和两个一样大小的网格的语义完全一致。

下面是模板化的复制构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T, size_t WIDTH,  size_t HEIGHT>
template <typename E, size t WIDTH2, size t HEIGHT2>
Grid<T, WIDTH, HEIGHT>::Grid(const Grid<E, WIDTH2, HEIGHT2>& src) {
for (size_t i; i < WIDTH; i++) {
for (size_t j; j < HEIGHT; j ++) {
if(i < WIDTH2 && j < HEIGHT2 ) {
mCells[i][j] = src.at(i, j);
} else {
mcells[i][j].reset();
}
}
}
}

类模板的特例化

模板的另一个实现称为模板特例化(template specialization)。编写一个模板类特例化时,必须指明这是一个模板,以及正在为哪种特定的类型编写这个模板。下面是为const char*特例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "Grid.h"
class Grid<const char*> {
public:
explicit Grid(size_t width = kDefaultwidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;
// Explicitly default a copy constructor and ass ignment operator.
Grid<const char*>& operator= (const Grid& rhs) = default;
// Explicitly default a move constructor and assignment operator.
Grid<const char*>& operator= (Grid&& rhs) = default;
std::optional<std::string>& at(size_t x, size_t y);
const std::optional<std::string>& at(size_t x, size_t y) const;
size_t getHeight() const { return mHeight; }
size_t getwidth() const { return mWidth;}
static const size_t kDefaultwidth = 10;
static const size_t kDefaultHeight = 10;
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::optional<std::string>>> mCells; .
size_t mWidth, mHeight;
};

1
2
template <>
class Grid<const char*>

上述语法告诉编译器,这个类是Grid类的const char*特例化版本。假设没有使用这种语法,而是尝试编写下面这样的代码:

1
class Grid

编译器不允许这样做,因为已经有一个名为Grid的类(原始的类模板)。只能通过特例化重用这个名称。特例化的主要好处就是可对用户隐藏。当用户创建int或SpreadsheetCell类型的Grid时,编译器从原始的Grid 模板生成代码。当用户创建const char*类型的Grid时,编译器会使用const char*的特例化版本。这些全部在后台自动完成。

下面是const char*特例化版本的方法的实现。与模板定义不同,不必在每个方法定义之前重复template<>语法:

1
2
3
4
5
6
Grid<const char*>::Grid(size_t width, size_t height) : mWidth(width), mHeight (height) {
mCells.resize (mWidth);
for (auto& column : mCells){
column.resize (mHeight) ;
}
}

从类模板派生

可从类模板派生。如果一个派生类从模板本身继承,那么这个派生类也必须是模板。此外,还可从类模板派生某个特定实例,这种情况下,这个派生类不需要是模板。下面针对前一种情况举一个例子:

1
2
3
4
5
6
template <typename T>
class GameBoard : public Grid<T> {
public:
explicit GameBoard(size_t width = Grid<T>::kDefaultwidth, size_t height = Grid<T>::kDefaultHeight);
void move(size_t xSrc, size_t ySrc, size_t xDest, size_t yDest);
};

继承的语法和普通继承一样,区别在于基类是Grid<T>,而不是Grid: public Grid<T>语法表明,这个类继承了Grid实例化对类型参数T有意义的所有内容。

下面是构造函数和move()方法的实现。同样,要注意调用基类构造函数时对Grid<T>的使用。此外,尽管很多编译器并没有强制使用this指针或Grid<T>::引用基类模板中的数据成员和方法,但名称查找规则要求使用this指针或Grid<T>::

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
GameBoard<T>::GameBoard(size_t width, size_t height) : Grid<T> (width, height) { }

template <typename T>
void GameBoard<T>::move(size_t xSrc, size_t ySrc, size_t xDest, size_t yDest) {
Grid<T>::at(xDest, yDest) = std::move (Grid<T>::at(xSrc, ySrc));
Grid<T>::at(xSrc, ySrc).reset();
// or:
// this->at (xDest, yDest) = std::move (this->at(xSrc, ySrc));
// this->at (xSrC, ySrc).reset() ;
}

继承还是特例化

表12-1总结了两者的区别。

通过继承来扩展实现和使用多态。通过特例化自定义特定类型的实现。

模板别名

可使用类型别名给模板化的类赋予另一个名称。假定有如下类模板:

1
2
template<typename T1, typename T2>
class MyTemplateClass { };

可定义如下类型别名,给定两个模板类型参数:

1
using OtherName = MyTemplateClass<int, double>;

还可仅指定一些类型, 其他类型则保持为模板类型参数,这称为别名模板(alias template),例如:

1
2
template<typename T1>
using OtherName = MyTemplateClass<T1, double>;

函数模板

还可为独立函数编写模板。例如,可编写一个通用函数,该函数在数组中查找一个值并返回这个值的索引:

1
2
3
4
5
6
7
8
9
static const size_t NOT_FOUND = static_cast<size_t>(-1);
template <typename T>
size_t Find(const T& value, const T* arr, size_t size) {
for(size_t i = 0; i < size; i ++){
if (arr[i] == value)
return i; // Found it; return the index
}
return NOT_FOUND; // Failed to find it; return NOT_FOUND
}

可通过两种方式调用这个函数;一种是通过尖括号显式地指定类型;另一种是忽略类型,让编译器根据参数自动推断类型。下面列举一些例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int myInt = 3, intArray[] = {1, 2, 3, 4};
const size_t sizeIntArray = std::size(intArray);
size_t res;
res = Find(myInt,intArray, sizeIntArray); // calls Find<int> by deduction
res = Find<int>(myInt, intArray, sizeIntArray); // calls Find<int> explicitly
if (res != NOT_FOUND)
cout << res << endl;
else
cout << "Not found" << endl;
double myDouble = 5.6, doubleArray[] = {1.2, 3.4, 5.7, 7.5};
const size_t sizeDoubleArray = std::size(doubleArray);

// calls Find<double> by deduction
res = Find (myDouble, doubleArray, sizeDoubleArray);
// calls Find<double> explicitly
res = Find<double>(myDouble, doubleArray, sizeDoubleArray);
if (res != NOT_FOUND)
cout << res << endl;
else
cout << "Not found" << endl;
res = Find<double> (myInt, doubleArray, sizeDoubleArray);

SpreadsheetCe1l cell1(10), cellArray[] = {SpreadsheetCell(4), SpreadsheetCel1(10) };
const size_t sizeCellArray = std::size(cellArray);
res = Find(cell1, cellArray, sizeCellArray) ;
res = Find<Spreadsheetcell>(celll, cellArray, sizeCellArray) ;

前面Find()函数的实现需要把数组的大小作为一个参数。有时编译器知道数组的确切大小,例如,基于堆栈的数组。用这种数组调用Find()函数,就不需要传递数组的大小。为此,可添加如下函数模板。该实现仅把调用传递给前面的Find()函数模板。这也说明函数模板可接收非类型的参数,与类模板一样。

1
2
3
4
template <typename T, size_t N>
size_t Find(const T& value, const T(&arr) [N]) {
return Find(value, arr, N);
}

与类模板方法定义一样,函数模板定义(不仅是原型)必须能用于使用它们的所有源文件。因此,如果多个源文件使用函数模板,或使用本章前面讨论的显式实例化,就应把其定义放在头文件中。函数模板的模板参数可以有默认值,与类模板一样。

函数模板的特例化

就像类模板的特例化一样,函数模板也可特例化。

1
2
3
4
5
6
7
template<>
size_t Find<const char*> (const char* const& value, const char* const* arr, size_t size) {
for (size_t i = 0; i < size; i++)
if (strcmp(arr[i], value) == 0)
return i; // Found it; return the index
return NOT_FOUND; // Failed to find it; return NOT_FOUND
}

如果参数类型可通过参数推导出来,那么可在函数名中忽略<const char*>,将这个函数原型简化为:

1
2
template<>
size_t Find(const char* const& value, const char* const* arr, size_t size)

函数模板的重载

还可用非模板函数重载模板函数。例如,如果不编写用于const char*的Find()函数模板,那么需要编写一个非模板的独立Find()函数以直接操作const char*

1
2
3
4
5
6
size_t Find(const char* const& value, const char* const* arr, size_t size) {
for(size_t i = 0; i < size; i ++)
if (strcmp(arr[i], value) == 0)
return i;
return NOT_FOUND;
}

这个函数的调用规则有所不同:

1
2
3
4
5
6
const char* word = "two";
const char* words [] = {"two", "three", "four"};
const size_t sizeWords = std::size(words);
size_t res;

res = Find(word, words, sizeWords) ; // Calls non-template function!

因此,如果想要函数在显式指定了const char*时能正常工作,以及在没有指定时能通过自动类型推导正常工作,那么应该编写一个特例化的模板版本,而不是编写一个非模板的重载版本。

同时使用函数模板重载和特例化

可同时编写一个适用于const char*的特例化Find()函数模板,以及一个适用于const char*的独立Find()函数。编译器总是优先选择非模板化的函数,而不是选择模板化的版本。然而,如果显式地指定模板的实例化,那么会强制编译器使用模板化的版本。

类模板的友元函数模板

如果需要在类模板中重载运算符,函数模板会非常有用。假定operator+是一个独立的函数模板,其定义应该直接放在Grid.h中,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T>
Grid<T> operator+ (const Grid<T>& lhs,const Grid<T>& rhs) {
size_t minwidth = std::min (lhs.getwidth(), rhs.getwidth());
size_t minHeight = std::min(lhs.getHeight(), rhs.getHeight());
Grid<T> result (minWidth, minHeight);

for (size_t y = 0; y < minHeight; ++y) {
for (size_t x = 0; x < minWidth; ++x) {

const auto& leftElement = lhs.mCells[x][y];
const auto& rightElement = rhs.mCells[x][y];
if(leftElement.has_value() && rightElement.has_value())
result.at(x, y) = leftElement.value() + rightElement.value();
}
}
return result;
}

对模板参数推导的更多介绍

编译器根据传递给函数模板的实参来推导模板参数的类型;而对于无法推导的模板参数,则需要显式指定。例如,如下add()函数模板需要三个模板参数:返回值的类型以及两个操作数的类型。

1
2
template<typename RetType, typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1+t2;}

调用这个函数模板时,可指定如下所有三个参数:

1
auto result = add<long long, int, int>(1, 2);

但由于模板参数T1和T2是函数的参数,编译器可以推导这两个参数,因此调用add()时可仅指定返回值的类型:

1
auto result = add<long long>(1, 2);

当然,仅在要推导的参数位于参数列表的最后时,这才可行。假设以如下方式定义函数模板:

1
2
template<typename T1, typename RetType, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }

必须指定RetType,因为编译器无法推导该类型。但由于RetType 是第二个参数,因此必须显式指定T1:

1
auto result = add<int, long 1ong>(1, 2);

也可提供返回类型模板参数的默认值,这样调用add()时可不指定任何类型:

1
2
3
template<typename RetType = long long, typename T1,typename T2>
RetType add(const T2& t2) { return t1 + t2; }
auto result = add(1, 2);

函数模板的返回类型

add()函数模板的返回类型取决于模板类型参数,如何才能做到这一点?例如,考虑如下模板函数:

1
2
template<typename T1, typename T2>
RetType add(const T1& t1, const T2& t2) { return t1 + t2; }

在这个示例中,RetType应当是表达式t1+t2的类型,但由于不知道T1和T2是什么,因此并不知道这一点。只需要编写如下add()函数模板:

1
2
3
auto add(const T1& t1, const T2& & t2) {
return t1 + t2;
}

但是,使用auto来推导表达式类型时去掉了引用和const限定符;decltype没有去除这些。在继续使用add()函数模板前,先分析auto和decltype(使用非模板示例)之间的区别。假设有以下函数:

1
2
3
4
const std::string message = "Test"; 
const std::string& getString() {
return message;
}

auto s1 = getString();,由于auto会去掉引用和const限定符,因此s1的类型是string,并制作一个副本。如果需要一个const引用,可将其显式地设置为引用,并标记为const,如下所示:

1
const auto& s2 = getString();

另一个解决方案是使用decltypedecltype不会去掉引用和const限定符:

1
decltype (getString()) s3 = getString();

这里,s3的类型是const string&,但存在代码冗余,因为需要将getString()指定两次。如果getString()是更复杂的表达式,这将很麻烦。为解决这个问题,可使用decltype(auto):

1
decltype(auto) s4 = getString();

s4的类型也是const string&

了解到这些后,可使用decltype(auto)编写add()函数,以避免去掉任何const和引用限定符:

1
2
3
4
template<typename T1, typename T2>
decltype (auto) add (const T1& t1, const T2& t2) {
return t1 + t2;
}

在C++14之前,不支持推导函数的返回类型和decltype(auto)。C++11 引入的decltype(expression)解决了这个问题。例如,你或许会编写如下代码:

1
2
template<typename T1,typename T2>
decltype(t1+t2) add(const T1& t1, const T2& t2) { return t1 + t2; }

但这是错误的。你在原型行的开头使用了t1和t2,但这些尚且不知。在语义分析器到达参数列表的末尾时,才能知道t1和t2。

通常使用替换函数语法(alternative function syntax)解决这个问题。注意在这种新语法中,返回类型是在参数列表之后指定的(拖尾返回类型),因此在解析时参数的名称(以及参数的类型,因此也包括t1+t2类型)是已知的:

1
2
3
4
template<typename T1, typename T2>
auto add(const T1& t1, const T2& t2) -> decltype(t1+t2) {
return t1 + t2;
}

但现在,C++支持自动返回类型推导和decltype(auto),建议你使用其中的一种机制, 而不要使用替换函数语法。

可变模板

除了类模板、类方法模板和函数模板外,C++14 还添加了编写可变模板的功能。语法如下:

1
2
template <typename T>
constexpr T pi = T(3.141592653589793238462643383279502884);

这是pi值的可变模板。为了在某种类型中获得pi值,可使用如下语法:

1
2
float piFloat = pi<float>;
long double piLongDouble = pi<long double>;

这样总会得到在所请求的类型中可表示的pi近似值。与其他类型的模板一样, 可变模板也可以特殊化。

C++ IO揭秘

C++通过(stream)提供了更精良的输入输出方法。流是一种灵活且面向对象的IO方法。

使用流

流的含义

所有的流都可以看成数据滑槽。流的方向不同,关联的来源和目的地也不同。cout和cin都是C++在std名称空间中预定义的流实例。表13-1简要描述了所有预定义的流。

说明
cin 输入流,从“输入控制台”读取数据
cout 缓冲的输出流,向“输出控制台”写入数据
cerr 非缓冲的输出流,向“错误控制台”写入数据,“ 错误控制台”通常等同于“输出控制台”
clog cerr的缓冲版本

缓冲的流和非缓冲的流的区别在于,前者不是立即将数据发送到目的地,而是缓冲输入的数据,然后以块方式发送;而非缓冲的流则立即将数据发送到目的地。缓冲的目的通常是提高性能,对于某些目的地(如文件)而言,一次性写入较大的块时速度更快。注意,始终可使用flush()方法刷新缓冲区,强制要求缓冲的流将其当前所有的缓冲数据发送到目的地。

有关流的另一个要点是:流不仅包含普通数据,还包含称为当前位置(current position)的特殊数据。当前位置指的是流将要进行下一次读写操作的位置。

流的来源和目的地

在C++中,流可使用3个公共的来源和目的地:控制台、文件和字符串。

  • 控制台输入流允许程序在运行时从用户那里获得输入,使程序具有交互性。
  • 文件流从文件系统中读取数据并向文件系统写入数据。
  • 字符串流是将流隐喻应用于字符串类型的例子。使用字符串流时,可像处理其他任何流一样处理字符数据。

流式输出

输出的基本概念

输出流定义在<ostream>头文件中。大部分程序员都会在程序中包含<iostream>头文件,这个头文件又包含输入流和输出流的头文件。<iostream>头文件还声明了所有预定义的流实例:cout、cin、cerr、clog以及对应的宽版本。

使用输出流的最简单方法是使用<<运算符。通过<<可输出C++的基本类型。此外,C++的string类也兼容<<

cout流是写入控制台的内建流,控制台也称为标准输出(standard output)。可将<<的使用串联起来,从而输出多个数据段。这是因为<<运算符返回一个流的引用,因此可以立即对同一个流再次应用<<运算符。

C++流可正确解析C风格的转义字符,例如包含\n的字符串,也可使用std::endl开始一个新行。\nendl的区别是,\n仅开始一个新行,而endl还会刷新缓存区。使用endl时要小心,因为过多的缓存区刷新会降低性能。

输出流的方法

put()和write()

put()write()是原始的输出方法。这两个方法接收的不是定义了输出行为的对象或变量,put()接收单个字符,write()接收一个字符数组。传给这些方法的数据按照原本的形式输出,没有做任何特殊的格式化和处理操作。例如,下面的代码段接收一个C风格的字符串,并将它输出到控制台,这个函数没有使用<<运算符:

1
2
const char* test = "hello there\n";
cout.write(test, strlen(test));

下面的代码段通过put()方法,将C风格字符串的给定索引输出到控制台:

1
cout.put('a');

flush()

向输出流写入数据时,大部分输出流都会进行缓冲,也就是积累数据,而不是立即将得到的数据写出去。在以下任意一种条件下,流将刷新(或写出)积累的数据:

  • 遇到sentinel(如endl标记)时。
  • 流离开作用域被析构时。
  • 要求从对应的输入流输入数据时(即要求从cin输入时,cout会刷新)。
  • 流缓存满时。
  • 显式地要求流刷新缓存时。

显式要求流刷新缓存的方法是调用流的flush()方法。

不是所有的输出流都会缓存。例如,cer 流就不会缓存其输出。

处理输出错误

当一个流处于正常的可用状态时,称这个流是“好的”。调用流的good()方法可以判断这个流当前是否处于正常状态。

1
2
if(cout.good())
cout << "All good" << end1;

通过good()方法可方便地获得流的基本验证信息,但不能提供流不可用的原因。还有一个bad()方法提供了稍多信息。如果bad()方法返回true,意味着发生了致命错误(相对于非致命错误,例如到达文件结尾)。另一个方法fail()在最近一次操作失败时返回true,但没有说明下一次操作是否也会失败。例如,对输出流调用flush()后,可调用fail()确保流仍然可用。

1
2
3
cout.flush();
if(cout.fall())
cerr << "Unable to flush to standard out" << endl;

流具有可转换为bool类型的转换运算符。转换运算符与调用fail()时返回的结果相同。因此,可将前面的代码段重写为:

1
2
3
cout.flush();
if (!cout)
cerr << "Unable to flush to standard out" << endl;

有一点需要指出,遇到文件结束标记时,good()fail()都会返回false。关系如下:good() == (!fail() && !eof())

还可要求流在发生故障时抛出异常。然后编写一个catch处理程序来捕捉ios_base::failure异常,然后对这个异常调用what()方法,获得错误的描述信息,调用code()方法获得错误代码。不过,是否能获得有用信息取决于所使用的标准库实现:

1
2
3
4
5
6
cout.exceptions(ios::failbit | ios::badbit | ios::eofbit);
try {
cout << "Hello World." << endl;
} catch (const ios_base::failure& ex) {
cerr << "Caught exception:" << ex.what() << ", error code ="<< ex.code() << endl;
}

输出操作算子

C++流还能识别操作算子(manipulator),操作算子是能修改流行为的对象,而不是(或额外提供)流能够操作的数据。

endl就是一个操作算子。endl操作算子封装了数据和行为。它要求流输出一个行结束序列,并且刷新缓存。其他有用的操作算子大部分定义在<ios><iomanip>标准头文件中。列表后的例子展示了如何使用这些操作算子。

  • boolalpha和noboolalpha:要求流将布尔值输出为truefalse(boolalpha)或1和0(noboolalpha)。默认行为是noboolalpha。
  • hex、oct和dec:分别以十六进制、八进制和+进制输出数字。
  • stprecision:设置输出小数时的小数位数。这是一个参数化的操作算子。
  • setw:设置输出数值数据的字段宽度。这是一个参数化的操作算子。
  • setfill:当数字宽度小于指定宽度时,设置用于填充的字符。这是一个参数化的操作算子。
  • showpoint和noshowpoint:对于不带小数部分的浮点数,强制流总是显示或不显示小数点。
  • put_money:一个参数化的操作算子,向流写入一个格式化的货币值。
  • put_time:一个参数化的操作算子,向流写入一个格式化的时间值。
  • quoted:一个参数化的操作算子,把给定的字符串封装在引号中,并转义嵌入的引号。

上述操作算子对后续输出到流中的内容有效,直到重置操作算子为止,但setw仅对下一个输出有效。

如果不关心操作算子的概念,通常也能应付过去。流通过precision()这类方法提供了大部分相同的功能。以如下代码为例:

1
cout << "This should be '1.2346': "<< setprecision(5) << 1.23456789 << endl;

这行代码可转换为方法调用。该方法的优点是,它们返回前面的值以便恢复:

1
2
cout.precision(5);
cout << "This should be '1.2346'; "<< 1.23456789 << endl;

流式输入

输入流为结构化数据和非结构化数据的读入提供了简单方法。

输入的基本概念

读入数据对应的运算符是>>。通过>>从输入流读入数据时,代码提供的变量保存接收的值。默认情况下,>>运算符根据空白字符对输入值进行标志化。>>运算符可用于不同的变量类型,就像<<运算符一样。

处理输入错误

输入流提供了一些方法用于检测异常情形。大部分和输入流有关的错误条件都发生在无数据可读时。查询输入流状态的最常见方法是在条件语句中访问输入流。例如,只要cin保持在“良好”状态,下面的循环就继续进行:

1
while (cin) ( ... )

同时可以输入数据:

1
while (cin >> ch) { ... }

还可在输入流上调用good()bad()fail()方法,就像输出流那样。还有一个eof()方法,如果流到达尾部,就返回true。与输出流类似,遇到文件结束标记时,good()fail()都会返回false。关系如下:good() == (!fail() && !eof())

下面的程序展示了从流中读取数据并处理错误的常用模式。这个程序从标准输入中读取数字,到达文件末尾时显示这些数字的总和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int sum = 0;
if (!cin.good()) {
cerr << "Standard input is in a bad state!" << endl;
return 1;
}
int number;
while(!cin.bad()) {
cin >> number:
if (cin.good()) {
sum += number;
} else if (cin.eof()) {
break;
} else if (cin.fail()) {
// Failure!
cin.clear();
// Clear the failure state.
string badToken;
cin >> badToken; // consume the bad input.
err << "WARNTNG:Bad input encountered:" << badToken << endl;
}
}

输入方法

输入流也提供了一些方法,获得相比普通>>运算符更底层的访问功能。

get()

get()方法允许从流中读入原始输入数据。get()的最简单版本返回流中的下一个字符,其他版本一次读入多个字符。get()常用于避免>>运算符的自动标志化。

在条件环境中对一个输入流求值时,只有当这个输入流可以用于下一次读取时才会返回true。如果遇到错误或者到达文件末尾,都会使流求值为false。

unget()

对于大多数场合来说,理解输入流的正确方式是将输入流理解为单方向的滑槽。数据被丢入滑槽,然后进入变量。unget()方法打破了这个模型,允许将数据塞回滑槽。调用unget()会导致流回退一个位置,将读入的前一个字符放回流中。调用fail()方法可查看unget()是否成功。

下面的代码使用了unget(),允许名字中出现空白字符。将这段代码逐字符读入,并检查字符是否为数字。如果字符不是数字,就将字符添加到guestName;如果字符是数字,就通过unget()将这个字符放回到流中,循环停止,然后通过>>运算符输入一个整数partySize。noskipws输入操作算子告知流不要跳过空白字符,就像读取其他任何字符一样读取空白字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
vold getReservationData() {
string guestName;
int partySize = 0;
// Read characters until we find a digit
char ch;
cin >> noskipws;
while (cin >> ch) {
if (isdigit(ch)) {
cin.unget();
if(cin.fail ())
cout << "unget() failed" << endl;
break;
}
guestName += ch;
}
// Read partysize, if the stream is not in error state
if (cin)
cin >> partySize;
if (!cin) {
cerr << "Error getting party size." << endl;
return;
}

cout << "Thank you " << guestName << ", party of " << partysize << endl;
if (partysize> 10) {
cout << "An extra gratuity will apply." << endl;
}
}

putback()

putback()unget()一样,允许在输入流中反向移动一个字符。区别在于putback()方法将放回流中的字符接收为参数:

1
2
3
4
char ch1;
cin >> chl;
cin.putback('e');
// 'e' will be the next character read oft the stream.

peek()

通过peek()方法可预览调用get()后返回的下一个值。再次以滑槽为例,可想象为查看一下滑槽,但是不把值取出来。
peek()非常适合于在读取前需要预先查看一个值的场合。

getine()

getline()方法用一行数据填充字符缓存区,数据量最多至指定大小。指定的大小中包括\0字符。因此,下面的代码最多从cin 中读取kBufferSize - 1个字符,或者读到行尾为止:

1
2
char buffer[kBufferSize] = { 0 };
cin.getline (buffer, kBuffersize);

调用getline()时,从输入流中读取一行,读到行尾为止。不过,行尾字符不会出现在字符串中。有个版本的get()函数执行的操作和getline()一样,区别在于get()把换行序列留在输入流中。

还有一个用于C++字符串的std:getline()函数。这个函数定义在<string>头文件和std名称空间中。它接收一个流引用、一个字符串引用和一个可选的分隔符作为参数。使用这个版本的getine()函数的优点是不需要指定缓存区的大小。

1
2
string myString;
std::getine(cin, myString);

输入操作算子

下面列出了内建的输入操作算子,它们可发送到输入流中,以自定义数据读入的方式。

  • boolalpha和noboolalpha:如果使用了boolalpha,字符串false会被解释为布尔值false;其他任何字符串都会被解释为布尔值true。如果设置了noboolalpha,0会被解释为false,其他任何值都被解释为true。
  • hex、oct和dec:分别以十六进制、八进制和十进制读入数字。
  • skipws和noskipws:告诉输入流在标记化时跳过空白字符,或者读入空白字符作为标记。默认为skipws。
  • ws:一个简便的操作算子,表示跳过流中当前位置的一串空白字符。
  • get_money:一个参数化的操作算子,从流中读入一个格式化的货币值。
  • get_time:一个参数化的操作算子,从流中读入一个格式化的时间值。
  • quoted:一个参数化的操作算子,读取封装在引号中的字符串,并转义嵌入的引号。

字符串流

可通过字符串流将流语义用于字符串。通过这种方式,可得到一个内存中的流(in memory stream)来表示文本数据。字符串流也非常适合于解析文本,因为流内建了标记化的功能。std::ostringstream类用于将数据写入字符串,std::istringtream类用于从字符串中读出数据。这两个类都定义在<sstream>头文件中。由于ostringstreamistringstream把同样的行为分别继承为ostreamistream,因此这两个类的使用也非常类似。

下面的程序从用户那里请求单词,然后输出到一个ostringtream中,通过制表符将单词分开。在程序的最后,整个流通过str()方法转换为字符串对象,并写入控制台。输入标记“done”,可停止标记的输入。

1
2
3
4
5
6
7
8
9
ostringstream outstream;
while (cin) {
string nextToken;
cout << "Next token:";
cin >> nextToken;
if (!cin || nextToken == "done")
break;
outStream << nextToken << "\t";
}

从字符串流中读入数据非常类似。下面的函数创建一个Muffin对象,并填充字符串输入流中的数据。流数据的格式固定,因此这个函数可轻松地将数据值转换为对Mufin类的设置方法的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Muffin createMuffin(istringstream& stream) {
Muffin muftin;
string description;
int size;
bool hasChips;

stream >> description >> size >> boolalpha >> hasChips;
if (stream) {
muffin.setsize(size);
muffin.setDescription (description);
muffin.setHasChocolateChips (hasChipa);
}
return muffin;
}

文件流

文件本身非常符合流的抽象,因为读写文件时,除数据外,还涉及读写的位置。在C++中,std::ofstreamstd::ifstream类提供了文件的输入输出功能。这两个类在<fstream>头文件中定义。在处理文件系统时,错误情形的检测和处理非常重要。可以通过前面描述的标准错误处理机制检测错误情形。

输出文件流和其他输出流的唯一主要区别在于:文件流的构造函数可以接收文件名以及打开文件的模式作为参数。默认模式是写文件(ios_base::out),这种模式从文件开头写文件,改写任何已有的数据。给文件流构造
函数的第二个参数指定常量ios_base::app,还可按追加模式打开输出文件流。

常量 说明
ios_base::app 打开文件,在每一次写操作之前,移到文件末尾
ios_base::ate 打开文件,打开之后立即移到文件末尾
ios_base::binary 以二进制模式执行输入输出操作(相对于文本模式)
ios_base::in 打开文件,从开头开始读取
ios_base::out 打开文件,从开头开始写入,覆盖已有的数据
ios_base::trunc 打开文件,并删除(截断)任何已有数据

注意,可组合模式。例如,如果要打开文件用于输出(以二进制模式),同时截断现有数据,可采用如下方式指定打开模式:

1
ios_base::out | ios_base::binary | ios_base::trunc

ifstream自动包含ios_base::in模式,ofstream自动包含ios_base::out模式,即使不显式地将in或out指定为模式,也同样如此。

下面的程序打开文件ts.txt,并输出程序的参数。istram 和ofstream析构函数会自动关闭底层文件,因此不需要显式调用close()

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, char* argv[]) {
ofstream outFile("test.txt", ios_base::trunc);
if (!outFile.good()) {
cerr << "Error while opening output file!" << endl;
return -1;
}
outFile << "There were " << argc << "arguments to this program." << endl;
outFile << "They are:"<< endl;
for (int i = 0; i ≤ arge; i ++)
outFile << argv[i] << endl;
return 0;
}

文本模式与二进制模式

默认情况下,文件流在文本模式中打开。如果指定ios_base:binary标志,将在二进制模式中打开文件。在二进制模式中,要求把流处理的字节写入文件。读取时,将完全按文件中的形式返回字节。

在文本模式中,会执行一些隐式转换,写入文件或从文件中读取的每一行都以\n结束。但是,行结束符在文件中的编码方式与操作系统相关。因此,如果文件以文本模式打开,而写入的行以\n结尾,在写入文件前,底层实现会自动将\n转换为\r\n。同样,从文件读取行时,从文件读取的\r\n会自动转换回\n。

通过seek()和tell()在文件中转移

所有的输入流和输出流都有seek()tell()方法。

seek()方法允许在输入流或输出流中移动到任意位置。seek()有好几种形式。输入流中的seek()版本实际上称为seekg(),输出流中的seek()版本称为seekp()。有的流既可以输入又可以输出,例如文件流。这种情况下,流需要记住读位置和独立的写位置。这也称为双向IO

seekg()seekp()有两个重载版本。其中一个重载版本接收一个参数:绝对位置。这个重载版本将定位到这个绝对位置。另一个重载版本接收一个偏移量和一个位置,这个重载版本将定位到距离给定位置一定偏移量的位置。位置的类型为std::streampos,偏移量的类型为std::streamoff,这两种类型都以字节计数。预定义的三个位置如表所示。

位置 说明
ios_base::beg 表示流的开头
ios_base::end 表示流的结尾
ios_base::cur 表示流的当前位置

例如,要定位到输出流中的一个绝对位置,可使用接收一个参数的seekp()版本,如下所示,这个例子通过ios_base::beg常量定位到流的开头:

1
outStream.seekp(ios_base::beg);

在输入流中,定位方法完全一样, 只不过用的是seekp()方法:

1
instream.seekg(ios_base::beg);

接收两个参数的版本可定位到流中的相对位置。第一个参数表示要移动的位置数,第二个参数表示起始点。要相对文件的起始位置移动,使用ios_base::beg常量。要相对文件的末尾位置移动,使用ios_base::end常量。要相对文件的当前位置移动,使用`ios_base::cur常量。例如,下面这行代码从流的起始位置移动到第二个字节。

1
outStream.seekp(2, ios_base::beg);

下例转移到输入流中的倒数第3个字节:

1
instream.seekg(-3, ios_base::end);

可通过tell()方法查询流的当前位置,这个方法返回一个表示当前位置的streampos值。利用这个结果,可在执行seek()之前记住当前标记的位置,还可查询是否在某个特定位置。和seek()一样,输入流和输出流也有不同版本的tell()。输入流使用的是tellg(),输出流使用的是tellp()。下面的代码检查输入流的当前位置,并判断是否在起始位置:

1
2
3
std::streampos curPos = instream.tel1g();
if (ios_base::beg == curPos)
cout << "We're at the beginning." << endl;

将流链接在一起

任何输入流和输出流之间都可以建立链接,从而实现“访问时刷新”的行为。换句话说,当从输入流请求数据时,链接的输出流会自动刷新。这种行为可用于所有流,但对于可能互相依赖的文件流来说特别有用。通过tie()方法完成流的链接。要将输出流链接至输入流,对输入流调用tie()方法,并传入输出流的地址。要解除链接,传入nullptr。

下面的程序将一个文件的输入流链接至一个完全不同的文件的输出流。也可链接至同一个文件的输出流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ifstream inFile("input.txt"); // Note:input.txt must exist.
ofstream outFile ("output.txt");
// Set up a link between inFile and outFile.
inFile.tie (&soutFile);

// output some text to outFile. Normally, this would
// not flush because std::endl is not sent.
outFlle << "Hello there!";
// outFlle has NOT been. flushed.
// Read some text from inF1le. This w111 trigger flush()
// on outFile
string nextToken;
inFile >> nextToken;
// outFile HAS been flushed.

这种关系意味着:每次写入一个文件时,发送给另一个文件的缓存数据会被刷新。可通过这种机制保持两个相关文件的同步。

这种流链接的一个例子是cout和cin之间的链接。每当从cin输入数据时,都会自动刷新cout。cerr和cout之间也存在链接,这意味着到cerr的任何输出都会导致刷新cout,而clog未链接到cout。

双向I/O

双向流可同时以输入流和输出流的方式操作。双向流是iostream的子类,而iostreamistreamostream的子类,因此这是一个多重继承示例。显然,双向流支持>><<运算符,还支持输入流和输出流的方法。fstram类提供了双向文件流。fstream特别适用于需要替换文件中数据的应用程序,因为可通过读取文件找到正确的位置,然后立即切换为写入文件。

双向流用不同的指针保存读位置和写位置。在读取和写入之间切换时,需要定位到正确的位置。

错误处理

错误与异常

C++提供了对异常的语言支持,但不要求使用异常。然而,在C++中无法完全忽略异常,因为一些基本工具(例如内存分配例程)会用到它们。

异常的含义

异常是这样一种机制:一段代码提醒另一段代码存在“异常”情况或错误情况,所采用的路径与正常的代码路径不同。遇到错误的代码抛出异常,处理异常的代码捕获异常。当某段代码抛出异常时,程序控制立刻停止逐步执行,并转向异常处理程序(exception handler),异常处理程序可在任何地方,可位于同一函数中的下一行,也可在堆栈中相隔好几个函数调用。

C++中异常的优点

C++错误处理标准使用函数返回的整数代码和errno宏表示错误,每个线程都有自己的errno值。errno用作线程局部整数变量(thread-local integer variable),被调用函数使用这个变量将发生的错误告诉调用函数。整数返回代码和errno的使用并不一致。有些函数可能用返回值0表示成功,用-1表示错误。这些不一致性可能会引起问题,因为程序员在遇到新函数时,会假定它的返回代码与其他类似函数相同。

异常具有许多优点。

  • 将返回代码作为报告错误的机制时,调用者可能会忽略返回的代码,不进行局部处理或不将错误代码向上提交。
  • 返回的整数代码通常不会包含足够的信息。使用异常时,可将任何信息从发现错误的代码传递到处理错误的代码。除错误信息外,异常还可用来传递其他信息。
  • 异常处理可跳过调用堆栈的层次。也就是说,某个函数可处理沿着堆栈进行数次函数调用后发生的错误,而中间函数不需要有错误处理程序。返回代码要求堆栈中每一层调用的函数都必须在前一层之后显式地执行清理。

在现代编译器中,不抛出异常时几乎没有这个开销,实际抛出异常时这一开销也非常小。这并不是坏事,因为抛出异常应是例外情况。在C++中,并不强制异常处理。在C++中函数可抛出它想要抛出的任何异常,除非指定不会抛出任何异常(使用noexcept 关键字)。

异常机制

抛出和捕获异常

为了使用异常,要在程序中包括两部分:处理异常的try/catch结构和抛出异常的throw语句。二者都必须以某种形式出现,以进行异常处理。然而在许多情况下,throw 在一些库的深处发生,程序员无法看到这一点, 但仍然不得不用try/catch结构处理抛出的异常。try/catch 结构如下所示:

1
2
3
4
5
6
7
try {
// code which may result in an exception being thrown
} catch (exception-type1 exception-name) {
// code which responds to the exception of type 1
} catch (except ion-type2 exception-name) {
// code which responds to the exception of type 2
}

导致抛出异常的代码可能直接包含throw语句,也可能调用一个函数,这个函数可能直接抛出异常,也可能经过多层调用后调用为一个抛出异常的函数。如果没有抛出异常,catch 块中的代码不会执行;如果抛出了异常,throw 语句之后或者在抛出异常的函数后的代码不会执行,根据抛出的异常的类型,控制会立刻转移到对应的catch块。

如果catch块没有执行控制转移(例如返回一个值,抛出新的异常或者重新抛出异常),那么会执行catch块最后语句之后的“剩余代码”。演示异常处理的最简单示例是避免除0。这个示例抛出一个std::invalid_argument类型的异常,这种异常类型需要<stdexcept>头文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
double SafeDivide (double num, double den) {
if (den == 0)
throw invalid_argument("Divide by zero");
return num / den;
}
int main() {
try {
cout << safeDivide(5, 2) << endl;
cout << safeDivide(10, 0) << endl;
cout << SafeDivide(3, 3) << endl;
} catch (const invalid_argument& e) {
cout << "Caught exception:"<< e.what() << endl;
}
return 0;
}

输出如下所示:

1
2
2.5
Caught exception:Divide by zero

throw是C++中的关键字,这是抛出异常的唯一方法。throw行的invalid_argument()部分意味着构建invalid_argument类型的新对象并准备将其抛出。该层次结构中的每个类都支持what()方法,该方法返回一个描述异常的const char*字符串。该字符串在异常的构造函数中提供。

异常处理是这样一种方法:“尝试”执行一块代码,并用另一块代码响应可能发生的任何错误。在下面的main()函数中,catch语句响应任何被try块抛出的exception类型异常,并输出错误消息。如果try块结束时没有抛出异常,catch 块将被忽略。可将try/catch块当作if语句。如果在try块中抛出异常,就会执行catch块,否则忽略catch 块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
const string fileName = "IntegerFile.txt";
vector<int> myInts;
try {
myInts = readIntegerFile(fileName);
} catch (const exception& e) {
cerr << "Unable to open file "<< fileName << endl;
return 1;
}
for (const auto& element :myInts) {
cout << element << " ";
}
return 0;
}

尽管默认情况下,流不会抛出异常,但是针对错误情况,仍然可以调用exceptions()方法通知流抛出异常。

异常类型

可抛出任何类型的异常。可以抛出一个std:exception类型的对象,但异常未必是对象。也可以抛出一个简单的int值,如下所示:

1
2
3
4
5
6
7
vector<int> readIntegerFile(string_view fileName) {
ifstream inputstream (fileName.data());
if (inputstream.fail()) {
// We failed to open the file:throw an exception
throw 5;
}
}

此后必须修改catch语句:

1
2
3
4
5
6
try {
myInts = readIntegerFile (fileName);
} catch (int e) {
cerr << "Unable to open file "<< fileName << "(" << e << ")" << endl;
return 1;
}

另外,也可抛出一个C风格的const char*字符串。这项技术有时有用,因为字符串可包含与异常相关的信息。

1
2
3
4
5
6
7
vector<int> readIntegerFile(string_view fileName) {
ifstream inputStream (fileName .data());
if (inputStream.fail()) {
// We failed to open the file:throw an exception
throw "Unable to open file";
}
}

当捕获const char*异常时,可输出结果:

1
2
3
4
5
6
try {
myInts = readIntegerFile (fileName);
} catch (const char* e) {
cerr << e << end1;
return 1;
}

尽管前面有这样的示例,但通常应将对象作为异常抛出,原因有以下两点:

  • 对象的类名可传递信息。
  • 对象可存储信息,包括描述异常的字符串。

按const和引用捕获异常对象

在前面的示例中,readIntegerFile()抛出一个exception类型的对象。catch 行如下所示:

1
) catch (const exception& e) (

然而,在此并没有要求按const引用捕获对象,可按值捕获对象;此外,也可按非const引用捕获对象:

抛出并捕获多个异常

可让函数抛出两种不同类型的异常。invalid_argumentruntime_error都是定义在<stdexcept>头文件中的类,这个头文件是C++标准库的一部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vector<int> readIntegerFile(string_view fileName) {
ifstream inputstream(fileName.data());
if (inputStream.fail())
throw invalid argument ("Unable to open the file.");

vector<int> integers;
int temp;
while (inputstream >> temp)
integers.push_back(temp);

if (!inputstream.eof())
throw runtime_error("Error reading the file.");
return integers;
}

invalid_argumentruntime_error类没有公有的默认构造函数,只有以字符串作为参数的构造函数。现在main()可用两个catch语句捕获invalid_argumentruntime_error异常:

1
2
3
4
5
6
7
8
9
try {
...
} catch (const invalid_arguments& e) {
cerr << e.what() << endl;
return 1;
} catch (const runtime_error& e) {
cerr << e.what() << endl;
return 2;
}

如果异常在try块内部抛出,编译器将使用恰当的catch处理程序与异常类型匹配。因此,如果readIntegerFile()无法打开文件并抛出invalid_argument异常,第一个catch语句将捕获这个异常。如果readntegerFile()无法正确读取文件并抛出runtime_error异常,第二个catch语句将捕获这个异常。

匹配和const

对于想要捕获的异常类型而言,增加const属性不会影响匹配的目的。也就是说,这一行可以与runtime_error类型的任何异常匹配:

1
) catch (const runtime_errors e) (

匹配所有异常

可用特定语法编写与所有异常匹配的catch行,如下所示:

1
2
3
4
5
6
try {
myInts = readIntegeile (fileName) ;
} catch (...) {
cerr << "Error reading or opening file" << fileName << endl;
return 1;
}

三个点是与所有异常类型匹配的通配符。与所有异常匹配的catch块可以用作默认的catch处理程序。当异常抛出时,会按在代码中的显示顺序查找catch处理程序。下例用catch处理程序显式地处理invalid_argumentruntime_error异常,并用默认的catch处理程序处理其他所有异常。

1
2
3
4
5
6
7
8
9
try {
// Code that can throw exceptions
} catch (const invalid argument& e) {
// Handle invalid_argument exception
} catch (const runtime_errore e) {
// Handle runtime_error exception
} catch (...) {
// Handle all other exceptions
}

未捕获的异常

如果程序抛出的异常没有捕获,程序将终止。可对main()函数使用trycatch结构,以捕获所有未经处理的异常,如下所示:

1
2
3
4
5
try {
main(argc, argv);
} catch (...) {
// issue error message and terminate program
}

然而,这一行为通常并非我们希望的。异常的作用在于给程序一个机会,以处理和修正不希望看到的或不曾预期的情况。

当程序遇到未捕获的异常时,会调用内建的terminate()函数,这个函数调用<stdlib>中的abort()来终止程序。可调用set_terminate()函数设置自己的terminate_handler(),这个函数采用指向回调函数(既没有参数,也没有返回值)的指针作为参数。terminat()set_terminate()terminate_handler()都在<exception>头文件中声明。下面的代码高度概括了其运行原理:

1
2
3
4
5
6
7
8
try {
main(argc, argv);
) catch (...) {
if (terminate_handler != nullptr) {
terminate_handler();
} else {
terminate();
}

回调函数必须终止程序。错误是无法忽略的,然而可在退出之前输出一条有益的错误消息。下例中,main()函数没有捕获readIntegerFile()抛出的异常,而将teminate_handler()设置为自定义回调。这个回调通过调用exit()显示错误消息并终止进程。exit()函数接收返回给操作系统的一个整数,这个整数可用于确定进程的退出方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
void myTerminate() {
cout << "Uncaught exception!" << endl;
exit(1);
}
int main() {
set_terminate (myTerminate);
const string fileName = "IntegerFile.txt";
vector<int> myInts readIntegerFile(fileName);
for (const auto& element :myInts)
cout << element << " ";
cout << endl;
return 0;
}

当设置新的terminate_handler()时,set_terminate()会返回旧的terminate_handler()terminate_handler()被应用于整个程序,因此当需要新terminate_handler()的代码结束后,最好重新设置旧的terminate_handler()

在专门编写的软件中,通常会设置terminate_handler(),在进程结束前创建崩溃转储。此后将崩溃转储上传给调试器,从而允许确定未捕获的异常是什么,起因是什么。

noexcept

使用函数时,可使用noexcept关键字标记函数,指出它不抛出任何异常。

如果一个函数带有noexcept标记,却以某种方式抛出了异常,C++将调用terminate()来终止应用程序。在派生类中重写虚方法时,可将重写的虛方法标记为noexcep,即使基类中的版本不是noexcept。

抛出列表

C++的旧版本允许指定函数或方法可抛出的异常,这种规范叫作抛出列表(throw list)或异常规范(exception specification)。

自C++11之后,已不赞成使用异常规范;自C++17之后,已不再支持异常规范。但noexceptthrow()除外。

自C++11之后,异常规范虽然仍受支持,但已经极少使用。下面的这个readIntegerFile()函数包含了异常规范:

1
vector<int> readIntegerFile(string_view fileName) throw (invalid_argument, runtime_error) { }

如果函数抛出的异常不在异常规范内,C++运行时std:unexpected()默认情况下调用std:teminate()来终止应用程序。

异常与多态性

类是最有用的异常类型。实际上异常类通常具有层次结构,因此在捕获异常时可使用多态性。

标准异常体系

图14-3显了完整的层次结构。

这个层次结构中的每个类都支持what()方法,这个方法返回一个描述异常的const char*字符串。可在错误信息中使用这个字符串。大多数异常类(基类exception是明显的例外)都要求在构造函数中设置what()返回的字符串。readntegerFile()的另一个版本在错误消息中包含文件名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vector<int> readIntegerFile(string_view fileName) {
ifstream inputstream (fileName.data());
if (inputstream.fail()) {
// We failed to open the file: throw an exception
const string error = "Unable to open file "s + fileName.data();
throw invalid_argument (error);
}
// Read the integers one-by-one and add them to a vector
vector<int> integers;
int temp;
while (inputstream >> temp) {
integers.push_back(temp);
}
if (!inputstream.eof()) {
const string error = "Unable to read file "s + fileName.data();
throw runtime_error (error);
}
return integers;

在类层次结构中捕获异常

异常层次结构的一个特性是可利用多态性捕获异常。例如,如果观察main()中调用readIntegerFile()之后的两条catch语句,就可以发现这两条语句除了处理的异常类不同之外没有区别。invalid_argumentruntime_error都是exception的派生类,因此可使用exception类的一条catch语句替换这两条catch 语句:

1
2
3
4
5
6
try {
myInts = readIntegerFile (fileName);
} catch (const exception& e) {
cerr << e.what() << endl ;
return 2;
}

exception引用的catch语句可与exception的任何派生类匹配。

当利用多态性捕获异常时,一定要按引用捕获。如果按值捕获异常,就可能发生截断,在此情况下将丢失对象的信息。

当使用了多条catch子句时,会按在代码中出现的顺序匹配catch子句,第一条匹配的catch子句将被执行。如果某条catch子句比后面的catch子句范围更广,那么这条catch子句首先被匹配,而后面限制更多的catch子句根本不会被执行。因此,catch子句应按限制最多到限制最少的顺序出现。例如,假定要显式捕获readIntegerFile()invalid_argument,就应该让一般的异常与其他类型的异常匹配。正确做法如下所示:

1
2
3
4
5
6
7
8
try {
myInts = readIntegerFile (fileName) ;
} catch (const invalid_argument& e) ( // List the derived class first.
// Take some special action for invalid filename s
} catch (const exception& e) { // Now list exception
cerr << e.what() << endl;
return 1;
}

第一条catch子句捕获invalid_argument异常,第二条catch子句捕获任何类型的其他异常。然而,如果将catch子句的顺序弄反,第一条catch子句会捕获任何派生类类型的异常,第二条catch子句永远无法执行。

编写自己的异常类

编写自己的异常类有两个好处:

  1. C++标准库中的异常数目有限,可在程序中为特定错误创建更有意义的类名
  2. 可在异常中加入自己的信息,而标准层次结构中的异常只允许设置错误字符串

建议自己编写的异常类从标准的exception类直接或间接继承。例如,在readIntegerFile()中,invalid_argumentruntime_error不能很好地捕获文件打开和读取错误。可为文件错误定义自己的错误层次结构,从泛型类FileError开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class FileErrorpublic exception {
public:.
FileError(string_view fileName) :mFileName (fileName) { }
virtual const char* what() const noexcept override {
return mMessage.c_str();
}
string_view getFileName() const noexcept { return mFileName; }

protected:
void setMessage (string_view message) { mMessage = message; }
private:
string mFileName;
string mMessage;
};

编写exception的派生类时,需要重写what()方法,其返回值为一个在对象销毁之前一直有效的const char*字符串。在FileError中,这个字符串来自mMessage数据成员。FileError的派生类可使用受保护的setMessage()方法设置消息。泛型类`FileError还包含文件名以及文件名的公共访问器。

在编写将其对象用作异常的类时,有一个诀窍。当某段代码抛出一个异常时,使用移动构造函数或复制构造函数,移动或复制被抛出的值或对象。因此,如果编写的类的对象将作为异常抛出,对象必须复制和或移动。这意味着如果动态分配了内存,就必须编写析构函数、复制构造函数、复制赋值运算符和或移动构造函数与移动赋值运算符。

作为异常抛出的对象至少要复制或移动一次。异常可能被复制多次,但只有按值(而不是按引用)捕获异常才会如此。

按引用(最好是const 引用)捕获异常对象可避免不必要的复制。

嵌套异常

当处理第一个异常时,可能触发第二个异常,从而要求抛出第二个异常。遗憾的是,当抛出第二个异常时,正在处理的第一个异常的所有信息都会丢失。C++用嵌套异常(nested exception)提供了解决这一问题的方案,嵌套异常允许将捕获的异常嵌套到新的异常环境。

使用std::throw_with_nested()抛出一个异常时,这个异常中嵌套着另一个异常。第二个异常的catch处理程序可使用dynamic_cast()访问代表第一个异常的nested_exception。下例演示了嵌套异常的用法。这个示例定义了一个从exception类派生的MyException类,其构造函数接收一个字符串。

1
2
3
4
5
6
7
8
9
class MyExceptionpublic std::exception {
public:
MyException(string_view message) : mMessage (message) {}
virtual const char* what() const noexcept override {
return mMessage.c_str();
}
private:
string mMessage;
};

当处理第一个异常且需要抛出嵌套了第一个异常的第二个异常时,需要使用std::throw_with_nested()函数。下面的doSomething()函数抛出一个runtime_error异常,这个异常立即被catch处理程序捕获。catch 处理程序编写了一条消息,然后使用std::throw_with_nested()函数抛出第二个异常,第一个异常嵌套在其中。注意嵌套异常是自动实现的。

1
2
3
4
5
6
7
8
void doSomething() {
try {
throw runtime_error("Throwing a runtime_error exception");
} catch (const runtime_error& e) {
cout << "caught a runtime_error" << endl;
throw_with_nested(MyException ("MyException with std::throw_with_nested()"));
}
}

当捕获到这类异常时,会编写一条消息,然后使用dynamic_cast()访问嵌套的异常。如果内部没有嵌套异常,结果为空指针。如果有嵌套异常,会调用nested_exception
rethrow_nested()方法。这样会再次抛出嵌套异常,这一异常可在另一个try/catch块中捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main() {
try {
doSomething();
} catch (const MyException& e) {
cout << "caught MyException:" << e.what() << endl;
const auto* pNested = dynamic_cast<const nested_exception*>(&e);
if (pNested) {
try {
pNested->rethrow_nested();
} catch(const runtime_error& e) {
cout << e.what() << endl;
}
}
}
return 0;
}

前面的main()函数使用dynamic_cast()检测嵌套异常。如果想要检测嵌套异常,就不得不经常执行dynamic_cast(),因此标准库提供了一个名为std::rethrow_if_nested()的小型辅助函数,其用法如下所示:

1
2
3
4
5
6
7
8
try {
doSomething();
} catch (const MyException& e) {
rethrow_if_nested(e);
} catch (const runtime_error& e) {
cout << "Nested exception:"<< e.what() << endl;
}
return 0;

重新抛出异常

可使用throw关键字重新抛出当前异常,如下例所示:

1
2
3
4
5
6
7
8
9
void g() { throw invalid_argument ("Some exception"); }
void f() {
try {
g();
} catch (const invalid_argument& e) {
cout << "caught in f:" << e.what() << endl;
throw; // rethrow
}
}

始终使用throw;重新抛出异常。永远不要试图用throw e;重新抛出e,因为这样存在潜在的截断风险,可能丢失信息。

堆栈的释放与清理

当发现catch处理程序时,堆栈会释放所有中间堆栈帧,直接跳到定义catch处理程序的堆栈层。堆栈释放(stack unwinding)意味着调用所有具有局部作用域的名称的析构函数,并忽略在当前执行点之前的每个函数中所有的代码。然而当释放堆栈时,并不释放指针变量,也不会执行其他清理。在C++中,应该用基于堆栈的内存分配或者下面将要讨论的技术处理这种情况。

使用智能指针

如果基于堆栈的内存分配不可用,就应使用智能指针。在处理异常时,智能指针可使编写的代码自动防止内存或资源的泄漏。无论什么时候销毁智能指针对象,都会释放底层资源。下 面使用了智能指针unique_ptr

1
2
3
4
5
void funcOne() {
string strl;
auto str2 = make_unique<string> ("hello");
funcTwo();
}

当从funcOne()返回或抛出异常时,将自动删除str2指针。

捕获、清理并重新抛出

避免内存和资源泄漏的另一种技术是针对每个函数,捕获可能抛出的所有异常,执行必要的清理,并重新抛出异常,供堆栈中更高层的函数处理。下面是使用这一技术修改后的funcOne()函数:

1
2
3
4
5
6
7
8
9
10
11
void funcOne () {
string str1;
string* str2 = new string();
try {
funcTwo () ;
} catch (...) {
delete str2;
throw; // Rethrow the exception.
}
delete str2;
}

这个函数用异常处理程序封装对funcTwo()函数的调用,处理程序执行清理(在str2上调用delete)并重新抛出异常。关键字throw本身会重新抛出最近捕获的任何异常。注意catch语句使用…语法捕获所有异常。这一方法运行良好,但有点繁杂。需要特别注意,现在有两行完全相同的代码在str2上调用delete

常见的错误处理问题

内存分配错误

如果无法分配内存,newnew[]的默认行为是抛出bad_alloc类型的异常,这种异常类型在<new>头文件中定义。代码应该捕获并正确地处理这些异常。

不可能把对newnew[]的调用都放在try/catch块中,但至少在分配大块内存时应这么做。下例演示了如何捕获内存分配异常:

1
2
3
4
5
6
7
8
int* ptr = nullptr;
size_t integerCount = numeric_limits<size_t>::max();
try {
ptr = new int [integerCount];
} catch (const bad_alloc& e) {
cerr << "Unable to allocate memory:" << e.what() << endl;
return;
}

另一个考虑是记录错误时可能尝试分配内存。如果new执行失败,可能没有记录错误消息的足够内存。

不抛出异常的new

旧的C模式下,如果无法分配内存,内存分配例程将返回一个空指针。C++提供了newnew[]nothrow版本,如果内存分配失败,将返回nullptr,而不是抛出异常。使用语
new(nothrow)而不是new可做到这一点,如下所示:

1
2
3
int* ptr = new (nothrow) int [integerCount];
if (ptr == nullptr)
cerr << "Unable to allocate memory!" << endl;

定制内存分配失败的行为

C++允许指定new handler回调函数。默认情况下不存在new handler,因此newnew[]只是抛出bad_alloc异常。然而如果存在new handler,当内存分配失败时,内存分配例程会调用new handler而不是抛出异常。如果new handler返回,内存分配例程试着再次分配内存:如果失败,会再次调用new handler。这个循环变成无限循环,除非new handler用下面的3个选项之一改变这种情况。下面列出这些选项,并给出了注释:

  • 提供更多的可用内存 提供空间的技巧之一是在程序启动时分配一大块内存,然后在new handler中释放这块内存。关键在于,在程序启动时,分配一块足以完整保存文档的内存。当触发new handler时,可释放这块内存、保存文档、重启应用程序并重新加载保存的文档。
  • 抛出异常 C++标准指出,如果new handler抛出异常,那么必须是bad_alloc异常或者派生于bad_alloc的异常。
    • 可编写和抛出document_recovery_alloc异常,这种异常从bad_alloc继承而来。可在应用程序的某个地方捕获这种异常,然后触发文档保存操作,并重启应用程序。
    • 可编写和抛出派生于bad_allocplease_terminate_me异常。在顶层函数中可捕获这种异常,并通过从顶层函数返回来对其进行处理。
  • 设置不同的new handler 从理论上讲,可使用一系列 new handler,每个都试图分配内存,并在失败时设置一个不同的new handler。然而,这种情形通常过于复杂,并不实用。

如果在new handler中没有这么做,任何内存分配失败都会导致无限循环。

如果有一些内存分配会失败,但又不想调用new handler,那么在调用new之前,只需要临时将新的new handler重新设置为默认值nullptr。

调用在<new>头文件中声明的set_new_handler(),从而设置new handler

1
2
3
4
5
class please_terminate_mepublic bad_alloc { };
void myNewHandler () {
cerr << "Unable to allocate memory." << endl;
throw please_terminate_me();
}

new handler 不能有参数,也不能返回值。如前面列表中的第2个选项所述,new handler 抛出please_terminate_me异常。可采用以下方式设置new handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main()
try {
// Set the new new_handler and save the old one.
new_handler oldHandler = set_new_handler (myNewHandler);
// Generate allocation error.
size_t numInts = numeric_limits<size_t>::max();
int* ptr = new int [numInts];
// Reset the old new_handler
set_new_handler(oldHandler);
} catch (const please_terminate_me&) {
cerr << "Terminating program." << endl;
return 1;
}
return 0;
}

注意new_handler是函数指针类型的typedef,set_new_handler()会将其作为参数。

构造函数中的错误

虽然无法在构造函数中返回值,但是可以抛出异常。通过异常可很方便地告诉客户是否成功创建了对象。在异常离开构造函数前,必须在构造函数中仔细清理所有资源,并释放分配的所有内存。本节以Matix类作为示例,这个类的构造函数可正确处理异常。

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
class Matrix {
public:
Matrix(size_t width, size_t height);
virtual ~Matrix();
private :
void cleanup();
size_t mWidth = 0;
size_t mHeight = 0;
T** mMatrix = nullptr;
}

Matrix类的实现如下所示。注意:

  • 对new的第一个调用并没有用try/catch块保护。第一个new抛出异常也没有关系,因为构造函数此时还没有分配任何需要释放的内存。如果后面的new抛出异常,构造函数必须清理所有已经分配的内存。
  • 由于不知道T构造函数本身会抛出什么异常,因此用…捕获所有异常,并将捕获的异常嵌套在bad_alloc异常中。
  • 使用{}语法,通过首次调用new分配的数组执行零初始化,即每个元素都是nullptr。这简化了cleanup()方法,因为允许它在nullptr上调用delete。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
template <typename T>
Matrix<T>::Matrix(size_t width, size_t height) {
mMatrix = new T* [width] {}; // Array is zero-initialized!
mwidth = width;
mHeight = height;

try {
for(size_t i = 0; i < width; ++i) {
mMatrix[i] = new T[height];
}
} catch (...) {
std::cerr << "Exception caught in constructor, cleaning up..." << std::endl;
cleanup();
// Nest any caught exception inside a bad_alloc exception .
std::throw_with_nested(std::bad_alloc());
}
}

template <typename T>
Matrix<T>::~Matrix() {
cleanup();
}

template <typename T>
void Matrix<T>::cleanup() {
for(size_t i = 0; i < mWidth; ++i)
delete[]] mMatrix[i];
delete[] mMatrix;
mMatrix = nullptr;
mWidth = mHeight = 0;
}

如果异常离开了构造函数,将永远不会调用对象的析构函数!

构造函数的function-try-blocks

function-try-blocks用于普通函数和构造函数。本节重点介绍functin-try-blocks如何用于构造函数。下面的伪代码显示了构造函数的function-try-blocks的基本语法:

1
2
3
4
5
6
7
MyClass::MyClass()
try
: <ctor-initializer>
{ /* constructor body ... */
} catch (const exception& e) {
/* ... */
}

可以看出,try关键字应该刚好在ctor itializer之前。catch 语句应该在构造函数的右花括号之后,实际上是将catch语句放在构造函数体的外部。当使用构造函数的function-try-blocks时,要记住如下限制和指导方针:

  • catch语句将捕获任何异常,无论是构造函数体还是ctor-intializer直接或间接抛出的异常,都是如此。
  • catch语句必须重新抛出当前异常或抛出一个新异常。如果catch语句没有这么做,运行时将自动重新拋出当前异常。
  • atch语句可访问传递给构造函数的参数。
  • 当catch语句捕获function-tryblocks内的异常时,构造函数已构建的所有对象都会在执行catch 语句之前销毁。
  • 在catch语句中,不应访问对象成员变量,因为它们在执行catch语句前就销毁了。 但是,如果对象包含非类数据成员,例如裸指针,并且它们在抛出异常之前初始化,就可以访问它们。如果有这样的裸资源,就必须在catch语句中释放它们。
  • 对于functio-try-blocks中的catch语句而言,其中包含的函数不能使用return关键字返回值。构造函数与此无关,因为构造函数没有返回值。

由于有以上限制,构造函数的function-try-blocks 只在少数情况下有用:

  • 将ctor-intializer抛出的异常转换为其他异常。
  • 将消息记录到日志文件。
  • 释放在抛出异常之前就在ctor intializer 中分配了内存的裸资源。

下例演示function-try-blocks的用法

1
2
3
4
5
6
7
8
class Subobject {
public:
Subobject(int i);
};

Subobject::Subobject(int i) {
throw std::runtime_error ("Exception by Subobject ctor");
}

MyClass类有一个int*类型的成员变量以及一个SubObject类型的成员变量:

1
2
3
4
5
6
7
class MyClass {
public:
MyClass();
private:
int* mData = nullptr;
Subobject mSubobject;
};

SubObject 类没有默认构造函数。这意味着需要在MyClass类的ctor-intializer中初始化mSubObject。MyClass类的构造函数将使用function-try-blocks 捕获ctor-intializer 中抛出的异常。

1
2
3
4
5
6
7
8
9
MyClass::MyClass ()
try
: mData(new int[42]{ 1, 2, 3 }), mSubobject (42) {
/* ... constructor body ... */
} catch (const std::exception6 e) {
// Cleanup memory.
delete[] mData;
mData = nullptr;
}

记住,构造函数的function-try-blocks中的catch语句必须重新抛出当前异常,或者抛出新异常。前面的catch语句没有抛出任何异常,因此C++运行时将自动重新抛出当前异常。下面的简单函数使用了前面的类:
1
2
3
4
5
6
7
8
int main()
try {
MyClass m;
} catch (const std::exception& e) {
cout << "main() caught: " << e.what() << endl;
}
return 0;
}

通常,仅将裸资源作为数据成员时,才有必要使用function-try-blocks. 可使用诸如std::unique_ptr的RAII类来避免使用裸资源。

析构函数中的错误

必须在析构函数内部处理析构函数引起的所有错误。不应该让析构函数抛出任何异常,原因如下:

  1. 析构函数会被隐式标记为noexcept,除非添加了noexcept(false)标记,或者类具有子对象,而子对象的析构函数是noexcept(false)。如果带noexcept标记的析构函数抛出一个异常, C++运行时会调用std::teminate()来终止应用程序。
  2. 在堆栈释放过程中,如果存在另一个挂起的异常,析构函数可以运行。如果在堆栈释放期间从析构函数抛出一个异常,C++运行时会调用std::terminate()来终止应用程序。<exception>头文件中声明了一个函数uncaught_exception(),该函数可返回未捕获异常的数量;所谓未捕获异常,是指已经抛出但尚未到达匹配catch的异常。如果uncaught_exceptions()的结果大于0,则说明正在执行堆栈释放。
  3. 客户不会显式调用析构函数:客户调用delete,delete调用析构函数。如果在析构函数中抛出一个异常,客户无法在此使用对象调用delete,也不能显式地调用析构函数。
  4. 析构函数是释放对象使用的内存和资源的一个机会。如果因为异常而提前退出这个函数,就会浪费这个机会,将永远无法回头释放内存或资源。

C++运算符重载

运算符重载概述

重载运算符的原因

基本指导原则是:为了让自定义类的行为和内建类型一样。自定义类的行为越接近内建类型,就越便于这些类的客户使用。

运算符重载的限制

下面列出了重载运算符时不能做的事情:

  • 不能添加新的运算符。只能重定义语言中已经存在的运算符的意义。
  • 有少数运算符不能重载,例如.(对象成员访问运算符)、:(作用域解析运算符)、sizeof?(条件运算符)以及其他几个运算符。
  • arity描述了运算符关联的参数或操作数的数量。只能修改函数调用、new和delete运算符的arity。其他运算符的arity不能修改。
  • 不能修改运算符的优先级和结合性。这些规则确定了运算符在语句中的求值顺序。
  • 不能对内建类型重定义运算符。运算符必须是类中的一个方法,或者全局重载运算符函数至少有一个参数必须是一种用户定义的类型(例如一个类)。

运算符重载的选择

重载运算符时,需要编写名为operatorX的函数或方法,X是表示这个运算符的符号。例如:

1
SpreadsheetCell operator+(const SpreadsheetCells lhs, const SpreadsheetCells rhs);

方法还是全局函数

当运算符是类的方法时,运算符表达式的左侧必须是这个类的对象。当编写全局函数时,运算符表达式的左侧可以是不同类型的对象。有3种不同类型的运算符:

  • 必须为方法的运算符:C++语言要求一些运算符必须是类中的方法,因为这些运算符在类的外部没有意义。例如,operator-和类绑定得非常紧密,不能出现在其他地方。大部分运算符都没有施加这种要求。
  • 必须为全局函数的运算符:如果允许运算符左侧的变量是除了自定义的类之外的任何类型,那么必须将这个运算符定义为全局函数。确切地讲,这条规则适用于operator<<operator>>,这两个运算符的左侧是iostream对象,而不是自定义类的对象。此外,可交换的运算符(例如二元的+和-)允许运算符左侧的变量不是自定义类的对象。
  • 既可为方法又可为全局函数的运算符,建议遵循如下规则:把所有运算符都定义为方法,除非根据以上描述必须定义为全局函数。这条规则的一个主要优点是,方法可以是虚方法,但全局函数不能是虚函数。因此,如果准备在继承树中编写重载的运算符,应尽可能将这些运算符定义为方法。

将重载的运算符定义为方法时,如果这个运算符不修改对象,应将整个方法标记为const。这样,就可对const对象调用这个方法。

选择参数类型

参数类型的选择有一些限制,因为如前所述,大多数运算符不能修改参数数量。真正需要选择的地方在于判断是按值还是按引用接收参数,以及是否需要把参数标记为const。

按值传递还是按引用传递的决策很简单:应按引用接收每一个非基本类型的参数

const决策也很简单:除非要真正修改参数,否则将每个参数都设置为const

选择返回类型

应该让运算符返回的类型和运算符对内建类型操作时返回的类型一样。如果编写比较运算符,那么应该返回bool类型。如果编写算术运算符,那么应该返回表示运算结果的对象。返回值还是引用的一般原则是:如果可以,就返回一个引用,否则返回一个值:如果运算符构造了一个新对象,那么必须按值返回这个新对象。如果不构造新对象,可返回对调用这个运算符的对象的引用,或返回对其中一个参数的引用。

可作为左值(赋值表达式左侧的部分)修改的返回值必须是非const。否则,这个值应该是const。大部分很容易想到的运算符都要求返回左值,包括所有赋值运算符(operator-、operator+-和operator-等)。

不应重载的运算符

有些运算符即使允许重载,也不应该重载。具体来说,取地址运算符operator&的重载一般没什么特别的用途,如果重载会导致混乱,因为这样做会以可能异常的方式修改基础语言的行为(获得变量的地址)。整个标准库大量使用了运算符重载,但从没有重载取地址运算符。

可重载运算符小结

在表15-1中,T表示要编写重载运算符的类名,E是一种不同的类型。


右值引用

表15-1列出的普通赋值运算符的原型如下所示:

1
T& operator=(const T&);

移动赋值运算符的原型几乎一致,但使用了右值引用。这个运算符会修改参数,因此不能传递const参数。

1
T& operator=(T&&);

表15-1没有包含右值引用语义的示例原型。然而,对于大部分运算符来说,编写一个使用普通左值引用的版本以及一个使用右值引用的版本都是有意义的,但是否真正有意义取决于类的实现细节。标准库中的std:string类利用右值引用实现了operator+,如下所示:

1
string operator+(string&& lhs, string&& rhs);

这个运算符的实现会重用其中一个参数的内存,因为这些参数是以右值引用传递的。也就是说,这两个参数表示的都是operator+完成之后销毁的临时对象。上述operator+的实现具有以下效果:

1
return std::move(lhs.append(rhs));


1
return std::move(ths.insert(0, lhs));

事实上,std::string定义了几个具有不同左值引用和右值引用组合的重载的operator+运算符。下面列出std::string中所有接收两个字符串参数的operator+运算符

1
2
3
4
string operator+(const string& lhs, const string& rhs);
string operator+(string&& lhs, const string& rhs);
string operator+(const string& lhs, string&& rhs);
string operator+(string&& lhs, string&& rhs);

关系运算符

C++标准库有一个方便的<utility>头文件,它包含几个辅助函数和类,还在std::rel_ops名称空间中给关系运算符包含如下函数模板:

1
2
3
4
template<class T> bool operator!=(const T& a, const T& b);//Needs operator==
template<class T> bool operator>(const T& a, const T& b); //Needs operator<
template<class T> bool operator<=(const T& a, const T& b);//Needs operator<
template<class T> bool operator>=(const T& a, const T& b);//Needs operator<

这些函数模板根据==<运算符给任意类定义了运算符!=><=>=。如果在类中实现operator--operator<,就会通过这些模板自动获得其他关系运算符。只要添加#include <utility>和下面的using声明,就可将这些运算符用于自己的类:

1
using namespace std::rel_ops;

但是,这种技术带来的一个问题在于,现在可能为用于关系操作的所有类(而非只为自己的类)创建这些运算符。还有一个问题是隐式转换不可行。

重载算术运算符

重载一元负号和一元正号运算符

C++有几个一元算术运算符。一元负号和一元正号运算符是其中的两个。这些运算符不改变调用它们的对象,所以应把它们标记为const。下例将一元operator-运算符重载为SpreadsheetCell类的成员函数。

1
2
3
SpreadsheetCell SpreadsheetCell::operator-() const {
return SpreadsheetCell (-getValue());
}

operator-没有修改操作数,因此这个方法必须构造一个新的带有相反值的SpreadsheetCell对象,并返回这个对象的副本。因此,这个运算符不能返回引用。可按以下方式使用这个运算符:

1
2
SpreadsheetCell c1(4);
SpreadsheetCell c3 = -c1;

重载递增和递减运算符

可采用4种方法给变量加1:

1
2
3
4
i = i + 1;
i += 1;
++ i;
i ++;

后两种称为递增运算符。第一种形式是前缀递增,这个操作将变量增加1,然后返回增加后的新值,供表达式的其他部分使用。第二种形式是后缀递增,返回旧值(增加之前的值),供表达式的其他部分使用。递减运算符的功能类似。

operator++operator--的双重意义给重载带来了问题,C++引入了一种方法来区分前后缀:前缀版本的operator++operator--不接收参数,而后缀版本的接收一个不使用的int类型参数。如果要为SpreadsheetCell类重载这些运算符,原型如下所示:

1
2
3
4
SpreadsheetCell& operator++(); // Prefix
Spreadsheetcell operator++(int); // Postfix
SpreadsheetCell& operator--(); // Prefix
SpreadsheetCell operator--(int); // Postfix

前缀形式的结果值和操作数的最终值一致,因此前缀递增和前缀递减返回被调用对象的引用。然而后缀版本的递增操作和递减操作返回的结果值和操作数的最终值不同,因此不能返回引用。下面是operator++运算符的实现:

1
2
3
4
5
6
7
8
9
10
SpreadsheetCell& SpreadsheetCell::operator++() {
set(getValue() + 1);
return *this;
}

SpreadsheetCell SpreadsheetCell::operator++(int) {
auto oldCell(*this); // Save current value
++(*this); // Increment using prefix + 1
return oldCell; // Return the old value
}

递增和递减还能应用于指针。当编写的类是智能指针或迭代器时,可重载operator++operator--,以提供指针的递增和递减操作。

重载按位运算符和二元逻辑运算符

按位运算符和算术运算符类似,简写的按位赋值运算符也和简写的算术赋值运算符类似。逻辑运算符要困难一些。建议不要重载&&和||。这些运算符并不应用于单个类型,而是整合布尔表达式的结果。此外,重载这些运算符会失去短路求值,原因是在将运算符左侧和右侧的值绑定至重载的&&和运算符之前,必须对运算符的左侧和右侧进行求值。因此,一般对特定类型重载这些运算符都没有意义。

重载插入运算符和提取运算符

在编写插入和提取运算符前,需要决定如何将自定义的类向流输出,以及如何从流中提取自定义的类。在这个例子中,SpreadsheetCell将读取和写入double值。插入和提取运算符左侧的对象是istreamostream(例如cincout),而不是SpreadsheetCell对象。由于不能向istream类或ostream类添加方法,因此应将插入和提取运算符写为SpreadsheetCell类的全局函数。这些函数在SpreadsheetCell类中的声明如下所示:

1
2
3
4
5
class SpreadsheetCell{

};
std::ostream& operator<<(std::ostream& ostr, const SpreadsheetCell& cell);
std::istream& operator>>(std::istream& istr, SpreadsheetCell& cell);

将插入运算符的第一个参数设置为ostream的引用,这个运算符就能应用于文件输出流、字符串输出流、cout、cerr和clog等。与此类似,将提取运算符的参数设置为istream的引用,这个运算符就能应用于文件输入流、字符串输入流和cin。

operator<<operator>>的第二个参数是对要写入或读取的SpreadsheetCell对象的引用。插入运算符不会修改写入的SpreadsheetCell对象,因此这个引用可以是const引用。然而提取运算符会修改SpreadsheetCell对象,因此要求这个参数为非const引用。

这两个运算符返回的都是第一个参数传入的流的引用,所以这两个运算符的调用可以嵌套。记住,运算符的语法实际上是显式调用全局operator>>函数或operator<<函数的简写形式。 例如下面这行代码:

1
cin >> myCell >> anotherCell >> aThirdCell;

实际上是如下代码行的简写形式:
1
operator>>(operator>>(operator>>(cin, myCell), anotherCell), aThirdCell);

从中可以看出,第一次调用operator>>的返回值被用作下一次调用的输入值。因此必须返回流的引用,结果才能用于下一次嵌套的调用。否则嵌套调用无法编译。

重载下标运算符

一个动态分配的数组类允许设置和获取指定索引位置的元素,并会自动完成所有的内存分配操作,定义可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
class Array {
public:
Array();
virtual ~Array();
Array<T>& operator=(const Array<T>& rhs) = delete;
Array(const Array<T>& src) = delete;
const T& getElementAt(size_t x) const;
void setElementat(size_t x, const T& value):
private:
size_t getsize() const;
static const size_t kAllocSize = 4;
void resize(size_t newSize);
T* mElements = nullptr;
size_t mSize = 0;
};

这个接口支持设置和访问元素。它为随机访问提供了保证:客户可创建数组,并设置元素1、100和1000,而不必考虑内存管理问题。下面是这些方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T> Array<T>::Array() {
mSize = kAllocSize;
mElements = new T[mSize] {};// Elements are zero-initialized!
}
template <typename T> Array<T>::~Array() {
delete [] mElements;
mElements = nullptr;
}
template <typename T> void Array<T>::resize(size_t newSize) {
auto newArray = std::make_unique<T[]>(newSize);
for(size_t i = 0; i < mSize; i ++) {
newArray[i] = mElements[i];
}
delete[] mElements;
mSize = newSize;
mElements = newArray.release();
}

resize()首先创建一个适当大小的新数组,将其存储在unique_ptr中。然后将所有元素从旧数组复制到新数组。如果在复制值时任何地方出错,unique_ptr会自动清理内存。最后,在成功分配新数组和复制所有元素后,即未抛出异常,我们才删除旧的mElements数组,并为其指定新数组。最后一行必须使用release()来释放unique_ptr的新数组的所有权,否则,在调用unique_ptr的析构函数时,将销毁这个数组。

通过以下方式给类添加operator[]

1
2
3
4
5
template <typename T> T& Array<T>::operator[](size_t x) {
if (x >= mSize)
resize(x + kAllocSize);
return mElements[x];
}

operator[]可设置和获取元素,因为它返回的是一个对位置x处的元素的引用。可通过这个引用对这个元素赋值。当operator[]用在赋值语句的左侧时,赋值操作实际上修改了mElements数组中位置x处的值。

通过operator[]提供只读访问

理想情况下,可提供两个operator[]:一个返回引用,另一个返回const引用。为此,编写下面这样的代码:

1
2
3
4
5
template <typename T> const T& Array<T>::operator[](size_t x) const {
if (x >= mSize)
throw std::out_of_range("");
return mElements[x];
}

为const对象调用const operator[],因此无法增加数组大小。当给定索引越界时,当前实现抛出异常。另一种做法是返回零初始化值而非抛出零初始化元素。代码如下:

1
2
3
4
5
6
7
template <typename T> const T& Array<T>::operator[](size_t x) const {
if (x >= mSize) {
static T nullValue = T();
return.nullValue;
}
return mElements [x];
}

重载函数调用运算符

C++允许重载函数调用运算符,写作operator()。如果在自定义的类中编写了一个operator(),那么这个类的对象就可以当成函数指针使用。包含函数调用运算符的类对象称为函数对象,或简称为仿函数(functor)。只能将这个运算符重载为类中的非静态方法。下例是一个简单的类,它带有一个重载的operator()以及一个具有相同行为的类方法:

1
2
3
4
5
6
7
8
9
class FunctionObject {
public:
int operator() (int param); // Function call operator
int dosquare(int param) {return param * param; }
};

int FunctionObject::operator() (int param) {
return doSquare(param);
}

下面是使用函数调用运算符的代码示例:

1
2
3
4
int x = 3, xSquared, xSquaredAgain;
Functionobject square;
xSquared = square(x);
xSquaredAgain = square.doSquare (x);

相比标准的对象方法,函数对象的好处很简单:这些对象有时可以伪装成函数指针,可将这些函数对象当成回调函数传给其他函数

相比全局函数,函数对象的好处较为复杂。有两个主要好处:

  • 对象可在函数对象运算符的重复调用之间,在数据成员中保存信息。
  • 可通过设置数据成员来自定义函数对象的行为。

当然,通过全局变量或静态变量都可实现上述任何好处。然而,函数对象提供了一种更简洁的方式,而使用全局变量或静态变量在多线程应用程序中可能会产生问题。

函数调用运算符还可用于提供多维数组的下标。只要编写一个行为类似于operator[],但接收多个索引的operator()即可。这项技术的唯一问题是需要使用()而不是[]进行索引,例如myArray(3,4)-6。

重载解除引用运算符

可重载3个解除引用运算符:*->->*,只考虑*->的原始意义。*解除对指针的引用,允许直接访问这个指针指向的值,->是使用*解除引用之后再执行成员选择操作的简写。

在类中重载解除引用运算符,可使这个类的对象行为和指针一致。这种功能的主要用途是实现智能指针。还能用于标准库广泛使用的迭代器。

实现operator*

当解除对指针的引用时,经常希望能访问这个指针指向的内存:operator*应该返回一个引用:

1
2
3
4
5
6
template<typename T> T& Pointer<T>::operator*() {
return *mPtr;
}
template<typename T> T& Pointer<T>::operator*() const {
return *mPtr;
}

从这个例子可看出,operator*返回的是底层普通指针指向的对象或变量的引用。与重载下标运算符一样,同时提供方法的const版本和非const版本也很有用。

实现operator->

箭头运算符稍微复杂一些。应用箭头运算符的结果应该是对象的成员或方法。为实现这一点,应该能够实现operator*operator,而C++有充足的理由不允许重载operator.:不可能编写单个原型来捕捉任何可能选择的成员或方法。因此,C++将operator->当成一种特例。例如下面这行代码:

1
smartCell->set(5);

C++将这行代码解释为:
1
(smartCell.operator->())->set(5);

从中可看出,C++给重载的operator->返回的任何结果应用了另一个operator->。因此,必须返回一个指向对象的指针,如下所示:

1
2
3
4
5
6
7
8
9
10
11
template <typename T> class Pointer
public:
T* operator->();
const T* operator->() const;
};
template <typename T> T* Pointer<T>::operator->() {
return mPtr;
}
template <typename T> const T* Pointer<T>::operator->() const {
return mPtr;
}

operator.和operator->的含义

在C++中,不能在没有对象的情况下访问非静态数据成员或调用非静态方法。通过指针调用方法或访问数据成员时,必须在对象的上下文中解除对指针的引用。下例演示了这点。

1
2
3
SpreadsheetCell myCell;
double (Spreadsheetcell::*methodPtr) () const = &SpreadsheetCell::getValue();
cout << (mycell.*methodPtr) () << endl;

注意,.*运算符解除对方法指针的引用并调用这个方法。如果有一个指向对象的指针而不是对象本身,那么还有一个等效的operator->*可通过指针调用方法。这个运算符如下所示:

1
2
3
SpreadsheetCell* myCell = new SpreadsheetCell();
double (Spreadsheetcell::*methodPtr)() const = &spreadsheetCell::getValue;
cout << (myCell->*methodPtr)() <<endl;

编写转换运算符

可编写一个将SpreadsheetCell转换为double类型的转换运算符。原型如下所示:

1
operator double() const;

函数名为operator double。它没有返回类型,因为返回类型是通过运算符的名称确定的:double。这个函数是const,因为这个函数不会修改被调用的对象。实现如下所示:

1
SpreadsheetCell::operator double() const { return getValue(); }

使用显式转换运算符解决多义性问题

注意,为SpreadsheetCell对象编写double转换运算符时会引入多义性问题。例如下面这行加粗代码:

1
2
SpreadsheetCell cell(1.23);
double d2 = cell + 3.3;

现在这行代码无法成功编译。编译器不知道应该通过operator double()将cell对象转换为double类型,再执行double加法,还是通过double构造函数将3.3转换为SpreadsheetCell,再执行SpreadsheetCell加法。

在C++11之前,通常解决这个难题的方法是将构造函数标记为explicit,以避免使用这个构造函数进行自动转换。自C++11以后,可将double类型转换运算符标记为explicit以解决这个问题。下面的代码演示了这种方法的应用:

1
2
3
SpreadsheetCell cell = 6.6;
double d1 = static_cast<double>(cell);
double d2 = static_cast<double>(cell + 3.3);

下面解释上述代码中的各行:

  1. 使用隐式类型转换从double转换到SpreadsheetCell。由于是在声明中,因此这是通过调用接收double参数的构造函数进行的。
  2. 使用operator double()转换运算符。注意,由于这个转换运算符现在声明为explicit,因此要求进行强制类型转换。
  3. 通过隐式类型转换将3.3转换为SpreadsheetCell,再进行两个SpreadsheetCell对象的operator+操作,之后进行必要的显式类型转换以调用operator double()

重载内存分配和内存释放运算符

new和delete的工作原理

考虑下面这行代码:

1
SpreadsheetCell* cell = new SpreadsheetCell();

new SpreadsheetCell()这部分称为new表达式。它完成了两件事情。首先,通过调用operator new为SpreadsheetCell对象分配了空间。然后,为这个对象调用构造函数。只有这个构造函数完成了,才返回指针。

delete的工作方式与此类似。考虑下面这行代码:

1
delete cell;

这一行称为delete表达式。它首先调用cell的析构函数,然后调用operator delete来释放内存。可重载operator newoperator delete来控制内存的分配和释放,但不能重载new表达式和delete表达式。

new表达式和operator new

有6种不同形式的new表达式,每种形式都有对应的operator new。前面的章节已经展示了4种new表达式:newnew[]new(nothrow)new(nothrow) []。下面列出了<new>头文件中对应的4种operator new形式:

1
2
3
4
void* operator new(size_t size);
void* operator new[](size_t size);
void* operator new(size_t size, const std::nothrow_t&)noexcept;
void* operator new[](size_t size, const std::nothrow_t&) noexcept;

有两种特殊的new表达式,它们不进行内存分配,而在已有存储段上调用构造函数。这种操作称为placement new运算符。它们在已有的内存中构造对象,如下所示:

1
2
void* ptr = allocateMemorySomehow();
spreadsheetCell* cell = new (ptr) SpreadsheetCe11();

这个特性有点儿偏门,但知道这项特性的存在非常重要。如果需要实现内存池,以便在不释放内存的情况下重用内存,这项特性就非常方便。对应的operator new形式如下,但C++标准禁止重载它们:

1
2
void* operator new(size_t size, void* p) noexcept;
void* operator new[](size_t size, void* p) noexcept;

delete表达式和operator delete

只可调用两种不同形式的delete表达式:deletedelete[],没有nothrow和placement形式。然而,operator delete有6种形式,nothrow和placement形式只有在构造函数抛出异常时才会使用。

这种情况下,匹配调用构造函数之前分配内存时使用的operator newoperator delete会被调用。然而,如果正常地删除指针,delete会调用operator deleteoperator delete[],绝不会调用nothrow或placement形式。C++标准指出,从delete抛出异常的行为是未定义的。也就是说,delete永远都不应该抛出异常。因此nothrow版本的operator delete是多余的;而placement版本的delete应该是一个空操作,因为在placement new中并没有分配内存,因此也不需要释放内存。

更有用的技术是重载特定类的operator newoperator delete。仅当分配或释放特定类的对象时,才会调用这些重载的运算符。下面这个类重载了4个非placement形式的operator newoperator delete

1
2
3
4
5
6
7
8
9
10
11
class MemoryDemo {
public:
void* operator new(size_t size);
void operator delete(voId* ptr) noexcept;
void* operator new[](size_t size);
void operator delete[](void* ptr) noexcept;
void* operator new(size_t size, const std::nothrow_t&) noexcept;
void operator delete(void* ptr, const std::nothrow_t&) noexcept;
void* operator new[](size_t size, const std::nothrow_t&) noexcept;
void operator delete[](void* ptr, const std::nothrow_t&) noexcept;
};

当重载operator new时,要重载对应形式的operator delete。否则,内存会根据指定的方式分配,但是根据内建的语义释放,这两者可能不兼容。

显式地删除/默认化operator new和operator delete

下面的类删除了operator newnew[]。也就是说,这个类不能通过newnew[]动态创建:

1
2
3
4
5
class MyClass {
public:
void*operator new(size_t size) = delete;
void* operator new[](size_t size) = delete;
};

重载带有额外参数的operator new和operator delete

除了重载标准形式的operator new外,还可编写带额外参数的版本。这些额外参数可用于向内存分配例程传递各种标志或计数器。例如,一些运行时库在调试模式中使用这种形式。下面是MemoryDemo类中带有额外整数参数的operator newoperator delete原型:

1
2
void* operator new(size_t size, int extra);
void operator delete(void* ptr, int extra) noexcept;

编写带有额外参数的重载operator new时,编译器会自动允许编写对应的new表达式。new的额外参数以函数调用的语法传递。因此,可编写这样的代码:

1
2
MemoryDemo* memp = new (5) MemoryDemo();
delete memp;

定义带有额外参数的operator new时,还应该定义带有额外参数的对应operator delete。不能自己调用这个带有额外参数的operator delete,只有在使用了带有额外参数的operator new且对象的构造函数抛出异常时,才调用这个operator delete

C++标准库概述

编码原则

  • 使用模板:模板用于实现泛型编程。通过模板,才能编写适用于所有类型对象的代码,模板甚至可用于编写代码时未知的对象。
  • 使用运算符重载:C++标准库大量使用了运算符重载。

C++标准库概述

字符串

C++在<string>头文件提供内建的string类,处理内存管理:提供一些边界检查、赋值语义以及比较操作;还支持些操作,例如串联、子字符串提取以及子字符串或字符的替换。

从技术角度看,std::string是对std::basic_string模板进行char实例化的类型别名。

标准库还提供在<string_view>中定义的string_view类。这是各类字符串表示的只读视图,可用于简单替换const string&,而且不会带来开销。它从不复制字符串!

正则表达式

<regex>头文件提供了正则表达式。正则表达式简化了文本处理中常用的模式匹配任务。通过模式匹配可在字符串中搜索特定的模式,还能酌情将搜索到的模式替换为新模式。

I/0流

C++引入了一种新的使用流的输入输出模型。IO功能在如下几个头文件中定义:<fstream>
<iomanip><ios><iosfwd><iostream><istream><ostream><sstream><streambuf><strstream>

智能指针

  • 第一个问题是根本没有删除对象(没有释放存储)。这称为内存泄漏。
  • 另一个问题是一段代码删除了存储,而另一段代码仍然引用了这个存储,导致指向那个存储的指针不再可用或已重新分配用作他用,这称为悬挂指针(dangling pointer)。
  • 还有一个问题是一段代码释放了一块存储,而另一段代码试图释放同一块存储。这称为双重释放(doublefreeing)。

所有这些问题都会导致程序发生某种故障。C++用智能指针unique_ptrshared_ptrweek_ptr解决了这些问题。shared_ptrweek_ptr是线程安全的,在<memory>头文件中定义。

在C++11之前,unique_ptr的功能由名为auto_ptr的类型完成,C++17废弃了auto_ptr,不应再使用这种类型。

异常

C++语言支持异常,函数和方法能通过异常将不同类型的错误向上传递至调用的函数或方法。异常支持在如下几个头文件中定义:<exception><stdexcept><system_error>

数学工具

C++有完整且常见的数学函数可供使用,C++17增加了大量的特殊数学函数,处理勒让德多项式、B函数、椭圆积分、贝塞尔函数、柱函数和诺伊曼函数等。

标准库在<complex>头文件中提供了一个复数类,名为complex,这个类提供了对包含实部和虚部的复数的操作抽象。

标准库还在<valarray>头文件中包含一个valarray类,这个类和vector类相似,但对高性能数值应用做了特别优化。这个库提供了一些表示矢量切片概念的相关类。通过这些构件,可构建执行矩阵数学运算的类。

C++还提供了一种获取数值极限的标准方式,例如当前平台允许的整数的最大值。在C语言中,可以通过访问#define来获得这些信息,例如INT_MAX。尽管在C++中仍可使用这种方法,但建议使用定义在<limits>头文件中的numeric_limits类模板:

1
2
cout <<"Max int value:" <<numeric_limits<int>::max() << endl;
cout <<"Min int value:" <<numeric_limits<int>::min() << endl;

注意min()lowest()之间的差异:

  • 对于整数而言,min值等于lowest值。
  • 对于浮点类型而言,min值等于可表示的最小正值,而lowest值等于可表示的最大负值,即-max()

时间工具

C++在<chrono>头文件中包含了Chrono库。这个库简化了与时间相关的操作,例如特定时间间隔的定时操作和定时相关的操作。

容器

标准库中的所有容器都是类模板,因此可通过这些容器保存任意类型的数据,从内建的int和double等类型到自定义的类。每个容器实例都只能保存一种类型的对象,也就是说,这些容器都是同构集合。如果需要大小可变的异构集合,可将每个元素包装在std:any实例中,并将这些实例存储在容器中。另外,可在容器中存储std:variant实例。

vector

<vector>头文件定义了vector,vector保存了元素序列,提供对这些元素的随机访问。与数组一样,vector中的元素保存在连续内存中。

vector能够在vector尾部快速地插入和删除元素(摊还常量时间,amortized constant time)。摊还常量时间指的是大部分插入操作都是在常量时间内完成的。然而,有时vector需要增长大小以容纳新元素,此时的复杂度为O(N)。这个结果的平均复杂度为0(1),或称为摊还常量时间。

vector其他部位的插入和删除操作比较慢(线性时间),因为这种操作必须将所有元素向上或向下挪动一个位置,为新元素腾出空间,或填充删除元素后留下的空间。与数组一样,vector提供对任意元素的快速访问。

对于在vector<bool>中保存布尔值有一个专门的模板。这个特制模板特别对布尔元素进行空间分配的优化,然而标准并未规定vector<bool>的实现应该如何优化空间。vector<bool>和本章后面要讨论的bitset之间的区别在于,bitset容器的大小是固定的。

list

标准库list是一种双向链表数据结构,在<list>中定义。与数组和vector一样,list保存了元素的序列。然而,与数组或vector的不同之处在于,list中的元素不一定保存在连续内存中。相反,list中的每个元素都指定了如何在list中找到前一个和后一个元素,所以得名双向链表。

list的性能特征和vector完全相反。list提供较慢的元素查找和访问(线性时间),而找到相应的位置之后,元素的插入和删除却很快(常量时间)。然而,vector通常比list更快。可使用性能分析器确认这一点。

forward_list

<forward_list>中定义的forward_list是一种单向链表,而list容器是双向链表。forward_list只支持前向迭代,需要的内存比list少。与list类似,一旦找到相关位置, forward_list允许在任何位置执行插入和删除操作(常量时间);与list一样,不能快速地随机访问元素,

deque

deque是双头队列(double-ended queue)的简称。deque在<deque>中定义,能实现快速的元素访问(常量时间)。在序列的两端还实现了快速插入和删除(摊还常量时间),但在序列中间插入和删除的速度较慢线性时间),deque中的元素在内存中的存储不连续,速度可能比vector慢,如果需要在序列两头快速插入或删除元素,还要求快速访问所有元素,那么应该使用deque而不是vector。

array

<array>头文件定义了array,这是标准C风格数组的替代品。有时可事先知道容器中元素的确切数量,因此不需要vector或list提供的灵活性,。array特别适用于大小固定的集合,而且没有vector的开销。

使用array有几点好处:

  • array总能知道自己的大小;
  • 不会自动转换为指针类型,从而避免了某些类型的bug。

array没有提供插入和删除操作,但大小固定。大小固定的优点是,允许array在堆栈上分配内存,而不总是像vector那样需要堆访问权限。与vector一样,对元素的访问速度极快(常量时间)。

queue

queue容器在<queue>中定义,提供标准的先入先出语义。在使用queue容器时,从一端插入元素,从另一端取出元素。插入元素(摊还常量时间)和删除元素(常量时间)的操作都很快。

priority_queue

priority_queue也在<queue>中定义,提供与queue相同的功能,但其中的每个元素都有优先级。元素按优先顺序从队列中移除。在优先级相同的情况下,删除元素的顺序没有定义。对prionity_queue的插入和删除一般比简单的队列插入和删除要慢,因为只有对元素重排序,才能支持优先级。

stack

<stack>头文件定义了stack,它提供标准的先入后出。在堆栈中,最新插入的元素第一个被移除。stack容器实现了元素的快速插入和删除(常量时间)。

set和multiset

set类模板在<set>头文件中定义。顾名思义,标准库中的set保存的是元素的集合:每个元素都是唯一的,在集合中每个元素最多只有一个实例。标准库中的set和数学中集合的概念有一点区别:在标准库中,元素按照一定的顺序保存。set提供对数时间的插入、删除和查找操作。这意味着插入和删除操作比vector快,但比list慢。查找操作比list块,但比vector慢。

如果需要保持顺序,而且要求插入,删除和查找操作的性能接近,那么应当优先使用set而不是vector或list。如果严禁出现重复元素,也应当使用set。

注意,set不允许重复元素。也就是说,set中的每个元素都必须唯一。如果要存储重复元素,必须使用<set>头文件中定义的multiset。

map和multimap

<map>头文件定义了map类模板,这是一个关联数组。可将其用作数组,其中的索引可以是任意类型,如string。map保存的是键/值对。map按顺序保存元素,排序的依据是键值而非对象值。它还提供operator[]。如果需要关联键和值,就应该使用map。

multimap也在<map>头文件中定义,它和map的关系等同于multiset和set的关系。确切地讲,multimap是允许重复键的map。

无序关联容器/哈希表

标准库支持哈希表(hash table),哈希表也称为无序关联容器(unordered associative container)。有4个无序关联容器:

  • unordered map
  • unordered_multimap
  • unordered_set
  • unordered multiset

前两个在<unordered_map>中定义,后两个在<unordered_set>中定义。更贴切的名字应该是hash_maphash_set等。

这些无序关联容器的插入、删除和查找操作能以平均常量时间完成。最坏情况是线性时间。在无序的容器中查找元素的速度比普通map或set中的查找速度快得多。

bitset

bitset类抽象了位操作。<bitset>头文件定义了bitset容器,但没有实现某种特定的可插入或删除元素的数据结构;bitset有固定大小,不支持迭代器。可将bitset想象为可以读写的布尔值序列。

bitset不局限于int或其他基本数据类型的大小。因此,能操作40位的bitset,也能操作213位的bitset。bitset的实现会使用实现N个位所需的足够存储空间,通过bitset<N>声明bitset时指定N。

标准库容器小结

表16-1总结了标准库提供的容器。

算法

除容器外,标准库还提供了很多泛型算法的实现。算法指的是执行某项任务时采取的策略,例如排序任务和搜索任务。这些算法也是用函数模板实现的,因此可用于大部分不同类型的容器。

迭代器是算法和容器之间的中介。选代器提供了顺序遍历容器中元素的标准接口,因此任何算法都可以操作任何容器。

标准库中大约有100种算法,下面将这些算法分为不同的类别。除非特别说明,否则这些算法都在<algorithm>头文件中定义。

非修改顺序算法

非修改类的算法查找元素的序列,返回一些有关元素的信息。因为是非修改类的算法,所以这些算法不会改变序列中元素的值或顺序。

从C++17开始,search0接收一个可选的附加参数,以指定要使用的搜索算法(default_searcher、boyer_moore_searcher或boyer_moore_horspool_searcher),使用boyer_moore搜索算法,最坏情况下,模式未找到时的复杂度是O(N+M),模式找到时的复杂度为O(NM)。

标准库提供了表16-4中列出的比较算法。这些算法都不要求排序源序列。所有算法的最差复杂度都为线性复杂度

计数算法如表16-5所示。

修改序列算法

修改算法会修改序列中的一些元素或所有元素。有些修改算法在原位置修改元素,因此原始序列发生变化。另一些修改算法将结果复制到另一个不同的序列,所以原始序列没有变化。所有这些修改算法的最坏复杂度都为线性复杂度。表16-6汇总了这些修改算法。

操作算法

操作算法在单独的元素序列上执行函数。C++标准库提供了两种操作算法,如表16-7所示。它们的复杂度都是线性复杂度,不要求对原始序列进行排序。

交换算法

C++标准库提供如表16-8所示的交换算法。

分区算法

如果谓词返回true的所有元素都在谓词返回false的所有元素的前面,则按某个谓词对序列进行分区。序列中不满足谓词的第一个元素称为分区点(partition point)。C++标准库提供如表16-9所示的分区算法。

排序算法

C++标准库提供了一些不同的排序算法,不同的排序算法有不同的性能保证,如表16-10所示。

二叉树搜索算法

下面的二叉树搜索算法通常用于已排序的序列。排好序的序列也满足这个要求。所有这些算法都具有对数复杂度。

集合算法

集合算法是特殊的修改算法,对序列执行集合操作,如表16-12所示。这些算法最适合操作set容器的序列,但也能操作大部分容器的排序后序列。

堆算法

堆(heap)是一种标准的数据结构,数组或序列中的元素在其中以半排序的方式排序,因此能够快速找到“顶部”元素。

最大/最小算法

数值处理算法

<numeric>头文件提供了下述数值处理算法。这些算法都不要求排序原始序列。所有算法的复杂度都为线性复杂度,如表16-15所示。

置换算法

序列的置换包含相同的元素,但顺序变了。表16-16列出了用于置换的算法。

理解容器与迭代器

容器概述

标准库提供了16个容器,分为4大类。

  • 顺序容器
    • vector(动态数组)
    • deque
    • list
    • forward_list
    • array
  • 关联容器
    • map
    • multimap
    • set
    • multiset
  • 无序关联容器或哈希表
    • unordered_map
    • unordered_multimap
    • unordered_set
    • unordered_multiset
  • 容器适配器
    • queue
    • priority_queue
    • stack

此外,C++的string和流也可在某种程度上用作标准库容器,bitset可以用于存储固定数目的位。

对元素的要求

标准库容器对元素使用值语义(value semantic)。也就是说,在输入元素时保存元素的一份副本,通过赋值运算符给元素赋值,通过析构函数销毁元素。因此,编写要用于标准库的类时,一定要保证它们是可以复制的。请求容器中的元素时,会返回所存副本的引用。

如果喜欢引用语义,可存储元素的指针而非元素本身。当容器复制指针时,结果仍然指向同一元素。另一种方式是在容器中存储std::reference_wrapper。可使用std::ref()std::cref()创建reference_wrapper,使引用变得可以复制。reference_wrapper类模板以及ref()cref()函数模板在<functional>头文件中定义。

在容器中,可能存储“仅移动”类型,这是非可复制类型,但当这么做时,容器上的一些操作可能无法编译。“仅移动”类型的一个例子是std::unique_ptr

标准库容器的一个模板类型参数是所谓的分配器(allocator)。标准库容器可使用分配器为元素分配或释放内存。分配器类型参数具有默认值,因此几乎总是可以忽略它。

有关使用默认内存分配器和比较器的容器中元素的特别需求在表17-1中列出。

迭代器

标准库通过迭代器模式提供了访问容器元素使用的泛型抽象。每个容器都提供了容器特定的迭代器,迭代器实际上是增强版的智能指针,这种指针知道如何遍历特定容器的元素。可将迭代器想象为指向容器中特定元素的指针。与指向数组元素的指针一样,迭代器可以通过operator++移到下一个元素。C++标准定义了5大类迭代器,如表17-2所示。

另外,满足输出迭代器要求的迭代器称为“可变迭代器”,否则称为“不变迭代器”。还可使用std::distance()计算容器的两个迭代器之间的距离。迭代器的实现类似于智能指针类,因为它们都重载了特定的运算符。

基本的迭代器操作类似于普通指针(dumb pointer)支持的操作,因此普通指针可以合法用作特定容器的迭代器。事实上,vector选代器在技术上就是通过简单的普通指针实现的。

标准库中每个支持迭代器的容器类都为其选代器类型提供了公共类型别名,名为iteratorconst_iterator。允许反向迭代元素的容器还提供了名为reverse_iteratorconst_reverse_iterator的公共类型别名。通过这种方式,客户使用容器迭代器时不需要关心实际类型。

const_iteratorconst_reverse_iterator提供对容器元素的只读访问。

容器还提供了begin()end()方法。begin()方法返回引用容器中第一个元素的迭代器,end()方法返回的选代器等于在引用序列中最后一个元素的选代器上执行operator++后的结果。begin()end()一起提供了一个半开区间,包含第一个元素但不包含最后一个元素。采用这种看似复杂方式的原因是为了支持空区间(不包含任何元素的容器),此时begin()等于end()

与此类似,还有:

  • 返回const迭代器的cbegin()cend()方法
  • 返回反向选代器的rbegin()rend()方法
  • 返回const反向选代器的crbegin()crend()方法

顺序容器

vector

可以在vector中建立索引,还可以在尾部或任何位置添加新的元素。向vector插入元素或从vector删除元素通常需要线性时间,但这些操作在vector尾部执行时,实际运行时间为摊还常量时间。

vector概述

vector在<vector>头文件中被定义为一个带有两个类型参数的类模板:一个参数为要保存的元素类型,另一个参数为分配器(allocator)类型

1
template <class T, class Allocator = allocator<T>> class vector;

Allocator参数指定了内存分配器对象的类型, 客户可设置内存分配器,以便使用自定义的内存分配器。这个模板参数具有默认值。

Allocator类型参数的默认值足够大部分应用程序使用。

固定长度的vector

使用vector的最简单方式是将其用作固定长度的数组。vector提供了一个可以指定元素数量的构造函数,还提供了一个重载的operator[]以便访问和修改这些元素。C++标准指出:通过operator[]访问vector边界之外的元素时,得到的结果是未定义的。

除使用operator[]运算符外,还可通过at()front()back()访问vector元素。at()方法等同于operator[]运算符,区别在于at()会执行边界检查,如果索引超出边界,at()会抛出out_of_range异常。front()back()分别返回vector的第一个元素和最后一个元素的引用。

对vector应用operator[]运算符通常会返回一个对元素的引用,可将这个引用放在赋值语句的左侧。如果对const vector对象应用operator[]运算符,就会返回一个对const元素的引用,这个引用不能用作赋值的目标。

动态长度的vector

vector的真正强大之处在于动态增长的能力。push_back()方法能为新元素分配空间。基于区间的for循环不需要做任何修改。

构造函数和析构函数

默认的构造函数创建一个不包含元素的vector。

1
vector<int> intVector;

可指定元素个数,还可指定这些元素的值,如下所示:
1
vector<int> intVector(10, 100);

如果没有提供默认值,那么对新对象进行0初始化。0初始化通过默认构造函数构建对象。

可以使用包含初始元素的initializer_list构建vector:

1
vector<int> intVector({ 1, 2, 3, 4,5,6 ));

initializer_list还可以用于第1章提到的统一初始化。统一初始化可用于大部分标准库容器。例如:

1
2
3
4
5
6
vector<int> intVector1 = {1, 2, 3, 4, 5, 6};
vector<int> intVector2{1, 2, 3, 4, 5, 6};

还可以在堆上分配vector:
```C++
auto elementVector = make_unique<vector<Element>>(10);

vector的复制和赋值

vector存储对象的副本,其析构函数调用每个对象的析构函数。vector类的复制构造函数和赋值运算符对vector中的所有元素执行深度复制。因此,出于效率方面的考虑,应该通过引用或const引用向函数和方法传递vector。

除普通的复制和赋值外,vector还提供了assign()方法,这个方法删除所有现有的元素,并添加任意数目的新元素。这个方法特别适合于vector的重用。下面是一个简单的例子。intVector包含10个默认值为0的元素。然后通过assign()删除所有10个元素,并以5个值为100的元素代之。

1
2
vector<int> intVector(10);
intVector.assign(5,100);

如下所示,assign()还可接收initializer_list。intVector现在有4个具有给定值的元素。

1
intVector.assign({1, 2, 3, 4});

vector还提供了swap()方法,这个方法可交换两个vector的内容,并且具有常量时间复杂度。下面举一个简单示例:

1
2
3
vector<int> vectorOne(10);
vectorcint> vectorTwo(5, 100);
vectorOne.swap (vectorTwo);

vector的比较

标准库在vector中提供了6个重载的比较运算符:==!=<><=>=。如果两个vector的元素数量相等,而且对应元素都相等,那么这两个vector相等。两个vector的比较采用字典顺序:如果第一个vector中从0到i-1的所有元素都等于第二个vector中从0到i-1的所有元素,但第一个vector中的元素i小于第二个vector中的元素i,其中i在0到n之间,且n必须小于size(),那么第一个vector“小于”第二个vector。

通过operator==operator!=比较两个vector时,要求每个元素都能通过operator==运算符进行比较。通过

vector迭代器

首先,看一下for循环的初始化语句:

1
vector<double>::iterator iter = begin (doubleVector);

begin()返回引用容器中第一个元素的相应类型的迭代器。因此,这条初始化语句在iter变量中获取了引用doubleVector中第一个元素的迭代器。下面看一下for循环的比较语句:

1
iter != end(doubleVector);

这条语句检查迭代器是否超越了vector中元素序列的尾部。当到达这一点时,循环终止。递增语句++iter递增迭代器,以引用vector中的下一个元素。

只要可能,尽量使用前递增而不要使用后递增,因为前递增至少效率不会差,一般更高效。iter++必须返回一个新的选代器对象,而++iter只是返回对iter的引用。

上述使用迭代器的for循环可通过auto关键字简化:

1
2
for (auto iter = begin(doubleVector); iter != end(doubleVector); ++iter)
cout << *iter << " ";

访问对象元素中的字段

如果容器中的元素是对象,那么可对迭代器使用->运算符,调用对象的方法或访问对象的成员。

1
2
3
4
vector<string> stringVector(10, "hello");
for (auto it = begin(stringVector); it!= end(stringVector); ++ it)
it->append(" there");
}

使用基于区间的for循环,这段代码可以重写为:
1
2
3
vector<string> stringVector(10, "hello");
for (auto& str : stringVector)
str.append("there");

const_iterator

const_iterator是只读的,不能通过const_iterator修改元素。iterator始终可以转换为const_iterator,因此下面这种写法是安全的:

1
vector<type>::const_iterator it = begin (myVector);

然而,const_iterator不能转换为iterator。如果myVector是const_iterator,那么下面这行代码无法编译:

1
2
3
4
5
6
7
vector<type>::iterator it = begin (myVector);

在使用auto关键字时,const_iterator的使用看上去有一点区别。假设有以下代码:
```C++
vector<string> stringVector(10, "hello");
for (auto iter = begin(stringVector); iter != end(stringVector); ++iter)
cout << *iter << endl;

由于使用了auto关键字,编译器会自动判定iter变量的类型,然后将其设置为普通的iterator,因为stringVector不是const_iterator。如果需要结合auto使用只读的const_iterator,那么需要使用cbegin()cend(),而不是begin()end(),如下所示:

1
2
3
vector<string> stringVector(10, "hello");
for (auto iter = cbegin(stringVector); iter != cend(stringVector); ++iter)
cout << *iter << endl;

现在编译器会将iter变量的类型设置为const_iterator,因为cbegin()返回的就是const_iterator。

基于区间的for循环也可用于强制使用const_ iterator,如下所示:

1
2
3
vector<string> stringVector(10, "hello");
for (const auto& element : stringVector)
cout << element << endl;

迭代器还是索引?

  • 使用迭代器可在容器的任意位置插入、删除元素或元素序列。
  • 使用迭代器可使用标准库算法。
  • 通过迭代器顺序访问元素,通常比编制容器索引以单独检索每个元素的效率要高。

在vector中存储引用

可在诸如vector的容器中存储引用。为此,在容器中存储std:reference_wrapperstd:ref()cref()函数模板用于创建非const和const reference_wrapper实例。需要包含<functional>头文件。示例如下:

1
2
3
4
5
6
7
8
9
10
11
string str1 = "Hello";
string str2 = "World";

vector<reference_wrapper<string>> vec{ ref(str1) };
vec.push_back(ref(str2));

// Modify the string referred to by the second reference in the vector.
vec[1].get() += "!";

// The end result is that str2 is actually modified.
cout << str1 << " " << str2 << endl;

添加和删除元素

根据前面的描述,通过push_back()方法可向vector追加元素。vector还提供了删除元素的对应方法:pop_back()

pop_back()不会返回已删除的元素。如果要访问这个元素,必须首先通过back()获得这个元素。通过insert()方法可在vector中的任意位置插入元素,这个方法在迭代器指定的位置添加一个或多个元素,将所有后续元素向后移动,给新元素腾出空间。insert()有5种不同的重载形式:

  • 插入单个元素
  • 插入单个元素的n份副本
  • 从某个迭代器范围插入元素
  • 使用移动语义,将给定元素转移到vector中,插入一个元素
  • 向vector中插入一列元素,这列元素是通过initializer_list指定的

push_back()insert()还有把左值或右值作为参数的版本。两个版本都根据需要分配内存,以存储新元素。左值版本保存新元素的副本。右值版本使用移动语义,将给定元素的所有权转移到vector,而不是复制它们。

通过erase()可在vector中的任意位置删除元素,通过clear()可删除所有元素。erase()有两种形式:一种接收单个迭代器,删除单个元素;另一种接收两个迭代器,删除迭代器指定的元素范围。要删除满足指定条件的多个元素,一种解决方法是编写一个循环来遍历所有元素,然后删除每个满足条件的元素。然而,这种方法具有二次(平方)复杂度,对性能有很大影响。这种情况下,可使用删除擦除惯用法(remove-erase-idiom),这种方法的复杂度为线性复杂度。

移动语义

所有的标准库容器都包含移动构造函数和移动赋值运算符,从而实现了移动语义。这带来的一大好处是可以通过传值的方式从函数返回标准库容器,而不会降低性能。分析下面这个函数:

1
2
3
4
5
6
7
8
9
10
vector<int> createVectorofsize(size_t size) {
vector<int> vec(size);
int contents = 0;
for (auto& i : vec)
i = contents++;
return vec;
}

vector<int> myVector;
myVector.createVectorofsize (123);

如果没有移动语义,那么将createVectorOfSize()赋给myVector时,会调用复制赋值运算符。有了标准库容器中支持的移动语义后,就可避免这种vector复制。相反,对myVector的赋值会触发调用移动赋值运算符。

与此类似,push操作在某些情况下也会通过移动语义提升性能。vector类还定义了push_back(T&&val),这是push_back(const T& val)的移动版本。如果按照下列方式调用push_back()方法,则可以避免这种复制:

1
vec.push_back(move(myElement));

现在可以明确地说,myElement应移入vector。注意在执行这个调用后,myElement处于有效但不确定的状态。不应再使用myElement,除非通过调用clear()等使其重返确定状态。也可以这样调用push_back()

1
vec.push_back(string(5, 'a'));

上述vec.push_back()调用会触发移动版本的调用,因为调用string构造函数后生成的是一个临时string对象。push_back()方法将这个临时string对象移到vector中,从而避免了复制。

emplace操作

C++在大部分标准库容器中添加了对emplace操作的支持。emplace的意思是“放置到位”。emplace操作的一个示例是vector对象上的emplace_back(),这个方法在容器中分配空间,然后就地构建对象。例如:

从C++17开始,emplace_back()方法返回已插入元素的引用。在C++17之前,emplace_back()的返回类型是void。还有一个emplace()方法,可在vector的指定位置就地构建对象,并返回所插入元素的迭代器。

算法复杂度和迭代器失效

在vector中插入或删除元素,引用插入点、删除点或随后位置的所有迭代器在操作之后都失效了。vector内部的重分配可能导致引用vector中元素的所有迭代器失效,而不只是那些引用插入点或删除点之后的元素的迭代器。

vector内存分配方案

vector会自动分配内存来保存插入的元素。每次vector申请更多内存时,要分配一块更大的内存块,将所有元素复制到新的内存块。这个过程非常耗时,因此 vector的实现在执行重分配时,会分配比所需内存更多的内存,以尽量避免这个复制转移过程。

必须理解vector内部的内存工作原理有两个原因:

  1. 效率。vector分配方案能保证元素插入采用摊还常量时间复杂度:也就是说,大部分操作都采用常量时间,但是也会有线性时间(需要重新分配内存时)。如果关注运行效率,那么可控制vector执行内存重分配的时机。
  2. 迭代器失效。重分配会使引用vector内元素的所有选代器失效。因此,vector接口允许查询和控制vector的重分配。如果不显式地控制重分配,那么应该假定每次插入都会导致重分配以及所有迭代器失效。

大小和容量

vector提供了两个可获得大小信息的方法:size()capacity()size()方法返回vector中元素的个数,而capacity()返回的是vector在重分配之前可以保存的元素个数。因此,在重分配之前还能插入的元素个数为capacity() - size()

C++17引入了非成员的std::size()std::empty()全局函数。这些与用于获取迭代器的非成员函数(如std::begin()std::end()等)类似。非成员函数size()empty()可用于所有容器,也可用于静态分配的C风格数组(不通过指针访问)以及initializer_list。下面是一个将它们用于vector的例子:

1
2
3
vector<int> vec{1, 2, 3};
cout << size(vec) << endl;
cout << empty(vec) << endl;

预留容量

一种预分配空间的方式是调用reserve()。这个方法负责分配保存指定数目元素的足够空间。

另一种预分配空间的方法是在构造函数中,或者通过resize()assign()方法指定vector要保存的元素数目。这种方法会创建指定大小的vector(容量也可能就是这么大)。

直接访问数据

vector在内存中连续存储数据,可使用data()方法获取指向这块内存的指针。C++17引入了非成员的std::data()全局函数来获取数据的指针。它可用于array. vector容器、字符串、静态分配的C风格数组(不通过指针访问)和initializer_lists。下面是一个用于vector的示例:

1
2
3
vector<int> vec{1, 2, 3};
int* data1 = vec.data();
int* data2 = data(vec);

vector特化

C++标准要求对布尔值的vector进行部分特化,目的是通过“打包”布尔值的方式来优化空间分配。C++没有正好保存一个位的原始类型。一些编译器使用和char大小相同的类型来表示布尔值。其他一些编译器使用int类型。vector<bool>特化应该用单个位来存储“布尔数组”,从而节省空间。可将vector<bool>表示为位字段(bit-field)而不是vector。

vector<bool>特化实际上定义了一个名为reference的类,用作底层布尔(或位)值的代理。当调用operator[]at()或类似方法时,vector<bool>返回reference对象,这个对象是实际布尔值的代理。

由于vector<bool>返回的引用实际上是代理,因此不能取地址以获得指向容器中实际元素的指针。

在实际应用中,通过包装布尔值而节省一点空间似乎得不偿失。更糟糕的是,访问和修改vector<bool>中的元素比访问vector<int>中的元素慢得多。应该避免使用vector<bool>,而是使用bitset。

如果确实需要动态大小的位字段,建议使用vector<std::int_fast8_t>vector<unsigned char>std::int_fast8_t类型在<cstdint>中定义。这是一种带符号的整数类型,编译器必须为其使用最快的整数类型(至少8位)。

deque

deque(double-ended qucue的简称)几乎和vector是等同的,但用得更少。deque定义在<deque>头文件中。主要区别如下:

  • 不要求元素保存在连续内存中
  • deque支持首尾两端常量时间的元素插入和删除操作(vector只支持尾端的摊还常量时间)
  • deque提供了push_front()pop_front()emplace_front(),而vector没有提供
  • 在开头和末尾插入元素时,deque未使选代器失效
  • deque没有通过reserve()capacity()公开内存管理方案

list

list定义在<list>头文件中,是一种标准的双链表。list支持链表中任意位置常量时间的元素插入和删除操作,但访问单独元素的速度较慢(线性时间)。事实上,list根本没有提供诸如operator[]的随机访问操作。只有通过迭代器才能访问单个元素。

访问元素

list提供的访问元素的方法仅有front()back(),这两个方法的复杂度都是常量时间。这两个方法返回链表中第一个元素和最后一个元素的引用。对所有其他元素的访问都必须通过迭代器进行。

list支持begin()方法,这个方法返回引用链表中第一个元素的迭代器;还支持end()方法,这个方法返回引用链表中最后一个元素之后那个元素的迭代器。与vector类似,list还支持cbegin()cend()rbegin()rend()crbegin()crend()

list不支持元素的随机访问。

迭代器

list迭代器是双向的,不像vector迭代器那样提供随机访问。这意味着list迭代器之间不能进行加减操作和其他指针运算。

添加和删除元素

和vector一样,list也支持添加和删除元素的方法,包括push_back()pop_back()emplace()emplace_back()5种形式的insert()以及两种形式的erase()clear()。和deque一样,list还提供了push_front()emplace_front()pop_front()。list的奇妙之处在于,只要找到正确的操作位置,所有这些方法(clear()除外)的复杂度都是常量时间。

list大小

list不公开底层的内存模型。因此,list支持size()empty()resize(),但不支持reserve()capacity()。注意,list的size()方法具有常量时间复杂度。

list特殊操作

list提供了一些特殊操作,以利用其元素插入和删除很快这一特性。

串联

由于list类的本质是链表,因此可在另一个list的任意位置串联(splice)或插入整个list,其复杂度是常量时间。

串联���作对作为参数传入的list来说是破坏性的:从一个list中删除要插入另一个list的元素。

更高效的算法版本

splice()外,list类还提供了一些泛型标准库算法的特殊实现。

表17-3总结了list以方法形式提供特殊实现的算法。

forward_list

forward_list在<forward_list>头文件中定义,forward_list是单链表,而list是双链表。这意味着forward_list只支持前向迭代,如果需要修改任何链表,首先需要访问第一个元素之前的那个元素。由于forward_list没有提供反向遍历的迭代器,因此没有简单的方法可以访问前一个元素。所以,要修改的范围(例如提供给erase()splice()的范围)必须是前开的。

forward_list类定义了一个before_begin()方法,它返回一个指向链表开头元素之前的假想元素的迭代器。不能解除这个迭代器的引用,因为这个迭代器指向非法数据。然而,将这个迭代器递增1可得到与begin()返回的迭代器同样的效果;因此,这个方法可以用于构建前开的范围。表17-4总结了list和forward_list之间的区别。

forward_list和list的构造函数及赋值运算符类似。C++标准表明forward_list应该尽可能使用最小的空间。

array

array类定义在<array>头文件中,和vector类似,区别在于array的大小是固定的,不能增加或收缩。这个类的目的是让array能分配在栈上,而不是像vector那样总是需要访问堆。和vector一样,array支持随机访问迭代器,元素都保存在连续内存中。array支持front()back()at()operator[],还支持使用fill()方法通过特定元素将array填满。由于array大小固定,因此不支持push_back()pop_back()insert()erase()clear()resize()reserve()capacity()

与vector相比,array的缺点是,array的swap()方法具有线性时间复杂度,而vector的swap()方法具有常量时间复杂度。array声明需要两个模板参数:第一个参数指定元素类型,第二个参数指定array中元素的固定数量:

1
2
3
4
5
6
7
8
9
10
array<int, 3> arr = {9, 8, 7};
cout <<"Array size "<< arr.size() << endl;

for (const auto& i : arr)
cout << i << endl;

arr.fill(3);

for (auto iter = cbegin(arr); iter != cend(arr); ++iter)
cout << *iter << endl;

可使用std:get<n>()函数模板,从std::array检索位于索引位置n的元素。索引必须是常量表达式,不能是循环变量等。使用std:get<n>()的优势在于编译器在编译时检查给定索引是有效的,否则将导致编译错误,如下所示:

1
2
3
array<int, 3> myArray(1122, 33);
cout<< std::get<1>(myArray) <<endl;
cout << std::get<10>(myArray)<< endl; // Compilation error!

容器适配器

除标准的顺序容器外,标准库还提供了3种容器适配器:queue,priority_queue和stack。每种容器适配器都是对一种顺序容器的包装。它们允许交换底层容器,无须修改其他代码。容器适配器的作用是简化接口,只提供那些stack和queue抽象所需的功能。

queue

queue容器适配器定义在头文件<queue>中,queue提供了标准的“先入先出”语义。与通常情况一样,queue也写为类模板形式,如下所示:

1
template <class T, class Container = deque<T> > class queue;

T模板参数指定要保存在queue中的类型。另一个模板参数指定queue适配的底层容器。不过,由于queue要求顺序容器同时支持push_back()pop_front()两个操作,因此只有两个内建的选项:deque和list。大部分情况下,只使用默认的选项deque即可。

queue操作

queue接口非常简单:只有8个方法,再加上构造函数和普通的比较运算符。push()emplace()方法在queue的尾部添加一个新元素,pop()从queue的头部移除元素。通过front()back()可以分别获得第一个元素和最后一个元素的引用,而不会删除元素。与其他容器一样,在调用const对象时,front()back()返回的是const引用:调用非const对象时,这些方法返回的是非const引用(可读写)。

pop()不会返回弹出的元素。如果需要获得一份元素的副本,必须首先通过front()获得这个元素。queue还支持size()empty()swap()

priority_queue

优先队列(priority_queue)是一种按顺序保存元素的队列。优先队列不保证严格的FIFO顺序,而是保证在队列头部的元素任何时刻都具有最高优先级。这个元素可能是队列中最老的那个元素,也可能是最新的那个元素。如果两个元素的优先级相等,那么它们在队列中的相对顺序是未确定的。priority_queue容器适配器也定义在<queue>中。其模板定义如下:

1
template <class T, class Container = vector<T>, class Compare = less<T>>;

这个类没有看上去这么复杂。之前看到了前两个参数:T是priority_queue中保存的元素类型;Container是priority_queue适配的底层容器。priority_queue默认使用vector,但是也可以使用deque。这里不能使用list,因为priority_queue要求随机访问元素。第3个参数Compare复杂一些。less是一个类模板,支持两个类型为T的元素通过operator<运算符进行比较。也就是说,要根据operator<来确定队列中元素的优先级,可以自定义这里使用的比较操作。目前,只要保证为保存在priority_queue中的类型正确定义了operator<即可。

priority_queue的头元素是优先级最高的元素,默认情况下优先级是通过operator<运算符来判断的,比其他元素“小”的元素的优先级比其他元素低

priority_queue提供的操作

priority_queue提供的操作比queue还要少。push()emplace()可以插入元素,pop()可以删除元素,top()可以返回头元素的const引用。

在非const对象上调用top()top()返回的也是const引用,因为修改元素可能会改变元素的顺序,所以不允许修改。priority_queue没有提供获得尾元素的机制。

pop()不返回弹出的元素。如果需要获得一份副本,必须首先通过top()获得这个元素。

与queue一样,priority_queue支持size()empty()swap()。然而,priority_queue没有提供任何比较运算符。

stack

stack和queue几乎相同,区别在于stack提供先入后出(FILO)的语义,这种语义也称为后入先出,以区别于FIFO。stack定义在<stack>头文件中。模板定义如下所示:

1
template <class T, class Container = deque<T>>class stack;

可将vector、list或deque用作stack的底层容器。

stack操作

与queue类似,stack提供了push()emplace()pop()。区别在于:push()在stack项部添加一个新元素,将之前插入的所有元素都“向下推”;而pop()从stack顶部删除一个元素,这个元素就是最近插入的元素。如果在const对象上调用,top()方法返回顶部元素的const引用;如果在非const对象上调用,top()方法返回非const引用。

pop()不返回弹出的元素。如果需要获得一份副本,必须首先通过top()获得这个元素。stack支持empty()size()swap()和标准的比较运算符。

有序关联容器

有序关联容器将键映射到值。通常情况下,有序关联容器的插入、删除和查找时间是相等的。标准库提供的4个有序关联容器分别为map、multimap、set和multiset。每种有序关联容器都将元素保存在类似于树的有序数据结构中。还有4个无序关联容器:unordered_map、unordered_multimap、unordered_set和unordered_multiset。

pair工具类

pair类在<utility>头文件中定义。pair是一个类模板,它将两个可能属于不同类型的值组合起来。通过first和second公共数据成员访问这两个值。pair类定义了operator==operator<,用于比较first和second元素。下面给出了一些示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pair<string, int> myPair("hello", 5);
pair<string, int> myotherPair;

myotherPair.first = "hello";
myotherPair.second = 6;

pair<string, int> myThirdPair (myOtherPair);

if (mypair < myOtherPair)
cout <<"myPair is less than myotherPair"<<endl;
else
cout << "myPair is greater than or equal to myOtherPair" << endl;

if (myotherPair == myThirdPair)
cout << "myotherPair 1s equal to myThirdPair" << endl;
else
cout << "myOtherPair is not equal to myThirdPair" << endl;

这个库还提供了一个工具函数模板make_pair(),用于从两个值构造一个pair。例如:

1
pair<int, double> aPair = make_ pair(510.10);

如果需要向函数传递pair,或者把它赋予已有的变量,那么make_pair()更有用。与类模板不同,函数模板可从参数中推导类型,因此可通过make_pair()构建pair,而不需要显式地指定类型。还可结合使用make_pair()与auto关键字:

1
auto aSecondPair = make_pair(5, 10.10);

结构化绑定是另一个C++17特性,可用于将pair的元素分解为单独的变量。下面是一个示例:

1
2
3
4
5
pair<string, int> myPair("hello", 5);
auto[thestring, theint] = myPair;

cout << "theString:" << thestring << endl;
cout << "theInt: "<< theInt <<endl;

map

map定义在<map>头文件中,它保存的是键/值对,而不是只保存值。插入、查找和删除操作都是基于键的,值只不过是附属品。map根据键对元素排序存储,因此插入、删除和查找的复杂度都是对数时间。由于排好了序,因此枚举元素时,元素按类型的operator<或用户定义的比较器确定的顺序出现。通常情况下,map实现为某种形式的平衡树,例如红黑树。不过,树的结构并没有向客户公开。

构建map

map类模板接收4种类型:键类型、值类型、比较类型以及分配器类型。比较类型和之前描述的priority_queue中的比较类型类似,允许提供与默认不同的比较类。如果忽略比较参数和分配器参数,那么map的构建和vector或list的构建是一样的,区别在于,在模板实例化中需要分别指定键和值的类型。

1
2
3
4
5
6
7
8
9
class Data final {
public:
explicit Data(int value = 0): mValue(value) ()
int getValue() const { return mValue; }
void setValue(int value) { mValue = value; }
private:
int mValue;
};
map<int, Data> dataMaps;

map还支持统一初始化机制:

1
2
3
4
5
map<string, int> m = {
{ "Marc G.", 123 },
{ "Warren B.", 456},
{ "Peter V.W.", 789)
};

插入元素

map和其他关联容器插入时不需要指定位置。map的内部实现会判定要保存新元素的位置,只需要提供键和值即可。

map和其他有序关联容器提供了接收选代器位置作为参数的insert()方法。然而,这个位置只是容器找到正确位置的一种“提示”。不强制容器在那个位置插入元素。

在插入元素时,一定要记住map需要“唯一键”:map中的每个元素都要有不同的键。如果需要支持多个带有同一键的元素,有两个选择:可使用map,把另一个容器(如vector或array)用作键的值,也可以使用后面描述的multimap。

insert()方法

可使用insert()方法向map添加元素,它有一个好处:允许判断键是否已经存在。insert()方法的一个问题是必须将键/值对指定为pair对象或initializer_listinsert()的基本形式的返回类型是迭代器和布尔值组成的pair,返回类型这么复杂的原因是,如果指定的键已经存在,那么insert()不会改写元素值。返回的pair中的bool元素指出,insert()是否真的插入了新的键/值对。迭代器引用的是map中带有指定键的元素。

1
2
3
4
5
6
7
8
9
10
11
12
map<int, Data> dataMap;
auto ret = dataMap.insert({1, Data(4) });
if (ret.second)
cout <<"Insert succeeded!" << endl;
else
cout << "Insert failed!" << endl;

ret = dataMap.insert(make_pair(1, Data(6))); // Using a pair object
it (ret.second)
cout << "Insert succeeded!" << endl;
else
cout << "Insert failed!" << endl;

ret变量的类型是pair,如下所示:

1
pair<map<int, Data>::iterator, bool>ret;

pair的第一个元素是键类型为int、值类型为Data的map的map选代器。该pair的第二个元素为布尔值。

使用if语句的初始化器,只使用一条语句,即可将数据插入map并检查结果,如下所示:

1
2
3
4
if (auto result = dataMap.insert({1, Data(4) }); result.second)
cout << "Insert succeeded!"<< endl;
else
cout <<"Insert failed!" << endl;

甚至可将其与C++17结构化绑定结合使用:

1
2
3
4
if (auto [iter, success] = dataMap.insert({ 1, Data(4) }); success)
cout << "Insert succeeded!" << endl;
else
cout <<"Insert failed!"<<endl;

insert_or_assign()方法

insert_or_assign()insert()的返回类型类似。但是,如果已经存在具有给定键的元素,insert_or_assign()将用新值重写旧值,而insert()在这种情况下不重写旧值。与insert()的另一个区别在于,insert_or_assign()有两个独立的参数:键和值。

1
2
3
4
5
ret = dataMap.insert_or_assign(1, Data(7));
if (ret.second)
cout << "Inserted." << endl;
else
cout << "Overwritten."<< endl;

operator[]

向map插入元素的另一种方法是通过重载的operator[]。这种方法的区别主要在于语法:键和值是分别指定的。此外,operator[]总是成功。如果给定键没有对应的元素值,就会创建带有对应键值的新元素。如果具有给定键的元素已经存在,operator[]会将元素值替换为新指定的值。

1
2
3
map<int, Data> dataMap;
dataMap[1] = Data(4);
dataMap[1] = Data(6); // Replaces the element with key 1

不过,operator[]有一点要注意:它总会构建一个新的值对象,即使并不需要使用这个值对象也同样如此。因此,需要为元素值提供一个默认的构造函数,从而可能会比insert()的效率低。

如果请求的元素不存在,operator[]会在map中创建一个新元素,所以这个运算符没有被标记为const。尽管这很明显,但有时可能会看上去违背常理。

emplace方法

map支持emplace()emplace_hint(),从而在原位置构建元素,这与vector的emplace方法类似。C++17添加了try_emplace()方法,如果给定的键尚不存在,则在原位置插入元素;如果map中已经存在相应的键,则什么都不做。

map迭代器

map迭代器的工作方式类似于顺序容器的选代器。主要区别在于选代器引用的是键值对,而不只是值。如果要访问值,必须通过pair对象的second字段来访问。

1
2
for (auto iter = cbegin(dataMap); iter != cend(dataMap); ++iter)
cout << iter->second.getValue() << endl;

查找元素

map可根据指定的键查找元素,复杂度为指数时间。如果知道指定键的元素存在于map中,那么查找它的最简单方式是,只要在非const map或对map的非const引用上调用,就通过operator[]进行查找。operator[]的好处在于返回可直接使用和修改的元素引用,而不必考虑从pair对象中获得值。

1
2
3
4
map<int, Data> dataMap;
dataMap[1] = Data(4);
dataMap[1] = Data(6);
dataMap[1].setValue(100);

然而,如果不知道元素是否存在,就不能使用operator[]。因为如果元素不存在,这个运算符会插入一个包含相应键的新元素。作为替换方案,map提供了find()方法。如果元素在map中存在, 这个方法返回指向具有指定键的元素的选代器;如果元素在map中不存在,则返回end()选代器。
1
2
3
auto it = dataMap.find(1);
if(it != end(dataMap))
it->second.setValue (100);

如果只想知道在map中是否存在具有给定键的元素,那么可以使用count()成员函数。这个函数返回map中给定键的元素个数。对于map来说,这个函数返回的结果不是0就是1,因为map中不允许有具有重复键的元素

删除元素

map允许在指定的选代器位置删除一个元素或删除指定迭代器范围内的所有元素,这两种操作的复杂度分别为摊还常量时间和对数时间。从客户的角度看,用于执行上述操作的两个erase()方法等同于顺序容器中的erase()方法。

节点

所有有序和无序的关联容器都被称为基于节点的数据结构。从C++17开始,标准库以节点句柄(node handle)的形式,提供对节点的直接访问。确切类型并未指定,但每个容器都有一个名为node_type的类型别名,它指定容器节点句柄的类型。节点句柄只能移动,是节点中存储的元素的所有者。它提供对键和值的读写访问。

可基于给定的选代器位置或键,从关联容器(作为节点句柄)提取节点。从容器提取节点时,将其从容器中删除,因为返回的节点句柄是所提取元素的唯一拥有者。

C++提供了新的insert()重载,以允许在容器中插入节点句柄。使用extract()来提取节点句柄,使用insert()来插入节点句柄,可有效地将数据从一个关联容器传递给另一个关联容器,而不需要执行任何复制或移动。甚至可将节点从map移到multimap,从set移到multiset。下面的代码将键为1的节点转到第二个map:

1
2
3
map<int, Data> dataMap2;
auto extractedNode = dataMap.extract(1);
dataMap2.insert(std::move (extractedNode));

还有一个操作merge(),可将所有节点从一个关联容器移到另一个关联容器。无法移动的节点留在源容器中。一个示例如下:

1
2
3
map<int, int> src = { {1, 11}, {2, 22} };
map<int, int> dst = { {2, 22}, {3, 33}, {4, 44}, {5, 55} };
dst.merge(src);

完成合并操作后,src仍然包含一个元素{2, 22},因为目标已经包含这个元素,所以无法移动。操作后,dst包含{1, 11}、{2, 22}、{3, 33}、{4, 44}和{5, 55}。

multimap

multimap是一种允许多个元素使用同一个键的map。和map一样,multimap支持统一初始化。multimap的接口和map的接口几乎相同,区别在于:

  • multimap不提供operator[]at()。它们的语义在多个元素可以使用同一个键的情况下没有意义。
  • 在multimap上执行插入操作总是会成功。因此,添加单个元素的multimap::insert()方法只返回iterator而不返回pair。
  • map支持insert_or_assign()try_emplace()方法,而multimap不支持。

multimap允许插入相同的键值对。如果要避免这种冗余,必须在插入新元素之前执行显式检查。

multimap的最棘手之处是查找元素。不能使用operator[],因为并没有提供operator[]find()也不是非常有用,因为find()返回的是指向具有给定键的任意一个元素的iterator(未必是具有这个键的第一个元素)。

然而,multimap将所有带同一个键的元素保存在一起,并提供方法以获得这个子范围的iterator,这个子范围内的元素在容器中具有相同的键。lower_bound()upper_bound()方法分别返回匹配给定键的第一个元素和最后一个元素之后那个元素的对应 iterator。如果没有元素匹配这个键,那么lower_bound()upper_bound()返回的iterator相等。

如果需要获得具有给定键的元素对应的iterator,使用equal_range()方法比依次调用lower_bound()upper_bound()更高效。equal_range()返回两个iterator的pair,这两个iterator分别是lower_bound()upper_bound()返回的iterator。

map中也有lower_bound()upper_bound()equal_range()方法,但由于map中不允许多个元素带有同一个键,因此在map中,这些方法的用处不大。

set

set容器定义在<set>头文件中,和map非常类似。 区别在于set保存的不是键值对,在set中,值本身就是键。如果信息没有显式的键,且希望进行排序(不包含重复)以便快速地执行插入、查找和删除,就可以考虑使用set容器来存储此类信息。

set提供的接口几乎和map提供的接口完全相同,主要区别在于set没有提供operator[]insert_or_assign()try_emplace()。不能修改set中元素的键/值,因为修改容器中的set元素会破坏顺序。

multiset

multiset和set的关系等同于multimap和map的关系。multiset支持set的所有操作,但允许容器中同时保存多个互等的元素。这里没有提供multiset的例子,因为multiset与set和multimap太相似了。

无序关联容器/哈希表

标准库支持无序关联容器或哈希表。这种容器有4个:unordered_map,unordered_multimap,unordered_set和unordered_multiset。此前讨论的map、multimap、set和multiset容器对元素进行排序,而这些新的无序版本不会对元素进行排序。

哈希函数

无序关联容器也称为哈希表,这是因为它们使用了哈希函数(hash function)。哈希表的实现通常会使用某种形式的数组,数组中的每个元素都称为桶(bucket)。每个桶都有一个特定的数值索引,哈希函数将键转换为哈希值,再转换为桶索引。与这个键关联的值在桶中存储。

哈希函数的结果未必是唯一的。两个或多个键哈希到同一个桶索引,就称为冲突(collision)。当使用不同的键得到相同的哈希值,或把不同的哈希值转换为同一桶索引时,会发生冲突。可采用多种方法来处理冲突,例如二次重哈希线性链等方法。使用线性链时,桶不直接包含与键关联的数据值,而包含一个指向链表的指针。这个链表包含特定桶中的所有数据值。图17-1展示了原理。

从中能看出,相比普通map的查找方式,这种查找方式要快得多,但查找速度完全取决于冲突次数。哈希函数的选择非常重要。不产生冲突的哈希函数称为“完美哈希”。完美哈希的查找时间是常量:常规的哈希查找时间平均接近于1,与元素数量无关。随着冲突数的增加,查找时间会增加,性能会降低。增加基本哈希表的大小,可以减少冲突,但需要考虑高速缓存的大小。

C++标准为指针和所有基本数据类型提供了哈希函数,还为error_codeerror_conditionoptionalvariantbitsetunique_ptrshared_ptrtype_indexstringstring_viewvector<bool>thread::id提供了哈希函数。如果要使用的键类型没有可用的标准哈希函数,就必须实现自己的哈希函数。

下面的示例演示了如何编写自定义哈希函数。这个示例仅将请求传递给可用的一个标准哈希函数。

1
2
3
4
5
6
7
8
9
10
11
class IntWrapper {
public:
IntWrapper(int i) : mWrappedInt(i) { }
int getValue() const { return mWrappedint; }
private:
int mWrappedint;
};

bool operator--(const IntWrapper& lhs, const IntWrapper& rhs) {
return lhs.getValue() == ths.getValue();
}

为给IntWrapper编写哈希函数,应给IntWrapper编写std:hash模板的特例。std::hash模板在<functional>中定义。这个特例需要实现函数调用运算符,以计算并返回给定IntWrapper实例的哈希。对于这个示例,仅把请求传递给整数的标准哈希函数:

1
2
3
4
5
6
7
8
9
namespace std {
template<> struct hash<IntWrapper> {
using argument_type = IntWrapper;
using result_type = size_t;
result_type operator () (const argument_type& f) const {
return std::hash<int>()(f.getValue());
}
};
}

注意一般不允许把任何内容放在std名称空间中,但std类模板特例是这条规则的例外。hash类模板需要两个类型定义。函数调用运算符的实现只有一行代码,它为整数的标准哈希函数创建了一个实例std:hash<int>(),然后对该实例通过参数f.getValue()执行函数调用运算符。

unordered_map

unordered_map容器在<unordered_map>头文件中定义,也是一个类模板,如下所示:

1
2
3
4
5
6
7
template <class Key,
class T,
class Hash = hash<Key>,
Class Pred = std::equal_to<Key>,
class Alloc = std::allocator<std::pair<const Key,T>>
>
class unordered_map;

共有5个模板参数:键类型、值类型、哈希类型、判等比较类型和分配器类型。通过后面3个参数可以分别自定义哈希函数、判等比较函数和分配器函数。通常可忽略这些参数,因为它们有默认值。建议保留默认值。最重要的参数是前两个参数。与map一样,可使用统一初始化机制来初始化unordered_map, 如下所示:

1
2
3
4
5
6
7
8
9
10
11
unordered_map<int, string> m = {
{ 1, "Item 1"},
{ 2, "Item 2"},
{ 3, "Item 3"},
{ 4, "Item 4"}
};

cout << key << " " <<value << endl;

for (const auto&[key, value] : m)
cout << key << value << endl;


与普通的map一样,unordered_map中的所有键都应该是唯一的。

unordered_multimap

unordered_multimap是允许多个元素带有同一个键的unordered_map。 两者的接口几乎相同,区别在于:

  • unordered_multimap没有提供operator[]运算符和at(),它们的语义在多个元素可以使用同一个键的情况下没有意义。
  • 在unordered_multimap上执行插入操作总是会成功。因此,添加单个元素的unordered_multimap:insert()方法只返回迭代器而非pair。
  • unordered_map支持insert_or_assign()try_emplace()方法,而ordered_multimap不支持这两个方法。

unordered_set/unordered_multiset

<unordered_set>头文件定义了unordered_set和unordered_multiset,这两者分别类似于set和multiset;区别在于它们不会对键进行排序,而且使用了哈希函数。unordered_set和unordered_map的区别和之前讨论的set和map之间的区别类似,因此这里不再赘述。

其他容器

标准C风格数组

string

可将string看成字符的顺序容器。因此,C++ string实际上是一种功能完备的顺序容器。string包含的begin()end()方法返回string中的选代器,还包含insert()push_back()erase()size()empty()方法,以及基本顺序容器包含的其他所有内容。string非常接近于vector,甚至还提供了reserve()capacity()方法。

传统意义上,输入流和输出流并不是容器,因为它们并不保存元素。然而,可以把它们看成元素的序列,因而具有标准库容器的一些特性。C++流没有直接提供与标准库相关的任何方法,但是标准库提供了名为istream_iteratorostream_iterator的特殊迭代器,用于“遍历”输入流和输出流。

bitset

bitset是固定长度的位序列的抽象。一个位只能表示两个值——1和0,这两个值可以表示开关和真/假等意义。bitset还使用了设置(set)和清零(unset)两个术语。可将一个位从一个值切换(toggle)或翻转(nip)为另一个值。

bitset并不是真正的标准库容器:bitset的大小固定,没有对元素类型进行模板化,也不支持迭代。

bitset基础

bitset定义在<bitset>头文件中,根据保存的位数进行模板化。默认构造函数将bitset的所有字段初始化为0。另一个构造函数根据由0和1字符组成的字符串创建bitset。可通过set()reset()flip()方法改变单个位的值,通过重载的operator[]运算符可访问和设置单个字段的值。

注意对非const对象应用operator[]会返回一个代理对象,可为这个代理对象赋予一个布尔值,调用flip()~取反。还可通过test()方法访问单独字段。此外,通过普通的插入和抽取运算符可以流式处理bitsetbitset以包含0和1字符的字符串形式进行流式处理。

下面是一个简单例子:

1
2
3
4
5
6
7
8
9
10
bitset<10> myBitset;
myBitset.set(3);
myBitset.set(6);
myBitset[8] = true;
myBitset[9] = myBitset[3];

if (myBitset.test (3)) {
cout << "Bit 3 is set!"<< endl;
cout << myBitset << endl;
}

按位运算符

除基本的位操作外,bitset还实现了所有的按位运算符:&|^~<<>>&=|=^=<<=>>=。这些运算符的行为和操作真正的位序列相同。

1
2
3
4
5
6
7
8
9
10
auto str1 = "0011001100";
auto str2 - "0000111100";
bitset<10> bitsOne(stri);
bitset<10> bitsTwo(str2);

auto bitsThree = bitsOne & bitsTwo;
cout << bitsThree << endl;

bitsThree <<=4;
cout << bitsThree << endl;

掌握标准库算法

算法概述

算法把迭代器作为中介操作容器,而不直接操作容器本身。这样,算法没有绑定至特定的容器实现。所有标准库算法都实现为函数模板的形式,其中模板类型参数一般都是迭代器类型。将迭代器本身指定为函数的参数。大部分算法都定义在<algorithm>头文件中,一些数值算法定义在<numeric>头文件中。它们都在std名称空间中

find()和find_if()算法

find()在某个迭代器范围内查找特定元素。可将其用于任意容器类型的元素。这个算法返回引用所找到元素的迭代器;如果没有找到元素,则返回迭代器范围的尾迭代器。注意调用find()时指定的范围不要求是容器中元素的完整范围,还可以是元素的子集。

如果find()没有找到元素,那么返回的迭代器等于函数调用中指定的尾迭代器,而不是底层容器的尾迭代器

下面是一个std::find()示例。

1
2
3
4
5
6
7
8
9
while (true) {
cout <<"Enter a number to lookup (0 to stop):";
cin >> num;
if (num -- 0)
break;
auto endit = cend (myVector);
auto it = find(cbegin(myVector), endIt, num);
if (it == endit)
cout <<"Could not find "<< num << endl;

调用find()时将cbegin(myVector)endIt作为参数,其中,endIt定义为cend(myVector),因此搜索的是vector的所有元素。如果需要搜索一个子范围,可修改这两个迭代器。

使用if语句的初始化器(C++17),可使用如下加粗语句来调用find()并查找结果:

1
2
if (auto it = find(cbegin(myVector), endIt, num); it == endIt) {
cout << "Found " << *it << endl;

如果容器提供的方法具有与泛型算法同样的功能,那么应该使用相应的方法,那样速度更快。比如,泛型算法find()的复杂度为线性时间,用于map迭代器时也是如此;而map中find()方法的复杂度是对数时间。

find_if()find()类似,区别在于find_if()接收谓词函数回调作为参数,而不是简单的匹配元素。谓词返回true或false。find_if()算法对范围内的每个元素调用谓词,直到谓词返回true;如果返回了true,find_if()返回引用这个元素的迭代器。

accumulate()算法

我们经常需要计算容器中所有元素的总和或其他算术值。accumulate()函数就提供了这种功能,该函数在<numeric>中定义。通过这个函数的最基本形式可计算指定范围内元素的总和。例如,下面的函数计算vector中整数序列的算术平均值。

1
2
3
4
double arithmeticMean(const vector<int>& nums) {
double sum = accumulate(cbegin(nums), cend(nums), 0);
return sum / nums.size();
}

accumulate()算法接收的第三个参数是总和的初始值,在这个例子中为0(加法计算的恒等值),表示从0开始累加总和。accumulate()的第二种形式允许调用者指定要执行的操作,而不是执行默认的加法操作。这个操作的形式是二元回调。假设需要计算几何平均数。如果一个序列中有m个数字,那么几何平均数就是m个数字连乘的m次方根。在这个例子中,调用accumulate()计算乘积而不是总和。因此这个程序可以这样写:

1
2
3
4
5
6
7
8
int product (int num1, int num2) {
return num1 * num2;
}

double geometricMean(const vector<int>& nums) {
double mult = accumulate (cbegin(nums), cend(nums), 1, product);
return pow (mult, 1.0 / nums.size());
}

注意,将product()函数作为回调传递给accumulate(),而把累计的初始值设置为1(乘法计算的恒等值)而不是0。

在算法中使用移动语义

与标准库容器一样,标准库算法也做了优化,以便在合适时使用移动语义。这可极大地加速特定的算法,例如remove()。因此,强烈建议在需要保存到容器中的自定义元素类中实现移动语义。通过实现移动构造函数和移动赋值运算符,任何类都可添加移动语义。它们都被标记为noexcept,因为它们不应抛出异常。

std::function

std::function<functional>头文件中定义,可用来创建指向函数、函数对象或lambda表达式的类型:从根本上说可以指向任何可调用的对象。它被称为多态函数包装器,可以当成函数指针使用,还可用作实现回调的函数的参数。std::function模板的模板参数看上去和大多数模板参数都有所不同。语法如下所示:

1
std::function<R(ArgTypes...)>

R是函数返回值的类型,ArgTypes是一个以逗号分隔的函数参数类型的列表。

下例演示如何使用std::function实现一个函数指针。这段代码创建了一个函数指针f1,它指向函数func()。定义f1后,可通过函数名funcf1调用func()

1
2
3
4
5
6
7
8
9
void func(int num, const string& str) {
cout << "func(" << num <<", "<< str << endl;
}

int main() {
function<void(int, const string&)> f1 = func;
f1(1, "test");
return 0;
}

下面的f1定义实现了同样的功能,而且简短得多,但f1的编译器推断类型是函数指针(即void(*f1)(int, const string&))而不是std::function

1
auto f1 = func;

由于std::function类型的行为和函数指针一致,因此可传递给标准库算法,如下面这个使用了find_if()算法的例子所示:

1
2
3
4
5
6
7
8
9
10
11
12
bool isEven(int num1) {
return num % 2 == 0;
}

int main {
vector<int> vec{1,2,3,4,5,6,7,8,9};
function<bool (int)> fcn = isEven;
auto result = find_if(cbegin(vec), cend(vec), fcn);
if (result != cend(vec))
cout <<"First even number:"<< *result << endl;
return 0;
}

std::function真正有用的场合是将回调作为类的成员变量。在接收函数指针作为自定义函数的参数时,也可以使用std::function。下例定义了process()函数,这个函数接收一个对vector的引用和std::functionprocess()函数迭代给定vector中的所有元素,然后对每个元素调用指定的函数f。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void process(const vector<int>& vec, function<void(int)> f) {
for (auto& i : vec)
f(i);
}

void print (int num) {
cout << num <<" ";
}

int main() {
vector<int>vec{ 0,1,2,3,4,5,6,7,8,9 };
process(vec, print);

int sum = 0;
process (vec, [&sum](int num) {sum += num;});
return 0;
}

lambda表达式

使用lambda表达式可编写内嵌的匿名函数,而不必编写独立函数或函数对象,使代码更容易阅读和理解。

语法

下面定义一个lambda表达式,它仅把一个字符串写入控制台。

lambda表达式以方括号[]开始(这称为lambda引入符),其后是花括号{},其中包含lambda表达式体。lambda表达式被赋予自动类型变量basicLambda。第二行使用普通的函数调用语法执行lambda表达式。

1
2
auto basicLambda = [] {cout << "Hello from Lambda"<< endl; };
basicLambda();

lambda表达式可以接收参数。参数在圆括号中指定,用逗号分隔开,与普通函数相同。下面是使用参数的示例:

1
2
auto parametersLambda = [](int value){ cout <<"The value is "<<value << endl; };
parametersLambda(42);

如果lambda表达式不接收参数,就可指定空圆括号或忽略它们。

lambda表达式可返回值。返回类型在箭头后面指定,称为拖尾返回类型。下例定义的lambda表达式接收两个参数,返回它们的和:

1
2
auto returningLambda = [] (int a, int b) -> int { return a + b; };
int sum = returningLambda(11, 22);

可以忽略返回类型。如果忽略了返回类型,编译器就根据函数返回类型推断规则来推断lambda表达式的返回类型,如下所示:

1
2
auto returningLambda = [](int a, int b) { return a + b; };
int sum = returningLambda(11, 22);

lambda表达式可以在其封装的作用域内捕捉变量。例如,下面的lambda表达式捕捉变量data,将它用于lambda表达式体:

1
2
double data = 1.23;
auto capturingLambda = [data] { cout <<"Data "<< data << endl; };

lambda表达式的方括号部分称为lambda捕捉块(capture block)。捕捉变量的意思是可在lambda表达式体中使用这个变量。指定空白的捕捉块[]表示不从所在作用域内捕捉变量。如上例所示,在捕捉块中只写出变量名,将按值捕捉该变量。

编译器将lambda表达式转换为某种未命名的仿函数(即函数对象),捕捉的变量变成这个仿函数的数据成员。将按值捕捉的变量复制到仿函数的数据成员中。这些数据成员与捕捉的变量具有相同的const性质。在前面的capturingLambda示例中,仿函数得到非const数据成员data,因为捕捉的变量data不是const。但在下例中,仿函数得到const数据成员data, 因为捕捉的变量是const。

1
2
const double data = 1.23;
auto capturingLambda = [data]{ cout << "Data " << data << endl; };

仿函数总是实现函数调用运算符operator()。对于lambda表达式,这个函数调用运算符被默认标记为const,这表示即使在lambda表达式中按值捕捉了非const变量,lambda表达式也不能修改其副本。把lambda表达式指定为mutable,就可以把函数调用运算符标记为非const:

1
2
double data = 1.23;
auto capturinglambda = [data] () mutable { data *= 2; cout << "Data - " << data << endl; };

在这个示例中,非const变量data是按值捕捉的,因此仿函数得到了一个非const数据成员,它是data的副本。因为使用了mutable关键字,函数调用运算符被标记为非const,所以lambda表达式体可以修改data的副本。注意如果指定了mutable,就必须给参数指定圆括号,即使圆括号为空,也是如此。

在变量名前面加上&,就可按引用捕捉它。按引用捕捉变量时,必须确保执行lambda表达式时,该引用仍然是有效的。可采用两种方式来捕捉所在作用域内的所有变量。

  • [=]:通过值捕捉所有变量,
  • [&]:通过引用捕捉所有变量

还可以酌情决定捕捉哪些变量以及这些变量的捕捉方法,方法是指定一个捕捉列表,其中带有可选的默认捕捉选项。前缀为&的变量通过引用捕捉。不带前缀的变量通过值捕捉。默认捕捉应该是捕捉列表中的第一个元素,可以是=&。例如

  • [&x]:只通过引用捕捉x,不捕捉其他变量
  • [x]:只通过值捕捉x,不捕捉其他变量,
  • [=,&x,&y]:默认通过值捕捉,变量x和y是例外,这两个变量通过引用捕捉。
  • [&,x]:默认通过引用捕捉,变量x是例外,这个变量通过值捕捉。
  • [&x,&x]:非法,因为标识符不允许重复。
  • [this]:捕捉周围的对象。即使没有使用this->,也可在lambda表达式体中访问这个对象。
  • [*this]:捕捉当前对象的副本。如果在执行lambda表达式时对象不再存在,这将十分有用。

使用默认捕捉时,只有在lambda表达式体中真正使用的变量才会被捕捉,使用值(=)或引用(&)捕捉。未使用的变量不捕捉,

不建议使用默认捕捉,即使只捕捉在lambda表达式体中真正使用的变量,也同样如此。使用=默认捕捉可能在无意中引发昂贵的复制。使用&默认捕捉可能在无意间修改所在作用域内的变量,建议显式指定要捕捉的变量。

lambda表达式的完整语法如下所示:

1
2
3
[capture_block] (parameters) mutable constexpr
noexcept_specifier attributes ->
return_type { body }

lambda表达式包含以下部分

  • 捕捉块(capture block):指定如何捕捉所在作用域内的变量,并供lambda主体部分使用。
  • 参数(parameter,可选):lambda表达式使用的参数列表。只有在不需要任何参数并且没有指定mutable、constexpr、noexcep说明符、属性和返回类型的情况下才能忽略参数列表。该参数列表和普通函数的参数列表类似,
  • mutable(可选):把lambda表达式标记为mutable
  • constexpr(可选):将lambda表达式标记为constexpr,从而可在编译时计算。如果满足某些限制条件,即使忽略,也可能为lambda表达式隐式使用constexpr。
  • noexcept说明符(可选):用于指定noexcept子句,与普通函数的noexcept子句类似。
  • 特性(attribute,可选):用于指定lambda表达式的特性。
  • 返回类型(可选):返回值的类型。如果忽略,编译器会根据函数返回类型推断原则判断返回类型。

泛型lambda表达式

可以给lambda表达式的参数使用自动推断类型功能,而无须显式指定它们的具体类型。要为参数使用自动推断类型功能,只需要将类型指定为auto,类型推断规则与模板参数推断规则相同。

下例定义了一个泛型lambda表达式isGreaterThan100。这个lambda表达式与find_if()算法一起使用,一次用于整数vector,另一次用于双精度vector。

1
2
3
4
5
6
7
8
9
10
11
auto isGreaterThan100 = [](auto i)( return i > 100; };

vector<int> ints(11, 55, 101, 200);
auto it1 = find_if(cbegin(ints), cend(ints), isGreaterThan100);
if (it1 != cend(ints))
cout << "Found a value > 100: " << *it1 <<endl;

vector<double> doubles { 11.1, 55.5, 200.2 };
auto it2 = find_if(cbegin(doubles), cend(doubles), isGreaterThan100);
if (it2 != cend(doubles))
cout << "Found a value > 100: "<< *it2 << endl;

lambda捕捉表达式

lambda捕捉表达式允许用任何类型的表达式初始化捕捉变量。这可用于在lambda表达式中引入根本不在其内部的作用域内捕捉的变量,例如,下面的代码创建一个lambda表达式,其中有两个变量:myCapture使用lambda捕捉表达式初始化为字符串Pi:,pi在内部的作用域内按值捕捉。注意,用捕捉初始化器初始化的非引用捕捉变量,如myCapture,是通过复制来构建的,这表示省略了const限定符:

1
2
double pi = 3.1415;
auto myLambda = [myCapture = "Pi: ", pi] { cout << myCapture << pi; };

lambda捕捉变量可用任何类型的表达式初始化,也可用std:move()初始化。这对于不能复制、只能移动的对象而言很重要,例如unique_ptr。默认情况下,按值捕捉要使用复制语义,所以不可能在lambda表达式中按值捕捉unique_ptr。使用lambda捕捉表达式,可通过移动来捕捉它,例如:

1
2
auto myPtr = std::make_unique<double>(3.1415);
auto mylambda = [ p = std::move(myPtr)] { cout << *p; };

将lambda表达式用作返回类型

使用前面讨论的std::function,可从函数返回lambda表达式,分析以下定义:

1
2
function<int(vold)> multiplyBy2Lambda(int x) {
return [x]{ return 2 * x; };

这个函数的主体部分创建一个lambda表达式,通过值捕捉所在作用域的变量x,并返回一个整数,这个整数是传给multiplyBy2Lambda()的值的两倍。multiplyBy2Lambda()函数的返回类型为function<int(void)>,即一个不接收参数并返回一个整数的函数。函数体中定义的lambda表达式正好匹配这个原型。变量x通过值捕捉,因此,
在lambda表达式从函数返回之前,x值的副本被绑定至lambda表达式中的x。可按如下方式调用该函数:

1
2
function<int(void)> fn = multiplyBy2Lambda(5);
cout << fn() << endl;

标准库算法示例

count_if()

下例通过count_if()算法计算给定vector中满足特定条件的元素个数。通过lambda表达式的形式给出条件,这个lambda表达式通过值捕捉所在作用域内的value变量。

1
2
3
vector<int> vec {1, 2, 3, 4, 5, 6, 7, 8, 9};
int value = 3;
int cnt = count_if(cbegin(vec), cend(vec), [value] (int i) { return i > value;});

可对上面的这个例子进行扩展,以演示通过引用捕捉变量的方式。下面的lambda表达式通过递增所在作用域内按引用捕捉的一个变量,来计算调用次数。

1
2
3
4
vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9};
int value = 3;
int cntlambdaCalled = 0;
int cnt = count_if(cbegin(vec), cend(vec), [value, &cntLambdaCalled] (int i) {++cntLambdaCalled; return i > value;});

generate()

generate()算法需要一个迭代器范围,它把该迭代器范围内的值替换为从函数返回的值,并作为第三个参数。下例结合generate()算法和一个lambda表达式将2、4、8、16等值填充到vector。

1
2
3
vector<int> vec(10);
int value = 1;
generate(begin(vec), end(vec), [&value]{ value *= 2; return value; });

函数对象

在类中,可重载函数调用运算符,使类的对象可取代函数指针。将这些对象称为函数对象(finction object),或称为仿函数(functor)。

很多标准库算法,例如find_if()以及accumulate(),可接收函数指针、lambda表达式和仿函数作为参数,以更改函数行为。

建议尽可能使用lambda表达式,而不是小型函数对象,因为lambda表达式更便于使用、读取和理解。

算术函数对象

C++提供了5类二元算术运算符的仿函数类模板:plus,minus,multiplies,divides和modulus。此外提供了一元的取反操作。这些类对操作数的类型模板化,是对实际运算符的包装。它们接收一个或两个模板类型的参数,执行操作并返回结果。下面是一个使用plus类模板的示例:

1
2
3
plus<int> myPlus;
int res = myPlus(4, 5);
cout << res <<endl;

算术函数对象的好处在于可将它们以回调形式传递给算法,而使用算术运算符时却不能直接这样做。

算术函数对象只不过是对算术运算符的简单包装。如果在算法中使用函数对象作为回调,务必保证容器中的对象实现了恰当的操作。

透明运算符仿函数

C++支持透明运算符仿函数,允许忽略模板类型参数。例如,可只指定multiplies<>(而非multiplies<int>()

1
2
3
double geometricMeanTransparent(const vector<int>& nums)
double mult = accumulate(cbegin (nums), cend(nums), 1, multiplies<>());
return pow (mult, 1.0 / nums.size());

这些透明运算符仿函数的一个重要特性是,它们是异构的,即它们不仅比非透明运算符仿函数更简明,而且具有真正的函数性优势。例如,下面的代码使用透明运算符仿函数和双精度数1.1作为初始值,而vector包
含整数。accumulate()会把结果计算为double值,result是6.6:

1
2
vector<int> nums { 1,2,3 };
double result = accumulate(cbegin(nums), cend(nums), 1.1, mu1tip1ies<>());

如果这些代码使用非透明运算符仿函数,accumulate()会把结果计算为整数,result就是6。编译这些代码时,编译器会给出可能丢失数据的警告:

1
2
vector<int> nums { 1,2,3 };
double result - accumulate (cbegin (nums), cend(nums), 1.1, multiplies<int>());

比较函数对象

除算术函数对象类外,C++语言还提供了所有标准的比较:equal_tonot_equal_tolessgreaterless_equalgreater_equal。可将priority_queue的比较模板修改为greater。priority_queue模板定义如下所示:

1
template <class T, class Container = vector<T>, class Compare = less<T>>;

遗憾的是,Compare类型参数是最后一个参数,这意味着要指定比较操作,还必须指定容器类型。如果希望priority_queue通过greater按升序对元素排序,需要把上例中的priority_queue定义改为:
1
priority_queue<int, vector<int>, greater<>>myQueue;

注意,使用透明运算符greater<>定义了myQueue。与使用非透明运算符相比,使用透明比较回调的性能稍好一些。

逻辑函数对象

C++为3个逻辑操作提供了函数对象类,它们分别是:logical_not(operator!)logical_and(operator&&)logical_or(operator||)。逻辑操作只操作true和false值。

例如,可使用逻辑仿函数来实现allTrue()函数,这个函数检查容器中的所有布尔标志是否都为true:

1
2
3
bool allTrue (const vector<bool>& flags) {
return accumulate(begin(flags), end(flags), true, logical_and<>());
}

类似地,可使用logical_or仿函数实现anyTrue()函数,如果容器中至少有一个布尔标志为true,那么这个函数返回true:

1
2
3
bool anyTrue (const vector<bool>& flags) {
return accumulate(begin(flags),end(flags), false, logical_or<>());
}

按位函数对象

C++为所有按位操作添加了函数对象,它们分别是:bit_and(operator&)、bit_or(operator|)、bit_xor(operator^)和bit_not(operator!)。

函数对象适配器

函数适配器对函数组合(functional composition)提供了一些支持,也就是能将函数组合在一起,以精确提供所需的行为。

绑定器

绑定器(binder)可用于将函数的参数绑定至特定的值。为此要使用<functional>头文件中定义的std::bind(),它允许采用灵活的方式绑定可调用的参数。既可将函数的参数绑定至固定值,甚至还能重新安排函数参数的顺序。下面通过一个例子进行解释,假定有一个func()函数,它接收两个参数:

1
void func(int num, string_view str)

下面的代码演示如何通过bind()func()函数的第二个参数绑定至固定值myString。结果保存在f1()中。使用auto关键字是因为C++标准未指定bind()的返回类型,因而是特定于实现的。没有绑定至指定值的参数应该标记为_1_2_3等。这些都定义在std::placeholders名称空间中。

f1()的定义中,_1指定了调用func()时,f1()的第一个参数应该出现的位置。之后,就可以用一个整型参数调用f1()

1
2
3
string mystring = "abc";
auto f1 = bind(func, placeholders::_1, myString);
f1(16);

bind()还可用于重新排列参数的顺序,如下列代码所示。_2指定了调用func()时,20的第二个参数应该出
现的位置。换句话说,12(绑定的意义是:f2()的第一个参数将成为函数func()的第二个参数,f2()的第二个参数将成为函数func()的第一个参数

1
2
auto f2 = bind(func, placeholders::_2, placeholders::_1);
f2("Test"32);

<functional>头文件定义了std::ref()cref()辅助模板函数。它们可分别用于绑定引用或const引用。如果使用bind()调用函数,引用变量的值就不递增,因为建立了变量的一个副本。使用std::ref()正确传递对应的引用后会递增。

结合重载函数使用时,绑定参数会出现一个小问题。如果要对这些重载的函数使用bind(),那么必须显式地指定绑定这两个重载中的哪一个。如果需要绑定接收浮点数参数的重载函数的参数,需要使用以下语法:

1
auto f4 = bind((void(*)(float))overloaded, placeholders::_1);

取反器

not_fn

取反器(negator)类似于绑定器(binder),但对调用结果取反。例如,如果想要找到测试分数序列中第一个小于100的元素,那么可以对perfectScore()的结果应用notl()取反器适配器,如下所示:

1
2
3
4
auto enditer = end(myVector);
auto it = find_if(begin(myVector), endIter, not_fn(perfectScore));
if (it == enditer)
cout << "All perfect scores" << endl;

not_fn()仿函数对作为参数传入的每个调用结果取反。注意在这个示例中,也可以使用find_if_not()算法。

前面使用not_fn()取反器的find_if()调用可以用lambda表达式更简洁地表达:

1
auto it = find_if(begin(myVector), endIter, [](int i){return i < 100; });

not1和not2

C++17引入了std::not_fn()适配器。在C++17之前,可使用std:not1()not2()适配器。not1中的“1”是指:它的操作数必须是一个一元函数(只接收一个参数)。如果操作数是二元函数(接收两个参数),则必须改用not2()。下面是一个示例:

1
2
3
auto enditer = end(myVector);
function<bool(int)> f = perfectscore;
auto it = find_if(begin(myVector), endIter, not1(f));

如果想将not1()用于自己的仿函数类,则必须确保仿函数类的定义包含两个typedef:argument_typeresult_type。如果想要使用not2(),则仿函数类的定义必须提供3个typedef:first_argument_typesecond_argument_typeresult_type。为此,最简便的方式是从unary_functionbinary_function派生自己的函数对象类,具体取决于使用的是一个参数还是两个参数。在<functional>中定义的两个类在所提供函数的参数和返回类型上模板化。例如:

1
2
3
4
5
6
class Perfectscore : public std::unary_function<int, bool> {
public:
result_type operator() (const argument_type& score) const {
return score >= 100;
}
};

可采用如下方式使用这个仿函数:

1
auto it = find_if(begin(myVector), endIter, not1(PerfectScore()));

调用成员函数

假设有一个对象容器,有时需要传递一个指向类方法的指针作为算法的回调。调用方法指针的代码和调用普通函数指针的代码是不一样的,因为前者必须在对象的上下文中调用。C++提供了mem_fn()转换函数,在传递给算法之前可以对函数指针调用这个函数。必须将string中的empty()方法指针指定为&string::empty&string::部分不是可选的。

1
2
3
4
5
void findEmptyString(const vector<string>& strings) {
auto enditer = end(strings);
auto it = find_if(begin(strings), endIter, mem_fn(&string::empty));
if (it == enditer)
cout <<"No empty strings!"<< endl;

mem_fn()生成一个用作find_if()回调的函数对象。每次调用它时,都会对参数调用empty()方法。

即使容器内保存的不是对象本身,而是对象指针,mem_fn()的使用方法也完全一样,例如:

1
2
3
void findEmptystring(const vector<string*>& strings) {
auto enditer = end(strings);
auto it = find_if(begin(strings), enditer, mem_fn(&string::empty));

std::invoke()

C++17引入了std::invoke()std::invoke()<functional>中定义,可用于通过一组参数调用任何可调用对象。下例使用了三次invoke():一次调用普通函数,另一次调用lambda表达式,还有一次调用string实例的成员函数

1
2
3
4
5
6
7
8
9
void printMessage(string_view message) { cout << message << endl; }

int main() {
invoke(printMessage, "Hello invoke.");
invoke([](const auto& msg) { cout << msg << endl; }, "Hello invoke.");

string msg = "Hello invoke.";
cout << invoke(&string::size, msg) << endl;
}

invoke()本身的作用并不大,因为可以直接调用函数或lambda表达式。但在模板代码中,如果需要调用任意可调用对象,invoke()的作用会发挥出来。

算法详解

迭代器

迭代器有5类:输入、输出、正向、双向和随机访问迭代器。确切地讲,每个随机访问迭代器也是双向的,每个双向迭代器也是正向的,每个正向迭代器也是输入迭代器。满足输出迭代器要求的迭代器称为可变迭代器(mutable iterator),否则称为不可变迭代器(constant iterator)。

算法指定要使用的迭代器类型的标准方法是,在迭代器模板类型参数中使用以下名称:InputIteratorOutputIteratorForwardIteratorBidirectionalIteratorRandomAccessIterator。这些只是名称,没有提供绑定类型的检查。因此,可在调用需要RandomAccessIterator的算法时,传入双向迭代器。模板不会进行类型检查,因此允许这样的实例化。然而,函数中使用随机访问迭代器功能的代码,在使用双向迭代器时将无法成功编译。

非修改序列算法

非修改序列算法包括在某个范围内搜索元素的函数、比较两个范围的函数以及许多工具算法。

搜索算法

标准库提供了基本find()算法的一些其他变种,这些算法对元素序列执行操作。所有算法都使用默认的比较运算符operator==operator<,还提供了重载版本,以允许指定比较回调。下面是一些搜索算法的示例:

1
2
3
4
5
6
7
vector<int> myVector = {5, 6, 9. 8, 8, 3};
auto beginiter = cbegin (myVector);
auto enditer = cend(myVector);

auto it = find_if_not(beginiter, enditer, [](int i){ return i<8;});
if (it != enditer)
cout <<"First element not < 8 is "<< *it << endl;

专用的搜索算法

C++17给search()算法增加了额外的可选参数,允许指定要使用的搜索算法。有三个选项:default_searcherboyer_moore_searcherboyer_moore_horspool_searcher,它们都在<functional>中定义。后两个选项实现了知名的Boyer-Moore和Boyer-Moore-Horspool搜索算法。它们十分高效,可用于在一大块文本中查找子字符串。Boyer-Moore搜索算法的复杂度如下,N是在其中搜索的序列的大小,M是要查找的模式的大小。如果未找到模式,最坏情况下的复杂度为O(N+M),如果找到模式,最坏情况下的复杂度为O(NM)。Boyer-Moore和 Boyer-Moore-Horspool算法的区别在于,在初始化以及算法的每个循环迭代中,后者的固定开销较少;但是,后者在最坏情况下的复杂度明显高于前者算法。

比较算法

可通过3种不同的方法比较整个范围内的元素:equal()mismatch()和lexicographical_compare()`。这些算法的好处是可比较不同容器内的范围。例如,可比较vector和list的内容。一般情况下,这些算法最适用于顺序容器。这些算法的工作方法是比较两个集合中对应位置的值。下面列出每个算法的工作方式。

  • equal():如果所有对应元素都相等,则返回true。最初,equal()接收三个迭代器,分别是第一个范围的首尾迭代器,以及第二个范围的首迭代器。该版本要求两个范围的元素数目相同。
  • mismatch():返回多个迭代器,每个范围对应一个迭代器,表示范围内不匹配的对应元素。与equal()一样,存在三迭代器版本和四迭代器版本。
  • lexicographical_compare():如果第一个范围内的第一个不相等元素小于第二个范围内的对应元素,或如果第一个范围内的元素个数少于第二个范围,且第一个范围内的所有元素都等于第二个范围内对应的初始子序列,那么返回true。

如果要比较两个同类型容器的元素,可使用运算符operator==operator<,而不是equal()lexicographical_compare()

计数算法

非修改计数算法有all_of()any_of()none_of()count()count_if()。下面是一个算法的示例。

1
2
3
4
//all_of()
vector<int> vec2 = {1, 1, 1, 1};
if(all_of(cbegin(vec2), cend(vec2), [](int i){ return i== 1;)))
cout << “All elements are == 1"<<endl;

修改序列算法

标准库提供了多种修改序列算法,这些算法执行的任务包括:从一个范围向另一个范围复制元素、删除元素以及反转某个范围内元素的顺序。

修改算法不能将元素插入目标范围中,仅可重写/修改目标范围中已经存在的元素。

map和multimap的范围不能用作修改算法的目标范围。这些算法改写全部元素,而在map中,元素是键值对。map和multimap将键标记为const,因此不能为其赋值。set和multiset也是如此。 替换方案是使用插入迭代器。

转换

transform()算法对范围内的每个元素应用回调,期望回调生成一个新元素,并保存在指定的目标范围中。如果希望transform()将范围内的每个元素替换为调用回调产生的结果,那么源范围和目标范围可以是同一范围。其参数是源序列的首尾迭代器、目标序列的首迭代器以及回调。例如,可按如下方式将vector中的每个元素增加100:

1
transform(begin(myVector), end(myVector), begin(myVector), [](int i){ return it 100;}};

transform()的另一种形式对范围内的元素对调用二元函数,需要将第一个范围的首尾迭代器、第二个范围的首迭代器以及目标范围的首迭代器作为参数。 下例创建两个vector,然后通过transform()计算元素对的和,并将结果保存回第一个vector:

1
transform(begin(vec1), end(vec1), begin(vec2), begin(vec1), [](int a, int b){return a + b;});

transform()和其他修改算法通常返回一个引用目标范围内最后一个值后面那个位置(past-the-end)的迭代器。

复制

copy()算法可将一个范围内的元素复制到另一个范围,从这个范围内的第一个元素开始直到最后一个元素。源范围和目标范围必须不同,但在一定限制条件下可以重叠。限制条件如下:对于copy(b,e,d),如果d在b之前,则可以重叠;但如果d处于[b,e]范围,则行为不确定。与所有修改算法类似,copy()不会向目标范围插入元素,只改写已有的元素。

下面举一个使用copy()的简单例子,这个例子对vector应用resize()方法,以确保目标容器中有足够空间。这个例子将vec1中的所有元素复制到vec2:

1
2
3
4
vector<int> vecl, vec2;

vec2.resize(size(vec1));
copy(cbegin(vec1), cend(vec1), begin(vec2));

还有一个copy_backward()算法,这个算法将源范围内的元素反向复制到目标范围。换句话说,这个算法从源范围的最后一个元素开始,将这个元素放在目标范围的最后一个位置,然后在每一次复制之后反向移动。分析copy_backward(),源范围和目标范围必须是不同的,但在一定限制条件下可以重叠。限制条件如下:对于copy_backward(b,e,d),如果d在e之后,则能正确重叠;但如果d处于(b,e]范围,则行为不确定。前面的例子可按如下代码修改为使用copy_backward()而不是copy()。注意第三个参数应该指定end(vec2)而不是begin(vec2)

1
copy_backward (cbegin (vec1),cend(vec1), end(vec2));

得到的输出完全一致。

在使用copy_if()算法时,需要提供由两个迭代器指定的输入范围、由一个迭代器指定的输出范围以及一个谓词(函数或lambda表达式)。该算法将满足给定谓词的所有元素复制到目标范围。记住,复制不会创建或扩大容器,只是替换现有元素。因此,目标范围应当足够大,从而保存要复制的所有元素。当然,复制元素后,最好删除超出最后一个元素复制位置的空间。为便于达到这个目的,copy_if()返回了目标范围内最后一个复制的元素后面那个位置的迭代器,以便确定需要从目标容器中删除的元素个数。

1
2
3
4
5
vector<int> vec1, vec2;

vec2.resize(size(vec1));
auto enditerator = copy_if(cbegin(vec1), cend(vec1), begin(vec2), [](int i){ return i % 2 == 0;});
vec2.erase(enditerator, end(vec2));

copy_n()从源范围复制n个元素到目标范围。copy_n()的第一个参数是起始迭代器,第二个参数是指定要复制的元素个数,第三个参数是目标迭代器。copy_n()算法不执行任何边界检查,因此一定要确保起始迭代器递增n个要复制的元素后,不会超过集合的end(),否则程序会产生未定义的行为。

1
2
3
4
5
6
vector<int> vec1, vec2;

size_t cnt = 0;
cnt = min(cnt, size(vec1));
vec2.resize(cnt);
copy_n(cbegin(vec1), cnt, begin(vec2));

移动

有两个和移动相关的算法:move()move_backward()。如果要在自定义类型元素的容器中使用这两个算法,那么需要在元素类中提供移动赋值运算符。main()函数创建了一个带有3个MyClass对象的vector,然后将这些元素从vecSrc移到vecDst。注意这段代码包含两种不同的move()用法。一种是,move()函数接收一个参数,将Ivalue转换为rvalue;而另一种是,接收3个参数的move()是标准库的move()算法,这个算法在容器之间移动元素。

1
2
3
4
5
6
7
Class MyClass {}

int main() {
vector<MyClass> vecSrc{MyClass("a"), MyClass("b"), MyClass("c")};
vector<MyClass> vecDst(vecSrc.size());
move(begin(vecSrc), end(vecSrc),begin(vecDst));
}

替换

replace()replace_if()算法将一个范围内匹配某个值或满足某个谓词的元素替换为新的值。比如replace_if()算法的第一个和第二个参数指定了容器中元素的范围。第三个参数是一个返回true或false的函数或lambda表达式,如果它返回true,那么容器中的对应值被替换为第四个参数指定的值;如果它返回false,则保留原始值。例如,假定要将容器中的所有奇数值替换为0:

1
2
vector<int> vec;
replace_if(begin(vec),end(vec), [](int i) { return i%2 != 0;}, 0);

replace()replace_if()也有名为replace_copy()replace_copy_if()的变体,这些变体将结果复制到不同的目标范围。它们类似于copy(),因为目标范围必须足够大,以容纳新元素。

删除

如果对vector容器应用erase(),这个解决方案的效率非常低下,因为要保持vector在内存中的连续性,会涉及很多内存操作,因而得到:二次(平方)复杂度;所谓二次复杂度,是指运行时间是输入大小的平方的函数。这个问题的正确解决方案是“删除擦除法”,

算法只能访问迭代器抽象,不能访问容器。因此删除算法不能真正地从底层容器中删除元素,而是将匹配给定值或谓词的元素替换为下一个不匹配给定值或渭词的元素。为此使用移动赋值。结果是将范围分为两个集合:一个用于保存要保留的元素,另一个用于保存要删除的元素。返回的迭代器指向要删除的元素范围内的第一个元素。如果真的需要从容器中删除这些元素,必须先使用remove()算法,然后调用容器的erase()方法,将从返回的迭代器开始到范围尾部的所有元素删除。这就是删除擦除法。

1
2
3
auto it = remove_if(begin(strings), end(strings), [](const string& str) { return str.empty(); });

strings.erase(it, end(strings));

使用“删除擦除法”时,切勿忘记erase()的第二个参数!如果忘掉第二个参数,erase()将仅从容器中删除一个元素,即作为第一个参数传递的迭代器指向的元素。

remove()remove_if()remove_copy()remove_copy_if()变体不会改变源范围,而将所有未删除的元素复制到另一个目标范围。这些算法和copy()类似,要求目标范围必须足够大,以便保存新元素。

唯一化

unique()算法是特殊的remove()remove()能将所有重复的连续元素删除。list容器提供了自己的具有同样语义的unique()方法。

抽样

sample()算法从给定的源范围返回n个随机选择的元素,并存储在目标范围。它需要5个参数:

  • 要从中抽样的范围的首尾迭代器
  • 目标范围的首迭代器,将随机选择的元素存储在目标范围
  • 要选择的元素数量
  • 随机数生成引擎

反转

reverse()算法反转某个范围内元素的顺序。将范围内的第一个元素和最后一个元素交换,将第二个元素和倒数第二个元素交换,依此类推。reverse()最基本的形式是就地运行,要求两个参数:范围的首尾迭代器。还有一个名为reverse_copy()的版本,这个版本将结果复制到新的目标范围,它需要3个参数:源范围的首尾迭代器以及目标范围的起始迭代器。
目标范围必须足够大,以便保存新元素。

shuffle()以随机顺序重新安排某个范围内的元素,其复杂度为线性时间。它可用于实现洗牌等任务。shuffle()的参数是要乱序的范围的首尾迭代器,以及一个统一的随机数生成器对象,它指定如何生成随机数。

操作算法

此类算法只有两个:for_each()for_each_n(),后者是在C++17中引入的。它们对范围内的每个元素执行回调,或对范围内的前n个元素执行回调。

for_each()

下例说明如何使用for_each()算法和lambda表达式,计算范围内元素的和与积。注意,lambda表达式只显式捕捉需要的变量,它按引用捕捉变量,否则lambda表达式内对sum和product的修改无法在lambda表达式外可见:

1
2
3
4
5
vector<int> myVector;

int sum = O;
int product = 1;
for_each(cbegin(myVector), cend(myVector), [&sum, &product](int i){ sum += i; product *= i;});

for_each_n()

for_each_n()算法需要范围的起始迭代器、要迭代的元素数量以及函数回调。它返回的迭代器等于begin+n。它通常不执行任何边界检查。下例只迭代map的前两个元素:

1
for_each_n(cbegin(myMap),2, [] (const auto& p) {cout << p.first << p.second << endl; });

交换算法

C++标准库提供了以下交换算法

swap()

std::swap()用于有效地交换两个值,并使用移动语义(如果可用的话)。它的使用十分简单:

1
2
3
4
5
int a = 11;
int b = 22;
cout << "Before swap(): a = "<< a << ", b = "<< b << endl;
swap(a, b);
cout << "After swap(): a =" << a << ", b = "<< b << endl;

exchange()

std::exchange()<utility>中定义,用新值替换旧值,并返回旧值,如下所示:

1
2
3
4
5
int a = 11;
int b = 22;
cout << "Before exchange(): a = "<< a << ", b = "<< b << endl;
int returnedValue = exchange(a, b);
cout << "After exchange(): a = " << a << ", b = "<< b << endl;

exchange()用于实现移动赋值运算符。移动赋值运算符需要将数据从源对象移到目标对象。通常,源对象中的数据会变为null。

分区算法

partition_copy()算法将来自某个来源的元素复制到两个不同的目标。为每个元素选择特定目标的依据是谓词的结果:true或false。partition_copy()的返回值是一对迭代器:一个迭代器引用第一个目标范围内最后复制的那个元素的后一个位置,另一个迭代器引用第二个目标范围内最后复制的那个元素的后一个位置。将这些返回的迭代器与erase()结合使用,可删除两个目标范围内多余的元素。

1
2
3
4
5
6
vector<int> vec1, vecodd, veceven;
vecodd.resize(size(vec1));
vecEven.resize(size(vec1));
auto pairIters = partition_copy(cbegin(vec1), cend(vec1), begin (veceven), begin (vecodd), [](int i){return i%2 == 0;});
vecEven.erase(pairIters.first, end(veceven));
vecodd.erase (pairIters.second, end(vecodd));

partition()算法对序列排序,使谓词返回true的所有元素放在前面,使谓词返回false的所有元素放在后面,在每个分区中不保留元素最初的顺序。下例演示了如何把vector分为偶数在前、奇数在后的分区:

1
2
vector<int> vec;
partition(begin(vec), end(vec), [] (int i) {return i%2==0;});

排序算法

“排序算法”重新排列容器中元素的顺序,使集合中的元素保持连续顺序。因此,排序算法只能应用于顺序集合。通用的排序算法最适用于vector、deque、array和C风格数组。sort()函数一般情况下在O(NlogN)时间内对某个范围内的元素排序。将sort()应用于一个范围之后,根据运算符operator<,这个范围内的元素以非递减顺序排列(最低到最高)。如果不希望使用这个顺序,可以指定一个不同的比较回调,例如greater。sort()函数的一个名为stable_sort()的变体能保持范围内相等元素的相对顺序。然而,由于这个算法需要维护范围内相等元素的相对顺序,因此这个算法比sort()算法低效。

二叉树搜索算法

有几个搜索算法只用于有序序列或至少已分区的元素序列。这些算法有binary_search()lower_bound()upper_bound()equal_range()lower_bound()upper_bound()equal_range()算法类似于map和set容器中的对应方法。

lower_bound()算法在有序范围内查找不小于(即大于或等于)给定值的第一个元素,经常用于发现在有序的vector中应将新值插入哪个位置,使vector依然有序。下面是一个示例:

1
2
auto iter = lower_bound(begin (vec), end(vec), num);
vec.insert(iter, num);

binary_search()算法以对数时间而不是线性时间搜索元素,需要指定范围的首尾迭代器、要搜索的值以及可选的比较回调。如果在指定范围内找到这个值,这个算法返回true,否则返回false。 下面的例子演示了这个算法:

1
2
if (binary_search(cbegin (vec), cend(vec),num))
cout<<"That number is in the vector."<<endl;

集合算法

集合算法可用于任意有序范围。includes()算法实现了标准的子集判断功能,检查某个有序范围内的所有元素是否包含在另一个有序范围内,顺序任意。

set_union()set_intersection()set_difference()set_symmetric_difference()算法实现了这些操作的标准语义。在集合论中,并集得到的结果是两个集合中的所有元素。交集得到的结果是所有同时存在于两个集合中的元素。差集得到的结果是所有存在于第一个集合中,但是不存在于第二个集合中的元素。对称差集得到的结果是两个集合的“异或”:所有存在于其中一个集合中,但不同时存在于两个集合中的元素。

务必确保结果范围足够大,以保存操作的结果。对于set_union()set_symmetric_difference(),结果大小的上限是两个输入范围的总和。对于set_intersection(),结果大小的上限是两个输入范围的最小大小。对于set_difference()结果大小的上限是第一个输入范围的大小。

不能使用关联容器(包括set)中的迭代器范围来保存结果,因为这些容器不允许修改键。下面是这些算法的使用示例:

1
2
3
4
if (includes(cbegin(vec1), cend(vec1), cbegin (vec2), cend(vec2)))
cout << "The second set is a subset of the first." << endl;

auto newEnd = set_union(cbegin (vec1), cend(vecl), cbegin (vec2), cend (vec2), begin(result));

merge()算法可将两个排好序的范围归并在一起,并保持排好的顺序。结果是一个包含两个源范围内所有元素的有序范围。这个算法的复杂度为线性时间。这个算法需要以下参数:

  • 第一个源范围的首尾迭代器
  • 第二个源范围的首尾迭代器
  • 目标范围的起始迭代器
  • (可选)比较回调

如果没有merge(),还可通过串联两个范围,然后对串联的结果应用sort(),以达到同样的目的,但这样做的效率更低,复杂度为O(NlogN)而不是merge()的线性复杂度。

最大/最小算法

min()max()算法通过运算符operator<或用户提供的二元谓词比较两个或多个任意类型的元素,分别返回一个引用较小或较大元素的const引用。minmax()算法返回一个包含两个或多个元素中最小值和最大值的pair。这些算法不接收迭代器参数。还有使用迭代器范围的min_element()max_element()minmax_element()。下面的程序给出了一些示例:

并行算法

对于60多种标准库算法,C++17支持并行执行它们以提高性能,示例包括for_each()all_of()copy()count_if()find()replace()search()sort()transform(}等。支持并行执行的算法包含选项,接收所谓的执行策略作为第一个参数。

执行策略允许指定是否允许算法以并行方式或矢量方式执行。有三类标准执行策略,以及这些类型的三个全局实例,它们全部定义在std::execution名称空间的<execution>头文件中。如表所示。

执行策略类型 全局实例 描述

sequenced_policy|seq|不允许算法并行执行|
parallel_policy|par|允许算法并行执行|
parallel_unscquenced_policy|par_unscq|允许算法并行执行和矢量化执行,还允许在线程之间迁移执行|

注意,使算法使用parallel_unsequenced_policy执行策略,以允许对回调进行交错函数调用,即不按顺序执行,这意味着会对函数回调施加诸多限制。例如,不能分配释放内存、获取互斥以及使用非锁std::atomics等。对于其他标准策略,函数调用按顺序执行,但顺序无法确定。此类策略不会对函数调用操作施加限制。

下例使用并行策略,对vector的内容进行排序:

1
sort(std::execution::par, begin (myVector), end(myVector));

数值处理算法

inner_product()

<numeric>中定义的inner_product()算法计算两个序列的内积,例如,下面程序中的内积计算为(1*9)+(2*8)+(3*7)+(4*6)

1
2
3
vector<int> v1{1, 2, 3, 4};
vector<int> v2{9, 8, 7, 6};
cout << inner_product(cbegin(v1),cend(v1), cbegin(v2), 0) << endl;

iota()

<numeric>头文件中定义的iota()算法会生成指定范围内的序列值,从给定的值开始,并应用operator++来生成每个后续值。下面的例子展示了如何将这个新算法用于整数的vector,不过要注意这个算法可用于任意实现了operator++的元素类型:

1
2
3
4
vector<int> vec(10);
iota(begin (vec), end(vec), 5);
for (auto& i : vec)
cout << i << endl;

gcd()和lcm()

gcd()算法返回两个整数的最大公约数,而lcm()算法返回两个整数的最小公倍数。它们都定义在<numeric>中。下面是一个示例:

reduce()

需要使用新引入的std:reduce()算法,通过并行执行选项,计算广义和。例如,以下两行同样是求和,但是reduce()以并行和矢量化方式运行,因此速度更快,对于大型输入范围尤其如此:

1
2
double result1 = std::accumulate(cbegin(vec), cend(vec),0.0);
double result2 = std::reduce(std::execution::par_unseq, cbegin(vec), cend(vec));

transform_reduce()

std::inner_product()是另一个不支持并行执行的算法。 相反,需要使用广义的transform_reduce()算法,它具有并行执行选项,可用于计算内积等。

字符串的本地化与正则表达式

本地化

本地化字符串字面量

为能正确地本地化字符串,可采用下面的方式来实现:

1
cout << Format (IDS_TRANSFERRED, n) << endl;

IDS_TRANSFERRED是字符串资源表中一个条目的名称。对于英文版,IDS_TRANSFERRED可定义为"Read $1 bytes";对于荷兰语版,这条资源可以定义为"$1 bytes gelezen"Format()函数加载字符串资源,并将$1替换为n的值。

宽字符

用字节表示字符的问题在于,并不是所有的语言(或字符集)都可以用8位(即1个字节)来表示。C++有一种内建类型wchar_t,可以保存宽字符(wide character)。带有非ASCII字符的语言,例如日语和阿拉伯语,在C++中可以用wchar_t表示。然而,C++标准并没有定义wchar_t的大小。一些编译器使用16位,而另一些编译器使用32位。

在使用wchar_t时,需要在字符串和字符字面量的前面加上字母L,以表示应该使用宽字符编码。例如,要将wchar_t字符初始化为字母m,应该编写以下代码:

1
wchar_t myWideCharacter = L"m";

大部分常用类型和类都有宽字符版本。宽字符版本的string类为wstring。“前缀字母w”模式也可以应用于流。wofstrcam处理宽字符文件输出流,wifstream处理宽字符文件输入流。cout、cin、cerr和clog也有宽字节版本:wcout、wcin、wcerr和wclog。这些版本的使用和非宽字节版本的使用没有区别:

非西方字符集

宽字符是很大的进步,因为宽字符增加了定义一个字符可用的空间。在宽字符集中,和ASCII一样,字符用编号表示,现在称为码点。唯一的区别在于编号不能放在8个位中。下面的列表总结了支持的所有字符类型。

  • char:存储8个位。可用于保存ASCII字符,还可用作保存UTF-8编码的Unicode字符的基本构建块。使用UTF-8时,一个Unicode字符编码为1到4个char。
  • char16_t:存储16个位。可用作保存UTF-16编码的Unicode字符的基本构建块。其中,一个Unicode字符编码为一个或两个charl6_t。
  • char32_t:存储至少32个位。可用于保存UTF-32编码的Unicode字符,每个字符编码为一个char32_t。
  • wchar_t:保存一个宽字符,宽字符的大小和编码取决于编译器。

使用char16_t和char32_t而不是wchar_t的好处在于:char16_t的大小至少16位,char32_t的大小至少32位,它们的大小和编译器无关,而wchar_t不能保证最小的大小。

C++标准还定义了以下两个宏。

  • __STDC_UTF_32__:如果编译器定义了这个宏,那么类型char32_t使用UTF-32编码。如果没有定义这个宏,那么类型char32_t使用与编译器相关的编码。
  • __STDC_UTF_16__:如果编译器定义了这个宏,那么类型char16_I使用UTF-16编码。如果没有定义这个宏,那么类型char16_t使用与编译器相关的编码。

使用字符串前缀可将字符串字面量转换为特定类型。下面列出所有支持的字符串前缀。

  • u8:采用UTF-8编码的char字符串字面量
  • u:表示char16_t字符串字面量,如果编译器定义了__STDC_UTF_16__宏,则表示UTF-16编码。
  • U:表示char32_t字符串字面量,如果编译器定义了__STDC_UTF_32__宏,则表示UTF-32编码。
  • L:采用编译器相关编码的wchar_t字符串字面量。

所有这些字符串字面量都可与第2章介绍的原始字符串字面量前缀R结合使用。例如:

1
2
3
4
const char* s1 = u8R"(Raw UTF-8 encoded string literal)";
const wchar_t* s2 = LR"(Raw wide string literal)";
const char16_t* s3 = uR"(Raw char16_t string literal)";
const char32_t* s4 = UR"(Raw char32_t string literal)";

如果通过u8 UTF-8字符串字面量使用了Unicode编码,或者编译器定义了__STDC_UTF_16____STDC_UTF_32__宏,那么在非原始字符串字面量中可通过\uABCD符号插入指定的Unicode码点。例如,\u03C0表示pi字符。

std::string类外,目前还支持wstring、u16string和u32string。它们的定义如下:

1
2
3
4
using string = basic_string<char>;
using wstring = basic_string<wchar_t>;
using u16string = basic_string<char16_t>;
using u32string = basic_string<char32_t>;

多字节字符由一个或多个依赖编译器编码的字节组成,类似于Unicode通过UTF-8用1到4个字节表示,或者通过UTF-16用一个或两个16位值表示。

转换

C++标准提供codecvt类模板,以帮助在不同编码之间转换。<locale>头文件定义了如表19-1所示的4个编码转换类。

在C++17之前,<codecvt>中定义了以下三种代码转换:codecvt_utf8codecvt_utf16codecvt_utf8_utf16。可通过两种简便的转换接口使用它们:wstring_convertwbuffer_convert

正则表达式

正则表达式在<regex>头文件中定义,是标准库中的一个强大工具。C++包含对以下几种语法的支持。

  • ECMAScript:基于ECMAScript标准的语法。 ECMAScript是符合ECMA-262标准的脚本语言。
  • JavaScript:ActionScript和Jscript等语言的核心都使用ECMAScript语言标准。
  • basic:基本的POSIX语法
  • extended:扩展的POSIX语法
  • awk:POSIX awk实用工具使用的语法。
  • grep:POSIX grep实用工具使用的语法。
  • egrep:POSIX grep实用工具使用的语法,包含E参数,

ECMAScript语法

正则表达式模式是一个字符序列,这种模式表达了要匹配的内容。正则表达式中的任何字符都表示匹配自己,但以下特殊字符除外:^ $ \ . * ? () [] {} |下面将逐一讲解这些特殊字符。如果需要匹配这些特殊字符,那么需要通过\字符将其转义

锚点

特殊字符^字符匹配行终止符所在的位置。^$默认还分别匹配字符串的开头和结尾位置,但可以禁用这种行为。

例如,^test$只匹配字符串test,不匹配包含test和其他任何字符的字符串,例如Itesttest2testabc等。

通配符

通配符(wildcard)可用于匹配除换行符外的任意字符。例如,正则表达式a.c可以匹配abcaSc,但不匹配ab5cac

替代

|字符表示“或”的关系。例如,a|b表示匹配ab

分组

圆括号()用于标记子表达式,子表达式也称为捕捉组(capture group)。捕捉组有以下用途:

  • 捕捉组可用于识别源字符串中单独的子序列,在结果中会返回每一个标记的子表达式(捕捉组)。以如下正则表达式为例:(.)(ab|cd)(.)。 其中有3个标记的子表达式。对字符串1cd4运行regex_search(),执行这个正则表达式会得到含有4个条目的匹配结果。第一个条目是完整匹配1cd4,接下来的3个条目是3个标记的子表达式。这3个条目为:1cd4
  • 捕捉组可在匹配的过程中用于后向引用(back reference)的目的。
  • 捕捉组可在替换操作的过程中用于识别组件。

重复

使用以下4个重复字符可重复匹配正则:表达式中的部分模式:

  • *匹配零次或多次之前的部分。例如:a*b可匹配babaabaaaab等字符串。
  • +匹配一次或多次之前的部分。例如:a+b可匹配abaabaaaab等字符串,但不能匹配b
  • ?匹配零次或一次之前的部分。例如:a?b匹配bab,不能匹配其他任何字符串。
  • {...}表示区间重复。a{n}重复匹配a正好n次;a{n,}重复将a匹配n次或更多次;a{n,m}重复将a匹配n到m次,包含n次和m次。例如,^a{3,4}$可以匹配aaaaaaa,但不能匹配aaaaaaaa等字符串。

以上列表中列出的重复匹配字符称为贪婪匹配,因为这些字符可以找出最长匹配,但仍匹配正则表达式的其余部分。为进行非贪婪匹配,可在重复字符的后面加上一个?,例如*?+???{...}?。非贪婪匹配将其模式重复尽可能少的次数,但仍匹配正则表达式的其余部分。

优先级

与数学公式一样,正则表达式中元素的优先级也很重要。正则表达式的优先级如下。

  • 元素:例如a,是正则表达式最基本的构建块.
  • 量词:例如+*?{...},紧密绑定至左侧的元素,例如b+
  • 串联:例如ab+c,在量词之后绑定,
  • 替代符:例如,最后绑定。

例如正则表达式ab+c|d,它匹配abcabbcabbbc等字符串,还能匹配d。圆括号可以改变优先级顺序。例如,ab+(c|d)可以匹配abc、abbc、abbbc、 …abd、abbd和abbbd等字符串。不过,如果使用了圆括号,也意味着将圆括号内的内容标记为子表达式或捕捉组。

字符集合匹配

(a|b|c|...|z)这种表达式既冗长,又会引入捕捉组,为了避免这种正则表达式,可以使用一种特殊的语法,指定一组字符或字符的范围。此外,还可以使用“否定”形式的匹配。在方括号之间指定字符集合,[c1c2...cn]可以匹配字符c1、c2、…、cn中的任意字符。例如,[abc]可以匹配a、b和c中的任意字符。如果第一 一个字符是^表示“除了这些字符之外的任意字符”:

  • ab[cde]匹配abcabdabe
  • ab[^cde]匹配abfabp等字符串,但不匹配abcabdabe

如果想要指定所有字母,可编写下面这样的字符集合:

  • 使用方括号内的范围描述,这允许使用[a-zA-Z]这样的表达方式,这种表达方式能识别a到z和A到Z范围内的所有字母。如果需要匹配连字符,则需要转义这个字符,例如[a-zA-Z\-]+匹配任意单词,包括带连字符的单词。

词边界

词边界(word boundary的意思可能是:

  • 如果源字符串的第一个字符在单词字符(即字母、数字或下划线)之后,则表示源字符串的开头位置。匹配源字符串的开头位置默认为启用,但也可以禁用。
  • 如果源字符串的最后一个字符是单词字符之一,则表示源字符串的结束位置。匹配源字符串的结束位置默认为启用,但也可以禁用。
  • 单词的第一个字符,这个字符是单词字符之一,而且之前的字符不是单词字符。
  • 单词的结尾字符,这是单词字符之后的非单词字符,之前的字符是单词字符。

通过\b可匹配单词边界,通过\B匹配除单词边界外的任何内容。

后向引用

通过后向引用可引用正则:表达式本身的捕捉组:\n表示第n个捕捉组,且n>0。例如,正则表达式(d+)-.*-\1匹配以下格式的字符串:

  • 在一个捕捉组中(d+)捕捉的一个或多个数字
  • 接下来是一个连字符-
  • 接下来是0个或多个字符.*
  • 接下来是另一个连字符-
  • 接下来是第一个捕捉组捕捉到的相同数字\1

这个正则表达式能匹配123-abc-1231234-a-1234等字符串,但不能匹配123-abc-1234123-abc-321等字符串。

regex库

正则表达式库的所有内容都在<regex>头文件和std名称空间中。正则表达式库中定义的基本模板类型包括如下几种

  • basic_regex:表示某个特定正则表达式的对象。
  • match_results:匹配正则表达式的子字符串,包括所有的捕捉组。它是sub_match的集合。
  • sub_match:包含输入序列中一个迭代器对的对象,这些迭代器表示匹配的特定捕捉组。迭代器对中的一个迭代器指向匹配的捕捉组中的第一个字符,另一个迭代器指向匹配的捕捉组中最后一个字符后面的那个字符。它的str()方法把匹配的捕捉组返回为字符串

regex库提供了3个关键算法:regex_match()regex_search()regex_replace()。所有这些算法都有不同的版本,不同的版本允许将源字符串指定为STL字符串、字符数组或表示开始和结束的迭代器对。迭代器可以具有以下类型:

  • const char*
  • const wchar_t*
  • string::const_iterator
  • wstring::const_iterator

regex库还定义了以下两类正则表达式迭代器,这两类正则表达式迭代器非常适合于查找源字符串中的所有模式

  • regex_iterator:遍历一个模式在源字符串中出现的所有位置。
  • regex_token_iterator:遍历一个模式在源字符串中出现的所有捕捉组。

为方便regex库的使用,C++标准定义了很多属于以上模板的类型别名,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using regex = basic_regex<char>;
using wregex = basic_regex<wchar_t>;
using csub_match = sub_match<const char*>;
using wcsub_match = sub_match<const wchar_t*>;

using ssub_match = sub_match<string::const_iterator>;
using wssub_match = sub_match<wstring::const_iterator>;
using cmatch = match_results<const char*>;
using wcmatch = match_results<const wchar_t*>;
using smatch = match_results<string::const_iterator>;
using wsmatch = match_results<wstring::const_iterator>;

using cregex_iterator = regex_iterator<const char*>;
using wcregex_iterator = regex_iterator<const wchar_t*>;
using sregex_iterator = regex_iterator<string::const_iterator>;
using wsregex_iterator = regex_iterator<wstring::const_iterator>;

using cregex_token_iterator = regex_token_iterator<const char*>;
using wcregex_token_iterator = regex_token_iterator<const wchar_t*>;
using sregex_token_iterator = regex_token_iterator<string::const_iterator>;
using wsregex_token_iterator = regex_token_iterator<wstring::const_iterator>;

regex_match()

regex_match()算法可用于比较给定的源字符串和正则表达式模式。如果正则表达式模式匹配整个源字符串,则返回true,否则返回false。这个算法很容易使用。regex_match()算法有6个版本,这些版本接收不同类型的参数。它们都使用如下形式:

1
2
template<...>
bool regex_match (InputSequence[, MatchResults], RegEx[, Flags]);

InputSequence可以表示为:

  • 源字符串的首尾迭代器
  • std::string
  • C风格的字符串

可选的MatchResults参数是对match_results的引用,它接收匹配。如果regex_match()返回false,就只能调用match_results::empty()match_results::size(),其余内容都未定义。如果regex_match(),返回true表示找到匹配,可以通过match_results对象查看匹配的具体内容。

RegEx参数是需要匹配的正则表达式。可选的Flags参数指定匹配算法的选项。大多数情况下,可使用默认选项。

如果整个源字符串匹配正则表达式,那么前面介绍的regex_match()算法返回true,否则返回false。这个算法不能用于查找源字符串中匹配的子字符串,但通过regex_search()算法可以在源字符串中搜索匹配特定模式的子字符串。regex_search()算法有6个不同版本。 它们都具有如下形式:

1
2
template<...>
bool regex_search(InputSequence[, MatchResults], RegEx[,Flags]);

在输入字符串中找到匹配时,所有变体返回true,否则返回false;参数类似于regex_match()的参数。有两个版本的regex_search()算法接收要处理的字符串的首尾迭代器。你可能想在循环中使用regex_search()的这个版本,通过操作每个regex_search()调用的首尾迭代器,找到源字符串中某个模式的所有实例。千万不要这样做!如果正则表达式中使用了锚点(^$)和单词边界等,这样的程序会出问题。由于空匹配,这样会产生无限循环。

regex_iterator

根据前面的解释,绝对不要在循环中通过regex_search()获得模式在源字符串中的所有实例。应改用regex_iteratorregex_token_iterator。这两个迭代器和标准库容器的迭代器类似。

regex_iterator示例

这个例子中的正则表达式为[\\w]+,以搜索一个或多个单词字母。这个例子使用std::string作为来源,所以使用sregex_iterator作为迭代器。 这里使用了标准的迭代器循环,但是在这个例子中,尾迭代器的处理和普通标准库容器的尾迭代器稍有不同。一般情况下,需要为某个特定的容器指定尾迭代器,但对于regex_iterator,只有一个end迭代器。只需要通过默认的构造函数声明regex_iterator类型,就可获得这个尾迭代器。

for循环创建了一个首迭代器iter,它接收源字符串的首尾迭代器以及正则表达式作为参数。每次找到匹配时调用循环体,在这个例子中是每个单词。sregex_iterator遍历所有的匹配。通过解引用sregex_iterator,可得到一个smatch对象。访问这个smatch对象的第一个元素[0]可得到匹配的子字符串:

1
2
3
4
5
regex reg("[\\w]+");
const sregex_iterator end;
for (sregex_iterator iter(cbegin(str), cend(str), reg);
iter != end; ++iter)
cout << (*iter)[0] << endl;

从这个例子中可以看出,即使是简单的正则表达式,也能执行强大的字符串操作。

注意,regex_iteratorregex_token_iterator在内部都包含一个指向给定正则表达式的指针。它们都显式删除接收右值正则表达式的构造函数,因此无法使用临时regex对象构建它们。例如,下面的代码无法编译:

1
for (sregex_iterator iter (cbegin(stc), cend(str), regex("[\\w]+")); iter != end; ++iter)

regex_token_iterator

regex_token_iterator可用于在所有匹配的模式中自动遍历所有的或选中的捕捉组。regex_token_iterator有4个构造函数,格式如下:

1
2
3
4
5
regex_token_iterator (BidirectionalIterator a,
Bidirectionaliterator b,
const regex_type& re
[, SubMatches
[, Flags1]]);

所有构造函数都需要把首尾迭代器作为输入序列,还需要一个正则表达式。可选的SubMatches参数用于指定应迭代哪个捕捉组。可以用4种方式指定SubMatches:

  • 一个整数,表示要迭代的捕捉组的索引。
  • 一个vector,其中的整数表示要迭代的捕捉组的索引。
  • 带有捕捉组索引的initializer_list.
  • 带有捕捉组索引的C风格数组

忽略SubMatches或把它指定为0时,获得的迭代器将遍历索引为0的所有捕捉组,这些捕捉组是匹配整个正则表达式的子字符串。可选的Flags参数指定匹配算法的选项。大多数情况下,可以使用默认选项。

regex_token_iterator示例

可用regex_token_iterator重写前面的regex_iterator示例,如下所示。注意,与regex_iterator示例一样,在循环体中使用*iter而非(*iter)[0],因为使用submatch的默认值0时,记号迭代器会自动遍历索引为0的所有捕捉组。这段代码的输出和regex_iterator示例完全一致:

1
2
3
regex reg("[\\w]+");
for (sregex_token_iterator iter(cbegin(str), cend(str), reg); iter != end; ++iter)
cout<< "\"" << *iter << "\"" << endl;

regex_replace()

regex_replace()算法要求输入一个正则表达式,以及一个用于替换匹配子字符串的格式化字符串。这个格式化字符串可通过表中的转义序列,引用匹配子字符串中的部分内容。

转义序列 替换为
$n 匹配第n个捕捉组的字符串,例如$1表示第一个捕捉组,$2表示第二个捕捉组,依此类推;n必须大于0
$& 匹配整个正则表达式的字符串
$` 在输入序列中,在匹配正则表达式的子字符串左侧的部分
$’ 在输入序列中,在匹配正则表达式的子字符串右侧的部分
$$ 单个美元符号

regex_replace()算法有6个不同版本。这些版本之间的区别在于参数的类型。其中的4个版本使用如下格式:

1
string regex_replace (InputSequence, RegEx, FormatString[, Flags]);

这4个版本都在执行替换操作,后返回得到的字符串。InputSequence和 FormatString可以是std::string或C风格的字符串。RegEx参数是需要匹配的正则表达式。可选的Flags参数指定替换算法的选项。

regex_replace()算法的另外两个版本采用如下形式:

1
2
3
4
OutputIterator regex_replace (OutputIterator,
Bidirectionaliterator first,
BidirectionalIterator last,
RegEx, FormatString[, Flags]);

这两个版本把得到的字符串写入给定的输出迭代器,并返回这个输出迭代器。输入序列给定为首尾迭代器。其他参数与regex_replace()的另外4个版本相同。

其他库工具

ratio库

可通过ratio库精确地表示任何可在编译时使用的有限有理数。ratio对象在std::chrono:duration类中使用。与有理数相关的所有内容都在<ratio>头文件中定义,并且都在std名称空间中。有理数的分子和分母通过类型为std:intmax_t的编译时常量表示,这是一种有符号的整数类型,其最大宽度由编译器指定。ratio对象的定义方式和普通对象的定义方式不同,而且不能调用ratio对象的方法。需要使用类型别名。例如,下面这行代码定义了一个表示1/60的有理数编译时常量:

1
using r1 = ratio<1,60>;

r1有理数的分子和分母是编译时常量,可通过以下方式访问:

1
2
intmax_t num = r1::num;
intmax_t den = r1::den;

记住ratio是一个编译时常量,也就是说,分子和分母需要在编译时确定。下面的代码会产生编译错误:

1
2
3
intmax_t n = 1;
intmax_t d = 60;
using r1 = ratio<n, d>;

将n和d定义为常量就不会有编译错误了。

有理数总是化简的。对于有理数ratio<n,d>,计算最大公约数gcd、分子num和分母den的定义如下:

1
2
num = sign(n)*sign(d)*abs(n)/gcd
den = abs(d)/gcd

ratio库支持有理数的加法、减法、乘法和除法运算。由于所有这些操作都是在编译时进行的,因此不能使用标准的算术运算符,而应使用特定的模板和类型别名组合。可用的算术ratio模板包括ratio_addratio_subtractratio_multiplyratio_divide。这些模板将结果计算为新的ratio类型。这种类型可通过名为type的内嵌类型别名访问。

例如,下面的代码首先定义了两个ratio对象,一个表示1/60,另一个表示1/30。ratio_add模板将两个有理数相加,得到的result有理数应该是化简之后的1/20。

1
2
3
using r1 = ratio<1, 60>;
using r2 = ratio<1, 30>;
using result = ratio_add<r1, r2>::type;

C++标准还定义了一些ratio比较模板:ratio_equalratio_not_equalratio_lessratio_less_equalratio_greaterratio_greater_equal。与算术ratio模板一样,ratio比较模板也是在编译时求值的。这些比较模板创建了一种新类型std::bool_constant来表示结果。bool_constant也是std::integral_constant,即struct模板,里面保存了一种类型和一个编译时常量值。

例如,integral constant<int, 15>保存了一个值为15的整型值。bool_constant还是布尔类型的integral_constant。例如,bool_constant<true>integral_constant<bool,true>,存储值为true的布尔值。ratio比较模板的结果要么是bool_constant<bool,true>,要么是bool_constant<bool, false>。与bool_constantintegral_constant关联的值可通过value数据成员访问。

下面的代码演示了ratio_less的使用。

1
2
3
4
using r1 = ratio<1,60>;
using r2 = ratio<1,30>;
using res = ratio_less<r2, r1>;
cout << res::value << endl;

chrono库

chrono库是一组操作时间的库。这个库包含以下组件:

  • 持续时间
  • 时钟
  • 时点

所有组件都在std::chrono名称空间中定义,而且需要包含<chrono>头文件。 下面讲解每个组件。

持续时间

持续时间(duration)表示的是两个时间点之间的间隔时间,通过模板化的duration类来表示。duration类保存了滴答数和滴答周期(tick period)。滴答周期指的是两个滴答之间的秒数,是一个编译时ratio常量,也就是说可以是1秒的分数。duration模板接收两个模板参数,定义如下所示:

1
template <class Rep, class Period = ratio<1>> class duration {...}

第一个模板参数Rep表示保存滴答数的变量类型,应该是一种算术类型,例如long和double等。第二个模板参数Period是表示滴答周期的有理数常量。如果不指定滴答周期,那么会使用默认值ratio<1>,也就是说默认滴答周期为1秒。

duration类提供了3个构造函数:一个是默认构造函数;另一个构造函数接收一个表示滴答数的值作为参数;第三个构造函数接收另一个duration作为参数。后者可用于将一个duration转换为另一个duration,例如将分钟转换为秒。

duration支持算术运算,还支持比较运算符。duration类包含多个方法,如表所示。

方法 说明
Rep count() const 以滴答数返回duration值,返回类型是duration模板中指定的类型参数
static duration zero() 返回持续时间值等于0的duration
static duration min() 返回duration模板指定的类型参数表示的最小值/最大值持续时间的duration值
static duration max() 返回duration模板指定的类型参数表示的最小值/最大值持续时间的duration值

C++17添加了用于持续时间的floor()ceil()round()abs()操作,行为与用于数值数据时类似。

下面看一下如何在实际代码中使用duration。 每一个滴答周期为1秒的duration定义如下所示:

1
duration<long> d1;

由于ratio<1>是默认的滴答周期,因此这行代码等同于:
1
duration<long, ratio<1>> d1;

下面的代码指定了滴答周期为1分钟的duration(滴答周期为60秒):
1
duration<long, ratio<60>> d2;

下面的代码定义了每个滴答周期为1/60秒的duration:
1
duration<double, ratio<1, 60>> d3;

下面的例子展示了duration的几个方面。它展示了如何定义duration,如何对duration执行算术操作,以及如何将一个duration转换为另一个滴答周期不同的duration:

1
2
3
4
5
6
7
8
9
duration<long, ratio<60>> d1(123);
cout << d1.count() << endl;

duration<double> d2;
d2 = d2.max();
cout << d2.count () << endl;

duration<long, ratio<60>> d3(10); // = 10 minutes
duration<long, ratio<1>> d4(14); // = 14 seconds

特别注意下面两行:
1
2
duration<double, ratio<60>>d5 = d3+d4;
duration<long, ratio<1>>d6 = d3+d4;

这两行都计算了d3+d4,但第一行将结果保存在表示分钟的浮点值中,第二行将结果保存在表示秒的整数中。分钟到秒的转换(或秒到分钟的转换)自动进行。

chrono库还提供了以下标准的duration类型,它们位于std::chrono名称空间中:

1
2
3
4
5
6
using nanoseconds = duration<X 64 bits, nano>;
using microseconds = duration<X 55 bits, micro>;
using milliseconds = duration<X 45 bits, milli>;
using seconds = duration<X 35 bits>;
using minutes = duration<X 29 bits, ratio<60>>;
using hours = duration<X 23 bits, ratio<3600>>;

X的具体类型取决于编译器,但C++标准要求X的类型为至少指定大小的整数类型。使用这些预定义的类型,不是编写:

1
duration<long, ratio<60>> d9(10); // minutes

而是编写:
1
minutes d9(10);

时钟

clock类由time_pointduration组成。C++标准定义了3个clock。第一个称为system_clock,表示来自系统实时时钟的真实时间。第二个称为steady_clock,是一个能保证其time_point绝不递减的时钟。system_clock无法做出这种保证,因为系统时钟可以随时调整。第三个称为high_resolution_clock,这个时钟的滴答周期达到了最小值。high_resolution_clock可能就是stead_clocksystem_clock的别名,具体取决于编译器。

每个clock都有一个静态的now()方法,用于把当前时间用作time_pointsystem_clock定义了两个静态辅助函数,用于time_point和C风格的时间表示方法time_t之间的相互转换。第一个辅助函数为to_time_t(),它将给定time_point转换为time_t;第二个辅助函数为from_time_t(),它返回用给定time_t初始化的time_pointtime_t类型在<ctime.h>头文件中定义。下例展示了一个完整程序,它从系统获得当前时间,然后将这个时间以可供用户读取的格式输出到控制台。localtime()函数将time_t转换为用tm表示的本地时间,定义在<ctime>头文件中。

1
2
3
4
system_clock::time_point tpoint = system_clock::now();
time_t tt = system_clock::to_time_t(tpoint);

tm* t m localtime(&tt);

如果想要将时间转换为字符串,可使用std::stringstream,定义在<ctime>中。使用strftime()函数时,要求提供一个足够大的缓冲区,以容纳用户可读格式的给定时间。
1
2
3
4
5
system_clock::time_point tpoint = system_clock::now();
time_t tt = system_clock::to_time_t(tpoint);

char buffer[80] = {0};
strftime(buffer, sizeof(buffer), "%H:%M:%S", t);

通过chrono库还可计算一段代码执行所消耗的时间。下例展示了这个过程。变量start和end的实际类型为system_clock::time_pointdiff的实际类型为duration:

1
2
3
4
5
6
7
8
auto start = high_resolution_clock::now();

double d = 0;
for (int i = 0; i < 1000000; ++i)
d += sqrt(sin(i) * cos(i));

auto end = high_resolution_clock::now();
auto diff = end - start;

时点

time_point类表示的是时间中的某个时点,存储为相对于纪元(epoch)的duration。time_point总是和特定的clock关联,纪元就是所关联clock的原点。例如,经典UNIX/Linux的时间纪元是1970年1月1日。

time_point类包含time_since_epoch()函数,它返回的duration表示所关联clock的纪元和保存的时间点之间的时间,C++支持合理的time_pointduration算术运算。C++支持使用比较运算符来比较两个时间点,提供了两个静态方法:min()返回最小的时间点,而max()返回最大的时间点。

time_point类有3个构造函数

  • time_point():构造一个time_point,通过duration::zero()进行初始化。得到的time_point表示所关联clock的纪元
  • time_point(const duration& d):构造一个time_point,通过给定的duration进行初始化。得到的time_point表示纪元+d
  • template <class Duration2> time_point(const time_point<clock, Duration2>&t):构造一个time_point,通过t.time_since_epoch()进行初始化

每个time_point都关联一个clock。创建time_point时,指定clock作为模板参数:

1
time_point<steady_clock> tp1;

每个clock都知道各自的time_point类型,因此可编写以下代码:

1
steady_clock::time_point tpl;

下面的示例演示了time_point类:

1
2
3
4
5
time_point<steady_clock> tp1;
tp1 += minutes(10);

auto d1 = tp1.time_since_epoch();
duration<double> d2(dl);

生成随机数

在C++11之前,生成随机数的唯一方法是使用C风格的srand()rand()函数。srand()函数需要在应用程序中调用一次,这个函数初始化随机数生成器,也称为设置种子(sccding)。通常应该使用当前系统时间作为种子。初始化随机数生成器后,通过rand()生成随机数。下例展示了如何使用srand()rand()time(nullptr)调用返回系统时间,这个函数在<ctime>头文件中定义:

1
2
srand(static_cast<unsigned int> (time(nullptr)));
cout << rand() << endl;

可通过以下函数生成特定范围内的随机数:

1
2
3
int getRandom(int min, int max) {
return (rand() % static_cast<int>(max + 1 - min)) + min;
}

C++11的库能根据不同的算法和分布生成随机数。这个库定义在<random>头文件中。这个库有3个主要组件:随机数引擎(engine)、随机数引擎适配器(engine adapter)和分布(distribution)。 随机数引擎负责生成实际的随机数,并将生成后续随机数需要的状态保存起来。分布判断生成随机数的范围以及随机数在这个范围内的数学分布情况。随机数引擎适配器修改相关联的随机数引擎生成的结果。

随机数引擎

以下随机数引擎可供使用:

  • random_device
  • linear_congruential_engine
  • mersenne_twister_engine
  • subtract_with_carry_engine

random_device引擎不是基于软件的随机数生成器;这是一种特殊引擎,要求计算机连接能真正生成不确定随机数的硬件。随机数生成器的质量由随机数的熵(entropy)决定。如果random_device类使用的是基于软件的伪随机数生成器,那么这个类的entropy()方法返回的值为0.0。random_device的速度通常比伪随机数引擎更慢。因此,如果需要生成大量的随机数,建议使用伪随机数引擎,使用random_device为随机数引擎生成种子,除了random_device随机数引擎之外,还有3个伪随机数引擎:

  • 线性同余引擎(linear congruential engine)保存状态所需的内存量最少。状态是一个包含上一次生成的随机数的整数,如果尚未生成随机数,则保存的是初始种子。
  • 在这3个伪随机数引擎中,梅森旋转算法生成的随机数质量最高。梅森旋转算法的周期取决于算法参数,但比线性同余引擎的周期要长得多。梅森旋转算法保存状态所需的内存量也取决于算法参数,但是比线性同余引擎的整数状态高得多。例如,预定义的梅森旋转算法mt9937的周期为2^19937-1,状态包含625个整数,约为2.5KB。它是最快的随机数引擎之一
  • 带进位减法(subtract withcarry)引擎要求保存大约100字节的状态。不过,这个随机数引擎生成的随机数质量不如梅森旋转算法。

optional

std::optional<optional>中定义,用于保存特定类型的值,或什么都不保存。如果希望值是可选的,可将其用作函数的参数。如果函数可以返回一些值,或什么都不返回,通常也可将其用作函数的返回类型。这样,就不必从函数返回特殊值,如nullptr、-1和EOF等。在下面的示例中,函数返回optional:

1
2
3
4
5
optional<int> getData(bool givelt) {
if (giveIt)
return 42;
return nullopt;
}

可采用如下方式调用该函数:

1
2
auto data1 = getData(true);
auto data2 = getData(false);

要确定optional是否具有值,可使用has_value()方法,或在证语句中使用optional:

1
2
3
4
cout << "datal.has_value = "<< datal.has_value() <<endl;
if(data2) {
cout << "data2 has a value." << endl;
}

如果optional具有值,可使用value()接收它,或使用以下反引用运算符:

1
2
cout << "data1.value = " << data1.value() << endl;
cout << "data1.value = " << *data1 << endl;

如果在空的optional上调用value(),将抛出bad_optional_access异常。可使用value_or()返回optional值,或在optional为空时返回另一个值:

1
cout << "data2.value" << data2.value_or(0) << endl;

注意,不能在optional中存储引用,因此optional<T&>不可行。相反,应当使用<optionakT*>optional<reference_wrapper<T>>optional<reference_wrapper<const T>>

variant

std::variant<variant>中定义,可用于保存给定类型集合的一个值。定义variant时,必须指定它可能包含的类型。例如,以下代码定义variant一次可以包含整数、字符串或浮点值:

1
variant<int, string, float> v;

这里,这个默认构造的variant包含第一个类型(此处是int)的默认构造值。要默认构造variant,务必确保variant的第一个类型是默认可构造的。例如,下面的代码无法编译,因为Foo不是默认可构造的。

1
2
3
4
5
class Foo { public: Foo() = delete; Foo(int) { } };
class Bar { public: Bar() = delete; Bar(int) { } };
int main () {
variant<Foo, Bar> v;
}

事实上,Foo和Bar都不是默认可构造的。如果仍需要默认构造variant,可使用std:monostate(一个空的替代)作为variant的第一个类型:

1
variant<monostate, Foo, Bar> v;

可使用赋值运算符,在variant中存储内容:

1
2
3
4
variant<int, string, float> v;
v = 12;
v = 2.5f;
v = "An std::string"s;

variant在任何给定时间只能包含一个值。因此,对于这三行代码,首先将整数12存储在variant中,然后将variant改为包含浮点值,最后将variant改为包含字符串。

可使用index()方法来查询当前存储在variant中的值类型的索引。std:holds_alternative()函数模板可用于确定variant当前是否包含特定类型的值:

1
2
cout <<"Type index: "<< v.index() << endl;
cout <<"Contains an int:" << holds_alternative<int>(V) << endl;

使用std::get<index>()std::get<T>()从variant检索值。如果使用类型的索引,或使用与variant的当前值不匹配的类型,这些函数抛出bad_variant_access异常:

1
2
3
4
5
6
cout << std::get<string>(v) << endl;
try {
cout << std::get<0>(v)<<endl;
} catch (const bad_variant_access& ex) {
cout <<"Exception: " << ex.what()<<endl;
}

为避免异常,可使用std::get_if<index>()std::get_if<T>()辅助函数。这些函数接收指向variant的指针,返回指向请求值的指针;如果遇到错误,则返回nullptr。

1
2
string* theString = std::get_if<string>(&v);
int* theint = std::get_if<int>(&v);

可使用std::visit()辅助函数,将visitor模式应用于variant。假设以下类定义了多个重载的函数调用运算符,variant中的每个可能类型对应一个:

1
2
3
4
5
6
class MyVisitor {
public:
void operator ()(int i) { cout << "int " << i<< endl; }
void operator ()(const string& s) { cout<< "string" << s << endl; }
void operator ()(float t) { cout <<"float" << f << endl; }
};

可将其与std::visit()一起使用,如下所示:
1
visit(MyVisitor(), v);

这样就会根据variant中当前存储的值,调用适当的重载的函数调用运算符。

any

std::any<any>中定义,是一个可包含任意类型值的类。一旦构建,可确认any实例中是否包含值,以及所包含值的类型。要访问包含的值,需要使用any_cast(),如果失败,会抛出bad_any_cast类型的异常。下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
any empty;
any anint(3);
any aString("An std::string."s);
cout << "empty.has_value = " << empty.has_value() << endl;
cout << "anint.has_value = " << anint.has_value() << endl << endl;
cout <<"anint wrapped type = " << anint.type().name() << endl;
cout <<"aString wrapped type = " << aString.type().name() << endl <<endl;

int theInt = any_cast<int>(anInt);
cout << theInt << endl;
try {
int test = any_cast<int>(aString);
cout << test << endl;
} catch (const bad_any_cast& ex) {
cout << "Exception: " << ex.what() << endl;
}

输出如下所示。注意,aString的包装类型与编译器相关。

1
2
3
4
5
6
empty.has_value = 0
anint.has_value = 1
anInt wrapped type = int
astring wrapped type = class std: :basic_string<char, struct std::char_traits<char>,class std::allocator<char> >
3
Exception: Bad any_cast

可将新值赋给any实例,甚至是不同类型的新值:

1
2
3
any something(3);
// Now it contains an integer.
something = "An std::string"s; //Now the same instance contains a string.

any的实例可存储在标准库容器中。这样就可在单个容器中存放异构数据。这么做的唯一缺点在于,只能通过显式执行any_cast来检索特定值,如下所示:

1
2
3
4
vector<any> v;
v.push_back(any(42));
v.push_back(any("An std::string"s));
cout << any_cast<string>(v[1]) << endl;

与optional和variant一样,无法存储any实例的引用。可存储指针,也可存储reference_wrapper<const T>reference_wrapper<T>的实例。

元组

<utility>中定义的std::pair类可保存两个值,每个值都有特定的类型。每个值的类型都应该在编译时确定。下面是一个简单的例子:

1
2
pair<int, string> p1(16, "Hello World");
pair<bool, float> p2(true, 0.123f);

还有std::tuple类,这个类定义在<tuple>头文件中。tuple(元组)是pair的泛化,允许存储任意数量的值,每个值都有自己特定的类型。和pair一样,tuple的大小和值类型都是编译时确定的,都是固定的。tuple可通过tuple构造函数创建,需要指定模板类型和实际值。例如,下面的代码创建了一个tuple,其第一个元素是一个整数,第二个元素是一个字符串,最后一个元素是一个布尔值:

1
2
using MyTuple = tuple<int, string, bool>;
MyTuple t1(16, "Test", true);

std::get<i>()从tuple中获得第i个元素,i是从0开始的索引;因此<0>表示tuple的第一个元素,<1>表示tuple的第二个元素,依此类推。返回值的类型是tuple中那个索引位置的正确类型:
1
cout << "t1 = (" << get<0>(t1) << "," << get<1>(t1) << "," << get<2>(t1) << ")" << endl;

可通过<typeinfo>头文件中的typeid()检查get<i>()是否返回了正确的类型。下面这段代码的输出表明,get<I>(t1)返回的值确实是std::string

1
2
cout << "Type of get<1>(t1) = " << typeid(get<1>(t1)).name() << endl;
// Outputs: Type of get<1>(t1) = class std::basic_string<char, struct std::char_traits<char>, class std::allocator<char>>

也可根据类型使用std::get<T>()从tuple中提取元素,其中T是要提取的元素(而不是索引)的类型。如果tuple有几个所需类型的元素,编译器会生成错误。例如,可从tl中提取字符串元素:

1
cout << "String = "<< get<string>(t1) << endl;

遗憾的是,迭代tuple的值并不简单。无法编写简单循环或调用get<i>(mytuple)等,因为i的值在编译时必须是已知的。

可通过std:tuple_size模板来查询tuple的大小。 注意,tuple_size要求指定tuple的类型,而不是实际的tuple实例,例如t1:

1
2
cout << "Tuple Size " << tuple_size<MyTuple>::value << endl;
// Outputs: Tuple Size 3

如果不知道准确的tuple类型,始终可以使用decltype(),如下所示:

1
cout << "Tuple Size - " << tuple_size<decltype(t1)>::value << endl;

在C++17中,提供了构造函数的模板参数推导规则。在构造tuple时,可忽略模板类型形参,让编译器根据传递给构造函数的实参类型,自动进行推导。例如,下面定义同样的t1元组,它包含一个整数、一个字符串和一个布尔值。

1
std::tuple t1(16, "Test"s, true);

缘于类型的自动推导,不能通过&来指定引用。如果需要通过构造函数的模板参数推导方式,生成一个包含引用或常量引用的tuple,那么需要分别使用ref()cref()ref()cref()辅助函数在<functional>头文件中定义。例如,下面的构造会生成一个类型为tuple<int, double&, const double&, string&>的tuple:

1
2
3
double d = 3.14;
string str1 = "Test";
std::tuple t2(16, ref(d), cref(d), ref(str1));

为测试元组t2中的double引用,下面的代码首先将double变量的值写入控制台。然后调用get<1>(t2),这个函数实际上返回的是对d的引用,因为第二个tuple(索引1)元素使用了ref(d)。第二行修改引用的变量的值,最后一行展示了d的值的确通过保存在tuple中的引用修改了。注意,第三行未能编译,因为cref(d)用于第三个tuple元素,也就是说,它是d的常量引用。

1
2
3
4
5
6
cout << "d = "<< d << endl;
get<1>(t2) *= 21
// get<2>(t2) *= 2;
// ERROR because of cref()

cout <<"d = " << d << endl;

如果不使用构造函数的模板参数推导方法,可以使用std::make_tuple()工具函数创建一个tuple。利用这个辅助函数模板,只需要指定实际值,即可创建一个tuple。在编译时自动推导类型,例如:

1
auto t2 = std::make_tuple(16, ref(d), cref(d), ref(str1));

分解元组

可采用两种方法,将一个元组分解为单独的元素:结构化绑定(C++17)以及std::tie()

结构化绑定

C++17引入了结构化绑定,允许方便地将一个元组分解为多个变量。例如,下面的代码定义了一个tuple,这个tuple包括一个整数、一个字符串和一个布尔值;此后,使用结构化绑定,将这个tuple分解为三个独立的变量:

1
2
3
tuple t1(16, "Test"s, true);
auto[i, str, b] = t1;
cout<< "Decomposed: i = " << i << ", str = " << str << ", b = " << b << endl;

使用结构化绑定,无法在分解时忽略特定元素。如果tuple包含三个元素,则结构化绑定需要三个变量。如果想忽略元素,则必须使用tie()

tie()

如果在分解元组时不使用结构化绑定,可使用std:tie()工具函数,它生成一个引用tuple。下例首先创建一个tuple,这个tuple包含一个整数、一个字符串和一个布尔值;然后创建三个变量,即整型变量、字符串变量和布尔变量,将这些变量的值写入控制台。tie(i, str, b)调用会创建一个tuple,其中包含对i的引用、对str的引用以及对b的引用。使用赋值运算符,将t1赋给tie()的结果。由于tie()的结果是一个引用tuple,赋值实际上更改了三个独立变量中的值。

1
2
3
4
5
6
7
tuple<int, string, bool>t1(16, "Test", true);
int i = 0;
string str;
bool b = false;
cout << "Betore: i = " << i << ", str = " << str << ", b = "<< b <<endl;
tie(I, str, b) = t1;
cout << "After: i = " << i << ", str = " << str << ", b = "<< b <<endl;

串联

通过std::tuple_cat()可将两个tuple串联为一个tuple。在下面的例子中,t3的类型为tuple<int, string, bool, double, string>

1
2
3
tuple<int, string, bool> t1(16, "Test", true);
tuple<double, string> t2(3.14, "string 2");
auto t3 = tuple_cat(t1, t2);

比较

tuple还支持以下比较运算符:==、!=、<、>、<=和>=。为了能使用这些运算符,tuple中存储的元素类型也应该支持这些操作。例如:

1
2
3
4
5
6
tuple<int, string> t1(123, "def");
tuple<int, string> t2(123, "abc");
if(t1<t2)
cout << "tl < t2"<< endl;
else
cout << "t1 >= t2" << endl;

make_from_tuple()

使用std:make_from_tuple()可构建一个T类型的对象,将给定tuple的元素作为参数传递给T的构造函数。例如,假设具有以下类:

1
2
3
4
5
6
7
Class Foo {
public:
Foo(string str, int i):mStr(str), mInt(i) {}
private:
string mStr;
int mint;
};

可按如下方式使用make_from_tuple()
1
2
auto myTuple = make_tuple("Hello world.", 42);
auto foo = make_from_tuple<Foo>(myTuple);

提供给make_from_tuple()的实参未必是一个tuple,但必须支持std:get<>()std::tuple_sizestd::arraystd::pair也满足这些要求。

apply()

std::apply()调用给定的函数、lambda表达式和函数对象等,将给定tuple的元素作为实参传递。下面是一个例子:

1
2
int add(int a, int b) { return a + b; }
cout << apply(add, std::make_tuple(39, 3)) << endl;

make_from_tuple()一样,在日常工作中,该函数并不实用;不过,如果要编写使用模板的泛型代码,或进行模板元编程,那么这个函数可提供便利。

文件系统支持库

C++17引入了文件系统支持库,它们全部定义在<filesystem>头文件中,位于std::filesystem名称空间。它允许你编写可移植的用于文件系统的代码。使用它,可以区分是目录还是文件,迭代目录的内容,操纵路径,检索文件信息(如大小、扩展名和创建时间等)。下面介绍这个库最重要的两个方面:path(路径)和directory_entry(目录项)。

path

这个库的基本组件是path。path可以是绝对路径,也可以是相对路径,可包含文件名,也可不包含文件名。例如,以下代码定义了一些路径,注意使用了原始字符串字面量来避免对反斜线进行转义。

1
2
3
4
5
path p1(LR"(D:\Foo\Bar)");
path p2(L"D:/Foo/Bar");
path p3(L"D:/Foo/Bar/MyFile.txt");
path P4(LR"(..\SomeFolder)");
path p5(L"/usr/lib/X11");

将path转换为字符串(如使用c_str()方法)或插入流时,会将其转换为运行代码的系统中的本地格式。例如:

1
2
3
4
path p1(LR"(D:\Foo\Bar)");
path p2(L"D:/Foo/Bar");
cout << p1 << endl;
cout << p2 << endl;

可使用append()方法或operator/=,将组件追加到路径。路径会自动添加分隔符。例如:

1
2
3
4
path p(L"D:\\Foo");
p.append("Bar");
P /= "Bar";
cout << p << endl;

输出是D:\Foo\Bar\Bar

可使用concat()方法或operator+=,将字符串与现有路径相连。此时路径不会添加分隔符。append()operator/=自动添加路径分隔符,而concat()operator+=不会自动添加。

path接口支持remove_filenamereplace_filename()replace_extension()root_name()parent_path()extension()has_extension()is_absolute()is_relative()等操作。

directory_entry

path只表示文件系统的目录或文件。path可能指不存在的目录或文件。如果想要查询文件系统中的实际目录或文件,需要从path构建一个directory_entry。 如果给定目录或文件不存在,该结构会失败。directory_entry接口支持is_directory()is_regular_file()is_socket()is_symlink()file_size()last_write_time()等操作。

辅助函数

有一组完整的辅助函数可供使用。例如,可使用copy()复制文件或目录,使用create_directory()在文件系统中创建新目录,使用exists()查询给定目录或文件是否存在,使用file_size()获取文件大小,使用last_write_time()获取文件最近一次的修改时间,使用remove()删除文件,使用temp_directory_path()获取适于保存临时文件的目录,使用space()查询文件系统中的可用空间,等等。

目录迭代

如果想要递归地迭代给定目录中的所有文件和子目录,可使用如下recursive_directory_iterator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void processPath (const path& p) {
if (!exists(p))
return;

auto begin = recursive_directory_iterator(p);
auto end = recursive_directory_iterator();
for (auto iter = begin; iter != end; ++ iter) {
const string spacer(iter.depth()*2, ' ');
auto& entry = *iter;

if (is_regular_file(entry))
cout << spacer << "File: " << entry << endl;
else if (is_directory(entry))
cout << spacer << "Dir: "<< entry << endl;

自定义和扩展标准库

分配器

每个标准库容器都接收Allocator类型作为模板参数,大部分情况下默认值就足够了。例如,vector模板的定义如下所示

1
template <class T, class Allocator = allocator<T>>class vector;

容器构造函数还允许指定Allocator类型的对象。通过这些额外参数可自定义容器分配内存的方式。容器执行的每一次内存分配都是通过调用Allocator对象的allocate()方法进行的,每一次内存释放都是通过调用Allocator对象的deallocate()方法进行的。

有几种原因需要使用自定义的分配器。例如:

  • 如果底层分配器的性能无法接受,但可构建替换的分配器
  • 如果内存碎片问题(大量不同的分配和释放操作导致内存中出现很多不可用的小空洞)严重
  • 如果必须给操作系统特定的功能分配空间,

C++17引入了多态内存分配器的概念。对于指定为模板类型参数的容器的分配器,问题在于两个十分相似但具有不同分配器类型的容器区别很大。例如,具有不同分配器模板类型参数的两个vector容器是不同的。std::pmr名称空间的<memory_resource>中定义的多态内存分配器有助于解决这个问题。std::pmr::polymorphic_allocator是适当的分配器类,因为它满足各种要求,如具有allocate()deallocate()方法。polymorphic allocator的分配行为取决于构建期间的memory_resource,而非取决于模板类型参数。因此,在分配和释放内存时,虽然具有相同的类型,但不同polymorphic_allocator的行为迥异。

流适配器

标准库提供了4个流适配器(stream iterator)。它们是类似于迭代器的类模板,允许将输入流和输出流视为输入迭代器和输出迭代器。通过这些迭代器可对输入流和输出流进行适配,将它们在不同的标准库算法中分别当成来源和目标。下面列出可用的流迭代器:

  • ostream_iterator是一个输出流迭代器
  • istream_iterator是一个输入流迭代器

输出流迭代器

ostream_iterator是一个输出流迭代器,是一个类模板,接收元素类型作为类型参数。这个类的构造函数接收的参数包括一个输出流以及要在写入每个元素之后写入流的分隔符字符串。ostream_iterator通过operator<<运算符写入元素。可以用ostream_iterator一行代码打印出容器中的元素。

1
2
3
vector<int> myVector(10);
iota(begin (myVector), end (myVector), 1); // Fill vector with 1,2,3...10
copy (cbegin (myVector), cend (myVector), ostream_iterator<int>(cout, " "));

输入流迭代器

还可使用输入流迭代器istream_iterator通过迭代器抽象从输入流中读取值。这是类模板,将元素类型作为类型参数,通过operator>>运算符读取元素。istream_iterator可用作算法和容器方法的来源。

迭代器适配器

标准库提供了3个迭代器适配器(iterator adapter), 它们是基于其他迭代器构建的特殊迭代器。这3个迭代器适配器都在<iterator>头文件中定义。

反向迭代器

标准库提供了std::reverse_iterator类模板,以反向遍历双向迭代器或随机访问迭代器。标准库中所有可反向迭代的容器都提供了类型别名reverse_iterator以及rbegin()rend()方法。这些reverse_iterator类型别名的类型是std::reverse_iterator<T>,T等于容器的iterator类型别名。rbegin()方法返回指向容器中最后一个元素的reverse_iteratorrend()方法也返回一个reverse_iterator,这个迭代器指向容器中第一个元素之前的元素。对reverse_iterator应用operator++运算符,会对底层容器迭代器调用operator--运算符,反之亦然。例如,可通过以下方式从头到尾遍历一个集合:

1
for (auto iter = begin(collection); iter != end (collection); ++iter)

要从尾到头遍历这个集合的元素,可调用rbegin()rend()来使用reverse_iterator。注意,这里仍使用++iter

1
for(auto iter = rbegin(collection); iter != rend(collection); ++iter)

std::reverse_iterator主要用在标准库中没有等价算法能够反向运行的情况。

插入迭代器

为了让copy()这类算法的用途更广泛,标准库提供了3个插入迭代器以真正将元素插入容器:insert_iteratorback_insert_iteratorfront_insert_iterator。插入迭代器根据容器类型模板化,在构造函数中接收实际的容器引用。通过提供必要的迭代器接口,这些适配器可用作copy()这类算法的目标迭代器。这些适配器不会替换容器中的元素,而通过调用容器真正插入新元素。

基本的insert_iterator调用容器的insert(position, element)方法,back_insert_iterator调用push_back(element)方法,front insert_iterator调用push_front(element)方法,例如,结合back_insert_iteratorcopy_if()算法能为vectorTwo填充来自vectorOne的不等于100的所有元素:

1
2
3
4
vector<int> vectorone, vectorTwo;
back_insert_iterator<vector<int>> inserter(vectorTwo);
copy_if(cbegin (vectorOne), cend (vectorone), inserter, [](int i){return i != 100;});
copy (cbegin (vectorTwo), cend (vectorTwo), ostream_iterator<int>(cout, ""));

从这段代码可看出,在使用插入迭代器时,不需要事先调整目标容器的大小。

也可通过std::back_inserter()工具函数创建一个back_insert_iterator。例如,在前一个例子中,可删除定义inserter变量的那一行代码,然后将copy_if()调用改写为以下代码。结果和之前的实现完全相同:

1
copy_if(cbegin (vectorOne), cend (vectorOne), back_inserter (vectorTwo), [](int i){return i!=100;});

front_insert_iteratorinsert_iterator的工作方式类似,区别在于insert_iterator在构造函数中还接收初始的迭代器位置作为参数,并将这个位置传入第一次insert(position, element)调用。后续的迭代器位置提示通过每一次insert()调用的返回值生成。

使用insert_iterator的一个巨大好处是可将关联容器用作修改类算法的目标。关联容器实际上支持将接收迭代器位置作为参数的insert(),并将这个位置用作“提示”,但这个位置可忽略。在关联容器上使用insert_iterator时,可传入容器的begin()end()迭代器用作提示。insert_iterator在每次调用insert()后修改传输给insert()的迭代器提示,使其成为刚插入元素之后的那个位置。

移动迭代器

迭代适配器std:move_iterator的解除引用运算符会自动将值转换为rvalue引用,也就是说,这个值可移动到新的目的地,而不会有复制开销。在使用移动语义前,需要保证对象支持移动语义。

扩展标准库

编写标准库算法

find_all()

假设需要在指定范围内找到满足某个谓词的所有元素。find()find_if()是最符合条件的备选算法,但这些算法返回的都是仅引用一个元素的迭代器。可使用copy_if()找出所有满足谓词的元素, 但会用所找到元素的副本填充输出。如果想要避免复制,可使用copy_in()back_insert_iterator()vector<reference_wrapper<T>>中),但这不能给出所找到元素的位置。可自行编写能提供这个功能的版本,称为find_all()

copy_if()一样, 该算法给输出序列返回一个迭代器,指向输出序列中存储的最后一个元素后面的那个元素。下面是算法原型:

1
2
template <typename InputIterator, typename OutputIterator, typename Predicate>
OutputIterator find_all(InputIterator first, InputIterator last, OutputIterator dest, Predicate pred);

另一种可选方案是忽略输出迭代器,给输入序列返回一个迭代器,遍历输入序列中所有匹配的元素,但是这种方案要求编写自定义的迭代器类。

下一项任务是编写算法的实现。find_all()算法遍历输入序列中的所有元素,给每个元素调用谓词,把匹配元素的迭代器存储在输出序列中。下面是算法的实现:

1
2
3
4
5
6
7
8
9
10
11
template <typename InputIterator, typename OutputIterator, typename Predicate>
OutputIterator find_all(InputIterator first, Inputiterator last,OutputIterator dest, Predicate pred) {
while (first != last) {
if(pred(*first)) {
*dest = first;
++dest;
}
++first;
}
return dest;
}

iterator_traits

一些算法的实现需要迭代器的额外信息。例如,为保存临时值,算法可能需要知道迭代器引用的元素的类型,还可能需要知道迭代器是双向访问的还是随机访问的。C++提供了一个名为iterator_traits的类模板,以找到这些信息。通过要使用的迭代器类型实例化iterator_traits类模板,然后可访问以下5个类型别名:value_typedifference_typeiterator_categorypointerreference。例如,下面的模板函数声明了一个临时变量,其类型是iteratorType类型的迭代器引用的类型。注意,在iterator_traits这行前面要使用typename关键字。访问基于一个或多个模板参数的类型时,必须显式地指定typename。在这个例子中,模板参数IteratorType用于访问value_type类型:

1
2
3
4
5
template <typename IteratorType>
void iteratortraitsTest(IteratorType it) {
typename std::iterator_traits<IteratorType>::value_type temp;
temp = *it;
cout << temp << endl;

可通过以下代码测试这个函数:

1
2
vector<int> v{ 5 };
iteratorTraitsTest(cbegin(v));

在这段代码中,iteratorTraitsTest()函数中temp变量的类型为int。输出是5。

高级模板

深入了解模板参数

实际上有3种模板参数:类型参数、非类型参数和template template参数。

深入了解模板类型参数

模板的类型参数是模板的精髓。可声明任意数目的类型参数。标准库定义了几个模板化的容器类,包括vector和deque。下面是带有额外模板参数的类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename T, typename Container>
class Grid {
public
explicit Grid(size_t width = kDefaultwidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;

Grid(const Grid& src) = default;
Grid<T, Container>& operator=(const Grid& rhs) = default;

Grid(Grids& src) = default;
Grid<T, Container>& operator= (Gride&& rhs) = default;
typename Container::value_type& at(size_t x, size_t y);
const typename Container::value_types at(size_t x, size_t y) const;
size_t getHeight()const { return mHeight; }
size_t getwidth()const { return mwidth; }

static const size_t kDefaultwidth = 10;
static const size_t kDefaultHeight = 10;
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<Container> mCells;
size_t midth = 0, mHeight = 0;
};

现在这个模板有两个参数:T和Container。因此,所有引用了Grid<T>的地方现在都必须指定Grid<T, Container>以表示两个模板参数。其他仅有的变化是,mCells现在是Container的vector,而不是vector的vector。下面是构造函数的定义:

1
2
3
4
5
6
template <typename T, typename Container>
Grid<T, Container>::Grid(size_t width, size_t height) : mwidth(width), mheight(height) {
mCells.resize(mWidth);
for (auto& column : mCells)
column.resize(mHeight);
}

这个构造函数假设Container类型具有resize()方法。如果尝试通过指定没有resize()方法的类型来实例化这个模板,编译器将生成错误。at()方法的返回类型是存储在给定类型容器中的元素类型。可以使用typename Container:value_type访问该类型。下面是其余方法的实现:

1
2
3
4
5
template <typename T, typename Container>
void Grid<T, Container>::verifyCoordinate(size_t x, size_t y) const {
if(x >= mWidth || y >= mHeight)
throw std::out_of_range("");
}

现在,可按以下方式实例化和使用Grid对象:

1
2
Grid<int, vector<optional<int>>> myIntVectorGrid;
Grid<int, deque<optional<int>>> myIntDequeGrid;

给参数名称使用Container并不意味着类型必须是容器。可尝试用int实例化Grid类:

1
Grid<int, int> test; // WILL NOT COMPILE

此行代码无法成功编译,在尝试处理类模板定义的这一行之前,一切都正常

1
typename Container::value_types at(size_t x, size_t y);

在这一行,编译器意识到column是int类型,没有嵌入的value_type类型别名。

与函数参数一样,可给模板参数指定默认值。例如,可能想表示Grid的默认容器是vector。这个模板类定义如下所示:

1
template <typename T, typename Container = std::vector<std::optional<T>>>

可以使用第一个模板参数中的类型T作为第二个模板参数的默认值中optional模板的参数。C++语法要求不能在方法定义的模板标题行中重复默认值。现在有了这个默认参数后,实例化网格时,客户可指定或不指定底层容器:

1
2
3
Grid<int, deque<optional<int>>> myDequeGrid;
Grid<int, vector<optional<int>>> myVectorGrid;
Grid<int> myVectorGrid2 (myVectorGrid);

template template参数介绍

如果能编写以下代码就好了:

1
Grid<int, vector> myIntGrid;

Grid类应该能够判断出需要一个元素类型为int的optional vector。不过编译器不会允许传递这样的参数给普通的类型参数,因为vector本身并不是类型,而是模板。如果想要接收模板作为模板参数,那么必须使用一种特殊参数,称为template template参数。指定template template参数时,template template参数的完整规范包括该模板的参数。例如,vector和deque等容器有一个模板参数列表,如下所示。

1
2
3
template <typename E, typename Allocator = std::allocator<E>>
class vector {
};

要把这样的容器传递为template template参数, 只能复制并粘贴类模板的声明(在本例中是template <typename E, typename Allocator = allocator<E>> class vector),用参数名(Container)替代类名(vector),并把它用作另一个模板声明的template template参数,而不是简单的类型名。有了前面的模板规范,下面是接收一个容器模板作为第二个模板参数的Grid类的类模板定义:

1
2
3
4
5
6
7
8
9
10
11
template <typename T,
template <typename E, typename Allocator = std::allocator<E>> class Container = std::vector>
class Grid {
public:
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;
private:
void verifyCoordinate(size_t x, size_t y) const;
std::vector<Container<std::optional<T>>>mCells;
size_t mWidth = 0, mHeight = 0;
};

第一个模板参数与以前一样:元素类型T。第二个模板参数现在本身就是容器的模板,如vector或dcque。如前所述, 这种“模板类型”必须接收两个参数:元素类型E和分配器类型。

注意嵌套模板参数列表后面重复的单词class。这个参数在Grid模板中的名称是Container。默认值现为vector而不是vector<T>,因为Container是模板而不是实际类型。

template template参数更通用的语法规则是:

1
template <..., template <TemplateTypeParams> class ParameterName, ...>

从C++17开始,也可以用typename关键字替代class,如下所示:

1
template <..., template <TemplateTypeParams> typename ParameterName, ...>

在代码中不要使用Container本身,而必须把Container<std::optiona<T>>指定为容器类型。例如,现在mCells的声明如下:

1
std::vector<Container<std::optional<T>>> mCells;

不需要更改方法定义,但必须更改模板行,例如:

1
2
3
4
5
6
template <typename T,
template <typename E, typename Allocator = std::allocator<E>> class Container>
void Grid<T, Container>::verifyCoordinate(size_t x, size_t y) const {
if (x >= mWidth || y >= mHeight)
throw std::out_of_range("");
}

可以这样使用Grid模板:

1
2
3
4
Grid<int, vector>myGrid;
myGrid.at(1, 2) = 3;
cout << myGrid.at(1,2).value_or(0) << endl;
Grid<int, vector> myGrid2(myGrid);

上述C++语法有点令人费解,因为它试图获得最大的灵活性。尽量不要在这里陷入语法困境,记住主要概念:可向其他模板传入模板作为参数

深入了解非类型模板参数

有时可能想让用户指定一个默认元素,用来初始化网格中的每个单元格。下面是实现这个目标的一种完全合理的方法,它使用T()作为第二个模板参数的默认值:

1
2
template <typename T, const T DEFAULT = T()>
class Grid { };

这个定义是合法的。可使用第一个参数中的类型T作为第二个参数的类型,非类型参数可为const,就像函数参数一样。可使用T的初始值来初始化网格中的每个单元格:

1
2
3
4
5
6
7
8
9
10
template <typename T, const T DEFAULT>
Grid<T, DEFAULT>::Grid(size_t width, size_t height) : mWidth(width), mHeight(height) {
mCells.resize(mWidth);
for (auto& column : mcells) {
column.resize(mHeight);
for (auto& element : column) {
element = DEFAULT;
}
}
}

其他的方法定义保持不变,只是必须向模板行添加第二个模板参数,所有Grid<T>实例要变为Grid<T,DEFAULT>。完成这些修改后,可实例化一个int网格,并为所有元素设置初始值:

1
2
Grid<int> myIntGrid;
Grid<int, 10> myIntGrid2;

非类型参数不能是对象,甚至不能是double和float值。非类型参数被限定为整型、 枚举、指针和引用。

允许用户指定网格初始元素值的一种更详尽方式是使用T引用作为非类型模板参数。下面是新的类定义:

1
2
template <typename T, const T& DEFAULT>
class Grid {};

现在可为任何类型实例化这个模板类。C++17标准指定,作为第二个模板参数传入的引用必须是转换的常量表达式(模板参数类型),不允许引用子对象、临时对象、字符串字面量、typeid表达式的结果或预定义的__func__变量。下例声明了带有初始值的int网格和SpreadsheetCell网格。

1
2
3
4
5
6
7
int main () {
int defaultint = 11;
Grid<int, defaultint> myIntGrid;
SpreadsheetCell defaultCe11(1.2);
Grid<Spreadsheetcell, defaultcell> mySpreadsheet;
return 0;
}

但这些是C++17的规则,大多数编译器尚未实施这些规则。在C++17之前,传给引用非类型模板参数的实参不能是临时的,不能是无链接(外部或内部)的命名左值。因此,对于上面的示例,下面使用C++17之前的规则。使用内部链接定义初始值:

1
2
3
4
5
6
7
8
9
namespace {
int defaultInt = 11;
spreadsheetcell defaultCell(1.2);
}
int main () {
Grid<int, defaultint> myIntGrid;
Grid<Spreadsheetcell, defaultcell> mySpreadsheet;
return 0;
}

模板类部分特例化

可编写部分特例化的类,这个类允许特例化部分模板参数,而不处理其他参数。例如,基本版本的Grid模板带有宽度和高度的非类型参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T, size_t WIDTH, size_t HEIGHT>
class Grid {
public:
Grid() = default;
virtual ~Grid() = default;
Grid(const Grid& src) = default;
Grid& operator=(const Grid& rhs) = default;
std::optional<T>& at(size_t x, size_t y);
const std::optional<T>& at(size_t x, size_t y) const;
size_t getHeight() const { return HEIGHT; }
size_t getWidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t x, size_t y) const;
std::optional<T> mCells[WIDTH][HEIGHT];
};

可采用这种方式为char*C风格字符串特例化这个模板类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <size_t WIDTH, size_t HEIGHT>
class Grid<const char*, WIDTH, WEIGHT> {
public:
Grid() = default;
virtual ~Grid() = default;
Grid(const Grid& src) = default;
Gride operator-(const Grid& rhs) = default;
std::optional<std::string>& at(size_t x, size_t y);
const std::optional<std::string>& at(size_t x, size_t y) const;
size_t getHeight()const { return HEIGHT; }
size_t getWidth() const { return WIDTH; }
private:
void verifyCoordinate(size_t x, size_t y) const;
std::optional<std::string> mCells [WIDTH][HEIGHT];
};

在这个例子中,没有特例化所有模板参数。因此,模板代码行如下所示:

1
2
template <size_t WIDTH, size_t HEIGHT>
class Grid<const char*, WIDTH, HEIGHT>

注意,这个模板只有两个参数:WIDTH和HEIGHT。然而,这个Grid类带有3个参数:T、WIDTH和HEIGHT。因此,模板参数列表包含两个参数,而显式的Grid<const char*, WIDTH, HEIGHT>包含3个参数。实例化模板时仍然必须指定3个参数。不能只通过高度和宽度实例化模板:

1
2
3
Grid<int, 2, 2> myIntGrid;
Grid<const char*, 2, 2> myStringGrid;
Grid<2, 3> test;

上述语法的确很乱。更糟糕的是,在部分特例化中,与完整特例化不同,在每个方法定义的前面要包含模板代码行,如下所示:

1
2
3
4
5
6
template <size_t WIDTH, size_t HEIGHT>
const std::optional<std::string>&
Grid<const char*, WIDTH, HEIGHT>::at(size_t x, size_t y) const {
verifyCoordinate(x, y);
return mCells[x][y];
}

需要这一带有两个参数的模板行,以表示这个方法针对这两个参数做了参数化处理。注意,需要表示完整类名时,都要使用Grid<const char*, WIDTH, HEIGHT>

前面的例子并没有表现出部分特例化的真正威力。可为可能的类型子集编写特例化的实现,而不需要为每种类型特例化。下面是类的定义,假设只用一个参数特例化最早版本的Grid。在这个实现中,Grid成为所提供指针的拥有者,所以它在需要时自动释放内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename T>
class Grid<T*> {
public:
explicit Grid(size_t width = kDefaultWidth, size_t height = kDefaultHeight);
virtual ~Grid() = default;

Grid(const Grid& src);
Grid<T*>& operator=(const Grids rhs);

Grid(Grid&& src) = default;
Grid<T*>& operator=(Grid&& rhs) = default;
void swap(Gride other) noexcept;
std::unique_ptr<T>& at(size_t x, size_t y);
const std::unique_ptr<T>& at(size_t x, size_t y) const;
size_t getHeight() const { return mHeight; }
size_t getwidth()const { return mWidth; }
static const size_t kDefaultwidth = 10;
static const size_t kDefaultHeight = 10;
private:
void verlfyCoordinate(size_t x, size_t y) const;
std::vector<std::vector<std::unique_ptr<T>>>mCells;
size_t mwidth = 0, mileight = 0;

像往常一样,下面这两行代码是关键所在:

1
2
template <typename T>
class Grid<T*>

上述语法表明这个类是Grid模板对所有指针类型的特例化。只有T是指针类型的情况下才提供实现。请注意,如果像下面这样实例化网格:Grid<int*> myIntGrid,那么T实际上是int而非int*。这不够直观,但遗憾的是,这种语法就是这样使用的。下面是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Grid<int> myIntGrid; // Uses the non-specialized grid
Grid<int*> psGrid(2, 2); // Uses the partial specialization for pointer types
psGrid.at(0, 0) = make_unique<int>(1);
psGrid.at(0, 1) = make_unique<int>(2);
psGrid.at(1, 0) = make_unique<int>(3);
Grid<int*> psGrid2(psGrid);
Grid<int*> psGrid3;
psGrid3 = psGrid2;

auto& element = psGrid2.at(1, 0);
if (element) {
cout << *element << endl;
*element = 6;
}

通过重载模拟函数部分特例化

C++标准不允许函数的模板部分特例化。相反,可用另一个模板重载函数。区别十分微妙。假设要编写一个特例化的Find()函数模板,这个特例化对指针解除引用,对指向的对象直接调用operator--。根据类模板部分特例化的语法,可能会编写下面的代码:

1
2
3
4
5
6
7
template <typename T>
size_t Find<T*>(T* const& value, T* const* arr, size_t size) {
for (size_t i = 0; i < size; i ++)
if (*arr[i] == *value)
return i;
return NOT_FOUND;
}

然而,这种声明函数模板部分特例化的语法是C++标准所不允许的。实现所需行为的正确方法是为Find()编写一个新模板,区别看似微不足道且不切合实际,但不这样就无法编译:

1
2
3
4
5
6
7
template <typename T>
size_t Find(T* const& value, T* const* arr, size_t size) {
for (size_t i = 0; i < size; i ++)
if (*arr[i] == *value)
return i;
return NOT_FOUND;
}

这个Find()版本的第一个参数是T* const&,这是为了与原来的Find()函数模板(它把const T&作为第一个参数)保持一致,但这里将T*(而不是T* const&)用作Find()部分特例化的第一个参数,这也是可行的。

模板递归

N维网格:初次尝试

前面的Grid模板示例到现在为止只支持两个维度,这限制了它的实用性。一种方法是只编写一个一维网格。然后,利用另一个网格作为元素类型实例化Grid,可创建任意维度的网格。这种Grid元素类型本身可以用网格作为元素类型进行实例化,依此类推。下面是OneDGrid类模板的实现。这只是前面例子中Grid模板的一维版本,添加了resize()方法,并用operator[]替换了at()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template <typename T>
class OneDGrid {
public:
explicit OneDGrid(size_t size = kDefaultsize);
virtual ~OneDGrid() = default;
T& operator[](size_t x);
const T& operator[](size_t x) const;
void resize(size_t newSize);
size_t getsize() const { return mElements.size();}
static const size_t kDefaultsize = 10;
private:
std::vector<T> mElements;
};
template <typename T>
OneDGrid<T>::OneDGrid(size_t size) {
resize(size);
}
template <typename T>
void OneDGtid<T>::resize(size t newSize) {
mElements.resize(newSize);
}
template <typename T>
T& OneDGrid<T>::operator [] (size_t x) {
return mElements[x];
}

有了OneDGrid的这个实现,就可通过如下方式创建多维网格:

1
2
3
4
5
6
OneDGrid<int> singleDGrid;
OneDGrid<OneDGrid<int>> twoDGrid;
OneDGrid<OneDGrid<OneDGrid<int>>> threeDGrid;
singleDGrid[3] = 5;
twoDGrid[3][3] = 5;
threeDGrid[3][3][3] = 5;

真正的N维网格

可以编写一个类模板来自动进行递归。然后,可创建如下N维网格:

1
2
3
NDGrid<int, 1> singleDGrid;
NDGrid<int, 2> twoDGrid;
NDGrid<int, 3> threeDGrid;

NDGrid类模板需要元素类型和表示维度的整数作为参数。这里的关键问题在于,NDGrid的元素类型不是模板参数列表中指定的元素类型,而是上一层递归的维度中指定的另一个NDGrid。换句话说,三维网格是二维网格的矢量,二维网格是一维网格的各个矢量。

使用递归时,需要处理基本情形(base case)。 可编写维度为1的部分特例化的NDGrid,其中元素类型不是另一个NDGrid,而是模板参数指定的元素类型。下面是NDGrid模板定义的一般形式,突出显示了与前面OneDGrid的不同之处:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T, size_t N>
class NDGrid {
public:
explicit NDGrid(size_t size = kDefaultSize);
virtual ~NDGrid() = default;
NDGrid<T, N-1>& operator[](size_t x);
const NDGrid<T, N-1>& operator [] (size_t x) const;
void resize(size_t newSize);
size_t getsize() const { return mElements.size();}
static const size_t kDefaultsize =10;
private:
std::vector<NDGrid<T, N-1>> mElements;
};

注意,mElements是NDGrid<T, N-1>的矢量:这是递归步骤。此外,operator[]返回一个指向元素类型的引用,依然是NDGrid<T, N-1>而非T。基本情形的模板定义是维度为1的部分特例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
class NDGrid<T, 1> {
public:
explicit NDGrid(size_t size = kDefaultSize);
virtual ~NDGrid() = default;
T& operator[](size_t x);
const T& operator[](size_t x) const;
void resize(size_t newSize);
size_t getSize() const { return mElements.size();}
static const size_t kDefaultsize = 10;
private:
std::vector<T> mElements;
};

模板递归实现最棘手的部分不是模板递归本身,而是网格中每个维度的正确大小。这个实现创建了N维网格,每个维度都是一样大的。为每个维度指定不同的大小要困难得多。

下面是NDGrid主模板的实现,这里突出显示了与OneDGrid之间的差异:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T, size_t N>
NDGrid<T, N>::NDGrid(size_t size) {
resize(size);
}
template <typename T, size_t N>
void NDGrid<T, N>::resize(size_t newSize) {
mElements.resize(newSize);
for (auto& element : mElements)
element.resize(newSize);
}

template <typename T, size_t N>
NDGrid<T, N-1>& NDGrid<T, N>::operator[](size_t x) {
return mElements[x];
}

下面是部分特例化的实现(基本情形)。请注意,必须重写很多代码,因为不能在特例化中继承任何实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
NDGrid<T, 1>::NDGrid(size_t size) {
resize(size);
}
template <typename T>
void NDGrid<T, 1>::resize(size_t newSize) {
mElements.resize (newSize);
}

template <typename T>
T& NDGrid<T, 1>::operator[](size_t x) {
return mElements[x];
}

现在,可编写下面这样的代码:

1
2
3
4
NDGrid<int, 3> my3DGrid;
my3DGrid[2][1][2] = 5;
my3DGrid[1][1][1] = 5;
cout << my3DGrid[2][1](2]<< endl;

可变参数模板

普通模板只可采取固定数量的模板参数。可变参数模板(variadic template)可接收可变数目的模板参数。例如,下面的代码定义了一个模板,它可以接收任何数目的模板参数,使用称为Types的参数包(parameter pack):

1
2
template<typename... Types>
Class MyVarladicTemplate { };

可用任何数量的类型实例化MyVariadicTemplate,例如:

1
2
MyVariadicTemplate<int> instancel;
MyVariadicTemplate<string, double, list<int>>instance2;

甚至可用零个模板参数实例化:
1
MyVariadicTemplate<> instance3;

为避免用零个模板参数实例化可变参数模板,可以像下面这样编写模板:

1
2
template<typename T1, typename... Types>
class MyVariadicTemplate { };

类型安全的变长参数列表

可变参数模板允许创建类型安全的变长参数列表。下面的例子定义了一个可变参数模板processValues(),它允许以类型安全的方式接收不同类型的可变数目的参数。函数processValues()会处理变长参数列表中的每个值,对每个参数执行handleValue()函数。 这意味着必须对每种要处理的类型编写handleValue()函数,例如下例中的int、double和string:

1
2
3
4
5
6
7
8
9
10
void handleValue(int value) { cout <<"Integer: " << value << endl; }
void handleValue(double value) { cout << "Double: " << value << endl; }
void handleValue(string_view value) { cout << value << endl; }
void processValues() { /* Nothing to do in this base case.*/ }

template<typename T1, typename... Tn>
void processValues(T1 arg1, Tn... args) {
handleValue(arg1);
processValues(args...);
}

在前面的例子中,三点运算符“…”用了两次。这个运算符出现在3个地方,有两个不同的含义。首先,用在模板参数列表中typename的后面以及函数参数列表中类型Tn的后面。在这两种情况下,它都表示参数包。参数包可接收可变数目的参数。

“…”运算符的第二种用法是在函数体中参数名args的后面。这种情况下,它表示参数包扩展。这个运算符会解包/展开参数包,得到各个参数。它基本上提取出运算符左边的内容,为包中的每个模板参数重复该内容,并用逗号隔开。从前面的例子中取出以下行:

1
processValues (args...);

这一行将args参数包解包(或扩展)为不同的参数,通过逗号分隔参数,然后用这些展开的参数调用processValues()函数。模板总是需要至少一个模板参数: T1。通过args...递归调用processValues()的结果是: 每次调用都会少一个模板参数。

由于processValues()函数的实现是递归的,因此需要采用一种方法来停止递归。为此,实现一个processValues()函数,要求它接收零个参数。可通过下面的代码来测试processValues()可变参数模板:

1
processValues(1, 2, 3.56, "test", 1.1f);

这个例子生成的递归调用是:
1
2
3
4
5
6
7
8
9
10
11
processValues(1, 2, 3.56, "test", 1.1f);
handleValue(1);
processValues(2, 3.56, "test", 1.1f);
handleValue(2);
processValues(3.56, "test", 1.1f);
handleValue(3.56);
processValues("test", 1.1f);
handleValue("test");
processValues(1.1f);
handleValue(1.1f);
processValues();

重要的是要记住,这种变长参数列表是完全类型安全的。processValues()函数会根据实际类型自动调用正确的handleValue()重载版本。C++中也会像通常那样自动执行类型转换。然而,如果调用processValues()时带有某种类型的参数,而这种类型没有对应的handleValue()函数,编译器会产生错误。

为了在使用非const引用的同时也能使用字面量值,可使用转发引用(forwarding references)。以下实现使用了转发引用T&&,还使用std::forward()完美转发所有参数。“完美转发”意味着,如果把rvalue传递给processValues(),就将它作为ralue引用转发:如果把lvalue或lvalue引用传递给processValues(),就将它作为lvalue引用转发。

1
2
3
4
5
6
void processValues() {/* Nothing to do in this base case.*/}
template<typename T1, typename... Tn>
void processValues(T1&& arg1, Tn&&... args) {
handleValue(std::forward<T1> (arg1));
processValues(std::forward<Tn>(args)...);
}

有一行代码需要做进一步解释:
1
processValues(std::forward<Tn> (args)...);

“…”运算符用于解开参数包,它在参数包中的每个参数上使用std::forward(),用逗号把它们隔开。例如,假设args是一个参数包,有三个参数(al、a2和a3),分别对应三种类型(A1、A2和A3)。扩展后的调用如下:

1
processValues(std::forward<A1>(a1), std::forward<A2>(a2), std::forward<A3>(a3));

在使用了参数包的函数体中,可通过以下方法获得参数包中参数的个数:

1
int numOfArgs = sizeof...(args);

折叠表达式

C++17增加了对折叠表达式(folding expression)的支持。这样一来,将可更容易地在可变参数模板中处理参数包。下面分析一些示例。以递归方式定义前面的processValues()函数模板,如下所示:

1
2
3
4
5
6
void processValues(){/* Nothing to do in this base case.*/}
template<typename T1, typename... Tn>
void ProcessValues(T1 arg1, Tn...args) {
handleValue(arg1);
processValues(args...);
}

由于以递归方式定义,因此需要基本情形来停止递归。使用折叠表达式,利用一元右折叠,通过单个函数模板来实现。此时,不需要基本情形:
1
2
3
4
template<typename... Tn>
void processvalues (const Tn&... args) {
(handleValue(args), ...);
}

基本上,函数体中的三个点触发折叠。扩展这一行,针对参数包中的每个参数调用handleValue(),对handleValue()的每个调用用逗号分隔。例如,假设args是包含三个参数(a1、a2和a3)的参数包。一元右折叠扩展后的形式如下:

1
(handleValue(a1), (handleValue(a2), handleValue(a3)));

下面是另一个示例。printValues()函数模板将所有实参写入控制台,实参之间用换行符分开。

1
2
3
4
template<typename... Values>
void printValues (const Values&... values) {
((cout << values << endl), ...);
}

假设values是包含三个参数(v1、v2和v3)的参数包。一元右折叠扩展后的形式如下:

1
((cout << v1 << endl), ((cout << v2 << endl), (cout << v3 << endl)));

调用printValues()时可使用任意数量的实参,如下所示:

1
printValues(1, "test", 2.34);

模板元编程

模板元编程的目标是在编译时而不是运行时执行一些计算。模板元编程基本上是基于C++的一种小型编程语言。下面首先讨论一个简单示例,这个例子在编译时计算一个数的阶乘,并在运行时能将计算结果用作简单的常数。

编译时阶乘

下面的代码演示了在编译时如何计算一个数的阶乘。代码使用了本章前面介绍的模板递归,我们需要一个递归模板和用于停止递归的基本模板。根据数学定义,0的阶乘是1,所以用作基本情形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<unsigned char f>
class Factorial {
public:
static const unsigned long long val = (f * Factorial<f - 1>::val);
};

template<>
class Factorial<0> {
public:
static const unsigned long long val = 1;
};

int main () {
cout << Factorial<6>::val << enal;
return 0;
}

这将计算6的阶乘,数学表达为6!,值为1x2x3x4x5x6或720。

上面这个具体示例在编译时计算一个数的阶乘,但未必需要使用模板元编程。由于引入了constexpr,可不使用模板,写成如下形式。不过,模板实现仍然是实现递归模板的优秀示例。

1
2
3
4
5
6
constexpr unsigned long long factorial (unsigned char f) {
if(f == 0)
return 1;
else
return f * factorial(f-1);
}

如果调用如下版本,则在编译时计算值:

1
constexpr auto f1 = factorial(6);

不过,在这条语句中,切勿忘掉constexpr。 如果编写如下代码,将在运行时完成计算!

1
auto f1 = factorial(6);

在模板元编程版本中,不能犯此类错误。始终使计算在编译时完成。

循环展开

模板元编程的第二个例子是在编译时展开循环,而不是在运行时执行循环。注意循环展开(loop unrolling)应仅在需要时使用,因为编译器通常足够智能,会自动展开可以展开的循环。

这个例子再次使用了模板递归,因为需要在编译时在循环中完成一些事情。在每次递归中,Loop模板都会通过i-1实例化自身。当到达0时,停止递归。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<int i>
class Loop {
public:
template <typename FuncType>
static inline void Do(FuncType func) {
Loop<i - 1>::Do(func);
func(i);
}
};

template<>
class Loop<0> {
public:
template <typename FuncType>
static inline void Do(FuncType /* func*/){}
};

可以像下面这样使用Loop模板:

1
2
3
4
void DoWork(int i) { cout << "DoWork("<< i<<")" << endl;}
int main() {
Loop<3>::Do(DoWork);
}

这段代码将导致编译器展开循环,并连续3次调用DoWork()函数。这个程序的输出如下所示:

1
2
3
DoWork(1)
DoWork(2)
DoWork(3)

使用lambda表达式,可使用接收多个参数的DoWork20版本:

1
2
3
4
5
6
7
void DoWork2(string str, int i) {
cout << "DoWork2("<< str << ", " << i << ")" << endl;
}
int main() {
Loop<2>::Do([](int i) { DoWork2("TestStr", i); });
return 0;
}

上述代码首先实现了一个函数,这个函数接收一个字符串和一个int值。main()函数使用lambda表达式,在每个迭代上将一个固定的字符串TestStr作为第一个参数调用DoWork20。编译并运行上述代码,输出应该如下所示:

1
2
DoWork2(TestStr, 1)
DoWork2(TestStr, 2)

打印元组

这个例子通过模板元编程来打印std::tuple中的各个元素。与模板元编程中的大部分情况一样,这个例子也使用了模板递归。tuple_print类模板接收两个模板参数:tuple类型和初始化为元组大小的整数。然后在构造函数中递归地实例化自身,每一次调用都将大小减小。当大小变成0时,tuple_print的一个部分特例化停止递归。main()函数演示了如何使用这个tuple_print类模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename TupleType, int n>
class tuple_print {
public:
tuple_print (const TupleType& t) {
tuple_print<TupleType, n - 1> tp(t);
cout << get<n - 1>(t) << endl;
}
};

template<typename TupleType>
class tuple_print<TupleType, 0> {
public:
tuple_print(const TupleType&) {}
};

int main() {
using MyTuple = tuple<int, string, bool>;
MyTuple t1(16, "Test", true);
tuple_print<MyTuple, tuple_size<MyTuple>::value> tp(t1);
}

constexpr if

C++17引入了constexpr if。这些是在编译时(而非运行时)执行的if语句。如果constexpr if语句的分支从未到达,就不会进行编译。这可用于简化大量的模板元编程技术,也可用于本章后面讨论的SFINAE。

例如,可按如下方式使用constexpr if,简化前面的打印元组元素的代码。注意,不再需要模板递归基本情形,原因在于可通过constexpr if语句停止递归。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename TupleType, int n>
class tuple_print_helper {
public:
tuple_print_helper (const TupleTypes t) {
if constexpr(n>1) {
tuple_print_helper<TupleType, n - 1>tp(t);
}
cout << get<n - 1>(t) << endl;
}
};

template<typename T>
void tuple_print(const T& t) {
tuple_print_helper<T, tuple_size<T>::value>tph(t);
}

现在,甚至可丢弃类模板本身,替换为简单的函数模板tuple_print_helper:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename TupleType, int n>
void tuple_print_helper (const TupleType& t) {
if constexpr(n>1) {
tuple_print_helper<TupleType, n - 1>(t);
}
cout << get<n - 1>(t) << endl;
}

template<typename T>
void tuple_print (const T& t) {
tuple_print_helper<T, tuple_size<T>::value>(t);
}

可对其进一步简化。将两个方法合为一个,如下所示:

1
2
3
4
5
6
template<typename TupleType, int n = tuple_size<TupleType>::value>
void tuple_print(const TupleType& t) {
if constexpr(n > 1)
tuple_print<TupleType, n - 1>(t);
cout << get<n - 1>(t) << endl;
}

仍然像前面那样进行调用:

1
2
auto t1 = make_tuple(167, "Testing", false, 2.3);
tuple_print(t1);

使用编译时整数序列和折叠

C++使用std::integer_sequence(在<utility>中定义)支持编译时整数序列。模板元编程的一个常见用例是生成编译时索引序列,即size_t类型的整数序列。此处,可使用辅助用的std::index_sequence。可使用std::index_sequence_for生成与给定的参数包等长的索引序列。

下面使用可变参数模板、编译时索引序列和C++17折叠表达式,实现元组打印程序:

1
2
3
4
5
6
7
8
template<typename Tuple, size_t... Indices>
void tuple_print_helper(const Tuple& t, index_sequence<Indices...>) {
((cout << get<Indices>(t) << endl), ...);
}
template<typename... Args>
void tuple_print (const tuple<Args...>& t) {
tuple_print_helper(t, index_sequence_for<Args...>());
}

可按与前面相同的方式调用:

1
2
auto t1 = make_tuple(167, "Testing", false, 2.3);
tuple_print(t1);

调用时,tuple_print_helper()函数模板中的一元右折叠表达式扩展为如下形式:

1
2
3
4
(((cout << get<0>(t) << endl),
((cout << get<1>(t) << endl),
((cout << get<2>(t) << endl),
(cout << get<3>(t) << endl)))));

类型trait

通过类型trait可在编译时根据类型做出决策。例如,可编写一个模板,这个模板要求从某种特定类型派生的类型,或者要求可转换为某种特定类型的类型,或者要求整数类型,等等。C++标准为此定义了一些辅助类。所有与类型trait相关的功能都定义在<type_traits>头文件中。类型trait分为几个不同类别。下面列出了每个类别的可用类型trait的一些例子。

使用类型类别

在给出使用类型trait的模板示例前,首先要了解一下诸如is_integral的类的工作方式。C++标准对integral_constant类的定义如下所示:

1
2
3
4
5
6
7
8
template <class T, T v>
struct integral_constant {
static constexpr T value = v;
using value_type = T;
using type = integral_constant<T,v>;
constexpr operator value_type() const noexcept { return value; }
constexpr value_type operator() () const noexcept { return value; }
};

这也定义了bool_constanttrue_typefalse_type类型别名:
1
2
3
4
template <bool B>
using bool_constant = integral_constant<bool,B>;
using true_type = bool_constant<true>;
using false_type = bool_constant<false>;

这定义了两种类型:true_typefalse_type。当调用true_type::value时,得到的值是true;调用false_type::value时,得到的值是false。还可调用true_type::type。这将返回true_type类型。这同样适用于false_type。诸如is_integralis_class的类继承了true_typefalse_type。例如,is_integral为类型bool特例化,如下所示:

1
template<> struct is_integral<bool> : public true_type { };

这样就可编写is_integral<bool>::value,并返回true。注意,不需要自己编写这些特例化,这些是标准库的部分。下面的代码演示了使用类型类别的最简单例子:

1
2
3
4
5
6
7
8
if (is_integral<int>::value)
cout <<"int is integral" << endl;
else
cout << "int is not integral" << endl;
if (is_class<string>::value)
cout << "string is a class" << endl;
else
cout << "string is not a class" << endl;

这个例子通过is_integral来检查int是否为整数类型,并通过is_class来检查string是否为类。输出如下:

1
2
int is integral
string is a class

对于每一个具有value成员的trait,C++17添加了一个变量模板,它与trait同名,后跟_v。不是编写some_trait<T>::value,而是编写some_trait_v<T>,例如is_integral_v<T>is_const_v<T>等。 下面用变量模板重写了前面的例子:

1
2
3
4
5
6
7
8
if (is_integral_v<int>)
cout <<"int is integral" << endl;
else
cout <<"int is not integral" << endl;
if(is_class_v<string>)
cout <<"string is a class"<< endl;
else
cout <<"string is not a class" <<endl;

只有结合模板根据类型的某些属性生成代码时,类型trait才更有用。下面的模板示例演示了这一点。代码定义了函数模板process_helper()两个重载版本,这个函数模板接收一种类型作为模板参数。第一个参数是一个值,第二个参数是true_typefalse_type的实例。process()函数模板接收一个参数,并调用process_helper()函数:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void process_helper(const T& t, true_type) {
cout << t <<" is an integral type." << endl;
}
template<typename T>
void process_helper (const T& t, talse_type) {
cout << t <<"is a non-integral type."<<endl;
}
template<typename T>
void process (const T& t) {
process_helper(t, typename is_integral<T>::type());
}

process_helper()函数调用的第二个参数定义如下:

1
typename is_integral<T>::type()

该参数使用is_integral判断T是否为整数类型。使用::type访问结果integral_constant类型,可以是true_typefalse_typeprocess_helper()函数需要true_typefalse_type的一个实例作为第二个参数,这也是为什么::type后面有两个空括号的原因。注意,process_helper()函数的两个重载版本使用了类型为true_typefalse_type的无名参数。因为在函数体的内部没有使用这些参数,所以这些参数是无名的。这些参数仅用于函数重载解析。

这些代码的测试如下:

1
2
3
process(123);
process(2.2);
process("Test"s);

这个例子的输出如下:

1
2
3
123 is an integral type,
2.2 is a non-integral type.
Test is a non-integral type

前面的例子只使用单个函数模板来编写,但没有说明如何使用类型trait,以基于类型选择不同的重载。

1
2
3
4
5
6
7
8
template<typename T>
void process (const T& t) {
if constexpr (is_integral_v<T>){
cout <it <<" is an integral type."<<endl;
} else {
cout <<t <<"is a non-integral type."<<endl;
}
}

使用类型关系

有三种类型关系:is_sameis_base_ofis_convertible。下面将给出一个例子来展示如何使用is_same。其余类型关系的工作原理类似。下面的same()函数模板通过is_same类型trait特性判断两个给定参数是否类型相同,然后输出相应的信息。

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T1, typename T2>
void same (const T1& t1, const T2& t2) {
bool areTypesTheSame = is_same_v<T1,T2>;
cout << "'" << t1 << "' and '" << t2 << "' are '";
cout << (areTypesTheSame ? "the same types." : "different types.") << endl;
}

int main() {
same(1, 32);
same(1, 3.01);
same(3.01, "Test"s);
}

输出如下所示:

1
2
3
'1' and '32' are the same types.
'1’ and '3.01' are different types
'3.01' and 'Test' are different types

使用enable_if

使用enable_if需要了解“替换失败不是错误”SFINAE特性,这是C++中一个复杂晦涩的特性。下面仅讲解SFINAE的基础知识。

如果有一组重载函数,就可以使用enable_if根据某些类型特性有选择地禁用某些重载。enable_if通常用于重载函数组的返回类型。enable_if接收两个模板类型参数。第一个参数是布尔值,第二个参数是默认为void的类型。如果布尔值是true,enable_if类就有一种可使用::type访问的嵌套类型,这种嵌套类型由第二个模板类型参数给定。如果布尔值是false,就没有嵌套类型。

C++标准为具有type成员的trait(如enable_if)定义别名模板,这些与trait同名,但附加了_t。例如,不编写如下代码:

1
typename enable_if<...,bool>::type

而编写如下更简短的版本:

1
enable_if_t<..., bool>

通过enable_if,可将前面使用same()函数模板的例子重写为一个重载的check_type()函数模板。在这个版本中,check_type()函数根据给定值的类型是否相同, 返回true或false。如果不希望check_type()返回任何内容,可删除return语句,可删除enable_if的第二个模板类型参数,或用void替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename T1, typename T2>
enable_if_t<is_same_v<T1, T2>, bool>
check_type (const T1& t1, const T2& t2) {
cout << "'" << t1 << "' and '" << t2 << "' ";
cout << "are the same types."<< endl;
return true;
}

template<typename T1, typename T2>
enable_if_t<!is_same_v<T1,T2>, bool>
check_type (const T1& t1, const T2& t2) {
cout << "'" << t1 << "' and '" << t2 <<"' ";
cout << "are different types." << endl;
return false;
}

int main () {
check_type(1, 32);
check_type(1, 3.01);
check_type(3.01, "Test"s);
}

输出与前面的相同。

上述代码定义了两个版本的check_type(),它们的返回类型都是enable_if的嵌套类型bool。首先,通过is_same_v检查两种类型是否相同,然后通过enable_if_t获得结果。当enable_if_t的第一个参数为true时,enable_if_t的类型就是bool;当第一个参数为false时, 将不会有返回类型。这就是SFINAE发挥作用的地方。

当编译器开始编译main()函数的第一行时,它试图找到接收两个整型值的check_type()函数。编译器会在源代码中找到第一个重载的check_type()函数模板,并将T1和T2都设置为整数,以推断可使用这个模板的实例。然后,编译器会尝试确定返回类型。由于这两个参数是整数,因此是相同的类型,is_same_v<T1,T2>将返回true,这导致enable_if_t<true, bool>返回类型bool。这样实例化时一切都很好,编译器可使用该版本的check_type()

然而,当编译器尝试编译main()函数的第二行时,编译器会再次尝试找到合适的check_type()函数。编译器从第一个check_type()开始, 判断出可将T1设置为int类型,将T2设置为double类型。然后,编译器会尝试确定返回类型。这一次,T1和T2是不同的类型,这意味着is_same_v<T1,T2>将返回false。因此enable_if_t<false,bool>不表示类型,check_type()函数不会有返回类型。编译器会注意到这个错误,但由于SFINAE,还不会产生真正的编译错误。编译器将正常回溯,并试图找到另一个check_type()函数。这种情况下,第二个check_type()可以正常工作,因为!is_same_v<T1,T2>为true,此时enable_if_t<true,bool>返回类型bool。

如果希望在一组构造函数上使用enable_if,就不能将它用于返回类型,因为构造函数没有返回类型。此时可在带默认值的额外构造函数参数上使用enable_if

使用constexpr if简化enable_if结构

从前面的示例可以看到,使用enable_if将十分复杂。某些情况下,C++17引入的constexpr if特性有助于极大地简化enable_if。例如,假设有以下两个类:

1
2
3
4
5
6
class IsDoable {
public:
void doit() const { cout << "IsDoable::doit()"<<endl;}
};

class Derived : public IsDoable { };

可创建一个函数模板call_doit()。如果方法可用,它调用doit()方法;否则在控制台上打印错误消息。为此,可使用enable_if,检查给定类型是否从IsDoable派生:

1
2
3
4
5
6
7
8
9
10
template<typename T>
enable_if_t<is_base_of_v<IsDoable, T>,void>
call_doit (const T& T) {
t.doit();
}
template<typename T:
enable_if_t<!is_base_of_v<IsDoable, T>, void>
call_doit (const T&) {
cout << "Cannot call doit()!" << endl;
}

下面的代码对该实现进行测试:

1
2
3
Derived d;
call_doit(d);
call_doit(123);

输出如下:

1
2
IsDoable::doit()
Cannot call doit()!

使用C++17的constexpr if可极大地简化enable_if实现:

1
2
3
4
5
6
7
template<typename T>
void call_doit(const T& [[maybe_unused]] t) {
if constexpr(is_base_of_v<IsDoable, T>){
t.doit();
else
cout << "Cannot call doit()!"<< endl;
}

无法使用普通if语句做到这一点!使用普通if语句,两个分支都需要编译,而如果指定并非从IsDoable派生的类型T,这将失败。此时,t.doit()一行无法编译。但是,使用constexpr if语句,如果提供了并非从IsDoable派生的类型,t.doit()一行甚至不会编译!

不使用is_base_of类型trait,也可使用C++17新引入的is_invocabletrait,这个trait可用于确定在调用给定函数时是否可以使用一组给定的参数。下面是使用is_invocable trait的call_doit()实现:

1
2
3
4
5
6
7
template<typename T>
void call_doit (const T& [[maybe_unused]] t) {
if constexpr (is_invocable_v<decltype (6IsDoable::doit),T>){
t.doit();
else
cout << "Cannot call doit()!" << endl;
}

逻辑运算符trait

在三种逻辑运算符trait:串联(conjunction)、分离(disjunction)与否定(negation)。以_v结尾的可变模板也可供使用。这些trait接收可变数量的模板类型参数,可用于在类型trait上执行逻辑操作,如下所示:

1
2
3
4
cout << conjunction_v<is_integral<int>, is_integral<short>> << " ";
cout << conjunction_v<is_integral<int>, is_integra1<double>> << " ";
cout << disjunction_v<is_integral<int>, is_integral<double>, is_integral<short>> << " ";
cout << negation_v<is_integral<int>> << " ";

C++多线程编程

多线程编程概述

C++98/03不支持多线程编程,所以必须借助第三方库或目标操作系统中的多线程API。自C++11开始,C++有了一个标准的多线程库,使编写跨平台的多线程应用程序变得更容易了。目前的C++标准仅针对CPU,不适用于GPU,这种情形将来可能会改变。

争用条件

当多个线程要访问任何种类的共享资源时,可能发生争用条件。共享内存上下文的争用条件称为“数据争用”。当多个线程访问共享的内存,且至少有一个线程写入共享的内存时,就会发生数据争用。

撕裂

撕裂(tearing)是数据争用的特例或结果。有两种撕裂类型:撕裂读和撕裂写。如果线程已将数据的一部分写入内存,但还有部分数据没有写入,此时读取数据的其他任何线程将看到不一致的数据,发生撕裂读。如果两个线程同时写入数据,其中一个线程可能写入数据的一部分,而另一个线程可能写入数据的另一部分,最终结果将不一致,发生撕裂写。

死锁

死锁指的是两个线程因为等待访问另一个阻塞线程锁定的资源而造成无限阻塞,这也可扩展到超过两个线程的情形。例如,假设有两个线程想要访问某共享资源,它们必须拥有权限才能访问该资源。如果其中一个线程当前拥有访问该资源的权限,但由于其他一些原因而被无限期阻塞,那么此时,试图获取同一资源权限的另一个线程也将无限期阻塞。

现在设想两个线程中的代码按如下顺序执行。

  • 线程1:获取A
  • 线程2:获取B
  • 线程1:获取B(等待/阻塞,因为B被线程2持有)
  • 线程2:获取A(等待/阻塞,因为A被线程1持有)

现在两个线程都在无限期地等待,这就是死锁情形。可以看到一个表示死锁情形的环。这两个线程将无限期地等待。

最好总是以相同的顺序获得权限,以避免这种死锁。也可在程序中包含打破这类死锁的机制。一种可行的方法是试图等待一定的时间,看看能否获得某个资源的权限。如果不能在某个时间间隔内获得这个权限,那么线程停止等待,并释放当前持有的其他锁。线程可能睡眠一小段时间,然后重新尝试获取需要的所有资源。这种方法也可能给其他线程获得必要的锁并继续执行的机会。这种方法是否可用在很大程度上取决于特定的死锁情形。

不要使用前一段中描述的那种变通方法,而是应该尝试避免任何可能的死锁情形。如果需要获得由多个互斥对象保护的多个资源的权限,而非单独获取每个资源的权限,推荐使用标准的std::lock()std::try_lock()函数。这两个函数会通过一次调用获得或尝试获得多个资源的权限。

伪共享

大多数缓存都使用所谓的“缓存行(cache line)”。对于现代CPU而言,缓存行通常是64个字节。如果需要将一些内容写入缓存行,则需要锁定整行。如果代码结构设计不当,对于多线程代码而言,这会带来严重的性能问题。可使用显式的内存对齐(memory alignment)方式优化数据结构,确保由多个线程处理的数据不共享任何缓存行。为了以便携方式做到这一点,C++17引入了hardware_destructive_interference_size常量,该常量在<new>中定义,为避免共享缓存行,返回两个并发访问的对象之间的建议偏移量。可将这个值与alignas关键字结合使用,以合理地对齐数据。

线程

借助在<thread>头文件中定义的C++线程库,启动新的线程将变得非常容易。可通过多种方式指定新线程中需要执行的内容。可让新线程执行全局函数、函数对象的operator()、lambda表达式甚至某个类实例的成员函数。

通过函数指针创建线程

标准C++的std::thread类使用的函数可以有任意数量的参数。假设counter()函数接收两个整数:第一个表示ID,第二个表示这个函数要循环的迭代次数。函数体是一个循环,这个循环执行给定次数的迭代。

1
2
3
4
5
void counter(int id, int numIterations) {
for(int i = 0; i < numIterations; ++i) {
cout << "Counter" << id << " has value " << i << endl;
}
}

可通过std::thread启动执行此函数的多个线程。 可创建线程t1,使用参数1和6执行counter():

1
thread t1 (counter, 1, 6);

thread类的构造函数是一个可变参数模板,也就是说,可接收任意数目的参数。第一个参数是新线程要执行的函数的名称。当线程开始执行时,将随后可变数目的参数传递给这个函数。

如果一个线程对象表示系统当前或过去的某个活动线程,则认为它是可结合的(joinable)。即使这个线程执行完毕,该线程对象也依然处于可结合状态。默认构造的线程对象是不可结合的。在销毁一个可结合的线程对象前,必须调用其join()detach()方法。对join()的调用是阻塞调用,会一直等到线程完成工作为止。调用detach()时,会将线程对象与底层OS线程分离。此时,OS线程将继续独立运行。调用这两个方法时,都会导致线程变得不可结合。如果一个仍可结合的线程对象被销毁,析构函数会调用std::terminate(),这会突然间终止所有线程以及应用程序本身。

下面的代码启动两个线程来执行counter()函数。启动线程后,main()调用这两个线程的join()方法。

1
2
3
4
thread t1(counter, 1, 6);
thread t2(counter, 2, 4);
t1.join();
t2.join();

这个示例的可能输出如下所示:

1
2
3
4
5
6
7
8
9
10
Counter 2 has value 0
Counter 1 has value 0
Counter 1 has value 1
Counter 1 has value 2
Counter 1 has value 3
Counter 1 has value 4
Counter 1 has value 5
Counter 2 has value 1
Counter 2 has value 2
Counter 2 has value 3

输出取决于系统中处理核心的数量以及操作系统的线程调度。

默认情况下,从不同线程访问cout是线程安全的,没有任何数据争用,除非在第一个输出或输入操作之前调用了cout.sync_with_stdio(false)。然而,即使没有数据争用,来自不同线程的输出仍然可以交错。

线程函数的参数总是被复制到线程的某个内部存储中。通过<functional>头文件中的std::ref()cref()按引用传递参数。

通过函数对象创建线程

不使用函数指针,也可以使用函数对象在线程中执行。之前使用函数指针技术,给线程传递信息的唯一方式是给函数传递参数。而使用函数对象,可向函数对象类添加成员变量,并可以采用任何方式初始化和使用这些变量。下例首先定义Counter类。这个类有两个成员变量:一个表示ID,另一个表示循环迭代次数。这两个成员变量都通过类的构造函数进行初始化。为让Counter类成为函数对象,需要实现operator()operator()的实现和counter()函数一样:

1
2
3
4
5
6
7
8
9
10
11
Class Counter {
public:
Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations) { }
void operator () () const {
for (int i = 0; i < mNumIterations; ++i)
cout << "Counter" << mId << " has value " << i << endl;
}
private:
int mId;
int mNumIterations;
};

下面的代码片段演示了通过函数对象初始化线程的三种方法。

  • 第一种方法使用了统一初始化语法。通过构造函数参数创建Counter类的一个实例,然后把这个实例放在花括号中,传递给thread类的构造函数。
  • 第二种方法定义了Counter类的一个命名实例,并将它传递给thread类的构造函数。
  • 第三种方法类似于第一种方法:创建Counter类的一个实例并传递给thread类的构造函数,但是使用了圆括号而不是花括号。
1
2
3
4
5
6
7
8
9
10
thread t1 { Counter{ 1,20 } };

Counter c(2,12);
thread t2(c);

thread t3 (Counter(3, 10));

t1.join();
t2.join();
t3.join();

通过lambda创建线程

lambda表达式能很好地用于标准C++线程库。下例启动一个线程来执行给定的lambda表达式:

1
2
3
4
5
6
7
8
9
int main () {
int id = 1;
int numIterations = 5;
thread t1([id, numIterations] {
for (int i = 0; i < numIterations; ++i) {
cout << "Counter" << id << " has value " << i << endl;
}
});
t1.join();

通过成员函数创建线程

还可在线程中指定要执行的类的成员函数。下例定义了带有process()方法的基类Request。main()函数创建Request类的一个实例,并启动一个新的线程,这个线程执行Request实例reqprocess()成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Request {
public:
Request (int id) : mId(id) { }
void process () {
cout <<"Processing request "<< mId << endl;
}
private:
int mId;
}

int main() {
Request req(100);
thread t { sRequest::process, &req };
t.join();
}

通过这种技术,可在不同线程中执行某个对象中的方法。如果有其他线程访问同一个对象,那么需要确认这种访问是线程安全的,以避免争用条件。

线程本地存储

C++标准支持线程本地存储的概念。通过关键字thread_local,可将任何变量标记为线程本地数据,即每个线程都有这个变量的独立副本,而且这个变量能在线程的整个生命周期中持续存在。对于每个线程,该变量正好初始化一次。例如,在下面的代码中,定义了两个全局变量;每个线程都共享唯一的k副本,而且每个线程都有自己的n副本:

1
2
int k;
thread_local int n;

注意,如果thread_local变量在函数作用域内声明,那么这个变量的行为和声明为静态变量是一致的,只不过每个线程都有自己独立的副本,而且不论这个函数在线程中调用多少次,每个线程仅初始化这个变量一次。

取消线程

C++标准没有包含在一个线程中取消另一个已运行线程的任何机制。实现这一目标的最好方法是提供两个线程都支持的某种通信机制。最简单的机制是提供一一个共享变量,目标线程定期检查这个变量,判断是否应该终止。其他线程可设置这个共享变量,间接指示线程关闭。这里必须注意,因为是由多个线程访问这个共享变量,其中至少有一个线程向共享变量写入内容。

从线程获得结果

一种方法是向线程传入指向结果变量的指针或引用,线程将结果保存在其中。另一种方法是将结果存储在函数对象的类成员变量中,线程执行结束后可获得结果值。只有使用std:ref(),将函数对象按引用传递给thread构造函数时,这才能生效。

然而,还有一种更简单的方法可从线程获得结果:future。通过future也能更方便地处理线程中发生的错误。

复制和重新抛出异常

如果一个线程抛出的异常不能在另一个线程中捕获,C++运行库将调用std:terminate(),从而终止整个应用程序。从一个线程抛出的异常不能在另一个线程中捕获。

不使用标准线程库,就很难在线程间正常地处理异常,甚至根本办不到。标准线程库通过以下和异常相关的函数解决了这个问题。这些函数不仅可用于std::exception,还可以用于所有类型的异常:int、string、自定义异常等。

exception_ptr current_exception() noexcept;

这个函数在catch块中调用,返回一个exception_ptr对象,这个对象引用目前正在处理的异常或其副本。如果没有处理异常,则返回空的exception_ptr对象。 只要存在引用异常对象的exception_ptr类型的对象,引用的异常对象就是可用的。exception_ptr对象的类型是NullablePointer,这意味着这个变量很容易通过简单的if语句来检查。

[[noreturn]] void rethrow_exception(exception_ptr p);

这个函数重新抛出由exception_ptr参数引用的异常。 未必在最开始生成引用的异常的那个线程中重新抛出这个异常,因此这个特性特别适合于跨不同线程的异常处理。[[noretun]]特性表示这个函数绝不会正常地返回。

templateexception_ptr make_exception_ptr(E e)noexcept;

这个函数创建一个引用给定异常对象副本的exception_ptr对象。这实际上是以下代码的简写形式:

1
2
3
4
5
try {
throw e;
} catch(...) {
return current_exception();
}

下面看一下如何通过这些函数实现不同线程间的异常处理。下面的代码定义了一个函数,这个函数完成一些事情并抛出异常。这个函数最终将运行在一个独立的线程中:

1
2
3
4
5
6
void doSomeWork() {
for (int i = 0; i < 5; ++ i)
cout << i << endl;
cout << "Thread throwing a runtime_error exception..."<<endl;
throw runtime_error("Exception from thread");
}

下面的threadFunc()函数将上述函数包装在一个try/catch块中,捕获doSomeWork()可能抛出的所有异常。为threadFunc()传入一个参数,其类型为exception_ptr&。一旦捕获到异常,就通过current_exception()函数获得正在处理的异常的引用,然后将引用赋给exception_ptr参数。之后,线程正常退出:

1
2
3
4
5
6
7
8
void threadFunc (exception_ptr& err) {
try {
dosomeWork();
} catch (...) {
cout << "thread caught exception, returning exception..." << endl;
err = current_exception();
}
}

以下doWorkInThread()函数在主线程中调用,其职责是创建一个新的线程,并开始在这个线程中执行threadFunc()函数。对类型为exception_ptr的对象的引用被作为参数传入threadFunc()。一旦创建了线程,
doWorkInThread()函数就使用join()方法等待线程执行完毕,之后检查error对象。由于exception_ptr的类型为NullablePointer,因此很容易通过if语句进行检查。如果是一个非空值,则在当前线程中重新抛出异常,在这个例子中,当前线程即主线程。在主线程中重新抛出异常,异常就从一个线程转移到另一个线程。

1
2
3
4
5
6
7
8
9
10
11
void doWorkInThread() {
exception_ptr error;

thread t{ threadFunc, ref(error) };
t.join();
if (error) {
cout << "Main thread received exception, rethrowing it..." << endl;
rethrow_exception (error);
} else {
cout << "Main thread did not receive any exception." << endl;
}

main()函数相当简单。它调用doWorkInThread(),将这一个调用包装在一个try/catch块中,捕获由doWorkInThread()创建的任何线程抛出的异常:

1
2
3
4
5
6
int main() {
try {
doWorkInThread();
} catch (const exception& e) {
cout << "Main function caught: " << e.what() << endl;
}

为让这个例子紧凑且更容易理解,main()函数通常使用join()阻塞主线程,并等待线程完成。当然,在实际的应用程序中,你不想阻塞主线程。例如,在GUI应用程序中,阻塞主线程意味着UI失去响应。此时,可使用消息传递范型在线程之间通信。例如,可让前面的threadFunc()函数给UI线程发送一条消息,消息的参数为current_exception()结果的一份副本。

原子操作库

原子类型允许原子访问,这意味着不需要额外的同步机制就可执行并发的读写操作。没有原子操作,递增变量就不是线程安全的,因为编译器首先将值从内存加载到寄存器中,递增后再把结果保存回内存。另一个线程可能在这个递增操作的执行过程中接触到内存,导致数据争用。

为使这个线程安全且不显式地使用任何同步机制,可使用std::atomic类型。下面是使用原子整数的相同代码:

1
2
atomic<int> counter(0);
++counter;

为使用这些原子类型,需要包含<atomic>头文件。 C++标准为所有基本类型定义了命名的整型原子类型

可使用原子类型,而不显式使用任何同步机制。但在底层,某些类型的原子操作可能使用同步机制。如果目标硬件缺少以原子方式执行操作的指令,则可能发生这种情况。可在原子类型上使用is_lock free()方法来查询它是否支持无锁操作;所谓无锁操作,是指在运行时,底层没有显式的同步机制。

可将std::atomic类模板与所有类型一起使用,并非仅限于整数类型。例如,可创建atomic<double>atomic<MyType>,但这要求MyType具有is_trivially_copy特点。底层可能需要显式的同步机制,具体取决于指定类型的大小。在下例中,Foo和Bar具有is_trivially_copy特点,即std::is_trivially_copyable_v都等于true。但atomic<Foo>并非无锁操作,而atomic<Bar>是无锁操作。

1
2
3
4
5
6
7
8
9
10
11
class Foo { private: int mArray[123]; };
class Bar { private: int mInt; };

int main() {
atomic<Foo> f;
cout << is_trivially_copyable_v<Foo> <<" "<<f.is_lock_free() <<endl;
// output: 1 0
atomic<Bar> b;
cout << is_trivially_copyable_v<Bar> <<" "<<b.is_lock_free() << endl;
// output: 1 1
}

原子操作

下面是一个原子操作示例:

1
bool atomic<T>::compare_exchange_strong(T& expected, T desired);

这个操作以原子方式实现了以下逻辑,伪代码如下:

1
2
3
4
5
6
7
if (*this == expected) {
*this = desired;
return true;
} else {
expected = *this;
return false;
}

这个逻辑初看起来令人感到陌生,但这是编写无锁并发数据结构的关键组件。无锁并发数据结构允许不使用任何同步机制来操作数据。

另一个例子是用于整型原子类型的atomic<T>::fetch_add()。这个操作获取该原子类型的当前值,将给定的递增值添加到这个原子值,然后返回未递增的原始值。例如:

1
2
3
4
5
atomic<int> value(10);
cout <<"Value = "<< value << endl;
int fetched = value.fetch_add(4);
cout << "Fetched = " << fetched << endl;
cout <<"Value = "<< value << endl;

如果没有其他线程操作fetched和value变量的内容,那么输出如下:

1
2
3
Value = 10
Fetched = 10
Value = 14

整型原子类型支持以下原子操作:fetch_add()fetch_sub()fetch_and()fetch_or()fetch_xor()++--&=^=|=。原子指针类型支持fetch_add()fetch_sub()++--+=-=

大部分原子操作可接收一个额外参数,用于指定想要的内存顺序。例如:

1
T atomic<T>::fetch_add(T value, memory_order = memory_order_seq_cst);

可改变默认的memory_order。C++标准提供了memory_order_relaxedmemory_order_consumememory_order acquirememory_order_releasememory_order_acq_relmemory_order_seq_cst,这些都定义在std名称空间中。然而,很少有必要使用默认之外的顺序。尽管其他内存顺序可能会比默认顺序性能好。

互斥

标准库支持互斥的形式包括互斥体(mutex)类和锁类。这些类都可以用来实现线程之间的同步。

互斥体类

互斥体(mutex,代表mutual exclusion)的基本使用机制如下:

  • 希望与其他线程共享内存读写的一个线程试图锁定互斥体对象。如果另一个线程正在持有这个锁,希望获得访问的线程将被阻塞,直到锁被释放,或直到超时。
  • 一旦线程获得锁,这个线程就可随意使用共享的内存,因为这要假定希望使用共享数据的所有线程都正确获得了互斥体对象上的锁
  • 线程读写完共享的内存后,线程将锁释放,使其他线程有机会获得访问共享内存的锁。如果两个或多个线程正在等待锁,没有机制能保证哪个线程优先获得锁,并且继续访问数据。

C++标准提供了非定时的互斥体类和定时的互斥体类。

非定时的互斥体类

标准库有三个非定时的互斥体类:std::mutexrecursive_mutexshared_mutex。前两个类在<mutex>中定义,最后一个类在<shared_mutex>中定义。每个类都支持下列方法。

  • lock():调用线程将尝试获取锁,并阻塞直到获得锁。这个方法会无限期阻塞。如果希望设置线程阻塞的最长时间,应该使用定时的互斥体类
  • try_lock():调用线程将尝试获取锁。如果当前锁被其他线程持有,这个调用会立即返回。如果成功获取锁,try_lock()返回true,否则返回false
  • unlock():释放由调用线程持有的锁,使另一个线程能获取这个锁

std::mutex是一个标准的具有独占所有权语义的互斥体类。只能有一个线程拥有互斥体。如果另一个线程想获得互斥体的所有权,那么这个线程既可通过lock()阻塞,也可通过try_lock()尝试失败。已经拥有std::mutex所有权的线程不能在这个互斥体上再次调用lock()try_lock(),否则可能导致死锁!

std::recursive_mutex的行为几乎和std::mutex一致,区别在于已经获得递归互斥体所有权的线程允许在同一个互斥体上再次调用lock()try_lock()。调用线程调用unlock()方法的次数应该等于获得这个递归互斥体上锁的次数。

shared_mutex支持“共享锁拥有权”的概念,这也称为readerswriters锁。线程可获取锁的独占所有权或共享所有权。独占拥有权也称为写锁,仅当没有其他线程拥有独占或共享所有权时才能获得。共享所有权也称读锁,如果其他线程都没有独占所有权,则可获得,但允许其他线程获取共享所有权。shared_mutex类支持lock()try_lock()unlock()。这些方法获取和释放独占锁。另外,它们具有以下与共享所有权相关的方法:lock_shared()try_lock_shared()unlock_shared()。这些方法与其他方法集合的工作方式相似,但尝试获取或释放共享所有权。

不允许已经在shared_mutex上拥有锁的线程在互斥体上获取第二个锁,否则会产生死锁!

定时的互斥体类

标准库提供了3个定时的互斥体类:std:timed_mutexrecursive_timed_mutexshared_timed_mutex。前两个类在<mutex>中定义,最后一个类在<shared_mutex>中定义。它们都支持lock()try_lock()unlock()方法,shared_timed_mutex还支持lock_shared()try_lock_shared()unlock_shared()。所有这些方法的行为与前面描述的类似。此外,它们还支持以下方法。

  • try_lock_for(rel_time):调用线程尝试在给定的相对时间内获得这个锁。如果不能获得这个锁,这个调用失败并返回false。如果在超时之前获得了这个锁,这个调用成功并返回true。
  • try_lock_until(abs_time):调用线程将尝试获得这个锁,直到系统时间等于或超过指定的绝对时间。如果能在超时之前获得这个锁,调用返回true。如果系统时间超过给定的绝对时间,将不再尝试获得锁,并返回false。

shared_timed_mutex还支持try_lock_shared_for()try_lock_shared_until()

已经拥有timed_mutexshared_timed_mutex所有权的线程不允许再次获得这个互斥体上的锁,否则可能导致死锁!

recursive_timed_mutex的行为和recursive_mutex类似,允许一个线程多次获取锁。

锁类是RAII类,可用于更方便地正确获得和释放互斥体上的锁;锁类的析构函数会自动释放所关联的互斥体。C++标准定义了4种类型的锁:std::lock_guardunique_lockshared_lockscoped_lock。最后一类是在C++17中引入的

lock_guard

lock_guard<mutex>中定义,有两个构造函数

  • explicit lock_guard(mutex_type& m);
    • 接收一个互斥体引用的构造函数。这个构造函数尝试获得互斥体上的锁,并阻塞直到获得锁。
  • lock_guard(mutex_type& m, adopt_lock_t);
    • 接收一个互斥体引用和一个std::adopt_lock_t实例的构造函数。C++提供了一个预定义的adopt_lock_t实例,名为std:adopt_lock。该锁假定调用线程已经获得引用的互斥体上的锁,管理该锁,在销毁锁时自动释放互斥体。

unique_lock

std:unique_lock定义在<mutex>中,是一类更复杂的锁,允许将获得锁的时间延迟到计算需要时,远在声明时之后。使用owns_lock()方法可以确定是否获得了锁。unique_lock也有bool转换运算符,可用于检查是否获得了锁。

unique_lock有如下几个构造函数。

  • explicit unique_lock(mutex_type& m);
    • 接收一个互斥体引用的构造函数。这个构造函数尝试获得互斥体上的锁,并且阻塞直到获得锁。
  • unique_lock(mutex_type& m, defer_lock_t) noexcept;
    • 接收一个互斥体引用和一个std::defer_lock_t实例的构造函数。C++提供了一个预定义的defer_lock_t实例,名为std::defer_lockunique_lock存储互斥体的引用,但不立即尝试获得锁,锁可以稍后获得。
  • unique_lock(mutex_type& m, try_to_lock_t);
    • 接收一个互斥体引用和一个std::try_to_lock_t实例的构造函数。C++提供了一个预定义的try_to_lock_t实例,名为std::try_to_lock。这个锁尝试获得引用的互斥体上的锁,但即便未能获得也不阻塞;此时,会在稍后获取锁
  • unique_lock(mutex_type& m, adopt_lock_t);
    • 接收一个互斥体引用和一个std::adopt_lock_t实例的构造函数。这个锁假定调用线程已经获得引用的互斥体上的锁。锁管理互斥体,并在销毁锁时自动释放互斥体。
  • unique_lock(mutex_type& m, const chrono:time_point<Clock, Duration>& abs_time);
    • 接收一个互斥体引用和一个绝对时间的构造函数。这个构造函数试图获取一个锁,直到系统时间超过给定的绝对时间。
  • unique_lock(mutex_type& m, const chrono:duration<Rep, Period>& rel_time);
    • 接收一个互斥体引用和一个相对时间的构造函数。这个构造函数试图获得一个互斥体上的锁,直到到达给定的相对超时时间。

unique_lock类也有以下方法:lock()try_lock()try_lock_for()try_lock_until()unlock()

shared_lock

shared_lock类在<shared_mutex>中定义,它的构造函数和方法与unique_lock相同。区别是,shared_lock类在底层的共享互斥体上调用与共享拥有权相关的方法。因此,shared_lock的方法称为lock()try_lock()等,但在底层的共享互斥体上,它们称为lock_shared()try_lock_shared()等。所以,shared_lockunique_lock有相同的接口,可用作unique_lock的替代品,但获得的是共享锁,而不是独占锁。

一次性获得多个锁

C++有两个泛型锁函数,可用于同时获得多个互斥体对象上的锁,而不会出现死锁。这两个泛型锁函数都在std名称空间中定义,都是可变参数模板函数。第第一个函数lock()不按指定顺序锁定所有给定的互斥体对象,没有出现死锁的风险。如果其中一个互斥锁调用抛出异常,则在已经获得的所有锁上调用unlock()。原型如下:

1
template <class L1, class L2, class L3> void lock(L1&, L2&, L3&...);

try_lock()函数具有类似的原型,但它通过顺序调用每个给定互斥体对象的try_lock(),试图获得所有互斥体对象上的锁。如果所有try_lock()调用都成功,那么这个函数返回-1。如果任何try_lock()调用失败,那么对所有已经获得的锁调用unlock(),返回值是在其上调用try_lock()失败的互斥体的参数位置索引。

scoped_lock

std::scoped_lock<mutex>中定义,与lock_guard类似,只是接收数量可变的互斥体。这样,就可极方便地获取多个锁。例如,可以使用scoped_lock,编写包含process()函数的那个示例,如下所示:

1
2
3
4
5
mutex mut1;
mutex mut2;
void process() {
scoped_lock locks(mutl, mut2);
}

std::call_once

结合使用std:call_once()std::once_flag可确保某个函数或方法正好只调用一次,不论有多少个线程试图调用call_once()(在同一once_flag上)都同样如此。 只有一个call_once()调用能真正调用给定的函数或方法。如果给定的函数不抛出任何异常,则这个调用称为有效的call_once()调用。如果给定的函数抛出异常,异常将传回调用者,选择另一个调用者来执行此函数。某个特定的once_flag实例的有效调用在对同一个once_flag实例的其他所有call_once()调用之前完成。 在同一个once_flag实例上调用call_once()的其他线程都会阻塞,直到调用结束。

下例演示了call_once()的使用。这个例子运行使用某个共享资源的processingFunction(),启动了3个线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
once_flag gOnceFlag;
void initializeSharedResources {
cout <<"Shared resources initialized."<< endl;
}

void processingEunction () {
call_once (gOnceFlag, initializeSharedResources);
cout << "Processing" << endl;
}
int main () {
vector<thread> threads (3);
for (auto& t : threads)
t = thread{ processingFunction };

for (autos t : threads)
t.join();
}

这段代码的输出如下所示:

1
2
3
4
Shared resources initialized
Processing
Processing
Processing

互斥体对象的用法示例

以线程安全方式写入流

下面的例子同步Counter类中所有对cont的访问。为实现这种同步,向这个类中添加一个静态的mutex对象。这个对象应该是静态的,因为类的所有实例都应该使用同一个mutex实例。在写入cout之前,使用lock_guard获得这个mutex对象上的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Counter {
public:
Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations) { }
void operator () () const {
for (int i = 0; i < mNumIterations; ++i) {
lock_guard 1ock(sMutex);
cout << "Counter" << mid <<" has value "<< i << endl;
}
}
private:
int mid;
int mNumIterations;
static mutex sMutex;
};
mutex Counter::sMutex;

这段代码在for循环的每次迭代中创建了一个lock_guard实例。建议尽可能限制拥有锁的时间,否则阻塞其他线程的时间就会过长。例如,如果lock_guard实例在for循环之前创建一次,就基本上丢失了这段代码中的所有多线程特性,因为一个线程在其for循环的整个执行期间都拥有锁,所有其他线程都等待这个锁被释放。

使用定时锁

下面的示例演示如何使用定时的互斥体。结合unique_lock使用了timed_mutex。将200毫秒的相对时间传给unique_lock构造函数,试图在200毫秒内获得一个锁。如果不能在这个时间间隔内获得这个锁,构造函数返回。之后,可检查这个锁是否已经获得,对这个lock变量应用if语句就可执行这种检查,因为unique_lock类定义了bool转换运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Counter {
public:
Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations) { }
void operator () () const
for (int i = 0; i < mNumIterations; ++ i) {
unique_lock lock (sTimedMutex, 200ms);
if(lock)
cout << "Counter" << mId << " has value " << i << endl;
else
// Lock not acquired in 200ms, skip output
}
private:
int mId;
int mNumIterations;
static timed_mutex sTimedMutex;
}

timed_mutex Counter::sTimedMutex;

条件变量

条件变量允许一个线程阻塞,直到另一个线程设置某个条件或系统时间到达某个指定的时间。条件变量允许显式的线程间通信。需要包含<condition_variable>头文件来使用条件变量。有两类条件变量。

  • std::condition_variable:只能等待unique_lock<mutex>上的条件变量:根据C++标准的描述,这个条件变量可在特定平台上达到最高效率,
  • std::condition_variable_any:可等待任何对象的条件变量,包括自定义的锁类型。

condition_variable类支持以下方法。

  • notify_one();
    • 唤醒等待这个条件变量的线程之一。
  • notify_all();
    • 唤醒等待这个条件变量的所有线程。
  • wait(unique_lock<mutex>& lk);
    • 调用wait()的线程应该已经获得lk上的锁。调用wait()的效果是以原子方式调用lk.unlock()并阻塞线程,等待通知。当线程被另一个线程中的notify_one()notify_all()调用解除阻塞时,这个函数会再次调用lk.lock(),可能会被这个锁阻塞,然后返回。
  • wait_for(unique_lock<mutex>& lk, const chrono::duration<Rep, Period>& rel_time);
    • 类似于此前的wait()方法,区别在于这个线程会被notify_one()notify_all()调用解除阻塞,也可能在给定超时时间到达后解除阻塞。
  • wait_until(unique_lock<mutex>& lk, const chrono::time_point<Clock, Duration>& abs_time);
  • 类似于此前的wait()方法,区别在于这个线程会被notify_one()notify_all()调用解除阻塞,也可能在系统时间超过给定的绝对时间时解除阻塞

也有一些其他版本的wait()wait_for()wait_until()接收一个额外的谓词参数。例如,接收一个额外谓词的wait()等同于:

1
2
while (!predicate())
wait(lk);

condition_variable_any类支持的方法和condition_variable类相同,区别在于condition_variable_any可接收任何类型的锁类,而不只是unique_lock<mutex>。锁类应提供lock()unlock()方法。

假唤醒

等待条件变量的线程可在另一个线程调用notify_one()notify_all()时醒过来,或在系统时间超过给定时间时醒过来,也可能不合时宜地醒过来。这意味着,即使没有其他线程调用任何通知方法,线程也会醒过来。因此,当线程等待一个条件变量并醒过来时,就需要检查它是否因为获得通知而醒过来。一种检查方法是使用接收谓词参数的wait()版本。

使用条件变量

例如,条件变量可用于处理队列项的后台线程。可定义队列,在队列中插入要处理的项。后台线程等待队列中出现项。把一项插入到队列中时,线程就醒过来,处理项,然后继续休眠,等待下一项。假设有以下队列:

1
queue<string> mQueue;

需要确保在任何时候只有一个线程修改这个队列。可通过互斥体实现这一点:

1
mutex mutex;

为了能在添加一项时通知后台线程,需要一个条件变量:

1
condition_variable mCondVar;

需要向队列中添加项的线程首先要获得这个互斥体上的锁,然后向队列中添加项,最后通知后台线程。无论当前是否拥有锁,都可以调用notify_one()notify_all(),它们都会正常工作:

1
2
3
4
5
// Lock mutex and add entry to the queue.
unique_lock lock (mMutex);
mQueue.push (entry);
// Notify condition variable to wake up thread.
mCondVar.notify_all();

后台线程在一个无限循环中等待通知。注意这里使用接收谓词参数的wait()方法正确处理线程不合时宜地醒过来的情形。谓词检查队列中是否有队列项。对wait()的调用返回时,就可以肯定队列中有队列项了。

1
2
3
4
unique_lock lock(mMutex);
while (true) {
// Wait for a notification.
mCondVar.wait (lock, [this]{ return !mQueue.empty();});

C++标准还定义了辅助函数std::notify_all_at_thread_exit(cond, lk),其中cond是一个条件变量,lk是一个unique_lock<mutex>实例。调用这个函数的线程应该已经获得了锁lk。当线程退出时,会自动执行以下代码:

1
2
lk.unlock();
cond.notify_all();

注意将锁lk保持锁定,直到该线程退出为止。所以,一定要确保这不会在代码中造成任何死锁,例如由于错误的锁顺序而产生的死锁。

future

可使用future更方便地获得线程的结果,并将异常转移到另一个线程中,然后另一个线程可以任意处置这个异常。当然,应该总是尝试在线程本身中处理异常,不要让异常离开线程。

futurepromise中存储结果。可通过future来获取promise中存储的结果。也就是说,promise是结果的输入端;future是输出端。一旦在同一线程或另一线程中运行的函数计算出希望返回的值,就把这个值放在promise中。然后可以通过future来获取这个值。可将future/promise对想象为线程间传递结果的通信信道。

C++提供标准的future,名为std::future。可从std::future检索结果。T是计算结果的类型。

1
2
future<T> myFuture;
T result = myFuture.get();

调用get()以取出结果,并保存在变量result中。如果另一个线程尚未计算完结果,对get()的调用将阻塞,直到该结果值可用。只能在future上调用一次get()。按照标准,第二次调用的行为是不确定的。可首先通过向future询问结果是否可用的方式来避免阻塞:

1
2
3
4
if (myFuture.wait_for(0))
T result = myFuture.get();
else
// Value is not yet available

std::promise和std::future

C++提供了std::promise类,作为实现promise概念的一 种方式。可在promise上调用set_value()来存储结果,也可调用set_exception()在promise中存储异常。注意,只能在特定的promise上调用set_value()set_exception()一次。如果多次调用它,将抛出std::future_error异常。

如果线程A启动另一个线程B以执行计算,则线程A可创建一个std::promise将其传给已启动的线程。注意,无法复制promise,但可将其移到线程中。线程B使用promise存储结果。将promise移入线程B之前,线程A在创建的promise上调用get_future(),这样,线程B完成后就能访问结果。下面是一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void DoWork (promise<int> thePromise) {
thePromise.set_value(42);
}
int main () {
promise<int> myPromise;
// Get the future of the promise.
auto theFuture = myPromise.get_future();
// Create a thread and move the promise into it.
thread theThread{ DoWork, std::move(myPromise) };

// Get the result,
int result = theFuture.get();
cout << "Result: " << result << endl;
// Make sure to join the thread,
theThread.join();
}

std::packaged_task

有了std::packaged_task,将可以更方便地使用promise,下面的代码创建一个packaged_task来执行CalculateSum(),通过调用get_future(),从packaged_task检索future。启动一个线程,并将packaged_task移入其中。无法复制packaged_task!启动线程后,在检索到的future上调用get()来获得结果。在结果可用前,将一直阻塞。

CalculateSum()不需要在任何类型的promise中显式存储任何数据。packaged_task自动创建promise,自动在promise中存储被调用函数(这里是CalculateSum())的结果,并自动在promise中存储函数抛出的任何异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int CalculateSum(int a, int b) { return a+b; }
int main () {
// Create a packaged task to run CalculateSum.
packaged_task<int(int, int)> task (CalculateSum);
// Get the future for the result of the packaged task.
auto theFuture = task.get_future():
// Create a thread, move the packaged task into it, and
// execute the packaged task with the given arguments.
thread theThread{ std::move(task), 39, 3};
// Do some more work...

int result = theFuture.get();
cout << result << endl;
// Make sure to join the thread.
theThread.join();
}

std::async

如果想让C++运行时更多地控制是否创建-一个线程以进行某种计算,可使用std::async()。它接收一个将要执行的函数,并返回可用于检索结果的future。async()可通过两种方法运行函数:

  • 创建一个新的线程,异步运行提供的函数。
  • 在返回的future上调用get()方法时,在主调线程上同步地运行函数。

如果没有通过额外参数来调用async(),C++运行时会根据一些因素(例如系统中处理器的数目)从两种方法中自动选择一种方法。也可指定策略参数,从而调整C++运行时的行为。

  • launch::async:强制C++运行时在一个不同的线程上异步地执行函数。
  • launch::deferred:强制C++运行时在调用get()时,在主调线程上同步地执行函数。
  • launch::async|launch::deferred:允许C++运行时进行选择(一默认行为)。

下例演示了async()的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
int calculate () {
return 123;
}

int main () {
auto myFuture = async (calculate);
//auto myFuture = async (launch::async, calculate);
//auto myFuture = async(launch: :deferred, calculate);
// Do some more work..
// Get the result.
int result = myFuture.get();
cout << result << endl;
}

从这个例子可看出,std::async()是以异步方式(在不同线程中)或同步方式(在同一线程中)执行一些计算并在随后获取结果的最简单方法之一。

异常处理

使用future的一大优点是它们会自动在线程之间传递异常。在future上调用get()时,要么返回计算结果,要么重新抛出与future关联的promise中存储的任何异常。使用packaged_taskasync()时,从已启动的函数抛出的任何异常将自动存储在promise中。如果将std::promise用作promise, 可调用set_exception()以在其中存储异常。下面是一个使用async()的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int calculate() {
throw runtime_error("Exception thrown from calculate().");
}
int main() {
// Use the launch::async policy to force asynchronous execution.
auto myFuture = async (launch::async, calculate);

// Get the result.
try {
int result = myFuture.get();
cout << result << endl;
} catch (const exception& ex) {
cout << "Caught exception: " << ex.what() << endl;
}
}

std::shared_future

std::future<T>只要求T可移动构建。在future<T>上调用get()时,结果将移出future,并返回给你。这意味着只能在future<T>上调用get()一次。

如果要多次调用get(),甚至从多个线程多次调用,则需要使用std::shared_future<T>,此时,T需要可复制构建。可使用std::future::share(),或给shared_future构造函数传递future,以创建shared_future。注意,future不可复制,因此需要将其移入shared_future构造函数。

shared_future可用于同时唤醒多个线程。例如,下面的代码片段定义了两个lambda表达式,它们在不同的线程上异步地执行。每个lambda表达式首先将值设置为各自的promise,以指示已经启动。接着在signalFuture
调用get(),这一直阻塞,直到可通过future获得参数为止:此后将继续执行。每个lambda表达式按引用捕获各自的promise,按值捕获signalFuture,因此这两个lambda表达式都有signalFuture的副本。主线程使用async()在不同线程上执行这两个lambda表达式,一直等到线程启动,然后设置signalPromise中的参数以唤醒这两个线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
promise<void> thread1started, thread2Started;
promise<int> signalPromise;

auto signalFutrue = signalPromise.get_future().share();
//shared_future<int> signalFuture (signalPromise.get_future());

auto function1 = [&thread1started, signalFuture] {
threadistarted.set_value();
// Wait until parameter is set
int parameter = signalFuture.get();
};

auto function2 = [&thread2Started, signalFuture] {
thread2started.set_value();
// Wait until parameter is set.
int parameter = signalFuture.get();
};

// Run both lambda expressions asynchronously.
// Remember to capture the future returned by async()!
auto result1 = async (launch::async, function1);
auto result2 = async (launch::async, function2);

// Wait until both threads have started.
thread1started.get_future().wait();
thread2started.get_future().wait();
// Both threads are now waiting for the parameter.
// Set the parameter to wake up both of them.
signalPromise.set_value(42);

线程池

如果不在程序的整个生命周期中动态地创建和删除线程,还可以创建可根据需要使用的线程池。这种技术通常用于需要在线程中处理某类事件的程序。

由于线程池中的所有线程都是预先存在的,因此操作系统调度这些线程并运行的效率大大高于操作系统创建线程并响应输入的效率。此外,线程池的使用允许管理创建的线程数,因此根据平台的不同,可以少至1个线程,也可以多达数千个线程有几个库实现了线程池。

线程设计和最佳实践

本节简要介绍几个有关多线程编程的最佳实践

  • 使用并行标准库算法:标准库中包含大量算法。从C++17开始,有60多个算法支持并行执行。尽量使用这些并行算法,而非编写自己的多线程代码。
  • 终止应用程序前,确保所有thread对象都不是可结合的:确保对所有thread对象都调用了join()detach()。仍可结合的thread析构函数将调用std::terminate(),从而突然间终止所有线程和应用程序。最好的同步就是没有同步:如果采用合理的方式设计不同的线程,让所有的线程在使用共享数据时只从共享数据读取,而不写入共享数据,或者只写入其他线程不会读取的部分,那么多线程编程就会变得简单很多。
  • 尝试使用单线程的所有权模式:这意味着同一时间拥有1个数据块的线程数不多于1。拥有数据意味着不允许其他任何线程读写这些数据。当线程处理完数据时,数据可传递到另一个线程,那个线程目前拥有这些数据的唯一且完整的责任/拥有权。这种情况下,没必要进行同步。
  • 在可能时使用原子类型和操作:通过原子类型和原子操作更容易编写没有争用条件和死锁的代码,因为它们能自动处理同步。如果在多线程设计中不可能使用原子类型和操作,而且需要共享数据,那么需要使用同步机制(如互斥)来确保同步的正确性。
  • 使用锁保护可变的共享数据:如果需要多个线程可写入的可变共享数据,而且不能使用原子类型和操作,那么必须使用锁机制,以确保不同线程之间的读写是同步的。
  • 尽快释放锁:当需要通过锁保护共享数据时,务必尽快释放锁。当一个线程持有一个锁时,会使得其他线程阻塞等待这个锁,这可能会降低性能
  • 不要手动获取多个锁:应当改用std::lock()std::try_lock()。如果多个线程需要获取多个锁,那么所有线程都要以同样的顺序获得这些锁,以防止死锁。可通过泛型函数std::lock()std::try_lock()获取多个锁。
  • 使用RAII锁对象:使用lock_guardunique_lockshared_lockscoped_lockRAII类,在正确的时间自动释放锁
  • 使用支持多线程的分析器:通过支持多线程的分析器找到多线程应用程序中的性能瓶颈,分析多个线程是否确实利用了系统中所有可用的处理能力。
    • 使用线程池,而不是动态创建和销毁大量线程:动态地创建和销毁大量的线程会导致性能下降。这种情况下,最好使用线程池来重用现有的线程。
  • 使用高级多线程库:尽可能使用高级多线程库,例如Intel Threading Building Blocks(TBB). Microsoft Parallel Pattems Library(PPL)等,而不是自己实现。

充分利用软件工程方法

编写高效的C++程序

语言层次的效率

使用某些语言级别的优化(如按引用传递)是良好的编码风格。

高效地操纵对象

C++在幕后做了很多工作,特别是和对象相关的工作。总是应该注意编写的代码对性能的影响。

通过引用传递

应该尽可能不要通过值向函数或方法传递对象。如果函数形参的类型是基类,而将派生类的对象作为实参按值传递,则会将派生对象切片,以符合基类类型。这导致信息丢失,而按引用传递能避免这种开销。这条规则很难记住的一个原因是:从表面上看,按值传递不会有任何问题。

如果函数必须修改对象,可通过引用传递对象。如果函数不应该修改对象,可通过const引用传递。

应避免通过指针传递,因为相对按引用传递,按指针传递相对过时,相当于倒退到C语言了,很少适合于C++。

按引用返回

正如应该通过引用将对象传递给函数一样,也应该从函数返回引用,以避免对象发生不必要的复制。但有时不可能通过引用返回对象,例如编写重载的operator+和其他类似运算符时。永远都不要返回指向局部对象的引用或指针,局部对象会在函数退出时被销毁。

自C++11以后,C++语言支持移动语义,允许高效地按值返回对象,而不是使用引用语义。

通过引用捕捉异常

应该通过引用捕捉异常,以避免分片和额外的复制。抛出异常的性能开销很大,因此任何提升效率的小事情都是有帮助的。

使用移动语义

应该为类实现移动构造函数和移动赋值运算符,以允许C++编译器为类对象使用移动语义。根据“零规则”,设计类时,使编译器生成复制和移动构造函数以及复制和移动赋值运算符便足够了。如果编译器不能隐式定义这些类,那么在允许的情况下,可显式将它们设置为default。如果这行不通,应当自行实现。

对象使用了移动语义时,从函数中通过值返回不会产生很大的复制开销,因而效率更高。

避免创建临时对象

有些情况下,编译器会创建临时的无名对象。为一个类编写全局operator+之后,可对这个类的对象和其他类型的对象进行加法运算,只要其他类型的对象可转换为这个类的对象即可。

返回值优化

通过值返回对象的函数可能导致创建一个临时对象。

预分配内存

使用C++标准库容器的一个重要好处是:它们自动处理内存管理。给容器添加元素时,容器会自动扩展。但有时,这会带来性能问题。例如,std::vector容器在内存中连续存储元素。如果需要扩展,则需要分配新的内存块,然后将所有元素移动(或复制)到新的内存中。

如果预先知道要在vector中添加的元素数量,或大致能够评估出来,就可以在开始添加元素前预分配足够的内存。vector具有容量(capacity)和大小(size),容量指不需要重新分配的情况下可添加的元素数量,大小指容器中的实际元素数量。可以预分配内存,使用reserve()更改容量,使用resize()重新设置vector的大小。

使用内联方法和函数

内联(inline)方法或函数的代码可以直接插到被调用的地方,从而避免函数调用的开销。

一方面,应将所有符合这种优化条件的函数和方法标记为inline。但不要过度使用该功能,因为它实际上背离了基本设计原则;基本设计原则是将接口与实现分离,这样一来,不需要更改接口即可完善实现。仅考虑为常用的基本类使用该功能。另外记住,程序员的内联请求只是给编译器提供的建议,编译器有权拒绝这些建议。

另一方面,编译器会在优化过程中内联一些适当的函数和方法,即使这些函数没有用inline关键字标记,甚至即使这些函数在源文件(而非头文件)中实现也是如此。

设计层次的效率

尽可能多地缓存

下面是通常执行缓慢的任务清单。

  • 磁盘访问:在程序中应避免多次打开和读取同一个文件。如果内存可用,并且需要频繁访问这个文件,那么应将文件内容保存在内存中,
  • 网络通信:如果需要经由网络通信,那么程序会受网络负载的影响而行为不定。将网络访问当成文件访问处理,尽可能多地缓存静态信息。
  • 数学计算:如果需要在多个地方使用非常复杂的计算结果,那么执行这种计算一次并共享结果。但是,
  • 如果计算不是非常复杂,那么计算可能比从缓存中提取更快。如果需要确定这种情形,可使用分析器。
  • 对象分配:如果程序需要大量创建和使用短期对象,可以考虑使用本章后面讨论的对象池。
  • 线程创建:这个任务也很慢。可将线程“缓存”在线程池中,类似于在对象池中缓存对象。

常见的缓存问题是:保存的数据往往是底层信息的副本。在缓存的生命周期中,原始数据可能发生变化。

缓存失效的技术之一是要求管理底层数据的实体通知“程序数据发生了变化”。可通过程序在管理器中注册回调的方式实现这一点。另外,程序还可轮询某些会触发自动重新填充缓存的事件。

使用对象池

存在不同类型的对象池。一种对象池是一次分配一大块内存,此时,对象池就地创建多个较小对象。可将这些对象分发给客户,在客户完成时重用它们,这样就不必另外调用内存管理器为各个对象分配内存或解除内存分配

本节描述另一类对象池。如果程序需要大量同类型的短期对象,这些对象的构造函数开销很大,分析器确认这些对象的内存分配和释放是性能瓶颈,就可为这些对象创建对象池或缓存。每当代码中需要一个对象时,可从对象池中请求一个。当用完对象时,将这个对象返回对象池中。对象池只创建一次对象,因此对象的构造函数只调用一次,而不是每次需要使用时都调用。

因此,对象池适用于构造函数需要为很多对象进行一些设置操作的情况,也适用于通过构造函数之外的方法调用为对象设置一些实例特有的参数。

对象池的实现

对象池实现中最困难的部分是跟踪哪些对象是空闲的,哪些对象正在使用。这个实现采取的方法是将空闲对象保存在一个队列中。每次客户端请求对象时,对象池从队列前端取出一个对象给客户端。

代码使用std::queue类。实现并非是线程安全的。要达到线程安全的目的,一种方式是使用无锁并发队列。但标准库并不提供任何并发数据结构,因此必须使用第三方库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
class ObjectPool {
public:
ObjectPool() = default;
virtual ~ObjectPool() = default;
// Prevent assignment and pass-by-value
ObjectPool(const ObjectPool<T>& src) = delete;
ObjectPool<T>& operator=(const ObjectPool<T>& rhs) = delete;
// The type of smart pointer returned by acquireObject().
using Object = std::shared_ptr<T>;
// Reserves and returns an object for use.
Object acquireObject();
private:
// stores the objects that are not currently in use by clients.
std::queue<std::unique_ptr<T>> mFreeList;
};

使用这个对象池时,必须确保对象池自身的寿命超出对象池给出的所有对象的寿命。对象池的用户通过模板参数,指定用于创建对象的类的名称。

acquireObject()从空闲列表返回顶部对象,如果没有空闲对象,则首先分配新对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
typename ObjectPool<T>::Object ObjectPool<T>::acquireObject() {
if (mFreeList.empty())
mFreeList.emplace (std::make_unique<T>());

std::unique_ptr<T> obj(std::move(mFreeList.front()));
mFreeList.pop();

Object smartobject(obj.release(), [this](T* t){
mFreelist.emplace(t);
});

return smartobject;
}

使用对象池

假设一个应用程序使用了大量短期对象,这些短期对象具有昂贵的构造函数。假设有如下ExpensiveObject类定义:

1
2
3
4
5
6
7
8
class ExpensiveObject {
public:
Expensiveobject() { /*Expensive construction */ }
virtual ~ExpensiveObject() = default;

private:
// Data members (not shown)
};

不是在程序的生命周期中创建和删除大量此类对象,而是可以使用前面开发的对象池。程序结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ObjectPool<ExpensiveObject>::Object getExpensiveObject(ObjectPool<ExpensiveObject>& pool) {
// Obtain an ExpensiveObject object from the pool.
auto object = pool.acquireObject();
// Populate the object. (not shown)
return object;
}

void processExpensiveObject(ObjectPool <Expensiveobject>::Object& object) {
// Process the object. (not shown)
}

int main() {
ObjectPool<ExpensiveObject> requestPool;
{
vector<ObjectPool<ExpensiveObject>::Object> objects;
for(size_t i = 0; i < 10; ++ i)
objects.push_back (getExpensiveobject (requestPool));
}
for (size_t i = 0; i < 100; ++ i) {
auto reg = getExpensiveObject (requestPool);
processExpensiveObject (req);
}
return 0;
}

main()函数的第一部分包含一个内部代码块,它创建了10个ExpensiveObject对象,并将它们存储在Object容器中。由于创建的所有Object对象都存储在vector中并持续存在,对象池将不得不创建10个ExpensiveObject实例。在这个内部代码块的闭括号处,vector超出作用域,其中的所有Object对象会自动释放,回到对象池中。

在第二个for循环中,getExpensiveObject()返回的Object对象(=shared_ptrs)在for循环每个迭代的结束处超出作用域,因此自动释放,回到对象池中。如果给ExpensiveObject类的构造函数添加一条输出语句,你将看到,在整个程序运行期间,只对构造函数调用10次,即使main()函数中第二个for循环的循环次数达到数百次也同样如此。

熟练掌握调试技术

调试的基本定律

断言

<cassert>头文件定义了assert宏。它接收一个布尔表达式,如果表达式求值为false,则打印出一条错误消息并终止程序。如果表达式求值为true,则什么也不做。

断言可迫使程序在bug来源的确切点公开bug。如果没有在这一点设置断言,那么程序可能会带着错误的值继续执行,因而bug可能在后面才显现出来。因此,断言允许尽早检测到bug。

标准assert宏的行为取决于NDEBUG预处理符号:如果没有定义该符号,则发生断言,否则忽略断言。编译器通常在编译发布版本时定义这个符号。如果要在发布版本中保留断言,就必须改变编译器的设置,或者编写自己的不受NDEBUG值影响的断言。

可在代码中任何需要“假设”变量处于某些状态的地方使用断言。例如,如果调用的库函数应该返回一个指针,并且声称绝对不会返回nullptr,那么在函数调用之后抛出断言,以确保指针不是nullptr。

注意,假设应该尽可能少。例如,如果正在编写一个库函数,不要断言参数的合法性。相反,要对参数进行检查,如果参数非法,返回错误代码或抛出异常作为规则,断言应只用于真正有问题的情形,因此在开发过程中遇到的断言绝不应忽略。如果在开发过程中遇到一个断言,应修复而不是禁用它。

静态断言

static_assert允许在编译时对断言求值。static_assert调用按收两个参数:编译时求值的表达式和字符串。当表达式计算为false时,编译器将给出包含指定字符串的错误提示。下例核实是否在使用64位编译器进行编译:

1
static_assert(sizeof(void*) == 8, "Requires 64-bit compilation.");

如果编译时使用32位编译器,指针是4个字符,编译器将给出错误提示,如下所示:

1
test.cpp(3): error C2338: Requires 64-bit compilation.

从C++17开始,字符串参数变为可选的,如下所示:

1
static_assert (sizeof(void*)==8);

此时,如果表达式的计算结果是false,将得到与编译器相关的错误消息。

另一个展示static_assert强大功能的例子是和类型trait结合使用。例如,如果编写一个函数模板或类模板,那么可结合使用static_assert和类型trait,当模板类型不符合一定条件时,生成编译器错误。下例要求process()的模板类型将Base1作为基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base1 {};
class Base1child : public Base1 {};
class Base2 {};
class Base2Child : public Base2 {};

template<typename T>
void process (const T& t) {
static_assert(is_base_of_v<Base1, T>, "Basel should be a base for T.");
}

int main() {
process(Base1());
process (Base1Child());
// process(Base2());
// Exror
// process(Base2Child());
// Error
}

调试技术

调试可重复的bug

可一致高效地重现bug时,应开始在代码中找到导致bug的根源。此时的目标是找到触发这个问题的准确代码行。可采用两种不同的策略

  • 记录调试消息:在程序中添加足够的调试消息并观察bug重现时的输出
  • 使用调试器:通过调试器可单步跟踪程序的执行,定点观察内存状态和变量的值。

调试不可重现的bug

  • 尝试将不可重现的bug转换为可重现的bug。
  • 分析错误日志。如果程序根据前面的描述带有生成错误日志的功能,那么这一点很容易实现。
  • 获取和分析跟踪。如果程序带有跟踪输出(例如之前描述的环形缓冲区),那么这一点很容易实现。
  • 如果有的话,检查崩溃/内存文件。在UNIX和Linux上,这些内存转储文件称为核心文件(core file)。每个平台都提供了分析这些内存转储文件的工具。例如,这些工具可用来生成应用程序的堆栈跟踪信息,或查看应用程序崩溃之前内存中的内容。
  • 检查代码。遗憾的是,这往往是检查不可重现bug的根源的唯一策略。
  • 使用内存观察工具。

调试内存问题

内存错误的分类

下面介绍内存错误的分类。

表总结了5种涉及释放内存的主要错误。

可以看出,有些内存释放错误不会立即导致程序终止。这些bug更微妙,会导致程序在运行一段时间之后出错。

内存访问错误另一类的内存错误涉及实际的内存读写,如表所示。

调试内存错误的技巧

  • 验证带有动态分配内存的类具有以下这种析构函数:能准确地释放对象中分配的内存,不多也不少。
  • 确保类能够通过复制构造函数和赋值运算符:正确处理复制和赋值。确保移动构造函数和移动赋值运算符把源对象中的指针,正确设置为nullptr,这样其析构函数才不会释放该内存。
  • 检查可疑的类型转换。如果将对象的指针从一种类型转换为另一种类型,确保转换是合法的。在可能的情况下,使用dynamic_cast。

  • 确保每个new调用都匹配一个delete调用。同样,每个对malloc、alloc和calloc的调用都要匹配一个对free的调用。每个new[]调用也要匹配一个delete[]调用。为避免多次释放内存或使用已释放的内存,建议释放内存后将指针设置为nullptr。

  • 检查缓冲区溢出。每次迭代访问数组或读写C风格的字符串时,验证没有越过数组或字符串的结尾访问内存。
  • 检查无效指针的解除引用。
  • 在堆栈上声明指针时,确保总是在声明中初始化指针。例如,使用T* p = nullptrT* p = new T,但是绝不要使用T* p
  • 同样,确保总在类的初始化器或构造函数中初始化指针数据成员,既可以在构造函数中分配内存,也可将指针设置为nullptr。

651 673

使用设计技术和框架

容易忘记的语法

编写类

不要忘了开头部分。下面是一个简单的类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#pragma once

class Simple {
public:
Simple();
virtual ~Simple() = default;

Simple(const Simple& src) = delete;
Simple& operator=(const Simple& rhs) = delete;

Simple (Simple&& src) = default;
Simple& operator=(Simple&& rhs) = default;

virtual void publicMethod();
int mPublicinteger;
protected:
virtual void protectedMethod();
int mProtectedinteger = 41;
private:
virtual void privateMethod();
int mPrivateInteger = 42;
static const int kconstant = 2;
static int sStaticInt;
};

通常,至少要将析构函数设置为virtual,因为其他人可能想从这个类派生新类。也允许保留析构函数为非virtual,但这只限于将类标记为final,以防止其他类从其派生的情况。如果只想将析构函数设置为virtual,但不需要析构函数中的任何代码,则可显式地设置为default,如Simple类示例所示。

这个示例也说明,可显式地将特殊成员函数设置为delete或default。将复制构造函数和复制赋值运算符设置为delete,以防止赋值和按值传递,而将移动构造函数和移动赋值运算符显式设置为default。

派生类

要从现有的类派生,可声明一个新类,这个类是另一个类的扩展类。下面是DerivedSimple类的定义,DerivedSimple从Simple派生而来:

1
2
3
4
5
6
7
8
#pragma once

class Derivedsimple : public Simple {
public:
DerivedSimple();
virtual void publicMethod() override; // Overridden method
virtual void anotherMethod();
};

使用“复制和交换”惯用语法

只需要创建对象的一个副本,修改这个副本(可以是复杂算法,可能抛出异常)。最后,当不再抛出异常时,将这个副本与原始对象进行交换。赋值运算符是一个可使用“复制和交换”惯用语法的操作示例。赋值运算符首先制作原始对象的一个本地副本,此后仅使用不抛出异常的swap()实现,将这个副本与当前对象进行交换。

始终存在更好的方法

RAII

RAII(Resource Acquisition Is Initialization,资源获得即初始化)是一个简单却十分强大的概念。它用于在RAII实例离开作用域时自动释放已获取的资源。这是在确定的时间点发生的。基本上,新RAII实例的构造函数获取特定资源的所有权,并使用资源初始化实例,因此得名RAII。在销毁RAII实例时,析构函数自动释放所获取的资源。下面的RAII类File安全地包装C风格的文件句柄(std::FILE),并在RAII实例离开作用域时自动关闭文件。RAII类也提供get()release()reset()方法,这些方法的行为类似于标准库类(如std::unique_ptr)中的同名方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <cstdio>
class File final {
public:
File(std::FILE* file);
~File();

File(const File& src) = delete;
File& operator=(const File& rhs) = delete;

File(File&& src) noexcept = default;
File& operator=(File&& rhs) noexcept = default;

std::FILE* get() const noexcept;
std::FILE* release() noexcept;
void reset(std::FILE* file = nullptr) noexcept;
private:
std::FILE* mFile;
};

File::File(std::FILE* file): mFile(file) { }
File::~File() {
reset();
}
std::FILE* File::get() const noexcept {
return mFile;
}
std::FILE* File::release() noexcept {
std::FILE* file = mFile;
mFile = nullptr;
return file;
}
void File::reset (std::FILE* file) noexcept {
if (mFile)
fclose(mFile);
mFile = file;
}

用法如下:

1
File myFile(fopen("input.txt", "z"));

myFile实例一旦离开作用域,就会调用它的析构函数,并自动关闭文件。

双分派

双分派(double dispatch)技术用于给多态性概念添加附加维度。C++没有提供相应的语言机制,以根据多个对象的运行时类型选择行为。虚方法本身不足以建立这种场景的模型,它们仅根据接收对象的运行时类型来确定方法或行为。

有些面向对象语言允许基于两个或多个对象的运行时类型,在运行时选择方法,它们将该功能称为多方法(multi-methods)。而在C++中,并没有支持多方法的核心语言功能,但可以使用双分派技术,从而创建针对多个对象的虚函数。

注意双分派实际上是多分派的特例。所谓多分派,是指根据两个或多个对象的运行时类型来选择行为。在实践中,双分派可根据两个对象的运行时类型选择行为,这通常就能满足需要。

首先重点分析单个派生类,可能是Bear类。该类需要一个具有以下声明的方法:

1
virtual bool eats (const Animal& prey) const override;

双分派的关键在于基于参数上的方法调用来确定结果。假设Animal类有一个eatenBy()方法,该方法将Animal引用作为参数。如果当前Animal会被传入的动物捕食,该方法返回true。有了这个方法,eats()方法的定义变得十分简单:

1
2
3
bool Bear::eats(const Animal& prey) const {
return prey.eatenBy(*this);
}

初看起来,这个解决方案给单多态方法添加了另一个方法调用层。毕竟,每个派生类都必须为每个Animal派生类实现eatenBy()版本。但有一个重要区别:多态发生了两次!当调用eats()方法时,多态性确定,是调用Bear::eats()Fish::eats()还是其他。当调用eatenBy()方法时,多态性再次确定要调用哪个类的方法版本,调用prey对象的运行时类型的eatenBy()。注意,*this的运行时类型始终与编译时类型相同,这样,编译器可为实参(这里是Bear)调用eatenBy()的正确重载版本。

下面是使用双分派的Animal层次结构的类定义。注意forward declarations是必需的,因为基类使用派生类的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Fish;
class Bear;
class Dinosaur;

class Animal {
public:
virtual bool eats (const Animal& prey)const = 0;
virtual bool eatenBy (const Bear&) const = 0;
virtual bool eatenBy (const Fish&) const = 0;
virtual bool eatenBy (const Dinosaur&) const = 0;
};
class Bear : public Animal {
public:
virtual bool eats(const Animal& prey) const override;
virtual bool eatenBy(const Bear&) const override;
virtual bool eatenBy(const Fish&) const override;
virtual bool eatenBy(const Dinosaur&) const override;
};

实现代码如下所示。注意,Animal的派生类以相同的方式实现eats()方法,但不能向上延伸到基类;如果尝试这么做,编译器不知道要调用eatenBy()方法的哪个重载版本,因为*this是Animal而非特定的派生类。根据对象的编译时类型(而非运行时类型)来确定方法重载方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool Bear::eats(const Animal& prey) const { return prey.eatenBy(*this); }
bool Bear::eatenBy(const Bear&) const { return false; }
bool Bear::eatenBy(const Fish&) const { return false; }
bool Bear::eatenBy(const Dinosaur&) const { return true; }

bool Fish::eats(const Animal& prey) const { return prey.eatenBy(*this): }
bool Fish::eatenBy(const Bear&) const { return true; }
bool Fish::eatenBy(const Fish&) const { return true; }
bool Fish::eatenBy(const Dinosaur&) const { return true; }

bool Dinosaur::eats(const Animal& prey) const { return prey.eatenBy(*this); }
bool Dinosaur::eatenBy(const Bear&) const { return false; }
bool Dinosaur::eatenBy (const Fish&) const { return false; }
bool Dinosaur::eatenBy(const Dinosaur&) const { return true; }

开发跨平台和跨语言应用程序

跨平台开发

整数大小

C++标准并未定义整数类型的准确大小。C++标准仅指出:有5种标准的有符号整数类型:signed charshort intintlong intlong long int。在这个列表中,后一种类型占用的存储空间大于或等于前一种类型。

二进制兼容性

为支持不具备二进制兼容性的平台,一种解决方案是在每个目标平台上使用编译器分别构建每个版本。另一种解决方案是交叉编译(cross-compiling)。 如果为开发使用平台X,但想使程序运行在平台Y和Z上,可在平台X上使用交叉编译器,为平台Y和Z生成二进制代码。

地址大小

当提到架构是32位时,通常是说地址大小是32位或4字节。通常而言,具有更大地址大小的系统可处理更多内存,在复杂系统上的运行速度更快,由于指针是内存地址,自然与地址大小密切相关。许多程序员都认为,指针的大小始终是4字节,但这是错误的。需要意识到,大多数大小都不是C+标准预先确定的。C++标准只是说,short integer占用的空间小于或等于integerinteger占用的空间小于或等于long integer

指针的大小未必与整数大小相同。例如,在64位平台上,指针是64位,但整数可能是32位。将64位指针强制转换为32位整数时,将丢失32个关键位!C++标准在<cstdin>中定义了std::intptr_t整数类型,它的大小至少足以存储一个指针。根据C++标准,这种类型的定义是可选的,但几乎所有编译器都支持它。

字节顺序

现在将数字分为“内存大小”部分。剩余的唯一问题是如何将它们存储在内存中。需要两个字节,但字节序是不确定的,事实上取决于相关系统的架构,一种表示数字的方式是将高位字节首先放入内存,接着将低位字节放入内存。这种策略被称为大端序,因为数字的较大部分首先放入。另一种是按相反顺序放置字节,首先将低序字节放入内存。这种策略被称为小端序,因为数字的较小部分首先放置。

跨语言开发

链接C代码

在C++程序中开始使用已经编译的C代码之前,首先需要了解“名称改编”这个概念。为实现函数重载,会对复杂的C++名称平台进行“扁平化”。例如,如果有一个C++程序,则编写以下代码是合法的:

1
2
3
void MyFunc(double);
void MyFunc(int);
void MyFunc(int, int);

这意味着链接器将看到MyFunc,但是不知道该调用哪种版本的MyFunc函数。因此,所有C++编译器执行名称改编操作,以生成合理的名称,如下所示:

1
2
3
MyFunc_double
MyFunc_int
MyFunc_int_int

为避免与定义的其他名称发生冲突,生成的名称通常使用一些字符。对于链接器而言,这些字符是合法的;对于C++源代码而言,这些字符是非法的。例如,Microsoft VC++生成如下名称:

1
2
3
?MyFunc8@YAXN@Z
?MyFunc8@YAXH@Z
?MyFunc@@YAXHH@Z

C语言不支持函数重载(编译器将报错,指出是重复定义)。因此,C编译器生成的名称十分简单,例如_MyFunc。因此,如果用C++编译器编译一个简单的程序,即便仅有一个MyFunc名称实例,也仍会生成一个请求,要求链接到改编后的名称。但是当链接C库时,找不到所需的已改编名称,因此链接器将报错。因此,有必要告知C++编译器不要改编相应的名称。为此,需要在头文件中使用extern"language"限定,以告知客户端代码创建与指定语言兼容的名称;如果库源是C++,还需要在定义站点使用这个限定,以告知库代码生成与指定语言兼容的名称。

extern"language"的语法如下:

1
2
extern "language" declaration1();
extern "language" declaration2();

也可能如下:

1
2
3
4
extern "language" {
declaration1();
declaration2();
}

C++标准指出,可使用任何语言规范;因此,从原理上讲,编译器可支持以下代码:

1
2
3
4
extern "C" MyFunc(int i);
extern "Fortran" MatrixInvert (Matrix* M);
extern "Pascal" SomeLegacySubroutine(int n);
extern "Ada" AimMissileDefense (double angle);

但实际上,许多编译器只支持C。每个编译器供应商都会告知你所支持的语言指示符。

例如,在以下代码中,将doCFunction()的函数原型指定为外部C函数:

1
2
3
4
5
6
7
8
extern "C" {
void docFunction(int i);
}

int main() {
doCFunction(8); // Calls the C function.
return 0;
}

在链接阶段,在附加的已编译二进制文件中提供doCFunction()的实际定义。 extern关键字告知编译器:链接的代码是用C编译的。使用extern的更常见模式是在头文件级别。可以编写另一个头文件,将原始文件打包到extern块中,以指定定义函数的整个头文件是用C编写的。

另一个常见模型是编写单个头文件,然后根据条件针对C或C++对其进行编译。如果为C++编译,C++编译器将预定义__cplusplus符号。该符号不是为C编译定义的。因此,可以经常看到以下形式的头文件:

1
2
3
4
5
6
7
8
#ifdef __cplusplus
extern"C" {
#endif
declaration1();
declaration2();
#ifdef __cplusplus
}
#endif

附录C

标准库头文件C++标准库的接口包含87个头文件,其中有26个表示C标准库。要记住源代码中应该包含哪些头文件往往很难,所以这个附录简要描述每个头文件的内容,按照以下8类组织:

  • C标准库
  • 容器算法、迭代器和分配器
  • 通用工具
  • 数学工具
  • 异常
  • I/O流
  • 线程支持库

C标准库

C++标准库包含完整的C标准库。头文件通常是一样的,除了以下两点:

  • 头文件为<cname>而不是<name.h>
  • <cname>头文件中声明的所有名称都在std名称空间中

为了后向兼容,如有必要,仍可包含<name.h>。然而,这样会把名字放在全局名称空间而不是std名称空间中。另外,<name.h>已不赞成使用。建议避免这种用法。

表C-1总结了最常用功能。注意建议避免使用C功能,而尽量使用等价的C++功能。

头文件名 内容
<cassert> assert()
<ccomplex> 只包括<complex>。从C++17开始,已不赞成使用
<cctype> 字符谓词和操作函数,例如isspace()tolower()
<cerrno> 定义errno表达式,它是一个宏,获得某些C函数的最后一个错误编号
<cfenv> 支持浮点数环境,例如浮点异常、浮点数取整等
<cfloat> 和浮点数算术相关的C风格定义,例如FLT_MAX
<cinttypes> 定义与printf()scanf()和类似函数结合使用的一些宏,还定义一些操作intmax_t的函数
<ciso646> 在C语言中,<iso646.h>文件定义宏and和or等。在C++中,这些都是关键字,所以这个头文件为空
<climits> C风格的限制定义,例如INT_MAX。建议改用C++中对应的<limits>
<clocale> 用于本地化的宏和函数,例如LC_ALLsetlocale()。见C++中对应的<locale>
<cmath> 数学工具,包括三角函数、sqrt()fabs()
<csetjmp> setjmp()和longjmp()`,绝不要在C++中使用
<csignal> signal()raise(),避免在C++中使用
<cstdalign> 和对齐相关的宏__alignas_is_defined,从C++17开始已不赞成使用
<cstdarg> 处理变长参数列表的宏和类型
<cstdbool> 与布尔类型相关的宏__bool_true_false_are_defined,从C++17开始已不赞成使用
<cstddef> 重要的常量,例如NULL,以及重要的类型,例如size_t
<cstdint> 定义一些标准的整数类型,例如int8_t和int64_t等,还包含表示这些类型的最大值和最小值的宏
<cstdio> 文件操作,包括fopen()fclose()。格式化I/O:printf()scanf()等系列函数。字符I/O:getc()putc()等系列函数。文件定位:fseck()ftell()。建议改用C++流
<cstdlib> 随机数操作:rand()srand(),从C++14开始已不建议使用,而改用C++<random>。这个头文件包含abort()exit()函数,应该避免使用这两个函数。C风格的内存分配函数:calloc()malloc()realloc()free()。C风格的排序和搜索函数;qsort()bscarch(),字符串到数值的转换函数;atof()atoi()等。一组与多字节/宽字符串处理相关的函数
<cstring> 底层内存管理函数,包括memcpy()memset()。C风格的字符串函数,例如strcpy()strcmp()
<ctgmath> 只包含<ccomplex><cmath>,从C++17开始已不赞成使用
<ctime> 时间相关的函数,包括time()localtime()
<cuchar> 定义一些与Unicode相关的宏和函数例如mbrtoc16()
<cwchar> 宽字符版本的字符串、内存和I/O函数
<cwctype> <cctype>中函数的宽字符版本:iswspace()towlower()

容器

可在以下12个头文件中找到标准库容器的定义,如表C-2所示。

头文件名 内容
<array> array类模板
<bitset> bitset类模板
<deque> deque类模板
<forward_list> forward list类模板
<list> list类模板
<map> map和multimap类模板
<queue> queue和priority_queue类模板
<set> set和multisct类模板
<stack> stack类模板
<unordered_map> unordered_map和unordered_multimap类模板
<unordered_set> unordered_set和unordered multiset类模板
<vector> vector类模板和vector<bool>特例化

每个头文件都包含使用特定容器需要的所有定义,包括迭代器。

算法、迭代器和分配器

表中的不同头文件定义可用的标准库算法、迭代器和分配器。

头文件名 内容
<algorithm> 标准库中大部分算法的原型
<execution> 定义与标准库算法起使用的执行策略类型
<functional> 定义内建函数对象、取反器、绑定器和适配器
<iterator> 定义iterator_trait、迭代器标签、iterator、reverse_iterator、插入迭代器(例如back_insert_iterator)和流迭代器
<memory> 定义默认分配器、一些处理容器内未初始化内存的工具函数
<memory_resource> 定义多态分配器和内存资源
<numeric> 一些数值算法的原型,比如accumulate()inner_product()partial_sum()adjacent_difference()
<scoped_allocator> 可用于内嵌容器的分配器,例如字符串的vector、map的vector

通用工具

标准库在一些不同的头文件中包含一些通用的工具函数

头文件名 内容
<any> 定义any类
<charconv> 定义chars_format枚举类、from_chars()函数、to_chars()函数和相关结构
<chrono> 定义chrono库
<codecvt> 为不同字符编码提供代码转换的facet。从C++17开始,已经不赞成使用这个头文件
<filesystem> 定义用于处理文件系统的可用类和函数
<initializer_list> 定义initializer_list类
<limits> 定义numeric_limits类模板,以及大部分内建类型的特例化
<locale> 定义locale类、use_facet()has_facet()模板函数以及facet系列函数
<new> 定义bad_alloc异常和set_new_handler()函数,以及operator newoperator delete的所有6种原型
<optional> 定义optional类
<random> 定义随机数生成器库
<ratio> 定义Ratio库,以操作编译时有理数
<regex> 定义正则表达式库
<string> 定义basic_string类模板以及string和wstring的类型别名实例
<string_view> 定义basic_string_view类模板和类型别名aliases string_view和wstring_view
<system_error> 定义错误分类和错误代码
<tuple> 定义tuple类模板,作为pair类模板的泛化
<type_traits> 定义模板元编程中使用的类型trait
<typeindex> 定义type_info的简单包装,可在关联容器和无序关联容器中用作索引类型
<typeinfo> 定义bad_cast和bad_typeid异常。 定义type_info类,typcid运算符返回这个类的对象
<utility> 定义pair类模板和make_pair()。这个头文件还定义了工具函数swap()exchange()move()
<variant> 定义variant类

数学工具

C++提供了一些数值处理功能。

头文件名 内容
<complex> 定义处理复数的complex类模板
<valarray> 定义valarray类,以及处理数学矢量和矩阵的相关类和类模板

异常

头文件名 内容
<exception> 定义exception和bad_exception类,以及set_unexpected()set_terminate()uncaught_exception()函数
<stdexcept> 没有定义在<exception>中的非领域相关的异常

I/O流

通常情况下应用程序只需要包含<fstream><iomanip><iostream><istream><ostrcam><sstream>

头文件名 内容
<fstream> 定义了basic_filebuf、basic_ifstream、basic_ofstream和basic_fstream类,声明了filebufwfilebufifstreamwifstreamofstreamwofstreamfstreamwfstream类型别名
<iomanip> 声明了其他地方没有声明的I/O运算符
<ios> 定义了ios_base和basic_ios类,声明了大部分流运算符。几乎不需要直接包含这个头文件
<iosfwd> 其他I/O流头文件中出现的模板和类型别名的前向声明。几乎不需要直接包含这个头文件
<iostream> 声明了cin、cout. cerr和clog以及对应的宽字符版本。注意这不仅是<istream><ostrcam>的组合
<istream> 定义了basic_istreambasic_iostream类,声明了istreamwistreamiostreamwiostream类型别名
<ostream> 定义了basic_ostream类。声明了ostream和wostream类型别名
<sstream> 定义了basic_stringbufbasic_istringstreambasic_ostringstreambasic_stringstream类,声明了stringbufwstringbufistringstreamwistringstreamostringstreamwostringstreamstringstreamwstringstream类型别名
<strcambuf> 声明了basic_streambuf类以及streambufwstreambuf类型别名。 几乎不需要直接包含这个头文件
<strstream> 已不赞成使用

线程库

C++包含一个线程库,允许编写与平台无关的多线程应用程序。

头文件名 内容
<atomic> 定义了原子类型、atomic<T>以及原子操作
<condition_variable> 定义了condition_variablecondition_variable_any
<future> 定义了futurepromisepackaged_taskasync()
<mutex> 定义了不同的非共享互斥体、锁类以及call_once()
<shared_mutex> 定义了shared_mutexshared_timed_mutexshared_lock
<thread> 定义了thread类

声明

本手册是 Agner Fog 优化手册系列第一册 “Optimizing software in C++:An optimization guide for Windows,Linux adn Mac.” 的中文翻译。可以从www.agner.org/optimize/上获取该手册英文版的最新版本。当前中文版是基于2018.9.5日更新的版本翻译的。版权声明请参考本手册最后一章。

1 简介

本手册适用于那些想要使软件更快的编程人员和软件开发者。本手册假设读者熟练掌握 C++ 编程语言,并了解编译器是如何工作的。至于选择 C++ 作为本手册基础的原因,将在稍后解释.

本手册的内容基于笔者对编译器和微处理器是如何工作的研究。本手册中的建议是针对 x86 家族的微处理器,包括 IntelAMDVIA 的处理器(包括 64 位版本)。x86 处理器是 WidowsLinuxBSDMac OS X 中最常用的平台,即使这些操作系统也适用于其他微处理器,当然很多设备也使用其他平台和变异语言。

本手册是一个系列五本手册中的第一本:

  1. Optimizing software in C++:An optimization guide for Windows,Linux adn Mac.

  2. Optimizing subroutines in assembly languague:An optimization guide for x86 platforms.

  3. The microarchitecture of Intel,AMD and VIA CPUs:An optimization guide for assembly programmers and compiler makers.

  4. Instruction tables:Lists of instruction latencies,throughputs and micro-operation breakdowns for Intel, AMD and VIA CPUs.

  5. Calling conventions for dirrerent C++ compilers and operating systems.

    这些手册的最新版本可以在www.agner.org/optimize/,版权声明将列在手册的最后一章。

只用高级语言编写软件的读者只需要阅读本书即可。后续的内容是为了那些想要深入了解指令集,汇编语言和编译器,处理器微架构的读者准备的。对于 CPU 热点代码,可以通过使用汇编获得更高层次的优化,这将会在后续的内容中进一步讨论。

请注意到有非常多的人使用到我的优化手册。因此我不可能有时间回答每一个人的问题。请不要将你的编程问题发送给我,因为你将得不到任何答案。建议初学者在提高自己的编程经验后,再来尝试手册中所提到的技术。,如果你在相关书籍和手册中找不到答案的话,你可以在互联网上的诸多论坛中找到你问题的答案。

我想要感谢那些给我的优化手册发送修正和建议的人,我很高兴能够收到相关信息。

1.1 优化的代价

如今大学的编程课程在软件开发过程中强调结构化、面向对象、模块化、可重用性、系统化。但是这些要求通常都和优化软件的速度和大小相冲突的。

如今,软件老师更经常建议我们函数或者方法的行数应该尽可能的少。但是在几十年前,建议通常是相反的:如果某些功能只会调用一次,那么就不要把他们封装在分离的子程序中。软件编写风格的建议的变化,是因软件项目变的越来越大、越来越复杂,需要将注意力集中在软件开发中,而且电脑的性能也越来越强大。

软件结构化开发的高优先级和程序性能的低优先级,首先反映在编程语言和接口框架的选择上。这对于最终的用户来说,这通常是一个缺点,他们不得不购买性能更加强大的计算机,来应对更大的软件包,即使对于简单的任务,响应时间也长的不能接受,这使得他们感到沮丧。

有时候为了使软件更小更快,有必要在软件开发的高级原则上做一些妥协。本手册讨论了如何在这些要求之间取得合理的平衡。讨论了如何识别和隔离程序中的最关键部分,并将优化工作集中在该部分。讨论了在相对原始的编程风格中,如何克服缺少自动检查数组越界,无效指针等问题。讨论了在哪些高级编程结构需要的执行时间更多,哪些需要的执行时间更少。

2 选择最优平台

2.1 硬件平台的选择

硬件平台的选择相对于过去来说,变成的更不重要了。RISC(精简指令集)和 CISC(复杂指令集)处理器、PC大型主机(mainframes)以及 简单处理器(simple processors)和 向量处理器(vector processors)之间的区别,变得越来越模糊。拥有 CISC 指令集的标准 PC 处理器也包括了 RISC 核心、向量处理指令(vector processing instruction)、多核、超过以前大型主机的处理速度。

现如今,对于确定任务的硬件平台的选择通常是由诸如价格、兼容性、第二选择(sencond source)和可用的好的开发工具等因素而不是处理能力决定的。在一个网路中连接几个标准 PC 可能比投资一个大型主机更便宜、更有效率。具有大规模并行向量处理能力的大型超级计算机在科学计算中占有一席之地,但是对于大多数目的来说,标准 PC 处理器还是首选,因为它们具有更高的性价比。

从技术角度来看,标准 PC 处理器的的 CISC 指令集(也称为 x86)不是最佳的。这个指令集还在维护,是为了兼容那些在 70 年代产生的软件,而当时 RAM 和硬盘空间是非常稀缺的资源。然而,CISC 指令集实际上要比它的名声要好。紧凑的代码使得缓存的效率在缓存资源依旧非常有限的今天更加高效。CISC 指令集实际上在缓存资源非常有限的时候表现的比 RISC 指令集更好。x86 指令集最糟糕的问题是寄存器的缺乏。这个问题在 x86 指令集的 64 位扩展中得到了缓解,其中的寄存器数量翻了一倍。

由于无法控制网络资源的响应时间,对于关键的应用程序,不建议使用依赖网络资源的瘦客户机(Thin clients)。

小型手持设备正变得越来越受欢迎,并被用于越来越多的用途,如电子邮件、浏览网页,这些在以前都需要使用一台 PC。类似的,我们正看到有越来越多的设备和机器采用嵌入式处理器。我对使用哪些平台和操作系统更高效,没有任具体的建议。但我们需要认识到这些设备通常情况下,内存和计算能力都是要弱于 PC 的,这非常重要。因此在这样的系统上节约使用资源比在 PC 平台上更加重要。然而,通过良好的软件设计,即使在这样的小型设备上,许多应用程序也可以具有良好的表现,这些将在第17章进行讨论。

本手册基于标准的 PC 平台,采用 IntelAMD 或者 VIA 处理器,使用 WindowsLinuxBSD 或者 MAC 操作系统。这里给出的很多建议也适用于其它平台,但是都只在 PC 平台上通过测试。

图形加速器

平台的选择明显受任务要求的影响。例如,较大的图形应用编程序最好在具有图形协处理器或者图形加速卡的平台上实现。一些系统也有专门的物理处理器来处理游戏或者动画中的物理运动。

在某些情况下,图形加速卡的高处理能力可以用于除了图形渲染之外的其他用途。然而,这样的应用具有非常高的系统依赖性。因此如果可移植性非常重要的话,就不推荐这么做。本手册将不会讨论图形处理器。

可编程逻辑器件

可编程逻辑器件是一种可以使用硬件描述语言(如 VHDLVerilog)进行编程的芯片。常见的有 CPLDFPGA。编程语言(例如 C++)和硬件描述语言的区别是:编程语言定义了一个一系列指令的算法,而硬件描述语言定义了由例如触发器多路复用器算术单元 等原件和连接它们的导线组成的硬件电路。硬件描述语言天生就是并行的,因为它定义的是电气连接而不是一系列串行操作。

对于一个复杂的数字操作,可编程逻辑器件通常比微处理器中处理的更快,因为硬件可以按特定的目的连接。

FPGA 中实现微处理器作为所谓的软核(soft processor)是可能的。然而这样的处理通常比专用微处理器慢的多,因此它本身并没有什么优势。但是在某些情况下,使用硬件描述语言在同一芯片中定义的软核,执行某些关键应用中的特定指令,是一个非常有效的解决方案。当然将专用微处理器和 FPGA 集成在同一个芯片中是一个更加强大额解决方案。像这样的混合解决方案已经在一些嵌入式系统中被采用了。

我认为这样类似的解决方案,会有一天在 PC 处理器中采用。应用程序将可以定义由硬件描述语言编码的应用程序专用指令。这样的处理器除了代码缓存和数据缓存外,还将会有用于硬件描述代码的缓存。

2.2 微处理器的选择

由于激烈的竞争,不同竞品微处理器的基准性能都非常接近。多核处理器对于那些需要并行运行多个线程的应用程序来说,是有好处的。而小型轻量型的低功耗处理器对于非密集型的应用来说也是相当强大的。

一些系统具有图形处理单元,无论是在图形卡上,亦或是集成在 CPU 芯片中。这样的单元可以当作协处理器,做一些繁重的图形计算。在某些情况下,也可以将图形处理单元的计算能力用于其它目的,而不是当初设计它的目的。一些系统还有一个物理处理单元用于计算电脑游戏中物体的运动。

2.3 操作系统的选择

x86 家族中,所有较新的处理都可以在 16位、32位以及 64位模式下运行。

16位模式在较早的操作系统 DOSWindows 3.x 中使用。如果程序或者数据的大小超过 64kbytes,这些系统将使用内存分割。这是非常低效的。现代微处理器没有针对 16位模式进行优化,一些系统也没有向后兼容 16位的程序。除了小型嵌入式系统,是不建议编写 16位程序的。

如今(2013年)32位和64位的操作系统非常常见,它们在性能上也没有很大的区别。64位软件的市场并不是很大,但可以确定的是 64位系统将是未来的主流。

对于一些具很多函数调用的大量使用 CPU 资源的应用程序来说,64位系统可以提升5-10%的性能。如果性能瓶颈在其它地方,32位系统和 64位系统并没有区别。当然使用大量内存的应用程序可以得益于 64位系统大地址空间。

软件开发者可以在两个版本中选择需要消耗大量内存的软件:为了与现有系统的兼容的 32位版本,以及具有最佳性能的 64位版本。

对于 32位软件,Windows操作系统Linux操作系统的性能几乎相当,因为这两个系统使用同样的函数调用约定(function calling conventions )。FreeBSDOpen BSD 在软件优化上,几乎所有方面都是相同的。这里说关于Linux 的所有建议,同样适用于BSD系统

基于IntelMac OS X 操作系统 实在BSD 的基础上开发的,但是编译器默认使用位置无关代码position-independent code)和延迟绑定,这会降低它的效率。可以通过使用静态链接 和不使用 位置无关代码(选项:-fno-pic),来提升性能。

相对于 32位系统, 64位系统具有以下几个优点:

  1. 两倍的寄存器数量。这样可以在寄存器中而不是内存中存储中间数据和局部变量。
  2. 函数参数使用寄存器传递,而不是使用堆栈,这使得函数调用额效率更高。
  3. 整数寄存器扩展到 64位。这样的唯一好处是,应用程序可以使用 64位整数。
  4. 大内存的分配和释放的效率更高。
  5. 所有的 64位CPU 和操作系统都支持SSE2指令集。
  6. 64位指令集支持数据的自相关寻址,这使得位置无关代码 的效率更高。

相对于 32位系统, 64位系统具有以下几个缺点:

  1. 指针、引用和堆栈入口使用 64位而不是 32位,这导致数据缓存的效率更低。
  2. 在 64位模式下,如果装载地址不能保证小于2^31 , 访问静态或者全局数组将会需要几个额外的指令来计算地址。这些额外的成本在 64位的 WindowsMac 程序中可以观察到,但是在 Linux 中很少见。
  3. 在大内存模型(代码和数据的大小超过 2Gbytes)中,地址的计算将更加的复杂。虽然这种大内存模型很少能用到。
  4. 一些指令的长度, 64位模式下的长度要比 32位模式下要长 1 字节。
  5. 一些 64位编译器要不如它们的 32位版本。

总的来说,如果程序有很多函数调用、大量大内存快的分配、或者可以利用 64位整数的优势,那么你可以期待 64位程序会比 32位程序跑的略微快一点。当程序使用超过 2 gigabytes 的数据时,就非常有必要使用 64位的系统了。

当在 64位模式下运行时,操作系统之间的相似性将会消失,因为函数的调用约定是不同的。64位的Windows 只允许 4 个函数参数通过寄存器传递,而 64位的LinuxBSDMac 允许通过寄存器传递 14 个参数( 6 个整数和 8 个浮点数)。还有其他的细节使得 64位Linux 的函数调用比 64位Windows 的效率更高(详见第五册:Calling conventions for different compilers and operating systems)。一个具有很多函数调用的的程序,有可能在64位的Linux 上,比在64位的Windows 运行的更快。64位Windows 的这个缺点可以通关是关键函数为内联的或者静态的,或者通过使用可以使进行这个程序优化的编译器来减轻。

2.4 编程语言的选择

在开始一个新的软件项目之前,决定哪种编程语言最适合手上的项目是非常重要的。低级语言有利于优化程序执行速度,而高级语言则有利于开发出清晰和结构良好的代码,以及快速和容易的开发用户界面,利用网络资源和数据库的接口等。

最终应用程序的效率取决于编程语言是如何实现的。当代码被编程并翻译成二进制可以行代码时,效率最高。C++Pascal以及Fortran的绝大多数实现都是通过编译器的。

其它一些编程语言通过解释器实现。代码按原样分发(distribute),运行时逐行解释。例如JavaScriptPHPASP以及UNIX shell script。解释代码是是非常没有效率的,因为循环的每一次迭代,被一次又一次的解释位一个循环的主体。

有些是通过即时编译(just-in-time compilation)实现的。程序代码按照原样存储,一边编译一边执行,例如Perl

一些现代编程语言使用一种中间代码(byte code,字节码),源码被编程成中间代码,这是分发的代码。中间代码不能按照原样立即执行,在执行之前,它必须经过第二步的解释或者编译。Java 的一些实现基于解释器,解释器通过模拟所谓的 Java 虚拟机来解释中间代码。最好的 Java 虚拟机对于代码的最常用部分使用即时编译。C#托管C++ 以及 MicroSoft .Net FrameWork 的一些其它一些语言都是基于中间代码的即时编译。

使用中间代码的目的是为了独立于平台且紧凑。使用中间代码的最大缺点是:为了解释或者编译中间代码,用户必须安装庞大的runtime framework。而这个framework 通常需要使用比代码本身多的多的资源。中间代码的另一个缺点是:它增加了额外的抽象层,这使得一些具体的优化更加困难。另一方面,即时编译器可以针对它所运行的 CPU 进行专门的优化,而在预编译代码中进行针对 CPU 的优化更加复杂。

编程语言及其实现的历史揭示了一个曲折的过程,反映了效率、平台独立性和易于开发的等相关冲突的考量。例如,第一台 PC 有一个Basic 的解释器,而由于Basic 解释器实在太慢了,很快就有了Basic 编译器。如今,最受欢迎的Basic 版本,是基于中间代码和即时编译的Visual Basic .NET。一些早期的Pascal 实现使用类似今天Java 的中间代码,但从有了真正的可用的编译器后,该语言获得了显著的欢迎。

从本文的讨论中可以清楚的看到,编程语言的选择需要在效率、可移植性和开发时间等原因进行妥协。当效率很重要的时候,解释类编程语言就不再考虑范围内。而当可移植性和易于开发比速度更重要时,基于中间代码和即使编译的语言可能是一种可行的这种方案。这包括C#Visual Basic 以及最好的Java 实现。然而,这些语言的缺点时运行时框非常庞大,而每次运行程序时都必须加载该框架。加载框架和编译程序的时间有可能比执行程序所要的时间还长。而且运行时框架所消耗的资源可能比运行程序本身还多。程序使用这样的框架,对于简单的任务例如按下按钮或者移动鼠标,有时会有难以接受的长响应时间。当速度很关键时就应该避免使用.Net framework

毫无疑问,使用完全编译的代码可以获得最快的执行速度。编译语言包括CC++DPascalFortan 以及其它几种非著名语言。由于一些原因,我更喜欢C++。一些非常好的编译器和优化的函数库都支持C++C++ 是一种先进的高级语言(advancd high-level language),具有其他语言中少见的丰富的高级特性。但是C++ 还将低级的C 语言作为一个子集,因此可以进行低层次的优化。多数C++ 编译器都支持生成汇编语言,这对于检查编译器对代码的优化程度非常有用。此外,当最高级别的优化是必要的时候,大多数 C++ 编译器允许类似会汇编的函数指令、内联汇编或者易于链接汇编语言模块。C++ 编译器存在于所有主流平台,在这个意义上,C++ 语言是可移植的。Pascal 相对于C++ 具有很多优势。但是不是很通用。Fortran 也相当有效率,但是语法相当的过时。

由于有强大的开发工具可用,C++ 开发非常高效。Microsoft Visual Studio 是一种非常流行的开发工具。这个工具可以使用C++ 的两种不同实现,直接编译和基于.NET framework公共语言运行时的中间代码。显然,当速度很重要时,直接编译的版本更受青睐。

C++ 的一个重要缺点与安全性相关。它没有对数组越界、整数溢出以及无效指针的检查。这些检查的缺失使得代码执行速度比那些拥有这些检查的编程语言更快。由于程序规则无法排除这些错误情况,这使得程序员有责任对这些错误进行显示的检查。后面将会有关于这些检查的指导。

当性能优化具有很高优先级时,C++ 绝对时首选的编程语言。与其他编程语言相比,性能上的提升是相当可观的。当性能对最终的用户很重要时,在开发时间上可能会有的细微提升相对于性能提升所获得的收益,是说的过去的。

由于其他一些原因,可能需要基于中间代码的高级框架,但是部分代码仍需要仔细优化。在这种情况下,混合实现可能是一个可行的解决档案。代码中最重要的部分可以由基于编译的C++ 或者汇编语言实现,而剩余的部分包括用户界面等,可以使用高级框架实现。被优化的代码部分可以被编译为动态链接库(DLL),供其他代码调用。这不是一个最佳的解决档案,因为高级框架任然消耗大量的资源,而这两种代码之间的转换也会产生额外耗费 CPU 时间的消耗。但是当对时间要求高的部分可以完全包含在 DLL 中时,这种解决方案也可以显著的提高性能。

另一个值得考虑的选择是D语言D语言 具有JavaC++ 的许多特性,同时避免了很多C++ 的缺点。而且,D语言 编译成的二进制代码可以与C 或者C++ 代码链接在一起,但是D语言 的 IDE 和编译器没有C++ 的开发的好。

2.5 编译器的选择

市面上有几种不同的C++ 编译器可供选择。很难预测哪一个编译器对于一段特定的代码可以做到最佳的优化。每一个编译器都会做一些非常聪明和非常愚蠢的事情。下面将列举一些常见的编译器。

Microsoft Visual Studio

这是一个非常友好的编译器,具有许多特性。完整的版本非常昂贵,但是有限制的非商业版本是免费的。Visual Studio 可以为 . Net框架 构建代码,也可以直接编译代码(编译时不使用公共语言运行时,CLR,生成二进制代码)。支持 32位和 64位 Windows。集成开发环境(IDE)支持多种编程语言的分析和调试。支持多核处理的 OpenMP 指令。Visual Studio 的优化相当好,但它不是最好的。

Borland/CodeGear/Embarcadero C++ builder

它的 IDE 具有很多和 VS 相同的特性,只支持 32位Windows。不支持最新的指令集。优化做的没有 MicrosoftIntelGnu 的编译器好。

Intel C++ compiler (parallel composer)

Intel 编译器没有它自己的 IDE。它可以作为 VSEclipse 的插件。当使用命令行或者 make 工具时,它也可以作为一个独立的编译器。支持 32位和 64位 的WindowsLinux,也支持基于 IntelMac OSItaniumx系统Intel 编译器支持向量指令、自动矢量化、OpenMP 和自动并行化。支持 CPU 调度,为不同的 CPU生成不同版本的代码。在所有的平台上,对于内联汇编都有非常好的支持,使得在WindowsLinux 上使用相同的内联汇编语法成为可能。编译还提供了一些具有最佳优化的数学函数库。

Intel 编译器最重要的缺点是:它编译的代码在AMDVIA 的处理器上运行的较慢或者根本不运行。可以通过绕过所谓的 CPU分派机制 来避免这个问题,该分派机制检查代码是否运行在Intel CPU 上。请参考 13.7 Intel 编译器中的 CPU分派

就代码可以从它众多的优化特性中受益和可以移植到众多平台上的来说,Intel 编译器是一个很好额选择。

Gnu

虽然对用户不够友好,但这是可以使用的最佳编译器之一。它是免费并且开源的。它支持大多数Linux 发行版本、BSDMac OS X,无论是 32位的还是 64位的。支持 OpenMP、自动并行化和自动矢量化。Gnu 的函数库至今还没有被完全优化过。同时支持AMDIntel 的向量数学库(vector math libraries)。Gnu C++ 编译器可以在众多的平台上使用,包括 32位和 64位的LinuxBSDWindows 以及Mac。对于所有的平台来说Gnu 编译器都是一个非常不错的选择。它是使用命令行运行的独立编译器,但是可以用于很多 IDE,包括EclipseNetBeansCodeBlocksBloodShed

Clang

Clang 编译器基于LLVM(Low Level Virtual Machine)。它和Gnu 编译器在很多方面都相似,并与Gnu 编译器高度兼容。这是Mac 平台上最常用的编译器,也支持LinuxWindows 平台。对于所有的平台Clang编译器都是一个不错的选择。它可以和Eclipse IDE 一起使用。

PGI

该编译器支持 32位和 64位的WindowsLinuxMac。支持并行编程、OpenMP 和自动矢量化。优化做的相当不错。但是向量指令的效率很低。

Digital Mars

这是一个便宜的编译器,用于32位 Windows,包含 IDE。优化做的不是很好。

Open Watcom

另一个32位的 Windows 开源编译器。默认情况下不符合标准的调用约定,优化做的很不错。

Codeplay VectorC

一个 32位的 Windows 商业编译器。可以集成到 Microsoft Visual Studio IDE 中。显然已经不再更新了。可以做自动矢量化。优化功能中等水平。支持三种不同的目标文件格式。

总结

在没有 IDE 的情况下,所有这些编译器都可以作为命令行版本使用。商业编译器有免费的试用版本提供。

Linux 平台 上,通常可以混合来自不同编译器的目标文件(Object File),在某些情况下,也可以在 Windows 平台 上也可以。MicrosoftIntel 的Windows编译器在目标文件级别上完全兼容,而Digital Mars 编译器基本上与它们兼容。EmbarcaderoCodeplayWatcom 编译器在目标文件级别上与其他编译器不兼容。

为了良好的代码性能,我建议在 Unix 应用程序中使用 GnuClangIntel 编译器,在 Windows 应用程序中使用 GnuClangIntelMicrosoft 编译器。如果你希望你的代码在 AMD 微处理器上高效运行,请不要使用 Intel 编译器。

编译器的选择可能在某些情况下由兼容遗留代码,IDE 具体的参数选择,调试工具,简单的 GU I开发,数据库集成 web应用程序集成,混合语言编程等要求决定。如果所选择的编译器不提供最好的优化,在这种情况下,使用不同的编译器生成最关键模块可能是非常有帮助的。在大多数情况下,如果包含必要的库文件,那么由Intel 编译器生成的目标文件可以毫无问题地链接到使用MicrosoftGnu 编译器生成的项目中。或者,使用最好的编译器生成DLL,并从使用另一个编译器构建的项目中调用它。

2.6 函数库的选择

有些应用程序将大部分执行时间花在执行库函数上。耗时的库函数通常属于以下类别之一:

  1. 文件输入/输出
  2. 图形和声音处理
  3. 内存和字符串操作
  4. 数学函数
  5. 加密,解密和数据压缩

大多数编译器都包含用于这些目的的标准库。不幸的是,标准库并不总是完全优化的。

库函数通常是许多用户在许多不同应用程序中使用的一小段代码。因此,与优化特定于应用程序中的代码相比,值得在优化库函数方面投入更多的精力。最好的函数库是使用汇编语言自动 CPU分派 以及最新的指令集扩展 高度优化的。

如果分析显示在某个特定应用程序中函数库占用了大量 CPU 时间,或者如果这是显而易见的,那么可以通过使用不同的函数库来显著的提高性能。如果应用程序在库函数中花费了大部分时间,那么除了寻找最有效的库和节省库函数调用之外,可能不需要优化其他任何地方。建议尝试不同的库,看看哪个最好。

下面将讨论一些常见的函数库。还有许多用于特殊目的的库。

Microsoft

微软编译器自带。有些函数优化得很好,有些则没有。支持 32位和 64位 Windows

Borland / CodeGear / Embarcadero

Borland C++ builder自带。未针对SSE2 和后续指令集进行优化。只支持32位 Windows

Gnu

Gnu 编译器自带。没有像编译器本身优化的好。64位版本比 32位版本好。Gnu 编译器经常插入内置代码,而不是最常见的内存和字符串指令。内置代码不是最优的。使用选项-fno-builtin 可以迫使编译器使用库版本来替代内置版本。Gnu 库支持 32位和 64位LinuxBSD。当前的Windows 可使用版本还不是最新的。

Mac

Mac OS XDarwin)上 Gnu 编译器中包含的库是 Xnu 项目的一部分。在所谓的 commpage 中,操作系统内核中包含了一些最重要的函数。这些功能针对Intel Core 和稍后的Intel 处理器版本进行了高度优化。AMD 处理器和早期的英特尔处理器根本不被支持。只能在 Mac 平台上运行。

Intel

Intel 编译器包含标准函数库。还有一些特殊用途的库,如“Intel Math Kernel Library”和 “ntegrated Performance Primitives”。这些函数库针对大型数据集进行了高度优化。然而,英特尔的库在 AMD 和 VIA 处理器上并不能总是运行良好。有关解释和可能的解决方法,请参见后面的章节。支持所有 x86 和 x86-64 平台。

AMD

AMD Math core library 包含优化过的数学函数。它也适用于英特尔处理器。性能不如Intel库。支持 32位和 64位WindowsLinux

AsmLib

我自己的函数库是,是为了演示而创建的。可以从www.agner.org/optimize/asmlib.zip获得。目前包括内存和字符串函数的优化版本,以及其他一些很难在其他地方找到的函数。在最新的处理器上运行时,比大多数其他库都要快。支持所有 x86 和 x86-64 平台。

Test Processor Microsoft CodeGear Intel Mac Gnu 32位s Gnu 32位s-fno-builtin Gnu 64位s-fno-builtin Asmlib
memcpy16kB aligned operands Intel Core 2 0.12 0.18 0.12 0.11 0.18 0.18 0.18 0.11
memcpy16kB unaligned op. Intel Core 2 0.63 0.75 0.18 0.11 1.21 0.57 0.44 0.12
memcpy16kB aligned operands AMD Opteron K8 0.24 0.25 0.24 n.a. 1.00 0.25 0.28 0.22
memcpy16kB unaligned op. AMD Opteron K8 0.38 0.44 0.40 n.a. 1.00 0.35 0.29 0.28
strlen128 bytes Intel Core 2 0.77 0.89 0.40 0.30 4.5 0.82 0.59 0.27
strlen128 bytes AMD Opteron K8 1.09 1.25 1.61 n.a. 2.23 0.95 0.6 1.19

表中的数字是每字节数据的核心时钟周期(低数字意味着良好的性能)。对齐的操作数意味着源和目标的地址都可以被16整除。
用于测试库的版本(不是最新的)

  • Microsoft Visual studio 2008, v. 9.0
  • CodeGear Borland bcc, v. 5.5
  • Mac: Darwin8 g++ v 4.0.1.
  • Asmlib: v. 2.00
  • Intel C++ compiler, v. 10.1.020. 使用库libircmt.lib 中的 _intel_fast_memcpy__intel_new_strlen 函数。函数名没有记录。

2.7 用户界面框架的选择

典型软件项目中的大多数代码都用于用户界面。不需要大量计算的应用程序很可能在用户界面上花费的 CPU 时间比在程序的基本任务上花费的还要多。

程序员很少从头开始编写自己的图形用户界面。这不仅浪费了程序员的时间,也给最终用户带来了不便。出于可用性的考虑,菜单、按钮、对话框等应该尽可能地标准化。程序员可以使用操作系统附带的标准用户界面元素或编译器和开发工具附带的库。

Microsoft Foundation Classes 是一个流行的 Windows C++ 用户界面库(MFC)。与之竞争的产品是 Borland 现已停止继续维护的Object Windows LibraryOWL)。Linux 系统有几个可用的图形界面框架。用户界面库可以作为运行时 DLL 或静态库链接。除非多个应用程序同时使用同一个 DLL,运行时DLL 比静态库占用更多的内存资源。

用户界面库可能比应用程序本身更大,需要更多的时间来加载。一个轻量级的替代方案是Windows Template LibraryWTL)。WTL 应用程序通常比 MFC 应用程序更快、更紧凑。由于糟糕的文档、缺乏高级开发工具,WTL 应用程序可能会花费更多的时间去开发。

通过放弃使用图形用户界面并使用控制台模式程序,可以获得最简单的用户界面。控制台模式程序的输入通常在命令行或输入文件中指定。输出到控制台或文件。控制台模式的程序是快速、紧凑和易于开发的。方便移植到不同的平台,因为它不依赖于系统特定的图形界面调用。可用性可能很差,因为它缺少图形用户界面的自解释菜单。控制台模式程序对于从其他应用程序(如实现工具库)调用非常有用。

结论是,用户界面框架的选择必须是开发时间、可用性、程序紧凑性和运行时间之间的折衷。没有一个通用的解决方案对所有应用程序都是最好的。

2.8 克服C++语言的缺点

虽然 C++ 在优化方面有很多优点,但它也有一些缺点,这使得开发人员不得不选择其他编程语言。本节将讨论在选择C++ 进行优化时如何克服这些缺点。

可移植性

C++ 是完全可移植的,因为它的语法在所有主要平台上都是完全标准化和受支持的。然而,C++ 也是一种允许直接访问硬件接口和系统调用的语言。这些当然是系统特有的。为了方便在平台之间进行移植,建议将用户界面代码和其他系统特定部分放在一个单独的模块中,并将代码的任务特定部分(应该是与系统无关的)放在另一个模块中。

整数的大小和其他硬件相关细节取决于硬件平台和操作系统。详情见 7.2 整型变量和运算符

开发时间

一些开发人员认为特定的编程语言和开发工具比其他语言和开发工具使用起来更快。虽然有些区别仅仅是习惯的问题,但确实有些开发工具具有强大的功能,可以自动完成许多琐碎的编程工作。通过一致的模块化和可重用类,可以降低 C++ 项目的开发时间并提高可维护性。

安全性

C++ 语言最严重的问题与安全性有关。标准C++ 的实现没有检查数组边界违规和无效指针。这是C++ 程序中常见的错误来源,也是黑客可能的攻击点。有必要遵守某些编程原则,以防止在涉及安全性的程序中出现此类错误。

无效指针的问题可以通过使用引用代替指针,通过初始化指针为 0,通过将指针指向的对象无效时将指针设置为 0 来避免,还可以通过避免指针算术和指针类型转换来避免。通常使用指针的链表和其他数据结构可以使用更高效的容器类模板替代,如9.7 容器类所述。避免使用scanf函数。

数组越界可能是C++ 程序错误的最常见原因。对数组边界外的赋值操作,可能会重写其他变量,更糟糕的是,它可能会重写定义数组的函数的返回地址。这会导致各种奇怪和意想不到的行为。数组通常用作存储文本或输入数据的缓冲区。缺少对输入数据缓冲区溢出的检查是黑客经常利用的一个常见错误。

防止此类错误的一个好方法是使用经过良好测试的容器类来替换数组。标准模板库(STL)是此类容器类的一个有用来源。不幸的是,许多标准容器类以一种低效的方式来使用动态内存分配。有关如何避免动态内存分配的示例,请参见9.6 动态内存分配。有关高效容器类的讨论,请参见9.7 容器类www.agner.org/optimize/cppexamples.zip上本手册的附录含带有边界检查和各种高效容器类的数组示例。

文本字符串尤其有问题,因为字符串的长度可能没有特定的限制。在字符数组中存储字符串的老式C 风格方法快速有效,但不安全,除非在存储之前检查每个字符串的长度。这个问题的标准解决方案是使用 string 类,例如 stringCString。这是安全且灵活的,但在大型应用程序中效率非常低。每次创建或修改字符串时,string 类都会分配一个新的内存块。这可能会导致内存碎片化,并涉及高成本的堆管理和垃圾收集。一个不影响安全性的更有效的解决方案是将所有字符串存储在一个内存池中。有关如何在内存池中存储字符串,请参见附录中的示例(参见www.agner.org/optimize/cppexamples.zip)。

整数溢出是另一个安全问题。官方的C 标准说,在溢出的情况下,有符号整数的行为是“未定义的”。这允许编译器忽略溢出或假设它没有发生。在Gnu 编译器的情况下,假设不发生带符号整数溢出的不幸后果是,它允许编译器优化掉溢出检查。对于这个问题,有许多可能的补救措施:(1)在溢出前进行检查,(2)使用无符号整数 —— 它们是保证回绕(wrap around),(3)使用选项 -ftrapv 捕获整数溢出,但这是非常低效的,(4)使用选项 -Wstrict-overflow = 2,对这样的优化进行警告,(5)使用选项 -fwrapv-fno-strict-overflow 明确定义溢出行为。

在代码中速度很重要的关键部分,你可能会偏离上述安全建议。如果不安全的代码仅限于经过良好测试的函数、类、模板或模块,并且与程序的其余部分有定义良好的接口,那么这是被允许的。

3 找到消耗时间最多的地方

3.1 一个时钟周期是多长?

在本手册中,我使用 CPU 时钟周期而不是秒或微秒来作为时间度量单位。这是因为不同计算机有不同的速度。今天,如果我写下的某个任务需要 10μs,那么在下一代的电脑,它可能只需要 5μs,而我的手册将很快被淘汰。但是如果我写下某事需要 10个时钟周期,即使 CPU 时钟频率加倍,那么它仍然需要 10个时钟周期。

时钟周期的长度是时钟频率的倒数。例如,如果时钟频率是 2GHz,那么时钟周期的长度是:

1
2
3
$$
\frac {1} {2GHz}=5ns
$$

一台计算机上的时钟周期并不总是可以与另一台计算机上的时钟周期相比较。奔腾4 (NetBurst) CPU 的被设计为具有比其他 CPU 更高的时钟频率,但是总的来说,在执行同一段代码时,它比其他 CPU 耗费更多的时钟周期。

假设程序中的一个循环重复1000次,循环中有100个浮点运算(加法、乘法等)。在 2GHz CPU 的上,如果每个浮点运算需要5个时钟周期,然后我们可以大致估计,循环将1000 100 5 * 0.5 ns = 250μs。我们应该尝试优化这个循环吗?当然不!250μs 小于 1/50 的时间刷新屏幕。用户不可能看到延迟。但是在这个循环中还存在另一个循环,另一个循环也重复 1000次,那么我们估计计算时间为 250毫秒。这种延迟的时间足够长,足以引起注意,但也不够长,足以令人厌烦。我们可能决定做一些测量,看看我们的估计是否正确,或者计算时间是否实际超过 250毫秒。如果响应时间太长,用户实际上必须等待结果,那么我们将考虑是否有可以改进的地方。

3.2 使用分析器查找热点(hot spots

在开始优化任何东西之前,必须先识别程序的关键部分。在一些程序中,超过 99% 的时间花在最内部的循环中进行数学计算。在其他程序中,99% 的时间花在读取和写入数据文件上,只有不到 1% 的时间花在实际操作这些数据上。优化重要部分的代码,而不是优化只占总时间的一小部分的代码,这一点非常重要。优化代码中不太重要的部分不仅会浪费时间,还会使代码不太清晰,更难于调试和维护。

大多数编译器包都包含一个分析器,它可以告诉我们每个函数被调用的次数和时间。也有第三方分析器,如AQtimeIntel VTuneAMD CodeAnalyst

有几种不同的分析方法:

  • 植入:编译器在每次函数调用时插入额外的代码,以计算调用函数的次数和时间。
  • 调试:分析器在每个函数或每一行代码中插入临时调试断点。
  • 基于时间的采样:分析器告诉操作系统生成一个中断,例如每毫秒一次。分析器会统计在程序的每个部分中发生中断的次数。这不需要修改被测程序,但可靠性较差。
  • 基于事件的采样:分析器告诉 CPU 在某些事件上生成中断,例如每发生1000次缓存不命中。这使得查看程序的哪个部分有最多的缓存丢失、分支错误预测、浮点异常等等成为可能。基于事件的采样需要基于 CPU 的分析器。对于Intel CPU 使用Intel VTune,对于 AMD CPU 使用AMD CodeAnalyst

不幸的是,分析器通常是不可靠的。它们有时会给出误导的结果,或者完全因为技术问题而失败。分析器的一些常见问题是:

  • 粗糙的时间分辨率。如果时间是以毫秒级的分辨率测量的,如果关键函数的执行需要几微秒,那么测量可能变得不精确,或者干脆为零。
  • 执行时间过短或过长。如果被测试的程序在短时间内完成,那么采样生成的数据太少,无法进行分析。如果程序执行时间太长,那么采样生成的数据太多,超出分析器的分析能力。
  • 等待用户输入。许多程序将大部分时间用于等待用户输入或网络资源。这个时间包含在分析文件中。为了使分析可行,可能需要修改程序以使用一组测试数据而不是用户输入。
  • 来自其他过程的干扰。分析器不仅测量被测试程序中所花费的时间,而且还测量在同一台计算机上运行的所有其他进程(包括分析器本身)所使用的时间。
  • 函数地址在优化后的程序中是模糊的。分析器通过地址识别程序中的所有热点,并尝试将这些地址转换为函数名。但是,高度优化的程序经常以这样一种方式重新组织:函数名和代码地址之间没有明确的对应关系。内联函数的名称对于分析器可能根本不可见。其结果将是关于哪些函数花费的时间最多的误导性报告。
  • 使用调试版本的代码。一些分析器要求你正在测试的代码包含调试信息,以便识别单个函数或代码函数。代码的调试版本没有被优化。
  • 在 CPU 内核之间跳转。进程或线程不一定停留在多核 CPU 上的同一处理器内核中,但事件计数器可以。这导致在多个 CPU 内核之间跳转的线程的事件计数没有意义。你可能需要通过设置线程关联掩码将线程锁定到特定的 CPU 内核。
  • 再现性差。程序执行中的延迟可能是由不可重现的随机事件引起的。诸如任务切换和垃圾收集之类的事件可以在随机时间发生,这使得程序的某些部分看起来比正常情况下花费的时间更长。

有多种方法可以替代分析器。一个简单的替代方法是在调试器中运行程序,并在程序运行时按下break。如果有一个热点,占用 90%的 CPU 时间,那么中断有 90%的机会发生在这个热点。重复中断几次可能足以确定一个热点。在调试器中使用调用堆栈来识别热点周围的情况。

有时,识别性能瓶颈的最佳方法是将度量工具直接放入代码中,而不是使用现成的分析器。这虽然不能解决与概要分析相关的所有问题,但通常会提供更可靠的结果。如果你不满意分析器的工作方式,那么你可以将所需的测量仪器插入程序本身。你可以添加计数器变量来计算程序的每个部分执行了多少次。此外,你可以读取程序中每个最重要或关键部分前后的时间,以度量每个部分所花费的时间。有关此方法的进一步讨论,请参阅 16 测试速度

你的测量代码应该包含 #if 指令,以便可以在代码的最终版本中禁用它。在代码中插入自己的分析工具,是在程序开发过程中跟踪程序性能的一种非常有用的方法。

如果时间间隔很短,时间测量可能需要很高的分辨率。在 Windows 中,你可以使用 GetTickCountQueryPerformanceCounter 函数获得毫秒级的分辨率。使用 CPU 中的时间戳计数器可以获得更高的分辨率,它以 CPU 时钟频率计数(在 Windows 中: __rdtsc())。

如果线程在不同的 CPU 内核之间跳转,时间戳计数器将会失效。在时间度量期间,你可能必须将线程固定到特定的CPU核心,以避免这种情况。(在Windows 中是SetThreadAffinityMask,在Linux 中是sched_setaffness)。

程序应该用一组真实的测试数据进行测试。测试数据应该具有典型的随机性,以便获得真实数量的缓存丢失和分支错误预测的情况。

当发现程序中最耗时的部分时,重要的是将优化工作集中在耗时的部分上。关键代码片段可以使用16 测试速度中描述的方法进行进一步测试和研究。

分析器对查找出与 CPU 密集型代码相关的问题最有帮助。但是许多程序在加载文件或访问数据库、网络和其他资源时所花费的时间比算术运算要多。下面几节将讨论程序中最常见的耗时部分。

3.3 安装程序

安装程序包所需的时间通常不被认为是软件优化问题。但这肯定会占用用户的时间。如果软件优化的目标是为了用户节省时间,那么安装软件包并使其正常工作所花费的时间是不能忽略的。由于现代软件的高度复杂性,安装过程花费一个多小时是很正常的。为了找到并解决兼容性问题,用户必须多次重新安装软件包,这种情况也很常见。

软件开发人员在决定软件包是否使用需要安装许多文件的复杂框架时,应该考虑安装时间和兼容性问题。安装过程应该始终使用标准化的安装工具。需要可以在开始时选择所有安装的选项,以便在无人参与的情况下继续安装过程的其余部分。卸载也应该以标准化的方式进行

3.4 自动更新

许多软件程序通过互联网定期自动下载更新。有些程序在每次计算机启动时都会搜索更新,即使该程序从未被使用过。安装了许多这类程序的计算机需要花费几分钟才能启动,这完全是在浪费用户的时间。其他一些程序在每次启动时搜索更新。如果当前版本满足用户的需求,则用户可能不需要更新。搜索更新应该是可选的,应该默认是关闭的,除非有令人信服的安全理由进行更新。更新过程应该在低优先级线程中运行,并且只有在程序实际使用时才运行。任何程序在不使用时,都不应该在后台进程中运行。下载的程序更新的安装应该延迟到程序关闭并重新启动。

操作系统的更新尤其耗时。有时安装操作系统的自动更新需要几个小时。这是非常有问题的,因为这些耗时的更新可能在不方便的时候出现。如果用户在离开工作场所之前出于安全原因必须关闭或注销计算机,并且系统禁止用户在更新过程中关闭计算机,那么这将是一个非常大的问题。

3.5 程序加载

很多时候,加载一个程序要比执行它花费更多的时间。对于基于大型运行时框架、中间代码、解释器、即时编译器等的程序,加载时间可能会非常长,这是使用Java, C#Visual Basic 等编程语言编写的程序的常见情况。

但是,即使是用编译的C++ 实现的程序,加载程序也会耗费时间。如果程序使用大量运行时 DLL(动态链接的库或共享对象)、资源文件、配置文件、帮助文件和数据库,通常会发生这种情况。当程序启动时,操作系统可能不会加载一个大程序的所有模块。有些模块可能只在需要时加载,或者在 RAM 大小不足时将其交换到硬盘。

用户希望对简单的操作(如按键或鼠标移动)立即作出响应。如果因为它需要从磁盘加载模块或资源文件,导致响应延迟了几秒钟,这对于用户来说是不可接受的。使用大量内存的应用程序会迫使操作系统将内存交换到磁盘。内存交换是鼠标移动或按键等简单操作的响应时间长得不可接受的常见原因。

避免大量的 DLL,配置文件、资源文件、帮助文件等,分散再硬盘的不同地方。几个文件、最好和.exe 文件在同一个路径下,这样是可以接受的。

3.6 动态链接和位置无关的代码

函数库可以是静态链接库(\.lib,*.a),或动态链接库,也称为共享对象(*.dll,* . so)。有几个因素可以使动态链接库比静态链接库慢。这些因素将在*14.11 静态库 VS 动态库中详细解释。

位置无关代码用于类 Unix 系统中的共享对象。默认情况下,Mac 系统在任何地方都使用与位置无关的代码。位置无关的代码效率很低,尤其是在 32位模式下,原因如14.11 静态库 VS 动态库中所述。

3.7 文件存取

读取或写入硬盘上的文件通常比处理文件中的数据花费更多的时间,特别是如果用户有一个病毒扫描程序,扫描所有要访问的文件。

文件的顺序向前访问比随机访问快。读或写大块比一次读或写一小块文件更快。一次读写不要少于几千字节。你可以将整个文件复制到内存缓冲区中,并在一个操作中读写它,而不是以非顺序的方式读写几个位。通常,访问最近访问过的文件要比第一次访问快得多。这是因为文件已经复制到磁盘缓存。

远程或可移动媒体(如软盘和u盘)上的文件可能不会被缓存。这可能会产生非常戏剧性的后果。我曾经编写过一个Windows程序,该程序通过调用 WritePrivateProfileString 来创建一个文件,它每写一行就会打开和关闭一次文件。由于磁盘缓存,这在硬盘上工作得非常快,但是将文件写入软盘需要几分钟。

包含数字数据的大文件如果以二进制形式存储比以ASCII格式存储的文件更紧凑和高效。二进制数据存储的一个缺点是它不可读,并且不容易移植到具有大端存储的系统中。

在具有许多文件输入/输出操作的程序中,优化文件访问比优化CPU使用更重要。如果处理器在等待磁盘操作完成时可以执行其他工作,那么将文件访问放在一个单独的线程中可能会有好处。

3.8 系统数据库

在 Windows 中访问系统数据库可能需要几秒钟时间。与 Windows 系统中的大型注册数据库相比,将特定于应用程序的信息存储在单独的文件中更有效。注意,如果使用 GetPrivateProfileStringWritePrivateProfileString 等函数读写配置文件(*.ini 文件),系统可能会将信息存储在数据库中。

3.9 其他数据库

许多软件应用程序使用数据库来存储用户数据。数据库会消耗大量的 CPU 时间、RAM 和磁盘空间。在简单的情况下,可以用普通的旧数据文件替换数据库。数据库查询通常可以通过使用索引、使用集合而不是循环等方式进行优化。优化数据库查询超出了本手册的范围,但是你应该知道,优化数据库访问通常可以获得很多好处。

3.10 图形

图形用户界面可能使用大量的计算资源。通常会使用特定的图形框架。操作系统可以在其 API 中提供这样的框架。在某些情况下,在操作系统 API 和应用程序软件之间有一个额外的第三方图形框架层。这样一个额外的框架会消耗大量额外的资源。

应用软件中的每个图形操作都通过调用图形库或 API 函数的函数调用实现的,然后这些函数调用设备驱动程序。对图形函数的调用非常耗时,因为它可能经过多个层,并且需要切换到受保护模式并再次返回。显然,对绘制整个多边形或位图的图形函数进行一次调用要比通过多次函数调用分别绘制每个像素或线条更有效率。

计算机游戏和动画中图形对象的计算当然也是很耗时的,特别是在没有图形处理单元的情况下。

各种图形函数库和驱动程序的性能差别很大。对于使用哪一种更好,我没有具体的建议。

3.11 其它系统资源

对打印机或其他设备的最好是一次写入大块内容,而不是每次一小块,因为对驱动程序的每次调用都涉及到切换到受保护模式并再次返回的开销。

访问系统设备和使用操作系统的高级工具可能会很耗时,因为它可能涉及到加载几个驱动程序、配置文件和系统模块。

3.12 访问网络

一些应用程序使用Internet或内部网络进行自动更新、远程帮助文件、数据库访问等。这里所存在的问题是访问时间无法控制。在简单的测试配置中,网络访问可能会很快,但在网络过载或用户远离服务器的使用情况下,网络访问可能很慢或完全无法访问。在决定是在本地还是远程存储帮助文件和其他资源时,这些问题应该被列入考量之中。如果需要频繁更新,那么最好在本地映射远程数据。访问远程数据库通常需要使用密码登录。对于许多辛勤工作的软件用户来说,登录是一个恼人的耗时过程。在某些情况下,如果网络或数据库负载过重,登录过程可能需要一分钟以上。

3.13 访问内存

与对数据进行计算所需的时间相比,从 RAM 内存访问数据需要相当长的时间。这就是所有现代计算机都有内存缓存的原因。通常,一级数据缓存为 8 - 64Kb,二级缓存为 256Kb - 2Mb。计算机中也有可能还存在一个三级缓存。

如果程序中所有数据的总和大于二级缓存,并且分散在内存中或以非顺序方式访问,那么内存访问可能是程序中最耗时的地方。如果变量在内存缓存中,读写它只需要 2 - 3 个时钟周期,如果不缓存,则需要几百个时钟周期。关于数据存储,请参考 7.1 不同类型变量的存储;关于内存缓存,请参考 9 优化内存访问

3.14 上下文切换

上下文切换是多任务环境中不同任务之间的切换,多线程程序中不同线程的切换,或大型程序中的不同部分的切换。频繁的上下文切换会降低性能,因为数据缓存的内容,代码缓存、分支目标缓冲区、分支模式历史等都可能需要更新。

如果分配给每个任务或线程的时间片很小,上下文切换将会更频繁。时间片的长度由操作系统决定,而不是由应用程序。

在具有多个 CPU 或具有多核 CPU 的计算机中,上下文切换的数量会更少。

3.15 依赖链

现代微处理器可以乱序(错序)执行。这意味着如果软件指定 A 和 B 的计算,而 A 的计算速度较慢,则微处理器可以在计算完 A 之前开始计算 B。显然,这只有在计算 B 不需要 A 的值时才有可能。

为了利用乱序执行的优势,必须避免长依赖链。依赖链是一系列的计算,其中每个计算依赖于前一个计算的结果。这会妨碍 CPU 同时执行多个计算或者导致混乱。有关如何打破依赖关系链的示例,请参见 11 乱序执行

3.16 执行单元的吞吐量

延迟和执行单元的吞吐量之间有一个重要的区别。例如,在现代 CPU 上执行浮点加法可能需要 3 - 5 个时钟周期。但每个时钟周期可以开始一个新的浮点加法。这意味着,如果每个加法依赖于前一个加法的结果,那么每三个时钟周期只有一个加法。但是,如果所有的加法都是独立的,那么每个时钟周期可以有一个加法。

在计算密集型的程序中,如果没有上面提到的耗时代码占主导地位,并且没有很长的依赖链,则性能才可能达到最高。在这种情况下,性能受到执行单元吞吐量的限制,而不是延迟或内存访问的限制。

现代微处理器的执行核心分为几个执行单元。通常,有两个或多个整数单元、一个或两个浮点加法单元和一个或两个浮点乘法单元。这意味着可以同时进行整数加法、浮点加法和浮点乘法。

因此,进行浮点运算的代码最好能够平衡加法和乘法。减法和加法使用相同的执行单位。除法需要更长的时间。在浮点操作之间执行整数操作而不降低性能是可能的,因为整数操作使用不同的执行单元。例如,执行浮点运算的循环通常使用整数运算来递增循环计数器、比较循环计数器与其极限等。在大多数情况下,可以假设这些整数操作不会增加总计算时间。

4 性能和可用性

更好的软件产品是可以为用户节省时间的产品。对于许多计算机用户来说,时间是一种宝贵的资源,许多时间浪费在速度慢、难于使用、不兼容或容易出错的软件上。所有这些问题都是可用性问题,我认为应该从更广泛的可用性角度来看待软件性能。

这不是一本关于可用性的手册,但是我认为有必要在这里引起软件编程人员注意一些影响软件使用效率的常见障碍。有关这个主题的更多信息,请参见我在 Wikibooks 上提供的免费电子书Usability for Nerds

下面的列表指出了一些让软件用户感到沮丧和浪费时间的典型原因,以及软件开发人员应该注意的一些重要的可用性问题。

  1. 大型运行时框架。.NET 框架和Java 虚拟机框架通常比它们运行的程序占用更多的资源。这些框架是资源问题和兼容性问题的常见来源,它们在框架本身的安装过程中、在框架下运行的程序的安装过程中、在程序启动过程中以及在程序运行过程中都会浪费大量时间。使用这种运行时框架的主要原因是为了跨平台的可移植性。不幸的是,跨平台兼容性并不总是如预期的那么好。我相信通过更好地标准化编程语言、操作系统和 API,可以更有效地实现可移植性。

  2. 内存交换。软件开发人员通常拥有比最终用户拥有更多 RAM 的,功能更强大的计算机。因此,开发人员可能看不到过多的内存交换和其他资源问题,对于最终用户,这些问题将会导致使用大量资源的应用程序表现很差。

  3. 安装问题。程序的安装和卸载过程应该标准化,由操作系统而不是由单独的安装工具来完成。

  4. 自动更新。如果网络不稳定或新版本有旧版本中不存在的问题,则软件的自动更新可能会导致问题。更新机制经常会弹出一些烦人的弹出消息,比如请安装这个重要的新更新,或者在用户在忙于重要工作时,告诉它们要重启计算机。更新机制不应该中断用户,而应该只显示一个独立的图标,表示有更新的可用,或者在计算机重新启动时自动更新。软件分销商经常滥用更新机制来宣传其软件的新版本。这对用户来说很烦人。

  5. 兼容性问题。所有软件都应该在不同的平台、不同的屏幕分辨率、不同的系统颜色设置和不同的用户访问权限上进行测试。软件应该使用标准的 API 调用,而不是自定义的修改和直接访问硬件。应该使用现成的协议和标准化的文件格式。Web 系统应该在不同的浏览器、不同的平台、不同的屏幕分辨率等环境中进行测试。应遵守可访问性指南。

  6. 复制保护。一些复制保护方案是基于违反或规避操作系统标准的黑客攻击。这种方案是兼容性问题和系统崩溃的常见根源。许多复制保护方案都是基于硬件识别的。当硬件更新时,这种方案会导致问题。大多数的复制保护方案都让用户感到厌烦,并且阻止合法备份的复制,却没有阻止非法的复制。应该权衡复制保护方案的好处和在可用性和必要支持上付出的成本。

  7. 硬件更新。更改硬盘或其他硬件通常要求重新安装所有软件,并且还会丢失用户设置。花上整个工作日或者更多时间重新安装的情况也很常见。许多软件应用程序需要有更好的备份功能,当前的操作系统需要更好的硬盘复制支持。

  8. 安全。具有网络访问权限的软件易受病毒攻击和其他滥用的弱点,可能使用户付出极其昂贵的代价。而防火墙、病毒扫描器和其他保护手段是造成兼容性问题和系统崩溃的最常见原因。此外,病毒扫描器比计算机上的其他任何东西都要花费更多的时间,这种情况并不少见。作为操作系统一部分的安全软件通常比第三方安全软件更可靠。

  9. 后台服务。许多在后台运行的服务对用户来说是不必要的,是对资源的浪费。考虑只在用户激活时运行服务。

  10. 过多的特性。由于市场原因,软件通常会向每个新版本添加新特性。这可能会导致软件速度变慢或需要更多的资源,即使用户从不使用过这些新特性。

  11. 认真对待用户反馈。用户的抱怨应该被视为关于 bug、兼容性问题、可用性问题和所需新特性的有价值的信息来源。系统地处理用户反馈,确保信息得到合理利用。用户应该得到关于问题调查和解决方案计划的回复。可以从网站上方便的获得补丁。

5 选择最优算法

要优化 CPU 密集型软件,首先要找到最佳算法。算法的选择对于排序、搜索和数学计算等任务非常重要。在这种情况下,选择最好的算法比优化想到的第一个算法,你可以得到更多的提升。在某些情况下,你可能需要测试几种不同的算法,以便找到在一组典型测试数据上最有效的算法。

话虽如此,我必须提醒凡事不要过度。如果一个简单的算法可以足够快地完成这项工作,就不要使用高级和复杂的算法。例如,一些程序员甚至使用哈希表来存储很小的数据列表。对于非常大的数据库,哈希表可以显著地提高搜索时间,但是对于使用二分搜索甚至线性搜索的都可以很快完成的列表,就没有理由使用它。哈希表增加了程序的大小和数据文件的大小。如果瓶颈是文件访问或缓存访问,而不是 CPU 时间,这反而会降低效率。复杂算法的另一个缺点是,它使程序的开发成本更高,而且更容易出错。

对于不同目的的不同算法的讨论超出了本手册的范围。你必须查阅关于标准任务(如排序和搜索)的算法和数据结构的一般文献,或针对更复杂的数学任务的特定文献。

在开始编写代码之前,你可以考虑其他人是否已经在你之前完成了这项工作。针对许多标准任务的优化函数库有许多种来源。例如,Boost包含许多常用的经过良好测试的库。“Intel Math Kernek Library”包含许多函数用于常见的数学计算,包括线性代数和统计学,以及“Intel Performance Primitives”库包含许多含函数用于音频和视频处理,信号处理、数据压缩和密码学(www.intel.com)。如果你正在使用Intel 函数库,确保在非Intel 处理器上可以运作良好(参见 13.7 Intel 编译器中的 CPU分派)。

在开始编程之前选择最优算法通常说起来容易做起来难。许多程序员已经发现,只有在他们将整个软件项目放在一起并对其进行测试之后,才有更好的方法来处理问题。通过测试和分析程序性能以及研究瓶颈获得的见解,可以更好地理解问题的整个结构。这种新的见解可以导致程序的完全重新设计,例如,当你发现有更好的方法来组织数据时。

对一个已经投入使用的的程序进行彻底的重新设计当然是一项相当大的工作,但这可能是一个相当好的投资。重新设计不仅可以提高性能,还可以得到更易于维护的、结构良好的程序。实际上,你花在重新设计程序上的时间可能比你花在解决原来设计糟糕的程序的问题上的时间要少。

6 开发过程

关于使用哪种软件开发过程和软件工程原则存在着相当多的争论。我不打算推荐任何具体的模型。相反,我将对开发过程如何影响最终产品的性能发表一些评论。

在规划阶段,最好对数据结构、数据流和算法进行全面的分析,以预测哪些资源是最关键的。然而,在早期规划阶段可能有许多未知的因素,因此很难对问题进行详细的概述。在后一种情况下,你可以将软件开发工作视为一个学习过程,其中主要的反馈来自于测试。在这里,你应该为多次迭代的重新设计做好准备。

一些软件开发模型具有严格的形式,它要求在软件的逻辑架构中有几个抽象层。你应该知道,这种形式主义有其固有的性能成本。将软件分割成过多的抽象层是降低性能的常见原因。

由于大多数开发方法本质上都是增量的或迭代的,所以一定要有一种策略来保存每个中间版本的备份。对于单人项目,制作每个版本的 zip文件就足够了。对于团队项目,建议使用版本控制工具。

7 不同C++结构的效率

大多数程序员对于如何将一段程序代码转换成机器码以及微处理器如何处理这些代码,了解很少或者根本不了解。例如,许多程序员不知道双精度运算和单精度运算一样快。谁会知道模板类比多态类更高效呢?本章旨在解释不同C++ 语言元素的相对效率,以帮助程序员选择最有效的替代方案。本系列手册的其他卷进一步解释了理论背景。

7.1 不同类型变量的存储

变量和对象存在内存的存储位置,取决于它们在C++程序中是如何声明的。这会影响数据缓存的效率(参见第89页,TODO)。如果数据在内存中随机分布,则数据缓存的效率很低。因此,理解变量是如何存储的非常重要。对于简单的变量、数组和对象,存储原则是相同的。

栈存储(Storage on the stack)

函数中声明的变量和对象存储在栈中,但以下描述的几种情况除外。

栈是内存的一部分,以先入后出的方式组织。它用于存储函数返回地址(即函数是从哪里调用的)、函数参数、局部变量,以及保存在函数返回之前必须恢复的寄存器。每次调用函数时,它都会为所有这些目的在栈上分配所需的空间。当函数返回时释放该内存空间。下一次调用函数时,新函数的参数可以使用相同的空间。

因为重复使用相同范围的地址,栈是用来存储数据效率最高的内存空间了。如果没有很大的数组,那么这一部分数据基本是缓存在一级缓存中的,而这里的访问速度是非常快的。

我们从中可以学到的教训是,所有变量和对象最好在使用它们的函数中声明。

通过在{}中声明变量,可以使变量的作用域更小。然而,大多数编译器在函数返回之前不会释放变量所使用的内存,即使在退出声明变量的{}时可以释放内存。如果变量存储在寄存器中(见下文),那么它可能在函数返回之前被释放。

全局或静态存储(Global or static storage)

在任何函数之外声明的变量称为全局变量。可以在任何函数中访问它们。全局变量存储在内存的静态部分中。静态内存还用于使用static关键字声明的变量、浮点常量、字符串常量、数组初始化器列表、switch语句跳转表和虚拟函数表。

静态数据区域通常分为三部分:一部分用于程序从不修改的常量,一部分用于程序可能会修改的初始化变量,另一部分用于程序可能会修改的未初始化变量。

静态数据的优点是可以在程序启动之前将其初始化为所需的值。缺点是内存空间在整个程序执行过程中都被占用,即使变量只在程序的一小部分中使用。这会降低数据缓存的效率。

如果可以避免,就不要定义全局变量。不同线程之间的通信可能需要全局变量,但这是惟一不可避免的情况。如果一个变量被多个不同的函数访问,或者你希望避免将变量做为函数的参数的额外开销,那么让它成为全局变量可能是有用的。但是,将需要访问相同变量的函数作为同一个类的成员函数并在类中保存共享的变量可能是一个更好的方案。当然喜欢用哪一种方案是编程风格的问题。

通常最好将查找表声明为static,例如

1
2
3
4
5
// Example 7.1
float SomeFunction (int x) {
static float list[] = {1.1, 0.3, -2.0, 4.4, 2.5};
return list[x];
}

在这里使用 static 的优点是在调用函数时不需要初始化这个数组。当程序加载到内存中时,这些值就会被放在那里。如果将上面的示例中的 static 去掉,那么每次调用函数时都必须重新将所有这五个值放入数组中。这是通过将整个列表从静态内存复制到堆栈内存来完成的。在大多数情况下,从静态内存中复制常数数据到栈是在浪费时间。但这在特殊情况下可能是最优的:在循环中多次使用数据,而一级缓存已经被很多数组占用了,这时你可能会想把数据保存在栈上。

字符串常量和浮点常量在优化后的代码中,被存储在静态内存中。例如:

1
2
3
// Example 7.2
a = b * 3.5;
c = d + 3.5;

在这里,常量3.5将会被存储在静态内存中。大多数编译器将识别出这两个常量是相同的,因此只需要存储一个常量。将整个程序中所有相同的常量会被在一起,以最小化常量使用的缓存空间。

整数常量通常包含在指令代码中。你可以假设整数常量不存在缓存问题。

寄存器存储(Register storage)

有限数量的变量可以存储在寄存器中而不是主存中。寄存器是 CPU 中用于临时存储的一小块内存。存储在寄存器中的变量可以被快速访问。所有优化编译器都会自动选择一个函数中最常用的变量存储在寄存器中。同一个寄存器可以用于多个变量,只要它们的使用(生存周期)不重叠。

寄存器的数量非常有限。在 32 位操作系统中,大约有 6 个整数寄存器可用于一般用途,而在 64 位系统中,有 14 个整数寄存器。

浮点变量使用不同类型的寄存器。32位操作系统中有 8个浮点寄存器,64位操作系统中有 16个浮点寄存器。一些编译器很难在 32位模式下生成浮点寄存器变量,除非启用了 SSE2 指令集(或更高版本)。

易变变量(Volatile)

volatile 关键字用于声明一个变量可变被其它线程改变。这可以阻止编译器依赖变量始终具有代码中先前分配的值的假设来进行优化。例如:

1
2
3
4
5
6
7
8
9
10
11
// Example 7.3. Explain volatile

volatile int seconds; // incremented every second by another thread
void DelayFiveSeconds()
{
seconds = 0;
while (seconds < 5)
{
// do nothing while seconds count to 5
}
}

在本例中,DelayFiveSeconds 函数将等待,直到另一个线程将秒数增加到5。如果 seconds 没有声明为 volatile,那么具有优化功能的编译器将假设秒在 while 循环中保持为零,因为循环中没有任何东西可以更改该值。循环 while(0 < 5){} ,将是一个无限循环。

要注意到 volatile 并不意味着原子性(atomic)。它不会阻止两个线程同时改变变量。如果上面示例中的代码试图在其他线程增加 seconds 的同时将 seconds 置为零,那么它可能会失败。更安全的实现是只读取 seconds 的值,并等待该值改变五次。

线程本地存储(Thread-local storage)

大多数编译器可以使用关键字 __thread__declspec(thread) 来实现静态变量和全局变量的线程本地存储。这样的变量对于每个线程都有一个实例。线程本地存储是低效的,因为它是通过存储在线程环境块中的指针进行访问的。如果可能的话,应该避免线程本地存储,并将其替换为栈上的存储(参见上文)。存储在栈中的变量总是属于创建它们的线程。

Far

具有分段内存的系统,如DOS 和16位Windows,允许使用关键字 far(数组也可以用 huge 声明)将变量存储在其它数据段中。far 存储、far 指针和 far 过程是低效的。如果一个程序在一个段(内存)中有太多的数据,那么建议使用允许更大段的不同操作系统( 32位或 64位系统)。

动态内存分配

动态内存分配由 newdelete运算符或mallocfree函数完成。这些运算符和函数消耗大量时间。内存中称为堆的一部分保留给动态分配。当以随机顺序分配和释放不同大小的对象时,堆很容易变得碎片化。堆管理器可以花费大量时间清理不再使用的空间并搜索空闲空间。这称为垃圾收集。按顺序分配的对象不一定按顺序存储在其中。当堆变的碎片化时,它们可能分散在不同的地方。这使得数据缓存效率低下。

动态内存分配还会使代码更复杂,更容易出错。程序必须保留指向所有已分配对象的指针,并跟踪它们何时不再使用。重要的是,在程序流的所有可能的情况下,也要释放所有分配的对象。不这样做是一个常见的导致错误的原因,称为内存泄漏。更糟糕的一种错误是在释放对象之后访问该对象。程序逻辑可能需要额外的开销来防止此类错误。

有关使用动态内存分配的优点和缺点的进一步讨论,请参见9.6 动态内存分配

一些编程语言,如 Java,所有对象都使用动态内存分配。这当然是低效的。

类中声明的变量

类中声明的变量按照它们在类声明的顺序存储。存储类型由类的对象是在哪里定义的来决定。类、结构或联合的对象可以使用上面提到的任何存储方法。除了在最简单的情况下,对象不能存储在寄存器中,但是它的数据成员可以复制到寄存器中。

带有 static 修饰符的类成员变量将存储在静态内存中,并且只有一个实例。同一类的非静态成员将存储在该类的每个实例中。

将变量存储在类或结构中是一种很好的方法,可以确保在程序的相同部分中使用的变量也存储在彼此附近。使用类的优点和缺点见7.19 结构体和类

7.2 整型变量和运算符

整数大小

整数可以是不同的大小,可以有符号也可以无符号。下表总结了可用的不同整数类型。

delaration size, bits minimum value maximum value in stdint.h
char 8 -128 127 int8_t
shortint in 16-bit system: int 16 -32768 32767 int16_t
int in 16-bit system: long int 32 -2^31 2^31 -1 int32_t
long long or int64_t MS compiler:__int64 64位 Linux: long int 64 -2^63 2^63 -1 int64_t
unsigned char 8 0 255 uint8_t
unsigned short int in 16-bit system: unsigned int 16 0 65535 uint16_t
unsigned int in 16-bit system: unsigned long 32 0 2^32 -1 uint32_t
unsigned long long or uint64_t MS compiler: unsigned __int64 64位 Linux: unsigned long int 64 0 2^64 -1 uint64_t

Table 7.1 Sizes of different integer types

不幸的是,对于不同的平台,声明特定大小的整数的方式是不同的,如上表所示。如果标准头文件 stdin .hinttypes.h 可用,则建议使用标准头文件,以可移植的方式定义特定大小的整数类型。

无论大小如何,整数运算在大多数情况下都是很快的。但是,使用大于最大可用寄存器大小的整数大小是低效的。换句话说,在 16位系统中使用 32位整数或在 32位系统中使用 64位整数效率会很低,特别是在代码涉及乘法或除法的情况下。

如你定义一个 int 类型,而不指定该类型的大小,编译器将始终选择效率最高的整数大小。较小大小的整数(charshort int)的效率稍微低一些。在许多情况下,编译器在进行计算时将这些类型转换为默认大小的整数,然后只使用结果中较低的 8位或 16位。你可以假设类型转换需要 0 或 1 个时钟周期。在 64位系统中,只要不进行除法,32位整数和 64位整数的效率之间只有极小的差别。

建议在与大小无关且没有溢出风险的情况下使用默认整数大小,例如简单变量、循环计数器等。在大型数组中,为了更好地使用数据缓存,最好使用对于特定用途来说足够大的最小整数大小。大小不同于 8、16、32和 64位的位域(Bit-fields)效率较低。在 64位系统中,如果应用程序可以利用额外的位,那么可以使用 64位整数。

无符号整数类型 size_t 在 32位系统中是 32位,在 64位系统中是 64位。当你希望确保永远不会发生溢出时(即使是对于大于 2GB的数组),它可以被用于数组大小和数组索引。

在考虑特定整数大小是否足够大以满足特定用途时,必须考虑中间计算是否会导致溢出。例如,在表达式$a = (bc)/d$中,即使$a$、$b$、$c$和$d$都低于最大值,也可能发生($bc$)溢出。这里没有对整数溢出的自动检查。

有符号整数 VS 无符号整数

在大多数情况下,使用有符号整数和无符号整数在速度上没有区别。但在一些情况下会有一些区别:

  1. 除以常数:当你将一个整数除以一个常数时,无符号要快于有符号(参见14.5 整数除法)。这也适用于模运算符 %
  2. 对于大多数指令集,有符号整数比无符号整数转换成浮点数要快(参见14.8 浮点数和整数相互转换:将整数转换成浮点数)。
  3. 有符号变量和无符号变量的溢出行为不同。无符号变量的溢出会得到一个较小的正数。带符号变量的溢出没有被正式定义。正常的行为下,正溢出将会变为负值,但是编译器可以基于不会发生溢出的假设,优化掉依赖于溢出的分支。

有符号整数和无符号整数之间的转换是无代价的。这仅仅是对相同的位进行不同的解释。负整数在转换为无符号时将被解释为一个非常大的正数。

1
2
3
4
5
6
// Example 7.4. Signed and unsigned integers

int a, b;
double c;
b = (unsigned int)a / 10; // Convert to unsigned for fast division
c = a * 2.5; // Use signed when converting to double

例 7.4中,我们将 a 转换为无符号整数,以使除法更快。当然,这只在确定 a 永远不会为负的情况下有效。最后一行是将 a 隐式地转换为 double 然后乘以常数 2.5,结果也是 double 类型的。在这里我们希望 a 是有符号的。

确保不要在比较中混合有符号整数和无符号整数,例如 <。比较有符号整数和无符号整数的结果是模糊的,可能会产生不希望的结果。

整数运算符

整数运算通常非常快。在大多数微处理器上,简单的整数操作(如加减、比较、位操作和移位操作)只需一个时钟周期。

乘法和除法需要更长的时间。在奔腾4处理器上,整数乘法需要 11 个时钟周期,在大多数其他微处理器,需要 3 - 4 个时钟周期。整数除法需要 40 - 80 个时钟周期,具体取决于微处理器。整数除法在 AMD 处理器上整数位数越小速度越快,但在英特尔处理器上不会这样。关于指令延迟的详细信息列在手册4:“Instruction tables”中。关于如何加速乘法和除法的技巧,请分别参考14.4 整数乘法14.5 整数除法

自增和自减运算符

增量(前缀)运算符 ++i 和增量(后缀)运算符 i++ 和加法一样快。当仅用于递增整数变量时,使用递增前或递增后都没有区别。效果完全相同。例如,for (i=0; i<n; i++)for (i=0; i<n; ++i)是一样的。但是当使用表达式的结果时,效率可能会有所不同。例如,x = array[i++]x = array[++i] 速度更快,因为在后一种情况下,数组元素的地址的计算必须等待 i 的新值,这将使 x 的可用性延迟大约两个时钟周期。显然,如果将增量(前缀)更改为增量(后缀),则必须调整i的初始值。

还有一些情况下,增量(前缀)比增量(后缀)更有效率。例如,在 a = ++b 的情况下;编译器会在这条语句之后识别出 ab 的值是相同的,这样它就可以对两者使用相同的寄存器,而表达式 a = b++,将使 ab 的值不同,这样它们就不能使用相同的寄存器。

这里所说的关于递增运算符的所有内容也适用于整数变量上的递减运算符。

7.3 浮点变量和运算符

x86 家族中的现代微处理器有两种不同类型的浮点寄存器,相应地也有两种不同类型的浮点指令。每种类型都有各有优缺点。

进行浮点运算的原始方法涉及到将8个浮点寄存器组成一个寄存器栈(register stack)。这些寄存器具有长双精度(80位)。使用寄存器栈的优点是:

  1. 所有的计算都是长双精度的。
  2. 不同精度之间的转换不需要额外的时间。
  3. 对于数学函数,如对数函数和三角函数,有一些指令可用。
  4. 代码很紧凑,在代码缓存中占用的空间很小。

寄存器堆栈也有缺点:

  1. 由于寄存器堆栈的组织方式,编译器很难生成寄存器变量。
  2. 浮点数比较较慢,除非使用奔腾-II或者更新的指令集。
  3. 整数和浮点数之间的转换效率很低。
  4. 当使用长双精度时,除法、平方根和数学函数需要更多的时间。

还有一种新的浮点运算方法涉及8个或16个向量寄存器(XMMYMM),可用于多种用途。浮点运算以单精度或双精度进行,中间结果的计算精度始终与操作数相同。使用向量寄存器的优点是:

  1. 浮点寄存器变量很容易实现。
  2. 矢量运算可用于对XMM 寄存器中两个双精度或四个单精度变量的矢量进行并行计算(参见12 使用向量操作)。如果AVX 指令集是可用的,那么在YMM 寄存器中每个向量可以容纳4个双精度或8个单精度变量。

缺点是:

  1. 不支持长双精度。
  2. 在运算数具有混合精度的表达式的计算需要精确的转换指令,这可能非常耗时(参见14.7 不要混合使用 float 和 double)。
  3. 数学函数必须使用函数库,但这通常比硬件函数更快。

浮点栈寄存器在所有具有浮点功能的系统中都可用(64位Windows 的设备驱动程序除外)。XMM 向量寄存器可以在64位系统中使用,也可以在启用SSE2 或更高的指令集时在 32位系统中使用(单精度只需要SSE )。如果处理器和操作系统支持AVX 指令集,则可以使用YMM 寄存器。有关如何测试这些指令集的可用性,请参见13 为不同指令集生成多个版本的关键代码

XMM 寄存器可用时,大多数编译器都会使用它进行浮点计算,例如在 64位系统中,或者启用SSE2 指令集时。很少有编译器能够混合这两种类型的浮点运算,并为每种计算选择最优的类型。

在大多数情况下,双精度运算不会比单精度运算花费更多的时间。当使用浮点寄存器时,单精度和双精度的速度没有差别。长双精度只需要稍多一点的时间。单精度的除法、平方根和数学函数的计算速度都快于双精度。当使用XMM 寄存器时,加、减、乘等操作的速度,无论精度如何,在大多数处理器上仍是相同的(未使用向量操作)。

如果对应用程序有利,可以使用双精度,而不必太担心成本。如果你有大数组,并且希望将尽可能多的数据放入数据缓存,则可以使用单精度。如果可以利用向量操作,单精度就很好,如12 使用向量操作所述。

根据微处理器的不同,浮点加法需要3 - 6个时钟周期。乘法需要 4 - 8 个时钟周期。除法需要 14 - 45 个时钟周期。当使用浮点栈寄存器时,浮点比较效率不高。在使用浮点堆栈寄存器时,浮点或双精度浮点到整数的转换需要很长时间。

当使用XMM 寄存器时,不要混合使用单精度和双精度。参见14.7 不要混合使用 float 和 double

如果可能的话,避免整数和浮点变量之间的转换。见14.8 浮点数和整数相互转换

XMM 寄存器中长生浮点浮点数向下溢出的应用程序,可以从flush-to- zero 模式而不是在向下溢出的情况下生成非规格化数(subnormal number)中获益:

1
2
3
4
// Example 7.5. Set flush-to-zero mode (SSE):

#include <xmmintrin.h>
_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);

强烈建议设置为 flush-to-zero 模式,除非有特殊原因需要使用非规格化数。此外,如果SSE2 可用,你可以设置为 denormars-are-zero 模式:

1
2
3
4
// Example 7.6. Set flush-to-zero and denormals-are-zero mode (SSE2):

#include <xmmintrin.h>
_mm_setcsr(_mm_getcsr() | 0x8040);

有关数学函数的更多信息,请参阅14.10 数学函数12. 7 用于向量的数学函数

7.4 枚举

enum只是一个隐藏的整数。枚举的效率和整数一样。注意,枚举数(值名)将与具有相同名称的任何变量或函数冲突。因此,头文件中的枚举应该具有长且唯一的枚举数名称,或者将其放在命名空间中。

7.5 布尔值

布尔操作数的顺序

布尔运算符 &&|| 的操作数将会按照下面的顺序就行计算。如果 && 的第一个操作数为 false,那么就不会计算第二个操作数的值,因为表达式的结果无论第二个操作数的值是 true 还是 false,结果都为 false。类似的,如果 || 的第一个操作数为 true,那么也不会计算第二个操作数的值,因为无论如何结果都为 true

将通常为 true 的操作数放在 && 表达式的最后,或者作为 || 表达式的第一个操作数中,这可能是有好处的。例如,假设 a 在 50% 的情况下为真,b在 10% 的情况下为真。当 a 为真时,表达式a && b需要对 b求值,即 50% 的情况。等价表达式 b && a只需要在 btrue 时对 a 求值,只有 10% 的情况。如果 ab 的计算时间相同,并且分支预测机制预测的可能性相同,这样的计算速度会更快。有关分支预测的解释,请参见7.12 分支及 switch 语句

如果一个操作数的可预测性比另一个更好,那么将最可预测的操作数放在前面。

如果一个操作数的计算速度快于另一个操作数,则将计算速度最快的操作数放在第一位。

但是,在交换布尔操作数的顺序时必须小心。如果操作数的求值有副作用,或者如果第一个操作数决定第二个操作数是否有效,则不能交换操作数。例如:

1
2
3
4
5
6
// Example 7.7

unsigned int i;
const int ARRAYSIZE = 100;
float list[ARRAYSIZE];
if (i < ARRAYSIZE && list[i] > 1.0) { ...

在这里,你不能交换操作数的顺序,因为当 i 不小于 ARRAYSIZE 时,表达式 list[i] 是无效的。另一个例子:

1
2
3
// Example 7.8

if (handle != INVALID_HANDLE_VALUE && WriteFile(handle, ...)) { ...

布尔变量被过度检查(overdetermined)

布尔变量存储在 8位整数中,0 代表 false, 1 代表 true

由于所有以布尔变量作为输入的运算符都要检查输入是否有除01之外的值,因此布尔变量会被过度检查。但是以布尔值作为输出的运算符只能产生 0 或 1。这使得使用布尔变量作为输入的操作效率低于可能的效率。比如说:

1
2
3
4
5
// Example 7.9a

bool a, b, c, d;
c = a && b;
d = a || b;

编译器通常是以以下方式实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
bool a, b, c, d;
if (a != 0)
{
if (b != 0)
{
c = 1;
}
else
{
goto CFALSE;
}
}
else
{
CFALSE:
c = 0;
}
if (a == 0)
{
if (b == 0)
{
d = 0;
}
else
{
goto DTRUE;
}
}
else
{
DTRUE:
d = 1;
}

这当然远远不是最优的。为了防止错误的预测,这些分支可能耗费很长的时间(详见7.12 分支及 switch 语句)。如果知道操作数除了 0 就是 1,布尔运算可以变得有效率的多。编译器之所以不这样假设,是因为如果变量是没有被初始化的或者是来自其它未知来源的。如果 ab 被初始化为有效的值,或者他们来自输为布尔值的运算符,那么上述代码是可以被优化的。优化后的代码类似下面这样:

1
2
3
4
5
// Example 7.9b

char a = 0, b = 0, c, d;
c = a & b;
d = a | b;

在这里我使用 char(或 int)来代替 bool ,是为了使用位运算符(&|)来代替布尔运算符(&&||)。位运算符是单条指令,只需一个时钟周期。即使 ab 不是 0 或 1,OR运算符(|)也可以正常工作。如果操作数有不是 0 或 1 的值,AND&)运算符和EXCLUSIVE OR运算符(^)可能会给出不一致的结果。

请注意到这里有几个陷阱(pitfalls)。你不能使用 ~ 代替 NOT。相反,如果已知变量的值为 01,你可以对变量 XOR1,来对变量进行取反操作(Boolean NOT)。

1
2
3
4
// Example 7.10a

bool a, b;
b = !a;

可以被优化为:

1
2
3
4
// Example 7.10b

char a = 0, b;
b = a ^ 1;

如果当 afalse 时,表达式不应该被计算的话,你不能使用 a&b 代替 a&&b。类似的,如果当 atrue 时,表达式不应该被计算的话,不能用 a|b 代替 a||b

使用位运算符的技巧在操作数是变量而不是比较表达式等其它情况时,更有优势。例如:

1
2
3
4
// Example 7.11

bool a; float x, y, z;
a = x > y && z != 0;

上述形式在大多数情况下通常是最优的。不要把 && 改成 &,除非你希望 && 表达式产生很多错误的分支预测。

布尔向量操作:

一个整数可以被当作布尔向量使用。例如,如果 ab是 32位整数,那么表达式 y=a&b,将会在一个时钟周期中进行32个与操作。运算符 &|^~ 在进行布尔向量操作时都非常常用。

7.6 指针和引用

指针 VS 引用

指针和引用的效率是一样的,因为它们实际上做的事情是相同的。例如:

1
2
3
4
5
6
7
8
9
10
// Example 7.12

void FuncA (int * p)
{
*p = *p + 2;
}
void FuncB (int & r)
{
r = r + 2;
}

这两个函数做的是相同的事情,如果你查看编译器生成的代码时,你会注意到这两个函数的代码完全相同,区别仅仅在于编程风格。使用指针的优点是:

  1. 当你查看上面的函数体时,可以清楚地看到 p 是一个指针,但是不清楚 r 是一个引用还是一个简单的变量。使用指针使读者更清楚地了解正在发生的事情。
  2. 可以用指针做引用不可能做的事情。你可以改变指针指向什么,你可以用指针做算术运算。

使用引用的优点是:

  1. 使用引用时语法更简单。
  2. 引用比指针更安全,因为在大多数情况下,它们肯定指向一个有效的地址。如果指针没有初始化,或者指针算术计算超出了有效地址的范围,亦或是指针被转换为错误的类型,指针可能无效并导致致命错误。
  3. 对于复制构造函数和重载运算符来说,引用更加常用。
  4. 被声明为常量引用的函数参数接受表达式作为参数,而指针和非常量引用则需要一个变量。

效率

通过指针或引用访问变量或对象可能与直接访问它一样快。拥有这种效率的原因在于微处理器的构造方式。函数中声明的所有非静态变量和对象都存储在堆栈中,并且实际上是相对于栈指针寻址的。同样,我们知道在C++ 中。类中声明的所有非静态变量和对象都可以通过已知的隐式指针 ‘this’ 进行访问。因此,我们可以得出这样的结论:结构良好的C++ 程序中的大多数变量实际上都是通过指针这种方式访问的,这就是效率相同的原因。然而,使用指针和引用也有缺点。最重要的是,它需要一个额外的寄存器来保存指针或引用的值。寄存器是一种稀缺资源,尤其是在 32位模式下。如果没有足够的寄存器,那么指针每次使用时都必须从内存中加载,这会使程序变慢。另一个缺点是指针的值需要几个时钟周期才能访问所指向的变量。

指针算术

指针实际上是一个保存内存地址的整数。因此,指针算术运算和整数算术运算一样快。当一个整数被添加到一个指针时,它的值乘以所指向的对象的大小。例如:

1
2
3
4
5
// Example 7.13

struct abc {int a; int b; int c;};
abc * p; int i;
p = p + i;

在这里,p 所增加的值不是 i 而是 i*12,因为 abc 的大小是12个字节。p 加上 i 的时间等于做乘法和加法的时间的和。如果 abc 的大小是 2 的幂,那么乘法可以被移位运算代替,移位运算要快得多。在上面的示例中,通过向结构体中添加一个整数,abc的大小可以增加到 16 字节。

递增或递减指针不需要乘法,只需要加法。比较两个指针只需要一个整数比较,这是很快的。计算两个指针之间的差值需要一个除法,这是很慢的,除非所指向的对象类型的大小是 2 的幂(关于除法,请参阅14.5 整数运算)。

在计算指针的值之后,访问所指向的对象大约需要两个时钟周期。因此,建议在使用指针之前计算该指针的值。例如,x = *(p++)x = *(++p) 更高效,因为在后一种情况下,x 的读取必须在指针 p 被递增后等待几个时钟周期,而在前一种情况下,x 可以在 p 在递增之前被读取。有关递增和递减运算符的更多讨论,请参见 7.2 整型变量和运算符 关于递增和递减运算的讨论。

7.7 函数指针

如果可以预测目标地址,那么通过函数指针调用函数通常要比直接调用函数多花几个时钟周期。如果函数指针的值与上次执行语句时相同,则可以预测目标地址。如果函数指针的值发生了变化,那么目标地址很可能会被错误地预测,从而导致长时间的延迟。有关分支预测,请参见7.12 分支和 switch 语句。如果函数指针的变化遵循一个简单的规则,奔腾M 处理器可能能够预测目标地址,而奔腾4 和 AMD 处理器只要函数指针发生了变化,就一定会做出错误的预测。

7.8 成员指针(Member pointers)

在简单的情况下,数据成员指针只是存储数据成员相对于对象开头的偏移量,而成员函数指针只是成员函数的地址。但是在一些特殊的情况下,比如需要实现复杂的多的多重继承。一定要避免这些复杂的情况。

如果编译器没有关于成员指针所引用的类的完整信息,那么它必须使用最复杂的成员指针实现。例如:

1
2
3
4
// Example 7.14

class c1;
int c1::*MemberPointer;

在这里,定义 MemberPointer 时,编译器除了类 c1 的名称之外,没有其他关于类 c1 的信息。因此,它必须假设这是最坏的情况,并对成员指针进行复杂的实现。可以通过在声明 MemberPointer 之前完整地声明 c1 来避免这种情况。避免多重继承虚函数 和其他会降低成员指针效率的复杂情况。

大多数C++ 编译器都有不同的选项来控制成员指针的实现方式。如果可能的话,使用尽可能简单的实现选项,并确保对使用相同成员指针的所有模块使用相同的编译器选项。

7.9 智能指针

智能指针是一个行为像指针的对象。它有一个特殊的特性,当指针被删除时,它所指向的对象被删除。智能指针仅用于使用 new 存储在动态分配内存中的对象。使用智能指针的目的是为了确保对象被正确删除,以及在对象不再使用时释放内存。智能指针可以被认为是只包含单个元素的容器。

智能指针最常见的实现是 auto_ptrshared_ptrauto_ptr的特性是,始终只有一个 auto_ptr 拥有所分配的对象,并且通过赋值将所有权从一个 auto_ptr 转移到另一个 auto_ptrshared_ptr 则允许多个指针指向同一个对象。

通过智能指针访问对象没有额外的成本。无论 p 是简单指针还是智能指针,通过 *pp->member 访问对象的速度都是一样快的。但是,每当创建、删除、复制或从一个函数转移到另一个函数时,都会产生额外的成本。shared_ptr 的这些成本要高于 auto_ptr

当程序的逻辑结构要求一个对象必须由一个函数动态创建,然后由另一个函数删除,并且这两个函数互不相关(不是同一个类的成员)时,智能指针非常有用。如果相同的函数或类负责创建和删除对象,则不需要使用智能指针。

如果一个程序为每个智能指针使用许多动态分配的小对象,那么需要考虑一下这个解决方案的成本是否太高。将所有对象合用到一个容器中可能更有效,最好是使用连续内存。参见9.7 容器类关于容器类的讨论。

7.10 数组

数组是通过在内存中连续存储元素来实现的。没有存储关于数组大小的信息。这使得在 CC++ 中使用数组比在其他编程语言中更快,但也更不安全。这个安全问题可以通过定义一个容器类来解决,该类的行为类似于一个带有边界检查的数组,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Example 7.15a. Array with bounds checking

template <typename T, unsigned int N> class SafeArray
{
protected:
T a[N]; // Array with N elements of type T39
public:
SafeArray()
{
// Constructor
memset(a, 0, sizeof(a)); // Initialize to zero
}
int Size()
{
// Return the size of the array
return N;
}
T & operator[] (unsigned int i)
{
// Safe [] array index operator
if (i >= N)
{
// Index out of range. The next line provokes an error.
// You may insert any other error reporting here:
return *(T*)0; // Return a null reference to provoke error
}
// No error
return a[i]; // Return reference to a[i]
}
};

www.agner.org/optimize/cppexamples.zip中给出更多关于容器类的例子。

使用上述模板类的数组是通过将类型和大小指定为模板参数来声明的,如下面的例7.15b所示。可以使用方括号索引访问它,就像普通数组一样。构造函数将所有元素设置为零。如果你不希望这个初始化,或者类型 T 是一个具有默认构造函数的类,它会执行必要的初始化,那么可以删除 memset 这一行。编译器可能会报告 memset 已被弃用。这是因为如果参数 size 错误,它会导致错误,但它仍然是将数组设置为 0 的最快方法。如果索引超出范围,[] 运算符将检测到错误(参见14.2 边界检查)。在这里,通过返回空引用这一非常规的方式引发错误消息。如果数组元素被访问,这将在受保护的操作系统中引发错误消息,并且这个错误很容易通过调试器跟踪。你可以用任何其他形式的错误报告来替换这一行。例如,在Windows 中,你可以这么写:FatalAppExitA(0,"Array index out of range");,更好方法的是创建自己的错误消息函数。

下面的例子演示了如何使用SafeArray

1
2
3
4
5
6
7
8
// Example 7.15b

SafeArray <float, 100> list; // Make array of 100 floats
for (int i = 0; i < list.Size(); i++)
{
// Loop through array
cout << list[i] << endl; // Output array element
}

由列表初始化的数组最好是静态的,如7.1节,如全局或静态存储 中所述。数组可以使用 memset初始化为 0:

1
2
3
4
// Example 7.16

float list[100];
memset(list, 0, sizeof(list));

应该按顺序访问多维数组,保证最后一个索引变化最快(最后一个索引用于最内层循环):

1
2
3
4
5
6
7
8
// Example 7.17

const int rows = 20, columns = 50;
float matrix[rows][columns];
int i, j; float x;
for (i = 0; i < rows; i++)
for (j = 0; j < columns; j++)
matrix[i][j] += x;

这确保了元素是按顺序访问的。这两个循环的相反顺序将使访问是非顺序的,这将降低数据缓存的效率。当不按顺序索引时,为了使地址的计算更高效,那么除了第一个维度外,所有维度的大小最好是 2 的幂:

1
2
3
4
5
6
7
8
// Example 7.18

int FuncRow(int); int FuncCol(int);
const int rows = 20, columns = 32;
float matrix[rows][columns];
int i; float x;
for (i = 0; i < 100; i++)
matrix[FuncRow(i)][FuncCol(i)] += x;

在这里,代码必须计算 (FuncRow(i)*columns + FuncCol(i)) * sizeof(float) 才能找到矩阵元素的地址。在这种情况下,当大小是 2 的幂时,乘上列数的速度更快。在前面的示例中,这不是问题,因为优化编译器可以看到这些行是连续访问的,并且可以通过将行长度添加到前一行的地址来计算每行的地址。

同样的建议也适用于结构体或类对象的数组。如果以非顺序访问元素,则对象的大小(以字节为单位)最好为 2 的幂。

将列数设置为 2 的幂的建议并不总是适用于大于一级数据缓存且以非顺序访问的数组,因为这可能会导致缓存竞争。关于这个问题的讨论请参阅9 优化内存访问

7.11 类型转换

C++ 语法有几种不同的类型转换方法:

1
2
3
4
5
6
7
// Example 7.19

int i; float f;
f = i; // Implicit type conversion
f = (float)i; // C-style type casting
f = float(i); // Constructor-style type casting
f = static_cast<float>(i); // C++ casting operator

这些不同的方法有完全相同的效果。使用哪种方法取决于编程风格。下面讨论不同类型相互转换的时间消耗。

signed/unsigned 转换

1
2
3
4
// Example 7.20

int i;
if ((unsigned int)i < 10) { ...

有符号整数和无符号整数之间的转换只是使编译器换一种解释整数的不同位的方法。没有检查溢出,代码没有消耗额外的时间。这些转换可以随意使用,而不需要担心任何性能成本。

整型类型大小的转换

1
2
3
4
// Example 7.21

int i; short int s;
i = s;

如果整数有符号,则通过扩展符号位将其转换为更大的类型;如果没有符号,则通过扩展 0 将其转换为更大的类型。如果代码是算术表达式,这通常需要一个时钟周期。如果是从内存中的变量中读取值,则类型大小转换通常不需要额外的时间,如例7.22所示:

1
2
3
4
5
// Example 7.22

short int a[100]; int i, sum = 0;
for (i=0; i<100; i++)
sum += a[i];

将整数转换成较小的类型大小只需忽略较高的位即可。没有溢出检查。例如:

1
2
3
4
// Example 7.23

int i; short int s;
s = (short int)i;

这种转换不需要额外的时间。它只存储 32 位整数中较低的 16 位。

浮点精度转换

当使用浮点寄存器栈时,浮点、双精度和长双精度之间的转换不需要额外的时间。使用XMM 寄存器时,需要 2 到 15个时钟周期(取决于处理器)。有关寄存器栈与XMM 寄存器的区别,请参见7.3 浮点变量和运算符。例如:

1
2
3
4
// Example 7.24

float a; double b;
a += b;

在本例中,如果使用XMM 寄存器,那么转换的成本很高。为了避免这种情况,ab 应该是同一类型的。进一步讨论请参阅14.7 不要混合使用 float 和 double

整型转换为浮点型

将有符号整数转换为浮点数或双精度浮点数需要 4 - 16个时钟周期,这取决于处理器和使用的寄存器类型。无符号整数的转换需要更长的时间。如果没有溢出的风险,首先将无符号整数转换为有符号整数会更快:

1
2
3
4
// Example 7.25

unsigned int u; double d;
d = (double)(signed int)u; // Faster, but risk of overflow

整型到浮点型的转换有时可以通过将整数替换为浮点型变量来避免。例如:

1
2
3
4
// Example 7.26a

float a[100]; int i;
for (i = 0; i < 100; i++) a[i] = 2 * i;

在本例中,可以通过添加一个浮点变量来避免 i 转换为 float:

1
2
3
4
5
// Example 7.26b

float a[100]; int i; float i2;
for (i = 0, i2 = 0; i < 100; i++, i2 += 2.0f)
a[i] = i2;

浮点型转换为整型

如果不启用SSE2 或者更新的指令集,浮点数到整数的转换将花费很长的时间。通常,转换需要 50 - 100 个时钟周期。原因是C/ C++ 标准指定了截断,因此浮点四舍五入的模式必须更改为截断并再次返回。如果在代码的关键部分存在浮点数到整数的转换,那么对其采取一些措施是很重要的。可能的解决方案有:

  1. 使用不同类型的变量避免转换。
  2. 通过将中间结果存储为浮点数,将转换移出最内层循环。
  3. 使用 64位模式或启用SSE2 指令集(需要一个支持该模式的微处理器)。
  4. 使用四舍五入代替截断,并用汇编语言制作一个舍入函数。有关舍入的详细信息,请参见14.8 浮点数和整数相互转换

指针类型转换

指针可以转换为另一种类型的指针。同样,可以将指针转换为整数,也可以将整数转换为指针。确保整数有足够的位来保存指针是很重要的。

这些转换不会产生任何额外的代码。这仅仅是用不同的方式解释相同的位或者绕过语法检查的问题。

当然,这些转换是不安全的。程序员有责任确保结果是有效的。

重新解释对象

通过类型转换它的地址,编译器可以将一个变量或对象当作另一个不同的类型来处理:

1
2
3
4
// Example 7.27

float x;
*(int*)&x |= 0x80000000; // Set sign bit of x

这里的语法可能看起来有点奇怪。将 x 的地址类型转换为指向整数的指针,然后对该指针取值,以便将 x 作为整数访问。编译器不会生成任何额外的代码来实际创建指针。指针被简单地优化掉了,结果是 x 被当作一个整数。但是,运算符强制编译器将 x 存储在内存中,而不是寄存器中。上面的示例使用 | 运算符设置 x 的符号位,只能应用于整数。这样操作比 x = -abs(x) 更快。

在类型转换指针时,有许多危险的地方需要注意:

  1. 这个技巧违反了标准 C 的严格的别名规则,规定不同类型的两个指针不能指向相同的对象( char 指针除外)。优化编译器可以将浮点数和整数表示形式存储在两个不同的寄存器中。你需要检查编译器是否按照你希望的方式运行。使用 union 会更安全,如14.9 用整数操作来改变浮点型变量例 14.23所示。
  2. 如果将对象视为比实际更大的对象,这个技巧就会失效。如果 intfloat 使用更多的位,上面的代码将会失败。(两者在 x86系统中都使用 32个位)。
  3. 如果你只访问变量的一部分,例如 64位双精度浮点数中的 32位,那么代码将无法移植到使用大端存储的平台上。
  4. 如果你以部分访问的方式访问变量,例如,如果你一次操作 64位 double 类型的32位,那么由于 CPU 中的存储转发延迟,代码的执行速度可能会低于预期(参见手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。

const_cast

const_cast 运算符用于解除 const 对指针的限制。它有一些语法检查,因此比 C 风格的类型转换更加安全,而无需添加任何额外的代码。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Example 7.28

class c1
{
const int x; // constant data
public:
c1() : x(0) {}; // constructor initializes x to 0
void xplus2()
{
// this function can modify x
*const_cast<int*>(&x) += 2;
} // add 2 to x
};

这里 const_cast 运算符的作用是消除 x 上的 const 限制,这是一种解除语法限制的方法,但它不会生成任何额外的代码,也不会花费任何额外的时间。这是确保一个函数可以修改 x,而其他函数不能修改 x 的有用方法。

static_cast

static_cast 运算符的作用与 C 风格的类型转换相同。例如,它用于将 float 转换为 int

reinterpret_cast

reinterpret_cast 运算符用于指针转换。它的作用与 C 风格类型转换相同,只是多了一点语法检查。它不产生任何额外的代码。

dynamic_cast

dynamic_cast 运算符用于将指向一个类的指针转换为指向另一个类的指针。它在运行时检查转换是否有效。例如,当基类的指针转换为派生类的指针时,它检查原始指针是否实际指向派生类的对象。这种检查使得 dynamic_cast 比简单类型的转换更耗时,但也更安全。它可以捕获那些无法检测到的编程错误。

转换类对象

只有当程序员定义了构造函数、重载赋值运算符或重载类型转换运算符(指定如何进行转换)时,才有可能进行涉及类对象(而不是指向对象的指针)的转换。构造函数或重载运算符与成员函数的效率是一样的。

7.12 分支和switch语句

现代微处理器的高速运转是通过一个流水线(pipeline)来实现的,指令在执行之前,会在不同的几个阶段中被提取和解码。然而,流水线结构有一个大问题。当代码有分支时(例如 if-else),微处理器事先不知道选取这两个分支中哪个分支的数据送入流水线。如果错误的分支被输入到管道中,那么需要 10 - 20 个时钟周期之后才能检测到错误,在这段时间内,它通过获取、解码和推测性地执行指令所做的工作已经被浪费了。结果是,每当微处理器将一个分支送入到管道中,然后发现它选择了错误的分支时,它就会浪费几个时钟周期。

微处理器设计者已经竭尽全力减少这个问题的发生。其中最重要的方法是分支预测。现代微处理器使用先进的算法,根据该分支和附近其他分支的过去历史来预测分支的发展方向。对于不同类型的微处理器,用于分支预测的算法是不同的。这些算法在手册3“The microarchitecture of Intel, AMD and VIA CPUs”中有详细的描述。

在微处理器做出正确预测的情况下,执行分支指令通常需要 0 - 2 个时钟周期。根据处理器的不同,从分支错误预测中恢复所需的时间大约为 12 - 25 个时钟周期。这被称为分支预测错误的惩罚。

如果大多数时候分支是可预测的,那么它们的消耗很少;但是如果预测错误,那么它们的消耗就大了。当然,总是沿着同一方向发展的分支是可预测的。一个分支在大多数情况下是单向的,很少是反向的,只有当它向另一个方向发展时,才会预测错误。一个分支向一个方向走了很多次,然后又向另一个方向走了很多次,只有当它发生变化时才会被错误地预测。如果一个遵循简单周期模式的分支,在一个有很少或没有其它分支的循环中,那么也可以很好地被预测。一个简单的周期模式可以是,例如,一条路走两遍,另一条路走三遍。同样的,两倍第一种方法,三倍另一种方法,等等。最坏的情况是一个分支随机地向一个方向或另一个方向移动,任意方向的概率都是50%。这样的分支有50%的几率会被错误的预测。

for 循环或 while 循环也是一种分支。在每次迭代之后,它决定是重复还是退出循环。如果重复计数很小且始终相同,则通常可以很好地预测循环分支。根据处理器的不同,可以完美预测的最大循环数在 9 到 64 之间变化。嵌套循环只能在某些处理器上得到很好的预测。在许多处理器上,包含多个分支的循环并不能很好地被预测。

switch 语句也是一种分支,它可以有两个以上的分支。如果 case 标签是遵循每个标签等于前一个标签加 1 的序列,在这个时候 switch语句的效率是最高的,因为它可以被实现为一个目标跳转表。如果 switch 语带有许多标签值,并且彼此相差较大,这将是低效的,因为编译器必须将其转换成一个分支树。

在较老的处理器上,会简单的认为带有顺序标签的 switch 语句的执行分支与上一次相同。因此,无论何时它走了不是上次走的分支,它肯定会被错误地预测。较新的处理器有时能够预测一个 switch 语句,如果它遵循一个简单的周期模式,或者它与前面的分支相关,并且不同目标的数量很少的话。

分支和 switch 语句的数量最好在程序的关键部分控制在较少的水平,特别是在分支的可预测性较差的情况下。如果展开循环可以消除分支,那这可能是有用的,这将在下一段中解释。

分支和函数调用的目标保存在称为分支目标缓冲区的特殊缓存中。如果一个程序中有许多分支或函数调用,那么在分支目标缓冲区中就可能产生竞争。这种竞争的结果是,即使分支具有良好的可预测性,它们也可能被错误地预测。由于这个原因,甚至函数调用也可能被错误地预测。因此,在代码的关键部分具有许多分支和函数调用的程序可能会收到预测错误的影响。

在某些情况下,可以用表查找来替换难以预测的分支。例如:

1
2
3
4
// Example 7.29a

float a; bool b;
a = b ? 1.5f : 2.6f;

?: 运算符在这里就是一个分支。如果它的可预测性很差,那么可以用一个查找表来代替它:

1
2
3
4
5
// Example 7.29b

float a; bool b = 0;
const float lookup[2] = {2.6f, 1.5f};
a = lookup[b];

如果将 bool 变量用作数组索引,那么需要确保它被初始化或来自可靠的源,这非常重要,这样它除了 0 或 1 之外就不会有其他值。见7.5 布尔值:布尔变量被过度检查

在某些情况下,编译器可以根据指定的指令集,自动使用条件转移来代替分支。

14.1 使用查找表14.3 使用位运算符一次检查多个值中的例子展示了减少分支数量的各种方法。

手册3:“The microarchitecture of Intel, AMD and VIA CPUs”提供了不同微处理器中分支预测的更多细节。

7.13 循环

循环的效率取决于微处理器对循环控制分支的预测能力。有关分支预测的说明,请参阅前文和手册3:“The microarchitecture of Intel, AMD and VIA CPUs”。一个具有一个较小并且固定的重复计数,没有分支的循环,可以完美地被预测。如上所述,可以预测的最大循环数取决于处理器。只有在某些具有特殊循环预测器的处理器上,嵌套循环才能被很好地预测。在其他处理器上,只能很好地预测最内层的循环。只有在循环退出时,才会错误地预测具有高重复计数的循环。例如,如果一个循环重复 1000次,那么循环控制分支在 1000次中只会出现一次错误预测,因此错误预测的惩罚对总执行时间的影响可以忽略不计。

循环展开

在某些情况下,展开循环可能有很多好处。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Example 7.30a

int i;
for (i = 0; i < 20; i++)
{
if (i % 2 == 0)
{
FuncA(i);
}
else
{
FuncB(i);
}
FuncC(i);
}

这个循环重复 20 次,交替调用 FuncAFuncB,然后是 FuncC。展开两个给出循环得到:

1
2
3
4
5
6
7
8
9
10
// Example 7.30b

int i;
for (i = 0; i < 20; i += 2)
{
FuncA(i);
FuncC(i);
FuncB(i+1);
FuncC(i+1);
}

这么做有三个好处:

  1. i<20 循环控制分支执行 10 次而不是 20 次。
  2. 重复计数已经从 20 减少到 10,这意味着可以在奔腾4上完美地进行预测。
  3. if 分支被消除。

展开循环同样也有缺点:

  1. 展开循环在代码缓存或micro-op 缓存中占用更多空间。
  2. 非常小的循环(少于65个字节的代码)在 Core2 处理器上可以执行得更好。
  3. 如果重复计数为奇数,并将其展开为 2,则必须在循环之外执行额外的迭代。通常,当重复计数不能被展开因子整除时,就会出现这种问题。

只有在能够取得特定好处的情况下,才应该使用循环展开。如果一个循环包含浮点运算,且循环计数器是整数,那么通常可以假设整个计算时间是由浮点代码决定的,而不是由循环控制分支决定的。在这种情况下,展开循环是没有任何好处的。

最好避免在有micro-op 缓存的处理器上展开循环。因为节省mircro-op 缓存的使用非常重要。

如果有利可图的话(见8.1 编译器是如何优化的:循环展开), 编译器通常会自动展开一个循环。程序员不必手动展开循环,除非需要获得特定的优势,例如消除 例 7.30b中的 if 分支。

循环控制条件

最高效的循环控制条件是一个简单的整数计数器。一个具有无序功能的微处理器(参见11 乱序执行)将能够在几个迭代之前评估循环控制语句。

如果循环控制分支依赖于循环内部的计算,则效率较低。下面的示例将以零结束的 ASCII 字符串转换为小写:

1
2
3
4
5
// Example 7.31a

char string[100], *p = string;
while (*p != 0)
*(p++) |= 0x20;

如果字符串的长度是已知的,那么使用循环计数器效率会更高:

1
2
3
4
5
// Example 7.31b

char string[100], *p = string; int i, StringLength;
for (i = StringLength; i > 0; i--)
*(p++) |= 0x20;

在数学迭代中,循环控制分支依赖于循环内部的计算是一种常见情况,如泰勒展开和牛顿-拉弗森迭代。在这里,需要重复迭代,直到残差小于一定的公差。计算残差的绝对值并将其与公差进行比较所需的时间可能会很长,因此确定最坏情况下的最大重复计数并始终使用此迭代次数的效率会更高。这种方法的优点是微处理器可以提前执行循环控制分支,并在循环内的浮点运算完成之前解决任何分支的错误预测。如果典型的重复计数接近最大重复计数,且每次迭代的残差计算对总计算时间有显著贡献时,则该方法是有好处的。

循环计数器最好是整数。如果循环需要浮点计数器,那么创建一个额外的整数计数器。例如:

1
2
3
4
5
// Example 7.32a

double x, n, factorial = 1.0;
for (x = 2.0; x <= n; x++)
factorial *= x;

这可以通过添加一个整数计数器并在循环控制条件中使用整数来提升效率:

1
2
3
4
5
// Example 7.32b

double x, n, factorial = 1.0; int i;
for (i = (int)n - 2, x = 2.0; i >= 0; i--, x++)
factorial *= x;

注意带有多个计数器的循环中的逗号和分号之间的区别,如例 7.32b所示。for 循环有三个子句:初始化、条件和增量。这三个子句用分号分隔,每个子句中的多个语句用逗号分隔。条件子句中应该只有一个语句。

将整数与零进行比较有时比将其与任何其他数字进行比较的效率更高。因此,将循环计数减少到 0 比将其增加到某个正值 n 要稍微快一些。但如果循环计数器用作数组索引,则不是这样。数据缓存是为向前而不是向后访问数组而优化的。

复制或清除数组

对于诸如复制数组或将数组中的元素全部设置为零这样的琐碎任务,使用循环可能不是最佳选择。例如:

1
2
3
4
5
6
7
8
9
10
// Example 7.33a

const int size = 1000; int i;
float a[size], b[size];
// set a to zero
for (i = 0; i < size; i++)
a[i] = 0.0;
// copy a to b
for (i = 0; i < size; i++)
b[i] = a[i];

使用memsetmemcpy函数通常会更快:

1
2
3
4
5
6
7
8
// Example 7.33b

const int size = 1000;
float a[size], b[size];
// set a to zero
memset(a, 0, sizeof(a));
// copy a to b
memcpy(b, a, sizeof(b));

至少在简单的情况下,大多数编译器会自动使用 memsetmemcpy 的替换这些循环。显式地使用 memsetmemcpy 是不安全的,因为如果参数 size 大于目标数组的大小,可能会发生严重地错误。但是如果循环计数太大,同样的错误也会发生在循环中。

7.14 函数

函数调用可能会使程序慢下来,原因如下:

  1. 函数调用使微处理器跳转到不同的代码地址,然后再返回。这可能需要4个时钟周期。在大多数情况下,微处理器能够将调用和返回操作与其他计算重叠以节省时间。
  2. 如果代码分散在内存中,那么代码缓存的效率就会降低。
  3. 在 32 位模式下,函数参数存储在堆栈中。将参数存储在堆栈上并再次读取它们需要额外的时间。如果参数是关键依赖链的一部分,这个延迟是很明显的。
  4. 需要额外的时间来设置栈帧(stack frame)、保存和恢复寄存器,可能还需要保存异常处理信息。
  5. 每个函数调用语句需要在分支目标缓冲区(BTB)中占用空间。如果程序的关键部分有许多调用和分支,BTB 中的竞争可能导致分支预测错误。

以下方法可用于减少在程序关键部分中,在函数调用上所花费的时间。

避免不必要的函数

一些编程教科书建议,长度超过几行的每个函数都应该分成多个函数。我不同意这个规则。将一个函数分解成多个更小的函数只会降低程序的效率。仅仅因为一个函数很长就拆分它并不会使得程序更清晰,除非这个函数正在执行多个逻辑上不同的任务。如果可能的话,关键的最内层循环最好完全保留在一个函数中。

使用内联函数

内联函数会像宏一样展开,因此调用该函数的每个语句都会被函数体替换。如果使用了 inline 关键字,或者在类定义中定义了函数的主体,那么函数通常是内联的。如果函数很小,或者只在程序中的一个位置调用它,那么内联函数是有好处的。小函数通常由编译器自动内联。另一方面,在某些情况下,如果内联会导致技术问题或性能问题,编译器可能会忽略对函数内联的请求。

避免在最内层循环中嵌套函数调用

调用其他函数的函数称为帧函数(frame function),而不调用任何其他函数的函数称为叶函数(leaf function)。叶函数比框架函数更高效,原因见第63页(TODO)。如果程序关键的最内层循环包含对帧函数的调用,那么代码有可能通过内联帧函数或使帧函数调用的所有函数内联(把帧函数变为叶函数)来提升效率。

使用宏代替函数

#define 声明的宏肯定是内联的。但是要注意,宏的参数每次使用时都会被重新计算。例如:

1
2
3
4
// Example 7.34a. Use macro as inline function

#define MAX(a,b) (a > b ? a : b)
y = MAX(f(x), g(x));

在这个例子中,f(x)g(x) 被计算了两次,因为宏引用了它们两次。你可以通过使用内联函数而不是宏来避免这种情况。如果你想让函数可以使用任何类型的参数,那么可以使用模板:

1
2
3
4
5
6
7
// Example 7.34b. Replace macro by template

template <typename T>
static inline T max(T const & a, T const & b)
{
return a > b ? a : b;
}

宏的另一个问题是名称不能重载或限制作用区域。宏将干扰具有相同名称的任何函数或变量,而与作用域或命名空间无关。因此,对于宏来说,使用足够长且唯一的名称非常重要,这在头文件中尤其重要。

使用 fastcall 函数

在 32位模式下,关键字 __fastcall 将会改变函数的调用方式,使用寄存器而不是栈来传递前两个整型参数(CodeGear 编译器则是前三个)。这可以提升拥有整型参数的函数的速度。

浮点型参数则不会被 __fastcall 影响。成员函数中隐藏的 'this' 指针也被视为一个参数,所以可能只剩下一个空闲寄存器用于传输其他参数。因此,确保在使用__fastcall 时,最关键的整数参数放在第一位。64位模式下的函数参数默认是使用寄存器传递的。因此,64位模式下无法识别 __fastcalll 关键字。

使函数局部化

应该使同一个模块中使用的函数(即当前.cpp 文件)是局部的。这使得编译器更容易将函数内联,并对函数调用进行优化。有三种方法使一个函数局部化:

  1. 将关键字 static 添加到函数声明中。这是最简单的方法,但它不适用于类成员函数,在类成员函数中,static 有不同的含义。
  2. 函数或类放入匿名的命名空间中。
  3. Gnu 编译器中,允许使用 __attribute__((visibility("hidden")))

使用全程序优化

一些编译器具有对整个程序进行优化的选项,也可以选择将多个 .cpp 文件组合成一个对象文件。这使得编译器能够在组成程序的所有 .cpp 模块之间优化寄存器分配和参数传递。对于作为目标文件或库文件分发的函数库,不能使用全程序优化。

使用 64位模式

在 64位模式下,参数传递比在 32位模式下更高效,而 64位Linux 比64位Windows 更快。在 64位Linux 中,前 6个整数参数和前 8个浮点参数使用寄存器传递,总计 14个寄存器参数。而在64位Windows 中,前四个参数在寄存器中传递,而不管它们是整数还是浮点数。因此,如果函数有四个以上的参数,64位Linux 比64位Windows 更快。32位Linux和32位Windows 在这个层面上没有差别。

7.15 函数参数

在大多数情况下,函数参数是按值传递的。这意味着参数的值被复制到一个局部变量中。对于 intfloatdoubleboolenum 以及指针和引用等简单类型,这非常快。

数组总是使用指针传递,除非它们被打包在类或者结构体中。

如果参数是复合类型,例如结构体或类,那么情况会更复杂一些。复合类型的参数传递在符合一下几个条件的情况下是最高效的:

  1. 对象很小,可以装入一个寄存器中。
  2. 对象没有拷贝构造函数和析构函数。
  3. 对象没有虚成员。
  4. 对象没有使用运行时类型识别(RTTI )。

如果这些条件中,有任何一个不满足,那么使用指针或引用来传递对象通常会更快。如果对象很大,那么显而易见,复制整个对象需要时间。当对象复制到参数时,必须调用复制构造函数,如果有析构函数的话,必须在函数返回之前调用析构函数。

将复合对象传递给函数的首选方法是使用 const 引用。const 引用确保原始对象没有被修改。与指针或非 const 引用不同,const 引用允许函数参数为表达式或匿名对象。如果函数是内联的,编译器可以很容易地优化掉 const 引用。

另一种解决方案是使函数成为对象的类(或结构体)的成员,这同样有用。

在 32位系统中,简单的函数参数在栈上传递,但在 64位系统,使用寄存器中传递。后者效率更高。64位Windows 允许在寄存器中传输最多4个参数。64位Unix 系统允许在寄存器中传输最多14个参数( 8个浮点数或双精度数加上 6个整数、指针或引用参数)。成员函数中的 this 指针占用一个参数。手册5:“Calling conventions for different C++ compilers and operating systems”给出了更多的细节。

7.16 函数返回类型

函数的返回类型最好是简单类型、指针、引用或 void。返回复合类型的对象更为复杂,而且常常效率低下。

复合类型的对象只能在最简单的情况下在寄存器中返回。有关何时可以在寄存器中返回对象的详细信息,请参见手册5:“Calling conventions for different C++ compilers and operating systems”。

除了最简单的情况外,复合对象的返回方式是通过一个隐藏指针将它们复制到调用方指定的位置。复制构造函数(如果有的话)通常在复制过程中被调用,而析构函数则在销毁原始对象时被调用。在简单的情况下,编译器可以通过在对象的最终目的地构造对象来避免调用复制构造函数和析构函数的,但是不要依赖这一点。

你可以考虑以下替代方法,而不是返回复合对象:

  1. 在函数中构造对象。
  2. 使函数修改一个现有的对象,而不是创建一个新的对象。现有对象可以通过指针或引用传递给函数,或者函数可以是对象的类的成员。
  3. 使函数返回一个指向函数内部定义的静态对象的指针或引用。这是有效的,但也有风险。返回的指针或引用仅在下一次调用函数并覆盖本地对象(可能在不同的线程中)之前有效。如果忘记将局部对象定义为静态的,那么一旦函数返回,它就会失效。
  4. 使用 new 在函数中构造一个对象,并返回一个指向它的指针。由于动态内存分配的成本,这是低效的。如果忘记删除对象,此方法还涉及内存泄漏的风险。

7.17 函数尾调用

尾调用是优化函数调用的一种方法。如果函数的最后一条语句是对另一个函数的调用,那么编译器可以用跳转到第二个函数来替换该调用。优化编译器将自动完成此任务。第二个函数不会返回到第一个函数,而是直接返回第一个函数被调用的位置。这样效率更高,因为它消除了返回操作。例如:

1
2
3
4
5
6
7
8
9
// Example 7.35. Tail call

void function2(int x);
void function1(int y)
{
...
function2(y+1);
}

在这里,通过直接跳到 function2 来消除 function1 的返回。即使有返回值,也可以这样做:

1
2
3
4
5
6
7
8
// Example 7.36. Tail call with return value

int function2(int x);
int function1(int y)
{
...
return function2(y+1);
}

尾调用优化只有在两个函数具有相同的返回类型时才有效。如果函数在栈上有参数(在 32位模式下通常是这样),那么这两个函数必须为参数使用相同数量的栈空间。

7.18 递归函数

递归函数是一个调用自身的函数。函数递归调用对于处理递归数据结构非常有用。递归函数的代价是所有参数和局部变量在每次递归时都会有一个新实例,这会占用栈空间。深度递归还会降低返回地址的预测效率。这个问题通常出现在递归深度超过 16 的情况下(参见手册3“The microarchitecture of Intel, AMD and VIA CPUs”中对返回栈缓冲区的解释)。

递归函数调用仍然是处理分支数据树结构最有的效解决方案。较宽的树形结构比较深的树形结构,有更高的递归效率。无分支递归总是可以用循环代替,这样的效率更高。递归函数的一个常见教科书例子是阶乘函数:

1
2
3
4
5
6
7
// Example 7.37. Factorial as recursive function

unsigned long int factorial(unsigned int n)
{
if (n < 2) return 1;
return n * factorial(n-1);
}

这种实现非常低效,因为 n 的所有实例和所有返回地址都会占用栈上的存储空间。使用循环效率更高:

1
2
3
4
5
6
7
8
9
10
11
12
// Example 7.38. Factorial function as loop

unsigned long int factorial(unsigned int n)
{
unsigned long int product = 1;
while (n > 1)
{
product *= n;
n--;
}
return product;
}

递归尾调用(尾递归)比其他递归调用更高效,但仍然不如循环快。

初学者有时会调用 main 函数来重启程序。这不是一个好主意,因为每次递归调用 main 函数时,栈都会被所有本地变量的新实例填满。重新启动程序的正确方法是在 main 函数中使用循环。

7.19 结构体和类

现在,编程教科书推荐面向对象编程作为一种使软件更加清晰和模块化的方法。所谓的对象是结构和类的实例。面向对象的编程风格对程序性能既有积极的影响,也有消极的影响。积极的影响是:

  1. 如果一起使用的变量是相同结构或类的成员,那么它们会被存储在一起。这使得数据缓存更有效率。
  2. 不需要将类成员的变量作为参数传递给类成员函数。这些变量避免了参数传递的开销。

面向对象编程的负面影响有:

  1. 非静态成员函数有一个 this 指针,该指针作为隐形参数传递给函数。this 的参数传输开销会在所有非静态成员函数上产生。
  2. this 指针占用一个寄存器。在 32位系统中,寄存器是一种稀缺资源。
  3. 虚成员函数的效率较低(参见7.22 虚成员函数 )。

关于面向对象编程的正面影响还是负面影响占主导地位,还没有一个通用的说法。至少,可以这样说,使用类和成员函数的代价并不大。如果面向对象的编程风格有利于程序的逻辑结构和清晰性,那么你可以使用这种风格,只要你避免在程序最关键的部分调用过多的函数。结构体的使用(没有成员函数的)对性能没有负面影响。

7.20 类的数据成员(变量实例)

类或结构体的数据成员是按创建类或结构实例时声明它们的顺序连续存储。将数据组织到类或结构体中不存在性能损失。访问类或结构体对象的数据成员所花费的时间不比访问简单变量多。

大多数编译器将数据成员对齐到可以被特定数整除的地址以优化访问,不同数据类型的这个数的大小如下表所示:

Type size,bytes alignments, bytes
bool 1 1
char, signed or unsigned 1 1
short int, signed or unsigned 2 2
int, signed or unsigned 4 4
64位 integer, signed or unsigned 8 8
pointer or reference, 32位 mode 4 4
pointer or reference, 64位 mode 8 8
float 4 4
double 8 8
long double 8, 10, 12 or 16 8 or 16

Table 7.2. Alignment of data members

如果结构体或类中的成员的大小不一,这样的对齐会导致未被使用的字节空洞。

1
2
3
4
5
6
7
8
9
10
11
// Example 7.39a

struct S1
{
short int a; // 2 bytes. first byte at 0, last byte at 1
// 6 unused bytes
double b; // 8 bytes. first byte at 8, last byte at 15
int d; // 4 bytes. first byte at 16, last byte at 19
// 4 unused bytes
};
S1 ArrayOfStructures[100];

这里,ab 之间有 6 个未使用的字节,因为 b 必须从一个能被 8 整除的地址开始。最后还有 4 个未使用的字节。由于数组中 S1 的下一个实例必须从一个可被 8 整除的地址开始,这样做,方便将其中的 b成员与 8 对齐。通过将最小的成员放在最后,可以将未使用的字节数减少到 2:

1
2
3
4
5
6
7
8
9
10
// Example 7.39b

struct S1
{
double b; // 8 bytes. first byte at 0, last byte at 7
int d; // 4 bytes. first byte at 8, last byte at 11
short int a; // 2 bytes. first byte at 12, last byte at 13
// 2 unused bytes
};
S1 ArrayOfStructures[100];

这样子重新排序使结构体少用了 8个字节,数组占用的空间减少了 800字节。

通过对数据成员的重新排序,结构体和类对象通常可以变得更小。如果类至少有一个虚成员函数,则在第一个数据成员之前或最后一个成员之后有一个指向虚拟表的指针。这个指针在 32位系统中是4字节,在 64位系统中是 8字节。如果你对一个结构体或其每个成员的大小有疑问,那么你可以使用 sizeof 运算符进行一些测试。sizeof 运算符返回的值包括对象末尾未使用的字节。

如果成员相对于结构体或类的开头的偏移量小于 128,则访问数据成员的代码会更紧凑,因为偏移量可以表示为 8位有符号数字。如果相对于结构或类的开头的偏移量是 128字节或更多,那么偏移量必须表示为 32位数字(在 8位到 32位偏移量之间,指令集没有其它可选择的偏移量)。例如:

1
2
3
4
5
6
7
8
9
// Example 7.40

class S2
{
public:
int a[100]; // 400 bytes. first byte at 0, last byte at 399
int b; // 4 bytes. first byte at 400, last byte at 403
int ReadB() {return b;}
};

b的偏移量是 400。任何通过指针或成员函数(如 ReadB)访问 b 的代码都需要将偏移量编码为 32位数字。如果交换了 ab,那么可以使用一个被编码为 8位有符号数字的偏移量来访问它们,或者完全不使用偏移量。这使得代码更紧凑,从而更有效地使用代码缓存。因此,建议在结构或类声明中,大数组和其他大对象放在最后,最常用的数据成员放在前面。如果不可能在前 128 个字节中包含所有数据成员,则将最常用的成员放在前 128个字节中。

7.21类的成员函数(方法)

每次声明或创建类的新对象时,它都会生成数据成员的新实例。但是每个成员函数只有一个实例。函数代码不会被复制,因为相同的代码可以应用于类的所有实例。

调用成员函数与调用简单函数使用结构体(类)的指针或引用一样快。例如:

1
2
3
4
5
6
7
8
9
10
// Example 7.41
class S3
{
public:
int a;
int b;
int Sum1() {return a + b;}
};
int Sum2(S3 * p) {return p->a + p->b;}
int Sum3(S3 & r) {return r.a + r.b;}

Sum1, Sum2Sum3 这三个函数做的是完全一样的事情,它们的效率是一样的。如果查看编译器生成的代码,你会注意到一些编译器将为这三个函数生成完全相同的代码。Sum1 有一个隐式的 this 指针,它在 Sum2Sum3pr 的作用相同。无论你是让函数成为类的成员,还是给它一个指向类或结构的指针或引用,都只是编程风格的问题。一些编译器通过使用寄存器中而不是栈传输 this,使 Sum1 在 32位Windows 中比 Sum2Sum3 效率略高一些。

静态成员函数不能访问任何非静态数据成员或非静态成员函数。静态成员函数比非静态成员函数快,因为它不需要 this 指针。如果成员函数不需要访问任何非静态的东西,可以通过将它们声明为静态以变得更快。

7.22 虚成员函数

虚函数用于实现多态类。一个多态类的每个实例都有一个指针指向一个指针表(虚函数表),其中的指针指向虚函数的不同版本。这个所谓的虚函数表用于在运行时查找虚函数的正确版本。多态性是面向对象程序比非面向对象程序效率低的主要原因之一。如果可以避免使用虚函数,那么你就可以获得面向对象编程的大多数优势,而无需付出性能成本。

如果函数调用语句总是调用虚函数的相同版本,那么调用虚成员函数所花费的时间要比调用非虚成员函数多几个时钟周期。如果版本发生了变化,你可能会得到10 - 20个时钟周期的错误预测惩罚。虚函数调用的预测和错误预测规则与 switch 语句相同,如7.12 分支和 switch语句所述;

在对已知类型的对象调用虚函数时,可以绕过分派机制,但是不能总是依赖编译器绕过分派机制,即使这样做使显而易见的。见8.1 编译器是如何优化的:实体化

只有在编译时无法知道调用了多态成员函数的哪个版本时,才会需要运行时多态。如果需要在程序的关键部分中使用虚函数,那么你可以考虑是否可以在不使用多态性或使用编译时多态性的情况下完成所需的功能。

有时可以使用模板而不是虚函数来获得所需的多态性效果。模板参数应该是包含具有多个版本的函数的类。这个方法更快,因为模板参数总是在编译时解析,而不是在运行时解析。7.30 模板:使用模板实现多态中的例 7.47展示了如何做到这一点。不幸的是,它的语法非常笨拙,可能不值得花这么多功夫。

7.23 运行时类型识别(RTTI)

运行时类型识别会向所有类对象添加额外的信息,而且效率不高。如果编译器有RTTI 选项,那么关闭它并使用其他实现。

7.24 继承

派生类的对象与包含父类和子类成员的简单类的对象的实现方法相同。父类和子类的成员访问速度相同。一般来说,你可以假设使用继承几乎没有任何性能损失。

由于如下原因代码缓存的性能可能会有轻微的下降:

  1. 父类数据成员的大小被添加到子类成员的偏移量中。访问总偏移量大于 127字节的数据成员的代码稍微不那么紧凑。7.20 类的数据成员(变量实例)
  2. 父类和子类的成员函数通常存储在不同的模块中。这可能会导致大量的跳转和低效的代码缓存。这个问题可以通过确保相互调用的函数存储在彼此附近来解决。详情见9.3 一起使用的函数应该被放在一起

同一个类从多个父类继承会导致成员指针和虚函数,或者通过指向基类之一的指针访问派生类对象的复杂性很高。你可以通过在派生类中创建对象来避免多重继承:

1
2
3
4
5
6
7
8
// Example 7.42a. Multiple inheritance

class B1; class B2;
class D : public B1, public B2
{
public:
int c;
};

可以替换为:

1
2
3
4
5
6
7
8
9
// Example 7.42b. Alternative to multiple inheritance

class B1; class B2;
class D : public B1
{
public:
B2 b2;
int c;
};

7.25 构造函数和析构函数

构造函数在内部被实现为一个成员函数,该成员函数返回对象的引用。新对象的内存分配不一定由构造函数本身完成。因此构造函数和其他成员函数效率一样。这适用于默认构造函数、复制构造函数和任何其他构造函数。

类不需要构造函数。如果对象不需要初始化,则不需要默认构造函数。如果仅通过复制所有数据成员就可以复制对象,则不需要复制构造函数。可以将简单的构造函数定义为内联的来提高性能。

无论何时通过赋值复制对象、作为函数参数或作为函数返回值,都可以调用复制构造函数。如果复制构造函数涉及内存或其他资源的分配,则它可以相当耗时。有很多方法可以避免这种浪费的内存块的复制,例如:

  1. 使用对象的引用或指针,而不是复制它。
  2. 使用“移动构造函数”(move constructor)来转移内存块的所有权。这需要一个支持C++ 0x 的编译器。
  3. 创建一个成员函数或友元函数或运算符,将内存块的所有权从一个对象转移到另一个对象。失去内存块所有权的对象应该将其指针设置为 NULL。当然,应该有一个析构函数来销毁对象所拥有的任何内存块。
  4. 析构函数和成员函数效率一样。如果没有必要,不要创建析构函数。虚析构函数和虚成员函数效率一样。见7.24 继承

7.26 联合体

union 是数据成员共享相同内存空间的结构。union 可以通过允许从不同时使用的两个数据成员共享同一块内存来节省内存空间。参见9.4 一起使用的变量应该存储在一起

union 还可以用于以不同的方式访问相同的数据。例如:

1
2
3
4
5
6
7
8
9
10
// Example 7.43

union
{
float f;
int i;
} x;
x.f = 2.0f;
x.i |= 0x80000000; // set sign bit of f
cout << x.f; // will give -2.0

在本例中,f 的符号位是通过使用位或( |)运算符设置的,该运算符只能用于整数。

7.27 位域

位域可能有助于使数据更加紧凑。访问位域成员不如访问结构的成员效率高。如果在大数组可以节省缓存空间或使文件更小,那么额外的时间是合理的。

使用 <<| 组合操作来操作位域比单独操作成员要快。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Example 7.44a

struct Bitfield
{
int a:4;
int b:2;
int c:2;
};
Bitfield x;
int A, B, C;
x.a = A;
x.b = B;
x.c = C;

假设ABC的值很小,不会导致溢出,可以通过以下方式对该代码进行改善:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Example 7.44b

union Bitfield
{
struct
{
int a:4;
int b:2;
int c:2;
};
char abc;
};
Bitfield x;
int A, B, C;
x.abc = A | (B << 4) | (C << 6);

或者,如果需要防止溢出:

1
2
3
// Example 7.44c

x.abc = (A & 0x0F) | ((B & 3) << 4) | ((C & 3) <<6 );

7.28 重载函数

重载函数的不同版本被简单地视为不同的函数。使用重载函数没有性能损失。

7.29 重载运算符

重载的运算符相当于一个函数。使用重载运算符与使用具有相同功能的函数效率一样。

表达式具有多个重载运算符,将导致为中间结果创建临时对象,这可能是我们不希望看到的。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Example 7.45a

class vector
{ // 2-dimensional vector
public:
float x, y; // x,y coordinates
vector() {} // default constructor
vector(float a, float b)
{
x = a;
y = b;
} // constructor
vector operator + (vector const & a)
{ // sum operator
return vector(x + a.x, y + a.y);
} // add elements
};
vector a, b, c, d;
a = b + c + d; // makes intermediate object for (b + c)

为中间结果(b+c)创建临时对象可以通过加入以下操作来避免:

1
2
3
4
// Example 7.45b

a.x = b.x + c.x + d.x;
a.y = b.y + c.y + d.y;

幸运的是,大多数编译器会在简单的情况下自动进行优化。

7.30 模板

模板与宏的相似之处在于,模板参数在编译之前被它们的值所替换。下面的例子说明了函数参数和模板参数之间的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Example 7.46

int Multiply (int x, int m)
{
return x * m;
}

template <int m>
int MultiplyBy (int x)
{
return x * m;
}

int a, b;
a = Multiply(10,8);
b = MultiplyBy<8>(10);

ab 都得到$10 * 8 = 80$。区别在于 m 传递到函数的方式。在这个简单的函数中,m 在运行时从调用者转移到被调用的函数。但是在模板函数中,m 在编译时被它的值所代替,这样编译器看到的是常量 8 而不是变量 m。使用模板参数而不是函数参数的优点是避免了参数传递的开销。缺点是编译器需要为每个不同的值创建模板函数的新实例。如果在本例中使用许多不同的系数作为模板参数来调用 MultiplyBy,那么代码可能会变得非常大。

在上面的例子中,模板函数比简单函数快,因为编译器知道它可以通过移位操作来实现乘以 2 的幂。x*8x<<3所代替,速度更快。在简单函数的情况下,编译器不知道 m 的值,因此不能进行优化,除非函数可以内联。(在上面的例子中,编译器能够内联和优化这两个函数,并简单地将 80 存入 ab 中。但在更复杂的情况下,编译器可能无法做到这一点)。

模板参数也可以是类型。7.10 数组中的示例展示了如何使用相同的模板创建不同类型的数组。

模板是高效的,因为模板参数总是在编译时被解析。模板使源代码更加复杂,而不是编译后的代码。一般来说,使用模板在执行速度方面没有任何成本。

如果模板参数完全相同,则将两个或多个模板实例合并为一个。如果模板参数不同,那么每一组模板参数都将生成一个实例。有许多实例的模板会使编译后的代码变大,并使用更多的缓存空间。

过度使用模板会使代码难以阅读。如果模板只有一个实例,那么你也可以使用 #defineconsttypedef 来代替模板参数。

模板可以用于元编程,如15 元编程所述;

使用模板实现多态

模板类可用于实现编译时多态性,这比使用虚拟成员函数获得的运行时多态性更加高效。下面的示例首先展示了运行时多态性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Example 7.47a. Runtime polymorphism with virtual functions

class CHello
{
public:
void NotPolymorphic(); // Non-polymorphic functions go here
virtual void Disp(); // Virtual function
void Hello()
{
cout << "Hello ";
Disp(); // Call to virtual function
}
};
class C1 : public CHello
{
public:
virtual void Disp()
{
cout << 1;
}
};
class C2 : public CHello
{
public:
virtual void Disp()
{
cout << 2;
}
};
void test ()
{
C1 Object1; C2 Object2;
CHello * p;
p = &Object1;
p->NotPolymorphic(); // Called directly
p->Hello(); // Writes "Hello 1"
p = &Object2;
p->Hello(); // Writes "Hello 2"
}

如果编译器不知道对象 p 指向什么类(参见8.1 编译器是如何优化的:去虚拟化),则会在运行时分发到 C1::Disp()C2::Disp()。当前的编译器不太擅长优化掉 p,并内联 Object1.Hello() 的调用,不过将来的编译器可能能够做到这一点。

如果在编译时知道对象是属于类C1还是C2,那么我们就可以避免低效的虚函数分发过程。这可以通过在活动模板库(ATL )和Windows模板库(WTL )中使用的特殊技巧来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// Example 7.47b. Compile-time polymorphism with templates

// Place non-polymorphic functions in the grandparent class:
class CGrandParent
{
public:
void NotPolymorphic();
};
// Any function that needs to call a polymorphic function goes in the
// parent class. The child class is given as a template parameter:
template <typename MyChild>
class CParent : public CGrandParent
{
public:
void Hello()
{
cout << "Hello ";
// call polymorphic child function:
(static_cast<MyChild*>(this))->Disp();
}
};
// The child classes implement the functions that have multiple
// versions:
class CChild1 : public CParent<CChild1>
{
public:
void Disp()
{
cout << 1;
}
};
class CChild2 : public CParent<CChild2>
{
public:
void Disp()
{
cout << 2;
}
};
void test ()
{
CChild1 Object1; CChild2 Object2;
CChild1 * p1;
p1 = &Object1;
p1->Hello(); // Writes "Hello 1"
CChild2 * p2;
p2 = &Object2;
p2->Hello(); // Writes "Hello 2"
}

在这里 CParent 是一个模板类,它通过模板参数获取关于其子类的信息。它可以通过将它的 this 指针类型转换为指向它的子类的指针来调用它的子类的多态成员。只有将正确的子类名作为模板参数时,这才是安全的。换句话说,你必须确保子类的声明 class CChild1 : public CParent<CChild1> { 和模板参数具有相同的名称。

现在继承的顺序如下。第一代类(CGrandParent)包含任何非多态成员函数。第二代类(CParent<>)包含任何需要调用多态函数的成员函数。第三代类包含多态函数的不同版本。第二代类可以通过模板参数获取关于第三代类的信息。

如果对象的类名是已知的,那么在运行时分派虚成员函数将不会浪费时间。这些信息包含在具有不同类型的 p1p2 中。缺点是 CParent::Hello() 有多个实例占用缓存空间。

例 7.47b中的语法显然是非常笨拙的。通过避免虚函数分发机制,我们节省出来的几个时钟周期,难以证明如此复杂的难以理解的,因此也难以维护的代码是合适的。如果编译器能够自动执行去虚拟化(参见第8.1 编译器是如何优化的:去虚拟化),那么依赖编译器优化肯定比使用这种复杂的模板方法更加方便。

7.31 线程

线程用于同时或看起来是同时地执行两个或多个作业。如果计算机只有一个CPU 核心,那么不可能同时执行两个任务。对于前台任务,每个线程将获得通常为 30ms 的时间片,对于后台任务,每个线程将获得 10 ms 的时间片。每个时间片之后的上下文切换非常耗时,因为所有缓存都必须适应新的上下文。可以通过设置更长的时间片来减少上下文切换的次数。这将使应用程序运行得更快,但用户输入的响应时间会更长。(在Windows 中,你可以通过在高级系统性能选项下为后台服务选择优化性能,将时间片增加到 120ms。我不知道这在Linux 中是否可行)。

为不同任务的不同线程分配不同的优先级是非常有用的。例如,在字处理软件中,用户希望按下一个按键或移动鼠标时能够立即得到响应,这项任务必须有很高的优先级。而其他任务,例如拼写检查和重新分页,在其他优先级较低的线程中运行。如果不同的任务没有被划分成具有不同优先级的线程,那么当程序忙于拼写检查时,可能需要花很长时间来响应键盘和鼠标输入,这是用户所不希望遇到的。

如果应用程序有图形用户界面,那么任何需要很长时间的任务,比如繁重的数学计算,都应该安排在单独的线程中。否则程序将无法快速响应键盘或鼠标输入。

在应用程序中执行类似线程的调度而不调用操作系统线程调度程序,以节省开销是可能的。这可以通过在图形用户界面(在Windows MFC 中为 OnIdle)的消息循环中调用的函数中逐块地进行大量的后台计算来实现。这种方法可能比在只有一个CPU 内核的系统中创建单独的线程要快,但是它要求后台作业可以被分割成合适持续时间的多个小块。

充分利用具有多个CPU 内核的系统的最佳方法是将工作划分为多个线程。然后每个线程可以在自己的CPU 内核上运行。

在优化多线程应用程序时,我们必须考虑多线程的四种成本:

  1. 启动和停止线程的成本。如果与启动和停止线程所需的时间相比,任务的持续时间较短,则不要将其放入单独的线程中。
  2. 任务切换的成本。如果具有相同优先级的线程数量不超过CPU 内核的数量,则此成本达到最小值。
  3. 线程间同步和通信的成本。信号量、互斥量等的开销相当大。如果两个线程经常为了访问同一资源而相互等待,那么最好将它们合并到一个线程中。多个线程之间共享的变量必须声明为 volatile。这将阻止编译器对该变量进行优化。
  4. 不同的线程需要单独的存储空间。多线程使用的函数或类都不应该依赖于静态变量或全局变量。(参见线程本地存储p.28,TODO)。线程有各自的堆栈。如果线程共享相同的缓存,这可能会导致缓存竞争。

多线程程序必须使用线程安全的函数。线程安全的函数永远不应该使用静态变量。

有关多线程技术的进一步讨论,请参见10 多线程

7.32 异常和错误处理

运行时错误会导致异常,这些异常可以通过陷阱(traps)或软件中断的形式检测到。可以使用 try-catch 块捕捉这些异常。如果启用异常处理且没有 try-catch 块,则程序将崩溃,并显示错误消息。

异常处理旨在检测很少发生的错误,并以一种优雅的方式从错误条件中恢复。你可能认为只要没有发生错误,异常处理就不需要额外的时间,但不幸的是,这并不总是正确的。为了知道如何在异常事件中恢复,程序可能需要做大量的记录工作。这种记录的消耗在很大程度上取决于编译器。有些编译器具有高效的基于表的方法,开销很少或没有,而其他编译器则具有低效的基于代码的方法,或者需要运行时类型识别(RTTI ),这会影响代码的其他部分。更详细的信息请参阅 ISO/IEC TR18015 Technical Report on C++ Performance

下面的例子解释了为什么需要记录工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Example 7.48

class C1
{
public:
...
~C1();
};

void F1()
{
C1 x;
...
}
void F0()
{
try
{
F1();
}
catch (...)
{
...
}
}

函数 F1 在返回时应该调用对象 x 的析构函数。但是如果 F1 中的某个地方发生异常怎么办?然后我们跳出 F1 而不返回。F1 的清理工作被阻止了,因为它被中断了。现在,异常处理程序负责调用 x 的析构函数,这只有在 F1 保存了要调用的析构函数的所有信息或可能需要的任何其他清理信息时才有可能。如果 F1 调用另一个函数进而调用另一个函数,等等,如果在最里面的函数产生了一个异常,然后异常处理程序需要关于函数调用链和需要遵循的函数调用的顺序等所有信息,来检查所有必要的清理工作。这叫做堆栈展开。

即使没有异常发生,所有函数仍必须为异常处理程序保存一些信息。这就是异常处理在某些编译器中代价高昂的原因。如果你的应用程序不需要异常处理,那么应该禁用它,以便使代码更小、更高效。你可以通过关闭编译器中的异常处理选项来禁用整个程序的异常处理。你也可以通过向函数原型中添加 throw() 声明来禁用单个函数的异常处理:

1
void F1() throw();

这允许编译器假设 F1 永远不会抛出任何异常,这样它就不必为函数 F1 保存恢复信息。但是,如果 F1 调用另一个可能抛出异常的函数 F2,那么 F1 必须检查 F2 抛出的异常,并在 F2 实际抛出异常时调用 std::unexpected() 函数。因此,只有当 F1 调用的所有函数也有一个 throw() 声明时才可以对 F1 使用 throw() 声明。throw() 声明对于库函数很有用。

编译器会区分叶函数和帧函数。帧函数是至少调用一个其他函数的函数。叶函数是一个不调用任何其他函数的函数。叶函数比帧函数简单,因为如果可以排除异常,或者在发生异常时没有什么需要清理的情况下,堆栈展开信息可以被忽略。帧函数可以通过内联它调用的所有函数来转换为叶函数。如果程序最内层的关键循环不包含对帧函数的调用,则可以得到最佳性能。

虽然throw()语句在某些情况下可以提升、优化程序性能,但是没有理由添加诸如throw(A,B,C)这样的语句来显式地告诉函数可以抛出什么样的异常。实际上,编译器可能会添加额外的代码来检查抛出的异常是否属于指定的类型(参见Sutter的文章:A Pragmatic Look at Exception Specifications, Dr Dobbs Journal, 2002)。

在某些情况下,即使在程序最关键的部分使用异常处理也是最优的。如果替代实现的效率较低,并且你希望能够从错误中恢复,那么就会出现这种情况。下面的示例演示了这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Example 7.49

// Portability note: This example is specific to Microsoft compilers.
// It will look different in other compilers.
#include <excpt.h>
#include <float.h>
#include <math.h>
#define EXCEPTION_FLT_OVERFLOW 0xC0000091L

void MathLoop()
{
const int arraysize = 1000; unsigned int dummy;
double a[arraysize], b[arraysize], c[arraysize];
// Enable exception for floating point overflow:
_controlfp_s(&dummy, 0, _EM_OVERFLOW);
//_controlfp(0, _EM_OVERFLOW); // if above line doesn't work
int i = 0; // Initialize loop counter outside both loops
// The purpose of the while loop is to resume after exceptions:
while (i < arraysize)
{
// Catch exceptions in this block:
__try
{
// Main loop for calculations:
for ( ; i < arraysize; i++)
{
// Overflow may occur in multiplication here:
a[i] = log (b[i] * c[i]);
}
}
// Catch floating point overflow but no other exceptions:
__except (GetExceptionCode() == EXCEPTION_FLT_OVERFLOW
? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
{
// Floating point overflow has occurred.
// Reset floating point status:
_fpreset();
_controlfp_s(&dummy, 0, _EM_OVERFLOW);
// _controlfp(0, _EM_OVERFLOW); // if above doesn't work
// Re-do the calculation in a way that avoids overflow:
a[i] = log(b[i]) + log(c[i]);
// Increment loop counter and go back into the for-loop:
i++;
}
}
}

假设 b[i]c[i] 中的数字非常大,以至于在乘法 b[i]*c[i] 中可以发生溢出,尽管这种情况很少发生。上面的代码将捕获溢出时的异常,并以一种花费更多时间但避免溢出的方式重新执行计算。对每个因子取对数,而不是对乘积取对数,可以确保不会发生溢出,但是计算时间增加了一倍。

支持异常处理所需的时间可以忽略不计,因为在关键的最内层循环中没有 try 块或函数调用(日志除外)。log 是一个库函数,我们假设它是经过优化的。无论如何,我们都不能更改其可能的异常处理支持。异常发生时代价很高,但这不是问题,因为我们假设这种情况很少发生。

在这里,测试循环内部的溢出条件不需要任何成本,因为我们依赖微处理器硬件在发生溢出时引发异常。异常被操作系统捕获,如果有 try 块,操作系统会将其重定向到程序中的异常处理程序。

捕获硬件异常存在可移植性问题。这种机制依赖于编译器、操作系统和 CPU 硬件中的非标准化细节。将这样的应用程序移植到不同的平台可能需要修改代码。

让我们在这个例子中看看异常处理的可能替代方法。在相乘之前,我们可以检查 b[i]c[i] 是否太大,从而检查溢出。这将需要两个浮点数比较,这是比较耗时的,因为它们必须在最内层循环中。另一种可能是始终使用安全的公式 a[i] = log(b[i]) + log(c[i]),这将使 log 的调用次数增加一倍,而对数需要很长时间来计算。如果有一种方法可以在不检查所有数组元素的情况下检查循环之外的溢出,那么这可能是一种更好的解决方案。如果所有因子都是由相同的几个参数生成的,那么在循环之前进行这样的检查是可能的。或者,如果结果由某些公式组合成单个结果,那么可以在循环之后进行检查。

异常和向量代码

向量指令对于并行执行多个计算是有用的。下文第12章对此进行了描述。异常处理不适用于向量代码,因为向量中的单个元素可能会导致异常,而其他向量元素可能不会。由于分支在向量代码中实现的方式,你甚至可以在未采用的分支中得到异常。如果代码可以从向量指令中获益,那么最好禁用异常捕获,转而依赖 NANINF 的传递。见下文7.34 堆栈展开的其它情况。关于这一点进一步讨论参见www.agner.org/optimize/nan_propagation.pdf

避免异常处理的成本

当不需要尝试从错误中恢复时,不需要异常处理。如果你只是希望程序发出错误消息并在出现错误时停止程序,那么就没有理由使用 trycatchthrow。更好的方法是定义自己的错误处理函数,该函数只打印适当的错误消息,然后调用 exit

如果有已分配的资源需要被清理的话, 调用 exit 可能并不安全,解释如下。还有其他不使用异常处理错误的可能方法。检测错误的函数可以返回一个错误代码,调用函数可以使用该代码进行恢复或发出错误消息。

建议使用系统的、经过深思熟虑的方法来处理错误。你必须区分可恢复错误和不可恢复错误;确保分配的资源在发生错误时得到清理;并向用户发送适当的错误消息。

编写异常安全代码

假设一个函数以独占模式打开一个文件,并且在文件关闭之前有一个错误条件终止了程序。程序被终止之后,该文件将保持锁定后,用户将无法访问该文件,直到计算机重新启动。为了防止这类问题,你必须使你的程序异常安全。换句话说,程序必须在异常或其他错误情况下清理所有东西。可能需要清理的东西包括:

  1. 使用 new 或者 malloc 分配的内存。
  2. 窗口、图形画刷等的句柄。
  3. 锁定的互斥量。
  4. 打开的数据库连接。
  5. 打开的文件和网络连接。
  6. 需要被删除的临时文件。
  7. 需要保存的用户工作。
  8. 任何其它已分配的资源。

C++ 处理清理工作的方法是创建一个析构函数。可以将读取或写入文件的函数包装到具有确保文件关闭的析构函数的类中。相同的方法可以用于任何其他资源,例如动态分配的内存、窗口、互斥量、数据库连接等等。

C++ 异常处理系统确保调用本地对象的所有析构函数。如果包装器类有析构函数来处理分配资源的所有清理工作,则程序是异常安全的。如果析构函数引发另一个异常,则系统可能会出现问题。

如果你使用自己的错误处理系统而不是使用异常处理,那么你无法确保调用了所有析构函数并清理了资源。如果错误处理程序调用 exit()abort()_endthread() 等,则不能保证所有析构函数被调用。在不使用异常的情况下处理不可恢复错误的安全方法是从函数返回。如果可能,函数可能返回错误代码,或者错误代码可以存储在全局对象中。然后调用函数必须检查错误代码。如果后者也需要清理,那么它必须返回给自己的调用者,依此类推。

7.33 堆栈展开的其它情况

前面一节描述了一种称为堆栈展开的机制,异常处理程序使用这种机制清理和调用任何必要的析构函数,这些析构函数在出现异常时跳出函数,而不使用正常的返回路径。这种机制也适用于其他两种情况:

当线程终止时,可以使用堆栈展开机制。目的是检测线程中声明的任何对象是否具有需要调用的析构函数。建议在结束线程之前从需要清理的函数返回。你不能确保对 _endthread() 的调用会清除堆栈。这种行为依赖于具体的实现。

当使用 longjmp 函数从函数中跳出时,也使用堆栈展开机制。如果可能,避免使用 longjmp。在效率相当重要的代码中不要依赖 longjmp

7.34 NANINF的传递

在大多数情况下,浮点错误会传播到一系列计算的最终结果。这是异常和错误捕获的一种非常有效的替代方法。

浮点溢出和除以 0 得到无穷大。如果你把无穷大和某数相加或相乘,结果就是无穷大。INF代码可以以这种方式传播到最终结果。然而,并不是所有使用INF输入的操作都会得到INF。如果用一个正常的数字除以INF,会得到0。特殊情况INF-INFINF/INF得到NAN (not-a-number)。当你用 0 除以 0 以及函数的输入超出范围时,比如sqrt(-1)log(-1),也会出现特殊的代码NAN

使用 NAN 作为输入的大多数操作将输出 NAN,因此 NAN 将传播到最终结果。这是一种简单有效的浮点错误检测方法。几乎所有以 INFNAN 形式出现的浮点错误都将传播到它们最终结果。如果打印结果,你将看到 INFNAN,而不是数字。跟踪错误不需要额外的代码,INFNAN 的传播也不需要额外的成本。

NAN 可以包含带有额外信息的负载(payload)。函数库可以在出现错误时将错误代码放入此负载中,此负载将传播到最终的结果。

当参数为 INFNAN 时,函数 finite() 将返回 false,如果它是一个普通的浮点数,则返回 true。这可用于在浮点数转换为整数之前检测错误,以及在其他需要检查错误的情况下。

INFNAN 传播的详细信息请参阅NAN propagation versus fault trapping in floating point code。该手册还讨论了 INFNAN 的传递失败的情况,以及影响这些代码传递的编译器优化选项。

7.35 预处理命令

就程序性能而言,预处理指令(以#开头的所有指令)的性能成本很少,因为它们在程序编译之前就已经解析了。

#if 指令对于支持多个平台或使用相同源代码的多个配置是很有用的。#ifif 更高效,因为 #if 是在编译时解析的,而 if 是在运行时解析的。

当用于定义常量时,#define 指令等价于 const 定义。例如,#define ABC 123const int ABC = 123 的效率相同的,因为在大多数情况下,编译器优化可以用它的值替换整数常量。然而,const int 声明在某些情况下可能占用内存空间,而 #define 指令从来不占用内存空间。浮点常量总是会占用内存空间,即使没有给它命名。

当作为宏使用时,#define指令有时比函数更高效。参见7.14 函数:使用宏代替函数的讨论。

7.36 命名空间

使用名称空间,对执行速度没有影响。

8 编译器中的优化

8.1 编译器是如何优化的

现代编译器为了提高性能,会对代码进行大量修改。知道编译器能做什么和不能做什么,对程序员是很有帮助的。下面几节描述了一些编译器优化,这些优化是程序员需要了解的。

函数内联

编译器可以用被调用函数的主体替换函数调用。例如:

1
2
3
4
5
6
7
8
9
10
11
// Example 8.1a

float square (float a)
{
return a * a;
}

float parabola (float x)
{
return square(x) + 1.0f;
}

编译器可以将对square的调用替换为square内部的代码:

1
2
3
4
5
6
// Example 8.1b

float parabola (float x)
{
return x * x + 1.0f;
}

函数内联的优点是:

  1. 节约了调用、返回和参数传递的开销。
  2. 因为代码变得连续了,代码缓存的效率会更高。
  3. 如果只调用一次内联函数,那么代码就会变得更小。
  4. 如下所述,函数内联可以使其他优化的成为可能。

函数内联的缺点是:如果对内联函数有多个调用且函数体很大,则代码会变得更大。 如果函数很小,或者只从一个或几个地方调用它,那么编译器更可能使函数内联。

常量折叠和常数传播

只包含常量的表达式或子表达式将被计算结果替换。例如:

1
2
3
4
// Example 8.2a

double a, b;
a = b + 2.0 / 3.0;

编译器将会替换成下面的代码:

1
2
3
// Example 8.2b

a = b + 0.666666666666666666667;

这其实很方便,使用 2.0/3.0 要比计算值并使用许多小数要来的容易。建议为这样的子表达式加上括号,以确保编译器将其识别为子表达式。例如,b*2.0/3.0 将识别为 (b*2.0)/3.0,而不是b*(2.0/3.0),除非为常量子表达式加上括号。

常量可以通过一系列的计算来传播:

1
2
3
4
5
6
7
8
9
// Example 8.3a

float parabola (float x)
{
return x * x + 1.0f;
}
float a, b;
a = parabola (2.0f);
b = a + 1.0f;

有可能被编译器替换成:

1
2
3
4
// Example 8.3b

a = 5.0f;
b = 6.0f;

当表达式包含不能被内联的函数或者不能再编译时期计算的时候,常量折叠和常量传播就不可能起作用。例如:

1
2
3
// Example 8.4

double a = sin(0.8);

sin函数是在一个单独的函数库中定义的,不能期望编译器能够内联这个函数并在编译时计算它。一些编译器能够在编译时计算最常见的数学函数,如 sqrtpow,但不能计算更复杂的函数,比如 sin

消除指针

如果指向的目标已知,则可以消除指针或引用。例如:

1
2
3
4
5
6
7
8
// Example 8.5a

void Plus2 (int * p)
{
*p = *p + 2;
}
int a;
Plus2 (&a);

可能被编译器替换成:

1
2
3
// Example 8.5b

a += 2;

消除公共子表达式

如果相同的子表达式出现多次,那么编译器可能只会计算一次。例如:

1
2
3
4
5
// Example 8.6a

int a, b, c;
b = (a+1) * (a+1);
c = (a+1) / 4;

可能被编译器替换成:

1
2
3
4
5
6
// Example 8.6b

int a, b, c, temp;
temp = a+1;
b = temp * temp;
c = temp / 4;

寄存器变量

最常用的变量存储被在寄存器中(参见7.1 不同类型变量的存储:寄存器存储(register storage))。

在 32位系统中,整数寄存器变量的最大数量大约是 6个,在 64位系统中大约是 14个。

在 32位系统中,浮点寄存器变量的最大数量为 8个,在 64位系统中为 16个。一些编译器很难在 32位系统中生成浮点寄存器变量,除非启用了SSE2(或更高版本)指令集。

编译器将选择最常用的变量做为寄存器变量。这包括指针和引用,它们可以存储在整数寄存器中。寄存器变量的典型候选对象是临时中间变量、循环计数器、函数参数、指针、引用、this 指针、公共子表达式和归纳变量(见下文)。

如果一个变量的地址被取走,也就是说,如果有指向它的指针或引用,那么这个变量就不能存储在寄存器中。因此,对于可能受益于寄存器存储的变量,你应该避免使任何指针或引用。

活动范围分析

变量的活动范围是指变量被使用的代码范围。对于活动范围不重叠的变量,编译器优化可以使用相同的寄存器。这在可用寄存器数量有限的时候是非常有用的。例如:

1
2
3
4
5
6
7
8
9
10
11
// Example 8.7

int SomeFunction (int a, int x[])
{
int b, c;
x[0] = a;
b = a + 1;
x[1] = b;
c = b + 1;
return c;
}

在本例中,abc 可以共享同一个寄存器,因为它们的活动范围不重叠。如果 c = b + 1 更改为 c = a + 2,那么 ab 就不能使用相同的寄存器,因为它们的活动范围现在重叠了。

编译器通常不会将此原则用于存储在内存中的对象。对于不同的对象,它不会使用相同的内存区域,即使它们的活动范围不重叠。有关如何使不同的对象共享相同的内存区域的示例,请参见9.4 一起使用的函数应该被放在一起

合并相同的分支

通过合并相同的代码片段,可以使代码更加紧凑。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Example 8.8a

double x, y, z; bool b;
if (b)
{
y = sin(x);
z = y + 1.;
}
else
{
y = cos(x);
z = y + 1.;
}

可能被编译器替换为:

1
2
3
4
5
6
7
8
9
10
11
12
// Example 8.8b

double x, y; bool b;
if (b)
{
y = sin(x);
}
else
{
y = cos(x);
}
z = y + 1.;

消除跳转

可以通过复制它跳转到的代码来避免跳转。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13

int SomeFunction (int a, bool b)
{
if (b)
{
a = a * 2;
}
else
{
a = a * 3;
}
return a + 1;
}

这段代码从a=a*2跳转到return a+1;,。编译器可以通过复制return语句来消除这个跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Example 8.9b

int SomeFunction (int a, bool b)
{
if (b)
{
a = a * 2;
return a + 1;
}
else
{
a = a * 3;
return a + 1;
}
}

如果条件可以被简化为永远为真或永远为假,则可以消除分支:

1
2
3
4
5
6
7
8
9
10
// Example 8.10a

if (true)
{
a = b;
}
else
{
a = c;
}

可以被简化为:

1
2
3
// Example 8.10b

a = b;

如果可以从前一个分支知道某个分支的情况,那么也可以删除该分支。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Example 8.11a

int SomeFunction (int a, bool b)
{
if (b)
{
a = a * 2;
}
else
{
a = a * 3;
}
if (b)
{
return a + 1;
}
else
{
return a - 1;
}
}

编译器可能会把这个简化成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Example 8.11b

int SomeFunction (int a, bool b)
{
if (b)
{
a = a * 2;
return a + 1;
}
else
{
a = a * 3;
return a - 1;
}
}

循环展开

如果需要高度优化,一些编译器将会展开循环。见7.13 循环。如果循环体非常小,或者它使进一步优化成为可能,那么这可能是有利的。重复计数非常小的循环可以完全展开,以避免循环开销。例如:

1
2
3
4
5
// Example 8.12a

int i, a[2];
for (i = 0; i < 2; i++)
a[i] = i+1;

编译器可能会把这个简化成:

1
2
3
4
5
// Example 8.12b

int a[2];
a[0] = 1;
a[1] = 2;

不幸的是,一些编译器展开太多。过多的循环展开不是最优的,因为它会占用太多的代码缓存空间,并且会填满某些微处理器的循环缓冲区。在某些情况下,关闭编译器中的循环展开选项是有用的。

移动循环中的不变代码

如果计算独立于循环计数器,则可以将其移出循环。例如:

1
2
3
4
5
6
7
// Example 8.13a

int i, a[100], b;
for (i = 0; i < 100; i++)
{
a[i] = b * b + 1;
}

可能会被编译器改成这样:

1
2
3
4
5
6
7
8
// Example 8.13b

int i, a[100], b, temp;
temp = b * b + 1;
for (i = 0; i < 100; i++)
{
a[i] = temp;
}

归纳变量(Induction variables)

循环计数器的线性函数表达式可以通过在前一个值上添加一个常数来计算。例如:

1
2
3
4
5
6
7
// Example 8.14a

int i, a[100];
for (i = 0; i < 100; i++)
{
a[i] = i * 9 + 3;
}

编译器可能会将其改成下面的形式以避免乘法:

1
2
3
4
5
6
7
8
9
// Example 8.14b

int i, a[100], temp;
temp = 3;
for (i = 0; i < 100; i++)
{
a[i] = temp;
temp += 9;
}

归纳变量常用于计算数组元素的地址。例如:

1
2
3
4
5
6
7
8
9
// Example 8.15a

struct S1 {double a; double b;};
S1 list[100]; int i;
for (i = 0; i < 100; i++)
{
list[i].a = 1.0;
list[i].b = 2.0;
}

为了访问 list 的元素,编译器必须计算它的地址。list[i] 的地址等于 list 的起始地址加上 i*sizeof(S1)。这是一个关于 i 的线性函数,这是可以通过归纳变量计算的。编译器可以使用相同的归纳变量来访问 list[i].alist[i].b。当可以提前计算归纳变量的最终值时,也可以消去 i,用归纳变量作为循环计数器。这可以将代码简化为:

1
2
3
4
5
6
7
8
9
// Example 8.15b

struct S1 {double a; double b;};
S1 list[100], *temp;
for (temp = &list[0]; temp < &list[100]; temp++)
{
temp->a = 1.0;
temp->b = 2.0;
}

因子 sizeof(S1) = 16 实际上隐藏在例 8.15b中的C++ 语法后面。&list[100] 的整数表示形式为 (int)(&list[100]) = (int)(&list[0]) + 100*16,而 temp++ 实际上是在 temp 的整数值上加上 16。

编译器不需要归纳变量来计算简单类型的数组元素的地址,当地址可以表示为一个基地址加上一个常数加上索引乘以一个系数1,2,4或8(但不是任何其他因数), CPU 中有硬件支持这样的计算。如果在例 8.15a中的 abfloat 而不是 double,那么 sizeof(S1) 的值将是 8,那么就不需要归纳变量了,因为 CPU 有硬件可以寄计算 index 乘上 8。

我研究的编译器不为浮点表达式或更复杂的整数表达式生成归纳变量。有关如何使用归纳变量计算多项式的示例,请参见8.3 编译器优化的障碍:浮点归纳变量

排序

编译器可以为了并行执行对指令重新排序。例如:

1
2
3
4
5
// Example 8.16

float a, b, c, d, e, f, x, y;
x = a + b + c;
y = d + e + f;

在这个例子中,编译器可以交错这两个公式,先算 a + b,然后是 d + e,然后将 c 加到第一个和中,之后 f 被加到第二个和中,第一个结果是存储在 x 中,最后第二个结果存储在 y 中。这样做的目的是帮助CPU 同时进行多个计算。现代CPU 实际上可以在没有编译器帮助的情况下对指令进行重新排序(参见11 乱序执行),但是编译器可以使CPU 更容易地对指令进行重新排序。

代数化简

多数编译器可以使用代数的基本定律来化简简单的代数表达式。例如,编译器可以将表达式 -(-a) 更改为 a

我不认为程序员会经常写出像 -(-a) 这样的表达式,但是这种表达式可能是其他优化(如函数内联)的结果。可化简的表达式也经常作为宏展开的结果出现。

然而,程序员经常编写可以化简的表达式。这可能是因为未化简的表达式更好地解释了程序背后的逻辑,或者因为程序员没有考虑代数化简的可能性。例如,程序员可能更喜欢使用 if(!a && !b) 而不是同等的 if(!(a || b)) 即使后者少用一个运算符。幸运的是,在这种情况下,所有编译器都能够进行化简。

你不能指望编译器化简复杂的代数表达式。例如,在我测试的编译器中,只有一个编译器能够将 (a*b*c)+(c*b*a) 化简为 a*b*c*2。在编译器中实现很多代数规则是相当困难的。一些编译器可以化简某些类型的表达式,而另一些编译器可以化简其他类型的表达式,但我所见过的编译器都不能化简所有类型的表达式。在布尔代数中,可以实现一种通用算法(例如,Quine-McCluskey 或者 Espresso)来化简任何表达式,但我测试过的编译器似乎都没有这样做。

编译器在化简整数表达式上比浮点表达式做得更好,尽管这两种情况下的代数规则是相同的。这是因为浮点表达式的代数操作可能会产生我们不希望的效果。这种效果可以用下面的例子来说明:

1
2
3
4
// Example 8.17

char a = -100, b = 100, c = 100, y;
y = a + b + c;

这里 y 的值是 $-100+100+100 = 100$。现在,根据代数规则,我们可以这样写:

1
2

y = c + b + a;

如果子表达式 c+b 可以在其他地方重用,那么这可能很有用。在这个例子中,我们使用的是 8 位整数,范围从-128到+127。整数溢出将使值反转(wrap around)。127 加 1 等于 -128,减 1 等于 -128。计算 c+b 会产生溢出,结果是 -56 而不是 200。接下来,我们将 -100 加到 -56 中,这将产生一个下溢,得到 100,而不是 -156。令人惊讶的是,我们得到了正确的结果,因为向上溢出和向下溢出相互抵消了。这就是为什么对整数表达式使用代数操作是安全的(<<=>>= 运算符除外)。

同样的讨论不适用于浮点表达式。浮点变量在上溢和下溢时,不会反转。浮点变量的范围非常大,除了在特殊的数学应用中,我们不必太担心上溢和下溢。但是我们必须担心精度的损失。让我们用浮点数重复上面的例子:

1
2
3
4
// Example 8.18

float a = -1.0E8, b = 1.0E8, c = 1.23456, y;
y = a + b + c;

这里的计算结果先得出 a+b=0,然后 0+1.23456 = 1.23456。但是如果我们改变操作数的顺序,先加 bc,就不会得到相同的结果。b + c = 100000001.23456。浮点类型的精度大约为7位有效数字,因此 b+c 的值四舍五入为 100000000。把 a 加到这个数上得到 0,而不是 1.23456

这里讨论的结果是,改变浮点操作数的顺序,就有丢失精度的风险。除非你指定一个允许不需要精确的浮点运算的选项,否则编译器不会这么做。即使打开了所有相关的优化选项,编译器也不会执行诸如 0/a = 0这样的明显简化,因为如果 a 为 0、无穷大或 NAN(不是一个数字),这将是无效的。不同的编译器的行为不同,因为对于哪些不精确应该被允许,哪些不应该被允许,存在不同的观点。

不能依赖编译器对浮点代码执行任何代数消减,只能依赖于对整数代码进行最简单的缩减。手动消减会更安全。我测试了在7个不同的编译器,简化各种代数表达式的能力。结果如下表8.1所示。

去虚拟化(Devirtualization)

如果知道所需要虚函数的版本,编译器优化可以绕过虚函数表查找,直接调用虚函数。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Example 8.19. Devirtualization

class C0
{
public:
virtual void f();
};
class C1 : public C0
{
public:
virtual void f();
};
void g()
{
C1 obj1;
C0 * p = & obj1;
p->f(); // Virtual call to C1::f
}

如果不进行优化,编译器需要在虚函数表中查找 p->f() 调用是否要转到 C0::fC1::f。但是编译器优化将看到 p 总是指向类 C1 的对象,因此它可以直接调用 C1::f,而不使用虚函数表。不幸的是,很少有编译器能够进行这种优化。

8.2 不同编译器的对比

我在 9 种不同的C++ 编译器上做了一系列的实验,看看它们是否能够进行不同种类的优化。结果见表8.1。该表显示了不同的编译器是否成功地在我的测试示例中应用了各种优化方法和代数化简。

该表可以提供一些关于你可以期望特定的编译器获得哪些优化,以及必须手动进行哪些优化的指导。

必须强调的是,编译器在不同的测试示例上可能有不同的行为。你不能期望编译器总是根据表格的结果来运行。

Optimization method Microsoft Borland Intel Gnu PathScale PGI Digital Mars Watcom Codeplay
Function inlining X - X X X X - - X
Constant folding X X X X X X X X X
Constant propagation X - X X X X - - X
Pointer elimination X X X X X X X X X
Common subexpression elimin, integer X (X) X X X X X X X
Common subexpression elimin, float X - X X X X - X X
Register variables, integer X X X X X X X X X
Register variables, float X - X X X X - X X
Live range analysis X X X X X X X X X
Join identical branches X - - X - - - X -
Eliminate jumps X X X X X X - X X
Eliminate branches X - X X X X - - -
Remove branch that is always true/false X - X X X X X X X
Loop unrolling X - X X X X - - X
Loop invariant code motion X - X X X X X X X
Induction variables for array elements X X X X X X X X X
Induction variables for other integer expressions X - X X X - X X X
Induction variables for float expressions - - - - - - - - -
Automatic vectorization - - X X X X - - X
Devirtualization - - - X - - - - -
Profile-guided optimization X - X X X X - - -
Whole program optimization X - X X X - - - -
Integer algebra reductions:
a+b = b+a X (X) X X X X - X X
a*b = b*a X (X) X X X X - X X
a+b+c = a+(b+c) X - X X - - X X -
a+b+c = c+a+b X - - X - - - - -
a+b+c+d = (a+b)+(c+d) - - X X - - - - -
a*b+a*c = a*(b+c) X - X X X - - - X
a*x*x*x+b*x*x+c*x+d = ((a*x+b)*x+c)*x+d X - X X X - X X X
X*X*X*X*X*X=((X^2 )^2 )^2 - - - X - - - - -
a+a+a+a=a*4 X - X X - - - - X
-(-a)=a X - X X X X X X -
a-(-b)=a+b X - X X X X - X -
a-a = 0 X - X X X X X X X
a+0 = a X X X X X X X X X
a*0 = 0 X X X X X X X - X
a*1 = a X X X X X X X X X
(-a)*(-b) = a*b X - X X X - - - -
a/a = 1 - - - - X - - - X
a/1 = a X X X X X X X X X
0/a = 0 - - - X X - - X X
(-a == -b) = (a == b) - - - X X - - - -
(a-c == b+c) = (a == b) - - - - X - - - -
!(a < b) = (a >= b) X X X X X X X X X
(a<b && b<c && a<c) = (a<b && b<c) - - - - - - - - -
Multiply by constant = shift and add X X X X - X X X -
Divide by constant = multiply and shift X - X X X (-) X - -
Floating point algebra reductions:
a+b = b+a X - X X X X - - X
a*b = b*a X - X X X X - - X
a+b+c = a+(b+c) X - X X - - - - -
(a+b)+c = a+(b+c) - - X X - - - - -
a*b*c = a*(b*c) X - X - - - - - -
a+b+c+d = (a+b)+(c+d) - - - X - - - - -
a*b+a*c = a*(b+c) X - - - X - - - -
a*x*x*x+b*x*x+c*x+d = ((a*x+b)*x+c)*x+d X - X X X - - - -
X*X*X*X*X*X=((X^2 )^2 )^2 - - - X - - - - -
a+a+a+a=a*4 X - X X - - - - -
-(-a)=a - - X X X X X X -
a-(-b)=a+b - - - X X X - X -
a+0 = a X - X X X X X X -
a*0 = 0 - - X X X X - X X
a*1 = a X - X X X X X - X
(-a)*(-b) = a*b - - - X X X - - -
a/a = 1 - - - - X - - - X
a/1 = a X - X X X - X - -
0/a = 0 - - - X X - - X X
(-a == -b) = (a == b) - - - X X - - - -
(-a > -b) = (a < b) - - X X - - - - X
Divide by constant = multiply by reciprocal X X - X X - - X -
Boolean algebra reductions:
!(!a) = a X - X X X X X X X
a && a = a X - X X X X - - -
Bit vector algebra reductions:
~(~a) = a X - X X X X X - -
a & a = a X - - X X X - - X
a & ~a = 0 - - - X X X - - -
a&b&c&d = (a&b)&(c&d) - - - X - - - - -
a ^ 0 = a X X X X X - X X X
a ^ -1 = ~a X - X X X - X X -
a ^ a = 0 X - X X X X - X X
a ^ ~a = -1 - - - X X X - - -
~a ^ ~b = a ^ b - - - X X - - - -
a<<b<<c = a<<(b+c) X - X X X - - X X
Integer XMM (vector) reductions:
Common subexpression elimination X n.a. X X X - n.a. n.a. X
Constant folding - n.a. - X - - n.a. n.a. -
a+b = b+a, a*b = b*a - n.a. - X - - n.a. n.a. X
(a+b)+c = a+(b+c) - n.a. - - - - n.a. n.a. -
a*b+a*c = a*(b+c) - n.a. - - - - n.a. n.a. -
X*X*X*X*X*X=((X^2 )^2 )^2 - n.a. - - - - n.a. n.a. -
a+a+a+a = a*4 - n.a. - - - - n.a. n.a. -
-(-a) = a - n.a. - - - - n.a. n.a. -
a-a = 0 - n.a. X - - - n.a. n.a. -
a+0 = a - n.a. - - - - n.a. n.a. -
a*0 = 0 - n.a. - X - - n.a. n.a. -
a*1 = a - n.a. - X - - n.a. n.a. -
(-a)*(-b) = a*b - n.a. - - - - n.a. n.a. -
!(a < b) = (a >= b) - n.a. - - - - n.a. n.a. -
Floating point XMM (vector) reductions:
a+b = b+a, a*b = b*a X n.a. - X - - n.a. n.a. X
(a+b)+c = a+(b+c) - n.a. - - - - n.a. n.a. -
a*b+a*c = a*(b+c) - n.a. - - - - n.a. n.a. -
-(-a) = a - n.a. - - - - n.a. n.a. -
a-a = 0 - n.a. - X - - n.a. n.a. -
a+0 = a - n.a. X - - - n.a. n.a. -
a*0 = 0 - n.a. X - - - n.a. n.a. -
a*1 = a - n.a. - X - - n.a. n.a. -
a/1 = a - n.a. - X - - n.a. n.a. -
0/a = 0 - n.a. X X - - n.a. n.a. -
Divide by constant = multiply by reciprocal - n.a. - - - - n.a. n.a. -
Boolean XMM (vector) reductions:
~(~a) = a - n.a. - - - - n.a. n.a. -
a & ~a = 0 - n.a. - X - - n.a. n.a. -
a & 0 = 0 - n.a. - X - - n.a. n.a. -
a ^ a = 0 - n.a. X X - - n.a. n.a. -
andnot(a,a) = 0 - n.a. - X - - n.a. n.a. -
a<<b<<c = a<<(b+c) - n.a. - - - - n.a. n.a. -

Tabel 8.1. Comparison of optimizations in different C++ compilers

测试中所有相关的优化选项都被打开,包括放宽浮点精度。被测试的编译器版本如下:

  1. Microsoft C++ Compiler v. 14.00 for 80x86 / x64 (Visual Studio 2005).
  2. Borland C++ 5.82 (Embarcadero/CodeGear/Borland C++ Builder 5, 2009).
  3. Intel C++ Compiler v. 11.1 for IA-32/Intel64, 2009.
  4. Gnu C++ v. 4.1.0, 2006 (Red Hat).
  5. PathScale C++ v. 3.1, 2007.
  6. PGI C++ v. 7.1-4, 2008.
  7. Digital Mars Compiler v. 8.42n, 2004.
  8. Open Watcom C/C++ v. 1.4, 2005.
  9. Codeplay VectorC v. 2.1.7, 2004.

没有发现MicrosoftIntelGnuPathScale 编译器的 32位和 64位代码的优化功能有任何差异。

8.3 编译器优化的障碍

有几个因素妨碍编译器执行我们希望它完成的优化。对于程序员来说,了解这些障碍并知道如何避免它们是很重要的。下面将讨论优化的一些重要障碍。

无法跨模块优化

编译器除了正在编译的模块外,没有关于其他模块中的函数的信息。这阻止了它对函数调用进行优化。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Example 8.20
//module1.cpp

int Func1(int x)
{
return x*x + 1;
}

//module2.cpp
int Func2()
{
int a = Func1(2);
...
}

假如 Func1Func2 在同一个模块中,那么编译器将能够进行函数内联和常量传播,并将 a 化简为常量5。但是在编译 module2.cpp 时,编译器没有关于 Func1 的必要信息。

解决这个问题最简单的方法是使用 #include 指令将多个 .cpp 模块组合成一个模块。这个方法适用于所有编译器。有些编译器有一个称为“全程序优化”的特性,它将支持跨模块的优化(参见8.5 编译器优化选项)。

指针别名(pointer aliasing)

当通过指针或引用访问变量时,编译器可能无法完全排除所指向的变量与代码中的其他变量相同的可能性。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Example 8.21

void Func1 (int a[], int * p)
{
int i;
for (i = 0; i < 100; i++)
{
a[i] = *p + 2;
}
}

void Func2()
{
int list[100];
Func1(list, &list[8]);
}

在这里,需要重新加载 *p 并计算 *p+2 100次,因为 p 所指向的值与循环过程中会发生变化的 a[] 中的一个元素相同。不允许假定 *p+2 是可以移出循环的循环不变代码。例 8.21确实是一个非常刻意的例子,但关键是编译器不能排除这种刻意的例子存在的理论可能性。因此,编译器不能假设 *p+2 是一个可以移动到循环外部的循环不变表达式。

大多数编译器都有一个假设没有指针别名(/Oa )的选项。克服可能的指针别名障碍的最简单方法是打开这个选项。这要求你仔细分析代码中的所有指针和引用,以确保在代码的同一部分中没有以一种以上的方式访问任何变量或对象。如果编译器支持的,还可以通过使用关键字 __restrict__restrict__ 告诉编译器某个特定指针不是任何变量的别名。

我们永远不能确定编译器是否接受关于没有指针别名的提示。确保代码得到优化的唯一方法是显式地进行优化。在例 8.21中,如果你确信指针不是数组中的任何元素的别名,那么可以先计算 *p+2 并将其存储在循环外部的临时变量中。这种方法要求你能够预先知道优化的障碍在哪里。

动态内存分配

动态分配(使用newmalloc)的任何数组或对象都必须通过指针进行访问。对于程序员来说,指向不同动态分配的对象的指针没有重叠或混淆是很明显的,但是编译器通常看不到这一点。它还阻止了编译器以最佳的方式来对齐数据,或者阻止编译器知道对象是对齐的。最好在需要的函数中声明对象和固定大小的数组。

纯函数(Pure functions)

纯函数是一个没有副作用(side-effects)的函数,它的返回值只取决于参数的值。这与“函数”的数学概念密切相关。

多次使用相同参数调用纯函数肯定会得到相同的结果。编译器可以消除包含纯函数调用的常见子表达式,并且可以移出包含纯函数调用的循环不变代码。不幸的是,如果函数是在不同的模块或函数库中定义的,编译器则无法知道函数是否为纯函数。

因此,当涉及纯函数调用时,有必要手动进行优化,如公共子表达式消除、常量传播和移动循环不变代码。

Gnu 编译器和用于LinuxIntel 编译器都有一个属性,该属性可以用于声明函数原型,以告诉编译器这是一个纯函数。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Example 8.22

#ifdef __GNUC__
#define pure_function __attribute__((const))
#else
#define pure_function
#endif

double Func1(double) pure_function ;
double Func2(double x)
{
return Func1(x) * Func1(x) + 1.;
}

在这里,Gnu 编译器将只调用 Func1一次,而其他编译器将会调用两次。

其他一些编译器(MicrosoftIntel )知道像 sqrtpowlog 这样的标准库函数是纯函数,但不幸的是,无法告诉这些编译器用户定义的函数是纯函数。

虚函数和函数指针

编译器几乎不可准确地预测将调用虚函数的哪个版本,或者函数指针指向什么。因此,它不能内联这些函数,也不能对函数调用进行优化。

代数化简

大多数编译器可以做简单的代数化简,比如-(a) = a,但是它们不能做更复杂的化简。代数化简是一个复杂的过程,这很难在编译器中实现。

由于数学的纯粹性(mathematical purity),许多代数化简是不被允许的。在许多情况下,可以构造一些晦涩的例子,其中化简会导致溢出或精度损失,特别是在浮点表达式中(参见第74页TODO)。编译器不能排除特定情况下某个特定化简无效的可能性,但是程序员可以。因此,在许多情况下有必要显式地进行代数化简。

整数表达式不太容易出现溢出和精度损失的问题,原因见8.1 编译器是如何优化的:代数化简。因此,编译器可以对整数表达式进行比浮点数表达式更多的化简。大多数涉及整数加法、减法和乘法的化简在所有情况下都是被允许的,而许多涉及除法和关系运算符(如“>”)的化简,由于数学的纯粹性是不被允许的。例如,由于存在隐藏的溢出的可能性,编译器不能将整数表达式 -a > -b 化简为 a < b

表 8.1显示了编译器在某些情况下,能够进行哪些化简,以及不能进行哪些化简。编译器无法完成的所有化简都必须由程序员手动完成。

浮点归纳变量

编译器不能生成浮点归纳变量的原因与它们不能对浮点表达式进行代数化简的原因相同。因此,有必要手动完成这项工作。当循环计数器的函数通过以前的值计算比使用循环计数器计算更有效时,这个方法就很有用。循环计数器的$n$次多项式的任何表达式都可以通过$n$次加法来计算,而不需要使用乘法。下面的例子展示了使用加法二阶多项式的原理:

1
2
3
4
5
6
7
8
9
// Example 8.23a. Loop to make table of polynomial

const double A = 1.1, B = 2.2, C = 3.3; // Polynomial coefficients
double Table[100]; // Table
int x; // Loop counter
for (x = 0; x < 100; x++)
{
Table[x] = A*x*x + B*x + C; // Calculate polynomial
}

这个多项式的计算通过两个归纳变量,只需要两个加法就可以完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Example 8.23b. Calculate polynomial with induction variables

const double A = 1.1, B = 2.2, C = 3.3; // Polynomial coefficients
double Table[100]; // Table
int x; // Loop counter
const double A2 = A + A; // = 2*A
double Y = C; // = A*x*x + B*x + C
double Z = A + B; // = Delta Y
for (x = 0; x < 100; x++)
{
Table[x] = Y; // Store result
Y += Z; // Update induction variable Y
Z += A2; // Update induction variable Z
}

例8.23b中的循环中有两个循环依赖链(loop-carried dependency chain),即两个归纳变量 YZ。每个依赖链都有一个延迟,这个延迟与浮点加法的延迟相同。这个延迟足够小,说明该方法是合适的。一个较长的循环依赖链会使归纳变量方法变得不利,除非该值是从一个两次或多次迭代的值计算出来的。

如果你考虑到每个值都是从序列中位于 r 位置之前的值计算出来的,其中 r 是一个向量中的元素数或循环展开因子,那么归纳变量的方法也可以向量化。要在每种情况下找到正确的公式,需要一点数学知识。

内联函数的非内联副本

函数内联的复杂性在于,同一个函数可能从另一个模块调用。为了在另一个模块中调用该函数,编译器必须生成一个内联函数的非内联的副本,。如果没有其他模块调用这个函数,那么这个非内联副本就是无用代码。这种代码片段降低了缓存的效率。

有很多方法可以解决这个问题。如果一个函数没有被任何其他模块引用,那么将关键字 static 添加到函数定义中。这将告诉编译器不能从任何其他模块调用该函数。静态声明使编译器更容易评估使函数内联是否是最优的,并防止编译器生成未使用的内联函数副本。static关键字还使各种其他优化成为可能,由于这些函数在其他模块中是无法访问的,因此编译器不必遵守任何特定的函数调用约定。可以用 static 声明所有本地非成员函数。

不幸的是,这个方法并不适用于类成员函数,因为 static 关键字对于成员函数有不同的含义。可以通过在类定义中声明函数体来强制使成员函数内联。这将防止编译器生成函数的非内联副本,但它的缺点是,即使在不适合内联的情况下(例如,如果成员函数很大,并且从许多不同的地方调用),函数也总是内联的。

一些编译器有一个选项(Windows/Gy, Linux- fffunction -sections),允许链接器删除未引用的函数。建议打开此选项。

8.4 CPU 优化的障碍

现代 CPU 可以通过乱序执行指令来进行很多优化。如3.15 依赖链所述,代码中的长依赖链妨碍CPU 的乱序执行。避免长依赖链,特别是具有长延迟的循环依赖链。

8.5 编译器优化选项

所有C++ 编译器都有各种各样的优化选项,你可以打开或关闭它们。研究正在使用的编译器的可用选项并打开所有相关选项是非常重要的。

许多优化选项与调试不兼容。调试器可以一次一行地执行代码,并显示所有变量的值。显然,当部分代码被重新排序、内联或优化时,这是不可能的。生成两个版本的可执行文件是很常见的:一个带有完整调试支持的调试版本(在程序开发期间使用)和一个带有所有相关优化选项的发布版本。大多数IDE(集成开发环境)都有用于生成目标文件和可执行文件的调试版本和发布版本的工具。确保能够区分这两个版本,并在可执行文件的优化版本中关闭调试和性能分析支持。

大多数编译器都提供了大小优化和速度优化的选择。当代码非常快时,你希望可执行文件尽可能小;或者当代码缓存非常关键时,优化大小是非常重要的。当CPU 访问和内存访问是消耗巨大时,速度优化是与之相关的。选择可用的优化程度最大的优化选项。

一些编译器提供配置分析引导的优化。其工作方式如下。首先,编译程序并使之支持分析。然后使用分析器进行测试运行,分析器确定程序流以及每个函数和分支执行的次数。然后,编译器可以使用这些信息来优化代码,并将不同的函数按最佳顺序排列。

一些编译器支持全程序优化。这可以通过两个步骤进行编译。所有源文件首先被编译成中间文件格式,而不是通常的目标文件格式。然后在第二步中将中间文件链接在一起后完成编译。寄存器分配和函数内联是在第二步中完成的。中间文件格式没有标准化。它甚至不兼容同一编译器的不同版本。因此,不可能以这种格式分发函数库。

其他编译器提供了将多个 .cpp 文件编译为单个对象文件的可能性。这使编译器能够在启用过程间优化时进行跨模块优化。一种更原始但更有效的方法是通过 #include 指令将所有源文件连接到一个文件中,并声明所有函数为静态或内联的。这将使编译器能够对整个程序进行过程间优化。

CPU 发展的历史中,每一代CPU 都增加了可用的指令集,更新的指令集使得编译器可以生成更高效的代码,但这使得代码与旧的CPU 不兼容。奔腾Pro 指令集使浮点数比较更高效。所有现代CPU 都支持这个指令集。SSE2 指令集非常有意思,因为它使浮点代码在某些情况下更高效,并使使用向量指令成为可能(参见12 使用向量操作)。然而,使用SSE2 指令集并不总是最优的。在某些情况下,SSE2指令集使浮点代码变的更慢,特别是在代码混合浮点和双精度浮点时(参见12 使用向量操作)。目前大多数CPU 和操作系统都支持SSE2 指令集。

当不需要兼容旧版本CPU 时,可以选择较新的指令集。更好的方法是,你可以使代码中最关键部分的有多个版本支持不同的CPU。这个方法在13 为不同指令集生成多个版本的软件代码中有解释。

当没有异常处理时,代码会变得更高效。建议关闭对异常处理的支持,除非代码依赖于结构化异常处理,并且你希望代码能够从异常中恢复。见7.32 异常和错误处理

建议关闭对运行时类型识别(RTTI)的支持。参见第55页(TODO)。

建议启用快速浮点运算或关闭对严格浮点运算的要求,除非要求严格。参见7.23 运行时类型识别(RTTI)中的讨论。

如果选项“函数级链接”(function level linking)可用,可以打开该选项。有关此选项的解释,请参见8.3 编译器优化的障碍:内联函数的非内联副本

如果你确定代码没有指针别名,请使用“假设没有指针混叠“(assume no pointer aliasing)选项。有关解释,请参阅8.3 编译器优化的障碍:指针别名。(Microsoft编译器 仅在专业版企业版 中支持该选项)。

不要修正“FDIV bug”。FDIV bug是最老版本的奔腾CPU 中的一个小错误。在一些罕见的浮点除法情况下可能会导致轻微的不精确。修正了FDIV bug 将导致浮点除法变慢。

许多编译器都有“标准栈帧”(standard stack frame)或“帧指针“(frame pointer)选项。标准栈帧用于调试和异常处理。省略标准堆栈帧可以使函数调用更快,并节省出一个额外的寄存器用于其他目的。这是有利的,因为寄存器是一种稀缺资源。除非程序依赖异常处理,否则不要使用堆栈帧。

8.6 优化指令

一些编译器有许多关键字和指令,用于在代码中的特定位置给出特定的优化指令。其中许多指令是特定于编译器的。你不能期望Windows编译器 的指令在Linux编译器 上工作,反之亦然。但是大多数Microsoft指令 可以在Intel编译器Gnu编译器Windows 版本上工作,而大多数Gnu 指令也可用于PathScaleIntel 编译器的Linux 版本。

适用于所有C++编译器的关键字

可以将 register 关键字添加到变量声明中,告诉编译器希望它是一个寄存器变量。register 关键字只是一个提示,编译器可能不会接受提示,但是在编译器无法预测哪些变量将被最多使用的情况下,它会非常有用。

register 相反的是 volatilevolatile关键字确保变量永远不会存储在寄存器中,即使是临时的。这适用于在多个线程之间共享的变量,但也可以在用于测试目的时,关闭的变量的所有优化。

const 关键字表示变量永远不会改变。这将允许编译器在许多情况下优化掉变量。例如:

1
2
3
4
5
6
7
// Example 8.24. Integer constant

const int ArraySize = 1000;
int List[ArraySize];
...
for (int i = 0; i < ArraySize; i++)
List[i]++;

在这里,编译器可以将所有出现的 ArraySize 替换为$1000$。如果循环计数 ArraySize 是常量,编译器在编译时能知道它的值,则可以以更高效的方式实现示例 8.24中的循环。将不会为整数常量分配内存,除非它的地址(&ArraySize)被取走。

const 指针或 const 引用不能更改它所指向的内容。const 成员函数不能修改数据成员。建议在适当的情况下使用 const 关键字来为编译器提供关于变量、指针或成员函数的额外信息,因为这可能会提高优化的可能性。例如,编译器可以安全地假设类数据成员的值在调用同一类的 const 函数时保持不变。

根据上下文,static 关键字有多种含义。当关键字 static 应用于非成员函数时,意味着该函数不被任何其他模块访问,这使得内联更加高效,并支持过程间优化, 见8.3 编译器优化的障碍:内联函数的非内联副本;当应用于全局变量时,意味着它不被任何其他模块访问,这将支持过程间优化;当应用于函数内部的局部变量时,意味着该变量将在函数返回时保留,并在下一次调用该函数时保持不变,这可能是低效的,因为一些编译器会插入额外的代码来防止多个线程同时访问该变量。即使变量被声明为 const,也可能会这样。

然而,可能有一个原因将局部变量声明为静态,并确保它只在第一次调用函数时初始化。例如:

1
2
3
4
5
6
7
// Example 8.25

void Func ()
{
static const double log2 = log(2.0);
...
}

在这里,log(2.0) 只会在第一次执行 Func 被计算。如果没有 static,将会在每次执行 Func 时重新计算。这样做的缺点是,函数必须检查以前是否调用过它。这比再次计算对数要快,但是将 log2 作为全局 const 变量或将其替换为计算后的值会更快。

static 关键字用于类的成员函数时表示该函数不能访问任何非静态的成员变量和成员函数。由于不需要 this 指针,调用静态成员函数会比非静态成员函数更快。建议在任何合适的时候将成员函数声明为静态的。

特定编译器的关键字

快速函数调用。__fastcall或者 __attribute__((fastcall))fastcall 修饰符可以使函数调用在 32位模式下更快。前两个整型参数将会在寄存器而不是栈中传递(对于 CodeGear编译器 则是前三个参数)。快速调用函数在编译器之间不兼容。在 64位模式下不需要快速调用,因为参数已经在寄存器中传递的。

纯函数。__attribute__((const))(Linux only),制定函数位纯函数。这将允许消除公共子表达式和移动循环不变代码。参见8.3 编译器优化的障碍:纯函数

假设没有指针别名。__declspec(noalias)__restrict#pragma optimize("a", on),假设没有指针别名。见8.3 编译器优化的障碍:指针别名的解释。注意到这些指令并不是总是有用的。

数据对齐。__declspec(align(16))__atrribute__((aligned(16))),指定数组和结构体的对齐。这对向量操作非常有用,参见12 使用向量操作

8.7 检查编译器做了什么

研究编译器生成的代码,看看它如何优化代码,这是非常有用的。有时编译器会做一些非常巧妙的事情来提高代码的效率,而有时它会做一些非常愚蠢的事情。查看编译器输出通常能够发现可以通过修改源代码来改进的内容,如下面的示例所示。

检查编译器生成的代码的最佳方法是将编译器选项设置位输出汇编语言。在大多数编译器上,可以从命令行调用编译器来,通过使用所有相关优化选项和选项-S/Fa 以输出汇编代码来实现这一点。一些系统上的IDE 也提供输出汇编代码的选项。如果编译器有输出汇编语言的选项,则可以使用目标文件反汇编器。

请注意 Intel 编译器 在输出汇编代码时,有源代码注释选项:FAs-fsource-asm。这个选项使汇编输出的可读性更高,但不幸的是这会妨碍某些特定的优化。如果你想要看到拥有全部优化的结果,请不要使用源代码注释选项。

还可以在调试器的反汇编窗口中看到编译器生成的代码。但是,你在调试器中看到的代码不是优化后的版本,因为调试选项阻止了优化。调试器无法在完全优化的代码中设置断点,因为它没有行号信息。通常可以使用的内联汇编指令在代码中插入一个固定的断点:interrupt 3。代码是 __asm int 3,或 __asm("int 3"),或 __debugbreak ()。如果你在调试器中运行优化的代码(release 版本),那么它将在 interrupt 3 断点处中断,并显示反汇编后的代码,可能没有关于函数名和变量名的信息。记住删除断点interrupt 3

下面的示例显示编译器的汇编代码输出是什么样子的,以及如何使用它来改进代码。

1
2
3
4
5
6
7
8
9
10
// Example 8.26a

void Func(int a[], int & r)
{
int i;
for (i = 0; i < 100; i++)
{
a[i] = r + i/2;
}
}

对于例 8.26a(32位模式)生成以下汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
; Example 8.26a compiled to assembly:
ALIGN 4 ; align by 4
PUBLIC ?Func@@YAXQAHAAH@Z ; mangled function name
?Func@@YAXQAHAAH@Z PROC NEAR ; start of Func
; parameter 1: 8 + esp ; a
; parameter 2: 12 + esp ; r
$B1$1: ; unused label
push ebx ; save ebx on stack
mov ecx, DWORD PTR [esp+8] ; ecx = a
xor eax, eax ; eax = i = 0
mov edx, DWORD PTR [esp+12] ; edx = r
$B1$2: ; top of loop
mov ebx, eax ; compute i/2 in ebx
shr ebx, 31 ; shift down sign bit of i
add ebx, eax ; i + sign(i)
sar ebx, 1 ; shift right = divide by 2
add ebx, DWORD PTR [edx] ; add what r points to
mov DWORD PTR[ecx+eax*4],ebx ; store result in array
add eax, 1 ; i++
cmp eax, 100 ; check if i < 100
jl $B1$2 ; repeat loop if true
$B1$3: ; unused label
pop ebx ; restore ebx from stack
ret ; return
ALIGN 4 ; align
?Func@@YAXQAHAAH@Z ENDP ; mark end of procedure

编译器生成的大多数注释已经被我的注释(灰色)所取代。阅读和理解编译器生成的汇编代码需要一定的经验。让我详细解释一下上面的代码。看着有点怪异的名字 ?Func@@YAXQAHAAH@ZFunc 的名称,其中添加了许多关于函数类型及其参数的信息。这叫做名称重整(name mangling)。汇编的名称允许使用 “?” 、“@”和“$”。有关名称重整的详细信息在手册5:“Calling conventions for different C++ compilers and operating systems”中有解释。参数 ar 在地址为 esp+8esp+12 的栈上传递,并分别加载到 ecxedx 中(在64位模式下,参数将在寄存器中传递,而不是在栈中)。ecx现在包含数组 a 的第一个元素的地址,edx 包含 r 指向的变量的地址。引用和指针在汇编代码中是一样的。寄存器 ebx 在使用之前入栈,在函数返回之前出栈。这是因为寄存器使用约定不允许函数更改ebx 的值。只有寄存器 eaxecxedx 可以自由更改。循环计数器 i 作为寄存器变量存储在 eax 中。循环初始化条件 i=0,已翻译成指令 xor eax,eax。这是一种将寄存器设置为 0 的常见方法,比 mov eax, 0 更快。循环体从标签 $B1$2 开始。这只是编译器为标签选择的任意名称。它使用 ebx 作为计算 i/2+r 的临时寄存器。指令 mov ebx,eax / shr ebx,31i 的符号位复制到 ebx的最小有效位。接下来的两条指令是 add ebx, eax / sar ebx,1把这个加到i上然后向右移动一个位置以便将i除以2。指令 add ebx, DWORD PTR [edx] 加到 ebx 上的不是 edx,而是地址位为 edx 中值的变量。方括号表示使用 edx 中的值作为内存指针。这是 r 所指向的变量。现在 ebx 包含 i/2+r。下一条指令 mov DWORD PTR [ecx+eax*4],ebx 将这个结果存储在 a[i] 中。注意数组地址的计算是很高效的。ecx 包含数组开头的地址。eax 保存了索引 i,这个索引必须乘以每个数组元素的大小(以字节为单位)才能计算出第 i 个元素的地址,int 的大小是 4。所以数组元素 a[i] 的地址是 ecx+eax*4。结果 ebx 存储在地址 [ecx+eax*4]。这都是在一条指令中完成的。CPU 支持这种指令来快速访问数组元素。指令 add eax,1 是循环增量 i++cmp eax, 100/ jl $B1$2 是循环条件 i < 100。它将 eax 与 100 进行比较,如果 i < 100,则跳回回 $B1$2 标签。pop ebx 恢复在开始时保存的 ebx 值。ret 从函数返回。

汇编代码清单显示了三个可以进一步优化的地方。我们注意到的第一个地方是它对 i 的符号做了一些怪异的处理,以便将 i 除以2。编译器没有注意到 i 不能是负的,所以我们不需要关心符号位。我们可以通过将 i 声明为无符号整型数或在除以 2 之前将 i 的类型转换为无符号整型数,来告诉编译器这一点(参见14.5 整数除法)。

我们注意到的第二个地方是,r 所指向的值会从内存中重新加载 100次。这是因为我们忘记告诉编译器假设没有指针别名(8.3 编译器优化的障碍:指针别名)。添加编译器选项“assume no pointer aliasing”(如果可用的化)有可能改善代码。

第三个可以改进的是 r+i/2 可以通过归纳变量来计算因为它是循环索引的阶梯函数(staircase function)。整数除法将会防止编译器生成归纳变量,除非循环由 2 展开。(8.1 编译器是如何优化的:循环展开)。

结论是,我们可以帮助编译器优化例 8.26a,方法是将循环由 2 展开,并创建一个显式归纳变量。(这消除了对前两个改进建议的需要)。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Example 8.26b

void Func(int a[], int & r)
{
int i;
int Induction = r;
for (i = 0; i < 100; i += 2)
{
a[i] = Induction;
a[i+1] = Induction;
Induction++;
}
}

例 8.26b编译器将生成以下汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
; Example 8.26b compiled to assembly:
ALIGN 4 ; align by 4
PUBLIC ?Func@@YAXQAHAAH@Z ; mangled function name
?Func@@YAXQAHAAH@Z PROC NEAR ; start of Func
; parameter 1: 4 + esp ; a
; parameter 2: 8 + esp ; r
$B1$1: ; unused label
mov eax, DWORD PTR [esp+4] ; eax = address of a
mov edx, DWORD PTR [esp+8] ; edx = address in r
mov ecx, DWORD PTR [edx] ; ecx = Induction
lea edx, DWORD PTR [eax+400] ; edx = point to end of a
$B2$2: ; top of loop
mov DWORD PTR [eax], ecx ; a[i] = Induction;
mov DWORD PTR [eax+4], ecx ; a[i+1] = Induction;
add ecx, 1 ; Induction++;
add eax, 8 ; point to a[i+2]
cmp edx, eax ; compare with end of array
ja $B2$2 ; jump to top of loop
$B2$3: ; unused label
ret ; return from Func
ALIGN 4
; mark_end;
?Func2@@YAXQAHAAH@Z ENDP

这个解决方案显然更好。尽管它在一个循环中执行了两次迭代,但循环体现在只包含 6条指令,而不是 9条。编译器用包含当前数组元素地址的第二个归纳变量(eax)来代替 i。它没有将 i 与循环条件中的100 进行比较,而是将数组指针 eax 与数组末端的地址进行比较,后者是它预先计算并存储在 edx中。此外,这个解决方案少使用了一个寄存器,这样它就不必压入和弹出 ebx

9 优化内存访问

9.1 代码和数据缓存

高速缓存是计算机主存的代理。代理更小,更接近CPU,比主内存快,因此访问速度更快。可能有两级或者三级缓存,以便尽可能快地访问使用最多的数据。

CPU 的速度比RAM 内存的速度增长得快。因此,高效的缓存变得越来越重要。

9.2 缓存结构

如果你正在编写需要非顺序访问大数据结构的程序,并且希望防止缓存竞争,那么如果了解缓存是如何组织的,将会很有有用。如果你喜欢更多的启发式指导原则,可以跳过本节。

大多数缓存被组织成行和组。让我用一个例子来解释一下。我的示例是一个大小为 8kb、行大小为 64字节的缓存。每行包含 64个连续字节的内存。1KB 是 1024字节,所以我们可以计算出行数是 8*1024/64 = 128。这些行按 32组,每组 4个缓存线的方式组织在一起。这意味着不能将特定的内存地址加载到任意的缓存行中。只能使用 32组缓存中的一个,但是在同一个组中的4行可以随意使用。我们可以通过公式:(set) = (memory address) / (line size) % (number of sets),来计算特定内存地址使用缓存中的哪个组。在这里,/ 表示带截断的整数除法,% 表示模。例如,如果我们想从内存地址a = 10000中读取数据,那么我们有(set) =(10000 / 64) % 32 = 28$。这意味着必须将a读入第 28组中的 4个缓存线之一。如果我们使用十六进制数,计算就会变得更简单,因为所有的数都是 2的幂。使用十六进制数,我们得到a = 0x2710(set) = (0x2710 / 0x40) % 0x20 = 0x1C$。从地址 0x2710 读取或写入变量将导致缓存将地址 0x27000x273F 的全部 640x40 字节加载到组 0x1C 的4个缓存行之一。如果程序随后读取或写入该范围内的任何其他地址,那么该值已经在缓存中,因此我们不必等待另一个内存访问。

假设一个程序从地址 0x2710 读取数据,之后从地址 0x2F000x37000x3F000x4700 读取数据。这些地址都属于组 0x1C。每组中只有 4条缓存线。如果缓存总是选择最近使用最少的缓存线,那么当我们从 0x4700 读取数据时,将会覆盖地址范围从 0x27000x273F 的内容。再次读取地址0x2710将导致缓存不命中。但是,如果程序从具有不同设置值的不同地址读取,那么包含地址范围从 0x27000x273F 的行仍然在缓存中。问题只出现在地址之间的间隔是 0x800 的倍数的情况下。我把这段距离称为关键步长。内存中的距离是关键步长的倍数的变量将争夺相同的缓存线。关键步长可以这样计算:(critical stride) = (number of sets) * (line size) = (total cache size) / (number of ways)

如果一个程序包含许多分散在内存中的变量和对象,那么就存在这样一种风险,即多个变量恰好被多个关键步长隔开,从而在数据缓存中引起竞争。如果程序内存中有许多分散的函数,那么在代码缓存中也会发生同样的情况。如果在程序的同一部分中使用的几个函数恰好被多个关键步间隔,那么这可能会在代码缓存中引起竞争。接下来的章节将描述各种避免这些问题的方法。

关于缓存如何工作的更多细节可以在 Wikipedia 的词条:CPU缓存中找到。

手册3:“The microarchitecture of Intel, AMD and VIA CPUs”涵盖了不同处理器的缓存组织细节。

9.3 一起使用的函数应该被放在一起

如果在代码内存中使用的函数彼此接近,那么代码缓存的工作效率最高。函数通常按照它们在源代码中出现的顺序存储。因此,最好将代码中最关键部分中使用的函数集中在同一个源文件中,这些函数彼此相邻。将经常使用的函数与很少使用的函数分开,并将很少使用的分支(如错误处理)放在函数的末尾或单独的函数中。

有时,为了模块化,函数被保存在不同的源文件中。例如,在一个源文件中有父类的成员函数,在另一个源文件中有派生类的成员函数,这样做可能比较方便。如果父类和派生类的成员函数是在程序的相同关键部分被调用的,那么在程序内存中保持这两个模块的连续是有利的。这可以通过控制模块链接的顺序来实现。链接顺序通常是模块在项目窗口或makefile中出现的顺序。你可以通过向链接器请求映射文件来检查内存中函数的顺序。映射文件告诉每个函数相对于程序开始的地址。映射文件包含从静态库链接(.lib.a )的库函数的地址,但不是动态库(.dll.so)。没有一种简单的方法可以控制动态链接库函数的地址。

9.4 一起使用的变量应该被放在一起

缓存不命中的代码是非常高昂的。从缓存中加载一个变量只需要几个时钟周期,但是如果变量不在缓存中,那么将需要耗费超过 100个时钟周期的时间从RAM 加载它。

如果一起使用的数据片段在内存中彼此靠近存储,则缓存的工作效率最高。变量和对象最好在使用它们的函数中声明。这些变量和对象将存储在栈中,栈很可能在一级缓存中。7.1 不同类型变量的存储解释了不同类型的变量存储。如果可能,避免全局变量和静态变量,并避免动态内存分配(newdelete)。

面向对象编程是一种将数据存储在一起的有效方法。类的数据成员(也称为属性)总是一起存储在类的对象中。父类和派生类的数据成员一起存储在派生类的对象中(参见7.19 类和结构体)。

如果代码中有大数据结构,那么存储数据的顺序可能非常重要。例如,如果一个程序有两个数组,ab,并且元素的访问顺序是a[0]b[0]a[1]b[1],…,然后,你可以通过将数据组织为结构体的数组来提高性能:

1
2
3
4
5
6
7
8
9
10
// Example 9.1a

int Func(int);
const int size = 1024;
int a[size], b[size], i;
...
for (i = 0; i < size; i++)
{
b[i] = Func(a[i]);
}

如果按如下方法组织数据,那么这个例子中的数据可以在内存中被按顺序访问:

1
2
3
4
5
6
7
8
9
10
11
12
// Example 9.1b

int Func(int);
const int size = 1024;
struct Sab {int a; int b;};
Sab ab[size];
int i;
...
for (i = 0; i < size; i++)
{
ab[i].b = Func(ab[i].a);
}

使用例 9.1b中这样的数据结构,程序代码中将不会有额外的开销。相反的,代码将变的更加简单,因为只需要计算一个数组的地址,而不是两个。

一些编译器将为不同的数组使用不同的内存空间,即使它们从未同时被使用过。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Example 9.2a

void F1(int x[]);
void F2(float x[]);
void F3(bool y)
{
if (y)
{
int a[1000];
F1(a);
}
else
{
float b[1000];
F2(b);
}
}

在这里,可以为 ab 使用相同的内存区域,因为它们的活动范围不重叠。通过将 ab 放入 union 中,可以节省大量缓存空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Example 9.2b

void F3(bool y)
{
union
{
int a[1000];
float b[1000];
};
if (y)
{
F1(a);
}
else
{
F2(b);
}
}

当然,使用 union 不是一种安全的编程实践,因为如果 ab 的使用重叠,编译器不会发出警告。你应该只对占用大量缓存空间的大型对象使用此方法。将简单变量放入union中不是最佳选择,因为它会阻止寄存器变量的使用。

9.5 数据对齐

如果将变量存储在可被变量大小整除的内存地址中,则访问该变量的效率最高。例如,double 占用 8字节的存储空间。因此,最好将其存储在可被 8整除的地址中。大小应该总是 2的幂。大于 16字节的对象应该存储在可被 16整除的地址中。你通常可以假设编译器会自动处理这种对齐。

结构和类成员的对齐可能会造成缓存空间的浪费,如7.20 类的数据成员(变量实例)例 7.39所展示的那样。

你可以选择按缓存线大小对齐大型对象和数组(通常是 64字节)。这可以确保对象或数组的开头与缓存线的开头一致。一些编译器会自动对齐大的静态数组,但你也可以通过以下方式显示指定:

1
__declspec(align(64)) int BigArray[1024]; // Windows syntax


1
int BigArray[1024] __attribute__((aligned(64))); // Linux syntax

关于动态分配内存对齐的讨论参见9.7 容器类12.8 对齐动态分配的内存12.9 对齐RGB视频或三维向量

9.6 动态分配内存

对象和数组可以通过 newdeletemallocfree 动态分配。在编译时期不知道所需的内存大小时,这可能非常有用。下面是动态内存分配的四种典型用法:

  1. 可以在编译时不知道数组大小的情况下动态分配大数组。
  2. 当编译时不知道对象总数时,可以动态分配可变数量的对象。
  3. 可以动态分配文本字符串和类似大小可变对象。
  4. 对于栈来说太大的数组可以动态分配。

动态分配内存的优点有:

  1. 在某些情况下提供了更清晰的程序结构。
  2. 不会分配超过所需的空间。缓存效率与为了覆盖最坏的情况下最大可能的内存要求,固定大小的数组变的很大时相比,会高的多。
  3. 当不能预先给出所需内存空间的合理上限时,这是非常有用的。

动态分配内存的缺点有:

  1. 动态分配和释放内存的过程比其他类型的存储需要更多的时间。见7.1 不同类型变量的存储
  2. 当以随机顺序分配和释放不同大小的对象时,堆空间就会变得碎片化。这使得数据缓存效率低下。
  3. 如果已分配的数组已满,则可能需要调整其大小。这可能需要分配一个新的更大的内存块,并将整个内容复制到新块中。指向旧块中的数据的任何指针都将失效。
  4. 当堆空间变得过于碎片化时,堆管理器将启动垃圾收集。此垃圾收集可能在不可预测的时间开始,并在用户等待响应的不方便的时间导致程序执行的延迟。
  5. 程序员有责任确保已分配的所有内容也被释放。如果不这样做,将导致堆被填满。这是一种常见的编程错误,称为内存泄漏。
  6. 序员有责任确保在释放对象之后没有对象被访问。没有这么做也是一个常见的编程错误。
  7. 所分配的内存可能不是最佳对齐的。有关如何对齐动态分配的内存,请参见12.8 对齐动态分配的内存
  8. 编译器很难优化使用指针的代码,因为它不能排除别名(参见8.3 编译器优化的障碍:指针别名)。
  9. 当行长度在编译时是未知的,矩阵或多维数组的效率较低,因为在每次访问时需要额外的工作来计算行地址。编译器可能无法使用归纳变量对其进行优化。

在决定是否使用动态内存分配时,权衡利弊是很重要的。当数组的大小或对象的数量在编译时已知或可以知道合理的上限时,没有理由使用动态内存分配。

当分配的数量有限时,动态内存分配的成本可以忽略不计。因此,当一个程序有一个或几个可变大小的数组时,动态内存分配是有利的。另一种解决方案是将数组设置得非常大,以覆盖最坏的情况,但这会浪费缓存空间。如果一个程序有几个大数组,并且每个数组的大小是关键步长(参见9.2 缓存结构)的倍数,那么很可能会在数据缓存中引起竞争。

如果一个数组中的元素数量在程序执行期间增长,那么最好从一开始就分配最终的数组大小,而不是一步一步地分配更多的空间。在大多数系统中,你无法增加已经分配的内存块的大小。如果最终大小无法预测,或者预测结果太小,那么就需要分配一个新的更大内存块,并将旧内存块的内容复制到新的更大内存块的开头。当然,这是低效的,并且会导致堆空间变得碎片化。另一种方法是保留多个内存块,要么以链表的形式,要么以内存块的索引的形式。具有多个内存块的方法使得对单个数组元素的访问更加复杂和耗时。

一个可变数量的对象集合通常被实现为一个链表。链表中的每个元素都有自己的内存块和指向下一个块的指针。链表的效率不如线性数组,原因如下:

  1. 每个对象都是单独分配的。分配、释放和垃圾收集需要大量的时间。
  2. 对象没有连续地存储在内存中。这会降低数据缓存的效率。
  3. 额外的内存空间用于链接指针和堆管理器为每个分配的块存储的信息。
  4. 遍历链表比遍历线性数组要花费更多的时间。在加载前一个元素指针之前,不能加载任何指针。这就形成了一个关键的依赖链,这会妨碍乱序执行。

为所有对象分配一个大内存块(内存池)通常比为每个对象分配一个小内存块效率更高。

使用 newdelete 分配可变大小的数组的一个鲜为人知的替代方法是使用 alloca 分配来代替。这是一个在栈上而不是堆上分配内存的函数。内存空间在当从调用 alloca 的函数返回时会被自动释放。在使用 alloca 时,不需要显式地释放空间。与 newdeletemallocfree 相比,alloca 的优势有:

  1. 分配过程的开销很小,因为微处理器有硬件支持对栈的操作。
  2. 由于堆栈的先入后出特性,内存空间不会变得支离破碎。
  3. 重新分配没有成本,因为它在函数返回时将自动执行。不需要垃圾收集。
  4. 所分配的内存与栈上的其他对象是连续的,这使得数据缓存非常高效。

下面的例子将展示如何适应alloca分配可变大小的数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <malloc.h>
void SomeFunction (int n)
{
if (n > 0)
{
// Make dynamic array of n floats:
float * DynamicArray = (float *)alloca(n * sizeof(float));
// (Some compilers use the name _alloca)
for (int i = 0; i < n; i++)
{
DynamicArray[i] = WhateverFunction(i);
// ...
}
}
}

显然,函数不应该返回任何使用 alloca 分配的指针或引用,因为它在函数返回时被释放。alloca 可能与结构化异常处理不兼容。有关使用 alloca 的限制,请参阅编译器手册。

C99 扩展支持可变大小的数组。这个特性是有争议的,并且只在 C 中可用,而不能在C++ 中使用。你可以使用 alloca 而不是可变大小的数组,因为它提供了相同的功能。

9.7 容器类

每当使用动态内存分配时,建议将分配的内存包装到容器类中。容器类必须具有析构函数,以确保所分配的所有内容也被释放。这是防止内存泄漏和与动态内存分配相关的其他常见编程错误的最佳方法。

容器类还可以方便地向数组中添加边界检查,以及使用先进的数据结构,如先进先出(或先进后出)访问、排序和搜索工具、二叉树、哈希映射等。

通常以模板的形式创建容器类,其中包含的对象类型作为模板参数提供。使用模板没有性能成本。

现成的容器类模板可用于许多不同的用途。最常用的容器集是标准模板库(STL ),它时大多数现代 C++编译器 自带的。使用现成容器的优点是你不必重新发明轮子。STL 中的容器是通用的、灵活的、经过良好测试的,对于许多不同的用途都非常有用。

然而,STL 是以通用性和灵活性为准则而设计,而执行速度、内存经济性、缓存效率和代码大小的优先级较低。特别是在 STL 中,内存分配存在不必要的浪费。一些 STL 模板,如 listsetmap,甚至可能分配比容器中对象更大的内存块。STL deque(双向链表)为每四个对象分配一个内存块。STL vector 将所有的对象都存储在同一个内存块中,当这快内存被填满时会重新分配,这种情况经常发生,因为块大小每次只增长 50%或更少。在一个实验中,10个元素被一个接一个地插入STL vector中,结果导致内存一共重新分配了 7次,大小分别是 1、2、3、4、5、6和13(MS Visual Studio 2008 version)。可以在将第一个对象添加到 vector 之前,可以通过调用 vector::reserve 预先分配预测或估计的最终大小的内存来防止这种浪费的行为。其他 STL 容器没有预先分配内存的功能。

频繁地使用newdelete(或mallocfree)分配和释放内存会导致内存碎片化和缓存效率低下。如上所述,这会导致内存管理和垃圾收集的开销很大。

STL 的通用性还会影响代码的大小。实际上STL 因代码膨胀和复杂性而饱受批评(en.wikipedia.org/wiki/Standard_Template_Library)。存储在 STL 容器中的对象允许具有构造函数和析构函数。每次移动对象时都会调用每个对象的复制构造函数和析构函数,而这种情况经常发生。如果存储的对象本身就是容器,那么这是必要的。但是在 STL 使用一个或多个vector中实现一个矩阵,就像我们经常看到的那样,这肯定是一个非常低效的解决方案。

许多容器使用链表。链表是使容器可扩展的一种简便方法,但效率非常低。在大多数情况下,线性数组比链表快。

STL 中用于访问容器元素的所谓的迭代器对于许多程序员来说很麻烦,如果你可以使用带有简单索引的线性列表,那么它们就不是必需的。一个好的编译器可以在某些情况下优化掉迭代器的额外开销(但不是全部)。

幸运的是,在执行速度、节约内存和代码大小比代码通用性具有更高优先级的情况下,可以使用效率更高的替代方案。最重要的补救措施是内存池。将多个对象存储在一个大内存块中比将每个对象存储在它自己分配的内存块中更有效。如果没有复制构造函数和析构函数要调用,可以通过对 memcpy 的一次调用复制或移动包含许多对象的大块,而不是单独移动每个对象。

我实现了一组示例容器类,它们使用这些方法来提高效率。这些文件作为本手册的附录可以在www.agner.org/optimize/cppexamples.zip上获得,其中包含用于不同用途的容器类和模板。所有这些示例都针对执行速度和最小化内存碎片进行了优化。为了安全起见,包含了边界检查,但是如果出于性能原因需要,可以在调试之后删除。在 STL 的性能不令人满意的情况下,可以使用这些示例容器。

在为特定用途选择容器时,应考虑以下因素:

  1. 包含一个还是多个元素?如果容器包含一个元素,那么可以使用智能指针(见7.9 智能指针)。
  2. 编译时是否知道大小?如果在编译时已知元素的数量,或者可以设置不太大的上限,那么最优解决方案是一个固定大小的数组或容器,而不需要动态内存分配。但是,如果数组或容器对于栈来说太大的时候,则可能需要动态内存分配。
  3. 在存储第一个元素之前,大小是否已知?如果在存储第一个元素之前可以知道元素的总数(或者有一个合理的估计),那么最好使用允许预先分配(reserve)内存的容器,而不是分段分配内存或当内存块太小的时候重新分配。
  4. 对象是连续编号的么?如果对象是由连续的索引或有限范围内的键标识的,那么简单的数组是高效的解决方案。
  5. 对象是以先进先出的方式访问的么?如果在先进先出(FIFO)的基础上访问对象,则使用队列。将队列作为循环缓冲区而不是链表使用更高效。
  6. 对象是以先进后出的方式访问的么?如果对象是在先入后出(FILO)的基础上访问的,那么使用带有栈顶部索引的线性数组。
  7. 对象是由键标识的么?如果键值被限制在一个较窄的范围内,那么可以使用一个简单的数组。如果对象的数量很多,那么最高效的解决方案可能是二叉树或哈希图。
  8. 对象有顺序吗?如果你需要做这样的搜素:“离元素 x 最近的是哪个?”或者 “在 x 和 y之间有多少个元素?”,那么你可以使用有序列表或者二叉树。
  9. 添加所有对象之后是否需要搜索?如果需要搜索工具,但必须在容器中存储了所有对象之后,那么线性数组将是一个高效的解决方案。在添加所有元素之后对数组进行排序,然后使用二分搜索来查找元素。哈希表也可能是一种高效的解决方案。
  10. 添加所有对象之前是否需要搜索?如需要搜索工具,并且可以随时添加新对象,那么解决方案就更复杂了。如果元素的总数很少,那么有序列表是最高效的解决方案,因为它的简单。但是如果列表很大,有序列表会非常低效,因为在列表中插入一个新元素会导致所有后续元素都需要移动。在这种情况下我们需要二叉树或者哈希表。如果元素是有序的,并且在一定间隔后就会有搜素请求,那么可以使用二叉树。哈希表则可以在元素没有特定顺序但又唯一的键标识时使用。
  11. 对象是否具有混合类型或大小?可以在同一个内存池中存储不同类型的对象或不同长度的字符串。见 www.agner.org/optimize/cppexamples.zip。如果在编译时知道元素的数量和类型,那么就不需要使用容器或内存池。
  12. 是否要对齐?一些应用程序要求数据按可以被整除的地址对齐。特别是使用向量指令时,需要对齐的地址可以被 16整出。在某些情况下,将数据结构对齐到可被缓存线大小整除的地址(通常为64)可以提高性能。
  13. 是否使用多线程?如果多个线程可以同时添加、删除或修改对象,那么容器类通常不是线程安全的。在多线程应用程序中,为每个线程设置单独的容器要比临时锁定一个容器以供每个线程独占访问高效的多。
  14. 有指向包含的对象的指针么?将指针指向包含的对象可能是不安全的,因为容器可能在需要重新分配内存时移动对象。容器内的对象应该通过其在容器中的索引或键来标识,而不是通过指针或引用。但是,如果没有其他线程访问容器,则可以将指向此类对象的指针或引用传递给不添加或删除任何对象的函数。
  15. 容器可以被回收么?创建和删除容器的消耗很大。如果程序的逻辑允许,复用一个容器可能比删除它再重新创建一个更高效。

我在www.agner.org/optimize/cppexamples.zip中提供了几个合适的容器类模板示例。如果不需要标准模板库容器的通用性和灵活性,那么可以使用它们作为标准模板库(STL )的替代品。你可以编写自己的容器类,或者修改可用的容器类来满足特定的需求。

9.8 字符串

文本字符串通常具有在编译时不知道的可变长度。文本字符串在 stringwstringCString 等类中的存储使用 newdelete 来在每次创建或修改字符串时分配一个新的内存块。如果一个程序创建或修改了很多字符串,这可能是非常低效的。

在大多数情况下,处理字符串最快的方法是使用老式 C 风格的字符数组。字符串可以通过 C 函数如 strcpystrcatstrlensprintf 等进行操作。但是要注意,这些函数没有检查数组是否溢出。数组溢出会导致在程序的其他地方出现难以预测的错误,这些错误很难诊断。程序员有责任确保数组足够大,能够处理包括终止符(0)在内的字符串,并在必要时进行溢出检查。在www.agner.org/optimize/asmlib.zipasmlib 库中提供了常用字符串函数的快速版本以及用于字符串搜索和解析的高效函数。

如果希望在不影响安全性的情况下提高速度,可以将所有字符串存储在内存池中,如上所述,在本手册的附录www.agner.org/optimize/cppexamples.zip中提供相关示例。

9.9 按顺序访问数据

当按顺序访问数据时,缓存的工作效率最高。当逆序访问数据时,它的工作效率略低,而当以随机方式访问数据时,它的工作效率则低得多。这对读取和写入数据都是适用的。

多维数组应该以在最内层循环中变化最后一个索引的方式进行访问。这反映了元素在内存中存储的顺序。例如:

1
2
3
4
5
6
7
8
// Example 9.4

const int NUMROWS = 100, NUMCOLUMNS = 100;
int matrix[NUMROWS][NUMCOLUMNS];
int row, column;
for (row = 0; row < NUMROWS; row++)
for (column = 0; column < NUMCOLUMNS; column++)
matrix[row][column] = row + column;

不要交换这两个循环的顺序(除非是在 Fortran 中,具有相反的存储顺序)。

9.10 在大数据结构中的缓存竞争

按顺序访问多维数组并不总是可能的。一些应用程序(例如,在线性代数中)需要其他访问模式。如果一个大矩阵中的行之间的距离恰好等于关键步长,就会导致严重的延迟,如9.2 缓存组织所述。如果矩阵行的大小(以字节为单位)是 2 的高次幂,就会发生这种情况。

下面的例子说明了这一点。我的例子是一个对二次矩阵进行转置的函数,即每个元素矩阵 [r][c] 与元素矩阵 [c][r] 交换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Example 9.5a
const int SIZE = 64;// number of rows/columns in matrix
void transpose(double a[SIZE][SIZE])// function to transpose matrix
{
// define a macro to swap two array elements:
#define swapd(x,y) {temp=x; x=y; y=temp;}
int r, c; double temp;
for (r = 1; r < SIZE; r++)
{ // loop through rows
for (c = 0; c < r; c++)
{
// loop columns below diagonal
swapd(a[r][c], a[c][r]); // swap elements
}
}
}

void test ()
{
__declspec(__align(64)) // align by cache line size
double matrix[SIZE][SIZE]; // define matrix
transpose(matrix); // call transpose function
}

矩阵的转置和以对角线为轴做镜像是一样的。对角线以下的每个元素矩阵 [r][c] 在对角线以上的镜像位置与元素矩阵 [c][r]交换。例 9.5a中的循环 c 从最左边的列到对角线。对角线上的元素保持不变。

这段代码的问题是,如果对角线以下的元素矩阵 [r][c] 是逐行访问的,那么对角线以上的镜像元素矩阵 [c][r] 是逐列访问的。

假设现在我们在奔腾4电脑上运行这段代码,矩阵的大小是 64。电脑的一级缓存为 8 kb = 8192 bytes,4 路,行大小为 64。每个缓存行可以保存8个 double 变量,每个变量的大小为8个字节。关键步长为 $8192/4=2048 bytes = 4 rows$。

让我们看看循环内部发生了什么,例如当 r = 28 时。我们从对角线以下的第 28行取出元素,并将这些元素与对角线以上的第 28列交换。第 28行中的前 8个元素共享同一缓存线。因为缓存线按行而不是按列缓存,在第 28列中的 8个元素将进入 8个不同的缓存行中。每四个高速缓存线属于同一组高速缓存。当我们操作到第 28列中的16号元素时,缓存将收回该列中0号使用的缓存线。17号元素将覆盖1号元素使用的缓存线,18号元素将覆盖 2号元素使用的缓存线,依此类推。这意味着当我们将第 29列与第 29行交换时,对角线以上使用的所有缓存线都被覆盖了。因为在我们需要下一个元素之前,它会被删除,每个缓存线必须重新加载 8次。我已经通过使用不同矩阵大小的奔腾4上的示例9.5a来测量转置矩阵所需的时间来证实这一点。我的实验结果如下,时间单位是每个数组元素所需要要的时钟周期。

Matrix Size Total kilobytes Time per element
63*63 31 11.6
64*64 32 16.4
65*65 33 11.8
127*127 126 12.2
128*128 128 17.4
129*129 130 14.4
511*511 2040 38.7
512*512 2048 230.7
513*513 2056 38.1

Table 9.1. Time for transposition of different size matrices, clock cycles per element.

从表中可以看出,当矩阵的大小是一级缓存大小的倍数时,转置矩阵要多花 40%的时间。这是因为关键步长是矩阵行的倍数。由于无序执行机制可以预先加载数据,延迟比一级缓存从二级缓存中重新加载数据的时间少。

当竞争发生在二级缓存中时,这种效果更为显著。二级缓存$512 kb$,8路。二级缓存的关键步长是$512 kb / 8 = 64 kb$。这对应于$512512$矩阵中的16行数据。我在*表 9.1中的实验结果表明,在二级缓存中发生竞争时,转置矩阵所需的时间是不发生竞争时的 6倍。这种效果在二级缓存竞争中比在一级缓存竞争中强得多的原因是二级缓存一次不能预加载多行。

解决这个问题的一个简单方法是使矩阵中的行比需要的长,以避免关键步长是矩阵行大小的倍数。我试着让矩阵的大小为$512*520$,包含不使用最后 8列。这消除了竞争,时间消耗减少到 36个时钟周期。

在某些情况下,不可能向矩阵中添加未使用的列。例如,一个数学函数库应该能够有效地处理所有大小的矩阵。在这种情况下,一个有效的解决方案是将矩阵分成更小的正方形,一次处理一个正方形。这被称为square blocking tiling示例9.5b演示了这种技术:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Example 9.5b

void transpose(double a[SIZE][SIZE])
{
// Define macro to swap two elements:
#define swapd(x,y) {temp=x; x=y; y=temp;}
// Check if level-2 cache contentions will occur:
if (SIZE > 256 && SIZE % 128 == 0)
{
// Cache contentions expected. Use square blocking:
int r1, r2, c1, c2; double temp;
// Define size of squares:
const int TILESIZE = 8; // SIZE must be divisible by TILESIZE
// Loop r1 and c1 for all squares:
for (r1 = 0; r1 < SIZE; r1 += TILESIZE)
{
for (c1 = 0; c1 < r1; c1 += TILESIZE)
{
// Loop r2 and c2 for elements inside sqaure:
for (r2 = r1; r2 < r1+TILESIZE; r2++)
{
for (c2 = c1; c2 < c1+TILESIZE; c2++)
{
swapd(a[r2][c2],a[c2][r2]);
}
}
}
// At the diagonal there is only half a square.
// This triangle is handled separately:
for (r2 = r1+1; r2 < r1+TILESIZE; r2++)
{
for (c2 = r1; c2 < r2; c2++)
{
swapd(a[r2][c2],a[c2][r2]);
}
}
}
}
else
{
// No cache contentions. Use simple method.
// This is the code from example 9.5a:
int r, c; double temp;
for (r = 1; r < SIZE; r++)
{ // loop through rows
for (c = 0; c < r; c++)
{
// loop columns below diagonal
swapd(a[r][c], a[c][r]); // swap elements
}
}
}
}

在我的实验中,使用这段代码,对于512*512的矩阵来说,每个元素消耗50个时钟周期。

二级缓存中竞争的代价是如此的昂贵,因此对它们采取措施非常重要。因此,你应该了解矩阵中列数为 2的高次幂的情况。一级缓存中的竞争消耗较少,可能不值得为了一级缓存中使用像square blocking这么复杂的技术。

Squre blocking以及类似的技术在 S. Goedecker 和 A. Hoisie 2001年出版的 “Performance Optimization of Numerically Intensive Codes”一书中有更详细的描述。

9.11 显示缓存控制

具有 SSESSE2 指令集的微处理器具有某些指令,允许你操作数据缓存。这些指令可以从支持指令集函数(如MicrosoftInte l和 Gnu )的编译器中访问。其他编译器需要通过汇编代码来访问这些指令。

Function Assembly name Intrinsic function name Instruction set
Prefetch PREFETCH _mm_prefetch SSE
Store 4 bytes without cache MOVNTI _mm_stream_si32 SSE2
Store 8 bytes without cache MOVNTQ _mm_stream_pi SSE
Store 16 bytes without cache MOVNTPS _mm_stream_ps SSE
Store 16 bytes without cache MOVNTPD _mm_stream_pd SSE2
Store 16 bytes without cache MOVNTDQ _mm_stream_si128 SSE2

Table 9.2. Cache control instructions.

除了表 9.2中提到的指令外,还有其他缓存控制指令,如flusefence 指令,但这些指令与优化几乎没有关系。

预加载数据

预取指令可用于预先加载我们希望稍后在程序流中使用的缓存线。然而,在我测试的所有示例中,这并没有提高执行速度。原因是由于无序执行和先进的预测机制,现代处理器能自动预取数据。现代微处理器能够在包含多个具有不同步长的数据流的有规律访问模式下自动预取数据。因此,如果按照固定步长有规律地访问数据,则不必显式地预取数据。

未缓存内存存储(uncached memory store)

未缓存写入(uncached write)比未缓存读取(uncached read)开销更大,因为写入会需要先读取。然后再写回整个缓存行。

所谓的非时序写指令(MOVNT)就是为了解决这个问题而设计的。这些指令直接写入内存,而无需加载缓存线。如果我们正在写入未缓存的内存中,并且我们不希望在缓存线被清除之前再次从相同的或附近的地址读取数据,那么这是非常有利的。不要在同一个内存区域同时使用非时序的写操作和普通的写操作或读操作。

非时序写指令不适合例 9.5,因为我们在相同的地址读和写,所以无论如何都会加载缓存线。如果我们修改例 9.5,使它只写,那么非时序写指令的效果就会很明显。下面的示例对一个矩阵进行置换,并将结果存储在不同的数组中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Example 9.6a
const int SIZE = 512; // number of rows and columns in matrix
// function to transpose and copy matrix
void TransposeCopy(double a[SIZE][SIZE], double b[SIZE][SIZE])
{
int r, c;
for (r = 0; r < SIZE; r++)
{
for (c = 0; c < SIZE; c++)
{
a[c][r] = b[r][c];
}
}
}

这个函数逐列写入矩阵 a,而由于关键步长导致所有写入都在一级缓存和二级缓存中都需要加载新的缓存线。使用非时序写指令可以防止二级缓存为矩阵 a 的加载任何缓存线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Example 9.6b.
#include "xmmintrin.h" // header for intrinsic functions
// This function stores a double without loading a cache line:
static inline void StoreNTD(double * dest, double const & source)
{
_mm_stream_pi((__m64*)dest, *(__m64*)&source); // MOVNTQ
_mm_empty(); // EMMS
}
const int SIZE = 512; // number of rows and columns in matrix
// function to transpose and copy matrix
void TransposeCopy(double a[SIZE][SIZE], double b[SIZE][SIZE])
{
int r, c;
for (r = 0; r < SIZE; r++)
{
for (c = 0; c < SIZE; c++)
{
StoreNTD(&a[c][r], b[r][c]);
}
}
}

在奔腾4计算机上测量了不同矩阵大小下每个矩阵单元的执行时间。测量结果如下:

Matrix size Time per element Example 9.6a Time per element Example 9.6b
64*64 14.0 80.8
65*65 13.6 80.9
512*512 378.7 168.5
513*513 58.7 168.3

Table 9.3. Time for transposing and copying different size matrices, clock cycles per element.

表 9.3所示,当且仅当可能出现二级缓存不命中时,不经过缓存存储数据的方法才是有利的。$6464$的矩阵大小会导致一级缓存中出现不命中,但这对总执行时间几乎没有任何影响,因为一个存储操作上的缓存不命中不会延迟后续指令。$512 512$的矩阵大小导致二级缓存不命中。这对执行时间有非常显著的影响,因为内存总线已经饱和。这是可以通过使用非时序写来改善。如果缓存竞争可以通过其他方式防止,如9.10 在大数据结构中的缓存竞争所述,那么非时序写指令就不是最优的。

使用表 9.2中所列的指令有一定的限制。所有这些指令都要求微处理器具有表中所列的 SSESSE2 指令集。16字节指令MOVNTPSMOVNTPDMOVNTDQ要求操作系统支持 XMM 寄存器;参见13 使用不同指令集生成多个版本的关键代码

当使用#pragma vector nontemporal时,Intel编译器 可以在向量化代码中自动插入非时态写操作。然而,在例 9.6b中这并不适用。

在任何浮点指令之前,MOVNTQ 指令后必须跟着 EMMS 指令。代码为_mm_empty(),如例 9.6b所示。MOVNTQ 指令不能在 64位Windows 设备驱动程序中使用。

10 多线程

CPU 的时钟频率受到物理因素的限制。在时钟频率有限的情况下,提高 CPU 密集型程序的吞吐量的方法是同时做多个事情。有三种方法可以并行地执行任务:

  1. 使用多个 CPU多 核 CPU,如本章所述。
  2. 使用现代 CPU 的乱序执行能力,如第11章所述。
  3. 使用现代 CPU 的向量操作,如第12章所述。

多数现代 CPU 都拥有两个或更多个核心,可以预期的是,在未来核心的数量还会继续增加。为了使用多个 CPU 或者多个 CPU 核心,我们需要将任务划分到不同的线程。这里有两个主要的方法:功能分解和数据分解。功能分解意味着不同的线程做不同的工作。例如,一个线程处理用户界面,另一个线程处理和远程数据库的通信,第三个线程处理数学计算。将用户界面和耗时任务放在不同的线程中是很重要的,否则响应时间会变的长且不规则,这是很令人讨厌的。将耗时的任务放在低优先级的单独线程中通常是很有帮助的。

然而,在许多情况下,一个任务就消耗了大部分资源。在这种情况下,我们需要将数据分割成多个块,以便利用多个处理器内核。然后每个线程应该处理自己的数据块。这就是数据分解。

在决定并行处理是否有利时,区分粗粒度并行和细粒度并行非常重要。粗粒度并行是指长序列的操作可以独立于并行运行的其他任务的情况。细粒度并行是指任务被划分为许多小的子任务,但是在与其他子任务进行必要的协调之前,不可能在特定的子任务上工作很长时间。

由于不同内核之间的通信和同步比较慢,因此粗粒度并行比使用细粒度并行效率更高。如果粒度太细,那么将任务拆分为多个线程是没有优势的。无序执行(第11章)和向量操作(第12章)是利用细粒度并行的更有用的方法。

使用多个 CPU 内核的方法是将工作划分为多个线程。7.31 线程讨论了线程的使用。在数据分解的情况下,我们最好不要有比系统中可用的内核或逻辑处理器数量更多的具有相同优先级的线程。可用逻辑处理器的数量可以通过系统函数获得(例如 Windows 中的 GetProcessAffinityMask)。

有几种方法可以在多个CPU 内核之间划分工作负载:

  1. 定义多个线程,并在每个线程中投入等量的工作。此方法适用于所有编译器。
  2. 使用自动并行化。GnuIntelPathScale 编译器可以自动检测代码中的并行化机会,并将其划分为多个线程,但编译器可能无法找到数据的最佳分解方案。
  3. 使用 OpenMP 指令。OpenMPC++Fortran 中定义并行处理的标准。这些指令被 MicrosoftIntelPathScaleGnu 编译器所支持。有关详细信息,请参见www.openmp.org和编译器手册。
  4. 使用具有内部使用多线程的函数库,例如 Intel Math Kernel Library

多个 CPU 内核或逻辑处理器通常共享相同的缓存,至少在最后一级缓存中是这样,在某些情况下甚至共享相同的一级缓存。共享相同缓存的优点是线程之间的通信变得更快,并且线程可以共享相同的代码和只读数据。缺点是,如果线程使用不同的内存区域,缓存就会被填满,如果线程写入相同的内存区域,就会发生缓存竞争。

只读的数据可以在多个线程之间共享,而可以修改的数据应该被每个线程单独存储。让两个或多个线程写入同一缓存行是没有任何好处的,因为线程会使彼此的缓存无效,并造成较大的延迟。每个线程都有自己的堆栈。使数据特定于线程的最简单方法是在线程函数中声明它,使其为线程本地的,以便将其存储在堆栈中。或者,你可以为包含特定于线程的数据定义结构或类,并为每个线程创建一个实例。此结构或类应至少按缓存线大小进行对齐,以避免多个线程写入同一缓存线。在现代处理器上,缓存线大小通常为 64个字节。在未来的处理器上,缓存线大小可能会更大(128 或 256字节)。

线程之间有很多通信和同步方法,如信号量、互斥量和消息系统。所有这些方法都很耗时。因此,应该对数据和资源进行组织,以便尽可能减少线程之间的必要通信。例如,如果多个线程共享相同的队列、列表、数据库或者其他数据结构,那么你可以考虑能否给每个线程分配自己的数据,当所有线程完成耗时的数据处理后,最后再将多个数据合并起来。

如果在一个只有一个逻辑处理器的系统上运行多个线程,而这些线程会争夺相同的资源,那么这不是一种优势。但是将耗时计算放在一个优先级低于用户界面的单独线程中可能是一个好主意。将文件访问和网络访问放在不同的线程中也很有用,这样一个线程可以在另一个线程等待硬盘或网络响应时进行计算。

Intel 提供了各种支持多线程软件的开发工具。参见《Intel 技术期刊》2007年第11卷第4期

10.1 同步多线程技术

许多微处理器能够在每个内核中运行两个线程。例如,一个有 4个内核的处理器可以同时运行 8个线程。这个处理器有四个物理处理器,但有八个逻辑处理器。

超线程”是 Intel 对同步多线程的称呼。在同一个内核中运行的两个线程总是会争夺相同的资源,比如缓存和执行单元。如果有任何的共享资源是制约性能的因素,那么使用同步多线程没有任何优势。相反,由于缓存回收和其他资源冲突,每个线程的运行速度可能不到一半。但是,如果大部分时间存在缓存不命中、分支错误预测或长依赖链,那么每个线程的运行速度将超过单线程速度的一半。在这种情况下,使用同步多线程有一点优势,但性能不会提高一倍。与另一个线程共享内核资源的线程总是比在内核中单独运行的线程运行得慢。

为了确定在特定的应用程序中使用同步多线程是否有利,常常需要进行测试。

如果同步多线程没有优势,那么有必要查询某些操作系统函数(例如Windows 中的 GetLogicalProcessorInformation),以确定处理器是否具有同步多线程。如果有,那么你可以通过只使用序号为偶数的逻辑处理器(0、2、4等)来避免同步多线程。旧的操作系统缺乏区分物理处理器数量和逻辑处理器数量的必要功能。

没有一个可以告诉处理器给一个线程分配比另一个线程更高优先级的办法。因此,低优先级线程经常会从运行在同一内核中的高优先级线程窃取资源。操作系统的责任是避免在同一个处理器内核中运行优先级相差很大的两个线程。不幸的是,当代的操作系统并不能很好地解决这个问题。

Intel编译器 能够生成两个线程,其中一个线程用于为另一个线程预加载数据。然而,在大多数情况下,自动硬件预加载比软件预加载效率更高。

11 乱序执行

除了一些小的低功耗 CPU (如Intel Atom )之外,所有现代 x86 CPU 都可以乱序执行指令或同时执行多个操作。下面的示例展示了如何利用这种功能:

1
2
3
4
// Example 11.1a

float a, b, c, d, y;
y = a + b + c + d;

这个表达式计算为 ((a+b)+c)+d。这是一个依赖链,每个加法都必须等待前一个的结果。你可以这样写来提高效率:

1
2
3
4
// Example 11.1b

float a, b, c, d, y;
y = (a + b) + (c + d);

两个括号可以独立计算。在计算完 (a+b) 之前,CPU 将开始计算 (c+d)。这可以节省几个时钟周期。你不能假定优化编译器会自动将例 11.1a中的代码更改为例 11.1b,尽管这似乎是一件显而易见的事情。编译器不对浮点表达式进行这种优化的原因是,它可能会导致精度的损失,如8.1 编译器是如何优化的:代数化简所述。你必须手动添加这些括号。

当依赖链较长时,其影响更大。在循环中通常是这样。考虑下面的例子,它计算100个数字的和:

1
2
3
4
5
6
// Example 11.2a

const int size = 100;
float list[size], sum = 0; int i;
for (i = 0; i < size; i++)
sum += list[i];

这里有一个很长的依赖链。如果浮点加法需要 5个时钟周期,那么这个循环大约需要 500个时钟周期。通过展开循环并将依赖链一分为二,可以显著提高性能:

1
2
3
4
5
6
7
8
9
10
// Example 11.2b

const int size = 100;
float list[size], sum1 = 0, sum2 = 0; int i;
for (i = 0; i < size; i += 2)
{
sum1 += list[i];
sum2 += list[i+1];
}
sum1 += sum2;

如果微处理器从时间 T 到 T+5 对sum1做加法,那么它可以从时间 T+1 到 T+6 对 sum2 做加法,整个循环只需要 256个时钟周期。

在循环中,每个迭代都需要前一个迭代的结果,这种循环中的计算称为循环依赖链。这样的依赖链可能非常长,并且非常耗时。如果这样的依赖链能够被打破,将会有很多收益。sum1sum2 这两个求和变量称为累加器。当前的CPU只有一个浮点加法单元,但是如上所述,这个单元是流水线操作的,因此它可以在前一个加法完成之前开始一个新的加法。

浮点加法和乘法的累加器的最佳数量可能是 3个或 4个,这取决于CPU

如果循环的次数不能被展开因子整除,那么展开循环就会变得稍微复杂一些。例如,如果例 11.2blist 中的元素数量是奇数,那么我们必须在循环之外计算最后一个元素,或者向 list 中添加一个额外的伪元素,使这个额外的元素为零。

如果没有循环依赖链,则不需要展开循环并使用多个累加器。具有乱序执行功能的微处理器可以重叠迭代,并在前一个迭代完成之前开始下一个迭代的计算。例如:

1
2
3
4
5
6
7
8
9
10
// Example 11.3

const int size = 100; int i;
float a[size], b[size], c[size];
float register temp;
for (i = 0; i < size; i++)
{
temp = a[i] + b[i];
c[i] = temp * temp;
}

具有无序功能的微处理器非常智能。他们可以检测到例 11.3中循环的一次迭代中的寄存器临时值独立于前一次迭代中的值。这允许它在计算完前一个值之前开始计算一个新的临时值。它通过为 temp 分配一个新的物理寄存器来实现这一点,即使在机器码中出现的逻辑寄存器是相同的。这叫做寄存器重命名。CPU可以保留同一逻辑寄存器的许多重命名实例。

这种优势是自动产生的。没有理由展开循环并使用 temp1temp2。现代 CPU 能够在满足某些条件的情况下重命名寄存器和并行执行多个计算。使 CPU 能够重叠循环迭代计算的条件为:

  1. 没有循环依赖链。一次迭代的计算不应该依赖于前一次迭代的结果(循环计数器除外,当它是整数时,它的计算速度很快)。
  2. 所有的中间结果都应该保存在寄存器中,而不是内存中。重命名机制只对寄存器有效,而对内存或缓存中的变量无效。在例 11.3中,即使没有 register 关键字,大多数编译器也会使 temp 成为寄存器变量。CodeGear编译器不能生成浮点寄存器变量,但会在内存中保存临时变量。这会阻止 CPU 的重叠计算。
  3. 循环分支需要可以被预测。如果重复计数很大或恒定,则不存在此问题。如果循环计数很小且不断变化,那么CPU可能偶尔会预测循环分支已经退出了,而实际上它没有,因此无法开始下一个计算。然而,乱序执行机制允许CPU提前增加循环计数器,这样它就可以在判断错误之前及时发现。因此,你不必太担心这种情况。

通常,乱序执行机制是自动工作的。但是,程序员可以做一些事情来最大限度地利用乱序执行。最重要的是避免过长的依赖链。你可以做的另一件事是混合不同类型的操作,以便在 CPU 中的不同执行单元之间均匀地分配工作。只要不需要在整数和浮点数之间进行转换,就可以混合使用整数和浮点数计算。将浮点加法与浮点乘法混合使用、将简单整数与向量整数操作混合使用、将数学计算与内存访问混合使用也有很大的好处。

过长的依赖链会给 CPU 的乱序执行资源带来了压力,即使它们没有进入循环的下一次迭代。一个现代的 CPU 通常可以处理 100多个待定操作(参见手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。将循环分割并存储中间结果,对打破一个非常长的依赖链是有帮助的。

12 使用向量操作

现如今的微处理器拥有向量指令,使得同时对向量的所有元素进行操作成为可能。这也称为单指令多数据操作(SIMD)。每个向量的总大小可以是 64位(MMX)、128位(XMM)、256位(YMM)和 512位(ZMM)。

在大型数据集中,对多个数据元素执行相同操作且程序逻辑允许并行计算时,向量操作非常有用。例如图像处理、声音处理以及向量和矩阵的数学运算。本质上是串行的算法,比如大多数排序算法,不太适合向量操作。严重依赖于查找表或需要大量数据变换的算法(如许多加密算法)可能也不太适合向量操作。

向量操作使用一组特殊的向量寄存器。如果 SSE2 指令集可用,每个向量寄存器的最大大小为128位(XMM );如果微处理器和操作系统支持 AVX 指令集,则为256位(YMM );当 AVX512 指令集可用时为 512位。每个向量中的元素数量取决于数据元素的大小和类型,如下所示:

Type of elements Size of each elements, bits Number of elements Total size of vector, bits Instruction set
char 8 8 64 MMX
short int 16 4 64 MMX
int 32 2 64 MMX
int64_t 64 1 64 MMX
char 8 16 128 SSE2
short int 16 8 128 SSE2
int 32 4 128 SSE2
int64_t 64 2 128 SSE2
float 32 4 128 SSE
double 64 2 128 SSE2
char 8 32 256 AVX2
short int 16 16 256 AVX2
int 32 8 256 AVX2
int64_t 64 4 256 AVX2
float 32 8 256 AVX
double 64 4 256 AVX
char 8 64 512 AVX512BW
short int 16 32 512 AVX512BW
int 32 16 512 AVX512
int64_t 64 8 512 AVX512
float 32 16 512 AVX512
double 64 8 512 AVX512

Table 12.1. Vector sizes available in different instruction set extensions

例如,当SSE2 指令集可用时,一个 128位的XMM 寄存器可以组织成一个包含 8个 16位整数或 4个浮点数的向量。应该避免使用老旧的 64位MMX 寄存器,因为它们不能与x87 类型的浮点代码混合使用。

128位的 XMM 向量必须按照 16对齐,即存储在一个可以被 16整除的内存地址中(见下文)。256位的YMM 向量最好按 32对齐,而512位 ZMM 寄存器需要按 64对齐,但是在编译 AVX 和之后的指令集时,对齐的要求不那么严格。

在较新的处理器上,向量操作特别快。许多处理器可以像标量一样快速地计算向量(标量意味着单个元素)。支持新向量大小的第一代处理器的执行单元、内存端口(memory ports)等通常只有最大向量大小的一半。为了处理向量中所有元素,这些单元必须被使用两次。

数据元素越小,向量运算的使用就越有优势。例如,你可以同时进行四个 float 的加法,而对于 double 则只有两个。在现在CPU 中,如果数据很符合向量寄存器,则使用向量运算几乎总是有利的。如果要将正确的数据放入正确的向量元素中,需要进行大量的数据操作,那么这样做可能并没有什么优势。

12.1 AVX 指令集和 YMM 寄存器

128位的 XMM 寄存器在 AVX 指令集中被扩展为256位的 YMM 寄存器。AVX 指令集的主要优点是它允许更大的浮点向量。还有其他一些优势可能会在一定程度上提高性能。AVX2 指令集也允许 256位整数向量。

AVX 指令集编译的代码只有在 CPU 和操作系统都支持 AVX 的情况下才能运行。在 Windows 7Windows Server 2008 R2Linux 内核2.6.30及以上版本中支持 AVXMicrosoftIntelGnuClang 的最新编译器支持 AVX 指令集。

在某些英特尔处理器上混合使用和不使用 AVX 支持编译的代码时会出现问题。当从 AVX 代码转换到 非AVX 代码时,由于YMM 寄存器状态的变化,会造成性能损失。应该在从 AVX 代码转换到 非AVX 代码之前调用内部函数 _mm256_zeroupper() 来避免这种损失。在以下情况下,这是必要的:

  1. 如果程序的一部分是使用 AVX 支持编译,而另一部分未使用 AVX 支持编译时,那么需要在离开 AVX 部分之前调用 _mm256_zeroupper()
  2. 如果一个函数使用 CPU分派,在多个版本中使用或者不使用 AVX 支持进行编译,那么在离开 AVX 部分之前调用 _mm256_zeroupper()
  3. 如果使用了 AVX 支持编译的代码调用了编译器附带的库之外的库中的函数,而该库不支持 AVX ,则在调用库函数之前调用 _mm256_zeroupper()

12.2 AVX512 指令集和 ZMM 寄存器

256位的YMM 寄存器在AVX512 指令集中被扩展为 512位的ZMM 寄存器。在 64位模式下,向量寄存器的数量从 16个扩展到 32个,而在 32位模式下只有 8个向量寄存器。因此,最好将AVX512 代码编译为 64位模式。

AVX512 指令集还添加了一组掩码寄存器。它们被用作布尔向量。几乎任何向量指令都可以用掩码寄存器进行掩模操作,这样,只有当掩码寄存器中的对应位为1时,才会计算向量元素。这使得使用分支的代码向量化效率更高。

AVX512 还有几个额外的扩展。所有支持AVX512 的处理器都有一些这样的扩展,但是到目前为止还没有一个处理器拥有全部这些扩展(写于2016年)。下面是对AVX512 的已有和计划添加的扩展:

  1. AVX512F。基础扩展。所有支持 AVX512 的处理器都有这个扩展。包含在 512位向量中对 32位和 64位整数,floatdouble 的操作以及掩码操作。
  2. AVX512VL。包含在 128和 256位向量中的相同操作。包含掩码操作和 32个向量寄存器。
  3. AVX512BW。包含在 512位向量中 8位和 16位整数的操作。
  4. AVX512DQ。64位整数的乘法和转换指令。以及其它一些浮点和双精度指令。
  5. AVX512ER。 快速倒数、倒数平方根、指数函数。对于 float类型是准确值,对于 double 类型则是近似值。
  6. AVX512CD。冲突检测。找到向量中的重复元素。
  7. AVX512PF。带聚集/分散逻辑的预取指令。
  8. AVX512VBMI。 8位粒度的置换和移位指令。
  9. AVX512IFMA。打包 52位整数的乘法和加法。
  10. AVX512_4VNNIW。Iterated dot product on 16-bit integers.
  11. AVX512_4FMAPS。 Iterated fused multiply-and-add, single precision.

这使得CPU分派 更加复杂。你可以选择对特定任务有用的扩展,并为具有此扩展的处理器创建代码分支。

AVX512 代码中,_mm256_zeroupper() 的使用不那么重要,但是仍然推荐使用。参见手册2:“Optimizing subroutines in assembly language”的13.2 节和手册5:“Calling conventions”的6.3节

12.3 自动向量化

好的编译器(如 GnuClangIntel 编译器)可以在并行性明显的情况下自动使用向量操作。有关详细说明,请参阅编译器文档。例如:

1
2
3
4
5
6
7
8
9
// Example 12.1a. Automatic vectorization

const int size = 1024;
int a[size], b[size];
// ...
for (int i = 0; i < size; i++)
{
a[i] = b[i] + 2;
}

一个好的编译器会在指定SSE2 或更高的指令集时使用向量操作来优化这个循环。根据使用指令集的不同,代码将读取4个,或8个,或16个 b 中的元素到一个向量寄存器中,与另一个向量寄存器包含(2,2,2,…)做加法,并将结果存储到 a 中。此操作将被重复多次,次数为数组大小除以每个向量的元素数量。速度相应地提高了。循环计数能最好能被每个向量的元素数整除。你甚至可以在数组的末尾添加多余的元素,使数组大小成为向量大小的倍数。

当数组是通过指针访问的时候,这将会有一个缺点,例如:

1
2
3
4
5
6
7
8
9
// Example 12.1b. Vectorization with alignment problem

void AddTwo(int * __restrict aa, int * __restrict bb)
{
for (int i = 0; i < size; i++)
{
aa[i] = bb[i] + 2;
}
}

如果数组按向量大小对对齐,XMMYMMZMM 寄存器分别为16、32或64字节,则性能最佳。在 AVX 之前,指令集下的高效的向量操作要求数组按可被16整除的地址排列。在例 12.1a中,编译器可以根据需要对数组进行对齐,但在例 12.1b中,编译器无法确定数组是否正确对齐。循环仍然可以向量化,但是代码的效率会降低,因为编译器必须对未对齐的数组采取额外的预防措施。当通过指针或引用访问数组时,你可以做许多事情来提高代码的效率:

  1. 如果使用的是 Intel 编译器,可以使用 #pragma vector aligned_assume_aligned 指令,告诉编译器数组是对齐的,并确保它们是对齐的。
  2. 将函数声明为 inline。这使编译器可能将 例 12.1b简化为12.1a
  3. 如果可能的话,启用向量大小最大的指令集。AVX 和之后的指令集对对齐的限制很少,无论数组是否对齐,生成的代码都是高效的。

如果满足以下条件,自动向量化效果最好:

  1. 使用支持自动向量化的编译器,如 GnuClangIntelPathScale
  2. 使用编译器的最新版本。编译器在向量化方面变得越来越好。
  3. 使用适当的编译器选项来启用所需的指令集( /arch:SSE2/arch:AVX 等,用于 Windows-msse2-mavx 等,用于 Linux)。
  4. 使用限制较少的浮点选项。对于Gnu 编译器,使用 -O3 -fnotrapping-math -fno-math-errno
  5. 对于 SSE2 ,数组和大结构的地址按 16字节对齐,对于AVX 最好按 32字节对齐,而 AVX512 则最好按 64字节对齐。
  6. 循环计数最好是一个能被向量中的元素数整除的常数。
  7. 如果数组是通过指针访问的,因此在你想要向量化的函数的范围内,对齐是不可见的,那么请遵循上面给出的建议。
  8. 如果数组或结构体是通过指针或引用访问的,那么显式地告诉编译器指针没有别名 (如果合适的话)。有关如何做到这一点,请参阅编译器文档。
  9. 在向量元素级别上最小化分支的使用。
  10. 避免在向量元素级别使用查找表。

你可以查看汇编代码输出清单,以查看代码是否确实按预期被向量化(参见8.7 检查编译器做了什么)。

如果对一系列连续变量执行相同的操作,则编译器还可以在没有循环的情况下使用向量操作。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Example 12.2

__declspec(align(16)) // Make all instances of S1 aligned

struct S1
{ // Structure of 4 floats
float a, b, c, d;
};
void Func()
{
S1 x, y;
...
x.a = y.a + 1.;
x.b = y.b + 2.;
x.c = y.c + 3.;
x.d = y.d + 4.;
};

4个浮点数的结构适合 128位的 XMM 寄存器。在例 12.2中,优化后的代码将结构 y 加载到向量寄存器中,添加常量向量 (1,2,3,4),并将结果存储在 x 中。

编译器并不总是能够正确地预测向量化是否有利。Intel 编译器允许你始终使用 #pragma vector always 来告诉编译器总是进行向量化,或者使用 #pragma novector 来告诉编译器不要向量化。必须将 pragmas 语句放在循环或一系列你所希望应用的语句之前。

使用适合应用程序的最小数据大小是有利的。在例 12.3中,例如,你可以通过使用 short int 代替 int 以得到 2倍的速度。short int 是 16位的, 而 int 是 32位的,所以在相同的向量中,你可以存储 8个 short int 类型的数字,而只能存储 4个 int 类型的数字。因此,在不会产生溢出的情况下,使用足够大的最小位宽的类型类存储问题中的数字是有利的。同样地,如果代码可以向量化,那么使用 float 代替 double 是有好处的,因为 float 占用 32位,而 double 占用 64位。

SSE2 向量指令集不能对大小大于 short int(16位)的整数进行乘法。没有指令可以进行整数除法。但是 vector class libraryasmlib中有函数可以进行整数向量除法。

12.4 使用指令集函数

很难预测编译器是否会将循环向量化。下面的例子显示了编译器可以自动向量化,也可以不自动向量化的代码。代码中有一个分支,它为数组中的每个元素选择两个表达式:

1
2
3
4
5
6
7
8
9
10
// Example 12.4a. Loop with branch

// Loop with branch
void SelectAddMul(short int aa[], short int bb[], short int cc[])
{
for (int i = 0; i < 256; i++)
{
aa[i] = (bb[i] > 0) ? (cc[i] + 2) : (bb[i] * cc[i]);
}
}

可以使用所谓的指令集函数显式地向量化代码。当类似例 12.4a等当前编译器不会自动向量化代码的情况下非常有用。或者在自动向量化的代码不够优化的情况下,它也很有用。

指令集函数是基本操作,即每个指令集函数调用都被翻译成一个或几个机器指令。 GnuClangIntelMicrosoftPathScale 编译器都支持指令集函数(PGI 编译器也支持指令集函数,但效率很低,Codeplay 编译器支持部分指令集函数,但是函数名与其他编译器不兼容)。使用 GnuClangIntel 编译器可以获得最佳的性能。

我们想对例 12.4a中的循环进行向量化,这样我们就可以在包含 8个 16位整数的向量中同时处理 8个元素。根据可用的指令集,循环内部的分支可以以多种方式实现。最兼容的方法是制作一个位掩码,当 bb[i] > 0 为真时全为 1,当为假时全为 0。将 cc[i]+2与上掩码,对掩码取反并与上 bb[i]**cc[i]。表达式的结果是与上全部是1的掩码结果不变,而与上全是 0的掩码得到的结果为 0。然后对这两个记过进行或操作就能得到要选择的表达式。

例 12.4b将展示如何使用 SSE2 指令集的函数实现上述步骤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Example 12.4b. Vectorized with SSE2

#include <emmintrin.h> // Define SSE2 intrinsic functions
// Function to load unaligned integer vector from array
static inline __m128i LoadVector(void const * p)
{
return _mm_loadu_si128((__m128i const*)p);
}
// Function to store unaligned integer vector into array
static inline void StoreVector(void * d, __m128i const & x)
{
_mm_storeu_si128((__m128i *)d, x);
}
// Branch/loop function vectorized:
void SelectAddMul(short int aa[], short int bb[], short int cc[])
{
// Make a vector of (0,0,0,0,0,0,0,0)
__m128i zero = _mm_set1_epi16(0);
// Make a vector of (2,2,2,2,2,2,2,2)
__m128i two = _mm_set1_epi16(2);
// Roll out loop by eight to fit the eight-element vectors:
for (int i = 0; i < 256; i += 8)
{
// Load eight consecutive elements from bb into vector b:
__m128i b = LoadVector(bb + i);
// Load eight consecutive elements from cc into vector c:
__m128i c = LoadVector(cc + i);
// Add 2 to each element in vector c
__m128i c2 = _mm_add_epi16(c, two);
// Multiply b and c
__m128i bc = _mm_mullo_epi16 (b, c);
// Compare each element in b to 0 and generate a bit-mask:
__m128i mask = _mm_cmpgt_epi16(b, zero);
// AND each element in vector c2 with the bit-mask:
c2 = _mm_and_si128(c2, mask);
// AND each element in vector bc with the inverted bit-mask:
bc = _mm_andnot_si128(mask, bc);
// OR the results of the two AND operations:
__m128i a = _mm_or_si128(c2, bc);
// Store the result vector in eight consecutive elements in aa:
StoreVector(aa + i, a);
}
}

生成的代码将非常高效,因为它一次处理8个元素,并且避免了循环中的分支。例 12.4b的执行速度是例 12.4a的3到7倍,具体取决于循环中分支的可预测性。

__m128i 类型定义了一个存储整数的 128位向量。它可以存储 16个 8位的整数,8个 16位的整数,4个 32位的整数,或者2个 64位的整数。__m128 类型定义了一个包含 4个 float 变量的 128位向量。__m128d 类型定义了包含 2个 double类型变量的 128为变量。

指令集向量函数的名称以 _mm 开头。编译器手册或 Intel 的编程手册:“IA-32 Intel Architecture Software Developer’s Manual” 2A and 2B卷中列出了这些函数。指令集中有数百种不同的函数,很难找到适合特定用途的函数。

例 12.4b中笨拙的 AND-OR 结构可以被 SSE4.1 指令集中的 blend 指令替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Example 12.4c. Same example, vectorized with SSE4.1

// Function to load unaligned integer vector from array
static inline __m128i LoadVector(void const * p)
{
return _mm_loadu_si128((__m128i const*)p);
}
// Function to store unaligned integer vector into array
static inline void StoreVector(void * d, __m128i const & x)
{
_mm_storeu_si128((__m128i *)d, x);
}
void SelectAddMul(short int aa[], short int bb[], short int cc[])
{
// Make a vector of (0,0,0,0,0,0,0,0)
__m128i zero = _mm_set1_epi16(0);
// Make a vector of (2,2,2,2,2,2,2,2)
__m128i two = _mm_set1_epi16(2);
// Roll out loop by eight to fit the eight-element vectors:
for (int i = 0; i < 256; i += 8)
{
// Load eight consecutive elements from bb into vector b:
__m128i b = LoadVector(bb + i);
// Load eight consecutive elements from cc into vector c:
__m128i c = LoadVector(cc + i);
// Add 2 to each element in vector c
__m128i c2 = _mm_add_epi16(c, two);
// Multiply b and c
__m128i bc = _mm_mullo_epi16 (b, c);
// Compare each element in b to 0 and generate a bit-mask:
__m128i mask = _mm_cmpgt_epi16(b, zero);
// Use mask to choose between c2 and bc for each element
__m128i a = _mm_blendv_epi8(bc, c2, mask);
// Store the result vector in eight consecutive elements in aa:
StoreVector(aa + i, a);
}
}

你必须为要编译的指令集包含合适的头文件。头文件的名称如下:

Instruction set Header file
MMX mmintrin.h
SSE xmmintrin.h
SSE2 emmintrin.h
SSE3 pmmintrin.h
Suppl. SSE3 tmmintrin.h
SSE4.1 smmintrin.h
SSE4.2 nmmintrin.h (MS) smmintrin.h (Gnu)
AES, PCLMUL wmmintrin.h
AVX immintrin.h
AMD SSE4A ammintrin.h
AMD XOP ammintrin.h (MS) xopintrin.h (Gnu)
AMD FMA4 fma4intrin.h (Gnu)
all intrin.h (MS) x86intrin.h (Gnu)

Table 12.2. Header files for intrinsic functions

你必须确保 CPU 支持相应的指令集。如果你包含了高于 CPU 支持的指令集头文件,那么你就有可能插入 CPU 不支持的指令,程序就会崩溃。有关如何检查支持的指令集,请参见13 使用不同指令集生成多个版本的关键代码

数据对齐

如果数据的地址按可被向量大小(16或32字节)整除方式对齐,那么将数据加载到向量中会更快。这对旧的处理器和英特尔 Atom 处理器都有很大的影响,但在大多数较新的处理器上不是很重要。下面的例子展示了如何对齐数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Example 12.5. Aligned arrays

// Define macro for aligning data
#ifdef _MSC_VER // If Microsoft compiler
#define Alignd(X) __declspec(align(16)) X
#else // Gnu compiler, etc.
#define Alignd(X) X __attribute__((aligned(16)))
#endif
const int size = 256; // Array size
Alignd ( short int aa[size] ); // Make three aligned arrays
Alignd ( short int bb[size] );
Alignd ( short int cc[size] );
// Function to load aligned integer vector from array
static inline __m128i LoadVectorA(void const * p)
{
return _mm_load_si128((__m128i const*)p);
}
// Function to store aligned integer vector into array
static inline void StoreVectorA(void * d, __m128i const & x)
{
_mm_store_si128((__m128i *)d, x);
}

查找表向量化

查找表对于优化代码非常有用,如14.1 使用查找表所述。不幸的是,查找表常常是向量化的一个障碍。最新的指令集包括一些可用于向量化查找表的指令。这些指令总结如下。

Intrinsic function Max. number of elementsin table Size of each table element Number of simultaneouslookups Instruction set needed
_mm_shuffle_epi8 16 1 byte = char 16 SSSE3
_mm_perm_epi8 32 1 byte = char 16 XOP, AMD only
_mm_permutevar_ps 4 4 bytes = float or int 4 AVX
_mm256_permutevar_ps 4 4 bytes = float or int 8 AVX2
_mm_i32gather_epi32 unlimited 4 bytes = int 4 AVX2
_mm256_i32gather_epi32 unlimited 4 bytes = int 8 AVX2
_mm_i64gather_epi32 unlimited 8 bytes = int64_t 2 AVX2
_mm256_i64gather_epi32 unlimited 8 bytes = int64_t 4 AVX2
_mm_i32gather_ps unlimited 4 bytes = float 4 AVX2
_mm256_i32gather_ps unlimited 4 bytes = float 8 AVX2
_mm_i64gather_pd unlimited 8 bytes = double 2 AVX2
_mm256_i64gather_pd unlimited 8 bytes = double 4 AVX2

Table 12.3. Intrinsic functions for vectorized table lookup

使用指令集函数可能会非常繁琐,代码会变得非常庞大,难以阅读。如下一节所述,使用向量类通常更简单一些。

12.5 使用向量类

例 12.4b例 12.4c中的方式编写程序确实很乏味。通过将这些向量操作包装到 C++ 类中,并使用重载的运算符(如向量加法),可以以更清晰易懂的方式编写相同的代码。运算符是内联的,因此生成的机器码与直接使用指令集函数时的机器码相同。只是编写 a + b 比编写 _mm_add_epi16(a,b) 更容些。

目前可以使用几种不同的预定义的向量类库,包括一个来自 Intel的,一个来自我编写的。我编写的向量类库(VCL )有许多特性,请参见www.agner.org/optimize/#vectorclassIntel vector class library 最近没有更新,我觉得可能有些过时。

Vector class library Intel VCL (Agner)
Available from Intel and Microsoft C++ compilers VCL
Include file dvec.h vectorclass.h
Supported compilers Intel, Microsoft Intel, Microsoft, Gnu, Clang
Supported operating systems Windows, Linux, Mac Windows, Linux, Mac, BSD
Instruction set control no yes
License license included in compiler price GNU General Public License, optional commercial license

Table 12.4. Vector class libraries

下表列出了可用的向量类。包含适当的头文件将使你能够访问所有这些类。

Size of each element, bits Number of elements in vector Type of elements Total size of vector, bits Vector class, Intel Vector class,VCL
8 8 char 64 Is8vec8
8 8 unsigned char 64 Iu8vec8
16 4 short int 64 Is16vec4
16 4 unsigned short int 64 Iu16vec4
32 2 int 64 Is32vec2
32 2 unsigned int 64 Iu32vec2
64 1 int64_t 64 I64vec1
8 16 char 128 Is8vec16 Vec16c
8 16 unsigned char 128 Iu8vec16 Vec16uc
16 8 short int 128 Is16vec8 Vec8s
16 8 unsigned short int 128 Iu16vec8 Vec8us
32 4 int 128 Is32vec4 Vec4i
32 4 unsigned int 128 Iu32vec4 Vec4ui
64 2 int64_t 128 I64vec2 Vec2q
64 2 uint64_t 128 Vec2uq
8 32 char 256 Vec32c
8 32 unsigned char 256 Vec32uc
16 16 short int 256 Vec16s
16 16 unsigned short int 256 Vec16us
32 8 int 256 Vec8i
32 8 unsigned int 256 Vec8ui
64 4 int64_t 256 Vec4q
64 4 uint64_t 256 Vec4uq
32 16 int 512 Vec16i
32 16 unsigned int 512 Vec16ui
64 8 int64_t 512 Vec8q
64 8 uint64_t 512 Vec8uq
32 4 float 128 F32vec4 Vec4f
64 2 double 128 F64vec2 Vec2d
32 8 float 256 F32vec8 Vec8f
64 4 double 256 F64vec4 Vec4d
32 16 float 512 Vec16f
64 8 double 512 Vec8d

Table 12.5. Vector classes defined in two libraries

不建议使用总大小为 64位的向量,因为它们与浮点数代码不兼容。如果使用 64位的向量,那么必须在 64位向量操作之后和浮点代码之前执行调用 _mm_empty()。较大的向量没有这个问题。

只有在 CPU 和操作系统支持的情况下,256位和512位大小的向量才可用 (参见12.1 AVX 指令集和 YMM寄存器)。我的 VCL 向量类库可以用两个128位向量模拟一个256位向量,或者将用两个256位向量或四个128位向量模拟一个512位向量。下面的示例展示了与例 12.4b相同功能的代码,使用 Intel vector classes 重写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Example 12.4d. Same example, using Intel vector classes

#include <dvec.h> // Define vector classes
// Function to load unaligned integer vector from array
static inline __m128i LoadVector(void const * p)
{
return _mm_loadu_si128((__m128i const*)p);
}
// Function to store unaligned integer vector into array
static inline void StoreVector(void * d, __m128i const & x)
{
_mm_storeu_si128((__m128i *)d, x);
}
void SelectAddMul(short int aa[], short int bb[], short int cc[])
{
// Make a vector of (0,0,0,0,0,0,0,0)
Is16vec8 zero(0,0,0,0,0,0,0,0);
// Make a vector of (2,2,2,2,2,2,2,2)
Is16vec8 two(2,2,2,2,2,2,2,2);
// Roll out loop by eight to fit the eight-element vectors:
for (int i = 0; i < 256; i += 8)
{
// Load eight consecutive elements from bb into vector b:
Is16vec8 b = LoadVector(bb + i);
// Load eight consecutive elements from cc into vector c:
Is16vec8 c = LoadVector(cc + i);
// result = b > 0 ? c + 2 : b * c;
Is16vec8 a = select_gt(b, zero, c + two, b * c);
// Store the result vector in eight consecutive elements in aa:
StoreVector(aa + i, a);
}
}

同样的例子使用我的VCL 向量类是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Example 12.4e. Same example, using VCL

#include "vectorclass.h" // Define vector classes
void SelectAddMul(short int aa[], short int bb[], short int cc[])
{
// Define vector objects
Vec16s a, b, c;
// Roll out loop by eight to fit the eight-element vectors:
for (int i = 0; i < 256; i += 16)
{
// Load eight consecutive elements from bb into vector b:
b.load(bb+i);
// Load eight consecutive elements from cc into vector c:
c.load(cc+i);
// result = b > 0 ? c + 2 : b * c;
a = select(b > 0, c + 2, b * c);
// Store the result vector in eight consecutive elements in aa:
a.store(aa+i);
}
}

由于对齐的问题,Microsoft 编译器不允许将向量对象作为函数参数。建议使用常量引用:

1
2
3
4
5
6
7
// Example 12.6. Function with vector parameters

Vec4f polynomial (Vec4f const & x)
{
// polynomial(x) = 2.5*x^2 - 8*x + 2
return (2.5f * x - 8.0f) * x + 2.0f;
}

使用向量类进行 CPU分派

VCL 向量类库使从相同的源代码位不同的指令集编译代码成为可能。该库具有为给定指令集选择最佳实现的预处理指令。

下面的示例展示了如何使用自动 CPU分派实现例 12.4e中的 SelectAddMul 。本例中的代码应该编译三次,一次使用 SSE2 指令集,一次使用 SSE4.1 指令集,一次用于 AVX2 指令集,所有三个版本都应该被链接到同一个可执行文件中。SSE2VCL 支持的最旧指令集,SSE4.1select 函数中具有优势,AVX2 指令集具有更大的向量寄存器的优势。当使用 AVX2 指令集编译时,VCL 将会为 Vec16s 分配一个256位向量寄存器,使用低版本的指令集时,则分配两个 128位向量寄存器。预处理宏 INSTRSET 用于在使用不同指令集时赋予函数不同的名称。更多内容详见 vectorclass manual

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// Example 12.7. Vector class code with automatic CPU dispatching
#include "vectorclass.h" // vector class library
#include <stdio.h> // define fprintf
// define function type
typedef void FuncType(short int aa[], short int bb[], short int cc[]);
// function prototypes for each version
FuncType SelectAddMul, SelectAddMul_SSE2, SelectAddMul_SSE41,
SelectAddMul_AVX2, SelectAddMul_dispatch;
// Define function name depending on instruction set
#if INSTRSET == 2 // SSE2
#define FUNCNAME SelectAddMul_SSE2
#elif INSTRSET == 5 // SSE4.1
#define FUNCNAME SelectAddMul_SSE41
#elif INSTRSET == 8 // AVX2
#define FUNCNAME SelectAddMul_AVX2
#endif
// specific version of the function. Compile once for each version
void FUNCNAME(short int aa[], short int bb[], short int cc[])
{
Vec16s a, b, c; // Define biggest possible vector objects
// Roll out loop by 16 to fit the biggest vectors:
for (int i = 0; i < 256; i += 16)
{
b.load(bb+i);
c.load(cc+i);
a = select(b > 0, c + 2, b * c);
a.store(aa+i);
}
}
#if INSTRSET == 2
// make dispatcher in only the lowest of the compiled versions
#include "instrset_detect.cpp" // instrset_detect function
// Function pointer initially points to the dispatcher.
// After first call it points to the selected version
FuncType * SelectAddMul_pointer = &SelectAddMul_dispatch;
// Dispatcher
void SelectAddMul_dispatch(short int aa[], short int bb[], short int cc[])
{
// Detect supported instruction set
int iset = instrset_detect();
// Set function pointer
if (iset >= 8)
SelectAddMul_pointer = &SelectAddMul_AVX2;
else if (iset >= 5)
SelectAddMul_pointer = &SelectAddMul_SSE41;
else if (iset >= 2)
SelectAddMul_pointer = &SelectAddMul_SSE2;
else
{
// Error: lowest instruction set not supported
fprintf(stderr, "\nError: Instruction set SSE2 not supported");
return;
}
// continue in dispatched version
return (*SelectAddMul_pointer)(aa, bb, cc);
}
// Entry to dispatched function call
inline void SelectAddMul(short int aa[], short int bb[], short int cc[])
{
// go to dispatched version
return (*SelectAddMul_pointer)(aa, bb, cc);
}
#endif // INSTRSET == 2

12.6 为向量化转换串行代码

并不是所有具有并行结构的代码都可以轻松地组织成向量形式的。很多代码都是串行的,也就是说每个计算都依赖于前一个的结果。然而,如果代码是重复的,则可以以一种可被向量化的方式组织代码。最简单的情况是一长串数字的和:

1
2
3
4
5
// Example 12.8a. Sum of a list
float a[100];
float sum = 0;
for (int i = 0; i < 100; i++)
sum += a[i];

上述的代码是串行的,因为每次迭代 sum 的值都依赖于前一次迭代后 sum 的值。诀窍是将循环按 n 展开并重新组织代码,每个值依赖于 n 个位置之前的值,其中 n 是向量中元素的数量。如果 n = 4,我们得到:

1
2
3
4
5
6
7
8
9
10
11
// Example 12.8b. Sum of a list, rolled out by 4
float a[100];
float s0 = 0, s1 = 0, s2 = 0, s3 = 0, sum;
for (int i = 0; i < 100; i += 4)
{
s0 += a[i];
s1 += a[i+1];
s2 += a[i+2];
s3 += a[i+3];
}
sum = (s0+s1)+(s2+s3);

现在,s0s1s2s3可以组合成一个128位的向量,这样我们就可以在一个操作中做4个加法。如果我们使用 fast math 选项并指定SSE 或更高指令集的选项,一个好的编译器会自动将例 12.8a转换为12.8b,并将代码向量化。

再一些更复杂的情况下不能自动向量化。例如,让我们看看泰勒级数的例子。指数函数可由级数计算:

1
2
3
4

$$
e^x=\sum_{n=0}^\infty\frac{x^n}{n!}
$$

C++ 实现看起来可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Example 12.9a. Taylor series
float Exp(float x)
{
// Approximate exp(x) for small x
float xn = x; // x^n
float sum = 1.f; // sum, initialize to x^0/0!
float nfac = 1.f; // n factorial
for (int n = 1; n <= 16; n++)
{
sum += xn / nfac;
xn *= x;
nfac *= n+1;
}
return sum;
}

在这里每个 $x^n$ 的值由前一个值计算而来,即 $x^n = x(x^{n-1})$,每个 $n!$ 的值也由前一个值计算而来,即 $n!= n(n-1)!$。如果我们想要将循环按 4展开,那我们必须要用 4个位置之前的值来计算当前的值。因此,我们将用 $x^4x^{n-4}$来计算$x^n$。没有简单的方法来展开阶乘的计算,但是这个并不是必需的,因为阶乘并不依赖 $x$ ,我们可以将值预先计算好,存一个表中。更好的方法是存储阶乘的倒数,这样我们就不需要除法了(如你所知,除法是很慢的)。现在上述的的代码可以按如下的方式向量化(使用 *Intel vector classes):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Example 12.9b. Taylor series, vectorized
#include <dvec.h> // Define vector classes (Intel)
#include <pmmintrin.h> // SSE3 required
// This function adds the elements of a vector, uses SSE3.
// (This is faster than the function add_horizontal)
static inline float add_elements(__m128 const & x)
{
__m128 s;
s = _mm_hadd_ps(x, x);
s = _mm_hadd_ps(s, s);
return _mm_cvtss_f32(s);
}
float Exp(float x)
{
// Approximate exp(x) for small x
__declspec(align(16)) // align table by 16
const float coef[16] = { // table of 1/n!
1., 1./2., 1./6., 1./24., 1./120., 1./720., 1./5040.,
1./40320., 1./362880., 1./3628800., 1./39916800.,
1./4.790016E8, 1./6.22702E9, 1./8.71782E10,
1./1.30767E12, 1./2.09227E13};
float x2 = x * x; // x^2
float x4 = x2 * x2; // x^4
// Define vectors of four floats
F32vec4 xxn(x4, x2*x, x2, x); // x^1, x^2, x^3, x^4
F32vec4 xx4(x4); // x^4
F32vec4 s(0.f, 0.f, 0.f, 1.f); // initialize sum
for (int i = 0; i < 16; i += 4)
{
// Loop by 4
s += xxn * _mm_load_ps(coef+i); // s += x^n/n!
xxn *= xx4; // next four x^n
}
return add_elements(s); // add the four sums
}

这个循环在一个向量中计算四个连续的项。如果循环很长,那么进一步展开循环可能是值得的,因为这里的速度可能受到 xxn 相乘的延迟而不是吞吐量的限制(参见11 乱序执行)。这里的系数表是在编译时计算的。在运行时计算系数表可能更方便,只要你能确保系数表只被计算一次,而不是每次调用函数时都会被计算一次。

12. 7 用于向量的数学函数

对于向量,有很多的函数库可以用于计算对数函数、指数函数、三角函数等数学函数。这些函数库对于向量化数学代码非常有用。

向量数学库有两种:长向量库(long vector library )和短向量库(short vector library )。为了解释它们之间的区别,我们假设你想用相同的函数对一千个数进行计算。使用长向量库时,你将一个包含一千个数字的数组作为参数提供给库函数,该函数将一千个结果存储在另一个数组中。使用长向量库的缺点是,如果要进行一长串计算,则必须在进行进一步计算前之前,必须每个步骤的中间结果存储在临时数组中。使用短向量库时,你可以将数据集划分为子向量,这些子向量与 CPU 中向量寄存器的大小相匹配。如果向量寄存器可以容纳 4个数字,那么你必须调用库函数 250次,每次将 4个数字装入向量寄存器。库函数将在向量寄存器中返回结果,向量寄存器可以在计算序列中的下一个步骤直接使用,而不需要将中间结果存储在 RAM 内存中。尽管有额外的函数调用,但这可能会更快,因为 CPU 可以在预取下一个函数的代码的同时进行计算。然而,如果计算序列形成了长依赖链,使用短向量的方法可能会处于不利的地位。我们希望 CPU 在完成对第一个子向量的计算之前开始对第二个子向量的计算。长依赖链可能会填满 CPU 中挂起的指令队列,并阻止其充分利用乱序执行的计算能力。

下面是一些长向量数学库的列表:

  1. Intel vector math library (VML, MKL)。支持所有x86 平台。这个库降低了非英特尔CPU 的性能,除非你重写了英特尔的 CPU分派程序。见13.7 Intel 编译器中的 CPU分派
  2. Intel Performance Primitives (IPP)。支持所有x86 平台。在非英特尔 CPU 上也能工作的很好。包括许多用于统计学,信号处理和图像处理的函数。
  3. Yeppp。开源库。支持 x86ARM 平台以及多种编程语言。www.yeppp.info

下面是一些短向量数学库的列表:

  1. Sleef library。支持多种不同的平台。开源。www.sleef.org
  2. Intel short vector math library (SVML)。这是由 Intel 编译器 提供的,并通过自动向量化调用。Gnu编译器 可以通过选项 -mveclibabi=svml 使用这个库。如果不使用 Intel 编译器话,这个库通常可以很好地处理 非Intel CPU。见13.7 Intel 编译器中的 CPU分派
  3. AMD LIBM library。只在64位LinuxWindows 平台上可用。这个库在没有FMA4 指令集的情况下降低了CPU 的性能(这个指令集最初是由英特尔设计的,但目前只有 AMD 的CPU 支持)。在 Gnu编译器 中,可以通过选项 -mveclibabi=acml 使用这个库。
  4. VCL vector class library。支持所有x86 平台。支持 MicrosoftIntelGnuClang 编译器。代码是内联的,不需要链接外部库。www.agner.org/optimize/#vectorclass

所有这些库都具有很好的性能和精度。速度比任何非向量库快很多倍。

SVMLLIBM 库中的函数名没有很好的说明文档。如果你想直接调用库函数,可以参考下表中的例子:

Library exp function of 4 floats exp function of 2 double
Intel SVML v.10.2 & earlier vmlsExp4 vmldExp2
Intel SVML v.10.3 & later __svml_expf4 __svml_exp2
Intel SVML + ia32intrin.h _mm_exp_ps _mm_exp_pd
AMD Core Math Library __vrs4_expf __vrd2_exp
AMD LIBM Library amd_vrs4_expf amd_vrd2_exp
VCL vector class library exp exp

12.8 对齐动态分配的内存

使用 newmalloc 分配的内存通常按 8字节对齐,而不是按 16字节对齐。当矢量运算需要按 16对齐时,这就是个问题了。Intel 编译器 通过定义 _mm_malloc_mm_free 解决了这个问题。

更通用的方法是将分配的数组封装到容器类中,由容器类负责对齐。参见www.agner.org/optimize/cppexamples.zip了解如何使用向量访问使数组对齐。

12.9 对齐RGB视频或三维向量

RGB 图像数据每个点有三个值。这不适用于具有四个浮点数的向量。这一点同样适用于三维几何和其他奇数大小的向量数据。为了提高效率,数据必须按向量大小对齐。使用未对齐的读和写可能会降低执行速度,从而减少使用向量操作的优势。你可以选择以下一种解决方案,具体哪种方案最适合取决于你的算法:

  1. 加入不使用的第四个值,使数据刚好可以装入向量中。这是一个简单的解决方案,但是它增加了内存使用量。如果内存访问是瓶颈,需要避免使用这种方法。
  2. 将四个(或八个)点的的数据组成一组,其中一个向量中有四个 R值,下一个向量中有四个 G值,最后一个向量中有四个 B值。
  3. 首先将所有的 R值组织成数据,然后是所有的 G值,最后是所有的 B值。

选择哪种方法取决于哪种方法最适合所讨论的算法。你可以选择可以写出最简单代码的方法。

如果点的数量不能被向量大小整除,那么可以在最后面添加几个未使用的点,以得到整数个向量。

12.10 总结

如果代码天生具有并行性,那么使用向量可以大大提高速度。增益取决于每个向量的元素数。最简单和最干净的解决方案是依赖于编译器的自动向量化。在并行性明显且代码只包含简单标准操作的情况下,编译器将自动向量化代码。你所要做的就是使用适当的指令集和限制少的浮点选项。

然而,在许多情况下,编译器无法自动对代码进行向量化,或者以一种不太理想的方式进行向量化。在这里,你必须显式地向量化代码,有很多方法可以做到:

  1. 使用汇编语言。
  2. 使用指令集。
  3. 使用预定义的向量类。

使用向量类是向量化代码的最简单方法。如果你需要的操作向量类库并没有提供,那么你可以通过使用向量类外加几个指令集函数来实现。无论你选择使用指令集函数还是向量类,这只是是否方便的问题 — 在性能上没有区别。一个好的优化编译器应该在这两种情况下生成相同的代码。指令集函数看起来笨拙而乏味。当你使用向量类和重载运算符时,代码将变得更具可读性。

好的编译器通常能够在手动向量化代码之后进一步优化代码。编译器可以使用函数内联、公共子表达式消除、常量传播、循环优化等优化技术。这些技术很少用于手动编写编码,因为它使代码变得笨拙、容易出错,而且难以维护。因此,在许多情况下,手动向量化与编译器的进一步优化相结合可以得到最好的结果。当前的编译器并不总是擅长于在向量化的代码上进行常量传播和某些其他优化技术。因此,在编译器的自动向量化不出问题情况下,依赖于编译器的自动向量化会更好。为了找到最佳解决方案,可能需要进行一些试验。

向量化代码通常包含许多额外的指令,用于将数据转换为正确的格式,并将它们放到向量中的正确位置。这种数据转换和变换有时会比实际计算花费更多的时间。在决定使用向量化代码是否有利可图时,应该考虑到这一点。

我将通过总结决定矢量化有多有利的因素来结束本节。

使向量化有利的因素:

  1. 小数据类型:charshortintfloat
  2. 大数组中对所有数据进行相似的操作。
  3. 数组大小可以被向量大小整除。
  4. 在不可预测的分支中选择两个简单表达式。
  5. 只有向量操作数可用的操作:取最小值、取最大值、饱和加法、快速近似倒数、快速近似倒数平方根、RGB色差。
  6. 向量指令集可用时,如:AVXAVX2AVX-512
  7. 数学向量函数库。
  8. 使用 GNUClangIntel 编译器。

使向量化不那么有用的因素:

  1. 大数据类型:int64_tdouble
  2. 未对齐的数据。
  3. 额外的数据转换:需要 shufflingpackingunpacking 等操作。
  4. 分支可预测时,在未选中时可以跳过大量表达式。
  5. 编译器没有足够的指针对齐和别名信息。
  6. 在指令集中缺少合适类型的向量操作,如 SSE4.1 之前,没有 32位整数乘法和整数除法。
  7. 执行单元小于向量寄存器大小的老 CPU

对于程序员来说,向量化的代码更不容易编写,因此也更容易出错。因此,向量化代码最好放在可重用且经过良好测试的库模块和头文件中。

13 为不同指令集生成多个版本的关键代码

微处理器制造商不断地向指令集中添加新的指令。这些新的指令可以使某些类型的代码执行得更快。对指令集最重要的补充是第12章中提到的向量运算。

如果代码是为特定的指令集编译的,那么它将与支持该指令集或任何更高指令集的所有CPU 兼容,但可能不与更早的CPU 兼容。向后兼容指令集的顺序如下:

Instruction set Important features
80386 32 bit mode
SSE 128 bit float vectors
SSE2 128 bit integer and double vectors
SSE3 horizontal add, etc.
SSSE3 a few more integer vector instructions
SSE4.1 some more vector instructions
SSE4.2 string search instructions
AVX 256 bit float and double vectors
AVX2 256 bit integer vectors
FMA3 floating point multiply-and-add
AVX-512 512 bit integer and floating point vectors

Table 13.1. Instruction sets

手册4 “Instruction tables”提供了对指令集的更详细的说明。在混合使用 AVX 或更高版本编译的代码和不使用 AVX 编译的代码时会有一定的限制,如12.1 AVX 指令集和 YMM寄存器中所述。

使用最新指令集的一个缺点是缺失了与旧版本微处理器的兼容性。这个难题可以在关键部分通过为不同的 CPU 使用多个版本的代码中来解决。这称为 CPU分派。例如,你可能希望创建一个利用AVX2 指令集优势的版本,另一个只使用SSE2 指令集的,以及一个而不使用任何这些指令集与旧版本微处理器兼容的通用版本。程序应该自动检测CPU 支持哪个指令集。

13.1 CPU分派策略

在开发、测试和维护方面,将一段代码转换成多个版本,每个版本都针对一组特定的 CPU 进行仔细的优化和微调,这代价是相当大的。对于在多个应用程序中使用的通用函数库,这些代价是合理的,但这对于用于特定应用程序的代码并不是总是合理的。如果你考虑使用CPU分派 来生成高度优化的1代码,那么如果可能的话,最好以可重用库的形式来实现。这也使得测试和维护更加容易。

我对CPU分派 做了大量的研究,发现很多常用的程序都使用了不合适的CPU分派 方法。

CPU分派最常见的陷阱是:

  1. 为当前的处理器而不是未来的处理器做优化。考虑使用CPU分派 开发和发布函数库所需的时间。在应用程序程序员获得库的新版本之前,还需要额外的时间。再加上开发和推广应用程序所需的时间。在加上最终用户获得应用程序的最新版本所需的时间。总而言之,你的代码要在大多数最终用户的计算机上运行通常需要几年的时间。此时,你所优化的任何处理器都可能已经过时。程序员常常低估了这种时间延迟。
  2. 考虑特定的处理器型号而不是处理器特性。程序员通常会考虑的是“什么任务在处理器X上工作得最好?”而不是“什么任务在具有这个指令集的处理器上工作得最好?”。每个处理器型号使用的代码分支的列表将会非常长,而且难以维护。最终用户也不太可能拥有最新的版本。CPU分派器 不应该考虑CPU 的品牌和型号,而应该考虑它具有哪些指令集和其他特性。
  3. 假设处理器型号组成一个合理的序列。如果你知道处理器型号N支持一个特定的指令集,那么你就不能假定型号N+1会至少支持相同的指令集。一个数字更大的型号不一定是更新的。CPU 系列和型号并不总是连续的,对于未知的CPU,你不能根据它的系列和型号对其进行任何假设。
  4. 无法正确处理未知处理器。许多 CPU分派器 被设计成只处理已知的处理器。在编写程序时未知的其他品牌或型号,通常会使用通用的代码分支,这是性能最差的分支。我们必须记住,许多用户更愿意在最新的 CPU 上运行速度关键型程序,而这个CPU 在编写程序时很可能是未知的。 CPU分派器 应该给一个未知品牌或型号的 CPU 分配最好的分支,如果 CPU 支持该分支兼容的指令集的话。“我们不支持处理器X”这样的常见借口在这里是不恰当的,它揭示了 CPU分派 的根本缺陷。
  5. 低估维持更新 CPU分派程序 的成本。很容易为特定CPU 型号对代码进行调优,然后认为在新的CPU 型号上市时你可以进行更新。但是,调优、测试、验证和维护一个新的代码分支的成本是如此之高,以至于在未来的许多年里,对每一个进入市场的新处理器都这么做是不现实的。即使是大型软件公司也常常无法使它们的CPU分派程序 保持最新。一个更现实的目标是,只有当一个新的指令集出现,使显著地提升性能成为可能时,才创建一个新的分支。
  6. 创建太多的代码分支。如果你正在创建针对特定的CPU 品牌或特定型号进行调优的分支,那么你很快就会得到许多占用缓存空间且难以维护的分支。你在特定 CPU 型号中处理的任何特定瓶颈或任何特别慢的指令在一两年内都可能变得不相关。通常,只要有两个分支就足够了:一个用于最新的指令集,另一个与最多 5年或 10年前的 CPU 兼容。CPU 市场发展如此之快,以至于今天全新的 CPU 将在明年成为主流。
  7. 忽略虚拟化CPUID 指令能够真正表示已知CPU 型号的时代已经结束了。虚拟化变得越来越重要。虚拟处理器会减少内核的数量,以便为同一机器上的其他虚拟处理器保留资源。为了反映这一点,或者与一些历史遗留软件兼容,虚拟处理器可能会给出一个错误的型号。它甚至可能有一个错误的供应商字符串。在未来,我们可能还会看到不对应于任何已知硬件CPU仿真处理器FPGA软核。这些虚拟处理器可以是任意品牌和型号。我们唯一可以依赖的CPUID 信息是特性信息,比如支持的指令集和缓存大小。

幸运的是,这些问题的解决方案在大多数情况下都非常简单:CPU分派器 应该拥有尽可能少的分支,并且分派应该基于CPU 支持的指令集,而不是CPU 的品牌、系列和型号。

我见过许多CPU分派 做的很差劲的例子。例如,Mathcad 的最新版本(v15.0)使用的是IntelMath Kernel LIbrary 6年前的版本(MKL v7.2)。这个库有一个CPU分派器,它不能以最优的方式处理现在的CPU。如果将CPUID 人为地更改为旧的 Pentium 4,在当前Intel CPU 上,某些任务的速度可以提高 33% 以上。原因是MKL 中的CPU分派程序 依赖于CPU 的族号(family number),Pentium 4的CPU 族号是 15,而所有较新的Intel CPU 的族号为 6!当 CPUID 被改成假的 Intel Pentium 4 时,在非 Intel CPU 上执行这项任务的速度提高了一倍多。更糟糕的是,许多软件产品无法识别VIA 处理器,因为这个品牌在软件开发时不太受欢迎。

对不同品牌CPU 的不平等的CPU分派机制 可能会成为一个严重的法律问题,正如你可以在我的博客中看到的那样。在这里,你还可以找到更糟糕的CPU分派的例子。

显然,你应该只对程序的最关键部分使用CPU分派 —— 最好隔离到单独的函数库中。只有当指令集相互不兼容时,才能使用将整个程序转换成多个版本这样激进的解决方案。一个具有定义良好的功能和接口的函数库比一个把分派分支分散在源文件中的程序更容易管理和测试、维护和验证。

13.2 指定型号的分派

在某些情况下,特定的代码实现在特定型号的处理器表现糟糕。你可以忽略这个问题,并假设下一个处理器型号将会表现的更好。如果这个问题太重要而不能忽略,那么解决方案是为该版本代码表现的不好的处理器型号创建一个负面清单(negative list )。为该版本代码表现良好的处理器型号列一个可用清单(positive list )不是一个好主意。原因是,每当市场上出现新的、更好的处理器时,都需要更新可用清单,这样的一个清单在你的软件生命周期内几乎肯定会过时。另一方面,在下一代处理器表现更好的情况下,负面清单不需要更新。每当处理器有一个特定的弱点或瓶颈时,生产者很可能会试图修复这个问题,使下一个型号表现的更好。

请再次记住,大多数软件在大部分时间内都是在编写软件时未知的处理器上运行的。如果软件包含运行最高级代码版本的处理器型号的可用清单,那么它将在程序编写时未知的处理器上运行较差的版本。但是,如果软件包含一个负面清单,其中列出了避免运行高级版本的处理器模型,那么它将在编程时未知的所有较新的型号上运行高级版本。

13.3 棘手的例子

在大多数情况下,可以根据所支持的指令集、缓存大小等CPUID 信息选择最优分支。但是,在一些情况下,有不同的方法可以做相同的事情,而CPUID 指令没有提供关于哪种实现最好的必要信息。这些情况有时用汇编语言处理。下面是一些例子:

  1. strlen 函数。字符串长度函数遍历字符串的所有字节以找到第一个值为零字节。好的实现使用XMM 寄存器,每次测试 16个字节,然后使用 BSF(bit scan forward)指令在 16个字节的块中定位第一个值为零字节。这个位扫描指在一些CPU 的实现速度特别慢。单独测试过 strlen 函数的程序员对该函数在位扫描指令很慢的CPU 上的性能不满意,并为特定的CPU 型号实现了一个单独的版本。但是,我们必须考虑到,对于每个函数调用,位扫描指令只执行一次,因此你必须在性能变得重要之前,调用该函数数十亿次,而很少有程序会需要这样做。因此,为位扫描指令的很慢的CPU 编写特殊版本的 strlen 函数是不值得的。我的建议是直接使用位扫描指令,并期待在未来的CPU 上,这是最快的解决方案。
  2. 一半大小的执行单元。向量寄存器已经从 64位的MMX 增加到了 128位的XMM 和 256位的YMM 寄存器。第一个支持 128位向量寄存器的处理器实际上执行单元只有 64位。每个 128位操作被分成两个 64位操作,因此使用更大的向量大小几乎没有任何速度上的优势。后来的型号拥有 128位的完整执行单元,因此速度更快。同样,最初支持 256位指令的处理器将 256位读取操作拆分为两个 128位读取操作。对于即将到来的 512位指令集以及将来寄存器大小的进一步扩展,也可以期望得到相同的结果。通常,新寄存器大小的全部优势只出现在支持它的第二代处理器中。在某些情况下,向量实现仅在具有全尺寸执行单元的CPU 上是最优的。问题是CPU分派程序 很难知道最大的向量寄存器是以半速还是全速处理的。这个问题的一个简单解决方案是,只有在支持下一个更高的指令集时才使用新的寄存器大小。例如,只有在这种应用程序中支持AVX2 时才使用AVX。或者,使用负面清单,包含那些使用最新指令集没有优势的处理器型号。
  3. 高精度数学。用于高精度数学的库允许使用大整数加法。这通常在循环中使用 ADC 指令(带进位)完成,其中进位必须从一个迭代传递到下一个迭代中。进位可以保存在进位标志中,也可以保存在寄存器中。如果进位保存在进位标志中,那么循环分支必须依赖使用零标志的指令,并且不能修改进位标志(例如 DECJNZ)。在将标志寄存器的分割为进位标志位和零标志位存在问题的老版本Intel CPUs 上,由于所谓的部分标志位(so-called partical flags)在处理器中存在问题,这个方案可能会导致很长的延迟,但是在AMD CPUs 上不存在这个问题(详见手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。这是少数几种使根据CPU 品牌进行分派变得合理的情况之一。在特定品牌的最新CPU 上表现良好的版本也可能是未来同样品牌其它型号的最优选择。较新的处理器支持用于高精度数学的 ADX 指令。
  4. 内存复制。复制内存块有几种不同的方法。这些方法在手册2“Optimizing subroutines in assembly language”第17.9节“移动数据块”中进行了讨论,其中还讨论了在不同的处理器上哪种方法速度最快。在C++ 程序中,你应该选择一个最新的,很好地实现了 memcpy 函数的函数库。由于存在不同的微处理器、不同的对齐方式和不同大小的需要复制的数据块等很多不同情况,因此惟一合理的解决方案是使用一个具有CPU分派 的标准函数库。这个函数非常重要,并且被广泛使用,大多数函数库的这个函数都有CPU分派 功能,尽管不是所有库都有最好的和最新的解决方案。编译器在复制大型对象时可能会隐式地使用 memcpy 函数,除非有一个复制构造函数以其他方式指定。

在诸如此类的困难情况下,记住你的代码很可能大部分时间在编写程序时还未知的处理器上运行是很重要的 。因此,重点要考虑哪种方法可能对未来处理器效果最好,并且为所有支持必要指令集的处理器选择该方法。如果问题在未来由于微处理器硬件设计的总体改进而消失,那么就不值得花费精力来根据复杂的条件或者具体CPU 型号的清单来编写CPU分派器

终极解决方案的选择将需要性能测试,该测试度量关键代码的每个版本的速度,以查看在实际处理器上哪个解决方案是最优的。然而,这涉及到时钟频率可能的动态变化,以及由于中断和任务切换而导致测量不稳定的问题。因此,为了做出可靠的决策,有必要对不同的版本进行多次交替测试。

13.4 测试和维护

当软件使用CPU分派 时,有两件事情需要测试:

  1. 通过使用特定版本的代码,你在速度上获得了多少增益。
  2. 检查所有版本代码是否正确工作。

速度测试最好是针对每个代码分支所特定的CPU 类型进行。换句话说,如果你想优化几个不同CPU,就需要在几个不同的CPU 上进行测试。

另一方面,没有必要使用许多不同的CPU 来验证所有代码分支是否正确工作。一个使用低版本指令集的代码分支仍然可以在一个支持高版本指令集的CPU 上运行。因此,你只需要一个支持最高版本指令集的CPU 来测试所有分支的正确性。因此,建议在代码中添加一个测试特性,使你能够重写CPU分派 并运行任何代码分支来进行测试。

如果代码是作为函数库或单独的模块实现的,那么编写一个可以单独调用所有代码分支并测试其功能的测试程序是很方便的。这对以后的维护非常有帮助。然而,这不是一本关于测试理论的教科书。关于如何测试软件模块的正确性的建议一定可以在其他地方找到。

13.5 实现

CPU分派机制 可以在不同的地方实现,在不同的时间做出分派决策:

  1. 在每次调用时分派。使用分支树或者 switch 语句为关键函数选择合适的版本。每次调用关键函数时都会判断分支。这存在需要花费时间去判断分支的问题。
  2. 在第一次调用时分派。通过函数指针调用函数,该指针最初指向一个调度器。分派器更改函数指针并使其指向函数的正确版本。这样做的好处是,在函数从未被调用时,分派器不会花费时间决定使用哪个版本。下面的例 13.1演示了这种方法。
  3. 在初始化时生成指针。程序或函数库有一个初始化路径,该初始化路径在第一次调用关键函数之前调用。初始化路径将函数指针设置为函数的正确版本。这样做的好处是,函数调用的响应时间是一致的。
  4. 在初始化时加载库。每个代码版本都在一个单独的动态链接库(.dll 或 .so)中实现。程序有一个初始化路径,它会加适当版本的库。如果库非常大,或者必须使用不同的编译器编译不同的版本,则该方法非常有用。
  5. 在加载阶段分派。程序使用一个过程链接表(PLT),过程链接表在程序加载时候初始化。这个方法需要操作系统的支持,在最新版本的Linux 系统中可用(Mac OS 或许也可以),见13.6 GNU 编译器中的 CPU分派
  6. 在安装时分派。每个代码版本都在一个单独的动态链接库(.dll 或 .so)中实现。安装程序创建适当版本库的符号链接,应用程序通过符号链接加载库。
  7. 使用不同的可执行文件。如果指令集互不相容,可以使用这种方法。你可以为 32位和 64位系统创建单独的可执行程序。程序的适当版本可以在安装过程中选择,也可以通过可执行文件选择。

如果关键代码的不同版本使用不同的编译器编译,那么建议对所有关键代码调用的库函数使用静态链接,这样你不需要分发应用程序中属于不同编译器的所有代码。

各种指令集的可用性可以通过调用系统函数来确定(例如:在Windows 中可以调用 IsProcessorFeaturePresent)。或者,你可以直接调用CPUID 指令,也可以使用我提供的函数库中的CPU 检测函数,这个函数的名字是 InstructionSet()。下面的这个例子将展示如何使用 InstructionSet() 实现在第一次调用方法时进行CPU 分派:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// Example 13.1
// CPU dispatching on first call

// Header file for InstructionSet()
#include "asmlib.h"

// Define function type with desired paramet
typedef int CriticalFunctionType(int parm1, int parm2);

// Function prototype
CriticalFunctionType CriticalFunction_Dispatch;

// Function pointer serves as entry point.
// After first call it will point to the appropriate function version
CriticalFunctionType * CriticalFunction = &CriticalFunction_Dispatch;

// Lowest version
int CriticalFunction_386(int parm1, int parm2) {...}

// SSE2 version
int CriticalFunction_SSE2(int parm1, int parm2) {...}

// AVX version
int CriticalFunction_AVX(int parm1, int parm2) {...}

// Dispatcher. Will be called only first time
int CriticalFunction_Dispatch(int parm1, int parm2)
{
// Get supported instruction set, using asmlib library
int level = InstructionSet();
// Set pointer to the appropriate version (May use a table
// of function pointers if there are many branches):
if (level >= 11)
{
// AVX supported
CriticalFunction = &CriticalFunction_AVX;
}
else if (level >= 4)
{
// SSE2 supported
CriticalFunction = &CriticalFunction_SSE2;
}
else
{
// Generic version
CriticalFunction = &CriticalFunction_386;
}
// Now call the chosen version
return (*CriticalFunction)(parm1, parm2);
}

int main()
{
int a, b, c;
...
// Call critical function through function pointer
a = (*CriticalFunction)(b, c);
...
return 0;
}

函数 InstructionSet() 包含在函数库 asmlib。这个函数是独立于操作系统的,它检查 CPU 和操作系统是否支持不同的指令集。例13.1CriticalFunction 的不同版本可以在必要时放在单独的模块中,每个模块都为特定的指令集编译。

13.6 GNU 编译器中的 CPU分派

Linux 中引入了一个名为“Gnu 间接函数 ”的特性,并在 2010年被 Gnu 实用工具所支持。该特性用于CPU 分派,并在Gnu C 库中被使用。它需要编译器、链接器和加载器的支持(binutils 的版本为 2.20, glibc 版本为 2.11的 ifunc 分支)。

使用这个特性按照下述方法以一种普通的方式使用过程链接表(PLT ):同一函数有两个或多个版本,每个版本都针对特定的CPU 或其他硬件条件进行了优化。分派函数决定使用哪个函数,并返回指向所需函数的指针。PLT 入口最初指向调度函数。当程序加载时,加载器调用调度函数,并用从调度函数获得的指针替换PLT 入口。这将使对函数的任何调用转到所需的版本。注意,分配函数通常在程序开始运行之前调用,并且在调用任何构造函数之前调用。因此,分配函数不能依赖于正在初始化的任何其他东西。即使从未调用被调度函数,也很可能调用调度函数。

不幸的是,目前在 Gnu 手册中描述的语法并不能正确工作。然而,可以使用以下方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// Example 13.2. CPU dispatching in Gnu compiler
// Same as example 13.1, Requires binutils version 2.20 or later

// Header file for InstructionSet()
#include "asmlib.h"

// Lowest version
int CriticalFunction_386(int parm1, int parm2) {...}

// SSE2 version
int CriticalFunction_SSE2(int parm1, int parm2) {...}

// AVX version
int CriticalFunction_AVX(int parm1, int parm2) {...}

// Prototype for the common entry point
extern "C" int CriticalFunction ();
__asm__ (".type CriticalFunction, @gnu_indirect_function");

// Make the dispatcher function.
typeof(CriticalFunction) * CriticalFunctionDispatch(void)
__asm__ ("CriticalFunction");
typeof(CriticalFunction) * CriticalFunctionDispatch(void)
{
// Returns a pointer to the desired function version
// Get supported instruction set, using asmlib library
int level = InstructionSet();
// Set pointer to the appropriate version (May use a table
// of function pointers if there are many branches):
if (level >= 11)
{
// AVX supported
return &CriticalFunction_AVX;
}
if (level >= 4)
{
// SSE2 supported
return &CriticalFunction_SSE2;
}
// Default version
return &CriticalFunction_386;
}

int main()
{
int a, b, c;
...
// Call critical function
a = CriticalFunction(b, c);
...
return 0;
}

Gnu C 函数库中,间接函数特性被用于一些特别关键的函数。

13.7 Intel 编译器中的 CPU分派

Intel 编译器有一个特性,可以为一个函数的生成多个版本对应于多个Intel CPU。每次调用方法都使用分派。当调用该函数时,为函数分配所期望的版本。通过使用选项/QaxAVX-axAVX 编译模块,可以对模块中所有合适的函数进行自动分派。甚至会给非关键函数也生成多个版本。通过使用指令 _declspec(cpu_dispatch(…)),可以只对速度关键的函数执行分派。有关详细信息,请参阅Intel C++ 编译器文档。注意,Intel 编译器中的CPU分派机制 只适用于Intel CPU,而不适用于AMDVIA 等其他品牌的CPU。下一节展示了一种解决CPU 检测机制中的这种限制和其他缺陷的方法。

Intel 编译器中的CPU分派机制 的效率低于Gnu 编译器 中的机制,因为它对关键函数的每次调用都进行分派。在某些情况下,每次调用函数时,Intel 的机制都会执行一系列分支,而Gnu 的机制则在过程链接表中存储指向所需版本的指针。如果一个分派函数调用另一个分派函数,那么后者的分派分支也将被执行,即使此时已经知道CPU 的类型。这可以通过内联后一个函数来避免,但是更好的方法是像例 13.1 中(13.5 实现)那样显式地执行CPU分派

Intel 编译器和函数库具有自动 CPU分派 的特性。针对不同的处理器和指令集,很多Intel 函数库都有几个不同的版本。同样的,编译器可以使用自动 CPU分派为用户写的代码生成多个版本的代码。

不幸的是,Intel 编译器的CPU 检测机制 存在几个缺陷:

  1. 只有在运行在Intel 处理器上时才会选择代码的最佳版本。CPU分派程序 在检查它支持的指令集之前,检查处理器是否是Intel 的。如果处理器不是Intel 的,则选择较差版本的代码,即使处理器与较好版本的代码兼容。这可能导致在AMDVIA 处理器上的性能急剧下降。
  2. 显式 CPU分派 只适用于Intel 处理器。对于非 Intel 处理器,通过简单地执行一个非法操作,使分派器发出错误信号,然后使程序崩溃。
  3. CPU分派器 不检查操作系统是否支持XMM 寄存器。它将在不支持SSE 的旧操作系统上崩溃。

Intel 发布的几个函数库具有类似的CPU分派机制,其中一些函数库还以次优方式处理非Intel CPU

英特尔CPU分派器 以非最佳方式处理非英特尔 CPU 的事实已经成为一个严重的法律问题。详情请参阅我的博客

Intel 编译器的行为使程序员陷入了一个糟糕的困境。你可能更喜欢使用Intel 编译器,因为它具有许多高级优化特性,而且你可能希望使用经过良好优化的Intel 函数库,但是谁愿意给程序加上一个说它在非 Intel 机器上不能很好地工作的标签呢?

这个问题可能的解决方案如下所列:

  1. 使用特定的指令集编译,例如 /arch:SSE2。编译器将为这个指令集生成最优代码,并且大多数库函数直插入SSE2 版本,而不进行CPU分派。测试一下程序是否在非英特尔CPU 上令人满意地运行。如果没有,则可能需要替换CPU 检测功能,如下所述。该程序将与不包含当前选择的指令集的旧版本微处理器不兼容。
  2. 为代码中最关键的部分创建两个或多个版本,并使用指定的合适指令集分别编译它们。在代码中插入显式的CPU分派,以调用适合其运行的微处理器的版本。
  3. 替换或绕过英特尔编译器的 CPU检测功能。该方法将在下面一节中讨论。
  4. 直接调用特定于CPU 版本的库函数。特定于CPU 的函数的名称带有后缀,例如对于AVX 指令集,后缀为 .R。这些后缀在手册5:“calling conventions”中的表 19中列出。函数名中符号.C++ 中是不被允许的,因此你需要使用汇编代码或 objconv 或类似的实用程序来修改对象文件中的名称。
  5. 使用在所有品牌CPU 上都运行良好的函数库。

如果程序中最耗时的部分包含自动 CPU分派 或内存密集型函数,如 memcpymemmovememset或数学函数,如powlogexpsin等,则可以使用上述一种或多种方法提升非intel 处理器 的性能。

重载 Intel CPU 检测功能

在某些情况下,CPU检测功能 有两个版本,一个区分CPU 品牌,另一个不区分。

未文档中记录的Intel 库函数 _intel_cpu_features_init() 设置变量 _intel_cpu_feature_indicator(其中每个位表示Intel CPU上特定的CPU 特性)。另一个函数 _intel_cpu_features_init_x() 在不区分CPU 品牌的情况下执行相同的操作,并以类似的方式设置变量 __intel_cpu_feature_indicator_x。只需将这些变量设置为零,然后调用_intel_cpu_features_init_x(),就可以绕过对CPU 品牌的检查。

在其他情况下,可以通过使另一个具有相同名称的函数来替换Intel 函数库和编译器生成的代码中的CPU 检测函数。在Windows 操作系统,这需要使用静态链接(例如,选项 /MT )。在LinuxMac 系统中,静态链接和动态链接都能起作用。

http://www.agner.org/optimize/asmlib.zip 中的文件包含这些方法的完整代码示例。

如果你正在使用Intel 编译器,那么请确保启动代码和 main() 在编译时没有任何限制CPU 品牌的选项。代码的关键部分可以放在一个单独的CC++ 文件中,并为所需的指令集编译。如果这些方法中有任何一种绕过了CPU 品牌检查,那么关键部分就可以在任何CPU 品牌的CPU 上得到最佳的性能。

Intel 函数库与其他编译器一起使用时,这些方法也可以工作。包括MKLVMLSVML 等函数库。IPP 函数库不需要任何补丁。

注意,这些方法是基于我自己的研究,而不是基于公开的信息。它们在 Intel编译器从版本 7 到 14 的测试中运行良好,每个版本都有一些变化。这些示例适用于 WindowsLinux, 32位和64位。它们还没有在 Mac 系统中进行测试。

14 具体的优化主题

14.1 使用查找表

如果列表是被缓存过的,从表中读取一个常数的值是很快的。通常情况下,从缓存在一级缓存的列表读取操作只需要花费几个时钟周期。如果函数只有有限数量的可能输入,我们可以利用这个事实,用查找表来替换函数调用。

让我们以整数阶乘函数($n!$)为例。唯一允许的输入是从0到12的整数。更大的输入会导致溢出,负的输入会得到无穷大。阶乘函数的一个典型实现是下面这样的:

1
2
3
4
5
6
7
8
9
10
// Example 14.1a

int factorial (int n)
{
// n!
int i, f = 1;
for (i = 2; i <= n; i++)
f *= i;
return f;
}

这种计算需要 $n-1$次乘法,而这将会花费当长的时间。如果使用查找表的话效率会更高:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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 137)
return FactorialTable[n]; // Table lookup
}
else
{
return 0; // return 0 if out of range
}
}

该实现使用查找表,而不是在每次调用函数时重新计算值。我在这里添加了一个界限检查,因为当 n 是数组索引时,n 超出范围的后果可能比n是循环计数时更严重。边界检查的方法在下面的14.2 边界检查中解释。

表应该声明为 const,以便启用常量传播和其他优化。你可以将函数声明为内联的。

用查找表替换函数,在可能输入的数量有限且没有缓存问题的大多数情况下是有利的,如果你希望每次调用后列表从缓存中被擦出,以及计算函数所花费的时间小于从内存中重新加载值的时间,加上程序的其他部分占据缓存所导致的时间开销之和,那么使用查找表是没有好处的。

查找表无法使用当前的指令集进行向量化。如果这妨碍使用更快的向量化代码,那么就不要使用查找表。

在静态内存中存储数据可能会导致缓存问题,因为静态数据可能分散在不同的内存地址。如果缓存是一个问题,那么将表从静态内存复制到最内层循环外的栈内存上可能是有用的。我们可以在函数中但在最内层循环之外声明表(不使用 static 关键字):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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中的 FactorialTable 在调用 CriticalInnerFunction 时从静态内存中复制到栈上。编译器将表存储在静态内存中,并在函数开始的地方插入代码,将表复制到栈内存中。当然,复制表需要额外的时间,但是当它位于关键的最内层循环之外时,这是被允许的。循环将使用存储在栈内存中的表的副本,这与其它本地变量相邻,因此缓存效率可能比静态内存更高。

如果你不喜欢手工计算表值并将值插入代码中,那么你当然可以让程序进行计算。只要只需要一次计算,那么计算表所花费的时间并不重要。有人可能会说,在程序中计算表比直接输入值更安全,因为手写表中的输入错误可能无法被检测到。

查找表的原理可用于程序在两个或多个常量之间进行选择的任何情况。例如,在两个常量之间进行选择的分支可以被一个包含两个条目的表替换。如果分支的可预测性很差,这可能可以提升性能。例如:

1
2
3
4
// Example 14.2a

float a; int b;
a = (b == 0) ? 1.0f : 2.5f;

如果我们假设 b 总是 0 或 1,并且它的值可预测性很差,那么使用查找表来代替分支是有利的:

1
2
3
4
// Example 14.2b
float a; int b;
const float OneOrTwo5[2] = {1.0f, 2.5f};
a = OneOrTwo5[b & 1];

在这里,因为安全性的原因,我将 b 按位与上 1,b & 1的值肯定只有 0 或 1(参见14.2 边界检查)。如果 b 的值肯定为 0 或 1,那么就可以省略对 b 的额外检查。使用 a = OneOrTwo5[b!=0],同样可以正确运行,但是效率稍低。但是,当 bfloatdouble 类型时,这种方法效率很低,因为我测试的所有编译器对OneOrTwo5[b!=0] 的实现都是 OneOrTwo5[(b!=0) ? 1 : 0],在这种情况下,我们无法摆脱分支。当 b 是浮点数时,编译器使用不同的实现似乎不合逻辑。我觉得原因是编译器制的开发人员认为浮点数比较比整数比较更容易预测。对于表达式 a = 1.0f + b * 1.5f,当 b 是一个浮点数时是高效的,但如果 b 是一个整数则效率较低,因为整数到浮点数的转换比查找表花费更多的时间。

将查找表作为 switch 语句的替代尤其有利,因为 switch 语句的可预测性经常较差。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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;
}

这可以使用查找表来提升效率:

1
2
3
4
5
6
7
8
9
10
// 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 边界检查

C++ 中,通常有必要检查数组索引是否超出范围。这常常看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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 < 0i >= size 这两个比较可以使用一个比较替换:

1
2
3
4
5
6
7
8
9
// Example 14.4b
if ((unsigned int)i >= (unsigned int)size)
{
cout << "Error: Index out of range";
}
else
{
list[i] += 1.0f;
}

i 被解释为无符号整数时,i 可能的负值将以一个较大的正数出现,这将触发错误条件。用一个比较替换两个比较可以加快代码的速度,因为测试一个条件相对比较昂贵,而类型转换根本不会生成额外的代码。

这个方法可以扩展到一般情况下:你想要检查一个整数是否在一个特定的区间之内:

1
2
3
4
5
// Example 14.5a

const int min = 100, max = 110; int i;
...
if (i >= min && i <= max) { ...

可以修改成:

1
2
3
// Example 14.5b

if ((unsigned int)(i - min) <= (unsigned int)(max - min)) { ...

如果所需区间的长度是 2的幂,则有一种更快的方法来限制整数的范围。例如:

1
2
3
4
5
// Example 14.6

float list[16]; int i;
...
list[i & 15] += 1.0f;

这需要略微解释一下。i&15 的值肯定在 0 到 15 的区间内。如果 i 在这个区间之外,例如 i = 18 ,那么 & 运算符(按位与)将 i 的二进制值截断为 4 位,结果将是 2。结果与 i 除上 16 的余数相同。如果我们不需要错误消息的话,这种方法在数组索引超出范围时可以防止程序出错。需要注意的是,这种方法只适用于2的幂(即2、4、8、16、32、64、……)。通过按位与上$2^{n -1}$,我们可以确保一个数的值小于 $2^n$,并且不是负的。按位与操作隔离数字中有效的低 n 位,并将所有其他位设为零。

14.3 使用位运算符一次检查多个值

位运算符 &|^~<<>> 可以在一次操作中测试或操作整数的所有位。例如,如果 32 位整数的每个位都有特定的含义,那么可以使用 | 运算符在一个操作中设置多个位;你用 & 运算符清除或遮掩掉多个位。你可以用 ^ 运算符转换多个位。

& 运算符对于测试单个操作中的多个条件也很有用。例如:

1
2
3
4
5
6
7
8
9
10
// 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 语句有三个条件,它们被实现为三个分支。如果将 SundayMonday 等常量定义为 2的幂,则可以将它们合并为一个分支:

1
2
3
4
5
6
7
8
9
10
// 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 中设置了 TuesdayWednesdayFriday 中的的任何位,& 操作的结果将是非零的,因此将被视为真。

注意布尔运算符 &&||! 以及对应的位运算符 &|~。布尔运算符产生一个结果,true(1)或 false (0),且第二个操作数只在需要时计算。位运算符在应用于 32位整数时,会产生 32个结果,它们总是对两个操作数进行求值。然而,位运算符的计算速度比布尔运算符快得多,因为只要操作数是整数表达式而不是布尔表达式,它们就不需要使用分支。

当使用整数作为布尔向量时,位运算符可以做很多事情,而且这些操作非常快。这在包含许多布尔表达式的程序中很有用。无论常量是用 enumconst 还是 #define 定义的,都不会影响性能。

14.4 整数乘法

整数乘法比加法和减法需要更长的时间(3 - 10个时钟周期,取决于处理器)。编译器优化通常会用一个常量替换整数乘法,并结合加法和移位操作。乘以 2的幂要比乘以其他常数快,因为它可以通过移位操作完成。例如,a*16 使用 a << 4计算,a * 17 使用 (a << 4) + a 计算。当与常数相乘时,你可以利用和 2的幂相乘的这个优势。编译器也有快速乘以 3、5 和 9 的方法。

在计算数组元素的地址时,会有隐式的乘法计算。在某些情况下,当因子为2的幂时,这个乘法会更快。例如:

1
2
3
4
5
6
7
8
9
10
11
12
// 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 的因子是 (cloumns * sizeof(float)) = 8 * 4 = 32。这是 2的幂,所以编译器可以用 j << 5 替换 j * 32。如果列的大小不是 2的幂,那么乘法会花费更长的时间。因此,如果以无序方式访问矩阵中的行,则将矩阵中的列数设置为 2的幂是有利的。

这同样适用于结构体或类元素的数组。如果以无序方式访问对象,则每个对象的大小最好是 2的幂。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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.814.9 中的代码发生了更改,以 i 代替 j 作为索引,那么编译器可以看到地址是按顺序访问的,并且可以通过在前一个地址上添加一个常量来计算每个地址(参见8.1 编译器时如何优化的:归纳变量(Induction variables))。在这种情况下,大小是否为2的幂并不重要。

使大小为2次幂的建议并不适用于非常大的数据结构。相反,如果矩阵太大以至于缓存成为问题,则应该尽量避免大小为2的幂。如果矩阵中的列数是2的幂,并且矩阵大于缓存,那么就可以得到代价非常昂贵的缓存竞争,如9.10 在大数据结构中的缓存竞争所解释的那样。

14.5 整数除法

整数除法的耗时要比加法、减法和乘法的耗时长得多(32位整数需要27 - 80个时钟周期,具体取决于处理器)。

整数除以 2的幂可以用移位运算来做,这样会快得多。

除以一个常数比除以一个变量快的多,因为编译器优化可以通过选择合适的 $n$ 使用公式: $a * (2^n/b) >> n$ 来计算 $a/b$。 常量 $(2^n/b)$ 是被预先计算好的,乘法是通过位的扩展数(extended number of bits)来完成的。该方法稍微复杂一些,因为必须添加符号和舍入误差的各种更正。该方法在手册2: “Optimizing subroutines in assembly language” 中有更详细的描述。当被除数是无符号的,该方法会快的多。

以下准则可用于改进包含整数除法的代码:

  1. 整数除以常数比变量快。确保在编译时知道除数的值。
  2. 如果常数是 2的幂的话,整数除法会更快。
  3. 当被除数是无符号时,整数除以常量会更快。

例如:

1
2
3
4
5
6
7
8
// 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

相同的准则同样适用于取模运算:

1
2
3
4
5
6
7
8
// 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中的函数库包含用于这些计算的各种函数。

通过将循环按常数展开,可以避免将循环计数器除以一个常数,例如:

1
2
3
4
5
6
7
8
// Example 14.12a

int list[300];
int i;
for (i = 0; i < 300; i++)
{
list[i] += i / 3;
}

这个可以使用下面的代码替换:

1
2
3
4
5
6
7
8
9
10
// 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;
}

类似的方法也可以用于避免模运算:

1
2
3
4
5
6
7
8
// Example 14.13a

int list[300];
int i;
for (i = 0; i < 300; i++)
{
list[i] = i % 3;
}

可以被替换成:

1
2
3
4
5
6
7
8
9
10
// 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.12b14.13b中的循环展开仅当循环计数可被展开因子整除时才有效。如果不能被整除,则必须在循环之外执行额外的操作:

1
2
3
4
5
6
7
8
9
10
11
// Example 14.13c

int list[301];
int i;
for (i = 0; i < 301; i += 3)
{
list[i] = 0;
list[i+1] = 1;
list[i+2] = 2;
}
list[300] = 0;

14.6 浮点数除法

浮点数除法的耗时比加法、减法和乘法(20 - 45个时钟周期)耗时要长得多。

浮点数除以一个常数可以用乘以常数的倒数来代替:

1
2
3
4
// Example 14.14a

double a, b;
a = b / 1.2345;

可以把这个改成:

1
2
3
4
// Example 14.14b

double a, b;
a = b * (1. / 1.2345);

编译器将在编译时计算 (1./1.2345) 的值,并将倒数插入到代码中,因此你将不会在除法上花费时间。一些编译器会自动将例 14.14a中的代码替换为14.14b的,但只有在某些选项被设置为放宽浮点精度要求时才会这样做(请参阅8.1 编译器是如何优化的:代数化简)。因此显式地进行这种优化更加安全。

有时除法会被完全消除,例如:

1
2
3
// Example 14.15a

if (a > b / c)

有时会被替换成:

1
2
3
// Example 14.15b

if (a * c > b)

但是要注意这里的陷阱:如果 c < 0,不等式符号必须反转。如果 bc 是整数,除法是不精确的,而乘法是精确的。

乘法和除法可以结合在一起,例如:

1
2
3
4
// Example 14.16a

double y, a1, a2, b1, b2;
y = a1/b1 + a2/b2;

这里我们可以通过公分母来消去一个除法:

1
2
3
4
// Example 14.16b

double y, a1, a2, b1, b2;
y = (a1*b2 + a2*b1) / (b1*b2);

使用公分母的技巧甚至可以用于完全独立的除法。例如:

1
2
3
4
5
// Example 14.17a

double a1, a2, b1, b2, y1, y2;
y1 = a1 / b1;
y2 = a2 / b2;

这可以这样变化:

1
2
3
4
5
6
// 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 不要混合使用 float 和 double

不管你使用的是单精度还是双精度,浮点数的计算通常花费相同的时间。但是在为 64位操作系统编译的程序和使用指令集SSE2 或更高版本编译的程序中,混合使用单精度和双精度是有代价的。例如:

1
2
3
4
// Example 14.18a

float a, b;
a = b * 1.2; // Mixing float and double is bad

C/C++ 标准规定所有浮点数常量在默认情况下都是双精度的。 所以在这个例子中, 1.2 是一个双精度的常量。因此,在将 b 与双精度常数相乘之前,需要将 b 从单精度转换为双精度,然后再将结果转换回单精度。这些转换需要很长的时间。你可以通过避免转换,来使代码达到 5倍的效率,无论是通过使常数变成单精度或 使 ab 变成双精度的:

1
2
3
4
5
6
7
// 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 浮点数和整数相互转换

将浮点数转换成整数

根据C++ 语言的标准,所有从浮点数到整数的转换都使用向零的截断,而不是四舍五入。这是不幸的,因为除非使用SSE2 指令集,否则截断要比舍入花费更长的时间。如果可能,建议启用SSE2 指令集。在64位模式下,SSE2 总是被启用。

在没有SSE2 的情况下,从浮点数到整数的转换通常需要 40个时钟周期。如果在代码的关键部分不能避免从 floatdoubleint 的转换,那么可以使用舍入而不是截断来提高效率。这大约快了三倍。程序的逻辑可能需要修改,以补偿舍入和截断之间的差异。

使用 lrintflrint 函数可以高效地将浮点数或双精度数转换为整数。不幸的是,由于对C99 标准的争议,许多商业编译器中缺少这些这些函数。下面的例 14.19给出了 lrint 函数的实现。该函数将浮点数四舍五入到最近的整数。如果两个整数相等,则返回偶数。没有溢出检查。此函数适用于 32位Windows 和 32位Linux 中的 MicrosoftIntelGnu 编译器 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Example 14.19

static inline int lrint (double const x) { // Round to nearest integer
int n;
#if defined(__unix__) || defined(__GNUC__)
// 32位 Linux, Gnu/AT&T syntax:
__asm ("fldl %1 \n fistpl %0 " : "=m"(n) : "m"(x) : "memory" );
#else
// 32位 Windows, Intel/MASM syntax:
__asm fld qword ptr x;
__asm fistp dword ptr n;
#endif
return n;
}

这段代码只适用于兼容Intel/x86 的微处理器。函数库amslib也提供了该函数。

在 64位模式下或启用 SSE2 指令集时,四舍五入和截断之间的速度没有差别。缺失的功能在 64位模式或启用SSE2 指令集时可以按如下代码实现:

1
2
3
4
5
6
7
8
9
10
11
// 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));
}

例 14.21 中的代码比其他四舍五入方法更快,但当启用SSE2 指令集时,它既不比截断快,也不比截断慢。

将整数转换成浮点数

整数到浮点数的转换比浮点数转换到整数快。转换时间通常在 5 到 20 个时钟周期之间。在某些情况下,使用浮点变量进行简单的计算可能是有利的,以避免从整数到浮点的转换。

无符号整数转换为浮点数的效率低于有符号整数转换成浮点数。如果无符号整数转换为有符号整数不会导致溢出,那么在转换为浮点数之前将无符号整数转换为有符号整数效率会更高。例如:

1
2
3
4
// Example 14.22a

unsigned int u; double d;
d = u;

如果你确定$u < 2^{31}$,那么在转换为浮点数之前先将其转换为有符号的:

1
2
3
4
// Example 14.22b

unsigned int u; double d;
d = (double)(signed int)u;

14.9 用整数操作来改变浮点型变量

根据IEEE 754 (1985) 标准,浮点数以二进制表示形式存储。几乎在所有现代微处理器和操作系统中都使用这个标准(一些非常老的DOS 编译器除外)。

floatdoublelong double 的表示法反映了形式为$\pm 2^{eee}.1.ffff$的浮点值。$\pm$表示符号,$eee$ 是指数,$fffff$ 是分数形式的二进制小数。符号位存储为单个位,0 表示正数,1 表示负数。指数存储为偏置二进制整数,分数存储为二进制数。如果可能的话,指数总是规格化的,所以小数点前的值是 1。这个“1”不包括在表示形式中,除非是 long double 类型。格式可以表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

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
};

非零浮点数的值可以使用下面的方式计算:

1
2
3
4
5
6
7
8
9
10
11
12
13

$$
floatvalue = (-1)^{sign}*2^{exponent-127}*(1+fraction*2^{-23}),
$$

$$
doublevalue = (-1)^{sign}*2^{exponent-1023}*(1+fraction*2^{-52}),
$$

$$
longdoublevalue = (-1)^{sign}*2^{exponent-16383}*(1+fraction*2^{-63}).
$$

如果除符号位之外的所有位都为 0,则值为 0。0可以表示可以包括或者不包括符号位。

浮点格式是标准化的这一事实允许我们使用整数操作直接操作浮点表示的不同部分。这可能是一个优势,因为整数操作比浮点操作快。只有当你确信你知道你在做什么时,你才应该使用这些方法。有关注意事项,请参阅本节的末尾。

我们只需要反转一个符号位就可以改变浮点数的符号:

1
2
3
4
5
6
7
8
// Example 14.23

union
{
float f;
int i;
} u;
u.i ^= 0x80000000; // flip sign bit of u.f

我们可以将符号位设置成 0以得到绝对值:

1
2
3
4
5
6
7
8
// Example 14.24

union
{
float f;
int i;
} u;
u.i &= 0x7FFFFFFF; // set sign bit to zero

我们可以通过测试除符号位以外的所有位来检查浮点数是否为零:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Example 14.25

union
{
float f;
int i;
} u;
if (u.i & 0x7FFFFFFF)
{
// test bits 0 - 30
// f is nonzero
}
else
{
// f is zero
}

我们对指数部分加上 $n$ 就可以将一个非零浮点数乘上 $2^n$:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Example 14.26

union
{
float f;
int i;
} u;
int n;
if (u.i & 0x7FFFFFFF)
{
// check if nonzero
u.i += n << 23; // add n to exponent
}

例 14.26不会检查溢出,而且只有$n$是整数时才能有用。当没有下溢风险时,你可以对指数部分减去 $n$ 以达到除以 $2^n$的目的。

1
2
3
4
5
6
7
8
9
10
11
// Example 14.27

union
{
float f;
int i;
} u, v;
if (u.i > v.i)
{
// u.f > v.f if both positive
}

例 14.27假设我们知道 u.fv.f 都是正的。如果两者都是负数,或者其中一个为 0,另一个为 -0(符号位为0),则会失败。

我们可以将符号位移出来比较绝对值:

1
2
3
4
5
6
7
8
9
10
11
// Example 14.28

union
{
float f;
unsigned int i;
} u, v;
if (u.i * 2 > v.i * 2)
{
// abs(u.f) > abs(v.f)
}

例 14.28中乘以 2 将移出符号位,使其余位表示浮点数绝对值的单调递增函数。

我们可以通过设置分数部分的位将在区间 $0 <= n < 2^{23}$的整数转换成在区间 $[1.0, 2.0)$的浮点数:

1
2
3
4
5
6
7
8
9
// Example 14.29

union
{
float f;
int i;
} u;
int n;
u.i = (n & 0x7FFFFF) | 0x3F800000; // Now 1.0 <= u.f < 2.0

该方法对随机数生成器非常有用。

通常,如果浮点变量存储在内存中,那么以整数的形式访问它会更快,但如果它是寄存器变量,则不会更快。union 强制变量存储在内存中,至少是临时的。因此,如果相同变量使用寄存器可以使其它临近代码获益时,那么使用上述示例中的方法将没有好处。

在这些例子中,我们使用 union 而不是指针的类型转换,是因为这种方法更安全。指针的类型转换可能不适用于遵循标准 C 严格的别名规则的编译器,该规则指定不同类型的指针不能指向同一对象,char 指针除外。

上面的例子都使用单精度。在 32位系统中使用双精度浮点数会变得更复杂。双精度浮点数用 64个位表示,但是 32 位系统不支持 64位整数。许多 32位系统允许你定义 64位整数,但是它们实际上用两个32位整数来表示,效率较低。你可以使用双精度浮点数的高 32位,它允许你访问符号位、指数和分数中的高几位。例如,可以这样测试双精度浮点数的符号:

1
2
3
4
5
6
7
8
9
10
11
12
// Example 14.23b

union
{
double d;
int i[2];
} u;
if (u.i[1] < 0)
{
// test sign bit
// u.d is negative or -0
}

不建议通过修改 double 类型的一半二进制位来修改它,比如,如果你想要通过 u.i[1] ^= 0x80000000 来反转上述示例中的符号位的话,但这很在 CPU 中产生存储转发延迟(参见手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。在64 位系统中,可以通过使用 64位整数而不是两个 32位整数表示 double 来避免这种情况。

访问双精度浮点数中的 32位的另一个问题是,它不能移植到大端存储的系统中。因此,如果要具有大端存储的其他平台上实现,例 14.23b例 14.30 将需要修改。所有x86 平台(WindowsLinuxBSD、基于Intel CPUMac OS 等)都使用小端存储,但其他系统可能使用大端存储(如PowerPC)。

我们可以通过比较 32 - 62 位来近似比较双精度浮点数。这在高斯消元法中求矩阵中值最大的主元是很有用的。例 14.28 中的方法在主元搜寻中可以这么使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Example 14.30

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.30 找到数组中 数字(除去符号位)最大(或差不多最大的)的元素。它可能无法区分相对差小于$2^{-20}$的元素,但这对于寻找合适的主元来说是足够准确的。整数比较可能比浮点比较更快。在大的端系统中,你必须用 u[0] 替换 u[1]

14.10 数学函数

最常见的数学函数如对数、指数函数、三角函数等都是在 x86 CPU 的硬件中实现的。然而,在大多数情况下,当SSE2 指令集可用时,软件实现比硬件实现更快。如果启用了SSE2 指令集,好的编译器将使用软件实现。

使用这些函数的软件实现而不是硬件实现的优势对于单精度比对于双精度更大。但在大多数情况下,软件实现要比硬件实现快,即使对于双精度也是如此。

通过包含与Intel C++ 编译器一起提供的库:libmmt.lib 和头文件 mathimf.h,你可以在不同的编译器中使用Intel 数学函数库。这个库包含许多有用的数学函数。Intel’s Math Kernel Library 提供了许多高级数学函数,可以从www.intel.com获得(可参见12.7 用于向量的数学函数)。AMD 数学核心库 包含类似的功能,但优化较差。

注意,当在非Intel 处理器上运行时,Intel 函数库没有使用最好的指令集(有关如何克服这个限制,请参阅13.7 Intel 编译器中的 CPU分派)。

14.11 静态库 VS 动态库

函数库可以实现为静态链接库(.ilb, .a),或动态链接库,也称为共享对象(.dll, . so)。静态链接的机制是链接器从库文件中提取所需的函数并将它们复制到可执行文件中。只需要将可执行文件分发给最终用户。

动态链接的工作方式则不同。动态库中函数的链接在加载库或运行时解析。因此,当程序运行时,可执行文件和一个或多个动态库都被会加载到内存中。可执行文件和所有动态库都需要分发给最终用户。

静态链接相对于动态链接的优点是:

  1. 使用静态链接,应用程序只需要包含库中所需要的部分,而使用动态链接则需要将整个库(或至少库的大部分)加载到内存中,即使只需要库中的一个函数。
  2. 当使用静态链接时,所有代码都包含在一个可执行文件中。而使用动态链接使得程序启动时必须加载多个文件。
  3. 调用动态库中的函数要比调用在静态链接库中的函数花费更长的时间,因为它需要通过导入表中的指针进行额外的跳转,还可能需要在过程链接表(PLT )中进行查找。
  4. 当代码分布在多个动态库之中时,内存空间变得更加碎片化。动态库加载在可被内存页大小(4096)整除的圆形内存地址(round memory addresses)处。这将使所有动态库争用相同的高速缓存线路。这降低了代码缓存和数据缓存的效率。
  5. 动态库在某些系统中效率可能会较低,因为需要位置无关代码(参见下面的内容)。
  6. 如果使用动态链接,安装使用相同动态库的更新版本的第二个应用程序,可以改变第一个应用程序的行为,但是如果使用静态链接,则不能改变第一个应用程序的行为。

使用动态链接的优点是:

  1. 同时运行的多个应用程序可以共享相同的动态库,无需将库的多个实例加载到内存中。这适用于同时运行多个进程的服务器。实际上,只有代码节和只读数据节可以共享。任何可写数据部分,每个进程都需要一个单独的实例。
  2. 无需更新调用程序,动态链接库就可以更新到新的版本。
  3. 动态链接库可以被不支持静态链接的编程语言调用。
  4. 使用动态链库可以用于为已有程序制作插件来添加新的功能。

权衡每种方法的上述优点,显然静态链接更适合于速度关键型函数。许多函数库都有静态和动态版本。如果速度很重要,则建议使用静态版本。

有些系统允许函数调用的延迟绑定。延迟绑定的原则是,在加载程序时不解析链接函数的地址,而是等到第一次调用该函数时才解析。延迟绑定对于大型库非常有用,因为在大型库中,在单个会话中实际调用的函数很少。但是延迟绑定肯定会降低所调用函数的性能。当一个函数第一次被调用时,由于它需要加载动态链接器,会出现相当大的延迟。

延迟绑定造成的延迟会导致交互程序的可用性问题,因为单击菜单的响应时间变得不一致,有时长得令人无法接受。因此,延迟绑定应该只用于非常大的库。

无法预先确定加载动态库的内存地址,因为固定地址可能与另一个需要相同地址的动态库冲突。有两种常用的方法来处理这个问题:

  1. 重定位。如果需要,代码中的所有指针和地址都会被修改,以适应实际的加载地址。重定位由链接器和加载器完成。
  2. 位置无关代码。代码中的所有地址都是相对于当前位置的。

Windows 中,dll 使用重定位。链接器将dll 重新定位到特定的加载地址。如果这个地址不是空的,那么dll 将被加载程序重新定位(rebase )到另一个地址。在主可执行文件中调用dll 中的函数要经过导入表或指针。dll 中的变量可以通过 main 函数中的导入指针来访问(A variable in a DLL can be accessed from main through an imported pointer),但是很少使用这个特性。通过函数调用来交换数据或指向数据的指针更为常见。对dll 内数据的内部引用在 32 位模式下使用绝对引用,在 64位模式下使用相对引用。后者的效率略微高一点,因为相对引用在加载时不需要重新定位。

共享对象在类Unix 系统中默认使用位置无关代码。这比重定位的效率要低,尤其是在 32位模式下。下一章将描述这是如何工作的,并提出避免位置无关代码成本的方法。

14.12 位置无关代码

LinuxBSDMac 系统中的共享对象通常使用所谓的位置无关代码。“位置无关代码”的名称实际上比它所表达的含义更丰富。编译为位置无关的代码具有以下特性:

  1. 代码部分不包含需要重新定位的绝对地址,只包含自相对地址(self-relative addresses )。因此,代码段可以在任意内存地址加载,并在多个进程之间共享。
  2. 数据部分不会在多个进程之间共享,因为它通常包含可写数据。因此,数据部分可能包含需要重新定位的指针或地址。
  3. LinuxBSD 中,所有公共函数和公共数据都可以被覆盖。如果主可执行文件中的函数与共享对象中的函数具有相同的名称,那么不仅在主可执行文件调用时,而且在从共享对象调用时,主可执行文件中的版本都将是优先的。同样的,当主可执行文件中的全局变量具有与共享对象中的全局变量相同的名称时,即使是从共享对象访问,也将使用主可执行文件中的实例。这种所谓的符号插入是为了模拟静态库的行为。为了实现这个“覆盖”特性,共享对象有一个指向其函数的指针表,称为过程链接表(PLT )和一个指向其变量的指针表,称为全局偏移表(GOT )。所有对函数和公共变量的访问都要经过PLTGOT

允许在LinuxBSD 中重写公共函数和数据的符号插入 特性代价高昂,而且在大多数库中从不会使用。每当调用共享对象中的函数时,都需要在过程链接表中查找函数地址。当访问共享对象中的公共变量时,则需要先在全局偏移表中查找该变量的地址。即使访问同一个共享对象中访问函数或变量,也需要这些查找表。显然,所有这些表查找操作都会大大降低执行速度。更详细的讨论可以在 https://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位LinuxBSD 中,所有静态数据都使用较慢的GOT 查找过程,包括不需要“覆盖”特性的本地数据。这包括静态变量、浮点常量、字符串常量和初始化过的数组。我无法解释为什么不必要的时候使用这种延迟很高的流程。

显然,避免繁重的位置无关代码和表查找的最佳方法是使用静态链接,如前一节(14.11 静态库 VS 动态库)所述。在无法避免动态链接的情况下,有多种方法可以避免位置无关代码的时间消耗特性。这些解决方法依赖于系统,如下所述。

32 位 Linux 中的共享对象

根据Gnu 编译器手册,共享对象通常都是使用 -fpic 选项编译的。该选项使代码段是位置无关的,为所有函数生成PLT,为所有公共和静态数据生成GOT

不使用 -fpic 选项也可以编译共享对象。这样我们就可以摆脱了上面提到的所有问题。代码将运行得更快,因为只需要一个步骤,我们就可以访问内部变量和内部函数,而不是通过前面介绍的复杂的地址计算和表查找机制。在没有 -fpic 选项的情况下编译的共享对象要快得多,除非是一个非常大的共享对象,而其中大多数函数都不会被调用。在 32位Linux 中不使用 -fpic 编译的缺点是加载器将有更多的引用需要重新定位,但是这些地址计算只需要执行一次,而在每次访问时必须执行运行时地址计算。在不使用 -fpic 选项的情况下编译代码部分时,每个进程都需要一个实例,因为代码部分中的重新定位对每个进程来说是不同的。显然,我们失去了覆盖公共符号的能力,但无论如何很少需要使用这个特性。

为了可以移植到 64位模式,你最好避免全局变量或者隐藏它们,解释如下。

64 位 Linux 中的共享对象

在 64位模式下,计算自相对地址的过程要简单得多,因为 64位指令集支持数据的相对寻址。在 64位模式下,由于默认使用相对地址,对特殊的位置无关代码需求更少。然而,我们仍然希望摆脱对本地引用的GOTPLT 查找。

在 64位模式下,如果我们不使用-fpic 选项编译共享对象,我们会遇到其它的问题。编译有时会使用 32位的绝对地址(主要是静态数组)。这在主可执行文件中是没有问题的,因为它肯定是在低于 2GB的地址的地方加载的,但对于共享对象则不是这样的,共享对象通常加载在 32 位(signed)地址无法表示的较高地址。在这种情况下,链接器会产生一条错误信息。最佳的解决方案是使用-fpie 选项代替-fpic 选项来进行编译。这将在代码部分生成相对地址,但对于内部引用它不会使用GOTPLT。因此,它将比用 -fpic 选项编译时运行得更快,并且对于 32位的情况,它不会有上面提到的缺点。在 32位模式下,-fpie 选项的作用没有那么大,因为它仍然使用GOT*。

另一种方法是使用-mcmodel=large 选项编译,但这将对所有内容使用 64位地址,这是非常低效的,而且它将在代码部分产生重定位,因此不能被共享。

使用 -fpie 选项时,在 64位共享对象中,不可以有公共变量,因为当链接器看到一个公共变量的相对引用时,它会产生一个错误消息,因为它期望这个公共变量有一个GOT 入口。你可以通过避免使用任何公共变量来避免该错误。所有全局变量(即定义在任何函数外部的变量)都应该使用声明 static_attribute__((visibility ("hidden")) 来隐藏。

Gnu 编译器 5.1 及以后版本有一个选项:-fno-semantic-interposition,可以使它能够避免使用 PLTGOT,但仅限于同一文件中的引用。通过使用内联汇编代码为变量提供两个名称,一个全局名称和一个本地名称,并为本地引用使用本地名称,可以得到相同的效果。

尽管有这些技巧,当使用多个模块(源文件)生成共享对象时,并且存在一个模块调用另一个模块时,你可能仍然会得到错误消息:“ “relocation R_X86_64_PC32 against symbol `functionname’ can not be used when making a shared object; recompile with -fPIC”。我至今没有找到该问题的解决方法。

BSD 中的共享变量
BSD 中的共享对象与 Linux 中的工作方式相同。

32位 Mac OS X

32位Mac OS X 的编译器默认情况下使位置无关代码和延迟绑定,即使不使用共享对象。目前在 32位Mac 代码中用于计算自相对地址的方法使用了一种不幸的方法,它会导致错误地预测返回地址,从而延迟执行(有关返回预测的解释,请参阅手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。

只要在编译器中关闭与位置无关代码的标志,就可以显著加速不属于共享对象的所有代码。因此,请记住,在为 32位Mac OS X 编译时,总是要指定编译器选项 -fno-pic,除非你正在创建一个共享对象。

使用选项-fno-pic 编译共享对象,并使用选项 -read_only_relocs suppress 链接共享对象时,可以不使用位置无关代码。

内部引用不会使用GOTPLT

64位 Mac OS X

代码部分始终与位置无关,因为这是这里使用的内存模型的最有效的解决方案。编译器选项-fno-pic 显然没有效果。

内部引用不会使用GOTPLT

Mac OS X 中,不需要采取特别的措施来加速 64位共享对象。

14.13 系统编程

设备驱动程序、中断服务路由、系统核心和高优先级线程是速度特别关键的地方。在系统代码或高优先级线程中非常耗时的函数可能会阻塞其他所有内容的执行。

系统代码必须遵守寄存器使用的某些规则,如手册5中的“Calling conventions for different C++ compilers and operating systems”中 “内核代码中的寄存器用法”一章所述。因此,你只能使用针对系统代码的编译器和函数库。系统代码应该使用 CC++汇编语言 编写。

在系统代码中节约资源的使用是非常重要的。动态内存分配特别有风险,因为它涉及在不方便的时候激活非常耗时的垃圾收集器的风险。队列应该实现为固定大小的循环缓冲区,而不是链表。不要使用STL容器。见9.6 动态内存分配

15 元编程

元编程意味着编写生成代码的代码。例如,在解释脚本语言中,通常可以编写一段生成字符串的代码段,然后将该字符串解释为代码。

如果计算的所有输入在编译时都可用,元编程在编译语言(如C++)中非常有用,可以在编译时期而不是运行时期做一些计算。(当然,在所有事情都在运行时发生的解释语言中,则没有这样的优势)。

C++ 中,可以考虑使用以下技术进行元编程:

  1. 预处理指令。例如使用 #if 代替 if。这是一个移除无效代码的有用方法,但是,由于预处理器先于编译器,并且只理解最简单的表达式和运算符,所以它所能做的工作受到了严重的限制。
  2. 编写一个C++ 程序,生成另一个C++ 程序(或它的一部分)。在某些情况下,这可能很有用,例如生成最终程序中作为静态数组的数学函数表。当然,这需要编译第一个程序的输出。
  3. 优化编译器可能会在编译时尽可能多地执行操作。例如,所有好的编译器都会将 int x = 2 * 5 化简位 int x = 10;
  4. 模板在编译时实例化。在编译模板实例之前,将其参数替换为它们的实际值。这就是为什么使用模板实际上没有成本的原因(见7.30 模板)。使用模板元编程可以表达任何算法,但是这种方法非常复杂和笨拙,稍后你就会看到。

下面的例子解释了当指数是编译时已知的整数时,如何使用元编程来加速幂函数的计算。

1
2
3
4
5
6
// Example 15.1a. Calculate x to the power of 10

double xpow10(double x)
{
return pow(x,10);
}

在一般情况下,pow 函数使用对数,但在上面这种情况下,它将识别到 10是整数,因此结果可以只使用乘法计算。当指数为正整数时,在 pow 函数中使用以下算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Example 15.1b. Calculate integer power using loop

double ipow (double x, unsigned int n)
{
double y = 1.0; // used for multiplication
while (n != 0)
{
// loop for each bit in nn
if (n & 1)
y *= x; // multiply if bit = 1
x *= x; // square x
n >>= 1; // get next bit of n
}
return y; // return y = pow(x,n)
}
double xpow10(double x)
{
return ipow(x,10); // ipow faster than pow
}

当我们展开循环并重新组织时,例 15.1b 中使用的方法将更容易理解:

1
2
3
4
5
6
7
8
9
10
// Example 15.1c. Calculate integer power, loop unrolled

double xpow10(double x)
{
double x2 = x *x; // x^2
double x4 = x2*x2; // x^4
double x8 = x4*x4; // x^8
double x10 = x8*x2; // x^10
return x10; // return x^10
}

正如我们所看到的,只需要四次乘法就可以计算出 pow(x,10)。那怎么才能将 例 15.1b 转换到 例 15.1c呢?我们利用了在编译时已知 n 的事实,消除了只依赖于 n 的所有内容,包括 while 循环、if 语句和所有整数计算。例 15.1c中的代码比 例 15.1b 更快,在这种情况下,它可能也更小。

例 15.1b例 15.1c 的转换是由我手动完成的,但是如果我们想生成一段代码,使它可以用于编译时已知的常量 n,那么我们需要元编程。我测试过的所有编译器都不能自动将 例 15.1a 转换为 例 15.1c,只有 Gnu 编译器 才能将 例 15.1b 转换为 例 15.1c。我们只能希望将来的编译器能够自动进行这样的优化,但只要不能做到,我们就可能需要元编程。

下一个示例显示使用模板元编程来实现计算。如果你不懂也不要惊慌。我给出这个示例只是为了说明模板元编程是多么复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Example 15.1d. Integer power using template metaprogramming

// Template for pow(x,N) where N is a positive integer constant.
// General case, N is not a power of 2:
template <bool IsPowerOf2, int N>
class powN
{
public:
static double p(double x) {
// Remove right-most 1-bit in binary representation of N:
#define N1 (N & (N-1))
return powN<(N1&(N1-1))==0,N1>::p(x) * powN<true,N-N1>::p(x);
#undef N1
}
};

// Partial template specialization for N a power of 2
template <int N>
class powN<true,N>
{
public:
static double p(double x)
{
return powN<true,N/2>::p(x) * powN<true,N/2>::p(x);
}
};

// Full template specialization for N = 1. This ends the recursion
template<>
class powN<true,1>
{
public:
static double p(double x)
{
return x;
}
};

// Full template specialization for N = 0
// This is used only for avoiding infinite loop if powN is
// erroneously called with IsPowerOf2 = false where it should be true.
template<>
class powN<true,0>
{
public:
static double p(double x)
{
return 1.0;
}
};

// Function template for x to the power of N
template <int N>
static inline double IntegerPower (double x)
{
// (N & N-1)==0 if N is a power of 2
return powN<(N & N-1)==0,N>::p(x);
}

// Use template to get x to the power of 10
double xpow10(double x)
{
return IntegerPower<10>(x);
}

如果你想知道这是怎么回事,请看下面的解释。如果你不确定是否需要,可以跳过下面的解释。

C++ 模板元编程中,循环被实现为递归模板。powN 模板正在调用自己,以便模拟 例 15.1b 中的 while 循环。分支是通过(部分)模板特化实现的, 这就是对 例 15.1b中的 if 分支的实现。递归必须始终以非递归模板特化结束,而不是在模板中包含分支。

powN 模板是类模板而不是函数模板,因为只允许对类进行部分模板特化。将 N 分解成二进制表示的各个位是非常需要技巧的的。我使用的技巧是 N1 = N&(N-1) 给得到 N 的去掉最右边的 1 位的值。如果 N 是 2 的幂,那么 N&(N-1) 为 0。常量 N1 可以用其他方法定义,而不是只能使用宏定义,但是这里使用的方法是我尝试过的所有编译器中唯一全部适用的方法。

MicrosoftIntelGnu 编译器实际上按照预期地将 例15.1d 化简到 例 15.1c,而BorlandDigital Mars 编译器产生的代码不太理想,因为它们无法消除公共子表达式。

为什么模板元编程如此复杂?因为C++ 的模板特性从来不是为该目的设计。这只是碰巧可行。模板元编程非常复杂,我认为使用它是不明智的。复杂的代码本身就是一个风险,而且验证、调试和维护这些代码的成本非常高,因此很少有理由在获得相对较小的性能收益时使用它。

然而在某些情况下,模板元编程是确保在编译时完成某些计算的唯一方法。(可以在我的 vector class library 中找到例子)。

D 语言 允许编译时 if 语句(称为 static if),但不没有编译时循环编译时生成标识符名称。我们只能希望这样的功能在将来能够实现。如果C++ 的未来版本应该会允许 编译时 If编译时 while 循环,那么将例 15.1b转换为元编程将非常简单。MASM 汇编语言 具有完整的元编程特性,包括通过字符串函数来定义函数名和变量名的能力。在手册2“Optimizing subroutines in assembly language”的“宏循环”一节中,提供了一个类似于例 15.1b例 15.1d的使用汇编语言的元编程实现。

当我们在等待更好的元编程工具出现时,我们可以选择那些最擅长在任何可能的情况下自动进行等价化简的编译器。使用自动将 例 15.1a 简化到例 15.1c的编译器当然是最简单和最可靠的解决方案。(在我的测试中,Intel 编译器例 15.1a简化为内联的例 15.1bGnu 编译器 可以将例 15.1b简化为例 15.1c,但是没有一个编译器能将例 15.1a简化为例 15.1c)。

16 测试速度

测试程序的速度是优化工作的重要组成部分。你必须检查你的修改是否真的提高了速度。

有多种可用的分析器,它们对于查找热点和测量程序的总体性能非常有用。然而,分析器并不总是准确的,而且当程序花费大部分时间等待用户输入或读取磁盘文件时,可能很难准确地测量你需要的是什么。有关分析的讨论请参见3.2 使用分析器查找热点(hot spots

当确定了热点之后,隔离热点并仅对代码的这一部分进行测量可能是有帮助的。这可以通过使用所谓的时间戳计数器来获得CPU 时钟的分辨率来实现。这是一个计数器,用来测量CPU 启动以来的时钟脉冲数。时钟周期的长度是时钟频率的倒数,如3.1 一个时钟周期是多长?所述。如果你在执行一段关键代码之前和之后读取时间戳计数器的值,那么你可以得到确切的时间消耗,即两个时钟计数之间的差值。

使用例 16.1中列出的函数 ReadTSC 可以获得时间戳计数器的值。此代码仅适用于支持指令集函数的编译器。或者,你可以使用www.agner.org/optimize/testp.zip中的头文件 timingtest.h,或者从www.agner.org/optimize/asmlib.zip获得 ReadTSC 作为库函数来使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Example 16.1

#include <intrin.h> // Or #include <ia32intrin.h> etc.
long long ReadTSC()
{
// Returns time stamp counter
int dummy[4]; // For unused returns
volatile int DontSkip; // Volatile to prevent optimizing
long long clock; // Time
__cpuid(dummy, 0); // Serialize
DontSkip = dummy[0]; // Prevent optimizing away cpuid
clock = __rdtsc(); // Read time
return clock;
}

你可以使用此函数来测量执行关键代码前后的时钟计数。测试设置可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Example 16.2

#include <stdio.h>
#include <asmlib.h> // Use ReadTSC() from library asmlib..
// or from example 16.1
void CriticalFunction(); // This is the function we want to measure
...
const int NumberOfTests = 10; // Number of times to test
int i; long long time1;
long long timediff[NumberOfTests]; // Time difference for each test
for (i = 0; i < NumberOfTests; i++)
{
// Repeat NumberOfTests times
time1 = ReadTSC(); // Time before test
CriticalFunction(); // Critical function to test
timediff[i] = ReadTSC() - time1; // (time after) - (time before)
}
printf("\nResults:"); // Print heading
for (i = 0; i < NumberOfTests; i++)
{
// Loop to print out results
printf("\n%2i %10I64i", i, timediff[i]);
}

例 16.2中的代码调用关键函数十次,并将每次运行的时间消耗存储在一个数组中。然后在测试循环之后输出这些值。以这种方式测量的时间包括调用 ReadTSC 函数所需的时间。你可以从计数中减去这个值。这个值可以简单地通过移除例 16.2中的 CriticalFunction 函数的调用来测量。

测量的时间按以下方式解释。第一次调用地计数通常高于随后的数。这是当代码和数据没有被缓存时执行 CriticalFunction 函数所需要的时间。随后的计数给出当代码和数据被经可能缓存好时所需的执行时间 。第一个计数和随后的计数分别表示“最坏情况”和“最佳情况”的值。这两个值中哪一个最接近真实情况取决于最终程序中对 CriticalFunction 函数的调用一次还是多次,以及对 CriticalFunction 调用之间是否有其他代码使用缓存。如果你的优化工作集中在CPU 效率上,那么它是“最好的情况”就很重要,你应该看看某个修改是否有利可图。另一方面,如果你的优化工作集中于按顺序排列数据以提高缓存效率上,然后你还可以查看“最坏情况”下的计数。在任何情况下,典型应用程序中,用户可能经过的时间延迟应该时这么计算的:时钟计数 时钟周期 调用 CriticalFunction 函数的次数。

有时候,你测量的时钟计数比正常情况下要高得多。当在 CriticalFunction 函数执行期间发生任务切换时,就会发生这种情况。你无法在受保护的操作系统中避免这种情况,但是你可以通过在测试前增加线程优先级并在测试后将优先级设置为正常来减少这个问题的发生。

时钟计数经常波动,测试结果的可重复性可能不是很好。这是因为现代CPU 可以根据工作负载动态地改变时钟频率。工作负荷大时时钟频率增大,工作负荷小时时钟频率减小,以节约电能。有多种方法可以获得可重复的时间测量值:

  1. 通过在测试代码之前给CPU 一些繁重的工作来预热CPU
  2. 禁用BIOS 设置中的省电选项。
  3. Intel CPU 上:使用内核时钟周期计数器(见下面内容)。

16.1 使用性能监视器计数器

许多CPU 都有一个内置的测试特性,称为性能监视计数器。性能监视器计数器是CPU 中的一个计数器,可以设置它来计数某些事件,比如执行的机器指令数量、缓存丢失、分支错误预测等。这些计数器对于研究性能问题非常有用。性能监视计数器是特定于CPU 的,每个CPU 模型都有自己的一组性能监视参数。

CPU 厂商会提供适合他们CPU 的分析工具。英特尔的分析器叫做 VTuneAMD 的分析器叫做 CodeAnalyst。这些分析器对于识别代码中的热点非常有用。

在我自己的研究中,为了使用性能监视器计数器,我开发了一个测试工具。我的测试工具同时支持IntelAMDVIA 处理器,可以从www.agner.org/optimize/testp.zip获得。这个工具不是分析器。它不是用于寻找热点的,而是用于在确定了热点之后研究代码段。

我的测试工具可以以两种方式使用。第一种方法是将要测试的代码插入测试程序本身并重新编译它。我使用它来测试单个汇编指令或小段代码。第二种方法是在运行要优化的程序之前设置性能监视器计数器,并在要测试的代码段之前和之后读取程序内部的性能计数器。你可以使用与上面例 16.2相同的原则,但是读取一个或多个性能监视器计数器,替换(除了)时间戳计数器。测试工具可以在所有CPU 内核中设置并启用一个或多个性能监视器计数器,并保持启用它们(每个CPU 内核中有一组计数器)。计数器会一直开着,直到你关掉它们,或者直到电脑重置或进入睡眠模式。有关详细信息,请参阅我的测试工具手册(www.agner.org/optimize/testp.zip)。

英特尔处理器中一个特别有用的性能监视器计数器称为核心时钟周期计数器。核心时钟周期计数器是按照CPU 核心运行时的实际时钟频率而不是外部时钟计算时钟周期的。这给出了一个几乎与时钟频率变化无关的度量。当测试一段代码的哪个版本最快时,核心时钟周期计数器非常有用,因为你可以避免时钟频率上升和下降的问题。

记得在程序中插入一个开关,以便在不测试时关闭计数器的读取。当性能监视器计数器被禁用时,试图读取它们将导致程序崩溃。

16.2 单元测试的陷阱

在软件开发中,通常单独测试每个函数或类。这种单元测试对于验证优化函数的功能是必要的,但是不幸的是,单元测试并没有提供关于函数性能在速度方面的全部信息。

假设你有两个不同版本的关键函数,你想找出哪个是最快的。测试这一点的典型方法是编写一个小型测试程序,使用一组合适的测试数据多次调用关键函数,并测量所需的时间。在此单元测试下性能最好的版本可能比其他版本占用更大的内存。在单元测试中看不到缓存不命中损失,因为测试程序使用的代码和数据内存总量可能小于缓存大小。

当在最终程序中插入关键函数时,代码缓存和数据缓存很可能是关键资源。现代CPU 的速度如此之快,以至于时钟周期花费在执行指令上不太可能像内存访问和缓存大小那样成为瓶颈。如果是这种情况,那么关键函数的最佳版本可能是单元测试中花费更长的时间但内存占用更少的版本。

例如,如果你想知道展开一个大循环是否有利的,那么你不能依赖单元测试而不考虑缓存效果。

通过为链接器使用“生成映射文件”选项,你可以查看链接映射或汇编代码列表来计算函数使用了多少内存。代码缓存使用和数据缓存使用都很重要。分支目标缓冲区也是一个关键的缓存。因此,还应该考虑函数中跳转、调用和分支的数量。

一个实际的性能测试不仅应该包含单个函数或热点,还应该包含包含关键函数和热点的最内层循环。应该使用一组真实的数据来进行测试,以便为分支错误预测获得可靠的结果。性能度量不应该包括程序中等待用户输入的任何部分。用于文件输入和输出的时间应该分开测量。

不幸的是,用单元测试来度量性能的谬论非常普遍。即使是一些最佳优化的函数库也会使用过多的循环展开,因此内存占用非常大。

16.3 最差条件测试

大多数性能测试都是在最佳条件下进行的。消除了所有干扰的影响,所有资源都是充足的,缓存条件是最优的。在最佳条件下测试是有用的,因为它提供了更可靠和可重复的结果。如果你想比较同一算法的两种不同实现的性能,那么你需要消除所有干扰影响,以便使测量尽可能准确和可重复。

然而,在某些情况下,在最坏的情况下测试性能更为相关。例如,如果你想确保对用户输入的响应时间永远不会超过可接受的范围,那么你应该在最坏情况下测试响应时间。

产生流媒体音频或视频的程序也应该在最坏的情况下进行测试,以确保它们始终保持预期的实时速度。输出中的延迟或故障是不可接受的。

在测试最坏情况下的性能时,以下每一种方法都可能是相关的:

  1. 第一次激活程序的某个特定部分时,由于代码的延迟加载、缓存不命中和分支预测错误,它可能比之后的速度慢。
  2. 测试整个软件包,包括所有运行时库和框架,而不是隔离单个函数。在软件包的不同部分之间切换,以增加程序代码的某些部分未被缓存或甚至被交换到磁盘的可能性。
  3. 依赖于网络资源和服务器的软件应该在流量较大的网络和被充分使用的服务器上进行测试,而不是专用的测试服务器。
  4. 使用包含大量数据的大型数据文件和数据库。
  5. 使用CPU 速度慢、RAM 不足、安装了大量无关软件、运行了大量后台进程、硬盘速度慢且碎片化的旧计算机。
  6. 使用不同品牌的 CPU、不同类型的显卡等进行测试。
  7. 使用杀毒程序,扫描所有的文件访问。
  8. 同时运行多个进程或线程。如果微处理器支持超线程,那么尝试在同一个处理器内核中运行两个线程。
  9. 尝试分配比现有内存更多的RAM,以便强制将内存交换到磁盘。
  10. 通过使最内层循环中使用的代码大小或数据大于缓存大小来触发缓存不命中。或者,你可以主动地使缓存失效。操作系统可能有一个用于此目的的函数,或者你可以使用指令集函数 _mm_clflush
  11. 使数据比正常情况更随机,从而引发分支错误预测。

17 在嵌入式系统中优化

在小型嵌入式应用程序中使用的微控制器比标准PC 拥有更少的计算资源。时钟频率可以低 100倍甚至 1000倍;而且 RAM内存的数量甚至可能比 PC少一百万倍。尽管如此,如果你避免使用大型图形框架、解释器、即时编译器、系统数据库以及通常用于大型系统的其他额外软件层和框架,则可以使软件在这样的小型设备上运行得相当快。

系统越小,选择一个占用较少资源的软件框架就越重要。在最小的设备上,甚至没有操作系统。

可以通过选择可以在 PC上交叉编译的编程语言来获得最佳的性能。任何要求在目标设备上编译或者解释的语言都会对资源产生极大的浪费。由于这些原因,首选的语言通常是 CC++。关键设备驱动程序可能需要汇编语言。

如果遵循下面的指导原则,C++ 只需要比C 多一点点的资源。你可以根据最适合所需程序结构的方式选择 CC++

节约内存的使用是很重要的。大数组应该在函数中声明,以便在函数返回时释放它们。或者,你可以将相同的数组重用于多个目的。

应该避免所有使用 new / deletemalloc / free的动态内存分配,因为管理内存堆的开销很大。堆管理器有一个垃圾收集器,它可能以不可预测的间隔消耗时间,这可能会干扰实时应用程序。

请记住,STL (标准模板库)和其他容器类库中的容器类使用 newdelete 来动态内存,而且常常过多地使用动态内存分配。除非你有足够的资源,否则绝对应该避免使用这些容器。例如,FIFO 队列应该被实现为一个固定大小的循环缓冲区,以避免动态内存分配。不要使用链表(请参阅9.7 容器类)。

字符串类的所有常见实现都使用动态内存分配。你应该避免这些,并以老式的 C风格使用字符数组处理字符串。注意,C风格的字符串函数不会检查数组是否溢出。程序员需要确保数组足够大,可以处理字符串(包括终止符 0),并在必要时进行溢出检查(参见9.8 字符串)。

C++ 中的虚函数比非虚函数占用更多的资源。尽可能避免使用虚函数。

较小的微处理器没有本地浮点执行单元。此类处理器上的任何浮点运算都需要一个很大的浮点库,这非常耗时。因此,应该避免使用浮点表达式。例如,a = b * 2.5 可能改为a = b * 5 / 2(注意中间表达式 b * 5 可能会溢出)。只要程序中有一个浮点常量,就会加载整个浮点库。如果你想用两个小数来计算一个数字,那么你应该把它乘以100,这样它就可以表示为一个整数。

整数变量可以是 8位、16位或 32位(很少有 64位)。如果需要,可以使用不会导致特定应用程序溢出的最小整数大小来节省RAM空间。整数大小没有跨平台标准化。有关每种整数类型的大小,请参阅编译器文档。

中断服务程序和设备驱动程序尤其重要,因为它们可以阻止其他所有东西的执行。这通常属于系统编程领域,但在没有操作系统的应用程序中,这是应用程序程序员的工作。当没有操作系统时,程序员更容易忘记系统代码是很关键的,因此系统代码没有与应用程序代码分离。中断服务程序应该做尽可能少的工作。通常,它应该将接收到的数据的一个单元保存在静态缓冲区中,或者从缓冲区发送数据。它永远不应该响应某个命令,或者执行它所服务的特定事件之外的其他输入/输出。中断接收到的命令最好以较低的优先级响应,通常在主程序的消息循环中响应。有关系统代码的进一步讨论,请参见14.13 系统编程

在本章中,我描述了一些对资源有限的小型设备特别重要的考虑。本手册其余部分的大部分建议也与小型设备有关,但由于小型微控制器的设计会存在一些差异:

  1. 较小的微控制器没有分支预测(见7.12 分支和 switch语句)。软件中不需要考虑分支预测。
  2. 较小的微控制器没有缓存(见9.2 缓存结构)。不需要组织数据来优化缓存。
  3. 较小的微控制器没有无序执行。没有必要打破依赖链(见3.15 依赖链)。

18 编译器选项一览

Table 18.1. Command line options relevant to optimization

MS compiler Windows Gnu compiler Linux Intel compiler Windows Intel compiler Linux
Optimize for speed /O2 or /Ox -O3 or -Ofast /O3 -O3
Interprocedural optimization /Og
Whole program optimization /GL —combine -fwhole-program /Qipo -ipo
No exception handling /EHs
No stack frame /Oy -fomit- frame-pointer -fomit- frame-pointer
No runtime type identification (RTTI) /GR– -fno-rtti /GR- -fno-rtti
Assume no pointer aliasing /Oa -fno-alias
Non-strict floating point -ffast-math /fp:fast /fp:fast=2 -fp-model fast, -fp-model fast=2
Simple member pointers /vms
Fastcall functions /Gr
Function level linking (remove unreferen-ced functions) /Gy -ffunction- sections /Gy -ffunction- sections
SSE instruction set (128 bit float vectors) /arch:SSE -msse /arch:SSE -msse
SSE2 instruction set (128 vectors of integer or double) /arch:SSE2 -msse2 /arch:SSE2 -msse2
SSE3 instruction set -msse3 /arch:SSE3 -msse3
Suppl. SSE3 instr. set -mssse3 /arch:SSSE2 -mssse3
SSE4.1 instr. set -msse4.1 /arch:SSE4.1 -msse4.1
AVX instr. set /arch:AVX -mAVX /arch:AVX -mAVX
Automatic CPU dispatch /QaxSSE3, etc. (Intel CPU only) -axSSE3, etc. (Intel CPU only)
Automatic vectorization -O3 -fno- trapping-math -fno- math-errno-mveclibabi
Automatic paralleli- zation by multiplethreads /Qparallel -parallel
Parallelization by OpenMP directives /openmp -fopenmp /Qopenmp -openmp
32 bit code -m32
64 bit code -m64
Static linking /MT -static /MT -static
(multithreaded)
Generate assembly listing /FA -S - masm=intel /FA -S
Generate map file /Fm
Generate optimization report /Qopt-report -opt-report

Table 18.2. Compiler directives and keywords relevant to optimization

MS compiler Windows Gnu compiler Linux Intel compiler Windows Intel compiler Linux
Align by 16 __declspec(align(16)) __attribute((aligned(16))) __declspec(align(16)) __attribute((aligned(16)))
Assume pointer isaligned #pragma vector aligned #pragma vector aligned
Assume pointer notaliased #pragma optimize("a", on) __restrict __restrict __declspec(noalias) __restrict #pragma ivdep __restrict #pragma ivdep
Assume function ispure __attribute((const)) __attribute((const))
Assume function does notthrow exceptions throw() throw() throw() throw()
Assume function called only fromsame module static static static static
Assume member functioncalled only fromsame module __attribute__((visibility ("internal"))) __attribute__((visibility ("internal")))
Vectorize #pragma vector always #pragma vector always
Optimize function #pragma optimize(...)
Fastcall function __fastcall __attribute((fastcall)) __fastcall
Noncached write #pragma vector nontemporal #pragma vector nontemporal

Table 18.3. Predefined macros

MS compiler Windows Gnu compiler Linux<\br> Intel compiler Windows Intel compiler Linux
Compiler identification MSC_VER and not __INTEL_COMPILER __GNUC__ and not _INTEL_COMPILER __INTEL_COMPILER __INTEL_COMPILER
16 bit not _WIN32 n.a. n.a. n.a.
platform
32 bitplatform not _WIN64 not _WIN64
64 bit platform _WIN64 _LP64 _WIN64 _LP64
Windows platform _WIN32 _WIN32
Linux platform n.a. __unix__ __linux__ __unix__ __linux__
x86 platform _M_IX86 _M_IX86
x86-64 platform M_IX86 and _WIN64 _M_X64 _M_X64

19 文献

Agner Fog 的其它手册
本手册是五本系列中手册的第一本。有关手册列表,请参见1 简介

关于代码优化的文献
Intel:”Intel 64 and IA-32 Architectures Optimization Reference Manual”。developer.intel.com。许多用于在英特尔 CPU 优化 C++和汇编代码的建议。定期更新版本;

AMD:”Software Optimization Guide for AMD Family 15h Processors”。 www.amd.com。许多用于在 AMD CPU 优化 C++和汇编代码的建议。定期更新版本;

Intel:”Intel® C++ Compiler Documentation”。包含在英特尔 C++编译器中,可以从 www.intel.com 上找到。使用 Intel C++ 编译器优化特性的手册

维基百科关于编译器优化的文章。en.wikipedia.org/wiki/Compiler_optimization

ISO/IEC TR 18015, “Technical Report on C++ Performance”。 www.openstd.org/jtc1/sc22/wg21/docs/TR18015.pdf

OpenMP。www.openmp.org。用于并行处理的OpenMP指令的文档。

Scott Meyers: “Effective C++”. Addison-Wesley. Third Edition, 2005; and “More Effective C++”. Addison-Wesley, 1996。这两本书包含了许多关于高级c++编程的技巧,如何避免难以发现的错误,以及一些提高性能的技巧。

Stefan Goedecker and Adolfy Hoisie: “Performance Optimization of Numerically Intensive Codes”, SIAM 2001。关于 C++ 和 Fortran 代码优化的高级书籍。主要关注具有大数据集的数学应用。涵盖个人电脑,工作站和科学向量处理器。

Henry S. Warren, Jr.: “Hacker’s Delight”. Addison-Wesley, 2003。包含许多位操作技巧。

Michael Abrash: “Zen of code optimization”, Coriolis group books 1994。大部分已经过时了。

Rick Booth: “Inner Loops: A sourcebook for fast 32位 software development”, AddisonWesley 1997。大部分已经过时了。

微处理器文档
Intel: “IA-32 Intel Architecture Software Developer’s Manual”, Volume 1, 2A, 2B, and 3A and3B. developer.intel.com.

AMD: “AMD64 Architecture Programmer’s Manual”, Volume 1 - 5. www.amd.com

网络论坛
一些互联网论坛和新闻组包含关于代码优化的有用讨论。参见www.agner.org/optimization和新闻组 comp.lang.asm.x86 的一些链接。

20 版权声明

这五本手册的版权归 Agner Fog 所有。不允许公开分发和镜像。出于教育目的,允许向有限的受众进行非公开发行。这些手册中的代码示例可以无限制地使用。知识共享许可CC-BY-SA将在我死后自动生效。参见 https://creativecommons.org/licenses/by-sa/4.0/legalcode

模板

面对创建一个 eBPF 项目,您是否对如何开始搭建环境以及选择编程语言感到困惑?别担心,我们为您准备了一系列 GitHub 模板,以便您快速启动一个全新的eBPF项目。只需在GitHub上点击 Use this template 按钮,即可开始使用。

搭建BPF程序运行环境

下载内核源码

下载的内核版本应与你系统的版本一致,查看当前内核版本 uname -r

然后在源码镜像站点(http://ftp.sjtu.edu.cn/sites/ftp.kernel.org/pub/linux/kernel)下载对应版本的内核源码

也可以通过Ubuntu apt仓库下载。Ubuntu官方自己维护了每个操作系统版本的背后的Linux内核代码,可以通过以下两种apt命令方式获取相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 第一种方式
# 先搜索
> apt-cache search linux-source
linux-source - Linux kernel source with Ubuntu patches
linux-source-4.15.0 - Linux kernel source for version 4.15.0 with Ubuntu patches
linux-source-4.18.0 - Linux kernel source for version 4.18.0 with Ubuntu patches
linux-source-5.0.0 - Linux kernel source for version 5.0.0 with Ubuntu patches
linux-source-5.3.0 - Linux kernel source for version 5.3.0 with Ubuntu patches
# 再安装
> apt install linux-source-4.15.0

# 第二种方式
> apt-get source linux
Reading package lists... Done
NOTICE: 'linux' packaging is maintained in the 'Git' version control system at:
git://git.launchpad.net/~ubuntu-kernel/ubuntu/+source/linux/+git/bionic
Please use:
git clone git://git.launchpad.net/~ubuntu-kernel/ubuntu/+source/linux/+git/bionic
to retrieve the latest (possibly unreleased) updates to the package.
Need to get 167 MB of source archives.
Get:2 https://mirrors.ustc.edu.cn/ubuntu bionic-updates/main linux 4.15.0-99.100 (tar) [158 MB]
...

# 以上两种方式,内核源代码均下载至/usr/src/目录下

安装依赖项

1
apt install libncurses5-dev flex bison libelf-dev binutils-dev libssl-dev

安装Clang和LLVM

然后使用以下两条命令分别安装 clang 和 llvm

1
2
apt install clang`
`apt install llvm

配置内核

在源码根目录下使用make defconfig生成.config<c/ode>文件

解决modpost: not found错误

因为直接make M=samples/bpf时,会报错缺少modules的错误。修复modpost的错误,以下两种解决方案二选一

1
2
make modules_prepare
make script

关联内核头文件

1
make headers_install

编译内核程序样例

在源码根目录下执行make M=samples/bpf,

此时进入linux-source-4.15.0/smaples/bpf中,会看到生成了BPF字节码文件*_kern.o和用户态的可执行文件

img

你可以运行几个试试,例如sockex1

img

使用BPF C编写hello world程序

先了解一下原理吧

img

BPF程序经过Clang/LLVM编译成BPF字节码,然后通过BPF系统调用的方式加载进内核,然后交给BPF虚拟机来执行,也是JIT的方式动态转成机器码

内核有很多hook点,我们在写BPF程序时也会做事件源配置。当hook点上的事件发生时,就会执行我们的BPF程序。

我们还可以在BPF程序中创建一个Map,把我们想拿到的数据保存在Map中,然后用户态程序就可以拿到。

总之,就是我们可以通过BPF程序拿到内核的一些数据

hello world程序

img

进入samples/bpf目录,可以利用自带的Makefile编译,

编写hello_kern.c:

1
2
3
4
5
6
7
8
9
10
11
12
#include <linux/bpf.h>
#include "bpf_helpers.h"
#define SEC(NAME) __attribute__((section(NAME), used))

SEC("tracepoint/syscalls/sys_enter_execve")
int bpf_prog(void *ctx){
char msg[] = "Hello World\n";
bpf_trace_printk(msg, sizeof(msg));
return 0;
}

char _license[] SEC("license") = "GPL";

这个程序的作用就是当发生系统调用(sys_enter_execve)时在终端输出”Hello World”,其实bpf_trace_printk只是将msg写到一个管道文件中

编写hello_user.c:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include "bpf_load.h"

int main(int argc, char **argv){
if(load_bpf_file("hello_kern.o")!=0){
printf("The kernel didn't load BPF program\n");
return -1;
}

read_trace_pipe();
return 0;
}

这个程序的作用是将包含BPF的文件hello_kern.o通过系统调用的方式加载进内核,read_trace_pipe()读取管道文件并打印到终端

修改Makefile

模仿原有的,有四处需要修改:

1
2
3
4
5
6
7
8
9
10
# List of programs to build
hostprogs-y += hello

# Libbpf dependencies
hello-objs := bpf_load.o $(LIBBPF) hello_user.o

# Tell kbuild to always build the programs
always += hello_kern.o

HOSTLOADLIBES_hello += -lelf

编译

可以返回源码根目录用 make M=samples/bpfmake samples/bpf/ 编译

或者直接在当前目录(samples/bpf) 执行make 编译

可以查看编译后的结果,生成了hello可执行文件

img

运行

img

进一步

进一步学习BPF程序是如何转换成字节码的

BPF程序中的节(section)

img

img

SEC宏会将宏里面的内容(kprobe/sys_write)作为节的名字放到elf文件中,也就是目标文件,可以用readelf工具查看

还用宏生成了一个名字为license的section

BPF程序中的字节码(bytecode)

可以用objdump工具查看

img

可见是将我们的bpf程序编译到elf文件的某个节中,右边黄框内就是常说的bpf字节码,对应左边灰色内容

接下来讲一下,bpf程序是如何转成字节码的

BPF内核辅助函数调用转换为BPF字节码的过程

img

我们用到的BPF内核辅助函数是bpf_trace_printk

bpg_prog是我们的elf函数名字,分析下call 6是怎么得到的?

img

BPF_FUNC_map_lookup_elem(BPF_FUNC_trace_printk类似)是在bpf.h中定义的,只不过是宏的形式,我们将其展开:

img

可见BPF_FUNC_trace_printk的相对位置是6,

一般BPF内核辅助函数转汇编是这样的:

img

就是BPF_call id,id就是bpf_func_id中的id;

进一步就是BPF_EMIT_CALL(func name)

例如,在内核中的某一处代码,调用bpf_map_lookup_elem,在BPF指令集编程中,就是使用BPF_EMIT_CALL来调用的

img

不难想象,我们调用bpf_trace_printk也是采用同样的调用方式

BPF_EMIT_CALL(func name)是如何转化成字节码的呢?

img

_bpf_call_base啥也没做,直接返回0,可见只是需要其地址,而差值就是在enum中的位置

进一步分析

img img

所以,call 6对应的字节码就是85 00 00 00 06 00 00 00

我们还可以进一步查看JIT前后字节码的变化:

img

首先执行objdump -s hello_kern.o得到JIT之前的字节码:

img

在一直运行hello

进入linux-source-4.15.0/tools/bpf/bpftool目录,make,生成bpftool工具,

通过 ./bpftool prog show 显示加载了哪些BPF程序:

img

可见我们的hello程序对应的id为86,钩子类型为tracepoint

再使用./bpftool prog dump xlated id 86 opcodes 即可查看JIT之后的字节码:

img

对比起来看:

img

其他的没变,可以看到这个变化,这是因为JIT前call使用的id,JIT后成了调用函数到这个指令的距离

BPF程序到BPF字节码的编译过程:Clang与LLVM

img

img

LLVM支持很多后端,通过命令llc -version

img

bpf target有三种,不指定就根据系统的大小端法

有两种方式编译BPF程序:

img

gcc缺少BPF backend,幸运的是clang支持BPF. 之前的Makefile就是使用clang将hello_kern.c编译成hello_kern.o

右边的图表示一步到位和分布编译的结果是一样的,而且是之前用Makefile编译的也一样

img

分步编译是生成中间IR文件,默认是.ll格式

教程

Hello World,基本框架和开发流程

eBPF开发环境准备与基本开发流程

在开始编写eBPF程序之前,我们需要准备一个合适的开发环境,并了解eBPF程序的基本开发流程。本部分将详细介绍这些内容。

安装必要的软件和工具

要开发eBPF程序,您需要安装以下软件和工具:

  • Linux 内核:由于eBPF是内核技术,因此您需要具备较新版本的Linux内核(推荐4.8及以上版本),以支持eBPF功能。
  • LLVM 和 Clang:这些工具用于编译eBPF程序。安装最新版本的LLVM和Clang可以确保您获得最佳的eBPF支持。

eBPF 程序主要由两部分构成:内核态部分和用户态部分。内核态部分包含 eBPF 程序的实际逻辑,用户态部分负责加载、运行和监控内核态程序。当您选择了合适的开发框架后,如 BCC(BPF Compiler Collection)、libbpf、cilium/ebpf或eunomia-bpf等,您可以开始进行用户态和内核态程序的开发。以 BCC 工具为例,我们将介绍 eBPF 程序的基本开发流程:

当您选择了合适的开发框架后,如BCC(BPF Compiler Collection)、libbpf、cilium/ebpf或eunomia-bpf等,您可以开始进行用户态和内核态程序的开发。以BCC工具为例,我们将介绍eBPF程序的基本开发流程:

  • 安装BCC工具:根据您的Linux发行版,按照BCC官方文档的指南安装BCC工具和相关依赖。
    编写eBPF程序(C语言):使用C语言编写一个简单的eBPF程序,例如Hello World程序。该程序可以在内核空间执行并完成特定任务,如统计网络数据包数量。
  • 编写用户态程序(Python或C等):使用Python、C等语言编写用户态程序,用于加载、运行eBPF程序以及与之交互。在这个程序中,您需要使用BCC提供的API来加载和操作内核态的eBPF程序。
  • 编译eBPF程序:使用BCC工具,将C语言编写的eBPF程序编译成内核可以执行的字节码。BCC会在运行时动态从源码编译eBPF程序。
  • 加载并运行eBPF程序:在用户态程序中,使用BCC提供的API加载编译好的eBPF程序到内核空间,然后运行该程序。
  • 与eBPF程序交互:用户态程序通过BCC提供的API与eBPF程序交互,实现数据收集、分析和展示等功能。例如,您可以使用BCC API读取eBPF程序中的map数据,以获取网络数据包统计信息。
  • 卸载eBPF程序:当不再需要eBPF程序时,用户态程序应使用BCC API将其从内核空间卸载。
  • 调试与优化:使用 bpftool 等工具进行eBPF程序的调试和优化,提高程序性能和稳定性。

通过以上流程,您可以使用BCC工具开发、编译、运行和调试eBPF程序。请注意,其他框架(如libbpf、cilium/ebpf和eunomia-bpf)的开发流程大致相似但略有不同,因此在选择框架时,请参考相应的官方文档和示例。

通过这个过程,你可以开发出一个能够在内核中运行的 eBPF 程序。eunomia-bpf 是一个开源的 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。它基于 libbpf 的 CO-RE 轻量级开发框架,支持通过用户态 WASM 虚拟机控制 eBPF 程序的加载和执行,并将预编译的 eBPF 程序打包为通用的 JSON 或 WASM 模块进行分发。我们会使用 eunomia-bpf 进行演示。

下载安装 eunomia-bpf 开发工具

可以通过以下步骤下载和安装 eunomia-bpf:

下载 ecli 工具,用于运行 eBPF 程序:

1
2
3
$ wget https://aka.pw/bpf-ecli -O ecli && chmod +x ./ecli
$ ./ecli -h
Usage: ecli [--help] [--version] [--json] [--no-cache] url-and-args

下载编译器工具链,用于将 eBPF 内核代码编译为 config 文件或 WASM 模块:

1
2
3
4
5
$ wget https://github.com/eunomia-bpf/eunomia-bpf/releases/latest/download/ecc && chmod +x ./ecc
$ ./ecc -h
eunomia-bpf compiler
Usage: ecc [OPTIONS] <SOURCE_PATH> [EXPORT_EVENT_HEADER]
....

也可以使用 docker 镜像进行编译:

1
2
3
4
$ docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest # 使用 docker 进行编译。`pwd` 应该包含 *.bpf.c 文件和 *.h 文件。
export PATH=PATH:~/.eunomia/bin
Compiling bpf object...
Packing ebpf object and config into /src/package.json...

Hello World - minimal eBPF program

我们会先从一个简单的 eBPF 程序开始,它会在内核中打印一条消息。我们会使用 eunomia-bpf 的编译器工具链将其编译为 bpf 字节码文件,然后使用 ecli 工具加载并运行该程序。作为示例,我们可以暂时省略用户态程序的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
#define BPF_NO_GLOBAL_DATA
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

typedef unsigned int u32;
typedef int pid_t;
const pid_t pid_filter = 0;

char LICENSE[] SEC("license") = "Dual BSD/GPL";

SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{
pid_t pid = bpf_get_current_pid_tgid() >> 32;
if (pid_filter && pid != pid_filter)
return 0;
bpf_printk("BPF triggered from PID %d.\n", pid);
return 0;
}

这段程序通过定义一个 handle_tp 函数并使用 SEC 宏把它附加到sys_enter_write tracepoint(即在进入 write 系统调用时执行)。该函数通过使用bpf_get_current_pid_tgidbpf_printk函数获取调用 write 系统调用的进程 ID,并在内核日志中打印出来。

bpf_trace_printk(): 一种将信息输出到trace_pipe(/sys/kernel/debug/tracing/trace_pipe)简单机制。 在一些简单用例中这样使用没有问题, but它也有一些限制:最多3 参数; 第一个参数必须是%s(即字符串);同时trace_pipe在内核中全局共享,其他并行使用trace_pipe的程序有可能会将 trace_pipe 的输出扰乱。 一个更好的方式是通过BPF_PERF_OUTPUT(), 稍后将会讲到。

void *ctx:ctx本来是具体类型的参数, 但是由于我们这里没有使用这个参数,因此就将其写成void *类型。
return 0;必须这样,返回0。

要编译和运行这段程序,可以使用 ecc 工具和 ecli 命令。首先在 Ubuntu/Debian 上,执行以下命令:

1
sudo apt install clang llvm

ecc 编译程序:

1
2
3
$ ./ecc minimal.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...

或使用 docker 镜像进行编译:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

然后使用 ecli 运行编译后的程序:

1
2
$ sudo ecli run package.json
Runing eBPF program...

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
$ sudo cat /sys/kernel/debug/tracing/trace_pipe | grep "BPF triggered sys_enter_write"
<...>-3840345 [010] d... 3220701.101143: bpf_trace_printk: write system call from PID 3840345.
<...>-3840345 [010] d... 3220701.101143: bpf_trace_printk: write system call from PID 3840345.

按 Ctrl+C 停止 ecli 进程之后,可以看到对应的输出也停止。

eBPF 程序的基本框架

如上所述, eBPF 程序的基本框架包括:

  • 包含头文件:需要包含 等头文件。
  • 定义许可证:需要定义许可证,通常使用 “Dual BSD/GPL”。
  • 定义 BPF 函数:需要定义一个 BPF 函数,例如其名称为 handle_tp,其参数为void *ctx,返回值为 int。通常用 C 语言编写。
  • 使用 BPF 助手函数:在例如 BPF 函数中,可以使用 BPF 助手函数bpf_get_current_pid_tgid()bpf_printk()
  • 返回值

tracepoints

跟踪点(tracepoints)是内核静态插桩技术,跟踪点在技术上只是放置在内核源代码中的跟踪函数,实际上就是在源码中插入的一些带有控制条件的探测点,这些探测点允许事后再添加处理函数。比如在内核中,最常见的静态跟踪方法就是 printk,即输出日志。又比如:在系统调用、调度程序事件、文件系统操作和磁盘 I/O 的开始和结束时都有跟踪点。 于 2009 年在 Linux 2.6.32 版本中首次提供。跟踪点是一种稳定的 API,数量有限。

总结

eBPF 程序的开发和使用流程可以概括为如下几个步骤:

  • 定义 eBPF 程序的接口和类型:这包括定义 eBPF 程序的接口函数,定义和实现 eBPF 内核映射(maps)和共享内存(perf events),以及定义和使用 eBPF 内核帮助函数(helpers)。
  • 编写 eBPF 程序的代码:这包括编写 eBPF 程序的主要逻辑,实现 eBPF 内核映射的读写操作,以及使用 eBPF 内核帮助函数。
  • 编译 eBPF 程序:这包括使用 eBPF 编译器(例如 clang)将 eBPF 程序代码编译为 eBPF 字节码,并生成可执行的 eBPF 内核模块。ecc 本质上也是调用 clang 编译器来编译 eBPF 程序。
  • 加载 eBPF 程序到内核:这包括将编译好的 eBPF 内核模块加载到 Linux 内核中,并将 eBPF 程序附加到指定的内核事件上。
  • 使用 eBPF 程序:这包括监测 eBPF 程序的运行情况,并使用 eBPF 内核映射和共享内存进行数据交换和共享。
  • 在实际开发中,还可能需要进行其他的步骤,例如配置编译和加载参数,管理 eBPF 内核模块和内核映射,以及使用其他高级功能等。
  • 需要注意的是,BPF 程序的执行是在内核空间进行的,因此需要使用特殊的工具和技术来编写、编译和调试 BPF 程序。eunomia-bpf 是一个开源的 BPF 编译器和工具包,它可以帮助开发者快速和简单地编写和运行 BPF 程序。

kprobes 技术背景

开发人员在内核或者模块的调试过程中,往往会需要要知道其中的一些函数有无被调用、何时被调用、执行是否正确以及函数的入参和返回值是什么等等。比较简单的做法是在内核代码对应的函数中添加日志打印信息,但这种方式往往需要重新编译内核或模块,重新启动设备之类的,操作较为复杂甚至可能会破坏原有的代码执行过程。

而利用kprobes技术,用户可以定义自己的回调函数,然后在内核或者模块中几乎所有的函数中动态的插入探测点,当内核执行流程执行到指定的探测函数时,会调用该回调函数,用户即可收集所需的信息了,同时内核最后还会回到原本的正常执行流程。如果用户已经收集足够的信息,不再需要继续探测,则同样可以动态地移除探测点。因此kprobes技术具有对内核执行流程影响小和操作方便的优点。

kprobes技术包括的3种探测手段分别时kprobe、jprobe和kretprobe。首先kprobe是最基本的探测方式,是实现后两种的基础,它可以在任意的位置放置探测点(就连函数内部的某条指令处也可以),它提供了探测点的调用前、调用后和内存访问出错3种回调方式,分别是pre_handler、post_handler和fault_handler,其中pre_handler函数将在被探测指令被执行前回调,post_handler会在被探测指令执行完毕后回调(注意不是被探测函数),fault_handler会在内存访问出错时被调用;jprobe基于kprobe实现,它用于获取被探测函数的入参值;最后kretprobe从名字中就可以看出其用途了,它同样基于kprobe实现,用于获取被探测函数的返回值。

kprobes的技术原理并不仅仅包含存软件的实现方案,它也需要硬件架构提供支持。其中涉及硬件架构相关的是CPU的异常处理和单步调试技术,前者用于让程序的执行流程陷入到用户注册的回调函数中去,而后者则用于单步执行被探测点指令,因此并不是所有的架构均支持,目前kprobes技术已经支持多种架构,包括i386、x86_64、ppc64、ia64、sparc64、arm、ppc和mips(有些架构实现可能并不完全,具体可参考内核的Documentation/kprobes.txt)。

kprobes的特点与使用限制:

  1. kprobes允许在同一个被被探测位置注册多个kprobe,但是目前jprobe却不可以;同时也不允许以其他的jprobe回调函数和kprobe的post_handler回调函数作为被探测点。
  2. 一般情况下,可以探测内核中的任何函数,包括中断处理函数。不过在kernel/kprobes.carch/*/kernel/kprobes.c程序中用于实现kprobes自身的函数是不允许被探测的,另外还有do_page_faultnotifier_call_chain
  3. 如果以一个内联函数为探测点,则kprobes可能无法保证对该函数的所有实例都注册探测点。由于gcc可能会自动将某些函数优化为内联函数,因此可能无法达到用户预期的探测效果;
  4. 一个探测点的回调函数可能会修改被探测函数运行的上下文,例如通过修改内核的数据结构或者保存与struct pt_regs结构体中的触发探测器之前寄存器信息。因此kprobes可以被用来安装bug修复代码或者注入故障测试代码;
  5. kprobes会避免在处理探测点函数时再次调用另一个探测点的回调函数,例如在printk()函数上注册了探测点,则在它的回调函数中可能再次调用printk函数,此时将不再触发printk探测点的回调,仅仅时增加了kprobe结构体中nmissed字段的数值;
  6. 在kprobes的注册和注销过程中不会使用mutex锁和动态的申请内存;
  7. kprobes回调函数的运行期间是关闭内核抢占的,同时也可能在关闭中断的情况下执行,具体要视CPU架构而定。因此不论在何种情况下,在回调函数中不要调用会放弃CPU的函数(如信号量、mutex锁等);
  8. kretprobe通过替换返回地址为预定义的trampoline的地址来实现,因此栈回溯和gcc内嵌函数__builtin_return_address()调用将返回trampoline的地址而不是真正的被探测函数的返回地址;
  9. 如果一个函数的调用次数和返回次数不相等,则在类似这样的函数上注册kretprobe将可能不会达到预期的效果,例如do_exit()函数会存在问题,而do_execve()函数和do_fork()函数不会;
  10. 如果当在进入和退出一个函数时,CPU运行在非当前任务所有的栈上,那么往该函数上注册kretprobe可能会导致不可预料的后果,因此,kprobes不支持在X86_64的结构下为__switch_to()函数注册kretprobe,将直接返回-EINVAL。

kprobe 示例

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
pid_t pid;
const char *filename;

pid = bpf_get_current_pid_tgid() >> 32;
filename = BPF_CORE_READ(name, name);
bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
return 0;
}

SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret)
{
pid_t pid;

pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("KPROBE EXIT: pid = %d, ret = %ld\n", pid, ret);
return 0;
}

这段代码是一个简单的 eBPF 程序,用于监测和捕获在 Linux 内核中执行的 unlink 系统调用。unlink 系统调用的功能是删除一个文件,这个 eBPF 程序通过使用 kprobe(内核探针)在do_unlinkat函数的入口和退出处放置钩子,实现对该系统调用的跟踪。

首先,我们导入必要的头文件,如 vmlinux.h,bpf_helpers.h,bpf_tracing.h 和 bpf_core_read.h。接着,我们定义许可证,以允许程序在内核中运行。

1
2
3
4
5
6
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

接下来,我们定义一个名为BPF_KPROBE(do_unlinkat)的 kprobe,当进入 do_unlinkat 函数时,它会被触发。该函数接受两个参数:dfd(文件描述符)和 name(文件名结构体指针)。在这个 kprobe 中,我们获取当前进程的 PID(进程标识符),然后读取文件名。最后,我们使用 bpf_printk 函数在内核日志中打印 PID 和文件名。

1
2
3
4
5
6
7
8
9
10
11
SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
pid_t pid;
const char *filename;

pid = bpf_get_current_pid_tgid() >> 32;
filename = BPF_CORE_READ(name, name);
bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
return 0;
}

接下来,我们定义一个名为BPF_KRETPROBE(do_unlinkat_exit)的 kretprobe,当从 do_unlinkat 函数退出时,它会被触发。这个 kretprobe 的目的是捕获函数的返回值(ret)。我们再次获取当前进程的 PID,并使用 bpf_printk 函数在内核日志中打印 PID 和返回值。

1
2
3
4
5
6
7
8
9
SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret)
{
pid_t pid;

pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("KPROBE EXIT: pid = %d, ret = %ld\n", pid, ret);
return 0;
}

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。

要编译这个程序,请使用 ecc 工具:

1
2
3
$ ecc kprobe-link.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...

然后运行:

1
sudo ecli run package.json

在另外一个窗口中:

1
2
3
4
touch test1
rm test1
touch test2
rm test2

在 /sys/kernel/debug/tracing/trace_pipe 文件中,应该能看到类似下面的 kprobe 演示输出:

1
2
3
4
5
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
rm-9346 [005] d..3 4710.951696: bpf_trace_printk: KPROBE ENTRY pid = 9346, filename = test1
rm-9346 [005] d..4 4710.951819: bpf_trace_printk: KPROBE EXIT: ret = 0
rm-9346 [005] d..3 4710.951852: bpf_trace_printk: KPROBE ENTRY pid = 9346, filename = test2
rm-9346 [005] d..4 4710.951895: bpf_trace_printk: KPROBE EXIT: ret = 0

Fentry

fentry(function entry)和fexit(function exit)是eBPF(扩展的伯克利包过滤器)中的两种探针类型,用于在Linux内核函数的入口和退出处进行跟踪。它们允许开发者在内核函数执行的特定阶段收集信息、修改参数或观察返回值。这种跟踪和监控功能在性能分析、故障排查和安全分析等场景中非常有用。

与 kprobes 相比,fentry 和 fexit 程序有更高的性能和可用性。在这个例子中,我们可以直接访问函数的指针参数,就像在普通的 C 代码中一样,而不需要使用各种读取帮助程序。fexit 和 kretprobe 程序最大的区别在于,fexit 程序可以访问函数的输入参数和返回值,而 kretprobe 只能访问返回值。从 5.5 内核开始,fentry 和 fexit 对 eBPF 程序可用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

SEC("fentry/do_unlinkat")
int BPF_PROG(do_unlinkat, int dfd, struct filename *name)
{
pid_t pid;

pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("fentry: pid = %d, filename = %s\n", pid, name->name);
return 0;
}

SEC("fexit/do_unlinkat")
int BPF_PROG(do_unlinkat_exit, int dfd, struct filename *name, long ret)
{
pid_t pid;

pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("fexit: pid = %d, filename = %s, ret = %ld\n", pid, name->name, ret);
return 0;
}

这段程序是用C语言编写的eBPF(扩展的伯克利包过滤器)程序,它使用BPF的fentry和fexit探针来跟踪Linux内核函数do_unlinkat。在这个教程中,我们将以这段程序作为示例,让您学会如何在eBPF中使用fentry监测捕获unlink系统调用。

程序包含以下部分:

  • 包含头文件:包括vmlinux.h(用于访问内核数据结构)、bpf/bpf_helpers.h(包含eBPF帮助函数)、bpf/bpf_tracing.h(用于eBPF跟踪相关功能)。
  • 定义许可证:这里定义了一个名为LICENSE的字符数组,包含许可证信息”Dual BSD/GPL”。
  • 定义fentry探针:我们定义了一个名为BPF_PROG(do_unlinkat)的fentry探针,该探针在do_unlinkat函数的入口处被触发。这个探针获取当前进程的PID(进程ID)并将其与文件名一起打印到内核日志。
  • 定义fexit探针:我们还定义了一个名为BPF_PROG(do_unlinkat_exit)的fexit探针,该探针在do_unlinkat函数的退出处被触发。与fentry探针类似,这个探针也会获取当前进程的PID并将其与文件名和返回值一起打印到内核日志。

通过这个示例,您可以学习如何在eBPF中使用fentry和fexit探针来监控和捕获内核函数调用,例如在本教程中的unlink系统调用。

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。我们使用 eunomia-bpf 编译运行这个例子。

编译运行上述代码:

1
2
3
4
5
$ ecc fentry-link.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...
$ sudo ecli run package.json
Runing eBPF program...

在另外一个窗口中:

1
2
3
4
touch test_file
rm test_file
touch test_file2
rm test_file2

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
4
5
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
rm-9290 [004] d..2 4637.798698: bpf_trace_printk: fentry: pid = 9290, filename = test_file
rm-9290 [004] d..2 4637.798843: bpf_trace_printk: fexit: pid = 9290, filename = test_file, ret = 0
rm-9290 [004] d..2 4637.798698: bpf_trace_printk: fentry: pid = 9290, filename = test_file2
rm-9290 [004] d..2 4637.798843: bpf_trace_printk: fexit: pid = 9290, filename = test_file2, ret = 0

总结

这段程序是一个 eBPF 程序,通过使用 fentry 和 fexit 捕获 do_unlinkat 和 do_unlinkat_exit 函数,并通过使用 bpf_get_current_pid_tgid 和 bpf_printk 函数获取调用 do_unlinkat 的进程 ID、文件名和返回值,并在内核日志中打印出来。

在 eBPF 中捕获进程打开文件的系统调用集合,使用全局变量过滤进程 pid

eBPF(Extended Berkeley Packet Filter)是一种内核执行环境,它可以让用户在内核中运行一些安全的、高效的程序。它通常用于网络过滤、性能分析、安全监控等场景。eBPF 之所以强大,是因为它能够在内核运行时捕获和修改数据包或者系统调用,从而实现对操作系统行为的监控和调整。

本文是 eBPF 入门开发实践教程的第四篇,主要介绍如何捕获进程打开文件的系统调用集合,并使用全局变量在 eBPF 中过滤进程 pid。

在 Linux 系统中,进程与文件之间的交互是通过系统调用来实现的。系统调用是用户态程序与内核态程序之间的接口,它们允许用户态程序请求内核执行特定操作。在本教程中,我们关注的是 sys_openat 系统调用,它是用于打开文件的。

当进程打开一个文件时,它会向内核发出sys_openat系统调用,并传递相关参数(例如文件路径、打开模式等)。内核会处理这个请求,并返回一个文件描述符(file descriptor),这个描述符将在后续的文件操作中用作引用。通过捕获 sys_openat 系统调用,我们可以了解进程在什么时候以及如何打开文件。

在 eBPF 中捕获进程打开文件的系统调用集合

首先,我们需要编写一段 eBPF 程序来捕获进程打开文件的系统调用,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>

/// @description "Process ID to trace"
const volatile int pid_target = 0;

SEC("tracepoint/syscalls/sys_enter_openat")
int tracepoint__syscalls__sys_enter_openat(struct trace_event_raw_sys_enter* ctx)
{
u64 id = bpf_get_current_pid_tgid();
u32 pid = id;

if (pid_target && pid_target != pid)
return false;
// Use bpf_printk to print the process information
bpf_printk("Process ID: %d enter sys openat\n", pid);
return 0;
}

/// "Trace open family syscalls."
char LICENSE[] SEC("license") = "GPL";

这段 eBPF 程序实现了:

  • 引入头文件: 包含了内核数据结构的定义, 包含了 eBPF 程序所需的辅助函数。
  • 定义全局变量 pid_target,用于过滤指定进程 ID。这里设为 0 表示捕获所有进程的 sys_openat 调用。
  • 使用 SEC 宏定义一个 eBPF 程序,关联到 tracepoint “tracepoint/syscalls/sys_enter_openat”。这个 tracepoint 会在进程发起 sys_openat 系统调用时触发。
  • 实现 eBPF 程序tracepoint__syscalls__sys_enter_openat,它接收一个类型为struct trace_event_raw_sys_enter的参数 ctx。这个结构体包含了关于系统调用的信息。
  • 使用bpf_get_current_pid_tgid()函数获取当前进程的 PID 和 TGID(线程组 ID)。由于我们只关心 PID,所以将其赋值给 u32 类型的变量 pid。
  • 检查pid_target变量是否与当前进程的 pid 相等。如果 pid_target 不为 0 且与当前进程的 pid 不相等,则返回 false,不对该进程的sys_openat调用进行捕获。
  • 使用bpf_printk()函数打印捕获到的进程 ID 和 sys_openat 调用的相关信息。这些信息将在用户空间通过 BPF 工具查看。
  • 将程序许可证设置为 “GPL”,这是运行 eBPF 程序的必要条件。

这个 eBPF 程序可以通过 libbpf 或 eunomia-bpf 等工具加载到内核并执行。它将捕获指定进程(或所有进程)的 sys_openat 系统调用,并在用户空间输出相关信息。

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。我们使用 eunomia-bpf 编译运行这个例子。

编译运行上述代码:

1
2
3
4
5
$ ecc opensnoop.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...
$ sudo ecli run package.json
Runing eBPF program...

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
<...>-3840345 [010] d... 3220701.101179: bpf_trace_printk: Process ID: 3840345 enter sys openat
<...>-3840345 [010] d... 3220702.158000: bpf_trace_printk: Process ID: 3840345 enter sys openat

此时,我们已经能够捕获进程打开文件的系统调用了。

使用全局变量在 eBPF 中过滤进程 pid

全局变量在 eBPF 程序中充当一种数据共享机制,它们允许用户态程序与 eBPF 程序之间进行数据交互。这在过滤特定条件或修改 eBPF 程序行为时非常有用。这种设计使得用户态程序能够在运行时动态地控制 eBPF 程序的行为。

在我们的例子中,全局变量 pid_target 用于过滤进程 PID。用户态程序可以设置此变量的值,以便在 eBPF 程序中只捕获与指定 PID 相关的 sys_openat 系统调用。

使用全局变量的原理是,全局变量在 eBPF 程序的数据段(data section)中定义并存储。当 eBPF 程序加载到内核并执行时,这些全局变量会保持在内核中,可以通过 BPF 系统调用进行访问。用户态程序可以使用 BPF 系统调用中的某些特性,如bpf_obj_get_info_by_fdbpf_obj_get_info,获取 eBPF 对象的信息,包括全局变量的位置和值。

可以通过执行 ecli -h 命令来查看 opensnoop 的帮助信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ecli package.json -h
Usage: opensnoop_bpf [--help] [--version] [--verbose] [--pid_target VAR]

Trace open family syscalls.

Optional arguments:
-h, --help shows help message and exits
-v, --version prints version information and exits
--verbose prints libbpf debug information
--pid_target Process ID to trace

Built with eunomia-bpf framework.
See https://github.com/eunomia-bpf/eunomia-bpf for more information.

可以通过--pid_target参数来指定要捕获的进程的 pid,例如:

1
2
$ sudo ./ecli run package.json  --pid_target 618
Runing eBPF program...

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
<...>-3840345 [010] d... 3220701.101179: bpf_trace_printk: Process ID: 618 enter sys openat
<...>-3840345 [010] d... 3220702.158000: bpf_trace_printk: Process ID: 618 enter sys openat

总结

本文介绍了如何使用 eBPF 程序来捕获进程打开文件的系统调用。在 eBPF 程序中,我们可以通过定义tracepoint__syscalls__sys_enter_opentracepoint__syscalls__sys_enter_openat函数并使用 SEC 宏把它们附加到sys_enter_opensys_enter_openat两个 tracepoint 来捕获进程打开文件的系统调用。我们可以使用bpf_get_current_pid_tgid函数获取调用 open 或 openat 系统调用的进程 ID,并使用 bpf_printk 函数在内核日志中打印出来。在 eBPF 程序中,我们还可以通过定义一个全局变量 pid_target 来指定要捕获的进程的 pid,从而过滤输出,只输出指定的进程的信息。

通过学习本教程,您应该对如何在 eBPF 中捕获和过滤特定进程的系统调用有了更深入的了解。这种方法在系统监控、性能分析和安全审计等场景中具有广泛的应用。

在 eBPF 中使用 uprobe 捕获 bash 的 readline 函数调用

本文是 eBPF 入门开发实践教程的第五篇,主要介绍如何使用 uprobe 捕获 bash 的 readline 函数调用。

什么是uprobe

uprobe是一种用户空间探针,uprobe探针允许在用户空间程序中动态插桩,插桩位置包括:函数入口、特定偏移处,以及函数返回处。当我们定义uprobe时,内核会在附加的指令上创建快速断点指令(x86机器上为int3指令),当程序执行到该指令时,内核将触发事件,程序陷入到内核态,并以回调函数的方式调用探针函数,执行完探针函数再返回到用户态继续执行后序的指令。

uprobe基于文件,当一个二进制文件中的一个函数被跟踪时,所有使用到这个文件的进程都会被插桩,包括那些尚未启动的进程,这样就可以在全系统范围内跟踪系统调用。

uprobe适用于在用户态去解析一些内核态探针无法解析的流量,例如http2流量(报文header被编码,内核无法解码),https流量(加密流量,内核无法解密)。

使用 uprobe 捕获 bash 的 readline 函数调用

uprobe 是一种用于捕获用户空间函数调用的 eBPF 的探针,我们可以通过它来捕获用户空间程序调用的系统函数。

例如,我们可以使用 uprobe 来捕获 bash 的 readline 函数调用,从而获取用户在 bash 中输入的命令行。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

#define TASK_COMM_LEN 16
#define MAX_LINE_SIZE 80

/* Format of u[ret]probe section definition supporting auto-attach:
* u[ret]probe/binary:function[+offset]
*
* binary can be an absolute/relative path or a filename; the latter is resolved to a
* full binary path via bpf_program__attach_uprobe_opts.
*
* Specifying uprobe+ ensures we carry out strict matching; either "uprobe" must be
* specified (and auto-attach is not possible) or the above format is specified for
* auto-attach.
*/
SEC("uretprobe//bin/bash:readline")
int BPF_KRETPROBE(printret, const void *ret)
{
char str[MAX_LINE_SIZE];
char comm[TASK_COMM_LEN];
u32 pid;

if (!ret)
return 0;

bpf_get_current_comm(&comm, sizeof(comm));

pid = bpf_get_current_pid_tgid() >> 32;
bpf_probe_read_user_str(str, sizeof(str), ret);

bpf_printk("PID %d (%s) read: %s ", pid, comm, str);

return 0;
};

char LICENSE[] SEC("license") = "GPL";

这段代码的作用是在 bash 的 readline 函数返回时执行指定的BPF_KRETPROBE函数,即 printret 函数。

在 printret 函数中,我们首先获取了调用 readline 函数的进程的进程名称和进程 ID,然后通过 bpf_probe_read_user_str函数读取了用户输入的命令行字符串,最后通过 bpf_printk 函数打印出进程 ID、进程名称和输入的命令行字符串。

除此之外,我们还需要通过 SEC 宏来定义 uprobe 探针,并使用 BPF_KRETPROBE 宏来定义探针函数。

在 SEC 宏中,我们需要指定 uprobe 的类型、要捕获的二进制文件的路径和要捕获的函数名称。例如,上面的代码中的 SEC 宏的定义如下:

1
SEC("uprobe//bin/bash:readline")

这表示我们要捕获的是 /bin/bash 二进制文件中的 readline 函数。

接下来,我们需要使用 BPF_KRETPROBE 宏来定义探针函数,例如:

1
BPF_KRETPROBE(printret, const void *ret)

这里的 printret 是探针函数的名称,const void *ret是探针函数的参数,它代表被捕获的函数的返回值。

然后,我们使用了bpf_get_current_comm函数获取当前任务的名称,并将其存储在 comm 数组中。

1
bpf_get_current_comm(&comm, sizeof(comm));

使用 bpf_get_current_pid_tgid 函数获取当前进程的 PID,并将其存储在 pid 变量中。

1
pid = bpf_get_current_pid_tgid() >> 32;

使用bpf_probe_read_user_str函数从用户空间读取 readline 函数的返回值,并将其存储在 str 数组中。

1
bpf_probe_read_user_str(str, sizeof(str), ret);

最后使用 bpf_printk 函数输出 PID、任务名称和用户输入的字符串。

1
bpf_printk("PID %d (%s) read: %s ", pid, comm, str);

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。我们使用 eunomia-bpf 编译运行这个例子。

编译运行上述代码:

1
2
3
4
5
$ ecc bashreadline.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...
$ sudo ecli run package.json
Runing eBPF program...

运行这段程序后,可以通过查看 /sys/kernel/debug/tracing/trace_pipe 文件来查看 eBPF 程序的输出:

1
2
3
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
bash-32969 [000] d..31 64001.375748: bpf_trace_printk: PID 32969 (bash) read: fff
bash-32969 [000] d..31 64002.056951: bpf_trace_printk: PID 32969 (bash) read: fff

可以看到,我们成功的捕获了 bash 的 readline 函数调用,并获取了用户在 bash 中输入的命令行。

总结

在上述代码中,我们使用了 SEC 宏来定义了一个 uprobe 探针,它指定了要捕获的用户空间程序 (bin/bash) 和要捕获的函数 (readline)。此外,我们还使用了 BPF_KRETPROBE 宏来定义了一个用于处理 readline 函数返回值的回调函数 (printret)。该函数可以获取到 readline 函数的返回值,并将其打印到内核日志中。通过这样的方式,我们就可以使用 eBPF 来捕获 bash 的 readline 函数调用,并获取用户在 bash 中输入的命令行。

捕获进程发送信号的系统调用集合,使用 hash map 保存状态

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具,它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

本文是 eBPF 入门开发实践教程的第六篇,主要介绍如何实现一个 eBPF 工具,捕获进程发送信号的系统调用集合,使用 hash map 保存状态。

sigsnoop
示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

#define MAX_ENTRIES 10240
#define TASK_COMM_LEN 16

struct event {
unsigned int pid;
unsigned int tpid;
int sig;
int ret;
char comm[TASK_COMM_LEN];
};

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, __u32);
__type(value, struct event);
} values SEC(".maps");


static int probe_entry(pid_t tpid, int sig)
{
struct event event = {};
__u64 pid_tgid;
__u32 tid;

pid_tgid = bpf_get_current_pid_tgid();
tid = (__u32)pid_tgid;
event.pid = pid_tgid >> 32;
event.tpid = tpid;
event.sig = sig;
bpf_get_current_comm(event.comm, sizeof(event.comm));
bpf_map_update_elem(&values, &tid, &event, BPF_ANY);
return 0;
}

static int probe_exit(void *ctx, int ret)
{
__u64 pid_tgid = bpf_get_current_pid_tgid();
__u32 tid = (__u32)pid_tgid;
struct event *eventp;

eventp = bpf_map_lookup_elem(&values, &tid);
if (!eventp)
return 0;

eventp->ret = ret;
bpf_printk("PID %d (%s) sent signal %d to PID %d, ret = %d",
eventp->pid, eventp->comm, eventp->sig, eventp->tpid, ret);

cleanup:
bpf_map_delete_elem(&values, &tid);
return 0;
}

SEC("tracepoint/syscalls/sys_enter_kill")
int kill_entry(struct trace_event_raw_sys_enter *ctx)
{
pid_t tpid = (pid_t)ctx->args[0];
int sig = (int)ctx->args[1];

return probe_entry(tpid, sig);
}

SEC("tracepoint/syscalls/sys_exit_kill")
int kill_exit(struct trace_event_raw_sys_exit *ctx)
{
return probe_exit(ctx, ctx->ret);
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

上面的代码定义了一个 eBPF 程序,用于捕获进程发送信号的系统调用,包括 kill、tkill 和 tgkill。它通过使用 tracepoint 来捕获系统调用的进入和退出事件,并在这些事件发生时执行指定的探针函数,例如 probe_entry 和 probe_exit。

在探针函数中,我们使用 bpf_map 存储捕获的事件信息,包括发送信号的进程 ID、接收信号的进程 ID、信号值和系统调用的返回值。在系统调用退出时,我们将获取存储在 bpf_map 中的事件信息,并使用 bpf_printk 打印进程 ID、进程名称、发送的信号和系统调用的返回值。

最后,我们还需要使用 SEC 宏来定义探针,并指定要捕获的系统调用的名称,以及要执行的探针函数。

我们使用 eunomia-bpf 编译运行这个例子。编译运行上述代码:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

或者

1
2
3
4
5
6
$ ecc sigsnoop.bpf.c
Compiling bpf object...
Generating export types...
Packing ebpf object and config into package.json...
$ sudo ecli run package.json
Runing eBPF program...

运行这段程序后,可以通过查看/sys/kernel/debug/tracing/trace_pipe文件来查看 eBPF 程序的输出:

1
2
3
4
5
6
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
node-3517 [003] d..31 82575.798191: bpf_trace_printk: PID 3517 (node) sent signal 0 to PID 3427, ret = 0
node-15194 [003] d..31 82575.849227: bpf_trace_printk: PID 15194 (node) sent signal 0 to PID 3427, ret = 0
node-30016 [003] d..31 82576.001361: bpf_trace_printk: PID 30016 (node) sent signal 0 to PID 3427, ret = 0
cpptools-srv-38617 [002] d..31 82576.461085: bpf_trace_printk: PID 38617 (cpptools-srv) sent signal 0 to PID 30496, ret = 0
node-30040 [002] d..31 82576.467720: bpf_trace_printk: PID 30016 (node) sent signal 0 to PID 3427, ret = 0

总结

本文主要介绍如何实现一个 eBPF 工具,捕获进程发送信号的系统调用集合,使用 hash map 保存状态。使用 hash map 需要定义一个结构体:

1
2
3
4
5
6
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, __u32);
__type(value, struct event);
} values SEC(".maps");

并使用一些对应的 API 进行访问,例如bpf_map_lookup_elembpf_map_update_elembpf_map_delete_elem等。

捕获进程执行/退出时间,通过 perf event array 向用户态打印输出

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具,它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

本文是 eBPF 入门开发实践教程的第七篇,主要介绍如何捕获 Linux 内核中进程执行的事件,并且通过 perf event array 向用户态命令行打印输出,不需要再通过查看 /sys/kernel/debug/tracing/trace_pipe 文件来查看 eBPF 程序的输出。通过 perf event array 向用户态发送信息之后,可以进行复杂的数据处理和分析。

perf buffer

eBPF 提供了两个环形缓冲区,可以用来将信息从 eBPF 程序传输到用户区控制器。第一个是perf环形缓冲区,,它至少从内核v4.15开始就存在了。第二个是后来引入的 BPF 环形缓冲区。本文只考虑perf环形缓冲区。

execsnoop

通过 perf event array 向用户态命令行打印输出,需要编写一个头文件,一个 C 源文件。示例代码如下:

头文件:execsnoop.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef __EXECSNOOP_H
#define __EXECSNOOP_H

#define TASK_COMM_LEN 16

struct event {
int pid;
int ppid;
int uid;
int retval;
bool is_exit;
char comm[TASK_COMM_LEN];
};

#endif /* __EXECSNOOP_H */

源文件:execsnoop.bpf.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include "execsnoop.h"

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_execve")
int tracepoint__syscalls__sys_enter_execve(struct trace_event_raw_sys_enter* ctx)
{
u64 id;
pid_t pid, tgid;
struct event event={0};
struct task_struct *task;

uid_t uid = (u32)bpf_get_current_uid_gid();
id = bpf_get_current_pid_tgid();
pid = (pid_t)id;
tgid = id >> 32;

event.pid = tgid;
event.uid = uid;
task = (struct task_struct*)bpf_get_current_task();
event.ppid = BPF_CORE_READ(task, real_parent, tgid);
bpf_get_current_comm(&event.comm, sizeof(event.comm));
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
return 0;
}

char LICENSE[] SEC("license") = "GPL";

这段代码定义了个 eBPF 程序,用于捕获进程执行 execve 系统调用的入口。

在入口程序中,我们首先获取了当前进程的进程 ID 和用户 ID,然后通过 bpf_get_current_task 函数获取了当前进程的 task_struct 结构体,并通过 bpf_probe_read_str 函数读取了进程名称。最后,我们通过 bpf_perf_event_output 函数将进程执行事件输出到 perf buffer。

使用这段代码,我们就可以捕获 Linux 内核中进程执行的事件, 并分析进程的执行情况。

使用容器编译:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

或者使用 ecc 编译:

1
ecc execsnoop.bpf.c execsnoop.h

运行

1
2
3
4
5
6
7
8
9
$ sudo ./ecli run package.json 
TIME PID PPID UID COMM
21:28:30 40747 3517 1000 node
21:28:30 40748 40747 1000 sh
21:28:30 40749 3517 1000 node
21:28:30 40750 40749 1000 sh
21:28:30 40751 3517 1000 node
21:28:30 40752 40751 1000 sh
21:28:30 40753 40752 1000 cpuUsage.sh

总结

本文介绍了如何捕获 Linux 内核中进程执行的事件,并且通过 perf event array 向用户态命令行打印输出,通过 perf event array 向用户态发送信息之后,可以进行复杂的数据处理和分析。在 libbpf 对应的内核态代码中,定义这样一个结构体和对应的头文件:

1
2
3
4
5
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");

就可以往用户态直接发送信息。

在 eBPF 中使用 exitsnoop 监控进程退出事件,使用 ring buffer 向用户态打印输出

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具。它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

本文是 eBPF 入门开发实践教程的第八篇,在 eBPF 中使用 exitsnoop 监控进程退出事件。

ring buffer

现在有一个新的 BPF 数据结构可用,eBPF 环形缓冲区(ring buffer)。它解决了 BPF perf buffer(当今从内核向用户空间发送数据的事实上的标准)的内存效率和事件重排问题,同时达到或超过了它的性能。它既提供了与 perf buffer 兼容以方便迁移,又有新的保留/提交API,具有更好的可用性。另外,合成和真实世界的基准测试表明,在几乎所有的情况下,所以考虑将其作为从BPF程序向用户空间发送数据的默认选择。

eBPF ringbuf vs eBPF perfbuf

只要 BPF 程序需要将收集到的数据发送到用户空间进行后处理和记录,它通常会使用 BPF perf buffer(perfbuf)来实现。Perfbuf 是每个CPU循环缓冲区的集合,它允许在内核和用户空间之间有效地交换数据。它在实践中效果很好,但由于其按CPU设计,它有两个主要的缺点,在实践中被证明是不方便的:内存的低效使用和事件的重新排序。

为了解决这些问题,从Linux 5.8开始,BPF提供了一个新的BPF数据结构(BPF map)。BPF环形缓冲区(ringbuf)。它是一个多生产者、单消费者(MPSC)队列,可以同时在多个CPU上安全共享。

BPF ringbuf 支持来自 BPF perfbuf 的熟悉的功能:

  • 变长的数据记录。
  • 能够通过内存映射区域有效地从用户空间读取数据,而不需要额外的内存拷贝和/或进入内核的系统调用。
  • 既支持epoll通知,又能以绝对最小的延迟进行忙环操作。

同时,BPF ringbuf解决了BPF perfbuf的以下问题:

  • 内存开销。
  • 数据排序。
  • 浪费的工作和额外的数据复制。

exitsnoop

本文是 eBPF 入门开发实践教程的第八篇,在 eBPF 中使用 exitsnoop 监控进程退出事件,并使用 ring buffer 向用户态打印输出。

使用 ring buffer 向用户态打印输出的步骤和 perf buffer 类似,首先需要定义一个头文件:

头文件:exitsnoop.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef __BOOTSTRAP_H
#define __BOOTSTRAP_H

#define TASK_COMM_LEN 16
#define MAX_FILENAME_LEN 127

struct event {
int pid;
int ppid;
unsigned exit_code;
unsigned long long duration_ns;
char comm[TASK_COMM_LEN];
};

#endif /* __BOOTSTRAP_H */

源文件:exitsnoop.bpf.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "exitsnoop.h"
char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");

SEC("tp/sched/sched_process_exit")
int handle_exit(struct trace_event_raw_sched_process_template* ctx)
{
struct task_struct *task;
struct event *e;
pid_t pid, tid;
u64 id, ts, *start_ts, duration_ns = 0;

/* get PID and TID of exiting thread/process */
id = bpf_get_current_pid_tgid();
pid = id >> 32;
tid = (u32)id;

/* ignore thread exits */
if (pid != tid)
return 0;

/* reserve sample from BPF ringbuf */
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

/* fill out the sample with data */
task = (struct task_struct *)bpf_get_current_task();

e->duration_ns = duration_ns;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
e->exit_code = (BPF_CORE_READ(task, exit_code) >> 8) & 0xff;
bpf_get_current_comm(&e->comm, sizeof(e->comm));

/* send data to user-space for post-processing */
bpf_ringbuf_submit(e, 0);
return 0;
}

这段代码展示了如何使用 exitsnoop 监控进程退出事件并使用 ring buffer 向用户态打印输出:

  1. 首先,我们引入所需的头文件和 exitsnoop.h。
  2. 定义一个名为 “LICENSE” 的全局变量,内容为 “Dual BSD/GPL”,这是 eBPF 程序的许可证要求。
  3. 定义一个名为rbBPF_MAP_TYPE_RINGBUF 类型的映射,它将用于将内核空间的数据传输到用户空间。指定 max_entries256 * 1024,代表 ring buffer 的最大容量。
  4. 定义一个名为 handle_exit 的 eBPF 程序,它将在进程退出事件触发时执行。传入一个名为 ctx 的 trace_event_raw_sched_process_template 结构体指针作为参数。
  5. 使用 bpf_get_current_pid_tgid() 函数获取当前任务的 PID 和 TID。对于主线程,PID 和 TID 相同;对于子线程,它们是不同的。我们只关心进程(主线程)的退出,因此在 PID 和 TID 不同时返回 0,忽略子线程退出事件。
  6. 使用 bpf_ringbuf_reserve 函数为事件结构体 e 在 ring buffer 中预留空间。如果预留失败,返回 0。
  7. 使用 bpf_get_current_task() 函数获取当前任务的 task_struct 结构指针。
  8. 将进程相关信息填充到预留的事件结构体 e 中,包括进程持续时间、PID、PPID、退出代码以及进程名称。
  9. 最后,使用 bpf_ringbuf_submit 函数将填充好的事件结构体 e 提交到 ring buffer,之后在用户空间进行处理和输出。

这个示例展示了如何使用 exitsnoop 和 ring buffer 在 eBPF 程序中捕获进程退出事件并将相关信息传输到用户空间。这对于分析进程退出原因和监控系统行为非常有用。

Compile and Run

我们使用 eunomia-bpf 编译运行这个例子。

Compile:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

Or

1
2
3
4
$ ecc exitsnoop.bpf.c exitsnoop.h
Compiling bpf object...
Generating export types...
Packing ebpf object and config into package.json...

Run:

1
2
3
4
5
6
7
8
9
10
11
$ sudo ./ecli run package.json 
TIME PID PPID EXIT_CODE DURATION_NS COMM
21:40:09 42050 42049 0 0 which
21:40:09 42049 3517 0 0 sh
21:40:09 42052 42051 0 0 ps
21:40:09 42051 3517 0 0 sh
21:40:09 42055 42054 0 0 sed
21:40:09 42056 42054 0 0 cat
21:40:09 42057 42054 0 0 cat
21:40:09 42058 42054 0 0 cat
21:40:09 42059 42054 0 0 cat

总结

本文介绍了如何使用 eunomia-bpf 开发一个简单的 BPF 程序,该程序可以监控 Linux 系统中的进程退出事件, 并将捕获的事件通过 ring buffer 发送给用户空间程序。在本文中,我们使用 eunomia-bpf 编译运行了这个例子。

捕获进程调度延迟,以直方图方式记录

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具。它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

runqlat 是一个 eBPF 工具,用于分析 Linux 系统的调度性能。具体来说,runqlat 用于测量一个任务在被调度到 CPU 上运行之前在运行队列中等待的时间。这些信息对于识别性能瓶颈和提高 Linux 内核调度算法的整体效率非常有用。

runqlat 原理

本教程是 eBPF 入门开发实践系列的第九部分,主题是 “捕获进程调度延迟”。在此,我们将介绍一个名为 runqlat 的程序,其作用是以直方图的形式记录进程调度延迟。

Linux 操作系统使用进程来执行所有的系统和用户任务。这些进程可能被阻塞、杀死、运行,或者正在等待运行。处在后两种状态的进程数量决定了 CPU 运行队列的长度。

进程有几种可能的状态,如:

  • 可运行或正在运行
  • 可中断睡眠
  • 不可中断睡眠
  • 停止
  • 僵尸进程

等待资源或其他函数信号的进程会处在可中断或不可中断的睡眠状态:进程被置入睡眠状态,直到它需要的资源变得可用。然后,根据睡眠的类型,进程可以转移到可运行状态,或者保持睡眠。

即使进程拥有它需要的所有资源,它也不会立即开始运行。它会转移到可运行状态,与其他处在相同状态的进程一起排队。CPU可以在接下来的几秒钟或毫秒内执行这些进程。调度器为 CPU 排列进程,并决定下一个要执行的进程。

根据系统的硬件配置,这个可运行队列(称为 CPU 运行队列)的长度可以短也可以长。短的运行队列长度表示 CPU 没有被充分利用。另一方面,如果运行队列长,那么可能意味着 CPU 不够强大,无法执行所有的进程,或者 CPU 的核心数量不足。在理想的 CPU 利用率下,运行队列的长度将等于系统中的核心数量。

进程调度延迟,也被称为 “run queue latency”,是衡量线程从变得可运行(例如,接收到中断,促使其处理更多工作)到实际在 CPU 上运行的时间。在 CPU 饱和的情况下,你可以想象线程必须等待其轮次。但在其他奇特的场景中,这也可能发生,而且在某些情况下,它可以通过调优减少,从而提高整个系统的性能。

我们将通过一个示例来阐述如何使用 runqlat 工具。这是一个负载非常重的系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# runqlat
Tracing run queue latency... Hit Ctrl-C to end.
^C
usecs : count distribution
0 -> 1 : 233 |*********** |
2 -> 3 : 742 |************************************ |
4 -> 7 : 203 |********** |
8 -> 15 : 173 |******** |
16 -> 31 : 24 |* |
32 -> 63 : 0 | |
64 -> 127 : 30 |* |
128 -> 255 : 6 | |
256 -> 511 : 3 | |
512 -> 1023 : 5 | |
1024 -> 2047 : 27 |* |
2048 -> 4095 : 30 |* |
4096 -> 8191 : 20 | |
8192 -> 16383 : 29 |* |
16384 -> 32767 : 809 |****************************************|
32768 -> 65535 : 64 |*** |

在这个输出中,我们看到了一个双模分布,一个模在0到15微秒之间,另一个模在16到65毫秒之间。这些模式在分布(它仅仅是 “count” 列的视觉表示)中显示为尖峰。例如,读取一行:在追踪过程中,809个事件落入了16384到32767微秒的范围(16到32毫秒)。

在后续的教程中,我们将深入探讨如何利用 eBPF 对此类指标进行深度跟踪和分析,以更好地理解和优化系统性能。同时,我们也将学习更多关于 Linux 内核调度器、中断处理和 CPU 饱

runqlat 的实现利用了 eBPF 程序,它通过内核跟踪点和函数探针来测量进程在运行队列中的时间。当进程被排队时,trace_enqueue 函数会在一个映射中记录时间戳。当进程被调度到 CPU 上运行时,handle_switch 函数会检索时间戳,并计算当前时间与排队时间之间的时间差。这个差值(或 delta)被用于更新进程的直方图,该直方图记录运行队列延迟的分布。该直方图可用于分析 Linux 内核的调度性能。

runqlat 代码实现

首先我们需要编写一个源代码文件 runqlat.bpf.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// SPDX-License-Identifier: GPL-2.0
// Copyright (c) 2020 Wenbo Zhang
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>
#include "runqlat.h"
#include "bits.bpf.h"
#include "maps.bpf.h"
#include "core_fixes.bpf.h"

#define MAX_ENTRIES 10240
#define TASK_RUNNING 0

const volatile bool filter_cg = false;
const volatile bool targ_per_process = false;
const volatile bool targ_per_thread = false;
const volatile bool targ_per_pidns = false;
const volatile bool targ_ms = false;
const volatile pid_t targ_tgid = 0;

struct {
__uint(type, BPF_MAP_TYPE_CGROUP_ARRAY);
__type(key, u32);
__type(value, u32);
__uint(max_entries, 1);
} cgroup_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u32);
__type(value, u64);
} start SEC(".maps");

static struct hist zero;

/// @sample {"interval": 1000, "type" : "log2_hist"}
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u32);
__type(value, struct hist);
} hists SEC(".maps");

static int trace_enqueue(u32 tgid, u32 pid)
{
u64 ts;

if (!pid)
return 0;
if (targ_tgid && targ_tgid != tgid)
return 0;

ts = bpf_ktime_get_ns();
bpf_map_update_elem(&start, &pid, &ts, BPF_ANY);
return 0;
}

static unsigned int pid_namespace(struct task_struct *task)
{
struct pid *pid;
unsigned int level;
struct upid upid;
unsigned int inum;

/* get the pid namespace by following task_active_pid_ns(),
* pid->numbers[pid->level].ns
*/
pid = BPF_CORE_READ(task, thread_pid);
level = BPF_CORE_READ(pid, level);
bpf_core_read(&upid, sizeof(upid), &pid->numbers[level]);
inum = BPF_CORE_READ(upid.ns, ns.inum);

return inum;
}

static int handle_switch(bool preempt, struct task_struct *prev, struct task_struct *next)
{
struct hist *histp;
u64 *tsp, slot;
u32 pid, hkey;
s64 delta;

if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

if (get_task_state(prev) == TASK_RUNNING)
trace_enqueue(BPF_CORE_READ(prev, tgid), BPF_CORE_READ(prev, pid));

pid = BPF_CORE_READ(next, pid);

tsp = bpf_map_lookup_elem(&start, &pid);
if (!tsp)
return 0;
delta = bpf_ktime_get_ns() - *tsp;
if (delta < 0)
goto cleanup;

if (targ_per_process)
hkey = BPF_CORE_READ(next, tgid);
else if (targ_per_thread)
hkey = pid;
else if (targ_per_pidns)
hkey = pid_namespace(next);
else
hkey = -1;
histp = bpf_map_lookup_or_try_init(&hists, &hkey, &zero);
if (!histp)
goto cleanup;
if (!histp->comm[0])
bpf_probe_read_kernel_str(&histp->comm, sizeof(histp->comm),
next->comm);
if (targ_ms)
delta /= 1000000U;
else
delta /= 1000U;
slot = log2l(delta);
if (slot >= MAX_SLOTS)
slot = MAX_SLOTS - 1;
__sync_fetch_and_add(&histp->slots[slot], 1);

cleanup:
bpf_map_delete_elem(&start, &pid);
return 0;
}

SEC("raw_tp/sched_wakeup")
int BPF_PROG(handle_sched_wakeup, struct task_struct *p)
{
if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

return trace_enqueue(BPF_CORE_READ(p, tgid), BPF_CORE_READ(p, pid));
}

SEC("raw_tp/sched_wakeup_new")
int BPF_PROG(handle_sched_wakeup_new, struct task_struct *p)
{
if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

return trace_enqueue(BPF_CORE_READ(p, tgid), BPF_CORE_READ(p, pid));
}

SEC("raw_tp/sched_switch")
int BPF_PROG(handle_sched_switch, bool preempt, struct task_struct *prev, struct task_struct *next)
{
return handle_switch(preempt, prev, next);
}

char LICENSE[] SEC("license") = "GPL";

这其中定义了一些常量和全局变量,用于过滤对应的追踪目标:

1
2
3
4
5
6
7
8
9
#define MAX_ENTRIES 10240
#define TASK_RUNNING 0

const volatile bool filter_cg = false;
const volatile bool targ_per_process = false;
const volatile bool targ_per_thread = false;
const volatile bool targ_per_pidns = false;
const volatile bool targ_ms = false;
const volatile pid_t targ_tgid = 0;

这些变量包括最大映射项数量、任务状态、过滤选项和目标选项。这些选项可以通过用户空间程序设置,以定制 eBPF 程序的行为。

接下来,定义了一些 eBPF 映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct {
__uint(type, BPF_MAP_TYPE_CGROUP_ARRAY);
__type(key, u32);
__type(value, u32);
__uint(max_entries, 1);
} cgroup_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u32);
__type(value, u64);
} start SEC(".maps");

static struct hist zero;

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u32);
__type(value, struct hist);
} hists SEC(".maps");

这些映射包括:

  • cgroup_map 用于过滤 cgroup;
  • start 用于存储进程入队时的时间戳;
  • hists 用于存储直方图数据,记录进程调度延迟。

接下来是一些辅助函数:

trace_enqueue 函数用于在进程入队时记录其时间戳:

1
2
3
4
5
6
7
8
9
10
11
12
13
static int trace_enqueue(u32 tgid, u32 pid)
{
u64 ts;

if (!pid)
return 0;
if (targ_tgid && targ_tgid != tgid)
return 0;

ts = bpf_ktime_get_ns();
bpf_map_update_elem(&start, &pid, &ts, BPF_ANY);
return 0;
}

pid_namespace 函数用于获取进程所属的 PID namespace:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static unsigned int pid_namespace(struct task_struct *task)
{
struct pid *pid;
unsigned int level;
struct upid upid;
unsigned int inum;

/* get the pid namespace by following task_active_pid_ns(),
* pid->numbers[pid->level].ns
*/
pid = BPF_CORE_READ(task, thread_pid);
level = BPF_CORE_READ(pid, level);
bpf_core_read(&upid, sizeof(upid), &pid->numbers[level]);
inum = BPF_CORE_READ(upid.ns, ns.inum);

return inum;
}

handle_switch 函数是核心部分,用于处理调度切换事件,计算进程调度延迟并更新直方图数据:

1
2
3
4
static int handle_switch(bool preempt, struct task_struct *prev, struct task_struct *next)
{
...
}

首先,函数根据 filter_cg 的设置判断是否需要过滤 cgroup。然后,如果之前的进程状态为 TASK_RUNNING,则调用 trace_enqueue 函数记录进程的入队时间。接着,函数查找下一个进程的入队时间戳,如果找不到,直接返回。计算调度延迟(delta),并根据不同的选项设置(targ_per_process,targ_per_thread,targ_per_pidns),确定直方图映射的键(hkey)。然后查找或初始化直方图映射,更新直方图数据,最后删除进程的入队时间戳记录。

接下来是 eBPF 程序的入口点。程序使用三个入口点来捕获不同的调度事件:

  • handle_sched_wakeup:用于处理 sched_wakeup 事件,当一个进程从睡眠状态被唤醒时触发。
  • handle_sched_wakeup_new:用于处理 sched_wakeup_new 事件,当一个新创建的进程被唤醒时触发。
  • handle_sched_switch:用于处理 sched_switch 事件,当调度器选择一个新的进程运行时触发。

这些入口点分别处理不同的调度事件,但都会调用 handle_switch 函数来计算进程的调度延迟并更新直方图数据。

最后,程序包含一个许可证声明:

1
char LICENSE[] SEC("license") = "GPL";

这一声明指定了 eBPF 程序的许可证类型,这里使用的是 “GPL”。这对于许多内核功能是必需的,因为它们要求 eBPF 程序遵循 GPL 许可证。

runqlat.h

然后我们需要定义一个头文件runqlat.h,用来给用户态处理从内核态上报的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
#ifndef __RUNQLAT_H
#define __RUNQLAT_H

#define TASK_COMM_LEN 16
#define MAX_SLOTS 26

struct hist {
__u32 slots[MAX_SLOTS];
char comm[TASK_COMM_LEN];
};

#endif /* __RUNQLAT_H */

编译运行

我们使用 eunomia-bpf 编译运行这个例子。

Compile:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

或者

1
2
3
4
$ ecc runqlat.bpf.c runqlat.h
Compiling bpf object...
Generating export types...
Packing ebpf object and config into package.json...

Run:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
$ sudo ecli run examples/bpftools/runqlat/package.json -h
Usage: runqlat_bpf [--help] [--version] [--verbose] [--filter_cg] [--targ_per_process] [--targ_per_thread] [--targ_per_pidns] [--targ_ms] [--targ_tgid VAR]

A simple eBPF program

Optional arguments:
-h, --help shows help message and exits
-v, --version prints version information and exits
--verbose prints libbpf debug information
--filter_cg set value of bool variable filter_cg
--targ_per_process set value of bool variable targ_per_process
--targ_per_thread set value of bool variable targ_per_thread
--targ_per_pidns set value of bool variable targ_per_pidns
--targ_ms set value of bool variable targ_ms
--targ_tgid set value of pid_t variable targ_tgid

Built with eunomia-bpf framework.
See https://github.com/eunomia-bpf/eunomia-bpf for more information.

$ sudo ecli run examples/bpftools/runqlat/package.json
key = 4294967295
comm = rcu_preempt

(unit) : count distribution
0 -> 1 : 9 |**** |
2 -> 3 : 6 |** |
4 -> 7 : 12 |***** |
8 -> 15 : 28 |************* |
16 -> 31 : 40 |******************* |
32 -> 63 : 83 |****************************************|
64 -> 127 : 57 |*************************** |
128 -> 255 : 19 |********* |
256 -> 511 : 11 |***** |
512 -> 1023 : 2 | |
1024 -> 2047 : 2 | |
2048 -> 4095 : 0 | |
4096 -> 8191 : 0 | |
8192 -> 16383 : 0 | |
16384 -> 32767 : 1 | |

$ sudo ecli run examples/bpftools/runqlat/package.json --targ_per_process
key = 3189
comm = cpptools

(unit) : count distribution
0 -> 1 : 0 | |
2 -> 3 : 0 | |
4 -> 7 : 0 | |
8 -> 15 : 1 |*** |
16 -> 31 : 2 |******* |
32 -> 63 : 11 |****************************************|
64 -> 127 : 8 |***************************** |
128 -> 255 : 3 |********** |

完整源代码请见:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/9-runqlat

总结

runqlat 是一个 Linux 内核 BPF 程序,通过柱状图来总结调度程序运行队列延迟,显示任务等待运行在 CPU 上的时间长度。编译这个程序可以使用 ecc 工具,运行时可以使用 ecli 命令。

runqlat 是一种用于监控Linux内核中进程调度延迟的工具。它可以帮助您了解进程在内核中等待执行的时间,并根据这些信息优化进程调度,提高系统的性能。可以在 libbpf-tools 中找到最初的源代码:https://github.com/iovisor/bcc/blob/master/libbpf-tools/runqlat.bpf.c

在 eBPF 中使用 hardirqs 或 softirqs 捕获中断事件

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具。它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

本文是 eBPF 入门开发实践教程的第十篇,在 eBPF 中使用 hardirqs 或 softirqs 捕获中断事件。 hardirqs 和 softirqs 是 Linux 内核中两种不同类型的中断处理程序。它们用于处理硬件设备产生的中断请求,以及内核中的异步事件。在 eBPF 中,我们可以使用同名的 eBPF 工具 hardirqs 和 softirqs 来捕获和分析内核中与中断处理相关的信息。

hardirqs 和 softirqs 是什么?

hardirqs 是硬件中断处理程序。当硬件设备产生一个中断请求时,内核会将该请求映射到一个特定的中断向量,然后执行与之关联的硬件中断处理程序。硬件中断处理程序通常用于处理设备驱动程序中的事件,例如设备数据传输完成或设备错误。

softirqs 是软件中断处理程序。它们是内核中的一种底层异步事件处理机制,用于处理内核中的高优先级任务。softirqs 通常用于处理网络协议栈、磁盘子系统和其他内核组件中的事件。与硬件中断处理程序相比,软件中断处理程序具有更高的灵活性和可配置性。

实现原理

在 eBPF 中,我们可以通过挂载特定的 kprobe 或者 tracepoint 来捕获和分析 hardirqs 和 softirqs。为了捕获 hardirqs 和 softirqs,需要在相关的内核函数上放置 eBPF 程序。这些函数包括:

  • 对于 hardirqs:irq_handler_entry 和 irq_handler_exit。
  • 对于 softirqs:softirq_entry 和 softirq_exit。

当内核处理 hardirqs 或 softirqs 时,这些 eBPF 程序会被执行,从而收集相关信息,如中断向量、中断处理程序的执行时间等。收集到的信息可以用于分析内核中的性能问题和其他与中断处理相关的问题。

为了捕获 hardirqs 和 softirqs,可以遵循以下步骤:

  1. 在 eBPF 程序中定义用于存储中断信息的数据结构和映射。
  2. 编写 eBPF 程序,将其挂载到相应的内核函数上,以捕获 hardirqs 或 softirqs。
  3. 在 eBPF 程序中,收集中断处理程序的相关信息,并将这些信息存储在映射中。
  4. 在用户空间应用程序中,读取映射中的数据以分析和展示中断处理信息。

通过上述方法,我们可以在 eBPF 中使用 hardirqs 和 softirqs 捕获和分析内核中的中断事件,以识别潜在的性能问题和与中断处理相关的问题。

hardirqs 代码实现

hardirqs 程序的主要目的是获取中断处理程序的名称、执行次数和执行时间,并以直方图的形式展示执行时间的分布。让我们一步步分析这段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// SPDX-License-Identifier: GPL-2.0
// Copyright (c) 2020 Wenbo Zhang
#include <vmlinux.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include "hardirqs.h"
#include "bits.bpf.h"
#include "maps.bpf.h"

#define MAX_ENTRIES 256

const volatile bool filter_cg = false;
const volatile bool targ_dist = false;
const volatile bool targ_ns = false;
const volatile bool do_count = false;

struct {
__uint(type, BPF_MAP_TYPE_CGROUP_ARRAY);
__type(key, u32);
__type(value, u32);
__uint(max_entries, 1);
} cgroup_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, u64);
} start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, struct irq_key);
__type(value, struct info);
} infos SEC(".maps");

static struct info zero;

static int handle_entry(int irq, struct irqaction *action)
{
if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

if (do_count) {
struct irq_key key = {};
struct info *info;

bpf_probe_read_kernel_str(&key.name, sizeof(key.name), BPF_CORE_READ(action, name));
info = bpf_map_lookup_or_try_init(&infos, &key, &zero);
if (!info)
return 0;
info->count += 1;
return 0;
} else {
u64 ts = bpf_ktime_get_ns();
u32 key = 0;

if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

bpf_map_update_elem(&start, &key, &ts, BPF_ANY);
return 0;
}
}

static int handle_exit(int irq, struct irqaction *action)
{
struct irq_key ikey = {};
struct info *info;
u32 key = 0;
u64 delta;
u64 *tsp;

if (filter_cg && !bpf_current_task_under_cgroup(&cgroup_map, 0))
return 0;

tsp = bpf_map_lookup_elem(&start, &key);
if (!tsp)
return 0;

delta = bpf_ktime_get_ns() - *tsp;
if (!targ_ns)
delta /= 1000U;

bpf_probe_read_kernel_str(&ikey.name, sizeof(ikey.name), BPF_CORE_READ(action, name));
info = bpf_map_lookup_or_try_init(&infos, &ikey, &zero);
if (!info)
return 0;

if (!targ_dist) {
info->count += delta;
} else {
u64 slot;

slot = log2(delta);
if (slot >= MAX_SLOTS)
slot = MAX_SLOTS - 1;
info->slots[slot]++;
}

return 0;
}

SEC("tp_btf/irq_handler_entry")
int BPF_PROG(irq_handler_entry_btf, int irq, struct irqaction *action)
{
return handle_entry(irq, action);
}

SEC("tp_btf/irq_handler_exit")
int BPF_PROG(irq_handler_exit_btf, int irq, struct irqaction *action)
{
return handle_exit(irq, action);
}

SEC("raw_tp/irq_handler_entry")
int BPF_PROG(irq_handler_entry, int irq, struct irqaction *action)
{
return handle_entry(irq, action);
}

SEC("raw_tp/irq_handler_exit")
int BPF_PROG(irq_handler_exit, int irq, struct irqaction *action)
{
return handle_exit(irq, action);
}

char LICENSE[] SEC("license") = "GPL";

这段代码是一个 eBPF 程序,用于捕获和分析内核中硬件中断处理程序(hardirqs)的执行信息。程序的主要目的是获取中断处理程序的名称、执行次数和执行时间,并以直方图的形式展示执行时间的分布。让我们一步步分析这段代码。

包含必要的头文件和定义数据结构:

1
2
3
4
5
6
7
#include <vmlinux.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include "hardirqs.h"
#include "bits.bpf.h"
#include "maps.bpf.h"

该程序包含了 eBPF 开发所需的标准头文件,以及用于定义数据结构和映射的自定义头文件。

定义全局变量和映射:

1
2
3
4
5
6
7
8
#define MAX_ENTRIES 256

const volatile bool filter_cg = false;
const volatile bool targ_dist = false;
const volatile bool targ_ns = false;
const volatile bool do_count = false;

...

该程序定义了一些全局变量,用于配置程序的行为。例如,filter_cg 控制是否过滤 cgroup,targ_dist 控制是否显示执行时间的分布等。此外,程序还定义了三个映射,分别用于存储 cgroup 信息、开始时间戳和中断处理程序的信息。

定义两个辅助函数 handle_entryhandle_exit

这两个函数分别在中断处理程序的入口和出口处被调用。handle_entry 记录开始时间戳或更新中断计数,handle_exit 计算中断处理程序的执行时间,并将结果存储到相应的信息映射中。

定义 eBPF 程序的入口点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
SEC("tp_btf/irq_handler_entry")
int BPF_PROG(irq_handler_entry_btf, int irq, struct irqaction *action)
{
return handle_entry(irq, action);
}

SEC("tp_btf/irq_handler_exit")
int BPF_PROG(irq_handler_exit_btf, int irq, struct irqaction *action)
{
return handle_exit(irq, action);
}

SEC("raw_tp/irq_handler_entry")
int BPF_PROG(irq_handler_entry, int irq, struct irqaction *action)
{
return handle_entry(irq, action);
}

SEC("raw_tp/irq_handler_exit")
int BPF_PROG(irq_handler_exit, int irq, struct irqaction *action)
{
return handle_exit(irq, action);
}

这里定义了四个 eBPF 程序入口点,分别用于捕获中断处理程序的入口和出口事件。tp_btfraw_tp 分别代表使用 BPF Type Format(BTF)和原始 tracepoints 捕获事件。这样可以确保程序在不同内核版本上可以移植和运行。

Softirq 代码也类似,这里就不再赘述了。

运行代码

eunomia-bpf 是一个结合 Wasm 的开源 eBPF 动态加载运行时和开发工具链,它的目的是简化 eBPF 程序的开发、构建、分发、运行。可以参考 https://github.com/eunomia-bpf/eunomia-bpf 下载和安装 ecc 编译工具链和 ecli 运行时。我们使用 eunomia-bpf 编译运行这个例子。

要编译这个程序,请使用 ecc 工具:

1
2
3
$ ecc hardirqs.bpf.c
Compiling bpf object...
Packing ebpf object and config into package.json...

然后运行:

1
sudo ecli run ./package.json

总结

在本章节(eBPF 入门开发实践教程十:在 eBPF 中使用 hardirqs 或 softirqs 捕获中断事件)中,我们学习了如何使用 eBPF 程序捕获和分析内核中硬件中断处理程序(hardirqs)的执行信息。我们详细讲解了示例代码,包括如何定义数据结构、映射以及 eBPF 程序入口点,以及如何在中断处理程序的入口和出口处调用辅助函数来记录执行信息。

在 eBPF 中使用 libbpf 开发用户态程序并跟踪 exec() 和 exit() 系统调用

eBPF (Extended Berkeley Packet Filter) 是 Linux 内核上的一个强大的网络和性能分析工具。它允许开发者在内核运行时动态加载、更新和运行用户定义的代码。

在本教程中,我们将了解内核态和用户态的 eBPF 程序是如何协同工作的。我们还将学习如何使用原生的 libbpf 开发用户态程序,将 eBPF 应用打包为可执行文件,实现跨内核版本分发。

libbpf 库,以及为什么需要使用它

libbpf 是一个 C 语言库,伴随内核版本分发,用于辅助 eBPF 程序的加载和运行。它提供了用于与 eBPF 系统交互的一组 C API,使开发者能够更轻松地编写用户态程序来加载和管理 eBPF 程序。这些用户态程序通常用于分析、监控或优化系统性能。

使用 libbpf 库有以下优势:

  • 它简化了 eBPF 程序的加载、更新和运行过程。
  • 它提供了一组易于使用的 API,使开发者能够专注于编写核心逻辑,而不是处理底层细节。
  • 它能够确保与内核中的 eBPF 子系统的兼容性,降低了维护成本。

同时,libbpf 和 BTF(BPF Type Format)都是 eBPF 生态系统的重要组成部分。它们各自在实现跨内核版本兼容方面发挥着关键作用。BTF(BPF Type Format)是一种元数据格式,用于描述 eBPF 程序中的类型信息。BTF 的主要目的是提供一种结构化的方式,以描述内核中的数据结构,以便 eBPF 程序可以更轻松地访问和操作它们。

BTF 在实现跨内核版本兼容方面的关键作用如下:

  • BTF 允许 eBPF 程序访问内核数据结构的详细类型信息,而无需对特定内核版本进行硬编码。这使得 eBPF 程序可以适应不同版本的内核,从而实现跨内核版本兼容。
  • 通过使用 BPF CO-RE(Compile Once, Run Everywhere)技术,eBPF 程序可以利用 BTF 在编译时解析内核数据结构的类型信息,进而生成可以在不同内核版本上运行的 eBPF 程序。

结合 libbpf 和 BTF,eBPF 程序可以在各种不同版本的内核上运行,而无需为每个内核版本单独编译。这极大地提高了 eBPF 生态系统的可移植性和兼容性,降低了开发和维护的难度。

什么是 bootstrap

Bootstrap 是一个使用 libbpf 的完整应用,它利用 eBPF 程序来跟踪内核中的 exec() 系统调用(通过 SEC("tp/sched/sched_process_exec") handle_exec BPF 程序),这主要对应于新进程的创建(不包括 fork() 部分)。此外,它还跟踪进程的 exit() 系统调用(通过 SEC("tp/sched/sched_process_exit") handle_exit BPF 程序),以了解每个进程何时退出。

这两个 BPF 程序共同工作,允许捕获关于新进程的有趣信息,例如二进制文件的文件名,以及测量进程的生命周期,并在进程结束时收集有趣的统计信息,例如退出代码或消耗的资源量等。这是深入了解内核内部并观察事物如何真正运作的良好起点。

Bootstrap 还使用 argp API(libc 的一部分)进行命令行参数解析,使得用户可以通过命令行选项配置应用行为。这种方式提供了灵活性,让用户能够根据实际需求自定义程序行为。虽然这些功能使用 eunomia-bpf 工具也可以实现,但是这里我们使用 libbpf 可以在用户态提供更高的可扩展性,不过也带来了不少额外的复杂度。

Bootstrap

Bootstrap 分为两个部分:内核态和用户态。内核态部分是一个 eBPF 程序,它跟踪 exec() 和 exit() 系统调用。用户态部分是一个 C 语言程序,它使用 libbpf 库来加载和运行内核态程序,并处理从内核态程序收集的数据。

内核态 eBPF 程序 bootstrap.bpf.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
/* Copyright (c) 2020 Facebook */
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "bootstrap.h"

char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, pid_t);
__type(value, u64);
} exec_start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");

const volatile unsigned long long min_duration_ns = 0;

SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
struct task_struct *task;
unsigned fname_off;
struct event *e;
pid_t pid;
u64 ts;

/* remember time exec() was executed for this PID */
pid = bpf_get_current_pid_tgid() >> 32;
ts = bpf_ktime_get_ns();
bpf_map_update_elem(&exec_start, &pid, &ts, BPF_ANY);

/* don't emit exec events when minimum duration is specified */
if (min_duration_ns)
return 0;

/* reserve sample from BPF ringbuf */
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

/* fill out the sample with data */
task = (struct task_struct *)bpf_get_current_task();

e->exit_event = false;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));

fname_off = ctx->__data_loc_filename & 0xFFFF;
bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)ctx + fname_off);

/* successfully submit it to user-space for post-processing */
bpf_ringbuf_submit(e, 0);
return 0;
}

SEC("tp/sched/sched_process_exit")
int handle_exit(struct trace_event_raw_sched_process_template* ctx)
{
struct task_struct *task;
struct event *e;
pid_t pid, tid;
u64 id, ts, *start_ts, duration_ns = 0;

/* get PID and TID of exiting thread/process */
id = bpf_get_current_pid_tgid();
pid = id >> 32;
tid = (u32)id;

/* ignore thread exits */
if (pid != tid)
return 0;

/* if we recorded start of the process, calculate lifetime duration */
start_ts = bpf_map_lookup_elem(&exec_start, &pid);
if (start_ts)
duration_ns = bpf_ktime_get_ns() - *start_ts;
else if (min_duration_ns)
return 0;
bpf_map_delete_elem(&exec_start, &pid);

/* if process didn't live long enough, return early */
if (min_duration_ns && duration_ns < min_duration_ns)
return 0;

/* reserve sample from BPF ringbuf */
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

/* fill out the sample with data */
task = (struct task_struct *)bpf_get_current_task();

e->exit_event = true;
e->duration_ns = duration_ns;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
e->exit_code = (BPF_CORE_READ(task, exit_code) >> 8) & 0xff;
bpf_get_current_comm(&e->comm, sizeof(e->comm));

/* send data to user-space for post-processing */
bpf_ringbuf_submit(e, 0);
return 0;
}

这段代码是一个内核态 eBPF 程序(bootstrap.bpf.c),主要用于跟踪 exec() 和 exit() 系统调用。它通过 eBPF 程序捕获进程的创建和退出事件,并将相关信息发送到用户态程序进行处理。下面是对代码的详细解释。

首先,我们引入所需的头文件,定义 eBPF 程序的许可证以及两个 eBPF maps:exec_start 和 rb。exec_start 是一个哈希类型的 eBPF map,用于存储进程开始执行时的时间戳。rb 是一个环形缓冲区类型的 eBPF map,用于存储捕获的事件数据,并将其发送到用户态程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include "bootstrap.h"

char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, pid_t);
__type(value, u64);
} exec_start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");

const volatile unsigned long long min_duration_ns = 0;

接下来,我们定义了一个名为 handle_exec 的 eBPF 程序,它会在进程执行 exec() 系统调用时触发。首先,我们从当前进程中获取 PID,记录进程开始执行的时间戳,然后将其存储在 exec_start map 中。

1
2
3
4
5
6
7
8
9
10
SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
// ...
pid = bpf_get_current_pid_tgid() >> 32;
ts = bpf_ktime_get_ns();
bpf_map_update_elem(&exec_start, &pid, &ts, BPF_ANY);

// ...
}

然后,我们从环形缓冲区 map rb 中预留一个事件结构,并填充相关数据,如进程 ID、父进程 ID、进程名等。之后,我们将这些数据发送到用户态程序进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// reserve sample from BPF ringbuf
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

// fill out the sample with data
task = (struct task_struct *)bpf_get_current_task();

e->exit_event = false;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));

fname_off = ctx->__data_loc_filename & 0xFFFF;
bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)ctx + fname_off);

// successfully submit it to user-space for post-processing
bpf_ringbuf_submit(e, 0);
return 0;

最后,我们定义了一个名为 handle_exit 的 eBPF 程序,它会在进程执行 exit() 系统调用时触发。首先,我们从当前进程中获取 PID 和 TID(线程 ID)。如果 PID 和 TID 不相等,说明这是一个线程退出,我们将忽略此事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SEC("tp/sched/sched_process_exit")
int handle_exit(struct trace_event_raw_sched_process_template* ctx)
{
// ...
id = bpf_get_current_pid_tgid();
pid = id >> 32;
tid = (u32)id;

/* ignore thread exits */
if (pid != tid)
return 0;

// ...
}

接着,我们查找之前存储在 exec_start map 中的进程开始执行的时间戳。如果找到了时间戳,我们将计算进程的生命周期(持续时间),然后从 exec_start map 中删除该记录。如果未找到时间戳且指定了最小持续时间,则直接返回。

1
2
3
4
5
6
7
8
9
10
11
// if we recorded start of the process, calculate lifetime duration
start_ts = bpf_map_lookup_elem(&exec_start, &pid);
if (start_ts)
duration_ns = bpf_ktime_get_ns() - *start_ts;
else if (min_duration_ns)
return 0;
bpf_map_delete_elem(&exec_start, &pid);

// if process didn't live long enough, return early
if (min_duration_ns && duration_ns < min_duration_ns)
return 0;

然后,我们从环形缓冲区 map rb 中预留一个事件结构,并填充相关数据,如进程 ID、父进程 ID、进程名、进程持续时间等。最后,我们将这些数据发送到用户态程序进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    /* reserve sample from BPF ringbuf */
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e)
return 0;

/* fill out the sample with data */
task = (struct task_struct *)bpf_get_current_task();

e->exit_event = true;
e->duration_ns = duration_ns;
e->pid = pid;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
e->exit_code = (BPF_CORE_READ(task, exit_code) >> 8) & 0xff;
bpf_get_current_comm(&e->comm, sizeof(e->comm));

/* send data to user-space for post-processing */
bpf_ringbuf_submit(e, 0);
return 0;
}

这样,当进程执行 exec() 或 exit() 系统调用时,我们的 eBPF 程序会捕获相应的事件,并将详细信息发送到用户态程序进行后续处理。这使得我们可以轻松地监控进程的创建和退出,并获取有关进程的详细信息。

除此之外,在 bootstrap.h 中,我们还定义了和用户态交互的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
/* Copyright (c) 2020 Facebook */
#ifndef __BOOTSTRAP_H
#define __BOOTSTRAP_H

#define TASK_COMM_LEN 16
#define MAX_FILENAME_LEN 127

struct event {
int pid;
int ppid;
unsigned exit_code;
unsigned long long duration_ns;
char comm[TASK_COMM_LEN];
char filename[MAX_FILENAME_LEN];
bool exit_event;
};

#endif /* __BOOTSTRAP_H */
用户态,bootstrap.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
/* Copyright (c) 2020 Facebook */
#include <argp.h>
#include <signal.h>
#include <stdio.h>
#include <time.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include "bootstrap.h"
#include "bootstrap.skel.h"

static struct env {
bool verbose;
long min_duration_ms;
} env;

const char *argp_program_version = "bootstrap 0.0";
const char *argp_program_bug_address = "<bpf@vger.kernel.org>";
const char argp_program_doc[] =
"BPF bootstrap demo application.\n"
"\n"
"It traces process start and exits and shows associated \n"
"information (filename, process duration, PID and PPID, etc).\n"
"\n"
"USAGE: ./bootstrap [-d <min-duration-ms>] [-v]\n";

static const struct argp_option opts[] = {
{ "verbose", 'v', NULL, 0, "Verbose debug output" },
{ "duration", 'd', "DURATION-MS", 0, "Minimum process duration (ms) to report" },
{},
};

static error_t parse_arg(int key, char *arg, struct argp_state *state)
{
switch (key) {
case 'v':
env.verbose = true;
break;
case 'd':
errno = 0;
env.min_duration_ms = strtol(arg, NULL, 10);
if (errno || env.min_duration_ms <= 0) {
fprintf(stderr, "Invalid duration: %s\n", arg);
argp_usage(state);
}
break;
case ARGP_KEY_ARG:
argp_usage(state);
break;
default:
return ARGP_ERR_UNKNOWN;
}
return 0;
}

static const struct argp argp = {
.options = opts,
.parser = parse_arg,
.doc = argp_program_doc,
};

static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
if (level == LIBBPF_DEBUG && !env.verbose)
return 0;
return vfprintf(stderr, format, args);
}

static volatile bool exiting = false;

static void sig_handler(int sig)
{
exiting = true;
}

static int handle_event(void *ctx, void *data, size_t data_sz)
{
const struct event *e = data;
struct tm *tm;
char ts[32];
time_t t;

time(&t);
tm = localtime(&t);
strftime(ts, sizeof(ts), "%H:%M:%S", tm);

if (e->exit_event) {
printf("%-8s %-5s %-16s %-7d %-7d [%u]",
ts, "EXIT", e->comm, e->pid, e->ppid, e->exit_code);
if (e->duration_ns)
printf(" (%llums)", e->duration_ns / 1000000);
printf("\n");
} else {
printf("%-8s %-5s %-16s %-7d %-7d %s\n",
ts, "EXEC", e->comm, e->pid, e->ppid, e->filename);
}

return 0;
}

int main(int argc, char **argv)
{
struct ring_buffer *rb = NULL;
struct bootstrap_bpf *skel;
int err;

/* Parse command line arguments */
err = argp_parse(&argp, argc, argv, 0, NULL, NULL);
if (err)
return err;

/* Set up libbpf errors and debug info callback */
libbpf_set_print(libbpf_print_fn);

/* Cleaner handling of Ctrl-C */
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);

/* Load and verify BPF application */
skel = bootstrap_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open and load BPF skeleton\n");
return 1;
}

/* Parameterize BPF code with minimum duration parameter */
skel->rodata->min_duration_ns = env.min_duration_ms * 1000000ULL;

/* Load & verify BPF programs */
err = bootstrap_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load and verify BPF skeleton\n");
goto cleanup;
}

/* Attach tracepoints */
err = bootstrap_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}

/* Set up ring buffer polling */
rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
if (!rb) {
err = -1;
fprintf(stderr, "Failed to create ring buffer\n");
goto cleanup;
}

/* Process events */
printf("%-8s %-5s %-16s %-7s %-7s %s\n",
"TIME", "EVENT", "COMM", "PID", "PPID", "FILENAME/EXIT CODE");
while (!exiting) {
err = ring_buffer__poll(rb, 100 /* timeout, ms */);
/* Ctrl-C will cause -EINTR */
if (err == -EINTR) {
err = 0;
break;
}
if (err < 0) {
printf("Error polling perf buffer: %d\n", err);
break;
}
}

cleanup:
/* Clean up */
ring_buffer__free(rb);
bootstrap_bpf__destroy(skel);

return err < 0 ? -err : 0;
}

这个用户态程序主要用于加载、验证、附加 eBPF 程序,以及接收 eBPF 程序收集的事件数据,并将其打印出来。我们将分析一些关键部分。

首先,我们定义了一个 env 结构,用于存储命令行参数:

1
2
3
4
static struct env {
bool verbose;
long min_duration_ms;
} env;

接下来,我们使用 argp 库来解析命令行参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static const struct argp_option opts[] = {
{ "verbose", 'v', NULL, 0, "Verbose debug output" },
{ "duration", 'd', "DURATION-MS", 0, "Minimum process duration (ms) to report" },
{},
};

static error_t parse_arg(int key, char *arg, struct argp_state *state)
{
// ...
}

static const struct argp argp = {
.options = opts,
.parser = parse_arg,
.doc = argp_program_doc,
};

main() 函数中,首先解析命令行参数,然后设置 libbpf 的打印回调函数 libbpf_print_fn,以便在需要时输出调试信息:

1
2
3
4
5
err = argp_parse(&argp, argc, argv, 0, NULL, NULL);
if (err)
return err;

libbpf_set_print(libbpf_print_fn);

接下来,我们打开 eBPF 脚手架(skeleton)文件,将最小持续时间参数传递给 eBPF 程序,并加载和附加 eBPF 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
skel = bootstrap_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open and load BPF skeleton\n");
return 1;
}

skel->rodata->min_duration_ns = env.min_duration_ms * 1000000ULL;

err = bootstrap_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load and verify BPF skeleton\n");
goto cleanup;
}

err = bootstrap_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}

然后,我们创建一个环形缓冲区(ring buffer),用于接收 eBPF 程序发送的事件数据:

1
2
3
4
5
6
rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
if (!rb) {
err = -1;
fprintf(stderr, "Failed to create ring buffer\n");
goto cleanup;
}

handle_event() 函数会处理从 eBPF 程序收到的事件。根据事件类型(进程执行或退出),它会提取并打印事件信息,如时间戳、进程名、进程 ID、父进程 ID、文件名或退出代码等。

最后,我们使用 ring_buffer__poll() 函数轮询环形缓冲区,处理收到的事件数据:

1
2
3
4
while (!exiting) {
err = ring_buffer__poll(rb, 100 /* timeout, ms */);
// ...
}

当程序收到 SIGINT 或 SIGTERM 信号时,它会最后完成清理、退出操作,关闭和卸载 eBPF 程序:

1
2
3
4
5
6
7
cleanup:
/* Clean up */
ring_buffer__free(rb);
bootstrap_bpf__destroy(skel);

return err < 0 ? -err : 0;
}
安装依赖

构建示例需要 clang、libelf 和 zlib。包名在不同的发行版中可能会有所不同。

在 Ubuntu/Debian 上,你需要执行以下命令:

1
sudo apt install clang libelf1 libelf-dev zlib1g-dev

在 CentOS/Fedora 上,你需要执行以下命令:

1
sudo dnf install clang elfutils-libelf elfutils-libelf-devel zlib-devel
编译运行

编译运行上述代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ make
BPF .output/bootstrap.bpf.o
GEN-SKEL .output/bootstrap.skel.h
CC .output/bootstrap.o
BINARY bootstrap
$ sudo ./bootstrap
[sudo] password for yunwei:
TIME EVENT COMM PID PPID FILENAME/EXIT CODE
03:16:41 EXEC sh 110688 80168 /bin/sh
03:16:41 EXEC which 110689 110688 /usr/bin/which
03:16:41 EXIT which 110689 110688 [0] (0ms)
03:16:41 EXIT sh 110688 80168 [0] (0ms)
03:16:41 EXEC sh 110690 80168 /bin/sh
03:16:41 EXEC ps 110691 110690 /usr/bin/ps
03:16:41 EXIT ps 110691 110690 [0] (49ms)
03:16:41 EXIT sh 110690 80168 [0] (51ms)

总结

通过这个实例,我们了解了如何将 eBPF 程序与用户态程序结合使用。这种结合为开发者提供了一个强大的工具集,可以实现跨内核和用户空间的高效数据收集和处理。通过使用 eBPF 和 libbpf,您可以构建更高效、可扩展和安全的监控和性能分析工具。

使用 eBPF 程序 profile 进行性能分析

本教程将指导您使用 libbpf 和 eBPF 程序进行性能分析。我们将利用内核中的 perf 机制,学习如何捕获函数的执行时间以及如何查看性能数据。

libbpf 是一个用于与 eBPF 交互的 C 库。它提供了创建、加载和使用 eBPF 程序所需的基本功能。本教程中,我们将主要使用 libbpf 完成开发工作。perf 是 Linux 内核中的性能分析工具,允许用户测量和分析内核及用户空间程序的性能,以及获取对应的调用堆栈。它利用内核中的硬件计数器和软件事件来收集性能数据。

eBPF 工具:profile 性能分析示例

profile 工具基于 eBPF 实现,利用 Linux 内核中的 perf 事件进行性能分析。profile 工具会定期对每个处理器进行采样,以便捕获内核函数和用户空间函数的执行。它可以显示栈回溯的以下信息:

  • 地址:函数调用的内存地址
  • 符号:函数名称
  • 文件名:源代码文件名称
  • 行号:源代码中的行号

这些信息有助于开发人员定位性能瓶颈和优化代码。更进一步,可以通过这些对应的信息生成火焰图,以便更直观的查看性能数据。

在本示例中,可以通过 libbpf 库编译运行它(以 Ubuntu/Debian 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ git submodule update --init --recursive
$ sudo apt install clang libelf1 libelf-dev zlib1g-dev
$ make
$ sudo ./profile
COMM: chronyd (pid=156) @ CPU 1
Kernel:
0 [<ffffffff81ee9f56>] _raw_spin_lock_irqsave+0x16
1 [<ffffffff811527b4>] remove_wait_queue+0x14
2 [<ffffffff8132611d>] poll_freewait+0x3d
3 [<ffffffff81326d3f>] do_select+0x7bf
4 [<ffffffff81327af2>] core_sys_select+0x182
5 [<ffffffff81327f3a>] __x64_sys_pselect6+0xea
6 [<ffffffff81ed9e38>] do_syscall_64+0x38
7 [<ffffffff82000099>] entry_SYSCALL_64_after_hwframe+0x61
Userspace:
0 [<00007fab187bfe09>]
1 [<000000000ee6ae98>]

COMM: profile (pid=9843) @ CPU 6
No Kernel Stack
Userspace:
0 [<0000556deb068ac8>]
1 [<0000556dec34cad0>]

实现原理

profile 工具由两个部分组成,内核态中的 eBPF 程序和用户态中的 profile 符号处理程序。profile 符号处理程序负责加载 eBPF 程序,以及处理 eBPF 程序输出的数据。

内核态部分

内核态 eBPF 程序的实现逻辑主要是借助 perf event,对程序的堆栈进行定时采样,从而捕获程序的执行流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
/* Copyright (c) 2022 Meta Platforms, Inc. */
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

#include "profile.h"

char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");

SEC("perf_event")
int profile(void *ctx)
{
int pid = bpf_get_current_pid_tgid() >> 32;
int cpu_id = bpf_get_smp_processor_id();
struct stacktrace_event *event;
int cp;

event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event)
return 1;

event->pid = pid;
event->cpu_id = cpu_id;

if (bpf_get_current_comm(event->comm, sizeof(event->comm)))
event->comm[0] = 0;

event->kstack_sz = bpf_get_stack(ctx, event->kstack, sizeof(event->kstack), 0);

event->ustack_sz = bpf_get_stack(ctx, event->ustack, sizeof(event->ustack), BPF_F_USER_STACK);

bpf_ringbuf_submit(event, 0);

return 0;
}

接下来,我们将重点讲解内核态代码的关键部分。

定义 eBPF maps events

1
2
3
4
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");

这里定义了一个类型为 BPF_MAP_TYPE_RINGBUF 的 eBPF maps 。Ring Buffer 是一种高性能的循环缓冲区,用于在内核和用户空间之间传输数据。max_entries 设置了 Ring Buffer 的最大大小。

定义 perf_event eBPF 程序:

1
2
SEC("perf_event")
int profile(void *ctx)

这里定义了一个名为 profile 的 eBPF 程序,它将在 perf 事件触发时执行。

获取进程 ID 和 CPU ID:

1
2
int pid = bpf_get_current_pid_tgid() >> 32;
int cpu_id = bpf_get_smp_processor_id();

bpf_get_current_pid_tgid() 函数返回当前进程的 PID 和 TID,通过右移 32 位,我们得到 PID。bpf_get_smp_processor_id() 函数返回当前 CPU 的 ID。

预留 Ring Buffer 空间:

1
2
3
event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event)
return 1;

通过 bpf_ringbuf_reserve() 函数预留 Ring Buffer 空间,用于存储采集的栈信息。若预留失败,返回错误.

获取当前进程名:

1
2
if (bpf_get_current_comm(event->comm, sizeof(event->comm)))
event->comm[0] = 0;

使用 bpf_get_current_comm() 函数获取当前进程名并将其存储到 event->comm

获取内核栈信息:

1
event->kstack_sz = bpf_get_stack(ctx, event->kstack, sizeof(event->kstack), 0);

使用 bpf_get_stack() 函数获取内核栈信息。将结果存储在 event->kstack,并将其大小存储在 event->kstack_sz

获取用户空间栈信息:

1
event->ustack_sz = bpf_get_stack(ctx, event->ustack, sizeof(event->ustack), BPF_F_USER_STACK);

同样使用 bpf_get_stack() 函数,但传递 BPF_F_USER_STACK 标志以获取用户空间栈信息。将结果存储在 event->ustack,并将其大小存储在 event->ustack_sz

将事件提交到 Ring Buffer:

1
bpf_ringbuf_submit(event, 0);

最后,使用 bpf_ringbuf_submit() 函数将事件提交到 Ring Buffer,以便用户空间程序可以读取和处理。

这个内核态 eBPF 程序通过定期采样程序的内核栈和用户空间栈来捕获程序的执行流程。这些数据将存储在 Ring Buffer 中,以便用户态的 profile 程序能读取。

用户态部分

这段代码主要负责为每个在线 CPU 设置 perf event 并附加 eBPF 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static long perf_event_open(struct perf_event_attr *hw_event, pid_t pid,
int cpu, int group_fd, unsigned long flags)
{
int ret;

ret = syscall(__NR_perf_event_open, hw_event, pid, cpu, group_fd, flags);
return ret;
}

int main(){
...
for (cpu = 0; cpu < num_cpus; cpu++) {
/* skip offline/not present CPUs */
if (cpu >= num_online_cpus || !online_mask[cpu])
continue;

/* Set up performance monitoring on a CPU/Core */
pefd = perf_event_open(&attr, pid, cpu, -1, PERF_FLAG_FD_CLOEXEC);
if (pefd < 0) {
fprintf(stderr, "Fail to set up performance monitor on a CPU/Core\n");
err = -1;
goto cleanup;
}
pefds[cpu] = pefd;

/* Attach a BPF program on a CPU */
links[cpu] = bpf_program__attach_perf_event(skel->progs.profile, pefd);
if (!links[cpu]) {
err = -1;
goto cleanup;
}
}
...
}

perf_event_open 这个函数是一个对 perf_event_open 系统调用的封装。它接收一个 perf_event_attr 结构体指针,用于指定 perf event 的类型和属性。pid 参数用于指定要监控的进程 ID(-1 表示监控所有进程),cpu 参数用于指定要监控的 CPU。group_fd 参数用于将 perf event 分组,这里我们使用 -1,表示不需要分组。flags 参数用于设置一些标志,这里我们使用 PERF_FLAG_FD_CLOEXEC 以确保在执行 exec 系列系统调用时关闭文件描述符。

在 main 函数中:

1
2
3
for (cpu = 0; cpu < num_cpus; cpu++) {
// ...
}

这个循环针对每个在线 CPU 设置 perf event 并附加 eBPF 程序。首先,它会检查当前 CPU 是否在线,如果不在线则跳过。然后,使用 perf_event_open() 函数为当前 CPU 设置 perf event,并将返回的文件描述符存储在 pefds 数组中。最后,使用 bpf_program__attach_perf_event() 函数将 eBPF 程序附加到 perf event。links 数组用于存储每个 CPU 上的 BPF 链接,以便在程序结束时销毁它们。

通过这种方式,用户态程序为每个在线 CPU 设置 perf event,并将 eBPF 程序附加到这些 perf event 上,从而实现对系统中所有在线 CPU 的监控。

以下这两个函数分别用于显示栈回溯和处理从 ring buffer 接收到的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
static void show_stack_trace(__u64 *stack, int stack_sz, pid_t pid)
{
const struct blazesym_result *result;
const struct blazesym_csym *sym;
sym_src_cfg src;
int i, j;

if (pid) {
src.src_type = SRC_T_PROCESS;
src.params.process.pid = pid;
} else {
src.src_type = SRC_T_KERNEL;
src.params.kernel.kallsyms = NULL;
src.params.kernel.kernel_image = NULL;
}

result = blazesym_symbolize(symbolizer, &src, 1, (const uint64_t *)stack, stack_sz);

for (i = 0; i < stack_sz; i++) {
if (!result || result->size <= i || !result->entries[i].size) {
printf(" %d [<%016llx>]\n", i, stack[i]);
continue;
}

if (result->entries[i].size == 1) {
sym = &result->entries[i].syms[0];
if (sym->path && sym->path[0]) {
printf(" %d [<%016llx>] %s+0x%llx %s:%ld\n",
i, stack[i], sym->symbol,
stack[i] - sym->start_address,
sym->path, sym->line_no);
} else {
printf(" %d [<%016llx>] %s+0x%llx\n",
i, stack[i], sym->symbol,
stack[i] - sym->start_address);
}
continue;
}

printf(" %d [<%016llx>]\n", i, stack[i]);
for (j = 0; j < result->entries[i].size; j++) {
sym = &result->entries[i].syms[j];
if (sym->path && sym->path[0]) {
printf(" %s+0x%llx %s:%ld\n",
sym->symbol, stack[i] - sym->start_address,
sym->path, sym->line_no);
} else {
printf(" %s+0x%llx\n", sym->symbol,
stack[i] - sym->start_address);
}
}
}

blazesym_result_free(result);
}

/* Receive events from the ring buffer. */
static int event_handler(void *_ctx, void *data, size_t size)
{
struct stacktrace_event *event = data;

if (event->kstack_sz <= 0 && event->ustack_sz <= 0)
return 1;

printf("COMM: %s (pid=%d) @ CPU %d\n", event->comm, event->pid, event->cpu_id);

if (event->kstack_sz > 0) {
printf("Kernel:\n");
show_stack_trace(event->kstack, event->kstack_sz / sizeof(__u64), 0);
} else {
printf("No Kernel Stack\n");
}

if (event->ustack_sz > 0) {
printf("Userspace:\n");
show_stack_trace(event->ustack, event->ustack_sz / sizeof(__u64), event->pid);
} else {
printf("No Userspace Stack\n");
}

printf("\n");
return 0;
}

show_stack_trace() 函数用于显示内核或用户空间的栈回溯。它接收一个 stack 参数,是一个指向内核或用户空间栈的指针,stack_sz 参数表示栈的大小,pid 参数表示要显示的进程的 ID(当显示内核栈时,设置为 0)。函数中首先根据 pid 参数确定栈的来源(内核或用户空间),然后调用 blazesym_symbolize() 函数将栈中的地址解析为符号名和源代码位置。最后,遍历解析结果,输出符号名和源代码位置信息。

event_handler() 函数用于处理从 ring buffer 接收到的事件。它接收一个 data 参数,指向 ring buffer 中的数据,size 参数表示数据的大小。函数首先将 data 指针转换为 stacktrace_event 结构体指针,然后检查内核和用户空间栈的大小。如果栈为空,则直接返回。接下来,函数输出进程名称、进程 ID 和 CPU ID 信息。然后分别显示内核栈和用户空间栈的回溯。调用 show_stack_trace() 函数时,分别传入内核栈和用户空间栈的地址、大小和进程 ID。

这两个函数作为 eBPF profile 工具的一部分,用于显示和处理 eBPF 程序收集到的栈回溯信息,帮助用户了解程序的运行情况和性能瓶颈。

总结

通过本篇 eBPF 入门实践教程,我们学习了如何使用 eBPF 程序进行性能分析。在这个过程中,我们详细讲解了如何创建 eBPF 程序,监控进程的性能,并从 ring buffer 中获取数据以分析栈回溯。我们还学习了如何使用 perf_event_open() 函数设置性能监控,并将 BPF 程序附加到性能事件上。在本教程中,我们还展示了如何编写 eBPF 程序来捕获进程的内核和用户空间栈信息,进而分析程序性能瓶颈。通过这个例子,您可以了解到 eBPF 在性能分析方面的强大功能。

统计 TCP 连接延时,并使用 libbpf 在用户态处理数据

eBPF (Extended Berkeley Packet Filter) 是一项强大的网络和性能分析工具,被应用在 Linux 内核上。eBPF 允许开发者动态加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。

本文是 eBPF 入门开发实践教程的第十三篇,主要介绍如何使用 eBPF 统计 TCP 连接延时,并使用 libbpf 在用户态处理数据。

背景

在进行后端开发时,不论使用何种编程语言,我们都常常需要调用 MySQL、Redis 等数据库,或执行一些 RPC 远程调用,或者调用其他的 RESTful API。这些调用的底层,通常都是基于 TCP 协议进行的。原因是 TCP 协议具有可靠连接、错误重传、拥塞控制等优点,因此在网络传输层协议中,TCP 的应用广泛程度超过了 UDP。然而,TCP 也有一些缺点,如建立连接的延时较长。因此,也出现了一些替代方案,例如 QUIC(Quick UDP Internet Connections,快速 UDP 网络连接)。

分析 TCP 连接延时对网络性能分析、优化以及故障排查都非常有用。

tcpconnlat 工具概述

tcpconnlat 这个工具能够跟踪内核中执行活动 TCP 连接的函数(如通过 connect() 系统调用),并测量并显示连接延时,即从发送 SYN 到收到响应包的时间。

TCP 连接原理

TCP 连接的建立过程,常被称为“三次握手”(Three-way Handshake)。以下是整个过程的步骤:

  1. 客户端向服务器发送 SYN 包:客户端通过 connect() 系统调用发出 SYN。这涉及到本地的系统调用以及软中断的 CPU 时间开销。
  2. SYN 包传送到服务器:这是一次网络传输,涉及到的时间取决于网络延迟。
  3. 服务器处理 SYN 包:服务器内核通过软中断接收包,然后将其放入半连接队列,并发送 SYN/ACK 响应。这主要涉及 CPU 时间开销。
  4. SYN/ACK 包传送到客户端:这是另一次网络传输。
  5. 客户端处理 SYN/ACK:客户端内核接收并处理 SYN/ACK 包,然后发送 ACK。这主要涉及软中断处理开销。
  6. ACK 包传送到服务器:这是第三次网络传输。
  7. 服务器接收 ACK:服务器内核接收并处理 ACK,然后将对应的连接从半连接队列移动到全连接队列。这涉及到一次软中断的 CPU 开销。
  8. 唤醒服务器端用户进程:被 accept() 系统调用阻塞的用户进程被唤醒,然后从全连接队列中取出来已经建立好的连接。这涉及一次上下文切换的CPU开销。

完整的流程图如下所示:

tcpconnlat1

在客户端视角,在正常情况下一次TCP连接总的耗时也就就大约是一次网络RTT的耗时。但在某些情况下,可能会导致连接时的网络传输耗时上涨、CPU处理开销增加、甚至是连接失败。这种时候在发现延时过长之后,就可以结合其他信息进行分析。

tcpconnlat 的 eBPF 实现

为了理解 TCP 的连接建立过程,我们需要理解 Linux 内核在处理 TCP 连接时所使用的两个队列:

  • 半连接队列(SYN 队列):存储那些正在进行三次握手操作的 TCP 连接,服务器收到 SYN 包后,会将该连接信息存储在此队列中。
  • 全连接队列(Accept 队列):存储已经完成三次握手,等待应用程序调用 accept() 函数的 TCP 连接。服务器在收到 ACK 包后,会创建一个新的连接并将其添加到此队列。

理解了这两个队列的用途,我们就可以开始探究 tcpconnlat 的具体实现。tcpconnlat 的实现可以分为内核态和用户态两个部分,其中包括了几个主要的跟踪点:tcp_v4_connect, tcp_v6_connecttcp_rcv_state_process

这些跟踪点主要位于内核中的 TCP/IP 网络栈。当执行相关的系统调用或内核函数时,这些跟踪点会被激活,从而触发 eBPF 程序的执行。这使我们能够捕获和测量 TCP 连接建立的整个过程。

让我们先来看一下这些挂载点的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(tcp_v4_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("kprobe/tcp_v6_connect")
int BPF_KPROBE(tcp_v6_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("kprobe/tcp_rcv_state_process")
int BPF_KPROBE(tcp_rcv_state_process, struct sock *sk)
{
return handle_tcp_rcv_state_process(ctx, sk);
}

这段代码展示了三个内核探针(kprobe)的定义。tcp_v4_connecttcp_v6_connect 在对应的 IPv4 和 IPv6 连接被初始化时被触发,调用 trace_connect() 函数,而 tcp_rcv_state_process 在内核处理 TCP 连接状态变化时被触发,调用 handle_tcp_rcv_state_process() 函数。

接下来的部分将分为两大块:一部分是对这些挂载点内核态部分的分析,我们将解读内核源代码来详细说明这些函数如何工作;另一部分是用户态的分析,将关注 eBPF 程序如何收集这些挂载点的数据,以及如何与用户态程序进行交互。

tcp_v4_connect 函数解析

tcp_v4_connect函数是Linux内核处理TCP的IPv4连接请求的主要方式。当用户态程序通过socket系统调用创建了一个套接字后,接着通过connect系统调用尝试连接到远程服务器,此时就会触发tcp_v4_connect函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/* This will initiate an outgoing connection. */
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
struct sockaddr_in *usin = (struct sockaddr_in *)uaddr;
struct inet_timewait_death_row *tcp_death_row;
struct inet_sock *inet = inet_sk(sk);
struct tcp_sock *tp = tcp_sk(sk);
struct ip_options_rcu *inet_opt;
struct net *net = sock_net(sk);
__be16 orig_sport, orig_dport;
__be32 daddr, nexthop;
struct flowi4 *fl4;
struct rtable *rt;
int err;

if (addr_len < sizeof(struct sockaddr_in))
return -EINVAL;

if (usin->sin_family != AF_INET)
return -EAFNOSUPPORT;

nexthop = daddr = usin->sin_addr.s_addr;
inet_opt = rcu_dereference_protected(inet->inet_opt,
lockdep_sock_is_held(sk));
if (inet_opt && inet_opt->opt.srr) {
if (!daddr)
return -EINVAL;
nexthop = inet_opt->opt.faddr;
}

orig_sport = inet->inet_sport;
orig_dport = usin->sin_port;
fl4 = &inet->cork.fl.u.ip4;
rt = ip_route_connect(fl4, nexthop, inet->inet_saddr,
sk->sk_bound_dev_if, IPPROTO_TCP, orig_sport,
orig_dport, sk);
if (IS_ERR(rt)) {
err = PTR_ERR(rt);
if (err == -ENETUNREACH)
IP_INC_STATS(net, IPSTATS_MIB_OUTNOROUTES);
return err;
}

if (rt->rt_flags & (RTCF_MULTICAST | RTCF_BROADCAST)) {
ip_rt_put(rt);
return -ENETUNREACH;
}

if (!inet_opt || !inet_opt->opt.srr)
daddr = fl4->daddr;

tcp_death_row = &sock_net(sk)->ipv4.tcp_death_row;

if (!inet->inet_saddr) {
err = inet_bhash2_update_saddr(sk, &fl4->saddr, AF_INET);
if (err) {
ip_rt_put(rt);
return err;
}
} else {
sk_rcv_saddr_set(sk, inet->inet_saddr);
}

if (tp->rx_opt.ts_recent_stamp && inet->inet_daddr != daddr) {
/* Reset inherited state */
tp->rx_opt.ts_recent = 0;
tp->rx_opt.ts_recent_stamp = 0;
if (likely(!tp->repair))
WRITE_ONCE(tp->write_seq, 0);
}

inet->inet_dport = usin->sin_port;
sk_daddr_set(sk, daddr);

inet_csk(sk)->icsk_ext_hdr_len = 0;
if (inet_opt)
inet_csk(sk)->icsk_ext_hdr_len = inet_opt->opt.optlen;

tp->rx_opt.mss_clamp = TCP_MSS_DEFAULT;

/* Socket identity is still unknown (sport may be zero).
* However we set state to SYN-SENT and not releasing socket
* lock select source port, enter ourselves into the hash tables and
* complete initialization after this.
*/
tcp_set_state(sk, TCP_SYN_SENT);
err = inet_hash_connect(tcp_death_row, sk);
if (err)
goto failure;

sk_set_txhash(sk);

rt = ip_route_newports(fl4, rt, orig_sport, orig_dport,
inet->inet_sport, inet->inet_dport, sk);
if (IS_ERR(rt)) {
err = PTR_ERR(rt);
rt = NULL;
goto failure;
}
/* OK, now commit destination to socket. */
sk->sk_gso_type = SKB_GSO_TCPV4;
sk_setup_caps(sk, &rt->dst);
rt = NULL;

if (likely(!tp->repair)) {
if (!tp->write_seq)
WRITE_ONCE(tp->write_seq,
secure_tcp_seq(inet->inet_saddr,
inet->inet_daddr,
inet->inet_sport,
usin->sin_port));
tp->tsoffset = secure_tcp_ts_off(net, inet->inet_saddr,
inet->inet_daddr);
}

inet->inet_id = get_random_u16();

if (tcp_fastopen_defer_connect(sk, &err))
return err;
if (err)
goto failure;

err = tcp_connect(sk);

if (err)
goto failure;

return 0;

failure:
/*
* This unhashes the socket and releases the local port,
* if necessary.
*/
tcp_set_state(sk, TCP_CLOSE);
inet_bhash2_reset_saddr(sk);
ip_rt_put(rt);
sk->sk_route_caps = 0;
inet->inet_dport = 0;
return err;
}
EXPORT_SYMBOL(tcp_v4_connect);

参考链接:https://elixir.bootlin.com/linux/latest/source/net/ipv4/tcp_ipv4.c#L340

接下来,我们一步步分析这个函数:

首先,这个函数接收三个参数:一个套接字指针sk,一个指向套接字地址结构的指针uaddr和地址的长度addr_len

1
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)

函数一开始就进行了参数检查,确认地址长度正确,而且地址的协议族必须是IPv4。不满足这些条件会导致函数返回错误。

接下来,函数获取目标地址,如果设置了源路由选项(这是一个高级的IP特性,通常不会被使用),那么它还会获取源路由的下一跳地址。

1
2
3
4
5
6
7
8
nexthop = daddr = usin->sin_addr.s_addr;
inet_opt = rcu_dereference_protected(inet->inet_opt,
lockdep_sock_is_held(sk));
if (inet_opt && inet_opt->opt.srr) {
if (!daddr)
return -EINVAL;
nexthop = inet_opt->opt.faddr;
}

然后,使用这些信息来寻找一个路由到目标地址的路由项。如果不能找到路由项或者路由项指向一个多播或广播地址,函数返回错误。

接下来,它更新了源地址,处理了一些TCP时间戳选项的状态,并设置了目标端口和地址。之后,它更新了一些其他的套接字和TCP选项,并设置了连接状态为SYN-SENT

然后,这个函数使用inet_hash_connect函数尝试将套接字添加到已连接的套接字的散列表中。如果这步失败,它会恢复套接字的状态并返回错误。

如果前面的步骤都成功了,接着,使用新的源和目标端口来更新路由项。如果这步失败,它会清理资源并返回错误。

接下来,它提交目标信息到套接字,并为之后的分段偏移选择一个安全的随机值。

然后,函数尝试使用TCP Fast Open(TFO)进行连接,如果不能使用TFO或者TFO尝试失败,它会使用普通的TCP三次握手进行连接。

最后,如果上面的步骤都成功了,函数返回成功,否则,它会清理所有资源并返回错误。

总的来说,tcp_v4_connect函数是一个处理TCP连接请求的复杂函数,它处理了很多情况,包括参数检查、路由查找、源地址选择、源路由、TCP选项处理、TCP Fast Open,等等。它的主要目标是尽可能安全和有效地建立TCP连接。

内核态代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// SPDX-License-Identifier: GPL-2.0
// Copyright (c) 2020 Wenbo Zhang
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>
#include "tcpconnlat.h"

#define AF_INET 2
#define AF_INET6 10

const volatile __u64 targ_min_us = 0;
const volatile pid_t targ_tgid = 0;

struct piddata {
char comm[TASK_COMM_LEN];
u64 ts;
u32 tgid;
};

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 4096);
__type(key, struct sock *);
__type(value, struct piddata);
} start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");

static int trace_connect(struct sock *sk)
{
u32 tgid = bpf_get_current_pid_tgid() >> 32;
struct piddata piddata = {};

if (targ_tgid && targ_tgid != tgid)
return 0;

bpf_get_current_comm(&piddata.comm, sizeof(piddata.comm));
piddata.ts = bpf_ktime_get_ns();
piddata.tgid = tgid;
bpf_map_update_elem(&start, &sk, &piddata, 0);
return 0;
}

static int handle_tcp_rcv_state_process(void *ctx, struct sock *sk)
{
struct piddata *piddatap;
struct event event = {};
s64 delta;
u64 ts;

if (BPF_CORE_READ(sk, __sk_common.skc_state) != TCP_SYN_SENT)
return 0;

piddatap = bpf_map_lookup_elem(&start, &sk);
if (!piddatap)
return 0;

ts = bpf_ktime_get_ns();
delta = (s64)(ts - piddatap->ts);
if (delta < 0)
goto cleanup;

event.delta_us = delta / 1000U;
if (targ_min_us && event.delta_us < targ_min_us)
goto cleanup;
__builtin_memcpy(&event.comm, piddatap->comm,
sizeof(event.comm));
event.ts_us = ts / 1000;
event.tgid = piddatap->tgid;
event.lport = BPF_CORE_READ(sk, __sk_common.skc_num);
event.dport = BPF_CORE_READ(sk, __sk_common.skc_dport);
event.af = BPF_CORE_READ(sk, __sk_common.skc_family);
if (event.af == AF_INET) {
event.saddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
event.daddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_daddr);
} else {
BPF_CORE_READ_INTO(&event.saddr_v6, sk,
__sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
BPF_CORE_READ_INTO(&event.daddr_v6, sk,
__sk_common.skc_v6_daddr.in6_u.u6_addr32);
}
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
&event, sizeof(event));

cleanup:
bpf_map_delete_elem(&start, &sk);
return 0;
}

SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(tcp_v4_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("kprobe/tcp_v6_connect")
int BPF_KPROBE(tcp_v6_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("kprobe/tcp_rcv_state_process")
int BPF_KPROBE(tcp_rcv_state_process, struct sock *sk)
{
return handle_tcp_rcv_state_process(ctx, sk);
}

SEC("fentry/tcp_v4_connect")
int BPF_PROG(fentry_tcp_v4_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("fentry/tcp_v6_connect")
int BPF_PROG(fentry_tcp_v6_connect, struct sock *sk)
{
return trace_connect(sk);
}

SEC("fentry/tcp_rcv_state_process")
int BPF_PROG(fentry_tcp_rcv_state_process, struct sock *sk)
{
return handle_tcp_rcv_state_process(ctx, sk);
}

char LICENSE[] SEC("license") = "GPL";

这个eBPF(Extended Berkeley Packet Filter)程序主要用来监控并收集TCP连接的建立时间,即从发起TCP连接请求(connect系统调用)到连接建立完成(SYN-ACK握手过程完成)的时间间隔。这对于监测网络延迟、服务性能分析等方面非常有用。

首先,定义了两个eBPF maps:starteventsstart是一个哈希表,用于存储发起连接请求的进程信息和时间戳,而events是一个PERF_EVENT_ARRAY类型的map,用于将事件数据传输到用户态。

1
2
3
4
5
6
7
8
9
10
11
12
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 4096);
__type(key, struct sock *);
__type(value, struct piddata);
} start SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");

tcp_v4_connecttcp_v6_connect的kprobe处理函数trace_connect中,会记录下发起连接请求的进程信息(进程名、进程ID和当前时间戳),并以socket结构作为key,存储到start这个map中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int trace_connect(struct sock *sk)
{
u32 tgid = bpf_get_current_pid_tgid() >> 32;
struct piddata piddata = {};

if (targ_tgid && targ_tgid != tgid)
return 0;

bpf_get_current_comm(&piddata.comm, sizeof(piddata.comm));
piddata.ts = bpf_ktime_get_ns();
piddata.tgid = tgid;
bpf_map_update_elem(&start, &sk, &piddata, 0);
return 0;
}

当TCP状态机处理到SYN-ACK包,即连接建立的时候,会触发tcp_rcv_state_process的kprobe处理函数handle_tcp_rcv_state_process。在这个函数中,首先检查socket的状态是否为SYN-SENT,如果是,会从start这个map中查找socket对应的进程信息。然后计算出从发起连接到现在的时间间隔,将该时间间隔,进程信息,以及TCP连接的详细信息(源端口,目标端口,源IP,目标IP等)作为event,通过bpf_perf_event_output函数发送到用户态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
static int handle_tcp_rcv_state_process(void *ctx, struct sock *sk)
{
struct piddata *piddatap;
struct event event = {};
s64 delta;
u64 ts;

if (BPF_CORE_READ(sk, __sk_common.skc_state) != TCP_SYN_SENT)
return 0;

piddatap = bpf_map_lookup_elem(&start, &sk);
if (!piddatap)
return 0;

ts = bpf_ktime_get_ns();
delta = (s64)(ts - piddatap->ts);
if (delta < 0)
goto cleanup;

event.delta_us = delta / 1000U;
if (targ_min_us && event.delta_us < targ_min_us)
goto

cleanup;
__builtin_memcpy(&event.comm, piddatap->comm,
sizeof(event.comm));
event.ts_us = ts / 1000;
event.tgid = piddatap->tgid;
event.lport = BPF_CORE_READ(sk, __sk_common.skc_num);
event.dport = BPF_CORE_READ(sk, __sk_common.skc_dport);
event.af = BPF_CORE_READ(sk, __sk_common.skc_family);
if (event.af == AF_INET) {
event.saddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
event.daddr_v4 = BPF_CORE_READ(sk, __sk_common.skc_daddr);
} else {
BPF_CORE_READ_INTO(&event.saddr_v6, sk,
__sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
BPF_CORE_READ_INTO(&event.daddr_v6, sk,
__sk_common.skc_v6_daddr.in6_u.u6_addr32);
}
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
&event, sizeof(event));

cleanup:
bpf_map_delete_elem(&start, &sk);
return 0;
}

理解这个程序的关键在于理解Linux内核的网络栈处理流程,以及eBPF程序的运行模式。Linux内核网络栈对TCP连接建立的处理过程是,首先调用tcp_v4_connecttcp_v6_connect函数(根据IP版本不同)发起TCP连接,然后在收到SYN-ACK包时,通过tcp_rcv_state_process函数来处理。eBPF程序通过在这两个关键函数上设置kprobe,可以在关键时刻得到通知并执行相应的处理代码。

一些关键概念说明:

  • kprobe:Kernel Probe,是Linux内核中用于动态追踪内核行为的机制。可以在内核函数的入口和退出处设置断点,当断点被触发时,会执行与kprobe关联的eBPF程序。
  • map:是eBPF程序中的一种数据结构,用于在内核态和用户态之间共享数据。
  • socket:在Linux网络编程中,socket是一个抽象概念,表示一个网络连接的端点。内核中的struct sock结构就是对socket的实现。

用户态数据处理

用户态数据处理是使用perf_buffer__poll来接收并处理从内核发送到用户态的eBPF事件。perf_buffer__poll是libbpf库提供的一个便捷函数,用于轮询perf event buffer并处理接收到的数据。

首先,让我们详细看一下主轮询循环:

1
2
3
4
5
6
7
8
9
10
/* main: poll */
while (!exiting) {
err = perf_buffer__poll(pb, PERF_POLL_TIMEOUT_MS);
if (err < 0 && err != -EINTR) {
fprintf(stderr, "error polling perf buffer: %s\n", strerror(-err));
goto cleanup;
}
/* reset err to return 0 if exiting */
err = 0;
}

这段代码使用一个while循环来反复轮询perf event buffer。如果轮询出错(例如由于信号中断),会打印出错误消息。这个轮询过程会一直持续,直到收到一个退出标志exiting

接下来,让我们来看看handle_event函数,这个函数将处理从内核发送到用户态的每一个eBPF事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void handle_event(void* ctx, int cpu, void* data, __u32 data_sz) {
const struct event* e = data;
char src[INET6_ADDRSTRLEN];
char dst[INET6_ADDRSTRLEN];
union {
struct in_addr x4;
struct in6_addr x6;
} s, d;
static __u64 start_ts;

if (env.timestamp) {
if (start_ts == 0)
start_ts = e->ts_us;
printf("%-9.3f ", (e->ts_us - start_ts) / 1000000.0);
}
if (e->af == AF_INET) {
s.x4.s_addr = e->saddr_v4;
d.x4.s_addr = e->daddr_v4;
} else if (e->af == AF_INET6) {
memcpy(&s.x6.s6_addr, e->saddr_v6, sizeof(s.x6.s6_addr));
memcpy(&d.x6.s6_addr, e->daddr_v6, sizeof(d.x6.s6_addr));
} else {
fprintf(stderr, "broken event: event->af=%d", e->af);
return;
}

if (env.lport) {
printf("%-6d %-12.12s %-2d %-16s %-6d %-16s %-5d %.2f\n", e->tgid,
e->comm, e->af == AF_INET ? 4 : 6,
inet_ntop(e->af, &s, src, sizeof(src)), e->lport,
inet_ntop(e->af, &d, dst, sizeof(dst)), ntohs(e->dport),
e->delta_us / 1000.0);
} else {
printf("%-6d %-12.12s %-2d %-16s %-16s %-5d %.2f\n", e->tgid, e->comm,
e->af == AF_INET ? 4 : 6, inet_ntop(e->af, &s, src, sizeof(src)),
inet_ntop(e->af, &d, dst, sizeof(dst)), ntohs(e->dport),
e->delta_us / 1000.0);
}
}

handle_event函数的参数包括了CPU编号、指向数据的指针以及数据的大小。数据是一个event结构体,包含了之前在内核态计算得到的TCP连接的信息。

首先,它将接收到的事件的时间戳和起始时间戳(如果存在)进行对比,计算出事件的相对时间,并打印出来。接着,根据IP地址的类型(IPv4或IPv6),将源地址和目标地址从网络字节序转换为主机字节序。

最后,根据用户是否选择了显示本地端口,将进程ID、进程名称、IP版本、源IP地址、本地端口(如果有)、目标IP地址、目标端口以及连接建立时间打印出来。这个连接建立时间是我们在内核态eBPF程序中计算并发送到用户态的。

编译运行

1
2
3
4
5
6
7
8
9
10
11
12
$ make
...
BPF .output/tcpconnlat.bpf.o
GEN-SKEL .output/tcpconnlat.skel.h
CC .output/tcpconnlat.o
BINARY tcpconnlat
$ sudo ./tcpconnlat
PID COMM IP SADDR DADDR DPORT LAT(ms)
222564 wget 4 192.168.88.15 110.242.68.3 80 25.29
222684 wget 4 192.168.88.15 167.179.101.42 443 246.76
222726 ssh 4 192.168.88.15 167.179.101.42 22 241.17
222774 ssh 4 192.168.88.15 1.15.149.151 22 25.31

源代码:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/13-tcpconnlat

总结

通过本篇 eBPF 入门实践教程,我们学习了如何使用 eBPF 来跟踪和统计 TCP 连接建立的延时。我们首先深入探讨了 eBPF 程序如何在内核态监听特定的内核函数,然后通过捕获这些函数的调用,从而得到连接建立的起始时间和结束时间,计算出延时。

我们还进一步了解了如何使用 BPF maps 来在内核态存储和查询数据,从而在 eBPF 程序的多个部分之间共享数据。同时,我们也探讨了如何使用 perf events 来将数据从内核态发送到用户态,以便进一步处理和展示。

在用户态,我们介绍了如何使用 libbpf 库的 API,例如 perf_buffer__poll,来接收和处理内核态发送过来的数据。我们还讲解了如何对这些数据进行解析和打印,使得它们能以人类可读的形式显示出来。

记录 TCP 连接状态与 TCP RTT

eBPF (扩展的伯克利数据包过滤器) 是一项强大的网络和性能分析工具,被广泛应用在 Linux 内核上。eBPF 使得开发者能够动态地加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。

在我们的 eBPF 入门实践教程系列的这一篇,我们将介绍两个示例程序:tcpstatestcprtttcpstates 用于记录 TCP 连接的状态变化,而 tcprtt 则用于记录 TCP 的往返时间 (RTT, Round-Trip Time)。

tcprtttcpstates

网络质量在当前的互联网环境中至关重要。影响网络质量的因素有许多,包括硬件、网络环境、软件编程的质量等。为了帮助用户更好地定位网络问题,我们引入了 tcprtt 这个工具。tcprtt 可以监控 TCP 链接的往返时间,从而评估网络质量,帮助用户找出可能的问题所在。

当 TCP 链接建立时,tcprtt 会自动根据当前系统的状况,选择合适的执行函数。在执行函数中,tcprtt 会收集 TCP 链接的各项基本信息,如源地址、目标地址、源端口、目标端口、耗时等,并将这些信息更新到直方图型的 BPF map 中。运行结束后,tcprtt 会通过用户态代码,将收集的信息以图形化的方式展示给用户。

tcpstates 则是一个专门用来追踪和打印 TCP 连接状态变化的工具。它可以显示 TCP 连接在每个状态中的停留时长,单位为毫秒。例如,对于一个单独的 TCP 会话,tcpstates 可以打印出类似以下的输出:

1
2
3
4
5
6
7
8
SKADDR           C-PID C-COMM     LADDR           LPORT RADDR           RPORT OLDSTATE    -> NEWSTATE    MS
ffff9fd7e8192000 22384 curl 100.66.100.185 0 52.33.159.26 80 CLOSE -> SYN_SENT 0.000
ffff9fd7e8192000 0 swapper/5 100.66.100.185 63446 52.33.159.26 80 SYN_SENT -> ESTABLISHED 1.373
ffff9fd7e8192000 22384 curl 100.66.100.185 63446 52.33.159.26 80 ESTABLISHED -> FIN_WAIT1 176.042
ffff9fd7e819

2000 0 swapper/5 100.66.100.185 63446 52.33.159.26 80 FIN_WAIT1 -> FIN_WAIT2 0.536
ffff9fd7e8192000 0 swapper/5 100.66.100.185 63446 52.33.159.26 80 FIN_WAIT2 -> CLOSE 0.006

以上输出中,最多的时间被花在了 ESTABLISHED 状态,也就是连接已经建立并在传输数据的状态,这个状态到 FIN_WAIT1 状态(开始关闭连接的状态)的转变过程中耗费了 176.042 毫秒。

在我们接下来的教程中,我们会更深入地探讨这两个工具,解释它们的实现原理,希望这些内容对你在使用 eBPF 进行网络和性能分析方面的工作有所帮助。

tcpstate

由于篇幅所限,这里我们主要讨论和分析对应的 eBPF 内核态代码实现。以下是 tcpstate 的 eBPF 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
const volatile bool filter_by_sport = false;
const volatile bool filter_by_dport = false;
const volatile short target_family = 0;

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, __u16);
__type(value, __u16);
} sports SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, __u16);
__type(value, __u16);
} dports SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, struct sock *);
__type(value, __u64);
} timestamps SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(__u32));
__uint(value_size, sizeof(__u32));
} events SEC(".maps");

SEC("tracepoint/sock/inet_sock_set_state")
int handle_set_state(struct trace_event_raw_inet_sock_set_state *ctx)
{
struct sock *sk = (struct sock *)ctx->skaddr;
__u16 family = ctx->family;
__u16 sport = ctx->sport;
__u16 dport = ctx->dport;
__u64 *tsp, delta_us, ts;
struct event event = {};

if (ctx->protocol != IPPROTO_TCP)
return 0;

if (target_family && target_family != family)
return 0;

if (filter_by_sport && !bpf_map_lookup_elem(&sports, &sport))
return 0;

if (filter_by_dport && !bpf_map_lookup_elem(&dports, &dport))
return 0;

tsp = bpf_map_lookup_elem(&timestamps, &sk);
ts = bpf_ktime_get_ns();
if (!tsp)
delta_us = 0;
else
delta_us = (ts - *tsp) / 1000;

event.skaddr = (__u64)sk;
event.ts_us = ts / 1000;
event.delta_us = delta_us;
event.pid = bpf_get_current_pid_tgid() >> 32;
event.oldstate = ctx->oldstate;
event.newstate = ctx->newstate;
event.family = family;
event.sport = sport;
event.dport = dport;
bpf_get_current_comm(&event.task, sizeof(event.task));

if (family == AF_INET) {
bpf_probe_read_kernel(&event.saddr, sizeof(event.saddr), &sk->__sk_common.skc_rcv_saddr);
bpf_probe_read_kernel(&event.daddr, sizeof(event.daddr), &sk->__sk_common.skc_daddr);
} else { /* family == AF_INET6 */
bpf_probe_read_kernel(&event.saddr, sizeof(event.saddr), &sk->__sk_common.skc_v6_rcv_saddr.in6_u.u6_addr32);
bpf_probe_read_kernel(&event.daddr, sizeof(event.daddr), &sk->__sk_common.skc_v6_daddr.in6_u.u6_addr32);
}

bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));

if (ctx->newstate == TCP_CLOSE)
bpf_map_delete_elem(&timestamps, &sk);
else
bpf_map_update_elem(&timestamps, &sk, &ts, BPF_ANY);

return 0;
}

tcpstates主要依赖于 eBPF 的 Tracepoints 来捕获 TCP 连接的状态变化,从而跟踪 TCP 连接在每个状态下的停留时间。

定义 BPF Maps

tcpstates程序中,首先定义了几个 BPF Maps,它们是 eBPF 程序和用户态程序之间交互的主要方式。sportsdports分别用于存储源端口和目标端口,用于过滤 TCP 连接;timestamps用于存储每个 TCP 连接的时间戳,以计算每个状态的停留时间;events则是一个 perf_event 类型的 map,用于将事件数据发送到用户态。

追踪 TCP 连接状态变化

程序定义了一个名为handle_set_state的函数,该函数是一个 tracepoint 类型的程序,它将被挂载到sock/inet_sock_set_state这个内核 tracepoint 上。每当 TCP 连接状态发生变化时,这个 tracepoint 就会被触发,然后执行handle_set_state函数。

handle_set_state函数中,首先通过一系列条件判断确定是否需要处理当前的 TCP 连接,然后从timestampsmap 中获取当前连接的上一个时间戳,然后计算出停留在当前状态的时间。接着,程序将收集到的数据放入一个 event 结构体中,并通过bpf_perf_event_output函数将该 event 发送到用户态。

更新时间戳

最后,根据 TCP 连接的新状态,程序将进行不同的操作:如果新状态为 TCP_CLOSE,表示连接已关闭,程序将从timestampsmap 中删除该连接的时间戳;否则,程序将更新该连接的时间戳。

用户态的部分主要是通过 libbpf 来加载 eBPF 程序,然后通过 perf_event 来接收内核中的事件数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static void handle_event(void* ctx, int cpu, void* data, __u32 data_sz) {
char ts[32], saddr[26], daddr[26];
struct event* e = data;
struct tm* tm;
int family;
time_t t;

if (emit_timestamp) {
time(&t);
tm = localtime(&t);
strftime(ts, sizeof(ts), "%H:%M:%S", tm);
printf("%8s ", ts);
}

inet_ntop(e->family, &e->saddr, saddr, sizeof(saddr));
inet_ntop(e->family, &e->daddr, daddr, sizeof(daddr));
if (wide_output) {
family = e->family == AF_INET ? 4 : 6;
printf(
"%-16llx %-7d %-16s %-2d %-26s %-5d %-26s %-5d %-11s -> %-11s "
"%.3f\n",
e->skaddr, e->pid, e->task, family, saddr, e->sport, daddr,
e->dport, tcp_states[e->oldstate], tcp_states[e->newstate],
(double)e->delta_us / 1000);
} else {
printf(
"%-16llx %-7d %-10.10s %-15s %-5d %-15s %-5d %-11s -> %-11s %.3f\n",
e->skaddr, e->pid, e->task, saddr, e->sport, daddr, e->dport,
tcp_states[e->oldstate], tcp_states[e->newstate],
(double)e->delta_us / 1000);
}
}

handle_event就是这样一个回调函数,它会被 perf_event 调用,每当内核有新的事件到达时,它就会处理这些事件。

handle_event函数中,我们首先通过inet_ntop函数将二进制的 IP 地址转换成人类可读的格式,然后根据是否需要输出宽格式,分别打印不同的信息。这些信息包括了事件的时间戳、源 IP 地址、源端口、目标 IP 地址、目标端口、旧状态、新状态以及在旧状态停留的时间。

这样,用户就可以清晰地看到 TCP 连接状态的变化,以及每个状态的停留时间,从而帮助他们诊断网络问题。

总结起来,用户态部分的处理主要涉及到了以下几个步骤:

  1. 使用 libbpf 加载并运行 eBPF 程序。
  2. 设置回调函数来接收内核发送的事件。
  3. 处理接收到的事件,将其转换成人类可读的格式并打印。

以上就是tcpstates程序用户态部分的主要实现逻辑。通过这一章的学习,你应该已经对如何在用户态处理内核事件有了更深入的理解。在下一章中,我们将介绍更多关于如何使用 eBPF 进行网络监控的知识。

tcprtt

在本章节中,我们将分析tcprtt eBPF 程序的内核态代码。tcprtt是一个用于测量 TCP 往返时间(Round Trip Time, RTT)的程序,它将 RTT 的信息统计到一个 histogram 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/// @sample {"interval": 1000, "type" : "log2_hist"}
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_ENTRIES);
__type(key, u64);
__type(value, struct hist);
} hists SEC(".maps");

static struct hist zero;

SEC("fentry/tcp_rcv_established")
int BPF_PROG(tcp_rcv, struct sock *sk)
{
const struct inet_sock *inet = (struct inet_sock *)(sk);
struct tcp_sock *ts;
struct hist *histp;
u64 key, slot;
u32 srtt;

if (targ_sport && targ_sport != inet->inet_sport)
return 0;
if (targ_dport && targ_dport != sk->__sk_common.skc_dport)
return 0;
if (targ_saddr && targ_saddr != inet->inet_saddr)
return 0;
if (targ_daddr && targ_daddr != sk->__sk_common.skc_daddr)
return 0;

if (targ_laddr_hist)
key = inet->inet_saddr;
else if (targ_raddr_hist)
key = inet->sk.__sk_common.skc_daddr;
else
key = 0;
histp = bpf_map_lookup_or_try_init(&hists, &key, &zero);
if (!histp)
return 0;
ts = (struct tcp_sock *)(sk);
srtt = BPF_CORE_READ(ts, srtt_us) >> 3;
if (targ_ms)
srtt /= 1000U;
slot = log2l(srtt);
if (slot >= MAX_SLOTS)
slot = MAX_SLOTS - 1;
__sync_fetch_and_add(&histp->slots[slot], 1);
if (targ_show_ext) {
__sync_fetch_and_add(&histp->latency, srtt);
__sync_fetch_and_add(&histp->cnt, 1);
}
return 0;
}

首先,我们定义了一个 hash 类型的 eBPF map,名为hists,它用来存储 RTT 的统计信息。在这个 map 中,键是 64 位整数,值是一个hist结构,这个结构包含了一个数组,用来存储不同 RTT 区间的数量。

接着,我们定义了一个 eBPF 程序,名为tcp_rcv,这个程序会在每次内核中处理 TCP 收包的时候被调用。在这个程序中,我们首先根据过滤条件(源/目标 IP 地址和端口)对 TCP 连接进行过滤。如果满足条件,我们会根据设置的参数选择相应的 key(源 IP 或者目标 IP 或者 0),然后在hists map 中查找或者初始化对应的 histogram。

接下来,我们读取 TCP 连接的srtt_us字段,这个字段表示了平滑的 RTT 值,单位是微秒。然后我们将这个 RTT 值转换为对数形式,并将其作为 slot 存储到 histogram 中。

如果设置了show_ext参数,我们还会将 RTT 值和计数器累加到 histogram 的latencycnt字段中。

通过以上的处理,我们可以对每个 TCP 连接的 RTT 进行统计和分析,从而更好地理解网络的性能状况。

总结起来,tcprtt eBPF 程序的主要逻辑包括以下几个步骤:

  1. 根据过滤条件对 TCP 连接进行过滤。
  2. hists map 中查找或者初始化对应的 histogram。
  3. 读取 TCP 连接的srtt_us字段,并将其转换为对数形式,存储到 histogram 中。
  4. 如果设置了show_ext参数,将 RTT 值和计数器累加到 histogram 的latencycnt字段中。

tcprtt 挂载到了内核态的 tcp_rcv_established 函数上:

1
void tcp_rcv_established(struct sock *sk, struct sk_buff *skb);

这个函数是在内核中处理TCP接收数据的主要函数,主要在TCP连接处于ESTABLISHED状态时被调用。这个函数的处理逻辑包括一个快速路径和一个慢速路径。快速路径在以下几种情况下会被禁用:

  • 我们宣布了一个零窗口 - 零窗口探测只能在慢速路径中正确处理。
  • 收到了乱序的数据包。
  • 期待接收紧急数据。
  • 没有剩余的缓冲区空间。
  • 接收到了意外的TCP标志/窗口值/头部长度(通过检查TCP头部与预设标志进行检测)。
  • 数据在两个方向上都在传输。快速路径只支持纯发送者或纯接收者(这意味着序列号或确认值必须保持不变)。
  • 接收到了意外的TCP选项。

当这些条件不满足时,它会进入一个标准的接收处理过程,这个过程遵循RFC793来处理所有情况。前三种情况可以通过正确的预设标志设置来保证,剩下的情况则需要内联检查。当一切都正常时,快速处理过程会在tcp_data_queue函数中被开启。

编译运行

对于 tcpstates,可以通过以下命令编译和运行 libbpf 应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ make
...
BPF .output/tcpstates.bpf.o
GEN-SKEL .output/tcpstates.skel.h
CC .output/tcpstates.o
BINARY tcpstates
$ sudo ./tcpstates
SKADDR PID COMM LADDR LPORT RADDR RPORT OLDSTATE -> NEWSTATE MS
ffff9bf61bb62bc0 164978 node 192.168.88.15 0 52.178.17.2 443 CLOSE -> SYN_SENT 0.000
ffff9bf61bb62bc0 0 swapper/0 192.168.88.15 41596 52.178.17.2 443 SYN_SENT -> ESTABLISHED 225.794
ffff9bf61bb62bc0 0 swapper/0 192.168.88.15 41596 52.178.17.2 443 ESTABLISHED -> CLOSE_WAIT 901.454
ffff9bf61bb62bc0 164978 node 192.168.88.15 41596 52.178.17.2 443 CLOSE_WAIT -> LAST_ACK 0.793
ffff9bf61bb62bc0 164978 node 192.168.88.15 41596 52.178.17.2 443 LAST_ACK -> LAST_ACK 0.086
ffff9bf61bb62bc0 228759 kworker/u6 192.168.88.15 41596 52.178.17.2 443 LAST_ACK -> CLOSE 0.193
ffff9bf6d8ee88c0 229832 redis-serv 0.0.0.0 6379 0.0.0.0 0 CLOSE -> LISTEN 0.000
ffff9bf6d8ee88c0 229832 redis-serv 0.0.0.0 6379 0.0.0.0 0 LISTEN -> CLOSE 1.763
ffff9bf7109d6900 88750 node 127.0.0.1 39755 127.0.0.1 50966 ESTABLISHED -> FIN_WAIT1 0.000

对于 tcprtt,我们可以使用 eunomia-bpf 编译运行这个例子:

Compile:

1
docker run -it -v `pwd`/:/src/ ghcr.io/eunomia-bpf/ecc-`uname -m`:latest

或者

1
2
3
4
$ ecc runqlat.bpf.c runqlat.h
Compiling bpf object...
Generating export types...
Packing ebpf object and config into package.json...

运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
$ sudo ecli run package.json -h
A simple eBPF program


Usage: package.json [OPTIONS]

Options:
--verbose Whether to show libbpf debug information
--targ_laddr_hist Set value of `bool` variable targ_laddr_hist
--targ_raddr_hist Set value of `bool` variable targ_raddr_hist
--targ_show_ext Set value of `bool` variable targ_show_ext
--targ_sport <targ_sport> Set value of `__u16` variable targ_sport
--targ_dport <targ_dport> Set value of `__u16` variable targ_dport
--targ_saddr <targ_saddr> Set value of `__u32` variable targ_saddr
--targ_daddr <targ_daddr> Set value of `__u32` variable targ_daddr
--targ_ms Set value of `bool` variable targ_ms
-h, --help Print help
-V, --version Print version

Built with eunomia-bpf framework.
See https://github.com/eunomia-bpf/eunomia-bpf for more information.

$ sudo ecli run package.json
key = 0
latency = 0
cnt = 0

(unit) : count distribution
0 -> 1 : 0 | |
2 -> 3 : 0 | |
4 -> 7 : 0 | |
8 -> 15 : 0 | |
16 -> 31 : 0 | |
32 -> 63 : 0 | |
64 -> 127 : 0 | |
128 -> 255 : 0 | |
256 -> 511 : 0 | |
512 -> 1023 : 4 |******************** |
1024 -> 2047 : 1 |***** |
2048 -> 4095 : 0 | |
4096 -> 8191 : 8 |****************************************|

key = 0
latency = 0
cnt = 0

(unit) : count distribution
0 -> 1 : 0 | |
2 -> 3 : 0 | |
4 -> 7 : 0 | |
8 -> 15 : 0 | |
16 -> 31 : 0 | |
32 -> 63 : 0 | |
64 -> 127 : 0 | |
128 -> 255 : 0 | |
256 -> 511 : 0 | |
512 -> 1023 : 11 |*************************** |
1024 -> 2047 : 1 |** |
2048 -> 4095 : 0 | |
4096 -> 8191 : 16 |****************************************|
8192 -> 16383 : 4 |********** |

总结

通过本篇 eBPF 入门实践教程,我们学习了如何使用tcpstates和tcprtt这两个 eBPF 示例程序,监控和分析 TCP 的连接状态和往返时间。我们了解了tcpstates和tcprtt的工作原理和实现方式,包括如何使用 BPF map 存储数据,如何在 eBPF 程序中获取和处理 TCP 连接信息,以及如何在用户态应用程序中解析和显示 eBPF 程序收集的数据。

使用 USDT 捕获用户态 Java GC 事件耗时

eBPF (扩展的伯克利数据包过滤器) 是一项强大的网络和性能分析工具,被广泛应用在 Linux 内核上。eBPF 使得开发者能够动态地加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。这个特性使得 eBPF 能够提供极高的灵活性和性能,使其在网络和系统性能分析方面具有广泛的应用。此外,eBPF 还支持使用 USDT (用户级静态定义跟踪点) 捕获用户态的应用程序行为。

在我们的 eBPF 入门实践教程系列的这一篇,我们将介绍如何使用 eBPF 和 USDT 来捕获和分析 Java 的垃圾回收 (GC) 事件的耗时。

USDT 介绍

USDT 是一种在应用程序中插入静态跟踪点的机制,它允许开发者在程序的关键位置插入可用于调试和性能分析的探针。这些探针可以在运行时被 DTrace、SystemTap 或 eBPF 等工具动态激活,从而在不重启应用程序或更改程序代码的情况下,获取程序的内部状态和性能指标。USDT 在很多开源软件,如 MySQL、PostgreSQL、Ruby、Python 和 Node.js 等都有广泛的应用。

用户层面的追踪机制:用户级动态跟踪和 USDT

在用户层面进行动态跟踪,即用户级动态跟踪(User-Level Dynamic Tracing)允许我们对任何用户级别的代码进行插桩。比如,我们可以通过在 MySQL 服务器的 dispatch_command() 函数上进行插桩,来跟踪服务器的查询请求:

1
2
3
4
5
# ./uprobe 'p:cmd /opt/bin/mysqld:_Z16dispatch_command19enum_server_commandP3THDPcj +0(%dx):string'
Tracing uprobe cmd (p:cmd /opt/bin/mysqld:0x2dbd40 +0(%dx):string). Ctrl-C to end.
mysqld-2855 [001] d... 19957757.590926: cmd: (0x6dbd40) arg1="show tables"
mysqld-2855 [001] d... 19957759.703497: cmd: (0x6dbd40) arg1="SELECT * FROM numbers"
[...]

这里我们使用了 uprobe 工具,它利用了 Linux 的内置功能:ftrace(跟踪器)和 uprobes(用户级动态跟踪,需要较新的 Linux 版本,例如 4.0 左右)。其他的跟踪器,如 perf_events 和 SystemTap,也可以实现此功能。

许多其他的 MySQL 函数也可以被跟踪以获取更多的信息。我们可以列出和计算这些函数的数量:

1
2
3
4
5
6
7
8
9
# ./uprobe -l /opt/bin/mysqld | more
account_hash_get_key
add_collation
add_compiled_collation
add_plugin_noargs
adjust_time_range
[...]
# ./uprobe -l /opt/bin/mysqld | wc -l
21809

这有 21,000 个函数。我们也可以跟踪库函数,甚至是单个的指令偏移。

用户级动态跟踪的能力是非常强大的,它可以解决无数的问题。然而,使用它也有一些困难:需要确定需要跟踪的代码,处理函数参数,以及应对代码的更改。

用户级静态定义跟踪(User-level Statically Defined Tracing, USDT)则可以在某种程度上解决这些问题。USDT 探针(或者称为用户级 “marker”)是开发者在代码的关键位置插入的跟踪宏,提供稳定且已经过文档说明的 API。这使得跟踪工作变得更加简单。

使用 USDT,我们可以简单地跟踪一个名为 mysql:query__start 的探针,而不是去跟踪那个名为 _Z16dispatch_command19enum_server_commandP3THDPcj 的 C++ 符号,也就是 dispatch_command() 函数。当然,我们仍然可以在需要的时候去跟踪 dispatch_command() 以及

其他 21,000 个 mysqld 函数,但只有当 USDT 探针无法解决问题的时候我们才需要这么做。

在 Linux 中的 USDT,无论是哪种形式的静态跟踪点,其实都已经存在了几十年。它最近由于 Sun 的 DTrace 工具的流行而再次受到关注,这使得许多常见的应用程序,包括 MySQL、PostgreSQL、Node.js、Java 等都加入了 USDT。SystemTap 则开发了一种可以消费这些 DTrace 探针的方式。

你可能正在运行一个已经包含了 USDT 探针的 Linux 应用程序,或者可能需要重新编译(通常是 —enable-dtrace)。你可以使用 readelf 来进行检查,例如对于 Node.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# readelf -n node
[...]
Notes at offset 0x00c43058 with length 0x00000494:
Owner Data size Description
stapsdt 0x0000003c NT_STAPSDT (SystemTap probe descriptors)
Provider: node
Name: gc__start
Location: 0x0000000000bf44b4, Base: 0x0000000000f22464, Semaphore: 0x0000000001243028
Arguments: 4@%esi 4@%edx 8@%rdi
[...]
stapsdt 0x00000082 NT_STAPSDT (SystemTap probe descriptors)
Provider: node
Name: http__client__request
Location: 0x0000000000bf48ff, Base: 0x0000000000f22464, Semaphore: 0x0000000001243024
Arguments: 8@%rax 8@%rdx 8@-136(%rbp) -4@-140(%rbp) 8@-72(%rbp) 8@-80(%rbp) -4@-144(%rbp)
[...]

这就是使用 —enable-dtrace 重新编译的 node,以及安装了提供 “dtrace” 功能来构建 USDT 支持的 systemtap-sdt-dev 包。这里显示了两个探针:node:gc__start(开始进行垃圾回收)和 node:http__client__request

在这一点上,你可以使用 SystemTap 或者 LTTng 来跟踪这些探针。然而,内置的 Linux 跟踪器,比如 ftrace 和 perf_events,目前还无法做到这一点(尽管 perf_events 的支持正在开发中)。

Java GC 介绍

Java 作为一种高级编程语言,其自动垃圾回收(GC)是其核心特性之一。Java GC 的目标是自动地回收那些不再被程序使用的内存空间,从而减轻程序员在内存管理方面的负担。然而,GC 过程可能会引发应用程序的停顿,对程序的性能和响应时间产生影响。因此,对 Java GC 事件进行监控和分析,对于理解和优化 Java 应用的性能是非常重要的。

在接下来的教程中,我们将演示如何使用 eBPF 和 USDT 来监控和分析 Java GC 事件的耗时,希望这些内容对你在使用 eBPF 进行应用性能分析方面的工作有所帮助。

eBPF 实现机制

Java GC 的 eBPF 程序分为内核态和用户态两部分,我们会分别介绍这两部分的实现机制。

内核态程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
/* Copyright (c) 2022 Chen Tao */
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/usdt.bpf.h>
#include "javagc.h"

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 100);
__type(key, uint32_t);
__type(value, struct data_t);
} data_map SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__type(key, int);
__type(value, int);
} perf_map SEC(".maps");

__u32 time;

static int gc_start(struct pt_regs *ctx)
{
struct data_t data = {};

data.cpu = bpf_get_smp_processor_id();
data.pid = bpf_get_current_pid_tgid() >> 32;
data.ts = bpf_ktime_get_ns();
bpf_map_update_elem(&data_map, &data.pid, &data, 0);
return 0;
}

static int gc_end(struct pt_regs *ctx)
{
struct data_t data = {};
struct data_t *p;
__u32 val;

data.cpu = bpf_get_smp_processor_id();
data.pid = bpf_get_current_pid_tgid() >> 32;
data.ts = bpf_ktime_get_ns();
p = bpf_map_lookup_elem(&data_map, &data.pid);
if (!p)
return 0;

val = data.ts - p->ts;
if (val > time) {
data.ts = val;
bpf_perf_event_output(ctx, &perf_map, BPF_F_CURRENT_CPU, &data, sizeof(data));
}
bpf_map_delete_elem(&data_map, &data.pid);
return 0;
}

SEC("usdt")
int handle_gc_start(struct pt_regs *ctx)
{
return gc_start(ctx);
}

SEC("usdt")
int handle_gc_end(struct pt_regs *ctx)
{
return gc_end(ctx);
}

SEC("usdt")
int handle_mem_pool_gc_start(struct pt_regs *ctx)
{
return gc_start(ctx);
}

SEC("usdt")
int handle_mem_pool_gc_end(struct pt_regs *ctx)
{
return gc_end(ctx);
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

首先,我们定义了两个映射(map):

  • data_map:这个 hashmap 存储每个进程 ID 的垃圾收集开始时间。data_t 结构体包含进程 ID、CPU ID 和时间戳。
  • perf_map:这是一个 perf event array,用于将数据发送回用户态程序。

然后,我们有四个处理函数:gc_startgc_end 和两个 USDT 处理函数 handle_mem_pool_gc_starthandle_mem_pool_gc_end。这些函数都用 BPF 的 SEC("usdt") 宏注解,以便在 Java 进程中捕获到与垃圾收集相关的 USDT 事件。

gc_start 函数在垃圾收集开始时被调用。它首先获取当前的 CPU ID、进程 ID 和时间戳,然后将这些数据存入 data_map

gc_end 函数在垃圾收集结束时被调用。它执行与 gc_start 类似的操作,但是它还从 data_map 中检索开始时间,并计算垃圾收集的持续时间。如果持续时间超过了设定的阈值(变量 time),那么它将数据发送回用户态程序。

handle_gc_starthandle_gc_end 是针对垃圾收集开始和结束事件的处理函数,它们分别调用了 gc_startgc_end

handle_mem_pool_gc_starthandle_mem_pool_gc_end 是针对内存池的垃圾收集开始和结束事件的处理函数,它们也分别调用了 gc_startgc_end

最后,我们有一个 LICENSE 数组,声明了该 BPF 程序的许可证,这是加载 BPF 程序所必需的。

用户态程序

用户态程序的主要目标是加载和运行eBPF程序,以及处理来自内核态程序的数据。它是通过 libbpf 库来完成这些操作的。这里我们省略了一些通用的加载和运行 eBPF 程序的代码,只展示了与 USDT 相关的部分。

第一个函数 get_jvmso_path 被用来获取运行的Java虚拟机(JVM)的 libjvm.so 库的路径。首先,它打开了 /proc/<pid>/maps 文件,该文件包含了进程地址空间的内存映射信息。然后,它在文件中搜索包含 libjvm.so 的行,然后复制该行的路径到提供的参数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static int get_jvmso_path(char *path)
{
char mode[16], line[128], buf[64];
size_t seg_start, seg_end, seg_off;
FILE *f;
int i = 0;

sprintf(buf, "/proc/%d/maps", env.pid);
f = fopen(buf, "r");
if (!f)
return -1;

while (fscanf(f, "%zx-%zx %s %zx %*s %*d%[^\n]\n",
&seg_start, &seg_end, mode, &seg_off, line) == 5) {
i = 0;
while (isblank(line[i]))
i++;
if (strstr(line + i, "libjvm.so")) {
break;
}
}

strcpy(path, line + i);
fclose(f);

return 0;
}

接下来,我们看到的是将 eBPF 程序(函数 handle_gc_starthandle_gc_end)附加到Java进程的相关USDT探针上。每个程序都通过调用 bpf_program__attach_usdt 函数来实现这一点,该函数的参数包括BPF程序、进程ID、二进制路径以及探针的提供者和名称。如果探针挂载成功,bpf_program__attach_usdt 将返回一个链接对象,该对象将存储在skeleton的链接成员中。如果挂载失败,程序将打印错误消息并进行清理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
skel->links.handle_mem_pool_gc_start = bpf_program__attach_usdt(skel->progs.handle_gc_start, env.pid,
binary_path, "hotspot", "mem__pool__gc__begin", NULL);
if (!skel->links.handle_mem_pool_gc_start) {
err = errno;
fprintf(stderr, "attach usdt mem__pool__gc__begin failed: %s\n", strerror(err));
goto cleanup;
}

skel->links.handle_mem_pool_gc_end = bpf_program__attach_usdt(skel->progs.handle_gc_end, env.pid,
binary_path, "hotspot", "mem__pool__gc__end", NULL);
if (!skel->links.handle_mem_pool_gc_end) {
err = errno;
fprintf(stderr, "attach usdt mem__pool__gc__end failed: %s\n", strerror(err));
goto cleanup;
}

skel->links.handle_gc_start = bpf_program__attach_usdt(skel->progs.handle_gc_start, env.pid,
binary_path, "hotspot", "gc__begin", NULL);
if (!skel->links.handle_gc_start) {
err = errno;
fprintf(stderr, "attach usdt gc__begin failed: %s\n", strerror(err));
goto cleanup;
}

skel->links.handle_gc_end = bpf_program__attach_usdt(skel->progs.handle_gc_end, env.pid,
binary_path, "hotspot", "gc__end", NULL);
if (!skel->links.handle_gc_end) {
err = errno;
fprintf(stderr, "attach usdt gc__end failed: %s\n", strerror(err));
goto cleanup;
}

最后一个函数 handle_event 是一个回调函数,用于处理从perf event array收到的数据。这个函数会被 perf event array 触发,并在每次接收到新的事件时调用。函数首先将数据转换为 data_t 结构体,然后将当前时间格式化为字符串,并打印出事件的时间戳、CPU ID、进程 ID,以及垃圾回收的持续时间。

1
2
3
4
5
6
7
8
9
10
11
12
static void handle_event(void *ctx, int cpu, void *data, __u32 data_sz)
{
struct data_t *e = (struct data_t *)data;
struct tm *tm = NULL;
char ts[16];
time_t t;

time(&t);
tm = localtime(&t);
strftime(ts, sizeof(ts), "%H:%M:%S", tm);
printf("%-8s %-7d %-7d %-7lld\n", ts, e->cpu, e->pid, e->ts/1000);
}

安装依赖

构建示例需要 clang、libelf 和 zlib。包名在不同的发行版中可能会有所不同。

在 Ubuntu/Debian 上,你需要执行以下命令:

1
sudo apt install clang libelf1 libelf-dev zlib1g-dev

在 CentOS/Fedora 上,你需要执行以下命令:

1
sudo dnf install clang elfutils-libelf elfutils-libelf-devel zlib-devel

编译运行

在对应的目录中,运行 Make 即可编译运行上述代码:

1
2
3
4
5
6
7
8
9
$ make
$ sudo ./javagc -p 12345
Tracing javagc time... Hit Ctrl-C to end.
TIME CPU PID GC TIME
10:00:01 10% 12345 50ms
10:00:02 12% 12345 55ms
10:00:03 9% 12345 47ms
10:00:04 13% 12345 52ms
10:00:05 11% 12345 50ms

编写 eBPF 程序 Memleak 监控内存泄漏

eBPF(扩展的伯克利数据包过滤器)是一项强大的网络和性能分析工具,被广泛应用在 Linux 内核上。eBPF 使得开发者能够动态地加载、更新和运行用户定义的代码,而无需重启内核或更改内核源代码。

在本篇教程中,我们将探讨如何使用 eBPF 编写 Memleak 程序,以监控程序的内存泄漏。

背景及其重要性

内存泄漏是计算机编程中的一种常见问题,其严重程度不应被低估。内存泄漏发生时,程序会逐渐消耗更多的内存资源,但并未正确释放。随着时间的推移,这种行为会导致系统内存逐渐耗尽,从而显著降低程序及系统的整体性能。

内存泄漏有多种可能的原因。这可能是由于配置错误导致的,例如程序错误地配置了某些资源的动态分配。它也可能是由于软件缺陷或错误的内存管理策略导致的,如在程序执行过程中忘记释放不再需要的内存。此外,如果一个应用程序的内存使用量过大,那么系统性能可能会因页面交换(swapping)而大幅下降,甚至可能导致应用程序被系统强制终止(Linux 的 OOM killer)。

调试内存泄漏的挑战

调试内存泄漏问题是一项复杂且挑战性的任务。这涉及到详细检查应用程序的配置、内存分配和释放情况,通常需要应用专门的工具来帮助诊断。例如,有一些工具可以在应用程序启动时将 malloc() 函数调用与特定的检测工具关联起来,如 Valgrind memcheck,这类工具可以模拟 CPU 来检查所有内存访问,但可能会导致应用程序运行速度大大减慢。另一个选择是使用堆分析器,如 libtcmalloc,它相对较快,但仍可能使应用程序运行速度降低五倍以上。此外,还有一些工具,如 gdb,可以获取应用程序的核心转储并进行后处理以分析内存使用情况。然而,这些工具通常在获取核心转储时需要暂停应用程序,或在应用程序终止后才能调用 free() 函数。

eBPF 的作用

在这种背景下,eBPF 的作用就显得尤为重要。eBPF 提供了一种高效的机制来监控和追踪系统级别的事件,包括内存的分配和释放。通过 eBPF,我们可以跟踪内存分配和释放的请求,并收集每次分配的调用堆栈。然后,我们可以分析这些信息,找出执行了内存分配但未执行释放操作的调用堆栈,这有助于我们找出导致内存泄漏的源头。这种方式的优点在于,它可以实时地在运行的应用程序中进行,而无需暂停应用程序或进行复杂的前后处理。

memleak eBPF 工具可以跟踪并匹配内存分配和释放的请求,并收集每次分配的调用堆栈。随后,memleak 可以打印一个总结,表明哪些调用堆栈执行了分配,但是并没有随后进行释放。例如,我们运行命令:

1
2
3
4
5
6
7
8
9
10
11
# ./memleak -p $(pidof allocs)
Attaching to pid 5193, Ctrl+C to quit.
[11:16:33] Top 2 stacks with outstanding allocations:
80 bytes in 5 allocations from stack
main+0x6d [allocs]
__libc_start_main+0xf0 [libc-2.21.so]

[11:16:34] Top 2 stacks with outstanding allocations:
160 bytes in 10 allocations from stack
main+0x6d [allocs]
__libc_start_main+0xf0 [libc-2.21.so]

运行这个命令后,我们可以看到分配但未释放的内存来自于哪些堆栈,并且可以看到这些未释放的内存的大小和数量。

随着时间的推移,很显然,allocs 进程的 main 函数正在泄漏内存,每次泄漏 16 字节。幸运的是,我们不需要检查每个分配,我们得到了一个很好的总结,告诉我们哪个堆栈负责大量的泄漏。

memleak 的实现原理

在基本层面上,memleak 的工作方式类似于在内存分配和释放路径上安装监控设备。它通过在内存分配和释放函数中插入 eBPF 程序来达到这个目标。这意味着,当这些函数被调用时,memleak 就会记录一些重要信息,如调用者的进程 ID(PID)、分配的内存地址以及分配的内存大小等。当释放内存的函数被调用时,memleak 则会在其内部的映射表(map)中删除相应的内存分配记录。这种机制使得 memleak 能够准确地追踪到哪些内存块已被分配但未被释放。

对于用户态的常用内存分配函数,如 malloccalloc 等,memleak 利用了用户态探测(uprobe)技术来实现监控。uprobe 是一种用于用户空间应用程序的动态追踪技术,它可以在运行时不修改二进制文件的情况下在任意位置设置断点,从而实现对特定函数调用的追踪。

对于内核态的内存分配函数,如 kmalloc 等,memleak 则选择使用了 tracepoint 来实现监控。Tracepoint 是一种在 Linux 内核中提供的动态追踪技术,它可以在内核运行时动态地追踪特定的事件,而无需重新编译内核或加载内核模块。

内核态 eBPF 程序实现

memleak 内核态 eBPF 程序实现

memleak 的内核态 eBPF 程序包含一些用于跟踪内存分配和释放的关键函数。在我们深入了解这些函数之前,让我们首先观察 memleak 所定义的一些数据结构,这些结构在其内核态和用户态程序中均有使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef __MEMLEAK_H
#define __MEMLEAK_H

#define ALLOCS_MAX_ENTRIES 1000000
#define COMBINED_ALLOCS_MAX_ENTRIES 10240

struct alloc_info {
__u64 size; // 分配的内存大小
__u64 timestamp_ns; // 分配时的时间戳,单位为纳秒
int stack_id; // 分配时的调用堆栈ID
};

union combined_alloc_info {
struct {
__u64 total_size : 40; // 所有未释放分配的总大小
__u64 number_of_allocs : 24; // 所有未释放分配的总次数
};
__u64 bits; // 结构的位图表示
};

#endif /* __MEMLEAK_H */

这里定义了两个主要的数据结构:alloc_infocombined_alloc_info

alloc_info 结构体包含了一个内存分配的基本信息,包括分配的内存大小 size、分配发生时的时间戳 timestamp_ns,以及触发分配的调用堆栈 ID stack_id

combined_alloc_info 是一个联合体(union),它包含一个嵌入的结构体和一个 __u64 类型的位图表示 bits。嵌入的结构体有两个成员:total_sizenumber_of_allocs,分别代表所有未释放分配的总大小和总次数。其中 40 和 24 分别表示 total_size 和 number_of_allocs这两个成员变量所占用的位数,用来限制其大小。通过这样的位数限制,可以节省combined_alloc_info结构的存储空间。同时,由于total_size和number_of_allocs在存储时是共用一个unsigned long long类型的变量bits,因此可以通过在成员变量bits上进行位运算来访问和修改total_size和number_of_allocs,从而避免了在程序中定义额外的变量和函数的复杂性。

接下来,memleak 定义了一系列用于保存内存分配信息和分析结果的 eBPF 映射(maps)。这些映射都以 SEC(".maps") 的形式定义,表示它们属于 eBPF 程序的映射部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const volatile size_t min_size = 0;
const volatile size_t max_size = -1;
const volatile size_t page_size = 4096;
const volatile __u64 sample_rate = 1;
const volatile bool trace_all = false;
const volatile __u64 stack_flags = 0;
const volatile bool wa_missing_free = false;

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, pid_t);
__type(value, u64);
__uint(max_entries, 10240);
} sizes SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u64); /* address */
__type(value, struct alloc_info);
__uint(max_entries, ALLOCS_MAX_ENTRIES);
} allocs SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u64); /* stack id */
__type(value, union combined_alloc_info);
__uint(max_entries, COMBINED_ALLOCS_MAX_ENTRIES);
} combined_allocs SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, u64);
__type(value, u64);
__uint(max_entries, 10240);
} memptrs SEC(".maps");

struct {
__uint(type, BPF_MAP_TYPE_STACK_TRACE);
__type(key, u32);
} stack_traces SEC(".maps");

static union combined_alloc_info initial_cinfo;

这段代码首先定义了一些可配置的参数,如 min_size, max_size, page_size, sample_rate, trace_all, stack_flagswa_missing_free,分别表示最小分配大小、最大分配大小、页面大小、采样率、是否追踪所有分配、堆栈标志和是否工作在缺失释放(missing free)模式。

接着定义了五个映射:

  1. sizes:这是一个哈希类型的映射,键为进程 ID,值为 u64 类型,存储每个进程的分配大小。
  2. allocs:这也是一个哈希类型的映射,键为分配的地址,值为 alloc_info 结构体,存储每个内存分配的详细信息。
  3. combined_allocs:这是另一个哈希类型的映射,键为堆栈 ID,值为 combined_alloc_info 联合体,存储所有未释放分配的总大小和总次数。
  4. memptrs:这也是一个哈希类型的映射,键和值都为 u64 类型,用于在用户空间和内核空间之间传递内存指针。
  5. stack_traces:这是一个堆栈追踪类型的映射,键为 u32 类型,用于存储堆栈 ID。

以用户态的内存分配追踪部分为例,主要是挂钩内存相关的函数调用,如 malloc, free, calloc, realloc, mmapmunmap,以便在调用这些函数时进行数据记录。在用户态,memleak 主要使用了 uprobes 技术进行挂载。

每个函数调用被分为 “enter” 和 “exit” 两部分。”enter” 部分记录的是函数调用的参数,如分配的大小或者释放的地址。”exit” 部分则主要用于获取函数的返回值,如分配得到的内存地址。

这里,gen_alloc_enter, gen_alloc_exit, gen_free_enter 是实现记录行为的函数,他们分别用于记录分配开始、分配结束和释放开始的相关信息。

函数原型示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
SEC("uprobe")
int BPF_KPROBE(malloc_enter, size_t size)
{
// 记录分配开始的相关信息
return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(malloc_exit)
{
// 记录分配结束的相关信息
return gen_alloc_exit(ctx);
}

SEC("uprobe")
int BPF_KPROBE(free_enter, void *address)
{
// 记录释放开始的相关信息
return gen_free_enter(address);
}

其中,malloc_enterfree_enter 是分别挂载在 mallocfree 函数入口处的探针(probes),用于在函数调用时进行数据记录。而 malloc_exit 则是挂载在 malloc 函数的返回处的探针,用于记录函数的返回值。

这些函数使用了 BPF_KPROBEBPF_KRETPROBE 这两个宏来声明,这两个宏分别用于声明 kprobe(内核探针)和 kretprobe(内核返回探针)。具体来说,kprobe 用于在函数调用时触发,而 kretprobe 则是在函数返回时触发。

gen_alloc_enter 函数是在内存分配请求的开始时被调用的。这个函数主要负责在调用分配内存的函数时收集一些基本的信息。下面我们将深入探讨这个函数的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int gen_alloc_enter(size_t size)
{
if (size < min_size || size > max_size)
return 0;

if (sample_rate > 1) {
if (bpf_ktime_get_ns() % sample_rate != 0)
return 0;
}

const pid_t pid = bpf_get_current_pid_tgid() >> 32;
bpf_map_update_elem(&sizes, &pid, &size, BPF_ANY);

if (trace_all)
bpf_printk("alloc entered, size = %lu\n", size);

return 0;
}

SEC("uprobe")
int BPF_KPROBE(malloc_enter, size_t size)
{
return gen_alloc_enter(size);
}

首先,gen_alloc_enter 函数接收一个 size 参数,这个参数表示请求分配的内存的大小。如果这个值不在 min_sizemax_size 之间,函数将直接返回,不再进行后续的操作。这样可以使工具专注于追踪特定范围的内存分配请求,过滤掉不感兴趣的分配请求。

接下来,函数检查采样率 sample_rate。如果 sample_rate 大于1,意味着我们不需要追踪所有的内存分配请求,而是周期性地追踪。这里使用 bpf_ktime_get_ns 获取当前的时间戳,然后通过取模运算来决定是否需要追踪当前的内存分配请求。这是一种常见的采样技术,用于降低性能开销,同时还能够提供一个代表性的样本用于分析。

之后,函数使用 bpf_get_current_pid_tgid 函数获取当前进程的 PID。注意这里的 PID 实际上是进程和线程的组合 ID,我们通过右移 32 位来获取真正的进程 ID。

函数接下来更新 sizes 这个 map,这个 map 以进程 ID 为键,以请求的内存分配大小为值。BPF_ANY 表示如果 key 已存在,那么更新 value,否则就新建一个条目。

最后,如果启用了 trace_all 标志,函数将打印一条信息,说明发生了内存分配。

最后定义了 BPF_KPROBE(malloc_enter, size_t size),它会在 malloc 函数被调用时被 BPF uprobe 拦截执行,并通过 gen_alloc_enter 来记录内存分配大小。 我们刚刚分析了内存分配的入口函数 gen_alloc_enter,现在我们来关注这个过程的退出部分。具体来说,我们将讨论 gen_alloc_exit2 函数以及如何从内存分配调用中获取返回的内存地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
static int gen_alloc_exit2(void *ctx, u64 address)
{
const pid_t pid = bpf_get_current_pid_tgid() >> 32;
struct alloc_info info;

const u64* size = bpf_map_lookup_elem(&sizes, &pid);
if (!size)
return 0; // missed alloc entry

__builtin_memset(&info, 0, sizeof(info));

info.size = *size;
bpf_map_delete_elem(&sizes, &pid);

if (address != 0) {
info.timestamp_ns = bpf_ktime_get_ns();

info.stack_id = bpf_get_stackid(ctx, &stack_traces, stack_flags);

bpf_map_update_elem(&allocs, &address, &info, BPF_ANY);

update_statistics_add(info.stack_id, info.size);
}

if (trace_all) {
bpf_printk("alloc exited, size = %lu, result = %lx\n",
info.size, address);
}

return 0;
}
static int gen_alloc_exit(struct pt_regs *ctx)
{
return gen_alloc_exit2(ctx, PT_REGS_RC(ctx));
}

SEC("uretprobe")
int BPF_KRETPROBE(malloc_exit)
{
return gen_alloc_exit(ctx);
}

gen_alloc_exit2 函数在内存分配操作完成时被调用,这个函数接收两个参数,一个是上下文 ctx,另一个是内存分配函数返回的内存地址 address

首先,它获取当前线程的 PID,然后使用这个 PID 作为键在 sizes 这个 map 中查找对应的内存分配大小。如果没有找到(也就是说,没有对应的内存分配操作的入口),函数就会直接返回。

接着,函数清除 info 结构体的内容,并设置它的 size 字段为之前在 map 中找到的内存分配大小。并从 sizes 这个 map 中删除相应的元素,因为此时内存分配操作已经完成,不再需要这个信息。

接下来,如果 address 不为 0(也就是说,内存分配操作成功了),函数就会进一步收集一些额外的信息。首先,它获取当前的时间戳作为内存分配完成的时间,并获取当前的堆栈跟踪。这些信息都会被储存在 info 结构体中,并随后更新到 allocs 这个 map 中。

最后,函数调用 update_statistics_add 更新统计数据,如果启用了所有内存分配操作的跟踪,函数还会打印一些关于内存分配操作的信息。

请注意,gen_alloc_exit 函数是 gen_alloc_exit2 的一个包装,它将 PT_REGS_RC(ctx) 作为 address 参数传递给 gen_alloc_exit2。在我们的讨论中,我们刚刚提到在gen_alloc_exit2函数中,调用了update_statistics_add 函数以更新内存分配的统计数据。下面我们详细看一下这个函数的具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void update_statistics_add(u64 stack_id, u64 sz)
{
union combined_alloc_info *existing_cinfo;

existing_cinfo = bpf_map_lookup_or_try_init(&combined_allocs, &stack_id, &initial_cinfo);
if (!existing_cinfo)
return;

const union combined_alloc_info incremental_cinfo = {
.total_size = sz,
.number_of_allocs = 1
};

__sync_fetch_and_add(&existing_cinfo->bits, incremental_cinfo.bits);
}

update_statistics_add 函数接收两个参数:当前的堆栈 ID stack_id 以及内存分配的大小 sz。这两个参数都在内存分配事件中收集到,并且用于更新内存分配的统计数据。

首先,函数尝试在 combined_allocs 这个 map 中查找键值为当前堆栈 ID 的元素,如果找不到,就用 initial_cinfo(这是一个默认的 combined_alloc_info 结构体,所有字段都为零)来初始化新的元素。

接着,函数创建一个 incremental_cinfo,并设置它的 total_size 为当前内存分配的大小,设置 number_of_allocs 为 1。这是因为每次调用 update_statistics_add 函数都表示有一个新的内存分配事件发生,而这个事件的内存分配大小就是 sz

最后,函数使用 __sync_fetch_and_add 函数原子地将 incremental_cinfo 的值加到 existing_cinfo 中。请注意这个步骤是线程安全的,即使有多个线程并发地调用 update_statistics_add 函数,每个内存分配事件也能正确地记录到统计数据中。

总的来说,update_statistics_add 函数实现了内存分配统计的更新逻辑,通过维护每个堆栈 ID 的内存分配总量和次数,我们可以深入了解到程序的内存分配行为。 在我们对内存分配的统计跟踪过程中,我们不仅要统计内存的分配,还要考虑内存的释放。在上述代码中,我们定义了一个名为 update_statistics_del 的函数,其作用是在内存释放时更新统计信息。而 gen_free_enter 函数则是在进程调用 free 函数时被执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void update_statistics_del(u64 stack_id, u64 sz)
{
union combined_alloc_info *existing_cinfo;

existing_cinfo = bpf_map_lookup_elem(&combined_allocs, &stack_id);
if (!existing_cinfo) {
bpf_printk("failed to lookup combined allocs\n");
return;
}

const union combined_alloc_info decremental_cinfo = {
.total_size = sz,
.number_of_allocs = 1
};

__sync_fetch_and_sub(&existing_cinfo->bits, decremental_cinfo.bits);
}

update_statistics_del 函数的参数为堆栈 ID 和要释放的内存块大小。函数首先在 combined_allocs 这个 map 中使用当前的堆栈 ID 作为键来查找相应的 combined_alloc_info 结构体。如果找不到,就输出错误信息,然后函数返回。如果找到了,就会构造一个名为 decremental_cinfocombined_alloc_info 结构体,设置它的 total_size 为要释放的内存大小,设置 number_of_allocs 为 1。然后使用 __sync_fetch_and_sub 函数原子地从 existing_cinfo 中减去 decremental_cinfo 的值。请注意,这里的 number_of_allocs 是负数,表示减少了一个内存分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int gen_free_enter(const void *address)
{
const u64 addr = (u64)address;

const struct alloc_info *info = bpf_map_lookup_elem(&allocs, &addr);
if (!info)
return 0;

bpf_map_delete_elem(&allocs, &addr);
update_statistics_del(info->stack_id, info->size);

if (trace_all) {
bpf_printk("free entered, address = %lx, size = %lu\n",
address, info->size);
}

return 0;
}

SEC("uprobe")
int BPF_KPROBE(free_enter, void *address)
{
return gen_free_enter(address);
}

接下来看 gen_free_enter 函数。它接收一个地址作为参数,这个地址是内存分配的结果,也就是将要释放的内存的起始地址。函数首先在 allocs 这个 map 中使用这个地址作为键来查找对应的 alloc_info 结构体。如果找不到,那么就直接返回,因为这意味着这个地址并没有被分配过。如果找到了,那么就删除这个元素,并且调用 update_statistics_del 函数来更新统计数据。最后,如果启用了全局追踪,那么还会输出一条信息,包括这个地址以及它的大小。 在我们追踪和统计内存分配的同时,我们也需要对内核态的内存分配和释放进行追踪。在Linux内核中,kmem_cache_alloc函数和kfree函数分别用于内核态的内存分配和释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SEC("tracepoint/kmem/kfree")
int memleak__kfree(void *ctx)
{
const void *ptr;

if (has_kfree()) {
struct trace_event_raw_kfree___x *args = ctx;
ptr = BPF_CORE_READ(args, ptr);
} else {
struct trace_event_raw_kmem_free___x *args = ctx;
ptr = BPF_CORE_READ(args, ptr);
}

return gen_free_enter(ptr);
}

上述代码片段定义了一个函数memleak__kfree,这是一个bpf程序,会在内核调用kfree函数时执行。首先,该函数检查是否存在kfree函数。如果存在,则会读取传递给kfree函数的参数(即要释放的内存块的地址),并保存到变量ptr中;否则,会读取传递给kmem_free函数的参数(即要释放的内存块的地址),并保存到变量ptr中。接着,该函数会调用之前定义的gen_free_enter函数来处理该内存块的释放。

1
2
3
4
5
6
7
8
9
10
SEC("tracepoint/kmem/kmem_cache_alloc")
int memleak__kmem_cache_alloc(struct trace_event_raw_kmem_alloc *ctx)
{
if (wa_missing_free)
gen_free_enter(ctx->ptr);

gen_alloc_enter(ctx->bytes_alloc);

return gen_alloc_exit2(ctx, (u64)(ctx->ptr));
}

这段代码定义了一个函数 memleak__kmem_cache_alloc,这也是一个bpf程序,会在内核调用 kmem_cache_alloc函数时执行。如果标记 wa_missing_free 被设置,则调用 gen_free_enter 函数处理可能遗漏的释放操作。然后,该函数会调用 gen_alloc_enter 函数来处理内存分配,最后调用gen_alloc_exit2函数记录分配的结果。

这两个 bpf 程序都使用了 SEC 宏定义了对应的 tracepoint,以便在相应的内核函数被调用时得到执行。在Linux内核中,tracepoint 是一种可以在内核中插入的静态钩子,可以用来收集运行时的内核信息,它在调试和性能分析中非常有用。

在理解这些代码的过程中,要注意 BPF_CORE_READ 宏的使用。这个宏用于在 bpf 程序中读取内核数据。在 bpf 程序中,我们不能直接访问内核内存,而需要使用这样的宏来安全地读取数据。

用户态程序

在理解 BPF 内核部分之后,我们转到用户空间程序。用户空间程序与BPF内核程序紧密配合,它负责将BPF程序加载到内核,设置和管理BPF map,以及处理从BPF程序收集到的数据。用户态程序较长,我们这里可以简要参考一下它的挂载点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int attach_uprobes(struct memleak_bpf *skel)
{
ATTACH_UPROBE_CHECKED(skel, malloc, malloc_enter);
ATTACH_URETPROBE_CHECKED(skel, malloc, malloc_exit);

ATTACH_UPROBE_CHECKED(skel, calloc, calloc_enter);
ATTACH_URETPROBE_CHECKED(skel, calloc, calloc_exit);

ATTACH_UPROBE_CHECKED(skel, realloc, realloc_enter);
ATTACH_URETPROBE_CHECKED(skel, realloc, realloc_exit);

ATTACH_UPROBE_CHECKED(skel, mmap, mmap_enter);
ATTACH_URETPROBE_CHECKED(skel, mmap, mmap_exit);

ATTACH_UPROBE_CHECKED(skel, posix_memalign, posix_memalign_enter);
ATTACH_URETPROBE_CHECKED(skel, posix_memalign, posix_memalign_exit);

ATTACH_UPROBE_CHECKED(skel, memalign, memalign_enter);
ATTACH_URETPROBE_CHECKED(skel, memalign, memalign_exit);

ATTACH_UPROBE_CHECKED(skel, free, free_enter);
ATTACH_UPROBE_CHECKED(skel, munmap, munmap_enter);

// the following probes are intentinally allowed to fail attachment

// deprecated in libc.so bionic
ATTACH_UPROBE(skel, valloc, valloc_enter);
ATTACH_URETPROBE(skel, valloc, valloc_exit);

// deprecated in libc.so bionic
ATTACH_UPROBE(skel, pvalloc, pvalloc_enter);
ATTACH_URETPROBE(skel, pvalloc, pvalloc_exit);

// added in C11
ATTACH_UPROBE(skel, aligned_alloc, aligned_alloc_enter);
ATTACH_URETPROBE(skel, aligned_alloc, aligned_alloc_exit);

return 0;
}

在这段代码中,我们看到一个名为attach_uprobes的函数,该函数负责将uprobes(用户空间探测点)挂载到内存分配和释放函数上。在Linux中,uprobes是一种内核机制,可以在用户空间程序中的任意位置设置断点,这使得我们可以非常精确地观察和控制用户空间程序的行为。

这里,每个内存相关的函数都通过两个uprobes进行跟踪:一个在函数入口(enter),一个在函数退出(exit)。因此,每当这些函数被调用或返回时,都会触发一个uprobes事件,进而触发相应的BPF程序。

在具体的实现中,我们使用了ATTACH_UPROBEATTACH_URETPROBE两个宏来附加uprobes和uretprobes(函数返回探测点)。每个宏都需要三个参数:BPF程序的骨架(skel),要监视的函数名,以及要触发的BPF程序的名称。

这些挂载点包括常见的内存分配函数,如malloc、calloc、realloc、mmap、posix_memalign、memalign、free等,以及对应的退出点。另外,我们也观察一些可能的分配函数,如valloc、pvalloc、aligned_alloc等,尽管它们可能不总是存在。

这些挂载点的目标是捕获所有可能的内存分配和释放事件,从而使我们的内存泄露检测工具能够获取到尽可能全面的数据。这种方法可以让我们不仅能跟踪到内存分配和释放,还能得到它们发生的上下文信息,例如调用栈和调用次数,从而帮助我们定位和修复内存泄露问题。

注意,一些内存分配函数可能并不存在或已弃用,比如valloc、pvalloc等,因此它们的附加可能会失败。在这种情况下,我们允许附加失败,并不会阻止程序的执行。这是因为我们更关注的是主流和常用的内存分配函数,而这些已经被弃用的函数往往在实际应用中较少使用。

完整的源代码:https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/16-memleak

编译运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git clone https://github.com/iovisor/bcc.git --recurse-submodules 
$ cd libbpf-tools/
$ make memleak
$ sudo ./memleak
using default object: libc.so.6
using page size: 4096
tracing kernel: true
Tracing outstanding memory allocs... Hit Ctrl-C to end
[17:17:27] Top 10 stacks with outstanding allocations:
1236992 bytes in 302 allocations from stack
0 [<ffffffff812c8f43>] <null sym>
1 [<ffffffff812c8f43>] <null sym>
2 [<ffffffff812a9d42>] <null sym>
3 [<ffffffff812aa392>] <null sym>
4 [<ffffffff810df0cb>] <null sym>
5 [<ffffffff81edc3fd>] <null sym>
6 [<ffffffff82000b62>] <null sym>
...

总结

通过本篇 eBPF 入门实践教程,您已经学习了如何编写 Memleak eBPF 监控程序,以实时监控程序的内存泄漏。您已经了解了 eBPF 在内存监控方面的应用,学会了使用 BPF API 编写 eBPF 程序,创建和使用 eBPF maps,并且明白了如何用 eBPF 工具监测和分析内存泄漏问题。我们展示了一个详细的例子,帮助您理解 eBPF 代码的运行流程和原理。

第一章 引言

  • BPF提供了一种在各种内核时间和应用程序事件发生时运行一段小程序的机制。由指令集、存储对象和辅助函数等几部分组成。应用领域分别是网络、可观测性和安全。
  • 跟踪(tracing)是基于事件记录。嗅探(snoop)、时间记录和跟踪,通常指的是一回事。
  • 采样(sampling):通过获取全部观测量的子集来描绘目标的大致图像;这也被称为生成性能剖析样本或profiling。有一个BPF工具就叫profile,它基于计时器来对运行中的代码定时采样。
  • 可观测性(observability):通过全面观测来理解一个系统,可以实现这一目标的工具可以归类为可观测工具。
  • BCC(BPF编辑器集合,BPF Compiler Collection)是最早用于开发BPF跟踪程序的高级框架。它提供了一个编写内核BPF程序的C语言环境,同时还提供了其他高级语言环境来实现用户端接口。
  • bpftrace是一个新近出现的前端,它提供了专门用于创建BPF工具的高级语言支持。
1
2
3
bpftrace -e 'tracepoint:syscalls:sys_enter_open { printf("%s %s\n",comm,str(args->filename)); }'
bpftrace -l 'tracepoint:syscalls:sys_enter_open*'
bpftrace -e 'tracepoint:syscalls:sys_enter_open*{@[probe]=count();}'
  • bpftrace在编写功能强大的单行程序、短小的脚本方面甚为理想;BCC则更适合开发复杂的脚本和作为后台进程使用,它还可以调试其他库的支持。
  • 动态插桩:kprobes和uprobes。可能存在由于软件改名而动态插桩不可用
  • 静态插桩:tracepoint和USDT。将稳定的事件名字编码到软件代码中

/img/bcc_tracing_tools

/img/image-20230904104906639

第二章 扩展版BPF

  • BPF指令通过BPF验证器验证,再由BPF虚拟机执行。BPF虚拟机的实现既包括一个解释器【非即时编译】,又包括一个JIT编译器:JIT编译器负责生成处理器可直接执行的机器指令。验证器会拒绝那些不安全的操作,这包括针对无界循环的检查。BPF程序必须在有限时间内完成。

  • BPF可以利用辅助函数获取内核状态,利用BPF映射表进行存储。BPF程序在特定时间发生时执行,包括kprobes、uprobes和跟踪点等信息。

  • BPF具有高效率和生产环境安全性等特点,它已经内置在Linux内核中。有了BPF,就可以在生产环境中直接运行这些工具,而无需增加新的内核组件。

  • BPF指令集查看:bpftool

1
2
3
bpftool prog show
bpftool prog dump xlated id 36
bpftool prog dump xlated id 37 opcodes
  • bpftrace查看BPF指令集
1
bpftrace -v /usr/share/bpftrace/tools/biolatency.bt
  • 调用栈回溯
    • 基于栈指针的调用栈回溯
    • 调试信息
    • 最后分支记录LBR,目前BPF不支持
    • ORC:oops回滚能力
    • 符号:调用栈信息目前在内核中是以地址数组形式记录的,这些地址可以通过用户态的程序翻译为符号(比如函数的名字)
  • 火焰图
    • Linux的perf剖析器将其样本摘要为调用树格式,显示每个分支所占的百分比。
    • BCC的profile工具则采用了另外一种摘要方式:对每个独特的调用栈分别计数。
  • 事件源
    • kprobes:内核动态插桩支持。可以对任何内核函数进行插桩,它还可以对函数内部的指令进行插桩。kretprobes可以用来对内核函数的返回进行插桩以获得返回值。当用kprobes和kretprobes对同一个函数进行插桩时,可以使用时间戳来记录函数执行时长。过程如下
      • 将在要插桩的目标地址中的字节内容复制并保存(为的是给单步断点指令腾出位置)
      • 以单步中断指令覆盖目标地址:在x86_64上是int3指令。(如果kprobes开启了优化,则使用jmp指令)
      • 当指令流执行到断点时,断点处理函数会检查这个断点是否是由kprobes注册的,如果是,就会执行kprobes处理函数。
      • 原始的指令会接着执行,指令流继续。
      • 当不再需要kprobes时,原始的字节内容会被复制回目标地址上,这样这些指令就回到了它们的初始状态。
1
bpftrace -e 'kprobe:vfs_* {@[probe]=count();}'
  • uprobes:用户态程序动态插桩。uprobes可以在用户态程序的函数入口、特定偏移处,以及函数返回处进行插桩。
    • 过程:和kprobes类似。将一个快速断点指令插入目标指令处,该指令将执行转交给uprobes处理函数。当不再需要uprobes时,目标指令会恢复为原来的样子。
1
2
3
4
5
perf top 
find / -name 'libc-2.17.so'
# /usr/lib64/libc-2.17.so
bpftrace -l 'uprobe:/usr/lib64/libc-2.17.so:gethost*'
bpftrace -e 'uprobe:/usr/lib64/libc-2.17.so:gethost*{@[probe]=count();}'
  • 跟踪点tracepoints:对内核进行静态插桩。内核开发者在内核函数中的特定逻辑位置处,有意放置了这些插桩点;然后这些跟踪点会被编译到内核的二进制文件中。原理如下:
    • 在内核编译阶段会在跟踪点位置插入一条不做任何具体工作的指令。在x86_64架构上,这是一个5字节nop指令。这个长度的选择是为了确保以后可以将它替换为一个5字节的jump指令。
    • 在函数尾部会插入一个跟踪点处理函数,也叫做蹦床函数。这个函数会遍历一个存储跟踪点探针回调函数的数组。(之所以叫蹦床函数,是因为在执行过程中函数会跳入,然后再跳出这个处理函数)。
    • 在执行过程中,当某个跟踪器启用跟踪点时(该跟踪点可能已经被其他跟踪器所启用)
      • 在跟踪点回调函数数组中插入一条新的跟踪器回调函数,以RCU形式进行同步更新。
      • 如果之前跟踪点处于禁用状态,nop指令地址会重写为跳转到蹦床函数的指令。
    • 当跟踪器禁用某个跟踪点时:
      在跟踪点回调函数数组中删除该跟踪器的回调函数,并且以RCU形式进行同步更新。
      如果最后一个回调函数也被去除了,那么jmp指令再重写为nop指令。

RCU(Read-Copy Update)机制:首先将需要修改的内容复制出一份副本,然后在副本上进行修改操作。在写者进行修改操作的过程中,旧数据没有做任何更新,不会产生读写竞争,因此依然可以被读者并行访问。当写者修改完成后,写者直接将新数据内存地址替换掉旧数据的内存地址,由于内存地址替换操作是原子的,因此可以保证读写不会产生冲突。内存地址替换后,原有读者访问旧数据,新的读者将访问新数据。当原有读者访问完旧数据,进入静默期后,旧数据将被写者删除回收。当然,通常写者只进行更新、删除指针操作,旧数据内存的回收由另一个线程完成。

1
bpftrace -e 'tracepoint:sched:sched_process_exec {printf("exec by %s\n",comm);}'
  • USDT:用户预定义静态跟踪提供了一个用户空间版的跟踪点机制。需要被添加到源代码并编译到最终的二进制文件中,在插桩点留下nop指令,在ELF notes段中存放元数据。
    • 原理:当编译应用程序时,在USDT探针的地址放置一个nop指令。在插桩时,这个地址会由内核使用uprobes动态将其修改为一个断点指令。
  • PMC性能监控计数器。
    • 模式
      • 计数:PMC能够跟踪事件发生的频率。
      • 溢出采样:PMC在所监控的事件发生到一定次数时通知内核,这样内核可以获得额外的状态。监控的事件可能会以每秒百万、亿级别的频率发生,如果对每次事件都进行中断会导致系统性能下降到不可用。解决方案就是利用一个可编程的计数器进行采样,具体来说,是当计数器溢出时就向内核发送信号(比如每10000次LLC缓存未命中事件,或者每百万次阻塞的指令时钟周期)。
    • 采样模式对BPF跟踪来说更值得关注,因为它产生的事件给BPF程序提供了执行的时机。BCC和bpftrace都支持PMC事件跟踪。
  • perf_events:perf所依赖的采样和跟踪机制。

第三章 性能分析

  • 目标:改进最终用户的体验以及降低运行成本。
  • 目标量化:延迟、速率、吞吐量、利用率、成本。
  • 业务负载画像:理解实际运行的业务负载。推荐步骤如下:
    • 负载是谁产生的(比如,进程ID、用户ID、进程名、IP地址)?
    • 负载为什么会产生(代码路径、调用栈、火焰图)?
    • 负载的组成是什么(IOPS、吞吐量、负载类型)?
    • 负载怎样随着时间发生变化(比较每个周期的摘要信息)?
1
2
vfsstat
bpftrace -e 'kprobe:vfs_read {@[comm] = count();}'
  • 下钻分析:从一个指标开始,然后将这个指标拆分成多个组成部分,再将最大的组件进一步拆分为更小的组件,不断重复这个过程直到定位出一个或多个根因。推荐步骤如下
    • 从业务最高层级开始分析
    • 检查下一层次的细节
    • 挑出最感兴趣的部分或者线索。
    • 如果问题没有解决,跳转到第二步。
  • USE方法论:针对每一个资源,分别去检查:使用率、饱和度、错误。
  • 检查清单发:列出一系列工具和指标,用于对照运行和检查。
  • Linux60秒分析
1
2
3
4
5
6
7
8
9
10
uptime # 平均负载
dmesg|tail # 系统日志
vmstat 1 # r CPU正在执行和等待执行的进程数量。不包括I/O
mpstat -P ALL 1 # 将每个CPU分解到各个状态下的时间打印出来。
pidstat 1 # 按每个进程展示CPU的使用情况
iostat -xz 1 # 显示存储设备的I/O指标
free -m
sar -n DEV 1 # 网络设备指标
sar -n TCP,ETCP 1 # TCP指标和TCP错误信息。active/s 本地发起的TCP连接数/s。passive/s 远端发起的TCP连接数/s。 retrans/s TCP重传数/s
top

BCC工具检查列表

1
2
3
4
5
6
7
8
9
10
11
execsnoop # 跟踪execve系统调用
opensnoop # 跟踪open系统调用,包括打开文件的路径、打开操作是否成功
ext4slower(或者brtfs*、xfs*、zfs*) # 跟踪ext4文件系统的常见操作
biolatency # 跟踪磁盘I/O延迟
biosnoop # 将每一次磁盘I/O请求打印出来,包含延迟之类的细节信息。
cachestat # 展示文件系统缓存的统计信息。
tcpconnect
tcpaccept
tcpretrans
runqlat # 线程等待CPU运行的时间进行统计
profile # CPU剖析器,可以用来理解那些代码路径消耗了CPU资源。周期性的对调用栈进行采样,然后将消重后的调用栈连同出现的次数一起打印出来。

第四章 BCC

  • funccount:对事件—特别是函数调用—进行计数
1
2
3
4
5
6
7
8
9
10
11
12
13
funccount [-h] [-p PID] [-i INTERVAL] [-d DURATION] [-T] [-r] [-D] pattern

Count all malloc() calls in libc:
# funccount c:malloc

展示每秒块I/O事件的数量
# funccount -i 1 't:block:*'
展示每秒libc中getaddrinfo()(域名解析)函数的调用次数
# funccount -i 1 c:getaddrinfo
对libgo中全部的“os.*”调用进行计数
# funccount 'go:os.*'
u:lib:name 对lib库中名为name的USDT探针进行插桩
path:name 对位于path路径下文件中的用户态函数name()进行插桩
  • stackcount:对导致某事件发生的函数调用栈进行计数。stackcount可以回答如下问题:
    • 某个事件为什么会被调用?调用的代码路径是什么?
    • 有哪些不同的代码路径会调用该事件,它们的调用频次如何。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
火焰图
stackcount -fP -D 10 ktime_get>out.stackcount01.txt
git clone https://github.com/brendangregg/FlameGraph.git
cd FlameGraph
/Users/junmo/www/FlameGraph/flamegraph.pl --hash --bgcolors=grey < out.stackcount01.txt > out.stackcount01.svg

对创建块I/O的函数调用栈进行计数
stackcount t:block:block_rq_insert
对发送IP数据包的调用栈进行计数
stackcount ip_output
对导致线程阻塞并且导致脱离CPU的调用栈进行计数
stackcount t:sched:sched_switch
对导致系统调用read()的调用栈进行计数
stackcount t:syscalls:sys_enter_read
  • trace是一个BCC多用途工具,可以针对多个数据源进行每个事件的跟踪,支持kprobes、uprobes、跟踪点和USDT探针。它可以回答如下解决问题:
    • 当某个内核态/用户态函数被调用时,调用参数是什么?
    • 这个函数的返回值是什么?调用失败了吗?
    • 这个函数是如何被调用的?相应的用户态或内核态函数调用栈是什么?
    • 因为trace会对每个事件产生一行输出,因此它比较适用于低频事件。对于高频事件,可以采用过滤表达式,只打印感兴趣的事件。
1
2
3
4
5
6
7
8
9
10
11
12
# arg2是do_sys_open()函数的第二个参数,打印文件名
trace 'do_sys_open "%s",arg2'
# 跟踪内核函数do_sys_open(),并打印返回值
trace 'r::do_sys_open "ret: %d", retval'
# 跟踪do_nanosleep(),并且打印用户态的调用栈
trace -U 'do_nanosleep "mode: %d",arg2'
# 跟踪通过pam库进行身份鉴别的请求
trace 'pam:pam_start "%s: %s",arg1,arg2'

# bcc使用系统头文件和内核头文件来获取结构体信息
trace 'do_nanosleep(struct hrtimer_sleeper *t) "task: %x",t->task'
trace -I 'net/sock.h' 'udpv6_sendmsg(struct sock *sk) (sk->sk_dport == 13568)'
  • argdist:针对函数调用参数分析的多用途工具
    • $retval:函数的返回值
    • $latency:从进入到返回的时长,单位是纳秒
    • $entry(param):在探针进入(entry)时param的值。
1
2
3
4
5
6
7
argdist -H 'r::__tcp_select_window():int:$retval'
# 将内核函数vfs_read的返回值以直方图的形式打印出来
argdist -H 'r::vfs_read()'
# 以直方图对pid为1005的进程的用户态调用libc的read()函数的返回值(size)进行统计输出
argdist -p 1005 -H 'r:c:read()'
# Aggregate interrupts by interrupt request (IRQ)
argdist -C 't:irq:irq_handler_entry():int:args->irq'

第五章 bpftrace

1
2
3
4
5
bpftrace -e 'tracepoint:syscalls:sys_enter_execve {printf("%s -> %s\n",comm,str(args->filename))}'
# 展示新进程的创建,以及参数信息
bpftrace -e 'tracepoint:syscalls:sys_enter_execve {join(args->argv)}'
# 展示进程的磁盘I/O尺寸
bpftrace -e 'tracepoint:block:block_rq_issue {printf("%d %s %d\n",pid,comm,args->bytes)}'
  • 探针格式:
    • kprobe对内核进行插桩,只需要一个标识符:内核函数名
    • uprobe对用户态函数进行插桩,需要两个标识符:二进制文件的路径和函数名
    • 可以使用逗号将多个探针并列,指向同一个执行动作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/bpftrace
BEGIN
{
printf("Hello world!\n");
}
END
{
printf("Game over!\n");
}
kprobe:vfs_read
{
@start[tid] = nsecs;
}

kretprobe:vfs_read
/@start[tid]/ // 过滤器
{
$duration_us = (nsecs - @start[tid]) / 1000;
@us[pid,comm] = hist($duration_us);
delete(@start[tid]);
}
  • 变量

    • 内置变量:由bpftrace预先定义好,通常是一个只读的信息源
    • 临时变量:被用于临时计算,字首加”$”作为前缀。
    • 映射表变量:使用BPF映射表来存储对象,名字带有”@”前缀。它们可以用作全局存储,在不同动作之间传递数据。
  • 探针类型

    • 内核静态插桩点 tracepoint[t]
    • 用户静态定义插桩点 usdt[U]
    • 内核动态函数插桩 kprobe[k]
    • 内核动态函数返回值插桩 kretprobe[kr]
    • 用户态动态函数插桩 uprobe[u]
    • 用户态动态函数返回值插桩 uretprobe[ur]
    • 内核软件事件 software[s]
      • cpu-clock[cpu] CPU真实时间,默认采样间隔1000000
      • task-clock CPU任务时间,默认采样间隔1000000
      • page-faults[faults] 缺页中断,默认采样间隔100
      • context-switches[cs] 上下文切换,默认采样间隔100
      • 略…
    • 硬件基于计数器的插桩 hardware[h]
    • 对全部CPU进行时间采样 profile[p]
      • profile:[hz|s|ms|us]:rate;对于全部CPU激活
    • 周期性报告(从一个CPU上) interval[i]
      • interval:[s:ms]:rate ;对于一个CPU
    • BEGIN bpftrace启动
    • END bpftrace退出
1
bpftrace -lv tracepoint:syscalls:sys_enter_read
  • bpftrace控制流
    • 过滤器 probe /filter/ {action}
    • 三元运算符 test ? true_statement : false_statement
    • if 语句 if(test){} else{}
    • 循环展开 unroll(count){statements}
  • bpftrace内置变量
    • pid tid uid username
    • nsecs 时间戳 纳秒
    • elapsed 时间错,单位纳秒,字bpftrace启动开始计时
    • cpu 处理器ID
    • comm 进程名
    • kstack ustack 调试栈信息
    • func 被跟踪函数名字
    • probe 当前探针全名
    • arg0…argN
    • retval
    • curtask 内核task_struct地址
    • cgroup
    • 1 , . . . , 1,…,1,…,N bpftrace程序的位置参数
  • bpftrace函数
    • printf time str
    • join 将多个字符串用空格进行连接并打印出来
    • kstack ustack
    • ksym usym 地址转换为字符串形式名字
    • system 执行shell命令
  • bpftrace映射表的操作函数
    • count() sum(int n) avg(int n) min(int n) max(int n)
    • stats(int n) 返回事假次数、平均值和总和
    • hist(int n) 打印2的幂方的直方图
    • lhist(int n,int min,int max,int step) 打印线性直方图
    • delete(@m[key]) 删除key
    • print(@m[,top[,div]]) 打印映射表,top指明只打印最高的top项目,div是一个整数分母,用来将数值整除后输出
    • clear(@m) 删除映射表中全部的键
    • zero(@m) 将映射表中所有的值设置为0
1
bpftrace -e 'k:vfs_* {@[probe] = count();} END {print(@,5);clear(@);}'

第六章 CPU

  • 事件源
    • 软中断 irq:softirq 跟踪点[t:irq:softirq]
    • 硬中断 irq:irq_handler 跟踪点[t:irq:irq_handler]
    • 运行队列 t:workqueue:*跟踪点
    • 定时采样 PMC或是基于定时器的采样器
    • CPU电源控制事件 power跟踪点 [t:workqueue:*]
    • CPU周期 PMC数据
  • 查看所有cpu是否正常使用 mpstat -P ALL
  • perf火焰图
1
2
3
4
5
6
7
8
9
10
11
# perf.data
perf record -F 99 -a -g -o cycle_0526.perf -- sleep 30

# 用perf script工具对cycle_0526.perf进行解析
perf script -i cycle_0526.perf &> perf.unfold

# 将perf.unfold中的符号进行折叠:
./stackcollapse-perf.pl perf.unfold &> perf.folded

# svg
./flamegraph.pl perf.folded > perf.svg
  • execsnoop跟踪全系统中新进程执行信息的工具。利用这个工具可以找到消耗大量CPU的短进程,并且可以用来分析软件执行过程,包括启动脚本等。

    1
    2
    # bpftrace版本实现
    bpftrace -e 't:syscalls:sys_enter_execve {printf ("%-10u %-5d ",elapsed/1000000,pid);join(args->argv);}'
  • exitsnoop 跟踪进程退出事件,打印出进程的总运行时长和退出原因。可以帮助调试短时进程。

  • runqlat: CPU调度延迟分析工具。在需求超过供给,CPU资源处于饱和状态时,这个工具可以用来识别和量化问题的严重性。runqlat利用对CPU调度器的线程唤醒事件和线程上下文切换事件的跟踪来计算线程从唤醒到运行之间的时间间隔。

1
2
3
4
5
6
runqlat 10 1 # 运行10s,只打印1次。nsecs单位为微秒
# -m 以毫秒为单位输出
# -P 给每个进程打印一个直方图
# --pidnss 给每个PID空间打印一个直方图
# -p PID 指定进程
# -T 输出包含时间戳
  • runqlen 采样CPU运行队列的长度信息,可以统计有多少线程正在等待运行,并以直方图的方式输出。
1
2
3
4
runqlen -C 10 1
# -C 每个CPU输出一个直方图
# -O 运行队列占有率信息,运行队列不为0的时长百分比
# -T 输出时间戳信息
  • runqslower 可以列出运行队列中等待延迟超过阈值的线程名字,可以输出受延迟影响的进程名和对应的延时时长。

  • cpudist 用来展示每次线程唤醒之后在CPU上执行的时长分布。在内部跟踪CPU调度器的上下文切换事件,在繁忙的生产环境中发生的频率很高,额外消耗显著,使用时多小心。

  • profile:定时采样调用栈信息并且汇报调用栈出现频率信息。默认以49Hz的频率同时采样所有的CPU的用户态和内核态的调用栈。

    • U 仅输出用户态调用栈信息
    • K 仅输出内核态调用栈
    • a 在函数名称上加上标记(例如,在内核态函数加上”_[k]”)
    • d 在用户态和内核态调用栈之间加上分隔符
    • f 以折叠方式输出
    • p PID 仅跟踪给定的进程
1
2
3
4
profile -af 30 > out.stacks01
./flamegraph.pl --color=java < ../out.stacks01 > out.svg
# 核心实现等同于如下bpftrace
bpftrace -e 'profile:hz:49 /pid/ { @samples[ustack,kstack,comm]=count();}'
  • offcputime 用于统计线程阻塞和脱离CPU运行的时间,同时输出调用栈信息,以便理解阻塞原因。这个工具正好是profile工具的对立面;这两个工具结合起来覆盖了线程的全部生命周期:profile覆盖在CPU之上运行的分析,而offcputime则分析脱离CPU运行的时间
    • U 仅输出用户态调用栈信息
    • K 仅输出内核态调用栈
    • u 仅包括用户态线程
    • k 仅包括内核态线程
    • f 以折叠方式输出
    • p PID 仅跟踪给定的进程
1
2
3
4
# 内核调用栈5秒的火焰图
offcputime -fKu 5 > out.offcuptime01.txt
./flamegraph.pl --hash --bgcolors=blue --title="OFF-CPU Time Flame Graph" \
< out.offcputime01.txt > out.offcputime01.svg
  • syscount 统计系统中的系统调用数量.
1
2
/usr/share/bcc/tools/syscount -LP
bpftrace -e 't:syscalls:sys_enter_*{@[probe]=count();}'
  • argdist和trace:针对每个事件自定义处理方法、
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 获取参数名字
$ tplist -v syscalls:sys_enter_read
syscalls:sys_enter_read
int __syscall_nr;
unsigned int fd;
char * buf;
size_t count;
# count read调用缓存的大小
argdist -H 't:syscalls:sys_enter_read():int:args->count'
argdist -H 't:syscalls:sys_exit_read():int:args->ret'
# 等价bpftrace程序如下
bpftrace -e 't:syscalls:sys_enter_read {@ = hist(args->count);}'
bpftrace -e 't:syscalls:sys_exit_read {@ = hist(args->ret);}'
# bpftrace针对负值有一个单独的统计区间([...,0]),read的返回值如果是负数就说明出现了错误
# 统计出现的错误频率
bpftrace -e 't:syscalls:sys_exit_read /args->ret<0/ {@ = lhist(- args->ret,0,100,1);}'

trace 't:syscalls:sys_enter_execve "-> %s", args->filename'
    • funccount 可以统计事件和函数调用频率。此工具是根据函数动态跟踪来统计的:对内核态函数使用kprobes,对用户态函数使用uprobes
1
2
3
4
funccount 'tcp_*' # 统计内核以tcp_开头的所有函数
funccount -i 1 get_page_from_freelist

bpftrace -e 'k:tcp_* {@[probe] = count();} interval:s:1{print(@);clear(@);}'
  • softirqs 显示系统中软中断消耗的CPU时间。
1
bpftrace -e 't:irq:softirq_entry {@[args->vec] = count();}'
  • hardirqs 显示系统处理硬中断的时间。
  • cpuwalk.bt 采样每个CPU上运行的进程名,并且以线性直方图的方式输出。

第七章 内存

  • Linux操作系统采用的虚拟内存机制,每个进程都有自己的虚拟内存地址空间,仅当实际使用内存的时候才会映射到物理内存之上。
  • 内存管理机制
    • 页换出守护进程(kswapd):会被定期唤醒,它会批量扫描活跃页的LRU列表和非活跃页的LRU列表以寻找可以释放的内存。当空闲内存低于某个阈值的时候,该进程就会被唤醒,当空闲内存高于另外一个阈值的时才会休息。
    • 物理换页设备(swap device):当系统内存不够时,换页设备允许系统以一种降级模式运行:进程可以继续申请内存,但是不经常使用的页将会被换入换出到对应的换页设备上,但是这一般会导致应用程序运行速度大幅下降。
    • 在极端情况下)直接杀掉内存溢出的进程(OOM Killer):按预定规则(将除内核关键任务和init(PID 1)进程之外的占用内存最多的进程杀死)杀掉进程。
  • 堆内存:存储在进程虚拟内存地址空间的一段动态区间中的内存
  • 空闲内存列表freelist:内核为每个CPU和DRAM组维护一组空闲内存列表,这样可以直接响应内存分配请求。同时,内核软件本身的内存分配需求也从这个空闲内存列表直接获取,一般通过内核内存分配器进行,例如,slab分配器。

  • 内存页的生命周期:

    • 应用程序发起内存分配请求
    • 应用程序库代码要么直接从空闲列表中响应请求,要么先扩展虚拟内存地址空间再分配。根据内存分配库的不同实现,有以下两种选项:
      • 利用brk()系统调用来扩展堆的尺寸,以便用新的堆地址响应请求。
      • 利用mmap()系统调用来创建一个新的内存段地址。
  • 内存分配之后,应用程序试图使用store/load指令来使用之前分配的内存地址,这就要调用CPU内部的内存管理单元来进行虚拟地址到物理地址的转换。当虚拟地址没有对应的物理地址时,会导致MMU发出一个缺页错误(page fault)。
  • 缺页错误由内核处理。在对应的处理函数中,内核会在物理内存空闲列表中找到一个空闲地址并映射到该虚拟地址。接下来,内存会通知MMU以便未来直接查找该映射。现在用户进程占用了一个物理内存页。进程所使用的全部物理内存数量称为常驻集大小(RSS)。
  • 当系统内存需求超过一定水平时,内核中的页换出守护进程就开始寻找可以释放的内存页。
    • 文件系统页:从磁盘中读出并且是没有被修改过的页(blacked by disk),这些页可以立即释放,需要再读取回来。
    • 被修改过的文件系统页:这些页被称为“脏页”,这些页需要先写回磁盘才能被释放。
    • 应用程序内存页:这些页被称为匿名页(anonymous memory),因为这些页不是来源于某个文件的。如果系统中有换页设备(swap device),那么这些页可以先存入换页设备,再被释放。将内存页写入换页设备成为换页。
  • 页压缩:内核中有一个压缩程序来移动内存页,以便扩大连续内存。
  • 文件系统缓存和缓冲区:Linux会借用空闲内存作为文件系统的缓存,如果有需要的话会再释放。

事件源

1
2
3
4
5
6
7
8
9
10
用户态内存分配  usdt:/usr/lib64/libc-2.28.so:*
内核态内存分配 t:kmem:*
堆内存扩展
共享内存函数
缺页错误 kprobes、软件事件,以及exception跟踪点
页迁移 t:migration:*'
页压缩 t:compaction:*
VM扫描器 t:vmscan:*
内存访问周期 PMC
bpftrace -l 'usdt:/usr/lib64/libc-2.28.so:*'
  • 内存分析策略

    • 检查OOM Killer杀掉的进程的信息。(dmesg)
    • 检查系统中是否有换页设备,以及使用的换页空间大小;并且检查这些换页设备是否有活跃的I/O操作(iostat vmstat)
    • 检查系统中的空闲内存的数量,以及整个系统的缓存使用情况(free)
    • 按进程检查内存使用量(top ps)
    • 检查系统的缺页错误的发生频率,并且检查缺页错误发生时的调用栈信息,这可以解释RSS增长的原因
    • 检查缺页错误与那些文件有关
    • 通过跟踪brk()和mmap()系统调用
    • BPF工具
    • 使用PMC测量硬件缓存命中率和内存空间,以便分析导致内存I/O发生的函数和指令信息(perf)
  • 传统工具

    • dmesg 内核日志
    • 内核统计信息:/proc/meminfo /proc/swaps
      • swapon:显示系统是否使用换页设备
      • free:统计全系统内存使用量
      • ps:进程状态命令按进程显示内存用量。%MEM 物理内存占比所有内存;VSZ虚拟内存;RSS常驻集大小
      • pmap:按地址空间段展示进程内存用量。pmap -x PID
      • vmstat:按时间展示各种全系统的统计数据,包括内存、CPU,以及存储I/O。
      • sar:是一个可以打印不同目标、不同监控指标的复合工具。-B选项打印的是页统计信息。
    • 硬件统计和硬件采样:用PMC来观测内存I/O事件,这些PMC统计的是处理器中的CPU单元到主内存之间的I/O操作,中间还涉及了CPU缓存。PMC提供两种方式:累计和采样。累计方式提供的是统计信息,额外消耗几乎为0;而采样模式则将发生的事件存入一个文件中供后期分析。
  • oomkill:用来跟踪OOM Killer事件的信息,以及打印出平均负载等详细信息。

  • memleak:用来跟踪内存分配和释放事件对应的调用栈信息。随着时间的推移,这个工具可以显示长期不被释放的内存。根据内存分配频繁程度,性能会下降,只能用来调试。

  • mmapsnoop:跟踪全系统的mmap系统调用并打印出映射请求的详细信息,这对内存映射调试来说是很有用的。

1
bpftrace -e 't:syscalls:sys_enter_mmap {@[comm] = count();}'
  • brkstack:跟踪brk调用
1
2
3
4
trace -U t:syscalls:sys_enter_brk
stackcount -PU t:syscalls:sys_enter_brk

bpftrace -e 't:syscalls:sys_enter_brk {@[ustack,comm]=count();}'
  • shmsnoop:可以跟踪System V的共享内存系统调用:shmget()、shmat()、shmdt、以及shmctl()。
  • faults:跟踪缺页错误和对应的调用栈信息,截取首次使用该内存触发缺页错误的代码路径。缺页错误会直接导致RSS的增长,所以这里截取的调用栈信息可以用来解释进程内存用量的增长。
1
2
3
4
5
6
7
8
9
stackcount -U t:exceptions:page_fault_user
stackcount t:exceptions:page_fault_kernel

火焰图
stackcount -f -PU t:exceptions:page_fault_user > out.pagefaults01.txt
flamegraph.pl --hash --width=800 --title="Page Fault Flame Graph" \
--color=java --bgcolor=green < out.pagefaults01.txt >out.pagefaults01.svg

bpftrace -e 's:page-faults:1 {@[ustack,comm]=count();}'
  • ffaults根据文件名来跟踪缺页错误。
1
2
find / -name "mm.h"
bpftrace --include "/usr/src/kernels/4.18.0-193.28.1.el8_2.x86_64/include/linux/mm.h" -e 'k:handle_mm_fault{$vma=(struct vm_area_struct *)arg0; $file=$vma->vm_file->f_path.dentry->d_name.name; @[str($file)]=count();}'
  • vmscan:使用vmscan跟踪点来观察页换出守护进程(kswapd)的操作,该进程在系统内存压力上升时负责释放内存以便重用。
1
funccount 't:vmscan:*'
  • drsnoop:用来跟踪内存释放过程中直接回收部分,可以显示受到影响的进程,以及对应的延迟:直接回收所需的时间。可以用来分析内存受限的系统中应用程序的性能影响。

  • swapin:展示了那个进程正在从换页设备中换入页,前提是系统有正在使用的换页设备。

1
bpftrace -e 'k:swap_readpage{@[comm,pid]=count();} interval:s:1{time();print(@);clear(@)}'
  • hfaults:通过跟踪巨页相关的缺页错误信息,按进程展示详细信息。
1
bpftrace -e 'k:hugetlb_fault{@[pid,comm]=count();}'

第八章 文件系统

  • 逻辑I/O是指向文件系统发送的请求。如果这些请求最终必须要由磁盘设备服务,那么它们就变成了物理I/O。很多逻辑I/O直接从文件缓存中返回,而不必发往磁盘设备。
  • 裸I/O:一种应用程序绕过文件系统层直接使用磁盘设备的方式。
  • 文件系统缓存:
    • 页缓存:该缓存的内容是虚拟内存页,包括文件的内容,以及I/O缓冲的信息,该缓存的主要作用是提高文件性能和目录I/O性能。
    • inode缓存:inodes(索引节点)是文件系统用来描述所存对象的一个数据结构体。VFS层有一个通用版本的inode,Linux维护这个缓存,是因为检查权限以及读取其他元数据的时候,对这些结构体的读取非常频繁。
    • 目录缓存:又叫dcache,这个缓存包括目录元素名到VFS inode之间的映射信息,这可以提高路径名查找速度。
  • 预读取Read-Ahead:又叫预缓存,该功能如果检测到一个顺序式的读操作,就会预测出接下来会使用的页,主动将其加载到页缓存中。
  • 写回Write-Back:先在内存中缓存要修改的页,再在一段时间后由内核的工作线程将修改写入磁盘,这样可以避免应用程序阻塞于较慢的磁盘I/O。
  • 传统工具
    • df显示文件系统的磁盘用量
    • mount可以将文件系统挂载到系统上,并且可以列出这些文件系统的类型和挂载参数。
    • strace可以跟踪系统中的系统调用,可以用这个命令来观察系统中的文件系统调用操作。
1
strace -tttT cksum /usr/bin/cksum
  • perf可以跟踪文件系统跟踪点,利用kprobes来跟踪VFS和文件系统的内部函数
1
2
3
4
5
perf trace cksum /usr/bin/cksum
perf stat -e 'ext4:*' -a
perf record -e ext4:ext4_da_write_begin -a // 由于perf.data是写入文件系统的,如果跟踪的是文件系统的写事件,那么就会产生一个自反馈循环

bpftrace -e 't:ext4:ext4_da_write_begin{@ = hist(args->len);}'
  • opensnoop:跟踪文件打开事件,对发现系统中使用的数据文件、日志文件以及配置文件来说十分有用。该工具还可以揭示由于快速打开大量文件导致的性能问题

  • statsnoop:跟踪stats类型的系统调用。stats返回的是文件的信息。

  • syncsnoop:可以配合时间戳展示sync调用信息。sync的作用是将修改过的数据写回磁盘。

  • mmapfiles: 跟踪mmap调用,并且统计映射入内存地址范围的文件频率信息

1
2
3
4
5
6
7
8
9
#!/usr/bin/bpftrace
#include <linux/mm.h>
kprobe:do_mmap{
$file = (struct file *)arg0;
$name = $file->f_path.dentry;
$dir1 = $name->d_parent;
$dir2 = $dir1->d_parent;
@[str($dir2->d_name.name), str($dir1->d_name.name),str($name->d_name.name)] = count();
}
  • scread:跟踪read系统调用,同时展示对应的文件名
1
2
3
4
5
6
7
8
9
#!/usr/bin/bpftrace
#include <linux/sched.h>
#include <linux/fs.h>
#include <linux/fdtable.h>
t:syscalls:sys_enter_read{
$task = (struct task_struct *)curtask;
$file = (struct file *)*($task->files->fdt->fd + args->fd); // 运行失败
@filename[str($file->f_path.dentry->d_name.name)] = count();
}
  • fmapfault 跟踪内存映射文件的缺页错误,按进程名和文件名来统计。
1
2
3
4
5
6
7
#!/usr/bin/bpftrace
#include <linux/mm.h>
kprobe:filemap_fault{
$vf = (struct vm_fault *)arg0;
$file = $vf->vma->vm_file->f_path.dentry->d_name.name;
@[comm, str($file)] = count();
}
  • filelife 展示短期文件的生命周期:这些文件在跟踪过程中产生并且随后就被删除了
  • vfsstat 可以摘要统计常见的VFS调用:读/写(I/O)、创建、打开,以及fsync。这个工具可以提供一个最高层次的虚拟文件系统操作负载分析。
  • vfscount 统计所有的VFS函数。
1
2
funccount 'vfs_*'
bpftrace -e 'kprobe:vfs_* {@[func] = count();}'
  • vfssize 可以以直方图方式统计VFS读取尺寸和写入尺寸,并按进程名、VFS文件名以及操作类型进行分类。
  • fileslower 用于显示延迟超过某个阈值的同步模式的文件读取和写入操作。
  • filetop 显示读写最频繁的文件的文件名
  • 同步写操作必须要等待存储I/O完全完成,即写穿透(write-through)模式,而普通文件读写的写入操作只要写入缓存就成功了,即写回模式(write-back)。
  • cachestat 展示页缓存的命中率统计信息。可以用来检查页缓存的命中率和有效程度。
  • cachetop 按进程统计cachestat
  • writeback.bt 展示页缓存的写回操作:页扫描的时间、脏页写入磁盘的时间、写回事件的类型,以及持续的时间。
    • periodic 周期性写回操作,涉及的页不多
    • background 后台写回操作,每次写入很多页,一般是在系统空闲内存低的情况下进行的异步页写回操作。
  • dcstat 可以展示目录缓存(dcache)的统计信息.
  • dcsnoop 跟踪目录缓存的查找操作,展示每次查找的详细信息。
  • mountsnoop 输出挂载的文件系统。
  • xfsslower 跟踪常见的XFS文件系统操作:对超过阈值的慢速操作打印出每个事件的详细信息。
  • xfsdist 作用是观察XFS文件系统,以直方图方式统计常见的操作延迟
  • ext4dist 以直方图方式统计常见的操作延迟
  • icstat 跟踪inode缓存的查找操作,并打印出每秒统计结果

部署spack

下载

release(推荐)

github下载最新的release:github.com/spack/spack…

git

1
2
3
git clone https://github.com/spack/spack.git ~/spack
cd ~/spack
git checkout releases/v0.17

✨tips: github 国内下载慢,百度 github镜像加速站 或 油猴脚本 - github 高速下载

激活

对于超算/lab/hpc等多用户场景,建议将spack本地放在全局/共享目录下

单个普通用户建议放在 ~/.spack/ 或 /opt下

解压下载好的压缩包, 加载环境变量(根据具体情况变更路径):

1
source spack/share/spack/setup-env.sh

可以将此命令写在~/.bashrc/etc/profile中 , 打开终端自动生效

配置

spack 的用户配置文件均在 ~/.spack 下,首次使用 spack 可能没有此目录,使用 3.2 会自动创建

基本配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
$ spack config get config > ~/.spack/config.yaml
$ spack config get config
config:
install_tree:
root: $spack/opt/spack
projections:
all: ${ARCHITECTURE}/${COMPILERNAME}-${COMPILERVER}/${PACKAGE}-${VERSION}-${HASH}
template_dirs:
- $spack/share/spack/templates
build_stage:
- $tempdir/$user/spack-stage
- $user_cache_path/stage
test_stage: $user_cache_path/test
source_cache: $spack/var/spack/cache
misc_cache: $user_cache_path/cache
connect_timeout: 10
verify_ssl: true
suppress_gpg_warnings: false
install_missing_compilers: false
checksum: true
deprecated: false
dirty: false
build_language: C
locks: true
url_fetch_method: urllib
ccache: false
concretizer: clingo
db_lock_timeout: 3
package_lock_timeout: null
shared_linking: rpath
allow_sgid: true
terminal_title: false
debug: false
build_jobs: 16

解析:

  • install_tree 中 root 为当前用户 软件安装的路径 , projections 为软件路径的命名规范
  • verify_ssl 在install时会校验url的ssl证书,离线环境可以选择关闭(值为 fasle)
  • locks 锁机制: 默认开启,同一用户分别安装相同的软件,只允许有一个进程执行,执行完后,另一个进程将跳过该软件安装;关闭的话,可能制造重复安装,但在某些文件系统的特殊权限限制下,需要关闭,否则会报奇怪的读写错误
  • build_job 安装时默认使用的最大cpu核心数
  • checksum 校验源码包的hash值

编译器

使用spack compiler find将会自动查找本机的所有编译器,生成配置文件~/.spack/linux/compilers.yaml

spack compilers查看添加到配置文件中的编译器

1
2
3
4
5
6
7
8
9
10
11
12
13
$ spack compilers
==> Available compilers
-- clang centos7-x86_64 -----------------------------------------
clang@12.0.1 clang@3.4.2

-- gcc centos7-x86_64 -------------------------------------------
gcc@10.2.0 gcc@8.5.0 gcc@7.5.0 gcc@6.5.0 gcc@4.9.4 gcc@4.8.5

-- intel centos7-x86_64 -----------------------------------------
intel@2021.4.0 intel@19.0.5.281

-- oneapi centos7-x86_64 ----------------------------------------
oneapi@2021.4.0

源码镜像仓库

自建源码镜像仓库

1
$ spack mirror create -d <PATH> --all

一个标准的源码镜像仓库符合以下目录规范:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mirror/
cmake/
cmake-2.8.10.2.tar.gz
dyninst/
dyninst-8.1.1.tgz
dyninst-8.1.2.tgz
libdwarf/
libdwarf-20130126.tar.gz
libdwarf-20130207.tar.gz
libdwarf-20130729.tar.gz
libelf/
libelf-0.8.12.tar.gz
libelf-0.8.13.tar.gz
libunwind/
libunwind-1.1.tar.gz
mpich/
mpich-3.0.4.tar.gz
mvapich2/
mvapich2-1.9.tgz

添加私有源码镜像仓库

1
$ spack mirror add <scope> <path>

生成配置文件~/.spack/linux/mirrors.yaml

例如 添加 一个 在/opt/mirror下的源码镜像仓库,给它取名为haha:

1
spack mirror add haha /opt/mirror

查看已添加的源码镜像仓库

1
2
$ spack mirror list
spack-public https://mirror.spack.io

默认使用 位于美国亚马逊云的 spack公共仓库 mirror.spack.io ,在国内获取 源码/索引 的速度可能会很慢

脚本源

查看当前使用的脚本源

1
2
3
$ spack repo list
==> 1 package repository.
builtin /root/spack/var/spack/repos/builtin

默认使用spack官方的脚本源

添加私有脚本源

一个标准的spack脚本源符合如下的路径规范:

1
2
3
4
5
6
7
$ tree -L /root/spack/var/spack/repos/yeesuan
/root/spack/var/spack/repos/yeesuan
├── packages
│ ├── gromacs
│ │ ├── package.py
│ │ └── __pycache__
└── repo.yaml

其中 repo.yaml 定义了该脚本源的命名空间:

1
2
repo:
namespace: 'yeesuan'

添加该脚本源:

1
$ spack repo add /root/spack/var/spack/repos/yeesuan

modules

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
$ spack config get modules > ~/.spack/modules
$ spack config get modules
modules:
prefix_inspections:
lib:
- LD_LIBRARY_PATH
lib64:
- LD_LIBRARY_PATH
bin:
- PATH
man:
- MANPATH
share/man:
- MANPATH
share/aclocal:
- ACLOCAL_PATH
lib/pkgconfig:
- PKG_CONFIG_PATH
lib64/pkgconfig:
- PKG_CONFIG_PATH
share/pkgconfig:
- PKG_CONFIG_PATH
? ''
: - CMAKE_PREFIX_PATH

# These are configurations for the module set named "default"
default:
# roots:
# tcl: $spack/share/spack/modules
# lmod: $spack/share/spack/lmod
enable:
- tcl

# Default configurations if lmod is enabled
lmod:
hierarchy:
- mpi

prefix_inspections:定义了声明的环境变量和路径的对应关系

其中defalut中roots的tcl /lmod 定义了 安装软件后,modules 文件的安装位置

自定义packages

spack external find可以添加系统中的软件,为spack 管理 (超算环境不推荐)

1
2
3
4
5
6
7
8
$ spack external find
==> The following specs have been detected on this system and added to /root/.spack/packages.yaml
bash@4.2.46 gawk@4.0.2 krb5@1.15.1 pkg-config@0.27.1 sqlite@3.7.17
bzip2@1.0.6 gcc@4.8.5 llvm@3.4.2 python@2.7.5 tar@1.26
cpio@2.11 git@2.36.0 llvm-doe@3.4.2 python@3.6.8 texinfo@5.1
diffutils@3.3 gmake@3.82 openssh@7.4p1 rsync@3.1.2 xz@5.2.2
file@5.11 go@1.18.1 openssl@1.0.2k-fips rust@1.60.0
findutils@4.5.11 groff@1.22.2 perl@5.16.3 sed@4.2.2

这是我们自定义添加的两块软件:

1
2
3
4
5
6
7
8
9
packages:
cmake:
externals:
- spec: cmake@3.21.4
prefix: /yeesuan/linux-centos7-haswell/gcc-4.8.5/cmake-3.21.4-4q7yowzqqc6x36tsxd2bsgeenwci6iqt
util-linux-uuid:
externals:
- spec: util-linux-uuid@2.36.2
prefix: /yeesuan/linux-centos7-haswell/gcc-4.8.5/util-linux-uuid-2.36.2-psepywix72fmt453hwcmrepmqslrai3a

可以按照上述规范添加自己的软件

基本使用

spack中有什么?

基础命令:spack list

默认返回所有支持spack安装的软件:

1
2
3
4
$ spack list
==> 5969 packages.
3dtk intel-oneapi-inspector pexsi py-pygeos r-parallelmap
3proxy intel-oneapi-ipp pfapack py-pygetwindow r-param helpers

支持通配符查找:

1
2
3
4
5
$ spack list *blas*
==> 18 packages.
blaspp blast-legacy cblas hipblas ncbi-magicblast openblas
blasr blast-plus flexiblas libblastrampoline ncbi-rmblastn rocblas
blasr-libcpp blast2go graphblast liblas netlib-xblas samblaster

查找描述里包含某个关键词:

1
2
3
4
5
6
7
8
9
$ spack list -d physics
==> 46 packages.
albany cradl flecsph herwig3 n2p2 py-openmc sombrero
alps damask freefem herwigpp openmc py-uproot3-methods thepeg
amp datatransferkit geant4 jali pennant pythia6 trilinos
ascent delphes genfit libsakura podio pythia8 yambo
axom exciting hepmc lorene portage r-qvalue
clhep fastjet hepmc3 mcutils precice recola
cp2k flecsi heputils minuit py-espresso rivet

查看软件详细信息

基础命令:spack info <package_name>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
$ spack info vasp
MakefilePackage: vasp

Description:
The Vienna Ab initio Simulation Package (VASP) is a computer program
for atomic scale materials modelling, e.g. electronic structure
calculations and quantum-mechanical molecular dynamics, from first
principles.

Homepage: https://vasp.at

Externally Detectable:
False

Tags:
None

Preferred version:
6.1.1 file:///root/vasp.6.1.1.tgz

Safe versions:
6.1.1 file:///root/vasp.6.1.1.tgz
5.4.4.pl2 file:///root/vasp.5.4.4.pl2.tgz
5.4.4 file:///root/vasp.5.4.4.tgz

Deprecated versions:
None

Variants:
Name [Default] When Allowed values Description
=============== ==== =============== ====================================

cuda [off] -- on, off Enables running on Nvidia GPUs
scalapack [off] -- on, off Enables build with SCALAPACK
vaspsol [off] -- on, off Enable VASPsol implicit solvation model
https://github.com/henniggroup/VASPsol
Installation Phases:
edit build install

Build Dependencies:
blas cuda fftw lapack mpi netlib-scalapack qd rsync

Link Dependencies:
blas cuda fftw lapack mpi netlib-scalapack qd

Run Dependencies:
mpi

Virtual Packages:
None

解析
1.MakefilePackage表示vasp用make构建

其他的,像gromacs 是CMakePackage,表示用cmake构建,fftw 是 Autotools, 流程中需要先configuremake

2.Description: 软件的介绍

3.Homepage:软件官网

4.Preferred version是推荐版本,Safe versions是安全的(经过充分验证的)版本

其中,左侧列是版本号,右侧列是软件的URL地址

spack默认使用 系统命令 curl “下载”软件 ,curl支持的协议有 https/http/file/…. ,上面例子表示默认使用当前路径下的源码包
5.Variants 我翻译为“特性”

第一列为特性的名字,第二列为特性的条件,第三列为特性的值,其中,第一列,变量名后的中括号内,是默认的变量值

6.Installation Phases 表示了该软件安装时的三个步骤

7.Build Dependencies 表示了构建前需要加载的环境

8.Link Dependencies 表示了构建时用到的链接库

9.Run Dependencies 表示了在使用软件时,需要加载的环境

安装软件

基础命令:spack install <package_name>

1
2
3
4
5
6
7
8
9
10
$ spack install zlib
==> Bootstrapping clingo from pre-built binaries
==> Installing zlib-1.2.11-3rlgy7ycxtoho44una6o3itgfjltkmpd
==> No binary for zlib-1.2.11-3rlgy7ycxtoho44una6o3itgfjltkmpd found: installing from source
==> Fetching https://mirror.spack.io/_source-cache/archive/c3/c3e5e9fdd5004dcb542feda5ee4f0ff0744628baf8ed2dd5d66f8ca1197cb1a1.tar.gz
==> No patches needed for zlib
==> zlib: Executing phase: 'install'
==> zlib: Successfully installed zlib-1.2.11-3rlgy7ycxtoho44una6o3itgfjltkmpd
Fetch: 1.06s. Build: 3.09s. Total: 4.15s.
[+] /home/spack/spack/opt/spack/linux-ubuntu18.04-x86_64/gcc-7.5.0/zlib-1.2.11-3rlgy7ycxtoho44una6o3itgfjltkmpd

进阶-高度自定义的安装命令

指定编译器

编译器需要搭配 百分号 “%”,只能跟在软件名后面

例如使用gcc编译器:

1
spack install zlib%gcc

软件名和编译器之间允许有多个空格,所以也可以写成这样:

1
spack install zlib %gcc

一般地,我们的系统中存在多个版本的gcc编译器,所以需要指定使用哪一个版本的编译器:

1
spack install zlib %gcc@6.5.0

需要说明的是,这里的编译器 名称,可能不会和 spack list 中一一对应,

例如,英特尔oneapi的编译器,list表里的名称为 intel-oneapi-compilers, 但spack compilers里则是intel

指定依赖

以2.1为例,vasp的依赖,其中的blas、lapack、netlib-scalapack 这三个数学库在英特尔的mkl库中均有实现,

所以安装vasp时可以指定使用mkl,搭配 “^”

1
spack install vasp ^intel-mkl

2020之后的mkl库的包名发生了变化,我们指定适合用2021.4.0版本的

1
spack install vasp ^intel-oneapi-mkl@2021.4.0

我们的cc编译器也想用intel的icc, 版本号和mkl相同

1
spack install vasp %intel@2021.4.0 ^intel-oneapi-mkl@2021.4.0

特性

仍以2.1为例,官方提供的vasp有一个叫vaspsol的特性,它是vasp的一个扩展包,默认关闭状态,如果想开启此特性,需要搭配“+”使用

1
spack install vasp +vaspsol

mpich的特性pmi的值有多个,默认为mpi

1
pmi [pmi]            --      off, pmi, pmi2, pmix    PMI interface.

我们想全都使用,则使用“=”表达,以英文小写逗号“,”分隔不同的值

1
spack install mpich mpi=pmi,pmi2,pmix

mpich的特性fortran的值默认时开启状态,我们不想使用它,搭配波浪线

1
spack install mpich ~fortran

多线程

构建时可以指定使用的最大cpu核心数,在install后面 加入 -j 参数, 后接核心数

1
spack install -j 64 vasp

✨tips: 可以通过系统命令 nproc 查询到最大核心数 ,也可以写成变量的形式

综合

以安装gcc6.5.0为例,使用系统自带的gcc4.8.5编译,需要开启几乎全部的特性,其中的go使用1.16版本

1
spack install -j 64 -y gcc@6.5.0 %gcc@4.8.5 +binutils+bootstrap+piclibs+strip languages=ada,c,c++,fortran,go,java,jit,lto,objc,obj-c++ ^go@1.16

✨tips: 其中的 “-y”可以默认选择一些软件的提示性的编译选项或其他选项

卸载软件

基础命令:spakc uninstall <package_name>

1
2
3
4
5
6
7
8
9
10
11
12
13
$ spack uninstall autoconf
==> Error: autoconf matches multiple packages:

-- linux-centos7-cascadelake / gcc@10.2.0 -----------------------
kwq2zrg autoconf@2.69

-- linux-centos7-skylake_avx512 / gcc@6.5.0 ---------------------
2vv3qa2 autoconf@2.69

==> Error: You can either:
a) use a more specific spec, or
b) specify the spec by its hash (e.g. `spack uninstall /hash`), or
c) use `spack uninstall --all` to uninstall ALL matching specs.

像上面,卸载报错,我们可以指定编译器:

1
spack uninstall autoconf %gcc@6.5.0

方便地,使用其hash值:

1
spack uninstall /2vv3qa2

默认卸载全部autoconf:

1
spack uninstall --all autoconf

有时候,卸载该软件也需要卸载其依赖:

1
spack uninstall -d <package_name>

查找已安装的软件

基础命令:spack find

1
2
3
4
5
6
7
8
9
10
11
$ spack find
==> 805 installed packages
-- linux-centos7-cascadelake / gcc@10.2.0 -----------------------
boost@1.77.0 intel-oneapi-mpi@2021.4.0 python@3.8.12
bzip2@1.0.8 libbsd@0.11.3 readline@8.1
cereal@1.3.0 libffi@3.3 sqlite@3.36.0

-- linux-centos7-cascadelake / intel@19.0.5.281 -----------------
abinit@9.4.2 intel-mpi@2019.5.281 parmetis@4.0.3
amg@1.2 intel-tbb@2020.3 parmetis@4.0.3
...

指定包名

1
2
3
4
5
6
7
$ spack find vasp
==> 2 installed packages
-- linux-centos7-cascadelake / intel@19.0.5.281 -----------------
vasp@5.4.4

-- linux-centos7-cascadelake / intel@2021.4.0 -------------------
vasp@6.1.0

指定编译器

1
2
3
4
$ spack find vasp %intel@2021.4.0
==> 1 installed package
-- linux-centos7-cascadelake / intel@2021.4.0 -------------------
vasp@6.1.0

指定版本号

1
2
3
4
$ spack find vasp@5.4.4
==> 1 installed package
-- linux-centos7-cascadelake / intel@19.0.5.281 -----------------
vasp@5.4.4

查看软件的特征值

1
2
3
4
5
6
7
$ spack find -l openmpi
==> 2 installed packages
-- linux-centos7-haswell / gcc@4.8.5 ----------------------------
pt2putc openmpi@4.1.1

-- linux-centos7-skylake_avx512 / gcc@6.5.0 ---------------------
tchnk2v openmpi@4.1.1

✨tips: 1.spack支持使用/<hash_value>的方式替代<package_name>,hash_vaule支持长写(完整 )和短写(前面3到7个字符) 2.可以使用 -L 查看完整的特征值

查看软件的特征值和依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ spack find -l -d openmpi
==> 2 installed packages
-- linux-centos7-haswell / gcc@4.8.5 ----------------------------
pt2putc openmpi@4.1.1
y5iroad hwloc@2.6.0
bmzkyfq libpciaccess@0.16
xkbs7ll libxml2@2.9.12
rpsecj5 libiconv@1.16
e6cbjcd xz@5.2.5
z6uqlxn zlib@1.2.11
ez4gk67 ncurses@6.2
owe3cjs libevent@2.1.12
jwr4s44 openssl@1.1.1l
2hfxs5z numactl@2.0.14
tjk74sh openssh@8.7p1
ugfkpjd libedit@3.1-20210216

查看软件的路径

1
2
3
4
5
6
7
spack find -p openmpi
==> 2 installed packages
-- linux-centos7-haswell / gcc@4.8.5 ----------------------------
openmpi@4.1.1 /yeesuan495/.spack/software/linux-centos7-haswell/gcc-4.8.5/openmpi-4.1.1-pt2putcrv2o53naxpesk3rsb4xl44pqa

-- linux-centos7-skylake_avx512 / gcc@6.5.0 ---------------------
openmpi@4.1.1 /yeesuan495/.spack/software/linux-centos7-skylake_avx512/gcc-6.5.0/openmpi-4.1.1-tchnk2vf4iskpfkh7giwurtmyipwmjma

location (建议用find -p)

location 命令也可以定位软件的路径

1
2
$ spack location -i openmpi%gcc@4.8.5
/yeesuan495/.spack/software/linux-centos7-haswell/gcc-4.8.5/openmpi-4.1.1-pt2putcrv2o53naxpesk3rsb4xl44pqa

cd 进入软件根目录

1
2
$ spack cd -i openmpi%gcc@4.8.5
[yxxxx@xxxx openmpi-4.1.1-pt2putcrv2o53naxpesk3rsb4xl44pqa]$

查看软件的命名空间

1
2
3
4
5
6
7
8
9
10
$ spack find -N mpich
==> 1 installed package
-- linux-centos7-skylake_avx512 / gcc@6.5.0 ---------------------
local.mpich@3.4.2

$ spack find -N xz
==> 3 installed packages
-- linux-centos7-cascadelake / gcc@10.2.0 -----------------------
builtin.xz@5.2.5
...

使用软件

基础命令:

加载软件环境变量 spack load <package_name>

卸载软件环境变量 spack unload <package_name>

查看已加载的依赖

1
2
3
4
5
$ spack find --loaded
==> 4 loaded packages
-- linux-centos7-cascadelake / intel@2021.4.0 -------------------
fftw@3.3.10 intel-oneapi-mpi@2021.4.0
intel-oneapi-mkl@2021.4.0 intel-oneapi-tbb@2021.4.0

仅加载软件本身或它的依赖

仅软件本身:

1
spack load --only package <package_name>

仅依赖:

1
spack load --only dependencies <package_name>

查看加载软件的所有环境变量

1
spack load --sh <package_name>

指定参数加载环境变量

指定版本号spack load <package_name>@<package_version>

指定编译器spack load <package_name> %<compiler_name>@<compiler_version>

指定用到的依赖/特性spack load <package_name> ^<dependency_name>

指定命名空间spack load <scope>.<package_name>

上述加载方式都可以组合使用

使用hash值 : spack load /<hash_value>

✨tips: 有时候我们查到的软件版本号和编译器甚至命名空间都一样,它将很难区分

通常,使用spack find -l -d <package_name> 可以打印多个“相同”的软件的依赖详情,我们选择其中一个的hash值加载

打工人的时间是如何计算的

作者:GPUS开发者
链接:https://zhuanlan.zhihu.com/p/339478619
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

今天主要说两点, 一点是如何正确的计算一段操作所用的时间。这里的一段操作是指的, GPU设备上的kernel计算, 以及, 数据传输操作。

正确的计时也是从今天开始的, CUDA优化章节的重要基础,因为你的代码干了什么, 例如对一张图片进行边缘查找, 或者颜色分布进行直方图统计, 这些工作量你本身, 作为代码的编写者, 是知道的. 此时再加上了正确的计时方法, 则你可以立刻衡量出来, “我的具体XXX操作过程, 在XXX ms内完成, 性能是XXX”(例如10张图片/秒)。

但是我们历年来, 很遗憾的看到, 大部分人的做法都是错误的. 甚至使用了错误的测时结果, 来气势汹汹询问一些问题. 此时, 因为你的基础部分(计时)是错误的, 从而导致了你的问题整体无效.

这点无论是从, 我们的论坛上的帖子中, 还是我们的直接的客户支持用, 用户给出的他们的代码中, 都可以看到这样的错误.

今天我们就说一下, 这些错误的根源, 和正确的计时方式该如何进行. 错误的计时根源往往有两种, 一种是对GPU上的代码片段的执行的特性, 具有误解。

例如在我们之前的文章中, 我们知道一个kernel的启动是异步的, 也就是一旦该kernel成功启动后, 它就开始在GPU上执行了. CPU这边的诸如<<<>>>()的菱形启动符, 是会在kernel完成了启动后, 就立刻返回CPU上的下一行代码执行的.

CPU并不自动等待GPU上的工作完成!

这点是CUDA在设计的时候, 为了充分能让GPU作为一个劳工的身份, 去完成一些重活而设计的; 而CPU作为CEO, 并不需要在”GPU劳工”辛苦忙碌的时候, 必须啥都不干的同步等待的。

就如同一家公司里的老板, 布置出来了活给员工, 那么员工在干活的期间, 老板并不是必须等待员工慢慢干完, 才能返回老板自己的下一个工作事项的。老板完全是布置完活后就没事了,然后可以继续给另外一个员工布置活, 或者自己悠闲地去喝着茶了。

这点说起来很简单, 但是很多人都在理解上犯了错. 我来举个例子.

https://bbs.gpuworld.cn/index.php?topic=73413.0

img

例如本帖, 本帖楼主犯了一个常见的错误, 没有等待kernel完成, 就立刻对它进行计时, 然后得出了错误的问题前提: “一个kernel如果被反复调用的话, 是会越来越慢的”。

我们看下该楼主的具体做法:

1
2
3
start = clock();
DeModuate <<<BLOCK_NUM, THREAD_NUM >>> (....);
end = clock();

楼主这里直接测了起始时刻start, 然后立刻用<<<>>>调用了自己的kernel, 然后不等该kernel”实际上的完成工作”, 就立刻测量了结束时间end, 然后就认为从start到end, 这两段时刻的差值, 是kernel的实际执行时间, 这是严重错误的。

这就像公司老板, 先看了一下手表, 现在的时刻是1点29分, 记录成Start; 然后叫了员工如花说,”如花,去把上次和我们合作活动的NV公司的联系人, 沟通一下XXX事宜”; 然后对如花说完这话后, 立刻又看了一下手表, 现在是1点30分, 记录成End.

然后老板认为,如花完成和某公司的沟通工作, 一共用时: 从1点29分到1点30分, 共总1分钟.

这显然是严重错误的. 这样的计时方式, 并不是员工实质完成一个工作的时间, 而只是老板(CPU)对员工(GPU)的派活, 所耗费的时间. 并不能实质衡量某工作的时间的.

类似的, 该帖子的楼主也犯了这个错误, 他也是立刻用<<<>>>给GPU派活后, 立刻看了一下表, 从而导致他理解得到了错误的信息, 从而让整个问题化为无意义. (错误的前提下, 给出的提问是无意义的)。

那么正确的做法是什么呢?

正确的做法(之一)是, CPU在给GPU派活前, 的确可以记录时刻Start; 但是一旦给GPU派活后, 必须等待GPU完成该活, 才能记录时刻End. 此时的End减去Start, 才是真正的干活耗时。

这就像公司老板给员工如花布置活前, 记录了1点29分为start时刻; 然后给如花布置了沟通联系的活了后, 老板等待, 例如2点00分, 如花届时完成了该活后, 才记录为end时刻.

此时的end - start = 2:00 - 1:29 = 31分钟, 才是如花真正干完该活所用的时间. 这样才是正确的.

不仅仅如此, 我们还会在今天的内容中看到, 除了老板自己去计时的方式, 我们还可以要求员工(GPU)去计时, 即员工如花自行在自己干活前记录一下开始时刻, 然后去干活, 然后员工如花在干完后, 自行也再记录一下结束时刻, 然后并将结束和开始的差值, 作为干活时间, 汇报给老板(CPU)即可.

回到该楼主的帖子, 我们很遗憾的看到, 该楼主在我们给出了两次回答和解决方式建议后, 即分别要求楼主用第三方工具验证他的计时错误的前提(这样他可以自行发现他的错误, 从而增长经验), 和直接给出了建议(即明确的告诉了他哪里是理解错了后), 他均无视了我们. 并继续在后续的跟帖中, 给出他自行认为的理解. 这点我们是感觉非常可惜的.

实际上人是互相尊重的, 特别是在作为提问者, 你更加应该尊重回答者给出的信息的. 无视这一点, 并取得”面子上”的好处, 是无益于事情的. 我们在这里今天严肃的提出这一点。

是希望其他的客户或者非客户, 在论坛提出了问题后, 在看到论坛给出的解答后, 不要为了”面子”, 带着有色眼镜, 从而实质上的无益于楼主们在论坛的经验的获取, 和以后遭遇相似问题时候的快速解决.

(反过来, 如果你尊重了论坛, 则你本次能反思得到了经验, 得到技术上的成长; 下次遇到后还能快速回忆场景, 快速解决, 节省干活时间, 增加在老板心中好的评价).

然后我们继续说一下该例子, 楼主的正当做法应该是:

  1. CPU记录开始时间
  2. CPU给GPU派活
  3. CPU等待GPU完成该活
  4. CPU记录结束时间

    我们在这里插入了步骤3, 也是手册上今天的CPU计时内容章节, 所推荐的做法(cudaDeviceSynchronize()同步等待, 或者其他任何等效的同步方式). 只有加上了该等待, 你的开始到结束的时间差, 才是真正的干活时间.

似是而非的计时方法

作者:GPUS开发者
链接:https://zhuanlan.zhihu.com/p/339698093
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

我们继续回到今天的第一个大话题, 正确的计时. 因为这话题的确很重要了, 没有了正确的计时, 一切对工作效果(代码运行快慢)的衡量工作, 都会变成虚有.

首先, 我们已经说了, 正确的逻辑顺序. 即CPU开始时刻记录->CPU发布任务给GPU->CPU等待GPU完成->CPU记录结束时刻。 这4个步骤, 任何一个步骤错误了, 都会导致错误的结果。

我们还在论坛上经常看到有人会: 启动kernel->记录开始时刻->记录结束时刻, 这样的做法也是错误的(记录开始时刻必须在启动kernel前)。特别的, 在使用非专业卡, 或者在WDDM驱动下, 该问题可能会被掩盖,即哪怕你先启动了kernel, 然后才记录开始时刻, 有的时候看起来”结果是对的”, 这是因为WDDM驱动之类的有缓冲, 它可能在某种情况下会导致kernel的启动, 是被延后执行(正好插入到你记录完开始时刻后), 从而导致可能的错误的”咦, 我本次这样写也对了”的假象,从而隐藏了问题, 从而让书写者本次可能会认为, 这样写没问题的假象。我们这里严重强调一下, 必须保证正确的逻辑顺序, 才能得到正确答案。

回到今天的计时章节. 除了正确的逻辑顺序, 和工作逻辑流程上的保证(即在开始工作前计第1次时间, 必须等待工作实际完成后, 才能计第2次时间). 我们还需要对基本的计时工具本身, 进行讨论. 本实践指南手册上, 在今天这里简单的说了一下, 你应当在正确的操作系统平台上, 使用正确的计时工具/函数调用. 而我们准备详细的说一下这点, 分别对应常见的台式机上的Windows开发平台, 和我们目前在售的嵌入式上的Jetson平台(Linux)上的正确做法. 这里我们只推荐两种正确做法(也有其他的, 但这两种是推荐的).

(1) 在Windows上请使用QueryPerformanceCounter()/Frequency()这两个函数来进行计时. (技术上的理由: 它使用了主板上ACPI提供的HPET计时器, 该计时器在常见的主板上, 保证了至少几个Mhz+以上的时间分辨率, 足够)。

(2) 在Jetson嵌入式平台上, 使用gettimeofday()系统调用来进行时刻的记录. 它也同样具有很好的时间分辨率/精确性.

然后在任何一个平台上, 均不建议使用__rdtsc()或者clock()函数进行计时. 这里要重要说一下。这两个函数均是多年来, 在我们的用户中, 流行的两种方式, 可惜它们均存在一种问题(真的是好可惜. 对的选择的不多, 错误的大家都很喜欢).

我们还用上文提到的那个帖子的错误做法(https://bbs.gpuworld.cn/index.php?topic=73413.0)举例好了(没错, 除了之前的那个计时错误, 该帖子还有更多的计时错误). 该帖子是今天的实践手册上的正确做法的, 多个连续反面例子. 还是很起到很好的警示作用的.

该帖子中, 记录时刻使用了clock(). 很可惜, 该函数在不同的环境下, 有不同的返回值解释. 我们分别看一下MSDN上的clock解释, 和Linux上的man(3)手册中的解释.

首先是Linux上man手册的解释, 这是我们的jetson平台. 在该平台上, 它返回的是调用者进程所耗费的”CPU时间”, 什么叫CPU时间, 和我们常用的时间有什么不同?

举个例子说, 我在CPU上执行了一个可执行文件, 上去执行了3秒, 然后突然打开了一个巨大的磁盘文件(假设我们用的是普通的机械硬盘), 或者需要从网络读取一个资源, 而突然卡顿上了10秒无响应, 然后又立刻执行了4秒. 那么此时, 它实际上使用的CPU时间是7秒(4+3), 也就是中途它卡在磁盘读取或者等待网络数据返回的时候, 那10秒虽然流逝了, 基本上是不耗费CPU的。这和我们常用概念中的实际时间(一共经过了17秒, 4+10+3=17), 是不同的.

因此你看, 虽然实际上计算了很久, 但是从”CPU时间”的角度说, 可能很短, 因此该函数和我们常用的实际生活中的时间概念是不同的, 在我们的jetson的linux平台上, 是不能使用的.

我们平常日使用的时间, 在计算机中, 叫real time, 即中文翻译是”实时钟”;也叫wall time, 就是你挂载墙上的钟上经过的时间.

幸运的是, 微软的Windows平台上的clock()实现(请参考MSDN), 是返回的wall time, 也就是可用的我们的实际时间, 那么看上去, Windows上至少能用它?

实际上很可惜, 和jetson上一样, Windows也不能用. 这是为何? 因为很多客户在使用的时候, 只考虑了该计时方式的逻辑上的意义, 而没有考虑该计时方式的精度/时间分辨率. 在Windows平台上, 该函数的分辨率只有计时Hz到小于1Khz, 用人话说就是, 假设是50Hz, 它最小的分辨率只有20ms(1秒=1000ms, 分成50个周期). 而我们的代码运行的速度往往很快, 某个片段往往很短, 例如13ms, 和37ms(随意的举例), 用该函数得到的结果可能就会分别取整到了0ms, 和20或者40ms了.

此时时间都错误的离谱. 这就像我们用墙上的钟表的秒针(最长的那个指针)来计时一样, 它的分辨率只有1s级别, 如果我们的代码运行了300ms, 你会发现秒针没动, 运行时间为0;

或者我们的代码运行了1.7s, 你会发现秒针动了1下或者2下, 时间也错的离谱.

因为秒针的时间分辨率/精度不够, 所以不能用来计时。而clock()也存在类似的这个问题. 所以也不能用。

我们需要的是逻辑正确, 精度足够的计时器.

也是今天实践手册上, 在说”计时器”的选择章节, 强调的重要因素. 幸运的是, 我们的QueryPerformanceCounter()和gettimeofday()在2个平台上均可以满足这两点要求. 因此它们才变成了今天我们推荐的计时工具(CPU端, 或者你理解成老板专用工具). 此外, 今天的两个被拒绝的工具中(clock & __rdtsc), 后者也存在一处或者多处问题, 因此也不能用.

rdtsc主要是存在时基漂移(在后期的CPU和主板中逐渐的解决了), 不能跨核心同步, 以及, 还有rdtscp版本来解决其他”CPU乱序执行上”的其他问题. 这些问题或多或少的在后来的CPU/主板/操作系统中都逐渐解决了, 但是我们不敢打包票. 因此也不推荐使用.

GPU端的CUDA Event计时

好了. 你已经会了CPU端计时了, 记住, 正确的计时逻辑顺序, 和使用正确的计时工具, 这两点满足了, 你就会有正确的测时结果. 我们继续说一下GPU端的计时. 和CPU端的计时类似, 它同样需要2个方面: 正确的逻辑, 和正确的工具使用.

在开始这两点之前, 我们先说一下GPU端计时的优势和特色.

优势和特色主要有两点, 1个就是可以将计时本身当作命令发布下去, 而不需要一定在特定的时刻, CPU亲自动手去记录. 2个就是可以方便记录比较复杂的计时场景(特别是多流和传输/计算异步重叠的时候). 我们先说一下1点.

还记得我们之前的例子么? 老板让员工如花去完成一个活, 然后老板在如花开始动手之前, 和如花完整的完成了工作后, 分别进行了时间记录. 这个例子还可以这样做——老板: “如花,你去干XXX活. 干活前后你记下时间, 最后将这个活和用时都汇报给我”. 这种方式相当于是老板将计时本身的任务, 当成活布置给了员工, 这样老板可以在半夜12点突发奇想, 通过微信给员工如花布置任务: “明天9点上班后, 干YYY. 我晚点来, 你统计一下时间”. 而不需要老板必须在明天9点那一瞬间, 亲自不布置记录.

也不需要老板时刻的焦急的等待如花去完成, 最后在如花于11点完成的瞬间, 立刻找笔纸记录下来结束时间.

大大减轻了老板的调度成本, 和指挥公司运营的压力. 类似的, 我们的GPU作为一个劳力或者说协处理器的角色, CPU也需要调度它.

通过GPU端计时, 我们可以将计时本身的任务, 布置给GPU即可. 这样CPU上的调度(代码)可以有更自由的安排, 也减轻了用户们写代码上的逻辑安排的压力. 我们具体看看怎么做:

GPU上的计时, 是通过CUDA Event来完成的, 它可以理解成一种非常轻量的空白kernel, 只用来记录一下时间而已 (因此很多用户忧虑的, GPU上执行event的记录工作, 会不会拖慢GPU —- 完全不会的).

具体说, 是通过在特定的CUDA流中, 发布一种叫cudaEventRecord()的任务进去而已.

这样, 该流中的命令们, 一旦当GPU执行到”记录Event”的时刻, GPU就立刻记录一下当前的时间(注意, 是从GPU的角度, 有它的时间分辨率. 本实践手册保证了至少2Mhz+的分辨率/精度). 然后继续往下执行该流中的其他常规任务(例如kernel计算). 这种记录几乎完全不占用GPU的处理能力.

所以在GPU上, 我们可以知道, 该工具(CUDA Event)是精确可靠的计时工具, 那么只剩下来逻辑的正确性了. 保证了后者, 你就可以得到了GPU上的正确计时, 不能保证, 则一切无从谈起. 但是很遗憾的, 我们从这10年来的客户反馈上来看, 很多客户并不能合理的安排一个GPU上的计时逻辑. 从而导致了错误的解决.

我先说一下GPU上正确的逻辑安排应当是一个什么顺序的:

假设用户已经有了1个CUDA流stream, 2个CUDA Event分别是start和end, 现在需要对该流中的1个kernel K, 进行计时, 正确的逻辑是:

  1. cudaEventRecord(start, stream); //在流中发布计时命令, 要求记录start时间
  2. K<<<….stream>>>(); //在流中发布kernel K
  3. cudaEventRecord(end, stream); //在流中发布计时end时间
  4. 同步

其中第4点非常重要, 常见的有3中做法. 即cudaDeviceSynchronize()进行设备同步, cudaStreamSynchronize()进行流同步, cudaEventSynchronize()进行Event同步.

其中设备同步是大家喜闻乐见的, 相当于老板等待公司人员全部空闲下来的时候, 再检查两个start和end时间(的差). 例如老板可能会等待晚上9点, 发现都下班了, 然后再优先的拿出今天如花完成工作K的记录本, 查看一下K的前后时间, 得到一个用时.

这种方式虽然最简单方便, 但是老板可能会在一个很晚的时间后, 才能得到今天的工作汇总(因为你进行了设备同步, 等待设备(公司)上的所有工作完成后才能得到这个汇总), 很多时候不恰当, 或者导致GPU设备/公司运营效率低下.

第二种方式, 则是进行流同步, 大致相当于员工同步. 老板可以等待如花突然闲置下来了, 然后拿出如花的工作记录本, 查看一下她完成工作K的信息, 和前后工作的记录时刻. 从而知道了如花对工作K的计时. 这种方式好很多, 因为此时, 另外一个员工翠花可能依然有活在干, 时间也不过是下午3点, 老板及早的知道了, 还说不定有余力能调度其他事项. 提高公司运营效率.

第三种方式, 则是进行事件(Event)同步, 这相当于员工同步里的细项. 特别是在该员工有连续的多个活的时候非常好用(例如老板给如花布置了活K和K2, 并要求在K完成后立刻计时). 老板可以等待员工如花完成了工作K, 并记录了结束时刻的那一个瞬间, 立刻从沉睡的沙发上惊醒, 然后立刻检查如花该工作的信息和前后时刻. 而如花此时本身, 已经继续去干下一个活K2了.

这样老板不仅及时的在惊醒的瞬间, 慢慢开始泡茶喝(相当于CPU上的后续调度处理)检查如花的活K的相关信息的时候, 如花自身还在干下一个活. 提高了老板和该员工的同时的调度和工作效率.

所以你看, 最应当做的应该是方式3(对事件进行同步).

但是虽然事件同步很好用. 但是我们很遗憾的看到, 很多用户并不能正确的使用它.

毕竟这就如同很多家公司存在, 并不是所有的公司的老板, 都有能完善强力的调度协调能力的. 我们分析了一下历年来用户们不能正确的通过事件同步, 来计时的一些问题, 主要暴露出来的问题有这些点:

用户不能理解cudaEventRecord()只是发布了一个让GPU计时的”任务”. 这种发布并非是当前的CPU发布命令时候的时刻, 而是GPU上实际执行到了该计时任务处的时刻.

还用我们刚才的例子吧. 老板半夜在12点发布了微信命令, 如花在第二天的9点才开始干活, 那么实际上执行开始时间记录(cudaEventRecord(start, straem))的时刻, 是第二天的9点! 而不是半夜的12点!

这点相当多的用户都理解错了. 一定要注意.

其次则是, 必须要等待实际上的stream中的K任务完成了, 并记录了后续的stop时间后, 才能用两个时间做减法, 得到夹在中间的K任务的真正耗时.

也可以看我们之前的举例, 如花在9点开始干活, 然后干了2个小时的K任务, 完成于11点, 并记录完成事件stop; 然后她继续从11点又干了3个小时的任务K2, 以及其他各种任务到下午5点下班. 然后工作里的其他员工都干到了晚上11点才下班.

那么作为老板, 你在10点立刻去尝试减掉开始时刻9点是不对的, 因为该活并没有实际上的完成. 从晚上11点(设备同步)去检查, 发现是上午11点完成的, 得到11-9=2, 是对的; 从下午5点(如花下班, 流同步)去检查, 发现也是上午11点完成的, 也得到2个小时, 也是对的; 从上午11点整去检查(如花完成记录K完成后的stop事件时间), 也能得到2个小时, 也是对的.

这分别对应了我们的cudaEvent/Stream和DeviceSynchronize()三个同步调用.

读者们可以大致评估一下效果, 但不管怎样, 你要记住, 发布记录命令本身也是一个任务, 必须等到该任务实际上完成了记录才可以(用3大同步去等!). 以及, 切记任务实际上的完成记录的时间, 和你发布这一系列命令的时间毫无关系(你在半夜12点的微信上发布的好么!)

记录这两点, 大致你对GPU端的cuda event计时就没有大问题了.

GPU端Event计时的重要特色

作者:GPUS开发者
链接:https://zhuanlan.zhihu.com/p/340203355
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

我们在上面的内容中说过, cuda event计时还有它的丰富的特色, 你已经看到了它能正确的计时, 还不耽误老板(CPU)上的提前半夜调度的便利. 我们下一个要讲的, 就是说它可以方便的跨流, 跨一堆任务进行计时. 但在说这个特色前, 我们需要将手册的一点说法进行修正。

本实践手册在今天的GPU计时章节, 说了, 一定要在默认流中进行调用啊, 等等的话, 这个说法实际上是不对的。

(坑:手册的这段有它的历史来源, 已经10年不管了,不用看手册里的解释,它的确是不对的。CUDA这10年来, 历经了v2 API变更, 从每个host线程独享一个context变化到共享; 历经了对非默认流同步的流的变更, 等等。)

手册这里的一定要在默认流中同步的做法, 减少了调度上的灵活性, 并实际上导致了性能的下降。我们不建议一定需要在默认流中记录并同步, 这是完全没有必要的. 什么手册上说的, 在其他流中记录会不准的, 在我们的多年使用中, 并不存在这个现象。

我们在修正了手册的这点说法后, 继续到GPU端的event计时上的其他特色。

这种其他特色是指, 可以对一个实际的操作序列整体, 进行计时。

我们还用一家公司来举例好了. 该公司今天有3个主题: 联系另外一家B, 得到信息. 然后根据信息开始工作K. 同时今天还有消防检查, 要一大早的就进行安全隐患排查。

于是老板想知道, 在有消防检查主题存在的情况下, 今天的工作效率(联系合作伙伴公司, 并后续的进行工作K)还能有多少, 这就涉及到了计时, 一个比较复杂的计时。

这实际上是比较实际的应用, 因为你单纯的今天公司啥都没有, 只专心干1-2个主题的情况比较少见,很多时候都是公司里有这种复杂场景的, 此时的工作效率评估很有必要。

这里可以用修复了手册上”必须在0流/默认流中”进行记录的说法, 此时完全可以有3个员工, Tina, Linda, Rabbit, 来同时开始做事, 它们大致对应了3个流。

老板首先对Tina下令, 序列如下: 记录你的开始工作时间Start, 你联系公司S光, 然后将结果告诉同事Linda, 你继续干你的日常其他活。

接着老板对Linda下令, 你先干你手工的日常活, 等Tina告诉你了S光的信息后, 你根据这个修改报价, 并完成当前积攒的出货. 然后你这两个干完后, 记录结束时间End。

最后老板对Rabbit下令, 你今天刷一天的墙, 并将灭火器的位置和容量都检查好, 等待迎接消防检查.

然后老板感兴趣的是这里的在Rabbit忙碌于今天的卫生环境处理的时候, Tina从联系S光开始(Start时刻), 到最后的Linda完成改价格和出货的时刻(End时刻), 这两个人都用了多久, 效率如何.(注意, 为了消防检查, 已经占用了一部分公司Rabbit资源).

这个时刻即可使用上刚才说的GPU端的Event计时, 配合多流同步操作. 即分别是:

流Tina中: Record Event Start -> 联系S光 -> Recrod Event 联系Done-> other jobs
流Linda中: 设备端同步Event: 联系Done -> 改价格并发货 -> 记录完成事件Stop -> Other jobs
流Rabbit: 刷墙 -> 消防任务

这是一个比较实际的GPU应用的流程, 你将这些都替换成流中的异步传输, 替换成kernel间的依赖, 替换Rabbit成一个不能压满设备的需要持续调用的小kernel. 即可符合实际中的常见情况。

这种常见情况必须这样操作, 才有可能充分利用GPU. 手册里的必须在特定流中做特定事情, 无法正确评估实际应用场景中的时间和性能. 所以我们举了这个例子.

好了, 回到这个例子. 注意我们这里有好多Event, 那么老板关系的主工作流程是从哪里到哪里? 是到Tina完成联系S光(Event 联系_Done)吗? 不是的.

实际上是到流Linda中的”发货完成事件”。

需要老板在至少等待到该事件完成后, 才可以评估联系同行—修改价格—发货(或者发布新报价)这一系列流程中的复杂任务, 的时间和工作性能表现. (当然, 老板也可能等晚上全部员工散场后再看, 但那样可能会影响老板本来调度能力, 因为她可能半下午就可以根据情况决定是否要发布新任务或者修改计划了)

你会看到, 我们这里复杂案例, 跨越了多个流来计时, 并在轻量背景GPU任务持续存在的情况下(Rabbit), 合理的评估了员工Tina和Linda的整体工作序列的表现. 这是修复了手册上的错误说法, 并给出的非常实际的例子。

GPU显存的特色

今天的内容将首先介绍GPU的显存的特色, 包括巨大的带宽, 巨大的延迟, 和最小传输粒度等. 这些都是很多讲CUDA的书容易忽视的地方(它们往往注重在计算本身),然后再具体分析GPU的片外的显存, 片内的shared memory, 和片内的各级缓存的情况. 这些情况的分析对于写出一个效率良好的CUDA代码, 还是很重要的. 你只有先了解一个东西, 才能应对好这个东西. 而且今天的实践手册章节还介绍了一些坑和注意事项, 以及, 给出了一些经典的例子(但是今天我们可能不说这个例子).

我们先看看GPU的显存的特色.

GPU的显存的特色主要在于, 相比CPU依靠一级, 二级, 三级(4级)等各级缓存, 以及, 充分的硬件prefetch等措施, 来降低硬件所预估的某个小范围内的某个随机数据的交付时间(即, 降低一定范围内的读取延迟), GPU并不依靠这点。

GPU上的显存大部分情况, 是具有非常长的延迟的. 这种非常长的延迟并不会影响显存的巨大带宽的发挥, 手册今天你看到了一张7.0计算能力的V100的卡, 具有将近900GB/s的峰值带宽, 非常惊人的。

而之所以能不影响显存本身的巨大带宽的发挥, 是依靠我们上次说过的, GPU上能够海量并发执行的线程数量, 当一个线程在等待超长延迟的读取的结果的返回的时候, 能够切换到下一个线程(或者更高的执行调度单位)上去执行, 从而将这个延迟给掩盖掉(详见我们之前的CUDA C编程指南手册). 这样, GPU的显存和CPU的内存, 或者说GPU和CPU上的存储器的体系结构, 具有截然不同的鲜明特别. 也就是我们常说的, CPU(的各级大容量和快速访问)的缓存, 是为了降低延迟而设计的; 而GPU(的缺乏这种各级大容量的缓存), 则是为了尽量将有用的晶体管交给计算或者资源(例如寄存器)进行延迟掩盖, 从而能发挥接近峰值的吞吐率, 而设计的.

所以, 这种”高延迟”和”大带宽”并存的显存访问方式, 将会伴随我们几乎以后所有的CUDA代码的编写时候的考虑。

此外, 今日的实践手册还指出了. 大部分的CUDA代码在追求极致性能的时候, 所面临的主要矛盾, 往往是在显存访问上. (而不是在其他的几个矛盾, 例如计算性能上), 不过这是不一定的, 虽然大部分的GPU程序可能如此, 但是很多还是强烈的需求计算性能的, 而不一定短板是在显存的带宽之类的上. 而大部分GPU程序只所以如此, 往往来源于以下3个方面:

(1) NV在生产显卡的时候, 往往搭配一定的计算性能和显存性能. 例如说, 低端的只有几个TFlops或者TIOPS的卡, 可能往往配备128-bit的GDDR5/6的显存, 带宽本身就较低. 一般这种搭配, 对于很多算法实现, 总是计算性能够, 而访存带宽不够的. 所以出现了这种现象。

这种现象往往需要通过深入的实践GPU上的CUDA代码书写, 才能逐渐在具体硬件上得到缓解(例如今天下面要讲的, 如果在固定的一定峰值性能的显存的卡上, 充分发挥出来global memory的性能. 或者充分使用shared/texture之类的, 来尽量节省显存的访问, 来发挥性能, 这样的优化),总之你是直接买回来一张卡, 就试图认为我随意写写, 显存就不是问题, 问题在GPU核心芯片的计算上, 那是不可能的。

老黄的搭配总是有它的一定道理的, 总是你不好好优化存储器访问, 随意的写写, 是会卡住的. 当然, 这也是为何本手册的下面的各个不同的存储器/缓存的优化指南存在的意义。

(2) 很多代码是从CPU迁移过来的, 或者说老代码逐渐优化到GPU上了. 以前在CPU上的时候, 计算性能和访存性能拉得并不开, 但是随着GPU的到来和飞速发展, 它的计算比访存的差距, 拉得越来越大,例如原本在CPU上已经做好的东西, 在CPU上计算和显存是7:1(随意的假设)时候, 大致内存和CPU性能都没有太大的短板. 一切都很好; 而如果你迁移到GPU上的时候, 因为GPU的计算峰值提升的幅度, 远远比显存的带宽提升的幅度大, 这个比值可能变成了77:1,此时原本的各方面均衡的代码, 往往会优先的主要体现在访存上, 出现短板. 因此就需要优化。而且特别的, 如果我们之前说的, 因为CPU上有各级较大容量的缓存在为你服务着, 而GPU上(相比来说)往往没有甚至非常缺乏, 此时就算是提升的幅度比一样, 你也需要考虑优化访存了。

(3) 随着老黄历代的GPU的性能发展, GPU本身的前后代对比(而不是GPU比CPU), 依然会呈现一个计算峰值的提升比, 会远远超过显存带宽的提升比的问题. 也就是我们常说的, 随着一代一代的新卡的问世, 计算性能将比访存性能提升的更快。

例如我们知道大约10年前就有100GB/s-200GB/s的显存的卡了. 例如当年我们使用的经典老卡GTX480. 而今天手册的例子上的V100才不过到890多GB/s, 这是将近过去了一个10年了,只有几倍的提升,而考虑计算峰值, 当年480的1TFLOPS的卡, 而现在的卡不考虑TensorCore也有几十TFlops了(最新的30系列的卡),这就几十倍了. 算上TensorCore那就计算性能提升的更惊人了.

你会看到, 因为这种一代代的GPU卡, 计算性能总是更快速的提升的现象. 就自动会让GPU上的代码, 包括哪些从头就是为GPU设计和实现的代码, 本身会随着时间的发展, 自动趋向于卡访存的情况.

因此大部分的代码, 应当首先考虑本手册说的, 对显存的特性的了解和优化. (GPU上原生设计的代码本身如此, 更不用说那些从CPU迁移过来的代码了)。

这三点可能是主要的为何本手册上去先说这个的原因。

GPU显存的粒度

我们先看一下具体的, 一些使用显存所需要注意的事项, 以及, 一些简单的 技巧, 来提高显存的性能的使用和发挥.

我们回到之前的话题, 之前主要说了GPU显存的特点, 即高带宽和高延迟, 以及, 在GPU上面应用的主要容易遇到的矛盾或者说短板是显存带宽, 而往往不是计算。

这里还需要说一下, 显存的访问, 是具有最低的粒度要求的. 这个粒度很多时候是32B (但HBM的系列可能除外, 之前看到的第三方资料—-我没有使用过HBM的N卡, 说是64B),

粒度(granularity)实际上是指, 你访问了某段地址的哪怕1B, GPU也要至少传输32B的问题. 从已有的资料和历年的GTC的PDF和PPT来看, 这个32B, 指的是从显存(DRAM)传输到L2, 和L2传输到L1的粒度. 注意我们现在都是Pascal+的卡了, 以前的老卡还有L2->L1的overfetch的问题, 现在也没有了, 就是32B. 虽然32B很小, 但是不当的访存, 例如本手册上的例子, 大跨步(strided)的访问4B或者1B, 会导致周围的连续32B都会被读取的. 这样会严重的浪费有效带宽的. 然后手册上还说了, 除了读取的这种情况下, 对带宽的浪费, 在有ECC存在的情况下(依然先不考虑HBM显存的卡, 那个很特殊),

也会导致浪费. 为何? 因为ECC的不是对1B为单位的显存中存储的内容的保护和纠错, 而是对一个计算单位的. 对一个计算单位整体进行ECC编码的计算, 然后附加的跟随写入. 这样就导致了两个问题:

(1) 普通的无ECC的显存, 可以通过掩码的方式, 例如我可以直接传输一定大小的内容, 假设是4B of 32B, 其中将这4B(32-bit的)范围, 设定有效写入, 其他28B, 设定忽略. 但是ECC的做不到这点, 他必须将整个一定的纠错基本计算单位粒度的那么大小的,读取到L2,然后L2就地修改其中的1个或者多个字节, 才能重新计算ECC. 这样就导致了, 在一定传输和ECC粒度存在的情况下, 写入会导致读取的现象存在.

(2)不仅仅如此, 伴随写入的ECC编码, 是在这个基本计算粒度的后面跟随的, 这样哪怕你写入了1B这么小, 实际光额外追加的ECC编码就很大了(例如8B, 具体得看相应的某代的GPU的白皮书或者其他资料才能知道)。所以这个粒度的问题, 存在于对显存的读取, 更加也存在于有ECC的情况下, 对显存的写入. 反而是无ECC的显卡, 纯写入的时候可能受影响的要小一点.

更不用说本章节手册提到过的, 一些GDDR5/GDDR6显存的卡的ECC的编码还要跟随占用传输带宽和存储容量的问题(这个影响读取和写入, 本章节手册已经说过了)。

好了. 继续回到GPU显存的传输粒度这个话题上.

虽然看起来这样很坑, 但是本手册同时提到了, GPU很适合这种零散的随机性的大范围跳转的读取, 相比CPU依然具有很大的优势, 这听起来很反直觉, 是为何呢? 这是因为CPU的内存, 同样具有这个特点, 也有它的粒度. 而且这个粒度比GPU的还大(依然暂时不考虑HBM显存的情况). 依然应该是后者。

前者存在CPU同样的过大的cache大小, 和overfetch的情况, 也存在每个CPU核心, 只能应付少量的outstanding进行中的cache miss后的读取. 而后者, 作为集成的GPU, 具有GPU的特别, 可以同时有较大数量的从SM到L2的进行中的请求, 来等待取得. 从而可能有更好的性能. 我们之前在图灵系列实体显卡的测试中, 大范围的4B为单位的读取, 能提供有效的带宽是大约在显存的4%左右. 或者以32B传输为单位, 大约在30%+这个数值, 和今天手册上给出的图, 并不一致. 我们将有机会重新在新一代的卡(8.6的安培), 或者我们的Jetson产品上给出重新测试. 以便让用户知道毫无规则的访存的时候, GPU依然具有的, 比CPU好很多的优势. 这点切记. (因为实际点的代码总是可能会涉及到一个大查找表的, 此时的下标可能毫无规律)。

Okay. 我们已经理解了GPU显存的特点, 高带宽, 高延迟, 最小粒度(其中这点是和CPU共有的). 以及, 刚才上面说了, 大部分人的主要短板可能是在访存上. 那么我们下篇就继续进一步的, 说说Global Memory的相关优化.

GPU卡和Jetson上显存优化的特色

我们下面就继续进一步的, 说说Global Memory的相关优化.

要说对它的优化, 我们得先知道Global Memory是什么, 和很多人的印象里的不同, 它不一定是显存. 还可能是映射的内存. (例如zero-copy时候的手工分配的, 和退化的Unified Memory的情况). 我们主要说一下当Global Memory是显存, 和是zero-copy的情况, 而暂时忽略是退化的Unified Memory的情况。

说一下这两者时候的注意事项和优化.

首先本实践手册这里, 提到了zero-copy内存, 这种是锁定在物理页面中, 而不能被交换到磁盘上, 同时又能被GPU设备, 直接访问到的内存(映射成了global memory)。

这种内存, 具有多个特点.

先说一下实体显卡上的, 再说一下我们的Jetson上的。

在实体显卡上, 因为卡是通过PCI-E连接到CPU的, 此时的一切传输(从内存到显存), 均需要通过PCI-E进行. 而PCI-E的带宽非常有限. 通常只有16GB/s的理论带宽. 注意手册这里第一次给出了实际能达到的, 在PCI-E 3.0 x16下的传输带宽, 往往是只有12GB/s左右, 这个结果是和实际日常使用中的情况是一样的. 手册这里给出的的确是比较准确的数字. 而这个带宽, 很多时候, 只有通过锁定在页面中的缓冲区传输, 才能达到的, 为何? 主要是这个涉及了CUDA C编程指南中, 和CUDA Runtime API手册中的最前面说的一些情况. 我简单这里重复一下吧.

一个是作为PCI-E上的GPU设备, 他用自带的DMA引擎, 进行BUS Mastering的时候, 本身就只能访问物理页面(或者说, 物理页面范围). 而不能支持CPU端的虚拟内存. (否则它将需要理解CPU端的页表结构等一系列问题, 才能自动转换)。我们知道当年的某蓝色巨人的XGA显卡, 作为它的8514显卡的继承人, 具有CPU端的虚拟内存支持, 并重新实现了兼容于x86 CPU的页表和相应的CR寄存器,作为当年的该机型的用户, 超越了20年的感动依然在心中。然而其他所有后来的显卡都没有这个特点, 因此他们就只能用自己的DMA Engines访问物理内存(好吧, 某些DGX例外). 因此, 当用户要求传输一段普通的可换页内存的时候, 要么显卡驱动内部, 先将该段内存缓冲区的内容, 复制到自己内部的一个小的锁定的物理页面范围上去, 然后再从这里安全的传输; 要么就是就地尝试锁定, 然后传输. 但前者多了一次内存<->内存的传输, 后者则有锁定和解锁开销(这里面的细节可以看CUDA Runtime API手册的前面, 历年GTC也有过详细描述) 。

前者几乎将内存的有效带宽降低了一半,例如我们上次距离的那个68GB/s峰值的内存的机器(4通道DDR4-2133), 这样一倒腾, 内存带宽就实际上只有34GB/s了. 哪怕你不考虑在CPU上运行的应用程序的需要, 光这点带宽, 传输两三张卡就撑不住了,而使用这种锁定了页面的内存, 则可以就地开始传输, 节省了一半的内存的带宽, 因为过程从内存->内存->显存, 变成了内存->显存了. 还是很容易喂饱你的卡们的. 这个是很适合在实体GPU上传输的特性。

这是对于实体卡说的,对于我们的Jetson产品, 实际上是并没有独立的显存和内存的, 也不是通过PCI-E总线传输的, CPU和GPU都在SoC的内部, 共享SoC提供的内存(显存)控制器. 此时如果你照搬之前的经验, 直接来一个cudaMemcpy*()系列函数. 则实际上你无辜的在该内存(显存)内部, 倒腾了一次. 无任何意义. 当时他们提出, 一定要利用页面锁定内存, 能无传输的就地访问的特性(俗称zero-copy, 可以看成是最最简化版的Unified Memory). 取消掉这个在Jetson上的无辜传输. 然后让GPU就地访问CPU的工作数据. 这点还是非常重要的。

不过在Jetson系列产品上, 直接使用zero-copy的方式会禁用GPU的缓存(L2)的, 不如后来的更好用一点(但限制更大)的Jetson上的Unified Memory方式好(也是直接共享, 但启用L2, 但会禁用CPU-GPU同时访问). 这点等我们到了Unified Memory的时候再说。

好了.回到本章节前面的页面锁定内存的传输和作为zero-copy上的特性内容。

注意本章节这里提到了, 要在CUDA 2.2+, 无显存的那种集成显卡上, 使用zero-copy, 云云的. 而没有提到Jetson. 这是因为手册10年来没改的缘故了.

我们现在已经将近10年买不到这种无显存的卡了(以前一些笔记本上有),而Jetson产品也已经普及。

所以我们这里做了调整, 取消了手册上说的过时的不存在的内容, 而增加了Jetson上应当考虑zero-copy就地使用的做法.

此外, 手册上介绍了如何能利用页面锁定的内存, 有效的进行计算和传输重叠的特性. 手册这里给出了一个很好用的东西:

就是当我们只有1个kernel要启动, 和1份缓冲区要使用的时候, 如果能让这个进行多次传输和计算的重叠. 本实践手册这里给出了如下建议: 即将你的kernel修改里面的线程或者block的坐标/下标映射, 将原本一次启动的kernel, 工作于一个大缓冲区上, 改成N次启动(注意每次里面的坐标的变化), 并每次传输1/N内容的缓冲区, 这样可以尽量达到传输和计算的重叠. 这个是一个很好的实践方式. 因为很多时候, 你想利用传输和计算重叠这个优化, 但是你找不到多余的kernel计算任务, 和多余的传输任务来重叠,此时, 你应当考虑本手册中的, 拆分计算规模, 和拆分成1/N每次传输的建议. 这种建议还是很好的. 当然这样做, 你需要注意坐标的偏移和变化. 不要计算错了.

实际上, 在某OpenCL对等的规范中, 在友商家的卡上, 我们可以直接在启动kernel的时候, 提供这个坐标偏移量. 从而完成类似的操作, 而不需怎么改动代码. 所以你看, 这是一个相当实用的东西, 实用到友商家已经提供了API以方便你这样做了.

此外, 手册还提供了另外一个建议, 就是直接不要传输了, 直接就地使用. 因为zero-copy可以以一定的粒度, 直接从内存跨越PCI-E到L2(然后进一步到SM中). 这样根据手册这里的说法, 能在kernel的指令级别, 实现计算和传输的重叠. 但是根据我们的实践, 大部分的使用效果实际上并不好. 我们怀疑是可能是跨越PCI-E带来了更大更难掩盖的延迟或者其他因素, 这些需要等待确定. 但是手册中不强调的另外一个用法, 实际上效果非常好. 即将zero-copy作为容纳结果数据的, 写入的缓冲区. 这种可以将结果的回传, 和kernel的计算, 也在kernel的指令级别, 进行重叠. 而无需你事后开一个异步传输在某流中. 在我们的日常实践中, 这种具有非常好的使用效果. 好到了很多人都发现了这点. 并且在arvix上发文, 用FPGA拦截了zero-copy作为写入结果的缓冲区的时候, kernel的写入, 在指令级别的重叠, 通过L2, 跨越PCI-E回传的时候的情况, 并做了分析. 我们现在大致知道, 根据此文(我不记得番号了, 但是容易找到. 用FPGA + Zero-Copy的字样, 近期文章),

手册中的这种作为直接回写操作的做法, L2会产生跨越PCI-E的32B, 64B, 128B大小的传输的.

然后本文章还分析了, 为何只能达到约12GB/s, out of 16GB/s的原因(因为PCI-E的包大小的问题的浪费, 而不是编码问题). (注意这是说的PCI-E 3.0, 而不是4.0, 后者可能大约达到25GB/s, 这是根据我们客户的反馈, 而不是实际我们的测试).

大致是分析完了显存的特性、一个令人意外的(非常分散的不合并读写的优势)、 两种global memory中的前一种, 以及它的传输上的好用的地方, 和在Jetson上的应用。

一些规避的坑和优化的要点

我们的CPU在读取的时候, 从它的内存读取到它的L2的时候(L3或者L4, 作为LLC, 很多时候是victim cache, 也就是读取的时候不经过, 只有被淘汰的数据才尽最大挽留的存放, 所以这里不提), 粒度往往是至少64B的,这样, 同样零散的分布的读取1B的数据, GPU效率是1/32, 而CPU可能只有1/64. 更加可怕的是, CPU往往会对邻近的cache块/行, 进行预读, 和预测性的预读 实际上很可能会导致, 读取1B, 传输了上百B甚至更多的情况, 此时从效率来说, GPU的1/32要远远超过了CPU. 更何况, 这个是从效率上的说法, 实际能有效提供的带宽, 要用效率乘以各自的峰值, 显存具有大得多的峰值, 此时再乘以更高的效率,就得到了在这种严重不适合GPU, 也不适合CPU的情况下, GPU的性能依然要更好的情况出现. 这点很多书上往往进行了忽略. 因为这些书教育我们, 一定要使用合并性的访问, 要使用适合GPU的访问. 从而导致了很多用户, 不敢将这种不适合GPU的访存, 进行CUDA化改写, 这是很错误的。

本实践手册的这个章节, 破除了这个迷信思想, 还是需要的. 特别的, 在我们的jetson产品上, 存储器的体系结构(hierarchy), 是缺乏一个主芯片级别的统一最后一级缓存的, 即所有的数据, 都最终要通过存储器(LPDDR4), 才能得到一致. 哪怕此时问题来说, 同样的一个渣代码, 无论用CPU还是迁移到GPU上, 访存都是很零散的, 用户你究竟是准备用自带的ARM CPU核心来读取呢? 还是准备用集成的GPU部分来读取呢?

前面的话题已经说了, 如何在Global Memory做, 以尽量取得较好的性能优势. 以及, 和传输相关的方面的话题. 但是有一点没有说, 就是8.0+计算能力新引入的, 将部分Global memory中的缓冲区, 形成一个较长时间片段内, 锁定在L2 Cache中的效果. 或者用户可以理解成, 在一定的时间范围内, 将L2的某部分设定成尽量类似L1之于shared那样的, 类似手工管理, 或者说缓慢淘汰的效果. 这个不说是因为我们还没有测试, 同时, 我们所有在售的Jetson产品都不支持这个特性. 我们可能在最后的时候, 在8.0+上进行测试, 然后重新说这个话题。

好了. 先进行今天的内容. 今天的内容是如何尽量发挥shared memory的性能. 这个其实也是老生常谈了. 要发挥shared memory的性能, 我们得知道为何我们要用shared memory, 为何它的性能是在某些特定的kernel中, 是性能影响因素,因为你既然读到这里, 如果你的kernel本身不卡在shared memory性能上, 甚至根本连shared memory都不会用到, 则自然你不继续看了, 如果你需要继续看, 则至少你已经用了shared, 或者想用, 并且想解决使用中的性能瓶颈, 或者提前避开一些坑. 所以我们就说点这些。

如同本实践手册所说, shared memory在某种意义上, 等于是手工管理的L1 cache. 这种说法, 对于来自CPU的用户来说, 听起来还是比较有吸引力的.

因为一个传统的L1 cache你只能被动的使用它, 并且预估自己的那些访存模式, 适合被L1缓冲, 从而尽量的去好好使用. 而Shared Memory作为完全用户管理的东西, 你有充分的自由可以随意使用, 任何情况下都不会像L1那样, 数据存在自动淘汰可能, 总是可以安全的存储, 高速的使用的.

但是我们作为GPU, 一个追求吞吐率的设备(上次说过的), 很多时候用户们追求近乎100%的压榨出来上面的某些单元的性能, shared也不例外。

今天就大致说了一下, 哪些是影响因素, 并再次(再N次)的给出了使用shared memory进行分块矩阵乘法和转置的例子, 用来显出使用了shared后的高速度来。

我们直接说一下一些规避的坑,和优化的要点:

第一点则是, 尽量规避shared memory上的bank conflict. 这个也是老生常谈了. 我们现在用的, 能买到的新卡, 都是4B宽的Bank. 每个Bank用户应当理解成在每个周期内, 能独立给出4B数据的独立单元,这样每个SM里面, 如果有32个Banks的话, 能给出128B/周期的性能. 这个还是很惊人的,因为对于从CPU迁移过来的老代码来说, 自家的L1 cache, 也不过常见每个周期能给出2个32B读取, 和1个32B写入这种. 也就是96B/周期. 但是CPU的核心数才多少, GPU的SM数量又多少。

一个动辄80多个SM的GPU, shared能聚合给出10TB+到20TB+/s的性能(假设频率从1Ghz~2Ghz的GPU主频). 所以很多老代码, 进行了优化, 迁移到GPU后, 第一步就是考虑尽量利用shared的这个高速特性. 从而发挥性能. 然而, 这个高速度只是理想状态, 一旦shared发生了bank conflict后, 性能会下降的. 下降的程度和你bank conflict的程度有关系. 而具体bank conflict是什么, 我们这里不讲. 因为实在是讲的太多太多次了(几十次是有了). 感兴趣的用户可以回看我们的编程手册内容, 或者回看Sisiy的阿三书. 里面都扯了好多好多。

这里主要说的一点是, 在近期的NV给出的资料中, 揭露了一个新的现象.

就是我们以前一直说Bank Conflict的时候,根据手册,都是用的warp整体(在现在你能买到的卡上), 作为bank conflict分析的, 也就是32个线程内部之间的有无bank冲突. 从而尝试优化. 但是这种手册上给出的分析方法, 和实际的使用中的profiler给出的conflict的报告, 和实际因为达到的性能, 很多时候是理论和实际结果不符合的。很多情况下, profiler给出的bank conflict数量要少很多, 性能指标也要好很多.

例如本论坛的这个例子:

https://bbs.gpuworld.cn/index.php?topic=73410.0

该例子的楼主们, 以及, 奈奈同学, 给出了自己观察到的不同于手册说明的现象. 并且进一步的挖掘出来了, NV只在GTC上给出的一个PDF资料. 该资料里有不同于手册的说法: 即: 在8B, 16B的这种非4B的访问情况下, 也就是类似float2, float4, double, double2这种访问的情况下, bank conflict的计算不是按照warp进行的, 而是分别实际上按照half-warp和1/4 warp进行的. 这点符合实际实践中的profiler的报告的性能结果. 我们今天在这里额外的从论坛揪出这个案例, 同时用NV的这个资料, 进行说明:

在特定的访存方式下, bank conflict的计算应当采用另外的范围(即1/4或者1/2的warp), 而不是从warp整体. 当读者或者用户正好使用这种访存方式的时候, 无需过度的去考虑优化Bank Conflict的问题, 因为很可能此时conflict根本就不存在.

这点需要注意了. 此外, 我们还想给出一点说明的是, 有的时候, 将shared memory作为一个高速的查找表的时候(参考我们之前编程指南手册说过的, shared memory的三大用途之一), 如果下标高度规律性的一致, 在warp内或者block内部如此, 则编译器可能会生成另外一种带有LDS.U后缀的shared读取指令, 会让实际的读取的延迟降低很多, 等效吞吐率提升很多. 该现象很容易发现, 也不报告任何的bank conflict. 但是我们目前还不知道为何会这样, 以及, 如何能让编译器触发这点. 这里的给出只是用来说明, 很多时候本实践手册中的conflict方面的相关优化并不成立, 用户应当以实际的应用中的性能分析器对相关单元的指标报告为准. 然后手册今天不出乎意料的, 继续引入了矩阵乘法/转置的内容, 用来说明shared memory在重复使用数据, 和转换不适合的低效global memory的访存为适合的高效的shared memory访问的特点.

重复使用数据就不用说了, 既然shared memory作为手工管理的L1 cache, 他自然也有cache的这种提供缓冲和高速性能的特点; 而转换不当的访存模式(例如常见的纵向坐标优先或者说大跨步等的方式), 经过shared中转了一次, 变成了恰当的模式, 则用户应该看一下. 后者是很多用户容易忽略的, 特别是对于一些案例, 数据明明只需要使用1次, 那么为何我还需要先读到shared memory中缓冲一下, 然后再从shared memory读取一下呢? 因为对于很多这种的, 哪怕你只用一次, 经过shared memory这么一倒腾, 就可以让访存模式理顺很多, 哪怕只用一次, 也是有性能优势的. 而这种优势, 在直接使用L1 — 不具有不同深度的bank的同时数据供应 — 是做不到的. 但是shared可以。

一些规避的坑和优化的要点(续)

我们需要指出的是, 任何用户现在均不应当手工去尝试进行shared memory上的”优化”, 从而能让自己的”矩阵乘法”变得更快. 因为NV自带的cublas库已经在那里了. 该库超越了大部分人的写作水平, 也包括在给你读这些的今天的我们.因为cublas可能会使用更加底层, 接近硬件的工具, 来书写, 而不是较高层次的CUDA C和PTX.任何用户都应该考虑直接使用该库. 这也是为何我们今天不说这个例子的第二个理由(第一个理由是之前说过太多次了).

越过这个例子之后, 我们再说一下常用的shared memory的, 作为warp和block内部的数据交换缓冲区这点. 这点还是对性能的发挥很重要的. 很多时候, 我们需要在协作的, warp内部的线程之间, block内部的warps之间, 进行数据交换. 来完成特定的算法实现的要求. 此时用shared非常好. 而两个常见的来自论坛的用户的其他做法, 则是不推荐的:

第一个做法是直接分配global memory上的普通缓冲区, 然后每个线程都写入自己指定的位置, 用这个来交流数据. 这个做法还是很不好的. 延迟大, 带宽低. 而且每个block中的每个warp中的每个线程, 都需要计算自己的下标, 就像普通的global中的缓冲区那样.

而使用了shared来作为内部的数据交换, 则具有延迟低, 带宽高, 以及, 更美妙的是, 每个block都有自己的同名shared中的副本, 同样的下标在不同的block中, 自然的被分开了, 从而导致你能用简单的下标来完成交换, 而无需计算完成的, 全局独立的下标. 下标是完全可以在block间的级别重复,而不发生冲突的.

第二个做法则是尝试使用local memory进行数据交换. 这个是错误的, 我们已经在论坛调试了无数这样的代码了. 这主要是针对来自CPU的用户. 甚至是一些高级的CPU用户. 他们本能的认为, 我线程还在活着, 我定义了局部数据, 哪怕是在某些类似stack上的东西, 我也可以临时性的将指向其中的指针, 给其他人(伙伴线程)使用, 只要我存在, stack上的东西就有效.

这点在CPU来说, 的确是的, 而且是在CPU上的多线程数据交换的时候的, 在每个线程都活着的时候, 一个危险而好用的技巧. (因为它规避了小缓冲区动态分配和释放, 直接一个指向stack上的临时性内容的指针就可以了) ,然而, 这点在GPU上并不成立. 在GPU上, 这样将会导致实际上为每个线程分配一种叫local memory的东西, 你的指针指向local memory的内容, 只会在本线程内部, 该指针有效.

如果你用一些技巧获取指针, 传递给另外的线程, 你会发现指针能用还是能用, 但是指向的内容是其他线程的对应指针位置的内容, 这点就非常有意思而难以debug了(其实不难, 因为你载坑一次下次就知道了).

此时依然推荐使用shared进行高效的数据交换. 这点需要注意了. 我们不想在论坛继续解决这种问题了. 我们最后补充2点, shared memory的用途, 是本章节手册没有强调的.

一点则是作为查找表. 当查找表的规模适合的时候, 也就是从128B+到几十个KB的时候, 应当考虑使用shared memory.

(过小的查找表, 例如小于128B, 你应当考虑放在warp的32个线程中的每人的1个寄存器中. 然后用shuffle进行下标查找. 这是不容易出错的方式). (而过大的, 你shared也放不了. 此时可以部分放在shared, 其他部分或者整体考虑放在global中, 或者使用其他策略)

放在shared中这点, 看起来是理所当然的. 但是实际上根据我们的经验, 大部分用户会反直觉的, 首先考虑将查找表放入constant中. 这是非常不对的. 因为后者不不能很好的支持无规律的下标访问. 等到constant的时候我们再说.

只是因为它带有”常数”的这种名字, 很多人就用它来顾名思义的进行类似常数/系数查找之类的用途, 这是不对的. 依然这里用shared才会有较好的性能.

第二点则是, 某些kernel无可避免的需要进行某种类似结果compact的操作, 或者说, 先不能一次性的生成最终结果, 而是先生成一个接近最终结果的半成品, 然后最后才能有效的排除/筛掉一部分. 这种操作往往是因为半成品本身, 需要根据前后的值进行进一步的运算, 才能去掉某些结果, 在图像处理中很常见. 此时就有多种选择了.

一种是再开一个kernel, 从global中处理. 另外一种则是回传CPU, 进行这种filtering. 此时完全可以将临时结果先写入到shared中, 然后再从shared中进行筛选. 这个是比在后续的kernel于GPU上, 或者回传后筛选都是高效的. 至少它降低了传输的大小, 我们已经知道了之前的内容说过, PCI-E传输是比较慢的, 应当尽量优化它. 这种将结果分两次写入, 第一次写入shared, 第二次再从shared中写入目标global memory中的做法, 听起来也很简单. 但是根据我司这10年来的经验, 很多用户是会自动无视这点, 或者说自动忘记这点的, 为何? 因为大部分的CUDA书都在教你, 如何有效的用shared缓冲/重用输入数据, 而几乎从来不提, 对结果的写入也可以中途经过shared stage一下,从而完成过滤/压实之类的操作.

从而让读这些书多的人形成了思维定势, 自动对结果写入使用shared这点进行了忽略, 这点还是应该要注意的. 论坛好多人已经这样思维定势了.

最后关于shared memory的则是, 从计算能力5.0开始, shared memory本身具有一定的计算能力. 例如shared本身可以比较高效的计算加法(例如你在atomicAdd的时候). 而5.0之前都是使用了类似锁定—SP加法—解锁的策略, 现在直接是让对应的存储单元(shared)完成这个计算操作了. 因为5.0+的卡是我们现在能买到的唯一的卡, 一些基本的计算, 有的时候可以让shared去完成.

例如我们之前老生常谈N次的纯粹在SP中完成的某些规约操作. 包括图像处理中的, 很经典的, 追加一个List. 可以用shared上的原子操作在shared上尝试构造一个小的. 然后整体在追加到global memory中. 这样可以某种程度的完成行/块级别的内部有序. 也能降低global memory上的, L2级别的原子操作的压力. 特别是当相当多的SM中的请求, 密集的对1个L2上的4B索引位置要求进行原子操作的时候. 因为根据我们的经验, L2上的原子操作可以可以很密集, 但一定要错开, 就想是存在某种类似shared meomry上的分片或者bank机制那样. 如果你不想去研究探讨L2上的这个机制(没错, 我们看到的L2实际上是很多独立的小L2聚合而成的), 则先在各自SM内部的缓冲区上拼接构造, 然后一次性的完成1次, 而不是等效的几十次到几百次的L2原子操作请求, 并最终写入. 还是很好的.

此外, 从目前我们能买到的新卡(例如RTX3070), 已经支持直接从global memory读取到shared memory了. 这是一个极好的特性. 是从友商AMD那里学来的特性.

从Global memory到Shared memory

上一篇里我们说到目前我们能买到的新卡(例如RTX3070), 已经支持直接从global memory读取到shared memory了. 这是一个极好的特性. 是从友商AMD那里学来的特性。我们稍微解释一下。

从大约10年前的GCN的A卡开始, A卡具有一个独家特性, 可以直接从global中加载到LDS中(相当于shared memory), 这样做有很多好处, 例如可以实现异步效果, 可以让某block在请求后台的global->shared的传输中, 主体逻辑在做一些准备或者初始化操作. 而不需要像以前那样, 必须先读到寄存器, 然后从寄存器写入到shared. 读取到寄存器本身无问题, 反正寄存器的占用只是临时的,但会导致主体逻辑卡住, 在主体逻辑一旦试图从寄存器访问到未就绪的数据的时候. 虽说上次内容, 我们都知道, 可以依靠切换warp, 让SM执行其他没有卡住的warp中的内容,但是实际上你在用老nvprof/nvvp或者新的nsight compute的时候, 在选择了PC Sampling的时候, 能看到具体的,往往前面这种一碰载入到寄存器的初始用的数据, 就卡住的情况还是家常便饭的(会显示一个很长的long scoreboard等待采样计数, 类似的东西).

而长期以来,A家则提供了异步的载入, 同时还提供了查询和等待/同步操作, 能让主体逻辑去查询, 后台的异步载入到shared memory进行到哪里了,或者在主体逻辑真的完成了所有前期工作后, 要开始使用shared了, 可以选择的进行一次等待/同步操作.幸运的是, 我们这10年来, 有两点终于得到了满足.

一点是NV终于现在提供了这个特性了, 而且异步载入指令, 选择性的计数等待(例如发出来3批传输, 等待到传输完第一批的时候), 和整体等待/同步等特性. 这样有效的降低了常见的每个warp头部的低效的”冷片段”, 有利于整体显卡性能的发挥. (所以说, 你买一张8.0+的卡还是值得的,)

二点则是, AMD从10年前引入这个特性(其实比NV做的还好, 因为还可以做轮询进度)后, 始终拒绝在自家的OpenCL中, 将该特性导出. 从而实质性的, 能让你买到支持的硬件, 但是就不让你用(有其他方式能用, 但是这里不提, 因为无关今日话题). 从而降低了NV这10年来在追赶优质硬件设计上的压力. 这是新的来自8.0+上的重要的shared memory上的特性和优化,应当注意. 如果你不喜欢现在新版本的C++风格的在CUDA C中的导出, 则你依然可以使用PTX中的传统C风格的调用方式, 手工导出特性即可. 注意对于16B读取(从每个线程的角度), 该新特性允许直接从Global memory中bypass掉L1, 直接送进shared, 避免了对L1中的内容的污染. 这点适合我们今天前面说的, 有些数据哪怕只读取1次, 但为何转换成合适的访存模型, 也可以考虑shared那条的用途. 或者其他的, 任何避免污染L1的情况的用途.

local memory你可能不知道的好处

下面我们简单的再说一下local memory.

首先要注意的是local memory并不local, 它实际上依然是一段显存. 但可能会被各级相关的缓存所缓冲。

主要用途有两点:

一点是你(读者)使用,当你需要每个线程的一段缓冲区的时候,你并不需要单独的开一个全局的大的缓冲区,然后作为参数传递给kernel, 让kernel里的每个线程找到自己对应的一部分使用。你可以直接来一个局部的大数组(不能过大!), 享受类似以前的CPU上的C风格的, stack上的定义的数组, 或者类似CPU上的alloca()的分配风格, 能自动的每人一份, 而且能自动释放, 很是方便,而且不仅仅如此, 你如果传递进来一个大缓冲区这样用, 你需要为所有的一次启动的线程分配缓冲区. 而用local memory, 则只需要保证能真正同时上到SM里执行的那些线程的数量所需要的缓冲区,举个例子说, 前者你启动了1M个线程, 每个线程需要1KB, 则你需要1GB的显存提前手工分配了.而如果你使用后者, 某GPU device实际上只能同时执行10K个这样的线程, 其他的暂时没上的, 在其他block中的线程们, 会等待下次轮批次再上, 则硬件上只需要准备/分配出来100MB的显存, 即可应付, 因为这些线程不是真的”同时”在运行中的(具体参考我们之前的编程指南手册).这点不仅仅降低了手工管理的成本, 还降低了你花钱买一张更大显存的卡的成本.特别的是在Jetson设备上, 显存(内存)容量有限, 用户应当考虑这点.但是很遗憾的, 很多人就是喜欢传递过来一个额外的数组/指针这样使用, 原因我们还未知.

此外, 使用local memory还有一个好处, 就是虽然它像global一样, 被各级缓存缓冲, 但是它有更精细的缓存控制策略, 可以允许对local memory上特定位置的访问, 标记成discard, 或者说last use(PTX手册用语). 允许cache直接将对应的cache line内容, 就地丢弃掉, 而无需必须回写下一级缓存甚至到显存. 这点作为global memory是做不到的。

此外, 今天的实践手册没有说明的是, local memory还具有强制合并访问的特性.我们都说用了local memory, 但是几乎没人讨论”local memory是否是合并的”, 既然我们今天已经知道了它也是用的显存模拟出来的, 为何不讨论这点?这是因为local memory有自动交错的特性. 例如我们定义了一个int dog[N]; 假设dog被编译器选择放置到了local memory上, warp中的每个线程都在访问同样下标的, 例如dog[K]的时候, 实际上来自32个线程的同样下标的访问会被合并成连续的地址空间上排布的一段128B的内容, 非常的合并.用户可以理解成local memory实际上总是按warp排布的, 任何int dog[N]都是内在的被存储为int _dog[N][32]这种自动交错.

从而总是自动形成了, 当下标一致的情况下, 自动合并的效果. (这点最早见于2013年的CUDA Handbook, 这是一本好书, 但是国内翻译的书质量不高,所以我们一直没推荐。也可以参考我们之前的CUDA编程指南中的内容)

因为这种自动交错/合并的存在. 对local memory中, 来自同一个warp的杂乱的下标/指针访问这种, 应当避免. 因为默认是一致的. 杂乱的访问会导致访存被拆分成多次请求, 严重降低效率.这是local memory的用途一.用途二则是, 方便编译器安排一些无法有效的放入寄存器, 例如当前阶段寄存器资源用的太多了, 或者一些访存方式(例如对寄存器试图进行下标索引—-N卡不支持这种), 不能放入.

纹理存储优势

根据之前的内容, 你已经知道, 纹理可以提供免费的值变换, 和免费的坐标变换, 以及免费的越界处理, 以及, 更加优化的访存/缓存效果. 我们主要从这4点说开.

先说一下免费的值变换. 有些算法需要将数据作为8-bit或者16-bit整数存储, 然后读取到后, 再转换为float之类的浮点数, 和其他类型进行运算. 而这个转换过程, 需要用户手工写, 哪怕是一个简单的float b = (float)a;这种. 以及, 这种转换还需要占用SFU(特殊功能单元), 注意SFU在新版本的Nsight profiler中已经简单的改名成了XU单元了. 那么此时, 无论是从转换指令本身, 需要占据额外的硬件资源; 还是从编写代码的人的角度, 他需要手写额外的代码行, 都是一种开销. 而纹理读取的时候, 可以利用上其数据路径中的自带的转换功能, 从而节省掉对SFU/XU或者人工编码成本的开销.

这样有可能带来额外的性能提升, 和对人力成本的节省.

例如我们知道, 在很多代卡的架构上, 一次SFU完成的整数到float的转换, 性能只有常规指令的1/4:

img

如图, 我们可以看到了7.x的卡上, 每SM每周期可以执行64条常规的float加法/乘法/乘加, 这往往构成了你的代码的运算主体;

img

而从8-bit或者16-bit或者其他整数类型转换成float的时候, 吞吐率就只有16条/SM/周期了, 相当于在7.X上转换本身只有常规计算的1/4的性能. 甚至这点在8.6上更加糟糕, 因为8.6的双倍速的float运算, 导致如果你读取一个普通的8-bit或者16-bit整数(u)int8/16_t, 然后进行一次手工到float的转换, 相当于大约等效8条后续的正常计算的性能被浪费掉了(某种意义上), 即转换只有1/8的效率. 此时如果你的代码SFU/XU是瓶颈, 或者因为使用SFU而导致了浪费了指令发射能力的话, 应当考虑使用texture自带的免费转换功能, 来节省对应的SFU的I2F之类的转换指令. 这样会可能带来额外的性能提升.

不过需要注意的是, 自动的转换是一个”归一化”的过程, 将会从8-bit或者16-bit的有/无符号整数范围映射到[-1.0f, 1.0f]或者[0.0f, 1.0f], 其中包括了1.0f了, 这点使用的时候应当小心. 例如考虑是等效乘以了1/255还是1/256的系数的问题(包括还是不包括1.0f右边界).

好在大部分的使用float运算的代码, 应当很容易处理这种问题. 这是使用texture的带来的可能的第一个优化上的效果.

注意第一点的值变换除了归一化读取到的值, 还有低精度的插值效果, 这个线性插值效果我们曾经已经在编程指南手册中说过了, 这里就重点说了. (虽然本手册这里强调了一下). 如果适用你的算法, 则利用硬件自动的插值的效果可以进一步节省你的手工运算量, 从而潜在的可能提升性能.

这两点都属于今天的texture带来的4点中的第一大点, 即自动/免费对读取到的值变换的好处.

第二点的好处是, 带来了自动的免费坐标变换, 即所谓归一化的坐标. 这点什么时候有好处?

例如图像处理或者神经网络的输入图像, 可以大小自动适配. 也就是说, 我一个256x256的图片, 和一个512x512的图片, 使用了自动的免费坐标归一化功能后, 后者和前者可以自动的等效缩放. 这点节省了用户单独的写一个kernel进行缩放的过程. 减少了工作量和出错可能, 也节省了一次kernel的代价.

当然, 现在用深度学习的用户可能不在乎这点, 也没法在乎, 因为他们如果使用框架的话, 能配置的只是简单的文本文件描述(例如对网络结构的描述). 不需要手写任何代码, 自然也不需要考虑这点. AI么, 会用记事本就能搞AI. 有数的.

但回到正题, 本章节说的坐标自动映射(或者等效的图像自动缩放功能), 的确节省了用户的开发成本. 此外, 和值变换不同的是, 这种坐标映射是右边界不包含的, 即一个图像(或者2D数组), 会被映射到[0.0, 1.0)的坐标范围, 手册这里的说法是, 映射到[0.0, 1.0 - 1/N], 注意)和]. 这样的映射在N是一定范围内的整数次方的时候, 或者说图像/2D数组宽度/高度是2的倍数的情况下, 可以在缩放的情况下, 依然精确表示坐标. 从而使得这个特性不仅仅适用于图像这类的数据, 也适用一定的需要严格坐标指定的普通2D数组/矩阵之类的算法/代码. 因为一定范围内的1/2^N在我们用的卡上, 是可以被精确表示的浮点数. (注意不是所有的浮点数/坐标都可以被精确表示). 这样texture就又带来了, 免费的而且一定情况下是精确的坐标变换/缩放功能. 使用它依然可以解放掉你的主代码去干其他事情. 从而可能带来无论是编程世间, 还是性能上的提升. 这是第二点.

此外, 我们往往不仅仅需要像(1)(2)点所说的那样, 无论对要读取的坐标进行变换, 还是要对读取到的值做进一步的变换处理, 在实际的2D数组/图像的读取中, 往往还需要考虑边界情况. 不考虑边界情况往往会代码你的代码行为异常, 或者出现无法预测的结果.

继续回到第三点. 我们看下纹理给我们带来的边界/越界处理都有什么好处/优势. 好处有两点:

第一点是, 在指定了一定的边界模式后, 越界不再需要考虑. 即节省了用户的代码编写工作量开销, 也消除了用户哪怕想付出努力/工作量, 却不小心遗漏导致出错的情况.

这点在今天的优化指南手册中, 正好给错过了重点.

我们知道之前在编程指南手册中, 我们和大约一起阅读过有4点边界/越界自动处理, 即自动填充0, 自动重复边界值, 卷绕和镜像模式.

而且我们当时还分别对这4种模式都画了图, 从而让你能够理解, 当时手册上只有文字描述的不好理解的尴尬. 但是今天的优化实践手册中, 只在表格中提到了后两者(卷绕/镜像). 但是实际上, 往往有用的是前两者。

我们已经无数次的在论坛上接到楼主们的求助, 诸如: “我需要在我的矩阵周围绕上一圈0, 应该怎么做” 类似这种的问题, 往往本着就问题回答问题的角度, 我们往往在论坛上给出的答案是: 重新申请一个宽度左右大2个元素, 高度也大2个元素的新矩阵/2D数组/图像, 然后将原始矩阵内容复制到中间, 然后周围一圈写入0. 或者我们给出的建议是, 每次读取都强制的走一个越界处理的code path, 即有效坐标正常读取, 越界/边界的部分, 直接范围0模拟一次读取到了一圈0的效果. 你看到, 无论是那一种, 都需要用户付出功夫. 而如果使用今天手册章节中说到的texture的自动边界/越界处理的话, 你可以免费. 我们具体说一下.

我们设定今天手册中没有说到的边界自动绕0模式, 此时, 就像论坛中很多人试图做的那样, 直接对一个纹理坐标进行读取(纹理中往往较拾取)即可, 如果没有越界, 和你的普通读取效果一样, 如果越界了, 自动返回0. 这样, 你不需要额外的处理或者if之类的判断语句, 效果却自动达到. 注意这不仅仅减少了你的编码工作量负担, 也减少了无论是多一个环绕0的kernel的执行成本, 或者是用if判断越界与否的处理的代码执行成本.

因为要知道, 绝大部分的代码, 都是要上1个或者多个if来对, x或者y坐标之类的进行有效范围判定的. 无论你是看老樊的书, 或者看阿三的书, 或者写过任何CUDA代码, 应当对这点都深有体会. 而今天, 如果纹理能适用你的数据类型/代码, 则你可以自动得到这个免费特性. 从而提升你的编码效率, 也提升了你的代码执行的性能. 这是说的第三大点的边界/越界处理中的自动返回0值的情况. 实际上, 在图像处理中, 往往还需要在边界超出的地方重复最后有效的值, 例如你在做某种梯度或者边缘之类的检测/处理之类的.

纹理读取也对这种提供了直接的免费边界/越界处理. 这就等效于你手工围绕上了一圈或者多圈边界值. 注意这个特性也很常用, 而且不用纹理用其他方式手工实现起来很麻烦. 麻烦主要在于你不知道边界需要涉及到越界出来多深(特别是对图像处理来说, 参考当年某维), 你可能需要围绕1圈, 2圈甚至更多圈, 而使用纹理的这个特性你可以免费绕上任意圈. 而没有成本. 另外则是这种需要考虑越界的方向, 往往需要考虑4个或者8个方向, 例如从左边界线, 上边界线, 右边界线, 下边界下, 或者上下左右4个顶点.

而今天, 你如果使用texture的第3大点的这种特性, 这一切都是免费的, if的多个分支可以被省略了, 从而潜在的可能提升性能. 而且主要是减少了代码编写者的成本, 和出错的可能.

注意对于第三点, 本优化实践手册说的是另外两点, 根据我们的经验和论坛上的各大楼主们的反馈, 另外两点并不常用. 我们这里就不说了, 如果你感兴趣可以看我们之前的编程指南手册, 里面说的很详细.

texture和surface

上一篇说的3大特性, 都等于在访存的同时, 还附加上一定的固定功能的运算/变换处理. 这种特性, 叫采样器特性(sampler). 而我们都知道, 采样器是在只读路径上的. 而去掉了采样器的texture在CUDA里叫做surface.

因为本优化实践手册编写的年代较早, 这里没有怎么提到surface. 我们简单的说法一下surface.

surface不具有刚才说的texture的采样器只读路径上的这些优势,但是surface具有额外的特性, 它可以写入, 而texture不能.除此之外, surface和texture还具有非采样器的另外的一个重要特性.这个重要特性是在多年前, 也包括最近一些年出的而没有动脑更新的书的经常重点强调的地方,即texture本身的缓存效果. 这是很多人至今还在坚持使用texture的重要因素. 我们来简单看下. 这个因素等于是在说存储本身, 而不是在对该存储的读取路径上的优势. 主要优势有两点, 一个是cache效果. 在某些卡上, 普通的读取不具有较好的缓存效果, 而texture读取有. 例如哪怕是到现在依然被CUDA 11.1所支持5.X硬件, 也是如此.

例如5.0的maxwell的卡, 对于普通的读取不能使用L1/read-only cache, 而texture和另外一种只读的读取方式(不维持一致性(NC)只读读取, 或者常见的__ldg()之类), 却可以充分利用. 此时使用texture或者surface读取, 就能获取此缓存效果上的优势了. 否则你的任何读取可能在此卡上都要走L2. 很亏. 这也是手册本章节说的, 具有带宽上的放大效果(注意, 本章节的其他内容这里不赞同, 因为手册可能很久没更改了, 例如手册说, 使用纹理和DRAM的直接读取具有一样的延迟啥的).另外的一种存储上的优势则是, 例如在使用cuda array的时候, 数据在显存中的排列本身, 可能是被重新排布过的,

constant和寄存器

我们继续说一下剩下的两点小内容.

第一个是内容是constant ,这个谁都知道是什么, 也知道它的优势, 例如我们都知道GPU是RISC架构,所有的数据都要单独的load操作进入寄存器后, 才能参与运算.,但是constant例外, 很多指令允许一个操作数, 作为constant的形式存在, 从而在一条指令内部, 聚合了该指令本身的计算功能, 外加对constant的读取在一起.

这里要注意两点:
(1)是7.5+的卡有单独的标量/Uniform路径, 不仅仅可以在SP的计算指令中, 集成对constant数据的读取为操作数, 从而节省了一条单独的load读取数据指令(例如常见的A = K * B + C; 这里的K就可能并不需要单独一条指令载入的). 7.5+(也就是图灵+)的卡, 其标量单元, 还可以单独在SP之外, 执行标量/constant载入指令, 进一步的提供灵活性和释放向量指令(例如可以在很提前的位置进行load, 而不是在遇到了c[Bank][offset]风格的cosntant操作数的后期时刻).

但是很遗憾的是, 目前的NV家的编译器还对标量路径代码生成支持的不好. 虽然我们知道这是竞争对手A家从10年前就有的功能(的确很多方面A卡硬件好), 而且A卡的配套软件质量非常渣, 但是这点上NV的编译器质量还是不如A卡的.

好在随着以后的CUDA Toolkit版本, 驱动版本的提升必然会逐渐的效果提升的. 总之读者现在该用constant就要用.

(2)点则是, 应当正确的使用constant, 这里的constant指的是手工放入constant中的内容. 我们在论坛常见很多楼主有很多错误/不当做法, 我举两个例子.

第一个例子是本优化实践手册这里说的, warp内的很多线程读取不同的constant数组中的元素, 这样做将完全失去constant的效果, 而且可能会起到反面作用(变慢).constant必须在warp一致的时候才能用. 其他使用将可能拖慢你的代码.这点论坛上已经有N个反面教材了.

另外一点是, 过度的使用constant, 表现为用户拼命的较近脑汁的将自己的代码中的常数, 例如1.0f, 233, 666这样的常数单独提出取出来,然后手工放入一个constant变量或者数组中. 从而认为这样可以”进一步的优化”.其实不是的. 首先说, 编译器会自动完成这个过程, 如果它认为某些数据能够从代码中自动被提取出来, 它会自动这样做, 并放入constant.
其次, constant并不是最快的, 有些数据如果合适, 可以直接嵌入在指令的内部, 作为”立即数”. 而立即数可以被保存在指令缓存(而不是任何数据缓存), 只要能取指令, 那么数据就已经免费就绪了. 所以用户手工的这样做(手工将kernel中的常数提取出来放入constant)是没有必要的, 甚至可能会起到反面的优化效果. 需要注意. 注意本实践手册是将其作为存储器分类的.一种是将寄存器作为存储器分类,一种是将其特化, 它就是寄存器, 而不将其作为通用意义上的存储器, 虽然也有register file(寄存器堆)之类的说法存在.

这里主要提到2个问题. 第一个问题是涉及到寄存器的bank conflict, 这点如同本优化指南说的,用户无法控制这个问题, 这个是编译器在生成目标代码的时候, 自动尽量规避的.这点我赞同. 同时本手册说了, 不用考虑用int4, float4, double2类似这种数据类型所可能带来的寄存器的bank conflict, 该用/不改用就用(不用). 这点可能是有点欲盖弥彰了.

因为在某代著名的3.5/3.7的时候(大Kepler), 压满显卡的峰值性能是如此的困难, 导致用户不得不考虑使用ILP(指令级别的线程内部的前后自我并行, 本优化指南后续章节会说). 而使用了ILP往往会导致使用int4/float4这种向量类型, 而根据已有的资料, 在大Kepler上这样做, 往往会导致严重的寄存器的bank conflict, 同时编译器竭尽全力还无法很好的避免, 这就很尴尬了. 所以手册虽然这里这样说了, 但是用户是否该用, 该如何用才是优化的, 请自行考虑.

好在现在随着时代的发展, K80这种卡已经逐渐的消失了. 再可预见的将来我们应当不太用担心这个问题了.毕竟, 如同人不能同时两次跨入同一条河流, NV总不能在同一个地方(指坑爹的Kepler架构)栽倒两次的. 这是第一点关于寄存器要说的.

第二点关于寄存器要说的则是, 很多代码, 并非使用寄存器越少越好, 也并非使用寄存器越多越好. 其寄存器的使用有个最佳点(甜点). 而这个甜点的值是无法确定的(和具体的kernel, 卡, 以及kernel和kernel间的组合情况有关). 我们前几天在老樊的群里看到有用户一本正经的讨论,我将寄存器从XXX个降低到了YYY个, 结果性能并没有提升, 为何(@#(@(!这个其实很正常。所以我们这里提出尽量可以考虑自动化的尝试寄存器的最佳使用点, 例如写一个脚本自动控制寄存器的用量, 用不同的用量值自动重新编译和运行评估代码, 从而能自动发现这个甜点,而不是用户自己(就像老樊的群里那样)去反复尝试, 费时费力, 可能还找不到这个最有点.

不改变代码本身如何提升性能?

因为GPU的SM是海量超线程的, 远比常见的CPU的一个物理核心的2个或者4个超线程(HT)要多的多, GPU依靠这种海量的超线程数量来提供最大可能的吞吐率(这点我们稍后说)。而这种海量的超线程, 当你每个线程的寄存器资源用的比较多的时候, 则SM上能同时驻留的线程数量就越少, 从而影响了GPU的这种海量的超线程的能力, 从而潜在的可能影响了性能的发挥,所以有的时候我们不能肆无忌惮的使用寄存器资源,而是需要通过某种手段去限制编译器生成的代码中, 对寄存器的具体使用数量。

在日常的应用中, 不改变代码本身, 而是简单的改变每个线程的寄存器资源使用数量(变多或者变少), 就有可能提升性能,所以这是一种常见的优化方式, 具体到今天的手册章节, 手册提出了两种做法:

一种做法是编译的时候, 对每个具体的.cu的CUDA源代码文件, 使用nvcc -maxrregcount=N的参数来编译。这种做法将会把此文件中的所有的kernel, 都统一限定成最多使用N个寄存器。

注意这里有需要注意的地方, 首先是这种限制是以源代码文件为单位生效的, 如果你文件中存在不止一个kernel, 则所有的kernel的限制都是一样的, 你有的时候可能不得不拆分源代码成多个文件, 从而使得每个文件里面只有1个kernel, 从而能单独的用-maxrregcount=N的参数来限定。

其次则是这种做法限定的是Regular Registers, 注意到参数中是maxrreg, 而不是maxreg了么, 中间多了一个r. 而其他种类的寄存器, 例如predicate register或者uniform register(计算能力7.5+), 则无法通过这种方式限制, 但好在一般我们也不需要限制后两种寄存器的数量。

这是手册今天告诉我们的第一种限制方法, 简单明快的限定成N这种具体值, 比较直接.

而手册中说到的另外的一种限制方式, 则是通过__launch__bounds__()来修饰kernel本身,将此行放置在kernel的最前面, 即可限制该kernel的寄存器使用数量。

注意这种方式可以每个kernel单独放置一种修饰, 甚至可以每个kernel根据编译时候的计算能力选择, 放置多种修饰. 控制性比较强,因为它不想-maxrregcount那样的是整个文件一起来的, 人家是单个kernel, 甚至单个kernel的单个计算能力编译下的效果来的, 所以可以很精细的指控。

但是坏处是, __launch__bounds__()无法直接指定一个具体的寄存器用量N, 而是间接的指定我需要1个SM上最少有XX个YY线程的Blocks, 然后编译器再自动计算一下, 这个XX个是需要限制到多少个寄存器的情况下, 才能满足约束, 类似这种的. 比较晦涩一点。

所以实际使用中, 这两种方式可以根据需要来, 一个直接的粗糙的; 一个间接的精细的. 读者们可以尝试根据实际需要使用.

这是我们今天所说的, 通过限制寄存器数量来尝试优化性能的两种具体做法.

下一篇, 我们会说一下菱形启动符号, 也就是<<<>>>这种, 和其他一些方面, 能带来的性能变化。

occupancy越高越好么?

在今天的手册上, 这些统称为对kernel的执行(环境)配置, 来调节性能.

首先是, 手册提出了occupancy的概念, 和这个概念的重要性, 以及, 观察从而能设定occupancy的3种方式.

各位读者, 只要是用CUDA的, 就一定遭遇过occupancy这个词, 俗称”SM占用率”。这是一个百分比值, 例如某kernel在某卡上运行, 取得了90%的占用率; 而某某kernel, 则在此卡上, 只有30%的占用率, 等等。

你的同学, 同事, 朋友, 总在会尝试劝告你说, 一定要提高这个占用率啊, occupancy高了才能性能好啊, 否则你就在浪费你的卡啊, 类似这种说法.

这种说法对也不对。

首先手册说了为何这种说法对:

因为我们的GPU是一个吞吐率为设计目标的处理器, 每一个晶体管都是尽量为了最大化的提供性能而存在, 而不像CPU那样, 为了延迟而设计, 很多晶体管都在为尽快做好1件事情而努力. 这点我们之前说过.

所以为了这点, GPU需要用海量的线程在SM上执行着, 当某些线程(精确的说, warp)卡住了, GPU从而能切换到另外一些线程去执行. 用这种简单的方式, 最大化的发挥性能. 等于我同时在洗衣, 做饭, 看娃多种任务同时进行, 一旦我卡在了等待洗衣机运转上, 我就可以切换去做饭, 一旦做饭正在煮着了, 我就可以去给娃喂奶. 用这种方式反复横向切换, 从而能最大化的利用我的时间(GPU的性能).

而Occupancy则代表了, GPU的SM上能驻留的线程数量(我今天在干的活的数量), 和该SM的最大能驻留的线程数量的(我累死最大能同时干的活的数量)的比值。这是occupancy的定义(实际上略微有差异, 特别是涉及到achieved occupancy的时候)。

100%的occupancy可以看成我一共能干10件事同时, 我今天就是在同时干10件事,而20%的occupancy则是我一共能干10件事今天, 但是我今天只同时干两件事。所以你看到, 一般情况下来说, 越高的occupancy(接近100%), 往往会越可能的发挥性能; 而越低的occupancy, 则往往会可能造成设备的低效运转。

这是今天手册上说, 为何为何尽量提高occupancy, 往往会提高性能的原因, 也是你的同事, 朋友, 同学往往会建议你这样提升的原因,但是事情不是那么绝对的, 有的时候, 较低的occupancy反而可能会带来更好的性能, 这点在历届GTC的演讲中都有提到过, 网上也能搜索到很多案例。

手册这里总的说法是, 因为当SM里的总资源固定的时候(想想成你家的面积好了), 较低的occupancy(想想成今天你只干2件事好了), 会给每件事带来更多的资源(想想成, 你需要一个手工画一个图, 较大的桌子可能让你干的更起劲, 从而比你同时用小桌子绘图憋屈, 同时在煮饭的总产出要好)。

下文我们会和手册一起, 对具体SM里的资源进行逐方面的分解, 看看occupancy vs 资源 vs 性能变化的具体讨论.

测量Occupancy的三种方式

一般的来说, occupancy往往有个折中点, 过高了或者过低了性能都不好. (就如同你干得过少, 或者干得过累都不好一样). 好了, 我们有了occupancy的概念, 知道了无需一味的去追逐occupancy, 就已经是一个很大的胜利了. 我们下面将具体看一下, 如何测量, 调节occupancy, 并从理论的角度看下它们可能带来的性能变化。

手册里先说了计算/测量occupancy的三个方式, 然后再说了调节一些资源的使用, 会occupancy造成怎样的变化可以反映出来。

我们先看看手册说的occupancy的测量/计算方式. 这个其实以前在编程指南手册上也有涉及, 只是可能没有今天的这样的系统一点。

一种是纯手工计算, 纯手工计算是指的人为的设定或者找到某kernel的, 寄存器使用量, shared memory使用量, block里的线程数量这三种因素/资源的使用后,通过和手册中的特定计算能力下的这三种资源的情况(该表在编程指南手册的后面有)对比, 从而手工的计算出来一个理论的occupancy.

这种方式不需要任何工具, 而且可以在你敲代码的时候, 自然的在大脑中提前形成大致的轮廓. 坏处是比较枯燥, 而且你需要比较熟悉特定计算能力的情况, 才能大致的对比出来你的kernel, 将来在该计算能力的卡上运行, 会得到一个怎样的理论occupancy。

第二种计算/测量occupancy的方式, 则是使用工具. 具体的可以分成静态的一个Excel文件(即本章节的图中的occupancy calculator的那个.xlsx文件),在里面选好了寄存器资源, shared memory资源, block中的线程数量, 和对应的计算能力后, 该.xlsx文件中的宏, 会自动为你计算一下.

该静态的计算工具文件好处是可以免除记忆特定计算能力的参数, 而且还提供了一些高级参数(手册所没有提供的), 例如特定计算能力的某些资源是按照什么方式/粒度分配的信息. 同时不需要安装复杂的开发环境, 例如你可以将此excel文件复制到手机上打开, 或者上传到OFFICE365, 一样可以随时随地使用。坏处是你依然需要手工去测量/计算这3个基本资源, 在你的kernel下的具体使用量, 才能进行后续计算。

而同时也存在另外一种工具, 动态的分析工具, 指的是nsight或者nvprof类似这种的profiler, 它们会在你的kernel运行起来后, 自动为你抓取到这个信息, 从而免除了3个基本数据的手工取得, 也免除了后续的计算过程, 全自动。坏处则是你只能取得你所拥有的计算能力的卡(例如, 一张8.6的30HX显卡(尚未问世, 我们假定的30HX是最近的8.6计算能力)), 在此卡上实际运行时候的数据,你无法取得你所没有拥有的一张计算能力的卡上的情况. 但是手工计算和用Excel计算都是可以算出来一张不存在的卡上的情况的。

对于公司开发的情况, 例如拥有所有的要出售的产品所针对的, 市场上的所有代的计算能力的卡或者Jetson产品, 都购买回来的情况的时候(每种至少一个), 则无需担忧这种。这是第二种用工具的方式.

而第三种则比较主动一点了, 可以编程的通过相应的occupancy api (见cuda runtime api的手册, 或者我们之前的编程指南的稍微提到的部分内容), 在运行的时候, 动态的获取到我的某kernel, 在现在的某卡(例如3号卡上), 用XXX的资源配置或者线程形状, 能取得百分之多少的occupancy。

这种方式坏处是需要用户编程, 增加了额外的编码负担(和出错的可能). 好处则是, 你的代码可以在将来的卡上, 在开发的时候无论纸面或者实际的资料都没有的情况下, 在未来的某一天实际运行的时候, 代码自我分析和发现得到occupancy. 例如将来在一张30HX上, 此卡尚未问世, 我们也不知道计算能力的情况, 但是用第三种API的方式, 将来可以动态的得到, 从而潜在的能动态的(用代码)微调occupancy。

好了. 这是关于取得/测量Occupancy的三种方式, 今天我们简单的说了, 寄存器资源的限制, Occupancy的意义和高低对性能的可能影响, 以及, Occupancy的具体测量/计算方式。这三个因素其实还挺重要的,很多时候我们写代码, 当算法固定了, 实现也基本固定的情况下, 想调节性能, 只能从这3种基本不太影响现有不的代码格局的方面入手。所以关于这3方面的优化调节, 也往往排在算法—>实现—->(今天的执行/配置方面的调节)这么的一个重要顺序.

因为例如有更好的排在前面的情况, 例如一个快10倍的算法, 你应当先去考虑选择它, 而不是今天的这些”优化方面”,你很难简单的通过”优化”去将一个GPU上的应用性能继续提升10X, 但是更换算法, 你有可能。所以大家在实际使用中, 不要舍本逐末, 应当至少什么是最先考虑的. 只有当最先考虑的因素都完成后, 再进行这些介绍的经验和手册告诉你的实践操作. 就如同刚才说的某妹子, 她如果直接嫁一个100倍有钱的老公, 还管这些一天怎么干活, 怎么做事? 后面的这些将毫无意义.

我们在下次的内容中, 将会具体结合寄存器, shared memory, block形状这三种因素, 综合occupancy分析, 3因素 vs occupancy vs 性能的情况.

如何执行配置优化以及对性能调优的影响

接上一天的occupancy后面,继续说说寄存器的延迟掩盖,blocks形状和使用,shared memory的使用,以及,concurrent kernels和CUDA Context等方面,对性能调优的影响。

首先我们从寄存器的延迟掩盖开始。本小结首先讲述了,当需要使用寄存器中的数据,而该数据没有准备好的时候,从而无法取得数据喂给SM中的执行单元,从而可能导致执行的线程被卡住(stall)而不能就绪执行的状态。小结只讲述了常见的A = XXX; 这种形式的寄存器上的结果计算延迟。并用volta举例常规的计算有4个周期的延迟,在此期间内,立刻使用结果数据是不可以的,需要等待4个周期才可以。并讲述了可以临时切换到其他warps中的指令继续执行来掩盖的方式。本小结是乐观的,认为这一般不构成对性能的影响。

但是实际上,随着现在nsight compute的流行,long/short scoreboard的stall reason之类的分析指标的公开,很多操作对寄存器的结果写入,可能要超过这例子中的4个周期不少。感兴趣的读者可以参考这个链接:cloudcore:CUDA微架构与指令集(4)-指令发射与warp调度 。这里的讨论比当年scott grey在NV英文论坛的讨论要热闹一些,下面也有一些NV的国人在加入讨论。进一步扩展的读者可以参考里面的相关scoreboard的内容继续展开。

我们这里只额外说一下,使用s_xxx[idx] = d_xxx[idx]形式的,从global memory看似’一步到位’写入到shared memory的做法。实际上会被编译成中间的分步的tmp = d_xxx[idx]; s_xxx[idx] = tmp; 的经过寄存器(tmp)的分解过程,导致中间第二次写入的时候有一次对寄存器的依赖。使用8.6和8.7计算能力的人们,建议考虑新版的cuda::memcpy_async的载入方式,这种可以直接越过寄存器。

这是今天的第一小节。

第二小节讨论了block和grid的形状对性能的影响问题。这个是个喜闻乐见的讨论,在我们夏令营和冬令营的活动中,被人讨论了无数次了。小节首先澄清了,grid和block的1D还是2D还是3D的形状,从本质上并不影响性能,影响性能的只是无论1D还是到3D时候的,计算出来的每个block里的线程总数量,和blocks的总数量。

小节同时说明了,这些线程和blocks的数量(和其他资源),影响了在SM上的active warps的数量。能达到的active warps数量,才是之前的occupancy之类的很重要的原因。而active warps的数量,往往决定了延迟掩盖,和对SM各个单元的利用程度。这样性能就取决于这些单元的利用率情况,因为一旦我们买回来了一张卡,硬件的SM数量,和SM里面的执行单元配置是固定死的了,硬件本身乘以利用率,才会影响最终的性能发挥。

然后小节往下说了,该如何调整kernel启动时候的方括号里的第一个和第二个参数。大部分情况下,调优kernel,需要同时(in tandem)试验性的调整这两个参数。但每个参数也有他们自己的调整策略:

对于第一个参数(blocks数量): 基本的策略是要足够多,至少每个SM上得有1个block。同时,考虑到了1个SM上如果只有1个block的话,一旦该block中的线程们,执行了__syncthreads()进行等待同步的话,很可能导致SM上warps大部分都处于等待状态了,降低该SM的使用率。所以这个至少的1个block还需要调更多。手册的建议是,亲这边应该至少上几千个blocks每张卡。理由很简单:考虑到现在的8.6的3090的卡,有82个SM。每个SM上可以上到多达16个blocks,这样82 * 16等于差不多1000。几千个差不多能将一张卡上个几批次。手册说到,我们要面向未来考虑,将来的卡更强。所以数量不能保守。

阅读到这里,我们应当结合实际一点。因为随着block对资源的使用不同(例如shared memory), 一个批次能上多少个blocks,对于固定的卡,随着kernel的不同是不同的。建议读者使用nsight compute, 观察里面特定kernel的waves数量指标,该指标说明了某kernel的blocks需要分成几个wave(批次),才能上完。

以及,对于某些因为算法的角度的限制,不能有效扩大blocks数量的情况下,针对本章节讨论到的,因为__syncthreads()而导致1给block中的warps在SM上整体stall的问题。可以考虑使用细粒度的部分同步手段。也就是使用cuda::barrier(需要计算能力7.0+),进行1个block中的部分线程进行同步。这样当部分线程在wait()或者arrive_and_wait()进行同步的话。该block中的其他不参与barrier同步的线程依然有机会执行,继续利用SM上的执行单元。

以及,新版本的上一部分手册(CUDA Programming Guide), 现在已经正式引入了很多C++风格的东西了。上一段说到的asynchronous barrier, 在当年我们阅读编程指南的时候,没有涉及。建议读者重新阅读相关章节。

然后继续回到<<<>>>的第二个参数,也就是block中的线程数量的优化考虑。手册这里主要考虑了你不能用过小的blocks,例如只有32个线程的block. 因为SM往往还有例如16个block/SM的硬限制。使用过小的block往往会导致SM上去的总warps数量不足,可能会影响性能。手册这里建议的方式是,至少上64个线程的block,然后逐步调整block中的线程数量, 找到特定kernel的最佳性能点。这个逐步调整,可以从128或者256个线程起步。

手册继续说,调整到适可而止就行了,没必要追求极限。例如通过调整前两个参数,让SM能上到66%的occupancy,和能上到100%的occupancy,可能并不会对性能起到太显著的影响。因为调整的目的是追求性能,而不是单纯追求指标。为了得到过高的occupancy,有的时候你只能降低寄存器数量之类的,从而导致使用了过多的local memory, 反而影响性能。

而另外一方面,因为除了我们之前说过的TLP(例如依靠切换warps)来充分利用硬件的执行单元,还存在ILP的方式,也就是线程内部的前后指令本身的并行性,来提高效率。手册这里指出了,只要内部的ILP程度足够,哪怕较低的occupancy也是足够的。对于这个问题,我们建议读者继续扩展阅读经典文章:《Better performance at lower occupancy》(链接: http://dmacssite.github.io/materials/volkov10-GTC.pdf ),该文章描述了哪怕很低的occupancy,也可以通过ILP取得优异性能的方式。虽然这个文章较老,但是依然非常经典。

另外的,我们夏天搞夏令营活动的时候,客串出场的樊博士,也在他的实践中(GPUMD项目),指出了这点,例如在他的《Efficient molecular dynamics simulations with many-body potentials on GPU》中,老樊写道:“哪怕使用float的时候只有50%的occupancy;或者使用double的时候只能到25%的occupancy。性能也相当不错”。(arvix: https://arxiv.org/abs/1610.03343 ), 感兴趣的读者也可以扩展阅读。

这两篇文章都分别有12年和5年的历史了,但是里面的思想,是正确和不过时的。

函数和指令使用的选择和优化

今天的主要内容是<优化指南>手册里面,对一些函数和指令使用的选择和优化。大致分为普通的计算函数/指令,和访存相关的方面。

我们先从计算函数/指令开始。

首先上去的小节,是关于整数除法和求余操作的优化写法。当除法A / B, 和求余A % B的时候,如果B是2的整数次方,也就是B = 2^N的时候,前者A / B可以直接写成移位操作A >> N;后者A % B, 可以直接写成逻辑与操作A & (N - 1)。 无论是移位操作,还是逻辑与操作,都是单周期的指令,远比老老实实的除法和求余快得多。

手册本节指出了,当B是编译时刻的2^N形式的常数的时候,编译器会自动发现这一点,同时自动为你进行这个优化。但是如果B不能在编译时刻确定,例如作为一个参数,B传递给了kernel,此时为了避免进行昂贵的除法和求余,可以考虑手工将B转换成指数值,然后手工进行移位和逻辑与操作。例如原本要传递进来256, 现在可以传递进来8(也就是log2(256)), 然后直接A >> 8和A && (8 - 1)即可,从而规避了昂贵的代码产生。

这是第一小节。第二小节则依然是说的整数,主要涉及到在使用下标和循环控制变量的时候,对有符号整数和无符号整数的选择。并讨论了C语言默认为有符号整数时候,编写代码的人如果偷懒不写上unsiged字样,则在循环控制变量和下标计算上,将生成较为劣化的代码。

小节说明了,这是因为无符号整数的溢出和累加都很方便,而有符号的则需要处理溢出的特殊情况,需要占用额外的指令。

我们这里以前忽略过这点,今天我们用计算能力8.6上的指令生成,分别测试了默认情况,和标注了unsiged字样的整数,在这两种情况下带来的优势——我们给读者测试了对于常见的形如p[i * 8] = 0,当i是int和unsigned int时候的,单语句的代码生成的效果对比,用来验证一下手册的这个优化的说法:

在i是无符号整数的时候,p[i * 8]编译生成了2条指令的序列。

指令(1):用LEA指令计算p的低32位地址累加i左移3位

指令(2):如果有进位溢出,p高32位+1

我们的GPU是32位机,只能每次进行32位整数运算,对于这p[i * 8]形式的64-bit最终地址计算,这已经是最优的代码生成了。

而在i是常规有符号的整数的时候,却编译生成3条指令的序列,多了一条:

(1)单独计算i * 8的值

(2)整数加法, 并得到是否溢出的标志

(3)根据溢出标志,执行32位符号扩展的LEA.HI.X.SX32指令。

你看,在使用下标的时候,在int i的定义身上,简单的加上unsigned的无符号标注,就能得到性能优化。

类似的,根据手册本小节的说法,当下标在循环里面的时候,编译器还可以对unsigned的下标,进行更强的替换处理(strength reduction,参考: https://en.wikipedia.org/wiki/Strength_reduction ), 例如我们在一个for(i)循环中的p[i * 8]的使用,发现了每次i的递增,乘以8被reduced到每次加8,和地址的计算等方面的指令生成,也有类似的优化效果。所以你付出的代价只是将声明变量的时候,添加一个unsigned标记,就可以得到显著的好处。这点值得考虑。

以及,本小节实际上说的是:对于循环变量尽量使用有符号的整数,理由是,无符号的行为是精确定义的,有符号没有精确定义溢出行为,所以编译器有更多的操作(优化)空间,但是我们编译测试发现是反的,建议读者们自己实验决定究竟是什么情况。

—刚才例子中的无符号情况的生成结果(cuobjdump), 一共两条指令。LEA和加法(8.6上用FP32 path的IMAD.X的A + 0 * B + 进位指令,模拟了A + 进位加法)。

img

—刚才例子中,有符号情况的生成结果, 一共三条指令(移位用FP32路径的IMAD.SHL模拟替换)。

img

也就是移位,第32位加法,高32位符号扩展加法。这三条。

(关于7.5和8.6上,用FP32路径上的IMAD/IMUL的指令模拟常规INT32指令,达到port平衡,是另外一个话题。这两个计算能力都能超过64指令/周期/SM的INT32的指令峰值上限,因为这种模拟替换和平衡,用nsight很容易发现这点)

好了。两个小节的整数指令方面的优化选择说完了,我们下面继续今天的主要内容,关于float方面的优化选择。

首先说的是,计算1/ � ,时候的做法(你想法将X放到根号里面,我不会),本小节指出了,有单独的单精度和双精度的rsqrtf()、rsqrt(), 来直接完成求根号X,然后再求倒数的一体化运算。如果可能,尽量使用这个。会带来更好的效率和精度。编译器并不总是能将1.0 / sqrt()的写法,转换成对应的一体化函数版本的。

然后下一小节手册从上面两个相似名字的数学运算函数(结尾带有f和不带有它)开始,说了容易不小心将float写成double,并生成了double运算代码,导致速度降低很多的情况。主要有这两点:

(1)读者写代码的时候,如果不小心,使用1.0,而不是1.0f这样的常数,根据C的规则,含有这个常数的式子,将在运算过程中,提升到double进行运算,式子算完后,再转换回来成float进行赋值给lvalue对象。这样有了来回转换double<->float的指令开销,也有了慢速的double指令计算的开销。

(2)CUDA编译器实际上是一个C++编译器,在math_functions.h之类的头文件里面,有C++风格的重载。例如sqrt()函数,有double sqrt(double)的版本的,也有float sqrt(float)的版本的。如果用户不小心,在式子里面给出了double的中间结果作为参数,同时函数结尾没有显式的写出f()结尾,那么因为重载的同名函数存在,将实际上使用的是慢速的double版本的。也有生成慢速的代码。

所以我们读者应当尽量小心注意使用浮点常数和函数后面f结尾,避免生成慢速的代码(double总是要慢的,而且会占用更多的资源),特别是在家用卡上(8.6的家用卡走double路径将只有1/64的速度)。

我们的老朋友樊博士,对于忘记了f后缀写上,而导致代码变慢了很多很多,具有惨痛的教训。并在冬令营/夏令营上,给我们深刻的说过这点。

然后这小节还提了在进行概率统计之类的运算的时候,如果要使用正态分布的误差函数,特别要注意这点。因为erfcf()这个函数(注意f结尾),在单精度的时候特别快。例如我们在计算N(0, 0.5)的正态分布的2个西格玛内的概率的时候,使用float p = 1.0f - erfcf(1.0f / 0.707f);类似这种写法(注意好多f结尾),将特别快。

最后这小节还提到了,不仅仅我们浮点数有这种情况,8-bit和16-bit的整数,在直接在我们GPU上使用的时候,通常情况(不考虑深度学习时候的多个打包在一起的运算),都需要转换成32-bit整数,才能进行运算。这是因为我们的N卡,在进行整数计算的时候,是严格的32-bit机器,不像x86 CPU那样能就地干8-bit和16-bit指令。这样不小心就会导致额外的代价产生。

总之,适当的写法,和数据类型的使用,能避免转换的代价,和昂贵代码路径的生成。读者还是需要注意这里的优化的。

下一节则谈论了对我们读者们喜闻乐见的powf()、pow()(分别是float和double版本,如上面说过的)的通用幂函数运算时候的优化,主要针对了几种特殊的指数值,可以不用通用的幂运算完成:

img

如图,通用的指数运算的快速替换写法。

我们这里简单的举几个例子就好:

计算x的1/6次方,可以先计算一次x的平方根倒数,再计算一次立方根倒数,这样就得到1/6次方的值,而无需使用昂贵的pow之类的函数。再例如:x的2/3次方,可以先求一个立方根,然后再求一次平方,这样就快速得到了2/3次方。

注意这个快速替换表格里的公式,很多都使用了特殊的GPU上专用的函数,例如rsqrt, rcbrt(二次方和三次方根的倒数),而不是标准的C库(libm),在CPU上我们能见到的sqrt、cbrt(二次方和三次方根),如果我们读者从以前的代码编写经验来,可能喜欢使用嵌套两次立方根,得到1/9次方的值,我们不推荐读者这样来。因为特殊的rsqrtf()这种,可能在实现上具有更好的精度和性能。例如我们之前的章节知道过,SFU这种喜欢提供平方根的倒数的这种快速的接近,可能有助于性能的提升。总之我们建议保留此表格,直接使用里面的写法,而不是读者使用更熟悉的替换形式,为了能够保证足够的精度和性能。

注意事项

今天的主要内容将讲述三个方面。一个是Global Memory访存时候的优化注意事项,另外则是循环或者条件语句导致的分支时候优化注意事项,和几个消除warp内分支的案例。

我们先从今天的对Global Memory的优化开始:首先,优化指南手册说,要尽量减少对Global Memory的使用。因为uncached的global访问,将有巨大的延迟。应当考虑尽量使用shared memory代替,如果可能的话。同时手册给出了一个s_buffer[index] = g_buffer[index];的简单从global载入shared memory的例子分析。

先说下这里的uncached的意义,在NV的多个文档中,uncached是指通过L2 cache进行的访存,例如对显存或者映射过来的内存,而不是完全没有任何cache,这点需要注意用词。

其次则是对于这个例子来说,手册指出了:看似只有一个简单的等号,就赋值了,但这里实际上是右边进行了从global memory ===> 寄存器的载入,然后再从左边寄存器===> shared memory的写入,这样右侧的载入将导致很大的延迟,影响性能。这种代码实际上经常在我们的代码中出现,例如1个warp或者block,从global memory载入一个矩阵,然后在shared memory重新映射下标,进行转置;或者是基本上大部分的kernel开头,将一些公用常见数据,从global进行载入到shared memory,方便后续的使用。那么这两种情况应当具体怎么优化呢?

首先,无论是哪种情况,手册说了,可以完全不用手工处理,依赖硬件自动优化即可。因为GPU的SM硬件本身,存在warp之间的调度,当一个warp的从global的载入,需要等待而卡住的时候,另外一个warp可以被调度执行,从而可以互相掩盖了。也就是常说的TLP延迟掩盖。但这种情况下想要有较好的效果,你可能需要有较多的warp发出进行中的global memory访存请求才可以。例如论坛最近的这个例子:求问几个有关优化方面的问题 。我们的会员已经明显的意识到了这一点,并试图用launch_bounds来自动化的提升驻留的活跃warps数量,从而自动进行这点优化(虽然他的效果不太好)。

此外, 在计算能力8.0+的卡上,可以手工使用memcpy_async进行异步的global memory —> shared memory的载入,从而直接越过global —-> register —-> shared的中间的寄存器的依赖。这种优化很多时候有正面效果,但是不一定总会发生。例如对于刚才的2种情况来说:

如果是kernel开头就需要载入一些数据到shared memory, 必须等Shared memory中的数据就绪了,才能作为初始值参与运算的话,这种直接载入(用等号),和手写memcpy_async异步载入并无本质区别,因为你都会立刻卡住在初始数据的准备上。

但如果你是kernel运算的中途,需要载入部分数据到shared memory, 则memcpy_async的异步载入就很有用了。像是流程: 载入1—->数据1计算—-> 载入2—->数据2继续参与运算—->载入3——>数据3继续参与运算的流程。这样的话,可以按照另外一本手册的流程,稍微变形成为:载入1, 异步载入2—->数据1计算, 异步载入3—->等待之前的异步载入2就绪并参与计算—->如此重叠。

这样的话,根据你重叠的程度不同,如果有一份重叠,像是刚才箭头的那样,则等于你提升了两倍的驻留warps,或者说提升了两倍的occupancy的延迟掩盖效果。而如果你还有余力(手册上有重叠的程度的做法)提升overlap的async load的程度的话,例如有2X的重叠,则等于提升到了三倍的occupancy。效果将很显著。

特别是对于那些occupancy已经提升到了极限了,或者是我们刚才的那个论坛的链接,因为某些原因无法继续提升occupancy了,则用这种方式掩盖global memory的延迟,将是另外一条路的选择,它有效的提升了等效occupancy,或者从另外一个角度说,提升了warp内部的ILP并行。

这是今天的第一点,对于global memory的高延迟的掩盖优化。

今天的第二个方面则是重头戏,也是频繁在我们论坛上出现的内容。实际上,刚才那个论坛链接,也涉及到了下面的内容。即循环和条件控制指令,以及warp边界上的分支,和warp内部的分支,对性能的影响。

手册首先说,if/while/for之类的语句,会生成进行边界判断和流程控制指令,而这些指令本身会影响性能(因为要执行的指令变多了)。这种情况下,对于循环,可以考虑用#pragma unroll将循环展开。展开后,单次循环所需要的边界判断、循环控制变量的增加或者减少运算、跳转之类的操作,就一定程度的消失了,从而执行的更多的循环体的计算本身,提升性能。这是一点。

另外一点则对于循环和判断语句来说,这些附带的流程控制指令,还可能会导致warp边界上和warp内部的分支,也会影响性能。

手册说了,warp边界上的分支就是以32为单位(目前的warpSize是32),不同的warp在执行了流控指令后,跳到不同的位置来执行,这种情况的分支,具有较弱的性能影响,因为可能只有对于I-Cache指令缓存之类的取指令的代价增加。而手册继续说了,warp内部的分支,也就是我们喜(深)闻(恶)乐(痛)见(绝)的divergent branch, 具有比较严重的性能影响。这种分支,是在每32个线程的内部位置,不在32的整数倍的边界上,会导致warp内部不能100%的效率执行代码。因为SIMT的目前的CUDA的执行情况,例如有2个分支,分别在1个warp内部,有13个线程,和19个线程要执行两段不同的代码。只能scheduler分别以13/32的效率(41%)执行一段,和以19/32(59%)的效率执行一段。浪费了执行单元的峰值性能。

例如刚才论坛的例子,这里的profiler报告的实际有效执行效率才13.5/32 = 42%。

img

如果是那种卡计算的kernel(本例不是),一张3090秒变成了3070,对于warp内的分支(divergent branch),先不要着急, 我们稍后说下如何解决这个优化上的重头戏。然后手册继续往说,除了warp边界的分支,和warp内的分支,手册还指出了第三种情况导致的性能损失,就是在7.0+的卡上的独立线程调度(Indepent Thread Scheduling)导致的性能损失。

我们都知道,常用的图灵和安培这两代卡,都有独立线程调度,和因此引入的新的syncwarp()或者XXX_sync()之类的新函数,例如我们以前用的shfl, 变成了__shfl_sync(). 这种独立线程调度,有它的好处,一个最大的好处就是可以应对渣代码,和应对直接从CPU上移植过来的线程之间的锁之类的东西。

渣代码这里指的是目前我们好多群里都比较常见的,SM和MEM任何一个都无法用满的情况(例如论坛的这个链接里的情况),看上去不卡计算,也没有卡在访存瓶颈上(这种往往卡在了延迟等待上)。对于这种代码,7.x和8.x的独立线程调度,允许以比warp单位更细的粒度,进行线程调度执行。例如说:

1
2
3
4
5
6
7
8
9
10
if (xxxx) 
{
x = g_xxxx[xxxx];
f1(x);
}
else
{
f2(x);
f3(x);
}

这种代码在7.0+的卡上,因为独立线程调度的存在,当if条件有效的上面的那个路径,卡在了等待x的值读取回来的时候,可以让warp中的其他线程切换到else部分先执行着f2和f3,而不至于因为f1卡在访存等待上,导致其他分支不能执行。这样总的延迟就从 很长的访存—->f1 -> f2和f3,变成了很长的访存(暂停) —-> 切换到f2和f3执行——>(差不多之前的延迟完成了),再f1继续执行。这样就有效形成了warp内部的不同分支不同的重叠执行。f2和f3部分的执行,掩盖在了另外一个分支的延迟中了。

我们用数字继续说明,假设从宏观的SM角度看,如果传统上:读取长延迟期间SM 0%有效使用率,10X周期;执行F1期间,因为warp不满,40%的效率,5X周期;F2和F3期间,也是warp不满,60%的效率,8X周期。则没有独立线程调度能力的时候,总宏观上的SM执行成本:10X周期的SM 0% -> 5X周期的40% SM -> 8X周期的60% SM效率。总共用了23X周期,SM有29.5%的平均效率。则在7.0+的卡上,变成了:10X周期的读取等待(期间SM以8X的周期,60的效率执行了另外的F2和F3操作;和2X的空闲),5X的40%效率的F1操作。这样独立线程调度情况下,一共只需要15X周期的时间成本,减少了8/23; 同时期间的平均效率,SM也提升到了45.3%,提升了20%。

所以这种卡上,虽然分支依然是divergent branch的warp内分支,但是总执行时间成本,和平均SM使用效率,都提升了。也就是说,独立线程调度,不能自动解决warp内分支的问题。但在举例的这种情况下,可以减少延迟的等待,和期间的平均执行效率。那么看起来独立线程调度的确是一个好事,但优化指南手册为何说它可能会导致性能降低呢?(刚才的例子明明是提升了啊!)

那是因为,独立线程调度,不会自动汇合不同的分支,从而往后原本正常的warp内完全合并无分支的代码,再继续享受着独立调度的好(恶)处(果)。我们还是用刚才的例子好了,实际的代码中,再宏观点看,往往是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
if (xxxx) 
{
x = g_xxxx[xxxx];
f1(x);
}
else
{
f2(x);
f3(x);
}
//下面是后续操作
zzzzz;

我们刚才说了,在if和else里头,我们享受到了福利了。那么后续的zzzzz可不是这种情况了。因为独立线程调度不会自动愈合,后面的zzzzz将会依然处于独立调度阶段,也就是说:

原本走if分支的那些线程(40%),会以40%的warp中的线程有效状态,执行一遍zzzzz;然后走else分支的那些线程(剩下的60%),会继续再按照独立线程调度,走一遍zzzzz!这样的话,后续的就完全没有福利了,反而是性能下降了。

此时优化指南手册告诉我们,我们需要使用例如一条syncwarp(), 让warp中的40%和60%的两部分线程们,进行合并。这样合并以后,就会用100%的warp内(完全无分支)效率,一共执行1次后续的zzzzz操作即可。这是我们在较新的卡上写代码需要注意的,可以时不时的加一个syncwarp(), 往往有助于性能提升。以及,因为syncwarp()本身会编译出来一条WARPSYNC指令,占了1条指令发射机会。如果你认为不能太频繁的使用过多的话,也可以考虑带有sync结尾的其他函数,例如说:__shfl_sync(), 它正常的作用执行一次warp内的不同线程间的数据交换(执行成本类似一次无bank conflict的最好情况下的Shared访存,见GTC上的多次演示)。同时它还能给你带来一次warp的sync效果,却不需要任何额外执行,也可以考虑这点。

这是今天手册的第二部分,也就是warp间分支、warp内分支、较新的卡上的独立调度引入的后续隐形的分支的优化注意事项。

那我们继续今天的第三部分,也是最后一部分,关于之前的重点需要优化的:warp内部的分支(divergent branch)的优化处理方式。

还记得刚才的3090秒变3070的例子么?divergent branch很多时候非常伤害性能的。论坛的这个例子,楼主也在苦苦追求减少warp内部分支的方法。我们将大概说明3个常用的小技巧,来处理warp内的divergent branch。两种分别应对warp内有选择性的执行一部分代码,其他线程等待。和一种对应warp内分支有常见的两种代码路径,warp需要部分线程选择性的执行一部分,和其他线程再选择性的执行另外一部分的情况。而其他更复杂的分支,往往是这三种情况的组合,就不说了。

先说一下第一种情况,我举个例子。常见的GPU上,有N个线程,每个线程处理1个任务,一共处理完N个任务,也就是最常见的CUDA的分而治之的典型做法。在这种典型代码中,这N个任务往往处理的成本不同。有的任务可能只需要50%的处理步骤就能完成,有的任务可能却需要90%的时间,而有的任务则特别快,10%的时间就能完成。这种代码跑起来,会性能warp中的空洞。例如所有线程的平均是100%的时间,某warp内有19个线程,只用了10%的就完成了,剩下13人却还需要很长的时间,那么等于有大部分的时间,warp都只有13/32的执行效率,剩下的19/32都变成了空洞袖手旁观。

这种情况有两种常见的处理方法,一种叫warp compaction, 一种叫task reload/lane bubble fill, 搜索相应的文章能看到大量的具体技巧的应用效果演示,有图有文字。

简单的说一下前者就是,如果我有一个256线程的block,也就是里面有8个warp,处理一批256个任务。其中在代码的执行1/3位置,预估有一些人会提前结束(例如每个warp中大约会有40%好了);在代码的2/3位置,每个warp大部分的人会结束(例如大约warp中大约有90%)的人会结束;而剩余的1/3代码执行量,则为warp中的10%的顽固分子。

那么整个流程看起来,前1/3每个warp都有100%的执行效率,中间1/3,每个warp只有60%的有效执行效率,后1/3,每个warp只有10%的可怜效率了。平均起来全程只有56%的总体效率。这下子,一张3090,可能变得连3070都不如了,怎么办?

这个时候可以考虑compact一下warps,例如说,在1/3位置压缩一下,从分布在8个warps中的60%有效线程,和40%的空洞,压缩到前面,变成了前4-5个warp基本全满载,后面3-4个warps无任务了,退出。这个时候效率立刻就又恢复到了几乎100%了。

类似的,我们在90%的位置也压缩一下,密集任务到1个warp里,剩下warps也退出。这样从执行多个warps中的不满lanes的代码,变成了只有较少数量的warps执行满载的代码。提升了效率。注意这个技巧需要前序和(prefix sum, 或者叫scan)操作。能否有效写对scan操作对于很多CUDA用户来说,是个问题。

而另外一个技巧则不需要prefix sum操作(虽然这个操作在NV的博客上已经出现了无数次了),比较简单,但需要每个任务是内部有多次循环,循环量不同的情况。例如我们有N个任务,其中40%的任务需要迭代100次才能完成,50%的任务却需要迭代500次,而10%的顽固的,需要迭代1000次才能搞定。

如果正常的写代码,那种1个线程对应1个任务的,随便抽取出来一个warp来说,平均来说:

整体一共需要1600迭代步,其中前100步是100%效率的;中间500步是60%效率;最后1000步只有可怜10%效率了。warp的整体迭代过程,只有31%的效率!现在3090可以变成3060了!怎么办?这种代码的提升warp中的空泡,可以考虑使用重新加载任务的方式来完成。

例如说:

1
2
3
4
5
6
7
8
9
10
11
12
while(true)
{
if (my_job_done) //将分别在大约100步,500步,100步的时候,为warp的部分线程重新装填任务数据
{
my_id += total_threads;
if (my_id > total_jobs) return;
load_job_data(my_id);
}
_syncwarp();
//继续以填充了warp里的空泡的接近满载的效率执行
....
}

这种技巧不需要任何scan之类的操作,只需要你将线程的数量缩小到任务的1/M,这样每个线程平均执行了M倍的任务量,整体空泡将第1个任务后面的M-1个任务给填充起来。如果任务的时间步是随机分布的话,则这种方式具有较好的效果(平均M个任务后,warp里的每个人总时间都差不多了)。

上面的两种技巧,无论是从blocks中将多个含有空泡的warp,压缩起来;还是重新装填任务,将空泡填满。都能有效的提升warp的执行效率。像是我们论坛的这个例子的warp里的没有被predicated off掉的才30%多的线程的情况,就可以提升大约3X的性能。当然,这两种优化技巧,都需要付出额外的代价,不适合那种非常非常小的空泡/分支/任务,因为此时,你填充的”优化”代码所付出的执行成本,超过了你原本的细小空洞了,无意义。不能为了优化而优化,或者为了追求profiler报告的数字好看,硬上,毕竟小优化怡情,大优化伤身,强上优化灰飞烟灭。

然后我们继续看下另外一种情况,如果代码中不规则的夹杂了可选的代码路径,怎么办,例如这个:

1
2
3
4
5
6
7
8
9
10
11
12
....//正常处理
if (条件1)
{
//额外步骤1
}
....//正常处理
if (条件2)
{
//额外步骤2
}
....
正常处理

如果条件1和条件2在每个warp中对于每个线程,都有50%的概率进去的话,同时这种可选的额外步骤,占据总执行量的50%的话,那么整体执行效率将只有75%.

此时就不好使用刚才的重新装填任务来填充warp里的lanes空泡的方法了,不过可以考虑compact一下warps,但是这里有个问题,我们还需要恢复到正常的执行状态,来执行中间的正常处理过程(因为中间的这些正常处理过程,本来就是100%的warp无分支的效率)。此时你可以将之前的方法1进行变种,compact后,分配到纯空泡的那些warps/线程不能退出,需要在来一个__syncthreads()之类的等待(等待期间不会占用额外的SM里的SP或者其他单元的处理能力)。这样简单变种后,代码整体变成了:

(1)blocks中的所有warps都无分支满载效率

(2)少数warps满载或者接近满载的效率,剩下warps不占用任何执行单元资源。

(3)同步后恢复各自身份,继续回到(1)的情况。

注意这个方法有两倍的block内部数据交换的成本(因为压缩warps空泡的时候,线程间交换了一次数据;恢复身份的时候又交换了回来数据),和最开始介绍的约压缩越小的那种方式的每次压缩只有1次的成本要高的,是否整体合算,读者自行决定(或者搜索了相关的文章后,看他们文章的例子里的数据)。以及,实际上,如果读者能转过来弯,不怕数据的下标映射之类的混乱的话,实际上第二次交换可以省略,但需要较多的脑力成本(你自己想一下)。我们可能会在下一次冬令营讲述完divergent branch后,介绍这个优化方式,并出一道block内部交换数据成本较高的考试题,来尽量诱导大家不进行二次交换。

最后要说的则是上面这三种方法的扩展开来,对于常见的代码中的:

1
2
3
4
5
6
7
8
if (....)
{
}
else
{
}
....
//以上的if else充斥了整个kernel,就像我们的楼主的这个论坛问题贴。

最后如果你的kernel会是这种代码,总是充斥了这种两路分支的话,如果结合上面的方式,在if前重拍成为两组任务,需要两组前序和的计数序列。但是具体怎么做,这里就不说了,很容易能扩展得到。实际上可以简易的证明,对于一个有K个warps的block,总是可以得到至少K - 1的重拍任务后的warps,和最多只有1个的有divergent branch的warp。

这样我们就结束了今天的内容。注意divergent branch总是一个优化重点。以及,注意以上的所有优化方式都有一个前提:优化引入的额外操作的成本,要小于将warp内的空泡lanes填充后的收益。否则,优化就是白忙乎。注意,优化的方式不能万能的,得根据实际问题才能知道是否如此。所以有的时候,干活的人一顿操作猛如虎,最后没有收益,也不要失望。

整体视角

1、高性能编程关注点

1. 系统层面

  • 简化控制流程和数据流程
  • 减少消息传递次数
  • 负载均衡,比如避免个别服务器成为性能瓶颈
  • 充分利用硬件性能,比如打满 CPU
  • 减少系统额外开销,比如上下文切换等
  • 批处理与数据预取、内存屏障、绑核、伪共享、核隔离等

2. 算法层面

  • 高效算法降低时间和空间复杂度
  • 高效的数据结构设计
  • 增加任务的并发性(如协程)、减少锁的开销(lock_free)

3. 代码层面

  • I-cache(指令),D-cache(数据) 优化
  • 代码执行顺序的调整,比如减少分支预测失败率
  • 编译优化选项,比如 PGO、LTO、BOLT等
  • 语言本身相关的优化技巧
  • 减少函数调用栈的深度
  • 操作放置到编译期执行,比如模板
  • 延迟计算:
    • (1)两端构建(当实例能够被静态地构建时,经常会缺少构建对象所需的信息。在构建对象时,我们并 不是一气呵成,而是仅在构造函数中编写建立空对象的最低限度的代码。稍后,程序再调用该对象的初始化成员函数来完成构建。将初始化推迟至有足够的额外数据时,意味 着被构建的对象总是高效的、扁平的数据结构;
    • (2)写时复制(指当一个对象被复制时,并不复制它的动态成员变量,而是让两个实例共享动态变量。只在其中某个实例要修改该变量时,才会真正进行复制)

2、预置知识 - Cache

1. Cache hierarchy

Cache(缓存)一般分为 3 级:L1、L2、L3. 通常来说 L1、L2是集成在 CPU 里面的(可以称之为On-chip cache),而 L3 是放在 CPU 外面(可以称之为 Off-chip cache)。当然这个不是绝对的,不同 CPU 的做法可能会不太一样。当然,Register(寄存器)里的数据读写是最快的。比如,矩阵乘法优化:

2. Cache size

Cache 的容量决定了有多少代码和数据可以放到 Cache 里面,如果一个程序的热点(hotspot)已经完全填充了整个 Cache,那 么再从 Cache 角度考虑优化就是白费力气了。

3. Cache line size

CPU 从内存 Load 数据是一次一个 cache line;往内存里面写也是一次一个 cache line,所以一个 cache line 里面的数据最好是读写分开,否则就会相互影响。

4. Cache associative

全关联(full associative):内存可以映射到任意一个 Cache line;

N-way 关联:这个就是一个哈希表的结构,N 就是冲突链的长度,超过了 N,就需要替换。

5. Cache type

I-cache(指令)、D-cache(数据)、TLB(MMU 的 cache)

3、系统优化方法

1. Asynchronous

异步,yyds!

2. Polling

Polling 是网络设备里面常用的一个技术,比如 Linux 的 NAPI 或者 epoll。与之对应的是中断,或者是事件。Polling 避免了状态切换的开销,所以有更高的性能。但是,如果系统里面有多种任务,如何在 Polling 的时候,保证其他任务的执行时间?Polling 通常意味着独占,此时系统无法响应其他事件,可能会造成严重后果。凡是能用事件或中断的地方都能用 Polling 替代,是否合理,需要结合系统的数据流程来决定。

3. 静态内存池

静态内存有更好的性能,但是适应性较差(特别是系统里面有多个 任务的时候),而且会有浪费(提前分配,还没用到就分配了)。

4. 并发优化:lock-free 和 lock-less。

lock-free 是完全无锁的设计,有两种实现方式:

• Per-cpu data, 上文已经提及过,就是 thread local

• CAS based,CAS 是 compare and swap,这是一个原子操作(spinlock 的实现同样需要 compare and swap,但区别是 spinlock 只有两个状态 LOCKED 和 UNLOCKED,而 CAS 的变量可以有多个状态);其次,CAS 的实现必须由硬件来保障(原子操作),CAS 一次可以操作 32 bits,也有 MCAS,一次可以修改一块内存。基于 CAS 实现的数据结构没有一个统一、一致的实现方法,所以有时不如直接加锁的算法那么简单,直接,针对不同的数据结构,有不同的 CAS 实现方法,读者可以自己搜索。

lock-less 的目的是减少锁的争用(contention),而不是减少锁。这个和锁的粒度(granularity)相关,锁的粒度越小,等待的时间就越短,并发的时间就越长。

锁的争用,需要考虑不同线程在获取锁后,会执行哪些不同的动作。比如多线程队列,一般情况下,我们一把锁锁住整个队列,性能很差。如果所有的 enqueue 操作都是往队列的尾部插入新节点,而所有的 dequeue 操作都是从队列的头部删除节点,那么 enqueue 和 dequeue 大部分时候都是相互独立的,我们大部分时候根本不需要锁住整个队列,白白损失性能!那么一个很自然就能想到的算法优化方案就呼之欲出了:我们可以把那个队列锁拆成两个:一个队列头部锁(head lock)和一个队列尾部锁(tail lock),伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
typedef struct node_t {
TYPE value;
node_t *next
} NODE;

typedef struct queue_t {
NODE *head;
NODE *tail;
LOCK q_h_lock;
LOCK q_t_lock;
} Q;

initialize(Q *q) {
node = new_node() // Allocate a free node
node->next = NULL // Make it the only node in the linked list
q->head = q->tail = node // Both head and tail point to it
q->q_h_lock = q->q_t_lock = FREE // Locks are initially free
}

enqueue(Q *q, TYPE value) {
node = new_node() // Allocate a new node from the free list
node->value = value // Copy enqueued value into node
node->next = NULL // Set next pointer of node to NULL
lock(&q->q_t_lock) // Acquire t_lock in order to access Tail
q->tail->next = node // Link node at the end of the queue
q->tail = node // Swing Tail to node
unlock(&q->q_t_lock) // Release t_lock


dequeue(Q *q, TYPE *pvalue) {
lock(&q->q_h_lock) // Acquire h_lock in order to access Head
node = q->head // Read Head
new_head = node->next // Read next pointer
if new_head == NULL // Is queue empty?
unlock(&q->q_h_lock) // Release h_lock before return
return FALSE // Queue was empty
endif
*pvalue = new_head->value // Queue not empty, read value
q->head = new_head // Swing Head to next node
unlock(&q->q_h_lock) // Release h_lock
free(node) // Free node
return TRUE // Queue was not empty, dequeue succeeded
}

5. 进程间通信 - 共享内存

对于本地进程间需要高频次的大量数据交互,首推共享内存这种方案。

现代操作系统普遍采用了基于虚拟内存的管理方案,在这种内存管理方式之下,各个进程之间进行了强制隔离。程序代码中使用的内存地址均是一个虚拟地址,由操作系统的内存管理算法提前分配映射到对应的物理内存页面,CPU在执行代码指令时,对访问到的内存地址再进行实时的转换翻译。

img

从上图可以看出,不同进程之中,虽然是同一个内存地址,最终在操作系统和 CPU 的配合下,实际存储数据的内存页面却是不同的。而共享内存这种进程间通信方案的核心在于:如果让同一个物理内存页面映射到两个进程地址空间中,双方不是就可以直接读写,而无需拷贝了吗?

img

当然,共享内存只是最终的数据传输载体,双方要实现通信还得借助信号、信号量等其他通知机制。

6. I/O 优化 - 多路复用技术

网络编程中,当每个线程都要阻塞在 recv 等待对方的请求,如果访问的人多了,线程开的就多了,大量线程都在阻塞,系统运转速度也随之下降。这个时候,你需要多路复用技术,使用 select 模型,将所有等待(accept、recv)都放在主线程里,工作线程不需要再等待。

img

但是,select 不能应付海量的网站访问。这个时候,你需要升级多路复用模型为 epoll。select 有三弊,epoll 有三优:

  • select 底层采用数组来管理套接字描述符,同时管理的数量有上限,一般不超过几千个,epoll使用树和链表来管理,同时管理数量可以很大
  • select不会告诉你到底哪个套接字来了消息,你需要一个个去询问。epoll 直接告诉你谁来了消息,不用轮询
  • select进行系统调用时还需要把套接字列表在用户空间和内核空间来回拷贝,循环中调用 select 时简直浪费。epoll 统一在内核管理套接字描述符,无需来回拷贝

7. 线程池技术

使用一个公共的任务队列,请求来临时,向队列中投递任务,各个工作线程统一从队列中不断取出任务来处理,这就是线程池技术。

img

多线程技术的使用一定程度提升了服务器的并发能力,但同时,多个线程之间为了数据同步,常常需要使用互斥体、信号、条件变量等手段来同步多个线程。这些重量级的同步手段往往会导致线程在用户态/内核态多次切换,系统调用,线程切换都是不小的开销。

4、算法优化

比如高效的过滤算法、哈希算法、分治算法等等,大家在刷题的过程中估计都能感受到算法的魅力了,这里不再赘述。

5、代码层次优化

1. I-cache 优化

一是相关的源文件要放在一起;二是相关的函数在object文件里面,也应该是相邻的。这样,在可执行文件被加载到内存里面的时候,函数的位置也是相邻的。相邻的函数,冲突的几率比较小。而且相关的函数放在一起,也符合模块化编程的要求:那就是 高内聚,低耦合。

如果能够把一个 code path 上的函数编译到一起(需要编译器支持,把相关函数编译到一起), 很显然会提高 I-cache 的命中率,减少冲突。但是一个系统有很多个 code path,所以不可能面面俱到。不同的性能指标,在优化的时候可能是冲突的。所以尽量做对所以 case 都有效的优化,虽然做到这一点比较难。

常见的手段有函数重排(获取程序运行轨迹,重排二进制目标文件(elf 文件)里的代码段)、函数冷热分区等。

2. D-cache相关优化

  • Cache line alignment (cache 对齐)

数据跨越两个 cacheline,就意味着两次 load 或者两次 store。如果数据结构是 cacheline 对齐的,就有可能减少一次读写。数据结构的首地址 cache line 对齐,意味着可能有内存浪费(特别是数组这样连续分配的数据结构),所以需要在空间和时间两方面权衡。

  • 分支预测

likely/unlikely

  • Data prefetch (数据预取)

使用 X86 架构下 gcc 内置的预取指令集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

int binarySearch(int *array, int number_of_elements, int key) {
int low = 0, high = number_of_elements-1, mid;
while(low <= high) {
mid = (low + high)/2;
#ifdef DO_PREFETCH
// low path
__builtin_prefetch (&array[(mid + 1 + high)/2], 0, 1);
// high path
__builtin_prefetch (&array[(low + mid - 1)/2], 0, 1);
#endif

if(array[mid] < key)
low = mid + 1;
else if(array[mid] == key)
return mid;
else if(array[mid] > key)
high = mid-1;
}
return -1;
}
  • Register parameters (寄存器参数)

一般来说,函数调用的参数少于某个数,比如 3,参数是通过寄存器传递的(这个要看 ABI 的约定)。所以,写函数的时候,不要带那么多参数。

  • Lazy computation (延迟计算)

延迟计算的意思是最近用不上的变量,就不要去初始化。通常来说,在函数开始就会初始化很多数据,但是这些数据在函数执行过程中并没有用到(比如一个分支判断,就退出了函数),那么这些动作就是浪费了。

变量初始化是一个好的编程习惯,但是在性能优化的时候,有可能就是一个多余的动作,需要综合考虑函数的各个分支,做出决定。

延迟计算也可以是系统层次的优化,比如 COW(copy-on-write) 就是在 fork 子进程的时候,并没有复制父进程所有的页表,而是只复制指令部分。当有写发生的时候,再复制数据部分,这样可以避免不必要的复制,提供进程创建的速度。

  • Early computation (提前计算)

有些变量,需要计算一次,多次使用的时候。最好是提前计算一下,保存结果,以后再引用,避免每次都重新计算一次。

  • Allocation on stack (局部变量)

适当定义一些全局变量避免栈上的变量

  • Per-cpu data structure (非共享的数据结构)

比如并发编程时,给每个线程分配独立的内存空间

  • Move exception path out (把 exception 处理放到另一个函数里面)

只要引入了异常机制,无论系统是否会抛出异常,异常代码都会影响代码的大小与性能;未触发异常时对系统影响并不明显,主要影响一些编译优化手段;触发异常之后按异常实现机制的不同,其对系统性能的影响也不相同,不过一般很明显。所以,不用担心异常对正常代码逻辑性能的影响,同时不要借用异常机制处理业务逻辑。现代 C++ 编译器所使用的异常机制对正常代码性能的影响并不明显,只有出现异常的时候异常机制才会影响整个系统的性能,这里有一些测试数据。

另外,把 exception path 和 critical path 放到一起(代码混合在一起),就会影响 critical path 的 cache 性能。而很多时候,exception path 都是长篇大论,有点喧宾夺主的感觉。如果能把 critical path 和 exception path 完全分离开,这样对 i-cache 有很大帮助

  • Read, write split (读写分离)

伪共享(false sharing):就是说两个无关的变量,一个读,一个写,而这两个变量在一个cache line里面。那么写会导致cache line失效(通常是在多核编程里面,两个变量在不同的core上引用)。读写分离是一个很难运用的技巧,特别是在code很复杂的情况下。需要不断地调试,是个力气活(如果有工具帮助会好一点,比如 cache miss时触发 cpu 的 execption 处理之类的)

6、总结

上面所列举的大多数还是通用的高性能编程手段,从物理硬件 CPU、内存、硬盘、网卡到软件层面的通信、缓存、算法、架构每一个环节的优化都是通往高性能的道路。软件性能瓶颈定位的常用手段有 perf(火焰图)以及在 Intel CPU 上使用 pmu-tools 进行 TopDown 分析。接下来,我们将从 C++ 编程语言本身层面出发,探讨下不同场景下最高效的 C++ 代码实现方式。

img

并发优化

1、单线程中的并发

SIMD 指令集优化

提到并发,大家默认会认为是多核多线程技术,实际上单核单线程内也能利用上硬件细粒度的并发能力:SIMD(Single Instruction Multiple Data),与之相对的就是多核多线程中的 MIMD(Multiple Instruction Multiple Data)。CPU 指令集的发展经历了 MMX(Multi Media eXtension)、SSE(Streaming SIMD Extensions)、AVX(Advanced Vector Extensions)、IMCI 等。笔者在 Intel 实习期间,就是用 SSE 128 位指令集实现了 FFT(快速傅立叶变换),而以前是基于 SSE 64 位指令集实现的。

下面是一个利用 SSE 指令进行优化的例子:将 Mat1 和 Mat2 矩阵元素乘积之后更新到 Mat2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 优化前
void MatMulti(Mat m1, Mat m2) {
for (int i = 0; i < m1.rows; i++) {
float *pixel_1 = (float *)m1.data + i * m1.step / 4; // 32f
float *pixel_2 = (float *)m2.data + i * m2.step / 4; // 32f
for (int j = 0; j < m1.cols; j++) {
*pixel_2 = (*pixel_1) * (*pixel_2);
pixel_1 += 1;
pixel_2 += 1;
}
}
}

// 优化后
void SSEMatMulti(Mat m1, Mat m2)
{
for (int i = 0; i < m1.rows; i++)
{
float *pixel_1 = (float *)m1.data + i * m1.step / 4; // 32f
float *pixel_2 = (float *)m2.data + i * m2.step / 4; // 32f
for (int j = 0; j < m1.cols; j++)
{
__m128 sse_1 = _mm_load_ps(pixel_1); // 将 pixel_1 地址指向的值复制给 sse_1
__m128 sse_2 = _mm_load_ps(pixel_2); // 将 pixel_2 地址指向的值复制给 sse_2
__m128 h = _mm_mul_ss(sse_1, sse_2);
_mm_storer_ps(pixel_2, h);
pixel_1 += 1;
pixel_2 += 1;
}
}
}

关于指令集优化,更多内容请参考下面这篇文章:

C/C++指令集介绍以及优化(主要针对SSE优化)98 赞同 · 1 评论文章

OoOE(Out of Ordered Execution)优化

经典 5 级 RISC 流水线如下图所示,分为 5 个步骤:取指 -> 译码 -> 计算 -> 访存 -> 写回。

img

当执行环节遇到数据依赖,以及缓存未命中等场景,就会导致整体停顿的产生,其中 MEM 环节的影响尤其明显,主要是因为多级缓存及多核共享带来的单次访存所需周期数参差不齐的现象越来越严重。为了减轻停顿的影响,现代 CPU 引入了乱序执行结合超标量的技术,什么意思呢?一方面:对于重点执行部件,比如计算、访存,增加多份来支持并行;另一方面:在执行部件前引入缓冲池/队列机制。最终从流水线模式向类似”多线程”的方式靠拢。

TMAM(Top-down Micro-architecture Analysis Methodology,自顶向下的微架构分析方法)

这是 Intel CPU 工程师归纳总结用于优化 CPU 性能的方法论。TMAM 理论基础就是将各类 CPU 各类微指令进行归类从大的方面先确认可能出现的瓶颈,再进一步分析找到瓶颈点,该方法也符合我们人类的思维,从宏观再到细节,过早的关注细节,往往需要花费更多的时间。这套方法论的优势在于:

  • 即使没有硬件相关的知识也能够基于 CPU 的特性优化程序
  • 系统性的消除我们对程序性能瓶颈的猜测:分支预测成功率低?CPU 缓存命中率低?内存瓶颈?
  • 快速的识别出在多核乱序 CPU 中瓶颈点
  • Intel 提供分析工具:pmu-tools

笔者在华为期间,就是用这套方法对 5G 核心网进行性能优化。TMAM 将各种 CPU 资源大致分为 4 类:

img

  1. Retiring

Retiring 表示运行有效的 uOps 的 pipeline slot,即这些 uOps 最终会退出(注意一个微指令最终结果要么被丢弃、要么退出将结果回写到 register),它可以用于评估程序对 CPU 的相对比较真实的有效率。理想情况下,所有流水线 slot 都应该是”Retiring”。100% 的 Retiring 意味着每个周期的 uOps Retiring数将达到最大化,极致的 Retiring 可以增加每个周期的指令吞吐数(IPC)。需要注意的是,Retiring 这一分类的占比高并不意味着没有优化的空间。例如 retiring 中 Microcode assists 的类别实际上是对性能有损耗的,我们需要避免这类操作。

  1. Bad Speculation

Bad Speculation 表示错误预测导致浪费 pipeline 资源,包括由于提交最终不会 retired 的 uOps 以及部分 slots 是由于从先前的错误预测中恢复而被阻塞的。由于预测错误分支而浪费的工作被归类为”错误预测”类别。例如:if、switch、while、for等都可能会产生 bad speculation。

  1. Front-End-Bound
  • 取指令
  • 将指令进行解码成微指令
  • 将指令分发给 Back-End,每个周期最多分发4条微指令

Front-End Bound 表示处理其的 Front-End 的一部分 slots 没法交付足够的指令给 Back-End。Front-End 作为处理器的第一个部分其核心职责就是获取 Back-End 所需的指令。在 Front-End 中由预测器预测下一个需要获取的地址,然后从内存子系统中获取对应的缓存行,在转换成对应的指令,最后解码成uOps(微指令)。Front-End Bound 意味着,会导致部分slot 即使 Back-End 没有阻塞也会被闲置。例如因为指令 cache misses引起的阻塞是可以归类为 Front-End Bound。

优化建议:

(1)尽可能减少代码的 footprint:C/C++可以利用编译器的优化选项来帮助优化,比如 GCC -O* 都会对 footprint 进行优化或者通过指定 -fomit-frame-pointer 也可以达到效果;

(2)充分利用 CPU 硬件特性:宏融合(macro-fusion)特性可以将2条指令合并成一条微指令,它能提升 Front-End 的吞吐。 比如下图中的循环示例:

img

所以建议循环条件中的类型采用无符号的数据类型可以使用到宏融合特性提升 Front-End 吞吐量。

(3)调整代码布局(co-locating-hot-code)

  • 充分利用编译器的 PGO 特性:-fprofile-generate -fprofile-use
  • 可以通过__attribute__ ((hot)) __attribute__ ((code)) 来调整代码在内存中的布局,hot 的代码在解码阶段有利于 CPU 进行预取。

其他优化选项,可以参考:

GCC优化选项link.segmentfault.com/?enc=IsWvcWLv6jLcFmwla6JftA%3D%3D.YzsQCES2I7zZaQzhb4r136cyNY2G%2BnkGtS8mCjA7ZvfQ2MH2GJToX83L1KPVLZs6vG%2F%2BWSB0kM4EFtQyoBwbWA%3D%3D

GCC 通用属性优化link.segmentfault.com/?enc=G2y8z%2BV1BEbm%2BtUNGilarQ%3D%3D.0cpASZjv8hkrvrjlLTKEJMEOD88PlbnAD5vXXrLIdlTU6vZmCvCKEu35f5HdIyoHB373G3%2B0YRdlDSK9Q29tLw%2FSO4vX4EH%2FOAVcrD6IHyyANKmDqlQRQEy9I89OXyAm

(4)分支预测优化

\4. Back-End-Bound

  • 接收 Front-End 提交的微指令
  • 必要时对 Front-End 提交的微指令进行重排
  • 从内存中获取对应的指令操作数
  • 执行微指令、提交结果到内存

Back-End Bound 表示部分 pipeline slots 因为 Back-End 缺少一些必要的资源导致没有 uOps 交付给 Back-End。

Back-End 处理器的核心部分是通过调度器乱序地将准备好的 uOps 分发给对应执行单元,一旦执行完成,uOps 将会根据程序的顺序返回对应的结果。例如:像 cache-misses 引起的阻塞(停顿)或者因为除法运算器过载引起的停顿都可以归为此类。此类别可以在进行细分为两大类:Memory-Bound 、Core Bound。

归纳总结一下就是:

Front End Bound = Bound in Instruction Fetch -> Decode (Instruction Cache, ITLB)Back End Bound = Bound in Execute -> Commit (Example = Execute, load latency)
Bad Speculation = When pipeline incorrectly predicts execution (Example branch mispredict memory ordering nuke)
Retiring = Pipeline is retiring uops

一个微指令状态可以按照下图决策树进行归类:

img

上图中的叶子节点,程序运行一定时间之后各个类别都会有一个 pipeline slot 的占比,只有 Retiring 的才是我们所期望的结果,那么每个类别占比应该是多少才是合理或者说性能相对来说是比较好,没有必要再继续优化?Intel 在实验室里根据不同的程序类型提供了一个参考的标准:

img

只有 Retiring 类别是越高越好,其他三类都是占比越低越好。如果某一个类别占比比较突出,那么它就是我们进行优化时重点关注的对象。

优化建议:

(1)合理使用缓存行对齐

CPU 的缓存是弥足珍贵的,应该尽量的提高其使用率,平常使用过程中可能存在一些误区导致 CPU cache 有效利用率比较低。下面来看一个不适合进行缓存行对齐的例子:

1
2
3
4
5
6
7
8
9
#include <stdlib.h>
#define CACHE_LINE

struct S1 {
int r1;
int r2;
int r3;
S1() : r1(1), r2(2), r3(3) {}
} CACHE_LINE;

下面这个是测试效果:

img

做了缓存行对齐:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <string.h>

#define CACHE_LINE __attribute__((aligned(64)))

struct S1 {
int r1;
int r2;
int r3;
S1() : r1(1), r2(2), r3(3) {}
} CACHE_LINE;

测试结果:

img

通过对比两个 retiring 就知道,这种场景下没有做 cache 对齐缓存利用率高,因为在单线程中采用了缓存行导致 cpu cache 利用率低,在上面的例子中缓存行利用率才 3*4/64 = 18%。缓存行对齐使用原则:

  • 多个线程存在同时写一个对象、结构体的场景(即存在伪共享的场景)
  • 对象、结构体过大的时候
  • 将高频访问的对象属性尽可能的放在对象、结构体首部

2、多线程中的并发

临界区保护技术

  • Mutual Execlusion(pessimistic locking):基本的互斥技术,存在某个时间周期,算法没有任何实质进展,典型的悲观锁算法
  • Lock Free (optimistic locking):组成算法的一个线程没有任何实质进展,基于 CAS 同步提交,若遇到冲突,回滚
  • Wait Free:任意时间周期,算法的任意一个线程都有实质进展

举个例子:多线程累加,上述三种技术对应以下实现方案:

  • 上锁后累加
  • 累加后 CAS 提交
  • 累加后 FAA(Fetch and Add)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
uint64_t calc(uint64_t* seq, size_t size) {
for (size_t i = 0; i < size; i++) {
seq[(i + 1) & 7] += seq[i & 7];
}
return seq[i & 7];
}

std::mutex mtx;
uint64_t sum = 0;
size_t workload = 10000;
uint64_t seq[512] = {0};

{
// Mutual Exclusion
std::lock_guard<std::mutex> lock(mtx);
sum += calc(seq, workload);
}

{
// Lock Free / Atomic CAS
auto curr = atomic_sum.load(std::memory_order_relaxed);
auto next = curr;
do {
next = curr + calc(seq, workload)
} while (!atomic_sum.compare_exchange_weak(curr, next, std::memory_ordered_relaxed));
}

{
// Wait Free / Atmoic Modify
atomic_sum.fetch_add(calc(seq, workload), std::memory_order_relaxed);
}

实际操作中,我们该如何选择呢?

  • 优先考虑 Wait Free 的方法,如果可以的话,在性能上接近完全消除了临界区的效果
  • 充分缩减临界区
  • 在临界区足够小,且无 Wait Free 方案时,不必对 Lock Free 过度执着,因为 Lock Free “无效预测执行” 以及支持撤销回滚的两阶段提交算法非常复杂,反而会引起过多的消耗。锁本身的开销虽然稍重于原子操作,但其实可以接受的。真正影响性能的是临界区被迫串行执行所带来的并行能力折损。

并发队列

在上一篇文章中已经提到过,这里不再赘述了。

伪共享

多个 CPU 同时对同一个缓存行的数据进行修改,导致 CPU cache 的数据不一致,也就是缓存失效问题。为什么伪共享只发生在多线程的场景,而多进程的场景不会有问题?这是因为 linux 虚拟内存的特性,各个进程的虚拟地址空间是相互隔离的,也就是说在数据不进行缓存行对齐的情况下,CPU 执行进程 1 时加载的一个缓存行的数据,只会属于进程 1,而不会存在一部分是进程 1、另外一部分是进程 2。

img

(上图中不同型号的 L2 cache 组织形式可能不同,有的可能是每个 core 独占,例如 skylake)

伪共享之所以对性能影响很大,是因为他会导致原本可以并行执行的操作,变成了并发执行。这是高性能服务不能接受的,所以我们需要对齐进行优化,方法就是 CPU 缓存行对齐(cache line align)解决伪共享,本来就是一个以空间换取时间的方案。

Linux系统中采用 MESI 协议处理缓存一致性,所谓 MESI 即是指 CPU 缓存的四种状态:

  • M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有);
  • E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据;
  • S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝;
  • I(无效,Invalid):缓存行失效, 不能使用。

img

MESI状态转换

每个 CPU 缓存行都在四个状态之间互相转换,以此决定 CPU 缓存是否失效,比如 CPU 对一个缓存行执行了写入操作,则此操作会导致其他 CPU 的该缓存行进入 Invalid 无效状态,CPU 需要使用该缓存行的时候需要从内存中重新读取。由此就解决了多 CPU 之间的缓存一致性问题。消除伪共享有如下两种方法:

  1. 缓存行填充(Padding):为了避免伪共享就需要将可能造成伪共享的多个变量处于不同的缓存行中,可以采用在变量后面填充字节的方式达到该目的。
  2. 尽量让相关访问的数据在一个 cache-line
  3. 使用某些语言或编译器中强制变量对齐,将变量都对齐到缓存行大小,避免伪共享发生。

内存优化

1、tcmalloc 和 jemalloc

线程池技术中,每个线程各司其职,完成一个一个的任务。在 malloc 看来,就是多个长生命周期的线程,随机的在各个时间节点进行内存申请和内存释放。基于这样的场景,首先,尽量分配连续地址空间。其次,多线程下需要考虑分区隔离和减少竞争。

tcmalloc 和 jemalloc 共同的思路是引入线程缓存机制。通过一次从后端获取大块内存,放入缓存供线程多次申请,降低对后端的实际竞争强度。主要不同点是,当线程缓存被击穿后,tcmalloc 采用了单一的 page heap(简化了中间的 transfer cache 和 central cache) 来承载;而 jemalloc 采用了多个 arena(甚至超过了服务器 core 数)。一般来讲,在线程数较少,或释放强度较低的情况下,较为简洁的 tcmalloc 性能稍胜 jemalloc。在 core 数较多、申请释放频繁时,jemalloc 因为锁竞争强度远小于 tcmalloc,性能较好

理想的 malloc 模型是什么?

  • 低竞争性和连续性

微服务、流式计算、缓存,这几种业务模型几乎涵盖了所有主流的后端服务场景。而这几种业务对内存的应用有一个重要的特征:拥有边界明确的生命周期。比如在早期的 server 设计中,每个 client 请求都分配一个单独的线程处理,处理完再整体销毁。但随着新型的子任务级线程池并发技术的广泛应用,即请求细分为多个子任务充分利用多核并发来提升计算性能。

std::vector 如何优化?这里提供一种思路:

  • 和典型的 vector 处理主要不同点是:在 clear 或者 pop_back 等操作缩减大小之后,内容对象并不实际析构,只是清空重置。因此,再一次用到这个槽位的时候,可以直接拿到已经构造好的元素,而且其 capacity 之内的内存依然持有。当反复使用同一个实例时,容器内存和每个元素自身的 capacity 都会趋于饱和值,反复的分配和构造需求都被减少了。

内存分配和实例构造功能解耦。这也是 PMR(Polymorphic Memory Resource,C++17 的新特性)设计的出发点,大名鼎鼎的 EASTL 就是它的原型,它就是为低延迟、高频、计算密集型任务开发的。

2、string

短字符串分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <chrono>
#include <iostream>

struct Timer {
std::chrono::high_resolution_clock::time_point start, end;
std::chrono::duration<float> duration;
Timer() { start = std::chrono::high_resolution_clock::now(); }
~Timer() {
end = std::chrono::high_resolution_clock::now();
duration = end - start;
float ns = duration.count() * 1000000.0f;
std::cout << "Timer took " << ns << "ns"
<< "\n";
}
};

const int SIZE = 1000000;
void test_stack() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
char buf[12];
}
}

void test_string() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::string str("hello world");
}
}

int main() {
test_stack();
test_string();
return 0;
}

测试结果:

img

短字符串构造,char 和 string 性能差不多

长字符串分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const int SIZE = 1000000;
void test_stack() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
char buf[32];
}
}

void test_string() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::string str("hello world, it is test string.");
}
}

int main() {
test_stack();
test_string();
return 0;
}

测试结果:

img

长字符串构造,string 性能比 char 差很多

string 在 libstadc++ 和 libc++ 的实现方式是不一样的

std::pmr::string

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <memory_resource>

const int SIZE = 1000000;
void test_stack() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::string str("hello world, it is test string.");
}
}

void test_string() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::pmr::string str("hello world, it is test string.");
}
}

测试结果:

img

std::pmr::string允许我们在栈上创建string,当超过 1024 个字节后才会在堆上申请内存。

3、vector

stl 中 vector 的内存增长速度是 2 的幂次方,而这个值是可以调整的,比如:folly 的 small vector

folly/small_vector.md at main · facebook/follygithub.com/facebook/folly/blob/main/folly/docs/small_vector.md

4、map

STL 中的 map 是基于红黑树来实现的,而高效的 map 必然是 hash map,进一步优化的思路就是在 hash map 的基础上引入内存池技术。

C++ 数据结构设计:如何高效地存储并操作超大规模的 70 赞同 · 7 评论文章

https://github.com/ktprime/emhashgithub.com/ktprime/emhash

5、protobuf

比如采取某些字段合并策略,尽量减少序列化、反序列化的次数。

6、高效使用智能指针

  • 使用 std::make_shared 代替 new T
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyClass {
public:
MyClass(std::string s, int i) : s(s), i(i) {} // 使用初始化列表比较快

public:
std::string s;
int i;
};

const int SIZE = 1000000;
void test1() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::shared_ptr<MyClass> p(new MyClass("hello", 123)); // 会调用两次内存管理器,第一次用于创建 MyClass 的实例,第二次用来创建 std::shared_ptr 的内部结构。
}
}

void test2() {
Timer timer;
for (int i = 0; i < SIZE; i++) {
std::shared_ptr<MyClass> p = std::make_shared<MyClass>("hello", 123); // 一次性分配内存同时保存以上两种数据结构
}
}

int main() {
test1();
test2();
return 0;
}

测试结果:

img

  • 避免使用 std::shared_ptr 作为函数的入参,而是通过 get() 函数传递实际的指针
  • 通过 = delete 修饰,在类定义中禁止不希望发生的复制

1. GPU 简介

GPU(Graphics Processing Unit)是一种图形渲染设备,是显卡(Video Card/Graphics Card)的计算核心。GPU 最初仅用作纹理映射和多边形着色等需要较多存储空间的图形处理任务,不过,现代 GPU 已经不再局限于 3D 图形处理。GPU 已经成为了通用的多核处理器,它在 浮点运算、并行计算 等方面提供数十倍乃至于上百倍于 CPU 的性能。

GPU 和 CPU 有设计理念的不同,GPU 中包含大量的 ALU(Arithmetic Logic Unit),如图 1 所示。

gpu_cpu.gif

Figure 1: GPU 和 CPU 有设计理念的不同,GPU 中包含大量的计算单元

2. CUDA C 编程

Nvidia 提出了 CUDA(Compute Unified Device Architecture)编程模型,它在 C(注:也支持 Fortran)语言的基础上进行了很小的扩展,使得应用程序既可以包含在 CPU 中执行的代码,又可以包含在 GPU 中执行的代码,充分利用了 CPU 和 GPU 各自的优点。

CUDA 程序的执行过程如图 2 所示,这个图演示了先执行 CPU 代码,再执行 GPU 代码,然后又执行 CPU 代码,又再执行 GPU 代码的情况。

gpu_cuda_prog.gif

Figure 2: Execution of a CUDA program

CUDA 程序中, 一个函数用 __global__ 修饰,表明这个函数在 GPU 中运行,且被称为“kernel”。

2.1. Hello World

下面是 CUDA 版本的 Hello World 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>

// Your first kernel (= GPU function)
__global__ void helloFromGPU (void) // __global__ 是 CUDA 的扩展,表明这个函数在 GPU 中运行
{
printf("Hello World from GPU! Thread %d\n", threadIdx.x);
}

int main(void)
{
helloFromGPU <<<1, 4>>>(); // <<< >>> 是 CUDA 的扩展,用于指定GPU线程规模

cudaDeviceReset();
return 0;
}

使用 nvcc 进行编译:

1
$ nvcc hello.cu –o hello

测试运行得到的可执行程序:

1
2
3
4
5
$ ./hello
Hello World from GPU! Thread 0
Hello World from GPU! Thread 1
Hello World from GPU! Thread 2
Hello World from GPU! Thread 3

2.2. 实例:数组相加

下面通过“两个浮点数数组相加”的例子来介绍一下 CUDA 编程。

2.2.1. CPU 版本

先看一下“两个浮点数数组相加”的 CPU 版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <math.h>

// function to add the elements of two arrays
void add(int n, float *x, float *y)
{
for (int i = 0; i < n; i++)
y[i] = x[i] + y[i];
}

int main(void)
{
int N = 1<<20; // 1M elements

float *x = new float[N];
float *y = new float[N];

// initialize x and y arrays on the host
for (int i = 0; i < N; i++) {
x[i] = 1.0f;
y[i] = 2.0f;
}

// Run on the CPU
add(N, x, y);

// Check for errors (all values should be 3.0f)
float maxError = 0.0f;
for (int i = 0; i < N; i++)
maxError = fmax(maxError, fabs(y[i]-3.0f));
std::cout << "Max error: " << maxError << std::endl;

// Free memory
delete [] x;
delete [] y;

return 0;
}

编译并运行:

1
2
3
$ g++ add.cpp -o add
$ ./add
Max error: 0

2.2.2. 改造为 GPU 版本

下面我们来看看如何把前面的程序改造为 GPU 版本。

GPU 只能访问 GPU 中的内存,称为 Device Memory;而 CPU 能访问的内存称为 Host Memory。

gpu_device_memory.gif

Figure 3: Host Memory and Device Memory

对于前面例子,我们要把输入数据(x, y 两个数组)所占的内存从 Host Memory 复制到 Device Memory 中,然后执行 GPU 计算,计算完成后,把计算后的结果从 Device Memory 复制加 Host Memory 中。 这是 GPU 编程的通用编程模式。

下面是改造后的 GPU 版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 这个例子仅启动了 1 个 GPU 线程,没有利用 GPU 优势
#include <iostream>
#include <math.h>

// Kernel function to add the elements of two arrays
__global__ // __global__ 表示其将在 GPU 上运行
void add(int n, float *x, float *y)
{
for (int i = 0; i < n; i++)
y[i] = x[i] + y[i];
}

int main(void)
{
int N = 1<<20; // 1M elements

float *x = new float[N];
float *y = new float[N];

// initialize x and y arrays on the host
for (int i = 0; i < N; i++) {
x[i] = 1.0f;
y[i] = 2.0f;
}

//
float *dev_x, *dev_y;
int size = N * sizeof(float);
cudaError_t err
err = cudaMalloc((void **)&dev_x, size); // 在 GPU 上分配内存
if (err != cudaSuccess) {
printf("%s in %s at line %d\n", cudaGetErrorString(err),__FILE__,__LINE__);
exit(EXIT_FAILURE);
}
err = cudaMalloc((void **)&dev_y, size);
if (err != cudaSuccess) {
printf("%s in %s at line %d\n", cudaGetErrorString(err),__FILE__,__LINE__);
exit(EXIT_FAILURE);
}
cudaMemcpy(dev_x, x, size, cudaMemcpyHostToDevice); // 把输入数据从 Host 内存到 Device 内存
cudaMemcpy(dev_y, y, size, cudaMemcpyHostToDevice);

add<<<1, 1>>>(N, x, y); // 在 GPU 上执行计算

cudaMemcpy(y, dev_y, size, cudaMemcpyDeviceToHost); // 把结果从 Device 内存复制回 Host 内存

cudaFree(dev_x); // 释放 Device 内存
cudaFree(dev_y);

// Check for errors (all values should be 3.0f)
float maxError = 0.0f;
for (int i = 0; i < N; i++)
maxError = fmax(maxError, fabs(y[i]-3.0f));
std::cout << "Max error: " << maxError << std::endl;

// Free Host memory
delete [] x;
delete [] y;

return 0;
}

经过这个改造后,函数 add 在 GPU 上运行,但仅启动了一个 GPU 线程,这并没有利用 GPU 的优势。为了利用 GPU 优势,我们需要对函数 add 本身进行改造。后面将对此进行介绍。

2.2.3. 线程结构 <<<numBlocks, threadsPerBlock>>>

在启用 GPU 线程时,需要使用语法 <<<numBlocks, threadsPerBlock>>> 指定线程结构。

比如: kernel1<<<1, 4>>>(); 表示 1 个 block,每个 block 中有 4 个线程。
在 kernel 函数中,可以通过 threadIdx.x 知道自己是第几个线程。这例子中 kernel1 中打印 threadIdx.x 时会分别得到 0,1,2,3,参考节 2.1 中的例子。

又如: kernel1<<<2, 4>>>(); 表示 2 个 block,每个 block 中有 4 个线程。
在 kernel 函数中, 可以通过 blockIdx.x 知道自己是第几个 block,这个例子中会分别为 0,1;可以通过 threadIdx.x 知道自己是第几个线程。 这例子中 kernel1 中打印 threadIdx.x 时会分别得到 0,1,2,3。也就是说 8 个线程中打印 blockIdx.xthreadIdx.x 时会得到:

1
2
3
4
5
6
7
8
9
blockIdx.x   threadIdx.x
0 0
0 1
0 2
0 3
1 0
1 1
1 2
1 3

在 kernel 函数,通过 blockDim.x 可以知道 threadIdx.x 的维度(最大 x 下标加 1)。也就是说 8 个线程中打印 blockIdx.x, threadIdx.x, blockDim.x 时会得到:

1
2
3
4
5
6
7
8
9
blockIdx.x   threadIdx.x   blockDim.x
0 0 4
0 1 4
0 2 4
0 3 4
1 0 4
1 1 4
1 2 4
1 3 4

这样,以方式 kernel1<<<2, 4>>>(); 启动 kernel1 时,在 kernel1 函数中使用 blockIdx.x * blockDim.x + threadIdx.x 就可以得到 0,1,2,3,4,5,6,7。

这里介绍的线程结构比较简单,关于更多细节,可参考节:3.1

2.2.4. GPU 版本 2(monolithic kernel)

为了充分利用 GPU 优势,我们让每个 GPU 线程仅处理数组中的一个元素。kernel 函数改造为如下:

1
2
3
4
5
6
7
__global__
void add(int n, float *x, float *y) // kernel中仅处理一个元素,每个 kernel处理不同元素
{
int i = blockIdx.x * blockDim.x + threadIdx.x; // 获取元素下标,其含义参考上一节内容
if (i < n)
y[i] = x[i] + y[i];
}

这种类型的 kernel 被称为“monolithic kernel”。

每个线程仅处理 1 个元素,数组有 N=1<<20 (即 1M)元素,故我们需要启动 1M 线程,每个线程处理不同元素,下面这都是可行的:

1
2
add<<<ceil(N/512.0), 512>>>(N, x, y); // ceil(N/512.0) = 2048 个 block,每个 block 中有 512 个线程;2048 * 512 = 1M
add<<<ceil(N/256.0), 256>>>(N, x, y); // ceil(N/256.0) = 4096 个 block,每个 block 中有 256 个线程;4096 * 256 = 1M

我们可以直接配置 1 个 block,让 block 的线程数为 N 吗?像下面这样:

1
add<<<1, N>>>(N, x, y);               // 这是不行的,N 超过了每个 block 中的最大线程数的限制

这是不行的。因为每个 block 中的最大线程数是有限制的:

  1. 当 Compute capability < 2.0 时,每个 block 中的最大线程数为 512;
  2. 当 Compute capability >= 2.0 时,每个 block 中的最大线程数为 1024。

2.2.5. GPU 版本 3(grid-stride loop)

在上一节介绍的 monolithic kernel 是不灵活的。启动 GPU 线程时,必须指定恰当的 <<<numBlocks, threadsPerBlock>>> 参数,否则可能出现数组元素没有被处理的情况(这种情况在指定的 numBlocks 太小时可能出现);此外,当元素规模变得更大时,可能会超过 numBlocks 的最大限制。

这种介绍另外一种更加灵活的 Kernel 函数编写方式——grid-stride loop:

1
2
3
4
5
6
7
8
__global__
void add(int n, float *x, float *y) // grid-stride loop
{
int index = blockIdx.x * blockDim.x + threadIdx.x;
int stride = blockDim.x * gridDim.x;
for (int i = index; i < n; i += stride)
y[i] = x[i] + y[i];
}

grid-stride loop 形式的 kernel 很灵活,其正确性和调用时如何指定线程无关。比如,下面这些调用形式都可以得到正确的结果:

1
2
3
4
5
add<<<1, 256>>>(N, x, y);
add<<<2, 256>>>(N, x, y);
add<<<4096, 256>>>(N, x, y);
add<<<1, 1>>>(N, x, y);
......

下面分别介绍这几种情况。

当使用 add<<<1, 256>>>(N, x, y); 时,共 256 个线程,每个线程处理 4096 个元素,每个线程中各变量如下:

1
2
3
4
5
6
blockIdx.x  blockDim.x  threadIdx.x  gridDim.x  index    stride
0 256 0 1 0 256 * 1
0 256 1 1 1 256 * 1
......
0 256 254 1 254 256 * 1
0 256 255 1 255 256 * 1

例如,index 为 0 的线程将对数组下标为 0, 256, 2256, …, 4095256 的元素(共 4096 个)进行处理。

当使用 add<<<2, 256>>>(N, x, y); 共 512 个线程,每个线程处理 2048 个元素,每个线程中各变量如下:

1
2
3
4
5
6
7
8
9
10
11
blockIdx.x  blockDim.x  threadIdx.x  gridDim.x  index    stride
0 256 0 2 0 256 * 2
0 256 1 2 1 256 * 2
......
0 256 254 2 254 256 * 2
0 256 255 2 255 256 * 2
1 256 0 2 256 256 * 2
1 256 1 2 257 256 * 2
......
1 256 254 2 510 256 * 2
1 256 255 2 511 256 * 2

例如,index 为 0 的线程将对数组下标为 0, 512, 2512, …, 2047512 的元素(共 2048 个)进行处理。

当使用 add<<<4096, 256>>>(N, x, y); 时,共 1M 线程(index 为 515 的线程如图 4 所示,图片摘自 https://developer.nvidia.com/blog/even-easier-introduction-cuda/ ),每个线程处理 1 个元素,每个线程中各变量如下:

1
2
3
4
5
6
7
8
9
10
11
blockIdx.x  blockDim.x  threadIdx.x  gridDim.x  index    stride
0 256 0 4096 0 256 * 4096
0 256 1 4096 1 256 * 4096
......
0 256 254 4096 254 256 * 4096
0 256 255 4096 255 256 * 4096
1 256 0 4096 256 256 * 4096
1 256 1 4096 257 256 * 4096
......
4095 256 254 4096 1048574 256 * 4096
4095 256 255 4096 1048575 256 * 4096

gpu_cuda_indexing.gif

Figure 4: add<<<4096, 256>>>(N, x, y); 中 index 为 515 的线程

当使用 add<<<1, 1>>>(N, x, y); 时,共 1 个线程,每个线程处理 1M 元素,每个线程中各变量如下:

1
2
blockIdx.x  blockDim.x  threadIdx.x  gridDim.x  index    stride
0 1 0 1 0 1 * 1

CUDA Pro Tip: Write Flexible Kernels with Grid-Stride Loops 一文中,总结了 grid-stride loop 的几个优点:

  1. Scalability and thread reuse. By using a loop, you can support any problem size even if it exceeds the largest grid size your CUDA device supports. Moreover, you can limit the number of blocks you use to tune performance.
  2. Debugging. By using a loop instead of a monolithic kernel, you can easily switch to serial processing by launching one block with one thread. add<<<1, 1>>>(N, x, y); This makes it easier to emulate a serial host implementation to validate results.
  3. Portability and readability. The grid-stride loop code is more like the original sequential loop code than the monolithic kernel code, making it clearer for other users.

2.3. Function Execution Space Specifiers

函数除了用 __global__ 修饰外,还可以指定 __device__, __host__ ,它们的含义和区别如表 1 所示。

name Executed on the: Only callable from the:
__global__ device host
__device__ device device
__host__ host host

所谓 host 就是指 CPU,而 device 就是 GPU。

__global__ 不能和 __device__ 同时使用,也不能和 __host__ 同时使用;而 __device____host__ 可以同时使用。

参考:https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#function-declaration-specifiers

2.3.1. __forceinline__ and __noinline__

当一个函数用 __device__ 修饰时,编译器自己会决定是否对该函数进行内联编译。

我们也可以指定 __forceinline__ 或者 __noinline__ 来强制使用(或者不使用)内联编译。

3. 可伸缩的并行执行

3.1. CUDA 线程组织

一个 Grid 内的所有线程会执行相同的 kernel 函数。

在语法 <<<m, n>>> 中,参数 m 和 n 除了可以是 int 类型外,还可以是 dim3 类型(三维数组):

1
2
3
dim3 dimGrid(2, 1, 1);
dim3 dimBlock(4, 1, 1);
kernel1<<<dimGrid, dimBlock>>>(...); // 这相同于 kernel1<<<2, 4>>>(...)

下面我们看一个复杂一些的例子,dimGrid 为 2 x 2 x 1,dimBlock 为 4 x 2 x 2:

1
2
3
dim3 dimGrid(2, 2, 1);
dim3 dimBlock(4, 2, 2);
Kernel1<<<dimGrid, dimBlock>>>(...);

这个例子中,block 共 4 个,表现为二维形式;而 thread 共 8 个,表现为三维形式,如图 5 所示。

gpu_dimgrid_dimblock.png

Figure 5: A multidimensional example of CUDA grid organization.

内置变量 gridDim.x, gridDim.y, gridDim.z 分别保存着 grid 的三个维度的信息,上面例子中,由于 dimGrid 为 2 x 2 x 1,所以有:

1
2
3
gridDim.x = 2
gridDim.y = 2
gridDim.z = 1

内置变量 blockDim.x, blockDim.y, blockDim.z 分别保存着 block 的三个维度的信息,上面例子中,由于 dimBlock 为 4 x 2 x 2,所以有:

1
2
3
blockDim.x = 4
blockDim.y = 2
blockDim.z = 2

blockIdx.x, blockIdx.y, blockIdx.z 保存着 grid 中当前 block 的下标, threadIdx.x, threadIdx.y, threadIdx.z 保存着 block 中当前 thread 的下标。如果以图 5 中面向读者的右下角那个 thread 为当前 thread,则有:

1
2
3
4
5
6
blockIdx.x = 1
blockIdx.y = 1
blockIdx.z = 0
threadIdx.x = 0
threadIdx.y = 1
threadIdx.z = 3

3.1.1. 各种维度情况下线程的编号

不管采用什么维度的 grid 和 block,我们都可以得到当前 thread 的扁平的全局一维下标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 1D grid of 1D blocks
__device__
int getGlobalIdx_1D_1D(){
return blockIdx.x *blockDim.x + threadIdx.x;
}

// 1D grid of 2D blocks
__device__
int getGlobalIdx_1D_2D(){
return blockIdx.x * blockDim.x * blockDim.y
+ threadIdx.y * blockDim.x + threadIdx.x;
}

// 1D grid of 3D blocks
__device__
int getGlobalIdx_1D_3D(){
return blockIdx.x * blockDim.x * blockDim.y * blockDim.z
+ threadIdx.z * blockDim.y * blockDim.x
+ threadIdx.y * blockDim.x + threadIdx.x;
}

// 2D grid of 1D blocks
__device__ int getGlobalIdx_2D_1D(){
int blockId = blockIdx.y * gridDim.x + blockIdx.x;
int threadId = blockId * blockDim.x + threadIdx.x;
return threadId;
}

// 2D grid of 2D blocks
__device__
int getGlobalIdx_2D_2D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x;
int threadId = blockId * (blockDim.x * blockDim.y)
+ (threadIdx.y * blockDim.x) + threadIdx.x;
return threadId;
}

// 2D grid of 3D blocks
__device__
int getGlobalIdx_2D_3D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x;
int threadId = blockId * (blockDim.x * blockDim.y * blockDim.z)
+ (threadIdx.z * (blockDim.x * blockDim.y))
+ (threadIdx.y * blockDim.x) + threadIdx.x;
return threadId;
}

// 3D grid of 1D blocks
__device__
int getGlobalIdx_3D_1D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x
+ gridDim.x * gridDim.y * blockIdx.z;
int threadId = blockId * blockDim.x + threadIdx.x;
return threadId;
}

// 3D grid of 2D blocks
__device__
int getGlobalIdx_3D_2D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x
+ gridDim.x * gridDim.y * blockIdx.z;
int threadId = blockId * (blockDim.x * blockDim.y)
+ (threadIdx.y * blockDim.x) + threadIdx.x;
return threadId;
}

// 3D grid of 3D blocks
__device__
int getGlobalIdx_3D_3D(){
int blockId = blockIdx.x + blockIdx.y * gridDim.x
+ gridDim.x * gridDim.y * blockIdx.z;
int threadId = blockId * (blockDim.x * blockDim.y * blockDim.z)
+ (threadIdx.z * (blockDim.x * blockDim.y))
+ (threadIdx.y * blockDim.x) + threadIdx.x;
return threadId;
}

参考:https://cs.calvin.edu/courses/cs/374/CUDA/CUDA-Thread-Indexing-Cheatsheet.pdf

3.1.2. 抽象概念(Grid/Block/Thread)和硬件的映射关系

下面是抽象概念(Grid/Block/Thread)和硬件的映射关系:

  • Grids map to GPUs
  • Blocks map to the MultiProcessors (MP)
  • Threads map to Stream Processors (SP)
  • Warps are groups of (32) threads that execute simultaneously

3.2. 映射线程到多维数据(RGB 转灰度图片实例)

grid 可以是 1D,2D,3D,block 也可以是 1D,2D,3D,那我们应该如何选择线程的组织形式呢?这往往由待处理数组的结构的决定。 比如,处理图片时,由于图片是像素点的二维数组,这时采用 2D grid 和 2D block 是个不错的选择。假设,现在要处理图片的像素规模为 x×y=76×62 。我们决定采用 16 x 16 的 2D block,这时 x 方向上至少需要 5 block,而 y 方向上至少需要 4 block,如图 6 所示。

gpu_img_size.gif

Figure 6: Using a 2D thread grid to process a 76 × 62 picture P.

从图 6 中可以看到,在 x 方向上有 4 个多余的线程,在 y 方向上有 2 个多余的线程。在 kernel 函数中通过边界检查让多余线程不执行操作即可。

假设 GPU 任务为 RGB 彩色图片转灰色图片,则可以这样启动 kernel:

1
2
3
4
5
int m = 76;
int n = 62;
dim3 dimGrid(ceil(m/16.0), ceil(n/16.0), 1); // 5 x 4 x 1
dim3 dimBlock(16, 16, 1); // 16 x 16 x 1
colorToGreyscaleConversion<<<dimGrid,dimBlock>>>(d_Pin, d_Pout, m, n);

关键的 kernel,即 colorToGreyscaleConversion 的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// we have 3 channels corresponding to RGB
// The input image is encoded as unsigned characters [0, 255]
__global__
void colorToGreyscaleConversion(unsigned char * Pout, unsigned
char * Pin, int width, int height) {,
int Col = threadIdx.x + blockIdx.x * blockDim.x; // threadIdx.x: [0, 15] ,blockIdx.x: [0, 4],blockDim.x 总是为 16
int Row = threadIdx.y + blockIdx.y * blockDim.y; // threadIdx.y: [0, 15] ,blockIdx.y: [0, 3],blockDim.y 总是为 16
if (Col < width && Row < height) { // 多余的线程不会通过这个边界检查
// get 1D coordinate for the grayscale image
int greyOffset = Row*width + Col;
// one can think of the RGB image having
// CHANNEL times columns than the grayscale image
int rgbOffset = greyOffset*CHANNELS; // RGB 有 3 个通道,CHANNELS 为 3
unsigned char r = Pin[rgbOffset ]; // red value for pixel
unsigned char g = Pin[rgbOffset + 2]; // green value for pixel
unsigned char b = Pin[rgbOffset + 3]; // blue value for pixel
// perform the rescaling and store it
// We multiply by floating point constants
Pout[grayOffset] = 0.21f*r + 0.71f*g + 0.07f*b; // RGB 转灰色的公式
}
}

3.3. 图片模糊处理实例

下面看一个更复杂的图片处理例子——图片模糊处理。

图片模糊处理的一种方式就是“把当前像素相邻的几个像素的平均值”作为当前像素的值,如图 7 所示,它取的是 3 x 3 小窗口里的像素的平均值(当然这个小窗口也可以更大,如 5 x 5 或 7 x 7 等)。

gpu_img_blur.gif

Figure 7: Each output pixel is the average of a patch of pixels in the input image.

下面是图片模糊处理 blurKernel 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
__global__
void blurKernel(unsigned char * in, unsigned char * out, int w, int h) {
int Col = threadIdx.x + blockIdx.x * blockDim.x;
int Row = threadIdx.y + blockIdx.y * blockDim.y;
if (Col < w && Row < h) {
int pixVal = 0;
int pixels = 0;

// Get the average of the surrounding BLUR_SIZE x BLUE_SIZE box
for (int blurRow = -BLUR_SIZE; blurRow < BLUR_SIZE + 1; ++blurRow) {
for (int blurCol = -BLUE_SIZE; blurCol < BLUR_SIZE + 1; ++blurCol) {
int curRow = Row + blurRow;
int curCol = Col + blurCol;

// Verify we have a valid image pixel
if (curRow > -1 && curRow < h && curCol > -1 && curCol < w) {
pixVal += in[curRow * w + curCol];
pixels++; // Key track of number of pixels in the avg
}
}
}

// Write our new pixel value out
out[Row * w + Col] = (unsigned char)(pixVal / pixels);
}
}

上面代码中,如果计算 3 x 3 小窗口里的像素的平均值(9 个像素点的平均值),则 BLUE_SIZE = 1;如果计算 5 x 5 小窗口里的像素的平均值(25 个像素点的平均值),则 BLUE_SIZE = 2。

需要说明的是,对于角上和边上的像素,其平均值并没有计算 9 个像素点,如图 8 所示。

gpu_img_blur_edge.gif

Figure 8: 角上仅考虑了 4 个像素点的平均,边上仅考虑了 6 个像素点的平均

3.4. Barrier Synchronization(限于 block 内)

CUDA 中,可以使用函数 __syncthreads() ,让同一个 block 中的线程进行同步。也就是说,当一个线程调用 __syncthreads() 后,它会等待同一个 block 中的所有其它线程都到达 __syncthreads() 所在位置后,才往下执行。

不过,需要注意的是。一个 __syncthreads() 必须被同一个 block 中所有线程都执行,或者都不执行。假设在 if-then-else 语句的 if 和 else 分支中各有一个 __syncthreads() 语句,而同一个 block 中的有些线程执行进入了 if 分支,而另外一些线程进入了 else 分支,那么这个程序会一直等待。

这种同步机制限定在同一个 block 内,也就是说 block 之间没有任何的依赖和约束,它们可以以任意顺序执行, 这提供了 Transparent Scalability,如图 9 所示。

gpu_block_no_sync.png

Figure 9: Lack of synchronization constraints between blocks enables transparent scalability for CUDA programs.

3.5. Thread Scheduling

Thread 调度属于硬件的实现细节,了解这些实现细节有助于我们进行性能调优。

CUDA 程序一般会创建一些线程块(Block), Block 会被调度到空闲的 Streaming Multiprocessors(SM)上去。当 Block 执行完毕后,Block 会退出 SM,释放出 SM 的资源,以供其他待 Block 调度进去。

因此,无论是只有 2 个 SM 的 GPU,还是有 4 个 SM 的 GPU,这些线程块都会被调度执行,只不过 4 个 SM 的 GPU 一般会执行得更快。因此,同样的程序,可以在具有不同 SM 数量上的 GPU 运行,这称为 Automatic Scalability。如图 10 所示。

gpu_automatic_scalability.png

Figure 10: Automatic Scalability

更细节一点, 一个 block 分配给 SM 执行时,还会进一步拆分为 Warp,它是以 32 个 thread 组成的小分组(warpSize 是一个硬件的参数,它往往为 32)。Warp 是 SM 内的线程调度的最小单元。 假设,一个 block 中共有 256 个线程,则我们可以计算出这个 block 包含 256/32 = 8 个 Warp。

一个 Warp 在执行时,遵循 Single instruction, multiple threads(SIMT)模式。也就是 32 个 thread 会共享“instruction fetching”过程,并不是每个 thread 分别去“instruction fetching”,而是“instruction fetching”后,给 32 个线程都执行。这种方式可以大大减少频繁的“instruction fetching”过程。

4. Memory and Data Locality

4.1. Memory-Bound Programs

考虑节 3.3 中介绍的图片模糊 kernel 的最核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Get the average of the surrounding BLUR_SIZE x BLUE_SIZE box
for (int blurRow = -BLUR_SIZE; blurRow < BLUR_SIZE + 1; ++blurRow) {
for (int blurCol = -BLUE_SIZE; blurCol < BLUR_SIZE + 1; ++blurCol) {
int curRow = Row + blurRow;
int curCol = Col + blurCol;

// Verify we have a valid image pixel
if (curRow > -1 && curRow < h && curCol > -1 && curCol < w) {
pixVal += in[curRow * w + curCol];
pixels++; // Key track of number of pixels in the avg
}
}
}

在内层 for 循环的每次迭代中,有 1 次 Global Memory 的访问(即对 in[] 数组的访问),有 1 次浮点数的加法运算(即 pixVal += in[curRow * w + curCol] )。

我们把“浮点运算次数”和“取内存次数”的比值定义为 compute-to-globalmemory-access ratio (CGMA),对于上面例子有:
浮点运算次数访问次数CGMA=浮点运算次数Global Memory 访问次数=11=1.0

假设 Global memory 的访问速度是 1000 GB/s(即 1 TB/s),考虑单精度浮点数占用 4 个字节,那么每秒可以加载 1000/4=250 giga 浮点数,也就是说 kernel 每秒处理浮点数不会超过 250 GFLOPS。

设某 GPU 的浮点计算性能为 12 TFLOPS,那么运行上面 kernel 时,仅达到浮点计算能力峰值的 2%,没有充分地利用 GPU。像这种,执行速度的 “瓶颈位于内存访问过程”的程序被称为“memory-bound program”。

后文将介绍如何减少内存的访问次数,以提高程序执行速度。

4.2. 矩阵乘法

下面介绍矩阵 M 和 N 相乘得到结果矩阵 P。

假设每个线程仅计算结果矩阵 P 的一个元素,可以使用下面的 kernel 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__global__
void MatrixMulKernel(float* M, float* N, float* P, int Width) {
// Calculate the row index of the P element and M
int Row = blockIdx.y * blockDim.y + threadIdx.y;
// Calculate the column index of P and N
int Col = blockIdx.x * blockDim.x + threadIdx.x;
if ((Row < Width) && (Col < Width)) {
float Pvalue = 0;
// each thread computes one element of the block sub-matrix
for (int k = 0; k < Width; ++k) {
Pvalue += M[Row*Width+k] * N[k*Width+Col];
}
P[Row*Width+Col] = Pvalue;
}
}

这个 kernel 和节 3.2 介绍的彩色图片转灰度图片的 colorToGreyscaleConversion 基本类似。kernel 中 Row 和 Col 的如图 11 所示。

gpu_matrix_mul.gif

Figure 11: Row 和 Col 的计算

和彩色图片转灰度图片类似,我们也是采用 2D block。假设矩阵为 4 x 4 的,采用 2 x 2 的 block,那么 kernel 执行如 12 图所示。

gpu_matrix_example.gif

Figure 12: MatrixMulKernel 执行示意图

如果仅考虑 block(0,0) 中线程的执行,则如图 13 所示。

gpu_matrix_example_block.png

Figure 13: block(0,0) 中线程的执行

在下面最关键代码中:

1
2
3
for (int k = 0; k < Width; ++k) {
Pvalue += M[Row*Width+k] * N[k*Width+Col];
}

有两次 Global memory 的访问,一次浮点乘法和一次浮点加法。所以上一节介绍的 CGMA 值会为 1,这是一个“memory-bound program”,我们需要想办法减少内存的访问次数。

4.3. CUDA 内存类型

CUDA 设备中有不同的内存类型,可以帮助我们提高 CGMA,以提高程序性能。

CUDA 的内存类型如图 14 所示。

gpu_cuda_memory_types.gif

Figure 14: Overview of the CUDA device memory model

通过表 2 所示语法可以声明程序变量位于哪种内存中。

Variable declaration Memory Scope Lifetime
Automatic variables other than arrays Register Thread Kernel
Automatic array variables Local Thread Kernel
__device__ __shared__ int SharedVar; Shared Block Kernel
__device__ int GlobalVar; Global Grid Application
__device__ __constant__ int ConstVar; Constant Grid Application

4.4. 矩阵相乘优化(Tile 优化)

如何减少矩阵相乘时对 Global memory 的访问呢?我们先看看图 13 的情况。

block(0,0) 中的 4 个线程读取 Global memory 的情况如图 15 所示。可以发现:Global memory 中的数据被读取了多次。

gpu_matrix_block00.png

Figure 15: block(0,0) 线程读取内存的情况

如果同一个 block 中的线程仅从 Global memory 中读取输入矩阵一次,放入到 Shared memory 中,则可以减少对 Global memory 的访问,以提高程序性能。

这种优化被为 Tile 优化。下面是一个采用 Tile 优化的矩阵相乘的 kernel 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
__global__
void MatrixMulKernel(float* d_M, float* d_N, float* d_P,
int Width) {
__shared__ float Mds[TILE_WIDTH][TILE_WIDTH]; // 后面会把 d_M 数据(Global memory)先保存到Shared memory 中
__shared__ float Nds[TILE_WIDTH][TILE_WIDTH]; // 后面会把 d_M 数据(Global memory)先保存到Shared memory 中

int bx = blockIdx.x; int by = blockIdx.y;
int tx = threadIdx.x; int ty = threadIdx.y;
// Identify the row and column of the d_P element to work on
int Row = by * TILE_WIDTH + ty;
int Col = bx * TILE_WIDTH + tx;
float Pvalue = 0;
// Loop over the d_M and d_N tiles required to compute d_P element
for (int ph = 0; ph < Width/TILE_WIDTH; ++ph) {
// Collaborative loading of d_M and d_N tiles into shared memory
Mds[ty][tx] = d_M[Row*Width + ph*TILE_WIDTH + tx];
Nds[ty][tx] = d_N[(ph*TILE_WIDTH + ty)*Width + Col];
__syncthreads(); // 确保当每个线程需要的数据被不同线程加载到 Shared memory 中后,同 block 中的线程才往下执行
for (int k = 0; k < TILE_WIDTH; ++k) {
Pvalue += Mds[ty][k] * Nds[k][tx];
}
__syncthreads(); // 确保当所有线程都执行完上面的计算后,同 block 中的线程才往下执行
}
d_P[Row*Width + Col] = Pvalue;
}

5. Unified Memory

CUDA 6 中引入了 Unified Memory,不用显式地使用 cudaMemcpy 在 Host 和 Device 之间复制内存了,简化了编程步骤,如图 16 所示。

gpu_cuda_6_unified_memory.gif

Figure 16: CUDA 6 Unified Memory

摘自:Unified Memory in CUDA 6

6. 并行计算模式

在《Programming Massively Parallel Processors, 3rd, 2017》一书介绍了一些并行计算模式,如:Convolution、Prefix Sum、Histogram、Sparse Matrix Computation、Merge Sort、Graph Search。

这里不介绍它们,有兴趣的读者可以参考原著。

7. Compute Capability

CUDA 的计算能力 Compute Capability 可以认为是硬件的版本。表 3 列出了不同 Compute Capability 下的一些产品型号。

Compute Capability Micro-architecture GeForce(消费级) Quadro(专业级) Tesla(数据中心) Jetson(嵌入式)
1.0 Tesla GeForce 8800 GTX Quadro FX 5600
2.0 Fermi GeForce GTX 590 Quadro Plex 7000
3.0 Kepler GeForce GTX 770 Quadro K5000 Tesla K10
3.2 Kepler
3.5 Kepler GeForce GTX TITAN Z Quadro K6000 Tesla K40, Tesla K20
3.7 Kepler Tesla K80
5.0 Maxwell GeForce GTX 750 Quadro K1200
5.2 Maxwell GeForce GTX TITAN X Quadro M5000 Tesla M60, Tesla M40
5.3 Maxwell Jetson TX1, Tegra X1
6.0 Pascal Quadro GP100 Tesla P100
6.1 Pascal GeForce GTX 1080 Quadro P6000 Tesla P40, Tesla P4
6.2 Pascal Jetson TX2
7.0 Volta NVIDIA TITAN V Quadro GV100 Tesla V100
7.2 Volta Jetson AGX Xavier
7.5 Turing Geforce RTX 2080 Quadro RTX 8000 Tesla T4
8.0 Ampere

不同 Compute Capability 的区别可以参考:https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#compute-capabilities

8. 开发工具

8.1. nvcc

nvcc 是 CUDA 编程器,在节 2.1 中介绍了它的基本用法。

8.2. nvprof

nvprof 是对 CUDA 程序进行性能瓶颈分析的工具。

下面是使用 nvprof 对矩阵相乘 CUDA 程序进行分析的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
$ nvprof matrixMul
[Matrix Multiply Using CUDA] - Starting...
==27694== NVPROF is profiling process 27694, command: matrixMul
GPU Device 0: "GeForce GT 640M LE" with compute capability 3.0

MatrixA(320,320), MatrixB(640,320)
Computing result using CUDA Kernel...
done
Performance= 35.35 GFlop/s, Time= 3.708 msec, Size= 131072000 Ops, WorkgroupSize= 1024 threads/block
Checking computed result for correctness: OK

Note: For peak performance, please refer to the matrixMulCUBLAS example.
==27694== Profiling application: matrixMul
==27694== Profiling result:
Time(%) Time Calls Avg Min Max Name
99.94% 1.11524s 301 3.7051ms 3.6928ms 3.7174ms void matrixMulCUDA<int=32>(float*, float*, float*, int, int)
0.04% 406.30us 2 203.15us 136.13us 270.18us [CUDA memcpy HtoD]
0.02% 248.29us 1 248.29us 248.29us 248.29us [CUDA memcpy DtoH]

==27964== API calls:
Time(%) Time Calls Avg Min Max Name
49.81% 285.17ms 3 95.055ms 153.32us 284.86ms cudaMalloc
25.95% 148.57ms 1 148.57ms 148.57ms 148.57ms cudaEventSynchronize
22.23% 127.28ms 1 127.28ms 127.28ms 127.28ms cudaDeviceReset
1.33% 7.6314ms 301 25.353us 23.551us 143.98us cudaLaunch
0.25% 1.4343ms 3 478.09us 155.84us 984.38us cudaMemcpy
0.11% 601.45us 1 601.45us 601.45us 601.45us cudaDeviceSynchronize
0.10% 564.48us 1505 375ns 313ns 3.6790us cudaSetupArgument
0.09% 490.44us 76 6.4530us 307ns 221.93us cuDeviceGetAttribute
0.07% 406.61us 3 135.54us 115.07us 169.99us cudaFree
0.02% 143.00us 301 475ns 431ns 2.4370us cudaConfigureCall
0.01% 42.321us 1 42.321us 42.321us 42.321us cuDeviceTotalMem
0.01% 33.655us 1 33.655us 33.655us 33.655us cudaGetDeviceProperties
0.01% 31.900us 1 31.900us 31.900us 31.900us cuDeviceGetName
0.00% 21.874us 2 10.937us 8.5850us 13.289us cudaEventRecord
0.00% 16.513us 2 8.2560us 2.6240us 13.889us cudaEventCreate
0.00% 13.091us 1 13.091us 13.091us 13.091us cudaEventElapsedTime
0.00% 8.1410us 1 8.1410us 8.1410us 8.1410us cudaGetDevice
0.00% 2.6290us 2 1.3140us 509ns 2.1200us cuDeviceGetCount
0.00% 1.9970us 2 998ns 520ns 1.4770us cuDeviceGet

8.3. nvidia-smi

nvidia-smi (NVIDIA System Management Interface) 是管理 NVIDIA GPU 设备的命令行工具。可以监控 GPU 使用情况以及更改 GPU 状态。

下面是 nvidia-smi 的运行例子,输出中 GPU-Util 为 100% 表示 GPU 正在满负载工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ nvidia-smi
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 418.67 Driver Version: 418.67 CUDA Version: 10.1 |
|-------------------------------+----------------------+----------------------+
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
|===============================+======================+======================|
| 0 Tesla P4 On | 00000000:00:08.0 Off | 0 |
| N/A 59C P0 47W / 75W | 1399MiB / 7611MiB | 100% Default |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes: GPU Memory |
| GPU PID Type Process name Usage |
|=============================================================================|
| 0 22589 C ./test 1389MiB |
+-----------------------------------------------------------------------------+

9. 参考

本文主要考虑

什么是rocm?

Radeon Open Computing platform 全套驱动程序,开发工具,API和AMD GPU监控工具的集合。用来支持AMD的GPU以及其他现有的加速器。

CUDA到HIP转码

CUDA与HIP

CUDA是NVIDIA开发的GPU SDK(软件开发框架),主要针对NVIDIA GPU硬件开发,而HIP是AMD开发的GPU SDK,主要是针对AMD GPU硬件开发,同时兼容NVIDIA GPU硬件上的开发。试想AMD为何会如此雄心壮志?其实是无奈之举。显然当今CUDA的生态处于绝对优势(dominant),AMD要想迎头赶上,必须兼容CUDA。如何实现兼容CUDA?答案就是利用HIP。

HIP(Heterogeneous-Computing Interface for Portability)实际上就是构造异构计算的接口,一方面对接AMD HCC(Heterogeneous Compute Compiler),另一方面对接CUDA NVCC。HIP位于HCC和NVCC的上层(或者说在HC和CUDA的上层),HIP的API接口与CUDA API接口类似,但不完全相同。CUDA代码需要通过转码改写为HIP形式才可以在AMD GPU上编译运行,AMD编译环境称为ROCm(Radeon Open Compute Platform),早期使用HCC/HC模式,而今主要发展基于Clang和LLVM开发的编译器,实际上命令行在Clang模式下,hcc就是alias到clang命令。我们都知道Clang+LLVM是一个开源的编译器框架,除了支持C/C++编译,也支持CUDA的编译。AMD将Clang+LLVM进行扩展形成HIP的底层编译器,以支持AMD GPU编译。实际上在ROCm环境,HIP有三种平台模式(通过环境变量HIP_PLATFORM区别):clang、hcc和nvcc。而HIP提供的hipcc命令,实质是一个perl脚本,通过HIP_PLATFORM等环境变量,调用不同的底层编译器,实现统一编译模式。

HIP转码的实现

如果你留意,可以发现ROCm的HIP项目中提供了一个hipify-clang的工具。这个hipify-clang工具是基于Clang编译器的抽象语法树重构引擎机制,实现CUDA到HIP的API函数名和type名的重命名和include头文件名的替换(详见下一节分析),理论上是最可靠的一种代码转换方式。因为字面意思的文本转换难以区分API语义,如分别函数名还是参数名。

hipify-clang从根本上可以解决CUDA到HIP的转码,但不等于说没有困难,困难在于CUDA的版本很多,各版本之间也有不兼容的API问题,而且CUDA少量函数或变量名,在HIP底层并没有实现对应体。

但总的来说,AMD的伙计们还是很给力,不断在更新hipify-clang,也支持最新CUDA 10.1的API转换。基于hipify-clang工具还可以生成perl转码的map文件或python转码的map文件,这里的map文件实质就是转码函数或变量名的映射代码行。一般hipify-clang是随着ROCm环境一起安装的,没法及时更新。导致hipify-clang的新功能没法应用。

HIP项目的bin目录中提供了一个名为hipify-perl的可执行的脚本,借助perl语言定义了CUDA到HIP转码的主体框架以及转换名称的map内容,这个map内容实际上是由hipify-clang工具生成。更新了hipify-clang工具,也应该更新hipify-perl脚本。但hipify-clang工具需要Clang+LLVM的SDK环境,这是一个较复杂的开发软件环境,一般用户难以驾驭,导致编译hipify-clang有困难。不过,本项目中直接提供了最新的hipify-perl脚本。

hipify-clang代码简介

hipify-clang作为HIP的一个子模块而存在,官方代码文件见 https://github.com/ROCm-Developer-Tools/HIP/tree/master/hipify-clang ,理解其需要一些Clang和LLVM知识背景。相关代码文件简介如下:

  • main.cpp 入口函数main的定义文件。
    首先完成命令行参数解析,支持Perl和Python的map导出(见其中的generatePerl和generatePython两个函数),对每个输入待转码的文件,会创建RefactoringTool和actionFactory对象,并填充相应的Clang RefactoringTool的工作参数,最终构建出Clang refactoring的基本框架,核心在于执行Tool.runAndSave(&actionFactory)启动整个重构的工作流程,其中会调用重载的HipifyAction类中定义的转码函数。

  • ArgParse.cpp/.h 定义命令行参数的解析。
    在main函数中被调用。

  • ReplacementsFrontendActionFactory.h 定义一个基于clang::tooling::FrontendActionFactory的工厂类。
    main中实例化为对象actionFactory,供Tool.runAndSave函数调用。

  • LLVMCompat.cpp/.h 新建了命令空间llcompat和定义版本兼容函数。
    其中定义兼容不同版本的各类函数,包括SourceLocation的begin和end定位函数、getReplacements函数、insertReplacement函数和EnterPreprocessorTokenStream函数等等。

  • CUDA2HIP.cpp/.h 定义转码映射关系对象。
    定义了两个std::map<llvm::StringRef, hipCounter>类型的数据对象CUDA_RENAMES_MAP和CUDA_INCLUDE_MAP。在CUDA到HIP转码时,函数名和type名的转码映射关系定义在CUDA_RENAMES_MAP中,它们又由CUDA2HIP_XXX_API_functions.cpp和CUDA2HIP_XXX_API_types.cpp中定义的子类map组合而来。
    头文件名替换映射关系定义在CUDA_INCLUDE_MAP中。

  • HipifyAction.cpp/.h 定义了HipifyAction类。
    HipifyAction类继承了clang::ASTFrontendActionclang::ast_matchers::MatchFinder::MatchCallback接口,实现基于Clang前端解析重命名机制的行为。这里是实现转码的重心之处。函数名和type名转码的重命名操作在RewriteToken函数中完成。HipifyAction的关键函数体结构为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void HipifyAction::ExecuteAction() { //重载ASTFrontendAction的接口函数
while (RawTok.isNot(clang::tok::eof)) {
RewriteToken(RawTok); //调用自定义函数,执行CUDA_RENAMES_MAP替换。
RawLex.LexFromRawLexer(RawTok);
}
// Register yourself as the preprocessor callback, by proxy.
// 自定义预处理阶段的回调函数,跳转调用hipifyAction的InclusionDirective和PragmaDirective函数
// InclusionDirective函数完成CUDA_INCLUDE_MAP替换。
PP.addPPCallbacks(std::unique_ptr<PPCallbackProxy>(new PPCallbackProxy(*this)));
// Now we're done futzing with the lexer, have the subclass proceeed with Sema and AST matching.
clang::ASTFrontendAction::ExecuteAction();//完成基类的操作
}

void HipifyAction::run(const clang::ast_matchers::MatchFinder::MatchResult& Result) {//重载MatchCallback的接口函数
if (cudaLaunchKernel(Result)) return; //调用自定义函数
if (cudaSharedIncompleteArrayVar(Result)) return;//调用自定义函数
}

其中cudaLaunchKernel实现CUDA kernel<<<*>>> 函数的替换。cudaSharedIncompleteArrayVar实现 CUDA __shared__变量定义的重构,即添加HIP_DYNAMIC_SHARED宏包装。

  • Statistics.cpp/.h 定义转码统计类,按子类型计数,便于最后输出统计结果。
  • StringUitils.cpp/.h 定义String辅助操作的类。

另外在HIP项目的tests目录,有hipify-clang的单元测试文件,可以作为hipify-clang和hipify-perl的测试输入文件。如

  • tests/hipify-clang/unit_tests/headers/headers_test_10.cu
  • tests/hipify-clang/unit_tests/headers/headers_test_11.cu
  • tests/hipify-clang/unit_tests/libraries/cuRAND/poisson_api_example.cu

hipify-perl程序简介

hipify-perl是HIP项目提供的一个CUDA到HIP转码的perl脚本,官方代码文件见 https://github.com/ROCm-Developer-Tools/HIP/blob/master/bin/hipify-perl ,本质上是基于文本字符串替换方式进行CUDA到HIP转码的关键字替换,包括类型名和函数名等替换。hipify-perl中的关键字替换的map可以从hipify-clang导出,hipify-perl提供了一个转码的框架。

使用说明

本项目中,主要文件简介:

  • hipify-perl
    基于hipify-clang最新map内容的版本
  • hipify-cmakefile
    处理cmake文件(如CMakeList.txt)转码的脚本
  • cuda2hip.sh
    调用hipify-perl实现文件夹的转码
  • cuda2hip.sed
    供sed调用的脚本文件,补充hipify-perl没有实现的关键字转码
  • cuda2hipsed.sh
    调用hipify-perl和sed脚本实现文件夹的转码

CUDA到HIP转码通常基于hipify-clang或hipify-perl。

  • 直接使用hipify-clang进行代码转换,理论上hipify-clang是最准确的转码方式,但是它基于编译过程,对软件编译头文件有强烈依赖,容易导致编译过程中断,对转码产生一定影响。
  • 还有一种折中的办法,是使用hipify-clang的输出map更新hipify-perl脚本。先用hipify-perl脚本进行主体转换,再用cuda2hip.sed脚本补充转换。应用这两个脚本转换之后,转码成功率相对高些。

hipify-clang

1
2
./hipify-clang --help
./hipify-clang --cuda-path=/usr/local/cuda-10.0 -I /usr/local/cuda-10.0/samples/common/inc lib/*.cu

hipify-clang是基于Clang+LLVM SDK编译的二进制可执行文件。需要在Clang+LLVM的环境下编译获得,这个环境可以是LLVM官方版本,也可以是ROCm下LLVM分支版本(主要使用Clang前端API区别不大)。这里的CUDA头文件版本,需要与编译Clang时的一致,-I指定编译过程中搜索的include头文件目录,可能需要指定多个路径,便于hipify-clang对代码的扫描-编译-转码过程顺利通过。

hipify-perl

1
./hipify-perl <file>

<file>为待转换的CUDA代码文件名。程序在转码之后会检验代码是否还包含cuda、cublas和curand等字眼,如果存在则给出警告(warning)提示,这些警告需要我们确认是否需要转码。

cuda2hip.sh

1
./cuda2hip.sh <dir>

调用hipify-perl脚本进行文件夹内所有代码转换。默认通配*.c**.h**.inl文件(下同)。
<dir>为待转换的CUDA代码所在目录名,可以使用空格隔空,输入多个文件目录名。

cuda2hip.sed

  • 第一种使用方式

    1
    ./cuda2hip.sed <files>

    <files>为待转换的CUDA代码文件名,可使用Shell通配符。
    结果输出到标准输出端。

  • 第二种使用方式

    1
    sed -i -f cuda2hip.sed <files>

    <files>为待转换的CUDA代码文件名,可使用Shell通配符。-i表示in-place替换。

  • 第三种使用方式

    1
    find . -type f -name *.c* -o -name *.h* -o -name *.inl |xargs sed -i -f cuda2hip.sed

    这里借助find查找C/C++和CUDA代码文件,对每个查找到的文件调用cuda2hip.sed进行转码。

cuda2hipsed.sh

1
./cuda2hipsed.sh <dir>

调用hipify-perl和cuda2hip.sed脚本进行文件夹内所有代码转换。默认通配*.c**.h**.inl文件。<dir>为待转换的CUDA代码所在目录名,可以使用空格输入多个文件目录。

Getting Started with HIP API

HIP API Overview

HIP API包括hipMalloc、hipMemcpy和hipFree等函数。熟悉CUDA的程序员也将能够快速学习并开始使用HIPAPI进行编码。计算内核通过“hipLaunchKernel”宏调用启动。

HIP API Examples

Example 1

下面是一个显示HIP API代码片段的示例:

1
2
3
4
5
6
7
8
9
hipMalloc(&A_d, Nbytes));
hipMalloc(&C_d, Nbytes));
hipMemcpy(A_d, A_h, Nbytes, hipMemcpyHostToDevice);
const unsigned blocks = 512;
const unsigned threadsPerBlock = 256;
hipLaunchKernel(vector_square, /* compute kernel*/
dim3(blocks), dim3(threadsPerBlock), 0/*dynamic shared*/, 0/*stream*/, /*launch config*/
C_d, A_d, N); /* arguments to the compute kernel */
hipMemcpy(C_h, C_d, Nbytes, hipMemcpyDeviceToHost);

HIP内核语言定义了用于确定网格和块坐标、数学函数、短向量、原子和计时器函数的内置函数。它还为函数类型、地址空间和优化控件指定了其他定义和关键字。有关详细说明。

Example 2

下面是一个定义简单“vector_square”内核的示例。

1
2
3
4
5
6
7
8
9
10
template <typename T>
__global__ void
vector_square(T *C_d, const T *A_d, size_t N)
{
size_t offset = (blockIdx.x * blockDim.x + threadIdx.x);
size_t stride = blockDim.x * gridDim.x;
for (size_t i=offset; i<N; i+=stride) {
C_d[i] = A_d[i] * A_d[i];
}
}

HIP运行时API代码和计算内核定义可以存在于同一源文件中——HIP负责适当地生成主机和设备代码。

Introduction to Memory Allocation

Host Memory

hipHostMalloc分配被映射到系统中所有GPU的地址空间的固定主机内存。此主机内存有两种使用情况:

  • 更快的HostToDevice和DeviceToHost数据传输:运行时跟踪hipHostMalloc分配,可以避免常规未固定内存所需的某些设置。要在特定系统上进行精确测量,请尝试使用hipBusBandwidth工具的—unpinted和—pinted开关。
  • 零拷贝GPU访问:GPU可以通过CPU/GPU互连直接访问主机内存,无需复制数据。这避免了复制的需要,但在内核访问期间,每次内存访问都必须遍历互连,这可能比访问GPU的本地设备内存慢几十倍。当内存访问不频繁(可能只有一次)时,零拷贝内存可能是一个不错的选择。零拷贝内存通常是“一致”的,因此不会被GPU缓存,但如果需要,这可以被覆盖。

Memory allocation flags

hipHostMalloc始终设置hipHostMalocPortable和hipHostMallocMapped标志。上述两种使用模型使用相同的分配标志,不同之处在于周围代码如何使用主机内存。

hipHostMallocNumaUser是允许主机内存分配遵循用户设置的NUMA策略的标志。

NUMA-aware host memory allocation

非统一内存体系结构(NUMA)策略确定如何分配内存,并选择最接近每个GPU的CPU。

NUMA还测量GPU和CPU设备之间的距离。默认情况下,每个GPU选择一个Numa CPU节点,该节点之间的Numa距离最小;主机存储器被自动分配为最接近当前GPU设备的NUMA节点的存储器池。

注意,使用不同GPU的hipSetDevice API可以访问主机分配。然而,它可能具有更长的NUMA距离。

Managed memory allocation

HIP现在支持并自动管理异构内存管理(HMM)分配。HIP应用程序在进行托管内存API调用hipMallocManaged之前执行功能检查。

例如

1
2
3
4
5
6
7
8
9
10
int managed_memory = 0;
HIPCHECK(hipDeviceGetAttribute(&managed_memory, hipDeviceAttributeManagedMemory,p_gpuDevice));
if (!managed_memory )` | {
printf ("info: managed memory access not supported on the device %d\n Skipped\n", p_gpuDevice);
}
else {
HIPCHECK(hipSetDevice(p_gpuDevice));
HIPCHECK(hipMallocManaged(&Hmm, N * sizeof(T)));
. . .
}

HIP Stream Memory Operations

HIP支持流内存操作,以实现网络节点和GPU之间的直接同步。添加了以下API:

  • hipStreamWaitValue32
  • hipStreamWaitValue64
  • hipStreamWriteValue32
  • hipStreamWriteValue64

Coherency Controls

ROCm为主机内存定义了两个一致性选项:

  • 一致性内存:支持内核运行时的细粒度同步。例如,内核可以执行主机CPU或其他(对等)GPU可见的原子操作。同步指令包括threadfence_system和C++11风格的原子操作。然而,一致性存储器不能被GPU缓存,因此可能具有较低的性能。
  • 非一致性内存:可由GPU缓存,但无法在内核运行时支持同步。非一致性内存可以选择性地仅在命令(内核结束或复制命令)边界处同步。当不需要细粒度同步时,此内存适用于高性能访问。

HIP为开发人员提供控件,通过传递给hipHostMalloc的分配标志和HIP_HOST_COHERENT环境变量来选择使用哪种类型的内存。默认情况下,环境变量HIP_HOST_CONTENT在HIP中设置为0。HIP当前版本中的控制逻辑如下:

  • 没有传递任何标志:主机内存分配是一致的,HIP_host_coherent环境变量被忽略。
  • hipHostMallocCoherent=1:主机内存分配将是一致的,HIP_host_coherent环境变量将被忽略。
  • hipHostMallocMapped=1:主机内存分配将是一致的,HIP_host_CONTENT环境变量将被忽略。
  • hipHostMallocNonCoherent=1,hipHostMalocCoherent=0,hipHostMallocMapped=0:主机内存将是非一致的,HIP_host_CONTENT环境变量被忽略。
  • hipHostMallocCoherent=0,hipHostMalocNonCoherent=0,hipHostMallocMapped=0,但设置了其他HostMalloc标志之一:
    • 如果HIP_HOST_COHERENT定义为1,则主机内存分配是一致的。
    • 如果未定义HIP_HOST_COHERENT,或定义为0,则主机内存分配是非一致的。
    • hipHostMallocCoherent=1,hipHostMalocNonCoherent=1:非法。

Visibility of Zero-Copy Host Memory

​ 下表描述了一致和非一致主机内存可见性。注意,一致主机内存在同步点自动可见。

HIP API Synchronization Effect Fence Coherent Host Memory Visibility Non-Coherent Host Memory Visibility
hipStreamSynchronize 主机等待指定流中的所有命令完成 system-scope release yes yes
hipDeviceSynchronize 主机等待指定设备上所有流中的所有命令完成 system-scope release yes yes
hipEventSynchronize 主机等待指定的事件完成 device-scope release yes depends - see the description below
hipStreamWaitEvent 流等待指定的事件完成 none yes no

hipEventSynchronize

开发人员可以控制hipEvents的发布范围。默认情况下,GPU对每个记录的事件执行设备范围获取和释放操作。这将使主机和设备内存对在同一设备上执行的其他命令可见。

当使用hipEventCreateWithFlags创建事件时,可以指定更强的系统级围栏。

  • hipEventReleaseToSystem:在记录事件时执行系统范围释放操作。这将使一致和非一致主机内存对系统中的其他代理可见,但可能涉及诸如缓存刷新之类的重量级操作。一致内存通常在内核同步机制中使用较轻的权重,例如原子操作,因此不需要使用hipEventReleaseToSystem。
  • hipEventDisableTiming:使用此标志创建的事件不会记录分析数据,因此,如果用于同步,将提供最佳性能。

注意:对于使用hipExtLaunchKernelGGL/hipExtLaunchKernel的内核调度中的HIP事件,API中传递的事件不会被显式记录,只能用于获取特定启动的经过时间。

例如,如果在多个分派中使用事件,来自不同hipExtLaunchKernelGGL/hipExtLaunchKernel调用的开始和停止事件将被视为无效的未记录事件,并且HIP显示来自hipEventElapsedTime的错误“hipErrorInvalidHandle”。

一致主机内存是默认的,也是最容易使用的,因为CPU在特定的同步点可以看到内存。该内存允许内核内同步命令(如threadfence_system)透明地工作。HIP/ROCm还支持GPU中使用“非一致”主机内存分配的缓存主机内存。这可以提高性能,但必须注意使用正确的同步。

Direct Dispatch

默认情况下,直接调度在HIP运行时启用。利用这一特性,传统的生产者-消费者模型不再适用,其中运行时为每个HIP流创建一个工作线程(消费者),而主机线程(生产者)将命令排入命令队列(每个流)。

对于直接调度,在调度和某些同步的情况下,运行时将直接将数据包排队到AQL队列(用户模式队列到GPU)。这显示了HIP调度API的总延迟和在GPU上启动第一波的延迟。

此外,随着线程调度延迟和原子/锁同步延迟的减少,在运行时消除线程减少了分派数量的差异。

可以通过设置以下环境变量AMD_DIRECT_DISPATCH=0禁用此功能

HIP Runtime Compilation

HIP支持运行时编译(hipRTC),与其他API相比,通过常规离线静态编译,hipRTC的使用将提供优化和性能改进的可能性。

hipRTC API接受字符串格式的HIP源文件作为输入参数,并通过编译HIP源代码文件来创建程序句柄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
#include <test_common.h>

#include <hip/hiprtc.h>
#include <hip/hip_runtime.h>

#include <cassert>
#include <cstddef>
#include <memory>
#include <iostream>
#include <iterator>
#include <vector>

static constexpr auto NUM_THREADS{128};
static constexpr auto NUM_BLOCKS{32};

static constexpr auto saxpy{
R"(
#include "test_header.h"
#include "test_header1.h"
extern "C"
__global__
void saxpy(real a, realptr x, realptr y, realptr out, size_t n)
{
size_t tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid < n) {
out[tid] = a * x[tid] + y[tid] ;
}
}
)"};

int main()
{
using namespace std;

hiprtcProgram prog;
int num_headers = 2;
std::vector<const char*> header_names;
std::vector<const char*> header_sources;
header_names.push_back("test_header.h");
header_names.push_back("test_header1.h");
header_sources.push_back("#ifndef HIPRTC_TEST_HEADER_H\n#define HIPRTC_TEST_HEADER_H\ntypedef float real;\n#endif //HIPRTC_TEST_HEADER_H\n");
header_sources.push_back("#ifndef HIPRTC_TEST_HEADER1_H\n#define HIPRTC_TEST_HEADER1_H\ntypedef float* realptr;\n#endif //HIPRTC_TEST_HEADER1_H\n");
hiprtcCreateProgram(&prog, // prog
saxpy, // buffer
"saxpy.cu", // name
num_headers, // numHeaders
&header_sources[0], // headers
&header_names[0]); // includeNames

hipDeviceProp_t props;
int device = 0;
hipGetDeviceProperties(&props, device);
std::string sarg = std::string("--gpu-architecture=") + props.gcnArchName;
const char* options[] = {
sarg.c_str()
};

hiprtcResult compileResult{hiprtcCompileProgram(prog, 1, options)};

size_t logSize;
hiprtcGetProgramLogSize(prog, &logSize);

if (logSize) {
string log(logSize, '\0');
hiprtcGetProgramLog(prog, &log[0]);

cout << log << '\n';
}

if (compileResult != HIPRTC_SUCCESS) { failed("Compilation failed."); }

size_t codeSize;
hiprtcGetCodeSize(prog, &codeSize);

vector<char> code(codeSize);
hiprtcGetCode(prog, code.data());

hiprtcDestroyProgram(&prog);

hipModule_t module;
hipFunction_t kernel;
hipModuleLoadData(&module, code.data());
hipModuleGetFunction(&kernel, module, "saxpy");

size_t n = NUM_THREADS * NUM_BLOCKS;
size_t bufferSize = n * sizeof(float);

float a = 5.1f;
unique_ptr<float[]> hX{new float[n]};
unique_ptr<float[]> hY{new float[n]};
unique_ptr<float[]> hOut{new float[n]};

for (size_t i = 0; i < n; ++i) {
hX[i] = static_cast<float>(i);
hY[i] = static_cast<float>(i * 2);
}

hipDeviceptr_t dX, dY, dOut;
hipMalloc(&dX, bufferSize);
hipMalloc(&dY, bufferSize);
hipMalloc(&dOut, bufferSize);
hipMemcpyHtoD(dX, hX.get(), bufferSize);
hipMemcpyHtoD(dY, hY.get(), bufferSize);

struct {
float a_;
hipDeviceptr_t b_;
hipDeviceptr_t c_;
hipDeviceptr_t d_;
size_t e_;
} args{a, dX, dY, dOut, n};

auto size = sizeof(args);
void* config[] = {HIP_LAUNCH_PARAM_BUFFER_POINTER, &args,
HIP_LAUNCH_PARAM_BUFFER_SIZE, &size,
HIP_LAUNCH_PARAM_END};

hipModuleLaunchKernel(kernel, NUM_BLOCKS, 1, 1, NUM_THREADS, 1, 1,
0, nullptr, nullptr, config);
hipMemcpyDtoH(hOut.get(), dOut, bufferSize);

for (size_t i = 0; i < n; ++i) {
if (fabs(a * hX[i] + hY[i] - hOut[i]) > fabs(hOut[i])* 1e-6) { failed("Validation failed."); }
}

hipFree(dX);
hipFree(dY);
hipFree(dOut);

hipModuleUnload(module);

passed();
}

该示例显示了如何使用运行时编译机制对HIP应用程序进行编程。

Use of Long Double Type

在HIP-Clang中,长双精度类型是x86_64的80位扩展精度格式,AMD GPU不支持这种格式。HIP-Clang将长双类型视为AMD GPU的IEEE双类型。只要长双类型的数据不在主机和设备之间传输,在HIP源代码中使用长双类型不会导致问题。但是,长双精度类型不应用作内核参数类型。

FMA and Contractions

默认情况下,HIP Clang假设-ffp-contract=fast。对于x86_64,FMA默认关闭,因为通用x86_64目标默认不支持FMA。要在x86_64上打开FMA,请在CPU支持的FMA上使用-mfma或-march=native。当启用收缩且CPU未启用FMA指令时,GPU可以为可收缩的表达式生成与CPU不同的数值结果。

Use of _Float16 Type

如果在x86_64的Clang(或hipcc)和gcc之间使用宿主函数,则其定义由一个编译器编译,但由不同的编译器编译调用方,_Float16或包含Float16的聚合不能用作函数参数或返回类型。这是因为x86_64上的_Float16缺少稳定的ABI。在clang和gcc之间传递_Float16或包含_Float6的聚合可能会导致未定义的行为。

Math Functions with Special Rounding Modes

HIP不支持舍入模式为ru(向上舍入)、rd(向下舍入)和rz(向零舍入)的数学函数。HIP仅支持舍入模式为rn(舍入到最近值)的数学函数。带有后缀_ru_rd_rz的数学函数的实现方式与带有后缀_rn的数学函数相同。它们是一种变通方法,可以让程序使用它们进行编译。

Creating Static Libraries

HIP Clang支持生成两种类型的静态库。

  • 第一类静态库不导出设备功能,仅导出和启动同一库中的主机功能。这种类型的优点是能够与非hipcc编译器(如gcc)链接。
  • 第二种类型导出设备功能,以便由其他代码对象链接。然而,这需要使用hipcc作为链接器。此外,第一类库包含主机对象,其中设备代码嵌入为胖二进制文件。它是使用标志—emit-static lib生成的。第二类库包含可重定位的设备对象,并使用ar生成。

以下是创建和使用静态库的示例:

Type 1 using —emit-static-lib:

1
2
hipcc hipOptLibrary.cpp --emit-static-lib -fPIC -o libHipOptLibrary.a
gcc test.cpp -L. -lhipOptLibrary -L/path/to/hip/lib -lamdhip64 -o test.out

Type 2 using system ar:

1
2
3
hipcc hipDevice.cpp -c -fgpu-rdc -o hipDevice.o
ar rcsD libHipDevice.a hipDevice.o
hipcc libHipDevice.a test.cpp -fgpu-rdc -o test.out

HIP Kernel Language

HIP提供了一种C++语法,适用于编译通常出现在计算内核中的大多数代码,包括类、名称空间、运算符重载、模板等。此外,它还定义了专门针对加速器设计的其他语言功能,例如以下内容:

  • 使用标准C++的内核启动语法,类似于函数调用,可移植到所有HIP目标
  • 可用于主机或设备的短矢量标头
  • 类似于标准C++编译器中包含的“Math.h”标头中的数学函数
  • 用于访问特定GPU硬件功能的内置功能

本节描述了可以从HIP内核访问的内置变量和函数。它面向熟悉CUDA内核语法并希望了解HIP的不同之处的读者。

Function-Type Qualifiers

__device__:在设备上运行,只被设备调用。

__global__:在设备上执行,从主机调用。必须是void返回类型。

__host__:在主机上调用并执行。__host__可以与__device__组合,在这种情况下,函数同时为主机和设备编译。这些函数不能使用HIP网格坐标函数。例如,“threadIdx_x”。一种可能的解决方法是将必要的坐标信息作为参数传递给函数。__host__不能与__global__组合。

HIP解析__noinline____forceinline__关键字,并将它们转换为相应的Clang属性。

Calling global Functions

__global__函数通常称为内核,调用一个函数称为启动内核。这些函数要求调用者指定包含网格和块维度的“执行配置”。执行配置还可以包括用于启动的其他信息,例如要分配的额外共享内存量以及内核应该执行的流。HIP除了Cuda<<<>>>语法之外,还引入了一个标准的C++调用约定,将执行配置传递给内核。

  • 在HIP中,内核使用<<<>>>语法或“hipLaunchKernel”函数启动。
  • hipLaunchKernel的前五个参数如下:
    • symbol kernelName:要启动的内核的名称。要支持包含“,”的模板内核,请使用HIP_KERNEL_NAME宏。hipify工具自动地插入这个宏
    • dim3 gridDim:指定要启动的块数的三维网格尺寸。
    • dim3 blockDim:指定每个块中线程数的3D块尺寸。
    • size_t dynamicShared:启动内核时要分配的额外共享内存量(请参阅shared)
    • hipStream_t:内核应该执行的流。值0对应于NULL流(请参阅同步函数)。
  • 内核参数必须遵循五个参数
1
2
3
4
5
6
7
8
// Example pseudo code introducing hipLaunchKernel:
__global__ MyKernel(hipLaunchParm lp, float *A, float *B, float *C, size_t N)
{
...
}
MyKernel<<<dim3(gridDim), dim3(groupDim), 0, 0>>> (a,b,c,n);
// Alternatively, kernel can be launched by
// hipLaunchKernel(MyKernel, dim3(gridDim), dim3(groupDim), 0/*dynamicShared*/, 0/*stream), a, b, c, n);

hipLaunchKernel宏始终以上面指定的五个参数开头,后跟内核参数。HIPIFY工具可以选择将CUDA启动语法转换为hipLaunchKernel,包括将<<<>>>中的可选参数转换为五个所需的hipLaunchKer参数。dim3构造函数接受零到三个参数,默认情况下将未指定的维度初始化为1。见dim3。内核使用坐标内置(线程、块、网格)来确定当前正在执行的工作项的坐标索引和坐标边界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Example showing device function, __device__ __host__
// <- compile for both device and host
float PlusOne(float x)
{
return x + 1.0;
}
__global__
void MyKernel (const float *a, const float *b, float *c, unsigned N)
{
unsigned gid = threadIdx.x; // <- coordinate index function
if (gid < N) {
c[gid] = a[gid] + PlusOne(b[gid]);
}
}
void callMyKernel()
{
float *a, *b, *c; // initialization not shown...
unsigned N = 1000000;
const unsigned blockSize = 256;
MyKernel<<<dim3(gridDim), dim3(groupDim), 0, 0>>> (a,b,c,n);
// Alternatively, kernel can be launched by
// hipLaunchKernel(MyKernel, dim3(N/blockSize), dim3(blockSize), 0, 0, a,b,c,N);
}

Variable-Type Qualifiers

constant

目前支持__constant__关键字,主机在启动内核之前先写常量内存,在内核运行时这块内存对GPU而言是只读的。获取常量内存的函数主要有hipGetSymbolAddress(), hipGetSymbolSize(),
hipMemcpyToSymbol(), hipMemcpyToSymbolAsync(), hipMemcpyFromSymbol(),
hipMemcpyFromSymbolAsync()。

shared

extern __shared__允许主机动态分配共享内存,并指定为启动参数。

以前,为了准确起见,必须使用HIP_dynamic_shared宏声明动态共享内存,因为在同一内核中使用静态共享内存可能会导致内存范围重叠和数据竞争。

现在,HIPClang编译器支持外部共享声明,不再需要HIP_DYNAMIC_shared选项。

managed

HIP组合主机/设备编译中支持托管内存(__managed__关键字除外)。这个关键字的支持正在开发。

restrict

__restrict__关键字告诉编译器,关联的内存指针不会与内核或函数中的任何其他指针别名。此功能可以帮助编译器生成更好的代码。

在大多数情况下,所有指针参数都必须使用此关键字来实现好处。

Built-In Variables

Coordinate Built-Ins

这些内建的变量表明了运行中的grid的工作线程坐标。在hip_runtime.h中定义,而不是被编译器隐式定义。

HIP Syntax CUDA Syntax
threadIdx.x threadIdx.x
threadIdx.y threadIdx.y
threadIdx.z threadIdx.z
blockIdx.x blockIdx.x
blockIdx.y blockIdx.y
blockIdx.z blockIdx.z
blockDim.x blockDim.x
blockDim.y blockDim.y
blockDim.z blockDim.z
gridDim.x gridDim.x
gridDim.y gridDim.y
gridDim.z gridDim.z

warpSize

warpSize变量的类型为int,包含目标设备的warp大小(以线程为单位)。

注意,所有当前的Nvidia设备返回32作为该变量,所有当前AMD设备返回64。设备代码应使用内置的warpSize来开发便携式波形感知代码。

Vector Types

请注意,这些类型是在hip_runtime.h中定义的,编译器不会自动提供。

Short Vector Types

短向量类型派生自基本整数和浮点类型。它们是在hip_vector_types.h中定义的结构。向量的第一、第二、第三和第四个分量分别通过x、y、z和w字段访问。所有短向量类型都支持make_<type_name>()形式的构造函数。例如,float4 make_float4(float x, float y, float z, float w)创建float4类型和值(x, y, z, w)的向量。

HIP支持以下短矢量格式:

  • Signed Integers
    • char1, char2, char3, char4
    • short1, short2, short3, short4
    • int1, int2, int3, int4
    • long1, long2, long3, long4
    • longlong1, longlong2, longlong3, longlong4
  • Unsigned Integers
    • uchar1, uchar2, uchar3, uchar4
    • ushort1, ushort2, ushort3, ushort4
    • uint1, uint2, uint3, uint4
    • ulong1, ulong2, ulong3, ulong4
    • ulonglong1, ulonglong2, ulonglong3, ulonglong4
  • Floating Points
    • float1, float2, float3, float4
    • double1, double2, double3, double4

dim3

dim3 是一个三维整型数组,用于指定grid和线程组的维度,未指定的维度会被初始化为1。

1
2
3
4
5
6
typedef struct dim3 {
uint32_t x;
uint32_t y;
uint32_t z;
dim3(uint32_t _x=1, uint32_t _y=1, uint32_t _z=1) : x(_x), y(_y), z(_z) {};
};

Memory-Fence Instructions

HIP支持__threadfence()__threadfence_block()。HIP为HIP-Clang路径下的threadfence_system()提供了一种解决方法。要启用此解决方法,应在启用环境变量HIP_COHERENT_HOST_ALLOC的情况下构建HIP 。

使用了__threadfence_system()的内核需要作如下修改:

  • 内核应该只在细粒度系统内存上运行;它应该与hipHostMalloc()一起分配。
  • 删除分配的细粒度系统内存区域的所有内存。

Synchronization Functions

HIP支持__syncthreads() . __syncthreads_count(int)__syncthreads_and(int)__syncthreads_or(int)正在开发中。

Math Functions

HIP-Clang 支持一系列数学操作,能够在设备处调用。

Single Precision Mathematical Functions

Function use Supported on Host Supported on Device
float acosf ( float x ) Calculate the arc cosine of the input argument.
float acoshf ( float x ) Calculate the nonnegative arc hyperbolic cosine of the input argument.
float asinf ( float x ) Calculate the arc sine of the input argument.
float asinhf ( float x ) Calculate the arc hyperbolic sine of the input argument.
float atan2f ( float y, float x ) Calculate the arc tangent of the ratio of first and second input arguments.
float atanf ( float x ) Calculate the arc tangent of the input argument.
float atanhf ( float x ) Calculate the arc hyperbolic tangent of the input argument.
float cbrtf ( float x ) Calculate the cube root of the input argument.
float ceilf ( float x ) Calculate ceiling of the input argument.
float copysignf ( float x, float y ) Create value with given magnitude, copying sign of second value.
float cosf ( float x ) Calculate the cosine of the input argument.
float coshf ( float x ) Calculate the hyperbolic cosine of the input argument.
float erfcf ( float x ) Calculate the complementary error function of the input argument.
float erff ( float x ) Calculate the error function of the input argument.
float exp10f ( float x ) Calculate the base 10 exponential of the input argument.
float exp2f ( float x ) Calculate the base 2 exponential of the input argument.
float expf ( float x ) Calculate the base e exponential of the input argument.
float expm1f ( float x ) Calculate the base e exponential of the input argument, minus 1.
float fabsf ( float x ) Calculate the absolute value of its argument.
float fdimf ( float x, float y ) Compute the positive difference between x and y.
float floorf ( float x ) Calculate the largest integer less than or equal to x.
float fmaf ( float x, float y, float z ) Compute x × y + z as a single operation.
float fmaxf ( float x, float y ) Determine the maximum numeric value of the arguments.
float fminf ( float x, float y ) Determine the minimum numeric value of the arguments.
float fmodf ( float x, float y ) Calculate the floating-point remainder of x / y.
float frexpf ( float x, int* nptr ) Extract mantissa and exponent of a floating-point value. x
float hypotf ( float x, float y ) Calculate the square root of the sum of squares of two arguments.
int ilogbf ( float x ) Compute the unbiased integer exponent of the argument.
__RETURN_TYPE1 isfinite ( float a ) Determine whether the argument is finite.
__RETURN_TYPE1 isinf ( float a ) Determine whether the argument is infinite.
__RETURN_TYPE1 isnan ( float a ) Determine whether the argument is a NaN.
float ldexpf ( float x, int exp ) Calculate the value of x ⋅ 2exp.
float log10f ( float x ) Calculate the base 10 logarithm of the input argument.
float log1pf ( float x ) Calculate the value of loge( 1 + x ).
float logbf ( float x ) Calculate the floating-point representation of the exponent of the input argument.
float log2f ( float x ) Calculate the base 2 logarithm of the input argument.
float logf ( float x ) Calculate the natural logarithm of the input argument.
float modff ( float x, float* iptr ) Break down the input argument into fractional and integral parts. x
float nanf ( const char* tagp ) Returns “Not a Number” value. x
float nearbyintf ( float x ) Round the input argument to the nearest integer.
float powf ( float x, float y ) Calculate the value of the first argument to the power of the second argument.
float remainderf ( float x, float y ) Compute single-precision floating-point remainder.
float remquof ( float x, float y, int* quo ) Compute single-precision floating-point remainder and part of quotient. x
float roundf ( float x ) Round to nearest integer value in floating-point.
float scalbnf ( float x, int n ) Scale floating-point input by an integer power of two.
__RETURN_TYPE1 signbit ( float a ) Return the sign bit of the input.
void sincosf ( float x, float* sptr, float* cptr ) Calculate the sine and cosine of the first input argument. x
float sinf ( float x ) Calculate the sine of the input argument.
float sinhf ( float x ) Calculate the hyperbolic sine of the input argument.
float sqrtf ( float x ) Calculate the square root of the input argument.
float tanf ( float x ) Calculate the tangent of the input argument.
float tanhf ( float x ) Calculate the hyperbolic tangent of the input argument.
float truncf ( float x ) Truncate input argument to an integral part.
float tgammaf ( float x ) Calculate the gamma function of the input argument.
float erfcinvf ( float y ) Calculate the inverse complementary function of the input argument.
float erfcxf ( float x ) Calculate the scaled complementary error function of the input argument.
float erfinvf ( float y ) Calculate the inverse error function of the input argument.
float fdividef ( float x, float y ) Divide two floating-point values.
float frexpf ( float x, int *nptr ) Extract mantissa and exponent of a floating-point value.
float j0f ( float x ) Calculate the value of the Bessel function of the first kind of order 0 for the input argument.
float j1f ( float x ) Calculate the value of the Bessel function of the first kind of order 1 for the input argument.
float jnf ( int n, float x ) Calculate the value of the Bessel function of the first kind of order n for the input argument.
float lgammaf ( float x ) Calculate the natural logarithm of the absolute value of the gamma function of the input argument.
long long int llrintf ( float x ) Round input to nearest integer value.
long long int llroundf ( float x ) Round to nearest integer value.
long int lrintf ( float x ) Round input to the nearest integer value.
long int lroundf ( float x ) Round to nearest integer value.
float modff ( float x, float *iptr ) Break down the input argument into fractional and integral parts.
float nextafterf ( float x, float y ) Returns next representable single-precision floating-point value after an argument.
float norm3df ( float a, float b, float c ) Calculate the square root of the sum of squares of three coordinates of the argument.
float norm4df ( float a, float b, float c, float d ) Calculate the square root of the sum of squares of four coordinates of the argument.
float normcdff ( float y ) Calculate the standard normal cumulative distribution function.
float normcdfinvf ( float y ) Calculate the inverse of the standard normal cumulative distribution function.
float normf ( int dim, const float *a ) Calculate the square root of the sum of squares of any number of coordinates.
float rcbrtf ( float x ) Calculate the reciprocal cube root function.
float remquof ( float x, float y, int *quo ) Compute single-precision floating-point remainder and part of quotient.
float rhypotf ( float x, float y ) Calculate one over the square root of the sum of squares of two arguments.
float rintf ( float x ) Round input to nearest integer value in floating-point.
float rnorm3df ( float a, float b, float c ) Calculate one over the square root of the sum of squares of three coordinates of the argument.
float rnorm4df ( float a, float b, float c, float d ) Calculate one over the square root of the sum of squares of four coordinates of the argument.
float rnormf ( int dim, const float *a ) Calculate the reciprocal of square root of the sum of squares of any number of coordinates.
float scalblnf ( float x, long int n ) Scale floating-point input by an integer power of two.
void sincosf ( float x, float *sptr, float *cptr ) Calculate the sine and cosine of the first input argument.
void sincospif ( float x, float *sptr, float *cptr ) Calculate the sine and cosine of the first input argument multiplied by PI.
float y0f ( float x ) Calculate the value of the Bessel function of the second kind of order 0 for the input argument.
float y1f ( float x ) Calculate the value of the Bessel function of the second kind of order 1 for the input argument.
float ynf ( int n, float x ) Calculate the value of the Bessel function of the second kind of order n for the input argument.

Double Precision Mathematical Functions

Function use Supported on Host Supported on Device
double acos ( double x ) Calculate the arc cosine of the input argument.
double acosh ( double x ) Calculate the nonnegative arc hyperbolic cosine of the input argument.
double asin ( double x ) Calculate the arc sine of the input argument.
double asinh ( double x ) Calculate the arc hyperbolic sine of the input argument.
double atan ( double x ) Calculate the arc tangent of the input argument.
double atan2 ( double y, double x ) Calculate the arc tangent of the ratio of first and second input arguments.
double atanh ( double x ) Calculate the arc hyperbolic tangent of the input argument.
double cbrt ( double x ) Calculate the cube root of the input argument.
double ceil ( double x ) Calculate ceiling of the input argument.
double copysign ( double x, double y ) Create value with given magnitude, copying sign of second value.
double cos ( double x ) Calculate the cosine of the input argument.
double cosh ( double x ) Calculate the hyperbolic cosine of the input argument.
double erf ( double x ) Calculate the error function of the input argument.
double erfc ( double x ) Calculate the complementary error function of the input argument.
double exp ( double x ) Calculate the base e exponential of the input argument.
double exp10 ( double x ) Calculate the base 10 exponential of the input argument.
double exp2 ( double x ) Calculate the base 2 exponential of the input argument.
double expm1 ( double x ) Calculate the base e exponential of the input argument, minus 1.
double fabs ( double x ) Calculate the absolute value of the input argument.
double fdim ( double x, double y ) Compute the positive difference between x and y.
double floor ( double x ) Calculate the largest integer less than or equal to x.
double fma ( double x, double y, double z ) Compute x × y + z as a single operation.
double fmax ( double , double ) Determine the maximum numeric value of the arguments.
double fmin ( double x, double y ) Determine the minimum numeric value of the arguments.
double fmod ( double x, double y ) Calculate the floating-point remainder of x / y.
double frexp ( double x, int* nptr ) Extract mantissa and exponent of a floating-point value. x
double hypot ( double x, double y ) Calculate the square root of the sum of squares of two arguments.
int ilogb ( double x ) Compute the unbiased integer exponent of the argument.
__RETURN_TYPE1 isfinite ( double a ) Determine whether an argument is finite.
__RETURN_TYPE1 isinf ( double a ) Determine whether an argument is infinite.
__RETURN_TYPE1 isnan ( double a ) Determine whether an argument is a NaN.
double ldexp ( double x, int exp ) Calculate the value of x ⋅ 2exp.
double log ( double x ) Calculate the base e logarithm of the input argument.
double log10 ( double x ) Calculate the base 10 logarithm of the input argument.
double log1p ( double x ) Calculate the value of loge( 1 + x ).
double log2 ( double x ) Calculate the base 2 logarithm of the input argument.
double logb ( double x ) Calculate the floating-point representation of the exponent of the input argument.
double modf ( double x, double* iptr ) Break down the input argument into fractional and integral parts. x
double nan ( const char* tagp ) Returns “Not a Number” value. x
double nearbyint ( double x ) Round the input argument to the nearest integer.
double pow ( double x, double y ) Calculate the value of the first argument to the power of the second argument.
double remainder ( double x, double y ) Compute double-precision floating-point remainder.
double remquo ( double x, double y, int* quo ) Compute double-precision floating-point remainder and part of quotient. x
double round ( double x ) Round to nearest integer value in floating-point.
double scalbn ( double x, int n ) Scale floating-point input by an integer power of two.
__RETURN_TYPE1 signbit ( double a ) Return the sign bit of the input.
double sin ( double x ) Calculate the sine of the input argument.
void sincos ( double x, double* sptr, double* cptr ) Calculate the sine and cosine of the first input argument. x
double sinh ( double x ) Calculate the hyperbolic sine of the input argument.
double sqrt ( double x ) Calculate the square root of the input argument.
double tan ( double x ) Calculate the tangent of the input argument.
double tanh ( double x ) Calculate the hyperbolic tangent of the input argument.
double tgamma ( double x ) Calculate the gamma function of the input argument.
double trunc ( double x ) Truncate input argument to an integral part.
double erfcinv ( double y ) Calculate the inverse complementary function of the input argument.
double erfcx ( double x ) Calculate the scaled complementary error function of the input argument.
double erfinv ( double y ) Calculate the inverse error function of the input argument.
double frexp ( float x, int *nptr ) Extract mantissa and exponent of a floating-point value.
double j0 ( double x ) Calculate the value of the Bessel function of the first kind of order 0 for the input argument.
double j1 ( double x ) Calculate the value of the Bessel function of the first kind of order 1 for the input argument.
double jn ( int n, double x ) Calculate the value of the Bessel function of the first kind of order n for the input argument.
double lgamma ( double x ) Calculate the natural logarithm of the absolute value of the gamma function of the input argument.
long long int llrint ( double x ) Round input to a nearest integer value.
long long int llround ( double x ) Round to nearest integer value.
long int lrint ( double x ) Round input to a nearest integer value.
long int lround ( double x ) Round to nearest integer value.
double modf ( double x, double *iptr ) Break down the input argument into fractional and integral parts.
double nextafter ( double x, double y ) Returns next representable single-precision floating-point value after an argument.
double norm3d ( double a, double b, double c ) Calculate the square root of the sum of squares of three coordinates of the argument.
float norm4d ( double a, double b, double c, double d ) Calculate the square root of the sum of squares of four coordinates of the argument.
double normcdf ( double y ) Calculate the standard normal cumulative distribution function.
double normcdfinv ( double y ) Calculate the inverse of the standard normal cumulative distribution function.
double rcbrt ( double x ) Calculate the reciprocal cube root function.
double remquo ( double x, double y, int *quo ) Compute single-precision floating-point remainder and part of quotient.
double rhypot ( double x, double y ) Calculate one over the square root of the sum of squares of two arguments.
double rint ( double x ) Round input to the nearest integer value in floating-point.
double rnorm3d ( double a, double b, double c ) Calculate one over the square root of the sum of squares of three coordinates of the argument.
double rnorm4d ( double a, double b, double c, double d ) Calculate one over the square root of the sum of squares of four coordinates of the argument.
double rnorm ( int dim, const double *a ) Calculate the reciprocal of the square root of the sum of squares of any number of coordinates.
double scalbln ( double x, long int n ) Scale floating-point input by an integer power of two.
void sincos ( double x, double *sptr, double *cptr ) Calculate the sine and cosine of the first input argument.
void sincospi ( double x, double *sptr, double *cptr ) Calculate the sine and cosine of the first input argument multiplied by PI.
double y0f ( double x ) Calculate the value of the Bessel function of the second kind of order 0 for the input argument.
double y1 ( double x ) Calculate the value of the Bessel function of the second kind of order 1 for the input argument.
double yn ( int n, double x ) Calculate the value of the Bessel function of the second kind of order n for the input argument.

__RETURN_TYPE 取决于编译器,通常在C里是int,在C++里是bool。

Integer Intrinsics

下表列出了支持的整数内部函数。注意,内部函数仅在设备上受支持。

Function use
unsigned int __brev ( unsigned int x ) Reverse the bit order of a 32-bit unsigned integer.
unsigned long long int __brevll (unsigned long long int x ) Reverse the bit order of a 64-bit unsigned integer.
int __clz ( int x ) Return the number of consecutive high-order zero bits in a 32-bit integer.
unsigned int __clz(unsigned int x ) Return the number of consecutive high-order zero bits in 32-bit unsigned integer.
int __clzll ( long long int x ) Count the number of consecutive high-order zero bits in a 64-bit integer.
unsigned int __clzll(long long int x ) Return the number of consecutive high-order zero bits in 64-bit signed integer.
unsigned int __ffs(unsigned int x ) Find the position of least significant bit set to 1 in a 32-bit unsigned integer.1
unsigned int __ffs( int x ) Find the position of least significant bit set to 1 in a 32-bit signed integer.
unsigned int __ffsll(unsigned long long int x ) Find the position of least significant bit set to 1 in a 64-bit unsigned integer.1
unsigned int __ffsll(long long int x ) Find the position of least significant bit set to 1 in a 64 bit signed integer.
unsigned int __popc ( unsigned int x ) Count the number of bits that are set to 1 in a 32-bit integer.
int __popcll ( unsigned long long int x ) Count the number of bits that are set to 1 in a 64-bit integer.
int __mul24 ( int x, int y ) Multiply two 24-bit integers.
unsigned int __umul24 ( unsigned int x, unsigned int y ) Multiply two 24-bit unsigned integers.

__ffs()__ffsll()的HIP-Clang实现包含添加constant+1以生成ffs结果格式的代码。对于这种开销是不可接受的,并且程序员愿意专门针对平台的情况优化,HIP-Clang提供__lastbit_u32_u32__lastbit_u32_u64

Floating-point Intrinsics

下表列出了支持的浮点内部函数。注意,内部函数仅在设备上受支持。

Function use
float __cosf ( float x ) Calculate the fast approximate cosine of the input argument.
float __expf ( float x ) Calculate the fast approximate base e exponential of the input argument.
float __frsqrt_rn ( float x ) Compute 1 / √x in round-to-nearest-even mode.
float __fsqrt_rd ( float x ) Compute √x in round-down mode.
float __fsqrt_rn ( float x ) Compute √x in round-to-nearest-even mode.
float __fsqrt_ru ( float x ) Compute √x in round-up mode.
float __fsqrt_rz ( float x ) Compute √x in round-towards-zero mode.
float __log10f ( float x ) Calculate the fast approximate base 10 logarithm of the input argument.
float __log2f ( float x ) Calculate the fast approximate base 2 logarithm of the input argument.
float __logf ( float x ) Calculate the fast approximate base e logarithm of the input argument.
float __powf ( float x, float y ) Calculate the fast approximate of xy.
float __sinf ( float x ) Calculate the fast approximate sine of the input argument.
float __tanf ( float x ) Calculate the fast approximate tangent of the input argument.
double __dsqrt_rd ( double x ) Compute √x in round-down mode.
double __dsqrt_rn ( double x ) Compute √x in round-to-nearest-even mode.
double __dsqrt_ru ( double x ) Compute √x in round-up mode.
double __dsqrt_rz ( double x ) Compute √x in round-towards-zero mode.

Texture Functions

以下头文件中列出了支持的纹理函数:”texture_functions.h”和”texture_indirect_functions.h” 。

Timer Functions

HIP提供以下内置功能,用于从设备读取高分辨率计时器。

  • clock_t clock()
  • long long int clock64()

返回设备上每个时钟周期递增的计数器值。返回值的差异就是计时间隔。

Atomic Functions

原子函数作为驻留在全局或共享内存中的读-修改-写操作执行。在原子操作期间,没有其他设备或线程可以观察或修改内存位置。如果来自不同设备或线程的多条指令以同一内存位置为目标,指令以未定义的顺序序列化。

HIP添加了以_system为后缀的新API,以支持系统范围的原子操作。例如,atomicAnd 专用于GPU设备,atomicAnd_system将允许开发人员将原子操作扩展到系统范围,从GPU设备扩展到系统中的其他CPU和GPU设备。

HIP支持以下原子操作:

Function Supported in HIP Supported in CUDA
int atomicAdd(int* address, int val)
int atomicAdd_system(int* address, int val)
unsigned int atomicAdd(unsigned int* address,unsigned int val)
unsigned int atomicAdd_system(unsigned int* address, unsigned int val)
unsigned long long atomicAdd(unsigned long long* address,unsigned long long val)
unsigned long long atomicAdd_system(unsigned long long* address, unsigned long long val)
float atomicAdd(float* address, float val)
float atomicAdd_system(float* address, float val)
double atomicAdd(double* address, double val)
double atomicAdd_system(double* address, double val)
int atomicSub(int* address, int val)
int atomicSub_system(int* address, int val)
unsigned int atomicSub(unsigned int* address,unsigned int val)
unsigned int atomicSub_system(unsigned int* address, unsigned int val)
int atomicExch(int* address, int val)
int atomicExch_system(int* address, int val)
unsigned int atomicExch(unsigned int* address,unsigned int val)
unsigned int atomicExch_system(unsigned int* address, unsigned int val)
unsigned long long atomicExch(unsigned long long int* address,unsigned long long int val)
unsigned long long atomicExch_system(unsigned long long* address, unsigned long long val)
unsigned long long atomicExch_system(unsigned long long* address, unsigned long long val)
float atomicExch(float* address, float val)
int atomicMin(int* address, int val)
int atomicMin_system(int* address, int val)
unsigned int atomicMin(unsigned int* address,unsigned int val)
unsigned int atomicMin_system(unsigned int* address, unsigned int val)
unsigned long long atomicMin(unsigned long long* address,unsigned long long val)
int atomicMax(int* address, int val)
int atomicMax_system(int* address, int val)
unsigned int atomicMax(unsigned int* address,unsigned int val)
unsigned int atomicMax_system(unsigned int* address, unsigned int val)
unsigned long long atomicMax(unsigned long long* address,unsigned long long val)
unsigned int atomicInc(unsigned int* address)
unsigned int atomicDec(unsigned int* address)
int atomicCAS(int* address, int compare, int val)
int atomicCAS_system(int* address, int compare, int val)
unsigned int atomicCAS(unsigned int* address,unsigned int compare,unsigned int val)
unsigned int atomicCAS_system(unsigned int* address, unsigned int compare, unsigned int val)
unsigned long long atomicCAS(unsigned long long* address,unsigned long long compare,unsigned long long val)
unsigned long long atomicCAS_system(unsigned long long* address, unsigned long long compare, unsigned long long val)
int atomicAnd(int* address, int val)
int atomicAnd_system(int* address, int val)
unsigned int atomicAnd(unsigned int* address,unsigned int val)
unsigned int atomicAnd_system(unsigned int* address, unsigned int val)
unsigned long long atomicAnd(unsigned long long* address,unsigned long long val)
unsigned long long atomicAnd_system(unsigned long long* address, unsigned long long val)
int atomicOr(int* address, int val)
int atomicOr_system(int* address, int val)
unsigned int atomicOr(unsigned int* address,unsigned int val)
unsigned int atomicOr_system(unsigned int* address, unsigned int val)
unsigned int atomicOr_system(unsigned int* address, unsigned int val)
unsigned long long atomicOr(unsigned long long int* address,unsigned long long val)
unsigned long long atomicOr_system(unsigned long long* address, unsigned long long val)
int atomicXor(int* address, int val)
int atomicXor_system(int* address, int val)
unsigned int atomicXor(unsigned int* address,unsigned int val)
unsigned int atomicXor_system(unsigned int* address, unsigned int val)
unsigned long long atomicXor(unsigned long long* address,unsigned long long val))
unsigned long long atomicXor_system(unsigned long long* address, unsigned long long val)

注意:为了保持浮点/双原子加法函数的向后兼容性,CMake文件中引入了一个新的编译标志__HIP_USE_CMPXCHG_FOR_FP_ATOMICS。默认情况下未设置此编译标志(“0”),因此HIP运行时使用当前的float/double atomicAdd函数。如果使用CMake选项将编译标志设置为1,D__HIP_USE_CMPXCHG_FOR_FP_ATOMICS=1,则旧的浮点/双原子加法函数用于与不支持浮点原子的编译器兼容。有关如何构建HIP运行时的详细信息,请参阅本指南中的HIP安装部分。

开发中的注意事项和功能HIP支持32位整数的原子操作。此外,它还支持原子浮点加法运算。

然而,AMD硬件使用CAS循环实现浮点加法,因此此函数可能无法有效执行。

Warp Cross-Lane Functions

在warp中的所有lane上运行。硬件保证所有warp lane将同步执行,因此不需要额外的同步,指令也不使用共享内存。

注意,英伟达和AMD设备具有不同的warp尺寸,因此代码应使用warpSize内置来查询warp尺寸。CUDA路径中的代码需要仔细审查,以确保其不假定warpSize为32。假设warpSize为32的代码在Warp-64机器上运行,它将仅使用一半的机器资源。

WarpSize 内置应该只能使用在设备函数中,它的值仅取决于GPU的架构。主机函数应该使用hipGetDeviceProperties来获取GPU设备的默认warpSize。

1
2
3
4
cudaDeviceProp props;
cudaGetDeviceProperties(&props, deviceID);
int w = props.warpSize;
// implement portable algorithm based on w (rather than assume 32 or 64)

Warp Vote and Ballot Functions

1
2
3
int __all(int predicate)
int __any(int predicate)
uint64_t __ballot(int predicate)

warp中的线程称为lane,编号从0到warpSize-1。对于这些函数,每个warp lane通道贡献1——比特值,它被有效地广播到warp中的所有lane。每个通道中的32位整型减少为1位值:0(predicate=0)或1(predicate!=0)__any__all提供了其他warp lane贡献的参数的概要视图:

  • __any()如果任何warp lane提供非零谓词,则返回1,否则返回0

  • __all()如果所有其他warp lane贡献非零谓词,则返回1,否则返回0

应用程序可以使用hasWarpVote设备属性或HIP_ARCH_AS_WARP_VOTE编译器定义测试目标平台是否支持任意/所有指令。

__ballot提供包含来自每个通道的1位谓词值的位掩码。结果的第n位包含第n个warp lane贡献的1位。请注意,HIP的__ballot函数支持64位返回值(与32位相比)。从CUDA移植的代码应该支持HIP版本的此指令支持的更大的warp大小。应用程序可以使用hasWarpBallot设备属性或HIP_ARCH_AS_WARP_ballot编译器定义测试目标平台是否支持ballot指令。

Cooperative Groups Functions

协作组是以不同于块的粒度在线程之间形成和通信的机制。CUDA 9中引入了此功能。HIP支持以下内核语言协作组类型或函数。

Function HIP CUDA
void thread_group.sync() ;
unsigned thread_group.size();
unsigned thread_group.thread_rank() ;
bool thread_group.is_valid();
grid_group this_grid();
void grid_group.sync() ;
unsigned grid_group.size() ;
unsigned grid_group.thread_rank() ;
bool grid_group.is_valid();
multi_grid_group this_multi_grid() ;
void multi_grid_group.sync();
unsigned multi_grid_group.size() ;
unsigned multi_grid_group.thread_rank() ;
bool multi_grid_group.is_valid() ;
unsigned multi_grid_group.num_grids() ;
unsigned multi_grid_group.grid_rank();
thread_block this_thread_block() ;
multi_grid_group this_multi_grid() ;
void multi_grid_group.sync();
void thread_block.sync() ;
unsigned thread_block.size() ;
unsigned thread_block.thread_rank() ;
bool thread_block.is_valid() ;
dim3 thread_block.group_index() ;
dim3 thread_block.thread_index()

Warp Matrix Functions

warp矩阵函数允许warp在元素以未指定的方式分布在lane上的小矩阵上协同操作。CUDA 9中引入了此功能。

HIP不支持任何内核语言warp矩阵类型或函数。

Function Supported in HIP Supported in CUDA
void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned lda)
void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned lda, layout_t layout)
void store_matrix_sync(T* mptr, fragment<...> &a, unsigned lda, layout_t layout)
void fill_fragment(fragment<...> &a, const T &value)
void mma_sync(fragment<...> &d, const fragment<...> &a, const fragment<...> &b, const fragment<...> &c , bool sat)

Independent Thread Scheduling

在支持CUDA的某些体系结构中引入的对独立线程调度的硬件支持允许线程彼此独立地进行,并启用以前不允许的经内同步。

HIP不支持这种类型的线程调度。

Assert

assert函数正在开发中,HIP不支持abort调用。

Printf

支持printf函数

Device-Side Dynamic Global Memory Allocation

设备端动态全局内存分配正在开发中。

__launch_bounds__

GPU多处理器有一个固定的资源池(主要是寄存器和共享内存),这些资源由主动运行的warp共享。使用更多资源可以增加内核的IPC,但会减少可用于其他warp的资源,并限制可以同时运行的warp的数量。因此,GPU在资源使用和性能之间有着复杂的关系。

__launchbounds__允许应用程序提供影响生成代码所使用的资源(主要是寄存器)的使用提示。它是必须附加到__global__函数的函数属性:

__global__ void __launch_bounds__ (MAX_THREADS_PER_BLOCK, MIN_WARPS_PER_EU) MyKernel(...) ... MyKernel(...)

launch_bounds支持两个参数:

  • MAX_THREADS_PER_BLOCK-程序员保证内核将以少于MAX_THREADS_PER_BLOCK的线程启动。(在NVCC上,这映射到.mantid PTX指令)。如果未指定launch_bounds,则MAX_THREADS_PER_BLOCK是设备支持的最大块大小(通常为1024或更大)。指定MAX_THREADS_PER_BLOCK小于最大值有效地允许编译器使用比默认无约束编译更多的资源,该编译在启动时支持所有可能的块大小。每个块的线程数是(hipBlockDim_x*hipBlockDim_y*hipBlockDim_z)的乘积。

  • MIN_WARPS_PER_EU—指导编译器最小化资源使用,以便在多处理器上同时激活所请求的warp数。由于活动warp会争夺相同的固定资源池,编译器必须减少每个warp所需的资源(主要是寄存器)。MIN_WARPS_PER_EU是可选的,如果未指定,则默认为1。指定大于默认值1的MIN_WARPS_PER_EU有效地限制了编译器的资源使用。

当使用HIPAPI(例如,hipModuleLaunchKernel())启动内核时,HIP将进行验证,以确保输入内核维度大小不大于指定的launch_bounds。如果AMD_LOG_LEVEL设置为正确的值,则如果超过指定的launch_bounds,HIP将返回启动失败。错误详细信息显示在错误日志消息中,包括内核大小、启动边界和出错内核的名称的启动参数。通常有助于识别断层内核。此外,内核dim大小和启动边界值也有助于调试此类故障。

Compiler Impact

编译器使用这些参数如下:

  • 编译器仅使用提示来管理寄存器使用,不会自动减少共享内存或其他资源。
  • 如果编译器无法生成满足指定启动边界要求的内核,则编译失败。
  • 编译器从MAX_THREADS_PER_BLOCK导出启动时可使用的最大warp/块数。MAX_THREADS_PER_BLOCK的值小于默认值允许编译器使用更大的寄存器池:每个warp使用寄存器,此提示包含启动到小于最大值的warp/块大小。
  • 编译器从MIN_WARPS_PER_EU导出内核可使用的最大寄存器数(以满足所需的同时活动块)。如果MIN_WARPS_PER_EU为1,则内核可以使用多处理器支持的所有寄存器。
  • 编译器通过溢出寄存器(到共享或全局内存)或使用更多指令,确保内核中使用的寄存器小于两个允许的最大值。
  • 编译器可以使用启发式方法来增加寄存器使用量,或者可以简单地避免溢出。MAX_THREADS_PER_BLOCK在这种情况下特别有用,因为它允许编译器使用更多寄存器,并避免编译器限制寄存器使用(可能溢出)以满足启动时从未使用过的大数据块大小的要求。

CU and EU Definitions

计算单元(CU)负责执行一个工作组的wave。它由一个或多个负责执行wave的执行单元(EU)组成。一个EU可以有足够的资源来维持不止一个执行wave的状态。这使得EU可以通过以与CPU上的对称多线程类似的方式在wave之间切换来隐藏延迟。为了适应EU的多个wave,一个wave所使用的资源必须受到限制。限制这样的资源可以允许更大的延迟隐藏,但这可能导致不得不将某些寄存器状态泄漏到内存中。该属性允许高级开发人员调整能够适应EU资源的wave数量。它可以用于确保至少有一个特定的数字适合于隐藏延迟,也可以用于确保不超过某个特定的数量适合于限制缓存抖动。

Porting from CUDA __launch_bounds

CUDA 定义了__launch_bounds,用于去控制占用。

__launch_bounds(MAX_THREADS_PER_BLOCK, MIN_BLOCKS_PER_MULTIPROCESSOR)

第二个参数 __launch_bounds必须被转换为__hip_launch_bounds的格式,它使用warps和执行单元EU,而不是blocks 和multiprocessors

MIN_WARPS_PER_EXECUTION_UNIT = (MIN_BLOCKS_PER_MULTIPROCESSOR * MAX_THREADS_PER_BLOCK) / 32

接口的主要区别在于:

  • Warps(而不是块):开发人员试图告诉编译器控制资源利用率,以保证一定数量的活动Warps/EU用于延迟隐藏。以块为单位指定活动warp似乎隐藏了warp大小的微观结构细节,然而,这会使接口更加混乱,因为开发人员最终需要计算warp的数量以获得所需的控制级别。
  • 执行单元(而非多处理器):使用执行单元而不是多处理器为具有多个执行单元/多处理器的体系结构提供支持。例如,AMD GCN架构每个多处理器有4个执行单元。hipDeviceProps有一个字段executionUnitsPerMultiprocessor。如果需要,可以使用平台特定的编码技术(如#ifdef)为NVCC和HIP Clang平台指定不同的launch_bound

Maxregcount

与nvcc不同,HIP Clang不支持--maxregcount选项。相反,我们鼓励用户使用hip_launch_bounds指令,因为这些参数比寄存器等微架构细节更直观和可移植,而且该指令允许每个内核控制,而不是整个文件。hip_launch_bounds同时适用于hip Clang和nvcc

Register Keyword

register关键字在C++中被弃用,nvcc和HIP Clang都会默默忽略。可以将选项“-Wdeprecated register”传递给编译器警告消息。

Pragma Unroll

支持使用编译时已知的绑定展开。例如:

1
2
3
4
5
6
#pragma unroll 16 /* hint to compiler to unroll next loop by 16 */
for (int i=0; i<16; i++) ...
#pragma unroll 1 /* tell compiler to never unroll the loop */
for (int i=0; i<16; i++) ...
#pragma unroll /* hint to compiler to completely unroll next loop. */
for (int i=0; i<16; i++) ...

In-Line Assembly

支持GCN ISA内联汇编。例如:

asm volatile ("v_mac_f32_e32 %0, %2, %3" : "=v" (out[i]) : "0"(out[i]), "v" (a), "v" (in[i]));

HIP编译器使用asm() 语句将GCN插入内核。使用volatile关键字,以便优化器不得改变volatile操作的数量或相对于其他volatile运算改变其执行顺序。v_mac_f32_e32是GCN指令。有关更多信息,请参阅AMD GCN3 ISA体系结构手册。按顺序排列的各个操作数的索引由%提供,后跟操作数列表中的位置“v”是32位VGPR寄存器的约束代码(针对特定于目标的AMDGPU)。有关更多信息,请参阅AMDGPU支持的约束代码列表。输出约束由“=”前缀指定,如上所示(“=v”)。这表示程序集将写入此操作数,然后该操作数将作为asm表达式的返回值可用。输入约束没有前缀-只有约束代码。约束字符串“0”表示将指定的输出寄存器也用作输入(它是第0个约束)。

C++ Support

以下C++特性不支持:

  • Run-time-type information (RTTI)
  • Virtual functions
  • Try/catch

Kernel Compilation

hipcc现在支持将C++/HIP内核编译为二进制代码对象。

二进制文件的文件格式为“.co”,表示代码对象。以下命令使用“hipcc”构建代码对象。

1
2
3
4
`hipcc --genco --offload-arch=[TARGET GPU] [INPUT FILE] -o [OUTPUT FILE]`
[TARGET GPU] = GPU architecture
[INPUT FILE] = Name of the file containing kernels
[OUTPUT FILE] = Name of the generated code object file

ROCm Code Object Tooling

ROCm编译器生成的代码对象(可执行文件、对象文件和共享对象库)可以使用本节中列出的工具进行检查和提取。

roc-obj

Examples

从一系列可执行文件中抽取对象

1
roc-obj <executable>...

从所有可执行文件中抽取ROCm代码对象,并反汇编:

1
2
roc-obj --disassemble <executable>...
roc-obj -d <executable>...

HIP Logging

HIP提供了日志机制来监控HIP代码运行,根据日志级别和掩码,HIP将为不同的函数类别打印出不同的日志信息。

HIP Logging Level

HIP日志默认关闭,可以通过设置AMD_LOG_LEVEL打开,不同的值定义了不同的日志级别。

1
2
3
4
5
6
7
enum LogLevel {
LOG_NONE = 0,
LOG_ERROR = 1,
LOG_WARNING = 2,
LOG_INFO = 3,
LOG_DEBUG = 4
};

HIP Logging Mask

日志掩码在运行时可以被设置为不同的值以输出不同的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
enum LogMask {
LOG_API = 0x00000001, //!< API call
LOG_CMD = 0x00000002, //!< Kernel and Copy Commands and Barriers
LOG_WAIT = 0x00000004, //!< Synchronization and waiting for commands to finish
LOG_AQL = 0x00000008, //!< Decode and display AQL packets
LOG_QUEUE = 0x00000010, //!< Queue commands and queue contents
LOG_SIG = 0x00000020, //!< Signal creation, allocation, pool
LOG_LOCK = 0x00000040, //!< Locks and thread-safety code.
LOG_KERN = 0x00000080, //!< kernel creations and arguments, etc.
LOG_COPY = 0x00000100, //!< Copy debug
LOG_COPY2 = 0x00000200, //!< Detailed copy debug
LOG_RESOURCE = 0x00000400, //!< Resource allocation, performance-impacting events.
LOG_INIT = 0x00000800, //!< Initialization and shutdown
LOG_MISC = 0x00001000, //!< misc debug, not yet classified
LOG_AQL2 = 0x00002000, //!< Show raw bytes of AQL packet
LOG_CODE = 0x00004000, //!< Show code creation debug
LOG_CMD2 = 0x00008000, //!< More detailed command info, including barrier commands
LOG_LOCATION = 0x00010000, //!< Log message location
LOG_ALWAYS = 0xFFFFFFFF, //!< Log always even mask flag is zero
};

一旦AMD_LOG_LEVEL被设置,日志掩码将被设置为默认的0x7FFFFFFF,同样有一个环境变量AMD_LOG_MASK可以被设置。

HIP Logging Command

为了输出HIP日志信息,函数被定义为:

1
2
3
4
5
6
7
8
9
10
11
12
#define ClPrint(level, mask, format, ...)
do {
if (AMD_LOG_LEVEL >= level) {
if (AMD_LOG_MASK & mask || mask == amd::LOG_ALWAYS) {
if (AMD_LOG_MASK & amd::LOG_LOCATION) {
amd::log_printf(level, __FILENAME__, __LINE__, format, ##__VA_ARGS__);
} else {
amd::log_printf(level, "", 0, format, ##__VA_ARGS__);
}
}
}
} while (false)

在HIP代码中,调用ClPrint(),例如:

1
ClPrint(amd::LOG_INFO, amd::LOG_INIT, "Initializing HSA stack.");  

HIP Logging Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
user@user-test:~/hip/bin$ export AMD_LOG_LEVEL=4
user@user-test:~/hip/bin$ ./hipinfo
:3:rocdevice.cpp :453 : 23647210092: Initializing HSA stack.
:3:comgrctx.cpp :33 : 23647639336: Loading COMGR library.
:3:rocdevice.cpp :203 : 23647687108: Numa select cpu
agent[0]=0x13407c0(fine=0x13409a0,coarse=0x1340ad0) for gpu agent=0x1346150
:4:runtime.cpp :82 : 23647698669: init
:3:hip_device_runtime.cpp :473 : 23647698869: 5617 : [7fad295dd840] hipGetDeviceCount: Returned hipSuccess
:3:hip_device_runtime.cpp :502 : 23647698990: 5617 : [7fad295dd840] hipSetDevice ( 0 )
:3:hip_device_runtime.cpp :507 : 23647699042: 5617 : [7fad295dd840] hipSetDevice: Returned hipSuccess
--------------------------------------------------------------------------------
device# 0
:3:hip_device.cpp :150 : 23647699276: 5617 : [7fad295dd840] hipGetDeviceProperties (0x7ffdbe7db730, 0 )
:3:hip_device.cpp :237 : 23647699335: 5617 : [7fad295dd840] hipGetDeviceProperties: Returned hipSuccess
Name: Device 7341
pciBusID: 3
pciDeviceID: 0
pciDomainID: 0
multiProcessorCount: 11
maxThreadsPerMultiProcessor: 2560
isMultiGpuBoard: 0
clockRate: 1900 Mhz
memoryClockRate: 875 Mhz
memoryBusWidth: 0
clockInstructionRate: 1000 Mhz
totalGlobalMem: 7.98 GB
maxSharedMemoryPerMultiProcessor: 64.00 KB
totalConstMem: 8573157376
sharedMemPerBlock: 64.00 KB
canMapHostMemory: 1
regsPerBlock: 0
warpSize: 32
l2CacheSize: 0
computeMode: 0
maxThreadsPerBlock: 1024
maxThreadsDim.x: 1024
maxThreadsDim.y: 1024
maxThreadsDim.z: 1024
maxGridSize.x: 2147483647
maxGridSize.y: 2147483647
maxGridSize.z: 2147483647
major: 10
minor: 12
concurrentKernels: 1
cooperativeLaunch: 0
cooperativeMultiDeviceLaunch: 0
arch.hasGlobalInt32Atomics: 1
arch.hasGlobalFloatAtomicExch: 1
arch.hasSharedInt32Atomics: 1
arch.hasSharedFloatAtomicExch: 1
arch.hasFloatAtomicAdd: 1
arch.hasGlobalInt64Atomics: 1
arch.hasSharedInt64Atomics: 1
arch.hasDoubles: 1
arch.hasWarpVote: 1
arch.hasWarpBallot: 1
arch.hasWarpShuffle: 1
arch.hasFunnelShift: 0
arch.hasThreadFenceSystem: 1
arch.hasSyncThreadsExt: 0
arch.hasSurfaceFuncs: 0
arch.has3dGrid: 1
arch.hasDynamicParallelism: 0
gcnArch: 1012
isIntegrated: 0
maxTexture1D: 65536
maxTexture2D.width: 16384
maxTexture2D.height: 16384
maxTexture3D.width: 2048
maxTexture3D.height: 2048
maxTexture3D.depth: 2048
isLargeBar: 0
:3:hip_device_runtime.cpp :471 : 23647701557: 5617 : [7fad295dd840] hipGetDeviceCount (0x7ffdbe7db714 )
:3:hip_device_runtime.cpp :473 : 23647701608: 5617 : [7fad295dd840] hipGetDeviceCount:Returned hipSuccess
:3:hip_peer.cpp :76 : 23647701731: 5617 : [7fad295dd840] hipDeviceCanAccessPeer (0x7ffdbe7db728, 0, 0 )
:3:hip_peer.cpp :60 : 23647701784: 5617 : [7fad295dd840] canAccessPeer: Returned hipSuccess
:3:hip_peer.cpp :77 : 23647701831: 5617 : [7fad295dd840] hipDeviceCanAccessPeer: Returned hipSuccess
peers:
:3:hip_peer.cpp :76 : 23647701921: 5617 : [7fad295dd840] hipDeviceCanAccessPeer ( 0x7ffdbe7db728, 0, 0 )
:3:hip_peer.cpp :60 : 23647701965: 5617 : [7fad295dd840] canAccessPeer: Returned hipSuccess
:3:hip_peer.cpp :77 : 23647701998: 5617 : [7fad295dd840] hipDeviceCanAccessPeer: Returned hipSuccess
non-peers: device#0
:3:hip_memory.cpp :345 : 23647702191: 5617 : [7fad295dd840] hipMemGetInfo ( 0x7ffdbe7db718, 0x7ffdbe7db720 )
:3:hip_memory.cpp :360 : 23647702243: 5617 : [7fad295dd840] hipMemGetInfo: Returned hipSuccess
memInfo.total: 7.98 GB
memInfo.free: 7.98 GB (100%)

Debugging HIP

Debugging tools

Using ltrace

ltrace是一个标准的linux工具,它在每次动态库调用时都会向stderr提供消息。由于ROCr和ROCt(ROC thunk,是ROC内核驱动程序的用户空间接口)都是动态库,因此这提供了一种简单的方法来跟踪这些库中的活动。在使用命令行调试器深入了解细节之前,跟踪可以是快速观察应用程序流的强大方式。ltrace是可视化整个ROCm软件堆栈的运行时行为的有用工具。跟踪还可以显示与关键路径上对费时API的意外调用相关的性能问题。

跟踪HIP API和输出的命令行:

1
2
3
4
5
6
7
$ ltrace -C -e "hip*" ./hipGetChanDesc
hipGetChanDesc->hipCreateChannelDesc(0x7ffdc4b66860, 32, 0, 0) = 0x7ffdc4b66860
hipGetChanDesc->hipMallocArray(0x7ffdc4b66840, 0x7ffdc4b66860, 8, 8) = 0
hipGetChanDesc->hipGetChannelDesc(0x7ffdc4b66848, 0xa63990, 5, 1) = 0
hipGetChanDesc->hipFreeArray(0xa63990, 0, 0x7f8c7fe13778, 0x7ffdc4b66848) = 0
PASSED!
+++ exited (status 0) +++

命令行仅跟踪API和输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
$ ltrace -C -e "hsa*" ./hipGetChanDesc
libamdhip64.so.4->hsa_init(0, 0x7fff325a69d0, 0x9c80e0, 0 <unfinished ...>
libhsa-runtime64.so.1->hsaKmtOpenKFD(0x7fff325a6590, 0x9c38c0, 0, 1) = 0
libhsa-runtime64.so.1->hsaKmtGetVersion(0x7fff325a6608, 0, 0, 0) = 0
libhsa-runtime64.so.1->hsaKmtReleaseSystemProperties(3, 0x80084b01, 0, 0) = 0
libhsa-runtime64.so.1->hsaKmtAcquireSystemProperties(0x7fff325a6610, 0, 0, 1) = 0
libhsa-runtime64.so.1->hsaKmtGetNodeProperties(0, 0x7fff325a66a0, 0, 0) = 0
libhsa-runtime64.so.1->hsaKmtGetNodeMemoryProperties(0, 1, 0x9c42b0, 0x936012) = 0
...
<... hsaKmtCreateEvent resumed> ) = 0
libhsa-runtime64.so.1->hsaKmtAllocMemory(0, 4096, 64, 0x7fff325a6690) = 0
libhsa-runtime64.so.1->hsaKmtMapMemoryToGPUNodes(0x7f1202749000, 4096, 0x7fff325a6690, 0) = 0
libhsa-runtime64.so.1->hsaKmtCreateEvent(0x7fff325a6700, 0, 0, 0x7fff325a66f0) = 0
libhsa-runtime64.so.1->hsaKmtAllocMemory(1, 0x100000000, 576, 0x7fff325a67d8) = 0
libhsa-runtime64.so.1->hsaKmtAllocMemory(0, 8192, 64, 0x7fff325a6790) = 0
libhsa-runtime64.so.1->hsaKmtMapMemoryToGPUNodes(0x7f120273c000, 8192, 0x7fff325a6790, 0) = 0
libhsa-runtime64.so.1->hsaKmtAllocMemory(0, 4096, 4160, 0x7fff325a6450) = 0
libhsa-runtime64.so.1->hsaKmtMapMemoryToGPUNodes(0x7f120273a000, 4096, 0x7fff325a6450, 0) = 0
libhsa-runtime64.so.1->hsaKmtSetTrapHandler(1, 0x7f120273a000, 4096, 0x7f120273c000) = 0
<... hsa_init resumed> ) = 0
libamdhip64.so.4->hsa_system_get_major_extension_table(513, 1, 24, 0x7f1202597930) = 0
libamdhip64.so.4->hsa_iterate_agents(0x7f120171f050, 0, 0x7fff325a67f8, 0 <unfinished ...>
libamdhip64.so.4->hsa_agent_get_info(0x94f110, 17, 0x7fff325a67e8, 0) = 0
libamdhip64.so.4->hsa_amd_agent_iterate_memory_pools(0x94f110, 0x7f1201722816, 0x7fff325a67f0,
0x7f1201722816 <unfinished ...>
libamdhip64.so.4->hsa_amd_memory_pool_get_info(0x9c7fb0, 0, 0x7fff325a6744, 0x7fff325a67f0) = 0
libamdhip64.so.4->hsa_amd_memory_pool_get_info(0x9c7fb0, 1, 0x7fff325a6748, 0x7f1200d82df4) = 0
...
<... hsa_amd_agent_iterate_memory_pools resumed> ) = 0
libamdhip64.so.4->hsa_agent_get_info(0x9dbf30, 17, 0x7fff325a67e8, 0) = 0
<... hsa_iterate_agents resumed> ) = 0
libamdhip64.so.4->hsa_agent_get_info(0x9dbf30, 0, 0x7fff325a6850, 3) = 0
libamdhip64.so.4->hsa_agent_get_info(0x9dbf30, 0xa000, 0x9e7cd8, 0) = 0
libamdhip64.so.4->hsa_agent_iterate_isas(0x9dbf30, 0x7f1201720411, 0x7fff325a6760,
0x7f1201720411) = 0
libamdhip64.so.4->hsa_isa_get_info_alt(0x94e7c8, 0, 0x7fff325a6728, 1) = 0
libamdhip64.so.4->hsa_isa_get_info_alt(0x94e7c8, 1, 0x9e7f90, 0) = 0
libamdhip64.so.4->hsa_agent_get_info(0x9dbf30, 4, 0x9e7ce8, 0) = 0
...
<... hsa_amd_memory_pool_allocate resumed> ) = 0
libamdhip64.so.4->hsa_ext_image_create(0x9dbf30, 0xa1c4c8, 0x7f10f2800000, 3 <unfinished ...>
libhsa-runtime64.so.1->hsaKmtAllocMemory(0, 4096, 64, 0x7fff325a6740) = 0
libhsa-runtime64.so.1->hsaKmtQueryPointerInfo(0x7f1202736000, 0x7fff325a65e0, 0, 0) = 0
libhsa-runtime64.so.1->hsaKmtMapMemoryToGPUNodes(0x7f1202736000, 4096, 0x7fff325a66e8, 0) = 0
<... hsa_ext_image_create resumed> ) = 0
libamdhip64.so.4->hsa_ext_image_destroy(0x9dbf30, 0x7f1202736000, 0x9dbf30, 0 <unfinished ...>
libhsa-runtime64.so.1->hsaKmtUnmapMemoryToGPU(0x7f1202736000, 0x7f1202736000, 4096, 0x9c8050) =
0
libhsa-runtime64.so.1->hsaKmtFreeMemory(0x7f1202736000, 4096, 0, 0) = 0
<... hsa_ext_image_destroy resumed> ) = 0
libamdhip64.so.4->hsa_amd_memory_pool_free(0x7f10f2800000, 0x7f10f2800000, 256, 0x9e76f0) = 0
PASSED!

Using ROCgdb

ROCm上的HIP开发人员可以使用AMD的ROCgdb进行调试和分析。ROCgdb是Linux的ROCm源代码级调试器,基于GNU源代码级调试程序GDB。它类似于cuda gdb。它可以用于调试器前端,如eclipse、vscode或gdbdashboard。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ export PATH=$PATH:/opt/rocm/bin
$ rocgdb ./hipTexObjPitch
GNU gdb (rocm-dkms-no-npi-hipclang-6549) 10.1
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
...
For bug reporting instructions, please see:
<https://github.com/ROCm-Developer-Tools/ROCgdb/issues>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
...
Reading symbols from ./hipTexObjPitch...
(gdb) break main
Breakpoint 1 at 0x4013d1: file /home/test/hip/tests/src/texture/hipTexObjPitch.cpp, line 98.
(gdb) run
Starting program: /home/test/hip/build/directed_tests/texture/hipTexObjPitch
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, main ()
at /home/test/hip/tests/src/texture/hipTexObjPitch.cpp:98
98 texture2Dtest<float>();
(gdb)c

Debugging HIP Applications

下面的示例显示了如何在运行应用程序时从调试器获取有用的信息,这会导致GPUVM错误问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Memory access fault by GPU node-1 on address 0x5924000. Reason: Page not present or supervisor
privilege.
Program received signal SIGABRT, Aborted.
[Switching to Thread 0x7fffdffb5700 (LWP 14893)]
0x00007ffff2057c37 in __GI_raise (sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:56
56 ../nptl/sysdeps/unix/sysv/linux/raise.c: No such file or directory.
(gdb) bt
#0 0x00007ffff2057c37 in __GI_raise (sig=sig@entry=6) at
../nptl/sysdeps/unix/sysv/linux/raise.c:56
#1 0x00007ffff205b028 in __GI_abort () at abort.c:89
#2 0x00007ffff6f960eb in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#3 0x00007ffff6f99ea5 in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#4 0x00007ffff6f78107 in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#5 0x00007ffff744f184 in start_thread (arg=0x7fffdffb5700) at pthread_create.c:312
#6 0x00007ffff211b37d in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:111
(gdb) info threads
Id Target Id Frame
4 Thread 0x7fffdd521700 (LWP 14895) "caffe" pthread_cond_wait@@GLIBC_2.3.2 () at
../nptl/sysdeps/unix/sysv/linux/x86_64/pthread_cond_wait.S:185
3 Thread 0x7fffddd22700 (LWP 14894) "caffe" pthread_cond_wait@@GLIBC_2.3.2 () at
../nptl/sysdeps/unix/sysv/linux/x86_64/pthread_cond_wait.S:185
* 2 Thread 0x7fffdffb5700 (LWP 14893) "caffe" 0x00007ffff2057c37 in __GI_raise
(sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:56
1 Thread 0x7ffff7fa6ac0 (LWP 14892) "caffe" 0x00007ffff6f934d5 in ?? () from
/opt/rocm/hsa/lib/libhsa-runtime64.so.1
(gdb) thread 1
[Switching to thread 1 (Thread 0x7ffff7fa6ac0 (LWP 14892))]
#0 0x00007ffff6f934d5 in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
(gdb) bt
#0 0x00007ffff6f934d5 in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#1 0x00007ffff6f929ba in ?? () from /opt/rocm/hsa/lib/libhsa-runtime64.so.1
#2 0x00007fffe080beca in HSADispatch::waitComplete() () from /opt/rocm/hcc/lib/libmcwamp_hsa.so
#3 0x00007fffe080415f in HSADispatch::dispatchKernelAsync(Kalmar::HSAQueue*, void const*, int,
bool) () from /opt/rocm/hcc/lib/libmcwamp_hsa.so
#4 0x00007fffe080238e in Kalmar::HSAQueue::dispatch_hsa_kernel(hsa_kernel_dispatch_packet_s
const*, void const*, unsigned long, hc::completion_future*) () from
/opt/rocm/hcc/lib/libmcwamp_hsa.so
#5 0x00007ffff7bb7559 in hipModuleLaunchKernel () from /opt/rocm/hip/lib/libhip_hcc.so
#6 0x00007ffff2e6cd2c in mlopen::HIPOCKernel::run (this=0x7fffffffb5a8, args=0x7fffffffb2a8,
size=80) at /root/MIOpen/src/hipoc/hipoc_kernel.cpp:15
...

Useful Environment Variables

HIP提供了允许HIP、HIP-clang或HSA驱动程序禁用功能或优化的环境变量。这些不适用于生产,但可用于诊断应用程序(或驱动程序)中的同步问题。有关环境变量的描述,请参见以下章节。它们在ROCm路径上受支持。

Kernel Enqueue Serialization 内核排队序列化

开发人员可以使用环境变量从主机控制内核命令序列化,

  • AMD_SERIALIZE_KERNEL,用于序列化内核队列。
  • AMD_SERIALIZE_KERNEL=1,排队前等待完成,
  • AMD_SERIALIZE_KERNEL=2,排队后等待完成,
  • AMD_SERIALIZE_KERNEL=3,两者都有。或AMD_SERIALIZE_COPY,用于序列化副本。
  • AMD_SERIALIZE_COPY=1,排队前等待完成
  • AMD_SERIALIZE_COPY=2,排队后等待完成
  • AMD_SERIALIZE_COPY=3,两者都有。

Making Device Visible

对于具有多个设备的系统,可以通过设置环境变量-HIP_visible_devices使HIP只能看到某些设备。HIP只能看到序列中存在索引的设备。例如:

1
$ HIP_VISIBLE_DEVICES=0,1

或者在应用中:

1
2
3
4
5
if (totalDeviceNum > 2) {
setenv("HIP_VISIBLE_DEVICES", "0,1,2", 1);
assert(getDeviceNumber(false) == 3);
... ...
}

Dump code object

开发人员可以通过设置环境变量GPU_dump_code_object转储代码对象以分析编译器相关问题

HSA提供环境变量帮助分析驱动程序或硬件中的问题。例如

  • HSA_ENABLE_SDMA=0它使主机到设备和设备到主机的副本使用计算着色器blit内核,而不是专用DMA复制引擎。计算着色器副本具有较低的延迟(通常小于5us),可以实现DMA副本引擎大约80%的带宽。此环境变量用于隔离硬件复制引擎的问题。
  • HSA_ENABLE_INTERRUPT=0使用基于内存的轮询而非中断检测完成信号。此环境变量可用于诊断驱动程序中的中断风暴问题。

Summary of Environment Variables in HIP

Environment Variable Default Value Usage
AMD_LOG_LEVEL Enable HIP log on different Levels. 0 0: Disable log. 1: Enable log on error level. 2: Enable log on warning and below levels. 0x3: Enable log on information and below levels. 0x4: Decode and display AQL packets.
AMD_LOG_MASK Enable HIP log on different Levels. 0x7FFFFFFF 0x1: Log API calls. 0x02: Kernel and Copy Commands and Barriers. 0x4: Synchronization and waiting for commands to finish. 0x8: Enable log on information and below levels. 0x20: Queue commands and queue contents. 0x40:Signal creation, allocation, pool. 0x80: Locks and thread-safety code. 0x100: Copy debug. 0x200: Detailed copy debug. 0x400: Resource allocation, performance-impacting events. 0x800: Initialization and shutdown. 0x1000: Misc debug, not yet classified. 0x2000: Show raw bytes of AQL packet. 0x4000: Show code creation debug. 0x8000: More detailed command info, including barrier commands. 0x10000: Log message location. 0xFFFFFFFF: Log always even mask flag is zero.
HIP_VISIBLE_DEVICES Only devices whose index is present in the sequence are visible to HIP. 0,1,2: Depending on the number of devices on the system.
GPU_DUMP_CODE_OBJECT Dump code object. 0 0: Disable. 1: Enable.
AMD_SERIALIZE_KERNEL Serialize kernel enqueue. 0 1: Wait for completion before enqueue. 2: Wait for completion after enqueue. 3: Both.
AMD_SERIALIZE_COPY Serialize copies. 0 1: Wait for completion before enqueue. 2: Wait for completion after enqueue. 3: Both.
HIP_HOST_COHERENT Coherent memory in hipHostMalloc. 0 0: memory is not coherent between host and GPU. 1: memory is coherent with host.
AMD_DIRECT_DISPATCH Enable direct kernel dispatch. 0 0: Disable. 1: Enable

General Debugging Tips

  • “gdb —args”可用于方便地将可执行文件和参数传递给gdb。
  • 从GDB中,您可以设置环境变量“set env”。请注意,该命令不使用“=”符号:(gdb)set env AMD_SERIALIZE_KERNEL 3
  • 故障将由运行时捕获,但实际上是由GPU上运行的异步命令生成的。因此,GDB回溯将在运行时显示路径。
  • 为了确定故障的真实位置,通过查看环境变量AMD_SERIALIZE_KERNEL=3 AMD_SERALIZE_COPY=3,强制内核同步执行。这将迫使HIP运行时在重新调整之前等待内核完成执行。如果错误发生在内核执行过程中,您可以在回溯中看到启动内核的代码。需要进行一些猜测来确定哪个线程实际导致了问题——通常是在libhsaruntime64.so中等待的线程。
  • 内核内部的VM故障可能由以下原因引起:
    • 不正确的代码(即延伸超过阵列边界的循环),
    • 内存问题-无效的内核参数(空指针、未注册的主机指针、坏指针),
    • 同步问题,
    • 编译器问题(编译器生成的代码不正确),
    • 运行时问题。

HIP Version

自ROCm v4.2发布以来,HIP版本定义更新如下:HIP_VERSION=HIP_VERSION_MAJOR * 10000000 + HIP_VERSION_MINOR * 100000 + HIP_VERSION_PATCH),HIP版本可以从以下HIP API调用中查询,hipRuntimeGetVersion(&runtimeVersion);

Transiting from CUDA to HIP

Transition Tool: HIPIFY

Sample and Practice

Add hip/bin path to the PATH.

1
$ export PATH=$PATH:[MYHIP]/bin

Define the environment variable.

1
$ export HIP_PATH=[MYHIP]

Build an executable file.

1
2
3
4
5
6
$ cd ~/hip/samples/0_Intro/square
$ make
/home/user/hip/bin/hipify-perl square.cu > square.cpp
/home/user/hip/bin/hipcc square.cpp -o square.out
/home/user/hip/bin/hipcc -use-staticlib square.cpp -o square.out.static

Execute the file.

1
2
3
4
5
6
7
8
9
$ ./square.out
info: running on device Vega20 [Radeon Pro W5500]
info: allocate host mem ( 7.63 MB)
info: allocate device mem ( 7.63 MB)
info: copy Host2Device
info: launch 'vector_square' kernel
info: copy Device2Host
info: check result
PASSED!

HIP Porting Process

Porting a New CUDA Project

General Tips

  • 在CUDA机器上启动端口通常是最简单的方法,因为您可以将部分代码增量地移植到HIP,而将其余代码留在CUDA中。(回想一下,在CUDA机器上,HIP只是CUDA上的一个薄层,因此这两种代码类型可以在nvcc平台上互操作。)此外,HIP端口可以与原始CUDA代码进行功能和性能比较。
  • CUDA代码移植到HIP并在CUDA机器上运行后,在AMD机器上使用HIP编译器编译HIP代码。
  • HIP端口可以取代CUDA版本:HIP可以提供与本地CUDA实现相同的性能,同时具有对Nvidia和AMD架构的可移植性以及未来C++标准支持的优势。您可以通过条件编译或将其添加到开源HIP基础结构来处理特定于平台的特性。
  • 使用bin/hipconvertinplace-perl.sh发送CUDA源目录中的所有代码文件。

Scanning existing CUDA code to scope the porting effort 扫描现有CUDA代码以确定移植工作的范围

hipinspecte-perl.sh工具将扫描源目录,以确定哪些文件包含CUDA代码,以及其中有多少代码可以自动转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
> cd examples/rodinia_3.0/cuda/kmeans
> $HIP_DIR/bin/hipexamine-perl.sh.
info: hipify ./kmeans.h =====>
info: hipify ./unistd.h =====>
info: hipify ./kmeans.c =====>
info: hipify ./kmeans_cuda_kernel.cu =====>
info: converted 40 CUDA->HIP refs( dev:0 mem:0 kern:0 builtin:37 math:0 stream:0 event:0 err:0
def:0 tex:3 other:0 ) warn:0 LOC:185
info: hipify ./getopt.h =====>
info: hipify ./kmeans_cuda.cu =====>
info: converted 49 CUDA->HIP refs( dev:3 mem:32 kern:2 builtin:0 math:0 stream:0 event:0 err:0
def:0 tex:12 other:0 ) warn:0 LOC:311
info: hipify ./rmse.c =====>
info: hipify ./cluster.c =====>
info: hipify ./getopt.c =====>
info: hipify ./kmeans_clustering.c =====>
info: TOTAL-converted 89 CUDA->HIP refs( dev:3 mem:32 kern:2 builtin:37 math:0 stream:0 event:0
err:0 def:0 tex:15 other:0 ) warn:0 LOC:3607
kernels (1 total) : kmeansPoint(1)

hipinspect-perl扫描指定目录中找到的每个代码文件(cpp、c、h、hpp等):

  • 没有CUDA代码(kmeans.h)的文件只打印一行摘要,列出源文件名。
  • 带有CUDA代码的文件打印找到的内容的摘要-例如,kmeans_CUDA_kernel.cu文件:
1
2
info: hipify ./kmeans_cuda_kernel.cu =====>
info: converted 40 CUDA->HIP refs( dev:0 mem:0 kern:0 builtin:37 math:0 stream:0 event:0
  • kmeans_cuda_kernel.cu中的信息:

    • 有多少CUDA调用转换为HIP(40)
    • 所用CUDA功能的分解(dev:0 mem:0等)。此文件使用了许多CUDA内置(37)和纹理函数(3)。
    • 类似CUDA API但未转换的代码的警告(此文件中为0)。
    • 计算此文件的代码行数(LOC)-185。
  • hipinspect-perl还在流程结束时为所有文件收集的统计数据提供一份摘要。这与每文件报告的格式类似,还包括所有已调用内核的列表。上面的示例:

1
2
3
info: TOTAL-converted 89 CUDA->HIP refs( dev:3 mem:32 kern:2 builtin:37 math:0 stream:0 event:0
err:0 def:0 tex:15 other:0 ) warn:0 LOC:3607
kernels (1 total) : kmeansPoint(1)

Converting a project in-place

1
hipify-perl --inplace  

对于每个输入文件file,此脚本将:

  • 如果file.prehip文件不存在,请将原始代码复制到扩展名为.prehip的新文件中。然后将代码文件发送。
  • 如果“FILE.previip”文件存在,请将FILE.prehip发送并保存到FILE。这对于测试hipify工具集的改进非常有用。

hipconvertinplace-perl.sh脚本将对指定目录中的所有代码文件执行就地转换。这在处理现有CUDA代码库时非常方便,因为脚本保留了现有的目录结构和文件名,并包含了工作。就地转换后,您可以查看代码以向目录名添加其他参数。

1
> hipconvertinplace-perl.sh MY_SRC_DIR

Library Equivalents

CUDA Library ROCm Library Comment
cuBLAS rocBLAS Basic Linear Algebra Subroutines
cuFFT rocFFT Fast Fourier Transfer Library
cuSPARSE rocSPARSE Sparse BLAS + SPMV
cuSolver rocSOLVER Lapack library
AMG-X rocALUTION Sparse iterative solvers and preconditioners with Geometric and Algebraic MultiGrid
Thrust rocThrust C++ parallel algorithms library
CUB rocPRIM Low Level Optimized Parallel Primitives
cuDNN MIOpen Deep learning Solver Library
cuRAND rocRAND Random Number Generator Library
EIGEN EIGEN C++ template library for linear algebra: matrices, vectors, numerical solvers,
NCCL RCCL Communications Primitives Library based on the MPI equivalents

Distinguishing Compiler Modes

Identifying HIP Target Platform

所有HIP项目都以AMD或NVIDIA平台为目标。平台会影响包含的头文件和用于链接的库。

  • 如果HIP平台以AMD为目标,则定义HIP_PLATFORM_AMD。注意,如果HIP平台针对AMD,则先前定义了HIP_PLATFORM_HCC。现在已弃用。
  • 如果HIP平台以NVIDIA为目标,则定义HIP_PLATFORM_NVDIA。注意,如果HIP平台针对NVIDIA,则先前定义了HIP_PLATFORM_NVCC。现在已弃用

Identifying the Compiler: HIP-Clang or NVIDIA

通常,了解底层编译器是HIP Clang还是NVIDIA是很有用的。这些知识可以保护特定于平台的代码或有助于特定于平台性能的调整

1
2
3
4
5
6
7
8
9
#ifdef __HIP_PLATFORM_AMD__
// Compiled with HIP-Clang
#endif
#ifdef __HIP_PLATFORM_NVIDIA__
// Compiled with nvcc
// Could be compiling with CUDA language extensions enabled (for example, a ".cu file)
// Could be in pass-through mode to an underlying host compile OR (for example, a .cpp file)
#ifdef __CUDACC__
// Compiled with nvcc (CUDA language extensions enabled)

HIP Clang直接生成主机代码(使用Clang x86目标),而无需将代码传递给另一个主机编译器。因此,它们没有__CUDACC__定义的等价物。

Identifying Current Compilation Pass: Host or Device 识别当前编译过程:主机或设备

NVCC对代码进行两次传递:一次传递主机代码,一次传递设备代码。HIP Clang将对代码进行多次传递:一次用于主机代码,一次用于设备代码上的每个架构。当编译器(HIP-Clang或nvcc)为__global__内核内的设备或设备函数编译代码时,__HIP_DEVICE_COMPILE__设置为非零值。__HIP_DEVICE_COMPILE__可以替换__CUDA_ARCH__定义上的#ifdef检查。

1
2
//#ifdef__CUDA_ARCH__
#if __HIP_DEVICE_COMPILE__

__CUDA_ARCH__不同,__HIP_DEVICE_COMPILE__值为1或未定义,它不表示目标设备的功能。

Compiler Defines: Summary

Define HIP-Clang nvcc Other (GCC, ICC, Clang, etc.)
HIP-related defines:
__HIP_PLATFORM_AMD__ Defined Undefined Defined if targeting AMD platform; undefined otherwise
__HIP_PLATFORM_NVIDIA__ Undefined Defined Defined if targeting NVIDIA platform; undefined otherwise
__HIP_DEVICE_COMPILE__ 1 if compiling for device; undefined if compiling for host 1 if compiling for device; undefined if compiling for host Undefined
__HIPCC__ Defined Defined Undefined
__HIP_ARCH_* 0 or 1 depending on feature support (see below) 0 or 1 depending on feature support (see below) 0
nvcc-related defines:
__CUDACC__ Defined if source code is compiled by nvcc; undefined otherwise Undefined
__NVCC__ Undefined Defined Undefined
__CUDA_ARCH__ Undefined Unsigned representing compute capability (e.g., “130”) if in device code; 0 if in host code Undefined
hip-clang-related defines:
__HIP__ Defined Undefined Undefined
HIP-Clang common defines:
__clang__ Defined Defined Undefined

Identifying Architecture Features

HIP_ARCH Defines

一些CUDA代码会检查__CUDA_ARCH__是否是特定值来判断设备有无某种特性。

1
2
#if (__CUDA_ARCH__ >= 130)
// doubles are supported

这种类型的代码需要特别注意,因为AMD和CUDA设备具有不同的架构能力。此外,您无法通过与体系结构版本号的简单比较来确定功能的存在。HIP提供一组定义和设备属性,以查询是否支持特定的体系结构特性。

__HIP_ARCH_*定义可以替换__CUDA_ARCH__值的比较:

1
2
3
4
//#if (__CUDA_ARCH__ >= 130) // non-portable
if __HIP_ARCH_HAS_DOUBLES__ { // portable HIP feature query
// doubles are supported
}

对于主机代码,__HIP_ARCH_*定义设置为0。您只应在设备代码中使用HIP_ARCH字段。

Device-Architecture Properties

主机代码应该查询hipGetDeviceProperties返回的设备属性中的体系结构功能标志,而不是直接测试“major”和“minor”字段:

1
2
3
4
5
hipGetDeviceProperties(&deviceProp, device);
//if ((deviceProp.major == 1 && deviceProp.minor < 2)) // non-portable
if (deviceProp.arch.hasSharedInt32Atomics) { // portable HIP feature query
// has shared int32 atomic operations ...
}

Table of Architecture Properties

下表显示了HIP支持的一整套体系结构属性。

Define (use only in device code) Device Property (run time query) Comment
32-bit atomics:
__HIP_ARCH_HAS_GLOBAL_INT32_ATOMICS__ hasGlobalInt32Atomics 32-bit integer atomics for global memory
__HIP_ARCH_HAS_GLOBAL_FLOAT_ATOMIC_EXCH__ hasGlobalFloatAtomicExc h 32-bit float atomic exchange for global memory
__HIP_ARCH_HAS_SHARED_INT32_ATOMICS__ hasSharedInt32Atomics 32-bit integer atomics for shared memory
__HIP_ARCH_HAS_SHARED_FLOAT_ATOMIC_EXCH__ hasSharedFloatAtomicExc h 32-bit float atomic exchange for shared memory
__HIP_ARCH_HAS_FLOAT_ATOMIC_ADD__ hasFloatAtomicAdd 32-bit float atomic add in global and shared memory
64-bit atomics
__HIP_ARCH_HAS_GLOBAL_INT64_ATOMICS__ hasGlobalInt64Atomics 64-bit integer atomics for global memory
__HIP_ARCH_HAS_SHARED_INT64_ATOMICS__ hasSharedInt64Atomics 64-bit integer atomics for shared memory
Doubles
__HIP_ARCH_HAS_DOUBLES__ hasDoubles Double-precision floating point
Warp cross-lane operations:
__HIP_ARCH_HAS_WARP_VOTE__ hasWarpVote Warp vote instructions (any, all)
__HIP_ARCH_HAS_WARP_BALLOT__ hasWarpBallot Warp ballot instructions
__HIP_ARCH_HAS_WARP_SHUFFLE__ hasWarpShuffle Warp shuffle operations (shfl_*)
__HIP_ARCH_HAS_WARP_FUNNEL_SHIFT__ hasFunnelShift Funnel shift two input words into one
Sync
__HIP_ARCH_HAS_THREAD_FENCE_SYSTEM__ hasThreadFenceSystem threadfence_syste m
__HIP_ARCH_HAS_SYNC_THREAD_EXT__ hasSyncThreadsExt syncthreads_count, syncthreads_and, syncthreads_or
Miscellaneous
__HIP_ARCH_HAS_SURFACE_FUNCS__ hasSurfaceFuncs
__HIP_ARCH_HAS_3DGRID__ has3dGrid Grids and groups are 3D
__HIP_ARCH_HAS_DYNAMIC_PARALLEL__ hasDynamicParallelism

Finding HIP

如果不存在默认HIP_PATH,Makefile可以使用以下语法有条件地提供默认HIP_PATH:

1
HIP_PATH ?= $(shell hipconfig --path)

Identifying HIP Runtime

HIP可以依赖于ROCclr或CUDA作为运行时。

AMD平台HIP使用名为ROCclr的Radeon Open Compute公共语言运行时。ROCclr是一个虚拟设备接口,HIP运行时可以与不同的后端交互,允许运行时在Linux和Windows上工作而不需要付出太多努力。

在NVIDIA平台上,HIP只是CUDA之上的一个薄层。在非AMD平台上,HIP运行时确定CUDA是否可用并可以使用。如果可用,HIP_PLATFORM设置为NVIDIA,并使用CUDA路径下面的路径。

hipLaunchKernel

hipLaunchKernel是一个可变的宏,它接受启动配置(网格dims、组dims、流、动态共享大小)和数量可变的内核参数作为参数。然后根据平台的不同,将该序列扩展为适当的内核启动语法。虽然这可能是一种方便的单行内核启动语法,但当嵌套在其他宏中时,宏实现可能会导致问题。例如,考虑以下内容:

1
2
3
4
5
6
7
// Will cause compile error:
#define MY_LAUNCH(command, doTrace) \
{\
if (doTrace) printf ("TRACE: %s\n", #command); \
(command); /* The nested ( ) will cause compile error */\
}
MY_LAUNCH (hipLaunchKernel(vAdd, dim3(1024), dim3(1), 0, 0, Ad), true, "firstCall");

注意:避免在括号内嵌套宏参数-这里有一个可行的替代方案:

1
2
3
4
5
6
#define MY_LAUNCH(command, doTrace) \
{\
if (doTrace) printf ("TRACE: %s\n", #command); \
command;\
}
MY_LAUNCH (hipLaunchKernel(vAdd, dim3(1024), dim3(1), 0, 0, Ad), true, "firstCall");

Compiler Options

HIPcc是一个可移植的编译器驱动程序,它调用nvcc或HIP Clang(取决于目标系统)并附加所有必需的include和library选项。它将选项传递给目标编译器。调用hipcc的工具必须确保编译器选项适合目标编译器。hipconfig脚本可能有助于识别目标平台、编译器和运行时。它还可以帮助适当设置选项。

Compiler Options Supported on AMD Platforms

Option Description
—amdgpu-target= [DEPRECATED] This option is replaced by --offload-arch=<target>. Generate code for the given GPU target. Supported targets are gfx701, gfx801, gfx802, gfx803, gfx900, gfx906, gfx908, gfx1010, gfx1011, gfx1012, gfx1030, gfx1031. This option could appear multiple times on the same command line to generate a fat binary for multiple targets.
—fgpu-rdc Generate relocatable device code, which allows kernels or device functions calling device functions in different translation units.
-ggdb Equivalent to -g plus tuning for GDB. This is recommended when using ROCm’s GDB to debug GPU code.
—gpu-max-threads-per block= Generate code to support up to the specified number of threads per block.
-O Specify the optimization level.
-offload-arch= Specify the AMD GPU [target ID] https://clang.llvm.org/docs/ClangOffloadBundlerFileFormat.html#target-id
-save-temps Save the compiler-generated intermediate files.
-v Show the compilation steps.

Option for specifying GPU processor

—offload-arch=X

Linking Issues

Linking with hipcc

hipcc为HIP以及加速器编译器(nvcc或AMD编译器)添加了必要的库。建议与hipcc链接,因为它会自动将二进制文件链接到必要的HIP运行库。它还支持链接和管理GPU对象。-lm Option

Linking Code with Other Compilers

CUDA代码通常使用nvcc作为加速器代码(定义和启动内核,通常在.cu或.cuh文件中定义)。它还为应用程序的其余部分使用标准编译器(g++)。nvcc是一个使用标准主机编译器(gcc)生成主机代码的预处理器。使用此工具编译的代码只能使用nvcc和宿主编译器支持的语言特性的交集。在某些情况下,您必须注意确保主机编译器的数据类型和对齐方式与设备编译器的相同。仅支持某些主机编译器,例如,最近的nvcc版本缺少Clang主机编译器功能。HIP Clang使用相同的基于Clang的编译器生成设备和主机代码。该代码使用与gcc相同的API,这允许不同的gcc兼容编译器生成的代码链接在一起。例如,使用HIP Clang编译的代码可以与使用“标准”编译器(如gcc、ICC和Clang)编译的代码链接。注意确保所有编译器使用相同的标准C++头和库格式。

libc++ and libstdc++

默认情况下,hipcc链接到libstdc++。这在g++和HIP之间提供了更好的兼容性。

如果将--stdlib=libc++传递给hipcc,hipcc将使用libc++库。通常,libc++提供了一组更广泛的C++特性,而libstdc++是更多编译器(特别是包括g++)的标准。

当交叉链接C++代码时,任何使用C++标准库中类型的C++函数(包括std::string、std::vector和其他容器)都必须使用相同的标准库实现。它们包括以下内容:

  • HIP-Clang中定义的从标准编译器调用的函数或内核
  • 标准编译器中定义的函数从HIP Clang调用。
  • 具有这些接口的应用程序应使用默认的libstdc++链接。

完全使用hipcc编译的应用程序,受益于libstdc++不支持的高级C++功能,并且不需要nvcc的可移植性,可以选择使用libc++。

HIP Headers (hip_runtime.h, hip_runtime_api.h)

hip_runtime.h和hip_runtime_api.h文件定义了编译hip程序所需的类型、函数和枚举:

  • hip_runtime_api.h:定义所有hip运行时api(例如,hipMalloc)以及调用它们所需的类型。仅调用HIPAPI但既不定义也不启动任何内核的源文件都可以包含hip_runtime_api.h。hip_runtime _api.h不使用自定义hc语言特性,可以使用标准C++编译器编译。
  • hip_runtime.h:包含在hip_runtme_api.h中。它还提供了创建和启动内核所需的类型和定义。它可以使用标准C++编译器编译,但将暴露可用函数的子集。

CUDA对这两个文件的内容略有不同。在某些情况下,您可能需要将hipified代码转换为包含更丰富的hip_runtime.h,而不是hip_runtme_api.h。

Using a Standard C++ Compiler

可以使用标准C/C++编译器(gcc或ICC)编译 hip_runtime_api.h。HIP头文件路径和定义(__HIP_PLATFORM_AMD__ 或者 __HIP_PLATFORM_NVIDIA__)必须传给标准编译器,hipconfig会返回必要的选项:

1
2
> hipconfig --cxx_config
-D__HIP_PLATFORM_AMD__ -I/home/user1/hip/include

您可以捕获hipconfig输出并将其传递给标准编译器;下面是makefile语法示例:

1
CPPFLAGS += $(shell $(HIP_PATH)/bin/hipconfig --cpp_config)

默认情况下,nvcc包含一些头文件。然而,HIP不包含默认头文件,而是必须明确包含所有必需的文件。具体来说,调用HIP运行时API或定义HIP内核的文件必须明确包含适当的HIP头。如果编译过程报告找不到必要的api(例如,“错误:标识符’hipSetDevice’未定义”),请确保文件包含hip_runtime.h(或hip_runtme_api.h,如果合适)。hipify-perl脚本会自动将“cudaruntime.h”转换为“hip_runtime.h”,并将“cuda_runtime_api.h”转换成“hip_rountime_api.h”,但可能会丢失嵌套的头或宏。

cuda.h

HIP Clang路径提供了一个空的cuda.h文件。一些现有的CUDA程序包含此文件,但不需要任何功能。

Choosing HIP File Extensions

许多现有CUDA项目使用“.cu”和“.cuh”文件扩展名来指示应该通过nvcc编译器运行的代码。对于快速HIP端口,保持这些文件扩展名不变通常更容易,因为这样可以减少更改目录中的文件名和文件中的#include语句所需的工作量。

对于可以重新分解的新项目或端口,我们建议对源文件使用扩展名“.hip.cpp”,对头文件使用“.hip.h”或“.hip.hpp”。这表明代码是标准的C++代码,但也为make工具在适当时运行hipcc提供了唯一的指示。

Workarounds

memcpyToSymbol

hipMemcpyToSymbol的HIP支持已完成。该特性允许内核定义可以在主机端访问的设备端数据符号。符号可以在__constant或设备空间中。

请注意,符号名称需要封装在HIP_symbol宏中,如下面的代码示例所示。这也适用于hipMemcpyFromSymbolhipGetSymbolAddresshipGetSymbolSize

例如,设备代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include<hip/hip_runtime.h>
#include<hip/hip_runtime_api.h>
#include<iostream>
#define HIP_ASSERT(status) \
assert(status == hipSuccess)
#define LEN 512
#define SIZE 2048
__constant__ int Value[LEN];
__global__ void Get(hipLaunchParm lp, int *Ad)
{
int tid =threadIdx.x + blockIdx.x *blockDim.x;
Ad[tid] = Value[tid];
}
int main()
{
int *A, *B, *Ad;
A = new int[LEN];
B = new int[LEN];
for(unsigned i=0;i<LEN;i++)
{
A[i] = -1*i;
B[i] = 0;
}
HIP_ASSERT(hipMalloc((void**)&Ad, SIZE));
HIP_ASSERT(hipMemcpyToSymbol(HIP_SYMBOL(Value), A, SIZE, 0, hipMemcpyHostToDevice));
hipLaunchKernel(Get, dim3(1,1,1), dim3(LEN,1,1), 0, 0, Ad);
HIP_ASSERT(hipMemcpy(B, Ad, SIZE, hipMemcpyDeviceToHost));
for(unsigned i=0;i<LEN;i++)
{
assert(A[i] == B[i]);
}
std::cout<<"Passed"<<std::endl;
}

CU_POINTER_ATTRIBUTE_MEMORY_TYPE

要在HIP/HIP Clang中获取指针的内存类型,应该使用hipPointerGetAttributes API。API的第一个参数是hipPointerAttribute_t,其成员变量为memoryTypememoryType表示输入指针分配在设备或主机上。

1
2
3
4
5
6
7
8
double * ptr;
hipMalloc(reinterpret_cast<void**>(&ptr), sizeof(double));
hipPointerAttribute_t attr;
hipPointerGetAttributes(&attr, ptr); /*attr.memoryType will have value as hipMemoryTypeDevice*/
double* ptrHost;
hipHostMalloc(&ptrHost, sizeof(double));
hipPointerAttribute_t attr;
hipPointerGetAttributes(&attr, ptrHost); /*attr.memoryType will have value as hipMemoryTypeHost*/

threadfence_system

threadence_system使所有设备内存写入、对映射主机内存的所有写入以及对其他GPU设备内存的写入对其他CPU和GPU可见。一些实现可以通过刷新GPU L2缓存来提供这种行为。HIP/HIP-Clang不提供此功能。作为解决方法,用户可以将环境变量HSA_DISABLE_CACHE=1设置为禁用GPU二级缓存。这将影响所有访问和所有内核,因此可能会影响性能。

Textures and Cache Control

计算程序有时使用纹理来访问专用纹理缓存或使用纹理采样硬件进行插值和夹持。前一种方法使用具有线性插值的简单点采样器,本质上只读取单个点。后一种方法使用采样器硬件对多个样本进行插值和组合。AMD硬件以及最近的竞争硬件都有统一的纹理/L1缓存,因此不再有专用的纹理缓存。但nvcc路径通常将全局加载缓存在二级缓存中,一些程序可能会从一级缓存内容的显式控制中受益。为此,我们建议使用__ldg指令。

AMD编译器目前将所有数据加载到L1和L2缓存中,因此__ldg被视为noop。对于功能可移植性,我们建议如下:

  • 对于仅使用纹理以从改进的缓存中获益的程序,请使用__ldg指令
  • 使用纹理对象和引用API的程序在HIP上运行良好

HIP Porting Driver API

Porting CUDA Driver API

CUDA提供了单独的CUDA驱动程序和运行时API。这两个API在功能上有很大的重叠:

  • 这两个API都支持事件、流、内存管理、内存复制和错误处理。
  • 两种API提供了相似的性能。
  • 驱动程序API调用以前缀cu开头,而运行时API以前缀cuda开头。例如,驱动程序API包含cuEventCreate,而运行时API包含cudaEventCreate,具有类似的功能。
  • 驱动程序API定义的错误代码空间与运行时API使用的编码约定不同,但在很大程度上重叠。例如,驱动程序API定义CUDA_ERROR_INVALID_VALUE,而运行时API定义cudaErrorInvalidValue

注意:驱动程序API提供了运行时API没有提供的两个附加功能:cuModule和cuCtx API。

cuModule API

驱动程序API的模块部分提供了如何以及何时加载加速器代码对象的额外控制。例如,驱动程序API允许从文件或内存指针加载代码对象。可以从加载的代码对象中提取内核或全局数据的符号。相反,运行时API在运行时自动加载并(如果需要)从可执行二进制文件编译所有内核。在此模式下,必须使用NVCC编译内核代码,以便自动加载能够正常运行。

驱动程序和运行时API都定义了一个用于启动内核的函数(称为cuLaunchKernel或cudaLaunchKernel)。内核参数和执行配置(网格维度、组维度、动态共享内存和流)作为参数传递给启动函数。Runtime还提供了用于启动内核的<<<>>>语法,它类似于一个特殊的函数调用,比显式启动API更易于使用(特别是内核参数的处理)。然而,此语法不是标准的C++,只有在使用NVCC编译主机代码时才可用。

模块特性在直接生成代码对象的环境中非常有用,例如新的加速器语言前端。此处不使用NVCC。相反,环境可能具有不同的内核语言或不同的编译流。其他环境有许多内核,不希望它们全部自动加载。Module函数可用于加载生成的代码对象并启动内核。正如我们将在下面看到的,HIP定义了一个模块API,它对代码对象管理提供了类似的显式控制。

cuCtx API

驱动程序API将“上下文”和“设备”定义为单独的实体。上下文包含一个设备,理论上一个设备可以有多个上下文。每个上下文都包含一组特定于上下文的流和事件。历史上,上下文也为GPU定义了唯一的地址空间,但在统一内存平台中可能不再是这种情况(因为CPU和同一进程中的所有设备共享一个统一的地址空间)。上下文API还提供了一种在设备之间切换的机制,允许单个CPU线程向不同的GPU发送命令。HIP以及CUDA运行时的最新版本提供了其他机制来实现这一壮举,例如使用流或cudaSetDevice。

CUDA运行时API将上下文API与设备API统一起来。这简化了API,几乎没有功能损失,因为每个上下文都可以包含一个设备,多个上下文的好处已经被其他接口所取代。HIP提供了一个上下文API,以方便从现有驱动程序代码进行移植。在HIP中,Ctx函数在很大程度上提供了用于更改活动设备的替代语法。大多数新应用程序都倾向于使用hipSetDevice或流API,因此HIP已将hipCtx API标记为已弃用。在未来的版本中可能无法提供对这些API的支持。有关弃用API的详细信息,请参阅HIP弃用API:https://github.com/ROCm-DeveloperTools/HIP/blob/main/docs/markdown/hip_deprecated_api_list.md

HIP Module and Ctx APIs

HIP没有提供两个单独的API,而是用模块和Ctx控件的新API扩展了HIP API。

hipModule API

与CUDA驱动程序API一样,模块API提供了对代码加载方式的额外控制,包括从文件或内存指针加载代码的选项。NVCC和HIP Clang针对不同的体系结构,并使用不同的代码对象格式:NVCC是“cubin”或“ptx”文件,而HIP Clangpath是“hsaco”格式。生成这些代码对象的外部编译器负责为每个平台生成和加载正确的代码对象。值得注意的是,没有可以同时包含NVCC和HIP Clang平台代码的胖二进制格式。下表总结了每个平台上使用的格式:

Format APIs NVCC HIP-CLANG
Code Object hipModuleLoad, hipModuleLoadData .cubin or PTX text .hsaco
Fat Binary hipModuleLoadFatBin .fatbin .hip_fatbin

hipcc使用HIP-Clang或NVCC来编译主机代码。两者都可以将代码对象嵌入到最终的可执行文件中,并且这些代码对象将在应用程序启动时自动加载。hipModule API可用于加载其他代码对象,并以此方式为自动加载的代码对象提供扩展功能。如果需要,HIP-Clang允许两种功能一起使用。可以创建一个没有内核的程序,因此没有自动加载。

hipCtx API

HIP在现有设备功能上提供了一个Ctx API作为薄层。此Ctx API可用于设置当前上下文或查询与上下文关联的设备的属性。当前上下文由其他API(如hipStreamCreate)隐式使用。

hipify translation of CUDA Driver API

HIPIFY工具将用于流、事件、模块、设备、内存管理、上下文、分析器的CUDA驱动程序API转换为等效的HIP驱动程序调用。例如,cuEventCreate将被转换为hipEventCreate。HIPIFY工具还将错误代码从Driver命名空间和编码约定转换为等效的HIP错误代码。因此,HIP统一了这些公共函数的API。内存复制API需要额外的解释。CUDA驱动程序在API的名称中包含内存方向(即cuMemcpyH2D),而CUDA驱动API提供了一个具有指定方向的参数的单一内存复制API,并且还支持运行时自动确定方向的“默认”方向。HIP提供了两种样式的API:例如,hipMemcpyH2D和hipMemcpy。在某些情况下,第一种风格可能更快,因为它们避免了检测不同内存方向的主机开销。

HIP定义单个错误空间,并对所有错误使用驼峰大小写(即hipErrorInvalidValue)

HIP-Clang Implementation Notes

.hip_fatbin

hip clang将来自不同翻译单元的设备代码链接在一起。对于每个设备目标,都会生成一个代码对象。不同设备目标的代码对象由clang卸载绑定器绑定为一个fatbinary,该fatbinary作为全局符号__hip_fatbin嵌入到可执行或共享对象的ELF文件的.hip_fatbin部分中。

Initialization and Termination Functions

HIP-Clang为主机代码编译的每个翻译单元生成初始化和终止函数。初始化函数调用__hipRegisterFatBinary来注册ELF文件中嵌入的fatbinary。它们还调用__hipRegisterFunction__hipRegisterVar来注册内核函数和设备端全局变量。终止函数调用__hipUnregisterFatBinary。HIP Clang发出一个全局变量__HIP_gpubin_handle,类型为void**,带有linkonce链接,每个主机翻译单元的初始值为0。每个初始化函数检查__hip_gpubin_handle,并仅在__hip_gpubin_handle为0时注册fatbinary,并将__hip_gpubin_handle的返回值保存到__hip_gpubin_handle。这是为了保证fatbinary只注册一次。在终端功能中也进行了类似的检查。

Kernel Launching

HIP Clang支持CUDA<<<>>>语法、hipLaunchKernelhipLaunchKernelGGL启动内核。后两个是扩展到CUDA<<<>>>语法的宏。当动态链接器加载可执行或共享库时,将调用初始化函数。在初始化函数中,当调用__hipRegisterFatBinary时,将加载包含所有内核的代码对象;当调用__hipRegisterFunction时,存根函数与代码对象中的相应内核相关联。HIP Clang实现了两组启动API的内核。

默认情况下,在主机代码中,对于<<<>>>语句,hip-clang首先发出hipConfigureCall调用以设置线程和网格,然后发出带有给定参数的存根函数调用。在存根函数中,为每个内核参数调用hipSetupArgument,然后使用指向存根函数的函数指针调用hipLaunchByPtr。在hipLaunchByPtr中,与存根函数关联的真正内核被启动。

如果HIP程序是用-fhip-new-launch-api编译的,在主机代码中,对于<<<>>>语句,HIP-clang首先发出__hipPushCallConfiguration的调用,以将网格维度、块维度、共享内存使用情况和流保存到堆栈中,然后发出带有给定参数的存根函数调用。在存根函数中,调用__hipPopCallConfiguration以获取保存的网格维度、块维度、共享内存使用情况和流,然后hipLaunchKernel被调用,加上指向存根函数的函数指针。在hipLaunchKernel中,与存根函数关联的真实内核被启动。

Address Spaces

HIP Clang定义了一个进程范围的地址空间,其中CPU和所有设备从单个统一池分配地址。因此,地址可以在上下文之间共享,并且与原始CUDA定义不同,新的上下文不会为设备创建新的地址空间。

Using hipModuleLaunchKernel

hipModuleLaunchKernel是HIP世界中的cuLaunchKernel。它采用与cuLaunchKernel相同的参数。

Additional Information

HIP Clang在调用HIP API时创建主上下文。在纯驱动程序API代码中,HIPClang将创建一个主上下文,而HIP/NVCC将有一个空的上下文堆栈。HIP Clang将在主上下文为空时将其推送到上下文堆栈。这可能会在混合运行时和驱动程序API的应用程序中产生细微的差异。

NVCC Implementation Notes

Interoperation between HIP and CUDA Driver

CUDA应用程序可能希望将CUDA驱动程序代码与HIP代码混合。此表显示了启用此交互的类型等效性。

HIP Type CU Driver Type CUDA Runtime Type
hipModule_t CUmodule
hipFunction_t CUfunction
hipCtx_t CUcontext
hipDevice_t CUdevice
hipStream_t CUstream cudaStream_t
hipEvent_t CUevent cudaEvent_t
hipArray CUarray cudaArray

Compilation Options

hipModule_t接口不支持用于控制PTX编译选项的cuModuleLoadDataEx函数。HIP Clang不使用PTX,也不支持这些编译选项。HIP Clang代码对象始终包含完全编译的ISA,并且不需要作为加载步骤的一部分进行额外编译。相应的HIP函数hipModuleLoadDataEx在HIP Clang上表现为hipModuleDoadData(不使用编译选项),在NVCC路径上表现cuModuleLoadData

例如

CUDA

1
2
3
4
5
6
7
8
9
10
11
CUmodule module;
void *imagePtr = ...; // Somehow populate data pointer with code object
const int numOptions = 1;
CUJit_option options[numOptions];
void * optionValues[numOptions];
options[0] = CU_JIT_MAX_REGISTERS;
unsigned maxRegs = 15;
optionValues[0] = (void*)(&maxRegs);
cuModuleLoadDataEx(module, imagePtr, numOptions, options, optionValues);
CUfunction k;
cuModuleGetFunction(&k, module, "myKernel");

HIP

1
2
3
4
5
6
7
8
9
10
11
12
13
hipModule_t module;
void *imagePtr = ...; // Somehow populate data pointer with code object
const int numOptions = 1;
hipJitOption options[numOptions];
void * optionValues[numOptions];
options[0] = hipJitOptionMaxRegisters;
unsigned maxRegs = 15;
optionValues[0] = (void*)(&maxRegs);
// hipModuleLoadData(module, imagePtr) will be called on HIP-Clang path, JIT options will not be used, and
// cupModuleLoadDataEx(module, imagePtr, numOptions, options, optionValues) will be called on NVCC path
hipModuleLoadDataEx(module, imagePtr, numOptions, options, optionValues);
hipFunction_t k;
hipModuleGetFunction(&k, module, "myKernel");

下边的例子展示了如何使用hipModuleGetFunction:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include<hip_runtime.h>
#include<hip_runtime_api.h>
#include<iostream>
#include<fstream>
#include<vector>
#define LEN 64
#define SIZE LEN<<2
#ifdef __HIP_PLATFORM_HCC__
#define fileName "vcpy_isa.co"
#endif
#ifdef __HIP_PLATFORM_NVCC__
#define fileName "vcpy_isa.ptx"
#endif
#define kernel_name "hello_world"
int main(){
float *A, *B;
hipDeviceptr_t Ad, Bd;
A = new float[LEN];
B = new float[LEN];
for(uint32_t i=0;i<LEN;i++){
A[i] = i*1.0f;
B[i] = 0.0f;
std::cout<<A[i] << " "<<B[i]<<std::endl;
}

#ifdef __HIP_PLATFORM_NVCC__
hipInit(0);
hipDevice_t device;
hipCtx_t context;
hipDeviceGet(&device, 0);
hipCtxCreate(&context, 0, device);
#endif
hipMalloc((void**)&Ad, SIZE);
hipMalloc((void**)&Bd, SIZE);
hipMemcpyHtoD(Ad, A, SIZE);
hipMemcpyHtoD(Bd, B, SIZE);
hipModule_t Module;
hipFunction_t Function;
hipModuleLoad(&Module, fileName);
hipModuleGetFunction(&Function, Module, kernel_name);
std::vector<void*>argBuffer(2);
memcpy(&argBuffer[0], &Ad, sizeof(void*));
memcpy(&argBuffer[1], &Bd, sizeof(void*));
size_t size = argBuffer.size()*sizeof(void*);
void *config[] = {
HIP_LAUNCH_PARAM_BUFFER_POINTER, &argBuffer[0],
HIP_LAUNCH_PARAM_BUFFER_SIZE, &size,
HIP_LAUNCH_PARAM_END
};
hipModuleLaunchKernel(Function, 1, 1, 1, LEN, 1, 1, 0, 0, NULL, (void**)&config);
hipMemcpyDtoH(B, Bd, SIZE);
for(uint32_t i=0;i<LEN;i++){
std::cout<<A[i]<<" - "<<B[i]<<std::endl;
}
#ifdef __HIP_PLATFORM_NVCC__
hipCtxDetach(context);
#endif
return 0;
}

HIP Module and Texture Driver API

HIP支持纹理驱动程序API,但纹理引用应在主机范围内声明。以下代码说明了HIP_PLATFORM_HCC平台使用纹理参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Code to generate code object
#include "hip/hip_runtime.h"
extern texture<float, 2, hipReadModeElementType> tex;
__global__ void tex2dKernel(hipLaunchParm lp, float* outputData,
int width, int height)
{
int x = blockIdx.x*blockDim.x + threadIdx.x;
int y = blockIdx.y*blockDim.y + threadIdx.y;
outputData[y*width + x] = tex2D(tex, x, y);
}
// Host code:
texture<float, 2, hipReadModeElementType> tex;
void myFunc ()
{
// ...
textureReference* texref;
hipModuleGetTexRef(&texref, Module1, "tex");
hipTexRefSetAddressMode(texref, 0, hipAddressModeWrap);
hipTexRefSetAddressMode(texref, 1, hipAddressModeWrap);
hipTexRefSetFilterMode(texref, hipFilterModePoint);
hipTexRefSetFlags(texref, 0);
hipTexRefSetFormat(texref, HIP_AD_FORMAT_FLOAT, 1);
hipTexRefSetArray(texref, array, HIP_TRSA_OVERRIDE_FORMAT);
// ...
}

使用hip实现矩阵乘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
#include <stdio.h>
#include <stdlib.h>

#include <hip/hip_runtime.h>
#include <hip/hip_runtime_api.h>

#define M 4
#define K 4
#define N 4

void initial(double* list,int row,int col)
{
double *num = list;
for (int i=0; i<row*col; i++)
{
num[i] = rand()%10;
}
}

void CpuMatrix(double *A,double *B,double *C)
{
int i,j,k;

for( i=0; i<M; i++)
{
for(j=0; j<N; j++)
{
double sum = 0;
for(int k=0; k<K; k++)
{
sum += A[i*K + k] * B[k * N + j];
}
C[i * N + j] = sum;
}
}
}

__global__ void GpuMatrix(double *dev_A,double *dev_B,double *dev_C)
{
int ix = hipBlockIdx_x * hipBlockDim_x + hipThreadIdx_x;
int iy = hipBlockIdx_y * hipBlockDim_y + hipThreadIdx_y;
if(ix<K && iy<M)
{
double sum = 0;
for( int k = 0; k < K;k++)
{
sum += dev_A[iy*K + k] * dev_B[k*N + ix];
}
dev_C[iy * N + ix] = sum;
}
}

void printMatrix(double *list,int row,int col)
{
double *p = list;
for(int i=0; i<row; i++)
{
for(int j=0; j<col; j++)
{
printf("%10lf",p[j]);
}
p = p + col;
printf("\n");
}
}
int main(int argc,char **argv)
{
int Axy = M*K;
int Abytes = Axy * sizeof(double);

int Bxy = K*N;
int Bbytes = Bxy * sizeof(double);

int nxy = M*N;
int nbytes = nxy * sizeof(double);

float time_cpu,time_gpu;

clock_t start_cpu,stop_cpu;

hipEvent_t start_GPU,stop_GPU;

double *host_A, *host_B, *host_C, *c_CPU;
host_A = (double*)malloc(Abytes);
host_B = (double*)malloc(Bbytes);
host_C = (double*)malloc(nbytes);
c_CPU = (double*)malloc(nbytes);


initial(host_A,M,K);

printf("A:(%d,%d):\n",M,K);
printMatrix(host_A,M,K);

initial(host_B,K,N);

printf("B:(%d,%d):\n",K,N);
printMatrix(host_B,K,N);

// start_cpu = clock();
CpuMatrix(host_A,host_B,host_C);
// stop_cpu = clock();

printf("Host_C:(%d,%d):\n",M,N);
// printf("\nCPU time is %f(ms)\n",(float)(stop_cpu-start_cpu)/CLOCKS_PER_SEC);
printMatrix(host_C,M,N);
double *dev_A,*dev_B,*dev_C;
hipMalloc(&dev_A,Axy*sizeof(double));
hipMalloc(&dev_B,Bxy*sizeof(double));
hipMalloc(&dev_C,nxy*sizeof(double));

dim3 block(1024,1);
dim3 grid(64,64);

hipMemcpy(dev_A,host_A,Abytes,hipMemcpyDeviceToHost);
hipMemcpy(dev_B,host_B,Bbytes,hipMemcpyDeviceToHost);

hipEventCreate(&start_GPU);
hipEventCreate(&stop_GPU);
hipEventRecord(start_GPU,0);
hipLaunchKernelGGL(GpuMatrix,grid,block,0,0,dev_A,dev_B,dev_C);
hipEventRecord(stop_GPU,0);
hipEventSynchronize(start_GPU);
hipEventSynchronize(stop_GPU);
hipEventElapsedTime(&time_gpu, start_GPU,stop_GPU);
printf("\nThe time from GPU:\t%f(ms)\n", time_GPU/1000);
hipDeviceSynchronize();
hipEventDestroy(start_GPU);
hipEventDestroy(stop_GPU);

hipMemcpy(c_CPU,dev_C,nbytes,hipMemcpyDeviceToHost);
printf("device_C:(%d,%d):\n",M,N);
printMatrix(c_CPU,M,N);

hipFree(dev_A);
hipFree(dev_B);
hipFree(dev_C);
free(host_A);
free(host_B);
free(host_C);
free(c_CPU);

return 0;
}

结果如下:

img

img

使用结构体实现HIP的矩阵乘

共享内存使用__shared__内存空间说明符来分配。

共享内存应该比全局内存快得多,这在线程结构中有提及并在共享内存中有详细描述。因此,任何可以用

共享内存访问替换全局内存访问的机会都应该被利用,如下面的矩阵乘法示例所示。

下面的示例代码是不利用共享内存的矩阵乘法的简单实现。每个线程读取 A 的一行和 B 的一列,并计算 C 的相应元素,如图 9 所示。因此,A 将从全局内存中被读取 B.width 次,而 B 将被读取 A.height 次。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <hip/hip_runtime.h>
#include <hip/hip_runtime_api.h>

typedef struct{
int width;
int height;
float* elements;
}Matrix;

#define BLOCK_SIZE 4

__global__ void MatMulKernel(const Matrix,const Matrix,Matrix);

void initial(float* A,int N)
{
int i;
for(i = 0;i<N;i++)
{
A[i] = rand()%10;
}
}

void shuchu(Matrix A,int N)
{

int j=0;
for(int i=0; i < N; i++)
{
if( j == A.width)
{
printf("\n");
j = 0;
i--;
}else
{
printf("%15lf",A.elements[i]);
j++;
}
}
}

__global__ void MatMulKernel(Matrix A,Matrix B,Matrix C)
{
float Cvalue = 0;
int row = hipBlockIdx_y * hipBlockDim_y + hipThreadIdx_y;
int col = hipBlockIdx_x * hipBlockDim_x + hipThreadIdx_x;
for(int e = 0; e < A.width; ++e)
{
Cvalue += A.elements[row * A.width + e] * B.elements[e*B.width + col];
}
C.elements[row * C.width + col] = Cvalue;
}

//在CPU上计算矩阵乘
void CpuMatrix(Matrix A,Matrix B,Matrix C)
{
int M,N,K;
M = A.height;
N = B.width;
K = A.width;
int i,j,k;
for(i = 0;i < M;i++)
{
for(j = 0;j<N;j++)
{
float sum = 0.0;
for(k = 0;k<K;k++)
{
sum += A.elements[i * K + k] * B.elements[k * N + j];
}
C.elements[i * N + j] = sum;
}
}
}
void MatMul(Matrix A,Matrix B,Matrix C)
{
Matrix d_A;
Matrix d_B;
Matrix d_C;
d_A.width = A.width;
d_A.height = A.height;
d_B.width = B.width;
d_B.height = B.height;
d_C.width = C.width;
d_C.height = C.height;
size_t size_A = A.width * A.height * sizeof(float);
size_t size_B = B.width * B.height * sizeof(float);
size_t size_C = C.width * C.height * sizeof(float);

hipMalloc(&d_A.elements,size_A);
hipMalloc(&d_B.elements,size_B);
hipMalloc(&d_C.elements,size_C);
dim3 dimBlock(BLOCK_SIZE,BLOCK_SIZE);
dim3 dimGrid(B.width / dimBlock.x,A.height / dimBlock.y);

hipMemcpy(d_A.elements,A.elements,size_A,hipMemcpyHostToDevice);
hipMemcpy(d_B.elements,B.elements,size_B,hipMemcpyHostToDevice);
//测试时间
float gpu_time;
hipEvent_t start_GPU,stop_GPU;
hipEventCreate(&start_GPU);
hipEventCreate(&stop_GPU);
hipEventRecord(start_GPU,0);

hipLaunchKernelGGL(MatMulKernel,dimGrid,dimBlock,0,0,d_A,d_B,d_C);

hipEventRecord(stop_GPU,0);
hipEventSynchronize(start_GPU);
hipEventSynchronize(stop_GPU);
hipEventElapsedTime(&gpu_time,start_GPU,stop_GPU);
hipDeviceSynchronize();
printf("\nGPU spend time is: %lf(ms)\n",gpu_time/1000);
hipEventDestroy(start_GPU);
hipEventDestroy(stop_GPU);
hipMemcpy(C.elements,d_C.elements,size_C,hipMemcpyDeviceToHost);

printf("\nGPU result is :\n");
shuchu(C,C.width*C.height);
printf("\n");
hipFree(d_A.elements);
hipFree(d_B.elements);
hipFree(d_C.elements);
}
int main()
{
Matrix A;
Matrix B;
Matrix C;
A.width = BLOCK_SIZE;
A.height = BLOCK_SIZE;
B.width = BLOCK_SIZE;
B.height = BLOCK_SIZE;
C.width = BLOCK_SIZE;
C.height = BLOCK_SIZE;

int size = BLOCK_SIZE * BLOCK_SIZE;
int size_A = A.width * A.height * sizeof(float);
int size_B = B.width * B.height * sizeof(float);
int size_C = C.width * C.height * sizeof(float);

A.elements = (float *)malloc(size_A);
B.elements = (float *)malloc(size_B);
C.elements = (float *)malloc(size_C);

initial(A.elements,A.height*A.width);
printf("A:\n");
shuchu(A,A.width*A.height);

printf("\nB:\n");
initial(B.elements,B.height*B.width);
shuchu(B,B.width*B.height);

//调用CPU计算
//测试CPU的计算时间
clock_t start_CPU,stop_CPU;
double cpu_time;
start_CPU = clock();

CpuMatrix(A,B,C);
stop_CPU = clock();
//cpu_time = (double)(stop_CPU-start_CPU)/CLOCKS_PER_SEC;
//printf("\nCPU time is %lf(ms)\n",cpu_time);
printf("\nCPU result :\n");
shuchu(C,C.width*C.height);
/ shuchu(C,C.width*C.height);
printf("\n");
MatMul(A,B,C);
return 0;
}

运行结果如下:

img

利用结构体实现HIP的数组相加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <hip/hip_runtime.h>
#include <hip/hip_runtime_api.h>

typedef struct{
int width;
float* elements;
}Matrix;

#define BLOCK_SIZE 4

__global__ void MatMulKernel(const Matrix,const Matrix,Matrix);

void initial(float* A,int N)
{
int i;
for(i = 0;i<N;i++)
{
A[i] = rand()%10;
}
}

void shuchu(Matrix A,int N)
{
for(int i=0; i < N; i++)
{
printf("%10lf",A.elements[i]);
}
printf("\n");
}

__global__ void MatMulKernel(Matrix A,Matrix B,Matrix C)
{
int col = hipBlockIdx_x * hipBlockDim_x + hipThreadIdx_x;
C.elements[col] = A.elements[col]+B.elements[col];
}

void CpuMatrix(Matrix A,Matrix B,Matrix C)
{
int N;
N = B.width;
int i;
for(i=0;i<N;i++)
{
C.elements[i] = A.elements[i] + B.elements[i];
}
}
void MatMul(Matrix A,Matrix B,Matrix C)
{
Matrix d_A;
Matrix d_B;
Matrix d_C;
d_A.width = A.width;
d_B.width = B.width;
d_C.width = C.width;

size_t size_A = A.width * sizeof(float);
size_t size_B = B.width * sizeof(float);
size_t size_C = C.width * sizeof(float);

hipMalloc(&d_A.elements,size_A);
hipMalloc(&d_B.elements,size_B);
hipMalloc(&d_C.elements,size_C);
dim3 dimBlock(BLOCK_SIZE,BLOCK_SIZE);
dim3 dimGrid(1);

hipMemcpy(d_A.elements,A.elements,size_A,hipMemcpyHostToDevice);
hipMemcpy(d_B.elements,B.elements,size_B,hipMemcpyHostToDevice);

float gpu_time;
hipEvent_t start_GPU,stop_GPU;
hipEventCreate(&start_GPU);
hipEventCreate(&stop_GPU);
hipEventRecord(start_GPU,0);

hipLaunchKernelGGL(MatMulKernel,dimGrid,dimBlock,0,0,d_A,d_B,d_C);

hipEventRecord(stop_GPU,0);
hipEventSynchronize(start_GPU);
hipEventSynchronize(stop_GPU);
hipEventElapsedTime(&gpu_time,start_GPU,stop_GPU);
hipDeviceSynchronize();
printf("\nGPU spend time is: %lf(ms)\n",gpu_time/1000);
hipEventDestroy(start_GPU);
hipEventDestroy(stop_GPU);
hipMemcpy(C.elements,d_C.elements,size_C,hipMemcpyDeviceToHost);

printf("\nGPU result is :\n");
shuchu(C,C.width);
printf("\n");
hipFree(d_A.elements);
hipFree(d_B.elements);
hipFree(d_C.elements);
}
int main()
{
Matrix A;
Matrix B;
Matrix C;
A.width = BLOCK_SIZE;

B.width = BLOCK_SIZE;

C.width = BLOCK_SIZE;

int size_A = A.width * sizeof(float);
int size_B = B.width * sizeof(float);
int size_C = C.width * sizeof(float);

A.elements = (float *)malloc(size_A);
B.elements = (float *)malloc(size_B);
C.elements = (float *)malloc(size_C);

initial(A.elements,A.width);
printf("A:\n");
shuchu(A,A.width);

printf("\nB:\n");
initial(B.elements,B.width);
shuchu(B,B.width);

CpuMatrix(A,B,C);

printf("\nCPU result :\n");
shuchu(C,C.width);

printf("\n");
MatMul(A,B,C);
return 0;
}

复制代码;)

运行结果如下:

img

使用共享内存实现矩阵乘法(利用了结构体)

下面的示例代码是利用共享内存的矩阵乘法的实现.在这个实现中,每个线程块负责计算 C 的一个方形子 矩阵 Csub ,块内的每个线程负责计算 Csub 的一个元素.如图 10 所示,Csub 等于两个矩阵的乘积:维度为 (A.width, block_size)的子矩阵 A 与 Csub 有相同的行索引,维度为(block_size, A.width )的子矩阵 B 与 Csub 有相同的列索引.为了适应设备资源的需求,将这两个矩阵根据需要分为维度为 block_size 的多个 正方形矩阵.计算这些方形矩阵的乘积之和即可得到 Csub .这些乘积中的每一个的计算都是首先将两个 对应的正方形矩阵从全局内存加载到共享内存,一个线程加载一个元素,然后再让每个线程计算一个元 素.每个线程将这些乘积的结果累积到一个寄存器中,完成后再将结果写入全局内存.

通过这种方式分块计算,我们充分利用了快速的共享内存,并节省了大量的全局内存带宽,因为 A 只从全局内存中读取了(B.width / block_size)次,B 只从全局内存中读取了(A.height / block_size)次。

前一段示例代码中的矩阵类型使用了 stride 字段进行扩充,因此可以使用相同类型有效地表示子矩阵。device函数用于获取和设置元素并从矩阵中构建任何子矩阵。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#include <stdio.h>
#include <stdlib.h>
#include <hip/hip_runtime.h>
#include <hip/hip_runtime_api.h>

typedef struct{
int width;
int height;
int stride;
float* elements;
}Matrix;

#define BLOCK_SIZE 4

//初始化
void initial(float* A,int N)
{
int i;
for(i = 0;i<N;i++)
{
A[i] = rand()%10;
}
}


__device__ float GetElement(const Matrix A,int row,int col)
{
return A.elements[row*A.stride+col];
}

__device__ void SetElement(Matrix A,int row,int col,float value)
{
A.elements[row*A.stride+col]=value;
}
__device__ Matrix GetSubMatrix(Matrix A,int row,int col)
{
Matrix Asub;
Asub.width = BLOCK_SIZE;
Asub.height = BLOCK_SIZE;
Asub.stride = A.stride;
Asub.elements = &A.elements[A.stride*BLOCK_SIZE*row+BLOCK_SIZE*col];
return Asub;
}

void shuchu(Matrix A,int N)
{

int j=0;
for(int i=0; i < N; i++)
{
if( j == A.width)
{
printf("\n");
j = 0;
i--;
}else
{
printf("%15lf",A.elements[i]);
j++;
}
}
printf("\n");
}
__global__ void MatMulKernel(Matrix A,Matrix B,Matrix C)
{
int blockRow = hipBlockIdx_y;
int blockCol = hipBlockIdx_x;
Matrix Csub = GetSubMatrix(C,blockRow,blockCol);
float Cvalue = 0;
int row = hipThreadIdx_y;
int col = hipThreadIdx_x;
for(int m=0; m<(A.width/BLOCK_SIZE);++m)
{
Matrix Asub = GetSubMatrix(A,blockRow,m);
Matrix Bsub = GetSubMatrix(B,m,blockCol);
__shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
__shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];

As[row][col]=GetElement(Asub,row,col);
Bs[row][col]=GetElement(Bsub,row,col);

__syncthreads();
for(int e = 0;e<BLOCK_SIZE;++e)
{
Cvalue += As[row][e]*Bs[e][col];
}
__syncthreads();
SetElement(Csub,row,col,Cvalue);
}
}

void MatMul(const Matrix A,const Matrix B,Matrix C)
{
Matrix d_A;
d_A.width = d_A.stride = A.width;
d_A.height = A.height;
size_t size = A.width * A.height * sizeof(float);
hipMalloc(&d_A.elements,size);
hipMemcpy(d_A.elements,A.elements,size,hipMemcpyHostToDevice);

Matrix d_B;
d_B.width = d_B.stride=B.width;
d_B.height = B.height;
size = B.width * B.height * sizeof(float);
hipMalloc(&d_B.elements,size);
hipMemcpy(d_B.elements,B.elements,size,hipMemcpyHostToDevice);

Matrix d_C;
d_C.width = d_C.stride = C.width;
d_C.height = C.height;
size = C.width * C.height * sizeof(float);
hipMalloc(&d_C.elements,size);
dim3 dimBlock(BLOCK_SIZE,BLOCK_SIZE);
dim3 dimGrid(B.width / dimBlock.x,A.height / dimBlock.y);

float gpu_time;
hipEvent_t start_GPU,stop_GPU;
hipEventCreate(&start_GPU);
hipEventCreate(&stop_GPU);
hipEventRecord(start_GPU,0);

hipLaunchKernelGGL(MatMulKernel,dimGrid,dimBlock,0,0,d_A,d_B,d_C);

hipEventRecord(stop_GPU,0);
hipEventSynchronize(start_GPU);
hipEventSynchronize(stop_GPU);
hipEventElapsedTime(&gpu_time,start_GPU,stop_GPU);
hipDeviceSynchronize();
printf("\nGPU spend time is: %lf(ms)\n",gpu_time/1000);
hipEventDestroy(start_GPU);
hipEventDestroy(stop_GPU);

hipMemcpy(C.elements,d_C.elements,size,hipMemcpyDeviceToHost);
printf("\nGPU result is:\n");
shuchu(C,C.width*C.height);
hipFree(d_A.elements);
hipFree(d_B.elements);
hipFree(d_C.elements);
}

//使用CPU进行计算
void CpuMatrix(Matrix A,Matrix B,Matrix C)
{
int M,N,K;
M = A.height;
N = B.width;
K = A.width;
int i,j,k;
for(i = 0;i < M;i++)
{
for(j = 0;j<N;j++)
{
float sum = 0.0;
for(k = 0;k<K;k++)
{
sum += A.elements[i * K + k] * B.elements[k * N + j];
}
C.elements[i * N + j] = sum;
}
}
}
int main()
{
Matrix A;
Matrix B;
Matrix C;

A.width = BLOCK_SIZE;
A.height = BLOCK_SIZE;
B.width = BLOCK_SIZE;
B.height = BLOCK_SIZE;
C.width = BLOCK_SIZE;
C.height = BLOCK_SIZE;

int size_A = A.width * A.height * sizeof(float);
int size_B = B.width * B.height * sizeof(float);
int size_C = C.width * C.height * sizeof(float);

A.elements = (float *)malloc(size_A);
B.elements = (float *)malloc(size_B);
C.elements = (float *)malloc(size_C);

initial(A.elements,A.height*A.width);
printf("A:\n");
shuchu(A,A.width*A.height);

printf("\nB:\n");
initial(B.elements,B.height*B.width);
shuchu(B,B.width*B.height);

CpuMatrix(A,B,C);
printf("\nCPU result :\n");
shuchu(C,C.width*C.height);
MatMul(A,B,C);
return 0;
}

运行结果如下:

img

TAU

TAU是一个面向MPI与OpenMP并行程序的profiler,在目前看到的OpenMPI的Profiler中算是比较健全的一个(x)。官网:https://www.cs.uoregon.edu/research/tau/home.php

其实Intel的vtune也不是不能用,但是面向OpenMPI的时候会有些限制。TAU可以根据不同的MPI发行版重新编译,感觉会好一些。

安装

官方的INSTALL中给出的configure建议参数:

1
Copy% ./configure -c++=mpicxx -cc=mpicc -fortran=mpif90 -mpi -ompt -iowrapper -bfd=download -dwarf=download -otf=download -unwind=download -pdt=<path_to_pdt_dir>

pdt不使用,因为是一个挺大的东西,目前还没用过。而且一般profile到第二层就足够了(见使用部分)。ompt也有比较大的概率不支持,可以直接关掉。ompt是貌似是OpenMP提供给外部工具的一个接口,如果没有OpenMP成分的话关掉没什么影响。(https://www.openmp.org/spec-html/5.0/openmpsu15.html)

之后发现BFD也是直接下载不了,关了。没发现有什么用。

最后改成:

1
Copy./configure -c++=mpicxx -cc=mpicc -fortran=mpif90 -mpi -iowrapper -dwarf=download -otf=download -unwind=download -prefix=<prefix>

configure结束之后有提示。根据提示添加一下PATH,然后直接make install即可。TAU的编译过程是内含在install过程中的。

在make install的过程中,大概率是要报错的。这也是TAU比较令我无语的一个地方……它的动态库编译flag基本上都是错的。需要进行一个手动修改。

在主目录的Makefile下,在Line 195左右的位置,把编译命令换成:

(-diag-disable是针对intel编译器的,如果不是intel编译器记得去掉。其实都可以去掉,但是现在用icc不加这个选项就会报出一堆的warning,看着心烦)

1
2
3
Copy@echo "*********** RECURSIVELY MAKING SUBDIRECTORIES ***********"
@for i in ${SUBDIR}; do (echo "*** COMPILING $$i DIRECTORY"; cd $$i;\
$(MAKE) "MAKE=$(MAKE)" "CC=$(CC) -diag-disable=10441 -fPIC" "TAU_F90=$(TAU_F90) -fPIC" "TAU_CC=$(TAU_CC) -fPIC" "CXX=$(CXX) -diag-disable=10441 -fPIC" "AR_SHFLAGS=-shared" "TAU_SHFLAGS=-shared -o" install ) || exit $$?; done

这里其实就是改了一堆flag。这样编译就可以正常通过了。至此完成安装。

使用

TAU提供了三种详细程度不同的profile:

截屏2023-03-24 上午10.09.29

简单来说,三种profile方式详细程度逐渐提高,但最粗略的不需要重新编译,第二种需要重新编译,最详细的一种要求使用PDT。

interposition

这个最简单。从

1
Copympirun <mpi-args> <program>

换成

1
Copympirun <mpi-args> tau_exec <program> 

即可。熟悉vtune的话会觉得差不多。

这样的方式可以得到各个MPI函数的占用的时间,但是没法得到callpath等更细致的信息。

recompile

需要重新编译。

TAU的configure方式比较特殊。如果在配置完一个TAU之后,再在相同的目录下configure一个配置不同的TAU,其内容不会被覆盖,而是两个版本并存。TAU通过一个Makefile来决定使用哪种配置,Makefile在<prefix>/x86_64/lib下。

相应控制TAU的配置的环境变量是TAU_MAKEFILE.

TAU提供的编译器是tau_cc.sh,tau_f90.sh等,其实就是一层wrapper,根据MAKEFILE的内容来指定底层实际的编译器。用这几个命令来编译的时候也可以看到,就是多连接了一些库。

recompile之后不需要额外的运行参数,只需要正常运行就能生成profile文件。不过说实话,如果不开启一些环境变量,感觉和interposition相比也没有丰富多少。

1
CopyPROFILEDIR=$PWD/tau-pro mpirun --host f0104:72,f0105:72 -n 144 <program>

有用的环境变量包括:

TAU_TRACE:开启tracing。生成文件的格式会改变,并且会变得很大。此时文件夹由TRACEDIR决定。

TAU_COMM_MATRIX:为1时启动通讯记录。这样profile中会记录下哪个进程与哪个进程进行了多少通讯,比较直观。

TAU_CALLPATH:为1时追踪函数的调用记录。没有开的时候,只会记录下这个函数总的调用花了多少时间;开了之后会记录从哪个函数中调用这个函数花了多少时间。不过,在recompile等级下好像也没法追踪到自定义的函数体(可能是因为我profile的是fortran的代码?不清楚),并且展开之后会比较乱,也不一定好用。

最后得到的文件使用paraprof工具可以进行可视化。能实现的比较有用的功能:

  • 单个进程的各个MPI函数调用时间排序;
  • 单个MPI函数在各个进程上调用时间分布直方图;
  • Communication Matrix。

测试过程

动态插桩(Dynamic instrumentation)

在mpirun的命令中插入一个tau_exec,实现动态插桩。普通的MPI运行命令。后面是一系列程序运行的参数:

1
mpirun -np 8 ./swap -k 19 -c 5 -i ./data/S.aureus.fasta -o Saur_k19_c5

加了tau_exec之后的运行命令:

1
mpirun -np 8 tau_exec ./swap -k 19 -c 5 -i ./data/S.aureus.fasta -o Saur_k19_c5

接着目录下会多了几个类似于profile.0.0.0的文件。直接在当前目录下执行pprof命令:

1
pprof

显示结果如下图所示:

在这里插入图片描述

这种方法只能够查看到MPI的函数调用情况,并不能看到用户的自定义函数的调用情况。因此不太推荐这种插桩方法。

源码插桩(Source instrumentation)

直接在源码中进行插桩。

首先,要选择我们想要借助TAU获得的信息(e.g. MPI support, tracing, CUDA hardware counters, etc)。我们要将TAU_MAKEFILE变量设置为相应的pdt。因为我们现在使用TAU来测MPI程序的信息,因此将TAU_MAKEFILE变量设为tau-mpi-pdt:

1
export TAU_MAKEFILE=$TAU_HOME/lib/Makefile.tau-mpi-pdt

接着,使用tau_cc.sh或者tau_cxx.sh而不是使用mpicc或者mpicxx来编译cpp文件。以下代码是从别处抄来的,因为我测的这个程序使用MakeFile文件来进行编译的,我就直接在MakeFile文件中进行修改,将mpicxx替换成tau_cxx.sh。

1
tau_cxx.sh wave2d.cpp -o wave2d

编译完成后,还是使用mpirun运行:
1
mpirun -np 4 tau_exec ./swap -k 19 -c 5 -i ./data/S.aureus.fasta -o Saur_k19_c5

接着就是使用各种可视化工具来对性能测试的数据进行可视化。pprof是一个基于文本的可视化工具。先使用pprof试试:

1
pprof

可以看出确实多了很多用户自定义函数的执行情况,而不是只限于MPI函数。但是可能是因为没有解析出来的缘故,很多函数都只是给出了地址,而没有给出函数名字。

基于编译器的插桩(Compiler-based instrumentation)和可选择代码区域的插桩(Selective instrumentation

基于编译器的插桩介于Source和Dynamic之间。而选择代码区域的插桩大致就是在代码中指定一块区域。两个我都没怎么使用过,就不介绍了。文档中还是推荐使用源码(Source)插桩。

GDB

贴原文链接:https://blog.csdn.net/zb872676223/article/details/37906049

原理

在前面几节,我们讲了gdb的命令,以及这些命令在调试时候的作用,并以例子进行了演示。作为C/C++ coder,要知其然,更要知其所以然。所以,借助本节,我们大概讲下GDB调试的原理。

gdb 通过系统调用 ptrace 来接管一个进程的执行。ptrace 系统调用提供了一种方法使得父进程可以观察和控制其它进程的执行,检查和改变其核心映像以及寄存器。它主要用来实现断点调试和系统调用跟踪。

ptrace系统调用定义如下:

1
2
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data)

  • pid_t pid:指示 ptrace 要跟踪的进程
  • void *addr:指示要监控的内存地址
  • enum __ptrace_request request:决定了系统调用的功能,几个主要的选项:
    • PTRACE_TRACEME:表示此进程将被父进程跟踪,任何信号(除了 SIGKILL)都会暂停子进程,接着阻塞于 wait() 等待的父进程被唤醒。子进程内部对 exec() 的调用将发出 SIGTRAP 信号,这可以让父进程在子进程新程序开始运行之前就完全控制它
    • PTRACE_ATTACH:attach 到一个指定的进程,使其成为当前进程跟踪的子进程,而子进程的行为等同于它进行了一次 PTRACE_TRACEME 操作。但需要注意的是,虽然当前进程成为被跟踪进程的父进程,但是子进程使用 getppid() 的到的仍将是其原始父进程的pid
    • PTRACE_CONT:继续运行之前停止的子进程。可同时向子进程交付指定的信号

调试原理

运行并调试新进程,步骤如下:

  • 运行gdb exe
  • 输入run命令,gdb执行以下操作:
  • 通过fork()系统调用创建一个新进程
  • 在新创建的子进程中执行ptrace(PTRACE_TRACEME, 0, 0, 0)操作
  • 在子进程中通过execv()系统调用加载指定的可执行文件

attach运行的进程

可以通过gdb attach pid来调试一个运行的进程,gdb将对指定进程执行ptrace(PTRACE_ATTACH, pid, 0, 0)操作。需要注意的是,当我们attach一个进程id时候,可能会报如下错误:

1
2
Attaching to process 28849
ptrace: Operation not permitted.

这是因为没有权限进行操作,可以根据启动该进程用户下或者root下进行操作。

断点原理

实现原理

当我们通过b或者break设置断点时候,就是在指定位置插入断点指令,当被调试的程序运行到断点的时候,产生SIGTRAP信号。该信号被gdb捕获并 进行断点命中判断。

设置原理

在程序中设置断点,就是先在该位置保存原指令,然后在该位置写入int 3。当执行到int 3时,发生软中断,内核会向子进程发送SIGTRAP信号。当然,这个信号会转发给父进程。然后用保存的指令替换int 3并等待操作恢复。

命中判断

gdb将所有断点位置存储在一个链表中。命中判定将被调试程序的当前停止位置与链表中的断点位置进行比较,以查看断点产生的信号。

条件判断

在断点处恢复指令后,增加了一个条件判断。如果表达式为真,则触发断点。由于需要判断一次,添加条件断点后,是否触发条件断点,都会影响性能。在 x86 平台上,部分硬件支持硬件断点。不是在条件断点处插入 int 3,而是插入另一条指令。当程序到达这个地址时,不是发出int 3信号,而是进行比较。特定寄存器的内容和某个地址,然后决定是否发送int 3。因此,当你的断点位置被程序频繁“通过”时,尽量使用硬件断点,这将有助于提高性能。

单步原理

这个ptrace函数本身就支持,可以通过ptrace(PTRACE_SINGLESTEP, pid,…)调用来实现单步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
printf("attaching to PID %d\n", pid);
if (ptrace(PTRACE_ATTACH, pid, 0, 0) != 0)
{
perror("attach failed");
}
int waitStat = 0;
int waitRes = waitpid(pid, &waitStat, WUNTRACED);
if (waitRes != pid || !WIFSTOPPED(waitStat))
{
printf("unexpected waitpid result!\n");
exit(1);
}

int64_t numSteps = 0;
while (true) {
auto res = ptrace(PTRACE_SINGLESTEP, pid, 0, 0);
}

上述代码,首先接收一个pid,然后对其进行attach,最后调用ptrace进行单步调试。

测试程序

我们先看看我们的测试程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/* in eg1.c */
int wib(int no1, int no2)
{
int result, diff;
diff = no1 - no2;
result = no1 / diff;
return result;
}

int main()
{
pid_t pid;

pid = fork();
if (pid <0) {
printf("fork err\n");
exit(-1);
} else if (pid == 0) {
/* in child process */
sleep(60); ------------------ (!)

int value = 10;
int div = 6;
int total = 0;
int i = 0;
int result = 0;

for (i = 0; i < 10; i++) {
result = wib(value, div);
total += result;
div++;
value--;
}
printf("%d wibed by %d equals %d\n", value, div, total);
exit(0);
} else {
/* in parent process */
sleep(4);
wait(-1);
exit(0);
}
}

该测试程序中子进程运行过程中会在wib函数中出现一个’除0’异常。现在我们就要调试该子进程。

调试原理

不知道大家发现没有,在(!)处在我们的测试程序在父进程fork后,子进程调用sleep睡了60秒。这就是关键,这个sleep本来是不该存在于子进程代码中的,而是而了使用GDB调试后加入的,它是我们调试的一个关键点。为什么要让子进程刚刚运行就开始sleep呢?因为我们要在子进程睡眠期间,利用 shell命令获取其process id,然后再利用gdb调试外部进程的方法attach到该process id上,调试该进程。
我们现在调试的是mpi程序,intel的mpiexec可以直接-gdb进行调试,但是用gnu的话就不行了,只能gdb attach来调试,下述。

调试过程

GDB 调试程序的前提条件就是你编译程序时必须加入调试符号信息,即使用’-g’编译选项。首先编译我们的源程序gcc -g -o eg1 eg1.c。编译好之后,我们就有了我们的调试目标eg1。由于我们在调试过程中需要多个工具配合,所以你最好多打开几个终端窗口,另外一点需要注意的是最好在eg1的working directory下执行gdb程序,否则gdb回提示’No symbol table is loaded’。你还得手工load symbol table。好了,下面我们就’按部就班’的开始调试我们的eg1。

执行eg1:eg1 & --- 让eg1后台运行

查找进程id:ps -fu YOUR_USER_NAME

或在linux下使用getpid()函数

运行gdb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
gdb
(gdb) attach xxxxx --- xxxxx为利用ps命令获得的子进程process id
(gdb) stop --- 这点很重要,你需要先暂停那个子进程,然后设置一些断点和一些Watch
(gdb) break 37 -- 在result = wib(value, div);这行设置一个断点,可以使用list命令察看源代码
Breakpoint 1 at 0x10808: file eg1.c, line 37.
(gdb) continue
Continuing.

Breakpoint 1, main () at eg1.c:37
37 result = wib(value, div);
(gdb) step
wib (no1=10, no2=6) at eg1.c:13
13 diff = no1 - no2;
(gdb) continue
Continuing.

Breakpoint 1, main () at eg1.c:37
37 result = wib(value, div);
(gdb) step
wib (no1=9, no2=7) at eg1.c:13
13 diff = no1 - no2;
(gdb) continue
Continuing.

Breakpoint 1, main () at eg1.c:37
37 result = wib(value, div);
(gdb) step
wib (no1=8, no2=8) at eg1.c:13
13 diff = no1 - no2;
(gdb) next
14 result = no1 / diff;
(gdb) print diff
$6 = 0 ------- 除数为0,我们找到罪魁祸首了。
(gdb) next
Program received signal SIGFPE, Arithmetic exception.
0xff29d830 in .div () from /usr/lib/libc.so.1

至此,我们调试完毕。

GDB调试精粹

一、列文件清单

list / l
列出产生执行文件的源代码的一部分

1
2
3
4
5
6
7
8
9
10
11
//列出 line1 到 line2 行之间的源代码  
(gdb) list line1, line2

//输出从上次调用list命令开始往后的10行程序代码
(gdb) list

//输出第 n 行附近的10行程序代码
(gdb) list n

//输出函数function前后的10行程序代码
(gdb) list function

二、执行程序

run / r
运行准备调试的程序,在它后面可以跟随发给该程序的任何参数,包括标准输入和标准输出说明符(<和>)和shell通配符(*、?、[、])在内。
如果你使用不带参数的run命令,gdb就再次使用你给予前一条run命令的参数,这是很有用的。

set args
命令就可以修改发送给程序的参数,而使用

show args
命令就可以查看其缺省参数的列表。

1
2
(gdb) set args –b –x  
(gdb) show args

三、显示数据

print / p
查看变量的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//利用print 命令可以检查各个变量的值。  
(gdb) print p (p为变量名)
print 是 gdb 的一个功能很强的命令,利用它可以显示被调试的语言中任何有效的表达式。表达式除了包含你程序中的变量外,还可以包含以下内容:

//对程序中函数的调用
(gdb) print find_entry(1, 0)

//数据结构和其他复杂对象
(gdb) print *table_start
$8={e=reference=’\000’,location=0x0,next=0x0}

//值的历史成分
(gdb)print $1 ($1为历史记录变量,在以后可以直接引用 $1 的值)
whatis

查看变量的类型

//whatis 命令可以显示某个变量的类型
(gdb) whatis p
type = int *

四、设置与清除断点

break / b
可以用来在调试的程序中设置断点,该命令有如下四种形式:

1
2
3
4
5
6
7
8
9
10
11
12
//使程序恰好在执行给定行之前停止  
break line-number

//使程序恰好在进入指定的函数之前停止

break function-name

//如果condition(条件)是真,程序到达指定行或函数时停止
break line-or-function if condition

//在指定例程的入口处设置断点
break routine-name

如果该程序是由很多原文件构成的,你可以在各个原文件中设置断点,而不是在当前的原文件中设置断点,其方法如下:

1
2
3
4
(gdb) break filename:line-number  

(gdb) break filename:function-name
break if

要想设置一个条件断点,可以利用break if命令,如下所示:
1
2
3
4
(gdb) break line-or-function if expr  

(gdb) break 46 if testsize==100
clean number

清除原文件中某一代码行上的所有断点

注:number 为原文件的某个代码行的行号

五、断点的管理

断点是我们在调试中经常用的一个功能,我们在指定位置设置断点之后,程序运行到该位置将会暂停,这个时候我们就可以对程序进行更多的操作,比如查看变量内容,堆栈情况等等,以帮助我们调试程序。

以设置断点的命令分为以下几类:

  • breakpoint
  • watchpoint
  • catchpoint
  1. 显示当前gdb的断点信息
    info break

  2. delete 删除指定的某个断点
    delete breakpoint

1
2
3
4
5
//该命令将会删除编号为1的断点  
(gdb) delete breakpoint 1

//如果不带编号参数,将删除所有的断点
(gdb) delete breakpoint
  1. 禁止、允许使用某个断点
    1
    2
    disable breakpoint 1
    enable breakpoint 1
    该命令将禁止、允许断点 1,同时断点信息的 (Enb)域将变为 n、y

breakpoint可以根据行号、函数、条件生成断点,下面是相关命令以及对应的作用说明:

命令 作用
break [file]:function 在文件file的function函数入口设置断点
break [file]:line 在文件file的第line行设置断点
info breakpoints 查看断点列表
break [+-]offset 在当前位置偏移量为[+-]offset处设置断点
break *addr 在地址addr处设置断点
break … if expr 设置条件断点,仅仅在条件满足时
ignore n count 接下来对于编号为n的断点忽略count次
clear 删除所有断点
clear function 删除所有位于function内的断点
delete n 删除指定编号的断点
enable n 启用指定编号的断点
disable n 禁用指定编号的断点
save breakpoints file 保存断点信息到指定文件
source file 导入文件中保存的断点信息
break 在下一个指令处设置断点
clear [file:]line 删除第line行的断点

watchpoint是一种特殊类型的断点,类似于正常断点,是要求GDB暂停程序执行的命令。区别在于watchpoint没有驻留某一行源代码中,而是指示GDB每当某个表达式改变了值就暂停执行的命令。

watchpoint分为硬件实现和软件实现两种。前者需要硬件系统的支持;后者的原理就是每步执行后都检查变量的值是否改变。GDB在新建数据断点时会优先尝试硬件方式,如果失败再尝试软件实现。

命令 作用
watch variable 设置变量数据断点
watch var1 + var2 设置表达式数据断点
rwatch variable 设置读断点,仅支持硬件实现
awatch variable 设置读写断点,仅支持硬件实现
info watchpoints 查看数据断点列表
set can-use-hw-watchpoints 0 强制基于软件方式实现

使用数据断点时,需要注意:

  • 当监控变量为局部变量时,一旦局部变量失效,数据断点也会失效
  • 如果监控的是指针变量p,则watch *p监控的是p所指内存数据的变化情况,而watch p监控的是p指针本身有没有改变指向

最常见的数据断点应用场景:「定位堆上的结构体内部成员何时被修改」。由于指针一般为局部变量,为了解决断点失效,一般有两种方法。

命令 作用
print &variable 查看变量的内存地址
watch (type )address 通过内存地址间接设置断点
watch -l variable 指定location参数
watch variable thread 1 仅编号为1的线程修改变量var值时会中断

catchpoint从字面意思理解,是捕获断点,其主要监测信号的产生。例如c++的throw,或者加载库的时候,产生断点行为。

命令 含义
catch fork 程序调用fork时中断
tcatch fork 设置的断点只触发一次,之后被自动删除
catch syscall ptrace 为ptrace系统调用设置断点

六、单步执行

next / n
不进入的单步执行

step
进入的单步执行

finish
如果已经进入了某函数,而想退出该函数返回到它的调用函数中,可使用命令finish

until
结束当前循环

七、函数的调用

call name
调用和执行一个函数

1
2
3
(gdb) call gen_and_sork( 1234,1,0 )  
(gdb) call printf(“abcd”)
$1=4

八、 原文件的搜索

1
search text

该命令可显示在当前文件中包含text串的下一行。

1
reverse-search text

该命令可以显示包含text 的前一行。

小结:常用的 gdb 命令

backtrace / bt 显示程序中的当前位置和表示如何到达当前位置的栈跟踪(同义词:where)

breakpoint / b 在程序中设置一个断点

cd 改变当前工作目录

clear 删除刚才停止处的断点

commands 命中断点时,列出将要执行的命令

continue 从断点开始继续执行

delete 删除一个断点或监测点;也可与其他命令一起使用

display 程序停止时显示变量和表达时

down 下移栈帧,使得另一个函数成为当前函数

frame 选择下一条continue命令的帧

info 显示与该程序有关的各种信息

jump 在源程序中的另一点开始运行

kill 异常终止在gdb 控制下运行的程序

list 列出相应于正在执行的程序的原文件内容

next 执行下一个源程序行,从而执行其整体中的一个函数

print 显示变量或表达式的值

pwd 显示当前工作目录

ptype 显示一个数据结构(如一个结构或C++类)的内容

quit 退出gdb

reverse-search 在源文件中反向搜索正规表达式

run 执行该程序

search 在源文件中搜索正规表达式

set variable 给变量赋值

signal 将一个信号发送到正在运行的进程

step 执行下一个源程序行,必要时进入下一个函数

undisplay display 命令的反命令,不要显示表达式

until 结束当前循环

up 上移栈帧,使另一函数成为当前函数

watch 在程序中设置一个监测点(即数据断点)

whatis 显示变量或函数类型

九、查看运行时数据

在你调试程序时,当程序被停住时,你可以使用print命令(简写命令为p),或是同义命令inspect来查看当前程序的运行数据。print命令的格式是:
print

print /是表达式,是你所调试的程序的语言的表达式(GDB可以调试多种编程语言),是输出的格式,比如,如果要把表达式按16进制的格式输出,那么就是/x。

  1. 表达式
    print和许多GDB的命令一样,可以接受一个表达式,GDB会根据当前的程序运行的数据来计算这个表达式,既然是表达式,那么就可以是当前程序运行中的const常量、变量、函数等内容。可惜的是GDB不能使用你在程序中所定义的宏。表达式的语法应该是当前所调试的语言的语法,由于C/C++是一种大众型的语言,所以,本文中的例子都是关于C/C++的。(而关于用GDB调试其它语言的章节,我将在后面介绍)。在表达式中,有几种GDB所支持的操作符,它们可以用在任何一种语言中。

@是一个和数组有关的操作符,在后面会有更详细的说明。 ::指定一个在文件或是一个函数中的变量。 {}表示一个指向内存地址的类型为type的一个对象。

  1. 程序变量
    在GDB中,你可以随时查看以下三种变量的值:
  2. 全局变量(所有文件可见的)
  3. 静态全局变量(当前文件可见的)
  4. 局部变量(当前Scope可见的)

如果你的局部变量和全局变量发生冲突(也就是重名),一般情况下是局部变量会隐藏全局变量,也就是说,如果一个全局变量和一个函数中的局部变量同名时,如果当前停止点在函数中,用print显示出的变量的值会是函数中的局部变量的值。如果此时你想查看全局变量的值时,你可以使用“::”操作符:

1
2
file::variable 
function::variable

可以通过这种形式指定你所想查看的变量,是哪个文件中的或是哪个函数中的。例如,查看文件f2.c中的全局变量x的值:
1
(gdb) p 'f2.c'::x 

当然,“::”操作符会和C++中的发生冲突,GDB能自动识别“::” 是否C++的操作符,所以你不必担心在调试C++程序时会出现异常。 另外,需要注意的是,如果你的程序编译时开启了优化选项,那么在用GDB调试被优化过的程序时,可能会发生某些变量不能访问,或是取值错误码的情况。这个是很正常的,因为优化程序会删改你的程序,整理你程序的语句顺序,剔除一些无意义的变量等,所以在GDB调试这种程序时,运行时的指令和你所编写指令就有不一样,也就会出现你所想象不到的结果。对付这种情况时,需要在编译程序时关闭编译优化。一般来说,几乎所有的编译器都支持编译优化的开关,例如,GNU的C/C++编译器GCC,你可以使用“-gstabs”选项来解决这个问题。关于编译器的参数,还请查看编译器的使用说明文档。

  1. 数组
    有时候,你需要查看一段连续的内存空间的值。比如数组的一段,或是动态分配的数据的大小。你可以使用GDB的“@”操作符,“@”的左边是第一个内存的地址的值,“@”的右边则你你想查看内存的长度。例如,你的程序中有这样的语句:
    int *array = (int *) malloc (len * sizeof (int));
    于是,在GDB调试过程中,你可以以如下命令显示出这个动态数组的取值:

    1
    p *array@len 

    @的左边是数组的首地址的值,也就是变量array所指向的内容,右边则是数据的长度,其保存在变量len中,其输出结果,大约是下面这个样子的:

    1
    2
    (gdb) p *array@len 
    $1 = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40}

    如果是静态数组的话,可以直接用print数组名,就可以显示数组中所有数据的内容了。

  2. 输出格式
    一般来说,GDB会根据变量的类型输出变量的值。但你也可以自定义GDB的输出的格式。例如,你想输出一个整数的十六进制,或是二进制来查看这个整型变量的中的位的情况。要做到这样,你可以使用GDB的数据显示格式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    x 按十六进制格式显示变量。 
    d 按十进制格式显示变量。
    u 按十六进制格式显示无符号整型。
    o 按八进制格式显示变量。
    t 按二进制格式显示变量。
    a 按十六进制格式显示变量。
    c 按字符格式显示变量。
    f 按浮点数格式显示变量。
    (gdb) p i
    $21 = 101
    (gdb) p/a i
    $22 = 0x65
    (gdb) p/c i
    $23 = 101 'e'
    (gdb) p/f i
    $24 = 1.41531145e-43
    (gdb) p/x i
    $25 = 0x65
    (gdb) p/t i
    $26 = 1100101
  3. 查看内存
    你可以使用examine命令(简写是x)来查看内存地址中的值。x命令的语法如下所示:
    1
    x/ 
    n、f、u是可选的参数。
    n 是一个正整数,表示显示内存的长度,也就是说从当前地址向后显示几个地址的内容。 f 表示显示的格式,参见上面。如果地址所指的是字符串,那么格式可以是s,如果地址是指令地址,那么格式可以是i。u 表示从当前地址往后请求的字节数,如果不指定的话,GDB默认是4个bytes。u参数可以用下面的字符来代替,b表示单字节,h表示双字节,w表示四字节,g表示八字节。当我们指定了字节长度后,GDB会从指内存定的内存地址开始,读写指定字节,并把其当作一个值取出来。

n/f/u三个参数可以一起使用。例如:

  • 命令:x/3uh 0x54320 表示,从内存地址0x54320读取内容,h表示以双字节为一个单位,3表示三个单位,u表示按十六进制显示。
  1. 自动显示
    你可以设置一些自动显示的变量,当程序停住时,或是在你单步跟踪时,这些变量会自动显示。相关的GDB命令是display。
    display
    格式i和s同样被display支持,一个非常有用的命令是:
    display/i $pc
    $pc是GDB的环境变量,表示着指令的地址,/i则表示输出格式为机器指令码,也就是汇编。于是当程序停下后,就会出现源代码和机器指令码相对应的情形,这是一个很有意思的功能。
    info display
    查看display设置的自动显示的信息。GDB会打出一张表格,向你报告当然调试中设置了多少个自动显示设置,其中包括,设置的编号,表达式,是否enable。

  2. 设置显示选项
    GDB中关于显示的选项比较多,这里我只例举大多数常用的选项。

    1
    2
    set print address 
    set print address on

    打开地址输出,当程序显示函数信息时,GDB会显出函数的参数地址。系统默认为打开的,
    如:

    1
    2
    3
    4
    5
    (gdb) f 
    #0 set_quotes (lq=0x34c78 "<<", rq=0x34c88 ">>")
    at input.c:530
    530 if (lquote != def_lquote)
    set print address off

关闭函数的参数地址显示,如:

1
2
3
4
(gdb) set print addr off 
(gdb) f
#0 set_quotes (lq="<<", rq=">>") at input.c:530
530 if (lquote != def_lquote)

1
show print address 

查看当前地址显示选项是否打开。

1
2
set print array 
set print array on

打开数组显示,打开后当数组显示时,每个元素占一行,如果不打开的话,每个元素则以逗号分隔。这个选项默认是关闭的。与之相关的两个命令如下,我就不再多说了。
set print array off
show print array

1
set print elements 

这个选项主要是设置数组的,如果你的数组太大了,那么就可以指定一个来指定数据显示的最大长度,当到达这个长度时,GDB就不再往下显示了。如果设置为0,则表示不限制。

1
show print elements

查看print elements的选项信息。

1
set print null-stop 

如果打开了这个选项,那么当显示字符串时,遇到结束符则停止显示。这个选项默认为off。

1
set print pretty on 

如果打开printf pretty这个选项,那么当GDB显示结构体时会比较漂亮。如:

1
2
3
4
5
6
7
8
$1 = { 
next = 0x0,
flags = {
sweet = 1,
sour = 1
},
meat = 0x54 "Pork"
}

1
set print pretty off 

关闭printf pretty这个选项,GDB显示结构体时会如下显示:

1
$1 = {next = 0x0, flags = {sweet = 1, sour = 1}, meat = 0x54 "Pork"} 

show print pretty查看GDB是如何显示结构体的。

set print sevenbit-strings
设置字符显示,是否按“/nnn”的格式显示,如果打开,则字符串或字符数据按/nnn显示,
如“/065”。

show print sevenbit-strings
查看字符显示开关是否打开。

set print union
设置显示结构体时,是否显式其内的联合体数据。例如有以下数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef enum {Tree, Bug} Species; 
typedef enum {Big_tree, Acorn, Seedling} Tree_forms;
typedef enum {Caterpillar, Cocoon, Butterfly}
Bug_forms;
struct thing {
Species it;
union {

Tree_forms tree;
Bug_forms bug;
} form;
};
struct thing foo = {Tree, {Acorn}};

当打开这个开关时,执行 p foo 命令后,会如下显示:
$1 = {it = Tree, form = {tree = Acorn, bug = Cocoon}}
当关闭这个开关时,执行 p foo 命令后,会如下显示:
$1 = {it = Tree, form = {...}}

show print union
查看联合体数据的显示方式
set print object
在C++中,如果一个对象指针指向其派生类,如果打开这个选项,GDB会自动按照虚方法调用的规则显示输出,如果关闭这个选项的话,GDB就不管虚函数表了。这个选项默认是off。

show print object
查看对象选项的设置。

set print static-members
这个选项表示,当显示一个C++对象中的内容是,是否显示其中的静态数据成员。默认是on。

show print static-members
查看静态数据成员选项设置。

set print vtbl
当此选项打开时,GDB将用比较规整的格式来显示虚函数表时。其默认是关闭的。

show print vtbl
查看虚函数显示格式的选项。

  1. 历史记录
    当你用GDB的print查看程序运行时的数据时,你每一个print都会被GDB记录下来。GDB会以$1, $2, $3 …..这样的方式为你每一个print命令编上号。于是,你可以使用这个编号访问以前的表达式,如$1。这个功能所带来的好处是,如果你先前输入了一个比较长的表达式,如果你还想查看这个表达式的值,你可以使用历史记录来访问,省去了重复输入。

  2. GDB环境变量
    你可以在GDB的调试环境中定义自己的变量,用来保存一些调试程序中的运行数据。要定义一个GDB的变量很简单只需。使用GDB的set命令。GDB的环境变量和UNIX一样,也是以$起头。如:set $foo = *object_ptr

使用环境变量时,GDB会在你第一次使用时创建这个变量,而在以后的使用中,则直接对其賦值。环境变量没有类型,你可以给环境变量定义任一的类型。包括结构体和数组。

show convenience
该命令查看当前所设置的所有的环境变量。
这是一个比较强大的功能,环境变量和程序变量的交互使用,将使得程序调试更为灵活便捷。
例如:

1
2
set $i = 0 
print bar[$i++]->contents

于是,当你就不必,print bar[0]->contents, print bar[1]->contents地输入命令了。输入这样的命令后,只用敲回车,重复执行上一条语句,环境变量会自动累加,从而完成逐个输出的功能。

  1. 查看寄存器
    要查看寄存器的值,很简单,可以使用如下命令:

info registers
查看寄存器的情况。(除了浮点寄存器)

info all-registers
查看所有寄存器的情况。(包括浮点寄存器)

info registers
查看所指定的寄存器的情况。
寄存器中放置了程序运行时的数据,比如程序当前运行的指令地址(ip),程序的当前堆栈地址(sp)等等。你同样可以使用print命令来访问寄存器的情况,只需要在寄存器名字前加一个$符号就可以了。如:p $eip。

  1. 改变程序的执行

一旦使用GDB挂上被调试程序,当程序运行起来后,你可以根据自己的调试思路来动态地在GDB中更改当前被调试程序的运行线路或是其变量的值,这个强大的功能能够让你更好的调试你的程序,比如,你可以在程序的一次运行中走遍程序的所有分支。

修改变量值
修改被调试程序运行时的变量值,在GDB中很容易实现,使用GDB的print命令即可完成。
如:

1
(gdb) print x=4 

x=4这个表达式是C/C++的语法,意为把变量x的值修改为4,如果你当前调试的语言是Pascal,那么你可以使用Pascal的语法:x:=4。
在某些时候,很有可能你的变量和GDB中的参数冲突,如:

1
2
3
4
5
6
(gdb) whatis width 
type = double
(gdb) p width
$4 = 13
(gdb) set width=47
Invalid syntax in expression.

因为,set width是GDB的命令,所以,出现了“Invalid syntax in expression”的设置错误,此时,你可以使用set var命令来告诉GDB,width不是你GDB的参数,而是程序的变量名,如:
(gdb) set var width=47

另外,还可能有些情况,GDB并不报告这种错误,所以保险起见,在你改变程序变量取值时,最好都使用set var格式的GDB命令。

跳转执行
一般来说,被调试程序会按照程序代码的运行顺序依次执行。GDB提供了乱序执行的功能,也就是说,GDB可以修改程序的执行顺序,可以让程序执行随意跳跃。这个功能可以由GDB的jump命令来完:

1
jump

指定下一条语句的运行点。可以是文件的行号,可以是file:line格式,可以是+num这种偏移量格式。表式着下一条运行语句从哪里开始。
注意,jump命令不会改变当前的程序栈中的内容,所以,当你从一个函数跳到另一个函数时,当函数运行完返回时进行弹栈操作时必然会发生错误,可能结果还是非常奇怪的,甚至于产生程序Core Dump。所以最好是同一个函数中进行跳转。 熟悉汇编的人都知道,程序运行时,有一个寄存器用于保存当前代码所在的内存地址。所以,jump命令也就是改变了这个寄存器中的值。于是,你可以使用“set $pc”来更改跳转执行的地址。如:
set $pc = 0x485

产生信号量
使用singal命令,可以产生一个信号量给被调试的程序。如:中断信号Ctrl+C。这非常方便于程序的调试,可以在程序运行的任意位置设置断点,并在该断点用GDB产生一个信号量,这种精确地在某处产生信号非常有利程序的调试。 语法是:signal ,UNIX的系统信号量通常从1到15。所以取值也在这个范围。
single命令和shell的kill命令不同,系统的kill命令发信号给被调试程序时,是由GDB截获的,而single命令所发出一信号则是直接发给被调试程序的。

强制函数返回
如果你的调试断点在某个函数中,并还有语句没有执行完。你可以使用return命令强制函数忽略还没有执行的语句并返回。
return
使用return命令取消当前函数的执行,并立即返回,如果指定了,那么该表达式的值会被认作函数的返回值。

强制调用函数

call表达式中可以一是函数,以此达到强制调用函数的目的。并显示函数的返回值,如果函数返回值是void,那么就不显示。 另一个相似的命令也可以完成这一功能——print,print后面可以跟表达式,所以也可以用他来调用函数,print和call的不同是,如果函数返回void,call则不显示,print则显示函数返回值,并把该值存入历史数据中。

GDB支持下列语言:C, C++, Fortran, PASCAL, Java, Chill, assembly, 和 Modula-2。一般说来,GDB会根据你所调试的程序来确定当然的调试语言,比如:发现文件名后缀为“.c”的,GDB会认为是C程序。文件名后缀为“.C, .cc, .cp, .cpp, .cxx, .c++”的,GDB会认为是C++程序。而后缀是“.f, .F”的,GDB会认为是Fortran程序,还有,后缀为如果是“.s, .S”的会认为是汇编语言。 也就是说,GDB会根据你所调试的程序的语言,来设置自己的语言环境,并让GDB的命令跟着语言环境的改变而改变。比如一些GDB命令需要用到表达式或变量时,这些表达式或变量的语法,完全是根据当前的语言环境而改变的。例如C/C++中对指针的语法是*p,而在Modula-2中则是p^。并且,如果你当前的程序是由几种不同语言一同编译成的,那到在调试过程中,GDB也能根据不同的语言自动地切换语言环境。这种跟着语言环境而改变的功能,真是体贴开发人员的一种设计。

下面是几个相关于GDB语言环境的命令:
show language
查看当前的语言环境。如果GDB不能识为你所调试的编程语言,那么,C语言被认为是默
认的环境。

info frame
查看当前函数的程序语言。

info source
查看当前文件的程序语言。
如果GDB没有检测出当前的程序语言,那么你也可以手动设置当前的程序语言。使用set language命令即可做到。
当set language命令后什么也不跟的话,你可以查看GDB所支持的语言种类:

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) set language 
The currently understood settings are:
local or auto Automatic setting based on source file
c Use the C language
c++ Use the C++ language
asm Use the Asm language
chill Use the Chill language
fortran Use the Fortran language
java Use the Java language
modula-2 Use the Modula-2 language
pascal Use the Pascal language
scheme Use the Scheme language

于是你可以在set language后跟上被列出来的程序语言名,来设置当前的语言环境。

多进程、多线程

多进程

GDB在调试多进程程序(程序含fork调用)时,默认只追踪父进程。可以通过命令设置,实现只追踪父进程或子进程,或者同时调试父进程和子进程。

命令 作用
info inferiors 查看进程列表
attach pid 绑定进程id
inferior num 切换到指定进程上进行调试
print $_exitcode 显示程序退出时的返回值
set follow-fork-mode child 追踪子进程
set follow-fork-mode parent 追踪父进程
set detach-on-fork on fork调用时只追踪其中一个进程
set detach-on-fork off fork调用时会同时追踪父子进程

在调试多进程程序时候,默认情况下,除了当前调试的进程,其他进程都处于挂起状态,所以,如果需要在调试当前进程的时候,其他进程也能正常执行,那么通过设置set schedule-multiple on即可。

同上面一样,我们仍然以一个例子进行模拟多进程调试,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <unistd.h>

int main()
{
pid_t pid = fork();
if (pid == -1) {
perror("fork error\n");
return -1;
}

if(pid == 0) { // 子进程
int num = 1;
while(num == 1){
sleep(10);
}
printf("this is child,pid = %d\n", getpid());
} else { // 父进程
printf("this is parent,pid = %d\n", getpid());
wait(NULL); // 等待子进程退出
}
return 0;
}

在上面代码中,包含两个进程,一个是父进程(也就是main进程),另外一个是由fork()函数创建的子进程。

在默认情况下,在多进程程序中,GDB只调试main进程,也就是说无论程序调用了多少次fork()函数创建了多少个子进程,GDB在默认情况下,只调试父进程。为了支持多进程调试,从GDB版本7.0开始支持单独调试(调试父进程或者子进程)和同时调试多个进程。

那么,我们该如何调试子进程呢?我们可以使用如下几种方式进行子进程调试。

attach

首先,无论是父进程还是子进程,都可以通过attach命令启动gdb进行调试。我们都知道,对于每个正在运行的程序,操作系统都会为其分配一个唯一ID号,也就是进程ID。如果我们知道了进程ID,就可以使用attach命令对其进行调试了。

在上面代码中,fork()函数创建的子进程内部,首先会进入while循环sleep,然后在while循环之后调用printf函数。这样做的目的有如下:

帮助attach捕获要调试的进程id
在使用gdb进行调试的时候,真正的代码(即print函数)没有被执行,这样就可以从头开始对子进程进行调试

使用如下命令编译生成可执行文件test_process

1
g++ -g test_process.cc -o test_process

现在,我们开始尝试启动调试。

1
2
3
gdb -q ./test_process
Reading symbols from /root/test_process...done.
(gdb)

这里需要说明下,之所以加-q选项,是想去掉其他不必要的输出,q为quite的缩写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(gdb) r
Starting program: /root/./test_process
Detaching after fork from child process 37482.
this is parent,pid = 37478
[Inferior 1 (process 37478) exited normally]
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb) attach 37482
//符号类输出,此处略去
(gdb) n
Single stepping until exit from function __nanosleep_nocancel,
which has no line number information.
0x00007ffff72b3cc4 in sleep () from /lib64/libc.so.6
(gdb)
Single stepping until exit from function sleep,
which has no line number information.
main () at test_process.cc:8
8 while(num==10){
(gdb)

在上述命令中,我们执行了n(next的缩写),使其重新对while循环的判断体进行判断。

1
2
3
4
5
6
7
8
(gdb) set num = 1
(gdb) n
12 printf("this is child,pid = %d\n",getpid());
(gdb) c
Continuing.
this is child,pid = 37482
[Inferior 1 (process 37482) exited normally]
(gdb)

为了退出while循环,我们使用set命令设置了num的值为1,这样条件就会失效退出while循环,进而执行下面的printf()函数;在最后我们执行了c(continue的缩写)命令,支持程序退出。

指定进程

默认情况下,GDB调试多进程程序时候,只调试父进程。GDB提供了两个命令,可以通过follow-fork-mode和detach-on-fork来指定调试父进程还是子进程。

1
follow-fork-mode

该命令的使用方式为:

1
(gdb) set follow-fork-mode mode

其中,mode有以下两个选项:

  • parent:父进程,mode的默认选项
  • child:子进程,其目的是告诉 gdb 在目标应用调用fork之后接着调试子进程而不是父进程,因为在Linux系统中fork()系统调用成功会返回两次,一次在父进程,一次在子进程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(gdb) show follow-fork-mode
Debugger response to a program call of fork or vfork is "parent".
(gdb) set follow-fork-mode child
(gdb) r
Starting program: /root/./test_process
[New process 37830]
this is parent,pid = 37826

^C
Program received signal SIGINT, Interrupt.
[Switching to process 37830]
0x00007ffff72b3e10 in __nanosleep_nocancel () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb) n
Single stepping until exit from function __nanosleep_nocancel,
which has no line number information.
0x00007ffff72b3cc4 in sleep () from /lib64/libc.so.6
(gdb) n
Single stepping until exit from function sleep,
which has no line number information.
main () at test_process.cc:8
8 while(num==10){
(gdb) show follow-fork-mode
Debugger response to a program call of fork or vfork is "child".
(gdb)

在上述命令中,我们做了如下操作:

  • show follow-fork-mode:通过该命令来查看当前处于什么模式下,通过输出可以看出,处于parent即父进程模式
  • set follow-fork-mode child:指定调试子进程模式
  • r:运行程序,直接运行程序,此时会进入子进程,然后执行while循环
  • ctrl + c:通过该命令,可以使得GDB收到SIGINT命令,从而暂停执行while循环
  • n(next):继续执行,进而进入到while循环的条件判断处
  • show follow-fork-mode:再次执行该命令,通过输出可以看出,当前处于child模式下

如果一开始指定要调试子进程还是父进程,那么使用follow-fork-mode命令完全可以满足需求;但是如果想在调试过程中,想根据实际情况在父进程和子进程之间来回切换调试呢?

GDB提供了另外一个命令:

1
(gdb) set detach-on-fork mode

其中mode有如下两个值:

  • on:默认值,即表明只调试一个进程,可以是子进程,也可以是父进程
  • off:程序中的每个进程都会被记录,进而我们可以对所有的进程进行调试

如果选择关闭detach-on-fork模式(mode为off),那么GDB将保留对所有被fork出来的进程控制,即可用调试所有被fork出来的进程。可用 使用info forks命令列出所有的可被GDB调试的fork进程,并可用使用fork命令从一个fork进程切换到另一个fork进程。

  • info forks: 打印DGB控制下的所有被fork出来的进程列表。该列表包括fork id、进程id和当前进程的位置
  • fork fork-id: 参数fork-id是GDB分配的内部fork编号,该编号可用通过上面的命令info forks获取

多线程

多线程开发在日常开发工作中很常见,所以多线程的调试技巧非常有必要掌握。

默认调试多线程时,一旦程序中断,所有线程都将暂停。如果此时再继续执行当前线程,其他线程也会同时执行。

|命令|作用|
|info threads|查看线程列表|
|print $_thread|显示当前正在调试的线程编号|
|set scheduler-locking on|调试一个线程时,其他线程暂停执行|
|set scheduler-locking off|调试一个线程时,其他线程同步执行|
|set scheduler-locking step|仅用step调试线程时其他线程不执行,用其他命令如next调试时仍执行|

如果只关心当前线程,建议临时设置 scheduler-locking 为 on,避免其他线程同时运行,导致命中其他断点分散注意力。

为了方便进行演示,我们创建一个简单的例子,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <chrono>
#include <iostream>
#include <string>
#include <thread>
#include <vector>

int fun_int(int n) {
std::this_thread::sleep_for(std::chrono::seconds(10));
std::cout << "in fun_int n = " << n << std::endl;

return 0;
}

int fun_string(const std::string &s) {
std::this_thread::sleep_for(std::chrono::seconds(10));
std::cout << "in fun_string s = " << s << std::endl;

return 0;
}

int main() {
std::vector<int> v;
v.emplace_back(1);
v.emplace_back(2);
v.emplace_back(3);

std::cout << v.size() << std::endl;

std::thread t1(fun_int, 1);
std::thread t2(fun_string, "test");

std::cout << "after thread create" << std::endl;
t1.join();
t2.join();
return 0;
}

上述代码比较简单:

  • 函数fun_int的功能是休眠10s,然后打印其参数
  • 函数fun_string功能是休眠10s,然后打印其参数
  • main函数中,创建两个线程,分别执行上述两个函数

下面是一个完整的调试过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
(gdb) b 27
Breakpoint 1 at 0x4013d5: file test.cc, line 27.
(gdb) b test.cc:32
Breakpoint 2 at 0x40142d: file test.cc, line 32.
(gdb) info b
Num Type Disp Enb Address What
1 breakpoint keep y 0x00000000004013d5 in main() at test.cc:27
2 breakpoint keep y 0x000000000040142d in main() at test.cc:32
(gdb) r
Starting program: /root/test
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".

Breakpoint 1, main () at test.cc:27
(gdb) c
Continuing.
3
[New Thread 0x7ffff6fd2700 (LWP 44996)]
in fun_int n = 1
[New Thread 0x7ffff67d1700 (LWP 44997)]

Breakpoint 2, main () at test.cc:32
32 std::cout << "after thread create" << std::endl;
(gdb) info threads
Id Target Id Frame
3 Thread 0x7ffff67d1700 (LWP 44997) "test" 0x00007ffff7051fc3 in new_heap () from /lib64/libc.so.6
2 Thread 0x7ffff6fd2700 (LWP 44996) "test" 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
* 1 Thread 0x7ffff7fe7740 (LWP 44987) "test" main () at test.cc:32
(gdb) thread 2
[Switching to thread 2 (Thread 0x7ffff6fd2700 (LWP 44996))]
#0 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
(gdb) bt
#0 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
#1 0x00007ffff7097cc4 in sleep () from /lib64/libc.so.6
#2 0x00007ffff796ceb9 in std::this_thread::__sleep_for(std::chrono::duration<long, std::ratio<1l, 1l> >, std::chrono::duration<long, std::ratio<1l, 1000000000l> >) () from /lib64/libstdc++.so.6
#3 0x00000000004018cc in std::this_thread::sleep_for<long, std::ratio<1l, 1l> > (__rtime=...) at /usr/include/c++/4.8.2/thread:281
#4 0x0000000000401307 in fun_int (n=1) at test.cc:9
#5 0x0000000000404696 in std::_Bind_simple<int (*(int))(int)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (this=0x609080)
at /usr/include/c++/4.8.2/functional:1732
#6 0x000000000040443d in std::_Bind_simple<int (*(int))(int)>::operator()() (this=0x609080) at /usr/include/c++/4.8.2/functional:1720
#7 0x000000000040436e in std::thread::_Impl<std::_Bind_simple<int (*(int))(int)> >::_M_run() (this=0x609068) at /usr/include/c++/4.8.2/thread:115
#8 0x00007ffff796d070 in ?? () from /lib64/libstdc++.so.6
#9 0x00007ffff7bc6dd5 in start_thread () from /lib64/libpthread.so.0
#10 0x00007ffff70d0ead in clone () from /lib64/libc.so.6
(gdb) c
Continuing.
after thread create
in fun_int n = 1
[Thread 0x7ffff6fd2700 (LWP 45234) exited]
in fun_string s = test
[Thread 0x7ffff67d1700 (LWP 45235) exited]
[Inferior 1 (process 45230) exited normally]
(gdb) q

在上述调试过程中:

  • b 27 在第27行加上断点
  • b test.cc:32 在第32行加上断点(效果与b 32一致)
  • info b 输出所有的断点信息
  • r 程序开始运行,并在第一个断点处暂停
  • c 执行c命令,在第二个断点处暂停,在第一个断点和第二个断点之间,创建了两个线程t1和t2
  • info threads 输出所有的线程信息,从输出上可以看出,总共有3个线程,分别为main线程、t1和t2
  • thread 2 切换至线程2
  • bt 输出线程2的堆栈信息
  • c 直至程序结束
  • q 退出gdb

动态链接库中函数的地址确定

有一个问题是我们调用了动态链接库里面的函数,我们怎么知道动态链接库里面的函数的地址呢?事实上,直到我们第一次调用这个函数,我们并不知道这个函数的地址,这个功能要做延迟绑定 lazy bind。 因为程序的分支很多,并不是所有的分支都能跑到,想想我们的异常处理,异常处理分支的动态链接库里面的函数也许永远跑不到,所以,一上来就解析所有出现过的动态库里面的函数是个浪费的办法,降低性能并且没有必要。

下面我们看下延迟绑定的效果。我写了个程序,先睡15s,然后pthread_create 一个线程。我们用LD_DEBUG观察符号的解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include<stdio.h>
#include<stdlib.h>
#include<pthread.h>

void* myfunc()
{
while(1)
{
sleep(10);
}
return NULL;
}

int main()
{
sleep(15);
pthread_t tid = 0;
int ret = pthread_create(&tid,NULL,myfunc,NULL);
if(ret)
{
fprintf(stderr,"pthread create failed %m \n");
return -1;
}

ret = pthread_join(tid,NULL);
if(ret)
{
fprintf(stderr,"pthread join failed %m\n");
return -2;
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
root@libin:~/program/C/plt_got# LD_DEBUG=symbols ./test
2849: symbol=_res; lookup in file=./test [0]
2849: symbol=_res; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=_res; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
2849: symbol=_IO_file_close; lookup in file=./test [0]
2849: symbol=_IO_file_close; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=_IO_file_close; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
2849: symbol=rpc_createerr; lookup in file=./test [0]
2849: symbol=rpc_createerr; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=rpc_createerr; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]

2849: transferring control: ./test
2849:
2849: symbol=sleep; lookup in file=./test [0]
2849: symbol=sleep; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=sleep; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
===================================================================================

然后停了15s,才解析出pthread_create的地址,由此可见,得确是运行时重定位,知道用到这个函数pthread_create才真正去找这个函数的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
2849:    
2849: symbol=sleep; lookup in file=./test [0]
2849: symbol=sleep; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=sleep; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
===================================================================================
2849: symbol=pthread_create; lookup in file=./test [0]
2849: symbol=pthread_create; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=__getpagesize; lookup in file=./test [0]
2849: symbol=__getpagesize; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=__getpagesize; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
2849: symbol=mmap; lookup in file=./test [0]
2849: symbol=mmap; lookup in file=/lib/tls/i686/cmov/libpthread.so.0 [0]
2849: symbol=mmap; lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]

真正动态库中函数地址的解析是第一次调用的时候做的,然后如果再次用到动态库的解析过的函数,就直接用第一次解析的结果。很自然的想法就是,一定有地方存储函数的地址,否则第一次解析出来的结果,第二次调用也没法利用。 这个存储动态库函数的地方就要GOT,Global Offset Table。 OK,我们可以想象,如果我的程序里面用到了6个动态库里面的函数,那个这个GOT里面就应该存有6个条目,每个条目里面存储着对应函数的地址。事实的确是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root@libin:~/program/C/plt_got# readelf -r test

Relocation section '.rel.dyn' at offset 0x394 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
08049ff0 00000206 R_386_GLOB_DAT 00000000 __gmon_start__
0804a020 00000905 R_386_COPY 0804a020 stderr

Relocation section '.rel.plt' at offset 0x3a4 contains 6 entries:
Offset Info Type Sym.Value Sym. Name
0804a000 00000107 R_386_JUMP_SLOT 00000000 pthread_join
0804a004 00000207 R_386_JUMP_SLOT 00000000 __gmon_start__
0804a008 00000407 R_386_JUMP_SLOT 00000000 __libc_start_main
0804a00c 00000507 R_386_JUMP_SLOT 00000000 fprintf
0804a010 00000607 R_386_JUMP_SLOT 00000000 pthread_create
0804a014 00000707 R_386_JUMP_SLOT 00000000 sleep

我们看到了有全局变量stderr和gmon_start需要重定位,这些本文并不关心。下面是需要重定位的函数,可以看出,我们调用动态库里面的函数都在这了,fprintf是Glibc库的,pthread_create是pthread库的等等。

.got.plt这个段的起始地址是0x8049ff4。 .got.plt这个section大小为0x24 = 36,可是我们只有6个需要解析地址的function,4*6=24个字节,只需要24个字节就能存放这6个函数指针。多出来的12个字节是dynamic段地址,ModuleID 和 _dl_runtime_resolve的地址,如下图所示

OK 。我们看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) b main
Breakpoint 1 at 0x8048551: file test.c, line 19.
(gdb) r
Starting program: /home/libin/program/C/plt_got/test
[Thread debugging using libthread_db enabled]

Breakpoint 1, main () at test.c:19
19 sleep(15);
(gdb) x/24x 0x8049ff4
0x8049ff4 <_GLOBAL_OFFSET_TABLE_>: 0x08049f18 0x0012c8f8 0x00123270 0x0804841a
0x804a004 <_GLOBAL_OFFSET_TABLE_+16>: 0x0804842a 0x0015daf0 0x0804844a 0x0804845a
0x804a014 <_GLOBAL_OFFSET_TABLE_+32>: 0x0804846a 0x00000000 0x00000000 0x0029c580
0x804a024 : 0x00000000 0x00000000 0x00000000 0x00000000
0x804a034: 0x00000000 0x00000000 0x00000000 0x00000000
0x804a044: 0x00000000 0x00000000 0x00000000 0x00000000

蓝色的0x0849f18是dynamic段的地址

1
[21] .dynamic DYNAMIC 08049f18 000f18 0000d8 08 WA 7 0 4

接下来,我们要分析PLT 和GOT的关系了。

1
2
3
4
5
6
7
(gdb) disas main

0x0804857e <+54>: lea 0x1c(%esp),%eax
0x08048582 <+58>: mov %eax,(%esp)
0x08048585 <+61>: call 0x8048454 <pthread_create@plt>
0x0804858a <+66>: mov %eax,0x18(%esp)
0x0804858e <+70>: cmpl $0x0,0x18(%esp)

要执行pthread_create 函数,跳到PLT部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
libin@libin:~/program/C/plt_got$ objdump -dj .plt test

test: file format elf32-i386

Disassembly of section .plt:

08048404 :
8048404: ff 35 f8 9f 04 08 pushl 0x8049ff8
804840a: ff 25 fc 9f 04 08 jmp *0x8049ffc
8048410: 00 00 add %al,(%eax)

08048414 :
8048414: ff 25 00 a0 04 08 jmp *0x804a000
804841a: 68 00 00 00 00 push $0x0
804841f: e9 e0 ff ff ff jmp 8048404 <_init+0x30>

08048424 <__gmon_start__@plt>:
8048424: ff 25 04 a0 04 08 jmp *0x804a004
804842a: 68 08 00 00 00 push $0x8
804842f: e9 d0 ff ff ff jmp 8048404 <_init+0x30>

08048434 <__libc_start_main@plt>:
8048434: ff 25 08 a0 04 08 jmp *0x804a008
804843a: 68 10 00 00 00 push $0x10
804843f: e9 c0 ff ff ff jmp 8048404 <_init+0x30>

08048444 :
8048444: ff 25 0c a0 04 08 jmp *0x804a00c
804844a: 68 18 00 00 00 push $0x18
804844f: e9 b0 ff ff ff jmp 8048404 <_init+0x30>

08048454 :
8048454: ff 25 10 a0 04 08 jmp *0x804a010
804845a: 68 20 00 00 00 push $0x20
804845f: e9 a0 ff ff ff jmp 8048404 <_init+0x30>

08048464 :
8048464: ff 25 14 a0 04 08 jmp *0x804a014
804846a: 68 28 00 00 00 push $0x28
804846f: e9 90 ff ff ff jmp 8048404 <_init+0x30>

PLT部分认为pthread_create函数存放在GOT,0x804a010是GOT里面的一个条目,这个条目存储着pthread_create函数的地址。当第二次以至于第N次调用pthead_create的时候,的的确确存放着pthread_create的地址,但是第一次不行,第一次这个条目里面还没记录这个地址。那么这个条目记录的是什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) x/10i 0x8048454
0x8048454 : jmp *0x804a010
0x804845a : push $0x20
0x804845f : jmp 0x8048404
0x8048464 : jmp *0x804a014
0x804846a : push $0x28
0x804846f : jmp 0x8048404
0x8048474: add %al,(%eax)
0x8048476: add %al,(%eax)
0x8048478: add %al,(%eax)
0x804847a: add %al,(%eax)
(gdb) x/10x 0x804a010
0x804a010 <_GLOBAL_OFFSET_TABLE_+28>: 0x0804845a 0x0804846a 0x00000000 0x00000000
0x804a020 : 0x0029c580 0x00000000 0x00000000 0x00000000
0x804a030: 0x00000000 0x00000000

0x804a010这个地址最终应该记录的是pthread_create的地址,但是目前还不是,记录的是0x084845a

1
2
3
4
08048454 :
8048454: ff 25 10 a0 04 08 jmp *0x804a010
804845a: 68 20 00 00 00 push $0x20
804845f: e9 a0 ff ff ff jmp 8048404 <_init+0x30>

从PLT跳到GOT 找地址,但是第一次找的时候,并不是pthread_create的地址,而是又跳回来PLT,我们看到push了0x20之后,跳到了0x8048404。 每一个PLT的代码段,都是push了一个值之后,跳到了0x8048404。大家可以去上面的图验证。

接下来,我们看0x8048404存放的是啥指令:

1
2
3
4
5
6
7
8
9
10
11
(gdb) x/10i 0x8048404
0x8048404: pushl 0x8049ff8
0x804840a: jmp *0x8049ffc
0x8048410: add %al,(%eax)
0x8048412: add %al,(%eax)
0x8048414 <</span>pthread_join@plt>: jmp *0x804a000
0x804841a <</span>pthread_join@plt+6>: push $0x0
0x804841f <</span>pthread_join@plt+11>: jmp 0x8048404
0x8048424 <</span>__gmon_start__@plt>: jmp *0x804a004
0x804842a <</span>__gmon_start__@plt+6>: push $0x8
0x804842f <</span>__gmon_start__@plt+11>: jmp 0x8048404
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
(gdb) x/10x 0x8049ffc
0x8049ffc <</span>_GLOBAL_OFFSET_TABLE_+8>: 0x00123270 0x0804841a 0x0804842a 0x0015daf0
0x804a00c <</span>_GLOBAL_OFFSET_TABLE_+24>: 0x0804844a 0x0804845a 0x0804846a 0x00000000
0x804a01c <</span>__dso_handle>: 0x00000000 0x0029c580
(gdb) x/10i 0x00123270
0x123270 <</span>_dl_runtime_resolve>: push %eax
0x123271 <</span>_dl_runtime_resolve+1>: push %ecx
0x123272 <</span>_dl_runtime_resolve+2>: push %edx
0x123273 <</span>_dl_runtime_resolve+3>: mov 0x10(%esp),%edx
0x123277 <</span>_dl_runtime_resolve+7>: mov 0xc(%esp),%eax
0x12327b <</span>_dl_runtime_resolve+11>: call 0x11d5a0 <</span>_dl_fixup>
0x123280 <</span>_dl_runtime_resolve+16>: pop %edx
0x123281 <</span>_dl_runtime_resolve+17>: mov (%esp),%ecx
0x123284 <</span>_dl_runtime_resolve+20>: mov %eax,(%esp)
0x123287 <</span>_dl_runtime_resolve+23>: mov 0x4(%esp),%eax
`````

我们看到0x8049ffc就是GOT的第三项,前文提到的dl_runtime_resolve的地址。这个函数将帮助我们将pthread_create函数地址定位,并且填入GOT表的相应位置 0x804a010。

我们watch下GOT pthread_create对应条目,看下这个条目啥时候变化:

`````
(gdb) b main
Breakpoint 1 at 0x8048551: file test.c, line 19.
(gdb) r
Starting program: /home/libin/program/C/plt_got/test
[Thread debugging using libthread_db enabled]

Breakpoint 1, main () at test.c:19
19 sleep(15);
(gdb) watch *0x804a010
Hardware watchpoint 2: *0x804a010
(gdb) c
Continuing.
Hardware watchpoint 2: *0x804a010

Old value = 134513754
New value = 1260912
_dl_fixup (l=<</span>value optimized out>, reloc_arg=<</span>value optimized out>) at dl-runtime.c:155
155 dl-runtime.c: 没有那个文件或目录.
in dl-runtime.c
(gdb) bt
#0 _dl_fixup (l=<</span>value optimized out>, reloc_arg=<</span>value optimized out>) at dl-runtime.c:155
#1 0x00123280 in _dl_runtime_resolve () at ../sysdeps/i386/dl-trampoline.S:37
#2 0x0804858a in main () at test.c:21
(gdb)
`````

看到了,是_dl_runtime_resolve调用了_dl_fixup修改了GOT的对应条目。

`````
(gdb) x/10i 1260912
0x133d70 <</span>__pthread_create_2_1>: push %ebp
0x133d71 <</span>__pthread_create_2_1+1>: mov %esp,%ebp
0x133d73 <</span>__pthread_create_2_1+3>: push %edi
0x133d74 <</span>__pthread_create_2_1+4>: push %esi
0x133d75 <</span>__pthread_create_2_1+5>: push %ebx
0x133d76 <</span>__pthread_create_2_1+6>: call 0x132340 <</span>__i686.get_pc_thunk.bx>
0x133d7b <</span>__pthread_create_2_1+11>: add $0x10279,%ebx
0x133d81 <</span>__pthread_create_2_1+17>: sub $0x4c,%esp
0x133d84 <</span>__pthread_create_2_1+20>: mov 0xc(%ebp),%edx
0x133d87 <</span>__pthread_create_2_1+23>: test %edx,%edx
`````

这是第一次。第二次就比较简单了,因为GOT里面有一个条目已经有了pthread_create函数的地址。
![](http://blog.chinaunix.net/attachment/201209/16/24774106_13478011589N9A.png)

# Perf
## Perf 简介
Perf 是用来进行软件性能分析的工具。

通过它,应用程序可以利用 PMU,tracepoint 和内核中的特殊计数器来进行性能统计。它不但可以分析指定应用程序的性能问题 (per thread),也可以用来分析内核的性能问题,当然也可以同时分析应用代码和内核,从而全面理解应用程序中的性能瓶颈。

最初的时候,它叫做 Performance counter,在 2.6.31 中第一次亮相。此后他成为内核开发最为活跃的一个领域。在 2.6.32 中它正式改名为 Performance Event,因为 perf 已不再仅仅作为 PMU 的抽象,而是能够处理所有的性能相关的事件。

使用 perf,您可以分析程序运行期间发生的硬件事件,比如 instructions retired ,processor clock cycles 等;您也可以分析软件事件,比如 Page Fault 和进程切换。

这使得 Perf 拥有了众多的性能分析能力,举例来说,使用 Perf 可以计算每个时钟周期内的指令数,称为 IPC,IPC 偏低表明代码没有很好地利用 CPU。Perf 还可以对程序进行函数级别的采样,从而了解程序的性能瓶颈究竟在哪里等等。Perf 还可以替代 strace,可以添加动态内核 probe 点,还可以做 benchmark 衡量调度器的好坏。。。

Perf通过系统调用`sys_perf_event_open` 陷入到内核中,内核根据perf 提供的信息在PMU(Performance Monitoring Unit)上初始化一个硬件性能计数器(PMC: Performance Monitoring Counter)。PMC随着指定硬件事件的发生而自动累加。在PMC 溢出时,PMU 触发一个PMI(Performance Monitoring Interrupt)中断。内核在PMI 中断的处理函数中保存PMC 的计数值,触发中断时的指令地址,当前时间戳以及当前进程的PID,TID,comm 等信息。我们把这些信息统称为一个采样(sample)。内核会将收集到的sample 放入用于跟用户空间通信的Ring Buffer。用户空间里的perf 分析程序采用mmap 机制从ring buffer 中读入采样,并对其解析。

## 背景知识
有些背景知识是分析性能问题时需要了解的。比如硬件 cache;再比如操作系统内核。应用程序的行为细节往往是和这些东西互相牵扯的,这些底层的东西会以意想不到的方式影响应用程序的性能,比如某些程序无法充分利用 cache,从而导致性能下降。比如不必要地调用过多的系统调用,造成频繁的内核 / 用户切换。等等。方方面面,这里只是为本文的后续内容做一些铺垫,关于调优还有很多东西,我所不知道的比知道的要多的多。

当算法已经优化,代码不断精简,人们调到最后,便需要斤斤计较了。cache 啊,流水线啊一类平时不大注意的东西也必须精打细算了。

### 硬件特性之 cache
内存读写是很快的,但还是无法和处理器的指令执行速度相比。为了从内存中读取指令和数据,处理器需要等待,用处理器的时间来衡量,这种等待非常漫长。Cache 是一种 SRAM,它的读写速率非常快,能和处理器处理速度相匹配。因此将常用的数据保存在 cache 中,处理器便无须等待,从而提高性能。Cache 的尺寸一般都很小,充分利用 cache 是软件调优非常重要的部分。

### 硬件特性之流水线,超标量体系结构,乱序执行
提高性能最有效的方式之一就是并行。处理器在硬件设计时也尽可能地并行,比如流水线,超标量体系结构以及乱序执行。

处理器处理一条指令需要分多个步骤完成,比如先取指令,然后完成运算,最后将计算结果输出到总线上。在处理器内部,这可以看作一个三级流水线,如下图所示:

![](/img/20201126102100.gif)
图 1. 处理器流水线

指令从左边进入处理器,上图中的流水线有三级,一个时钟周期内可以同时处理三条指令,分别被流水线的不同部分处理。

超标量(superscalar)指一个时钟周期发射多条指令的流水线机器架构,比如 Intel 的 Pentium 处理器,内部有两个执行单元,在一个时钟周期内允许执行两条指令。

此外,在处理器内部,不同指令所需要的处理步骤和时钟周期是不同的,如果严格按照程序的执行顺序执行,那么就无法充分利用处理器的流水线。因此指令有可能被乱序执行。

上述三种并行技术对所执行的指令有一个基本要求,即相邻的指令相互没有依赖关系。假如某条指令需要依赖前面一条指令的执行结果数据,那么 pipeline 便失去作用,因为第二条指令必须等待第一条指令完成。因此好的软件必须尽量避免这种代码的生成。

### 硬件特性之分支预测
分支指令对软件性能有比较大的影响。尤其是当处理器采用流水线设计之后,假设流水线有三级,当前进入流水的第一条指令为分支指令。假设处理器顺序读取指令,那么如果分支的结果是跳转到其他指令,那么被处理器流水线预取的后续两条指令都将被放弃,从而影响性能。为此,很多处理器都提供了分支预测功能,根据同一条指令的历史执行记录进行预测,读取最可能的下一条指令,而并非顺序读取指令。

分支预测对软件结构有一些要求,对于重复性的分支指令序列,分支预测硬件能得到较好的预测结果,而对于类似 switch case 一类的程序结构,则往往无法得到理想的预测结果。

上面介绍的几种处理器特性对软件的性能有很大的影响,然而依赖时钟进行定期采样的 profiler 模式无法揭示程序对这些处理器硬件特性的使用情况。处理器厂商针对这种情况,在硬件中加入了 PMU 单元,即 performance monitor unit。

PMU 允许软件针对某种硬件事件设置 counter,此后处理器便开始统计该事件的发生次数,当发生的次数超过 counter 内设置的值后,便产生中断。比如 cache miss 达到某个值后,PMU 便能产生相应的中断。

捕获这些中断,便可以考察程序对这些硬件特性的利用效率了。

### Tracepoints
Tracepoint 是散落在内核源代码中的一些 hook,一旦使能,它们便可以在特定的代码被运行到时被触发,这一特性可以被各种 trace/debug 工具所使用。Perf 就是该特性的用户之一。

假如您想知道在应用程序运行期间,内核内存管理模块的行为,便可以利用潜伏在 slab 分配器中的 tracepoint。当内核运行到这些 tracepoint 时,便会通知 perf。

Perf 将 tracepoint 产生的事件记录下来,生成报告,通过分析这些报告,调优人员便可以了解程序运行时期内核的种种细节,对性能症状作出更准确的诊断。

## 性能事件

在程序运行中发生的,可能影响到程序性能的软硬件件事件,使用perf list命令可以显示当前软硬件环境下支持的所有事件,大致可以分为三种:

- Hardware Event由PMU部件产生,在特定的条件下探测性能事件是否发生以及发生的次数。比如CPU周期、分支指令、TLB重填例外、Cache缺失等。
- Software Event是内核产生的事件,分布在各个功能模块中,统计和操作系统相关性能事件。比如系统调用次数、上下文切换次数、任务迁移次数、缺页例外次数等。
- Tracepoint Event是内核中静态tracepoint所触发的事件,这些tracepoint用来判断程序运行期间内核的行为细节,比如slab分配器的分配次数等。基于ftrace框架实现,内核中的所有tracepoint都可以作为perf的性能事件

`cat /sys/kernel/debug/tracing/available_events`,可查看当前系统的所有tracepoint分成了几大类:
- ext4 文件系统的tracepoint events,如果是其它文件系统,比如XFS,也有对应的tracepoint event;
- jbd2 文件日志的tracepoint events;
- skb 内存的tracepoint events;
- net,napi,sock,udp:网络的tracepoint events;
- scsi, block, writeback 磁盘IO
- kmem 内存
- sched 调度
- syscalls 系统调用

## perf 的基本使用
说明一个工具的最佳途径是列举一个例子。

考查下面这个例子程序。其中函数`longa()`是个很长的循环,比较浪费时间。函数`foo1`和`foo2`将分别调用该函数 10 次,以及 100 次。

清单 1. 测试程序 t1
```C
//test.c
void longa()
{
int i,j;
for(i = 0; i < 1000000; i++)
j=i; //am I silly or crazy? I feel boring and desperate.
}

void foo2()
{
int i;
for(i=0 ; i < 10; i++)
longa();
}

void foo1()
{
int i;
for(i = 0; i< 100; i++)
longa();
}

int main(void)
{
foo1();
foo2();
}

找到这个程序的性能瓶颈无需任何工具,肉眼的阅读便可以完成。Longa()是这个程序的关键,只要提高它的速度,就可以极大地提高整个程序的运行效率。

但,因为其简单,却正好可以用来演示 perf 的基本使用。假如 perf 告诉您这个程序的瓶颈在别处,您就不必再浪费宝贵时间阅读本文了。

准备使用 perf

安装 perf 非常简单,只要您有 2.6.31 以上的内核源代码,那么进入 tools/perf 目录然后敲入下面两个命令即可:

1
2
make 
make install

性能调优工具如 perf,Oprofile 等的基本原理都是对被监测对象进行采样,最简单的情形是根据 tick 中断进行采样,即在 tick 中断内触发采样点,在采样点里判断程序当时的上下文。假如一个程序 90% 的时间都花费在函数 foo() 上,那么 90% 的采样点都应该落在函数 foo() 的上下文中。运气不可捉摸,但我想只要采样频率足够高,采样时间足够长,那么以上推论就比较可靠。因此,通过 tick 触发采样,我们便可以了解程序中哪些地方最耗时间,从而重点分析。

稍微扩展一下思路,就可以发现改变采样的触发条件使得我们可以获得不同的统计数据:

以时间点 ( 如 tick) 作为事件触发采样便可以获知程序运行时间的分布。

以 cache miss 事件触发采样便可以知道 cache miss 的分布,即 cache 失效经常发生在哪些程序代码中。如此等等。

因此让我们先来了解一下 perf 中能够触发采样的事件有哪些。

perf —help

先了解一下概貌

perf 命令用法还是挺简单的,根据功能区分了COMMAND,每个COMMAND有各自的用法。

用得比较多的有list, record, report, script, stat, top。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
usage: perf [--version] [--help] [OPTIONS] COMMAND [ARGS]

The most commonly used perf commands are:
annotate Read perf.data (created by perf record) and display annotated code
archive Create archive with object files with build-ids found in perf.data file
bench General framework for benchmark suites
buildid-cache Manage build-id cache.
buildid-list List the buildids in a perf.data file
data Data file related processing
diff Read perf.data files and display the differential profile
evlist List the event names in a perf.data file
inject Filter to augment the events stream with additional information
kmem Tool to trace/measure kernel memory properties
kvm Tool to trace/measure kvm guest os
list List all symbolic event types
lock Analyze lock events
mem Profile memory accesses
record Run a command and record its profile into perf.data
report Read perf.data (created by perf record) and display the profile
sched Tool to trace/measure scheduler properties (latencies)
script Read perf.data (created by perf record) and display trace output
stat Run a command and gather performance counter statistics
test Runs sanity tests.
timechart Tool to visualize total system behavior during a workload
top System profiling tool.
probe Define new dynamic tracepoints
trace strace inspired tool

See 'perf help COMMAND' for more information on a specific command.

Perf list,perf 事件

使用perf list命令可以列出所有能够触发 perf 采样点的事件。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ perf list 
List of pre-defined events (to be used in -e):
cpu-cycles OR cycles [Hardware event]
instructions [Hardware event]

cpu-clock [Software event]
task-clock [Software event]
context-switches OR cs [Software event]

ext4:ext4_allocate_inode [Tracepoint event]
kmem:kmalloc [Tracepoint event]
module:module_load [Tracepoint event]
workqueue:workqueue_execution [Tracepoint event]
sched:sched_{wakeup,switch} [Tracepoint event]
syscalls:sys_{enter,exit}_epoll_wait [Tracepoint event]


不同的系统会列出不同的结果,在 2.6.35 版本的内核中,该列表已经相当的长,但无论有多少,我们可以将它们划分为三类:

  • Hardware Event 是由 PMU 硬件产生的事件,比如 cache 命中,当您需要了解程序对硬件特性的使用情况时,便需要对这些事件进行采样;
  • Software Event 是内核软件产生的事件,比如进程切换,tick 数等 ;
  • Tracepoint event 是内核中的静态 tracepoint 所触发的事件,这些 tracepoint 用来判断程序运行期间内核的行为细节,比如 slab 分配器的分配次数等。

上述每一个事件都可以用于采样,并生成一项统计数据,时至今日,尚没有文档对每一个 event 的含义进行详细解释。我希望能和大家一起努力,以弄明白更多的 event 为目标。。。

Perf stat

做任何事都最好有条有理。老手往往能够做到不慌不忙,循序渐进,而新手则往往东一下,西一下,不知所措。

面对一个问题程序,最好采用自顶向下的策略。先整体看看该程序运行时各种统计事件的大概,再针对某些方向深入细节。而不要一下子扎进琐碎细节,会一叶障目的。

有些程序慢是因为计算量太大,其多数时间都应该在使用 CPU 进行计算,这叫做 CPU bound 型;有些程序慢是因为过多的 IO,这种时候其 CPU 利用率应该不高,这叫做 IO bound 型;对于 CPU bound 程序的调优和 IO bound 的调优是不同的。

如果您认同这些说法的话,Perf stat 应该是您最先使用的一个工具。它通过概括精简的方式提供被调试程序运行的整体情况和汇总数据。

还记得我们前面准备的那个例子程序么?现在将它编译为可执行文件 t1

1
gcc –o t1 – g test.c

下面演示了 perf stat 针对程序 t1 的输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$perf stat ./t1 
Performance counter stats for './t1':

262.738415 task-clock-msecs ## 0.991 CPUs
2 context-switches ## 0.000 M/sec
1 CPU-migrations ## 0.000 M/sec
81 page-faults ## 0.000 M/sec
9478851 cycles ## 36.077 M/sec (scaled from 98.24%)
6771 instructions ## 0.001 IPC (scaled from 98.99%)
111114049 branches ## 422.908 M/sec (scaled from 99.37%)
8495 branch-misses ## 0.008 % (scaled from 95.91%)
12152161 cache-references ## 46.252 M/sec (scaled from 96.16%)
7245338 cache-misses ## 27.576 M/sec (scaled from 95.49%)

0.265238069 seconds time elapsed

上面告诉我们,程序 t1 是一个 CPU bound 型,因为 task-clock-msecs 接近 1。
对 t1 进行调优应该要找到热点 ( 即最耗时的代码片段 ),再看看是否能够提高热点代码的效率。

缺省情况下,除了 task-clock-msecs 之外,perf stat 还给出了其他几个最常用的统计信息:

  • Task-clock-msecs:CPU 利用率,该值高,说明程序的多数时间花费在 CPU 计算上而非 IO。
  • Context-switches:进程切换次数,记录了程序运行过程中发生了多少次进程切换,频繁的进程切换是应该避免的。
  • Cache-misses:程序运行过程中总体的 cache 利用情况,如果该值过高,说明程序的 cache 利用不好
  • CPU-migrations:表示进程 t1 运行过程中发生了多少次 CPU 迁移,即被调度器从一个 CPU 转移到另外一个 CPU 上运行。
  • Cycles:处理器时钟,一条机器指令可能需要多个 cycles,
  • Instructions: 机器指令数目。
  • IPC:是 Instructions/Cycles 的比值,该值越大越好,说明程序充分利用了处理器的特性。
  • Cache-references: cache 命中的次数
  • Cache-misses: cache 失效的次数。

通过指定 -e 选项,您可以改变 perf stat 的缺省事件 ( 关于事件,在上一小节已经说明,可以通过 perf list 来查看 )。假如您已经有很多的调优经验,可能会使用 -e 选项来查看您所感兴趣的特殊的事件。

1
2
3
4
5
6
7
8
9
10
11
12
-e <event>:指定性能事件(可以是多个,用,分隔列表)
-p <pid>:指定待分析进程的 pid(可以是多个,用,分隔列表)
-t <tid>;:指定待分析线程的 tid(可以是多个,用,分隔列表)
-a:从所有 CPU 收集系统数据
-d:打印更详细的信息,可重复 3 次
-d:L1 和 LLC data cache
-d -d:dTLB 和 iTLB events
-d -d -d:增加 prefetch events
-r <n>;:重复运行命令 n 次,打印平均值。n 设为 0 时无限循环打印
-c <cpu-list>:只统计指定 CPU 列表的数据,如:0,1,3或1-2
-A:与-a选项联用,不要将 CPU 计数聚合
-I <N msecs>:每隔 N 毫秒打印一次计数器的变化,N 最小值为 100 毫秒

perf top

使用 perf stat 的时候,往往您已经有一个调优的目标。比如我刚才写的那个无聊程序 t1。

也有些时候,您只是发现系统性能无端下降,并不清楚究竟哪个进程成为了贪吃的 hog。

此时需要一个类似 top 的命令,列出所有值得怀疑的进程,从中找到需要进一步审查的家伙。类似法制节目中办案民警常常做的那样,通过查看监控录像从茫茫人海中找到行为古怪的那些人,而不是到大街上抓住每一个人来审问。

Perf top 用于实时显示当前系统的性能统计信息。该命令主要用来观察整个系统当前的状态,比如可以通过查看该命令的输出来查看当前系统最耗时的内核函数或某个用户进程。

让我们再设计一个例子来演示吧。

不知道您怎么想,反正我觉得做一件有益的事情很难,但做点儿坏事儿却非常容易。我很快就想到了如代码清单 2 所示的一个程序:

清单 2. 一个死循环

1
while (1) i++;

我叫他 t2。启动 t2,然后用 perf top 来观察:

下面是 perf top 的可能输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PerfTop: 705 irqs/sec kernel:60.4% [1000Hz cycles] 
--------------------------------------------------
sampl pcnt function DSO
1503.00 49.2% t2
72.00 2.2% pthread_mutex_lock /lib/libpthread-2.12.so
68.00 2.1% delay_tsc [kernel.kallsyms]
55.00 1.7% aes_dec_blk [aes_i586]
55.00 1.7% drm_clflush_pages [drm]
52.00 1.6% system_call [kernel.kallsyms]
49.00 1.5% __memcpy_ssse3 /lib/libc-2.12.so
48.00 1.4% __strstr_ia32 /lib/libc-2.12.so
46.00 1.4% unix_poll [kernel.kallsyms]
42.00 1.3% __ieee754_pow /lib/libm-2.12.so
41.00 1.2% do_select [kernel.kallsyms]
40.00 1.2% pixman_rasterize_edges libpixman-1.so.0.18.0
37.00 1.1% _raw_spin_lock_irqsave [kernel.kallsyms]
36.00 1.1% _int_malloc /lib/libc-2.12.so

很容易便发现 t2 是需要关注的可疑程序。不过其作案手法太简单:肆无忌惮地浪费着 CPU。所以我们不用再做什么其他的事情便可以找到问题所在。但现实生活中,影响性能的程序一般都不会如此愚蠢,所以我们往往还需要使用其他的 perf 工具进一步分析。

通过添加 -e 选项,您可以列出造成其他事件的 TopN 个进程 / 函数。比如 -e cache-miss,用来看看谁造成的 cache miss 最多。

1
2
3
4
5
6
7
-e <event>:指明要分析的性能事件。
-p <pid>:仅分析目标进程及其创建的线程。
-k <path>:带符号表的内核映像所在的路径。
-K:不显示属于内核或模块的符号。
-U:不显示属于用户态程序的符号。
-d <n>:界面的刷新周期,默认为2s。
-g:得到函数的调用关系图。

使用 perf record, 解读 report

使用 top 和 stat 之后,您可能已经大致有数了。要进一步分析,便需要一些粒度更细的信息。比如说您已经断定目标程序计算量较大,也许是因为有些代码写的不够精简。那么面对长长的代码文件,究竟哪几行代码需要进一步修改呢?这便需要使用 perf record 记录单个函数级别的统计信息,并使用 perf report 来显示统计结果。

perf record收集一段时间内的性能事件到文件 perf.data,随后需要用perf report命令分析

1
2
3
4
5
6
7
8
9
10
11
-e <event>:指定性能事件(可以是多个,用,分隔列表)
-p <pid>:指定待分析进程的 pid(可以是多个,用,分隔列表)
-t <tid>:指定待分析线程的 tid(可以是多个,用,分隔列表)
-u <uid>:指定收集的用户数据,uid为名称或数字
-a:从所有 CPU 收集系统数据
-g:开启 call-graph (stack chain/backtrace) 记录
-C <cpu-list>:只统计指定 CPU 列表的数据,如:0,1,3或1-2
-r <RT priority>:perf 程序以SCHED_FIFO实时优先级RT priority运行这里填入的数值越大,进程优先级越高(即 nice 值越小)
-c <count>: 事件每发生 count 次采一次样
-F <n>:每秒采样 n 次
-o <output.data>:指定输出文件output.data,默认输出到perf.data

—call-graph 堆栈展开的方式,perf支持3种方式:perf record -g --call-graph (fp,dwarf,lbr)

  • fp: perf默认采用的方式,需要关闭对堆栈有影响的编译优化(-fno-omit-frame-pointer,-fno-optimize-sibling-calls),否则可能获取不到正确的堆栈信息。
    • 优点:性能消耗小,生成文件小,report速度快。
    • 缺点:不遵守X86Calling convention的函数无法获取堆栈信息,内联函数无法获取堆栈信息。
  • dwarf:
    • 优点:堆栈信息最详细,内联函数也可以获取堆栈信息。
    • 缺点:性能消耗大,生成文件大,report时间长。
  • lbr:
    • 优点:性能消耗极小,堆栈信息非常准确
    • 缺点:需要处理器支持(尝试了阿里云的ECS并不支持,所以实际没有使用过)。

您的调优应该将注意力集中到百分比高的热点代码片段上,假如一段代码只占用整个程序运行时间的 0.1%,即使您将其优化到仅剩一条机器指令,恐怕也只能将整体的程序性能提高 0.1%。俗话说,好钢用在刀刃上,不必我多说了。

仍以 t1 为例。

1
2
perf record – e cpu-clock ./t1 
perf report

结果如下图所示:


图 2. perf report 示例

不出所料,hot spot 是longa()函数。

但,代码是非常复杂难说的,t1 程序中的 foo1() 也是一个潜在的调优对象,为什么要调用 100 次那个无聊的 longa() 函数呢?但我们在上图中无法发现 foo1 和 foo2,更无法了解他们的区别了。

我曾发现自己写的一个程序居然有近一半的时间花费在 string 类的几个方法上,string 是 C++ 标准,我绝不可能写出比 STL 更好的代码了。因此我只有找到自己程序中过多使用 string 的地方。因此我很需要按照调用关系进行显示的统计信息。

使用 perf 的 -g 选项便可以得到需要的信息:

1
2
perf record – e cpu-clock – g ./t1 
perf report

结果如下图所示:


图 3. perf – g report 示例

通过对 calling graph 的分析,能很方便地看到 91% 的时间都花费在 foo1() 函数中,因为它调用了 100 次 longa() 函数,因此假如 longa() 是个无法优化的函数,那么程序员就应该考虑优化 foo1,减少对 longa() 的调用次数。

之前的命令:

1
2
3
4
5
6
7
8
9
sudo perf record -g -a --call-graph dwarf,65000 -p 进程号 -d 1 -b
sudo perf report -i perf.data > perf.txt

火焰图:
perf report -i perf.data > perf.txt
perf script > out.perf

./stackcollapse-perf.pl out.perf > out.folded
./flamegraph.pl out.folded > kernel.svg

Perf Script

读取 Perf Record 结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-i, --input <file>    input file name
-G, --hide-call-graph
When printing symbols do not display call chain
-F, --fields <str> comma separated output fields prepend with 'type:'. Valid types: hw,sw,trace,raw. Fields: comm,tid,pid,time,cpu,event,trace,ip,sym,dso,addr,symoff,period
-a, --all-cpus system-wide collection from all CPUs
-S, --symbols <symbol[,symbol...]>
only consider these symbols
-C, --cpu <cpu> list of cpus to profile
-c, --comms <comm[,comm...]>
only display events for these comms
--pid <pid[,pid...]>
only consider symbols in these pids
--tid <tid[,tid...]>
only consider symbols in these tids
--time <str> Time span of interest (start,stop)
--show-kernel-path
Show the path of [kernel.kallsyms]
--show-task-events
Show the fork/comm/exit events
--show-mmap-events
Show the mmap events
--per-event-dump Dump trace output to files named by the monitored events

使用 PMU 的例子

例子 t1 和 t2 都较简单。所谓魔高一尺,道才能高一丈。要想演示 perf 更加强大的能力,我也必须想出一个高明的影响性能的例子,我自己想不出,只好借助于他人。下面这个例子 t3 参考了文章“Branch and Loop Reorganization to Prevent Mispredicts”

该例子考察程序对奔腾处理器分支预测的利用率,如前所述,分支预测能够显著提高处理器的性能,而分支预测失败则显著降低处理器的性能。首先给出一个存在 BTB 失效的例子:

清单 3. 存在 BTB 失效的例子程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//test.c 
#include <stdio.h>
#include <stdlib.h>

void foo()
{
int i,j;
for(i=0; i< 10; i++)
j+=2;
}
int main(void)
{
int i;
for(i = 0; i< 100000000; i++)
foo();
return 0;
}

用 gcc 编译生成测试程序 t3:
1
gcc – o t3 – O0 test.c

用 perf stat 考察分支预测的使用情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[lm@ovispoly perf]$ ./perf stat ./t3 

Performance counter stats for './t3':

6240.758394 task-clock-msecs ## 0.995 CPUs
126 context-switches ## 0.000 M/sec
12 CPU-migrations ## 0.000 M/sec
80 page-faults ## 0.000 M/sec
17683221 cycles ## 2.834 M/sec (scaled from 99.78%)
10218147 instructions ## 0.578 IPC (scaled from 99.83%)
2491317951 branches ## 399.201 M/sec (scaled from 99.88%)
636140932 branch-misses ## 25.534 % (scaled from 99.63%)
126383570 cache-references ## 20.251 M/sec (scaled from 99.68%)
942937348 cache-misses ## 151.093 M/sec (scaled from 99.58%)

6.271917679 seconds time elapsed

可以看到 branche-misses 的情况比较严重,25% 左右。我测试使用的机器的处理器为 Pentium4,其 BTB 的大小为 16。而 test.c 中的循环迭代为 20 次,BTB 溢出,所以处理器的分支预测将不准确。

对于上面这句话我将简要说明一下,但关于 BTB 的细节,请阅读参考文献。

for 循环编译成为 IA 汇编后如下:

清单 4. 循环的汇编

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// C code 
for ( i=0; i < 20; i++ )
{ … }

//Assembly code;
mov esi, data
mov ecx, 0
ForLoop:
cmp ecx, 20
jge
EndForLoop

add ecx, 1
jmp ForLoop
EndForLoop:

可以看到,每次循环迭代中都有一个分支语句 jge,因此在运行过程中将有 20 次分支判断。每次分支判断都将写入 BTB,但 BTB 是一个 ring buffer,16 个 slot 写满后便开始覆盖。假如迭代次数正好为 16,或者小于 16,则完整的循环将全部写入 BTB,比如循环迭代次数为 4 次,则 BTB 应该如下图所示:


图 4. BTB buffer

这个 buffer 完全精确地描述了整个循环迭代的分支判定情况,因此下次运行同一个循环时,处理器便可以做出完全正确的预测。但假如迭代次数为 20,则该 BTB 随着时间推移而不能完全准确地描述该循环的分支预测执行情况,处理器将做出错误的判断。

我们将测试程序进行少许的修改,将迭代次数从 20 减少到 10,为了让逻辑不变,j++ 变成了 j+=2;

清单 5. 没有 BTB 失效的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h> 
#include <stdlib.h>

void foo()
{
int i,j;
for(i=0; i< 10; i++)
j+=2;
}
int main(void)
{
int i;
for(i = 0; i< 100000000; i++)
foo();
return 0;
}

此时再次用 perf stat 采样得到如下结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[lm@ovispoly perf]$ ./perf stat ./t3 

Performance counter stats for './t3:

2784.004851 task-clock-msecs ## 0.927 CPUs
90 context-switches ## 0.000 M/sec
8 CPU-migrations ## 0.000 M/sec
81 page-faults ## 0.000 M/sec
33632545 cycles ## 12.081 M/sec (scaled from 99.63%)
42996 instructions ## 0.001 IPC (scaled from 99.71%)
1474321780 branches ## 529.569 M/sec (scaled from 99.78%)
49733 branch-misses ## 0.003 % (scaled from 99.35%)
7073107 cache-references ## 2.541 M/sec (scaled from 99.42%)
47958540 cache-misses ## 17.226 M/sec (scaled from 99.33%)

3.002673524 seconds time elapsed

Branch-misses 减少了。

特殊用法以及内核调优示例

之前介绍了 perf 最常见的一些用法,关注于 Linux 系统上应用程序的调优。现在让我们把目光转移到内核以及其他 perf 命令上面来。

在内核方面,人们的兴趣五花八门,有些内核开发人员热衷于寻找整个内核中的热点代码;另一些则只关注某一个主题,比如 slab 分配器,对于其余部分则不感兴趣。对这些人而言,perf 的一些奇怪用法更受欢迎。当然,诸如 perf top,perf stat, perf record 等也是内核调优的基本手段,但用法和 part1 所描述的一样,无需重述。

此外虽然内核事件对应用程序开发人员而言有些陌生,但一旦了解,对应用程序的调优也很有帮助。我曾经参与开发过一个数据库应用程序,其效率很低。通过常规的热点查询,IO 统计等方法,我们找到了一些可以优化的地方,以至于将程序的效率提高了几倍。可惜对于拥有海量数据的用户,其运行时间依然无法达到要求。进一步调优需要更加详细的统计信息,可惜本人经验有限,实在是无计可施。。。从客户反馈来看,该应用的使用频率很低。作为一个程序员,为此我时常心情沮丧。。。

假如有 perf,那么我想我可以用它来验证自己的一些猜测,比如是否太多的系统调用,或者系统中的进程切换太频繁 ? 针对这些怀疑使用 perf 都可以拿出有用的报告,或许能找到问题吧。但过去的便无可弥补,时光不会倒流,无论我如何伤感,世界绝不会以我的意志为转移。所以我们好好学习 perf,或许可以预防某些遗憾吧。

这里我还要提醒读者注意,讲述 perf 的命令和语法容易,但说明什么时候使用这些命令,或者说明怎样解决实际问题则很困难。就好象说明电子琴上 88 个琴键的唱名很容易,但想说明如何弹奏动听的曲子则很难。

在简述每个命令语法的同时,我试图通过一些示例来说明这些命令的使用场景,但这只能是一种微薄的努力。因此总体说来,本文只能充当那本随同电子琴一起发售的使用说明书。。。

使用 tracepoint

当 perf 根据 tick 时间点进行采样后,人们便能够得到内核代码中的 hot spot。那什么时候需要使用 tracepoint 来采样呢?

我想人们使用 tracepoint 的基本需求是对内核的运行时行为的关心,如前所述,有些内核开发人员需要专注于特定的子系统,比如内存管理模块。这便需要统计相关内核函数的运行情况。另外,内核行为对应用程序性能的影响也是不容忽视的:

以之前的遗憾为例,假如时光倒流,我想我要做的是统计该应用程序运行期间究竟发生了多少次系统调用。在哪里发生的?

下面我用 ls 命令来演示 sys_enter 这个 tracepoint 的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[root@ovispoly /]# perf stat -e raw_syscalls:sys_enter ls 
bin dbg etc lib media opt root selinux sys usr
boot dev home lost+found mnt proc sbin srv tmp var

Performance counter stats for 'ls':

101 raw_syscalls:sys_enter

0.003434730 seconds time elapsed


[root@ovispoly /]# perf record -e raw_syscalls:sys_enter ls

[root@ovispoly /]# perf report
Failed to open .lib/ld-2.12.so, continuing without symbols
# Samples: 70
#
# Overhead Command Shared Object Symbol
# ........ ............... ............... ......
#
97.14% ls ld-2.12.so [.] 0x0000000001629d
2.86% ls [vdso] [.] 0x00000000421424
#
# (For a higher level overview, try: perf report --sort comm,dso)
#

这个报告详细说明了在 ls 运行期间发生了多少次系统调用 ( 上例中有 101 次 ),多数系统调用都发生在哪些地方 (97% 都发生在 ld-2.12.so 中 )。

有了这个报告,或许我能够发现更多可以调优的地方。比如函数 foo() 中发生了过多的系统调用,那么我就可以思考是否有办法减少其中有些不必要的系统调用。

您可能会说 strace 也可以做同样事情啊,的确,统计系统调用这件事完全可以用 strace 完成,但 perf 还可以干些别的,您所需要的就是修改 -e 选项后的字符串。

罗列 tracepoint 实在是不太地道,本文当然不会这么做。但学习每一个 tracepoint 是有意义的,类似背单词之于学习英语一样,是一项缓慢痛苦却不得不做的事情。

Perf probe

tracepoint 是静态检查点,意思是一旦它在哪里,便一直在那里了,您想让它移动一步也是不可能的。内核代码有多少行?我不知道,100 万行是至少的吧,但目前 tracepoint 有多少呢?我最大胆的想象是不超过 1000 个。所以能够动态地在想查看的地方插入动态监测点的意义是不言而喻的。

Perf 并不是第一个提供这个功能的软件,systemTap 早就实现了。但假若您不选择 RedHat 的发行版的话,安装 systemTap 并不是件轻松愉快的事情。perf 是内核代码包的一部分,所以使用和维护都非常方便。

我使用的 Linux 版本为 2.6.33。因此您自己做实验时命令参数有可能不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[root@ovispoly perftest]# perf probe schedule:12 cpu 
Added new event:
probe:schedule (on schedule+52 with cpu)

You can now use it on all perf tools, such as:

perf record -e probe:schedule -a sleep 1

[root@ovispoly perftest]# perf record -e probe:schedule -a sleep 1
Error, output file perf.data exists, use -A to append or -f to overwrite.

[root@ovispoly perftest]# perf record -f -e probe:schedule -a sleep 1
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.270 MB perf.data (~11811 samples) ]
[root@ovispoly perftest]# perf report
# Samples: 40
#
# Overhead Command Shared Object Symbol
# ........ ............... ................. ......
#
57.50% init 0 [k] 0000000000000000
30.00% firefox [vdso] [.] 0x0000000029c424
5.00% sleep [vdso] [.] 0x00000000ca7424
5.00% perf.2.6.33.3-8 [vdso] [.] 0x00000000ca7424
2.50% ksoftirqd/0 [kernel] [k] 0000000000000000
#
# (For a higher level overview, try: perf report --sort comm,dso)
#

上例利用 probe 命令在内核函数 schedule() 的第 12 行处加入了一个动态 probe 点,和 tracepoint 的功能一样,内核一旦运行到该 probe 点时,便会通知 perf。可以理解为动态增加了一个新的 tracepoint。

此后便可以用 record 命令的 -e 选项选择该 probe 点,最后用 perf report 查看报表。如何解读该报表便是见仁见智了,既然您在 shcedule() 的第 12 行加入了 probe 点,想必您知道自己为什么要统计它吧?

比如你想跟踪kernel的某个function, 甚至某一行代码,某些变量的值。或者你想跟踪用户软件的某个function,甚至某一行代码,某些变量的值。首先要添加需要动态跟踪的对象(function, var, …),然后record,和report分析,这和前面的用法是一样的。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
Listing variables available for tcp_sendmsg():

# perf probe -V tcp_sendmsg
Available variables at tcp_sendmsg
@<tcp_sendmsg+0>
size_t size
struct kiocb* iocb
struct msghdr* msg
struct sock* sk
Creating a probe for tcp_sendmsg() with the "size" variable:

# perf probe --add 'tcp_sendmsg size'
Added new event:
probe:tcp_sendmsg (on tcp_sendmsg with size)

You can now use it in all perf tools, such as:

perf record -e probe:tcp_sendmsg -aR sleep 1
Tracing this probe:

# perf record -e probe:tcp_sendmsg -a
^C[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.052 MB perf.data (~2252 samples) ]
# perf script
# ========
# captured on: Fri Jan 31 23:49:55 2014
# hostname : dev1
# os release : 3.13.1-ubuntu-12-opt
# perf version : 3.13.1
# arch : x86_64
# nrcpus online : 2
# nrcpus avail : 2
# cpudesc : Intel(R) Xeon(R) CPU E5645 @ 2.40GHz
# cpuid : GenuineIntel,6,44,2
# total memory : 1796024 kB
# cmdline : /usr/bin/perf record -e probe:tcp_sendmsg -a
# event : name = probe:tcp_sendmsg, type = 2, config = 0x1dd, config1 = 0x0, config2 = ...
# HEADER_CPU_TOPOLOGY info available, use -I to display
# HEADER_NUMA_TOPOLOGY info available, use -I to display
# pmu mappings: software = 1, tracepoint = 2, breakpoint = 5
# ========
#
sshd 1301 [001] 502.424719: probe:tcp_sendmsg: (ffffffff81505d80) size=b0
sshd 1301 [001] 502.424814: probe:tcp_sendmsg: (ffffffff81505d80) size=40
sshd 2371 [000] 502.952590: probe:tcp_sendmsg: (ffffffff81505d80) size=27
sshd 2372 [000] 503.025023: probe:tcp_sendmsg: (ffffffff81505d80) size=3c0
sshd 2372 [001] 503.203776: probe:tcp_sendmsg: (ffffffff81505d80) size=98
sshd 2372 [001] 503.281312: probe:tcp_sendmsg: (ffffffff81505d80) size=2d0
sshd 2372 [001] 503.461358: probe:tcp_sendmsg: (ffffffff81505d80) size=30
sshd 2372 [001] 503.670239: probe:tcp_sendmsg: (ffffffff81505d80) size=40
sshd 2372 [001] 503.742565: probe:tcp_sendmsg: (ffffffff81505d80) size=140
sshd 2372 [001] 503.822005: probe:tcp_sendmsg: (ffffffff81505d80) size=20
sshd 2371 [000] 504.118728: probe:tcp_sendmsg: (ffffffff81505d80) size=30
sshd 2371 [000] 504.192575: probe:tcp_sendmsg: (ffffffff81505d80) size=70
[...]
The size is shown as hexadecimal.

跟踪某行代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# perf probe -L tcp_sendmsg
<tcp_sendmsg@/mnt/src/linux-3.14.5/net/ipv4/tcp.c:0>
0 int tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t size)
2 {
struct iovec *iov;
struct tcp_sock *tp = tcp_sk(sk);
struct sk_buff *skb;
6 int iovlen, flags, err, copied = 0;
7 int mss_now = 0, size_goal, copied_syn = 0, offset = 0;
bool sg;
long timeo;
[...]
79 while (seglen > 0) {
int copy = 0;
81 int max = size_goal;

skb = tcp_write_queue_tail(sk);
84 if (tcp_send_head(sk)) {
85 if (skb->ip_summed == CHECKSUM_NONE)
max = mss_now;
87 copy = max - skb->len;
}

90 if (copy <= 0) {
new_segment:
[...]


# perf probe -V tcp_sendmsg:81
Available variables at tcp_sendmsg:81
@<tcp_sendmsg+537>
bool sg
int copied
int copied_syn
int flags
int mss_now
int offset
int size_goal
long int timeo
size_t seglen
struct iovec* iov
struct sock* sk
unsigned char* from


Now lets trace line 81, with the seglen variable that is checked in the loop:

# perf probe --add 'tcp_sendmsg:81 seglen'
Added new event:
probe:tcp_sendmsg (on tcp_sendmsg:81 with seglen)

You can now use it in all perf tools, such as:

perf record -e probe:tcp_sendmsg -aR sleep 1

# perf record -e probe:tcp_sendmsg -a
^C[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.188 MB perf.data (~8200 samples) ]
# perf script
sshd 4652 [001] 2082360.931086: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x80
app_plugin.pl 2400 [001] 2082360.970489: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x20
postgres 2422 [000] 2082360.970703: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x52
app_plugin.pl 2400 [000] 2082360.970890: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x7b
postgres 2422 [001] 2082360.971099: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0xb
app_plugin.pl 2400 [000] 2082360.971140: probe:tcp_sendmsg: (ffffffff81642ca9) seglen=0x55
[...]

跟踪用户软件的指定function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# perf probe -x /lib/x86_64-linux-gnu/libc-2.15.so --add malloc
Added new event:
probe_libc:malloc (on 0x82f20)

You can now use it in all perf tools, such as:

perf record -e probe_libc:malloc -aR sleep 1

Tracing it system-wide:

# perf record -e probe_libc:malloc -a
^C[ perf record: Woken up 12 times to write data ]
[ perf record: Captured and wrote 3.522 MB perf.data (~153866 samples) ]
The report:

# perf report -n
[...]
# Samples: 45K of event 'probe_libc:malloc'
# Event count (approx.): 45158
#
# Overhead Samples Command Shared Object Symbol
# ........ ............ ............... ............. ..........
#
42.72% 19292 apt-config libc-2.15.so [.] malloc
19.71% 8902 grep libc-2.15.so [.] malloc
7.88% 3557 sshd libc-2.15.so [.] malloc
6.25% 2824 sed libc-2.15.so [.] malloc
6.06% 2738 which libc-2.15.so [.] malloc
4.12% 1862 update-motd-upd libc-2.15.so [.] malloc
3.72% 1680 stat libc-2.15.so [.] malloc
1.68% 758 login libc-2.15.so [.] malloc
1.21% 546 run-parts libc-2.15.so [.] malloc
1.21% 545 ls libc-2.15.so [.] malloc
0.80% 360 dircolors libc-2.15.so [.] malloc
0.56% 252 tr libc-2.15.so [.] malloc
0.54% 242 top libc-2.15.so [.] malloc
0.49% 222 irqbalance libc-2.15.so [.] malloc
0.44% 200 dpkg libc-2.15.so [.] malloc
0.38% 173 lesspipe libc-2.15.so [.] malloc
0.29% 130 update-motd-fsc libc-2.15.so [.] malloc
0.25% 112 uname libc-2.15.so [.] malloc
0.24% 108 cut libc-2.15.so [.] malloc
0.23% 104 groups libc-2.15.so [.] malloc
0.21% 94 release-upgrade libc-2.15.so [.] malloc
0.18% 82 00-header libc-2.15.so [.] malloc
0.14% 62 mesg libc-2.15.so [.] malloc
0.09% 42 update-motd-reb libc-2.15.so [.] malloc
0.09% 40 date libc-2.15.so [.] malloc
0.08% 35 bash libc-2.15.so [.] malloc
0.08% 35 basename libc-2.15.so [.] malloc
0.08% 34 dirname libc-2.15.so [.] malloc
0.06% 29 sh libc-2.15.so [.] malloc
0.06% 26 99-footer libc-2.15.so [.] malloc
0.05% 24 cat libc-2.15.so [.] malloc
0.04% 18 expr libc-2.15.so [.] malloc
0.04% 17 rsyslogd libc-2.15.so [.] malloc
0.03% 12 stty libc-2.15.so [.] malloc
0.00% 1 cron libc-2.15.so [.] malloc
This shows the most malloc() calls were by apt-config, while I was tracing.

User: malloc() with size

As of the Linux 3.13.1 kernel, this is not supported yet:

# perf probe -x /lib/x86_64-linux-gnu/libc-2.15.so --add 'malloc size'
Debuginfo-analysis is not yet supported with -x/--exec option.
Error: Failed to add events. (-38)
As a workaround, you can access the registers (on Linux 3.7+). For example, on x86_64:

# perf probe -x /lib64/libc-2.17.so '--add=malloc size=%di'
probe_libc:malloc (on 0x800c0 with size=%di)

Perf sched

调度器的好坏直接影响一个系统的整体运行效率。在这个领域,内核黑客们常会发生争执,一个重要原因是对于不同的调度器,每个人给出的评测报告都各不相同,甚至常常有相反的结论。因此一个权威的统一的评测工具将对结束这种争论有益。Perf sched 便是这种尝试。

Perf sched 有五个子命令:

  • perf sched record # low-overhead recording of arbitrary workloads
  • perf sched latency # output per task latency metrics
  • perf sched map # show summary/map of context-switching
  • perf sched trace # output finegrained trace
  • perf sched replay # replay a captured workload using simlated threads

用户一般使用’ perf sched record ’收集调度相关的数据,然后就可以用’ perf sched latency ’查看诸如调度延迟等和调度器相关的统计数据。

其他三个命令也同样读取 record 收集到的数据并从其他不同的角度来展示这些数据。下面一一进行演示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
perf sched record sleep 10     # record full system activity for 10 seconds 
perf sched latency --sort max # report latencies sorted by max

-------------------------------------------------------------------------------------
Task | Runtime ms | Switches | Average delay ms | Maximum delay ms |
-------------------------------------------------------------------------------------
:14086:14086 | 0.095 ms | 2 | avg: 3.445 ms | max: 6.891 ms |
gnome-session:13792 | 31.713 ms | 102 | avg: 0.160 ms | max: 5.992 ms |
metacity:14038 | 49.220 ms | 637 | avg: 0.066 ms | max: 5.942 ms |
gconfd-2:13971 | 48.587 ms | 777 | avg: 0.047 ms | max: 5.793 ms |
gnome-power-man:14050 | 140.601 ms | 434 | avg: 0.097 ms | max: 5.367 ms |
python:14049 | 114.694 ms | 125 | avg: 0.120 ms | max: 5.343 ms |
kblockd/1:236 | 3.458 ms | 498 | avg: 0.179 ms | max: 5.271 ms |
Xorg:3122 | 1073.107 ms | 2920 | avg: 0.030 ms | max: 5.265 ms |
dbus-daemon:2063 | 64.593 ms | 665 | avg: 0.103 ms | max: 4.730 ms |
:14040:14040 | 30.786 ms | 255 | avg: 0.095 ms | max: 4.155 ms |
events/1:8 | 0.105 ms | 13 | avg: 0.598 ms | max: 3.775 ms |
console-kit-dae:2080 | 14.867 ms | 152 | avg: 0.142 ms | max: 3.760 ms |
gnome-settings-:14023 | 572.653 ms | 979 | avg: 0.056 ms | max: 3.627 ms |
...
-----------------------------------------------------------------------------------
TOTAL: | 3144.817 ms | 11654 |
---------------------------------------------------

上面的例子展示了一个 Gnome 启动时的统计信息。各个 column 的含义如下:

  • Task: 进程的名字和 pid
  • Runtime: 实际运行时间
  • Switches: 进程切换的次数
  • Average delay: 平均的调度延迟
  • Maximum delay: 最大延迟

这里最值得人们关注的是 Maximum delay,一般从这里可以看到对交互性影响最大的特性:调度延迟,如果调度延迟比较大,那么用户就会感受到视频或者音频断断续续的。

其他的三个子命令提供了不同的视图,一般是由调度器的开发人员或者对调度器内部实现感兴趣的人们所使用。

首先是 map:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ perf sched map 
...

N1 O1 . . . S1 . . . B0 . *I0 C1 . M1 . 23002.773423 secs
N1 O1 . *Q0 . S1 . . . B0 . I0 C1 . M1 . 23002.773423 secs
N1 O1 . Q0 . S1 . . . B0 . *R1 C1 . M1 . 23002.773485 secs
N1 O1 . Q0 . S1 . *S0 . B0 . R1 C1 . M1 . 23002.773478 secs
*L0 O1 . Q0 . S1 . S0 . B0 . R1 C1 . M1 . 23002.773523 secs
L0 O1 . *. . S1 . S0 . B0 . R1 C1 . M1 . 23002.773531 secs
L0 O1 . . . S1 . S0 . B0 . R1 C1 *T1 M1 . 23002.773547 secs
T1 => irqbalance:2089
L0 O1 . . . S1 . S0 . *P0 . R1 C1 T1 M1 . 23002.773549 secs
*N1 O1 . . . S1 . S0 . P0 . R1 C1 T1 M1 . 23002.773566 secs
N1 O1 . . . *J0 . S0 . P0 . R1 C1 T1 M1 . 23002.773571 secs
N1 O1 . . . J0 . S0 *B0 P0 . R1 C1 T1 M1 . 23002.773592 secs
N1 O1 . . . J0 . *U0 B0 P0 . R1 C1 T1 M1 . 23002.773582 secs
N1 O1 . . . *S1 . U0 B0 P0 . R1 C1 T1 M1 . 23002.773604 secs

星号表示调度事件发生所在的 CPU。

点号表示该 CPU 正在 IDLE。

Map 的好处在于提供了一个的总的视图,将成百上千的调度事件进行总结,显示了系统任务在 CPU 之间的分布,假如有不好的调度迁移,比如一个任务没有被及时迁移到 idle 的 CPU 却被迁移到其他忙碌的 CPU,类似这种调度器的问题可以从 map 的报告中一眼看出来。

如果说 map 提供了高度概括的总体的报告,那么 trace 就提供了最详细,最底层的细节报告。

1
2
3
4
5
6
7
8
9
10
pipe-test-100k-13520 [001]  1254.354513808: sched_stat_wait: 
task: pipe-test-100k:13521 wait: 5362 [ns]
pipe-test-100k-13520 [001] 1254.354514876: sched_switch:
task pipe-test-100k:13520 [120] (S) ==> pipe-test-100k:13521 [120]
:13521-13521 [001] 1254.354517927: sched_stat_runtime:
task: pipe-test-100k:13521 runtime: 5092 [ns], vruntime: 133967391150 [ns]
:13521-13521 [001] 1254.354518984: sched_stat_sleep:
task: pipe-test-100k:13520 sleep: 5092 [ns]
:13521-13521 [001] 1254.354520011: sched_wakeup:
task pipe-test-100k:13520 [120] success=1 [001]

要理解以上的信息,必须对调度器的源代码有一定了解,对一般用户而言,理解他们十分不易。幸好这些信息一般也只有编写调度器的人感兴趣。。。

Perf replay 这个工具更是专门为调度器开发人员所设计,它试图重放 perf.data 文件中所记录的调度场景。很多情况下,一般用户假如发现调度器的奇怪行为,他们也无法准确说明发生该情形的场景,或者一些测试场景不容易再次重现,或者仅仅是出于“偷懒”的目的,使用 perf replay,perf 将模拟 perf.data 中的场景,无需开发人员花费很多的时间去重现过去,这尤其利于调试过程,因为需要一而再,再而三地重复新的修改是否能改善原始的调度场景所发现的问题。

下面是 replay 执行的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ perf sched replay 
run measurement overhead: 3771 nsecs
sleep measurement overhead: 66617 nsecs
the run test took 999708 nsecs
the sleep test took 1097207 nsecs
nr_run_events: 200221
nr_sleep_events: 200235
nr_wakeup_events: 100130
task 0 ( perf: 13519), nr_events: 148
task 1 ( perf: 13520), nr_events: 200037
task 2 ( pipe-test-100k: 13521), nr_events: 300090
task 3 ( ksoftirqd/0: 4), nr_events: 8
task 4 ( swapper: 0), nr_events: 170
task 5 ( gnome-power-man: 3192), nr_events: 3
task 6 ( gdm-simple-gree: 3234), nr_events: 3
task 7 ( Xorg: 3122), nr_events: 5
task 8 ( hald-addon-stor: 2234), nr_events: 27
task 9 ( ata/0: 321), nr_events: 29
task 10 ( scsi_eh_4: 704), nr_events: 37
task 11 ( events/1: 8), nr_events: 3
task 12 ( events/0: 7), nr_events: 6
task 13 ( flush-8:0: 6980), nr_events: 20
------------------------------------------------------------
#1 : 2038.157, ravg: 2038.16, cpu: 0.09 / 0.09
#2 : 2042.153, ravg: 2038.56, cpu: 0.11 / 0.09
^C

perf bench

除了调度器之外,很多时候人们都需要衡量自己的工作对系统性能的影响。benchmark 是衡量性能的标准方法,对于同一个目标,如果能够有一个大家都承认的 benchmark,将非常有助于”提高内核性能”这项工作。

目前,就我所知,perf bench 提供了 3 个 benchmark:

  1. Sched message

    1
    2
    [lm@ovispoly ~]$ perf bench sched messaging
    # Running sched/messaging benchmark...# 20 sender and receiver processes per group# 10 groups == 400 processes run Total time: 1.918 [sec]

    sched message 是从经典的测试程序 hackbench 移植而来,用来衡量调度器的性能,overhead 以及可扩展性。该 benchmark 启动 N 个 reader/sender 进程或线程对,通过 IPC(socket 或者 pipe) 进行并发的读写。一般人们将 N 不断加大来衡量调度器的可扩展性。Sched message 的用法及用途和 hackbench 一样。

  2. Sched Pipe

    1
    2
    [lm@ovispoly ~]$ perf bench sched pipe
    # Running sched/pipe benchmark...# Extecuted 1000000 pipe operations between two tasks Total time: 20.888 [sec] 20.888017 usecs/op 47874 ops/sec

    sched pipe 从 Ingo Molnar 的 pipe-test-1m.c 移植而来。当初 Ingo 的原始程序是为了测试不同的调度器的性能和公平性的。其工作原理很简单,两个进程互相通过 pipe 拼命地发 1000000 个整数,进程 A 发给 B,同时 B 发给 A。。。因为 A 和 B 互相依赖,因此假如调度器不公平,对 A 比 B 好,那么 A 和 B 整体所需要的时间就会更长。

  3. Mem memcpy

    1
    2
    [lm@ovispoly ~]$ perf bench mem memcpy
    # Running mem/memcpy benchmark...# Copying 1MB Bytes from 0xb75bb008 to 0xb76bc008 ... 364.697301 MB/Sec

    这个是 perf bench 的作者 Hitoshi Mitake 自己写的一个执行 memcpy 的 benchmark。该测试衡量一个拷贝 1M 数据的 memcpy() 函数所花费的时间。我尚不明白该 benchmark 的使用场景。。。或许是一个例子,告诉人们如何利用 perf bench 框架开发更多的 benchmark 吧。

这三个 benchmark 给我们展示了一个可能的未来:不同语言,不同肤色,来自不同背景的人们将来会采用同样的 benchmark,只要有一份 Linux 内核代码即可。

perf lock

锁是内核同步的方法,一旦加了锁,其他准备加锁的内核执行路径就必须等待,降低了并行。因此对于锁进行专门分析应该是调优的一项重要工作。

我运行 perf lock 后得到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Name acquired contended total wait (ns) max wait (ns) min 

&md->map_lock 396 0 0 0
&(&mm->page_tabl... 309 0 0 0
&(&tty->buf.lock... 218 0 0 0
&ctx->lock 185 0 0 0
key 178 0 0 0
&ctx->lock 132 0 0 0
&tty->output_loc... 126 0 0 0
。。。
&(&object->lock)... 1 0 0 0
&(&object->lock)... 0 0 0 0
&(&object->lock)... 0 0 0 0
&p->cred_guard_m... 0 0 0 0

=== output for debug===

bad: 28, total: 664
bad rate: 4.216867 %
histogram of events caused bad sequence
acquire: 8
acquired: 0
contended: 0
release: 20

对该报表的一些解释如下:

  • “Name”: 锁的名字,比如 md->map_lock,即定义在 dm.c 结构 mapped_device 中的读写锁。
  • “acquired”: 该锁被直接获得的次数,即没有其他内核路径拥有该锁的情况下得到该锁的次数。
  • “contended”冲突的次数,即在准备获得该锁的时候已经被其他人所拥有的情况的出现次数。
  • “total wait”:为了获得该锁,总共的等待时间。
  • “max wait”:为了获得该锁,最大的等待时间。
  • “min wait”:为了获得该锁,最小的等待时间。

目前 perf lock 还处于比较初级的阶段,我想在后续的内核版本中,还应该会有较大的变化,因此当您开始使用 perf lock 时,恐怕已经和本文这里描述的有所不同了。不过我又一次想说的是,命令语法和输出并不是最重要的,重要的是了解什么时候我们需要用这个工具,以及它能帮我们解决怎样的问题。

perf tracepoint

event中的一种类型,实际上是一些比较常见的系统调用。

不在里面的可以使用前面介绍的动态跟踪的方式进行跟踪。

支持哪些tracepoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
perf list | awk -F: '/Tracepoint event/ { lib[$1]++ } END {for (l in lib) { printf "  %-16s %d\n", l, lib[l] } }' | sort | column

block 18 jbd2 11 kvmmmu 9 napi 1 sched 15 skb 3 timer 12 writeback 16
ext4 46 kmem 42 mce 1 net 4 scsi 5 sock 2 udp 1 xfs 314
irq 5 kvm 21 module 5 power 3 signal 2 syscalls 548 workqueue 4

perf list
......
xfs:xfs_attr_list_sf [Tracepoint event]
xfs:xfs_attr_list_sf_all [Tracepoint event]
xfs:xfs_attr_list_leaf [Tracepoint event]
xfs:xfs_attr_list_leaf_end [Tracepoint event]
xfs:xfs_attr_list_full [Tracepoint event]
xfs:xfs_attr_list_add [Tracepoint event]
......

主要包含以下tracepoint subtype

1
2
3
4
5
6
7
block: block device I/O
ext3, ext4: file system operations
kmem: kernel memory allocation events
random: kernel random number generator events
sched: CPU scheduler events
syscalls: system call enter and exits
task: task events

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
I used perf_events to record the block request (disk I/O) issue and completion static tracepoints:

# perf record -e block:block_rq_issue -e block:block_rq_complete -a sleep 120
[ perf record: Woken up 36 times to write data ]
[ perf record: Captured and wrote 8.885 MB perf.data (~388174 samples) ]
# perf script
[...]
randread.pl 2522 [000] 6011.824759: block:block_rq_issue: 254,16 R 0 () 7322849 + 16 [randread.pl]
randread.pl 2520 [000] 6011.824866: block:block_rq_issue: 254,16 R 0 () 26144801 + 16 [randread.pl]
swapper 0 [000] 6011.828913: block:block_rq_complete: 254,16 R () 31262577 + 16 [0]
randread.pl 2521 [000] 6011.828970: block:block_rq_issue: 254,16 R 0 () 70295937 + 16 [randread.pl]
swapper 0 [000] 6011.835862: block:block_rq_complete: 254,16 R () 26144801 + 16 [0]
randread.pl 2520 [000] 6011.835932: block:block_rq_issue: 254,16 R 0 () 5495681 + 16 [randread.pl]
swapper 0 [000] 6011.837988: block:block_rq_complete: 254,16 R () 7322849 + 16 [0]
randread.pl 2522 [000] 6011.838051: block:block_rq_issue: 254,16 R 0 () 108589633 + 16 [randread.pl]
swapper 0 [000] 6011.850615: block:block_rq_complete: 254,16 R () 108589633 + 16 [0]
[...]

perf Kmem

Perf Kmem 专门收集内核 slab 分配器的相关事件。比如内存分配,释放等。可以用来研究程序在哪里分配了大量内存,或者在什么地方产生碎片之类的和内存管理相关的问题。

Perf kmem 和 perf lock 实际上都是 perf tracepoint 的特例,您也完全可以用 Perf record – e kmem: 或者 perf record – e lock: 来完成同样的功能。但重要的是,这些工具在内部对原始数据进行了汇总和分析,因而能够产生信息更加明确更加有用的统计报表。

perf kmem 的输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[root@ovispoly perf]# ./perf kmem --alloc -l 10 --caller stat 
---------------------------------------------------------------------------
Callsite | Total_alloc/Per | Total_req/Per | Hit | Ping-pong| Frag
---------------------------------------------------------------------------
perf_mmap+1a8 | 1024/1024 | 572/572|1 | 0 | 44.141%
seq_open+15| 12384/96 | 8772/68 |129 | 0 | 29.167%
do_maps_open+0| 1008/16 | 756/12 |63 | 0 | 25.000%
...| ... | ...| ... | ... | ...
__split_vma+50| 88/88 | 88/88 | 1 | 0 | 0.000%
---------------------------------------------------------------------------
Alloc Ptr | Total_alloc/Per | Total_req/Per | Hit |Ping-pong| Frag
---------------------------------------------------------------------------
0xd15d4600|64/64 | 33/33 1 | 0 | 48.438%
0xc461e000|1024/1024 | 572/572 |1 | 0 | 44.141%
0xd15d44c0| 64/64 | 38/38 |1 | 0 | 40.625%
... | ... | ... | ... | ... | ...
---------------------------------------------------------------------------

SUMMARY
=======
Total bytes requested: 10487021
Total bytes allocated: 10730448
Total bytes wasted on internal fragmentation: 243427
Internal fragmentation: 2.268563%
Cross CPU allocations: 0/246458

该报告有三个部分:根据 Callsite 显示的部分,所谓 Callsite 即内核代码中调用 kmalloc 和 kfree 的地方。比如上图中的函数 perf_mmap,Hit 栏为 1,表示该函数在 record 期间一共调用了 kmalloc 一次,假如如第三行所示数字为 653,则表示函数 sock_alloc_send_pskb 共有 653 次调用 kmalloc 分配内存。

对于第一行 Total_alloc/Per 显示为 1024/1024,第一个值 1024 表示函数 perf_mmap 总共分配的内存大小,Per 表示平均值。

比较有趣的两个参数是 Ping-pong 和 Frag。Frag 比较容易理解,即内部碎片。虽然相对于 Buddy System,Slab 正是要解决内部碎片问题,但 slab 依然存在内部碎片,比如一个 cache 的大小为 1024,但需要分配的数据结构大小为 1022,那么有 2 个字节成为碎片。Frag 即碎片的比例。

Ping-pong 是一种现象,在多 CPU 系统中,多个 CPU 共享的内存会出现”乒乓现象”。一个 CPU 分配内存,其他 CPU 可能访问该内存对象,也可能最终由另外一个 CPU 释放该内存对象。而在多 CPU 系统中,L1 cache 是 per CPU 的,CPU2 修改了内存,那么其他的 CPU 的 cache 都必须更新,这对于性能是一个损失。Perf kmem 在 kfree 事件中判断 CPU 号,如果和 kmalloc 时的不同,则视为一次 ping-pong,理想的情况下 ping-pone 越小越好。Ibm developerworks 上有一篇讲述 oprofile 的文章,其中关于 cache 的调优可以作为很好的参考资料。

后面则有根据被调用地点的显示方式的部分。

最后一个部分是汇总数据,显示总的分配的内存和碎片情况,Cross CPU allocation 即 ping-pong 的汇总。

Perf timechart

很多 perf 命令都是为调试单个程序或者单个目的而设计。有些时候,性能问题并非由单个原因所引起,需要从各个角度一一查看。为此,人们常需要综合利用各种工具,比如 top,vmstat,oprofile 或者 perf。这非常麻烦。

此外,前面介绍的所有工具都是基于命令行的,报告不够直观。更令人气馁的是,一些报告中的参数令人费解。所以人们更愿意拥有一个“傻瓜式”的工具。

以上种种就是 perf timechart 的梦想,其灵感来源于 bootchart。采用“简单”的图形“一目了然”地揭示问题所在。

加注了引号的原因是,perf timechart 虽然有了美观的图形输出,但对于新手,这个图形就好象高科技节目中播放的 DNA 图像一样,不明白那些坐在屏幕前的人是如何从密密麻麻的点和线中找到有用的信息的。但正如受过训练的科学家一样,经过一定的练习,相信您也一定能从下图中找到您想要的。


图 1. perf timechart

人们说,只有黑白两色是一个人内心压抑的象征,Timechart 用不同的颜色代表不同的含义。上图的最上面一行是图例,告诉人们每种颜色所代表的含义。蓝色表示忙碌,红色表示 idle,灰色表示等待,等等。

接下来是 per-cpu 信息,上图所示的系统中有两个处理器,可以看到在采样期间,两个处理器忙碌程度的概括。蓝色多的地方表示忙碌,因此上图告诉我们,CPU1 很忙,而 CPU2 很闲。

再下面是 per-process 信息,每一个进程有一个 bar。上图中进程 bash 非常忙碌,而其他进程则大多数时间都在等待着什么。Perf 自己在开始的时候很忙,接下来便开始 wait 了。

总之这张图告诉了我们一个系统的概况,但似乎不够详细?

Timechart 可以显示更详细的信息,上图实际上是一个矢量图形 SVG 格式,用 SVG viewer 的放大功能,我们可以将该图的细节部分放大,timechart 的设计理念叫做”infinitely zoomable”。放大之后便可以看到一些更详细的信息,类似网上的 google 地图,找到国家之后,可以放大,看城市的分布,再放大,可以看到某个城市的街道分布,还可以放大以便得到更加详细的信息。

完整的 timechart 图形和颜色解读超出了本文的范围,感兴趣的读者可以到作者 Arjan 的博客上查看。这里仅举一个例子,上图中有一条 bar 对应了 Xorg 进程。多数时候该进程都处于 waiting 状态,只有需要显示什么的时候它才会开始和内核通信,以便进行绘图所需的 IO 操作。

将 Xorg 条目放大的例子图形如下:


图 2. perf timechart detail

上图中需要注意的是几条绿色的短线,表示进程通信,即准备绘图。假如通信的两个进程在图中上下相邻,那么绿线可以连接他们。但如果不相邻,则只会显示如上图所示的被截断的绿色短线。

蓝色部分表示进程忙碌,黄色部分表示该进程的时间片已经用完,但仍处于就绪状态,在等待调度器给予 CPU。

通过这张图,便可以较直观地看到进程在一段时间内的详细行为。

绘制perf火焰图

使用perf report -tui或者-stdio输出的文本不够直观的话,使用火焰图可以很直观的表现出哪些代码是瓶颈所在。

1
2
3
4
5
压测
$pgbench -M prepared -n -r -P 1 -c 32 -j 32 -T 100

收集统计信息
#perf record -a -g -v sleep 30

生成火焰图

1
2
3
4
5
# git clone https://github.com/brendangregg/FlameGraph      # or download it from github
# mv perf.data FlameGraph/
# cd FlameGraph
# perf script | ./stackcollapse-perf.pl > out.perf-folded
# cat out.perf-folded | ./flamegraph.pl > perf-kernel.svg

pic

绘制perf热力图

1
2
3
4
5
压测
$pgbench -M prepared -n -r -P 1 -c 32 -j 32 -T 100

收集统计信息
#perf record -a -g -v sleep 30

生成热力图

1
2
3
4
5
6
7
# git clone https://github.com/brendangregg/HeatMap      # or download it from github
# mv perf.data HeatMap/
# cd HeatMap
# perf script | awk '{ gsub(/:/, "") } $5 ~ /issue/ { ts[$6, $10] = $4 }
$5 ~ /complete/ { if (l = ts[$6, $9]) { printf "%.f %.f\n", $4 * 1000000,
($4 - l) * 1000000; ts[$6, $10] = 0 } }' > out.lat_us
# ./trace2heatmap.pl --unitstime=us --unitslat=us --maxlat=50000 out.lat_us > out.svg

使用 Script 增强 perf 的功能

通常,面对看似复杂,实则较有规律的计算机输出,程序员们总是会用脚本来进行处理:比如给定一个文本文件,想从中找出有多少个数字 0125,人们不会打开文件然后用肉眼去一个一个地数,而是用 grep 命令来进行处理。

perf 的输出虽然是文本格式,但还是不太容易分析和阅读。往往也需要进一步处理,perl 和 python 是目前最强大的两种脚本语言。Tom Zanussi 将 perl 和 python 解析器嵌入到 perf 程序中,从而使得 perf 能够自动执行 perl 或者 python 脚本进一步进行处理,从而为 perf 提供了强大的扩展能力。因为任何人都可以编写新的脚本,对 perf 的原始输出数据进行所需要的进一步处理。这个特性所带来的好处很类似于 plug-in 之于 eclipse。

下面的命令可以查看系统中已经安装的脚本:

1
2
3
4
5
# perf trace -l 
List of available trace scripts:
syscall-counts [comm] system-wide syscall counts
syscall-counts-by-pid [comm] system-wide syscall counts, by pid
failed-syscalls-by-pid [comm] system-wide failed syscalls, by pid

比如 failed-syscalls 脚本,执行的效果如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# perf trace record failed-syscalls 
^C[ perf record: Woken up 11 times to write data ]
[ perf record: Captured and wrote 1.939 MB perf.data (~84709 samples) ]

perf trace report failed-syscalls
perf trace started with Perl script \
/root/libexec/perf-core/scripts/perl/failed-syscalls.pl

failed syscalls, by comm:

comm # errors
-------------------- ----------
firefox 1721
claws-mail 149
konsole 99
X 77
emacs 56
[...]

failed syscalls, by syscall:

syscall # errors
------------------------------ ----------
sys_read 2042
sys_futex 130
sys_mmap_pgoff 71
sys_access 33
sys_stat64 5
sys_inotify_add_watch 4
[...]

该报表分别按进程和按系统调用显示失败的次数。非常简单明了,而如果通过普通的 perf record 加 perf report 命令,则需要自己手工或者编写脚本来统计这些数字。

遇到的其它问题

非root用户运行perf时出现的警告

1
2
3
4
5
6
7
8
9
10
11
user# perf record  --call-graph dwarf -e task-clock,cpu-clock  -p pid
WARNING: Kernel address maps (/proc/{kallsyms,modules}) are restricted,
check /proc/sys/kernel/kptr_restrict.

Samples in kernel functions may not be resolved if a suitable vmlinux
file is not found in the buildid cache or in the vmlinux path.

Samples in kernel modules won't be resolved at all.

If some relocation was applied (e.g. kexec) symbols may be misresolved
even with a suitable vmlinux or kallsyms file.

原因是perf只能采集所允许用户空间下的事件,可使用:u指定

1
user# perf record  --call-graph dwarf -e task-clock:u,cpu-clock:u  -p pid

非root用户运行perf时使用-a选项出现的警告

1
2
Warning:
PID/TID switch overriding SYSTEM

原因是非root用户不能使用-a来对所有内核事件进行采样

1
2
3
4
5
6
mapping pages error
user# perf record --call-graph dwarf -e task-clock:u,cpu-clock:u -p pid -m 256
Permission error mapping pages.
Consider increasing /proc/sys/kernel/perf_event_mlock_kb,
or try again with a smaller value of -m/--mmap_pages.
(current value: 256,0)

原因是-m 选项指定的mmap data pages的大小超过perf系统设置中限定的最大值,该最大值可通过以下方式查看:

1
2
user# cat /proc/sys/kernel/perf_event_mlock_kb     
516

如果不填-m选项,默认使用最大值。

另外要注意的是perf_event_mlock_kb是该用户可使用的最大值,如果用户同时运行了多个perf,则多个perf使用共享该值。例如,perf_event_mlock_kb设置512,有2个perf正在运行,则每个perf可使用512/2即256.

另外,如果-m所填参数不是2的幂次方,perf会帮你向上扩充到2的幂次方,例如,你输入-m 12,perf会帮你扩展到16.

qsub教程

PBS作业管理,即以qsub、qstat、qdel命令为核心的集群作业管理系统,且它是开源的。

在此环境下运行,用户不需要指定程序在哪些节点上运行,程序所需的硬件资源由PBS管理和分配。

qsub、qstat、qdel的功能分别为“提交作业”、“查看作业状态”、“删除作业”。

非PBS下mpi计算

通常来说,使用mpirun即可,例如mpirun -np 16 ./pom.kii2b.exe < /dev/null > pom.log,意为在一个节点上使用16核执行pom.kii2b.exe,stdin重定向至空,stdout重定向至pom.log。

之后可能会再执行几个mv命令之类的,例如把pom.log文件移动至别处,防止多次调用时覆盖log。

PBS作业提交

直接执行qsub会提示输入PBS作业脚本,这样很不方便,因此通常来说都是把qsub脚本写到文件里,重定向输入来进行作业提交,例如qsub < cess.sbpom.qsub(其实不加<也可以),提交一个写在cess.sbpom.qsub文件里的PBS脚本。

PBS脚本形如下面那段代码从#!/bin/shmv pom.log ../$TIME.pom.log的部分。

由于存在需要多次提交相似作业的可能,例如我有20个文件夹的数据,每个文件夹里数据的处理方式相同,调用同一个程序,总不能写20个PBS脚本。因此更为通常的做法是,使用shell脚本进行qsub脚本输出再提交,举例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    PNAME=fz_letkf
NODE=1
NP=16
QSUBTIME="24:00:00"
NOWDIR=`pwd`
QSUB=cess.letkf.qsub
LETKF=letkf020.m01
cat <<EOF >$QSUB
#!/bin/sh
#PBS -q hpca
#PBS -V
#PBS -N $PNAME
#PBS -l nodes=$NODE:ppn=$NP
#PBS -l walltime="$QSUBTIME"
#PBS -o /home/xfh_stu/WORK3/qsublog
#PBS -j oe
cd $NOWDIR
mpirun ./$LETKF < /dev/null
mv pom.log ../$TIME.pom.log
EOF
qsub $QSUB >cessrunid

调用此脚本就会自动将PBS脚本输出至$QSUB文件中,并提交此作业。通常在这段代码外面会套上循环,每次修改相应变量,从而实现一次提交多个相似作业。

在这里cat命令使用了一种叫做heredoc的写法,用于输出大段文字,同时还要替换其中的变量。界定符EOF是可以自定义的,不过通常来说都使用EOF。另外,用于结束的界定符必须顶格写(想不顶格也是可以的,但是有其他限制,且不方便)。

命令格式:

1
2
3
4
5
6
qsub [-a date_time] [-c interval] [-C directive_prefix]
[-e path] [-I] [-j join] [-k keep] [-l resource_list] [-m mail_options]
[-M user_list][-N name] [-o path] [-p priority] [-q destination] [-r c]
[-S path_list] [-u user_list][-v variable_list] [-V]
[-W additional_attributes] [-z]
[script]

下面解释一下参数的意思。

  • -q:指定使用的队列。可使用qstat -q查看队列信息,包括队列名、资源限制、正在运行的任务数等。
  • -V:将执行qsub命令时拥有的环境变量都export该作业。
  • -N:指定作业名。
  • -l:指定作业使用的节点数与核数、时间限制等。
  • -o:重定向此作业的stdout至指定的文件夹中,名为作业名.o作业ID。
  • -j oe:合并此作业的stdout与stderr。

qsub成功提交作业后,会在stdout输出Job ID,形如作业ID.主机名,例如15252.manager。在上面的例子中,我将qsub的结果重定向至cessrunid文件中,用于存储作业ID,以便后续处理。

查看作业状态

执行qstat即可查看当前正在执行的作业以及刚刚完成的作业。在S那一列,C表示已完成,R表示正在执行,Q表示正在等待,还有一些其他不常见的状态,可以man qstat并以job state为关键词查询即可。

命令格式:qatat [-f][-a][-i] [-n][-s] [-R] [-Q][-q][-B][-u]
参数说明:
-f jobid 列出指定作业的信息
-a 列出系统所有作业
-i 列出不在运行的作业
-n 列出分配给此作业的结点
-s 列出队列管理员与scheduler所提供的建议
-R 列出磁盘预留信息
-Q 操作符是destination id,指明请求的是队列状态
-q 列出队列状态,并以alternative形式显示
-au userid 列出指定用户的所有作业
-B 列出PBS Server信息
-r 列出所有正在运行的作业
-Qf queue 列出指定队列的信息
-u 若操作符为作业号,则列出其状态。
若操作符为destination id,则列出运行在其上的属于user_list中用户的作业状态。

现在还有个问题,即如何在脚本中判断作业完成与否呢?一个很实际的例子是,我在脚本中一次提交完多个作业后,后续的脚本必须在完成这些作业后才能继续执行,那么就需要知道这些作业有没有完成。

通常有两种思路:

一是查看程序本应输出的文件有没有正常输出,即判断输出文件是否存在。或者在程序中写一些日志输出语句,脚本就可以通过查找日志文件某关键的一句话有没有输出从而知道程序运行有没有正常完成。

二是查看上文中通过-o参数指定的作业stdout输出文件,文件名为作业名.o作业ID。这也就是为什么我要把qsub提交信息保存到cessrunid这个文件里。通常来说,只要作业正常完成了,就会生成此文件。

对于第一种思路,就要根据程序具体情况来编写了。

对于第二种思路,一个典型的判断脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
while :
do

temp=`cat cessrunid`
runid=${PNAME}.o${temp%.manager}
runfilename='/home/xfh_stu/WORK3/qsublog/'$runid
if [ -f "$runfilename" ]; then
break
fi

sleep 60

done

大意为:

提取出cessrunid这个文件里的qsub提交信息,并去掉后面的.manager主机名(CESS集群主机名为manager),之后改写成作业名.o作业ID的形式,并加上路径,判断该文件是否存在。

如果文件存在,则说明作业已完成,即可break掉这个无限循环,继续后面的操作了。

这里需要注意的一个地方就是,在无限循环里的每次判断中间要加上一个sleep语句,比如我设置的是每分钟跑一次循环,这样机器就不会由于每时每刻都在执行判断而耗尽资源。

删除作业

执行qdel -W 15 15303即可在15秒后停止并删除Job ID为15303的作业。

管理作业

qmgr 命令—用于队列管理

1
2
3
4
5
6
qmgr -c “create queue batch queue_type=execution”
qmgr -c “set queue batch started=true
qmgr -c “set queue batch enabled=true
qmgr -c “set queue batch resources_default.nodes=1″
qmgr -c “set queue batch resources_default.walltime=3600″
qmgr -c “set server default_queue=batch”

脚本的正确使用方法

通常来说,我们都是在shell脚本中进行qsub脚本输出再提交该qsub脚本。

这里存在一个问题,即shell脚本自身需要后台执行。如果执行前台执行脚本,就会导致断开SSH连接后,脚本就会停止执行。

因此,需要使用nohup ./your.script.name.sh &命令,它可以脚本在后台执行且将stdout重定向至nohup.out文件中。要注意命令最后的&是不可缺少的,如果不写,脚本虽然也会在后台执行,但是在关闭SSH后就会停止。

另外,在执行完这个命令之后要按一下回车,使其回到shell上来。

当我们解决脚本后台执行的问题后,又出现了新问题,即如何停止该脚本?

通过脚本提交的PBS作业可以通过qdel命令结束掉,而脚本本身停止就需要kill掉该脚本的进程了。

首先,我们使用ps -ef | grep your.script.name.sh查询到脚本的进程PID,之后执行kill xxxxx即可停止PID为xxxxx的进程了。

tmux使用备忘

简介

tmux is a terminal multiplexer. It lets you switch easily between several programs in one terminal, detach them (they keep running in the background) and reattach them to a different terminal.

快捷键

操作 快捷键
启动 tmux
退出 ctrl + d 或者exit
前缀键 ctrl + b
查看帮助 ctrl + b ?
新建会话 tmux new -s blog
分离会话 ctrl + b d tmux detach
查看会话 tmux ls
接入会话 tmux attach -t 0
tmux attach -t blog
杀死会话 tmux kill-session -t 0
tmux kill-session -t blog
切换会话 tmux switch -t 0
tmux switch -t blog
重命名窗口 tmux rename-window <new-name>
ctrl + b ,
创建一个新窗口 ctrl + b c
切换上一个窗口 ctrl + b n
切换到指定编号窗口 ctrl + b number
从列表中选择窗口 ctrl + b w
列出所有快捷键 tmux list-keys
列出所有命令及参数 tmux list-commands
列出所有会话信息 tmux info
wwtmux配置 tmux source-file ~/.tmux.conf
左右分屏 ctrl + b %
上下分屏 ctrl + b "
切换分屏窗口 ctrl + b 方向键

切换pane

ctrl+b o 依次切换当前窗口下的各个pane。

ctrl+b Up|Down|Left|Right 根据按箭方向选择切换到某个pane。

ctrl+b Space (空格键) 对当前窗口下的所有pane重新排列布局,每按一次,换一种样式。

ctrl+b z 最大化当前pane。再按一次后恢复。

关闭pane

ctrl+b x 关闭当前使用中的pane,操作之后会给出是否关闭的提示,按y确认即关闭。

tmux window中的历史输出查看

在tmux里面,因为每个窗口(tmux window)的历史内容已经被tmux接管了,当我们在每个tmux的window之间进行来回切换,来回操作,那么我们没有办法看到一个window里面屏幕上的历史输出。没办法使用鼠标滚动(例如在SecureCRT中)查看之前的内容,在SecureCRT中通过鼠标滚动看到的输出一定是各个tmux的window的输出混乱夹杂在一起的,如果要看当前窗口的历史内容,那么应该怎么办呢,通过在当前的tmux window 按 ctrl-b 进入copy mode,然后就可以用PgUp/PgDn来浏览历史输出了,按q退出。

使用mpiP工具分析AMG程序

下载mpiP

mpiP需要依赖几个库:

  • libunwind : 用来收集调用栈信息
  • binutils : 用来解析程序地址到源代码行的信息

安装libunwind

安装的时候会在autoreconf -i这一步就报错,导致没有生成Makefile文件,应该先安装libtool这个工具

执行libtool中带的libtoolize,发现报错

1
2
3
4
5
6
7
8
9
10
11
12
[yuhao@localhost libunwind]$ ../libtool/bin/libtoolize 
libtoolize: putting auxiliary files in AC_CONFIG_AUX_DIR, 'config'.
libtoolize: linking file 'config/ltmain.sh'
libtoolize: You should add the contents of the following files to 'aclocal.m4':
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/libtool.m4'
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/ltoptions.m4'
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/ltsugar.m4'
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/ltversion.m4'
libtoolize: '/home/yuhao/tool/libtool/share/aclocal/lt~obsolete.m4'
libtoolize: Consider adding 'AC_CONFIG_MACRO_DIRS([m4])' to configure.ac,
libtoolize: and rerunning libtoolize and aclocal.
libtoolize: Consider adding '-I m4' to ACLOCAL_AMFLAGS in Makefile.am.

先尝试执行下边的命令,会报错:

1
2
3
4
5
6
7
8
[yuhao@localhost libunwind]$ aclocal -I /home/yuhao/tool/libtool/share/aclocal/
[yuhao@localhost libunwind]$ autoreconf -i
src/Makefile.am:10: error: Libtool library used but 'LIBTOOL' is undefined
src/Makefile.am:10: The usual way to define 'LIBTOOL' is to add 'LT_INIT'
src/Makefile.am:10: to 'configure.ac' and run 'aclocal' and 'autoconf' again.
src/Makefile.am:10: If 'LT_INIT' is in 'configure.ac', make sure
src/Makefile.am:10: its definition is in aclocal's search path.
autoreconf: automake failed with exit status: 1

尝试按照上边的提示,在configure.ac中加入AC_CONFIG_MACRO_DIRS([m4]),其中m4替换成libtool中的aclocal路径。再执行一遍libtoolize

1
2
[yuhao@localhost libunwind]$ ../libtool/bin/libtoolize 
libtoolize: Consider adding '-I /home/yuhao/tool/libtool/share/aclocal/' to ACLOCAL_AMFLAGS in Makefile.am.

又让把一个路径加到Makefile.am中

再从头开始执行一遍,总算好了。

1
2
3
4
5
[yuhao@localhost libunwind]$ ../libtool/bin/libtoolize 
[yuhao@localhost libunwind]$ aclocal
ac[yuhao@localhost libunwind]$ autoheader
a[yuhao@localhost libunwind]$ autoreconf
[yuhao@localhost libunwind]$ ./configure CC=icc CFLAGS="-std=c11 -g -O3 -ip" CXX=icpc CCASFLAGS=-g --prefix=/home/yuhao/tool/libunwind

编译的时候又报错了,最后使用了2019的intel编译器才能编译:

1
2
3
4
5
6
7
8
9
libtool: compile:  icc -DHAVE_CONFIG_H -I. -I../include -I../include -I../include/tdep-x86_64 -I. -D_GNU_SOURCE -DNDEBUG -g -std=c++11 -O3 -ip -D__EXTENSIONS__ -MT os-linux.lo -MD -MP -MF .deps/os-linux.Tpo -c os-linux.c  -fPIC -DPIC -o .libs/os-linux.o
icc: command line warning #10370: option '-std=c++11' is not valid for C compilations
In file included from ../include/tdep-x86_64/libunwind_i.h(41),
from ../include/tdep/libunwind_i.h(25),
from ../include/libunwind_i.h(356),
from os-linux.c(33):
../include/dwarf.h(355): error: identifier "_Atomic" is undefined
_Atomic uint32_t generation; /* generation number */

安装好依赖库后,相应配置mpiP的安装选项(依赖库路径,编译器,编译选项等),再按照提示安装即可。

mpiP生成一个动态库libmpiP.so,使用起来比较简单,不需要用它来重编程序。但为了得到正确的源文件和行号信息,最好用-g选项重编一下。两种使用方式

  • 将mpiP库与可执行文件链接起来。如果链接命令包含MPI库,要将mpiP库排序到MPI库之前,如 -l mpiP -lmpi
  • 在运行时设置LD_PRELOAD环境变量来加载mpiP库

mpiP提供一系列运行时选项给用户,通过打开它们来采集对应的信息。主要用到的一些列举如下:

  • -k n设置调用栈回溯的深度(默认1)
  • -o 在初始化时关掉profiling,在希望profiling的特定代码段通过MPI_Pcontrol()打开
  • -p 报告包含点对点通信的消息大小、通信子
  • -y 报告包含集合通信的消息大小、通信子

这些选项通过环境变量MPIP的设置来生效,如:export MPIP=”-t 10.0 -k 2” (bash)。

AMG编译和运行过程

选择候选程序中的AMG(https://github.com/LLNL/AMG)进行测试。clone下来之后,按照README进行编译。注意几个编译选项,开启了`-DHYPRE_USING_PERSISTENT_COMM`来使用MPI的重复非阻塞通信来提高性能,开启了`-DHYPRE_HOPSCOTCH`来提高OpenMP的优化。它还提供`-DHYPRE_PROFILE`来做一些很简单的计时,但这里没有用。

编译需要修改Makefile.include中的INCLUDE_CFLAGS,加上-g选项;同时修改INCLUDE_LFLAGS,加上-lmpip选项。

编译过程:依次进入子目录utilities,krylov,IJ_mv,parcsr_ls,parcsr_mv,seq_mv,编译各个源文件,并生成一个对应的静态库*.a,最后进入test目录编译和链接测试程序。

mpiP对AMG的分析结果

设置环境变量MPIP=”-k5,-e,-y,-p”,程序结束运行时输出一个mpiP的记录文件。

打开可以看到几部分。首先是记录了程序运行的信息,每个MPI进程的分布:

第二部分记录了每个进程花在MPI相关的函数上的时间,占总的时间比例:

可以看到通信负载并不是很均衡,最小通信耗时的进程只是最大的一半左右。

第三部分展示了各个MPI相关函数调用的信息,每一个ID对应一个位置的调用(同样的MPI函数出现在不同地方的调用,有不同的ID,如下图的Waitall),这里设置了调用栈回溯为5层,所以每个ID重复出现5次,Lev较大的是调用者,同时有每一个函数所在的源文件和行数。

如果程序所使用的进程很多,且通信模式复杂的话,这一部分将会非常庞大。这一次的运行,该部分共有47733条记录。

第四部分记录了总耗时在前20个的MPI函数调用。这里的site就对应上一部分的ID,可以据此判定这一项对应哪个位置的调用。

类似的记录还有发的消息总大小、次数、平均大小。按照消息总大小展示前20名。

由于打开了-y选项,下一部分展示了集合通信的总耗时、通信子大小、数据大小。可见程序中涉及的集合通信只有四个地方。但不知道为什么,这里没有site,不知道怎么去找对应的调用位置。

由于打开了-p选项,下一部分展示了点对点通信的通信子大小、消息大小等,MPI_Sent %似乎表示的是这一个调用占的发送消息的总比例,但也没有显示site。

最后一部分是每个调用位置的统计信息。该部分首先是时间信息,包括了操作时间的最大最小值和平均值,以及该调用位置耗时在它进程的MPI总时间和整个程序时间中的占比(Rank列中的星号代表该调用位置的累加的信息)。这部分的排序是按字母序的,a开头的Allreduce在前。

该部分然后是通信的消息大小的统计。

分析结果的简单分析和理解

mpiP只能简单测量通信特征,从以上结果,大致可以有几点判断:

  • 程序中的通信以点对点通信为主
    • ISend在发送消息的量上占比很高,各个位置的调用次数都很多,且通信消息的大小较大;对应使用的异步接收的Waiall说明是重复的非阻塞通信,且耗时占比很高
    • 集合通信的类型较少,耗时占比小,消息大小也很小
  • 最耗时的点对点通信(即程序中的update halo操作),消息大小都在5.5 KB左右
  • 通信负载很不均衡,MPI时间的min/max只有50%左右
  • 程序的通信热点所在的路径包含了函数hypre_ParCSRMatrixMatvecOutOfPlace,因为在调用位置的栈回溯中大量出现该函数(但仅通过通信特征来明确程序热点似乎有点不足,虽然它确实就是程序最耗时的部分)

小结

综合来看,要对一个程序进行性能分析,包括全面的计算、访存和通信,得需要多种的分析工具。光靠一种有点难以全面评估。而mpiP给出的信息只能覆盖通信特征,且缺乏直观,尤其在大规模进程数时,得到的callsite statistics非常庞大,需要用户自己通过site ID去找最耗时的通信调用对应在哪个地方。虽然通过mpiP的MPI_Pcontrol()可以实现任意具体代码段的分析,能减小输出的文件的规模,便于针对性的分析,但这就需要用户对程序代码本身有理解了才能在代码里添加MPI_Pcontrol()的控制。而且配合MPI_Pcontrol()启用-o选项后,不知道为什么,输出的文件里就没有具体通信调用处的通信子大小、消息大小等信息了。

Git的奇技淫巧

Git是一个 “分布式版本管理工具”,简单的理解版本管理工具:大家在写东西的时候都用过 “回撤” 这个功能,但是回撤只能回撤几步,假如想要找回我三天之前的修改,光用 “回撤” 是找不回来的。而 “版本管理工具” 能记录每次的修改,只要提交到版本仓库,你就可以找到之前任何时刻的状态(文本状态)。

下面的内容就是列举了常用的 Git 命令和一些小技巧,可以通过 “页面内查找” 的方式进行快速查询:Ctrl/Command+f

开卷必读

如果之前未使用过 Git,可以学习 Git 小白教程入门

  1. 一定要先测试命令的效果后,再用于工作环境中,以防造成不能弥补的后果!到时候别拿着砍刀来找我
  2. 所有的命令都在git version 2.7.4 (Apple Git-66)下测试通过
  3. 统一概念:
    • 工作区:改动(增删文件和内容)
    • 暂存区:输入命令:git add 改动的文件名,此次改动就放到了 ‘暂存区’
    • 本地仓库(简称:本地):输入命令:git commit 此次修改的描述,此次改动就放到了 ’本地仓库’,每个 commit,我叫它为一个 ‘版本’。
    • 远程仓库(简称:远程):输入命令:git push 远程仓库,此次改动就放到了 ‘远程仓库’(GitHub 等)
    • commit-id:输出命令:git log,最上面那行 commit xxxxxx,后面的字符串就是 commit-id

展示帮助信息

1
git help -g

The command output as below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
The common Git guides are:
attributes Defining attributes per path
cli Git command-line interface and conventions
core-tutorial A Git core tutorial for developers
cvs-migration Git for CVS users
diffcore Tweaking diff output
everyday A useful minimum set of commands for Everyday Git
glossary A Git Glossary
hooks Hooks used by Git
ignore Specifies intentionally untracked files to ignore
modules Defining submodule properties
namespaces Git namespaces
repository-layout Git Repository Layout
revisions Specifying revisions and ranges for Git
tutorial A tutorial introduction to Git
tutorial-2 A tutorial introduction to Git: part two
workflows An overview of recommended workflows with Git

'git help -a' and 'git help -g' list available subcommands and some concept guides. See 'git help <command>' or 'git help <concept>' to read about a specific subcommand or concept.

回到远程仓库的状态

抛弃本地所有的修改,回到远程仓库的状态。

1
git fetch --all && git reset --hard origin/master

重设第一个 commit

也就是把所有的改动都重新放回工作区,并清空所有的 commit,这样就可以重新提交第一个 commit 了

1
git update-ref -d HEAD

展示工作区和暂存区的不同

输出工作区暂存区的 different (不同)。

1
git diff

还可以展示本地仓库中任意两个 commit 之间的文件变动:

1
git diff <commit-id> <commit-id>

展示暂存区和最近版本的不同

输出暂存区和本地最近的版本 (commit) 的 different (不同)。

1
git diff --cached

展示暂存区、工作区和最近版本的不同

输出工作区暂存区 和本地最近的版本 (commit) 的 different (不同)。

1
git diff HEAD

快速切换到上一个分支

1
git checkout -

删除已经合并到 master 的分支

1
git branch --merged master | grep -v '^\*\|  master' | xargs -n 1 git branch -d

展示本地分支关联远程仓库的情况

1
git branch -vv

关联远程分支

关联之后,git branch -vv 就可以展示关联的远程分支名了,同时推送到远程仓库直接:git push,不需要指定远程仓库了。

1
git branch -u origin/mybranch

或者在 push 时加上 -u 参数

1
git push origin/mybranch -u

列出所有远程分支

-r 参数相当于:remote

1
git branch -r

列出本地和远程分支

-a 参数相当于:all

1
git branch -a

创建并切换到本地分支

1
git checkout -b <branch-name>

从远程分支中创建并切换到本地分支

1
git checkout -b <branch-name> origin/<branch-name>

删除本地分支

1
git branch -d <local-branchname>

删除远程分支

1
git push origin --delete <remote-branchname>

或者

1
git push origin :<remote-branchname>

重命名本地分支

1
git branch -m <new-branch-name>

查看标签

1
git tag

展示当前分支的最近的 tag

1
git describe --tags --abbrev=0

查看标签详细信息

1
git tag -ln

本地创建标签

1
git tag <version-number>

默认 tag 是打在最近的一次 commit 上,如果需要指定 commit 打 tag:

1
$ git tag -a <version-number> -m "v1.0 发布(描述)" <commit-id>

推送标签到远程仓库

首先要保证本地创建好了标签才可以推送标签到远程仓库:

1
git push origin <local-version-number>

一次性推送所有标签,同步到远程仓库:

1
git push origin --tags

删除本地标签

1
git tag -d <tag-name>

删除远程标签

删除远程标签需要先删除本地标签,再执行下面的命令:

1
git push origin :refs/tags/<tag-name>

切回到某个标签

一般上线之前都会打 tag,就是为了防止上线后出现问题,方便快速回退到上一版本。下面的命令是回到某一标签下的状态:

1
git checkout -b branch_name tag_name

放弃工作区的修改

1
git checkout <file-name>

放弃所有修改:

1
git checkout .

恢复删除的文件

1
2
3
git rev-list -n 1 HEAD -- <file_path> #得到 deleting_commit

git checkout <deleting_commit>^ -- <file_path> #回到删除文件 deleting_commit 之前的状态

以新增一个 commit 的方式还原某一个 commit 的修改

1
git revert <commit-id>

回到某个 commit 的状态,并删除后面的 commit

和 revert 的区别:reset 命令会抹去某个 commit id 之后的所有 commit

1
2
3
4
5
6
7
git reset <commit-id>  #默认就是-mixed参数。

git reset –mixed HEAD^ #回退至上个版本,它将重置HEAD到另外一个commit,并且重置暂存区以便和HEAD相匹配,但是也到此为止。工作区不会被更改。

git reset –soft HEAD~3 #回退至三个版本之前,只回退了commit的信息,暂存区和工作区与回退之前保持一致。如果还要提交,直接commit即可

git reset –hard <commit-id> #彻底回退到指定commit-id的状态,暂存区和工作区也会变为指定commit-id版本的内容

修改上一个 commit 的描述

如果暂存区有改动,同时也会将暂存区的改动提交到上一个 commit

1
git commit --amend

查看 commit 历史

1
git log

查看某段代码是谁写的

blame 的意思为‘责怪’,你懂的。

1
git blame <file-name>

显示本地更新过 HEAD 的 git 命令记录

每次更新了 HEAD 的 git 命令比如 commint、amend、cherry-pick、reset、revert 等都会被记录下来(不限分支),就像 shell 的 history 一样。
这样你可以 reset 到任何一次更新了 HEAD 的操作之后,而不仅仅是回到当前分支下的某个 commit 之后的状态。

1
git reflog

修改作者名

1
git commit --amend --author='Author Name <email@address.com>'

修改远程仓库的 url

1
git remote set-url origin <URL>

增加远程仓库

1
git remote add origin <remote-url>

列出所有远程仓库

1
git remote

查看两个星期内的改动

1
git whatchanged --since='2 weeks ago'

把 A 分支的某一个 commit,放到 B 分支上

这个过程需要 cherry-pick 命令,参考

1
git checkout <branch-name> && git cherry-pick <commit-id>

给 git 命令起别名

简化命令

1
2
3
4
5
git config --global alias.<handle> <command>

比如:git status 改成 git st,这样可以简化命令

git config --global alias.st status

存储当前的修改,但不用提交 commit

详解可以参考廖雪峰老师的 git 教程

1
git stash

保存当前状态,包括 untracked 的文件

untracked 文件:新建的文件

1
git stash -u

展示所有 stashes

1
git stash list

回到某个 stash 的状态

1
git stash apply <stash@{n}>

回到最后一个 stash 的状态,并删除这个 stash

1
git stash pop

删除所有的 stash

1
git stash clear

从 stash 中拿出某个文件的修改

1
git checkout <stash@{n}> -- <file-path>

展示所有 tracked 的文件

1
git ls-files -t

展示所有 untracked 的文件

1
git ls-files --others

展示所有忽略的文件

1
git ls-files --others -i --exclude-standard

强制删除 untracked 的文件

可以用来删除新建的文件。如果不指定文件文件名,则清空所有工作的 untracked 文件。clean 命令,注意两点

  1. clean 后,删除的文件无法找回
  2. 不会影响 tracked 的文件的改动,只会删除 untracked 的文件
1
git clean <file-name> -f

强制删除 untracked 的目录

可以用来删除新建的目录,注意:这个命令也可以用来删除 untracked 的文件。详情见上一条

1
git clean <directory-name> -df

展示简化的 commit 历史

1
git log --pretty=oneline --graph --decorate --all

把某一个分支到导出成一个文件

1
git bundle create <file> <branch-name>

从包中导入分支

新建一个分支,分支内容就是上面 git bundle create 命令导出的内容

1
git clone repo.bundle <repo-dir> -b <branch-name>

执行 rebase 之前自动 stash

1
git rebase --autostash

从远程仓库根据 ID,拉下某一状态,到本地分支

1
git fetch origin pull/<id>/head:<branch-name>

详细展示一行中的修改

1
git diff --word-diff

清除 gitignore 文件中记录的文件

1
git clean -X -f

展示所有 alias 和 configs

注意: config 分为:当前目录(local)和全局(golbal)的 config,默认为当前目录的 config

1
2
git config --local --list (当前目录)
git config --global --list (全局)

展示忽略的文件

1
git status --ignored

commit 历史中显示 Branch1 有的,但是 Branch2 没有 commit

1
git log Branch1 ^Branch2

在 commit log 中显示 GPG 签名

1
git log --show-signature

删除全局设置

1
git config --global --unset <entry-name>

新建并切换到新分支上,同时这个分支没有任何 commit

相当于保存修改,但是重写 commit 历史

1
git checkout --orphan <branch-name>

展示任意分支某一文件的内容

1
git show <branch-name>:<file-name>

clone 下来指定的单一分支

1
git clone -b <branch-name> --single-branch https://github.com/user/repo.git

忽略某个文件的改动

关闭 track 指定文件的改动,也就是 Git 将不会在记录这个文件的改动

1
git update-index --assume-unchanged path/to/file

恢复 track 指定文件的改动

1
git update-index --no-assume-unchanged path/to/file

忽略文件的权限变化

不再将文件的权限变化视作改动

1
git config core.fileMode false

以最后提交的顺序列出所有 Git 分支

最新的放在最上面

1
git for-each-ref --sort=-committerdate --format='%(refname:short)' refs/heads/

在 commit log 中查找相关内容

通过 grep 查找,given-text:所需要查找的字段

1
git log --all --grep='<given-text>'

把暂存区的指定 file 放到工作区中

不添加参数,默认是 -mixed

1
git reset <file-name>

强制推送

1
git push -f <remote-name> <branch-name>

一图详解

优雅的提交Commit信息

使用Angular团队提交规范

主要有以下组成

  • 标题行: 必填, 描述主要修改类型和内容
  • 主题内容: 描述为什么修改, 做了什么样的修改, 以及开发的思路等等
  • 页脚注释: 放 Breaking Changes 或 Closed Issues

常用的修改项

  • type: commit 的类型
  • feat: 新特性
  • fix: 修改问题
  • refactor: 代码重构
  • docs: 文档修改
  • style: 代码格式修改, 注意不是 css 修改
  • test: 测试用例修改
  • chore: 其他修改, 比如构建流程, 依赖管理.
  • scope: commit 影响的范围, 比如: route, component, utils, build…
  • subject: commit 的概述
  • body: commit 具体修改内容, 可以分为多行
  • footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.

memcached完全剖析

memcached是什么?

memcached是以LiveJournal旗下Danga Interactive 公司的Brad Fitzpatric为首开发的一款软件。现在已成为mixi、hatena、 Facebook、Vox、LiveJournal等众多服务中提高Web应用扩展性的重要因素。

许多Web应用都将数据保存到RDBMS中,应用服务器从中读取数据并在浏览器中显示。 但随着数据量的增大、访问的集中,就会出现RDBMS的负担加重、数据库响应恶化、 网站显示延迟等重大影响。

这时就该memcached大显身手了。memcached是高性能的分布式内存缓存服务器。 一般的使用目的是,通过缓存数据库查询结果,减少数据库访问次数,以提高动态Web应用的速度、 提高可扩展性。

图1 一般情况下memcached的用途

memcached的特征

memcached作为高速运行的分布式缓存服务器,具有以下的特点。

  • 协议简单
  • 基于libevent的事件处理
  • 内置内存存储方式
  • memcached不互相通信的分布式

协议简单

memcached的服务器客户端通信并不使用复杂的XML等格式,而使用简单的基于文本行的协议。因此,通过telnet 也能在memcached上保存数据、取得数据。下面是例子。

1
2
3
4
5
6
7
8
9
10
$ telnet localhost 11211
Trying 127.0.0.1...
Connected to localhost.localdomain (127.0.0.1).
Escape character is '^]'.
set foo 0 0 3 (保存命令)
bar (数据)
STORED (结果)
get foo (取得命令)
VALUE foo 0 3 (数据)
bar (数据)

协议文档位于memcached的源代码内,也可以参考以下的URL。

http://code.sixapart.com/svn/memcached/trunk/server/doc/protocol.txt

基于libevent的事件处理

libevent是个程序库,它将Linux的epoll、BSD类操作系统的kqueue等事件处理功能 封装成统一的接口。即使对服务器的连接数增加,也能发挥O(1)的性能。 memcached使用这个libevent库,因此能在Linux、BSD、Solaris等操作系统上发挥其高性能。 关于事件处理这里就不再详细介绍,可以参考Dan Kegel的The C10K Problem。

libevent: http://www.monkey.org/~provos/libevent/
The C10K Problem: http://www.kegel.com/c10k.html

内置内存存储方式

为了提高性能,memcached中保存的数据都存储在memcached内置的内存存储空间中。 由于数据仅存在于内存中,因此重启memcached、重启操作系统会导致全部数据消失。 另外,内容容量达到指定值之后,就基于LRU(Least Recently Used)算法自动删除不使用的缓存。 memcached本身是为缓存而设计的服务器,因此并没有过多考虑数据的永久性问题。 关于内存存储的详细信息,本连载的第二讲以后前坂会进行介绍,请届时参考。

memcached不互相通信的分布式

memcached尽管是“分布式”缓存服务器,但服务器端并没有分布式功能。 各个memcached不会互相通信以共享信息。那么,怎样进行分布式呢? 这完全取决于客户端的实现。本连载也将介绍memcached的分布式。

图2 memcached的分布式

接下来简单介绍一下memcached的使用方法。

memcached的安装

运行memcached需要本文开头介绍的libevent库。Fedora 8中有现成的rpm包, 通过yum命令安装即可。

1
$ sudo yum install libevent libevent-devel

memcached的源代码可以从memcached网站上下载。本文执笔时的最新版本为1.2.5。 Fedora 8虽然也包含了memcached的rpm,但版本比较老。因为源代码安装并不困难, 这里就不使用rpm了。

下载memcached:http://www.danga.com/memcached/download.bml
memcached安装与一般应用程序相同,configure、make、make install就行了。

1
2
3
4
5
6
$ wget http://www.danga.com/memcached/dist/memcached-1.2.5.tar.gz
$ tar zxf memcached-1.2.5.tar.gz
$ cd memcached-1.2.5
$ ./configure
$ make
$ sudo make install

默认情况下memcached安装到/usr/local/bin下。

memcached的启动

从终端输入以下命令,启动memcached。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ /usr/local/bin/memcached -p 11211 -m 64m -vv
slab class 1: chunk size 88 perslab 11915
slab class 2: chunk size 112 perslab 9362
slab class 3: chunk size 144 perslab 7281
中间省略
slab class 38: chunk size 391224 perslab 2
slab class 39: chunk size 489032 perslab 2
<23 server listening
<24 send buffer was 110592, now 268435456
<24 server listening (udp)
<24 server listening (udp)
<24 server listening (udp)
<24 server listening (udp)

这里显示了调试信息。这样就在前台启动了memcached,监听TCP端口11211 最大内存使用量为64M。调试信息的内容大部分是关于存储的信息, 下次连载时具体说明。

作为daemon后台启动时,只需

1
$ /usr/local/bin/memcached -p 11211 -m 64m -d

这里使用的memcached启动选项的内容如下。

  • -p使用的TCP端口。默认为11211
  • -m最大内存大小。默认为64M
  • -vv用very vrebose模式启动,调试信息和错误输出到控制台
  • -d作为daemon在后台启动

上面四个是常用的启动选项,其他还有很多,通过

1
$ /usr/local/bin/memcached -h

命令可以显示。许多选项可以改变memcached的各种行为, 推荐读一读。

使用Cache::Memcached

Perl的memcached客户端有

  • Cache::Memcached
  • Cache::Memcached::Fast
  • Cache::Memcached::libmemcached

等几个CPAN模块。这里介绍的Cache::Memcached是memcached的作者Brad Fitzpatric的作品, 应该算是memcached的客户端中应用最为广泛的模块了。

使用Cache::Memcached连接memcached

下面的源代码为通过Cache::Memcached连接刚才启动的memcached的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/perl

use strict;
use warnings;
use Cache::Memcached;

my $key = "foo";
my $value = "bar";
my $expires = 3600; # 1 hour
my $memcached = Cache::Memcached->new({
servers => ["127.0.0.1:11211"],
compress_threshold => 10_000
});

$memcached->add($key, $value, $expires);
my $ret = $memcached->get($key);
print "$ret\n";

在这里,为Cache::Memcached指定了memcached服务器的IP地址和一个选项,以生成实例。 Cache::Memcached常用的选项如下所示。

  • servers 用数组指定memcached服务器和端口
  • compress_threshold 数据压缩时使用的值
  • namespace 指定添加到键的前缀

另外,Cache::Memcached通过Storable模块可以将Perl的复杂数据序列化之后再保存, 因此散列、数组、对象等都可以直接保存到memcached中。

保存数据

向memcached保存数据的方法有

  • add
  • replace
  • set

它们的使用方法都相同:

1
2
3
my $add = $memcached->add( '键', '值', '期限' );
my $replace = $memcached->replace( '键', '值', '期限' );
my $set = $memcached->set( '键', '值', '期限' );

向memcached保存数据时可以指定期限(秒)。不指定期限时,memcached按照LRU算法保存数据。 这三个方法的区别如下:

  • add 仅当存储空间中不存在键相同的数据时才保存
  • replace 仅当存储空间中存在键相同的数据时才保存
  • set 与add和replace不同,无论何时都保存

获取数据

获取数据可以使用get和get_multi方法。

1
2
my $val = $memcached->get('键');
my $val = $memcached->get_multi('键1', '键2', '键3', '键4', '键5');

一次取得多条数据时使用get_multi。get_multi可以非同步地同时取得多个键值, 其速度要比循环调用get快数十倍。

删除数据

删除数据使用delete方法,不过它有个独特的功能。

1
$memcached->delete('键', '阻塞时间(秒)');

删除第一个参数指定的键的数据。第二个参数指定一个时间值,可以禁止使用同样的键保存新数据。 此功能可以用于防止缓存数据的不完整。但是要注意,set函数忽视该阻塞,照常保存数据

增一和减一操作

可以将memcached上特定的键值作为计数器使用。

1
2
my $ret = $memcached->incr('键');
$memcached->add('键', 0) unless defined $ret;

增一和减一是原子操作,但未设置初始值时,不会自动赋成0。因此, 应当进行错误检查,必要时加入初始化操作。而且,服务器端也不会对 超过2 SUP(32)时的行为进行检查。

Slab Allocation机制:整理内存以便重复使用

最近的memcached默认情况下采用了名为Slab Allocator的机制分配、管理内存。 在该机制出现以前,内存的分配是通过对所有记录简单地进行malloc和free来进行的。 但是,这种方式会导致内存碎片,加重操作系统内存管理器的负担,最坏的情况下, 会导致操作系统比memcached进程本身还慢。Slab Allocator就是为解决该问题而诞生的。

下面来看看Slab Allocator的原理。下面是memcached文档中的slab allocator的目标:

the primary goal of the slabs subsystem in memcached was to eliminate memory fragmentation issues totally by using fixed-size memory chunks coming from a few predetermined size classes.

也就是说,Slab Allocator的基本原理是按照预先规定的大小,将分配的内存分割成特定长度的块, 以完全解决内存碎片问题。

Slab Allocation的原理相当简单。 将分配的内存分割成各种尺寸的块(chunk), 并把尺寸相同的块分成组(chunk的集合)(图1)。
图1 Slab Allocation的构造图

而且,slab allocator还有重复使用已分配的内存的目的。 也就是说,分配到的内存不会释放,而是重复利用。

Slab Allocation的主要术语

Page

分配给Slab的内存空间,默认是1MB。分配给Slab之后根据slab的大小切分成chunk。

Chunk

用于缓存记录的内存空间。

Slab Class

特定大小的chunk的组。

在Slab中缓存记录的原理

下面说明memcached如何针对客户端发送的数据选择slab并缓存到chunk中。

memcached根据收到的数据的大小,选择最适合数据大小的slab(图2)。 memcached中保存着slab内空闲chunk的列表,根据该列表选择chunk, 然后将数据缓存于其中。

图2 选择存储记录的组的方法

实际上,Slab Allocator也是有利也有弊。下面介绍一下它的缺点。

Slab Allocator的缺点

Slab Allocator解决了当初的内存碎片问题,但新的机制也给memcached带来了新的问题。

这个问题就是,由于分配的是特定长度的内存,因此无法有效利用分配的内存。 例如,将100字节的数据缓存到128字节的chunk中,剩余的28字节就浪费了(图3)。
图3 chunk空间的使用

对于该问题目前还没有完美的解决方案,但在文档中记载了比较有效的解决方案。

The most efficient way to reduce the waste is to use a list of size classes that closely matches (if that’s at all possible) common sizes of objects that the clients of this particular installation of memcached are likely to store.

就是说,如果预先知道客户端发送的数据的公用大小,或者仅缓存大小相同的数据的情况下, 只要使用适合数据大小的组的列表,就可以减少浪费。

但是很遗憾,现在还不能进行任何调优,只能期待以后的版本了。 但是,我们可以调节slab class的大小的差别。 接下来说明growth factor选项。

使用Growth Factor进行调优

memcached在启动时指定 Growth Factor因子(通过-f选项), 就可以在某种程度上控制slab之间的差异。默认值为1.25。 但是,在该选项出现之前,这个因子曾经固定为2,称为“powers of 2”策略。

让我们用以前的设置,以verbose模式启动memcached试试看:

1
$ memcached -f 2 -vv

下面是启动后的verbose输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
slab class   1: chunk size    128 perslab  8192
slab class 2: chunk size 256 perslab 4096
slab class 3: chunk size 512 perslab 2048
slab class 4: chunk size 1024 perslab 1024
slab class 5: chunk size 2048 perslab 512
slab class 6: chunk size 4096 perslab 256
slab class 7: chunk size 8192 perslab 128
slab class 8: chunk size 16384 perslab 64
slab class 9: chunk size 32768 perslab 32
slab class 10: chunk size 65536 perslab 16
slab class 11: chunk size 131072 perslab 8
slab class 12: chunk size 262144 perslab 4
slab class 13: chunk size 524288 perslab 2

可见,从128字节的组开始,组的大小依次增大为原来的2倍。 这样设置的问题是,slab之间的差别比较大,有些情况下就相当浪费内存。 因此,为尽量减少内存浪费,两年前追加了growth factor这个选项。

来看看现在的默认设置(f=1.25)时的输出(篇幅所限,这里只写到第10组):

1
2
3
4
5
6
7
8
9
10
slab class   1: chunk size     88 perslab 11915
slab class 2: chunk size 112 perslab 9362
slab class 3: chunk size 144 perslab 7281
slab class 4: chunk size 184 perslab 5698
slab class 5: chunk size 232 perslab 4519
slab class 6: chunk size 296 perslab 3542
slab class 7: chunk size 376 perslab 2788
slab class 8: chunk size 472 perslab 2221
slab class 9: chunk size 592 perslab 1771
slab class 10: chunk size 744 perslab 1409

可见,组间差距比因子为2时小得多,更适合缓存几百字节的记录。 从上面的输出结果来看,可能会觉得有些计算误差, 这些误差是为了保持字节数的对齐而故意设置的。

将memcached引入产品,或是直接使用默认值进行部署时, 最好是重新计算一下数据的预期平均长度,调整growth factor, 以获得最恰当的设置。内存是珍贵的资源,浪费就太可惜了。

接下来介绍一下如何使用memcached的stats命令查看slabs的利用率等各种各样的信息。

查看memcached的内部状态

memcached有个名为stats的命令,使用它可以获得各种各样的信息。 执行命令的方法很多,用telnet最为简单:

1
$ telnet 主机名 端口号

连接到memcached之后,输入stats再按回车,即可获得包括资源利用率在内的各种信息。 此外,输入”stats slabs”或”stats items”还可以获得关于缓存记录的信息。 结束程序请输入quit。

这些命令的详细信息可以参考memcached软件包内的protocol.txt文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$ telnet localhost 11211
Trying ::1...
Connected to localhost.
Escape character is '^]'.
stats
STAT pid 481
STAT uptime 16574
STAT time 1213687612
STAT version 1.2.5
STAT pointer_size 32
STAT rusage_user 0.102297
STAT rusage_system 0.214317
STAT curr_items 0
STAT total_items 0
STAT bytes 0
STAT curr_connections 6
STAT total_connections 8
STAT connection_structures 7
STAT cmd_get 0
STAT cmd_set 0
STAT get_hits 0
STAT get_misses 0
STAT evictions 0
STAT bytes_read 20
STAT bytes_written 465
STAT limit_maxbytes 67108864
STAT threads 4
END
quit

另外,如果安装了libmemcached这个面向C/C++语言的客户端库,就会安装 memstat 这个命令。 使用方法很简单,可以用更少的步骤获得与telnet相同的信息,还能一次性从多台服务器获得信息。
1
$ memstat --servers=server1,server2,server3,...

libmemcached可以从下面的地址获得:http://tangent.org/552/libmemcached.html

查看slabs的使用状况

使用memcached的创造者Brad写的名为memcached-tool的Perl脚本,可以方便地获得slab的使用情况 (它将memcached的返回值整理成容易阅读的格式)。可以从下面的地址获得脚本:

http://code.sixapart.com/svn/memcached/trunk/server/scripts/memcached-tool
使用方法也极其简单:

1
$ memcached-tool 主机名:端口 选项

查看slabs使用状况时无需指定选项,因此用下面的命令即可:
1
$ memcached-tool 主机名:端口

获得的信息如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 ##  Item_Size   Max_age  1MB_pages Count   Full?
1 104 B 1394292 s 1215 12249628 yes
2 136 B 1456795 s 52 400919 yes
3 176 B 1339587 s 33 196567 yes
4 224 B 1360926 s 109 510221 yes
5 280 B 1570071 s 49 183452 yes
6 352 B 1592051 s 77 229197 yes
7 440 B 1517732 s 66 157183 yes
8 552 B 1460821 s 62 117697 yes
9 696 B 1521917 s 143 215308 yes
10 872 B 1695035 s 205 246162 yes
11 1.1 kB 1681650 s 233 221968 yes
12 1.3 kB 1603363 s 241 183621 yes
13 1.7 kB 1634218 s 94 57197 yes
14 2.1 kB 1695038 s 75 36488 yes
15 2.6 kB 1747075 s 65 25203 yes
16 3.3 kB 1760661 s 78 24167 yes

各列的含义为:

  • ‘#’ slab class编号
  • Item_Size Chunk大小
  • Max_age LRU内最旧的记录的生存时间
  • 1MB_pages 分配给Slab的页数
  • Count Slab内的记录数
  • Full? Slab内是否含有空闲chunk

从这个脚本获得的信息对于调优非常方便,强烈推荐使用。

memcached在数据删除方面有效利用资源

数据不会真正从memcached中消失
上次介绍过, memcached不会释放已分配的内存。记录超时后,客户端就无法再看见该记录(invisible,透明), 其存储空间即可重复使用。

Lazy Expiration

memcached内部不会监视记录是否过期,而是在get时查看记录的时间戳,检查记录是否过期。 这种技术被称为lazy(惰性)expiration。因此,memcached不会在过期监视上耗费CPU时间。

LRU:从缓存中有效删除数据的原理

memcached会优先使用已超时的记录的空间,但即使如此,也会发生追加新记录时空间不足的情况, 此时就要使用名为 Least Recently Used(LRU)机制来分配空间。 顾名思义,这是删除“最近最少使用”的记录的机制。 因此,当memcached的内存空间不足时(无法从slab class 获取到新的空间时),就从最近未被使用的记录中搜索,并将其空间分配给新的记录。 从缓存的实用角度来看,该模型十分理想。

不过,有些情况下LRU机制反倒会造成麻烦。memcached启动时通过“-M”参数可以禁止LRU,如下所示:

1
$ memcached -M -m 1024

启动时必须注意的是,小写的“-m”选项是用来指定最大内存大小的。不指定具体数值则使用默认值64MB。

指定“-M”参数启动后,内存用尽时memcached会返回错误。 话说回来,memcached毕竟不是存储器,而是缓存,所以推荐使用LRU。

memcached的最新发展方向

memcached的roadmap上有两个大的目标。一个是二进制协议的策划和实现,另一个是外部引擎的加载功能。

关于二进制协议

使用二进制协议的理由是它不需要文本协议的解析处理,使得原本高速的memcached的性能更上一层楼, 还能减少文本协议的漏洞。目前已大部分实现,开发用的代码库中已包含了该功能。 memcached的下载页面上有代码库的链接。

http://danga.com/memcached/download.bml

二进制协议的格式

协议的包为24字节的帧,其后面是键和无结构数据(Unstructured Data)。 实际的格式如下(引自协议文档):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Byte/     0       |       1       |       2       |       3       |   
/ | | | |
|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|
+---------------+---------------+---------------+---------------+
0/ HEADER /
/ /
/ /
/ /
+---------------+---------------+---------------+---------------+
24/ COMMAND-SPECIFIC EXTRAS (as needed) /
+/ (note length in th extras length header field) /
+---------------+---------------+---------------+---------------+
m/ Key (as needed) /
+/ (note length in key length header field) /
+---------------+---------------+---------------+---------------+
n/ Value (as needed) /
+/ (note length is total body length header field, minus /
+/ sum of the extras and key length body fields) /
+---------------+---------------+---------------+---------------+
Total 24 bytes

如上所示,包格式十分简单。需要注意的是,占据了16字节的头部(HEADER)分为 请求头(Request Header)和响应头(Response Header)两种。 头部中包含了表示包的有效性的Magic字节、命令种类、键长度、值长度等信息,格式如下:

Request Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Byte/     0       |       1       |       2       |       3       |
/ | | | |
|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|
+---------------+---------------+---------------+---------------+
0| Magic | Opcode | Key length |
+---------------+---------------+---------------+---------------+
4| Extras length | Data type | Reserved |
+---------------+---------------+---------------+---------------+
8| Total body length |
+---------------+---------------+---------------+---------------+
12| Opaque |
+---------------+---------------+---------------+---------------+
16| CAS |
| |
+---------------+---------------+---------------+---------------+

Response Header
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Byte/     0       |       1       |       2       |       3       |
/ | | | |
|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|
+---------------+---------------+---------------+---------------+
0| Magic | Opcode | Key Length |
+---------------+---------------+---------------+---------------+
4| Extras length | Data type | Status |
+---------------+---------------+---------------+---------------+
8| Total body length |
+---------------+---------------+---------------+---------------+
12| Opaque |
+---------------+---------------+---------------+---------------+
16| CAS |
| |
+---------------+---------------+---------------+---------------+

如希望了解各个部分的详细内容,可以checkout出memcached的二进制协议的代码树, 参考其中的docs文件夹中的protocol_binary.txt文档。

HEADER中引人注目的地方

看到HEADER格式后我的感想是,键的上限太大了!现在的memcached规格中,键长度最大为250字节, 但二进制协议中键的大小用2字节表示。因此,理论上最大可使用65536字节(216)长的键。 尽管250字节以上的键并不会太常用,二进制协议发布之后就可以使用巨大的键了。

二进制协议从下一版本1.3系列开始支持。

外部引擎支持

我去年曾经试验性地将memcached的存储层改造成了可扩展的(pluggable)。

http://alpha.mixi.co.jp/blog/?p=129
MySQL的Brian Aker看到这个改造之后,就将代码发到了memcached的邮件列表。 memcached的开发者也十分感兴趣,就放到了roadmap中。现在由我和 memcached的开发者Trond Norbye协同开发(规格设计、实现和测试)。 和国外协同开发时时差是个大问题,但抱着相同的愿景, 最后终于可以将可扩展架构的原型公布了。 代码库可以从memcached的下载页面 上访问。

外部引擎支持的必要性

世界上有许多memcached的派生软件,其理由是希望永久保存数据、实现数据冗余等, 即使牺牲一些性能也在所不惜。我在开发memcached之前,在mixi的研发部也曾经 考虑过重新发明memcached。

外部引擎的加载机制能封装memcached的网络功能、事件处理等复杂的处理。 因此,现阶段通过强制手段或重新设计等方式使memcached和存储引擎合作的困难 就会烟消云散,尝试各种引擎就会变得轻而易举了。

简单API设计的成功的关键

该项目中我们最重视的是API设计。函数过多,会使引擎开发者感到麻烦; 过于复杂,实现引擎的门槛就会过高。因此,最初版本的接口函数只有13个。 具体内容限于篇幅,这里就省略了,仅说明一下引擎应当完成的操作:

  • 引擎信息(版本等)
  • 引擎初始化
  • 引擎关闭
  • 引擎的统计信息
  • 在容量方面,测试给定记录能否保存
  • 为item(记录)结构分配内存
  • 释放item(记录)的内存
  • 删除记录
  • 保存记录
  • 回收记录
  • 更新记录的时间戳
  • 数学运算处理
  • 数据的flush

对详细规格有兴趣的读者,可以checkout engine项目的代码,阅读器中的engine.h。

重新审视现在的体系

memcached支持外部存储的难点是,网络和事件处理相关的代码(核心服务器)与 内存存储的代码紧密关联。这种现象也称为tightly coupled(紧密耦合)。 必须将内存存储的代码从核心服务器中独立出来,才能灵活地支持外部引擎。 因此,基于我们设计的API,memcached被重构成下面的样子:

重构之后,我们与1.2.5版、二进制协议支持版等进行了性能对比,证实了它不会造成性能影响。

在考虑如何支持外部引擎加载时,让memcached进行并行控制(concurrency control)的方案是最为容易的, 但是对于引擎而言,并行控制正是性能的真谛,因此我们采用了将多线程支持完全交给引擎的设计方案。

memcached的分布式

正如第1次中介绍的那样,memcached虽然称为“分布式”缓存服务器,但服务器端并没有“分布式”功能。 服务器端仅包括第2次、第3次前坂介绍的内存存储功能,其实现非常简单。至于memcached的分布式,则是完全由客户端程序库实现的。这种分布式是memcached的最大特点。

memcached的分布式是什么意思?
这里多次使用了“分布式”这个词,但并未做详细解释。 现在开始简单地介绍一下其原理,各个客户端的实现基本相同。

下面假设memcached服务器有node1~node3三台, 应用程序要保存键名为“tokyo”“kanagawa”“chiba”“saitama”“gunma” 的数据。
图1 分布式简介:准备

首先向memcached中添加“tokyo”。将“tokyo”传给客户端程序库后, 客户端实现的算法就会根据“键”来决定保存数据的memcached服务器。 服务器选定后,即命令它保存“tokyo”及其值。

图2 分布式简介:添加时

同样,“kanagawa”“chiba”“saitama”“gunma”都是先选择服务器再保存。

接下来获取保存的数据。获取时也要将要获取的键“tokyo”传递给函数库。 函数库通过与数据保存时相同的算法,根据“键”选择服务器。 使用的算法相同,就能选中与保存时相同的服务器,然后发送get命令。 只要数据没有因为某些原因被删除,就能获得保存的值。
图3 分布式简介:获取时

这样,将不同的键保存到不同的服务器上,就实现了memcached的分布式。 memcached服务器增多后,键就会分散,即使一台memcached服务器发生故障 无法连接,也不会影响其他的缓存,系统依然能继续运行。

接下来介绍第1次中提到的Perl客户端函数库Cache::Memcached实现的分布式方法。

Cache::Memcached的分布式方法

Perl的memcached客户端函数库Cache::Memcached是 memcached的作者Brad Fitzpatrick的作品,可以说是原装的函数库了。

Cache::Memcached - search.cpan.org
该函数库实现了分布式功能,是memcached标准的分布式方法。

根据余数计算分散

Cache::Memcached的分布式方法简单来说,就是“根据服务器台数的余数进行分散”。 求得键的整数哈希值,再除以服务器台数,根据其余数来选择服务器。

下面将Cache::Memcached简化成以下的Perl脚本来进行说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
use strict;
use warnings;
use String::CRC32;

my @nodes = ('node1','node2','node3');
my @keys = ('tokyo', 'kanagawa', 'chiba', 'saitama', 'gunma');

foreach my $key (@keys) {
my $crc = crc32($key); ## CRC値
my $mod = $crc % ( $#nodes + 1 );
my $server = $nodes[ $mod ]; ## 根据余数选择服务器
printf "%s => %s\n", $key, $server;
}

Cache::Memcached在求哈希值时使用了CRC。

String::CRC32 - search.cpan.org
首先求得字符串的CRC值,根据该值除以服务器节点数目得到的余数决定服务器。 上面的代码执行后输入以下结果:

1
2
3
4
5
tokyo       => node2
kanagawa => node3
chiba => node2
saitama => node1
gunma => node1

根据该结果,“tokyo”分散到node2,“kanagawa”分散到node3等。 多说一句,当选择的服务器无法连接时,Cache::Memcached会将连接次数 添加到键之后,再次计算哈希值并尝试连接。这个动作称为rehash。 不希望rehash时可以在生成Cache::Memcached对象时指定“rehash => 0”选项。

根据余数计算分散的缺点

余数计算的方法简单,数据的分散性也相当优秀,但也有其缺点。 那就是当添加或移除服务器时,缓存重组的代价相当巨大。 添加服务器后,余数就会产生巨变,这样就无法获取与保存时相同的服务器, 从而影响缓存的命中率。用Perl写段代码来验证其代价。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use strict;
use warnings;
use String::CRC32;

my @nodes = @ARGV;
my @keys = ('a'..'z');
my %nodes;

foreach my $key ( @keys ) {
my $hash = crc32($key);
my $mod = $hash % ( $#nodes + 1 );
my $server = $nodes[ $mod ];
push @{ $nodes{ $server } }, $key;
}

foreach my $node ( sort keys %nodes ) {
printf "%s: %s\n", $node, join ",", @{ $nodes{$node} };
}

这段Perl脚本演示了将“a”到“z”的键保存到memcached并访问的情况。 将其保存为mod.pl并执行。

首先,当服务器只有三台时:

1
2
3
4
$ mod.pl node1 node2 nod3
node1: a,c,d,e,h,j,n,u,w,x
node2: g,i,k,l,p,r,s,y
node3: b,f,m,o,q,t,v,z

结果如上,node1保存a、c、d、e……,node2保存g、i、k……, 每台服务器都保存了8个到10个数据。

接下来增加一台memcached服务器。

1
2
3
4
5
$ mod.pl node1 node2 node3 node4
node1: d,f,m,o,t,v
node2: b,i,k,p,r,y
node3: e,g,l,n,u,w
node4: a,c,h,j,q,s,x,z

添加了node4。可见,只有d、i、k、p、r、y命中了。像这样,添加节点后 键分散到的服务器会发生巨大变化。26个键中只有六个在访问原来的服务器, 其他的全都移到了其他服务器。命中率降低到23%。在Web应用程序中使用memcached时, 在添加memcached服务器的瞬间缓存效率会大幅度下降,负载会集中到数据库服务器上, 有可能会发生无法提供正常服务的情况。

mixi的Web应用程序运用中也有这个问题,导致无法添加memcached服务器。 但由于使用了新的分布式方法,现在可以轻而易举地添加memcached服务器了。 这种分布式方法称为 Consistent Hashing。

Consistent Hashing

Consistent Hashing的简单说明

Consistent Hashing如下所示:首先求出memcached服务器(节点)的哈希值, 并将其配置到0~2SUP(32)的圆(continuum)上。 然后用同样的方法求出存储数据的键的哈希值,并映射到圆上。 然后从数据映射到的位置开始顺时针查找,将数据保存到找到的第一个服务器上。 如果超过2SUP(32)仍然找不到服务器,就会保存到第一台memcached服务器上。

图4 Consistent Hashing:基本原理

从上图的状态中添加一台memcached服务器。余数分布式算法由于保存键的服务器会发生巨大变化 而影响缓存的命中率,但Consistent Hashing中,只有在continuum上增加服务器的地点逆时针方向的 第一台服务器上的键会受到影响。

图5 Consistent Hashing:添加服务器

因此,Consistent Hashing最大限度地抑制了键的重新分布。 而且,有的Consistent Hashing的实现方法还采用了虚拟节点的思想。 使用一般的hash函数的话,服务器的映射地点的分布非常不均匀。 因此,使用虚拟节点的思想,为每个物理节点(服务器) 在continuum上分配100~200个点。这样就能抑制分布不均匀, 最大限度地减小服务器增减时的缓存重新分布。

通过下文中介绍的使用Consistent Hashing算法的memcached客户端函数库进行测试的结果是, 由服务器台数(n)和增加的服务器台数(m)计算增加服务器后的命中率计算公式如下:

1
(1 - n/(n+m)) * 100

支持Consistent Hashing的函数库

本连载中多次介绍的Cache::Memcached虽然不支持Consistent Hashing, 但已有几个客户端函数库支持了这种新的分布式算法。 第一个支持Consistent Hashing和虚拟节点的memcached客户端函数库是 名为libketama的PHP库,由last.fm开发。
至于Perl客户端,连载的第1次中介绍过的Cache::Memcached::Fast和Cache::Memcached::libmemcached支持 Consistent Hashing。

两者的接口都与Cache::Memcached几乎相同,如果正在使用Cache::Memcached, 那么就可以方便地替换过来。Cache::Memcached::Fast重新实现了libketama, 使用Consistent Hashing创建对象时可以指定ketama_points选项。

1
2
3
4
my $memcached = Cache::Memcached::Fast->new({
servers => ["192.168.0.1:11211","192.168.0.2:11211"],
ketama_points => 150
});

另外,Cache::Memcached::libmemcached 是一个使用了Brain Aker开发的C函数库libmemcached的Perl模块。 libmemcached本身支持几种分布式算法,也支持Consistent Hashing, 其Perl绑定也支持Consistent Hashing。