6.6 KiB
Yep — what you’re describing is exactly how fast JS engines make “normal-looking arrays” fast: an array carries an internal “elements kind”, e.g. “all int32”, “all doubles”, “boxed values”, and it transitions to a more general representation the moment you store something that doesn’t fit. V8 literally documents this “elements kinds” + transitions model (packed smi → packed double → packed elements, plus “holey” variants). (V8)
1) “Numbers-only until polluted” arrays: do it — but keep it brutally simple
For CellScript, you can get most of the win with just three internal kinds:
- PACKED_I32 (or “fit int” if you want)
- PACKED_F64
- PACKED_VALUE (boxed JSValue)
Rules:
- On write, if kind is I32 and value is i32 → store unboxed.
- If value is non-i32 number → upgrade whole backing store to F64.
- If value is non-number → upgrade to VALUE.
- Never downgrade (keep it one-way like V8 does). (V8)
Extra credit that matters more than it sounds: forbid sparse arrays (or define them away). If an out-of-range write extends the array, fill with null so the storage remains dense. That keeps iteration tight and avoids “holey” variants (which are a real perf cliff in engines). (V8)
Your standard library already nudges toward dense arrays (constructors like array(n) fill with null).
2) Your “fast property op assuming data properties only” is the biggest bang
Since you’ve banned Proxy and all descriptor/accessor machinery , you can add VM ops that assume the world is sane:
GET_PROP_PLAINSET_PROP_PLAIN
Then slap an inline cache (IC) on them: cache (shape_id, slot_offset) for a given property name/key. On hit → direct load/store by offset. On miss → slow path resolves, updates cache.
This is not hypothetical: QuickJS forks have pursued this; QuickJS-NG had discussion of polymorphic inline caches (PolyIC) with reported big wins in some forks. (GitHub)
Even if you keep “objects are fully dynamic”, ICs still work great because most call sites are monomorphic in practice.
3) “What else should I remove to make JITing easier + faster?”
The best deletions are the ones that eliminate invalidation (stuff that forces “anything could change”):
- Prototype mutation (you already forbid it;
memecreates, “prototypes cannot be changed”). - Accessors / defineProperty / descriptors (you already forbid it).
- Proxy / Reflect (already gone).
- Property enumeration order guarantees — and you already don’t guarantee key order for
array(object). That’s secretly huge: you can store properties in whatever layout is fastest (hash + compact slots) without “insertion order” bookkeeping. - Sparse arrays / hole semantics (if you delete this, your array JIT story becomes way easier).
Stuff that’s less important than people think:
- Keeping
deleteas a keyword is fine if you implement it in a JIT-friendly way (next point).
4) You can keep delete without wrecking shapes: make it a “logical delete”
If you want “obj[k] = null deletes it”, you can implement deletion as:
- keep the slot/offset stable
- store null and mark the property as “absent for enumeration / membership”
So the shape doesn’t thrash and cached offsets stay valid. delete obj[k] becomes the same thing.
That’s the trick: you keep the semantics of deletion, but avoid the worst-case performance behavior (shape churn) that makes JITs sad.
5) What assumptions do “meme + immutable prototypes” unlock?
Two big ones:
- Prototype chain links never change, so once you’ve specialized a load, you don’t need “prototype changed” invalidation machinery.
- If your prototypes are usually stone (module exports from
use()are stone) , then prototype contents don’t change either. That means caching “property X lives on prototype P at offset Y” is stable forever.
In a JIT or even in an interpreter with ICs, you can:
- guard receiver shape once per loop (or hoist it)
- do direct loads either from receiver or a known prototype object
6) What do stone and def buy you, concretely?
stone(value) is a promise: “no more mutations, deep.” That unlocks:
- hoisting shape checks out of loops (because the receiver won’t change shape mid-loop)
- for stone arrays: no push/pop → stable length + stable element kind
- for stone objects: stable slot layout; you can treat them like read-only structs when the key is known
But: stone doesn’t magically remove the need to identify which layout you’re looking at. If the receiver is not a compile-time constant, you still need some guard (shape id or pointer class id). The win is you can often make that guard once, then blast through the loop.
def is about bindings, not object mutability:
- a
defglobal / module binding can be constant-folded and inlined - a
defthat holds akey()capability makesobj[that_key]an excellent JIT target: the key identity is constant, so the lookup can be cached very aggressively.
7) LuaJIT comparison: what it’s doing, and where you could beat it
LuaJIT is fast largely because it’s a tracing JIT: it records a hot path, emits IR, and inserts guards that bail out if assumptions break. (GitHub) Tables also have a split array part + hash part representation, which is why “array-ish” use is fast. (Percona Community)
Could CellScript beat LuaJIT? Not as an interpreter. But with:
- unboxed dense arrays (like above),
- plain-data-property ICs,
- immutable prototypes,
- plus either a trace JIT or a simple baseline JIT…
…you can absolutely be in “LuaJIT-ish” territory for the patterns you care about (actors + data + tight array loops). The big JS engines are still monsters in general-purpose optimization, but your constraints are real leverage if you cash them in at the VM/JIT level.
If you implement only two performance features this year: (1) dense unboxed arrays with one-way kind transitions, and (2) inline-cached GET/SET for plain properties. Everything else is garnish.