The CPU oracle
On this page

Rendered from AUTHORING.md at commit 26de7be8. The package is imported here by its 0.2.0 name, typeshade.

The CPU oracle

After this page you can run a module on the CPU in double precision and compare its numbers against what a GPU produced.

The CPU oracle is a third backend over the same IR the WGSL and GLSL writers read. It evaluates the module in JavaScript, with no device and no browser, and every function in the module becomes a callable you pass numbers to and read numbers back from. It answers what the shader should compute, which is the reference a GPU run is held against when a pixel comes out wrong. It runs the same validation the two GPU writers run, so a module it rejects is a module they would reject too.

Compiling a module for the CPU

compileModule(m) returns a CpuModule: a fns record keyed by function name, plus a setBinding method for the resources the module reads. Entry points and helpers are both in fns, under the names they were declared with. Values crossing the boundary are plain JavaScript. A scalar is a number or a boolean, a vector or a matrix is a flat number[], and a struct is an object keyed by field name.

import { module, fn, vec2fT, dot, sqrt, compileModule } from 'typeshade'
const len = fn('len', { p: vec2fT }, ({ p }) => sqrt(dot(p, p)))
const m = module({ funcs: [len] })
const cpu = compileModule(m)
cpu.fns.len([3, 4]) // → 5

compileModule walks the IR node by node on every call. compileModuleJs(m) takes the same arguments and returns the same shape, and it walks each function body once, emits JavaScript source for it and builds that source with new Function. Reach for it when the same module runs thousands of times, on a per-frame path or across a whole buffer of rows. A body it cannot generate falls back to the interpreter for that one function, so a module can be part compiled and part interpreted with no change at the call site. The one case a caller handles is a host that forbids eval, where new Function itself throws and compileModule is the way through.

Bindings and calls

A binding is a resource the pipeline supplies: a uniform block, a storage buffer, a texture, a sampler. The oracle has no GPU memory behind those, so you supply each value with setBinding(name, value) before the first call. The name is the one the declaration carries, which is the as name for a uniform struct and the declared name for a storage buffer or a resource. reflect(m).bindGroups lists every binding a host must fill, including the ones a lowering injects. The oracle asks only for the ones a function it runs actually reads, so a module with f64 arithmetic needs no value for the injected _fp64 guard texture that fp64 describes. A binding a function reads and nothing set throws typeshade/cpu: unbound <name>.

import { module, fn, uniformStruct, storageBuffer, f32T, u32T, compileModule } from 'typeshade'
const U = uniformStruct('U', { group: 0, binding: 0, as: 'u' }, { scale: f32T })
const data = storageBuffer('data', f32T, { group: 0, binding: 1, access: 'read' })
const scale_at = fn('scale_at', { i: u32T }, ({ i }) => data.at(i).mul(U.field.scale))
const m = module({ uses: [U, data], funcs: [scale_at] })
const cpu = compileModule(m)
cpu.setBinding('u', { scale: 2 }) // a uniform block: an object keyed by field name
cpu.setBinding('data', [1, 2, 3]) // a buffer: one flat array
cpu.fns.scale_at(2) // → 6

Arrays and structs are held by reference, so a read_write storage buffer is written in place: bind an array, run the calls, then read that same array back for the results. An f64 value is one JavaScript number on this side. The GPU carries it as a pair of f32 values, a high part and a low part, and splitF64(x) returns that pair for the host to pack into the buffer, so the two sides describe the same number in the shape each one needs.

The f64 and f32 precision modes

Both compile functions take a precision option, and it decides how f32 arithmetic is evaluated. Under 'f64', the default, every operation runs in JavaScript’s double precision with no rounding in between, so the result is the mathematically intended one to 53 significand bits. Use it to ask whether the module picked the right operations in the right order. It is blind by construction to an error that appears only once a value is squeezed into 32 bits.

Under 'f32', every f32-typed operation rounds to f32 afterwards, with an infinity on overflow, over the same module the GPU backends are given. Use it to ask whether the target computes this value, and a comparison against a GPU readback can then be an ulp-scale one instead of a tolerance wide enough to hide a real disagreement.

import { module, fn, f32T, compileModule } from 'typeshade'
const acc = fn('acc', { a: f32T, b: f32T }, ({ a, b }) => a.add(b))
const m = module({ funcs: [acc] })
compileModule(m).fns.acc(1, 2 ** -30) // → 1.0000000009313226
compileModule(m, { precision: 'f32' }).fns.acc(1, 2 ** -30) // → 1

