On this page

Profiling and builds: sources and measurements

The research behind the C# profiling and GC sections of the performance skill and behind the godot-export-release skill (versions, debug and release builds, reproducible exports, signing, store uploads), plus the kit changes made from it: export -GameVersion, the export check for missing C#, two doctor warnings and the import cache in the action. Gathered on 2026-10-21. Tests ran on Godot 4.7.2 mono and .NET 8.0.31 on Linux x86_64 (4 cores, headless), with dotnet-trace, dotnet-counters and dotnet-gcdump 10.0.750501 and dotnet-counters 8.0.547301. Marks: tested means run here, source means read in the code, doc means stated by the vendor, and unconfirmed means not verified. What the kit already had (bench, the GC-gen0 custom monitor, StringName caching, pipeline warm-up, export -Run, the release workflow, the Godot, template and NuGet caches) was not researched again.

Sources

Source What it gave
The Profiler The editor profiler does not see C#; frame, physics frame, idle and physics time
Debugger panel, logging page, objectdb_profiler.html The Visual Profiler times rendering only; --gpu-profile and Print GPU Profile; the ObjectDB Profiler (4.6+)
Performance Some monitors return 0 in release; custom monitors work in both
SConstruct at 4.7-stable The profiler, profiler_path, profiler_sample_callstack, profiler_track_memory, profiler_record_on_demand options
Tracy, .../profiling/perfetto.html, the profiling index Custom builds with Tracy or Perfetto; official Perfetto templates since 4.7; no debug symbols in official binaries
Godot modules/mono/mono_gd/gd_mono.cpp @4.7-stable; dotnet/runtime release/8.0 StartupHookProvider.CoreCLR.cs, vm/assembly.cpp Why dotnet-counters sees nothing in a Godot game
Godot.NET.Sdk 4.7.2 Sdk.props and Sdk.targets Build configurations and defined symbols
class_editorexportplatform{windows,macos,android}, exporting_for_windows, exporting_for_macos, exporting_for_android, exporting_for_ios Where config/version goes; signing options and environment variables
learn.microsoft.com/en-us/azure/trusted-signing/... (redirects to .../azure/artifact-signing/how-to-signing-integrations); github.com/Azure/artifact-signing-action README Artifact Signing with SignTool and the GitHub Action
Steam: Uploading to Steam; game-ci/steam-deploy README steamcmd builds, the VDF keys, build accounts, config.vdf
itch.io/docs/butler butler push, push-preview, BUTLER_API_KEY
dev.epicgames.com/docs/epic-games-store/publishing-tools/uploading-binaries/bpt-instructions-170 BuildPatchTool modes
docs.gog.com (HTTP 500; search snippets only) GOGGalaxyPipelineBuilder build-game
getsentry/sentry-docs cli/dif.mdx sentry-cli debug-files upload
godot-builds 4.7.2-stable SHA512-SUMS.txt Which Perfetto builds and debug symbols are published
Piotr Mintus, "Performance and Memory Postmortem for Middle-earth: Shadow of War" (Monolith, GDC 2018 inferred; notes) Character LODs pushed out from measured CPU frame time; a deep pipeline bound by V-Sync put the screen about 200 ms behind the input

Profiling C#

Performance monitors in exports (tested)

Monitor Release export Debug export
TimeProcess 0.00301 0.00353
TimePhysicsProcess 0.00056 0.00111
MemoryStatic 0 23680100
A custom monitor Works Works

TimeProcess and TimePhysicsProcess are in seconds. The class reference: some monitors "will always return 0 when used in a project exported in release mode"; custom monitors are readable "(debug and release mode)".

.NET diagnostics tools (tested)

Tool Result against a Godot 4.7.2 mono game
dotnet-trace ps Lists the game as Godot_v4.7.2-stable_mono_linux.x86_64; use the game's own PID (pgrep matched the timeout wrapper)
dotnet-trace collect --profile gc-verbose Works (813 KB in 6 s); after convert --format speedscope, allocations resolve to Probe.WorkAlloc()
dotnet-trace collect --profile dotnet-sampled-thread-time Works, about 100 Hz managed stacks; engine C++ frames not resolved. cpu-sampling applies only to collect-linux in version 10
dotnet-gcdump collect, report Works; a type table of the heap
The same tools on an exported release build Work; DOTNET_EnableDiagnostics=0 blocks them
dotnet-counters 8 and 10 Nothing: a CSV with only the header; monitor crashes without a terminal
dotnet-counters collect --counters System.Runtime after calling RuntimeEventSource.Initialize by reflection 28 counters, e.g. allocation rate 26.9 MB/s (448 KB a frame at 60 fps, as measured in the game), 1 to 2 gen0 GCs a second, 7.87 ms/s paused

Why the counters are empty (source): Godot hosts .NET as a component (hostfxr_initialize_for_runtime_config, hdt_load_assembly_and_get_function_pointer) and never runs a Main; the runtime calls RuntimeEventSource.Initialize() only from the startup path that runs Main. The reflection call reaches an internal type, so the skill keeps it under #if DEBUG. Without any tool, GC.GetTotalAllocatedBytes(true), GC.CollectionCount(n) and GC.GetTotalPauseDuration() as custom monitors work in release too.

GC and JIT settings (tested)

Where settings are read:

Run Read from csproj settings DOTNET_* environment
Editor or --path The editor's GodotSharp/Api/Debug/GodotPlugins.runtimeconfig.json (rollForward: LatestMajor) No effect (ConcurrentGarbageCollection, TieredPGO) Apply (DOTNET_gcServer=1, DOTNET_gcConcurrent=0)
Exported game data_<Name>_<platform>/<Name>.runtimeconfig.json (self-contained, .NET 8.0.31) Apply (System.GC.Concurrent: false, TieredPGO: false) Not measured

Which runtime: rollForward: LatestMajor makes an editor or --path run use the newest .NET runtime installed, not .NET 8. On GitHub's ubuntu-26.04 image 20261111 that is .NET 11, and there godottest under coverlet hit a CLR assert at shutdown (Attempt to reattach detached thread, ebr.cpp:144) after every test passed; with .NET 8 it did not (reproduced both ways, #264). So every Godot the kit starts gets DOTNET_ROLL_FORWARD=Major, which the .NET host applies over the runtimeconfig's setting: .NET 8 when it is installed, otherwise the lowest newer major. A value you set yourself is kept, and the kit's log names the one in force on its Godot: line. The editor you open by hand is not changed; set the variable in your environment to keep it on .NET 8 too.

<ServerGarbageCollector>true</ServerGarbageCollector> is not written to the runtimeconfig (Godot game projects are libraries with EnableDynamicLoading); <RuntimeHostConfigurationOption Include="System.GC.Server" Value="true" /> works.

Headless editor-binary runs, 3000 frames after 60 warm-up; "work" is the C# section of _Process, where GC pauses land. The workload keeps about 80 MB alive (300k int[64]) and allocates 2000 int[32] a frame, a quarter of them kept in a 200k ring so they get promoted (334 KB a frame):

Setting Gen 0/1/2 GC pause total Work p99 Frames over 8 ms Worst frame
Default (workstation, concurrent) 56/30/3 1000 to 1172 ms 16 to 18 ms 58 34 to 56 ms
DOTNET_gcConcurrent=0 17/1/1 826 to 878 ms 0.6 to 0.8 ms 17 128 to 153 ms
DOTNET_gcServer=1 1/1/0 161 to 172 ms 0.7 to 0.9 ms 1 162 to 172 ms
DOTNET_GCgen0size=0x4000000 17/17/3 1046 to 1094 ms 1.0 to 2.8 ms 19 to 20 122 to 147 ms
GCSettings SustainedLowLatency 56/30/3 1068 to 1108 ms 17 to 18 ms 58 40 ms
The same work, pooled 0/0/0 0 ms 0.43 ms 0 2.1 ms

With short-lived garbage only (448 KB a frame, small heap): 78 gen0 collections and 31 to 33 ms of pauses in 3000 frames, p99 0.88 ms, against 0 collections and p99 0.49 ms pooled. TieredCompilation=0, TieredPGO=0, TC_QuickJitForLoops=0 and ReadyToRun=0 made no measurable change (p99 0.80 to 1.30 ms, noise). Settings trade many medium pauses for fewer large ones; server GC also keeps a heap per core and uses more memory. Pooling removes the hitches, and GC.GetTotalPauseDuration() is the number to put on a monitor.

Builds

Configurations (source)

