Guide contents

Lesson 48 of 73

Benchmarking Inference

When you run a model on your own machine or on a server, one question comes up quickly: how fast is it? "Fast" can mean several things. It can mean how long you wait before the first word appears, how smoothly the words stream after that, or how many people the server can handle at the same time. A benchmark is a repeatable measurement that answers such questions with numbers. This lesson shows which numbers to collect, how each one is computed, and which mistakes make benchmark results misleading.

The lesson matters because the numbers decide real choices: which model size to run, how many users one machine can serve, and whether a setting such as a larger batch helps or hurts. A number measured the wrong way leads to the wrong choice.

After this lesson you can compute the standard latency metrics from the timestamps of streamed tokens, separate the speed of the whole server (throughput) from the speed one user sees, report percentiles and goodput, generate realistic load, and read the results of llama-bench, llama-batched-bench, and vllm bench serve. You also build and run loop bench, the Java load tester of Project 15.

The table lists the ideas in the order they appear. The last column gives the example numbers where each idea is used.

Idea Plain definition Examples
TTFT Time to first token: how long you wait from sending the request until the first piece of the answer arrives 1, 4, 8, 9
ITL Inter-token latency: the gap between two consecutive pieces of a streamed answer 1, 14
TPOT Time per output token after the first one: (E2E - TTFT) / (tokens - 1) 1, 4, 14
E2E latency End-to-end latency: from sending the request until the last token arrives 1, 6, 7
Throughput How many tokens or requests the whole server finishes per second 2, 8
Percentile The value that a given share of the measurements stay below (p99 means 99%) 3, 6
Goodput Requests per second that also met every latency target 4, 14
Open and closed load Requests that arrive on a schedule, versus clients that wait for each answer before asking again 5, 6, 7
Concurrency sweep The same benchmark repeated with more and more clients at the same time 8, 18
loop bench Project 15: a Java load tester for any server that speaks the OpenAI API 16 to 19

The simulations in this lesson are deterministic. Time comes from a cost model or from an injected clock (a clock object the program is given, so tests can control it), never from the real wall clock, so every run prints the same output. The lesson builds on three earlier lessons: the step cost model of Batching and Scheduling, the prefix cache of Paged KV Cache and Prefix Caching, and the streaming ChatClient of The OpenAI API and Streaming (Project 11). Percentiles and coordinated omission are also covered in general form in Measuring Latency: Percentiles and Coordinated Omission. Facts about llama-bench and llama-batched-bench come from the llama.cpp source and binaries at commit 0c1e57098. Facts about vllm bench serve come from the vLLM 0.30.0 source (vllm/benchmarks/serve.py and vllm/benchmarks/lib/endpoint_request_func.py).

Example 1: TTFT, ITL, TPOT, and end-to-end latency from token timestamps

When you chat with a model, the server does not wait until the whole answer is ready. It sends the answer in small pieces called chunks as they are produced, and each chunk holds one or more tokens (a token is a small piece of text, often a word or part of a word). This is called streaming. A client that records the arrival time of each chunk has everything it needs to compute every latency metric used for LLM serving.

The program below takes one request sent at time 0 and eight chunks. One chunk holds two tokens, and one gap is long because the server paused. Look at the output for four numbers: the wait for the first chunk, the total time, the list of gaps, and the average time per token.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

public class TokenTimings {
    record Chunk(long atMs, int tokens) {}            // when a streamed chunk arrived, and how many tokens it held

    public static void main(String[] args) {
        long sentMs = 0;
        List<Chunk> chunks = List.of(new Chunk(412, 1), new Chunk(436, 1), new Chunk(461, 1), new Chunk(485, 1),
                new Chunk(557, 2), new Chunk(581, 1), new Chunk(830, 1), new Chunk(854, 1));
        int tokens = chunks.stream().mapToInt(Chunk::tokens).sum();
        long ttft = chunks.get(0).atMs() - sentMs;
        long e2e = chunks.get(chunks.size() - 1).atMs() - sentMs;
        List<Long> itl = new ArrayList<>();
        for (int i = 1; i < chunks.size(); i++) itl.add(chunks.get(i).atMs() - chunks.get(i - 1).atMs());
        double tpot = (double) (e2e - ttft) / (tokens - 1);
        double meanItl = itl.stream().mapToLong(Long::longValue).average().orElse(0);
        long maxItl = itl.stream().mapToLong(Long::longValue).max().orElse(0);
        System.out.println("chunks=" + chunks.size() + " tokens=" + tokens);
        System.out.println("TTFT      = " + ttft + " ms");
        System.out.println("E2E       = " + e2e + " ms");
        System.out.println("ITL list  = " + itl + " ms");
        System.out.printf(Locale.ROOT, "mean ITL  = %.2f ms, max ITL = %d ms%n", meanItl, maxItl);
        System.out.printf(Locale.ROOT, "TPOT      = (%d - %d) / (%d - 1) = %.2f ms%n", e2e, ttft, tokens, tpot);
        System.out.printf(Locale.ROOT, "decode speed seen by this user = %.1f tokens/s%n", 1000 / tpot);
        System.out.printf(Locale.ROOT, "tokens / E2E                   = %.1f tokens/s%n", tokens * 1000.0 / e2e);
    }
}
Output
chunks=8 tokens=9
TTFT      = 412 ms
E2E       = 854 ms
ITL list  = [24, 25, 24, 72, 24, 249, 24] ms
mean ITL  = 63.14 ms, max ITL = 249 ms
TPOT      = (854 - 412) / (9 - 1) = 55.25 ms
decode speed seen by this user = 18.1 tokens/s
tokens / E2E                   = 10.5 tokens/s

The four metrics answer four different questions. A restaurant is a rough analogy: TTFT is the wait until the first dish arrives, ITL is the pause between dishes, and E2E is the time until you finish. The analogy stops working at TPOT, which has no restaurant equivalent, so use the definitions below.

  • TTFT is 412 ms: the time from sending the request to the first chunk. It covers the network, any waiting in a queue, and prefill (the step where the model reads the whole prompt; see Generation, Prefill, Decode, and the KV Cache). In practice it is the pause before any text appears.
  • E2E (end-to-end latency) is 854 ms: the time until the last chunk.
  • ITL (inter-token latency) is the list of gaps between consecutive chunks: [24, 25, 24, 72, 24, 249, 24]. It has seven entries for eight chunks, because a gap needs two chunks. The 72 ms gap belongs to the chunk that carried two tokens. The server produced two tokens before sending, so the reader saw nothing for two token times and then both tokens at once. The 249 ms gap is a stall, for example when a long prompt from another user is being prefilled in the same step (Batching and Scheduling, Example 4).
  • TPOT (time per output token) is (E2E - TTFT) / (tokens - 1) = (854 - 412) / 8 = 55.25 ms. The numerator is the time spent generating after the first token, and the denominator is the number of tokens after the first. So TPOT is an average per token for this one request.

The mean ITL (63.14 ms) differs from TPOT (55.25 ms) because ITL counts chunks and TPOT counts tokens. Seven gaps share the 442 ms of decode time for ITL, while eight tokens share the same 442 ms for TPOT. vLLM's benchmark uses exactly these definitions: TTFT is the time of the first chunk that has a choices entry, every later chunk adds one ITL value, and TPOT is computed per request with the formula above.

The last two lines show two different "tokens per second" values for the same request. The decode speed the user sees is 1000 / TPOT = 18.1 tokens per second. Dividing all tokens by E2E gives 10.5, a lower number, because it also spreads the 412 ms of TTFT over the tokens. A report that says "tokens per second" without a definition can mean either one, so always check which it is.

Example 2: aggregate throughput versus per-user speed

Two different speeds exist in a serving system. Throughput is the total work the server finishes per second, summed over all requests. Speed (per user) is what one stream of text receives. Batching (running several requests through the model together, as in Batching and Scheduling) raises the first and lowers the second.

The first half of the program uses the step model from that lesson: a decode step costs a fixed 12 ms (the time to read the model weights) plus 0.6 ms for each sequence in the batch. It prints one row per batch size. The second half computes both quantities from a trace of four finished requests. In the output, compare the two "tok/s" columns of the table, then read the trace lines.

Java
import java.util.List;
import java.util.Locale;

public class ThroughputVsUser {
    record Req(double startS, double ttftS, double endS, int outTokens) {}

    public static void main(String[] args) {
        System.out.println("batch  step ms  tok/s per user  tok/s total");
        for (int b : new int[] {1, 2, 4, 8, 16, 32}) {
            double stepMs = 12 + 0.6 * b;                     // decode step cost model: fixed weights read + per-sequence work
            System.out.printf(Locale.ROOT, "%5d  %7.1f  %14.1f  %11.1f%n", b, stepMs, 1000 / stepMs, b * 1000 / stepMs);
        }
        List<Req> trace = List.of(new Req(0.0, 0.4, 6.4, 200), new Req(0.5, 0.9, 4.9, 120),
                new Req(1.0, 1.6, 9.6, 250), new Req(7.0, 7.3, 9.8, 100));
        double first = trace.stream().mapToDouble(Req::startS).min().orElse(0);
        double last = trace.stream().mapToDouble(Req::endS).max().orElse(0);
        int tokens = trace.stream().mapToInt(Req::outTokens).sum();
        double perUser = trace.stream().mapToDouble(r -> (r.outTokens() - 1) / (r.endS() - r.ttftS())).average().orElse(0);
        System.out.printf(Locale.ROOT, "trace: %d tokens in %.1f s -> output throughput %.1f tok/s%n", tokens, last - first, tokens / (last - first));
        System.out.printf(Locale.ROOT, "trace: mean per-request decode speed %.1f tok/s%n", perUser);
        for (Req r : trace)
            System.out.printf(Locale.ROOT, "  request at %.1f s: %d tokens, decode %.1f tok/s%n", r.startS(), r.outTokens(), (r.outTokens() - 1) / (r.endS() - r.ttftS()));
    }
}
Output
batch  step ms  tok/s per user  tok/s total
    1     12.6            79.4         79.4
    2     13.2            75.8        151.5
    4     14.4            69.4        277.8
    8     16.8            59.5        476.2
   16     21.6            46.3        740.7
   32     31.2            32.1       1025.6
trace: 670 tokens in 9.8 s -> output throughput 68.4 tok/s
trace: mean per-request decode speed 33.4 tok/s
  request at 0.0 s: 200 tokens, decode 33.2 tok/s
  request at 0.5 s: 120 tokens, decode 29.8 tok/s
  request at 1.0 s: 250 tokens, decode 31.1 tok/s
  request at 7.0 s: 100 tokens, decode 39.6 tok/s

In the table, one sequence gets 79.4 tokens per second, and the server also produces 79.4, because it serves only that sequence. With 32 sequences, each step takes 12 + 0.6 x 32 = 31.2 ms, so each user receives 1000 / 31.2 = 32.1 tokens per second. That is 40% of the single-user speed. The server as a whole produces 32 x 32.1 = 1,025.6 tokens per second, 12.9 times more than before. The two numbers move in opposite directions, so a report needs both.

The trace shows how benchmark tools compute totals. Output throughput is all generated tokens divided by the wall-clock span of the benchmark: 670 tokens between the first send (0.0 s) and the last finish (9.8 s), which is 68.4 tokens per second. vLLM's vllm bench serve computes output_throughput the same way, as the sum of output lengths divided by the benchmark duration. The span includes the quiet period from 4.9 s to 7.0 s, when no request was running.

The per-request decode speeds (29.8 to 39.6 tokens per second) describe what each user saw. Their mean is 33.4. Multiplying that mean by the number of requests does not give throughput, because the requests did not all run at the same time.

Example 3: percentiles with HdrHistogram

A single average hides the slow requests that users notice, so latency is reported as percentiles. A percentile p is the value below which p percent of the measurements fall. For example, p50 is the median, and p99 is a value that only 1 request in 100 exceeds. If p99 TTFT is 2 seconds, then 1 request in 100 waited longer than 2 seconds.

Storing every measurement to compute percentiles gets expensive for long benchmarks. HdrHistogram is a Java library that solves this: it counts values into buckets whose width is a fixed fraction of the value, so it uses a fixed amount of memory for any number of values and can read any percentile quickly (Measuring Latency, Example 2).

The program records 1,000 TTFT values in microseconds. 95% are near 180 ms. The other 5% are between 1.5 and 2.5 seconds, which models requests that queued behind long prompts. It prints the exact percentile (from the sorted list) next to HdrHistogram's answer. Look at how the mean compares with the percentiles.

Java
import java.util.Arrays;
import java.util.Locale;
import java.util.Random;
import org.HdrHistogram.Histogram;

public class HdrPercentiles {
    public static void main(String[] args) {
        Random random = new Random(42);
        Histogram h = new Histogram(60_000_000L, 3);        // values up to 60 s in microseconds, 3 significant digits
        long[] all = new long[1000];
        for (int i = 0; i < all.length; i++) {
            long us = random.nextDouble() < 0.05
                    ? 1_500_000 + random.nextInt(1_000_000)                          // 5%: queued behind a long prompt
                    : Math.max(50_000, 180_000 + (long) (random.nextGaussian() * 30_000)); // 95%: normal TTFT
            all[i] = us;
            h.recordValue(us);
        }
        Arrays.sort(all);
        System.out.printf(Locale.ROOT, "count %d, mean %.3f ms%n", h.getTotalCount(), h.getMean() / 1000);
        System.out.println("percentile   exact ms     hdr ms");
        for (double p : new double[] {50, 90, 95, 99, 99.9, 100}) {
            int rank = (int) Math.ceil(Math.round(p * 10) * all.length / 1000.0) - 1;   // nearest rank
            System.out.printf(Locale.ROOT, "%10s  %9.3f  %9.3f%n", "p" + (p == 100 ? "max" : (p % 1 == 0 ? String.valueOf((int) p) : String.valueOf(p))),
                    all[rank] / 1000.0, h.getValueAtPercentile(p) / 1000.0);
        }
        System.out.println("bucket of 1,234,567 us: " + h.lowestEquivalentValue(1_234_567) + " .. " + h.highestEquivalentValue(1_234_567));
    }
}
Output
count 1000, mean 243.856 ms
percentile   exact ms     hdr ms
       p50    180.964    180.991
       p90    225.272    225.279
       p95    244.762    244.863
       p99   2190.347   2191.359
     p99.9   2455.679   2457.599
      pmax   2470.951   2471.935
bucket of 1,234,567 us: 1233920 .. 1234943

The mean is 243.9 ms, a value almost no request had. The typical request takes about 181 ms (p50), and the slow ones take over 2 seconds. The mean lies between the two groups and describes neither. p50, p90, and p95 all fall in the fast group, below 245 ms. The p99 jumps to 2,190 ms because 5% of the requests are slow, so the 99th percentile lands inside the slow group. A report with only p50 and p95 would show no problem at all.

The two columns compare the exact nearest-rank percentile (sort the values and take the one at position ceil(p/100 x n)) with HdrHistogram's answer. They agree within 0.1%. The reason is Histogram(60_000_000L, 3): the 3 means three significant decimal digits, so each value is stored in a bucket no wider than about 1/1,000 of the value. The last line shows one such bucket. Every value from 1,233,920 to 1,234,943 microseconds shares a single bucket of 1,024 microseconds, and getValueAtPercentile returns the highest value of the bucket.

HdrHistogram's p99.9 (2,457.6 ms) and the exact p99.9 (2,455.7 ms) agree with each other, and both differ from the max (2,471 ms). With 1,000 values, p99.9 is the second slowest value and the max is the slowest. Percentiles beyond p99 therefore need many more samples to mean anything (Example 15).

Example 4: goodput under SLOs

Fast on average is not the same as good for users. A service level objective (SLO) is a latency target that a deployment promises, such as "TTFT at most 500 ms and TPOT at most 60 ms". Goodput is the number of completed requests per second that met every SLO. The DistServe paper introduced the term, and vllm bench serve --goodput reports it.

The program applies the vLLM rule to ten finished requests from a 20-second run. One detail of that rule: a request that produced only one token has a TPOT of 0, because there is no second token to measure a gap to. The table shows each request against the two targets, and the last lines count how many requests pass under four different SLOs.

Java
import java.util.List;
import java.util.Locale;

public class Goodput {
    record Done(int id, double ttftMs, double e2eMs, int outTokens) {
        double tpotMs() { return outTokens > 1 ? (e2eMs - ttftMs) / (outTokens - 1) : 0; }   // vLLM: 0 for one token
    }

    static long good(List<Done> done, double ttftSlo, double tpotSlo) {
        return done.stream().filter(d -> d.ttftMs() <= ttftSlo && d.tpotMs() <= tpotSlo).count();
    }

    public static void main(String[] args) {
        List<Done> done = List.of(
                new Done(1, 310, 6_310, 121), new Done(2, 420, 9_420, 151), new Done(3, 1_250, 7_250, 101),
                new Done(4, 380, 12_380, 161), new Done(5, 290, 290, 1), new Done(6, 2_900, 9_900, 141),
                new Done(7, 450, 5_450, 101), new Done(8, 330, 4_330, 41), new Done(9, 610, 6_610, 121),
                new Done(10, 395, 8_795, 141));
        double durationS = 20.0;
        System.out.println("id  TTFT ms  TPOT ms  TTFT<=500  TPOT<=60");
        for (Done d : done)
            System.out.printf(Locale.ROOT, "%2d  %7.0f  %7.2f  %9s  %8s%n", d.id(), d.ttftMs(), d.tpotMs(),
                    d.ttftMs() <= 500 ? "yes" : "no", d.tpotMs() <= 60 ? "yes" : "no");
        System.out.printf(Locale.ROOT, "request throughput: %d / %.0f s = %.2f req/s%n", done.size(), durationS, done.size() / durationS);
        for (double[] slo : new double[][] {{500, 60}, {1000, 60}, {500, 100}, {300, 50}}) {
            long g = good(done, slo[0], slo[1]);
            System.out.printf(Locale.ROOT, "goodput TTFT<=%4.0f ms, TPOT<=%3.0f ms: %2d good -> %.2f req/s%n", slo[0], slo[1], g, g / durationS);
        }
    }
}
Output
id  TTFT ms  TPOT ms  TTFT<=500  TPOT<=60
 1      310    50.00        yes       yes
 2      420    60.00        yes       yes
 3     1250    60.00         no       yes
 4      380    75.00        yes        no
 5      290     0.00        yes       yes
 6     2900    50.00         no       yes
 7      450    50.00        yes       yes
 8      330   100.00        yes        no
 9      610    50.00         no       yes
10      395    60.00        yes       yes
request throughput: 10 / 20 s = 0.50 req/s
goodput TTFT<= 500 ms, TPOT<= 60 ms:  5 good -> 0.25 req/s
goodput TTFT<=1000 ms, TPOT<= 60 ms:  6 good -> 0.30 req/s
goodput TTFT<= 500 ms, TPOT<=100 ms:  7 good -> 0.35 req/s
goodput TTFT<= 300 ms, TPOT<= 50 ms:  1 good -> 0.05 req/s

The table checks each request against the SLO TTFT <= 500 ms and TPOT <= 60 ms. Requests 3, 6, and 9 miss the TTFT target (1,250, 2,900, and 610 ms). Requests 4 and 8 meet it but miss the TPOT target (75 and 100 ms per token). Request 5 produced one token, so its TPOT is 0 by the vLLM rule and only its TTFT counts. Request 2 has a TPOT of exactly 60.00 ms and passes, because the comparison is "less than or equal" in vLLM's code (slo >= value).

All ten requests completed, so request throughput is 10 / 20 = 0.50 requests per second. Only five met both targets, so goodput is 0.25 requests per second. Half of the server's work did not give users the experience that was promised.

The last lines show that goodput depends on the SLO:

  • Relaxing TTFT to 1,000 ms admits request 9, giving 6 good requests.
  • Relaxing TPOT to 100 ms admits requests 4 and 8, giving 7 good requests.
  • Tightening both targets to 300 ms and 50 ms leaves one request, request 5 (TTFT 290 ms, TPOT 0).

A goodput number therefore means nothing without the SLO next to it. Goodput also explains why a bigger batch is not always better: it can raise throughput and lower goodput, by pushing TPOT past the target.

Example 5: seeded Poisson arrivals

A load generator must decide when to send each request. Real users do not click on an exact schedule, so the standard model is a Poisson process: independent users whose requests arrive at an average rate λ (lambda, requests per second). The gap between two arrivals follows an exponential distribution with mean 1/λ. For example, at 4 requests per second the mean gap is 0.25 s, but many gaps are much shorter and some are longer.

To generate a gap, take a uniform random number u in [0, 1) and compute -ln(1 - u) / λ. This is the inverse of the exponential distribution's cumulative function (the function that gives the chance of a gap being at most a given length). Check one case by hand: with u = 0.5 and λ = 4, the gap is -ln(0.5) / 4 = 0.693 / 4 = 0.173 s.

The program generates 10,000 arrivals at 4 requests per second. It uses a seeded Random (a random generator started from a fixed number), so the run is repeatable.

Java
import java.util.Locale;
import java.util.Random;

public class PoissonArrivals {
    public static void main(String[] args) {
        double rate = 4.0;                                   // requests per second
        Random random = new Random(7);
        double t = 0;
        StringBuilder first = new StringBuilder();
        int n = 10_000, shortGaps = 0;
        int[] perSecond = new int[3000];
        for (int i = 0; i < n; i++) {
            double gap = -Math.log(1 - random.nextDouble()) / rate;   // exponential gap: inverse of the CDF
            t += gap;
            if (gap < 0.05) shortGaps++;
            if (i < 8) first.append(String.format(Locale.ROOT, "%.3f ", t));
            if (t < perSecond.length) perSecond[(int) t]++;
        }
        System.out.println("first 8 arrival times (s): " + first.toString().trim());
        System.out.printf(Locale.ROOT, "%d arrivals in %.1f s: mean rate %.3f req/s, mean gap %.4f s%n", n, t, n / t, t / n);
        System.out.printf(Locale.ROOT, "gaps under 50 ms: %.1f%% (a fixed 250 ms schedule has 0%%)%n", 100.0 * shortGaps / n);
        int full = (int) t, min = Integer.MAX_VALUE, max = 0;
        int[] hist = new int[16];
        for (int s = 0; s < full; s++) { min = Math.min(min, perSecond[s]); max = Math.max(max, perSecond[s]); hist[Math.min(15, perSecond[s])]++; }
        System.out.println("requests in one 1-second window: min " + min + ", max " + max + " (fixed schedule: always 4)");
        StringBuilder line = new StringBuilder("windows with k requests (k:count):");
        for (int k = 0; k <= max; k++) line.append(' ').append(k).append(':').append(hist[k]);
        System.out.println(line);
    }
}
Output
first 8 arrival times (s): 0.328 0.674 0.781 1.350 1.658 1.766 1.798 2.272
10000 arrivals in 2556.2 s: mean rate 3.912 req/s, mean gap 0.2556 s
gaps under 50 ms: 17.3% (a fixed 250 ms schedule has 0%)
requests in one 1-second window: min 0, max 13 (fixed schedule: always 4)
windows with k requests (k:count): 0:44 1:217 2:392 3:535 4:472 5:362 6:263 7:141 8:76 9:30 10:16 11:5 12:2 13:1

The gaps between the first arrivals are irregular: 0.346 s, 0.107 s, 0.569 s, and then three requests within 0.14 s (1.658, 1.766, 1.798). Over 10,000 arrivals, the measured rate is 3.912 per second and the mean gap is 0.2556 s, close to the target of 4 and 0.25. The seed gives every server configuration the same arrival pattern, so comparisons between configurations are fair.

The last three lines show what a fixed schedule hides. 17.3% of the gaps are shorter than 50 ms, and one-second windows hold anywhere from 0 to 13 requests, while a fixed 250 ms schedule always sends exactly 4. The histogram of windows (3:535 4:472 5:362 ...) has the shape of a Poisson distribution with mean 4. A server that keeps up with 4 evenly spaced requests per second can still build a queue when 9 or 10 requests arrive within one second.

vLLM's benchmark uses the same idea. With --request-rate set to a finite value, it draws gaps from a gamma distribution with shape --burstiness and scale 1 / (rate x burstiness). A burstiness of 1 (the default) turns the gamma distribution into the exponential one, so the arrivals are Poisson. Values below 1 make traffic burstier, and values above 1 make it more even. With --request-rate inf (the default), all requests are sent at time 0.

Example 6: closed-loop versus open-loop load and coordinated omission

Two designs exist for a load generator, and they behave very differently when the server stalls.

  • A closed-loop generator has a fixed number of clients. Each client sends its next request only after the previous answer arrives. This models "N users chatting".
  • An open-loop generator sends requests on a schedule (such as the Poisson arrivals of Example 5) whether or not earlier requests have finished.

Coordinated omission is the measurement error of a closed-loop tester. When the server stalls, the tester also stops sending, so the requests that would have waited during the stall are never sent and never measured (Measuring Latency, Example 4). The tester and the server "coordinate" and the slow period is omitted from the data.

The program shows the effect. A server handles one request at a time, 50 ms each, and freezes for 2 seconds at t = 10 s. A client intends to send one request every 100 ms for 30 seconds. The program measures the same test four ways and prints percentiles for each.

Java
import java.util.Locale;
import org.HdrHistogram.Histogram;

public class OpenVsClosed {
    // one request at a time, 50 ms each; the server is frozen from 10,000 ms to 12,000 ms
    static double serve(double start) {
        if (start >= 10_000 && start < 12_000) start = 12_000;
        double end = start + 50;
        if (start < 10_000 && end > 10_000) end += 2_000;
        return end;
    }

