Skip to content

Add opt-in compiled template artifacts - #72

Draft
cappuc wants to merge 47 commits into
mainfrom
feat/compiler-2
Draft

Add opt-in compiled template artifacts#72
cappuc wants to merge 47 commits into
mainfrom
feat/compiler-2

Conversation

@cappuc

@cappuc cappuc commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add an opt-in compiler that writes requireable PHP template artifacts
  • add the CanBeCompiled extension seam with safe runtime fallback for unsupported nodes and tags
  • preserve interpreted render/stream behavior, context boundaries, limits, interrupts, and exception metadata
  • add artifact safety checks, atomic publication, integration coverage, and compiler/cache benchmarks
  • document the Wayfinder decisions and rollout/performance gates

Performance

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 benchLoadAndRenderCompiled at 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

  • PHPStan
  • Pint
  • 857 Pest tests / 2,269 assertions
  • git diff --check
  • compiler and cache PHPBench groups

@cappuc
cappuc force-pushed the feat/compiler-2 branch from 166c653 to 96cb1ca Compare July 31, 2026 15:20
@cappuc
cappuc force-pushed the feat/compiler-2 branch from f1b4955 to e6879cc Compare July 31, 2026 22:25
cappuc and others added 16 commits August 1, 2026 10:01
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
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

PHPBench comparison (PHP 8.2)

PHP 8.2 | Runner ubuntu24 | Base f91f238 | PR dd12cf7 | 10 iterations x 20 revs | 1 warmup

Positive ops/s is faster. RSD above 5% is marked high.

Benchmark Base ops/s PR ops/s Delta ops/s RSD (base / PR) Delta memory Memory %
ThemeBench::benchParse 523.87 ops/s 531.79 ops/s +1.51% 0.78% / 2.12% +1.86 MiB +22.74%
ThemeBench::benchRender 399.61 ops/s 385.17 ops/s -3.61% 1.13% / 2.03% +1.86 MiB +21.69%
ThemeBench::benchStream 330.46 ops/s 318.92 ops/s -3.49% 1.74% / 1.23% +1.86 MiB +23.88%
ThemeBench::benchTokenize 1,111.58 ops/s 1,113.67 ops/s +0.19% 1.07% / 1.06% +1.87 MiB +25.38%
ThemeBench::benchRenderCompiled - 223.52 ops/s - - - -
ThemeBench::benchStreamCompiled - 205.43 ops/s - - - -
  • Improved (> +2%): 0

  • Neutral (within ±2%): 2

  • Regressed (< -2%): 2 (median -3.55%)

  • Worst throughput regression: ThemeBench::benchRender (-3.61%)

  • Total memory change: +23.34%

  • Missing in base result: ThemeBench::benchRenderCompiled, ThemeBench::benchStreamCompiled

  • Regression threshold (PHPBENCH_MAX_REG): 5.00%

  • Threshold status: PASSED

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

PHPBench comparison (PHP 8.3)

PHP 8.3 | Runner ubuntu24 | Base f91f238 | PR dd12cf7 | 10 iterations x 20 revs | 1 warmup

Positive ops/s is faster. RSD above 5% is marked high.

Benchmark Base ops/s PR ops/s Delta ops/s RSD (base / PR) Delta memory Memory %
ThemeBench::benchParse 561.67 ops/s 570.75 ops/s +1.62% 2.13% / 0.93% +1.88 MiB +22.67%
ThemeBench::benchRender 425.14 ops/s 401.81 ops/s -5.49% 2.06% / 2.05% +1.88 MiB +21.58%
ThemeBench::benchStream 355.28 ops/s 339.22 ops/s -4.52% 2.09% / 2.19% +1.88 MiB +23.73%
ThemeBench::benchTokenize 1,278.83 ops/s 1,272.18 ops/s -0.52% 1.88% / 1.82% +1.88 MiB +25.22%
ThemeBench::benchRenderCompiled - 230.60 ops/s - - - -
ThemeBench::benchStreamCompiled - 223.64 ops/s - - - -
  • Improved (> +2%): 0

  • Neutral (within ±2%): 2

  • Regressed (< -2%): 2 (median -5.00%)

  • Worst throughput regression: ThemeBench::benchRender (-5.49%)

  • Total memory change: +23.22%

  • Missing in base result: ThemeBench::benchRenderCompiled, ThemeBench::benchStreamCompiled

  • Regression threshold (PHPBENCH_MAX_REG): 5.00%

  • Threshold status: FAILED

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

PHPBench comparison (PHP 8.5)

PHP 8.5 | Runner ubuntu24 | Base f91f238 | PR dd12cf7 | 10 iterations x 20 revs | 1 warmup

Positive ops/s is faster. RSD above 5% is marked high.

Benchmark Base ops/s PR ops/s Delta ops/s RSD (base / PR) Delta memory Memory %
ThemeBench::benchParse 400.87 ops/s 424.61 ops/s +5.92% 2.08% / 2.58% +1.56 MiB +17.61%
ThemeBench::benchRender 310.48 ops/s 310.68 ops/s +0.07% 1.17% / 1.90% +1.98 MiB +22.88%
ThemeBench::benchStream 259.47 ops/s 256.04 ops/s -1.32% 1.87% / 1.99% +2.05 MiB +25.71%
ThemeBench::benchTokenize 1,138.93 ops/s 1,182.50 ops/s +3.82% 1.63% / 2.01% +1.96 MiB +26.63%
ThemeBench::benchRenderCompiled - 193.61 ops/s - - - -
ThemeBench::benchStreamCompiled - 181.15 ops/s - - - -
  • Improved (> +2%): 2 (median +4.87%)

  • Neutral (within ±2%): 2

  • Regressed (< -2%): 0

  • Worst throughput regression: n/a

  • Total memory change: +22.99%

  • Missing in base result: ThemeBench::benchRenderCompiled, ThemeBench::benchStreamCompiled

  • Regression threshold (PHPBENCH_MAX_REG): 5.00%

  • Threshold status: PASSED

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

PHPBench comparison (PHP 8.4)

PHP 8.4 | Runner ubuntu24 | Base f91f238 | PR dd12cf7 | 10 iterations x 20 revs | 1 warmup

Positive ops/s is faster. RSD above 5% is marked high.

Benchmark Base ops/s PR ops/s Delta ops/s RSD (base / PR) Delta memory Memory %
ThemeBench::benchParse 307.08 ops/s 325.79 ops/s +6.09% 2.10% / 2.57% +1.96 MiB +22.49%
ThemeBench::benchRender 236.41 ops/s 230.00 ops/s -2.71% 0.89% / 2.04% +1.91 MiB +22.19%
ThemeBench::benchStream 197.84 ops/s 196.05 ops/s -0.90% 1.25% / 1.37% +1.92 MiB +24.70%
ThemeBench::benchTokenize 859.08 ops/s 862.83 ops/s +0.44% 1.51% / 2.58% +2.13 MiB +29.22%
ThemeBench::benchRenderCompiled - 146.91 ops/s - - - -
ThemeBench::benchStreamCompiled - 136.73 ops/s - - - -
  • Improved (> +2%): 1 (median +6.09%)

  • Neutral (within ±2%): 2

  • Regressed (< -2%): 1 (median -2.71%)

  • Worst throughput regression: ThemeBench::benchRender (-2.71%)

  • Total memory change: +24.45%

  • Missing in base result: ThemeBench::benchRenderCompiled, ThemeBench::benchStreamCompiled

  • Regression threshold (PHPBENCH_MAX_REG): 5.00%

  • Threshold status: PASSED

cappuc added 3 commits August 3, 2026 16:44
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant