macos · level 9

Debugging with leaks & Instruments

leaks, vmmap, sample, spindump, and Instruments.app — when to reach for which.

175 XP

Debugging with leaks & Instruments

When a macOS process misbehaves — leaks memory, spins CPU, hangs, mysteriously grows — Apple ships a small but excellent set of CLI diagnostic tools, plus the GUI Instruments.app for deeper exploration. Knowing which to reach for collapses "vague performance complaint" to "the bug is at line 47" much faster than guessing.

Analogy

This toolset is like a hospital's diagnostic kit. leaks is the blood test (one-shot, finds specific abnormal values). sample is the EEG (records short brain activity, shows what's happening cognitively). spindump is the MRI (full snapshot of the patient when they collapse). vmmap is the body map (here's everything the patient is carrying). Instruments is the multi-day workup with continuous monitoring — the right tool when you need to watch trends over time, not catch a single moment.

The four CLIs

All four are built-in. Plain text output. Scriptable. Each answers a specific diagnostic question.

Tool Question it answers
leaks What memory is allocated and unreferenced?
sample What is the process spending CPU time on? (last 10s)
spindump What is every thread of this hung process doing right now?
vmmap What does the virtual memory layout of this process look like?

leaks — finding leaks

A "leak" by leaks's definition is an allocation that's still live in the process but no live pointer references it — i.e. it's unreachable, can never be freed, but hasn't been collected. (Pure garbage in any GC'd language; classic leak in C/Obj-C/C++/Swift with manual or ARC memory management.)

# Run against a running process by name or pid
leaks Safari

# Or against a specific pid
leaks 12345

# Even better — get full stack traces for the allocations
MallocStackLogging=1 ./myapp &
leaks myapp

The stack traces require MallocStackLogging=1 to be set when the process started. Without it, you get the bare addresses but not the call paths — much less useful.

Output looks like:

Process: myapp [12345]
Path: /usr/local/bin/myapp
Load Address: 0x10000

Process 12345: 234 nodes malloced for 4567 KB
Process 12345: 12 leaks for 1024 total leaked bytes.

Leak: 0x600001234 size=512 zone=DefaultMallocZone
   Call stack: malloc | calloc | new_buffer | parse_response | http_get

The "Leak: ..." section names the function chain that allocated the memory that's now leaked. You walk back from the deepest function — that's where the allocation happened — and figure out which path through your code didn't free it.

For automated runs:

# Run a process, report leaks at exit:
MallocStackLogging=1 leaks --atExit -- ./myapp

sample — short CPU profile

sample records the call stack of every thread of a process every few milliseconds for some duration, then aggregates: "thread X spent 30% of its time inside parse_response."

sample Safari 10                  # 10-second sample of Safari
sample Safari 10 -file /tmp/safari.txt    # write to a file
sample 12345 5 -mayDie            # sample even if the process exits

Output is a stack-tree report:

Sampling process 12345 for 10 seconds...
Sample analysis of process 12345 written to /tmp/safari.txt

Total number in stack (recursive counted multiple, when >=5):
        45      _pthread_start  (in libsystem_pthread.dylib)
          45    thread_main  (myapp)
            32    parse_response  (myapp)
              32    json_parse  (libfoo.dylib)
                28    json_lex  (libfoo.dylib)
                4     json_alloc  (libfoo.dylib)
            13    http_send  (myapp)

Read top-down: 45/45 samples were in _pthread_startthread_main. 32 of those were in parse_response. The hot spot is json_lex (28 samples).

sample is the right tool when:

  • You suspect CPU-bound work but don't know where it lives.
  • You want to share a quick perf snapshot in a bug report.
  • You don't have time to set up Instruments.

spindump — hang and spin reports

When a process is hung or unresponsive, spindump captures a much richer report than sample — including kernel-level info about lock waits, IO blocks, and what the system was doing.

sudo spindump 12345 10 -file /tmp/spin.txt

(spindump usually needs sudo for full access.) The output is structured per-thread, with annotations like:

Thread 0x12345    "main"       priority 47    cpu time 4.5s
  *4321  ??? (kernel) [thread waiting]
    1234  __psynch_cvwait  (libsystem_kernel)
      4321  pthread_cond_wait_relative_np  (libsystem_pthread)
        4321  AppKit
          4321  -[NSDocument runModalSavePanelForSaveOperation:delegate:didSaveSelector:contextInfo:]
            4321  __CFRunLoopRun
              [waiting on file IO from ~/Documents/large.bin]

You see "thread is waiting for a file IO that never returned" — diagnosis is much more direct than sample for hangs.

The system also runs spindump automatically when an app becomes unresponsive, writing the report to /Library/Logs/DiagnosticReports/. That's where the OS gathers the data behind those "force-quit?" dialogs.

vmmap — virtual memory layout

vmmap 12345
# or:
vmmap Safari --summary

Output describes every region the process has mapped:

==== Non-writable regions for process 12345
__TEXT                 100000000-100001000     [    4K     4K     0K     0K] r-x/rwx SM=COW  myapp
__LINKEDIT             100100000-100110000     [   64K    64K     0K     0K] r--/rwx SM=COW  myapp

==== Writable regions for process 12345
__DATA                 100040000-100050000     [   64K    64K    32K     0K] rw-/rwx SM=PRV
MALLOC_TINY            7fa000000000-7fa000800000 [   8M     1M     0K     0K] rw-/rwx SM=PRV  ...
MALLOC_LARGE           7fa800000000-7fab00000000 [  3.0G    1.5G   0K     0K] rw-/rwx SM=PRV  ...

==== Summary for process 12345
ReadOnly portion of Libraries: Total=234M resident=68M
Writable regions: Total=4G written=2G resident=2G

Use vmmap when:

  • The process's memory footprint is much larger than its allocation count suggests (huge mmap'd regions, big libraries, huge anonymous mappings).
  • You want to know "what TYPE of memory is this consuming?" — code, data, malloc, file-mapped.
  • You're debugging a OOM-killed process to understand where it spent the memory.

Instruments.app — the GUI

Instruments ships with Xcode (the full Xcode IDE; not the standalone CommandLineTools install). It's a GUI built around the same kernel facilities sample, leaks, vmmap, and friends use, but with a continuous timeline.

Standard templates:

Template What it shows
Time Profiler Like sample, but continuous, with a timeline you can scrub
Allocations Live allocation events, with stack traces — find leaks AND lifetime patterns
Leaks Same as leaks CLI but live, with on-the-fly stack traces
System Trace Threads, scheduler, system calls — kernel-level view
File Activity What files are being read/written and by whom
Network Network calls, sizes, timings
Energy Power draw — battery impact analysis
Metal / Core Animation GPU and animation profiling

The killer feature: timelines. You can see CPU spike at 14:23:05, click into that moment, and Instruments shows the call stacks responsible. You can compare two runs side-by-side.

The trade-off: Instruments has UI overhead (RAM, CPU). For low-overhead snapshots in production, the CLIs are still the right answer. For "I want to understand this app's behaviour over a 5-minute scenario," Instruments wins.

Picking the right tool

A decision flow for a misbehaving process:

Is it leaking memory? (RSS climbs forever)
   → leaks (with MallocStackLogging) → fix the unreleased allocation

Is it CPU-bound? (one core pegged)
   → sample <pid> 10 → see the hot stack → optimise

Is it hung? (UI not responding)
   → spindump <pid> 10 → see what thread is waiting on → fix the wait

Is the memory footprint huge but no leaks?
   → vmmap --summary <pid> → identify large mappings → reduce them

Do I need a 5-minute timeline view of allocations and CPU?
   → Instruments.app, Allocations + Time Profiler templates

Ergonomics — the recipes that pay off

# Quick memory snapshot (no MallocStackLogging needed for this)
leaks <pid>

# Short CPU profile, no setup
sample <pid> 10

# A combined "what's wrong with this process" report
{
  echo "=== top ==="
  top -pid <pid> -l 1
  echo "=== sample ==="
  sample <pid> 5
  echo "=== leaks ==="
  leaks <pid>
  echo "=== vmmap ==="
  vmmap --summary <pid>
} > /tmp/process-report.txt

# Attach that file to a bug — most macOS engineers can read it cold

A note on production

leaks, sample, and spindump are safe to run against running production processes. They take a brief sample (sample for ~10s, leaks scans the heap, vmmap reads the kernel maps). They don't pause the process meaningfully.

leaks does pause the process briefly while it walks the heap, which can be a problem on processes with multi-GB heaps. Use it carefully on large servers; prefer Instruments' Allocations template if interruption is unacceptable.

What to internalise

  • leaks for unreferenced allocations; set MallocStackLogging=1 for stack traces.
  • sample for short CPU profile of a running process.
  • spindump for hang reports — what every thread is waiting on.
  • vmmap for the memory-layout view; --summary is the quick read.
  • Instruments.app for time-series profiling and exploring; the CLIs for one-shot snapshots and bug reports.

Tools in the wild

5 tools
  • leaksfree tier

    Built-in. Find unreferenced live allocations and (with MSL) their stack traces.

    cli
  • samplefree tier

    Built-in. Sample-based CPU profiler — short snapshot of a running process.

    cli
  • spindumpfree tier

    Built-in. Hang/spin reports — what every thread is doing during a stall.

    cli
  • vmmapfree tier

    Built-in. Virtual memory map of a process — regions, mappings, permissions.

    cli
  • Instrumentsfree tier

    Xcode-bundled GUI profiler — Time Profiler, Allocations, System Trace, network, IO, energy.

    service