    static void report(String name, Histogram h) {
        System.out.printf(Locale.ROOT, "%-34s n=%3d  p50=%7.1f  p90=%7.1f  p99=%7.1f  max=%7.1f ms%n", name, h.getTotalCount(),
                h.getValueAtPercentile(50) / 1000.0, h.getValueAtPercentile(90) / 1000.0,
                h.getValueAtPercentile(99) / 1000.0, h.getMaxValue() / 1000.0);
    }

    public static void main(String[] args) {
        Histogram closedNaive = new Histogram(60_000_000L, 3), closedHdr = new Histogram(60_000_000L, 3);
        Histogram closedIntended = new Histogram(60_000_000L, 3), open = new Histogram(60_000_000L, 3);
        double free = 0;                                       // closed loop: one client, waits for each response
        for (double planned = 0; planned < 30_000; planned += 100) {
            double sent = Math.max(planned, free);
            free = serve(sent);
            closedNaive.recordValue((long) ((free - sent) * 1000));                  // from the actual send
            closedHdr.recordValueWithExpectedInterval((long) ((free - sent) * 1000), 100_000);
            closedIntended.recordValue((long) ((free - planned) * 1000));            // from the intended send
        }
        double serverFree = 0;                                  // open loop: send on schedule, server queues
        for (double planned = 0; planned < 30_000; planned += 100) {
            serverFree = serve(Math.max(planned, serverFree));
            open.recordValue((long) ((serverFree - planned) * 1000));
        }
        report("closed loop, from actual send", closedNaive);
        report("closed loop, expected-interval fix", closedHdr);
        report("closed loop, from intended send", closedIntended);
        report("open loop, from intended send", open);
    }
}
Output
closed loop, from actual send      n=300  p50=   50.0  p90=   50.0  p99=   50.0  max= 2050.0 ms
closed loop, expected-interval fix n=319  p50=   50.0  p90=   50.0  p99= 1750.0  max= 2050.0 ms
closed loop, from intended send    n=300  p50=   50.0  p90=  550.4  p99= 1900.5  max= 2050.0 ms
open loop, from intended send      n=300  p50=   50.0  p90=  550.4  p99= 1900.5  max= 2050.0 ms

The first line is the closed-loop tester measuring from the moment it actually sent each request. The request sent at t = 10 s waited out the freeze and took 2,050 ms. The tester then sent the delayed requests back to back, each served in 50 ms. Its p50, p90, and p99 are all 50 ms, and the only trace of a two-second outage is the max. This is coordinated omission: the outage is nearly invisible.

The second line applies HdrHistogram's recordValueWithExpectedInterval(value, 100_000). This method also records the values the missing requests would have seen (1,950 ms, 1,850 ms, down to 150 ms), based on the expected 100 ms interval. The count rises from 300 to 319, and p99 becomes 1,750 ms.

The third line measures each closed-loop request from its intended send time, the fix described in Measuring Latency, Example 6. The requests that were due during the freeze now count the time they spent waiting to be sent. As a result, p90 is 550 ms and p99 is 1,900 ms. HdrHistogram prints 550.4 and 1,900.5 because those values fall in buckets of 512 and 1,024 microseconds.

The fourth line, the open-loop tester, gives the same numbers. With one client and a first-in-first-out server, waiting in the client and waiting in the server queue take the same time. In general they differ, and the rule is to measure from the intended start.

LLM benchmarks are often closed-loop by design (--max-concurrency in vLLM, the worker loop in loop bench), because "N users chatting" is a closed system. To keep the results honest, vLLM's tester records client_queue_time: the time a request waited for a free concurrency slot after its scheduled arrival. When both a request rate and a maximum concurrency are given, it can also report e2el_including_client_queue, which is end-to-end latency measured from the intended start.

Example 7: Little's law in a closed-loop benchmark

Little's law connects three quantities of any stable system: L = λW. Here L is the average number of requests inside the system, λ is the rate at which requests complete, and W is the average time one request spends inside. A coffee shop shows the idea: if 2 customers finish per minute and each stays 3 minutes, then on average 6 customers are inside. The law was introduced for servers in Batching and Scheduling, Example 7. In a closed-loop benchmark it works as a check on the measurements.

