WORKBENCH
05 · InferenceLesson 5.2

Baseline: my Spark vs Fireworks.

How does a naive model server on my own hardware compare to a commercial inference API?

Listen · 12 min0:00 / 11:30

Fireworks is a company that runs open AI models on its own big GPUs and rents them out over the internet: you send a prompt, text streams back, and you pay a fraction of a cent for every word-piece it writes. Your DGX Spark can run the very same model on your desk. This is the race between the two, measured properly.

Why bother? Because everything you do for the rest of this track is a claim that a number moved: “batching made it faster”, “smaller weights helped”. Without a fair, repeatable way to measure, those claims are just a feeling. It is also a question you will get at work and in interviews: should we run this model ourselves or pay someone? The honest answer depends on how long the prompt is, how long the answer is, and how many people show up at once. By the end you will have your own numbers for all three.

It leans on two earlier lessons: the script that fires many requests at a server, and the waiting-in-line math, both from Waiting vs working, and your prediction from Why ~17 tokens per second? that the Spark tops out around 17 word-pieces a second, which you finally get to check. What you build here is a scoreboard, and the next lessons (how much memory a model needs, remembering past work instead of redoing it, serving many people at once) each exist to move a row on it.

Today

Before this, you want Waiting vs working (you reuse its load generator) and Why ~17 tokens per second? (you check its prediction here). Budget an evening for the server, an evening for the benchmark, and one overnight run.

The Problem

In the last lesson you did one division, 273 GB/s over 16 GB, and predicted your Spark would decode an 8B model at about 17 tokens per second, tops. Hold that number.

Now open Fireworks’ playground, pick an 8B Llama, and ask it for a short story. The text arrives faster than you can read it. Do the same on your Spark and it comes out at roughly the speed you read aloud. So Fireworks is faster. Obviously.

Except: run both from the Spark itself, with a short prompt, and watch the very first word. On localhost your server can show the first word before Fireworks does, because your request never left the box. Then it falls behind on every word after that. And if three friends hit your Spark at the same moment, the third one stares at a blank screen for most of a minute while Fireworks serves thousands of strangers without blinking.

So “which one is faster?” has no answer yet. Faster at what? For how many people? Measured how? This lesson pins all of that down, and turns it into a scoreboard you will spend the rest of this track trying to move.

The Idea

Six numbers that mean “fast”

Think of a restaurant. How long until the first plate lands? How steady is the pace after that? How long is the whole meal? And how many diners can the kitchen feed per hour? A diner cares about the first three. The owner cares about the fourth. An LLM API has the same four, plus two more that come from looking at many requests instead of one.

  1. TTFTTime to first token: from the moment your code sends the request to the moment the first piece of generated text arrives. This is what makes a chat app feel responsive.. The wait before anything happens.
  2. ITLInter-token latency: the gap between one generated token and the next, once text is flowing. Also called time per output token (TPOT). Averaged over a request it is the inverse of tokens per second., also called TPOT. The pace once text is flowing.
  3. Output tokens per second, per stream. Just one over ITL. This is the number you predicted in the last lesson (17 tok/s was a ceiling on it).
  4. End-to-end latency. First token to last token, plus the wait before. For N output tokens it is roughly TTFT + (N − 1) × ITL.
  5. Aggregate throughputTotal output tokens per second across every request the server is handling at once. One user at 15 tok/s and sixteen users at 15 tok/s each are the same per-stream speed but a 16× different throughput.. The owner’s number. It is what an inference company sells.
  6. The tail. Every one of the above, reported as a percentileThe value below which that fraction of your measurements fall. p50 (the median) is the typical request; p99 is the value only 1 in 100 requests is worse than.: p50 for the typical request, p99 for the unlucky one. You already know why p99 matters: at scale, someone is always the unlucky one.

Where do those milliseconds actually go? Follow one request.

FIG_001[ ANATOMY OF A REQUEST ]
CLIENTyour harnessNETWORKLAN or internetGATEWAYTLS, authQUEUEwait your turnGPUprefill, decodeITL½ RTTTCP + TLSfirst callQUEUEPREFILLcompute-bound½ RTTDECODE STREAMbandwidth-bound, one token per stepTIME TO FIRST TOKEN (TTFT)END-TO-END LATENCYt = 0: request leaves your codelast token arrives
One streaming request, end to end. Everything up to the first token lands in TTFT; prefill is compute work, each decode step is mostly memory reads. Which segments are big depends entirely on where the server sits and how busy it is.

Left to right:

So TTFT is network + handshake + queue + prefill. Everything after is decode, at one ITL per token.

