TL;DR

A 5-minute music track set to Decompress On Load froze the main thread for about 390 ms on every cold play, in a Development Build. Switching the clip to Compressed In Memory or Streaming brought the worst frame down to about 2 ms. Object pooling the AudioSource made the play call about 0.02 ms faster, which is real but irrelevant to that spike.

Background

I recently took over a Unity project at work whose GameManager plays audio by instantiating a GameObject with an AudioSource, then destroying it after a set time. The project was built in a hurry, and the frame rate sometimes drops when the SoundManager does something. Clicking the spike in the Profiler showed file-related markers alongside the SoundManager.

The advice I got was to pool the SoundManager’s objects. I had read about object pooling but never used it. So I built a separate test project at home to answer one question before touching the work code: is creating and destroying the GameObject actually what costs the frames?

My hypothesis going in was that it probably isn’t. Creating one GameObject is cheap. Loading and decoding a long audio file is not.

Test setup

The test separates two variables that the work project mixes together: how the AudioSource is created, and how the clip is imported.

Three playback methods

MethodWhat it does
InstantiateMimics the work SoundManager: Instantiate a prefab with an AudioSource, Play(), Destroy after 10 s
PooledGets an AudioSource from UnityEngine.Pool.ObjectPool<AudioSource>, plays it, releases it after 10 s
DirectPlays on one AudioSource that already exists in the scene. No GameObject work at all: the baseline

If Direct spikes as badly as Instantiate, the GameObject was never the problem.

Five import-setting variants of the same mp3 (Vorbis, same quality, Load In Background off for all)

ClipLoad TypePreload Audio Data
BGM_DOL_PreOnDecompress On Loadon
BGM_DOL_PreOffDecompress On Loadoff
BGM_CIM_PreOnCompressed In Memoryon
BGM_CIM_PreOffCompressed In Memoryoff
BGM_StreamStreaming(default)

Cold and warm plays

  • Cold: AudioClip.UnloadAudioData() before every play, so Unity has to load the data at play time. This is the first time a track plays in a real game.
  • Warm: the data is already in memory from the previous play. This is the same track playing again.
  • A Streaming clip never reports Loaded, so it has a single phase.

Each clip × phase × method cell ran 5 times: 135 recorded runs per session.

Using Unity’s ObjectPool<T>

Unity has shipped a generic pool in UnityEngine.Pool since 2021.1, so there’s no need to write your own. You hand it four callbacks: how to create an item, and what to do on get, release and destroy.

pool = new ObjectPool<AudioSource>(
    createFunc: CreateSource,
    actionOnGet: src => src.gameObject.SetActive(true),
    actionOnRelease: OnReleaseSource,      // Stop(), clip = null, SetActive(false)
    actionOnDestroy: src => Destroy(src.gameObject),
    collectionCheck: true,
    defaultCapacity: prewarmCount,
    maxSize: maxSize);

Playing a sound becomes pool.Get(), set the clip, Play(). When its time is up, pool.Release(src) puts it back.

Things I didn’t know until I used it

  • The pool is lazy. defaultCapacity only sizes the internal stack; it creates nothing. The first Get() still calls Instantiate. To pay that cost at startup, prewarm by getting N items and releasing them all:
using (ListPool<AudioSource>.Get(out List<AudioSource> temp))
{
    for (int i = 0; i < count; i++) temp.Add(pool.Get());
    foreach (AudioSource src in temp) pool.Release(src);
}
  • maxSize caps what the pool keeps, not what it hands out. Release more than maxSize items and the extras are destroyed.
  • collectionCheck: true throws if you release the same object twice. It’s a cheap safety net while developing.
  • The namespace has more than ObjectPool. ListPool, used in the prewarm above, lends you a temporary List<T> without allocating a new one each time.

Measuring without fooling myself

For each play, the harness records three numbers and appends a row to a CSV:

  • callMs: how long the play call itself took, timed with Stopwatch.
  • worstFrameMs: the worst main-thread frame time in the 30 frames after the play. Some loading happens a frame later, so watching only the frame of the call would miss it.
  • gcKB: total managed allocation over those 30 frames.

The frame and GC numbers come from ProfilerRecorder, which works in Development Builds without the Profiler window attached:

mainThread = ProfilerRecorder.StartNew(ProfilerCategory.Internal, "Main Thread");
gcAlloc    = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame");

LastValue returns the previous frame, so Update collects the numbers first and handles input second. The frame in which a play happened is then read on the next Update.

Each method is also wrapped in a ProfilerMarker (AudioBench.Pooled and so on), so it can be found by name in the Profiler’s timeline.

