Skip to content

Add TraceLog.ResetCallStacks to bound real time call stack growth - #4

Draft
jamescrosswell wants to merge 6 commits into
mainfrom
fix/bound-realtime-callstack-growth
Draft

Add TraceLog.ResetCallStacks to bound real time call stack growth#4
jamescrosswell wants to merge 6 commits into
mainfrom
fix/bound-realtime-callstack-growth

Conversation

@jamescrosswell

@jamescrosswell jamescrosswell commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Note

Internal review round. what we'd like to submit to microsoft/perfview. The branch is based
on upstream main (af227405), so it produces the same clean diff against both bases.
The only part to delete before submitting upstream is this note.

Background

The .NET runtime includes EventPipe which creates a nettrace stream for runtime events that are used for profiling. TraceLog, from the TraceEvent library in PerfView, turns those raw events into something symbolic — it tracks processes, threads, modules, methods and code addresses so that TraceEvent.CallStack() returns frames rather than hex addresses.

Call stacks are the bulk of that data and the most redundant: consecutive samples usually share almost every frame. So TraceCallStacks does not store stacks, it interns them into a prefix tree. Each node is CallStackInfo { codeAddressIndex, callerIndex }, a whole stack is identified by a single CallStackIndex. This structure is maintained in three arrays: callStacks (nodes), callees (each node's children, searched when interning) and threads (per-thread roots).

For a trace file this works really well. The input is finite, and while you are analysing it you want every stack it contains to stay resolvable, so the tree is deliberately a cache with no eviction.

Real time use case

microsoft#1867 added streaming / in-memory EventPipe support, and microsoft#2169 added the rundown-provider API that lets frames resolve mid-session. Those made TraceLog usable against a live session rather than a file — which is what continuous profilers need, and what the Sentry .NET SDK is built on.

The data model came along unchanged. InternCallStackIndex still appends, and nothing is ever released. If you point that at a server process that runs for weeks then the tree grows for the life of the session (i.e. indefinitely).

Additionally:

  • It grows with stack diversity, not sample volume. Repeated stacks are free — they resolve to an existing node. Every new stack shape appends nodes. A fresh async state machine, a new code path, a JIT'd generic each add more, so a busy service keeps minting them indefinitely.
  • It grows whether or not anyone is consuming stacks. The session interns everything it sees, so a profiler that samples 10% of transactions pays the same growth as one that samples all of them.

This is already known

The three it trims are the small event-to-stack maps. This PR adds a fourth — the largest one, roughly fourteen times the size of the next biggest structure in the dump above.

The change

The addition of a new TraceLog.ResetCallStacks() method that discards the interned call stacks and returns the tree to its initial state.

It is inert for every existing consumer. It is opt in, nothing calls it, no existing behaviour changes, and it throws outside a real time session.

Two implementation details worth review attention:

  1. It assigns fresh GrowableArrays rather than calling GrowableArray.Clear(). Clear() only resets the length and keeps the backing store — and for callees and threads that store holds a reference to every child List<CallStackIndex> ever created, which is the bulk of the memory being released.
  2. It also clears eventsToStacks and cswitchBlockingEventsToStacks, which map events to the indexes just invalidated. Those hold plain structs, so a length reset genuinely is enough there — they are cleared for correctness, not for memory.

Measured

Synthetic workload, 600 s, real time EventPipe session, .NET 9, Linux container:

without reset with reset
interned call stacks at 600 s 1,231,862, still climbing bounded, cycling 919 – 7,121
managed heap 3.4 → 119.2 MiB 3.5 → ~7 MiB, flat
RSS 65.2 → 200.7 MiB 65.6 → 81.4 MiB
samples processed 307,418 340,987

After warm up the reset run moved +2.1 MiB over 9 minutes and 174 resets — about 0.012 MiB per reset. Sample throughput went up, because the baseline spends real time growing and collecting those tables. The workload is deliberately far more stack-diverse than a real service, to make the effect measurable in minutes; the shape matches the field report below, the rate is exaggerated.

From the field — getsentry/sentry-dotnet#5469, an ASP.NET Core service on Linux growing ~0.6 GB/day until the kernel OOM-killed it at ~8 days of uptime. dotnet-gcdump showed the two halves of this tree at exactly the same size:

35,087,256   CallStackInfo[]
35,087,256   List<CallStackIndex>[]
 2,548,664   MethodInfo[]
 2,548,536   CodeAddressInfo[]

Setting the profile sample rate to 0 — the only thing that stops a session being created at all — cured it completely, with every TraceEvent type disappearing from the heap.

Why the other tables are left alone

codeAddresses, methods and ILToNativeMap also grow, and are deliberately untouched.

Clearing them would be irreversible. The rundown that populates method names for code JIT'd before the session started runs once, as a separate short-lived session at TraceLog creation, and cannot be re-run against a live session.

They are not strictly bounded — tiered compilation, expression trees, RegexOptions.Compiled and reflection emit all mint new methods over time — so a residual, much smaller growth remains for apps doing continuous runtime codegen.

Addressing this needs a different mechanism, and is left as separate work.

The contract ResetCallStacks asks of callers

Universal preconditions:

  1. No CallStackIndex obtained earlier may still be in use
  2. It must be called on the thread that processes events

What is not universal is when a given consumer can know its indexes are dead — a consumer that resolves synchronously in the callback can reset between any two events, while one that caches by index can only do so between cache lifetimes. That depends entirely on its own retention pattern, so the API states the invariant and leaves the timing to the caller.

This is also why we've added an explicit method rather than, say, a MaxCallStackCount property that resets itself inside the event pump.

Test

EventPipeParsing.ResetCallStacksDiscardsInternedStacksAndKeepsInterning, modelled on the existing
SessionStreaming test. It starts a real time session, lets stacks accumulate, resets from inside the sample callback, then asserts both halves of the contract: the tables are emptied, and stacks interned afterwards still resolve through to a method name.

It also walks the entire post-reset caller chain, range checking every link. That is deliberate — GrowableArray's indexer only guards with Debug.Assert, so in Release a stale index reads the backing store and silently resolves to the wrong frame instead of throwing. Asserting one frame resolves is not enough to catch that; walking the chain is.

Runs in ~350 ms, and is skipped below .NET Core 3.0 like its neighbour.

A known gap, flagged deliberately. The test does not fail if threads is left uncleared — verified this by mutation. Stale per-thread roots resolve to default(CallStackInfo), whose code address index is 0, which is almost never a valid/real address, so they sit inert and get appended to. The consequence is retained memory plus a rare correctness risk (a code-address-0 collision, or IndexOutOfRangeException once a stale index exceeds the new capacity) rather than deterministic corruption. A memory-based test would cover it but seems a poor trade for CI time. Suggestions welcome.

jamescrosswell and others added 2 commits August 12, 2026 16:49
In a real time session the call stack interning tables grow for the
lifetime of the session: TraceCallStacks.InternCallStackIndex appends to
callStacks and callees for every distinct stack observed, and nothing is
ever released. Trace files are finite so this never mattered there, but a
long running process with diverse stacks grows without bound.

FlushRealTimeEvents already trims eventsToStacks, eventsToCodeAddresses
and cswitchBlockingEventsToStacks, but not the interning tables
themselves, which are by far the largest of the structures involved.

Add TraceLog.ResetCallStacks(), which discards the interned call stacks
and returns those tables to their initial state. It is opt in, restricted
to real time sessions, and invalidates any CallStackIndex handed out
earlier, so the caller decides when no such index is live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jamescrosswell and others added 4 commits August 13, 2026 11:18
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant