|
| 1 | +"""gemma-4 bounded-decode concurrency ceiling (native hybrid cache, small window). |
| 2 | +
|
| 3 | +ADR 0015 item #2 on gemma-4. KEY FINDING (probed separately): gemma-4 keeps |
| 4 | +recall 1.0 with the sliding window shrunk to ~68 *natively* — no Kakeya |
| 5 | +restoration needed — because its 5 full-attention layers (of 30) carry recall. |
| 6 | +So on gemma-4 "bounded decode" reduces to `sliding_window=W` on the native |
| 7 | +HybridCache; this bench measures the resulting concurrency ceiling at long |
| 8 | +context to compare against vLLM. |
| 9 | +
|
| 10 | +(Honest caveat: this is native-window tuning that vLLM can also apply, so it is |
| 11 | +NOT a Kakeya algorithmic advantage on gemma-4 — that requires a full-attention |
| 12 | +model where shrinking the window kills recall and only restoration recovers it.) |
| 13 | +
|
| 14 | +Sweeps N (concurrent sessions) at a fixed context, greedy, reporting per-N peak |
| 15 | +GPU memory, per-session recall, and aggregate decode tok/s; the ceiling is the |
| 16 | +largest N that fits. |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import argparse |
| 22 | +import json |
| 23 | +import sys |
| 24 | +import time |
| 25 | +from collections import Counter |
| 26 | +from pathlib import Path |
| 27 | +from typing import Any, Dict, List |
| 28 | + |
| 29 | + |
| 30 | +def main() -> int: |
| 31 | + ap = argparse.ArgumentParser(description=__doc__) |
| 32 | + ap.add_argument("--verifier-id", default="google/gemma-4-26B-A4B-it") |
| 33 | + ap.add_argument("--sliding-window", type=int, default=68) |
| 34 | + ap.add_argument("--haystack-lines", type=int, default=3100) |
| 35 | + ap.add_argument("--batch-sizes", default="1,2,4,8,16,24,32") |
| 36 | + ap.add_argument("--gen-tokens", type=int, default=64) |
| 37 | + ap.add_argument("--pool", type=int, default=40) |
| 38 | + ap.add_argument("--seed", type=int, default=0) |
| 39 | + ap.add_argument("--output", default=None) |
| 40 | + args = ap.parse_args() |
| 41 | + |
| 42 | + import torch |
| 43 | + from transformers import AutoModelForCausalLM, AutoTokenizer |
| 44 | + sys.path.insert(0, "."); sys.path.insert(0, "sdks/python") |
| 45 | + from inference_engine.v04.niah_eval import make_niah_dataset |
| 46 | + |
| 47 | + device = torch.device("cuda") |
| 48 | + tok = AutoTokenizer.from_pretrained(args.verifier_id) |
| 49 | + print(f"[gb] loading {args.verifier_id} sdpa bf16", file=sys.stderr, flush=True) |
| 50 | + model = AutoModelForCausalLM.from_pretrained( |
| 51 | + args.verifier_id, dtype=torch.bfloat16, attn_implementation="sdpa", |
| 52 | + ).to(device).eval() |
| 53 | + tc = model.config.get_text_config() |
| 54 | + native_sw = tc.sliding_window |
| 55 | + tc.sliding_window = args.sliding_window |
| 56 | + if hasattr(model.config, "sliding_window"): |
| 57 | + model.config.sliding_window = args.sliding_window |
| 58 | + print(f"[gb] sliding_window {native_sw} -> {args.sliding_window}", file=sys.stderr, flush=True) |
| 59 | + |
| 60 | + def enc(t): |
| 61 | + ids = tok.apply_chat_template([{"role": "user", "content": t}], |
| 62 | + add_generation_prompt=True, tokenize=True, |
| 63 | + return_tensors="pt") |
| 64 | + return (ids["input_ids"] if hasattr(ids, "keys") else ids)[0].tolist() |
| 65 | + |
| 66 | + pool = make_niah_dataset(n_samples=args.pool, haystack_min_lines=args.haystack_lines, |
| 67 | + haystack_max_lines=args.haystack_lines, seed=args.seed) |
| 68 | + encs = [(enc(s.prompt_text), s.answer_text) for s in pool] |
| 69 | + modal = Counter(len(e[0]) for e in encs).most_common(1)[0][0] |
| 70 | + bucket = [(i, a) for i, a in encs if len(i) == modal] |
| 71 | + print(f"[gb] modal prompt len={modal}, {len(bucket)} equal-length", file=sys.stderr, flush=True) |
| 72 | + batch_sizes = [int(x) for x in args.batch_sizes.split(",") if x.strip()] |
| 73 | + need = max(batch_sizes) |
| 74 | + while len(bucket) < need: |
| 75 | + bucket += bucket[: need - len(bucket)] |
| 76 | + |
| 77 | + def recall(ids_out, ans): |
| 78 | + return ans in tok.decode(ids_out, skip_special_tokens=True) |
| 79 | + |
| 80 | + @torch.no_grad() |
| 81 | + def run(N): |
| 82 | + sel = bucket[:N] |
| 83 | + ids = torch.tensor([s[0] for s in sel], device=device) |
| 84 | + ans = [s[1] for s in sel] |
| 85 | + torch.cuda.reset_peak_memory_stats(device) |
| 86 | + # prefill |
| 87 | + out = model(input_ids=ids, use_cache=True, logits_to_keep=1) |
| 88 | + cache = out.past_key_values |
| 89 | + nxt = out.logits[:, -1, :].argmax(-1) |
| 90 | + gen = [[int(nxt[i])] for i in range(N)] |
| 91 | + T = ids.size(1) |
| 92 | + torch.cuda.synchronize(device); t0 = time.perf_counter() |
| 93 | + for step in range(args.gen_tokens - 1): |
| 94 | + cur = nxt.view(N, 1) |
| 95 | + cpos = torch.tensor([T + step], device=device) |
| 96 | + out = model(input_ids=cur, past_key_values=cache, use_cache=True, |
| 97 | + cache_position=cpos, logits_to_keep=1) |
| 98 | + cache = out.past_key_values |
| 99 | + nxt = out.logits[:, -1, :].argmax(-1) |
| 100 | + for i in range(N): |
| 101 | + gen[i].append(int(nxt[i])) |
| 102 | + torch.cuda.synchronize(device); dt = time.perf_counter() - t0 |
| 103 | + tps = (N * args.gen_tokens) / dt |
| 104 | + rec = sum(recall(gen[i], ans[i]) for i in range(N)) / N |
| 105 | + peak = torch.cuda.max_memory_allocated(device) / 1e9 |
| 106 | + return tps, rec, peak |
| 107 | + |
| 108 | + # warmup |
| 109 | + try: |
| 110 | + run(1) |
| 111 | + except Exception as e: # noqa: BLE001 |
| 112 | + print(f"[gb] warmup note: {e}", file=sys.stderr) |
| 113 | + |
| 114 | + rows: List[Dict[str, Any]] = [] |
| 115 | + single_tps = None |
| 116 | + for N in batch_sizes: |
| 117 | + try: |
| 118 | + tps, rec, peak = run(N) |
| 119 | + except torch.OutOfMemoryError as e: # noqa: BLE001 |
| 120 | + print(f"[gb] N={N}: OOM ({e})", file=sys.stderr, flush=True) |
| 121 | + rows.append({"agents": N, "oom": True}) |
| 122 | + break |
| 123 | + if single_tps is None: |
| 124 | + single_tps = tps |
| 125 | + row = {"agents": N, "decode_aggregate_tps": round(tps, 2), |
| 126 | + "recall": round(rec, 3), |
| 127 | + "parallel_speedup_vs_n1": round(tps / single_tps, 2), |
| 128 | + "peak_gpu_gb": round(peak, 2)} |
| 129 | + rows.append(row) |
| 130 | + print(f"[gb] N={N:3d} | {row['decode_aggregate_tps']} tok/s " |
| 131 | + f"(x{row['parallel_speedup_vs_n1']}) | recall {row['recall']} | " |
| 132 | + f"peak {row['peak_gpu_gb']} GB", file=sys.stderr, flush=True) |
| 133 | + |
| 134 | + report = { |
| 135 | + "kind": "gemma_bounded_decode", |
| 136 | + "config": {"verifier_id": args.verifier_id, "sliding_window": args.sliding_window, |
| 137 | + "native_sliding_window": native_sw, "modal_prompt_len": modal, |
| 138 | + "gen_tokens": args.gen_tokens, "batch_sizes": batch_sizes, |
| 139 | + "note": ("native gemma-4 hybrid cache with shrunk sliding window; " |
| 140 | + "no Kakeya restoration (recall comes from the 5 full-attn " |
| 141 | + "layers). vLLM can apply the same window — not a Kakeya moat.")}, |
| 142 | + "env": {"gpu": torch.cuda.get_device_name(0), "torch": torch.__version__}, |
| 143 | + "results": rows, |
| 144 | + } |
| 145 | + if args.output: |
| 146 | + Path(args.output).parent.mkdir(parents=True, exist_ok=True) |
| 147 | + Path(args.output).write_text(json.dumps(report, indent=2)) |
| 148 | + print(f"[gb] wrote {args.output}", file=sys.stderr) |
| 149 | + return 0 |
| 150 | + |
| 151 | + |
| 152 | +if __name__ == "__main__": |
| 153 | + raise SystemExit(main()) |
0 commit comments