borch

Python

The Python route

borch_webgpu is borch.ts with a Python surface on it. Textbook training code runs in the tab, on the GPU, with nothing installed and nothing sent anywhere.

It is worth being precise about why this page exists. borch is shaped like PyTorch, and the people that shape is for read Python. But the JavaScript surface is the one that carries the awkward parts — await on every read, model.call(x) instead of calling the model. On the Python side most of that is gone, so this is the surface where the resemblance is strongest. This page is its reference: how the forwarding works, what it costs, where it stops. To learn with it, every block in Learn and Tutorials has a Python tab; to work in it, the Notebook and the Workbench are Python end to end.

It forwards, it does not reimplement

There is no list of supported operations. The module's __getattr__ takes the name you asked for, converts snake_case to camelCase, and calls that method on the tensor. If borch.ts does not have it, you get an AttributeError naming both spellings — it does not approximate the answer with numpy.

That is a design decision with a consequence worth knowing: the Python surface tracks borch.ts automatically, so it cannot fall behind by forgetting to add a name. What it can do is quietly build something on top, and the repository has a test that counts those places rather than trusting anyone to notice.

The missing: line above names both spellings. The opening clause is torch's own wording, so a check written against torch still matches; the hint after it says what borch.ts would have to be given. This page used to warn that errors arrived in Korean — they did, at 133 of 168 throw sites. That is over: the library's messages are English, and a test refuses a Korean one.

What torch code has to change

Five things separate borch from torch. Three of them are artefacts of JavaScript, and on this surface they are simply not there.

torchborch.tsborch_webgpu
await init() nothing
t.item() await t.item() t.item()
model(x) model.call(x) model(x)
scope(async () => …) with torch.scope():
keepAlive(t) torch.keep_alive(t)

scope() is the one that stays, and it has to: a training step makes thousands of intermediate buffers, and neither language's garbage collector returns GPU memory in time. In the same 201-step run, dropping the with takes held memory from 1.0MB to 228.9MB.

Why there is no await

WebGPU has no synchronous read. Getting a number off the GPU is a promise, and in JavaScript that promise reaches all the way up to your code as await t.item(). Pyodide's run_sync, on top of WebAssembly's JavaScript Promise Integration, lets the Python frame wait without the caller knowing, so loss.item() is a plain expression again.

One condition applies, and it is the reason this works on this site: the page has to enter Python asynchronously. That is a property of how the page starts Pyodide, not something your code does.

What it costs

Measured on this machine, over localhost, with the browser cache cold: the first Python run pulled 25.1MB across six files — Pyodide, the Python standard library, the WebAssembly build and a numpy wheel — and took 1,066ms end to end. The second run took 2ms. The 25.1MB is the part that will differ on a real connection; the rest is the shape you should expect.

All of it is served from the same origin as the page. Nothing is fetched from a CDN and nothing is uploaded, which is the same promise the JavaScript side makes.

Where it stops

Three honest edges, all of them checked while writing this page.

A tensor leaves a scope only if you say so. Made inside with torch.scope(): and left unmarked, it is released at the end of the block and using it afterwards raises. There are two marks, and they are not the same one: torch.keep_alive(t) means no scope ever releases it — that is what parameters and optimizer state want — while with torch.scope() as s: s.keep(t) hands it to the enclosing scope, so it goes when that one closes. Use the first on an intermediate value and it accumulates every step.

This page said for a while that Python had neither, which was true when it was written and was measured. It is worth saying what changed rather than quietly editing the sentence: the binding had the scope but not the doors, and the error it raised claimed keepAlive was absent from borch.ts, which was false — the check asked whether the name is a tensor method while the message spoke about borch.ts as a whole. Both are fixed, and the message now says which of the two it means.

torch.cuda.is_available() answers False, on purpose. borch uses the GPU but it is not CUDA, and textbook code branches on that name constantly. The buffer pool is emptied with torch.empty_cache() instead.

Two different memory questions. torch.memory() answers "is this leaking" and deliberately excludes the pool; torch.pooled() answers "how much is held". A benchmark reading only the first once reported 269.7MB while the pool held 1,699.6MB.

See it match

The claim underneath all of this is that the two surfaces reach the same kernels. Run the block on javascript, then press python and run it again.

The same loop, in the training lesson. Lesson 4 carries the 201-step linear regression in both languages for the same reason — the losses agree to the last digit because there is only one set of kernels underneath.