Temp Memory Reuse
Temp
memory is backed by a fixed size block that’s cleared by Unity every frame. Allocations on subsequent frames return pointers to this same block. The allocated memory therefore isn’t unique. How much of a problem is this? Today we’ll do some experiments to find out!
Outside of a Job
First up, let’s try allocating Temp
memory from outside of a job. We’ll allocate once in Start
and write 123
to the allocated memory. Then we’ll allocate once every Update
. If the pointer to the newly-allocated memory matches the pointer to the first memory allocation, we’ll print the frame the reuse was detected on and the value stored at the allocation.
using System; using Unity.Burst; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; using Unity.Jobs; using UnityEngine; unsafe class TestScript : MonoBehaviour { private void* first; private void Start() { first = UnsafeUtility.Malloc(4, 4, Allocator.Temp); *(int*)first = 123; } private void Update() { void* alloc = UnsafeUtility.Malloc(4, 4, Allocator.Temp); if (alloc == first) { print("Reused on frame " + Time.frameCount + ": " + *(int*)alloc); } } }
Running this on Unity 2019.3.0f6, we see a print on every single frame starting at 1
:
Reused on frame 1: 123 Reused on frame 2: 123 Reused on frame 3: 123 Reused on frame 4: 123 Reused on frame 5: 123 ...
The value we read is always 123
, indicating that Unity isn’t clearing the contents of the fixed size block, such as by setting all the bytes to zero, but rather just resetting its internal tracking of the next byte to allocate. This is faster since there’s no need to write zeroes to the whole block, which is about 16 KB, but it may cause more subtle bugs to creep in.
For example, a game might inadvertently rely on Temp
memory not being cleared across frames only to later have a seemingly-unrelated change to the code cause that memory to be overwritten by writing to another allocation. In the case of the above script, imagine writing 456
to alloc
and then later reading first
and expecting it to still be 123
. Because the allocated memory was reused, the problem of aliasing has been introduced and can be quite difficult to debug.
NativeArray
Next up, we’ll use NativeArray
to perform the allocation. We won’t use UnsafeUtility.Malloc
or deal with raw pointers anymore.
This script has the same elements as the previous one: an initial allocation in Start
followed by an allocation every Update
. We can compare the allocations using the overloaded ==
operator in NativeArray
. We’ll also pass NativeArrayOptions.UninitializedMemory
to the NativeArray
constructor so as to not clear out the contents of the array.
using System; using Unity.Burst; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; using Unity.Jobs; using UnityEngine; class TestScript : MonoBehaviour { private NativeArray<int> first; private void Start() { first = new NativeArray<int>(1, Allocator.Temp); first[0] = 123; } private void Update() { NativeArray<int> alloc = new NativeArray<int>( 1, Allocator.Temp, NativeArrayOptions.UninitializedMemory); if (alloc == first) { print("Reused on frame " + Time.frameCount + ": " + alloc[0]); } } }
This prints the same messages as before:
Reused on frame 1: 123 Reused on frame 2: 123 Reused on frame 3: 123 Reused on frame 4: 123 Reused on frame 5: 123 ...
The reason is simple if the programmer knows how the NativeArray
constructor is implemented. Here’s a decompiled version:
public unsafe NativeArray(int length, Allocator allocator, NativeArrayOptions options = NativeArrayOptions.ClearMemory) { NativeArray<T>.Allocate(length, allocator, out this); if ((options & NativeArrayOptions.ClearMemory) != NativeArrayOptions.ClearMemory) return; UnsafeUtility.MemClear(this.m_Buffer, (long) this.Length * (long) UnsafeUtility.SizeOf<T>()); } private static unsafe void Allocate(int length, Allocator allocator, out NativeArray<T> array) { long size = (long) UnsafeUtility.SizeOf<T>() * (long) length; if (allocator <= Allocator.None) throw new ArgumentException("Allocator must be Temp, TempJob or Persistent", nameof (allocator)); if (length < 0) throw new ArgumentOutOfRangeException(nameof (length), "Length must be >= 0"); NativeArray<T>.IsUnmanagedAndThrow(); if (size > (long) int.MaxValue) throw new ArgumentOutOfRangeException(nameof (length), string.Format("Length * sizeof(T) cannot exceed {0} bytes", (object) int.MaxValue)); array = new NativeArray<T>(); array.m_Buffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf<T>(), allocator); array.m_Length = length; array.m_AllocatorLabel = allocator; array.m_MinIndex = 0; array.m_MaxIndex = length - 1; DisposeSentinel.Create(out array.m_Safety, out array.m_DisposeSentinel, 1, allocator); }
The first line calls NativeArray<T>.Allocate
which calls UnsafeUtility.Malloc
with the Allocator.Temp
parameter we passed into the constructor. Because we didn’t pass NativeArrayOptions.ClearMemory
for the contructor’s options
parameter, UnsafeUtility.MemClear
wasn’t called to clear out the memory.
Essentially, we’ve recreated what the first script did using NativeArray
. The printed messages are therefore the same. That said, there are some interesting behaviors to note here.
Reading the contents of the NativeArray
created in Update
with alloc[0]
worked every time. No exception was thrown from any of the “sentinels” or “safeties” that NativeArray
uses. Specifically, here’s how the indexer looks:
public unsafe T this[int index] { get { this.CheckElementReadAccess(index); return UnsafeUtility.ReadArrayElement<T>(this.m_Buffer, index); } [WriteAccessRequired] set { this.CheckElementWriteAccess(index); UnsafeUtility.WriteArrayElement<T>(this.m_Buffer, index, value); } } internal AtomicSafetyHandle m_Safety; [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")] private unsafe void CheckElementReadAccess(int index) { if (index < this.m_MinIndex || index > this.m_MaxIndex) this.FailOutOfRangeError(index); AtomicSafetyHandleVersionMask* versionNode = (AtomicSafetyHandleVersionMask*) (void*) this.m_Safety.versionNode; if ((this.m_Safety.version & AtomicSafetyHandleVersionMask.Read) != ~(AtomicSafetyHandleVersionMask.WriteInv | AtomicSafetyHandleVersionMask.Write) || this.m_Safety.version == (*versionNode & AtomicSafetyHandleVersionMask.WriteInv)) return; AtomicSafetyHandle.CheckReadAndThrowNoEarlyOut(this.m_Safety); }
AtomicSafetyHandle
doesn’t protect us from reading memory that’s been allocated to another NativeArray
. The protection Unity offers is that we’re no longer allowed to write to first
after it’s implicitly disposed at the end of the frame. To see what happens, let’s add a write before the print
:
first[0] = 123; print("Reused on frame " + Time.frameCount + ": " + alloc[0]);
Now we get this exception from first[0] = 123
:
InvalidOperationException: The NativeArray has been deallocated, it is not allowed to access it Unity.Collections.LowLevel.Unsafe.AtomicSafetyHandle.CheckWriteAndThrowNoEarlyOut (Unity.Collections.LowLevel.Unsafe.AtomicSafetyHandle handle) <0x160398f10 + 0x00052> in <96c9baffa9414fe5b4eb911e74f8f90e>:0 Unity.Collections.NativeArray`1[T].CheckElementWriteAccess (System.Int32 index) (at /Users/builduser/buildslave/unity/build/Runtime/Export/NativeArray/NativeArray.cs:132) Unity.Collections.NativeArray`1[T].set_Item (System.Int32 index, T value) (at /Users/builduser/buildslave/unity/build/Runtime/Export/NativeArray/NativeArray.cs:147) TestScript.Update () (at Assets/TestScript.cs:69)
This guarantees that the memory allocation is only accessible by NativeArray
instances for the frame it was allocated. A NativeArray
created with NativeArrayOptions.UninitializedMemory
on a future frame might rely on this old memory, but this is the cost of that option.
Allocations in Jobs
Finally, let’s allocate Temp
memory from within a job, do several frames of pointless work, then allocate again. We’ll store these pointers in a NativeArray
allocated with Persistent
. A script will run the job asynchronously, and check on its status every Update
. When the job is done, we’ll print the frame it was done on, the two allocated pointers, and the distance between them.
using System; using Unity.Burst; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; using Unity.Jobs; using UnityEngine; [BurstCompile] unsafe struct TestJob : IJob { public NativeArray<long> Reused; public void Execute() { // First allocation Reused[0] = (long)UnsafeUtility.Malloc(4, 4, Allocator.Temp); // Do several frames of pointless work for (int i = 0; i < 1000000000; ++i) { if (Reused[0] == 0) { Reused[0]++; } } // Second allocation Reused[1] = (long)UnsafeUtility.Malloc(4, 4, Allocator.Temp); } } class TestScript : MonoBehaviour { private NativeArray<long> reused; private JobHandle handle; private bool isDone; private void Start() { reused = new NativeArray<long>(2, Allocator.Persistent); handle = new TestJob { Reused = reused }.Schedule(); } private void Update() { if (!isDone && handle.IsCompleted) { isDone = true; handle.Complete(); print( "\nFrame: " + Time.frameCount + "\nFirst: " + reused[0] + "\nSecond: " + reused[1] + "\nDistance: " + (reused[1] - reused[0] - 4)); } } private void OnDestroy() { handle.Complete(); reused.Dispose(); } }
Here’s what this prints:
Frame: 77 First: 5086422048 Second: 5086422080 Distance: 28
The pointless work we had the job do successfully delayed the second allocation by 76 frames. Unity should have cleared the Temp
allocator after the first frame and the next 75 frames. However, the second allocation doesn’t match the first allocation so there’s been no reuse. It’s 28 bytes after its end instead of the 16 bytes we saw before, but that’s likely due to the upgrade to Unity 2019.3 since previous articles on this topic.
This points to there being a Temp
allocator for jobs that is distinct from the Temp
allocator that’s used outside of jobs because we’ve seen in the first script and before that the allocator is cleared every frame.
Conclusion
Raw Temp
memory allocation with UnsafeUtility.Malloc
outside of a job is dangerous. The memory will be implicitly reclaimed and reallocated later. The resulting aliasing may cause bugs that are difficult to debug and there will be no warnings from Unity to help.
Using native collections like NativeArray
with properly-implemented safety checks doesn’t eliminate the potentially-surprising implicit deallocations, but at least Unity will produce warnings in the form of exceptions.
Jobs apparently have their own Temp
allocator. It appears to not be cleared every frame like the Temp
allocator outside of jobs is. Multi-frame jobs therefore don’t need to worry about reuse of the fixed size block that backs the Temp
allocator. They already have plenty to worry about.