feat(sdk): support bring-your-own hasher for metrics via BuildHasher - #3388
feat(sdk): support bring-your-own hasher for metrics via BuildHasher#3388bryantbiggs wants to merge 1 commit into
Conversation
159dab5 to
f80ee5d
Compare
We don't use
A lot of users provide keys and values from incoming request etc, so we should be safe by default. If a user explicitly opts-in to |
cijothomas
left a comment
There was a problem hiding this comment.
Thanks for striving to improve metrics perf. As noted in my comments, I am okay with this change if we feature gate it, so users are explicitly opting into this.
@utpilla might have considered/tried this before, but we didn't quite end up adding it - Would want to get his thoughts too.
f80ee5d to
5e4684b
Compare
92730a0 to
916519b
Compare
|
taking a deeper look - |
916519b to
d58b454
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3388 +/- ##
======================================
Coverage 82.9% 82.9%
======================================
Files 130 130
Lines 27350 27498 +148
======================================
+ Hits 22675 22819 +144
- Misses 4675 4679 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
That's not entirely true. While That said, I do acknowledge that most deployments are probably low-risk, dimension values typically come from controlled sources, and our cardinality capping provides an additional layer of protection. I'm supportive of giving users a performance escape hatch, I just want us to be thoughtful about how we expose it. On the implementation approach: my concern with a crate-specific feature flag is maintenance. If we add A cleaner approach would be to make the internal storage generic over If we do decide to go with a feature flag approach in the meantime, I'd strongly prefer the feature name include an |
Yes, I can take a look and propose something. However, it does feel like a piece of functionality that will be commonly unknown to users. If most users don't know they can improve performance by bringing a different hash algorithm in their implementation then I would argue it's a change that is not valuable. |
|
I dug into how every other major OTel SDK handles hashing for this exact same data structure — the per-instrument map that looks up attribute sets to aggregation buckets on every
Key takeaways:
On the On naming — I'd push back on the |
|
@bryantbiggs @utpilla "unsafe" prefix feels a bit aggressive and can incorrectly imply (Btw, I don't think we can just follow what Go or C++ is doing, or go easy just because no documented exploit has occurred. As a foundation library, we need to be safe-by-default; if a particular user knows their scenario won't result in an exploitation, then can opt-in to things.) |
|
Thinking out loud, could this be done via an API and an external crate / user code? |
Yes - that is what @utpilla was describing #3388 (comment) |
whoops - accidental alignment! |
4bd7153 to
7ce5e38
Compare
16a49ea to
5d181a0
Compare
|
@bryantbiggs Do you have bandwidth to check how easy it is to support bring-your-own-hash to the Metric sdk? If it is too much changes, I am okay with the feature-flag option for now. There is a risk of more users asking for different hash approaches, but lets tackle it when we actually reach there. |
5d181a0 to
bba3af4
Compare
bba3af4 to
c78a4d6
Compare
|
@utpilla @cijothomas @scottgerring — thanks for the thoughtful feedback here. I've reworked the PR to follow your suggestion: instead of a per-hasher feature flag, it now supports bring-your-own-hasher.
I also prototyped the explicit-generic variant ( Updated design rationale and a benchmark (~29% faster counter hot path with |
| // On the measurement hot path the SDK looks up an aggregation bucket for | ||
| // each attribute set in an internal hash map. By default this uses the | ||
| // standard library's SipHash, which is resistant to hash-flooding (HashDoS) | ||
| // attacks. For high-cardinality workloads whose attribute values come from |
There was a problem hiding this comment.
does it matter how high the cardinality is? Hashing cost if still paid irrespective of whether attributes has high cardinality or not.
There was a problem hiding this comment.
You're right — the hash is computed on every measurement regardless of cardinality, so tying the benefit to "high-cardinality" was misleading. Reworded in 070956d to frame it as a per-measurement cost: a faster hasher helps recording throughput on any hot instrument, independent of how many distinct attribute sets exist.
| //! Run with: | ||
| //! ```text | ||
| //! cargo bench --bench metrics_hasher --features metrics,experimental_metrics_custom_reader | ||
| //! ``` |
There was a problem hiding this comment.
nit: can you share the numbers as a comment here for quick reference. (similar to how we do for most other benchmarks)
There was a problem hiding this comment.
Added in 070956d. With the single-attribute-set bench (see below), median Counter::add is ~153 ns with the default SipHash vs ~108 ns with foldhash (~30% faster) on a Threadripper 1900X.
| /// internal trackers map of every aggregation this builder produces. It | ||
| /// defaults to [`RandomState`] (SipHash-1-3), preserving the DoS-resistant | ||
| /// default; users can opt into a faster hasher via the meter provider builder. | ||
| pub(crate) struct AggregateBuilder<T, S = RandomState> { |
There was a problem hiding this comment.
/// Defaults to RandomState (SipHash-1-3), which is HashDoS-resistant.
/// Use [MeterProviderBuilder::with_hasher] to install a faster hasher.
^ lets use comments like this in code docs throughout. Else this reads as PR-motivation than code documentation. (they can still be in changlog/ PR desc)
There was a problem hiding this comment.
Agreed. Trimmed the AggregateBuilder doc to the terse code-doc style you suggested in 070956d.
| std::mem::take(&mut *trackers) | ||
| // Replace with a fresh empty map that reuses the same hash builder, | ||
| // cloned from the current map. Using `mem::replace` (rather than | ||
| // `mem::take`) avoids requiring `S: Default` and preserves the |
There was a problem hiding this comment.
Just realized that we had a gap in this area - we used to take leaving a default HashMap in place which didn't start with the required capacity... That is an existing gap. We could fix that while we are here, or leave it for another short follow up.
There was a problem hiding this comment.
Its simple enough - lets fix that in this PR itself.
There was a problem hiding this comment.
Fixed in 070956d: drain_and_reset now replaces the trackers with HashMap::with_capacity_and_hasher(1 + cardinality_limit, …) instead of a zero-capacity map, so the next collection cycle refills without reallocating. Added a regression test (drain_and_reset_preallocates_tracker_capacity) that I confirmed fails on the old with_hasher version (capacity 0).
| }) | ||
| }, | ||
| |rands| { | ||
| counter.add( |
There was a problem hiding this comment.
for demonstrating the gain of improved hashing, lets use a single attribute-set and remove the random etc, to get a pure feel of the gains by using faster hash.
There was a problem hiding this comment.
Done in 070956d. Dropped the RNG and the 1600-series spread; the bench now records one fixed attribute set repeatedly, so the delta is purely the hasher cost on a steady-state lookup. That's where the ~153 ns vs ~108 ns numbers above come from.
| shutdown_invoked: AtomicBool, | ||
| /// The per-numeric-type aggregate-function builders, with the chosen hasher | ||
| /// already erased in. Threaded into every meter this provider creates. | ||
| builders: AggregateFnsBuilders, |
There was a problem hiding this comment.
we use the same seed for every instrument inside this provider right? Should we keep the existing behavior of generating fresh seed for each value map (i.e each instrument?)
There was a problem hiding this comment.
Good catch — fixed in 070956d to preserve the per-instrument behavior for the default. The builder now stores a hasher factory (Arc<dyn Fn() -> S>) instead of one stored instance: the default uses RandomState::new, so each instrument's ValueMap gets a freshly-seeded RandomState (matching std's per-HashMap seeding and the prior behavior), while with_hasher(h) clones the user's instance so a configured/fixed-seed hasher is preserved across instruments. Added default_hasher_is_reseeded_per_instrument and custom_hasher_seed_is_preserved_across_instruments tests (the former I confirmed fails if the default falls back to a shared cloned seed).
For what it's worth on the threat model: a single per-provider seed wouldn't have been a HashDoS regression here (the seed is process-random and never exposed; std's per-map seeding is itself just a counter increment off a shared per-thread key, and it mainly guards against iteration-order leakage, which these internal maps don't expose). But restoring fresh-per-instrument for the default is essentially free and keeps behavior identical to main, so it's the cleaner choice.
cijothomas
left a comment
There was a problem hiding this comment.
This is awesome. Left few minor comments. Not yet marking as approved - will do another review once the comments are addressed.
Would also need another pair of eyes (anything touching metric aggregation is complex and benefits from multiple reviewers!)
Replace the proposed `metrics-use-foldhash` feature flag with support for a user-supplied `BuildHasher` on the metrics internal storage. The default remains the standard library's `RandomState` (SipHash-1-3), preserving the DoS-resistant default and existing behavior. Users with high-cardinality workloads and trusted attribute values can opt into a faster non-cryptographic hasher (e.g. foldhash, ahash) via the new `MeterProviderBuilder::with_hasher`, without adding any SDK feature flags or dependencies. The chosen hasher is monomorphized into the per-instrument `ValueMap` storage and then type-erased at the existing `Arc<dyn Measure>` boundary via a `dyn AggregateFnsBuilder` factory. As a result `SdkMeterProvider` and `MeterProviderBuilder` stay non-generic, `with_hasher` returns the same builder type, and the provider keeps working through the type-erased `dyn MeterProvider` path used by `opentelemetry::global` -- no public API generics, no change to existing code. A `metrics_hasher` benchmark compares the default hasher against foldhash (dev-dependency only) on the counter hot path.
5818885 to
070956d
Compare
Summary
Following the discussion below, this PR pivots from the original
metrics-use-foldhashfeature flag to a bring-your-own-hasher design, assuggested by @utpilla and @scottgerring.
The metrics hot path (
counter.add()/histogram.record()) looks up anaggregation bucket for each attribute set in an internal
HashMapinsideValueMap. That map is now parameterized over a user-chosen[
BuildHasher], defaulting to the standard library'sRandomState(SipHash-1-3). Users who want more throughput on high-cardinality workloads can
supply their own hasher via the new
MeterProviderBuilder::with_hasher:No new SDK feature flags, and no new SDK runtime dependencies — users bring
their own hasher crate (
foldhash,ahash, or anything implementingBuildHasher).Why this approach
This addresses the concerns raised in review:
RandomState(SipHash), which is HashDoS-resistant. The faster, non-resistant path is
strictly opt-in. Existing behavior is unchanged.
feature flag per hasher (each a new dependency to vet), users pick any hasher.
The
foldhashdependency, themetrics-use-foldhashfeature, and thedeny.tomlexception from the previous revision are gone.lives entirely in user code through
with_hasher.Design / public API
The chosen hasher is monomorphized into the per-instrument
ValueMapstorageand then type-erased at the existing
Arc<dyn Measure>boundary, via aninternal aggregate-function factory. As a result:
SdkMeterProviderandMeterProviderBuilderstay non-generic —with_hasherreturns the same builder type, and the provider keeps workingthrough the type-erased
dyn MeterProviderpath used byopentelemetry::global. The public API adds exactly one method and no typeparameters; code that does not call
with_hasheris entirely unaffected.ValueMapis concrete behind theexisting
Arc<dyn Measure>, so the only added indirection is a singledyndispatch at instrument-creation time (cold path), never onadd/record.stdtypes (BuildHasher,RandomState) appear in the public API, sono
allowed-external-types.tomlchange is required.Alternatives considered: exposing the hasher as a public generic type
parameter on
SdkMeterProvider<S = RandomState>(threaded through the meterprovider) was prototyped, but it leaks a viral, hard-to-reverse type parameter
into the SDK's most central public type for a niche, set-once performance knob.
The type-erased approach keeps that cost out of the public API.
Benchmark
benches/metrics_hasher.rscompares the counter hot path using the defaultSipHash against
foldhashinstalled viawith_hasher. Both providers areidentical except for the hash builder. 1600 time series, 4 attributes per
measurement.
addRandomState(SipHash-1-3, default)foldhashviawith_hasherAMD Ryzen Threadripper 1900X, x86_64, rustc 1.93.0, criterion 0.5. Single
representative run; absolute numbers are hardware-dependent.
Run with:
Refs: #3371