onejs-sl 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/encode.ts DELETED
@@ -1,376 +0,0 @@
1
- /**
2
- * Turns a recorded program into the buffer the VM shader evaluates.
3
- *
4
- * Phase 2 of `Specs/SHADER_LANG.md`, and the first consumer of the Phase 1 IR.
5
- * Three passes, all here, all pure TypeScript:
6
- *
7
- * 1. reachable drop anything the result does not depend on
8
- * 2. live ranges the last instruction that reads each value
9
- * 3. linear scan assign a register, free it when its range ends
10
- *
11
- * WHY EIGHT REGISTERS. Phase 0 measured a dynamically indexed register file in a
12
- * fragment shader on three stacks. On ANGLE D3D11 and on NVIDIA's GL driver,
13
- * shrinking the file from 16 entries to 8 costs 3.0x to 4.0x less, which is what
14
- * a spill to per thread local memory looks like. Eight is not a guess, it is the
15
- * number the hardware asked for.
16
- *
17
- * WHY AN INDEXED STORE AND NOT AN UNROLLED CHAIN. At 8 registers the two Windows
18
- * backends want opposite things: D3D11 is 1.38x to 1.44x FASTER with the indexed
19
- * store, GL is about 0.84x faster with the unrolled chain, and both reach the
20
- * same best case on their preferred one. Branching on the renderer would take
21
- * the best of both and ship a code path that can only ever be tested on one of
22
- * the two machines that disagree. One shape everywhere has the better worst
23
- * case, 0.199 against 0.237, and no untestable branch.
24
- */
25
-
26
- import {
27
- MAX_TEXTURES, SLError, TYPE, reachable, type NodeRef, type Program, type SLNode, type SLType,
28
- } from "./ir"
29
- import { INPUT_ID, SLOP } from "./ops"
30
- import { compile, type Compiled } from "./compile"
31
-
32
- // Lives in ops.ts with the other wire constants; re-exported so nothing that
33
- // reached it through the encoder has to move.
34
- export { INPUT_ID }
35
- // Lives in ir.ts, since a host reads a graph with it too (`inputsUsed`).
36
- export { reachable }
37
-
38
- /**
39
- * Registers in the VM's file. Phase 0's answer, not a preference.
40
- *
41
- * A program that needs more is refused rather than spilled. A spill slot is a
42
- * second dynamic index into a second array, and any program that needs one is
43
- * already past the budget this design is honest about.
44
- */
45
- export const REGISTERS = 8
46
-
47
- /** Instructions in one program. The VM's loop is bounded and must be. */
48
- export const MAX_INSTRUCTIONS = 256
49
-
50
- /**
51
- * Two texels per instruction, fixed width, so instruction `i` sits at texels
52
- * `2i` and `2i+1` and the shader needs no cursor to advance.
53
- *
54
- * Fixed width wastes a texel on instructions with no immediate. That is the
55
- * right trade: variable width means decoding a length and advancing a cursor,
56
- * which is a dependent read per instruction and defeats the point of storing the
57
- * program in a texture at all.
58
- *
59
- * texel 2i [ op, dst, a, b ]
60
- * texel 2i+1 depends on the op:
61
- * SWIZZLE [ c0, c1, c2, c3 ], unused channels are -1
62
- * COMPOSE [ c, d, argCount, 0 ], the third and fourth args
63
- * SAMPLE [ slot, 0, 0, 0 ]
64
- * FBM [ octaves, 0, 0, 0 ]
65
- * CONST [ x, y, z, w ]
66
- * otherwise unused, and zero
67
- *
68
- * Every value is a float. Integers ride in floats exactly up to 2^24, which is
69
- * far above any field here, and the encoder asserts each one is in range rather
70
- * than trusting it.
71
- */
72
- export const TEXELS_PER_INSTRUCTION = 2
73
-
74
- /**
75
- * A program as the VM's buffer, plus everything `compile` gives a host.
76
- *
77
- * The metadata and the lazy sources are `compile`'s, so the two cannot drift.
78
- */
79
- export interface Encoded extends Compiled {
80
- /** Flat RGBA float data, `TEXELS_PER_INSTRUCTION * 4` numbers per instruction. */
81
- data: Float32Array
82
- instructions: number
83
- /** Register holding the result when the program halts. */
84
- resultRegister: number
85
- /** Registers actually used, for the shader to size nothing and for reporting. */
86
- registersUsed: number
87
- /**
88
- * The lowest VM encoding that can run this buffer. A VM older than this
89
- * refuses the program; see `SL_WIRE_VERSION`.
90
- */
91
- wire: number
92
- }
93
-
94
- interface Instr {
95
- op: number
96
- dst: number
97
- a: number
98
- b: number
99
- imm: [number, number, number, number]
100
- }
101
-
102
- /**
103
- * The last position in `order` that reads each node.
104
- *
105
- * A register is free the instant its value's last reader has run, which is what
106
- * lets an eight entry file hold a program with far more than eight values in it.
107
- */
108
- export function liveRanges(nodes: SLNode[], order: NodeRef[], result: NodeRef): Map<NodeRef, number> {
109
- const pos = new Map<NodeRef, number>()
110
- order.forEach((ref, i) => pos.set(ref, i))
111
- const last = new Map<NodeRef, number>()
112
- order.forEach((ref, i) => {
113
- const n = nodes[ref]
114
- const reads: NodeRef[] = n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
115
- for (const r of reads) last.set(r, i)
116
- })
117
- // The result outlives every instruction, or it could be freed and overwritten
118
- // before anybody reads it.
119
- last.set(result, order.length)
120
- for (const ref of order) if (!last.has(ref)) last.set(ref, pos.get(ref)!)
121
- return last
122
- }
123
-
124
- /**
125
- * Names the loops behind an over-long program. A loop unrolls, so a count that
126
- * reads as one line of source is emitted once per iteration, and without this
127
- * the error blames "instructions" the author never wrote. A loop is named only
128
- * when it fills a quarter of the budget or more: a two iteration loop in a
129
- * program made long by other code is not the thing to lower.
130
- *
131
- * Worded for both surfaces: the span comes from `sl.repeat` in the EDSL and
132
- * from `for` in a `.sl` file, and an author who wrote one should not be told
133
- * about the other.
134
- */
135
- function blameLoops(program: Program, order: NodeRef[]): string {
136
- const parts: string[] = []
137
- for (const loop of program.loops ?? []) {
138
- const ops = order.filter((r) => r >= loop.start && r < loop.end).length
139
- if (ops * 4 >= MAX_INSTRUCTIONS) parts.push(`a loop of ${loop.count} accounts for ${ops}`)
140
- }
141
- if (parts.length === 0) return ""
142
- return ` A loop unrolls, so every iteration is emitted in full: ${parts.join(", ")}. ` +
143
- `Lower the count or slim the body.`
144
- }
145
-
146
- /**
147
- * The program as the VM runs it.
148
- *
149
- * The IR carries a shape's whole parameter list, up to six. One instruction
150
- * holds a shape id and four immediates, so a shape given a fifth or sixth
151
- * becomes SDF_WIDE: its first two parameters stay immediates and the other
152
- * four come from a constant register. Only the VM sees this; every compiled
153
- * backend reads the IR's SDF directly. The hash is the program's own, since
154
- * nothing about what it computes changed.
155
- */
156
- export function forVm(program: Program): Program {
157
- const wide = (n: SLNode) => n.k === "call" && n.op === SLOP.SDF && (n.imm?.length ?? 0) > 5
158
- if (!program.nodes.some(wide)) return program
159
- const nodes: SLNode[] = []
160
- const map: NodeRef[] = []
161
- program.nodes.forEach((n, i) => {
162
- if (n.k === "call" && wide(n)) {
163
- const im = n.imm!
164
- const rest = nodes.push({ k: "const", type: TYPE.VEC4, v: [im[3] ?? 0, im[4] ?? 0, im[5] ?? 0, im[6] ?? 0] }) - 1
165
- map[i] = nodes.push({
166
- k: "call", type: n.type, op: SLOP.SDF_WIDE, args: [map[n.args[0]!]!, rest], imm: [im[0] ?? 0, im[1] ?? 0, im[2] ?? 0, 0],
167
- }) - 1
168
- return
169
- }
170
- if (n.k === "swizzle") map[i] = nodes.push({ ...n, src: map[n.src]! }) - 1
171
- else if (n.k === "call") map[i] = nodes.push({ ...n, args: n.args.map((a) => map[a]!) }) - 1
172
- else map[i] = nodes.push(n) - 1
173
- })
174
- const at = (ref: NodeRef) => ref < map.length ? map[ref]! : nodes.length
175
- return { ...program, nodes, result: map[program.result]!, loops: program.loops.map((l) => ({ ...l, start: at(l.start), end: at(l.end) })) }
176
- }
177
-
178
- /**
179
- * The limits `encode` refuses a program for that are not about registers: its
180
- * length and its sampler slots. Shared with `vmFit`, so the two cannot
181
- * disagree about what fits.
182
- */
183
- export function checkBudget(program: Program, order: NodeRef[]): void {
184
- if (order.length > MAX_INSTRUCTIONS) {
185
- throw new SLError(
186
- `this program is ${order.length} operations and the VM runs at most ${MAX_INSTRUCTIONS}. ` +
187
- `The loop in the shader is bounded and has to be.` + blameLoops(program, order),
188
- )
189
- }
190
- for (const t of program.textures) {
191
- if (t.slot >= MAX_TEXTURES) throw new SLError(`texture "${t.name}" is past the sampler budget`)
192
- }
193
- }
194
-
195
- /**
196
- * A register for every node in `order`, by linear scan, and the most in use
197
- * at once. Throws when the file runs out. Shared with `vmFit`.
198
- */
199
- export function allocate(program: Program, order: NodeRef[]): { reg: Map<NodeRef, number>, peak: number } {
200
- const last = liveRanges(program.nodes, order, program.result)
201
- const reg = new Map<NodeRef, number>()
202
- const free: number[] = []
203
- for (let r = REGISTERS - 1; r >= 0; r--) free.push(r)
204
- /** Which node currently owns each register, for freeing. */
205
- const owner = new Array<NodeRef | null>(REGISTERS).fill(null)
206
- let peak = 0
207
-
208
- order.forEach((ref, i) => {
209
- // Free first, so a value whose last reader is THIS instruction can hand
210
- // its register straight to this instruction's result. Without that an
211
- // eight register file would behave like a seven register one on every
212
- // chained expression, which is most of them.
213
- for (let r = 0; r < REGISTERS; r++) {
214
- const o = owner[r]
215
- if (o !== null && last.get(o)! < i) { owner[r] = null; free.push(r) }
216
- }
217
-
218
- const args = readsOf(program.nodes[ref]).map((r) => {
219
- const rr = reg.get(r)
220
- if (rr === undefined) throw new SLError(`internal: node ${r} has no register when ${ref} needs it`)
221
- return rr
222
- })
223
-
224
- // Operands are read before the destination is written, so a register
225
- // whose last use is this instruction can also be the destination.
226
- for (let r = 0; r < REGISTERS; r++) {
227
- const o = owner[r]
228
- if (o !== null && last.get(o)! === i && !reg.has(ref)) {
229
- // Only reclaim a register this instruction is actually reading;
230
- // anything else is still live for a later instruction.
231
- if (args.includes(r)) { owner[r] = null; free.push(r) }
232
- }
233
- }
234
-
235
- const dst = free.pop()
236
- if (dst === undefined) {
237
- throw new SLError(
238
- `this program needs more than ${REGISTERS} registers at once. The VM has ${REGISTERS} ` +
239
- `because that is what the hardware measured fastest, and spilling is deliberately not ` +
240
- `implemented: a spill slot is a second dynamic index into a second array. Split the ` +
241
- `program, or reuse fewer intermediate values at the same time.`,
242
- )
243
- }
244
- owner[dst] = ref
245
- reg.set(ref, dst)
246
- peak = Math.max(peak, REGISTERS - free.length)
247
- })
248
- return { reg, peak }
249
- }
250
-
251
- function readsOf(n: SLNode): NodeRef[] {
252
- return n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
253
- }
254
-
255
- /**
256
- * The lowest VM that can run `vm`, the result of `forVm(source)`: 2 only where
257
- * a wide shape made SDF_WIDE, so everything else still runs on a wire 1 VM.
258
- */
259
- export function wireOf(source: Program, vm: Program): number {
260
- return vm === source ? 1 : 2
261
- }
262
-
263
- export function encode(source: Program): Encoded {
264
- const program = forVm(source)
265
- const order = reachable(program.nodes, program.result)
266
- checkBudget(program, order)
267
- const { reg, peak } = allocate(program, order)
268
-
269
- const out = order.map((ref) => {
270
- const n = program.nodes[ref]
271
- const reads = readsOf(n)
272
- // The width of the first operand, for the ops whose result narrows.
273
- const srcWidth = reads.length > 0 ? program.nodes[reads[0]].type : undefined
274
- return emit(n, reg.get(ref)!, reads.map((r) => reg.get(r)!), srcWidth)
275
- })
276
-
277
- const data = new Float32Array(out.length * TEXELS_PER_INSTRUCTION * 4)
278
- out.forEach((ins, i) => {
279
- const o = i * 8
280
- data[o] = ins.op; data[o + 1] = ins.dst; data[o + 2] = ins.a; data[o + 3] = ins.b
281
- data[o + 4] = ins.imm[0]; data[o + 5] = ins.imm[1]
282
- data[o + 6] = ins.imm[2]; data[o + 7] = ins.imm[3]
283
- })
284
-
285
- // The hash, names, defaults and sources are compile's, from the SOURCE
286
- // program: every compiled backend reads the IR's own SDF, and forVm keeps
287
- // the hash, since nothing about what the program computes changed.
288
- const encoded = compile(source) as Encoded
289
- encoded.data = data
290
- encoded.instructions = out.length
291
- encoded.resultRegister = reg.get(program.result)!
292
- encoded.registersUsed = peak
293
- encoded.wire = wireOf(source, program)
294
- return encoded
295
- }
296
-
297
- const NONE: [number, number, number, number] = [0, 0, 0, 0]
298
-
299
- /**
300
- * Ops whose result is narrower than their input, so the shader cannot infer how
301
- * many components to read from the destination type.
302
- */
303
- const WIDTH_SENSITIVE = new Set<number>([
304
- SLOP.LENGTH, SLOP.DISTANCE, SLOP.DOT, SLOP.NORMALIZE, SLOP.LUMINANCE,
305
- ])
306
-
307
- function emit(n: SLNode, dst: number, args: number[], srcWidth?: SLType): Instr {
308
- switch (n.k) {
309
- case "const": {
310
- const v = n.v
311
- return { op: SLOP.CONST, dst, a: 0, b: n.type, imm: [v[0] ?? 0, v[1] ?? 0, v[2] ?? 0, v[3] ?? 0] }
312
- }
313
- case "input":
314
- return { op: SLOP.INPUT, dst, a: INPUT_ID[n.name], b: n.type, imm: [...NONE] }
315
- case "uniform":
316
- return { op: SLOP.UNIFORM, dst, a: n.slot, b: n.type, imm: [...NONE] }
317
- case "swizzle": {
318
- const c = n.chans
319
- return {
320
- op: SLOP.SWIZZLE, dst, a: args[0], b: n.type,
321
- imm: [c[0] ?? -1, c[1] ?? -1, c[2] ?? -1, c[3] ?? -1],
322
- }
323
- }
324
- case "call": {
325
- if (n.op === SLOP.COMPOSE) {
326
- if (args.length > 4) throw new SLError(`compose takes at most 4 parts, got ${args.length}`)
327
- return {
328
- op: SLOP.COMPOSE, dst, a: args[0] ?? 0, b: args[1] ?? 0,
329
- imm: [args[2] ?? 0, args[3] ?? 0, args.length, n.type],
330
- }
331
- }
332
- // Ops whose RESULT is narrower than their input need the source
333
- // width, because a register is a float4 whatever the value in it is
334
- // and length(vec2) is not length of four components. Found by
335
- // writing the shader, not by writing the encoder: nothing in the IR
336
- // is wrong, the information simply was not reaching the consumer.
337
- if (WIDTH_SENSITIVE.has(n.op)) {
338
- const src = srcWidth
339
- if (src === undefined) throw new SLError(`internal: ${n.op} has no source width`)
340
- return { op: n.op, dst, a: args[0] ?? 0, b: args[1] ?? 0, imm: [src, 0, 0, 0] }
341
- }
342
- // SDF carries a shape id plus four parameters, which is one more
343
- // number than the immediate holds. The id goes in the second
344
- // operand slot, free because SDF reads one register.
345
- if (n.op === SLOP.SDF) {
346
- const im = n.imm ?? []
347
- return {
348
- op: SLOP.SDF, dst, a: args[0] ?? 0, b: im[0] ?? 0,
349
- imm: [im[1] ?? 0, im[2] ?? 0, im[3] ?? 0, im[4] ?? 0],
350
- }
351
- }
352
- if (args.length > 3) {
353
- throw new SLError(`internal: ${n.op} has ${args.length} operands and the encoding holds 3`)
354
- }
355
- // A third operand rides in the immediate, which is free for every op
356
- // that has one (clamp, mix, smoothstep, select) because none of them
357
- // carries a literal.
358
- const imm: [number, number, number, number] = n.imm !== undefined
359
- ? [n.imm[0] ?? 0, n.imm[1] ?? 0, n.imm[2] ?? 0, n.imm[3] ?? 0]
360
- : [args[2] ?? 0, 0, 0, 0]
361
- return { op: n.op, dst, a: args[0] ?? 0, b: args[1] ?? 0, imm }
362
- }
363
- }
364
- }
365
-
366
- /**
367
- * Every field must survive a float32 round trip exactly, or an instruction
368
- * decodes as a different instruction. Cheap to check and impossible to debug
369
- * from the wrong picture it would otherwise produce.
370
- */
371
- export function checkEncodable(v: number, what: string): void {
372
- if (!Number.isFinite(v)) throw new SLError(`${what} is ${v}, which cannot be encoded`)
373
- if (Number.isInteger(v) && Math.abs(v) > 0x1000000) {
374
- throw new SLError(`${what} is ${v}, past the 2^24 an integer survives in a float`)
375
- }
376
- }
package/src/limits.ts DELETED
@@ -1,50 +0,0 @@
1
- /**
2
- * `onejs-sl/limits`: whether a program fits the VM, without encoding it.
3
- *
4
- * The VM is the one backend with limits. A compiled backend (every web build,
5
- * an editor with a generated shader) runs any program; the VM runs one that is
6
- * at most `MAX_INSTRUCTIONS` long and never needs more than `REGISTERS`
7
- * values at once. OneJS refuses an over-limit program only in a build that
8
- * still runs the VM. A host that wants to warn an author ahead of that asks
9
- * here, and gets the same answer `encode` would give, because both run the
10
- * same checks.
11
- */
12
- import { SLError, type Program } from "./ir"
13
- import { allocate, checkBudget, forVm, reachable, wireOf } from "./encode"
14
-
15
- export { REGISTERS, MAX_INSTRUCTIONS } from "./encode"
16
- export { VM_UNIFORMS, VM_TEXTURES } from "./ops"
17
-
18
- export interface VmFit {
19
- /** Every OneJS VM at `wire` or newer runs it. */
20
- fits: boolean
21
- /** Operations after dead code is dropped, as the VM would run them. */
22
- instructions: number
23
- /** The most values live at once, when the register file held them all. */
24
- registers?: number
25
- /** The oldest VM encoding that can run it. */
26
- wire: number
27
- /** Why it does not fit, in the words `encode` throws with. */
28
- reason?: string
29
- }
30
-
31
- export function vmFit(source: Program): VmFit {
32
- const program = forVm(source)
33
- const order = reachable(program.nodes, program.result)
34
- const reasons: string[] = []
35
- let registers: number | undefined
36
- const attempt = (check: () => void) => {
37
- try {
38
- check()
39
- } catch (e) {
40
- if (!(e instanceof SLError)) throw e
41
- reasons.push(e.message)
42
- }
43
- }
44
- attempt(() => checkBudget(program, order))
45
- attempt(() => { registers = allocate(program, order).peak })
46
- const fit: VmFit = { fits: reasons.length === 0, instructions: order.length, wire: wireOf(source, program) }
47
- if (registers !== undefined) fit.registers = registers
48
- if (reasons.length > 0) fit.reason = reasons.join(" ")
49
- return fit
50
- }
package/src/vm.ts DELETED
@@ -1,11 +0,0 @@
1
- /**
2
- * `onejs-sl/vm`: the buffer OneJS's shader language VM evaluates.
3
- *
4
- * Only a host that runs the VM needs this. `SL_WIRE_VERSION` is the newest
5
- * encoding it writes; each result's `wire` is the oldest VM that can run it.
6
- */
7
- export {
8
- encode, forVm, reachable, liveRanges, REGISTERS, MAX_INSTRUCTIONS, TEXELS_PER_INSTRUCTION,
9
- } from "./encode"
10
- export type { Encoded } from "./encode"
11
- export { SL_WIRE_VERSION, INPUT_ID, VM_UNIFORMS, VM_TEXTURES } from "./ops"