feat: add compiled VM renderer#224
Conversation
|
@paganotoni Can you check why the tests failed to run? |
|
@Mido-sys it's time to update our CI here.
Same missing LC_UUID load command aborts every package built with -race on macOS ARM64 + Go 1.22.12. dyld kills test binary before any test runs. Other matrix jobs cancel because fail-fast strategy sees first macOS failure. This is Go 1.22 race-detector / macOS runner incompatibility, not a test assertion failure in PR code. |
|
@Mido-sys can you try pulling from main into your PR branch? |
|
@paganotoni Done. But some tests timed out. |
|
It's green now. Thanks. |
|
Great! Thanks for this. I learned something new given how fast the rendering is going. Apparently Windows Tick is 15ms and this is, of course, faster. |
This PR adds an opt-in compiled VM renderer for Plush while keeping the existing interpreter path as the default. The new renderer lives under
VM/and supports the same user-facing Plush behavior as the interpreter: expressions, variables, functions, closures, helpers, struct and map access, nested chains, loops, conditionals, partials, punch-hole rendering, render budgets, escaping, trim tags, optionalifparentheses, and mixed Go numeric comparisons.The root package can now switch render engines with
plush.SetRenderMode(plush.RenderModeVM)after importinggithub.com/gobuffalo/plush/v5/VM/plush. Applications that want direct compiled reuse can callVM/plush.Compileand render the returned template repeatedly.Major Changes
RenderModeInterpreterremains the default.RenderModeVMroutes rootplush.Renderthrough the compiled renderer when the VM package is imported.VM/plush.Compile.Compatibility
This is opt-in. Existing applications continue using the interpreter unless they explicitly enable VM render mode or call the VM package directly.
For repeated direct renders:
VM Bytecode Cache
The VM bytecode cache reuses compiled bytecode for file-backed templates. The interpreter already caches parsed ASTs through the template cache. This PR extends the same cache entry so the VM can store compiled bytecode beside the AST.
The first render of a cached file-backed template does this:
Later renders of the same filename can skip parse and compile:
The cache stores bytecode and execution plans, not request data. It does not cache context values, helper return values, struct fields, branch results, rendered HTML, or partial output. Each render still reads fresh values from the provided context.
The cache also respects Plush safety rules:
.plush,.plush.html, and compatible.htmltemplates can use the filename bytecode cache..htmlpartials still consult the current partial feeder when needed, so cached bytecode does not silently reuse stale partial source.Fast Paths
The VM includes automatic fast paths for common Plush shapes. These require no template changes:
strings.Builder<%= item.Name %><%= robots[0].Name.Echo() %>labels["status"]<%= count + 1 %>uint32 == 0andfloat32 == 3partial("row", {name: item.Name})partial("row", {label: label(item.Name, prefix)})Fast paths cache lookup plans and call plans, not the current values. For example, a loop over items may cache how to read
Namefrom the item type, but it still reads each item's currentNamevalue on every render.New Language And Runtime Behavior
This PR also brings the following behavior into both the interpreter and VM paths where applicable:
ifconditions #102int32,uint32,uint64,float32, andfloat64Benchmarks
Latest local checkpoint, comparing VM bytecode cache with interpreter AST cache:
These numbers come from local benchmark medians using
count=3and500msbenchtime. Exact results will vary by machine and template shape. One-shot VM rendering is intentionally not the headline number because parse and compile time are included; the main performance path is cached bytecode or compiled-template reuse.Integrated application validation was also run against a live local Buffalo application using response headers from the render engine. These measurements include real application helpers, nested partials, large generated output, request context setup, and application-specific template work, so they are intentionally more conservative than the VM microbenchmarks:
The live scenario tests were run by toggling the application render mode, warming each page, then collecting 100 HTTPS requests at concurrency 10 with
curl -k. The comparison uses theX-Plush-Render-Engine-Time-Msresponse header, not total ApacheBench wall time, so the numbers are scoped to the Plush render engine and the helpers/partials executed during render.These
X-Plush-*headers are new render diagnostics added for the VM work. They are intended for validation, profiling, and A/B testing; they are not nginx, Buffalo, or browser-provided timings.For each scenario:
PLUSH_RENDER_MODE=interpreter.PLUSH_RENDER_MODE=vm.X-Plush-Vm-Bytecode-Cache: hitandX-Plush-Fast-Path: fast.Example live benchmark commands:
The generated response body sizes were also measured from the live scenarios with
curlusing the downloaded body byte count. These are uncompressed body bytes as seen bycurlduring local HTTPS requests:The live benchmark headers mean:
X-Plush-Render-Engine-Time-MsX-Plush-Render-Modeinterpreterorvm.X-Plush-Vm-Bytecode-Cachehit; a first render may showmiss-store; interpreter mode reportsdisabled.X-Plush-Fast-Pathfastmeans the compiled fast plan handled the render;genericmeans first compile/generic execution/fallback behavior.X-Plush-Fast-Plan-*X-Plush-VM-Helper-Time-MsX-Plush-VM-Helper-CallsX-Plush-VM-Helper-Hotspotsname:calls:time_msform, for exampleformat_label:700:3465.659in an aggregated CSV sample.X-Plush-VM-Partial-Time-MsX-Plush-VM-Partial-CallsX-Plush-VM-Partial-Hotspotspartial:calls:time_msform, useful for identifying expensive nested partials.The tested pages had very different template shapes:
The light scenario is a compact template with one helper call, so it mostly measures the fixed overhead of entering the render engine and executing a simple plan. The mixed scenario exercises a wider template shape with many property reads, conditionals, loops, helper calls, and one partial. The heavy helper/partial scenario has fewer top-level compiled-plan operations than the mixed scenario, but it performs much more work at runtime because its partials and helpers expand into additional nested rendering and data-preparation work.
The heavy helper/partial scenario is intentionally a large stress case rather than a small synthetic template. It includes many nested partial renders and real template helpers, such as formatting helpers, asset/tag helpers, URL/path helpers, content helpers, and helpers that prepare structured data for output. In one VM hotspot sample, the render spent about 36.461ms/request inside helpers and about 157.198ms/request inside partial rendering. That means a large part of the remaining render time was not VM bytecode execution itself; it was work the VM correctly had to call into. This makes the scenario a useful ceiling check: the VM still wins, but once parse, compile, lookup, and opcode dispatch overhead are reduced, the next bottlenecks become helper logic, data formatting, and nested partial rendering.
Benchmark command:
Additional benchmark entry points:
Test Plan
Full package test suite:
go test ./...VM-specific package tests:
go test ./VM/...