203 lines
7.8 KiB
Python
203 lines
7.8 KiB
Python
|
|
"""Text-embedding cache and transient-encoder contract for Vili's resident Kimodo worker.
|
||
|
|
|
||
|
|
The worker never holds the 8B text encoder. CachedTextEncoder answers Kimodo's
|
||
|
|
encoder call from a CultCache store of float32 vectors and sends only the
|
||
|
|
missing texts to kimodo-text-encode.py, a one-shot child that loads the encoder,
|
||
|
|
encodes, writes its answer to a file and exits. Nothing here logs or raises with
|
||
|
|
a prompt in it: an error names a count, a size or a rule, never a text.
|
||
|
|
"""
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import time
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import msgpack
|
||
|
|
import torch
|
||
|
|
|
||
|
|
from cultcache_py import CultCache, SingleFileMessagePackBackingStore, define_database_entry_type, database_entry_field, leaves as t
|
||
|
|
|
||
|
|
GIB = 1024 ** 3
|
||
|
|
MIN_FREE_VRAM = 2 * GIB
|
||
|
|
GPU_RESERVE = int(3 * GIB) # headroom for activations and offloaded layers streamed through the card
|
||
|
|
CPU_RESERVE = int(1.5 * GIB)
|
||
|
|
EMBEDDING_DIM = 4096
|
||
|
|
EMBEDDING_BYTES = EMBEDDING_DIM * 4
|
||
|
|
CHILD_EXIT_GPU_BUSY = 3
|
||
|
|
CHILD_PATH = Path(__file__).with_name("kimodo-text-encode.py")
|
||
|
|
LLAMA_BASE = "meta-llama/Meta-Llama-3-8B-Instruct" # the base model the mntp adapter repo points at
|
||
|
|
|
||
|
|
|
||
|
|
class GpuBusy(RuntimeError):
|
||
|
|
"""The card has too little free VRAM to host the encoder; there is no CPU fallback."""
|
||
|
|
|
||
|
|
code = "gpu-busy"
|
||
|
|
|
||
|
|
def __init__(self, free_vram: int) -> None:
|
||
|
|
self.free_vram = free_vram
|
||
|
|
super().__init__(f"gpu-busy: {free_vram / GIB:.2f} GiB of VRAM free, {MIN_FREE_VRAM / GIB:.0f} GiB needed")
|
||
|
|
|
||
|
|
|
||
|
|
def plan_placement(free_vram: int, mem_available: int) -> dict:
|
||
|
|
"""The accelerate max_memory for one child call: free VRAM and available RAM, each less its reserve."""
|
||
|
|
if free_vram < MIN_FREE_VRAM:
|
||
|
|
raise GpuBusy(free_vram)
|
||
|
|
return {0: free_vram - GPU_RESERVE, "cpu": max(mem_available - CPU_RESERVE, 0)}
|
||
|
|
|
||
|
|
|
||
|
|
def read_snapshot_revision(hf_home: Path, repo_id: str) -> str:
|
||
|
|
"""The commit the mounted Hugging Face cache resolves a repo's main ref to."""
|
||
|
|
ref = Path(hf_home) / "hub" / ("models--" + repo_id.replace("/", "--")) / "refs" / "main"
|
||
|
|
return ref.read_text().strip()
|
||
|
|
|
||
|
|
|
||
|
|
def encoder_identity(*, revisions: dict, dtype: str, transformers_version: str, peft_version: str, kimodo_commit: str) -> str:
|
||
|
|
"""The canonical string a vector is keyed under: every input that can change it."""
|
||
|
|
parts = [f"{repo}@{revision}" for repo, revision in sorted(revisions.items())]
|
||
|
|
parts += [f"dtype={dtype}", f"transformers={transformers_version}", f"peft={peft_version}", f"kimodo={kimodo_commit}"]
|
||
|
|
return ";".join(parts)
|
||
|
|
|
||
|
|
|
||
|
|
def embedding_key(identity: str, text: str) -> str:
|
||
|
|
return hashlib.sha256((identity + "\n" + text).encode("utf-8")).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class TextEmbedding:
|
||
|
|
encoderIdentity: str
|
||
|
|
text: str
|
||
|
|
dtype: str
|
||
|
|
dim: int
|
||
|
|
vector: bytes
|
||
|
|
vectorSha256: str
|
||
|
|
gpuBytes: int
|
||
|
|
cpuBytes: int
|
||
|
|
encodeSeconds: float
|
||
|
|
createdAt: str
|
||
|
|
|
||
|
|
|
||
|
|
TEXT_EMBEDDING = define_database_entry_type(
|
||
|
|
"gamecult.vili.text_embedding.v0",
|
||
|
|
[
|
||
|
|
database_entry_field("encoderIdentity", 0, codec=t.string),
|
||
|
|
database_entry_field("text", 1, codec=t.string),
|
||
|
|
database_entry_field("dtype", 2, codec=t.string),
|
||
|
|
database_entry_field("dim", 3, codec=t.int32),
|
||
|
|
database_entry_field("vector", 4, codec=t.bytes),
|
||
|
|
database_entry_field("vectorSha256", 5, codec=t.string),
|
||
|
|
database_entry_field("gpuBytes", 6, codec=t.int64),
|
||
|
|
database_entry_field("cpuBytes", 7, codec=t.int64),
|
||
|
|
database_entry_field("encodeSeconds", 8, codec=t.float64),
|
||
|
|
database_entry_field("createdAt", 9, codec=t.string),
|
||
|
|
],
|
||
|
|
cls=TextEmbedding,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def open_store(path) -> CultCache:
|
||
|
|
cache = (
|
||
|
|
CultCache.builder()
|
||
|
|
.register_document_type(TEXT_EMBEDDING)
|
||
|
|
.add_generic_store(SingleFileMessagePackBackingStore(path))
|
||
|
|
.build()
|
||
|
|
)
|
||
|
|
cache.pull_all_backing_stores()
|
||
|
|
return cache
|
||
|
|
|
||
|
|
|
||
|
|
def run_child(texts: list, out_path, child_path=CHILD_PATH, timeout: float = 1800.0) -> dict:
|
||
|
|
"""Run the one-shot encoder child for `texts` and return its answer file. Raises GpuBusy or RuntimeError."""
|
||
|
|
process = subprocess.run(
|
||
|
|
[sys.executable, str(child_path), "--out", str(out_path)],
|
||
|
|
input=json.dumps({"texts": texts}).encode("utf-8"),
|
||
|
|
stdout=subprocess.DEVNULL,
|
||
|
|
timeout=timeout,
|
||
|
|
)
|
||
|
|
answer = msgpack.unpackb(Path(out_path).read_bytes(), raw=False) if Path(out_path).exists() else {}
|
||
|
|
if process.returncode == CHILD_EXIT_GPU_BUSY:
|
||
|
|
raise GpuBusy(int(answer.get("freeVramBytes", 0)))
|
||
|
|
if process.returncode != 0:
|
||
|
|
raise RuntimeError(f"text encoder child failed with exit code {process.returncode}")
|
||
|
|
return answer
|
||
|
|
|
||
|
|
|
||
|
|
def encode_misses_in_child(texts: list) -> dict:
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
return run_child(texts, Path(directory) / "answer.mp")
|
||
|
|
|
||
|
|
|
||
|
|
class CachedTextEncoder:
|
||
|
|
"""Kimodo's encoder contract, answered from the store; misses go to one child call per request."""
|
||
|
|
|
||
|
|
def __init__(self, store: CultCache, identity: str, encode_misses, device="cpu", dtype_name="float32") -> None:
|
||
|
|
self.store = store
|
||
|
|
self.identity = identity
|
||
|
|
self._encode_misses = encode_misses
|
||
|
|
self._device = device
|
||
|
|
self._dtype_name = dtype_name
|
||
|
|
self.reset()
|
||
|
|
|
||
|
|
def reset(self) -> None:
|
||
|
|
self.hits = 0
|
||
|
|
self.misses = 0
|
||
|
|
self.encode_seconds = 0.0
|
||
|
|
self.embeddings = []
|
||
|
|
|
||
|
|
def to(self, device):
|
||
|
|
self._device = device
|
||
|
|
return self
|
||
|
|
|
||
|
|
def eval(self):
|
||
|
|
return self
|
||
|
|
|
||
|
|
def get_device(self):
|
||
|
|
return self._device
|
||
|
|
|
||
|
|
def count(self) -> int:
|
||
|
|
return len(self.store.get_all(TEXT_EMBEDDING))
|
||
|
|
|
||
|
|
def __call__(self, texts):
|
||
|
|
single = isinstance(texts, str)
|
||
|
|
texts = [texts] if single else list(texts)
|
||
|
|
keys = [embedding_key(self.identity, text) for text in texts]
|
||
|
|
present = [self.store.get(TEXT_EMBEDDING, key) is not None for key in keys]
|
||
|
|
missing = list(dict.fromkeys(text for text, found in zip(texts, present) if not found))
|
||
|
|
self.hits += sum(present)
|
||
|
|
self.misses += len(missing)
|
||
|
|
if missing:
|
||
|
|
started = time.perf_counter()
|
||
|
|
answer = self._encode_misses(missing)
|
||
|
|
self.encode_seconds += time.perf_counter() - started
|
||
|
|
self._admit(missing, answer)
|
||
|
|
rows = []
|
||
|
|
for text, key in zip(texts, keys):
|
||
|
|
record = self.store.get_required(TEXT_EMBEDDING, key)
|
||
|
|
self.embeddings.append({"text": text, "vectorSha256": record.vectorSha256})
|
||
|
|
rows.append(torch.frombuffer(bytearray(record.vector), dtype=torch.float32))
|
||
|
|
features = torch.stack(rows)[:, None].to(self._device)
|
||
|
|
return features, [1] * len(texts)
|
||
|
|
|
||
|
|
def _admit(self, missing, answer) -> None:
|
||
|
|
vectors = answer.get("vectors", [])
|
||
|
|
if len(vectors) != len(missing) or any(len(v) != EMBEDDING_BYTES for v in vectors):
|
||
|
|
raise RuntimeError(f"text encoder child returned {len(vectors)} vectors for {len(missing)} texts, each {EMBEDDING_BYTES} bytes expected")
|
||
|
|
placement = answer.get("placement", {})
|
||
|
|
seconds = answer.get("encodeSeconds", [0.0] * len(missing))
|
||
|
|
for text, vector, took in zip(missing, vectors, seconds):
|
||
|
|
vector = bytes(vector)
|
||
|
|
self.store.put(TEXT_EMBEDDING, embedding_key(self.identity, text), TextEmbedding(
|
||
|
|
encoderIdentity=self.identity,
|
||
|
|
text=text,
|
||
|
|
dtype="float32",
|
||
|
|
dim=EMBEDDING_DIM,
|
||
|
|
vector=vector,
|
||
|
|
vectorSha256=hashlib.sha256(vector).hexdigest(),
|
||
|
|
gpuBytes=int(placement.get("gpuBytes", 0)),
|
||
|
|
cpuBytes=int(placement.get("cpuBytes", 0)),
|
||
|
|
encodeSeconds=float(took),
|
||
|
|
createdAt=datetime.now(timezone.utc).isoformat(),
|
||
|
|
))
|