Back to all posts

Which Unity memory number should you trust? Eight readings and the one Framedash sends

A Unity game’s memory usage can look different in the Framedash dashboard, Task Manager, and a C# heap counter. In our Windows demo drawing 20,000 cubes, those readings were 167 MB, 739 MB, and 8 MB. They came from different observations: the dashboard and heap figures are medians from separate runs, while Task Manager’s 739 MB is a single-moment reading.

The first thing to check is what each counter measures. Framedash’s Unity SDK reports memory in use by Unity’s allocators, which is different from either the managed heap or the process’s physical-memory footprint. To compare the counters under controlled conditions, we recorded eight readings in every frame of the same build.

Four ways to define memory usage

The readings cover four scopes. Thinking of them as layers helps distinguish the questions they answer, but they are not four nested boxes.

  • Managed heap: memory for C# objects such as class instances and arrays, managed by the garbage collector
  • Unity allocated memory: memory in use within Unity’s allocator pools, including engine-side allocations such as texture data beyond the managed heap
  • Unity reserved memory: the full allocator pools Unity holds from the OS, including capacity available for reuse
  • Process memory: the OS view, which also accounts for memory outside Unity’s allocators, such as executable and DLL mappings

The OS itself provides more than one view. The working set counts pages resident in physical memory; private commit counts memory committed for the process’s exclusive use, whether resident or paged out. In this test the OS readings were larger than Unity’s, but you cannot subtract one scope from another to obtain a reliable breakdown. Graphics-driver memory is a separate counter, not another enclosing layer.

Drawing 20,000 cubes two ways

We used the project from the previous post, with two ways of drawing the same 20,000 cubes:

  • Naive: one GameObject and one MeshRenderer per cube
  • InstancedDirect: no per-cube GameObject; a script draws the cubes with Graphics.RenderMeshInstanced

These correspond to NAIVE and INSTANCED_DIRECT in the previous post. The environment was Unity 6000.4.11f1, the Built-in Render Pipeline, D3D12, the Mono scripting backend, an RTX 4070 Ti, and 1920x1080. The main table uses release players with telemetry transmission off.

Each mode ran a 15-second warmup followed by 120 seconds of measurement. In this experiment, we took all eight readings in the same frame, immediately after the SDK’s Collect() call. Each table entry is that counter’s median (p50) over the measured frames. The row values therefore do not describe a single frame.

In the tested Mono player, System.Diagnostics.Process.WorkingSet64 and PrivateMemorySize64 both returned 0. We used P/Invoke to call psapi's GetProcessMemoryInfo instead. A cross-check against Get-Process running outside the player gave medians of 848.5 MB and 848.7 MB, a difference of 0.2 MB.

Eight readings, each summarized over 120 seconds

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 -

The shaded row is the counter the Framedash Unity SDK sends. Frame-time p50 was 9.25 ms for Naive and 1.44 ms for InstancedDirect, consistent with the previous post. That post’s 169.0 MB and this run’s 167.2 MB came from separate runs with the same build configuration and arguments; both use the allocated-memory counter.

A horizontal bar chart of six memory readings, each with the Naive and InstancedDirect values side by side. From the top: GetMonoUsedSizeLong 8.2 and 6.9 MB, GetMonoHeapSizeLong 10.3 and 7.9 MB, the shaded GetTotalAllocatedMemoryLong 167.2 and 56.6 MB, GetTotalReservedMemoryLong 263.4 and 155.2 MB, OS working set 852.4 and 412.0 MB, OS private (commit) 1,168.3 and 681.9 MB
Linear scale. Six of the eight readings are plotted: 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.

Switching from Naive to InstancedDirect reduced Unity allocated memory by 66%, managed-heap usage by 16%, and managed-heap capacity by 23%. All three describe the same change. A claim such as “memory fell 66%” needs the counter name to be useful.

What the counters tell us

Managed-heap usage and capacity

GetMonoUsedSizeLong reports managed-heap usage, including objects awaiting collection; GetMonoHeapSizeLong reports its capacity. Their Naive medians were 8.2 MB and 10.3 MB, a difference of 2.1 MB. The heap has room that is not currently occupied by objects, though subtracting these medians is not a separate measurement of free space.

GC.GetTotalMemory(false) matched the used-heap reading at the displayed precision on this Mono backend. That agreement is a result of this test, not a promise for every backend. Heap usage also does not measure allocation rate or collection time. For GC-related frame-time problems, inspect per-frame allocations and collection activity in the Profiler alongside heap size.

Unity’s used and reserved allocator pools

GetTotalAllocatedMemoryLong reports memory in use within Unity’s allocator pools. The 8.2 MB managed-heap reading is only about 5% of the 167.2 MB allocated reading. Its small decline cannot explain the 110.6 MB reduction in allocated memory. That points to native engine memory as the main saving in this mode change, although this test does not provide an object-by-object breakdown.

GetTotalReservedMemoryLong includes unused capacity in those pools. Naive reserved 263.4 MB and used 167.2 MB; the difference between those medians is 96.2 MB. For InstancedDirect it is 98.6 MB, almost unchanged. This explains why the percentage reductions differ, 41% reserved versus 66% allocated, even though the absolute reductions are close: 108.2 MB and 110.6 MB. These differences between medians are not separately sampled unused-memory values.

Process working set and private commit

The OS working set counts pages currently resident in physical memory, including shared pages. Task Manager calls this Working set. Private commit counts committed memory exclusive to the process, including memory that has been paged out; it corresponds to Commit size.

Naive’s working-set median of 852.4 MB exceeds its allocated-memory median of 167.2 MB by 685.2 MB. That gap is a difference between counters with different definitions, not a measured category of memory. Executable and DLL mappings, graphics-driver allocations, and plugin allocations can sit outside Unity’s allocator tracking. This experiment does not determine how much each contributes.

