Instruments: Measuring Before You Optimize

Learn Instruments' central discipline — measure, find the bottleneck, change one thing, measure again — and the key tools built around that loop: Time Profiler, Allocations, Leaks, Zombies, Energy, Network, File Activity, System Trace, and custom signposts.

Instruments is the profiling and analysis application that ships with Xcode's toolchain, and unlike LLDB — which answers 'what is the state of my program right now' — Instruments answers 'what is my program actually doing over time.' It attaches to a running app and records a timeline of activity: CPU samples, memory allocations, network requests, file reads, and more, depending on which of its many specialized instruments you choose to run. This lesson treats Instruments not as a grab-bag of separate tools but as a single discipline organized around one simple, repeatable loop, and walks through the specific instruments you'll reach for at each stage of that loop.

The measure-first discipline

The single most important idea in this entire lesson is not any individual instrument — it's the loop that governs how all of them should be used: measure, find the bottleneck, change one thing, measure again. Performance work done any other way — guessing which part of the code is slow based on intuition, then 'optimizing' it — is, empirically, wrong an enormous fraction of the time. The part of a program that feels slow to reason about is very often not the part that's actually consuming the time or memory; the real bottleneck is frequently somewhere unglamorous and easy to overlook.

Instruments exists specifically to replace that guesswork with evidence. Before changing a single line of code for performance reasons, the discipline this lesson teaches is to run the relevant instrument, look at what it actually recorded, and identify — from real data, not intuition — where the time or memory is really going. Only then does it make sense to change anything.

The loop's third and fourth steps matter just as much as the first two. A single targeted change should follow from what measurement revealed, rather than a scattershot set of changes made all at once — because a scattershot approach makes it impossible to know afterward which change actually helped, which did nothing, and which might have made things worse. And the loop closes by measuring again: confirming, with the same instrument, that the change produced the improvement it was meant to, rather than assuming it did because it seemed like it should.

Time Profiler: where CPU time actually goes

Time Profiler is the standard first stop for any performance investigation concerned with CPU usage — a UI that feels sluggish, an operation that takes longer than it should, a background task competing for CPU with the main thread. It works by sampling the program's call stack at a regular, high frequency while it runs, recording which function is executing at each sample across every thread.

Aggregated over many samples, this produces a statistical picture of exactly where CPU time is being spent: which functions account for the largest share of total execution time, presented both as a flat list sorted by time and as a call tree that shows time attributed all the way down through nested calls. The heaviest functions at the top of that list are, empirically, where the time is actually going — which is frequently a surprise, and exactly the kind of surprise the measure-first discipline exists to catch.

