456 lines
16 KiB
Python
456 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify GradCache produces identical gradients to full-memory chunked reference.
|
|
|
|
The CORRECT comparison for GradCache is NOT "vanilla full-batch" vs "GradCache chunked",
|
|
because chunked forward can produce slightly different embeddings (bf16 accumulation order).
|
|
|
|
Instead, we compare:
|
|
1. REFERENCE: forward each chunk WITH grad (same chunks!) → concat embeddings →
|
|
compute loss → backward. This keeps all activations in memory.
|
|
2. GRADCACHE: the actual 3-step process from train_contrastors.py.
|
|
|
|
Both process the SAME chunks in the SAME order with the SAME precision, so the
|
|
embeddings are identical. The only difference is HOW gradients are computed:
|
|
reference does one backward through the full graph, GradCache uses surrogate loss.
|
|
If GradCache's chain rule decomposition is correct, gradients must match exactly.
|
|
|
|
Usage:
|
|
CUDA_VISIBLE_DEVICES=0 python training/tests/test_grad_equivalence.py
|
|
"""
|
|
|
|
import copy
|
|
import sys
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from train_contrastors import (
|
|
LogitScale,
|
|
clip_loss,
|
|
grad_cache_loss,
|
|
chunk_inputs,
|
|
forward_query,
|
|
forward_doc,
|
|
_clear_rope_deltas,
|
|
)
|
|
|
|
|
|
def chunked_reference_forward_backward(model, q_chunks, d_chunks, logit_scale):
|
|
"""Full-memory reference: forward ALL chunks with grad, then single backward.
|
|
|
|
This computes the EXACT SAME embeddings as GradCache step 1 (same chunks,
|
|
same order, same autocast), but keeps all activations for a single backward.
|
|
"""
|
|
# Forward each chunk with grad, same as GradCache would
|
|
q_embs = []
|
|
with torch.autocast("cuda", dtype=torch.bfloat16):
|
|
for chunk in q_chunks:
|
|
_clear_rope_deltas(model)
|
|
q_embs.append(model(**chunk))
|
|
q_emb = torch.cat(q_embs, dim=0)
|
|
|
|
d_embs = []
|
|
with torch.autocast("cuda", dtype=torch.bfloat16):
|
|
for chunk in d_chunks:
|
|
_clear_rope_deltas(model)
|
|
d_embs.append(model(**chunk))
|
|
d_emb = torch.cat(d_embs, dim=0)
|
|
|
|
# Same loss computation as GradCache step 2
|
|
# gather_enabled=True with no dist → effectively gather_enabled=False
|
|
with torch.autocast("cuda", dtype=torch.bfloat16):
|
|
loss, acc = clip_loss(q_emb, d_emb, logit_scale, gather_enabled=True)
|
|
|
|
loss.backward()
|
|
return loss.detach(), acc.detach()
|
|
|
|
|
|
def make_fake_inputs(processor, batch_size, device):
|
|
from PIL import Image
|
|
import numpy as np
|
|
|
|
queries = [f"What is topic number {i}?" for i in range(batch_size)]
|
|
images = []
|
|
for i in range(batch_size):
|
|
arr = np.random.randint(0, 255, (200, 300, 3), dtype=np.uint8)
|
|
images.append(Image.fromarray(arr))
|
|
|
|
from train_contrastors import process_queries, process_doc_images
|
|
|
|
query_inputs = process_queries(processor, queries)
|
|
doc_inputs = process_doc_images(processor, images)
|
|
|
|
query_inputs = {k: v.to(device) for k, v in query_inputs.items()}
|
|
doc_inputs = {k: v.to(device) for k, v in doc_inputs.items()}
|
|
return query_inputs, doc_inputs
|
|
|
|
|
|
def collect_grads(model, logit_scale):
|
|
"""Collect gradients, cast to fp32 for comparison."""
|
|
names, grads = [], []
|
|
for n, p in model.named_parameters():
|
|
if p.requires_grad:
|
|
names.append(n)
|
|
grads.append(p.grad.clone().float() if p.grad is not None else None)
|
|
for n, p in logit_scale.named_parameters():
|
|
names.append(f"logit_scale.{n}")
|
|
grads.append(p.grad.clone().float() if p.grad is not None else None)
|
|
return names, grads
|
|
|
|
|
|
def compare_gradients(grads_ref, grads_gc, names, verbose=True):
|
|
"""Compare gradients. Returns (cosine, rel_l2, max_rel_diff)."""
|
|
flat_ref, flat_gc = [], []
|
|
max_rel = 0.0
|
|
n_compared = 0
|
|
|
|
if verbose:
|
|
print(f"\n{'Parameter':<60} {'MaxDiff':>12} {'MeanDiff':>12} {'RelDiff':>12}")
|
|
print("-" * 100)
|
|
|
|
for name, gr, ggc in zip(names, grads_ref, grads_gc):
|
|
if gr is None and ggc is None:
|
|
continue
|
|
if gr is None or ggc is None:
|
|
print(f" MISMATCH: {name} — one grad is None")
|
|
return 0.0, float("inf"), float("inf")
|
|
if gr.abs().max().item() == 0 and ggc.abs().max().item() == 0:
|
|
continue
|
|
|
|
flat_ref.append(gr.flatten())
|
|
flat_gc.append(ggc.flatten())
|
|
n_compared += 1
|
|
|
|
abs_diff = (gr - ggc).abs()
|
|
max_diff = abs_diff.max().item()
|
|
mean_diff = abs_diff.mean().item()
|
|
scale = gr.abs().mean().item()
|
|
rel_diff = mean_diff / max(scale, 1e-12)
|
|
max_rel = max(max_rel, rel_diff)
|
|
|
|
if verbose:
|
|
print(f"{name:<60} {max_diff:>12.6e} {mean_diff:>12.6e} {rel_diff:>12.6e}")
|
|
|
|
if not flat_ref:
|
|
print("No non-zero gradients to compare.")
|
|
return 1.0, 0.0, 0.0
|
|
|
|
ref_cat = torch.cat(flat_ref)
|
|
gc_cat = torch.cat(flat_gc)
|
|
cosine = F.cosine_similarity(ref_cat.unsqueeze(0), gc_cat.unsqueeze(0)).item()
|
|
l2_diff = (ref_cat - gc_cat).norm().item()
|
|
rel_l2 = l2_diff / max(ref_cat.norm().item(), 1e-12)
|
|
|
|
if verbose:
|
|
print(f"\nCompared {n_compared} parameter groups")
|
|
print(f" Cosine similarity: {cosine:.10f}")
|
|
print(f" Relative L2 diff: {rel_l2:.6e}")
|
|
print(f" Max per-param rel: {max_rel:.6e}")
|
|
|
|
return cosine, rel_l2, max_rel
|
|
|
|
|
|
def run_test(
|
|
label,
|
|
model,
|
|
model_state,
|
|
ls_state,
|
|
query_inputs,
|
|
doc_inputs,
|
|
chunk_size,
|
|
device,
|
|
verbose=True,
|
|
):
|
|
"""Compare chunked-reference vs GradCache for a given chunk_size."""
|
|
|
|
# --- Chunked reference (full-memory backward) ---
|
|
model.load_state_dict(model_state)
|
|
ls_ref = LogitScale(init_value=1 / 0.07).to(device)
|
|
ls_ref.load_state_dict(ls_state)
|
|
model.zero_grad()
|
|
ls_ref.zero_grad()
|
|
|
|
# Create fresh chunks for each path (can't share autograd graphs)
|
|
q_chunks_ref = chunk_inputs(
|
|
{k: v.clone() for k, v in query_inputs.items()}, chunk_size
|
|
)
|
|
d_chunks_ref = chunk_inputs(
|
|
{k: v.clone() for k, v in doc_inputs.items()}, chunk_size
|
|
)
|
|
|
|
# Seed before reference forward so dropout masks are deterministic
|
|
torch.manual_seed(42)
|
|
torch.cuda.manual_seed_all(42)
|
|
loss_ref, _ = chunked_reference_forward_backward(
|
|
model, q_chunks_ref, d_chunks_ref, ls_ref
|
|
)
|
|
names, grads_ref = collect_grads(model, ls_ref)
|
|
|
|
# --- GradCache (actual function from train_contrastors.py) ---
|
|
model.load_state_dict(model_state)
|
|
ls_gc = LogitScale(init_value=1 / 0.07).to(device)
|
|
ls_gc.load_state_dict(ls_state)
|
|
model.zero_grad()
|
|
ls_gc.zero_grad()
|
|
|
|
q_chunks_gc = chunk_inputs(
|
|
{k: v.clone() for k, v in query_inputs.items()}, chunk_size
|
|
)
|
|
d_chunks_gc = chunk_inputs(
|
|
{k: v.clone() for k, v in doc_inputs.items()}, chunk_size
|
|
)
|
|
|
|
# Same seed → GradCache step 1 (no-grad forward) uses the same dropout masks
|
|
# as the reference. Step 3 (replay) uses RandContext to reproduce them.
|
|
torch.manual_seed(42)
|
|
torch.cuda.manual_seed_all(42)
|
|
loss_gc, acc_gc = grad_cache_loss(
|
|
model=model,
|
|
query_chunks=q_chunks_gc,
|
|
doc_chunks=d_chunks_gc,
|
|
logit_scale=ls_gc,
|
|
query_process_fn=forward_query,
|
|
doc_process_fn=forward_doc,
|
|
)
|
|
_, grads_gc = collect_grads(model, ls_gc)
|
|
|
|
if verbose:
|
|
print(f"\n Loss reference: {loss_ref.item():.8f}")
|
|
print(f" Loss GradCache: {loss_gc.item():.8f}")
|
|
print(f" Loss diff: {abs(loss_ref.item() - loss_gc.item()):.2e}")
|
|
|
|
cosine, rel_l2, max_rel = compare_gradients(
|
|
grads_ref, grads_gc, names, verbose=verbose
|
|
)
|
|
return cosine, rel_l2, loss_ref.item(), loss_gc.item()
|
|
|
|
|
|
def main():
|
|
device = torch.device("cuda:0")
|
|
batch_size = 4
|
|
|
|
print("Loading model + processor...")
|
|
from models.biqwen3 import BiQwen3
|
|
from transformers import AutoProcessor
|
|
from peft import LoraConfig, get_peft_model
|
|
|
|
model_name = "Qwen/Qwen3-VL-Embedding-2B"
|
|
base_model = BiQwen3.from_pretrained(model_name, dtype=torch.bfloat16)
|
|
processor = AutoProcessor.from_pretrained(model_name)
|
|
patch_size = processor.image_processor.patch_size
|
|
merge_size = processor.image_processor.merge_size
|
|
tile = patch_size * merge_size
|
|
processor.image_processor.max_pixels = 256 * tile * tile
|
|
processor.image_processor.size["longest_edge"] = (
|
|
processor.image_processor.max_pixels
|
|
)
|
|
processor.tokenizer.padding_side = "left"
|
|
|
|
print("Creating fake data...")
|
|
query_inputs, doc_inputs = make_fake_inputs(processor, batch_size, device)
|
|
|
|
results = {}
|
|
|
|
# =========================================================================
|
|
# PART A: dropout=0 (isolates GradCache chain-rule math)
|
|
# =========================================================================
|
|
print("\n" + "=" * 80)
|
|
print("PART A: lora_dropout=0.0 — tests GradCache chain-rule decomposition")
|
|
print("=" * 80)
|
|
|
|
lora_config = LoraConfig(
|
|
r=8,
|
|
lora_alpha=8,
|
|
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
|
|
lora_dropout=0.0,
|
|
task_type="FEATURE_EXTRACTION",
|
|
)
|
|
model = get_peft_model(copy.deepcopy(base_model), lora_config).to(device)
|
|
model.train()
|
|
model_state = copy.deepcopy(model.state_dict())
|
|
ls_state = LogitScale(init_value=1 / 0.07).state_dict()
|
|
|
|
for cs in [batch_size, 2, 1]:
|
|
label = f"A_chunk{cs}"
|
|
print(f"\n--- chunk_size={cs} {'(degenerate)' if cs == batch_size else ''} ---")
|
|
cos, rl2, _, _ = run_test(
|
|
label, model, model_state, ls_state, query_inputs, doc_inputs, cs, device
|
|
)
|
|
results[label] = cos
|
|
|
|
# =========================================================================
|
|
# PART B: dropout=0.05 — tests RandContext RNG replay
|
|
# =========================================================================
|
|
print("\n" + "=" * 80)
|
|
print("PART B: lora_dropout=0.05 — tests RandContext RNG state replay")
|
|
print("=" * 80)
|
|
|
|
lora_config_drop = LoraConfig(
|
|
r=8,
|
|
lora_alpha=8,
|
|
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
|
|
lora_dropout=0.05,
|
|
task_type="FEATURE_EXTRACTION",
|
|
)
|
|
model_drop = get_peft_model(copy.deepcopy(base_model), lora_config_drop).to(device)
|
|
model_drop.train()
|
|
model_drop_state = copy.deepcopy(model_drop.state_dict())
|
|
|
|
for cs in [batch_size, 2, 1]:
|
|
label = f"B_drop_chunk{cs}"
|
|
print(
|
|
f"\n--- chunk_size={cs} with dropout {'(degenerate)' if cs == batch_size else ''} ---"
|
|
)
|
|
cos, rl2, _, _ = run_test(
|
|
label,
|
|
model_drop,
|
|
model_drop_state,
|
|
ls_state,
|
|
query_inputs,
|
|
doc_inputs,
|
|
cs,
|
|
device,
|
|
)
|
|
results[label] = cos
|
|
|
|
# =========================================================================
|
|
# PART C: clip_loss label correctness (unit tests, no model needed)
|
|
# =========================================================================
|
|
print("\n" + "=" * 80)
|
|
print("PART C: clip_loss label arithmetic")
|
|
print("=" * 80)
|
|
|
|
# C1: Basic case — no hard negatives, no gather
|
|
print("\n--- Test C1: basic labels (no hard negs, no gather) ---")
|
|
N, D = 4, 8
|
|
q = F.normalize(torch.randn(N, D, device=device), dim=-1)
|
|
d = F.normalize(torch.randn(N, D, device=device), dim=-1)
|
|
ls = LogitScale(init_value=1 / 0.07).to(device)
|
|
loss, acc = clip_loss(q, d, ls, gather_enabled=False)
|
|
# Labels should be [0, 1, 2, 3] — each query matches its own doc
|
|
sim = ls(q @ d.T)
|
|
expected_labels = torch.arange(N, device=device)
|
|
expected_loss = F.cross_entropy(sim, expected_labels)
|
|
c1_ok = abs(loss.item() - expected_loss.item()) < 1e-5
|
|
print(
|
|
f" loss={loss.item():.6f} expected={expected_loss.item():.6f} "
|
|
f"diff={abs(loss.item() - expected_loss.item()):.2e} [{'PASS' if c1_ok else 'FAIL'}]"
|
|
)
|
|
results["C1_basic_labels"] = 1.0 if c1_ok else 0.0
|
|
|
|
# C2: Hard negatives — 2 hard negs per query, docs interleaved
|
|
print("\n--- Test C2: hard negative labels (2 negs/query) ---")
|
|
num_hard_neg = 2
|
|
docs_per_q = 1 + num_hard_neg # 3
|
|
# doc layout: [pos0, neg0a, neg0b, pos1, neg1a, neg1b, pos2, ..., pos3, ...]
|
|
d_hn = F.normalize(torch.randn(N * docs_per_q, D, device=device), dim=-1)
|
|
loss_hn, _ = clip_loss(q, d_hn, ls, gather_enabled=False)
|
|
# Labels should point to positive positions: [0, 3, 6, 9]
|
|
sim_hn = ls(q @ d_hn.T)
|
|
expected_hn_labels = torch.arange(N, device=device) * docs_per_q
|
|
expected_hn_loss = F.cross_entropy(sim_hn, expected_hn_labels)
|
|
c2_ok = abs(loss_hn.item() - expected_hn_loss.item()) < 1e-5
|
|
print(
|
|
f" labels={expected_hn_labels.tolist()} loss={loss_hn.item():.6f} "
|
|
f"expected={expected_hn_loss.item():.6f} "
|
|
f"diff={abs(loss_hn.item() - expected_hn_loss.item()):.2e} [{'PASS' if c2_ok else 'FAIL'}]"
|
|
)
|
|
results["C2_hard_neg_labels"] = 1.0 if c2_ok else 0.0
|
|
|
|
# C3: Verify assertion fires for bad divisibility
|
|
print("\n--- Test C3: assertion for bad doc/query ratio ---")
|
|
d_bad = F.normalize(torch.randn(N + 1, D, device=device), dim=-1)
|
|
c3_ok = False
|
|
try:
|
|
clip_loss(q, d_bad, ls, gather_enabled=False)
|
|
print(" ERROR: no assertion raised!")
|
|
except AssertionError as e:
|
|
c3_ok = True
|
|
print(f" AssertionError raised as expected: {e}")
|
|
print(" [PASS]")
|
|
results["C3_divisibility_assert"] = 1.0 if c3_ok else 0.0
|
|
|
|
# =========================================================================
|
|
# PART D: _clear_rope_deltas prevents stale state
|
|
# =========================================================================
|
|
print("\n" + "=" * 80)
|
|
print("PART D: _clear_rope_deltas prevents image→text state leakage")
|
|
print("=" * 80)
|
|
|
|
# Use the no-dropout model for this test
|
|
model.load_state_dict(model_state)
|
|
model.eval()
|
|
|
|
# Forward an image batch to populate rope_deltas
|
|
with torch.no_grad():
|
|
with torch.autocast("cuda", dtype=torch.bfloat16):
|
|
_clear_rope_deltas(model)
|
|
_ = model(**{k: v[:2] for k, v in doc_inputs.items()})
|
|
|
|
# Check that rope_deltas is set on the inner model
|
|
inner = model
|
|
while hasattr(inner, "model"):
|
|
inner = inner.model
|
|
has_rope = hasattr(inner, "rope_deltas") and inner.rope_deltas is not None
|
|
print(f" rope_deltas set after image forward: {has_rope}")
|
|
|
|
# Now forward text — WITHOUT clearing, this should fail or give wrong results
|
|
# if rope_deltas shape mismatches the text batch
|
|
# First, demonstrate that clearing prevents the issue:
|
|
_clear_rope_deltas(model)
|
|
cleared = not hasattr(inner, "rope_deltas") or inner.rope_deltas is None
|
|
print(f" rope_deltas cleared after _clear_rope_deltas: {cleared}")
|
|
|
|
d_ok = False
|
|
try:
|
|
with torch.no_grad():
|
|
with torch.autocast("cuda", dtype=torch.bfloat16):
|
|
_clear_rope_deltas(model)
|
|
_ = model(**{k: v[:3] for k, v in query_inputs.items()})
|
|
d_ok = True
|
|
print(" Text forward after clear succeeded: [PASS]")
|
|
except Exception as e:
|
|
print(f" Text forward after clear failed: {e} [FAIL]")
|
|
results["D_rope_deltas"] = 1.0 if d_ok else 0.0
|
|
|
|
model.train()
|
|
|
|
# =========================================================================
|
|
# Summary
|
|
# =========================================================================
|
|
print("\n" + "=" * 80)
|
|
print("SUMMARY")
|
|
print("=" * 80)
|
|
|
|
# Threshold: the reference and GradCache should produce nearly identical gradients
|
|
# since they compute the same function. Only bf16 non-determinism can cause diffs.
|
|
cos_threshold = 0.999
|
|
|
|
all_pass = True
|
|
for name, cos in results.items():
|
|
ok = cos > cos_threshold
|
|
if not ok:
|
|
all_pass = False
|
|
print(f" {name:<25} cosine={cos:.10f} [{'PASS' if ok else 'FAIL'}]")
|
|
|
|
print(f"\n Threshold: cosine > {cos_threshold}")
|
|
if all_pass:
|
|
print("\n ALL TESTS PASSED.")
|
|
print(" GradCache's surrogate backward matches full-memory backward.")
|
|
print(" RandContext correctly replays dropout RNG states.")
|
|
else:
|
|
print("\n SOME TESTS FAILED — GradCache produces incorrect gradients.")
|
|
for name, cos in results.items():
|
|
if cos <= cos_threshold:
|
|
print(f" {name}: cosine={cos:.10f}")
|
|
print("=" * 80)
|
|
|
|
return 0 if all_pass else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|