Graphics-driver memory and an unavailable reading

GetAllocatedMemoryForGraphicsDriver provides readings in the Editor and development players, with native reporting gated by ENABLE_PROFILER. It returned 0 in our release players and 99.0 MB in both development modes. We observed no difference between the modes at this precision.

The release value of 0 means the reading was unavailable, not that the driver used no memory. Framedash omits mem.vram when this API does not return a positive value.

Why Task Manager shows another number

On Task Manager’s Processes tab, the executable’s Memory column read 738.7 MB. At the same time, external queries returned a working set of 795.5 MB and private commit of 1,091.3 MB. The displayed column was the private working set: resident pages exclusive to the process. It excludes shared resident pages and paged-out private memory, so it matches neither OS counter in the table.

Those single-moment observations also differ from the table’s medians of 852.4 MB and 1,168.3 MB over 120 seconds. Both the counter and the sampling window matter.

Keep the build type fixed when comparing changes

We also measured both modes in development builds. Allocated memory was 183.6 MB and 84.6 MB; reserved memory was 337.7 MB and 221.5 MB. All four exceeded the release figures: 167.2 MB and 56.6 MB allocated, 263.4 MB and 155.2 MB reserved. Development builds include profiling instrumentation, but this comparison does not isolate its share of the increase.

The working set moved in opposite directions: 792.1 MB for Naive, below its release result, and 469.6 MB for InstancedDirect, above it. A rise in allocator usage need not produce a matching rise in resident pages. To judge an optimization, compare release with release or development with development. Otherwise, the build configuration becomes another variable.

Two charts comparing the release build with the development build. On the left, allocated memory: 167.2 and 56.6 MB in release, 183.6 and 84.6 MB in development. On the right, the OS working set: 852.4 and 412.0 MB in release, 792.1 and 469.6 MB in development
The two panels use different scales, so compare within a panel, not across. Allocated rises in the development build for both modes, by 16.4 MB and 28.0 MB, while the working set does not move the same way: -60.3 MB for Naive and +57.6 MB for InstancedDirect.

What Framedash puts in its memory field

Its wire schema has one int64 field, memory_used_bytes, but the source depends on the engine. The dashboard divides bytes by 1048576 and labels the result MB; that divisor corresponds to MiB.

  • Unity: Profiler.GetTotalAllocatedMemoryLong(), the counter that read 167.2 MB and 56.6 MB here. The SDK includes it in the perf_heartbeat sent every 10 seconds
  • UE5: FPlatformMemory::GetStats().UsedPhysical, a platform-specific process physical-memory statistic, rather than Unity’s allocator counter
  • Godot (C#): Performance.Monitor.MemoryStatic when it returns a positive reading; otherwise the SDK falls back to System.GC.GetTotalMemory(false). Exported release builds commonly use this managed-heap fallback

In Godot, the same field can therefore change meaning with the build configuration. Do not compare it across builds that use different sources. The Unity readings of 8.2 MB for the managed heap and 167.2 MB for allocated memory illustrate the distinction; they are not a conversion factor for Godot.

The Unity SDK also attaches mem.heap from GetMonoUsedSizeLong and mem.vram from GetAllocatedMemoryForGraphicsDriver when the respective API returns a positive value. A missing key is not a zero-byte measurement.

The run with telemetry enabled

With the SDK transmitting telemetry in a separate run, GetMonoUsedSizeLong p50 rose from 8.2 MB to 9.8 MB in Naive and from 6.9 MB to 8.8 MB in InstancedDirect. Allocated memory was 167.0 MB and 56.6 MB, and the working set was 857.3 MB and 415.3 MB. Those allocated and working-set readings were all within 5 MB of their telemetry-off counterparts.

The managed-heap increases of 1.6 MB and 1.9 MB are observations for this configuration, not a general SDK overhead estimate. The results do not separate event buffers, serialization, and SDK logging, and the run records do not establish whether the logging settings matched.

Framedash’s build comparison shows p50 and p95 together. The p95 can reveal increases among higher readings even when the median stays similar, but it is not the peak. framedash perf-diff can run from CI for a pre-release build comparison. Check the sampling population before comparing its output with a per-frame benchmark.

The Framedash build comparison screen showing build ids 20260828-Naive-memlayers-tel and 20260828-InstancedDirect-memlayers-tel side by side, with the memory p50 and p95 columns visible
The two build ids compared. This is the run measured with telemetry on, where memory p50 goes from 167.0 MB to 56.6 MB. The percentiles on this screen are over the 133 events each build sent, not over frames like the table above.

Where these results apply

The measurements cover Windows, D3D12, Mono, a desktop GPU, and 1920x1080. We did not test IL2CPP or another resolution. The workload rewrites all 20,000 positions every frame. Other platforms, backends, resolutions, and workloads need their own measurements; the relationships and reductions here are not general Unity ratios.

Choose a counter, then keep the comparison consistent

A report saying only “memory is 852 MB” leaves out what was counted. The same run also produced 8.2 MB and 1,168.3 MB. Choose the measurement for the question:

  • To check a change in Unity’s allocator usage, compare allocated memory; use reserved memory to see the pool capacity as well
  • To assess device memory demands, inspect process working set and commit on the target platform, including peaks. A 120-second median alone cannot establish that the game will fit
  • To investigate garbage collection, examine managed-heap usage together with allocation rate and collection activity

For a before-and-after test, keep the counter, platform, scripting backend, build type, resolution, workload, and telemetry settings consistent. Use the same warmup, sampling cadence, and aggregation window. A Task Manager snapshot, a per-frame p50, and a percentile of telemetry events answer different questions even when all three display MB.