How does an AI server answer many people at once?

A server writes one word for many people in a single step, and a new request takes any free place at once. Here is how much that gains, and where it stops.

  • 5 min read
  • 16 min to go deeper
On this page
  1. In one minute
  2. Explore the numbers
  3. How it works
  4. What this means when you build
  5. Go deeperoptional
  6. Skills this lesson builds
  7. Check yourself
  8. Further reading
  9. Codeoptional

A chat server does not answer people one at a time. Take an AI model with 8 billion numbers inside, called weights, on one A100 chip built for AI work, with 1,000 tokens in each chat so far. A token is a small piece of text, often a whole word. In its best case, one token for one user takes 7.94 milliseconds (ms, thousandths of a second). One token for each of 64 users, written in the same step, takes 11.99 ms: 1.5 times as long for 64 times the output. Real services are slower than these best cases. This lesson shows how a server gets so much from one step, and where that stops.

In one minute

A step is one run through the whole model, and it writes one new token for each user in it. Each step fetches all the weights from memory, and that fetch costs the same for one user or for many, so a server puts many users into one step. That group is a batch, and each place in it is a slot. A simple server, using fixed batching, runs a group until its longest reply ends and only then takes new people, so slots of finished replies sit empty while others wait. Continuous batching gives a free slot to a waiting request at the very next step. Picture a tour bus that leaves with whoever is waiting and returns only when everyone is back, against a chairlift where you take the next free chair.

Explore the numbers

Open the widget. It runs the same 48 invented requests through two servers with 8 slots each, one with fixed batching and one with continuous batching. Drag the traffic from 0.5 to 16 new requests a second and slide the clock. It shows a model at the chip’s peak speed, not measurements. At the opening setting, 8 requests a second, the fixed server has 2 of 8 slots in use and 22 people waiting; the continuous one has 7 in use and none waiting.

How it works

One step, many users

The A100 reads 2,039 GB (gigabytes, billions of bytes) a second from memory, so fetching the 16.06 GB of weights takes 7.88 ms. A step cannot end sooner, whoever is in it. Lesson 23 shows why one user leaves the chip idle. Each added user costs a little: the step must also read that user’s attention cache, the notes kept about their earlier tokens, which takes 0.064 ms at 1,000 tokens.

So one user’s step takes 7.94 ms, or 126 tokens a second. With 64 users it takes 11.99 ms: 5,337 tokens a second in all, but 83 for each user. With 256 users it takes 24.33 ms: 10,521 in all and 41 each. The server’s total rises while each person’s pace falls. The chip’s arithmetic is busy under 1% of the time with one user and 27.5% with 64.

Bars of step time for 1, 64 and 256 users with 1,000 tokens each: 7.94, 11.99 and 24.33 ms, mostly the same weights fetch plus a growing cache part.

Figure 1. One step at 1,000 tokens per user. Grey is the weights fetch, the same for everyone; orange is the users’ cache reading.

Fixed groups waste places

Take four slots and six requests, A to F, that all arrive at once. Their replies run 2, 6, 3, 1, 4 and 2 tokens. With fixed batching, A to D start together. D is done after 1 step, A after 2 and C after 3, but B needs 6, so slots sit empty while E and F wait. They start at step 7, and the job takes 10 steps. With continuous batching, E takes D’s slot at step 2 and F takes A’s at step 3, and all is done in 6 steps. In this toy every step costs the same. Of the 24 slot-steps (4 slots times 6 steps), continuous batching fills 18; fixed batching fills the same 18 of 40.

A request that joins also has its prompt (the text the person sent) read in the step it joins, which makes that step longer. Go deeper counts that.

Two grids with a row for each of four slots and a column for each step. Fixed groups take 10 steps and leave empty slots; continuous batching takes 6.

Figure 2. The same six requests, two ways. Filled cells are replies under way; dashed cells are empty slots, orange when a request waits.

What the widget shows

The widget’s requests have prompts of 32 to 577 tokens and replies of 10 to 268. At 8 requests a second the fixed server finishes in 14.1 s and writes 349 tokens a second in all; the continuous server finishes in 8.45 s and writes 582. The average wait for the first word, called time to first token, is 3.43 s against 0.03 s, and an average of 38% of slots are in use against 61%.

With light traffic, 0.5 requests a second, both finish at 103.9 s, because each waits for the last request to arrive. Fixed batching still averages 0.19 s to the first word against 0.01 s. With every reply 60 tokens long, fixed batching wastes no slot inside a group, so the gap shrinks: 7.2 s against 6.9 s at 8 requests a second.

Where it stops

The chip has 80 GB of memory (the data sheet says 80GB; read here as 80 billion bytes, whereas lesson 18 reads it as 80 GiB), and the weights take 16.06 GB, which leaves 63.94 GB for caches. The model here is Llama 3.1 8B, an open model with 8 billion weights, and it needs 128 KiB (about 131,000 bytes) of cache for each token, so a 1,000-token chat takes about 131 MB (million bytes), and 487 such users fill the memory. At 2,000 tokens each, 243 fit; at 8,192 each, 59. This is a best case, in which all the rest holds caches. Lesson 22 shows how a server uses that memory.

Short chats meet another limit. At 100 tokens each, arithmetic becomes the slower job from 175 users, and the step then grows with every user added.

What this means when you build

  • Watch each user’s pace, not only the server’s total. Going from 1 to 64 users raised the total from 126 to 5,337 tokens a second, while each user’s pace fell from 126 to 83.
  • Cache memory caps the crowd. Long chats take many places, so cap prompt and reply lengths if you want more users per chip.
  • Choose continuous batching when reply lengths vary. Fixed batching took 14.1 s for the same 48 requests; continuous batching took 8.45 s. With equal replies the gain was small.
  • A busy server shows up as a longer wait for the first word. At 8 requests a second, fixed batching added 3.43 s on average, continuous 0.03 s.
  • Test under load. One user alone shows the best pace and says nothing about a crowd.

Optionalabout 16 min

Go deeper

Optional. This part is for readers who write Python. The code is in the code/ folder next to this lesson. From inside that folder, python3 show_numbers.py prints every number below, and python3 -B -m pytest -q -p no:cacheprovider runs the tests.

The step time

Let P be the number of weights, c the bytes of cache for one token, and n the number of users writing. A step handles one token for each of them, plus every token of the prompts that join in that step:

tokens = n + joining prompt tokens
flops  = 2 P tokens
bytes  = 2 P + c (sum of the users' contexts)
step   = max(flops / peak, bytes / bandwidth)

A user’s context is its prompt plus the tokens written so far. For Llama 3.1 8B, P is 8,030,261,248 and 2 P is 16,060,522,496 operations per token. The weights are 2 bytes each, 16.06 GB. The cache needs c = 2 x 32 x 8 x 128 x 2 = 131,072 bytes per token: a key and a value for each of 32 layers and 8 key-value heads, 128 numbers long, at 2 bytes each. Peak is 312e12 operations a second, bandwidth 2,039e9 bytes a second and memory 80e9 bytes, the A100 figures of lesson 19 and lesson 23. A step is limited by memory when the bytes take longer than the operations, and by arithmetic otherwise, a tie counting as arithmetic.

Left out are the arithmetic of attention, the cache that a joining prompt writes, and attention among prompt tokens. All are small next to the weights: at 1,000 tokens the attention arithmetic is 4 x 32 x 4,096 x 1,000 operations, about 3% of the 16.06 billion for the weights.

A worked example at two batch sizes

Each user has 1,000 tokens. In the table, Math and Memory are the two times, and the step is the larger one.

Time in ms 1 user 64 users
Math 0.0515 3.2945
Memory 7.9409 11.9907
Step 7.94 11.99

One user gets 125.9 tokens a second. The 64 users get 5,337.4 in all, 83.4 each. For 64 users the memory side reads 16.06 GB of weights plus 64 x 1,000 x 131,072 bytes = 8.39 GB of cache, 24.45 GB in all, which takes 11.99 ms. The math side needs 64 x 16.06 billion operations over 312 trillion a second: 3.29 ms. Memory is the slower job, and the chip is 3.2945 / 11.9907 = 27.5% busy. The step is 1.51 times as long as one user’s, so the server writes 64 x 7.9409 / 11.9907 = 42.4 times as many tokens a second.

Whether arithmetic can win depends on the context. Each user adds 51.5 microseconds of arithmetic. Each user adds cache reading of 64.3 microseconds at 1,000 tokens, which is more, so memory stays the slower job up to the 487 users that fit. At 100 tokens the cache reading is 6.4 microseconds, less than the arithmetic. The first count where arithmetic wins is 175 users, with a step of 9.01 ms. From there the server cannot write more than peak over 2 P, 19,427 tokens a second, however many users are added.

The scheduler

The whole simulator is a loop over steps. Here it is with its comments left out:

def simulate(requests, slots, policy, cost=step_seconds):
    assert policy in ("fixed", "continuous")
    n = len(requests)
    held, made = [-1] * slots, [0] * n
    first, last, steps, join_step = [0.0] * n, [0.0] * n, [], [0] * n
    now, nxt, done = 0.0, 0, 0
    while done < n:
        joined = []
        if policy == "continuous" or all(h < 0 for h in held):
            if all(h < 0 for h in held) and requests[nxt].arrival > now:
                now = requests[nxt].arrival
            for s in range(slots):
                if held[s] < 0 and nxt < n and requests[nxt].arrival <= now:
                    held[s] = nxt
                    joined.append(nxt)
                    join_step[nxt] = len(steps)
                    nxt += 1
        writing = [h for h in held if h >= 0 and h not in joined]
        dt = cost([requests[i].prompt + made[i] for i in writing], sum(requests[i].prompt for i in joined))
        start, now = now, now + dt
        snapshot, finished = list(held), []
        for s, h in enumerate(held):
            if h < 0:
                continue
            made[h] += 1
            if h in joined:
                first[h] = now
            if made[h] == requests[h].reply:
                last[h] = now
                held[s] = -1
                finished.append(h)
        steps.append(Step(start, now, snapshot, joined, finished))
        done += len(finished)
    return Run(steps, first, last, join_step)

One line separates the two policies: policy == "continuous" or all(h < 0 for h in held). Continuous batching may refill a slot at the start of every step. Fixed batching refills only when every slot is empty. A request that joins counts its prompt in cost and gets its first token at the end of that step; each later step writes one more. An idle server jumps ahead to the next arrival. cost is batch_step(...)’s time, the formula above in four lines, and the toy example below passes a cost of 1 for every step.

The toy by hand

With a cost of 1, four slots and replies 2, 6, 3, 1, 4, 2, continuous batching gives this grid. A request takes a slot at the start of a step, in order of arrival, and holds it for as many steps as its reply has tokens. A dot is an empty slot:

step     1 2 3 4 5 6
slot 1   A A F F . .
slot 2   B B B B B B
slot 3   C C C . . .
slot 4   D E E E E .

D leaves after step 1, so E takes its slot at step 2; A leaves after step 2, so F takes its slot at step 3. The grid has 18 letters, the 18 tokens of the six replies, in 6 x 4 = 24 cells. Fixed batching writes the same 18 tokens in 10 x 4 = 40 cells.

How much memory limits the crowd

The users that fit are (80e9 - 16.06e9) / (131,072 x context), rounded down. In the table, Tokens is how many each user has, Cache is the cache of one user, and Users is how many fit.

Tokens Cache Users
100 13 MB 4,878
1,000 131 MB 487
2,000 262 MB 243
4,000 524 MB 121
8,192 1.074 GB 59

Cache per user grows with context, so the crowd shrinks in proportion. When the memory is full the step reads almost all of it: 487 users at 1,000 tokens read 79.89 GB and take 39.18 ms, and 243 users at 2,000 tokens read 79.76 GB and take 39.12 ms. That is why the lines in Figure 3 end where they do.

Step time against users for 100, 1,000 and 2,000 tokens each; each line ends where memory runs out, at 39.2 ms for the longer chats.

Figure 3. Step time against users. A line stops where the memory is full.

This is the best case. The weights and caches are not all the memory a real server uses, and a server that reserves room for each chat’s longest possible length fits fewer chats: lesson 22 works that out.

Light traffic and heavy traffic

The 48 requests are one seeded draw (seed 2026): prompts from 32 to 577 tokens, mean 191.4, and replies from 10 to 268, mean 102.4, 4,914 tokens in all. The traffic setting only scales the gaps between arrivals. These are the results for 8 slots. Rate is new requests a second. The first table gives the seconds until all 48 replies are done, the second the average wait in seconds for the first word.

Rate Fixed Continuous
0.5 103.9 103.9
1 53.6 52.9
2 28.9 27.4
4 16.5 14.7
8 14.1 8.45
16 14.1 6.32
Rate Fixed Continuous
0.5 0.19 0.01
1 0.41 0.01
2 0.78 0.01
4 1.13 0.02
8 3.43 0.03
16 5.02 0.53

Paired bars for 0.5, 8 and 16 requests a second: tokens a second in all, and average wait for the first word, with fixed groups and with continuous batching.

Figure 4. Light and heavy traffic, 8 slots. Orange is fixed batching and green is continuous batching.

At 0.5 requests a second the last request arrives at 102.0 s and its reply is 241 tokens long, so both servers finish when it does and sit idle between arrivals; only the wait differs. The fixed server’s 14.1 s is the same at 8 and 16 requests a second because it never idles: groups run back to back and it cannot go faster. The continuous server keeps improving, as 16 requests a second fill more of the slots, until all 8 are busy. With very mixed replies (5 to 513 tokens) the gap grows: at 8 requests a second fixed batching finishes in 25.7 s and continuous in 10.4 s. Over all 72 settings tried (3 reply mixes, 4 slot counts, 6 traffic levels), continuous batching was never slower and never made the average or slowest wait longer. That is a result for this workload and this model, not a law.

What the model leaves out

Network time, the software around the model, and chips running below their data-sheet peak are all missing, as in lesson 23. So is the memory limit inside the simulator: it never runs out, whereas a real server must decide what to do when the caches fill, for example pausing a request. Real services may also share one model across several chips, which this toy does not.

The model also lets a joining prompt delay everyone else’s next word. A 577-token prompt that joins 7 users with 300 tokens each makes that step 30.06 ms and limited by arithmetic, against 8.01 ms without it. The toy adds a joining prompt’s whole length to that one step. Sarathi-Serve (Agrawal et al., 2024) splits a long prompt into chunks of near-equal size so that new requests join without pausing the decodes under way; only its abstract was read for this lesson, on 2026-10-19.

Proof it works

test_batching_sim.py pins the numbers above. The constants must equal lesson 23’s. A lone user with an empty cache must cost lesson 23’s 7.877 ms decode step, and a prompt that joins an empty server must cost its prefill. The step times, memory caps and the 175-user turning point are checked, as is the claim that each user adds more cache reading than arithmetic at 1,000 tokens but not at 100. The toy runs slot by slot under both policies and must match the table. In every one of the 72 settings and for both policies, nobody may sit in two slots at once, each reply must take exactly as many steps as it has tokens, a fixed lane takes newcomers only when empty, and a continuous lane has an empty slot only when nobody waits. test_widget.py runs the widget’s JavaScript in Node against the Python for all 72 settings and both policies, comparing steps, finish times, summaries and snapshots, and drives the page through a small stand-in for the browser.

Where this is used in practice

The idea of scheduling one step at a time comes from Orca (Yu et al., OSDI 2022). Its abstract says that earlier systems scheduled whole batches, so finished requests could not be returned early and new arrivals waited for the batch to end; it proposes iteration-level scheduling, one model iteration at a time, and reports that on a GPT-3 175B model it outperforms NVIDIA’s FasterTransformer on latency and throughput. The paper does not use the phrase continuous batching, which the Hugging Face blog post below defines as ragged batching combined with dynamic scheduling. The vLLM paper (Kwon et al., SOSP 2023) pairs this kind of batching with paged cache memory and reports 2 to 4 times the throughput of FasterTransformer and Orca at the same latency; those figures are theirs, from their setups, not from this lesson’s toy. All of these were read as abstracts or articles on 2026-10-19.

Skills this lesson builds

  • Explain why a step with many users costs little more than a step with one.
  • Work out a step time from a model’s weights, a chip’s data sheet and the users’ context lengths.
  • Compare fixed batching with continuous batching on a small schedule, slot by slot.
  • Find how many chats fit in a chip’s memory, and say what limits the number.
  • Report a server’s speed as the total and the pace for each user, with the wait for the first word.

Portfolio project: write a small simulator with your own invented arrival times and reply lengths, and run it with fixed batching and with continuous batching. Compare finish time, average wait for the first word and the share of slots in use, at light and at heavy traffic. Publish the code, one chart and a note on where your results disagree with this lesson’s.

Check yourself

  1. Why does a step with 64 users take only about 1.5 times as long as a step with one?

    Answer

    Every step fetches all the weights from memory, 7.88 ms, however many users are in it. Each added user adds only the reading of their cache, 0.064 ms at 1,000 tokens. That makes 7.94 ms for one user and 11.99 ms for 64.

  2. In the toy with four slots, why does fixed batching take 10 steps and continuous batching 6?

    Answer

    Request B needs 6 steps, and a fixed group runs until its longest reply ends. So the slots of D, A and C sit empty while E and F wait, and E and F start at step 7. Continuous batching gives D’s slot to E at step 2 and A’s slot to F at step 3.

  3. Eight users each have 1,000 tokens. What is the step time, and how many tokens a second does the server write in all?

    Answer

    The memory side reads 16.06 GB plus 8 x 131 MB = 17.11 GB, which takes 8.39 ms. The math side needs 0.41 ms, so memory sets the time. The server writes 8 / 0.00839 = 953 tokens a second, or 119 for each user.

  4. How many users with 4,000 tokens each fit beside the weights on an 80 GB chip?

    Answer

    The cache for one user is 4,000 x 131,072 = 524.3 MB. The memory left after the weights is 80 - 16.06 = 63.94 GB, and 63.94 GB over 524.3 MB is 121.9, so 121 users fit, as a best case.

  5. At 0.5 requests a second both servers finish at 103.9 s. Does batching make no difference there?

    Answer

    It makes a difference to the wait, not to the finish time. The last request arrives at 102.0 s, so both servers finish when it does and idle between arrivals. But fixed batching still averages 0.19 s to the first word against 0.01 s.

Further reading

Gentle

Deep

Optional

Code

Each file runs on its own. Download it, or read it here.

batching_sim.py264 linesDownload batching_sim.py
Show code
"""A step-by-step scheduler for one GPU that serves many users, on top of the time model of lesson 23 (why-ai-waits-before-the-first-word).

The model is Llama 3.1 8B with 16-bit weights on an NVIDIA A100 80GB SXM. One step of the server is one pass of the model over every token in it:
the next token of each user who is already writing, plus the whole prompt of each user who joins in that step. The pass reads every weight once,
whatever the number of users, and reads the attention cache of each user who is writing. Its time is the larger of two floors, as in lessons 19 and 23:

    memory floor = (2 P + cache bytes per token * sum of the writing users' contexts) / bandwidth
    math floor   = 2 P * (writing users + joining prompt tokens) / peak          (2 FLOPs per weight per token)

Both floors assume the chip runs at its data-sheet peak, so these are best cases, not measurements. The scheduler runs two policies over the same
requests, one step at a time: fixed groups ("fixed") and continuous batching ("continuous"). The workload is seeded and invented.

Python 3 standard library only. The widget (../widget.html) repeats this scheduler in JavaScript; test_widget.py runs both and compares them.
"""
from __future__ import annotations

import math
import random
from dataclasses import dataclass
from typing import Callable, NamedTuple

# --- the chip and the model: copied from lesson 23 (prefill_sim.py), which copied the chip from lesson 19.
# test_batching_sim.py checks every value against ../../why-ai-waits-before-the-first-word/code/prefill_sim.py.
A100_PEAK_FLOPS = 312e12          # NVIDIA A100 data sheet, revision "Jun21", 80GB SXM column: 312 TFLOPS dense FP16
A100_BANDWIDTH = 2039e9           # the same column: 2,039 GB/s. GB is 10**9 bytes.
GPU_MEMORY_BYTES = 80e9           # the same column: "80GB HBM2e"; the sheet does not say which GB, read here as 80 billion bytes
FP16_BYTES = 2
HIDDEN, FFN, LAYERS, HEADS, KV_HEADS, VOCAB = 4096, 14336, 32, 32, 8, 128256     # Llama 3.1 8B, config.json
HEAD_DIM = HIDDEN // HEADS


def parameter_count() -> int:
    attention = 2 * HIDDEN * HIDDEN + 2 * HIDDEN * KV_HEADS * HEAD_DIM
    layer = attention + 3 * HIDDEN * FFN + 2 * HIDDEN
    return LAYERS * layer + 2 * VOCAB * HIDDEN + HIDDEN


PARAMS = parameter_count()
KV_BYTES_PER_TOKEN = 2 * LAYERS * KV_HEADS * HEAD_DIM * FP16_BYTES        # lesson 22: 128 KiB for each token of context


# --- one step ---------------------------------------------------------------------------------

@dataclass(frozen=True)
class Pass:
    flops: int
    nbytes: int

    @property
    def math_s(self) -> float:
        return self.flops / A100_PEAK_FLOPS

    @property
    def memory_s(self) -> float:
        return self.nbytes / A100_BANDWIDTH

    @property
    def seconds(self) -> float:
        return max(self.math_s, self.memory_s)

    @property
    def bound(self) -> str:
        """A tie counts as arithmetic-limited, as in lessons 19 and 23."""
        return "arithmetic" if self.flops / self.nbytes >= A100_PEAK_FLOPS / A100_BANDWIDTH else "memory"

    @property
    def busy(self) -> float:
        """Share of the chip's peak arithmetic this step reaches."""
        return self.math_s / self.seconds


def batch_step(contexts: list[int], joining_tokens: int = 0) -> Pass:
    """One step for users who are writing (`contexts` holds how many tokens each one has in its cache) and for prompts that join (`joining_tokens` in all).
    Left out: the attention arithmetic, the cache a joining prompt writes and the attention among prompt tokens (lesson 23's "extras"), all small next to the rest."""
    tokens = len(contexts) + joining_tokens
    return Pass(2 * PARAMS * tokens, FP16_BYTES * PARAMS + KV_BYTES_PER_TOKEN * sum(contexts))


def step_seconds(contexts: list[int], joining_tokens: int = 0) -> float:
    return batch_step(contexts, joining_tokens).seconds


def users_that_fit(context: int) -> int:
    """How many users, each with `context` tokens in the cache, fit in the chip's memory beside the weights (best case: all the rest is cache)."""
    return int((GPU_MEMORY_BYTES - FP16_BYTES * PARAMS) // (KV_BYTES_PER_TOKEN * context))


# --- the scheduler ----------------------------------------------------------------------------

class Request(NamedTuple):
    arrival: float       # seconds since the first request
    prompt: int          # tokens in the prompt
    reply: int           # tokens in the reply, at least 1


class Step(NamedTuple):
    start: float
    end: float
    slots: list          # for each slot, the id of the request running in it during this step, or -1
    joined: list         # ids that joined at the start of this step (prefill happens in it)
    finished: list       # ids whose last token came out at the end of this step


class Run(NamedTuple):
    steps: list
    first_token: list    # for each request, the time its first token came out
    finished: list       # for each request, the time its last token came out
    join_step: list      # for each request, the index of the step it joined in


def simulate(requests: list[Request], slots: int, policy: str, cost: Callable[[list[int], int], float] = step_seconds) -> Run:
    """Run `requests` (sorted by arrival) through `slots` slots, one step at a time. A request's id is its index.

    fixed:      new requests are taken only when every slot is empty, so a group runs until its longest reply ends and finished users' slots sit empty.
    continuous: at the start of every step, each empty slot takes the next waiting request.
    Either way, an idle server waits for the next arrival. A request that joins has its prompt read in the step it joins, and its first token comes
    out at the end of that step; each later step writes one more token. `cost(contexts, joining_tokens)` is the step time."""
    assert policy in ("fixed", "continuous")
    n = len(requests)
    held, made = [-1] * slots, [0] * n
    first, last, steps, join_step = [0.0] * n, [0.0] * n, [], [0] * n
    now, nxt, done = 0.0, 0, 0
    while done < n:
        joined = []
        if policy == "continuous" or all(h < 0 for h in held):
            if all(h < 0 for h in held) and requests[nxt].arrival > now:
                now = requests[nxt].arrival                         # nothing is running: wait for the next request
            for s in range(slots):
                if held[s] < 0 and nxt < n and requests[nxt].arrival <= now:
                    held[s] = nxt
                    joined.append(nxt)
                    join_step[nxt] = len(steps)
                    nxt += 1
        writing = [h for h in held if h >= 0 and h not in joined]
        dt = cost([requests[i].prompt + made[i] for i in writing], sum(requests[i].prompt for i in joined))
        start, now = now, now + dt
        snapshot, finished = list(held), []
        for s, h in enumerate(held):
            if h < 0:
                continue
            made[h] += 1
            if h in joined:
                first[h] = now
            if made[h] == requests[h].reply:
                last[h] = now
                held[s] = -1
                finished.append(h)
        steps.append(Step(start, now, snapshot, joined, finished))
        done += len(finished)
    return Run(steps, first, last, join_step)


def report(run: Run, requests: list[Request], slots: int) -> dict:
    """What the lesson and the widget say about a run. Sums are plain left-to-right loops so that JavaScript gives the same bits.
    `slots_in_use` is the share of slot-seconds in use while the server was running steps (waiting for the next request is not counted)."""
    waits = [run.first_token[i] - requests[i].arrival for i in range(len(requests))]
    total_wait = tokens = 0.0
    for w in waits:
        total_wait += w
    for r in requests:
        tokens += r.reply
    used = busy = 0.0
    for st in run.steps:
        used += sum(1 for h in st.slots if h >= 0) * (st.end - st.start)
        busy += st.end - st.start
    seconds = run.steps[-1].end
    return {"replies": len(requests), "seconds": seconds, "tokens_per_second": tokens / seconds, "average_wait": total_wait / len(requests),
            "slowest_wait": max(waits), "slots_in_use": used / (slots * busy), "steps": len(run.steps)}


def snapshot(run: Run, requests: list[Request], t: float) -> dict:
    """What one lane shows at simulated time t (seconds): for each slot how much of its reply is written (0 to 1, or -1 when empty),
    how many requests have arrived and wait for a slot, and how many replies are finished."""
    k = len(run.steps) - 1
    while k >= 0 and run.steps[k].start > t:
        k -= 1
    shares = [-1.0] * len(run.steps[0].slots)
    if k >= 0 and t < run.steps[k].end:
        for s, h in enumerate(run.steps[k].slots):
            if h >= 0:
                shares[s] = (k - run.join_step[h] + 1) / requests[h].reply
    waiting = sum(1 for i, r in enumerate(requests) if r.arrival <= t < run.steps[run.join_step[i]].start)
    return {"slots": shares, "in_use": sum(1 for x in shares if x >= 0), "waiting": waiting, "done": sum(1 for f in run.finished if f <= t)}


# --- the invented workload ----------------------------------------------------------------------
# A seeded draw, not a measurement. Each request has a prompt of 30 to 600 tokens (equal steps on a log scale, mean about 190) and a reply whose length
# depends on the "mix" setting. Requests arrive at random, with the gaps between them drawn once; a traffic setting only scales the gaps.

SEED = 2026
N_REQUESTS = 48
RATES = [0.5, 1, 2, 4, 8, 16]                      # new requests per second, the traffic slider's stops
SLOT_CHOICES = [4, 8, 16, 32]
MIXES = {"same": (60, 60), "mixed": (10, 300), "very mixed": (5, 600)}   # reply length from low to high, equal steps on a log scale


def draw_workload(seed: int = SEED, n: int = N_REQUESTS) -> dict:
    rng = random.Random(seed)
    gaps = [rng.expovariate(1.0) for _ in range(n)]                   # seconds between requests at one request a second
    prompts = [round(30 * (600 / 30) ** rng.random()) for _ in range(n)]
    where = [rng.random() for _ in range(n)]
    replies = {name: [round(lo * (hi / lo) ** x) for x in where] for name, (lo, hi) in MIXES.items()}
    return {"gaps": gaps, "prompts": prompts, "replies": replies}


WORKLOAD = draw_workload()


def make_requests(rate: float, mix: str, workload: dict = WORKLOAD) -> list[Request]:
    """The same requests at any traffic: only the gaps scale. The first request arrives at time 0."""
    out, t = [], 0.0
    for gap, prompt, reply in zip(workload["gaps"], workload["prompts"], workload["replies"][mix]):
        out.append(Request(t, prompt, reply))
        t += gap / rate
    return out


# --- what the widget starts with, and the words it shows (widget.html repeats these in JavaScript; test_widget.py compares them) --------

OPENING = {"rate": 8, "slots": 8, "mix": "mixed", "share": 0.4}     # the opening state: heavy-ish traffic, 40% of the way through the longer run
QUEUE_CAP = 16                                                       # little squares drawn for waiting requests; more are counted as "+N"
TIME_TICKS = 1000                                                    # the time slider has this many positions
PLAY_SECONDS = 10                                                    # Play takes about this long for the longer run


def mix_names(workload: dict = WORKLOAD) -> dict:
    names = {"same": "Same length", "mixed": "Mixed", "very mixed": "Very mixed"}
    out = {}
    for key, replies in workload["replies"].items():
        lo, hi = min(replies), max(replies)
        out[key] = f"{names[key]} ({lo} tokens)" if lo == hi else f"{names[key]} ({lo} to {hi} tokens)"
    return out


def commas(n: int) -> str:
    return f"{n:,}"


def fmt_seconds(s: float) -> str:
    return f"{s:.2f}" if s < 10 else f"{s:.1f}"


def rate_text(rate: float) -> str:
    return f"{rate:g}"


def time_at(tick: int, longest_end: float) -> float:
    """Simulated seconds at a position of the time slider."""
    return tick / TIME_TICKS * longest_end


def now_text(t: float, longest_end: float) -> str:
    return f"{fmt_seconds(t)} s of {fmt_seconds(longest_end)} s"


def state_line(snap: dict, slots: int, total: int) -> str:
    return f"In use {snap['in_use']} of {slots}. {snap['waiting']} waiting. {snap['done']} of {total} done."


def summary_line(rep: dict) -> str:
    tokens = commas(int(f"{rep['tokens_per_second']:.0f}"))
    return (f"Whole run: done after {fmt_seconds(rep['seconds'])} s, {tokens} tokens a second. "
            f"First word after {fmt_seconds(rep['average_wait'])} s on average, {fmt_seconds(rep['slowest_wait'])} s at worst. "
            f"Slots in use: {math.floor(rep['slots_in_use'] * 100 + 0.5)}% on average.")
embed_widget.py39 linesDownload embed_widget.py
Show code
"""Keep the copy of the model, the chip and the workload inside ../widget.html equal to the ones in batching_sim.py.

    python3 embed_widget.py          rewrite the data block in widget.html
    python3 embed_widget.py --check  exit 1 if widget.html differs (test_widget.py runs the same check)
"""
import json
import re
import sys
from pathlib import Path

import batching_sim as sim

WIDGET = Path(__file__).resolve().parent.parent / "widget.html"
CONFIG_BLOCK = re.compile(r'(<script type="application/json" id="bw-config">)(.*?)(</script>)', re.S)


def config():
    """What the page needs to run the same scheduler: the model, the chip, the drawn requests and the settings it offers."""
    return {
        "params": sim.PARAMS, "bytesPerWeight": sim.FP16_BYTES, "kvBytes": sim.KV_BYTES_PER_TOKEN, "peak": sim.A100_PEAK_FLOPS, "bandwidth": sim.A100_BANDWIDTH,
        "gaps": sim.WORKLOAD["gaps"], "prompts": sim.WORKLOAD["prompts"], "replies": sim.WORKLOAD["replies"],
        "rates": sim.RATES, "slotChoices": sim.SLOT_CHOICES, "mixNames": sim.mix_names(), "opening": sim.OPENING,
        "queueCap": sim.QUEUE_CAP, "timeTicks": sim.TIME_TICKS, "playSeconds": sim.PLAY_SECONDS,
    }


def embed(html):
    """html with the data block rewritten from batching_sim.py."""
    settings = json.dumps(config(), ensure_ascii=False, separators=(", ", ": "))
    return CONFIG_BLOCK.sub(lambda m: m.group(1) + "\n" + settings + "\n" + m.group(3), html, count=1)


if __name__ == "__main__":
    current = WIDGET.read_text(encoding="utf-8")
    updated = embed(current)
    if "--check" in sys.argv:
        sys.exit(0 if updated == current else 1)
    WIDGET.write_text(updated, encoding="utf-8")
    print("widget.html data block now matches batching_sim.py")
show_numbers.py91 linesDownload show_numbers.py
Show code
"""Print every number the lesson quotes. Run: python3 show_numbers.py"""
import statistics

from batching_sim import (
    A100_BANDWIDTH, A100_PEAK_FLOPS, FP16_BYTES, GPU_MEMORY_BYTES, KV_BYTES_PER_TOKEN, MIXES, N_REQUESTS, OPENING, PARAMS, RATES, SEED, SLOT_CHOICES,
    WORKLOAD, Request, batch_step, make_requests, mix_names, now_text, report, simulate, snapshot, state_line, summary_line, time_at, users_that_fit,
)


def toy() -> None:
    requests = [Request(0.0, 1, r) for r in (2, 6, 3, 1, 4, 2)]
    for policy in ("fixed", "continuous"):
        run = simulate(requests, 4, policy, cost=lambda contexts, joining: 1.0)
        print(f"  {policy}: {len(run.steps)} steps; first tokens at {[run.first_token[i] for i in range(6)]}; last tokens at {run.finished}")
        for k, st in enumerate(run.steps):
            print(f"    step {k + 1}: slots {st.slots}")


def main() -> None:
    one = batch_step([1000])
    print(f"parameters {PARAMS:,}; weights {FP16_BYTES * PARAMS / 1e9:.2f} GB; chip {A100_PEAK_FLOPS / 1e12:.0f} TFLOP/s, {A100_BANDWIDTH / 1e9:.0f} GB/s, {GPU_MEMORY_BYTES / 1e9:.0f} GB")
    print(f"cache per token {KV_BYTES_PER_TOKEN:,} bytes ({KV_BYTES_PER_TOKEN / 1024:.0f} KiB); weights alone take {FP16_BYTES * PARAMS / A100_BANDWIDTH * 1000:.3f} ms to read")
    print(f"arithmetic per user {2 * PARAMS / A100_PEAK_FLOPS * 1e6:.1f} us; cache reading per user at 1,000 tokens {1000 * KV_BYTES_PER_TOKEN / A100_BANDWIDTH * 1e6:.1f} us, at 100 tokens {100 * KV_BYTES_PER_TOKEN / A100_BANDWIDTH * 1e6:.1f} us")
    print()
    print("one step, every user with the same context")
    print("context | users | step ms | limited by | server tokens/s | per user tokens/s | cache GB read | busy")
    for context in (1000, 100):
        for users in (1, 8, 64, 175, 256, 487):
            if users > users_that_fit(context):
                continue
            p = batch_step([context] * users)
            print(f"{context:7,} | {users:5} | {p.seconds * 1000:7.2f} | {p.bound:10} | {users / p.seconds:15,.0f} | {1 / p.seconds:17.1f} | {users * context * KV_BYTES_PER_TOKEN / 1e9:6.2f} | {p.busy * 100:4.1f}%")
    print(f"  one user at 1,000 tokens: {one.seconds * 1000:.2f} ms (weights alone {FP16_BYTES * PARAMS / A100_BANDWIDTH * 1000:.2f} ms, so the cache adds {(one.seconds - FP16_BYTES * PARAMS / A100_BANDWIDTH) * 1000:.2f} ms)")
    first = next(n for n in range(1, 10000) if batch_step([100] * n).bound == "arithmetic")
    print(f"  at 100 tokens of context the step turns arithmetic-limited from {first} users; at 1,000 tokens never up to {users_that_fit(1000)}")
    print(f"  64 users against 1 user at 1,000 tokens: step time x{batch_step([1000] * 64).seconds / one.seconds:.2f}, server tokens/s x{64 * one.seconds / batch_step([1000] * 64).seconds:.1f}")
    print()
    print("users that fit beside the weights (best case: all the rest is cache)")
    for context in (100, 1000, 2000, 8192):
        print(f"  context {context:6,}: cache {context * KV_BYTES_PER_TOKEN / 1e9:6.3f} GB each -> {users_that_fit(context):,} users; room for cache {(GPU_MEMORY_BYTES - FP16_BYTES * PARAMS) / 1e9:.2f} GB")
    print(f"  4,000 tokens each: {users_that_fit(4000)} users")
    print()
    print("the worked table, and the steps that fill the memory")
    for contexts in ([1000], [1000] * 8, [1000] * 64):
        p = batch_step(contexts)
        print(f"  {len(contexts):3} users at 1,000 tokens: math {p.math_s * 1000:.4f} ms, memory {p.memory_s * 1000:.4f} ms, step {p.seconds * 1000:.2f} ms,"
              f" {len(contexts) / p.seconds:,.1f} tokens/s in all, {1 / p.seconds:.2f} per user, {p.nbytes / 1e9:.2f} GB read")
    for users, context in ((users_that_fit(1000), 1000), (users_that_fit(2000), 2000)):
        p = batch_step([context] * users)
        print(f"  memory full, {users} users at {context:,} tokens: step {p.seconds * 1000:.2f} ms, {p.nbytes / 1e9:.2f} GB read")
    joiner = batch_step([300] * 7, 577)
    print(f"  a 577-token prompt joins 7 users at 300 tokens: step {joiner.seconds * 1000:.2f} ms ({joiner.bound}) against {batch_step([300] * 7).seconds * 1000:.2f} ms without it")
    print()
    print("toy example: six requests at time zero, four slots, unit steps, replies 2 6 3 1 4 2")
    toy()
    print()
    prompts, replies = [r.prompt for r in make_requests(1, "mixed")], {m: [r.reply for r in make_requests(1, m)] for m in MIXES}
    print(f"workload: seed {SEED}, {N_REQUESTS} requests; prompts {min(prompts)} to {max(prompts)} (mean {statistics.fmean(prompts):.1f})")
    for m in MIXES:
        print(f"  {mix_names()[m]}: replies {min(replies[m])} to {max(replies[m])}, mean {statistics.fmean(replies[m]):.1f}, total {sum(replies[m]):,}")
    print(f"  first arrivals at traffic 1: {[round(r.arrival, 2) for r in make_requests(1, 'mixed')[:5]]}; gaps sum {sum(WORKLOAD['gaps']):.1f} s at one a second")
    slow = make_requests(0.5, "mixed")
    print(f"  at 0.5 a second the last request arrives at {slow[-1].arrival:.1f} s and its reply is {slow[-1].reply} tokens")
    print()
    print("results (8 slots unless noted): seconds | tokens/s | average wait | slowest wait | slots in use")
    for mix in MIXES:
        for slots in (8,):
            for rate in RATES:
                requests = make_requests(rate, mix)
                a, b = (report(simulate(requests, slots, p), requests, slots) for p in ("fixed", "continuous"))
                print(f"  {mix:10} S={slots:2} rate {rate:4}: fixed {a['seconds']:6.1f} s {a['tokens_per_second']:5.0f}/s wait {a['average_wait']:5.2f}/{a['slowest_wait']:5.2f} use {a['slots_in_use'] * 100:3.0f}%"
                      f" | continuous {b['seconds']:6.1f} s {b['tokens_per_second']:5.0f}/s wait {b['average_wait']:5.2f}/{b['slowest_wait']:5.2f} use {b['slots_in_use'] * 100:3.0f}%")
    never_worse = all(
        report(simulate(make_requests(r, m), s, "continuous"), make_requests(r, m), s)[k] <= report(simulate(make_requests(r, m), s, "fixed"), make_requests(r, m), s)[k]
        for m in MIXES for s in SLOT_CHOICES for r in RATES for k in ("seconds", "average_wait", "slowest_wait"))
    print(f"  continuous never slower or longer-waiting than fixed in all {len(MIXES) * len(SLOT_CHOICES) * len(RATES)} settings: {never_worse}")
    print()
    print("opening state of the widget")
    requests = make_requests(OPENING["rate"], OPENING["mix"])
    runs = [simulate(requests, OPENING["slots"], p) for p in ("fixed", "continuous")]
    reps = [report(run, requests, OPENING["slots"]) for run in runs]
    end = max(rep["seconds"] for rep in reps)
    t = time_at(round(OPENING["share"] * 1000), end)
    print(f"  {now_text(t, end)}")
    for p, run, rep in zip(("fixed", "continuous"), runs, reps):
        print(f"  {p}: {state_line(snapshot(run, requests, t), OPENING['slots'], N_REQUESTS)}")
        print(f"    {summary_line(rep)}")


if __name__ == "__main__":
    main()
test_batching_sim.py191 linesDownload test_batching_sim.py
Show code
"""Guards for batching_sim.py: the chip and model it copies from lesson 23, the one-step time, the two schedulers, the invented workload, and the numbers the lesson prints."""
import importlib.util
import sys
from pathlib import Path

import pytest

import batching_sim as sim

sys.dont_write_bytecode = True                           # loading lesson 23's file below must not leave a __pycache__ in its folder
LESSON_23 = Path(__file__).resolve().parents[2] / "why-ai-waits-before-the-first-word" / "code" / "prefill_sim.py"
GIB = 2**30
GROUPS = [(rate, mix, slots) for mix in sim.MIXES for slots in sim.SLOT_CHOICES for rate in sim.RATES]


def lesson_23():
    spec = importlib.util.spec_from_file_location("prefill_sim_lesson_23", LESSON_23)
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module                      # dataclasses looks the module up by name
    spec.loader.exec_module(module)
    return module


def toy(policy):
    """Four slots, six requests that all arrive at once with replies of 2, 6, 3, 1, 4 and 2 tokens, every step taking one second."""
    requests = [sim.Request(0.0, 10, reply) for reply in (2, 6, 3, 1, 4, 2)]
    return requests, sim.simulate(requests, 4, policy, cost=lambda contexts, joining: 1.0)


def test_model_and_chip_equal_lesson_23s_and_a_lone_user_costs_what_it_did_there():
    """Guards a copy that drifted: this lesson quotes lesson 23's 7.88 ms step and 128 KiB per token, so one user alone must still cost exactly that."""
    ours, theirs = sim, lesson_23()
    for name in ("PARAMS", "KV_BYTES_PER_TOKEN", "A100_PEAK_FLOPS", "A100_BANDWIDTH", "FP16_BYTES"):
        assert getattr(ours, name) == getattr(theirs, name), name
    assert ours.batch_step([0]).seconds == theirs.decode_step(1).seconds == ours.FP16_BYTES * ours.PARAMS / ours.A100_BANDWIDTH
    for n in (1, 100, 154, 600):                          # a prompt that joins an empty server is lesson 23's prefill
        assert ours.batch_step([], n).seconds == theirs.prefill(n).seconds
    for context in (1, 1000, 8000):                       # a user alone reads its cache and nothing else (lesson 23, with the cache counted)
        assert ours.batch_step([context]).memory_s == theirs.decode_step(context, True).memory_s


def test_numbers_the_lesson_prints_for_one_step():
    """Pins the step times, rates and cache sizes in lesson.md and the figures, so a change to the model cannot leave stale prose."""
    step = lambda users, context: sim.batch_step([context] * users)
    ms = lambda users, context: round(step(users, context).seconds * 1000, 2)
    assert [ms(1, 1000), ms(64, 1000), ms(256, 1000)] == [7.94, 11.99, 24.33]
    assert [round(users / step(users, 1000).seconds) for users in (1, 64, 256)] == [126, 5337, 10521]
    assert [round(1 / step(users, 1000).seconds, 1) for users in (1, 64, 256)] == [125.9, 83.4, 41.1]
    assert [round(users * 1000 * sim.KV_BYTES_PER_TOKEN / 1e9, 2) for users in (1, 64, 256)] == [0.13, 8.39, 33.55]
    assert round(sim.FP16_BYTES * sim.PARAMS / sim.A100_BANDWIDTH * 1000, 2) == 7.88 and ms(64, 100) == 8.29
    assert all(step(users, 1000).bound == "memory" for users in (1, 64, 256, 487))
    assert round(sim.batch_step([1000] * 64).busy * 100, 1) == 27.5 and sim.batch_step([1000]).busy < 0.01     # the share of the step the arithmetic is busy


def test_each_user_adds_more_reading_than_arithmetic_at_a_thousand_tokens_but_not_at_a_hundred():
    """Guards the lesson's claim about why the chip stays memory-limited: reading one user's cache costs 64 us at 1,000 tokens and
    51 us of arithmetic is all a user adds, so the step never turns arithmetic-limited. At 100 tokens it does, from 175 users on."""
    us = lambda seconds: round(seconds * 1e6, 1)
    math_per_user = sim.batch_step([0]).math_s
    assert us(math_per_user) == 51.5 and us(sim.KV_BYTES_PER_TOKEN * 1000 / sim.A100_BANDWIDTH) == 64.3
    assert not any(sim.batch_step([1000] * u).bound == "arithmetic" for u in range(1, sim.users_that_fit(1000) + 1))
    assert sim.batch_step([100] * 174).bound == "memory" and sim.batch_step([100] * 175).bound == "arithmetic"


def test_users_that_fit_in_memory_beside_the_weights():
    """Guards the memory cap the lesson states (best case, all the rest of the 80 GB is cache): 487, 243 and 59 users."""
    assert [sim.users_that_fit(c) for c in (1000, 2000, 8192)] == [487, 243, 59]
    assert (sim.GPU_MEMORY_BYTES - sim.FP16_BYTES * sim.PARAMS) / 1e9 == pytest.approx(63.94, abs=5e-3) and 8192 * sim.KV_BYTES_PER_TOKEN == GIB


@pytest.mark.parametrize("policy", ["fixed", "continuous"])
def test_toy_example_slot_by_slot(policy):
    """Guards the hand-worked example in the lesson (figure 1 and the Go-deeper table): which request sits in which slot at each step."""
    requests, run = toy(policy)
    slots = [step.slots for step in run.steps]
    if policy == "fixed":
        assert slots == [[0, 1, 2, 3], [0, 1, 2, -1], [-1, 1, 2, -1], [-1, 1, -1, -1], [-1, 1, -1, -1], [-1, 1, -1, -1],
                         [4, 5, -1, -1], [4, 5, -1, -1], [4, -1, -1, -1], [4, -1, -1, -1]]
        assert [run.first_token[i] for i in (4, 5)] == [7.0, 7.0] and run.steps[-1].end == 10.0
    else:
        assert slots == [[0, 1, 2, 3], [0, 1, 2, 4], [5, 1, 2, 4], [5, 1, -1, 4], [-1, 1, -1, 4], [-1, 1, -1, -1]]
        assert [run.first_token[i] for i in (4, 5)] == [2.0, 3.0] and run.steps[-1].end == 6.0
    assert run.finished[1] == 6.0 and [s.finished for s in run.steps][:3] == [[3], [0], [2]]


def test_snapshot_reads_a_lane_in_the_middle_of_a_step():
    """Guards the widget's picture: half way through step 0 each slot shows the share of its reply written (a request of 2, 6, 3 and 1 tokens),
    and two requests are counted as waiting until the step they join starts."""
    requests, run = toy("continuous")
    assert sim.snapshot(run, requests, 0.5) == {"slots": [0.5, 1 / 6, 1 / 3, 1.0], "in_use": 4, "waiting": 2, "done": 0}
    assert sim.snapshot(run, requests, 1.5)["slots"] == [1.0, 2 / 6, 2 / 3, 0.25] and sim.snapshot(run, requests, 1.5)["done"] == 1
    assert sim.snapshot(run, requests, 5.9) == {"slots": [-1.0, 6 / 6, -1.0, -1.0], "in_use": 1, "waiting": 0, "done": 5}
    assert sim.snapshot(run, requests, 6.0) == {"slots": [-1.0] * 4, "in_use": 0, "waiting": 0, "done": 6}


def test_an_idle_server_waits_for_the_next_request_and_a_lone_prompt_costs_lesson_23s_prefill():
    """Guards the clock jump: a request that arrives at 10 s finds nothing running, so its first token comes one joining step later, not at 0 s plus anything."""
    prefill = lesson_23().prefill
    requests = [sim.Request(0.0, 100, 3), sim.Request(10.0, 600, 2)]
    for policy in ("fixed", "continuous"):
        run = sim.simulate(requests, 2, policy)
        assert run.steps[-2].start == 10.0 and run.first_token[1] - 10.0 == pytest.approx(prefill(600).seconds, rel=1e-9)
        assert run.first_token[0] == prefill(100).seconds and run.steps[-1].end == run.finished[1]


@pytest.mark.parametrize("policy", ["fixed", "continuous"])
def test_every_setting_obeys_the_rules_of_its_policy(policy):
    """Guards scheduler slips a reader would see as a bug in the picture, in every traffic, reply and slot setting: a request in two places, a reply with
    the wrong number of steps, a start before arrival, a first token out of order, a fixed group that takes a newcomer early, or a continuous lane
    with an empty slot while someone waits."""
    for rate, mix, slots in GROUPS:
        requests = sim.make_requests(rate, mix)
        run = sim.simulate(requests, slots, policy)
        where = {i: [] for i in range(len(requests))}
        for k, step in enumerate(run.steps):
            assert len(step.slots) == slots and len(set(h for h in step.slots if h >= 0)) == sum(1 for h in step.slots if h >= 0), (rate, mix, slots, k)
            assert step.end > step.start and (k == 0 or step.start >= run.steps[k - 1].end)
            for s, h in enumerate(step.slots):
                if h >= 0:
                    where[h].append((k, s))
            writing = [h for h in step.slots if h >= 0 and h not in step.joined]
            if policy == "fixed" and step.joined:
                assert not writing, (rate, mix, slots, k)                       # a new group starts only into an empty lane
            if policy == "continuous" and -1 in step.slots:
                assert all(run.join_step[i] <= k for i, r in enumerate(requests) if r.arrival <= step.start), (rate, mix, slots, k)
        for i, r in enumerate(requests):
            assert [k for k, _ in where[i]] == list(range(run.join_step[i], run.join_step[i] + r.reply)), (rate, mix, slots, i)
            assert len({s for _, s in where[i]}) == 1                          # a reply stays in its slot
            assert run.steps[run.join_step[i]].start >= r.arrival and run.first_token[i] == run.steps[run.join_step[i]].end
            assert run.first_token[i] <= run.finished[i] == run.steps[run.join_step[i] + r.reply - 1].end


def test_equal_replies_leave_no_slot_empty_inside_a_fixed_group_so_fixed_only_waits():
    """Guards the lesson's caveat that with same-length replies fixed groups waste no slots (a group finishes all at once) and lose only by making newcomers wait."""
    for rate in sim.RATES:
        requests = sim.make_requests(rate, "same")
        run = sim.simulate(requests, 8, "fixed")
        assert all(len(step.finished) in (0, sum(1 for h in step.slots if h >= 0)) for step in run.steps), rate


def test_continuous_is_never_slower_and_never_makes_anyone_wait_longer_in_any_setting_here():
    """Guards the lesson's claim that continuous batching is at least as good on the same requests: the same replies finish no later and the first word comes no later,
    on average and at worst, in every one of the 72 settings (it is not a law: a different workload could break it)."""
    for rate, mix, slots in GROUPS:
        requests = sim.make_requests(rate, mix)
        fixed, continuous = (sim.report(sim.simulate(requests, slots, p), requests, slots) for p in ("fixed", "continuous"))
        for key in ("seconds", "average_wait", "slowest_wait"):
            assert continuous[key] <= fixed[key] * (1 + 1e-12), (rate, mix, slots, key)


def test_numbers_the_lesson_prints_for_the_two_schedulers():
    """Pins the results in lesson.md and figure 4 for 8 slots and mixed replies: light traffic (0.5 a second), the opening state (8) and heavy traffic (16)."""
    def results(rate, mix="mixed", slots=8):
        requests = sim.make_requests(rate, mix)
        return [sim.report(sim.simulate(requests, slots, p), requests, slots) for p in ("fixed", "continuous")]
    line = lambda rep: (round(rep["seconds"], 1), round(rep["tokens_per_second"]), round(rep["average_wait"], 2), round(rep["slowest_wait"], 2), round(rep["slots_in_use"] * 100))
    assert [line(r) for r in results(0.5)] == [(103.9, 47, 0.19, 1.91, 13), (103.9, 47, 0.01, 0.03, 14)]
    assert [line(r) for r in results(8)] == [(14.1, 349, 3.43, 6.18, 38), (8.4, 582, 0.03, 0.33, 61)]
    assert [line(r) for r in results(16)] == [(14.1, 349, 5.02, 9.1, 38), (6.3, 777, 0.53, 1.27, 80)]
    assert [line(r)[:4] for r in results(8, "same")] == [(7.2, 400, 0.31, 0.54), (6.9, 420, 0.02, 0.04)]


def test_workload_is_the_seeded_draw_the_lesson_describes():
    """Guards a changed draw: the lesson says 48 requests, prompts of 30 to 600 tokens averaging about 190, and three reply mixes of 60, 10 to 268 and 5 to 513 tokens."""
    w = sim.WORKLOAD
    assert sim.draw_workload() == w and len(w["gaps"]) == len(w["prompts"]) == 48 and all(len(r) == 48 for r in w["replies"].values())
    assert min(w["prompts"]) >= 30 and max(w["prompts"]) <= 600 and round(sum(w["prompts"]) / 48) == 191
    assert [(min(r), max(r)) for r in w["replies"].values()] == [(60, 60), (10, 268), (5, 513)]
    assert [round(sum(r) / 48, 1) for r in w["replies"].values()] == [60.0, 102.4, 157.4]
    assert sim.mix_names() == {"same": "Same length (60 tokens)", "mixed": "Mixed (10 to 268 tokens)", "very mixed": "Very mixed (5 to 513 tokens)"}


def test_traffic_only_scales_the_gaps_between_arrivals():
    """Guards the widget's promise that the slider changes only the traffic: the same prompts and replies arrive at every rate, 8 times as fast at 8 times the rate."""
    slow, fast = sim.make_requests(1, "mixed"), sim.make_requests(8, "mixed")
    assert slow[0].arrival == fast[0].arrival == 0.0
    assert [(r.prompt, r.reply) for r in slow] == [(r.prompt, r.reply) for r in fast]
    assert all(s.arrival / 8 == pytest.approx(f.arrival, rel=1e-12) for s, f in zip(slow, fast))
    assert all(a.arrival <= b.arrival for a, b in zip(slow, slow[1:]))


def test_the_opening_state_shows_the_point_at_once():
    """Guards the first screen: at the opening setting, 40% in, the fixed lane has most slots empty while 22 wait, and the continuous lane has most slots full and nobody waiting."""
    o = sim.OPENING
    requests = sim.make_requests(o["rate"], o["mix"])
    runs = [sim.simulate(requests, o["slots"], p) for p in ("fixed", "continuous")]
    t = sim.time_at(round(o["share"] * sim.TIME_TICKS), max(r.steps[-1].end for r in runs))
    fixed, continuous = (sim.snapshot(r, requests, t) for r in runs)
    assert (fixed["in_use"], fixed["waiting"], fixed["done"]) == (2, 22, 15) and (continuous["in_use"], continuous["waiting"], continuous["done"]) == (7, 0, 32)
    assert sim.state_line(fixed, 8, 48) == "In use 2 of 8. 22 waiting. 15 of 48 done."
    assert sim.fmt_seconds(t) == "5.63"
test_widget.py362 linesDownload test_widget.py
Show code
"""Guards for ../widget.html: its data block, its size, that its scheduler gives the same steps and words as batching_sim.py, and what a reader sees."""
import json
import math
import re
import subprocess
from functools import lru_cache

import batching_sim as sim
from embed_widget import CONFIG_BLOCK, WIDGET, config, embed

HTML = WIDGET.read_text(encoding="utf-8")
ROOT = HTML[HTML.index('<div class="bw-root">'):HTML.index("<script")]
POLICIES = ("fixed", "continuous")
SETTINGS = [(rate, mix, slots) for mix in sim.MIXES for slots in sim.SLOT_CHOICES for rate in sim.RATES]
TICKS = [0, 1, 3, 250, 400, 500, 777, 999, 1000]

# Reads the scheduler out of the widget between its marker comments, runs it with Node, and compares every step, time, count and line of words
# with the ones Python wrote into the fixture, bit for bit (the seconds are compared as exact numbers).
PARITY = r"""
import fs from 'node:fs';
const [fixture, widget] = process.argv.slice(2);
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const html = fs.readFileSync(widget, 'utf8');
const cfg = JSON.parse(html.match(/<script type="application\/json" id="bw-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the model ('), html.indexOf('/* ---------- display'));
const api = new Function('cfg', "'use strict';" + js + '; return {makeRequests, simulate, report, snapshot, commas, fmtSeconds, timeAt, nowText, stateLine, summaryLine, stepSeconds};')(cfg);
const bad = [];
let checks = 0;
const same = (what, got, want) => { checks++; if (JSON.stringify(got) !== JSON.stringify(want)) bad.push([what, JSON.stringify(got).slice(0, 200), JSON.stringify(want).slice(0, 200)]); };
fx.cases.forEach((c) => {
  const what = c.rate + '/' + c.mix + '/' + c.slots + '/' + c.policy;
  const reqs = api.makeRequests(c.rate, c.mix);
  const run = api.simulate(reqs, c.slots, c.policy);
  same(what + ' arrivals', reqs.map((r) => r.arrival), c.arrivals);
  same(what + ' steps', run.steps.map((s) => [s.start, s.end, s.slots, s.joined, s.finished]), c.steps);
  same(what + ' first tokens', run.first, c.first);
  same(what + ' finish times', run.finished, c.finished);
  same(what + ' join steps', run.joinStep, c.joinStep);
  const rep = api.report(run, reqs, c.slots);
  same(what + ' report', [rep.seconds, rep.tokensPerSecond, rep.averageWait, rep.slowestWait, rep.slotsInUse], c.report);
  same(what + ' summary', api.summaryLine(rep), c.summary);
  c.snaps.forEach((s) => {
    const snap = api.snapshot(run, reqs, s.t);
    same(what + ' snapshot at ' + s.t, [snap.slots, snap.inUse, snap.waiting, snap.done], s.snap);
    same(what + ' state line at ' + s.t, api.stateLine(snap, c.slots, reqs.length), s.line);
  });
});
fx.words.forEach((w) => same('words ' + JSON.stringify(w.args), [api.commas(w.args[0]), api.fmtSeconds(w.args[1]), api.nowText(w.args[1], w.args[2]), api.timeAt(w.args[3], w.args[2])], w.want));
same('cost of one step', [api.stepSeconds(0, 0, 100), api.stepSeconds(1, 1000, 0), api.stepSeconds(64, 64000, 0)], fx.steps);
console.log(JSON.stringify({ checks, bad: bad.slice(0, 4) }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, moves the sliders, presses Play, changes the settings and reads back what a reader sees.
PAGE = r"""
import fs from 'node:fs';
const [widget, fixture] = process.argv.slice(2);
const html = fs.readFileSync(widget, 'utf8');
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const config = html.match(/<script type="application\/json" id="bw-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

function el(tag) {
  const e = { tag, children: [], listeners: {}, value: '', checked: false, hidden: false, style: {}, attrs: {}, textContent: '' };
  e.setAttribute = (k, v) => { e.attrs[k] = v; };
  e.getAttribute = (k) => e.attrs[k];
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  e.appendChild = (c) => (e.children.push(c), c);
  e.replaceChildren = (...c) => { e.children = c; };
  return e;
}
function open(reduced) {
  const page = { root: el('html'), messages: [], frames: [], parent: { other: true } };
  fx.ids.forEach((id) => { page[id] = el('x'); });
  page['bw-config'].textContent = config;
  const doc = { getElementById: (id) => page[id], createElement: el, documentElement: page.root };
  const win = { addEventListener: (type, f) => page.messages.push(f), matchMedia: () => ({ matches: !!reduced }), requestAnimationFrame: (f) => page.frames.push(f) };
  new Function('document', 'window', 'parent', code)(doc, win, page.parent);
  return page;
}
const fire = (e, type) => (e.listeners[type || 'click'] || []).forEach((f) => f({ target: e }));
const bad = [];
let checks = 0;
const want = (what, got, expected) => { checks++; if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, JSON.stringify(got).slice(0, 160), JSON.stringify(expected).slice(0, 160)]); };
function look(page, v, what) {                       // compares the page with one expected view
  want(what + ': traffic words', page['bw-rate'].textContent, v.rate);
  want(what + ': time words', page['bw-now'].textContent, v.now);
  ['fixed', 'continuous'].forEach((name, k) => {
    const lane = v.lanes[k], cells = page['bw-' + name + '-cells'].children;
    want(what + ': ' + name + ' cells', cells.map((c) => [c.attrs['data-state'], c.children[0].style.width]), lane.cells.map((c) => [c.state, c.width]));
    want(what + ': ' + name + ' queue', page['bw-' + name + '-queue'].children.map((q) => q.attrs['data-on']), lane.squares);
    want(what + ': ' + name + ' more', page['bw-' + name + '-more'].textContent, lane.more);
    want(what + ': ' + name + ' state', page['bw-' + name + '-state'].textContent, lane.state);
    want(what + ': ' + name + ' summary', page['bw-' + name + '-summary'].textContent, lane.summary);
  });
}
const key = (rate, mix, slots, tick) => [rate, mix, slots, tick].join('|');
const spoken = (page) => page['bw-live'].textContent;

const first = open(false);
look(first, fx.views[key(8, 'mixed', 8, 400)], 'opening state');
want('opening controls', [first['bw-traffic'].value, first['bw-traffic'].max, first['bw-time'].value, first['bw-time'].max, first['bw-slots'].value, first['bw-mix'].value],
  [String(fx.rates.indexOf(8)), String(fx.rates.length - 1), '400', '1000', '8', 'mixed']);
want('option lists', [first['bw-slots'].children.map((o) => o.textContent), first['bw-mix'].children.map((o) => o.textContent)], [fx.slotChoices.map(String), fx.mixLabels]);
want('nothing spoken yet', spoken(first), '');
want('play is shown', first['bw-play'].hidden, false);

// every traffic stop, by dragging (input) and by releasing (change); the time position stays where it was
const traffic = open(false);
fx.rates.forEach((rate, i) => {
  traffic['bw-traffic'].value = String(i);
  const heard = spoken(traffic);
  fire(traffic['bw-traffic'], 'input');
  look(traffic, fx.views[key(rate, 'mixed', 8, 400)], 'traffic ' + rate);
  want('dragging stays silent at ' + rate, spoken(traffic), heard);
  want('slider reads out at ' + rate, traffic['bw-traffic'].attrs['aria-valuetext'], rate + ' new requests a second');
  fire(traffic['bw-traffic'], 'change');
  want('release speaks at ' + rate, spoken(traffic), fx.said.traffic[String(rate)]);
});

// the time slider at both ends and in between, and the words it speaks on release
const clock = open(false);
fx.ticks.forEach((tick) => {
  clock['bw-time'].value = String(tick);
  fire(clock['bw-time'], 'input');
  look(clock, fx.views[key(8, 'mixed', 8, tick)], 'tick ' + tick);
  fire(clock['bw-time'], 'change');
  want('time speaks at ' + tick, spoken(clock), fx.said.time[String(tick)]);
});

// the two settings under More settings
const more = open(false);
fx.slotChoices.forEach((n) => {
  more['bw-slots'].value = String(n);
  fire(more['bw-slots'], 'change');
  look(more, fx.views[key(8, 'mixed', n, 400)], 'slots ' + n);
  want('slots speak at ' + n, spoken(more), fx.said.slots[String(n)]);
});
more['bw-slots'].value = '8';
fx.mixes.forEach((mix) => {
  more['bw-mix'].value = mix;
  fire(more['bw-mix'], 'change');
  look(more, fx.views[key(8, mix, 8, 400)], 'mix ' + mix);
  want('mix speaks for ' + mix, spoken(more), fx.said.mix[mix]);
});

// Play: the first press plays the run from time 0; it moves the clock by the real time that passed, stops itself at the end, restarts from the start,
// goes on from where the clock is after Pause or a moved slider, and Pause and the slider stop it
const player = open(false);
fire(player['bw-play']);
want('play starts', [player['bw-play'].textContent, spoken(player), player.frames.length], ['Pause', 'Playing.', 1]);
let frames = player.frames.splice(0); frames[0](0);
look(player, fx.views[key(8, 'mixed', 8, 0)], 'first Play starts at time 0, not at the opening 40%');
frames = player.frames.splice(0); frames[0](1000);          // one second of real time is a tenth of the longer run
look(player, fx.views[key(8, 'mixed', 8, 100)], 'one second played');
want('time slider follows the clock', player['bw-time'].value, '100');
fire(player['bw-play']);
want('pause', [player['bw-play'].textContent, spoken(player)], ['Play', fx.said.time['100']]);
player.frames.splice(0);
fire(player['bw-play']);
frames = player.frames.splice(0); frames[0](5000);
look(player, fx.views[key(8, 'mixed', 8, 100)], 'Play after Pause goes on from the pause');
frames = player.frames.splice(0); frames[0](15000);
look(player, fx.views[key(8, 'mixed', 8, 1000)], 'end of the run');
want('stops itself at the end', [player['bw-play'].textContent, player.frames.length, spoken(player)], ['Play', 0, fx.said.finished]);
fire(player['bw-play']);
frames = player.frames.splice(0); frames[0](20000);
look(player, fx.views[key(8, 'mixed', 8, 0)], 'Play at the end starts over');
want('playing again', player['bw-play'].textContent, 'Pause');
player['bw-time'].value = '250'; fire(player['bw-time'], 'input');
want('moving the slider stops Play', [player['bw-play'].textContent, player['bw-time'].value], ['Play', '250']);
const dragged = open(false);                                  // a reader who put the clock back on the opening spot is still going on from there
dragged['bw-time'].value = '400'; fire(dragged['bw-time'], 'input');
fire(dragged['bw-play']);
frames = dragged.frames.splice(0); frames[0](0); frames = dragged.frames.splice(0); frames[0](1000);
look(dragged, fx.views[key(8, 'mixed', 8, 500)], 'Play after a moved slider goes on from the slider');

// reduced motion: no Play, nothing moves, and the page opens on the finished run
const calm = open(true);
want('reduced motion hides Play', calm['bw-play'].hidden, true);
look(calm, fx.views[key(8, 'mixed', 8, 1000)], 'reduced motion opens on the finished state');
want('reduced motion time position', calm['bw-time'].value, '1000');

// the host page theme: only the parent window may set it, and only to light or dark
const themed = open(false);
const send = (data, source) => themed.messages.forEach((f) => f({ data, source }));
send({ type: 'spicyshark:theme', theme: 'dark' }, { other: false });
send({ type: 'spicyshark:theme', theme: 'purple' }, themed.parent);
send({ type: 'other', theme: 'dark' }, themed.parent);
send(null, themed.parent);
want('ignored messages', themed.root.attrs, {});
send({ type: 'spicyshark:theme', theme: 'dark' }, themed.parent);
want('theme from the parent', themed.root.attrs, { 'data-theme': 'dark' });
send({ type: 'spicyshark:theme', theme: 'light' }, themed.parent);
want('theme back to light', themed.root.attrs, { 'data-theme': 'light' });

console.log(JSON.stringify({ checks, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""


@lru_cache(maxsize=None)
def runs(rate, mix, slots):
    requests = sim.make_requests(rate, mix)
    simulated = [sim.simulate(requests, slots, policy) for policy in POLICIES]
    return requests, simulated, [sim.report(run, requests, slots) for run in simulated]


def view(rate, mix, slots, tick):
    """What the page should show after it has settled on these settings and this time position."""
    requests, simulated, reports = runs(rate, mix, slots)
    end = max(rep["seconds"] for rep in reports)
    t = sim.time_at(tick, end)
    lanes = []
    for run, rep in zip(simulated, reports):
        snap = sim.snapshot(run, requests, t)
        waiting = snap["waiting"]
        lanes.append({
            "cells": [{"state": "use" if x >= 0 else "wait" if waiting else "empty", "width": f"{math.floor(x * 100)}%" if x >= 0 else "0%"} for x in snap["slots"]],
            "squares": ["1" if i < waiting else "0" for i in range(sim.QUEUE_CAP)],
            "more": f"+{waiting - sim.QUEUE_CAP}" if waiting > sim.QUEUE_CAP else "",
            "state": sim.state_line(snap, slots, len(requests)), "summary": sim.summary_line(rep),
        })
    return {"rate": sim.rate_text(rate), "now": sim.now_text(t, end), "lanes": lanes}


def said_after_a_run_setting(rate, mix, slots):
    _, _, reports = runs(rate, mix, slots)
    return f"{sim.rate_text(rate)} new requests a second. Fixed groups. {sim.summary_line(reports[0])} Continuous batching. {sim.summary_line(reports[1])}"


def said_at(tick, rate=8, mix="mixed", slots=8):
    v = view(rate, mix, slots, tick)
    return f"At {v['now']}. Fixed groups: {v['lanes'][0]['state']} Continuous batching: {v['lanes'][1]['state']}"


def run_node(tmp_path, script, fixture, args):
    (tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
    (tmp_path / "check.mjs").write_text(script, encoding="utf-8")
    result = subprocess.run(["node", str(tmp_path / "check.mjs"), *args(tmp_path)], capture_output=True, text=True, timeout=300)
    assert result.returncode == 0, result.stdout + result.stderr
    return json.loads(result.stdout)


def test_widget_data_block_matches_the_python_constants():
    """Guards a constant edit that was not copied into the widget (run embed_widget.py)."""
    assert embed(HTML) == HTML


def test_embed_rewrites_a_widget_whose_data_block_drifted():
    """Guards embed() skipping the block: a hand-edited parameter count or a changed reply length must be put back."""
    drifted = HTML.replace('"params": 8030261248', '"params": 7000000000', 1).replace('"mixed": [', '"mixed": [999, ', 1)
    assert drifted != HTML and embed(drifted) == HTML
    assert json.loads(CONFIG_BLOCK.search(HTML).group(2)) == json.loads(json.dumps(config()))


def test_every_element_the_script_looks_up_exists_inside_the_root():
    """Guards a widget that never starts: the parity run covers only the scheduler, so a misspelt id passed there and threw in the browser."""
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
    for suffix in ("cells", "queue", "more", "state", "summary"):                    # looked up per lane as $('bw-' + name + '-suffix')
        assert f"$('bw-' + name + '-{suffix}')" in script or f"$('bw-' + lane.name + '-{suffix}')" in script, suffix
        lookups |= {f"bw-{lane}-{suffix}" for lane in POLICIES}
    missing = sorted(i for i in lookups if i != "bw-config" and f'id="{i}"' not in ROOT)
    assert not missing, missing
    assert 'id="bw-config"' in HTML


def test_widget_is_small_and_asks_for_nothing_online():
    assert len(HTML.splitlines()) <= 300 and len(HTML.encode("utf-8")) < 40 * 1024
    page = CONFIG_BLOCK.sub("", HTML)
    for banned in ("http://", "https://", "fetch(", "XMLHttpRequest", "WebSocket", "sendBeacon", "import("):
        assert banned not in page, banned
    assert " src=" not in page and "@import" not in page


def test_widget_has_no_form_element():
    """Guards a dead button: the lesson page loads widget.html in a sandboxed frame, where a form cannot submit."""
    assert "<form" not in HTML and 'type="submit"' not in HTML


def test_widget_shows_at_most_five_controls_and_hides_the_rest():
    """Guards the first screen at 390 px: the two sliders and Play stay in view, and the two selects wait in a closed details element."""
    outside = re.sub(r"<details>.*?</details>", "", ROOT, flags=re.S)
    assert len(re.findall(r"<(?:select|button|input|textarea)\b", outside)) == 3
    assert "<details><summary>More settings</summary>" in ROOT and "<details open" not in ROOT
    assert re.search(r"<details><summary>More settings</summary>.*?<select id=\"bw-slots\">.*?<select id=\"bw-mix\">", ROOT, re.S)


def test_widget_keeps_the_site_theme_listener_and_palette():
    assert "d.type !== 'spicyshark:theme'" in HTML and "e.source !== parent" in HTML and "parent === window" in HTML
    for pair in ("--bg: light-dark(#ffffff, #0a0f17)", "--panel: light-dark(#ebf0f6, #131b28)", "--ink: light-dark(#0a1626, #f4f7fb)",
                 "--mute: light-dark(#44566b, #a3b1c4)", "--ctl: light-dark(#66768a, #7a8a9f)",
                 "--green: light-dark(#00855F, #009E73)", "--orange: light-dark(#b36200, #E69F00)", ":root[data-theme=light]", ":root[data-theme=dark]"):
        assert pair in HTML, pair
    for banned in ("#0072B2", "#56B4E9", "#D55E00"):
        assert banned.lower() not in HTML.lower()
    assert not set(re.findall(r"#[0-9a-fA-F]{3,8}\b", HTML)) - {"#ffffff", "#0a0f17", "#ebf0f6", "#131b28", "#0a1626", "#f4f7fb", "#44566b", "#a3b1c4",
                                                               "#66768a", "#7a8a9f", "#00855F", "#009E73", "#b36200", "#E69F00"}


def test_widget_scheduler_gives_the_steps_and_words_of_the_python_model(tmp_path):
    """Guards the widget's JavaScript drifting from batching_sim.py: the lesson prints Python's numbers and the reader sees the widget's, so every step of every run
    (all 72 settings, both policies) must agree to the last bit, and so must the totals, the snapshots at nine moments and the lines of words."""
    cases = []
    for rate, mix, slots in SETTINGS:
        requests, simulated, reports = runs(rate, mix, slots)
        end = max(rep["seconds"] for rep in reports)
        for policy, run, rep in zip(POLICIES, simulated, reports):
            snaps = []
            for tick in TICKS:
                t = sim.time_at(tick, end)
                snap = sim.snapshot(run, requests, t)
                snaps.append({"t": t, "snap": [snap["slots"], snap["in_use"], snap["waiting"], snap["done"]], "line": sim.state_line(snap, slots, len(requests))})
            cases.append({"rate": rate, "mix": mix, "slots": slots, "policy": policy, "arrivals": [r.arrival for r in requests],
                          "steps": [[s.start, s.end, s.slots, s.joined, s.finished] for s in run.steps], "first": run.first_token, "finished": run.finished,
                          "joinStep": run.join_step, "snaps": snaps, "summary": sim.summary_line(rep),
                          "report": [rep["seconds"], rep["tokens_per_second"], rep["average_wait"], rep["slowest_wait"], rep["slots_in_use"]]})
    words = [{"args": [n, s, e, k], "want": [sim.commas(n), sim.fmt_seconds(s), sim.now_text(s, e), sim.time_at(k, e)]}
             for n, s, e, k in ((7, 0.0, 14.1, 0), (1234, 9.995, 103.9, 400), (999999, 10.0, 8.45, 1000), (0, 0.034, 0.5, 1), (12, 3.4349, 3.4349, 777))]
    steps = [sim.batch_step([], 100).seconds, sim.batch_step([1000]).seconds, sim.batch_step([1000] * 64).seconds]
    result = run_node(tmp_path, PARITY, {"cases": cases, "words": words, "steps": steps}, lambda d: [str(d / "fixture.json"), str(WIDGET)])
    assert result["bad"] == [] and result["checks"] >= len(cases) * (6 + len(TICKS) * 2 + 1)


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the opening state (8 requests a second, 40% in), every traffic stop, the time slider, the two settings, Play and Pause, the
    finished state under reduced motion, what is spoken on release, the theme message, and the words that keep a best case from reading as a measurement."""
    outside = re.sub(r"<details>.*?</details>", "", ROOT, flags=re.S)
    assert "A model at the chip&rsquo;s peak speed, not a measurement. The 48 requests are invented." in outside and sim.N_REQUESTS == 48
    assert "A new group starts only when every slot is empty." in outside and "A waiting request takes any empty slot at once." in outside
    leaves_out = re.search(r"<details><summary>What this toy leaves out</summary>(.*?)</details>", ROOT, re.S).group(1)
    for said in ("Network time", "running out of memory for the caches", "Real servers are slower than these times", "Llama 3.1 8B with 16-bit weights on one A100"):
        assert said in re.sub(r"\s+", " ", leaves_out), said
    assert re.findall(r'<li><i data-state="(\w+)"></i><span>(.*?)</span></li>', ROOT) == [("use", "Reply under way"), ("empty", "Empty slot"), ("wait", "Empty while people wait")]
    assert 'class="bw-sr" id="bw-live" role="status" aria-live="polite" aria-atomic="true"' in ROOT      # what a screen reader hears
    assert ".bw-root button { font: inherit; font-weight: 600; font-size: 0.9rem; line-height: 1.2; min-height: 44px; min-width: 72px; padding: 2px 10px; white-space: nowrap;" in HTML
    assert ".bw-root [hidden] { display: none; }" in HTML                                              # Play really disappears under reduced motion
    views = {}
    mixes = list(sim.MIXES)
    for rate in sim.RATES:
        views[f"{rate}|mixed|8|400"] = view(rate, "mixed", 8, 400)
    for tick in TICKS + [100]:
        views[f"8|mixed|8|{tick}"] = view(8, "mixed", 8, tick)
    for slots in sim.SLOT_CHOICES:
        views[f"8|mixed|{slots}|400"] = view(8, "mixed", slots, 400)
    for mix in mixes:
        views[f"8|{mix}|8|400"] = view(8, mix, 8, 400)
    opening = views["8|mixed|8|400"]
    assert opening["lanes"][0]["state"] == "In use 2 of 8. 22 waiting. 15 of 48 done." and opening["lanes"][1]["state"] == "In use 7 of 8. 0 waiting. 32 of 48 done."
    assert opening["lanes"][0]["more"] == "+6" and opening["lanes"][1]["more"] == "" and opening["now"] == "5.63 s of 14.1 s"
    assert {c["state"] for c in opening["lanes"][0]["cells"]} == {"use", "wait"} and {c["state"] for c in opening["lanes"][1]["cells"]} == {"use", "empty"}
    said = {"traffic": {str(rate): said_after_a_run_setting(rate, "mixed", 8) for rate in sim.RATES},
            "time": {str(tick): said_at(tick) for tick in TICKS + [100]},
            "slots": {str(n): said_after_a_run_setting(8, "mixed", n) for n in sim.SLOT_CHOICES},
            "mix": {mix: said_after_a_run_setting(8, mix, 8) for mix in mixes},
            "finished": "Finished. " + said_at(1000)[said_at(1000).index("Fixed groups:"):]}
    ids = sorted(set(re.findall(r'id="(bw-[\w-]+)"', HTML)))
    fixture = {"ids": ids, "views": views, "said": said, "rates": sim.RATES, "slotChoices": sim.SLOT_CHOICES, "mixes": mixes, "ticks": TICKS,
               "mixLabels": [sim.mix_names()[m] for m in mixes]}
    result = run_node(tmp_path, PAGE, fixture, lambda d: [str(WIDGET), str(d / "fixture.json")])
    assert result["bad"] == [] and result["checks"] > 200