Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
169a27d
docs: define compiler wayfinder map
cappuc Jul 31, 2026
b10795d
feat: add compiler artifact foundation
cappuc Jul 31, 2026
01cf3a5
feat: add compiler extension fallback seam
cappuc Jul 31, 2026
1d435eb
refactor: compile nodes through CanBeCompiled
cappuc Jul 31, 2026
a6f72cf
refactor: add compiler writer API
cappuc Jul 31, 2026
6b6c5e6
refactor: make compiler writers fluent
cappuc Jul 31, 2026
d542b01
refactor: chain body node compilation
cappuc Jul 31, 2026
1aca53b
feat: preserve compiled render and stream parity
cappuc Jul 31, 2026
f61e31d
feat: harden compiler fallback seam
cappuc Jul 31, 2026
fa067a2
feat: harden compiled artifact publication
cappuc Jul 31, 2026
71efe91
perf: add compiler benchmark gate
cappuc Jul 31, 2026
383ad94
perf: benchmark compiled template cache loads
cappuc Jul 31, 2026
96cb1ca
perf: benchmark compiled theme rendering
cappuc Jul 31, 2026
47b4fb6
docs: finalize compiled template contracts
cappuc Jul 31, 2026
6a1d728
refactor: add template contract base
cappuc Jul 31, 2026
d4506cc
refactor: widen template execution seams
cappuc Jul 31, 2026
6103127
refactor: make compiled rendering stream-first
cappuc Jul 31, 2026
5a16fa8
refactor: generate var-exported compiled classes
cappuc Jul 31, 2026
468db51
fix: include fallback metadata in compiler errors
cappuc Jul 31, 2026
814c0a3
feat: compile conditional tags
cappuc Jul 31, 2026
ac27bff
test: cover compiled runtime partials
cappuc Jul 31, 2026
0327868
test: cover compiled control-flow parity
cappuc Jul 31, 2026
83c9e12
fix: compare compiled stream output by value
cappuc Jul 31, 2026
e6879cc
fix: harden compiled parity and load benchmarks
cappuc Jul 31, 2026
5ae5aab
refactor: rename template contract and parsed template
cappuc Aug 1, 2026
a5fed56
refactor: remove legacy template interface
cappuc Aug 1, 2026
70be7a6
refactor: share compiled theme benchmark setup
cappuc Aug 1, 2026
5f7585e
refactor: remove unused compiler cache abstractions
cappuc Aug 1, 2026
b2fd13c
perf: stop rebuilding variable nodes on every compiled render
cappuc Aug 1, 2026
20d7e83
perf: drop the generator and closure layers from compiled rendering
cappuc Aug 1, 2026
ba7ff22
perf: stop rebuilding shared state and walking paths for plain keys
cappuc Aug 1, 2026
1ae8794
perf: compile for-loop bodies into methods the tag drives
cappuc Aug 1, 2026
c5e7357
perf: rebuild the common compiled nodes with constructors, not VarExp…
cappuc Aug 1, 2026
707dd35
refactor: stop claiming Variable compiles to code
cappuc Aug 1, 2026
5126e9d
refactor: simplify compiled template output
cappuc Aug 3, 2026
7b89d85
refactor: stream compiled template output
cappuc Aug 3, 2026
a0d4e1f
refactor: inline compiled generator bodies
cappuc Aug 3, 2026
814c958
refactor: stream compiled fallback nodes
cappuc Aug 3, 2026
39e5718
perf: stream compiled for loop bodies
cappuc Aug 3, 2026
893f65a
refactor: use lazy iterable closures for compiled node boundaries
cappuc Aug 3, 2026
b5d144d
updated bench
cappuc Aug 3, 2026
89bb072
run bench on draft pr
cappuc Aug 3, 2026
bad1bb0
removed var exporter requirement
cappuc Aug 3, 2026
19c0da6
run benchmark on draft pr
cappuc Aug 3, 2026
60be1cc
report branch-only benchmark rows
cappuc Aug 3, 2026
34b67ae
Merge branch 'main' into feat/compiler-2
cappuc Aug 3, 2026
dd12cf7
Merge branch 'main' into feat/compiler-2
cappuc Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions .github/workflows/phpbench.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@ name: PHPBench PR Benchmark

