【问题标题】:What is the fastest way to check the leading characters in a char array?检查 char 数组中的前导字符的最快方法是什么?
【发布时间】:2020-12-03 01:09:08
【问题描述】:

我的代码遇到了瓶颈,所以这个问题的主要问题是性能。

我有一个十六进制校验和,我想检查一个字符数组的前导零。这就是我正在做的:

bool starts_with (char* cksum_hex, int n_zero) {
  bool flag {true};
  for (int i=0; i<n_zero; ++i)
    flag &= (cksum_hex[i]=='0');
  return flag;
}

如果cksum_hexn_zero 前导零,则上述函数返回true。但是,对于我的应用程序,此功能非常昂贵(占总时间的 60%)。换句话说,它是我的代码的瓶颈。所以我需要改进它。

我还检查了 C++20 中可用的 std::string::starts_with,我发现性能没有差异:

// I have to convert cksum to string
std::string cksum_hex_s (cksum_hex);
cksum_hex_s.starts_with("000");     // checking for 3 leading zeros

有关更多信息,我正在使用g++ -O3 -std=c++2a,我的 gcc 版本是 9.3.1。

问题

  • 检查 char 数组中的前导字符的更快方法是什么?
  • std::string::starts_with 有没有更有效的方法?
  • 按位运算在这里有用吗?

【问题讨论】:

  • @GuillaumeGris 我确信 n_zero 永远不会超过 cksum_hex 的范围。我们在编译时不知道 n_zero。
  • 这是一个非常微不足道的函数,很难想象一个程序会占用 60% 的时间,除非n_zero 通常非常高。函数是内联的吗?我想不会,因为内联函数通常不会出现在分析器上。内联可能是最大的胜利。此外,有两种类型的分析器,仪器分析器和采样分析器。检测分析器通常会增加每个函数调用的开销,这会夸大频繁调用函数的重要性。
  • 可能是一个愚蠢的问题,但你为什么使用“&=”运算符?如果一发现非零数字就返回 false,难道不是更简单、检查更少吗?
  • 对于那些想知道为什么按位运算可能是个好主意的人,它允许编译器进行一些向量化,因为它可以“知道”n_zero。字符将被读取。想象一下n_zero == 4,然后所有这个函数都被“加载一个未对齐的 int 并检查它的值是否为零”而不是四个比较替换。编译器不能用逻辑和来做到这一点
  • 你能像 SSE2 一样使用 x86 SIMD 吗?

标签: c++ performance optimization c++20


【解决方案1】:

如果你修改你的函数以提前返回

bool starts_with (char* cksum_hex, int n_zero) {
  for (int i=0; i<n_zero; ++i)
  {
    if (cksum_hex[i] != '0') return false;
  }
  return true;
}

如果n_zerofalse 结果很大,它会更快。否则,也许你可以尝试分配一个全局字符数组'0'并使用std::memcmp

// make it as big as you need
constexpr char cmp_array[4] = {'0', '0', '0', '0'};
bool starts_with (char* cksum_hex, int n_zero) {
    return std::memcmp(cksum_hex, cmp_array, n_zero) == 0;
}

这里的问题是您需要假设n_zero 的一些最大可能值。

Live example

=== 编辑 ===

考虑到抱怨没有分析数据来证明建议的方法是合理的,你去吧:

使用的数据:

const char* cs1 = "00000hsfhjshjshgj";
const char* cs2 = "20000hsfhjshjshgj";
const char* cs3 = "0000000000hsfhjshjshgj";
const char* cs4 = "0000100000hsfhjshjshgj";

memcmp 在所有情况下都是最快的,但 cs2 具有提前返回 impl。

【讨论】:

  • 我不相信这两个实现都比 op 实现更快。您是否有任何分析数据表明这是一种改进?
  • 当然我没有任何分析数据,我只是给 OP 一些想法尝试,然后也许对他来说会有更好的效果。提前返回可能会提高 0x1000000... 等数据的性能。对于memcmp,老实说不知道,因为它取决于它的实现(参见示例中生成的程序集)。
  • 需要考虑的一点是,quick-bench 似乎无法针对最近的架构进行编译。比较 godbolt 上 memcmp 的程序集与原始程序集,-march=broadwell(例如)会导致 memcmp 版本的大量向量指令
  • @GuillaumeGris 比较这些专门用于比较 8 个字节的实现:godbolt.org/z/E1WGn1。出于某种原因,基于循环的变体转换为单个(未对齐的)加载+ cmp 指令。只有memcmp 版本是。这适用于所有 GCC、Clang 和 Intel。我不知道为什么,这似乎是一个非常简单的优化。我最终会问另一个问题。
  • 我有个坏消息,恐怕:quick-bench.com/q/yEjbk9vg92UvVfkD4R4qyK-XmpUmemcmp 在编译时不知道 n_zero 时似乎更慢
【解决方案2】:

大概你也有二进制校验和? 与其先将其转换为 ASCII 文本,不如先查看4*n 的高位以直接检查n 的半字节以查找0,而不是检查@ 987654326@ 与'0' 相等的字节。

