Skip to content

feat(loss): add Sinkhorn-Knopp centering and KoLeo group sizes for DINOv3 - #2005

Open
saud5150 wants to merge 8 commits into
lightly-ai:masterfrom
saud5150:feat/dinov3-phase1-losses
Open

feat(loss): add Sinkhorn-Knopp centering and KoLeo group sizes for DINOv3#2005
saud5150 wants to merge 8 commits into
lightly-ai:masterfrom
saud5150:feat/dinov3-phase1-losses

Conversation

@saud5150

@saud5150 saud5150 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Part of #1881 — phase 1 of 3, so this does not close the issue.

Description

  • My change is breaking

The three loss-level pieces of DINOv3's first training phase, scoped in #1881 (comment). Paper and reference evidence for each is in that thread; this is what changed.

DINOLoss(output_dim=65536, center_mode="sinkhorn_knopp")       # next to "mean"
IBOTPatchLoss(output_dim=65536, center_mode="sinkhorn_knopp")
KoLeoLoss(group_size=16, topk=1, gather_distributed=False)

center_mode mirrors DINOv2/v3's own train.centering key. Sinkhorn-Knopp already existed in the repo as swav_loss.sinkhorn, so the shared implementation now lives in lightly/models/modules/center.py beside Center and swav_loss.sinkhorn delegates to it, keeping its signature and output dtype. In Sinkhorn-Knopp mode the center buffer stays registered but unused, so checkpoints load across modes, and IBOTPlusPlusPatchLoss inherits the mode through the extracted _teacher_probabilities(). KoLeoLoss searches nearest neighbors within consecutive groups of group_size; topk and gather_distributed complete the reference KoLeoLossDistributed surface in ~15 lines.

Defaults are unchanged and no existing test was adapted.

Sharing the implementation changes two things for SwaV, both improvements:

  • The exponential is computed in float32. sinkhorn previously returned NaN in fp16 for |out| > ~0.55 at the default epsilon=0.05, which is reachable under AMP with cosine-similarity logits. The codes are cast back, so the returned dtype is unchanged.
  • The number of samples is all-reduced instead of derived from the world size. Identical whenever ranks hold equally many samples, which is always the case for SwaV, and it is what lets iBOT reuse the helper, where the masked-token count differs per rank. It rides in the existing sum_Q all-reduce, so the number of collectives is unchanged.

Two things not in the issue thread:

  • exp(logit / 0.04) overflows fp32 at |logit| >= ~3.6. DINO heads L2-normalize before the weight-normed prototype layer, so logits stay in [-1, 1] and this cannot trigger. The reference has the identical exposure, and stabilizing it under DDP needs a globally reduced max, i.e. an extra collective per step. I matched the reference.
  • DINOLoss already matches DINOv3's loss composition. Upstream averages the global-crop and local-crop terms separately and recombines them with dino_global_scale / dino_local_scale, which are proportional to the term counts; for the pretrain default reweight_dino_local_loss: false that is algebraically the uniform mean over view pairs DINOLoss already computes. Checked numerically on identical inputs with 2 global and 6 local views: 5.9371023 vs 5.9371023 for mean centering, 5.6693587 vs 5.6693592 for Sinkhorn-Knopp.

Commits are independently reviewable; the KoLeo commit shares no file with the others, and the review round is kept in its own commits on top so the incremental diff stays readable.

Tests

  • My change is covered by existing tests
  • My change needs new tests
  • I have added/adapted tests accordingly.
  • I have manually tested the change.

29 new tests. sinkhorn_knopp is verified against a copy of the DINOv3 reference across 9 temperature x iteration combinations, following the OriginalDINOLoss pattern already in test_dino_loss.py, plus row-sum, prototype-marginal and detachment tests. The loss-level tests cover wiring rather than repeating the algorithm: Sinkhorn-Knopp applied jointly over all teacher views as upstream does, the center staying zero while remaining in the state dict, IBOTPlusPlusPatchLoss inheriting the mode, and a half-precision forward that fails if the cast back to the input dtype is dropped, since torch.einsum does not promote dtypes.

KoLeoLoss gets 14 tests, including two real two-rank tests on the shared gloo pool from #1982, marked DDP. The forward test asserts every rank sees the loss of the non-distributed run on the concatenated global batch; the gradient test asserts the local gradient is NUM_PROCESSES times the non-distributed one, since every rank computes the loss of the whole global batch and GatherLayer all-reduces the identical gradients before slicing out the local one — the factor DDP cancels when it averages parameter gradients. A correct gathered forward can still have a wrong backward, as #1977 showed. The mocker-simulated world_size=2 test is kept alongside them because it isolates grouping and neighbor indexing from GatherLayer.

Manually verified that the shared helper is a no-op for SwaV: bit-identical to the previous swav_loss.sinkhorn over 3600 random (input, epsilon, iterations) combinations single-process, and over two gloo ranks with gather_distributed both True and False.

Also ran a toy convergence check, since the tests above are all unit-level. A student/teacher pair with a DINOProjectionHead overfits 300 steps on a fixed batch of 256 synthetic views with 64 prototypes. Entropy is that of the mean teacher assignment as a fraction of its maximum, so 1.0 is perfectly balanced prototype usage and 0.0 is collapse:

                                first    last    teacher entropy
DINOLoss      mean              2.537    0.028    0.820
DINOLoss      sinkhorn_knopp    3.034    0.063    0.992
IBOTPatchLoss mean              2.402    0.211    0.802
IBOTPatchLoss sinkhorn_knopp    2.875    0.319    0.998

Both modes train in both losses. Sinkhorn-Knopp holds prototype usage essentially uniform where mean centering drifts to 0.80, which is the property it is chosen for; its slightly higher final loss is the expected cost of enforcing balance per batch rather than nudging a running center. This is a synthetic overfit check, not a benchmark, and says nothing about downstream quality. KoLeoLoss decreases in the same setup with and without grouping (0.076 to -0.232 ungrouped, -0.125 to -0.412 with group_size=8), and grouping leaves the global nearest-neighbor cosine similarity higher (0.56 vs 0.21) because only within-group neighbors are pushed apart.

make format-check   # All checks passed / 576 files already formatted
make lint           # All checks passed
make type-check     # Success: no issues found in 551 source files
make test-distributed                              # 4 passed, 1 skipped
make -C docs html-noplot                           # build succeeded
python -m pytest tests --runslow                   # 1832 passed, 224 skipped

Run on Python 3.9 with current torch; the 3.7 and 3.12 CI legs are unexercised locally. The full run also reports 4 macOS-specific failures (assertWarns dict iteration, two PyAV spawn pickling, one DCL gloo mismatch) that reproduce on a clean upstream/master.

Documentation

  • I have added docstrings to all changed/added public functions/methods.
  • My change requires a change to the documentation ( .rst files).
  • I have updated the documentation accordingly.
  • The autodocs update the documentation accordingly.

Every new parameter is documented on both the class Attributes and the __init__ Args of the affected loss. lightly.loss.html is the only rendered page that changes, attached as a PDF printout.

Improvements put into another issue:

  • DINOLoss cannot express the scheduled local-crop reweighting (reweight_dino_local_loss: true) used by the DINOv3 gram-anchoring config. Not needed for phase 1; I will raise it with the refinement-phase work.
  • DINOLoss(center_mode="mean") fails on half-precision input, because the float32 center buffer promotes the teacher output while the student output stays fp16 and torch.einsum does not promote. Pre-existing on master and untouched here; happy to open an issue.

Issues covering the breaking change:

  • None. Not a breaking change: all defaults are unchanged and no existing test was adapted.

@saud5150

Copy link
Copy Markdown
Contributor Author

@liopeer

liopeer commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

/review

@liopeer liopeer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks correct, I would mainly try to re-use the existing SwAV code though. I will have again a detailed look again later.

Comment thread lightly/loss/dino_loss.py Outdated
Comment thread lightly/models/modules/center.py Outdated
DINOv2 and DINOv3 replace the mean centering of the teacher output with the
Sinkhorn-Knopp centering from SwAV. DINOLoss now accepts
center_mode="sinkhorn_knopp", which normalizes the teacher logits so that every
prototype receives the same total weight across the batch instead of subtracting
a running center.

Following the reference implementation, the normalization is applied jointly
over all teacher views and no center is tracked. The center buffer stays
registered so that the state dict does not depend on the center mode.

The number of samples is reduced across processes rather than derived from the
world size, so that the shared helper also works for the iBOT loss where the
number of masked tokens differs between processes.
The DINOv3 paper applies Sinkhorn-Knopp centering to both the DINO and the iBOT
objective, and the reference training code asserts that centering is set to
sinkhorn_knopp. IBOTPatchLoss now accepts the same center_mode as DINOLoss.

Teacher normalization and the center update move into helpers so that
IBOTPlusPlusPatchLoss picks up the new mode without duplicating the branch.
DINOv3 applies the KoLeo regularizer to small batches of 16 samples. The
released configs reach that by running with a per-GPU batch size of 16, which is
not a setting lightly users are likely to train with.