on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review

permissions:
contents: read
Expand All @@ -23,12 +18,11 @@ env:

jobs:
benchmark:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4', '8.5']
php: [ '8.2', '8.3', '8.4', '8.5' ]
name: PHPBench (PHP ${{ matrix.php }})

steps:
Expand Down
16 changes: 16 additions & 0 deletions .scratch/compiler/issues/001-artifact-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Define the public compiler artifact contract
type: wayfinder:grilling
status: closed
assignee: Fabio Capucci
parent: ../map.md
blocks: []
---

## Question

What public compile API should write the PHP artifact, and what exactly should requiring that file return or define so callers can render it with the existing RenderContext contract?

## Resolution

`Environment::compile(Template $template, string $compiledPath)` is the additive public entry point. It writes a PHP artifact at the caller-provided path; the artifact defines a deterministic final generated class extending the abstract compiled-template runtime base and returns an instance implementing `TemplateInterface`. The compiled template exposes both `render()` and lazy `stream()`, with `render()` collecting the stream output. Existing parsing, rendering, and interpreted cache APIs remain unchanged. Cache identity and environment consistency remain application-managed.
16 changes: 16 additions & 0 deletions .scratch/compiler/issues/002-partial-graph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Define partial graph compilation and invalidation
type: wayfinder:grilling
status: closed
assignee: Fabio Capucci
parent: ../map.md
blocks: 001-artifact-contract.md
---

## Question

For templates that load partials through render/include tags, should compilation produce one artifact per template or a root artifact for the reachable graph, and what application-managed cache key and invalidation contract keeps the graph coherent?

## Resolution

Version one produces one PHP artifact per logical template. The compiled template keeps partial loading as a runtime lookup by template name, so the application can compile partials independently and replace only the artifact whose source changed. Static partial names discovered during parsing may drive precompilation or application-level dependency tracking; dynamic partials retain the existing runtime path. The compiler does not define cache keys or invalidation rules. Static partial inlining is deferred to [Evaluate static partial inlining](007-static-partial-inlining.md).
16 changes: 16 additions & 0 deletions .scratch/compiler/issues/003-runtime-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Define compiled render and stream parity
type: wayfinder:grilling
status: closed
assignee: Fabio Capucci
parent: ../map.md
blocks: 001-artifact-contract.md
---

## Question

Which observable behaviors must compiled render and stream preserve—output chunking, lazy execution, state and outputs, resource limits, and Liquid exception metadata—and how should the generated artifact expose those semantics?

## Resolution

Compiled artifacts must preserve the `TemplateInterface` contract for both `render()` and `stream()`. Streaming remains lazy and must produce the same complete output; chunk boundaries may differ from the interpreter. Compiled `render()` collects the compiled stream, while the standard `Template` retains separate render and stream implementations. Compiled execution must merge and persist shared outputs and errors, enforce the same render/assign/resource limits, preserve interrupt behavior, and attach the same template and source-line metadata to Liquid exceptions. A compiled path that cannot preserve these semantics uses a safe interpreter fallback for the affected node or fails compilation when that fallback cannot be reconstructed.
16 changes: 16 additions & 0 deletions .scratch/compiler/issues/004-extension-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Define compiler extension and fallback seams
type: wayfinder:grilling
status: closed
assignee: Fabio Capucci
parent: ../map.md
blocks: 001-artifact-contract.md
---

## Question

What stable interface should custom nodes and tags implement to emit optimized PHP, and what generic runtime fallback should handle existing or third-party tags that do not opt into direct compilation?

## Resolution

`CanBeCompiled` is an optional trusted PHP extension interface implemented by individual nodes or tags; its fluent compiler-context method emits the stream-oriented PHP body without changing `Tag`, `LiquidExtension`, `TagRegistry`, or filter registration APIs. Filters continue to resolve through the runtime context. Nodes and tags without the interface use their existing `stream()` or `render()` behavior through a fallback that is reconstructed with Symfony VarExporter and loaded once per artifact. Template-controlled text, names, and values never reach raw PHP emission. If a fallback node cannot be safely represented by VarExporter, compilation fails with the template name, node class, and source line.
18 changes: 18 additions & 0 deletions .scratch/compiler/issues/005-artifact-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
title: Define compiled artifact safety and deployment behavior
type: wayfinder:grilling
status: closed
assignee: Fabio Capucci
parent: ../map.md
blocks: 001-artifact-contract.md
---

## Question

What guarantees are required when writing and loading generated PHP files—safe source emission, path ownership, atomic replacement, corrupted artifacts, concurrent writers, and OPcache/deployment behavior?

## Resolution

The compiled artifact directory is trusted and application-owned; generated PHP is not sandboxed. Every template-originated string, name, and value must pass through Symfony VarExporter, and template content must never reach raw PHP emission or choose generated identifiers. Raw source-generation hooks are trusted compiler/plugin code, not template input. Compilation must fail clearly when a value or fallback node cannot be safely encoded or reconstructed. Generated class identities are deterministic from template/source content and do not include a compiler-version marker; the application owns invalidation.

Artifacts are written to a same-directory temporary file and atomically published, with deterministic content-based artifact/class identities. Loading validates the returned `TemplateInterface` object and treats corrupt or invalid files as cache misses. OPcache is invalidated after publication; deployments may use versioned or rebuilt artifact directories. Security coverage must include PHP-looking template payloads, quotes, escapes, control characters, and generated-source syntax validation. Existing interpreted caches remain unchanged.
16 changes: 16 additions & 0 deletions .scratch/compiler/issues/006-performance-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Define compiler performance and rollout gates
type: wayfinder:grilling
status: closed
assignee: Fabio Capucci
parent: ../map.md
blocks: 001-artifact-contract.md
---

## Question

Which representative workloads and separate tokenize, parse, compile, load, render, and stream measurements prove that compiled templates improve the target path without regressing correctness, memory, or normal interpreter performance?

## Resolution

The gate uses an identical baseline on `main` and one deterministic production-shaped storefront workload. It measures tokenize, parse, compile/write, fresh artifact require/load, compiled render, compiled stream, interpreted render/stream, and existing template-cache load/render separately. Correctness checks compare exact complete rendered output and fully consumed stream output outside timed subjects; stream laziness and error behavior remain covered by focused tests. A compiled path must improve beyond the existing ±2% noise band with RSD at or below 5%, avoid interpreter regressions and material memory growth, and report compile/write cost separately. Rollout remains opt-in; static partial inlining is evaluated only after this baseline is reliable.
24 changes: 24 additions & 0 deletions .scratch/compiler/issues/007-static-partial-inlining.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
title: Evaluate static partial inlining
type: wayfinder:grilling
status: closed
assignee: Fabio Capucci
parent: ../map.md
blocks: 003-runtime-parity.md, 006-performance-gate.md
---

## Question

When static partial dependencies are known at compile time, under what measured performance and semantic conditions should the compiler inline them into a parent artifact, and how would that affect invalidation, errors, streaming, and deployment?

## Resolution

Static partial inlining is a future opt-in optimization; version one keeps one artifact per logical template with runtime partial lookup.

A partial is eligible only when its name is a literal known during parsing, its complete transitive dependency graph is available and acyclic, and every participating node and tag can emit safe compiled code. Dynamic or unknown names, cycles, unsupported compilation, unsafe fallback, or incomplete dependency discovery retain runtime linking.

Inlining embeds the compiled partial body in the parent artifact but retains the partial's isolated `RenderContext` boundary; it must preserve complete render and stream output, output bags, template and line exception metadata, resource limits, interrupts, and current error handling. Chunk boundaries need not remain identical.

The parent artifact identity includes transitive dependency content hashes. The compiler does not impose a compiler-version component on generated class names; the application owns invalidation and may include its own artifact-format key. It must rebuild affected parents, publish a consistent artifact set atomically or through a versioned artifact directory, and never activate a parent with stale inlined dependencies.

Inlining is accepted only when exact output, error, and stream tests pass and the representative storefront benchmark improves compiled render and stream beyond the established noise band (more than 2%, RSD at most 5%) without interpreter regressions or material memory growth. If it does not clear that gate, runtime-linked artifacts remain the implementation.
35 changes: 35 additions & 0 deletions .scratch/compiler/map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Compiler Wayfinder

