Lesson 4: Measured performance
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
The first three lessons measured bytes, which are exact. This lesson measures time, which isn’t. A timing depends on the processor, on what else the machine is doing, on the JIT’s tier, and on details of the benchmark that are easy to get wrong. The lesson starts with how BenchmarkDotNet deals with all that, then puts four pieces of performance folklore to the test on Guitar Alchemist code: “the JIT needs warming up”, “SearchValues beats a loop”, “FrozenDictionary beats Dictionary”, and “SIMD beats a scalar loop”.
GA links point to commit a826864; runtime links point to commit 4271d88 of dotnet/runtime, tagged v10.0.12.
Running the lesson’s program and its benchmarks
Section titled “Running the lesson’s program and its benchmarks”bash code/csharp-advanced/check.sh # every lesson, compared with expected/dotnet run --project code/csharp-advanced/Advanced -c Release -- l4 # this lesson only, after check.shcd code/csharp-advanceddotnet run -c Release --project Benchmarks -- --filter "*SearchBenchmarks*" # one benchmark classAdvanced/Lesson4.cs checks that the code being compared computes the same results; CI compares its output on three OSes. The timings come from Benchmarks/, run one class at a time on the author’s machine:
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9445/25H2/2025Update/HudsonValley2)Intel Core Ultra 9 285K 3.70GHz, 1 CPU, 24 logical and 24 physical cores.NET SDK 10.0.112 [Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3 DefaultJob : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3CI runs every benchmark once with --job Dry, which checks that they run and measures nothing. Timings are never compared in CI: a shared runner’s timings vary from one run to the next more than most of the differences below.
How BenchmarkDotNet measures
Section titled “How BenchmarkDotNet measures”A benchmark is a public method marked [Benchmark] in a public class. BenchmarkSwitcher generates a separate project for each class, builds it in Release, and runs each benchmark in its own process, so that one benchmark’s JIT state, GC state and static fields don’t leak into the next (How it works). In that process, it goes through stages, which the log shows. Here is VoicingWithSpans from lesson 2, cut down:
OverheadJitting 1: 1 op, 297100.00 ns, 297.1000 us/opWorkloadJitting 1: 1 op, 1748700.00 ns, 1.7487 ms/opWorkloadPilot 1: 16 op, 5800.00 ns, 362.5000 ns/opWorkloadPilot 2: 32 op, 6600.00 ns, 206.2500 ns/op...WorkloadPilot 15: 262144 op, 39632100.00 ns, 151.1845 ns/opWorkloadPilot 16: 524288 op, 66413500.00 ns, 126.6737 ns/opWorkloadPilot 17: 1048576 op, 32909200.00 ns, 31.3847 ns/opWorkloadPilot 18: 2097152 op, 66566700.00 ns, 31.7415 ns/op...OverheadActual 1: 16777216 op, 22217200.00 ns, 1.3242 ns/op...WorkloadWarmup 1: 16777216 op, 529044400.00 ns, 31.5335 ns/op...// BeforeActualRunWorkloadActual 1: 16777216 op, 745563700.00 ns, 44.4391 ns/opWorkloadActual 2: 16777216 op, 787768100.00 ns, 46.9546 ns/op- Jitting calls the method once, so that the time of JIT compilation isn’t counted as a measurement.
- Pilot doubles the number of calls per iteration until an iteration lasts long enough to be timed precisely: here 16,777,216 calls, about half a second.
- Overhead runs the same loop around an empty method; its time is subtracted from the workload’s.
- Warmup repeats iterations until the time per call stops changing, then Actual runs the iterations that the statistics use: at least 15, more if they vary.
The pilot also shows the JIT at work. For about 500,000 calls the method took around 150 ns; then, suddenly, 31 ns. Nothing in the benchmark changed: the runtime replaced the method’s first, unoptimized code with optimized code, as the next section explains. A stopwatch around a loop of 100,000 calls would have measured the unoptimized code and concluded the method was five times slower than it is.
The end of the log shows another surprise: the warmup reached 31 ns, and the actual iterations measured 41 to 52 ns, for the same code. The table of lesson 2 reports 46 ns. Running the benchmark again with BenchmarkDotNet’s --affinity option, which pins the process to one core, gave 32.4 ns with a standard deviation under 0.5 ns, whether pinned to the first core (--affinity 1) or to the last one (--affinity 8388608). The author’s processor mixes performance and efficiency cores, and the scheduler moves threads between them; whether that explains the slower iterations hasn’t been investigated, to verify. The practical lesson stands: when the warmup and the actual iterations disagree, or when BenchmarkDotNet warns about a multimodal distribution, rerun before believing the table.
The rules that follow from this (Good practices):
- Build in Release, without a debugger. BenchmarkDotNet refuses a Debug build.
- Return the result of the work, as every benchmark in the course does, so that the JIT can’t remove a computation nobody uses.
- Keep setup out of the measured method:
[GlobalSetup]runs once before the iterations,[Params]runs the benchmark for each value. - Run on a quiet machine, one benchmark class at a time, and read
ErrorandStdDevbefore comparing two means.
Tiered compilation, OSR and dynamic PGO
Section titled “Tiered compilation, OSR and dynamic PGO”When a method is called for the first time, the runtime doesn’t compile it with every optimization: that would slow startup. With tiered compilation, on by default since .NET Core 3.0:
- Tier 0: the method is compiled quickly with few optimizations, or its precompiled ReadyToRun code is used; most of the .NET libraries ship with ReadyToRun code.
- After 30 calls, counted once a 100 ms delay without new tier-0 compilations has passed, the method is queued for tier 1, compiled with full optimization in the background (
clrconfigvalues.h#L474-L480). Later calls use the new code. - A method that is called once but loops for a long time, like
Main, would be stuck in tier 0. On-stack replacement (OSR) switches it to optimized code in the middle of the loop. - With dynamic PGO (profile-guided optimization), on by default since .NET 8 (
clrconfigvalues.h#L520), tier 0 code also counts which branches are taken and which types an interface call actually receives. Tier 1 uses that profile: for example, when a call onIEnumerable<int>always receives aList<int>, it checks forList<int>and calls its methods directly, where they can be inlined (guarded devirtualization).
flowchart LR A[First call] --> B{ReadyToRun code?} B -- yes --> C[Precompiled code] B -- no --> D[Tier 0, instrumented for PGO] C -- 30 calls --> E[Tier 1, optimized] D -- 30 calls --> E D -- long loop --> F[OSR: optimized code mid-loop] D -. profile .-> EEach step can be switched off with an environment variable (compilation settings). Benchmarks/JitBenchmarks.cs runs the same two methods under four jobs: the defaults, DOTNET_TieredPGO=0, DOTNET_TieredCompilation=0 (everything compiled fully optimized on the first call, without a profile), and DOTNET_ReadyToRun=0 (the libraries’ precompiled code ignored, so they go through the tiers too):
private static readonly IEnumerable<int> Values = PitchClassSetId.Items.Select(id => id.Value).ToList();
// A loop over an interface: with PGO, the JIT sees that Values is always a List<int> and devirtualizes the calls[Benchmark]public long SumThroughInterface(){ long sum = 0; foreach (var value in Values) sum += value; return sum;}
// GA's collection of the 4096 sets, enumerated through IReadOnlyCollection<PitchClassSetId>[Benchmark]public int CardinalityOfEverySet(){ var notes = 0; foreach (var id in PitchClassSetId.Items) notes += id.Cardinality; return notes;}| Method | Job | EnvironmentVariables | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
| SumThroughInterface | Default | Empty | 1.219 μs | 0.0100 μs | 0.0094 μs | 1.00 | 0.01 | - | NA |
| SumThroughInterface | NoPGO | DOTNET_TieredPGO=0 | 11.341 μs | 0.0594 μs | 0.0556 μs | 9.30 | 0.08 | 40 B | NA |
| SumThroughInterface | NoReadyToRun | DOTNET_ReadyToRun=0 | 1.212 μs | 0.0064 μs | 0.0059 μs | 0.99 | 0.01 | - | NA |
| SumThroughInterface | NoTiering | DOTNET_TieredCompilation=0 | 11.305 μs | 0.0890 μs | 0.0833 μs | 9.27 | 0.10 | 40 B | NA |
| CardinalityOfEverySet | Default | Empty | 2.656 μs | 0.0306 μs | 0.0286 μs | 1.00 | 0.01 | 40 B | 1.00 |
| CardinalityOfEverySet | NoPGO | DOTNET_TieredPGO=0 | 11.353 μs | 0.0778 μs | 0.0728 μs | 4.28 | 0.05 | 40 B | 1.00 |
| CardinalityOfEverySet | NoReadyToRun | DOTNET_ReadyToRun=0 | 2.672 μs | 0.0418 μs | 0.0391 μs | 1.01 | 0.02 | 40 B | 1.00 |
| CardinalityOfEverySet | NoTiering | DOTNET_TieredCompilation=0 | 11.303 μs | 0.0717 μs | 0.0636 μs | 4.26 | 0.05 | 40 B | 1.00 |
The two methods behave differently, and both results are worth reading closely.
SumThroughInterfaceruns 9 times faster with PGO, and allocates nothing. Without the profile (NoPGO), or without tiers at all (NoTiering, where nothing is ever instrumented), everyMoveNext()andCurrentis an interface call, andGetEnumerator()boxes theList<int>.Enumeratorstruct: 40 bytes. With the profile, the JIT knows thatValuesis always aList<int>: it adds a type check, calls the struct enumerator’s methods directly and inlines them, and since the enumerator no longer escapes, it doesn’t box it. That combination of guarded devirtualization and escape analysis is one of the .NET 10 improvements described in Performance Improvements in .NET 10.CardinalityOfEverySet, over GA’sPitchClassSetId.Items, gains 4 times with PGO but still allocates 40 bytes. Its collection is the compiler-generated<>z__ReadOnlyList<PitchClassSetId>from lesson 1, and PGO doesn’t get rid of its enumerator allocation; why exactly, I haven’t looked into, to verify.DOTNET_TieredCompilation=0is no faster than the defaults, and here much slower: code compiled once, fully optimized but without a profile, loses what PGO brings. Turning tiers off to “skip warmup” trades startup and PGO for nothing in steady state.DOTNET_ReadyToRun=0changes nothing measurable once the code has reached tier 1; ReadyToRun matters for startup, which this benchmark doesn’t measure.
SearchValues<T>: searching for any of several values
Section titled “SearchValues<T>: searching for any of several values”Finding the first accidental in a chord symbol is a loop over characters. SearchValues<T>, added in .NET 8, precomputes a set of values once, and IndexOfAny then uses the fastest search for that set on the current processor (Lesson4.cs#L53-L83):
static readonly SearchValues<char> Accidentals = SearchValues.Create("#b");static readonly SearchValues<string> Qualities = SearchValues.Create(["maj7", "m7b5", "dim7", "sus4"], StringComparison.Ordinal);
public static int IndexOfAccidental(string symbol){ var i = symbol.AsSpan(1).IndexOfAny(Accidentals); return i < 0 ? -1 : i + 1;}== SearchValues: the same answers as a loopfirst accidental: -1 -1 -1 -1 -1 3 -1 1 1 1 1 1 -1 2; same as the loop: Truequality found: -1 -1 -1 1 -1 1 1 -1 2 -1 -1 -1 1 -1SearchValues<T> is abstract, and Create returns a specialized subclass. Which one depends on the processor, which is why the program prints its name on a machine-dependent line:
# SearchValues.Create("#b") is Any2CharPackedSearchValues# SearchValues.Create(["maj7", ...]) is AsciiStringSearchValuesTeddyNonBucketizedN3`2On x64, two ASCII characters get a packed implementation, which narrows UTF-16 characters to bytes to compare more of them per instruction; on the macOS Arm64 runner, the same call returned Any2SearchValues`2. The four strings use Teddy, a vectorized multi-substring algorithm, on both. SearchBenchmarks searches the 14 chord symbols, and a chord chart of 400 bars, 9,606 characters, whose only accidental is near the end:
| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|
| SymbolsLoop | 16.51 ns | 0.352 ns | 0.711 ns | 1.00 | 0.06 | - | NA |
| SymbolsSearchValues | 28.53 ns | 0.587 ns | 1.576 ns | 1.73 | 0.12 | - | NA |
| ChartLoop | 2,298.83 ns | 45.726 ns | 115.556 ns | 139.53 | 9.25 | - | NA |
| ChartSearchValues | 162.38 ns | 7.412 ns | 21.853 ns | 9.86 | 1.39 | - | NA |
| ChartIndexOfAnyTwoChars | 166.70 ns | 4.382 ns | 12.853 ns | 10.12 | 0.89 | - | NA |
On the chord symbols, 1 to 6 characters long, the loop wins: SearchValues is 1.7 times slower. Each call to IndexOfAny has to check the length and choose a path before it looks at a character, and that costs more than looking at three or four characters. On the chart, the loop is 14 times slower than SearchValues, 2.3 µs against 162 ns, because the vectorized search compares many characters per instruction. And IndexOfAny('#', 'b') does as well as SearchValues there (exercise 3). SearchValues is for long inputs, or for sets of values that the simple overloads don’t cover.
FrozenDictionary: which implementation you get
Section titled “FrozenDictionary: which implementation you get”FrozenDictionary<TKey, TValue>, added in .NET 8, is a read-only dictionary that spends more time when it is created to be faster when it is read. ToFrozenDictionary() examines the keys and picks an implementation (FrozenDictionary.cs#L157-L280). The program prints the type it gets for a few sets of keys, including two of GA’s (Lesson4.cs#L85-L111):
== FrozenDictionary: which implementation ToFrozenDictionary() picks10 chord suffixes (string keys) LengthBucketsFrozenDictionary`1same, StringComparer.OrdinalIgnoreCase LengthBucketsFrozenDictionary`112 pitch classes (int keys) WithFullValues`312 PitchClass keys (record struct) ValueTypeDefaultComparerFrozenDictionary`2GA: 144 (int, int) keys, PitchClass - ValueTypeDefaultComparerFrozenDictionary`2GA: ProgrammaticForteCatalog.ForteByPrimeFormId ValueTypeDefaultComparerFrozenDictionary`2 (224 keys)lookups agree with the Dictionary: True- String keys of few different lengths go into length buckets: a lookup first checks the key’s length, then compares with at most five keys of that length (
LengthBuckets.cs#L13). Other string keys are analyzed for the shortest substring that tells them apart, and only that substring is hashed. - Integer keys in a dense range, here 0 to 11, need no hashing at all: .NET 10’s
DenseIntegralFrozenDictionarystores the values in an array indexed by the key, as long as the range is at most ten times the number of keys (DenseIntegralFrozenDictionary.cs#L27).WithFullValuesis its nested class for a range with no gaps. - Other value types, like GA’s
PitchClassrecord struct or a tuple, get a general hash table that callsEqualityComparer<TKey>.Defaultdirectly, without a virtual call. Up to 10 keys, a linear search is used instead (Constants.cs#L32); 12 pitch classes are just above that.
Benchmarks/LookupBenchmarks.cs looks up twelve chord suffixes, two of them missing, in a Dictionary and in its frozen copy:
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|
| DictionaryTryGetValue | 45.45 ns | 0.928 ns | 2.310 ns | 45.94 ns | 1.00 | 0.07 | - | NA |
| FrozenDictionaryTryGetValue | 48.86 ns | 1.920 ns | 5.660 ns | 50.79 ns | 1.08 | 0.14 | - | NA |
For these twelve lookups, FrozenDictionary is not faster: 48.9 ns against 45.5 ns, a difference within its own error margin. The keys are short, and hashing them is already cheap. The frozen dictionary’s advantages, on keys where its analysis avoids hashing long strings, or on a large read-only table, don’t show up on a table of chord suffixes. Measure your own keys before switching.
GA’s pitch-class subtraction: a dictionary for arithmetic
Section titled “GA’s pitch-class subtraction: a dictionary for arithmetic”GA’s PitchClass is a number from 0 to 11. Its - operator looks up the difference in a FrozenDictionary of the 144 possible pairs, built once behind a Lazy (PitchClass.cs#L110-L137):
private static readonly Lazy<FrozenDictionary<(int, int), PitchClass>> _lazySubtractionDictionary = new(GetSubtractionDictionary);
public static PitchClass NormalizedSubtraction(PitchClass pitchClass1, PitchClass pitchClass2) => _lazySubtractionDictionary.Value[(pitchClass1.Value, pitchClass2.Value)];Each value in that table is computed with FromValue((pcValue1 - pcValue2 + 12) % 12): the dictionary caches a subtraction, an addition and a remainder. The program checks that the arithmetic gives the same 144 results, and that the lookup allocates nothing (Lesson4.cs#L113-L131):
== GA PitchClass subtraction: FrozenDictionary lookup versus arithmetic144 of 144 pairs agree; E - 1 = T, 1 - E = 2bytes allocated by one GA subtraction: 0PitchClassBenchmarks subtracts every pair of pitch classes, 144 subtractions per call, with GA’s operator, with the arithmetic, and with the flat array of exercise 2:
| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|
| GaOperatorMinus | 582.72 ns | 12.656 ns | 37.318 ns | 1.00 | 0.09 | - | NA |
| Arithmetic | 134.46 ns | 2.281 ns | 2.134 ns | 0.23 | 0.02 | - | NA |
| LookupTable | 41.51 ns | 0.417 ns | 0.390 ns | 0.07 | 0.00 | - | NA |
The dictionary lookup costs about 4 ns per subtraction, 583 ns for 144: it reads Lazy<T>.Value, builds a tuple key, hashes it, finds the bucket and compares. The arithmetic takes 0.9 ns: a division for the %, and the range check in PitchClass’s init accessor. The flat array takes 0.3 ns: a multiplication, an addition and a read. GA’s cache is 14 times slower than the table, and 4 times slower than the computation it caches. It allocates nothing, so a memory profiler would never point at it.
SIMD: Vector<T> and TensorPrimitives
Section titled “SIMD: Vector<T> and TensorPrimitives”GA compares voicings by the dot product of their embeddings, vectors of double. Its SimdOps.Dot uses Vector<T>, whose operations the JIT compiles to SIMD instructions (single instruction, multiple data), processing several elements at once:
public static double Dot(ReadOnlySpan<double> a, ReadOnlySpan<double> b){ var len = a.Length; var vsz = Vector<double>.Count; var i = 0; var acc = 0.0;
if (Vector.IsHardwareAccelerated && len >= vsz) { var vacc = Vector<double>.Zero; var last = len - len % vsz; for (; i < last; i += vsz) { var va = new Vector<double>(a.Slice(i, vsz)); var vb = new Vector<double>(b.Slice(i, vsz)); vacc += va * vb; }
acc += Vector.Dot(vacc, Vector<double>.One); }
for (; i < len; i++) { acc += a[i] * b[i]; }
return acc;}Vector<double>.Count is the number of doubles the processor handles at once: 4 with AVX2 on x64, 2 on Arm64. The loop multiplies the elements lane by lane, accumulates each lane separately, and adds the lanes at the end; a scalar tail handles the remaining elements. TensorPrimitives.Dot, from the System.Numerics.Tensors package, does the same job in the library. The program compares the three on 1,027 elements, a length that leaves a tail (Lesson4.cs#L133-L169):
== Vectorized dot products: GA SimdOps.Dot, TensorPrimitives.Dot and a scalar loopsmall integers: scalar 157.000000, SimdOps 157.000000, TensorPrimitives 157.000000; equal to 1e-9: Truesmall integers: bit-identical to the scalar loop: Truerandom doubles: scalar 3.010745, SimdOps 3.010745, TensorPrimitives 3.010745; equal to 1e-9: Truerandom doubles: bit-identical to the scalar loop: (depends on the vector width)SimdOps.Dot allocates nothing itself: 0 bytesThe results agree to 1e-9, but aren’t always bit-identical. Floating-point addition isn’t associative: adding the elements in four lanes and then summing the lanes rounds differently from adding them one by one. On small integers, which a double represents exactly, no rounding happens. The size of the difference depends on the processor:
# Vector.IsHardwareAccelerated True, Vector<double>.Count 4, Vector256 True, Vector512 False# random doubles: SimdOps - scalar = 1.15E-014, TensorPrimitives - scalar = 1.02E-014That’s the author’s machine. The Linux runner reported Vector512 True and a TensorPrimitives difference of 4.88E-015: TensorPrimitives uses 512-bit vectors when the processor has them, while Vector<double> stayed at 256 bits. The macOS runner, on Arm64, reported Vector<double>.Count 2 and 1.29E-014. A test that compares a floating-point result with a stored value must use a tolerance.
DotBenchmarks runs the three on 16 and 1,024 elements:
| Method | Length | Mean | Error | StdDev | Ratio | RatioSD |
|---|---|---|---|---|---|---|
| Scalar | 16 | 4.688 ns | 0.0540 ns | 0.0479 ns | 1.00 | 0.01 |
| GaSimdOps | 16 | 2.768 ns | 0.1335 ns | 0.3936 ns | 0.59 | 0.08 |
| TensorPrimitivesDot | 16 | 2.906 ns | 0.0970 ns | 0.2859 ns | 0.62 | 0.06 |
| Scalar | 1024 | 431.979 ns | 8.5494 ns | 9.1478 ns | 1.00 | 0.03 |
| GaSimdOps | 1024 | 132.389 ns | 3.2484 ns | 9.5780 ns | 0.31 | 0.02 |
| TensorPrimitivesDot | 1024 | 96.334 ns | 2.4099 ns | 7.1056 ns | 0.22 | 0.02 |
On 1,024 elements, GA’s SimdOps.Dot is 3.3 times faster than the scalar loop, and TensorPrimitives.Dot 4.5 times. The JIT doesn’t vectorize the scalar loop by itself: with double, reordering the additions would change the result, so each addition waits for the previous one. On 16 elements, both vectorized versions are about 1.7 times faster. For GA, replacing the body of SimdOps.Dot with a call to TensorPrimitives.Dot would be simpler and faster on this machine; the Arm64 timings haven’t been measured, to verify.
If you know Spring and Reactor
Section titled “If you know Spring and Reactor”Every difficulty of this lesson exists on the JVM, and JMH answers it with the same means. Its README makes the same promise BenchmarkDotNet does: “Do not assume that a nice harness will magically free you from considering benchmarking pitfalls. We only promise to make avoiding them easier, not avoiding them completely.”
| BenchmarkDotNet | JMH |
|---|---|
| one process per benchmark | @Fork, five measurement forks by default, because “JVMs are notoriously good at profile-guided optimizations”, and two benchmarks in one JVM mix their profiles |
| several runs to see the run-to-run variance | forks again: “JVMs are complex systems, and the non-determinism is inherent for them” |
| warmup iterations, then measurement iterations | @Warmup and @Measurement: five iterations of ten seconds each, by default |
[MemoryDiagnoser], allocated bytes per operation |
-prof gc, whose gc.alloc.rate.norm is bytes per operation |
| tier 0, then tier 1, with OSR for a loop that is already running | the interpreter, C1 and C2, with on-stack replacement, “converting an interpreted stack frame into a compiled stack frame” |
| dynamic PGO devirtualises an interface call | C2, the “highly optimizing bytecode compiler”, compiles from the profile the lower tiers collected |
DOTNET_TieredCompilation=0 to see what tiering was worth |
-XX:-TieredCompilation, documented as “By default, this option is enabled”, or -Xint for the interpreter alone |
| escape analysis puts a box on the stack | -XX:+DoEscapeAnalysis, also enabled by default, with scalar replacement |
SearchValues<T>, FrozenDictionary |
no equivalent API: Map.of is immutable but not re-optimised for lookups, and String.indexOf is an intrinsic |
Vector<T>, and TensorPrimitives above it |
the Vector API, in its twelfth incubation (JEP 537), which states it “will incubate until necessary features of Project Valhalla become available as preview features” |
Two things the JVM side adds. The garbage collector is one more source of variance: gc.alloc.rate.norm is nearly deterministic, while a timing on a heap that is filling up is not — which is the argument of lesson 2, made in a benchmark harness. And measuring a Reactor pipeline measures its scheduler hops as much as its work: a benchmark that assembles a chain and subscribes once mostly measures assembly, and StepVerifier.withVirtualTime removes the waiting but not the hops.
The last rule of this lesson holds on both sides. A timing belongs to the machine that produced it: JMH and BenchmarkDotNet both print the environment above the table for that reason, and neither number means anything on a CI runner.
Exercises
Section titled “Exercises”- Build a
FrozenDictionaryfrom the seven natural notes,"C"to"B", mapped to their index. Which implementation do you get, and why not length buckets? - Replace GA’s subtraction dictionary with a flat array of 144
PitchClassvalues indexed byleft * 12 + right. Check it against GA’s operator for every pair, measure its allocations, and add it toPitchClassBenchmarks. - On a chord chart of 100 bars followed by
"F#m7b5", compareIndexOfAny('#', 'b')withIndexOfAny(SearchValues). Do they return the same index? Benchmark them on the chart ofSearchBenchmarks.
Solutions
-
OrdinalStringFrozenDictionary_LeftJustifiedSingleChar. All seven keys have the same length, so length buckets would put seven keys in one bucket, more than the limit of five. The key analysis then finds that the first character alone tells the keys apart, and the dictionary hashes that single character instead of the whole string (Lesson4.cs#L27-L31).1. the 7 natural notes: OrdinalStringFrozenDictionary_LeftJustifiedSingleChar`1 -
The table is computed once; a subtraction is an index calculation and an array read (
Lesson4.cs#L38-L43):static readonly PitchClass[] DifferenceTable =[.. Enumerable.Range(0, 144).Select(i => PitchClass.FromValue((i / 12 - i % 12 + 12) % 12))];public static PitchClass SubtractWithTable(PitchClass left, PitchClass right) =>DifferenceTable[left.Value * 12 + right.Value];2. table of 144 PitchClass: agrees with GA True, 0 bytesIn
PitchClassBenchmarksabove, the table is theLookupTablerow: 41.5 ns for the 144 subtractions, 14 times faster than GA’s operator. -
Both return 1101, the position of the
#. For two or threechars,IndexOfAny(char, char)already has vectorized paths;SearchValuesadds a one-time analysis of the set, and pays off for larger sets, or sets that don’t fit those special cases.3. IndexOfAny('#', 'b') 1101, IndexOfAny(SearchValues) 1101In
SearchBenchmarksabove,ChartIndexOfAnyTwoCharsandChartSearchValuestake 167 ns and 162 ns on the long chart, within each other’s error margin.
Key takeaways
Section titled “Key takeaways”- BenchmarkDotNet runs each benchmark in its own process, finds an iteration length, subtracts the overhead, warms up, then measures. A stopwatch around a loop measures the JIT’s first tier.
- Tiered compilation and dynamic PGO are why code gets faster as it runs. PGO made an interface loop 9 times faster and removed its allocation; turning tiers off made it slower.
SearchValueswins on long inputs and loses on very short ones; for two or three characters,IndexOfAny(char, char)is as fast.ToFrozenDictionary()picks an implementation from the keys, and on short string keys it was no faster thanDictionary. Check what you get, and measure.- A dictionary that caches cheap arithmetic is slower than the arithmetic: GA’s pitch-class subtraction is 4 times slower than computing it, and 14 times slower than a flat array.
- SIMD sped up a dot product 3 to 4.5 times on 1,024 elements, and changes the last bits of floating-point results with the vector width. Prefer
TensorPrimitivesto a hand-writtenVector<T>loop. - Tie every timing to a machine, read the error margins and the warnings, and never compare timings in CI.
Sources
Section titled “Sources”- Microsoft Learn: Runtime configuration options for compilation, ReadyToRun compilation,
SearchValues<T>,FrozenDictionary<TKey, TValue>, Use SIMD-accelerated numeric types,TensorPrimitives. - BenchmarkDotNet: How it works, Good practices, Jobs.
- The .NET blog: Stephen Toub, Performance Improvements in .NET 10.
- dotnet/runtime at
v10.0.12(commit4271d88):clrconfigvalues.h,FrozenDictionary.cs,DenseIntegralFrozenDictionary.cs,LengthBuckets.cs. - Guitar Alchemist at
a826864:PitchClass.cs,SimdOps.cs.