Ir al contenido
learn

Diario

  • Código del curso: el programa de las lecciones, los ejemplos descompilados, los fragmentos rechazados y los benchmarks, comparados con su salida esperada por check.sh
  • CI en Linux, Windows y macOS, con una ejecución de prueba (dry run) de cada benchmark
  • Lección 1: memoria, valores, referencias y spans
  • Lección 2: el recolector de basura
  • Lección 3: async y await por dentro
  • Lección 4: rendimiento medido
  • Lección 5: genéricos en profundidad
  • Un nuevo plan en cuatro partes y 24 lecciones, con ASP.NET Core en profundidad y los equivalentes en Spring y Reactor
  • Lección 6: canales
  • Lección 7: TPL Dataflow
  • Lección 8: Rx.NET
  • Lección 9: elegir un flujo
  • Lecciones 10-14: estado compartido y ruta de una petición ASP.NET Core
  • Lección 15: servicios hospedados y trabajo en segundo plano
  • Lección 16: autenticación y autorización
  • Lección 17: traducción de un oráculo de especificación Petri acotado en diseño de pruebas deterministas para Channel, TPL Dataflow y Rx
  • Apéndice 1: cinco miembros de GA optimizados, demostrados sobre los 4096 conjuntos de clases de altura y luego medidos — con los benchmarks reescritos en cuanto se vio que medían el JIT
  • Apéndice 2: el pipeline de indexación de GA perfilado, tres cambios demostrados contra la propia salida de GA y medidos, y enviados aguas arriba como pull requests
  • Apéndice 4: plugins sin MEF — cuatro módulos, cada uno en su propio contexto descargable, un único contenedor, siete escenarios predichos y luego medidos

Este curso perfila GuitarAlchemist/ga, un repositorio público, y corre sobre .NET 10 y BenchmarkDotNet: todo lo que sigue es código de otros, incluso donde el autor de GA y el del curso son la misma persona. Tres filas subieron aguas arriba como pull requests y se fusionaron; las demás nunca se reportaron, y la columna Estado dice cuál es cuál.

Esperado Lo que pasa Dónde Medición Estado
Reconocer un acorde para cada voicing cuesta lo que cuesta un reconocimiento 667 125 voicings usan solo unos 2500 conjuntos de clases de altura distintos, así que la búsqueda de patrón de cada conjunto se repetía unas 266 veces CanonicalChordRecognizer.IdentifyChordSet dotnet-trace situaba allí cerca del 52 % del hilo principal, en su mayoría construyendo HashSet. Con una caché de 4096 casillas, VoicingAnalyzer.Analyze pasó de 234,34 ms y 759 MB a 8,94 ms y 22 MB Corregido aguas arriba, PR #695 2026-09-17
El vector de clases de intervalo se calcula una vez por conjunto Se recalculaba en cada llamada la teoría atonal de GA El mismo camino de análisis; el volcado de los 8192 vectores es idéntico byte a byte antes y después Corregido aguas arriba, PR #694 2026-09-17
OptickIndexReader.Dimension se lee una vez Se leía en cada consulta, mediante una propiedad que lanza un Where + Sum de LINQ sobre el registro de particiones OptickIndexReader.Dimension Los 38 MB por consulta atribuidos a la búsqueda se asignaban ahí, no en Parallel.For: un barrido secuencial asignaba los mismos 38 MB Corregido aguas arriba, PR #693 2026-09-17
La exportación del índice OPTIC-K es reproducible desde un commit La primera exportación de la noche difiere de las siguientes en 266 de 313 047 entradas, con el mismo commit de GA exportación OPTIC-K 266 entradas de 313 047, identificadas por instrumento y diagrama Corregido upstream por #730, fusionada el 2026-09-24. La causa era el generador paralelo de voicings, cuyo orden de salida seguía la planificación de los hilos; dos exportaciones de guitarra del main de GA tenían hashes distintos antes, idénticos después 2026-09-17, 2026-09-24
El camino paralelo de VoicingGenerator es más rápido que el secuencial Era el doble de lento: un solo consumidor construía todos los diagramas y descartaba los duplicados VoicingGenerator.cs 1.419 ms frente a 711 ms para 667.125 voicings Corregido upstream por #730: los productores hacen ese trabajo, 649-679 ms frente a 786-861 ms en secuencial, medido para la PR; no vuelto a medir en este curso 2026-09-17, 2026-09-24
PitchClassSet.GetCompatibleKeys es lo bastante barato para llamarlo por voicing Reconstruía las 30 tonalidades y sus conjuntos de clases de altura en cada llamada PitchClassSet.cs 13,5 % del perfil del análisis; 180,5 ms y 110.116 bytes por llamada en 4.096 llamadas Corregido upstream por #730: una prueba de inclusión sobre máscaras de 12 bits, calculada una vez por conjunto, 0,1 ms y 0 bytes; no vuelto a medir en este curso 2026-09-17, 2026-09-24
ValueObjectUtils<TSelf>.Items devuelve la colección que guarda Crea una ValueObjectCollection<TSelf> nueva de 32 bytes en cada llamada ValueObjectUtils.cs 32 bytes por llamada Corregido upstream por #730: una sola colección inmutable, 0 bytes por lectura 2026-09-16, 2026-09-24
IRangeValueObject<TSelf>.EnsureValueInRange normaliza sobre el rango real Normaliza con un tamaño de rango desviado en uno IRangeValueObject.cs Valor equivocado en el límite Corregido upstream en #598, fusionada el 2026-09-24, sin prueba; #731 añade una, que falla con el código antiguo 2026-09-16, 2026-09-24
default(Str) da un objeto de valor válido Da la cuerda 0, que la propia comprobación de rango de Str prohíbe — igual que new Str[n] y new T() en un método genérico todo objeto de valor de GA cuyo mínimo es 1 La lección 5 muestra las tres puertas Reproducido, límite del patrón más que defecto 2026-09-16
El out de IStaticReadonlyCollection<out TSelf> aporta varianza No aporta nada: 13 de sus 17 implementaciones son structs, y la interfaz no tiene miembros de instancia IStaticReadonlyCollection<out TSelf> 13 de 17 Reproducido, inocuo 2026-09-16
ValueObjectCache<T> construye lo que sus llamadores usan Construye en su primer uso dos FrozenSet que nadie lee ValueObjectCache.cs Dos conjuntos construidos, cero lectores Corregido upstream por #730: los dos conjuntos se construyen en el primer uso 2026-09-16, 2026-09-24
El calentamiento de un benchmark mide lo que miden sus iteraciones El mismo código midió 31 ns en el calentamiento y de 41 a 52 ns en las iteraciones BenchmarkDotNet, VoicingWithSpans Fijado a un núcleo con --affinity mide 32,4 ns, en el primer núcleo igual que en el último: los núcleos del Core Ultra 9 285K no son iguales Entendido, y por eso la lección fija el núcleo 2026-09-14
Añadir [MemoryDiagnoser] a una serie en marcha añade su columna Sin recompilar, la serie usó el build anterior y no imprimió ninguna columna Allocated, sin avisar BenchmarkDotNet Sin columna, sin aviso Reproducido, un error del usuario que la herramienta vuelve invisible 2026-09-14
El PGO dinámico elimina la asignación del enumerador como en la lección 4 No: la primera versión con máscaras seguía asignando 72 bytes por llamada, de enumeradores obtenidos por IEnumerable<int> PGO dinámico de .NET 10 Pasar a int[] y HashSet<int> quitó los 72 bytes y lo hizo 2,7 veces más rápido todavía Reproducido; por qué el caso de la lección 4 se desvirtualiza y este no sigue abierto 2026-09-17
PitchClassSetId.ItemsSpan devuelve una vista de los ids Copiaba los 4.096 ids en un array nuevo en cada llamada: Items es una lista de solo lectura generada por el compilador, así que la prueba is PitchClassSetId[] siempre era falsa PitchClassSetId.cs#L59-L71 16.408 bytes por llamada Corregido upstream por #687, fusionada el 2026-09-20; no se volvió a medir 2026-09-14, 2026-09-24
El operador - de PitchClass cuesta más o menos lo que cuesta (a - b + 12) % 12 Buscaba el resultado en un FrozenDictionary de 144 tuplas PitchClass.cs#L110-L137 583 ns para los 144 pares, frente a 134 ns para la aritmética y 41,5 ns para un array plano Corregido upstream por #687 con una tabla plana de 144 entradas; no se volvió a medir 2026-09-14, 2026-09-24
Se puede bloquear sobre Try.OfAsync, y cancelarlo Esperaba sin ConfigureAwait(false), así que bloquear sobre él bajo un contexto de sincronización de un solo hilo producía un interbloqueo, y convertía OperationCanceledException en un fallo corriente Try.cs#L62-L73 Mostrado en la lección 3 Corregido upstream por #687 2026-09-14, 2026-09-24
LazyWithExpiration<T> no cuesta nada mientras espera Ocupaba un hilo del pool en Thread.Sleep por valor LazyWithExpiration.cs#L20-L40 64 valores que expiraban tras un segundo retrasaban un Task.Run ajeno 2 s en mi máquina y de 10 a 12 s en los runners de 3 y 4 núcleos Corregido upstream por #687: la expiración es una marca de tiempo comprobada en cada acceso 2026-09-14, 2026-09-24

El trabajo de rendimiento invita a leer una predicción dentro de su propio resultado, así que estas filas conservan la hipótesis tal como se escribió y la dejan equivocarse. Dos de las seis primeras están refutadas, y el método del apéndice 2 — comparar dos builds de GA en lugar de dos métodos, y comprobar que cada respuesta es idéntica byte a byte — es él mismo la tercera fila. Las cinco filas del apéndice 4 se cumplieron todas, incluida la última, escrita para poner a prueba una explicación y no un comportamiento.

Pregunta Hipótesis Resultado Veredicto Dónde
¿Es FrozenDictionary más rápido que Dictionary con doce sufijos de acorde cortos? Escrita de antemano: sí, para eso está No lo era Refutada 2026-09-14
¿Es SearchValues más rápido que un bucle sobre símbolos de acorde de 1 a 6 caracteres? Escrita de antemano: sí 1,7 veces más lento Refutada 2026-09-14
¿Da un build con los cambios de análisis respuestas idénticas byte a byte a main? Escrita de antemano, y es el método entero: la demostración compara dos builds de GA, no dos métodos Idénticas en los cinco volcados: TryMatch 258 048 líneas, reconocimiento 106 496, vectores de clases de intervalo 8192, los 667 125 voicings, 2048 búsquedas sobre el índice real de 313 047 entradas Confirmada 2026-09-17
¿Venían de Parallel.For los 38 MB por consulta de la búsqueda? Escrita de antemano: el paralelismo era el sospechoso Un barrido secuencial asignó los mismos 38 MB, lo que absolvió a Parallel.For y señaló a GetVector Refutada 2026-09-17
¿Sobrevive el particionado por rangos de la búsqueda al arreglo de asignaciones? No escrita como predicción: medía un 8 % más rápido antes del arreglo, así que parecía digno de conservar Un 22 % más lento después. Descartado Refutada 2026-09-17
¿Se acelera la exportación OPTIC-K entera de punta a punta, y no solo el benchmark? Escrita de antemano: el análisis es la mayor parte de la exportación Cuatro ejecuciones alternando main y el build modificado: 142,8 s frente a 62,9 s, luego 95,0 s frente a 38,3 s. El mismo binario de main tardó 142,8 s y luego 95,0 s: la máquina no estaba ociosa, y son los pares, no los tiempos absolutos, el resultado Confirmada 2026-09-17
¿Pueden dos módulos ejecutar dos versiones de Newtonsoft.Json en un mismo proceso? No escrita como predicción: vista primero en un prototipo 13.0.3 y 13.0.1 lado a lado, ambas con la versión de ensamblado 13.0.0.0 Confirmada 2026-10-22, Plugins/
¿Puede el módulo B usar un servicio del módulo A sin referenciar A? Escrita de antemano: sí si la interfaz está en los contratos; sin A, ValidateOnBuild falla al construir el contenedor B invierte el deletreo de A; sin A, «Unable to resolve service for type ‘Contracts.IChordSpeller’» desde BuildServiceProvider Confirmada 2026-10-22
¿Un ensamblado cargado en dos contextos da un solo tipo? Escrita de antemano: no, dos tipos con un mismo nombre, y un solo IModule compartido Mismo nombre True, mismo tipo False; el IModule que implementan ambos es el del host Confirmada 2026-10-22
¿Se descarga todavía un módulo cuya herramienta hace correr un Timer? Escrita de antemano: sí una vez liberado el contenedor, nunca cuando se abandona 1 de 1 liberado; 0 de 1 abandonado, tras 100 recolecciones, en cada ejecución. El control sin temporizador: 2 de 2 abandonado Confirmada 2026-10-22
¿Es lo que captura el callback del temporizador lo que retiene el módulo C? Escrita tras el resultado de la fila anterior, antes del código del módulo D: un callback estático deja descargarse al módulo, ya que un Timer activo puede recolectarse El módulo D se descarga, 1 de 1, pero en diez recolecciones seguidas solo 36 veces de 40; con 10 ms entre recolecciones, tres cada vez Confirmada 2026-10-22