With N clients and no pause between requests, every client always has exactly one request in the system, so L = N and the law says N = throughput x mean latency. With a think time Z (a pause between an answer and the user's next request), each client cycles between "in the server" and "thinking". Applied to the whole loop, the law becomes N = λ(W + Z).

The program simulates a batching server. Each step costs 10 + 0.5 x batch ms plus 0.05 ms per prompt token being prefilled. Prompts have 200 tokens and answers have 50 tokens. It checks both forms of the law, so look for lambda*W matching L, and lambda*(W+Z) matching N.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

public class LittleClosedLoop {
    static final int PROMPT = 200, OUTPUT = 50;

    static final class Req { double arrival; int done; Req(double a) { arrival = a; } }

    // n clients; each waits think ms after a response before sending the next request
    static void run(int n, double think) {
        List<Req> active = new ArrayList<>();
        double[] nextSend = new double[n];
        Req[] ofClient = new Req[n];
        double now = 0, end = 120_000, busySum = 0, latencySum = 0;
        long finished = 0;
        while (now < end) {
            for (int c = 0; c < n; c++)
                if (ofClient[c] == null && nextSend[c] <= now) { ofClient[c] = new Req(nextSend[c]); active.add(ofClient[c]); }
            if (active.isEmpty()) { double next = Double.MAX_VALUE; for (double s : nextSend) next = Math.min(next, s); now = next; continue; }
            int prefills = 0;
            for (Req r : active) if (r.done == 0) prefills++;
            double step = 10 + 0.5 * active.size() + 0.05 * PROMPT * prefills;    // ms for this forward pass
            busySum += active.size() * step;                                     // request-ms spent in the system
            now += step;
            for (int c = 0; c < n; c++) {
                Req r = ofClient[c];
                if (r == null || !active.contains(r)) continue;
                if (++r.done == OUTPUT) {
                    active.remove(r); ofClient[c] = null; finished++;
                    latencySum += now - r.arrival; nextSend[c] = now + think;
                }
            }
        }
        double lambda = finished / (now / 1000), w = latencySum / finished / 1000, l = busySum / now;
        System.out.printf(Locale.ROOT, "N=%2d think=%4.1f s: lambda=%6.2f req/s  W=%5.3f s  L=%5.2f  lambda*W=%5.2f  lambda*(W+Z)=%5.2f%n",
                n, think / 1000, lambda, w, l, lambda * w, lambda * (w + think / 1000));
    }

    public static void main(String[] args) {
        for (int n : new int[] {1, 4, 16}) run(n, 0);
        for (int n : new int[] {4, 16}) run(n, 2_000);
    }
}
Output
N= 1 think= 0.0 s: lambda=  1.87 req/s  W=0.535 s  L= 1.00  lambda*W= 1.00  lambda*(W+Z)= 1.00
N= 4 think= 0.0 s: lambda=  6.23 req/s  W=0.640 s  L= 4.00  lambda*W= 3.99  lambda*(W+Z)= 3.99
N=16 think= 0.0 s: lambda= 15.07 req/s  W=1.060 s  L=16.00  lambda*W=15.97  lambda*(W+Z)=15.97
N= 4 think= 2.0 s: lambda=  1.52 req/s  W=0.640 s  L= 0.97  lambda*W= 0.97  lambda*(W+Z)= 4.00
N=16 think= 2.0 s: lambda=  5.20 req/s  W=1.060 s  L= 5.60  lambda*W= 5.51  lambda*(W+Z)=15.91

Without think time, the measured λW equals N: 1.00 for one client, 3.99 for four, and 15.97 for sixteen. The small gap (15.97 instead of 16) comes from the requests still running when the simulation stops. They occupy the server but have not finished, so they count in L and not in W. Going from 4 to 16 clients raises throughput from 6.23 to 15.07 requests per second, and raises latency from 0.640 s to 1.060 s, because W = N / λ: more clients share a throughput that grows more slowly than N.

With 2 seconds of think time, four clients produce only 1.52 requests per second, and the number of requests inside the server (L) is 0.97, not 4. Most clients are thinking at any moment. λW = 0.97 matches L, and λ(W + Z) = 4.00 matches N. With sixteen clients, λ(W + Z) = 15.91, within 1% of 16. L (5.60) and λW (5.51) differ by under 2% for the same end-of-run reason.

A benchmark whose concurrency, throughput, and mean latency violate N ≈ λ(W + Z) has a bug: lost requests, a wrong duration, or clients that did not run in parallel. The law also helps with capacity planning. For 1,000 users with 30 s of think time and 3 s of latency, λ = 1,000 / 33 ≈ 30 requests per second.

Example 8: a concurrency sweep: the throughput and latency curve

A concurrency sweep runs the same closed-loop benchmark with an increasing number of clients and records throughput and latency at each level. The result shows how the server behaves from light load to overload.

The program uses the server of Example 7, with 16 slots. A slot is a place in the batch, so at most 16 requests run at once and the rest wait in a queue (as with -np 16 in llama-server or --max-num-seqs 16 in vLLM). Answers have 30 to 70 tokens drawn from a seeded generator, so requests do not finish in lockstep. In the output, watch where the throughput columns stop changing and which latency column absorbs the extra load.

Java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Locale;
import java.util.Random;

public class ConcurrencySweep {
    static final int PROMPT = 200, SLOTS = 16;

    static final class Req {
        final int client, output; final double arrival; double firstToken; int done;
        Req(int c, double a, int out) { client = c; arrival = a; output = out; }
    }

    static double pct(List<Double> v, double p) {
        List<Double> s = new ArrayList<>(v); s.sort(null);
        return s.get((int) Math.ceil(p / 100 * s.size()) - 1);
    }

    static void run(int n) {
        Random random = new Random(1);                                    // output lengths 30 to 70 tokens
        Deque<Req> waiting = new ArrayDeque<>();
        List<Req> running = new ArrayList<>();
        List<Double> ttft = new ArrayList<>(), tpot = new ArrayList<>(), e2e = new ArrayList<>();
        for (int c = 0; c < n; c++) waiting.add(new Req(c, 0, 30 + random.nextInt(41)));          // all clients send at time 0
        double now = 0, end = 120_000;
        long tokens = 0;
        while (now < end) {
            while (running.size() < SLOTS && !waiting.isEmpty()) running.add(waiting.poll());   // admit into free slots
            int prefills = 0;
            for (Req r : running) if (r.done == 0) prefills++;
            now += 10 + 0.5 * running.size() + 0.05 * PROMPT * prefills;
            for (Req r : new ArrayList<>(running)) {
                if (r.done++ == 0) r.firstToken = now;
                tokens++;
                if (r.done == r.output) {
                    running.remove(r);
                    ttft.add(r.firstToken - r.arrival); e2e.add(now - r.arrival);
                    tpot.add((now - r.firstToken) / (r.output - 1));
                    waiting.add(new Req(r.client, now, 30 + random.nextInt(41)));                     // closed loop: send again at once
                }
            }
        }
        System.out.printf(Locale.ROOT, "%3d  %7.2f  %8.0f  %8.0f  %8.0f  %7.1f  %7.0f%n", n, e2e.size() / (now / 1000), tokens / (now / 1000),
                pct(ttft, 50), pct(ttft, 99), tpot.stream().mapToDouble(Double::doubleValue).average().orElse(0),
                e2e.stream().mapToDouble(Double::doubleValue).average().orElse(0));
    }

    public static void main(String[] args) {
        System.out.println("  N    req/s     tok/s  TTFTp50  TTFTp99  TPOT ms   E2E ms");
        for (int n : new int[] {1, 2, 4, 8, 16, 24, 32, 64}) run(n);
    }
}
Output
  N    req/s     tok/s  TTFTp50  TTFTp99  TPOT ms   E2E ms
  1     1.83        93        21        21     10.5      544
  2     3.44       176        21        31     11.2      579
  4     6.19       313        22        32     12.6      645
  8    10.19       513        24        44     15.4      782
 16    15.06       754        28        48     21.0     1059
 24    15.06       754       558       846     21.0     1585
 32    15.06       754      1088      1322     21.0     2108
 64    15.06       754      3200      3560     21.0     4181

The table has two regions.

From 1 to 16 clients, every request has a slot. Throughput climbs from 1.83 to 15.06 requests per second (93 to 754 tokens per second), because each step serves more sequences for little extra cost. The price shows in TPOT, which rises from 10.5 ms to 21.0 ms, because steps get longer with more sequences and with prefills of new requests mixed in. TTFT stays low (p50 of 21 to 28 ms), because a new request starts in the next step. The p99 TTFT (31 to 48 ms) is higher than the p50 because a request that arrives while others are being prefilled shares a longer step.

From 16 clients on, throughput is flat at 15.06 requests per second. The server is saturated, and every extra client waits in the queue for a free slot. TPOT does not change (21.0 ms), because the batch inside the server is still 16. All the extra waiting appears in TTFT: p50 is 558 ms at 24 clients, 1,088 ms at 32, and 3,200 ms at 64. E2E grows in proportion to N, as Little's law requires when λ is fixed: 64 / 15.06 = 4.25 s, close to the measured 4.18 s.

The point where throughput stops rising, here 16 clients, is called the knee of the curve. A deployment is sized below the knee, at the highest concurrency whose TTFT and TPOT still meet the SLO (Example 4). Past the knee, more load only adds queueing.

Example 9: prompt length distributions and cache effects

TTFT depends on how many prompt tokens the server must compute, so the prompts a benchmark sends shape the result. Two effects matter. The first is the spread of prompt lengths: real traffic has many short prompts and a few very long ones. The second is the prefix cache, which reuses the KV cache (the stored attention data of earlier tokens) for a prompt prefix the server has seen before (Paged KV Cache and Prefix Caching). A cached prefix does not need to be computed again.

The program computes TTFT as 25 ms plus prefill at 4,000 tokens per second, for four workloads of 1,000 requests each. The cache stores whole 16-token blocks, so a prefix of 1,500 tokens caches only the first 93 full blocks (1,488 tokens). The last prompt token is always computed again, so that the model produces the output scores (logits) needed to choose the next token.

Java
import java.util.Arrays;
import java.util.Locale;
import java.util.Random;

public class PromptMix {
    static final int BLOCK = 16;                         // the prefix cache stores whole 16-token blocks

    // prompt = shared prefix + unique part; cachedPrefix says whether the shared prefix is already in the cache
    static double ttftMs(int prefix, int unique, boolean cachedPrefix) {
        int cached = cachedPrefix ? (prefix / BLOCK) * BLOCK : 0;
        if (cached == prefix + unique) cached -= 1;      // the last prompt token is always computed again
        int computed = prefix + unique - cached;
        return 25 + computed / 4.0;                      // 25 ms overhead, 4,000 prompt tokens per second
    }

    static void report(String name, double[] ttft, long promptTokens, long computedTokens) {
        Arrays.sort(ttft);
        double mean = Arrays.stream(ttft).average().orElse(0);
        System.out.printf(Locale.ROOT, "%-30s mean %6.1f  p50 %6.1f  p99 %6.1f ms  cache hit %5.1f%%%n", name, mean,
                ttft[ttft.length / 2 - 1], ttft[(int) Math.ceil(0.99 * ttft.length) - 1], 100.0 * (promptTokens - computedTokens) / promptTokens);
    }

    static int logNormal(Random r, double median, double sigma, int max) {
        return (int) Math.min(max, Math.max(8, Math.round(median * Math.exp(sigma * r.nextGaussian()))));
    }

    public static void main(String[] args) {
        int n = 1000;
        Random random = new Random(3);
        double[][] t = new double[4][n];
        long[][] tok = new long[4][2];
        for (int i = 0; i < n; i++) {
            int len = logNormal(random, 600, 1.0, 8192), user = logNormal(random, 200, 0.8, 4096);
            int[][] cases = {{1024, 0, i > 0 ? 1 : 0}, {0, 1024, 0}, {0, len, 0}, {1500, user, i > 0 ? 1 : 0}};
            for (int c = 0; c < 4; c++) {
                int prefix = cases[c][0], unique = cases[c][1];
                boolean hit = cases[c][2] == 1;
                t[c][i] = ttftMs(prefix, unique, hit);
                tok[c][0] += prefix + unique;
                tok[c][1] += Math.round((t[c][i] - 25) * 4);
            }
        }
        report("same 1024-token prompt", t[0], tok[0][0], tok[0][1]);
        report("random 1024-token prompts", t[1], tok[1][0], tok[1][1]);
        report("lognormal lengths, median 600", t[2], tok[2][0], tok[2][1]);
        report("1500 shared + lognormal 200", t[3], tok[3][0], tok[3][1]);
    }
}
Output
same 1024-token prompt         mean   25.5  p50   25.3  p99   25.3 ms  cache hit  99.8%
random 1024-token prompts      mean  281.0  p50  281.0  p99  281.0 ms  cache hit   0.0%
lognormal lengths, median 600  mean  273.3  p50  178.5  p99 1399.8 ms  cache hit   0.0%
1500 shared + lognormal 200    mean   97.7  p50   79.5  p99  440.0 ms  cache hit  83.6%

Same 1,024-token prompt. Every request after the first finds the whole prompt in the cache and computes one token, so TTFT is 25.3 ms and the cache hit rate is 99.8%. A benchmark that repeats one prompt measures the cache, not prefill.

Random 1,024-token prompts. The prompts have different content but the same length, so nothing is cached and every TTFT is 281.0 ms (25 + 1,024 / 4). This is the standard way to defeat the cache. vLLM's random dataset (the default of --dataset-name) builds prompts from random token ids, and the loop bench prompts in Example 18 start with a unique request number.

Lognormal lengths with median 600. A lognormal distribution (the logarithm of the length is normally distributed) has many short prompts and a long tail of long ones. This is a common shape for chat traffic. The mean TTFT, 273.3 ms, is close to the fixed-length case, but p50 is 178.5 ms and p99 is 1,399.8 ms, because the slowest 1% of prompts have thousands of tokens. A fixed-length benchmark with the same mean misses this tail, which also slows other users' decodes.

1,500 shared tokens plus a lognormal user part. This is the shape of coding agent traffic: a long system prompt with tool definitions, followed by a short part that varies. The cache holds 93 full blocks (1,488 tokens) of the shared prefix, so 83.6% of all prompt tokens are cache hits. Mean TTFT drops to 97.7 ms and p99 to 440.0 ms. Without the cache, every request would compute at least 1,500 tokens (400 ms). vLLM's --random-prefix-len adds such a prefix to a benchmark.

Example 10: llama-bench versus a server benchmark

Two kinds of tools exist for measuring llama.cpp, and they answer different questions. llama-bench measures the raw speed of the llama.cpp compute path. It loads a model, processes a prompt of random tokens, generates tokens, and reports tokens per second. There is no HTTP, no scheduler, and only one sequence, so it isolates the model and hardware from everything else.

The command below uses the tiny random-weight model from the course reference build. The options are:

  • -p sets the prompt length of the prompt test (pp, for prompt processing).
  • -n sets the number of generated tokens of the generation test (tg, for token generation).
  • -r sets the number of repetitions (default 5).
  • -t sets the thread count.
  • -d sets the depth: how many tokens are already in the KV cache before the timed part starts.
Terminal
llama-bench -m tiny-random-qwen2vocab-f32.gguf -p 64 -n 16 -r 2 -t 2 -d 0,256

The table below is representative output from the pinned llama.cpp build on a shared 4-core CPU with the tiny random-weight model. Timings differ by machine.

Output
| model                          |       size |     params | backend    | threads |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | --------------: | -------------------: |
| llama ?B all F32               | 307.51 MiB |    80.61 M | CPU        |       2 |            pp64 |    5355.13 ± 1795.52 |
| llama ?B all F32               | 307.51 MiB |    80.61 M | CPU        |       2 |            tg16 |       156.60 ± 31.38 |
| llama ?B all F32               | 307.51 MiB |    80.61 M | CPU        |       2 |     pp64 @ d256 |    5371.36 ± 1186.63 |
| llama ?B all F32               | 307.51 MiB |    80.61 M | CPU        |       2 |     tg16 @ d256 |        153.59 ± 1.67 |

build: 0c1e570 (1)

Each row is one test. pp64 is the speed of processing a 64-token prompt in one batch: about 5,355 tokens per second. tg16 is the speed of generating 16 tokens one at a time: about 157 tokens per second. The ratio of 34 between them is the prefill and decode difference explained in FLOPs, Bandwidth, and the Roofline Model. A prompt batch reuses each weight for many tokens, while each generated token reads all the weights again.

The ± value is the standard deviation across repetitions, which tells you how much the repeated runs disagreed. It is large here because the run used two repetitions on a shared machine. The @ d256 rows repeat the tests with 256 tokens already in the cache, to show how speed falls as context grows. For this tiny model the change is within the noise.

Three details come from tools/llama-bench/llama-bench.cpp. One warmup run happens before the timed repetitions unless --no-warmup is given. The KV cache is cleared before each repetition. The depth tokens are not timed. Beyond these, -pg 512,128 adds a combined test of a 512-token prompt followed by 128 generated tokens. Every parameter accepts a comma-separated list or a range (-p 128,512, -t 4-8, -ngl 0-32+8), and llama-bench runs every combination.

A server benchmark measures something different: what a client of llama-server sees. That includes HTTP and JSON, tokenization and chat templates, the slot scheduler, sampling, and other users in the batch. Use llama-bench to compare settings such as -ngl (how many layers go on the GPU), -fa (flash attention), -ctk (the KV cache type), thread counts, and quantizations (Tuning llama.cpp). It cannot answer "how many users can this server handle", which needs a load test such as Examples 13 and 18.

Example 11: llama-batched-bench: batched decoding without a server

llama-batched-bench sits between llama-bench and a full server test. It measures batched decoding in the llama.cpp library, with several sequences at once but without HTTP. The course's reference build does not include it, so the facts here come from tools/batched-bench/ (source and README) at the pinned commit.

Its main options are:

  • -npp lists prompt lengths.
  • -ntg lists generation lengths.
  • -npl lists the numbers of parallel sequences.
  • Every combination of the three lists runs.
  • -pps makes all sequences share one prompt, which is prefilled once and copied.
  • -tgs generates the sequences one after another instead of in one batch.
  • --output-format jsonl prints one JSON object per row.

The README at this commit shows this command:

Terminal
llama-batched-bench -m model.gguf -c 2048 -b 2048 -ub 512 -npp 128,256,512 -ntg 128,256 -npl 1,2,4,8,16,32 [-pps]

The output has one row per combination, with these columns: PP and TG (prompt and generated tokens per sequence), B (the number of parallel sequences), N_KV (how many KV cache cells are needed), T_PP and S_PP (prefill time and speed), T_TG and S_TG (generation time and speed), and T and S (total time and speed). The program applies the formulas from batched-bench.cpp to times from the README's sample table for a 7B model (a model with 7 billion parameters). It also adds two rows with assumed times for the shared-prompt mode. Reading the code shows how each column is derived from the times.

Java
import java.util.Locale;

public class BatchedBenchColumns {
    // formulas from tools/batched-bench/batched-bench.cpp
    static void row(int pp, int tg, int pl, boolean ppShared, boolean kvUnified, double tPp, double tTg) {
        int nKv = ppShared ? (kvUnified ? pp : pl * pp) + pl * tg : pl * (pp + tg);
        double speedPp = ppShared ? pp / tPp : pl * pp / tPp;
        double speedTg = pl * tg / tTg;
        double t = tPp + tTg;
        double speed = (ppShared ? pp + pl * tg : pl * (pp + tg)) / t;
        System.out.printf(Locale.ROOT, "|%6d | %6d | %4d | %6d | %8.3f | %8.2f | %8.3f | %8.2f | %8.3f | %8.2f |%n",
                pp, tg, pl, nKv, tPp, speedPp, tTg, speedTg, t, speed);
    }

    public static void main(String[] args) {
        System.out.println("|    PP |     TG |    B |   N_KV |   T_PP s | S_PP t/s |   T_TG s | S_TG t/s |      T s |    S t/s |");
        row(128, 128, 1, false, false, 0.108, 3.079);     // times from the README sample table
        row(128, 128, 8, false, false, 0.751, 7.344);
        row(128, 128, 32, false, false, 3.408, 8.801);
        row(128, 128, 32, true, false, 0.108, 8.801);   // assumed times: -pps, one prefill of 128 tokens
        row(128, 128, 32, true, true, 0.108, 8.801);    // the same with -kvu
        double perSeq1 = 128 / 3.079, perSeq32 = 128 / 8.801;
        System.out.printf(Locale.ROOT, "per-sequence decode speed: B=1 %.2f t/s, B=32 %.2f t/s%n", perSeq1, perSeq32);
    }
}
Output
|    PP |     TG |    B |   N_KV |   T_PP s | S_PP t/s |   T_TG s | S_TG t/s |      T s |    S t/s |
|   128 |    128 |    1 |    256 |    0.108 |  1185.19 |    3.079 |    41.57 |    3.187 |    80.33 |
|   128 |    128 |    8 |   2048 |    0.751 |  1363.52 |    7.344 |   139.43 |    8.095 |   253.00 |
|   128 |    128 |   32 |   8192 |    3.408 |  1201.88 |    8.801 |   465.40 |   12.209 |   670.98 |
|   128 |    128 |   32 |   8192 |    0.108 |  1185.19 |    8.801 |   465.40 |    8.909 |   474.13 |
|   128 |    128 |   32 |   4224 |    0.108 |  1185.19 |    8.801 |   465.40 |    8.909 |   474.13 |
per-sequence decode speed: B=1 41.57 t/s, B=32 14.54 t/s

The first three rows match the README's table within rounding. S_TG agrees exactly. S_PP and S differ in the last digits (1,185.19 against the README's 1,186.64 for B = 1), because the tool divides by the unrounded times and the README prints them rounded to milliseconds.

S_TG is B x TG / T_TG. With 32 sequences, the server generates 465.40 tokens per second, against 41.57 for one sequence. The last line divides that by B: each of the 32 sequences gets 14.54 tokens per second, against 41.57 alone. This is the trade of Example 2 (more total speed, less speed per user), measured without scheduler or HTTP overhead. The numbers are therefore an upper bound for a server with the same model and flags.

The two -pps rows show how the shared-prompt mode changes the accounting. The prompt is prefilled once, so S_PP is PP / T_PP and the total speed counts the prompt once. N_KV depends on whether the KV cache is unified across sequences (-kvu, off by default in this tool). Without it, each sequence gets its own copy of the prompt's cells (B x PP + B x TG = 8,192). With it, the prompt's cells are shared (PP + B x TG = 4,224).

Example 12: reading llama-bench JSON in Java

For scripted comparisons, llama-bench -o jsonl prints one JSON object per test, with every parameter and the raw samples (the individual timings of each repetition). A script can then parse the results instead of reading a table. The command below runs a combined -pg 64,16 test (a 64-token prompt followed by 16 generated tokens). The Java program parses one line captured from the pinned build (with some fields removed) and recomputes the reported speed from the samples.

Terminal
llama-bench -m tiny-random-qwen2vocab-f32.gguf -p 0 -n 0 -pg 64,16 -r 2 -t 2 -o jsonl
Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Locale;

public class LlamaBenchJson {
    // one line of `llama-bench -o jsonl` from the pinned build (tiny random model; some fields removed)
    static final String LINE = """
        {"build_commit": "0c1e570", "backends": "CPU", "model_type": "llama ?B all F32", "model_size": 322446336, \
        "model_n_params": 80611584, "n_batch": 2048, "n_ubatch": 512, "n_threads": 2, "type_k": "f16", "type_v": "f16", \
        "n_gpu_layers": -1, "flash_attn": -1, "n_prompt": 64, "n_gen": 16, "n_depth": 0, \
        "avg_ns": 77120449, "stddev_ns": 9304004, "avg_ts": 1044.942729, "stddev_ts": 126.064519, \
        "samples_ns": [ 83699374, 70541524 ],"samples_ts": [ 955.802, 1134.08 ]}""";

    public static void main(String[] args) throws Exception {
        JsonNode r = new ObjectMapper().readTree(LINE);
        int prompt = r.get("n_prompt").asInt(), gen = r.get("n_gen").asInt(), depth = r.get("n_depth").asInt();
        String test = prompt > 0 && gen > 0 ? "pp" + prompt + "+tg" + gen : prompt > 0 ? "pp" + prompt : "tg" + gen;
        System.out.println("test " + test + (depth > 0 ? " @ d" + depth : "") + ", threads " + r.get("n_threads").asInt()
                + ", K/V cache " + r.get("type_k").asText() + "/" + r.get("type_v").asText());
        double sum = 0;
        for (JsonNode ns : r.get("samples_ns")) {
            double ts = 1e9 * (prompt + gen) / ns.asLong();                  // llama-bench: (n_prompt + n_gen) / time
            sum += ts;
            System.out.printf(Locale.ROOT, "  sample %.3f ms -> %.3f t/s%n", ns.asLong() / 1e6, ts);
        }
        System.out.printf(Locale.ROOT, "avg_ts (mean of samples) %.3f, reported %.3f%n", sum / r.get("samples_ns").size(), r.get("avg_ts").asDouble());
        System.out.printf(Locale.ROOT, "tokens / avg_ns          %.3f%n", 1e9 * (prompt + gen) / r.get("avg_ns").asLong());
        System.out.printf(Locale.ROOT, "model %.2f MiB, %.2f M parameters, %.2f bytes per parameter%n", r.get("model_size").asLong() / 1048576.0,
                r.get("model_n_params").asLong() / 1e6, r.get("model_size").asDouble() / r.get("model_n_params").asLong());
    }
}
Output
test pp64+tg16, threads 2, K/V cache f16/f16
  sample 83.699 ms -> 955.802 t/s
  sample 70.542 ms -> 1134.084 t/s
avg_ts (mean of samples) 1044.943, reported 1044.943
tokens / avg_ns          1037.338
model 307.51 MiB, 80.61 M parameters, 4.00 bytes per parameter

The first line rebuilds the test name from n_prompt, n_gen, and n_depth. The markdown table prints this same test as pp64+tg16. The samples are the two repetitions, 83.7 ms and 70.5 ms. Each becomes a speed as (n_prompt + n_gen) / time, which is the formula in llama-bench.cpp (get_ts). For the first sample, 80 tokens in 83.7 ms is 955.8 tokens per second.

avg_ts is the mean of the per-sample speeds (1,044.943), not the token count divided by the mean time (1,037.338). The two differ because the mean of 1/x is not 1/mean(x). For a combined pg test, the speed mixes prefill and decode tokens into one number, so it is not comparable with a tg result.

The last line uses model_size and model_n_params. Dividing size by parameter count gives 4.00 bytes per parameter, which matches the F32 type (32-bit floating point, 4 bytes) of every tensor in this test model. For a quantized model the same division gives the average size per weight of the file, which is the quantity that Memory Math multiplies by the parameter count.

Example 13: vllm bench serve options

vllm bench serve is vLLM's load tester. It works with any server that speaks the OpenAI API, llama-server included. It loads the model's Hugging Face tokenizer for two jobs: building prompts of exact token lengths, and counting tokens when a response has no usage object.

The random dataset builds the prompts. It samples lengths from a uniform integer range, prepends one fixed random prefix of --random-prefix-len tokens to every prompt, and fills the rest with random token ids, seeded by --seed. Random content means the prefix cache cannot help except for the shared prefix you choose.

The command below tests a chat workload with Poisson arrivals, a concurrency cap, and SLOs. Read it as five groups: where the server is, what prompts to send, how fast to send them, how many to run at once, and what to report.

Terminal
vllm bench serve \
  --backend openai-chat --base-url http://127.0.0.1:8000 --endpoint /v1/chat/completions \
  --model Qwen/Qwen2.5-Coder-7B-Instruct \
  --dataset-name random --random-input-len 1024 --random-output-len 256 --random-range-ratio 0.5 \
  --random-prefix-len 512 --num-prompts 500 --seed 0 \
  --request-rate 8 --burstiness 1.0 --max-concurrency 64 --num-warmups 10 --ignore-eos \
  --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,90,99 \
  --goodput ttft:500 tpot:50 --save-result --result-dir results/

This command needs a running vLLM server and a GPU (tier 3), so the lesson does not run it. The table lists the options, with defaults taken from vllm/benchmarks/serve.py and vllm/benchmarks/datasets/datasets.py in 0.30.0.

Option Default Meaning
--backend openai Request format: openai for /v1/completions, openai-chat for chat completions, also embedding and rerank backends
--base-url, --host, --port, --endpoint none, 127.0.0.1, 8000, /v1/completions The request URL is base-url + endpoint, or http://host:port + endpoint
--dataset-name random Prompt source; others include sharegpt, burstgpt, sonnet, hf, custom, and prefix_repetition
--random-input-len, --random-output-len 1024, 128 Token lengths of the random dataset
--random-range-ratio 0.0 Lengths drawn from [len x (1 - r), len x (1 + r)]; one value or a JSON object with input and output
--random-prefix-len 0 Fixed prefix tokens before the random part, for prefix cache tests
--num-prompts 1000 Number of requests
--request-rate inf Requests per second; inf sends all at time 0
--burstiness 1.0 Gamma shape of the gaps; 1 is Poisson
--max-concurrency none Cap on requests in flight; the others wait in the client
--num-warmups 0 Warmup requests before the measured run
--ignore-eos off Sends ignore_eos, so every request generates the full output length
--percentile-metrics ttft,tpot,itl Metrics with percentiles; also e2el, client_queue_time, e2el_including_client_queue
--metric-percentiles 99 Which percentiles to print
--goodput none SLOs as KEY:VALUE in milliseconds; keys ttft, tpot, e2el
--save-result, --save-detailed, --result-dir off, off, current directory Write a JSON file with the metrics; --save-detailed adds per-request data such as TTFTs, ITLs, and errors

Two details from the source affect the numbers. First, TTFT is taken at the first chunk with a choices entry, and ITL gets one value per later chunk, so a server that bundles several tokens into one chunk produces fewer, larger ITL values. Second, --max-concurrency combined with --request-rate makes the run partly closed. If the server falls behind, requests wait in the client, and the time they wait is reported as client_queue_time rather than included in TTFT (the coordinated omission issue of Example 6).

Example 14: the vllm bench serve report, computed in Java

The report that vllm bench serve prints has a fixed layout: a title line, totals and rates, then one section per metric with the mean, the median, and the selected percentiles. Reading real reports is easier when you know how each line is computed.

The program applies the rules of calculate_metrics in serve.py to eight synthetic requests, one of which failed. It prints the result with the same format strings ({:<40} {:<10.2f} in Python becomes %-40s %-10.2f in Java, with trailing spaces removed). The goodput SLOs are TTFT 400 ms and TPOT 50 ms. Check the totals first, then each metric section.

Java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;

public class VllmReport {
    record Out(boolean success, double start, int promptLen, double ttft, double[] itl, int outputTokens) {
        double latency() { return ttft + Arrays.stream(itl).sum(); }
    }

    static double pct(List<Double> v, double p) {                 // numpy's default: linear interpolation
        double[] a = v.stream().mapToDouble(Double::doubleValue).sorted().toArray();
        double idx = p / 100 * (a.length - 1);
        int lo = (int) Math.floor(idx), hi = (int) Math.ceil(idx);
        return a[lo] + (a[hi] - a[lo]) * (idx - lo);
    }

    static void line(String k, Object v) {
        System.out.println(String.format(Locale.ROOT, v instanceof Double ? "%-40s %-10.2f" : "%-40s %-10s", k, v).stripTrailing());
    }

    static void metric(String header, String name, List<Double> seconds) {
        int left = (50 - header.length()) / 2;                   // Python's "{s:-^50}" centering
        System.out.println("-".repeat(left) + header + "-".repeat(50 - header.length() - left));
        line("Mean " + name + " (ms):", seconds.stream().mapToDouble(Double::doubleValue).average().orElse(0) * 1000);
        line("Median " + name + " (ms):", pct(seconds, 50) * 1000);
        line("P99 " + name + " (ms):", pct(seconds, 99) * 1000);
    }

    public static void main(String[] args) {
        List<Out> outs = new ArrayList<>();
        for (int i = 0; i < 8; i++) {
            double[] itl = new double[19 + 5 * i];
            Arrays.fill(itl, 0.040 + 0.002 * i);
            outs.add(new Out(i != 7, 0.5 * i, 100 + 50 * i, 0.200 + 0.050 * i, itl, 20 + 5 * i));
        }
        double duration = 6.0;
        List<Double> ttft = new ArrayList<>(), tpot = new ArrayList<>(), itl = new ArrayList<>(), e2el = new ArrayList<>();
        int completed = 0, input = 0, output = 0, good = 0;
        for (Out o : outs) {
            if (!o.success()) continue;
            completed++; input += o.promptLen(); output += o.outputTokens();
            double t = o.outputTokens() > 1 ? (o.latency() - o.ttft()) / (o.outputTokens() - 1) : 0;
            if (o.outputTokens() > 1) tpot.add(t);
            for (double x : o.itl()) itl.add(x);
            ttft.add(o.ttft()); e2el.add(o.latency());
            if (o.ttft() <= 0.400 && t <= 0.050) good++;          // --goodput ttft:400 tpot:50
        }
        System.out.println("=".repeat(12) + " Serving Benchmark Result " + "=".repeat(12));
        line("Successful requests:", completed);
        line("Failed requests:", outs.size() - completed);
        line("Benchmark duration (s):", duration);
        line("Total input tokens:", input);
        line("Total generated tokens:", output);
        line("Request throughput (req/s):", completed / duration);
        line("Request goodput (req/s):", good / duration);
        line("Output token throughput (tok/s):", output / duration);
        line("Total token throughput (tok/s):", (input + output) / duration);
        metric("Time to First Token", "TTFT", ttft);
        metric("Time per Output Token (excl. 1st token)", "TPOT", tpot);
        metric("Inter-token Latency", "ITL", itl);
        metric("End-to-end Latency", "E2EL", e2el);
        System.out.println("=".repeat(50));
    }
}
Output
============ Serving Benchmark Result ============
Successful requests:                     7
Failed requests:                         1
Benchmark duration (s):                  6.00
Total input tokens:                      1750
Total generated tokens:                  245
Request throughput (req/s):              1.17
Request goodput (req/s):                 0.83
Output token throughput (tok/s):         40.83
Total token throughput (tok/s):          332.50
---------------Time to First Token----------------
Mean TTFT (ms):                          350.00
Median TTFT (ms):                        350.00
P99 TTFT (ms):                           497.00
-----Time per Output Token (excl. 1st token)------
Mean TPOT (ms):                          46.00
Median TPOT (ms):                        46.00
P99 TPOT (ms):                           51.88
---------------Inter-token Latency----------------
Mean ITL (ms):                           47.18
Median ITL (ms):                         48.00
P99 ITL (ms):                            52.00
----------------End-to-end Latency----------------
Mean E2EL (ms):                          1954.00
Median E2EL (ms):                        1914.00
P99 E2EL (ms):                           3024.12
==================================================

