Skip to content
  • Donal McBreen's avatar
    0279c033
    Fewer object allocations in Encryption::Properties · 0279c033
    Donal McBreen authored
    `delegate_missing_to` and `Enumerable#find` both allocate objects. When
    selecting a large number of encrypted values with can lead to a
    significant number of allocations.
    
    ```
    
    require "bundler/inline"
    
    gemfile(true) do
      source "https://rubygems.org"
    
      git_source(:github) { |repo| "https://github.com/#{repo}.git" }
    
      # Activate the gem you are reporting the issue against.
      gem "activerecord", "~> 7.0.0"
      gem "sqlite3"
      gem "benchmark-ips"
    end
    
    require "active_record"
    require 'benchmark/ips'
    
    ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
    
    ActiveRecord::Encryption.configure \
      primary_key: "test master key",
      deterministic_key: "test deterministic key",
      key_derivation_salt: "testing key derivation salt",
      support_unencrypted_data: true
    
    ActiveRecord::Schema.define do
      create_table :comments, force: true do |t|
        t.string :message, length: 1000
      end
    end
    
    class Comment < ActiveRecord::Base
      encrypts :message
    end
    
    srand(123456)
    1000.times { Comment.create!(message: 100.times.map { ("A".."Z").to_a.sample }.join) }
    
    if ENV['OPTIMIZED']
      module ActiveRecord
        module EncryptionPropertiesAvoidAllocations
          extend ActiveSupport::Concern
    
          ALLOWED_VALUE_CLASSES = [String, ActiveRecord::Encryption::Message, Numeric, TrueClass, FalseClass, Symbol, NilClass, Float, Integer]
    
          if %w{delegation all}.include?(ENV['OPTIMIZED'])
            delegate :each, :[], :key?, to: :data
          end
    
          if %w{find all}.include?(ENV['OPTIMIZED'])
            # find also involves allocations, so we can cache the results which classes
            # are valid to avoid the call
            def validate_value_type(value)
              unless ALLOWED_VALUE_CLASSES.include?(value.class) || ALLOWED_VALUE_CLASSES.any? { |klass| value.is_a?(klass) }
                raise ActiveRecord::Encryption::Errors::ForbiddenClass, "Can't store a #{value.class}, only properties of type #{ALLOWED_VALUE_CLASSES.inspect} are allowed"
              end
            end
          end
        end
      end
    
      ActiveRecord::Encryption::Properties.prepend(ActiveRecord::EncryptionPropertiesAvoidAllocations);
    end
    
    def allocation_count
      before = GC.stat(:total_allocated_objects)
      yield
      GC.stat(:total_allocated_objects) - before
    end
    
    allocated_objects = allocation_count { Comment.pluck(:message) }
    puts "Optimization: #{ENV['OPTIMIZED'] || "none"}, allocations: #{allocated_objects}"
    
    Benchmark.ips do |x|
      x.config(time: 30)
      x.report("default")                 { Comment.pluck(:message) }
      x.report("delegation_optimization") { Comment.pluck(:message) }
      x.report("find_optimization")       { Comment.pluck(:message) }
      x.report("all_optimization")        { Comment.pluck(:message) }
      x.hold! "temp_results"
      x.compare!
    end
    ```
    
    Results:
    ```
    $ ruby encryption_properties_benchmark.rb; OPTIMIZED=delegation ruby encryption_properties_benchmark.rb; OPTIMIZED=find ruby encryption_properties_benchmark.rb; OPTIMIZED=all ruby encryption_properties_benchmark.rb
    ...snip...
    Optimization: none, allocations: 72238
    Warming up --------------------------------------
                 default     6.000  i/100ms
    Calculating -------------------------------------
                 default     70.643  (± 4.2%) i/s -      2.118k in  30.052046s
    
    ...snip...
    Optimization: delegation, allocations: 62313
    Warming up --------------------------------------
    delegation_optimization
                             7.000  i/100ms
    Calculating -------------------------------------
    delegation_optimization
                             75.785  (± 4.0%) i/s -      2.275k in  30.086061s
    
    Pausing here -- run Ruby again to measure the next benchmark...
    
    Comparison:
    delegation_optimization:       75.8 i/s
                 default:       70.6 i/s - same-ish: difference falls within error
    
    ...snip...
    Optimization: find, allocations: 60306
    Warming up --------------------------------------
       find_optimization     7.000  i/100ms
    Calculating -------------------------------------
       find_optimization     74.179  (± 4.0%) i/s -      2.226k in  30.082991s
    
    Pausing here -- run Ruby again to measure the next benchmark...
    
    Comparison:
    delegation_optimization:       75.8 i/s
       find_optimization:       74.2 i/s - same-ish: difference falls within error
                 default:       70.6 i/s - same-ish: difference falls within error
    
    ...snip...
    Optimization: all, allocations: 50315
    Warming up --------------------------------------
        all_optimization     8.000  i/100ms
    Calculating -------------------------------------
        all_optimization     84.689  (± 5.9%) i/s -      2.528k in  30.008722s
    
    Comparison:
        all_optimization:       84.7 i/s
    delegation_optimization:       75.8 i/s - 1.12x  (± 0.00) slower
       find_optimization:       74.2 i/s - 1.14x  (± 0.00) slower
                 default:       70.6 i/s - 1.20x  (± 0.00) slower
    
    ```
    
    Overall it's about 15% faster with a 30% reduction in allocations.
    0279c033
    Fewer object allocations in Encryption::Properties
    Donal McBreen authored
    `delegate_missing_to` and `Enumerable#find` both allocate objects. When
    selecting a large number of encrypted values with can lead to a
    significant number of allocations.
    
    ```
    
    require "bundler/inline"
    
    gemfile(true) do
      source "https://rubygems.org"
    
      git_source(:github) { |repo| "https://github.com/#{repo}.git" }
    
      # Activate the gem you are reporting the issue against.
      gem "activerecord", "~> 7.0.0"
      gem "sqlite3"
      gem "benchmark-ips"
    end
    
    require "active_record"
    require 'benchmark/ips'
    
    ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
    
    ActiveRecord::Encryption.configure \
      primary_key: "test master key",
      deterministic_key: "test deterministic key",
      key_derivation_salt: "testing key derivation salt",
      support_unencrypted_data: true
    
    ActiveRecord::Schema.define do
      create_table :comments, force: true do |t|
        t.string :message, length: 1000
      end
    end
    
    class Comment < ActiveRecord::Base
      encrypts :message
    end
    
    srand(123456)
    1000.times { Comment.create!(message: 100.times.map { ("A".."Z").to_a.sample }.join) }
    
    if ENV['OPTIMIZED']
      module ActiveRecord
        module EncryptionPropertiesAvoidAllocations
          extend ActiveSupport::Concern
    
          ALLOWED_VALUE_CLASSES = [String, ActiveRecord::Encryption::Message, Numeric, TrueClass, FalseClass, Symbol, NilClass, Float, Integer]
    
          if %w{delegation all}.include?(ENV['OPTIMIZED'])
            delegate :each, :[], :key?, to: :data
          end
    
          if %w{find all}.include?(ENV['OPTIMIZED'])
            # find also involves allocations, so we can cache the results which classes
            # are valid to avoid the call
            def validate_value_type(value)
              unless ALLOWED_VALUE_CLASSES.include?(value.class) || ALLOWED_VALUE_CLASSES.any? { |klass| value.is_a?(klass) }
                raise ActiveRecord::Encryption::Errors::ForbiddenClass, "Can't store a #{value.class}, only properties of type #{ALLOWED_VALUE_CLASSES.inspect} are allowed"
              end
            end
          end
        end
      end
    
      ActiveRecord::Encryption::Properties.prepend(ActiveRecord::EncryptionPropertiesAvoidAllocations);
    end
    
    def allocation_count
      before = GC.stat(:total_allocated_objects)
      yield
      GC.stat(:total_allocated_objects) - before
    end
    
    allocated_objects = allocation_count { Comment.pluck(:message) }
    puts "Optimization: #{ENV['OPTIMIZED'] || "none"}, allocations: #{allocated_objects}"
    
    Benchmark.ips do |x|
      x.config(time: 30)
      x.report("default")                 { Comment.pluck(:message) }
      x.report("delegation_optimization") { Comment.pluck(:message) }
      x.report("find_optimization")       { Comment.pluck(:message) }
      x.report("all_optimization")        { Comment.pluck(:message) }
      x.hold! "temp_results"
      x.compare!
    end
    ```
    
    Results:
    ```
    $ ruby encryption_properties_benchmark.rb; OPTIMIZED=delegation ruby encryption_properties_benchmark.rb; OPTIMIZED=find ruby encryption_properties_benchmark.rb; OPTIMIZED=all ruby encryption_properties_benchmark.rb
    ...snip...
    Optimization: none, allocations: 72238
    Warming up --------------------------------------
                 default     6.000  i/100ms
    Calculating -------------------------------------
                 default     70.643  (± 4.2%) i/s -      2.118k in  30.052046s
    
    ...snip...
    Optimization: delegation, allocations: 62313
    Warming up --------------------------------------
    delegation_optimization
                             7.000  i/100ms
    Calculating -------------------------------------
    delegation_optimization
                             75.785  (± 4.0%) i/s -      2.275k in  30.086061s
    
    Pausing here -- run Ruby again to measure the next benchmark...
    
    Comparison:
    delegation_optimization:       75.8 i/s
                 default:       70.6 i/s - same-ish: difference falls within error
    
    ...snip...
    Optimization: find, allocations: 60306
    Warming up --------------------------------------
       find_optimization     7.000  i/100ms
    Calculating -------------------------------------
       find_optimization     74.179  (± 4.0%) i/s -      2.226k in  30.082991s
    
    Pausing here -- run Ruby again to measure the next benchmark...
    
    Comparison:
    delegation_optimization:       75.8 i/s
       find_optimization:       74.2 i/s - same-ish: difference falls within error
                 default:       70.6 i/s - same-ish: difference falls within error
    
    ...snip...
    Optimization: all, allocations: 50315
    Warming up --------------------------------------
        all_optimization     8.000  i/100ms
    Calculating -------------------------------------
        all_optimization     84.689  (± 5.9%) i/s -      2.528k in  30.008722s
    
    Comparison:
        all_optimization:       84.7 i/s
    delegation_optimization:       75.8 i/s - 1.12x  (± 0.00) slower
       find_optimization:       74.2 i/s - 1.14x  (± 0.00) slower
                 default:       70.6 i/s - 1.20x  (± 0.00) slower
    
    ```
    
    Overall it's about 15% faster with a 30% reduction in allocations.
Loading