Predict firstwrite it down

Same 8B model. Your Spark, called from a script running on the Spark itself, versus Fireworks, called over the internet. One user, a 256-token prompt, a 256-token answer. Who shows the first token first? Who finishes the whole answer first? Guess a number for each.

Reveal

First token: plausibly your Spark, or close to a tie. Localhost costs nothing, and nobody is queued ahead of you. Fireworks pays an internet round trip plus its gateway and scheduler, which can add up to a hundred milliseconds or more before its (much faster) prefill even starts. Your prefill of 256 tokens is slower, but it is short: about the cost of one pass over the weights plus some compute, so on the order of 100 ms.

Whole answer: Fireworks, by a mile. 255 more tokens at your ~13 tok/s is about 20 seconds. At a couple of hundred tokens per second it is closer to one or two. The internet round trip is paid once; the per-token speed is paid 255 times. Short answers favor the box nearby, long answers favor the fast GPU.

Tokens are not chunks

The server streams text using SSEServer-sent events: a plain HTTP response that never “finishes” quickly. The server keeps writing lines that start with data: followed by a blank line, and the client reads them as they arrive.: one HTTP response that stays open, with the server writing data: ... lines as text becomes ready. It is tempting to treat each event as one token and time the gaps. Don’t. An event can carry several tokens.

Your own server will do it on purpose: Hugging Face’s streamer holds text back until it has a whole word, so “Hello!” might arrive as one event even though it is two or three tokens. A busy API might do it by accident, flushing a few tokens at once after a hiccup. Either way, counting events gives you the wrong token count, and the gaps between events are not ITL.

FIG_002[ TOKENS VS EVENTS ]
SERVER MAKES TOKENSITLtoken 1token NCLIENT GETS SSE EVENTS121312111a stall: 3 tokens arrive at oncesendt_firstt_lastTTFTTOK/S = (N − 1) / (t_last − t_first)N comes from usage.completion_tokens, not from counting events
The server makes tokens at a steady rhythm; the client receives events that carry one or more of them. Count tokens from the usage field the server reports, time the window from first to last token, and divide.

So the harness does three things per request: stamp the time it sends, stamp the time of the first event that contains text (TTFT), and stamp the time of the last event with text. Then it takes the token count N from the server’s own report (usage.completion_tokens, sent in the final event) and computes decode speed as (N − 1) divided by the first-to-last window. Minus one because the first token was produced by prefill, not by a decode step. The largest gap between events is still worth recording, as a stutter detector.

Where each one wins

Now play with the whole thing. This figure is a model with made-up-but-reasonable rates (printed underneath), not a measurement. Its job is to show you the shape before your harness shows you the numbers.

FIG_003[ SPARK VS FIREWORKS, ONE REQUEST ]
To first token
Whole request
networkhandshakequeueprefilldecode
TTFT · Spark
TTFT · Fireworks
E2E · Spark
E2E · Fireworks

Spark link

Model, not measurement. Spark: prefill ≈ max(60 ms, prompt ÷ 3,000 tok/s), decode ≈ 13 tok/s, one request at a time. Fireworks: 50 ms gateway + scheduling, prefill ≈ prompt ÷ 25,000 tok/s, decode ≈ 180 tok/s, shared batch. Replace these with your own numbers once you have them.

Top: the race to the first token. Bottom: the whole answer, on its own time scale. Try a 4,096-token prompt, then Tailscale from away, then 16 users. At concurrency above 1 the Spark bar shows a request in steady state, waiting behind everyone else.

Things to try:

Why is Fireworks’ per-token speed so much higher, when decode is “just” reading weights? Because everything in the division from the last lesson is different for them. Their GPUs use HBMHigh-bandwidth memory: memory stacked right next to the GPU chip with a very wide connection. An H100’s spec sheet lists about 3.35 TB/s, roughly 12 times the Spark’s 273 GB/s., about an order of magnitude more bandwidth than your Spark’s LPDDR5x. They may serve the model in FP8An 8-bit floating point format. Storing weights in 8 bits instead of 16 halves the bytes read per token, so a bandwidth-bound decoder gets up to twice as fast. Some quality can be lost; that is the quantization lesson., halving the bytes per token. They may use speculative decoding, which produces several tokens per weight read. And their code is fused, compiled GPU kernels, not a Python loop. They don’t tell you all of it, which is fine. You will build each piece and see what it is worth.

Many users at once

Here is the part that surprises people.

Predict firstwrite it down

