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.
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
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.
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).
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.
Learn — eleven lessons
Tensors → autograd → modules → training → convolution → saving. Every code block executes in the page, and you can edit it first.
◆Tutorials — ten projects
One problem taken to the end: a classifier, the training loop rebuilt from raw tensors, CIFAR-10, adversarial examples, a character RNN, the FFT, attention, an autoencoder, least squares.
◆API reference
Every exported name, extracted from the compiler's own declarations — signatures, kinds, and the torch name each answers to. Searchable.
◆Playground
A full editor in JavaScript or Python, with the loss curve, the dispatch count and the GPU memory a run held.
◆Models — the hub
Pretrained weights that load straight into a tab. The page downloads one and checks it against the sample its publisher froze, in front of you.
◆Vision — torchvision's seat
Transforms, v2, the geometry of boxes, masks and keypoints, and the dataset decoders — with the way each of them goes quietly wrong.
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.
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.
| Piece | State | What it is today |
|---|---|---|
| Tensor | here | Shape, dtype, device, broadcasting, indexing and views, shared storage |
| WebGPU backend | here | Hand-written WGSL kernels. No fallback |
| Autograd | here | Reverse-mode. Tested for values and separately for "does the gradient flow" |
| nn.Module | here | Linear, Conv1d/2d/3d, normalization, pooling, RNN/LSTM/GRU cells, many losses |
| Optimizer | here | SGD, Adam, RMSprop and more, with LR schedulers |
| Playground | here | 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/O | here | 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 wasm | here | 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.
| Here | There | What it holds |
|---|---|---|
| borch | torch |
Tensors, autograd, modules, optimisers — the runtime everything else stands on |
| borchvision | torchvision |
Transforms, v2, box and mask geometry, dataset decoders — 663 names |
| bimm | timm |
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.ts | borch-webgpu | |
|---|---|---|---|
| ms / step | 154.9 | 118.5 | 123.4 |
| epoch | 2.02 min | 1.55 min | 1.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 page | batch 16 | batch 32 | batch 64 |
|---|---|---|---|
apple / metal-3 — borch.ts | 38.6 ms/step | 63.1 | 119.1 |
apple / metal-3 — TF.js 4.22.0 | 86.4 | 170.0 | 347.5 |
| ratio | 2.2× | 2.7× | 2.9× |
nvidia / lovelace (RTX 4090, Chrome 143) — borch.ts | 28.1 | 38.7 | 69.7 |
nvidia / lovelace — TF.js 4.22.0 | 67.8 | 112.2 | 205.8 |
| ratio | 2.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) forward | adapter | batch 1 | batch 16 |
|---|---|---|---|
borch.ts, eval() | apple / metal-3 | 4.1 ms | 10.6 ms |
borch.ts, eval() + fuse_conv_bn_eval + nn.intrinsic | apple / metal-3 | 2.9 ms | 7.8 ms |
| ONNX Runtime Web 1.29.0 | apple / metal-3 | 4.4 ms | 5.3 ms |
| ORT is faster than the fused network by | 0.7× | 1.5× | |
borch.ts, eval() | nvidia / lovelace (RTX 4090, Linux) | 3.6 ms | 8.1 ms |
borch.ts, eval() + fuse_conv_bn_eval + nn.intrinsic | nvidia / lovelace | 2.8 ms | 4.6 ms |
| ONNX Runtime Web 1.29.0 | nvidia / lovelace | 3.4 ms | 3.5 ms |
| ORT is faster than the fused network by | 0.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
| Tier | Today |
|---|---|
T1 values and gradients (allclose 1e-5) | 100% — 132 generated cases |
| T2 error equivalence | 12/12 — exception types · searchable messages 9/9 |
T3 repr equivalence | 15/15 |
| dtype promotion | 112/112 |
| shared storage (view · slice) | 13/13 |
| common API names | 144/144 |
| T4 bit equivalence | explicit 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 of | Runs | Ceiling | |
|---|---|---|---|
pyborch — import borch | numpy | anywhere · Pyodide | MNIST class |
| borch-ts (npm) | WGSL directly, zero deps | browser only | CIFAR ResNet-18, 1.5 min/epoch |
| bimm-ts (npm) | borch-ts | browser only | the architecture catalog — a model from a name |
| borch-hub (npm) | borch-ts, bimm-ts | browser only | a published model, hashed and checked before it loads |
| borch-webgpu (Python) | borch.ts above | browser only | the 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.