An assessment of lang/base (hand-written kernel) and lang/src (compiled image),
with measurements, and a deep evaluation of the "dumb interpreter + packages as
nested JSON data" idea sketched in lang/base/kernel-interpreter.js.
All measurements taken on Node v20.9.0, Windows 11, this machine, 2026-08-28.
lang/base is in good shape: ~1,700 hand-written lines (excluding the
generated parser), cleanly layered, already ES6-capability-based despite its
ES5 syntax. The weak spots are almost all peripheral: AMD/RequireJS, the
generated bulk of lang/src, and a bit-rotted toolchain.initialize(), not by JS parsing. Measured:
332 ms total for the deploy image in Node, of which 230 ms (70%) is
initialize() — and 11,795 of 11,915 Object.defineProperty calls happen
there, mostly the selectors × detached-roots DNU copying in copySuperclass.
There is a ~10 µs replacement for that 9,000-call storm (§5)..st (6.8×). Per file, roughly
⅓ IDE metadata, ⅓ debug-context scaffolding, ⅓ code+boilerplate — of which
only ~9–10% is real translated logic.kernel-interpreter.js (§6): a one-line this-loss
bug, and non-local return not yet wired (a working exception-based fix is
included, §6.4). Sail-as-JSON is ~1.5× the .st source size, ~6.6× smaller
than compiled devel JS. Pure interpretation measured ~92× slower than compiled
JS on send-chain arithmetic, so the end state should be tiered: interpret
first / on demand, baseline-compile hot methods from sail (§7).Compiler-Interpreter: today the debugger needs the entire compiler
front-end, re-parses source: strings, reconstructs the PC from sendIdx
counting, and cannot work at all against a deploy-stripped image. A sail
interpreter produces real contexts natively (§7.5).lang/base — the kernel| file | lines | role |
|---|---|---|
boot.js |
94 | assembles the brikz layers, defers runtime loading until initialize() |
brikz.js |
68 | micro-DI: named briks with deps, rebuildable |
kernel-checks.js |
93 | fail-fast capability asserts (globalThis, native Promise, setPrototypeOf, …) |
kernel-fundamentals.js |
267 | selector registry, package descriptors, method model, behaviorals |
kernel-language.js |
426 | class/metaclass model, traits + composition, class construction |
kernel-runtime.js |
455 | method installation, DNU, detached roots, contexts, send2 |
junk-drawer.js |
135 | inherits, selector mangling, small utilities |
helpers.js / lang.js / deploy.js / devel.js |
~140 | public API + package-set manifests |
kernel-interpreter.js |
48 | unwired sketch: the sail interpreter (§6) |
parser.js |
4,032 | generated by pegjs from parser.pegjs |
Layering is genuinely clean: fundamentals (no runtime assumptions) → language
(class model) → runtime (loaded lazily via runtimeLoadedPromise in
boot.js:50). Cross-layer coupling goes through the emit event table, not
direct calls. Despite the "targets old ES3" reputation, kernel-checks.js
hard-requires ES6 runtime capabilities; only the syntax is ES5.
lang/src — the compiled image26 packages; 3,375,006 B ≈ 3.22 MB of .js (92,462 lines) from 496,535 B ≈
485 KB of .st. Non-test: 2.27 MB. The Compiler-* chain alone (AST, Core,
IR, Inlining, Interpreter, Semantic): 730 KB. Largest files: Kernel-Tests
(693 KB), Kernel-Collections (368 KB), Kernel-Objects (228 KB), Compiler-IR
(215 KB).
Byte anatomy (consistent across files; Kernel-Objects.js measured exactly):
| bucket | share | notes |
|---|---|---|
IDE metadata (source:, args:, referencedClasses:, comments, pragmas:) |
~33% | pragmas: alone is 10.9 KB in Kernel-Objects because inlineJS: bodies appear three times (source, pragma, code) |
debug-context scaffolding (withContext, $ctx1.fill, $ctx1.sendIdx=, pragma markers) |
~36% | 252 of 286 Kernel-Objects methods are context-wrapped |
| code + per-method boilerplate | ~31% | of which real translated statements are only ~9–10% of the file (~21.6 KB in Kernel-Objects.js) |
The //>>excludeStart("ide"/"ctx"/"imports") pragmas are consumed only by
the r.js optimizer configs (cli, sdk, grunt-init-project) at bundle time —
nothing strips at devel load time. Emitted code is strictly ES5 syntax (var,
function, no arrows/let/class anywhere outside metadata strings), with ES6
Promise in the $pkg.isReady import prologue.
deploy.js pulls 11 packages (Kernel-Helpers, -Objects, -Classes, -Methods,
-Collections, -Dag, -Infrastructure, -Promises, -Exceptions, -Announcements,
Platform-Services) = 1,336,017 B that must be fetched, parsed, and executed
before initialize() can run AmberBootstrapInitialization._run(). devel.js
adds 6 more, lang.js the 7-package compiler chain. Everything is eager:
every addMethod runs, every selector registers, then initialize() installs
everything onto prototypes.
instantiateFn indirection (kernel-fundamentals.js:135,
kernel-runtime.js:224): methods carry a factory, not a raw function.
This is 80% of the hook a lazy execution scheme needs — it just fires at
class-init time instead of first-send time.buildCompositionChain) is
compact, and traitMethodChanged propagates per-selector rather than
rebuilding compositions.Object.create(null) dictionaries,
hasOwnProperty hygiene, fail-fast checks.config.js (duplicated
shim deps from amd-config-builder) is a symptom. Amber can't be consumed
from ESM-native tooling without ceremony.Compiler-Interpreter is heavy and fragile: hard AMD deps on
Compiler-AST/Core/Semantic; AIContext >> initializeAST re-parses the
source: string via Compiler new; PC recovery re-walks the AST counting
sendIdx occurrences of evaluatedSelector; ASTDebugger >> stepInto is
shouldBeImplemented; and none of it can work against a deploy-stripped
image (no source:).es6-promise polyfill still loaded by index.html even though
kernel-checks.js asserts native Promise, "engines": ">=4.0.0".Object.create(globalThis) as the globals object and patching of native
prototypes: works, but precludes two Amber instances per realm and invites
subtle shadowing. This is a design identity, not a bug — named here as the
constraint it is.instantiateFn calls for 1,345 addMethods — trait aliasing re-instantiates
per user (aliased() in kernel-language.js copies instantiateFn).Instrumented boot of the deploy image (RequireJS in Node, counters patched
around Object.defineProperty and the api registration functions):
phases (ms): kernelLoad 30.4 | packageLoadAndExec 72.2 | initialize 229.6 | total 332.2
counters: addClass 99 | addTrait 13 | addMethod 1,345 | methodFactoryCalls 1,630
defineProperty 11,915 — of which 11,795 during initialize()
census: 893 selectors | 10 detached roots | 1,501 local methods (incl. class side)
deploy set on disk: 1,336,017 B
Reading of the numbers:
initialize() costs
230 ms — 3× more. The common intuition "the payload is what's slow" is
wrong for Amber-on-V8; the registration storm is what's slow.defineProperty arithmetic during initialize() matches the code
exactly: 893 DNU handlers onto the root prototype
(RuntimeSelectorsBrik.installNewSelectors) + 10 detached roots × 893
selectors copied by copySuperclass (kernel-runtime.js:165) ≈ 9,823, plus
~1,900 real method installs ≈ 11.8 k. copySuperclass alone is ~75% of
the boot's property-definition work — and every later selectorsAdded
event fans out to all detached roots again.JSON.parse of a 201 KB sail-flavoured nested-array blob
(the size of the deploy set's .st sources) takes 2.4 ms.copySuperclass exists to make detached roots (String, Number, Array, …)
appear to inherit from their Amber superclass. In 2026, that can be actual
inheritance: insert the Amber superclass's prototype into the builtin's chain
once, instead of copying every selector forever.
// in detachClass / setClassConstructor, replacing copySuperclass:
Object.setPrototypeOf(klass.fn.prototype, klass.superclass.fn.prototype);
Measured feasibility (Node 20): insertion of 4 builtin prototypes takes
10.3 µs (vs ~9,000 defineProperty calls ≈ most of 230 ms), natives keep
working (toUpperCase, sort, toFixed verified), Amber methods and DNU
handlers are reached through the chain, and 3M message sends through the
inserted layer run at ~18 ns/send (allocation included). The historical reason
for the copying workaround — no reliable setPrototypeOf on builtins in
ES3/ES5 browsers — is gone; kernel-checks.js already asserts
setPrototypeOf. It also deletes the selectorsAdded fan-out and the
propagateMethodChange copying for detached roots.
Caveats to verify against the real image: Function.prototype (BlockClosure)
and Error.prototype chains get an extra hop for all JS code in the realm
(the same objects already carry hundreds of copied _-methods today, so the
delta is the lookup hop, not the pollution); engines deoptimize code that ran
before the chain mutation, so do all insertions before user code warms up —
i.e. exactly where FrameBindingBrik runs today.
setPrototypeOf on builtin roots (researched 2026-08)Which objects may legally refuse. Per the ES spec, the only immutable
prototype exotic objects are Object.prototype (made so by an ES2017-era
normative change, implemented in engines 2015–2016) plus, in browsers,
window and location. Every prototype Amber uses as a detached root —
String/Number/Boolean/Date/Array/RegExp/Error/Function/Promise .prototype
— is an ordinary object with the ordinary, mutable [[SetPrototypeOf]].
MDN states this explicitly ("other built-in prototype objects, such as
Function.prototype and Array.prototype, are not protected"). Amber never
needs to touch Object.prototype itself — inserted chains simply terminate
there — so the immutability rule is never in play. No correctness bug about
mutating builtin prototypes surfaced in MDN/engine-tracker searches; the
documented concerns are exclusively about performance.
Since when (per mdn/browser-compat-data):
| mechanism | Chrome | Edge | Firefox | Safari | IE | Opera | Node | Deno |
|---|---|---|---|---|---|---|---|---|
Object.setPrototypeOf |
34 (2014) | 12 | 31 (2014) | 9 (2015) | 11 | 21 | 0.12.0 (2015) | 1.0 |
legacy __proto__ setter |
1 | 12 | 1 | 3 | 11 | 10.5 | 0.10.0 | flag-gated |
globalThis (Amber's actual floor, asserted by kernel-checks.js) |
71 (2018) | 79 | 65 (2019) | 12.1 (2019) | — | 58 | 12 (2019) | 1.0 |
The decisive row is the third one: Amber's own boot asserts globalThis
(and native Promise), so every engine that can run Amber at all is 4–5 years
newer than the engines that introduced setPrototypeOf. Within Amber's
support envelope the operation is available unconditionally — browser and
Node alike (amber-cli's real floor is Node 12 because of the same assert,
regardless of the stale "engines": ">=4" field; setPrototypeOf itself has
been in Node since 0.12). The only runtimes that would need the current
copying fallback are IE ≤ 10-era engines, which Amber already cannot boot on.
Empirical proof, this machine. A 30-check harness (all nine root
prototypes: insertion accepted, Amber-style dispatch through the layer,
natives intact — string/array/date/regexp/error/function/promise operations,
instanceof, typeof, JSON, coercions — for..in enumeration hygiene,
symmetric restore, and Object.prototype/window correctly throwing
TypeError): all pass on Node 20.9 (V8) and on Chromium 148 in a real
browser page. SpiderMonkey/JSC were not run here; given spec + BCD the
expectation is identical, and the harness is trivially pasteable into their
consoles for confirmation.
One measured performance caveat: holey arrays. After inserting a layer
into Array.prototype's chain, V8's "no elements on the prototype chain"
fast path can no longer vouch for the chain, and reading holes consults it:
a hole-reading loop degraded ~6× (9 ms → 54 ms) on Node while the same loop
over a packed array was unaffected. This matters to Amber because
Array class >> new: is <inlineJS: 'return new Array(anInteger)'>
(Kernel-Collections.st:1258) — a holey array. Cleanest fix: allocate
new Array(anInteger).fill(null) — packed elements (fast path preserved,
holes never read through the chain) and closer to Smalltalk semantics,
where Array new: 5 answers a nil-filled array. Alternatively, keep Array
alone on the copying path and insert chains only for the other roots.
Browser-context timing right after mutation also showed transient
deoptimization on packed loops (re-JIT recovers), reinforcing: do all
insertions once, at boot, before anything warms up.
lang/base/kernel-interpreter.js)The 48-line sketch is a chain-threading interpreter over nested arrays. It was
exercised against a minimal facade (sendFn/runBlock); everything below is
verified by running it, not just by reading it.
Sail is a message chain encoding — each element of a chain is evaluated with
self = the previous element's value; a chain's value is its last element's:
| form | meaning |
|---|---|
| non-array | literal, becomes the new threaded value |
[0, e1, e2, …] |
sub-chain: evaluate e1…en threading self; value = last |
[n, key, cont…] (n>0) |
read contexts[n][key], then thread it through cont… — reads are fused with their continuation; a bare read must be terminated, e.g. [1,"tmp",[]] (an empty array is the identity expression) |
[-n, key] |
write threaded value into contexts[n][key] (assignment-as-chain-element) |
["sel", a1, …] |
send sel to threaded value; args evaluated as a chain themselves |
[["sel", flags], …] |
flagged send: flags&1 → pass contexts[0].sendTag (super-send channel); flags&2 → answer the receiver (cascade) |
["{}", e1, …] |
dynamic array {…} |
["^", …] |
return threaded value |
["[]", x, y, body…] |
block closure; slots 1–2 are reserved (arity/temp-names fit naturally); body starts at index 3; invocation pushes arguments and a temps object, so a block's params are [k, 0], [k, 1], … where k = its arg-context index |
["$…", …] |
facade escape — the natural hook for interpreter-level inlining of ifTrue:/whileTrue:/and: etc. |
Verified working on the engine as committed (via the 4-arg entry point):
send chains, context read/write, dynamic arrays, block creation + invocation,
cascade flag, super/sendTag flag, method-level ^ mid-body.
The chain-threading design is a genuinely good fit for Smalltalk: unary chains
(self class name first isVowel) cost 2 array slots each, and statements
compose as [0, …] groups. Scope resolution is already done at encode time
(numeric context indices), which is what makes a trivial baseline compiler
possible later (§7.4).
kernel-interpreter.js:45:
return index == null ? interpret(contexts, [0, sail], self, 1) : …
The recursive call is a plain call, so this (the facade) becomes
globalThis; any send then throws facade.sendFn is not a function
(reproduced). One-line fix: interpret.call(this, contexts, [0, sail], self, 1).
^ in a block does not return from the home methodEach block invocation creates a fresh engine (engine(newContexts, facade)
inside the "[]" closure), with its own returnValue cell. A ^ inside a
block therefore terminates only the block's own chain; the home method's
doChain never sees it and continues executing.
Reproduced with findFirstEven: coll = coll do: [:e | e even ifTrue: [^e]]. ^nil
over [1,3,4,5]: Smalltalk semantics require 4; the engine as committed
answers nil. This is not fixable inside facade.runBlock alone — by the time
the block's value reaches runBlock, "returned via ^" and "fell off the end"
are indistinguishable. A signal must cross the block→home boundary.
The home method activation owns a home cell; blocks share it (they pass it
into the engines they spawn); ^ throws; the home activation catches its own
token; finally kills the cell so escaped blocks fail correctly.
function NonLocalReturn (home, value) { this.home = home; this.value = value; }
function engine (contexts, facade, home) {
return function doChain (sail, self, i) {
for (var args = [], l = sail.length; i < l; ++i) {
args.push(self = doExpr(sail[i], self));
}
return args;
function doExpr (sail, self) {
if (!Array.isArray(sail)) return sail;
var selector = sail[0], flags = 0;
if (typeof selector === "number")
return selector === 0 ? doChain(sail, self, 1).pop() :
selector < 0 ? (contexts[-selector][sail[1]] = self) :
doChain(sail, contexts[selector][sail[1]], 2).pop();
if (Array.isArray(selector)) { flags = selector[1]; selector = selector[0]; }
if (typeof selector !== "string") return self;
if (selector[0] === "$") return facade[selector](sail, self, flags);
if (selector === "^") {
if (!home.alive) throw new Error("BlockContext>>cannotReturn: home context already returned");
throw new NonLocalReturn(home, self);
}
if (selector === "[]") return function () {
var newContexts = contexts.slice();
var argContext = arguments;
return facade.runBlock(function () {
newContexts.push(argContext, {});
return engine(newContexts, facade, home)(sail, this, 3).pop();
}, newContexts, this);
};
var args = doChain(sail, self, 1);
if (selector === "{}") return args;
var result = facade.sendFn(
self, selector, args, flags & 1 ? contexts[0].sendTag : null);
return flags & 2 ? self : result;
}
};
}
return function interpret (contexts, sail, self, index) {
var home = { alive: true };
try {
return engine(contexts, this, home)(
index == null ? [0, sail] : sail, self, index == null ? 1 : index).pop();
} catch (e) {
if (e instanceof NonLocalReturn && e.home === home) return e.value;
throw e;
} finally {
home.alive = false;
}
};
All tests pass on this variant, including the ones the committed sketch cannot
express: NLR out of a nested block (findFirstEven → 4), NLR unwinding
through an intermediate interpreted method (its own home doesn't match, so
it rethrows — ^7 inside a block passed to another method skips that method's
+100), and an escaped block whose home already returned raising
cannotReturn. This is the same $early-throw strategy the AOT compiler uses
(Compiler-IR.st emits var $early={}; try {…} catch(e){if(e===$early)…}),
so semantics stay aligned. Note the throw-always design also covers
method-level ^; if that ever shows up in profiles, the home engine can keep
the cheap returnValue path for its own chain and reserve the throw for
block-originated returns.
Same workload, fixed engine vs hand-written JS (V8, warmed):
| workload | interpreted | hand-written JS | ratio |
|---|---|---|---|
send-chain arithmetic (x+4)*2+x, 10⁶ activations |
329 ms | 3.6 ms | ~92× |
findFirstEven incl. NLR throw, 2×10⁵ activations |
2,147 ms | 1,512 ms | 1.4× |
Two lessons: (a) the "10–100×" folk estimate is right, and the ~330 ns per
interpreted activation is perfectly fine for UI callbacks, doIts, and cold
code; (b) where exceptions dominate (NLR-heavy code), interpretation overhead
is noise — the expensive thing is the throw, which compiled Amber pays too.
| method | .st source |
sail JSON | compiled devel .js |
|---|---|---|---|
ProtoObject >> printOn: |
147 B | 215 B | 1,418 B |
findFirstEven: |
69 B | 98 B | — |
Sail JSON ≈ 1.45× the .st source, ≈ 6.6× smaller than devel JS.
Extrapolated: the whole image's code as sail ≈ 700 KB (vs 3.22 MB), the
deploy set ≈ 290 KB (vs 1.34 MB) — before gzip, which loves sail's repetitive
structure. messageSends need not be stored at all: the loader can derive a
package's selector set by walking the sail arrays (or store it per package as
one deduplicated list, which is cheaper than per-method arrays anyway).
IDE metadata (source, args, comments, protocols) moves to a sidecar file
along the exact boundary excludeIdeData already defines, fetched only when
an IDE asks.
From §5, the boot budget is ~30% parse/exec and ~70% registration. Data-form packages attack both:
JSON.parse at ~2.4 ms/200 KB replaces JS parse+exec at ~72 ms/1.3 MB,
and can run in a worker / be cached in IndexedDB;// installAmberMethodIntoAmberClass, lazy variant
function trampoline (method, klass) {
var fn = function () {
var real = materialize(method, klass); // interpret-mode wrapper or baseline-compiled
installMethodOfJsObject(klass.fn.prototype, method.jsSelector, real);
return real.apply(this, arguments);
};
fn.a$tramp = method; // recognizable to tooling
return fn;
}
This is a call-time-lazy instantiateFn — the model already has the slot.
Combined with §5.1 (prototype insertion kills the copying storm), boot work
becomes: parse JSON + create 112 classes/traits + one cheap closure per
method. Estimated well under 50 ms for today's deploy image, and — more
importantly — it stops scaling with image size: a minimal kernel can be
live while the rest of the image streams in, because a send to a
not-yet-loaded package's method is just a DNU that can await the package.
Don't keep AOT .js and sail as two authoritative formats — they will
drift. The clean end state: sail is the package format (what amberc
emits, what transports commit), and JS is a cache derived from sail
(baseline-compiled at runtime, or ahead-of-time for deploy builds where CSP
forbids new Function — see §7.4). During transition, keep emitting both from
the same IR so nothing regresses.
Because sail already has resolved scope indices and linearized chains, a
sail→JS translator is a few hundred lines of base code with no dependency
on Compiler-*: each chain element maps 1:1 to an expression, blocks map to
closures, ^ maps to the $early throw, flagged sends map to
sendTag/receiver-return. Strategy:
new Function the baseline JS and
swap it onto the prototype;The 92× figure from §6.5 is the cost of not doing this for hot code; the 1.4× figure shows how little it matters for cold/exceptional paths.
Compiler-Interpreter today: needs Compiler-AST/Core/Semantic loaded,
re-parses source: per method, recovers the PC by re-walking the AST counting
sendIdx occurrences, stepInto unimplemented, impossible on stripped
images. A sail interpreter is a debugger substrate: contexts, receivers,
temps, and the current node are its ordinary working state; stepInto is
natural (descend instead of send); and it works on exactly the artifact that
was deployed, because sail is the deployed artifact. The $ctx scaffolding
(36% of every devel file, plus per-call runtime cost) then has a
deletion path: run-code-you-want-to-debug interpreted instead of instrumenting
all compiled code forever.
inlineJS: methods — sail can't express them; they live in a small per-
package .js companion (or the JSON carries the JS string and non-CSP
environments Function it; CSP environments need the companion file).imports: — stays a loader concern; the JSON manifest lists them, the
loader resolves them (today's $pkg.isReady promise moves into the loader).define()
array; this is also the exit ramp from AMD, since data packages have no
module format at all. Only the ~10 hand-written base files still need a
module story (ESM with an AMD shim during transition).this-loss; §6.4 NLR) —
they're prerequisites for anything sail-shaped, and small.selectorsAdded/copySuperclass machinery.es6-promise (kernel-checks already asserts native
Promise), pegjs→peggy, jshint→eslint, honest engines field.IRSailTranslator beside
IRJSTranslator) + JSON package loader in base + trampoline-lazy install
(§7.2). Keep AOT .js emission until parity is proven.base (§7.4) for steady-state speed.Compiler-Interpreter's re-parse/PC-reconstruction machinery.base; packages are data by then, so AMD dies quietly.this) whenever IRJSTranslator is next touched.Leave alone: the detached-root/native-prototype design (it is Amber), brikz (obscure but small and correct — document it instead), the trait engine.
Three throwaway Node scripts (run from a session scratchpad, not committed):
baseUrl at the repo root and
the amber/amber/core path mapping; wraps Object.defineProperty and the
api.addMethod/addClass/addTrait/method functions between loading
amber/boot and amber/deploy; times kernel load / package load+exec /
amber.initialize(); then censuses api.traitsOrClasses and
api.allSelectors(). Also times JSON.parse on a sail-shaped blob sized to
the deploy set's .st sources.Object.setPrototypeOf,
verifies native methods still work, times the insertion and 3M sends through
the inserted layer.lang/base/kernel-interpreter.js via a
20-line AMD shim, drives it with a minimal facade
(sendFn switch + runBlock), runs the semantics/defect tests of §6,
the fixed engine of §6.4 with its NLR test battery, the size measurements of
§7.1 (extracting the real printOn: emission from Kernel-Objects.js for
comparison), and the §6.5 benchmarks.