File size: 10,449 Bytes
ad474c9 | 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 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 | """Wide slice gates: several bits of adder per stage.
The 1-bit gate in :mod:`hdc.adder` is 8 rows and 32 stages. A gate that
handles ``k`` bits at once is ``2**(2k+1)`` rows and ``ceil(32/k)`` stages, so
the trade is explicit:
k=1 8 rows 32 stages
k=2 32 rows 16 stages
k=4 512 rows 8 stages
k=8 131072 rows 4 stages
Everything here is content-addressable in the same sense as the 1-bit gate: a
stage's inputs select a row *by vector resemblance*, and the row's outputs are
recovered by unbinding fields and cleaning them up against the gate atoms.
Two design points that are easy to get wrong
--------------------------------------------
**A lazy table.** 131 072 rows at d=10 000 would be 164 MB of vectors, and
enumerating them costs ~1.6 s of XOR folding before the first addition. Since
a 32-bit add only ever touches 4 byte-rows, the table is materialised *on
demand*: each visited row is composed, decoded and written into storage once,
then cached. The worst case is still complete; the common case is 4 rows.
**Exact keys, similarity fallback.** A gate key is an exact XOR of
slot-and-position atoms, so it is looked up in a dictionary — a 131 072-row
similarity scan would cost more than the 32-stage ripple it replaces. The
fuzzy path is still there (:meth:`SliceGate.resolve_similar`) because a
damaged key should degrade to "the nearest row" rather than fail; it is just
not on the hot path. Any row resolution therefore reports a confidence, and
an exact hit reports 1.0.
Why the key does not collapse
-----------------------------
:mod:`hdc.adder` documents the parity bug: XORing a *shared* zero/one pair
loses everything but parity, so ``1^1^1`` and ``1^0^0`` collide. Here every
(slot, position, value) triple has its own atom, so the key is an XOR of
distinct vectors encoding all ``2k+1`` inputs, and the 2**(2k+1) keys stay
distinct.
"""
from __future__ import annotations
from dataclasses import dataclass
from .hypervec import (
CODEC_THRESHOLD,
DEFAULT_DIM,
bind,
bundle,
hvc,
role,
sim,
)
from .memory import HDCStorage
#: Above this many rows a gate is built lazily instead of eagerly.
EAGER_MAX_ROWS = 4096
@dataclass(frozen=True)
class SliceStep:
"""One multi-bit slice of the carry chain."""
index: int
a: int
b: int
carry_in: int
total: int
carry_out: int
confidence: float
radix: int
def render(self) -> str:
mask = (1 << self.radix) - 1
return (
"slice %2d..%-2d a=0x%0*x b=0x%0*x cin=%d -> sum=0x%0*x cout=%d"
" (lookup sim %.3f)"
% (
self.index,
self.index + self.radix - 1,
self.radix // 4 or 1,
self.a & mask,
self.radix // 4 or 1,
self.b & mask,
self.carry_in,
self.radix // 4 or 1,
self.total & mask,
self.carry_out,
self.confidence,
)
)
@dataclass(frozen=True)
class SliceRow:
"""One materialised gate row."""
a: int
b: int
carry_in: int
total: int
carry_out: int
key: int
cell: str
def render(self) -> str:
return "a=0x%x b=0x%x cin=%d -> sum=0x%x cout=%d %s" % (
self.a, self.b, self.carry_in, self.total, self.carry_out, self.cell
)
class SliceGate:
"""A ``k``-bit adder slice whose truth table lives in HDC storage."""
def __init__(
self,
radix: int = 8,
d: int = DEFAULT_DIM,
storage: HDCStorage | None = None,
threshold: float = CODEC_THRESHOLD,
eager: bool | None = None,
):
if not 1 <= radix <= 8:
raise ValueError("radix must be between 1 and 8 bits")
self.radix = radix
self.d = d
self.threshold = threshold
self.row_count = 1 << (2 * radix + 1)
self.eager = (self.row_count <= EAGER_MAX_ROWS) if eager is None else eager
self.storage = storage if storage is not None else HDCStorage(
d=d, capacity=max(64, self.row_count if self.eager else 4096),
label="gate%d" % radix,
)
#: One atom per (slot, position, value): this is what keeps keys distinct.
self.a_atoms = {(i, b): hvc("gate/s%d/a/%d/%d" % (radix, i, b), d)
for i in range(radix) for b in (0, 1)}
self.b_atoms = {(i, b): hvc("gate/s%d/b/%d/%d" % (radix, i, b), d)
for i in range(radix) for b in (0, 1)}
self.cin_atoms = {b: hvc("gate/s%d/cin/%d" % (radix, b), d) for b in (0, 1)}
self.cout_atoms = {b: hvc("gate/s%d/cout/%d" % (radix, b), d) for b in (0, 1)}
self.sum_atoms = {(i, b): hvc("gate/s%d/sum/%d/%d" % (radix, i, b), d)
for i in range(radix) for b in (0, 1)}
self.rows: dict[int, SliceRow] = {}
self.lookups = 0
self.materialised = 0
self.similarity_fallbacks = 0
if self.eager:
self._build_all()
# ------------------------------------------------------------- the gate
def key_of(self, a: int, b: int, c: int) -> int:
"""Exact gate address: XOR of every slot/position/value atom."""
key = 0
for i in range(self.radix):
key ^= self.a_atoms[(i, (a >> i) & 1)]
for i in range(self.radix):
key ^= self.b_atoms[(i, (b >> i) & 1)]
key ^= self.cin_atoms[1 if c else 0]
return key & ((1 << self.d) - 1)
def _cell_name(self, a: int, b: int, c: int) -> str:
width = (2 * self.radix + 1 + 3) // 4
return "adder/s%d/%0*x" % (self.radix, width, (c << (2 * self.radix)) | (b << self.radix) | a)
def _build_row(self, a: int, b: int, c: int) -> SliceRow:
"""Compose the row's record, write it to storage, and memoise it."""
mask = (1 << self.radix) - 1
total = (a + b + c) & mask
carry = (a + b + c) >> self.radix
key = self.key_of(a, b, c)
fields = [
bind(role("key", self.d), key, self.d),
bind(role("carry", self.d), self.cout_atoms[carry], self.d),
]
for i in range(self.radix):
fields.append(
bind(role("sum%d" % i, self.d), self.sum_atoms[(i, (total >> i) & 1)], self.d)
)
record = bundle(fields, self.d)
cell = self._cell_name(a, b, c)
self.storage.write(cell, record, symbol=True)
row = SliceRow(a, b, c, total, carry, key, cell)
self.rows[key] = row
self.materialised += 1
return row
def _build_all(self) -> None:
mask = (1 << self.radix) - 1
for c in (0, 1):
for b in range(mask + 1):
for a in range(mask + 1):
self._build_row(a, b, c)
def resolve(self, a: int, b: int, c: int) -> tuple[SliceRow, float]:
"""Look up the row for these inputs; materialise it on first use."""
self.lookups += 1
key = self.key_of(a, b, c)
row = self.rows.get(key)
if row is not None:
return row, 1.0
return self._build_row(a, b, c), 1.0
def resolve_similar(self, a: int, b: int, c: int) -> tuple[SliceRow, float]:
"""Content-addressable fallback: nearest materialised row.
Only useful when a key arrives damaged (or comes from outside this
gate). Exact keys resolve at 1.0 through :meth:`resolve`; this path
reports the similarity it actually achieved.
"""
key = self.key_of(a, b, c)
if key in self.rows:
return self.rows[key], 1.0
best_row, best = None, -2.0
for candidate, row in self.rows.items():
s = sim(candidate, key, self.d)
if s > best:
best, best_row = s, row
if best_row is None:
self.similarity_fallbacks += 1
return self._build_row(a, b, c), 0.0
self.similarity_fallbacks += 1
return best_row, best
# ------------------------------------------------------------- evaluate
def evaluate(self, a: int, b: int, c: int) -> tuple[int, int, float]:
"""Run one slice: resolve the row, then read its fields back out."""
row, key_confidence = self.resolve(a, b, c)
record = self.storage.read(row.cell)
carry, carry_margin = self._read_bit(
bind(record, role("carry", self.d), self.d), self.cout_atoms
)
total = 0
margins = [carry_margin]
for i in range(self.radix):
bit, margin = self._read_bit(
bind(record, role("sum%d" % i, self.d), self.d),
{0: self.sum_atoms[(i, 0)], 1: self.sum_atoms[(i, 1)]},
)
total |= bit << i
margins.append(margin)
if (row.total, row.carry_out) != (total, carry):
raise AssertionError(
"gate row read back as %s but was built as %s"
% ((total, carry), (row.total, row.carry_out))
)
return total, carry, min(min(margins), key_confidence)
def _read_bit(self, probe: int, atoms: dict) -> tuple[int, float]:
s0 = sim(probe, atoms[0], self.d)
s1 = sim(probe, atoms[1], self.d)
if s1 >= s0:
return 1, s1 - s0
return 0, s0 - s1
# ---------------------------------------------------------------- views
def summary(self) -> dict:
return {
"radix": self.radix,
"stages_for_32": (32 + self.radix - 1) // self.radix,
"table_rows": self.row_count,
"materialised": len(self.rows),
"eager": self.eager,
"lookups": self.lookups,
"similarity_fallbacks": self.similarity_fallbacks,
"cells": len(self.storage),
}
def table_text(self, limit: int = 12) -> str:
head = "gate radix=%d: %d rows total, %d materialised%s" % (
self.radix,
self.row_count,
len(self.rows),
" (eager)" if self.eager else " (lazy)",
)
rows = [head]
for row in list(self.rows.values())[:limit]:
rows.append(" " + row.render())
if len(self.rows) > limit:
rows.append(" ... %d more rows" % (len(self.rows) - limit))
return "\n".join(rows)
|