Skip to content

perf: compile fields into a cached render pipeline (stacked on #608) - #1

Draft
Hamdy wants to merge 1 commit into
hamdy/fix-memoizationfrom
hamdy/compile-render-pipeline
Draft

Hamdy wants to merge 1 commit into
hamdy/fix-memoizationfrom
hamdy/compile-render-pipeline

Conversation

@Hamdy

@Hamdy Hamdy commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Checklist:

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

Stacked on procore-oss/blueprinter#608 (:if/:unless memoization). Base branch is hamdy/fix-memoization; review that PR first. The tables below isolate what this PR adds on top of procore-oss#608, and the cumulative effect vs main.

Summary

Builds on the memoization fix. Field#compile resolves each field's per-field decisions — extractor, datetime formatting, defaults, :if/:unless — once into a closure, with a fast path for plain fields (public_send/[] read, no conditions/format/default). ViewCollection caches the compiled closures per view alongside :fields/:transformers, and rendering drives object_to_hash from them, hoisting the per-view lookups out of the per-object loop.

To keep the cache honest, Blueprinter::Configuration gains a generation counter bumped by the compiled-attribute writers and reset_configuration!. ViewCollection keys its cache on it and Field keys its condition memoization on it, so global config (datetime_format, field_default, :if/:unless) changed after a render still takes effect on the next render. Config attributes are split into COMPILED_ATTRIBUTES (cache-invalidating) and LIVE_ATTRIBUTES (re-read per render).

image

Results

Measured with this repo's spec/benchmarks/throughput_test.rb (Ruby 3.3.9), median of 3 runs, same harness across all three refs: main (85ce233), hamdy/fix-memoization (procore-oss#608, 2a24d75), and this branch (d98e6b1). main has no benchmark harness, so it was run against a copy of this branch's harness.

All three refs

Metric main hamdy/fix-memoization (procore-oss#608) This PR
µs/object — 1000 × 6 fields 4.12 2.48 0.90
Renders/sec — 1000 × 6 fields 243 404 1,110
Median ms — 1000 × 12 fields 8.52 4.85 1.84
Renders/sec — 1000 × 12 fields 117 206 543

This PR vs hamdy/fix-memoization — what this PR adds

Metric Better procore-oss#608 This PR Change
µs/object — 1000 × 6 fields lower ↓ 2.48 0.90 2.76× faster (−63.7%)
Renders/sec — 1000 × 6 fields higher ↑ 404 1,110 +174.8%
Median ms — 1000 × 12 fields lower ↓ 4.85 1.84 2.64× faster
Renders/sec — 1000 × 12 fields higher ↑ 206 543 +163.6%

This PR vs main — cumulative (memoization + compile pipeline) 🔥

Metric Better main This PR Change
µs/object — 1000 × 6 fields lower ↓ 4.12 0.90 4.58× faster (−78.2%)
Renders/sec — 1000 × 6 fields higher ↑ 243 1,110 +356.8%
Median ms — 1000 × 12 fields lower ↓ 8.52 1.84 4.63× faster
Renders/sec — 1000 × 12 fields higher ↑ 117 543 +364%

The two optimizations are roughly multiplicative: the memoization fix removes per-object condition re-resolution (~1.67×), and the compile fast path removes per-object extractor branching on top of that (~2.7×), for a collective ~4.6× vs main. These are single-machine wall-clock numbers with normal jitter — read them to ~2 significant figures.

image

Correctness — the invalidation caveat

The compiled closures bake in global config resolved at first render, so a stale cache would be a real behaviour bug. The generation counter prevents it, verified end-to-end:

  • spec/integrations/base_spec.rb — render once, then set global field_default / datetime_format / :if; the next render reflects it.
  • spec/units/view_collection_spec.rb — the compiled cache rebuilds when the generation changes.
  • spec/units/field_spec.rb — condition memoization re-resolves on generation change, and does not re-resolve while it is unchanged (the hot path).
  • spec/units/configuration_spec.rb — every COMPILED_ATTRIBUTES writer bumps the generation; no LIVE_ATTRIBUTES writer does.

The fast path is behaviourally equivalent to the extractor path for plain fields: with no format/default configured, AutoExtractor#extract reduces to the raw public_send/[] read.

Verification

bundle exec rspec    # 431 examples, 0 failures
bundle exec rubocop  # 34 files, no offenses

No public API change. No version bump.

…sed invalidation

Builds on the :if/:unless memoization fix. Field#compile resolves each field's
per-field decisions (extractor, datetime formatting, defaults, :if/:unless) once
into a closure, with a fast path for plain fields (public_send/[] read, no
conditions/format/default). ViewCollection caches the compiled closures per view
alongside :fields/:transformers, and rendering drives object_to_hash from them,
hoisting the per-view lookups out of the per-object loop.

To keep this safe, Blueprinter::Configuration gains a generation counter bumped
by the compiled-attribute writers and reset_configuration!; ViewCollection keys
its cache on it and Field keys its condition memoization on it, so global config
(datetime_format, field_default, :if/:unless) changed after a render still takes
effect on the next render. Attributes are split into COMPILED_ATTRIBUTES (cache
invalidating) and LIVE_ATTRIBUTES (re-read per render).

~2.7x faster renders on top of the memoization fix (~4.5x vs main).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: hamdy <hamdy.abdelbadeea@procore.com>
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