Sixteen people send a request to your naive Spark server at the same instant. Each wants a 256-token answer, and your server handles exactly one request at a time at about 13 tok/s. How long does the 16th person wait for their first token? What is the server’s total throughput while all this happens?

Reveal

Each request takes about 20 seconds of service (a fraction of a second of prefill, then 255 tokens at ~13 tok/s). The 16th person waits for 15 full requests before theirs starts: about 15 × 20 s = 5 minutes to the first token. Meanwhile the server’s total throughput is still ~13 tok/s, exactly what it was with one user. Adding users added only waiting.

FIG_004[ SIXTEEN USERS, ONE GPU ]
TTFT · median
TTFT · worst
Throughput · all users
Per user

Each request: 256 prompt tokens, 256 output tokens. Dashed line = waiting. Solid blue = prefill. Light blue = decode.

Naive: each request waits for every one before it, so first-token time grows in a staircase while total throughput stays flat. Batched (a preview of the batching lesson): everyone shares each pass over the weights, so throughput climbs and nobody waits in line. Same illustrative rates as Fig. 003.

Your load generator from Waiting vs working runs a closed loopA load test where a fixed number of workers each send a request, wait for the full answer, then send the next. The number of requests in flight is held constant; the arrival rate adjusts itself.: c workers, each sending its next request as soon as the last one finishes. In steady state there are always c requests in the system, and the one at the back of the line waits behind c − 1 others. That is Little's LawFor any stable system: average number of things inside = arrival rate × average time each spends inside. L = λ × W. It holds for queues at a coffee shop and for requests in a server. from lesson 4, made concrete. L = c requests are in the system. The server finishes λ = 1 / S requests per second, where S is one request’s service time. So each request spends W = L / λ = c × S in the system. Double the users, double the wait. Throughput never moves.

Fireworks behaves completely differently, because it does not serve one request at a time. It runs one pass over the weights for a whole batch of requests, so the expensive memory read is shared. Sixteen more users barely change their per-pass time, which is why their p99 hardly moves as you turn up concurrency. Your server will get the same trick in the batching lesson.

How many samples a p99 needs

Suppose you send 20 requests and sort their TTFTs. Where is the p99? It is the value that 99% of requests beat, so it sits between the 19th and 20th sorted samples, 81% of the way toward the slowest. In other words: your “p99” is just your single worst request. One unlucky garbage collection, one network blip, and that is your headline number.

To trust a percentile you want something like ten samples beyond it. For p90 that means about 100 requests. For p99, about 1,000. That has a real cost here: at 20 seconds a request, 1,000 requests on your naive server is five and a half hours. So be honest instead of heroic. Report n next to every percentile, report p90 when n is small, and print “n under 100” instead of a p99 that is really a max. A nice side effect of the flat throughput: on the naive server, 200 requests take about the same wall time whether c is 1 or 16.

One client for both servers

Fireworks exposes an OpenAI-compatible APIAn HTTP API shaped exactly like OpenAI’s: POST to /v1/chat/completions with a JSON body of model, messages and max_tokens. Most inference providers and engines (vLLM, SGLang) speak it, so one client works against all of them.. You POST a JSON body with model, messages, max_tokens and stream: true to /v1/chat/completions, and get back a stream like this:

data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}

data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Once"},"finish_reason":null}]}

data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" upon a"},"finish_reason":null}]}

data: {"id":"...","choices":[{"index":0,"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":41,"completion_tokens":256,"total_tokens":297}}

data: [DONE]

Three details matter for timing. The first event often carries only the role and empty content, so TTFT is the first event with non-empty content. Fireworks’ docs say it returns usage in the final chunk of a stream; OpenAI’s own API only does that if you ask with stream_options. And the stream ends with the literal line data: [DONE], which is not JSON.

If your Spark server speaks the same API, the benchmark client has no idea which server it is talking to. That is the whole design.

FIG_005[ THE HARNESS ]
PROMPTSprompts.jsonlc WORKERSasyncio + httpxSPARK · YOUR SERVERhttp://spark:8000/v1FIREWORKShttps://api.fireworks.ai/inference/v1same bodydata: {"choices": [{"delta": ...}]} one SSE event at a timeSSE PARSERperf_counter() eachONE RECORDttft, e2e, n, tok/sresults/*.json+ sha, model, dtypeSCOREBOARD.mdone row per changescoreboard.py
A fixed prompt set feeds c async workers. The same request body goes to either server. The parser timestamps events as they arrive, each request becomes one record, and each run becomes one results file and one scoreboard row.

Rules for a fair fight

A benchmark is only as good as the boring parts. Write these into the harness, not into your memory.

Build It

Your project folder for the whole track:

inference/
  server_naive.py     # today: the "before" picture
  bench.py            # the harness, reused by every later lesson
  scoreboard.py
  prompts/short.jsonl
  prompts/long.jsonl
  results/            # one JSON per run, committed
  SCOREBOARD.md       # one row per change, forever
Get PyTorch and the model onto the SparkAgent: fine

The Spark is an ARM64 CPU with a Blackwell GPU (GB10). A plain pip install torch may hand you a wheel that is not built for that combination, and you will lose an evening finding out. Follow NVIDIA’s DGX Spark documentation and playbooks for the current way to get PyTorch; at the time of writing that means one of NVIDIA’s NGC PyTorch containers. The shape, with the tag left for you to look up:

# Shape only: take the current image tag from NVIDIA's DGX Spark docs / the NGC catalog.
docker run --gpus all -it --rm --ipc=host -p 8000:8000 \
  -v "$HOME/inference:/work" \
  -v "$HOME/.cache/huggingface:/root/.cache/huggingface" \
  nvcr.io/nvidia/pytorch:<tag>-py3

# inside the container
pip install transformers fastapi uvicorn httpx
python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name())"

