Skip to content

Commit ef36617

Browse files
Reuben Brooksclaude
andcommitted
Compile sync where provable: maybe-await call sites, sync demotion, let flattening
The eval-capable backend compiled essentially every lambda async and awaited every head call; only leaf functions over primitives were demoted to sync. JSC pays heavily for async frames and promise allocation, making the kernel suite ~2x slower on Bun than Node. Three changes, all dynamic - eval redefinition keeps working with no opt-in restrictions: - Maybe-await: head calls in async contexts compile to (w$ = $.t(f(...))) instanceof Promise ? await w$ : w$, with one let-declared slot per function. Callers suspend only when the callee actually returned a Promise, so redefining any function to an async replacement just makes call sites take the await branch. - Structural sync demotion: any compiled arrow whose body contains no await is emitted as a plain function. Subsumes the leaf rule and adds tail-call-only functions (their calls are $.b bounces). 66% of kernel lambdas are now sync. - Let flattening: inside a function, (let X Y Z) compiles to (X$tN = Y, Z) with per-function alpha-renaming and block-scoped declarations instead of an awaited async iife. Awaited iifes in the rendered kernel: 2330 -> 41 (remaining traps). Fixed a latent alias bug en route: buildApp's local-callee path used raw SafeId instead of variable(), breaking alpha-renamed bindings. - funSyncGeneric over-application chains through then() when settle yields a Promise (sync wrapper trampolining into an async function). Kernel certification suite (134/134 everywhere, serial A/B, node v25.4.0 / bun 1.3.14 / deno 2.8.3): node 15.3s -> 10.1s (~1.58x), bun 33.5s -> 19.5s (~1.77x), deno 13.8s -> 9.8s (~1.49x). AOT artifacts also faster (fib 210 -> 83 ms under load). All suites green: backend 369, frontend 10, extensions, ratatoskr e2e, smoke on all three runtimes, REPL interactive smoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0bc94ed commit ef36617

5 files changed

Lines changed: 119 additions & 23 deletions

File tree

bin/ratatoskr-build.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import backend from '../lib/backend.js';
3232
import overrides from '../lib/overrides.js';
3333
import { parseFile } from '../scripts/parser.js';
3434
import {
35-
Arrow, Block, Call, Const, Id, Program, Return, Statement, generate
35+
Arrow, Block, Call, Const, Id, Let, Program, Return, Statement, generate
3636
} from '../lib/ast.js';
3737

