perf(trace-utils)!: span pool to reduce alloc churn on the send path - #2382
perf(trace-utils)!: span pool to reduce alloc churn on the send path#2382paullegranddc wants to merge 5 commits into
Conversation
# Changes Pass a PooledChunks argument around to the trace exporter that can contain a reference to the span pool. Whenever spans get dropped, the chunks will get returned to the passed pool. Previous use cases that don't want to use the Pool can call PooledChunks::unpooled. # Motivation Allocation of collection is one of the most expensive things we perform in the tracer (with allocation of Strings). Being able to reuse allocated capacity should improve performance (to be benchmarked in actual code).
📚 Documentation Check Results📦
|
🔒 Cargo Deny Results📦
|
|
✅ All CI checks and tests passed. 🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: 6ff2c62 | Docs | View more details | Give us feedback! |
Artifact Size Benchmark Reportaarch64-alpine-linux-musl
aarch64-unknown-linux-gnu
libdatadog-x64-windows
libdatadog-x86-windows
x86_64-alpine-linux-musl
x86_64-unknown-linux-gnu
|
BenchmarksComparisonBenchmark execution time: 2026-08-24 13:26:26 Comparing candidate commit c3bbbdf in PR branch Found 9 performance improvements and 12 performance regressions! Performance is the same for 131 metrics, 0 unstable metrics.
|
ekump
left a comment
There was a problem hiding this comment.
My only real concern is the potential flaky test, otherwise LGTM
| return Ok(AgentResponse::Unchanged); | ||
| } | ||
| return self.send_otlp_traces_inner(traces, config).await; | ||
| // The OTLP mapper transforms spans into a different representation and consumes |
There was a problem hiding this comment.
I don't think this comment is accurate?
| // The OTLP mapper transforms spans into a different representation and consumes | |
| // The OTLP mapper borrows the spans and builds a separate OTLP | |
| // representation, the original spans are recycled to the pool when `traces` drops. |
| /// if span usage spikes, and then goes down. | ||
| fn drop_policy() -> bool { | ||
| const PCT_OF_SPANS_RETURNED_DROPPED: f64 = 0.1; | ||
| rand::thread_rng().gen_bool(PCT_OF_SPANS_RETURNED_DROPPED) |
There was a problem hiding this comment.
This is going to get called on every span being flushed, right? Isn't thread_rng() going to be expensive? Could you do it at the chunk level instead? Or, does it have to be random at all? Could you just drop every X spans deterministically in order to shrink the pool?
| mut traces: Vec<Vec<Span<T>>>, | ||
| mut traces: PooledChunks<'_, T>, | ||
| ) -> Result<AgentResponse, TraceExporterError> { | ||
| // `traces` is a `PooledChunks`: keeping it owned (rather than moving its inner `Vec` |
There was a problem hiding this comment.
Not sure if this is the right place, but should we document somewhere that only sampled spans make it to the pool?
| // Serialize span links / span events before `span` is consumed below. v0.5 has no | ||
| // dedicated slots for them, so they are flattened into `meta` as JSON strings. |
There was a problem hiding this comment.
I think this comment is stale now, span isn't consumed anymore.
| fn returned_spans_are_recycled_and_reset() { | ||
| let pool = SpanPool::<crate::span::BytesData>::new(100); | ||
| { | ||
| // No drop policy control here, but with a single span it is very likely retained. |
There was a problem hiding this comment.
"very likely" is another way of saying flaky.
What is this test actually covering? won't s.name be the same whether it comes from the pool or is a new span?
Should we have a DropPolicy that's injectable? Something like...
#[derive(Debug)]
enum DropPolicy {
Random(f64),
EveryN { n: usize, counter: AtomicUsize }, // deterministic, no global
Never, // tests only?
}
impl DropPolicy {
fn should_drop(&self) -> bool {
match self {
DropPolicy::Random(p) => rand::thread_rng().gen_bool(*p),
DropPolicy::EveryN { n, counter } =>
counter.fetch_add(1, Ordering::Relaxed) % n == 0,
DropPolicy::Never => false,
}
}
}
#[derive(Debug, Clone)]
pub struct SpanPool<T: TraceData> {
queue: crossbeam_channel::Sender<Span<T>>,
receiver: crossbeam_channel::Receiver<Span<T>>,
drop_policy: Arc<DropPolicy>,
} |
Should we have benchmark tests too? |
Motivation
Allocation is expensive, and spans use a lot of small collections (meta, metrics, span links, events...)
Being able to recycle the allocation should be beneficial in term of perf.
This PR adds a span pool to the trace exporter to do this
Changes
Add SpanPool trace utils
Add PooledChunks through the send path in libdd-data-pipeline: