perf(profiling)!: reduce profiler arena memory footprint#2048
perf(profiling)!: reduce profiler arena memory footprint#2048taegyunkim wants to merge 7 commits into
Conversation
📚 Documentation Check Results📦
|
Clippy Allow Annotation ReportComparing clippy allow annotations between branches:
Summary by Rule
Annotation Counts by File
Annotation Stats by Crate
About This ReportThis report tracks Clippy allow annotations for specific rules, showing how they've changed in this PR. Decreasing the number of these annotations generally improves code quality. |
🔒 Cargo Deny Results📦
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## taegyunkim/prof-14423-prof-dictinary-bench #2048 +/- ##
==============================================================================
+ Coverage 73.53% 73.57% +0.04%
==============================================================================
Files 475 475
Lines 79007 79200 +193
==============================================================================
+ Hits 58095 58270 +175
- Misses 20912 20930 +18
🚀 New features to boost your workflow:
|
|
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
|
3da10e3 to
477c1f4
Compare
|
Note that historically the tension here was between fragmentation and memory use -- that's why we set the higher defaults. (See for instance https://docs.google.com/document/d/1g_H7G9s_H9yoxlpyw_B0aoUyIVmo0ZQBzQkp5EUUyX8/edit?tab=t.0 ) This not to say that we can't or shouldn't adjust these numbers, it's more to add context to why larger numbers were chosen rather than starting with smallest possible and just letting it grow. |
@ivoanjo Thanks for the context! That makes sense, and this is why this PR uses capped geometric growth. A couple of differences make this less risky than the story from your report:
So this keeps the lower memory floor for small/common profiles, while avoiding the "smallest possible and just keep growing tiny chunks" behavior. I agree we should validate this with real workloads, especially Ruby if we're worried about fragmentation. |
|
Ahh that's great, thanks for the extra context. In particular, I missed the detail where these come from Excited to see the improvements from this one :D |
|
@ivoanjo the DoE results look very good for Python with this change For all three archetypes, we see reduction in heap live size, heap live samples, allocated memory, allocations without change in cpu-time.
|
45a451c to
4686b93
Compare
2e23e15 to
477834f
Compare
65a7aff to
6726636
Compare
| /// doesn't have enough space for the requested allocation, and then links the | ||
| /// new [LinearAllocator] to the previous one, creating a chain. This is where | ||
| /// its name comes from. | ||
| /// its name comes from. Each successful growth doubles the target chunk size |
There was a problem hiding this comment.
have we experimented with other factors? e.g. 1.5x would still grow geometrically, but not as fast.
There was a problem hiding this comment.
No, I did not benchmark other factors. Chose 2x because the goal here is to lower the memory footprint for applications smaller dictionaries, while converging back to prior chunk sizes quickly for larger workloads.
| /// this in mind when sizing your hint if you are trying to be precise, | ||
| /// such as making sure a specific object fits. | ||
| pub const fn new_in(chunk_size_hint: usize, allocator: A) -> Self { | ||
| let initial_node_size = Self::normalize_node_size(chunk_size_hint); |
There was a problem hiding this comment.
why does one of these get a function and the other is inline?
There was a problem hiding this comment.
This was removed in the latest refactor. The generic 1MiB default cap was removed from ChainAllocator, and new_in now delegates to new_capped_in(chunk_size_hint, chunk_size_hint, allocator) so it keeps fixed-size routine chunks.
| pub const SIZE_HINT: usize = 1024 * 1024; | ||
| // Keep the per-shard arena small; larger dictionaries grow | ||
| // geometrically up to the historical 1 MiB chunk size. | ||
| pub const SIZE_HINT: usize = 64 * 1024; |
There was a problem hiding this comment.
should this be named INITIAL_SIZE_HINT
| // geometrically up to the historical 4 MiB chunk size, while common | ||
| // profiles fit comfortably below this initial size. Talk to .NET | ||
| // profiling engineers before making this any bigger. | ||
| const SIZE_HINT: usize = 512 * 1024; |
There was a problem hiding this comment.
for the other case, we went from 64K-1M, here we go from 512K-4M. Why
There was a problem hiding this comment.
512K is not a number derived from a benchmark.
The difference is that dictionary arenas are per-shard, while StringTable is a single arena per profile.
With 16 string shards, 64KiB per shard is already a 1MiB initial size. Applying the same 64KiB to StringTable would make the whole table start at only 64KiB, which felt too small.
512KiB still reduces the initial reservation substantially from the historically 4MiB, while being less aggressive than the per-shard dictionary setting.
| if Layout::from_size_align(next, align).is_ok() { | ||
| next | ||
| } else { | ||
| current |
There was a problem hiding this comment.
Why is this the right fall-back when from_size_align fails?
There was a problem hiding this comment.
current comes from the layout we just successfully constructed and allocated, so it is known to be valid for this alignment. If the next target is not valid, keeping current preserves the last valid routine chunk size instead of storing an invalid future hint that would make subsequent growth fail.
2418e69 to
a0dfbe4
Compare
## What does this PR do? Adds a focused Criterion benchmark for `ProfilesDictionary` unique string insertion. The benchmark uses `ProfilesDictionary` and covers 1, 2, 4, and 16 producer threads, so follow-up arena sizing/growth changes have a baseline in the GitLab benchmark job. #2048 changes the default configs for the ProfilesDictionary ## Why these thread counts? This benchmark is focused on dictionary string interning. It is not a full end-to-end profiler benchmark. | Thread count | Why it is useful | |---|---| | 1 | Approximates single-writer consumers such as ddprof. ddprof appears to intern from the worker thread while export happens from a separate inactive profile buffer. | | 2 | Approximates current dd-trace-py contention: one native stack sampler thread can intern off-GIL while one Python/Cython collector path is active under the GIL. | | 4 / 16 | Stress cases for future higher-concurrency scenarios, including possible free-threaded/no-GIL Python work. | In dd-trace-py, profile mutation and serialization are guarded by `profile_mtx`, but dictionary interning can happen before a sample is added to the profile. This means dictionary insertion can still be concurrent even when profile writes are serialized. ## What this does not cover This benchmark does not model every profiler behavior. In particular, it does not cover: - function or mapping insertion, - duplicate-heavy/cached lookup workloads, - profile serialization reading from the dictionary, - full profiler end-to-end behavior. ## Why only `ProfilesDictionary`? I originally tried an additional synthetic benchmark comparing 4 vs 16 shards. Local exploratory results suggested 16 shards helps once there is concurrent insertion: ```text 1 thread: 4 shards ~40 µs, 16 shards ~55 µs 2 threads: 4 shards ~157 µs, 16 shards ~129 µs 4 threads: 4 shards ~385 µs, 16 shards ~280 µs 16 threads: 4 shards ~2.64 ms, 16 shards ~1.61 ms ``` However, that synthetic comparison also changed total initial hash-table capacity because the capacity was applied per shard. Since the current follow-up keeps the production shard count at 16, this PR stays minimal and only adds the `ProfilesDictionary` benchmark. If we revisit shard count later, we should add a dedicated shard-count benchmark that holds total starting capacity constant across shard counts. ## How to test the change? - cargo +nightly-2026-02-08 fmt --all -- --check - cargo check -p libdd-profiling --benches - cargo +stable clippy -p libdd-profiling --benches -- -D warnings [PROF-14423](https://datadoghq.atlassian.net/browse/PROF-14423) [PROF-14423]: https://datadoghq.atlassian.net/browse/PROF-14423?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: morrisonlevi <levi.morrison@datadoghq.com> Co-authored-by: taegyun.kim <taegyun.kim@datadoghq.com>
f562ea7 to
803c49b
Compare
151bd7f to
22092c5
Compare



What does this PR do?
Reduces the profiler arena memory floor while preserving larger-workload behavior by adding opt-in capped geometric growth to
ChainAllocatorand using it for profiler arenas.This PR uses the
ProfilesDictionaryCriterion benchmark added in #2088 so the GitLab benchmark job can compare the change.Changes:
ChainAllocator.ChainAllocator::new_capped_in(initial, max, allocator)for callers that want a smaller initial chunk but a historical/max chunk size after growth.ChainAllocator::new_in(...)using fixed-size routine chunks. Geometric growth is opt-in throughnew_capped_in(...).StringTableinitial chunks from 4 MiB to 512 KiB, capped at the historical 4 MiB chunk size.ParallelStringSet/ParallelSliceSetat 16 shards and does not change shard selection.Motivation
Python profiler memory analysis showed that common profiles keep only tens to hundreds of KiB of dictionary/string-table content, but libdatadog reserved much larger arena chunks up front. This created a high per-process memory floor, especially across forked workers.
The smaller initial chunks reduce that floor. Geometric growth avoids keeping large/high-cardinality services on tiny chunks indefinitely, so they ramp back to the previous chunk sizes after a few growth events.
The 64 KiB dictionary initial chunk size is a compromise between lowering the fixed per-shard memory floor and avoiding very small chunks. With 16 string shards, it lowers the initial string-set arena floor from 16 MiB to 1 MiB, while geometric growth reaches the historical 1 MiB chunk size after four growths.
Shard count
This PR keeps the existing 16-shard default and shard selection logic.
I originally explored reducing
ParallelStringSet/ParallelSliceSetfrom 16 shards to 4, but dropped that from this PR. The extra memory saved by reducing shards after the arena-size change is relatively small (12 * 64 KiB = 768 KiBfor the string set), while 16 shards preserve better concurrent insertion headroom.Consumer concurrency summary:
So this PR focuses on the main memory win: smaller initial arenas with capped growth, without reducing shard count.
Additional Notes
Expected growth patterns:
64 KiB -> 128 KiB -> 256 KiB -> 512 KiB -> 1 MiB -> ...StringTable:512 KiB -> 1 MiB -> 2 MiB -> 4 MiB -> ...Oversized individual allocations still allocate chunks large enough for the request, even if larger than the routine growth cap.
Approximate initial arena floor after this change:
ParallelStringSet:16 * 64 KiB = 1 MiBinstead of16 * 1 MiB = 16 MiB.FunctionSet:4 * 64 KiB = 256 KiBinstead of4 * 1 MiB = 4 MiB.MappingSet:2 * 64 KiB = 128 KiBinstead of2 * 1 MiB = 2 MiB.StringTable:512 KiBinstead of4 MiB.How to test the change?
Latest local validation:
Previously ran:
cargo check -p libdd-alloc -p libdd-profiling cargo check -p libdd-profiling --benches cargo +stable clippy -p libdd-alloc -p libdd-profiling --all-targets --all-features -- -D warnings cargo nextest run -p libdd-alloc -p libdd-profiling cargo test --doc -p libdd-alloc -p libdd-profilingPROF-14423