mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-16 09:20:42 -07:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user