Skip to content

Commit

Permalink
WIP: Loom
Browse files Browse the repository at this point in the history
  • Loading branch information
ggmichaelgo committed Dec 11, 2024
1 parent fdd8c71 commit a6eef1c
Show file tree
Hide file tree
Showing 9 changed files with 318 additions and 13 deletions.
19 changes: 15 additions & 4 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,21 @@ namespace :benchmark do
end

desc "Run unit benchmarks"
task :unit do
Dir["./performance/unit/*_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
namespace :unit do
desc "Run all unit benchmarks"
task :all do
Dir["./performance/unit/*_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
end
end

%w[lexer loom].each do |benchmark|
desc "Run the #{benchmark} benchmark"
task benchmark.to_sym do
puts "🧪 Running #{benchmark}"
ruby "./performance/unit/#{benchmark}_benchmark.rb"
end
end
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/liquid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ module Liquid
require 'liquid/usage'
require 'liquid/registers'
require 'liquid/template_factory'
require 'liquid/loom'
15 changes: 11 additions & 4 deletions lib/liquid/block_body.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ def parse(tokenizer, parse_context, &block)
end
end

def freeze
@nodelist.freeze
super
end
# TODO: Freeze the nodelist after optimization
# def freeze
# @nodelist.freeze
# super
# end

private def parse_for_liquid_tag(tokenizer, parse_context)
while (token = tokenizer.shift)
Expand Down Expand Up @@ -154,6 +155,12 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag)
end
new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
@blank &&= new_tag.blank?

if parse_context.eager_optimize
next if new_tag.nodelist&.all? { _1.nodelist.empty? } # this is an empty block
next if new_tag.is_a?(If) && new_tag.blocks.empty? # this is an empty If block
end

@nodelist << new_tag
when token.start_with?(VARSTART)
whitespace_handler(token, parse_context)
Expand Down
71 changes: 71 additions & 0 deletions lib/liquid/loom.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

module Liquid
class Loom
class << self
def optimize(template)
new(template).optimize
end
end

def initialize template
@root = template.root
end

def optimize
merge_if_blocks
end

def merge_if_blocks
nodelist_list = [@root.nodelist]

while nodelist_list.any?
next_nodelist_list = []

nodelist_list.each do |nodelist|
i = 0
while i < nodelist.length
node = nodelist[i]
chain_if_blocks(nodelist, node, i) if node.is_a?(If)
i += 1
end
end

nodelist_list = next_nodelist_list
end
end

private

def chain_if_blocks(nodelist, first_if_node, first_if_index)
used_variables = Set.new

# only check the top level Condition (ignore children conditions for now)
first_if_node.blocks.each do |condition|
used_variables << condition.left
used_variables << condition.right if condition.right
end

if_blocks = []

nodelist[first_if_index + 1..-1].each do |node|
break unless node.is_a?(If)

# check if the variables used in the current block are used in the previous block
first_if_node.blocks.each do |condition|
if used_variables.include?(condition.left) || (condition.right && used_variables.include?(condition.right))
break
end
end

if_blocks << node
end

nodelist.delete_if { |node| if_blocks.include?(node) }

if_blocks.each do |if_block|
first_if_node.blocks << if_block.blocks.first
end
end
end
end
3 changes: 2 additions & 1 deletion lib/liquid/parse_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
module Liquid
class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :partial, :warnings, :error_mode, :environment
attr_reader :partial, :warnings, :error_mode, :environment, :eager_optimize

def initialize(options = Const::EMPTY_HASH)
@environment = options.fetch(:environment, Environment.default)
@template_options = options ? options.dup : {}

@locale = @template_options[:locale] ||= I18n.new
@warnings = []
@eager_optimize = options.fetch(:eager_optimize, false)

self.depth = 0
self.partial = false
Expand Down
34 changes: 31 additions & 3 deletions lib/liquid/tags/if.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class If < Block
def initialize(tag_name, markup, options)
super
@blocks = []
@has_else_block = false
push_block('if', markup)
end

Expand All @@ -33,17 +34,44 @@ def nodelist
def parse(tokens)
while parse_body(@blocks.last.attachment, tokens)
end
@blocks.reverse_each do |block|
block.attachment.remove_blank_strings if blank?
block.attachment.freeze

if parse_context.eager_optimize && definitive_false_statement?
@blocks.clear
else
@blocks.reverse_each do |block|
block.attachment.remove_blank_strings if blank?
block.attachment.freeze
end
end
end

def definitive_false_statement?
# check if any blocks have variable lookups
@blocks.each do |condition|
return false if condition.left.is_a?(VariableLookup) || condition.right&.is_a?(VariableLookup)

child_condition = condition.child_condition

while child_condition
return false if child_condition&.left.is_a?(VariableLookup) || child_condition&.right&.is_a?(VariableLookup)
child_condition = child_condition.child_condition
end
end

# check if all blocks are false
@blocks.each do |condition|
return false if condition.evaluate
end

true
end

ELSE_TAG_NAMES = ['elsif', 'else'].freeze
private_constant :ELSE_TAG_NAMES

def unknown_tag(tag, markup, tokens)
if ELSE_TAG_NAMES.include?(tag)
@has_else_block = true
push_block(tag, markup)
else
super
Expand Down
6 changes: 5 additions & 1 deletion lib/liquid/template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ def default_resource_limits
# See Liquid::Profiler for more information
def parse(source, options = {})
environment = options[:environment] || Environment.default
new(environment: environment).parse(source, options)
template = new(environment: environment).parse(source, options)

Loom.optimize(template) if options[:eager_optimize]

template
end
end

Expand Down
68 changes: 68 additions & 0 deletions performance/unit/loom_benchmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

require "benchmark/ips"
require 'liquid'

RubyVM::YJIT.enable

TEMPLATE = <<~LIQUID
{% if false %}
{% for i in (1..1000000) %}
{{ "Hello world!" }}
{% endfor %}
{% endif %}
{% assign result = 1 %}
{% if foo == 1 %}{% assign result = 1 %}{% endif %}{% if foo == 2 %}{% assign result = 2 %}{% endif %}{% if foo == 3 %}{% assign result = 3 %}{% endif %}
Result: {{ result }}
{% for i in (1..1000000) %}
{% endfor %}
{% for i in (1..1000000) %}
{% endfor %}
{% for i in (1..1000000) %}
{% endfor %}
{% for i in (1..1000000) %}
{% endfor %}
LIQUID

baseline_template = Liquid::Template.parse(TEMPLATE, eager_optimize: false)
optimized_template = Liquid::Template.parse(TEMPLATE, eager_optimize: true)

[nil, 1, 2, 3].each do |foo|
baseline_output = baseline_template.render('foo' => foo)
optimized_output = optimized_template.render('foo' => foo)

if baseline_output != optimized_output
puts "WARNING! Baseline and optimized templates render differently for foo=#{foo}"
puts "Baseline: #{baseline_output}"
puts "Optimized: #{optimized_output}"

raise
end
end

def render(template, foo)
template.render('foo' => foo)
end

Benchmark.ips do |x|
x.config(time: 20, warmup: 3)

x.report("baseline") do
[nil, 1, 2, 3].each do |foo|
render(baseline_template, foo)
end
end

x.report("optimized") do
[nil, 1, 2, 3].each do |foo|
render(optimized_template, foo)
end
end

x.compare!
end
Loading

0 comments on commit a6eef1c

Please sign in to comment.