C++ 性能优化篇四《优化字符串的使用:案例研究》

C++性能优化 xuhss 2483℃ 0评论

只有少数人才能触摸到魔法琴弦(string), 可是聒噪的名声却企图击败他们; 悲哀于那些从来都不歌唱的人们, 死亡时却要带着他们的音乐陪葬!

​ ——奥利弗 • 温德尔 • 霍姆斯 1 ,“无声”(1858)

C++ 的 std::string 类模板是 C++ 标 准 库 中 使 用 最 广 泛 的 特 性 之 一。 例 如, 谷 歌 Chromium2 开 发 者 论 坛(https://groups.google.com/a/chromium.org/forum/#!msg/chromiumdev/EUqoIz2iFU4/kPZ5ZK0K3gEJ)上的一篇帖子中提到,“在 Chromium 中,std::string 对内存管理器的调用次数占到了内存管理器被调用的总次数的一半”。只要操作字符串的 代码会被频繁地执行,那么那里就有优化的用武之地。本章将会通过讨论“优化字符串处 理”来阐释优化中反复出现的主题。

4.1 为什么字符串很麻烦

字符串在概念上很简单,但是想要实现高效的字符串却非常微妙。由于 std::string 中特性的特定组合的交互方式,使得实现高效的字符串几乎不可能。的确,在编写本书时,几 种流行的编译器曾经实现的 std::string 在许多方面都不符合标准。

而且,为了能够跟上 C++ 标准的变化,std::string 的行为也在不断地变化。这意味着, 在 C++98 编译器中实现的符合标准的 std::string 的行为可能与在 C++11 之后实现的 std::string 的行为是不同的。

字符串的某些行为会增加使用它们的开销,这一点与实现方式无关。字符串是动态分配 的,它们在表达式中的行为与值相似,而且实现它们需要大量的复制操作。

4.1.1 字符串是动态分配的

字符串之所以使用起来很方便,是因为它们会为了保存内容而自动增长。相比之下,C 的 库函数(strcat()、strcpy() 等)工作于固定长度的字符数组上。为了实现这种灵活性, 字符串被设计为动态分配的。相比于 C++ 的大多数其他特性,动态分配内存耗时耗力。因 此无论如何,字符串都是性能优化热点。当一个字符串变量超出了其定义范围或是被赋予 了一个新的值后,动态分配的存储空间会被自动释放。与下面这段代码展示的需要为动态 分配的 C 风格的字符数组手动释放内存相比,这样无疑方便了许多。

char* p = (char*) malloc(7);
strcpy(p, "string");
 ...
free(p);

尽管如此,但字符串内部的字符缓冲区的大小仍然是固定的。任何会使字符串变长的操 作,如在字符串后面再添加一个字符或是字符串,都可能会使字符串的长度超出它内部的 缓冲区的大小。当发生这种情况时,操作会从内存管理器中获取一块新的缓冲区,并将字 符串复制到新的缓冲区中。

为了能让字符串增长时重新分配内存的开销“分期付款”,std::string 使用了一个小技巧。字符串向内存管理器申请的字符缓冲区的大小并非与字符串所需存储的字符数完全一 致,而是比该数值更大。例如,有些字符串的实现方式所申请的字符缓冲区的大小是需要 存储的字符数的两倍。这样,在下一次申请新的字符缓冲区之前,字符串的容量足够允许它增长一倍。下一次某个操作需要增长字符串时,现有的缓冲区足够存储新的内容,可以 避免申请新的缓冲区。这个小技巧带来的好处是随着字符串变得更长,在字符串后面再添 加字符或是字符串的开销近似于一个常量;而其代价则是字符串携带了一些未使用的内存 空间。如果字符串的实现策略是字符串缓冲区增大为原来的两倍,那么在该字符串的存储 空间中,有一半都是未使用的。

4.1.2 字符串就是值

在赋值语句和表达式中,字符串的行为与值是一样的(请参见 6.1.3 节)。2 和 3.14159 这 样的数值常量是值。可以将一个新值赋予给一个变量,但是改变这个变量并不会改变这个 值。例如:

int i,j;
i = 3; // i的值是3
j = i; // j的值也是3
i = 5; // i的值现在是5,但是j的值仍然是3

将一个字符串赋值给另一个字符串的工作方式是一样的,就仿佛每个字符串变量都拥有一 份它们所保存的内容的私有副本一样:

std::string s1, s2;
s1 = "hot"; // s1是"hot"
s2 = s1; // s2是"hot"
s1[0] = 'n'; // s2仍然是"hot",但s1变为了"not"

由于字符串就是值,因此字符串表达式的结果也是值。如果你使用 s1 = s2 + s3 + s4; 这 条语句连接字符串,那么 s2 + s3 的结果会被保存在一个新分配的临时字符串中。连接 s4 后的结果则会被保存在另一个临时字符串中。这个值将会取代 s1 之前的值。接着,为第一 个临时字符串和 s1 之前的值动态分配的内存将会被释放。这会导致多次调用内存管理器。

4.1.3 字符串会进行大量复制

由于字符串的行为与值相似,因此修改一个字符串不能改变其他字符串的值。但是字符串 也有可以改变其内容的变值操作。正是因为这些变值操作的存在,每个字符串变量必须表 现得好像它们拥有一份自己的私有副本一样。实现这种行为的最简单的方式是当创建字符 串、赋值或是将其作为参数传递给函数的时候进行一次复制。如果字符串是以这种方式实 现的,那么赋值和参数传递的开销将会变得很大,但是变值函数(mutating function)和非常量引用的开销却很小。

有一种被称为“写时复制”(copy on write)的著名的编程惯用法,它可以让对象与值具有 同样的表现,但是会使复制的开销变得非常大。在 C++ 文献中,它被简称为 COW(详见 6.5.5 节)。在 COW 的字符串中,动态分配的内存可以在字符串间共享。每个字符串都可 以通过引用计数知道它们是否使用了共享内存。当一个字符串被赋值给另一个字符串时, 所进行的处理只有复制指针以及增加引用计数。任何会改变字符串值的操作都会首先检查 是否只有一个指针指向该字符串的内存。如果多个字符串都指向该内存空间,所有的变值 操作(任何可能会改变字符串值的操作)都会在改变字符串值之前先分配新的内存空间并 复制字符串:

COWstring s1, s2;
s1 = "hot"; // s1是"hot"
s2 = s1; // s2是"hot"(s1和s2指向相同的内存)
s1[0] = 'n';// s1会在改变它的内容之前将当前内存空间中的内容复制一份
 // s2仍然是"hot",但s1变为了"not"

写时复制这项技术太有名了,以至于开发人员可能会想当然地以为 std::string 就是以 这种方式实现的。但是实际上,写时复制甚至是不符合 C++11 标准的实现方式,而且问题百出。

如果以写时复制方式实现字符串,那么赋值和参数传递操作的开销很小,但是一旦字符串 被共享了,非常量引用以及任何变值函数的调用都需要昂贵的分配和复制操作。在并发代 码中,写时复制字符串的开销同样很大。每次变值函数和非常量引用都要访问引用计数 器。当引用计数器被多个线程访问时,每个线程都必须使用一个特殊的指令从主内存中得 到引用计数的副本,以确保没有其他线程改变这个值(详见 12.2.7 节)。

在 C++11 及之后的版本中,随着“右值引用”和“移动语义”(详见 6.6 节)的出现,使用 它们可以在某种程度上减轻复制的负担。如果一个函数使用“右值引用”作为参数,那么 当实参是一个右值表达式时,字符串可以进行轻量级的指针复制,从而节省一次复制操作。

4.2 第一次尝试优化字符串

假设通过分析一个大型程序揭示出了代码清单 4-1 中的 remove_ctrl() 函数的执行时间在 程序整体执行时间中所占的比例非常大。这个函数的功能是从一个由 ASCII 字符组成的字 符串中移除控制字符。看起来它似乎很无辜,但是出于多种原因,这种写法的函数确实性 能非常糟糕。实际上,这个函数是一个很好的例子,向大家展示了在编码时完全不考虑性 能是多么地危险。

代码清单 4-1 需要优化的 remove_ctrl()

std::string remove_ctrl(std::string s) {
 std::string result;
 for (int i=0; i<s.length(); ++i) {
 if(s[i] >= 0x20)
 result = result + s[i];
 }
 return result;
}

remove_ctrl() 在循环中对通过参数接收到的字符串 s 的每个字符进行处理。循环中的代码 就是导致这个函数成为热点的原因。if 条件语句从字符串中得到一个字符,然后与一个字面常量进行比较。这里没有什么问题。但是第 5 行的语句就不一样了。

正如之前所指出的,字符串连接运算符的开销是很大的。它会调用内存管理器去构建一个 新的临时字符串对象来保存连接后的字符串。如果传递给 remove_ctrl() 的参数是一个由可打印的字符组成的字符串,那么 remove_ctrl() 几乎会为 s 中的每个字符都构建一个临时字符串对象。对于一个由 100 个字符组成的字符串而言,这会调用 100 次内存管理器来 为临时字符串分配内存,调用 100 次内存管理器来释放内存。

除了分配临时字符串来保存连接运算的结果外,将字符串连接表达式赋值给 result 时可能 还会分配额外的字符串。当然,这取决于字符串是如何实现的。

  • 如果字符串是以写时复制惯用法实现的,那么赋值运算符将会执行一次高效的指针复制 并增加引用计数。
  • 如果字符串是以非共享缓冲区的方式实现的,那么赋值运算符必须复制临时字符串的内 容。如果实现是原生的,或者 result 的缓冲区没有足够的容量,那么赋值运算符还必 须分配一块新的缓冲区用于复制连接结果。这会导致 100 次复制操作和 100 次额外的内 存分配。
  • 如果编译器实现了 C++11 风格的右值引用和移动语义,那么连接表达式的结果是一个 右值,这表示编译器可以调用 result 的移动构造函数,而无需调用复制构造函数。因此, 程序将会执行一次高效的指针复制。

每次执行连接运算时还会将之前处理过的所有字符复制到临时字符串中。如果参数字符串 有 n 个字符,那么 remove_ctrl() 会复制O(n2 ) 个字符。所有这些内存分配和复制都会导致 性能变差。

因为 remove_ctrl() 是一个小且独立的函数,所以我们可以构建一个测试套件,通过反复 地调用该函数来测量通过优化到底能将该函数的性能提升多少。关于构建测试套件和测 优化字符串的使用:案例研究 | 57 量性能的内容,我们已经在第 3 章中讨论过了。读者可以从我的个人网站(http://www.guntheroth.com)下载这个函数的测试套件以及本书中的其他代码

我所编写的这个时间测试会以一个长达 222 个字符且其中包含多个控制字符的字符串作为 参数,反复地调用 remove_ctrl() 函数。测量结果是平均每次调用花费 24.8 微秒。这个数 字自身并不重要,因为这是在我的 PC(英特尔 i7 平板)、操作系统(Windows 8.1)和编 译器(Visual Studio 2010,32 位,正式版)上得出的测试结果。重要的是,它是测量性能改善的基准值。

在下面的小节中,我将会介绍 remove_ctrl() 函数的性能优化步骤和结果。

4.2.1 使用复合赋值操作避免临时字符串

我首先通过移除内存分配和复制操作来优化 remove_ctrl()。代码清单 4-2 是 remove_ ctrl() 改善后的版本,其中第 5 行中会产生很多临时字符串对象的连接表达式被替换为了 复合赋值操作符 +=。

代码清单 4-2 remove_ctrl_mutating():复合赋值操作符

std::string remove_ctrl_mutating(std::string s) {
 std::string result;
 for (int i=0; i<s.length(); ++i) {
 if(s[i] >= 0x20)
 result += s[i];
 }
 return result;
}

这个小的改动却带来了很大的性能提升。在相同的测试下,现在,平均每次调用只花费 1.72 微秒,性能提升了 13 倍。这次改善源于移除了所有为了分配临时字符串对象来保存 连接结果而对内存管理器的调用,以及相关的复制和删除临时字符串的操作。赋值时的分配和复制操作也可以被移除,不过这取决于字符串的实现方式。

4.2.2 通过预留存储空间减少内存的重新分配

remove_ctrl_mutating() 函数仍然会执行一个导致 result 变长的操作。这意味着 result 会被反复地复制到一个更大的内部动态缓冲区中。正如之前所讨论的,每次字符串的字 符缓冲区发生溢出时,std::string 的一种可能的实现方式 3 会申请两倍的内存空间。如果 std::string 是以这种规则实现的,那么对于一个含有 100 个字符的字符串来说,重新分 配内存的次数可能会多达 8 次。

假设字符串中绝大多数都是可打印的字符,只有几个是需要被移除的控制字符,那么参数 字符串 s 的长度几乎等于结果字符串的最终长度。代码清单 4-3 通过使用 std::string() 的 reserve() 成员函数预先分配足够的内存空间来优化 remove_ctrl_mutating()。使用 reserve() 不仅移除了字符串缓冲区的重新分配,还改善了函数所读取的数据的缓存局部 性(cache locality),因此我们从中得到了更好的改善效果。

代码清单 4-3 remove_ctrl_reserve():预留存储空间

std::string remove_ctrl_reserve(std::string s) {
 std::string result;
 result.reserve(s.length());
 for (int i=0; i<s.length(); ++i) {
 if (s[i] >= 0x20)
 result += s[i];
 }
 return result;
}

移除了几处内存分配后,程序性能得到了明显的提升。对 remove_ctrl_reserve() 进行测 试的结果是每次调用耗时 1.47 微秒,相比 remove_ctrl_mutating() 提高了 17%。

4.2.3 消除对参数字符串的复制

到目前为止,我已经通过移除对内存管理器的调用成功地优化了 remove_ctrl() 函数。因 此,继续寻找和移除其他内存分配操作是合理的。

如果通过值将一个字符串表达式传递给一个函数,那么形参(在本例中即 s)将会通过复 制构造函数被初始化。这可能会导致复制操作,当然,这取决于字符串的实现方式。

  • 如果字符串是以写时复制惯用法方式实现的,那么编译器会调用复制构造函数,这将执 行一次高效的指针复制并增加引用计数。
  • 如果字符串是以非共享缓冲区的方式实现的,那么复制造函数必须分配新的缓冲区并复 制实参的内容。
  • 如果编译器实现了 C++11 风格的右值引用和移动语义,而且实参是一个表达式,那么 它就是是一个右值,这样编译器将会调用移动构造函数,这会执行一次高效的指针复制。 如果实参是一个变量,那么将会调用形参的构造函数,这会导致一次内存分配和复制。6.6 节中将详细讲解右值引用和移动语义。

代码清单 4-4 中的 remove_ctrl_ref_args() 是改善后的永远不会复制 s 的函数。由于该函 数不会修改 s,因此没有理由去复制一份 s。取而代之的是,remove_ctrl_ref_args() 会给 s 一个常量引用作为参数。这省去了另外一次内存分配。由于内存分配是昂贵的,所以哪 怕只是一次内存分配,也值得从程序中移除。

代码清单 4-4 remove_ctrl_ref_args():移除实参复制

std::string remove_ctrl_ref_args(std::string const& s) {
 std::string result;
 result.reserve(s.length());
 for (int i=0; i<s.length(); ++i) {
 if (s[i] >= 0x20)
 result += s[i];
 }
 return result;
}

改善后的结果令人大吃一惊。remove_ctrl_ref_args() 的测试结果是每次调用花费 1.60 微秒,相比 remove_ctrl_reserve() 性能下降了 8%。

到底发生了什么呢?当这段函数运行时,Visual Studio 2010 应该会复制字符串。因此,这 次修改本应该能够省去一次内存分配。原因可能是并没有真正省去这次内存分配,或是将 s 从字符串修改为字符串引用后导致其他相关因素抵消了节省内存分配带来的性能提升。

引用变量是作为指针实现的。因在,当在 remove_ctrl_ref_args() 中每次出现 s 时,程序 都会解引指针,而在 remove_ctrl_reserve() 中则不会发生解引。我推测这些额外的开销 可能足以导致性能下降。

4.2.4 使用迭代器消除指针解引

解决方法是在字符串上使用迭代器,如代码清单 4-5 所示。字符串迭代器是指向字符缓冲 区的简单指针。与在循环中不使用迭代器的代码相比,这样可以节省两次解引操作。

代码清单 4-5 remove_ctrl_ref_args_it():remove_ctrl_ref_args() 的使用了迭代器的版本

std::string remove_ctrl_ref_args_it(std::string const& s) {
 std::string result;
 result.reserve(s.length());
 for (auto it=s.begin(),end=s.end(); it != end; ++it) {
 if (*it >= 0x20)
 result += *it;
 }
 return result;
}

测试结果令人满意,每次调用 remove_ctrl_ref_args_it() 的时间为 1.04 微秒。与不使用 迭代器的版本相比,这绝对是非常棒的结果。但是如果将 s 变为字符串引用的话会怎么样 呢?为了确定这项优化到底是否有助于改善性能,我编写了一个使用了迭代器的 remove_ ctrl_reserve()。测试结果是每次调用 remove_ctrl_reserve_it() 的时间为 1.26 微秒,比 修改前的 1.47 微秒略有减少。这说明将参数类型修改为字符串引用确实提高了程序性能。

实际上,我为函数名以 remove_ctrl() 开头的函数都编写过对应的使用迭代器的版本。在 所有这些函数中,使用迭代器都比不使用迭代器要快。(不过,在 4.3 节中,我们将会看到 这个技巧并非总是有效。)

在 remove_ctrl_ref_args_it() 中还包含另一个优化点,那就是用于控制 for 循环的 s.end() 的值会在循环初始化时被缓存起来。这样可以节省 2n 的间接开销,其中 n 是参数 字符串的长度。

4.2.5 消除对返回的字符串的复制

remove_ctrl() 函数的初始版本是通过值返回处理结果的。C++ 会调用复制构造函数将处 理结果设置到调用上下文中。虽然只要可能的话,编译器是可以省去(即简单地移除)调 用复制构造函数的,但是如果我们想要确保不会发生复制,那么有几种选择。其中一种选 择是将字符串作为输出参数返回,这种方法适用于所有的 C++ 版本以及字符串的所有实现方式。这也是编译器在省去调用复制构造函数时确实会进行的处理。代码清单 4-6 展示了

代码清单 4-6 remove_ctrl_ref_result_it():移除对返回值的复制

void remove_ctrl_ref_result_it (
 std::string& result,
 std::string const& s)
{
 result.clear();
 result.reserve(s.length());
 for (auto it=s.begin(),end=s.end(); it != end; ++it) {
 if (*it >= 0x20)
 result += *it;
 }
}

当程序调用 remove_ctrl_ref_resultit() 时,一个指向字符串变量的引用会被传递给形参 result。如果 result 引用的字符串变量是空的,那么调用 reserve() 将分配足够的内存空 间用于保存字符。如果程序之前使用过这个字符串变量,例如程序循环地调用了 remove ctrl_ref_result_it(),那么它的缓冲区可能已经足够大了,这种情况下可能无需分配 新的内存空间。当函数返回时,调用方的字符串变量将会接收返回值,无需进行复制。 remove_ctrl_ref_result_it() 的优点在于多数情况下它都可以移除所有的内存分配。

remove_ctrl_ref_result_it() 的性能测量结果是每次调用花费 1.02 微秒,比修改之前的版 本快了大约 2%。 remove_ctrl_ref_result_it() 已经非常高效了,但是相比 remove_ctrl() 而言,它的接口 很容易导致调用方误用这个函数。引用——即使是常量引用——的行为与值的行为并非完 全相同。下面的函数调用将会返回一个空字符串,这与预想的结果不同:

std::string foo("this is a string");
remove_ctrl_ref_result_it(foo, foo);

4.2.6 用字符数组代替字符串

当程序有极其严格的性能需求时,可以如代码清单 4-7 所示,不使用 C++ 标准库,而是利 用 C 风格的字符串函数来手动编写函数。相比 std::string,C 风格的字符串函数更难以 使用,但是它们却能带来显著的性能提升。要想使用 C 风格的字符串,程序员必须手动分 配和释放字符缓冲区,或者使用静态数组并将其大小设置为可能发生的最差情况。如果内 存的使用量非常严格,那么可能无法声明很多静态数组。不过,在局部存储区(即函数调 用栈)中往往有足够的空间可以静态地声明大型临时缓冲区。当函数退出时,这些缓冲区 将会被回收,而产生的运行时开销则微不足道。除了一些限制极度严格的嵌入式环境外, 在栈上声明最差情况下的缓冲区为 1000 甚至 10 000 个字符是没有问题的。

代码清单 4-7 remove_ctrl_cstrings():在底层编码

void remove_ctrl_cstrings(char* destp, char const* srcp, size_t size) {
 for (size_t i=0; i<size; ++i) {
 if (srcp[i] >= 0x20)
 *destp++ = srcp[i];
 }
 *destp = 0;
}

测试结果是每次调用 remove_ctrl_cstrings() 的时间为 0.15 微秒。这比上一个版本的函数 快了 6 倍,比最初的版本更是快了足足 170 倍。获得这种改善效果的原因之一是移除了若 干函数调用以及改善了缓存局部性。

不 过, 优 秀的 缓 存 局 部 性 可 能 会 误 导 性 能 测 量。 通 常, 在 两 次 调 用 removectrl cstrings() 之间的其他操作会刷新缓存。但是当在一个循环中频繁地调用该函数时,指令 和数据可能会驻留在缓存中。

另一个影响 remove_ctrl_cstrings() 的因素是它的接口与初始函数相比发生了太多改 变。如果有许多地方都调用了初始版本函数,那么将那些代码修改为调用现在的这个函 数需要花费很多人力和时间,而且修改后代码也可能需要优化。尽管如此,removectrl cstrings() 这个例子仍然说明,只要开发人员愿意完全重写函数和改变它的接口,他们可 以获得很大的性能提升。

停下来思考
正如在之前的章节中所提到的,在进行性能优化时,要注意权衡简单性、安全性与所 获得的性能提升效果。相比 remove_ctrl(),remove_ctrl_ref_result_it() 需要改变函 数签名,这可能会引入潜在的错误。remove_ctrl_cstrings() 的性能改善代价是手动 管理临时存储空间。对于某些开发团队来说,这个代价太大了。 对于一项性能改善是否值得增加接口的复杂性或是增加需要评审函数调用的工作量, 开发人员有不同的观点,有时候观点还非常强硬。特别钟爱通过输出参数返回函数值 的开发人员可能会认为危险用例不太可能出现,而且可以记录下来。通过输出参数返 回字符串还可以让函数返回值发挥其他作用,例如返回错误代码。那些反对这项优 化的开发人员可能会说,这里没有明显地警告用户远离危险的用例,而且一个微妙的 bug 带来的麻烦远比性能优化的价值大。最后,团队必须回答一个问题:“我们需要将 程序性能提高多少?” 我无法告诉你什么时候优化过度了,因为这取决于性能改善有多重要。但是开发人员 应当注意性能的转变,然后停下来多多思考。 C++ 为开发人员提供了很多选择,从编写简单、安全但效率低下的代码,到编写高效 但必须谨慎使用的代码。其他编程语言的提倡者可能会认为这是一个缺点,但是就优 化而言,这是 C++ 最强有力的武器之一。

4.2.7 第一次优化总结

表 4-1 中总结了对 remove_ctrl() 采取各种优化手段后的测试结果。这些结果都来自于遵 循一个简单的规则:移除内存分配和相关的复制操作。第一个优化手段带来的性能提升效 果最显著。

许多因素都会影响绝对时间,包括处理器、基础时钟频率、内存总线频率、编译器和优化 器。我已经提供了调试版和正式(优化后)版的测试结果来证明这一点。虽然正式版代码 比调试版代码的运行速度快得多,但是在调试版和正式版中都可以看出改善效果。

表4-1:性能总结VS 2010,i7

函数 调试版 Δ 正式版 Δ 正式版与调试版
remove_ctrl() 967 微秒 24.8 微秒 3802%
remove_ctrl_mutating() 104 微秒 834% 1.72 微秒 1341% 5923%
remove_crtl_reserve() 102 微秒 142% 1.47 微秒 17% 6853%
remove_ctrl_ref_args_it() 215 微秒 9% 1.04 微秒 21% 20559%
remove_ctrl_ref_result_it() 215 微秒 0% 1.02 微秒 2% 21012%
remove_ctrl_cstrings() 1 微秒 9698% 0.15 微秒 601% 559%

正式版本的性能提升百分比看起来更具有戏剧性。这可能是受到了阿达姆法则的影响。在 调试版本中,函数的内联展开被关闭了,这增加了每个函数调用的开销,也导致内存分配 的执行时间所占的比重降低了。

4.3 第二次尝试优化字符串

开发人员还可以通过其他途径寻求更好的性能。我们将在本节中讨论几种优化选择。

4.3.1 使用更好的算法

一种优化选择是尝试改进算法。初始版本的 remove_ctrl() 使用了一种简单的算法,一次 将一个字符复制到结果字符串中。这个不幸的选择导致了最差的内存分配行为。代码清单 4-8 在初始设计的基础上,通过将整个子字符串移动至结果字符串中改善了函数性能。这 个改动可以减少内存分配和复制操作的次数。remove_ctrl_block() 中展示的另外一种优化 选择是缓存参数字符串的长度,以减少外层 for 循环中结束条件语句的性能开销。

代码清单 4-8 remove_ctrl_block():一种更快的算法

std::string remove_ctrl_block(std::string s) {
 std::string result;
 for (size_t b=0, i=b, e=s.length(); b < e; b = i+1) {
     for (i=b; i<e; ++i) {
     if (s[i] < 0x20)
     break;
     }
 result = result + s.substr(b,i-b);
 }
 return result;
}

测试结果是每次调用 remove_ctrl_block() 的运行时间为 2.91 微秒,这个速度大约比初始 版本的 remove_ctrl() 快了 7 倍。

这个函数与以前一样,可以通过使用复合赋值运算符替换字符串连接运算符来改善 (remove_ctrl_block_mutatel(),每次调用的时间是 1.27 微秒)其性能,但是 substr() 仍 然生成临时字符串。由于这个函数将字符添加到了 result 的末尾,开发人员可以通过重 载 std::string 的 append() 成员函数来复制子字符串,且无需创建临时字符串。修改后的 remove_ctrl_block_mutate() 函数(如代码清单 4-9 所示)的测试结果是每次调用耗时 0.65 微秒。这个结果轻松地战胜了 remove_ctrl_ref_result_it() 的每次调用 1.02 微秒的成绩, 比初始版本的 remove_ctrl() 快了 36 倍。这个简单的例子向我们展示了选择一种更好的算 法是一种多么强大的优化手段。

代码清单 4-9 remove_ctrl_block_append():一种更快的算法

std::string remove_ctrl_block_append(std::string s) {
 std::string result;
 result.reserve(s.length());
 for (size_t b=0,i=b; b < s.length(); b = i+1) {
 for (i=b; i<s.length(); ++i) {
 if (s[i] < 0x20) break;
 }
 result.append(s, b, i-b);
 }
 return result;
}

这个结果还可以通过预留 result 的存储空间和移除参数复制(remove_ctrl_block_args(), 每次调用的时间是 0.55 微秒)以及通过移除返回值的复制(remove_ctrl_block_ret(),每次调用的时间是 0.51 微秒)来改善。

有一件事情对性能没有改善效果,至少在最开始没有,那就是使用迭代器重写 remove_ ctrl_block()。不过,如表 4-2 所示,在将参数和返回值都变为引用类型后,使用了迭代 器的版本的开销突然从增加 10 倍变为了减少 20%。

表4-2:第二种remove_ctrl算法的性能总结

每次调用时间 Δ(与上一次相比)
remove_ctrl() 24.8 微秒
remove_ctrl_block() 2.91 微秒 751%
remove_ctrl_block_mutate() 1.27 微秒 129%
remove_ctrl_block_append() 0.65 微秒 95%
remove_ctrl_block_args() 0 0.55 微秒 27%
remove_ctrl_block_ret() 0.51 微秒 6%
remove_ctrl_block_ret_it() 0.43 微秒 19%

另外一种改善性能的方法是,通过使用 std::string 的 erase() 成员函数移除控制字符来改变字符串。代码清单 4-10 展示了这种修改方法。

代码清单 4-10 remove_ctrl_erase():不创建新的字符串,而是修改参数字符串的值作 为结果返回

std::string remove_ctrl_erase(std::string s) {
 for (size_t i = 0; i < s.length();)
 if (s[i] < 0x20)
 s.erase(i,1);
 else ++i;
 return s;
}

这种算法的优势在于,由于 s 在不断地变短,除了返回值时会发生内存分配外,其他情 况下都不会再发生内存分配。修改后的函数性能非常棒,测试结果是每次调用耗时 0.81 毫秒,比初始版本的 remove_ctrl() 快了 30 倍。如果在第一次优化中取得了这个优异的 结果,开发人员可能认为自己胜利了,然后退出优化战场,不会考虑如何进一步优化。 有时候,选择一种不同的算法后程序会变得更快;即使没有变快,也可能会变得比原来更容易优化。

4.3.2 使用更好的编译器

我使用 Visual Studio 2013 运行了相同的测试。Visual Studio 2013 实现了移动语义,这应 当会让一些函数更快。不过,结果却有点让人看不懂。在调试模式下的运行结果是 Visual Studio 2013 比 Visual Studio 2010 快了 5%~15%,不过从命令行运行的结果是 VS2013 慢了 5%~20%。我也试过 Visual Studio 2015 RC 版,结果更慢。这可能与容器类的改变有关。一 个新版本的编译器可能会改善性能,不过这需要开发人员通过测试去验证,而不是想当然。

4.3.3 使用更好的字符串库

std::string 的定义曾经非常模糊,这让开发人员在实现字符串时有更广泛的选择。后来, 对性能和可预测性的需求最终迫使 C++ 标准明确了它的定义,导致很多新奇的实现方式不 再适用。定义 std::string 的行为是一种妥协,它是经过很长一段时间以后从各种设计思 想中演变出来的。

  • 与其他标准库容器一样,std::string 提供了用于访问字符串中单个字符的迭代器。
  • 与 C 风格的字符串一样,std::string 提供了类似数组索引的符号,可以使用运算符 [] 访问它的元素。std::string 还提供了一种用于获取指向以空字符结尾的 C 风格字符串 的指针的机制。
  • 与 BASIC 字符串类似,std::string 有一个连接运算符和可以赋予字符串值语义(value semantics)的返回值的函数。
  • std::string 提供的操作非常有限,有些开发人员会感觉受到了限制。

希望 std::string 与 C 风格的字符数组一样高效,这个需求推动着字符串的实现朝着在紧 邻的内存中表现字符串的方向前进。C++ 标准要求迭代器能够随机访问,而且禁止写时复 制语义。这样更容易定义 std::string,而且更容易推论出哪些操作会使在 std::string 中使用迭代器无效,但它同时也限制了更聪明的实现方式的范围。

而且,商业 C++ 编译器的 std::string 的实现必须足够直接,使其可以被测试,以确保字 符串的行为符合标准,并且在所有可考虑到的情况下都具有可接受的性能。编译器厂商犯 错的代价是非常大的。这会推动 std::string 的实现趋于简单。

标准所定义的 std::string 的行为有一些缺点。向一个含有 100 万个字符的字符串中插 入一个字符会导致整个字符串都被复制一份,而且可能会发生内存分配。类似地,所有 返回值的子字符串的操作都必须分配内存和复制它们的结果。一些开发人员会通过避开 一个或多个之前提到的限制(迭代器、索引、C 风格的访问方式、值语义、简单性)来 寻找优化机会。

  1. 采用更丰富的std::string库

有时候,使用更好的库也表示使用额外的字符串函数。许多库都可以与 std::string 共同 工作,下面列举了其中一部分。

Boost 字符串库(http://www.boost.org/doc/libs/?view=category_String

Boost 字符串库提供了按标记将字符串分段、格式化字符串和其他操作 std::string 的 函数。这为那些喜爱标准库中的 头文件的开发人员提供了很大的帮助。

C++ 字符串工具包(http://www.partow.net/programming/strtk/index.html

另一个选择是 C++ 字符串工具包(StrTk)。StrTk 在解析字符串和按标记将字符串分段 方面格外优秀,而且它兼容 std::string。

  1. 使用std::stringstream避免值语义

C++ 已经有几种字符串实现方式了:模板化的、支持迭代器访问的、可变长度的 std::string 字符串;简单的、基于迭代器的 std::vector;老式的、C 风格的以空 字符结尾的、固定长度的字符数组。

尽管很难用好 C 风格的字符串,但我们之前已经通过实验看到了,在适当的条件下,使用 C 风格的字符数组替换 C++ 的 std::string 后可以极大程度地改善程序的性能。这两种实 现方式都很难完美地适用于所有情况。

C++ 中还有另外一种字符串。std::stringstream 之于字符串,就如同 std::ostream 之于 输出文件。std::stringstream 类以一种不同的方式封装了一块动态大小的缓冲区(事实 上,通常就是一个 std::string),数据可以被添加至这个实体(请参见 6.1.3 节中的内容) 中。std::stringstream 是一个很好的例子,它展示了如何在类似的实现的顶层使用不同的 API 来提高代码性能。代码清单 4-11 展示了 std::stringstream 的使用方法。

代码清单 4-11 std::stringstream:类似于字符串,但却是一个对象

std::stringstream s;
for (int i=0; i<10; ++i) {
 s.clear();
 s << "The square of " << i << " is " << i*i << std::endl;
 log(s.str());
}

这段代码展示了几个优化代码的技巧。由于 s 被修改为了一个实体,这个很长的插入表达 式不会创建任何会临时字符串,因此不会发生内存分配和复制操作。另外一个故意的改动 是将 s 声明了在循环外。这样,s 内部的缓存将会被复用。第一次循环时,随着字符被添 加至对象中,可能会重新分配几次缓冲区,但是在接下来的迭代中就不太可能会重新分配 缓冲区了。相比之下,如果将 s 定义在循环内部,每次循环时都会分配一块空的缓冲区, 而且当使用插入运算符添加字符时,还有可能重新分配缓冲区。

如果 std::stringstream 是用 std::string 实现的,那么它在性能上永远不能胜过 std::string。 它的优点在于可以防止某些降低程序性能的编程实践。

  1. 采用一种新奇的字符串实现方式

开发人员可能会发现字符串缺乏抽象性。C++ 最重要的特性之一是没有内置字符串等抽象 性,却以模板或者函数库的形式提供了这种抽象性。std::string 等可选的实现方式成为 了这门编程语言的特性。所以一位非常聪明的开发人员实现的字符串的性能可能会更好。 通过移除一个或多个在本节开头列举出的限制(迭代器、索引、C 风格访问、简单性), 可以定义出自己的字符串类来优化那些因使用了 std::string 而无法优化的代码。

随着时间的推移,开发人员提出了许多聪明的字符串数据结构,承诺可以显著地降低内存 分配和复制字符串内容的开销。但是出于以下几个原因,这可能会是“塞壬的歌声” 。

  • 任何想要取代 std::string 的实现方式都必须具有足够的表现力,且在大多数场合都比 std::string 更高效。提议的绝大多数可选实现方式都无法确保在多数情况下可以提高 性能。
  • 将一个大型程序中出现的所有 std::string 都换成其他字符串是一项浩大的工程,而且 无法确保这一定能提高性能。
  • 虽然有许多种可选的字符串概念被提出来了,而且有一些已经实现了,但是想要通过谷 歌找到一种像 std::string 一样完整的、经过测试的、容易理解的字符串实现,却需要 花费一些工夫。

在设计程序时考虑替换 std::string 可能比在进行优化时替换 std::string 更现实。虽然 对于一个有足够时间和资源的大团队来说,在进行优化时替换 std::string 也是可能的, 但是结果的不确定性太高,因而这种优化不太现实。但是仍然有其他实现方式的字符串可以帮助我们。

名词 介绍
std::string_view string_view 可以解决 std::string 的某些问题。它包含一个指向字符串数据的无主指 针和一个表示字符串长度的值,所以它可以表示为 std::string 或字面字符串的子字 符串。与 std::string 的返回值的成员函数相比,它的 substring 和 trim 等操作更高 效。std::string. string_view 可能会被加入到 C++14 中。有些编译器现在已经实现了 std::experimental::string_view。string_view 与 std::string 的接口几乎相同。 std::string 的问题在于指针是无主的。程序员必须确保每个 string_view 的生命周期 都不会比它所指向的 std::string 的生命周期长。
folly::fbstring Folly 是一个完整的代码库,它被 Facebook 用在了他们自己的服务器上。它包含了高度 优化过的、可以直接替代 std::string 的 fbstring。在 fbstring 的实现方式中,对于短 的字符串是不用分配缓冲区的。fbstring 的设计人员声称他们测量到性能得到了改善。 由于这种特性,Folly 很可能非常健壮和完整。目前,只有 Linux 支持 Folly。
字符串类的工具包 http://johnpanzer.com/tsc_cuj/ToolboxOfStrings.html)这篇发表于 2000 年的文章和代码描述了一个模板化的字符串类型,其接口与 SGI5 的 std::string 相同。它提供了一个固定最大长度的字符串类型和一个可变长度的字符串 类型。这是模板元编程(template metaprogramming)魔法的一个代表作,但可能会让 一些人费解。对于那些致力于设计更好的字符串类的开发人员来说,这是一个切实可行 的候选类库。
C++03 表达式模板 http://craighenderson.co.uk/papers/exptempl/) 这是在 2005 年的一篇论文中展示的用于解决特定字符串连接问题的模板代码。表达 式模板重写了 + 运算符,这样可以创建一个表示两个字符串的连接或是一个字符串和 一个字符串表达式的连接的中间类型。当表达式模板被赋值给一个字符串时,表达式 模板将内存分配和复制推迟至表达式结束,只会执行一次内存分配。表达式模版兼容 std::string。当既存的代码中有一个连接一长串子字符串的表达式时,使用表达式模 板可以显著地提升性能。这个概念可以扩展至整个字符串库。
Better String 库 http://bstring.sourceforge.net/) 这个代码归档文件中包含了一个通用的字符串实现。它与 std::string 的实现方式不 同,但是包含一些强大的特征。如果许多字符串是从其他字符串中的一部分构建出来 的,bstring 允许通过相对一个字符串的偏移量和长度来组成一个新的字符串。我用过 以这种思想设计实现的有专利权的字符串,它们确实非常高效。在 C++ 中有一个称为 CBString 的 bstring 库的包装类。
rope https://www.sgi.com/tech/stl/Rope.html) 这是一个非常适合在长字符串中进行插入和删除操作的字符串库。它不兼容 std::string。
Boost 字符串算法 http://www.boost.org/doc/libs/1_60_0/doc/html/string_algo.html) 这是一个字符串算法库,它是对 std::string 的成员函数的补充。这个库是基于“查找 和替换”的概念构建起来的。

4.3.4 使用更好的内存分配器

每个 std::string 的内部都是一个动态分配的字符数组。std::string 看上去像是下面这样 的通用模板的一种特化:

namespace std {
 template < class charT,
 class traits = char_traits<charT>,
 class Alloc = allocator<charT>
 > class basic_string;
 typedef basic_string<char> string;
 ...
};

第三个模板参数 Alloc 定义了一个分配器——一个访问 C++ 内存管理器的专用接口。默认 情况下,Alloc 是 std::allocator,它会调用 ::operator new() 和 ::operator delete()—— 两个全局的 C++ 内存分配器函数。

我将会在第 13 章中详细讲解 ::operator new() 和 ::operator delete() 以及分配器对象 的行为。现在,我只能告诉读者,::operator new() 和 ::operator delete() 会做一项非 常复杂和困难的工作,为各种动态变量分配存储空间。它们需要为大大小小的对象以及 单线程和多线程程序工作。为了实现良好的通用性,它们在设计上做出了一些妥协。有 时,选择一种更加特化的分配器可能会更好。因此,我们可以指定默认分配器以外的为 std::string 定制的分配器作为 Alloc。

我编写了一个极其简单的分配器来展示可以获得怎样的性能提升。这个分配器可以管理几 个固定大小的内存块。如代码清单 4-12 所示,我首先为使用这种分配器的字符串创建了一 个 typedef。接着,我修改初始的、非常低效的 remove_ctrl() 来使用这种特殊的字符串。

代码清单 4-12 使用简单的、管理固定大小内存块的分配器的原始版本的 remove_ctrl()

typedef std::basic_string<
 char,
 std::char_traits<char>,
 block_allocator<char, 10>> fixed_block_string;
fixed_block_string remove_ctrl_fixed_block(std::string s) {
 fixed_block_string result;
 for (size_t i=0; i<s.length(); ++i) {
 if (s[i] >= 0x20)
 result = result + s[i];
 }
 return result;
}

测试结果非常有戏剧性。在相同的测试中,remove_ctrl_fixed_block() 的运行时间为 13 636 毫秒,大约比初始版本快了 7.7 倍。

修改分配器并不适用于怯懦的开发人员。你无法将基于不同分配器的字符串赋值给另外一 个字符串。修改后的示例代码之所以能够工作,仅仅是因为 s[i] 是一个字符,而不是一个 只有一个字符的 std::string。你可以通过将字符串转换为 C 风格的字符串,将一个字符 串的内容复制到另一个字符串中。例如,可以将示例代码修改为 result = s.c_str();

将代码中所有的 std::string 都修改为 fixed_block_string 将会带来很大的影响。因此, 如果一个团队认为需要对他们使用的字符串做些改变,那么最好在设计阶段就定义全工程 范围的 typedef:

typedef std::string MyProjString;

之后,当要进行涉及大量代码修改的实验时,只需要修改这一处代码即可。仅在新的 字符串与要替换的字符串有相同的成员函数时,这种方法才奏效。不同分配器分配的 std::basic_strings 具有这种特性。

4.4 消除字符串转换

现代世界的复杂性之一是不止有一种字符串。通常,字符串函数只适用于对相同类型的字 符串进行比较、赋值或是作为运算对象和参数,因此,程序员必须将一种类型的字符串转 换为另外一种类型。任何时候,涉及复制字符和动态分配内存的转换都是优化性能的机会。 转换函数库自身也可以被优化。更重要的是,大型程序的良好设计是可以限制这种转换的。

4.4.1 将C字符串转换为std::string

从以空字符结尾的字符串到 std::string 的无谓转换,是浪费计算机 CPU 周期的原因之 一。例如:

std::string MyClass::Name() const {
 return "MyClass";
}

这个函数必须将字符串常量 MyClass 转换为一个 std::string,分配内存和复制字符到 std::string 中。C++ 会自动地进行这次转换,因为在 std::string 中有一个参数为 char* 的构造函数。

转换为 std::string 是无谓的。std::string 有一个参数为 char* 的构造函数,因此当 Name() 的返回值被赋值给一个字符串或是作为参数传递给另外一个函数时,会自动进行转 换。上面的函数可以简单地写为:

char const* MyClass::Name() const {
 return "MyClass";
}

这会将返回值的转换推迟至它真正被使用的时候。当它被使用时,通常不需要转换:

char const* p = myInstance->Name(); // 没有转换
std::string s = myInstance->Name(); // 转换为'std::string'
std::cout << myInstance->Name(); // 没有转换

一个大型软件系统可能含有很多层(layer),这会让字符串转换成为一个大问题。如果在 某一层中接收的参数类型是 std::string,而在它下面一层中接收的参数类型是 char*,那 么可能需要写一些代码将 std::string 反转为 char*

void HighLevelFunc(std::string s) {
 LowLevelFunc(s.c_str());
}

4.4.2 不同字符集间的转换

现代 C++ 程序需要将 C 的字面字符串(ASCII,有符号字节)与来自 Web 浏览器的 UTF-8 (无符号,每个字符都是可变长字节)字符串进行比较,或是将由生成 UTF-16 的字流(带 或者不带端字节)的 XML 解析器输出的字符串转换为 UTF-8。转换组合的数量令人生畏。

移除转换的最佳方法是为所有的字符串选择一种固定的格式,并将所有字符串都存储为这 种格式。你可能希望提供一个特殊的比较函数,用于比较你所选择的格式和 C 风格的以空 字符结尾的字符串,这样就无需进行字符串转换。我个人比较喜欢 UTF-8,因为它能够表 示所有的 Unicode 代码点,可以直接与 C 风格的字符串进行比较(是否相同),而且多数 浏览器都可以输出这种格式。

在时间紧迫的情况下编写的大型程序中,你可能会发现在将一个字符串从软件中的一层传 递给另一层时,先将它从原来的格式转换为一种新的格式,然后再将它转换为原来的格式 的代码。可以通过重写类接口中的成员函数,让它们接收相同的字符串类型来解决这个问 题。不幸的是,这项任务就像是在 C++ 程序中加入常量正确性(const-correctness)。这种 修改涉及程序中的许多地方,难以控制其范围。

转载请注明:xuhss » C++ 性能优化篇四《优化字符串的使用:案例研究》

喜欢 (1)

您必须 登录 才能发表评论!