Shrinking Ruby Hashes

jandeboevrie1 pts0 comments

Shrinking Ruby Hashes | byroot’s blog

As you may know, one area of Ruby performance optimization that particularly interests me is memory usage.<br>Given that most Ruby deployments rely on fork, improving Copy-on-Write performance is generally where you get the<br>biggest bang for your buck, but that only helps with the somewhat static part of an application heap.

A significant contributor to memory usage is also the transient memory that is allocated during a request or job<br>cycle and released soon after.<br>As such, it’s also interesting to keep an eye out for opportunities to make various Ruby objects smaller.

And the Ruby object type that’s probably the biggest contributor to memory usage is likely Hash.<br>Hash instances are extremely common in Ruby applications and libraries, from option hashes and keyword arguments<br>to logging and API responses.

They’re so convenient that they’re perhaps overused sometimes, especially since they’re really not very memory-efficient.

So let’s dig into how much memory they use, a bit of history of how we got there, and what we could do about it.

Measuring Memory Usage

If you’ve read some of my previous posts, you are probably already familiar with the Ruby APIs that allow digging into memory usage.

The simplest one is ObjectSpace.memsize_of(obj):

>> Ruby::DESCRIPTION<br>=> "ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25]"<br>>> require 'objspace'<br>>> ObjectSpace.memsize_of({})<br>=> 160

So this tells us that an empty hash uses 160 bytes.<br>But without a comparison point, that doesn’t mean much, so let’s compare them to say, Struct:

require 'objspace'<br>puts "Ruby: #{RUBY_VERSION}"

11.times do |size|<br>struct_class = size.zero? ? Object : Struct.new(*size.times.map { |i| :"m_#{i}" })<br>struct = ObjectSpace.memsize_of(struct_class.new)<br>hash = ObjectSpace.memsize_of(Hash[size.times.map { |i| [i, i] }])<br>diff = (hash.to_f / struct).round(1)<br>puts "size: #{size} \tstruct: #{struct} \thash: #{hash} \tdiff: #{diff}x"<br>end

Which gives us:

Ruby 4.0.6<br>size: 0 struct: 40 hash: 160 diff: 4.0x<br>size: 1 struct: 40 hash: 160 diff: 4.0x<br>size: 2 struct: 40 hash: 160 diff: 4.0x<br>size: 3 struct: 40 hash: 160 diff: 4.0x<br>size: 4 struct: 80 hash: 160 diff: 2.0x<br>size: 5 struct: 80 hash: 160 diff: 2.0x<br>size: 6 struct: 80 hash: 160 diff: 2.0x<br>size: 7 struct: 80 hash: 160 diff: 2.0x<br>size: 8 struct: 80 hash: 160 diff: 2.0x<br>size: 9 struct: 160 hash: 544 diff: 3.4x<br>size: 10 struct: 160 hash: 544 diff: 3.4x

As you can see, Hash uses 2 to 4 times as much memory as Struct1 or a Plain Old Ruby Object (PORO).

I could almost say to stop using Hash when a PORO could do and leave it at that (and that would be good advice),<br>but the goal is to dig into why Hash uses so much memory and what we can do about it.

If you studied hash tables, that may seem like I’m stating the obvious here.<br>Of course, hash tables need more memory!

What may be less obvious is that in the above example, up until size 8, Ruby’s Hash instances aren’t actually hash tables.

So let’s actually look at the implementation and its history to understand how we got here.

Open Addressing

The Hash implementation changed a lot over Ruby’s lifetime.

The first major change I remember was when the Hash-table implementation was entirely rewritten by Vladimir Makarov for Ruby 2.4.<br>It then changed from a more traditional design to open-addressing.<br>I’m not going to dig into the differences much, there are much better sources than me on that, but the thing I’ll point out, though,<br>is that while that change made hashes noticeably faster, it also significantly increased the “header” size.

By header, I mean the C struct that keeps track of the entries and bins.<br>Prior to the change, the st_table struct was 48B:

#include<br>#include

struct st_hash_type;<br>struct packed_entry;<br>struct st_table_entry;

typedef unsigned long long st_index_t;<br>#define ST_INDEX_BITS (sizeof(st_index_t) * CHAR_BIT)

struct st_table {<br>const struct st_hash_type *type;<br>st_index_t num_bins;<br>unsigned int entries_packed : 1;<br>st_index_t num_entries : ST_INDEX_BITS - 1;<br>union {<br>struct {<br>struct st_table_entry **bins;<br>void *private_list_head[2];<br>} big;<br>struct {<br>struct st_packed_entry *entries;<br>st_index_t real_entries;<br>} packed;<br>} as;<br>};

int main(int argc, char **argv)<br>fprintf(stderr, "sizeof(struct st_table) = %ld\n", sizeof(struct st_table));<br>return 0;

sizeof(struct st_table) = 48

In Vladimir’s initial patch, that struct grew to 88B, but after some improvements, when the patch actually<br>landed, it only grew to 64B, and then in a follow-up commit,<br>Nobu shrunk it down further to 56B.

#include

struct st_hash_type;<br>struct st_table_entry;

typedef unsigned long long st_index_t;

struct st_table {<br>/* Cached features of the table -- see st.c for more details. */<br>unsigned char entry_power, bin_power, size_ind;<br>/* How many times the table was rebuilt. */<br>unsigned int rebuilds_num;<br>const struct st_hash_type *type;<br>/* Number of entries currently in the table. */<br>st_index_t num_entries;<br>/* Array...

struct hash size ruby diff memory

Related Articles