DEV Community

Dmitriy Nesteryuk
Dmitriy Nesteryuk

Posted on

Grape: The cost of ActiveSupport::HashWithIndifferentAccess

By default Grape uses ActiveSupport::HashWithIndifferentAccess as a parameter builder. The most important thing about ActiveSupport::HashWithIndifferentAccess is that symbols are mapped to strings when used as keys.

So, if your routers refer to parameters by using symbols, keys get converted to strings. Let's see how it affects memory.

require 'ruby-prof'
require 'grape'

class API < Grape::API
  prefix :api
  version 'v1', using: :path

  params do
    requires :address, type: Hash do
      requires :street, type: String
      requires :postal_code, type: Integer
      optional :city, type: String
    end
  end

  post '/' do
    declared = declared(params)
    declared[:address][:street]
  end
end

options = {
  method: 'POST',
  params: {
    address: {
      street: 'Test Street',
      postal_code: '90698',
      city: 'Imagine'
    }
  }
}

env = Rack::MockRequest.env_for('/api/v1', options)

# warm up
API.call env

RubyProf.measure_mode = RubyProf::MEMORY

result = RubyProf.profile do
  API.call env
end

printer = RubyProf::FlatPrinter.new(result)
printer.print(STDOUT, min_percent: 1)

For profiling Grape 1.2.5 and Ruby 2.6.3 were used. Results:

Measure Mode: memory
Thread ID: 47373520927160
Fiber ID: 47373526155200
Total: 41112.000000
Sort by: self_time

 %self      total      self      wait     child     calls  name
 13.66   9304.000  5616.000     0.000  3688.000       18  *Hash#each_pair
 10.70  12648.000  4400.000     0.000  8248.000       29  *Class#new
  8.68   5088.000  3568.000     0.000  1520.000        8  *Array#map
  5.64   2320.000  2320.000     0.000     0.000       58   Symbol#to_s
  5.08   2088.000  2088.000     0.000     0.000        9   Hash#merge

According to the Ruby-prof doc, the total represents a number of bytes. Let's try another builder which only supports symbols.

Grape.configure do |config|
  config.param_builder = Grape::Extensions::Hash::ParamBuilder
end

Results:

Measure Mode: memory
Thread ID: 47077831236000
Fiber ID: 47077836608980
Total: 33328.000000
Sort by: self_time

 %self      total      self      wait     child     calls  name
 11.28   5736.000  3760.000     0.000  1976.000        7   Hash#each_pair
 10.71   5088.000  3568.000     0.000  1520.000        8  *Array#map
  6.94   9664.000  2312.000     0.000  7352.000       25  *Class#new
  6.27   2088.000  2088.000     0.000     0.000        9   Hash#merge
  4.08   1440.000  1360.000     0.000    80.000        5   Grape::Endpoint#run_filters

Switching to the simpler builder saves 7,60 Kbytes per request. Yeah, it isn't crazy number, but the change is one LOC. So, if you can agree with your team on using symbols everywhere, you can save a chunk of memory. By the way, the parameter builder can be configured per route, thus, the migration might be done per route.

However, if strings are used as keys for parameters:

  params do
    requires 'address', type: Hash do
      requires 'street', type: String
      requires 'postal_code', type: Integer
      optional 'city', type: String
    end
  end

  post '/' do
    declared = declared(params)
    declared['address']['street']
  end

the picture is a bit better for Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder.

Measure Mode: memory
Thread ID: 47413799950760
Fiber ID: 47413805177840
Total: 39832.000000
Sort by: self_time

 %self      total      self      wait     child     calls  name
 14.10   9064.000  5616.000     0.000  3448.000       18  *Hash#each_pair
 11.05  12448.000  4400.000     0.000  8048.000       29  *Class#new
  8.96   5088.000  3568.000     0.000  1520.000        8  *Array#map
  5.24   2088.000  2088.000     0.000     0.000        9   Hash#merge
  4.66   2016.000  1856.000     0.000   160.000        8   ActiveSupport::HashWithIndifferentAccess#[]=

Anyway Grape::Extensions::Hash::ParamBuilder is a winner.

Upcoming Grape 1.3.0

It has an improvement in Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder, so it will be slightly better.

Measure Mode: memory
Thread ID: 46918412761520
Fiber ID: 46918421185000
Total: 36448.000000
Sort by: self_time

 %self      total      self      wait     child     calls  name
 12.86   8336.000  4688.000     0.000  3648.000       11  *Hash#each_pair
  9.79   5248.000  3568.000     0.000  1680.000        8  *Array#map
  8.89  11880.000  3240.000     0.000  8640.000       27  *Class#new
  5.93   2160.000  2160.000     0.000     0.000       54   Symbol#to_s
  5.73   2088.000  2088.000     0.000     0.000        9   Hash#merge

Top comments (1)

Collapse
 
dylanjha profile image
Dylan Jhaveri

The bit of memory overhead is good to know about, and certainly not good, but it would be interesting to see how the memory overhead performs in the real world.

If the extra bytes get garbage collected efficiently after the request is over then the overhead per request might not make a difference when your application is running in production.

On the other hand, Rails memory bloat happens pretty easily so it's also possible that this extra memory per request does cause problems. It would be interesting to see.