Skip to content

Commit 7146946

Browse files
performance improvements
1 parent 33eccc1 commit 7146946

6 files changed

Lines changed: 463 additions & 35 deletions

File tree

bench_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,33 @@ func BenchmarkRender_Qwen3(b *testing.B) {
9292
}
9393
}
9494

95+
// =============================================================================
96+
// Chat template benchmarks – Qwen3.6 (hybrid model used by kronk benchmarks)
97+
// =============================================================================
98+
99+
func BenchmarkCompile_Qwen36(b *testing.B) {
100+
source := chatTemplates["Qwen3.6-35B-A3B-UD-Q4_K_M"]
101+
for b.Loop() {
102+
if _, err := jinja.Compile(source); err != nil {
103+
b.Fatal(err)
104+
}
105+
}
106+
}
107+
108+
func BenchmarkRender_Qwen36(b *testing.B) {
109+
tmpl, err := jinja.Compile(chatTemplates["Qwen3.6-35B-A3B-UD-Q4_K_M"])
110+
if err != nil {
111+
b.Fatal(err)
112+
}
113+
114+
b.ResetTimer()
115+
for b.Loop() {
116+
if _, err := tmpl.Render(chatBenchData); err != nil {
117+
b.Fatal(err)
118+
}
119+
}
120+
}
121+
95122
// =============================================================================
96123
// Complex template benchmarks – Gemma4 multi-turn tool calling (heaviest)
97124
// =============================================================================

builtins.go

Lines changed: 97 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,26 @@ package jinja
33
import (
44
"encoding/json"
55
"fmt"
6-
"maps"
76
"math"
87
"sort"
8+
"strconv"
99
"strings"
1010
"time"
1111
"unicode"
1212
)
1313

1414
// cachedBuiltins is a read-only scope populated once with all built-in
15-
// globals and filters. Each render clones it cheaply via cloneScope.
15+
// globals and filters. It is shared across every render — no code path
16+
// writes to the builtins scope (only chained reads through parent
17+
// links and lookupFilter), so a single shared instance is safe and
18+
// avoids ~20 allocations per render.
1619
var cachedBuiltins = func() *scope {
1720
s := newScope(nil)
1821
registerGlobals(s)
1922
registerFilters(s)
2023
return s
2124
}()
2225

23-
// cloneBuiltins returns a shallow copy of the cached builtins scope so
24-
// each render gets its own scope without re-registering every function.
25-
func cloneBuiltins() *scope {
26-
return cloneScope(cachedBuiltins)
27-
}
28-
29-
// cloneScope creates a shallow copy of a scope (same parent, copied vars map).
30-
func cloneScope(s *scope) *scope {
31-
vars := make(map[string]Value, len(s.vars))
32-
maps.Copy(vars, s.vars)
33-
return &scope{vars: vars, parent: s.parent}
34-
}
35-
3626
// =============================================================================
3727
// Global functions
3828
// =============================================================================
@@ -353,25 +343,105 @@ func filterTojson(args []Value, kwargs map[string]Value) (Value, error) {
353343
return NewString(""), nil
354344
}
355345

356-
goVal := valueToGo(args[0])
357-
358-
var data []byte
359-
var err error
360-
346+
// The indented form is used rarely in chat templates; fall back to
347+
// the reflection-based encoder for correctness instead of duplicating
348+
// indent state in the direct encoder.
361349
if indent, ok := kwargs["indent"]; ok && !indent.IsNone() && !indent.IsUndefined() {
362350
n := int(toInt64(indent))
363-
prefix := ""
364-
indentStr := strings.Repeat(" ", n)
365-
data, err = json.MarshalIndent(goVal, prefix, indentStr)
366-
} else {
367-
data, err = json.Marshal(goVal)
351+
data, err := json.MarshalIndent(valueToGo(args[0]), "", strings.Repeat(" ", n))
352+
if err != nil {
353+
return NewString(""), nil
354+
}
355+
return NewString(string(data)), nil
368356
}
369357

370-
if err != nil {
358+
// Direct Value-tree encoder. Bypasses valueToGo's intermediate
359+
// map[string]any/[]any allocations and json.Marshal's reflection
360+
// path. Per-render allocations drop from ~80 (Dict.Set + valueToGo
361+
// + reflect) to a handful (one strings.Builder grow + one Marshal
362+
// per leaf string for escaping).
363+
var sb strings.Builder
364+
if err := encodeValueJSON(&sb, args[0]); err != nil {
371365
return NewString(""), nil
372366
}
367+
return NewString(sb.String()), nil
368+
}
369+
370+
// encodeValueJSON writes the JSON encoding of v directly into sb without
371+
// going through an intermediate Go-typed tree. Strings are delegated to
372+
// encoding/json so escaping rules match the standard library exactly.
373+
func encodeValueJSON(sb *strings.Builder, v Value) error {
374+
switch v.kind {
375+
case KindUndefined, KindNone, KindCallable:
376+
sb.WriteString("null")
377+
return nil
378+
379+
case KindBool:
380+
if v.AsBool() {
381+
sb.WriteString("true")
382+
} else {
383+
sb.WriteString("false")
384+
}
385+
return nil
386+
387+
case KindInt:
388+
var buf [20]byte
389+
sb.Write(strconv.AppendInt(buf[:0], v.AsInt(), 10))
390+
return nil
391+
392+
case KindFloat:
393+
f := v.AsFloat()
394+
if math.IsInf(f, 0) || math.IsNaN(f) {
395+
return fmt.Errorf("tojson: cannot encode %v", f)
396+
}
397+
var buf [32]byte
398+
sb.Write(strconv.AppendFloat(buf[:0], f, 'g', -1, 64))
399+
return nil
400+
401+
case KindString:
402+
data, err := json.Marshal(v.AsString())
403+
if err != nil {
404+
return err
405+
}
406+
sb.Write(data)
407+
return nil
408+
409+
case KindList:
410+
sb.WriteByte('[')
411+
for i, item := range v.AsList().Items {
412+
if i > 0 {
413+
sb.WriteByte(',')
414+
}
415+
if err := encodeValueJSON(sb, item); err != nil {
416+
return err
417+
}
418+
}
419+
sb.WriteByte(']')
420+
return nil
421+
422+
case KindDict:
423+
sb.WriteByte('{')
424+
d := v.AsDict()
425+
for i, key := range d.Keys {
426+
if i > 0 {
427+
sb.WriteByte(',')
428+
}
429+
data, err := json.Marshal(key)
430+
if err != nil {
431+
return err
432+
}
433+
sb.Write(data)
434+
sb.WriteByte(':')
435+
if err := encodeValueJSON(sb, d.Data[key]); err != nil {
436+
return err
437+
}
438+
}
439+
sb.WriteByte('}')
440+
return nil
441+
}
373442

374-
return NewString(string(data)), nil
443+
sb.WriteString("null")
444+
return nil
375445
}
376446

