Paya / code /paya /features.py
yeabwang's picture
Paya model bundle and revised model card
534cc76
Raw History Blame Contribute Delete
5.47 kB
"""Cache frozen FP32 features by signal, preprocessing and encoder identity."""
from pathlib import Path
import hashlib
import json
import sqlite3
import torch
from .provenance import CACHE_CONTRACT, fingerprint
from .atomic import atomic_output
def tensor_digest(values):
import hashlib
digest = hashlib.sha256()
digest.update(values.contiguous().numpy().tobytes())
return digest.hexdigest()
class FeatureCache:
def __init__(self, directory):
self.directory = Path(directory)
self.directory.mkdir(parents=True, exist_ok=True)
def get_or_encode(self, model, prepared):
binding = {"contract": CACHE_CONTRACT, "model": model.identity(),
"prepared": prepared.identity}
path = self.directory / (fingerprint(binding) + ".pt")
if path.exists():
payload = torch.load(path, map_location="cpu", weights_only=True)
if payload.get("binding") != binding:
raise ValueError("Frozen feature cache contract mismatch")
values = payload["values"]
if values.shape != (1, 512):
raise ValueError("Frozen feature cache shape mismatch")
if values.dtype != torch.float32:
raise ValueError("Frozen feature cache must preserve FP32 computation")
if not torch.isfinite(values).all():
raise ValueError("Nonfinite cached features")
if tensor_digest(values) != payload.get("digest"):
raise ValueError("Cached feature content changed")
return values.to(model.device)
encoded = model.encode(prepared)
values = encoded.cpu().float()
with atomic_output(path) as output:
torch.save({"binding": binding, "values": values,
"digest": tensor_digest(values)}, output)
return encoded
class SQLiteFeatureCache:
"""Batch native encodes and keep verified FP32 features in one durable store."""
def __init__(self, path):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
self.db = sqlite3.connect(path)
self.db.executescript('''PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
PRAGMA cache_size=-16384; PRAGMA temp_store=FILE;
CREATE TABLE IF NOT EXISTS metadata(binding TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS features(id TEXT PRIMARY KEY, values_blob BLOB, digest TEXT);''')
@torch.no_grad()
def get_many_or_encode(self, model, prepared):
identity = dict(model.identity())
identity.pop('head', None)
current = {"contract":CACHE_CONTRACT,"model":identity}
binding = json.dumps(current,sort_keys=True)
existing = self.db.execute('SELECT binding FROM metadata').fetchone()
if existing:
previous = json.loads(existing[0])
# Old stores bound head settings even though frozen features do not depend on them.
if isinstance(previous.get('model'),dict):
previous['model'].pop('head',None)
if previous != current:
raise ValueError("Frozen feature cache contract mismatch")
if not existing:
with self.db: self.db.execute('INSERT INTO metadata VALUES (?)',(binding,))
unique = {value.identity:value for value in prepared}
found = {}
keys = list(unique)
for start in range(0,len(keys),500):
chunk=keys[start:start+500]
for key,content,digest in self.db.execute(
'SELECT id,values_blob,digest FROM features WHERE id IN ('+','.join('?' for _ in chunk)+')',chunk):
if len(content)!=512*4 or hashlib.sha256(content).hexdigest()!=digest:
raise ValueError("Cached feature content changed")
values=torch.frombuffer(bytearray(content),dtype=torch.float32).clone()
if not torch.isfinite(values).all():
raise ValueError("Nonfinite cached features")
found[key]=values
shapes = {}
for key,value in unique.items():
if key not in found:
shape=tuple(model.preprocessor.format(value).samples.shape)
shapes.setdefault(shape,[]).append((key,value))
for entries in shapes.values():
encoded=model.encode_batch([value for _,value in entries]).cpu().float()
updates=[]
for (key,_),values in zip(entries,encoded):
content=values.contiguous().numpy().tobytes()
updates.append((key,content,hashlib.sha256(content).hexdigest()))
found[key]=values
with self.db:
self.db.executemany('INSERT INTO features VALUES (?,?,?)',updates)
return torch.stack([found[value.identity] for value in prepared]).to(model.device)
def get_or_encode(self, model, prepared):
return self.get_many_or_encode(model,[prepared])
def open_feature_cache(config):
if not config.get("feature_cache"):
return None
kind=config.get("feature_cache_format","files")
if kind=="sqlite": return SQLiteFeatureCache(config["feature_cache"])
if kind=="files": return FeatureCache(config["feature_cache"])
raise ValueError("feature_cache_format must be files or sqlite")