Skip to content

fix: memoize field :if/:unless conditions - #608

Open
Hamdy wants to merge 2 commits into
procore-oss:mainfrom
Hamdy:hamdy/fix-memoization
Open

Hamdy wants to merge 2 commits into
procore-oss:mainfrom
Hamdy:hamdy/fix-memoization

Conversation

@Hamdy

@Hamdy Hamdy commented Sep 19, 2026 •

Copy link
Copy Markdown

Checklist:

  • I have updated the necessary documentation
  • I have signed off all my commits as required by DCO
  • My build is green

Summary

Field#if_callable/#unless_callable memoized with @_ivar ||= callable_from(...). But callable_from returns false when no :if/:unless is configured — the common case — and false ||= never memoizes. Both conditions were re-resolved for every field of every object rendered, each hitting Blueprinter.configuration. stackprof attributed 25% of render wall time to Field#callable_from.

The fix replaces ||= with a defined? guard so falsey results memoize too — each condition resolves exactly once per field. Two lines in lib/blueprinter/field.rb.

Results

main vs this branch, via bundle exec rake benchmarks (Ruby 3.3.9):

Description Better main This PR Change
Mechanism
Field#callable_from calls per render (1000 objects) lower ↓ 12,000 12 1000× less
Wall time per render
Median render time — 1 object × 6 fields lower ↓ 0.005 ms 0.003 ms 1.67× faster
Median render time — 100 objects × 6 fields lower ↓ 0.415 ms 0.248 ms 1.67× faster
Median render time — 1000 objects × 6 fields lower ↓ 4.344 ms 2.416 ms 1.80× faster
Median render time — 1000 objects × 12 fields lower ↓ 9.521 ms 4.810 ms 1.98× faster
Cost per object (size-independent)
µs/object — 1000 objects × 6 fields lower ↓ 4.34 2.42 −44.2%
µs/object — 1000 objects × 12 fields lower ↓ 9.52 4.81 −49.5%
Throughput
Renders/sec — 1000 objects × 6 fields higher ↑ 230.2 413.9 +79.8%
Renders/sec — 1000 objects × 12 fields higher ↑ 105.0 207.9 +98.0%
Memory
Allocations per render — 1000 objects × 6 fields lower ↓ 1,009 1,009 unchanged

The gain scales with field-count × object-count (1.67× → 1.98×), as expected for eliminating per-field-per-object work. Allocations are unchanged by design — this removes redundant CPU work, not allocations.

Behaviour change — please review

Global config.if/config.unless are now read once per field, on first use, so they must be set before rendering. This is consistent with config.sort_fields_by, already snapshotted in ViewCollection#initialize. It is called out in the CHANGELOG and pinned by a spec.

Tests & verification

spec/units/field_spec.rb adds coverage for Field#skip? (field-level procs, symbols, global config, precedence, memoization); three examples fail against the old code. A small spec/benchmarks/ harness runs under the existing rake benchmarks CI task with scale-relative assertions.

bundle exec rake             # 412 examples, 0 failures
bundle exec rake benchmarks  # 0 failures

No public API change. No version bump.

Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>
Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>
@Hamdy
Hamdy marked this pull request as ready for review September 19, 2026 20:09
@Hamdy
Hamdy requested review from a team and ritikesh as code owners September 19, 2026 20:09
Comment thread CHANGELOG.md
@@ -1,5 +1,5 @@
## Unreleased
--
- 🐛 [BUGFIX] Fixes broken memoization of field-level `:if`/`:unless` conditions. Because `callable_from` returns `false` when no condition is configured, `||=` never memoized it, so conditions were re-resolved (including a global configuration lookup) for every field of every rendered object. Renders are now ~1.6x faster. Note: global `config.if`/`config.unless` are now read once per field on first use, so they must be set before rendering — consistent with `config.sort_fields_by`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Let’s rework the verbiage a bit here:

  • "~1.6x faster" depends on the blueprint/benchmark shape. I’d suggest either being specific about the numbers with a few scenarios, state an “up to” with a single number, or just keep the statement more general (e.g. “improving render times”).
  • What wasn't memoized was an unset condition (field-level or global). Configured conditions were already memoized, so "field-level" undersells the cause a bit.
  • The behaviour note could be more precise: what changes is that a global config.if set after a blueprint's first render is now ignored, where before it took effect. A global proc set before the first render was already captured (a line in the README under "Global Config Setting - if and unless" saying to set these before the first render would help too).
  • sort_fields_by is captured earlier (when the blueprint is defined), so I’d omit as the comparison only holds loosely.

Comment thread lib/blueprinter/field.rb
Comment on lines +28 to +35
# NOTE: `callable_from` returns `false` when no callable is configured, which is the common case.
# `||=` therefore never memoizes it, causing a full re-resolution (including a global config
# lookup) for every field of every object rendered. The `defined?` guard memoizes falsey values
# too, so resolution happens exactly once per field.
#
# As a result, global `config.if`/`config.unless` are read once per field, on first use. Set them
# before rendering. This matches `config.sort_fields_by`, which is likewise snapshotted when a
# ViewCollection is built.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: The additional context is appreciated, but can we make this a bit more concise? Maybe something like:

Suggested change
# NOTE: `callable_from` returns `false` when no callable is configured, which is the common case.
# `||=` therefore never memoizes it, causing a full re-resolution (including a global config
# lookup) for every field of every object rendered. The `defined?` guard memoizes falsey values
# too, so resolution happens exactly once per field.
#
# As a result, global `config.if`/`config.unless` are read once per field, on first use. Set them
# before rendering. This matches `config.sort_fields_by`, which is likewise snapshotted when a
# ViewCollection is built.
# `callable_from` returns false when no callable is configured, which means `||=` would re-run
# it on every call. Instead, a `defined?` guard allows falsey values to be memoized as well.

Comment on lines +7 to +15
# Rendering a collection should allocate O(1) objects per rendered object -- essentially the one
# result Hash -- and NOT O(fields). A Hash counts as a single object however many keys it holds, so
# widening a Blueprint from 6 to 12 fields should barely move the total.
#
# Two relative assertions, both machine independent:
#
# 1. allocations per rendered object stays small (near 1)
# 2. doubling the field count does not double allocations
#

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Let’s just clarify that this is the case for attribute/simple fields specifically. Allocations do move with new fields if they’re block/association based.

Comment thread spec/units/field_spec.rb
Comment on lines +14 to +16
def field(options = {})
Blueprinter::Field.new(:first_name, :first_name, extractor, blueprint, options)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Let’s use subject(:field) and let(:options) here instead.

Comment thread spec/units/field_spec.rb
# `@_if_callable ||= callable_from(:if)` memoization never took hold and re-resolved the
# callable -- including a global config lookup -- on every field of every rendered object.
# This accounted for ~25% of total render wall time.
it 'resolves each condition exactly once, even though resolution returns false' do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: We can prevent regression with L47 and L149 alone. I’d omit this assertion since we’re effectively testing the implementation by stubbing a private method on the subject under test, as well as L38, which actually passes on main currently.

Comment thread spec/units/field_spec.rb
expect(seen).to eq([[:first_name, object, { foo: :bar }], [:first_name, object, { foo: :baz }]])
end

it 'memoizes the proc itself, calling it once per skip? invocation' do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: This checks that the proc is called on every skip? call, but it says nothing about memoization. Maybe "calls the condition on every skip? invocation"?

@lessthanjacob

Copy link
Copy Markdown
Contributor

Thanks for pushing this up @Hamdy! The improvements look promising.

I left a few comments, but outside of that, I’d lean towards splitting out the additional benchmarks from the fix itself (given that it represents only a small fraction of the code here). We’ve had previous discussions around reworking the benchmark tooling previously, so I’d be happy to work with you on that separately from this change!

This branch has not been deployed

No deployments
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.

2 participants