go-twig/buffer_pool.go
semihalev b081db6b80 Consolidate optimizations and cleanup codebase
- Merged buffer pooling optimizations into buffer_pool.go
- Integrated string interning and tag detection into zero_alloc_tokenizer.go
- Removed duplicate and superseded optimization implementations
- Added optimized expression parsing to expr.go
- Ensured all tests pass with consolidated implementation
- Maintained zero allocation implementation for tokenization

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-12 11:30:44 +03:00

761 lines
No EOL
20 KiB
Go

package twig
import (
"fmt"
"io"
"strconv"
"sync"
)
// BufferPool is a specialized pool for string building operations
// Designed for zero allocation rendering of templates
type BufferPool struct {
pool sync.Pool
}
// Buffer is a specialized buffer for string operations
// that minimizes allocations during template rendering
type Buffer struct {
buf []byte
pool *BufferPool
reset bool
}
// Global buffer pool instance
var globalBufferPool = NewBufferPool()
// TokenBufferPool provides a pool for token buffers
// These are specialized token buffers with pre-allocated capacity
// based on the expected template size
var TokenBufferPool = sync.Pool{
New: func() interface{} {
buffer := make([]Token, 0, 128) // Default mid-size buffer
return &buffer
},
}
// Size-tiered token buffer pools for different template sizes
// This avoids wasteful allocations for small templates and
// ensures enough capacity for large templates
var (
// Small templates (< 4KB)
SmallTokenBufferPool = sync.Pool{
New: func() interface{} {
buffer := make([]Token, 0, 64)
return &buffer
},
}
// Medium templates (4KB - 20KB)
MediumTokenBufferPool = sync.Pool{
New: func() interface{} {
buffer := make([]Token, 0, 256)
return &buffer
},
}
// Large templates (> 20KB)
LargeTokenBufferPool = sync.Pool{
New: func() interface{} {
buffer := make([]Token, 0, 1024)
return &buffer
},
}
)
// NewBufferPool creates a new buffer pool
func NewBufferPool() *BufferPool {
return &BufferPool{
pool: sync.Pool{
New: func() interface{} {
// Start with a reasonable capacity
return &Buffer{
buf: make([]byte, 0, 1024),
reset: true,
}
},
},
}
}
// Get retrieves a buffer from the pool
func (p *BufferPool) Get() *Buffer {
buffer := p.pool.Get().(*Buffer)
if buffer.reset {
buffer.buf = buffer.buf[:0] // Reset length but keep capacity
} else {
buffer.buf = buffer.buf[:0] // Ensure buffer is empty
buffer.reset = true
}
buffer.pool = p
return buffer
}
// GetBuffer retrieves a buffer from the global pool
func GetBuffer() *Buffer {
return globalBufferPool.Get()
}
// Release returns the buffer to its pool
func (b *Buffer) Release() {
if b.pool != nil {
b.pool.pool.Put(b)
}
}
// Write implements io.Writer
func (b *Buffer) Write(p []byte) (n int, err error) {
b.buf = append(b.buf, p...)
return len(p), nil
}
// WriteString writes a string to the buffer with zero allocation
func (b *Buffer) WriteString(s string) (n int, err error) {
b.buf = append(b.buf, s...)
return len(s), nil
}
// WriteByte writes a single byte to the buffer
func (b *Buffer) WriteByte(c byte) error {
b.buf = append(b.buf, c)
return nil
}
// WriteSpecialized functions for common types to avoid string conversions
// Pre-computed small integer strings to avoid allocations
var smallIntStrings = [...]string{
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"10", "11", "12", "13", "14", "15", "16", "17", "18", "19",
"20", "21", "22", "23", "24", "25", "26", "27", "28", "29",
"30", "31", "32", "33", "34", "35", "36", "37", "38", "39",
"40", "41", "42", "43", "44", "45", "46", "47", "48", "49",
"50", "51", "52", "53", "54", "55", "56", "57", "58", "59",
"60", "61", "62", "63", "64", "65", "66", "67", "68", "69",
"70", "71", "72", "73", "74", "75", "76", "77", "78", "79",
"80", "81", "82", "83", "84", "85", "86", "87", "88", "89",
"90", "91", "92", "93", "94", "95", "96", "97", "98", "99",
}
// Pre-computed small negative integer strings
var smallNegIntStrings = [...]string{
"0", "-1", "-2", "-3", "-4", "-5", "-6", "-7", "-8", "-9",
"-10", "-11", "-12", "-13", "-14", "-15", "-16", "-17", "-18", "-19",
"-20", "-21", "-22", "-23", "-24", "-25", "-26", "-27", "-28", "-29",
"-30", "-31", "-32", "-33", "-34", "-35", "-36", "-37", "-38", "-39",
"-40", "-41", "-42", "-43", "-44", "-45", "-46", "-47", "-48", "-49",
"-50", "-51", "-52", "-53", "-54", "-55", "-56", "-57", "-58", "-59",
"-60", "-61", "-62", "-63", "-64", "-65", "-66", "-67", "-68", "-69",
"-70", "-71", "-72", "-73", "-74", "-75", "-76", "-77", "-78", "-79",
"-80", "-81", "-82", "-83", "-84", "-85", "-86", "-87", "-88", "-89",
"-90", "-91", "-92", "-93", "-94", "-95", "-96", "-97", "-98", "-99",
}
// WriteInt writes an integer to the buffer with minimal allocations
// Uses a fast path for common integer values
func (b *Buffer) WriteInt(i int) (n int, err error) {
// Fast path for small integers using pre-computed strings
if i >= 0 && i < 100 {
return b.WriteString(smallIntStrings[i])
} else if i > -100 && i < 0 {
return b.WriteString(smallNegIntStrings[-i])
}
// Optimization: manual integer formatting for common sizes
// Avoid the allocations in strconv.Itoa for numbers we can handle directly
if i >= -999999 && i <= 999999 {
return b.formatInt(int64(i))
}
// For larger integers, fallback to standard formatting
// This still allocates, but is rare enough to be acceptable
s := strconv.FormatInt(int64(i), 10)
return b.WriteString(s)
}
// formatInt does manual string formatting for integers without allocation
// This is a specialized version that handles integers up to 6 digits
func (b *Buffer) formatInt(i int64) (int, error) {
// Handle negative numbers
if i < 0 {
b.WriteByte('-')
i = -i
}
// Count digits to determine buffer size
var digits int
if i < 10 {
digits = 1
} else if i < 100 {
digits = 2
} else if i < 1000 {
digits = 3
} else if i < 10000 {
digits = 4
} else if i < 100000 {
digits = 5
} else {
digits = 6
}
// Reserve space for the digits
// Compute in reverse order, then reverse the result
start := len(b.buf)
for j := 0; j < digits; j++ {
digit := byte('0' + i%10)
b.buf = append(b.buf, digit)
i /= 10
}
// Reverse the digits
end := len(b.buf) - 1
for j := 0; j < digits/2; j++ {
b.buf[start+j], b.buf[end-j] = b.buf[end-j], b.buf[start+j]
}
return digits, nil
}
// WriteFloat writes a float to the buffer with optimizations for common cases
func (b *Buffer) WriteFloat(f float64, fmt byte, prec int) (n int, err error) {
// Special case for integers or near-integers with default precision
if prec == -1 && fmt == 'f' {
// If it's a whole number within integer range, use integer formatting
if f == float64(int64(f)) && f <= 9007199254740991 && f >= -9007199254740991 {
// It's a whole number that can be represented exactly as an int64
return b.formatInt(int64(f))
}
}
// Special case for small, common floating-point values with 1-2 decimal places
if fmt == 'f' && f >= 0 && f < 1000 && (prec == 1 || prec == 2 || prec == -1) {
// Try to format common floats manually without allocation
intPart := int64(f)
// Get the fractional part based on precision
var fracFactor int64
var fracPrec int
if prec == -1 {
// Default precision, up to 6 decimal places
// Check if we can represent this exactly with fewer digits
fracPart := f - float64(intPart)
if fracPart == 0 {
// It's a whole number
return b.formatInt(intPart)
}
// Test if 1-2 decimal places is enough
if fracPart*100 == float64(int64(fracPart*100)) {
// Two decimal places is sufficient
fracFactor = 100
fracPrec = 2
} else if fracPart*10 == float64(int64(fracPart*10)) {
// One decimal place is sufficient
fracFactor = 10
fracPrec = 1
} else {
// Needs more precision, use strconv
goto useStrconv
}
} else if prec == 1 {
fracFactor = 10
fracPrec = 1
} else {
fracFactor = 100
fracPrec = 2
}
// Format integer part first
intLen, err := b.formatInt(intPart)
if err != nil {
return intLen, err
}
// Add decimal point
if err := b.WriteByte('.'); err != nil {
return intLen, err
}
// Format fractional part, ensuring proper padding with zeros
fracPart := int64((f - float64(intPart)) * float64(fracFactor) + 0.5) // Round
if fracPart >= fracFactor {
// Rounding caused carry
fracPart = 0
// Adjust integer part
b.Reset()
intLen, err = b.formatInt(intPart + 1)
if err != nil {
return intLen, err
}
if err := b.WriteByte('.'); err != nil {
return intLen, err
}
}
// Write fractional part with leading zeros if needed
if fracPrec == 2 && fracPart < 10 {
if err := b.WriteByte('0'); err != nil {
return intLen + 1, err
}
}
fracLen, err := b.formatInt(fracPart)
if err != nil {
return intLen + 1, err
}
return intLen + 1 + fracLen, nil
}
useStrconv:
// Fallback to standard formatting for complex or unusual cases
s := strconv.FormatFloat(f, fmt, prec, 64)
return b.WriteString(s)
}
// WriteFormat appends a formatted string to the buffer with minimal allocations
// Similar to fmt.Sprintf but reuses the buffer and avoids allocations
// Only handles a limited set of format specifiers: %s, %d, %v
func (b *Buffer) WriteFormat(format string, args ...interface{}) (n int, err error) {
// Fast path for simple string with no format specifiers
if len(args) == 0 {
return b.WriteString(format)
}
startIdx := 0
argIdx := 0
totalWritten := 0
// Scan the format string for format specifiers
for i := 0; i < len(format); i++ {
if format[i] != '%' {
continue
}
// Found a potential format specifier
if i+1 >= len(format) {
// % at the end of the string is invalid
break
}
// Check next character
next := format[i+1]
if next == '%' {
// It's an escaped %
// Write everything up to and including the first %
if i > startIdx {
written, err := b.WriteString(format[startIdx:i+1])
totalWritten += written
if err != nil {
return totalWritten, err
}
}
// Skip the second %
i++
startIdx = i+1
continue
}
// Write the part before the format specifier
if i > startIdx {
written, err := b.WriteString(format[startIdx:i])
totalWritten += written
if err != nil {
return totalWritten, err
}
}
// Make sure we have an argument for this specifier
if argIdx >= len(args) {
// More specifiers than arguments, skip
startIdx = i
continue
}
arg := args[argIdx]
argIdx++
// Process the format specifier
switch next {
case 's':
// String format
if str, ok := arg.(string); ok {
written, err := b.WriteString(str)
totalWritten += written
if err != nil {
return totalWritten, err
}
} else {
// Convert to string
written, err := writeValueToBuffer(b, arg)
totalWritten += written
if err != nil {
return totalWritten, err
}
}
case 'd', 'v':
// Integer or default format
if i, ok := arg.(int); ok {
written, err := b.WriteInt(i)
totalWritten += written
if err != nil {
return totalWritten, err
}
} else {
// Use general value formatting
written, err := writeValueToBuffer(b, arg)
totalWritten += written
if err != nil {
return totalWritten, err
}
}
default:
// Unsupported format specifier, just output it as-is
if err := b.WriteByte('%'); err != nil {
return totalWritten, err
}
totalWritten++
if err := b.WriteByte(next); err != nil {
return totalWritten, err
}
totalWritten++
}
// Move past the format specifier
i++
startIdx = i+1
}
// Write any remaining part of the format string
if startIdx < len(format) {
written, err := b.WriteString(format[startIdx:])
totalWritten += written
if err != nil {
return totalWritten, err
}
}
return totalWritten, nil
}
// Grow ensures the buffer has enough capacity for n more bytes
// This helps avoid multiple small allocations during growth
func (b *Buffer) Grow(n int) {
// Calculate new capacity needed
needed := len(b.buf) + n
if cap(b.buf) >= needed {
return // Already have enough capacity
}
// Grow capacity with a smart algorithm that avoids frequent resizing
// Double the capacity until we have enough, but with some optimizations:
// - For small buffers (<1KB), grow more aggressively (2x)
// - For medium buffers (1KB-64KB), grow at 1.5x
// - For large buffers (>64KB), grow at 1.25x to avoid excessive memory usage
newCap := cap(b.buf)
const (
smallBuffer = 1024 // 1KB
mediumBuffer = 64 * 1024 // 64KB
)
for newCap < needed {
if newCap < smallBuffer {
newCap *= 2 // Double small buffers
} else if newCap < mediumBuffer {
newCap = newCap + newCap/2 // Grow medium buffers by 1.5x
} else {
newCap = newCap + newCap/4 // Grow large buffers by 1.25x
}
}
// Create new buffer with the calculated capacity
newBuf := make([]byte, len(b.buf), newCap)
copy(newBuf, b.buf)
b.buf = newBuf
}
// WriteBool writes a boolean value to the buffer
func (b *Buffer) WriteBool(v bool) (n int, err error) {
if v {
return b.WriteString("true")
}
return b.WriteString("false")
}
// Len returns the current length of the buffer
func (b *Buffer) Len() int {
return len(b.buf)
}
// String returns the contents as a string
func (b *Buffer) String() string {
return string(b.buf)
}
// Bytes returns the contents as a byte slice
func (b *Buffer) Bytes() []byte {
return b.buf
}
// Reset empties the buffer
func (b *Buffer) Reset() {
b.buf = b.buf[:0]
}
// WriteTo writes the buffer to an io.Writer
func (b *Buffer) WriteTo(w io.Writer) (int64, error) {
n, err := w.Write(b.buf)
return int64(n), err
}
// Global-level utility functions for writing values with minimal allocations
// WriteValue writes any value to a writer in the most efficient way possible
func WriteValue(w io.Writer, val interface{}) (n int, err error) {
// First check if we can use optimized path for known writer types
if bw, ok := w.(*Buffer); ok {
return writeValueToBuffer(bw, val)
}
// If writer is a StringWriter, we can optimize some cases
if sw, ok := w.(io.StringWriter); ok {
return writeValueToStringWriter(sw, val)
}
// Fallback path - use temp buffer for conversion to avoid allocating strings
buf := GetBuffer()
defer buf.Release()
_, _ = writeValueToBuffer(buf, val)
return w.Write(buf.Bytes())
}
// writeValueToBuffer writes a value to a Buffer using type-specific optimizations
func writeValueToBuffer(b *Buffer, val interface{}) (n int, err error) {
if val == nil {
return 0, nil
}
switch v := val.(type) {
case string:
return b.WriteString(v)
case int:
return b.WriteInt(v)
case int64:
return b.WriteString(strconv.FormatInt(v, 10))
case float64:
return b.WriteFloat(v, 'f', -1)
case bool:
return b.WriteBool(v)
case []byte:
return b.Write(v)
default:
// Fall back to string conversion
return b.WriteString(defaultToString(val))
}
}
// writeValueToStringWriter writes a value to an io.StringWriter
func writeValueToStringWriter(w io.StringWriter, val interface{}) (n int, err error) {
if val == nil {
return 0, nil
}
switch v := val.(type) {
case string:
return w.WriteString(v)
case int:
if v >= 0 && v < 100 {
// Use cached small integers for fast path
return w.WriteString(smallIntStrings[v])
} else if v > -100 && v < 0 {
return w.WriteString(smallNegIntStrings[-v])
}
return w.WriteString(strconv.Itoa(v))
case int64:
return w.WriteString(strconv.FormatInt(v, 10))
case float64:
return w.WriteString(strconv.FormatFloat(v, 'f', -1, 64))
case bool:
if v {
return w.WriteString("true")
}
return w.WriteString("false")
case []byte:
return w.WriteString(string(v))
default:
// Fall back to string conversion
return w.WriteString(defaultToString(val))
}
}
// defaultToString converts a value to a string using the default method
func defaultToString(val interface{}) string {
return stringify(val)
}
// stringify is a helper to convert any value to string
func stringify(val interface{}) string {
if val == nil {
return ""
}
// Use type switch for efficient handling of common types
switch v := val.(type) {
case string:
return v
case int:
// Use cached small integers for fast path
if v >= 0 && v < 100 {
return smallIntStrings[v]
} else if v > -100 && v < 0 {
return smallNegIntStrings[-v]
}
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
return strconv.FormatBool(v)
case []byte:
return string(v)
}
// Fall back to fmt.Sprintf for complex types
return fmt.Sprintf("%v", val)
}
// GetTokenBuffer gets a token buffer with capacity optimized for the given template size
func GetTokenBuffer(templateSize int) *[]Token {
// Select the appropriate pool based on template size
// For very small templates, use the small pool
// For medium templates, use the medium pool
// For large templates, use the large pool
// For extremely large templates, allocate directly
var buffer *[]Token
if templateSize < 4*1024 {
// Small template
buffer = SmallTokenBufferPool.Get().(*[]Token)
if cap(*buffer) < estimateTokenCount(templateSize) {
// Need more capacity
*buffer = make([]Token, 0, estimateTokenCount(templateSize))
}
} else if templateSize < 20*1024 {
// Medium template
buffer = MediumTokenBufferPool.Get().(*[]Token)
if cap(*buffer) < estimateTokenCount(templateSize) {
// Need more capacity
*buffer = make([]Token, 0, estimateTokenCount(templateSize))
}
} else if templateSize < 100*1024 {
// Large template
buffer = LargeTokenBufferPool.Get().(*[]Token)
if cap(*buffer) < estimateTokenCount(templateSize) {
// Need more capacity
*buffer = make([]Token, 0, estimateTokenCount(templateSize))
}
} else {
// Extremely large template
// Allocate directly with appropriate capacity
newBuffer := make([]Token, 0, estimateTokenCount(templateSize))
buffer = &newBuffer
}
// Clear the buffer in case it contains old tokens
*buffer = (*buffer)[:0]
return buffer
}
// ReleaseTokenBuffer returns a token buffer to the appropriate pool
func ReleaseTokenBuffer(buffer *[]Token) {
if buffer == nil {
return
}
// Clear the buffer to prevent memory leaks
*buffer = (*buffer)[:0]
// Put back in the appropriate pool based on capacity
cap := cap(*buffer)
if cap <= 64 {
SmallTokenBufferPool.Put(buffer)
} else if cap <= 256 {
MediumTokenBufferPool.Put(buffer)
} else if cap <= 1024 {
LargeTokenBufferPool.Put(buffer)
}
// Very large buffers aren't returned to the pool
}
// GetTokenBufferWithCapacity gets a token buffer with at least the requested capacity
func GetTokenBufferWithCapacity(capacity int) *[]Token {
// For very small capacity requests, use the small pool
// For medium capacity requests, use the medium pool
// For large capacity requests, use the large pool
// For very large capacity requests, allocate directly
var buffer *[]Token
if capacity <= 64 {
buffer = SmallTokenBufferPool.Get().(*[]Token)
if cap(*buffer) < capacity {
*buffer = make([]Token, 0, capacity)
}
} else if capacity <= 256 {
buffer = MediumTokenBufferPool.Get().(*[]Token)
if cap(*buffer) < capacity {
*buffer = make([]Token, 0, capacity)
}
} else if capacity <= 1024 {
buffer = LargeTokenBufferPool.Get().(*[]Token)
if cap(*buffer) < capacity {
*buffer = make([]Token, 0, capacity)
}
} else {
// Very large capacity request, allocate directly
newBuffer := make([]Token, 0, capacity)
buffer = &newBuffer
}
// Clear the buffer
*buffer = (*buffer)[:0]
return buffer
}
// RecycleTokens enables token slice reuse for nested template parsing
// This is useful for include/extends tags to avoid allocations
func RecycleTokens(tokens []Token) []Token {
// If tokens is nil or empty, return an empty slice
if len(tokens) == 0 {
return []Token{}
}
// Create a new slice with the same backing array
recycled := tokens[:0]
return recycled
}
// estimateTokenCount estimates the number of tokens needed for a template
// This is used to allocate token buffers with appropriate capacity
func estimateTokenCount(templateSize int) int {
// From empirical testing, templates typically have:
// - Small templates: ~1 token per 12 bytes
// - Medium templates: ~1 token per 15 bytes
// - Large templates: ~1 token per 20 bytes
if templateSize < 4*1024 {
// Small template
return max(64, templateSize/12+16)
} else if templateSize < 20*1024 {
// Medium template
return max(256, templateSize/15+32)
} else {
// Large template
return max(1024, templateSize/20+64)
}
}