2018-03-29

bootsnapコードリーディング(load_iseqを使った高速読み込み)

bootsnap(1.2.0)のコードリーディングをしました。今回はload_iseqによるrequire, loadの高速化を見ていきます。

まず最初にBootsnap.setupが呼ばれます。load_iseqが関連するところはBootsnap::CompileCache.setupになります。

module Bootsnap
  InvalidConfiguration = Class.new(StandardError)

  def self.setup(
    cache_dir:,
    development_mode: true,
    load_path_cache: true,
    autoload_paths_cache: true,
    disable_trace: false,
    compile_cache_iseq: true,
    compile_cache_yaml: true
  )
    if autoload_paths_cache && !load_path_cache
      raise InvalidConfiguration, "feature 'autoload_paths_cache' depends on feature 'load_path_cache'"
    end

    setup_disable_trace if disable_trace

    Bootsnap::LoadPathCache.setup(
      cache_path:       cache_dir + '/bootsnap-load-path-cache',
      development_mode: development_mode,
      active_support:   autoload_paths_cache
    ) if load_path_cache

    Bootsnap::CompileCache.setup(
      cache_dir: cache_dir + '/bootsnap-compile-cache',
      iseq: compile_cache_iseq,
      yaml: compile_cache_yaml
    )
  end

  def self.setup_disable_trace
    RubyVM::InstructionSequence.compile_option = { trace_instruction: false }
  end
end

Bootsnap::CompileCache.setupの処理を見ていきます。setupはBootsnap::CompileCache::ISeq.install!を呼び出します。

module Bootsnap
  module CompileCache
    def self.setup(cache_dir:, iseq:, yaml:)
      if iseq
        require_relative 'compile_cache/iseq'
        Bootsnap::CompileCache::ISeq.install!(cache_dir)
      end

      if yaml
        require_relative 'compile_cache/yaml'
        Bootsnap::CompileCache::YAML.install!(cache_dir)
      end
    end
  end
end

install!はRubyVM::InstructionSequenceの特異クラスにInstructionSequenceMixinをprependします。InstructionSequenceMixinはload_iseqが定義されています。RubyVM::InstructionSequence.load_iseqが定義されている場合はrequireやloadの呼び出しでload_iseqが呼ばれます。このフックポイントでInstructionSequenceのバイナリファイルを読み出しています。Bootsnap::CompileCache::Native.fetchの戻り値はInstructionSequenceのインスタンスになります。

module Bootsnap
  module CompileCache
    module ISeq
      module InstructionSequenceMixin
        def load_iseq(path)
          # Having coverage enabled prevents iseq dumping/loading.
          return nil if defined?(Coverage) && Bootsnap::CompileCache::Native.coverage_running?

          Bootsnap::CompileCache::Native.fetch(
            Bootsnap::CompileCache::ISeq.cache_dir,
            path.to_s,
            Bootsnap::CompileCache::ISeq
          )
        rescue RuntimeError => e
          if e.message =~ /unmatched platform/
            puts "unmatched platform for file #{path}"
          end
          raise
        end

        def compile_option=(hash)
          super(hash)
          Bootsnap::CompileCache::ISeq.compile_option_updated
        end
      end

      def self.install!(cache_dir)
        Bootsnap::CompileCache::ISeq.cache_dir = cache_dir
        Bootsnap::CompileCache::ISeq.compile_option_updated
        class << RubyVM::InstructionSequence
          prepend InstructionSequenceMixin
        end
      end
    end
  end
end

Bootsnap::CompileCache::Native.fetchはC拡張のInit_bootsnap()で定義されています。fetchメソッドはbs_rb_fetch()になります。

