Training GNNs on WebGPU, and the Ways the Numbers Lie

Hand-written WGSL for GCN, GraphSAGE and GIN in the browser. The kernels were the easy part. The hard parts were a leakage trap that makes link prediction look nearly perfect, an oversmoothing metric that was wrong before the model was, and a test suite that had been skipping itself on every machine.
Author

Benedict Thekkel

Published

September 16, 2026

model_playground runs ML models in the browser, on the visitor’s GPU. It is live at playground.benedictthekkel.com. Most of it deploys pretrained models through Transformers.js and ONNX Runtime Web, and none of that is my kernel code. This post is about the part that is: the graph neural network pages, built between 2026-09-12 and 2026-09-16, where training runs on WGSL compute shaders I wrote by hand.


One shader for three architectures

GCN, GraphSAGE and GIN look different on paper. In the forward pass they are the same operation: for each node, a scaled sum of its own features and its neighbours’ features. They differ only in the scales.

So there is one aggregation shader, over the adjacency in CSR form, one invocation per node:

for (var f = 0u; f < dims.nFeat; f = f + 1u) {
  var acc = selfScale * x[base + f];
  for (var e = start; e < end; e = e + 1u) {
    let j = colIdx[e];
    acc = acc + beta[j] * x[j * dims.nFeat + f];
  }
  out[base + f] = outScale * acc;
}

GCN’s symmetric normalisation, SAGE’s mean and GIN’s sum are all choices of selfScale, beta and outScale. And because the self-looped adjacency is symmetric, the backward pass is the same dispatch with two scale vectors swapped. One shader, both directions, three models.

Around it, five more small shaders: a naive matmul in 16x16 workgroups with no tiling, elementwise ops, transpose, scale, and a point-cloud renderer used elsewhere. 275 lines of WGSL in all. GAT is the exception: its attention coefficients are learned per edge, so it runs on a CPU propagator.

The dataset ships as CSR too. Cora’s 2,708 nodes and 1,433 features are 15.5 MB as dense float32; as a sparse structure it is 161 KB.


How you know the kernels are right

Two checks, at different layers:

  • Kernel parity. The GPU aggregation runs against a CPU reference implementation, in a real browser, and the worst element-wise error must be under 1e-5. Dropping the self-loop as a deliberate fault sends it to 0.87, so the test can actually fail.
  • Finite-difference gradient checks on every hand-written backward pass: GCN, SAGE, GIN and GAT at one, two and three layers, with and without dropout. They are evaluated at a point with non-zero bias, so no pre-activation sits exactly on ReLU’s kink, where the numerical gradient is meaningless.

While wiring those up I found that every WebGPU end-to-end test in the repo had been silently skipping itself, on every machine. The suite probed for navigator.gpu before navigating, from about:blank. WebGPU is only exposed in a secure context, and about:blank in that harness was not one, so the probe always said “no GPU” and every spec politely skipped. Green CI, zero coverage. A skip is a result too, and it is worth asserting on how many tests actually ran.


Oversmoothing: the metric was wrong first

Stack enough GCN layers and every node’s representation converges toward the same vector. That is oversmoothing, and the page has a slider for depth so you can watch it happen.

The first measure I used was the obvious one: mean pairwise cosine similarity over all node pairs. If representations converge, that should climb with depth. It came out as

0.720, 0.643, 0.663, 0.579, 0.677

which is not a trend. The metric was answering the wrong question. Oversmoothing is a local phenomenon first: connected nodes become indistinguishable. Unrelated nodes on opposite sides of the graph can stay far apart for a long time while every neighbourhood collapses internally.

The replacement measures similarity between adjacent nodes, on row-normalised representations, which is the Dirichlet energy up to a constant. With it, the picture is monotonic and matches the accuracy:

GCN layers Test accuracy Neighbour similarity
2 0.776 0.947
4 0.687 0.958
6 0.496 0.973
8 0.518 0.978

Accuracy is itself not monotonic (8 layers edges 6), which is a reminder that one run per depth is noise-sensitive. The similarity column is the one that moves cleanly.

GIN at 8 layers is a different failure wearing the same symptom: 0.064 accuracy. That is not smoothing. GIN sums neighbours without normalising, the sums overflow, and ReLU zeroes everything downstream. Every representation is dead. So the page reports the dead fraction separately and names the failure when more than half the units are dead, instead of letting a single accuracy number blame the wrong cause.


Takeaway

None of the three stories is about WGSL. The kernels were checked by parity and gradient tests and were right. Every real problem was in what the numbers meant:

Number What was wrong
Pairwise cosine vs depth Measured a global property of a local failure
GIN accuracy at depth 8 Blamed smoothing for overflow and dead ReLUs
Link-prediction AUC Would reward leakage, so it is bounded above in the test
Green WebGPU test suite Nothing had run

The habit that catches all four is the same: before trusting a metric, ask what the wrong answer would look like, and make sure a test can tell the two apart.


Back to top