Performance and comparison

Measured results for GPUlayer on an NVIDIA GPU and across machines, including a side-by-side comparison with PyTorch Lightning. Every number comes from a script in benchmarks/, and the raw logs are in benchmarks/results/. Charts are generated from those logs by scripts/make_perf_charts.py.

Training speed against PyTorch Lightning

The same model, data, optimizer and logged loss, trained with GPUlayer's Trainer and with Lightning's Trainer on one Tesla T4.

Training step time: GPUlayer and PyTorch Lightning

Case (Tesla T4)GPUlayer, ms per stepLightning, ms per stepResult
MLP, fp32, batch 2564.065.64GPUlayer 1.39x faster
MLP, fp16 mixed precision5.006.25GPUlayer 1.25x faster
CNN, fp16 mixed precision, batch 12810.8212.33GPUlayer 1.14x faster
DDP, 2 CPU processes3.209.56GPUlayer 2.99x faster
  • Small models (the MLPs) take a few milliseconds per step, so the trainer's own work per step matters. GPUlayer's loop does less of it, for example it does not wait for the GPU when a loss is logged.
  • The CNN is dominated by GPU compute. GPUlayer turns on cuDNN autotuning by default (Lightning leaves it off) and copies the next batch to the GPU while the current step runs. Turning these off in the same session measured 12.67 ms per step (both off), 11.53 ms (autotuning only), 12.10 ms (copy-ahead only) and 10.93 ms (both). On the MLPs they made no difference beyond run-to-run variation (4.16 to 4.32 ms per step across the four variants). Logs: benchmarks/results/2026-10-31-gcp-t4/.
  • DDP on CPU runs with each trainer's defaults. GPUlayer gives each process one CPU thread; Lightning leaves PyTorch's default, so its two processes compete for the 8 vCPUs. Much of the difference likely comes from that, not from the training loop.

Start-up time: GPUlayer and PyTorch Lightning

Start-up is Python start, imports and the fixed cost inside fit(). GPUlayer starts 0.1 to 0.7 s sooner on one GPU, and 0.97 s later for DDP, where the job is packaged and scheduled first.

Method. GCP n1-standard-8 with one Tesla T4 (driver 580, Spot VM), Ubuntu 22.04, Python 3.12, torch 2.15.0 with CUDA 13.2, Lightning 2.6.6, run on 2026-10-31. The DDP row ran the same day on an n1-standard-8 without a GPU (8 vCPUs, CPU-only torch 2.15.0). SGD with momentum and one logged loss per step; Lightning runs without logger, progress bar, model summary or checkpoints, GPUlayer without checkpoints. Each case runs 100 and 600 steps, three times each in fresh processes, one run at a time with nothing else on the GPU. Milliseconds per step is the difference of the median wall times divided by 500. Script: benchmarks/lightning_comparison/.

These are single-GPU results with small models. Lightning has far more training features (strategies such as FSDP and DeepSpeed, loggers, many callbacks); this compares speed only.

Distributed job start-up

GPUlayer starts the processes of a distributed job itself by default, with the same environment torchrun would set, instead of going through torchrun's elastic agent.

Distributed job start-up: direct launcher and torchrun

A trivial job (import torch, create the process group, one collective) with 2 nodes and 2 processes each, timed from submission until the client sees the result. The direct launcher saves 1.1 to 2.3 s per job; the rest is mostly import torch in each process.

Across machines

Two GCP VMs on one network: a server and two workers on an n1-standard-8 with a T4, and a third worker on an e2-standard-8, 15 job slots in total.

TestResult
CUDA jobplaced on the T4 machine
A request no worker can satisfyrejected with the reason
Distributed job across both machines (4 processes, gloo)correct result; 2.43 s with the direct launcher, 4.73 s with torchrun
One process failsboth machines stopped within 2.7 s

Spreading jobs over two machines

WorkloadJobsWall clockTask time added upJobs per worker (cpu8, t4-cpu, t4)
Tiny jobs, 0.02 s each5009.0 s11.0 s243, 188, 69
Data shards, 0.6 s each20011.8 s127.8 s115, 72, 13
Training runs (a learning-rate sweep)9655.2 s241.7 s56, 34, 6

The tiny jobs measure GPUlayer's own overhead: 55.6 jobs per second across the two machines. The training sweep reaches 4.4x parallelism on 15 slots, because each job also imports torch (about 1.8 s on these VMs) and the e2 machines' 8 vCPUs are 4 physical cores.

One training job on more machines

The same training job (a CNN on 32,768 images, 2 epochs, global batch 256 split across all processes) on 2, 4 and 8 GCP n1-standard-4 machines, 4 processes per machine, gloo over the network. GPUlayer runs it with one Trainer(num_nodes=N, devices=4) call submitted to the server; Lightning runs Trainer(num_nodes=N, devices=4, strategy="ddp") started with torchrun on each machine, as its documentation describes.

