Skip to content

✨ feat(mq-lang): add stack-based bytecode VM behind tarn feature - #2267

Open
harehare wants to merge 63 commits into
mainfrom
feat/tarn-bytecode-vm
Open

✨ feat(mq-lang): add stack-based bytecode VM behind tarn feature#2267
harehare wants to merge 63 commits into
mainfrom
feat/tarn-bytecode-vm

Conversation

@harehare

@harehare harehare commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Introduces "Tarn", a stack-based bytecode VM with static compile-time variable-slot resolution, as an alternative execution backend to the tree-walking Evaluator.

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • ♻️ Refactor
  • 📝 Documentation
  • ⚡ Performance
  • ✅ Test
  • 📦 Build / dependencies
  • 👷 CI

Checklist

  • I ran cargo fmt and cargo clippy and addressed any warnings
  • I ran just test-all and all tests pass
  • I added or updated tests covering this change
  • I updated relevant documentation (/docs, crate README.md) if needed
  • I added a changelog entry if this is a user-facing change

Additional Context

#1146

@codspeed-hq

codspeed-hq Bot commented Aug 29, 2026

Copy link
Copy Markdown

Merging this PR will regress 2 benchmarks

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 21 improved benchmarks
❌ 2 regressed benchmarks
✅ 6 untouched benchmarks
🆕 16 new benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
eval_string_interpolation 110.4 µs 149.8 µs -26.33%
eval_select_h 394.1 µs 472.5 µs -16.58%
eval_qualified_access_to_csv_module 26.2 ms 2.7 ms ×9.7
eval_while_speed_test 27.4 ms 7.3 ms ×3.7
eval_fibonacci 88.6 ms 29.2 ms ×3
eval_variable_assignment_chain 1,312.3 µs 528.4 µs ×2.5
eval_if_else_branching 4.9 ms 2.6 ms +84.97%
eval_object_field_access 3.3 ms 1.9 ms +75.42%
eval_array_fold 2.6 ms 1.5 ms +72.33%
eval_nested_function_calls 1,493 µs 977.3 µs +52.77%
eval_array_filter 6.5 ms 4.3 ms +50.5%
eval_array_chained_operations 5.6 ms 3.9 ms +45.99%
eval_nested_object_access 2.3 ms 1.6 ms +44.27%
eval_foreach 2.5 ms 1.7 ms +43.39%
eval_long_pipeline 3.6 ms 2.6 ms +35.03%
eval_pipeline_with_conditionals 2.6 ms 1.9 ms +32.71%
eval_array_map 3.6 ms 2.8 ms +29.36%
eval_yaml_parse 3.8 ms 3 ms +26.1%
eval_csv_parse 3.6 ms 2.9 ms +23.04%
eval_json_parse 2.7 ms 2.3 ms +20.67%
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing feat/tarn-bytecode-vm (d1a4b8d) with main (f3ed711)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (c4a6e48) during the generation of this report, so f3ed711 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@harehare
harehare force-pushed the feat/tarn-bytecode-vm branch from 2ed8456 to 21dde6d Compare September 1, 2026 14:21
Introduces "Tarn", a stack-based bytecode VM with static compile-time
variable-slot resolution, as an alternative execution backend to the
tree-walking Evaluator.
… tarn VM

- Replay Engine-loaded include/import modules before VM compilation so
  their exports stay resolvable, including across a `nodes` split.
- Let `break` cross a try/catch boundary via a new FlowBreak opcode
  instead of rejecting it as a compile error; `continue` still can't.
- Track local/upvalue names per chunk and sync them into the legacy
  dynamic env before get_variable/set_variable, so those builtins keep
  working against VM-compiled frames.
- Carry per-module debug sources through the VM debugger hook so
  breakpoints in included/imported modules show the right source.
- Strip a trailing .mq when checking trackable modules in coverage.
Adds OpCode::FlowContinue and a TryCatch continue_offset so a
`continue` inside a nested try/catch chunk now resumes the enclosing
while/foreach loop instead of failing to compile.

