2018-03-27

bootsnapコードリーディング(requireのパス検索の高速化)

require, loadの高速化を行うbootsnap(1.2.0)のコードリーディングをしました。今回はrequireのパス検索の高速化部分を読んでいきます。

railsではboot/setupをrequireしますが、最終的にBootsnap.setupが呼ばれるのでここからコードを読んでいきます。

Bootsnap.setupの定義は以下の通りで、requireのパス検索を高速化するのはBootsnap::LoadPathCache.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::LoadPathCache.setupはload_pach_cache/core_ext/kernel_require.rbをrequireします。以下、通常のrequire, loadのパターンを追っていきます(今回はActiveSupportのautoloadは追いません)

module Bootsnap
  module LoadPathCache

    class << self
      attr_reader :load_path_cache, :autoload_paths_cache, :loaded_features_index

      def setup(cache_path:, development_mode:, active_support: true)
        store = Store.new(cache_path)

        @loaded_features_index = LoadedFeaturesIndex.new

        @load_path_cache = Cache.new(store, $LOAD_PATH, development_mode: development_mode)
        require_relative 'load_path_cache/core_ext/kernel_require'

        if active_support
          require 'active_support/dependencies'
          @autoload_paths_cache = Cache.new(
            store,
            ::ActiveSupport::Dependencies.autoload_paths,
            development_mode: development_mode
          )
          require_relative 'load_path_cache/core_ext/active_support'
        end

load_pach_cache/core_ext/kernel_require.rbでは以下のようにKernelに対してモンキーパッチしており、requireやloadメソッドが新しいメソッドに置き換えられます。LoadPathCache.load_path_cache.findで指定した文字列に対して読み込むファイルの絶対パスを返してresolved変数にセットし、そのresolvedを引数に元のrequireを呼び出しています。絶対パスで指定した場合は$LOAD_PATHを辿らないため高速に読み込めるそうです。

module Kernel
  private

  alias_method :require_without_bootsnap, :require

  # Note that require registers to $LOADED_FEATURES while load does not.
  def require_with_bootsnap_lfi(path, resolved = nil)
    Bootsnap::LoadPathCache.loaded_features_index.register(path, resolved) do
      require_without_bootsnap(resolved || path)
    end
  end

  def require(path)
    return false if Bootsnap::LoadPathCache.loaded_features_index.key?(path)

    if resolved = Bootsnap::LoadPathCache.load_path_cache.find(path)
      return require_with_bootsnap_lfi(path, resolved)
    end

    raise Bootsnap::LoadPathCache::CoreExt.make_load_error(path)
  rescue Bootsnap::LoadPathCache::ReturnFalse
    return false
  rescue Bootsnap::LoadPathCache::FallbackScan
    require_with_bootsnap_lfi(path)
  end

LoadPathCache.load_path_cacheはBootsnap::LoadPathCache::Cacheのインスタンスです。以下のようにコンストラクタで#reinitializeを呼び出します。

module Bootsnap
  module LoadPathCache
    class Cache
      def initialize(store, path_obj, development_mode: false)
        @development_mode = development_mode
        @store = store
        @mutex = defined?(::Mutex) ? ::Mutex.new : ::Thread::Mutex.new # TODO: Remove once Ruby 2.2 support is dropped.
        @path_obj = path_obj
        @has_relative_paths = nil
        reinitialize
      end

#reinitializeはpush_paths_lockedで@indexと@dirsをセットします。

      def reinitialize(path_obj = @path_obj)
        @mutex.synchronize do
          @path_obj = path_obj
          ChangeObserver.register(self, @path_obj)
          @index = {}
          @dirs = Hash.new(false)
          @generated_at = now
          push_paths_locked(*@path_obj)
        end
      end

      def push_paths_locked(*paths)
        @store.transaction do
          paths.map(&:to_s).each do |path|
            p = Path.new(path)
            @has_relative_paths = true if p.relative?
            next if p.non_directory?
            entries, dirs = p.entries_and_dirs(@store)
            # push -> low precedence -> set only if unset
            dirs.each    { |dir| @dirs[dir]  ||= true }
            entries.each { |rel| @index[rel] ||= p.expanded_path }
          end
        end
      end

pathsは$LOAD_PATHになります。$LOAD_PATHからファイルとディレクトリの相対パスを取り出して@dirsと@indexをセットしています。@indexはファイル名をキーに$LOAD_PATHの絶対パスを値にしたハッシュになります。

Cache#findは以下のように定義されています。

      def find(feature)
        reinitialize if (@has_relative_paths && dir_changed?) || stale?
        feature = feature.to_s
        return feature if absolute_path?(feature)
        return File.expand_path(feature) if feature.start_with?('./')
        @mutex.synchronize do
          x = search_index(feature)
          return x if x

          # Ruby has some built-in features that require lies about.
          # For example, 'enumerator' is built in. If you require it, ruby
          # returns false as if it were already loaded; however, there is no
          # file to find on disk. We've pre-built a list of these, and we
          # return false if any of them is loaded.
          raise LoadPathCache::ReturnFalse if BUILTIN_FEATURES.key?(feature)

# ...

search_indexは@indexにファイル名のキーが存在すれば絶対パス化した文字列を返しています。

      if DLEXT2
        def search_index(f)
          try_index(f + DOT_RB) || try_index(f + DLEXT) || try_index(f + DLEXT2) || try_index(f)
        end
      else
        def search_index(f)
          try_index(f + DOT_RB) || try_index(f + DLEXT) || try_index(f)
        end
      end

      def try_index(f)
        if p = @index[f]
          p + '/' + f
        end
      end

また、各ファイルのエントリーとディレクトリはキャッシュしています。store.setでハッシュに格納しています。

      def entries_and_dirs(store)
        if stable?
          # the cached_mtime field is unused for 'stable' paths, but is
          # set to zero anyway, just in case we change the stability heuristics.
          _, entries, dirs = store.get(expanded_path)
          return [entries, dirs] if entries # cache hit
          entries, dirs = scan!
          store.set(expanded_path, [0, entries, dirs])
          return [entries, dirs]
        end

        cached_mtime, entries, dirs = store.get(expanded_path)

        current_mtime = latest_mtime(expanded_path, dirs || [])
        return [[], []]        if current_mtime == -1 # path does not exist
        return [entries, dirs] if cached_mtime == current_mtime

        entries, dirs = scan!
        store.set(expanded_path, [current_mtime, entries, dirs])
        [entries, dirs]
      end

このハッシュ値はBootsnap::LoadPathCache::Store#dump_data(#transaction経由)によってMessagePack形式でファイルとして格納されます。これが tmp/cache/bootsnap-load-path-cacheのファイルになります。

module Bootsnap
  module LoadPathCache
    class Store

      def dump_data
        # Change contents atomically so other processes can't get invalid
        # caches if they read at an inopportune time.
        tmp = "#{@store_path}.#{Process.pid}.#{(rand * 100000).to_i}.tmp"
        FileUtils.mkpath(File.dirname(tmp))
        exclusive_write = File::Constants::CREAT | File::Constants::EXCL | File::Constants::WRONLY
        # `encoding:` looks redundant wrt `binwrite`, but necessary on windows
        # because binary is part of mode.
        File.binwrite(tmp, MessagePack.dump(@data), mode: exclusive_write, encoding: Encoding::BINARY)
        FileUtils.mv(tmp, @store_path)
      rescue Errno::EEXIST
        retry
      end

Bootsnap::LoadPathCache::Path#entries_and_dirsメソッド内で#stable?によって分岐しているところですが、gemファイルなどの#stable? = trueなファイルは変更がないはずなのでentries, dirsを半永続的にキャッシュし、そうでなければmtimeが異なっていればキャッシュを使わずに再度ディレクトリを操作してentries, dirsを取得しています。

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