If pip starts downloading a new torch, stop it: you want the one the container shipped. Llama 3.1 is gated on Hugging Face, so accept the license on the model page and log in with the Hugging Face CLI (check its docs for the current login command). Download the weights once; the volume mount keeps them across container restarts.

Write the naive streaming serverHands only

One file, server_naive.py. It must load the model once in bf16 on the GPU, accept OpenAI-shaped chat requests at /v1/chat/completions, and stream SSE events in the format above, ending with a usage event and data: [DONE].

The scaffolding is boilerplate; take it as is:

# server_naive.py: deliberately naive. One request on the GPU at a time. No batching.
import json, os, threading, time, uuid

import torch
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer

MODEL_ID = os.environ.get("MODEL_ID", "meta-llama/Llama-3.1-8B-Instruct")

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.bfloat16).to("cuda")  # older transformers: torch_dtype=
model.eval()

gpu_lock = threading.Lock()  # THE naive part. Every later lesson attacks this line.
app = FastAPI()


class ChatRequest(BaseModel):
    model: str | None = None
    messages: list[dict]
    max_tokens: int = 256
    temperature: float = 0.0
    stream: bool = True
    ignore_eos: bool = False


def sse(obj: dict) -> str:
    return f"data: {json.dumps(obj)}\n\n"


def chunk(rid: str, created: int, delta: dict, finish=None, usage=None) -> dict:
    return {"id": rid, "object": "chat.completion.chunk", "created": created, "model": MODEL_ID,
            "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], "usage": usage}


@app.get("/health")
def health():
    return {"ok": True}

The endpoint is yours. Here is its shape:

@app.post("/v1/chat/completions")
def chat(req: ChatRequest):
    rid, created = f"chatcmpl-{uuid.uuid4().hex[:12]}", int(time.time())

    def events():                 # a plain (sync) generator, on purpose
        with gpu_lock:
            # 1. messages -> token ids with the model's chat template, on the GPU
            # 2. a TextIteratorStreamer (skip the prompt, skip special tokens)
            # 3. start model.generate(...) in a background thread, greedy, max_new_tokens,
            #    and min_new_tokens when req.ignore_eos
            # 4. yield the role event, then one event per piece of text the streamer gives you
            # 5. join the thread, count completion tokens from generate's output ids,
            #    yield the final event with finish_reason and usage, then "data: [DONE]\n\n"
            ...

    return StreamingResponse(events(), media_type="text/event-stream")

Why a thread? model.generate is one long blocking call: it does not return until the last token. The streamer is a queue that generate pushes text into as it goes. Something else has to pull text out of that queue and write it to the socket while generate is still running. Two jobs at once, so two threads: the generate thread does GPU work, and the response thread mostly sleeps on the queue. From Processes, threads and the GIL: threads are fine when one side is mostly waiting, which is exactly this case.

Why a sync generator? Iterating the streamer blocks. If you did that inside an async def, you would freeze the event loop, and from Waiting vs working you know what that means: no other request, not even /health, gets a word in. Starlette runs a plain generator passed to StreamingResponse in a worker thread, so blocking there is safe.

Why the lock? Without it, two requests would run two generate calls on the GPU at once, fighting over memory bandwidth in ways that are hard to reason about. With it, requests queue. That queue is the “before” picture. It is supposed to look bad at c = 16.

Hints: tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_dict=True, return_tensors="pt") gives you the inputs as a dict you can move to the GPU and pass to generate with **. Wrap the generate call in torch.inference_mode(). The output ids of generate include the prompt, so completion tokens = output length − prompt length. Check the transformers docs for streaming if the API has moved.

