Performance ยท 01

Measure First

Before any flag, cache or instance type: the discipline that decides whether your tuning is real. This page teaches the measure-find-fix-prove loop, USE and RED, why averages lie, how a load test can hide an outage (coordinated omission), and the three back-of-envelope laws - Little, Amdahl and the Universal Scalability Law - each reproduced with real output from plain-Java programs on the OrderService lab.

11
Chapters
5
Real labs
13
Diagrams
5
Runnable programs
01
Chapter One - The Problem

Why Guessing Fails: the 800 ms Request

The symptom. OrderService, the lab system used on every page of this guide, has a p99 of 800 ms on GET /orders/{id} and the SLO says 200 ms. A team spends a week on what sounds right: a bigger heap, a different garbage collector, a faster JSON library, more pods. Latency barely moves. Nobody measured where the 800 ms was spent.

Hero - one slow request, one dominant slice (illustrative scenario)
One request to GET /orders/42 - where did the 800 ms go? 30 database: 650 ms (81 %) 20 100 app ser. other What the team did first: - tuned -Xmx and swapped G1 for ZGC (touches < 5 % of the request) - rewrote a JSON mapper (touches the 20 ms slice) What the measurement said: - 650 ms of the budget sits behind one component; nothing else can win more than ~20 % Illustrative scenario numbers (the OrderService lab). Section 08 reproduces the attribution with real timers.

Performance work has an unusual property: intuition is usually wrong, and wrong in a consistent direction - we tune the code we understand and can see, while the time is spent in a network hop, a lock, a database plan or a queue that we cannot see without instruments. The cure is not cleverness; it is a loop that forces evidence before every change.

What this page gives you
  • The loop (measure, find the bottleneck, fix one thing, prove it) and the discipline of exit criteria.
  • The vocabulary every later page assumes: latency, throughput, utilisation, saturation; the USE and RED methods and the four golden signals.
  • Why averages lie, how percentiles are computed and (mis)combined, and the measurement bug called coordinated omission - each reproduced with real output from programs you can run.
  • Little's Law, Amdahl's Law and the Universal Scalability Law as back-of-envelope tools for pool sizes and scaling limits.
  • How to attribute an 800 ms request to its stages so the next step is obvious.
Key insight: A doctor does not operate because the patient "looks unwell" - she orders a scan first. Performance engineering without measurement is surgery by rumour; the scan is a percentile histogram, a profile and a trace.
02
Chapter Two - Mental Model

The Loop and Its Discipline

Every performance investigation, from a microbenchmark to a production incident, is the same four-step loop. What separates good engineers is not knowing more JVM flags but running the loop honestly: writing the hypothesis down, changing one variable, rerunning an identical test.

The performance loop
MEASURE baseline: p50/p95/p99, RPS, CPU FIND BOTTLENECK USE / RED, profile, trace FIX ONE THING one variable, one hypothesis PROVE IT same test, same data, compare Exit criteria (decide first) - SLO met: p99 < 200 ms @300 rps - headroom restored: CPU < 60 % - no new, worse bottleneck After a fix the bottleneck MOVES: db 650 ms -> 40 ms, then the downstream call (100 ms) is the new top item. Loop again. Skipping MEASURE gives folklore tuning; skipping PROVE gives placebo fixes that ship and change nothing.
Measure a baseline under realistic load
โ–ถ
Record p50/p95/p99, throughput, CPU and the resource counters you suspect. Without a baseline you cannot claim an improvement - only a feeling.
Find the bottleneck with a method, not a hunch
โ–ถ
Use USE/RED to pick the resource, then a profiler or trace to find the code or call. The bottleneck is the stage where making things faster actually shortens the request.
Fix one thing
โ–ถ
If you change the pool size, the query and the GC in one commit, you will never know which one helped - or which one hurt.
Prove it with the identical test
โ–ถ
Same hardware, same data, same warm-up, same load shape. Then look for the new bottleneck: it always moves.
Folklore tuningMeasurement-driven tuning
"Let us try G1 vs ZGC"Run the load test, read the GC log, notice pauses are 4 ms and irrelevant
"The database must be slow"Trace shows 650 ms in 11 sequential queries (N+1), each fast
"Add more pods"Throughput flat: the shared connection pool is the limit (see Capacity & Failure)
"It is 30 % faster on my laptop"Fair benchmark, warmed up, with error bars (see Benchmarking)
โš  Common bugs & edge cases:
  • Optimising without an exit criterion. "Make it faster" never ends. Write the target first (p99 < 200 ms at 300 rps on 2 vCPU) so you know when to stop.
  • Changing the test and the system together. If the load generator, data set or instance type differs between runs, the comparison is meaningless. Diagnose: keep a run sheet listing commit, flags, data, load script and machine for every number.
  • Optimising a non-bottleneck. A 10x speed-up of a 3 % stage yields at best 2.7 % (see Chapter 08). Diagnose: attribute time to stages before touching code.
03
Chapter Three - What To Measure

Latency, Throughput, Utilisation, USE and RED

Four quantities appear in every conversation and are routinely confused. Latency is how long one request takes. Throughput is how many complete per second. Utilisation is the fraction of time a resource is busy. Saturation is work that is waiting because the resource is already busy (a queue). They are linked: as utilisation approaches 100 %, queues form and latency explodes while throughput flattens (Chapter 06 and the hockey-stick lab on page 2 show the real curve).

QuantityUnitQuestion it answersTypical OrderService probe
Latencyms (percentiles)How long does a user wait?http.server.requests timer with percentile histogram
Throughputreq/sHow much work gets done?Rate of the same timer; load-generator achieved rps
Utilisation%How busy is each resource?CPU %, Tomcat busy threads / max, Hikari active / max
Saturationqueue lengthIs work waiting?Run-queue, Hikari pending, thread-pool queue size, TCP accept queue
Errorscount / rateWhat is failing?5xx rate, timeouts, rejected executions, OOM kills
USE vs RED on OrderService
OrderService (one task / pod) CPU Heap / GC Tomcat threads Hikari pool Aurora / RDS its own USE RED: measure the SERVICE from outside Rate (req/s) Errors (5xx/s) Duration (p50/p95/p99 per route) USE: measure each RESOURCE from inside Utilisation: % of time busy Saturation: queue length / waiting work Errors: rejected, timed out, OOM RED tells you THAT users hurt; USE tells you WHICH resource is the reason. Golden signals = latency + traffic + errors + saturation.
The three checklists
MethodApplies toMetricsUse it when
USE (Brendan Gregg)every resource: CPU, memory, disks, network, poolsUtilisation, Saturation, ErrorsYou do not yet know which resource is the problem; walk all of them.
RED (Tom Wilkie)every service / endpointRate, Errors, DurationBuilding dashboards and alerts that reflect user experience.
Four golden signals (Google SRE book)user-facing systemsLatency, Traffic, Errors, SaturationYou want RED plus a saturation signal in one list.
โš  Common bugs & edge cases:
  • Reading utilisation as health. 60 % average CPU can hide one saturated core, a CFS-throttled container or a pool at 100 %. Always read the saturation counter next to it. Diagnose: jcmd <pid> Thread.print and pool pending counts.
  • Mixing latency of successes and failures. Fast 500s lower your p50. Track latency of successful requests separately and alert on error rate.
  • Watching only throughput. A latency win that halves throughput is a regression. Record both for every run.
04
Chapter Four - Lab A: Percentiles

Why Averages Lie

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” the dashboard says 26 ms

The OrderService dashboard shows average latency 26 ms and everybody relaxes. Support, meanwhile, is forwarding screenshots of checkout pages that hang for two seconds. Both are true: the average is a single number computed from a distribution that is nothing like a bell curve.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” two populations

Hypothesis: most requests take the fast path (cache hit, ~15 ms) and a small share fall onto a slow path (a cold query, ~150 ms) or a pathological one (lock wait, retry, ~1-2 s). If so the mean sits in a valley where no real request lives and says nothing about the tail.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” compute the distribution

We need the distribution, not one number. The program below synthesises 100,000 request latencies from exactly that mixture (97 % ~15 ms, 2.5 % 120-200 ms, 0.5 % 0.8-2 s), sorts them and reads off nearest-rank percentiles. The generator is invented; the arithmetic and the traps it demonstrates are real. Full source in Chapter 09.

Shell - build and run
javac Percentiles.java && java Percentiles
Output - java Percentiles (real run)
requests=100000 mean = 26.0 ms p50 = 15.1 ms p90 = 19.4 ms p95 = 21.1 ms p99 = 184.5 ms p99.9 = 1803.6 ms max = 1999.4 ms histogram: [ 0, 10) ms : 4712 ### [ 10, 20) ms : 87650 ##################################################### [ 20, 30) ms : 4611 ### [ 30, 50) ms : 0 [ 50, 100) ms : 0 [ 100, 200) ms : 2509 ## [ 200, 500) ms : 0 [ 500, 1000) ms : 79 # [1000, 2000) ms : 439 # Trap: averaging per-instance p99 instance A p99 = 29.9 ms, instance B p99 = 991.1 ms average of the two p99s = 510.5 ms (WRONG) p99 of the merged samples = 940.2 ms (RIGHT) P(at least one call is slower than p99) for a page making k calls k= 1 -> 1.0 % k= 5 -> 4.9 % k= 10 -> 9.6 % k= 20 -> 18.2 % k= 50 -> 39.5 % k=100 -> 63.4 %
Real latency histogram: 100,000 requests (Percentiles.java output)
4,712 0-10 87,650 10-20 4,611 20-30 0 30-50 0 50-100 2,509 100-200 0 200-500 79 500-1000 439 1000-2000 Bar height is sqrt-scaled so the tiny tail buckets stay visible. latency bucket (ms) mean = 26.0 ms sits in the 20-30 bucket - where almost no request lives is not where users are, but where the average is. p50 = 15.1 p95 = 21.1 p99 = 184.5 p99.9 = 1,803.6 ms. 3.0 % of requests are slower than 100 ms.
Mean vs percentiles - same 100,000 requests
10 ms 100 ms 1000 ms mean 26.0 ms p50 15.1 ms p95 21.1 ms p99 184.5 ms p99.9 1803.6 ms max 1999.4 ms Log axis. A dashboard showing only the mean (26 ms) would call this service healthy; the 99.9th percentile is 70x worse.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • Mean 26.0 ms vs p50 15.1 ms: the mean is pulled up by a 0.5 % tail - the 518 requests above 500 ms (0.5 %) contribute ~7 ms of the 26 ms average all by themselves.
  • p99 = 184.5 ms and p99.9 = 1,803.6 ms: one in a hundred users sees ~12x the median; one in a thousand sees ~120x.
  • The histogram is multi-modal: three clusters (15 ms, 150 ms, 1.4 s) that correspond to three different code paths. A percentile curve exposes that; a mean erases it.
