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.
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.
| torch | borch.ts | borch_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.