""" scripts/gqa_regression_test.py — E2E smoke test for GQA on the 4b model ======================================================================== The bug: _chunked_attention crashes for Gemma3 4b (Hq=8, Hkv=4 — 2:1 GQA) because torch.matmul(qc, k.transpose) treats Hq/Hkv as batch dims and they must match (or one be 1). Hq=8 vs Hkv=4 fails. This test fires a real chat completion against the running server with gemma3-4b-it, parses the streaming response, and verifies: 1. HTTP 200 status, no error frame in SSE stream 2. Assistant returns non-empty content 3. Server log does NOT contain a fresh GQA/RuntimeError traceback TDD-red: fails on the buggy _chunked_attention. TDD-green: passes after the GQA fix in patch.py. Run: python scripts/gqa_regression_test.py """ import argparse import json import os import subprocess import sys import time from pathlib import Path import httpx REPO = Path(__file__).resolve().parent.parent DEFAULT_LOG = REPO / "local_debug.log" SERVER_URL = "https://localhost:7860/v1/chat/completions" SSL_VERIFY = False # self-signed def _http_post_chat(model_id, prompt, preset="BASELINE", max_tokens=128, timeout=300.0): """Send one streaming chat completion; return (status_code, full_text, error).""" payload = { "model": model_id, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, "temperature": 0.7, "stream": True, "preset": preset, } try: with httpx.stream( "POST", SERVER_URL, json=payload, timeout=timeout, verify=SSL_VERIFY, ) as r: if r.status_code != 200: body = r.read() return r.status_code, "", f"HTTP {r.status_code}: {body[:300]!r}" chunks = [] err = None for raw in r.iter_lines(): if not raw or not raw.startswith("data: "): continue data = raw[6:] if data.strip() == "[DONE]": break try: obj = json.loads(data) except Exception as e: return 200, "", f"JSON parse error on chunk: {data!r} ({e})" # error frame? if "error" in obj: err = obj["error"] continue # text chunk? for choice in obj.get("choices", []): delta = choice.get("delta", {}) content = delta.get("content") if content: chunks.append(content) text = "".join(chunks) if err: return 200, "", f"SSE error frame: {err}" return r.status_code, text, None except httpx.ConnectError as e: return 0, "", f"connect error: {e}" except Exception as e: return 0, "", f"exception: {type(e).__name__}: {e}" def _scan_log_for_gqa_errors(log_path, size_before): """Return RuntimeError/GQA/OOM lines APPENDED after the given byte offset.""" if not log_path.exists(): return [] bad = [] needles = ("RuntimeError", "GQA", "OutOfMemoryError", "tensor a (8) must match tensor b (4)") with log_path.open("rb") as f: f.seek(size_before) for raw in f: try: line = raw.decode("utf-8", errors="replace") except Exception: continue if any(n in line for n in needles): bad.append(line.rstrip()) return bad def main(): ap = argparse.ArgumentParser() ap.add_argument("--model", default="gemma3-4b-it") # Long prompt (>4096 tokens) forces prefill T > MEM_EFF_THRESHOLD in # _mem_eff_attention_forward, which is what triggers the chunked path # where the GQA bug lives. Short prompts take the SDPA fast path that # handles GQA natively and never reaches _chunked_attention. # 4b + RTX 2060 12GB: at T=4100 with chunked path (chunk=256) per-layer # score matrix is 8*256*4100*4B = 33 MB. Total chunked memory fits in # the ~1 GB headroom after 4b model + KV cache (~10.5 GB). Larger T # or larger chunk pushes OOM past GPU free memory. _LONG_BODY = ( "Dies ist ein langer Text über das Phänomen der Selbstwahrnehmung " "in einem Sprachmodell. " * 490 ) ap.add_argument("--prompt", default=( "Bitte fasse den folgenden Text in zwei Sätzen zusammen:\n\n" + _LONG_BODY )) ap.add_argument("--preset", default="BASELINE") ap.add_argument("--max-tokens", type=int, default=64) ap.add_argument("--timeout", type=float, default=300.0) ap.add_argument("--log", default=str(DEFAULT_LOG)) args = ap.parse_args() log_path = Path(args.log) log_size_before = log_path.stat().st_size if log_path.exists() else 0 t0 = time.time() print(f"[E2E] POST {args.model} (preset={args.preset}) prompt={args.prompt[:60]!r}...") status, text, err = _http_post_chat( args.model, args.prompt, preset=args.preset, max_tokens=args.max_tokens, ) elapsed = time.time() - t0 print(f"[E2E] status={status} text_len={len(text)} elapsed={elapsed:.1f}s") if err: print(f"[E2E] ERROR: {err}") print(f"[E2E] --- assistant ---") print(text[:800]) print(f"[E2E] -------------------") gqa_errors = _scan_log_for_gqa_errors(log_path, log_size_before) # All post-call errors are real failures: GQA mismatch, CUDA OOM, etc. fresh_errs = [l for l in gqa_errors if ( "Traceback" in l or "RuntimeError" in l or "OutOfMemoryError" in l )] failures = [] if status != 200: failures.append(f"HTTP status {status} (expected 200)") if err: failures.append(f"stream error: {err}") if not text.strip(): failures.append("empty assistant text") if fresh_errs: failures.append(f"server-side errors in log ({len(fresh_errs)} lines):") for l in fresh_errs[-6:]: failures.append(f" {l[:200]}") if failures: print("\n[E2E] FAIL") for f in failures: print(f" - {f}") sys.exit(1) print("\n[E2E] PASS — 4b GQA regression clean") if __name__ == "__main__": main()