|
| 1 | +"""PR-A3c end-to-end: per-session binding + true parallel multi-tenant decode. |
| 2 | +
|
| 3 | +Measures **parallel-inference throughput** for the recall-preserving restored S5 |
| 4 | +path, on CUDA. On one accelerator, true parallelism = a **batched** forward: N |
| 5 | +sessions decoded in one pass, each session = one batch row with its own KV-cache |
| 6 | +row (per-session binding). This is the capability v0.3's single-tenant served |
| 7 | +path lacks (RPCs serialized on one verifier — PR-A3c). |
| 8 | +
|
| 9 | +For each batch size N it runs, on the SAME N prompts: |
| 10 | + * batched **AR** (native HF gemma) — the parallel throughput ceiling |
| 11 | + * batched **restored S5** (Kakeya) — recall-preserving bounded path |
| 12 | +
|
| 13 | +and reports aggregate decode tok/s (N rows in parallel), per-session recall |
| 14 | +(must stay 1.0 — recall is the bottom line; the non-recall pure sink+window |
| 15 | +config is intentionally NOT tested), and parallel scaling vs the N=1 rate. |
| 16 | +
|
| 17 | +Equal-length prompts (a modal-length NIAH bucket, tiled) keep the batch clean — |
| 18 | +the restored forward has no attention-mask plumbing, so padding is avoided. |
| 19 | +Recall-sacrificing configs are out of scope by request. |
| 20 | +""" |
| 21 | + |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import argparse |
| 25 | +import json |
| 26 | +import sys |
| 27 | +import time |
| 28 | +from collections import Counter |
| 29 | +from pathlib import Path |
| 30 | +from typing import Any, Dict, List |
| 31 | + |
| 32 | +import torch |
| 33 | + |
| 34 | + |
| 35 | +@torch.no_grad() |
| 36 | +def _ar_batched(model, ids_bt, gen_tokens, device, eos_ids): |
| 37 | + """Batched AR decode. ids_bt: [N, T]. Returns (per_row_tokens, decode_s).""" |
| 38 | + N = ids_bt.size(0) |
| 39 | + out = model(input_ids=ids_bt, use_cache=True) |
| 40 | + cache = out.past_key_values |
| 41 | + nxt = out.logits[:, -1, :].argmax(-1) # [N] |
| 42 | + gen = [[int(nxt[i].item())] for i in range(N)] |
| 43 | + T = ids_bt.size(1) |
| 44 | + torch.cuda.synchronize(device) |
| 45 | + t0 = time.perf_counter() |
| 46 | + for step in range(gen_tokens - 1): |
| 47 | + cur = nxt.view(N, 1) |
| 48 | + pos = torch.full((N, 1), T + step, device=device, dtype=torch.long) |
| 49 | + out = model(input_ids=cur, past_key_values=cache, use_cache=True, |
| 50 | + cache_position=torch.tensor([T + step], device=device)) |
| 51 | + cache = out.past_key_values |
| 52 | + nxt = out.logits[:, -1, :].argmax(-1) |
| 53 | + for i in range(N): |
| 54 | + gen[i].append(int(nxt[i].item())) |
| 55 | + torch.cuda.synchronize(device) |
| 56 | + return gen, time.perf_counter() - t0 |
| 57 | + |
| 58 | + |
| 59 | +@torch.no_grad() |
| 60 | +def _restored_prefill_batched(restored, ids_bt, helpers): |
| 61 | + """Batched restored S5 prefill -> (DynamicCache, last_logits [N, V]).""" |
| 62 | + from transformers.cache_utils import DynamicCache |
| 63 | + n_layers = len(_decoder_layers(restored.verifier_model)) |
| 64 | + capture: list = [None] * n_layers |
| 65 | + out = restored.forward(ids_bt, capture_kv=capture, **helpers) |
| 66 | + logits = out.logits if hasattr(out, "logits") else out |
| 67 | + if any(c is None for c in capture): |
| 68 | + raise RuntimeError("restored prefill did not capture all layers " |
| 69 | + "(prompt must exceed sink+window)") |
| 70 | + cache = DynamicCache() |
| 71 | + for li, (k, v) in enumerate(capture): |
| 72 | + cache.update(k, v, li) |
| 73 | + return cache, logits[:, -1, :] |
| 74 | + |
| 75 | + |
| 76 | +@torch.no_grad() |
| 77 | +def _restored_decode_batched(model, cache, last_logits, gen_tokens, T, device): |
| 78 | + N = last_logits.size(0) |
| 79 | + nxt = last_logits.argmax(-1) |
| 80 | + gen = [[int(nxt[i].item())] for i in range(N)] |
| 81 | + torch.cuda.synchronize(device) |
| 82 | + t0 = time.perf_counter() |
| 83 | + for step in range(gen_tokens - 1): |
| 84 | + cur = nxt.view(N, 1) |
| 85 | + pos = torch.full((N, 1), T + step, device=device, dtype=torch.long) |
| 86 | + cpos = torch.tensor([T + step], device=device) |
| 87 | + out = model(input_ids=cur, position_ids=pos, cache_position=cpos, |
| 88 | + past_key_values=cache, use_cache=True) |
| 89 | + cache = out.past_key_values |
| 90 | + nxt = out.logits[:, -1, :].argmax(-1) |
| 91 | + for i in range(N): |
| 92 | + gen[i].append(int(nxt[i].item())) |
| 93 | + torch.cuda.synchronize(device) |
| 94 | + return gen, time.perf_counter() - t0 |
| 95 | + |
| 96 | + |
| 97 | +def _decoder_layers(model): |
| 98 | + from inference_engine.v04.cross_model_dlm_verifier import get_verifier_decoder |
| 99 | + return get_verifier_decoder(model).layers |
| 100 | + |
| 101 | + |
| 102 | +def main() -> int: |
| 103 | + ap = argparse.ArgumentParser(description=__doc__) |
| 104 | + ap.add_argument("--verifier-id", default="google/gemma-4-26B-A4B-it") |
| 105 | + ap.add_argument("--drafter-id", default="z-lab/gemma-4-26B-A4B-it-DFlash") |
| 106 | + ap.add_argument("--f-theta-dir", default="results/research/f_theta_v5_s5_sliding") |
| 107 | + ap.add_argument("--haystack-lines", type=int, default=160) |
| 108 | + ap.add_argument("--batch-sizes", default="1,2,4,8,16") |
| 109 | + ap.add_argument("--gen-tokens", type=int, default=24) |
| 110 | + ap.add_argument("--pool", type=int, default=24) |
| 111 | + ap.add_argument("--sink", type=int, default=4) |
| 112 | + ap.add_argument("--window", type=int, default=64) |
| 113 | + ap.add_argument("--seed", type=int, default=0) |
| 114 | + ap.add_argument("--output", default=None) |
| 115 | + args = ap.parse_args() |
| 116 | + |
| 117 | + if not torch.cuda.is_available(): |
| 118 | + print("[mt] CUDA required.", file=sys.stderr) |
| 119 | + return 2 |
| 120 | + device = torch.device("cuda") |
| 121 | + dtype = torch.bfloat16 |
| 122 | + from transformers import AutoModelForCausalLM, AutoTokenizer |
| 123 | + from transformers.models.gemma4.modeling_gemma4 import ( # type: ignore |
| 124 | + ALL_ATTENTION_FUNCTIONS, apply_rotary_pos_emb, eager_attention_forward, |
| 125 | + ) |
| 126 | + from inference_engine.v04 import ( |
| 127 | + CrossModelRestoredSinkWindowVerifier, DFlashDrafter, FThetaProjection, |
| 128 | + make_niah_dataset, |
| 129 | + ) |
| 130 | + from inference_engine.v04.cross_model_dlm_verifier import ( |
| 131 | + CrossModelDLMRestoredVerifier, full_attention_layer_indices, |
| 132 | + ) |
| 133 | + |
| 134 | + print(f"[mt] loading verifier {args.verifier_id}", file=sys.stderr, flush=True) |
| 135 | + tok = AutoTokenizer.from_pretrained(args.verifier_id) |
| 136 | + verifier = AutoModelForCausalLM.from_pretrained( |
| 137 | + args.verifier_id, dtype=dtype, attn_implementation="eager", |
| 138 | + ).to(device).eval() |
| 139 | + for p in verifier.parameters(): |
| 140 | + p.requires_grad_(False) |
| 141 | + drafter = DFlashDrafter.from_pretrained(args.drafter_id, dtype=dtype).to(device).eval() |
| 142 | + for p in drafter.parameters(): |
| 143 | + p.requires_grad_(False) |
| 144 | + f_theta = FThetaProjection.from_pretrained(args.f_theta_dir, dtype=torch.float32, device=device) |
| 145 | + exact_layers = full_attention_layer_indices(verifier) |
| 146 | + restored = CrossModelDLMRestoredVerifier( |
| 147 | + verifier_model=verifier, drafter=drafter, f_theta=f_theta, |
| 148 | + sink_size=args.sink, window_size=args.window, exact_layer_indices=exact_layers, |
| 149 | + ) |
| 150 | + helpers = dict(apply_rotary_pos_emb=apply_rotary_pos_emb, |
| 151 | + eager_attention_forward=eager_attention_forward, |
| 152 | + all_attention_functions=ALL_ATTENTION_FUNCTIONS) |
| 153 | + eos_ids = set(x for x in [tok.eos_token_id] if x is not None) |
| 154 | + |
| 155 | + def encode_chat(text): |
| 156 | + ids = tok.apply_chat_template( |
| 157 | + [{"role": "user", "content": text}], |
| 158 | + add_generation_prompt=True, tokenize=True, return_tensors="pt") |
| 159 | + if hasattr(ids, "keys"): |
| 160 | + ids = ids["input_ids"] |
| 161 | + return ids[0].tolist() |
| 162 | + |
| 163 | + # Build an equal-length prompt set: pick the modal token length so the batch |
| 164 | + # needs no padding (restored forward has no attention-mask path). |
| 165 | + pool = make_niah_dataset(n_samples=args.pool, |
| 166 | + haystack_min_lines=args.haystack_lines, |
| 167 | + haystack_max_lines=args.haystack_lines, seed=args.seed) |
| 168 | + enc = [(encode_chat(s.prompt_text), s.answer_text) for s in pool] |
| 169 | + lengths = Counter(len(e[0]) for e in enc) |
| 170 | + modal_len, _ = lengths.most_common(1)[0] |
| 171 | + bucket = [(ids, ans) for ids, ans in enc if len(ids) == modal_len] |
| 172 | + print(f"[mt] modal prompt len={modal_len}, {len(bucket)} equal-length prompts " |
| 173 | + f"(of {len(enc)})", file=sys.stderr, flush=True) |
| 174 | + batch_sizes = [int(x) for x in args.batch_sizes.split(",") if x.strip()] |
| 175 | + need = max(batch_sizes) |
| 176 | + while len(bucket) < need: # tile distinct prompts up to N |
| 177 | + bucket += bucket[: need - len(bucket)] |
| 178 | + |
| 179 | + def recall(tokens, ans): |
| 180 | + return ans in tok.decode(tokens, skip_special_tokens=True) |
| 181 | + |
| 182 | + # warmup (kernels) at the largest batch |
| 183 | + print("[mt] warmup ...", file=sys.stderr, flush=True) |
| 184 | + wb = torch.tensor([b[0] for b in bucket[:max(batch_sizes)]], device=device) |
| 185 | + try: |
| 186 | + _ar_batched(verifier, wb, 4, device, eos_ids) |
| 187 | + c, ll = _restored_prefill_batched(restored, wb, helpers) |
| 188 | + _restored_decode_batched(verifier, c, ll, 4, modal_len, device) |
| 189 | + except Exception as e: # noqa: BLE001 |
| 190 | + print(f"[mt] warmup note: {e}", file=sys.stderr) |
| 191 | + |
| 192 | + rows: List[Dict[str, Any]] = [] |
| 193 | + single = {} |
| 194 | + for N in batch_sizes: |
| 195 | + sel = bucket[:N] |
| 196 | + ids_bt = torch.tensor([s[0] for s in sel], device=device) |
| 197 | + ans = [s[1] for s in sel] |
| 198 | + # AR |
| 199 | + g_ar, dt_ar = _ar_batched(verifier, ids_bt, args.gen_tokens, device, eos_ids) |
| 200 | + ar_tps = (N * args.gen_tokens) / dt_ar |
| 201 | + ar_rec = sum(recall(g, a) for g, a in zip(g_ar, ans)) / N |
| 202 | + # restored S5 |
| 203 | + cache, last = _restored_prefill_batched(restored, ids_bt, helpers) |
| 204 | + g_rs, dt_rs = _restored_decode_batched(verifier, cache, last, |
| 205 | + args.gen_tokens, modal_len, device) |
| 206 | + rs_tps = (N * args.gen_tokens) / dt_rs |
| 207 | + rs_rec = sum(recall(g, a) for g, a in zip(g_rs, ans)) / N |
| 208 | + peak = round(torch.cuda.max_memory_allocated(device) / 1e9, 2) |
| 209 | + if N == 1: |
| 210 | + single = {"ar": ar_tps, "restored": rs_tps} |
| 211 | + row = { |
| 212 | + "agents": N, |
| 213 | + "ar_aggregate_tps": round(ar_tps, 2), |
| 214 | + "restored_aggregate_tps": round(rs_tps, 2), |
| 215 | + "ar_recall": round(ar_rec, 3), |
| 216 | + "restored_recall": round(rs_rec, 3), |
| 217 | + "ar_parallel_speedup_vs_n1": round(ar_tps / single["ar"], 2) if single else None, |
| 218 | + "restored_parallel_speedup_vs_n1": round(rs_tps / single["restored"], 2) if single else None, |
| 219 | + "peak_gpu_gb": peak, |
| 220 | + } |
| 221 | + rows.append(row) |
| 222 | + print(f"[mt] N={N:3d} | AR {row['ar_aggregate_tps']} tok/s " |
| 223 | + f"(x{row['ar_parallel_speedup_vs_n1']}, recall {row['ar_recall']}) | " |
| 224 | + f"restored {row['restored_aggregate_tps']} tok/s " |
| 225 | + f"(x{row['restored_parallel_speedup_vs_n1']}, recall {row['restored_recall']}) " |
| 226 | + f"| peak {peak}GB", file=sys.stderr, flush=True) |
| 227 | + |
| 228 | + report = { |
| 229 | + "kind": "k3_cuda_multitenant_parallel", |
| 230 | + "schema_version": 1, |
| 231 | + "config": { |
| 232 | + "verifier_id": args.verifier_id, "drafter_id": args.drafter_id, |
| 233 | + "haystack_lines": args.haystack_lines, "modal_prompt_len": modal_len, |
| 234 | + "gen_tokens": args.gen_tokens, "sink": args.sink, "window": args.window, |
| 235 | + "batch_sizes": batch_sizes, "exact_layers": exact_layers, |
| 236 | + "note": ("per-session binding via batched decode (each row = a " |
| 237 | + "session with its own KV-cache row); recall-preserving S5 " |
| 238 | + "only — non-recall configs out of scope."), |
| 239 | + }, |
| 240 | + "env": {"gpu": torch.cuda.get_device_name(0), "torch": torch.__version__}, |
| 241 | + "results": rows, |
| 242 | + } |
| 243 | + if args.output: |
| 244 | + Path(args.output).parent.mkdir(parents=True, exist_ok=True) |
| 245 | + Path(args.output).write_text(json.dumps(report, indent=2)) |
| 246 | + print(f"[mt] wrote {args.output}", file=sys.stderr) |
| 247 | + return 0 |
| 248 | + |
| 249 | + |
| 250 | +if __name__ == "__main__": |
| 251 | + raise SystemExit(main()) |
0 commit comments