It’s 2026, and the world still trains AI models much the way it did when I bought my first Titan V almost a decade ago. Volta had just introduced Tensor Cores, and 16-bit matrix multiplications into 32-bit accumulators were all the rage. PyTorch was only starting to catch up with TensorFlow, and Mellanox was still an independent company selling the InfiniBand cards that joined supercomputers. Since then, Nvidia has bought Mellanox and folded the network into the GPU, but most training code still treats it as an afterthought. What I believe we still don’t do enough is design software around scale-up networks.
There are three ways to scale a GPU workload, and the lines between them are blurry.
My favorite has always been squeezing more out of every chip, and everyone knows scale-out, adding more servers on a network. But the one in between is the most interesting: a few racks of GPUs talking to each other directly, with remarkable throughput, low latency, and networking primitives built into the hardware. The GPU world calls it scale-up, though I’d call it diagonal, as it adds more chips like scale-out while they still work like one machine. I believe it’s the most important way to scale software these years, and the one least compatible with traditional frameworks like PyTorch or JAX.
That’s the bet behind the upcoming UForm v4 models from Unum: trained around NVL72 pods, where 72 GPUs share one NVLink domain, and built as one model for every modality, rather than separate towers tied together at the top, the way OpenAI’s CLIP and our past UForm models work. So before the release, one simple question:
how much does designing for NVLink actually buy you?
Hardware
I’m pretty much the worst customer a cloud can have: relatively poor, full of hardware demands, and running concurrent RDMA experiments that can stall a machine and every GPU in it. That kind of R&D barely fits on a VM, and even on bare metal it needs machines reconfigured mid-experiment by a patient, hands-on infrastructure team. So for this round, I went to Emma, one of our portfolio companies at AAL VC, and picked two bare-metal boxes of 8× Nvidia B300 SXM6 GPUs:
- Inside each box, the eight GPUs share one NVSwitch with multicast: the scale-up network.
- Between the boxes, each GPU reaches its counterpart through one 100 Gb/s RoCE v2 port: the scale-out network, kept deliberately lean by using one of the four ports each ConnectX-8 card exposes.
Bird’s Eye View & the Results
As usual, let’s start with the dessert!
I wrote a contrastive loss, the kind that trains retrieval and perception embedding models from OpenAI’s CLIP to Google’s SigLIP, as one C++ header and one benchmark binary, where one host thread drives all eight GPUs of a box. Each GPU scores its $R$ rows of 1024-dimensional BFloat16 embeddings against every row on every GPU, at five Matryoshka prefixes.
It competes with the losses people actually train with, all differentiating the same prefixes:
- ScalingContrasts:
multimemPTX through the NVSwitch inside a box, RDMA verbs between boxes, andtcgen05tensor-core tiles for the products. - open_clip: SigLIP and CLIP losses under PyTorch, with NCCL passing chunks between neighbours, all-gathering them, or all-gathering with gradients for the softmax InfoNCE of CLIP, and cuBLAS for the products.
- big_vision: Google’s SigLIP under
jax.jit, where XLA shards it over every GPU and lowers its gathers to NCCL and its products to cuBLAS.
Each cell shows the best loss-step time, then memory per GPU.
On one box, where NVLink joins all eight GPUs:
| 8x B300 | ScalingContrasts | open_clip bidir | open_clip gather | open_clip CLIP | big_vision |
|---|---|---|---|---|---|
| $R = 1024$ | 1.89 ms · 19 MB | 26.4 ms · 241 MB | 22.8 ms · 270 MB | 11.0 ms · 1.71 GB | 3.27 ms · 484 MB |
| $R = 4096$ | 4.17 ms · 210 MB | 27.3 ms · 3.03 GB | 26.1 ms · 3.40 GB | 83.0 ms · 26.2 GB | 8.18 ms · 6.19 GB |
| $R = 16384$ | 39.0 ms · 2.95 GB | 142 ms · 45.1 GB | 153 ms · 50.6 GB | out of memory | 106 ms · 60.8 GB |
On two boxes, one process each, joined by the RoCE rails:
| 16x B300 | ScalingContrasts | open_clip bidir | open_clip gather | open_clip CLIP | big_vision |
|---|---|---|---|---|---|
| $R = 1024$ | 3.62 ms · 24 MB | 45.7 ms · 474 MB | 39.3 ms · 503 MB | 25.4 ms · 6.64 GB | 4.57 ms · 883 MB |
| $R = 4096$ | 9.72 ms · 227 MB | 49.4 ms · 5.97 GB | 42.0 ms · 6.34 GB | 333 ms · 104 GB | 20.9 ms · 11.1 GB |
| $R = 16384$ | 77.7 ms · 3.02 GB | 362 ms · 89.1 GB | 305 ms · 94.6 GB | out of memory | 223 ms · 105 GB |
ScalingContrasts is the fastest at every size, keeps the same pace per GPU on two boxes as on one at the largest batch, and its memory barely grows as GPUs are added. It’s an apples-to-oranges comparison on purpose: part of that gap comes from engineering, and part from a different loss shape, reflecting how embedding models have moved from two towers toward one since the early 2020s.
One Set or Two
A contrastive loss places matching things close and everything else far, in one embedding space. CLIP turns each row of cosines into a softmax over the batch, while SigLIP scores every cell on its own, with a learned temperature $t$ and bias $b$:
$$\mathcal{L} = -\frac{1}{N} \sum_{i} \sum_{j} \log \sigma\big(z_{ij} \cdot (t \cdot \cos(x_i, y_j) + b)\big)$$
where $z$ is +1 for a positive pair and -1 for every negative. Because every cell stands alone, the grid can be cut into tiles in any order. Matryoshka representations ask the first 64, 128, 256 and 512 dimensions to work as embeddings too, so the cost is five grids, not one.
Every negative a row sees is a row some other GPU computed, and more negatives make better embeddings. So the loss is not a function that happens to be distributed: the loss is the collective, and how much it exchanges depends on its shape:
In CLIP and SigLIP’s two-set losses, cells $(i, j)$ and $(j, i)$ pair different images with different captions, and each gradient has two owners, usually on different GPUs. My loss scores one set against itself, with the same labels and weights for mirrored cells, so the owner of row $i$ differentiates both: no embedding gradients cross GPUs. In UForm, an image and its caption are simply two rows of that set marked as a positive pair, which keeps the grid symmetric. The price is scoring every pair twice, once on each owner’s GPU, so at $R = 16384$ on eight GPUs, ScalingContrasts leads big_vision 2.7× in scored cells per second, but 1.36× in distinct pairs.
Part of that lead is plain engineering that any loss could adopt, and the rest is the shape of the loss: embeddings still travel, but their gradients never make the return trip. That’s the point of the whole article: the loss you can afford depends on the network you design for.
I’m not alone in walking that way:
- FuseLIP runs one early-fusion transformer, trained with SigLIP’s sigmoid loss.
- Qwen3-VL-Embedding and GTE add query-query and document-document negatives.
- CLIPPO and OneR use one tower for every modality, with a two-set loss.
On the memory side, DisCo-CLIP and Inf-CL share my instinct to never materialize the whole grid, and SigLIP’s own chunked loss swaps all-gathers for neighbour permutes. ScalingContrasts tiles the same way, but it stages peers over NVLink, and its embedding gradients stay home.
Down the Stack
So what makes such a loss cheap on a scale-up network? The answer follows the diagram from the start, from the inside out: the chip, the box, the switch, and the link between boxes.
On the Chip: Tensor Memory & the Matryoshka Sweep
Blackwell moved the tensor core’s accumulator into a dedicated 256 KB per SM, Tensor Memory, driven by tcgen05 instructions a single thread issues.
The forward kernel accumulates every cell’s full 1024-dimensional dot product there, then sweeps back down, subtracting dimensions 512 to 1023, then 256 to 511, 128 to 255 and 64 to 127, so the five prefixes come out widest first.
That order lets each band’s gradient build up as a running sum in tensor memory, while the MMA warp sweeps the next segment, so no Float32 cube of products ever reaches HBM.
The subtraction comes from the hardware: every tcgen05.mma takes a 32-bit instruction descriptor, and its bit 13 negates the A operand, so the same MMA subtracts instead of adding:
| |
The surprise was where the time went.
The tensor cores finish the products quickly, but every cell of every prefix then needs scalar math on ordinary CUDA cores: normalizing the cosine, the sigmoid, the logarithm and the gradient.
That arithmetic, not the MMAs, held the kernel to an eighth of the tensor cores’ peak.
Multiplying by stored inverse norms instead of dividing twice per cell, replacing log1pf with a degree-8 polynomial fit, and folding the per-tile partials in parallel made the whole step 1.8× faster.
Across the Box: Pointers, Not Gathers
Inside a box, every GPU maps every other GPU’s memory, and a load from a peer is just a load: NVLink is a memory bus pretending to be a network. So there is no all-gather: each GPU walks its peers one candidate block at a time, staging each over NVLink, as the copy engine feeding the tiles, the Tensor Memory Accelerator (TMA), reads only local memory, and scoring it before touching the next. At fixed $R$, the loss reuses its scratch for every peer, and as each row’s owner differentiates both mirrored cells, its embedding gradient never leaves its GPU.
PyTorch’s DDP and FSDP, Megatron, and most NCCL setups reserve a process or thread per GPU, which in my experience turns synchronization and graph capture into a nightmare once a network pushes sparsity beyond mixture-of-experts. Here one thread enqueues the within-box step for all eight GPUs and never waits on any of them:
| |
Yes, this is what modern C++ looks like if you use NumKong tensors 😉 If you want, you can even wrap the multicast memory from the next section into a custom allocator that hands every GPU the same offset in its own copy, so a buffer mirrored across GPUs becomes just another
nk::tensor<f32_t, multimem_allocator<f32_t>, 1>, wherenk::tensoris a memory-owning analog ofstd::mdspan.
Ordering inside the box comes only from device barriers, so the host never waits for GPU completion. It’s not free, though: the thread enqueues about 55 calls per GPU, and the last GPU starts so late that the final all-reduce spends almost 40% of a mid-sized step waiting. CUDA Graphs should remove the skew, but the cross-box path still blocks the thread on RDMA completions, and the learned temperature and bias still arrive as host floats.
Spinning has a nastier trap. With lazy loading, the first launch of a kernel can block the host thread on device synchronization. A device spinning in a barrier, for a peer the thread hasn’t reached yet, never goes idle, so nothing crashes and the step never ends. Loading every kernel on every GPU before the first barrier fixes it.
In the Switch: Barriers & Reductions
Since Hopper, the NVSwitch can add numbers in flight, NVLink SHARP in Nvidia’s words, exposed in PTX as multimem instructions on one address mapped to every GPU.
Our loss sums only three scalars this way, but a real training step sums every weight gradient, with the same all-reduce:
| |
It moves 1.3× the best bus bandwidth I measured with NCCL, repeats bit for bit, and keeps its rounds on the device, so a replay needs no host writes.
But the switch ends at the edge of the box.
Between Boxes: One Write per Embedding
Past it, each GPU writes its embeddings straight into its counterpart’s memory through its own RoCE port, once per step, while both boxes score their own blocks. Every byte that leaves a GPU has to earn its trip, and each trip is one work request:
| |
Without the nvidia_peermem module, GPU memory registers with the NIC as a dma-buf, and the scatter entry must name the device pointer, not the address the registration returns, or the first write dies with “local protection error”.
Footprint
Going this deep has a side benefit: the binaries get much smaller, so a training pipeline doesn’t have to start with an hour-long Docker pull:
- cuBLAS with cuBLASLt: 603 MB, 45× larger, just for the matrix products.
- NCCL: 242 MB, 18× larger, just for the collectives.
- NVSHMEM: 78 MB, 6× larger, just for the symmetric memory.
- ScalingContrasts: 13.4 MB for the tiles, the peer staging, the switch collectives and the rails, plus the CUDA driver, a 0.8 MB
libcudartandlibibverbs.
The catch is that those 13.4 MB target exactly one GPU model, and the machines coming next may each need a binary of their own.
The Future of Scale-Up
The scale-up domain is growing beyond an eight-GPU box, and Nvidia isn’t the only vendor building it. Across vendors and generations, the whole machine changes shape:
| Scale-up domain | GPUs | Per GPU, both ways | Fabric | Reductions | Matrix units |
|---|---|---|---|---|---|
| Nvidia Hopper | 8 | 0.9 TB/s | NVLink 4 through NVSwitch | ✓ | WGMMA |
| AMD MI355X | 8 | 1.1 TB/s | Infinity Fabric, a direct mesh | ✗ | MFMA |
| Nvidia Blackwell | 72 | 1.8 TB/s | NVLink 5 through NVSwitch | ✓ | TCGen05 |
| AMD MI455X | 72 | 3.6 TB/s | UALink over Ethernet switches | ? | WMMA |
| Nvidia Rubin | 72–144 | 3.6 TB/s | NVLink 6 through NVSwitch | ✓ | TCGen05 |
So, how much does designing for NVLink buy you?
On two B300 boxes, a one-set loss built around the switch runs 2.9× faster than big_vision’s two-tower SigLIP, holds 35× less memory per GPU, and keeps its per-GPU pace from one box to two.
And if you’re buying Rubin, it’s a different platform and a different binary, but this may be the first generation in years where the source can stay the same: Hopper’s wgmma gave way to Blackwell’s tcgen05 and tensor memory, while Rubin seemingly keeps both, along with the multimem reductions in its NVLink 6 switches.
The CUDA implementation and the PyTorch & JAX baselines are on GitHub, if you want to try it.