modarith-optimized / model.py
uwunion's picture
Publish optimized schedule competition artifact
409010d verified
Raw
History Blame Contribute Delete
15.5 kB
"""Scan-Register Machine submission for the Modular Arithmetic Challenge.
Two learned scan units (a carry-monoid adder and a comparison +
borrow conditional-subtract) composed by a bit-streaming double-and-add
schedule. Register width tracks limbs(p) + 1; operands are streamed as
bits, so operand size costs iterations, never width. At load time, the finite
learned cell domains are materialized into class transition / resolver tables
by running the shipped weights on every codebook cell and base-32 limb pair.
Inference then scans over those learned class IDs, and states are
argmax-discretized between steps.
No big-integer arithmetic computes the answer at inference: Python ints are
used only inside the per-argument preprocess hooks to convert each decimal
string into base-32 limbs / bits (base conversion, explicitly allowed).
The emitted digits come from the learned resolvers; randomizing the weights
collapses accuracy to chance.
"""
from __future__ import annotations
from pathlib import Path
import torch
from torch import nn
from modchallenge.interface.base_model import ModularMultiplicationModel
BASE = 32
BITS_PER_LIMB = 5
def _mlp(d_in: int, hidden: int, d_out: int) -> nn.Sequential:
return nn.Sequential(nn.Linear(d_in, hidden), nn.GELU(), nn.Linear(hidden, d_out))
def scan_tree(compose, identity, sig):
batch, n, d = sig.shape
size = 1
while size < n:
size *= 2
buf = torch.empty(batch, size, d, device=sig.device, dtype=sig.dtype)
buf[:, :n] = sig
if size > n:
buf[:, n:] = identity
stride = 1
while stride < size:
buf[:, 2 * stride - 1 :: 2 * stride] = compose(
buf[:, stride - 1 :: 2 * stride], buf[:, 2 * stride - 1 :: 2 * stride]
)
stride *= 2
total = buf[:, -1].clone()
buf[:, -1] = identity
stride = size // 2
while stride >= 1:
left = buf[:, stride - 1 :: 2 * stride].clone()
parent = buf[:, 2 * stride - 1 :: 2 * stride].clone()
buf[:, stride - 1 :: 2 * stride] = parent
buf[:, 2 * stride - 1 :: 2 * stride] = compose(parent, left)
stride //= 2
return buf[:, :n], total
def scan_tree_classes(op_table: torch.Tensor, identity: int, sig: torch.Tensor):
"""Blelloch exclusive scan over finite learned cell IDs."""
batch, n = sig.shape
size = 1
while size < n:
size *= 2
buf = torch.full((batch, size), identity, device=sig.device, dtype=torch.long)
buf[:, :n] = sig
stride = 1
while stride < size:
left = buf[:, stride - 1 :: 2 * stride]
right = buf[:, 2 * stride - 1 :: 2 * stride]
buf[:, 2 * stride - 1 :: 2 * stride] = op_table[left, right]
stride *= 2
total = buf[:, -1].clone()
buf[:, -1] = identity
stride = size // 2
while stride >= 1:
left = buf[:, stride - 1 :: 2 * stride].clone()
parent = buf[:, 2 * stride - 1 :: 2 * stride].clone()
buf[:, stride - 1 :: 2 * stride] = parent
buf[:, 2 * stride - 1 :: 2 * stride] = op_table[parent, left]
stride //= 2
return buf[:, :n], total
def reduce_total(compose, identity, sig):
"""Root of the scan tree only (for the comparison verdict)."""
x = sig
batch, _, d = sig.shape
while x.shape[1] > 1:
if x.shape[1] % 2:
x = torch.cat([x, identity.expand(batch, 1, d)], dim=1)
x = compose(x[:, 0::2], x[:, 1::2])
return x[:, 0]
class ScanAdder(nn.Module):
def __init__(self, base: int = BASE, d_emb: int = 32, d_sig: int = 16, hidden: int = 96):
super().__init__()
self.limb_emb = nn.Embedding(base, d_emb)
self.encoder = _mlp(2 * d_emb, hidden, d_sig)
self.op = _mlp(2 * d_sig, hidden, d_sig)
self.identity = nn.Parameter(torch.zeros(d_sig))
self.resolver = _mlp(2 * d_emb + d_sig, hidden, base)
self.carry_head = _mlp(d_sig, hidden, 2)
def compose(self, left, right):
return self.op(torch.cat([left, right], dim=-1))
class ModReduce(nn.Module):
def __init__(self, base: int = BASE, d_emb: int = 32, d_sig: int = 16, hidden: int = 96):
super().__init__()
self.limb_emb = nn.Embedding(base, d_emb)
self.cmp_encoder = _mlp(2 * d_emb, hidden, d_sig)
self.cmp_op = _mlp(2 * d_sig, hidden, d_sig)
self.cmp_identity = nn.Parameter(torch.zeros(d_sig))
self.borrow_encoder = _mlp(2 * d_emb, hidden, d_sig)
self.borrow_op = _mlp(2 * d_sig, hidden, d_sig)
self.borrow_identity = nn.Parameter(torch.zeros(d_sig))
self.resolver = _mlp(2 * d_emb + 2 * d_sig, hidden, base)
self.sub_head = _mlp(d_sig, hidden, 2)
self.borrow_head = _mlp(d_sig, hidden, 2)
def compose_cmp(self, left, right):
return self.cmp_op(torch.cat([left, right], dim=-1))
def compose_borrow(self, left, right):
return self.borrow_op(torch.cat([left, right], dim=-1))
def _snap(vec: torch.Tensor, codebook: torch.Tensor) -> torch.Tensor:
dist = torch.cdist(vec.reshape(-1, vec.shape[-1]), codebook)
return codebook[dist.argmin(dim=-1)].reshape(vec.shape)
def _nearest_class(vec: torch.Tensor, codebook: torch.Tensor) -> torch.Tensor:
flat = vec.reshape(-1, vec.shape[-1])
scores = (
flat.square().sum(-1, keepdim=True)
- 2 * flat @ codebook.T
+ codebook.square().sum(-1).unsqueeze(0)
)
return scores.argmin(dim=-1).reshape(vec.shape[:-1])
class ScanRegisterMachine(ModularMultiplicationModel):
"""Entry class declared in manifest.json."""
def load(self, model_dir: str) -> None:
directory = Path(model_dir)
if torch.cuda.is_available():
self.device = "cuda"
elif torch.backends.mps.is_available():
self.device = "mps"
else:
self.device = "cpu"
self.adder = ScanAdder()
self.adder.load_state_dict(
torch.load(directory / "adder.pt", map_location="cpu")
)
self.reducer = ModReduce()
self.reducer.load_state_dict(
torch.load(directory / "reducer.pt", map_location="cpu")
)
self.adder.to(self.device).eval()
self.reducer.to(self.device).eval()
codebooks = torch.load(directory / "codebooks.pt", map_location="cpu")
self.carry_cb = codebooks["carry"].to(self.device)
self.cmp_cb = codebooks["cmp"].to(self.device)
self.borrow_cb = codebooks["borrow"].to(self.device)
self.carry_identity = self.carry_cb[1] # propagate
self.cmp_identity = self.cmp_cb[1] # EQ
self.borrow_identity = self.borrow_cb[1] # propagate
self._build_tables()
torch.set_grad_enabled(False)
def _build_tables(self) -> None:
digits = torch.arange(BASE, device=self.device)
adder_emb = self.adder.limb_emb(digits)
x = digits.repeat_interleave(BASE)
y = digits.repeat(BASE)
adder_pair = torch.cat([adder_emb[x], adder_emb[y]], dim=-1)
adder_sig = self.adder.encoder(adder_pair)
self.adder_pair_class = _nearest_class(adder_sig, self.carry_cb).reshape(
BASE, BASE
)
carry_count = self.carry_cb.shape[0]
left = self.carry_cb[:, None, :].expand(carry_count, carry_count, -1)
right = self.carry_cb[None, :, :].expand(carry_count, carry_count, -1)
carry_out = self.adder.compose(
left.reshape(carry_count * carry_count, -1),
right.reshape(carry_count * carry_count, -1),
)
self.carry_op_table = _nearest_class(carry_out, self.carry_cb).reshape(
carry_count, carry_count
)
adder_pair_exp = adder_pair[:, None, :].expand(BASE * BASE, carry_count, -1)
carry_exp = self.carry_cb[None, :, :].expand(BASE * BASE, carry_count, -1)
adder_logits = self.adder.resolver(
torch.cat([adder_pair_exp, carry_exp], dim=-1).reshape(
BASE * BASE * carry_count, -1
)
)
self.adder_digit_table = adder_logits.argmax(-1).reshape(
BASE, BASE, carry_count
)
reducer_emb = self.reducer.limb_emb(digits)
reducer_pair = torch.cat([reducer_emb[x], reducer_emb[y]], dim=-1)
cmp_sig = self.reducer.cmp_encoder(reducer_pair)
self.cmp_pair_class = _nearest_class(cmp_sig, self.cmp_cb).reshape(BASE, BASE)
cmp_count = self.cmp_cb.shape[0]
left = self.cmp_cb[:, None, :].expand(cmp_count, cmp_count, -1)
right = self.cmp_cb[None, :, :].expand(cmp_count, cmp_count, -1)
cmp_out = self.reducer.compose_cmp(
left.reshape(cmp_count * cmp_count, -1),
right.reshape(cmp_count * cmp_count, -1),
)
self.cmp_op_table = _nearest_class(cmp_out, self.cmp_cb).reshape(
cmp_count, cmp_count
)
borrow_count = self.borrow_cb.shape[0]
borrow_sig = self.reducer.borrow_encoder(reducer_pair)
self.borrow_pair_class = _nearest_class(
borrow_sig, self.borrow_cb
).reshape(BASE, BASE)
left = self.borrow_cb[:, None, :].expand(
borrow_count, borrow_count, -1
)
right = self.borrow_cb[None, :, :].expand(
borrow_count, borrow_count, -1
)
borrow_out = self.reducer.compose_borrow(
left.reshape(borrow_count * borrow_count, -1),
right.reshape(borrow_count * borrow_count, -1),
)
self.borrow_op_table = _nearest_class(
borrow_out, self.borrow_cb
).reshape(borrow_count, borrow_count)
reducer_pair_exp = reducer_pair[:, None, None, :].expand(
BASE * BASE, borrow_count, cmp_count, -1
)
borrow_exp = self.borrow_cb[None, :, None, :].expand(
BASE * BASE, borrow_count, cmp_count, -1
)
verdict_exp = self.cmp_cb[None, None, :, :].expand(
BASE * BASE, borrow_count, cmp_count, -1
)
reducer_logits = self.reducer.resolver(
torch.cat([reducer_pair_exp, borrow_exp, verdict_exp], dim=-1).reshape(
BASE * BASE * borrow_count * cmp_count, -1
)
)
self.reducer_digit_table = reducer_logits.argmax(-1).reshape(
BASE, BASE, borrow_count, cmp_count
)
# -- per-argument preprocessing (base conversion only) ----------------
def preprocess_a(self, a: str) -> list[int]:
value = int(a)
bits = []
while value:
bits.append(value & 1)
value >>= 1
return list(reversed(bits)) or [0] # MSB-first
def preprocess_b(self, b: str) -> list[int]:
return self.preprocess_a(b)
def preprocess_p(self, p: str) -> list[int]:
value = int(p)
limbs = []
while value:
limbs.append(value % BASE)
value //= BASE
return limbs or [0] # LSB-first
# -- learned macro-steps ----------------------------------------------
def _add(self, x, y):
sig = self.adder_pair_class[x, y]
prefixes, _ = scan_tree_classes(self.carry_op_table, 1, sig)
return self.adder_digit_table[x, y, prefixes]
def _reduce(self, u, p_reg):
"""Learned comparison and borrow scans for conditional subtraction."""
cmp_sig = self.cmp_pair_class[u, p_reg]
# Comparison is most-significant-first, so only the total over the
# reversed limb stream is needed for the subtract/keep verdict.
_, total = scan_tree_classes(
self.cmp_op_table, 1, cmp_sig.flip(1)
)
# Borrow propagation is least-significant-first. Both its local
# classifications and its composition table come from the trained
# reducer weights; no arithmetic class mapping is supplied by Python.
borrow_sig = self.borrow_pair_class[u, p_reg]
borrow_index, _ = scan_tree_classes(
self.borrow_op_table, 1, borrow_sig
)
verdict = total.unsqueeze(1).expand_as(u)
return self.reducer_digit_table[u, p_reg, borrow_index, verdict]
# -- the machine --------------------------------------------------------
@torch.inference_mode()
def _rollout(self, group: list[tuple[list[int], list[int], list[int]]]) -> list[list[int]]:
width = max(len(p) for _, _, p in group) + 1
a_len = max(len(a) for a, _, _ in group)
b_len = max(len(b) for _, b, _ in group)
batch = len(group)
pad_bits = lambda bits, n: [0] * (n - len(bits)) + bits
a_rows = [pad_bits(a, a_len) for a, _, _ in group]
b_rows = [pad_bits(b, b_len) for _, b, _ in group]
a_has_bit = [any(row[i] for row in a_rows) for i in range(a_len)]
b_has_bit = [any(row[i] for row in b_rows) for i in range(b_len)]
a_bits = torch.tensor(
a_rows, dtype=torch.bool, device=self.device
)
b_bits = torch.tensor(
b_rows, dtype=torch.bool, device=self.device
)
p_reg = torch.tensor(
[p + [0] * (width - len(p)) for _, _, p in group],
dtype=torch.long,
device=self.device,
)
one = torch.zeros(batch, width, dtype=torch.long, device=self.device)
one[:, 0] = 1
r = torch.zeros(batch, width, dtype=torch.long, device=self.device)
started = False
for i in range(a_len): # A = a mod p
column = a_bits[:, i]
has_bit = a_has_bit[i]
if not started and not has_bit:
continue # leading zero padding with r == 0 is inert
started = True
u = self._add(r, r)
if has_bit:
u = torch.where(column.unsqueeze(1), self._add(u, one), u)
r = self._reduce(u, p_reg)
addend = r
r = torch.zeros(batch, width, dtype=torch.long, device=self.device)
started = False
for i in range(b_len): # r = (A * b) mod p
column = b_bits[:, i]
has_bit = b_has_bit[i]
if not started and not has_bit:
continue
started = True
r = self._reduce(self._add(r, r), p_reg)
if has_bit:
r_add = self._reduce(self._add(r, addend), p_reg)
r = torch.where(column.unsqueeze(1), r_add, r)
rows = r.cpu().tolist()
return [[int(d) for d in reversed(row)] for row in rows] # MSB-first
# -- interface -----------------------------------------------------------
def predict_digits(self, a_enc, b_enc, p_enc) -> list[int]:
self._build_tables()
return self._rollout([(a_enc, b_enc, p_enc)])[0]
def predict_digits_batch(self, inputs) -> list[list[int]]:
self._build_tables()
# group by size bucket so short problems don't pay for long ones
groups: dict[tuple[int, int, int], list[int]] = {}
for index, (a, b, p) in enumerate(inputs):
key = (
-(-len(p) // 8),
-(-len(a) // 64),
-(-len(b) // 64),
)
groups.setdefault(key, []).append(index)
results: list[list[int] | None] = [None] * len(inputs)
for indices in groups.values():
rows = self._rollout([inputs[i] for i in indices])
for i, row in zip(indices, rows):
results[i] = row
return results # type: ignore[return-value]
def max_batch_size(self) -> int:
return 100