Khởi động ứng dụng Ruby/Rails lớn nhanh hơn với bootsnap

Bootsnap là một thư viện có thể cắm vào Ruby, với sự hỗ trợ tùy chọn cho ActiveSupport và YAML, nhằm tối ưu hóa và tính toán các tính toán đắt tiền

Hiệu năng

  • Thời gian khởi động giảm khoảng 50%, từ khoảng 3s đến 6s trên một máy
  • Ví dụ đối với nền tảng Shopify- Khởi động nhanh hơn khoảng 75% giảm từ khoảng 25s xuống còn 6,5s

Cài đặt Bootsnap

Gem này hoạt động trên MacOSLinux

  1. Thêm bootsnap vào file Gemfile
    gem "bootsnap", require: false
    
  • Nếu bạn sử dụng rails, thêm vào file config/boot.rb dòng sau:
    require "bootsnap/setup
    
  • Nếu không sử dụng rails hoặc bạn muốn kiểm soát tốt hơn về mọi thứ. Hãy thêm vào thiết lập ứng dụng của bạn như sau:
    require 'bootsnap'
    env = ENV['RAILS_ENV'] || "development"
    Bootsnap.setup(
      cache_dir:            'tmp/cache',          # Path to your cache
      development_mode:     env == 'development', # Current working environment, e.g. RACK_ENV, RAILS_ENV, etc
      load_path_cache:      true,                 # Optimize the LOAD_PATH with a cache
      autoload_paths_cache: true,                 # Optimize ActiveSupport autoloads with cache
      disable_trace:        true,                 # (Alpha) Set `RubyVM::InstructionSequence.compile_option = { trace_instruction: false }`
      compile_cache_iseq:   true,                 # Compile Ruby code into ISeq cache, breaks coverage reporting.
      compile_cache_yaml:   true                  # Compile YAML into a cache
    )
    

Cách thức hoạt động của Bootsnap

Bootsnap tối ưu hóa các phương pháp để lưu trữ kết quả của tính toán đắt tiền, và có thể được nhóm lại thành hai loại sau:

  1. Path Pre-Scanning : * Kernel#requireKernel#load được chỉnh sửa để loại bỏ quét $LOAD_PATH * ActiveSupport::Dependencies.{autoloadable_module?,load_missing_constant,depend_on} được ghi đè để loại bỏ việc quét ActiveSupport::Dependencies.autoload_paths
  2. Compilation Caching: * RubyVM::InstructionSequence.load_iseq được thực hiện để cache kết quả của việc biên dịch ruby bytecode * YAML.load_file được chỉnh sửa để cache kết quả tải một đối tượng YAML trong định dạng MessagePack (hoặc Marshal nếu thông báo dử dụng kiểu không được hỗ trợ bởi MessagePack)

Path Pre-Scanning

(Đây là một sự phát triển nhở của bootscale) Khi bootsnap khởi động hoặc sửa đổi đường dẫn, Bootsnap::LoadPathCache sẽ tìm nạp danh sách các mục yêu cầu từ bộ nhớ cache hoặc thực hiện quét toàn bộ và kết quả bộ nhớ cache nếu thấy cần

Sau đó, khi chạy (eg: require 'foo', Ruby sẽ lặp lại mọi mục trên $LOAD_PATH ['x', 'y', ...], tìm kiếm x/foo.rb, y/foo.rb... Bootsnap nhìn vào tất các cached của mỗi LOAD_PATH và thay thế đường dẫn mở rộng đầy đủ của ruby vào. Sơ đồ sau sẽ trình bày cách ghi đè cho các tính năng *_path_cache

Bootsnap phân loại đường dẫn thành 2 loại :stablevolatile. * volatile được quét mỗi lần khởi động ứng dụng và bộ đệm của chúng chỉ có giá trị trong vòng 30s * stable: không hết hạn, Một khi nội dung của chúng được quét, nó sẽ không bao giờ thay đổi

Các thư mục duy nhất được coi là stable là những thứ trong thư mục ruby cài đặt tiền tố RbConfig::CONFIG['prefix'], eg: /usr/local/ruby or ~/.rubies/x.y.z, và những thứ dưới Gem.path (eg: ~/.gem/ruby/x.y.z) hoặc Bundler.bundle_path. Những thứ còn lại được coi là volatitle

Dưới đây là mã ngồn để làm rõ cách thức hoạt động:

require_relative '../explicit_require'

module Bootsnap
  module LoadPathCache
    class Cache
      AGE_THRESHOLD = 30 # seconds

      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

      # Does this directory exist as a child of one of the path items?
      # e.g. given "/a/b/c/d" exists, and the path is ["/a/b"], has_dir?("c/d")
      # is true.
      def has_dir?(dir)
        reinitialize if stale?
        @mutex.synchronize { @dirs[dir] }
      end

      # { 'enumerator' => nil, 'enumerator.so' => nil, ... }
      BUILTIN_FEATURES = $LOADED_FEATURES.reduce({}) do |acc, feat|
        # Builtin features are of the form 'enumerator.so'.
        # All others include paths.
        next acc unless feat.size < 20 && !feat.include?('/')

        base = File.basename(feat, '.*') # enumerator.so -> enumerator
        ext  = File.extname(feat) # .so

        acc[feat] = nil # enumerator.so
        acc[base] = nil # enumerator

        if [DOT_SO, *DL_EXTENSIONS].include?(ext)
          DL_EXTENSIONS.each do |dl_ext|
            acc["#{base}#{dl_ext}"] = nil # enumerator.bundle
          end
        end

        acc
      end.freeze

      # Try to resolve this feature to an absolute path without traversing the
      # loadpath.
      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)

          # The feature wasn't found on our preliminary search through the index.
          # We resolve this differently depending on what the extension was.
          case File.extname(feature)
          # If the extension was one of the ones we explicitly cache (.rb and the
          # native dynamic extension, e.g. .bundle or .so), we know it was a
          # failure and there's nothing more we can do to find the file.
          when '', *CACHED_EXTENSIONS # no extension, .rb, (.bundle or .so)
            nil
          # Ruby allows specifying native extensions as '.so' even when DLEXT
          # is '.bundle'. This is where we handle that case.
          when DOT_SO
            x = search_index(feature[0..-4] + DLEXT)
            return x if x
            if DLEXT2
              search_index(feature[0..-4] + DLEXT2)
            end
          else
            # other, unknown extension. For example, `.rake`. Since we haven't
            # cached these, we legitimately need to run the load path search.
            raise LoadPathCache::FallbackScan
          end
        end
      end

      if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
        def absolute_path?(path)
          path[1] == ':'
        end
      else
        def absolute_path?(path)
          path.start_with?(SLASH)
        end
      end

      def unshift_paths(sender, *paths)
        return unless sender == @path_obj
        @mutex.synchronize { unshift_paths_locked(*paths) }
      end

      def push_paths(sender, *paths)
        return unless sender == @path_obj
        @mutex.synchronize { push_paths_locked(*paths) }
      end

      def each_requirable
        @mutex.synchronize do
          @index.each do |rel, entry|
            yield "#{entry}/#{rel}"
          end
        end
      end

      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

      private

      def dir_changed?
        @prev_dir ||= Dir.pwd
        if @prev_dir == Dir.pwd
          false
        else
          @prev_dir = Dir.pwd
          true
        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

      def unshift_paths_locked(*paths)
        @store.transaction do
          paths.map(&:to_s).reverse_each do |path|
            p = Path.new(path)
            next if p.non_directory?
            entries, dirs = p.entries_and_dirs(@store)
            # unshift -> high precedence -> unconditional set
            dirs.each    { |dir| @dirs[dir]  = true }
            entries.each { |rel| @index[rel] = p.expanded_path }
          end
        end
      end

      def stale?
        @development_mode && @generated_at + AGE_THRESHOLD < now
      end

      def now
        Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i
      end

      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
    end
  end
end

Hoặc bạn có thể xem sơ đồ dứoi để hiểu về cách hoạt động:

Cần lưu ý là LoadErrors có thể rất tốn kém tài nguyên. Nếu require 'something', nhưng file đó lại không nằm trên $LOAD_PATH nó sẽ mất 2*$LOAD_PATH.lenght hệ thống tập tin truy cập để xác định nó. Bootsnap sẽ cache kết quả này, đưa ra LoadError mà không cần chạm vào hệ thống tập tin ở tất cả.

Complilation Caching

(Tài liệu dễ hiểu hơn của khái niệm này có thể được tìm thấy ở yomikomu) Lưu ý: Chúng ta sẽ tống rất nhiều thời gian để tải các tài liệu YAML trongquá trình khởi động ứng dụng và những mesage như MessagePackMarshal nhanh hơn nhiều so với YAML. Chúng tôi sử dụng cùng một cách biên dịch bộ nhớ cached cho tài liệu YAML, Tương đương với định dạng bytecode của Ruby là một tài liệu của MessagePack (hoặc Marshal với các tài liệu không hỗ trợ bởi MessagePack).

Các kết quả biên dịch này được lưu trữ trong một thư mục bộ nhớ cache, với tên tập tin được tạo ra bằng cách lấy một băm của đường dẫn mở rộng đầy đủ của tập tin đầu vào (FNV1a-64).

Trong khi trước đây, trình tự các syscalls tạo ra để yêu cầu một tập tin sẽ như sau:

open    /c/foo.rb -> m
fstat64 m
close   m
open    /c/foo.rb -> o
fstat64 o
fstat64 o
read    o
read    o
...
close   o

Thì với Bootsnap sẽ nhận được:

open      /c/foo.rb -> n
fstat64   n
close     n
open      /c/foo.rb -> n
fstat64   n
open      (cache) -> m
read      m
read      m
close     m
close     n

Bootsnap viết một tệp tin cache có chứa một tiêu đề 64 byte theo sau là nội dung của bộ nhớ cache. Tiêu đề là một khóa cache bao gồm một số trường:

  • version: Phiên bản schema
  • os_version: Một hash của phiên bản kernel hiện tại(MacOS, BSD) hoặc phiên bản Glibc (Linux)
  • Compile_option: Những thay đổi với RubyVM::InstructionSequence.compile_option
  • Ruby_version: Phiên bản của ruby.
  • Size: Kích thước source
  • mtime: Timestamp lần sửa đổi cuối cùng
  • data_size: số byte theo tiêu đề, mà chúng ta cần phải đọc nó vào bộ đệm.

Nếu khóa là hợp lệ, kết quả được nạp từ giá trị. Ngược lại, nó được tái tạo và hủy bộ nhớ cache hiện tại

Thử nghiệm

Hãy tưởng tượng ta có cấu trúc sau:

/
├── a
├── b
└── c
    └── foo.rb

Và có $LOAD_PATH:

["/a", "/b", "/c"]

Khi chúng ta gọi require 'foo' mà không dùng Bootsnap. Ruby sẽ sinh ra chuỗi syscalls này:

open    /a/foo.rb -> -1
open    /b/foo.rb -> -1
open    /c/foo.rb -> n
close   n
open    /c/foo.rb -> m
fstat64 m
close   m
open    /c/foo.rb -> o
fstat64 o
fstat64 o
read    o
read    o
...
close   o

Nhưng với bootsnap thì sẽ như sau:

open      /c/foo.rb -> n
fstat64   n
close     n
open      /c/foo.rb -> n
fstat64   n
open      (cache) -> m
read      m
read      m
close     m
close     n

Nếu chúng ta gọi require 'nope' mà không dùng bootsnap:

open    /a/nope.rb -> -1
open    /b/nope.rb -> -1
open    /c/nope.rb -> -1
open    /a/nope.bundle -> -1
open    /b/nope.bundle -> -1
open    /c/nope.bundle -> -1

Còn nếu chạy với Bootsnap thì sẽ không sinh ra bất cứ gì.

Tài Liệu tham khảo: bootsnap


All Rights Reserved