Polygrad JavaScript
Polygrad JavaScript
JavaScript bindings for Polygrad, a C11 tensor compiler with Node, WASM, and browser runtimes.
The package uses the same C compiler core as Polygrad Python. In Node it tries the native addon first and falls back to packaged WASM. In browsers it runs the C runtime through WASM, with optional WebGPU execution.
Use this package when JavaScript owns the application flow but tensor execution should still come from the shared Polygrad compiler/runtime: Node services, browser ML tools, WebGPU demos, or packages that accept a caller-provided Polygrad runtime.
Install
npm install polygrad
Node tries to build the native addon during install. If that fails, runtime creation can still use the WASM core. To skip native compilation:
POLYGRAD_SKIP_NATIVE=1 npm install polygrad
From this repository:
cd js
npm install
node test/test_wasm.js
Quick Start
const { Tensor } = require('polygrad')
const a = Tensor.rand(3, 4)
const b = Tensor.rand(4, 5)
const c = a.dot(b).softmax(-1)
console.log(c.toArray())
Autograd:
const { Tensor } = require('polygrad')
const x = new Tensor([1.0, 2.0, 3.0], { requiresGrad: true })
const loss = x.mul(x).sum()
loss.backward()
console.log(x.grad.toArray()) // [2, 4, 6]
Explicit runtime:
const polygrad = require('polygrad')
const pg = polygrad.create({ core: 'wasm' })
const y = new pg.Tensor([1, 2, 3]).mul(2).add(1)
console.log(y.toArray())
pg.dispose()
Linear algebra:
const { Tensor } = require('polygrad')
const A = new Tensor([[4.0, 2.0], [2.0, 5.0]])
const b = new Tensor([1.0, 3.0])
const x = A.solve(b)
console.log(x.toArray())
Structured linalg methods are portable tensor-composed fallbacks. Current
lstsq is solution-only for full-rank tall or square systems.
Choose A Runtime
The default JavaScript API is sync-first. polygrad.create(...) returns a
PolyRuntime immediately or throws; tensor construction and graph construction
are synchronous too.
Use explicit async startup when the host cannot or should not instantiate the
WASM core synchronously, for example older browser limits or a fetch/streaming
loader path. The async entry is not a drop-in default Tensor import because
startup itself must be awaited:
const { createAsync } = require('polygrad/async')
const pg = await createAsync({ core: 'wasm' })
Runtime matrix:
| Work | Sync API | Async API |
|---|---|---|
| Create normal Node/browser runtime | polygrad.create(opts) |
not needed |
| Create through async WASM loader | throws with guidance | createAsync(opts) from polygrad/async |
| Construct tensors and graph ops | new Tensor(...), x.mul(2) |
not needed |
| CPU/native/WASM realize/readback | x.realize(), x.toArray() |
realizeAsync() / toArrayAsync() also work |
| WebGPU realize/readback | throws PolyAsyncRequired |
await x.realizeAsync(), await x.toArrayAsync() |
const polygrad = require('polygrad')
const pg = polygrad.create({ core: 'wasm' })
console.log(pg.core, pg.device)
pg.dispose()
Runtime selection:
| Environment | core: "auto" |
core: "native" |
core: "wasm" |
|---|---|---|---|
| Node with native addon | native | native | wasm |
| Node without native addon | wasm | error | wasm |
| Browser | wasm | error | wasm |
Options:
core:auto,native, orwasm.device:auto,cpu,cuda,hip,x86,interp,wasm, orwebgpu, depending on the selected core and local runtime support.
Environment variables:
POLY_CORE=wasm node app.js
POLY_DEVICE=cuda node app.js
For the native core, POLY_DEVICE is handled by the C runtime and may select
cpu, cuda, hip, x86, or interp. For the WASM core, device choices are
wasm, interp, and browser webgpu when available.
Call pg.dispose() when an application or long-running script is done with an
explicit runtime.
Browser
With an npm-installed package and a browser bundler:
import { Tensor } from 'polygrad'
const y = new Tensor([1, 2, 3]).mul(2).add(1)
console.log(y.toArray())
For WebGPU, create an explicit runtime and use async execution/readback:
import { create } from 'polygrad'
const pg = create({ core: 'wasm', device: 'webgpu' })
const y = new pg.Tensor([1, 2, 3]).mul(2)
console.log(await y.toArrayAsync())
pg.dispose()
Bundlers should resolve the bare polygrad import to the browser bundle
through the package browser export condition. Node require('polygrad')
still resolves to the Node entry.
If a bundler or CDN resolver picks the Node entry by mistake, force the browser entry explicitly:
import { create } from 'polygrad/browser'
For a local checkout or manual browser bundle, build browser artifacts from the repository root:
make wasm-pkg
cd js
npm run build:browser
Outputs:
dist/polygrad.sync.jsfor a sync browser global.dist/polygrad.sync.mjsfor sync browser ESM.dist/polygrad.async.jsfor an explicit async-startup browser global.dist/polygrad.async.mjsfor explicit async-startup browser ESM.
Browser global:
<script src="./dist/polygrad.sync.js"></script>
<script>
const y = new polygrad.Tensor([1, 2, 3]).mul(2)
console.log(y.toArray())
polygrad.disposeDefault()
</script>
Local browser ESM with WebGPU:
<script type="module">
import { create } from './dist/polygrad.sync.mjs'
const pg = create({ core: 'wasm', device: 'webgpu' })
const y = new pg.Tensor([1, 2, 3]).mul(2)
console.log(await y.toArrayAsync())
pg.dispose()
</script>
Explicit async startup bundle:
<script type="module">
import { createAsync } from './dist/polygrad.async.mjs'
const pg = await createAsync({ core: 'wasm' })
const y = new pg.Tensor([1, 2, 3]).mul(2)
console.log(y.toArray())
pg.dispose()
</script>
Data Flow
Polygrad tensors are lazy. Build expressions freely, then call realize() or
read data back.
const { Tensor } = require('polygrad')
const x = new Tensor(new Float32Array([1, 2, 3, 4]), { shape: [4] })
const y = x.mul(3).sub(1).realize()
console.log(y.toTypedArray()) // Float32Array
For repeated loops, reuse tensor buffers instead of constructing new source tensors:
const { Tensor } = require('polygrad')
const x = new Tensor(new Float32Array([1, 2, 3, 4]), { shape: [4] })
x.realize()
x.copyFrom(new Float32Array([5, 6, 7, 8]))
Use toTypedArrays() when reading several outputs together.
const { Tensor } = require('polygrad')
const x = new Tensor([1, 2, 3])
const a = x.add(1)
const b = x.mul(2)
const [aData, bData] = Tensor.toTypedArrays(a, b)
JIT And Compile
jit(fn) follows tinygrad raw Tensor JIT behavior: first call runs normally,
second call captures realized schedules, later calls replay.
const { Tensor, jit, compile } = require('polygrad')
const f = jit((x) => x.add(1).realize())
f(new Tensor([1, 2, 3])) // normal run
f(new Tensor([4, 5, 6])) // capture
console.log(f(new Tensor([7, 8, 9])).toArray()) // replay
compile(fn, sampleInputs) warms and captures immediately:
const compiled = compile(
(x) => x.add(1).realize(),
[new Tensor([1, 2, 3])]
)
const out = compiled.run([new Tensor([7, 8, 9])])
console.log(out.toArray())
console.log(compiled.stats())
compiled.dispose()
Custom Kernels
Tensor.customKernel(...) mirrors tinygrad’s alpha custom-kernel shape. The
kernel function receives placeholder UOps and returns a SINK body. Polygrad
wraps the body in CALL, returns AFTER(...) tensors, and keeps execution in
the normal schedule and runtime caches.
const { Tensor, uop } = require('polygrad')
function addKernel(out, a, b) {
out = out.flatten(); a = a.flatten(); b = b.flatten()
const i = uop.range(out.numel(), 0)
return out.index(i).store(a.index(i).add(b.index(i))).end(i).sink()
}
const out = Tensor.empty([4], { dtype: 'float32' })
const y = out.customKernel(
new Tensor([1, 2, 3, 4]),
new Tensor([10, 20, 30, 40]),
addKernel
)[0]
console.log(y.toArray())
This is a UOp CALL extension point, not a raw program-launch API. Custom
backward functions are not implemented yet.
Common API Recipes
Default runtime:
const { Tensor, jit, compile, nn } = require('polygrad')
const x = new Tensor([1, 2, 3])
console.log(x.mul(2).add(1).toArray())
Explicit runtime and device selection:
const polygrad = require('polygrad')
const pg = polygrad.create({ core: 'wasm' })
const x = new pg.Tensor([1, 2, 3])
console.log(x.mul(2).toArray())
pg.dispose()
Create tensors. Shape constructors accept either Tensor.zeros(2, 3) or Tensor.zeros([2, 3]):
const a = Tensor.zeros([2, 3])
const b = Tensor.ones([2, 3])
const c = Tensor.randn([2, 3])
const d = Tensor.arange(6).reshape(2, 3)
const e = new Tensor(new Float32Array([1, 2, 3, 4]), { shape: [2, 2] })
Math, movement, indexing:
const x = Tensor.arange(12).reshape(3, 4)
const y = x.permute(1, 0).reshape(2, 6)
const z = y.relu().sum(1)
const picked = x.gather(1, new Tensor([[0, 2], [1, 3], [0, 1]], { dtype: 'int32' }))
Autograd and optimization:
const model = new nn.Linear(4, 1)
const opt = new nn.SGD(nn.getParameters(model), { lr: 0.01 })
const x = Tensor.randn(8, 4)
const target = Tensor.randn(8, 1)
opt.zeroGrad()
const loss = model.call(x).sub(target).square().mean()
loss.backward()
opt.step()
Linear algebra:
const A = new Tensor([[4, 2], [2, 5]])
const b = new Tensor([1, 3])
console.log(A.solve(b).toArray())
console.log(A.cholesky().toArray())
console.log(A.lstsq(b).toArray())
JIT and compile:
const f = jit((x) => x.add(1).realize())
f(new Tensor([1, 2, 3])) // run
f(new Tensor([4, 5, 6])) // capture
console.log(f(new Tensor([7, 8, 9])).toArray()) // replay
const compiled = compile((x) => x.mul(2).realize(), [Tensor.empty(3)])
console.log(compiled.run([new Tensor([1, 2, 3])]).toArray())
compiled.dispose()
Repeated input updates:
const x = new Tensor(new Float32Array([1, 2, 3]), { shape: [3] }).realize()
const f = compile((x) => x.square().sum().realize(), [x])
console.log(f.run([x]).item())
x.copyFrom(new Float32Array([4, 5, 6]))
console.log(f.run([x]).item())
f.dispose()
Readback:
const y = new Tensor([1, 2, 3]).mul(2)
console.log(y.toArray()) // typed array on sync runtimes
console.log(y.tolist()) // nested JS arrays
console.log(y.item()) // scalar tensors only
const [a, b] = Tensor.toTypedArrays(y, y.add(1))
WebGPU readback is explicit async:
const polygrad = require('polygrad')
const pg = polygrad.create({ core: 'wasm', device: 'webgpu' })
const y = new pg.Tensor([1, 2, 3]).mul(2)
console.log(await y.toArrayAsync())
pg.dispose()
Runtime inspection:
const polygrad = require('polygrad')
console.log(polygrad.stats())
console.log(polygrad.canRun({ op: 'add', shape: [1024] }))
canRun(...) is conservative. For some compound op/shape queries it throws
when support cannot be proven statically.
API reference at a glance:
| Area | Main APIs |
|---|---|
| Runtime | create, createAsync, disposeDefault, stats, canRun |
| Tensor creation | new Tensor(data), zeros, ones, full, rand, randn, eye, arange, empty |
| Tensor math | add, sub, mul, div, exp, log, sqrt, relu, gelu, silu, softmax |
| Reductions | sum, mean, max, argmax, sort, argsort, topk, var, std |
| Movement/indexing | reshape, expand, permute, shrink, flip, pad, cat, stack, gather, takeAlongAxis, scatter, scatterReduce |
| Linalg | dot, qr, triangularSolve, solveTriangular, cholesky, choleskySolve, solve, lstsq |
| Data/readback | realize, realizeAsync, toArray, toArrayAsync, toTypedArray, toTypedArrayAsync, toTypedArrays, toTypedArraysAsync, copyFrom, updateFrom |
| Compilation | jit, jitAsync, compile, compileAsync, Tensor.customKernel |
| Neural nets | nn.Linear, nn.SGD, nn.Adam, nn.AdamW, nn.getParameters, nn.getStateDict |
Package Integration
If your package is built on Polygrad, accept a PolyRuntime from the caller
instead of creating a hidden runtime:
function createModel({ polygrad: pg }) {
const weight = pg.Tensor.randn([4, 2]).realize()
const predict = pg.compile((x) => x.dot(weight).realize(), [
pg.Tensor.empty([1, 4])
])
return {
predict: (x) => predict.run([x]),
dispose: () => predict.dispose()
}
}
This lets applications share one set of runtime caches, device handles, and buffer residency across packages.
Tests
From the repository root:
make test-js-native
TMPDIR=$PWD/temp/cc_tmp EM_CACHE=$PWD/temp/emscripten-cache make test-js-wasm
DISPLAY=:1 make test-browser
make test-browser-matrix
test-browser keeps the default Chromium/WebGPU path. test-browser-matrix
runs the browser sync bundle against a non-WebGPU smoke matrix and skips local
browser executables that are not installed. Override the matrix when you need a
specific browser or older Chrome binary:
BROWSER_MATRIX="chromium,firefox,old-chrome=chromium@/path/to/chrome" make test-browser-matrix
BROWSER_MATRIX_DEVICES="auto,interp" make test-browser-matrix
Browser specs are chromium, firefox, webkit, chrome, or
label=engine@/absolute/path. WebGPU coverage remains Chromium-only and lives
in make test-browser.
License
MIT