2026-09-14 — Configuración y la versión fijada de GA

Sección titulada «2026-09-14 — Configuración y la versión fijada de GA»
  • El curso se compila con tres proyectos de GA, GA.Core, GA.Domain.Core y GA.Business.Config, descargados por fetch-ga.sh desde el commit a826864, la cabeza de la rama main de GA ese día, con un clon disperso y sin blobs: unos 11 MB en lugar del repositorio entero. Es el mismo commit y el mismo script que los del curso de teoría musical, así que ambos cursos leen el mismo código.
  • Mi máquina tiene el SDK de .NET 10.0.112 y una preview de 11.0. El global.json del curso fija 10.0.100 con rollForward: latestFeature, así que dotnet elige 10.0.112 en la carpeta del curso y la preview fuera de ella.
  • GA.Core hace referencia al paquete Microsoft.Net.Compilers.Toolset en su versión 4.11.0: ese proyecto lo compila el Roslyn 4.11 del paquete, no el Roslyn 5.0 del SDK. Compila sin problemas; no he buscado diferencias en el IL que produce, por verificar.
  • ilspycmd 11.0.0.9375 es una herramienta local, restaurada por check.sh. Dos sorpresas: -il desensambla el ensamblado entero e ignora -t Type, así que check.sh desensambla una sola vez el ensamblado Snippets; y el IL contiene comentarios // Method begins at RVA 0x…, que cambian con cada modificación de un método no relacionado, así que check.sh los elimina antes de comparar.
  • Descompilar con -lv CSharp4 muestra la máquina de estados asíncrona: en el nivel de lenguaje C# 4, ILSpy no puede volver a convertirla en await.

2026-09-14 — Comprobar los errores del compilador

Sección titulada «2026-09-14 — Comprobar los errores del compilador»
  • Los fragmentos rechazados se compilan en memoria con un pequeño programa Roslyn, CompileFail, en lugar de con un proyecto por fragmento: una sola compilación en lugar de once. Añade los using implícitos del SDK como un árbol de sintaxis aparte y hace referencia a los ensamblados del runtime. Sus mensajes son idénticos a los de dotnet build.
  • Faltaba CS4007 al principio: el comprobador llamaba a compilation.GetDiagnostics(), y un Span<int> usado a través de un await compilaba sin error. Ese error solo se notifica mientras el compilador reescribe el método como máquina de estados, cosa que GetDiagnostics() no hace. El comprobador llama ahora a compilation.Emit(Stream.Null) y lee sus diagnósticos.
  • Esperaba CS8175 para un span capturado por una lambda, por lo que recordaba de compiladores más antiguos; Roslyn 5.0 notifica CS9108 («Cannot use parameter ‘frets’ that has ref-like type inside an anonymous method…»). La lección 1 cita el mensaje real.
  • El fragmento de CS8425 produce una advertencia, no un error: el comprobador la espera y falla si no aparece.

2026-09-14 — Lo que hizo el runtime y el IL no dice

