Open the Profiler on a Unity game that AI wrote most of, and the most common result is that nothing is wrong with it. There’s no spike. No single method eating eight milliseconds, nothing obvious to go and fix. Fifty or sixty script samples sit under the same parent, none of them above a couple of per cent of the frame, and the build still misses its budget on the device.
The reasonable reading of that screen is that there’s nothing to find, and that the frame time is simply what the game costs. It usually isn’t. A flat distribution is a specific finding with a specific cause, and the useful question at that point isn’t which function is slow, but why a frame with nothing slow in it still misses.
What follows is the session I’d run, and how I’d read it. The Unity behaviour here was checked against the 6.3 manual on 11 August 2026, and the C# figures below were measured rather than remembered.
Profile the build, not the editor
Most of this falls apart if the numbers come from Play mode. Editor figures include the editor’s own work, which is the smaller of the two problems. The bigger one is that a development machine is fast enough to hide exactly the cost pattern you’re looking for. Sixty small allocations a frame are free on a desktop and are not free on a five-year-old Android phone, and the whole point of the exercise is to find out which machine you’re shipping to.
You can only profile a build if it’s a Development Build, and ticking that box exposes two more settings: Autoconnect Profiler, which bakes the editor’s IP address into the player so it connects on launch, and Deep Profiling Support.
Leave Deep Profiling off for the first pass. It injects instrumentation into every script method, which sounds like precisely the measurement you want. It also slows the application down enough that the timings stop being your timings, and Unity’s own manual says it works best for small games with simple scripting. Turn it on later, to answer a question the shallow trace has already raised.
Then run for ten minutes rather than thirty seconds, on the oldest device you intend to support. A phone that has been rendering your game for ten minutes is a different computer from the one that started, and the frame you care about is the one after it has warmed up.
The frame with no bottleneck
In the CPU module’s Hierarchy view, expand PlayerLoop → Update.ScriptRunBehaviourUpdate → BehaviourUpdate. Every MonoBehaviour with an Update method is a child of that sample.
Count the children. On a project built feature by feature with a tool, that number tends to be large, because a prompt that says “make the door open when the player is near” is answered with a script, and a script that has to check something every frame gets an Update. Nobody decided there would be sixty of them. They arrived one at a time, each one a sensible answer on its own.
Be precise about what that costs, because the folklore is wrong here. The dispatch itself is cheap. Unity works out once which magic methods a type defines, caches that, keeps a list of the behaviours that need updating, and walks the list. You need thousands of entries before the walking shows up on its own. The problem is what’s inside them, and the reason it’s hard to see is the same reason it’s hard to fix: the cost has been divided by sixty before it reaches your screen.
Sort by Total rather than Self, and read the parent instead of the children. If BehaviourUpdate is at forty per cent of the frame, that’s the finding, even when nothing underneath it is above two. That’s the number to write down.
One frame isn’t evidence. The Profile Analyzer package (com.unity.performance.profile-analyzer) scans a range of frames and gives you min, max, median, mean and quartiles for every marker, and it will put two scans side by side. For this pattern you want the median frame: the spikes are a separate problem, and a single captured frame is as likely to be a spike as not.
Sort by GC Alloc
The GC.Alloc column shows the bytes a sample put on the managed heap in that frame. In a steady frame, on a mobile target, you want it at or near zero.
What you find instead is a long tail of small allocations across the same sixty scripts. Three shapes account for most of it, and one of them is close to invisible:
static readonly List<int> scores = new List<int> { 3, 1, 4, 1, 5, 9, 2, 6 };
static int SumList()
{
int total = 0;
foreach (int s in scores) total += s; // 0 bytes
return total;
}
static int SumSequence(IEnumerable<int> seq)
{
int total = 0;
foreach (int s in seq) total += s; // 40 bytes, every call
return total;
}
List<T> returns a struct enumerator, so foreach over the concrete type never touches the heap. Pass the same list as IEnumerable<int> and foreach has to reach it through the interface, which boxes that struct. Measured with GC.GetAllocatedBytesForCurrentThread on .NET 9, that’s nothing for the first method and 40 bytes per call for the second. The exact figure moves between runtimes. The zero doesn’t, because the boxing follows from the type in the signature rather than from the runtime underneath it.
The other two are the ones people already half-know. scores.Where(s => s > 2).Sum() measured 72 bytes a call, for the closure and the iterator. "Score: " + total measured 40, and string interpolation compiles to the same thing.
Put all three in one Update and you’re at 152 bytes a frame. At sixty frames a second that’s about nine kilobytes a second from a single script, which is not a crisis. Sixty scripts doing something similar is closer to half a megabyte a second, and half a megabyte a second is a collection every few seconds, and a collection every few seconds is the stutter somebody has already filed as “feels janky on Android”.
Every one of those three lines is the clearest way to write what it means. scores.Where(s => s > 2).Sum() says sum the scores above two better than the loop does. A method that takes IEnumerable<int> is the more general signature, and taking the general type is what you’d tell a junior to do. Nothing in the prompt mentioned that this runs sixty times a second on a phone, so nothing in the answer accounts for it.
To get from a GC.Alloc sample to the line responsible, turn on Call Stacks mode in the CPU module rather than reaching for Deep Profiling. It gives you full call stacks for the allocation samples specifically, which is the question you actually have, at a fraction of what instrumenting every method costs.
The spikes are the pool that isn’t there
Alongside the flat frame you’ll usually see a periodic spike. Some of that is the collection you just accounted for. The rest tends to be Instantiate and Destroy, which show up as their own samples and are easy to read.
The pattern is nearly always the same: spawn the projectile, destroy it when it hits something. It’s the obvious way to write it, it’s what the docs show, and nothing about it is wrong until there are two hundred of them a second.
The fix is a pool, and this is the one item on the list that’s genuinely local. Since Unity 2021 you don’t have to write one or pick a package for it: UnityEngine.Pool.ObjectPool<T> ships with the engine, and its constructor takes your create, get, release and destroy callbacks plus a default capacity and a maximum size. It’s main-thread only, which for spawning gameplay objects is where you were anyway.
When the CPU frame is fine and it’s still slow
If the script side comes back clean, the Frame Debugger (Window → Analysis → Frame Debugger) steps through the frame one draw call at a time and, since Unity 5.6, tells you why each new batch started. “Objects have different materials” is the line to expect, because materials arrive one per prompt. Ask for a glowing enemy and you get a material. Ask for a slightly different glowing enemy and you get a second one. Nothing in either conversation was about draw calls.
Check which pipeline you’re on before acting on that, though, because the advice inverts. Under the built-in pipeline’s static and dynamic batching, distinct materials do break the batch, and atlasing textures so materials can be shared is the usual route out. Under the SRP Batcher on URP or HDRP, batches are bound to the shader variant instead: you can have as many materials as you like with different property values, as long as they share a shader, and it’s the keyword combinations spawning extra variants that cost you. Optimising for the wrong one of those is a productive-looking afternoon that changes nothing.
What a flat profile means
A profile with one hot function is a good result. You fix the function, the frame comes back, and the codebase is otherwise what it was that morning.
A profile with sixty small costs is a different kind of finding, and what makes it different is that you can’t fix it sixty times. Take the LINQ out of one Update and you’ve recovered nine kilobytes a second and left behind a method that reads slightly worse, and there are fifty-nine more, and next week’s feature adds another. Each individual change is arguable on its own terms, which is why they don’t get made.
What moves it is deciding where per-frame work is allowed to live and where allocation is allowed to happen, then putting that decision somewhere the tool reads before it writes. A rules file in the repository does most of it, and an architecture that keeps game state out of MonoBehaviours does the rest, because most of these allocations are in Update simply because there was nowhere else to put them. I’ve written up what I’d put in place from an empty project, and the performance argument is the least of the reasons for it.
The measurement itself is cheap. Half a day with a real device, a development build and the Profile Analyzer will tell you which of the two profiles you’re holding, and that’s a better thing to find out now than during a milestone.
