結論
複数のクラスを実装しているRuby内部のCコードでbuiltinをいくつか組み合わせるとビルドできなくなる。
RubyでRubyを実装する
最近のRubyは「Ruby自体をRubyで実装することで高速化できる」という場面がある。
その辺の詳しいことは以前記事にまとめたのでそちらを参照して頂ければと思います。
で、そういったケースでちょっとした罠があるので未来の自分用にメモとして記事にしてみた。
起きたこと
TrueClass
をRubyで実装することで高速化できるというチケットを少し前に投げてた。
パッチも作成してPull Requestで投げたんだけど、CIで軒並み死んでいるということに……(ローカルでちゃんとテスト回してないからこういうことになる……)
テストが落ちた原因としてはbootstraptest/test_insns.rb
でString#freeze
が再定義されていたため。
実は高速化にあたって文字列をfreezeして返すような以下のコードを書いてた。
class TrueClass # # call-seq: # true.to_s -> "true" # # The string representation of <code>true</code> is "true". # def to_s "true".freeze end end
このコードだとfreeze
が再定義されているケースで期待した動作にならなくなってしまうのだった……。
そこで、Ruby内部のC関数を呼び出す実装に変更してみた。
class TrueClass # # call-seq: # true.to_s -> "true" # # The string representation of <code>true</code> is "true". # def to_s Primitive.attr! 'inline' Primitive.cexpr! 'true_to_s(self)' end end
肝はPrimitive.cexpr! 'true_to_s(self)'
で、このコードでCで定義された関数を実行している(厳密には、このRubyコードをもとにCの関数が新しく定義され、それを実行しているみたい)
あとはビルドすればOKだろうと思い、ビルドすると関数が再定義されているというエラーが発生。
sh@MyComputer:~/rubydev/build$ make benchmark/trueclass.yml -e COMPARE_RUBY=~/.rbenv/shims/ruby -e BENCH_RUBY=../install/bin/ruby compiling ../ruby/compile.c compiling ../ruby/object.c In file included from ../ruby/object.c:4665: ../ruby/trueclass.rbinc:21:1: エラー: ‘mjit_compile_invokebuiltin_for__bi0’ が再定義されました 21 | mjit_compile_invokebuiltin_for__bi0(FILE *f, long index, unsigned stack_size, bool inlinable_p) | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In file included from ../ruby/object.c:4664: ../ruby/kernel.rbinc:42:1: 備考: 前の ‘mjit_compile_invokebuiltin_for__bi0’ の宣言はここです 42 | mjit_compile_invokebuiltin_for__bi0(FILE *f, long index, unsigned stack_size, bool inlinable_p) | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ cc1: 警告: 認識できないコマンドラインオプション ‘-Wno-self-assign’ です cc1: 警告: 認識できないコマンドラインオプション ‘-Wno-parentheses-equality’ です cc1: 警告: 認識できないコマンドラインオプション ‘-Wno-constant-logical-operand’ です Makefile:424: recipe for target 'object.o' failed make: *** [object.o] Error 1
原因
Rubyの実装(Cのコード)では一つのソースコード内にいくつかのRubyのクラスが定義されていることがある。
例えばobject.c
ではObjectクラスのほかNilClassやTrueClassなども定義されている。
今回は既にKernelモジュールの一部メソッドがRubyで実装されており、そこへ追加でTrueClassを別ファイルで実装したのがまずかった。
RubyでRubyを実装した場合、ビルド時にRubyのコードをもとに.rbinc
拡張子でCのコードが自動生成される。
その.rbinc
をCのコード内でインクルードすることでRubyで実装したメソッドを実行できるようにしている。
つまり今回の場合は以下のような形で二つの.rbinc
をインクルードしていた。
#include "kernel.rbinc" #include "trueclass.rbinc"
これがまずい背景としては自動生成されたCコードの関数名がかぶってしまっているため。
なので、上記のようにインクルードすると#include "trueclass.rbinc"
の部分で関数の再定義が起きてしまう。
つまり、RubyでRubyを実装する場合は基本的にCコード(object.c
など)に対して、Rubyのコードと.rbinc
が一つづつセットでないとエラーになるということ。
対処法
原因がはっきりしたので対処法をアレコレ考えてみた。
まあ、結論から言えば「自動生成されているCの関数名が重複しなければいい」ので、重複しないように修正した。
RubyのコードをCコードに変換しているのはtool/mk_builtin_loader.rb
というファイルになる。
で、このファイルの中で定義されているmk_builtin_header
メソッド内でCコードへの自動変換を行っている。
def mk_builtin_header file base = File.basename(file, '.rb') ofile = "#{file}inc" # bs = { func_name => argc } code = File.read(file) collect_iseq RubyVM::InstructionSequence.compile(code).to_a collect_builtin(base, Ripper.sexp(code), 'top', bs = {}, inlines = {}) begin f = open(ofile, 'w') rescue Errno::EACCES # Fall back to the current directory f = open(File.basename(ofile), 'w') end begin if File::ALT_SEPARATOR file = file.tr(File::ALT_SEPARATOR, File::SEPARATOR) ofile = ofile.tr(File::ALT_SEPARATOR, File::SEPARATOR) end lineno = __LINE__ f.puts "// -*- c -*-" f.puts "// DO NOT MODIFY THIS FILE DIRECTLY." f.puts "// auto-generated file" f.puts "// by #{__FILE__}" f.puts "// with #{file}" f.puts '#include "internal/compilers.h" /* for MAYBE_UNUSED */' f.puts '#include "internal/warnings.h" /* for COMPILER_WARNING_PUSH */' f.puts '#include "ruby/ruby.h" /* for VALUE */' f.puts '#include "builtin.h" /* for RB_BUILTIN_FUNCTION */' f.puts 'struct rb_execution_context_struct; /* in vm_core.h */' f.puts lineno = __LINE__ - lineno - 1 line_file = file inlines.each{|cfunc_name, (body_lineno, text, locals, func_name)| if String === cfunc_name f.puts "static VALUE #{cfunc_name}(struct rb_execution_context_struct *ec, const VALUE self)" lineno += 1 lineno, str = generate_cexpr(ofile, lineno, line_file, body_lineno, text, locals, func_name) f.write str else # cinit! f.puts "#line #{body_lineno} \"#{line_file}\"" lineno += 1 f.puts text lineno += text.count("\n") + 1 f.puts "#line #{lineno + 2} \"#{ofile}\"" # TODO: restore line number. lineno += 1 end } bs.each_pair{|func, (argc, cfunc_name)| decl = ', VALUE' * argc argv = argc \ . times \ . map {|i|", argv[#{i}]"} \ . join('') f.puts %'static void' f.puts %'mjit_compile_invokebuiltin_for_#{func}(FILE *f, long index, unsigned stack_size, bool inlinable_p)' f.puts %'{' f.puts %' fprintf(f, " VALUE self = GET_SELF();\\n");' f.puts %' fprintf(f, " typedef VALUE (*func)(rb_execution_context_t *, VALUE#{decl});\\n");' if inlines.has_key? cfunc_name body_lineno, text, locals, func_name = inlines[cfunc_name] lineno, str = generate_cexpr(ofile, lineno, line_file, body_lineno, text, locals, func_name) f.puts %' if (inlinable_p) {' str.gsub(/^(?!#)/, ' ').each_line {|i| j = RubyVM::CEscape.rstring2cstr(i).dup j.sub!(/^ return\b/ , ' val =') f.printf(%' fprintf(f, "%%s", %s);\n', j) } f.puts(%' return;') f.puts(%' }') end if argc > 0 f.puts %' if (index == -1) {' f.puts %' fprintf(f, " const VALUE *argv = &stack[%d];\\n", stack_size - #{argc});' f.puts %' }' f.puts %' else {' f.puts %' fprintf(f, " const unsigned int lnum = GET_ISEQ()->body->local_table_size;\\n");' f.puts %' fprintf(f, " const VALUE *argv = GET_EP() - lnum - VM_ENV_DATA_SIZE + 1 + %ld;\\n", index);' f.puts %' }' end f.puts %' fprintf(f, " func f = (func)%"PRIdPTR"; /* == #{cfunc_name} */\\n", (intptr_t)#{cfunc_name});' f.puts %' fprintf(f, " val = f(ec, self#{argv});\\n");' f.puts %'}' f.puts } f.puts "void Init_builtin_#{base}(void)" f.puts "{" table = "#{base}_table" f.puts " // table definition" f.puts " static const struct rb_builtin_function #{table}[] = {" bs.each.with_index{|(func, (argc, cfunc_name)), i| f.puts " RB_BUILTIN_FUNCTION(#{i}, #{func}, #{cfunc_name}, #{argc}, mjit_compile_invokebuiltin_for_#{func})," } f.puts " RB_BUILTIN_FUNCTION(-1, NULL, NULL, 0, 0)," f.puts " };" f.puts f.puts " // arity_check" f.puts "COMPILER_WARNING_PUSH" f.puts "#if GCC_VERSION_SINCE(5, 1, 0) || __clang__" f.puts "COMPILER_WARNING_ERROR(-Wincompatible-pointer-types)" f.puts "#endif" bs.each{|func, (argc, cfunc_name)| f.puts " if (0) rb_builtin_function_check_arity#{argc}(#{cfunc_name});" } f.puts "COMPILER_WARNING_POP" f.puts f.puts " // load" f.puts " rb_load_with_builtin_functions(#{base.dump}, #{table});" f.puts "}" ensure f.close end end
今回関数名が重複している箇所はf.puts %'mjit_compile_invokebuiltin_for_#{func}(FILE *f, long index, unsigned stack_size, bool inlinable_p)'
なのでこれを以下のように変更した。
f.puts %'mjit_compile_invokebuiltin_for_#{func.hash.abs}(FILE *f, long index, unsigned stack_size, bool inlinable_p)'
func
は呼び出されたCの関数名などが文字列で渡されており、それをハッシュ値に変換している。abs
を使っているのは値がマイナスの際にエラーになるのを回避するため。
あとは、自動生成された関数を呼び出せるようにする箇所を以下のように修正する。
f.puts " RB_BUILTIN_FUNCTION(#{i}, #{func}, #{cfunc_name}, #{argc}, mjit_compile_invokebuiltin_for_#{func.hash.abs}),"
こちらも同じように文字列をハッシュ値にしている。