Sección titulada «2026-09-14 — Lo que hizo el runtime y el IL no dice»
  • Encapsulado (boxing) eliminado por el JIT. Un box seguido de unbox.any del mismo tipo no asigna nada, ni siquiera en el nivel 0. Con DOTNET_TieredCompilation=0, una caja que no escapa del método se asigna en la pila, también cuando el valor se convierte a una interfaz: es la asignación de objetos en la pila de .NET 9, ampliada en .NET 10. check.sh ejecuta la lección 1 de las dos maneras.
  • El umbral del montón de objetos grandes se comprueba sobre el tamaño antes de redondear: byte[84,975] cuesta 85 000 bytes y se queda en la generación 0, byte[84,976] cuesta lo mismo y va al LOH (lección 2).
  • Los literales están en un montón congelado: GC.GetGeneration("C major") y GC.GetGeneration(typeof(PitchClass)) devuelven int.MaxValue.
  • Una primera versión del bucle de presupuesto de asignación de la lección 2 guardaba sus arrays en una variable local, y el JIT podía asignarlos en la pila después de OSR, sin dejar basura que recolectar. Ahora los arrays escapan a un anillo de 16.
  • Task.WhenAll conserva las excepciones internas en el orden en que fallaron las tareas, que cambia entre ejecuciones: el programa las ordena, e imprime el orden real en una línea que depende de la máquina.
  • SearchValues.Create("#b") devuelve Any2CharPackedSearchValues en x64 y Any2SearchValues`2 en Arm64 (el runner de macOS): otra línea que depende de la máquina.
  • El presupuesto de gen0 depende del tamaño de la caché de la máquina: 18 MB en mi máquina, 16 MB en el runner de Linux, 24 MB en el de Windows y 6 MB en el de macOS, donde los mismos 32 MB de basura provocaron cinco recolecciones en lugar de una.
  • BenchmarkDotNet 0.15.8. Máquina: Intel Core Ultra 9 285K (24 núcleos), 64 GB, Windows 11 25H2, .NET SDK 10.0.112, runtime 10.0.12. BenchmarkDotNet cambió el plan de energía de Windows a Alto rendimiento en cada ejecución, y lo restauró después.
  • La CI ejecuta cada benchmark con --job Dry. La primera ejecución de prueba tardó 8 min 30 s: JitBenchmarks declara sus cuatro jobs en una configuración, y --job Dry añadió un job de prueba a esos cuatro en lugar de sustituirlos. La configuración usa ahora Job.Dry cuando BENCHMARKS_DRY=1, y toda la ejecución de prueba tarda unos 30 s en local, y entre 17 y 53 s en los runners.
  • La ejecución completa de las siete clases, una tras otra y sin nada más en marcha, tardó unos 45 minutos. JitBenchmarks por sí sola ejecuta cuatro jobs y tardó 4 minutos.
  • El mismo código midió 31 ns en el calentamiento y entre 41 y 52 ns en las iteraciones reales (VoicingWithSpans). Fijado a un núcleo con --affinity, midió 32.4 ns, tanto en el primer núcleo como en el último. El Core Ultra 9 285K tiene núcleos de rendimiento y de eficiencia; no he comprobado de qué tipo son esos dos núcleos, ni he confirmado que la migración de subprocesos explique las iteraciones más lentas, por verificar. La lección 4 cuenta la historia y conserva las tablas sin fijar, ya que es lo que obtendrá un lector por defecto.
  • Añadí [MemoryDiagnoser] a JitBenchmarks mientras se ejecutaban las demás clases, sin recompilar: la ejecución usó la compilación anterior y no imprimió ninguna columna Allocated. Tras recompilar y volver a ejecutar, mostró el resultado en el que se basa la lección 4: con PGO, el bucle sobre IEnumerable<int> ya no asigna su enumerador de 40 bytes, y es 9 veces más rápido.
  • Dos de mis expectativas eran erróneas: FrozenDictionary no fue más rápido que Dictionary con doce sufijos de acorde cortos, y SearchValues fue 1.7 veces más lento que un bucle con símbolos de acorde de 1 a 6 caracteres. Ambos resultados aparecen en la lección 4 tal como se midieron.

2026-09-14 — Dogfooding: lo que las lecciones encontraron en GA

Sección titulada «2026-09-14 — Dogfooding: lo que las lecciones encontraron en GA»

Todavía no he comunicado ninguno de estos hallazgos al proyecto GA; los enumero para que sus mantenedores decidan.

  • PitchClassSetId.ItemsSpan copia los 4096 identificadores en un array nuevo en cada llamada, 16 408 bytes. Items está declarado como IReadOnlyCollection<PitchClassSetId> e inicializado con una expresión de colección, así que el compilador crea un <>z__ReadOnlyList<PitchClassSetId>, y la prueba is PitchClassSetId[] que debería evitar la copia siempre es falsa (lecciones 1 y 2).
  • El operador - de PitchClass busca en un FrozenDictionary de 144 tuplas para calcular (a - b + 12) % 12. Tarda 583 ns para los 144 pares, frente a 134 ns de la aritmética y 41.5 ns de un array plano de 144 valores (lección 4).
  • Try.OfAsync espera sin ConfigureAwait(false), así que bloquear esperándolo bajo un contexto de sincronización de un solo subproceso provoca un interbloqueo, y además captura OperationCanceledException, con lo que convierte una cancelación en un fallo ordinario (lección 3).
  • LazyWithExpiration<T> mide su caducidad con un Thread.Sleep en un subproceso del grupo de subprocesos, uno por valor. Con 64 valores que caducan al cabo de un segundo, un Task.Run sin relación esperó 2 s en mi máquina y entre 10 y 12 s en los runners de 3 y 4 núcleos (lección 3). No he buscado los sitios en los que GA lo usa, por verificar.
  • Commit d85a319, ejecución 34915741516: en verde en los tres sistemas operativos, solo el código de las lecciones.
  • Commit 8ba378e, ejecución 34919815590: las soluciones de los ejercicios, en verde en los tres sistemas operativos. Los jobs tardaron 1 min 23 s en Linux, 2 min en macOS y 3 min 14 s en Windows, de los cuales la ejecución de prueba de los 37 benchmarks ocupó 23 s, 27 s y 53 s. Unos 14 s de cada job corresponden a la demostración de LazyWithExpiration de la lección 3: un Task.Run esperó 11 001 ms en Linux, 11 766 ms en macOS y 11 050 ms en Windows, donde la ejecución anterior había medido 9878 ms.
  • El runner de Windows dio los mismos anchos de vector y las mismas diferencias en coma flotante que mi máquina; el runner de Linux tiene AVX-512.

2026-09-15 — Un nuevo plan, y empieza la parte 2

Sección titulada «2026-09-15 — Un nuevo plan, y empieza la parte 2»
  • El curso pasa de 12 a 24 lecciones en cuatro partes: runtime y rendimiento, concurrencia y flujo de datos, ASP.NET Core en profundidad, metaprogramación y herramientas. Las lecciones 1 a 4 conservan sus slugs; las antiguas lecciones 6 y 12 pasan a ser las lecciones 10 y 19, y las lecciones 2 y 3 apuntan ahora a ellas. Las lecciones 6 a 9 se escribieron antes que la lección 5.
  • Las partes 2 y 3 comparan cada tema con Spring y Reactor, de C# a Java. El curso de Spring Boot, Spring Cloud y Reactor hace la comparación en el otro sentido, así que las lecciones enlazan con sus páginas en lugar de volver a explicar Reactor, y la parte 3 construirá el equivalente en ASP.NET Core de su servicio de escalas.

2026-09-15 — Volver deterministas los programas concurrentes

Sección titulada «2026-09-15 — Volver deterministas los programas concurrentes»

Cada comportamiento de las lecciones 6 a 9 lo imprime el programa y se compara con expected/, en tres sistemas operativos. Una primera versión de cada lección imprimía algo que cambiaba entre ejecuciones; cada lección se ejecutó al menos nueve veces seguidas antes de hacer commit de su salida.

  • Barreras, no retardos. Los elementos se retienen con un TaskCompletionSource hasta que el programa ha visto lo que quiere mostrar, y «el productor está atascado» se mide esperando a que un contador deje de moverse, e imprimiendo después el contador.
  • Las continuaciones se ejecutan cuando quieren. Un WriteAsync(...).AsTask() que había terminado seguía indicando IsCompleted en false en algunas ejecuciones, porque su continuación es asíncrona: el programa ahora lo espera. Lo mismo con Fault sobre un bloque de Dataflow, cuyo Completion.Exception seguía siendo null justo después de la llamada.
  • EnsureOrdered = false no significa «al revés». Una primera prueba esperaba que el lento elemento 0 saliera el último; no lo hizo en 8 ejecuciones de 20. La lección 7 muestra ahora lo que un consumidor puede recibir mientras el elemento 0 se ejecuta.
  • Límites en tiempo virtual. Las notas en exactamente 500 o 1.000 ms caían en el borde de las ventanas de Sample y Buffer; las notas de la lección 8 están colocadas lejos de ellos.
  • Reader.Count lanza NotSupportedException en un canal no acotado creado con SingleReader = true: su CanCount es false. El programa vacía el canal para contar lo que queda.
  • Rx.NET 7.0 no tiene puente hacia IAsyncEnumerable: ToAsyncEnumerable() sobre un observable no compiló. La lección 9 escribe a mano las dos direcciones.
  • La primera versión de la lección 7 etiquetaba Dm7 como Forte 4-3, a partir de ProgrammaticForteCatalog de GA; la tabla de Forte dice 4-26. La lección toma ahora las etiquetas de CanonicalForteCatalog e imprime ambas.
  • Commits 69bb214 y b4d713f, ejecuciones 34994143077 y 34995561655: en verde en los tres sistemas operativos.
  • El benchmark de flujos de la lección 9 se ejecutó solo durante 5 minutos en la misma máquina que la lección 4. BenchmarkDotNet indicó una distribución bimodal para RxObserveOnTaskPool.

2026-09-15 — Dogfooding: canales, Dataflow y Rx en GA

Sección titulada «2026-09-15 — Dogfooding: canales, Dataflow y Rx en GA»

GA usa canales en su generador de voicings y en su comando de indexación, y TPL Dataflow y Rx.NET en una demo de rendimiento. Nada de esto se ha notificado todavía aguas arriba.

  • VoicingGenerator.GenerateAllVoicingsAsync: un consumidor que se detiene antes de tiempo, como el .Take(100) del ejemplo de uso, deja a los productores generando todas las ventanas en un canal no acotado; una ventana que lanza una excepción deja al consumidor esperando para siempre, porque nunca se llega a Writer.Complete(); el resumen dice que el orden se conserva, los comentarios de debajo dicen que no (lecciones 6 y 9).
  • IndexVoicingsCommand: cuando el consumidor falla, por ejemplo porque la base de datos está caída, registra el error y retorna, y los productores esperan para siempre ante el canal lleno (lección 6). El canal acotado en modo Wait es la elección correcta.
  • PerformanceOptimizationDemo: la parte de Dataflow cuenta los resultados de una List<T> que llena otro subproceso sin esperar a ese subproceso, e ignora el resultado de SendAsync (lección 7); la parte Rx cuenta lotes como eventos, 10 en lugar de 1.000, y espera 500 ms fijos en lugar de esperar el pipeline (lección 8). Su parte de canales es correcta.
  • La misma demo y MusicalAnalysisApp, ambos net10.0, hacen referencia al paquete System.Threading.Tasks.Dataflow 9.0.10, que .NET 10 ya tiene en su framework compartido. Un proyecto net10.0 nuevo con la misma referencia recibe la advertencia NU1510 al restaurar, y carga de todos modos el ensamblado del framework.
  • Los números de ProgrammaticForteCatalog siguen otro orden que la tabla de Forte, aunque sus observaciones dicen que las diferencias son menores: 4-3 para el acorde de séptima menor donde Forte dice 4-26 (lección 7).
  • GA tiene varias clases BackgroundService, entre ellas el precalentamiento de la caché y la inicialización del índice de voicings; la lección 15 es el lugar para leerlas.

2026-09-15 — Apéndice 1: optimizar GA, primero la demostración

Sección titulada «2026-09-15 — Apéndice 1: optimizar GA, primero la demostración»

La lección 4 medía el código de GA tal cual. Este apéndice reescribe tres de sus miembros, y lo interesante resultó no ser la ganancia de velocidad sino lo que la reescritura no tiene permitido cambiar.

  • Los tres son PitchClassSetId.IsClusterFree, PitchClassSet.IntervalClassVector y PitchClassSet.ClosestDiatonicKey. Todos reciben un conjunto de 12 bits, así que el dominio de entrada entero tiene 4096 valores: Advanced -- a1 compara cada reescritura con la respuesta de GA para cada uno de ellos, y la CI lo ejecuta en tres sistemas operativos. Escribir la demostración antes que el benchmark cambió lo que estaba dispuesto a afirmar.
  • IntervalClassVectorId empaqueta seis cuentas como dígitos en base 12, y las cuentas de 12 del agregado cromático acarrean. GA lo documenta como una limitación conocida. Empaquetarlo correctamente cambiaría el identificador del conjunto 4095, y ProgrammaticForteCatalog ordena cada cardinalidad por ese identificador, así que todos los números de Forte podrían moverse — la versión rápida reproduce el acarreo en su lugar. Una «corrección» colada dentro de un cambio de rendimiento es justamente de lo que trata este apéndice.
  • La respuesta de ClosestDiatonicKey depende de que OrderByDescending sea estable: los empates van a la tonalidad que Key.Items lista primero, las 15 mayores antes que las 15 menores. Un bucle que sustituyera al que va ganando con >= en lugar de > devolvería en silencio otra tonalidad en cada empate; el que sustituye con > coincide con GA en los 4096 conjuntos.
  • Los primeros benchmarks estaban mal, y halagaban. Llamar a cada miembro una vez sobre un argumento const dejó que el JIT plegara la llamada en un literal: el IsClusterFree rápido salía a 0,0107 ns, una veinticincoava parte de un ciclo. Un estático mutable eliminó el plegado y seguía dando una mediana de cero, porque BenchmarkDotNet resta un método vacío. Reescritos para barrer los 4096 conjuntos, IsClusterFree es 4 veces más rápido, no 31 — y yo habría publicado el 31.
  • El hallazgo son los 175 KB, no los microsegundos. IdentifyClosestKey recibe un Dictionary<Key, IReadOnlyCollection<PitchClass>> y lo desestructura como foreach (var (key, _) in items): los valores se construyen para las 30 tonalidades y nunca se leen. No hizo falta ningún perfilador — bastó con leer el método.
  • ToNormalForm y PrimeForm también están hechos, así que el apéndice demuestra ahora cinco miembros y no tres. Tabular la forma normal quitó los últimos 16 KB del ClosestDiatonicKey rápido, que ya no asigna nada: 1.615 veces más rápido, 175 KB por llamada eliminados. PrimeForm, que ya era aritmética de bits, solo dio 1,8 — el techo honesto de reescribir aritmética correcta, y merece estar al lado del 1.615.
  • Nada de esto se ha reportado aún aguas arriba.

2026-09-16 — Lección 5: genéricos en profundidad

Sección titulada «2026-09-16 — Lección 5: genéricos en profundidad»
  • La lección 5 llena el hueco entre las lecciones 4 y 6, que se escribieron antes. Sus salidas proceden de la misma máquina y del mismo SDK que el resto del curso; commit del código 378eec1.
  • Ejecución de CI 35173274425: verde en Linux, Windows y macOS. Las líneas que dependen de la máquina fueron las mismas en los tres runners que en mi máquina, incluido el de Arm64: 4592 bytes en el primer acceso a la caché de Str, y 1 y luego 0 métodos compilados para Shared<string> y luego Shared<object>.
  • El propio resumen del JIT como prueba. DOTNET_JitDisasmSummary=1 y DOTNET_JitStdOutFile funcionan en el runtime publicado, así que check.sh compara la lista de compilaciones de Shared<T>.Describe: cuatro para seis argumentos de tipo, una de ellas sobre System.__Canon. Lanza Advanced.dll directamente: a través de dotnet run, el propio proceso del SDK heredaría las variables y escribiría en el mismo archivo.
  • Lo que el runtime no comprueba. MakeGenericMethod aceptó (int, string) para un parámetro where T : unmanaged; el compilador lo rechaza con CS8377. notnull no deja nada en GenericParameterAttributes.
  • Un fragmento que esperaba que fallara compiló. IStaticReadonlyCollectionFromValues<TSelf> de GA oculta el Items abstracto estático con una propiedad new static que tiene cuerpo. Esperaba que T.Items, sobre un parámetro de tipo restringido a esa interfaz, se rechazara; Roslyn 5.0 lo compiló. Quité el fragmento; a qué miembro se enlaza esa llamada queda por verificar.
  • El desensamblado cambió mi lectura del benchmark. En el código compartido, leer un campo estático de Counter<T> llama a CORINFO_HELP_GET_NONGCSTATIC_BASE en cada iteración, tanto en el nivel 1 como con la compilación por niveles desactivada, y aun así el bucle solo fue 1.46 veces más lento que la versión para int.
  • De nuevo la PGO. Un foreach sobre PitchClass.Items asigna 72 bytes en las primeras llamadas del programa y 40 bytes en el benchmark, después de la PGO dinámica.

2026-09-16 — Dogfooding: las interfaces de objetos de valor de GA

Sección titulada «2026-09-16 — Dogfooding: las interfaces de objetos de valor de GA»

Nada de esto se ha notificado todavía aguas arriba.

  • ValueObjectUtils<TSelf>.Items crea un ValueObjectCollection<TSelf> nuevo de 32 bytes en cada lectura, aunque la interfaz documenta Items como memoizado; un foreach sobre él asigna 72 bytes (lección 5).
  • Values está declarado como IReadOnlyList<int> en IStaticValueObjectList<TSelf>, y las implementaciones devuelven un ImmutableArray<int>: 24 bytes de boxing por lectura. Doce lecturas tardaron 41.8 ns y asignaron 288 bytes, frente a 3.1 ns y nada a través del ImmutableArray (lección 5).
  • ValueObjectCache<T> construye en su primer uso dos FrozenSet que nada lee en los tres proyectos descargados: 4592 bytes para los 26 valores de Str (lección 5).
  • IRangeValueObject<TSelf>.EnsureValueInRange normaliza con un tamaño de rango de max - min en lugar de max - min + 1: 12 se convierte en 2 para una clase de altura. Ningún llamador de los proyectos descargados pasa normalize: true, así que el error está latente (lección 5).
  • default(Str), new Str[n] y new T() dan la cuerda 0, que la comprobación de rango de Str prohíbe; lo mismo vale para todos los objetos de valor de GA cuyo mínimo es 1 (lección 5).
  • El out de IStaticReadonlyCollection<out TSelf> no tiene ningún efecto: 13 de sus 17 implementaciones son structs, y la interfaz no tiene miembros de instancia (lección 5).

2026-09-17 — Apéndice 2: perfilar GA, con pull requests

Sección titulada «2026-09-17 — Apéndice 2: perfilar GA, con pull requests»

El apéndice 1 eligió qué optimizar leyendo GA. Esta vez eligió un perfilador, sobre el propio pipeline de GA en el commit 66bdd04, y cada cambio se envió aguas arriba. El trabajo fue de la noche del 2026-09-16 a la madrugada del 2026-09-17.

  • Tres pull requests.

    • #695: el reconocimiento de acordes con máscaras de 12 bits, calculado una sola vez por conjunto de clases de altura.
    • #694: el vector de clases de intervalo, calculado una sola vez por conjunto.
    • #693: OptickIndexReader.Dimension, leído una sola vez.

    Cada una se hizo en su propia rama a partir de main. Las pruebas de GA pasaron antes de abrir cada PR: GA.Domain.Core.Tests 458/458 y 459/459, y las pruebas de acordes y voicings de GA.Business.Core.Tests 421/421 y 419/419. En GA.Business.ML.Tests, las pruebas de búsqueda y de esquema pasaron 227 de 228; la que falla lo hace de la misma manera en main.

  • Adónde iba el tiempo. dotnet-trace sobre el análisis de voicings situó alrededor del 52 % del subproceso principal en CanonicalChordRecognizer.IdentifyChordSet, la mayor parte construyendo HashSet. Un sondeo dio 173 µs y 347 KB por voicing para VoicingAnalyzer.Analyze, y 38 MB por consulta para una búsqueda OPTIC-K.

  • La demostración compara dos compilaciones de GA, no dos métodos. Un único programa de volcado se compila contra main y contra la rama, escribe cada respuesta, y cmp compara los archivos. Todos fueron idénticos byte a byte:

    • TryMatch: 258.048 líneas;
    • reconocimiento: 106.496 líneas, cada conjunto × 13 bajos × 2 pasadas;
    • vectores de clases de intervalo: 8.192 líneas;
    • los 667.125 voicings de guitarra a través de VoicingAnalyzer.Analyze;
    • 2.048 búsquedas sobre el índice real de 313.047 entradas.

    La demostración propia del curso, GaPerf -- a2, comprueba 6.856.704 llamadas a TryMatch y 106.496 reconocimientos, y se ejecuta en la CI.

  • El hallazgo fue la caché, no las máscaras. Los 667.125 voicings usan unos 2.500 conjuntos de clases de altura distintos, así que la búsqueda de patrones de cada conjunto se repetía 266 veces. Con un array de 4096 casillas, el reconocimiento es 7.600 veces más rápido en el benchmark del curso. En GA, con los dos cambios, VoicingAnalyzer.Analyze pasó de 234,34 ms y 759 MB a 8,94 ms y 22 MB para 2.000 voicings.

  • Los 38 MB de la búsqueda no estaban en la búsqueda. Un experimento con un recorrido secuencial asignó los mismos 38 MB que el paralelo, lo que descartó a Parallel.For. Los bytes venían de GetVector, que leía una propiedad que ejecuta un Where + Sum de LINQ sobre el registro de particiones: 626.094 consultas por búsqueda. Leída una sola vez, una búsqueda pasó de 4,986 ms y 38,25 MB a 2,162 ms y 41 KB.

  • Dos resultados que no esperaba.

    • La primera versión con máscaras seguía asignando 72 bytes por llamada, por los enumeradores obtenidos a través de IEnumerable<int>. Distinguir int[] y HashSet<int> con un switch los eliminó, y la hizo otras 2,7 veces más rápida. La PGO dinámica no eliminó esta asignación, a diferencia de la de la lección 4; por qué no queda por verificar.
    • El particionado por rangos de la búsqueda, que parecía un 8 % más rápido antes de la corrección, resultó un 22 % más lento después. Lo descarté.
  • De extremo a extremo, la exportación OPTIC-K se ejecutó cuatro veces, alternando el main de GA y una compilación con los dos cambios de análisis: 142,8 s frente a 62,9 s, y después 95,0 s frente a 38,3 s. Los archivos de índice fueron idénticos entrada a entrada.

  • La máquina no estaba inactiva, y las cifras lo dicen.

    • Había otras sesiones en marcha. Cada compilación y cada serie de benchmarks tomó un bloqueo global de la máquina, y cada serie de BenchmarkDotNet tomó además el bloqueo de la GPU.
    • Al principio de cada serie registré la RAM libre y los procesos más activos: entre 13,6 y 18,5 GB libres, entre el 32 % y el 100 % de CPU total, con Microsoft Defender, Docker y WSL a la cabeza.
    • El mismo binario de exportación tardó 142,8 s, y luego 95,0 s.
    • El coordinador informó de dos capturas de Chrome sin interfaz hechas por otra sesión entre las 00:47 y las 00:49. Ninguna de mis series se ejecutó en esa ventana: los benchmarks anteriores terminaron a las 00:42:38, y el siguiente empezó a las 00:50:22. Aun así, volví a ejecutar las series de GA que acababan de correr justo antes, con las mismas asignaciones y tiempos dentro de las barras de error.
    • Las tablas de antes y después de GA usan ShortRun. Sus asignaciones son exactas; los tiempos, orientativos.
    • Una ejecución de la serie de acordes no llegó a hacerse: mi script apuntaba a un worktree que no existía, y la volví a lanzar.
  • Medido, y sin cambiar.

    • El camino paralelo de VoicingGenerator es el doble de lento que el secuencial (1.419 ms frente a 711 ms para 667.125 voicings), y PitchClassSet.GetCompatibleKeys ocupa el 13,5 % del perfil del análisis. Los dos archivos pertenecen a correcciones de GA en curso en otra sesión, así que estos hallazgos fueron allí como propuestas.
    • KeyIdentificationService.Identify no está en el camino caliente: 25 µs, una vez por petición.
    • Las asignaciones de los objetos de valor de la lección 5 no aparecen en este perfil.
  • La exportación del índice no siempre es reproducible. La primera exportación de la noche difiere de las posteriores en 266 de las 313.047 entradas, identificadas por instrumento y diagrama, aunque salió del mismo commit de GA. Todas las comparaciones de arriba se hacen entre exportaciones del mismo grupo; la causa queda por verificar.

2026-09-21 — Lecciones 10-14: concurrencia y ruta de petición ASP.NET Core

Sección titulada «2026-09-21 — Lecciones 10-14: concurrencia y ruta de petición ASP.NET Core»
  • Cinco lecciones ejecutables en .NET 10.0.12: lost update determinista, Lock, Interlocked, ConcurrentDictionary y Parallel.ForEachAsync limitado.
  • Servidores Kestrel reales en puertos loopback efímeros; se observaron ciclo de vida, petición, apagado graceful, orden de middleware y short-circuit 429.
  • Se verificaron lifetimes singleton/scoped, rechazo de captive dependency, keyed services y validación de options.
  • Se verificaron una Minimal API, un controlador en la misma tabla de routing y una respuesta JSON IAsyncEnumerable<string>.
  • Las salidas portables están en expected/l10.txt a expected/l14.txt; los mínimos del thread pool dependen de la máquina.
  • Son pruebas locales: buffering de proxy, límites de producción, identity provider real y starvation ante una dependencia remota quedan por medir.

2026-09-21 — Lecciones 15-16: trabajo hospedado y autorización local

Sección titulada «2026-09-21 — Lecciones 15-16: trabajo hospedado y autorización local»
  • La prueba de servicios hospedados usa barreras de terminación en lugar de demoras. Un canal limitado alimenta un worker singleton y dos operaciones resuelven instancias scoped distintas (scope-1 y scope-2). La cancelación del host se observa mientras el worker espera más trabajo.
  • Un segundo host libera un fallo deliberado y observa ApplicationStopping con BackgroundServiceExceptionBehavior.StopHost. Esto demuestra la política del host, no reintento ni recuperación.
  • La prueba de autenticación inicia Kestrel en un puerto loopback efímero. Los bearer tokens ausente, mal formado, caducado y con firma incorrecta devuelven 401; una identidad válida sin scope=scales.read devuelve 403; la identidad autorizada recibe 200 C major.
  • Ambas claves se generan en memoria para un solo proceso. La vida útil usa un instante fijo y ninguna clave ni token se imprime ni persiste.
  • Advanced compiló localmente sin advertencias y las dos salidas nuevas coinciden con expected/l15.txt y expected/l16.txt. Quedan por verificar la CI en tres sistemas, un servidor de autorización real, descubrimiento/rotación de claves y despliegue detrás de proxy.

2026-09-21 — Lección 17: oráculos Petri para pruebas de pipelines C#

Sección titulada «2026-09-21 — Lección 17: oráculos Petri para pruebas de pipelines C#»
  • Se reutilizó el ciclo de vida ejecutable de un elemento y una plaza de la lección Petri 14 en vez de crear un segundo modelo. Su suite específica explora los ocho marcados alcanzables, clasifica los tres marcados muertos terminales y comprueba el invariante de cola Channel free + queued = 1.
  • Se registró la frontera entre modelo y ejecución. Las pruebas Petri validan la especificación finita; no ejecutan Channel<T>, TPL Dataflow ni Rx.NET y, por tanto, no demuestran la implementación actual de GA.
  • Cada camino del modelo se convirtió en una receta de prueba de ejecución determinista basada en compuertas en lugar de pausas. La receta exige el resultado público de excepción o finalización y el asentamiento de cada participante poseído.
  • Se mantuvieron separadas las semánticas de capacidad: Channel cuenta elementos en cola en este modelo, la capacidad de un bloque de ejecución Dataflow incluye el elemento en curso y Rx no tiene límite hasta que el diseño lo añade.
  • Se documentaron dos límites deliberados: la cancelación es atómica y anterior al inicio, y la red de un elemento no puede reproducir un productor ya bloqueado por backpressure tras el fallo del consumidor. Quedan por verificar un modelo de dos elementos y fixtures de ejecución.

2026-09-24 — Correcciones upstream comprobadas de nuevo

Sección titulada «2026-09-24 — Correcciones upstream comprobadas de nuevo»
  • Los cuatro hallazgos de las lecciones 1 a 4 se corrigieron upstream en #687, fusionada el 2026-09-20, cada uno con un test que falla con el código antiguo: ItemsSpan devuelve su array subyacente, el - de PitchClass lee una tabla plana de 144 entradas (unos 42 ns en el benchmark de este curso, según la PR), Try.OfAsync usa ConfigureAwait(false) y deja pasar la cancelación, y LazyWithExpiration<T> comprueba una marca de tiempo en cada acceso. Comprobado en el código en GA 27a1257. El curso sigue fijado a a826864, así que sus benchmarks siguen midiendo el código antiguo; no los he ejecutado contra la corrección (por verificar).
  • La PR señala un cambio de comportamiento: MonadicServiceBase, en GaApi y GA.MusicTheory.Service, deja pasar ahora la cancelación de una petición en lugar de devolver un Try fallido.
  • Estos cuatro hallazgos solo figuraban en la entrada del 2026-09-14; ahora tienen sus filas en la tabla QA.
  • #730, fusionada el 2026-09-24, corrige los hallazgos de rendimiento aún abiertos, y la exportación que no era reproducible. Medido para la PR en una máquina, en Release, 4.096 llamadas por miembro tras un calentamiento; no he vuelto a ejecutar los benchmarks de este curso contra ella (por verificar):
    • PitchClassSet.ClosestDiatonicKey: 308,7 ms y 175.699 bytes por llamada antes, 1,0 ms y 0 bytes después; GetCompatibleKeys: 180,5 ms y 110.116 bytes, luego 0,1 ms y 0 bytes; ToNormalForm e IsNormalForm leen una tabla de 4.096 entradas. Una prueba compara cada uno con la implementación antigua, conservada como oráculo, en los 4.096 conjuntos, desempates incluidos.
    • ValueObjectUtils<TSelf>.Items devuelve una sola colección, y Values, leído a través de IReadOnlyList<int>, ya no empaqueta 24 bytes por lectura en nueve tipos.
    • El camino paralelo de VoicingGenerator emite ahora sus ventanas en orden de trastes, así que su flujo es el secuencial. Ese orden era la causa de la diferencia entre exportaciones: la exportación conserva el primero de los voicings igual de baratos. Dos exportaciones de guitarra del main de GA tenían los hashes 776c849e… y d0a7d1d9…; con la corrección, 138603e8… las dos veces.
  • EnsureValueInRange se corrigió dentro de #598, una PR de funcionalidades, sin prueba. #731 añade una: 9 pruebas pasan en main, y 5 de ellas fallan con el archivo de a826864.
  • El autor pidió una prueba del sucesor de MEF con dos módulos, A y B, y después este apéndice, verificado aquí en lugar de devuelto. Primero hubo un prototipo fuera del repositorio; el apéndice añade los módulos C y D y se ejecuta con check.sh.
  • Las hipótesis se escribieron antes de la primera ejecución de la versión del curso, y están en la tabla de experimentos. Las predicciones sobre el servicio compartido, la identidad de los tipos y el temporizador se cumplieron en la primera ejecución, con la misma salida en 31 ejecuciones en Windows 11 y el SDK de .NET 10.0.113.
  • El control, los módulos A y B abandonados sin Dispose, se añadió tras el resultado del temporizador: sin él, «el contenedor no se liberó» y «el temporizador corre» eran una sola explicación.
  • El módulo D pone a prueba la explicación sacada de la documentación de Timer, según la cual un temporizador activo puede recolectarse: solo se distingue de C por un callback estático. Se descargó, pero en 40 ejecuciones del primer host, diez recolecciones seguidas, el bucle del artículo sobre la descarga, no bastaron cuatro veces: tres veces el contexto seguía vivo con su ensamblado ya fuera de GetAssemblies, una vez con ModuleD todavía en la lista. Con 10 ms entre recolecciones, tres cada vez en 40 ejecuciones. El host espera ahora hasta 100 recolecciones, con 10 ms de intervalo, e imprime el recuento en una línea # que check.sh muestra sin compararla.
  • El módulo B recibió primero Newtonsoft.Json 12.0.3; la auditoría de NuGet lo señaló (NU1903, GHSA-5crp-9r3c-p9vr), así que B usa 13.0.1. Los dos paquetes 13.x llevan la versión de ensamblado 13.0.0.0, que la salida imprime ahora.
  • La cadena de referencias que mantiene cargado el módulo C, volcada con el gcroot de dotnet-dump; el apéndice 4 la deduce de dos medidas (apéndice 4).
  • Ejecutar las recetas de la lección 17 contra fixtures fijadas de Channel, Dataflow y Rx, incluido un ordenamiento de dos elementos con productor bloqueado; la evidencia actual es solo el oráculo Petri.
  • Por qué la PGO dinámica no eliminó los enumeradores encapsulados de la primera versión con máscaras de TryMatch (apéndice 2).
  • Por qué la exportación del índice OPTIC-K de GA difiere entre sesiones en 266 de las 313.047 entradas (apéndice 2).
  • A qué miembro se enlaza T.Items cuando una interfaz derivada oculta una propiedad abstracta estática con una propiedad new static (lección 5).
  • Por qué una llamada auxiliar por iteración solo hizo 1.46 veces más lento el bucle compartido de ReadStatic<string> (lección 5).
  • Cuál de los dos objetos de un foreach sobre PitchClass.Items deja de asignar la PGO dinámica (lección 5).
  • Si PoolingAsyncValueTaskMethodBuilder elimina los 104 bytes de ValueTaskAfterYield (lección 3).
  • El IL de GA.Core compilado por Roslyn 4.11 frente al del Roslyn 5.0 del SDK.
  • Las lecciones se escribieron en x64; los resultados de Arm64 provienen solo de las líneas dependientes de la máquina del runner de macOS, nunca de un benchmark.