Skip to content

Commit 432fbbe

Browse files
committed
perf: reduce parser and renderer allocations
1 parent d9fe594 commit 432fbbe

18 files changed

Lines changed: 398 additions & 97 deletions

BENCHMARKS.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Performance Benchmarks
2+
3+
This file records performance work that affects the template parser and
4+
renderer. It includes experiments that were kept and approaches that were
5+
removed or rejected after evaluation.
6+
7+
## Reproduction
8+
9+
The measurements below were collected on 2026-08-12 with Go 1.26.5 on an
10+
Apple M1 (`darwin/arm64`). Each result is the median of eight one-second runs
11+
with one logical processor:
12+
13+
```bash
14+
GOMAXPROCS=1 go test -run '^$' \
15+
-bench '^(BenchmarkEngine_Parse|BenchmarkTemplate_Render|BenchmarkTemplate_RenderStructProperty|BenchmarkTemplate_RenderIncludes)$' \
16+
-benchmem -benchtime=1s -count=8 .
17+
```
18+
19+
Absolute timings vary across machines and thermal conditions. Allocation
20+
counts and large relative changes are more stable.
21+
22+
## Overall result
23+
24+
| Benchmark | Time before | Time after | Change | Bytes before | Bytes after | Change | Allocations before | Allocations after | Change |
25+
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
26+
| Parse | 48.47 ms | 41.16 ms | -15% | 62.61 MB | 13.74 MB | -78% | 136,095 | 130,077 | -4% |
27+
| Loop render | 540.87 µs | 441.59 µs | -18% | 315,880 B | 147,856 B | -53% | 12,248 | 11,247 | -8% |
28+
| Struct property render | 462.43 µs | 340.41 µs | -26% | 207,880 B | 95,856 B | -54% | 8,748 | 7,747 | -11% |
29+
| 100 repeated includes | 467.51 µs | 97.05 µs | -79% | 1,331,160 B | 84,272 B | -94% | 4,610 | 1,433 | -69% |
30+
31+
## Incremental trials
32+
33+
The table records adjacent benchmark sweeps, so each row isolates one change
34+
more closely than the overall comparison.
35+
36+
| Trial | Benchmark | Time | Bytes/op | Allocs/op | Decision |
37+
| --- | --- | ---: | ---: | ---: | --- |
38+
| Pool generated yacc parsers | Parse | 48.47 → 36.21 ms | 62.61 → 19.61 MB | 136,095 → 128,100 | Kept: large time and memory reduction |
39+
| Preallocate scanned tokens | Parse | 36.21 → 36.81 ms | 19.61 → 13.71 MB | 128,100 → 128,077 | Kept: neutral time, 30% fewer bytes, three-line change |
40+
| Pass render contexts by pointer | Loop render | 540.87 → 477.73 µs | 315,880 → 163,888 B | 12,248 → 12,249 | Kept: 12% faster and 48% fewer bytes |
41+
| Precompute literal values | Loop render | 477.73 → 430.79 µs | 163,888 → 147,856 B | 12,249 → 11,247 | Kept: 10% faster and 1,002 fewer allocations |
42+
| Compile include arguments once | Repeated includes | 461.66 → 377.95 µs | 272,883 → 232,078 B | 4,511 → 3,811 | Kept: 18% faster |
43+
| Stream partial output | Repeated includes | 377.95 → 343.03 µs | 232,078 → 219,277 B | 3,811 → 3,411 | Kept: 9% faster and avoids a temporary result string |
44+
| Render-scoped compiled partial cache | Repeated includes | 343.03 → 107.12 µs | 219,277 → 100,273 B | 3,411 → 1,633 | Kept: 69% faster while preserving mutable stores |
45+
| Cache struct metadata | Struct property render | 441.93 → 336.65 µs | 207,904 → 95,904 B | 8,748 → 7,748 | Kept: 24% faster and 54% fewer bytes |
46+
47+
Literal values now allocate their runtime wrappers during parsing instead of
48+
during every render. This slightly offsets the parser pool's allocation-count
49+
reduction, but benefits every subsequent render of a compiled template.
50+
51+
## Tried and not kept
52+
53+
- **Process-wide compiled partial cache:** Rejected after the design trial. A
54+
cache shared across renders would require template-store invalidation,
55+
concurrency control, and configuration revision tracking. The render-scoped
56+
cache obtains the large repeated-partial win, rereads the store, compares the
57+
source bytes, and cannot become stale across top-level renders.
58+
- **Eager render-scoped cache allocation:** Implemented initially, then removed.
59+
It charged templates that never render partials and allocated a discarded map
60+
for every child context. The final implementation initializes the map only on
61+
the first partial compilation.
62+
- **Caching missing struct properties:** Implemented initially, then removed.
63+
It could retain an unbounded set of user-controlled property names in a
64+
process-wide map. The final cache stores only successful lookups, whose count
65+
is bounded by the fields and methods of encountered types.
66+
- **Handwritten default-delimiter scanner:** Rejected before implementation.
67+
After parser pooling, regular-expression matching was no longer the dominant
68+
allocator. Reimplementing delimiter, trimming, and malformed-token behavior
69+
would add substantial compatibility risk for a smaller remaining CPU target.

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,26 @@
33