void
Init_bootsnap(void)
{
  rb_mBootsnap = rb_define_module("Bootsnap");
  rb_mBootsnap_CompileCache = rb_define_module_under(rb_mBootsnap, "CompileCache");
  rb_mBootsnap_CompileCache_Native = rb_define_module_under(rb_mBootsnap_CompileCache, "Native");
  rb_eBootsnap_CompileCache_Uncompilable = rb_define_class_under(rb_mBootsnap_CompileCache, "Uncompilable", rb_eStandardError);

  current_ruby_revision = FIX2INT(rb_const_get(rb_cObject, rb_intern("RUBY_REVISION")));
  current_ruby_platform = get_ruby_platform();

  uncompilable = rb_intern("__bootsnap_uncompilable__");

  rb_define_module_function(rb_mBootsnap_CompileCache_Native, "coverage_running?", bs_rb_coverage_running, 0);
  rb_define_module_function(rb_mBootsnap_CompileCache_Native, "fetch", bs_rb_fetch, 3);
  rb_define_module_function(rb_mBootsnap_CompileCache_Native, "compile_option_crc32=", bs_compile_option_crc32_set, 1);
}

bs_rb_fetch()は以下のように定義されており、bs_fetch()を呼び出しています。bs_cache_pathではrequireで指定したパスを使ってキャッシュ用のパス(tmp/cache/bootsnap-compile-cache/xx/xxxxx)をcache_path変数にセットします。

static VALUE
bs_rb_fetch(VALUE self, VALUE cachedir_v, VALUE path_v, VALUE handler)
{
  Check_Type(cachedir_v, T_STRING);
  Check_Type(path_v, T_STRING);

  if (RSTRING_LEN(cachedir_v) > MAX_CACHEDIR_SIZE) {
    rb_raise(rb_eArgError, "cachedir too long");
  }

  char * cachedir = RSTRING_PTR(cachedir_v);
  char * path     = RSTRING_PTR(path_v);
  char cache_path[MAX_CACHEPATH_SIZE];

  { /* generate cache path to cache_path */
    char * tmp = (char *)&cache_path;
    bs_cache_path(cachedir, path, &tmp);
  }

  return bs_fetch(path, path_v, cache_path, handler);
}

bs_fetch()はメインの処理をしている関数になります。長いので抜粋しつつ解説していきます。

pathはrequireで指定した絶対パスが入ります。cache_pathは上述の通りキャッシュ用のファイルパスです。まずrequireで指定されたファイルのファイルディスクリプタをopen_current_file()によって取得します。その際、current_keyにstat情報等をセットします。このキーは主にキャッシュの比較用に使われます。

static VALUE
bs_fetch(char * path, VALUE path_v, char * cache_path, VALUE handler)
{
# ...
  VALUE input_data;   /* data read from source file, e.g. YAML or ruby source */
  VALUE storage_data; /* compiled data, e.g. msgpack / binary iseq */
  VALUE output_data;  /* return data, e.g. ruby hash or loaded iseq */

  VALUE exception; /* ruby exception object to raise instead of returning */

  /* Open the source file and generate a cache key for it */
  current_fd = open_current_file(path, &current_key, &errno_provenance);
  if (current_fd < 0) goto fail_errno;

open_current_file()はstat情報などを抜き出してcurrent_keyにセットします。ファイルが存在すればファイルディスクリプタを返します。

static int
open_current_file(char * path, struct bs_cache_key * key, char ** errno_provenance)
{
  struct stat statbuf;
  int fd;

  fd = open(path, O_RDONLY);
  if (fd < 0) {
    *errno_provenance = (char *)"bs_fetch:open_current_file:open";
    return fd;
  }
  #ifdef _WIN32
  setmode(fd, O_BINARY);
  #endif

  if (fstat(fd, &statbuf) < 0) {
    *errno_provenance = (char *)"bs_fetch:open_current_file:fstat";
    close(fd);
    return -1;
  }

  key->version        = current_version;
  key->ruby_platform  = current_ruby_platform;
  key->compile_option = current_compile_option_crc32;
  key->ruby_revision  = current_ruby_revision;
  key->size           = (uint64_t)statbuf.st_size;
  key->mtime          = (uint64_t)statbuf.st_mtime;

  return fd;
}

bs_fetch()ではさらにopen_cache_file()によってキャッシュファイルを開き、キャッシュが存在すればcache_key_equal()でキャッシュが有効かどうか(bootsnapのバージョンやファイルのmtime、データサイズを比較)をチェックし、有効であればそのキャッシュファイルを読み込みます。

  /* Open the cache key if it exists, and read its cache key in */
  cache_fd = open_cache_file(cache_path, &cached_key, &errno_provenance);
  if (cache_fd == CACHE_MISSING_OR_INVALID) {
    /* This is ok: valid_cache remains false, we re-populate it. */
  } else if (cache_fd < 0) {
    goto fail_errno;
  } else {
    /* True if the cache existed and no invalidating changes have occurred since
     * it was generated. */
    valid_cache = cache_key_equal(&current_key, &cached_key);
  }

  if (valid_cache) {
    /* Fetch the cache data and return it if we're able to load it successfully */
    res = fetch_cached_data(
      cache_fd, (ssize_t)cached_key.data_size, handler,
      &output_data, &exception_tag, &errno_provenance
    );
    if (exception_tag != 0)                   goto raise;
    else if (res == CACHE_MISSING_OR_INVALID) valid_cache = 0;
    else if (res == ERROR_WITH_ERRNO)         goto fail_errno;
    else if (!NIL_P(output_data))             goto succeed; /* fast-path, goal */
  }
  close(cache_fd);
  cache_fd = -1;
  /* Cache is stale, invalid, or missing. Regenerate and write it out. */

fetch_cached_data()ではキャッシュファイルを読み込み、bs_storage_to_output()でバイナリからInstructionSequenceに変換します。

static int
fetch_cached_data(int fd, ssize_t data_size, VALUE handler, VALUE * output_data, int * exception_tag, char ** errno_provenance)
{
# ...
  data = ALLOC_N(char, data_size);
  nread = read(fd, data, data_size);
  if (nread < 0) {
    *errno_provenance = (char *)"bs_fetch:fetch_cached_data:read";
    ret = -1;
    goto done;
  }
  if (nread != data_size) {
    ret = CACHE_MISSING_OR_INVALID;
    goto done;
  }

  storage_data = rb_str_new_static(data, data_size);

  *exception_tag = bs_storage_to_output(handler, storage_data, output_data);
  ret = 0;
done:
  if (data != NULL) xfree(data);
  return ret;
}

bs_storage_to_output()はprot_storage_to_output()経由でBootsnap::CompileCache::ISeq.storage_to_outputを呼び出します。

static VALUE
prot_storage_to_output(VALUE arg)
{
  struct s2o_data * data = (struct s2o_data *)arg;
  return rb_funcall(data->handler, rb_intern("storage_to_output"), 1, data->storage_data);
}

static int
bs_storage_to_output(VALUE handler, VALUE storage_data, VALUE * output_data)
{
  int state;
  struct s2o_data s2o_data = {
    .handler      = handler,
    .storage_data = storage_data,
  };
  *output_data = rb_protect(prot_storage_to_output, (VALUE)&s2o_data, &state);
  return state;
}

Bootsnap::CompileCache::ISeq.storage_to_outputはバイナリの文字列からInstructionSequenceに変換します。

module Bootsnap
  module CompileCache
    module ISeq
      def self.storage_to_output(binary)
        RubyVM::InstructionSequence.load_from_binary(binary)
      rescue RuntimeError => e
        if e.message == 'broken binary format'
          STDERR.puts "[Bootsnap::CompileCache] warning: rejecting broken binary"
          return nil
        else
          raise
        end
      end

bs_fetch()でキャッシュヒットしなかった場合は、bs_read_contents()でrequireしたファイルを読み込み、ファイルの中身をrb_str_new_static()でString化します。

  /* Read the contents of the source file into a buffer */
  if (bs_read_contents(current_fd, current_key.size, &contents, &errno_provenance) < 0) goto fail_errno;
  input_data = rb_str_new_static(contents, current_key.size);

  /* Try to compile the input_data using input_to_storage(input_data) */
  exception_tag = bs_input_to_storage(handler, input_data, path_v, &storage_data);
  if (exception_tag != 0) goto raise;
  /* If input_to_storage raised Bootsnap::CompileCache::Uncompilable, don't try
   * to cache anything; just return input_to_output(input_data) */
  if (storage_data == uncompilable) {
    bs_input_to_output(handler, input_data, &output_data, &exception_tag);
    if (exception_tag != 0) goto raise;
    goto succeed;
  }
  /* If storage_data isn't a string, we can't cache it */
  if (!RB_TYPE_P(storage_data, T_STRING)) goto invalid_type_storage_data;

rb_str_new_static()は静的領域にStringのデータを生成するためGCの影響を受けず、さらに文字列をString化するときに不要なメモリ確保をしていないらしいです。

参考 → ruby-trunk-changes r47625 - r47644 - PB memo

String化したデータはbs_input_to_storage()によってキャッシュファイルに格納されます。

bs_input_to_storage()はprot_input_to_storage()、try_input_to_storage()経由でBootsnap::CompileCache::ISeq.input_to_storageを呼び出します。

static VALUE
try_input_to_storage(VALUE arg)
{
  struct i2s_data * data = (struct i2s_data *)arg;
  return rb_funcall(data->handler, rb_intern("input_to_storage"), 2, data->input_data, data->pathval);
}

static VALUE
prot_input_to_storage(VALUE arg)
{
  struct i2s_data * data = (struct i2s_data *)arg;
  return rb_rescue2(
      try_input_to_storage, (VALUE)data,
      rescue_input_to_storage, Qnil,
      rb_eBootsnap_CompileCache_Uncompilable, 0);
}

static int
bs_input_to_storage(VALUE handler, VALUE input_data, VALUE pathval, VALUE * storage_data)
{
  int state;
  struct i2s_data i2s_data = {
    .handler    = handler,
    .input_data = input_data,
    .pathval    = pathval,
  };
  *storage_data = rb_protect(prot_input_to_storage, (VALUE)&i2s_data, &state);
  return state;
}

input_to_storageメソッドは対象のファイルをコンパイルしてInstructionSequenceにしたものをbinary化します(これよく見たら第一引数が _になっていて、読み込んだファイル使っていない…)

module Bootsnap
  module CompileCache
    module ISeq
      def self.input_to_storage(_, path)
        RubyVM::InstructionSequence.compile_file(path).to_binary
      rescue SyntaxError
        raise Uncompilable, 'syntax error'
      end

その後、atomic_write_cache_file()でファイルに書き出し、前述のbs_storage_to_output()で書き出したファイルからInstructionSequenceを取り出します。

  /* Write the cache key and storage_data to the cache directory */
  res = atomic_write_cache_file(cache_path, &current_key, storage_data, &errno_provenance);
  if (res < 0) goto fail_errno;

  /* Having written the cache, now convert storage_data to output_data */
  exception_tag = bs_storage_to_output(handler, storage_data, &output_data);
  if (exception_tag != 0) goto raise;

  /* If output_data is nil, delete the cache entry and generate the output
   * using input_to_output */
  if (NIL_P(output_data)) {
    if (unlink(cache_path) < 0) {
      errno_provenance = (char *)"bs_fetch:unlink";
      goto fail_errno;
    }
    bs_input_to_output(handler, input_data, &output_data, &exception_tag);
    if (exception_tag != 0) goto raise;
  }

  goto succeed; /* output_data is now the correct return. */

goto succeedではCLEANUPしてInstructionSequenceを返しています。

#define CLEANUP \
  if (contents != NULL) xfree(contents);   \
  if (current_fd >= 0)  close(current_fd); \
  if (cache_fd >= 0)    close(cache_fd);

succeed:
  CLEANUP;
  return output_data;
# ...
}

このようにして、iseqのキャッシュファイルを読み込むことでファイルの読み込みを高速にしています。

このエントリーをはてなブックマークに追加