Failed requests contribute nothing to token counts or latencies, but the rates still divide by the whole 6 s. Total input tokens (1,750) sums the prompt lengths of the successful requests. So Total token throughput (332.50) is (1,750 + 245) / 6. It counts prompt tokens as well as generated ones, which is why it is much larger than Output token throughput (40.83).

Request goodput (0.83) is 5 good requests out of 7, divided by 6 s, with SLOs of 400 ms TTFT and 50 ms TPOT. The requests with index 5 and 6 have TTFTs of 450 and 500 ms and fail the TTFT target. The others have TPOTs from 40 to 48 ms and pass.

Each metric section prints the mean, the median, and P99. vLLM computes percentiles with NumPy's default method, which interpolates linearly between the two nearest ranks (numpy.percentile). That is why P99 TTFT is 497.00 ms: a value between the two largest TTFTs (450 and 500), not one of the measured values. Nearest-rank and HdrHistogram (Example 3) return a measured value or its bucket instead. With thousands of requests the methods agree closely.

ITL and TPOT differ in what they pool. ITL pools the gaps of all requests, so long requests contribute more values. Its mean (47.18 ms) leans toward the later requests, which have the most tokens and the largest gaps. TPOT has one value per request, with a mean of 46.00 ms. In short, TPOT describes requests, while ITL describes gaps, including stalls that a per-request average smooths away.

Example 15: a pitfalls checklist

Most bad benchmark numbers come from a short list of mistakes. The program turns the list into code: each check looks at one property of a benchmark plan and prints a warning if the plan would distort the results. It runs the checks on two plans, a quick laptop run and a release benchmark.

Java
import java.util.ArrayList;
import java.util.List;

public class BenchChecklist {
    record Plan(String name, int warmupRequests, int requestsPerLevel, boolean uniquePrompts, boolean fixedOutputLength,
                boolean streaming, boolean openLoopOrQueueTime, boolean freshServerPerConfig, int clientCpuPercent,
                boolean usageTokenCounts, boolean sameMachineAsProduction) {}

    static List<String> check(Plan p) {
        List<String> w = new ArrayList<>();
        if (p.warmupRequests() == 0) w.add("no warmup: the first requests pay for loading, graph capture, and cold caches");
        if (p.requestsPerLevel() < 200) w.add("only " + p.requestsPerLevel() + " requests per level: p99 is set by the slowest " + Math.max(1, p.requestsPerLevel() / 100) + " request(s)");
        if (!p.uniquePrompts()) w.add("identical prompts: the prefix cache serves almost every prompt token");
        if (!p.fixedOutputLength()) w.add("output length not fixed: an early end-of-sequence changes tokens per request between runs");
        if (!p.streaming()) w.add("not streaming: TTFT and ITL cannot be measured, only end-to-end latency");
        if (!p.openLoopOrQueueTime()) w.add("closed loop without queue time: stalls are hidden (coordinated omission)");
        if (!p.freshServerPerConfig()) w.add("server reused across configurations: cache contents carry over");
        if (p.clientCpuPercent() > 70) w.add("load generator at " + p.clientCpuPercent() + "% CPU: the client may be the bottleneck");
        if (!p.usageTokenCounts()) w.add("tokens counted as chunks: servers may put several tokens in one chunk");
        if (!p.sameMachineAsProduction()) w.add("different hardware or engine version than production: numbers do not transfer");
        return w;
    }

    public static void main(String[] args) {
        List<Plan> plans = List.of(
                new Plan("quick laptop run", 0, 20, false, false, true, false, false, 85, false, false),
                new Plan("release benchmark", 10, 500, true, true, true, true, true, 30, true, true));
        for (Plan p : plans) {
            List<String> warnings = check(p);
            System.out.println(p.name() + ": " + warnings.size() + " warning(s)");
            for (String w : warnings) System.out.println("  - " + w);
        }
    }
}
Output
quick laptop run: 9 warning(s)
  - no warmup: the first requests pay for loading, graph capture, and cold caches
  - only 20 requests per level: p99 is set by the slowest 1 request(s)
  - identical prompts: the prefix cache serves almost every prompt token
  - output length not fixed: an early end-of-sequence changes tokens per request between runs
  - closed loop without queue time: stalls are hidden (coordinated omission)
  - server reused across configurations: cache contents carry over
  - load generator at 85% CPU: the client may be the bottleneck
  - tokens counted as chunks: servers may put several tokens in one chunk
  - different hardware or engine version than production: numbers do not transfer
release benchmark: 0 warning(s)

The quick run fails nine of the ten checks (it does stream, so that one passes). Here is each check, with the reason the mistake distorts results:

  1. No warmup. The first requests pay one-time costs: page faults while the weights are first read from disk (llama.cpp maps the model file with mmap by default), lazy setup in the engine, connection setup and JIT (just-in-time) compilation in a Java client, and empty caches. Both llama-bench (one warmup run by default) and vllm bench serve (--num-warmups) support a warmup.
  2. Too few requests. With 20 requests, p99 is the single slowest request (Example 3). A p99 needs at least several hundred samples, and a p99.9 needs several thousand.
  3. Identical prompts. The prefix cache removes prefill (Example 9), so TTFT looks 10 times better than with real traffic.
  4. Output length not fixed. A model that emits an end-of-sequence token early produces fewer tokens, so runs differ in how much work they do. Both llama-server and vLLM accept ignore_eos in the request body, and --ignore-eos sets it in vLLM's tester.
  5. Not streaming. The client sees only the end of the response, so TTFT and ITL cannot be measured.
  6. Closed loop without queue time. Stalls disappear from the percentiles (Example 6).
  7. Server reused across configurations. Prompt caches and prefix caches from the previous run make the next run faster.
  8. Load generator at 85% CPU. A client that cannot read responses fast enough adds its own delay to every timestamp.
  9. Tokens counted as chunks. Counting chunks undercounts tokens and overstates ITL, because servers may put several tokens in one chunk. The usage object of the final chunk (stream_options.include_usage) gives the server's own count.
  10. Different hardware or engine version. Results transfer only to the same GPU or CPU, driver, engine version, model file, and flags.

The release plan passes every check.

Example 16: Project 15, stage 1: the clock, timings, and statistics

loop bench is a load tester for any OpenAI-compatible server. You build it in four stages across Examples 16 to 19. Stage 1 is the measurement core, and it has four parts:

  • Clock. The benchmark gets time from an injected clock: an interface it asks for the time. In production it is implemented with System.nanoTime. In tests it is a scripted fake, so the output is the same on every run.
  • Stopwatch. It measures microseconds since its start. Every worker has its own stopwatch.
  • Fake clock. It advances by a list of deltas (time steps), one per reading, cycling through the list.
  • Timing and Stats. Timing holds one request's send time, chunk times, and token count, and computes TTFT, TPOT, and E2E as in Example 1. Stats records the values in HdrHistograms.

In the program, the fake clock's deltas describe one request: one reading for the send, then one reading per chunk. Three requests run back to back on one worker. Check that each request line shows the same TTFT, TPOT, and E2E, because the deltas repeat.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.HdrHistogram.Histogram;

public class BenchCore {
    interface Stopwatch { long micros(); }                  // time since the stopwatch started
    interface Clock { Stopwatch start(); }

    static Clock systemClock() {                            // production: System.nanoTime
        return () -> { long t0 = System.nanoTime(); return () -> (System.nanoTime() - t0) / 1000; };
    }

    /** Test clock: reading i of a stopwatch adds deltas[i % deltas.length] microseconds. */
    record FakeClock(long... deltas) implements Clock {
        public Stopwatch start() {
            long[] state = {0, 0};                           // {readings so far, current time}
            return () -> state[1] += deltas[(int) (state[0]++ % deltas.length)];
        }
    }

    /** One request: when it was sent, when each content chunk arrived, tokens from usage. */
    record Timing(long sentUs, List<Long> chunkUs, int tokens) {
        long ttft() { return chunkUs.get(0) - sentUs; }
        long e2e() { return chunkUs.get(chunkUs.size() - 1) - sentUs; }
        long tpot() { return tokens > 1 ? (e2e() - ttft()) / (tokens - 1) : 0; }
    }

    static final class Stats {
        final Histogram ttft = new Histogram(3_600_000_000L, 3), tpot = new Histogram(3_600_000_000L, 3);
        final Histogram itl = new Histogram(3_600_000_000L, 3), e2e = new Histogram(3_600_000_000L, 3);
        long tokens;
        void add(Timing t) {
            ttft.recordValue(t.ttft()); e2e.recordValue(t.e2e()); tokens += t.tokens();
            if (t.tokens() > 1) tpot.recordValue(t.tpot());
            for (int i = 1; i < t.chunkUs().size(); i++) itl.recordValue(t.chunkUs().get(i) - t.chunkUs().get(i - 1));
        }
        static String ms(Histogram h, double p) { return String.format(Locale.ROOT, "%.1f", h.getValueAtPercentile(p) / 1000.0); }
    }

    public static void main(String[] args) {
        Clock clock = new FakeClock(0, 300_000, 20_000, 20_000, 20_000, 60_000);  // send, TTFT, then 4 gaps
        Stopwatch sw = clock.start();
        Stats stats = new Stats();
        for (int r = 0; r < 3; r++) {                        // one worker, three requests back to back
            long sent = sw.micros();
            List<Long> chunks = new ArrayList<>();
            for (int c = 0; c < 5; c++) chunks.add(sw.micros());   // one reading per content chunk
            Timing t = new Timing(sent, chunks, 5);
            System.out.println("request " + r + ": sent " + sent + " us, chunks " + chunks + ", ttft " + t.ttft()
                    + ", tpot " + t.tpot() + ", e2e " + t.e2e());
            stats.add(t);
        }
        System.out.println("TTFT p50 " + Stats.ms(stats.ttft, 50) + " ms, TPOT p50 " + Stats.ms(stats.tpot, 50)
                + " ms, ITL p50 " + Stats.ms(stats.itl, 50) + " ms, ITL max " + Stats.ms(stats.itl, 100)
                + " ms, E2E p50 " + Stats.ms(stats.e2e, 50) + " ms, tokens " + stats.tokens);
        long t0 = systemClock().start().micros();
        System.out.println("system clock reading right after start is non-negative: " + (t0 >= 0));
    }
}
Output
request 0: sent 0 us, chunks [300000, 320000, 340000, 360000, 420000], ttft 300000, tpot 30000, e2e 420000
request 1: sent 420000 us, chunks [720000, 740000, 760000, 780000, 840000], ttft 300000, tpot 30000, e2e 420000
request 2: sent 840000 us, chunks [1140000, 1160000, 1180000, 1200000, 1260000], ttft 300000, tpot 30000, e2e 420000
TTFT p50 300.0 ms, TPOT p50 30.0 ms, ITL p50 20.0 ms, ITL max 60.0 ms, E2E p50 420.1 ms, tokens 15
system clock reading right after start is non-negative: true

The fake clock's deltas are 0, 300000, 20000, 20000, 20000, 60000: one reading for the send and five for the chunks, so one cycle covers one request. Request 0 is sent at 0 µs, and its chunks arrive at 300,000, 320,000, 340,000, 360,000, and 420,000 µs. The next reading in the cycle adds 0, so request 1 is sent at 420,000 µs, the moment request 0 finished. This is how a closed-loop worker behaves. All three requests have TTFT 300,000 µs and E2E 420,000 µs. TPOT is (420,000 - 300,000) / 4 = 30,000 µs.

The summary line reads the histograms. ITL p50 is 20.0 ms, because most of the gaps (nine of twelve) are 20 ms, and the ITL max is 60.0 ms (the three gaps of 60 ms). TPOT p50 is 30.0 ms, larger than the typical ITL, because it averages in the 60 ms gap. E2E p50 prints 420.1 ms because 420,000 µs falls in a bucket whose highest value is 420,095 µs (Example 3).

The fake clock gives deterministic output only if the code reads it exactly once per event. Stage 2 relies on that rule: the send is one reading, and every content chunk is one reading.

Example 17: Project 15, stage 2: a fake streaming server and closed-loop workers

Stage 2 connects the core to a real HTTP exchange. The client is a trimmed copy of Project 11's ChatClient from The OpenAI API and Streaming, Example 15. It has the same constructor ChatClient(baseUrl, apiKey) and the same stream(Request, Listener, Cancel) method and records. Tool calls, reasoning, and error parsing are removed, because a benchmark of plain text does not use them.

