Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow precreation of AttributeSets for metrics #1421

Merged
merged 37 commits into from
Jan 24, 2024

Conversation

KallDrexx
Copy link
Contributor

@KallDrexx KallDrexx commented Dec 1, 2023

Fixes #1387
Design discussion issue (if applicable) #

Changes

This PR creates a backwards incompatible change to the public metric interfaces to allow pre-created and cached attribute sets to be passed into Counter, Histogram, and Observer APIs instead of references to KeyValue slices.

The first benefit of this change is that there is no more hidden string cloning/allocations occurring. The AttributeSet that is passed in now contains an Arc<T> to it's internal data. This allows each measurement used by the instrument to share its internal KeyValue pairs instead of cloning them for each instrument. Likewise, the caller of OpenTelemetry-rust can also pas the same attribute set into multiple instrument measurement calls without performing any cloning.

A second benefit is that if consumers have a stable set of KeyValue pairs for a set of counters, they can create an AttributeSet once and only pay the sorting + deduplication + hash calculation costs a single time instead of every measurement call.

This should increase performance when metrics are used with common attributes, while having minimal impact when using dynamic attributes. The one case where performance regression might be encountered are instruments that heavily utilize the AttributeSet::retain() function, as we now have to clone the underlying KeyValue pairs and create a new AttributeSet for the retained version now that AttributeSets are internally immutable.

This is a breaking API change, however it now requires a previous call of cntr.add(1, &[KeyValue::new("K", i)]); to be changed to cntr.add(1, [KeyValue::new("K", i)].into());. Specifically, a reference is no longer passed in and it has to be converted into an AttributeSet, which can be done with the .into() clause.

The AttributeSet type had to be raised to the root opentelemetry trait to be visible to all types.

Metrics Stress Tests

main:
Number of threads: 6
Throughput: 6,459,800 iterations/sec
Throughput: 6,487,000 iterations/sec
Throughput: 6,085,600 iterations/sec
Throughput: 6,018,600 iterations/sec
pr:
Number of threads: 6
Throughput: 7,571,600 iterations/sec
Throughput: 7,786,000 iterations/sec
Throughput: 7,757,400 iterations/sec
Throughput: 7,617,400 iterations/sec

Likewise, a new stress test was added to show throughput with premade attribute sets

Number of threads: 6
Throughput: 10,310,000 iterations/sec
Throughput: 10,317,000 iterations/sec
Throughput: 10,384,000 iterations/sec
Throughput: 10,378,800 iterations/sec
Throughput: 10,299,600 iterations/sec

Metric_Counter Benchmark

main:
Counter_Add_Sorted      time:   [727.79 ns 736.87 ns 747.75 ns]
Found 11 outliers among 100 measurements (11.00%)
  4 (4.00%) high mild
  7 (7.00%) high severe

Counter_Add_Unsorted    time:   [720.25 ns 727.28 ns 734.66 ns]
Found 7 outliers among 100 measurements (7.00%)
  3 (3.00%) high mild
  4 (4.00%) high severe
pr:
Counter_Add_Sorted      time:   [764.60 ns 779.08 ns 796.56 ns]
                        change: [-9.8051% -5.0974% -0.7162%] (p = 0.04 < 0.05)
                        Change within noise threshold.
Found 7 outliers among 100 measurements (7.00%)
  3 (3.00%) high mild
  4 (4.00%) high severe

Counter_Add_Unsorted    time:   [742.61 ns 752.64 ns 765.10 ns]
                        change: [+0.6259% +2.5097% +4.2566%] (p = 0.01 < 0.05)
                        Change within noise threshold.
Found 5 outliers among 100 measurements (5.00%)
  5 (5.00%) high severe

Counter_Add_Cached_Attributes
                        time:   [81.280 ns 81.979 ns 82.869 ns]
Found 7 outliers among 100 measurements (7.00%)
  2 (2.00%) high mild
  5 (5.00%) high severe

(note that the last benchmark is new)

Metrics Benchmark

main: metric.main.txt

pr: metric.pr.txt

Merge requirement checklist

  • CONTRIBUTING guidelines followed
  • Unit tests added/updated (if applicable)
  • Appropriate CHANGELOG.md files updated for non-trivial, user-facing changes
  • Changes in public API reviewed (if applicable)

@KallDrexx KallDrexx requested a review from a team December 1, 2023 21:04
Copy link

codecov bot commented Dec 1, 2023

Codecov Report

Attention: 67 lines in your changes are missing coverage. Please review.

Comparison is base (5b456dd) 61.3% compared to head (d2f7ad0) 63.2%.

❗ Current head d2f7ad0 differs from pull request most recent head ef330e1. Consider uploading reports for the commit ef330e1 to get more accurate results

Files Patch % Lines
opentelemetry/src/attributes/set.rs 87.6% 35 Missing ⚠️
stress/src/metrics_cached_attrs.rs 0.0% 10 Missing ⚠️
opentelemetry/src/metrics/noop.rs 0.0% 5 Missing ⚠️
opentelemetry/src/metrics/instruments/gauge.rs 0.0% 4 Missing ⚠️
opentelemetry-sdk/src/metrics/instrument.rs 80.0% 2 Missing ⚠️
opentelemetry-sdk/src/metrics/meter.rs 33.3% 2 Missing ⚠️
opentelemetry/src/metrics/instruments/counter.rs 50.0% 2 Missing ⚠️
...lemetry/src/metrics/instruments/up_down_counter.rs 50.0% 2 Missing ⚠️
...pentelemetry-sdk/src/metrics/internal/aggregate.rs 94.7% 1 Missing ⚠️
opentelemetry-sdk/src/resource/mod.rs 96.0% 1 Missing ⚠️
... and 3 more
Additional details and impacted files
@@           Coverage Diff           @@
##            main   #1421     +/-   ##
=======================================
+ Coverage   61.3%   63.2%   +1.8%     
=======================================
  Files        146     147      +1     
  Lines      19459   19664    +205     
=======================================
+ Hits       11938   12428    +490     
+ Misses      7521    7236    -285     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@@ -266,7 +269,6 @@ mod tests {
// "multi_thread" tokio flavor must be used else flush won't
// be able to make progress!
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[ignore = "Spatial aggregation is not yet implemented."]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is one more ignored test to be un-ignored now.


impl Ord for HashKeyValue {
fn cmp(&self, other: &Self) -> Ordering {
match self.0.key.cmp(&other.0.key) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this is not introduced in this PR, but I am not able to follow the logic behind this comparison.. Wont a simple "self.0.key.cmp(&other.0.key)" be sufficient here? The question of Key's being equal should not occur as we de-dedup before sorting...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KallDrexx Gentle reminder for this! Not a blocker for this PR.

@xnorpx
Copy link

xnorpx commented Dec 2, 2023

@KallDrexx can you do:

 pub fn add(&self, value: T, attributes: impl Into<AttributeSet>) {
        self.0.add(value, attributes.into())
 }

to avoid the api break?

@@ -253,25 +253,25 @@ pub(crate) struct ResolvedMeasures<T> {
}

impl<T: Copy + 'static> SyncCounter<T> for ResolvedMeasures<T> {
fn add(&self, val: T, attrs: &[KeyValue]) {
fn add(&self, val: T, attrs: AttributeSet) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on making adding a generic function here to replace AttributeSet

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the outer Counter accept the Into<AttributeSet> but left the inner Sync* types alone.

@hdost
Copy link
Contributor

hdost commented Dec 4, 2023

@KallDrexx can you do:

 pub fn add(&self, value: T, attributes: impl Into<AttributeSet>) {
        self.0.add(value, attributes.into())
 }

to avoid the api break?

I really think we should do this, and we can impl From<> if we haven't already, and I believe into() on self should be free as it will be detected by the compiler.

@KallDrexx
Copy link
Contributor Author

Sorry been a bit busy the last few days, will give this a try tomorrow.

@KallDrexx
Copy link
Contributor Author

The Into<T> suggestion works to get rid of .into() clauses, but making it a backwards compatible change is much more nuanced and depends what we mean.

Specifically, do we wish to retain being able to pass in a &[KeyValue]?

The problem is the current From trait I created to support non-reference attribute set creation was

impl<I: IntoIterator<Item = KeyValue>> From<I> for AttributeSet
where
    <I as IntoIterator>::IntoIter: DoubleEndedIterator + ExactSizeIterator,
{
    fn from(values: I) -> Self {
        AttributeSet(Arc::new(InternalAttributeSet::from(values)))
    }
}

This works well for [KeyValue: N] type of values, as well as a Vec<KeyValue> or any other type of type that implements IntoIterator<KeyValue>. However, &[KeyValue] does not implement IntoIterator<KeyValue> but instead implements IntoIterator<&KeyValue>.

But you can't have impl<I: IntoIterator<Item = KeyValue>> From<I> for AttributeSet while also having impl<'a, I: IntoIterator<Item = &'a KeyValue>> From<I> for AttributeSet, because the compiler sees them as conflicting implementations.

I can fix this by removing these implementations and go for more concrete implementations, such as

impl<const N: usize> From<&[KeyValue; N]> for AttributeSet {
    fn from(value: &[KeyValue; N]) -> Self {
        AttributeSet(Arc::new(InternalAttributeSet::from(value.iter().cloned())))
    }
}

impl<const N: usize> From<[KeyValue; N]> for AttributeSet {
    fn from(value: [KeyValue; N]) -> Self {
        AttributeSet(Arc::new(InternalAttributeSet::from(value.into_iter())))
    }
}

However, this means you can only pass in references or owned fixed size arrays to create an attribute set. You can't pass in a hashset<KeyValue>, a HashMap<Key, Value>, a Vec<KeyValue> or any other type that can be converted to an attribute set without us explicitely adding more From<T> implementations.

So the question becomes, do we want to support shared references and keep the API backward compatible, but enforce a limited set of iterator types that can be utilized, or make the API more broad?

@KallDrexx
Copy link
Contributor Author

Take away from SIG call:

  1. Keeping hidden cloning out of the library's creation of AttributeSets is important so consumers of the API can have better visibility into the costs of their calls. Since Key and Value types can contained owned strings, it's required that we clone them if we take a shared reference, and therefore we seem to want the API to move away from taking in a shared reference to a slice.
  2. Having the From trait utilize generics and allow for an expansive set of collection/iterator types is important for maintaining the semantic meaning of how you create an attribute set.

I'm still open for discussion on it, but with that I'm going to go with the first code block I posted (IntoIterator From<I> implementation) for the moment.

@hdost
Copy link
Contributor

hdost commented Dec 5, 2023

Take away from SIG call:

1. Keeping hidden cloning out of the library's creation of `AttributeSet`s is important so consumers of the API can have better visibility into the costs of their calls.  Since `Key` and `Value` types can contained owned strings, it's required that we clone them if we take a shared reference, and therefore we seem to want the API to move away from taking in a shared reference to a slice.

2. Having the `From` trait utilize generics and allow for an expansive set of collection/iterator types is important for maintaining the semantic meaning of how you create an attribute set.

I'm still open for discussion on it, but with that I'm going to go with the first code block I posted (IntoIterator From<I> implementation) for the moment.

Well unfortunately I missed the call because I was assuming we were going to meet at the later time of 9am 😓 I'll try to read over this.

@KallDrexx
Copy link
Contributor Author

KallDrexx commented Dec 5, 2023

Note that the impl Into<AttributeSet> is less trivial to do for observers. This is because observers are passed in dynamically via register_callback and thus there's no easy way to resolve the generic at compile time. For example:

    fn register_callback(
        &self,
        instruments: &[Arc<dyn Any>],
        callbacks: Box<MultiInstrumentCallback>,
    ) -> Result<Box<dyn CallbackRegistration>>;

type MultiInstrumentCallback = dyn Fn(&dyn Observer) + Send + Sync;

Since we are just passing in a function which can take in any observer, there's no way for it to resolve at compile time what type of generic we will use for Into<AttributeSet>. I could set a generic parameter on the Observer trait itself, but that doesn't seem ideal because that means each observer (and potentially instrument provider) can only support one type of attribute set conversion each.

So I'm going to leave that as taking an AttributeSet instead of using generics unless someone disagrees.

#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct AttributeSet(Arc<InternalAttributeSet>);

impl<'a, I: IntoIterator<Item = &'a KeyValue>> From<I> for AttributeSet
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little unfortunate to require this to always be references from a caller perspective. One alternative could be to make a new trait so this can accept both iterators over references and owned values? e.g.

trait ToKv {
    fn to_kv(self) -> KeyValue;
}

impl ToKv for KeyValue {
    fn to_kv(self) -> KeyValue {
        self
    }
}

impl ToKv for &'_ KeyValue {
    fn to_kv(self) -> KeyValue {
        self.clone()
    }
}

impl<'a, KV, I> From<I> for AttributeSet
where
    KV: ToKv,
    I: IntoIterator<Item = KV>,
    <I as IntoIterator>::IntoIter: DoubleEndedIterator + ExactSizeIterator,
{
    fn from(values: I) -> Self {
        AttributeSet(Arc::new(InternalAttributeSet::from(
            values.into_iter().map(ToKv::to_kv),
        )))
    }
}

That would allow for a greater variety of arguments

let _ = AttributeSet::from(vec![KeyValue::new("owned", true)]);
let _ = AttributeSet::from(&[KeyValue::new("owned", false)]);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good trick. Done.

In order to do this. I had to slightly re-arrange the Resource to AttributeSet conversion, since my previous From<&Resource> to AttributeSet implementation ended up causing an ambiguous reference. So that code was moved into the Resource struct itself (which probably makes more sense anyway).

@@ -1,3 +1,4 @@
use opentelemetry::attributes::AttributeSet;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nit, but the other non-signal-prefixed types are exported at the opentelemetry root, could be more consistent to expose this as opentelemetry::AttributeSet;?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved AttributeSet reference to opentelemetry root

Copy link
Member

@jtescher jtescher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me, could update the examples to remove the extra reference when constructing the attribute sets to clean them up, but not strictly necessary.

@@ -131,7 +132,7 @@ async fn main() -> Result<(), Box<dyn Error + Send + Sync + 'static>> {

span.add_event("Sub span event", vec![]);

histogram.record(1.3, &[]);
histogram.record(1.3, AttributeSet::default());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not in this PR, but just realized that this is somewhat awkward having to pass empty/default when no dimensions!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kind of an artifact of the original state of the PR being backward incompatible. In the current state of the code &[] now works again.

//! histogram.record(100, &[KeyValue::new("key", "value")]);
//! let attributes = AttributeSet::from(&[KeyValue::new("key", "value")]);
//!
//! counter.add(100, attributes.clone());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am not sure how common is it to have 2 instruments used with same attributes.. maybe better to stick with existing examples only.

(Also counter's functionality is already provided by histogram, so this example of showing both could be confusing! But that is unrelated to this PR)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this example uses a histogram, and histograms couldn't be updated to take a Into<AttributeSet> either, the precreated attribute set is required here for at least that one.

@@ -46,7 +47,8 @@ use std::sync::{Arc, Mutex};
/// // Create and record metrics using the MeterProvider
/// let meter = meter_provider.meter(std::borrow::Cow::Borrowed("example"));
/// let counter = meter.u64_counter("my_counter").init();
/// counter.add(1, &[KeyValue::new("key", "value")]);
/// let attributes = AttributeSet::from(&[KeyValue::new("key", "value")]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am not sure if we should change all examples to use AttributeSet.. maybe show it only where it gives some perf benefits.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These examples were changed prior to a backward compatible solution being found, thus I had to update all the doctests to use precreated attributes for CI to pass.

let attrs2 = vec![
counter.add(5.0, attrs.clone());
counter.add(10.3, attrs.clone());
counter.add(9.0, attrs.clone());
Copy link
Member

@lalitb lalitb Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - do we need to clone() here?

let attrs2 = vec![
counter.add(5.0, attrs.clone());
counter.add(10.3, attrs.clone());
counter.add(9.0, attrs.clone());
Copy link
Member

@lalitb lalitb Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - same as earlier, we can remove clone() here? Also few more places in the integration test.

@@ -96,7 +91,7 @@ impl<T: Number<T>> AggregateBuilder<T> {
let filter = self.filter.as_ref().map(Arc::clone);
move |n, mut attrs: AttributeSet| {
if let Some(filter) = &filter {
attrs.retain(filter.as_ref());
attrs = attrs.clone_with(filter.as_ref());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will clone every HashKeyValue that passes the filter. Unfortunately, I don't think this would be easy to avoid. Not blocking this PR, maybe something we can look in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I wasn't happy about this part of the change, but the only way precreated attribute sets really worked is by making them immutable.

Copy link
Member

@lalitb lalitb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. Nicely done. Please update the ChangeLog to reflect breaking changes for Observables.

@cijothomas cijothomas merged commit 16fd1ab into open-telemetry:main Jan 24, 2024
12 of 13 checks passed
@KallDrexx KallDrexx deleted the premade_attribute_sets branch January 26, 2024 18:49
@cijothomas
Copy link
Member

This caused some unintended side-effects, opened a separate issue to discussion options : #1508

KallDrexx added a commit to KallDrexx/opentelemetry-rust that referenced this pull request Feb 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Feature]: Allow precreation of AttributeSets for metrics
7 participants