The “memory usage” of a game built in Unity changes depending on where you read it. We ran a demo that draws 20,000 cubes on Windows and measured it: the Framedash dashboard showed 167 MB, the Windows Task Manager showed 739 MB, and a Unity API reading the C# heap gave 8 MB. The three did not come from one moment, or even one run: the dashboard and API figures are medians, each over a run of its own, and Task Manager’s is a single instant we come back to later in this post. They disagree because they count different things. This post records memory eight ways at once from the same build and sorts out what each reading counts and which one Framedash sends.
Memory splits into four layers, from the inside out
How much memory a game uses depends on where you stop counting, and that gives four layers.
The innermost layer is where the objects your C# code creates live. Classes and arrays you new pile up here, and this is what the garbage collector cleans.
Outside that sits what the Unity engine actually has in use. Along with the C# objects, it covers what the engine holds internally, texture data among it.
Further out is what Unity has taken from the OS in bulk and keeps on hand. It does not hand memory back the moment it is done with it; it holds on for the next allocation, which makes this layer larger than what is in use.
The outermost layer is the whole process as the OS sees it. That takes in what Unity’s own allocators do not track: the executable, DLLs, the graphics driver.
In this measurement every layer outward read higher than the one inside it. The four are not strict subsets of one another, though: the working set counts the pages resident in physical memory, while private commit counts the pages the process has committed, paged out or not. Almost every memory readout you look at is a slice of one of these four. The exception is the graphics-driver reading further down, which is a counter of its own rather than a fifth layer wrapped around them.
Drawing 20,000 cubes two ways
The project is the same one from the last post: 20,000 cubes, drawn two ways that differ only in how they reach the screen.
- Naive: one GameObject and one MeshRenderer per cube
- InstancedDirect: no per-cube GameObject; a single script draws them with
Graphics.RenderMeshInstanced
These are the NAIVE and INSTANCED_DIRECT of the previous post. The environment is Unity 6000.4.11f1, the Built-in Render Pipeline, the D3D12 graphics API, an RTX 4070 Ti, and 1920x1080. We measured in a release player, not a development build.
Each mode ran a 15-second warmup followed by 120 seconds of measurement. All eight readings are taken together inside one frame, right after the SDK’s Collect(). The figures in the table below, though, are the median (p50) of each reading over those 120 seconds of frames, not a transcript of any single frame.
You cannot get the OS-level numbers from inside the Unity player with System.Diagnostics.Process.WorkingSet64 or PrivateMemorySize64. On the Mono runtime both return 0. We called psapi's GetProcessMemoryInfo through P/Invoke instead and cross-checked it against Get-Process run from outside: medians of 848.5 MB and 848.7 MB, so the two agree.
Eight values read from the same build
| Reading | Naive | InstancedDirect | Change |
|---|---|---|---|
GetMonoUsedSizeLong |
8.2 MB | 6.9 MB | -16% |
GC.GetTotalMemory(false) |
8.2 MB | 6.9 MB | -16% |
GetMonoHeapSizeLong |
10.3 MB | 7.9 MB | -23% |
GetTotalAllocatedMemoryLong |
167.2 MB | 56.6 MB | -66% |
GetTotalReservedMemoryLong |
263.4 MB | 155.2 MB | -41% |
| OS working set | 852.4 MB | 412.0 MB | -52% |
| OS private (commit) | 1,168.3 MB | 681.9 MB | -42% |
GetAllocatedMemoryForGraphicsDriver |
0 | 0 | - |
Three of the eight read the innermost layer, one reads what the engine has in use and one what it has borrowed, two read the OS side, and one reads the graphics driver. The shaded row is the layer the Framedash Unity SDK sends. Frame time p50 was 9.25 ms and 1.44 ms, in line with the previous post. The previous post’s 169.0 MB and today’s 167.2 MB are separate runs of the same build configuration, launched with the same arguments; in both, memory_used_bytes is this same allocated layer.
GC.GetTotalMemory(false) equals GetMonoUsedSizeLong at this precision, and GetAllocatedMemoryForGraphicsDriver is 0 in the release player. The dark bar is Naive, the light bar is InstancedDirect, and the shaded row is the layer the Framedash Unity SDK sends.The size of the win depends on which layer you quote it from. Allocated memory fell 66%; the managed heap in use fell 16%, and the heap reserved to hold it fell 23%. “We cut memory by 66%” and “memory only moved 16%” are one change seen from two layers, and both numbers are correct.
What each layer counts
GetMonoUsedSizeLong and GetMonoHeapSizeLong read the innermost layer, the managed heap where your C# objects live. In-use was 8.2 MB against 10.3 MB of heap, so 2.1 MB of that heap was reserved and empty. GC.GetTotalMemory(false) matched the in-use figure to the digits shown because both report the same managed heap. If you are chasing GC pressure, this is the layer to watch.
GetTotalAllocatedMemoryLong is the total that Unity’s internal allocators actually have in use. Of the 167.2 MB, the managed heap is 8.2 MB, or 5%. The rest is native, and so is nearly all of the 110.6 MB that disappeared when we stopped creating 20,000 GameObjects.
GetTotalReservedMemoryLong is the size of the pool Unity has taken from the OS and is holding. Allocated is what is in use inside that pool. Naive reserved 263.4 MB and used 167.2 MB of it, leaving 96.2 MB sitting empty. InstancedDirect left 98.6 MB empty, almost the same. As percentages the two layers look far apart, -41% against -66%, but in megabytes they shed 108.2 and 110.6, which is nearly identical.
The OS working set is the pages the process currently has resident in physical memory, the same thing Task Manager calls “Working set”. Private (commit) is the pages the process has committed for its own use, including any that have been paged out; Task Manager calls that “Commit size”.
In Naive, allocated read 167.2 MB while the working set read 852.4 MB. That is a gap of 685.2 MB. It is the distance between two counters that do not measure the same thing — the working set counts pages resident in physical memory, allocated counts what Unity’s allocators have in use — so it is not a measured amount of any one thing. Executable and DLL mappings, the graphics driver and plugin allocations all sit in the process and outside what Unity’s allocator tracks, but this measurement does not break the difference down.
The last reading, GetAllocatedMemoryForGraphicsDriver, is not one of the four layers. It is a counter of its own, reporting what the graphics driver has allocated. It returned 0 in the release build. The API needs ENABLE_PROFILER; measured in a development build, it reads 99.0 MB in both modes. Because both modes agree, dropping the GameObjects did not move the driver-side allocation. The 0 means “could not read”, not “using zero bytes”. The Framedash Unity SDK follows the same rule and omits the mem.vram key entirely when the reading is 0.
Task Manager’s Memory column matches nothing in the table
On Task Manager’s Processes tab, the Memory column for this executable read 738.7 MB. Queried from outside at the same moment, the working set was 795.5 MB and private was 1,091.3 MB. All three differ. What the Processes tab shows in that column is the private working set, which is neither of the two OS values in the table above.
None of those three matches the table’s 852.4 MB or 1,168.3 MB either. Task Manager shows a single-moment reading; the table holds the median over 120 seconds.
Do not mix development-build numbers with release ones
We measured both modes in a development build too. Allocated came to 183.6 MB and 84.6 MB, reserved to 337.7 MB and 221.5 MB. All four are above the release figures of 167.2 MB and 56.6 MB allocated, 263.4 MB and 155.2 MB reserved, because the profiler instrumentation costs memory of its own.
The working set did not move in one direction. It read 792.1 MB and 469.6 MB, so Naive was lower than release and InstancedDirect was higher. Allocated and reserved go up, and the working set does not reliably follow. That is why you cannot line up a development-build number against a release-build one and talk about the difference.
What Framedash sends is a different layer per engine
The wire schema has one memory field, an int64 memory_used_bytes. The dashboard divides it by 1048576 and shows MB. One field, but the layer that fills it depends on the engine.
- Unity:
Profiler.GetTotalAllocatedMemoryLong(), the 167.2 MB and 56.6 MB layer from this article, sent on theperf_heartbeatevery 10 seconds - UE5:
FPlatformMemory::GetStats().UsedPhysical, a resident-physical figure, so a layer closer to the OS number than Unity’s - Godot (C#):
Performance.Monitor.MemoryStatic. That monitor is unavailable in exported release builds, and the SDK falls back toSystem.GC.GetTotalMemory
Godot (C#) is the one case where the same column changes meaning with the build type. For a sense of how much lower a managed-heap-only number lands, use the table above: in Unity it was 8.2 MB against 167.2 MB.
The Unity SDK can also send the managed heap and the driver side under their own keys: mem.heap from GetMonoUsedSizeLong, and mem.vram from GetAllocatedMemoryForGraphicsDriver. Neither is sent when it reads 0.
In a separate run measured with the SDK sending telemetry, the GetMonoUsedSizeLong p50 went up: 8.2 MB to 9.8 MB in Naive, and 6.9 MB to 8.8 MB in InstancedDirect. Allocated read 167.0 MB and 56.6 MB in that run and the working set 857.3 MB and 415.3 MB, all within 5 MB of the telemetry-off run. The 1.6 MB and 1.9 MB that appeared are what the SDK costs the managed heap in this configuration. This run does not split that between the event buffers, serialization, and the SDK’s own logging.
A build-to-build comparison reports p50 and p95 side by side, so a change that leaves the median alone and stretches the tail still shows up. Run framedash perf-diff from CI and you can check the difference between builds at that granularity before you release.
How far these results carry
Everything above comes from one configuration: Windows, D3D12, the Mono backend, and a desktop GPU. How wide the gap runs between allocated and the working set changes with the platform and the scripting backend. We did not measure an IL2CPP build.
The relationships between layers are also specific to this setup. GC.GetTotalMemory(false) matched GetMonoUsedSizeLong on this Mono backend; nothing guarantees the same on another one.
Change the resolution and the working set moves with it. We have no numbers outside 1920x1080. The workload also rewrites all 20,000 positions every frame, which is worth checking before you map any of this onto your own project.
Name the layer before you quote the number
“Memory is 852 MB” does not settle what you are talking about. From the same run you could just as truthfully say 8.2 MB, or 1,168.3 MB.
The question you are asking picks the layer.
- Did my change reduce what the engine holds: allocated memory
- Will it fit in the device’s memory: working set and commit size
- GC pressure: the managed heap
When you compare, hold the layer, the build type, and the resolution fixed. And if you are on Framedash, check which layer your engine reports before you read the chart.
- The definitions of the metrics sent by the SDK are collected in the data model.
- The setup steps for the SDK are in the Unity SDK guide.