## Destination

Produce an implementation-ready, benchmark-backed design for an additive PHP compiler path in php-liquid: the existing interpreter remains unchanged; an explicit compile operation writes a PHP artifact that can be required and rendered; the design settles compiler interfaces, tag/node coverage, partial dependencies, runtime semantics, artifact handling, performance gates, and rollout.

## Notes

- Domain: php-liquid template compilation and compiled-template caching.
- Consult grilling, domain-modeling, research, and the existing benchmark conventions as tickets require.
- Planning only until the map is complete; implementation follows as a separate handoff.
- Compatibility is the default preference, not an absolute constraint.
- Existing tags remain supported; nodes/tags opt into direct compilation through an interface, with runtime fallback for non-compilable cases.
- The application owns environment consistency and invalidation, following the existing template-cache operational model.
- The generated artifact should be a PHP file that can be required and rendered; current interpreted behavior and current cache implementations are not changed by this effort.

## Decisions so far

- [Define the public compiler artifact contract](issues/001-artifact-contract.md) — Explicit compilation writes a caller-selected PHP artifact, and `require` returns a `Template`-compatible renderable object; existing APIs stay unchanged.
- [Define partial graph compilation and invalidation](issues/002-partial-graph.md) — Version one uses one artifact per logical template and runtime partial lookup; applications own precompilation and invalidation.
- [Define compiled render and stream parity](issues/003-runtime-parity.md) — Compiled execution preserves lazy chunked streams, state, limits, interrupts, and exception metadata, with interpreter fallback where needed.
- [Define compiler extension and fallback seams](issues/004-extension-seam.md) — Nodes and tags opt into direct PHP generation through `CanBeCompiled`; existing registrations and runtime fallbacks remain valid.
- [Define compiled artifact safety and deployment behavior](issues/005-artifact-safety.md) — Template literals are encoded as data, artifacts are trusted and atomically published, and invalid files fail closed as cache misses.
- [Define compiler performance and rollout gates](issues/006-performance-gate.md) — A main-baselined macro workload separates compile/load/render/stream costs, requires improvement beyond noise, and keeps rollout opt-in.
- [Evaluate static partial inlining](issues/007-static-partial-inlining.md) — Static, acyclic, fully compilable partial graphs may be inlined later with preserved partial context and stream semantics and transitive dependency hashes; runtime lookup remains the default until benchmark gates pass.

## Not yet specified

- Generated PHP line-to-Liquid debug maps beyond preserving Liquid source lines in runtime exceptions.

## Out of scope

