Octen-WebGR-0.6B / infer_gpu.py
bflhc's picture
Add files using upload-large-folder tool
2e7c909 verified
Raw History Blame Contribute Delete
8.98 kB
# Copyright 2026 Octen. Licensed under the Apache License, Version 2.0 (see LICENSE-CODE).
"""Reference GPU inference for Octen-WebGR: query -> top-K URLs by constrained beam search over two semantic-ID tokens.
Inference protocol (identical to the evaluation in the technical report):
1. Normalise the query: collapse Unicode whitespace to one space, strip, lowercase.
2. Keep at most the first 32 tokens of the query (decoded back to a string before building the prompt).
3. Prompt = "Query: {q}\\nURL:", tokenised without special tokens.
4. Level t (t = 0, 1) of the SID is vocabulary token 151936 + t * 65536 + code; only those 65,536 rows of
lm_head are scored at each level.
5. Constrained beam search: only prefixes present in sid_table/ are allowed, and log_softmax is taken over the
valid children only. K beams are kept at each level (default 20).
6. Score = sum of the two log-probabilities (exp(score) = the model's probability of the URL among valid SIDs).
(sid0, sid1) is looked up in sid_table/ to get the URL.
This implementation recomputes the prompt for every beam at level 1 and pads each batch to its longest prompt;
it favours clarity over speed. infer_cpu.py reuses the prompt's KV cache instead.
python infer_gpu.py --model Octen/Octen-WebGR-4B --query "youtube" "boeing internship"
python infer_gpu.py --model /path/to/local/copy --check # compare against smoke_test.json
"""
import argparse, glob, json, os, time
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
BASE_V, V, L, MAX_Q = 151936, 65536, 2, 32
PAD = 151643 # <|endoftext|>, used only for left padding (masked out)
def resolve(model):
"""Local directory, or a Hugging Face repo id that is downloaded once into the HF cache."""
if os.path.isdir(model):
return model
from huggingface_hub import snapshot_download
return snapshot_download(model)
def norm_query(q):
return " ".join(q.split()).lower()
class URLs:
"""URL column kept as one array per table file (no combined copy, so peak memory is one table); rows are global."""
def __init__(self, files):
self.parts = [pq.read_table(f, columns=["url"]).column("url").combine_chunks() for f in files]
self.start = np.cumsum([0] + [len(p) for p in self.parts])
def take(self, rows):
f = np.searchsorted(self.start, rows, side="right") - 1
return [self.parts[i][int(r - self.start[i])].as_py() for i, r in zip(f, rows)]
class GR:
def __init__(self, model, device="cuda"):
self.root = resolve(model)
self.dev = device
self.dev_type = torch.device(device).type
self.tok = AutoTokenizer.from_pretrained(self.root)
attn = "sdpa"
if self.dev_type == "cuda":
try:
import flash_attn # noqa: F401
attn = "flash_attention_2"
except ImportError:
pass
self.lm = AutoModelForCausalLM.from_pretrained(self.root, dtype=torch.bfloat16, attn_implementation=attn).to(device).eval()
assert self.lm.config.vocab_size == BASE_V + L * V, "unexpected vocabulary size: should be 151936 + 2 x 65536"
self.W = self.lm.lm_head.weight
files = sorted(glob.glob(f"{self.root}/sid_table/part-*.parquet"))
t = pa.concat_tables([pq.read_table(f, columns=["sid0", "sid1"]) for f in files])
key = t.column("sid0").to_numpy().astype(np.int64) * V + t.column("sid1").to_numpy().astype(np.int64)
assert (np.diff(key) > 0).all(), "sid_table must be sorted by key in strictly ascending order (one URL per SID)"
self.keys = torch.from_numpy(key).to(device) # row number = position in keys
ok0 = np.zeros(V, bool)
ok0[np.unique(key // V)] = True
self.ok0 = torch.from_numpy(ok0).to(device) # valid level-0 codes
self.url = URLs(files)
self.n = len(key)
def prompts(self, qs):
qs = [norm_query(q) for q in qs]
qi = self.tok(qs, add_special_tokens=False)["input_ids"]
qs = [self.tok.decode(x[:MAX_Q]) if len(x) > MAX_Q else q for q, x in zip(qs, qi)]
return self.tok([f"Query: {q}\nURL:" for q in qs], add_special_tokens=False)["input_ids"]
def last_hidden(self, seqs):
P = max(len(s) for s in seqs)
ids = torch.full((len(seqs), P), PAD, dtype=torch.long)
am = torch.zeros((len(seqs), P), dtype=torch.long)
for i, s in enumerate(seqs): # left padding: the last position is each sequence's last token
ids[i, P - len(s):] = torch.tensor(s)
am[i, P - len(s):] = 1
ids, am = ids.to(self.dev), am.to(self.dev)
pos = (am.cumsum(1) - 1).clamp(min=0)
return self.lm.model(input_ids=ids, attention_mask=am, position_ids=pos).last_hidden_state[:, -1]
@torch.no_grad()
def search(self, qs, K=20):
"""-> one list per query of (url, log_prob, table_row), sorted by score."""
with torch.autocast(self.dev_type, dtype=torch.bfloat16):
P = self.prompts(qs)
B = len(P)
ninf = float("-inf")
lg0 = (self.last_hidden(P) @ self.W[BASE_V:BASE_V + V].t()).float() # [B, V]
lp0 = lg0.masked_fill(~self.ok0, ninf).log_softmax(-1).masked_fill(~self.ok0, ninf)
k0 = min(K, int(self.ok0.sum()))
s0, c0 = lp0.topk(k0, dim=1) # [B, k0]
# Level 1: one sequence per (query, level-0 candidate) = prompt + that SID token
P1 = [p + [BASE_V + c] for p, cs in zip(P, c0.tolist()) for c in cs]
lg1 = (self.last_hidden(P1) @ self.W[BASE_V + V:BASE_V + 2 * V].t()).float() # [B*k0, V]
cc = c0.reshape(-1)
lo = torch.searchsorted(self.keys, cc * V)
n = torch.searchsorted(self.keys, (cc + 1) * V) - lo # number of valid children per prefix
r = torch.repeat_interleave(torch.arange(len(cc), device=self.dev), n)
j = torch.arange(int(n.sum()), device=self.dev) - torch.repeat_interleave(n.cumsum(0) - n, n) + lo[r]
ok1 = torch.zeros_like(lg1, dtype=torch.bool)
ok1[r, self.keys[j] - cc[r] * V] = True
lp1 = lg1.masked_fill(~ok1, ninf).log_softmax(-1).masked_fill(~ok1, ninf)
cand = (s0.reshape(-1, 1) + lp1).view(B, k0 * V)
s, flat = cand.topk(min(K, k0 * V), dim=1)
key = c0.gather(1, flat // V) * V + flat % V
row = torch.searchsorted(self.keys, key)
s, row = s.float().cpu().numpy(), row.cpu().numpy()
out = []
for b in range(B):
m = np.isfinite(s[b])
rr = row[b][m]
out.append(list(zip(self.url.take(rr), s[b][m].tolist(), rr.tolist())))
return out
def check(gr, bs=32):
"""Compare against smoke_test.json (hand-picked queries; expected results from the released weights and table)."""
ref = json.load(open(f"{gr.root}/smoke_test.json"))
assert gr.n == ref["table_rows"], f"sid_table has {gr.n:,} rows, expected {ref['table_rows']:,}"
qs, want = [x["query"] for x in ref["cases"]], [x["top20"] for x in ref["cases"]]
ov, top1, n = [], 0, len(qs)
for i in range(0, n, bs):
for res, w in zip(gr.search(qs[i:i + bs], 20), want[i:i + bs]):
got = [u for u, *_ in res]
ov.append(len(set(got) & set(w)) / max(len(w), 1))
top1 += bool(got and w and got[0] == w[0])
mo, t1 = float(np.mean(ov)), top1 / n
print(f"{n} queries: mean top-20 overlap {mo:.4f}, top-1 agreement {t1:.4f}, identical top-20 {np.mean(np.array(ov) == 1):.3f}")
ok = mo >= 0.9 and t1 >= 0.9
print("PASS" if ok else "FAIL (small differences from batching and kernels are expected, but overlap should be >= 0.9)")
return ok
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.path.dirname(os.path.abspath(__file__)), help="local directory or HF repo id")
ap.add_argument("--query", nargs="*", default=[])
ap.add_argument("--k", type=int, default=20)
ap.add_argument("--device", default="cuda")
ap.add_argument("--check", action="store_true")
a = ap.parse_args()
t0 = time.time()
gr = GR(a.model, a.device)
print(f"Loaded {gr.n:,} candidate URLs in {time.time() - t0:.0f}s", flush=True)
for q in a.query:
t = time.perf_counter()
res = gr.search([q], a.k)[0]
print(f"\n{q} ({(time.perf_counter() - t) * 1000:.0f} ms)")
for i, (u, lp, _) in enumerate(res, 1):
print(f" {i:2d}. {np.exp(lp):.3f} {u}")
if a.check and not check(gr):
raise SystemExit(1)
if __name__ == "__main__":
main()