Tuning converted code
Converted Go runs on .NET, and .NET gives you choices: how the program is compiled and deployed, how the runtime compiles it while it runs, and a few converter options that change what it costs. This page says which choices matter, what each one trades, and which to leave alone. Every claim names the platform it was measured on. For the numbers, see the performance comparison, which is re-measured as the converter changes.
In short: keep the .NET defaults. Publish with ReadyToRun or Native AOT when start time matters, and prefer the JIT over
Native AOT when tight loops dominate. Do not turn tiered compilation or dynamic PGO off for speed: on win-x64,
the default runtime ran a pointer-heavy benchmark in roughly 40% less time than with either one off.
Choosing a deployment
A converted program can run three ways. All three run the same converted code; they differ in when it is compiled.
| Deployment | How you get it | Compiled |
|---|---|---|
| JIT | dotnet run, dotnet build, or a framework-dependent dotnet publish |
as each method first runs, then recompiled hotter as it keeps running |
| ReadyToRun | dotnet publish -c Release of a converted program, which turns it on |
golib and the standard library ahead of time, then the JIT can still recompile hot methods; the program’s own code as it runs |
| Native AOT | dotnet publish -r <rid> -p:PublishAot=true |
entirely ahead of time, to a native executable with no JIT |
Which wins depends on what the program spends its time on. The win-x64 comparisons below come from the
performance comparison’s Go 1.23.1 table under History, measured on an earlier converter release; that page dates it.
- Start time. On
win-x64, a Native AOT program started in roughly an eighth of the time the same program took under the JIT, though still slower than Go. Onlinux-x64, ReadyToRun cut a converted program’s median start time by about a third against the JIT. - Tight loops. On
win-x64, the JIT ran slice-indexing, floating-point and pointer-heavy loops faster than Native AOT, which took roughly two to three times as long on each. Native AOT has no dynamic profile-guided optimization or on-stack replacement to tune a hot loop as it runs. - Maps. On
win-x64, converted map code ran faster than Go under both the JIT and Native AOT, and fastest under Native AOT. - Interfaces. On
win-x64, calling interface methods and asserting to concrete types took within about 15% of the same time under the JIT and Native AOT, with neither ahead on both. The one interface path with no compile-time answer, asserting a value to an interface it satisfies only structurally, took about two thirds longer under Native AOT than under the JIT. - Memory. On
win-x64, a Native AOT program’s peak working set was lower than the JIT’s on every benchmark. - Build cost. A Native AOT publish compiles every converted package the program references, so it is slow and the executable is large. Known issues has the measured cost and its platform.
A single-file publish compiles ReadyToRun one assembly at a time. For a self-contained single-file ReadyToRun
publish, the .NET SDK’s default is one composite image of the program and the framework. A converted program’s project
also compresses the bundle, so every start decompresses that image first. The project therefore sets
<PublishReadyToRunComposite>false</PublishReadyToRunComposite> unless you set it yourself, as the publish profiles
go2cs writes already do. Start-up wall time of a dotnet publish -p:PublishSingleFile=true on win-x64:
| ReadyToRun | Bundle | Start-up | Also |
|---|---|---|---|
| composite | compressed | 3,480 ms | a 571 MB executable; a string-matching loop ran 27% slower |
| one assembly at a time | compressed | 470 ms | |
| composite | uncompressed | 320 ms | a 1.70 GB executable, and about 700 s to publish |
To build the composite image anyway, pass -p:PublishReadyToRunComposite=true. On linux-x64 the composite image can
also run the ReadyToRun compiler out of memory
(Known issues).
Your own code is compiled as it runs. A ReadyToRun publish of a converted program precompiles golib and the
standard library, which is where start time is won. It leaves the program’s own assemblies, every project built from
your module, to the JIT. ReadyToRun compiles each assembly on its own: precompiled code cannot inline a call into
another assembly, and a loop that runs only once never leaves its precompiled form. On win-x64 (an AMD Ryzen 5 PRO
6650U), against a publish that precompiled everything:
- a published loop calling
strconv.FormatBoolran in about an eighth of the time; - a string-matching benchmark’s hot phase took about two thirds of the time;
- start time stayed within run-to-run variation, and the published folder came out a few tens of KB smaller.
To precompile your own code as well, set <GoReadyToRunUserCode>true</GoReadyToRunUserCode>. That is rarely
worth it. Across the performance suite, on the same machine, precompiling the program’s own code
makes 11 of the 17 programs slower than the default ReadyToRun publish and none faster. FieldView, Fib, RefLower,
Sort, Startup and TlsHandshake stay within measurement noise. Medians in ms; the ratio is precompiled-user-code
against the default:
| Benchmark | default ReadyToRun | GoReadyToRunUserCode |
ratio |
|---|---|---|---|
| StringView | 21.8 | 605.2 | 27.792 |
| IfaceCall | 339.6 | 1458.1 | 4.294 |
| String | 653.5 | 1995.5 | 3.054 |
| MatMul | 213.8 | 589.4 | 2.757 |
| Sieve | 139.5 | 265.2 | 1.901 |
| Iface | 376.1 | 660.4 | 1.756 |
| StringMatch | 804.7 | 1215.2 | 1.510 |
| Channel | 79.9 | 117.1 | 1.466 |
| IfaceShell | 574.6 | 728.4 | 1.268 |
| IfaceShellChurn | 1071.7 | 1188.1 | 1.109 |
| Map | 248.9 | 268.9 | 1.080 |
.NET runtime settings
Under the JIT and ReadyToRun, .NET compiles code in tiers. A method first gets a quick compile; one that keeps running is recompiled with full optimization, guided by a profile the runtime collected while it ran (dynamic PGO). Native AOT has no tiers and no dynamic PGO.
Leave tiered compilation and dynamic PGO on. On win-x64, a converted benchmark that takes field addresses in a
hot loop took roughly 40% less time with the defaults than with tiered compilation off, and about as much less than
with dynamic PGO off. Turning tiering off compiles every method once at full optimization, but without the profile
that the tiers collect, and the profile is worth more.
Across the performance suite, on win-x64 (an AMD Ryzen 5 PRO 6650U), turning
dynamic PGO off with DOTNET_TieredPGO=0 makes 13 of the 17 programs slower under the JIT and none faster; Startup,
Fib, Sieve and Channel stay within measurement noise. Turning it off is not recommended. Medians in ms, in-program
time; the ratio is PGO off against the default:
| Benchmark | default | DOTNET_TieredPGO=0 |
ratio |
|---|---|---|---|
| String | 339.0 | 1465.3 | 4.322 |
| IfaceCall | 358.3 | 807.0 | 2.252 |
| MatMul | 213.2 | 393.7 | 1.847 |
| FieldView | 1723.7 | 2917.6 | 1.693 |
| TlsHandshake | 1071.6 | 1513.4 | 1.412 |
| Iface | 374.7 | 528.0 | 1.409 |
| StringView | 20.9 | 29.4 | 1.405 |
| StringMatch | 791.2 | 1057.9 | 1.337 |
| IfaceShellChurn | 1076.8 | 1432.7 | 1.331 |
| RefLower | 511.3 | 645.7 | 1.263 |
| IfaceShell | 600.4 | 738.2 | 1.230 |
| Sort | 255.1 | 305.5 | 1.197 |
| Map | 253.6 | 264.6 | 1.044 |
The settings, if you need them, each in three equivalent forms: a property in the project file, a value in
runtimeconfig.json, or an environment variable for one run.
| Setting | Project file | runtimeconfig.json |
Environment |
|---|---|---|---|
| Tiered compilation | <TieredCompilation>false</TieredCompilation> |
"System.Runtime.TieredCompilation": false |
DOTNET_TieredCompilation=0 |
| Dynamic PGO | <TieredPGO>false</TieredPGO> |
"System.Runtime.TieredPGO": false |
DOTNET_TieredPGO=0 |
| Quick JIT for loops | <TieredCompilationQuickJitForLoops>false</TieredCompilationQuickJitForLoops> |
"System.Runtime.TieredCompilation.QuickJitForLoops": false |
DOTNET_TC_QuickJitForLoops=0 |
| Call-counting delay | <GoCallCountingDelayMs>0</GoCallCountingDelayMs> (a converted program’s own property) |
"System.Runtime.TieredCompilation.CallCountingDelayMs": 0 |
DOTNET_TC_CallCountingDelayMs=0 |
Microsoft documents every runtime compilation setting in Runtime configuration options for compilation.
A converted program counts calls from the start. .NET waits until about 100 ms pass with no new method compiled
before it counts calls toward optimizing a method. Until then, a short busy phase runs the standard library’s small
functions unoptimized. A converted program sets that delay to 0. This applies wherever code is JIT-compiled: under
dotnet run and dotnet build, and in a ReadyToRun publish, whose methods are still recompiled as they get hot. It
does not apply to Native AOT. On win-x64 (an AMD Ryzen 5 PRO 6650U):
- Under the JIT, a TLS handshake benchmark that loads 112 packages ran about a third faster (about 4.2 s down to 2.8 s). A trivial program took about 24 ms, roughly 5%, longer to start.
- Published with ReadyToRun, the same benchmark ran about 30% faster, and the trivial program took about 30 ms longer to start.
It trades start time for steady-state speed. Hot methods reach optimized code sooner, and the program pays for it in a slightly longer start, most visibly under ReadyToRun. When start time matters more to your program, restore .NET’s own delay in the converted project file:
<GoCallCountingDelayMs>runtime</GoCallCountingDelayMs>
or for one build or publish, dotnet publish -c Release -p:GoCallCountingDelayMs=runtime. Any other value is a delay
in milliseconds. The setting applies to an executable project; the environment variable overrides it for one run. A
C# application that references converted packages keeps .NET’s default unless it sets the runtimeconfig.json value
itself.
Before and after, across the performance suite. On win-x64 (an AMD Ryzen 5 PRO 6650U), the
performance suite’s programs ran with the template’s delay of 0 and with .NET’s own 100 ms. These
programs run for about 0.1 to 2.5 s, so the delay decides how much of each run executes before the optimized code
arrives. In a long-running service the delay is only a start-up transient, so the table overstates what the setting
costs once a service is warm. With the delay at 100, 13 of the 17 programs are slower under the JIT and 11 under
ReadyToRun; Startup is faster under both. Most of the JIT speed-up from go.* 1.26.10.1 to 1.26.10.2
is this setting: with the delay at 100, the 1.26.10.2 JIT times sit on 1.26.10.1’s. Medians in ms, in-program time
except Startup’s process wall time; the ratio is delay 100 against delay 0:
| Benchmark | JIT, delay 0 | JIT, delay 100 | ratio | ReadyToRun, delay 0 | ReadyToRun, delay 100 | ratio |
|---|---|---|---|---|---|---|
| Startup | 502.0 | 483.5 | 0.963 | 371.5 | 345.5 | 0.930 |
| Fib | 97.6 | 213.4 | 2.186 | 101.4 | 213.2 | 2.102 |
| String | 339.0 | 734.3 | 2.166 | within noise | ||
| TlsHandshake | 1071.6 | 2053.3 | 1.916 | 1078.2 | 1688.7 | 1.566 |
| Map | 253.6 | 470.9 | 1.857 | 248.7 | 466.1 | 1.874 |
| Sort | 255.1 | 434.8 | 1.704 | 255.0 | 437.6 | 1.716 |
| Channel | 80.7 | 126.1 | 1.563 | within noise | ||
| Iface | 374.7 | 558.8 | 1.491 | 380.9 | 616.7 | 1.619 |
| RefLower | 511.3 | 752.4 | 1.472 | 536.8 | 761.2 | 1.418 |
| IfaceShellChurn | 1076.8 | 1574.1 | 1.462 | 1090.9 | 1269.0 | 1.163 |
| StringMatch | 791.2 | 1125.8 | 1.423 | 838.8 | 1096.0 | 1.307 |
| IfaceCall | 358.3 | 435.8 | 1.216 | 349.0 | 506.5 | 1.451 |
| IfaceShell | 600.4 | 723.0 | 1.204 | 575.0 | 770.4 | 1.340 |
| FieldView | 1723.7 | 1959.8 | 1.137 | 1936.5 | 2089.3 | 1.079 |
MatMul, Sieve and StringView stay within measurement noise on both. Native AOT has no tiers, so the delay does not apply to it.
One setting to change on one runtime. Quick JIT for loops is what lets the JIT replace a running loop with an
optimized one. Ubuntu’s packaged .NET 10, rather than Microsoft’s build, can fail in that path on Linux;
Known issues describes the fault
and when it applies. Turning quick JIT for loops off avoids it. On
linux-x64, under Canonical’s .NET 10 build, that cost a converted program roughly a sixth more start time, and its
loop speed stayed within measurement noise.
Two places in go2cs turn tiering off on purpose, and neither is advice.
- Converted test hosts. A
-testshost runs with tiered compilation off by default, so every method is compiled once at full optimization and a test verdict cannot depend on when the runtime promotes a method.-test-tieredturns it back on. That trades speed for determinism, which a test needs and a program does not; a production program should not copy it. - The performance runner’s diagnostic arms. The performance suite can add a run with tiered compilation off, and measurements of individual changes also read runs with dynamic PGO off. They separate a cost the tiers recover from a cost the code always pays. They are measurement axes, not settings to deploy: a change that reads smaller with tiering off is still faster with the defaults.
Converter and runtime options
Most converter options change how the emitted C# reads, or which code is converted. They do not change what the program costs to run. These do.
String literals
By default a converted program keeps a string literal where Go has it: F("hello"). golib converts each literal from
C#’s UTF-16 to Go’s UTF-8 once and caches the result, so each later evaluation is a lookup that allocates nothing. A
string constant is one field, as Go declares it.
-hoist-literals is the optimization option. It gives each literal whose words make a meaningful name a named field,
F(helloˢ), and it reads every such field, and every string or other struct-typed constant, through a small accessor
under the same name; every other literal ("%v", ", ") keeps the plain cached form, so no literal is copied on each
evaluation. The JIT and Native AOT both inline that accessor into the code that reads it; they would not inline a
function that reads the field directly. The standard library is converted this way already, so code that calls into it
gets the benefit without the flag. A constant block shows the difference:
const (
Version = "1.0"
Name = "server"
)
// without the flag
public static readonly @string Version = "1.0"u8;
public static readonly @string Name = "server"u8;
// with -hoist-literals
public static ref readonly @string Version => ref Versionᵇ;
private static readonly @string Versionᵇ = "1.0"u8;
public static ref readonly @string Name => ref Nameᵇ;
private static readonly @string Nameᵇ = "server"u8;
Every use still reads Version and Name, so the Go function bodies are unchanged.
When it helps: a small function that returns or passes a literal or a constant, called from a tight loop.
- On
win-x64(an Intel Core i9-13900K), the standard library’sFormatBool, which returns one of two literals, took 0.66 ns per call through the accessor under the JIT, against 1.41 ns when it read the field directly and 1.07 ns with a plain literal. Under Native AOT it took 0.75 to 1.02 ns through the accessor, against 1.13 to 1.29 ns reading the field. - On the same machine, a loop calling a small standard-library function that returns a literal took roughly twice as long with plain literals under the JIT, once the JIT had optimized the function, and roughly 1.7 to 1.8 times as long under Native AOT. A smaller such function took up to a quarter longer under Native AOT.
- On
win-x64(an AMD Ryzen 5 PRO 6650U):- a literal returned from a small function called in a hot loop took roughly 1.7 to 1.8 times as long plain, under both the JIT and Native AOT;
- a literal argument inside the loop took roughly 1.6 times as long under the JIT, and a sixth longer under Native AOT;
- a local assigned from a literal took between a tenth and a seventh longer.
- In whole programs, a literal-heavy string-matching benchmark runs faster hoisted under Native AOT on both machines
measured on
win-x64: about 9% faster on an AMD Ryzen 5 PRO 6650U and about 5% on an Intel Core i9-13900K. On the Ryzen it runs about a quarter faster hoisted under the JIT. Parsing URL queries took about the same time either way.
Every comparison in this list after the first was measured against the direct field read, before the accessor existed; the accessor reads faster than that field.
Before and after, across the performance suite. Of the performance suite’s 17 programs, only
StringMatch moves beyond measurement noise with -hoist-literals. The other 16 stay within noise under both the JIT
and ReadyToRun, including TlsHandshake and StringView, which also get hoisted fields. Native AOT was timed for
StringMatch only. Medians in ms, in-program time; the ratio is hoisted against plain:
Machine (win-x64) |
Deployment | plain | -hoist-literals |
ratio |
|---|---|---|---|---|
| AMD Ryzen 5 PRO 6650U | JIT | 791.2 | 594.7 | 0.752 |
| AMD Ryzen 5 PRO 6650U | ReadyToRun | 838.8 | 566.8 | 0.676 |
| AMD Ryzen 5 PRO 6650U | Native AOT | 1288.5 | 1171.8 | 0.909 |
| Intel i9-13900K | Native AOT | 582.1 | 550.0 | 0.945 |
When it does not: a literal evaluated rarely costs one lookup either way. A literal passed as an any argument,
fmt.Println("x"), measured the same both ways: both forms pass one pre-boxed value. Before the accessor, a small
function that read a hoisted field could run slower than its plain form, because the compiler would not inline it;
the accessor removed that case. Measure your own hot path.
What it costs: one more declaration line per literal and per constant. The literal’s field is declared above the function that first uses it, and each constant becomes an accessor plus a private field. Go function bodies are unchanged. Plain literals describes both forms.
Goroutine stacks
Each goroutine runs on its own thread, and the thread reserves a fixed stack, because a .NET stack cannot grow the
way a Go stack does. The reserve is 256 MiB, or 16 MiB for a goroutine the converter proves cannot recurse
(how). It is address space, not memory, but it is what a thread start pays for:
on win-x64, parking 100,000 goroutines at once took roughly five times as long with the 256 MiB reserve as with
16 MiB. On linux-x64, at 10,000 goroutines, the reserve made no measurable difference, and a kernel limit on
memory mappings, not the reserve, stopped thread creation at around 16,000.
GO2CS_GOROUTINE_STACK sets the 256 MiB reserve and GO2CS_GOROUTINE_BOUNDED_STACK the 16 MiB one, as a byte
count with an optional KiB, MiB or GiB suffix; 0 takes .NET’s default thread stack. Lower the first only
for a program that starts many goroutines at once and never recurses deeply in one: recursion past the reserve ends
the process with no panic and no deferred calls (Limitations).
What else was considered
| Option | Why it is not a tuning option |
|---|---|
-var, -uco, -indent, -comments, -provenance, -license, -tree |
Change how the emitted C# reads or what metadata it carries; the program does the same work. |
-goroot, -gopath, -go2cspath, -stdlib, -recurse, -csproj, -tags, -platforms, -cgo |
Choose which Go code is converted and for which platforms, not how it runs. |
-debug, -convert-timeout, -platform-census, -version, -nuget-map-*, -module-* |
Govern the conversion itself. |
-tests, -test-* |
Converted test hosts only; -test-tiered is covered above. |
GOGC, GOMEMLIMIT |
Read and reported, but .NET’s collector does not follow them (Limitations). |
GO2CS_GC_PAUSE_HISTORY=0 |
Changes what runtime reports about garbage collection, not how the program runs. |
golib’s FieldViewCache.Disabled |
A switch for one test, which checks that &s.f stays one pointer as it does in Go. Setting it breaks that. |