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, error messages and printed form 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.

Start learning → Build something → Check your GPU → npm install borch-ts

Zero runtime dependencies · 214KB gzipped ES module (754KB raw) · safetensors checkpoints

Embeddable · no server

Drop a live model into any page — one line

Everything you just ran can live on your blog, your docs, your course — an iframe, no server, no sign-up. It trains in the reader's browser, on their GPU. Like a Gradio demo, minus the server.

Attention, live Copy embed code
A loss curve falling Copy embed code

Press Run inside a frame above — it trains right there. The Copy button hands you the one line to paste; change nothing. A widget on a browser without WebGPU says so in a sentence rather than breaking. See all widgets →

Start here

Two doors in — by where you're starting from

New to machine learning, or arriving with PyTorch already in hand? Take the door that fits. Both run entirely in this tab, with nothing to install.

Or browse everything — six ways in, every one of them running in the page:

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

Try it — no code to start

Teach a model in your tab

Give a few photos or sounds, press Train, and a classifier learns on your GPU — then edit the real torch code it trained, or export it as ONNX. Nothing is uploaded.

Open TM++ →

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.

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
ONNX exporthere exportOnnx(model, sample) traces one forward and writes the file with no dependency — a CNN, or a whole transformer encoder layer (layer_norm, attention's batched matmuls, softmax); ONNX Runtime Web reproduces the forward (max |Δ| 2.4e-7)
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

Measured 2026-09-22 with 0.6.0 as published: the training step is 4.5–10× faster than TF.js, 2.8–7.5× than jax-js and 14–18× than Burn — every library that trains in a browser on WebGPU — and the captured inference forward is ahead of ONNX Runtime Web on three adapters, for a ResNet and a ViT. The whole comparison, same page and same GPU with the adapter beside every number and the losing rows kept, is on the Compare page. Below: the earlier tables, kept as they were measured, and the matmul underneath within a whisker of torch on the same Metal GPU. The parity and golden runs are below in full; the speed tables — machine by machine, down to the dispatch — are folded so they do not wall the page.

Speed — the full tables, machine by machine

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 matmul underneath — measured against torch itself

The tables above are against another browser runtime. Against torch, on the same Mac GPU and at one shape (2048³), the subgroup-matrix GEMM measured 11.0 TFLOP/s to torch-on-Metal's 10.8 — on an aligned matrix the kernel is not paying to be in a browser. A compiled transformer step (a two-block GPT, sequence 128, width 256, batch 16) is 7.2 ms, its matmul near that shape's roofline. The bounds are stated so the number is not misread: this is torch's Metal backend, not its faster CUDA one, and one shape on one machine — and a sequence length that is not a multiple of eight drops attention's batched matmul to a slower tile (measured, 18% of a ViT-tiny step). Instrument: tests/browser/profile_py.py; the derivation is in ROADMAP.md, "Speed — measured".

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, 4764 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 4753 / 4753 [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
pyborch — import 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.