Download model-3/src/encoder.py from Jyo-K/PoPTard5: direct link, hf CLI and curl.
- Browser
- Download file 7.43 kB
-
https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-3/src/encoder.py
- Command line
-
hf download hf://Jyo-K/PoPTard5/model-3/src/encoder.py
-
curl -L -o encoder.py https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-3/src/encoder.py
7.43 kB
| """Resumable, fingerprint-validated embedding cache. | |
| Why this file exists | |
| -------------------- | |
| model-2 lost two multi-hour runs with nothing recoverable: | |
| * `encode_texts` streamed into ONE `<name>.npy.tmp.npy` and only promoted it to | |
| the final name after the very last chunk. A kill at 95% left 21 GB of garbage | |
| and the next run started from row 0. | |
| * The cache was accepted whenever `emb.shape[0] == len(texts)`. Change the model, | |
| the prefix or the text format and you silently reuse the wrong vectors. | |
| Here: | |
| * rows are written in shards of EMB["shard_rows"], each promoted atomically; | |
| * a kill costs at most one shard, and the next run resumes at the next shard; | |
| * `meta.json` fingerprints model id, prefix, max_len, dim, row count and a | |
| SHA-256 of the entity_id list, so a stale cache is REJECTED, never reused. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import os | |
| import time | |
| import numpy as np | |
| def _sha_ids(ids) -> str: | |
| h = hashlib.sha256() | |
| for i in ids: | |
| h.update(str(i).encode()) | |
| h.update(b"\n") | |
| return h.hexdigest() | |
| def fingerprint(hf_id, prefix, max_len, dim, ids) -> dict: | |
| return {"model": hf_id, "prefix": prefix, "max_len": int(max_len), "dim": int(dim), | |
| "n": len(ids), "ids_sha256": _sha_ids(ids)} | |
| def _shard_path(d, i): | |
| return os.path.join(d, f"shard_{i:05d}.npy") | |
| def _meta_path(d): | |
| return os.path.join(d, "meta.json") | |
| def cache_state(d: str, fp: dict, shard_rows: int): | |
| """-> (n_shards_total, n_shards_done, reason_if_invalid).""" | |
| n_total = (fp["n"] + shard_rows - 1) // shard_rows | |
| mp = _meta_path(d) | |
| if not os.path.exists(mp): | |
| return n_total, 0, None | |
| try: | |
| old = json.load(open(mp)) | |
| except Exception: | |
| return n_total, 0, "unreadable meta.json" | |
| for k in ("model", "prefix", "max_len", "dim", "n", "ids_sha256"): | |
| if old.get(k) != fp[k]: | |
| return n_total, 0, f"{k} changed ({old.get(k)!r} -> {fp[k]!r})" | |
| if old.get("shard_rows") != shard_rows: | |
| return n_total, 0, f"shard_rows changed ({old.get('shard_rows')} -> {shard_rows})" | |
| done = 0 | |
| for i in range(n_total): | |
| if os.path.exists(_shard_path(d, i)): | |
| done += 1 | |
| else: | |
| break | |
| return n_total, done, None | |
| class Encoder: | |
| """Thin wrapper over sentence-transformers with an explicit device.""" | |
| def __init__(self, hf_id, device, max_len, batch, fp16=True): | |
| import torch | |
| from sentence_transformers import SentenceTransformer | |
| self.torch = torch | |
| kw = {} | |
| if fp16 and str(device).startswith("cuda"): | |
| kw["model_kwargs"] = {"torch_dtype": torch.float16} | |
| self.model = SentenceTransformer(hf_id, device=device, **kw) | |
| try: | |
| self.model.max_seq_length = max_len | |
| except AttributeError: | |
| pass # StaticEmbedding has no sequence limit | |
| self.device = device | |
| self.batch = batch | |
| self.dim = self.model.get_sentence_embedding_dimension() | |
| def encode(self, texts, prefix): | |
| return self.model.encode([prefix + t for t in texts], batch_size=self.batch, | |
| normalize_embeddings=True, convert_to_numpy=True, | |
| show_progress_bar=False).astype(np.float16) | |
| def peak_vram_gb(self): | |
| if not str(self.device).startswith("cuda"): | |
| return 0.0 | |
| return self.torch.cuda.max_memory_allocated() / 1e9 | |
| def encode_to_shards(texts, ids, out_dir, hf_id, prefix, max_len, batch, device, | |
| shard_rows=250_000, log=print): | |
| """Encode `texts` into resumable shards under `out_dir`. Returns the meta dict. | |
| Safe to re-run: finished shards are skipped, a killed run resumes at the | |
| first missing shard. | |
| """ | |
| assert len(texts) == len(ids), f"{len(texts)} texts vs {len(ids)} ids" | |
| os.makedirs(out_dir, exist_ok=True) | |
| enc = None | |
| # dim is only known after the model loads; probe lazily but fingerprint first | |
| # with the declared dim from the zoo when resuming an existing cache. | |
| mp = _meta_path(out_dir) | |
| known_dim = json.load(open(mp))["dim"] if os.path.exists(mp) else None | |
| def build_fp(dim): | |
| return fingerprint(hf_id, prefix, max_len, dim, ids) | |
| if known_dim is not None: | |
| n_total, done, bad = cache_state(out_dir, build_fp(known_dim), shard_rows) | |
| if bad: | |
| log(f"[emb] cache INVALID ({bad}) -> re-encoding from scratch") | |
| for f in os.listdir(out_dir): | |
| if f.startswith("shard_") or f == "meta.json": | |
| os.remove(os.path.join(out_dir, f)) | |
| done = 0 | |
| n_total = (len(ids) + shard_rows - 1) // shard_rows | |
| elif done == n_total: | |
| log(f"[emb] cache complete and valid: {out_dir} ({n_total} shards)") | |
| return json.load(open(mp)) | |
| else: | |
| log(f"[emb] resuming: {done}/{n_total} shards already done") | |
| else: | |
| done = 0 | |
| n_total = (len(ids) + shard_rows - 1) // shard_rows | |
| t0 = time.time() | |
| for i in range(done, n_total): | |
| lo, hi = i * shard_rows, min((i + 1) * shard_rows, len(texts)) | |
| if enc is None: | |
| enc = Encoder(hf_id, device, max_len, batch) | |
| log(f"[emb] model={hf_id} dim={enc.dim} device={device}") | |
| E = enc.encode(texts[lo:hi], prefix) | |
| # NOTE: np.save appends ".npy" unless the name already ends in it, so the | |
| # temp name must end in .npy or the rename below looks for the wrong file. | |
| tmp = _shard_path(out_dir, i) + ".tmp.npy" | |
| np.save(tmp, E) | |
| os.replace(tmp, _shard_path(out_dir, i)) # atomic | |
| rate = (hi - (done * shard_rows)) / max(time.time() - t0, 1e-9) | |
| eta = (len(texts) - hi) / max(rate, 1e-9) / 60 | |
| log(f"[emb] shard {i + 1}/{n_total} rows {hi:,}/{len(texts):,} " | |
| f"{rate:,.0f} rec/s eta {eta:,.0f} min vram {enc.peak_vram_gb():.1f} GB") | |
| dim = enc.dim if enc is not None else known_dim | |
| meta = {**build_fp(dim), "shard_rows": shard_rows, "n_shards": n_total, | |
| "seconds": round(time.time() - t0, 1)} | |
| json.dump(meta, open(mp, "w"), indent=2) | |
| log(f"[emb] done -> {out_dir}") | |
| return meta | |
| def load_matrix(out_dir, mmap=True): | |
| """Concatenated (n, dim) float16 matrix. Built once into a single .npy so the | |
| search stage memory-maps one file instead of holding a list of shards.""" | |
| meta = json.load(open(_meta_path(out_dir))) | |
| flat = os.path.join(out_dir, "matrix.npy") | |
| if not os.path.exists(flat): | |
| M = np.lib.format.open_memmap(flat + ".tmp", mode="w+", dtype=np.float16, | |
| shape=(meta["n"], meta["dim"])) | |
| off = 0 | |
| for i in range(meta["n_shards"]): | |
| S = np.load(_shard_path(out_dir, i), mmap_mode="r") | |
| M[off:off + len(S)] = S | |
| off += len(S) | |
| assert off == meta["n"], f"row mismatch {off} != {meta['n']}" | |
| M.flush() | |
| del M | |
| os.replace(flat + ".tmp", flat) | |
| return np.load(flat, mmap_mode="r" if mmap else None), meta | |
| def read_ids(path): | |
| with open(path) as f: | |
| return [l.rstrip("\n") for l in f if l.strip()] | |
| def write_ids(path, ids): | |
| tmp = path + ".tmp" | |
| with open(tmp, "w") as f: | |
| f.write("\n".join(str(i) for i in ids)) | |
| os.replace(tmp, path) | |