File size: 9,398 Bytes
d36c5ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# 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()