borch

PyTorch's shape,
in a browser tab.

Your torch code, running in this tab — no install, no server, no account. Underneath: a TypeScript runtime on WebGPU with hand-written WGSL, and the values are held to real PyTorch's, case by case.

hero.py — a training loop, running here checking device…


        

Pressing Run sends no network request. The computation happens on your GPU and the values stay here.

Check your GPU → Build something → Lesson 0: fix the bug → npm install borch-ts

Zero runtime dependencies · 435KB gzipped ES module (1567KB raw) · safetensors checkpoints

Why borch

What disappears when the browser is the runtime

Python ML charges you before you start — virtualenvs, version conflicts, CUDA and cuDNN, and one more server in front if you want it on the web. borch deletes that line.

No Python environment

A browser and TypeScript are the whole setup. Nothing to install means no versions to collide.

Local-first

Data and computation stay in the user's tab. Nothing is uploaded, so nothing needs permission to be uploaded.

WebGPU-native

The kernels are hand-written WGSL. No TF.js, no other runtime in between — zero runtime dependencies.

PyTorch-shaped

Tensor · Autograd · nn.Module · Optimizer. No invented concepts. The five places it differs are listed below.

Documentation that runs

Every example here actually executes. Examples that are never run rot, and the first user is who finds out.

A URL is the deployment

Share a playground link and the person who opens it runs the same code on their own GPU, with no install.

How it works

Four hops become two

Server Browser HTTP Python API PyTorch GPU
borch Browser borch WebGPU GPU

Inside

TypeScript → Tensor/Autograd → operator runtime → hand-written WGSL shaders → the browser's GPU. Nothing else sits in the path, so when something is slow, it is our shader.

When WebGPU is missing

No WebGPU, no run. `init()` stops rather than reaching for another API — the TF.js version that used to live here dropped quietly to WebGL, and its numbers were read as a GPU's for a while. A software WebGPU adapter is a different thing: same API, same kernels, and the values are right — the golden passes on it. Only the clock is the CPU's, and the badge names whichever one you were given. What your platform asks for →

What your platform asks for before this will run — nothing on macOS, iOS or Android, two Chrome switches on Linux with an NVIDIA card, and two cells still marked not measured. The page that carries the measurements, and what to do in each case, is here.

Five places it differs from torch — you meet all of them in the first ten lines.
await init() comes first (acquiring the adapter is async) · reading a value is await t.item() (forward and backward are synchronous) · a step is wrapped in scope() (JS garbage collection does not release GPU memory in time) · anything that must survive is marked keepAlive() · you call a model with model.call(x) (JS cannot call an object).
And five places the values part from torch — each pinned by a check, so changing one goes red rather than drifting.
nn.Parameter(t) copies rather than sharing storage (there are no views here; the Python binding made the same choice so the two GPU sides do not part from each other) · on borch.ts, requiresGrad alone makes a tensor a parameter (torch counts only what is wrapped) · get_num_threads() answers 1 (every op runs on the calling thread; torch answers the machine's core count) · there is no float64 or complex128, and int32, int16, int8, uint8, float16, bfloat16 are names only — kept so a typo and an absence read differently, gathered into int64 and float32 · conj() flips the values at once, so is_conj() is never true (torch's is lazy).
Pinned in borch-ts/test/parity.ts ("here we part from torch") and tests/test_subset_claims.py. The golden holds only what agrees with torch; a divergence written in a comment alone is one the next person changes without reading it.

Learn · Tutorials · Models · Vision · API · Playground

Six ways in, all of them running

Read a lesson and press Run on the page you are reading. Look a name up and see the signature the compiler emitted. Or open the full editor with a loss curve and the GPU counters. Nothing to install for any of them.

All four are the same runtime. A lesson block, a tutorial block and the playground editor run identical code — the difference is how much of the page is prose and how much is yours.

Start the lessons → Or build something Check your GPU

Same kernels, Python surface

Run textbook code in the browser by changing one import

The playground has a Python mode. borch_webgpu on Pyodide calls the same WGSL kernels, so the losses match the JavaScript examples digit for digit. Pyodide and numpy are served from this repository — nothing leaves the page.

How it reads

import borch_webgpu as torch

model = torch.nn.Linear(1, 1)
opt = torch.optim.SGD(model.parameters(), lr=0.1)
crit = torch.nn.MSELoss()

for step in range(201):
    with torch.scope():          # the one line the browser adds
        opt.zero_grad()
        loss = crit(model(x), y)
        loss.backward()
        opt.step()

Why there is no await

WebGPU has no synchronous read, yet loss.item() simply returns a value — Pyodide's run_sync (on JSPI) fills that gap. One measured condition applies: the page must enter Python asynchronously. So the async parts of await init(), .call() and reading values all disappear, and the only difference left is scope().

You can also see what happens without that line: in the same training run, held GPU memory goes from 1.0MB to 228.9MB (measured on this machine).

The first run spends a few seconds loading Pyodide; after that it starts immediately. The Python page covers the surface in full — how it forwards names, what loading it costs, and where it stops. The playground carries five Python examples — tensors, autograd, linear regression, an MLP and a CNN — and lesson 6 runs the same training loop in both languages.

What exists · what does not

The long refusal list is the point

The goal is not "reproduce PyTorch" but equivalence within the range a curriculum uses. The table below is the current state, not a plan — what is missing is written as missing.

PieceStateWhat it is today
Tensorhere Shape, dtype, device, broadcasting, indexing and views, shared storage
WebGPU backendhere Hand-written WGSL kernels. No fallback
Autogradhere Reverse-mode. Tested for values and separately for "does the gradient flow"
nn.Modulehere Linear, Conv1d/2d/3d, normalization, pooling, RNN/LSTM/GRU cells, many losses
Optimizerhere SGD, Adam, RMSprop and more, with LR schedulers
Playgroundhere This site. Code, run, output, loss curve, GPU instrumentation
Vision (borchvision · borch.vision)here transforms, v2, functional, ops and 15 datasets — every value compared against real torchvision
Model I/Ohere safetensors — the Python borch, numpy and HF tools read the same file
Model catalogue (bimm)partial createModel(library, name, args) builds. On npm as bimm-ts; the catalogue is five families deep, not timm's hundreds
Learn (interactive tutorials)here Eleven lessons and ten tutorials, every code block running in the page — tensors through ResNet and a Vision Transformer
Hub (borch-hub)partial Manifest, hash check, environment report. Weights do load in a browser — measured, access-control-allow-origin: * on a 44.7MB safetensors. On npm as borch-hub, and this site reads the catalogue, loads a model and verifies it — the count is not written here because it moves
Without WebGPU — Python mode on wasmhere Pyodide is wasm and the core is numpy, so import borch as torch trains with no adapter — measured in a browser with navigator.gpu removed: loss 17.2945 → 0.000001. What is missing then is borch_webgpu, and it says so by not being importable
CUDA · distributed · mixed precision · torch.compile never Things that cannot exist in a browser, or that you must leave the browser to learn

Four packages, one runtime

It is not one library any more

Each has a seat you already know, except the last one, which has no equivalent on the Python side.

HereThereWhat it holds
borchtorch Tensors, autograd, modules, optimisers — the runtime everything else stands on
borchvisiontorchvision Transforms, v2, box and mask geometry, dataset decoders — 663 names
bimmtimm The architecture catalog — ResNet, EfficientNet, MobileNet, ViT
borch-hub A published manifest, the hash checked before loading, the environment checked before downloading

The last row is the one with no counterpart. torch.hub fetches from repositories and keeps no catalog; timm has the catalog and does not publish manifests. Here a model arrives with a sample it has to reproduce, and your browser checks that before it says the model loaded.

Download a model and verify it →

It costs about ten megabytes and finishes in a couple of seconds. Nothing is installed.

Only what was measured

How fast · how correct

CIFAR ResNet-18, batch 64 — same machine, same bench

TF.js version (removed)borch.tsborch-webgpu
ms / step154.9118.5123.4
epoch2.02 min1.55 min1.61 min
test accuracy (10 epochs, augmentation on)60.4%64.6%not measured

Measured again — 2026-09-03, same page, adapter named

CIFAR ResNet-18, same pagebatch 16batch 32batch 64
apple / metal-3 — borch.ts38.6 ms/step63.1119.1
apple / metal-3 — TF.js 4.22.086.4170.0347.5
ratio2.2×2.7×2.9×
nvidia / lovelace (RTX 4090, Chrome 143) — borch.ts28.138.769.7
nvidia / lovelace — TF.js 4.22.067.8112.2205.8
ratio2.4×2.9×3.0×

borch-ts/test/compare.ts trains the same step in TF.js 4.22.0 (its own layers API and NHWC layout, WebGPU backend, bytes pinned by tests/browser/assets.lock) right after borch.ts on one page. Held equal: architecture, SGD 0.05/0.9, cross-entropy, the seeded batch, two warm-up steps then five timed, a loss readback every step. npm run compare:ts reproduces it.

The half this library loses — inference, same page

ResNet-18 (CIFAR) forwardadapterbatch 1batch 16
borch.ts, eval()apple / metal-34.1 ms10.6 ms
borch.ts, eval() + fuse_conv_bn_eval + nn.intrinsicapple / metal-32.9 ms7.8 ms
ONNX Runtime Web 1.29.0apple / metal-34.4 ms5.3 ms
ORT is faster than the fused network by0.7×1.5×
borch.ts, eval()nvidia / lovelace (RTX 4090, Linux)3.6 ms8.1 ms
borch.ts, eval() + fuse_conv_bn_eval + nn.intrinsicnvidia / lovelace2.8 ms4.6 ms
ONNX Runtime Web 1.29.0nvidia / lovelace3.4 ms3.5 ms
ORT is faster than the fused network by0.8×1.3×

Same weights in both (one ResNet-18 exported from torch as safetensors and as ONNX by tests/browser/export_resnet18.py), WebGPU execution provider, and the table is printed only after both runtimes reproduce torch's logits to 1e-3 — measured Apple 7.5e-8 / 6.7e-8, NVIDIA 7.5e-8 / 4.5e-8. Forward pass, mean of twenty after three warm-ups, readback included. The eval-mode batch norm is one kernel now, and nn.utils.fusion.fuse_conv_bn_eval (torch's own name) folds each norm into the convolution before it — 176 → 56 dispatches a forward; before those two the table read Apple 9.9 / 18.8 ms and 4090 6.7 / 14.5 ms. The third was the convolution kernel — its grid, not its arithmetic: 512 → 512 channels on a 4 × 4 plane is 32 workgroups at batch 16 and 8 at batch 1 on a card with 128 SMs (about 1 % of peak), against a reduction 4,608 long. The forward now splits that reduction as the weight gradient already did (convForwardSplit), and the layer's GPU time went from 2.8 to 0.3 ms at batch 1. The fourth was the calls themselves — 64 dispatches for 3.0 ms of GPU work in the 4090's 5.6 ms — so the relu and the residual add now ride in the convolution's epilogue, torch's torch.ao.nn.intrinsic names (ConvReLU2d, ConvAddReLU2d, here nn.intrinsic): 39 dispatches, 5.6 → 4.6 ms. At batch 1 the fused network is ahead of ORT on both adapters; at batch 16 it is within 1.3–1.5×, and what is left is the early layers, where a 64-channel 32 × 32 convolution reads more than it multiplies. And the file leaves as ONNX: onnx.exportOnnx(model, sample) (torch.onnx.export in the Python binding) traces one forward and writes the file ORT Web runs — checked at 3.5e-8 against our own forward, at a batch it was not traced at too. On this page it is the fused network's own export that ORT runs: 4.5e-8 from torch's logits on Apple and 5.2e-8 on the 4090, at 3.1 / 5.3 ms and 3.2 / 3.4 ms — the same speed as the file torch exported. For inference alone, use ORT Web; what this library has that it does not is the training step above and torch's own shape of code.

One machine, and it is not written down here. The three columns can be compared with each other because they were timed against each other on the same box — they cannot be compared with anyone else's, because nothing on this page says which box it was. Correctness is measured on two vendors now; speed is one machine, so until this line can name it, read the columns as a ratio rather than a speed.

The two right-hand columns are the same kernels — the 4.9ms gap is the cost of passing through Python (Pyodide) once. The hand-written WGSL beat the TF.js version on the same bench and had no rank limit, which is why that version is gone.

Checked against real PyTorch

TierToday
T1 values and gradients (allclose 1e-5)100% — 132 generated cases
T2 error equivalence12/12 — exception types · searchable messages 9/9
T3 repr equivalence15/15
dtype promotion112/112
shared storage (view · slice)13/13
common API names144/144
T4 bit equivalenceexplicit non-goal

On top of that, 4744 golden cases hold all three implementations to the same expected values. Real torch cannot run in a browser, so the expectations are frozen natively and carried in. Golden matches on Apple Metal — agreeing 4733 / 4733 [apple / metal-3] — and on NVIDIA too: 3880 / 3880 [nvidia / blackwell] on an RTX 5080, in a real desktop session rather than a virtual framebuffer, measured on 2026-08-30 when the table held 3880 through the binding. The training agrees as well: thirty steps of a small ResNet, its per-layer gradients, its batch-norm buffers and its eval, against torch — 64 values, once through the Python binding and once through borch.ts's own API. The run that was read as a second vendor for months was not one. It passed 845 of 845 on a Linux GPU server whose adapter reported google / swiftshader — the CPU. The values were right and the vendor claim was not, which is why the runners now judge the adapter instead of printing it.

Five packages, and three of them share this name

Pick by where you intend to run

On top ofRunsCeiling
pyborchimport borchnumpyanywhere · PyodideMNIST class
borch-ts (npm)WGSL directly, zero depsbrowser onlyCIFAR ResNet-18, 1.5 min/epoch
bimm-ts (npm)borch-ts browser onlythe architecture catalog — a model from a name
borch-hub (npm)borch-ts, bimm-ts browser onlya published model, hashed and checked before it loads
borch-webgpu (Python)borch.ts abovebrowser onlythe same at 1.6 min/epoch

The first row said borch (PyPI) until it was checked. The distribution is named pyborch; borch is what you import. And it is not on PyPI — borch there is somebody else's package, a probabilistic-programming library, so pip install borch fetches a stranger's code. The wheel below is the install.

The npm row said borch too, and that package did not exist — the registry refused the name for being too close to borsh, batch and touch. It is published as borch-ts, which is what this repository has called it all along.

TypeScript — what this page uses

npm install borch-ts

import { init, Tensor, nn, optim, scope, keepAlive } from "borch-ts";
await init();

Python

uv add ./pyborch-1.4.0-py3-none-any.whl

import borch as torch          # changing the import is the whole change
import borch_webgpu as torch   # on the GPU, inside Pyodide

Build AI where your users already are — in the browser.

No install, no server, no account. One tab is enough.