Profilingcpu boundio boundutilizationconcurrencydiagnosis

Computing or Waiting?

The first fork in every performance investigation. A CPU-bound service wants better algorithms or more cores; an I/O-bound service wants concurrency, batching or a faster dependency. Applying either fix to the other problem reliably makes things worse.

Follow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
Is this process slow because it is doing too much work, or because it is waiting for someone else?
Symptom
Latency is high. That is all you know so far, and the next decision — profile the code or chase the dependency — depends entirely on this distinction.
Signal
CPU utilization of the process against its limit, plus run-queue length and voluntary context switches. Latency alone cannot distinguish the two cases, and neither can request rate.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

Telling them apart from the outside

The distinction is about where the wall-clock time goes. CPU-bound: the process has work to do and is limited by how fast it can execute instructions. Utilization sits near its limit, the run queue grows because threads are waiting for a core, and voluntary context switches are low because the process rarely gives up the CPU willingly. I/O-bound: the process is blocked on something external. Utilization is low, voluntary context switches are high (each block is a yield), and the threads are parked in read, epoll_wait or a lock.

The trap is that both produce identical user-visible symptoms — high latency, poor throughput, unhappy customers — and both get worse under load. Guessing produces confident wrong action: adding cores to an I/O-bound service changes nothing except the bill, and adding concurrency to a CPU-bound service makes it worse by adding context-switch overhead and lengthening the run queue.

One clarification worth making: "CPU-bound" is a property of the process at a moment, not of the service forever. The same service can be CPU-bound during a cache-miss storm and I/O-bound the rest of the time (Cache Stampede: Everyone Misses at Once), which is why the measurement must come from the incident window rather than from general knowledge about the service.

The signal set that separates them
SignalCPU-boundI/O-bound
CPU utilization vs limitNear saturation (>85% of quota)Low (often <30%) despite high latency
Run queue lengthGrows with load — threads waiting for a coreNear zero — nothing wants the CPU
Voluntary context switchesLow: the process rarely yieldsHigh: every block is a yield
Thread statesRunnable / runningBlocked in read, epoll, futex
CPU profileFull: real frames with real self timeNearly empty, or dominated by wait frames
Adding concurrencyMakes it worse (switching overhead)Helps until the dependency saturates
Adding coresHelps roughly linearly, up to contentionChanges nothing

The fixes do not transfer

For a CPU-bound service the levers are: do less work per request (better algorithm, fewer allocations, less serialization — Algorithmic Cost in a Request Handler and Allocation Rate Is a Cost Even Without a Leak), do the work elsewhere (cache the result, precompute, offload), or buy more CPU (more cores or more instances). Concurrency is not a lever; the cores are already busy, and more threads mean more context switching for the same throughput.

For an I/O-bound service the levers are: wait for fewer things (batch, cache, remove a dependency from the critical path), wait concurrently instead of serially (Sequential or Parallel: Same Work, Different Latency), or make the dependency faster. Here concurrency *is* the primary lever — a service waiting 80% of the time can handle far more in-flight requests with the same CPU, which is the entire argument for async I/O. More cores are close to useless.

The mixed case is common and worth naming: a service that is I/O-bound at low load can become CPU-bound at high load once concurrency rises enough to keep the cores busy — or once GC starts consuming CPU, which is the Allocation Rate Is a Cost Even Without a Leak pathway. The measurement has to be repeated at the load level you care about, and "we profiled it in staging at 5% of production traffic" is not that measurement.

Two services, both with 4 s p99. The dashboards look nothing alike.ILLUSTRATIVE
SignalValueWhat it tells youVerdict
A: cpu utilization96% of 2-core quotaSaturated — this one is computingsmoking gun
A: run queue5.2Threads queued for a core; latency is scheduler waitsmoking gun
A: voluntary ctx switches180/sLow — rarely yields, consistent with CPU-boundnormal
B: cpu utilization11% of 2-core quotaIdle while p99 is 4 s — it is waiting for somethingsmoking gun
B: run queue0.1Nothing wants the CPUnormal
B: voluntary ctx switches41,000/sConstantly blocking and yielding — classic I/O-boundsmoking gun

The cases that fool the test

Three situations break the simple reading. Container CPU quotas: a process at "40% CPU" measured against the host may be at 100% of its cgroup quota and getting throttled hard. Throttled time is the signal that matters, and it is invisible if you read host-level utilization — one of the most common misdiagnoses in containerized environments (CPU Saturation: When Cores Become the Queue).

Lock contention looks I/O-bound by every external signal — low CPU, high voluntary context switches, blocked threads — but the blocking is on other threads in the same process, not on external I/O. The fix is neither more cores nor more concurrency; it is reducing the critical section (Low CPU, High Latency: Lock Contention). An off-CPU or lock profile distinguishes it immediately.

Single-threaded runtimes distort the utilization reading. A Node.js process pinned at 100% of one core on an 8-core machine reads as 12.5% host utilization while being completely saturated (Event-Loop Lag: One Callback, Everybody Waits). Always compare against the limit that actually applies — the quota, the core the process can use, the event loop's capacity — rather than against total machine capacity.

yes, and containerizedyesno — low CPUHigh latency observedCPU near its LIMIT (quota, not host)?Throttled time > 0? -> quota-boundCPU profile: real frames? -> CPU-boundOff-CPU profile: blocked where?Blocked on futex/mutex -> lock contentionBlocked on socket/disk -> I/O-bound
UserLLMAgentToolDataDecisionHumanGuardrail

Key points

  • CPU-bound shows near-saturated utilization, a growing run queue and low voluntary context switches; I/O-bound shows low CPU, an empty run queue and constant yielding.
  • The fixes are not interchangeable: concurrency helps I/O-bound and hurts CPU-bound; more cores help CPU-bound and do nothing for I/O-bound.
  • Measure utilization against the limit that applies — cgroup quota, single-core capacity for single-threaded runtimes — not against host capacity.
  • Lock contention mimics I/O-bound on every external signal; only an off-CPU or lock profile separates them, and its fix is different again.
  • The classification is a property of the process under a specific load, not a permanent label — re-measure in the window that matters.

Progressive depth

Overview

Two reasons a program is slow: it has too much to do, or it is waiting for someone else. Check whether the CPU is busy. Busy means the first; idle means the second. The fixes are completely different.

Practical

Read CPU utilization against the applicable limit, run-queue length and the voluntary/involuntary context-switch split. Near-saturated plus a growing run queue is CPU-bound; low CPU plus tens of thousands of voluntary switches is blocking.

Advanced

Watch for the three fooling cases: cgroup throttling (host CPU lies), lock contention (looks I/O-bound, needs a different fix), and single-threaded runtimes (100% of one core reads as 12% of the host). An off-CPU profile resolves all three.

Internals

The distinction is really about thread state in the scheduler. A CPU-bound thread cycles runnable → running and is preempted involuntarily when its quantum expires. A blocking thread calls into the kernel, is moved to a wait queue, and yields voluntarily — which is why the context-switch split is diagnostic. See The Scheduling Problem and Context Switching for the mechanism.

Follow the diagnosis

The causal chain, hop by hop — and the readings that invite the wrong conclusion.

  1. 1
    Latency → engineer: p99 at 4 s, which is compatible with either diagnosis and therefore decides nothing.
  2. 2
    CPU utilization → engineer: 11% against quota, ruling out compute as the constraint.
  3. 3
    Context switches → engineer: 41,000 voluntary switches per second, meaning threads block and yield constantly.
  4. 4
    Off-CPU profile → engineer: threads parked in socket reads against the payments dependency, not in futexes.
  5. 5
    Dependency latency → engineer: the provider's p99 accounts for nearly all of the request time (What Changes When Work Crosses a Machine).
