Ruby Splat operator in method definition takes more memory

Viewed 150

While working on optimization on our codebase we tried to use bang methods to reduce object allocations where it made sense but we observed in benchmarks that the number of objects allocated decreased but overall memsize increased.

Reproduction Script:

# frozen_string_literal: true

require 'bundler/inline'

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

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem 'benchmark-memory', '0.1.2'
end

require 'benchmark/memory'

def with_bang(*methods)
  methods.tap(&:flatten!)
end

def without_bang(*methods)
  methods.flatten
end


Benchmark.memory do |x|
  x.report("with_bang") { with_bang(:a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o) }
  x.report("without_bang") { without_bang(:a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o) }
  x.compare!
end


# Output
# Ruby version: ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-darwin19]

# INPUT: (:a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o)
# Calculating -------------------------------------
#            with_bang   160.000  memsize (     0.000  retained)
#                          1.000  objects (     0.000  retained)
#                          0.000  strings (     0.000  retained)
#         without_bang    80.000  memsize (     0.000  retained)
#                          2.000  objects (     0.000  retained)
#                          0.000  strings (     0.000  retained)

# Comparison:
#         without_bang:         80 allocated
#            with_bang:        160 allocated - 2.00x more


# INPUT: (:a, :b, :c, :d, :e, [:f, :g], :h, :i, :j, :k, :l, :m, :n, :o)
# Calculating -------------------------------------
#            with_bang   240.000  memsize (     0.000  retained)
#                          3.000  objects (     0.000  retained)
#                          0.000  strings (     0.000  retained)
#         without_bang   480.000  memsize (     0.000  retained)
#                          3.000  objects (     0.000  retained)
#                          0.000  strings (     0.000  retained)

# Comparison:
#            with_bang:        240 allocated
#         without_bang:        480 allocated - 2.00x more

In my experimentation, I believe it is due to the splat operator conversion to an array. Following is the script which is hinting me to this conclusion.

# frozen_string_literal: true

require 'bundler/inline'

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

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem 'benchmark-memory', '0.1.2'
end

require 'benchmark/memory'

def with_splat(*methods)
  methods.flatten!
end

def without_splat
  methods = [:a, :b, :c, :d, :e, [:f, :g], :h, :i, :j, :k, :l, :m, :n, :o]
  methods.flatten!
end


Benchmark.memory do |x|
  x.report("with_splat") { with_splat(:a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o) }
  x.report("without_splat") { without_splat }
  x.compare!
end

# Output
# Ruby version: ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-darwin19]

# INPUT: (:a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o)
# Calculating -------------------------------------
#           with_splat   160.000  memsize (     0.000  retained)
#                          1.000  objects (     0.000  retained)
#                          0.000  strings (     0.000  retained)
#        without_splat    40.000  memsize (     0.000  retained)
#                          1.000  objects (     0.000  retained)
#                          0.000  strings (     0.000  retained)

# Comparison:
#        without_splat:         40 allocated
#           with_splat:        160 allocated - 4.00x more


# INPUT: (:a, :b, :c, :d, :e, [:f, :g], :h, :i, :j, :k, :l, :m, :n, :o)
# Calculating -------------------------------------
#           with_splat   240.000  memsize (     0.000  retained)
#                          3.000  objects (     0.000  retained)
#                          0.000  strings (     0.000  retained)
#        without_splat   240.000  memsize (     0.000  retained)
#                          3.000  objects (     0.000  retained)
#                          0.000  strings (     0.000  retained)

# Comparison:
#           with_splat:        240 allocated
#        without_splat:        240 allocated - same

What am I missing to understand this behavior? And why is it behaving in this manner?

Thank you!

EDIT: I have added new input to the benchmark comparisons which contain a nested array. With new input, we are seeing a different result than the previous benchmark and I am more confused!

1 Answers

Let's examine the two arrays more closely:

require 'objspace'

def with_splat(*methods)
  ObjectSpace.dump(methods, output: open('with_splat.json', 'w'))
end

def without_splat(methods)
  ObjectSpace.dump(methods, output: open('without_splat.json', 'w'))
end

with_splat(:a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o)
without_splat([:a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o])

ObjectSpace.dump_all(output: open('all_objects.json', 'w'))

The script generates 3 files:

  • with_splat.json contains data about the splatted array
  • without_splat.json contain data about the non splatted array
  • all_objects.json contains data about all objects (that's a lot!)

with_splat.json: (formatted)

{
  "address": "0x7feb941289a0",
  "type": "ARRAY",
  "class": "0x7feb940972c0",
  "length": 15,
  "memsize": 160,
  "flags": {
    "wb_protected": true
  }
}

without_splat.json: (formatted)

{
  "address": "0x7feb941287e8",
  "type": "ARRAY",
  "class": "0x7feb940972c0",
  "length": 15,
  "shared": true,
  "references": [
    "0x7feb941328d8"
  ],
  "memsize": 40,
  "flags": {
    "wb_protected": true
  }
}

As you can see, the latter array does consume less memory (40 vs 160), but it also has "shared": true set and it references another object at memory address 0x7feb941328d8.

Let's find that object in all_objects.json via jq:

$ jq 'select(.address == "0x7feb941328d8")' all_objects.json
{
  "address": "0x7feb941328d8",
  "type": "ARRAY",
  "frozen": true,
  "length": 15,
  "memsize": 160,
  "flags": {
    "wb_protected": true
  }
}

And that's the actual array with the very same memsize as the first array above.

Note that this array has "frozen": true set. I assume that Ruby creates these frozen arrays when it encounters an array literal. It can then create cheap(er) shared arrays upon evaluation.

Related