vkML 0.1.0

Performance

What vkML currently costs, where the time actually goes, and which of those numbers are trustworthy.

⚠ Warning

vkML is not fast yet, and this page does not pretend otherwise. The core is correct and heavily tested; the performance work is largely ahead. Everything below is a measurement, not a target.

The machine

ComponentWhat it is
Discrete GPUAMD Radeon RX 5600M (RADV NAVI10), 36 CUs, 5.75 GiB device-local, 256 MiB host-visible
Integrated GPUAMD Radeon Graphics (RADV RENOIR), 6 CUs
DriverRADV (Mesa)

Validation layers are on by default. Every figure below includes that cost, which is the honest default to measure — but it is worth knowing before comparing against anything else.

The headline number

Same CNN architecture, same batch, same optimiser, CIFAR-100:

Configurationms/step
PyTorch, CPU, 8 threads34.26
vkML, discrete GPU, 36 CUs35.77

A 36-compute-unit discrete GPU is 4% slower than PyTorch on a CPU. Since torch on a GPU would be many times faster than torch on a CPU, the real distance to parity is not the 1.04× this shows — it is that whole further multiple.

Where the time goes: starvation, not slow kernels

Batch scaling separates fixed cost from arithmetic. If per-sample time falls as the batch grows, the device was idle waiting for work:

Batchms/stepms/samplevs batch 64
6434.450.5381.00×
12826.230.2050.38×
25639.390.1540.29×
51270.290.1370.26×

Per-sample cost falls 3.9×. The strongest single line is batch 128: it does twice the work of batch 64 in less wall time, which only happens when fixed per-step cost dominates arithmetic.

At the batch size the examples use, most of a training step is overhead. Batch scaling infers that; it cannot say how much, or which part. Measuring it directly is what the next section does.

Measured directly: where a CIFAR step's time goes

Batch scaling and device substitution both locate the problem without closing it. vkML publishes each dispatch as a measured interval and each kernel choice as a decision, both carrying the same dispatch identity, so a consumer can join the two and account for a whole step:

python examples/cifar100/train.py --attribute 20
KernelDispatchesGPU ms% of step
matmul54049.62430.9%
add2409.5836.0%
im2col607.3494.6%
max_pool2d_backward607.2744.5%
sum, workgroup-tree structure1406.0163.7%
sum, lane-per-output structure402.8471.8%
col2im402.5861.6%
17 more210019.93312.4%
GPU busy105.212 65.4%
GPU idle inside submissions0.3850.2%
host and driver55.183 34.3%
step wall160.781100.0%

20 steps at batch 64 after 20 warm-up steps, best of 5 rounds, on the RX 5600M. One round of identical work varies by 20% on this machine — GPU time and host time do not scale together, so a single round distorts the split and not only the total.

matmul is 30.9% and the next line is 6.0%. When this measurement started it was not the largest line at all. Five rounds of measure–fix–remeasure have taken every other kernel below a tenth of a step. The two sum rows are one kernel reported under the two structures it chooses between — the profiler's cost joined to the planner's decision, which is what the dispatch identity exists for.

Two things only direct attribution could say. The 20 steps made 160 submissions — 8 each, of which 4 carry compute; the others are two uploads and the two behind .item(). And GPU idle time inside submissions is 0.2%: the barriers between dispatches are not the cost.

What it paid for, five times over

The first table this produced showed the optimiser spending 24 of a step's 39 submissions on eight parameters. Every parameter's update is independent, so the optimisers were rewritten to build all of them first and realise them together: 1.5–1.9× on the optimiser phase across all seven configurations, parameters bit-identical.

Re-attributing after that change — rather than assuming it had finished the job — showed backward() doing the same thing one layer down, and worse: 11 submissions per backward pass, five of them carrying a single dispatch. Two causes. The loop that deposits each parameter's gradient realised them one at a time. And two backward rules, for max-pooling and slicing, called realize() unconditionally where every other rule realises only in eager mode — so in the lazy mode both examples train under, they cut the graph three times per pass in a three-block CNN. 11 → 1, gradients bit-identical.

Re-attributing again pointed at what was left: one parameter assignment per parameter, each its own submission at a measured 40–80 µs. That one had been written down as blocked on two larger changes — and re-checking found the first had already been dissolved by the optimiser rewrite, and the second was never on the path. assign_ did not need to become part of the graph; it needed to stop being one submission per call, and only the backend knows what a submission is. The copy primitive now takes a list, and an optimiser step costs a constant three submissions regardless of how many parameters the model has.

At that point the scheduling was done and the profile pointed at kernels — and all three it named turned out to be addressing-bound rather than memory-bound, a diagnosis only a comparison against an equal-traffic kernel could make. The reduction launched one workgroup per output where it wanted one lane; im2col, col2im and max_pool2d spent more time computing which four bytes to move than moving them.