One training job on more machines: GPUlayer and PyTorch Lightning

MachinesProcessesGPUlayerLightningGPUlayer ahead byFinal loss (GPUlayer, Lightning)
28118.9 s120.8 s1.6%2.129, 2.125
41673.3 s77.4 s5.3%2.143, 2.137
83250.5 s57.3 s11.9%2.162, 2.164
  • GPUlayer trains 11.9% faster than Lightning on 8 machines. From 2 to 8 machines it gets 2.35x faster, Lightning 2.11x.
  • The lead comes from two-level gradient sync (ADR-0020), the Trainer's default with gloo when a job has several machines and several processes per machine: gradients are summed inside each machine before one all-reduce among the machines, so a step makes fewer network round trips. It grows with the number of machines and matters most for small models. NCCL jobs use PyTorch's DDP as is.
  • GPUlayer starts all the machines from one Trainer(num_nodes=N) call; with Lightning you start torchrun on each machine, with matching ranks and addresses.
  • The losses agree, so both trainers did the same work.
  • With only 2 epochs, fixed costs (start-up, import torch, process-group setup) take a growing share as the training time falls. GPUs with NCCL were not measured.

Method: both trainers on the same machines (GPUlayer's job pinned with worker_ids), runs alternating between the two, wall time from start until the last process exits, median of three runs. Scripts: benchmarks/lightning_comparison/scaling.py and scaling_run.py; logs in benchmarks/results/2026-10-30-gcp-two-level/.

One distributed job in one region

The same 4-machine training job as above (4 processes per machine, gloo, two-level gradient sync, 2 epochs) on 12 GCP n1-standard-4 workers, all on Intel Skylake: 4 in europe-west1-b, 4 in europe-west1-c, 4 in europe-west4-a. The machines were pinned to each layout, runs alternated between layouts, median of five (benchmarks/lightning_comparison/locality_run.py).

MachinesMedianCompared with one zone
4 in one zone (europe-west1-b)48.6 s1.00x
4 in another zone (europe-west1-c)45.4 s0.93x
2 + 2 in two zones of one region45.0 s0.93x
2 + 2 in two regions (europe-west1, europe-west4)56.2 s1.16x
  • Splitting the job across two regions made it 16 to 24% slower than keeping it in one region.
  • Two zones of one region cost nothing measurable: 45.0 s against 45.4 s for one of those zones alone. The gap between the two single-zone runs comes from the machines, not the network.
  • Submitted without pinning, the scheduler put the job in one zone by itself (placed on worker group [...] (same zone europe-west4-a, ...)).
  • The first run of each layout was 8 to 12 s slower (cold start) and is part of the median's five runs. Final losses were identical. Logs: benchmarks/results/2026-10-31-gcp-locality/ (an earlier run on mixed Haswell and Skylake machines is kept there too).

Thirty machines

One server and 30 workers (n1-standard-4, 4 slots each, 120 slots), 23 in Belgium (europe-west1) and 7 in the Netherlands (europe-west4), created with benchmarks/cloud/gcp.sh and tested with benchmarks/scale_test.py.

TestResult
5,000 tiny jobs, 2,000 data shards, 480 training runsall succeeded, spread over all 30 workers
Distributed job on 8 machines x 2 processescorrect; 4.3 s (direct launcher), 6.9 s (torchrun)
Distributed job on 16 machines x 2 processescorrect; 5.4 s (direct launcher), 8.1 s (torchrun)
One process fails in an 8-machine jobevery machine stopped within 4.9 s
Worker processes killed on 3 machines during 600 jobs9 jobs retried on other machines, 0 failed

Submitting 2,000 jobs on a separate run with 8 n1-standard-4 workers (32 slots; logs in benchmarks/results/2026-10-30-gcp-9vm/):

WorkloadSubmittingTotalJobs per second
2,000 tiny jobs, one request per job9.6 s24.1 s82.9
2,000 tiny jobs, submit_batch1.1 s21.6 s92.7
2,000 data shards (0.84 s each), submit_batch1.1 s60.2 s33.2

The workers set the limit, since each job starts a Python process. The shards ran 1,682 s of work in 60 s on 32 slots, 87% of the ideal.

What has not been measured

  • Training speed on several GPUs (NCCL works on 4 GPUs in one machine, but no timed training run yet), NCCL across machines, and FSDP on GPUs.
  • Training that runs for hours, and more than 30 machines.
  • AMD GPUs.

Run the benchmarks yourself

See benchmarks/README.md for running each benchmark locally, and benchmarks/cloud/gcp.sh for recreating these runs on Google Cloud.