44
## Unreleased
55

6+
### Performance
7+
8+
- Benchmarks on Apple M1 improved as follows (median of eight runs): parsing is
9+
15% faster with 78% fewer allocated bytes; loop rendering is 18% faster with
10+
53% fewer bytes; struct-property rendering is 26% faster with 54% fewer
11+
bytes; and 100 repeated includes are 79% faster with 94% fewer bytes.
12+
- Reduced parse allocations by pooling generated expression parsers and
13+
preallocating template tokens.
14+
- Reduced render allocations by sharing the internal render context and
15+
compiling literal values once.
16+
- Compile include arguments once, stream partial output directly, and reuse
17+
unchanged compiled partials within a top-level render.
18+
- Cache successful struct property and method metadata lookups.
19+
- See [BENCHMARKS.md](BENCHMARKS.md) for measurements and rejected trials.
20+
21+
### Fixed
22+
23+
- Preserve `os.IsNotExist` compatibility when a template read fails and the
24+
template-store root closes successfully.
25+
626
## 1.9.1 (2026-08-12)
727

828
### Changed

expressions/expressions.y

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ loop_modifiers: /* empty */ { $$ = loopModifiers{} }
127127
;
128128

129129
expr:
130-
LITERAL { val := $1; $$ = func(Context) values.Value { return values.ValueOf(val) } }
130+
LITERAL { val := values.ValueOf($1); $$ = func(Context) values.Value { return val } }
131131
| IDENTIFIER { name := $1; $$ = func(ctx Context) values.Value { return values.ValueOf(ctx.Get(name)) } }
132132
| expr PROPERTY { $$ = makeObjectPropertyExpr($1, $2) }
133133
| expr '[' expr ']' { $$ = makeIndexExpr($1, $3) }

expressions/parser.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ package expressions
77

88
import (
99
"fmt"
10+
"sync"
1011

1112
"github.com/osteele/liquid/values"
1213
)
1314

15+
var yaccParserPool = sync.Pool{
16+
New: func() any { return new(yyParserImpl) },
17+
}
18+
1419
type parseValue struct {
1520
Assignment
1621
Cycle
@@ -52,14 +57,24 @@ func parse(source string) (p *parseValue, err error) {
5257
// FIXME hack to recognize EOF
5358
lex := newLexer([]byte(source + ";"))
5459

55-
n := yyParse(lex)
60+
n := parseWithPooledYaccParser(lex)
5661
if n != 0 {
5762
return nil, SyntaxError(fmt.Errorf("syntax error in %q", source).Error())
5863
}
5964

6065
return &lex.parseValue, nil
6166
}
6267

68+
func parseWithPooledYaccParser(lex yyLexer) int {
69+
p := yaccParserPool.Get().(*yyParserImpl)
70+
defer func() {
71+
*p = yyParserImpl{}
72+
yaccParserPool.Put(p)
73+
}()
74+
75+
return p.Parse(lex)
76+
}
77+
6378
// EvaluateString is a wrapper for Parse and Evaluate.
6479
func EvaluateString(source string, ctx Context) (any, error) {
6580
expr, err := Parse(source)

expressions/y.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

parser/scanner.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ func Scan(data string, loc SourceLoc, delims []string) (tokens []Token) {
4646
// TODO error on unterminated {{ and {%
4747
// TODO probably an error when a tag contains a {{ or {%, at least outside of a string
4848
p, pe := 0, len(data)
49-
for _, m := range tokenMatcher.FindAllStringSubmatchIndex(data, -1) {
49+
matches := tokenMatcher.FindAllStringSubmatchIndex(data, -1)
50+
tokens = make([]Token, 0, 2*len(matches)+1)
51+
for _, m := range matches {
5052
ts, te := m[0], m[1]
5153
if p < ts {
5254
tokens = append(tokens, Token{Type: TextTokenType, SourceLoc: loc, Source: data[p:ts]})

render/context.go

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ type TemplateStore interface {
6666
}
6767

6868
type rendererContext struct {
69-
ctx nodeContext
69+
ctx *nodeContext
7070
node *TagNode
7171
cn *BlockNode
7272
}
@@ -160,44 +160,65 @@ func (c rendererContext) RenderChildren(w io.Writer) Error {
160160
}
161161

162162
func (c rendererContext) RenderFile(filename string, b map[string]any) (string, error) {
163+
buf := new(bytes.Buffer)
164+
if err := c.RenderFileTo(buf, filename, b); err != nil {
165+
return "", err
166+
}
167+
168+
return buf.String(), nil
169+
}
170+
171+
// RenderFileTo renders a template directly to a writer while inheriting the
172+
// parent lexical scope. It is used as an optional internal extension to Context.
173+
func (c rendererContext) RenderFileTo(w io.Writer, filename string, b map[string]any) error {
163174
bindings := make(map[string]any, len(c.ctx.bindings)+len(b))
164175
maps.Copy(bindings, c.ctx.bindings)
165176
maps.Copy(bindings, b)
166177

167-
return c.renderFile(filename, bindings)
178+
return c.renderFileTo(w, filename, bindings)
168179
}
169180

170181
// RenderFileIsolated renders a template without inheriting the parent lexical scope.
171182
// It is intentionally not part of Context, so adding the render tag does not break
172183
// third-party Context implementations.
173184
func (c rendererContext) RenderFileIsolated(filename string, bindings map[string]any) (string, error) {
174-
return c.renderFile(filename, maps.Clone(bindings))
185+
buf := new(bytes.Buffer)
186+
if err := c.RenderFileIsolatedTo(buf, filename, bindings); err != nil {
187+
return "", err
188+
}
189+
190+
return buf.String(), nil
175191
}
176192

177-
func (c rendererContext) renderFile(filename string, bindings map[string]any) (string, error) {
193+
// RenderFileIsolatedTo renders a template directly to a writer without
194+
// inheriting the parent lexical scope.
195+
func (c rendererContext) RenderFileIsolatedTo(w io.Writer, filename string, bindings map[string]any) error {
196+
return c.renderFileTo(w, filename, maps.Clone(bindings))
197+
}
198+
199+
func (c rendererContext) renderFileTo(w io.Writer, filename string, bindings map[string]any) error {
178200
source, err := c.ctx.config.TemplateStore.ReadTemplate(filename)
179201
if err != nil && errors.Is(err, fs.ErrNotExist) {
180202
// Is it cached?
181203
if cval, ok := c.ctx.config.Cache[filename]; ok {
182204
source = cval
183205
} else {
184-
return "", err
206+
return err
185207
}
186208
} else if err != nil {
187-
return "", err
188-
}
189-
190-
root, err := c.ctx.config.Compile(string(source), parser.SourceLoc{Pathname: filename, LineNo: 1})
191-
if err != nil {
192-
return "", err
209+
return err
193210
}
194211

195-
buf := new(bytes.Buffer)
196-
if err := Render(root, buf, bindings, c.ctx.config); err != nil {
197-
return "", err
212+
root, ok := c.ctx.cachedPartial(filename, source)
213+
if !ok {
214+
root, err = c.ctx.config.Compile(string(source), parser.SourceLoc{Pathname: filename, LineNo: 1})
215+
if err != nil {
216+
return err
217+
}
218+
c.ctx.cachePartial(filename, source, root)
198219
}
199220

200-
return buf.String(), nil
221+
return renderWithContext(root, w, c.ctx.child(bindings))
201222
}
202223

203224
// InnerString renders the children to a string.

render/file_template_store.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,12 @@ func (tl *FileTemplateStore) ReadTemplate(filename string) ([]byte, error) {
3939
source, readErr := root.ReadFile(rel)
4040
closeErr := root.Close()
4141

42-
return source, errors.Join(readErr, closeErr)
42+
switch {
43+
case readErr == nil:
44+
return source, closeErr
45+
case closeErr == nil:
46+
return source, readErr
47+
default:
48+
return source, errors.Join(readErr, closeErr)
49+
}
4350
}

render/node_context.go

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package render
22

33
import (
4+
"bytes"
45
"maps"
56

67
"github.com/osteele/liquid/expressions"
@@ -11,25 +12,55 @@ import (
1112
// This type has a clumsy name so that render.Context, in the public API, can
1213
// have a clean name that doesn't stutter.
1314
type nodeContext struct {
14-
bindings map[string]any
15-
config Config
16-
exprCtx expressions.Context
15+
bindings map[string]any
16+
config Config
17+
exprCtx expressions.Context
18+
partialCache map[string]cachedPartial
19+
}
20+
21+
type cachedPartial struct {
22+
source []byte
23+
root Node
1724
}
1825

1926
// newNodeContext creates a new evaluation context.
20-
func newNodeContext(scope map[string]any, c Config) nodeContext {
27+
func newNodeContext(scope map[string]any, c Config) *nodeContext {
2128
// The assign tag modifies the scope, so make a copy first.
2229
// TODO this isn't really the right place for this.
2330
vars := make(map[string]any, len(scope))
2431
maps.Copy(vars, scope)
2532

2633
c.Config.StrictVariables = c.StrictVariables
27-
ctx := nodeContext{bindings: vars, config: c}
34+
ctx := nodeContext{
35+
bindings: vars,
36+
config: c,
37+
}
2838
ctx.exprCtx = expressions.NewContext(vars, c.Config.Config)
29-
return ctx
39+
return &ctx
40+
}
41+
42+
func (c *nodeContext) child(scope map[string]any) *nodeContext {
43+
child := newNodeContext(scope, c.config)
44+
child.partialCache = c.partialCache
45+
return child
46+
}
47+
48+
func (c *nodeContext) cachedPartial(filename string, source []byte) (Node, bool) {
49+
entry, ok := c.partialCache[filename]
50+
return entry.root, ok && bytes.Equal(entry.source, source)
51+
}
52+
53+
func (c *nodeContext) cachePartial(filename string, source []byte, root Node) {
54+
if c.partialCache == nil {
55+
c.partialCache = make(map[string]cachedPartial)
56+
}
57+
c.partialCache[filename] = cachedPartial{
58+
source: bytes.Clone(source),
59+
root: root,
60+
}
3061
}
3162

3263
// Evaluate evaluates an expression within the template context.
33-
func (c nodeContext) Evaluate(expr expressions.Expression) (out any, err error) {
64+
func (c *nodeContext) Evaluate(expr expressions.Expression) (out any, err error) {
3465
return expr.Evaluate(c.exprCtx)
3566
}

render/nodes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
type Node interface {
1212
SourceLocation() parser.SourceLoc // for error reporting
1313
SourceText() string // for error reporting
14-
render(*trimWriter, nodeContext) Error
14+
render(*trimWriter, *nodeContext) Error
1515
}
1616

1717
// BlockNode represents a {% tag %}…{% endtag %}.

0 commit comments

Comments
 (0)