Skip to content

Commit 5e4684b

Browse files
committed
perf(sdk): add optional rapidhash for ValueMap HashMaps in metrics hot path
Replace the default SipHash-1-3 hasher with rapidhash (wyhash successor) behind an opt-in `metrics-use-rapidhash` feature flag for the HashMap used in ValueMap::trackers. SipHash's HashDoS resistance is unnecessary here since ValueMap is pub(crate) and keys are not attacker-controlled. rapidhash was selected after benchmarking four hashers (ahash, foldhash, rapidhash, std SipHash) on actual Vec<KeyValue> keys matching the ValueMap workload (1600 time series, 2-8 attributes per entry, mixed read/write). Benchmark results (Apple Silicon, aarch64-apple-darwin): | Benchmark (4 attrs) | ahash | foldhash | rapidhash | std SipHash | |--------------------------|---------|----------|-----------|-------------| | hash_only | 31.5 µs | 30.7 µs | 29.9 µs | 60.0 µs | | lookup_hit | 81.2 µs | 71.0 µs | 70.1 µs | 104.8 µs | | lookup_miss | 2.24 µs | 2.46 µs | 2.13 µs | 4.10 µs | | insert | 207 µs | 198 µs | 196 µs | 235 µs | | mixed_rw (ValueMap path) | 170 µs | 155 µs | 153 µs | 218 µs | Key findings: - rapidhash is 10% faster than ahash on the mixed read/write path that most closely models ValueMap::measure() - foldhash (now hashbrown's default hasher) is 8% faster than ahash - ahash has known unresolved performance regressions on ARM (#194, ~40% regression on M1 from 0.8.6->0.8.7) and AMD (#190, 73-151% regression with target-cpu=native) - ahash has had no throughput-focused optimization work merged in 2024-2025; three VAES PRs (#144, #186, #187) have been stalled for 2+ years - foldhash replaced ahash as the default hasher in hashbrown (PR #563) - rapidhash is the official successor to wyhash, adopted by Chromium and Microsoft Terminal The feature is opt-in to avoid adding a mandatory dependency. When `metrics-use-rapidhash` is not enabled, the standard library HashMap (SipHash) is used. Also adds a hasher_comparison benchmark for reproducing these results. Refs: #3371
1 parent dba1820 commit 5e4684b

3 files changed

Lines changed: 315 additions & 3 deletions

File tree

opentelemetry-sdk/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ autobenches = false
1212

1313
[dependencies]
1414
opentelemetry = { workspace = true }
15+
rapidhash = { version = "4", optional = true }
1516
opentelemetry-http = { workspace = true, optional = true }
1617
futures-channel = { workspace = true }
1718
futures-executor = { workspace = true }
@@ -34,7 +35,10 @@ all-features = true
3435
rustdoc-args = ["--cfg", "docsrs"]
3536

3637
[dev-dependencies]
38+
ahash = "0.8"
3739
criterion = { workspace = true, features = ["html_reports"] }
40+
foldhash = "0.2.0"
41+
rapidhash = "4"
3842
rstest = { workspace = true }
3943
temp-env = { workspace = true }
4044

@@ -59,6 +63,7 @@ experimental_logs_batch_log_processor_with_async_runtime = ["logs", "experimenta
5963
experimental_logs_concurrent_log_processor = ["logs"]
6064
experimental_trace_batch_span_processor_with_async_runtime = ["tokio/sync", "trace", "experimental_async_runtime"]
6165
experimental_metrics_disable_name_validation = ["metrics"]
66+
metrics-use-rapidhash = ["metrics", "rapidhash"]
6267
bench_profiling = []
6368

6469
[[bench]]
@@ -124,6 +129,11 @@ name = "log"
124129
harness = false
125130
required-features = ["logs"]
126131

132+
[[bench]]
133+
name = "hasher_comparison"
134+
harness = false
135+
required-features = ["metrics"]
136+
127137
[lib]
128138
bench = false
129139

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
//! Benchmark comparing ahash, foldhash, and rapidhash for ValueMap's HashMap.
2+
//!
3+
//! The key type is `Vec<KeyValue>`, which is how metrics attributes are stored.
4+
//! We test lookup (hit/miss) and insert across different attribute set sizes.
5+
6+
use criterion::{criterion_group, criterion_main, Criterion};
7+
use opentelemetry::KeyValue;
8+
use std::collections::HashMap;
9+
use std::hash::{BuildHasher, Hash};
10+
use std::time::Duration;
11+
12+
// --- Hasher setup ---
13+
14+
type AHashMap<K, V> = HashMap<K, V, ahash::RandomState>;
15+
type FoldHashMap<K, V> = HashMap<K, V, foldhash::fast::RandomState>;
16+
type RapidHashMap<K, V> = HashMap<K, V, rapidhash::fast::RandomState>;
17+
type StdHashMap<K, V> = HashMap<K, V>;
18+
19+
fn new_ahash_map<K: Hash + Eq, V>(cap: usize) -> AHashMap<K, V> {
20+
HashMap::with_capacity_and_hasher(cap, ahash::RandomState::new())
21+
}
22+
23+
fn new_foldhash_map<K: Hash + Eq, V>(cap: usize) -> FoldHashMap<K, V> {
24+
HashMap::with_capacity_and_hasher(cap, foldhash::fast::RandomState::default())
25+
}
26+
27+
fn new_rapidhash_map<K: Hash + Eq, V>(cap: usize) -> RapidHashMap<K, V> {
28+
HashMap::with_capacity_and_hasher(cap, rapidhash::fast::RandomState::default())
29+
}
30+
31+
fn new_std_map<K: Hash + Eq, V>(cap: usize) -> StdHashMap<K, V> {
32+
HashMap::with_capacity(cap)
33+
}
34+
35+
// --- Test data generation ---
36+
37+
static ATTRIBUTE_VALUES: [&str; 10] = [
38+
"value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8", "value9",
39+
"value10",
40+
];
41+
42+
/// Generate a set of KeyValue attribute vectors of a given size.
43+
fn generate_attribute_sets(num_attrs: usize, num_sets: usize) -> Vec<Vec<KeyValue>> {
44+
let mut sets = Vec::with_capacity(num_sets);
45+
for i in 0..num_sets {
46+
let mut attrs = Vec::with_capacity(num_attrs);
47+
for a in 0..num_attrs {
48+
let val_idx = (i + a) % ATTRIBUTE_VALUES.len();
49+
attrs.push(KeyValue::new(
50+
format!("attribute{}", a + 1),
51+
ATTRIBUTE_VALUES[val_idx],
52+
));
53+
}
54+
sets.push(attrs);
55+
}
56+
sets
57+
}
58+
59+
// --- Generic benchmark functions ---
60+
61+
fn bench_lookup_hit<S: BuildHasher + Default>(
62+
map: &HashMap<Vec<KeyValue>, u64, S>,
63+
keys: &[Vec<KeyValue>],
64+
) {
65+
for key in keys {
66+
std::hint::black_box(map.get(key));
67+
}
68+
}
69+
70+
fn bench_lookup_miss<S: BuildHasher + Default>(
71+
map: &HashMap<Vec<KeyValue>, u64, S>,
72+
miss_keys: &[Vec<KeyValue>],
73+
) {
74+
for key in miss_keys {
75+
std::hint::black_box(map.get(key));
76+
}
77+
}
78+
79+
fn bench_insert<S: BuildHasher + Clone>(hasher: &S, keys: &[Vec<KeyValue>], cap: usize) {
80+
let mut map: HashMap<Vec<KeyValue>, u64, S> =
81+
HashMap::with_capacity_and_hasher(cap, hasher.clone());
82+
for key in keys {
83+
map.insert(key.clone(), 1);
84+
}
85+
std::hint::black_box(&map);
86+
}
87+
88+
fn bench_mixed_read_write<S: BuildHasher + Clone>(
89+
hasher: &S,
90+
keys: &[Vec<KeyValue>],
91+
cap: usize,
92+
) {
93+
// Simulates the ValueMap hot path: read-lock lookup, then occasional write-lock insert
94+
let mut map: HashMap<Vec<KeyValue>, u64, S> =
95+
HashMap::with_capacity_and_hasher(cap, hasher.clone());
96+
97+
for (i, key) in keys.iter().enumerate() {
98+
// 80% lookups, 20% inserts (simulating warm cache)
99+
if i % 5 == 0 || !map.contains_key(key) {
100+
map.insert(key.clone(), 1);
101+
} else {
102+
if let Some(v) = map.get(key) {
103+
std::hint::black_box(v);
104+
}
105+
}
106+
}
107+
std::hint::black_box(&map);
108+
}
109+
110+
// --- Hash-only benchmark (pure hashing speed, no HashMap overhead) ---
111+
112+
fn bench_hash_only<S: BuildHasher>(hasher: &S, keys: &[Vec<KeyValue>]) {
113+
use std::hash::Hasher;
114+
for key in keys {
115+
let mut h = hasher.build_hasher();
116+
key.hash(&mut h);
117+
std::hint::black_box(h.finish());
118+
}
119+
}
120+
121+
// --- Criterion benchmarks ---
122+
123+
fn hasher_comparison(c: &mut Criterion) {
124+
let attr_counts = [2, 4, 8];
125+
let num_entries = 1600; // Matches the metrics_counter benchmark cardinality
126+
127+
for &num_attrs in &attr_counts {
128+
let keys = generate_attribute_sets(num_attrs, num_entries);
129+
let miss_keys = generate_attribute_sets(num_attrs, 100)
130+
.into_iter()
131+
.map(|mut kv| {
132+
// Modify to guarantee misses
133+
if let Some(last) = kv.last_mut() {
134+
*last = KeyValue::new(last.key.clone(), "MISS_VALUE_NEVER_INSERTED");
135+
}
136+
kv
137+
})
138+
.collect::<Vec<_>>();
139+
140+
let group_name = format!("{num_attrs}_attrs");
141+
142+
// --- Hash-only (pure hashing, no HashMap) ---
143+
{
144+
let mut group = c.benchmark_group(format!("hash_only/{group_name}"));
145+
group.warm_up_time(Duration::from_secs(1));
146+
group.measurement_time(Duration::from_secs(3));
147+
148+
let ahash_state = ahash::RandomState::new();
149+
let fold_state = foldhash::fast::RandomState::default();
150+
let rapid_state = rapidhash::fast::RandomState::default();
151+
let std_state = std::hash::RandomState::new();
152+
153+
group.bench_function("ahash", |b| b.iter(|| bench_hash_only(&ahash_state, &keys)));
154+
group.bench_function("foldhash", |b| {
155+
b.iter(|| bench_hash_only(&fold_state, &keys))
156+
});
157+
group.bench_function("rapidhash", |b| {
158+
b.iter(|| bench_hash_only(&rapid_state, &keys))
159+
});
160+
group.bench_function("std", |b| b.iter(|| bench_hash_only(&std_state, &keys)));
161+
group.finish();
162+
}
163+
164+
// --- Lookup hit ---
165+
{
166+
let mut group = c.benchmark_group(format!("lookup_hit/{group_name}"));
167+
group.warm_up_time(Duration::from_secs(1));
168+
group.measurement_time(Duration::from_secs(3));
169+
170+
let mut ahash_map = new_ahash_map(num_entries);
171+
let mut fold_map = new_foldhash_map(num_entries);
172+
let mut rapid_map = new_rapidhash_map(num_entries);
173+
let mut std_map = new_std_map(num_entries);
174+
for key in &keys {
175+
ahash_map.insert(key.clone(), 1);
176+
fold_map.insert(key.clone(), 1);
177+
rapid_map.insert(key.clone(), 1);
178+
std_map.insert(key.clone(), 1);
179+
}
180+
181+
group.bench_function("ahash", |b| b.iter(|| bench_lookup_hit(&ahash_map, &keys)));
182+
group.bench_function("foldhash", |b| {
183+
b.iter(|| bench_lookup_hit(&fold_map, &keys))
184+
});
185+
group.bench_function("rapidhash", |b| {
186+
b.iter(|| bench_lookup_hit(&rapid_map, &keys))
187+
});
188+
group.bench_function("std", |b| b.iter(|| bench_lookup_hit(&std_map, &keys)));
189+
group.finish();
190+
}
191+
192+
// --- Lookup miss ---
193+
{
194+
let mut group = c.benchmark_group(format!("lookup_miss/{group_name}"));
195+
group.warm_up_time(Duration::from_secs(1));
196+
group.measurement_time(Duration::from_secs(3));
197+
198+
let mut ahash_map = new_ahash_map(num_entries);
199+
let mut fold_map = new_foldhash_map(num_entries);
200+
let mut rapid_map = new_rapidhash_map(num_entries);
201+
let mut std_map = new_std_map(num_entries);
202+
for key in &keys {
203+
ahash_map.insert(key.clone(), 1);
204+
fold_map.insert(key.clone(), 1);
205+
rapid_map.insert(key.clone(), 1);
206+
std_map.insert(key.clone(), 1);
207+
}
208+
209+
group.bench_function("ahash", |b| {
210+
b.iter(|| bench_lookup_miss(&ahash_map, &miss_keys))
211+
});
212+
group.bench_function("foldhash", |b| {
213+
b.iter(|| bench_lookup_miss(&fold_map, &miss_keys))
214+
});
215+
group.bench_function("rapidhash", |b| {
216+
b.iter(|| bench_lookup_miss(&rapid_map, &miss_keys))
217+
});
218+
group.bench_function("std", |b| {
219+
b.iter(|| bench_lookup_miss(&std_map, &miss_keys))
220+
});
221+
group.finish();
222+
}
223+
224+
// --- Insert ---
225+
{
226+
let mut group = c.benchmark_group(format!("insert/{group_name}"));
227+
group.warm_up_time(Duration::from_secs(1));
228+
group.measurement_time(Duration::from_secs(3));
229+
230+
let ahash_state = ahash::RandomState::new();
231+
let fold_state = foldhash::fast::RandomState::default();
232+
let rapid_state = rapidhash::fast::RandomState::default();
233+
let std_state = std::hash::RandomState::new();
234+
235+
group.bench_function("ahash", |b| {
236+
b.iter(|| bench_insert(&ahash_state, &keys, num_entries))
237+
});
238+
group.bench_function("foldhash", |b| {
239+
b.iter(|| bench_insert(&fold_state, &keys, num_entries))
240+
});
241+
group.bench_function("rapidhash", |b| {
242+
b.iter(|| bench_insert(&rapid_state, &keys, num_entries))
243+
});
244+
group.bench_function("std", |b| {
245+
b.iter(|| bench_insert(&std_state, &keys, num_entries))
246+
});
247+
group.finish();
248+
}
249+
250+
// --- Mixed read/write (simulates ValueMap hot path) ---
251+
{
252+
let mut group = c.benchmark_group(format!("mixed_rw/{group_name}"));
253+
group.warm_up_time(Duration::from_secs(1));
254+
group.measurement_time(Duration::from_secs(3));
255+
256+
let ahash_state = ahash::RandomState::new();
257+
let fold_state = foldhash::fast::RandomState::default();
258+
let rapid_state = rapidhash::fast::RandomState::default();
259+
let std_state = std::hash::RandomState::new();
260+
261+
group.bench_function("ahash", |b| {
262+
b.iter(|| bench_mixed_read_write(&ahash_state, &keys, num_entries))
263+
});
264+
group.bench_function("foldhash", |b| {
265+
b.iter(|| bench_mixed_read_write(&fold_state, &keys, num_entries))
266+
});
267+
group.bench_function("rapidhash", |b| {
268+
b.iter(|| bench_mixed_read_write(&rapid_state, &keys, num_entries))
269+
});
270+
group.bench_function("std", |b| {
271+
b.iter(|| bench_mixed_read_write(&std_state, &keys, num_entries))
272+
});
273+
group.finish();
274+
}
275+
}
276+
}
277+
278+
criterion_group! {
279+
name = benches;
280+
config = Criterion::default()
281+
.warm_up_time(Duration::from_secs(1))
282+
.measurement_time(Duration::from_secs(3));
283+
targets = hasher_comparison
284+
}
285+
criterion_main!(benches);

opentelemetry-sdk/src/metrics/internal/mod.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use core::fmt;
99
#[cfg(not(target_has_atomic = "64"))]
1010
use portable_atomic::{AtomicI64, AtomicU64};
1111
use std::cmp::min;
12-
use std::collections::{HashMap, HashSet};
12+
use std::collections::HashSet;
1313
use std::mem::swap;
1414
use std::ops::{Add, AddAssign, DerefMut, Sub};
1515
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
@@ -24,6 +24,23 @@ use opentelemetry::{otel_warn, KeyValue};
2424
use super::data::{AggregatedMetrics, MetricData};
2525
use super::pipeline::DEFAULT_CARDINALITY_LIMIT;
2626

27+
#[cfg(feature = "metrics-use-rapidhash")]
28+
type HashMap<K, V> = std::collections::HashMap<K, V, rapidhash::fast::RandomState>;
29+
#[cfg(not(feature = "metrics-use-rapidhash"))]
30+
type HashMap<K, V> = std::collections::HashMap<K, V>;
31+
32+
#[cfg(feature = "metrics-use-rapidhash")]
33+
fn new_hashmap<K, V>(capacity: usize) -> HashMap<K, V> {
34+
std::collections::HashMap::with_capacity_and_hasher(
35+
capacity,
36+
rapidhash::fast::RandomState::default(),
37+
)
38+
}
39+
#[cfg(not(feature = "metrics-use-rapidhash"))]
40+
fn new_hashmap<K, V>(capacity: usize) -> HashMap<K, V> {
41+
std::collections::HashMap::with_capacity(capacity)
42+
}
43+
2744
// TODO Replace it with LazyLock once it is stable
2845
pub(crate) static STREAM_OVERFLOW_ATTRIBUTES: OnceLock<Vec<KeyValue>> = OnceLock::new();
2946

@@ -85,7 +102,7 @@ where
85102
{
86103
fn new(config: A::InitConfig, cardinality_limit: usize) -> Self {
87104
ValueMap {
88-
trackers: RwLock::new(HashMap::with_capacity(
105+
trackers: RwLock::new(new_hashmap(
89106
1 + min(DEFAULT_CARDINALITY_LIMIT, cardinality_limit),
90107
)),
91108
trackers_for_collect: OnceLock::new(),
@@ -100,7 +117,7 @@ where
100117
#[inline]
101118
fn trackers_for_collect(&self) -> &RwLock<HashMap<Vec<KeyValue>, Arc<A>>> {
102119
self.trackers_for_collect.get_or_init(|| {
103-
RwLock::new(HashMap::with_capacity(
120+
RwLock::new(new_hashmap(
104121
1 + min(DEFAULT_CARDINALITY_LIMIT, self.cardinality_limit),
105122
))
106123
})

0 commit comments

Comments
 (0)