Repository navigation
Conversation
|
I like those changes from a first glance. No API changes, so there'll be little chance of breakage. Do you mind converting this to a non-draft PR? Then I'll have a closer look. |
|
Self-review notes before this leaves draft. Verified locally: I also wrote a throwaway differential test that walks every Three things I'd like to settle before this is ready for review. 1.
|
Three corrections, all verified against the behaviour they claim to preserve.
Own properties only in the two AST loops. `streamContents` enumerates with
`Object.keys`, so `for...in` alone made `linkContentToContainer` and the
validator walk disagree with it about what counts as a child: an AST node with
a prototype carrying an enumerable property had that property treated as a
child, and `$container` written onto it. Unreachable for parser-built nodes,
which are object literals, but `linkContentToContainer` is public API and two
disagreeing definitions of "children" should not coexist.
The guard had also landed on the wrong loop: `assignMandatoryProperties`
iterates `typeMetaData.properties`, a generated object literal that cannot carry
inherited properties, while the two loops over caller-supplied `AstNode`s - the
only ones where it can matter - had none.
`for...in` plus `hasOwnProperty` gives exact parity with `Object.keys` while
still avoiding the per-node key array, so the allocation win is kept in full.
Distinct cache keys for categories containing a comma. `categories.join(',')`
made `['a,b']` and `['a', 'b']` collide on the same cached array. Joining with
the `\0` already used as the type separator keeps them distinct; the empty array
still keys distinctly from `undefined`.
Frozen cache entries. `readonly` is erased at runtime and `getCheckArray` hands
out the live entry, so a caller could `push` into it and poison the registry for
every subsequent lookup. Freezing before caching costs one call per distinct key
rather than per lookup.
Verified: tsc and eslint clean, full suite 1217 passed / 1 expected fail /
10 skipped.
The joined key still collided. `[]` and `[''] `both join to the empty string,
so both keyed to `Type\0`; a category containing the separator collided with
the two categories it looks like; and so did a type name containing it. Any
separator has this problem, because both the type name and the category names
are caller-supplied strings with no excluded characters.
`JSON.stringify([type, categories])` resolves it, but the key is built before
the cache is consulted, so it would run once per AST node - re-introducing a
per-node allocation on the path this change exists to clear. Measured on a
6516-document corpus it cost 4-9% against the joined key.
Keying on the identity of the categories array instead removes the question.
`validateAstNodes` passes the same array for every node of a document, so an
outer `WeakMap` from that array to a per-type `Map` turns each lookup into two
map reads with no allocation and no string building. Collisions are impossible
by construction rather than by choice of separator. Two arrays with equal
contents simply cache separately, which costs one extra resolution and stays
correct.
The outer map is a `WeakMap` so that a caller passing a fresh array per call
cannot make the registry retain it; `addEntry` replaces it rather than clearing.
Measured against the joined key, warm, median of 3, alternated over two rounds:
metadata (4191 docs) 695ms -> 673ms
api (920 docs) 428ms -> 409ms
api + checks 570ms -> 520ms
presentation (1062 docs) 578ms -> 542ms
presentation + checks 676ms -> 650ms
So it is both correct and faster than either string key.
Verified: tsc and eslint clean, full suite 1217 passed / 1 expected fail /
10 skipped.
|
concerns addressed |
Keying on the identity of the categories array made a mutation of that array invisible: `getCheckArray(type, cats)` with `cats = ['fast']` cached under the array object, and after `cats[0] = 'slow'` the next lookup returned the entry built for `'fast'`. Confirmed against the built registry - the same frozen array instance came back rather than a recomputed one. The cached entry now keeps a copy of the categories it was built for and the lookup compares contents before using it. Identity still selects the entry, so the common case remains two map reads, and the comparison is a length check plus one identity comparison per category - no allocation, and category lists are short. The copy is made only when an entry is built. This keeps what the identity key was for - no key string built once per AST node - without depending on callers treating the array as immutable, which was an unstated precondition the previous version silently required. Verified: `['fast']` -> 2 checks, mutated in place to `['slow']` -> 1, then pushed back to `['slow', 'fast']` -> 3. tsc and eslint clean, full suite 1217 passed / 1 expected fail / 10 skipped. The contents comparison is not distinguishable from noise in the corpus benchmark.
Draft, for the discussion in #2233.
Three changes across two independent findings. Both remove a per-AST-node allocation from a hot path; neither changes observable behaviour or a public signature. They are in separate files and can be split into two PRs if you would prefer to take them separately — say the word.
1. Parser:
linkContentToContainerandassignMandatoryPropertiespackages/langium/src/utils/ast-utils.tslinkContentToContaineris called fromLangiumParser.parse()with{ deep: true }, so it recurses over every node of every document. Per node it allocated an array fromObject.entries(node)plus a two-element array per own property, and a closure per array-valued property forforEach. Now afor...inwith an indexed inner loop.assignMandatoryPropertiesis called fromLangiumParser.construct()for every non-datatype node, and rebuilt an array withObject.values(typeMetaData.properties)each time, although that object is stable and owned by the generated reflection. Now iterates the keys, with the own-property guard yourguard-for-inrule asks for.Measured on a 6516-document / 7 MB workspace. The parse phase has a ±3% run-to-run spread on this machine, so this is the median of six runs with the change alternated on and off back to back, not a single A/B:
Standalone per-document parsing, indicative (3 repetitions, not alternated): 619 → 598 ms on 2095 documents of one grammar, 653 → 612 ms on 920 documents of another.
2. Validation:
validateAstNodespackages/langium/src/validation/validation-registry.tsanddocument-validator.tsThis is the larger of the two.
validateAstNodeswalks the AST with a genericTreeStreamand, for each node, callsgetChecks, which builds a fresh four-stage lazy pipeline —stream().concat().filter().map()— overwhelmingly to yield nothing, since most node types have no checks registered.In our profiles
langium/utils/stream.jswas the single largest self-time frame on every workload measured, 12.9–16.2% of CPU, and caller attribution charged essentially all of it tovalidateAstNodes.ValidationRegistrygainsgetCheckArray(type, categories), returning a cachedreadonly ValidationCheck[]. The registry is static once registration is done, so the list is computed once and invalidated inaddEntry.getCheckskeeps its exact signature and now returnsstream(this.getCheckArray(...)), so nothing public changes. This alone took the stream share from 16.2% to 8.0%.validateAstNodeswalks with an explicit stack instead ofstreamAst(...).iterator(), enumerating children exactly asstreamContentsdoes and pushing them in reverse so document order is preserved.prunebecomes "do not push children whenvalidateChildrenis false". It callsgetCheckArray, so no stream is constructed per node.Standalone per-document compiles (parse, then
validateDocument), warm, median of 3 after a discarded run:The gain is largely independent of how many checks are registered — what is removed is the per-node machinery, not the checks.
On a workspace build that parses 6516 documents but validates only 82, this is worth ~2.5%, which is what you would expect.
Verification
tscandeslintclean.validateAstNodes, since that change alters how the tree is traversed, so visit order and diagnostic order were exactly what needed confirming.Open questions
for...inacceptable for AST nodes given they are plain objects built by the parser, or would you prefer a cached key array per$typeforassignMandatoryProperties?getCheckArrayis additive. If you would rather not widen theValidationRegistrysurface, the walk could callgetChecksand pay for oneStreamImplper node instead of four — most of the win survives, but not all of it.