Add opt-in compiled template artifacts - #72
Conversation
Variable::compile() exported the lookup node inline into the render body,
so every {{ ... }} ran deepclone_from_array() to rehydrate its node graph
plus a new Variable() on every render. Parsed templates build those once
at parse time, so the compiled path was doing strictly more work than the
thing it was meant to speed up.
Hoist the node into a constructor-built property via compileFallback(),
and mirror BodyNode::render() in BodyNode::compile(): Text children need
no hasInterrupt() guard, and other children bail out after the fact
instead of wrapping every child in a check.
ThemeBench render, interleaved A/B min-of-60 against the parsed path:
compiled went from 1.248x slower to 0.920x.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four costs stood between a compiled template and a parsed one, none of them doing any work the compiler could not already do: - CompiledTemplate::render() collected a Generator whose body always yielded exactly one chunk, and RenderTag::render() reached its partial through that same chain. Together they cost a Generator per nesting level on the most common node in a real theme. streamCompiled() is now renderCompiled(): string, and stream() yields it once. - BodyNode compiled to a closure passed to renderCompiledBody(), costing an allocation and a call frame per body render (268 per theme run). The body is inlined instead, with a do/while(false) giving the interrupt bail-out a target; each nesting level gets its own accumulator. - compileFallback() routed every non-compiled node through the static renderNode() helper (940 calls per theme run) which re-derived the Disableable/Tag check at runtime. Both the dispatch and the check are now resolved at compile time and inlined. renderCompiledBody() and renderNode() are gone; CompiledTemplate has no runtime helpers left. ThemeBench render, interleaved A/B min-of-100 against the parsed path: compiled 2.16ms -> 2.00ms, parsed 2.35ms -> 2.01ms, ratio 0.92x -> ~1.00x. The parsed path shares the RenderTag win, so the ratio holds at parity while both got faster in absolute terms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two costs an xdebug profile of the storefront theme put at the top of the
render path, both shared by compiled and parsed templates:
- newIsolatedSubContext() built a RenderContext whose constructor merged
the environment registers into a fresh ContextSharedState, then threw
that state away on the next line by assigning the parent's. Every
{% render %} paid for it: 1105 times per theme run. The constructor now
accepts the state to adopt.
- Arr::set() exploded the key and walked the path even when there was no
path to walk. Setting a loop variable or a partial variable is the
common case and is now a direct assignment.
Interleaved A/B min-of-100 over the four storefront pages:
2.02ms -> 1.83ms on both paths (-9%), parity between them unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A for body was the last thing in a compiled template still walked node by node on every iteration, so a loop-heavy template gained nothing from compilation. Rather than emit the loop semantics as code, only the two bodies are compiled, each into its own private method, and ForTag receives them as closures. The segment, scope, forloop drop, register and interrupt handling stay in the tag, already tested and unchanged, so nothing about break, continue or the else branch had to be reimplemented in codegen. CompilerContext grows compileBodyToMethod(), which any tag that cannot be compiled itself can now use for its bodies. Attributed by toggling the feature alone: - 500-row loop template: 1.001x -> 0.908x against the parsed path - ThemeBench pages: 0.998x -> 0.991x (its loops are short) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…orter Every value a compiled template could not turn into code was exported through VarExporter, whose format rebuilds an object graph by writing properties directly. That works for any shape, but each graph costs a deepclone_from_array() hydration pass every time the template is instantiated -- and 61% of them were plain Variable/VariableLookup pairs that a constructor call describes exactly. CanBeExported lets a value emit its own constructor expression, with null declining so anything unusual keeps the VarExporter path. Implemented for Variable, VariableLookup, RangeLookup and Condition. What gets rebuilt is what the compiled template reads: a Condition body is left out because the compiler already emitted it as code and only evaluate() is ever called. Storefront theme, 29 templates: deepclone graphs 190 -> 62 artifact bytes 383K -> 291K instantiation 0.376ms -> 0.222ms with opcache (-41%) Render time is unchanged -- the objects are identical once built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Variable::compile() only called compileFallback(), which is exactly what subcompile() does for a node that does not implement CanBeCompiled, so implementing the contract bought nothing. Removing it leaves the generated artifacts byte-for-byte identical. The contract it does belong to is CanBeExported: a variable is not turned into code, its lookup is resolved at runtime, so the compiler keeps the object and only needs it rebuilt cheaply. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Preserve node-level error handling for yieldless compiled nodes - Keep compatibility with legacy compiled node generators
PHPBench comparison (PHP 8.2)PHP 8.2 | Runner ubuntu24 | Base
|
PHPBench comparison (PHP 8.3)PHP 8.3 | Runner ubuntu24 | Base
|
PHPBench comparison (PHP 8.5)PHP 8.5 | Runner ubuntu24 | Base
|
PHPBench comparison (PHP 8.4)PHP 8.4 | Runner ubuntu24 | Base
|
- Show PR-only benchmarks with throughput in comparison output - Add regression coverage and document the updated behavior
# Conflicts: # performance/benchmarks/ThemeBench.php # src/Render/RenderContext.php # src/Tags/CaseTag.php # src/Tags/ForTag.php # src/Tags/IfTag.php # src/Tags/RenderTag.php # src/Tags/UnlessTag.php # src/Template.php
Summary
CanBeCompiledextension seam with safe runtime fallback for unsupported nodes and tagsPerformance
The benchmark suite now separates compile, artifact load, compiled render/stream, interpreted render/stream, and template-cache load/render costs.
The latest cache comparison measured
benchLoadAndRenderCompiledat approximately 18,031 ns/op (1.36% RSD). The compiled path is currently slower than the existing cache implementations, so optimization is intentionally deferred to follow-up work.Validation
git diff --check