feat(loss): add WithGradCache wrapper for contrastive losses - #416
feat(loss): add WithGradCache wrapper for contrastive losses#416whybe-choi wants to merge 4 commits 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 ! |
ManuelFay
left a comment
There was a problem hiding this comment.
Hello ! PR looks clean, quite similar to the one I had opened at the time so looks familiar ! Thanks !
Sorry for not responding before, I am away from work for a few months so just looking at this now.
It looks to be well tested, the only thing that I would like is if it's a bit more documented:
- how should one activate / deactivate gradcache from the training loop ? basicqlly if there is a little part in the readme or a dedicated md file that explains gradcahce: a little code snippet, the rationale/the stats you give in this PR, the default behaviors, hparams, it would help usage a lot !
The other thing is that SentenceTransformers is bound to include these visual retrivers at terms and supports gradcache out of the box, so I was hesistating to merge this now (but probably worth it to do so regardless).
Thanks again for everything and sorry for the delays
|
Thanks for the review! I added a collapsible “Training with GradCache” example to the README. It now covers how to enable and disable I’d appreciate another look when you have time. Thanks! |
ManuelFay
left a comment
There was a problem hiding this comment.
Thanks, great !
Two minor technical follow-ups, but not preventing the merge:
-
validate that mini_batch_size > 0;
-
is this tested with an end-to-end Accelerate/DDP run with NCCL and gradient accumulation whcih would be the breaking situation I could envision ? worst case, we can follow this up later, but i know gradacc is non trivial with HF trainers
|
Thanks for the follow-up! I’ve added validation to ensure that mini_batch_size is greater than 0. For the end-to-end Accelerate/DDP run with NCCL and gradient accumulation, I’ll run additional tests and share the results here in a follow-up. |
|
Following up on my previous comment, I ran the additional end-to-end Accelerate/DDP test with NCCL and gradient accumulation and added it to the test suite. I added an end-to-end GPU integration test using the actual Test configuration
Each per-device batch of 8 samples is processed as four GradCache chunks of 2 samples. The test covers all 64 samples without repeating the dataset. AssertionsThe test verifies that:
The run completed without deadlocks, NCCL collective errors, nested-backward errors, or rank divergence. Environment
ResultsThe new end-to-end test passed: The complete GradCache test suite also passed: This full suite includes:
One implementation detail worth noting is that cross-rank document gathering occurs only on synchronization microsteps, matching the existing non-GradCache trainer path. Non-synchronization microsteps use the local per-rank batch. Therefore, the effective training batch size per optimizer step is 32 samples, but it should not be interpreted as a single contrastive negative pool of size 32. |
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!