File size: 12,736 Bytes
eb16d01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92d63d4
 
 
eb16d01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92d63d4
eb16d01
92d63d4
eb16d01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92d63d4
eb16d01
 
 
 
 
 
 
 
 
 
 
 
 
 
92d63d4
eb16d01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92d63d4
eb16d01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
"""
Inference benchmark: measure latency, throughput, memory and bandwidth
utilization of a trained checkpoint, sweeping over the decode batch size.

This is an "eval" in its own right: intelligence metrics (CORE etc.) say
nothing about what a model costs to run. Architecture choices (GQA, sliding
windows, ...) show up here, so improvements can be judged on both axes.

Background: inference has two very different regimes.
- Prefill processes the whole prompt in parallel: big matmuls, compute-bound.
- Decode generates one token at a time: every step re-reads all weights and
  the KV cache to do a tiny amount of math, so it is memory-bandwidth-bound
  and batching is nearly free until compute saturates.
Sweeping the batch size traces out the latency <-> throughput tradeoff curve
between these regimes. MBU (model bandwidth utilization) is the decode
counterpart of training MFU: achieved bytes/sec over the peak bandwidth of
the GPU. It measures how far the implementation is from the physical ceiling.

Output: a human-readable card and table, and then the very last line of stdout
is a single compact JSON document with all of the same data, so that scripts
can consume the benchmark without parsing the pretty formatting:

    result = json.loads(subprocess.run([...], capture_output=True, text=True).stdout.splitlines()[-1])

Examples:

    # benchmark a base model checkpoint on one GPU
    python -m scripts.infer_bench -i base -g d12

    # benchmark the SFT model, custom sweep
    python -m scripts.infer_bench -i sft --batch-sizes 1,4,16,64 --decode-tokens 512

    # machine-readable: grab the last line
    python -m scripts.infer_bench -i base -g d12 | tail -1 | jq .sweep
"""

import argparse
import json
import time
import torch

from nanochat.common import compute_init, compute_cleanup, autodetect_device_type, get_peak_bandwidth, get_peak_flops
from nanochat.checkpoint_manager import load_model
from nanochat.engine import Engine

# -----------------------------------------------------------------------------
# Measurement
# (the architecture-side cost accounting - FLOPs, KV cache bytes - lives on the
# GPT model itself: estimate_decode_flops, estimate_prefill_flops, kv_bytes_per_token, kv_read_bytes)

def weight_bytes(model):
    """Bytes of parameters as stored (each decode step reads all of them)."""
    return sum(p.numel() * p.element_size() for p in model.parameters())

def bench_generate(engine, prompt_tokens, batch_size, decode_tokens, temperature):
    """Run one timed generation. Returns dict of measurements."""
    device = engine.model.get_device()
    torch.cuda.reset_peak_memory_stats(device)
    torch.cuda.synchronize(device)

    generator = engine.generate(prompt_tokens, num_samples=batch_size,
                                max_tokens=decode_tokens, temperature=temperature)
    # The first next() runs the batch=1 prefill, the KV cache replication to
    # batch_size rows, and samples the first token: that is the TTFT.
    t_start = time.perf_counter()
    next(generator)
    torch.cuda.synchronize(device)
    ttft = time.perf_counter() - t_start

    # Every subsequent next() is one decode step for all rows.
    step_times = []
    while True:
        t0 = time.perf_counter()
        try:
            next(generator)
        except StopIteration:
            break
        torch.cuda.synchronize(device)
        step_times.append(time.perf_counter() - t0)

    peak_vram = torch.cuda.max_memory_allocated(device)
    return dict(ttft=ttft, step_times=step_times, peak_vram=peak_vram)

def build_prompt(tokenizer, num_tokens):
    """A natural-language prompt of exactly num_tokens tokens (random ids would
    do for speed, but a real prompt keeps argmax decoding from degenerating)."""
    paragraph = ("The history of science is the study of the development of science, "
                 "including both the natural and social sciences. Science is a body of "
                 "empirical, theoretical, and practical knowledge about the natural world. ")
    text = paragraph * (num_tokens // 10) # more than enough tokens
    tokens = tokenizer.encode(text, prepend="<|bos|>")
    assert len(tokens) >= num_tokens, "prompt text too short, increase the repetition"
    return tokens[:num_tokens]

# -----------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(description="Inference benchmark")
    parser.add_argument("-i", "--source", type=str, default="base", help="Checkpoint source: base|mid|sft")
    parser.add_argument("-g", "--model-tag", type=str, default=None, help="Model tag to load")
    parser.add_argument("-s", "--step", type=int, default=None, help="Step to load (default = last)")
    parser.add_argument("--prompt-tokens", type=int, default=2048, help="Prompt length for prefill")
    parser.add_argument("--decode-tokens", type=int, default=256, help="Tokens to generate per row")
    parser.add_argument("--batch-sizes", type=str, default="1,8,32,128", help="Comma-separated decode batch sizes")
    parser.add_argument("-t", "--temperature", type=float, default=0.0)
    args = parser.parse_args()

    device_type = autodetect_device_type()
    assert device_type == "cuda", "infer_bench currently assumes a CUDA GPU (for timing and VRAM measurement)"
    ddp, ddp_rank, ddp_local_rank, ddp_world_size, device = compute_init(device_type)
    assert ddp_world_size == 1, "infer_bench is a single GPU benchmark, run without torchrun"

    model, tokenizer, meta = load_model(args.source, device, phase="eval", model_tag=args.model_tag, step=args.step)
    config = model.config
    engine = Engine(model, tokenizer)

    # Clamp the prompt so prompt + decode fits in the training context
    max_prompt = config.sequence_len - args.decode_tokens
    prompt_len = min(args.prompt_tokens, max_prompt)
    if prompt_len < args.prompt_tokens:
        print(f"note: clamping prompt to {prompt_len} tokens so prompt+decode fits sequence_len={config.sequence_len}")
    prompt_tokens = build_prompt(tokenizer, prompt_len)

    # ------------------------------------------------------------------------
    # Static card: inference cost implied by the architecture, before measuring
    device_name = torch.cuda.get_device_name(device)
    peak_bw = get_peak_bandwidth(device_name)
    peak_flops = get_peak_flops(device_name)
    total_vram = torch.cuda.get_device_properties(device).total_memory
    w_bytes = weight_bytes(model)
    num_params = sum(p.numel() for p in model.parameters())
    kv_store = model.kv_bytes_per_token()
    context_mid = prompt_len + args.decode_tokens // 2 # representative decode context
    kv_read = model.kv_read_bytes(context_mid)
    # tokens/sec ceiling at batch 1: every step must at least re-read weights + KV
    ceiling_bs1 = peak_bw / (w_bytes + kv_read)
    # how many rows of full-context KV fit next to the weights
    max_rows = int((total_vram - w_bytes) / (kv_store * config.sequence_len))

    print("=" * 100)
    print(f"Model: {args.source} {meta.get('model_tag', '')} (step {meta['step']}) | "
          f"depth {config.n_layer}, dim {config.n_embd}, heads {config.n_head}, kv heads {config.n_kv_head} (GQA)")
    print(f"GPU: {device_name} | peak bandwidth {peak_bw/1e12:.2f} TB/s | peak compute {peak_flops/1e12:.0f} TFLOPS | VRAM {total_vram/2**30:.0f} GiB")
    print("-" * 100)
    dtype_counts = {}
    for p in model.parameters():
        dtype_name = str(p.dtype).replace("torch.", "")
        dtype_counts[dtype_name] = dtype_counts.get(dtype_name, 0) + p.numel()
    param_dtypes = ", ".join(f"{n:,} {dtype_name}" for dtype_name, n in sorted(dtype_counts.items()))
    print(f"Parameters: {num_params:,} ({param_dtypes}) | weight bytes as stored: {w_bytes/2**20:.0f} MiB")
    print(f"KV cache: {kv_store:,} bytes/token stored | {kv_read:,} bytes read/step at context {context_mid} "
          f"(window pattern {config.window_pattern})")
    print(f"Theoretical decode ceiling at batch 1: {ceiling_bs1:,.0f} tok/s | "
          f"max ~{max_rows:,} full-context rows in VRAM")
    print("=" * 100)

    # Everything printed above also goes into the final JSON line for scripts
    payload = {
        "source": args.source,
        "step": meta["step"],
        "model_config": meta["model_config"],
        "gpu": device_name,
        # None (not Infinity) for unknown GPUs, so the last line stays valid JSON
        "peak_bandwidth_bytes_per_sec": peak_bw if peak_bw != float("inf") else None,
        "total_vram_bytes": total_vram,
        "num_params": num_params,
        "param_dtypes": dtype_counts,
        "weight_bytes": w_bytes,
        "kv_bytes_per_token": kv_store,
        "kv_read_bytes_per_step": kv_read,
        "context_mid": context_mid,
        "peak_flops_per_sec": peak_flops if peak_flops != float("inf") else None,
        "decode_flops_per_token": model.estimate_decode_flops(context_mid),
        "ceiling_bs1_tok_per_sec": round(ceiling_bs1, 1) if ceiling_bs1 != float("inf") else None,
        "max_full_context_rows": max_rows,
        "prompt_tokens": prompt_len,
        "decode_tokens": args.decode_tokens,
        "temperature": args.temperature,
        "sweep": [],
    }

    # ------------------------------------------------------------------------
    # Prefill measurement: batch 1, a single decode step, so TTFT ~= prefill time.
    # Prefill is compute-bound, so MFU (not MBU) is its distance from the roofline.
    bench_generate(engine, prompt_tokens, 1, 2, args.temperature) # warmup
    prefill_result = bench_generate(engine, prompt_tokens, 1, 2, args.temperature)
    prefill_time = prefill_result["ttft"]
    prefill_mfu = 100 * model.estimate_prefill_flops(prompt_len) / prefill_time / peak_flops
    prefill_tok_per_sec = prompt_len / prefill_time
    print(f"Prefill (batch 1, {prompt_len} tokens): {prefill_tok_per_sec:,.0f} tok/s | MFU {prefill_mfu:.1f}%")
    payload["prefill"] = {
        "tok_per_sec": round(prefill_tok_per_sec, 1),
        "mfu_percent": round(prefill_mfu, 2),
        "time_sec": round(prefill_time, 6),
    }

    # ------------------------------------------------------------------------
    # Measured sweep over batch sizes. Decode reads all weights + KV every step:
    # MBU is the distance from the bandwidth roofline (binds at small batch),
    # MFU the distance from the compute roofline (binds at large batch).
    batch_sizes = [int(b) for b in args.batch_sizes.split(",")]
    header = f"{'batch':>6} {'TTFT ms':>9} {'TPOT ms':>9} {'tok/s':>10} {'MBU %':>7} {'MFU %':>7} {'VRAM GiB':>9} {'steps':>6}"
    print(header)
    print("-" * len(header))
    for batch_size in batch_sizes:
        # warmup (cublas autotune, allocator warm, attention kernels)
        bench_generate(engine, prompt_tokens, batch_size, 8, args.temperature)
        # timed run
        result = bench_generate(engine, prompt_tokens, batch_size, args.decode_tokens, args.temperature)
        step_times = result["step_times"]
        num_steps = len(step_times)
        if num_steps == 0:
            print(f"{batch_size:>6}  all rows terminated during warmup?! skipping")
            continue
        tpot = sorted(step_times)[num_steps // 2] # median decode step time
        tok_per_sec = batch_size * num_steps / sum(step_times)
        # MBU: bytes each decode step must move, over what the GPU can move
        bytes_per_step = w_bytes + batch_size * kv_read
        mbu = 100 * (bytes_per_step / tpot) / peak_bw
        # MFU: FLOPs each decode step must do, over what the GPU can do
        flops_per_step = batch_size * model.estimate_decode_flops(context_mid)
        mfu = 100 * (flops_per_step / tpot) / peak_flops
        vram_gib = result["peak_vram"] / 2**30
        note = "" if num_steps == args.decode_tokens - 1 else f" (early stop @ {num_steps})"
        print(f"{batch_size:>6} {result['ttft']*1e3:>9.1f} {tpot*1e3:>9.2f} {tok_per_sec:>10,.0f} "
              f"{mbu:>7.1f} {mfu:>7.2f} {vram_gib:>9.2f} {num_steps:>6}{note}")
        payload["sweep"].append({
            "batch_size": batch_size,
            "ttft_sec": round(result["ttft"], 6), # microsecond resolution, plenty for wall clock
            "tpot_sec": round(tpot, 6),
            "tok_per_sec": round(tok_per_sec, 1),
            "mbu_percent": round(mbu, 2),
            "mfu_percent": round(mfu, 4), # decode MFU is tiny at small batch, keep the signal

            "peak_vram_bytes": result["peak_vram"],
            "decode_steps": num_steps,
        })

    # The last line of stdout is the machine-readable version of the whole run
    print("-" * len(header))
    print(json.dumps(payload))

    compute_cleanup()

if __name__ == "__main__":
    main()