Two traps the program also demonstrates
TrapWhat goes wrongReal numbers from the run
Averaging percentilesInstance A has p99 29.9 ms, instance B (sick, 10 % of traffic) 991.1 ms. The mean of the two is 510.5 ms - a number that describes no request at all.Correct p99 of the merged samples: 940.2 ms. Percentiles do not average; merge histograms or raw samples.
Per-request p99 vs per-page experienceA page that makes k backend calls is slow if any call is slow: P = 1 - 0.99k.k = 10 → 9.6 %; k = 50 → 39.5 %; k = 100 → 63.4 % of page views hit at least one p99-slow call.
Tail amplification: a page of 100 calls hits the p99 63 % of the time (real output)
0% 25% 50% 75% 100% 0 20 40 60 80 100 k = backend calls per page / fan-out width 1.0% 4.9% 9.6% 18.2% 39.5% 63.4% P(at least one call is slower than its p99) = 1 - 0.99^k
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: the metric

The optimisation here is to the measurement: replace the average with p50 / p95 / p99 / p99.9 from a histogram, record them per route, and aggregate by merging histogram buckets, never by averaging percentiles. In Spring Boot this is management.metrics.distribution.percentiles-histogram.http.server.requests=true plus SLO buckets (see Actuator & Observability and Scaling & Observability). Which percentile? p99 for a service-level objective; p99.9 where a page fans out to many calls; p50 only to describe the typical case.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare
DashboardSaysWould you page someone?
Mean latency26.0 msNo - looks fine
p5015.1 msNo
p99184.5 msMaybe, if the SLO is 150 ms
p99.91,803.6 msYes, for a fan-out page: 63 % of 100-call pages see one of these

Verification of a measurement change is simple: the new view must reveal a problem the old view hid. It did.

โš  Common bugs & edge cases:
  • Percentiles computed per minute then averaged. Ask for the percentile of the whole window from merged buckets (Prometheus: histogram_quantile(0.99, sum by (le) (rate(...[5m])))). Diagnose: if your p99 is smooth while the raw max spikes, you are probably averaging.
  • Histogram buckets that miss the tail. If the top bucket is 1 s, a 30 s timeout is invisible. Include buckets up to the client timeout.
  • Too few samples. p99.9 from 500 requests is one data point. Use at least 1,000x the reciprocal of the tail you want (p99.9 needs > 10,000 samples).
  • Small-window percentiles under low load (e.g. 5 requests/min): alert on counts of slow requests, not on percentiles.
05
Chapter Five - Lab B: Coordinated Omission

The Load Test That Hides the Outage

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” a 5-second freeze, a p99 of 5 ms

Operations froze the OrderService instance for five seconds (think stop-the-world pause, failover or a saturated pool). The load-test report written by a naive closed-loop client says p99 = 5 ms. Customers disagree. Coordinated omission (Gil Tene's term) is the name for this: the measuring client slows down together with the system under test, so it politely stops sending exactly the requests that would have measured the problem.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

A client that waits for each response before sending the next only records one slow sample for the whole freeze. A client that sends on a fixed schedule and measures from the intended start time records one slow sample for every request that should have been sent during the freeze - hundreds of them. The p99 should differ by orders of magnitude.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” virtual-time simulation

The simulator models a 60 s test at a target of 100 req/s (one intended request every 10 ms). The server answers in 5 ms except between t = 20 s and t = 25 s, when it is frozen. Three clients: (1) closed loop measuring from actual send time, (2) the same client but measuring from the intended send time, (3) an open-model client that sends on schedule regardless of outstanding responses (ground truth). Virtual time makes the result deterministic; page 2 repeats the experiment against a real HTTP server.

Output - java CoordinatedOmission (real run)
closed loop (naive measurement) n= 5501 mean= 5.9 p50= 5.0 p99= 5.0 p99.9= 5.0 max= 5005.0 closed loop, corrected (intended) n= 6000 mean= 422.1 p50= 5.0 p99= 4705.0 p99.9= 4980.0 max= 5005.0 open model (ground truth) n= 6000 mean= 213.8 p50= 5.0 p99= 4405.0 p99.9= 4955.0 max= 5005.0
Coordinated omission: the same 5 s freeze, three views
server closed-loop client open-model client frozen 5 s 1 sample of 5 s - the other 499 never sent 500 samples of 0-5 s: every intended send is timed 0 s 10 s 20 s 30 s 40 s 50 s 60 s test time
p99 under a 5 s freeze, three ways of measuring (CoordinatedOmission.java)
1 ms 10 ms 100 ms 1000 ms 10000 ms closed loop naive p99 = 5.0 ms closed, intended times p99 = 4,705 ms open model p99 = 4,405 ms Same server, same freeze. Only the measurement changed: the naive loop reports a p99 that is 940x too good (log axis).
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • The naive closed loop recorded 5,501 samples instead of 6,000 - 499 requests that should have happened in the freeze were silently never issued. Its p99 is 5.0 ms; max 5,005 ms is a single sample.
  • Measuring from intended time (client 2) restores the missing samples: p99 = 4,705 ms. The open model gives 4,405 ms; the small difference is that client 2 is still one connection and queues behind itself.
  • A tell-tale of CO in a report: achieved throughput below the target and a max latency far above a tiny p99.9.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: the load model

Switch the generator from a closed model (N workers, each waits for a reply) to an open model (arrivals on a schedule, e.g. k6 constant-arrival-rate / ramping-arrival-rate executors, Gatling constantUsersPerSec, wrk2 -R), and record latency from the intended time. Real users do not wait for each other, so an open model is also the more honest model of internet traffic.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare
ClientSamplesmeanp99max
Closed loop, naive5,5015.9 ms5.0 ms5,005 ms
Closed loop, intended-time6,000422.1 ms4,705 ms5,005 ms
Open model6,000213.8 ms4,405 ms5,005 ms

On page 2 the same effect is measured against a real HTTP endpoint with a real 1.5 s freeze: closed loop p99 = 29.0 ms, open model p99 = 1,410 ms.

โš  Common bugs & edge cases:
  • Believing "p99 = 5 ms" from a closed-loop tool. Check the achieved request count against target rate x duration. Diagnose: a gap means omission.
  • Fixing it by dropping the outlier. The outlier is the finding.
  • Correcting CO and forgetting warm-up. The first seconds after start-up are slow for JIT reasons (page 4); discard or report them separately, do not average them in.
  • Production RUM is immune but server-side metrics are not. A server timer starts when the request is read; time spent in the accept queue or behind a saturated connector never appears. Measure at the client or load balancer as well.
06
Chapter Six - Lab C: Little's Law

L = λ x W: Sizing Pools and Reading Queues

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” "Connection is not available"

OrderService has a Hikari pool of 10 connections. At 200 req/s, each request holds a connection for ~50 ms. It runs fine in a test at 100 req/s and starts throwing SQLTransientConnectionException: ... Connection is not available, request timed out after 30000ms at peak. Nobody changed the code.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

Little's Law says the average number of items inside any stable system equals the arrival rate times the average time each spends inside: L = λ x W. For the pool: connections in use = requests/s x seconds each holds a connection. If that product exceeds the pool size, requests must queue - and queues grow without bound.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” measure it

The program injects open arrivals at a fixed rate, holds each "request" for a fixed service time (a sleep, on a virtual thread), and measures three things independently: arrival rate λ, mean time in system W, and the mean number in the system L by sampling an in-flight counter every 5 ms. If the law holds, the measured L must match λ x W.

Output - java LittleLaw (real run)
rate=100/s service= 50 ms | measured lambda= 100.1/s W= 50.6 ms | L measured= 5.03 lambda*W= 5.06 rate=200/s service= 50 ms | measured lambda= 200.1/s W= 50.2 ms | L measured= 9.97 lambda*W=10.05 rate=400/s service= 50 ms | measured lambda= 400.1/s W= 50.2 ms | L measured=19.93 lambda*W=20.08 rate=200/s service=120 ms | measured lambda= 200.1/s W= 120.4 ms | L measured=23.84 lambda*W=24.11
Little's Law: the system picture and a real measurement
OrderService (the "system") r1 r2 r3 r4 r5 L = requests in flight lambda lambda W = time each request stays inside L = lambda x W 0 5 10 15 20 25 0 100 200 300 400 500 arrival rate (req/s), service time 50 ms L=5.03 L=9.97 L=19.93 measured L (dots) vs lambda x 0.050 s (dashed)
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
ArrivalService timeMeasured Llambda x WMeaning for a pool of 10
100/s50 ms5.035.06Half the pool busy - healthy
200/s50 ms9.9710.05Exactly 100 % utilised: any jitter queues. This is the incident
400/s50 ms19.9320.08Needs 20 connections; with 10 the queue never drains
200/s120 ms23.8424.11A downstream slowdown from 50 to 120 ms more than doubles the need

The law held to within about 1 % in every row. That is why it is useful: it needs no assumption about the distribution of arrivals or service times, only a stable system. Note the last row - latency changes capacity. A slow dependency does not just slow requests, it multiplies the number of connections, threads and memory you need at the same traffic.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: provision to L, not to hope
  • Required concurrency L = λ x W; add 20-30 % headroom, because utilisation near 100 % queues badly (hockey-stick, see page 11).
  • For pools: pool size ≥ peak rps x p95 hold time. 250 rps x 0.060 s = 15; pick ~20, not 10 and not 200.
  • The same math sizes thread pools (blocking I/O: threads ≈ rps x time-in-request), and the instance count: instances = peak L / per-instance concurrency.
  • Better: shrink W. Cutting query time from 50 ms to 25 ms halves the pool and thread requirement at no hardware cost.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare
Pool sizeLoadExpected LResult
10200 rps x 50 ms10.0saturated: queueing, timeouts at peaks
10250 rps x 50 ms12.5unstable: pending grows
20250 rps x 50 ms12.562 % utilised: stable

Verify in production with the metric pair hikaricp.connections.active and hikaricp.connections.pending: pending should sit at ~0; active should track λ x W. (Rows above are the law applied to measured L; the pool outcomes are consequences of the law, not separate runs.)

โš  Common bugs & edge cases:
  • Using the average W for a pool. The tail holds connections longest; size on the p95/p99 hold time under stress, then check pending.
  • Applying the law to an unstable system. If arrivals exceed capacity, L grows forever and the law (which needs a steady state) does not describe it. Diagnose: queue length rising monotonically.
  • Forgetting W includes queueing. W is time in the whole system, including waiting. Use service time for capacity, response time for the user-visible latency.
  • Bigger pools as a cure. A pool larger than the database can serve in parallel just moves the queue into the database (page 8).
07
Chapter Seven - Lab D: Scaling Limits

Amdahl and the Universal Scalability Law

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” doubling the instances did not double the throughput

Going from 4 to 8 OrderService instances raised throughput by only ~1.5x; going to 16 added almost nothing. Two classic models explain why, and give you a number to plan with.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect
  • Amdahl's Law. If a fraction s of the work is serial (one shared lock, one primary database writer, one single-threaded step), speed-up with N workers is 1 / (s + (1 - s) / N) and can never exceed 1/s.
  • Universal Scalability Law (Neil Gunther). Adds a coherency term κ: workers also pay to keep shared state consistent (cache-line bouncing, lock hand-off, distributed coordination). C(N) = N / (1 + σ(N-1) + κN(N-1)). Because the quadratic term eventually wins, throughput peaks and then falls at N* = sqrt((1-σ)/κ).
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” evaluate the models

This program only evaluates the formulas - it is a calculator, not a measurement of any real service. σ = 5 % and κ = 0.002 are illustrative parameters; in practice you fit them to measured throughput at several concurrency levels (e.g. the step test on page 2).

Output - java ScalingLaws (real run of the formulas; parameters illustrative)
Amdahl: speedup(N) = 1 / (s + (1-s)/N) N s=5% s=10% 1 1.00 1.00 2 1.90 1.82 4 3.48 3.08 8 5.93 4.71 16 9.14 6.40 32 12.55 7.80 64 15.42 8.77 1024 19.64 9.91 limit 1/s: 20.0 and 10.0 USL: C(N) = N / (1 + sigma*(N-1) + kappa*N*(N-1)), sigma=0.050 kappa=0.0020 N C(N) 1 1.00 2 1.90 4 3.41 8 5.47 12 6.62 16 7.17 24 7.38 32 7.06 48 6.11 64 5.24 peak at N=22 (C=7.40); analytic N* = sqrt((1-sigma)/kappa) = 21.8
Amdahl: the serial fraction caps the speedup (ScalingLaws.java values)
0x 5x 10x 15x 20x 1 2 4 8 16 32 64 256 1024 N = parallel workers (log scale) s = 5 % (limit 20x; 64 workers = 15.4x) s = 10 % (limit 10x; 64 workers = 8.8x)
Universal Scalability Law: contention (sigma) + coherency (kappa) bend the curve down
0 5 10 15 20 0 8 16 24 32 48 64 N = instances (or threads) peak C(22) = 7.40 C(48) = 6.11: MORE instances, LESS throughput grey: linear blue: Amdahl red: USL
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • With s = 5 %, the best case is 20x; 16 workers give 9.1x (57 % efficiency), 64 give 15.4x.
  • With sigma = 5 % and kappa = 0.002 the USL peaks at N = 22 (C = 7.40); at 64 the system delivers 5.24x - less than at 12 (6.62x). Adding capacity made it worse.
  • What are the serial parts in OrderService? A single database primary taking every write, one Hikari pool shared by all threads in an instance, a synchronized audit log, a cache with a global lock. Page 5 finds such a lock with a real profile.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: attack sigma or kappa, not N
ParameterWhat raises itHow you lower it
σ (serial fraction / contention)Global locks, single writer, one shared pool, sequential stepsShard / partition, reduce lock hold time, read replicas, async hand-off, caching
κ (coherency cost)Shared mutable state, cross-node cache invalidation, chatty coordinationShare-nothing design, partition by key, batch coordination, eventual consistency
NMore threads / podsOnly after sigma and kappa are low - otherwise cost grows while throughput does not
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare

Prove a scaling fix by re-running a step test across several N and refitting: the curve should shift up and the knee move right. If throughput(N) is flat or falling, look for the shared resource (USE method) instead of buying nodes. Page 11 turns this into a capacity-planning calculation.

โš  Common bugs & edge cases:
  • Treating the models as predictions. They are lenses: fit them to measured data (at least 5-6 concurrency points) before trusting a number.
  • Scaling the stateless tier while the shared database is the serial part. Diagnose: app CPU low, DB CPU or connection count high; throughput flat as pods grow.
  • Ignoring that Amdahl is about a fixed problem size. If the workload grows with N (more users per node), Gustafson's view applies; distributed systems usually face the USL regime.
08
Chapter Eight - Lab E: Attribution

Where Did the 800 ms Go?

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” the budget

A single GET /orders/{id} takes about 800 ms against a 200 ms target. The architect proposes switching to a faster JSON library; the DBA proposes a bigger instance; the platform team proposes more pods. Which is right? The honest answer is "show me the breakdown".

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” the lab design

In the OrderService lab, time is distributed roughly as 30 ms application code, 650 ms database, 20 ms serialization, 100 ms other (downstream HTTP, filters, GC). These are the lab's scenario numbers, chosen so the exercise has a clear answer; your own system will have its own split.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” instrument the stages

The cheapest attribution tool is a timer around each stage: a span. Real systems use Micrometer Observation / OpenTelemetry (page 13) and read the trace waterfall; the plain-Java version below shows the mechanism with no dependencies. The four stages are simulated with sleeps to the lab's numbers, so what you see reproduced is the bookkeeping and the arithmetic, not a measurement of a real database.

Output - java Attribution (real run; stage durations simulated)
total request time: 824 ms app-logic 31 ms 3.7 % # db-query 650 ms 78.9 % ################################ serialization 20 ms 2.4 % # downstream-http 100 ms 12.2 % ##### What-if (make one stage 10x faster, others unchanged): speed up app-logic 10x -> total 796 ms (1.03x overall) speed up db-query 10x -> total 239 ms (3.45x overall) speed up serialization 10x -> total 806 ms (1.02x overall) speed up downstream-http 10x -> total 734 ms (1.12x overall)
Waterfall: one request attributed to its stages
app-logic 31 ms 4% db-query 650 ms 79% serialization 20 ms 2% downstream-http 100 ms 12% total 824 ms (measured wall time) 0 200 400 600 800 ms since request start Stages are simulated with sleeps in Attribution.java; the scenario and the technique are the point, the 650 ms is the lab design.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” Amdahl on one request

Apply Amdahl to the request: if a stage that takes fraction f of the time becomes k times faster, the overall speed-up is 1 / ((1 - f) + f / k). The what-if table in the output answers every argument at once:

Where a 10x speed-up pays off
app-logic 1.03x overall db-query 3.45x overall serialization 1.02x overall downstream-http 1.12x overall 1.0x = no gain What if ONE stage becomes 10x faster? Amdahl applied to a request (Attribution.java what-if output).
  • 10x faster JSON: 1.02x overall (806 ms). The proposed rewrite is a rounding error.
  • 10x faster app code: 1.03x. Even a perfect JIT cannot save this request.
  • 10x faster downstream call: 1.12x - worth doing, but second.
  • 10x faster database stage: 3.45x (824 to 239 ms). Only this change alone moves the request toward the 200 ms target.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: the biggest stage

Go after the 650 ms and nothing else. What that looks like is page 8 (Database): count the queries (N+1), look at the plan, add the missing index on orders.customer_id. Resist the temptation to also "tidy up" the JSON mapper in the same change - you would pollute the experiment.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” verify with a before/after table
StageBeforeTarget after DB fixShare after
app-logic31 ms31 ms~11 %
db-query650 ms~65 ms (10x, by design of the what-if)~24 %
serialization20 ms20 ms~7 %
downstream-http100 ms100 ms~37 %
total824 ms~216 ms-

Notice the bottleneck moved: after the database fix the downstream call is the largest slice - exactly what the loop predicts. The projected 216 ms is arithmetic from the what-if, labelled as such; the capstone (page 14) performs the real run.

โš  Common bugs & edge cases:
  • Averaging stages across requests. Slow requests often have a different dominant stage (a timeout on the downstream call). Attribute the slow percentile separately: filter traces to p99 requests.
  • Unattributed time. If the stages sum to less than the total, something is invisible: queueing before your timer (accept queue, thread-pool queue), GC pauses, or lock waits. Diagnose: compare client-side and server-side durations.
  • Spans around async work that do not propagate context: the waterfall shows a short parent and a mysterious gap.
09
Chapter Nine - Build It

Try It Yourself: The Plain-Java Lab Kit

Everything above runs with a bare JDK 21 (javac and java, no dependencies). Save each file under its class name and run it. Absolute numbers will differ on your machine - the shape (mean vs tail, omission, L = λW) will not. Results shown on this page were produced on a 12-core, laptop-class machine that was shared with other builds, which is itself a reminder that measurement environments are noisy.

Percentiles.java - nearest-rank percentiles, histogram and the two traps
Java - Percentiles.java
import java.util.*; public class Percentiles { // nearest-rank percentile on a sorted array static double pct(double[] sorted, double p) { int rank = (int) Math.ceil(p / 100.0 * sorted.length); return sorted[Math.max(0, rank - 1)]; } public static void main(String[] args) { Random rnd = new Random(42); int n = 100_000; double[] lat = new double[n]; for (int i = 0; i < n; i++) { double r = rnd.nextDouble(); if (r < 0.97) lat[i] = 15 + rnd.nextGaussian() * 3; // fast path ~15 ms else if (r < 0.995) lat[i] = 120 + rnd.nextDouble() * 80; // slow DB path 120-200 ms else lat[i] = 800 + rnd.nextDouble() * 1200; // pathological 0.8-2 s if (lat[i] < 1) lat[i] = 1; } double sum = 0; for (double v : lat) sum += v; double[] s = lat.clone(); Arrays.sort(s); System.out.printf("requests=%d%n", n); System.out.printf("mean = %7.1f ms%n", sum / n); System.out.printf("p50 = %7.1f ms%n", pct(s, 50)); System.out.printf("p90 = %7.1f ms%n", pct(s, 90)); System.out.printf("p95 = %7.1f ms%n", pct(s, 95)); System.out.printf("p99 = %7.1f ms%n", pct(s, 99)); System.out.printf("p99.9 = %7.1f ms%n", pct(s, 99.9)); System.out.printf("max = %7.1f ms%n", s[n - 1]); // histogram (bucket upper bounds in ms) double[] edges = {10, 20, 30, 50, 100, 200, 500, 1000, 2000}; System.out.printf("%nhistogram:%n"); int idx = 0, lo = 0; double prev = 0; for (double e : edges) { int c = 0; for (double v : lat) if (v >= prev && v < e) c++; System.out.printf(" [%4.0f, %4.0f) ms : %6d %s%n", prev, e, c, "#".repeat((int) Math.ceil(60.0 * c / n))); prev = e; } // Trap 1: averaging percentiles across instances double[] a = new double[9_000], b = new double[1_000]; for (int i = 0; i < a.length; i++) a[i] = 20 + rnd.nextDouble() * 10; // busy-but-healthy fleet: 9000 req for (int i = 0; i < b.length; i++) b[i] = 400 + rnd.nextDouble() * 600; // one sick instance: 1000 req double[] all = new double[a.length + b.length]; System.arraycopy(a, 0, all, 0, a.length); System.arraycopy(b, 0, all, a.length, b.length); Arrays.sort(a); Arrays.sort(b); Arrays.sort(all); System.out.printf("%nTrap: averaging per-instance p99%n"); System.out.printf("instance A p99 = %.1f ms, instance B p99 = %.1f ms%n", pct(a, 99), pct(b, 99)); System.out.printf("average of the two p99s = %.1f ms (WRONG)%n", (pct(a, 99) + pct(b, 99)) / 2); System.out.printf("p99 of the merged samples = %.1f ms (RIGHT)%n", pct(all, 99)); // Trap 2: a page that makes k calls System.out.printf("%nP(at least one call is slower than p99) for a page making k calls%n"); for (int k : new int[]{1, 5, 10, 20, 50, 100}) System.out.printf("k=%3d -> %5.1f %%%n", k, 100 * (1 - Math.pow(0.99, k))); } }
CoordinatedOmission.java - virtual-time simulation of a 5 s freeze
Java - CoordinatedOmission.java
import java.util.*; /** Virtual-time simulation: 60 s test, target 100 req/s, server normally answers in 5 ms, * but is frozen (stop-the-world style) for 5 s starting at t=20 s. */ public class CoordinatedOmission { static double pct(double[] sorted, double p) { int rank = (int) Math.ceil(p / 100.0 * sorted.length); return sorted[Math.max(0, rank - 1)]; } static double serviceDone(double start) { // when does a request that reaches the server at 'start' finish? double stallFrom = 20_000, stallTo = 25_000; if (start >= stallFrom && start < stallTo) start = stallTo; // waits for the freeze to end return start + 5; } static void report(String name, List<Double> l) { double[] a = l.stream().mapToDouble(Double::doubleValue).sorted().toArray(); double mean = Arrays.stream(a).average().orElse(0); System.out.printf("%-34s n=%5d mean=%7.1f p50=%6.1f p99=%8.1f p99.9=%8.1f max=%8.1f%n", name, a.length, mean, pct(a, 50), pct(a, 99), pct(a, 99.9), a[a.length - 1]); } public static void main(String[] args) { double interval = 10; // 100 req/s => one intended send every 10 ms double testEnd = 60_000; // 1. Closed-loop, one connection: next request is sent only after the previous one returns List<Double> closed = new ArrayList<>(); double t = 0; while (t < testEnd) { double done = serviceDone(t); closed.add(done - t); // measured from the actual send time t = Math.max(done, t + interval); // wait for response, then respect the pacing } report("closed loop (naive measurement)", closed); // 2. Same client, but latency measured from the INTENDED send time List<Double> corrected = new ArrayList<>(); t = 0; double intended = 0; while (intended < testEnd) { double send = Math.max(t, intended); double done = serviceDone(send); corrected.add(done - intended); // measured from when the request SHOULD have been sent t = done; intended += interval; } report("closed loop, corrected (intended)", corrected); // 3. Open model: requests are sent on schedule regardless of outstanding ones List<Double> open = new ArrayList<>(); for (double s = 0; s < testEnd; s += interval) open.add(serviceDone(s) - s); report("open model (ground truth)", open); } }
LittleLaw.java - measuring L, lambda and W with virtual threads
Java - LittleLaw.java
import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; /** Open arrivals at a fixed rate, each request holds a "server slot" for ~service time (sleep). * Measure arrival rate, mean time in system W and mean number in system L; check L = lambda * W. */ public class LittleLaw { public static void main(String[] args) throws Exception { for (int[] cfg : new int[][]{{100, 50}, {200, 50}, {400, 50}, {200, 120}}) { int rate = cfg[0], serviceMs = cfg[1]; AtomicInteger inSystem = new AtomicInteger(); LongAdder completed = new LongAdder(), totalNs = new LongAdder(); long durationMs = 8_000; ScheduledExecutorService sampler = Executors.newSingleThreadScheduledExecutor(); List<Integer> samples = Collections.synchronizedList(new ArrayList<>()); long startNs = System.nanoTime(); sampler.scheduleAtFixedRate(() -> { if (System.nanoTime() - startNs > 1_000_000_000L) samples.add(inSystem.get()); // skip 1 s warm-up }, 0, 5, TimeUnit.MILLISECONDS); try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) { long interval = 1_000_000_000L / rate; long next = System.nanoTime(); int sent = 0; while (System.nanoTime() - startNs < durationMs * 1_000_000L) { while (System.nanoTime() < next) Thread.onSpinWait(); next += interval; sent++; long arrive = System.nanoTime(); vt.submit(() -> { inSystem.incrementAndGet(); try { Thread.sleep(serviceMs); } catch (InterruptedException e) { } inSystem.decrementAndGet(); if (arrive - startNs > 1_000_000_000L) { completed.increment(); totalNs.add(System.nanoTime() - arrive); } }); } } sampler.shutdownNow(); double seconds = (durationMs - 1000) / 1000.0; double lambda = completed.sum() / seconds; double W = totalNs.sum() / 1e9 / completed.sum(); double L = samples.stream().mapToInt(Integer::intValue).average().orElse(0); System.out.printf("rate=%3d/s service=%3d ms | measured lambda=%6.1f/s W=%6.1f ms | L measured=%5.2f lambda*W=%5.2f%n", rate, serviceMs, lambda, W * 1000, L, lambda * W); } } }
Attribution.java - a hand-rolled span recorder with a what-if
Java - Attribution.java
import java.util.*; /** Hand-rolled span recorder: shows how to attribute an 800 ms request to stages. * Stage durations are simulated with sleeps (30 app / 650 db / 20 serialization / 100 other). */ public class Attribution { static final Map<String, Long> spans = new LinkedHashMap<>(); static void span(String name, Runnable r) { long t0 = System.nanoTime(); try { r.run(); } finally { spans.merge(name, System.nanoTime() - t0, Long::sum); } } static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { } } public static void main(String[] args) { long t0 = System.nanoTime(); span("app-logic", () -> sleep(30)); span("db-query", () -> sleep(650)); span("serialization", () -> sleep(20)); span("downstream-http",() -> sleep(100)); long total = System.nanoTime() - t0; System.out.printf("total request time: %.0f ms%n", total / 1e6); for (var e : spans.entrySet()) System.out.printf(" %-16s %6.0f ms %5.1f %% %s%n", e.getKey(), e.getValue() / 1e6, 100.0 * e.getValue() / total, "#".repeat((int) Math.round(40.0 * e.getValue() / total))); // Amdahl on the request: what if we make each part N times faster? System.out.println("\nWhat-if (make one stage 10x faster, others unchanged):"); for (var e : spans.entrySet()) { double rest = total / 1e6 - e.getValue() / 1e6; double after = rest + e.getValue() / 1e6 / 10; System.out.printf(" speed up %-16s 10x -> total %.0f ms (%.2fx overall)%n", e.getKey(), after, total / 1e6 / after); } } }
ScalingLaws.java - Amdahl and USL calculator
Java - ScalingLaws.java
public class ScalingLaws { public static void main(String[] args) { double[] sigmas = {0.05, 0.10}; System.out.println("Amdahl: speedup(N) = 1 / (s + (1-s)/N)"); System.out.printf("%6s %10s %10s%n", "N", "s=5%", "s=10%"); for (int n : new int[]{1, 2, 4, 8, 16, 32, 64, 1024}) System.out.printf("%6d %10.2f %10.2f%n", n, 1 / (0.05 + 0.95 / n), 1 / (0.10 + 0.90 / n)); System.out.printf("limit 1/s: %.1f and %.1f%n", 1 / 0.05, 1 / 0.10); double sigma = 0.05, kappa = 0.002; System.out.printf("%nUSL: C(N) = N / (1 + sigma*(N-1) + kappa*N*(N-1)), sigma=%.3f kappa=%.4f%n", sigma, kappa); System.out.printf("%6s %10s%n", "N", "C(N)"); double best = 0; int bestN = 0; for (int n = 1; n <= 64; n++) { double c = n / (1 + sigma * (n - 1) + kappa * n * (n - 1)); if (c > best) { best = c; bestN = n; } if (n == 1 || n == 2 || n == 4 || n == 8 || n == 12 || n == 16 || n == 24 || n == 32 || n == 48 || n == 64) System.out.printf("%6d %10.2f%n", n, c); } System.out.printf("peak at N=%d (C=%.2f); analytic N* = sqrt((1-sigma)/kappa) = %.1f%n", bestN, best, Math.sqrt((1 - sigma) / kappa)); } }
Experiments to try
  • Change the mixture in Percentiles (make the slow path 0.1 % instead of 0.5 %) and watch p99 move while the mean barely does.
  • In CoordinatedOmission, change the freeze from 5 s to 500 ms; the naive p99 stays tiny, the corrected p99 shrinks proportionally.
  • In LittleLaw push the rate above what the machine can sustain; the in-flight count stops following lambda x W because the system is no longer stable.
  • Fit sigma and kappa in ScalingLaws to the step-test data from the next page.
10
Chapter Ten - Interview Angles

Methodology Questions and Model Answers

  • Q: Why is average latency a poor SLI?
    Latency distributions are skewed and often multi-modal; the mean is dominated by neither the typical request nor the worst. Use percentiles (p50 for typical, p99/p99.9 for the experience of unlucky users) computed from histograms.
  • Q: Can you average p99s from several instances?
    No. Percentiles are not additive. Merge the underlying histograms (sum the buckets) or raw samples, then compute the percentile. Averaging p99 of a healthy and a sick instance gave 510 ms; the true p99 was 940 ms in the lab run.
  • Q: What is coordinated omission?
    A measurement bug where the load generator waits for slow responses and therefore stops issuing the very requests that would have recorded the slowness. Fix: open-model arrivals plus latency measured from the intended send time (or HdrHistogram's corrected recording).
  • Q: State Little's Law and use it.
    L = lambda x W. At 250 req/s with 60 ms per request, 15 requests are in flight on average, so a 10-connection pool must queue. Also: latency growth multiplies concurrency demand.
  • Q: USE vs RED?
    USE inspects resources (utilisation, saturation, errors) to find what is constrained; RED inspects services (rate, errors, duration) to see whether users are affected. Use RED to alert, USE to diagnose.
  • Q: Why did doubling instances not double throughput?
    A shared serial resource (DB primary, pool, lock) bounds speed-up (Amdahl) and coordination overhead can even reduce it (USL). Find the shared resource with USE, then fix or shard it before adding nodes.
  • Q: A 10x faster component but no visible improvement - why?
    It was a small fraction of the request. Overall speed-up is 1/((1-f)+f/k): 10x on a 3 % stage yields 1.03x.
  • Q: How many samples do I need for a p99.9?
    p99.9 means 1 request in 1,000, so a window of 10,000 requests contains only about 10 tail samples. Aggregate over longer windows, or alert on the count of requests above a threshold.
  • Q: What is wrong with load-testing against a warm laptop?
    Cold JIT, GC and caches, noisy neighbours, different data volume and no network - see Benchmarking for the discipline of baselines.
11
Chapter Eleven - Summary

Cheat Sheet and Recap

Measure-first cheat sheet
QuestionTool / formulaWatch out for
How slow is it, really?p50/p95/p99/p99.9 from histogramsNever average percentiles; keep tail buckets up to the timeout
Is my load test lying?Open model + intended-time latencyAchieved rps < target = coordinated omission
How big a pool / how many threads?L = lambda x W (+ headroom)Use p95 hold time; latency changes capacity
Why does scaling stall?Amdahl 1/(s+(1-s)/N); USL with kappaFind the serial / shared resource; throughput can fall
Where did the time go?Spans / trace waterfall, then Amdahl what-ifAttribute the slow percentile separately; account for unattributed time
What do I alert on?RED (+ saturation = golden signals)Symptoms for paging, causes for dashboards
โˆ‘ Measure First - what to remember
  • Loop: measure, find the bottleneck, fix one thing, prove it with the same test - then expect the bottleneck to move.
  • Averages lie: mean 26 ms hid p99 = 184.5 ms and p99.9 = 1,803.6 ms in the lab data; merge histograms, never average percentiles.
  • Coordinated omission turned a 4.7 s p99 into 5 ms in a closed-loop test; use open arrivals and intended-time latency.
  • Little's Law held within ~1 % in every measured row: L = lambda x W sizes pools and explains why a slow dependency exhausts them.
  • Scaling limits: Amdahl caps speed-up at 1/s; the USL shows throughput can fall past N* - the fix is lowering sigma and kappa, not buying N.
  • Attribute before optimising: a 10x faster database stage gave 3.45x overall; 10x on serialization gave 1.02x.
Related pages: