Skip to content

Optimize _map_collection#612

Open
moberegger wants to merge 1 commit intorails:mainfrom
affinity:moberegger/optimize-_map_collection
Open

Optimize _map_collection#612
moberegger wants to merge 1 commit intorails:mainfrom
affinity:moberegger/optimize-_map_collection

Conversation

@moberegger
Copy link
Contributor

Optimizes _map_collection. This PR is somewhat inspired by work proposed in #610 where filter_map would be used for Ruby 2.7+.

def _map_collection(collection)
  collection.filter_map do |element|
    mapped_element = _scope{ yield element }
    mapped_element unless mapped_element == BLANK
  end
end

My benchmarks for this show that this implementation was 1.61x slower – which was surprising – but it got me curious to see if there were any gains to be made here. I tried one other approach to save on a memory allocation for [BLANK]:

BLANKS = [BLANK].freeze

def _map_collection(collection)
  collection.map do |element|
    _scope{ yield element }
  end - BLANKS
end

but the proposed optimization performed the best both in terms of latency and memory. It saves on two intermediary array allocations, which I believe is where the 1.14x perf improvement comes from. The benchmarks below are directly against _map_collection (note: I made the method non-private to quickly profile it)

ruby 4.0.0 (2025-12-25 revision 553f1675f3) +YJIT +PRISM [arm64-darwin25]
Calculating -------------------------------------
            original      5.591M (± 4.5%) i/s  (178.86 ns/i) -     28.057M in   5.031369s
     with filter_map      3.470M (± 5.2%) i/s  (288.21 ns/i) -     17.387M in   5.030159s
with - BLANKS constant
                          5.556M (±12.4%) i/s  (179.98 ns/i) -     27.312M in   5.030075s
         with delete      6.375M (± 1.9%) i/s  (156.86 ns/i) -     32.194M in   5.051739s

Comparison:
            original:  5590921.9 i/s
         with delete:  6375138.6 i/s - 1.14x  faster
with - BLANKS constant:  5556106.4 i/s - same-ish: difference falls within error
     with filter_map:  3469692.4 i/s - 1.61x  slower
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +YJIT +PRISM [arm64-darwin25]
Calculating -------------------------------------
            original   120.000  memsize (     0.000  retained)
                         3.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)
     with filter_map    40.000  memsize (     0.000  retained)
                         1.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)
with - BLANKS constant
                        80.000  memsize (     0.000  retained)
                         2.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)
         with delete    40.000  memsize (     0.000  retained)
                         1.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)

Comparison:
     with filter_map:         40 allocated
         with delete:         40 allocated - same
with - BLANKS constant:         80 allocated - 2.00x more
            original:        120 allocated - 3.00x more

A simple benchmark against array! shows an improvement, too. I made this as simple as possible to exercise the changes in _map_collection as much as possible.

array = [1,2,3]

json.array! array do |item|
  #...
end
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +YJIT +PRISM [arm64-darwin25]
Calculating -------------------------------------
              before      3.595M (± 3.0%) i/s  (278.15 ns/i) -     17.984M in   5.007596s
               after      4.104M (± 3.8%) i/s  (243.64 ns/i) -     20.864M in   5.093031s

Comparison:
              before:  3595141.1 i/s
               after:  4104470.7 i/s - 1.14x  faster
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +YJIT +PRISM [arm64-darwin25]
Calculating -------------------------------------
              before   160.000  memsize (    40.000  retained)
                         4.000  objects (     1.000  retained)
                         0.000  strings (     0.000  retained)
               after    80.000  memsize (    40.000  retained)
                         2.000  objects (     1.000  retained)
                         0.000  strings (     0.000  retained)

Comparison:
               after:         80 allocated
              before:        160 allocated - 2.00x more

For more complex templates, I would expect the gains to be diluted, but figured it was worth doing given that the implementation change is small and isolated.

_scope{ yield element }
end - [BLANK]
end
collection.delete(BLANK)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Safe to mutate because the result of the map is local to the method. The input remains unaffected.

@moberegger
Copy link
Contributor Author

moberegger commented Feb 5, 2026

The Ruby 3.2 failure is fixed in #611.

Edit: Rebased with the recently merged changes from #611 to get CI green.

@moberegger moberegger force-pushed the moberegger/optimize-_map_collection branch from e2f3615 to 8032953 Compare February 10, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant