Repository navigation
Conversation
Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>
Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>
| @@ -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`. | |||
There was a problem hiding this comment.
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.ifset 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 theREADMEunder "Global Config Setting - if and unless" saying to set these before the first render would help too). sort_fields_byis captured earlier (when the blueprint is defined), so I’d omit as the comparison only holds loosely.
| # 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. |
There was a problem hiding this comment.
nit: The additional context is appreciated, but can we make this a bit more concise? Maybe something like:
| # 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. |
| # 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 | ||
| # |
There was a problem hiding this comment.
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.
| def field(options = {}) | ||
| Blueprinter::Field.new(:first_name, :first_name, extractor, blueprint, options) | ||
| end |
There was a problem hiding this comment.
suggestion: Let’s use subject(:field) and let(:options) here instead.
| # `@_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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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"?
|
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! |
Checklist:
Summary
Field#if_callable/#unless_callablememoized with@_ivar ||= callable_from(...). Butcallable_fromreturnsfalsewhen no:if/:unlessis configured — the common case — andfalse ||=never memoizes. Both conditions were re-resolved for every field of every object rendered, each hittingBlueprinter.configuration.stackprofattributed 25% of render wall time toField#callable_from.The fix replaces
||=with adefined?guard so falsey results memoize too — each condition resolves exactly once per field. Two lines inlib/blueprinter/field.rb.Results
mainvs this branch, viabundle exec rake benchmarks(Ruby 3.3.9):mainField#callable_fromcalls per render (1000 objects)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.unlessare now read once per field, on first use, so they must be set before rendering. This is consistent withconfig.sort_fields_by, already snapshotted inViewCollection#initialize. It is called out in the CHANGELOG and pinned by a spec.Tests & verification
spec/units/field_spec.rbadds coverage forField#skip?(field-level procs, symbols, global config, precedence, memoization); three examples fail against the old code. A smallspec/benchmarks/harness runs under the existingrake benchmarksCI task with scale-relative assertions.No public API change. No version bump.