The program also contains a fake server, so it needs no model. The server listens on 127.0.0.1:0 (port 0 means the system picks a free port) and streams the same chunk shapes as llama-server: a role chunk without content, one chunk per token, a usage chunk, and [DONE]. Two workers share five requests. Worker 0 sends requests 0, 2, and 4, and worker 1 sends requests 1 and 3. Each request asks for 4 tokens. In the output, look at when each request is sent and what its chunk times are.

Java
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.Stream;

public class BenchLevel {
    // ---- clock and statistics (stage 1) ----
    interface Stopwatch { long micros(); }
    interface Clock { Stopwatch start(); }
    static Clock systemClock() { return () -> { long t0 = System.nanoTime(); return () -> (System.nanoTime() - t0) / 1000; }; }
    record FakeClock(long... deltas) implements Clock {
        public Stopwatch start() { long[] s = {0, 0}; return () -> s[1] += deltas[(int) (s[0]++ % deltas.length)]; }
    }
    record Timing(long sentUs, List<Long> chunkUs, int tokens) {
        long ttft() { return chunkUs.get(0) - sentUs; }
        long e2e() { return chunkUs.get(chunkUs.size() - 1) - sentUs; }
        long tpot() { return tokens > 1 ? (e2e() - ttft()) / (tokens - 1) : 0; }
    }

    // ---- trimmed ChatClient from Project 11: streaming text only ----
    @JsonInclude(JsonInclude.Include.NON_NULL)
    record Message(String role, String content) {}
    @JsonInclude(JsonInclude.Include.NON_NULL)
    record Request(String model, List<Message> messages, Double temperature, Integer maxTokens, List<Object> tools) {}
    record Usage(int promptTokens, int completionTokens, int totalTokens) {}
    record Result(String content, String finishReason, Usage usage) {}
    interface Listener { void onContent(String text); }
    static final class Cancel { volatile boolean cancelled; }

    static final class ChatClient implements AutoCloseable {
        final HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
        final ObjectMapper json = new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        final URI endpoint;
        ChatClient(String baseUrl, String apiKey) { endpoint = URI.create(baseUrl + "/chat/completions"); }

        Result stream(Request request, Listener listener, Cancel cancel) throws IOException, InterruptedException {
            ObjectNode body = json.valueToTree(request);
            body.put("stream", true);
            body.putObject("stream_options").put("include_usage", true);
            HttpRequest req = HttpRequest.newBuilder(endpoint).timeout(Duration.ofMinutes(10)).header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body))).build();
            HttpResponse<Stream<String>> res = http.send(req, HttpResponse.BodyHandlers.ofLines());
            if (res.statusCode() >= 400) throw new IOException("HTTP " + res.statusCode());
            StringBuilder content = new StringBuilder();
            String finish = null;
            Usage usage = null;
            try (Stream<String> lines = res.body()) {
                Iterator<String> it = lines.iterator();
                while (it.hasNext() && !cancel.cancelled) {
                    String line = it.next();
                    if (!line.startsWith("data:")) continue;
                    String data = line.substring(5).trim();
                    if (data.equals("[DONE]")) break;
                    JsonNode chunk = json.readTree(data);
                    JsonNode u = chunk.path("usage");
                    if (u.isObject()) usage = new Usage(u.path("prompt_tokens").asInt(), u.path("completion_tokens").asInt(), u.path("total_tokens").asInt());
                    for (JsonNode choice : chunk.path("choices")) {
                        String text = choice.path("delta").path("content").asText("");
                        if (!text.isEmpty()) { content.append(text); listener.onContent(text); }
                        if (choice.hasNonNull("finish_reason")) finish = choice.get("finish_reason").asText();
                    }
                }
            }
            return new Result(content.toString(), finish, usage);
        }
        @Override public void close() { http.close(); }
    }

    static Timing once(ChatClient client, Stopwatch sw, int id, int maxTokens) throws Exception {
        List<Long> chunks = new ArrayList<>();
        long sent = sw.micros();                                    // reading 1: the send
        Result r = client.stream(new Request("tiny", List.of(new Message("user", "Request " + id)), 0.0, maxTokens, null),
                text -> chunks.add(sw.micros()), new Cancel());      // one reading per content chunk
        return new Timing(sent, chunks, r.usage().completionTokens());
    }

    // ---- fake streaming server: a role chunk, max_tokens one-token chunks, a usage chunk ----
    static HttpServer fakeServer() throws IOException {
        ObjectMapper json = new ObjectMapper();
        HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
        server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        server.createContext("/v1/chat/completions", ex -> {
            int n = json.readTree(ex.getRequestBody().readAllBytes()).path("max_tokens").asInt(16);
            ex.getResponseHeaders().set("Content-Type", "text/event-stream");
            ex.sendResponseHeaders(200, 0);
            try (OutputStream out = ex.getResponseBody()) {
                List<String> events = new ArrayList<>();
                events.add("{\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"finish_reason\":null}]}");
                for (int i = 0; i < n; i++)
                    events.add("{\"choices\":[{\"index\":0,\"delta\":{\"content\":\" tok" + i + "\"},\"finish_reason\":" + (i == n - 1 ? "\"length\"" : "null") + "}]}");
                events.add("{\"choices\":[],\"usage\":{\"prompt_tokens\":210,\"completion_tokens\":" + n + ",\"total_tokens\":" + (210 + n) + "}}");
                events.add("[DONE]");
                for (String e : events) { out.write(("data: " + e + "\n\n").getBytes(StandardCharsets.UTF_8)); out.flush(); }
            }
        });
        server.start();
        return server;
    }

    public static void main(String[] args) throws Exception {
        HttpServer server = fakeServer();
        Clock clock = new FakeClock(0, 200_000, 30_000, 30_000, 30_000);   // send, TTFT 200 ms, 3 gaps of 30 ms
        int n = 2, requests = 5;
        try (ChatClient client = new ChatClient("http://127.0.0.1:" + server.getAddress().getPort() + "/v1", null);
             ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<List<String>>> workers = new ArrayList<>();
            for (int w = 0; w < n; w++) {
                int worker = w;
                workers.add(pool.submit(() -> {
                    Stopwatch sw = clock.start();                          // each worker has its own stopwatch
                    List<String> lines = new ArrayList<>();
                    for (int id = worker; id < requests; id += n) {        // closed loop: next request after the last chunk
                        Timing t = once(client, sw, id, 4);
                        lines.add("worker " + worker + " request " + id + ": sent " + t.sentUs() / 1000 + " ms, chunks "
                                + t.chunkUs().stream().map(c -> c / 1000).toList() + " ms, tokens " + t.tokens()
                                + ", ttft " + t.ttft() / 1000 + ", tpot " + t.tpot() / 1000 + ", e2e " + t.e2e() / 1000);
                    }
                    return lines;
                }));
            }
            for (Future<List<String>> f : workers) f.get().forEach(System.out::println);
        } finally {
            server.stop(0);
        }
    }
}
Output
worker 0 request 0: sent 0 ms, chunks [200, 230, 260, 290] ms, tokens 4, ttft 200, tpot 30, e2e 290
worker 0 request 2: sent 290 ms, chunks [490, 520, 550, 580] ms, tokens 4, ttft 200, tpot 30, e2e 290
worker 0 request 4: sent 580 ms, chunks [780, 810, 840, 870] ms, tokens 4, ttft 200, tpot 30, e2e 290
worker 1 request 1: sent 0 ms, chunks [200, 230, 260, 290] ms, tokens 4, ttft 200, tpot 30, e2e 290
worker 1 request 3: sent 290 ms, chunks [490, 520, 550, 580] ms, tokens 4, ttft 200, tpot 30, e2e 290

Each line is one request. The listener of stream is called once per non-empty content delta, and once reads the stopwatch inside it. The role chunk has "content": null, so ChatClient does not call the listener for it and the clock is not read. TTFT therefore starts at the first content token, which is the definition users care about. The four content chunks give four readings, and the fake clock's deltas (0, 200000, 30000, 30000, 30000) put them at 200, 230, 260, and 290 ms after the send.

Worker 0's requests are sent at 0, 290, and 580 ms. Each begins when the previous one ends, which is the closed loop of Example 7. Worker 1 has its own stopwatch, so its requests also start at 0 and 290 ms, regardless of how the two virtual threads were scheduled. The output is deterministic although the workers run in parallel over real HTTP. The token count 4 comes from the usage chunk (completion_tokens), not from counting chunks (Example 15, item 9).

Example 18: Project 15, stage 3: the loop bench command

Stage 3 adds the command line, using picocli (picocli Basics), and the concurrency sweep of Example 8. The options are:

  • --concurrency 1,2,4,8 runs one level per value, where each value is the number of closed-loop clients.
  • --requests is the number of measured requests per level.
  • --warmup sends unmeasured requests first.
  • --slo-ttft-ms and --slo-tpot-ms set the SLOs used for goodput.

Each prompt starts with a unique request number, so the prefix cache cannot reuse it (Example 9). For each level, the command prints successes and failures, requests and output tokens per second, TTFT and TPOT p50 and p99, ITL p99, and goodput. The rates are computed over the level duration, which is the latest last-chunk time of any worker.

With no arguments, main runs a self-test against the fake server. It uses a fake clock per level whose TTFT is 150 + 25 x N ms and ITL is 20 + 2 x N ms, where N is the concurrency. This is a simple model of a server that slows down as concurrency grows, so the table should show the same pattern as Example 8.

Java
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.function.IntFunction;
import java.util.stream.Stream;
import org.HdrHistogram.Histogram;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

@Command(name = "bench", mixinStandardHelpOptions = true, description = "Load test an OpenAI-compatible server.")
public class LoopBench implements Callable<Integer> {
    @Option(names = "--url", required = true, description = "Base URL ending in /v1") String url;
    @Option(names = "--model", defaultValue = "default") String model;
    @Option(names = "--concurrency", split = ",", defaultValue = "1,2,4", description = "Closed-loop clients per level") int[] levels;
    @Option(names = "--requests", defaultValue = "16", description = "Requests per level") int requests;
    @Option(names = "--warmup", defaultValue = "2") int warmup;
    @Option(names = "--max-tokens", defaultValue = "32") int maxTokens;
    @Option(names = "--prompt-words", defaultValue = "200") int promptWords;
    @Option(names = "--slo-ttft-ms", defaultValue = "1000") double sloTtftMs;
    @Option(names = "--slo-tpot-ms", defaultValue = "100") double sloTpotMs;
    IntFunction<Clock> clocks = level -> systemClock();      // tests inject a fake clock per level

    // ---- clock and statistics (stage 1) ----
    interface Stopwatch { long micros(); }
    interface Clock { Stopwatch start(); }
    static Clock systemClock() { return () -> { long t0 = System.nanoTime(); return () -> (System.nanoTime() - t0) / 1000; }; }
    record FakeClock(long... deltas) implements Clock {
        public Stopwatch start() { long[] s = {0, 0}; return () -> s[1] += deltas[(int) (s[0]++ % deltas.length)]; }
    }
    record Timing(long sentUs, List<Long> chunkUs, int tokens) {
        long ttft() { return chunkUs.get(0) - sentUs; }
        long e2e() { return chunkUs.get(chunkUs.size() - 1) - sentUs; }
        long tpot() { return tokens > 1 ? (e2e() - ttft()) / (tokens - 1) : 0; }
    }

    // ---- trimmed ChatClient from Project 11: streaming text only ----
    @JsonInclude(JsonInclude.Include.NON_NULL)
    record Message(String role, String content) {}
    @JsonInclude(JsonInclude.Include.NON_NULL)
    record Request(String model, List<Message> messages, Double temperature, Integer maxTokens, List<Object> tools) {}
    record Usage(int promptTokens, int completionTokens, int totalTokens) {}
    record Result(String content, String finishReason, Usage usage) {}
    interface Listener { void onContent(String text); }
    static final class Cancel { volatile boolean cancelled; }

    static final class ChatClient implements AutoCloseable {
        final HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
        final ObjectMapper json = new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        final URI endpoint;
        ChatClient(String baseUrl, String apiKey) { endpoint = URI.create(baseUrl + "/chat/completions"); }

        Result stream(Request request, Listener listener, Cancel cancel) throws IOException, InterruptedException {
            ObjectNode body = json.valueToTree(request);
            body.put("stream", true);
            body.putObject("stream_options").put("include_usage", true);
            HttpRequest req = HttpRequest.newBuilder(endpoint).timeout(Duration.ofMinutes(10)).header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body))).build();
            HttpResponse<Stream<String>> res = http.send(req, HttpResponse.BodyHandlers.ofLines());
            if (res.statusCode() >= 400) throw new IOException("HTTP " + res.statusCode());
            StringBuilder content = new StringBuilder();
            String finish = null;
            Usage usage = null;
            try (Stream<String> lines = res.body()) {
                Iterator<String> it = lines.iterator();
                while (it.hasNext() && !cancel.cancelled) {
                    String line = it.next();
                    if (!line.startsWith("data:")) continue;
                    String data = line.substring(5).trim();
                    if (data.equals("[DONE]")) break;
                    JsonNode chunk = json.readTree(data);
                    JsonNode u = chunk.path("usage");
                    if (u.isObject()) usage = new Usage(u.path("prompt_tokens").asInt(), u.path("completion_tokens").asInt(), u.path("total_tokens").asInt());
                    for (JsonNode choice : chunk.path("choices")) {
                        String text = choice.path("delta").path("content").asText("");
                        if (!text.isEmpty()) { content.append(text); listener.onContent(text); }
                        if (choice.hasNonNull("finish_reason")) finish = choice.get("finish_reason").asText();
                    }
                }
            }
            return new Result(content.toString(), finish, usage);
        }
        @Override public void close() { http.close(); }
    }

    // ---- one request, one level ----
    Timing once(ChatClient client, Stopwatch sw, int id) throws Exception {
        String prompt = "Request " + id + ". " + "Summarize the build log. ".repeat(Math.max(1, promptWords / 4));
        List<Long> chunks = new ArrayList<>();
        long sent = sw.micros();
        Result r = client.stream(new Request(model, List.of(new Message("user", prompt)), 0.0, maxTokens, null),
                text -> chunks.add(sw.micros()), new Cancel());
        if (chunks.isEmpty()) throw new IOException("no content chunk");
        return new Timing(sent, chunks, r.usage() != null ? r.usage().completionTokens() : chunks.size());
    }

    String level(ChatClient client, int n) throws Exception {
        Clock clock = clocks.apply(n);
        Histogram ttft = new Histogram(3_600_000_000L, 3), tpot = new Histogram(3_600_000_000L, 3), itl = new Histogram(3_600_000_000L, 3);
        long tokens = 0, durationUs = 0;
        int ok = 0, failed = 0, good = 0;
        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<List<Timing>>> workers = new ArrayList<>();
            for (int w = 0; w < n; w++) {
                int worker = w;
                workers.add(pool.submit(() -> {                          // closed loop: requests w, w+n, w+2n, ...
                    Stopwatch sw = clock.start();
                    List<Timing> done = new ArrayList<>();
                    for (int id = worker; id < requests; id += n) {
                        try { done.add(once(client, sw, n * 1000 + id)); } catch (IOException e) { done.add(null); }
                    }
                    return done;
                }));
            }
            for (Future<List<Timing>> f : workers) {
                for (Timing t : f.get()) {
                    if (t == null) { failed++; continue; }
                    ok++; tokens += t.tokens();
                    ttft.recordValue(t.ttft());
                    if (t.tokens() > 1) tpot.recordValue(t.tpot());
                    for (int i = 1; i < t.chunkUs().size(); i++) itl.recordValue(t.chunkUs().get(i) - t.chunkUs().get(i - 1));
                    if (t.ttft() <= sloTtftMs * 1000 && t.tpot() <= sloTpotMs * 1000) good++;
                    durationUs = Math.max(durationUs, t.chunkUs().get(t.chunkUs().size() - 1));
                }
            }
        }
        double s = durationUs / 1e6;
        return String.format(Locale.ROOT, "%4d  %3d  %4d  %6.2f  %7.1f  %8.1f  %8.1f  %8.1f  %8.1f  %7.1f  %7.2f",
                n, ok, failed, ok / s, tokens / s, ttft.getValueAtPercentile(50) / 1000.0, ttft.getValueAtPercentile(99) / 1000.0,
                tpot.getValueAtPercentile(50) / 1000.0, tpot.getValueAtPercentile(99) / 1000.0, itl.getValueAtPercentile(99) / 1000.0, good / s);
    }

    public Integer call() throws Exception {
        try (ChatClient client = new ChatClient(url, null)) {
            Stopwatch warm = systemClock().start();
            for (int i = 0; i < warmup; i++) once(client, warm, -1 - i);
            System.out.println("conc   ok  fail   req/s    tok/s  TTFTp50  TTFTp99  TPOTp50  TPOTp99  ITLp99  goodput");
            for (int n : levels) System.out.println(level(client, n));
        }
        return 0;
    }

    // ---- fake streaming server: a role chunk, max_tokens one-token chunks, a usage chunk ----
    static HttpServer fakeServer() throws IOException {
        ObjectMapper json = new ObjectMapper();
        HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
        server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        server.createContext("/v1/chat/completions", ex -> {
            int n = json.readTree(ex.getRequestBody().readAllBytes()).path("max_tokens").asInt(16);
            ex.getResponseHeaders().set("Content-Type", "text/event-stream");
            ex.sendResponseHeaders(200, 0);
            try (OutputStream out = ex.getResponseBody()) {
                List<String> events = new ArrayList<>();
                events.add("{\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"finish_reason\":null}]}");
                for (int i = 0; i < n; i++)
                    events.add("{\"choices\":[{\"index\":0,\"delta\":{\"content\":\" tok" + i + "\"},\"finish_reason\":" + (i == n - 1 ? "\"length\"" : "null") + "}]}");
                events.add("{\"choices\":[],\"usage\":{\"prompt_tokens\":210,\"completion_tokens\":" + n + ",\"total_tokens\":" + (210 + n) + "}}");
                events.add("[DONE]");
                for (String e : events) { out.write(("data: " + e + "\n\n").getBytes(StandardCharsets.UTF_8)); out.flush(); }
            }
        });
        server.start();
        return server;
    }

    public static void main(String[] args) throws Exception {
        if (args.length > 0) System.exit(new CommandLine(new LoopBench()).execute(args));
        HttpServer server = fakeServer();
        try {
            LoopBench bench = new LoopBench();
            bench.clocks = n -> {                                  // the "server" slows down as concurrency grows
                long[] d = new long[1 + 8];                          // send, first chunk, 7 gaps (max_tokens 8)
                d[1] = (150 + 25L * n) * 1000;
                for (int i = 2; i < d.length; i++) d[i] = (20 + 2L * n) * 1000;
                return new FakeClock(d);
            };
            int code = new CommandLine(bench).execute("--url", "http://127.0.0.1:" + server.getAddress().getPort() + "/v1",
                    "--concurrency", "1,2,4,8", "--requests", "16", "--max-tokens", "8", "--slo-ttft-ms", "300", "--slo-tpot-ms", "30");
            System.out.println("exit code " + code);
        } finally {
            server.stop(0);
        }
    }
}
Output
conc   ok  fail   req/s    tok/s  TTFTp50  TTFTp99  TPOTp50  TPOTp99  ITLp99  goodput
   1   16     0    3.04     24.3     175.1     175.1      22.0      22.0     22.0     3.04
   2   16     0    5.43     43.5     200.1     200.1      24.0      24.0     24.0     5.43
   4   16     0    8.97     71.7     250.1     250.1      28.0      28.0     28.0     8.97
   8   16     0   13.29    106.3     350.2     350.2      36.0      36.0     36.0     0.00
exit code 0

Each row can be checked by hand. At concurrency 1, a request has TTFT 175 ms and seven gaps of 22 ms, so E2E is 175 + 7 x 22 = 329 ms. One worker sends 16 requests back to back, which takes 16 x 329 = 5,264 ms, or 5.264 s. That gives 16 / 5.264 = 3.04 requests per second and 128 / 5.264 = 24.3 tokens per second (16 requests of 8 tokens). TPOT is (329 - 175) / 7 = 22.0 ms.

At concurrency 8, each of the eight workers sends two requests of 350 + 7 x 36 = 602 ms, so the level lasts 1.204 s and reaches 13.29 requests per second. TTFT prints 175.1 and 350.2 because of the HdrHistogram buckets.

Goodput uses --slo-ttft-ms 300 and --slo-tpot-ms 30. Levels 1, 2, and 4 meet both targets (TTFT up to 250 ms, TPOT up to 28 ms), so goodput equals request throughput. At concurrency 8, TTFT is 350 ms and TPOT is 36 ms, so no request meets the SLO and goodput is 0.00, although throughput is the highest. The highest level with full goodput is 4. This is the pattern of Example 4 in a tool you built.

The production and test paths differ only in the clocks field. The HTTP client, the SSE (server-sent events) parsing, the worker loop, the statistics, and the report all run in the test. Failures are counted per request (the fail column) instead of stopping the level, so overload shows up as a count.

Example 19: running loop bench against llama-server

With arguments, the same program runs against a real server using the system clock. The commands below start llama-server with the tiny random-weight model and 4 slots, run three levels of 8 requests with 16 generated tokens each, and then stop the server. The & at the end of the first line runs the server in the background, and kill %1 stops it.

Terminal
llama-server -m tiny-random-qwen2vocab-f32.gguf -c 4096 -np 4 -t 2 --host 127.0.0.1 --port 18645 &
java -cp 'libs/*' LoopBench.java --url http://127.0.0.1:18645/v1 \
    --concurrency 1,2,4 --requests 8 --max-tokens 16 --prompt-words 100
kill %1

The table below is representative output from the pinned llama.cpp build with the tiny random-weight model, on a 4-core CPU shared with other jobs. Values differ on every run and machine.

Output
conc   ok  fail   req/s    tok/s  TTFTp50  TTFTp99  TPOTp50  TPOTp99  ITLp99  goodput
   1    8     0    5.92     94.7      36.4     171.0       6.7      11.6     19.2     5.92
   2    8     0    9.63    154.2      42.6      56.5      10.5      11.5     27.6     9.63
   4    8     0    9.00    144.0     131.6     282.4      16.3      18.7     65.0     9.00

The model has random weights, so its text is meaningless. The server still does the same work as for a trained model of that shape: about 200 prompt tokens per request (the server log shows prompt eval time = 37.56 ms / 198 tokens) and 16 generated tokens. Every request finished (fail 0), and the default SLOs (1,000 ms TTFT, 100 ms TPOT) are met, so goodput equals throughput.

From 1 to 2 clients, throughput rises from 94.7 to 154.2 tokens per second while TPOT grows from 6.7 to 10.5 ms. This is the healthy region of a sweep. At 4 clients, throughput no longer rises (144.0) and TTFT p50 jumps to 131.6 ms. With two server threads (-t 2) on a shared machine, more parallel requests now only make each one slower, which is the knee of Example 8. The TTFT p99 of 171.0 ms at concurrency 1 is a single slow request: with 8 requests per level, p99 is the slowest one (Example 15, item 2).

Interview points

  • TTFT measures queueing plus prefill. ITL differs from TPOT when chunks carry several tokens or when stalls are averaged away.
  • Aggregate throughput rises and per-user speed falls as the batch grows, so a report gives both, together with the load that produced them.
  • Latency is reported as percentiles (p50, p90, p99) with the sample count, because a mean between a fast group and a slow group describes neither.
  • Goodput counts only requests that met every SLO. It can fall while throughput rises, so a configuration is chosen by goodput at a stated SLO.
  • Closed-loop testers hide stalls (coordinated omission). The fixes are measuring from the intended send time, using an open-loop schedule, or reporting client queue time.
  • In a closed-loop benchmark, Little's law gives N = λ(W + Z), and results that violate it point to a measurement bug.
  • A concurrency sweep finds the knee: past the server's batch capacity, throughput flattens and TTFT grows in proportion to the queue length.
  • llama-bench measures the compute path for one sequence (pp, tg, depth). llama-batched-bench adds parallel sequences. Neither replaces an HTTP load test.
  • vllm bench serve reports totals, goodput, and TTFT, TPOT, ITL, and E2EL percentiles computed by linear interpolation, and its total token throughput includes prompt tokens.

Summary

  • TTFT is the time to the first streamed token, E2E is the time to the last, ITL is the list of gaps between chunks, and TPOT is (E2E - TTFT) / (tokens - 1).
  • Output throughput is generated tokens divided by the benchmark's duration, not a mean of per-request speeds.
  • Goodput is requests per second that met every SLO. In vLLM a one-token answer has a TPOT of 0, and SLO comparisons are inclusive.
  • Poisson arrivals have exponential gaps. vLLM's --burstiness 1 is Poisson, and --request-rate inf sends everything at time 0.
  • With N closed-loop clients and think time Z, N = λ(W + Z).
  • Past the knee of a concurrency sweep, throughput is flat, TPOT stops changing, and the extra latency appears in TTFT.
  • A shared 1,500-token prefix in a cache with 16-token blocks gives 1,488 cached tokens per request, and identical prompts give a near-100% hit rate.
  • llama-bench reports pp and tg tokens per second with a standard deviation over repetitions. avg_ts is the mean of per-sample speeds, and depth tokens are not timed.
  • llama-batched-bench reports S_TG = B x TG / T_TG. The -pps option shares one prompt, and N_KV depends on -kvu.
  • vllm bench serve takes TTFT at the first chunk with choices, records one ITL per later chunk, and counts total token throughput over prompt plus output tokens.
  • loop bench uses an injected clock with one stopwatch per worker, reads it once per send and once per content chunk, counts tokens from usage, and reports per-level throughput, percentiles, and goodput.