This commit inlines instructions for Class#new. To make this work, we added a new YARV instructions, `opt_new`. `opt_new` checks whether or not the `new` method is the default allocator method. If it is, it allocates the object, and pushes the instance on the stack. If not, the instruction jumps to the "slow path" method call instructions. Old instructions: ``` > ruby --dump=insns -e'Object.new' == disasm: #<ISeq:<main>@-e:1 (1,0)-(1,10)> 0000 opt_getconstant_path <ic:0 Object> ( 1)[Li] 0002 opt_send_without_block <calldata!mid:new, argc:0, ARGS_SIMPLE> 0004 leave ``` New instructions: ``` > ./miniruby --dump=insns -e'Object.new' == disasm: #<ISeq:<main>@-e:1 (1,0)-(1,10)> 0000 opt_getconstant_path <ic:0 Object> ( 1)[Li] 0002 putnil 0003 swap 0004 opt_new <calldata!mid:new, argc:0, ARGS_SIMPLE>, 11 0007 opt_send_without_block <calldata!mid:initialize, argc:0, FCALL|ARGS_SIMPLE> 0009 jump 14 0011 opt_send_without_block <calldata!mid:new, argc:0, ARGS_SIMPLE> 0013 swap 0014 pop 0015 leave ``` This commit speeds up basic object allocation (`Foo.new`) by 60%, but classes that take keyword parameters see an even bigger benefit because no hash is allocated when instantiating the object (3x to 6x faster). Here is an example that uses `Hash.new(capacity: 0)`: ``` > hyperfine "ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end'" "./ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end'" Benchmark 1: ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end' Time (mean ± σ): 1.082 s ± 0.004 s [User: 1.074 s, System: 0.008 s] Range (min … max): 1.076 s … 1.088 s 10 runs Benchmark 2: ./ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end' Time (mean ± σ): 627.9 ms ± 3.5 ms [User: 622.7 ms, System: 4.8 ms] Range (min … max): 622.7 ms … 633.2 ms 10 runs Summary ./ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end' ran 1.72 ± 0.01 times faster than ruby --disable-gems -e'i = 0; while i < 10_000_000; Hash.new(capacity: 0); i += 1; end' ``` This commit changes the backtrace for `initialize`: ``` aaron@tc ~/g/ruby (inline-new)> cat test.rb class Foo def initialize puts caller end end def hello Foo.new end hello aaron@tc ~/g/ruby (inline-new)> ruby -v test.rb ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24] test.rb:8:in 'Class#new' test.rb:8:in 'Object#hello' test.rb:11:in '<main>' aaron@tc ~/g/ruby (inline-new)> ./miniruby -v test.rb ruby 3.5.0dev (2025-03-28T23:59:40Z inline-new c4157884e4) +PRISM [arm64-darwin24] test.rb:8:in 'Object#hello' test.rb:11:in '<main>' ``` It also increases memory usage for calls to `new` by 122 bytes: ``` aaron@tc ~/g/ruby (inline-new)> cat test.rb require "objspace" class Foo def initialize puts caller end end def hello Foo.new end puts ObjectSpace.memsize_of(RubyVM::InstructionSequence.of(method(:hello))) aaron@tc ~/g/ruby (inline-new)> make runruby RUBY_ON_BUG='gdb -x ./.gdbinit -p' ./miniruby -I./lib -I. -I.ext/common ./tool/runruby.rb --extout=.ext -- --disable-gems ./test.rb 656 aaron@tc ~/g/ruby (inline-new)> ruby -v test.rb ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24] 544 ``` Thanks to @ko1 for coming up with this idea! Co-Authored-By: John Hawthorn <john@hawthorn.email>
489 lines
12 KiB
Ruby
489 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
#--
|
|
# ERB::Compiler
|
|
#
|
|
# Compiles ERB templates into Ruby code; the compiled code produces the
|
|
# template result when evaluated. ERB::Compiler provides hooks to define how
|
|
# generated output is handled.
|
|
#
|
|
# Internally ERB does something like this to generate the code returned by
|
|
# ERB#src:
|
|
#
|
|
# compiler = ERB::Compiler.new('<>')
|
|
# compiler.pre_cmd = ["_erbout=+''"]
|
|
# compiler.put_cmd = "_erbout.<<"
|
|
# compiler.insert_cmd = "_erbout.<<"
|
|
# compiler.post_cmd = ["_erbout"]
|
|
#
|
|
# code, enc = compiler.compile("Got <%= obj %>!\n")
|
|
# puts code
|
|
#
|
|
# <i>Generates</i>:
|
|
#
|
|
# #coding:UTF-8
|
|
# _erbout=+''; _erbout.<< "Got ".freeze; _erbout.<<(( obj ).to_s); _erbout.<< "!\n".freeze; _erbout
|
|
#
|
|
# By default the output is sent to the print method. For example:
|
|
#
|
|
# compiler = ERB::Compiler.new('<>')
|
|
# code, enc = compiler.compile("Got <%= obj %>!\n")
|
|
# puts code
|
|
#
|
|
# <i>Generates</i>:
|
|
#
|
|
# #coding:UTF-8
|
|
# print "Got ".freeze; print(( obj ).to_s); print "!\n".freeze
|
|
#
|
|
# == Evaluation
|
|
#
|
|
# The compiled code can be used in any context where the names in the code
|
|
# correctly resolve. Using the last example, each of these print 'Got It!'
|
|
#
|
|
# Evaluate using a variable:
|
|
#
|
|
# obj = 'It'
|
|
# eval code
|
|
#
|
|
# Evaluate using an input:
|
|
#
|
|
# mod = Module.new
|
|
# mod.module_eval %{
|
|
# def get(obj)
|
|
# #{code}
|
|
# end
|
|
# }
|
|
# extend mod
|
|
# get('It')
|
|
#
|
|
# Evaluate using an accessor:
|
|
#
|
|
# klass = Class.new Object
|
|
# klass.class_eval %{
|
|
# attr_accessor :obj
|
|
# def initialize(obj)
|
|
# @obj = obj
|
|
# end
|
|
# def get_it
|
|
# #{code}
|
|
# end
|
|
# }
|
|
# klass.new('It').get_it
|
|
#
|
|
# Good! See also ERB#def_method, ERB#def_module, and ERB#def_class.
|
|
class ERB::Compiler # :nodoc:
|
|
class PercentLine # :nodoc:
|
|
def initialize(str)
|
|
@value = str
|
|
end
|
|
attr_reader :value
|
|
alias :to_s :value
|
|
end
|
|
|
|
class Scanner # :nodoc:
|
|
@scanner_map = defined?(Ractor) ? Ractor.make_shareable({}) : {}
|
|
class << self
|
|
if defined?(Ractor)
|
|
def register_scanner(klass, trim_mode, percent)
|
|
@scanner_map = Ractor.make_shareable({ **@scanner_map, [trim_mode, percent] => klass })
|
|
end
|
|
else
|
|
def register_scanner(klass, trim_mode, percent)
|
|
@scanner_map[[trim_mode, percent]] = klass
|
|
end
|
|
end
|
|
alias :regist_scanner :register_scanner
|
|
end
|
|
|
|
def self.default_scanner=(klass)
|
|
@default_scanner = klass
|
|
end
|
|
|
|
def self.make_scanner(src, trim_mode, percent)
|
|
klass = @scanner_map.fetch([trim_mode, percent], @default_scanner)
|
|
klass.new(src, trim_mode, percent)
|
|
end
|
|
|
|
DEFAULT_STAGS = %w(<%% <%= <%# <%).freeze
|
|
DEFAULT_ETAGS = %w(%%> %>).freeze
|
|
def initialize(src, trim_mode, percent)
|
|
@src = src
|
|
@stag = nil
|
|
@stags = DEFAULT_STAGS
|
|
@etags = DEFAULT_ETAGS
|
|
end
|
|
attr_accessor :stag
|
|
attr_reader :stags, :etags
|
|
|
|
def scan; end
|
|
end
|
|
|
|
class TrimScanner < Scanner # :nodoc:
|
|
def initialize(src, trim_mode, percent)
|
|
super
|
|
@trim_mode = trim_mode
|
|
@percent = percent
|
|
if @trim_mode == '>'
|
|
@scan_reg = /(.*?)(%>\r?\n|#{(stags + etags).join('|')}|\n|\z)/m
|
|
@scan_line = self.method(:trim_line1)
|
|
elsif @trim_mode == '<>'
|
|
@scan_reg = /(.*?)(%>\r?\n|#{(stags + etags).join('|')}|\n|\z)/m
|
|
@scan_line = self.method(:trim_line2)
|
|
elsif @trim_mode == '-'
|
|
@scan_reg = /(.*?)(^[ \t]*<%\-|<%\-|-%>\r?\n|-%>|#{(stags + etags).join('|')}|\z)/m
|
|
@scan_line = self.method(:explicit_trim_line)
|
|
else
|
|
@scan_reg = /(.*?)(#{(stags + etags).join('|')}|\n|\z)/m
|
|
@scan_line = self.method(:scan_line)
|
|
end
|
|
end
|
|
|
|
def scan(&block)
|
|
@stag = nil
|
|
if @percent
|
|
@src.each_line do |line|
|
|
percent_line(line, &block)
|
|
end
|
|
else
|
|
@scan_line.call(@src, &block)
|
|
end
|
|
nil
|
|
end
|
|
|
|
def percent_line(line, &block)
|
|
if @stag || line[0] != ?%
|
|
return @scan_line.call(line, &block)
|
|
end
|
|
|
|
line[0] = ''
|
|
if line[0] == ?%
|
|
@scan_line.call(line, &block)
|
|
else
|
|
yield(PercentLine.new(line.chomp))
|
|
end
|
|
end
|
|
|
|
def scan_line(line)
|
|
line.scan(@scan_reg) do |tokens|
|
|
tokens.each do |token|
|
|
next if token.empty?
|
|
yield(token)
|
|
end
|
|
end
|
|
end
|
|
|
|
def trim_line1(line)
|
|
line.scan(@scan_reg) do |tokens|
|
|
tokens.each do |token|
|
|
next if token.empty?
|
|
if token == "%>\n" || token == "%>\r\n"
|
|
yield('%>')
|
|
yield(:cr)
|
|
else
|
|
yield(token)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def trim_line2(line)
|
|
head = nil
|
|
line.scan(@scan_reg) do |tokens|
|
|
tokens.each do |token|
|
|
next if token.empty?
|
|
head = token unless head
|
|
if token == "%>\n" || token == "%>\r\n"
|
|
yield('%>')
|
|
if is_erb_stag?(head)
|
|
yield(:cr)
|
|
else
|
|
yield("\n")
|
|
end
|
|
head = nil
|
|
else
|
|
yield(token)
|
|
head = nil if token == "\n"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def explicit_trim_line(line)
|
|
line.scan(@scan_reg) do |tokens|
|
|
tokens.each do |token|
|
|
next if token.empty?
|
|
if @stag.nil? && /[ \t]*<%-/ =~ token
|
|
yield('<%')
|
|
elsif @stag && (token == "-%>\n" || token == "-%>\r\n")
|
|
yield('%>')
|
|
yield(:cr)
|
|
elsif @stag && token == '-%>'
|
|
yield('%>')
|
|
else
|
|
yield(token)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
ERB_STAG = %w(<%= <%# <%)
|
|
def is_erb_stag?(s)
|
|
ERB_STAG.member?(s)
|
|
end
|
|
end
|
|
|
|
Scanner.default_scanner = TrimScanner
|
|
|
|
begin
|
|
require 'strscan'
|
|
rescue LoadError
|
|
else
|
|
class SimpleScanner < Scanner # :nodoc:
|
|
def scan
|
|
stag_reg = (stags == DEFAULT_STAGS) ? /(.*?)(<%[%=#]?|\z)/m : /(.*?)(#{stags.join('|')}|\z)/m
|
|
etag_reg = (etags == DEFAULT_ETAGS) ? /(.*?)(%%?>|\z)/m : /(.*?)(#{etags.join('|')}|\z)/m
|
|
scanner = StringScanner.new(@src)
|
|
while ! scanner.eos?
|
|
scanner.scan(@stag ? etag_reg : stag_reg)
|
|
yield(scanner[1])
|
|
yield(scanner[2])
|
|
end
|
|
end
|
|
end
|
|
Scanner.register_scanner(SimpleScanner, nil, false)
|
|
|
|
class ExplicitScanner < Scanner # :nodoc:
|
|
def scan
|
|
stag_reg = /(.*?)(^[ \t]*<%-|<%-|#{stags.join('|')}|\z)/m
|
|
etag_reg = /(.*?)(-%>|#{etags.join('|')}|\z)/m
|
|
scanner = StringScanner.new(@src)
|
|
while ! scanner.eos?
|
|
scanner.scan(@stag ? etag_reg : stag_reg)
|
|
yield(scanner[1])
|
|
|
|
elem = scanner[2]
|
|
if /[ \t]*<%-/ =~ elem
|
|
yield('<%')
|
|
elsif elem == '-%>'
|
|
yield('%>')
|
|
yield(:cr) if scanner.scan(/(\r?\n|\z)/)
|
|
else
|
|
yield(elem)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
Scanner.register_scanner(ExplicitScanner, '-', false)
|
|
end
|
|
|
|
class Buffer # :nodoc:
|
|
def initialize(compiler, enc=nil, frozen=nil)
|
|
@compiler = compiler
|
|
@line = []
|
|
@script = +''
|
|
@script << "#coding:#{enc}\n" if enc
|
|
@script << "#frozen-string-literal:#{frozen}\n" unless frozen.nil?
|
|
@compiler.pre_cmd.each do |x|
|
|
push(x)
|
|
end
|
|
end
|
|
attr_reader :script
|
|
|
|
def push(cmd)
|
|
@line << cmd
|
|
end
|
|
|
|
def cr
|
|
@script << (@line.join('; '))
|
|
@line = []
|
|
@script << "\n"
|
|
end
|
|
|
|
def close
|
|
return unless @line
|
|
@compiler.post_cmd.each do |x|
|
|
push(x)
|
|
end
|
|
@script << (@line.join('; '))
|
|
@line = nil
|
|
end
|
|
end
|
|
|
|
def add_put_cmd(out, content)
|
|
out.push("#{@put_cmd} #{content.dump}.freeze#{"\n" * content.count("\n")}")
|
|
end
|
|
|
|
def add_insert_cmd(out, content)
|
|
out.push("#{@insert_cmd}((#{content}).to_s)")
|
|
end
|
|
|
|
# Compiles an ERB template into Ruby code. Returns an array of the code
|
|
# and encoding like ["code", Encoding].
|
|
def compile(s)
|
|
enc = s.encoding
|
|
raise ArgumentError, "#{enc} is not ASCII compatible" if enc.dummy?
|
|
s = s.b # see String#b
|
|
magic_comment = detect_magic_comment(s, enc)
|
|
out = Buffer.new(self, *magic_comment)
|
|
|
|
self.content = +''
|
|
scanner = make_scanner(s)
|
|
scanner.scan do |token|
|
|
next if token.nil?
|
|
next if token == ''
|
|
if scanner.stag.nil?
|
|
compile_stag(token, out, scanner)
|
|
else
|
|
compile_etag(token, out, scanner)
|
|
end
|
|
end
|
|
add_put_cmd(out, content) if content.size > 0
|
|
out.close
|
|
return out.script, *magic_comment
|
|
end
|
|
|
|
def compile_stag(stag, out, scanner)
|
|
case stag
|
|
when PercentLine
|
|
add_put_cmd(out, content) if content.size > 0
|
|
self.content = +''
|
|
out.push(stag.to_s)
|
|
out.cr
|
|
when :cr
|
|
out.cr
|
|
when '<%', '<%=', '<%#'
|
|
scanner.stag = stag
|
|
add_put_cmd(out, content) if content.size > 0
|
|
self.content = +''
|
|
when "\n"
|
|
content << "\n"
|
|
add_put_cmd(out, content)
|
|
self.content = +''
|
|
when '<%%'
|
|
content << '<%'
|
|
else
|
|
content << stag
|
|
end
|
|
end
|
|
|
|
def compile_etag(etag, out, scanner)
|
|
case etag
|
|
when '%>'
|
|
compile_content(scanner.stag, out)
|
|
scanner.stag = nil
|
|
self.content = +''
|
|
when '%%>'
|
|
content << '%>'
|
|
else
|
|
content << etag
|
|
end
|
|
end
|
|
|
|
def compile_content(stag, out)
|
|
case stag
|
|
when '<%'
|
|
if content[-1] == ?\n
|
|
content.chop!
|
|
out.push(content)
|
|
out.cr
|
|
else
|
|
out.push(content)
|
|
end
|
|
when '<%='
|
|
add_insert_cmd(out, content)
|
|
when '<%#'
|
|
out.push("\n" * content.count("\n")) # only adjust lineno
|
|
end
|
|
end
|
|
|
|
def prepare_trim_mode(mode) # :nodoc:
|
|
case mode
|
|
when 1
|
|
return [false, '>']
|
|
when 2
|
|
return [false, '<>']
|
|
when 0, nil
|
|
return [false, nil]
|
|
when String
|
|
unless mode.match?(/\A(%|-|>|<>){1,2}\z/)
|
|
warn_invalid_trim_mode(mode, uplevel: 5)
|
|
end
|
|
|
|
perc = mode.include?('%')
|
|
if mode.include?('-')
|
|
return [perc, '-']
|
|
elsif mode.include?('<>')
|
|
return [perc, '<>']
|
|
elsif mode.include?('>')
|
|
return [perc, '>']
|
|
else
|
|
[perc, nil]
|
|
end
|
|
else
|
|
warn_invalid_trim_mode(mode, uplevel: 5)
|
|
return [false, nil]
|
|
end
|
|
end
|
|
|
|
def make_scanner(src) # :nodoc:
|
|
Scanner.make_scanner(src, @trim_mode, @percent)
|
|
end
|
|
|
|
# Construct a new compiler using the trim_mode. See ERB::new for available
|
|
# trim modes.
|
|
def initialize(trim_mode)
|
|
@percent, @trim_mode = prepare_trim_mode(trim_mode)
|
|
@put_cmd = 'print'
|
|
@insert_cmd = @put_cmd
|
|
@pre_cmd = []
|
|
@post_cmd = []
|
|
end
|
|
attr_reader :percent, :trim_mode
|
|
|
|
# The command to handle text that ends with a newline
|
|
attr_accessor :put_cmd
|
|
|
|
# The command to handle text that is inserted prior to a newline
|
|
attr_accessor :insert_cmd
|
|
|
|
# An array of commands prepended to compiled code
|
|
attr_accessor :pre_cmd
|
|
|
|
# An array of commands appended to compiled code
|
|
attr_accessor :post_cmd
|
|
|
|
private
|
|
|
|
# A buffered text in #compile
|
|
attr_accessor :content
|
|
|
|
def detect_magic_comment(s, enc = nil)
|
|
re = @percent ? /\G(?:<%#(.*)%>|%#(.*)\n)/ : /\G<%#(.*)%>/
|
|
frozen = nil
|
|
s.scan(re) do
|
|
comment = $+
|
|
comment = $1 if comment[/-\*-\s*([^\s].*?)\s*-\*-$/]
|
|
case comment
|
|
when %r"coding\s*[=:]\s*([[:alnum:]\-_]+)"
|
|
enc = Encoding.find($1.sub(/-(?:mac|dos|unix)/i, ''))
|
|
when %r"frozen[-_]string[-_]literal\s*:\s*([[:alnum:]]+)"
|
|
frozen = $1
|
|
end
|
|
end
|
|
return enc, frozen
|
|
end
|
|
|
|
# :stopdoc:
|
|
WARNING_UPLEVEL = Class.new {
|
|
attr_reader :c
|
|
def initialize from
|
|
@c = caller.length - from.length
|
|
end
|
|
}.new(caller(0)).c
|
|
private_constant :WARNING_UPLEVEL
|
|
# :startdoc:
|
|
|
|
def warn_invalid_trim_mode(mode, uplevel:)
|
|
warn "Invalid ERB trim mode: #{mode.inspect} (trim_mode: nil, 0, 1, 2, or String composed of '%' and/or '-', '>', '<>')", uplevel: uplevel + WARNING_UPLEVEL
|
|
end
|
|
end
|