Skip to content

perf: remove per-render allocations and repeated lookups from fields, blocks, and associations - #616

Open
Hamdy wants to merge 4 commits into
procore-oss:mainfrom
Hamdy:hamdy/perf
Open

Hamdy wants to merge 4 commits into
procore-oss:mainfrom
Hamdy:hamdy/perf

Conversation

@Hamdy

@Hamdy Hamdy commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Checklist:

  • I have updated the necessary documentation
  • I have signed off all my commits as required by DCO
  • My build is green (bundle exec rake locally)

Summary

Removes allocations and repeated work from the render path. Output is unchanged. Rendering is about 5-11% faster per object on plain fields and 10-15% faster on the CI IPS benchmarks. Block fields render about 38% faster and allocate 89% less, and associations with a default render about 10% faster and allocate 29% less.

Changes

One commit per change.

Fields and transformers are looked up once per render. prepare_data used to fetch them inside object_to_hash, so once per object in a collection. Extensions#pre_render also returns early when there are no extensions, since reduce with a block allocates even on an empty array.

Symbol#to_proc blocks are detected without Proc#parameters. BlockExtractor called block.parameters for every block field of every object, and Proc#parameters allocates three arrays each time. Symbol#to_proc is a native lambda taking (obj, *args), so it is the only kind of block that is a lambda, has arity -2, and has no Ruby source location. lambda? and arity never allocate, and plain DSL blocks stop at the first check; only a user lambda with arity -2 reaches source_location. Lambdas like ->(obj, **opts) and Method#to_proc blocks still get local_options.

Datetime formatting is skipped when no format is set. AutoExtractor asked every extracted value, including strings and numbers, whether it responds to strftime. It now calls the formatter only when the field or the global config sets a format. The global format is still read on every extract, so setting it after the first render still works, and config.datetime_format = false still raises as before. With no format the formatter returned the value unchanged anyway, so output is the same.

Not included

Earlier revisions of this PR also reused the parent's local_options for a nested render with the same view, built local_options once per render, stored exclude_if_nil on the Field, and memoized per-block and per-association results on the shared extractors. Those are dropped: the first two let a nested transformer's write to local_options leak into the parent's remaining fields, the third hid an option change made by a custom extractor, and the memos either mutated a VM-shared Symbol#to_proc object or wrote to an unsynchronized Hash from renders for no measurable gain over the uncached check. The nested local_options behavior is covered in spec/integrations/base_spec.rb.

Scope

  • CHANGELOG.md has the [REFACTOR] entry under ## Unreleased.
  • docs/architecture.md: render walkthrough, sequence diagram, and render performance rules match the code.
  • docs/configuration.md (datetime_format), docs/extension-points.md (nested local_options is a copy), and docs/making-changes.md (exclude_if_nil read after extraction) are updated. Each doc's generated field is bumped.

Benchmarks

Measured against main at 29069ec (which includes #608) on the same machine, Ruby 3.3.9, alternating main and this branch over 5 rounds. The numbers are medians. The CI benchmarks are the ones under spec/benchmarks/ plus throughput_test.rb and allocations_test.rb from hamdy/benchmarks (#614), run in two worktrees with the same benchmark files. This branch was faster in all 5 paired rounds on ActiveRecord IPS, on every throughput shape, and on the 1,000- and 10,000-object Basic Big-O rows, and in 4 of 5 on Basic IPS and the remaining Big-O rows. The fixtures use plain String fields, so they show only the general-path gain; the targeted probe covers block fields and associations.

Targeted probe (1,000 Struct objects, render_as_hash, allocations counted after one warm-up render; time per render is the best of five batches of 50, median over 5 rounds)

Blueprint Allocations, main Allocations, This PR Change Time, main Time, This PR Change
Identifier + 2 blocks + 2 &:sym fields 27,008 3,006 −88.9% 3.52 ms 2.18 ms −38.3%
Identifier + association with default: 7,007 5,005 −28.6% 2.89 ms 2.59 ms −10.4%
Identifier + 4 plain fields 1,007 1,005 −0.2% 2.00 ms 1.83 ms −8.5%

Iterations per second (higher is better)

Benchmark main This PR Change
Basic IPS 61,093 70,151 +14.8%
ActiveRecord IPS 65,605 72,131 +9.9%

Renders per second (higher is better)

Shape main This PR Change
1 object × 6 fields 272,353 285,989 +5.0%
100 objects × 6 fields 3,718 4,084 +9.8%
1,000 objects × 6 fields 365.6 409.3 +12.0%
1,000 objects × 12 fields 186.0 199.9 +7.5%

Microseconds per object (lower is better)

Shape main This PR Change
1 object × 6 fields 3.67 3.50 −4.6%
100 objects × 6 fields 2.69 2.45 −8.9%
1,000 objects × 6 fields 2.74 2.44 −10.9%
1,000 objects × 12 fields 5.38 5.00 −7.1%

Big-O render time, ms (lower is better)

Objects Basic main Basic PR Change ActiveRecord main ActiveRecord PR Change
100 0.232 0.206 −11.2% 0.295 0.274 −7.1%
1,000 1.911 1.730 −9.5% 2.603 2.389 −8.2%
10,000 21.115 18.434 −12.7% 26.828 23.929 −10.8%

Allocations per render (lower is better)

Shape main This PR Change
1 object × 6 fields 10 8 -2
100 objects × 6 fields 108 106 -2
1,000 objects × 6 fields 1,008 1,006 -2
1,000 objects × 12 fields 1,008 1,006 -2

The Big-O rows at 100 objects are one ~0.2-0.3 ms sample per round and the two sides overlap (Basic: main 0.215-0.291 ms, this branch 0.181-0.259 ms; ActiveRecord: main 0.268-0.389 ms, this branch 0.251-0.287 ms), so read the 1,000 and 10,000 rows.

Tests

  • spec/units/block_extractor_spec.rb: Symbol#to_proc blocks get only the object; plain blocks, lambdas (including ->(obj, *rest)), and Method#to_proc blocks still receive local_options.
  • spec/units/auto_extractor_spec.rb: with no format the formatter is skipped and field defaults still apply. Field and global formats still format dates and any object with strftime, a bad value still raises, datetime_format = false still raises, and a global format set after an earlier extract still applies.
  • spec/integrations/base_spec.rb: fields and transformers are fetched once for a collection, and a collection render allocates one object (the result hash) per rendered object. Render options are not mutated, pre_render sees options without :view/:root/:meta, nested view and :options merging still work, and a nested transformer's write to local_options does not reach the parent's later fields, whether or not the views match.
  • spec/units/extensions_spec.rb: the empty-extensions case.

@Hamdy
Hamdy requested review from a team and ritikesh as code owners October 23, 2026 15:49
@Hamdy
Hamdy force-pushed the hamdy/perf branch 2 times, most recently from 9b4272d to 1430904 Compare October 23, 2026 16:00
@Hamdy
Hamdy marked this pull request as draft October 23, 2026 16:04
@Hamdy
Hamdy force-pushed the hamdy/perf branch 4 times, most recently from b3d9d60 to d2d69a0 Compare October 24, 2026 02:23
Hamdy and others added 2 commits October 24, 2026 06:51
prepare_data fetched the view's fields and transformers inside
object_to_hash, so once per object in a collection. It now fetches them
once and passes them down.

Extensions#pre_render returns early when no extensions are registered;
reduce with a block on an empty array still allocates two objects.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>
BlockExtractor called block.parameters for every block field of every
object to tell Symbol#to_proc blocks apart from regular blocks.
Proc#parameters allocates three arrays per call.

Symbol#to_proc is a native lambda taking (obj, *args), so it is the only
kind of block that is a lambda, has arity -2, and has no Ruby source
location. lambda? and arity never allocate, and plain DSL blocks fail
the first one; only a user lambda with arity -2 reaches source_location,
which builds a [file, line] array.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>
@Hamdy
Hamdy force-pushed the hamdy/perf branch 3 times, most recently from c0fccab to a054994 Compare October 24, 2026 04:01
@Hamdy Hamdy changed the title perf: remove per-render allocations from blocks, associations, and nested renders perf: remove per-render allocations and repeated lookups from fields, blocks, and associations Oct 24, 2026
@Hamdy
Hamdy marked this pull request as ready for review October 27, 2026 12:00
Hamdy and others added 2 commits October 27, 2026 16:26
DateTimeFormatter asks every extracted value whether it implements
strftime, including strings and numbers. Skip that unless the field or
the global config sets a format, and keep reading the global format on
each extract so a later change still applies.

Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The render sequence diagram showed fields_for and transformers being
fetched inside the per-object loop; prepare_data now fetches them once
per render. The extension hook example in making-changes.md quotes
Extensions#pre_render, which now returns early with no extensions.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>

@piotrekpasciak piotrekpasciak left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can we add an entry to the CHANGELOG.md file? It may be needed for the upcoming release.

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