假设您谈论的是“全尺寸”CPU1,相对于其他的至少解决方案。每次搜索您可能会遭受多次分支错误预测,并且最终可能会多次检查输入字符串中的每个字符(因为您需要在二进制搜索中的每个节点处重复 strcmp)。
正如有人已经指出的那样,唯一真正了解的方法是衡量 - 但要做到这一点,您仍然需要能够首先弄清楚候选人是什么!此外,并非总是可以在现实场景中进行测量,因为甚至可能不知道这样的场景(例如,设计一个在许多不同情况下广泛使用的库函数)。
最后,了解什么可能会很快,让您既可以排除您知道表现不佳的候选人,又可以让您用直觉仔细检查您的测试结果:如果某些事情比您预期的要慢得多,那么值得检查一下原因(编译器是否做了一些愚蠢的事情),如果某些事情更快那么也许是时候更新你的直觉了。
因此,我将尝试真正尝试快速的方法 - 假设 速度真的很重要,您可以花一些时间验证复杂的解决方案。作为基线,一个简单的实现可能需要 100 ns,而真正优化的可能需要 10 ns。因此,如果您为此花费 10 个小时的工程时间,您将不得不调用此函数 4000 亿次,才能赢回 10 个小时5。当您考虑错误风险、维护复杂性和其他开销时,您将需要确保在尝试优化它之前调用此函数很多 万亿 次。这样的功能很少见,但肯定存在4。
也就是说,您缺少帮助设计快速解决方案所需的大量信息,例如:
- 您对搜索功能的输入是
std::string 或const char * 还是其他?
- 平均和最大字符串长度是多少?
- 您的大部分搜索会成功还是不成功?
- 你能接受一些误报吗?
- 字符串集在编译时是否已知,或者您是否可以接受较长的初始化阶段?
上面的答案可以帮助你划分设计空间,如下所述。
布隆过滤器
如果根据 (4),您可以接受(可控)数量的误报2,或每 (3) 您的大部分搜索都不会成功,那么您应该考虑使用Bloom Filter。例如,您可以使用 1024 位(128 字节)过滤器,并使用字符串的 60 位散列通过 6 个 10 位函数对其进行索引。这给出了
这样做的好处是,在哈希计算之外,它独立于字符串的长度,并且不依赖于匹配行为(例如,如果字符串倾向于,依赖于重复字符串比较的搜索会变慢有长的公共前缀)。
如果你可以接受误报,你就完成了 - 但如果你需要它始终正确但预计搜索大多不成功,你可以将它用作过滤器:如果布隆过滤器返回 false em> (通常情况下)你已经完成了,但如果它返回 true,你需要仔细检查下面讨论的始终正确的结构之一。所以常见的情况很快,但总是返回正确的答案。
完美哈希
如果大约 100 个字符串的集合在编译时是已知的,或者您可以做一些一次性繁重的工作来预处理字符串,您可以考虑使用完美哈希。如果你有一个编译时已知的搜索集,你可以将字符串插入gperf,它会输出一个哈希函数和查找表。
例如,我刚刚将 100 个随机英文单词3 输入gperf,它生成了一个哈希函数,只需查看 两个字符 即可唯一区分每个单词,像这样:
static unsigned int hash (const char *str, unsigned int len)
{
static unsigned char asso_values[] =
{
115, 115, 115, 115, 115, 81, 48, 1, 77, 72,
115, 38, 81, 115, 115, 0, 73, 40, 44, 115,
32, 115, 41, 14, 3, 115, 115, 30, 115, 115,
115, 115, 115, 115, 115, 115, 115, 16, 18, 4,
31, 55, 13, 74, 51, 44, 32, 20, 4, 28,
45, 4, 19, 64, 34, 0, 21, 9, 40, 70,
16, 0, 115, 115, 115, 115, 115, 115, 115, 115,
/* most of the table omitted */
};
register int hval = len;
switch (hval)
{
default:
hval += asso_values[(unsigned char)str[3]+1];
/*FALLTHROUGH*/
case 3:
case 2:
case 1:
hval += asso_values[(unsigned char)str[0]];
break;
}
return hval;
}
现在您的哈希函数 快速 并且可能很好预测(如果您没有太多长度为 3 或更短的字符串)。要查找字符串,您只需索引哈希表(也由gperf 生成),然后将您得到的内容与输入字符串进行比较。
在一些合理的假设下,这将尽可能快 - clang 生成如下代码:
in_word_set: # @in_word_set
push rbx
lea eax, [rsi - 3]
xor ebx, ebx
cmp eax, 19
ja .LBB0_7
lea ecx, [rsi - 1]
mov eax, 3
cmp ecx, 3
jb .LBB0_3
movzx eax, byte ptr [rdi + 3]
movzx eax, byte ptr [rax + hash.asso_values+1]
add eax, esi
.LBB0_3:
movzx ecx, byte ptr [rdi]
movzx edx, byte ptr [rcx + hash.asso_values]
cdqe
add rax, rdx
cmp eax, 114
ja .LBB0_6
mov rbx, qword ptr [8*rax + in_word_set.wordlist]
cmp cl, byte ptr [rbx]
jne .LBB0_6
add rdi, 1
lea rsi, [rbx + 1]
call strcmp
test eax, eax
je .LBB0_7
.LBB0_6:
xor ebx, ebx
.LBB0_7:
mov rax, rbx
pop rbx
ret
这是一大堆代码,但 ILP 数量还算合理。关键路径是通过 3 个相关的内存访问(在 str 中查找 char 值 -> 在哈希函数表中查找 char 的哈希值 -> 在实际哈希表中查找字符串),您预计这通常需要 20 个周期(当然还有 strcmp 时间)。
尝试
这个问题的“经典”compsci 解决方案是trie。 trie 可能是解决您的问题的合理方法,尤其是许多不成功的匹配可以在前几个字符内迅速被拒绝(这在很大程度上取决于匹配集的内容和您正在检查的字符串)。
您需要一个快速的 trie 实现来完成这项工作。总的来说,我觉得这种方法会受到串行依赖的内存访问的限制——每个节点都可能以一种指针追踪的方法被访问,所以你会受到 L1 访问延迟的影响。
优化strcmp
几乎所有上述解决方案在某些时候都依赖于strcmp - 例外是允许误报的布隆过滤器。所以你要确保这部分代码是快速的。
特别是编译器有时可能会内联strcmp 的“内置”版本而不是调用库函数:在快速测试中icc 进行了内联,但clang 和gcc 选择调用库函数。没有一个简单的规则会更快,但通常库例程通常是 SIMD 优化的,并且对于长字符串可能更快,而内联版本避免函数调用开销并且对于短字符串可能更快。您可以测试这两种方法,并主要强制编译器在您的情况下执行更快的操作。
更好的是,您可以利用对输入的控制来做得更好 - 例如,如果您可以确保输入字符串将被 null 填充,这样它的长度是 8 的倍数,那么您可以对哈希表(或任何其他结构)中的参考字符串执行相同的操作,并且您可以一次比较字符串 8 个字节。这不仅大大加快了匹配速度,而且大大减少了分支错误预测,因为它本质上量化了循环行为(所有 1-8 个字符的字符串循环一次,等等)。
1 这里我指的是台式机、服务器、笔记本电脑 CPU,甚至是现代智能手机 CPU,而不是嵌入式设备 MCU 或类似的东西。
2 允许误报 意味着即使输入字符串不在集合中,您的“集合中”有时也会返回 true。请注意,反过来它永远不会出错:当字符串 is 在集合中时,它 总是 返回 true - 没有 假阴性 .
3 具体来说,awk 'NR%990==0' /usr/share/dict/american-english > words。
4 例如,在计算的历史上,你的东西strcmp 被调用了多少次?如果再快 1 ns,会节省多少时间?
5 这在某种程度上将 CPU 时间与工程时间等同起来,这可能相差 1000 倍以上:亚马逊 AWS 每小时收取 0.02 美元的 CPU 时间费用,而且是一位优秀的工程师可以期望每小时 50 美元(在第一世界)。因此(非常粗略!)度量工程时间比 CPU 时间更有价值 2500 倍。因此,也许您需要为 10 小时的工作打上千万次电话才能获得回报……