This site describes solve-engine as it is on main: 2.43.0, which npm does not have yet. npm installs 2.40.0, so a page may show an answer that version does not give yet.
Performance
The engine is built to run on every keystroke, which sets the performance bar.
Throughput
Section titled “Throughput”Throughput is how much text the engine gets through each second, counting the whole journey from raw characters to computed results: lexing, normalising, parsing, compiling to bytecode and executing it. It runs over a document in a single pass, so the figure that matters for an editor is how many lines of a real document it clears per second.
| Document | Cold, first parse | Warm, re-parse |
|---|---|---|
| 100 lines | 29,212 lines/sec | 396,980 lines/sec |
| 1,000 lines | 120,142 lines/sec | 390,069 lines/sec |
| 10,000 lines | 119,071 lines/sec | 261,755 lines/sec |
| 50,000 lines | 115,416 lines/sec | 204,748 lines/sec |
Cold is a fresh engine meeting the document for the first time; warm is the same engine re-parsing it with its bytecode already cached. Within a single expression the pipeline divides as lex 11.5%, normalise 33.2%, parse and compile 30.8%, and execute 24.6%.
Measured on AMD EPYC 9V45 96-Core Processor, Node v22.23.2 on Linux. Throughput depends on the hardware and on what else the machine is doing at the time, so treat these as indicative; reproduce them with npm run bench.
The per-line rate rises from the small document to the medium one, because the fixed cost of standing an engine up is spread across more lines. Every line of the benchmark document evaluates: each one that reads a variable reads one assigned above it, so the figure is the rate of a document that works, not of one that fails on an undefined name.
The number to take from the table is its size: an engine meant to run on every keystroke keeps pace with a document far larger than anyone types into one, and re-parsing an unchanged document is quicker still, since its bytecode is already compiled. The sections below are why.
Bytecode, not tree walking
Section titled “Bytecode, not tree walking”Expressions compile to a compact bytecode and run on a stack machine. Compiling once and executing many times is much faster than re-walking a syntax tree, and it makes the execution step easy to bound.
Caching, in layers
Section titled “Caching, in layers”Compiled bytecode is cached by expression text, so an unchanged line skips
lexing, normalising, parsing and compiling entirely, on every entry point. Line
results are cached separately, but only by a long-lived incremental evaluator
(see Incremental re-evaluation below): there an
unchanged line off screen skips execution too, while parseDocument executes
every line on every call.
A line that does not parse is remembered as well. A line being typed does not parse for most of its life, so its next evaluation skips the lexing, the normalising and the throw, and re-runs only the forms that read other lines’ values. The memory is cleared with the compiled caches whenever a package or a user-defined unit changes what a line means.
The compiled caches are bounded and, when full, drop the entry that has gone
longest without being used. The bound is performance.defaultCacheSize (2,000
entries by default) plus the line count of the open document, so a whole
document always fits: a pass visits its lines in the same order every time, and
a cache smaller than the document would drop each line just before the next
pass wanted it, so that a warm pass over 10,000 distinct lines compiled every
one of them again. The lines a document re-evaluates on every keystroke stay
compiled while one-off expressions cycle out of the room on top. The cost is a
compiled program per distinct line while the document is open (about 3.4 MB more
at 10,000 lines than the old fixed bound held); clear() gives it back, and
with no document open the bound is defaultCacheSize alone.
Incremental re-evaluation
Section titled “Incremental re-evaluation”Which lines run after an edit depends on the entry point:
parseDocumentruns every line on each call, and skips only the compile stages for text it has seen before.evaluateDocumentis a one-off pass: it builds a fresh evaluator for the call, runs every line, and discards the evaluator.- A long-lived
ThreeTierEvaluator, told about each edit and given the range of lines on screen (the viewport), runs the edited line, the lines above the viewport that transitively depend on it through the dependency graph, and the visible lines. An unchanged line above the viewport is not run at all, and the lines below it wait until they are scrolled into view.
Measured over a 1,000-line note in which every line calls a counting function, after editing only its last line:
| Path | Lines executed |
|---|---|
parseDocument, same engine as the first pass | 1,000 |
evaluateDocument | 1,000 |
ThreeTierEvaluator, evaluating lines 1 to 1,000 | 1,000 |
ThreeTierEvaluator, evaluating the viewport, lines 981 to 1,000 | 20 |
The boundary: an evaluator asked for the whole document runs the whole
document. A variable’s value depends on where in the note it is read, so the
lines are run in order from the top, and the only lines that can be left alone
are the ones nobody is looking at. Making parseDocument skip unchanged lines
is not proposed, for the same reason.
Telling the evaluator about an edit
Section titled “Telling the evaluator about an edit”An evaluator hears about an edit in one of two ways. DocumentModel.editLine
replaces the text of one line. ThreeTierEvaluator.applyTransaction takes a
list of changes, each a first line, a number of lines to delete and the lines
to insert in their place, which is how most editors describe what happened
(CodeMirror’s change sets, for one).
A change that deletes as many lines as it inserts moves no line: a keystroke
inside a line arrives as “delete this line, insert its new text”. The
evaluator applies such a transaction line by line, in place, exactly as
editLine would: each line keeps its id and its compiled program, and only
the edited lines and whatever reads them run again. The result lists the ids
of the lines whose text changed in edited, with inserted and removed
empty. Any change that alters the line count (an insertion, a deletion, a
paste of a different number of lines) makes the whole transaction structural:
the lines below move, and each is followed to its new position.
// A keystroke on line 3: an edit in place, no line moves.evaluator.applyTransaction([{ startLine: 3, deleteCount: 1, insertLines: [":v2 = 8"] }]);// Enter pressed at the end of line 3: a structural edit, every line below moves down one.evaluator.applyTransaction([{ startLine: 4, deleteCount: 0, insertLines: [""] }]);evaluator.evaluate({ startLine: 1, endLine: 40 });The difference is the cost, and what survives below the viewport. On a 20,000-line note, a keystroke followed by evaluating the first 40 lines took 33.75 ms as a structural edit and 2.24 ms in place (#713), and a line far below, which did not read the edited one, kept its answer instead of losing it until it was scrolled to again. A host that already sends one-for-one replacements gets this without changing anything.
Two other costs of a long-lived evaluator are linear in the note since 2.42. A
repeat pass over bare assignments (price = 4, with no colon) used to cost the
square of the note, 5,975 ms for a second pass over 10,000 of them on the
machine these figures come from, and costs 212 ms now (#712). A line reading a
name nothing defines, evaluated from a timer callback or an event handler
rather than after an await, cost about 22 microseconds against under one for
2 + 5, and costs about 7 now (#714).
What an evaluator keeps between passes is linear in the note too. It records
which lines read which, so an edit or a live value reaches every line that
depends on it, and a running total used to record one entry for every line it
read: a ledger with total above after each entry held the square of its
length, and 3,000 such lines through evaluateDocument kept over 500 MB. A
span is one entry now, a tag total one entry on its tag and a section total
one on its block, and the same note keeps under 10 MB (#733). total by tag
is worked out once for all the breakdown lines that ask it of the same figures,
so 500 of them cost about what one does (#734).
Off the main thread
Section titled “Off the main thread”The caches keep a re-parse cheaper than a first parse. They do not move the first parse of a large document off the thread that asked for it, and on every keystroke that first parse, or a paste, is where jank comes from.
solve-engine/worker wraps the core evaluate methods behind a postMessage
boundary, so a host can run them on a Web Worker or a Node worker_threads
thread without hand-rolling the protocol. Results cross as a serialisable DTO,
never as a live Value, which carries BigInt, matrix objects and exact-decimal
sidecars that structured cloning cannot reproduce.
import { createWorkerEngine, eventTargetTransport } from "solve-engine/worker";
const worker = new Worker(new URL("./engine.worker.ts", import.meta.url), { type: "module" });const engine = await createWorkerEngine({ transport: eventTargetTransport(worker) });
const result = await engine.parseDocument(text);for (const line of result.lines) { if (line.result) render(line.lineNumber, line.result.text);}The worker entry is two lines: adapt the worker’s own global onto a transport and start the runtime.
import { startWorkerRuntime, eventTargetTransport } from "solve-engine/worker";startWorkerRuntime(eventTargetTransport(self));eventTargetTransport takes anything with the EventTargetLike shape: a DOM
Worker, a worker’s self and a MessagePort all fit it under strict, and
the type is exported for a host adapting something else. The Node equivalent is
messagePortTransport, for a worker_threads Worker or parentPort
(MessagePortLike).
A result that is an error carries its code in errorCode as well as its
message in text, so a host tells 5 kg in m (INCOMPATIBLE_UNITS) from any
other failure without reading the words:
const value = await engine.evaluateExpression("5 kg in m");value.errorCode; // "INCOMPATIBLE_UNITS"value.text; // "a mass cannot be converted to a length"A value that is not an error has no errorCode key at all.
Offloaded background compilation
Section titled “Offloaded background compilation”There is a second, narrower use of a worker inside the engine itself. While you read a document, the evaluator compiles the lines just past the viewport ahead of time, so scrolling does not pay for it. That compilation moves to a worker when there is one.
Running lines stays on the main thread. When live data lands and more than fifty
lines depend on it, the engine used to send their compiled bytecode to the same
workers, which hold none of the document’s variables or packages, so a line such
as price * qty came back as “Undefined variable: price”. A re-run now happens
on the main thread however many lines it touches, with the same answers as a
host that registers no factory.
There is no way for a library to make a worker on its own: the file a worker runs has to be a URL the host’s bundler produced, and every bundler spells that differently. So the engine asks. Register a factory once and the compile path uses it.
import { setEngineWorkerFactory } from "solve-engine";
setEngineWorkerFactory(() => new Worker(new URL("solve-engine/engine-worker", import.meta.url), { type: "module" }),);solve-engine/engine-worker is a bundle of its own, and it carries every
built-in package. That matters more than it sounds: a compile worker with no
vocabulary refuses every line a package gives meaning to, which is most lines,
and each of those falls back to the main thread having gained nothing. Being a
separate bundle is also what keeps it out of your own: a host that registers no
factory ships neither the worker nor the vocabulary it registers.
Registering nothing is a supported state, not a degraded one. Every published build behaved this way until this option existed, and the fallbacks are the paths that were always taken: compilation happens on the main thread when the line is next evaluated, which is where it happened anyway.
This does not replace solve-engine/worker above, and the two are unrelated.
That entry moves your calls off the thread; this one lets the engine get
ahead of itself on work you never asked for directly.
An AbortSignal on a call rejects the promise and aborts the work on the other
side, so a superseded keystroke does not race a stale result home.
const result = await engine.parseDocument(text, { signal });Packages cross as names, not objects, because a package carries functions that
postMessage cannot clone. createWorkerEngine({ packages: ["solve-arithmetic", "solve-uom"] }) selects among the built-ins the worker already bundles; a custom package is
baked into the worker entry via startWorkerRuntime(transport, { packages })
and selected by name from the main side.
Live values that resolve later
Section titled “Live values that resolve later”A currency, weather or historical-rate line comes back pending: the value is not
in hand when parseDocument returns. It resolves inside the worker some time
later, and onResolved is how that value reaches the main thread. It is a
subscription, not a per-call promise, because a resolution belongs to whichever
document is current when the value lands, not to one request.
const stop = engine.onResolved((lines) => { for (const { lineNumber, value } of lines) render(lineNumber, value.text);});
const result = await engine.parseDocument(text);// result.lines[n].result may be a pending DTO; onResolved delivers the live// value once it settles. `lines` is a batch, since resolutions that land in one// tick arrive together. Call stop() to unsubscribe.A failed resolution arrives through onAsyncError with the same structured
EngineError an in-process failure would surface. Both subscriptions track the
current document: parsing a new document supersedes the old one, and a value
still resolving for the superseded document is dropped rather than delivered
against the new one.
engine.onAsyncError(({ queryKey, packageId, error }) => { console.warn(`${packageId} could not resolve ${queryKey}: ${error.message}`);});Every host call, off the thread
Section titled “Every host call, off the thread”The worker client is not limited to evaluating. parseDocument is the batch
pass, which cannot re-run a line and so refuses goal seek; a host that moved its
evaluation to a worker used to lose goal seek, and kept a second engine on the
main thread for everything else. The client now proxies the calls a document’s
features rely on, each answering a clone-safe object (a DTO, data with no
functions or class instances in it, so it survives postMessage and JSON):
| Call | Mirrors | Answers |
|---|---|---|
evaluateDocument(text) | the evaluateDocument helper | the document through the incremental pass, goal seek included |
whatIf(text, overrides) | engine.whatIf | the document re-run with inputs changed |
explainLine(expression) | engine.explainLine | the steps and the result |
traceLine(text, line, { maxDepth, maxLines }) | engine.traceLine | the line’s inputs, each traced the same way |
settle({ timeoutMs }) | engine.settle | once no live value is in flight |
getSemanticTokens, getCompletions | LanguageService | highlighting and completions |
findReferences, getDefinition, rename, shiftLineReferences | LanguageService | positions and edits, refusals included |
const doc = await engine.evaluateDocument( ":deposit = 100000\n:rate = 4%\nmonthly repayment on deposit over 25 years at rate\nsolve line 3 for deposit = 900",);doc.lines[3].result?.text; // "= 170,507.23"Through the worker, evaluateDocument agrees with the main thread’s value for
value, and parseDocument still refuses goal seek with GOAL_SEEK_NO_DOCUMENT,
as the batch pass does in process.
A what-if’s overrides cross as a finite number or as text the worker evaluates
on its own ({ price: "$120" }); a Value cannot keep its type across
postMessage, so send its text. An argument postMessage cannot copy at all (a
function, a symbol) is refused before anything is sent, with
WORKER_ARGUMENT_NOT_CLONEABLE, rather than surfacing as a raw DataCloneError.
A trace reads the document given; when it is the text the worker evaluated last,
it reads those answers rather than evaluating again.
The boundary: an AbortSignal rejects the caller’s promise at once, but a goal
seek already running in the worker runs to the end, since the worker handles one
message at a time; its answer is then dropped. Packages still cross as names, and
the worker’s language service reads the worker’s own engine, so a package the
worker entry does not bake in is not highlighted there either.
Nothing about the synchronous API changes. solve-engine/worker is a separate,
side-effect-free entry point, so a host that never imports it pays nothing.
Bundle size, and tree-shaking
Section titled “Bundle size, and tree-shaking”Load-time performance is parse time, and parse time is bytes: before the first
line runs, a browser has to download the engine’s JavaScript and parse it. The
engine ships minified with sideEffects: false, and it registers only the
packages you give it. Together those are meant to let a bundler tree-shake the
build, which means leaving out the code nothing in your program refers to, such
as the built-in packages you never import.
Two measured figures bound what an import costs. Both come from bundling the engine for real on every commit with size-limit, which bundles with rolldown and compresses the result with brotli, as a web server would send it, and both are read here from the same data the home page quotes rather than typed in:
| Import | Minified and compressed |
|---|---|
import { ExpressionEngine }, the core with no package registered | 179 kB |
the whole root entry, close to what createEngine() pulls in | 278 kB |
An engine constructed with a handful of packages, such as
new ExpressionEngine({ packages: [ARITHMETIC_PACKAGE] }), lands between the
two. Each built-in package is built into files of its own, so a bundler can
leave out every package nothing imports. That matters most under esbuild, which
Obsidian plugins build with: esbuild leaves out whole files and keeps
everything inside a file it keeps, so while the built-ins shared one file, an
arithmetic-only engine bundled to nearly the size of createEngine(). The same
two programs bundled with esbuild, minified and compressed with brotli, measured
on every commit alongside the figures above:
| Bundled with esbuild | Minified and compressed |
|---|---|
createEngine() | 277 kB |
new ExpressionEngine({ packages: [ARITHMETIC_PACKAGE] }) | 181 kB |
The boundary: the shared core stays shared. The lexer, the parser, the VM and
the unit table are in every engine, so a slim engine is never much smaller than
the core with no package registered. createEngine stays the plain choice when
bundle size is not the concern. Each syntax page names
the package its feature needs, so a slim build registers exactly what it uses.
See choosing packages.
What to avoid
Section titled “What to avoid”Constructing a new engine per evaluation throws away every cache and re-registers
every package. Create one engine and call clear() between documents.
Enabling diagnostics collects a large amount of per-stage detail. It is intended for a devtool and should be off in production.