Skip to content

Commit 97de355

Browse files
committed
Benchmark packed verification prefixes with complete proposal work
Adaptive verification costs depend on physical target rows while proposal work remains fixed. Rectangular-round benchmarks cannot compare those packed prefixes from one preparation and common prefilled state. Add --verify-prefixes for batch-one benchmarks. Prefixes above one retain the configured logical width and full proposal count; prefix one uses the serving graph without proposals and retains draft-context maintenance. Restore one shared prefilled snapshot for each batch, context, and seed, and report target rows, proposal count, draft-forward size, and acceptance. This enables comparable prefix measurements without repeated model loads. Native serving behavior is unchanged and no inference speedup is claimed. The CPU benchmark suites pass 142 tests covering CLI constraints, selected shapes, row packing, and proposal/target RNG reservations. One existing CUDA snapshot/restore test is skipped; GPU model measurements have not been rerun on this branch. Design-Signed-Off-By: Codex (GPT-6); design-review=cfcf
1 parent 8c2e782 commit 97de355

11 files changed

Lines changed: 249 additions & 47 deletions

File tree

‎docs/cli-design.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ the timed graph starts.
222222
| `--batches LIST` | Distinct comma-separated request batches selected from `1,2,4,8`; prefill requires `1` | `1,2,4`; `1` with `--prefill` or `--speculation-study` |
223223
| `--contexts LIST` | Distinct consumed context lengths before each measured graph, multiples of 128; prefill also allows zero | `512,2048,8192,16384`; `0` with `--prefill`; `8192` with `--speculation-study` |
224224
| `--verify-width N` | Target tokens per request, including the pending token; `8,16`, or `8,13,16` with `--speculation-study` | `8` |
225+
| `--verify-prefixes LIST` | C1 packed prefixes measured from shared context, within `--verify-width`; proposal count remains fixed above prefix one. Cannot accompany `--prefill` or `--speculation-study` | unset |
225226
| `--study-widths LIST` | Prepare selected widths from `8,13,16` together and measure each from shared prefilled state; replaces scalar width/depth flags | unset |
226227
| `--speculation-study` | Measure private single-request speculative graphs without native completion publication or export | off |
227228
| `--warmup N` | Positive count of untimed restored rounds per case and seed | `5` |
@@ -241,7 +242,25 @@ that capacity from the concurrency requested through configuration or flags.
241242
`--no-mtp` applies only with `--prefill`. Mixed prefill and prompt-logprob graphs
242243
are unsupported.
243244

244-
Kimi K3.1 Pro supports verification width eight. Its selected catalog contains
245+
For Kimi K3.1 Pro, compare packed verification prefixes without loading weights again:
246+
247+
```sh
248+
.venv/bin/ignition bench kimi-k3.1-pro --gpus 0-15 \
249+
--mtp-draft-tokens 7 \
250+
--batches 1 --max-concurrency 1 --verify-prefixes 1,4,6,8 --contexts 8192 \
251+
--speculative-token-capacity 8 --prefill-chunk-size 4096 --online-mxfp8 \
252+
--autotune-effort default --output /tmp/kimi31-verification-prefixes.json
253+
```
254+
255+
Each prefix restores the same request, cache and sampling state before replay.
256+
For prefixes four, six and eight, the learned draft generates and samples all
257+
seven proposals, then verifies the selected prefix. The graph
258+
retains logical width eight while physical target rows are four, six or eight.
259+
Prefix one uses the width-one serving graph: it omits proposal generation but
260+
updates draft context, so it is not a target-only model measurement. JSON records
261+
the executed draft-forward size and acceptance separately from replay latency.
262+
263+
Kimi K3.1 Pro supports verification widths through eight. Its selected catalog contains
245264
the requested speculative rounds and the initialization and prefill graphs
246265
needed to establish their contexts. Vision weights retain their configured
247266
residency, while image graphs are omitted from this text benchmark.

‎ignition/bench/runner.py‎

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ class BenchmarkCase:
2121
context_tokens: int
2222

