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

148 lines
6.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# GradCache Training (train_contrastors.py)
GradCache training for Qwen3-VL-Embedding, adapted from [nomic-ai/contrastors](https://github.com/nomic-ai/contrastors). Verified correct via gradient equivalence tests (single-GPU + multi-GPU).
## Scope Note
This document describes the original GradCache path used by `train_contrastors.py` in `standard` mode.
For `--mode query-side-tune`:
- default backward path is `--query-side-backward direct`
- `--query-side-backward gradcache` still exists, but is currently experimental
- multi-GPU real-data smoke tests were stable with `direct` and unstable with `gradcache`
## Why GradCache?
Contrastive learning benefits from large batch sizes (more negatives = better). But GPU memory limits batch size. GradCache breaks this constraint:
```
Standard: batch=4, chunk=4 → memory of 4, negatives = 4
GradCache: batch=4, chunk=2 → memory of 2, negatives = 4
Multi-GPU: batch=4, chunk=2, 5 GPUs → memory of 2, negatives = 20
```
## How It Works
GradCache splits each batch into small chunks and processes them in 3 steps:
1. **Forward all chunks WITHOUT grad** → cache embeddings + RNG states (constant memory)
2. **Compute InfoNCE loss on ALL cached embeddings** → get embedding gradients via backward on detached tensors
3. **Replay forward WITH grad** using saved RNG states, apply surrogate loss (`dot(emb, cached_grad)`) → real parameter gradients
By the chain rule, `d(loss)/d(θ) = d(loss)/d(emb) · d(emb)/d(θ)`. Step 2 computes the first factor, step 3 computes the second. The result is mathematically identical to a full-batch backward pass.
### DDP Integration
Multi-GPU adds two distributed primitives:
- **`gather_with_grad`**: All-gathers document embeddings across ranks (step 2). Backward does `reduce_scatter` to distribute gradients back.
- **Manual `all_reduce(AVG)`**: All surrogate backward calls run under `no_sync()` to avoid DDP reducer deadlocks (query chunks skip the visual encoder while doc chunks use it → different "used" parameter sets confuse `find_unused_parameters`). Gradients are manually synced after all chunks.
The loss is scaled by `world_size` before backward, so after `all_reduce(AVG)`:
```
final_grad = (1/W) × Σ_r [W × d(CE_r)/d(θ)] = Σ_r d(CE_r)/d(θ) = d(total_CE)/d(θ)
```
### Key Components
| Component | Source | Purpose |
|-----------|--------|---------|
| `RandContext` | contrastors/rand_state.py | Save/restore GPU RNG state for dropout replay |
| `gather_with_grad` | contrastors/distributed.py | All-gather with gradient flow (backward = reduce_scatter) |
| `clip_loss` | contrastors/loss.py | InfoNCE with learnable logit scale + hard negative support |
| `grad_cache_loss` | contrastors/loss.py | Full GradCache pipeline (3-step) |
| `LogitScale` | contrastors/OpenCLIP | Learnable `log_scale` parameter, clamped post-step |
| `BiQwen3` | colpali-engine | Qwen3-VL wrapped as bi-encoder (last-token pool + L2 norm) |
### LogitScale
Learnable temperature in log-space, initialized to `ln(1/0.07) ≈ 2.66`:
```python
forward: similarity * exp(log_scale) # no clamp in forward (avoids gradient dead zone)
after optimizer.step(): log_scale.clamp_(0, ln(100)) # contrastors pattern
```
## Quick Start
```bash
PYTHON=.venv-sglang/bin/python
# Single GPU
CUDA_VISIBLE_DEVICES=3 $PYTHON training/train_contrastors.py \
--max-steps 500 --batch-size 4 --grad-cache-chunk 2
# Multi-GPU (5 GPUs, cross-GPU negatives + GradCache)
CUDA_VISIBLE_DEVICES=3,4,5,6,7 .venv-sglang/bin/torchrun --nproc_per_node=5 \
training/train_contrastors.py --max-steps 500 --batch-size 8 --grad-cache-chunk 2
# With hard negatives (requires train_hn.jsonl from mine_hard_negatives.py)
CUDA_VISIBLE_DEVICES=3,4,5,6,7 .venv-sglang/bin/torchrun --nproc_per_node=5 \
training/train_contrastors.py --train-jsonl training/data/train_hn.jsonl \
--num-hard-negatives 2 --batch-size 4 --grad-cache-chunk 2
# Resume from checkpoint
$PYTHON training/train_contrastors.py \
--resume training/output_contrastors/checkpoint-200
```
## Hyperparameters
| Parameter | Default | Notes |
|-----------|---------|-------|
| `--batch-size` | 4 | Per-GPU batch size |
| `--grad-cache-chunk` | 2 | Chunk size for GradCache (memory = this × per-sample cost) |
| `--lr` | 2e-5 | Peak learning rate (cosine schedule) |
| `--warmup-steps` | 50 | Linear warmup |
| `--max-steps` | 500 | Total training steps |
| `--temperature` | 0.07 | Initial temperature (learnable logit scale = 1/temp) |
| `--num-hard-negatives` | 0 | Hard negs per query (docs interleaved: [pos, neg1, neg2, ...]) |
| `--lora-r` | 32 | LoRA rank |
| `--lora-alpha` | 32 | LoRA alpha |
| `--max-num-visual-tokens` | 256 | Image resolution (~200K pixels) |
| `--max-grad-norm` | 1.0 | Gradient clipping (model + logit_scale) |
## Comparison with train_colpali.py
| Feature | train_colpali.py | train_contrastors.py |
|---------|-----------------|---------------------|
| Training infra | HF Trainer (ContrastiveTrainer) | Custom loop |
| GradCache | No | Yes |
| Cross-GPU negatives | `all_gather` | `gather_with_grad` |
| Temperature | Fixed | Learnable (LogitScale) |
| Hard negatives | No | Yes (`--num-hard-negatives`) |
| DDP strategy | Standard DDP | `no_sync` + manual `all_reduce` |
| Checkpoint/resume | HF Trainer | Manual |
## Verified Correct
Gradient equivalence tests confirm GradCache produces identical gradients to a full-memory reference (15 tests, all passing):
**Single-GPU** (`tests/test_grad_equivalence.py`):
- GradCache chain-rule decomposition: cosine ≥ 0.9999 for chunk_size = 1, 2, batch_size
- RandContext dropout replay: cosine ≥ 0.9999 for all chunk sizes
- `clip_loss` label arithmetic: basic, hard negatives, divisibility assertion
- `_clear_rope_deltas`: prevents image→text rope state leakage
**Multi-GPU** (`tests/test_grad_multi_gpu.py`, 2×GPU):
- GradCache DDP vs reference: cosine ≥ 0.9998 (with and without dropout)
- `gather_with_grad` backward: reduce_scatter gives correct gradient = W
- `loss*W + all_reduce(AVG)` = gradient of total loss: exact match
- Gradients identical across ranks after sync: max diff = 0
```bash
# Run tests
CUDA_VISIBLE_DEVICES=2 python training/tests/test_grad_equivalence.py
CUDA_VISIBLE_DEVICES=2,3 torchrun --nproc_per_node=2 training/tests/test_grad_multi_gpu.py
```
## Code Attribution
Core GradCache implementation adapted from [nomic-ai/contrastors](https://github.com/nomic-ai/contrastors):
- `contrastors/loss.py``grad_cache_loss`, `clip_loss`, `get_chunked_embeddings`
- `contrastors/rand_state.py``RandContext`
- `contrastors/distributed.py``gather_with_grad`
- `contrastors/models/biencoder/modeling_biencoder.py``LogitScale`
- `contrastors/trainers/text_text.py` — post-step `clamp_()` pattern