Reading a Time Profiler trace productively means distinguishing 'self time' (time spent directly inside a function's own code) from time attributed to functions it calls — a function with high total time but near-zero self time is usually just a thin wrapper around something else doing the real work further down the call tree, and the actual target for optimization is that deeper function, not the wrapper sitting above it in the list.

Allocations: tracking memory over time

The Allocations instrument records every memory allocation an app makes while it runs — not a single snapshot, but a full timeline: object counts, sizes, allocation call stacks, and how all of that changes minute to minute or interaction to interaction. This is the tool for understanding a program's memory behavior as a shape over time rather than a single number, which matters because a memory problem is frequently visible only as a pattern — a steady climb that never comes back down, a spike that's expected during one operation but doesn't fully release afterward, a category of object whose count keeps growing where it should stay roughly flat.

Allocations groups its recorded data by object type and by allocation call stack, which makes it possible to answer very specific questions: which class is responsible for the most total memory right now, which call stack is producing the largest number of a particular object, and whether a count that should have gone back to its baseline after some operation actually did. This last question — did memory come back down after the interaction that grew it — is one of the most common and most useful things Allocations is used to check.

Leaks and Zombies: two views of the same failure family

Leaks detects objects that are allocated but never freed because nothing retains a path to release them — memory that will sit there for the rest of the app's lifetime, unreachable and unusable, growing the app's overall footprint with nothing to show for it. As the earlier dedicated lesson on retain cycles established, a retain cycle — two or more objects holding strong references to each other in a loop with no outside reference left to break it — is the single most common root cause Leaks surfaces in ordinary Objective-C code, and its report typically shows exactly that: a group of objects retaining one another with no path back to anything still reachable from the rest of the app.

Zombies takes a different, deliberately drastic approach to a related but distinct problem: instead of truly deallocating an object when its retain count reaches zero, Zombies mode swaps it for a special 'zombie' object that remembers what it used to be. Any further message sent to that zombie — any method call on an object that should already be gone — triggers an immediate, clear crash with a message naming exactly which now-deallocated object was just messaged and what was sent to it.

This is invaluable specifically because use-after-free and over-release bugs are otherwise some of the hardest bugs to diagnose: without Zombies, sending a message to a deallocated object is undefined behavior that might crash immediately, might crash somewhere completely unrelated much later once that memory has been reused for something else, or might silently corrupt data with no crash at all — any of which makes the actual point of the mistake nearly impossible to find. Zombies converts that unpredictable, deferred failure into an immediate, precisely located one, which is exactly what makes it worth reaching for the moment you suspect this class of bug.

Energy, Network, and File Activity

Energy Log profiles battery and power usage — CPU activity, networking, location services, and other power-relevant subsystems — and is most directly relevant for mobile-platform apps where users notice and complain about excessive battery drain. It won't be the first instrument reached for in most desktop-focused Objective-C work, but it belongs in the toolkit for any app running on battery-powered hardware, where an operation that's merely 'a bit slow' by Time Profiler's measure might still be an outsized drain on battery if it keeps the radio or CPU busy longer than it needs to.

Network records the actual network requests an app makes while the instrument runs — their timing, size, and sequencing — which is the direct way to answer 'is this slowness actually a network problem' rather than guessing based on where in the code the slow-feeling operation happens to be. A screen that feels slow to load might be slow because of CPU work, or because it's waiting on a network request that's itself slow or unnecessarily serialized behind another one; Network is what actually distinguishes between those explanations rather than leaving you to assume.

File Activity tracks file system reads and writes over the same kind of timeline — useful for diagnosing an operation that turns out to be disk-bound rather than CPU-bound, or for noticing that some piece of code is reading or writing the same file far more often than it should, which is a pattern that's often invisible by just reading the source but immediately obvious once it's actually recorded and shown as a timeline.

System Trace and custom signposts

System Trace provides the broadest possible view of all of this at once: a single, system-wide timeline correlating CPU activity, thread scheduling, and system calls across every process, not just the one being profiled. It's the tool for questions that cross the boundary of a single app's own code — whether your app's threads are being starved of CPU time by something else running on the system, whether a hand-off between two of your own threads is introducing an unexpected delay, or how your app's activity lines up against system-level events happening around it.

Everything described so far in this lesson is built-in, system- or framework-level data that Instruments records automatically. Signposts, via the os_signpost API, are the mechanism for adding your own, application-specific markers into that same timeline — a custom event or a custom interval (a begin and matching end) that Instruments displays right alongside the built-in data, letting you see exactly when your own meaningful operations started and ended relative to CPU usage, allocations, or anything else being recorded.

This matters because the built-in instruments only know about system- and framework-level activity by default — they have no inherent concept of 'the user tapped the refresh button' or 'the search results finished rendering.' A signpost around exactly that operation turns those app-specific, meaningful moments into first-class entries on the same timeline as everything else Instruments is already recording, which is often the difference between a trace that's merely data and one that actually answers the question you set out to ask.

Profiling methodology, revisited

Closing this lesson where it opened: never guess at a performance problem. The instinct to look at a piece of code and decide it 'must' be the slow part is worth resisting, because it is wrong often enough, in real measured cases, that acting on it without evidence wastes effort on changes that don't move the needle — and worse, can introduce complexity or bugs in code that was never actually the problem in the first place.

The discipline is the loop this lesson opened with, applied deliberately every time: measure with the instrument that matches the suspected category of problem (Time Profiler for CPU, Allocations for memory growth, Leaks and Zombies for memory-safety bugs, Network and File Activity for I/O-bound suspicions), let the data — not intuition — identify the real bottleneck, make one targeted change aimed specifically at what the data showed, and measure again with the same instrument to confirm the change actually helped before considering the work done and moving on to the next thing.

This loop scales down to a five-minute investigation of one sluggish button tap and up to a sustained, multi-day effort chasing a slow-growing memory footprint across a whole app's lifecycle. The tools change depending on the category of problem; the loop governing how they're used does not.

Code Examples

cellForRowAtIndexPath: has high total weight but almost no self weight, meaning it isn't doing the heavy work itself — it's calling something that is. applyFilterSync: carries nearly all of the actual self time, making it the real target for optimization, not the cell configuration method that a first guess might have blamed.

This wraps the search operation in a begin/end signpost interval. In a System Trace or Time Profiler recording, this interval now shows up on the timeline as a labeled, app-specific event — letting you see exactly how long each search actually took and line it up against CPU or memory activity recorded during the same window, rather than relying only on generic system-level data.

Zombies mode replaces silent corruption or a delayed, misleading crash with an immediate, precise error naming the exact deallocated object and the exact message sent to it — converting a use-after-free from one of the hardest bugs to locate into one of the easiest, at the cost of extra memory overhead that makes it unsuitable for anything but debugging.

Weight       Self Weight   Symbol
82.4%  4.10s   0.02s (0.4%)  -[FeedViewController tableView:cellForRowAtIndexPath:]
81.9%  4.08s   3.95s (79.3%) -[ImageProcessor applyFilterSync:]
 0.7%  0.03s   0.03s (0.6%)  -[FeedViewController configureCell:withModel:]
#import <os/signpost.h>

static os_log_t searchLog;

- (void)performSearchWithQuery:(NSString *)query {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        searchLog = os_log_create("com.example.app", "Search");
    });

    os_signpost_id_t spid = os_signpost_id_generate(searchLog);
    os_signpost_interval_begin(searchLog, spid, "Search", "query=%{public}@", query);

    [self.searchService searchForQuery:query completion:^(NSArray *results) {
        os_signpost_interval_end(searchLog, spid, "Search");
        [self displayResults:results];
    }];
}
// Without Zombies enabled: unpredictable, possibly no crash at all,
// or a crash far away from the real mistake, in unrelated code.

