Skip to content

Instantly share code, notes, and snippets.

@tagliala
Created August 7, 2026 10:45
Show Gist options
  • Select an option

  • Save tagliala/3368450ed980b9216e4e52d1d9e7db9c to your computer and use it in GitHub Desktop.

Select an option

Save tagliala/3368450ed980b9216e4e52d1d9e7db9c to your computer and use it in GitHub Desktop.
Temple benchmarks
#!/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) }
)
#!/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>&copy; <%= 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