Pre-compiled schema programs (Stage 2 design note)
Status: design and measurement spike, 2026-09-10. Nothing in this note is wired into the generators yet; the
measurements come from ProgramImage (in Corvus.Text.Json.RuntimeEvaluator), the image/emit commands of the
runtime-evaluator benchmark harness, the write/image modes of the cold-start harness, and a throw-away "spike"
console project that compiled emitted C# against the evaluator.
Where Stage 0 leaves us
After Stage 0 a generated assembly carries, per compilation, one CorvusJsonSchemaProgram with the schema documents
as UTF-8 data and lazily compiled entry points. At first use the runtime evaluator parses the documents, resolves
references, reads any custom metaschema's $vocabulary, builds the node graph, and constructs the regular expressions.
Stage 2 moves everything except regular-expression construction to generation time, and hands regular expressions to
[GeneratedRegex].
What Stage 2 cannot remove is JIT of the evaluation engine itself, measured earlier at roughly 40 ms floor for the first evaluation in a cold process and about half that with ReadyToRun. That is a consumer publish setting, not something the package can ship.
What a compiled program is
The compiler's output is a plain object graph, SchemaNode[], with:
- per-node summary flags, the fused
NodePlan, the dialect and resource id, and the schema location bytes; - value keywords: a
TypeMask,const/enumvalues, normalised numeric bounds and divisor, string bounds, aPatternMatcher, format and content kinds with their assertion flags; - object keywords: a
Utf8NameMap<PropertyEntry>(with presence bits forrequired,dependentRequired,dependentSchemasanddependencies), pattern properties, additional/unevaluated properties, property names; - array keywords: prefix items, items, contains bounds, unevaluated items;
- in-place applicators: resolved
$reftargets,$dynamicReftables (resourceId -> nodeId),allOf/anyOf/oneOfwith their discriminators and type unions,not,if/then/else; - annotation keywords as raw JSON bytes, and every child edge (
ChildRef) with its evaluation-path segment bytes.
Only one thing in that graph still points at a parsed schema document: ConstantValue, used for deep equality of
object and array const/enum values. Everything else is bytes, integers, and small objects.
Two emitted shapes
A. Program image
ProgramImage serialises the graph to a versioned binary image (varints, length-prefixed byte arrays, a packed bit
set per node) plus one JSON array holding every const/enum value. JsonSchemaEvaluator.FromProgramImage rebuilds
the graph without the loader or compiler; the constants array is parsed once, which is the only parse left.
ToProgramImage records every entry point compiled so far, and ForEntryPoint on an image resolves only those.
Conformance: the entire JSON Schema Test Suite passes with every schema round-tripped through an image (the
ImageSuiteTests class), and all 37 Sourcemeta corpora agree instance for instance between the compiled program and
its image.
Warm, in-process, minimum of 15 rounds, interpreted regexes (compiled regexes are cached process-wide, so warm figures are the same either way):
| Compile | Image load | |
|---|---|---|
| All 37 corpora, total | 45.9 ms | 8.6 ms |
| Allocation, all corpora | 21.4 MB | 10.7 MB |
| Largest (ui5-manifest, 2688 nodes) | 8.2 ms | 1.4 ms |
| Median corpus (deno, 108 nodes) | 0.92 ms | 0.13 ms |
Process-cold, first schema in a fresh process (three runs each):
| First load | Warm | |
|---|---|---|
| Compile, aws-cdk (10 nodes) | 71 to 85 ms | 0.07 ms |
| Image, aws-cdk | 19 to 24 ms | 0.01 ms |
| Compile, cql2 (regex-heavy) | 35 to 39 ms | 2.5 to 2.8 ms |
| Image, cql2 | 33 to 38 ms | 0.4 ms |
The cql2 row is the regular-expression cost: with CompileRegularExpressions on (the default) the first construction
of each Regex compiles it to IL, and that dominates the image load. [GeneratedRegex] moves exactly that cost to
build time, so it is the case Stage 2 must handle, not a limit of the image.
Image size (version 4) is about two thirds of the schema text: 1.05 MB against 1.66 MB over the corpora, down from
2.16 MB in version 2. Three changes account for it. Byte payloads (path segments, property names, keywords, number
text, annotation JSON) are interned in one table and referenced by index. A node's schema location is stored as the
index of the earlier node whose location is its longest prefix on a segment boundary, plus the interned remainder,
so the per-node JSON pointer costs a few bytes instead of the whole string (the locations were the largest single
item: 195 KB of ui5-manifest's 525 KB). Keyword groups a node does not use (value, number, string, object, array,
in-place applicators, annotations) are skipped under a per-node section mask instead of writing every default. What
remains is dominated by annotation JSON (title, description, default, examples raw text, about 0.5 MB in
total), which verbose output needs and which compresses no further without changing what is kept. Load time is
unchanged (8.8 ms warm across the 37 corpora); the breakdown benchmark command reports the per-schema split.
The machine was loaded during these runs (load average 8 to 25 from concurrent builds); the minimum-of-N figures are stable, the cold first-load figures vary by roughly a third between runs.
B. C# static initialisers
StaticInitEmitter (benchmark harness, emit command) writes the same graph as C# object initialisers, chunked into
methods of 32 nodes, with the constants array as a UTF-8 literal, regexes constructed through PatternMatcher.Create
and the Build method returning a CompiledSchema through the image constructor. The spike project compiled five
corpora (babelrc, openapi, cmake-presets, krakend, ui5-manifest) and timed Build() against loading the equivalent
image; the emitted graph is checked by writing it back to an image and comparing bytes with the compiler's image.
Emitted source and compile cost (Release, five programs, one project):
| Corpus | Nodes | Emitted C# | Image |
|---|---|---|---|
| babelrc | 38 | 80 KB | 8 KB |
| openapi | 363 | 698 KB | 52 KB |
| cmake-presets | 677 | 1.36 MB | 146 KB |
| krakend | 1334 | 3.10 MB | 489 KB |
| ui5-manifest | 2688 | 5.52 MB | 582 KB |
Roslyn compiled the five programs in about 10 seconds of wall time; the resulting assembly is 5.4 MB. The emitted graphs are byte-for-byte identical to the compiler's when written back as images.
Type initialisation against image load, same process, three runs (first call includes JIT of the emitted methods; warm is the minimum of 20):
| Corpus | Build first | Build warm | Image first | Image warm |
|---|---|---|---|---|
| babelrc | 21 ms | 0.02 ms | 2.4 to 3.1 ms | 0.04 ms |
| openapi | 51 to 55 ms | 0.16 ms | 0.3 ms | 0.24 ms |
| cmake-presets | 99 to 104 ms | 0.32 ms | 0.6 ms | 0.45 ms |
| krakend | 245 to 256 ms | 0.9 ms | 1.3 to 1.7 ms | 1.1 ms |
| ui5-manifest | 421 to 427 ms | 1.5 ms | 2.1 to 2.3 ms | 2.0 ms |
The first-call column is the decisive one: JIT of the object-initialiser methods costs roughly 0.16 ms per node, so a 2688-node program spends over 400 ms initialising the first time, two hundred times the image load. Once JIT-compiled the static form is 25 to 35 percent faster than the image reader, which is not worth the cold-start cost, the tenfold source size, or the public surface the emitted code would need over the node model. ReadyToRun on the consumer would recover some of the JIT cost, but only for consumers that opt in, and the image needs no such help.
Where pre-compilation runs
Two producers emit programs today: the CLI (Corvus.Json.Cli, a normal .NET application) and the Roslyn source
generator (Corvus.Text.Json.SourceGenerator, netstandard2.0, which by design carries no binary dependencies and
compiles the V4 analysis core and selected Corvus.Text.Json files in as sources).
Pre-compiling in the CLI is straightforward: it can reference the evaluator package and call the compiler. Pre-compiling
in the source generator needs the schema compiler and the document model it reads with (ParsedJsonDocument,
JsonElement, JsonElementHelpers, UnescapedUtf8JsonString, EcmaRegexTranslator) compiled into the generator.
Both Corvus.Text.Json and the evaluator already target netstandard2.0, so this is a matter of linking sources the
way the generator links the V4 core, and of the generator's build-time cost. The alternative is for the source
generator to keep emitting the Stage 0 form while the CLI emits pre-compiled programs; that leaves two shapes in the
field and is not recommended.
Engine changes Stage 2 needs
- Regular-expression provider. Done (image version 2). The image carries a pattern table of every pattern that
needs a
Regex(prefix, length-range and trivial patterns are matched without one), in first-seen node order, and nodes reference patterns by index.JsonSchemaEvaluatorOptions.RegexProvideris asked for(index, pattern)before a regular expression is constructed, with index -1 when compiling from text; anullanswer falls back to construction.JsonSchemaEvaluator.GetImagePatternsreads the table so an emitter can write one[GeneratedRegex]method per entry, andToDotNetPatterngives the translated pattern the evaluator itself would construct, so the generated instance matches exactly. The emitted table belongs to the image-emission step. - Constants. The image carries the values as one JSON array parsed at load. Emitting them as statically
constructed values instead requires
const/enumdeep equality to work against standalone values rather than a document and index; the load-time parse measured under 0.1 ms for every corpus, so this is an optimisation to do after the shape is settled, not before. - Image versioning. The image version is tied to the evaluator assembly. The generator and the evaluator ship from the same repository, and a consumer referencing mismatched packages should fail at type initialisation with a clear message rather than silently recompiling from schema text; keeping the text as a fallback would forfeit the size and start-up gains.
- Entry points. Every entry point a generated type needs must be compiled when the image is produced; the generator already knows them (the program entries of Stage 0).
- String table. Done (image version 4): interned byte payloads, prefix-encoded locations and a section mask per node; see the size paragraph above.
Recommendation
Emit program images, not static initialisers. The image loads in a fifth of the compile time warm, a quarter of
it process-cold, halves compile-time allocation, needs no schema text, keeps the node model internal, and is already
conformance-clean. The emitted C# per compilation shrinks to a shim: the image as UTF-8 data, the entry-point table,
the options, and a [GeneratedRegex] method per pattern wired in through the regular-expression provider.
Order of work:
- Regular-expression provider on
FromProgramImage, and the emitted[GeneratedRegex]table (the cql2 row above is what this buys). RuntimeProgramGeneratoremits the image and the shim instead of the schema documents; the CLI drives it first because it can reference the evaluator directly. Done:CSharpLanguageProvider.Options.ProgramCompilertakes aSchemaProgramCompilerdelegate (the generator library keeps no evaluator dependency); the CLI suppliesRuntimeProgramCompiler.Compile, which compiles the program exactly as the emitted code would, registers every entry point, and returns the image and its patterns translated to .NET syntax. The emitted class is then partial, carries the image as base64 UTF-8 literals decoded on first use, loads it withFromProgramImage, and wires one[GeneratedRegex]method per pattern throughRegexProviderunder the same framework guard the old generator used; the schema documents are not embedded. Without a compiler the Stage 0 form is emitted.- Link the compiler into the source generator, so both producers emit the same shape. Done; see the next section.
- String table in the image (size only; done, image version 4), then constants as static values if the load-time parse ever shows up.
The source generator
The source generator takes no binary dependencies: every component it uses is linked in as source files, as the
JMESPath and JSONPath generators do, and it builds against System.Text.Json (STJ and BUILDING_SOURCE_GENERATOR
are defined). The program compiler follows the same rule, so the compiler and the image writer are linked as files
and must compile over System.Text.Json in that configuration; the Corvus document model is not brought in for
their sake.
The compile path already uses an element API that System.Text.Json shares (ValueKind, GetString,
TryGetProperty, ValueEquals, EnumerateObject/EnumerateArray, GetRawText). What differs is confined to a
few places:
- Element identity. The loader and compiler identify a subschema by (document, element index), which the Corvus
document exposes and
System.Text.Jsondoes not. UnderSTJthe loader assigns the index itself: every element it reaches (walking, resolving a pointer fragment, or an anchor) is interned by its JSON pointer, and the index is the intern ordinal. The node's schema location is then that pointer, which replacesTryGetJsonPointer, and pointer fragments are resolved by walking properties and array indices with the usual~0/~1decoding, which replacesTryResolvePointer. - Raw values.
GetRawSimpleValue(number text) becomesGetRawText;GetUtf8Stringbecomes the UTF-8 encoding ofGetString; the token type of a constant is derived from itsValueKind. - Constants.
ConstantValueholds the element and its (document, ordinal) identity instead of a document and index; the pool writesGetRawText. - Runtime-only members are compiled out under
STJ: number comparison andmultipleOf(they use the Corvus number helpers; the writer needs only the text), regular-expression matching, the message providers, the image reader except the pattern table,CompiledSchema's constants document, and everyEvaluateoverload.
The linked file set is the Compilation directory (compiler, loader, node model, fused-object analysis, name map,
keywords, URI utilities, metaschema table, image, and the System.Text.Json shim), the netstandard2.0 polyfills,
the public option, dialect, format-mode and exception types, the compile-side half of JsonSchemaEvaluator, the
ECMA regex translator (already linked), and the CLI's RuntimeProgramCompiler, which is the
SchemaProgramCompiler implementation the generator passes in its language provider options. The evaluation
directory is not linked. The generator's embedded metaschemas already use the logical names the evaluator's
metaschema table looks up. Because the generator applies the repository's analyzer set (StyleCop, Roslynator, the
analyzer-banned-API rules) to linked files, the evaluator sources follow the src conventions (no trailing
newline, no environment reads on the generator path: the A/B switches are constants under STJ).
One difference from the CLI's output: the generator's program carries no [GeneratedRegex] table. Roslyn does not
chain source generators, so the regular-expression generator never sees code another generator produced and the
partial methods would have no implementation. The generator's shim therefore returns no provider and the evaluator
constructs each expression from the image's pattern table on first use, as it does on runtimes without the regex
generator. The image itself is identical.
Version coupling is the same as today's source generator to Corvus.Text.Json pairing: the generator emits the image
version of the files it was built from, and FromProgramImage rejects any other version at type initialisation
with a message naming both versions. There is no schema-text fallback.
Throughput ceiling: emitted evaluation code against the engine
The ceiling command of the benchmark harness times hand-written, straight-line evaluators for the micro cases (the
best a per-schema code emitter could produce: same raw document access and helpers, no node graph, no flags, no
generic dispatch) against the engine on the same instances, after checking they agree. Quiet machine, minimum of 40
rounds, three runs of all cases interleaved and two runs of each case alone:
| Case | Engine path | Engine | Straight-line | Ratio |
|---|---|---|---|---|
| Object | fused Object plan |
122 to 163 ns | 92 to 285 ns | 0.3 to 2.3 (noise) |
| OneOf | discriminator | 59 to 200 ns | 106 to 358 ns | 1.7 to 1.9 |
| Array | array-items plan, uniqueItems |
465 to 579 ns | 409 to 481 ns | 0.7 to 0.9 |
| Unevaluated | allOf × 3 with evaluated bits |
230 to 482 ns | 25 to 141 ns | 0.10 to 0.30 |
| DynamicRef | dynamic scope, strict tree | 246 to 873 ns | 34 to 183 ns | 0.13 to 0.21 |
Two conclusions and one caveat.
- Where the compiler already fuses the node into a plan (objects, discriminated
oneOf, simple arrays) there is nothing left for emitted code to win; the discriminator beats the straight-line form because it short-circuits on the missing property. - The general path (in-place applicators over the same instance with evaluated-bit tracking, and dynamic scope
resolution) has three to ten times headroom, but the straight-line form does not win it by removing dispatch. It
wins it by making one pass over the properties with a single merged switch, and by knowing the dynamic reference's
target statically. Both are schema-level transformations the compiler can perform and the engine can execute as
plans: fuse the property tables of
allOf,$ref,if/then/elsebranches over the same object into one table with per-entry branch conditions, and demote a$dynamicRefwhose anchor is defined in the entry point's root resource (always the outermost scope, so always the answer) to a static reference. That puts the gain into every program, image or compiled, and into the runtime Validator, with no emitted code. - The absolute figures for cases under 300 ns move by two to three times between process configurations (the engine's DynamicRef case ran at 870 ns in the interleaved run and 250 ns alone; the straight-line Unevaluated case at 141 ns and 25 ns). That is JIT and dynamic PGO deciding differently about the same large generic methods, a known instability of this case. Ratios within a run are meaningful; absolute figures are not to two digits, and the variance is itself an argument for simpler fused plans over the general path.
Of the 37 corpora, only ui5-manifest and cmake-presets (heavy allOf), openapi (unevaluated*) and cql2
($dynamicRef) lean on the general path; the rest sit on planned paths. The corpus comparison in the harness is still
against the checked-in models generated by the previous generator (those projects reference only
Corvus.Text.Json), which makes it an independent oracle for agreement but means its "generated" column is the old
validation code, not the Stage 0 shims: geometric mean ratio 0.25 over 37 cases on this run.
Decision (2026-09-10): no emitted evaluation code; do the schema-level fusion in the compiler, after the regular expression provider.
Fusion, done
Two compiler transformations followed, both flag-mode plans with collecting mode unchanged, and both checked by running the whole JSON Schema Test Suite in collecting mode (the general path) as well as flag mode (the plans):
- Static
$dynamicRefwhen every entry resource defines the anchor with the same target. The scope is searched outermost-first and its outermost entry is always the resource evaluation started in, so the target is the answer on every path; programs with no remaining dynamic references keep no scope at all. This is re-applied as entry points are added, and only the entries that can reach the reference count: a generated program registers an entry for every type, and entries whose resource never defines the anchor cannot block a reference they never evaluate. Two reachable entries that resolve differently make the reference entry-resolved rather than dynamic: since each entry's resource is the outermost scope on every path, the target is a function of the entry resource, kept as a table on the reference and read once at evaluation with no scope maintenance (image version 5). Only a reaching entry whose resource lacks the anchor keeps the scope. This is what makes the strict-tree micro case as fast generated (entry points in both resources) as through the API (one entry).
Generated code against the API, measured on the micro cases with the fixes in (typed generated model, generated standalone evaluator, runtime API; quiet box, minimum of 40 rounds): object 262/258/253 ns, array 1.05/1.07/1.02 µs, string 83/80/76, unevaluated 245/241/238, dynamic reference 273/281/263, oneOf 68/63/63, verbose 3.09/2.82/3.06 µs. With tiered compilation disabled the three paths are identical to the nanosecond (oneOf 59/59/59, dynamic reference 241/240/242): they run the same machine code.
The harness had to be fixed to show this. Its quick timer warmed each workload with a hundred calls and then took the
minimum of forty rounds; that leaves paths at tier 0 or in on-stack-replacement code during measurement, because the
runtime's tiering delay restarts whenever new tier-0 code appears and an interleaved three-way loop keeps producing
it. Under that warmup the same process reported oneOf at 132 against 80 ns and the string case above 200 ns, and the
figures moved between runs. The timer now warms for a second, pauses past the tiering delay, warms again, and only
then measures. Two other explanations were chased and ruled out on the way: dynamic PGO (the gap persisted with it
off) and the document instance or its element type (a probe that evaluates one schema over documents of every model
type, and over twelve identical documents in either order, shows them all within 5%; docprobe command). None of
this can be measured on a loaded box: with a build running, the same document measured 198 ns in one pass and 434 in
the next.
The oneOf profile did show one engine cost worth taking: the discriminator reads its property through the document's
by-name lookup (JsonDocument.TryGetNamedPropertyValueIndexUnsafe, 30% of the evaluation) rather than the raw-access
scan the rest of the engine uses.
- Fused object plan (
FusedObjects,NodePlan.FusedObject) for a node withunevaluatedPropertieswhose object semantics spread overallOf,$refandif/then/elsewith required-only conditions: every property name any branch knows is resolved at compile time to the child schemas that apply (own property, matching pattern properties, or the branch'sadditionalProperties), unknown names resolve per branch at evaluation, conditional branches are deferred to a second step over the properties they touched, and the coverage bits feed the unevaluated check directly. It is restricted to nodes withunevaluatedPropertiesbecause there the pass over every instance property is unavoidable; fusing branches without it doubled cmake-presets (large instances, small branches, where the general path's schema-driven unrolled lookups win). A fused plan applies its contributors' keywords without entering them as nodes, so it is withheld only from nodes that can reach a live dynamic reference; the guard was program-wide at first, which cost every schema in a generated program its fused plans as soon as one schema in the compilation used$dynamicRef(the unevaluated micro case ran four times slower generated than through the API).
Micro cases after fusion (quiet-ish machine, ratio is engine after / engine before):
| Case | Engine before | Engine after | Straight-line |
|---|---|---|---|
| Unevaluated | 589 ns | 142 to 170 ns | 151 to 181 ns |
| DynamicRef (strict tree) | 878 ns | 226 to 242 ns | 192 to 200 ns |
| Object, Array, OneOf | unchanged | unchanged |
Corpora: openapi (27 unevaluated*) 8.1 ms to 7.2 ms; cmake-presets unchanged after the restriction; the rest within
noise. The general path remains the fallback for every shape the plan does not take, and CORVUS_RT_NO_FUSE=1
disables it for A/B runs.
Compile-step optimisations after the merge (2026-09-11)
Six changes, profiled on the corpora with the least gain over the shipping generated code (sampled with
dotnet-trace through the harness's profile command), all in the compiler and its plans: discriminators keyed on
integer and boolean constants (cmake-presets' root is a oneOf of eight branches on an integer version);
uniqueItems through a structural set that every array plan accepts (unreal-engine-uproject spent 55% of its time
renting, zeroing and transcoding for the shared hash set); the object plan taking patternProperties and
dependencies (draft-04 and clang-format roots were on the general path); a length-then-byte name map in place of a
hash per instance name (17 to 28% of cypress, cmake-presets and pre-commit-hooks); one-pass property matching for
unrolled objects and discriminators instead of by-name lookups through the document; and regex-free matchers for
anchored ASCII class sequences and literal alternations.
Measured as a pair, the commit before the six against the commit after, both harnesses run back to back on an idle
box pinned to the performance cores (taskset -c 0-11; this is a hybrid i7-13800H and unpinned runs land on
efficiency cores at random, which was the source of the bimodal noise seen earlier):
| Corpus | Runtime before | Runtime after | Speed-up | Ratio to generated, before to after |
|---|---|---|---|---|
| unreal-engine-uproject | 3.75 ms | 611 µs | 6.1 | 0.54 to 0.07 |
| jshintrc | 1.18 ms | 342 µs | 3.5 | 1.02 to 0.41 |
| omnisharp | 566 µs | 224 µs | 2.5 | 0.45 to 0.37 |
| ansible-meta | 331 µs | 133 µs | 2.5 | 0.50 to 0.41 |
| clang-format | 84 µs | 34 µs | 2.5 | 0.50 to 0.41 |
| babelrc, jsconfig, cypress, jasmine | 1.8 to 1.9 | |||
| cmake-presets | 9.58 ms | 5.52 ms | 1.7 | 0.72 to 0.57 |
| draft-04 | 5.88 ms | 3.38 ms | 1.7 | 0.60 to 0.34 |
| krakend | 346 µs | 245 µs | 1.4 | |
| openapi | 8.94 ms | 7.61 ms | 1.2 | 0.59 to 0.53 |
| geometric mean over 37 | 0.47 to 0.39 |
Corpora that read slower in the single paired run (vercel, ui5-manifest, pulumi, semantic-release) were repeated three times on each binary and are equal within run-to-run noise, which is up to 25% on the small ones even pinned and idle.
Fused plan: conditions on values, nested conditions, dependencies, required-only alternatives
openapi barely moved under the six items because its object schemas were still on the general path: its if
keywords test property values ({"properties": {"in": {"const": "query"}}, "required": ["in"]}, one with a
pattern), parameter and header carry a oneOf of two required-only branches (schema or content) and a
dependentSchemas whose schema holds further conditions, and security-scheme has six conditional allOf
branches. The fused plan now takes all of these. A condition carries value tests keyed like discriminator values
(tagged string, canonical integer and boolean constants; a pattern test for strings), checked as each property is
passed. Conditions nest: each records the condition and polarity it is reached through, and a contributor applies
only when its own condition matches and the chain above does; dependentSchemas and dependentRequired become
presence-gated contributors the same way. A oneOf/anyOf whose branches are plain required lists is decided
after the pass by counting the branches whose names were all seen. When a condition holds, the if schema's own
properties count as evaluated, as the general path's annotations do. Fused plans are rebuilt on load, so the image
format is unchanged.
openapi: 24 fused object schemas instead of 19; 7.61 ms to 5.62 ms (1.59 against the pre-optimisation 8.94 ms). cmake-presets took a further 1.2 (5.52 to 4.52 ms) from its required-only alternatives; omnisharp and nest-cli gained as well. Geometric mean over the 37 corpora: 0.38 against the shipping generated code, from 0.47 before this round of work. Pinned, back to back with the same baseline binary as above.
Engine mechanics after the schema-shape work (2026-09-11, second round)
With the fused plan covering openapi's shapes, the profiles of the least-improved corpora (jasmine, stale, jsconfig, pulumi, yamllint) pointed at the engine's per-property and per-node mechanics rather than any keyword. Six changes, each its own commit:
- Property names from the row spans. The object loops (object plan, fused plan, unrolled objects, discriminator
lookups) read a name's location and length from the metadata rows held as spans in the evaluation state, one
bounds check per field, instead of slicing a
ReadOnlyMemoryand converting it to a span per property; the fused loop's per-name step is split out so the raw span feeds it directly, andUtf8NameMapkeeps its per-length position and table in one bucket array. Alone this was worth 3% on the geometric mean: the loop was already tight, and the disassembly (DOTNET_JitDisasm) shows the remaining per-property cost is theSequenceEqualcall and the child's own entry, not the access. - Fused plan without
unevaluatedProperties; forward plan. The fused plan now fuses whenever its one pass replaces several: two or more contributors with object keywords, anifthe seen bits decide, or required-only alternatives. jasmine's root (allOfof three object branches) and stale's (type, two properties,allOfof a twelve-property$ref) each went from three passes to one. A node whose object keywords are all its own keeps the object plan, dependencies included: fusing draft-04's root for its twodependenciesalone cost 40% on that corpus, because the fused routine's fixed per-object cost outweighs a second pass over small objects. A node whose only assertion is a loneallOfbranch or a kept$refgetsNodePlan.Forwardand dispatches straight to the child's plan (yamllint's root). Both are derived from the graph on image load. - Type-dispatched
anyOf/oneOf. When every branch asserts atypeand no two accept the same token type, the token type selects the one branch that can match (pulumi'sruntime: a string or an object). The branch keeps its own type test, sointegerand draft 4 lexical integers need nothing special, and it marks straight into the parent's evaluated bits. - String leaves. An unescaped string is evaluated from its raw text with no wrapper to dispose, and since a
rune is one to four bytes,
minLengthpasses andmaxLengthfails from the byte length alone outside the band where the rune count matters. This exposed a quirk: a fixed-string document (property names) keeps the quotes on the one-argument raw-value overload, so the interface access path asks for them stripped explicitly. - Flag-mode entry. Without a collector and without a dynamic scope the scope buffer never grows, so the entry
has no try/finally and dispatches the root (or its elided
$reftarget) on its plan. This exposed a gap: the array-items plan returned early for a node withoutitems, skippinguniqueItems, which the general path had always covered at the root. Arrays of up to eight items are now compared pairwise, renting nothing. - Leftovers. The class-sequence parser flattens groups of alternatives (
^[Ee][Ss]2022(\.(A|B))?$, jsconfig's case-insensitive literals with optional suffixes;^[1-5](?:[0-9]{2}|XX)$) into whole alternatives, a dot is a one-rune class, and top-level alternations stay with the regex since^a|b$anchors only its ends. The unique-items hash mixes eight bytes at a time.
Measured on the like-for-like basis (blazebasis 200, pinned, idle box), baseline binary, new binary, baseline
again, so the third run gives the noise floor (0.99 on the geometric mean, up to 8% on a single corpus, and yamllint,
whose whole corpus is 20 µs, swings by half):
| Corpus | Before | After | After / before |
|---|---|---|---|
| jasmine | 107 µs | 68 µs | 0.63 |
| aws-cdk | 36 µs | 24 µs | 0.67 |
| lazygit | 88 µs | 59 µs | 0.68 |
| unreal-engine-uproject | 444 µs | 300 µs | 0.68 |
| pulumi | 417 µs | 309 µs | 0.74 |
| jsconfig | 352 µs | 268 µs | 0.76 |
| cmake-presets | 3.23 ms | 2.55 ms | 0.79 |
| cypress, fabric-mod, openapi, vercel | 0.80 to 0.83 | ||
| clang-format, ansible-meta, draft-04, stylecop, ui5 | 0.84 to 0.86 | ||
| stale, helm-chart-lock, cql2 | within noise | ||
| geometric mean over 37 | 0.85 |
Blaze, re-run the same afternoon (its own figures were 8% faster than in the earlier session, so both sides are taken from the same hour): geometric mean of our runtime over Blaze 1.06, from 1.23 for the baseline binary the same day. We lead on 17 corpora (ui5-manifest 0.25, jshintrc 0.40, clang-format 0.50, omnisharp 0.55, openapi 0.56, cql2 0.64, draft-04 0.76, cypress 0.78, pre-commit-hooks 0.79, ansible-meta and tmuxinator 0.82, deno and fabric-mod 0.85, lerna and aws-cdk 0.87, geojson 0.88, lazygit 0.92); within 20% on eleven; and still behind on the small-instance corpora: helm-chart-lock (5.2, unchanged), importmap (3.9), yamllint and ui5 (2.4), semantic-release and krakend (1.7). What remains there is the per-evaluation and per-object fixed cost, which the entry change only dented.
Two measurement notes. The quick 40 protocol is too noisy for changes of this size: its generated-code column, an
identical binary on both sides, moved by a median 10% and up to 2.7x between paired runs, so a paired quick
comparison cannot see a 10% change; blazebasis with the baseline run twice is the protocol now. And a method with
stackalloc cannot use on-stack replacement, so the JIT compiles the object loops straight to full opts without
dynamic PGO; nothing in them is virtual, so this costs little, but it is why EvalObjectPlanCore shows one tier in a
disassembly.
Strict object plan and per-property mechanics (2026-09-11, third round)
helm-chart-lock is the simplest shape in the corpus (an object of three required properties with
additionalProperties: false, one of them an array of such objects) and our plan for it was already the best we
had, so what was left was the cost per property: about 13 ns against Blaze's 2.5. Three commits:
- Name map keys as word loads; scratch context per call. Keys of up to sixteen bytes (the bucket guarantees
equal length) compare as one or two overlapping word loads instead of a
SequenceEqualcall. TheJsonSchemaContextthe shared format helpers need is a local at its three call sites instead of a field the evaluation state zeroed on every entry. - Type-only children tested in place;
requiredas a mask. A property entry, and a fused application, whose child is a type-only leaf carries the child's type mask, so the object plan, the fused plan and the strict loop test the token type without a call (and skip a child that istrue); with at most 64 seen bits,requiredis one mask test. Derived from the graph at compile time and on image load. NodePlan.StrictObject. An object node with named properties only (no pattern properties, dependencies or additional-properties schema;additionalPropertiesabsent,trueorfalse) and at most 64 seen bits gets one loop: look the name up, reject an unknown name when additional properties are false, test the token type for a type-only child and dispatch the rest on their plan, then one mask test forrequired.
Measured on the like-for-like basis after the box had recovered (the timer-overhead column read 13.5 ns in all three runs; noise floor 1.03 on the geometric mean):
| Corpus | Before | After | After / before |
|---|---|---|---|
| helm-chart-lock | 445 µs | 270 µs | 0.61 |
| gitpod-configuration | 125 µs | 77 µs | 0.62 |
| jshintrc | 187 µs | 131 µs | 0.70 |
| nest-cli | 109 µs | 76 µs | 0.70 |
| dependabot | 282 µs | 199 µs | 0.71 |
| omnisharp | 134 µs | 99 µs | 0.74 |
| lerna, deno, pre-commit-hooks, cmake-presets, cypress, cspell, importmap | 0.77 to 0.82 | ||
| ui5, semantic-release, tmuxinator, openapi | 1.04 to 1.12, inside that run's noise | ||
| geometric mean over 37 | 0.86 |
Against the same-day Blaze run the geometric mean of our runtime over Blaze is now 0.89; we lead on 22 corpora. helm-chart-lock is at 70 ns per instance against Blaze's 24, importmap 3.1 times Blaze, yamllint 2.1, ui5 2.7. The remaining gap on those is the per-evaluation entry (state setup and the root dispatch, about 15 ns) and the per-object prologue of the plans; the property loop itself is now a lookup, a word compare and a token-type test.
A measurement note for the record: from mid-afternoon the host ran the VM at a third of its speed (a throttled clock and runaway network traffic on the host), which the overhead column showed as 30 to 1,200 ns while guest load and memory looked normal. Every run in that window was discarded; the column is the health check before trusting any run.
In-place plans, maps and inline enums (2026-09-11, fourth round)
Profiles of the corpora still behind Blaze after the strict plan pointed at three more mechanics, each its own commit:
- Strict plan for maps and any
additionalProperties. The unknown-name decision is precomputed on the node: rejected (false), tested in place (a type-only leaf), dispatched on its plan (any other schema) or allowed. An object whose only keyword isadditionalProperties(importmap's maps of strings) skips the name lookup altogether. - Plans for nodes whose only keyword is a typed
anyOf/oneOf.NodePlan.TypeUnionandNodePlan.TypeDispatch: the child entry tests the mask or reads the table and jumps to the branch's plan without the general node prologue (semantic-release'spluginsitems are a string or an array, itsbranchesa string, an object or an array). A branch on an in-place cycle keeps the guarded general edge. - String enums in place; lookups without copying. A property entry or fused application whose child is a leaf
with only an
enumof strings carries the string set and is tested in the loops without a call, from the raw text when unescaped.Utf8NameMapgained a tagged lookup, so discriminator values and fused value tests no longer copy the value behind a tag byte.
A fourth change was tried and reverted: allocating the dynamic-scope stack only for programs that need it made the
stackalloc conditional, which turns it into a variable-size localloc and changed the entry method's frame setup
on every call; the small-instance corpora (cypress, aws-cdk, nest-cli, stale, yamllint) lost 7 to 16% from that
alone. The scope stack is back to a fixed frame slot.
Two lessons from measuring this round. First, the box's page-reporting order had been raised from 5 to 10 during
the afternoon, which halved guest throughput for everything (identical binaries ran 1.7 to 2.0 times slower on the
timer-free quick mode, and the overhead column read 19 ns instead of 13.5); every measurement taken in that state
was discarded once the cause was found, and the setting is back at the default. Second, the first cool-box run
showed a group of small corpora 10 to 30% slower: the child dispatch is inlined into every loop, and the two new
plan cases plus the inlined string-set test had doubled the strict loop's tier-1 code (3,136 to 6,042 bytes). The
loop now resolves a property to one mask, set or child and runs one type test and one dispatch, with the new plans
and the set test out of line (3,259 bytes).
Measured three-way on the cool box (overhead column 13.4 to 13.9 ns), twice; noise floor 1.02 on the geometric mean and up to 10% on a single small corpus:
| Corpus | Before | After | After / before (two runs) |
|---|---|---|---|
| semantic-release | 74 µs | 42 µs | 0.56, 0.57 |
| importmap | 45 µs | 27 µs | 0.65, 0.54 |
| fabric-mod | 253 µs | 195 µs | 0.78, 0.74 |
| dependabot | 210 µs | 165 µs | 0.82, 0.74 |
| tmuxinator | 36 µs | 28 µs | 0.70, 0.85 |
| pre-commit-hooks, cql2, stylecop, draft-04, cmake-presets, deno, krakend, pulumi | 0.85 to 0.94 | ||
| the rest | within the noise | ||
| geometric mean over 37 | 0.927, 0.917 |
Against the same-day Blaze run the geometric mean of our runtime over Blaze is 0.85; semantic-release is level with Blaze (0.93) and importmap at 1.9 from 2.9. helm-chart-lock did not move: its cost is the per-object prologue and the per-evaluation entry. A flag-mode entry without the scope buffer and the try/finally followed as its own commit and measured neutral to a few percent on the smallest corpora in an alternating A/B (five runs of each binary on seven corpora, medians: yamllint 1.02, cypress 0.98, aws-cdk 0.97, cspell 0.92), so the entry is not where the remaining fixed cost lives; the per-object prologue is the open item.
A third measurement lesson, and the cause of every "idle guest but slow" episode of the day: Windows core parking.
The host's power scheme had CPMINCORES at 4, which parked sixteen of the twenty logical processors, all six
P-cores among them, and left the VM and everything else on four E-core threads; from inside the guest that showed
as a run queue in the fifties with 96% idle, the same loop taking 0.12 to 0.89 s depending on which guest CPU it
landed on, and pinning inside the guest changing nothing, since guest CPU numbers have no fixed relation to host
cores. Setting CPMINCORES to 100 (powercfg /setacvalueindex SCHEME_CURRENT SUB_PROCESSOR CPMINCORES 100, the
same for DC, then /setactive) is the fix; pinning the vmmemWSL process to the P-cores is a useful belt and
braces. The harness's overhead column (13.5 ns healthy) is the check to run before trusting any measurement, and
alternating the two binaries in short runs and comparing medians is the protocol for changes of a few percent.
The strict loop's instruction budget (2026-09-11, late)
With the entry shown not to be the fixed cost, the tier-1 disassembly of the strict loop was read instruction by
instruction: about 150 per property with some twenty bounds checks, against the thirty or forty Blaze needs. Four
of the sources were avoidable without giving up the checks that matter: the value row's header (type and row count)
was read once for the type test and again to step to the next row; the name row's location and length were two
checked reads where one 8-byte read serves; the key compare did six checked slice reads on spans already known to
hold n bytes; and each property's entry was a class reached through a checked array load, then five field loads.
One commit takes all four: IDocumentAccess.TokenTypeAndNext, a single read for the name row, the key words read
through refs, and a value-type StrictEntry table parallel to the name map (Utf8NameMap.TryGetIndex).
Alternating A/B (five runs of each binary, medians): helm-chart-lock 0.85, ui5 and krakend 0.89, vercel 0.91, aws-cdk 0.92, cypress, cspell, jsconfig and stale 0.95; yamllint, whose root has one named property and whose instances have none of it, unchanged within noise. The same recipe then went into the object plan's loop and the fused loop (one header read, the type handed to the inline tests, the object plan resolving through the value table by index): against the round's baseline, draft-04 0.77 (its root sits on the object plan for its dependencies), helm-chart-lock and ui5 0.88, aws-cdk 0.91, cypress 0.94, krakend 0.95. yamllint alone reads about 1.10 in both A/B runs, some two nanoseconds per instance on a corpus whose properties are all unknown names; the loop's prologue for that case is the one place left to look at there.
The bounds checks were then sorted by what makes them unnecessary. Five per property are guaranteed by the name map's construction (the position read is within a key of the bucket's length, the table has 256 entries indexed by a byte, and the key, chain and entry indices came out of that table) and now go through refs; the two row reads are covered by one check per object that the container's rows exist; the slice into the text keeps its check, since its location and length come from the rows. The type test became a bit test: a strict entry carries the token types it accepts as one bit per token type, and only the integer case still calls out. The escaped-name lookup moved out of line to relieve register pressure. Against the round baseline, medians of five: helm-chart-lock 0.83 (from 0.88), importmap 0.55 (0.59), krakend 0.89 (0.95), stale 0.90 (0.95), vercel 0.93 (0.98), with the rest unchanged. The residual against Blaze on these corpora is now structural (span headers, the per-child plan switch, the JIT's register allocation across a large method) rather than incidental. The object plan's loop and the fused loop got the same treatment afterwards, worth cypress 0.84 (from 0.93) and draft-04 0.75 (0.76), the rest within noise.
Two more from the profiles taken after that. The name map's lookup had stopped inlining: the bucket struct was
nested in the generic map and the unchecked accessor was a generic method on it, so the shared instantiation needed
a runtime type lookup and both showed up as frames. With the bucket and the accessor moved to non-generic types the
loops inline them again, which was yamllint's unexplained 10% (0.98 from 1.10) and a further 4 to 8% on
helm-chart-lock, stale, aws-cdk and jsconfig. And krakend spent a quarter of its time in a regex: it applies
^[@$_#] as a pattern property on 128 objects, so every property of every instance went through a UTF-16 transcode
and a regex scan. A start-anchored class sequence without a closing anchor now matches as a prefix (fixed counts
before the last atom, the last atom's minimum count), and krakend halved (0.50 from 0.88).
A last pass on the call chain between the entry and the loops: forward chains collapse to their final target at
compile time and the entry skips a forwarding root; the strict object plan is entered in one call with its prologue
and loop in one method; the array-items loop steps siblings unchecked after one range check. Inlining the flag-mode
entry into Evaluate was tried and cost yamllint 15%, so the entry stays a call. importmap 0.49 (from 0.54), the
rest within noise.
The table taken on the healthy box before that last pass (both engines back to back, overhead column 14.6 ns): our runtime is fastest on 27 of 37 corpora, geometric mean 0.80 against Blaze; krakend, cypress and dependabot moved to our side, and only ui5-manifest (1.08), dependabot (1.11), cspell (1.13) and jsconfig (1.17) sit above parity besides the four structural ones: importmap 2.0, ui5 2.2, yamllint 2.3, helm-chart-lock 2.9.
Two more after that table. An object with no named properties takes a map loop that reads no names: every value takes the additional-properties resolution (importmap 0.39 from 0.49). And on the fused plan an entry's keyed value tests merge into one map from tagged value to the mask of tests that allow it, so a property under several conditions is looked up once, while the string-set membership test takes the token type its caller already has and reads the value's row once (ui5 0.84 from 0.88, cspell 0.92 from 1.05, draft-04 0.74 from 0.79, jsconfig 0.88 from 0.92).
Two from the shapes of individual corpora. cspell tests its word lists with ^(?=[^SET]+$)(?=(.*\\w)).+$ (and the
!+ variant): a lookahead that excludes a set and one that demands a word character. That form is recognised and
matched as one scan of the bytes, none in the set or a line terminator, at least one ASCII word character; cspell
0.73 (from 0.92). And jsconfig's root is a oneOf of three object branches over disjoint property names, which the
general path applied one branch at a time, each with its own pass over the instance. An unconditional oneOf or
anyOf whose branches carry object keywords now fuses into the object pass as a contributor group: each branch is a
contributor tagged with its group and branch index; a failed application marks the branch in a per-group bitmask
instead of failing the object; and the group is decided after the pass, any survivor for anyOf, exactly one for
oneOf. The group is refused when the keyword has a discriminator or type union (the general path decides those from
one property), when the node tracks coverage, and when a property name would receive an expensive child, anything
but a leaf, a simple array or a boolean schema, from more than one branch. That last guard came from cmake-presets,
whose eight discriminated branches share names with large children and went 5.8 times slower on an unguarded
version: applying every branch to every property is only cheaper than one branch at a time when the branches
mostly reject by name. jsconfig 0.68 (from 0.88), cmake-presets unchanged.
Three more tries on ui5 before the one that paid. Its root and the levels of its type-and-version decision tree
were on the general path. Their conditions key on null in places, which is now a keyable value for fused
conditions and discriminators, but that moved only two nodes: the real blocker is size, since fusing the root would
take about 80 contributors against the limit of 64. Raising the limit fused the root and made ui5 1.5 times slower
than the baseline: the fused pass applies every property to every gated application, and against a tree that
mostly rejects by one property's value that costs more than walking one branch. A shallow variant, folding a
branch whose subtree does not fit as an opaque child evaluated after the pass, was 1.17: the pass itself, with its
deferrals and condition tables, costs more per level than the general path's one-property if scan. What was
expensive was not the scans but the general path's in-place bookkeeping around each level, about a fifth of ui5's
time in EvalInPlace, EvalIf, EvalCore and their callees. A node whose keywords are type and object keywords
plus if/then/else, and which does not fuse, now takes a conditional plan: the strict or object plan for its own
keywords, then the if and the branch it selects dispatched as plain children. ui5 0.72 of the round baseline
(from 0.87), openapi 0.82 (from 0.93). A pattern of the form ^ES5|ES6|ES7$, whose anchors bind to the first and
last alternatives only, also matches without the regex now; jsconfig did not move, so that pattern is not on its
hot path.
The entry's fixed cost, read from the tier-1 listing of the public Evaluate: it inlined the whole flag-mode
entry and with it the general entry's stack scope buffer, which the frame then zeroed on every call (the public
class lacked SkipLocalsInit), and the evaluation state was built as a zeroed temporary and copied over. The
general entry is no longer inlined, the class skips locals init, and the state is written field by field in place:
frame 376 to 232 bytes, entry code 3061 to 2827 bytes, yamllint 0.91 of the round baseline. What remains at the
entry is the document type check, the raw-access construction and the memory-to-span conversion for the UTF-8
text, about twenty instructions. And ui5's bundle arrays carry additionalProperties: false, which applies to
nothing on an array; plan selection now treats object keywords under a type that excludes objects, and array
keywords under a type that excludes arrays, as absent, so those nodes take the array-items plan (ui5's general-path
nodes 7 to 4, ui5-manifest 0.85 from 0.88).
The table at the end of the day, on a healthy box (overhead 14.0 ns, both engines back to back): fastest on 30 of 37, geometric mean 0.77. Since the morning table jsconfig (1.17 to 0.82), cspell (1.13 to 0.84) and dependabot (1.11 to 0.94) crossed to our side; importmap 2.02 to 1.57, ui5 2.15 to 1.72 and helm-chart-lock 2.86 to 2.38 on the structural gaps; yamllint 2.33. One correction to the morning table: its babelrc row (0.46) was an artefact of the harness's per-corpus overhead probe spiking during that run, so too much was subtracted; babelrc is about 52 µs on every build including the round baseline, 1.13 against Blaze, and the morning's honest count was 26 of 37. Two lessons about measurement from the day. The host, a laptop, drops to a lower performance mode when its screen sleeps even while work continues over a remote session; both engines run about 35% slower and stay there, and nothing shows from inside the VM (no steal, same clocksource, same speed on every vCPU). Only alternating A/B pairs are trustworthy in that state; the overhead column of the harness's output (13 to 15 ns healthy) is the tell. And a single spike of that probe zeroes or inflates one corpus's row, so every row's overhead column is checked before a table is read.
Two more on the fixed costs, taken on the healthy box. The parsed document now keeps the array behind its UTF-8 memory when there is one and hands the evaluator its rows and text as spans in one call, so the entry no longer converts a memory to a span per evaluation; and the node a root's flag-mode evaluation enters is computed with the forwards instead of resolved on every call. And a strict-object child whose type admits objects, reached from a loop that already holds the value's token type, is entered at its loop directly: no second token-type read, no type test, no plan dispatch. yamllint 0.79 of the round baseline (from 0.91), helm-chart-lock 0.72 (from 0.78), the rest within noise. What remains at the entry is the document type test and the raw-access construction; what remains per object is the loop itself, whose per-property step is about the same work as Blaze's. The remaining gap on the small-instance corpora is the difference between a generic loop and a per-schema compiled closure, and the two core-library options that would narrow it further (a pre-classified name word per row, or short keys inline in the row) were judged not worth their parse-time cost, which every instance pays.
A measurement correction that moves the table more than any single optimisation did. The Blaze CLI is run once per corpus; our harness ran every corpus in one process, and the JIT's dynamic profile (block layout, guarded devirtualisation) is shaped by whichever corpora ran first, so a small corpus late in the order reads 10 to 30% slower than on its own: yamllint 2.69 in the one-process take against 1.90 in its own process on the same build and box, jshintrc 0.28 against 0.22, importmap 1.63 against 1.40, code-climate 0.90 against 0.75. Blaze's figures do not move between the two. The table is now taken one process per corpus on both sides: at commit 4e98a9c on the healthy box (overhead 14.6 ns), fastest on 31 of 37, geometric mean 0.73; the remaining losses are helm-chart-lock 2.32, yamllint 1.90, ui5 1.69, importmap 1.40, ui5-manifest 1.14 and stale 1.02, with babelrc at 0.97.
Cold start against Blaze (2026-09-12)
Wall time of one fresh process per corpus (median of three, pinned, healthy box): read the schema, compile it or
load a program image, read the instances file, validate every instance once, which is what the Blaze CLI's
validate --fast does. The harness's cold command and the Corvus.Text.Json.RuntimeEvaluator.ColdRunner project
run it; the runner publishes as ReadyToRun or native AOT (-p:ColdAot=true). Under the JIT the run is dominated by
start-up and JIT time: about 55 ms to reach main and 60 to 140 ms of JIT for the compiler before any validation,
so every corpus takes 120 to 320 ms against Blaze's 6 to 250 ms (median ratio 14.6, geojson and openapi the only
ones within 3x). ReadyToRun removes most of the JIT but keeps the runtime start-up: 42 to 330 ms (median ratio
4.4). Native AOT removes both: 3.6 to 155 ms, faster than Blaze on all 37 corpora, median ratio 0.50, totals 445 ms
against 1024 ms. Under AOT the schema compile is 0.1 to 3 ms for all but two corpora (krakend 6 ms and ui5-manifest
10 ms, their regular expressions), and loading a program image instead brings ui5-manifest from 26 to 15 ms; the
validation pass, parse included, is 0.5 to 12 ms for all but geojson (108 ms, large instances) and openapi (28 ms).
Blaze's own compile is what costs it on the large schemas: ui5-manifest 165 ms and openapi 100 ms end to end.
(The end-of-day tables that supersede this one are in RuntimeEvaluatorMeasurements.md.)
Blaze's compile can be measured on its own through its compile --fast --minify command (wall time in a fresh
process less the 2.3 ms of a bare --version; it includes writing the template as JSON, so it slightly overstates).
Medians over the corpora: Blaze 2.9 ms, ours under native AOT 0.34 ms, a ratio of 0.11; the large schemas are where
it tells, ui5-manifest 207 ms against 9.9, krakend 64 against 6.3, ui5 39 against 3.0, cmake-presets 24 against 1.7.
Blaze's templates are big (ui5-manifest's is 41 MB of JSON, cql2's 5.5 MB), so its validate --template, the
analogue of our program image, is no faster than compiling on those: parsing the template costs what the compile
did. Precompiled cold start like for like (Blaze validate --template against our AOT runner loading an image):
medians 9.8 ms against 4.7 ms, a ratio of 0.49, ours faster on all 37.
The warm measurement under the same three builds (the runner's warm command, one process per corpus, fresh Blaze
run): the JIT's tier-1 code is fastest on 30 of 37 at a geometric mean of 0.75 against Blaze; ReadyToRun 23 of 37
at 0.87; native AOT 23 of 37 at 0.88, about 17% behind the JIT at the median. The machine's instruction set instead
of the x64 baseline moves AOT by 2 to 4%, so the difference is dynamic PGO (guarded devirtualisation and
profile-driven layout), which native AOT lacks. The trade is therefore start-up against steady state: native AOT
starts in 4 to 25 ms where the JIT takes 120 to 320 ms, and settles 15 to 20% slower per evaluation. Table:
the end-of-day table in RuntimeEvaluatorMeasurements.md.
Regular expressions without a regex (2026-09-12, late)
Native AOT has no compiled regular expressions, so every pattern the matcher kinds did not cover ran in the
interpreter there, and the harness's patterns command counted 68 such pattern nodes across the corpora (335 in
all). The matcher's special kinds are replaced by one general form. A pattern is alternatives of atom sequences,
each anchored where its own ^ and $ say, so ^a|b|c$ anchors only its first alternative at the start and its
last at the end, and unanchored ones search (with a vectorised start when the first atom is one to three ASCII
bytes). A sequence qualifies when every variable atom but the last is followed by atoms that admit none of its
characters, up to the first that must consume something, so its greedy run ends exactly where the regex's would;
the atoms after the last variable one are fixed and are matched from the end. Groups of alternatives, optional
groups and small fixed repeats flatten into whole alternatives. Classes gained negation, \d \w \s and their
negations with ECMA-262's ASCII semantics (plus the non-ASCII members of \s, kept exactly), and literals outside
ASCII such as µ. A separated-list kind takes ^item(sep item)*$ and ^(item sep)*item$ when the separator
starts with a character the item's variable atom does not admit, with a different first or last item allowed. The
agreement test checks every pattern, the corpus ones included, against the translated regex on every input.
Regex-backed nodes fall from 68 to 8, the eight being backtracking shapes ([a-z]+[a-z0-9]+, a dot after a
variable atom, semver) and a lookahead. Under AOT cspell, krakend, ui5-manifest and unreal-engine-uproject are 20
to 25% faster; under the JIT, where the regex was compiled, they are 3 to 10% faster; jsconfig, whose one
alternation was a compiled literal search, sits 10% slower under AOT than the interpreted regex did and unchanged
under the JIT.
What a ReadyToRun library and a static profile would give (2026-09-12, late)
Two experiments with the cold runner, one process per corpus, medians over the corpora. Published framework-
dependent with PublishReadyToRun (the runner and the library precompiled; the shared framework already is) the
cold run drops from 107 ms to 53, from an image 79 to 41, the compile step 56 to 24 ms and the first validation
pass 20 to 3.9 ms: shipping the library precompiled would halve cold start for every consumer under the JIT. The
steady-state measurement under the precompiled library read slower on the smallest corpora (yamllint 1.49x,
helm-chart-lock 1.20x), which says the precompiled code had not been replaced by tier-1 code with a profile within
the warm-up; to be settled before it is recommended.
And the AOT gap is dynamic PGO: with DOTNET_TieredPGO=0 the JIT's steady state is 1.14x slower at the median and
native AOT is 1.12x, matching corpus by corpus (cspell 1.33 against 1.31, draft-04 1.27 against 1.33, helm-chart-lock
1.16 against 1.16, yamllint 0.98 against 1.06). ILC takes a static profile (--mibc, the MibcFile item), which
is how the framework itself is profile-optimised under AOT, so a profile of the corpus run through dotnet-pgo, built
from the runtime repository since it is not shipped, should recover most of the gap; the listings with and without
PGO (DOTNET_JitDisasm on the hot loops) are the map for the alternative of making the devirtualised sites direct
in source.
dotnet-pgo, built from the runtime repository (src/coreclr/tools/dotnet-pgo; the clr.tools restore needs the
NuGet audit off for an unrelated project), turns an instrumented trace of the warm run over every corpus
(DOTNET_TieredPGO=1, a call-count threshold of 10,000 so methods stay instrumented long enough, ReadyToRun=0,
the runtime provider at keyword 0x1E000080018 level 5) into a MIBC of 2,656 methods of ours. Published with it
(MibcFile, the runner's ColdMibc property; measure.sh profile), native AOT goes from 1.15x the JIT's steady
state to 1.08x at the median: importmap 1.18 to 0.93, openapi 1.10 to 0.98, yamllint 1.09 to 1.01, jsconfig 1.27
to 1.07, cspell 1.30 to 1.11, draft-04 1.33 to 1.16. Half the gap, from a four-second trace; the rest is what the
static profile cannot express or ILC does not use.
The ReadyToRun anomaly is real and has a different cause than expected. Forcing the JIT on the precompiled build
(DOTNET_ReadyToRun=0) restores parity (yamllint 20.7 to 14.9 µs, helm-chart-lock 247 to 205). The tier listing
(DOTNET_JitDisasmSummary) shows the hot methods are re-jitted from the precompiled code, through an instrumented
tier 1 to tier 1, but the tier-1 code they end with is a quarter of the size the pure-JIT process produces
(EvaluateFlagRaw 585 bytes against 2,420): on that path the JIT does not inline the loops into the entry, and a
profile given to crossgen (PublishReadyToRunPgoFiles) changes nothing since the runtime re-jits anyway. What
works is partial precompilation: a profile of the compile phase alone (a trace of prepare, which compiles every
schema and writes its image: 1,679 of our methods, none of the evaluation loops) and crossgen's --partial
(the runner's ColdPartial property), so the compiler is precompiled and the evaluation loops stay with the JIT.
Cold start keeps most of the full-R2R gain (yamllint 50 ms against 49 and 84 for the JIT, helm-chart-lock 55
against 54 and 89, ui5 113 against 88 and 159) and steady state is at JIT parity on every corpus (yamllint 14.2
against 14.0 µs, ui5 421 against 423). That is the form to ship for JIT consumers; measure.sh profile produces
both profiles.
What the profile buys, in source terms, was tried and rejected. Without a profile the public Evaluate is a
37-byte stub: it calls Evaluator.Evaluate, which calls the flag-mode entry, which dispatches the root plan, three
calls per evaluation; with the profile the JIT inlines the chain into a 2.9 KB entry. Marking the first two links
AggressiveInlining reproduces that chain everywhere, and native AOT gains 4% from it at the median (yamllint 8%),
less than the MIBC gives (1.08 to 1.04 against the JIT). But the JIT with the attribute loses what it had gained on
yamllint (0.79 of the pre-round baseline to 1.01): forcing the inline at the entry changes what the JIT then
inlines inside it, the same lesson as an earlier attempt to inline the flag-mode entry. The attributes are not in
the tree; the static profile is the route for AOT and the JIT keeps its own judgement.
The entry floor (2026-09-13)
What an evaluation costs before it reads the instance, measured by putting synthetic schemas over yamllint's own
instances (984 objects of about 350 bytes): the empty schema {}, {"type":"object"}, and yamllint's root reduced
to its one property. On the harness (per evaluation, clock overhead subtracted): the empty schema 7.5 ns, the type
test free, the one-property root 13 ns, yamllint itself 13.5. Blaze on the same files: the empty schema under 1
ns, the one-property root 6.9, yamllint 7.3. So the fixed cost of entering an evaluation was more than half of
yamllint, the whole of importmap's gap and a third of helm-chart-lock's, and Blaze pays next to nothing for it: a
compiled closure with no state to build.
The tier-1 listing of the public Evaluate on the empty schema, about 110 instructions, showed where the 26 cycles
went: a five-load dependent chain to reach the node the evaluation enters (program, its node array, the root, its
flag entry, that node's plan), two bounds checks on the way, the raw-access object built from the document and
copied into the state beside the spans, the spans spilled to the stack and reloaded, and the depth limit and entry
resource each fetched through two more loads. The evaluator now caches the entry data (node array, entry node, its
resource, the depth limit) in one immutable object per JsonSchemaEvaluator, published atomically and rebuilt
when the program's node array changes (an entry point added), so the chain is two loads; the document writes its
spans and its text memory straight into the state's fields through out parameters; and the raw-access object is
gone from the state, the one memory-returning accessor slicing the text memory itself. The frame went from 216 to
168 bytes (five zeroing stores from seven). Alternating A/B against the committed head: the empty schema 0.68
(8.7 to 5.9 ns), yamllint 0.89, importmap 0.96, jsconfig 0.97, aws-cdk 0.98, cypress 0.99, the large corpora
within noise. What remains of the floor is the frame (six callee-saved registers, the zeroing of a state that
holds eight GC references), the exact-type checks of the document, and the state's field writes; a smaller state
would need the scope and collector fields moved behind an indirection the general path pays for, which was not
worth it for another nanosecond.
Names as words, and three loops without a profile (2026-09-13)
The strict loop's tier-1 code on helm-chart-lock spends about 78 instructions on a type-only property row, 35 of them in the name lookup: the byte-position table needs a length-bucket load, a table load, the key with its null check and a two-word equality. Most property names are short. The name map now keeps its keys of at most eight bytes as zero-padded words grouped by length; a name of that length, with eight bytes of text readable from its start (checked once), is read as one word, masked to its length and compared against the keys of that length, three at most (a length with more keeps the table: comparing words in turn loses to it, which the first take showed on draft-04 and its seven seven-byte keywords). Both object loops take the name's location and length from its row through a document-access member and hand the map the text span, so nothing is sliced before the lookup. Against the head: aws-cdk 0.88, helm-chart-lock 0.90, importmap 0.92, pulumi 0.94.
The first take also left draft-04 at 1.14, and the JIT's compilation summary said why. Three evaluation methods were compiled "Tier-0 switched to FullOpts": a method with a loop and a stackalloc cannot be entered mid-way by on-stack replacement, so the JIT compiles it fully optimised at once and never instruments it. The general object loop, the fused object and the in-place anyOf had run all along without the profile-driven inlining every other loop is tuned by, and any code added to them was compiled by heuristics alone. The object loop and the anyOf now take their inline bitset from a fixed-size struct local (a span over it, no stackalloc); the fused object's seven buffers are allocated by a wrapper without a loop and passed in. With both changes, against the head: draft-04 0.94, cspell 0.89, ui5-manifest 0.93, jsconfig 0.96, cmake-presets 0.97, the rest within noise. One reading to know about: in a process that runs many corpora, the newly profiled loops take a profile shaped by the corpora that ran first (geojson read 1.15 in the shared-process A/B and 1.00 in its own), which is the per-process measurement rule again.
The same word comparison serves enum string values: the string-set match takes the value's location and length
from its row and hands the map the text span, so an enum value of up to eight bytes ("3.0", "library") is one
masked word against the keys of its length. Against the head: krakend 0.95, jsconfig 0.97, stale and ui5-manifest
0.98, ui5 0.99. The fixed-size bitset struct's span is MemoryMarshal.CreateSpan on .NET and a span over the
local's address on netstandard 2.0, which lacks it: the first gate caught that.
Where ui5 spends its time (2026-09-13)
A CPU sampling profile of the ui5 loop (dotnet-trace with the sample profiler, report topN) put 38% of the run in
the fused object's core, 17% in the conditional plan and 8% in the string-set match. ui5's root is a chain of
if/then/else keyed on specVersion, kind and type, and the fused objects that its branches become are large: the
harness's plan dump now prints their sizes, and the biggest has 48 contributing branches, 16 conditions and, for
nine property names, 74 applications: one per branch that defines the name, up to sixteen for kind. The deferred
pass then applied every active branch's test to the value, and most of those tests are the same test. Applications
with the same resolution (the same type-only test, the same string set, the same child node) are now coalesced at
compile time into one that lists its branches and is applied once when any of them is active (a branch of an
alternative group only merges within its branch, and the primary is an unconditional branch when there is one,
so the row loop applies it straight away); the fused row loop also looks names up by word. ui5 0.87 against the
head, the rest unchanged in their own processes.
What the dump also showed is that many applications stay distinct because they point at different nodes with
the same content: ui5 restates kind's enum and customConfiguration's schema at every version level of the
chain as separate subschemas. The compiler can point flag-mode references at one representative of each set of
identical subschemas (same resource, identical source text, no identifier keyword inside; collecting mode keeps
every node with its own location and paths, and a program with a dynamic scope is left alone since a node's
meaning there can depend on the path that reached it). On ui5's biggest fused node that takes the applications
from 74 to 57; the copies that remain distinct differ in content from version to version. Measured on this corpus
it is neutral at steady state (ui5 within 1% of the fused change alone, the rest within noise in their own
processes) and free at compile time (the comparison is by resource and text length first, then bytes), so it is
on by default (CORVUS_RT_NO_CANONICAL=1 turns it off) for the schemas that restate subschemas more than ui5 does.
It runs in both builds of the compiler: the runtime one reads a subschema's text from the parsed document's rows,
the source generator's (which compiles through System.Text.Json and embeds the image, whose flag-mode references
are stored as computed) through the element's raw text, so generated models get the same graph.
The four-axis table (2026-09-12, evening)
One table, eight implementations by 37 corpora, four measures each (the current version of it, with a fifth measure, is RuntimeEvaluatorMeasurements.md).
The rows pair Blaze's two paths (compile at run time; validate from its precompiled template) with ours: the runtime
evaluator compiling at run time, the runtime evaluator loading a program image, and the shipping generator's
strongly typed models, each under the JIT and as native AOT. The measures: cold (one fresh process, prepare, read
the instances, validate each once), warm (per evaluation at steady state), compile (the preparation step alone;
for generated code, its first evaluation), and memory as bytes allocated per evaluation at steady state (the
harness's GC.GetAllocatedBytesForCurrentThread over the timed loop; Blaze's binary carries its own allocator, so
its column is not measurable from outside, though its heap is flat across benchmark loops). Medians over the corpora:
| implementation | cold | warm | compile | memory | warm faster than Blaze on |
|---|---|---|---|---|---|
| blaze-compile | 10.5 ms | 118 µs | 2.9 ms | n/a | |
| blaze-template | 9.8 ms | 118 µs | 0 | n/a | |
| corvus-runtime-jit | 154 ms | 80 µs | 77 ms | 0 B | 31 of 37 |
| corvus-runtime-aot | 5.2 ms | 108 µs | 0.34 ms | 0 B | 22 of 37 |
| corvus-image-jit | 108 ms | 80 µs | 26 ms | 0 B | 31 of 37 |
| corvus-image-aot | 4.7 ms | 108 µs | 0.16 ms | 0 B | 22 of 37 |
| corvus-generated-jit | 124 ms | 82 µs | 38 ms | 0 B | 30 of 37 |
| corvus-generated-aot | 5.2 ms | 91 µs | 0.17 ms | 0 B | 25 of 37 |
| corvus-generated-previous-jit | 132 ms | 352 µs | 26 ms | 0 B | 1 of 37 |
| corvus-generated-previous-aot | 6.7 ms | 513 µs | 0.07 ms | 0 B | 0 of 37 |
The generated rows needed a correction on the way. The benchmark model projects' C/ directories dated from August,
before Stage 0, so their first measurement was of the previous generator's per-keyword validation code: 4.4 times
slower than the runtime evaluator at steady state (352 against 80 µs at the median), behind Blaze on 36 of 37,
and a profile of helm-chart-lock showed why: document access through the IJsonDocument interface per row, a
generic property-matcher map running as the shared instantiation and returning a delegate per property, a
JsonSchemaContext pushed and committed per child, and type-only leaves evaluated through a call. Those rows are
kept as corvus-generated-previous. Regenerated with the current generator (Regenerate-CurrentBenchmarks.ps1),
every model embeds a precompiled program image and validates through the runtime evaluator, and measures as the
runtime rows plus a thin call: 82 µs under the JIT, 91 µs under AOT, ahead of Blaze on 30 and 25 of 37, agreeing
with the runtime evaluator on every instance. What the table settles: the runtime evaluator, and now the generated
code, allocate nothing per evaluation on any corpus; and under AOT every Corvus row starts in 5 ms against Blaze's
10.
A measurement lesson from the generated rows: their first take subtracted a clock overhead read at 140 to 480 ns instead of 14 on twelve corpora, a tier-0 loop in a method run once, which took a 100 µs corpus down to 25. The clock's cost is now taken from a warmed non-generic loop as the minimum over several batches, in both measurements.
The table taken again at the end of the day with everything above in place (tools/measure.sh profile, publish,
warm, cold, table; commit c44ee2b's matcher, the partial ReadyToRun with the compile-phase profile as the
R2R row, the static profile in the native AOT rows; overhead 12.6 to 14.4 ns on every row). Medians over the corpora:
| implementation | cold | warm | compile | memory | warm faster than Blaze on |
|---|---|---|---|---|---|
| blaze-compile | 10.2 ms | 115 µs | 2.9 ms | n/a | |
| blaze-template | 10.2 ms | 115 µs | 0 | n/a | |
| corvus-runtime-jit | 150 ms | 76 µs | 77 ms | 0 B | 31 of 37 |
| corvus-runtime-r2r (partial, profiled) | 60 ms | 79 µs | 0 B | 30 of 37 | |
| corvus-runtime-aot (profiled) | 4.7 ms | 90 µs | 0.36 ms | 0 B | 29 of 37 |
| corvus-image-jit | 125 ms | 76 µs | 25 ms | 0 B | 31 of 37 |
| corvus-image-aot (profiled) | 4.4 ms | 90 µs | 0.15 ms | 0 B | 29 of 37 |
| corvus-generated-jit | 119 ms | 80 µs | 42 ms | 0 B | 31 of 37 |
| corvus-generated-aot (profiled) | 5.1 ms | 84 µs | 0.18 ms | 0 B | 28 of 37 |
Against the morning's table the native AOT rows went from 108 to 90 µs (the matcher and the profile; 1.09 of the JIT at the median, from 1.35) and from 22 to 29 corpora ahead of Blaze, the generated AOT row from 91 to 84 and 25 to 28; the JIT rows gained the matcher's share (80 to 76 µs) and the partial ReadyToRun row is new: cold start at 60 ms against the JIT's 150, steady state at 1.00 of the JIT's. The runtime evaluator under the JIT is fastest on 31 of 37 at a geometric mean of 0.75 of Blaze; the four structural losses remain (helm-chart-lock 2.4, yamllint 2.2, ui5 1.75, importmap 1.4), and the two others (ui5-manifest, stale) are within a few percent.
The table again at the end of 2026-09-13, after the entry floor, the word lookups, the profile for the three loops, the fused coalescing and the canonical references (the profile re-recorded on this code; overhead 12.9 to 14.1 ns; the table with the parse-and-evaluate measure added later that day is RuntimeEvaluatorMeasurements.md):
| implementation | cold | warm | compile | memory | warm faster than Blaze on |
|---|---|---|---|---|---|
| blaze-compile | 10.1 ms | 116 µs | 2.9 ms | n/a | |
| blaze-template | 9.7 ms | 116 µs | 0 | n/a | |
| corvus-runtime-jit | 150 ms | 77 µs | 82 ms | 0 B | 31 of 37 |
| corvus-runtime-r2r (partial, profiled) | 53 ms | 79 µs | 0 B | 30 of 37 | |
| corvus-runtime-aot (profiled) | 4.5 ms | 86 µs | 0.34 ms | 0 B | 30 of 37 |
| corvus-image-jit | 122 ms | 77 µs | 25 ms | 0 B | 31 of 37 |
| corvus-image-aot (profiled) | 4.3 ms | 86 µs | 0.14 ms | 0 B | 30 of 37 |
| corvus-generated-jit | 115 ms | 76 µs | 36 ms | 0 B | 30 of 37 |
| corvus-generated-aot (profiled) | 4.8 ms | 85 µs | 0.18 ms | 0 B | 28 of 37 |
Corpus by corpus against the previous evening's table the runtime evaluator is at 0.94 under the JIT and 0.93 under native AOT at the median (yamllint, krakend, aws-cdk, clang-format and deno 0.86 to 0.88 under the JIT; aws-cdk, lerna, jsconfig, yamllint and importmap 0.77 to 0.80 under AOT), the generated models at 0.92 and 0.94; native AOT is 1.06 of the JIT at the median, from 1.09, the loops that now take a profile taking the static one too. Against Blaze: fastest on 31 of 37, geometric mean 0.72 (from 0.75); the structural losses are helm-chart-lock 2.3, yamllint 1.9, ui5 1.6 (from 1.75), importmap 1.4, with ui5-manifest and stale at parity.
The parse step (2026-09-13)
The four-axis table now carries a fifth measure, parse and evaluate once: one pass over the corpus parsing and
evaluating every instance once at steady state, the shape of a service validating each request (measure.sh warm
runs it for every build through the harness's parseeval; Blaze's figure is a differential of its CLI, ten copies
of the corpus minus one instance, less the CLI's per-instance bookkeeping of 1.25 µs measured with trivial
instances against the empty schema). Medians over the 37 corpora on 2026-09-13: Blaze 2.74 ms per corpus pass,
Corvus runtime JIT 484 µs, ReadyToRun 505, native AOT 550, generated models 484 and 551; Corvus faster on 37 of
37 at a geometric mean of 0.18, 5.5 to 6x per document. The rest of this section is the parse step on its own.
The warm figures on both sides time evaluation of an already parsed instance; parsing is only inside the cold
figures. Measured on its own (harness parse <loops> <corpus...>: each instance parsed and disposed, the sum over
instances of the per-instance best of three batches; Blaze by difference, the CLI validating ten copies of the
corpus against the empty schema minus one instance, per instance, which also carries the CLI's per-instance
bookkeeping, about 2 µs), nanoseconds per instance:
| corpus | bytes/instance | Blaze parse | Corvus parse | Blaze warm eval | Corvus warm eval |
|---|---|---|---|---|---|
| jsconfig | 177 | 2,040 | 224 | 195 | 155 |
| helm-chart-lock | 342 | 2,830 | 221 | 22 | 52 |
| yamllint | 352 | 4,070 | 357 | 7 | 13 |
| importmap | 630 | 3,110 | 253 | 17 | 24 |
| cspell | 820 | 6,120 | 710 | 268 | 190 |
| ui5 | 487 | 4,250 | 442 | 262 | 390 |
| cmake-presets | 2,721 | 15,500 | 1,760 | 1,970 | 1,700 |
| draft-04 | 12,658 | 58,600 | 7,150 | 5,900 | 3,300 |
| geojson | 52,434 | 360,000 | 99,200 | 13,700 | 10,600 |
| openapi | 166,132 | 636,000 | 96,900 | 58,500 | 28,600 |
Per kilobyte Corvus parses at 0.4 to 1.3 µs and Blaze at 4 to 12 (the small end inflated by the CLI's bookkeeping; the large instances, where parsing dominates, put it at 4 to 7), which is the price of the document model Blaze validates against: a tree of nodes with hashed property maps, built at parse time. That is where the parse-time options this project rejected would have taken us. For a workload that parses each document once and validates it once, the four warm losses reverse: helm-chart-lock's 2.3x becomes 2,850 against 270 ns per document, yamllint's 1.9x 4,080 against 370, and the large corpora are 3 to 6 times faster end to end. (A first take of this table had yamllint's parse at 862 ns: the parser is a large method that reaches its final tier only after sustained work, and three warm-up passes had left its escapes path at tier 0. The parse measurements warm for a second, pause past the tiering delay and warm again, as the evaluation ones do.)
Against Blaze (2026-09-11)
Blaze is run through the Sourcemeta jsonschema CLI release binary (benchmarks/.../tools/blaze-compare.py, see
its README) over the same 37 corpora with --fast. The CLI's benchmark mode times every evaluation of every
instance on its own, subtracts the clock's overhead, and reports the mean over the loop with no warm-up; the
harness's blazebasis <loop> command does exactly the same after a JIT warm-up, so the two are compared like for
like: 200 loops each, pinned, idle box. Geometric mean of our runtime over Blaze: 1.32. We are faster on jshintrc
(0.48), clang-format (0.53), omnisharp (0.59), cql2 (0.65), openapi (0.72), draft-04 (0.80), ansible-meta (0.85),
fabric-mod, pre-commit-hooks, geojson and deno (0.90 to 0.96); within 40% on sixteen more; and behind by two to
five times on helm-chart-lock (5.2), importmap (4.8), yamllint (4.1), ui5 (2.7), semantic-release (2.2) and krakend
(2.0). At 20 loops Blaze's means on the small corpora carry its cold first iterations, so more loops favour it there:
yamllint fell from 14 to 7.6 µs between 20 and 200 loops while ours held at 31.
The corpora where Blaze leads most are the ones with the smallest instances: yamllint is 984 instances at 8 ns each in Blaze against 32 ns in ours, helm-chart-lock 3,888 at 26 ns against 136 ns. That is fixed cost per evaluation and per object, not any keyword, which is consistent with the profiles and puts the object-plan inner loop and the per-evaluation entry path first among the remaining optimisations. The shipping generated code is 3.9 behind Blaze on the same geometric mean.
Open decisions
- Resolved: the evaluator is part of
Corvus.Text.Json(namespaceCorvus.Text.Json.RuntimeEvaluator, sources underCorvus/Text/Json/RuntimeEvaluator). Generated code needs no reference beyondCorvus.Text.Json, and code generated before the merge keeps compiling because the namespace and public API are unchanged; onlyinternaldetails moved.