From 43af61732ae74275e67d9cb8d3415aba94135213 Mon Sep 17 00:00:00 2001 From: Brian Robbins Date: Tue, 31 Mar 2026 20:33:29 -0700 Subject: [PATCH 1/7] Use min-heap for EventCache.SortAndDispatch Replace the O(N*T) linear-scan merge in SortAndDispatch with an O(N*log(T)) min-heap merge, where N is the number of events and T is the number of threads. The previous implementation rebuilt a List from LINQ on every call and linearly scanned all thread queues for the minimum timestamp per event. The new implementation uses an array-backed binary min-heap keyed by timestamp. After extracting the minimum, only a single O(log T) sift-down is needed to restore the heap property. The heap list is reused across calls to avoid per-call allocations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TraceEvent/EventPipe/EventCache.cs | 130 ++++++++++++++++++++----- 1 file changed, 104 insertions(+), 26 deletions(-) diff --git a/src/TraceEvent/EventPipe/EventCache.cs b/src/TraceEvent/EventPipe/EventCache.cs index eaaac5513..3929ebbfa 100644 --- a/src/TraceEvent/EventPipe/EventCache.cs +++ b/src/TraceEvent/EventPipe/EventCache.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Runtime.InteropServices; namespace Microsoft.Diagnostics.Tracing.EventPipe @@ -165,49 +164,127 @@ private void CheckForPendingThreadRemoval() private unsafe void SortAndDispatch(long stopTimestamp) { - // This sort could be made faster by using a min-heap but this is a simple place to start - List> threadQueues = new List>(_threads.Values.Select(t => t.Events)); - while(true) + // Build a min-heap from non-empty thread queues whose front event is before stopTimestamp. + // This gives O(N * log(T)) merge performance instead of O(N * T) where N is the number + // of events and T is the number of threads. + _heap.Clear(); + foreach (EventPipeThread thread in _threads.Values) { - long lowestTimestamp = stopTimestamp; - Queue oldestEventQueue = null; - foreach(Queue threadQueue in threadQueues) + Queue q = thread.Events; + if (q.Count > 0) { - if(threadQueue.Count == 0) - { - continue; - } - long eventTimestamp = threadQueue.Peek().Header.TimeStamp; - if (eventTimestamp < lowestTimestamp) + long ts = q.Peek().Header.TimeStamp; + if (ts < stopTimestamp) { - oldestEventQueue = threadQueue; - lowestTimestamp = eventTimestamp; + _heap.Add(new HeapEntry(ts, q)); } } - if(oldestEventQueue == null) + } + + if (_heap.Count == 0) + { + return; + } + + HeapBuild(); + + // Merge events in timestamp order using the min-heap. + while (_heap.Count > 0) + { + HeapEntry min = _heap[0]; + EventMarker eventMarker = min.Queue.Dequeue(); + OnEvent?.Invoke(ref eventMarker.Header); + + if (min.Queue.Count > 0) { - break; + long nextTs = min.Queue.Peek().Header.TimeStamp; + if (nextTs < stopTimestamp) + { + // Update the root with the next timestamp and restore the heap property. + _heap[0] = new HeapEntry(nextTs, min.Queue); + HeapSiftDown(0); + } + else + { + HeapRemoveRoot(); + } } else { - EventMarker eventMarker = oldestEventQueue.Dequeue(); - OnEvent?.Invoke(ref eventMarker.Header); + HeapRemoveRoot(); + // Free internal storage for empty queues to prevent unbounded memory growth + // when the application creates and destroys threads over time. + min.Queue.TrimExcess(); } } + } + + #region Min-heap helpers for SortAndDispatch + + private struct HeapEntry + { + public long Timestamp; + public Queue Queue; + + public HeapEntry(long timestamp, Queue queue) + { + Timestamp = timestamp; + Queue = queue; + } + } - // If the app creates and destroys threads over time we need to flush old threads - // from the cache or memory usage will grow unbounded. AddThread handles the - // the thread objects but the storage for the queue elements also does not shrink - // below the high water mark unless we free it explicitly. - foreach (Queue q in threadQueues) + private void HeapBuild() + { + for (int i = _heap.Count / 2 - 1; i >= 0; i--) { - if(q.Count == 0) + HeapSiftDown(i); + } + } + + private void HeapSiftDown(int i) + { + int count = _heap.Count; + while (true) + { + int smallest = i; + int left = 2 * i + 1; + int right = 2 * i + 2; + if (left < count && _heap[left].Timestamp < _heap[smallest].Timestamp) + { + smallest = left; + } + if (right < count && _heap[right].Timestamp < _heap[smallest].Timestamp) { - q.TrimExcess(); + smallest = right; } + if (smallest == i) + { + break; + } + HeapEntry temp = _heap[i]; + _heap[i] = _heap[smallest]; + _heap[smallest] = temp; + i = smallest; } } + private void HeapRemoveRoot() + { + int lastIndex = _heap.Count - 1; + if (lastIndex == 0) + { + _heap.Clear(); + } + else + { + _heap[0] = _heap[lastIndex]; + _heap.RemoveAt(lastIndex); + HeapSiftDown(0); + } + } + + #endregion + private void FreeOldEventBuffers(long stopTimestamp) { while (_buffers.Count > 0) @@ -277,6 +354,7 @@ public EventBlockBuffer(FixedBuffer buffer, long maxTimestamp) EventPipeEventSource _source; ThreadCache _threads; Queue _buffers = new Queue(); + List _heap = new List(); } internal class EventMarker From 2b11d0cf73b6b3fbf25588b5a31ecd087a75406d Mon Sep 17 00:00:00 2001 From: Brian Robbins Date: Tue, 31 Mar 2026 20:36:42 -0700 Subject: [PATCH 2/7] Track active thread queues in EventCache.SortAndDispatch Maintain a HashSet of thread queues that have pending events instead of iterating all threads in the dictionary on every SortAndDispatch call. Queues are added to the active set when their first event is enqueued and removed when drained. This eliminates the Dictionary.Values enumeration which was ~28% of CPU during nettrace-to-TraceLog conversion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TraceEvent/EventPipe/EventCache.cs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/TraceEvent/EventPipe/EventCache.cs b/src/TraceEvent/EventPipe/EventCache.cs index 3929ebbfa..a2133e9ff 100644 --- a/src/TraceEvent/EventPipe/EventCache.cs +++ b/src/TraceEvent/EventPipe/EventCache.cs @@ -67,6 +67,10 @@ public void ProcessEventBlock(Block block) } else { + if (thread.Events.Count == 0) + { + _activeThreadQueues.Add(thread.Events); + } thread.Events.Enqueue(eventMarker); } @@ -164,13 +168,14 @@ private void CheckForPendingThreadRemoval() private unsafe void SortAndDispatch(long stopTimestamp) { - // Build a min-heap from non-empty thread queues whose front event is before stopTimestamp. - // This gives O(N * log(T)) merge performance instead of O(N * T) where N is the number - // of events and T is the number of threads. + // Build a min-heap from active thread queues (those with pending events) whose + // front event is before stopTimestamp. Using _activeThreadQueues avoids iterating + // all threads in the dictionary — only threads that have had events enqueued are checked. + // This gives O(N * log(T)) merge performance where N is the number of events and + // T is the number of active threads. _heap.Clear(); - foreach (EventPipeThread thread in _threads.Values) + foreach (Queue q in _activeThreadQueues) { - Queue q = thread.Events; if (q.Count > 0) { long ts = q.Peek().Header.TimeStamp; @@ -212,8 +217,9 @@ private unsafe void SortAndDispatch(long stopTimestamp) else { HeapRemoveRoot(); - // Free internal storage for empty queues to prevent unbounded memory growth - // when the application creates and destroys threads over time. + // Remove from active set and free internal storage to prevent unbounded + // memory growth when the application creates and destroys threads. + _activeThreadQueues.Remove(min.Queue); min.Queue.TrimExcess(); } } @@ -355,6 +361,7 @@ public EventBlockBuffer(FixedBuffer buffer, long maxTimestamp) ThreadCache _threads; Queue _buffers = new Queue(); List _heap = new List(); + HashSet> _activeThreadQueues = new HashSet>(); } internal class EventMarker From 437440b68f017d166cf2cfc6710c42371be7f117 Mon Sep 17 00:00:00 2001 From: Brian Robbins Date: Tue, 31 Mar 2026 20:36:51 -0700 Subject: [PATCH 3/7] Cache ParsedSymbolMetadata to avoid repeated JSON deserialization Cache the result of ProcessMappingSymbolMetadataParser.TryParse() in ProcessMappingMetadataTraceData so that repeated accesses to the ParsedSymbolMetadata property do not re-invoke JSON deserialization. The property is accessed twice per mapping event (for PE and ELF metadata checks), and the metadata objects are shared across multiple mappings via MetadataId. This was ~10% of CPU during conversion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Parsers/UniversalSystemTraceEventParser.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs b/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs index 307da3edd..723314b29 100644 --- a/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs +++ b/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs @@ -355,7 +355,20 @@ public sealed class ProcessMappingMetadataTraceData : TraceEvent public string SymbolMetadata {get { return GetShortUTF8StringAt(SkipVarInt(0)); } } - internal ProcessMappingSymbolMetadata ParsedSymbolMetadata { get { return ProcessMappingSymbolMetadataParser.TryParse(SymbolMetadata); } } + internal ProcessMappingSymbolMetadata ParsedSymbolMetadata + { + get + { + if (!_parsedSymbolMetadataCached) + { + _parsedSymbolMetadata = ProcessMappingSymbolMetadataParser.TryParse(SymbolMetadata); + _parsedSymbolMetadataCached = true; + } + return _parsedSymbolMetadata; + } + } + private ProcessMappingSymbolMetadata _parsedSymbolMetadata; + private bool _parsedSymbolMetadataCached; public string VersionMetadata {get {return GetShortUTF8StringAt(SkipShortUTF8String(SkipVarInt(0))); } } From 5f10bd2d29d009f89a3890e136d71135869ce3d6 Mon Sep 17 00:00:00 2001 From: Brian Robbins Date: Tue, 31 Mar 2026 21:02:39 -0700 Subject: [PATCH 4/7] Avoid redundant FileName string allocations in ProcessMapping handler Read ProcessMappingTraceData.FileName once into a local variable and pass it directly to UniversalMapping(string, ...) instead of going through the UniversalMapping(ProcessMappingTraceData, ...) overload. Previously, FileName was accessed 3 times per mapping event (IsNullOrEmpty check, StartsWith check, and inside UniversalMapping), each time allocating a new string via GetShortUTF8StringAt(). String allocation was ~12% of CPU in Release-mode profiling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SourceConverters/NettraceUniversalConverter.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/TraceEvent/SourceConverters/NettraceUniversalConverter.cs b/src/TraceEvent/SourceConverters/NettraceUniversalConverter.cs index 846043bf7..a34cd1107 100644 --- a/src/TraceEvent/SourceConverters/NettraceUniversalConverter.cs +++ b/src/TraceEvent/SourceConverters/NettraceUniversalConverter.cs @@ -62,7 +62,8 @@ public void BeforeProcess(TraceLog traceLog, TraceEventDispatcher source) } processes.Add(process); - if (!string.IsNullOrEmpty(data.FileName) && data.FileName.StartsWith(DotnetJittedCodeMappingName, StringComparison.Ordinal)) + string fileName = data.FileName; + if (!string.IsNullOrEmpty(fileName) && fileName.StartsWith(DotnetJittedCodeMappingName, StringComparison.Ordinal)) { // Don't create a module for jitted code. // These will be created for each jitted code symbol. @@ -70,7 +71,7 @@ public void BeforeProcess(TraceLog traceLog, TraceEventDispatcher source) } _mappingMetadata.TryGetValue(data.MetadataId, out ProcessMappingMetadataTraceData metadata); - TraceModuleFile moduleFile = process.LoadedModules.UniversalMapping(data, metadata); + TraceModuleFile moduleFile = process.LoadedModules.UniversalMapping(fileName, data.StartAddress, data.EndAddress, data.TimeStampQPC, metadata); }; universalSystemParser.ProcessMappingMetadata += delegate (ProcessMappingMetadataTraceData data) { From 3661dcdcbb0c21c13c71ce5c327698901138485b Mon Sep 17 00:00:00 2001 From: Brian Robbins Date: Wed, 1 Apr 2026 08:19:36 -0700 Subject: [PATCH 5/7] Reset ParsedSymbolMetadata cache in Dispatch to prevent stale data TraceEvent objects are reused across callbacks. The cached ParsedSymbolMetadata fields were never cleared between dispatches, which could return metadata from a previous event if the property was accessed on the template object rather than a clone. Reset _parsedSymbolMetadataCached and _parsedSymbolMetadata at the start of Dispatch() before invoking the callback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs b/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs index 723314b29..bbcecd521 100644 --- a/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs +++ b/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs @@ -380,6 +380,8 @@ internal ProcessMappingMetadataTraceData(Action } protected internal override void Dispatch() { + _parsedSymbolMetadataCached = false; + _parsedSymbolMetadata = null; Action(this); } From 0849a10194e9a7e6e856eb2255054b5c848e28ac Mon Sep 17 00:00:00 2001 From: Brian Robbins Date: Wed, 1 Apr 2026 13:44:11 -0700 Subject: [PATCH 6/7] Address code review feedback: MinHeap class, Clone override, comments Refactor the min-heap helpers into a self-contained private MinHeap class with XML doc comments on all public methods. Add comments explaining the binary heap child index formulas (2i+1, 2i+2). Use C# tuple swap syntax instead of a temp variable. Add Clone() override to ProcessMappingMetadataTraceData to explicitly copy the cached ParsedSymbolMetadata fields into the clone. Strings are immutable so a shallow copy of the reference is sufficient. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TraceEvent/EventPipe/EventCache.cs | 154 +++++++++++------- .../UniversalSystemTraceEventParser.cs | 8 + 2 files changed, 106 insertions(+), 56 deletions(-) diff --git a/src/TraceEvent/EventPipe/EventCache.cs b/src/TraceEvent/EventPipe/EventCache.cs index a2133e9ff..6fa9a63fa 100644 --- a/src/TraceEvent/EventPipe/EventCache.cs +++ b/src/TraceEvent/EventPipe/EventCache.cs @@ -181,7 +181,7 @@ private unsafe void SortAndDispatch(long stopTimestamp) long ts = q.Peek().Header.TimeStamp; if (ts < stopTimestamp) { - _heap.Add(new HeapEntry(ts, q)); + _heap.Add(ts, q); } } } @@ -191,101 +191,143 @@ private unsafe void SortAndDispatch(long stopTimestamp) return; } - HeapBuild(); + _heap.Build(); // Merge events in timestamp order using the min-heap. while (_heap.Count > 0) { - HeapEntry min = _heap[0]; - EventMarker eventMarker = min.Queue.Dequeue(); + Queue minQueue = _heap.PeekQueue; + EventMarker eventMarker = minQueue.Dequeue(); OnEvent?.Invoke(ref eventMarker.Header); - if (min.Queue.Count > 0) + if (minQueue.Count > 0) { - long nextTs = min.Queue.Peek().Header.TimeStamp; + long nextTs = minQueue.Peek().Header.TimeStamp; if (nextTs < stopTimestamp) { // Update the root with the next timestamp and restore the heap property. - _heap[0] = new HeapEntry(nextTs, min.Queue); - HeapSiftDown(0); + _heap.ReplaceRoot(nextTs, minQueue); } else { - HeapRemoveRoot(); + _heap.RemoveRoot(); } } else { - HeapRemoveRoot(); + _heap.RemoveRoot(); // Remove from active set and free internal storage to prevent unbounded // memory growth when the application creates and destroys threads. - _activeThreadQueues.Remove(min.Queue); - min.Queue.TrimExcess(); + _activeThreadQueues.Remove(minQueue); + minQueue.TrimExcess(); } } } - #region Min-heap helpers for SortAndDispatch + #region Min-heap Implementation - private struct HeapEntry + /// + /// A min-heap that orders entries by timestamp. Used to efficiently merge events + /// from multiple thread queues in timestamp order during SortAndDispatch. + /// + private class MinHeap { - public long Timestamp; - public Queue Queue; - - public HeapEntry(long timestamp, Queue queue) + private struct Entry { - Timestamp = timestamp; - Queue = queue; + public long Timestamp; + public Queue Queue; + + public Entry(long timestamp, Queue queue) + { + Timestamp = timestamp; + Queue = queue; + } } - } - private void HeapBuild() - { - for (int i = _heap.Count / 2 - 1; i >= 0; i--) + private readonly List _entries = new List(); + + public int Count => _entries.Count; + + public Queue PeekQueue => _entries[0].Queue; + + public void Clear() => _entries.Clear(); + + public void Add(long timestamp, Queue queue) { - HeapSiftDown(i); + _entries.Add(new Entry(timestamp, queue)); } - } - private void HeapSiftDown(int i) - { - int count = _heap.Count; - while (true) + /// + /// Establishes the heap property over all entries. Call once after adding all + /// entries via Add, before extracting from the heap. + /// + public void Build() { - int smallest = i; - int left = 2 * i + 1; - int right = 2 * i + 2; - if (left < count && _heap[left].Timestamp < _heap[smallest].Timestamp) + for (int i = _entries.Count / 2 - 1; i >= 0; i--) { - smallest = left; + SiftDown(i); } - if (right < count && _heap[right].Timestamp < _heap[smallest].Timestamp) + } + + /// + /// Replaces the root entry with a new timestamp for the same queue and restores + /// the heap property. Use when the root queue still has events to dispatch. + /// + public void ReplaceRoot(long newTimestamp, Queue queue) + { + _entries[0] = new Entry(newTimestamp, queue); + SiftDown(0); + } + + /// + /// Removes the root (minimum) entry from the heap and restores the heap property. + /// + public void RemoveRoot() + { + int lastIndex = _entries.Count - 1; + if (lastIndex == 0) { - smallest = right; + _entries.Clear(); } - if (smallest == i) + else { - break; + _entries[0] = _entries[lastIndex]; + _entries.RemoveAt(lastIndex); + SiftDown(0); } - HeapEntry temp = _heap[i]; - _heap[i] = _heap[smallest]; - _heap[smallest] = temp; - i = smallest; } - } - private void HeapRemoveRoot() - { - int lastIndex = _heap.Count - 1; - if (lastIndex == 0) - { - _heap.Clear(); - } - else + /// + /// Restores the min-heap property by moving the element at index i down the tree + /// until it is smaller than both children or reaches a leaf position. + /// + private void SiftDown(int i) { - _heap[0] = _heap[lastIndex]; - _heap.RemoveAt(lastIndex); - HeapSiftDown(0); + int count = _entries.Count; + while (true) + { + int smallest = i; + + // In a binary heap stored as an array, the children of node i are at + // indices 2i+1 (left) and 2i+2 (right). + int left = 2 * i + 1; + int right = 2 * i + 2; + + if (left < count && _entries[left].Timestamp < _entries[smallest].Timestamp) + { + smallest = left; + } + if (right < count && _entries[right].Timestamp < _entries[smallest].Timestamp) + { + smallest = right; + } + if (smallest == i) + { + break; + } + (_entries[i], _entries[smallest]) = (_entries[smallest], _entries[i]); + i = smallest; + } } } @@ -360,7 +402,7 @@ public EventBlockBuffer(FixedBuffer buffer, long maxTimestamp) EventPipeEventSource _source; ThreadCache _threads; Queue _buffers = new Queue(); - List _heap = new List(); + MinHeap _heap = new MinHeap(); HashSet> _activeThreadQueues = new HashSet>(); } diff --git a/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs b/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs index bbcecd521..90a936ea5 100644 --- a/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs +++ b/src/TraceEvent/Parsers/UniversalSystemTraceEventParser.cs @@ -385,6 +385,14 @@ protected internal override void Dispatch() Action(this); } + public override unsafe TraceEvent Clone() + { + var clone = (ProcessMappingMetadataTraceData)base.Clone(); + clone._parsedSymbolMetadata = _parsedSymbolMetadata; + clone._parsedSymbolMetadataCached = _parsedSymbolMetadataCached; + return clone; + } + protected internal override Delegate Target { get { return Action; } From bdac22e2d7fa3dd97bc6571b353443412704fcbe Mon Sep 17 00:00:00 2001 From: Brian Robbins Date: Wed, 1 Apr 2026 13:55:43 -0700 Subject: [PATCH 7/7] Add MinHeap unit tests and improve Build() comment Make MinHeap generic (MinHeap) and internal so it can be tested from the test project. Add 13 unit tests covering: empty heap, single element, ascending/descending/random input, duplicate keys, ReplaceRoot, RemoveRoot, Clear, and mixed operations. Add a comment to Build() explaining why iteration starts at Count/2-1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TraceEvent/EventPipe/EventCache.cs | 45 ++-- .../TraceEvent.Tests/Cache/MinHeapTests.cs | 230 ++++++++++++++++++ 2 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 src/TraceEvent/TraceEvent.Tests/Cache/MinHeapTests.cs diff --git a/src/TraceEvent/EventPipe/EventCache.cs b/src/TraceEvent/EventPipe/EventCache.cs index 6fa9a63fa..6492a41e5 100644 --- a/src/TraceEvent/EventPipe/EventCache.cs +++ b/src/TraceEvent/EventPipe/EventCache.cs @@ -196,7 +196,7 @@ private unsafe void SortAndDispatch(long stopTimestamp) // Merge events in timestamp order using the min-heap. while (_heap.Count > 0) { - Queue minQueue = _heap.PeekQueue; + Queue minQueue = _heap.PeekValue; EventMarker eventMarker = minQueue.Dequeue(); OnEvent?.Invoke(ref eventMarker.Header); @@ -227,20 +227,20 @@ private unsafe void SortAndDispatch(long stopTimestamp) #region Min-heap Implementation /// - /// A min-heap that orders entries by timestamp. Used to efficiently merge events - /// from multiple thread queues in timestamp order during SortAndDispatch. + /// A min-heap that pairs a long key with a value of type . + /// Entries are ordered by key so the minimum key is always at the root. /// - private class MinHeap + internal class MinHeap { private struct Entry { - public long Timestamp; - public Queue Queue; + public long Key; + public TValue Value; - public Entry(long timestamp, Queue queue) + public Entry(long key, TValue value) { - Timestamp = timestamp; - Queue = queue; + Key = key; + Value = value; } } @@ -248,21 +248,28 @@ public Entry(long timestamp, Queue queue) public int Count => _entries.Count; - public Queue PeekQueue => _entries[0].Queue; + public TValue PeekValue => _entries[0].Value; public void Clear() => _entries.Clear(); - public void Add(long timestamp, Queue queue) + public void Add(long key, TValue value) { - _entries.Add(new Entry(timestamp, queue)); + _entries.Add(new Entry(key, value)); } /// /// Establishes the heap property over all entries. Call once after adding all /// entries via Add, before extracting from the heap. /// + /// + /// Starts from the last non-leaf node (_entries.Count / 2 - 1) and sifts each + /// node down to its correct position. Leaves (the second half of the array) are + /// already trivially valid heaps of size 1, so they are skipped. + /// public void Build() { + // Start from the last non-leaf node and work backwards to the root. + // Nodes at indices [Count/2 .. Count-1] are leaves that need no adjustment. for (int i = _entries.Count / 2 - 1; i >= 0; i--) { SiftDown(i); @@ -270,12 +277,12 @@ public void Build() } /// - /// Replaces the root entry with a new timestamp for the same queue and restores - /// the heap property. Use when the root queue still has events to dispatch. + /// Replaces the root entry with a new key/value pair and restores the heap property. + /// Use when the root element has been consumed but its source still has more items. /// - public void ReplaceRoot(long newTimestamp, Queue queue) + public void ReplaceRoot(long newKey, TValue value) { - _entries[0] = new Entry(newTimestamp, queue); + _entries[0] = new Entry(newKey, value); SiftDown(0); } @@ -313,11 +320,11 @@ private void SiftDown(int i) int left = 2 * i + 1; int right = 2 * i + 2; - if (left < count && _entries[left].Timestamp < _entries[smallest].Timestamp) + if (left < count && _entries[left].Key < _entries[smallest].Key) { smallest = left; } - if (right < count && _entries[right].Timestamp < _entries[smallest].Timestamp) + if (right < count && _entries[right].Key < _entries[smallest].Key) { smallest = right; } @@ -402,7 +409,7 @@ public EventBlockBuffer(FixedBuffer buffer, long maxTimestamp) EventPipeEventSource _source; ThreadCache _threads; Queue _buffers = new Queue(); - MinHeap _heap = new MinHeap(); + MinHeap> _heap = new MinHeap>(); HashSet> _activeThreadQueues = new HashSet>(); } diff --git a/src/TraceEvent/TraceEvent.Tests/Cache/MinHeapTests.cs b/src/TraceEvent/TraceEvent.Tests/Cache/MinHeapTests.cs new file mode 100644 index 000000000..34283d5a1 --- /dev/null +++ b/src/TraceEvent/TraceEvent.Tests/Cache/MinHeapTests.cs @@ -0,0 +1,230 @@ +using Microsoft.Diagnostics.Tracing.EventPipe; +using System; +using System.Collections.Generic; +using Xunit; + +namespace TraceEventTests +{ + public class MinHeapTests + { + [Fact] + public void EmptyHeap_CountIsZero() + { + var heap = new EventCache.MinHeap(); + Assert.Equal(0, heap.Count); + } + + [Fact] + public void SingleElement_PeekReturnsIt() + { + var heap = new EventCache.MinHeap(); + heap.Add(42, "only"); + heap.Build(); + + Assert.Equal(1, heap.Count); + Assert.Equal("only", heap.PeekValue); + } + + [Fact] + public void Build_EstablishesMinOrder() + { + var heap = new EventCache.MinHeap(); + heap.Add(30, "thirty"); + heap.Add(10, "ten"); + heap.Add(20, "twenty"); + heap.Build(); + + Assert.Equal("ten", heap.PeekValue); + } + + [Fact] + public void RemoveRoot_YieldsAscendingOrder() + { + var heap = new EventCache.MinHeap(); + heap.Add(50, 50); + heap.Add(30, 30); + heap.Add(40, 40); + heap.Add(10, 10); + heap.Add(20, 20); + heap.Build(); + + var result = new List(); + while (heap.Count > 0) + { + result.Add(heap.PeekValue); + heap.RemoveRoot(); + } + + Assert.Equal(new[] { 10, 20, 30, 40, 50 }, result); + } + + [Fact] + public void RemoveRoot_SingleElement_EmptiesHeap() + { + var heap = new EventCache.MinHeap(); + heap.Add(1, "a"); + heap.Build(); + + heap.RemoveRoot(); + Assert.Equal(0, heap.Count); + } + + [Fact] + public void ReplaceRoot_MaintainsHeapOrder() + { + var heap = new EventCache.MinHeap(); + heap.Add(10, "ten"); + heap.Add(20, "twenty"); + heap.Add(30, "thirty"); + heap.Build(); + + Assert.Equal("ten", heap.PeekValue); + + // Replace root (10) with a larger key (25) — "twenty" (20) should become new root. + heap.ReplaceRoot(25, "twenty-five"); + Assert.Equal("twenty", heap.PeekValue); + } + + [Fact] + public void ReplaceRoot_WithSmallestKey_KeepsItAtRoot() + { + var heap = new EventCache.MinHeap(); + heap.Add(10, "ten"); + heap.Add(20, "twenty"); + heap.Add(30, "thirty"); + heap.Build(); + + // Replace root with an even smaller key — it should remain the root. + heap.ReplaceRoot(5, "five"); + Assert.Equal("five", heap.PeekValue); + } + + [Fact] + public void Clear_ResetsHeap() + { + var heap = new EventCache.MinHeap(); + heap.Add(1, "a"); + heap.Add(2, "b"); + heap.Build(); + + heap.Clear(); + Assert.Equal(0, heap.Count); + } + + [Fact] + public void DuplicateKeys_AllElementsPreserved() + { + var heap = new EventCache.MinHeap(); + heap.Add(10, "a"); + heap.Add(10, "b"); + heap.Add(10, "c"); + heap.Build(); + + var result = new List(); + while (heap.Count > 0) + { + result.Add(heap.PeekValue); + heap.RemoveRoot(); + } + + Assert.Equal(3, result.Count); + result.Sort(); + Assert.Equal(new[] { "a", "b", "c" }, result); + } + + [Fact] + public void LargeHeap_ExtractsInOrder() + { + var heap = new EventCache.MinHeap(); + var rng = new Random(12345); + var expected = new List(); + + for (int i = 0; i < 1000; i++) + { + int val = rng.Next(0, 100000); + heap.Add(val, val); + expected.Add(val); + } + + heap.Build(); + expected.Sort(); + + var result = new List(); + while (heap.Count > 0) + { + result.Add(heap.PeekValue); + heap.RemoveRoot(); + } + + Assert.Equal(expected, result); + } + + [Fact] + public void AlreadySorted_ExtractsInOrder() + { + var heap = new EventCache.MinHeap(); + for (int i = 1; i <= 10; i++) + { + heap.Add(i, i); + } + heap.Build(); + + var result = new List(); + while (heap.Count > 0) + { + result.Add(heap.PeekValue); + heap.RemoveRoot(); + } + + Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, result); + } + + [Fact] + public void ReverseSorted_ExtractsInOrder() + { + var heap = new EventCache.MinHeap(); + for (int i = 10; i >= 1; i--) + { + heap.Add(i, i); + } + heap.Build(); + + var result = new List(); + while (heap.Count > 0) + { + result.Add(heap.PeekValue); + heap.RemoveRoot(); + } + + Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, result); + } + + [Fact] + public void MixedOperations_RemoveAndReplace() + { + var heap = new EventCache.MinHeap(); + heap.Add(10, "ten"); + heap.Add(20, "twenty"); + heap.Add(30, "thirty"); + heap.Add(40, "forty"); + heap.Build(); + + // Extract min (10), then replace root with 25. + Assert.Equal("ten", heap.PeekValue); + heap.RemoveRoot(); + Assert.Equal("twenty", heap.PeekValue); + heap.ReplaceRoot(25, "twenty-five"); + + // Now heap has: 25, 30, 40. Min should be 25. + Assert.Equal("twenty-five", heap.PeekValue); + + var result = new List(); + while (heap.Count > 0) + { + result.Add(heap.PeekValue); + heap.RemoveRoot(); + } + Assert.Equal(new[] { "twenty-five", "thirty", "forty" }, result); + } + } +}