377447
func filterFromjson(args []Value, kwargs map[string]Value) (Value, error) {

eval.go

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -589,26 +589,101 @@ func (e *evaluator) evalSlice(n *sliceExpr) (Value, error) {
589589

590590
if obj.IsList() {
591591
list := obj.AsList()
592-
start, stop, step := resolveSlice(n, e, list.Len())
592+
length := list.Len()
593+
start, stop, step := resolveSlice(n, e, length)
594+
595+
// Fast path for the overwhelmingly common step==1 case
596+
// (e.g. messages[1:] in chat templates). A single allocation
597+
// of the exact size avoids the geometric growth of append.
598+
// Aliasing the source slice would be unsafe because List.Append
599+
// could later mutate beyond Len() into the backing array.
600+
if step == 1 {
601+
if start < 0 {
602+
start = 0
603+
}
604+
if stop > length {
605+
stop = length
606+
}
607+
if start >= stop {
608+
return NewList(nil), nil
609+
}
610+
items := make([]Value, stop-start)
611+
copy(items, list.Items[start:stop])
612+
return NewList(items), nil
613+
}
614+
593615
var items []Value
594616
if step > 0 {
617+
// Pre-size to the maximum possible iteration count to
618+
// avoid append's geometric growth allocations.
619+
count := (stop - start + step - 1) / step
620+
if count > 0 {
621+
items = make([]Value, 0, count)
622+
}
595623
for i := start; i < stop; i += step {
596-
if i >= 0 && i < list.Len() {
597-
items = append(items, list.Get(i))
624+
if i >= 0 && i < length {
625+
items = append(items, list.Items[i])
598626
}
599627
}
600628
} else if step < 0 {
629+
count := (start - stop - step - 1) / -step
630+
if count > 0 {
631+
items = make([]Value, 0, count)
632+
}
601633
for i := start; i > stop; i += step {
602-
if i >= 0 && i < list.Len() {
603-
items = append(items, list.Get(i))
634+
if i >= 0 && i < length {
635+
items = append(items, list.Items[i])
604636
}
605637
}
606638
}
607639
return NewList(items), nil
608640
}
609641

610642
if obj.IsString() {
611-
runes := []rune(obj.AsString())
643+
s := obj.AsString()
644+
645+
// ASCII fast path. Most chat-template string slicing operates
646+
// on tag/role names or message content that is ASCII; for those
647+
// we can index by byte and skip the []rune conversion entirely.
648+
// This is the dominant cost in the kronk dense profile because
649+
// the system prompt can be tens of thousands of bytes.
650+
if isASCII(s) {
651+
length := len(s)
652+
start, stop, step := resolveSlice(n, e, length)
653+
654+
if step == 1 {
655+
if start < 0 {
656+
start = 0
657+
}
658+
if stop > length {
659+
stop = length
660+
}
661+
if start >= stop {
662+
return NewString(""), nil
663+
}
664+
return NewString(s[start:stop]), nil
665+
}
666+
667+
if step == -1 {
668+
if start >= length {
669+
start = length - 1
670+
}
671+
if stop < -1 {
672+
stop = -1
673+
}
674+
if start <= stop {
675+
return NewString(""), nil
676+
}
677+
buf := make([]byte, 0, start-stop)
678+
for i := start; i > stop; i-- {
679+
buf = append(buf, s[i])
680+
}
681+
return NewString(string(buf)), nil
682+
}
683+
// Fall through to the general path for unusual steps.
684+
}
685+
686+
runes := []rune(s)
612687
length := len(runes)
613688
start, stop, step := resolveSlice(n, e, length)
614689
var result []rune
@@ -631,6 +706,18 @@ func (e *evaluator) evalSlice(n *sliceExpr) (Value, error) {
631706
return Undefined(), nil
632707
}
633708

709+
// isASCII reports whether s contains only 7-bit ASCII bytes. Used to
710+
// skip the []rune conversion in evalSlice's string branch when byte
711+
// indexing produces the same result as code-point indexing.
712+
func isASCII(s string) bool {
713+
for i := 0; i < len(s); i++ {
714+
if s[i] >= 0x80 {
715+
return false
716+
}
717+
}
718+
return true
719+
}
720+
634721
func resolveSlice(n *sliceExpr, e *evaluator, length int) (int, int, int) {
635722
step := 1
636723
if n.step != nil {

0 commit comments

Comments
 (0)