2323
def __post_init__(self):
24-
if (self.batch not in (1, 2, 4, 8) or self.width not in (8, 16)
25-
or self.packed_tokens != self.batch * self.width or self.context_tokens <= 0):
26-
raise ValueError("round benchmark requires B=1,2,4,8, W=8,16, P=B*W and positive context")
24+
if (self.batch not in (1, 2, 4, 8) or type(self.width) is not int or not 1 <= self.width <= 16
25+
or not self.batch <= self.packed_tokens <= self.batch * self.width
26+
or self.packed_tokens % self.batch or self.context_tokens <= 0):
27+
raise ValueError("round benchmark requires B=1,2,4,8, W=1 through 16, equal positive prefixes within W and positive context")
2728

2829

2930
@dataclass(frozen=True)
@@ -173,7 +174,7 @@ def _round(scheduler, case):
173174
if len(matches) != 1:
174175
raise ValueError(f"prepared catalog must contain exactly one complete round for {case}")
175176
shape, graph = matches[0]
176-
lanes = [Lane(request, request.consumed, case.width) for request in scheduler.active]
177+
lanes = [Lane(request, request.consumed, case.packed_tokens // case.batch) for request in scheduler.active]
177178
return scheduler._commit(Step("speculative", graph, case.batch, case.width, lanes, 0,
178179
packed_tokens=case.packed_tokens, draft_tokens=shape[4]))
179180

@@ -206,7 +207,13 @@ def _check_request_state(model, step, records):
206207
raise RuntimeError(f"request {request.ident} device state disagrees with graph completion")
207208

208209

209-
def _check_metadata(model, step, case):
210+
def _proposal_counters(forward, step):
211+
owner = forward.owner
212+
return [(int(owner.generation[lane.request.slot].item()),
213+
int(owner.offset[lane.request.slot].item())) for lane in step.lanes]
214+
215+
216+
def _check_metadata(model, step, case, *, proposal_before=None):
210217
if isinstance(case, PrefillCase):
211218
metadata = model.benchmark_prefills[step.graph]
212219
if (metadata.live_counts.cpu().tolist() != [case.chunk_tokens, 1]
@@ -216,8 +223,10 @@ def _check_metadata(model, step, case):
216223
raise RuntimeError("prefill metadata does not describe the exact benchmark chunk")
217224
return
218225
forward = model.benchmark_rounds[step.graph]
219-
for role, metadata, width in (("target", forward.metadata, case.width),
220-
("draft", forward.draft_metadata, forward.draft_inputs.shape[1])):
226+
stages = [("target", forward.metadata, case.packed_tokens // case.batch)]
227+
if forward.draft is not None:
228+
stages.append(("draft", forward.draft_metadata, forward.draft_inputs.shape[1]))
229+
for role, metadata, width in stages:
221230
expected_starts = [case.context_tokens] * case.batch
222231
expected_lengths = [width] * case.batch
223232
expected_offsets = [row * width for row in range(case.batch + 1)]
@@ -228,11 +237,14 @@ def _check_metadata(model, step, case):
228237
raise RuntimeError(f"{role} metadata does not describe the complete benchmark round")
229238
owner = forward.owner
230239
verification = forward.operations[2]
240+
if forward.draft is None and _proposal_counters(forward, step) != proposal_before:
241+
raise RuntimeError("a round without proposals changed draft RNG state")
231242
for lane in step.lanes:
232243
slot, generation = lane.request.slot, lane.request.generation
233-
if (int(owner.generation[slot].item()) != generation
234-
or int(owner.offset[slot].item()) != 4 * step.draft_tokens
235-
or int(owner.rng.generation[slot].item()) != generation
244+
if forward.draft is not None and (int(owner.generation[slot].item()) != generation
245+
or int(owner.offset[slot].item()) != 4 * step.draft_tokens):
246+
raise RuntimeError("proposal RNG did not consume its declared draw reservation")
247+
if (int(owner.rng.generation[slot].item()) != generation
236248
or int(owner.rng.target_offset[slot].item()) != verification.draw_reservation):
237249
raise RuntimeError("speculative sampling streams did not consume their declared draw reservations")
238250

@@ -314,34 +326,51 @@ def result(status, failure=None):
314326
return measurement_result(cases, measurements, warmup=warmup, repetitions=repetitions,
315327
seeds=seeds, status=status, failure=failure)
316328

329+
groups = {}
330+
for index, case in enumerate(cases):
331+
key = (case.batch, case.context_tokens, getattr(case, "chunk_tokens", 0))
332+
groups.setdefault(key, []).append(index)
317333
generation = int(model.benchmark_requests.generation.max().item())
318334
try:
319-
for case, samples in zip(cases, measurements):
335+
for indices in groups.values():
336+
# Every width starts from the same real prefix, request budget and RNG state.
337+
setup = max((cases[index] for index in indices), key=lambda case: getattr(case, "width", 0))
320338
for seed in seeds:
321-
times = []
322-
samples.append(dict(seed=seed, times_ms=times))
323339
generation += 1
324-
baseline = _prefill(model, runner, layout, case, tokenizer, seed, generation)
340+
baseline = _prefill(model, runner, layout, setup, tokenizer, seed, generation)
325341
runner.snapshot()
326-
for iteration in range(-1, warmup + repetitions):
327-
scheduler = deepcopy(baseline, {id(layout): layout})
328-
runner.restore()
329-
step = _prefill_step(scheduler, case) if isinstance(case, PrefillCase) else _round(scheduler, case)
330-
elapsed = runner.run(_message(scheduler, step))
331-
if iteration >= warmup:
332-
times.append(elapsed / 1e6)
333-
records = _complete(model, runner, scheduler, step, elapsed)
334-
_check_request_state(model, step, records)
335-
if isinstance(case, PrefillCase) and any(
336-
lane.request.consumed != case.context_tokens + case.chunk_tokens
337-
or len(lane.request.generated) != 1 for lane in step.lanes):
338-
raise RuntimeError("prefill replay did not consume the complete chunk and sample its pending token")
339-
if iteration == -1:
340-
_check_metadata(model, step, case)
341-
if not isinstance(case, PrefillCase):
342-
samples[-1]["acceptance"] = acceptance_observation(model.benchmark_rounds[step.graph])
343-
if progress is not None:
344-
progress(result("running"))
342+
for index in indices:
343+
case, samples = cases[index], measurements[index]
344+
times = []
345+
samples.append(dict(seed=seed, times_ms=times))
346+
for iteration in range(-1, warmup + repetitions):
347+
scheduler = deepcopy(baseline, {id(layout): layout})
348+
runner.restore()
349+
step = _prefill_step(scheduler, case) if isinstance(case, PrefillCase) else _round(scheduler, case)
350+
proposal_before = None
351+
if iteration == -1 and not isinstance(case, PrefillCase):
352+
proposal_before = _proposal_counters(model.benchmark_rounds[step.graph], step)
353+
elapsed = runner.run(_message(scheduler, step))
354+
if iteration >= warmup:
355+
times.append(elapsed / 1e6)
356+
records = _complete(model, runner, scheduler, step, elapsed)
357+
_check_request_state(model, step, records)
358+
if isinstance(case, PrefillCase) and any(
359+
lane.request.consumed != case.context_tokens + case.chunk_tokens
360+
or len(lane.request.generated) != 1 for lane in step.lanes):
361+
raise RuntimeError("prefill replay did not consume the complete chunk and sample its pending token")
362+
if iteration == -1:
363+
_check_metadata(model, step, case, proposal_before=proposal_before)
364+
if not isinstance(case, PrefillCase):
365+
forward = model.benchmark_rounds[step.graph]
366+
samples[-1]["acceptance"] = acceptance_observation(forward)
367+
samples[-1]["draft_forward_tokens_per_request"] = (
368+
forward.draft_inputs.shape[1] if forward.draft is not None else 0)
369+
samples[-1]["draft_context_updated"] = forward.context is not None
370+
samples[-1]["proposal_tokens_per_request"] = step.draft_tokens
371+
samples[-1]["target_tokens_per_request"] = case.packed_tokens // case.batch
372+
if progress is not None:
373+
progress(result("running"))
345374
runner.restore()
346375
runner.release_snapshot()
347376
except BaseException as error:

‎ignition/cli/bench.py‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def arguments(parser):
4343
help="preceding consumed tokens, multiples of 128 (default: 512,2048,8192,16384; --prefill: 0)")
4444
options.add_argument("--verify-width", type=int, metavar="N",
4545
help="target tokens per request including pending token: 8 or 16; 8,13,16 with --speculation-study")
46+
options.add_argument("--verify-prefixes", type=_integers, metavar="LIST",
47+
help="C1 packed verification prefixes, e.g. 1,4,6,8; retains the full proposal count for prefixes above one")
4648
options.add_argument("--warmup", type=int, default=5, metavar="N",
4749
help="positive untimed restored replays per case and seed (default: 5)")
4850
options.add_argument("--repetitions", type=int, default=20, metavar="N",
@@ -64,17 +66,23 @@ class Settings:
6466
prefill: bool = False
6567
speculation_study: bool = False
6668
study_widths: tuple[int, ...] = ()
69+
verification_prefixes: tuple[int, ...] = ()
6770

6871
@classmethod
6972
def from_args(cls, args):
7073
study = getattr(args, "speculation_study", False)
71-
batches = args.batches if args.batches is not None else ((1,) if args.prefill or study else (1, 2, 4))
74+
batches = args.batches if args.batches is not None else ((1,) if args.prefill or study or args.verify_prefixes else (1, 2, 4))
7275
contexts = args.contexts if args.contexts is not None else ((0,) if args.prefill else (8192,) if study else (512, 2048, 8192, 16384))
7376
if args.prefill and args.verify_width is not None:
7477
raise UsageError("--verify-width does not apply to --prefill")
7578
if args.prefill and batches != (1,):
7679
raise UsageError("--prefill requires --batches 1")
7780
study_widths = getattr(args, "study_widths", None)
81+
verification_prefixes = getattr(args, "verify_prefixes", None)
82+
if verification_prefixes is not None:
83+
if args.prefill or study or batches != (1,):
84+
raise UsageError("--verify-prefixes requires --batches 1 without --prefill or --speculation-study")
85+
verification_prefixes = tuple(sorted(verification_prefixes))
7886
if study_widths is not None:
7987
if not study or any(w not in (8, 13, 16) for w in study_widths):
8088
raise UsageError("--study-widths requires --speculation-study and widths from 8,13,16")
@@ -90,6 +98,8 @@ def from_args(cls, args):
9098
raise UsageError("--speculation-study requires --batches 1 and --verify-width 8,13,16 without --prefill")
9199
if not args.prefill and not study and width not in (8, 16):
92100
raise UsageError("ignition bench requires --verify-width 8 or 16 and matching --mtp-draft-tokens 7 or 15")
101+
if verification_prefixes and (verification_prefixes[0] < 1 or verification_prefixes[-1] > width):
102+
raise UsageError("--verify-prefixes must be positive and no larger than --verify-width")
93103
if any(value not in (1, 2, 4, 8) for value in batches):
94104
raise UsageError("--batches must select request counts from 1,2,4,8")
95105
if any(value < (0 if args.prefill else 128) or value % 128 for value in contexts):
@@ -107,7 +117,8 @@ def from_args(cls, args):
107117
if args.official_source is not None:
108118
raise UsageError("--official-source applies to core publishing; ignition bench exports no core")
109119
return cls(tuple(sorted(batches)), tuple(sorted(contexts)), 8 if args.prefill else width,
110-
args.warmup, args.repetitions, args.seeds, args.prefill, study, study_widths or ())
120+
args.warmup, args.repetitions, args.seeds, args.prefill, study, study_widths or (),
121+
verification_prefixes or ())
111122

112123

113124
def table(result):
@@ -161,7 +172,7 @@ def run(args):
161172
plan = replace(plan, benchmark_selection=BenchmarkSelection(
162173
batches=settings.batches, contexts=settings.contexts, width=settings.width,
163174
prefill_chunk=plan.prefill_chunk if settings.prefill else 0, speculation_study=settings.speculation_study,
164-
study_widths=settings.study_widths))
175+
study_widths=settings.study_widths, verification_prefixes=settings.verification_prefixes))
165176
print(json.dumps({"plan": plan.to_dict()}), file=sys.stderr, flush=True)
166177
progress = _progress(args.verbose)
167178
hub_cache = configuration.directory("hub_cache")
@@ -221,6 +232,7 @@ def _measure(model, plan, configuration, settings, progress, *, compile_forwards
221232
details["configuration"] = {"model": model.model_id, "checkpoint_directory": str(snapshot),
222233
"repository": model.checkpoint.repository, "revision": model.checkpoint.revision,
223234
"compiled_forwards": compile_forwards, "torch_version": torch.__version__,
235+
"verification_prefixes": settings.verification_prefixes, "logical_verification_width": settings.width,
224236
"requested_max_concurrency": plan.concurrency, "prepared_request_capacity": max(settings.batches),
225237
"preparation": {key: preparation[key] for key in (
226238
"tensor_parallel", "prefill_chunk", "stream_overlap", "context_parallel",
@@ -256,11 +268,19 @@ def record(result):
256268
tokenizer_asset = root.server_config["assets"]["tokenizer"]
257269
tokenizer = (Tokenizer.from_str(tokenizer_asset["text"]) if "text" in tokenizer_asset
258270
else Tokenizer.from_file(tokenizer_asset["path"]))
259-
scenarios = (tuple(PrefillCase(chunk_tokens=plan.prefill_chunk, context_tokens=context)
260-
for context in settings.contexts) if settings.prefill else
261-
tuple((StudyCase if settings.speculation_study else BenchmarkCase)(batch=batch, width=width, packed_tokens=batch * width,
262-
context_tokens=context) for batch in settings.batches for context in settings.contexts
263-
for width in settings.study_widths or (settings.width,)))
271+
if settings.prefill:
272+
scenarios = tuple(PrefillCase(chunk_tokens=plan.prefill_chunk, context_tokens=context)
273+
for context in settings.contexts)
274+
elif settings.speculation_study:
275+
scenarios = tuple(StudyCase(batch=batch, width=width, packed_tokens=batch * width,
276+
context_tokens=context)
277+
for batch in settings.batches for context in settings.contexts
278+
for width in settings.study_widths or (settings.width,))
279+
else:
280+
scenarios = tuple(BenchmarkCase(batch=batch, width=width, packed_tokens=packed,
281+
context_tokens=context)
282+
for batch, width, packed in plan.benchmark_selection.speculative_round_shapes
283+
for context in settings.contexts)
264284
execute = run_study if settings.speculation_study else run_benchmark
265285
result = execute(prepared, tokenizer, scenarios=scenarios, warmup=settings.warmup,
266286
repetitions=settings.repetitions, seeds=settings.seeds, progress=record)

‎ignition/planning.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,9 @@ def __post_init__(self):
220220
raise UsageError("benchmark graph preparation requires Qwen3.8-27B on one GPU or Kimi K3.1 Pro on sixteen GPUs")
221221
if selection.speculation_study and self.recipe.name != "qwen38_dense":
222222
raise UsageError("speculation studies require Qwen3.8-27B DFlash2")
223-
if not selection.prefill_chunk and (self.speculation is None or (8 if selection.speculation_study else selection.width) not in self.speculation.widths):
223+
required_widths = (8,) if selection.speculation_study else selection.selected_verification_widths
224+
if not selection.prefill_chunk and (self.speculation is None
225+
or not set(required_widths) <= set(self.speculation.widths)):
224226
raise UsageError("benchmark rounds require the selected speculative width")
225227
if selection.max_batch > self.concurrency:
226228
raise UsageError("benchmark batches exceed --max-concurrency")

0 commit comments

Comments
 (0)