EVIE-4.5B / code /compress /dump.py
xiaoxiaoshadiao's picture
EVIE-4.5B: Prefix-MRL weights, ARD training, eval, HAC
6e8abde verified
Raw History Blame Contribute Delete
19 kB
#!/usr/bin/env python3
"""Encode EVIE-4.5B (active head = HEAD_DIM) once: image-span tokens + queries.
Query is not pooled. Resume skips task json files whose dump_protocol still matches.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import time
from collections import Counter, defaultdict
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "shared"))
import torch
from torch.utils.data import DataLoader
from common import (
DUMP_PROTOCOL,
HEAD_DIM,
MVT,
SOUP_DIR,
SOUP_NAME,
atomic_torch_save,
atomic_write_json,
dump_ok,
dump_qrels,
pack_seqs,
)
import eval as evie_eval # noqa: E402
from hac import image_span_from_mask # noqa: E402
from paths import forbid_venv_path # noqa: E402
from colpali_engine.models import ColQwen3_5, ColQwen3_5Processor # noqa: E402
from colpali_engine.models.qwen3_5.colqwen3_5.modeling_colqwen3_5 import ( # noqa: E402
set_active_head,
)
from transformers.models.qwen3_5 import Qwen3_5Config # noqa: E402
def corpus_id_for(task: dict, max_docs: int) -> str:
if task["fmt"] == "beir":
payload = "beir|" + "\0".join(task["corpus"])
else:
payload = "paired|" + "\0".join(task["paired"])
payload += f"|max_docs={int(max_docs)}"
return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
def _first(meta: dict, *keys, default):
for key in keys:
value = meta.get(key)
if value is not None:
return value
return default
def split_doc_batch(emb: torch.Tensor, batch: dict, processor) -> list[dict]:
image_token_id = int(processor.image_token_id)
merge = int(processor.image_processor.merge_size)
attn = batch["attention_mask"].bool()
ids = batch["input_ids"]
thw = batch["image_grid_thw"]
recs = []
for i in range(emb.shape[0]):
mask = attn[i]
vec = emb[i][mask].contiguous().half()
tok = ids[i][mask]
start, end = image_span_from_mask(tok == image_token_id)
grid = [int(x) for x in thw[i].tolist()]
expected = (grid[1] // merge) * (grid[2] // merge)
if (end - start) != expected:
raise RuntimeError(
f"image tokens {end - start} != grid {grid} merge={merge} expected={expected}"
)
recs.append(
{
"vec": vec.cpu(),
"image_start": int(start),
"image_end": int(end),
"grid_thw": grid,
}
)
return recs
@torch.no_grad()
def encode_docs(processor, model, paths, device, batch_size, num_workers, meta_keys, max_docs, dedupe_key=None):
ds = evie_eval._ImageParquetDataset(
paths, meta_keys=meta_keys, skip_empty_query=False, dedupe_key=dedupe_key
)
loader = DataLoader(
ds,
batch_size=batch_size,
num_workers=num_workers,
collate_fn=evie_eval._collate_pil,
prefetch_factor=6 if num_workers > 0 else None,
persistent_workers=False,
)
records = []
with ThreadPoolExecutor(max_workers=1) as pool:
pending = None
it = iter(loader)
def submit_next():
try:
imgs, metas = next(it)
except StopIteration:
return None
return pool.submit(processor.process_images, imgs), metas
pending = submit_next()
while pending is not None:
fut, metas = pending
batch = fut.result()
nxt = submit_next()
batch_cpu = batch
batch_gpu = {k: v.to(device, non_blocking=True) for k, v in batch.items()}
emb = model(**batch_gpu).float().cpu()
if emb.ndim != 3 or int(emb.shape[-1]) != HEAD_DIM:
raise RuntimeError(f"doc emb shape {tuple(emb.shape)} is not [B,L,{HEAD_DIM}]")
recs = split_doc_batch(emb, batch_cpu, processor)
for rec, meta in zip(recs, metas):
rec["meta"] = meta
records.append(rec)
if max_docs and len(records) >= max_docs:
records = records[:max_docs]
break
pending = nxt
return records
@torch.no_grad()
def encode_queries(processor, model, texts, device, batch_size):
seqs = []
for start in range(0, len(texts), batch_size):
chunk = texts[start : start + batch_size]
chunk = [t if (t and str(t).strip()) else " " for t in chunk]
batch = processor.process_queries(chunk)
attn = batch["attention_mask"].bool()
batch_gpu = {k: v.to(device) for k, v in batch.items()}
for obj in (model, getattr(model, "get_base_model", lambda: None)()):
if obj is not None and hasattr(obj, "rope_deltas"):
obj.rope_deltas = None
emb = model(**batch_gpu).float().cpu()
if emb.ndim != 3 or int(emb.shape[-1]) != HEAD_DIM:
raise RuntimeError(f"query emb shape {tuple(emb.shape)} is not [B,L,{HEAD_DIM}]")
for i in range(emb.shape[0]):
seqs.append(emb[i][attn[i]].contiguous().half())
return seqs
def save_docs(path: Path, records: list[dict], ids: list[str]) -> dict:
packed = pack_seqs([r["vec"] for r in records])
payload = {
"ids": ids,
"packed": packed["packed"],
"offsets": packed["offsets"],
"image_start": torch.tensor([r["image_start"] for r in records], dtype=torch.int64),
"image_end": torch.tensor([r["image_end"] for r in records], dtype=torch.int64),
"grid_thw": torch.tensor([r["grid_thw"] for r in records], dtype=torch.int32),
"dim": HEAD_DIM,
"n": len(records),
"dump_protocol": DUMP_PROTOCOL,
}
atomic_torch_save(path, payload)
n_image = [int(r["image_end"] - r["image_start"]) for r in records]
n_full = [int(r["vec"].shape[0]) for r in records]
return {
"n_docs": len(records),
"avg_full_tokens": sum(n_full) / len(n_full),
"avg_image_tokens": sum(n_image) / len(n_image),
"min_image_tokens": min(n_image),
"max_image_tokens": max(n_image),
}
def load_model(device):
if not str(SOUP_DIR) or not (SOUP_DIR / "model.safetensors").is_file():
raise FileNotFoundError("set MODEL_DIR or EVIE_4_5B_DIR to an EVIE-4.5B checkpoint")
model_source = str(SOUP_DIR)
processor = ColQwen3_5Processor.from_pretrained(model_source, max_num_visual_tokens=MVT)
config = Qwen3_5Config.from_pretrained(model_source)
run_path = SOUP_DIR / "run_config.json"
if run_path.is_file():
run_cfg = json.loads(run_path.read_text(encoding="utf-8"))
heads = [int(d) for d in run_cfg["head_dims"]]
else:
heads = [int(d) for d in (getattr(config, "head_dims", None) or [])]
if not heads:
raise FileNotFoundError(
f"{SOUP_DIR} has no head_dims; point MODEL_DIR at EVIE-4.5B"
)
config.head_dims = heads
config.dim = max(heads)
attn = os.environ.get("EVAL_ATTN", "flash_attention_2")
model = ColQwen3_5.from_pretrained(
model_source,
config=config,
torch_dtype=torch.bfloat16,
attn_implementation=attn,
)
model.enable_bidirectional_attention()
set_active_head(model, HEAD_DIM)
model = model.to(device).eval()
merge = int(processor.image_processor.merge_size)
if merge != 2:
raise RuntimeError(f"expected merge_size=2, got {merge}")
print(
f"[dump] model={SOUP_NAME} head=d{HEAD_DIM} mvt={MVT} bidir=on "
f"processor={model_source} device={device}",
flush=True,
)
return processor, model
def dump_paired(task, processor, model, device, args, dump_root: Path, cid: str):
docs_path = dump_root / "docs" / f"{cid}.pt"
if not docs_path.is_file():
records = encode_docs(
processor,
model,
task["paired"],
device,
args.embed_batch,
args.num_workers,
meta_keys=["image_filename"],
max_docs=args.max_docs,
dedupe_key="image_filename",
)
if not records:
raise ValueError(f"no candidate pages for {evie_eval.task_tag(task)}")
ids = []
page_to_idx = {}
for i, rec in enumerate(records):
key = rec["meta"].get("image_filename")
if key is None:
raise ValueError(f"paired candidate missing image_filename in {task['subset']}")
key = str(key)
if key in page_to_idx:
raise ValueError(f"duplicate page was encoded twice: {key}")
page_to_idx[key] = i
ids.append(key)
stats = save_docs(docs_path, records, ids)
else:
blob = torch.load(docs_path, map_location="cpu", weights_only=False)
ids = list(blob["ids"])
page_to_idx = {k: i for i, k in enumerate(ids)}
stats = {"n_docs": len(ids), "reused": True}
queries, golds = evie_eval._paired_query_rows(task["paired"], page_to_idx)
if not queries:
raise ValueError(f"no non-empty queries for {task['subset']}")
if args.max_queries and args.max_queries < len(queries):
queries = queries[: args.max_queries]
golds = golds[: args.max_queries]
q_seqs = encode_queries(processor, model, queries, device, args.embed_batch)
relevant = [{g} for g in golds]
graded = [{g: 1.0} for g in golds]
return stats, q_seqs, relevant, graded
def dump_beir(task, processor, model, device, args, dump_root: Path, cid: str, corpus_cache: dict):
docs_path = dump_root / "docs" / f"{cid}.pt"
cache_key = (cid, args.max_docs)
if corpus_cache.get("key") == cache_key:
ids = corpus_cache["ids"]
stats = {"n_docs": len(ids), "reused": True, "in_memory": True}
elif docs_path.is_file():
blob = torch.load(docs_path, map_location="cpu", weights_only=False)
ids = list(blob["ids"])
corpus_cache.clear()
corpus_cache.update(key=cache_key, ids=ids)
stats = {"n_docs": len(ids), "reused": True}
else:
records = encode_docs(
processor,
model,
task["corpus"],
device,
args.embed_batch,
args.num_workers,
meta_keys=["corpus-id", "id"],
max_docs=args.max_docs,
)
ids = [str(_first(r["meta"], "corpus-id", "id", default=f"c{i}")) for i, r in enumerate(records)]
if len(set(ids)) != len(ids):
raise ValueError(f"duplicate corpus ids in {task['subset']}")
stats = save_docs(docs_path, records, ids)
corpus_cache.clear()
corpus_cache.update(key=cache_key, ids=ids)
cid_to_idx = {cid_: i for i, cid_ in enumerate(ids)}
q_rows = evie_eval.read_parquet_rows(task["queries"])
q_texts = [str(r["text"] if "text" in r else r["query"]) for r in q_rows]
q_ids = [str(r.get("id", r.get("query-id", f"q{i}"))) for i, r in enumerate(q_rows)]
rel_by_q, grad_by_q = defaultdict(set), defaultdict(dict)
for row in evie_eval.read_parquet_rows(task["qrels"]):
qid = str(row.get("query-id", row.get("id", "")))
doc = str(row.get("corpus-id", row.get("id", "")))
score = float(row.get("score", 1.0) or 1.0)
if score > 0 and doc in cid_to_idx:
rel_by_q[qid].add(cid_to_idx[doc])
grad_by_q[qid][cid_to_idx[doc]] = score
keep = [
i
for i in range(len(q_ids))
if q_texts[i].strip()
and q_texts[i].lower() != "none"
and q_ids[i] in rel_by_q
and rel_by_q[q_ids[i]]
]
if args.max_queries and args.max_queries < len(keep):
keep = keep[: args.max_queries]
if not keep:
raise ValueError(f"no non-empty queries with valid qrels for {task['subset']}")
q_seqs = encode_queries(processor, model, [q_texts[i] for i in keep], device, args.embed_batch)
relevant = [rel_by_q[q_ids[i]] for i in keep]
graded = [grad_by_q[q_ids[i]] for i in keep]
return stats, q_seqs, relevant, graded
def parse_args():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--eval-root", required=True)
ap.add_argument("--dump-dir", required=True)
ap.add_argument("--embed-batch", type=int, default=8)
ap.add_argument("--num-workers", type=int, default=8)
ap.add_argument("--max-queries", type=int, default=0)
ap.add_argument("--max-docs", type=int, default=0)
ap.add_argument("--datasets", default="")
return ap.parse_args()
def main():
args = parse_args()
rank, world, local = evie_eval.setup_ddp()
device = torch.device(f"cuda:{local}" if torch.cuda.is_available() else "cpu")
dump_root = forbid_venv_path(args.dump_dir, "dump-dir")
dump_root.mkdir(parents=True, exist_ok=True)
tasks = evie_eval.discover_tasks(args.eval_root)
if args.datasets:
want = {d.strip() for d in args.datasets.split(",") if d.strip()}
tasks = [t for t in tasks if t["dataset"] in want]
expected = len(tasks)
if not args.datasets:
want_full = {"eval": 10, "eval_v2": 4, "eval_v3": 48, "JinaVDR": 76}
got = Counter(t["dataset"] for t in tasks)
bad = {k: (want_full[k], got.get(k, 0)) for k in want_full if got.get(k, 0) != want_full[k]}
if bad:
raise RuntimeError(f"eval discovery incomplete: {bad}")
pending = []
for task in tasks:
slug = evie_eval.task_slug(evie_eval.task_tag(task))
marker = dump_root / "tasks" / f"{slug}.json"
if dump_ok(marker, dump_root):
continue
pending.append(task)
if world > 1:
pending = evie_eval._balanced_shard(pending, world, rank)
print(
f"[dump rank{rank}] pending {len(pending)}/{expected} "
f"(~{sum(evie_eval._task_corpus_size(t) for t in pending)} corpus rows)",
flush=True,
)
results = []
processor = model = None
corpus_cache = {}
if pending:
processor, model = load_model(device)
for task in pending:
tag = evie_eval.task_tag(task)
slug = evie_eval.task_slug(tag)
cid = corpus_id_for(task, args.max_docs)
t0 = time.time()
try:
if task["fmt"] == "paired":
stats, q_seqs, relevant, graded = dump_paired(
task, processor, model, device, args, dump_root, cid
)
else:
stats, q_seqs, relevant, graded = dump_beir(
task, processor, model, device, args, dump_root, cid, corpus_cache
)
q_path = dump_root / "queries" / f"{slug}.pt"
r_path = dump_root / "qrels" / f"{slug}.json"
atomic_torch_save(q_path, {**pack_seqs(q_seqs), "dump_protocol": DUMP_PROTOCOL})
dump_qrels(r_path, relevant, graded)
rec = {
"task": tag,
"slug": slug,
"dataset": task["dataset"],
"subset": task["subset"],
"lang": task.get("lang"),
"fmt": task["fmt"],
"corpus_id": cid,
"docs": f"docs/{cid}.pt",
"queries": f"queries/{slug}.pt",
"qrels": f"qrels/{slug}.json",
"n_docs": int(stats["n_docs"]),
"n_queries": len(q_seqs),
"stats": stats,
"seconds": round(time.time() - t0, 1),
"dump_protocol": DUMP_PROTOCOL,
"head_dim": HEAD_DIM,
"max_visual_tokens": MVT,
"bidirectional_attention": "on",
"weights": SOUP_NAME,
"alpha": 0.5,
}
atomic_write_json(dump_root / "tasks" / f"{slug}.json", rec)
results.append(rec)
print(
f"[dump rank{rank}] {tag}: {rec['n_queries']}q / {rec['n_docs']}d "
f"{rec['seconds']}s",
flush=True,
)
except Exception as exc:
import traceback
traceback.print_exc()
results.append({"task": tag, "slug": slug, "error": repr(exc)})
print(f"[dump rank{rank}] {tag} FAILED: {exc!r}", flush=True)
rank_path = dump_root / f"rank_{rank}.json"
atomic_write_json(rank_path, results)
if torch.cuda.is_available() and model is not None:
peak = torch.cuda.max_memory_allocated(device) / 1024**3
print(f"[dump rank{rank}] peak VRAM {peak:.2f} GiB", flush=True)
if world > 1:
torch.distributed.destroy_process_group()
if rank != 0:
return
deadline = time.monotonic() + int(os.environ.get("EVAL_FINALIZE_TIMEOUT_S", "21600"))
missing = list(range(world))
last = 0.0
while missing:
missing = []
for r in range(world):
p = dump_root / f"rank_{r}.json"
try:
json.loads(p.read_text(encoding="utf-8"))
except Exception:
missing.append(r)
if not missing:
break
now = time.monotonic()
if now >= deadline:
print(f"[dump] finalize timeout; missing ranks {missing}", flush=True)
break
if now - last >= 60:
print(f"[dump] waiting for ranks {missing}", flush=True)
last = now
time.sleep(2)
ok = []
failed = []
for task in tasks:
slug = evie_eval.task_slug(evie_eval.task_tag(task))
marker = dump_root / "tasks" / f"{slug}.json"
if dump_ok(marker, dump_root):
ok.append(json.loads(marker.read_text(encoding="utf-8")))
else:
failed.append(evie_eval.task_tag(task))
unique_docs = sorted({rec["corpus_id"] for rec in ok})
done = {
"dump_protocol": DUMP_PROTOCOL,
"status": "complete" if not failed else "partial",
"expected_tasks": expected,
"completed_tasks": len(ok),
"failed_or_missing": failed,
"unique_corpora": len(unique_docs),
"n_docs_sum_over_tasks": sum(rec["n_docs"] for rec in ok),
"n_queries_sum": sum(rec["n_queries"] for rec in ok),
"head_dim": HEAD_DIM,
"max_visual_tokens": MVT,
"weights": SOUP_NAME,
"alpha": 0.5,
"truncated": bool(args.max_docs or args.max_queries or args.datasets),
"datasets": args.datasets or None,
"eval_root": str(Path(args.eval_root).resolve()),
}
atomic_write_json(dump_root / "DONE.json", done)
print(f"[dump] {done['status']} {done['completed_tasks']}/{expected} -> {dump_root / 'DONE.json'}", flush=True)
if failed:
for tag in failed[:20]:
print(f" - MISS {tag}", flush=True)
raise SystemExit(2)
if __name__ == "__main__":
main()