Performance optimizations - #51
Draft
chris-peterson wants to merge 2 commits into
Draft
chris-peterson wants to merge 2 commits into
chris-peterson wants to merge 2 commits into
Conversation
chris-peterson
force-pushed
the
claude/add-performance-harness-bx8YU
branch
3 times, most recently
from
March 23, 2026 21:44
197262a to
2dd66ba
Compare
chris-peterson
force-pushed
the
claude/add-performance-harness-bx8YU
branch
from
August 4, 2026 17:27
2dd66ba to
d428b0b
Compare
Replace ConcurrentDictionary with lock-guarded flat arrays for EventContext field storage, preserving thread safety while eliminating hash table overhead and sort-on-render. Stopwatch replaced with Stopwatch.GetTimestamp() to avoid per-event object allocation. Lazy initialization for timers, counts, and PrivateData skips allocation when unused. Constructor batches field writes without locking since the object isn't yet shared. ThreadStatic StringBuilder reuse and single-pass key normalization reduce render allocations. Throughput: 179K -> 1,721K ops/sec (9.6x vs 6.4.7 release). Allocation: 12,450 -> 1,272 bytes/op (89.8% reduction). Adds benchmark harness and Benchmarks.Baseline project for NuGet package comparison.
chris-peterson
force-pushed
the
claude/add-performance-harness-bx8YU
branch
from
August 18, 2026 17:26
d428b0b to
c78235d
Compare
The benchmark projects measured configurations they never bound. Configuration.Create returns a config without assigning Configuration.Default, so the EventContexts the benchmarks build found an empty provider list and Dispose() skipped Render() entirely, while the 6.4.7 baseline used Configuration.Initialize and rendered. The BenchmarkDotNet classes now construct EventContext with the config under test, the throughput harness calls Initialize, and the baseline project links that harness rather than copying it so the two runs differ only in which Spiffy.Monitoring they reference. That makes the previous commit's numbers a comparison between a rendering baseline and a non-rendering candidate. Measured again over 60s: 187,708 -> 563,696 ops/sec and 12,180 -> 4,183 bytes/op, so 3.0x and -66% rather than 9.6x and -90%. Render() now holds the lock for its whole walk. It reads _keys, _vals and _count, which are only consistent with each other between mutations, and its TimeElapsed write is itself a mutation -- the ConcurrentDictionary it replaced made that safe by construction. The lock is a private object rather than the EventContext, which callers can also lock. Timers and PrivateData are created under it so a concurrent first use cannot discard a collection that already has entries in it, and Render() no longer writes MetricsKey into a dictionary it discards. Timer keys go through NormalizeKey. Rendering them raw let a key from ctx.Time(name) carry whitespace into the delimited output and forge an additional field. Caller resolution in the parameterless constructor handles lambdas, local functions, and generic async methods, which reached the raw compiler-generated names. StackTraceCleanup runs on every target framework rather than compiling out on net8.0, which is what left its replacement strings untested and missing the separator between the type and the method. Traces with no '<' skip the three patterns, so the common case pays one IndexOf. With no NET8_0_OR_GREATER branch left in the source, nothing distinguishes the two assets and the package targets netstandard2.0 alone again. The 60s runs measure the same either way -- 563,696 ops/sec against netstandard2.0, 559,423 against net8.0 -- so the second target was a compatibility surface with no behavior behind it. Dropped the bitmask lookup, the cached quote strings, ZeroTime and the AggressiveInlining attributes: with the harness rendering, removing all four measured within run-to-run noise.
chris-peterson
force-pushed
the
claude/add-performance-harness-bx8YU
branch
from
September 6, 2026 16:44
6d440f0 to
700ebe2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
EventContextis the object every logged event flows through: you construct one, set fields on it, time blocks with it, and disposing it renders onekey=valueline. Each event was costing 12,180 bytes — threeConcurrentDictionaryinstances, aStopwatch, LINQ sorting inRender(), and three separate dictionary passes to normalize keys. In a service logging at volume that is most of the GC pressure.Storage is now flat
string[]/object[]arrays behind a private lock, scanned linearly. At the 10-20 fields a real event carries, a linear scan beats hashing — and because insertion order falls out of an array for free, the ordering tuple and the counter that fed it both disappear along with the sort.Both numbers come from the same harness over 60s —
Benchmarks.Baselinelinks the candidate's harness source and swaps only theSpiffy.Monitoringreference, so the two runs can't drift apart. Gen0 count is flat: allocation rate is similar, but each event costs a third as much, so the same collections cover three times the events.The version bump is still owed — the csproj is at
6.4.7, which is already on nuget.org, sopublish.ymlwould skip this on a release until it moves.Review guide
Core change — read these first
EventContext.cs— the arrays that replace the dictionaries, plusFindKeyandAppendEntry.FindKeythrows on a null key: suppressed fields tombstone their slot by nulling it, so an unguarded null would address whichever field was suppressed first.Render()holds the lock for its whole walk — it reads_keys/_vals/_count, which are only consistent between mutations, and its ownTimeElapsedwrite is a mutation. TheConcurrentDictionarythis replaces made that safe for free; arrays don't.AutoTimer.cs— timestamp arithmetic instead of aStopwatchper event.Behavior worth checking
WriteTimerValuesroutes timer keys throughNormalizeKey. Skipping it letctx.Time(routeName)put whitespace straight into the delimited line and forge a field —Timer_keys_are_normalizedpins it.StateMachineTypePatternand its sibling resolve component/operation for the parameterless ctor. Lambdas, local functions and generic async methods each nest differently; the tests cover all three.StackTraceCleanup.csreplacesBen.Demystifier. It runs on every framework and short-circuits on traces with no<, so the ordinary case pays oneIndexOf. Its tests are what caught its replacement strings dropping the separator between type and method.RequiresEncapsulationno longer depends on which quote appears first —a'b"cused to be wrapped in', a character it contains.Footnotes
ThroughputHarnesscallsInitialize, notCreate; onlyInitializeassignsConfiguration.Default, which is what the contexts it builds bind.Ben.DemystifierandSystem.Diagnostics.StackTrace;TimerCollection.ShallowClone,GlobalEventContext.CopyToandStringExtensions.ContainsWhiteSpaceall lost their last callers.Approach & trade-offs
FindKeyis O(n). At <20 fields the cache-friendly scan wins on measurement; a service that puts hundreds of fields on one event would not. Worth flagging because nothing enforces the assumption.The lock is a private object, not
EventContext. Locking onthiswould let any caller holding a context contend with the library's own critical sections — including the user-suppliedBeforeLoggingActionsthat run duringDispose().StackTraceCleanupswallowsRegexMatchTimeoutExceptionand returns the trace uncleaned. The alternative is throwing out ofIncludeExceptionand breaking the caller's logging path. It matches how the surrounding code already treats logging failures, but it is a deliberate choice rather than an obvious one.Output ordering changes. Count fields render after value fields rather than interleaved by insertion order, and timer fields sort ordinally rather than by current culture. Both are improvements; both are visible to anything parsing the output positionally.