From b05f440e0df1ee2f4a3f76e81d76f3e46f1801e7 Mon Sep 17 00:00:00 2001 From: Connor Shea <2977353+connorshea@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:07:16 -0600 Subject: [PATCH 1/2] Build and memoize i18n keys lazily to shrink idle Value memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Enumerize::Value eagerly built and retained an array of i18n lookup keys plus a humanized fallback string in its constructor, regardless of whether #text was ever rendered. Build them lazily instead, and memoize the result on the (non-frozen) Attribute keyed by value name, so a value's keys are composed at most once and values whose #text is never displayed retain nothing. The cache is updated copy-on-write, so concurrent #text calls stay safe without locking, matching the thread-safety the frozen-at-boot version had. A race between two builds is last-writer-wins — always correct, at worst a redundant rebuild. When #text is never rendered, retained memory drops ~38% (~80 B/value) and class-definition-time allocations drop ~85%; rendered values match the old eager throughput. See benchmark/lazy_keys_benchmark.rb. Co-Authored-By: Claude Opus 4.8 (1M context) --- benchmark/lazy_keys_benchmark.rb | 131 +++++++++++++++++++++++++++++++ lib/enumerize/attribute.rb | 25 ++++++ lib/enumerize/value.rb | 28 ++++--- test/attribute_test.rb | 23 ++++++ test/value_test.rb | 5 ++ 5 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 benchmark/lazy_keys_benchmark.rb diff --git a/benchmark/lazy_keys_benchmark.rb b/benchmark/lazy_keys_benchmark.rb new file mode 100644 index 0000000..d989f74 --- /dev/null +++ b/benchmark/lazy_keys_benchmark.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +# Benchmark isolating the "lazy i18n keys" change in Enumerize::Value. +# +# It compares two implementations that differ ONLY in when the i18n lookup +# keys (and the humanized fallback string) are built: +# +# * EAGER - the previous behavior: build the keys array in #initialize and +# retain it on every Value instance forever. +# * LAZY - the current behavior: build the keys on first #text and memoize +# them copy-on-write on the attribute. Values whose #text is never +# rendered build and retain nothing; rendered values pay once. +# +# So the memory columns are measured with #text never called (lazy retains +# nothing) and the throughput column is measured warm (lazy keys memoized), +# which is the realistic render path. +# +# Run: ruby -Ilib benchmark/lazy_keys_benchmark.rb + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'enumerize' +require 'benchmark/ips' +require 'objspace' + +# A real attribute so the keys are built against real i18n_scopes/name. +KLASS = Class.new do + extend Enumerize + enumerize :status, in: %i[active inactive pending archived deleted suspended] +end +ATTR = KLASS.enumerized_attributes[:status] +NAMES = %i[active inactive pending archived deleted suspended].freeze + +# EAGER variant: replicate the pre-change behavior (keys built and retained in +# the constructor), so the only axis that varies is eager-vs-lazy. +class EagerValue < Enumerize::Value + def initialize(attr, name, value = nil) + super + @i18n_keys = build_i18n_keys + end + + def text + I18n.t(@i18n_keys[0], default: @i18n_keys[1..-1]) + end + + private + + def build_i18n_keys + keys = @attr.i18n_scopes.map do |s| + scope = Enumerize::Utils.call_if_callable(s, @value) + :"#{scope}.#{self}" + end + keys << :"enumerize.defaults.#{@attr.name}.#{self}" + keys << :"enumerize.#{@attr.name}.#{self}" + keys << ActiveSupport::Inflector.humanize(ActiveSupport::Inflector.underscore(self)) + keys + end +end + +def build_values(value_class) + NAMES.map { |n| value_class.new(ATTR, n).freeze } +end + +# --------------------------------------------------------------------------- +# 1. Retained memory per value (the win) — text never rendered. +# --------------------------------------------------------------------------- +def retained_bytes(value_class, count) + GC.start + before = ObjectSpace.memsize_of_all + store = Array.new(count) { build_values(value_class) } + GC.start + after = ObjectSpace.memsize_of_all + store.clear + after - before +end + +COUNT = 5_000 # attributes worth of values (× 6 values each = 30k Value objects) +eager_mem = retained_bytes(EagerValue, COUNT) +lazy_mem = retained_bytes(Enumerize::Value, COUNT) +values_total = COUNT * NAMES.size + +# --------------------------------------------------------------------------- +# 2. Allocations to construct one attribute's worth of values (boot cost). +# --------------------------------------------------------------------------- +def construct_allocs(value_class, reps) + GC.start + GC.disable + start = GC.stat(:total_allocated_objects) + reps.times { build_values(value_class) } + allocs = GC.stat(:total_allocated_objects) - start + GC.enable + allocs.to_f / reps +end + +REPS = 2_000 +eager_build_allocs = construct_allocs(EagerValue, REPS) +lazy_build_allocs = construct_allocs(Enumerize::Value, REPS) + +# --------------------------------------------------------------------------- +# 3. #text throughput — warm (lazy keys memoized on the attribute), the +# realistic render path. +# --------------------------------------------------------------------------- +eager_values = build_values(EagerValue) +lazy_values = build_values(Enumerize::Value) +lazy_values.each(&:text) # warm the attribute key cache + +puts "\n#text throughput (higher is better):" +text_report = Benchmark.ips do |x| + x.report('eager #text') { eager_values.each(&:text) } + x.report('lazy #text') { lazy_values.each(&:text) } + x.compare! +end + +eager_ips = text_report.entries.find { |e| e.label == 'eager #text' }.ips +lazy_ips = text_report.entries.find { |e| e.label == 'lazy #text' }.ips + +# --------------------------------------------------------------------------- +# Markdown summary table. +# --------------------------------------------------------------------------- +fmt_kb = ->(b) { format('%.1f KB', b / 1024.0) } +fmt_b = ->(b) { format('%.1f B', b.to_f) } +pct = ->(from, to) { format('%+.1f%%', (to - from) * 100.0 / from) } + +puts "\n\n## Lazy i18n keys — benchmark results" +puts "\nRuby #{RUBY_VERSION}, #{values_total} Value objects measured for memory.\n\n" +puts '| Metric | Eager (before) | Lazy (after) | Change |' +puts '| --- | --- | --- | --- |' +puts "| Retained memory, #{values_total} values (text never called) | #{fmt_kb[eager_mem]} | #{fmt_kb[lazy_mem]} | #{pct[eager_mem, lazy_mem]} |" +puts "| Retained memory per value | #{fmt_b[eager_mem.to_f / values_total]} | #{fmt_b[lazy_mem.to_f / values_total]} | #{fmt_b[(lazy_mem - eager_mem).to_f / values_total]}/value |" +puts "| Objects allocated building one attribute (6 values) | #{format('%.1f', eager_build_allocs)} | #{format('%.1f', lazy_build_allocs)} | #{pct[eager_build_allocs, lazy_build_allocs]} |" +puts "| #text throughput (i/s, 6 values/iter, warm) | #{format('%.0f', eager_ips)} | #{format('%.0f', lazy_ips)} | #{pct[eager_ips, lazy_ips]} |" +puts "\n_Lazy wins on memory and build cost; memoization keeps #text on par with eager._" diff --git a/lib/enumerize/attribute.rb b/lib/enumerize/attribute.rb index b22bb7c..707fe55 100644 --- a/lib/enumerize/attribute.rb +++ b/lib/enumerize/attribute.rb @@ -27,6 +27,11 @@ def initialize(klass, name, options={}) end @skip_validations_value = options.fetch(:skip_validations, false) + + # Lazily populated cache of i18n lookup keys, keyed by value name. Values + # whose #text is never rendered never build (or retain) their keys, while + # rendered values build them once and reuse them on subsequent calls. + @i18n_keys_cache = {} end def find_default_value(value) @@ -63,6 +68,26 @@ def i18n_scopes end end + # Returns the cached i18n lookup keys for +value+, building them via the + # given block on a miss. Memoizing here (rather than on the frozen Value) + # means a value's keys are composed at most once, recovering the cost of + # rebuilding them on every #text call, while values whose #text is never + # rendered never build or retain any keys. + # + # The cache is updated copy-on-write: readers always see a fully built hash + # and never a half-mutated one, so concurrent #text calls stay safe without + # locking. A race between two builds is last-writer-wins — the result is + # always correct; at worst a clobbered entry is rebuilt on its next call. + def i18n_keys(value) + key = value.to_s + cache = @i18n_keys_cache + cache[key] || begin + keys = yield + @i18n_keys_cache = cache.merge(key => keys) + keys + end + end + def options(options = {}) values = if options.empty? @values diff --git a/lib/enumerize/value.rb b/lib/enumerize/value.rb index a9672fc..0ff6eb4 100644 --- a/lib/enumerize/value.rb +++ b/lib/enumerize/value.rb @@ -14,20 +14,11 @@ def initialize(attr, name, value=nil) @value = value.nil? ? name.to_s : value super(name.to_s) - - @i18n_keys = @attr.i18n_scopes.map do |s| - scope = Utils.call_if_callable(s, @value) - - :"#{scope}.#{self}" - end - @i18n_keys << :"enumerize.defaults.#{@attr.name}.#{self}" - @i18n_keys << :"enumerize.#{@attr.name}.#{self}" - @i18n_keys << ActiveSupport::Inflector.humanize(ActiveSupport::Inflector.underscore(self)) # humanize value if there are no translations - @i18n_keys end def text - I18n.t(@i18n_keys[0], :default => @i18n_keys[1..-1]) if @i18n_keys + keys = @attr.i18n_keys(self) { build_i18n_keys } + I18n.t(keys[0], :default => keys[1..-1]) end def ==(other) @@ -44,6 +35,21 @@ def as_json(*) private + # Composes the ordered i18n lookup keys for this value. Invoked by the + # attribute only on a cache miss (see Attribute#i18n_keys), so values whose + # +text+ is never rendered never build or retain their keys. + def build_i18n_keys + keys = @attr.i18n_scopes.map do |s| + scope = Utils.call_if_callable(s, @value) + + :"#{scope}.#{self}" + end + keys << :"enumerize.defaults.#{@attr.name}.#{self}" + keys << :"enumerize.#{@attr.name}.#{self}" + keys << ActiveSupport::Inflector.humanize(ActiveSupport::Inflector.underscore(self)) # humanize value if there are no translations + keys + end + def predicate_call(value) value == self end diff --git a/test/attribute_test.rb b/test/attribute_test.rb index 6664442..8e782a1 100644 --- a/test/attribute_test.rb +++ b/test/attribute_test.rb @@ -39,6 +39,29 @@ def build_attr(*args, &block) end end + describe 'i18n keys' do + it 'builds the keys via the block on a miss' do + build_attr nil, 'foo', :in => %w[a b] + keys = attr.i18n_keys(attr.find_value('a')) { [:built] } + expect(keys).must_equal [:built] + end + + it 'memoizes per value and does not rebuild on a hit' do + build_attr nil, 'foo', :in => %w[a b] + first = attr.i18n_keys(attr.find_value('a')) { [:built] } + second = attr.i18n_keys(attr.find_value('a')) { raise 'should not rebuild' } + expect(second).must_be_same_as first + end + + it 'caches each value independently' do + build_attr nil, 'foo', :in => %w[a b] + a_keys = attr.i18n_keys(attr.find_value('a')) { [:a] } + b_keys = attr.i18n_keys(attr.find_value('b')) { [:b] } + expect(a_keys).must_equal [:a] + expect(b_keys).must_equal [:b] + end + end + describe 'arguments' do it 'returns arguments' do build_attr nil, :foo, :in => [:a, :b], :scope => true diff --git a/test/value_test.rb b/test/value_test.rb index 467a3e5..a9b36ea 100644 --- a/test/value_test.rb +++ b/test/value_test.rb @@ -11,6 +11,11 @@ class Attr < Struct.new(:values, :name, :i18n_scopes, :klass) def value?(value) values.include?(value) end + + # Caching is the real Attribute's concern; the double just composes. + def i18n_keys(_value) + yield + end end let(:attr) { Attr.new([], "attribute_name", [], Model) } From 813595113713d7d5443f68a2cf83e942f7662c94 Mon Sep 17 00:00:00 2001 From: Connor Shea <2977353+connorshea@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:19:45 -0600 Subject: [PATCH 2/2] rm bench --- benchmark/lazy_keys_benchmark.rb | 131 ------------------------------- 1 file changed, 131 deletions(-) delete mode 100644 benchmark/lazy_keys_benchmark.rb diff --git a/benchmark/lazy_keys_benchmark.rb b/benchmark/lazy_keys_benchmark.rb deleted file mode 100644 index d989f74..0000000 --- a/benchmark/lazy_keys_benchmark.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true - -# Benchmark isolating the "lazy i18n keys" change in Enumerize::Value. -# -# It compares two implementations that differ ONLY in when the i18n lookup -# keys (and the humanized fallback string) are built: -# -# * EAGER - the previous behavior: build the keys array in #initialize and -# retain it on every Value instance forever. -# * LAZY - the current behavior: build the keys on first #text and memoize -# them copy-on-write on the attribute. Values whose #text is never -# rendered build and retain nothing; rendered values pay once. -# -# So the memory columns are measured with #text never called (lazy retains -# nothing) and the throughput column is measured warm (lazy keys memoized), -# which is the realistic render path. -# -# Run: ruby -Ilib benchmark/lazy_keys_benchmark.rb - -$LOAD_PATH.unshift File.expand_path('../lib', __dir__) -require 'enumerize' -require 'benchmark/ips' -require 'objspace' - -# A real attribute so the keys are built against real i18n_scopes/name. -KLASS = Class.new do - extend Enumerize - enumerize :status, in: %i[active inactive pending archived deleted suspended] -end -ATTR = KLASS.enumerized_attributes[:status] -NAMES = %i[active inactive pending archived deleted suspended].freeze - -# EAGER variant: replicate the pre-change behavior (keys built and retained in -# the constructor), so the only axis that varies is eager-vs-lazy. -class EagerValue < Enumerize::Value - def initialize(attr, name, value = nil) - super - @i18n_keys = build_i18n_keys - end - - def text - I18n.t(@i18n_keys[0], default: @i18n_keys[1..-1]) - end - - private - - def build_i18n_keys - keys = @attr.i18n_scopes.map do |s| - scope = Enumerize::Utils.call_if_callable(s, @value) - :"#{scope}.#{self}" - end - keys << :"enumerize.defaults.#{@attr.name}.#{self}" - keys << :"enumerize.#{@attr.name}.#{self}" - keys << ActiveSupport::Inflector.humanize(ActiveSupport::Inflector.underscore(self)) - keys - end -end - -def build_values(value_class) - NAMES.map { |n| value_class.new(ATTR, n).freeze } -end - -# --------------------------------------------------------------------------- -# 1. Retained memory per value (the win) — text never rendered. -# --------------------------------------------------------------------------- -def retained_bytes(value_class, count) - GC.start - before = ObjectSpace.memsize_of_all - store = Array.new(count) { build_values(value_class) } - GC.start - after = ObjectSpace.memsize_of_all - store.clear - after - before -end - -COUNT = 5_000 # attributes worth of values (× 6 values each = 30k Value objects) -eager_mem = retained_bytes(EagerValue, COUNT) -lazy_mem = retained_bytes(Enumerize::Value, COUNT) -values_total = COUNT * NAMES.size - -# --------------------------------------------------------------------------- -# 2. Allocations to construct one attribute's worth of values (boot cost). -# --------------------------------------------------------------------------- -def construct_allocs(value_class, reps) - GC.start - GC.disable - start = GC.stat(:total_allocated_objects) - reps.times { build_values(value_class) } - allocs = GC.stat(:total_allocated_objects) - start - GC.enable - allocs.to_f / reps -end - -REPS = 2_000 -eager_build_allocs = construct_allocs(EagerValue, REPS) -lazy_build_allocs = construct_allocs(Enumerize::Value, REPS) - -# --------------------------------------------------------------------------- -# 3. #text throughput — warm (lazy keys memoized on the attribute), the -# realistic render path. -# --------------------------------------------------------------------------- -eager_values = build_values(EagerValue) -lazy_values = build_values(Enumerize::Value) -lazy_values.each(&:text) # warm the attribute key cache - -puts "\n#text throughput (higher is better):" -text_report = Benchmark.ips do |x| - x.report('eager #text') { eager_values.each(&:text) } - x.report('lazy #text') { lazy_values.each(&:text) } - x.compare! -end - -eager_ips = text_report.entries.find { |e| e.label == 'eager #text' }.ips -lazy_ips = text_report.entries.find { |e| e.label == 'lazy #text' }.ips - -# --------------------------------------------------------------------------- -# Markdown summary table. -# --------------------------------------------------------------------------- -fmt_kb = ->(b) { format('%.1f KB', b / 1024.0) } -fmt_b = ->(b) { format('%.1f B', b.to_f) } -pct = ->(from, to) { format('%+.1f%%', (to - from) * 100.0 / from) } - -puts "\n\n## Lazy i18n keys — benchmark results" -puts "\nRuby #{RUBY_VERSION}, #{values_total} Value objects measured for memory.\n\n" -puts '| Metric | Eager (before) | Lazy (after) | Change |' -puts '| --- | --- | --- | --- |' -puts "| Retained memory, #{values_total} values (text never called) | #{fmt_kb[eager_mem]} | #{fmt_kb[lazy_mem]} | #{pct[eager_mem, lazy_mem]} |" -puts "| Retained memory per value | #{fmt_b[eager_mem.to_f / values_total]} | #{fmt_b[lazy_mem.to_f / values_total]} | #{fmt_b[(lazy_mem - eager_mem).to_f / values_total]}/value |" -puts "| Objects allocated building one attribute (6 values) | #{format('%.1f', eager_build_allocs)} | #{format('%.1f', lazy_build_allocs)} | #{pct[eager_build_allocs, lazy_build_allocs]} |" -puts "| #text throughput (i/s, 6 values/iter, warm) | #{format('%.0f', eager_ips)} | #{format('%.0f', lazy_ips)} | #{pct[eager_ips, lazy_ips]} |" -puts "\n_Lazy wins on memory and build cost; memoization keeps #text on par with eager._"