Skip to content

Commit a25b71b

Browse files
feat(PR-A3c): batched multi-tenant parallel decode bench (per-session binding, recall-preserving S5)
Each batch row = a session with its own KV-cache row (per-session binding); one batched forward advances all N in parallel — the capability v0.3's single-tenant serialized served path lacks. Compares batched AR vs batched restored-S5 at N=1..16: aggregate decode tok/s, parallel scaling vs N=1, per-session recall (bottom line). Recall-sacrificing pure sink+window is intentionally out of scope. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent c75a2ac commit a25b71b

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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

Comments
 (0)