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.

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:

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):

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.

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.

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.