startoptimiserbackwardassign reductionsunfoldpool
submissions/step3925158 888
step wall13.57 ms12.09 ms11.71 ms10.07 ms 8.87 ms8.07 ms8.04 ms
GPU busy / 20 steps128.4 ms 112.1 ms105.2 ms
host and driver42.0%35.7%33.7% 24.0%30.1%30.4%34.3%
GPU / wall0.580.640.660.760.70 0.700.65

13.57 ms → 8.04 ms, a 1.69× end-to-end speedup, and every result is bit-identical. Nothing here changed what vkML computes — only when, and in what order the addresses are worked out.

Note the host share rising in the last three columns while the step gets faster. The same host cost against a smaller step is a larger fraction of it. A percentage is a ratio and this one has two moving ends; the milliseconds are the thing to read.

⚠ Fewer submissions is not the same as faster

An intermediate version of that change removed seven submissions from the optimiser and was slower than doing nothing — 17 submissions at 2.12 ms against 24 at 1.84 ms. The saving only appeared once both of the optimiser's passes batched. Submission count is a proxy for host cost, and the relationship is not monotonic.

The same report over any code of your own:

import vkml
from vkml.attribution import capture

with capture() as cap:
    for _ in range(20):
        train_one_step()

print(cap.report().table())

capture turns on profiling, submission retention and decision recording for its duration and turns them off again on exit. It is a consumer: it joins what the profiler and the planner each publish, and neither of them knows it exists.

ⓘ What this number is worth

The host and driver row is an upper bound. Its wall clock is a profiled one, and vkML's own measurement rules forbid subtracting an unprofiled run to remove the profiler's readback — so the readback lands in that bucket. The GPU rows are timestamps and are unaffected. The report prints GPU / wall alongside, because below about 0.5 a wall-clock comparison is inadmissible whatever the effect size.

Three independent observations agree

CIFAR-100's CNN spends 96.3% of its step in the forward/backward/optimiser region rather than in batch loading and transfer. That is a statement about the data path, not about the GPU: attributing inside that region puts 34.3% of the CNN's step outside every submission window too. Submission overhead is not confined to small models.

Two GPUs, identical results

WorkloadDiscrete (36 CU)Integrated (6 CU)Test accuracy
MNIST MLP, 10 epochs, batch 642.18 s/epoch1.99 s/epoch 97.47% on both
CIFAR-100 CNN, 10 epochs17.48 s/epoch60.45 s/epoch 28.90% on both

The accuracies are identical to the last digit on two different GPUs. That is the determinism contract holding across hardware, not a coincidence.

The timings show the same split as the batch scaling: on the compute-bound CNN the 6-CU part is 3.5× slower, as its compute-unit count predicts; on the MLP at batch 64 it ties the discrete card.

That tie was read as a framework problem for a long time, and it is a property of the batch size. One epoch of the MLP, after the scheduling work above:

BatchDiscrete (36 CU)Integrated (6 CU) Separation
642.18 s1.99 stied — integrated faster
1281.12 s1.20 stied
2560.62 s0.67 stied
5120.43 s0.63 s1.47×
10240.25 s0.43 s1.72×

Above batch 256 the two separate cleanly and in the right direction, and the gap grows with the batch. A 784→128→10 MLP at batch 64 is 0.61 ms of arithmetic — small enough that a fixed per-step host cost dominates it no matter how small that cost gets. Every size above halved from this section's work; the tie at 64 did not move, and could not.

Against PyTorch, on accuracy

WorkloadvkMLPyTorchDifference
MNIST MLP, GPU97.47%97.50%−0.03 pp
MNIST MLP, CPU backend97.75%97.50%+0.25 pp
CIFAR-100 CNN28.90%30.10%−1.20 pp

The CPU backend is not for training

Measured: 4 CIFAR steps took 12.66 s of compute, about 3.17 s/step. The full set is 782 steps per epoch, so one epoch is roughly 41 minutes and ten are about 6.9 hours. It did not complete a single epoch on even 2,000 examples within a 550-second budget.

That is the cost of the backend being a deliberately naive correctness oracle. Use it to check answers, not to get them.

What is not measured

There is no per-kernel attribution today. vulkan_last_profile returns submission-level ('submit', ms) pairs, so none of the evidence above identifies which kernel dominates a step. Everything here is indirect — batch scaling, device substitution, submission counting. That is enough to locate the problem and not enough to close it.

Timestamps are supported by the device; what is missing is recording them around each dispatch and aggregating by kernel name. Until that exists, treat any claim about a specific kernel's share of a step as unproven.

If you benchmark this yourself

vkML — Vulkan-first machine learning in C++20. Apache-2.0. Signatures on this page are generated from the installed module.