/ concept-collection / turing-surface
Sign in
concept-collection / turing-surface
679 lines · 23.2 KBBlameHistoryRaw
1/**
2 * Statement list -> a replayable sequence of GPU operations.
3 *
4 * Everything expensive happens once, here: pipeline compilation, buffer
5 * allocation, bind-group construction. Because numbl fixes every type and
6 * shape at lowering time, the resulting op sequence is fully static — so
7 * `encodeStep` is pure synchronous command recording, with no allocation, no
8 * pipeline lookup and no readback. That is what lets the whole timestep be
9 * encoded into one submit and keeps the CPU out of the loop.
10 */
11import { isMultiElement, scalarDouble } from 'numbl-src/numbl-core/jit/lowering/types.ts';
12import type { Assign, For, IRExpr, IRStmt } from 'numbl-src/numbl-core/jit/lowering/ir.ts';
13import type { NumericType, Type } from 'numbl-src/numbl-core/jit/lowering/types.ts';
14import { ShtPlan, type ShtBinding } from '../sht/sht.ts';
15import { DerivPlan, type DerivBinding } from '../sht/deriv.ts';
16import type { CompiledFunction } from './compile.ts';
17import { EXTERNAL_OPS } from './externals.ts';
18import {
19 buildKernel,
20 UnsupportedOnGpu,
21 WORKGROUP_SIZE,
22 type KernelInputs,
23} from './wgsl.ts';
25const isNumeric = (t: Type): t is NumericType => t.kind === 'Numeric';
26const isTensor = (t: Type): boolean => isNumeric(t) && isMultiElement(t);
27const numel = (t: NumericType): number => (t.shape ?? []).reduce((a, b) => a * b, 1);
29/**
30 * The compile-time value of a scalar expression, if it has one. A literal
31 * carries its own; a variable carries one when it was bound to a `const` (the
32 * host's fixed scalars) or computed from constants, because numbl propagates
33 * `exact` through the type lattice.
34 */
35const exactValue = (e: IRExpr): number | undefined => {
36 if (isNumeric(e.ty) && typeof e.ty.exact === 'number') return e.ty.exact;
37 return e.kind === 'NumLit' ? e.value : undefined;
38};
40/** Cap on the iterations a `for` may unroll to. Each one is real GPU work —
41 * its own pipelines at compile time and its own dispatches per step — so a
42 * runaway bound should be a clear error rather than a hang. */
43const MAX_UNROLL = 64;
45interface Slot {
46 buffer: GPUBuffer;
47 count: number;
50const makeBuffer = (device: GPUDevice, label: string, count: number): GPUBuffer =>
51 device.createBuffer({
52 label,
53 size: 4 * count,
54 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
55 });
57/**
58 * Buffers for host-bound variables, shared across plans.
59 *
60 * A model is two programs — `init` and `step` — compiled separately but
61 * operating on the same state. `U` in the step must be the very buffer `init`
62 * wrote, so the buffers for host bindings live here rather than inside either
63 * plan.
64 */
65export class HostBuffers {
66 #device: GPUDevice;
67 #slots = new Map<string, Slot>();
69 constructor(device: GPUDevice) {
70 this.#device = device;
71 }
73 ensure(name: string, count: number): Slot {
74 const existing = this.#slots.get(name);
75 if (existing) {
76 if (existing.count !== count) {
77 throw new UnsupportedOnGpu(
78 `'${name}' is ${existing.count} elements in one program and ` +
79 `${count} in another`,
80 );
81 }
82 return existing;
83 }
84 const slot = { buffer: makeBuffer(this.#device, `mgpu-${name}`, count), count };
85 this.#slots.set(name, slot);
86 return slot;
87 }
89 get(name: string): Slot | undefined {
90 return this.#slots.get(name);
91 }
93 /** Upload initial data for a host binding. */
94 upload(name: string, data: Float32Array): void {
95 const slot = this.#slots.get(name);
96 if (!slot) throw new Error(`upload: no buffer named '${name}'`);
97 if (data.length !== slot.count) {
98 throw new Error(
99 `upload '${name}': expected ${slot.count} elements, got ${data.length}`,
100 );
101 }
102 this.#device.queue.writeBuffer(slot.buffer, 0, data as Float32Array<ArrayBuffer>);
103 }
105 destroy(): void {
106 for (const s of this.#slots.values()) s.buffer.destroy();
107 this.#slots.clear();
108 }
111type Op =
112 | {
113 kind: 'kernel';
114 pipeline: GPUComputePipeline;
115 bindGroup: GPUBindGroup;
116 count: number;
117 label: string;
118 /** Set when the kernel had to write to scratch because its output
119 * aliases one of its inputs; copied back after the dispatch. */
120 copyBack?: { from: GPUBuffer; to: GPUBuffer; bytes: number };
121 }
122 | { kind: 'synth' | 'analys'; binding: ShtBinding; label: string }
123 | { kind: 'dtheta' | 'dphi'; binding: DerivBinding; label: string }
124 | { kind: 'dthetac' | 'dphic'; bindGroup: GPUBindGroup; label: string }
125 | { kind: 'copy'; from: GPUBuffer; to: GPUBuffer; bytes: number; label: string };
127export interface PlanSpec {
128 /** The specialized function this plan executes. */
129 fn: CompiledFunction;
130 /** Output index -> host binding name to copy the result into after the run,
131 * so the next call reads it (the new spectral state feeds the old). */
132 feedback: (string | null)[];
135/**
136 * Bind group layout for a kernel: the output at 0, `inputs` read-only storage
137 * buffers after it, then the params buffer.
138 *
139 * Declared explicitly rather than with `layout: 'auto'`, because an auto layout
140 * only contains the bindings the shader actually references — so a kernel that
141 * happens to use no parameters (`uuv = u .* u .* v`) would drop the params
142 * binding and no longer match the bind group. An explicit layout may carry
143 * bindings the shader ignores.
144 */
145function kernelLayout(device: GPUDevice, inputs: number): GPUBindGroupLayout {
146 const readOnly = (binding: number): GPUBindGroupLayoutEntry => ({
147 binding,
148 visibility: GPUShaderStage.COMPUTE,
149 buffer: { type: 'read-only-storage' },
150 });
151 return device.createBindGroupLayout({
152 entries: [
153 {
154 binding: 0,
155 visibility: GPUShaderStage.COMPUTE,
156 buffer: { type: 'storage' },
157 },
158 ...Array.from({ length: inputs }, (_, i) => readOnly(i + 1)),
159 readOnly(inputs + 1),
160 ],
161 });
164async function makePipeline(
165 device: GPUDevice,
166 code: string,
167 label: string,
168 bindGroupLayout: GPUBindGroupLayout,
169): Promise<GPUComputePipeline> {
170 device.pushErrorScope('validation');
171 const module = device.createShaderModule({ code, label });
172 const info = await module.getCompilationInfo();
173 const errors = info.messages.filter((m) => m.type === 'error');
174 if (errors.length) {
175 throw new UnsupportedOnGpu(
176 `generated WGSL failed to compile for '${label}':\n` +
177 errors.map((e) => ` ${e.lineNum}:${e.linePos} ${e.message}`).join('\n') +
178 `\n--- shader ---\n${code}`,
179 );
180 }
181 const pipeline = await device.createComputePipelineAsync({
182 layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
183 compute: { module, entryPoint: 'main' },
184 label,
185 });
186 const err = await device.popErrorScope();
187 if (err) throw new UnsupportedOnGpu(`pipeline '${label}': ${err.message}`);
188 return pipeline;
191/** A compiled .m step, ready to run on the GPU. */
192export class ModelPlan {
193 /** Scalar parameter names, in the order the params buffer expects them. */
194 readonly paramNames: string[];
196 #device: GPUDevice;
197 #sht: ShtPlan;
198 #deriv?: DerivPlan;
199 #ops: Op[];
200 #owned: GPUBuffer[];
201 #paramBuf: GPUBuffer;
202 #paramData: Float32Array;
203 /** Public name -> buffer, for uploading initial state and reading results. */
204 #byName: Map<string, Slot>;
206 private constructor(init: {
207 device: GPUDevice;
208 sht: ShtPlan;
209 deriv?: DerivPlan;
210 ops: Op[];
211 byName: Map<string, Slot>;
212 owned: GPUBuffer[];
213 paramBuf: GPUBuffer;
214 paramData: Float32Array;
215 paramNames: string[];
216 }) {
217 this.#device = init.device;
218 this.#sht = init.sht;
219 this.#deriv = init.deriv;
220 this.#ops = init.ops;
221 this.#byName = init.byName;
222 this.#owned = init.owned;
223 this.#paramBuf = init.paramBuf;
224 this.#paramData = init.paramData;
225 this.paramNames = init.paramNames;
226 }
228 static async create(
229 device: GPUDevice,
230 sht: ShtPlan,
231 spec: PlanSpec,
232 host: HostBuffers,
233 /** Computes dtheta/dphi — only needed if the .m calls them. */
234 deriv?: DerivPlan,
235 ): Promise<ModelPlan> {
236 const { fn } = spec;
238 const slots = new Map<string, Slot>();
239 const byName = new Map<string, Slot>();
240 const owned: GPUBuffer[] = [];
241 /** Scalars the .m computes from its parameters, by cName. */
242 const derivedScalars = new Map<string, { name: string; expr: IRExpr }>();
244 const alloc = (label: string, count: number): Slot => {
245 const buffer = makeBuffer(device, label, count);
246 owned.push(buffer);
247 return { buffer, count };
248 };
250 // Arguments, bound by what the function's signature declares. Array
251 // arguments come from the shared pool, so a value one function returns is
252 // the same buffer the next one reads. Scalar parameters share one small
253 // storage buffer, in signature order.
254 const paramNames: string[] = [];
255 const paramSlots = new Map<string, number>();
256 for (const p of fn.params) {
257 if (p.binding.kind === 'tensor') {
258 const count = p.binding.shape.reduce((x, y) => x * y, 1);
259 const slot = host.ensure(p.name, count);
260 slots.set(p.cName, slot);
261 byName.set(p.name, slot);
262 } else if (p.binding.kind === 'param') {
263 paramSlots.set(p.cName, paramNames.length);
264 paramNames.push(p.name);
265 }
266 // `const` arguments are exact in the IR and fold into the kernels.
267 }
268 const paramData = new Float32Array(Math.max(1, paramNames.length));
269 const paramBuf = device.createBuffer({
270 label: 'mgpu-params',
271 size: 4 * paramData.length,
272 usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
273 });
275 const ops: Op[] = [];
276 for (const stmt of fn.body) {
277 await planStatement(stmt);
278 }
280 // Feed declared outputs back into the argument buffers they replace.
281 fn.outputs.forEach((out, i) => {
282 const to = spec.feedback[i];
283 if (!to) return;
284 const src = slots.get(out.cName);
285 const dst = host.get(to);
286 if (!src) {
287 throw new UnsupportedOnGpu(
288 `'${fn.name}' declares the output '${out.name}' but never assigns it`,
289 );
290 }
291 if (!dst) throw new UnsupportedOnGpu(`'${to}' is not a host binding`);
292 if (src.count !== dst.count) {
293 throw new UnsupportedOnGpu(
294 `'${out.name}' (${src.count} elements) cannot feed ` +
295 `'${to}' (${dst.count})`,
296 );
297 }
298 ops.push({
299 kind: 'copy',
300 from: src.buffer,
301 to: dst.buffer,
302 bytes: 4 * src.count,
303 label: `${out.name} -> ${to}`,
304 });
305 });
307 return new ModelPlan({
308 device, sht, deriv, ops, byName, owned, paramBuf, paramData, paramNames,
309 });
311 async function planStatement(stmt: IRStmt): Promise<void> {
312 if (stmt.kind === 'ReturnFromFunction') return; // nothing follows it
313 if (stmt.kind === 'For') return planFor(stmt);
314 if (stmt.kind !== 'Assign') {
315 throw new UnsupportedOnGpu(
316 `a model function body may only contain assignments ` +
317 `(found '${stmt.kind}')`,
318 stmt.span,
319 );
320 }
321 if (!isNumeric(stmt.ty)) {
322 throw new UnsupportedOnGpu(
323 `'${stmt.name}' is not a numeric value`,
324 stmt.span,
325 );
326 }
327 if (!isTensor(stmt.ty)) {
328 // A scalar the model derives from its parameters (`us = a + b`). It
329 // gets no buffer and no dispatch: the kernels that read it bind it as
330 // a `let` in their prologue.
331 derivedScalars.set(stmt.cName, { name: stmt.name, expr: stmt.expr });
332 return;
333 }
334 const count = numel(stmt.ty);
336 // Reuse the destination buffer across steps: the same cName always maps
337 // to the same buffer, so a step allocates nothing.
338 let dest = slots.get(stmt.cName);
339 if (!dest) {
340 dest = alloc(`mgpu-${stmt.name}`, count);
341 slots.set(stmt.cName, dest);
342 } else if (dest.count !== count) {
343 throw new UnsupportedOnGpu(
344 `'${stmt.name}' changes size between assignments`,
345 stmt.span,
346 );
347 }
348 byName.set(stmt.name, dest);
350 const ext = externalCall(stmt);
351 if (ext) {
352 const argSlot = slots.get(ext.argCName);
353 if (!argSlot) {
354 throw new UnsupportedOnGpu(
355 `'${ext.name}' reads '${ext.argName}', which has no buffer`,
356 stmt.span,
357 );
358 }
359 const label = `${stmt.name} = ${ext.name}(${ext.argName})`;
360 if (ext.name === 'synth') {
361 ops.push({
362 kind: 'synth',
363 binding: sht.createSynthBinding(argSlot.buffer, dest.buffer),
364 label,
365 });
366 } else if (ext.name === 'analys') {
367 ops.push({
368 kind: 'analys',
369 binding: sht.createAnalysBinding(argSlot.buffer, dest.buffer),
370 label,
371 });
372 } else if (
373 ext.name === 'dtheta' || ext.name === 'dphi' ||
374 ext.name === 'dthetac' || ext.name === 'dphic'
375 ) {
376 if (!deriv) {
377 throw new UnsupportedOnGpu(
378 `'${ext.name}' needs the surface's derivative transforms, ` +
379 `which this plan was not given`,
380 stmt.span,
381 );
382 }
383 if (ext.name === 'dthetac' || ext.name === 'dphic') {
384 // Coefficient-space shuffles read at l+-1 (dthetac) or in place
385 // (dphic) and cannot alias their output: WebGPU forbids one buffer
386 // being readable and writable storage in the same dispatch, and
387 // there is no scratch-copy fallback here — refuse rather than
388 // silently reroute.
389 if (argSlot.buffer === dest.buffer) {
390 throw new UnsupportedOnGpu(
391 `'${stmt.name} = ${ext.name}(${ext.argName})' reads and ` +
392 `writes the same buffer; assign to a new name instead`,
393 stmt.span,
394 );
395 }
396 ops.push({
397 kind: ext.name,
398 bindGroup:
399 ext.name === 'dthetac'
400 ? deriv.createDthetacBinding(argSlot.buffer, dest.buffer)
401 : deriv.createDphicBinding(argSlot.buffer, dest.buffer),
402 label,
403 });
404 return;
405 }
406 ops.push(
407 ext.name === 'dtheta'
408 ? { kind: 'dtheta', binding: deriv.createDthetaBinding(argSlot.buffer, dest.buffer), label }
409 : { kind: 'dphi', binding: deriv.createDphiBinding(argSlot.buffer, dest.buffer), label },
410 );
411 } else {
412 throw new UnsupportedOnGpu(`unknown external op '${ext.name}'`, stmt.span);
413 }
414 return;
415 }
417 // Element-wise kernel. Collect the distinct tensor operands and give
418 // them dense binding slots.
419 const tensors = new Map<string, number>();
420 collectTensorVars(stmt.expr, (cName) => {
421 if (!tensors.has(cName)) tensors.set(cName, tensors.size);
422 });
424 const label = `${stmt.name} = <${count} elements, element-wise>`;
425 const kernel = buildKernel(
426 stmt,
427 {
428 tensors,
429 params: paramSlots,
430 scalars: derivedScalars,
431 } satisfies KernelInputs,
432 count,
433 label,
434 );
436 const bindGroupLayout = kernelLayout(device, tensors.size);
437 const pipeline = await makePipeline(device, kernel.code, label, bindGroupLayout);
439 // WebGPU forbids aliasing a writable storage binding with another
440 // binding in the same group, so an in-place update (`u = u + 1`) writes
441 // to scratch and copies back. Element-wise kernels only ever touch
442 // their own index, so the copy is the only cost.
443 const aliased = tensors.has(stmt.cName);
444 const target = aliased ? alloc(`mgpu-${stmt.name}-scratch`, count) : dest;
446 const entries: GPUBindGroupEntry[] = [
447 { binding: 0, resource: { buffer: target.buffer } },
448 ];
449 for (const [cName, i] of tensors) {
450 const s = slots.get(cName);
451 if (!s) {
452 throw new UnsupportedOnGpu(
453 `'${stmt.name}' reads a value with no buffer`,
454 stmt.span,
455 );
456 }
457 entries.push({ binding: i + 1, resource: { buffer: s.buffer } });
458 }
459 entries.push({ binding: tensors.size + 1, resource: { buffer: paramBuf } });
461 ops.push({
462 kind: 'kernel',
463 pipeline,
464 bindGroup: device.createBindGroup({
465 layout: bindGroupLayout,
466 entries,
467 }),
468 count,
469 label,
470 copyBack: aliased
471 ? { from: target.buffer, to: dest.buffer, bytes: 4 * count }
472 : undefined,
473 });
474 }
476 /**
477 * Unroll a counted loop into the op sequence.
478 *
479 * A plan is a fixed list of GPU operations with no branching, which is what
480 * makes a timestep pure command recording. A `for` with compile-time-known
481 * bounds still fits that: it is the same body planned once per iteration.
482 * Nothing else changes — numbl gives a variable one cName for every
483 * assignment to it, so the buffer an iteration writes is the buffer the
484 * next one reads, which is exactly a loop-carried value.
485 *
486 * The loop variable gets no buffer either: it is bound as a derived scalar
487 * to this iteration's literal value, so a kernel that reads `k` folds the
488 * number in. The binding is overwritten per iteration, before that
489 * iteration's body is planned and its WGSL emitted.
490 */
491 async function planFor(stmt: For): Promise<void> {
492 const from = exactValue(stmt.start);
493 const to = exactValue(stmt.end);
494 if (from === undefined || to === undefined) {
495 throw new UnsupportedOnGpu(
496 `a 'for' loop is unrolled into the op sequence, so its bounds must ` +
497 `be known when the model is compiled — ` +
498 `${from === undefined ? 'the start' : 'the end'} of this one is a ` +
499 `runtime value. Use a whole number, or a count the app supplies ` +
500 `as a fixed argument (changing it recompiles).`,
501 stmt.span,
502 );
503 }
504 const trips = Math.floor((to - from) / stmt.step) + 1;
505 if (!Number.isFinite(trips)) {
506 throw new UnsupportedOnGpu(`'for ${stmt.varName}' has no finite length`, stmt.span);
507 }
508 if (trips > MAX_UNROLL) {
509 throw new UnsupportedOnGpu(
510 `'for ${stmt.varName}' would unroll to ${trips} iterations, over the ` +
511 `limit of ${MAX_UNROLL}. Every iteration is separate GPU work, so a ` +
512 `long loop compiles slowly and runs no faster than writing it out.`,
513 stmt.span,
514 );
515 }
516 for (let i = 0; i < trips; i++) {
517 const value = from + i * stmt.step;
518 derivedScalars.set(stmt.cVar, {
519 name: stmt.varName,
520 expr: {
521 kind: 'NumLit',
522 value,
523 ty: scalarDouble(
524 value > 0 ? 'positive' : value < 0 ? 'negative' : 'zero',
525 value,
526 ),
527 span: stmt.span,
528 },
529 });
530 for (const s of stmt.body) await planStatement(s);
531 }
532 }
533 }
535 /** Upload parameter values, in `paramNames` order. Cheap — call freely. */
536 setParams(values: Record<string, number>): void {
537 this.paramNames.forEach((name, i) => {
538 const v = values[name];
539 this.#paramData[i] = Number.isFinite(v) ? v : 0;
540 });
541 this.#device.queue.writeBuffer(
542 this.#paramBuf,
543 0,
544 this.#paramData as Float32Array<ArrayBuffer>,
545 );
546 }
548 /** Buffer holding the named value, or undefined if the .m never binds it. */
549 buffer(name: string): GPUBuffer | undefined {
550 return this.#byName.get(name)?.buffer;
551 }
553 elementCount(name: string): number | undefined {
554 return this.#byName.get(name)?.count;
555 }
557 /**
558 * Record `steps` timesteps. Synchronous: no awaits, no readback. All of the
559 * ops share one compute pass, which WebGPU executes in submission order
560 * with a barrier between dispatches.
561 */
562 encodeSteps(encoder: GPUCommandEncoder, steps: number): void {
563 for (let s = 0; s < steps; s++) {
564 let pass: GPUComputePassEncoder | null = null;
565 const inPass = (): GPUComputePassEncoder => {
566 if (!pass) pass = encoder.beginComputePass({ label: 'mgpu-step' });
567 return pass;
568 };
569 const endPass = (): void => {
570 if (pass) {
571 pass.end();
572 pass = null;
573 }
574 };
575 for (const op of this.#ops) {
576 switch (op.kind) {
577 case 'kernel': {
578 const p = inPass();
579 p.setPipeline(op.pipeline);
580 p.setBindGroup(0, op.bindGroup);
581 p.dispatchWorkgroups(Math.ceil(op.count / WORKGROUP_SIZE));
582 if (op.copyBack) {
583 endPass();
584 encoder.copyBufferToBuffer(
585 op.copyBack.from, 0, op.copyBack.to, 0, op.copyBack.bytes,
586 );
587 }
588 break;
589 }
590 case 'synth':
591 this.#shtInto(inPass(), op);
592 break;
593 case 'analys':
594 this.#shtInto(inPass(), op);
595 break;
596 case 'dtheta':
597 this.#derivInto(inPass(), op);
598 break;
599 case 'dphi':
600 this.#derivInto(inPass(), op);
601 break;
602 case 'dthetac':
603 this.#deriv!.encodeDthetacInto(inPass(), op.bindGroup);
604 break;
605 case 'dphic':
606 this.#deriv!.encodeDphicInto(inPass(), op.bindGroup);
607 break;
608 case 'copy':
609 endPass();
610 encoder.copyBufferToBuffer(op.from, 0, op.to, 0, op.bytes);
611 break;
612 }
613 }
614 endPass();
615 }
616 }
618 #shtInto(pass: GPUComputePassEncoder, op: Op & { kind: 'synth' | 'analys' }): void {
619 if (op.kind === 'synth') this.#sht.encodeSynthInto(pass, op.binding);
620 else this.#sht.encodeAnalysInto(pass, op.binding);
621 }
623 #derivInto(pass: GPUComputePassEncoder, op: Op & { kind: 'dtheta' | 'dphi' }): void {
624 // planStatement already refused to plan a dtheta/dphi op without a
625 // DerivPlan, so #deriv is guaranteed set whenever an op of this kind exists.
626 if (op.kind === 'dtheta') this.#deriv!.encodeDthetaInto(pass, op.binding);
627 else this.#deriv!.encodeDphiInto(pass, op.binding);
628 }
630 /** Human-readable op sequence — what the .m actually compiled to. */
631 describe(): string[] {
632 return this.#ops.map((op) => `${op.kind.padEnd(7)} ${op.label}`);
633 }
635 destroy(): void {
636 for (const b of this.#owned) b.destroy();
637 this.#paramBuf.destroy();
638 this.#owned.length = 0;
639 }
642/** `x = synth(y)` / `x = analys(y)` -> the call's name and argument. */
643function externalCall(
644 stmt: Assign,
645): { name: string; argCName: string; argName: string } | null {
646 const e = stmt.expr;
647 if (e.kind !== 'Call' || !EXTERNAL_OPS.has(e.name)) return null;
648 if (e.args.length !== 1 || e.args[0].kind !== 'Var') {
649 throw new UnsupportedOnGpu(
650 `'${e.name}' must be applied to a single variable`,
651 stmt.span,
652 );
653 }
654 const arg = e.args[0];
655 return { name: e.name, argCName: arg.cName, argName: arg.name };
658function collectTensorVars(e: IRExpr, visit: (cName: string) => void): void {
659 const walk = (x: IRExpr): void => {
660 switch (x.kind) {
661 case 'Var':
662 if (isTensor(x.ty)) visit(x.cName);
663 return;
664 case 'Binary':
665 walk(x.left);
666 walk(x.right);
667 return;
668 case 'Unary':
669 walk(x.operand);
670 return;
671 case 'Call':
672 x.args.forEach(walk);
673 return;
674 default:
675 return;
676 }
677 };
678 walk(e);
moveopenescclose