自定义NoOpHasher单独执行插入/删除时性能更优,但组合执行删除+插入操作时性能骤降的问题求助
自定义NoOpHasher单独执行插入/删除时性能更优,但组合执行删除+插入操作时性能骤降的问题求助
大家好,我最近在做针对<u8, BoxedFnMut>类型的HashMap性能基准测试,其中BoxedFnMut定义为type BoxedFnMut = Box<dyn FnMut() + Send + 'static>。测试工具用的是divan 0.1.21,Rust版本是nightly 1.90.0。
我的核心思路是:因为Key是固定的u8类型,我想完全跳过哈希计算的开销,直接用u8的值作为哈希值,所以实现了一个极简的NoOpHasher,只针对u8/usize类型做写入处理,其他写入方法会直接panic(毕竟只给u8的Key用)。
我的NoOpHasher实现
type BoxedFnMut = Box<dyn FnMut() + Send + 'static>; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct NoOpHasher(pub usize); impl std::hash::Hasher for NoOpHasher { #[inline(always)] fn finish(&self) -> u64 { self.0 as u64 } fn write(&mut self, _: &[u8]) { panic!( "NoOpHasher::write should not be called: this is a no-op hasher custom built for u8 only" ) } #[inline(always)] fn write_u8(&mut self, i: u8) { unsafe { std::ptr::write_volatile(&mut self.0, i as usize) }; } #[inline(always)] fn write_usize(&mut self, i: usize) { unsafe { std::ptr::write_volatile(&mut self.0, i) }; } } impl std::hash::BuildHasher for NoOpHasher { type Hasher = NoOpHasher; fn build_hasher(&self) -> Self::Hasher { NoOpHasher(0) } }
基准测试代码结构
我把测试分成了三个独立的bench组:
- insert组:批量插入预生成的
<u8, BoxedFnMut>数据 - remove组:批量删除预插入的所有Key
- update组:对每个Key执行「先删除再插入」的组合操作
核心测试代码如下(已补全必要依赖逻辑):
use divan::Bencher; use divan::black_box; use std::collections::HashMap as StdHashMap; use rand::Rng; type BoxedFnMut = Box<dyn FnMut() + Send + 'static>; fn main() { divan::main() } #[inline(always)] fn generate_fn() -> BoxedFnMut { Box::new(|| {}) } #[inline(always)] fn prepare_data() -> Vec<(u8, BoxedFnMut)> { let mut rng = rand::thread_rng(); let mut data: Vec<(u8, BoxedFnMut)> = (0u8..255).map(|i| (i, generate_fn())).collect(); data.shuffle(&mut rng); data } #[divan::bench_group] mod insert { use super::*; #[divan::bench] fn insert_std_hashmap(bencher: Bencher) { let mut map = StdHashMap::with_capacity(255); let data = prepare_data(); bencher.bench_local(|| { for (key, value) in &data { black_box(map.insert(key, value)); } }); } #[divan::bench] fn insert_noophasher_hashmap(bencher: Bencher) { let mut map = StdHashMap::with_capacity_and_hasher(255, NoOpHasher::default()); let data = prepare_data(); bencher.bench_local(|| { for (key, value) in &data { black_box(map.insert(key, value)); } }); } } #[divan::bench_group] mod remove { use super::*; #[divan::bench] fn remove_std_hashmap(bencher: Bencher) { let mut map = StdHashMap::with_capacity(255); for (key, value) in prepare_data() { black_box(map.insert(key, value)); } let mut range = (0u8..255).collect::<Vec<_>>(); range.shuffle(&mut rand::thread_rng()); bencher.bench_local(|| { for key in &range { black_box(map.remove(&key)); } }); } #[divan::bench] fn remove_noophasher_hashmap(bencher: Bencher) { let mut map = StdHashMap::with_capacity_and_hasher(255, NoOpHasher::default()); for (key, value) in prepare_data() { black_box(map.insert(key, value)); } let mut range = (0u8..255).collect::<Vec<_>>(); range.shuffle(&mut rand::thread_rng()); bencher.bench_local(|| { for key in &range { black_box(map.remove(&key)); } }); } } #[divan::bench_group] mod update { use super::*; #[divan::bench] fn update_std_hashmap(bencher: Bencher) { let mut map = StdHashMap::with_capacity(255); let data = prepare_data(); for (key, value) in &data { black_box(map.insert(key, value)); } let range = prepare_data(); bencher.bench_local(|| { for (key, value) in &range { black_box({ map.remove(key); map.insert(key, value) }); } }); } #[divan::bench] fn update_noophasher_hashmap(bencher: Bencher) { let mut map = StdHashMap::with_capacity_and_hasher(255, NoOpHasher::default()); let data = prepare_data(); for (key, value) in &data { black_box(map.insert(key, value)); } let range = prepare_data(); bencher.bench_local(|| { for (key, value) in &range { black_box({ map.remove(key); map.insert(key, value) }); } }); } }
奇怪的性能现象
- 单独测试
insert和remove时,用NoOpHasher的HashMap比标准库默认实现快非常多,完全符合预期(毕竟跳过了哈希计算的开销) - 但测试
update(先删除再插入同一个Key)时,NoOpHasher版本的性能暴跌,完全达不到「remove耗时 + insert耗时」的预期,和标准库版本的性能差距也大幅缩小甚至反超
我已经尝试过的排查步骤
- 确认HashMap预分配了刚好足够的容量(255,和Key总数一致),排除扩容带来的额外开销
- 用
perf stat分析性能,没发现两个版本在指令数、缓存命中这类指标上有明显差异 - 生成
cargo flamegraph,但结果里divan框架的调用占比过高,看不到HashMap内部操作的细节 - 替换成
hashbrown::HashMap,insert和remove的性能确实提升了,但update的问题依然存在 - 测试hashbrown的默认哈希器,发现它的update性能比标准库快2倍,这说明问题大概率出在我的NoOpHasher实现上,尤其是和remove操作的交互逻辑
- 尝试换成FxHashMap,结果类似;给所有相关函数加上
#[inline(always)]也没有改善
现在我实在摸不着头脑,有没有大佬能帮我分析下:为什么单独的remove和insert都快,但组合起来就性能暴跌?我的NoOpHasher哪里写得有问题吗?或者HashMap内部在处理这种自定义Hasher时,remove和insert的组合会触发什么额外开销?
内容来源于stack exchange
相关产品推荐
相关产品推荐

