mod-arithmetic / model.py
mikelou's picture
Add faster compact NeuralHorner v8 prototype
efe8193 verified
Raw
History Blame Contribute Delete
6.36 kB
"""Smaller, lower-compute inference wrapper for NeuralHorner v8.
The learned transition is unchanged. Compared with the published wrapper:
* checkpoint tensors may be stored in bfloat16 and are restored to float32;
* logits are thresholded at zero (exactly equivalent to sigmoid(logit) > 0.5);
* only one operand is reduced before multiplication. The other operand is
streamed directly through the same Horner transition, eliminating a full
modulus-width recurrent pass.
"""
from __future__ import annotations
from pathlib import Path
import torch
from torch import nn
from modchallenge.interface.base_model import ModularMultiplicationModel
_MASK32 = (1 << 32) - 1
def _to_bits_small(vals: torch.Tensor, width: int) -> torch.Tensor:
shifts = torch.arange(width - 1, -1, -1, device=vals.device)
return (vals[:, None] >> shifts[None, :]) & 1
def to_bits_limbs(ints, dev, width: int) -> torch.Tensor:
nl = (width + 31) // 32
cols = []
for k in range(nl - 1, -1, -1):
limb = torch.tensor(
[(v >> (32 * k)) & _MASK32 for v in ints],
dtype=torch.int64,
device=dev,
)
cols.append(_to_bits_small(limb, 32))
bits = torch.cat(cols, dim=1)
return bits[:, nl * 32 - width:] if width < nl * 32 else bits
class Cell(nn.Module):
def __init__(self, dmodel: int = 96, hidden: int = 128):
super().__init__()
self.in_proj = nn.Linear(3, dmodel)
self.d_emb = nn.Embedding(2, dmodel)
self.gru = nn.GRU(
dmodel,
hidden,
num_layers=2,
batch_first=True,
bidirectional=True,
)
self.head = nn.Linear(2 * hidden, 1)
def forward(self, feat, d):
x = self.in_proj(feat) + self.d_emb(d)[:, None, :]
h, _ = self.gru(x)
return self.head(h).squeeze(-1)
def _bits_of(n: int) -> list[int]:
if n <= 0:
return [0]
out: list[int] = []
while n > 0:
out.append(n & 1)
n >>= 1
out.reverse()
return out
class BitSerialReducer(ModularMultiplicationModel):
def __init__(self) -> None:
self.model: Cell | None = None
self.device: torch.device | None = None
self.L = 32
self._Leff = 32
def load(self, model_dir: str) -> None:
if torch.cuda.is_available():
self.device = torch.device("cuda")
elif torch.backends.mps.is_available():
self.device = torch.device("mps")
else:
self.device = torch.device("cpu")
ckpt = torch.load(
Path(model_dir) / "weights.pt",
map_location="cpu",
weights_only=True,
)
self.L = int(ckpt.get("L", 32))
self.model = Cell(**ckpt.get("config", {}))
# load_state_dict casts compact bf16 checkpoint tensors back to fp32.
self.model.load_state_dict(ckpt["state_dict"])
self.model.to(self.device)
self.model.eval()
self.model.gru.flatten_parameters()
def preprocess_a(self, a):
return _bits_of(int(a))
def preprocess_b(self, b):
return _bits_of(int(b))
def preprocess_p(self, p):
return int(p)
@torch.inference_mode()
def predict_digits(self, a_enc, b_enc, p_enc):
return self.predict_digits_batch([(a_enc, b_enc, p_enc)])[0]
@torch.inference_mode()
def predict_digits_batch(self, inputs):
L = self.L
max_op = 4 * L
out: list[list[int]] = [[0] for _ in inputs]
idx, a_lists, b_lists, p_vals = [], [], [], []
for i, (a_enc, b_enc, p_enc) in enumerate(inputs):
p = int(p_enc)
a_bits = list(a_enc)
b_bits = list(b_enc)
if p < 2 or p >= (1 << L) or len(a_bits) > max_op or len(b_bits) > max_op:
continue
idx.append(i)
a_lists.append(a_bits)
b_lists.append(b_bits)
p_vals.append(p)
if not idx:
return out
dev = self.device
maxp = max(int(p).bit_length() for p in p_vals)
self._Leff = min(self.L, max(32, ((maxp + 31) // 32) * 32))
p_bits = to_bits_limbs(p_vals, dev, self._Leff).float()
# (a*b) mod p = ((a mod p)*b) mod p. Streaming the original b bits
# through the learned Horner cell avoids first reducing b and then
# scanning its L-bit residue a second time.
ra = self._reduce(a_lists, p_bits, dev)
prod = self._scan(b_lists, ra, p_bits, dev)
prod_list = prod.long().tolist()
for j, i in enumerate(idx):
out[i] = [int(x) for x in prod_list[j]]
return out
def max_batch_size(self) -> int:
return 256
def _step(self, s_bits, feat, d):
# The multiplicand and modulus channels stay constant for an entire
# scan. Reuse their preallocated feature tensor instead of rebuilding
# and copying all three channels at every recurrent step.
feat[:, :, 0].copy_(s_bits)
if self.device is not None and self.device.type == "cuda":
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
logits = self.model(feat, d)
# Comparing a bf16 value with zero has the same sign decision as
# first widening it to fp32, without allocating the fp32 logits.
return (logits > 0).float()
return (self.model(feat, d) > 0).float()
def _scan(self, bit_lists, x_bits, p_bits, dev):
n = len(bit_lists)
width = max(len(bits) for bits in bit_lists)
padded = torch.zeros((n, width), dtype=torch.long, device=dev)
for row, bits in enumerate(bit_lists):
if bits:
padded[row, width - len(bits):] = torch.tensor(
bits, dtype=torch.long, device=dev
)
state = torch.zeros((n, self._Leff), device=dev)
feat = torch.empty((n, self._Leff, 3), device=dev)
feat[:, :, 1].copy_(x_bits)
feat[:, :, 2].copy_(p_bits)
for pos in range(width):
state = self._step(state, feat, padded[:, pos])
return state
def _reduce(self, bit_lists, p_bits, dev):
ones = to_bits_limbs([1] * len(bit_lists), dev, self._Leff).float()
return self._scan(bit_lists, ones, p_bits, dev)