๐ Lesson 12.1: Profiling: Finding Bottlenecks
Your game runs. But does it run well? Before you rewrite a single line in the name of speed, you need to know where the time actually goes โ and the only honest way to find out is to measure. This lesson is your tour of Unity 6's Profiler, the tool that turns "it feels laggy" into "the physics step is eating 9 ms every third frame."
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Open the Profiler and read the CPU, GPU, Rendering, and Memory modules
- Spot a frame spike and drill into the frame that caused it
- Use the Hierarchy view to find the most expensive method in a frame
- Turn on Deep Profile and understand its cost and trade-offs
- Catch GC allocations that cause stutter, and use the Frame Debugger to inspect draw calls
- Read the in-Game Stats overlay for a quick health check
Estimated Time: 45 minutes ยท Prerequisite: Lesson 11.4 (Polishing a 2D Scene) โ plus any project of your own you can press Play on
In This Lesson
Measure Before You Optimize
Every experienced developer has a story about the day they spent hours "optimizing" a function that turned out to use 0.2% of a frame, while the real culprit โ a stray GetComponent in Update, or a light set to real-time when it should have been baked โ sat untouched. Optimization guided by a hunch is a coin flip. Optimization guided by the Profiler is engineering.
Unity runs your game one frame at a time. To feel smooth at 60 frames per second, each frame must finish in about 16.7 milliseconds; at 30 fps, 33.3 ms. If a frame overruns its budget, the game hitches. The Profiler shows you exactly how those milliseconds were spent, frame by frame, so you can find the one line that blew the budget instead of guessing.
๐ Definition
Bottleneck: the single slowest stage that limits your frame rate. A game is either CPU-bound (the processor can't prepare frames fast enough) or GPU-bound (the graphics card can't draw them fast enough). You optimize the bottleneck; speeding up anything else changes nothing.
๐ก The golden rule. Measure, change one thing, measure again. If the number didn't move, revert the change. Profiling is a loop, not a one-time reading โ and the loop keeps you honest about whether your "fix" actually fixed anything.
The Profiler Window
Open it with Window โธ Analysis โธ Profiler (shortcut Ctrl/Cmd + 7). Press Play and the window fills with live charts, one row per module. The top area is a scrolling timeline โ one thin vertical slice per frame โ and the bottom area shows a detailed breakdown of whichever frame you click. Here is the CPU Usage module drawn exactly as it appears, mid-capture, with a spike selected:
EnemySpawner.Update() ate 24.4 ms of it while allocating 1.4 MB of garbage.๐ก Why a diagram and not a screenshot? Like the Animator and Shader Graph you met earlier, the Profiler is a live, GPU-drawn window that doesn't capture cleanly as an image. This figure is rebuilt faithfully from a real capture โ every column, colour, and number matches what the tool shows.
The Modules: CPU, GPU, Rendering, Memory
Each row in the module list charts a different subsystem. You don't need all of them at once โ start with these four:
- CPU Usage โ the master view. Every frame is a stacked bar coloured by category: Scripts, Rendering, Physics, Animation, GarbageCollector, and Other. The tallest colour is where your CPU time goes.
- GPU Usage โ how long the graphics card spent drawing each frame. If GPU time is high while CPU time is low, you're GPU-bound โ the fix is fewer/cheaper pixels and draw calls, not faster code.
- Rendering โ draw calls, batches, triangles, and vertices per frame. This is where over-drawing and too many materials show up as a rising batch count.
- Memory โ total allocated memory and, crucially, per-frame GC Allocated. Click the module and switch to the detailed Memory Profiler for a full snapshot of textures, meshes, and managed objects.
Physics, Audio, UI, and others are there when you need them, but CPU + Rendering answer most questions.
โ Pro Tip: CPU-bound or GPU-bound?
Glance at CPU Usage and GPU Usage side by side. Whichever is consistently taller (closer to the 16.7 ms line) is your bottleneck. Optimizing the other one won't raise your frame rate by a single fps โ a lesson worth its weight in wasted afternoons.
Reading a Spike
A steady chart that suddenly grows a tall column is a spike โ one frame that took far longer than its neighbours. Spikes are what players feel as a stutter or hitch, and they're the most rewarding thing to hunt because fixing one restores smoothness instantly.
Here's the workflow shown in Figure 1:
- Pause capturing (or press Play then Pause) so the timeline stops scrolling.
- Click the tall column in the CPU chart. A white outline marks the selected frame.
- In the bottom panel, switch the view to Hierarchy and sort by Time ms (descending).
- The top row is your culprit. In the figure,
EnemySpawner.Update()took 24.4 ms โ more than the entire 16.7 ms budget on its own. - Expand it (the โธ arrow) to see which line inside it did the damage.
The Timeline view (the other bottom-panel mode) shows the same data as horizontal bars across worker threads โ excellent for seeing what the main thread was waiting on. Hierarchy answers "what was slow?"; Timeline answers "why was it slow?".
โ ๏ธ The first frame lies
The very first frames after pressing Play are always huge โ Unity is loading assets, JIT-compiling, and warming caches. Ignore them. Profile a steady-state frame from the middle of a capture, and profile a built player (not just the Editor) for numbers you can trust โ the Editor adds its own overhead.
Deep Profile
By default the Profiler records only methods that Unity has explicitly instrumented plus your Update/FixedUpdate entry points. That tells you which script is slow, but not which method inside it. Toggle Deep Profile in the toolbar and Unity instruments every managed method call, so the Hierarchy expands all the way down to the individual helper that's eating time.
โ ๏ธ Deep Profile changes the numbers
Instrumenting every call is expensive โ it can make your game run several times slower and inflates the absolute millisecond values. Use Deep Profile to find relative hotspots ("method A is 10ร method B"), never to read true timings. Turn it off, then re-measure normally to confirm your fix. It also requires a script recompile to switch on, so expect a short pause.
When Deep Profile is too heavy (common on big projects), you can instrument just the code you care about by wrapping it in a ProfilerMarker. This adds a named sample to the timeline with near-zero overhead:
using Unity.Profiling;
using UnityEngine;
public class EnemySpawner : MonoBehaviour
{
// Create the marker once, statically, so it costs nothing per frame.
static readonly ProfilerMarker s_SpawnMarker = new ProfilerMarker("EnemySpawner.SpawnWave");
void Update()
{
using (s_SpawnMarker.Auto()) // everything in this block shows as "EnemySpawner.SpawnWave"
{
SpawnWave();
}
}
void SpawnWave()
{
// ... your spawning logic ...
}
}
Now "EnemySpawner.SpawnWave" appears as its own labelled sample in the Profiler โ clean, targeted timing without the Deep Profile tax.
GC Allocations: The Silent Stutter
C# is a managed language: memory you allocate is cleaned up automatically by the garbage collector (GC). Convenient โ but when the GC runs, it can pause your whole game for a few milliseconds, and if it happens mid-gameplay you get a hitch. The fix is not "run the GC less"; it's allocate less garbage in the first place.
The GC Alloc column in the Hierarchy (see Figure 1) is your radar. Any row that allocates memory every frame is a suspect. Classic offenders:
GetComponent<T>()called inUpdateinstead of cached inAwake- String concatenation each frame (
"Score: " + scorein a UI update) foreachover certain collections in older code, or LINQ (.Where().ToList()) in hot loops- Physics calls that return arrays, like
Physics.RaycastAll(use theNonAllocvariants) new-ing up temporary objects, arrays, or lists insideUpdate
๐ Definition
GC spike: a frame that jumps in cost because the garbage collector ran. In the CPU chart it shows as a burst of the GarbageCollector colour. Your goal for smooth gameplay is a flat 0 B in the GC Alloc column during steady play โ no garbage means no collections means no hitches.
Lesson 12.2 is dedicated to killing these allocations โ object pooling, caching, and per-frame discipline. For now, the skill is simply seeing them: sort the Hierarchy by GC Alloc and watch which rows are non-zero every frame.
The Frame Debugger
If the Rendering module says your batch count is sky-high, the Frame Debugger (Window โธ Analysis โธ Frame Debugger) tells you why. Click Enable and it freezes the game on one frame, then lets you step through every draw call in the exact order the GPU executed them, redrawing the Game view up to that point.
For each draw call it shows what was drawn, which shader and material were used, and โ most valuably โ why the previous draw call couldn't be batched with it ("Objects have different materials", "different shader keywords", and so on). That single line of explanation is the key to reducing draw calls, which is exactly what we'll act on in the shipping lesson.
โ Pro Tip
Draw calls that could have batched but didn't are free performance waiting to be reclaimed. Sharing one material across many objects, using a single texture atlas, and marking static geometry as Static all collapse separate draw calls into batches. The Frame Debugger names the exact reason each one broke.
The Stats Overlay
Sometimes you don't need the full Profiler โ you just want a heartbeat. Click Stats in the top-right of the Game view and a small overlay appears with the essentials: current FPS and frame time, Batches and SetPass calls, Tris and Verts, and video memory usage.
It's the perfect quick check while you tweak a scene: change a light, glance at Batches; add an effect, glance at frame time. When a number jumps, then open the Profiler to investigate. Think of Stats as the dashboard warning light and the Profiler as the diagnostic scanner.
or hitches"] --> B["Glance at the
Stats overlay"] B --> C{"CPU-bound or
GPU-bound?"} C -- "CPU" --> D["Profiler โธ CPU Usage
find the spike frame"] C -- "GPU" --> E["Rendering module +
Frame Debugger"] D --> F["Hierarchy: top method?
GC Alloc non-zero?"] E --> F F --> G["Fix ONE thing"] G --> H["Measure again"] H --> A
Figure 2: The profiling loop โ a quick glance narrows the search, the Profiler pinpoints the cause, and you always re-measure after a change.
Hands-on Challenge
๐๏ธ Exercise 1: Manufacture a spike and catch it
Objective: Prove to yourself that the Profiler pinpoints a bad line of code.
- In any scene, add an empty GameObject with a script whose
Updatedeliberately wastes time and allocates garbage:using UnityEngine; public class BadUpdate : MonoBehaviour { void Update() { // Deliberately wasteful: allocates and busy-works every frame. var junk = new System.Collections.Generic.List<string>(); for (int i = 0; i < 20000; i++) junk.Add("garbage " + i); } } - Open Window โธ Analysis โธ Profiler and press Play.
- Watch the CPU chart climb and the GarbageCollector band appear. Pause, click a tall frame.
- In Hierarchy, sort by Time ms and by GC Alloc. Confirm
BadUpdate.Update()tops both. - Delete the object, press Play again, and confirm the chart flattens and GC Alloc returns to 0 B.
๐ก Hint: I can't see BadUpdate in the Hierarchy
Make sure you clicked a frame while paused (the white outline marks it), that the bottom panel is set to Hierarchy (not Timeline), and that you're on the CPU Usage module. If method names look generic, toggle Deep Profile on and re-run.
โ Success check
With BadUpdate present, one method dominates the frame and shows a large GC Alloc every frame. Remove it and the same frame drops back under budget with 0 B allocated โ you've measured a change, not guessed at one.
๐๏ธ Exercise 2: Add a marker
Wrap a chunk of your own gameplay code in a ProfilerMarker (see the code above), press Play, and find your named sample in the CPU Hierarchy. This is the everyday habit that lets you measure exactly the code you suspect โ no Deep Profile required.
๐ฏ Quick Quiz
Question 1: Your CPU Usage sits at 6 ms while GPU Usage sits at 15 ms every frame. Where should you optimize?
Question 2: Why should you not trust the absolute millisecond numbers reported while Deep Profile is on?
Question 3: A non-zero value in the GC Alloc column every single frame most likely causes what player-facing symptom?
Summary
๐ Key Takeaways
- Measure before optimizing. The Profiler turns "it feels slow" into a specific method, module, or draw call.
- A frame budget is ~16.7 ms at 60 fps. Your game is either CPU-bound or GPU-bound โ fix the taller bar.
- Click a spike, open the Hierarchy, sort by Time ms, and the top row is your culprit.
- Deep Profile reveals every method but distorts absolute timings โ use it for relative hotspots; use
ProfilerMarkerfor targeted samples. - Watch the GC Alloc column โ per-frame allocations cause stutter. Aim for 0 B in steady play.
- The Frame Debugger explains why draw calls didn't batch; the Stats overlay is your at-a-glance heartbeat.
๐ What's Next?
You can now find a bottleneck. Next comes the single most common fix for the GC spikes and instantiation hitches you just learned to spot. In Lesson 12.2: Object Pooling & Optimization, you'll build a reusable object pool that recycles bullets, enemies, and effects instead of constantly creating and destroying them โ plus a checklist of everyday wins like caching GetComponent and killing per-frame allocations.
๐ You can find the bottleneck now
Spike, Hierarchy, top row, measure again. That loop separates guessing from engineering โ and it's the same loop for a hobby game or a shipped title.