Run it with uvicorn server_naive:app --host 0.0.0.0 --port 8000 and test with curl -N (no buffering). You should see one data: line per word, not one big blob at the end.

Turn the load generator into a benchmark harnessHands only

Start from the asyncio + httpx load generator you wrote in lesson 4. You are adding streaming, per-token timing, percentiles, and a results file. Two helpers are pure plumbing; take them:

import json, time
import httpx


async def sse_events(resp: httpx.Response):
    """Yield each JSON payload from an OpenAI-style SSE stream. Stops at [DONE]."""
    async for line in resp.aiter_lines():
        if not line.startswith("data:"):
            continue  # blank separators, ": keep-alive" comments, "event:" lines
        data = line[len("data:"):].strip()
        if data == "[DONE]":
            return
        yield json.loads(data)


def pct(xs: list[float], q: float) -> float | None:
    """Percentile with linear interpolation (same as numpy's default)."""
    xs = sorted(xs)
    if not xs:
        return None
    k = (len(xs) - 1) * q / 100
    lo, hi = int(k), min(int(k) + 1, len(xs) - 1)
    return xs[lo] + (xs[hi] - xs[lo]) * (k - lo)

The measuring is yours:

async def one_request(client: httpx.AsyncClient, url: str, headers: dict, body: dict) -> dict:
    """Stream one request. Return a record with: ok, ttft, e2e, n_out, decode_tps,
    n_chunks, max_gap, error. Never raise: a failure is a record with ok=False."""
    # t_send = time.perf_counter()
    # async with client.stream("POST", url, headers=headers, json=body) as resp:
    #     non-200 -> record the status and the start of the body, return
    #     for each event: take usage if present; for each choice with non-empty delta content,
    #     stamp t_first (once), t_last (every time), and the gap since the previous one
    # ttft = t_first - t_send; e2e = end of stream - t_send
    # decode_tps = (n_out - 1) / (t_last - t_first), with n_out from usage


async def run_level(url, headers, model, prompts, conc, n_requests, n_warmup, max_tokens, ignore_eos):
    """Warm up, then run n_requests through `conc` closed-loop workers sharing one AsyncClient.
    Return (records, summary). Summary: n, n_ok, wall_s, throughput_tps (total output tokens
    of successful requests / wall_s), and p50/p90/p99 of ttft, e2e and decode_tps."""

Then a main() with argparse that loops over concurrency levels and writes one JSON file per run into results/. The metadata is what makes a number mean something six months from now:

{
  "meta": {
    "target": "spark-naive", "base_url": "http://localhost:8000/v1",
    "model": "meta-llama/Llama-3.1-8B-Instruct", "precision": "bf16",
    "change": "baseline", "git_sha": "3f2c9e1", "date": "2026-10-20T03:12:44Z",
    "max_tokens": 256, "ignore_eos": true, "prompts": "prompts/short.jsonl",
    "n": 200, "warmup": 3, "client": "spark"
  },
  "levels": [ { "summary": { "concurrency": 1, "ttft_p50": 0.0, "...": "..." }, "requests": ["..."] } ]
}

Hints:

  • httpx’s default timeout is 5 seconds. A request queued behind fifteen others on your server waits minutes before its first byte. Pass httpx.Timeout(connect=10, read=None, write=10, pool=None), and make the connection pool at least as large as c.
  • Workers share one iterator of request indices (for i in counter: in each worker) so the total is exactly n.
  • The API key comes from an environment variable named on the command line, never from the code: --key-env FIREWORKS_API_KEY, then headers["Authorization"] = f"Bearer {os.environ[name]}". Add .env to .gitignore before you create it.
  • Get the git sha with subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], text=True, stderr=subprocess.DEVNULL), and commit before a run so the sha points at the code that produced the numbers.
  • Test it against a fake: a 20-line FastAPI app that sleeps 50 ms, then streams fake tokens at 10 ms each, sometimes two or three per event, behind an asyncio.Lock. You know the right answers for that server (TTFT about 60 ms at c = 1, about (c − 1) × service time above that, flat throughput), so your harness has to reproduce them before you trust it on a real one.
Prompt sets and the scoreboardAgent: fine

Two prompt files, one JSON object per line, each {"messages": [...]}. short.jsonl: 50 varied chat prompts of a sentence or two (questions, small writing tasks, code questions). long.jsonl: 20 prompts of about 2,000 tokens each (paste a long public-domain passage plus a question about it), for measuring prefill. An agent can write these; check the lengths with the tokenizer.

The scoreboard script reads one results file, prints every concurrency level, and prints the row to paste:

# scoreboard.py: print every level of a results file, and its SCOREBOARD.md row.
import json, sys


def ms(x):
    return "-" if x is None else f"{x * 1000:.0f}"


def num(x, d=1):
    return "-" if x is None else f"{x:.{d}f}"


def main(path):
    run = json.load(open(path))
    meta, levels = run["meta"], {l["summary"]["concurrency"]: l["summary"] for l in run["levels"]}

    print(f"{meta['target']}  {meta['model']}  {meta['precision']}  sha={meta['git_sha']}")
    print(f"{'c':>3} {'n_ok':>5} {'ttft p50':>9} {'p90':>7} {'p99':>7} {'tok/s p50':>10} {'e2e p50':>8} {'tput':>8}")
    for c, s in sorted(levels.items()):
        p99 = ms(s["ttft_p99"]) if s["n_ok"] >= 100 else "n<100"
        print(f"{c:>3} {s['n_ok']:>5} {ms(s['ttft_p50']):>9} {ms(s['ttft_p90']):>7} {p99:>7} "
              f"{num(s['decode_tps_p50']):>10} {num(s['e2e_p50'], 2):>8} {num(s['throughput_tps']):>8}")

    one, sixteen = levels.get(1, {}), levels.get(16, {})
    row = [meta["date"][:10], meta["target"], meta["change"],
           f"{ms(one.get('ttft_p50'))} / {ms(one.get('ttft_p99'))}",
           num(one.get("decode_tps_p50")), num(sixteen.get("throughput_tps")),
           f"{meta['precision']}, n={meta['n']}, sha {meta['git_sha']}"]
    print("\nSCOREBOARD row:\n| " + " | ".join(row) + " |")


if __name__ == "__main__":
    main(sys.argv[1])

And SCOREBOARD.md, which you will append to at the end of every lesson in this track:

# Scoreboard: Llama 3.1 8B Instruct, short prompts, 256 output tokens, ignore_eos

| date | target | change | TTFT p50 / p99 ms (c=1) | tok/s p50 (c=1) | throughput tok/s (c=16) | notes |
|------|--------|--------|-------------------------|-----------------|-------------------------|-------|
|      | fireworks | reference |                      |                 |                         |       |
|      | spark  | baseline: HF generate, 1 at a time |  |                 |                         | bf16  |
Run both, overnightAgent: explain only

Commit, then:

export FIREWORKS_API_KEY=...   # in your shell or an untracked .env, never in a file you commit

python bench.py --target spark-naive --base-url http://localhost:8000/v1 \
  --model meta-llama/Llama-3.1-8B-Instruct --precision bf16 \
  --prompts prompts/short.jsonl --concurrency 1,4,16 --n 200 --max-tokens 256 --ignore-eos

python bench.py --target fireworks --base-url https://api.fireworks.ai/inference/v1 \
  --key-env FIREWORKS_API_KEY --model accounts/fireworks/models/llama-v3p1-8b-instruct \
  --precision undisclosed --prompts prompts/short.jsonl --concurrency 1,4,16,64 \
  --n 1000 --max-tokens 256 --ignore-eos

The Spark run takes a few hours (200 requests × ~20 s, three times). For c = 64 on the Spark, use --n 128: every request will wait about 20 minutes, which makes the point without costing a day. Then run both once more with prompts/long.jsonl, --max-tokens 64, at c = 1, to measure prefill. Check Fireworks’ pricing page before the big run; it is about a million output tokens of an 8B model (4 levels × 1,000 requests × 256), but you should know what you are spending.

Done when
  • curl -N against your server shows text arriving word by word, and the last events carry usage and [DONE].
  • Your harness reproduces the known answers on a fake server, including the (c − 1) × service-time staircase.
  • results/ holds runs for both targets at c = 1, 4, 16 (and 64 for Fireworks), each with full metadata.
  • SCOREBOARD.md has its first two rows, and nothing in the repo contains your API key.

Measure

Fill this in from scoreboard.py. Short prompts, 256 output tokens.

targetcTTFT p50TTFT p99 (n)tok/s p50e2e p50throughput
Spark, naive1
Spark, naive4
Spark, naive16
Spark, naive64
Fireworks1
Fireworks4
Fireworks16
Fireworks64

What the shape should look like. These are expectations to check, not results; your numbers win.

Spark tok/s at c = 1, against your prediction. Your ceiling was ~17 tok/s from the spec-sheet bandwidth. Naive generate usually lands noticeably below it, often somewhere between half and nine tenths of the ceiling. Write down measured ÷ predicted. The missing part is not memory: it is Python running the decode loop, launching hundreds of small GPU kernelsSmall programs that run on the GPU. Each layer of a forward pass launches several of them, and each launch has a fixed CPU-side cost of a few microseconds, which adds up when there are hundreds per token. per token, and waiting between them. The GPU is idle for slivers of every step.

Spark TTFT at c = 1, short prompts. On localhost, something like 100 to 200 ms: one pass over 16 GB of weights costs ~60 ms by itself before any compute, plus the prompt’s compute and Python overhead. Then look at the long prompts. Prefill is about 2 × parameters × prompt tokens operations: 2 × 8 billion × 2,000 = 32 trillion. If naive code gets a few tens of trillions of operations per second out of the GB10, that is somewhere around one second. Do the division backward from your measured TTFT to get your effective compute rate. That is a number worth knowing.

Spark at c = 4 and 16. TTFT p50 should be close to (c − 1) × your c = 1 end-to-end time, and throughput should be flat at roughly your single-stream tok/s. If both are true, you have just measured Little’s Law. At c = 16 that is several minutes to first token. The batching lesson exists to delete that number.

Fireworks at c = 1. TTFT is mostly your internet round trip plus their front door: expect a few hundred milliseconds, more on a bad connection or a busy hour. Per-stream tok/s for an 8B model is typically in the hundreds. Compare to your Spark: that ratio is the “how far behind am I” headline.

Fireworks at c = 16 and 64. TTFT and tok/s should barely move, and throughput should grow almost in proportion to c, until you hit your account’s rate limit. If you see 429s at c = 64, that is their limit on you, not their GPUs; record it.

The crossover. Find the prompt length at which Fireworks starts winning TTFT even at c = 1. It is where their faster prefill pays for the round trip.

Where the gap comes from

Every gap on your scoreboard has a cause, and every cause has a lesson. This is the map for the next few months.

FIG_006[ THE GAP, WIRED TO THE ROADMAP ]
ON YOUR SCOREBOARDBECAUSELESSONtok/s below the 17 tok/s linePython + launch overheadserving-enginesthe 17 tok/s line itself16 GB of weights read per tokenquantizationone token per weight reada pass can check many guessesspeculative-decodingthroughput flat as c growsone request per weight readbatchingTTFT p99 explodes at c = 16waits behind c − 1 requestsslow TTFT, same system promptre-prefills the same prefixprefix-cachinghow many users fit? long prompts?KV cache memory, attention costkv-cache · memory-math
Read left to right: a symptom you will see in your results, why it happens, and the lesson that attacks it. Each lesson ends by adding a row to SCOREBOARD.md.
What you seeWhyLesson that moves it
tok/s below your 17 tok/s ceilingPython loop, kernel launches, unfused opsInside vLLM and SGLang, FlashAttention
the ceiling itself16 GB read per token in bf16Quantization
one token per pass over the weightsnothing checks several guesses at onceSpeculative decoding
throughput flat as users growone request per pass over the weightsBatching
TTFT p99 explodes at c = 16every request waits for all before itBatching (continuous batching)
repeated system prompts cost full prefillthe same prefix is recomputed every timePrefix caching
how many users fit, at what contextthe KV cache grows with every tokenThe KV cache, Memory math

You will also benchmark vLLM and SGLang on the Spark in Inside vLLM and SGLang. They already do most of the above. You are building the naive server first so that when you read their code, you know exactly which problem each piece solves, because you measured that problem yourself.

Break It

Leave httpx’s default timeout. Run c = 16 against your server with a plain httpx.AsyncClient(). Requests start failing with ReadTimeout, because a queued request gets no bytes for minutes. If your harness drops errors, your p99 suddenly looks great: the slowest requests vanished from the data. Lesson: a timeout is a decision about which requests you are willing to not measure. Make it on purpose, and count failures.

Call generate on the event loop. Make the endpoint async def, call model.generate with the streamer directly, and iterate the streamer in the same function. Watch two things: curl localhost:8000/health hangs while a generation runs, and your harness sees TTFT ≈ end-to-end, because nothing gets flushed until the loop gets control back. That is lesson 4’s “stall” again, now at GPU scale.

Count events as tokens. Have the harness compute tok/s from the number of events instead of usage. With the Hugging Face streamer’s word-sized events, your speed reads noticeably low, because many words are more than one token. Against an API that sometimes bundles tokens, the error shifts around with load. Compare n_chunks to n_out in your records to see how far off you would have been.

Hang up halfway. Start a long request (max_tokens 1,024) and kill the client after a second. Check the server logs and the GPU’s utilization. Nothing in the naive server tells the generate thread that nobody is listening, so the GPU keeps producing an answer no one will read, and depending on how Starlette closes the generator, the lock may be released while it does, letting the next request run alongside a ghost. Real servers watch for disconnects and abort the request. Note it as a bug you will fix when you rewrite the server for batching.

Explain It Back

Answer each out loud, as if in an interview, before opening the answer.

Fireworks has roughly 10× better tok/s than your Spark, but your TTFT is lower at c = 1. Explain both.

TTFT is network plus queue plus prefill. For a short prompt on localhost, my network is basically zero and nobody is ahead of me, and prefill of a few hundred tokens is short even on slow hardware. Fireworks pays an internet round trip and its gateway and scheduler before its fast prefill even starts, so the fixed costs dominate. Tok/s is a different number: each decode step reads every weight once, so it is set by memory bandwidth and by how few bytes you read per token. They have HBM with roughly ten times the Spark’s bandwidth, may store weights in fewer bits, may use speculative decoding to get more than one token per read, and run compiled kernels instead of a Python loop. Fixed costs favor the nearby box; per-token costs favor the fast one. Make the prompt long or the answer long and Fireworks wins both.

How many concurrent users can your naive server handle at a 2-second TTFT SLO?

An SLOService level objective: a target you promise for a metric, like “p99 time to first token under 2 seconds”. of 2 seconds on TTFT means a request can wait almost not at all. One request takes about 20 seconds of service (256 tokens at ~13 tok/s). Anyone who arrives while another request is running waits for the rest of it, about 10 seconds on average. So the honest answer is one user, and only if that user’s requests never overlap with anyone else’s. With random arrivals, the fraction of requests that find the server busy equals its utilization, arrival rate × 20 s. For 99% of requests to find it idle, utilization must be about 1%: one request every half hour or so. Fireworks can promise the same SLO to many users because a new request joins a running batch instead of waiting for it to finish.

What does a million output tokens cost you on the Spark versus the API? Show the method.

Cost per hour of the Spark = purchase price ÷ the hours you expect to use it over its life, plus power draw in kW × your electricity price. Tokens per hour = measured aggregate throughput × 3,600 × the fraction of the time it is actually busy. Cost per million = cost per hour ÷ tokens per hour × 1,000,000. At the naive server’s ~13 tok/s, a million tokens is about 77,000 seconds, roughly 21 hours of the machine, so whatever an hour of Spark costs, multiply by 21. Compare that with the provider’s published price per million output tokens (input tokens are priced separately and are cheaper to produce, since prefill does them in bulk). Then redo it with the batched server’s throughput: the hours per million shrink by roughly the throughput gain. That is exactly the economics the inference companies run, and why batching is their business.

Why can’t 20 samples give you a p99?

With 20 sorted samples, the 99th percentile falls between the 19th and 20th, most of the way toward the 20th. So it is effectively the single slowest request, which is mostly luck: one garbage collection pause or network hiccup. To estimate a percentile you want around ten samples beyond it, so about 100 requests for p90 and 1,000 for p99. When you can’t afford that, report p90 and print n next to every number.

How do TTFT and throughput scale as users go from 1 to 64, on your server and on Fireworks?

On the naive server, the GPU does one request at a time, so throughput stays flat at one stream’s speed, and by Little’s Law the time each request spends in the system is c × the service time. TTFT grows linearly with users: about (c − 1) × 20 s, so tens of minutes at c = 64. On Fireworks, each pass over the weights serves a whole batch, and decode is memory-bound, so adding users costs little extra per pass. Throughput grows nearly linearly with c and per-user latency barely moves, until their batch fills up or your rate limit kicks in. The difference is not hardware speed; it is whether one weight read serves one user or many.

Go Deeper

Connections

This lesson closes the opening chapter: it uses the latency ladder from How slow is slow? for the network, threads from Processes, threads and the GIL for the streamer, the load generator and Little’s Law from Waiting vs working, and your prediction from Why ~17 tokens per second?.

Everything after it in the Inference track is a row on the scoreboard. Start with How much memory does a model need? and The KV cache, by hand, then Batching, where the c = 16 row stops being embarrassing.