Journal
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.
Progress
Section titled “Progress”- Course code: the lessons’ program, the decompiled examples, the rejected snippets and the benchmarks, compared with their expected output by
check.sh - CI on Linux, Windows and macOS, with a dry run of every benchmark
- Lesson 1: memory, values, references and spans
- Lesson 2: the garbage collector
- Lesson 3: async and await under the hood
- Lesson 4: measured performance
- Lesson 5: generics in depth
- A new outline in four parts and 24 lessons, with ASP.NET Core in depth and Spring and Reactor equivalents
- Lesson 6: channels
- Lesson 7: TPL Dataflow
- Lesson 8: Rx.NET
- Lesson 9: choosing a stream
- Lessons 10-14: shared state and the ASP.NET Core request path
- Lesson 15: hosted services and background work
- Lesson 16: authentication and authorization
- Lesson 17: a bounded Petri specification oracle translated into deterministic Channel, TPL Dataflow and Rx test design
- Appendix 1: five GA members optimised, proved on all 4096 pitch-class sets, then measured — with the benchmarks rewritten once they turned out to be measuring the JIT
- Appendix 2: GA’s indexing pipeline profiled, three changes proved against GA’s own output and measured, and sent upstream as pull requests
- Appendix 4: plugins without MEF — four modules, each in its own collectible context, one container, seven scenarios predicted then measured
This course profiles GuitarAlchemist/ga, a public repository, and runs on .NET 10 and BenchmarkDotNet — so everything below is somebody else’s code, even where the author of GA and the author of the course are the same person. Three rows went upstream as pull requests and were merged; the rest were never reported, and the Status column says which is which.
| Expected | What happens | Where | Measure | Status |
|---|---|---|---|---|
| Recognising a chord for each voicing costs what one recognition costs | 667,125 voicings use about 2,500 distinct pitch-class sets, so each set’s pattern search was repeated about 266 times | CanonicalChordRecognizer.IdentifyChordSet |
dotnet-trace put about 52% of the main thread there, most of it building HashSets. With a 4096-slot cache, VoicingAnalyzer.Analyze went from 234.34 ms and 759 MB to 8.94 ms and 22 MB |
Fixed upstream, PR #695 2026-09-17 |
| The interval-class vector is computed once per set | It was recomputed per call | GA’s atonal theory | Part of the same analysis path; the dump of 8,192 vectors is byte-identical before and after | Fixed upstream, PR #694 2026-09-17 |
OptickIndexReader.Dimension is read once |
It was read per query, through a property running a LINQ Where + Sum over the partition registry |
OptickIndexReader.Dimension |
The 38 MB per query attributed to the search were allocated here, not by Parallel.For: a sequential scan allocated the same 38 MB |
Fixed upstream, PR #693 2026-09-17 |
| The OPTIC-K index export is reproducible from one commit | The first export of the evening differs from the later ones in 266 of 313,047 entries, from the same GA commit | OPTIC-K export | 266 entries of 313,047, keyed by instrument and diagram | Fixed upstream by #730, merged 2026-09-24. The cause was the parallel voicing generator, whose output order followed thread scheduling; two guitar exports of GA’s main hashed differently before, identically after 2026-09-17, 2026-09-24 |
VoicingGenerator’s parallel path is faster than its sequential one |
It was twice as slow: a single consumer built every diagram and dropped the duplicates | VoicingGenerator.cs |
1,419 ms against 711 ms for 667,125 voicings | Fixed upstream by #730: the producers do that work, 649-679 ms against 786-861 ms sequential, measured for the PR; not re-measured in this course 2026-09-17, 2026-09-24 |
PitchClassSet.GetCompatibleKeys is cheap enough to call per voicing |
It rebuilt the 30 keys and their pitch-class sets on every call | PitchClassSet.cs |
13.5% of the analysis profile; 180.5 ms and 110,116 bytes per call over 4,096 calls | Fixed upstream by #730: a subset test on 12-bit masks, computed once per set, 0.1 ms and 0 bytes; not re-measured in this course 2026-09-17, 2026-09-24 |
ValueObjectUtils<TSelf>.Items returns the collection it holds |
It creates a new 32-byte ValueObjectCollection<TSelf> on each call |
ValueObjectUtils.cs |
32 bytes per call | Fixed upstream by #730: one immutable collection, 0 bytes per read 2026-09-16, 2026-09-24 |
IRangeValueObject<TSelf>.EnsureValueInRange normalizes over the real range |
It normalizes with a range size computed off by one | IRangeValueObject.cs |
Wrong value at the boundary | Fixed upstream in #598, merged 2026-09-24, without a test; #731 adds one, which fails on the old code 2026-09-16, 2026-09-24 |
default(Str) gives a valid value object |
It gives string 0, which Str’s own range check forbids — as does new Str[n] and new T() in a generic method |
Every GA value object whose minimum is 1 | Lesson 5 shows the three ways in | Reproduced, a limit of the pattern rather than a defect 2026-09-16 |
The out of IStaticReadonlyCollection<out TSelf> buys variance |
It buys nothing: 13 of its 17 implementers are structs, and the interface has no instance members | IStaticReadonlyCollection<out TSelf> |
13 of 17 | Reproduced, harmless 2026-09-16 |
ValueObjectCache<T> builds what its callers use |
It builds two FrozenSets at first use that nothing reads |
ValueObjectCache.cs |
Two sets built, zero readers | Fixed upstream by #730: the two sets are built on first use 2026-09-16, 2026-09-24 |
| A benchmark’s warmup measures what its iterations measure | The same code measured 31 ns in warmup and 41 to 52 ns in the iterations | BenchmarkDotNet, VoicingWithSpans |
Pinned to one core with --affinity it measured 32.4 ns, on the first core as on the last: the Core Ultra 9 285K’s cores are not alike |
Understood, and it is why the lesson pins the core 2026-09-14 |
Adding [MemoryDiagnoser] to a running series adds its column |
Without a rebuild, the run used the previous build and printed no Allocated column, silently |
BenchmarkDotNet | No column, no warning | Reproduced, user error made invisible by the tool 2026-09-14 |
| Dynamic PGO removes the enumerator allocation as it does in lesson 4 | It did not: the first mask version still allocated 72 bytes per call, from enumerators obtained through IEnumerable<int> |
.NET 10 dynamic PGO | Switching to int[] and HashSet<int> removed the 72 bytes and made it 2.7 times faster again |
Reproduced; why lesson 4’s case devirtualises and this one does not is still open 2026-09-17 |
PitchClassSetId.ItemsSpan returns a view of the ids |
It copied the 4,096 ids into a new array on every call: Items is a compiler-generated read-only list, so the is PitchClassSetId[] test was always false |
PitchClassSetId.cs#L59-L71 |
16,408 bytes per call | Fixed upstream by #687, merged 2026-09-20; not re-measured 2026-09-14, 2026-09-24 |
PitchClass’s - operator costs about what (a - b + 12) % 12 costs |
It looked the result up in a FrozenDictionary of 144 tuples |
PitchClass.cs#L110-L137 |
583 ns for the 144 pairs, against 134 ns for the arithmetic and 41.5 ns for a flat array | Fixed upstream by #687 with a flat 144-entry table; not re-measured 2026-09-14, 2026-09-24 |
Try.OfAsync can be blocked on, and cancelled |
It awaited without ConfigureAwait(false), so blocking on it under a single-threaded synchronization context deadlocked, and it turned OperationCanceledException into an ordinary failure |
Try.cs#L62-L73 |
Shown in lesson 3 | Fixed upstream by #687 2026-09-14, 2026-09-24 |
LazyWithExpiration<T> costs nothing while it waits |
It parked one thread-pool thread in Thread.Sleep per value |
LazyWithExpiration.cs#L20-L40 |
64 values expiring after one second delayed an unrelated Task.Run by 2 s on my machine and 10 to 12 s on the 3- and 4-core runners |
Fixed upstream by #687: the expiration is a timestamp checked on access 2026-09-14, 2026-09-24 |
Experiments
Section titled “Experiments”Performance work invites reading a prediction back out of its own result, so these rows keep the hypothesis as it was written and let it be wrong. Two of the first six are refuted, and appendix 2’s method — comparing two builds of GA rather than two methods, and checking that every answer is byte-identical — is itself the third row. The five rows of appendix 4 all held, including the last one, written to test an explanation rather than a behaviour.
| Question | Hypothesis | Result | Verdict | Where |
|---|---|---|---|---|
Is FrozenDictionary faster than Dictionary for twelve short chord suffixes? |
Written in advance: yes, that is what it is for | It was not faster | Refuted | 2026-09-14 |
Is SearchValues faster than a loop over chord symbols of 1 to 6 characters? |
Written in advance: yes | 1.7 times slower | Refuted | 2026-09-14 |
Does a build with the analysis changes give byte-identical answers to main? |
Written in advance, and it is the whole method: the proof compares two builds of GA, not two methods | Identical on all five dumps: TryMatch 258,048 lines, recognition 106,496, interval-class vectors 8,192, all 667,125 voicings, 2,048 searches on the real 313,047-entry index |
Confirmed | 2026-09-17 |
Were the search’s 38 MB per query caused by Parallel.For? |
Written in advance: parallelism was the suspect | A sequential scan allocated the same 38 MB, which cleared Parallel.For and pointed at GetVector |
Refuted | 2026-09-17 |
| Does range partitioning of the search survive the allocation fix? | Not written as a prediction: it measured 8% faster before the fix, so it looked worth keeping | 22% slower after it. Dropped | Refuted | 2026-09-17 |
| Does the whole OPTIC-K export get faster end to end, not just the benchmark? | Written in advance: the analysis is most of the export | Four runs alternating main and the changed build: 142.8 s against 62.9 s, then 95.0 s against 38.3 s. The same main binary took 142.8 s then 95.0 s, so the machine was not idle and the pairs, not the absolute times, are the result |
Confirmed | 2026-09-17 |
| Can two modules run two versions of Newtonsoft.Json in one process? | Not written as a prediction: seen in a prototype first | 13.0.3 and 13.0.1 side by side, both with assembly version 13.0.0.0 | Confirmed | 2026-10-22, Plugins/ |
| Can module B use a service of module A without referencing A? | Written in advance: yes if the interface lives in the contracts; without A, ValidateOnBuild fails when the container is built |
B reverses A’s spelling; without A, “Unable to resolve service for type ‘Contracts.IChordSpeller’” from BuildServiceProvider |
Confirmed | 2026-10-22 |
| Does one assembly loaded in two contexts give one type? | Written in advance: no, two types with one name, and one shared IModule |
Same name True, same type False; the IModule both implement is the host’s |
Confirmed | 2026-10-22 |
Does a module whose tool runs a Timer still unload? |
Written in advance: yes once the container is disposed, never when it is dropped | 1 of 1 disposed; 0 of 1 dropped, after 100 collections, in every run. The control without a timer: 2 of 2 dropped | Confirmed | 2026-10-22 |
| Is it what the timer’s callback captures that holds module C? | Written after the previous row’s result, before module D’s code: a static callback lets the module unload, since an active Timer can be collected |
Module D unloads, 1 of 1, but within ten back-to-back collections only 36 times in 40; with 10 ms between collections, three every time | Confirmed | 2026-10-22 |
2026-09-14 — Setup and the GA pin
Section titled “2026-09-14 — Setup and the GA pin”- The course builds against three GA projects,
GA.Core,GA.Domain.CoreandGA.Business.Config, fetched byfetch-ga.shfrom commita826864, the head of GA’smainthat day, with a blobless, sparse clone: about 11 MB instead of the whole repository. It’s the same commit and the same script as the music theory course, so both courses read the same code. - My machine has the .NET 10.0.112 SDK and an 11.0 preview. The course’s
global.jsonpins10.0.100withrollForward: latestFeature, sodotnetpicks 10.0.112 there and the preview elsewhere. GA.Corereferences theMicrosoft.Net.Compilers.Toolsetpackage in version 4.11.0: that project is compiled by the Roslyn 4.11 of the package, not by the SDK’s Roslyn 5.0. It builds fine; I haven’t looked for a difference in the IL it produces, to verify.ilspycmd11.0.0.9375 is a local tool, restored bycheck.sh. Two surprises:-ildisassembles the whole assembly and ignores-t Type, socheck.shdisassembles theSnippetsassembly once; and the IL contains// Method begins at RVA 0x…comments, which change with every edit of an unrelated method, socheck.shremoves them before comparing.- Decompiling with
-lv CSharp4shows the async state machine: at C# 4 language level, ILSpy can’t turn it back intoawait.
2026-09-14 — Checking compiler errors
Section titled “2026-09-14 — Checking compiler errors”- The rejected snippets are compiled in memory by a small Roslyn program,
CompileFail, rather than one project per snippet: one build instead of eleven. It adds the SDK’s implicitusings as a separate syntax tree and references the runtime’s assemblies. Its messages are identical to those ofdotnet build. - CS4007 was missing at first: the checker called
compilation.GetDiagnostics(), and aSpan<int>used across anawaitcompiled without error. That error is reported only while the compiler rewrites the method into a state machine, whichGetDiagnostics()doesn’t do. The checker now callscompilation.Emit(Stream.Null)and reads its diagnostics. - I expected CS8175 for a span captured by a lambda, from memory of older compilers; Roslyn 5.0 reports CS9108 (“Cannot use parameter ‘frets’ that has ref-like type inside an anonymous method…”). Lesson 1 quotes the real one.
- The CS8425 snippet produces a warning, not an error: the checker expects it and fails if it’s missing.
2026-09-14 — What the runtime did that the IL doesn’t say
Section titled “2026-09-14 — What the runtime did that the IL doesn’t say”- Boxing removed by the JIT. A
boxfollowed byunbox.anyof the same type allocates nothing, even in tier 0. WithDOTNET_TieredCompilation=0, a box that doesn’t escape the method is allocated on the stack, including when the value is cast to an interface: the object stack allocation of .NET 9, extended in .NET 10.check.shruns lesson 1 both ways. - The large object heap threshold is checked on the size before rounding:
byte[84,975]costs 85,000 bytes and stays in generation 0,byte[84,976]costs the same and goes to the LOH (lesson 2). - Literals are on a frozen heap:
GC.GetGeneration("C major")andGC.GetGeneration(typeof(PitchClass))returnint.MaxValue. - A first version of the allocation-budget loop of lesson 2 kept its arrays in a local variable, and the JIT could allocate them on the stack after OSR, leaving no garbage to collect. The arrays now escape into a ring of 16.
Task.WhenAllkeeps the inner exceptions in the order the tasks failed, which changes between runs: the program sorts them, and prints the actual order on a machine-dependent line.SearchValues.Create("#b")returnsAny2CharPackedSearchValueson x64 andAny2SearchValues`2on Arm64 (the macOS runner): another machine-dependent line.- The gen0 budget depends on the machine’s cache size: 18 MB on my machine, 16 MB on the Linux runner, 24 MB on the Windows runner and 6 MB on the macOS runner, where the same 32 MB of garbage caused five collections instead of one.
2026-09-14 — Benchmarks
Section titled “2026-09-14 — Benchmarks”- BenchmarkDotNet 0.15.8. Machine: Intel Core Ultra 9 285K (24 cores), 64 GB, Windows 11 25H2, .NET SDK 10.0.112, runtime 10.0.12. BenchmarkDotNet switched the Windows power plan to High performance for each run and back afterwards.
- CI runs every benchmark with
--job Dry. The first dry run took 8 min 30 s:JitBenchmarksdeclares its four jobs in a config, and--job Dryadded a dry job to them instead of replacing them. The config now usesJob.DrywhenBENCHMARKS_DRY=1, and the whole dry run takes about 30 s locally, 17 to 53 s on the runners. - The full run of the seven classes, one after the other with nothing else running, took about 45 minutes.
JitBenchmarksalone runs four jobs and took 4 minutes. - The same code measured 31 ns in warmup and 41 to 52 ns in the actual iterations (
VoicingWithSpans). Pinned to one core with--affinity, it measured 32.4 ns, on the first core as on the last. The Core Ultra 9 285K has performance and efficiency cores; I haven’t checked which kind those two cores are, nor confirmed that thread migration explains the slower iterations, to verify. Lesson 4 tells the story and keeps the unpinned tables, since that’s what a reader will get by default. - I added
[MemoryDiagnoser]toJitBenchmarkswhile the other classes were running, without rebuilding: the run used the previous build and printed noAllocatedcolumn. Rebuilt and rerun, it showed the result lesson 4 is built on: with PGO, the loop overIEnumerable<int>no longer allocates its 40-byte enumerator, and runs 9 times faster. - Two expectations of mine were wrong:
FrozenDictionarywas no faster thanDictionaryon twelve short chord suffixes, andSearchValueswas 1.7 times slower than a loop on chord symbols of 1 to 6 characters. Both are in lesson 4 as measured.
2026-09-14 — Dogfooding: what the lessons found in GA
Section titled “2026-09-14 — Dogfooding: what the lessons found in GA”None of these has been reported upstream yet; they are listed for the GA maintainers to decide on.
PitchClassSetId.ItemsSpancopies the 4,096 ids into a new array on every call, 16,408 bytes.Itemsis declared asIReadOnlyCollection<PitchClassSetId>and initialized with a collection expression, so the compiler creates a<>z__ReadOnlyList<PitchClassSetId>, and theis PitchClassSetId[]test that should avoid the copy is always false (lessons 1 and 2).PitchClass’s-operator looks up aFrozenDictionaryof 144 tuples to compute(a - b + 12) % 12. It takes 583 ns for the 144 pairs, against 134 ns for the arithmetic and 41.5 ns for a flat array of 144 values (lesson 4).Try.OfAsyncawaits withoutConfigureAwait(false), so blocking on it under a single-threaded synchronization context deadlocks, and it catchesOperationCanceledException, turning a cancellation into an ordinary failure (lesson 3).LazyWithExpiration<T>measures its expiration with aThread.Sleepon a thread-pool thread, one per value. With 64 values expiring after one second, an unrelatedTask.Runwaited 2 s on my machine and 10 to 12 s on the 3- and 4-core runners (lesson 3). I haven’t looked for the places where GA uses it, to verify.
2026-09-14 — CI
Section titled “2026-09-14 — CI”- Commit
d85a319, run 34915741516: green on the three OSes, lessons’ code only. - Commit
8ba378e, run 34919815590: the exercise solutions, green on the three OSes. Jobs took 1 min 23 s on Linux, 2 min on macOS and 3 min 14 s on Windows, of which the dry run of the 37 benchmarks took 23 s, 27 s and 53 s. About 14 s of each job is lesson 3’sLazyWithExpirationdemonstration: aTask.Runwaited 11,001 ms on Linux, 11,766 ms on macOS and 11,050 ms on Windows, where the previous run had measured 9,878 ms. - The Windows runner reported the same vector widths and the same floating-point differences as my machine; the Linux runner has AVX-512.
2026-09-15 — A new outline, and part 2 starts
Section titled “2026-09-15 — A new outline, and part 2 starts”- The course grows from 12 to 24 lessons in four parts: runtime and performance, concurrency and data flow, ASP.NET Core in depth, metaprogramming and tooling. Lessons 1 to 4 keep their slugs; the old lessons 6 and 12 become lessons 10 and 19, and lessons 2 and 3 now point there. Lessons 6 to 9 were written before lesson 5.
- Parts 2 and 3 compare each topic with Spring and Reactor, from C# to Java. The Spring Boot, Spring Cloud and Reactor course makes the comparison in the other direction, so the lessons link to its pages instead of explaining Reactor again, and part 3 will build the ASP.NET Core counterpart of its scales service.
2026-09-15 — Making concurrent programs deterministic
Section titled “2026-09-15 — Making concurrent programs deterministic”Every behaviour in lessons 6 to 9 is printed by the program and compared with expected/, on three OSes. A first version of each lesson printed something that changed between runs; each lesson ran at least nine times in a row before its output was committed.
- Gates, not delays. Items are held with a
TaskCompletionSourceuntil the program has seen what it wants to show, and “the producer is stuck” is measured by waiting until a counter stops moving, then printing the counter. - Continuations run when they like. A
WriteAsync(...).AsTask()that had completed still reportedIsCompletedfalse on some runs, because its continuation is asynchronous: the program now awaits it. The same withFaulton a Dataflow block, whoseCompletion.Exceptionwas stillnullright after the call. EnsureOrdered = falsedoesn’t mean “reversed”. A first test expected the slow item 0 to come out last; it didn’t in 8 runs of 20. Lesson 7 now shows what a consumer can receive while item 0 runs.- Boundaries in virtual time. Notes at exactly 500 or 1,000 ms fell on the edge of
SampleandBufferwindows; lesson 8’s notes are placed away from them. Reader.CountthrowsNotSupportedExceptionon an unbounded channel created withSingleReader = true: itsCanCountisfalse. The program drains the channel to count what’s left.- Rx.NET 7.0 has no bridge to
IAsyncEnumerable:ToAsyncEnumerable()on an observable didn’t compile. Lesson 9 writes both directions by hand. - Lesson 7’s first version labelled Dm7 as Forte 4-3, from GA’s
ProgrammaticForteCatalog; Forte’s table says 4-26. The lesson now takes the labels fromCanonicalForteCatalogand prints both. - Commits
69bb214andb4d713f, runs 34994143077 and 34995561655: green on the three OSes. - The stream benchmark of lesson 9 ran alone for 5 minutes on the same machine as lesson 4. BenchmarkDotNet reported a bimodal distribution for
RxObserveOnTaskPool.
2026-09-15 — Dogfooding: channels, Dataflow and Rx in GA
Section titled “2026-09-15 — Dogfooding: channels, Dataflow and Rx in GA”GA uses channels in its voicing generator and its index command, and TPL Dataflow and Rx.NET in a performance demo. None of these has been reported upstream yet.
VoicingGenerator.GenerateAllVoicingsAsync: a consumer that stops early, like the usage example’s.Take(100), leaves the producers generating every window into an unbounded channel; a window that throws leaves the consumer waiting forever, becauseWriter.Complete()is never reached; the summary says the order is preserved, the comments below it say it isn’t (lessons 6 and 9).IndexVoicingsCommand: when the consumer fails, the database being down for example, it logs and returns, and the producers wait forever on the full channel (lesson 6). The bounded channel inWaitmode is the right choice.PerformanceOptimizationDemo: the Dataflow part counts results from aList<T>filled by another thread without awaiting that thread, and ignoresSendAsync’s result (lesson 7); the Rx part counts batches as events, 10 instead of 1,000, and waits a fixed 500 ms instead of awaiting the pipeline (lesson 8). Its channel part is correct.- The same demo and
MusicalAnalysisApp, bothnet10.0, reference theSystem.Threading.Tasks.Dataflow9.0.10 package, which .NET 10 already has in its shared framework. A newnet10.0project with the same reference gets warning NU1510 at restore, and loads the framework’s assembly anyway. ProgrammaticForteCatalog’s numbers follow another ordering than Forte’s table, although its remarks call the differences minor: 4-3 for the minor seventh chord where Forte says 4-26 (lesson 7).- GA has several
BackgroundServiceclasses, among them the cache warming and the voicing index initialization; lesson 15 is the place to read them.
2026-09-15 — Appendix 1: optimising GA, with the proof first
Section titled “2026-09-15 — Appendix 1: optimising GA, with the proof first”Lesson 4 measured GA’s code as it is. This appendix rewrites three members of it, and the interesting part turned out not to be the speed-up but what the rewrite is not allowed to change.
- The three are
PitchClassSetId.IsClusterFree,PitchClassSet.IntervalClassVectorandPitchClassSet.ClosestDiatonicKey. They all take a 12-bit set, so the whole input domain is 4096 values:Advanced -- a1compares each rewrite with GA’s own answer for every one of them, and CI runs that on three OSes. Writing the proof before the benchmark changed what I was willing to claim. IntervalClassVectorIdpacks six counts as base-12 digits, and the chromatic aggregate’s counts of 12 carry. GA documents it as a known limitation. Packing it correctly would change set 4095’s id, andProgrammaticForteCatalogorders each cardinality by that id, so every Forte number could move — the fast version reproduces the carry instead. A “fix” smuggled into a performance change is the thing this appendix is about.ClosestDiatonicKey’s answer depends onOrderByDescendingbeing stable: ties fall to whichever keyKey.Itemslists first, the 15 major ones before the 15 minor ones. A loop that replaced its incumbent on>=rather than>would quietly return a different key on every tie; the one that replaces on>agrees with GA on all 4096 sets.- The first benchmarks were wrong, and flattering. Calling each member once on a
constargument let the JIT fold the call into a literal: the fastIsClusterFreecame out at 0.0107 ns, a twenty-fifth of a cycle. A mutable static removed the folding and still gave a median of zero, because BenchmarkDotNet subtracts an empty method. Rewritten to sweep all 4096 sets,IsClusterFreeis 4 times faster, not 31 — and I would have published the 31. - The 175 KB is the finding, not the microseconds.
IdentifyClosestKeyreceives aDictionary<Key, IReadOnlyCollection<PitchClass>>and destructures it asforeach (var (key, _) in items): the values are built for all 30 keys and never read. No profiler was needed — just reading the method. ToNormalFormandPrimeFormare done too, so the appendix now proves five members rather than three. Tabulating the normal form took the last 16 KB out of the fastClosestDiatonicKey, which now allocates nothing: 1,615 times faster, 175 KB per call gone.PrimeForm, which was already bit arithmetic, only gave 1.8 — the honest ceiling on rewriting correct arithmetic, and worth having next to the 1,615.- None of this has been reported upstream.
2026-09-16 — Lesson 5: generics in depth
Section titled “2026-09-16 — Lesson 5: generics in depth”- Lesson 5 fills the gap between lessons 4 and 6, which were written first. Its outputs come from the same machine and the same SDK as the rest of the course; code commit
378eec1. - CI run 35173274425: green on Linux, Windows and macOS. The machine-dependent lines were the same on the three runners as on my machine, the Arm64 one included: 4,592 bytes for the first access to
Str’s cache, and 1 then 0 methods compiled forShared<string>thenShared<object>. - The JIT’s own summary as a test.
DOTNET_JitDisasmSummary=1andDOTNET_JitStdOutFilework in the shipped runtime, socheck.shcompares the list ofShared<T>.Describecompilations: four for six type arguments, one of them overSystem.__Canon. It startsAdvanced.dlldirectly: throughdotnet run, the SDK’s own process would inherit the variables and write to the same file. - What the runtime doesn’t check.
MakeGenericMethodaccepted(int, string)for awhere T : unmanagedparameter; the compiler rejects it with CS8377.notnullleaves nothing inGenericParameterAttributes. - A snippet I expected to fail compiled. GA’s
IStaticReadonlyCollectionFromValues<TSelf>hides the static abstractItemswith anew staticproperty that has a body. I expectedT.Itemson a type parameter constrained to it to be rejected; Roslyn 5.0 compiled it. I dropped the snippet; which member that call binds to is to verify. - The disassembly changed my reading of the benchmark. In shared code, reading a static field of
Counter<T>callsCORINFO_HELP_GET_NONGCSTATIC_BASEon every iteration, at tier 1 as with tiering off, yet the loop was only 1.46 times slower than theintversion. - PGO again. A
foreachoverPitchClass.Itemsallocates 72 bytes in the program’s first calls and 40 bytes in the benchmark, after dynamic PGO.
2026-09-16 — Dogfooding: GA’s value-object interfaces
Section titled “2026-09-16 — Dogfooding: GA’s value-object interfaces”None of these has been reported upstream yet.
ValueObjectUtils<TSelf>.Itemscreates a new 32-byteValueObjectCollection<TSelf>on every read, although the interface documentsItemsas memoized; aforeachover it allocates 72 bytes (lesson 5).Valuesis declaredIReadOnlyList<int>inIStaticValueObjectList<TSelf>, and the implementers return anImmutableArray<int>: 24 bytes of boxing per read. Twelve reads took 41.8 ns and 288 bytes, against 3.1 ns and nothing through theImmutableArray(lesson 5).ValueObjectCache<T>builds twoFrozenSets at its first use, which nothing in the three fetched projects reads: 4,592 bytes forStr’s 26 values (lesson 5).IRangeValueObject<TSelf>.EnsureValueInRangenormalizes with a range size ofmax - mininstead ofmax - min + 1: 12 becomes 2 for a pitch class. No caller in the fetched projects passesnormalize: true, so it’s latent (lesson 5).default(Str),new Str[n]andnew T()give string 0, whichStr’s range check forbids; the same holds for every GA value object whose minimum is 1 (lesson 5).- The
outofIStaticReadonlyCollection<out TSelf>has no effect: 13 of its 17 implementers are structs, and the interface has no instance members (lesson 5).
2026-09-17 — Appendix 2: profiling GA, with pull requests
Section titled “2026-09-17 — Appendix 2: profiling GA, with pull requests”Appendix 1 chose what to optimise by reading GA. This time a profiler chose, on GA’s own pipeline at commit 66bdd04, and each change went upstream. The work ran from the evening of 2026-09-16 to the early hours of 2026-09-17.
-
Three pull requests.
- #695: chord recognition with 12-bit masks, computed once per pitch-class set.
- #694: the interval-class vector computed once per set.
- #693:
OptickIndexReader.Dimensionread once.
Each was made on its own branch from
main. The GA tests passed before each PR was opened: GA.Domain.Core.Tests 458/458 and 459/459, and the chord and voicing tests of GA.Business.Core.Tests 421/421 and 419/419. For GA.Business.ML.Tests, the search and schema tests passed 227 of 228; the failing test fails the same way onmain. -
Where the time went.
dotnet-traceover voicing analysis put about 52% of the main thread inCanonicalChordRecognizer.IdentifyChordSet, most of it buildingHashSets. A probe putVoicingAnalyzer.Analyzeat 173 µs and 347 KB per voicing, and an OPTIC-K search at 38 MB per query. -
The proof compares two builds of GA, not two methods. One dump program is built against
mainand against the branch, writes every answer, andcmpcompares the files. All were byte-identical:TryMatch: 258,048 lines;- recognition: 106,496 lines, every set × 13 basses × 2 passes;
- interval-class vectors: 8,192 lines;
- all 667,125 guitar voicings through
VoicingAnalyzer.Analyze; - 2,048 searches on the real 313,047-entry index.
The course’s own proof,
GaPerf -- a2, checks 6,856,704TryMatchcalls and 106,496 recognitions, and runs in CI. -
The cache was the finding, not the masks. 667,125 voicings use about 2,500 distinct pitch-class sets, so each set’s pattern search was repeated 266 times. With a 4096-slot array, recognition is 7,600 times faster in the course’s benchmark. In GA,
VoicingAnalyzer.Analyzewent from 234.34 ms and 759 MB to 8.94 ms and 22 MB for 2,000 voicings with both changes. -
The search’s 38 MB were not in the search. An experiment with a sequential scan allocated the same 38 MB as the parallel one, which cleared
Parallel.For. The bytes came fromGetVector, which read a property that runs a LINQWhere+Sumover the partition registry: 626,094 queries per search. Read once, one search went from 4.986 ms and 38.25 MB to 2.162 ms and 41 KB. -
Two results I didn’t expect.
- The first mask version still allocated 72 bytes per call, from enumerators obtained through
IEnumerable<int>. Switching onint[]andHashSet<int>removed them and made it 2.7 times faster again. Dynamic PGO didn’t remove this allocation, unlike the one in lesson 4; why not is to verify. - Range partitioning of the search, which looked 8% faster before the fix, was 22% slower after it. I dropped it.
- The first mask version still allocated 72 bytes per call, from enumerators obtained through
-
End to end, the OPTIC-K export ran four times, alternating GA’s
mainand a build with both analysis changes: 142.8 s against 62.9 s, then 95.0 s against 38.3 s. The index files were identical entry by entry. -
The machine was not idle, and the numbers say so.
- Other sessions were running. Every build and benchmark series took a machine-wide lock, and each BenchmarkDotNet series also took the GPU lock.
- Free RAM and the busiest processes were logged at the start of each series: 13.6 to 18.5 GB free, 32% to 100% total CPU, with Microsoft Defender, Docker and WSL on top.
- The same export binary took 142.8 s, then 95.0 s.
- The coordinator reported two headless Chrome captures by another session between 00:47 and 00:49. None of my series ran in that window: the benchmarks before it ended at 00:42:38, and the next one started at 00:50:22. The GA series that had been running just before were rerun anyway, with the same allocations and times within the error bars.
- GA’s before-and-after tables use
ShortRun. Their allocations are exact; the times are indicative. - One run of the chord series did not happen: my script pointed at a worktree that didn’t exist, and it was rerun.
-
Measured and not changed.
VoicingGenerator’s parallel path is twice as slow as its sequential one (1,419 ms against 711 ms for 667,125 voicings), andPitchClassSet.GetCompatibleKeystakes 13.5% of the analysis profile. Both files belong to GA fixes in progress in another session, so these went there as proposals.KeyIdentificationService.Identifyis not on the hot path: 25 µs, once per request.- Lesson 5’s value-object allocations don’t appear in this profile.
-
The index export is not always reproducible. The first export of the evening differs from the later ones in 266 of 313,047 entries, keyed by instrument and diagram, although it came from the same GA commit. Every comparison above is between exports of the same group; the cause is to verify.
2026-09-21 — Lessons 10-14: concurrency and the ASP.NET Core request path
Section titled “2026-09-21 — Lessons 10-14: concurrency and the ASP.NET Core request path”- Added five executable lessons against .NET 10.0.12: one deterministic lost-update schedule,
LockandInterlocked,ConcurrentDictionary, and a boundedParallel.ForEachAsyncprobe. - Started real Kestrel instances on ephemeral loopback ports. Verified the host lifecycle, one successful request, graceful shutdown, middleware nesting, and a deliberate
429short-circuit. - Verified singleton/scoped identity, captive-dependency rejection with scope validation, keyed services, and options validation.
- Verified one Minimal API with route/query binding and an endpoint filter, one controller in the same routing table, and an
IAsyncEnumerable<string>JSON response. - The portable output is checked in
expected/l10.txtthroughexpected/l14.txt. Thread-pool minimums remain machine-dependent evidence and are excluded with the existing#convention. - These are local protocol probes. They do not yet measure proxy buffering, production Kestrel limits, a real identity provider, or a remote dependency under thread-pool starvation.
2026-09-21 — Lessons 15-16: hosted work and local authorization
Section titled “2026-09-21 — Lessons 15-16: hosted work and local authorization”- The hosted-service proof uses completion gates rather than delays. One bounded channel feeds a singleton worker, and two operations resolve different scoped-service instances (
scope-1andscope-2). Host cancellation is observed while the worker is waiting for more work. - A second host releases a deliberate worker fault and observes
ApplicationStoppingunderBackgroundServiceExceptionBehavior.StopHost. This proves the host policy, not retry or recovery. - The authentication proof starts Kestrel on an ephemeral loopback port. Missing, malformed, expired and wrongly signed bearer tokens return 401; a valid identity without
scope=scales.readreturns 403; the authorized identity returns200 C major. - Both signing keys are generated in memory for one process. The lifetime decision uses a fixed course instant, and no key or token is printed or persisted.
Advancedbuilt locally with zero warnings, and both new transcripts matchedexpected/l15.txtandexpected/l16.txt. Three-OS CI, a real authorization server, key discovery/rotation and deployment behind a proxy remain to verify.
2026-09-21 — Lesson 17: Petri oracles for C# pipeline tests
Section titled “2026-09-21 — Lesson 17: Petri oracles for C# pipeline tests”- Reused the executable one-item, one-slot lifecycle from Petri lesson 14 rather than creating a second model. Its focused suite explores all eight reachable markings, classifies the three terminal dead markings and checks the Channel queue invariant
free + queued = 1. - Recorded the boundary between the model and the runtime. The Petri tests validate the finite specification; they do not execute
Channel<T>, TPL Dataflow or Rx.NET and therefore do not prove the current GA implementation. - Turned each model path into a deterministic runtime-test recipe based on gates rather than delays. The recipe requires the public exception or completion result and settlement of every owned participant.
- Kept capacity semantics separate: Channel capacity counts queued items in this model, Dataflow execution-block capacity includes the running item, and Rx has no bound until the design adds one.
- Documented two deliberate limits: cancellation is atomic and pre-start, and the one-item net cannot reproduce a producer already blocked by backpressure after consumer failure. A two-item model and runtime fixtures remain to verify.
2026-09-24 — Upstream fixes checked again
Section titled “2026-09-24 — Upstream fixes checked again”- The four findings of lessons 1-4 were fixed upstream by #687, merged 2026-09-20, each with a test that fails on the old code:
ItemsSpanreturns its backing array,PitchClass’s-reads a flat 144-entry table (about 42 ns in this course’s benchmark, according to the PR),Try.OfAsyncusesConfigureAwait(false)and lets cancellation propagate, andLazyWithExpiration<T>checks a timestamp on access. Checked in the source at GA27a1257. The course stays pinned toa826864, so its benchmarks still measure the old code; I haven’t run them against the fix (to verify). - The PR notes one change of behaviour:
MonadicServiceBase, in GaApi and GA.MusicTheory.Service, now lets a request’s cancellation propagate instead of returning a failedTry. - These four were only listed in the 2026-09-14 entry; they now have rows in the QA table.
- #730, merged 2026-09-24, fixes the performance findings still open, and the export that was not reproducible. Measured for the PR on one machine, Release, 4,096 calls per member after a warm-up; I haven’t re-run this course’s benchmarks against it (to verify):
PitchClassSet.ClosestDiatonicKey: 308.7 ms and 175,699 bytes per call before, 1.0 ms and 0 bytes after;GetCompatibleKeys: 180.5 ms and 110,116 bytes, then 0.1 ms and 0 bytes;ToNormalFormandIsNormalFormread a 4,096-entry table. A test compares each with the old implementation, kept as an oracle, on all 4,096 sets, tie-breaks included.ValueObjectUtils<TSelf>.Itemsreturns one collection, andValues, read throughIReadOnlyList<int>, no longer boxes 24 bytes per read on nine types.VoicingGenerator’s parallel path now emits its windows in fret order, so its stream is the sequential one. That order was the cause of the export difference: the export keeps the first of equally cheap voicings. Two guitar exports of GA’s main hashed776c849e…andd0a7d1d9…; with the fix,138603e8…both times.
EnsureValueInRangewas fixed inside #598, a feature PR, with no test. #731 adds one: 9 tests pass on main, and 5 of them fail with the file froma826864.
2026-10-22 — Appendix 4: plugins without MEF
Section titled “2026-10-22 — Appendix 4: plugins without MEF”- The author asked for a trial of MEF’s successor with two modules, A and B, then for this appendix, verified here rather than handed back. A prototype outside the repository came first; the appendix adds modules C and D and runs under
check.sh. - The hypotheses were written before the first run of the course version, and are in the Experiments table. The predictions about a shared service, type identity and the timer held on the first run, with the same output over 31 runs on Windows 11 and the .NET 10.0.113 SDK.
- The control, modules A and B dropped without
Dispose, was added after the timer’s result: without it, “the container was not disposed” and “the timer is running” were one explanation. - Module D tests the explanation drawn from the
Timerdocumentation, that an active timer can be collected: it differs from C only by a static callback. It unloaded, but over 40 runs of the first host, ten back-to-back collections, the unloadability article’s loop, were not enough four times: three times the context was alive with its assembly already gone fromGetAssemblies, once withModuleDstill listed. With 10 ms between collections, three every time over 40 runs. The host now waits for up to 100 collections, 10 ms apart, and prints the count on a#line thatcheck.shshows without comparing it. - Module B was first given Newtonsoft.Json 12.0.3; NuGet’s audit flagged it (NU1903, GHSA-5crp-9r3c-p9vr), so B uses 13.0.1. Both 13.x packages carry the assembly version 13.0.0.0, which the output now prints.
To verify
Section titled “To verify”-
The reference chain that keeps module C loaded, dumped with
dotnet-dump’sgcroot; appendix 4 reads it from two measurements (appendix 4). -
Execute the lesson 17 runtime recipes against pinned Channel, Dataflow and Rx fixtures, including a two-item blocked-producer schedule; the current evidence is the Petri oracle only.
-
Why dynamic PGO didn’t remove the boxed enumerators of the first mask version of
TryMatch(appendix 2). -
Why GA’s OPTIC-K index export differs between sessions in 266 of 313,047 entries (appendix 2).
-
Which member
T.Itemsbinds to when a derived interface hides a static abstract property with anew staticproperty (lesson 5). -
Why a helper call per iteration made the shared
ReadStatic<string>loop only 1.46 times slower (lesson 5). -
Which of the two objects of a
foreachoverPitchClass.Itemsdynamic PGO stops allocating (lesson 5). -
Whether
PoolingAsyncValueTaskMethodBuilderremoves the 104 bytes ofValueTaskAfterYield(lesson 3). -
The IL of
GA.Corecompiled by Roslyn 4.11 against the SDK’s Roslyn 5.0. -
The lessons were written on x64; the Arm64 results come only from the macOS runner’s machine-dependent lines, never from a benchmark.