RubyでRubyを実装する時の罠

結論

複数のクラスを実装しているRuby内部のCコードでbuiltinをいくつか組み合わせるとビルドできなくなる。

RubyRubyを実装する

最近のRubyは「Ruby自体をRubyで実装することで高速化できる」という場面がある。

その辺の詳しいことは以前記事にまとめたのでそちらを参照して頂ければと思います。

gamelinks007.hatenablog.com

で、そういったケースでちょっとした罠があるので未来の自分用にメモとして記事にしてみた。

起きたこと

TrueClassRubyで実装することで高速化できるというチケットを少し前に投げてた。

bugs.ruby-lang.org

パッチも作成してPull Requestで投げたんだけど、CIで軒並み死んでいるということに……(ローカルでちゃんとテスト回してないからこういうことになる……)

テストが落ちた原因としてはbootstraptest/test_insns.rbString#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を別ファイルで実装したのがまずかった。

RubyRubyを実装した場合、ビルド時にRubyのコードをもとに.rbinc拡張子でCのコードが自動生成される。
その.rbincをCのコード内でインクルードすることでRubyで実装したメソッドを実行できるようにしている。

つまり今回の場合は以下のような形で二つの.rbincをインクルードしていた。

#include "kernel.rbinc"
#include "trueclass.rbinc"

これがまずい背景としては自動生成されたCコードの関数名がかぶってしまっているため。
なので、上記のようにインクルードすると#include "trueclass.rbinc"の部分で関数の再定義が起きてしまう。

つまり、RubyRubyを実装する場合は基本的に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}),"

こちらも同じように文字列をハッシュ値にしている。

補足

「なぜこれで関数名が重複しないのか?」と疑問が湧くかもしれないので、補足する。

Rubyで書いた実装はビルド時にCのコードへと変換されるんだけど、これはそれぞれのファイルごとに処理される(今回ならkernel.rbtrueclass.rbは別々に処理される)

そのため文字列をハッシュ値に変換するのがそれぞれのファイルで実行される形になる。
で、Rubyで文字列のハッシュ値に変換する場合は実行するごとに値が異なるので値が同じにならないということ(まあハッシュ値の衝突とかは起こる可能性も考慮しなきゃなんだけども……)

なので、こういった修正でビルドができるようになる。