我为了统计一次,竟然对每个单词进行了两次哈希

上周我为一个日志解析器做了性能分析。它用于统计每日日志中的错误代码。其核心逻辑是一个简单的字典更新操作。

大多数开发者会这样写:

if (counts.TryGetValue(code, out int c))
    counts[code] = c + 1;
else
    counts[code] = 1;

这段代码对键进行了两次哈希。TryGetValue 通过计算哈希值并遍历桶(bucket)来查找条目。随后,索引器为了更新值,又重复执行了完全相同的操作。对于每个 token,你都在同一个键和同一个哈希值上浪费了两倍的 CPU 周期。

你可以跳过第二次查找。使用 CollectionsMarshal.GetValueRefOrAddDefault

ref int slot = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, code, out _);
slot++;

该方法通过一步操作即可完成查找或创建位置的过程。它直接向你提供指向存储位置的引用。你只需进行一次哈希和一次桶遍历,然后即可原地修改该值。

我用 500 万个 token 进行了测试。

测试结果: • TryGetValue + 索引器:160.0 ms • GetValueRefOrAddDefault:95.0 ms

单次查找版本的速度快了 1.7 倍。

至关重要的一点是,内存分配保持不变。这个技巧并不能节省内存,它只能节省 CPU。如果你的代码是因为垃圾回收(GC)而变慢,那么这个改动毫无作用;但如果你的代码是因为密集的计数操作而变慢,那么它会有所帮助。

当你的循环对现有键进行大量更新时,请使用这种方法。更新操作与插入操作的比例越高,收益就越大。

注意:该引用指向字典的内部存储。它仅在下一次结构性变化发生前保持有效。如果你要添加或删除键,请不要持有该引用。获取引用,修改值,然后继续执行后续操作即可。

对于大多数任务,请使用 TryGetValue,因为它更易读。只有当你的字典循环成为性能瓶颈时,才使用引用版本。

Source: https://dev.to/ssukhpinder/i-hashed-every-word-twice-to-count-it-once-1mg9