feat(loss): add WithGradCache wrapper for contrastive losses#416
feat(loss): add WithGradCache wrapper for contrastive losses#416whybe-choi wants to merge 1 commit into
Conversation
Introduce a single GradCache wrapper that enables large effective batch sizes under a fixed memory budget for both bi-encoder and late-interaction contrastive losses (Gao et al., 2021). Embeddings are computed in mini-batches without retaining the full activation graph; per-embedding gradients are cached from the wrapped loss and replayed through a backward hook that recomputes each mini-batch with gradients enabled. All scoring and loss math is delegated to the wrapped loss, so the wrapper stays compatible with every pooled or token-level contrastive loss and adds no loss-specific logic. ContrastiveTrainer delegates to the gradcache path when the loss exposes `gradcache_enabled`, leaving the standard non-cached path unchanged.
|
Hello, @QuentinJGMace and @ManuelFay ! Whenever you have some time, would you mind taking a look at this PR? Thank you! |
Experiment: memory usage and retrieval quality with GradCacheI ran a small experiment on ColQwen2 to check GradCache's practical payoff on a fixed GPU: (1) whether it actually lowers memory and lets me fit a batch that plain training OOMs on, and (2) whether the larger in-batch-negative pool improves retrieval. Setup
The memory wallOn this GPU, plain training (no GradCache) OOMs at batch size 192; the largest batch that fits is 128. With GradCache wrapping the same loss, batch size 192 trains fine by embedding
GradCache fits the larger 192-batch at lower peak VRAM than the baseline's 128-batch (83.4 vs 88.7 GiB), while keeping the GPU near-saturated (median util ≥ 96%). The ~10-point lower mean utilization reflects the mini-batch embed loop / double-forward — i.e. the extra wall-clock is real compute, not idle stalls. Training cost & qualityGradCache trades compute for memory (each branch is forwarded twice: once under
Retrieval quality (ViDoRe v1 / v2, NDCG@5; bold = better of the two runs). ViDoRe V2 task scores are averaged over their language subsets (fr / es / en / de; ESGHL is English-only):
TakeawayGradCache trains the 192-batch that plain training OOMs on, and does so at lower peak VRAM than the largest batch (128) the baseline could fit. The larger in-batch-negative pool clearly helps in-domain ViDoRe V1 (86.1 → 87.6 NDCG@5, +1.5), while out-of-domain V2 is essentially flat (48.1 → 48.2) — overall 75.2 → 76.3 across the 14 tasks, at ~1.6× wall-clock (4h 17m → 6h 48m). So on a fixed GPU, GradCache turns a hard memory ceiling into a modest, paid-for quality gain, concentrated on V1. Evaluation is on the ViDoRe v1 and v2 benchmarks (NDCG@5) using |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Hi, @ManuelFay and @QuentinJGMace ! |
Summary
Adds a single
WithGradCachewrapper that enables large effective batch sizes under a fixed memory budget for both bi-encoder and late-interaction contrastive losses (GradCache, Gao et al., 2021). Embeddings are computed in mini-batches without retaining the full activation graph; per-embedding gradients are cached from the wrapped loss and replayed through a backward hook that recomputes each mini-batch with gradients enabled.Context
This follows up on #399 (where I offered to continue the work) and the original prototype in #198. In #399, you laid out what it would take to merge:
Since you also noted that GradCache worked but didn't beat offline hard-negative mining, this PR targets the second criterion — self-contained, minimal bloat — rather than making a performance claim. Concretely:
WithGradCacheclass, not a flag. All GradCache logic lives in one new file,colpali_engine/loss/gradcache.py, and is opt-in by wrapping a loss. No global "gradcache flag" is threaded through the training code.ContrastiveTrainerroutes to the gradcache path only when the loss exposesgradcache_enabled(a ~16-line addition); the existing non-cached path is unchanged.I reimplemented from scratch rather than rebasing #198 to keep the footprint minimal and self-contained, but I'm happy to align with that branch if you'd prefer.
How it works
torch.no_grad(); embeddings are detached and markedrequires_grad. An RNG snapshot (RandContext) is captured per mini-batch.All scoring/loss math is delegated to the wrapped loss, so the wrapper is compatible with every pooled or token-level contrastive loss and adds no loss-specific logic.
ContrastiveTrainerdelegates to the gradcache path when the loss exposesgradcache_enabled, leaving the standard non-cached path unchanged. The ambient autocast policy is replayed in the backward hook, and the distributed positive-gather (with per-rank offset) is preserved.Tests
tests/loss/test_gradcache.py(toy encoder, runs on CPU; one CUDA-gated case):BiEncoderLoss,BiNegativeCELoss,ColbertLoss,ColbertNegativeCELoss, acrossmini_batch_sizesmaller / equal / larger than the batch.no_gradpath — returns the loss with no autograd hook and leaves parameter grads untouched.RandContext— dropout is reproduced across the two forward passes; gradcache with dropout is deterministic and finite.gloo(no GPU needed) parity over the all-gather + offset path, including the late-interaction front-padding branch for unequal doc lengths.slow) — bf16 / fp16 mixed-precision parity.pytest:
pytest tests/loss/test_gradcache.py— CPU + distributed (gloo)pytest tests/loss/test_gradcache.py -m slow— include the CUDA autocast parity caseNote
Claude Code helped with parts of the implementation and drafted the test cases; everything's been manually reviewed and verified end-to-end. But there may be something I could have missed, so feel free to add/delete/update features.
Thank you!