The pressure: "the report formatter is slow"
In ENGAGE, a separate console utility exports reports to clients. The scheduler runs it for each client: a new process, one report, exit. A developer noticed that the report is assembled slowly and added a measurement:
// ENGAGE, Tools/ReportExport/Program.cs — код с дефектом, это учебный материал
// фрагмент: report загружен выше
var sw = Stopwatch.StartNew();
var html = ClientReportRenderer.Render(report);
sw.Stop();
Console.WriteLine($"Render: {sw.ElapsedMilliseconds} ms");
The number came out large. The team is already discussing how to rewrite the renderer. But if you call Render a second time in the same process, it runs several times faster. Same code, same data.
The measurement didn't measure the renderer. It measured the compilation of the renderer to machine code, which happened at the moment of the first call. On top of that, the utility was launched with F5 in the Debug configuration, where optimizations are turned off. To read such measurements correctly, and then to choose between JIT, ReadyToRun and Native AOT, you need a model of what happens between .cs and the processor.
What F5 does: build and run
F5 in Visual Studio (or dotnet run) is two different steps.
Build. MSBuild reads the .csproj, resolves packages and calls the C# compiler, Roslyn. The following appear in bin/Debug/net10.0/:
ReportExport.dll— the assembly with your code: IL and metadata;ReportExport.exe(no extension on Linux) — the apphost, a native launcher that contains none of your code;ReportExport.runtimeconfig.json— which runtime is needed;ReportExport.deps.json— which assemblies the application depends on.
Run. The apphost (or the dotnet ReportExport.dll command) finds the installed runtime from runtimeconfig.json and loads the CLR; the CLR loads the assembly and compiles Main. From then on, each method is compiled when it is first called.
Debug and Release are different code. In Debug the compiler emits unoptimized IL and marks the assembly so that the JIT doesn't optimize either: nothing is inlined, and the debugger can stop on any line. The Visual Studio debugger can additionally suppress JIT optimizations. dotnet run builds Debug by default; dotnet publish, starting with .NET 8, builds Release. Any speed measurement is done in Release and without a debugger.
Why an intermediate language
Code in different languages must work together, on different operating systems and processors, and be memory-safe. Direct compilation would mean a compiler for every "language × processor" pair, and interpretation is too slow. The solution is an intermediate layer.
CIL (Common Intermediate Language, also known as IL, also known as MSIL) is the instruction set of an abstract stack machine. This is what Roslyn emits. The F# and VB compilers emit the same IL, which is why an F# library plugs into a C# project without wrappers.
Next to the IL, the assembly contains metadata — a complete description of all types, members, signatures and attributes. This is not debugging information, and it cannot be turned off: the runtime itself relies on it when it checks types and compiles methods. Reflection, serializers, DI containers, ORMs and test frameworks all grow out of metadata. So do decompilers, which recover nearly the original C# from a .dll. Debug symbols (.pdb) are a separate file and have almost nothing to do with decompilation.
The CLR: what runs while the process lives
The CLR (Common Language Runtime) is an implementation of the abstract CLI standard. It is not an interpreter and not a sandbox, but a set of services:
- the assembly loader — finds and loads dependencies;
- the JIT compiler — turns IL into machine code on demand;
- the memory manager with the garbage collector — allocates objects and frees unreachable ones (u03);
- type safety — you cannot read someone else's memory through a reference to
Engagement, and casts are checked; - a single exception model for all languages;
- threads and synchronization, the thread pool.
Code that executes under these services is called managed. The opposite is unmanaged: functions from native OS libraries or from C/C++. The bridge between the worlds is P/Invoke: a method with the [DllImport] attribute or, since .NET 7, [LibraryImport], for which the compiler generates the marshalling. At the boundary the runtime converts strings, structures and arrays between representations, and the GC cannot move objects that native code sees. Frequently calling a cheap native function can cost more than the work it does. The garbage collector doesn't free native memory: it is managed through SafeHandle and IDisposable.
CTS and CLS
The CTS (Common Type System) answers the question of which types exist at all: value and reference types, classes, interfaces, delegates, enumerations; all value types derive from System.ValueType. int in C# is an alias for System.Int32 — literally the same type. That is why int.MaxValue and System.Int32.MaxValue are one and the same member, not a "similar" one.
The CLS (Common Language Specification) is the subset of the CTS that all languages of the platform are required to understand. C# distinguishes Rate from rate; Visual Basic doesn't. So two public members that differ only in case are valid for the CTS but not CLS-compliant. Unsigned types in a public signature are not CLS-compliant either. The [assembly: CLSCompliant(true)] attribute turns the check on, and the compiler warns about such members. Today this matters mainly to authors of libraries for other languages.
Assembly, module, namespace, project
- A project (
.csproj) is a unit in the MSBuild build system. It is a design-time concept; the runtime knows nothing about it. - An assembly is what the project produces: a
.dll. It is the unit of deployment, versioning and loading. An assembly has a name, a version, a culture, and may have a signature. It is the assembly that the CLR loads, and it is the assembly boundary that limitsinternal. - A module is a file with IL and metadata inside an assembly. The format allows multi-file assemblies, but modern .NET doesn't support them: in practice, one assembly is one module.
- A namespace is only part of a type's full name. It is not a unit of deployment and not an access boundary. One assembly contains many namespaces, and one namespace can be spread across several assemblies.
// файл Rates/RateCache.cs, сборка Engage.Billing.dll
namespace Engage.Billing.Rates;
internal sealed class RateCache
{
public decimal? Find(string engagementCode) => null; // заглушка
}
// файл Invoices/InvoiceBuilder.cs, та же сборка Engage.Billing.dll
using Engage.Billing.Rates;
namespace Engage.Billing.Invoices;
public sealed class InvoiceBuilder
{
private readonly RateCache _rates = new(); // компилируется: та же сборка
}
internal means "inside Engage.Billing.dll", not "inside Engage.Billing.Rates". The RateCache class is not visible from Engage.Api.dll, even if you create a namespace with the same name there. An exception is made explicitly, with the InternalsVisibleTo attribute, usually for the test assembly.
JIT and tiered compilation
A method is compiled to machine code on its first call. The result is kept in the process's memory, and subsequent calls go to the ready code. Hence the cold start: the first requests to a fresh service and the first Render in the ENGAGE utility are slower than the ones that follow.
Compiling everything at once with optimizations takes too long for startup. That is why, since .NET Core 3.0, tiered compilation has been enabled by default:
- Tier0. The first call is compiled quickly and with almost no optimizations: no inlining, no clever register allocation. The code works, but slower than it could.
- Call counting. The runtime counts calls. When a method becomes hot (by default, around 30 calls; the threshold and delays are implementation details), it is queued for recompilation.
- Tier1. A background thread compiles the method again, this time with full optimizations, and subsequent calls go to the new version. The main thread doesn't wait.
- OSR (On-Stack Replacement). A method that is called once but spins a long loop will never become hot by the call counter. For such a method the runtime can switch to the optimized version right in the middle of the loop, without waiting for it to exit. OSR has been enabled by default since .NET 7 on x64 and Arm64.
- Dynamic PGO (profile-guided optimization). Enabled by default since .NET 8. A hot method first goes through an instrumented version that collects a profile: which branches execute, which types actually arrive at virtual and interface calls. Tier1 is built from this profile. For example, if
IRateProvider.GetRatealmost always receives aSqlRateProvider, the JIT will insert a type check and a direct inlined call, keeping the virtual call as a fallback path (guarded devirtualization).
A static compiler can't do this: the JIT knows the specific processor and the actual profile. On a long-lived service this pays for the deferred compilation.
Description
Схема слева направо. Узел «Program.cs, исходник C#» стрелкой «время сборки» ведёт к узлу «Roslyn (dotnet build)», от него стрелка к узлу «MyApp.dll, IL + метаданные». Из сборки расходятся три пути. Первый, обычный: стрелка «первый вызов метода» к узлу «JIT tier0, быстро, без оптимизаций»; от него стрелка «метод горячий или долгий цикл (OSR)» к узлу «JIT tier1, оптимизации по профилю PGO»; оба уровня JIT ведут к узлу «Машинный код в памяти процесса». Второй путь: стрелка «dotnet publish, PublishReadyToRun» к узлу «ReadyToRun, готовый код внутри сборки»; от него стрелка «старт без JIT» к машинному коду и пунктирная стрелка «горячие методы» к узлу tier1 — горячие методы всё равно перекомпилируются с оптимизациями. Третий путь: стрелка «dotnet publish, PublishAot» к узлу «Native AOT, весь код заранее», от него стрелка «машинный код целиком» к узлу «Нативный исполняемый файл без JIT и без IL». Смысл: машинный код появляется либо во время выполнения (JIT, в два уровня), либо заранее частично (ReadyToRun), либо заранее целиком (Native AOT).
There are settings (TieredCompilation, TieredPGO in the .csproj; the MethodImplOptions.AggressiveOptimization attribute — optimize right away, but without PGO), but touching them without measuring is guesswork.
How to measure: warm-up
Here is what the ENGAGE developer saw, only in an honest form:
using System.Diagnostics;
decimal[] hours = [8m, 7.5m, 6m, 8m];
for (var i = 0; i < 3; i++)
{
var start = Stopwatch.GetTimestamp();
var total = Billing.Total(hours, 180m);
var elapsed = Stopwatch.GetElapsedTime(start);
Console.WriteLine($"{i}: {total}, {elapsed.TotalMicroseconds:F1} мкс");
}
static class Billing
{
public static decimal Total(ReadOnlySpan<decimal> hours, decimal rate)
{
var sum = 0m;
foreach (var h in hours) sum += h * rate;
return sum;
}
}
The sum is the same in all three lines, but the time of the first one is noticeably larger: it includes the compilation of Total. The numbers depend on the machine and the runtime version, so they are not given here.
The first measurement measures the JIT. Three measurements in a row aren't a measurement either: tier1 hasn't arrived yet, and CPU frequency and background threads interfere. For honest measurement there is BenchmarkDotNet: it does the warm-up, waits for the steady state, repeats iterations, computes statistics and warns about Debug.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<TotalBench>();
[MemoryDiagnoser]
public class TotalBench
{
private readonly decimal[] _hours = [8m, 7.5m, 6m, 8m];
[Benchmark]
public decimal Total() => Billing.Total(_hours, 180m);
}
Run it with dotnet run -c Release. [MemoryDiagnoser] adds the number of allocations to the time: this will come in handy as early as u02, when we get to boxing. Measurement is covered in detail in u28.
For the ENGAGE utility the conclusion is different: the process lives for one report, and its main cost is startup and the JIT. This is a problem about the compilation mode, not about the algorithm.
ReadyToRun and Native AOT
| Mode | When machine code appears | What you get | What you pay |
|---|---|---|---|
| JIT (default) | on the first call, then tier1 | the best code from the profile, full reflection | cold start |
| ReadyToRun | in advance, at publish; hot methods are recompiled |
faster startup, everything else as with JIT | a larger assembly; precompiled code is more modest than tier1 |
| Native AOT | entirely in advance, at publish |
startup without JIT, a single native file, less memory | no dynamic loading or code generation, limited reflection |
ReadyToRun (<PublishReadyToRun>true</PublishReadyToRun>, requires a target RID: dotnet publish -c Release -r linux-x64) puts ready machine code into the assembly next to the IL. The IL doesn't go anywhere: if the precompiled code can't be used, the JIT compiles the method as usual, and tiered compilation still moves hot methods to tier1. The .NET libraries themselves ship in ReadyToRun format, so even an empty application doesn't compile the whole framework at startup. ReadyToRun is a safe step: compatibility is full, and all you lose is size.
Native AOT (<PublishAot>true</PublishAot>) compiles the whole application, together with the needed part of the runtime, into a single native executable for a specific OS and architecture. There is no JIT in it and no IL. Hence the restrictions:
- No dynamic loading of assemblies.
Assembly.LoadFromfor plugins doesn't work: everything that will execute must be known at publish time. - No code generation at run time.
Reflection.Emitand everything built on it are unavailable. - Reflection is limited. AOT throws out unused code (trimming). A type that is referenced only through a string or through reflection may simply not make it into the file. The compiler warns about dangerous places (
IL2026,IL3050), and these warnings must not be ignored. - Libraries must be compatible. A serializer that finds properties through reflection and a DI container that scans assemblies break under AOT. The way out is source generators: for example,
System.Text.JsonwithJsonSerializerContext.
[JsonSerializable(typeof(ClientReport))]
internal partial class ReportJsonContext : JsonSerializerContext { }
// вместо JsonSerializer.Serialize(report):
var json = JsonSerializer.Serialize(report, ReportJsonContext.Default.ClientReport);
How reflection and generators work, and why they conflict with AOT, is covered in u31.
How to choose. The deciding factor is the lifetime of the process.
- A command-line utility, a serverless function, a job per file: the process lives for seconds, JIT warm-up doesn't pay off, and cold start is the main metric. A candidate for Native AOT if the dependencies are compatible; otherwise ReadyToRun.
- A long-lived web service or queue handler: the process lives for days, and the JIT has time to reach tier1 with PGO. Regular JIT, optionally ReadyToRun. Native AOT would give less memory and a smaller container image here, but would take away reflection and PGO — the decision is made on all the criteria, not on startup alone.
The ENGAGE export utility is the first case.
.NET Framework, .NET, Mono and support timelines
- .NET Framework (latest version 4.8.1) — the original implementation, Windows only. It is supported along with Windows but not developed: there will be no new versions of C# or libraries for it.
- .NET (before version 5 — .NET Core) — the cross-platform implementation, the living branch. Everything in this course is about it. The number 4 was skipped to avoid confusion with Framework 4.x.
- Mono — the third implementation of the CLI. Its code lives in the .NET repository and serves as the runtime for mobile platforms and WebAssembly; Unity has its own branch of Mono.
.NET versions are released every November. Even-numbered ones are LTS, with three years of support; odd-numbered ones are STS, with two years. .NET 10 was released in November 2025; it is an LTS release supported until November 2028. .NET 9 and .NET 8 go out of support on November 10, 2026. That is why the course targets .NET 10 and C# 14 and marks C# 13 and .NET 9 features separately: you will encounter them in code, but there is no longer any reason to start new projects on .NET 9. ".NET Framework 4.7" in a job posting most likely means maintaining a legacy system; it's worth asking about the migration plan.
How to run the course examples
The material can be read without running any code, but if you want to check what an example prints, it's handy to keep the ENGAGE training template at hand: https://github.com/AndreyKuralekh/engage (.NET 10). The types from the examples are prepared there. This is a convenience, not a requirement: any example can be pasted into an empty dotnet new console by adding the missing types. Measurements, as said above, are done only with -c Release.
The cost
- JIT pays with startup. Every first call is a compilation; the first requests after a deployment are slower. The payment is one-time, and a long-lived process earns it back through profile-guided optimizations.
- Tiered compilation makes timing "float": first unoptimized code, then the method is quietly swapped for a fast one. A naive measurement lies.
- ReadyToRun pays with assembly size and, slightly, with code quality until the switch to tier1.
- Native AOT pays with capabilities: no dynamic loading, no code generation, reflection only over what is known at publish time, a separate build for each OS and architecture, no Dynamic PGO. Some familiar libraries will have to be replaced or configured.
- Metadata pays with openness: an assembly decompiles almost back to source.
- The managed/unmanaged boundary pays with marshalling. Frequent small calls through P/Invoke cost more than they seem, and native memory has to be freed by you.
How it sounds in an interview
"What happens between the moment you press Build and the moment the code runs?" A weak answer: "it compiles to IL, then JIT". A strong one is the same chain with the reasons: Roslyn emits IL and metadata because the platform is shared across languages and not tied to a processor; the host finds the runtime from runtimeconfig.json; the CLR loads the assembly and compiles a method on its first call — quickly at first (tier0), then, for hot methods, with profile-guided optimizations (tier1, Dynamic PGO). That is why a cold start is more expensive and a long-running process is faster. If startup is critical — ReadyToRun or Native AOT, and for AOT you pay with reflection. Follow-up question: "then why not always compile everything in advance?" — because you lose the execution profile and the dynamics: plugin loading, code generation, full reflection.
"What is the CLR?" A model answer: "The runtime that compiles IL into machine code on the target machine, enforces type safety and owns the process's memory through the garbage collector. It also contains the assembly loader, a single exception model and the thread pool. For me as a developer, the consequence is this: I don't manage object lifetimes directly, but in return I get reflection, metadata-based DI and exceptions shared by all languages. The boundary is P/Invoke: native code lives outside these guarantees." Listing abbreviations without consequences doesn't count. The follow-up question will almost certainly be about the GC — that's u03.
"After a deployment, the service responds more slowly for the first few seconds. Why, and what should be done?" The JIT: methods are compiled on their first call, and at first into unoptimized tier0. Remedies: ReadyToRun at publish, warm-up requests before the instance is added to the load balancer, and Native AOT for short-lived processes. Follow-up question: "how will you prove it's the JIT and not the database?" — a second identical request is faster, and a runtime counter shows how many methods have been compiled.
What to take away from the unit
- Roslyn emits IL and metadata; machine code appears at run time — except with Native AOT.
- F5 is build plus run; Debug turns off optimizations, and measurements are done only in Release without a debugger.
- The CLR is a set of services: loader, JIT, GC, type safety, exceptions, threads. P/Invoke is the boundary with unmanaged code.
- The JIT is tiered: tier0 on the first call, tier1 for hot methods, OSR for long loops, Dynamic PGO by default since .NET 8.
- The first measurement measures the JIT. For measurements, use BenchmarkDotNet.
- ReadyToRun speeds up startup and breaks nothing; Native AOT removes the JIT at the cost of dynamic loading, code generation and part of reflection.
- A short-lived process is a candidate for AOT; a long-lived service is a candidate for JIT.
- The assembly is the unit of deployment and the boundary of
internal; a namespace is only part of the name. - The CTS is which types exist; the CLS is which of them are safe to expose to other languages.
- The course targets .NET 10 LTS (until November 2028); .NET 8 and 9 go out of support on November 10, 2026.