3838
const USAGE = 'usage: node bin/ratatoskr-build.js <shaken-dir> <out.js> [--linked]';
@@ -146,6 +146,7 @@ const program = generate(Program([
146146
Const(Id('run'), Arrow(
147147
[Id('$')],
148148
Block(
149+
Let(Id('w$')), // maybe-await slot for top-level forms (see lib/backend.js)
149150
...Object.entries(body.subs).map(([key, value]) => Const(Id(key), value)),
150151
...body.ast.body,
151152
Return(Id('$'))),

lib/ast.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const Iife = (params, args, body, async = false) => Call(Arrow(params, body, asy
5353
const Let = (id, init) => Local('let', id, init);
5454
const Literal = value => ({ type: 'Literal', value });
5555
const Local = (kind, id, init) => ({ type: 'VariableDeclaration', kind, declarations: [{ type: 'VariableDeclarator', id, init }] });
56+
const Lets = ids => ({ type: 'VariableDeclaration', kind: 'let', declarations: ids.map(id => ({ type: 'VariableDeclarator', id })) });
5657
const Member = (object, property) =>
5758
property.type === 'Literal' && validName(property.value)
5859
? { type: 'MemberExpression', object, property: Id(property.value) }
@@ -92,7 +93,7 @@ const ExportDefault = declaration => ({ type: 'ExportDefaultDeclaration', declar
9293

9394
export {
9495
Arrow, Assign, Async, Await, Binary, Block, Call, Catch, Class, Conditional, Const, Debugger, DoWhile,
95-
Empty, ExportDefault, For, ForIn, ForOf, Generator, Id, If, Iife, ImportDefault, Let, Literal, Local, Member,
96+
Empty, ExportDefault, For, ForIn, ForOf, Generator, Id, If, Iife, ImportDefault, Let, Lets, Literal, Local, Member,
9697
New, NewObj, NewTarget, Procedure, Program, Property, Return, SafeId, Sequence, Slot, Spread, Statement, Static,
9798
Super, TaggedTemplate, Template, TemplateElement, TemplateLiteral, This, Try, Unary, Var, Vector, While,
9899
Yield, YieldMany,

lib/backend.js

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
Arrow, Binary, Block, Call, Catch, Conditional, Id, Iife, Literal,
2+
Arrow, Assign, Await, Binary, Block, Call, Catch, Conditional, Id, Iife, Lets, Literal,
33
Member, Return, SafeId, Sequence, Template, Try, Unary, Vector,
44
generate, isStatement
55
} from './ast.js';
@@ -101,6 +101,50 @@ const hasExternalCalls = (f, body) => {
101101
}
102102
return externals.size > 0;
103103
};
104+
// Scans a built syntax tree for awaits (bit 1) and uses of the w$ maybe-await
105+
// slot (bit 2) belonging to the current function level: awaits nested in inner
106+
// functions suspend the inner function, not this one, and every function that
107+
// uses w$ declares its own, so the scan stops at function boundaries.
108+
// Memoised: build is bottom-up and never adds awaits into an
109+
// already-constructed node, so results are stable.
110+
const scanCache = new WeakMap();
111+
const scan = node => {
112+
if (!node || typeof node !== 'object') {
113+
return 0;
114+
}
115+
if (scanCache.has(node)) {
116+
return scanCache.get(node);
117+
}
118+
const result =
119+
isArray(node) ? node.reduce((r, x) => r | scan(x), 0) :
120+
node.type === 'ArrowFunctionExpression'
121+
|| node.type === 'FunctionExpression' ? 0 :
122+
(node.type === 'AwaitExpression' ? 1 : 0)
123+
| (node.type === 'Identifier' && node.name === 'w$' ? 2 : 0)
124+
| Object.values(node).reduce((r, x) => r | scan(x), 0);
125+
scanCache.set(node, result);
126+
return result;
127+
};
128+
const containsAwait = node => (scan(node) & 1) !== 0;
129+
const usesSlot = node => (scan(node) & 2) !== 0;
130+
// Settled calls in async position only suspend when the callee actually
131+
// returned a Promise: (w$ = $.t(f(...))) instanceof Promise ? await w$ : w$.
132+
// w$ is declared once per enclosing function (see functionBody), so each
133+
// invocation has its own slot; the assign-test-await sequence is synchronous,
134+
// making reuse of one slot for every call site in the function safe.
135+
const MaybeAwait = ast =>
136+
Conditional(
137+
Binary('instanceof', Assign(Id('w$'), ast), Id('Promise')),
138+
Await(Id('w$')),
139+
Id('w$'));
140+
// Function bodies declare the maybe-await slot (when used at this level) and
141+
// the flattened let bindings collected while building the body (see buildLet).
142+
// Declarations are per-function, so every invocation gets fresh bindings for
143+
// closures to capture.
144+
const functionBody = (temps, body) => {
145+
const names = [...(usesSlot(body) ? ['w$'] : []), ...temps];
146+
return names.length === 0 ? body : Block(Lets(names.map(x => Id(x))), Return(body));
147+
};
104148
const fabricate = (x, subs) =>
105149
!(x instanceof Fabrication) ? new Fabrication(x, subs) :
106150
subs ? new Fabrication(x.ast, Object.assign({}, x.subs, subs)) :
@@ -113,7 +157,10 @@ const inject = (name, f) => {
113157
const placeholder = SafeId(name, '$c');
114158
return fabricate(f(placeholder), { [placeholder.name]: Call(Member$('c'), [Literal(name)]) });
115159
};
116-
const variable = (context, symbol) => ann(context.get(symbol).dataType, SafeId(nameOf(symbol)));
160+
const variable = (context, symbol) => {
161+
const { dataType, id } = context.get(symbol);
162+
return ann(dataType, id ? Id(id) : SafeId(nameOf(symbol)));
163+
};
117164
const idle = symbol => {
118165
const name = nameOf(symbol);
119166
const placeholder = ann('Symbol', SafeId(name, '$s'));
@@ -164,31 +211,67 @@ const buildDo = (context, [_do, ...exprs]) =>
164211
Sequence,
165212
...most(exprs).map(x => build(context.now(), x)),
166213
uncast(build(context, last(exprs))));
167-
const buildLet = (context, [_let, symbol, binding, body]) =>
168-
assemble(
169-
(y, z) => Call(Arrow([SafeId(nameOf(symbol))], z, context.async), [y], context.async),
214+
// Inside a function, (let X Y Z) flattens to the sequence (X$tN = Y, Z) with
215+
// X$tN alpha-renamed (unique per enclosing function) and declared in the
216+
// function body (see functionBody): no iife, no promise, no await. Each let
217+
// expression evaluates at most once per invocation of its enclosing function
218+
// (KLambda has no loops), so single assignment per fresh binding holds and
219+
// closures capture the right value. The $t suffix cannot collide with
220+
// escaped names ($xx hex pairs), cells ($c), idles ($s) or params ($N$).
221+
// At top level there is no enclosing function to host the declaration, so
222+
// the historical iife form is kept.
223+
const buildLet = (context, [_let, symbol, binding, body]) => {
224+
if (!context.fn) {
225+
return assemble(
226+
(y, z) => {
227+
const async = context.async && containsAwait(z);
228+
return Call(Arrow([SafeId(nameOf(asSymbol(symbol)))], functionBody([], z), async), [y], async);
229+
},
230+
uncast(build(context.now(), binding)),
231+
uncast(build(context.add([asSymbol(symbol), {}]), body)));
232+
}
233+
const name = `${SafeId(nameOf(asSymbol(symbol))).name}$t${context.fn.n++}`;
234+
context.fn.temps.push(name);
235+
return assemble(
236+
(y, z) => Sequence(Assign(Id(name), y), z),
170237
uncast(build(context.now(), binding)),
171-
uncast(build(context.add([asSymbol(symbol), {}]), body)));
238+
uncast(build(context.add([asSymbol(symbol), { id: name }]), body)));
239+
};
240+
const trapBlock = statement =>
241+
Block(...(usesSlot(statement) ? [Lets([Id('w$')])] : []), statement);
172242
const buildTrap = (context, [_trap, body, handler]) =>
173243
isForm(handler, 3, 'lambda')
174244
? assemble(
175-
(x, y) =>
176-
Iife([], [], Block(Try(
245+
(x, y) => {
246+
const async = context.async && (containsAwait(x) || containsAwait(y));
247+
return Iife([], [], trapBlock(Try(
177248
Block(Return(x)),
178-
Catch(SafeId(nameOf(handler[1])), Block(Return(y))))), context.async),
249+
Catch(SafeId(nameOf(handler[1])), Block(Return(y))))), async);
250+
},
179251
uncast(build(context.now(), body)),
180252
uncast(build(context.add([asSymbol(handler[1]), { dataType: 'Error' }]), handler[2])))
181253
: assemble(
182254
(x, y) =>
183-
Iife([], [], Block(Try(
255+
Iife([], [], trapBlock(Try(
184256
Block(Return(x)),
185257
Catch(Id('e$'), Block(Return(Call(y, [Id('e$')])))))), context.async),
186258
uncast(build(context.now(), body)),
187259
uncast(build(context, handler)));
188-
const buildFunction = (context, params, body) =>
189-
assemble(
190-
b => Call$('l', [Arrow(params.map(x => isSymbol(x) ? SafeId(nameOf(x)) : x), b, context.async)]),
191-
uncast(build(context.later().add(...params.filter(isSymbol).map(x => [x, {}])), body)));
260+
const buildFunction = (context, params, body) => {
261+
const fn = { temps: [], n: 0 };
262+
return assemble(
263+
b => {
264+
// demote to a plain function when the compiled body never suspends:
265+
// callers always settle or maybe-await results, so a sync callee just
266+
// means the await gets skipped
267+
const async = context.async && containsAwait(b);
268+
return Call$('l', [Arrow(
269+
params.map(x => isSymbol(x) ? SafeId(nameOf(x)) : x),
270+
functionBody(fn.temps, b),
271+
async)]);
272+
},
273+
uncast(build(context.later().with({ fn }).add(...params.filter(isSymbol).map(x => [x, {}])), body)));
274+
};
192275
const buildNestedLambda = (context, params, body) =>
193276
isForm(body, 3, 'lambda')
194277
? buildNestedLambda(context, [...params.map((p, i) => p === body[1] ? Id(`$${i}$`) : p), body[1]], body[2])
@@ -232,10 +315,11 @@ const buildInline = (context, [fExpr, ...argExprs]) =>
232315
...argExprs.map(x => build(context.now(), x)));
233316
const buildApp = (context, [fExpr, ...argExprs]) =>
234317
assemble(
235-
(f, ...args) => context.head
236-
? Call$('t', [Call(f, args)], context.async)
237-
: Call$('b', [f, ...args]),
238-
context.has(fExpr) ? fabricate(SafeId(nameOf(fExpr))) :
318+
(f, ...args) =>
319+
!context.head ? Call$('b', [f, ...args]) :
320+
context.async ? MaybeAwait(Call$('t', [Call(f, args)])) :
321+
Call$('t', [Call(f, args)]),
322+
context.has(fExpr) ? fabricate(variable(context, fExpr)) :
239323
isArray(fExpr) ? uncast(build(context.now(), fExpr)) :
240324
isSymbol(fExpr) ? inject(nameOf(fExpr), x => Member(x, Id('f'))) :
241325
raise('not a valid application form'),
@@ -263,7 +347,9 @@ const build = (context, expr) =>
263347
isArray(expr) ? buildApp (context, expr) :
264348
raise('not a valid form');
265349
const hoist = (fabr, async = false) =>
266-
fabr.keys.length === 0 ? fabr.ast : Iife(fabr.keys.map(x => Id(x)), fabr.values, fabr.ast, async);
350+
fabr.keys.length === 0 && !async
351+
? fabr.ast
352+
: Iife(fabr.keys.map(x => Id(x)), fabr.values, functionBody([], fabr.ast), async);
267353

268354
export default (options = {}) => {
269355
const $ = runtime(options);

lib/runtime.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,16 @@ export default (options = {}) => {
105105
// goes through the fixed-parameter wrappers below, which avoid
106106
// materializing a rest array on every call (a dominant cost on JSC and a
107107
// measurable one on V8). `args` must be a real array here.
108+
// settling a sync function's result can still yield a Promise (a sync
109+
// wrapper may trampoline into an async function), so over-application
110+
// chains through then() in that case instead of assuming a settled value
111+
const applyTo = (g, args) =>
112+
g instanceof Promise
113+
? g.then(h => asFunction(h)(...args))
114+
: asFunction(g)(...args);
108115
const funSyncGeneric = (f, arity, args) =>
109116
args.length === arity ? f(...args) :
110-
args.length > arity ? bounce(() => asFunction(settle(f(...args.slice(0, arity))))(...args.slice(arity))) :
117+
args.length > arity ? bounce(() => applyTo(settle(f(...args.slice(0, arity))), args.slice(arity))) :
111118
args.length === 0 ? funSync(f, arity) :
112119
Object.assign(funSync((...more) => f(...args, ...more), arity - args.length), { arity: f.arity - args.length });
113120
const funAsyncGeneric = (f, arity, args) =>

scripts/render.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'node:fs';
22
import { parseKernel } from './parser.js';
33
import backend from '../lib/backend.js';
44
import {
5-
Arrow, Block, Call, Const, ExportDefault, Id, ImportDefault, Program, Return, Statement,
5+
Arrow, Block, Call, Const, ExportDefault, Id, ImportDefault, Let, Program, Return, Statement,
66
generate
77
} from '../lib/ast.js';
88
import { formatDuration, formatGrid, measure } from './utils.js';
@@ -29,6 +29,7 @@ const measureRender = measure(() => {
2929
ExportDefault(Arrow(
3030
[Id('$')],
3131
Block(
32+
Let(Id('w$')), // maybe-await slot for top-level forms (see lib/backend.js)
3233
...Object.entries(body.subs).map(([key, value]) => Const(Id(key), value)),
3334
...body.ast.body,
3435
Return(Id('$'))),

0 commit comments

Comments
 (0)