Godot.NET.Sdk 4.7.2 has Debug;ExportDebug;ExportRelease. Editor runs use Debug (DEBUG;TOOLS), debug exports ExportDebug (DEBUG, Optimize=false), release exports ExportRelease (Optimize=true, no DEBUG). Always defined: GODOT, a platform symbol (GODOT_WINDOWS;GODOT_PC, GODOT_LINUXBSD, GODOT_MACOS, GODOT_ANDROID;GODOT_MOBILE, GODOT_IOS, GODOT_WEB; from the RuntimeIdentifier at export, the host OS in the editor) and version symbols (GODOT4_7, GODOT4_7_2, GODOT4_7_OR_GREATER). The SDK sets the Debug constants "to ensure that the user cannot override" them. What reaches each build was checked in the DLLs: see debug-tools.md.

An export that exits 0 without C# (tested)

--export-release on a C# project with no .sln logged ERROR: ... no solution file was found and "completed with warnings", exited 0, and produced a game with no data_* folder: no C# at all.

Versions (tested)

What Result
Version=1.2.3 InformationalVersion=1.2.3+abc1234 in the environment of godot --export-release The DLL has AssemblyVersion=1.2.3.0, InformationalVersion 1.2.3+abc1234: Godot's dotnet publish inherits the environment
A <Version> in the csproj as well The csproj wins over the environment
In a git checkout, no version set The .NET 8 SDK appends +<full commit sha> (1.0.0+9b27cbf2...); IncludeSourceRevisionInInformationalVersion=false turns it off
config/version="1.2.3-rc.1+g1a2b3c" WARNING: Invalid version number ... can only contain numeric characters (0-9) and non-consecutive periods (.), exit 0, Windows FileVersion 1.0.0.0
config/version="1.2.3" Windows FileVersion 1.2.3.0
config/version in override.cfg Ignored by export (FileVersion stayed 1.2.3.0) and not shipped

application/config/version becomes the Windows FileVersion and ProductVersion, the macOS short_version and version, and the Android version/name when the preset fields are empty (doc). Android version/code must be raised by hand for every Play upload.

Reproducible exports (tested)

Exported twice Result
Same machine, same path .pck, executable, .dll, .pdb, .deps.json byte-identical
Another checkout path .pck identical; .dll and .pdb differ (paths embedded)
Another path with ContinuousIntegrationBuild=true in the environment, outside git Still different
Another path with PathMap=<project dir>/=/_/ in the environment DLL and PDB identical (d912b817e6df both)

Inside git, ContinuousIntegrationBuild maps paths through SourceRoot, but the commit sha in InformationalVersion differs per commit, as expected. dotnet/include_debug_symbols=true in the preset ships a portable <Name>.pdb next to the DLL, which gives line numbers in C# stack traces; butler --ignore '*.pdb' leaves it out of a store upload while it goes to the crash service (sentry-cli debug-files upload -o <org> -p <project> [--include-sources] [--wait] <paths>, doc). Official desktop templates have no engine symbols.

Import cache (tested)

A project copied with cp -r (a new path and new file times) re-ran --import with no import lines; a cold run of examples/csharp logged 59. So .godot/imported, .godot/uid_cache.bin and .godot/editor/filesystem_cache* can be restored from a cache; .godot/mono holds build output and stale DLLs and stays out. On this small project no time was saved, since startup dominates.

Signing (doc)

Store uploads (doc; nothing was run)

Store Command form Notes
Steam steamcmd +login <account_name> <password> +run_app_build ..\scripts\app_build_1000.vdf +quit VDF keys AppID, Desc, ContentRoot, BuildOutput, Preview "1" ("only outputs logs and a file manifest"), SetLive ("the 'default' branch can not be set live automatically"), Depots with FileMapping. A build account with only Edit App Metadata and Publish App Changes; keep config.vdf between runs instead of a password; errors in BuildOutput/*.log. game-ci/steam-deploy@v3 takes configVdf
itch.io butler push <dir> user/game:channel --userversion 1.2.3 --ignore, --if-changed, --dry-run; butler push-preview --changes-only; BUTLER_API_KEY: "if your API key appears in a public build log, consider it burned"
Epic BuildPatchTool.exe ... -ClientSecretEnvVar="<VAR>" -mode=UploadBinary ..., then -mode=LabelBinary -ClientSecretEnvVar keeps the secret off the command line
GOG GOGGalaxyPipelineBuilder build-game project.json [--version=VERSION] From search snippets only

A release job runs on push: tags or workflow_dispatch behind a protected environment, never on fork pull requests (pull_request_target would expose secrets to fork code); it writes certificates and keystores from base64 secrets to a temporary file deleted in an always() step, and passes no secret as a logged argument.

Kit changes

Not confirmed