Created
August 7, 2026 10:45
-
-
Save tagliala/3368450ed980b9216e4e52d1d9e7db9c to your computer and use it in GitHub Desktop.
Temple benchmarks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| # Compare the previous =~ predicates with the current match? predicates inside | |
| # the real Temple methods that changed. | |
| # | |
| # Run one Ruby version: | |
| # MISE_RUBY_VERSION=3.3.12 mise exec -- ruby bench_match_predicate.rb | |
| # | |
| # Shorten a comparison run: | |
| # BENCHMARK_TIME=2 BENCHMARK_WARMUP=1 ALLOCATION_ITERATIONS=500 \ | |
| # MISE_RUBY_VERSION=3.3.12 mise exec -- ruby bench_match_predicate.rb | |
| require 'bundler/inline' | |
| gemfile(true) do | |
| source 'https://rubygems.org' | |
| gem 'benchmark-ips', '~> 2.8' | |
| end | |
| $LOAD_PATH.unshift File.expand_path('lib', __dir__) | |
| require 'temple' | |
| require 'benchmark/ips' | |
| BENCHMARK_TIME = ENV.fetch('BENCHMARK_TIME', '5').to_i | |
| BENCHMARK_WARMUP = ENV.fetch('BENCHMARK_WARMUP', '2').to_i | |
| ALLOCATION_ITERATIONS = ENV.fetch('ALLOCATION_ITERATIONS', '1_000').to_i | |
| # These copies intentionally preserve the pre-change code, including the | |
| # predicates that this benchmark compares. | |
| # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity | |
| # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity | |
| # rubocop:disable Performance/RegexpMatch, Style/Documentation | |
| # rubocop:disable Style/NumericPredicate, Style/OneClassPerFile | |
| # rubocop:disable Style/RedundantFreeze | |
| class LegacyCodeMerger < Temple::Filters::CodeMerger | |
| def on_multi(*exps) | |
| result = [:multi] | |
| code = nil | |
| exps.each do |exp| | |
| if exp.first == :code | |
| if code | |
| code << '; ' unless code =~ /\n\Z/ | |
| code << exp.last | |
| else | |
| code = exp.last.dup | |
| result << [:code, code] | |
| end | |
| elsif code && exp.first == :newline | |
| code << "\n" | |
| else | |
| result << compile(exp) | |
| code = nil | |
| end | |
| end | |
| result.size == 2 ? result[1] : result | |
| end | |
| end | |
| class LegacyPretty < Temple::HTML::Pretty | |
| def on_static(content) | |
| return [:static, content] unless @pretty | |
| unless @pre_tags && @pre_tags =~ content | |
| content = content.sub(/\A\s*\n?/, "\n".freeze) if @indent_next | |
| content = content.gsub("\n".freeze, indent) | |
| end | |
| @indent_next = false | |
| [:static, content] | |
| end | |
| end | |
| class LegacyRemoveBOM < Temple::Filters::RemoveBOM | |
| def call(string) | |
| return string if string.encoding.name !~ /^UTF-(8|16|32)(BE|LE)?/ | |
| string.gsub(Regexp.new("\\A\uFEFF".encode(string.encoding.name)), ''.freeze) | |
| end | |
| end | |
| module LegacyUtils | |
| module_function | |
| def indent_dynamic(text, indent_next, indent, pre_tags = nil) | |
| text = text.to_s | |
| safe = text.respond_to?(:html_safe?) && text.html_safe? | |
| return text if pre_tags && text =~ pre_tags | |
| level = text.scan(/^\s*/).map(&:size).min | |
| text = text.gsub(/(?!\A)^\s{#{level}}/, '') if level > 0 | |
| text = text.sub(/\A\s*\n?/, "\n".freeze) if indent_next | |
| text = text.gsub("\n".freeze, indent) | |
| safe ? text.html_safe : text | |
| end | |
| end | |
| # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity | |
| # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity | |
| # rubocop:enable Performance/RegexpMatch, Style/Documentation | |
| # rubocop:enable Style/NumericPredicate, Style/OneClassPerFile | |
| # rubocop:enable Style/RedundantFreeze | |
| CODE_MERGER_INPUT = [ | |
| :multi, | |
| [:code, 'if user'], | |
| [:newline], | |
| [:code, 'items.each do |item|'], | |
| [:newline], | |
| [:code, 'render(item)'], | |
| [:newline], | |
| [:code, 'end'], | |
| [:newline], | |
| [:code, 'end'] | |
| ].freeze | |
| # This represents a page containing raw preformatted static output alongside | |
| # normal abstract HTML nodes, as emitted by Temple-based template parsers. | |
| PRETTY_INPUT = [ | |
| :html, :tag, 'main', [:multi], | |
| [ | |
| :multi, | |
| [:html, :tag, 'h1', [:multi], [:static, 'Build report']], | |
| [:static, "<pre><code>bundle exec rake spec\n1,248 examples, 0 failures</code></pre>"], | |
| [:html, :tag, 'p', [:multi], [:static, 'All checks passed']] | |
| ] | |
| ].freeze | |
| DYNAMIC_PRE_CONTENT = <<~HTML | |
| <pre><code>bundle exec rake spec | |
| 1,248 examples, 0 failures</code></pre> | |
| HTML | |
| DYNAMIC_TEXT_CONTENT = <<~TEXT | |
| First line | |
| Second line | |
| Third line | |
| TEXT | |
| PRE_TAGS = /<code|<pre|<textarea/.freeze | |
| INDENT = "\n " | |
| BOM_TEMPLATE = "\uFEFF<!doctype html><title>Temple</title>" | |
| def allocations_per_call(iterations, &operation) | |
| 10.times { operation.yield } | |
| GC.start | |
| GC.disable | |
| before = GC.stat(:total_allocated_objects) | |
| iterations.times { operation.yield } | |
| after = GC.stat(:total_allocated_objects) | |
| (after - before).fdiv(iterations) | |
| ensure | |
| GC.enable | |
| end | |
| def report_allocations(cases) | |
| puts 'Allocations per call:' | |
| cases.each do |label, operation| | |
| allocations = allocations_per_call(ALLOCATION_ITERATIONS, &operation) | |
| puts format(' %<label>-12s %<allocations>8.2f objects', label: label, allocations: allocations) | |
| end | |
| puts | |
| end | |
| def benchmark(title) | |
| puts "=== #{title} ===" | |
| Benchmark.ips do |x| | |
| x.config(time: BENCHMARK_TIME, warmup: BENCHMARK_WARMUP) | |
| yield x | |
| x.compare! | |
| end | |
| puts | |
| end | |
| puts "Ruby #{RUBY_VERSION} (#{RUBY_ENGINE})" | |
| puts "Temple #{Temple::VERSION}" | |
| puts | |
| legacy_code_merger = LegacyCodeMerger.new | |
| current_code_merger = Temple::Filters::CodeMerger.new | |
| raise 'CodeMerger output differs' unless legacy_code_merger.call(CODE_MERGER_INPUT) == | |
| current_code_merger.call(CODE_MERGER_INPUT) | |
| benchmark('CodeMerger control-flow AST') do |x| | |
| x.report('legacy =~') { legacy_code_merger.call(CODE_MERGER_INPUT) } | |
| x.report('current match?') { current_code_merger.call(CODE_MERGER_INPUT) } | |
| end | |
| report_allocations( | |
| 'legacy =~' => -> { legacy_code_merger.call(CODE_MERGER_INPUT) }, | |
| 'match?' => -> { current_code_merger.call(CODE_MERGER_INPUT) } | |
| ) | |
| legacy_pretty = LegacyPretty.new(pretty: true) | |
| current_pretty = Temple::HTML::Pretty.new(pretty: true) | |
| legacy_pretty_output = legacy_pretty.compile(PRETTY_INPUT) | |
| current_pretty_output = current_pretty.compile(PRETTY_INPUT) | |
| raise 'Pretty output differs' unless legacy_pretty_output == current_pretty_output | |
| benchmark('Pretty HTML page with static preformatted output') do |x| | |
| x.report('legacy =~') { legacy_pretty.compile(PRETTY_INPUT) } | |
| x.report('current match?') { current_pretty.compile(PRETTY_INPUT) } | |
| end | |
| report_allocations( | |
| 'legacy =~' => -> { legacy_pretty.compile(PRETTY_INPUT) }, | |
| 'match?' => -> { current_pretty.compile(PRETTY_INPUT) } | |
| ) | |
| legacy_pre_output = LegacyUtils.indent_dynamic(DYNAMIC_PRE_CONTENT, false, INDENT, PRE_TAGS) | |
| current_pre_output = Temple::Utils.indent_dynamic(DYNAMIC_PRE_CONTENT, false, INDENT, PRE_TAGS) | |
| raise 'indent_dynamic pre output differs' unless legacy_pre_output == current_pre_output | |
| benchmark('indent_dynamic with preformatted output') do |x| | |
| x.report('legacy =~') do | |
| LegacyUtils.indent_dynamic(DYNAMIC_PRE_CONTENT, false, INDENT, PRE_TAGS) | |
| end | |
| x.report('current match?') do | |
| Temple::Utils.indent_dynamic(DYNAMIC_PRE_CONTENT, false, INDENT, PRE_TAGS) | |
| end | |
| end | |
| report_allocations( | |
| 'legacy =~' => lambda { | |
| LegacyUtils.indent_dynamic(DYNAMIC_PRE_CONTENT, false, INDENT, PRE_TAGS) | |
| }, | |
| 'match?' => lambda { | |
| Temple::Utils.indent_dynamic(DYNAMIC_PRE_CONTENT, false, INDENT, PRE_TAGS) | |
| } | |
| ) | |
| legacy_text_output = LegacyUtils.indent_dynamic(DYNAMIC_TEXT_CONTENT, true, INDENT, PRE_TAGS) | |
| current_text_output = Temple::Utils.indent_dynamic(DYNAMIC_TEXT_CONTENT, true, INDENT, PRE_TAGS) | |
| raise 'indent_dynamic text output differs' unless legacy_text_output == current_text_output | |
| benchmark('indent_dynamic with ordinary multiline output') do |x| | |
| x.report('legacy =~') do | |
| LegacyUtils.indent_dynamic(DYNAMIC_TEXT_CONTENT, true, INDENT, PRE_TAGS) | |
| end | |
| x.report('current match?') do | |
| Temple::Utils.indent_dynamic(DYNAMIC_TEXT_CONTENT, true, INDENT, PRE_TAGS) | |
| end | |
| end | |
| report_allocations( | |
| 'legacy =~' => lambda { | |
| LegacyUtils.indent_dynamic(DYNAMIC_TEXT_CONTENT, true, INDENT, PRE_TAGS) | |
| }, | |
| 'match?' => lambda { | |
| Temple::Utils.indent_dynamic(DYNAMIC_TEXT_CONTENT, true, INDENT, PRE_TAGS) | |
| } | |
| ) | |
| legacy_remove_bom = LegacyRemoveBOM.new | |
| current_remove_bom = Temple::Filters::RemoveBOM.new | |
| raise 'RemoveBOM output differs' unless legacy_remove_bom.call(BOM_TEMPLATE) == | |
| current_remove_bom.call(BOM_TEMPLATE) | |
| benchmark('RemoveBOM on a UTF-8 template') do |x| | |
| x.report('legacy =~') { legacy_remove_bom.call(BOM_TEMPLATE) } | |
| x.report('current match?') { current_remove_bom.call(BOM_TEMPLATE) } | |
| end | |
| report_allocations( | |
| 'legacy =~' => -> { legacy_remove_bom.call(BOM_TEMPLATE) }, | |
| 'match?' => -> { current_remove_bom.call(BOM_TEMPLATE) } | |
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| require 'bundler/inline' | |
| begin | |
| gemfile(true) do | |
| source 'https://rubygems.org' | |
| gem 'benchmark-ips', '~> 2.8' | |
| gem 'benchmark-memory', '~> 0.1' | |
| end | |
| $LOAD_PATH.unshift File.expand_path('lib', __dir__) | |
| require 'temple' | |
| require 'benchmark/ips' | |
| require 'benchmark/memory' | |
| puts "\nRuby version: #{RUBY_VERSION}" | |
| puts "Temple version: #{Temple::VERSION}\n\n" | |
| rescue Gem::LoadError => e | |
| puts "\nMissing Dependency:\n#{e.backtrace.first} #{e.message}" | |
| exit 1 | |
| rescue LoadError => e | |
| puts "\nError:\n#{e.backtrace.first} #{e.message}" | |
| exit 1 | |
| end | |
| # --------------------------------------------------------------------------- | |
| # Fixtures | |
| # --------------------------------------------------------------------------- | |
| SIMPLE_ERB = '<p>Hello, <%= name %>!</p>'.freeze | |
| COMPLEX_ERB = <<~ERB.freeze | |
| <html> | |
| <head><title><%= title %></title></head> | |
| <body> | |
| <% items.each do |item| %> | |
| <li class="<%= item.active? ? 'active' : 'inactive' %>"> | |
| <%== item.label %> | |
| <%= item.description %> | |
| </li> | |
| <% end %> | |
| <%# this is a comment %> | |
| <footer>© <%= year %> My App</footer> | |
| </body> | |
| </html> | |
| ERB | |
| STATIC_ERB = '<p>No dynamic content here. Just plain HTML.</p>'.freeze | |
| ERB_WITH_ESCAPE = '<%== "<script>alert(1)</script>" %> and <%= "<b>bold</b>" %>'.freeze | |
| PARSER = Temple::ERB::Parser.new | |
| ENGINE = Temple::ERB::Engine.new | |
| STATIC_FILT = Temple::Filters::StaticMerger.new | |
| MULTI_FLAT = Temple::Filters::MultiFlattener.new | |
| # --------------------------------------------------------------------------- | |
| # Warm up (also validates nothing is broken) | |
| # --------------------------------------------------------------------------- | |
| puts '--- Smoke check ---' | |
| puts ENGINE.call(SIMPLE_ERB).inspect | |
| puts | |
| # --------------------------------------------------------------------------- | |
| # IPS | |
| # --------------------------------------------------------------------------- | |
| puts '=== Iterations per second ===' | |
| Benchmark.ips do |x| | |
| x.config(time: 5, warmup: 2) | |
| x.report('ERB::Parser simple') { PARSER.call(SIMPLE_ERB) } | |
| x.report('ERB::Parser complex') { PARSER.call(COMPLEX_ERB) } | |
| x.report('ERB::Parser static') { PARSER.call(STATIC_ERB) } | |
| x.report('ERB::Engine simple') { ENGINE.call(SIMPLE_ERB) } | |
| x.report('ERB::Engine complex') { ENGINE.call(COMPLEX_ERB) } | |
| x.report('ERB::Engine escape') { ENGINE.call(ERB_WITH_ESCAPE) } | |
| x.report('StaticMerger') do | |
| STATIC_FILT.call([:multi, [:static, 'foo'], [:static, ' bar'], [:static, ' baz']]) | |
| end | |
| x.report('MultiFlattener') do | |
| MULTI_FLAT.call([:multi, [:multi, [:static, 'a'], [:static, 'b']], [:static, 'c']]) | |
| end | |
| x.compare! | |
| end | |
| # --------------------------------------------------------------------------- | |
| # Memory | |
| # --------------------------------------------------------------------------- | |
| puts "\n=== Memory allocations ===" | |
| Benchmark.memory do |x| | |
| x.report('ERB::Parser simple') { PARSER.call(SIMPLE_ERB) } | |
| x.report('ERB::Parser complex') { PARSER.call(COMPLEX_ERB) } | |
| x.report('ERB::Parser static') { PARSER.call(STATIC_ERB) } | |
| x.report('ERB::Engine simple') { ENGINE.call(SIMPLE_ERB) } | |
| x.report('ERB::Engine complex') { ENGINE.call(COMPLEX_ERB) } | |
| x.report('ERB::Engine escape') { ENGINE.call(ERB_WITH_ESCAPE) } | |
| x.report('StaticMerger') do | |
| STATIC_FILT.call([:multi, [:static, 'foo'], [:static, ' bar'], [:static, ' baz']]) | |
| end | |
| x.report('MultiFlattener') do | |
| MULTI_FLAT.call([:multi, [:multi, [:static, 'a'], [:static, 'b']], [:static, 'c']]) | |
| end | |
| x.compare! | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment