From f6d1b56b4e377c1092bb5019e4c367e4e943032f Mon Sep 17 00:00:00 2001 From: Sam Doiron Date: Wed, 13 Apr 2022 11:14:23 -0300 Subject: [PATCH] Add single-benchmark execute, simplify compilation --- Gemfile | 4 + lib/liquid/compile.rb | 241 ++++++++++++++++++----- lib/liquid/range_lookup.rb | 4 + lib/liquid/variable.rb | 4 + performance/benchmark | 63 +++++- performance/benchmark_child.rb | 8 - performance/benchmarks/fizzbuzz_10000.rb | 7 +- performance/benchmarks/simple_loop.rb | 13 ++ performance/benchmarks/theme_runner.rb | 2 +- 9 files changed, 281 insertions(+), 65 deletions(-) create mode 100644 performance/benchmarks/simple_loop.rb diff --git a/Gemfile b/Gemfile index d9a2a564..3563f255 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,10 @@ group :benchmark, :test do end end +group :development do + gem 'pry-byebug' +end + group :test do gem 'rubocop', '~> 1.4', require: false gem 'rubocop-shopify', '~> 1.0.7', require: false diff --git a/lib/liquid/compile.rb b/lib/liquid/compile.rb index 8cbe6bbe..e712e5c5 100644 --- a/lib/liquid/compile.rb +++ b/lib/liquid/compile.rb @@ -3,73 +3,214 @@ require 'pry-byebug' module Liquid - module CompileBlockBody - def parse(*) - super + class Compiler + def initialize + @ruby = +"" + @nodes = {} + end - ruby = +"->(__context, __output, __nodes) {\n" - compile(ruby) - ruby << "\n}" + def <<(line) + @ruby << line + end - nodes = nodelist.each_with_object({}) do |node, hash| - hash[node.object_id] = node + def var_name(name) + "__l_#{name}" + end + + def to_proc + puts @ruby if ENV['SHOW_RUBY'] == '1' + RubyVM::InstructionSequence.compile(<<~RUBY).eval.call(@nodes) + ->(__nodes) { + ->(__context, __output) { + #{@ruby} + __output + } + } + RUBY + end + + # Compiles any thing that can be returned by Expression/QuotedFragment + def compile_expr(node) + case node + when Liquid::VariableLookup + @nodes[node.object_id] = node + node.compile_expr(self) + when Liquid::RangeLookup + @nodes[node.object_id] = node + node.compile_expr(self) + when Range # returned by RangeLookup when range contains only literals + node.inspect + when Integer, Float, nil, true, false, '' + node.inspect + else + raise ArgumentError, "cannot compile node #{node.inspect}" end - - @instructions = RubyVM::InstructionSequence - .compile(ruby) - .eval end - def render_to_output_buffer(context, output) - @instructions.call(context, output, nodes) - output + def fallback_evaluate_expr(node) + "__nodes[#{node.object_id}].evaluate(__context)" end - def compile(ruby) - ruby << "__context.resource_limits.increment_render_score(#{nodelist.length})\n" - ruby << "catch(:__interrupt) do\n" - nodelist.each do |node| - if node.instance_of?(String) - ruby << "__output << #{node.inspect}\n" + def to_integer_expr(var_name) + <<~RUBY.strip + (begin + if #{var_name}.is_a?(Integer) + #{var_name} else - ruby << "begin\n" - if node.line_number.is_a?(Integer) - ruby << "__line_number = #{node.line_number}\n" - else - ruby << "__line_number = nil\n" + begin + Integer(#{var_name}.to_s) + rescue ::ArgumentError + raise Liquid::ArgumentError, "invalid integer" end - ruby << "__is_blank = #{!node.instance_of?(Variable) && node.blank?}\n" - ruby << "__node = __nodes[#{node.object_id}]\n" - if node.respond_to?(:compile) - node.compile(ruby) + end + end) + RUBY + end + + def slice_collection_expr(collection_name, from_name, to_name) + <<~RUBY.strip + (begin + if (#{from_name} != 0 || !#{to_name}.nil?) && #{collection_name}.respond_to?(:load_slice) + #{collection_name}.load_slice(#{from_name}, #{to_name}) + else + segments = [] + index = 0 + if #{collection_name}.is_a?(String) + #{collection_name}.empty? ? [] : [collection] + elsif !#{collection_name}.respond_to?(:each) + [] else - ruby << "__node.render_to_output_buffer(__context, __output)\n" - end - ruby << <<~RUBY - rescue => __exc - case __exc - when Liquid::MemoryError - raise - when Liquid::UndefinedVariable, Liquid::UndefinedDropMethod, Liquid::UndefinedFilter - __context.handle_error(__exc, __line_number) - else - __error_message = __context.handle_error(__exc, __line_number) - unless __is_blank - __output << __error_message - end + #{collection_name}.each do |item| + if #{to_name} && #{to_name} <= index + break end + + if #{from_name} && #{from_name} <= index + segments << item + end + + index += 1 end - throw :__interrupt if __context.interrupt? - RUBY + segments + end end - ruby << "__context.resource_limits.increment_write_score(__output)\n" - end - ruby << "end\n" + end) + RUBY end + + def compile(node) + if node.instance_of?(String) + self << "__output << #{node.inspect}\n" + else + @nodes[node.object_id] = node + if node.respond_to?(:compile) + node.compile(self) + else + line_number = if node.respond_to?(:line_number) && node.line_number.is_a?(Integer) + node.line_number + else + nil + end + catch_errors(line_number, show_message: !node.blank?) do + self << "__nodes[#{node.object_id}].render_to_output_buffer(__context, __output)\n" + end + end + end + end + + def catch_errors(line_number, show_message: true) + self << "begin\n" + yield + self << <<~RUBY + rescue => __exc + case __exc + when Liquid::MemoryError + raise + when Liquid::UndefinedVariable, Liquid::UndefinedDropMethod, Liquid::UndefinedFilter + __context.handle_error(__exc, #{line_number.inspect}) + else + __error_message = __context.handle_error(__exc, #{line_number.inspect}) + RUBY + self << "__output << __error_message\n" if show_message + self << "end\nend\n" + end + end class BlockBody - include CompileBlockBody + def render_to_output_buffer(context, output) + raise "Tried to render uncompiled block" unless @compiled + @compiled.call(context, output) + end + + def compile_top_level + compiler = Compiler.new + compile(compiler) + @compiled = compiler.to_proc + end + + def compile(compiler) + nodelist.each { |node| compiler.compile(node) } + end + end + + class Document + def parse(tokenizer, parse_context) + while parse_body(tokenizer) + end + @body.compile_top_level + @body.freeze + rescue SyntaxError => e + e.line_number ||= parse_context.line_number + raise + end + end + + class VariableLookup + def compile_expr(compiler) + compiler.var_name(@name) + end + end + + class Variable + def compile(compiler) + compiler.catch_errors(@line_number, show_message: true) do + compiler << "__output << #{@name.compile_expr(compiler)}.to_s\n" + end + end + end + + class For + def compile(compiler) + compiler << "collection = #{compiler.compile_expr(@collection_name)}\n" + + unless @from.nil? + compiler << <<~RUBY + from_value = #{compiler.compile_expr(@from)} + from = if from_value.nil? + 0 + else + #{compiler.to_integer_expr(:from_value)} + end + + limit_value = #{compiler.compile_expr(@limit)} + to = if limit_value.nil? + nil + else + #{compiler.to_integer_expr(:limit_value)} + from + end + + collection = #{compiler.slice_collection_expr(:collection, :from, :to)} + #{@reversed ? "segment.reverse!" : "" } + RUBY + end + + compiler << <<~RUBY + collection.each do |#{compiler.var_name(variable_name)}| + RUBY + compiler.compile(@for_block) + compiler << "end\n" + end end end diff --git a/lib/liquid/range_lookup.rb b/lib/liquid/range_lookup.rb index 7e159be6..1e6fa7ee 100644 --- a/lib/liquid/range_lookup.rb +++ b/lib/liquid/range_lookup.rb @@ -25,6 +25,10 @@ module Liquid start_int..end_int end + def compile_expr(compiler) + compiler.fallback_evaluate_expr(self) + end + private def to_integer(input) diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 95446e17..ea71721c 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -93,6 +93,10 @@ module Liquid context.apply_global_filter(obj) end + def compile + "__output << #{@name}\n" + end + def render_to_output_buffer(context, output) obj = render(context) diff --git a/performance/benchmark b/performance/benchmark index f087c65d..9519db22 100755 --- a/performance/benchmark +++ b/performance/benchmark @@ -1,7 +1,14 @@ #!/usr/bin/env ruby # frozen_string_literal: true + + +unless ENV.key?("BUNDLE_BIN_PATH") + exec("bundle", "exec", "ruby", __FILE__, *ARGV) +end + require "pry" +require "liquid" require "unicode_plot" require "optparse" require "open3" @@ -149,11 +156,65 @@ def print_columns(cols) end end +Benchmarks = Class.new do + def define(_name, benchmark) + @benchmark = benchmark + end + + def run + start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) + @benchmark.compile + parsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) + + # warmup + 1000.times { @benchmark.render} + warm = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) + + res = nil + 1000.times { res = @benchmark.render } + ran = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) + + puts res if res.is_a?(String) + + if ENV['SHOW_RUBY'] == '1' + STDERR.puts "note: SHOW_RUBY prevents gathering parse metrics" + STDERR.puts "render: #{(ran - warm) / 1000}µs" + else + STDERR.puts "parse: #{parsed - start}µs" + STDERR.puts "render: #{(ran - warm) / 1000}µs" + STDERR.puts "total: #{(ran - warm) / 1000 + (parsed - start)}µs" + end + end +end.new + +def execute + if ARGV.count != 2 + STDERR.puts "Usage: benchmark.rb record [output_path]" + exit(1) + end + + case ENV['ENGINE'] + when 'LIQUID_COMPILE' + require_relative '../lib/liquid/compile' + when 'LIQUID_C' + require 'liquid/c' + when 'LIQUID_RUBY' + else + raise "Invalid ENGINE: #{ENV['ENGINE'].inspect}, expected ENGINE=(LIQUID_RUBY|LIQUID_COMPILE)" + end + + require_relative "./benchmarks/#{ARGV[1]}.rb" + Benchmarks.run +end + case ARGV.first when "record", "r" record when "show", "s" show +when "execute", "x" + execute else - puts "Unknown option: #{ARGV.first}" + puts "Invalid command. Expected benchmark [record|show|execute] ..." + exit 1 end \ No newline at end of file diff --git a/performance/benchmark_child.rb b/performance/benchmark_child.rb index 22a7b430..f7b29e64 100644 --- a/performance/benchmark_child.rb +++ b/performance/benchmark_child.rb @@ -22,14 +22,6 @@ def get_time_us Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) end -def render_row(charts) - charts.map do |chart| - io = StringIO.new - chart.render(io) - puts io - end -end - Benchmarks = Class.new do def initialize @by_name = {} diff --git a/performance/benchmarks/fizzbuzz_10000.rb b/performance/benchmarks/fizzbuzz_10000.rb index 6e8ca7bd..14558a1c 100644 --- a/performance/benchmarks/fizzbuzz_10000.rb +++ b/performance/benchmarks/fizzbuzz_10000.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true -Benchmarks.define('fizzbuzz_10000', Class.new do - TEMPLATE = <<~LIQUID - {% for i in (1..10000) %}{{ i }} - {% endfor %} - LIQUID +Benchmarks.define('simple_loop', Class.new do + TEMPLATE = "{% for i in (1..1000) %}{{ i }}{% endfor %}" def compile @parsed = Liquid::Template.parse(TEMPLATE) diff --git a/performance/benchmarks/simple_loop.rb b/performance/benchmarks/simple_loop.rb new file mode 100644 index 00000000..14558a1c --- /dev/null +++ b/performance/benchmarks/simple_loop.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +Benchmarks.define('simple_loop', Class.new do + TEMPLATE = "{% for i in (1..1000) %}{{ i }}{% endfor %}" + + def compile + @parsed = Liquid::Template.parse(TEMPLATE) + end + + def render + @parsed.render + end +end.new) \ No newline at end of file diff --git a/performance/benchmarks/theme_runner.rb b/performance/benchmarks/theme_runner.rb index 997b9fad..7d8a8812 100644 --- a/performance/benchmarks/theme_runner.rb +++ b/performance/benchmarks/theme_runner.rb @@ -2,4 +2,4 @@ require_relative '../theme_runner' -Benchmarks.define('theme_runner', ThemeRunner.new) +#Benchmarks.define('theme_runner', ThemeRunner.new) \ No newline at end of file