Ctrl K
ComunidadGuías…

cortexm/accel.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Opportunistic Rust acceleration.

The Python/NumPy implementation is the REFERENCE and always works. If the
compiled wheels are installed (`pip install ./rust/cortexm-core` and
`pip install ./rust/quadrant`), the hot paths route through them:

* ``hashing.h64``          — keyed BLAKE2b (byte-exact parity, tested)
* ``vsa.bind/unbind``      — permutation gather with injected perms
* ``vsa.encode_fact``      — fused bind×3 + bundle + lexical mix + norm
* ``slb``                  — L1-resident lookaside buffer
* ``quadrant``             — page-clustered log-depth vector index

Design rule: the Rust side never GENERATES randomness — permutations and
role vectors are injected from Python's deterministic VSA state, so a
mixed Python/Rust deployment produces bit-identical holograms. Every
accelerated path has a NumPy fallback and a parity test
(``tests/test_rust_accel.py``).
"""

from __future__ import annotations

import os
from typing import Any

# --- optional wheels -------------------------------------------------------
try:                                            # pragma: no cover
    import cortexm_core as _core               # type: ignore
except Exception:                               # pragma: no cover
    _core = None

try:                                            # pragma: no cover
    import quadrant as _quadrant               # type: ignore
except Exception:                               # pragma: no cover
    _quadrant = None

RUST_AVAILABLE = _core is not None
QUADRANT_AVAILABLE = _quadrant is not None
_env = os.environ.get("CONTEXTM_RUST", "auto")
RUST_ENABLED = (_env == "1") or (_env == "auto" and RUST_AVAILABLE)
QUADRANT_ENABLED = (_env == "1") or (_env == "auto" and QUADRANT_AVAILABLE)


def rust_status() -> dict[str, Any]:
    return {
        "cortexm_core": bool(RUST_AVAILABLE and RUST_ENABLED),
        "quadrant": bool(QUADRANT_AVAILABLE and QUADRANT_ENABLED),
        "env": _env,
        "hint": ("" if RUST_AVAILABLE else
                 "build with: pip install ./rust/cortexm-core "
                 "./rust/quadrant (requires cargo + maturin)"),
    }


# --- hashing ---------------------------------------------------------------
if RUST_AVAILABLE and RUST_ENABLED:
    def h64(feature: str, seed: int = 0) -> int:
        return _core.h64(feature, seed)
else:                                           # pragma: no cover
    from cortexm.util import h64  # noqa: F401  (NumPy reference)


# --- VSA acceleration wrapper ----------------------------------------------
class RustVSA:
    """Binds a Python VSA to compiled bind/unbind/encode_fact.

    The authoritative perms live in the Python VSA; Rust receives them
    once via set_perm and accelerates the per-fact gathers afterwards.
    """

    def __init__(self, vsa) -> None:
        if not (RUST_AVAILABLE and RUST_ENABLED):
            raise RuntimeError("rust acceleration disabled or unavailable")
        self.vsa = vsa
        self._pb = _core.PermBindings(vsa.dims)
        self._injected: set[str] = set()

    def _inject(self, role: str) -> None:
        if role not in self._injected:
            self._pb.set_perm(role, self.vsa.perm(role).tolist())
            self._injected.add(role)

    def bind(self, role: str, filler):
        import numpy as np
        self._inject(role)
        f = np.ascontiguousarray(filler, dtype=np.float32)
        return self._pb.bind(role, f)

    def unbind(self, role: str, h):
        import numpy as np
        self._inject(role)
        hv = np.ascontiguousarray(h, dtype=np.float32)
        return self._pb.unbind(role, hv)

    def encode_fact(self, s_vec, r_vec, v_vec):
        import numpy as np
        for role in ("S", "R", "V"):
            self._inject(role)
        return self._pb.encode_fact(
            np.ascontiguousarray(s_vec, dtype=np.float32),
            np.ascontiguousarray(r_vec, dtype=np.float32),
            np.ascontiguousarray(v_vec, dtype=np.float32),
            self.vsa.lam)


# --- quadrant index wrapper --------------------------------------------------
class QuadrantANN:
    """Page-clustered log-depth index over holograms (approximate top-k).

    Falls back to exact numpy when the wheel is absent — callers get the
    same interface either way.
    """

    def __init__(self, vectors, page_capacity: int = 64) -> None:
        import numpy as np
        self.vectors = np.ascontiguousarray(vectors, dtype=np.float32)
        if QUADRANT_AVAILABLE and QUADRANT_ENABLED:
            self._idx = _quadrant.QuadrantIndex.build(
                self.vectors, page_capacity)
            self._exact = False
        else:                                    # pragma: no cover
            self._idx = None
            self._exact = True

    def search(self, query, k: int = 10, max_leaves: int = 16):
        import numpy as np
        q = np.ascontiguousarray(query, dtype=np.float32)
        if self._exact:                          # pragma: no cover
            sims = self.vectors @ q
            part = np.argpartition(-sims, k)[:k]
            order = part[np.argsort(-sims[part])]
            return order.tolist(), sims[order].tolist()
        ids, scores = self._idx.search(q, k, max_leaves)
        return list(ids), list(scores)

    def stats(self) -> dict:
        if self._exact:                          # pragma: no cover
            n = self.vectors.shape[0]
            return {"n_vectors": n, "mode": "exact-numpy",
                    "note": "quadrant wheel not installed"}
        return {"mode": "quadrant", "detail": self._idx.stats()}


# --- explicit binary/FP32 tiering ------------------------------------------
# User concern (architectural fix #1): "Binary HRR + FFT doesn't work →
# Separate tiers: binary for edge, FP32 for cloud."
# The codec stack already supports both (binary/RaBitQ for edge, PQ/INT8
# for cloud); this makes the routing EXPLICIT based on deployment tier.

EDGE_CODECS = ("binary", "rabitq")   # 96 B/v — fits on Raspberry Pi 5
CLOUD_CODECS = ("pq", "int8")        # 8 B/v or 770 B/v — bandwidth-dense


def detect_tier() -> str:
    """Auto-detect deployment tier from environment signals.

    Returns 'edge' or 'cloud'. Override via CONTEXTM_TIER env var.
    """
    explicit = os.environ.get("CONTEXTM_TIER", "").lower()
    if explicit in ("edge", "cloud"):
        return explicit
    # heuristic: cloud = multi-core + >4GB RAM + has scipy
    try:
        import multiprocessing
        if multiprocessing.cpu_count() >= 4:
            import os as _os
            mem = _os.sysconf("SC_PAGE_SIZE") * _os.sysconf("SC_PHYS_PAGES")
            if mem >= 4 * 1024**3:
                return "cloud"
    except Exception:
        pass
    return "edge"


def recommend_codec(tier: str | None = None, tmr: bool = True) -> str:
    """Recommend a codec for the deployment tier.

    Edge: binary (with TMR if self-healing required) — 96 B/v.
    Cloud: pq for bandwidth-dense, int8 for accuracy-critical.
    """
    t = tier or detect_tier()
    if t == "edge":
        return "binary" if tmr else "rabitq"
    return "pq"


def tier_status() -> dict:
    """Report current tier + recommended + active codec info."""
    tier = detect_tier()
    rec = recommend_codec(tier)
    return {
        "tier": tier,
        "recommended_codec": rec,
        "edge_codecs": list(EDGE_CODECS),
        "cloud_codecs": list(CLOUD_CODECS),
        "rust_enabled": RUST_ENABLED,
        "quadrant_enabled": QUADRANT_ENABLED,
        "note": (f"Auto-detected {tier} tier; recommend '{rec}' codec. "
                 f"Override with CONTEXTM_TIER=edge|cloud"),
    }


# --- SIMD kernel wrappers (Task 6-simd) ------------------------------------
# The Rust crate exposes `dot / dot_i8_f32 / cosine / l2_sq /
# batch_dot / batch_dot_i8 / topk / argmax` — each is a thin pyo3
# wrapper around the runtime-dispatched kernels in `rust/cortexm-core/
# src/simd.rs` (AVX-512 → AVX2+FMA → NEON → scalar). When the wheel is
# absent we fall back to numpy so callers always get a working answer.
#
# Design rule (mirrors RustVSA): the kernels NEVER allocate randomness
# and produce bit-compatible results across the Rust / NumPy paths (≤1e-5
# FP32 noise, asserted by `tests/test_rust_accel.py::TestSimdKernels`).
# Use the free-function form (`accel.cosine(a, b)`) for one-off queries;
# use the class form (`accel.SimdKernels().batch_dot(...)`) when you
# want to gate behaviour on `RUST_ENABLED` without re-checking globals.


def _as_f32(x) -> "np.ndarray":
    import numpy as np
    return np.ascontiguousarray(x, dtype=np.float32)


def dot(a, b) -> float:
    """SIMD dot product. NumPy fallback: `np.dot(a, b)`."""
    if RUST_AVAILABLE and RUST_ENABLED:
        import numpy as np
        return float(_core.dot(np.ascontiguousarray(a, dtype=np.float32),
                                np.ascontiguousarray(b, dtype=np.float32)))
    import numpy as np
    return float(np.dot(_as_f32(a), _as_f32(b)))


def dot_i8_f32(q8, q) -> float:
    """INT8 × f32 dot product. NumPy fallback casts int8 → f32 first."""
    if RUST_AVAILABLE and RUST_ENABLED:
        import numpy as np
        return float(_core.dot_i8_f32(
            np.ascontiguousarray(q8, dtype=np.int8),
            np.ascontiguousarray(q, dtype=np.float32)))
    import numpy as np
    q8 = np.ascontiguousarray(q8, dtype=np.int8)
    return float((q8.astype(np.float32) @ _as_f32(q)))


def cosine(a, b) -> float:
    """Cosine similarity via SIMD-accelerated dot + L2 norms.

    Identical vectors return exactly 1.0 (bit-exact through the
    SIMD self-dot path); distinct vectors agree with numpy's
    `a @ b / (|a|·|b|)` to within 1e-5 (FP32 lane-reduction noise).
    """
    if RUST_AVAILABLE and RUST_ENABLED:
        import numpy as np
        return float(_core.cosine(
            np.ascontiguousarray(a, dtype=np.float32),
            np.ascontiguousarray(b, dtype=np.float32)))
    import numpy as np
    a = _as_f32(a)
    b = _as_f32(b)
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-12))


def l2_sq(a, b) -> float:
    """Squared L2 distance `sum((a-b)**2)`. 0.0 for identical vectors."""
    if RUST_AVAILABLE and RUST_ENABLED:
        import numpy as np
        return float(_core.l2_sq(
            np.ascontiguousarray(a, dtype=np.float32),
            np.ascontiguousarray(b, dtype=np.float32)))
    import numpy as np
    d = _as_f32(a) - _as_f32(b)
    return float(d @ d)


def batch_dot(rows, q, n_rows: int, dims: int) -> list[float]:
    """Matrix-vector product over a flat `[n_rows × dims]` f32 slice.

    Far more cache-friendly than calling `dot()` per row from Python —
    one boundary crossing for the whole batch.  Returns a plain list so
    callers can `np.asarray(...)` if they want a contiguous array.
    """
    if RUST_AVAILABLE and RUST_ENABLED:
        import numpy as np
        r = np.ascontiguousarray(rows, dtype=np.float32).reshape(-1)
        q = np.ascontiguousarray(q, dtype=np.float32)
        return list(_core.batch_dot(r, q, n_rows, dims))
    import numpy as np
    r = np.asarray(rows, dtype=np.float32).reshape(n_rows, dims)
    q = _as_f32(q)
    return (r @ q).tolist()


def batch_dot_i8(packed, q, n_rows: int, dims: int) -> list[float]:
    """INT8-packed rows × f32 query.  Raw int8·f32 dot products — callers
    apply per-row scales (the codec's aux array) themselves."""
    if RUST_AVAILABLE and RUST_ENABLED:
        import numpy as np
        p = np.ascontiguousarray(packed, dtype=np.int8).reshape(-1)
        q = np.ascontiguousarray(q, dtype=np.float32)
        return list(_core.batch_dot_i8(p, q, n_rows, dims))
    import numpy as np
    p = np.asarray(packed, dtype=np.int8).reshape(n_rows, dims)
    q = _as_f32(q)
    return (p.astype(np.float32) @ q).tolist()


def topk(scores, k: int) -> list[tuple[int, float]]:
    """Top-`k` (idx, score) tuples in descending order. O(N) via
    `select_nth_unstable`, then O(k log k) for the prefix sort."""
    if RUST_AVAILABLE and RUST_ENABLED:
        import numpy as np
        s = np.ascontiguousarray(scores, dtype=np.float32)
        return [(int(i), float(v)) for i, v in _core.topk(s, k)]
    import numpy as np
    s = np.asarray(scores, dtype=np.float32)
    k = max(0, min(k, s.size))
    if k == 0:
        return []
    part = np.argpartition(-s, k - 1)[:k]
    part = part[np.argsort(-s[part])]
    return [(int(i), float(s[i])) for i in part]


def argmax(scores) -> tuple[int, float]:
    """Return (idx, value) of the max element. Ties → first occurrence."""
    if RUST_AVAILABLE and RUST_ENABLED:
        import numpy as np
        s = np.ascontiguousarray(scores, dtype=np.float32)
        i, v = _core.argmax(s)
        return (int(i), float(v))
    import numpy as np
    s = np.asarray(scores, dtype=np.float32)
    if s.size == 0:
        return (0, 0.0)
    i = int(np.argmax(s))
    return (i, float(s[i]))


class SimdKernels:
    """Container class that mirrors the Rust kernel surface so callers
    can wire `accel.SimdKernels()` once and dispatch uniformly. The
    NumPy fallbacks produce bit-identical results within 1e-5 (FP32
    lane-reduction noise, asserted by parity tests).

    Mirrors the existing `RustVSA` pattern: the constructor raises
    `RuntimeError` if Rust is explicitly disabled (`CONTEXTM_RUST=0`)
    — when Rust is *available* but the user opted out, callers should
    use the free functions above instead.
    """

    def __init__(self) -> None:
        if not (RUST_AVAILABLE and RUST_ENABLED):
            raise RuntimeError(
                "SimdKernels requires Rust acceleration enabled "
                "(CONTEXTM_RUST=auto or =1, and the cortexm_core wheel built)")

    def dot(self, a, b) -> float:
        import numpy as np
        return float(_core.dot(
            np.ascontiguousarray(a, dtype=np.float32),
            np.ascontiguousarray(b, dtype=np.float32)))

    def dot_i8_f32(self, q8, q) -> float:
        import numpy as np
        return float(_core.dot_i8_f32(
            np.ascontiguousarray(q8, dtype=np.int8),
            np.ascontiguousarray(q, dtype=np.float32)))

    def cosine(self, a, b) -> float:
        import numpy as np
        return float(_core.cosine(
            np.ascontiguousarray(a, dtype=np.float32),
            np.ascontiguousarray(b, dtype=np.float32)))

    def l2_sq(self, a, b) -> float:
        import numpy as np
        return float(_core.l2_sq(
            np.ascontiguousarray(a, dtype=np.float32),
            np.ascontiguousarray(b, dtype=np.float32)))

    def batch_dot(self, rows, q, n_rows: int, dims: int) -> list[float]:
        import numpy as np
        r = np.ascontiguousarray(rows, dtype=np.float32).reshape(-1)
        q = np.ascontiguousarray(q, dtype=np.float32)
        return list(_core.batch_dot(r, q, n_rows, dims))

    def batch_dot_i8(self, packed, q, n_rows: int,
                      dims: int) -> list[float]:
        import numpy as np
        p = np.ascontiguousarray(packed, dtype=np.int8).reshape(-1)
        q = np.ascontiguousarray(q, dtype=np.float32)
        return list(_core.batch_dot_i8(p, q, n_rows, dims))

    def topk(self, scores, k: int) -> list[tuple[int, float]]:
        import numpy as np
        s = np.ascontiguousarray(scores, dtype=np.float32)
        return [(int(i), float(v)) for i, v in _core.topk(s, k)]

    def argmax(self, scores) -> tuple[int, float]:
        import numpy as np
        s = np.ascontiguousarray(scores, dtype=np.float32)
        i, v = _core.argmax(s)
        return (int(i), float(v))