Octen-WebGR-4B / infer_cpu.py
bflhc's picture
Add files using upload-large-folder tool
d36c5ee verified
Raw History Blame Contribute Delete
9.4 kB
# Copyright 2026 Octen. Licensed under the Apache License, Version 2.0 (see LICENSE-CODE).
"""CPU inference for Octen-WebGR. Same protocol as infer_gpu.py (query normalisation, prompt, constrained beam search,
scoring); it only removes two algorithmic redundancies of the reference implementation:
1. Level 0 runs the prompt once and keeps its KV cache; the K beams of level 1 reuse it and each feed one token
(the reference recomputes "prompt + SID token" K times, roughly 7-8x the compute).
2. Level-1 log_softmax is taken over the valid children only: sid_table/ is sorted by key = sid0 * 65536 + sid1,
so the valid children of prefix c are the contiguous rows [start[c], start[c+1]).
Results match infer_gpu.py except for occasional swaps between candidates whose scores are nearly tied.
python infer_cpu.py --model Octen/Octen-WebGR-0.6B --query "youtube" "boeing internship" --threads 16
python infer_cpu.py --model /path/to/local/copy --check --threads 16
"""
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, MAX_Q = 151936, 65536, 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 GRCPU:
def __init__(self, model, dtype=torch.bfloat16, logits_fp32=False):
"""dtype: precision of the forward pass (bf16 matches the GPU evaluation; fp32 is slower).
logits_fp32: compute the lm_head step with fp32 weights (bf16 logits are spaced 0.125 apart at |logit| >= 16,
so nearly tied candidates can swap depending on the kernel)."""
self.root = resolve(model)
self.tok = AutoTokenizer.from_pretrained(self.root)
self.lm = AutoModelForCausalLM.from_pretrained(self.root, dtype=dtype, attn_implementation="sdpa").eval()
assert self.lm.config.vocab_size == BASE_V + 2 * V, "unexpected vocabulary size: should be 151936 + 2 x 65536"
W = self.lm.lm_head.weight.detach()
ldt = torch.float32 if logits_fp32 else dtype
self.W0 = W[BASE_V:BASE_V + V].to(ldt).contiguous() # the 65,536 rows of level 0
self.W1 = W[BASE_V + V:BASE_V + 2 * V].to(ldt).contiguous() # the 65,536 rows of level 1
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])
s0 = t.column("sid0").to_numpy().astype(np.int64)
s1 = t.column("sid1").to_numpy().astype(np.int64)
assert (np.diff(s0 * V + s1) > 0).all(), "sid_table must be sorted by key in strictly ascending order (one URL per SID)"
self.start = torch.from_numpy(np.searchsorted(s0, np.arange(V + 1))) # valid children of prefix c = rows [start[c], start[c+1])
self.ok0 = self.start[1:] > self.start[:-1] # valid level-0 codes
self.sid1 = torch.from_numpy(s1)
self.url = URLs(files)
self.n = len(s0)
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"]
@torch.inference_mode()
def search_rows(self, qs, K=20):
"""-> (scores, rows) per query, sorted by score; rows index sid_table/."""
P = self.prompts(qs)
B, T = len(P), max(len(p) for p in P)
ids = torch.full((B, T), PAD, dtype=torch.long)
am = torch.zeros((B, T), dtype=torch.long)
for i, p in enumerate(P): # left padding: the last position is each sequence's last token
ids[i, T - len(p):] = torch.tensor(p)
am[i, T - len(p):] = 1
out = self.lm.model(input_ids=ids, attention_mask=am, position_ids=(am.cumsum(1) - 1).clamp(min=0), use_cache=True)
lg0 = (out.last_hidden_state[:, -1].to(self.W0.dtype) @ self.W0.t()).float().masked_fill(~self.ok0, float("-inf"))
k0 = min(K, int(self.ok0.sum()))
s0, c0 = lg0.log_softmax(-1).topk(k0, dim=1) # [B, k0]
# Level 1: copy the KV cache per beam (the k0 beams of query b are adjacent); each beam feeds only its SID token
cache = out.past_key_values
cache.batch_repeat_interleave(k0)
am1 = torch.cat([am.repeat_interleave(k0, 0), torch.ones((B * k0, 1), dtype=torch.long)], 1)
pos1 = am.sum(1, keepdim=True).repeat_interleave(k0, 0) # position of the new token = number of real tokens
h1 = self.lm.model(input_ids=(BASE_V + c0).reshape(-1, 1), attention_mask=am1, position_ids=pos1,
past_key_values=cache, use_cache=True).last_hidden_state[:, -1]
lg1 = h1.to(self.W1.dtype) @ self.W1.t() # [B*k0, V]; only the valid children's columns are used
cc = c0.reshape(-1)
lo = self.start[cc]
n = self.start[cc + 1] - lo # number of valid children per beam
r = torch.repeat_interleave(torch.arange(B * k0), n) # beam of each child
j = torch.arange(int(n.sum())) - torch.repeat_interleave(n.cumsum(0) - n, n) + lo[r] # table row of each child
x = lg1[r, self.sid1[j]].float()
mx = torch.full((B * k0,), float("-inf")).scatter_reduce(0, r, x, "amax")
lse = mx + torch.zeros(B * k0).index_add_(0, r, (x - mx[r]).exp()).log()
sc = s0.reshape(-1)[r] + x - lse[r] # sum of the two log-probabilities
off = [0] + n.view(B, k0).sum(1).cumsum(0).tolist() # children of one query are adjacent in sc
res = []
for b in range(B):
s, i = sc[off[b]:off[b + 1]].topk(min(K, off[b + 1] - off[b]))
res.append((s.numpy(), j[off[b] + i].numpy()))
return res
def search(self, qs, K=20):
"""-> one list per query of (url, log_prob, table_row), sorted by score (same format as infer_gpu.GR.search)."""
return [list(zip(self.url.take(rr), s.tolist(), rr.tolist())) for s, rr in self.search_rows(qs, K)]
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 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("--threads", type=int, default=0, help="0 = torch default")
ap.add_argument("--fp32", action="store_true", help="fp32 forward pass (default bf16, as in the GPU evaluation)")
ap.add_argument("--check", action="store_true")
a = ap.parse_args()
if a.threads:
torch.set_num_threads(a.threads)
t0 = time.time()
gr = GRCPU(a.model, dtype=torch.float32 if a.fp32 else torch.bfloat16)
print(f"Loaded {gr.n:,} candidate URLs, {torch.get_num_threads()} threads, {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()