Also extracts bind_params's arity check into
parameter_uses_implicit_self and bundles the runtime services it
needs into a ParameterContext, and rewrites the arity/continue tests
as rstest cases plus a proptest covering arithmetic, closures, and
foreach semantics.
Cache module-free CompiledProgram bytecode on the CompiledProgram so
repeated eval_compiled calls skip recompilation, and pool local cell
Vecs across calls/try-catch frames instead of allocating fresh ones
each time. Also add bench-tree/bench-vm just recipes and a compiled
fibonacci benchmark for tree-walker vs VM comparison.
Continues the tarn VM performance work: profiled the workloads still
slower than the tree-walker with sample against debug-symbol release
builds, and fixed each root cause found rather than guessing.

- call_fixed_closure_from_stack no longer double-clones the caller's
  self value on every fixed-arity call.
- Split several rare/large OpCode arms (try/catch, array spread,
  type-check, selector matching, misc array ops, string
  interpolation, external globals) out of run_chunk_inner_impl into
  their own functions. Besides shrinking the hot dispatch loop for
  icache locality, this incidentally fixes a real bug: in debug
  builds the interpreter's per-call native stack frame was large
  enough that the configured recursion-depth limit had essentially no
  safety margin before a native stack overflow, crashing instead of
  raising a catchable RecursionError. selector_op is deliberately not
  marked cold, since markdown selector matching is mq's primary
  workload, not a rare path.
- Engine::load_builtin_module no longer populates the tree-walker's
  Env under the tarn feature; the VM compiles its own prelude
  independently and never reads it. Kept the builtin.mq parse itself
  (not just the more expensive Env population) so the global string
  interner's identifier order stays stable, since Ident's Ord (and
  therefore RuntimeValue::Dict's BTreeMap key order) currently depends
  on interning history.
- get/attr/set_children builtins, and the VM's own ArrayGetLocalAt
  fast path, no longer take the copy-on-write make_mut path for what
  are actually read-only operations, which was deep-cloning shared
  dicts/arrays just to read one entry.
- Fixed a real compiler bug: no def-declaring path ever marked its
  slot immutable, so compile_call's fast CallLocal path (which
  requires an immutable local) never triggered for calls to top-level
  functions from outside their own body, silently falling back to the
  slower dynamic CallValue path (including an O(n) stack shift) for
  what should be the common case.
- Ident::new tries a read-lock-only interner lookup before falling
  back to the write-locked get_or_intern, avoiding an unnecessary
  exclusive lock on repeat lookups of an already-interned string.

Verified throughout: full nextest suite on both engines and
--all-features, just test-mq-vm, workspace check, and clippy/fmt
across all three feature combinations.
ExecutionLimits::take_locals_with_initialized_prefix linearly scanned
ExecutionPools::local_pool (a single Vec<Vec<Cell>>, capped at 32
mixed-length entries) for one matching local count. Rebucket into a
FxHashMap<u16, Vec<Vec<Cell>>> keyed by local count, capped per
bucket, turning the lookup into a hashmap access instead of a scan
and scaling to programs with many distinct chunk shapes cycling
through the pool.
build-bench now passes --features tarn, so the benchmarks CI tracks
via CodSpeed measure the VM backend instead of the tree-walker.
Most #[allow(dead_code)] markers in the VM debugger-adapter code
carried comments saying they were waiting on Engine wiring in a
future milestone, but engine.rs's eval_compiled_vm already calls into
compile_and_run_debugged_many, which already drives a real
VmDebuggerHook, under the debugger feature. The wiring had landed;
the markers were just never cleaned up. Removed the now-inaccurate
ones and narrowed the remaining, genuinely feature-dependent ones to
precise #[cfg_attr(...)] conditions instead of blanket allows.

Removing VmDebuggerHook's blanket allow surfaced a real unused field:
module_loader was stored but never read, since eval_expression always
builds its own fixed StdModuleResolver-based loader inline by design.
Dropped the field and the R: ModuleResolver generic parameter it was
the only user of, simplifying VmDebuggerHook::new's signature.

Verified clean (no warnings) with clippy -D warnings across
--features tarn, --all-features, and default.
Engine::eval_debug_expression replaces switch_env for evaluating an
ad-hoc expression against a paused frame: under tarn it compiles the
expression with the frame's live bindings predeclared as slots, since
the VM never reads a dynamic Env. mq-dap gains an optional tarn
feature and its Evaluate-request handler now uses the new API.
… self

Adds differential (VM-vs-tree-walker) and property-based cases for
3-level closure nesting, sibling/shadowed let scopes, var mutation
visible to a closure, and implicit self combined with variadics.

Also documents a real, non-VM-specific finding: foreach's loop
variable is not freshly captured per iteration, so closures created
in the loop body all share the final value once called afterward -
confirmed identical on the tree-walker via a differential test.
Closes the gap on function-call and loop-heavy benchmarks that ran
slower on the tarn VM than the tree-walker:

- Index the local-frame pool by slot count instead of hashing it.
- Memoize Chunk::captures_local_slots instead of rescanning the
  chunk's code on every call.
- Stop round-tripping every statement's value through
  SetLocal(SELF_SLOT)/GetLocal(SELF_SLOT) in compile_body and
  compile_top_level; the last statement's value is only needed on
  the stack. while/until loops re-sync self explicitly since their
  condition re-reads it next iteration.
…spection

Introduces a debug-trace feature flag exposing Tarn VM internals for
mq-dbg diagnosis: bytecode dumps (--dump-bytecode) and operand-stack
snapshots at debugger stops (--dump-stack, "stack" command).
compile_program_impl walked the whole program to build
module_function_roots on every compile, even though it's only read
when a module is actually loaded. Most one-shot Engine::eval() calls
load none, so this was pure overhead on the VM's compile path that
the tree-walker has no equivalent of.

Cuts the VM/tree-walker gap on eval_function_call_overhead (33% ->
17%) and eval_large_program (17% -> 11%) benchmarks; eval_nodes moves
from a regression to parity.
Adds one-shot and compiled (cached-bytecode) benchmark pairs for the
section and table modules, matching the existing csv/json/yaml
pattern, so regressions in these real-world-heavy modules show up in
the CodSpeed VM benchmark suite going forward.

The compiled variants pass a single pre-aggregated array input rather
than using `nodes`, since `nodes` currently disqualifies the VM's
compiled-bytecode cache.
CachedProgram held one flat program and ran it once per input, which
can't express nodes' two-phase model (per-input, then once against
the aggregated array) — so any query containing nodes recompiled from
scratch on every eval_compiled call, same as a one-shot eval().

CachedProgram now optionally holds a second compiled program for the
post-nodes half, reusing the same split_at_nodes/aggregate logic the
non-cached path already had. Cuts eval_compiled_nodes from one-shot
cost (~60us) to ~25us.
Tracks the nodes compiled-bytecode-cache fix directly, and switches
the section/table compiled benchmarks from an artificial
pre-aggregated-array input to real `nodes | ...` now that it's
cacheable, matching realistic per-node usage.
RuntimeValue::VmClosure embedded VmClosureValue (~64 bytes) inline,
so the whole shared enum sized itself to the largest variant: 32
bytes without tarn, 64 with it. Every clone/move in the VM paid for
that under tarn.

Shared-wrapping it (matching Array/Dict's existing clone-on-write
pattern) brings it back to 32 bytes. Turns
eval_compiled_function_call_overhead from a regression into a VM win
(87us vs the tree-walker's 104us) and roughly doubles
eval_compiled_nested_function_calls.
compile_reachable_builtin_prelude rebuilt a name->definition map and
re-walked every reachable builtin function's body on each compile, even
though builtin.mq never changes within a process. Cache the per-function
soft-builtin call graph in a thread-local, keyed by the cached module's
identity, so repeated one-shot Engine::eval calls only pay for the graph
walk once.
…16 bytes

Vec<OpCode> stores every instruction inline, so the enum's size is fixed
to its largest variant's — Rust's usual tradeoff for avoiding a separate
byte-tag encoding layer. SelectorMatch/SelectorMatchWithArgs's inline
Selector (32 bytes) and MakeClosure's inline (u16, Vec<UpvalueSource>)
pushed every OpCode to 40 bytes, even for zero-payload instructions like
Pop or Dup.

Box the heaviest fields (SelectorMatch, SelectorMatchWithArgs, and the
new TryCatchInfo for TryCatch) so each variant carries at most a pointer
plus a small tag, dropping size_of::<OpCode>() to 16 bytes.

Also adds a peephole-optimizer test covering TryCatch's break/continue
offsets surviving dead-code removal, and a regression guard on
OpCode's size.
import "mod" as m qualified functions (m::helper) but left top-level
let/var bindings unqualified and, worse, reachable bare (base) from
the importing scope — unlike the tree-walker, which correctly rejects
the bare name and requires m::base. compile_import_functions already
threaded an alias through for functions; compile_module_vars_binding
now does the same for vars, registering them in qualified_bindings and
hiding the bare name, for both top-level import and import nested
inside an inline `module ... end` block. include is unaffected: its
whole point is direct, unqualified access.

Also introduces QualifiedName/QualifiedSlot in place of the
(Ident, Ident)/(usize, u16) tuples qualified_bindings used, so alias
vs. name and depth vs. slot aren't just positional.
try_fold_call folded add(x, 0)/mul(x, 1)/etc. to the non-literal
operand whenever the other side was a literal 0/1/"", assuming Number
arithmetic identities hold universally. They don't: add/sub/mul/div
are polymorphic (array concat/repeat, markdown value append, string
coercion, ...), so e.g. add(0, [1, 2]) is [0, 1, 2], not [1, 2], and
[1, 2] * 0 is [], not 0 — both previously folded to the wrong value.

Only the fully-literal fold, where both operands' concrete types are
known, is sound and stays. Replaces the tests asserting the old
behavior (scattered across four spots, several duplicates) with two
focused ones: one confirming these expressions now stay unfolded, and
a regression test reproducing the exact array cases that broke.
Every non-tail pipe stage writes its result to self (or a synthetic
slot) via SetLocal, and the next stage immediately reads it back via
GetLocal — a store-then-reload the existing peephole pass couldn't
touch (it only recognized the opposite GetLocal→SetLocal order). This
pattern isn't specific to any one construct: piped selectors, chained
builtin calls, and non-tail statements inside function bodies all
compile to it whenever the AST optimizer hasn't already merged them.

Add OpCode::TeeLocal(slot), which stores the top of stack into slot
without popping it (matching what the fused pair already computed —
the peephole rewrite mutates the SetLocal in place and drops the
GetLocal, same mechanism as the existing dead-code patterns), and wire
it into the interpreter the same way Dup peeks the stack.
token()/token_include_spaces() tried up to ~40 nom alternatives in
sequence for every token, with the most common case (identifiers) tried
last. Route on the input's first character instead, calling only the
parsers that can plausibly match, in the same relative priority the old
exhaustive alt() used. The old alt() stays as token_slow(), the
fallback for anything not in the dispatch table.

A differential test suite checks the fast path against token_slow() on
every tracked .mq file, curated edge cases, and randomized ASCII input,
which caught a real ordering bug during development (number_literal
also accepts a leading '+', so `+0` must not lex as Plus).
Cross-referenced every reference doc under docs/books/src/reference
against the existing integration/tarn/property-based test suites.
Coverage was already extensive, but these documented behaviors had
zero cases (verified manually against both the tree-walker and the
tarn VM before writing each one):

- `!~` (not-regex-match) operator
- `?` error-suppression operator
- `..` as an operator (not just the `range()` builtin): ascending,
  descending, single-element, and character ranges
- Property selector (`."key"`) mapping over an array of dicts, with
  non-dict elements producing None
- Selector calls with multiple depths (`.h(2, 3)`) and a range
  argument (`.h(1..3)`)
- The `:markdown` type pattern in `match`
'a'..'e' isn't valid mq syntax — there's no single-quoted char
literal, only double-quoted strings. Confirmed "a".."e" is what the
engine actually accepts and produces the documented ["a", ...] result.
Tarn compiled the two halves of a `nodes` split as independent
programs, so a `def`/`import`/`include`/`module` or `let`/`var`
declared before `nodes` silently resolved to "not defined" after it
(e.g. `def sitemap(...) end | nodes | sitemap(...)`) — the
tree-walker's shared env never had this problem. Static declarations
are now hoisted into the after-program at compile time; `let`/`var`
values are captured from the last per-input run and threaded into the
after-program as predeclared bindings, reusing the debugger's existing
predeclared-slot machinery. Both paths keep the original zero-overhead
code when there's nothing to hoist or capture.

Also consolidates the Engine<->Tarn VM dispatch (bytecode caching,
module-prelude replay, debugger hookup) behind a new `TarnVm` struct
so `Engine::eval_compiled_vm` just builds one and calls `run`, and
adds a cookbook-driven regression suite
(crates/mq-run/tests/cookbook_tests.rs) that runs docs/books/src/cookbook's
examples through both the tree-walker and Tarn.
Add `use crate::{engine, error, tarn}` where each was missing and
shorten the resulting `crate::tarn::X`/`crate::engine::X`/`crate::error::X`
paths to `tarn::X`/`engine::X`/`error::X`.

Also fixes a real bug this surfaced: TarnVm::run's cache-check block
was gated `#[cfg(not(feature = "debugger"))]` instead of
`#[cfg(all(feature = "tarn", not(feature = "debugger")))]`, so a
default build without the `tarn` feature failed to compile
(cargo build -p mq-lang was broken, only ever tested with --features
tarn). Verified default/tarn/tarn+debugger all build, lint, and test
clean.
A query using `import`/`include` couldn't see which builtin.mq
functions the imported module's own bodies would need, so any use of
one (e.g. `import "table" | table::tables()`, whose module calls
`map`/`filter`/`is_empty` etc. internally) tripped the "reachable set
incomplete" fallback and compiled the *entire* ~150-function builtin
prelude into the program's top-level chunk — rebuilding all of those
closures on every execution even though the query only ever calls a
handful of them.

Retry with just the specific names the failed compile reports missing
instead, growing the reachable set by one attempt's worth at a time
(bounded, falling back to the full prelude if it doesn't converge).
For `nodes | import "table" | table::tables()` this drops the
top-level chunk from 197 bound closures to 67, cutting
eval_compiled_table_tables's benchmark time by ~53% and
eval_section_sections's by ~34%, with no change to queries that don't
import a module.
… imports

reachable_module_functions bailed out to compiling every function in a
module whenever that module had any top-level import/include/var,
because a var initializer could call a function that reachability
analysis missed. Nested import directives never call back into the
module's own functions though, so that half of the guard was needless;
scanning the module's own vars for function references closes the real
gap instead.

table.mq's own `import "csv"` was tripping this guard, forcing all ~31
of its functions to compile on every query. Chunk count for a simple
`table::tables()` query drops from 113 to 35, and
eval_compiled_table_tables's mean benchmark time drops from ~810us to
~360us.
RuntimeValue::String(String) and RuntimeValue::Bytes(Vec<u8>) held
their data inline, so every clone deep-copied the buffer even when the
value was just being passed around unchanged. Array/Dict/Markdown/
Function already avoid this via Shared (Rc/Arc); String and Bytes now
follow the same pattern, with string_mut/bytes_mut added alongside the
existing array_mut/dict_mut/markdown_mut COW helpers for the small
number of builtins that mutate in place.

Every construction site across the workspace needed updating for the
new Shared<String>/Shared<Vec<u8>> payload; most were mechanical, a
handful of mutating builtins were switched to the COW helpers, and a
few by-value extractions now go through Shared::unwrap_or_clone (the
same pattern already used for Array/Dict in to_json_value/to_cbor_value).
RuntimeValue::Function held its params, body, and env as three separate
Shared pointers inline in the enum. That 24-byte payload set the size
of the whole RuntimeValue enum (32 bytes), so even trivial variants
like Number and Boolean paid for moving that much data, and for a
branch in the compiler-generated drop glue, on every clone/drop.

The three fields now live in one FunctionValue struct behind a single
Shared pointer (mirroring VmClosureValue's existing scheme), shrinking
RuntimeValue to 24 bytes and cloning a function value to one refcount
bump instead of three. eval_compiled_fibonacci's mean benchmark time
drops from ~6.76ms to ~4.9ms and eval_while_speed_test's from ~2.24ms
to ~1.72ms.
RuntimeValue::Markdown's Option<Selector> was 16 bytes (a plain usize
index has no spare bit pattern for Option to reuse as its None niche).
Selector::Index now stores index + 1 in a NonZeroUsize, so Option
<Selector> fits in 8 bytes via the niche NonZeroUsize provides.

RuntimeValue::Module(ModuleEnv) held its Ident + Shared<SharedCell
<Env>> inline (16 bytes); it's now Module(Shared<ModuleEnv>), an 8-byte
pointer, cloned as a refcount bump like the other Shared-wrapped
variants.

RuntimeValue is still 24 bytes after this — Markdown's own two fields
(Shared<Node> + Option<Selector>) now tie for the largest variant on
their own, so this doesn't move the overall size by itself. It's a
correct, low-risk step ahead of consolidating Markdown the same way
Function was.
Every GetLocal/SetLocal/TeeLocal in the dispatch loop indexed Locals'
backing Vec with a bounds check, even though bytecode::verify_chunks
already rejects any chunk where one of those opcodes' slots is out of
range for chunk.local_count, and Locals is always sized to exactly
that count. Added get_unchecked/set_unchecked and used them for these
three opcodes plus local_runtime_value's four callers (BinaryLocalLocal,
BinaryLocalConst, ArrayLenLocal, ArrayGetLocalAt) — all covered by the
same verify_chunks check. TeeLocal was missing from that check; added it.

eval_while_speed_test's mean benchmark time drops from ~1.72ms to
~1.53ms.
Locals::get_unchecked/set_unchecked trust verify_chunks to reject any
chunk where a local-slot opcode's slot is out of range. Nothing
covered that directly — add cases for every opcode carrying a local
slot (including param bindings), one per opcode so a future opcode
missing from that match fails its own case instead of hiding inside a
combined assertion. Confirmed the tee_local case fails without the
prior commit's fix by temporarily reverting it and re-running.
Const, GetEnvVar, and BinaryLocalConst all indexed chunk.constants
with a bounds check, though verify_chunks already rejects any chunk
where one of those opcodes' constant index is out of range. Same
treatment as the prior local-slot commit, plus a test confirming
verify_chunks still catches an out-of-bounds index for each of the
three.
The Function-payload refactor introduced Shared::new in the example
but the doctest's use statement didn't pull in the type, breaking
`cargo test --doc`.
Same fallout as the previous mq-lang doctest fix: the Function-payload
refactor's Shared::new example wasn't matched by an import here either.
@harehare
harehare force-pushed the feat/tarn-bytecode-vm branch from 95e51ad to d01a992 Compare September 3, 2026 12:34
Adds a `tarn` fuzz target so the arbitrary-script generator already used by
`interpreter` can also be run against the tarn bytecode VM instead of the
tree-walking evaluator, gated behind mq-lang's `tarn` feature.

Shared script-generation and eval logic moves into fuzz/src/lib.rs so both
targets stay in sync. `just test-fuzz-tarn` runs the new target.
`use core::f64;` pulled the `f64` module into scope, so `f64::NAN` /
`f64::INFINITY` / `f64::EPSILON` resolved to the deprecated free-standing
module constants instead of f64's inherent associated consts.
nom's escaped_transform succeeds with a zero-length match at EOF, so
interpolated_string's `while let Ok(...)` loop kept pushing empty segments
forever for an unterminated `s"..` instead of erroring, exhausting memory.
Found by fuzzing the tarn VM target.
Avoid an always-panics unwrap() by resuming the original panic via
std::panic::resume_unwind, and reformat the tarn target's
compile_error! call.
@harehare
harehare marked this pull request as ready for review September 3, 2026 15:35
Number::rem always went through f64's % operator, which lowers to a
libm fmod call regardless of operand type. Profiling a filter-heavy
workload (map/filter/fold over an array with a % check) showed fmod
at ~14% of samples. Integer-valued operands, by far the common case
for %, now go through i64 remainder instead, which is a single
hardware instruction and gives identical truncated-division results.
String interpolation (both engines) formatted every part through
Display, even string literal fragments and string-valued expression
results, which routes through RuntimeValue's Cow-wrapping fmt impl
and the full Formatter machinery for what's already a &str. Push
those directly instead.

Profiling a string-interpolation-heavy workload showed core::fmt::write
at ~14% of samples; after this change it drops to ~6%, with
Formatter::pad no longer appearing in the profile at all.
Selector::Index held a NonZeroUsize; Function and Module are already
single Shared pointers, so Markdown's two-field payload (Shared<Node>
+ Option<Selector>) was the sole variant keeping RuntimeValue at 24
bytes. Storing the index in a NonZeroU8 instead shrinks Option
<Selector> from 8 bytes to 1, and RuntimeValue from 24 to 16.

This caps indexable children at 254 (get/[i] on a markdown node's
values, e.g. a huge table row or list item beyond that). Selector::
index now returns None instead of panicking when it doesn't fit,
matching how get already treats any other out-of-range index —
RuntimeValue::NONE rather than an error or a crash.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant