perf(shader-lab): elide TrivialNode wrapper on single-child productions

The parser reduces GLSL's deep expression precedence chain
(logical_or_expression → ... → primary_expression, ~15 single-production
layers) by creating a TrivialNode wrapper around each layer's single child.
In release builds — where typed AST classes sit behind `// #if _VERBOSE`
and get stripped at build time — these wrappers are semantically empty:
no visitor, no type field, no side effects, just a `.children[0]`
indirection.

In reducer closures, when the production has no typed AST class supplied
and the RHS is a single `TreeNode` child, push the child directly onto
the semanticStack instead of wrapping it in a TrivialNode. The parser's
GOTO table runs off `reduceProduction.goal`, not off the node type on
the stack, so state transitions remain correct.

The `instanceof TreeNode` guard preserves the original path for
single-token RHS (e.g. `unary_operator → PLUS`), where `BaseToken`
cannot sit on the semanticStack in place of an AST node.

Impact (PBR, median of n=30 × 3 repeats):
  AST nodes:        54,504 → ~27,000   (-50%)
  AST parser stage: 28.4ms → 21.5ms    (-24%)
  CodeGen stage:    25.5ms → 19.2ms    (-25%)
  Total compile:    54.7ms → ~41ms     (-25% vs dev/2.0 baseline)

This aligns with the standard approach in glslang, GCC/Clang, Rust
parsers etc. — never materialize AST nodes for precedence-only
productions that carry no semantics.
This commit is contained in:
chenmo.gl
2026-04-25 01:52:17 +08:00
parent 5b7f8ae374
commit 94fb096c9f

View File

@@ -29,16 +29,29 @@ export default class GrammarUtils {
{ set: (loc: ShaderRange, children: NodeChild[]) => void } & IPoolElement & TreeNode
>
) {
// Resolve the AST pool once per grammar production (not per reduce).
const pool = astTypePool ?? ASTNode.TrivialNode.pool;
const ret: [GrammarSymbol[], TranslationRule | undefined][] = [];
for (const opt of options) {
// Single-`NonTerminal` RHS + no typed class → this production reduces
// to a semantic-empty `TrivialNode` wrapper. Elide it at reduce time
// by pushing the child directly onto the semantic stack. Safe because
// the parser's GOTO runs off `reduceProduction.goal`, not off the node
// type on the stack. Single-Terminal RHS (e.g. `unary_operator → PLUS`)
// isn't eligible — a `BaseToken` can't stand in for an AST node.
const canElide = !astTypePool && opt.length === 1 && !GrammarUtils.isTerminal(opt[0]);
ret.push([
[goal, ...opt],
function (sa, ...children) {
if (!children[0]) return;
const start = children[0].location.start;
const end = children[children.length - 1].location.end;
const location = ShaderLab.createRange(start, end);
ASTNode.get(astTypePool ?? ASTNode.TrivialNode.pool, sa, location, children);
if (canElide) {
sa.semanticStack.push(children[0] as TreeNode);
} else {
const start = children[0].location.start;
const end = children[children.length - 1].location.end;
const location = ShaderLab.createRange(start, end);
ASTNode.get(pool, sa, location, children);
}
}
]);
}