KoLeoLoss now splits the batch into consecutive groups of group_size and
searches nearest neighbors within each group, so the paper setting is
reproducible at any batch size. topk and gather_distributed are added for parity
with the reference implementation.

Defaults are unchanged: without group_size the whole batch forms one group and
the loss is identical to before.
swav_loss.sinkhorn and the Sinkhorn-Knopp centering added for DINOv3 ran the
same algorithm. The implementation now lives in center.py and swav_loss.sinkhorn
delegates to it, keeping its signature and output dtype. Verified identical to
the previous implementation over 3600 random input, epsilon and iteration
combinations single-process, and over two gloo ranks with gather_distributed
both True and False.

The shared version calculates the exponential in float32, which fixes a NaN in
the SwaV path for half-precision input, and all-reduces the number of samples
instead of deriving it from the world size, which the iBOT loss needs because
the number of masked tokens differs between processes.

The docstring also no longer claims that DINOv2 uses Sinkhorn-Knopp centering.
DINOv2 offers it as an option but keeps mean centering in its released configs,
where it reports no difference on ImageNet-1k. DINOv3 uses it for both
objectives.
KoLeoLoss now rejects gather_distributed when torch.distributed is unavailable,
matching the ten other losses that do this, and reports a topk that is too
large for the group size instead of failing inside torch.topk. The previous
check skipped groups of size one, where a topk above one reached torch.topk
with k larger than the dimension.

DINOLoss and IBOTPatchLoss reject a negative sinkhorn_iterations, matching
MSNLoss, instead of silently running no iterations.

The Sinkhorn-Knopp probabilities are cast back to the dtype of the teacher
output, which was already the case but is now covered by a half-precision test
and a comment, because torch.einsum does not promote dtypes and the loss fails
without it.
The gathered KoLeoLoss was only covered by a mocked world_size=2 test, which
bypasses GatherLayer and therefore does not exercise its backward. The shared
gloo pool from lightly-ai#1982 makes a real two-rank test possible.

The forward test asserts that every rank sees the loss of the non-distributed
run on the concatenated global batch. The gradient test asserts the local
gradient is NUM_PROCESSES times the non-distributed one, because every rank
computes the loss of the whole global batch and GatherLayer all_reduces the
identical gradients before slicing out the local one. DDP cancels the factor
when it averages parameter gradients across ranks.
@saud5150
saud5150 force-pushed the feat/dinov3-phase1-losses branch from 8ce532a to 5336c4b Compare August 10, 2026 19:29
Comment thread lightly/models/modules/center.py
Comment thread lightly/loss/ibot_loss.py Outdated
Comment thread lightly/models/modules/center.py Outdated
Comment thread lightly/loss/ibot_loss.py Outdated
Replaces the CENTER_MODE_SINKHORN_KNOPP/VALID_CENTER_MODES module-level
constants with a Literal["mean", "sinkhorn_knopp"] type on center_mode in
DINOLoss and IBOTPatchLoss, and an explicit if/elif/else with a final
ValueError instead of a membership check against the shared constant list.
CENTER_MODE_TO_FUNCTION is kept since Center itself still relies on it.

Literal is imported behind TYPE_CHECKING because it requires Python 3.8+ and
this package still supports 3.7; the guard is exercised by deleting
typing.Literal and reloading both modules, which succeeds because the
annotation is never evaluated at runtime.

sinkhorn_iterations must now be at least 1 rather than merely non-negative,
since 0 iterations does not produce a valid probability distribution for
DINOv3 centering. The bound only moves in DINOLoss and IBOTPatchLoss: the
shared sinkhorn_knopp() function in center.py is left unconstrained, because
SwaVLoss also calls it and has long-tested, pre-existing support for
sinkhorn_iterations=0 as a deliberate configuration.
Two failures reproduced on the Python 3.7 CI job, both invisible locally
because this machine resolves a newer torch than the one pinned there
(torch==1.13.1).

_nearest_neighbor_indices returned an expression assembled from tensor
arithmetic directly; under torch 1.13.1's weaker operator stubs mypy widens
that to Any, tripping no-any-return. Assigning to a Tensor-annotated variable
before returning fixes it, matching the same gotcha hit on the VISReg PR
(lightly-ai#1968).

test_sinkhorn_knopp__half_precision ran the full DINOLoss forward pass in
half precision, which calls F.log_softmax on the student output. That has no
CPU kernel for Half in torch 1.13.1. The dtype-cast behavior the test guards
against lives entirely in _teacher_probabilities, which never touches
log_softmax, so the test now calls that method directly instead of going
through forward(). Confirmed this still catches the regression it was written
for: reverting the .to(teacher_out.dtype) cast makes the new assertion fail.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants