Performance in version 3.0.1¶
Version 3.0.1 fixes the large BF16 time and memory regression in Euclidean distances used by Contrastive and batch-hard TripletMargin losses. The A100 and RTX 5090 measurements below show the improvement, comparisons with v2.9.0, and remaining limitations.
Low-precision distance fix¶
The version 3.0.1 fix was measured at
32bcfa8,
whose numerical code is identical to the released v3.0.1 code. These results
compare that code with released v2.9.0 and v3.0.0 on an A100 and RTX 5090.
Tables label it 3.0.1 code; CSV and raw records retain
candidate-32bcfa8 to identify the measured commit.
The complete 3.0.1 code timings and memory measurements
include NT-Xent and ProxyAnchor controls, float32 batches 128, 512, and 2,048,
and BF16 batches 512 and 2,048. Every successful configuration averages
900 forward/backward pairs across three fresh processes.
At BF16 batch 2,048, 128 dimensions, and four samples per class:
| GPU | Loss | v2.9.0 ms | v3.0.0 ms | 3.0.1 code ms | v3.0.0 → 3.0.1 code peak MiB |
|---|---|---|---|---|---|
| RTX 5090 | Contrastive | 2.56 | 13.3 | 2.23 | 2,118 → 356 |
| RTX 5090 | Batch-hard + Triplet | 1.10 | 16.4 | 1.38 | 2,118 → 151 |
| A100 | Contrastive | 7.63 | 23.4 | 6.32 | 2,118 → 308 |
| A100 | Batch-hard + Triplet | 4.93 | 30.0 | 5.47 | 2,118 → 103 |
Version 3.0.1 removes the severe regression on these workloads. It does not win every comparison: RTX 5090 batch-hard triplet remains 25.6% slower than v2.9.0, while the A100 process ranges overlap (4.64–5.36 ms for v2.9.0 and 4.41–6.54 ms for the 3.0.1 code). Contrastive uses about 3% more peak allocation than v2.9.0. The A100 controls did not reproduce the original 53% ProxyAnchor slowdown at float32 batch 2,048: the new release comparison measured 16%, and the 3.0.1 range overlaps v2.9.0. Small NT-Xent timings also overlap on that system; their original mean difference is not a stable regression estimate.
The correction avoids the direct Euclidean-distance backward kernel's batch-by-batch-by-dimension allocation when float32 matrix evaluation is numerically safe. It screens distances for cancellation and retains the direct calculation for nearby or duplicate rows, other norms, small matrices, compilation, CUDA graph capture, and reduced float32 matmul precision. These measurements use standard-normal inputs and highest float32 matmul precision; clustered training embeddings can take the slower direct path.
Values and full gradients were checked against direct-difference references
for float16 and BF16 random, nearby, duplicate, and mixed-scale inputs. GPU
tests also cover CUDA graph capture and reduced matmul precision. The four
follow-up losses passed float32 scalar and full-gradient comparison between
released v3.0.0 and 32bcfa8 before timing.
The A100 validation
and RTX 5090 validation
record the errors and archive checks. An additional
BF16 comparison at batch 2,048
on the A100 found unchanged Contrastive and batch-hard triplet loss values, with
maximum embedding-gradient absolute differences of 5.96e-8 and 2.38e-7.
Batch capacity and wider GPU coverage¶
The five-GPU benchmark and its maximum-batch searches measured v3.0.0. Version 3.0.1 has a separate two-GPU timing and memory comparison above; maximum batch sizes have not been remeasured for 3.0.1. Use the v3.0.0 capacity results as the measured baseline when planning memory for larger batches.
The GPU release comparison measures released v3.0.0 against v2.9.0 on RTX 4090, RTX 5090, L40S, A100, and H100 systems on 2026-11-01 UTC. It reports absolute v3.0.0 forward/backward time, peak memory, verified batch capacity, and version ratios for seven common losses plus batch-hard triplet mining. Full measurements retain both versions, all shapes, BF16, failures, and process ranges. The large-triplet follow-up uses shorter timing blocks where the main protocol exceeds its time limit.
In the v3.0.0 comparison, NT-Xent provides the clearest improvement: at batch 512, 128 dimensions, four samples per class, and float32, v3.0.0 is 2.28–33.5× faster and reduces peak PyTorch allocation by a factor of 79.2–186 across these five systems. On the RTX 5090, that is 0.733 ms per forward/backward pair and 82.7 MiB peak allocation. Under the 90% memory budget, its verified NT-Xent batch limit increases from 768 to 19,968; the H100 increases from 1,024 to 31,744.
These are loss-only results. Other losses have smaller gains, overlapping timing ranges, or regressions; the report includes all of them. The figures do not describe end-to-end training speed or establish one GPU as universally faster than another rented system.
Development measurements¶
Run benchmarks from the repository's frozen development environment. These
measurements establish a reference for changes; they do not impose a universal
speed requirement across machines. The runner imports the library from the
repository's src directory.
uv run --frozen --no-sync python -m benchmarks.run \
--batch-size 128 --dimensions 128 --repeats 20 --warmup 5 \
--output /tmp/pml-cpu.json
The default run measures contrastive loss, all-triplet margin loss, NT-Xent
loss, pair-margin mining, pair-margin mining followed by triplet margin loss
(mined-triplet, which converts mined pairs to triplets), and
normalized-softmax proxy loss. Inputs are standard-normal tensors with
balanced contiguous labels, four examples per class unless
--samples-per-class says otherwise. Distances normalize embeddings inside the
measured call. The contrastive negative margin is 1.4, triplet margin is 0.2,
NT-Xent temperature is 0.07, miner margins are 0.2 and 1.4, and proxy
temperature is 0.1. The proxy benchmark includes gradients for its learnable
weights.
The separate-cosine, reused-cosine, separate-lp, and reused-lp cases run
MultiSimilarityMiner, MultiSimilarityLoss, and ContrastiveLoss on one batch.
A separate case computes one distance matrix per module. A reused case
computes one matrix with gradients first and passes it to all three as
mat. Compare each pair to measure what
repeated distance computation costs; their losses are equal for the same seed.
These cases include no encoder, so they overstate the saving on a whole training
step.
The full-ntxent and anchor-batched-ntxent cases, and the matching triplet pair,
compare a full-batch backward() with
anchor_batched_backward in chunks of
--anchor-batch-size anchors. An anchor-batched case does all of its work in
forward_ms, so compare totals, and its backward time is null. The
unbounded-mined-triplet and bounded-mined-triplet cases compare a
TripletMarginMiner with and without
tuples_per_anchor=32. Peak memory for all of these
needs --device cuda.
The default run includes every case except distributed. Timings of a few milliseconds
vary between processes: the same case differed by up to 122% across four 20-repeat
runs on a rented GPU, while peak memory was identical. Compare timings from several
processes with --repeats 100 or more, and treat a difference as real only when the
ranges of the two cases do not overlap.
Change --batch-size, --dimensions, --samples-per-class, --dtype, and
--threads to describe the intended workload. Batch size must contain at least
two complete classes. Each case uses its own seeded inputs, independent of how
many random parameters the implementation initializes. Record multiple shapes
before drawing conclusions about scaling.
uv run --frozen --no-sync python -m benchmarks.run \
--device cuda --batch-size 512 --dimensions 128 \
--output /tmp/pml-cuda.json
uv run --frozen --no-sync python -m benchmarks.run \
--case proxy --mode compile --output /tmp/pml-compiled-proxy.json
Compilation uses torch.compile(..., fullgraph=True). An unsupported operation
fails the command; there is no automatic eager fallback. The first step,
including lazy graph compilation, is reported separately. Module construction,
compiler registration, and process startup precede that measurement. The default five
additional warmup steps precede twenty measured steps. Use a fresh process when
comparing startup behavior and record whether the compiler cache was warm.
For distributed measurements, run one process per device:
uv run --frozen --no-sync torchrun --standalone --nproc_per_node=2 \
-m benchmarks.run --case distributed --device cuda \
--batch-size 128 --dimensions 128 --output /tmp/pml-nccl.json
The distributed case includes a linear encoder, full-batch contrastive loss,
autograd-aware gathering, and DDP gradient synchronization. --batch-size is
per rank; the report also records the global size. Compare this case with its
own previous measurements, since the single-process contrastive case does not
include an encoder. Gloo/CPU works with --device cpu for local experiments.
Correctness tests launch their own workers and must not be wrapped in torchrun.
For a local macOS Gloo run, use the loopback interface and an explicit local address so rendezvous does not depend on the laptop's hostname resolving:
GLOO_SOCKET_IFNAME=lo0 uv run --frozen --no-sync torchrun \
--standalone --local-addr=127.0.0.1 --nproc_per_node=2 \
-m benchmarks.run --case distributed --device cpu \
--batch-size 128 --dimensions 128 --output /tmp/pml-gloo.json
Every measured step uses a fresh computation graph and cleared gradients. Forward timing includes graph construction; backward timing includes gradient computation and synchronization. CUDA synchronization brackets both intervals. Distributed barriers align the start of steps outside the measured intervals. Reports include raw per-rank samples, medians, spread, and the Git revision. Use the slowest rank when comparing distributed performance.
CUDA peak memory is additional allocated tensor memory above the pre-forward baseline, including saved intermediates and gradients. Model parameters and inputs are already present at that baseline. This is not allocator-reserved memory or total device usage. CPU peak tensor memory is reported as null. The runner verifies finite losses/gradients or valid miner indices before sampling; mathematical correctness is established by the reference tests.
Systematic release comparison¶
benchmarks/release.json defines a matched comparison of the exact released
v2.9.0 and v3.0.0 commits. The matrix covers NT-Xent, Contrastive, all-triplet
margin, batch-hard mining plus triplet margin, ArcFace, SupCon, MultiSimilarity,
and ProxyAnchor. Batch sizes are 128, 512, 2,048, and 8,192. The standard profile
uses 128-dimensional embeddings and four samples per class; the wide/dense
profile uses 512 dimensions and sixteen per class. Proxy losses instead use
cyclic labels and fixed class counts of 1,024 and 10,000 respectively. Those
class counts do not grow with the batch.
Both releases run in separate processes in the same frozen environment on each
GPU. The inputs are CPU-seeded standard-normal embeddings. Proxy weights are
identical, with ArcFace's old weight layout transposed and its old degree-based
margin translated to the same 0.5 radians. Other settings are explicit in
benchmarks/measurement.py. Before timing, each loss's scalar value and full
embedding/parameter gradients must match at float32 (rtol=2e-4, atol=2e-6).
No model, optimizer, or data-loading work is included.
The matrix uses ordinary full loss calls and backward passes. Anchor batching,
compilation, and distributed execution remain separate experiments.
Timing uses three independent process rounds with interleaved version order. Each process warms up for at least ten pairs and one second, then measures three blocks of 100 forward/backward pairs. Each pair builds a fresh graph and clears gradients. CUDA synchronizes only at block boundaries. The primary time is elapsed block wall time divided by 100; CUDA events provide averaged forward/backward breakdowns. The event objects are initialized before timing. Normal dispatch, event-recording, and gradient-clearing overhead remains in the wall time. Report the mean of process means and their range; overlapping ranges are not evidence of a reliable speed difference.
Peak total PyTorch allocation, incremental peak above the warmed pre-forward allocation, and allocator reservation are distinct metrics. The warmed baseline contains inputs, parameters, and any retained workspaces. The main report uses total allocated memory for memory ratios; incremental allocation excludes the warmed baseline and is useful for understanding additional loss costs. Neither metric includes all device usage, such as CUDA context memory. Capacity probes use 90% of device memory as the tensor allocator budget, leaving 10% outside it. They double batches and refine a success/failure bracket to 128 rows or 5%, whichever is larger. Each successful endpoint is confirmed in two more fresh processes, each with a first step, one warmup, and three measured steps. OOM, indexing limits, nonfinite results, a five-minute subprocess timeout, and the 1,048,576-row search ceiling remain distinct outcomes. A search ceiling is a lower bound, never a measured hardware limit. Actual encoder/optimizer training needs additional memory beyond these loss-only limits.
Float32 is the main comparison. BF16 inputs and parameters are also measured at batches 512 and 2,048 in the standard profile, without autocast. Finite losses and gradients are required, but the full cross-version gradient equivalence check is float32. BF16 results do not establish mixed-precision training quality.
Run from a clean, committed benchmark checkout:
uv sync --frozen --no-build --no-install-project --all-extras --python 3.12.13
git worktree add --detach /tmp/pml-v2 v2.9.0
git worktree add --detach /tmp/pml-v3 v3.0.0
uv run --frozen --no-sync python -m benchmarks.compare \
--source-2 /tmp/pml-v2 --source-3 /tmp/pml-v3 \
--output /tmp/pml-gpu-results
Use --resume with the same output directory after an interruption. Resume
requires the same complete configuration, source and benchmark revisions, and
GPU identity. Completed records are retained; unexpected worker errors stop the
campaign for investigation. The source revisions are checked against the JSON
matrix and dirty library source is rejected. The existing runner above remains
available for distributed, compilation, and other development experiments.
After collecting completed campaign directories from each GPU:
uv run --frozen --no-sync python -m benchmarks.report \
/tmp/rtx4090 /tmp/rtx5090 /tmp/l40s /tmp/a100 /tmp/h100 \
--output docs/benchmarks/gpu-3.0.0
The report generator requires complete timing, correctness, and capacity records. It produces Markdown tables, CSV measurements, standalone SVG scaling charts, compressed raw block measurements, and hardware/source provenance. GPU and host CPU both affect the result, especially on small eager workloads; measure the target training setup before setting a performance budget.
Large all-triplet batches can fit in memory but exceed the five-minute limit
when timing 300 pairs per process. The separate benchmarks/large-triplet.json
experiment measures batch 8,192 using three blocks of ten pairs in each of three
fresh processes, with the same warmup and input settings. It preserves the
primary experiment's timeouts and gives the shorter protocol its own records:
uv run --frozen --no-sync python -m benchmarks.compare \
--config benchmarks/large-triplet.json --phases speed \
--source-2 /tmp/pml-v2 --source-3 /tmp/pml-v3 \
--output /tmp/pml-large-triplet
uv run --frozen --no-sync python -m benchmarks.report \
/tmp/pml-large-triplet --timing-only \
--output /tmp/pml-large-triplet-report
--timing-only requires the complete timing matrix and exports the block count,
process ranges, memory, CSV, and raw evidence. Full-gradient agreement and batch
capacity remain separate measurements in the primary campaign.
Reproduce the 3.0.1 code measurements¶
benchmarks/low-precision-fix.json pins all three source commits. To reproduce
the timing matrix, create a clean worktree for each source and run the generic
campaign API from a clean, committed benchmark checkout:
git worktree add --detach /tmp/pml-fix 32bcfa8213a9a48d95153a07a6cf6e0e753e1a18
uv run --frozen --no-sync python - <<'PY'
import json
from pathlib import Path
from benchmarks.compare import Campaign, speed_plan
config = json.loads(Path("benchmarks/low-precision-fix.json").read_text())
sources = dict(zip(config["versions"], map(Path, (
"/tmp/pml-v2", "/tmp/pml-v3", "/tmp/pml-fix"
)), strict=True))
campaign = Campaign(config, sources, Path("/tmp/pml-fix-results"), "cuda", True)
for request in speed_plan(config):
campaign.run(request)
PY
uv run --frozen --no-sync python -m benchmarks.report \
/tmp/pml-fix-results --timing-only --output /tmp/pml-fix-report
Pre-release development reference (2026-10-29)¶
These historical measurements precede numerical changes in the final v3.0.0 release. They document development progress and should not be used as final release performance claims.
Measured on 2026-10-29 UTC with Python 3.12.13, PyTorch 2.14.0, float32, 128 dimensions, seed 17, and one CPU thread per rank. CPU rows ran on an Apple M1 Pro with ten warmup and fifty measured steps. CUDA rows ran on NVIDIA A40s with CUDA 13.0. Values are median milliseconds; peak is the additional allocated CUDA memory in MiB. Neither machine was isolated for benchmarking: use the raw sample spread and repeat on the target hardware before setting a budget.
| Case | M1 Pro b128 fwd / bwd | M1 Pro b512 fwd / bwd | A40 b128 fwd / bwd | A40 b512 fwd / bwd | A40 b512 peak |
|---|---|---|---|---|---|
| Contrastive | 0.291 / 0.196 | 2.888 / 1.473 | 2.026 / 2.901 | 1.994 / 2.439 | 19.5 |
| All-triplet | 1.197 / 0.446 | 19.489 / 5.879 | 3.810 / 3.241 | 3.841 / 3.312 | 63.2 |
| NT-Xent | 0.463 / 0.240 | 6.131 / 1.984 | 2.974 / 3.867 | 2.973 / 3.888 | 18.5 |
| Mined triplet | 0.909 / 0.265 | 12.285 / 2.808 | 5.606 / 3.249 | 4.562 / 3.261 | 27.6 |
| Pair miner | 0.361 / — | 4.635 / — | 0.857 / — | 0.832 / — | 7.7 |
| Proxy | 0.073 / 0.136 | 0.184 / 0.342 | 1.538 / 3.152 | 1.517 / 3.142 | 1.6 |
Raw results: M1 Pro batch 128 and batch 512; A40 batch 128 and batch 512. The A40 timings at these sizes are dominated by kernel launches, so they barely change with batch size.
The full-graph compiled proxy at batch 128 took 0.064 ms forward and 0.067 ms backward on the M1 Pro and 0.331 ms and 0.576 ms on the A40, after first steps of 3.3 s and 9.9 s (M1 Pro, A40). This workload does not justify compiling by default.
The distributed case, a linear encoder with the global-objective contrastive
loss and DDP, took 1.315 ms forward and 0.980 ms backward on the slower of two
Gloo ranks on the M1 Pro with 64 rows per rank
(raw). With two A40s and NCCL
with peer-to-peer transport disabled (NCCL_P2P_DISABLE=1), the slower rank took 5.360 ms and
4.849 ms with 64 rows per rank and 5.104 ms and 4.623 ms with 256
(64 rows,
256 rows). Disabling peer-to-peer
transport means these numbers do not measure direct GPU-to-GPU transfers.
Comparison with version 2.9.0¶
The same runner measured the v2.9.0 release on the same machines. Losses agree to float32 rounding for every case except the proxy loss, whose weights are initialized with a different layout. Composition paths changed the most: version 2 compared every positive pair with every negative pair when it turned mined pairs into triplets and when it computed NT-Xent, while version 3 groups negatives by anchor. The table uses 512 samples with sixteen per class, where those Cartesian intermediates are largest.
| Case, 512 × 16 per class | M1 Pro v2.9.0 fwd / bwd | M1 Pro v3 fwd / bwd | A40 v2.9.0 fwd / bwd | A40 v3 fwd / bwd | A40 peak v2.9.0 → v3 |
|---|---|---|---|---|---|
| NT-Xent | 15,202 / 15,781 ms | 6.149 / 2.046 ms | 185.5 / 143.7 ms | 1.692 / 1.782 ms | 31,627 → 18.4 MiB |
| Pair miner → triplet loss | 2,262 / 16.6 ms | 30.91 / 10.99 ms | 11.69 / 4.65 ms | 2.521 / 3.106 ms | 801 → 123 MiB |
With four samples per class, all-triplet enumeration at batch 512 took 331.6 ms forward on the M1 Pro in v2.9.0 and 19.5 ms in v3, and its A40 peak fell from 148 MiB to 63 MiB; NT-Xent's A40 peak fell from 6,485 MiB to 18.5 MiB. Raw results: v2.9.0 on the M1 Pro batch 128, batch 512, NT-Xent, and mined triplet; v3 on the M1 Pro NT-Xent and mined triplet; v2.9.0 on the A40 batch 128, batch 512, NT-Xent, and mined triplet; v3 on the A40 NT-Xent and mined triplet.
Large batches and tuple memory¶
The number of valid pairs and triplets can grow rapidly with batch size and
label composition. Triplet enumeration allocates only valid triplets, but the
result can still exceed available memory. A PyTorch nonzero error mentioning
INT_MAX means an intermediate exceeds its indexing limit. Reduce batch size
or use a miner that selects a bounded subset of pairs or triplets.