例如如果您将哈希(或其高 8 字节)作为 uint64_tunsigned __int128,将其右移以仅保留高 n 半字节。

我展示了一些示例,说明当两个输入都是运行时变量时它们如何为 x86-64 编译,但这些也可以很好地编译到其他 ISA,如 AArch64。这段代码都是可移植的 ISO C++。


bool starts_with (uint64_t cksum_high8, int n_zero)
{
    int shift = 64 - n_zero * 4;       // A hex digit represents a 4-bit nibble
    return (cksum_high8 >> shift) == 0;
}

clang 在 x86-64 上使用 -O3 -march=haswell 启用 BMI1/BMI2 做得很好

high_zero_nibbles(unsigned long, int):
        shl     esi, 2
        neg     sil                  # x86 shifts wrap the count so 64 - c is the same as -c
        shrx    rax, rdi, rsi        # BMI2 variable-count shifts save some uops.
        test    rax, rax
        sete    al
        ret

这甚至适用于 n=16 (shift=0) 以测试所有 64 位。 n_zero = 0 无法测试任何位;它会通过将uint64_t 移动一个移位计数> = 其宽度来遇到UB。 (在像 x86 这样包装越界移位计数的 ISA 上,适用于其他移位计数的代码生成将导致检查所有 16 位。只要 ​​UB 在编译时不可见......)希望你'反正不打算用n_zero=0 打电话。

其他选项:创建一个仅保留高 n*4 位的掩码,如果在 n_zero 之后准备好,则可能会缩短通过 cksum_high8 的关键路径。特别是如果n_zero 是内联后的编译时常量,这可以与检查cksum_high8 == 0 一样快。 (例如 x86-64 test reg, immediate。)

bool high_zero_nibbles_v2 (uint64_t cksum_high8, int n_zero) {
    int shift = 64 - n_zero * 4;         // A hex digit represents a 4-bit nibble
    uint64_t low4n_mask = (1ULL << shift) - 1;
    return cksum_high8 & ~low4n_mask;
}

或使用位扫描功能计算前导零位并比较&gt;= 4*n。不幸的是,ISO C++ until C++20 &lt;bit&gt;'s countl_zero 终于可移植地公开了这个已经存在了几十年的常见 CPU 功能(例如 386 bsf / bsr);在此之前仅作为编译器扩展,如 GNU C __builtin_clz

如果您想知道有多少并且没有一个特定的截止阈值,这很好。

bool high_zero_nibbles_lzcnt (uint64_t cksum_high8, int n_zero) {
    // UB on cksum_high8 == 0.  Use x86-64 BMI1 _lzcnt_u64 to avoid that, guaranteeing 64 on input=0
    return __builtin_clzll(cksum_high8) > 4*n_zero;
}

#include <bit>
bool high_zero_nibbles_stdlzcnt (uint64_t cksum_high8, int n_zero) {
    return std::countl_zero(cksum_high8) > 4*n_zero;
}

编译为(Haswell 的 clang):

high_zero_nibbles_lzcnt(unsigned long, int):
        lzcnt   rax, rdi
        shl     esi, 2
        cmp     esi, eax
        setl    al                    # FLAGS -> boolean integer return value
        ret

所有这些指令在 Intel 和 AMD 上都很便宜,而且 lzcnt 和 shl 之间甚至还有一些指令级并行性。

See asm output for all 4 of these on the Godbolt compiler explorer。 Clang 将 1 和 2 编译为相同的 asm。与-march=haswell 的两种 lzcnt 方式相同。否则,对于不是 UB 的 C++20 版本,它需要不遗余力地处理 input=0 的 bsr 极端情况。


要将这些扩展到更广泛的哈希,您可以检查高 uint64_t 是否全为零,然后继续下一个 uint64_t 块。


使用 SSE2 与字符串上的 pcmpeqb 进行比较,pmovmskb -> bsf 可以找到第一个 1 位的位置,因此字符串表示中有多少前导-'0' 字符,如果你有这个开始。所以 x86 SIMD 可以非常高效地完成此操作,您可以通过内部函数从 C++ 中使用它。

【讨论】:

  • 请注意,GCC 将其中的一些内容弄得一团糟,浪费了大量的指令。此外,如果没有-march=haswell 或其他lzcnt-启用选项,countl_zero 选项必须在输入全为零时分支。 (即使使用-march=haswell,GCC 也有一些优化缺失,即使lznct 做了正确的事情,仍然特殊转换 input=0。)
【解决方案3】:

与 memcmp 相比,您可以为自己创建一个足够大的零缓冲区。

const char *zeroBuffer = "000000000000000000000000000000000000000000000000000";

if (memcmp(zeroBuffer, cksum_hex, n_zero) == 0) {
   // ...
}

【讨论】:

  • 为什么会更快?
  • @GuillaumeGris memcmp 已被编译器优化。它不会逐字节比较。而且你没有循环操作。
【解决方案4】:

您想要检查以使您的应用程序更快的事项:

1。编译器可以在调用的地方内联这个函数吗?

要么在头文件中将函数声明为内联函数,要么将定义放在使用它的编译单元中。

2。不计算东西比计算更有效地更快

是否都需要调用这个函数?高成本通常是在高频循环或昂贵算法中调用的函数的标志。您通常可以通过优化外部算法来减少调用次数,从而减少函数花费的时间

3。 n_zero 是很小的,还是更好的常数?

编译器非常擅长针对通常较小的常量值优化算法。如果编译器知道该常量,它很可能会完全删除循环。

4。按位运算在这里有帮助吗?

它肯定有效果,并允许 Clang(但据我所知不是 GCC)进行一些矢量化。矢量化往往更快,但情况并非总是如此,具体取决于您的硬件和实际处理的数据。 是否是优化可能取决于n_zero 的大小。考虑到您正在处理校验和,它应该非常小,因此听起来像是一种潜在的优化。 对于已知的n_zero,使用按位运算允许编译器删除所有分支。虽然我没有测量,但我希望这会更快。

std::all_ofstd::string::starts_with 应该完全按照您的实现进行编译,除非它们将使用 &amp;&amp; 而不是 &amp;

【讨论】:

    【解决方案5】:

    除非n_zero 非常高,否则我同意其他人的观点,即您可能会误解分析器的结果。但无论如何:

    • 可以将数据交换到磁盘吗?如果您的系统处于 RAM 压力之下,数据可能会被换出到磁盘,并且在您对其执行第一次操作时需要将其加载回 RAM。 (假设这个校验和检查是一段时间内第一次访问数据。)

    • 您可以使用多个线程/进程来利用多核处理器。

    • 也许您可以使用输入数据的统计数据/相关性,或问题的其他结构特征。

      • 例如,如果您有大量数字(例如 50)并且您知道后面的数字非零的概率较高,您可以先检查最后一位。
      • 如果几乎所有您的校验和都应该匹配,您可以使用[[likely]] 给编译器提示,说明情况就是这样。 (可能不会有所作为,但值得一试。)

    【讨论】:

      【解决方案6】:

      在这个有趣的讨论中添加我的两分钱,虽然游戏有点晚了,但我想你可以使用std::equal,这是一种快速方法,方法略有不同,使用具有最大数量零的硬编码字符串,而不是零的数量。

      这可以传递给函数指针,指向要搜索的字符串的开头和结尾,以及零字符串,特别是 beginend 的迭代器,end 指向过去的位置想要的零个数,这些将被std::equal用作迭代器:

      Sample

      bool startsWith(const char* str, const char* end, const char* substr, const char* subend) {
          return  std::equal(str, end, substr, subend);
      }
      
      int main() {
      
          const char* str = "000x1234567";
          const char* substr = "0000000000000000000000000000";
          std::cout << startsWith(&str[0], &str[3], &substr[0], &substr[3]); 
      }
      

      使用@pptaszni's good answer中的测试用例和相同的测试条件:

      const char* cs1 = "00000hsfhjshjshgj";
      const char* cs2 = "20000hsfhjshjshgj";
      const char* cs3 = "0000000000hsfhjshjshgj";
      const char* cs4 = "0000100000hsfhjshjshgj";
      

      The result where as follows:

      比使用 memcmp 慢,但仍然更快(除了零数量较少的错误结果)并且比您的原始代码更一致。

      【讨论】:

      • @CodyGray,是的,我进行了一些测试,其中错误并且给了我大约 1.0,我将其编辑了但忘记更改“更快”的备注,仍然比 OP 更快,尤其是当零的数量开始增加。
      • 请注意,您的基准测试都是针对编译时常数长度的,让 clang 生成像 movzwl (%rax),%edx / xor (%rcx),%dx 这样的代码,最后是 or 来检查 3 个字节是否不同。 (我原以为它只是为result 分配了一个编译时常量布尔值,因为您只使用benchmark::DoNotOptimize(result);,而不是在输入上。但结果是使用2 对加载/异或,每对3-字节与 10 字节,这样就解释了不同字符串的恒定时间。如果重叠不聪明,9 字节比较可能会更糟。)
      • 但是无论如何,这种使用几个固定宽度的比较来达到请求的宽度只有在编译时知道宽度时才有效。否则它可能只会调用memcmp
      • @PeterCordes,感谢您的精彩分析,无论如何,如果没有 DoNotOptimize,这将与使用 memcmp 基本相同,并进行优化。
      【解决方案7】:

      使用std::all_of

      return std::all_of(chsum_hex, chsum_hex + n_zero, [](char c){ return c == '0'; })
      

      【讨论】:

      • 这很优雅,确实很好地使用了 stl 算法,但似乎没有回答 OP 问题。为什么这会更快?他做的逐位比较有用吗?
      • @GuillaumeGris 如果n_zero 的范围未知,则无法知道最快的方法是什么。只需使用std::all_of 并让编译器进行矢量化比较
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2018-05-04
      • 1970-01-01
      相关资源
      最近更新 更多