Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

3310 lines
127 KiB
Python

#!/usr/bin/env python3
"""Fine-tune Qwen3-VL-Embedding with contrastors-style GradCache training.
Uses contrastors' core ideas (GradCache, distributed gather_with_grad) with
colpali-engine's BiQwen3 model + processor for Qwen3-VL vision-language support.
Single GPU:
CUDA_VISIBLE_DEVICES=3 python training/train_contrastors.py --max-steps 100
Multi-GPU (5 GPUs, cross-GPU negatives + GradCache):
CUDA_VISIBLE_DEVICES=3,4,5,6,7 torchrun --nproc_per_node=5 \
training/train_contrastors.py --max-steps 500
Resume:
python training/train_contrastors.py --resume training/output_contrastors/checkpoint-200
"""
import argparse
import datetime
import functools
import json
import logging
import os
import signal
import io
import sys
import time
import urllib.parse
from urllib.parse import unquote
from pathlib import Path
from contextlib import nullcontext
from urllib import error as urlerror
from urllib import request as urlrequest
import numpy as np
import torch
import torch.distributed as dist
import torch.distributed.nn
import torch.nn.functional as F
from peft import LoraConfig, get_peft_model
from PIL import Image, ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True # tolerate truncated images in external datasets
from torch.nn.utils import clip_grad_norm_
from torch.utils.checkpoint import get_device_states, set_device_states
from torch.utils.data import (
ConcatDataset,
DataLoader,
Dataset,
DistributedSampler,
Sampler,
)
from transformers import (
get_constant_schedule_with_warmup,
get_cosine_schedule_with_warmup,
get_cosine_with_hard_restarts_schedule_with_warmup,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
stream=sys.stdout,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Contrastors core: GradCache + distributed gather (adapted from nomic-ai/contrastors)
# ---------------------------------------------------------------------------
class RandContext:
"""Save/restore RNG state for GradCache replay (from contrastors/rand_state.py)."""
def __init__(self, tensors):
if isinstance(tensors, dict):
tensors = list(tensors.values())
self.fwd_cpu_state = torch.get_rng_state()
self.fwd_gpu_devices, self.fwd_gpu_states = get_device_states(*tensors)
def __enter__(self):
self._fork = torch.random.fork_rng(devices=self.fwd_gpu_devices, enabled=True)
self._fork.__enter__()
torch.set_rng_state(self.fwd_cpu_state)
set_device_states(self.fwd_gpu_devices, self.fwd_gpu_states)
def __exit__(self, exc_type, exc_val, exc_tb):
self._fork.__exit__(exc_type, exc_val, exc_tb)
self._fork = None
def gather_with_grad(t):
"""All-gather tensors across GPUs while preserving gradients."""
if not dist.is_initialized() or dist.get_world_size() == 1:
return t
if t.ndim == 0:
t = t.unsqueeze(0)
return torch.cat(torch.distributed.nn.all_gather(t), dim=0)
class LogitScale(torch.nn.Module):
"""Learnable logit scale (from contrastors/OpenCLIP).
Contrastors uses a learnable log-scale initialized to ln(1/0.07) ≈ 2.66.
The parameter is clamped in-place AFTER optimizer.step() (not in forward),
matching contrastors' approach. Clamping in forward would create a dead zone
where gradients are zero when log_scale exceeds the limit.
"""
def __init__(self, init_value=1 / 0.07, max_value=100.0):
super().__init__()
self.log_scale = torch.nn.Parameter(torch.log(torch.tensor(init_value)))
self.max_log = float(torch.log(torch.tensor(max_value)))
def forward(self, x):
return x * self.log_scale.exp()
@torch.no_grad()
def clamp_(self):
"""Clamp log_scale in-place. Call after optimizer.step()."""
self.log_scale.clamp_(0, self.max_log)
class EMAModel:
"""Exponential Moving Average of model parameters for better generalization."""
def __init__(self, model, decay=0.999):
self.decay = decay
self.shadow = {
n: p.data.clone() for n, p in model.named_parameters() if p.requires_grad
}
@torch.no_grad()
def update(self, model):
for n, p in model.named_parameters():
if p.requires_grad and n in self.shadow:
self.shadow[n].mul_(self.decay).add_(p.data, alpha=1 - self.decay)
def apply(self, model):
"""Swap model params with EMA params. Call before eval."""
self.backup = {}
for n, p in model.named_parameters():
if p.requires_grad and n in self.shadow:
self.backup[n] = p.data.clone()
p.data.copy_(self.shadow[n])
def restore(self, model):
"""Restore original params after eval."""
for n, p in model.named_parameters():
if n in self.backup:
p.data.copy_(self.backup[n])
self.backup = {}
def siglip_loss(query, document, logit_scale, gather_enabled=False, hardness_alpha=0.0):
"""SigLIP pairwise sigmoid loss (Zhai et al. 2023).
Instead of softmax cross-entropy over the row, applies binary sigmoid loss
to each (query, document) pair independently. Positive pairs should have high
similarity, all other pairs should have low similarity.
This avoids the softmax bottleneck where increasing one logit requires
decreasing all others — each pair is classified independently.
"""
if gather_enabled:
document = gather_with_grad(document)
device = query.device
if query.dtype != document.dtype:
document = document.to(query.dtype)
num_queries = query.shape[0]
num_docs = document.shape[0]
# Compute scaled similarity matrix
similarity = logit_scale(torch.matmul(query, document.T))
# Build target matrix: +1 for positive pairs, -1 for negatives
if gather_enabled and dist.is_initialized():
rank = dist.get_rank()
world_size = dist.get_world_size()
else:
rank = 0
world_size = 1
# Compute positive indices (same logic as clip_loss for hard negatives)
labels = torch.arange(num_queries, device=device) + rank * num_queries
assert num_docs % (num_queries * world_size) == 0
stride = num_docs // (num_queries * world_size)
pos_indices = labels * stride # shape: [num_queries]
# Target: -1 everywhere, +1 at positive positions
target = -torch.ones(num_queries, num_docs, device=device, dtype=query.dtype)
target.scatter_(1, pos_indices.unsqueeze(1).long(), 1.0)
# SigLIP loss: -log_sigmoid(target * similarity) averaged over all pairs
loss = -F.logsigmoid(target * similarity).sum() / num_queries
if gather_enabled and dist.is_initialized():
loss = loss * dist.get_world_size()
# Accuracy: check if positive has highest similarity
accuracy = (similarity.argmax(dim=1) == pos_indices).float().mean()
return loss, accuracy
def clip_loss(
query,
document,
logit_scale,
gather_enabled=False,
hardness_alpha=0.0,
label_smoothing=0.0,
):
"""InfoNCE contrastive loss with hard negative support (from contrastors/loss.py).
Inspired by: https://github.com/mlfoundations/open_clip/blob/main/src/open_clip/loss.py#L66
When hard negatives are used, document contains interleaved positives and negatives:
[pos1, neg1a, neg1b, pos2, neg2a, neg2b, ...]
So document.size(0) = query.size(0) * (1 + num_hard_negatives).
Labels point to the positive positions: [0, 3, 6, 9, ...] for 2 hard negs.
hardness_alpha: LLaVE-style hardness weighting (Lan et al. 2025). When > 0,
adds alpha * cos_sim(q, d) to negative logits before softmax, so harder
negatives get upweighted. 0 = off (standard InfoNCE).
"""
if gather_enabled:
document = gather_with_grad(document)
device = query.device
if query.dtype != document.dtype:
document = document.to(query.dtype)
num_queries = query.shape[0]
labels = torch.arange(num_queries).to(device)
similarity = logit_scale(torch.matmul(query, document.T))
# Rank offset and world_size scaling only apply when documents are gathered
# across ranks. Without gather, each rank evaluates independently (local labels).
if gather_enabled and dist.is_initialized():
rank = dist.get_rank()
world_size = dist.get_world_size()
else:
rank = 0
world_size = 1
labels = labels + rank * num_queries
# Scale for hard negatives: docs_per_query_per_rank = doc.size(0) / (N * W)
assert document.size(0) % (num_queries * world_size) == 0, (
f"document.size(0)={document.size(0)} not divisible by "
f"num_queries*world_size={num_queries}*{world_size}"
)
labels = labels * (document.size(0) // (num_queries * world_size))
# LLaVE hardness weighting: upweight harder negatives in softmax
if hardness_alpha > 0:
with torch.no_grad():
raw_sim = torch.matmul(query, document.T)
neg_mask = torch.ones_like(similarity, dtype=torch.bool)
neg_mask.scatter_(1, labels.unsqueeze(1), False)
similarity = similarity + hardness_alpha * raw_sim * neg_mask
loss = F.cross_entropy(similarity, labels, label_smoothing=label_smoothing)
if gather_enabled and dist.is_initialized():
loss = loss * dist.get_world_size()
# accuracy for logging
accuracy = (similarity.argmax(dim=1) == labels).float().mean()
return loss, accuracy
def get_chunked_embeddings(model, chunks, process_fn):
"""Forward pass on chunks without grad, caching embeddings + RNG states."""
embeddings = []
rand_states = []
# Use the unwrapped model for no-grad forward (avoids DDP overhead)
raw_model = model.module if hasattr(model, "module") else model
with torch.autocast("cuda", dtype=torch.bfloat16):
with torch.no_grad():
for chunk in chunks:
rand_states.append(RandContext(chunk))
emb = process_fn(raw_model, chunk)
embeddings.append(emb)
return torch.cat(embeddings, dim=0), rand_states
def grad_cache_loss(
model,
query_chunks,
doc_chunks,
logit_scale,
query_process_fn,
doc_process_fn,
hardness_alpha=0.0,
loss_fn=None,
):
"""GradCache: large effective batch with constant memory (from contrastors/loss.py).
1. Forward all chunks WITHOUT grad → cache embeddings
2. Compute loss on full gathered embeddings → cache gradients
3. Replay forward WITH grad, backprop through surrogate loss
All surrogate backward passes run under no_sync(), then we manually
all_reduce gradients for both the model and logit_scale.
"""
if loss_fn is None:
loss_fn = clip_loss
raw_model = model.module if hasattr(model, "module") else model
# Step 1: get all embeddings without grad (no DDP involvement)
query_embs, query_states = get_chunked_embeddings(
model, query_chunks, query_process_fn
)
doc_embs, doc_states = get_chunked_embeddings(model, doc_chunks, doc_process_fn)
# Step 2: compute loss, get gradient w.r.t. embeddings
# This uses all_gather (NCCL collective #1) and its backward does
# reduce_scatter (NCCL collective #2). Both happen on detached tensors,
# NOT through DDP, so DDP doesn't interfere.
query_embs_d = query_embs.detach().requires_grad_()
doc_embs_d = doc_embs.detach().requires_grad_()
with torch.autocast("cuda", dtype=torch.bfloat16):
loss, accuracy = loss_fn(
query_embs_d,
doc_embs_d,
logit_scale,
gather_enabled=True,
hardness_alpha=hardness_alpha,
)
loss.backward()
query_cache = query_embs_d.grad
doc_cache = doc_embs_d.grad
loss_val = loss.detach()
# Step 3: accumulate real gradients via surrogate loss
# Combine all chunks into a single list so DDP sync happens exactly ONCE
# (on the very last backward call). This matches contrastors' pattern.
# Split grad cache to match the actual chunk sizes (doc may differ from query
# when hard negatives are used — doc batch = query_batch * (1+num_hard_neg))
q_chunk_sizes = [c["input_ids"].shape[0] for c in query_chunks]
d_chunk_sizes = [c["input_ids"].shape[0] for c in doc_chunks]
query_grad_chunks = query_cache.split(q_chunk_sizes)
doc_grad_chunks = doc_cache.split(d_chunk_sizes)
all_chunks = list(query_chunks) + list(doc_chunks)
all_grads = list(query_grad_chunks) + list(doc_grad_chunks)
all_states = list(query_states) + list(doc_states)
all_fns = [query_process_fn] * len(query_chunks) + [doc_process_fn] * len(
doc_chunks
)
# All backward calls use no_sync to avoid DDP reducer confusion (query chunks
# skip visual encoder while doc chunks use it → different "used" parameter sets
# across chunks causes intermittent NCCL deadlocks with find_unused_parameters).
# We manually all_reduce gradients after all backward calls.
has_ddp = hasattr(model, "no_sync")
for chunk, grad, state, fn in zip(all_chunks, all_grads, all_states, all_fns):
sync_ctx = model.no_sync() if has_ddp else nullcontext()
with sync_ctx:
with torch.autocast("cuda", dtype=torch.bfloat16):
with state:
emb = fn(raw_model, chunk)
surrogate = torch.dot(emb.flatten(), grad.flatten())
surrogate.backward()
# Manual gradient all_reduce (replaces DDP's built-in sync).
# Always call all_reduce on every requires_grad param (fill zero grad if None)
# to ensure all ranks issue the same number of NCCL collectives — prevents
# deadlocks if different chunks produce gradients on different param subsets.
if has_ddp and dist.is_initialized():
manual_all_reduce_grads(model, logit_scale)
return loss_val, accuracy.detach()
def manual_all_reduce_grads(*modules):
"""Average gradients across ranks for modules outside DDP's reducer path."""
for module in modules:
for param in module.parameters():
if not param.requires_grad:
continue
if param.grad is None:
param.grad = torch.zeros_like(param)
dist.all_reduce(param.grad, op=dist.ReduceOp.AVG)
def debug_trace(enabled, message):
"""Emit a per-rank debug line when tracing distributed hangs."""
if not enabled:
return
rank = dist.get_rank() if dist.is_initialized() else 0
logger.info(f"[debug rank={rank}] {message}")
def build_openai_client(api_base_url="", timeout=60):
"""Create an OpenAI-compatible client for local or hosted endpoints."""
from openai import OpenAI # pyright: ignore[reportMissingImports]
api_key = os.environ.get("OPENAI_API_KEY")
if api_base_url:
# Local vLLM endpoints use their own auth; don't leak OPENAI_API_KEY
vllm_key = os.environ.get("VLLM_API_KEY", "dummy")
return OpenAI(base_url=api_base_url, api_key=vllm_key, timeout=timeout)
if not api_key:
raise RuntimeError(
"OPENAI_API_KEY is not set. Provide an OpenAI key or pass --vllm-url "
"for a local OpenAI-compatible endpoint."
)
base = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
return OpenAI(api_key=api_key, base_url=base, timeout=timeout)
def preflight_simpleqa_client(vllm_url="", model_name=""):
"""Check SimpleQA answer/judge client before expensive model loading."""
try:
client = build_openai_client(vllm_url, timeout=30)
client.models.list()
target = vllm_url or "openai"
logger.info(f"SimpleQA API preflight OK: target={target} model={model_name}")
return True
except Exception as e:
target = vllm_url or "openai"
logger.warning(f"SimpleQA API preflight failed for {target}: {e}")
return False
def grad_cache_loss_query_side(
query_model,
frozen_doc_model,
query_chunks,
doc_chunks,
logit_scale,
query_process_fn,
doc_process_fn,
debug_enabled=False,
hardness_alpha=0.0,
):
"""GradCache variant for query-only tuning with a frozen document tower."""
raw_query_model = (
query_model.module if hasattr(query_model, "module") else query_model
)
# Step 1: query embeddings come from the trainable tower; document embeddings
# come from the frozen base tower so the datastore stays valid.
debug_trace(debug_enabled, "gradcache_query_side: start query no-grad pass")
query_embs, query_states = get_chunked_embeddings(
query_model, query_chunks, query_process_fn
)
debug_trace(debug_enabled, "gradcache_query_side: start doc frozen pass")
doc_embs = []
with torch.autocast("cuda", dtype=torch.bfloat16):
with torch.no_grad():
for chunk in doc_chunks:
doc_embs.append(doc_process_fn(frozen_doc_model, chunk))
doc_embs = torch.cat(doc_embs, dim=0)
# Step 2: compute loss and cache only the query-side gradients.
debug_trace(
debug_enabled, "gradcache_query_side: start loss backward on detached embs"
)
query_embs_d = query_embs.detach().requires_grad_()
doc_embs_d = doc_embs.detach().requires_grad_()
with torch.autocast("cuda", dtype=torch.bfloat16):
loss, accuracy = clip_loss(
query_embs_d,
doc_embs_d,
logit_scale,
gather_enabled=True,
hardness_alpha=hardness_alpha,
)
loss.backward()
query_cache = query_embs_d.grad
loss_val = loss.detach()
# Step 3: replay only query chunks; doc tower is frozen and never backprops.
q_chunk_sizes = [c["input_ids"].shape[0] for c in query_chunks]
query_grad_chunks = query_cache.split(q_chunk_sizes)
has_ddp = hasattr(query_model, "no_sync")
debug_trace(debug_enabled, "gradcache_query_side: replay query chunks")
for chunk, grad, state in zip(query_chunks, query_grad_chunks, query_states):
sync_ctx = query_model.no_sync() if has_ddp else nullcontext()
with sync_ctx:
with torch.autocast("cuda", dtype=torch.bfloat16):
with state:
emb = query_process_fn(raw_query_model, chunk)
surrogate = torch.dot(emb.flatten(), grad.flatten())
surrogate.backward()
if has_ddp and dist.is_initialized():
debug_trace(debug_enabled, "gradcache_query_side: manual all_reduce grads")
manual_all_reduce_grads(query_model, logit_scale)
debug_trace(debug_enabled, "gradcache_query_side: done")
return loss_val, accuracy.detach()
def direct_loss_query_side(
query_model,
frozen_doc_model,
q_inputs,
d_inputs,
logit_scale,
gather_enabled=True,
debug_enabled=False,
hardness_alpha=0.0,
):
"""Simpler query-side training path without GradCache."""
raw_query_model = (
query_model.module if hasattr(query_model, "module") else query_model
)
debug_trace(debug_enabled, "direct_query_side: query forward")
with torch.autocast("cuda", dtype=torch.bfloat16):
_clear_rope_deltas(raw_query_model)
q_emb = raw_query_model(**q_inputs)
debug_trace(debug_enabled, "direct_query_side: doc frozen forward")
with torch.autocast("cuda", dtype=torch.bfloat16):
with torch.no_grad():
_clear_rope_deltas(frozen_doc_model)
d_emb = frozen_doc_model(**d_inputs)
debug_trace(debug_enabled, "direct_query_side: loss backward")
with torch.autocast("cuda", dtype=torch.bfloat16):
loss, accuracy = clip_loss(
q_emb,
d_emb,
logit_scale,
gather_enabled=gather_enabled,
hardness_alpha=hardness_alpha,
)
loss.backward()
if dist.is_initialized():
debug_trace(debug_enabled, "direct_query_side: manual all_reduce grads")
manual_all_reduce_grads(query_model, logit_scale)
debug_trace(debug_enabled, "direct_query_side: done")
return loss.detach(), accuracy.detach()
# ---------------------------------------------------------------------------
# Data
# ---------------------------------------------------------------------------
class QueryImageDataset(Dataset):
"""Dataset of (query, positive_path, [negative_paths...]) from JSONL.
Supports two formats:
- Simple: {"query": "...", "chunk_path": "..."}
- Hard negatives: {"query": "...", "chunk_path": "...", "neg_chunk_paths": ["...", ...]}
Pre-validates all images at init time so every __getitem__ is guaranteed
to succeed — critical for DDP where all ranks must process the same
number of batches (a None/skipped batch on one rank deadlocks NCCL).
"""
def __init__(
self,
jsonl_path,
max_pairs=None,
num_hard_negatives=0,
skip_image_verify=False,
reverse=False,
):
self.pairs = [] # (query, pos_path, [neg_path1, neg_path2, ...])
self.num_hard_negatives = num_hard_negatives
jsonl_dir = Path(jsonl_path).resolve().parent
skipped = 0
def _resolve_path(path_str):
path = Path(path_str)
if path.is_absolute():
return str(path)
return str((jsonl_dir / path).resolve())
with open(jsonl_path) as f:
for line in f:
item = json.loads(line)
pos_path = _resolve_path(item["chunk_path"])
if not os.path.exists(pos_path):
skipped += 1
continue
if not skip_image_verify:
try:
with Image.open(pos_path) as im:
im.convert("RGB").verify()
except Exception as e:
logger.warning(f"Bad image {pos_path}: {e}")
skipped += 1
continue
# Collect hard negatives if present (path-check only, no image verify)
neg_paths = []
if num_hard_negatives > 0 and "neg_chunk_paths" in item:
for np_ in item["neg_chunk_paths"][:num_hard_negatives]:
np_ = _resolve_path(np_)
if os.path.exists(np_):
neg_paths.append(np_)
# Pad with None if not enough negatives (will be skipped in collate)
while len(neg_paths) < num_hard_negatives:
neg_paths.append(None)
self.pairs.append((item["query"], pos_path, neg_paths))
if reverse:
self.pairs.reverse()
logger.info("Reversed data order (high-quality-first curriculum)")
if max_pairs:
self.pairs = self.pairs[:max_pairs]
if skipped:
logger.warning(f"Skipped {skipped} missing/bad images at init")
n_with_negs = sum(
1 for _, _, negs in self.pairs if any(n is not None for n in negs)
)
logger.info(
f"Loaded {len(self.pairs)} valid pairs from {jsonl_path}"
+ (
f" ({n_with_negs} with hard negatives)"
if num_hard_negatives > 0
else ""
)
)
def __len__(self):
return len(self.pairs)
def __getitem__(self, idx):
return self.pairs[idx]
class MixedBatchSampler(Sampler):
"""Yields batches with exact per-source counts from a ConcatDataset.
Each source occupies a contiguous index range [start, start+length).
Every batch draws exactly `count` samples from each source.
Sources cycle (reshuffle) when exhausted.
Args:
source_ranges: list of (start_idx, dataset_length, count_per_batch)
shuffle: shuffle indices within each source per epoch
seed: base random seed
rank/world_size: for distributed training (each rank gets disjoint batches)
"""
def __init__(self, source_ranges, shuffle=True, seed=42, rank=0, world_size=1):
self.source_ranges = source_ranges
self.shuffle = shuffle
self.seed = seed
self.rank = rank
self.world_size = world_size
self.epoch = 0
self._num_batches = min(
length // count for _, length, count in source_ranges if count > 0
)
# In distributed mode, each rank gets a subset of batches
self._num_batches = self._num_batches // world_size
def set_epoch(self, epoch):
self.epoch = epoch
def __iter__(self):
rng = torch.Generator()
rng.manual_seed(self.seed + self.epoch)
# Build shuffled index pools per source
pools = []
for start, length, count in self.source_ranges:
if self.shuffle:
perm = torch.randperm(length, generator=rng).tolist()
else:
perm = list(range(length))
needed = (self._num_batches * self.world_size + 1) * count
full = (perm * ((needed // length) + 1))[:needed]
pools.append((start, full, count))
# Yield batches for this rank
total_batches = self._num_batches * self.world_size
for b in range(total_batches):
if b % self.world_size != self.rank:
continue
batch = []
for start, pool, count in pools:
offset = b * count
batch.extend(start + pool[offset + i] for i in range(count))
yield batch
def __len__(self):
return self._num_batches
class TextQueryDataset(Dataset):
"""Dataset of (query, positive_text, [negative_texts...]) from text-qa-pair JSONL.
Each row has: query, passage (positive), neg_passages (list of hard negative texts).
"""
def __init__(self, jsonl_paths, max_pairs=None, num_hard_negatives=0):
self.pairs = []
self.num_hard_negatives = num_hard_negatives
skipped = 0
for jsonl_path in (
jsonl_paths if isinstance(jsonl_paths, list) else [jsonl_paths]
):
with open(jsonl_path) as f:
for line in f:
item = json.loads(line)
if "passage" not in item or not item["passage"]:
skipped += 1
continue
neg_texts = []
if num_hard_negatives > 0 and "neg_passages" in item:
for nt in item["neg_passages"][:num_hard_negatives]:
if nt:
neg_texts.append(nt)
while len(neg_texts) < num_hard_negatives:
neg_texts.append(item["passage"]) # pad with positive
self.pairs.append((item["query"], item["passage"], neg_texts))
if max_pairs and len(self.pairs) >= max_pairs:
break
if max_pairs and len(self.pairs) >= max_pairs:
break
logger.info(
f"Loaded {len(self.pairs)} text pairs from {jsonl_paths} "
f"(skipped {skipped})"
)
def __len__(self):
return len(self.pairs)
def __getitem__(self, idx):
return self.pairs[idx]
# Task-specific instructions (Qwen3-VL-Embedding style)
QUERY_INSTRUCTION = "Retrieve images or text relevant to the user's query."
DOC_INSTRUCTION = "Represent the user's input."
# Pre-computed chat template strings (filled by init_chat_templates).
_QUERY_PREFIX = None # e.g. "<|im_start|>system\n...query instruction...<|im_end|>\n<|im_start|>user\n"
_QUERY_SUFFIX = None # e.g. "<|im_end|>\n<|im_start|>assistant\n"
_DOC_IMAGE_TMPL = None # static template for image docs
_DOC_TEXT_PREFIX = None # prefix for text doc template
_DOC_TEXT_SUFFIX = None # suffix for text doc template
def init_chat_templates(processor):
"""Pre-compute chat template strings once to avoid per-batch overhead."""
global \
_QUERY_PREFIX, \
_QUERY_SUFFIX, \
_DOC_IMAGE_TMPL, \
_DOC_TEXT_PREFIX, \
_DOC_TEXT_SUFFIX
# Query template with placeholder
q_msgs = [
{"role": "system", "content": [{"type": "text", "text": QUERY_INSTRUCTION}]},
{"role": "user", "content": [{"type": "text", "text": "PLACEHOLDER"}]},
]
q_text = processor.apply_chat_template(
q_msgs, tokenize=False, add_generation_prompt=True
)
idx = q_text.index("PLACEHOLDER")
_QUERY_PREFIX = q_text[:idx]
_QUERY_SUFFIX = q_text[idx + len("PLACEHOLDER") :]
# Image doc template is fully static (no per-sample text)
d_msgs = [
{"role": "system", "content": [{"type": "text", "text": DOC_INSTRUCTION}]},
{"role": "user", "content": [{"type": "image"}]},
]
_DOC_IMAGE_TMPL = processor.apply_chat_template(
d_msgs, tokenize=False, add_generation_prompt=True
)
# Text doc template with placeholder (for text-only training)
dt_msgs = [
{"role": "system", "content": [{"type": "text", "text": DOC_INSTRUCTION}]},
{"role": "user", "content": [{"type": "text", "text": "PLACEHOLDER"}]},
]
dt_text = processor.apply_chat_template(
dt_msgs, tokenize=False, add_generation_prompt=True
)
dt_idx = dt_text.index("PLACEHOLDER")
_DOC_TEXT_PREFIX = dt_text[:dt_idx]
_DOC_TEXT_SUFFIX = dt_text[dt_idx + len("PLACEHOLDER") :]
logger.info(f"Query prefix: {repr(_QUERY_PREFIX)}")
logger.info(f"Doc image template: {repr(_DOC_IMAGE_TMPL[:80])}...")
logger.info(f"Doc text prefix: {repr(_DOC_TEXT_PREFIX)}")
def process_queries(processor, queries):
"""Wrap queries in chat template with retrieval instruction."""
texts = [_QUERY_PREFIX + q + _QUERY_SUFFIX for q in queries]
return processor(text=texts, return_tensors="pt", padding="longest")
def process_doc_texts(processor, texts):
"""Wrap document texts in chat template with representation instruction."""
wrapped = [_DOC_TEXT_PREFIX + t + _DOC_TEXT_SUFFIX for t in texts]
return processor(text=wrapped, return_tensors="pt", padding="longest")
def process_doc_images(processor, images):
"""Wrap document images in chat template with representation instruction.
Returns batch-major pixel_values (B, max_patches, dim) so that
chunk_inputs / GradCache can split along the batch dimension.
BiQwen3.forward() un-pads back to flat before passing to Qwen3VLModel.
"""
texts = [_DOC_IMAGE_TMPL] * len(images)
batch = processor(text=texts, images=images, return_tensors="pt", padding="longest")
# Reshape pixel_values from flat (total_patches, dim) to batch-major
# (B, max_patches, dim) for GradCache chunking compatibility.
if "pixel_values" in batch and "image_grid_thw" in batch:
offsets = batch["image_grid_thw"].prod(dim=1).tolist()
pixel_chunks = list(torch.split(batch["pixel_values"], offsets))
batch["pixel_values"] = torch.nn.utils.rnn.pad_sequence(
pixel_chunks, batch_first=True
)
return batch
def make_text_collate_fn(processor, num_hard_negatives=0):
"""Collate for text-only training: queries and doc texts both as text."""
def collate(batch):
queries = [item[0] for item in batch]
doc_texts = []
for item in batch:
doc_texts.append(item[1]) # positive passage
neg_texts = item[2] if len(item) > 2 else []
for nt in neg_texts:
doc_texts.append(nt)
query_inputs = process_queries(processor, list(queries))
doc_inputs = process_doc_texts(processor, doc_texts)
return query_inputs, doc_inputs
return collate
def make_collate_fn(processor, num_hard_negatives=0):
"""Collate: process queries as text, images as visual prompts.
With hard negatives, document images are interleaved:
[pos1, neg1a, neg1b, pos2, neg2a, neg2b, ...]
so that document.size(0) = batch_size * (1 + num_hard_negatives).
"""
def _load_image(path):
with Image.open(path) as im:
return im.convert("RGB")
def collate(batch):
t_start = time.time()
queries = [item[0] for item in batch]
# Build document image list: positive + hard negatives per query
doc_images = []
for item in batch:
pos_path = item[1]
doc_images.append(_load_image(pos_path))
neg_paths = item[2] if len(item) > 2 else []
for np_ in neg_paths:
if np_ is not None:
doc_images.append(_load_image(np_))
else:
doc_images.append(_load_image(pos_path))
t_io = time.time()
query_inputs = process_queries(processor, list(queries))
t_q = time.time()
image_inputs = process_doc_images(processor, doc_images)
t_d = time.time()
total = t_d - t_start
if total > 5:
logger.warning(
f"Slow collate: io={t_io - t_start:.1f}s q={t_q - t_io:.1f}s "
f"img={t_d - t_q:.1f}s total={total:.1f}s"
)
return query_inputs, image_inputs
return collate
# ---------------------------------------------------------------------------
# Model helpers
# ---------------------------------------------------------------------------
def _clear_rope_deltas(model):
"""Clear stale rope_deltas state on Qwen3VL model.
Qwen3VLModel.compute_3d_position_ids stores self.rope_deltas after processing
image batches. On subsequent text-only forwards, it reuses this stale state,
causing shape mismatch: position_ids (3, batch, seq_len) + delta (old_batch, 1).
Must clear between image→text forward transitions.
"""
inner = model
# Unwrap PeftModel → BiQwen3 → Qwen3VLModel
while hasattr(inner, "model"):
inner = inner.model
if hasattr(inner, "rope_deltas"):
inner.rope_deltas = None
def forward_query(model, inputs, bidirectional=False):
"""Forward text query through model → normalized embedding."""
_clear_rope_deltas(model)
return model(**inputs, bidirectional=bidirectional)
def forward_doc(model, inputs, bidirectional=False):
"""Forward image document through model → normalized embedding."""
_clear_rope_deltas(model)
return model(**inputs, bidirectional=bidirectional)
def chunk_inputs(inputs, chunk_size):
"""Split a batch of inputs into chunks along batch dimension.
Only includes tensor values in chunks (non-tensors are dropped) so that
RandContext's get_device_states doesn't crash on non-tensor dict values.
"""
batch_size = next(
v.shape[0] for v in inputs.values() if isinstance(v, torch.Tensor)
)
# Verify all tensors are batch-major (first dim == batch_size).
# pixel_values must be (B, max_patches, dim), not flattened (sum_patches, dim).
for k, v in inputs.items():
if isinstance(v, torch.Tensor) and v.shape[0] != batch_size:
raise ValueError(
f"chunk_inputs: {k}.shape[0]={v.shape[0]} != batch_size={batch_size}. "
f"All tensors must be batch-major for chunking."
)
chunks = []
for start in range(0, batch_size, chunk_size):
chunk = {
k: v[start : start + chunk_size]
for k, v in inputs.items()
if isinstance(v, torch.Tensor)
}
chunks.append(chunk)
return chunks
# ---------------------------------------------------------------------------
# Retrieval evaluation (global QxM retrieval with expanded doc pool)
# ---------------------------------------------------------------------------
def compute_retrieval_metrics(q_embs, i_embs, gold_doc_indices):
"""Compute retrieval metrics for a query set against a larger document pool."""
sims = q_embs @ i_embs.T # (Q, M)
gold_doc_indices = np.asarray(gold_doc_indices)
rankings = (-sims).argsort(axis=1)
ranks = np.argmax(rankings == gold_doc_indices[:, None], axis=1)
pos_sims = sims[np.arange(sims.shape[0]), gold_doc_indices]
if sims.shape[1] > 1:
neg_sum = sims.sum(axis=1) - pos_sims
mean_neg_per_query = neg_sum / (sims.shape[1] - 1)
mean_neg_sim = float(mean_neg_per_query.mean())
margin = float((pos_sims - mean_neg_per_query).mean())
else:
mean_neg_sim = 0.0
margin = float(pos_sims.mean())
return {
"recall@1": float((ranks < 1).mean()),
"recall@5": float((ranks < 5).mean()),
"recall@10": float((ranks < 10).mean()),
"mrr": float((1.0 / (ranks + 1)).mean()),
"mean_pos_sim": float(pos_sims.mean()),
"mean_neg_sim": mean_neg_sim,
"margin": margin,
}
def resolve_jsonl_path(jsonl_path, path_str):
"""Resolve a possibly relative path against a JSONL file location."""
path = Path(path_str)
if path.is_absolute():
return str(path.resolve())
return str((Path(jsonl_path).resolve().parent / path).resolve())
def wikipedia_url_to_slug(url):
"""Normalize an enwiki URL to an article slug."""
if not url or "/wiki/" not in url:
return None
slug = unquote(url.split("/wiki/")[-1]).replace(" ", "_").split("#")[0]
if not slug or slug.startswith("Category:"):
return None
return slug
def load_slug_to_article_id(articles_json):
"""Load articles.json and build slug -> article_id map."""
with open(articles_json) as f:
articles = json.load(f)
return {slug: idx for idx, slug in enumerate(articles)}
def load_retrieval_queries(jsonl_path, max_examples=0):
"""Load query -> gold image pairs for retrieval eval against the full datastore."""
examples = []
with open(jsonl_path) as f:
for line in f:
item = json.loads(line)
chunk_path = resolve_jsonl_path(jsonl_path, item["chunk_path"])
examples.append(
{
"query": item["query"],
"gold_path": chunk_path,
}
)
if max_examples > 0 and len(examples) >= max_examples:
break
logger.info(f"Loaded {len(examples)} retrieval queries from {jsonl_path}")
return examples
def load_simpleqa_queryset(jsonl_path=None, max_examples=1000, articles_json=None):
"""Load SimpleQA queryset JSONL bundled with this repo."""
examples = []
slug_to_aid = None
if jsonl_path and os.path.exists(jsonl_path):
with open(jsonl_path) as f:
for line in f:
item = json.loads(line)
gold_article_ids = list(item.get("gold_article_ids", []))
if not gold_article_ids and articles_json:
if slug_to_aid is None:
slug_to_aid = load_slug_to_article_id(articles_json)
seen = set()
for url in item.get("urls", []):
slug = wikipedia_url_to_slug(url)
aid = slug_to_aid.get(slug) if slug else None
if aid is not None and aid not in seen:
seen.add(aid)
gold_article_ids.append(aid)
examples.append(
{
"id": item.get("id", str(len(examples))),
"query": item.get("query", item.get("problem", "")),
"answer": item.get("answer", ""),
"urls": item.get("urls", []),
"gold_article_ids": gold_article_ids,
}
)
if max_examples > 0 and len(examples) >= max_examples:
break
logger.info(f"Loaded {len(examples)} SimpleQA queries from {jsonl_path}")
return examples
raise FileNotFoundError(f"SimpleQA queryset not found at {jsonl_path!r}.")
@torch.no_grad()
def embed_query_texts(model, processor, queries, device, batch_size=128):
"""Embed text queries with the current query tower."""
raw = model.module if hasattr(model, "module") else model
all_embs = []
for i in range(0, len(queries), batch_size):
batch = queries[i : i + batch_size]
inputs = process_queries(processor, batch)
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.autocast("cuda", dtype=torch.bfloat16):
_clear_rope_deltas(raw)
emb = raw(**inputs, bidirectional=getattr(raw, "_bidirectional", False))
all_embs.append(emb.cpu().float().numpy())
if not all_embs:
return np.zeros((0, 0), dtype=np.float32)
return np.concatenate(all_embs, axis=0)
def fetch_tile_image(search_api_url, path, timeout=30, retries=3):
"""Fetch a tile image via the search API's /tile endpoint.
Falls back to local file if the path exists locally.
Retries on network errors with exponential backoff.
"""
if os.path.exists(path):
return Image.open(path)
tile_url = (
search_api_url.rstrip("/") + "/tile?" + urllib.parse.urlencode({"path": path})
)
for attempt in range(retries):
try:
req = urlrequest.Request(tile_url, method="GET")
with urlrequest.urlopen(req, timeout=timeout) as resp:
return Image.open(io.BytesIO(resp.read()))
except (TimeoutError, OSError) as e:
if attempt < retries - 1:
wait = 2**attempt
logger.warning(
f"fetch_tile_image attempt {attempt + 1}/{retries} failed for {path}: {e}, retrying in {wait}s"
)
time.sleep(wait)
else:
raise
def search_api_by_embeddings(search_api_url, query_embeddings, n_docs=3, timeout=120):
"""Search the wiki-screenshot index using pre-computed query embeddings."""
if query_embeddings.ndim != 2:
raise ValueError(
f"Expected 2D query embeddings, got shape={query_embeddings.shape}"
)
payload = {
"queries": [{"embedding": emb.tolist()} for emb in query_embeddings],
"n_docs": n_docs,
}
req = urlrequest.Request(
search_api_url.rstrip("/") + "/search",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlrequest.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urlerror.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Search API HTTP {e.code}: {body}") from e
except urlerror.URLError as e:
raise RuntimeError(f"Search API request failed: {e}") from e
@torch.no_grad()
def run_search_api_retrieval_eval(
model, processor, examples, device, search_api_url, batch_size=32, n_docs=3
):
"""Compute exact-image Recall@1/3 against the full search datastore."""
was_training = model.training
model.eval()
def _normalize_tile_path(path_str):
"""Normalize tile path for matching.
Absolute paths are used as-is. Relative paths starting with 'images/'
(from HF dataset) are matched by shard suffix for backward compatibility
with search APIs that return absolute paths.
"""
p = Path(path_str)
if p.is_absolute():
return str(p)
# Relative path (e.g. images/shard_XXX/...) — extract shard suffix
parts = p.parts
for i, part in enumerate(parts):
if part.startswith("shard_"):
return "/".join(parts[i:])
return path_str
def _match_paths(gold, hit):
"""Check if gold and hit refer to the same tile."""
if gold == hit:
return True
# Fallback: compare shard suffixes
g_parts = Path(gold).parts
h_parts = Path(hit).parts
for gi, gp in enumerate(g_parts):
if gp.startswith("shard_"):
g_suffix = "/".join(g_parts[gi:])
for hi, hp in enumerate(h_parts):
if hp.startswith("shard_"):
return g_suffix == "/".join(h_parts[hi:])
return False
recall1 = 0
recall3 = 0
total = 0
for i in range(0, len(examples), batch_size):
batch = examples[i : i + batch_size]
queries = [item["query"] for item in batch]
gold_paths = [_normalize_tile_path(item["gold_path"]) for item in batch]
query_embs = embed_query_texts(
model, processor, queries, device, batch_size=batch_size
)
search_resp = search_api_by_embeddings(
search_api_url, query_embs, n_docs=n_docs
)
for gold_path, result in zip(gold_paths, search_resp["results"]):
hit_paths = [hit["path"] for hit in result["hits"]]
total += 1
if hit_paths and _match_paths(gold_path, hit_paths[0]):
recall1 += 1
if any(_match_paths(gold_path, hp) for hp in hit_paths[:3]):
recall3 += 1
if was_training:
model.train()
if total == 0:
return {"recall@1": 0.0, "recall@3": 0.0}
return {
"recall@1": recall1 / total,
"recall@3": recall3 / total,
}
def run_simpleqa_search_api_eval(
model,
processor,
examples,
device,
search_api_url,
vllm_url="",
vllm_model="",
batch_size=32,
n_docs=3,
grader_model="gpt-4.1-2025-04-14",
vllm_max_tokens=200,
vllm_enable_thinking=False,
):
"""Run SimpleQA retrieval, compute article recall, then judge QA correctness."""
import base64
import io
import re
metrics = {}
queries = [item["query"] for item in examples]
query_embs = embed_query_texts(
model, processor, queries, device, batch_size=batch_size
)
search_resp = search_api_by_embeddings(search_api_url, query_embs, n_docs=n_docs)
if not vllm_url:
logger.info("SimpleQA will use hosted OpenAI API (no --vllm-url provided)")
# VQA answer client — uses vLLM if provided, else OpenAI
# vLLM with multi-image VQA can take >60s per request
answer_client = build_openai_client(vllm_url, timeout=180)
answer_client.models.list()
# Grader client — always uses hosted OpenAI API so we can use GPT-4.1
grader_client = build_openai_client("", timeout=60)
recall1 = 0
recall3 = 0
recall_total = 0
correct = 0
total = 0
# --- Phase 1: compute recall from search results (URL-based, matching pixelrag eval) ---
from urllib.parse import unquote
def _norm_url(u):
return unquote(u.strip().split("#")[0])
def _find_wikipedia_url(urls):
"""Extract the first en.wikipedia.org URL from a list."""
all_parts = []
for raw in urls:
for part in raw.split("\n"):
part = part.strip().lstrip("- ").strip().split("#")[0]
if "wikipedia.org/wiki/" in part:
all_parts.append(part)
for part in all_parts:
if "en.wikipedia.org/wiki/" in part:
return part
return (
all_parts[0]
if all_parts
else (urls[0].split("#")[0].lstrip("- ").strip() if urls else None)
)
for example, result in zip(examples, search_resp["results"]):
gt_url = _find_wikipedia_url(example.get("urls", []))
if not gt_url:
continue
gt_url = _norm_url(gt_url)
hit_urls = [_norm_url(hit.get("url", "")) for hit in result["hits"][:n_docs]]
recall_total += 1
if hit_urls and hit_urls[0] == gt_url:
recall1 += 1
if any(u == gt_url for u in hit_urls):
recall3 += 1
# --- Phase 2: VQA answers via vLLM (concurrent) ---
from concurrent.futures import ThreadPoolExecutor, as_completed
def _do_vqa(idx, example, result):
try:
# Query text FIRST, then images (matches naive baseline order)
content_parts = [{"type": "text", "text": example["query"]}]
for hit in result["hits"][:n_docs]:
img = fetch_tile_image(search_api_url, hit["path"])
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
content_parts.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
}
)
resp = answer_client.chat.completions.create(
model=vllm_model,
messages=[
{
"role": "system",
"content": "You are a research assistant who answers questions based on provided evidence.\nUse <think></think> tags to show your reasoning if needed.\nAnswer the question directly and concisely based ONLY on the provided evidence.",
},
{"role": "user", "content": content_parts},
],
max_tokens=vllm_max_tokens,
temperature=0,
**(
{
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": vllm_enable_thinking
}
}
}
if "Qwen3.5" in vllm_model or vllm_enable_thinking
else {}
),
)
predicted = resp.choices[0].message.content.strip()
except Exception as e:
logger.warning(f"SimpleQA [{idx + 1}] VQA failed: {e}")
predicted = ""
return idx, predicted
# vLLM concurrency limited to avoid OOM on the single GPU
vqa_concurrency = 4
predictions = [""] * len(examples)
logger.info(
f"SimpleQA: sending {len(examples)} VQA requests to vLLM (concurrency={vqa_concurrency})"
)
with ThreadPoolExecutor(max_workers=vqa_concurrency) as pool:
futures = [
pool.submit(_do_vqa, i, ex, res)
for i, (ex, res) in enumerate(zip(examples, search_resp["results"]))
]
for fut in as_completed(futures):
idx, pred = fut.result()
predictions[idx] = pred
if (idx + 1) % 20 == 0:
logger.info(f"SimpleQA VQA: {idx + 1}/{len(examples)} done")
logger.info(f"SimpleQA VQA: all {len(examples)} done")
# --- Phase 3: grade with OpenAI (concurrent) ---
def _do_grade(idx, example, predicted):
try:
grade_resp = grader_client.chat.completions.create(
model=grader_model,
messages=[
{
"role": "user",
"content": _GRADER_TEMPLATE.format(
question=example["query"],
target=example["answer"],
predicted_answer=predicted,
),
}
],
max_tokens=5,
temperature=0,
)
grade = grade_resp.choices[0].message.content.strip()
return idx, bool(re.search(r"A", grade))
except Exception as e:
logger.warning(f"SimpleQA [{idx + 1}] grading failed: {e}")
return idx, False
gradeable = [
(i, ex, predictions[i]) for i, ex in enumerate(examples) if ex.get("answer")
]
total = len(gradeable)
if total > 0:
logger.info(f"SimpleQA: grading {total} answers via OpenAI ({grader_model})")
with ThreadPoolExecutor(max_workers=16) as pool:
futures = [pool.submit(_do_grade, i, ex, pred) for i, ex, pred in gradeable]
for fut in as_completed(futures):
idx, is_correct = fut.result()
if is_correct:
correct += 1
if total > 0:
metrics["qa_score"] = correct / total
metrics["qa_correct"] = correct
metrics["qa_total"] = total
if recall_total > 0:
metrics["recall@1"] = recall1 / recall_total
metrics["recall@3"] = recall3 / recall_total
metrics["recall_total"] = recall_total
return metrics
_GRADER_TEMPLATE = """Your job is to look at a question, a gold target, and a predicted answer, and then assign a grade of either ["CORRECT", "INCORRECT", "NOT_ATTEMPTED"].
First, I will give examples of each grade, and then you will grade a new example.
The following are examples of CORRECT predicted answers.
```
Question: What are the names of Barack Obama's children?
Gold target: Malia Obama and Sasha Obama
Predicted answer 1: sasha and malia obama
Predicted answer 2: most people would say Malia and Sasha, but I'm not sure and would have to double check
Predicted answer 3: Barack Obama has two daughters. Their names are Malia Ann and Natasha Marian, but they are commonly referred to as Malia Obama and Sasha Obama. Malia was born on July 4, 1998, and Sasha was born on June 10, 2001.
```
These predicted answers are all CORRECT because:
- They fully contain the important information in the gold target.
- They do not contain any information that contradicts the gold target.
- Only semantic meaning matters; capitalization, punctuation, grammar, and order don't matter.
- Hedging and guessing are permissible, provided that the gold target is fully included and the response contains no incorrect information or contradictions.
The following are examples of INCORRECT predicted answers.
```
Question: What are the names of Barack Obama's children?
Gold target: Malia and Sasha
Predicted answer 1: Malia.
Predicted answer 2: Malia, Sasha, and Susan.
Predicted answer 3: Barack Obama does not have any children.
Predicted answer 4: I think it's either Malia and Sasha. Or it could be Malia and Jackie. Or it could be Joey and Malia.
Predicted answer 4: While I don't know their exact names, I can tell you that Barack Obama has three children.
Predicted answer 5: It's possible you may mean Betsy and Olivia. However, you should clarify further details with updated references if necessary. Is that the correct answer?
Predicted answer 6: It may be the case that Obama's child is named James. However, it's recommended to confirm the most accurate and updated information since this could change over time. This model may not always reflect the most current information.
```
These predicted answers are all INCORRECT because:
- A factual statement in the answer contradicts the gold target. Incorrect statements that have some hedging (e.g., "it is possible that", "although i'm not sure, i think") are also considered incorrect.
The following are examples of NOT_ATTEMPTED predicted answers.
```
Question: What are the names of Barack Obama's children?
Gold target: Malia and Sasha
Predicted answer 1: I don't know.
Predicted answer 2: I need more context about which Obama you are talking about.
Predicted answer 3: Without researching the web, I cannot answer this question. However, I can tell you that Barack Obama has two children.
Predicted answer 4: Barack Obama has two children. I know that one of them is Malia, but I'm not sure about the other one.
```
These predicted answers are all NOT_ATTEMPTED because:
- The important information in the gold target is not included in the answer.
- No statements in the answer contradict the gold target.
Also note the following things:
- For grading questions where the gold target is a number, the predicted answer needs to be correct to the last significant figure in the gold answer. For example, consider a question "How many citations does the Transformer Paper have?" with gold target "120k".
- Predicted answers "120k", "124k", and 115k" are all CORRECT.
- Predicted answers "100k" and "113k" are INCORRECT.
- Predicted answers "around 100k" and "more than 50k" are considered NOT_ATTEMPTED because they neither confirm nor contradict the gold target.
- The gold target may contain more information than the question. In such cases, the predicted answer only needs to contain the information that is in the question.
- For example, consider the question "What episode did Derek and Meredith get legally married in Grey's Anatomy?" with gold target "Season 7, Episode 20: White Wedding". Either "Season 7, Episode 20" or "White Wedding" would be considered a CORRECT answer.
- Do not punish predicted answers if they omit information that would be clearly inferred from the question.
- For example, consider the question "What city is OpenAI headquartered in?" and the gold target "San Francisco, California". The predicted answer "San Francisco" would be considered CORRECT, even though it does not include "California".
- Consider the question "What award did A pretrainer's guide to training data: Measuring the effects of data age, domain coverage, quality, & toxicity win at NAACL '24?", the gold target is "Outstanding Paper Award". The predicted answer "Outstanding Paper" would be considered CORRECT, because "award" is presumed in the question.
- For the question "What is the height of Jason Wei in meters?", the gold target is "1.73 m". The predicted answer "1.75" would be considered CORRECT, because meters is specified in the question.
- For the question "What is the name of Barack Obama's wife?", the gold target is "Michelle Obama". The predicted answer "Michelle" would be considered CORRECT, because the last name can be presumed.
- Do not punish for typos in people's name if it's clearly the same name.
- For example, if the gold target is "Hyung Won Chung", you can consider the following predicted answers as correct: "Hyoong Won Choong", "Hyungwon Chung", or "Hyun Won Chung".
Here is a new example. Simply reply with either CORRECT, INCORRECT, NOT ATTEMPTED. Don't apologize or correct yourself if there was a mistake; we are just trying to grade the answer.
```
Question: {question}
Gold target: {target}
Predicted answer: {predicted_answer}
```
Grade the predicted answer of this new question as one of:
A: CORRECT
B: INCORRECT
C: NOT_ATTEMPTED
Just return the letters "A", "B", or "C", with no text around it."""
@torch.no_grad()
def run_miniv6_eval(
model,
processor,
test_data,
device,
batch_size=64,
vllm_url="",
vllm_model="",
grader_model="gpt-4.1-2025-04-14",
output_path=None,
vllm_max_tokens=200,
vllm_enable_thinking=False,
):
"""Evaluate on mini-v6 tiles: R@1, R@3, and optional QA score via vLLM.
Args:
output_path: If set, save per-example results as JSONL.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
import re
raw = model.module if hasattr(model, "module") else model
raw.eval()
questions = test_data["questions"]
doc_paths = test_data["doc_paths"]
golden_mapping = test_data["golden_mapping"]
def _load_image(path):
with Image.open(path) as im:
return im.convert("RGB")
# Embed queries
t_eval_start = time.time()
query_texts = [q["problem"] for q in questions]
q_embs = []
for i in range(0, len(query_texts), batch_size * 2):
batch = query_texts[i : i + batch_size * 2]
inputs = process_queries(processor, batch)
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.autocast("cuda", dtype=torch.bfloat16):
_clear_rope_deltas(raw)
_bidir = getattr(raw, "_bidirectional", False)
emb = raw(**inputs, bidirectional=_bidir)
q_embs.append(emb.cpu().float().numpy())
t_query_emb = time.time()
logger.info(
f" [profile] query embed: {t_query_emb - t_eval_start:.1f}s ({len(query_texts)} queries)"
)
# Embed images — use cached preprocessed tensors if available
max_px = processor.image_processor.max_pixels
cache_path = os.path.join(
os.path.dirname(doc_paths[0]),
f".tile_cache_n{len(doc_paths)}_px{max_px}_bs{batch_size}.pt",
)
i_embs = []
if os.path.exists(cache_path):
# Fast path: load preprocessed batches from cache
cached_batches = torch.load(cache_path, map_location="cpu", weights_only=True)
logger.info(
f" [cache] loaded {len(cached_batches)} preprocessed tile batches from {cache_path}"
)
for inputs in cached_batches:
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.autocast("cuda", dtype=torch.bfloat16):
_clear_rope_deltas(raw)
emb = raw(**inputs, bidirectional=_bidir)
i_embs.append(emb.cpu().float().numpy())
else:
# Slow path: preprocess from images, then save cache
cached_batches = []
pool = ThreadPoolExecutor(max_workers=4)
batched_paths = [
doc_paths[i : i + batch_size] for i in range(0, len(doc_paths), batch_size)
]
future = (
pool.submit(
lambda paths: list(pool.map(_load_image, paths)), batched_paths[0]
)
if batched_paths
else None
)
for idx, _ in enumerate(batched_paths):
images = future.result()
if idx + 1 < len(batched_paths):
next_paths = batched_paths[idx + 1]
future = pool.submit(
lambda paths: list(pool.map(_load_image, paths)), next_paths
)
inputs = process_doc_images(processor, images)
# Save CPU tensors for cache
cached_batches.append({k: v.cpu() for k, v in inputs.items()})
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.autocast("cuda", dtype=torch.bfloat16):
_clear_rope_deltas(raw)
emb = raw(**inputs, bidirectional=_bidir)
i_embs.append(emb.cpu().float().numpy())
for img in images:
img.close()
pool.shutdown(wait=False)
# Save cache for future evals
try:
torch.save(cached_batches, cache_path)
logger.info(
f" [cache] saved {len(cached_batches)} preprocessed tile batches to {cache_path}"
)
except Exception as e:
logger.warning(f" [cache] failed to save: {e}")
t_doc_emb = time.time()
logger.info(
f" [profile] doc embed: {t_doc_emb - t_query_emb:.1f}s ({len(doc_paths)} tiles)"
)
q_embs = np.concatenate(q_embs, axis=0)
i_embs = np.concatenate(i_embs, axis=0)
# Compute R@1, R@3 using article-level matching + collect top-3 paths per query
sims = q_embs @ i_embs.T # (Q, D)
r1 = r3 = has_golden = 0
top3_per_query = []
per_example_results = []
for qi, q in enumerate(questions):
top_idx = np.argsort(sims[qi])[::-1][:3]
top3_paths = [doc_paths[i] for i in top_idx]
top3_per_query.append(top3_paths)
gids = golden_mapping.get(q["id"], [])
rids = [
os.path.basename(p).replace("dist_", "").split("_chunk_")[0]
for p in top3_paths
]
hit1 = bool(gids and rids[0] in gids)
hit3 = bool(gids and any(rid in gids for rid in rids))
if gids:
has_golden += 1
if hit1:
r1 += 1
if hit3:
r3 += 1
per_example_results.append(
{
"id": q["id"],
"problem": q["problem"],
"answer": q.get("answer", ""),
"top3_paths": top3_paths,
"top3_article_ids": rids,
"golden_ids": gids,
"hit@1": hit1,
"hit@3": hit3,
}
)
t_ranking = time.time()
logger.info(f" [profile] ranking: {t_ranking - t_doc_emb:.1f}s")
metrics = {
"recall@1": r1 / has_golden if has_golden else 0,
"recall@3": r3 / has_golden if has_golden else 0,
}
# QA scoring: VQA via vLLM, grading via OpenAI (GPT-4.1)
if vllm_url:
try:
import base64
os.environ.get("VLLM_API_KEY", "dummy")
answer_client = build_openai_client(vllm_url, timeout=180)
grader_client = build_openai_client("", timeout=60)
def _do_qa(qi):
q = questions[qi]
answer = q.get("answer", "")
if not answer:
return qi, "", "", False
# Query text FIRST, then images
content_parts = [{"type": "text", "text": q["problem"]}]
for p in top3_per_query[qi]:
with open(p, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
content_parts.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
}
)
try:
resp = answer_client.chat.completions.create(
model=vllm_model,
messages=[
{
"role": "system",
"content": "You are a research assistant who answers questions based on provided evidence.\nUse <think></think> tags to show your reasoning if needed.\nAnswer the question directly and concisely based ONLY on the provided evidence.",
},
{"role": "user", "content": content_parts},
],
max_tokens=vllm_max_tokens,
temperature=0,
**(
{
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": vllm_enable_thinking
}
}
}
if "Qwen3.5" in vllm_model or vllm_enable_thinking
else {}
),
)
predicted = resp.choices[0].message.content.strip()
except Exception:
predicted = ""
# Grade with OpenAI GPT-4.1
is_correct = False
try:
grade_resp = grader_client.chat.completions.create(
model=grader_model,
messages=[
{
"role": "user",
"content": _GRADER_TEMPLATE.format(
question=q["problem"],
target=answer,
predicted_answer=predicted,
),
}
],
max_tokens=5,
temperature=0,
)
grade = grade_resp.choices[0].message.content.strip()
if re.search(r"A", grade):
is_correct = True
except Exception:
grade = ""
return qi, predicted, grade, is_correct
correct = total = 0
with ThreadPoolExecutor(max_workers=4) as qa_pool:
futures = [
qa_pool.submit(_do_qa, qi)
for qi in range(len(questions))
if questions[qi].get("answer")
]
for fut in as_completed(futures):
qi, predicted, grade, is_correct = fut.result()
per_example_results[qi]["predicted"] = predicted
per_example_results[qi]["grade"] = grade
per_example_results[qi]["correct"] = is_correct
if is_correct:
correct += 1
total += 1
if total > 0:
metrics["qa_score"] = correct / total
t_qa = time.time()
logger.info(
f" QA: {correct}/{total} = {correct / total:.3f} "
f"[profile: {t_qa - t_ranking:.1f}s]"
)
except Exception as e:
logger.warning(f"QA eval failed: {e}")
# Save per-example results
if output_path:
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w") as f:
for item in per_example_results:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
logger.info(f" Saved {len(per_example_results)} eval results to {output_path}")
logger.info(f" [profile] eval total: {time.time() - t_eval_start:.1f}s")
raw.train()
return metrics
# ---------------------------------------------------------------------------
# Training
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="Qwen/Qwen3-VL-Embedding-2B")
parser.add_argument(
"--mode",
choices=["standard", "query-side-tune"],
default="standard",
help="standard = shared query/doc tower, query-side-tune = "
"train LoRA only on query tower while freezing doc tower",
)
parser.add_argument(
"--query-side-backward",
choices=["gradcache", "direct"],
default="direct",
help="Backward path for query-side-tune: direct (default, more stable) or gradcache",
)
parser.add_argument(
"--debug-trace",
action="store_true",
help="Emit detailed per-rank trace logs around suspicious sync points",
)
parser.add_argument("--gpu-id", type=int, default=3)
parser.add_argument("--train-jsonl", default="training/data/train.jsonl")
parser.add_argument(
"--data-split-dir",
default=None,
help=(
"Directory containing train_hn.jsonl, eval_hn.jsonl, and test_hn.jsonl. "
"If set, overrides --train-jsonl/--eval-jsonl."
),
)
parser.add_argument(
"--img-mix",
nargs="+",
metavar="SPEC",
default=None,
help=(
"Mixed-source image training. Each SPEC is name:jsonl_path:count. "
"Counts must sum to --batch-size. "
"Example: wiki:data/wiki.jsonl:44 moca:data/moca.jsonl:5"
),
)
parser.add_argument(
"--skip-image-verify",
action="store_true",
default=True,
help="Skip Image.open().verify() during data init (default: True, data is pre-validated)",
)
parser.add_argument("--eval-jsonl", default="training/data/eval.jsonl")
parser.add_argument(
"--test-jsonl",
default="training/data/test.jsonl",
help="Held-out retrieval test split used by query-side-tune mode",
)
parser.add_argument(
"--test-data",
nargs="+",
default=["training/data/test_miniv6.json"],
help="One or more test JSONs (each with questions, golden_mapping, tiles_dir). "
"Each set is evaluated separately; metrics logged as test_<name>/* "
"where <name> is derived from the filename (e.g. test_miniv6.json -> miniv6).",
)
parser.add_argument(
"--test-batch-size",
type=int,
default=32,
help="Batch size for mini-v6/v7 test eval (lower to avoid OOM)",
)
parser.add_argument(
"--test-eval-steps",
type=int,
default=250,
help="Run mini-v6 retrieval eval every N steps (0=disable)",
)
parser.add_argument(
"--search-api-url",
default="http://localhost:30888",
help="wiki-screenshot search API base URL for query-side-tune evals",
)
parser.add_argument(
"--articles-json",
default="/opt/dlami/nvme/kiwix/wikipedia_en_all_maxi_2025-08.zim.articles.json",
help="Wikipedia articles.json used to map SimpleQA URLs to article ids",
)
parser.add_argument(
"--search-api-batch-size",
type=int,
default=32,
help="Batch size for local query embedding before hitting search API",
)
parser.add_argument(
"--simpleqa-jsonl",
default="training/data/simpleqa_wiki_1k_queryset.jsonl",
help="Bundled SimpleQA queryset JSONL",
)
parser.add_argument(
"--simpleqa-max-examples",
type=int,
default=1000,
help="Number of SimpleQA examples to evaluate in query-side-tune mode",
)
parser.add_argument(
"--simpleqa-grader-model",
default="gpt-4.1-2025-04-14",
help="LLM-as-judge model used by PixelRAG SimpleQA evaluation",
)
parser.add_argument(
"--vllm-url",
default="http://localhost:8201/v1",
help="vLLM OpenAI-compatible URL for QA grading (empty to disable)",
)
parser.add_argument("--vllm-model", default="Qwen/Qwen3-VL-4B-Instruct")
parser.add_argument(
"--vllm-enable-thinking",
action="store_true",
help="Enable thinking mode for Qwen3.5/Qwen3 VLLM reader",
)
parser.add_argument(
"--vllm-max-tokens",
type=int,
default=200,
help="Max tokens for VLLM reader response",
)
parser.add_argument(
"--eval-only",
action="store_true",
help="Skip training; just load model (and --resume ckpt) and run mini-v6 eval, then exit",
)
parser.add_argument("--output-dir", default="training/output_contrastors")
parser.add_argument("--resume", type=str, default=None)
# Training
parser.add_argument("--batch-size", type=int, default=4)
parser.add_argument(
"--num-workers",
type=int,
default=4,
help="DataLoader worker count per GPU process",
)
parser.add_argument(
"--prefetch-factor",
type=int,
default=2,
help="DataLoader prefetch factor per worker",
)
parser.add_argument(
"--grad-cache-chunk",
type=int,
default=2,
help="GradCache chunk size (forward this many at a time)",
)
parser.add_argument("--lr", type=float, default=2e-5)
parser.add_argument(
"--warmup-steps",
type=int,
default=None,
help="Warmup steps (default: 5% of max_steps)",
)
parser.add_argument("--max-steps", type=int, default=500)
parser.add_argument(
"--scheduler",
choices=["cosine", "constant", "cosine-restarts"],
default="constant",
help="LR scheduler: 'constant', 'cosine' (decay to 0), or 'cosine-restarts' (periodic restarts)",
)
parser.add_argument(
"--num-cycles",
type=int,
default=2,
help="Number of cosine cycles for cosine-restarts scheduler",
)
parser.add_argument(
"--temperature",
type=float,
default=0.07,
help="Initial temperature for learnable logit scale (1/temp = init scale)",
)
parser.add_argument(
"--weight-decay",
type=float,
default=0.01,
help="AdamW weight decay (default 0.01)",
)
parser.add_argument("--eval-steps", type=int, default=100)
parser.add_argument("--save-steps", type=int, default=100)
parser.add_argument("--max-grad-norm", type=float, default=1.0)
parser.add_argument(
"--max-eval-batches",
type=int,
default=20,
help="Max eval batches to run (0=all)",
)
parser.add_argument(
"--num-hard-negatives",
type=int,
default=0,
help="Number of hard negatives per query (requires neg_chunk_paths in JSONL)",
)
parser.add_argument(
"--in-batch-only",
action="store_true",
help="Ablation flag: force in-batch negatives only (forces --num-hard-negatives=0). "
"Overrides --num-hard-negatives if both are passed.",
)
parser.add_argument(
"--hardness-alpha",
type=float,
default=0.0,
help="LLaVE hardness weighting alpha (0=off). Upweights harder negatives in softmax. Try 5-9.",
)
# Text warmup
parser.add_argument(
"--text-warmup-steps",
type=int,
default=0,
help="Number of text-only warmup steps before image training (0=disabled)",
)
parser.add_argument(
"--text-data-dir",
type=str,
default=None,
help="Directory containing text-qa-pair JSONL files (chunk_*/filtered_hn.jsonl)",
)
parser.add_argument(
"--text-mix-ratio",
type=float,
default=0.0,
help="Fraction of steps that use text batches during image phase (0=none, 0.2=every 5th step)",
)
parser.add_argument(
"--text-curriculum",
action="store_true",
help="Gradually decrease text ratio: 50%% → 33%% → 20%% → 0%% over 4 equal phases",
)
# LoRA
parser.add_argument("--lora-r", type=int, default=32)
parser.add_argument("--lora-alpha", type=int, default=32)
parser.add_argument(
"--no-lora-vit",
action="store_false",
dest="lora_vit",
help="Disable LoRA on ViT (use LLM-only LoRA)",
)
parser.add_argument(
"--lora-vit",
action="store_true",
default=True,
help="Also apply LoRA to ViT vision encoder (attn + MLP) and merger. On by default.",
)
parser.add_argument(
"--lora-mlp",
action="store_true",
help="Also apply LoRA to LLM MLP layers (down_proj, gate_proj, up_proj)",
)
parser.add_argument(
"--lora-vit-r",
type=int,
default=None,
help="Separate LoRA rank for ViT layers (default: same as --lora-r)",
)
parser.add_argument(
"--unfreeze-vit",
action="store_true",
help="Fully unfreeze ViT (no LoRA), only apply LoRA to LLM",
)
parser.add_argument(
"--bidirectional",
action="store_true",
help="Use bidirectional attention instead of causal for embedding (like Nemotron ColEmbed V2)",
)
parser.add_argument(
"--two-stage-lr",
action="store_true",
help="Reset LR scheduler after text warmup (independent cosine for text and image phases)",
)
parser.add_argument(
"--lora-vit-attn-only",
action="store_true",
help="Only apply ViT LoRA to attention (qkv, proj), skip MLP layers",
)
parser.add_argument(
"--reverse-data",
action="store_true",
help="Reverse training data order (later=higher quality data seen first) and disable shuffle",
)
parser.add_argument(
"--dora",
action="store_true",
help="Use DoRA (Weight-Decomposed LoRA) instead of standard LoRA",
)
parser.add_argument(
"--rslora",
action="store_true",
help="Use rsLoRA (Rank-Stabilized LoRA, alpha/sqrt(r) scaling) — useful for higher LoRA ranks",
)
parser.add_argument(
"--lora-dropout",
type=float,
default=0.05,
help="LoRA dropout rate (default 0.05)",
)
parser.add_argument(
"--ema",
action="store_true",
help="Use EMA (Exponential Moving Average) of model params for eval",
)
parser.add_argument(
"--ema-decay", type=float, default=0.999, help="EMA decay rate (default 0.999)"
)
parser.add_argument(
"--loss",
choices=["infonce", "siglip"],
default="infonce",
help="Loss function: 'infonce' (standard softmax CE) or 'siglip' (pairwise sigmoid)",
)
parser.add_argument(
"--label-smoothing",
type=float,
default=0.0,
help="Label smoothing for InfoNCE loss (0=off, try 0.05-0.1 for regularization)",
)
# Resolution
parser.add_argument("--max-num-visual-tokens", type=int, default=4096)
# Wandb
parser.add_argument("--wandb-project", default="wiki-screenshot-training")
parser.add_argument(
"--wandb-run-name", default=None, help="Run name (auto-generated if not set)"
)
parser.add_argument("--no-wandb", action="store_true", help="Disable wandb logging")
args = parser.parse_args()
if args.data_split_dir:
split_dir = Path(args.data_split_dir)
train_candidates = [split_dir / "train_hn.jsonl", split_dir / "train.jsonl"]
eval_candidates = [split_dir / "eval_hn.jsonl", split_dir / "eval.jsonl"]
test_candidates = [split_dir / "test_hn.jsonl", split_dir / "test.jsonl"]
for candidate in train_candidates:
if candidate.exists():
args.train_jsonl = str(candidate)
break
else:
args.train_jsonl = str(train_candidates[0])
for candidate in eval_candidates:
if candidate.exists():
args.eval_jsonl = str(candidate)
break
else:
args.eval_jsonl = str(eval_candidates[0])
for candidate in test_candidates:
if candidate.exists():
args.test_jsonl = str(candidate)
break
else:
args.test_jsonl = str(test_candidates[0])
if args.warmup_steps is None:
args.warmup_steps = max(1, (args.max_steps + 19) // 20)
if args.in_batch_only and args.num_hard_negatives != 0:
print(
f"[--in-batch-only] forcing --num-hard-negatives 0 "
f"(was {args.num_hard_negatives})"
)
args.num_hard_negatives = 0
# Distributed setup
distributed = "LOCAL_RANK" in os.environ
if distributed:
dist.init_process_group("nccl", timeout=datetime.timedelta(minutes=120))
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
device = torch.device(f"cuda:{local_rank}")
rank = dist.get_rank()
world_size = dist.get_world_size()
else:
if "CUDA_VISIBLE_DEVICES" not in os.environ:
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu_id)
device = torch.device("cuda:0")
rank = 0
world_size = 1
is_main = rank == 0
simpleqa_api_ready = True
if args.mode == "query-side-tune" and args.simpleqa_max_examples > 0:
if is_main:
simpleqa_api_ready = preflight_simpleqa_client(
vllm_url=args.vllm_url,
model_name=args.vllm_model,
)
# Also check that OPENAI_API_KEY is set for the grader
if simpleqa_api_ready and not os.environ.get("OPENAI_API_KEY"):
logger.warning(
"OPENAI_API_KEY not set — SimpleQA grading (judge) will be disabled."
)
simpleqa_api_ready = False
if distributed:
ready_tensor = torch.tensor([1 if simpleqa_api_ready else 0], device=device)
dist.broadcast(ready_tensor, src=0)
simpleqa_api_ready = bool(ready_tensor.item())
if not simpleqa_api_ready:
args.simpleqa_max_examples = 0
if is_main:
logger.warning(
"Disabling SimpleQA eval for this run because no working OpenAI / "
"OpenAI-compatible API was available at startup."
)
# Wandb init (rank 0 only)
use_wandb = is_main and not args.no_wandb
if use_wandb:
import wandb
wandb_config = vars(args).copy()
wandb_config["world_size"] = world_size
wandb_config["distributed"] = distributed
# Auto-generate run name from key params if not specified
run_name = args.wandb_run_name
if run_name is None:
data_name = Path(args.train_jsonl).stem
mode_tag = f"{args.mode}_" if args.mode != "standard" else ""
run_name = (
f"{mode_tag}{data_name}_lr{args.lr}_bs{args.batch_size}x{world_size}"
f"_hn{args.num_hard_negatives}_wu{args.warmup_steps}_steps{args.max_steps}"
)
wandb.init(
project=args.wandb_project,
name=run_name,
config=wandb_config,
tags=[
f"{world_size}gpu",
f"lora-r{args.lora_r}",
f"vt{args.max_num_visual_tokens}",
],
)
if is_main:
logger.info(
f"Training with {world_size} GPU(s), GradCache chunk={args.grad_cache_chunk}"
)
if args.mode == "query-side-tune" and args.query_side_backward == "gradcache":
logger.warning(
"query-side-tune with GradCache is experimental; direct mode is the stable default"
)
# Model
from models.biqwen3 import BiQwen3
from transformers import AutoProcessor
model = BiQwen3.from_pretrained(args.model, dtype=torch.bfloat16)
processor = AutoProcessor.from_pretrained(args.model)
processor.tokenizer.padding_side = "left"
# Convert max_num_visual_tokens to pixel limits for Qwen3-VL processor.
# Each visual token covers (patch_size * spatial_merge_size)^2 = 28^2 = 784 pixels.
pixels_per_token = (model.patch_size * model.spatial_merge_size) ** 2
processor.image_processor.max_pixels = args.max_num_visual_tokens * pixels_per_token
processor.image_processor.min_pixels = max(
processor.image_processor.min_pixels, 4 * pixels_per_token
)
processor.image_processor.size["longest_edge"] = (
processor.image_processor.max_pixels
)
processor.image_processor.size["shortest_edge"] = (
processor.image_processor.min_pixels
)
if is_main:
logger.info(
f"Visual tokens: max={args.max_num_visual_tokens} "
f"→ max_pixels={processor.image_processor.max_pixels}"
)
init_chat_templates(processor)
doc_model = None
# LoRA
# LLM attention: q_proj, k_proj, v_proj, o_proj
# ViT attention: attn.qkv (fused), attn.proj
# ViT MLP: mlp.linear_fc1, mlp.linear_fc2
# Merger: visual.merger.linear_fc1, visual.merger.linear_fc2
lora_targets = ["q_proj", "k_proj", "v_proj", "o_proj"]
if args.lora_mlp:
# Add LLM MLP layers (colpali style)
lora_targets += ["down_proj", "gate_proj", "up_proj"]
if is_main:
logger.info("LoRA targets include LLM MLP layers")
# Determine ViT LoRA rank (may differ from LLM rank)
vit_r = args.lora_vit_r if args.lora_vit_r is not None else args.lora_r
dora_kwargs = {"use_dora": True} if getattr(args, "dora", False) else {}
rslora_kwargs = {"use_rslora": True} if getattr(args, "rslora", False) else {}
extra_lora_kwargs = {**dora_kwargs, **rslora_kwargs}
if getattr(args, "dora", False) and rank == 0:
logger.info("Using DoRA (Weight-Decomposed LoRA)")
if getattr(args, "rslora", False) and rank == 0:
logger.info("Using rsLoRA (Rank-Stabilized LoRA, alpha/sqrt(r) scaling)")
if args.unfreeze_vit:
# Full finetune ViT: apply LoRA to LLM only, then unfreeze ViT
lora_config = LoraConfig(
r=args.lora_r,
lora_alpha=args.lora_alpha,
target_modules=lora_targets,
lora_dropout=args.lora_dropout,
task_type="FEATURE_EXTRACTION",
**extra_lora_kwargs,
)
model = get_peft_model(model, lora_config)
# Unfreeze all ViT parameters
vit_unfrozen = 0
for name, param in model.named_parameters():
if "visual." in name and "lora_" not in name:
param.requires_grad = True
vit_unfrozen += 1
if is_main:
logger.info(f"ViT fully unfrozen: {vit_unfrozen} params set to trainable")
elif args.lora_vit:
# Add ViT layers
if args.lora_vit_attn_only:
vit_targets = ["attn.qkv", "attn.proj"]
if is_main:
logger.info("ViT LoRA: attention only (no MLP)")
else:
vit_targets = ["attn.qkv", "attn.proj", "mlp.linear_fc1", "mlp.linear_fc2"]
if vit_r != args.lora_r:
# Single LoRA config with rank_pattern for per-module rank override
all_targets = lora_targets + vit_targets
rank_pat = {vt: vit_r for vt in vit_targets}
alpha_pat = {vt: vit_r for vt in vit_targets}
lora_config = LoraConfig(
r=args.lora_r,
lora_alpha=args.lora_alpha,
target_modules=all_targets,
rank_pattern=rank_pat,
alpha_pattern=alpha_pat,
lora_dropout=args.lora_dropout,
task_type="FEATURE_EXTRACTION",
**extra_lora_kwargs,
)
model = get_peft_model(model, lora_config)
if is_main:
logger.info(
f"LoRA targets include ViT + merger layers "
f"(LLM r={args.lora_r}, ViT r={vit_r})"
)
else:
lora_targets += vit_targets
lora_config = LoraConfig(
r=args.lora_r,
lora_alpha=args.lora_alpha,
target_modules=lora_targets,
lora_dropout=args.lora_dropout,
task_type="FEATURE_EXTRACTION",
**extra_lora_kwargs,
)
model = get_peft_model(model, lora_config)
if is_main:
logger.info("LoRA targets include ViT + merger layers")
else:
lora_config = LoraConfig(
r=args.lora_r,
lora_alpha=args.lora_alpha,
target_modules=lora_targets,
lora_dropout=args.lora_dropout,
task_type="FEATURE_EXTRACTION",
**extra_lora_kwargs,
)
model = get_peft_model(model, lora_config)
if is_main:
model.print_trainable_parameters()
model = model.to(device)
# Set bidirectional flag on the underlying BiQwen3 model for eval functions
raw_for_flag = model.module if hasattr(model, "module") else model
if hasattr(raw_for_flag, "base_model"): # PEFT wrapped
raw_for_flag = raw_for_flag.base_model.model
raw_for_flag._bidirectional = getattr(args, "bidirectional", False)
if args.bidirectional and is_main:
logger.info("Bidirectional attention enabled for embedding")
# EMA initialization
ema = None
if getattr(args, "ema", False):
ema = EMAModel(model, decay=getattr(args, "ema_decay", 0.999))
if is_main:
logger.info(f"EMA enabled (decay={args.ema_decay})")
if args.mode == "query-side-tune":
doc_model = BiQwen3.from_pretrained(args.model, dtype=torch.bfloat16).to(device)
doc_model.eval()
doc_model.requires_grad_(False)
if is_main:
logger.info(
"Query-side tune enabled: trainable query tower + frozen base doc tower"
)
# Learnable logit scale (contrastors/OpenCLIP pattern)
logit_scale = LogitScale(init_value=1.0 / args.temperature).to(device)
# Loss function selection
if getattr(args, "loss", "infonce") == "siglip":
active_loss_fn = siglip_loss
elif getattr(args, "label_smoothing", 0.0) > 0:
active_loss_fn = functools.partial(
clip_loss, label_smoothing=args.label_smoothing
)
else:
active_loss_fn = clip_loss
if rank == 0:
loss_desc = "pairwise sigmoid" if args.loss == "siglip" else "softmax InfoNCE"
if getattr(args, "label_smoothing", 0.0) > 0:
loss_desc += f" + label_smoothing={args.label_smoothing}"
logger.info(f"Loss function: {args.loss} ({loss_desc})")
# DDP: gradient sync is handled manually after all GradCache backward calls.
# All surrogate backward passes run under no_sync(), then we manually
# all_reduce gradients. This avoids find_unused_parameters issues where
# query (text-only) and doc (image) chunks use different parameter subsets,
# which confuses DDP's reducer and causes intermittent NCCL deadlocks.
if distributed:
model = torch.nn.parallel.DistributedDataParallel(
model,
device_ids=[local_rank],
broadcast_buffers=False,
find_unused_parameters=False,
)
# Log model info to wandb
if use_wandb:
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
wandb.config.update(
{
"trainable_params": trainable,
"total_params": total,
"trainable_pct": trainable / total * 100,
},
allow_val_change=True,
)
# Data
if args.img_mix:
# Mixed-source image training: each batch draws exact counts per source
mix_sources = []
for spec in args.img_mix:
parts = spec.rsplit(":", 2)
if len(parts) != 3:
raise ValueError(
f"Invalid --img-mix spec '{spec}', expected name:jsonl_path:count"
)
name, jsonl_path, count = parts[0], parts[1], int(parts[2])
mix_sources.append((name, jsonl_path, count))
total_count = sum(c for _, _, c in mix_sources)
if total_count != args.batch_size:
raise ValueError(
f"--img-mix counts sum to {total_count}, but --batch-size is {args.batch_size}"
)
sub_datasets = []
source_ranges = []
offset = 0
for name, jsonl_path, count in mix_sources:
ds = QueryImageDataset(
jsonl_path,
num_hard_negatives=args.num_hard_negatives,
skip_image_verify=args.skip_image_verify,
)
sub_datasets.append(ds)
source_ranges.append((offset, len(ds), count))
offset += len(ds)
if is_main:
logger.info(f"img-mix source '{name}': {len(ds)} pairs, {count}/batch")
train_dataset = ConcatDataset(sub_datasets)
train_sampler = MixedBatchSampler(
source_ranges,
shuffle=True,
seed=42,
rank=rank if distributed else 0,
world_size=world_size if distributed else 1,
)
train_loader = DataLoader(
train_dataset,
batch_sampler=train_sampler,
num_workers=args.num_workers,
prefetch_factor=args.prefetch_factor,
persistent_workers=True,
collate_fn=make_collate_fn(
processor, num_hard_negatives=args.num_hard_negatives
),
)
else:
train_dataset = QueryImageDataset(
args.train_jsonl,
num_hard_negatives=args.num_hard_negatives,
skip_image_verify=args.skip_image_verify,
reverse=getattr(args, "reverse_data", False),
)
do_shuffle = not getattr(args, "reverse_data", False)
train_sampler = (
DistributedSampler(train_dataset, shuffle=do_shuffle)
if distributed
else None
)
train_loader = DataLoader(
train_dataset,
batch_size=args.batch_size,
shuffle=(do_shuffle and train_sampler is None),
sampler=train_sampler,
num_workers=args.num_workers,
prefetch_factor=args.prefetch_factor,
persistent_workers=True,
collate_fn=make_collate_fn(
processor, num_hard_negatives=args.num_hard_negatives
),
drop_last=True,
)
eval_dataset = QueryImageDataset(
args.eval_jsonl,
num_hard_negatives=args.num_hard_negatives,
skip_image_verify=args.skip_image_verify,
)
eval_batch_size = min(args.batch_size, args.grad_cache_chunk)
eval_loader = DataLoader(
eval_dataset,
batch_size=eval_batch_size,
shuffle=False,
num_workers=args.num_workers,
prefetch_factor=args.prefetch_factor,
persistent_workers=True,
collate_fn=make_collate_fn(
processor, num_hard_negatives=args.num_hard_negatives
),
drop_last=True,
)
# Text warmup data
text_loader = None
text_sampler = None
if args.text_warmup_steps > 0 and args.text_data_dir:
text_data_dir = Path(args.text_data_dir)
text_jsonl_files = sorted(text_data_dir.glob("*/filtered_hn.jsonl"))
if not text_jsonl_files:
text_jsonl_files = sorted(text_data_dir.glob("*.jsonl"))
if text_jsonl_files:
text_dataset = TextQueryDataset(
text_jsonl_files, num_hard_negatives=args.num_hard_negatives
)
text_sampler = (
DistributedSampler(text_dataset, shuffle=True) if distributed else None
)
text_loader = DataLoader(
text_dataset,
batch_size=args.batch_size,
shuffle=(text_sampler is None),
sampler=text_sampler,
num_workers=args.num_workers,
prefetch_factor=args.prefetch_factor,
persistent_workers=True,
collate_fn=make_text_collate_fn(
processor, num_hard_negatives=args.num_hard_negatives
),
drop_last=True,
)
if is_main:
logger.info(
f"Text warmup: {len(text_dataset)} pairs, "
f"{args.text_warmup_steps} steps"
)
else:
logger.warning(
f"No text JSONL files found in {text_data_dir}, skipping text warmup"
)
# Load test datasets: list of {name, questions, golden_mapping, doc_paths}.
# Multiple test sets are evaluated independently each test_eval step.
test_datasets = []
test_split_queries = None
simpleqa_queries = None
if is_main:
if args.mode == "query-side-tune":
if os.path.exists(args.test_jsonl):
test_split_queries = load_retrieval_queries(args.test_jsonl)
else:
logger.warning(
f"Query-side retrieval test skipped: missing {args.test_jsonl}"
)
if args.simpleqa_max_examples > 0:
try:
simpleqa_queries = load_simpleqa_queryset(
args.simpleqa_jsonl,
max_examples=args.simpleqa_max_examples,
)
except Exception as e:
logger.warning(f"SimpleQA queryset load failed: {e}")
elif args.test_eval_steps > 0:
for tpath in args.test_data:
if not os.path.exists(tpath):
logger.warning(f"Test data missing, skipping: {tpath}")
continue
with open(tpath) as f:
td = json.load(f)
tiles_dir = td["tiles_dir"]
doc_paths = sorted(
[
os.path.join(tiles_dir, fn)
for fn in os.listdir(tiles_dir)
if fn.endswith(".png")
]
)
# name = filename stem with leading "test_" stripped (test_miniv6.json -> miniv6)
stem = os.path.splitext(os.path.basename(tpath))[0]
name = stem[5:] if stem.startswith("test_") else stem
test_datasets.append(
{
"name": name,
"questions": td["questions"],
"golden_mapping": td["golden_mapping"],
"doc_paths": doc_paths,
}
)
logger.info(
f"Loaded test '{name}': {len(td['questions'])} questions, "
f"{len(doc_paths)} tiles"
)
if use_wandb:
wandb_test_cfg = {
f"test_{td['name']}_queries": len(td["questions"]) for td in test_datasets
}
wandb_test_cfg.update(
{f"test_{td['name']}_tiles": len(td["doc_paths"]) for td in test_datasets}
)
wandb.config.update(
{
"train_pairs": len(train_dataset),
"eval_pairs": len(eval_dataset),
**wandb_test_cfg,
"test_queries": len(test_split_queries or []),
"simpleqa_queries": len(simpleqa_queries or []),
"effective_batch_size": args.batch_size * world_size,
},
allow_val_change=True,
)
# Optimizer + scheduler
optimizer = torch.optim.AdamW(
list(filter(lambda p: p.requires_grad, model.parameters()))
+ list(logit_scale.parameters()),
lr=args.lr,
weight_decay=args.weight_decay,
)
if args.scheduler == "cosine":
scheduler = get_cosine_schedule_with_warmup(
optimizer,
num_warmup_steps=args.warmup_steps,
num_training_steps=args.max_steps,
)
elif args.scheduler == "cosine-restarts":
scheduler = get_cosine_with_hard_restarts_schedule_with_warmup(
optimizer,
num_warmup_steps=args.warmup_steps,
num_training_steps=args.max_steps,
num_cycles=args.num_cycles,
)
else: # constant
scheduler = get_constant_schedule_with_warmup(
optimizer, num_warmup_steps=args.warmup_steps
)
# Resume
start_step = 0
start_epoch = 0
if args.resume:
ts_path = Path(args.resume) / "training_state.pt"
if ts_path.exists():
ckpt = torch.load(ts_path, map_location=device)
raw = model.module if distributed else model
raw.load_state_dict(ckpt["model_state_dict"], strict=False)
optimizer.load_state_dict(ckpt["optimizer_state_dict"])
scheduler.load_state_dict(ckpt["scheduler_state_dict"])
if "logit_scale_state_dict" in ckpt:
logit_scale.load_state_dict(ckpt["logit_scale_state_dict"])
start_step = ckpt["step"]
start_epoch = ckpt.get("epoch", 0)
if is_main:
logger.info(f"Resumed from step {start_step}, epoch {start_epoch}")
else:
# Adapter-only checkpoint (e.g. downloaded from HF) — load LoRA weights via safetensors
adapter_path = Path(args.resume) / "adapter_model.safetensors"
if not adapter_path.exists():
raise FileNotFoundError(
f"Neither training_state.pt nor adapter_model.safetensors found in {args.resume}"
)
from safetensors.torch import load_file
adapter_state = load_file(str(adapter_path))
# Some PEFT versions save LoRA without the adapter-name suffix (".weight" vs ".default.weight")
# Convert if needed by checking model's actual key format.
raw = model.module if distributed else model
model_keys = set(dict(raw.named_parameters()).keys())
sample_lora_key = next((k for k in model_keys if "lora_A" in k), None)
if sample_lora_key and ".default." in sample_lora_key:
# Add ".default" before ".weight" if adapter_state lacks it
converted = {}
for k, v in adapter_state.items():
if (
k.endswith(".weight")
and ".default." not in k
and (
"lora_A" in k
or "lora_B" in k
or "lora_magnitude_vector" in k
)
):
nk = k[: -len(".weight")] + ".default.weight"
converted[nk] = v
else:
converted[k] = v
adapter_state = converted
missing, unexpected = raw.load_state_dict(adapter_state, strict=False)
# Filter out base-model "missing" keys (those aren't in adapter)
real_missing = [
k for k in missing if "lora_" in k or "modules_to_save" in k
]
if is_main:
logger.info(
f"Loaded adapter from {adapter_path}: {len(adapter_state)} tensors. "
f"Missing LoRA keys: {len(real_missing)}, unexpected: {len(unexpected)}"
)
if real_missing[:3]:
logger.warning(f"Sample missing LoRA: {real_missing[:3]}")
if unexpected[:3]:
logger.warning(f"Sample unexpected: {unexpected[:3]}")
# Try to infer step from path (e.g. .../checkpoint-150 or .../ckpt250)
import re as _re
m = _re.search(r"(?:checkpoint-|ckpt)(\d+)", str(args.resume))
start_step = int(m.group(1)) if m else 0
if is_main:
logger.info(f"Adapter-only resume from inferred step {start_step}")
# Graceful shutdown
shutdown = False
def handle_signal(*_):
nonlocal shutdown
shutdown = True
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
# Checkpoint helper
def save_checkpoint(step, epoch):
if not is_main:
return
ckpt_dir = Path(args.output_dir) / f"checkpoint-{step}"
ckpt_dir.mkdir(parents=True, exist_ok=True)
raw = model.module if distributed else model
# Save LoRA adapter
raw.save_pretrained(str(ckpt_dir))
# Save training state
torch.save(
{
"step": step,
"epoch": epoch,
"model_state_dict": raw.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"scheduler_state_dict": scheduler.state_dict(),
"logit_scale_state_dict": logit_scale.state_dict(),
},
ckpt_dir / "training_state.pt",
)
logger.info(f"Checkpoint saved: {ckpt_dir}")
# Eval helper
@torch.no_grad()
def run_eval(step):
if ema is not None:
ema.apply(model)
model.eval()
total_loss = 0.0
total_acc = 0.0
n_batches = 0
raw = model.module if distributed else model
raw_doc = doc_model if doc_model is not None else raw
max_batches = (
args.max_eval_batches if args.max_eval_batches > 0 else float("inf")
)
for batch in eval_loader:
q_inputs, d_inputs = batch
q_inputs = {k: v.to(device) for k, v in q_inputs.items()}
d_inputs = {k: v.to(device) for k, v in d_inputs.items()}
with torch.autocast("cuda", dtype=torch.bfloat16):
_clear_rope_deltas(raw)
q_emb = raw(**q_inputs)
_clear_rope_deltas(raw_doc)
d_emb = raw_doc(**d_inputs)
loss, acc = active_loss_fn(
q_emb, d_emb, logit_scale, gather_enabled=False
)
total_loss += loss.item()
total_acc += acc.item()
n_batches += 1
if n_batches >= max_batches:
break
model.train()
if n_batches > 0 and is_main:
avg_loss = total_loss / n_batches
avg_acc = total_acc / n_batches
logger.info(
f"eval step={step} loss={avg_loss:.4f} "
f"acc={avg_acc:.4f} batches={n_batches}"
)
if use_wandb:
wandb.log(
{
"eval/loss": avg_loss,
"eval/accuracy": avg_acc,
"eval/batches": n_batches,
},
step=step,
)
def run_query_side_tests(step):
if not is_main:
return
torch.cuda.empty_cache()
if test_split_queries:
try:
logger.info(
f"Running test-split retrieval eval via search API: "
f"{len(test_split_queries)} queries..."
)
metrics = run_search_api_retrieval_eval(
model,
processor,
test_split_queries,
device,
search_api_url=args.search_api_url,
batch_size=args.search_api_batch_size,
n_docs=3,
)
for k, v in metrics.items():
logger.info(f" test_split/{k}: {v:.4f}")
if use_wandb:
wandb.log(
{f"test_split/{k}": v for k, v in metrics.items()}, step=step
)
except Exception as e:
logger.warning(f"test-split retrieval eval failed: {e}")
if simpleqa_queries:
try:
logger.info(
f"Running SimpleQA eval via search API: {len(simpleqa_queries)} queries..."
)
metrics = run_simpleqa_search_api_eval(
model,
processor,
simpleqa_queries,
device,
search_api_url=args.search_api_url,
vllm_url=args.vllm_url,
vllm_model=args.vllm_model,
batch_size=args.search_api_batch_size,
n_docs=3,
grader_model=args.simpleqa_grader_model,
vllm_max_tokens=args.vllm_max_tokens,
vllm_enable_thinking=args.vllm_enable_thinking,
)
for k, v in metrics.items():
if isinstance(v, (int, float)):
logger.info(f" simpleqa/{k}: {v:.4f}")
else:
logger.info(f" simpleqa/{k}: {v}")
if use_wandb and metrics:
wandb.log(
{
f"simpleqa/{k}": v
for k, v in metrics.items()
if isinstance(v, (int, float))
},
step=step,
)
except Exception as e:
logger.warning(f"SimpleQA eval failed: {e}")
if ema is not None:
ema.restore(model)
model.train()
# Train loop
model.train()
step = start_step
epoch = start_epoch
if is_main:
logger.info(f"Starting training from step {start_step}")
def run_test_evals(step, label_prefix="eval", log_to_wandb=True):
"""Run mini-v6/v8/... eval on every loaded test dataset and log per-set metrics."""
if not test_datasets:
return
for td in test_datasets:
name = td["name"]
torch.cuda.empty_cache()
logger.info(
f"Running test '{name}': {len(td['questions'])} queries, "
f"{len(td['doc_paths'])} tiles..."
)
metrics = run_miniv6_eval(
model,
processor,
td,
device,
batch_size=args.test_batch_size,
vllm_url=args.vllm_url,
vllm_model=args.vllm_model,
grader_model=args.simpleqa_grader_model,
output_path=os.path.join(
args.output_dir, f"{label_prefix}_step{step}_{name}.jsonl"
),
vllm_max_tokens=args.vllm_max_tokens,
vllm_enable_thinking=args.vllm_enable_thinking,
)
for k, v in metrics.items():
logger.info(f" test_{name}/{k}: {v:.4f}")
if log_to_wandb and use_wandb:
wandb.log(
{f"test_{name}/{k}": v for k, v in metrics.items()}, step=step
)
# Eval-only mode: run all test evals at current step (post-resume) and exit
if getattr(args, "eval_only", False) and is_main and test_datasets:
suffix = ""
if args.vllm_enable_thinking:
suffix += "_think"
suffix += f"_mt{args.vllm_max_tokens}"
logger.info(
f"[eval-only] Running test evals at step={start_step}, "
f"thinking={args.vllm_enable_thinking}, max_tokens={args.vllm_max_tokens}"
)
run_test_evals(
start_step, label_prefix=f"eval_only{suffix}", log_to_wandb=False
)
logger.info("[eval-only] Done. Exiting.")
return
# Step-0 baseline before any optimization updates.
# For query-side-tune this gives retrieval recall / SimpleQA QA against the
# frozen-base checkpoint, which is the most useful comparison point.
if start_step == 0:
if distributed:
dist.barrier()
if is_main and args.mode == "query-side-tune":
logger.info("Running step-0 query-side baseline eval...")
run_query_side_tests(step=0)
elif is_main and test_datasets:
logger.info("Running step-0 baseline test evals...")
run_test_evals(0)
model.train()
if distributed:
dist.barrier()
def prefetched(loader):
"""Prefetch next batch in a background thread while GPU is busy."""
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=1) as pool:
it = iter(loader)
future = pool.submit(next, it, None)
while True:
batch = future.result()
if batch is None:
break
future = pool.submit(next, it, None)
yield batch
# Text warmup phase
text_warmup_done = (
args.text_warmup_steps <= 0
or text_loader is None
or step >= args.text_warmup_steps
)
if not text_warmup_done and is_main:
logger.info(
f"Starting text warmup phase: steps {step}{args.text_warmup_steps}"
)
text_epoch = 0
while not text_warmup_done and step < args.text_warmup_steps:
if text_sampler:
text_sampler.set_epoch(text_epoch)
for batch in text_loader:
if step >= args.text_warmup_steps:
break
if shutdown:
break
t0 = time.time()
q_inputs, d_inputs = batch
q_inputs = {k: v.to(device) for k, v in q_inputs.items()}
d_inputs = {k: v.to(device) for k, v in d_inputs.items()}
optimizer.zero_grad()
q_chunks = chunk_inputs(q_inputs, args.grad_cache_chunk)
d_chunks = chunk_inputs(d_inputs, args.grad_cache_chunk)
loss, accuracy = grad_cache_loss(
model=model,
query_chunks=q_chunks,
doc_chunks=d_chunks,
logit_scale=logit_scale,
query_process_fn=functools.partial(
forward_query, bidirectional=args.bidirectional
),
doc_process_fn=functools.partial(
forward_doc, bidirectional=args.bidirectional
),
hardness_alpha=args.hardness_alpha,
loss_fn=active_loss_fn,
)
if args.max_grad_norm > 0:
clip_grad_norm_(
list(model.parameters()) + list(logit_scale.parameters()),
args.max_grad_norm,
)
optimizer.step()
logit_scale.clamp_()
if ema is not None:
ema.update(model)
scheduler.step()
step += 1
dt = time.time() - t0
if is_main:
temp = 1.0 / logit_scale.log_scale.exp().item()
cur_lr = scheduler.get_last_lr()[0]
logger.info(
f"[text-warmup] step={step}/{args.max_steps} "
f"loss={loss.item():.4f} acc={accuracy.item():.3f} "
f"lr={cur_lr:.2e} temp={temp:.4f} time={dt:.1f}s"
)
if use_wandb:
wandb.log(
{
"train/loss": loss.item(),
"train/accuracy": accuracy.item(),
"train/lr": cur_lr,
"train/temperature": temp,
"train/phase": 0,
},
step=step,
)
need_eval = args.eval_steps > 0 and step % args.eval_steps == 0
need_save = args.save_steps > 0 and step % args.save_steps == 0
need_test = args.test_eval_steps > 0 and step % args.test_eval_steps == 0
need_action = need_eval or need_test or need_save
if need_action:
if distributed:
dist.barrier()
if need_eval and is_main:
torch.cuda.empty_cache()
run_eval(step)
if need_save:
save_checkpoint(step, epoch)
if need_test and is_main and test_datasets:
run_test_evals(step)
model.train()
if distributed:
dist.barrier()
text_epoch += 1
if not text_warmup_done and is_main:
logger.info(f"Text warmup complete at step {step}, switching to image training")
text_warmup_done = True
# Two-stage LR: reset scheduler for image phase with fresh cosine decay
if args.two_stage_lr and args.text_warmup_steps > 0:
image_steps = args.max_steps - args.text_warmup_steps
if args.scheduler == "cosine":
scheduler = get_cosine_schedule_with_warmup(
optimizer,
num_warmup_steps=args.warmup_steps,
num_training_steps=image_steps,
)
elif args.scheduler == "constant":
scheduler = get_constant_schedule_with_warmup(
optimizer, num_warmup_steps=args.warmup_steps
)
# Reset lr to peak (optimizer stores current lr from text phase decay)
for pg in optimizer.param_groups:
pg["lr"] = args.lr
if is_main:
logger.info(
f"Two-stage LR: reset scheduler for image phase "
f"({image_steps} steps, warmup={args.warmup_steps})"
)
# Set up text interleaving for mixed training phase
text_mix_iter = None
text_mix_epoch = 0
use_text_mix = (
args.text_mix_ratio > 0 or args.text_curriculum
) and text_loader is not None
def _get_text_mix_interval(current_step):
"""Return how often to insert a text batch (0 = never)."""
if args.text_curriculum:
# Curriculum: gradually decrease text ratio over image phase
# Phase 1 (first 25%): 1:1 text:image (interval=2)
# Phase 2 (25-50%): 1:2 (interval=3)
# Phase 3 (50-75%): 1:4 (interval=5)
# Phase 4 (last 25%): no text (interval=0)
image_steps = args.max_steps - args.text_warmup_steps
progress = (current_step - args.text_warmup_steps) / max(image_steps, 1)
if progress < 0.25:
return 2 # 50% text
elif progress < 0.50:
return 3 # 33% text
elif progress < 0.75:
return 5 # 20% text
else:
return 0 # no text
elif args.text_mix_ratio > 0:
return max(1, round(1.0 / args.text_mix_ratio))
return 0
if use_text_mix and is_main:
if args.text_curriculum:
logger.info("Text curriculum enabled: 50%→33%→20%→0% over 4 phases")
else:
interval = _get_text_mix_interval(args.text_warmup_steps)
logger.info(
f"Text interleaving enabled: 1 text batch every {interval} steps "
f"(ratio={args.text_mix_ratio})"
)
def _get_text_mix_batch():
"""Get next text batch, cycling through epochs."""
nonlocal text_mix_iter, text_mix_epoch
if text_mix_iter is None:
if text_sampler:
text_sampler.set_epoch(text_mix_epoch)
text_mix_iter = iter(text_loader)
try:
return next(text_mix_iter)
except StopIteration:
text_mix_epoch += 1
if text_sampler:
text_sampler.set_epoch(text_mix_epoch)
text_mix_iter = iter(text_loader)
return next(text_mix_iter)
while step < args.max_steps:
if train_sampler:
train_sampler.set_epoch(epoch)
for batch in train_loader:
# Sync shutdown flag across ranks to prevent deadlock: if any rank
# received a signal, all ranks must exit together.
trace_enabled = args.debug_trace and step < 3
if distributed:
flag = torch.tensor([1.0 if shutdown else 0.0], device=device)
dist.all_reduce(flag, op=dist.ReduceOp.MAX)
shutdown = flag.item() > 0.5
if shutdown:
if is_main:
logger.info("Shutdown signal received, saving checkpoint...")
save_checkpoint(step, epoch)
if use_wandb:
wandb.finish()
if distributed:
dist.barrier()
return
if step >= args.max_steps:
break
# Interleave text batches during image training
cur_interval = _get_text_mix_interval(step) if use_text_mix else 0
is_text_step = cur_interval > 0 and step > 0 and step % cur_interval == 0
if is_text_step:
batch = _get_text_mix_batch()
t0 = time.time()
debug_trace(trace_enabled, f"train_loop: batch fetched at step={step}")
q_inputs, d_inputs = batch
q_inputs = {k: v.to(device) for k, v in q_inputs.items()}
d_inputs = {k: v.to(device) for k, v in d_inputs.items()}
t_transfer = time.time()
optimizer.zero_grad()
debug_trace(trace_enabled, "train_loop: optimizer.zero_grad done")
# GradCache forward — handles DDP sync internally via no_sync pattern
q_chunks = chunk_inputs(q_inputs, args.grad_cache_chunk)
d_chunks = chunk_inputs(d_inputs, args.grad_cache_chunk)
debug_trace(
trace_enabled,
f"train_loop: chunked q={len(q_chunks)} d={len(d_chunks)}",
)
if args.mode == "query-side-tune":
if args.query_side_backward == "direct":
loss, accuracy = direct_loss_query_side(
query_model=model,
frozen_doc_model=doc_model,
q_inputs=q_inputs,
d_inputs=d_inputs,
logit_scale=logit_scale,
gather_enabled=True,
debug_enabled=trace_enabled,
hardness_alpha=args.hardness_alpha,
)
else:
loss, accuracy = grad_cache_loss_query_side(
query_model=model,
frozen_doc_model=doc_model,
query_chunks=q_chunks,
doc_chunks=d_chunks,
logit_scale=logit_scale,
query_process_fn=forward_query,
doc_process_fn=forward_doc,
debug_enabled=trace_enabled,
hardness_alpha=args.hardness_alpha,
)
else:
loss, accuracy = grad_cache_loss(
model=model,
query_chunks=q_chunks,
doc_chunks=d_chunks,
logit_scale=logit_scale,
query_process_fn=forward_query,
doc_process_fn=forward_doc,
hardness_alpha=args.hardness_alpha,
)
debug_trace(trace_enabled, "train_loop: backward path returned")
t_fwdbwd = time.time()
if args.max_grad_norm > 0:
clip_grad_norm_(
list(model.parameters()) + list(logit_scale.parameters()),
args.max_grad_norm,
)
debug_trace(trace_enabled, "train_loop: clip_grad_norm done")
optimizer.step()
logit_scale.clamp_() # clamp log_scale in-place after step (contrastors pattern)
if ema is not None:
ema.update(model)
scheduler.step()
debug_trace(trace_enabled, "train_loop: optimizer/scheduler step done")
t_optim = time.time()
step += 1
dt = time.time() - t0
if is_main:
temp = 1.0 / logit_scale.log_scale.exp().item()
log_scale_val = logit_scale.log_scale.item()
cur_lr = scheduler.get_last_lr()[0]
loss_val_item = loss.item()
acc_val = accuracy.item()
phase_tag = "[text-mix] " if is_text_step else ""
dt_transfer = t_transfer - t0
dt_fwdbwd = t_fwdbwd - t_transfer
dt_optim = t_optim - t_fwdbwd
logger.info(
f"{phase_tag}step={step}/{args.max_steps} loss={loss_val_item:.4f} "
f"acc={acc_val:.3f} lr={cur_lr:.2e} "
f"temp={temp:.4f} time={dt:.1f}s "
f"[data={dt_transfer:.1f}s fwd+bwd={dt_fwdbwd:.1f}s optim={dt_optim:.1f}s]"
)
if use_wandb:
grad_norm = (
sum(
p.grad.norm().item() ** 2
for p in model.parameters()
if p.requires_grad and p.grad is not None
)
** 0.5
)
wandb.log(
{
"train/loss": loss_val_item,
"train/accuracy": acc_val,
"train/lr": cur_lr,
"train/temperature": temp,
"train/log_scale": log_scale_val,
"train/grad_norm": grad_norm,
"train/step_time_s": dt,
"train/epoch": epoch,
"train/samples_seen": step * args.batch_size * world_size,
"train/phase": 0.5 if is_text_step else 1,
"train/is_text_step": 1 if is_text_step else 0,
},
step=step,
)
# Eval + Save
# All ranks must participate in barrier; eval/test run only on rank 0
# but we barrier before AND after to keep ranks in sync.
need_eval = args.eval_steps > 0 and step % args.eval_steps == 0
need_save = args.save_steps > 0 and step % args.save_steps == 0
if args.mode == "query-side-tune":
need_test = need_save or (
args.test_eval_steps > 0 and step % args.test_eval_steps == 0
)
else:
need_test = (
args.test_eval_steps > 0 and step % args.test_eval_steps == 0
)
need_action = need_eval or need_test or need_save
if need_action:
if distributed:
dist.barrier()
if need_eval and is_main:
torch.cuda.empty_cache()
run_eval(step)
if need_save:
save_checkpoint(step, epoch)
if need_test and is_main:
if args.mode == "query-side-tune":
run_query_side_tests(step)
elif test_datasets:
run_test_evals(step)
model.train()
if distributed:
dist.barrier()
epoch += 1
if distributed:
dist.barrier()
save_checkpoint(step, epoch)
# Final test eval — skip if last step already triggered test eval
already_evaluated = args.test_eval_steps > 0 and step % args.test_eval_steps == 0
if is_main and not already_evaluated:
if args.mode == "query-side-tune":
run_query_side_tests(step)
elif test_datasets:
logger.info("Final test evals...")
run_test_evals(step)
if is_main:
logger.info(f"Training complete. Model saved to {args.output_dir}")
if use_wandb:
wandb.finish()
if __name__ == "__main__":
main()