Unity's logging system, in depth ② — designing a GC-free ring buffer

A log collector that causes GC spikes defeats itself. Struct entries, fixed arrays, and telling controllable allocations apart from ones you never owned.

  • unity
  • logging
  • performance

Unity logging, part 2 of 3 — ① Callback pitfalls · ② A GC-free ring buffer · ③ What changes in builds (③ coming next)

The previous post covered receiving log callbacks safely. This one is about where they go. The requirement is paradoxical — a collector built to catch performance bugs must not become the source of GC spikes. A diagnostic tool shouldn't contaminate what it observes.

Where the allocations leak

Count the allocation sites in a naive implementation.

// ❌ three allocations per log
_logs.Add(new LogEntry {                // ① heap allocation if it's a class
    Message = $"[{type}] {condition}",  // ② string interpolation = a new string
    ...
});
if (_logs.Count > Max) _logs.RemoveAt(0);  // ③ removing at the front = full copy

In a project where combat scenes emit dozens of logs a second, these three lines quietly build GC pressure.

Principle 1. Struct entries, fixed-array storage

struct LogEntry            // struct, not class — stored inline in the array
{
    public double Time;
    public LogType Type;
    public string Message;   // reference only (see "allocations you never owned")
    public string Stack;
}

sealed class LogRingBuffer
{
    readonly LogEntry[] _entries;   // allocated once at startup, reused forever
    int _head;
    int _count;

    public LogRingBuffer(int capacity) => _entries = new LogEntry[capacity];

    public void Add(in LogEntry entry)
    {
        _entries[_head] = entry;              // struct copy — no heap allocation
        _head = (_head + 1) % _entries.Length;
        if (_count < _entries.Length) _count++;
    }
}

There's no List.RemoveAt(0) either. A wrapping index gives you "overwrite the oldest" for free.

Principle 2. Separate allocations you control from ones you never owned

To be honest, this collector isn't fully zero-alloc. The condition and stackTrace strings were already allocated by Unity before it called you. That cost was paid whether or not you keep the reference.

That distinction simplifies the design.

  • Already-paid allocations (strings Unity made): store the reference. Zero extra cost as long as you don't copy or transform them.
  • Allocations you create (interpolation, formatting, collection resizing): eliminate all of them.
  • If the logging itself is the problem: fix the logs, not the collector — that's the Debug.Log cost problem in part 3.

Formatting ($"[{type}] {condition}") belongs at export time, not at store time. Export happens once, when the user presses capture, so allocation there is a one-off cost rather than a spike.

Principle 3. Borrow the snapshot buffer

Taking a snapshot with ToArray() at capture time allocates one array right then. For most projects, once per capture is acceptable. If captures are frequent or the buffer is large, copying into a reusable buffer removes even that.

// Snapshot into a caller-owned buffer
public int CopyTo(LogEntry[] dest)
{
    lock (_lock)
    {
        int n = Math.Min(_count, dest.Length);
        for (int i = 0; i < n; i++)
            dest[i] = _entries[(_head - n + i + _entries.Length) % _entries.Length];
        return n;
    }
}

The index arithmetic is easy to get wrong (the n entries before head, in chronological order), so this one function is worth a test.

Sizing — how many entries?

If "the last 30 seconds of context" is the goal, take your project's normal log rate × 30 seconds and add headroom for spikes. At 10 logs per second that's 300, so 512 with room to spare. Entries are two references plus value fields, so the buffer itself costs tens of KB; the real memory is in the strings — a function of log volume, not buffer size.


Collection is now safe and storage is quiet. The last variable is the environment — this code, which runs perfectly in the editor, comes back from an IL2CPP release build with empty stack traces and APIs that return nothing but zero. The last post covers what builds change.