- Making compilation the default execution path.
- Replacing or redesigning the existing interpreted template caches.
- Removing support for tags or requiring every existing tag to be rewritten before compilation can be used.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"benchmark": "phpbench run --group=default --warmup=1 --retry-threshold=5 --report=aggregate",
"benchmark:cache": "phpbench run --group=cache --warmup=1 --retry-threshold=5 --report=aggregate",
"benchmark:operations": "phpbench run --group=operations --warmup=1 --retry-threshold=5 --report=aggregate",
"benchmark:compiler": "phpbench run --group=compiler --warmup=1 --retry-threshold=5 --report=aggregate",
"profile": "phpbench xdebug:profile"
},
"config": {
Expand Down
48 changes: 40 additions & 8 deletions performance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,59 @@
composer benchmark # default group: the storefront theme
composer benchmark:cache # cache group: template-cache backends
composer benchmark:operations # operations group: individual operations
composer benchmark:compiler # compiler group: compiled/interpreted pipeline
php performance/profile-theme.php --output=profile.json
```

## What each group is for

The three groups have different jobs, and conflating them is how a benchmark suite
The benchmark groups have different jobs, and conflating them is how a benchmark suite
stops being useful.

**`default`** (`ThemeBench`) renders the storefront theme — 29 templates across
four pages. It answers *"did rendering get slower"* and nothing more. It cannot
tell you *what* got slower, because a regression in any one tag is averaged
across everything else. Don't expect it to localize.

**`cache`** (`TemplateCacheBench`) measures compilation and fresh-environment
loading for every supported template-cache backend.
four pages — with both interpreted `benchRender` and precompiled
`benchRenderCompiled` subjects. Compilation and artifact loading happen during
setup, outside the timed compiled-render subject. It answers *"did rendering get
slower"* and nothing more. It cannot tell you *what* got slower, because a
regression in any one tag is averaged across everything else. Don't expect it to
localize.

**`cache`** (`TemplateCacheBench`) measures template-cache build and
fresh-environment load+render for every supported backend. The compiled subject
builds deterministic PHP artifacts during setup, then
`benchLoadAndRenderCompiled` measures their filesystem-backed load+render path;
artifact compilation and cache setup are outside the timed boundary.

**`operations`** (`OperationBench`) measures single operations on tiny templates.
This is where per-feature sensitivity lives, and where a benchmark is allowed to
be unrealistic: an artificial template that does one thing 64 times is a better
instrument than a realistic page.

**`compiler`** (`CompilerBench`) measures compile/write, fresh artifact
require/load, compiled render, compiled stream, interpreted render and
interpreted stream as separate subjects over the same storefront fixture. All
template source reads, parsing, artifact setup and render data construction are
performed in setup; render and stream subjects only exercise their named runtime
path. Setup also compares complete compiled and interpreted output before timing begins,
including templates reached through partial lookup; stream chunk boundaries may differ.
The fresh artifact load subject invalidates filesystem metadata in a
`BeforeMethods` hook; its timed body requires and validates all artifacts in an isolated
PHP process, avoiding classes loaded during benchmark setup.

Run the compiler group with the same aggregate shape as the existing baseline:

```bash
vendor/bin/phpbench run --group=compiler --warmup=1 --retry-threshold=5 \
--report=aggregate --output=json > /tmp/php-liquid-compiler.json
php tools/phpbench-compare.php build/base.json /tmp/php-liquid-compiler.json
```

The current `build/base.json` contains only the four `ThemeBench` default-group
rows, so compiler rows appear as branch-only rows with their PR throughput and
are not treated as an improvement or regression. Establish a matching compiler
baseline on `main` before drawing compiler performance conclusions; the ignored
baseline artifact is intentionally not part of the repository.

The split is what lets the theme be realistic. Whenever realism and measurement
sensitivity conflict inside the theme, realism wins — sensitivity is not the
theme's job.
Expand Down Expand Up @@ -103,7 +135,7 @@ Known gaps, in rough priority order:
- **Coverage-only tags.** `tablerow`, `increment`, `decrement`, `ifchanged`,
`raw` and `doc` are unbenchmarked. Real themes barely use them, so they belong
in `operations` rather than in the theme.
- **`TemplateCacheBench` shape.** Six subjects are driven by six near-identical
- **`TemplateCacheBench` shape.** Seven subjects are driven by seven near-identical
`setUp*` wrappers around a string `match`; `ParamProviders` could reduce that
repetition. Each benchmark setup now receives a unique temporary cache path,
so concurrent runs do not share cache files.
54 changes: 54 additions & 0 deletions performance/Support/CompiledTemplatesCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace Keepsuit\Liquid\Performance\Support;

use Keepsuit\Liquid\Compiler\CompiledTemplate;
use Keepsuit\Liquid\Template;
use Keepsuit\Liquid\TemplatesCache\FilesystemTemplatesCache;

final class CompiledTemplatesCache extends FilesystemTemplatesCache
{
public function __construct(string $cachePath)
{
parent::__construct($cachePath, keepInMemory: false);
}

public function get(string $name): ?Template
{
$compiledPath = $this->getCompiledPath($name);

if (! is_file($compiledPath)) {
return null;
}

return $this->loadCompiledTemplate($compiledPath);
}

public function pathFor(string $name): string
{
return $this->getCompiledPath($name);
}

protected function getCompiledPath(string $name): string
{
return parent::getCompiledPath($name).'.php';
}

protected function saveCompiledTemplate(string $compiledPath, Template $template): void
{
throw new \LogicException('The compiled templates cache is read-only.');
}

protected function loadCompiledTemplate(string $compiledPath): ?Template
{
try {
$template = require $compiledPath;
} catch (\Throwable) {
return null;
}

return $template instanceof CompiledTemplate
? $template
: null;
}
}
Loading
Loading