Oximeter: Cache FieldSet values. - #10926
Conversation
|
Results, comparing this branch to main under a few scenarios:
Load generation comes from #10925, which attempts to simulate somewhat realistic metrics, based on the mgs producer, which is by far the highest-volume individual producer. We vary series cardinality, number of simulated producers, number of samples emitted per series per collection, to simulate different scenarios, with "mgs" being based observed load of mgs on colo. I also had claude write a harness for running and observing these tests at https://gist.github.com/jmcarp/728c7b4693446337708bb35902bd79f1—still deciding how/whether to clean them up and check them in. My high-level summary is that caching |
Oximeter spends a meaningful chunk of cpu time, and a meaningful number of allocations, parsing `FieldSet` values from incoming json. Because we encounter the same `FieldSet` values many times each, we can avoid a measureable amount of work by caching parsed `FieldSet` values, keying on unparsed `RawValue` values.
8597fac to
fd060cf
Compare
This is a good change, and I think it's good to keep pushing on it. But I want to clarify that this 16.5GiB number is for the zone, not the oximeter process itself. On |
bnaecker
left a comment
There was a problem hiding this comment.
Something about this doesn't quite make sense to me. I don't mean it's wrong or unhelpful. I think I'm mostly surprised at the effect size.
IIUC, we're creating a separate cache for each producer. So consider, MGS, our most prolific producer. The timeseries that MGS produces, say hardware_component:temperature, have their fields derived from completely static data, like the chassis_model or the sensor name. Unless I'm really missing something, that implies we should have a cache miss on the first collection, and never again. I think that we should very quickly see cache misses drop to zero, potentially after the very first collection. But that means the cache is static and storing all the field sets we parsed out before anyway, right? This is an extreme example, but I think the pattern in general holds: most producers I'm aware of don't change their fields very often, if ever. Why does the cache help us so much?
It seems like the most probable answer is that parsing the JSON fields consumes a bunch of memory, and that because those are all randomly distributed in time for each producer, the overall / average memory consumption is high. Can we prove that? If so, can we change that in a simpler way, say by using a different parsing crate?
| /// The duration the collection took. | ||
| duration: Duration, | ||
|
|
||
| cache: FieldSetCache, |
There was a problem hiding this comment.
Let's add a comment on this.
| @@ -0,0 +1,134 @@ | |||
| // This Source Code Form is subject to the terms of the Mozilla Public | |||
There was a problem hiding this comment.
I feel like this whole type should be just in the oximeter-collector crate. It's only consumed there, and never appears in the public API. It's really an implementation detail of that program.
| } | ||
|
|
||
| impl FieldSetCache { | ||
| pub fn new() -> Self { |
There was a problem hiding this comment.
I'd probably remove this and #[derive(Default)] instead.
| let seen = cached_fieldset.seen; | ||
| cached_fieldset.seen = false; | ||
| seen | ||
| }); |
There was a problem hiding this comment.
Can we track the change in cache size before and after this? It would be helpful to know if we're either (1) missing out on an opportunity by not holding cache entries longer or (2) if the cache size is constant, in which case we should dig more :)
| /// to represent fields get this property for free, since fields are | ||
| /// collected into a `BTreeMap`, which orders fields alphabetically. | ||
| pub struct FieldSetCache { | ||
| fieldsets: HashMap<Box<str>, CachedFieldSet>, |
There was a problem hiding this comment.
I think the Box<str> is fine, but a little surprising (to me, anyway). Probably worth a comment about why this over a String.
| "hits" => cache_stats.hits, | ||
| "misses" => cache_stats.misses, | ||
| "hit_rate" => format!("{:.3}", cache_stats.hits as f64 / cache_gets as f64), | ||
| ); |
There was a problem hiding this comment.
I would love to see this log message on a real deployment, or as close as we can get.
|
|
||
| // Target name and fields | ||
| pub(crate) target: FieldSet, | ||
| pub(crate) target: Arc<FieldSet>, |
There was a problem hiding this comment.
I missed this on the first review. This explains why the cache actually helps, because every sample from one producer shares the same FieldSet. When each collection task pushes those samples onto the database-insertion queue, there will be lots of sharing here. This all makes more sense now. The cache is really there to reduce the duplicated fields on the insertion buffer, not inside each collection task itself.
|
|
||
| /// A concrete type representing a single, timestamped measurement from a timeseries. | ||
| #[derive(Debug, Clone, Deserialize)] | ||
| pub struct RawSample<'a> { |
There was a problem hiding this comment.
I think the new types in this file should probably move to the oximeter-collector crate too, since it's also an implementation detail IMO.
|
Drive-by comment: I have opened #10954 to track the general chattiness of Oximeter. |
Oximeter spends a meaningful chunk of cpu time, and a meaningful number of allocations, parsing
FieldSetvalues from incoming json. Because we encounter the sameFieldSetvalues many times each, we can avoid a measureable amount of work by caching parsedFieldSetvalues, keying on unparsedRawValuevalues.Note: I'm marking this as a draft until I can also check in the benchmarking scripts I've been using to test this. So far, oximeter cpu/memory use look significantly better with this change, as does throughput, which should also help us #10552. But I want to be a bit more rigorous about the benchmarks before I call this change ready.