What this evidence makes people conclude — wrongly
  • "CPU is at 40%, so we have headroom." Against the host, maybe. Against a 0.5-core cgroup quota, that process is saturated and being throttled.
  • "CPU is low, so add threads." If the blocking is lock contention, more threads increase contention and make it worse.
  • "The CPU profile is empty, the profiler is broken." An empty CPU profile in a slow process is a definitive finding: it is not computing.
  • "It was I/O-bound last month." Cache hit rates, data volumes and traffic all change the answer. Re-measure.

Measure, fix, validate

An optimization is not finished until the metric that motivated it has moved.

How to measure it
  • • Read CPU utilization against the process's actual limit, plus cgroup throttled time if containerized.
  • • Check run-queue length and the voluntary/involuntary context-switch split — together they separate the two cases faster than any profile.
  • • Take a CPU profile: a nearly empty one is itself the finding, and redirects you to off-CPU or lock profiling.
  • • Sample thread states during the slow window to see where threads actually are (runnable, blocked on socket, blocked on futex).
What actually fixes it
  • • CPU-bound: reduce work per request first (algorithm, allocations, serialization), then add cores or instances — in that order, because scaling an inefficiency is expensive forever.
  • • I/O-bound: raise concurrency so waiting overlaps, and remove or batch dependencies on the critical path.
  • • Quota-bound: raise the limit or reduce per-request CPU; throttling is a configuration problem masquerading as a code problem.
  • • Lock-bound: shrink the critical section or shard the lock — neither cores nor concurrency will help ([[lock-contention]]).
How you know it worked
  • • CPU-bound fixes should show CPU seconds per request falling and throughput per instance rising at unchanged latency.
  • • I/O-bound fixes should show concurrency rising with CPU roughly unchanged, and latency falling because waits now overlap.
  • • Confirm the constraint actually moved by re-running the same signal set — a fix that leaves the classification unchanged did not address the constraint ([[bottleneck-migration]]).
  • • Watch the dependency's own metrics after raising concurrency: you may have relocated saturation downstream ([[saturation]]).
What it costs
  • • Raising concurrency on an I/O-bound service pushes load onto the dependency and can convert your latency problem into its saturation problem.
  • • Adding cores to a CPU-bound service is fast and permanent-feeling, and it hides an efficiency problem that returns at the next traffic step.
  • • Off-CPU profiling has higher overhead than CPU profiling in some runtimes, so it is not always suitable for always-on use.
  • • Reducing per-request CPU usually costs code complexity or readability, which is a real maintenance cost.
Stop it coming back
  • Dashboard CPU utilization against quota (not host) alongside throttled time, so the containerized misreading cannot recur.
  • Alert on CPU seconds per request and on concurrency, so both classes of regression are visible.
  • Include the classification and its evidence in the runbook for the service — it saves the first fifteen minutes of every future incident.
  • Re-measure after significant traffic or data-volume growth, since the classification can flip without any code change.

Accuracy

Performance numbers are conditional. These are the conditions.

What these numbers depend on
  • ILLUSTRATIVEThe two service dashboards are constructed to contrast cleanly; real systems are frequently mixed and shift between states under load.
  • ENVIRONMENT-SPECIFICUtilization only means something relative to the applicable limit — cgroup quota, single-core capacity, or hyperthread contention all change what "80% CPU" implies.

Misconceptions

Claim
“High latency means the code is slow.”
Reality
Most high-latency services are waiting, not computing. The CPU profile of an I/O-bound service is nearly empty and its code may be perfectly efficient.
Claim
“Low CPU means there is spare capacity.”
Reality
Low CPU with high latency usually means the constraint is elsewhere — a dependency, a lock, a pool. The spare CPU cannot be spent on anything.
Claim
“Async I/O makes services faster.”
Reality
It makes I/O-bound services handle more concurrent work with the same CPU. For CPU-bound work it adds overhead and improves nothing (Event-Loop Lag: One Callback, Everybody Waits is what happens when you put CPU work on an async runtime).

Apply it