perf: optimize compiled template allocations and performance

Major optimizations to reduce allocations and improve execution speed:

1. For loops: Replace catch/throw with while + break flag
   - Uses while loop with index instead of .each with catch/throw
   - Break implemented with flag variable, continue with next
   - Result: 18% fewer allocations, 85% faster for simple loops

2. Forloop property inlining
   - Inline forloop.index as (__idx__ + 1), forloop.first as (__idx__ == 0), etc.
   - Completely eliminates forloop hash allocation when all properties inlinable
   - Result: Loop with forloop went from +46% MORE to -16% FEWER allocations

3. LR.to_array helper with EMPTY_ARRAY constant
   - Centralized array conversion with frozen empty array for nil
   - Avoids allocations for empty collections

4. Inline LR.truthy? calls
   - Replace LR.truthy?(x) with (x != nil && x != false)
   - Eliminates method call overhead in conditions

5. Keep Time methods available in sandbox for date filter

Overall results:
- Allocations: 3.5% MORE -> 24% FEWER (27% improvement)
- Time: 64% faster -> 89% faster (25% improvement)

Also adds:
- compile_profiler.rb for measuring allocations/performance
- compile_acceptance_test.rb for output equivalence testing
- OPTIMIZATION.md documenting optimization status
This commit is contained in:
Tobi Lutke
2025-12-31 13:24:33 -04:00
parent 652b9c0897
commit c5a44be104
12 changed files with 996 additions and 59 deletions
+211
View File
@@ -0,0 +1,211 @@
# frozen_string_literal: true
require 'test_helper'
require 'yaml'
# Load Shopify-style tags and filters for performance templates
require_relative '../../performance/shopify/comment_form'
require_relative '../../performance/shopify/paginate'
require_relative '../../performance/shopify/json_filter'
require_relative '../../performance/shopify/money_filter'
require_relative '../../performance/shopify/shop_filter'
require_relative '../../performance/shopify/tag_filter'
require_relative '../../performance/shopify/weight_filter'
# Acceptance tests for compiled templates
#
# These tests run every performance benchmark template through both
# the interpreted Liquid renderer and the compiled Ruby renderer,
# verifying that outputs match exactly.
#
# Run with: RUBY_BOX=1 ruby -W:no-experimental -Ilib:test test/unit/compile_acceptance_test.rb
class CompileAcceptanceTest < Minitest::Test
include Liquid
PERFORMANCE_DIR = File.expand_path('../../performance', __dir__)
TESTS_DIR = File.join(PERFORMANCE_DIR, 'tests')
DATABASE_FILE = File.join(PERFORMANCE_DIR, 'shopify/vision.database.yml')
class << self
def database
@database ||= load_database
end
def load_database
db = if YAML.respond_to?(:unsafe_load_file)
YAML.unsafe_load_file(DATABASE_FILE)
else
YAML.load_file(DATABASE_FILE)
end
# From vision source - link products to collections
db['products'].each do |product|
collections = db['collections'].find_all do |collection|
collection['products'].any? { |p| p['id'].to_i == product['id'].to_i }
end
product['collections'] = collections
end
# Key tables by handles
db = db.each_with_object({}) do |(key, values), assigns|
assigns[key] = values.each_with_object({}) do |v, h|
h[v['handle']] = v
end
end
# Standard direct accessors
db['collection'] = db['collections'].values.first
db['product'] = db['products'].values.first
db['blog'] = db['blogs'].values.first
db['article'] = db['blog']['articles'].first
db['cart'] = {
'total_price' => db['line_items'].values.inject(0) { |sum, item| sum + item['line_price'] * item['quantity'] },
'item_count' => db['line_items'].values.inject(0) { |sum, item| sum + item['quantity'] },
'items' => db['line_items'].values,
}
db
end
def register_shopify_extensions!
return if @extensions_registered
env = Liquid::Environment.default
env.register_tag('paginate', Paginate)
env.register_tag('form', CommentForm)
env.register_filter(JsonFilter)
env.register_filter(MoneyFilter)
env.register_filter(WeightFilter)
env.register_filter(ShopFilter)
env.register_filter(TagFilter)
@extensions_registered = true
end
end
# File system for {% render %} and {% include %} tags
class TestFileSystem
def initialize(path)
@path = path
end
def read_template_file(template_path)
File.read(File.join(@path, "#{template_path}.liquid"))
end
end
def setup
self.class.register_shopify_extensions!
@database = self.class.database
end
# Find all test templates and generate a test method for each
Dir.glob(File.join(TESTS_DIR, '**/*.liquid')).each do |template_path|
# Skip theme.liquid files - they're layouts, not standalone templates
next if File.basename(template_path) == 'theme.liquid'
# Extract theme name and template name for test method name
relative_path = template_path.sub("#{TESTS_DIR}/", '')
theme_name = File.dirname(relative_path)
template_name = File.basename(relative_path, '.liquid')
test_method_name = "test_#{theme_name}_#{template_name}".gsub(/[^a-zA-Z0-9_]/, '_')
define_method(test_method_name) do
run_acceptance_test(template_path, theme_name, template_name)
end
end
private
def run_acceptance_test(template_path, theme_name, template_name)
# Read the template
template_source = File.read(template_path)
# Check for a theme layout
theme_path = File.join(File.dirname(template_path), 'theme.liquid')
layout_source = File.exist?(theme_path) ? File.read(theme_path) : nil
# Set up assigns
assigns = @database.dup
assigns['page_title'] = 'Test Page'
assigns['template'] = template_name
# Set up file system for partials
file_system = TestFileSystem.new(File.dirname(template_path))
# Render with interpreted Liquid
interpreted_output = render_interpreted(template_source, layout_source, assigns, file_system)
# Render with compiled Ruby
compiled_output = render_compiled(template_source, layout_source, assigns, file_system)
# Compare outputs
assert_equal(
interpreted_output,
compiled_output,
"Output mismatch for #{theme_name}/#{template_name}.liquid\n" \
"Interpreted length: #{interpreted_output.length}\n" \
"Compiled length: #{compiled_output.length}\n" \
"First difference at: #{find_first_diff(interpreted_output, compiled_output)}"
)
end
def render_interpreted(template_source, layout_source, assigns, file_system)
template = Template.parse(template_source)
template.registers[:file_system] = file_system
content = template.render!(assigns.dup)
if layout_source
layout = Template.parse(layout_source)
layout.registers[:file_system] = file_system
layout_assigns = assigns.dup
layout_assigns['content_for_layout'] = content
layout.render!(layout_assigns)
else
content
end
end
def render_compiled(template_source, layout_source, assigns, file_system)
template = Template.parse(template_source)
compiled = template.compile_to_ruby
# Set up filter handler with Shopify filters
filter_handler = Class.new do
include JsonFilter
include MoneyFilter
include WeightFilter
include ShopFilter
include TagFilter
end.new
compiled.filter_handler = filter_handler
content = compiled.call(assigns.dup, registers: { file_system: file_system })
if layout_source
layout = Template.parse(layout_source)
layout_compiled = layout.compile_to_ruby
layout_compiled.filter_handler = filter_handler
layout_assigns = assigns.dup
layout_assigns['content_for_layout'] = content
layout_compiled.call(layout_assigns, registers: { file_system: file_system })
else
content
end
end
def find_first_diff(str1, str2)
min_len = [str1.length, str2.length].min
diff_pos = (0...min_len).find { |i| str1[i] != str2[i] } || min_len
context_start = [0, diff_pos - 20].max
context_end = [str1.length, str2.length, diff_pos + 30].min
"position #{diff_pos}:\n" \
" Interpreted: #{str1[context_start...context_end].inspect}\n" \
" Compiled: #{str2[context_start...context_end].inspect}"
end
end