The mode is separate from the emulated f64 type, which fp64 covers. It stays a full double here while the GPU runs it as a pair of f32 values carrying about 48 significand bits. The WGSL, GLSL and CPU results for such a module agree within that width.

A comparison then reads the buffer the GPU wrote and walks it against the same module on the CPU, one row at a time.

import { compileModuleJs } from 'typeshade'
// m: the module from the bindings sample above.
const cpu = compileModuleJs(m, { precision: 'f32' })
cpu.setBinding('u', { scale: 2 })
cpu.setBinding('data', Array.from(rows)) // rows: the input the GPU run was given
// gpuOut: the Float32Array read back from that GPU run.
for (let i = 0; i < gpuOut.length; i++) {
const expected = cpu.fns.scale_at(i) as number
if (Math.abs(gpuOut[i] - expected) > 1e-6) {
throw new Error(`row ${i}: GPU ${gpuOut[i]}, CPU ${expected}`)
}
}

Calls with no CPU meaning

Three things throw when a call reaches them. A rawStmt payload is target text the IR never reads, so it has no evaluation here whichever spelling it carries. A placeholder that no composer swapped throws with its tag, which localizes the missing splice. The GPU-only intrinsics have nothing to compute from: the texture reads textureSample, textureSampleLevel, textureLoad and their array, bias, grad, gather and depth-comparison forms, textureStore, the queries textureDimensions and textureNumLayers, and the derivatives dpdx, dpdy and fwidth with their coarse and fine forms. The full set is exported as ORACLE_GPU_STUB_NAMES.

Compiling with { gpuStubs: true } turns those intrinsics into placeholder values: opaque black for a texture read, zero for a derivative, a 1 by 1 size for textureDimensions and one layer for textureNumLayers. The throw names the intrinsic and that option. Pass it when a stand-in value is acceptable for the question being asked, such as checking the geometry a fragment function computes around a sample it does not depend on. The binding still has to be set, because the module reads the texture and sampler variables before the call is stubbed.

import {
module,
fn,
resource,
texture2dfT,
samplerT,
vec2fT,
textureSample,
compileModule,
} from 'typeshade'
const tex = resource('tex', texture2dfT, { group: 0, binding: 0 })
const smp = resource('smp', samplerT, { group: 0, binding: 1 })
const shade = fn('shade', { uv: vec2fT }, ({ uv }) => textureSample(tex.node, smp.node, uv), {
stage: 'fragment',
})
const cpu = compileModule(module({ uses: [tex, smp], funcs: [shade] }), { gpuStubs: true })
cpu.setBinding('tex', 0) // the value is never read under a stub
cpu.setBinding('smp', 0)
cpu.fns.shade([0.5, 0.5]) // → [0, 0, 0, 1]

Derivatives with grad

grad(m, fn, param) differentiates a function of the module with respect to one of its parameters and returns { module, name }: the module with a new function added, which takes the same arguments as fn and returns d fn / d param at them, with the type of fn’s result. The new function is ordinary IR, so the oracle runs it, and the WGSL and GLSL writers emit it for an entry that calls it.

import { module, fn, f32T, sin, compileModule, grad } from 'typeshade'
const wave = fn('wave', { x: f32T, k: f32T }, ({ x, k }) => sin(k.mul(x)).mul(k))
const d = grad(module({ funcs: [wave] }), 'wave', 'k')
compileModule(d.module).fns[d.name](0.5, 2) // → cos(1) * 0.5 * 2 + sin(1) = 1.3817…

It works in forward mode: every f32, float vector and float matrix gets a derivative beside its value, and if, switch and for carry both through their bodies. A call to another function of the module goes through a helper, g_jvp, generated once per callee. The component-wise builtins have their textbook rules; floor, ceil, round, trunc, sign and step have a zero derivative, which is the derivative everywhere but at their jumps. For a vector parameter, pass { direction: [...] } and the result is the derivative along it.

A construct with no derivative rule is refused with SD0118 naming it, a texture sample, a derivative builtin or a struct the parameter would flow into among them, and only when the parameter reaches it; the pass never returns a zero derivative it did not derive. The generated function is checked against a central finite difference on the oracle, which is how to check one of your own.

Calling a module’s helpers from host code

A "use typeshade" module is also a module your application can import. With the Vite plugin in place, an ordinary .ts file imports a .shade.ts and calls the helper functions it exports. Each call runs that function’s code on the CPU, not on the GPU, at f32 precision, the way a GPU would round it. It is how host code shares a shader’s math, for a height query, a picking test or a unit test. A @compute entry imported the same way runs on the GPU instead: await entry(bindings, workgroups) dispatches it on WebGPU and reads what it wrote back into your arrays, and entry(canvas, bindings) draws a full-screen fragment entry into a canvas (docs/use-typeshade-surface.md §67):

// app.ts, ordinary TypeScript: terrain.shade.ts exports `height(p: vec2, k: vec4): f32`
import { height } from './terrain.shade.ts'
const h = height([0.5, 0.5], [1, 0.5, 2, 0.25]) // a number

The values are plain: a scalar is a number or a boolean, a vector a tuple ([x, y]), a matrix a flat column-major array, an array<T, N> an array and a struct an object. The call checks each argument and throws a TypeError naming the parameter when one does not fit, and what it returns is a fresh value. It is synchronous.

What a host can call is an exported function that is not an entry point, not generic, takes no function, and reaches no binding and no GPU-only builtin. Everything else the module exports appears to the host as never, with the reason, so calling it is a type error where you wrote the call. The setup is four lines, a plugin in vite.config.ts, two in tsconfig.json and tshc sync in prepare. The surface reference has them, with the full table of host values: docs/use-typeshade-surface.md §64.

A loop over an array

An exported function that takes an array with no size, array<T>, is a kernel function: host code awaits it, and its array is the caller’s, written in place. Each for at the top of its body runs on the GPU, one invocation per iteration, when the compiler proves that no iteration touches what another one does, and on the CPU otherwise, with a TS8070 warning that names the line and the remedy (docs/use-typeshade-surface.md §65).

The dispatch is that top-level loop’s, so a loop nested in it runs whole inside each invocation: a grid written as two nested loops is one invocation per row. When each cell should be its own invocation, write the grid as one flat loop, for (let i: u32 = 0; i < w * h; i++), and take x = i % w and y = i / w from the index.

A shader module that imports another

A "use typeshade" file imports what another one exports, as any TypeScript module does: import { fbm } from './noise.shade.ts'. The file you compile and the shader files it imports, directly or through another, are one program, and the program becomes one module. What your file reaches in an imported file comes with it, its helpers, structs, constants and bindings, and the imported file’s own entry points do not. Each file keeps its own scope, so two files can each have a private hash; the module emits the second one under a name of its own.

compile() reads what a source imports through a readDocument you pass it, which returns a file’s text by its path, or undefined when there is none. The Vite plugin, tshc check, tshc sync and the editor read the files themselves.

import { existsSync, readFileSync } from 'node:fs'
import { compile } from 'typeshade'
const read = (path: string) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined)
const { wgsl, diagnostics } = compile(read('src/clouds.shade.ts')!, {
fileName: 'src/clouds.shade.ts',
readDocument: read,
})

A shader library installs from npm like any other package, and a shader file imports it by the package’s name: import { fbm } from 'shade-noise'. The package is found in node_modules from the importing file’s directory up, as Node finds one, and the file read is the one its package.json publishes under the typeshade condition of exports, beside the JavaScript it publishes for host code:

{
"name": "shade-noise",
"exports": {
".": { "typeshade": "./src/index.shade.ts", "default": "./dist/index.js" },
"./*": { "typeshade": "./src/*.shade.ts" }
}
}

'shade-noise' reads the package’s src/index.shade.ts, and 'shade-noise/hash' the hash.shade.ts beside it. A package with no exports is imported by the path of its file, 'shade-noise/noise.shade.ts'. readDocument is asked for each package.json too, so the read above follows a package with no change. A program holds one copy of a package version however many dependencies reach it, and a helper of a package that the module renames is named for the package and its file, shade_noise_noise_hash.

A mistake in an imported file is reported at that file, line and column. An import the compiler cannot follow is a TS8072 on the import: a path that names no file, a file that does not begin with the directive, a package no node_modules holds, a subpath its exports does not name, a name the file does not export, a default import. The surface reference has every form an import takes, the names the module emits and each refusal: docs/use-typeshade-surface.md §68.

Edit this page Report a problem