// With Zombies enabled:
*** -[MyModel someMethod]: message sent to deallocated instance 0x600002a1c0a0

The Instruments profiling methodology loop

Profiling is empirical and iterative, not guesswork: measure first, let the data identify the real bottleneck, make one targeted change, then measure again to confirm it actually helped before moving on.

Reading a slow function's name and assuming it must be the bottleneck, then spending an afternoon optimizing it without ever running Time Profiler.

Run Time Profiler first, read self time (not just total time) in the call tree, and let the actual recorded data identify which function is really consuming the CPU before changing anything.

Intuition about which code 'feels' slow is wrong often enough in measured practice that skipping the measurement step routinely wastes effort optimizing code that was never the real bottleneck.

Shipping a build to users with Zombies mode still enabled because it seemed harmless to leave on.

Treat Zombies strictly as a debugging-time technique, disabled for any build given to real users.

Zombies mode never actually deallocates objects, replacing them with zombies instead, so memory usage grows without bound over the app's lifetime — acceptable temporarily while debugging, but unsustainable and inappropriate for a shipped build.

Making several performance changes at once and then measuring, unable to tell afterward which change actually helped, which did nothing, and which may have made something else worse.

Make one targeted change at a time, following directly from what measurement showed, and measure again after each individual change to confirm its specific effect.

Batching changes together destroys the ability to attribute the measured improvement (or lack of one) to any specific change, undermining the entire point of the measure-change-measure loop.

Seeing a small leak in the Leaks report and dismissing it as 'just a little memory,' not worth chasing down.

Investigate every leak Leaks surfaces, tracing it back to the retain cycle or missing release most commonly responsible, regardless of how small the individual leaked allocation looks.

A leak that looks negligible in isolation is frequently one instance of a pattern that recurs constantly during normal use — a cell, a view controller, or a network callback that leaks a little every single time it fires accumulates into a serious, user-visible problem over a long session.

What is the correct order of the Instruments profiling loop this lesson centers on? A. Change, measure, find bottleneck, measure again B. Measure, find bottleneck, change, measure again C. Find bottleneck, change, measure, repeat indefinitely D. Guess, change, ship, measure in production

B. Measure, find bottleneck, change, measure again — The loop starts with measuring to gather real data, uses that data to find the actual bottleneck, makes one targeted change, and measures again to confirm the change helped — never starting from a guess.

In a Time Profiler call tree, a function shows high total (weight) time but very low self time. What does this typically indicate? A. The function itself is the bottleneck and should be optimized directly B. The function is a thin wrapper whose calls account for most of the time; the real target is the function(s) it calls further down the tree C. The measurement is invalid and Time Profiler should be re-run D. The function is leaking memory

B. The function is a thin wrapper whose calls account for most of the time; the real target is the function(s) it calls further down the tree — Low self time with high total time means the function itself does little direct work — the time is being spent inside whatever it calls, and that deeper function is the real optimization target.

Which instrument specifically converts a use-after-free bug into an immediate, precisely located crash instead of unpredictable, possibly silent corruption? A. Time Profiler B. Network C. Zombies D. File Activity

C. Zombies — Zombies mode swaps a deallocated object for a zombie that crashes with a clear message on any further message send, turning an otherwise unpredictable failure into a deterministic, precisely diagnosable one.

What is the most common root cause that the Leaks instrument surfaces in ordinary Objective-C code, according to this lesson's cross-reference to the earlier Retain Cycles lesson? A. Buffer overflows B. Retain cycles — two or more objects holding strong references to each other with no outside reference left to break the cycle C. Data races between threads D. Signed integer overflow

B. Retain cycles — two or more objects holding strong references to each other with no outside reference left to break the cycle — A retain cycle keeps every object in the cycle alive indefinitely because each one is still retained by another member of the cycle, even though nothing outside the cycle can reach any of them — exactly the pattern Leaks is built to surface.

What is the purpose of a custom os_signpost interval in a profiling session? A. It replaces the need for Time Profiler entirely B. It marks an app-specific operation (with a begin and end) on the same Instruments timeline as built-in system and framework data, so your own meaningful events are visible alongside CPU, memory, or network activity C. It automatically fixes performance problems it detects D. It only works with the Leaks instrument

B. It marks an app-specific operation (with a begin and end) on the same Instruments timeline as built-in system and framework data, so your own meaningful events are visible alongside CPU, memory, or network activity — Instruments' built-in data is system- and framework-level by default; signposts let you add your own labeled events and intervals so app-specific moments (like a search or a screen load) show up on the same timeline for correlation.

Which instrument would you reach for first if you suspected a slow-feeling screen was actually waiting on a slow or serialized backend request, rather than being CPU-bound? A. Allocations B. Network C. Zombies D. Energy Log

B. Network — Network records the actual requests, their timing, and their sequencing during the profiling session, which directly confirms or rules out a network-based explanation rather than leaving it as a guess.

Measure
  ↓
Find bottleneck
  ↓
Change
  ↓
Measure again