Skip to content

Commit

Permalink
Speedup ActiveModel::Type::Integer#serialize by up to 10x
Browse files Browse the repository at this point in the history
- Add a fast path for Integer.
- Avoid using a regexp to check for numeric strings.
- Directly compare with min/max rather than use a Range.
- Inline some methods.

```
== str ==
ruby 3.3.4 (2024-07-09 revision be1089c8ec) +YJIT [arm64-darwin23]
Warming up --------------------------------------
       int.serialize   267.196k i/100ms
   opt_int.serialize     2.420M i/100ms
Calculating -------------------------------------
       int.serialize      2.815M (± 1.5%) i/s  (355.27 ns/i) -     14.161M in   5.032196s
   opt_int.serialize     28.564M (± 1.1%) i/s   (35.01 ns/i) -    145.221M in   5.084744s

Comparison:
       int.serialize:  2814789.7 i/s
   opt_int.serialize: 28563531.6 i/s - 10.15x  faster

== int ==
ruby 3.3.4 (2024-07-09 revision be1089c8ec) +YJIT [arm64-darwin23]
Warming up --------------------------------------
       int.serialize     1.084M i/100ms
   opt_int.serialize     3.926M i/100ms
Calculating -------------------------------------
       int.serialize     15.580M (± 1.3%) i/s   (64.18 ns/i) -     78.061M in   5.011141s
   opt_int.serialize     53.040M (± 1.5%) i/s   (18.85 ns/i) -    266.991M in   5.034991s

Comparison:
       int.serialize: 15580113.2 i/s
   opt_int.serialize: 53039540.2 i/s - 3.40x  faster
```

```ruby

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  gem "rails", "8.0.0.rc1"
  gem "sqlite3"
  gem "benchmark-ips"
end

require "active_record"
require "benchmark/ips"
require "logger"

module ActiveModel
  module Type
    class OptInteger < Value
      include Helpers::Numeric

      # Column storage size in bytes.
      # 4 bytes means an integer as opposed to smallint etc.
      DEFAULT_LIMIT = 4

      def initialize(**)
        super
        @limit ||= DEFAULT_LIMIT
        @max = 1 << (@limit * 8 - 1) # 8 bits per byte with one bit for sign
        @min = -@max
      end

      def type
        :integer
      end

      def deserialize(value)
        return if value.blank?
        value.to_i
      end

      def serialize(value)
        case value
        when ::Integer
          # noop
        when ::String
          int = value.to_i
          if int.zero? && value != "0"
            return if non_numeric_string?(value)
          end
          value = int
        else
          value = super
        end

        if value && (@max < value || @min > value)
          raise ActiveModel::RangeError, "#{value} is out of range for #{self.class} with limit #{limit} bytes"
        end

        value
      end

      def serialize_cast_value(value) # :nodoc:
        if value && (@max < value || @min > value)
          raise ActiveModel::RangeError, "#{value} is out of range for #{self.class} with limit #{limit} bytes"
        end

        value
      end

      def serializable?(value)
        cast_value = cast(value)
        return true unless cast_value
        return true unless (@max < value || @min > value)
        yield cast_value if block_given?
        false
      end

      private
        def cast_value(value)
          value.to_i rescue nil
        end
    end
  end
end

int = ActiveModel::Type::Integer.new
opt_int = ActiveModel::Type::OptInteger.new

puts "== str =="

Benchmark.ips do |x|
  x.report("int.serialize") { int.serialize("42") }
  x.report("opt_int.serialize") { opt_int.serialize("42") }
  x.compare!(order: :baseline)
end

puts "== int =="

Benchmark.ips do |x|
  x.report("int.serialize") { int.serialize(42) }
  x.report("opt_int.serialize") { opt_int.serialize(42) }
  x.compare!(order: :baseline)
end
```
  • Loading branch information
byroot committed Oct 28, 2024
1 parent e642926 commit 203b0e2
Show file tree
Hide file tree
Showing 3 changed files with 53 additions and 36 deletions.
24 changes: 20 additions & 4 deletions activemodel/lib/active_model/type/big_integer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,30 @@ module Type
# All casting and serialization are performed in the same way as the
# standard ActiveModel::Type::Integer type.
class BigInteger < Integer
def serialize(value) # :nodoc:
case value
when ::Integer
# noop
when ::String
int = value.to_i
if int.zero? && value != "0"
return if non_numeric_string?(value)
end
value = int
else
value = super
end

value
end

def serialize_cast_value(value) # :nodoc:
value
end

private
def max_value
::Float::INFINITY
end
def serializable?(value, &_)
true
end
end
end
end
63 changes: 32 additions & 31 deletions activemodel/lib/active_model/type/integer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ class Integer < Value

def initialize(**)
super
@range = min_value...max_value
@limit ||= default_limit
@max = 1 << (@limit * 8 - 1) # 8 bits per byte with one bit for sign
@min = -@max
end

def type
Expand All @@ -63,51 +65,50 @@ def deserialize(value)
end

def serialize(value)
return if value.is_a?(::String) && non_numeric_string?(value)
ensure_in_range(super)
case value
when ::Integer
# noop
when ::String
int = value.to_i
if int.zero? && value != "0"
return if non_numeric_string?(value)
end
value = int
else
value = super
end

if value && (@max <= value || @min > value)
raise ActiveModel::RangeError, "#{value} is out of range for #{self.class} with limit #{limit} bytes"
end

value
end

def serialize_cast_value(value) # :nodoc:
ensure_in_range(value)
if value && (@max <= value || @min > value)
raise ActiveModel::RangeError, "#{value} is out of range for #{self.class} with limit #{limit} bytes"
end

value
end

def serializable?(value)
cast_value = cast(value)
in_range?(cast_value) || begin
yield cast_value if block_given?
false
end
return true unless cast_value
return true unless (@max <= cast_value || @min > cast_value)
yield cast_value if block_given?
false
end

private
attr_reader :range

def in_range?(value)
!value || range.member?(value)
def default_limit
DEFAULT_LIMIT # allow redefinition
end

def cast_value(value)
value.to_i rescue nil
end

def ensure_in_range(value)
unless in_range?(value)
raise ActiveModel::RangeError, "#{value} is out of range for #{self.class} with limit #{_limit} bytes"
end
value
end

def max_value
1 << (_limit * 8 - 1) # 8 bits per byte with one bit for sign
end

def min_value
-max_value
end

def _limit
limit || DEFAULT_LIMIT
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ def check_version # :nodoc:

class SQLite3Integer < Type::Integer # :nodoc:
private
def _limit
def default_limit
# INTEGER storage class can be stored 8 bytes value.
# See https://www.sqlite.org/datatype3.html#storage_classes_and_datatypes
limit || 8
Expand Down

0 comments on commit 203b0e2

Please sign in to comment.