Things that would have skewed the numbers

  • The Editor. Editor runs showed about 300 KB of GC and a 4 ms worst frame for every method, including the baseline. In a Development Build both dropped to 0.4 KB and about 1–2 ms. The Editor’s own Console and Inspector work was being counted. Only Build numbers are used below.
  • vSync. With vSync on, every frame is padded to about 16.6 ms, which hides differences of a millisecond. The harness sets vSyncCount = 0 and an uncapped frame rate.
  • The Profiler connection. An attached Profiler adds overhead, so the CSV runs had Autoconnect Profiler off.
  • My own on-screen text. IMGUI allocates every frame, so the status overlay disables itself during each 30-frame window.
  • My own hands. At first I pressed keys manually, made mistakes, and left uneven gaps between runs. Pressing R now runs the whole matrix as a coroutine: 3 unrecorded warm-up plays, then 135 recorded runs with a 1-second pause between them. It takes about 4 minutes.

Results

Across two Development Build runs (270 rows), a cold play of Decompress On Load is the only case that costs more than one frame: about 394 ms, roughly 24 frames lost at 60 fps.

The playback method barely matters

MethodPlay call, warm (ms)Worst frame, cold Decompress On Load (ms)
Instantiate0.066391.5
Pooled0.033397.3
Direct0.027393.7

The warm play call is measured on the Compressed In Memory clips. Pooling halves the call cost compared with Instantiate, but the saving is about 0.03 ms, under 0.2% of a 16.6 ms frame. Against the decompression freeze, the three methods are within 6 ms of each other.

GC was 0.4–0.5 KB per play for every method, including Instantiate. Creating one GameObject happens mostly in native code and barely touches the managed heap.

Preload Audio Data made no difference to cold plays. Preload only decides whether the data loads at scene load; unloading before each play removes that, and the on/off cold medians were within a few ms.

The pattern that wasn’t there

The first Build run suggested that Instantiate and Pooled made the cold freeze worse than Direct. Running it again in reverse order showed that this was noise.

Cold Decompress On Load, median worst frameInstantiatePooledDirect
Run 1455 ms421 ms403 ms
Run 2 (clip order reversed)386 ms390 ms393 ms

In run 1, the gap came entirely from one clip, BGM_DOL_PreOff, whose cold plays scattered between 420 and 700 ms. My first explanation was an order effect: perhaps the first Decompress On Load clip in a run is always slower. Run 2 put BGM_DOL_PreOn first instead. If the order effect were real, the scatter would have moved to that clip. It didn’t: both clips stayed tight at about 385–405 ms, and the three methods ended within 7 ms of each other.

So the honest statement is: in 2 of 3 Build blocks, the freeze was about 390 ms whatever the method, and one block scattered for a reason I haven’t identified. One untested guess is that run 1 was the first launch after building, so the OS had not cached the asset file yet.

If I had stopped after one run, I would have published a difference between methods that doesn’t exist.

What I’ll do with this

For a long music track, the spike comes from the clip’s Load Type, not from how the AudioSource is created. Pooling would not have fixed it.

  1. Check the import settings first. Before refactoring any audio code, look at the Load Type of long clips. Decompress On Load on a multi-minute track is a red flag: the whole track is decoded into uncompressed audio on the main thread.
  2. Use Streaming or Compressed In Memory for music. Both stayed under 2 ms in every run. Streaming also keeps memory low, because only a buffer is held at a time.
  3. Give background music its own AudioSource or two (two for crossfades) rather than creating one per track.
  4. Keep pooling for frequent short sound effects. That’s where saving 0.03 ms per call, many times per second, starts to add up.
  5. Measure in a build, run it twice, and automate it. The Editor added about 3 ms and 300 KB of noise to every frame, and a single run showed a pattern that wasn’t real.

Next, I’ll check the Load Type and the profiler markers in the actual work project before changing any code.

What I didn’t test

  • One machine, one platform. All numbers are from my home PC (standalone Development Build). Mobile devices decode more slowly, so the freeze would probably be longer, but I haven’t measured it.
  • Load In Background. It was off for every clip. It could move the decode off the main thread, and it’s the next variable to test.
  • Many sounds at once. Each run played one clip on one source. Pooling’s real advantage, many short sounds per second, wasn’t part of this test.
  • Memory. I measured time and GC, not how much memory each Load Type uses.
  • The scattered block in run 1. Its cause is still unknown.
  • The work project itself. These results support a hypothesis about it; they don’t prove the work spike has the same cause until I check its settings.

Leave a Comment