diff options
Diffstat (limited to 'test/ruby/test_yjit.rb')
-rw-r--r-- | test/ruby/test_yjit.rb | 1249 |
1 files changed, 1168 insertions, 81 deletions
diff --git a/test/ruby/test_yjit.rb b/test/ruby/test_yjit.rb index 88f8e42813..796787e355 100644 --- a/test/ruby/test_yjit.rb +++ b/test/ruby/test_yjit.rb @@ -1,18 +1,27 @@ # frozen_string_literal: true +# +# This set of tests can be run with: +# make test-all TESTS='test/ruby/test_yjit.rb' + require 'test/unit' require 'envutil' require 'tmpdir' require_relative '../lib/jit_support' -return unless defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? +return unless JITSupport.yjit_supported? + +require 'stringio' # Tests for YJIT with assertions on compilation and side exits -# insipired by the MJIT tests in test/ruby/test_jit.rb +# insipired by the RJIT tests in test/ruby/test_rjit.rb class TestYJIT < Test::Unit::TestCase + running_with_yjit = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? + def test_yjit_in_ruby_description assert_includes(RUBY_DESCRIPTION, '+YJIT') - end + end if running_with_yjit + # Check that YJIT is in the version string def test_yjit_in_version [ %w(--version --yjit), @@ -22,33 +31,107 @@ class TestYJIT < Test::Unit::TestCase %w(--version --disable=yjit --yjit), %w(--version --disable=yjit --enable-yjit), %w(--version --disable=yjit --enable=yjit), - *([ - %w(--version --jit), - %w(--version --disable-jit --jit), - %w(--version --disable-jit --enable-jit), - %w(--version --disable-jit --enable=jit), - %w(--version --disable=jit --yjit), - %w(--version --disable=jit --enable-jit), - %w(--version --disable=jit --enable=jit), - ] if JITSupport.yjit_supported?), + %w(--version --jit), + %w(--version --disable-jit --jit), + %w(--version --disable-jit --enable-jit), + %w(--version --disable-jit --enable=jit), + %w(--version --disable=jit --yjit), + %w(--version --disable=jit --enable-jit), + %w(--version --disable=jit --enable=jit), ].each do |version_args| assert_in_out_err(version_args) do |stdout, stderr| assert_equal(RUBY_DESCRIPTION, stdout.first) assert_equal([], stderr) end end - end + end if running_with_yjit def test_command_line_switches assert_in_out_err('--yjit-', '', [], /invalid option --yjit-/) assert_in_out_err('--yjithello', '', [], /invalid option --yjithello/) - assert_in_out_err('--yjit-call-threshold', '', [], /--yjit-call-threshold needs an argument/) - assert_in_out_err('--yjit-call-threshold=', '', [], /--yjit-call-threshold needs an argument/) - assert_in_out_err('--yjit-greedy-versioning=1', '', [], /warning: argument to --yjit-greedy-versioning is ignored/) + #assert_in_out_err('--yjit-call-threshold', '', [], /--yjit-call-threshold needs an argument/) + #assert_in_out_err('--yjit-call-threshold=', '', [], /--yjit-call-threshold needs an argument/) + end + + def test_yjit_enable + args = [] + args << "--disable=yjit" if RubyVM::YJIT.enabled? + assert_separately(args, <<~RUBY) + assert_false RubyVM::YJIT.enabled? + assert_false RUBY_DESCRIPTION.include?("+YJIT") + + RubyVM::YJIT.enable + + assert_true RubyVM::YJIT.enabled? + assert_true RUBY_DESCRIPTION.include?("+YJIT") + RUBY + end + + def test_yjit_enable_stats_false + assert_separately(["--yjit-disable", "--yjit-stats"], <<~RUBY, ignore_stderr: true) + assert_false RubyVM::YJIT.enabled? + assert_nil RubyVM::YJIT.runtime_stats + + RubyVM::YJIT.enable + + assert_true RubyVM::YJIT.enabled? + assert_true RubyVM::YJIT.runtime_stats[:all_stats] + RUBY + end + + def test_yjit_enable_stats_true + args = [] + args << "--disable=yjit" if RubyVM::YJIT.enabled? + assert_separately(args, <<~RUBY, ignore_stderr: true) + assert_false RubyVM::YJIT.enabled? + assert_nil RubyVM::YJIT.runtime_stats + + RubyVM::YJIT.enable(stats: true) + + assert_true RubyVM::YJIT.enabled? + assert_true RubyVM::YJIT.runtime_stats[:all_stats] + RUBY + end + + def test_yjit_enable_stats_quiet + assert_in_out_err(['--yjit-disable', '-e', 'RubyVM::YJIT.enable(stats: true)']) do |_stdout, stderr, _status| + assert_not_empty stderr + end + assert_in_out_err(['--yjit-disable', '-e', 'RubyVM::YJIT.enable(stats: :quiet)']) do |_stdout, stderr, _status| + assert_empty stderr + end + end + + def test_yjit_enable_with_call_threshold + assert_separately(%w[--yjit-disable --yjit-call-threshold=1], <<~RUBY) + def not_compiled = nil + def will_compile = nil + def compiled_counts = RubyVM::YJIT.runtime_stats&.dig(:compiled_iseq_count) + + not_compiled + assert_nil compiled_counts + assert_false RubyVM::YJIT.enabled? + + RubyVM::YJIT.enable + + will_compile + assert compiled_counts > 0 + assert_true RubyVM::YJIT.enabled? + RUBY + end + + def test_yjit_enable_with_monkey_patch + assert_separately(%w[--yjit-disable], <<~RUBY) + # This lets rb_method_entry_at(rb_mKernel, ...) return NULL + Kernel.prepend(Module.new) + + # This must not crash with "undefined optimized method!" + RubyVM::YJIT.enable + RUBY end def test_yjit_stats_and_v_no_error - _stdout, stderr, _status = EnvUtil.invoke_ruby(%w(-v --yjit-stats), '', true, true) + _stdout, stderr, _status = invoke_ruby(%w(-v --yjit-stats), '', true, true) refute_includes(stderr, "NoMethodError") end @@ -60,7 +143,7 @@ class TestYJIT < Test::Unit::TestCase end assert_in_out_err([yjit_child_env, '-e puts RUBY_DESCRIPTION'], '', [RUBY_DESCRIPTION]) assert_in_out_err([yjit_child_env, '-e p RubyVM::YJIT.enabled?'], '', ['true']) - end + end if running_with_yjit def test_compile_setclassvariable script = 'class Foo; def self.foo; @@foo = 1; end; end; Foo.foo' @@ -82,6 +165,10 @@ class TestYJIT < Test::Unit::TestCase assert_compiles(':foo', insns: %i[putobject], result: :foo) end + def test_compile_opt_succ + assert_compiles('1.succ', insns: %i[opt_succ], result: 2) + end + def test_compile_opt_not assert_compiles('!false', insns: %i[opt_not], result: true) assert_compiles('!nil', insns: %i[opt_not], result: true) @@ -233,10 +320,10 @@ class TestYJIT < Test::Unit::TestCase end def test_compile_opt_aset - assert_compiles('[1,2,3][2] = 4', insns: %i[opt_aset]) - assert_compiles('{}[:foo] = :bar', insns: %i[opt_aset]) - assert_compiles('[1,2,3][0..-1] = []', insns: %i[opt_aset]) - assert_compiles('"foo"[3] = "d"', insns: %i[opt_aset]) + assert_compiles('[1,2,3][2] = 4', insns: %i[opt_aset], frozen_string_literal: false) + assert_compiles('{}[:foo] = :bar', insns: %i[opt_aset], frozen_string_literal: false) + assert_compiles('[1,2,3][0..-1] = []', insns: %i[opt_aset], frozen_string_literal: false) + assert_compiles('"foo"[3] = "d"', insns: %i[opt_aset], frozen_string_literal: false) end def test_compile_attr_set @@ -312,6 +399,32 @@ class TestYJIT < Test::Unit::TestCase RUBY end + def test_string_concat_utf8 + assert_compiles(<<~RUBY, frozen_string_literal: true, result: true) + def str_cat_utf8 + s = String.new + 10.times { s << "✅" } + s + end + + str_cat_utf8 == "✅" * 10 + RUBY + end + + def test_string_concat_ascii + # Constant-get for classes (e.g. String, Encoding) can cause a side-exit in getinlinecache. For now, ignore exits. + assert_compiles(<<~RUBY, exits: :any) + str_arg = "b".encode(Encoding::ASCII) + def str_cat_ascii(arg) + s = String.new(encoding: Encoding::ASCII) + 10.times { s << arg } + s + end + + str_cat_ascii(str_arg) == str_arg * 10 + RUBY + end + def test_opt_length_in_method assert_compiles(<<~RUBY, insns: %i[opt_length], result: 5) def foo(str) @@ -355,8 +468,31 @@ class TestYJIT < Test::Unit::TestCase assert_compiles("'foo' =~ /(o)./; $2", insns: %i[getspecial], result: nil) end - def test_compile_opt_getinlinecache - assert_compiles(<<~RUBY, insns: %i[opt_getinlinecache], result: 123, min_calls: 2) + def test_compile_getconstant + assert_compiles(<<~RUBY, insns: %i[getconstant], result: [], call_threshold: 1) + def get_argv(klass) + klass::ARGV + end + + get_argv(Object) + RUBY + end + + def test_compile_getconstant_with_sp_offset + assert_compiles(<<~RUBY, insns: %i[getconstant], result: 2, call_threshold: 1) + class Foo + Bar = 1 + end + + 2.times do + s = Foo # this opt_getconstant_path needs warmup, so 2.times is needed + Class.new(Foo).const_set(:Bar, s::Bar) + end + RUBY + end + + def test_compile_opt_getconstant_path + assert_compiles(<<~RUBY, insns: %i[opt_getconstant_path], result: 123, call_threshold: 2) def get_foo FOO end @@ -368,8 +504,8 @@ class TestYJIT < Test::Unit::TestCase RUBY end - def test_opt_getinlinecache_slowpath - assert_compiles(<<~RUBY, exits: { opt_getinlinecache: 1 }, result: [42, 42, 1, 1], min_calls: 2) + def test_opt_getconstant_path_slowpath + assert_compiles(<<~RUBY, exits: { opt_getconstant_path: 1 }, result: [42, 42, 1, 1], call_threshold: 2) class A FOO = 42 class << self @@ -396,8 +532,34 @@ class TestYJIT < Test::Unit::TestCase RUBY end + def test_opt_getconstant_path_general + assert_compiles(<<~RUBY, result: [1, 1]) + module Base + Const = 1 + end + + class Sub + def const + _const = nil # make a non-entry block for opt_getconstant_path + Const + end + + def self.const_missing(n) + Base.const_get(n) + end + end + + + sub = Sub.new + result = [] + result << sub.const # generate the general case + result << sub.const # const_missing does not invalidate the block + result + RUBY + end + def test_string_interpolation - assert_compiles(<<~'RUBY', insns: %i[objtostring anytostring concatstrings], result: "foobar", min_calls: 2) + assert_compiles(<<~'RUBY', insns: %i[objtostring anytostring concatstrings], result: "foobar", call_threshold: 2) def make_str(foo, bar) "#{foo}#{bar}" end @@ -453,6 +615,249 @@ class TestYJIT < Test::Unit::TestCase RUBY end + def test_getblockparam + assert_compiles(<<~'RUBY', insns: [:getblockparam]) + def foo &blk + 2.times do + blk + end + end + + foo {} + foo {} + RUBY + end + + def test_getblockparamproxy + assert_compiles(<<~'RUBY', insns: [:getblockparamproxy], exits: {}) + def foo &blk + p blk.call + p blk.call + end + + foo { 1 } + foo { 2 } + RUBY + end + + def test_ifunc_getblockparamproxy + assert_compiles(<<~'RUBY', insns: [:getblockparamproxy], exits: {}) + class Foo + include Enumerable + + def each(&block) + block.call 1 + block.call 2 + block.call 3 + end + end + + foo = Foo.new + foo.map { _1 * 2 } + foo.map { _1 * 2 } + RUBY + end + + def test_send_blockarg + assert_compiles(<<~'RUBY', insns: [:getblockparamproxy, :send], exits: {}) + def bar + end + + def foo &blk + bar(&blk) + bar(&blk) + end + + foo + foo + + foo { } + foo { } + RUBY + end + + def test_send_splat + assert_compiles(<<~'RUBY', result: "3#1,2,3/P", exits: {}) + def internal_method(*args) + "#{args.size}##{args.join(",")}" + end + + def jit_method + send(:internal_method, *[1, 2, 3]) + "/P" + end + + jit_method + RUBY + end + + def test_send_multiarg + assert_compiles(<<~'RUBY', result: "3#1,2,3/Q") + def internal_method(*args) + "#{args.size}##{args.join(",")}" + end + + def jit_method + send(:internal_method, 1, 2, 3) + "/Q" + end + + jit_method + RUBY + end + + def test_send_kwargs + # For now, this side-exits when calls include keyword args + assert_compiles(<<~'RUBY', result: "2#a:1,b:2/A") + def internal_method(**kw) + "#{kw.size}##{kw.keys.map { |k| "#{k}:#{kw[k]}" }.join(",")}" + end + + def jit_method + send(:internal_method, a: 1, b: 2) + "/A" + end + jit_method + RUBY + end + + def test_send_kwargs_in_receiver_only + assert_compiles(<<~'RUBY', result: "0/RK", exits: {}) + def internal_method(**kw) + "#{kw.size}" + end + + def jit_method + send(:internal_method) + "/RK" + end + jit_method + RUBY + end + + def test_send_with_underscores + assert_compiles(<<~'RUBY', result: "0/RK", exits: {}) + def internal_method(**kw) + "#{kw.size}" + end + + def jit_method + __send__(:internal_method) + "/RK" + end + jit_method + RUBY + end + + def test_send_kwargs_splat + # For now, this side-exits when calling with a splat + assert_compiles(<<~'RUBY', result: "2#a:1,b:2/B") + def internal_method(**kw) + "#{kw.size}##{kw.keys.map { |k| "#{k}:#{kw[k]}" }.join(",")}" + end + + def jit_method + send(:internal_method, **{ a: 1, b: 2 }) + "/B" + end + jit_method + RUBY + end + + def test_send_block + # Setlocal_wc_0 sometimes side-exits on write barrier + assert_compiles(<<~'RUBY', result: "b:n/b:y/b:y/b:n") + def internal_method(&b) + "b:#{block_given? ? "y" : "n"}" + end + + def jit_method + b7 = proc { 7 } + [ + send(:internal_method), + send(:internal_method, &b7), + send(:internal_method) { 7 }, + send(:internal_method, &nil), + ].join("/") + end + jit_method + RUBY + end + + def test_send_block_calling + assert_compiles(<<~'RUBY', result: "1a2", exits: {}) + def internal_method + out = yield + "1" + out + "2" + end + + def jit_method + __send__(:internal_method) { "a" } + end + jit_method + RUBY + end + + def test_send_block_only_receiver + assert_compiles(<<~'RUBY', result: "b:n", exits: {}) + def internal_method(&b) + "b:#{block_given? ? "y" : "n"}" + end + + def jit_method + send(:internal_method) + end + jit_method + RUBY + end + + def test_send_block_only_sender + assert_compiles(<<~'RUBY', result: "Y/Y/Y/Y", exits: {}) + def internal_method + "Y" + end + + def jit_method + b7 = proc { 7 } + [ + send(:internal_method), + send(:internal_method, &b7), + send(:internal_method) { 7 }, + send(:internal_method, &nil), + ].join("/") + end + jit_method + RUBY + end + + def test_multisend + assert_compiles(<<~'RUBY', result: "77") + def internal_method + "7" + end + + def jit_method + send(:send, :internal_method) + send(:send, :send, :internal_method) + end + jit_method + RUBY + end + + def test_getivar_opt_plus + assert_no_exits(<<~RUBY) + class TheClass + def initialize + @levar = 1 + end + + def get_sum + sum = 0 + # The type of levar is unknown, + # but this still should not exit + sum += @levar + sum + end + end + + obj = TheClass.new + obj.get_sum + RUBY + end + def test_super_iseq assert_compiles(<<~'RUBY', insns: %i[invokesuper opt_plus opt_mult], result: 15) class A @@ -471,6 +876,25 @@ class TestYJIT < Test::Unit::TestCase RUBY end + def test_super_with_alias + assert_compiles(<<~'RUBY', insns: %i[invokesuper opt_plus opt_mult], result: 15) + class A + def foo = 1 + 2 + end + + module M + def foo = super() * 5 + alias bar foo + + def foo = :bad + end + + A.prepend M + + A.new.bar + RUBY + end + def test_super_cfunc assert_compiles(<<~'RUBY', insns: %i[invokesuper], result: "Hello") class Gnirts < String @@ -489,7 +913,7 @@ class TestYJIT < Test::Unit::TestCase # Tests calling a variadic cfunc with many args def test_build_large_struct - assert_compiles(<<~RUBY, insns: %i[opt_send_without_block], min_calls: 2) + assert_compiles(<<~RUBY, insns: %i[opt_send_without_block], call_threshold: 2) ::Foo = Struct.new(:a, :b, :c, :d, :e, :f, :g, :h) def build_foo @@ -530,8 +954,8 @@ class TestYJIT < Test::Unit::TestCase assert_no_exits('{}.merge(foo: 123, bar: 456, baz: 789)') end + # regression test simplified from URI::Generic#hostname= def test_ctx_different_mappings - # regression test simplified from URI::Generic#hostname= assert_compiles(<<~'RUBY', frozen_string_literal: true) def foo(v) !(v&.start_with?('[')) && v&.index(':') @@ -567,12 +991,662 @@ class TestYJIT < Test::Unit::TestCase RUBY end + def test_int_equal + assert_compiles(<<~'RUBY', exits: :any, result: [true, false, true, false, true, false, true, false]) + def eq(a, b) + a == b + end + + def eqq(a, b) + a === b + end + + big1 = 2 ** 65 + big2 = big1 + 1 + [eq(1, 1), eq(1, 2), eq(big1, big1), eq(big1, big2), eqq(1, 1), eqq(1, 2), eqq(big1, big1), eqq(big1, big2)] + RUBY + end + + def test_opt_case_dispatch + assert_compiles(<<~'RUBY', exits: :any, result: [:"1", "2", 3]) + def case_dispatch(val) + case val + when 1 + :"#{val}" + when 2 + "#{val}" + else + val + end + end + + [case_dispatch(1), case_dispatch(2), case_dispatch(3)] + RUBY + end + + def test_code_gc + assert_compiles(code_gc_helpers + <<~'RUBY', exits: :any, result: :ok) + return :not_paged unless add_pages(100) # prepare freeable pages + RubyVM::YJIT.code_gc # first code GC + return :not_compiled1 unless compiles { nil } # should be JITable again + + RubyVM::YJIT.code_gc # second code GC + return :not_compiled2 unless compiles { nil } # should be JITable again + + code_gc_count = RubyVM::YJIT.runtime_stats[:code_gc_count] + return :"code_gc_#{code_gc_count}" if code_gc_count != 2 + + :ok + RUBY + end + + def test_on_stack_code_gc_call + assert_compiles(code_gc_helpers + <<~'RUBY', exits: :any, result: :ok) + fiber = Fiber.new { + # Loop to call the same basic block again after Fiber.yield + while true + Fiber.yield(nil.to_i) + end + } + + return :not_paged1 unless add_pages(400) # go to a page without initial ocb code + return :broken_resume1 if fiber.resume != 0 # JIT the fiber + RubyVM::YJIT.code_gc # first code GC, which should not free the fiber page + return :broken_resume2 if fiber.resume != 0 # The code should be still callable + + code_gc_count = RubyVM::YJIT.runtime_stats[:code_gc_count] + return :"code_gc_#{code_gc_count}" if code_gc_count != 1 + + :ok + RUBY + end + + def test_on_stack_code_gc_twice + assert_compiles(code_gc_helpers + <<~'RUBY', exits: :any, result: :ok) + fiber = Fiber.new { + # Loop to call the same basic block again after Fiber.yield + while Fiber.yield(nil.to_i); end + } + + return :not_paged1 unless add_pages(400) # go to a page without initial ocb code + return :broken_resume1 if fiber.resume(true) != 0 # JIT the fiber + RubyVM::YJIT.code_gc # first code GC, which should not free the fiber page + + return :not_paged2 unless add_pages(300) # add some stuff to be freed + # Not calling fiber.resume here to test the case that the YJIT payload loses some + # information at the previous code GC. The payload should still be there, and + # thus we could know the fiber ISEQ is still on stack on this second code GC. + RubyVM::YJIT.code_gc # second code GC, which should still not free the fiber page + + return :not_paged3 unless add_pages(200) # attempt to overwrite the fiber page (it shouldn't) + return :broken_resume2 if fiber.resume(true) != 0 # The fiber code should be still fine + + return :broken_resume3 if fiber.resume(false) != nil # terminate the fiber + RubyVM::YJIT.code_gc # third code GC, freeing a page that used to be on stack + + return :not_paged4 unless add_pages(100) # check everything still works + + code_gc_count = RubyVM::YJIT.runtime_stats[:code_gc_count] + return :"code_gc_#{code_gc_count}" if code_gc_count != 3 + + :ok + RUBY + end + + def test_disable_code_gc_with_many_iseqs + assert_compiles(code_gc_helpers + <<~'RUBY', exits: :any, result: :ok, mem_size: 1, code_gc: false) + fiber = Fiber.new { + # Loop to call the same basic block again after Fiber.yield + while true + Fiber.yield(nil.to_i) + end + } + + return :not_paged1 unless add_pages(250) # use some pages + return :broken_resume1 if fiber.resume != 0 # leave an on-stack code as well + + add_pages(2000) # use a whole lot of pages to run out of 1MiB + return :broken_resume2 if fiber.resume != 0 # on-stack code should be callable + + code_gc_count = RubyVM::YJIT.runtime_stats[:code_gc_count] + return :"code_gc_#{code_gc_count}" if code_gc_count != 0 + + :ok + RUBY + end + + def test_code_gc_with_many_iseqs + assert_compiles(code_gc_helpers + <<~'RUBY', exits: :any, result: :ok, mem_size: 1, code_gc: true) + fiber = Fiber.new { + # Loop to call the same basic block again after Fiber.yield + while true + Fiber.yield(nil.to_i) + end + } + + return :not_paged1 unless add_pages(250) # use some pages + return :broken_resume1 if fiber.resume != 0 # leave an on-stack code as well + + add_pages(2000) # use a whole lot of pages to run out of 1MiB + return :broken_resume2 if fiber.resume != 0 # on-stack code should be callable + + code_gc_count = RubyVM::YJIT.runtime_stats[:code_gc_count] + return :"code_gc_#{code_gc_count}" if code_gc_count == 0 + + :ok + RUBY + end + + def test_code_gc_with_auto_compact + assert_compiles((code_gc_helpers + <<~'RUBY'), exits: :any, result: :ok, mem_size: 1, code_gc: true) + # Test ISEQ moves in the middle of code GC + GC.auto_compact = true + + fiber = Fiber.new { + # Loop to call the same basic block again after Fiber.yield + while true + Fiber.yield(nil.to_i) + end + } + + return :not_paged1 unless add_pages(250) # use some pages + return :broken_resume1 if fiber.resume != 0 # leave an on-stack code as well + + add_pages(2000) # use a whole lot of pages to run out of 1MiB + return :broken_resume2 if fiber.resume != 0 # on-stack code should be callable + + code_gc_count = RubyVM::YJIT.runtime_stats[:code_gc_count] + return :"code_gc_#{code_gc_count}" if code_gc_count == 0 + + :ok + RUBY + end + + def test_code_gc_partial_last_page + # call_threshold: 2 to avoid JIT-ing code_gc itself. If code_gc were JITed right before + # code_gc is called, the last page would be on stack. + assert_compiles(<<~'RUBY', exits: :any, result: :ok, call_threshold: 2) + # Leave a bunch of off-stack pages + i = 0 + while i < 1000 + eval("x = proc { 1.to_s }; x.call; x.call") + i += 1 + end + + # On Linux, memory page size != code page size. So the last code page could be partially + # mapped. This call tests that assertions and other things work fine under the situation. + RubyVM::YJIT.code_gc + + :ok + RUBY + end + + def test_trace_script_compiled # not ISEQ_TRACE_EVENTS + assert_compiles(<<~'RUBY', exits: :any, result: :ok) + @eval_counter = 0 + def eval_script + eval('@eval_counter += 1') + end + + @trace_counter = 0 + trace = TracePoint.new(:script_compiled) do |t| + @trace_counter += 1 + end + + eval_script # JIT without TracePoint + trace.enable + eval_script # call with TracePoint + trace.disable + + return :"eval_#{@eval_counter}" if @eval_counter != 2 + return :"trace_#{@trace_counter}" if @trace_counter != 1 + + :ok + RUBY + end + + def test_trace_b_call # ISEQ_TRACE_EVENTS + assert_compiles(<<~'RUBY', exits: :any, result: :ok) + @call_counter = 0 + def block_call + 1.times { @call_counter += 1 } + end + + @trace_counter = 0 + trace = TracePoint.new(:b_call) do |t| + @trace_counter += 1 + end + + block_call # JIT without TracePoint + trace.enable + block_call # call with TracePoint + trace.disable + + return :"call_#{@call_counter}" if @call_counter != 2 + return :"trace_#{@trace_counter}" if @trace_counter != 1 + + :ok + RUBY + end + + def test_send_to_call + assert_compiles(<<~'RUBY', result: :ok) + ->{ :ok }.send(:call) + RUBY + end + + def test_invokeblock_many_locals + # [Bug #19299] + assert_compiles(<<~'RUBY', result: :ok) + def foo + yield + end + + foo do + a1=a2=a3=a4=a5=a6=a7=a8=a9=a10=a11=a12=a13=a14=a15=a16=a17=a18=a19=a20=a21=a22=a23=a24=a25=a26=a27=a28=a29=a30 = :ok + a30 + end + RUBY + end + + def test_bug_19316 + n = 2 ** 64 + # foo's extra param and the splats are relevant + assert_compiles(<<~'RUBY', result: [[n, -n], [n, -n]], exits: :any) + def foo(_, a, b, c) + [a & b, ~c] + end + + n = 2 ** 64 + args = [0, -n, n, n-1] + + GC.stress = true + [foo(*args), foo(*args)] + RUBY + end + + def test_gc_compact_cyclic_branch + assert_compiles(<<~'RUBY', result: 2) + def foo + i = 0 + while i < 2 + i += 1 + end + i + end + + foo + GC.compact + foo + RUBY + end + + def test_invalidate_cyclic_branch + assert_compiles(<<~'RUBY', result: 2, exits: { opt_plus: 1 }) + def foo + i = 0 + while i < 2 + i += 1 + end + i + end + + foo + class Integer + def +(x) = self - -x + end + foo + RUBY + end + + def test_tracing_str_uplus + assert_compiles(<<~RUBY, frozen_string_literal: true, result: :ok, exits: { putspecialobject: 1, definemethod: 1 }) + def str_uplus + _ = 1 + _ = 2 + ret = [+"frfr", __LINE__] + _ = 3 + _ = 4 + + ret + end + + str_uplus + require 'objspace' + ObjectSpace.trace_object_allocations_start + + str, expected_line = str_uplus + alloc_line = ObjectSpace.allocation_sourceline(str) + + if expected_line == alloc_line + :ok + else + [expected_line, alloc_line] + end + RUBY + end + + def test_str_uplus_subclass + assert_compiles(<<~RUBY, frozen_string_literal: true, result: :subclass) + class S < String + def encoding + :subclass + end + end + + def test(str) + (+str).encoding + end + + test "" + test S.new + RUBY + end + + def test_return_to_invalidated_block + # [Bug #19463] + assert_compiles(<<~RUBY, result: [1, 1, :ugokanai], exits: { definesmethod: 1, getlocal_WC_0: 1 }) + klass = Class.new do + def self.lookup(hash, key) = hash[key] + + def self.foo(a, b) = [] + + def self.test(hash, key) + [lookup(hash, key), key, "".freeze] + # 05 opt_send_without_block :lookup + # 07 getlocal_WC_0 :hash + # 09 opt_str_freeze "" + # 12 newarray 3 + # 14 leave + # + # YJIT will put instructions (07..14) into a block. + # When String#freeze is redefined from within lookup(), + # the return address to the block is still on-stack. We rely + # on invalidation patching the code at the return address + # to service this situation correctly. + end + end + + # get YJIT to compile test() + hash = { 1 => [] } + 31.times { klass.test(hash, 1) } + + # inject invalidation into lookup() + evil_hash = Hash.new do |_, key| + class String + undef :freeze + def freeze = :ugokanai + end + + key + end + klass.test(evil_hash, 1) + RUBY + end + + def test_return_to_invalidated_frame + assert_compiles(code_gc_helpers + <<~RUBY, exits: :any, result: :ok) + def jump + [] # something not inlined + end + + def entry(code_gc) + jit_exception(code_gc) + jump # faulty jump after code GC. #jit_exception should not come back. + end + + def jit_exception(code_gc) + if code_gc + tap do + RubyVM::YJIT.code_gc + break # jit_exec_exception catches TAG_BREAK and re-enters JIT code + end + end + end + + add_pages(100) + jump # Compile #jump in a non-first page + add_pages(100) + entry(false) # Compile #entry and its call to #jump in another page + entry(true) # Free #jump but not #entry + + :ok + RUBY + end + + def test_setivar_on_class + # Bug in https://github.com/ruby/ruby/pull/8152 + assert_compiles(<<~RUBY, result: :ok) + class Base + def self.or_equal + @or_equal ||= Object.new + end + end + + Base.or_equal # ensure compiled + + class Child < Base + end + + 200.times do |iv| # Need to be more than MAX_IVAR + Child.instance_variable_set("@_iv_\#{iv}", Object.new) + end + + Child.or_equal + :ok + RUBY + end + + def test_nested_send + #[Bug #19464] + assert_compiles(<<~RUBY, result: [:ok, :ok], exits: { defineclass: 1 }) + klass = Class.new do + class << self + alias_method :my_send, :send + + def bar = :ok + + def foo = bar + end + end + + with_break = -> { break klass.send(:my_send, :foo) } + wo_break = -> { klass.send(:my_send, :foo) } + + [with_break[], wo_break[]] + RUBY + end + + def test_str_concat_encoding_mismatch + assert_compiles(<<~'RUBY', result: "incompatible character encodings: BINARY (ASCII-8BIT) and EUC-JP") + def bar(a, b) + a << b + rescue => e + e.message + end + + def foo(a, b, h) + h[nil] + bar(a, b) # Ruby call, not set cfp->pc + end + + h = Hash.new { nil } + foo("\x80".b, "\xA1A1".dup.force_encoding("EUC-JP"), h) + foo("\x80".b, "\xA1A1".dup.force_encoding("EUC-JP"), h) + RUBY + end + + def test_io_reopen_clobbering_singleton_class + assert_compiles(<<~RUBY, result: [:ok, :ok], exits: { definesmethod: 1, opt_eq: 2 }) + def $stderr.to_i = :i + + def test = $stderr.to_i + + [test, test] + $stderr.reopen($stderr.dup) + [test, test].map { :ok unless _1 == :i } + RUBY + end + + def test_opt_aref_with + assert_compiles(<<~RUBY, insns: %i[opt_aref_with], result: "bar", frozen_string_literal: false) + h = {"foo" => "bar"} + + h["foo"] + RUBY + end + + def test_proc_block_arg + assert_compiles(<<~RUBY, result: [:proc, :no_block]) + def yield_if_given = block_given? ? yield : :no_block + + def call(block_arg = nil) = yield_if_given(&block_arg) + + [call(-> { :proc }), call] + RUBY + end + + def test_opt_mult_overflow + assert_no_exits('0xfff_ffff_ffff_ffff * 0x10') + end + + def test_disable_stats + assert_in_out_err(%w[--yjit-stats --yjit-disable]) + end + + def test_odd_calls_to_attr_reader + # Use of delegate from ActiveSupport use these kind of calls to getter methods. + assert_compiles(<<~RUBY, result: [1, 1, 1], no_send_fallbacks: true) + class One + attr_reader :one + def initialize + @one = 1 + end + end + + def calls(obj, empty, &) + [obj.one(*empty), obj.one(&), obj.one(*empty, &)] + end + + calls(One.new, []) + RUBY + end + + def test_kwrest + assert_compiles(<<~RUBY, result: true, no_send_fallbacks: true) + def req_rest(r1:, **kwrest) = [r1, kwrest] + def opt_rest(r1: 1.succ, **kwrest) = [r1, kwrest] + def kwrest(**kwrest) = kwrest + + def calls + [ + [1, {}] == req_rest(r1: 1), + [1, {:r2=>2, :r3=>3}] == req_rest(r1: 1, r2: 2, r3: 3), + [1, {:r2=>2, :r3=>3}] == req_rest(r2: 2, r1:1, r3: 3), + [1, {:r2=>2, :r3=>3}] == req_rest(r2: 2, r3: 3, r1: 1), + + [2, {}] == opt_rest, + [2, { r2: 2, r3: 3 }] == opt_rest(r2: 2, r3: 3), + [0, { r2: 2, r3: 3 }] == opt_rest(r1: 0, r3: 3, r2: 2), + [0, { r2: 2, r3: 3 }] == opt_rest(r2: 2, r1: 0, r3: 3), + [1, { r2: 2, r3: 3 }] == opt_rest(r2: 2, r3: 3, r1: 1), + + {} == kwrest, + { r0: 88, r1: 99 } == kwrest(r0: 88, r1: 99), + ] + end + + calls.all? + RUBY + end + + def test_send_polymorphic_method_name + assert_compiles(<<~'RUBY', result: %i[ok ok], no_send_fallbacks: true) + mid = "dynamic_mid_#{rand(100..200)}" + mid_dsym = mid.to_sym + + define_method(mid) { :ok } + + define_method(:send_site) { send(_1) } + + [send_site(mid), send_site(mid_dsym)] + RUBY + end + + def test_kw_splat_nil + assert_compiles(<<~'RUBY', result: %i[ok ok ok], no_send_fallbacks: true) + def id(x) = x + def kw_fw(arg, **) = id(arg, **) + def fw(...) = id(...) + def use = [fw(:ok), kw_fw(:ok), :ok.itself(**nil)] + + use + RUBY + end + + def test_empty_splat + assert_compiles(<<~'RUBY', result: %i[ok ok], no_send_fallbacks: true) + def foo = :ok + def fw(...) = foo(...) + def use(empty) = [foo(*empty), fw] + + use([]) + RUBY + end + + def test_byteslice_sp_invalidation + assert_compiles(<<~'RUBY', result: 'ok', no_send_fallbacks: true) + "okng".itself.byteslice(0, 2) + RUBY + end + + def test_leaf_builtin + assert_compiles(code_gc_helpers + <<~'RUBY', exits: :any, result: 1) + before = RubyVM::YJIT.runtime_stats[:num_send_iseq_leaf] + return 1 if before.nil? + + def entry = self.class + entry + + after = RubyVM::YJIT.runtime_stats[:num_send_iseq_leaf] + after - before + RUBY + end + + private + + def code_gc_helpers + <<~'RUBY' + def compiles(&block) + failures = RubyVM::YJIT.runtime_stats[:compilation_failure] + block.call + failures == RubyVM::YJIT.runtime_stats[:compilation_failure] + end + + def add_pages(num_jits) + pages = RubyVM::YJIT.runtime_stats[:live_page_count] + num_jits.times { return false unless eval('compiles { nil.to_i }') } + pages.nil? || pages < RubyVM::YJIT.runtime_stats[:live_page_count] + end + RUBY + end + def assert_no_exits(script) assert_compiles(script) end ANY = Object.new - def assert_compiles(test_script, insns: [], min_calls: 1, stdout: nil, exits: {}, result: ANY, frozen_string_literal: nil) + def assert_compiles( + test_script, insns: [], + call_threshold: 1, + stdout: nil, + exits: {}, + result: ANY, + frozen_string_literal: nil, + mem_size: nil, + code_gc: false, + no_send_fallbacks: false + ) reset_stats = <<~RUBY RubyVM::YJIT.runtime_stats RubyVM::YJIT.reset_stats! @@ -581,35 +1655,23 @@ class TestYJIT < Test::Unit::TestCase write_results = <<~RUBY stats = RubyVM::YJIT.runtime_stats - def collect_blocks(blocks) - blocks.sort_by(&:address).map { |b| [b.iseq_start_index, b.iseq_end_index] } - end - - def collect_iseqs(iseq) - iseq_array = iseq.to_a - insns = iseq_array.last.grep(Array) - blocks = RubyVM::YJIT.blocks_for(iseq) - h = { - name: iseq_array[5], - insns: insns, - blocks: collect_blocks(blocks), - } - arr = [h] - iseq.each_child { |c| arr.concat collect_iseqs(c) } - arr + def collect_insns(iseq) + insns = RubyVM::YJIT.insns_compiled(iseq) + iseq.each_child { |c| insns.concat collect_insns(c) } + insns end iseq = RubyVM::InstructionSequence.of(_test_proc) IO.open(3).write Marshal.dump({ result: #{result == ANY ? "nil" : "result"}, stats: stats, - iseqs: collect_iseqs(iseq), + insns: collect_insns(iseq), disasm: iseq.disasm }) RUBY script = <<~RUBY - #{"# frozen_string_literal: true" if frozen_string_literal} + #{"# frozen_string_literal: " + frozen_string_literal.to_s unless frozen_string_literal.nil?} _test_proc = -> { #{test_script} } @@ -618,7 +1680,7 @@ class TestYJIT < Test::Unit::TestCase #{write_results} RUBY - status, out, err, stats = eval_with_jit(script, min_calls: min_calls) + status, out, err, stats = eval_with_jit(script, call_threshold:, mem_size:, code_gc:) assert status.success?, "exited with status #{status.to_i}, stderr:\n#{err}" @@ -629,67 +1691,92 @@ class TestYJIT < Test::Unit::TestCase end runtime_stats = stats[:stats] - iseqs = stats[:iseqs] + insns_compiled = stats[:insns] disasm = stats[:disasm] - # Only available when RUBY_DEBUG enabled + # Check that exit counts are as expected + # Full stats are only available when --enable-yjit=dev if runtime_stats[:all_stats] recorded_exits = runtime_stats.select { |k, v| k.to_s.start_with?("exit_") } recorded_exits = recorded_exits.reject { |k, v| v == 0 } recorded_exits.transform_keys! { |k| k.to_s.gsub("exit_", "").to_sym } - if exits != :any && exits != recorded_exits - flunk "Expected #{exits.empty? ? "no" : exits.inspect} exits" \ - ", but got\n#{recorded_exits.inspect}" + # Exits can be specified as a hash of stat-name symbol to integer for exact exits. + # or stat-name symbol to range if the number of side exits might vary (e.g. write + # barriers, cache misses.) + if exits != :any && + exits != recorded_exits && + (exits.keys != recorded_exits.keys || !exits.all? { |k, v| v === recorded_exits[k] }) # triple-equal checks range membership or integer equality + stats_reasons = StringIO.new + ::RubyVM::YJIT.send(:_print_stats_reasons, runtime_stats, stats_reasons) + stats_reasons = stats_reasons.string + flunk <<~EOM + Expected #{exits.empty? ? "no" : exits.inspect} exits, but got: + #{recorded_exits.inspect} + Reasons: + #{stats_reasons} + EOM end end - # Only available when RUBY_DEBUG enabled + if no_send_fallbacks + assert_equal(0, runtime_stats[:num_send_dynamic], "Expected no use of fallback implementation") + end + + # Only available when --enable-yjit=dev if runtime_stats[:all_stats] missed_insns = insns.dup - all_compiled_blocks = {} - iseqs.each do |iseq| - compiled_blocks = iseq[:blocks].map { |from, to| (from...to) } - all_compiled_blocks[iseq[:name]] = compiled_blocks - compiled_insns = iseq[:insns] - next_idx = 0 - compiled_insns.map! do |insn| - # TODO: not sure this is accurate for determining insn size - idx = next_idx - next_idx += insn.length - [idx, *insn] - end - - compiled_insns.each do |idx, op, *arguments| - next unless missed_insns.include?(op) - next unless compiled_blocks.any? { |block| block === idx } + insns_compiled.each do |op| + if missed_insns.include?(op) # This instruction was compiled missed_insns.delete(op) end end unless missed_insns.empty? - flunk "Expected to compile instructions #{missed_insns.join(", ")} but didn't.\nCompiled ranges: #{all_compiled_blocks.inspect}\niseq:\n#{disasm}" + flunk "Expected to compile instructions #{missed_insns.join(", ")} but didn't.\niseq:\n#{disasm}" end end end - def eval_with_jit(script, min_calls: 1, timeout: 1000) + def script_shell_encode(s) + # We can't pass utf-8-encoded characters directly in a shell arg. But we can use Ruby \u constants. + s.chars.map { |c| c.ascii_only? ? c : "\\u%x" % c.codepoints[0] }.join + end + + def eval_with_jit(script, call_threshold: 1, timeout: 1000, mem_size: nil, code_gc: false) args = [ "--disable-gems", - "--yjit-call-threshold=#{min_calls}", - "--yjit-stats" + "--yjit-call-threshold=#{call_threshold}", + "--yjit-stats=quiet" ] - args << "-e" << script + args << "--yjit-exec-mem-size=#{mem_size}" if mem_size + args << "--yjit-code-gc" if code_gc + args << "-e" << script_shell_encode(script) stats_r, stats_w = IO.pipe - out, err, status = EnvUtil.invoke_ruby(args, - '', true, true, timeout: timeout, ios: {3 => stats_w} - ) + # Separate thread so we don't deadlock when + # the child ruby blocks writing the stats to fd 3 + stats = '' + stats_reader = Thread.new do + stats = stats_r.read + stats_r.close + end + out, err, status = invoke_ruby(args, '', true, true, timeout: timeout, ios: { 3 => stats_w }) stats_w.close - stats = stats_r.read + stats_reader.join(timeout) stats = Marshal.load(stats) if !stats.empty? - stats_r.close [status, out, err, stats] + ensure + stats_reader&.kill + stats_reader&.join(timeout) + stats_r&.close + stats_w&.close + end + + # A wrapper of EnvUtil.invoke_ruby that uses RbConfig.ruby instead of EnvUtil.ruby + # that might use a wrong Ruby depending on your environment. + def invoke_ruby(*args, **kwargs) + EnvUtil.invoke_ruby(*args, rubybin: RbConfig.ruby, **kwargs) end end |