Skip to content

perf: Optimize field access to eliminate memory allocations - #6112

Open
skcc321 wants to merge 2 commits into
mongodb:masterfrom
skcc321:perf/optimize-field-access-alternative
Open

perf: Optimize field access to eliminate memory allocations#6112
skcc321 wants to merge 2 commits into
mongodb:masterfrom
skcc321:perf/optimize-field-access-alternative

Conversation

@skcc321

@skcc321 skcc321 commented Feb 21, 2026

Copy link
Copy Markdown

Summary

This PR reduces allocations and improves Mongoid field-access performance through:

  • Fast paths for common, non-nested field operations.
  • Direct access paths for inexpensive scalar field types.
  • Avoiding projection work for documents without selected fields.
  • Optional caching of expensive demongoized values.
  • Lazy cache allocation and targeted cache invalidation.

Demongoized-value caching is opt-in and disabled by default:

Mongoid.configure do |config|
  config.cache_attribute_values = true
end

The configuration should be set during application initialization and should not be changed at runtime.

Motivation

Reading a Mongoid field currently involves several generic operations, even for ordinary scalar fields:

  • Splitting and traversing simple field names.
  • Constructing Projector instances when no projection is active.
  • Repeatedly demongoizing field values.
  • Repeating field metadata and relation lookups.
  • Allocating intermediate arrays and hashes.

These costs become significant in applications that repeatedly access fields across large numbers of documents.

The implementation deliberately separates two kinds of optimization:

  1. Fast paths that benefit normal field access regardless of caching.
  2. Optional caching for types where repeated demongoization is more expensive than a cache lookup.

Changes

1. Fast paths for simple field names

The common case is a field name without dot notation. The following methods now avoid splitting strings and constructing intermediate arrays for that case:

  • cleanse_localized_field_names
  • traverse_association_tree
  • database_field_name

Nested and association paths continue to use the existing traversal logic.

2. Projection fast path

attribute_missing? now immediately returns false when the document has no selected fields.

This avoids constructing or looking up a Projector during ordinary field access. Documents loaded with .only or .without continue to use projection checks, with lazily cached Projector instances when caching is enabled.

3. Scalar field getter fast paths

Repeated demongoization is not beneficial for types where the stored value is already the expected Ruby value.

The generated getters use direct raw-value access for safe scalar cases, including:

  • String
  • Integer
  • Float
  • Symbol
  • Mongoid::Boolean
  • BSON::ObjectId identifier fields

The direct path is only used when the raw value is already compatible with the declared field type. Values that still require conversion continue through process_raw_attribute.

Localized fields also continue through the normal demongoization path because their result depends on I18n.locale.

4. Optional demongoized-value caching

When cache_attribute_values is enabled, Mongoid caches repeated demongoization for selected types where conversion is relatively expensive:

  • Array
  • Hash
  • Date
  • DateTime
  • Time
  • Range
  • Set

Each entry stores the raw value reference together with its demongoized result and any validity metadata required by the field type:

  • Time and DateTime entries retain the UTC configuration and current thread's Time.zone, so changing the demongoization context refreshes the cached value.
  • Set and Range entries retain a deep snapshot of their raw Array or Hash, so in-place raw-value mutations refresh the cached value.

Replacing the raw value, changing relevant demongoization context, mutating tracked raw content, or writing through Mongoid's attribute APIs causes the cached value to be recomputed.

Cheap scalar types intentionally bypass this cache because a cache lookup would cost more than their normal conversion.

5. Cache invalidation

Cached values are invalidated through the normal Mongoid mutation paths, including:

  • Attribute writers
  • Attribute removal and unsetting
  • Renaming
  • Default application
  • Atomic increment, multiply, bit, push, pull, pop, and related operations
  • Document reload

Invalidation is field-specific so modifying one attribute does not discard unrelated cached values.

6. Lazy cache allocation and stable document shape

When caching is enabled, cache instance variables are initialized to nil in every document construction path:

  • New documents
  • Documents instantiated from database results
  • Reloaded documents

The underlying hashes are allocated only when first needed. This keeps document object shapes consistent while avoiding cache allocations for documents that never use them.

7. Benchmark improvements

perf/benchmark_cache_attribute_values.rb now runs three configurations:

  • Current branch with caching enabled
  • Current branch with caching disabled
  • Local master baseline

This separates improvements caused by the general field-access fast paths from improvements caused specifically by demongoized-value caching.

The benchmark also reports dedicated read, write, projection, dotted-field, and mutable-value scenarios.

Run it with:

./perf/benchmark_cache_attribute_values.rb

For a shorter validation run:

MONGOID_BENCH_REPETITIONS=1 \
MONGOID_BENCH_MEM_TIME=1 \
MONGOID_BENCH_MEM_WARMUP=0 \
./perf/benchmark_cache_attribute_values.rb

Performance Results

Representative local results, reported as median iterations per second; higher is better:

Test Cache enabled Cache disabled Master
String 10x 98.6k 90.4k 42.7k
Time 10x 50.6k 12.3k 11.8k
Date 10x 65.7k 35.4k 36.2k
Range 10x 55.8k 33.9k 34.0k

The three-way comparison shows two distinct effects:

  • Scalar fields such as String improve primarily because of the general getter and projection fast paths. Cache-enabled and cache-disabled results are similar.
  • Time, Date, and Range benefit from caching because their demongoization is more expensive.
  • Results vary across Ruby versions and hardware, so the benchmark reports raw iterations per second rather than presenting the percentages as universal application-level improvements.

The benchmark includes write-only and write-then-read cases so the read/write trade-offs remain visible.

Allocation Results

With allocation_stats available on MRI, the performance specifications verify zero allocations after warm-up for the tested native getter paths, including:

  • String
  • Integer
  • Float
  • Boolean
  • Symbol
  • BSON::ObjectId
  • Array
  • Hash
  • Date
  • Time
  • Range
  • Set

These checks cover both newly constructed and database-loaded documents where applicable.

Test Coverage

The added coverage includes:

  • Allocation behavior for common field types
  • Cache-enabled and cache-disabled configurations
  • Scalar getter fast paths
  • Custom identifier fields
  • Getter-after-setter behavior
  • Cache invalidation
  • Time-zone-aware Time and DateTime cache refreshes
  • In-place raw-value mutation detection for Set and Range
  • Atomic mutation operations
  • Lazy defaults
  • Database-loaded documents
  • Document reload
  • Field projections with .only and .without
  • Localized fields
  • Mutable array and hash access
  • Embedded documents
  • Concurrent field and projector access

CI runs the Mongoid suite with attribute-value caching both disabled and enabled.

Compatibility

Caching is disabled by default, so existing applications retain the non-cached behavior unless they explicitly enable cache_attribute_values.

The simple-field, scalar getter, and non-projected document fast paths are active independently of the cache setting.

No public APIs are removed or renamed.

Related Work

This PR addresses allocation and execution-time overhead in high-throughput field access while making the cost of optional caching measurable independently from the general field-access optimizations.

Copilot AI review requested due to automatic review settings February 21, 2026 21:18
@skcc321
skcc321 requested a review from a team as a code owner February 21, 2026 21:18
@skcc321
skcc321 requested a review from jamis February 21, 2026 21:18

Copilot AI left a comment

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.

Pull request overview

This PR introduces a demongoized-value caching layer and several fast paths to reduce allocations and speed up repeated field/attribute access in Mongoid documents, plus adds a dedicated performance spec suite to validate the allocation improvements.

Changes:

  • Cache demongoized field values in generated getters (with invalidation on writes and atomic operations) and add fast paths for common “no dot” field-name cases.
  • Add per-document caches for projection checks (attribute_missing?) and initialize caches early for consistent object shape.
  • Add MRI-only allocation_stats dependency and a comprehensive performance spec to assert zero allocations after warm-up.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
spec/mongoid/fields/performance_spec.rb Adds allocation-focused regression/performance tests for field access, caching, invalidation, and concurrency.
lib/mongoid/fields.rb Implements fast paths and demongoized-value caching in generated field getters.
lib/mongoid/document.rb Initializes per-document caches early (Concurrent::Map) to keep object shape stable and support cache usage.
lib/mongoid/attributes.rb Adds demongoized-cache invalidation helper and caches projectors for attribute_missing?.
lib/mongoid/stateful.rb Resets caches when resetting readonly/projection state (used during reload).
lib/mongoid/persistable/unsettable.rb Clears demongoized cache when unsetting attributes in-memory.
lib/mongoid/persistable/renamable.rb Clears demongoized cache entries for old/new names on rename.
lib/mongoid/persistable/pushable.rb Clears demongoized cache when mutating array fields via $addToSet/$push.
lib/mongoid/persistable/pullable.rb Clears demongoized cache when mutating array fields via $pull/$pullAll.
lib/mongoid/persistable/poppable.rb Clears demongoized cache when mutating array fields via $pop.
lib/mongoid/persistable/multipliable.rb Clears demongoized cache when mutating numeric fields via $mul.
lib/mongoid/persistable/logical.rb Clears demongoized cache when mutating numeric fields via $bit.
lib/mongoid/persistable/incrementable.rb Clears demongoized cache when mutating numeric fields via $inc.
Gemfile Adds allocation_stats for MRI to support the new allocation specs.

Comment thread lib/mongoid/attributes.rb Outdated
Comment thread lib/mongoid/attributes.rb

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Comment thread spec/mongoid/fields/performance_spec.rb
skcc321 added a commit to skcc321/mongoid that referenced this pull request Feb 21, 2026
Move database query outside thread loop to properly test concurrent access
to the same document instance. This eliminates 1000 DB queries, reduces
connection pool contention, and makes the test faster and more reliable.

Addresses review comment: mongodb#6112 (comment)
@skcc321
skcc321 requested a review from Copilot February 22, 2026 12:10

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Comment thread lib/mongoid/attributes.rb Outdated
Comment thread lib/mongoid/fields.rb Outdated
Comment thread lib/mongoid/config.rb

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comment thread lib/mongoid/config.rb Outdated

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.

Comment thread lib/mongoid/fields.rb Outdated
Comment thread lib/mongoid/fields.rb Outdated

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Comment thread spec/spec_helper.rb Outdated
Comment thread lib/mongoid/document.rb

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 6 comments.

Comment thread spec/mongoid/config_spec.rb Outdated
Comment thread spec/mongoid/fields/performance_spec.rb
Comment thread spec/mongoid/fields/performance_spec.rb Outdated
Comment thread lib/mongoid/fields.rb Outdated
Comment thread lib/mongoid/attributes.rb Outdated
Comment thread lib/mongoid/attributes.rb

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Comment thread spec/spec_helper.rb Outdated
Comment thread spec/mongoid/fields/performance_spec.rb
Comment thread lib/mongoid/config.rb Outdated

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

@skcc321

skcc321 commented Feb 26, 2026

Copy link
Copy Markdown
Author

@jamis is there any issue with the CI itself? I got all tests green on my local.

@jamis

jamis commented Mar 3, 2026

Copy link
Copy Markdown
Contributor

@jamis is there any issue with the CI itself? I got all tests green on my local.

Apologies; the team was sick last week and we're catching up now. The CI issue has been fixed; I've merged the changes into your branch. Let's see how it looks now!

@skcc321

skcc321 commented Mar 4, 2026

Copy link
Copy Markdown
Author

@jamis is there any issue with the CI itself? I got all tests green on my local.

Apologies; the team was sick last week and we're catching up now. The CI issue has been fixed; I've merged the changes into your branch. Let's see how it looks now!

NP. Thank you, @jamis

@jamis jamis left a comment

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.

This looks really promising! Your work has been thorough, and the implementation looks solid. Thank you very much.

I've added a few comments that should be addressed, but overall this looks promising.

In the PR description you show the difference in allocations, which is great. Could you also do some timing benchmarks to show how your changes measure up in that respect?

Comment thread lib/mongoid/fields.rb Outdated
Comment on lines +355 to +369
aliased = key
if aliased_associations && a = aliased_associations.fetch(key, nil)
aliased = a.to_s
end

if fields && f = fields[aliased]
yield(key, f, true) if block_given?
return f
elsif associations && rel = associations[aliased]
yield(key, rel, false) if block_given?
return nil
else
yield(key, nil, false) if block_given?
return nil
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.

Given that this basically duplicates the code below (basically lines 386-401), I think it might be preferable to refactor both sections, pulling the duplicated code out into a method. I know there's some performance overhead associated with method invocations, but unless that overhead is significant, I think it's worth it to avoid duplicating code.

Comment thread lib/mongoid/fields.rb Outdated
Comment on lines +711 to +712
@__demongoized_cache[name] = [raw, demongoized]
value = [raw, demongoized]

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.

Rather than creating the same tuple twice, you could just assign value to the previous assignment:

Suggested change
@__demongoized_cache[name] = [raw, demongoized]
value = [raw, demongoized]
value = @__demongoized_cache[name] = [raw, demongoized]

Comment thread spec/mongoid/fields/performance_spec.rb Outdated

reloaded = Band.find(band.id)

if Mongoid::Config.use_utc?

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.

Rather than testing for this here and below, would it be better to set this explicitly for this context? Otherwise the tests do nothing. Mongoid has a helper, config_override, that can set (and finally unset) a configuration setting in a test:

context 'Time field transformations' do
  config_override :use_utc, true
  # ...
end

Comment thread spec/mongoid/fields/performance_spec.rb Outdated
band.updated # First read - caches value
band.updated # Second read - from cache

expect(band.updated.utc?).to be(true) if Mongoid::Config.use_utc?

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.

Same comment as above (re: use_utc).

Comment thread spec/spec_helper.rb Outdated
# 1. All existing tests pass with the optimization active
# 2. No regressions are introduced by the caching layer
# 3. The feature is production-ready when users opt-in
config.cache_attribute_values = true

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.

It is very important (for hopefully obvious reasons) that our default configuration be tested by default. But it is also important that we test this new caching behavior rigorously.

Rather than hard-coding this to true, here, let's have it depend on an environment variable (e.g. MONGOID_CACHING_ENABLED or some such). If that variable is e.g. 1, the configuration option is set to true. Otherwise it is set to false.

Then, we can adjust the CI (in .github/workflows/test.yml, to start, and eventually to our evergreen config as well) to add that variable to the matrix.

Comment thread Gemfile Outdated
Comment on lines +21 to +23
platforms :mri do
gem 'allocation_stats', require: false
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.

Please move this to the gemfiles/standard.rb file, which is where we keep the majority of our dependency declarations. Make sure it goes into the :test group there.

skcc321 added a commit to skcc321/mongoid that referenced this pull request Mar 11, 2026
Move database query outside thread loop to properly test concurrent access
to the same document instance. This eliminates 1000 DB queries, reduces
connection pool contention, and makes the test faster and more reliable.

Addresses review comment: mongodb#6112 (comment)
@skcc321
skcc321 force-pushed the perf/optimize-field-access-alternative branch from ece0a28 to 25c777e Compare March 11, 2026 19:38
@skcc321

skcc321 commented Mar 11, 2026

Copy link
Copy Markdown
Author

This looks really promising! Your work has been thorough, and the implementation looks solid. Thank you very much.

I've added a few comments that should be addressed, but overall this looks promising.

In the PR description, you show the difference in allocations, which is great. Could you also do some timing benchmarks to show how your changes measure up in that respect?

Here are the benchmark results I got:

================================================================================
BENCHMARK COMPARISON: Current Branch vs Master
================================================================================

I:
Test                                   Current          Master  Improvement
--------------------------------------------------------------------------------
String 10x                               0.10M           0.07M       +43.1%
Integer 10x                              0.10M           0.07M       +33.9%
Float 10x                                0.10M           0.08M       +28.4%
Boolean 10x                              0.10M           0.08M       +25.9%
Date 10x                                 0.09M           0.08M       +25.2%
Time 10x                                 0.10M           0.03M      +279.5%
Hash 10x                                 0.09M           0.08M       +16.6%
Array 10x                                0.09M           0.08M        +8.7%
Range 10x                                0.09M           0.06M       +49.9%
BSON::ObjectId 10x                       0.08M           0.08M        +4.1%
iterate embedded (5 docs)                0.06M           0.04M       +35.5%
write then read                          0.18M           0.23M       -23.2%
================================================================================

II:
Test                                   Current          Master  Improvement
--------------------------------------------------------------------------------
String 10x                               0.10M           0.07M       +52.3%
Integer 10x                              0.10M           0.08M       +30.8%
Float 10x                                0.10M           0.08M       +28.8%
Boolean 10x                              0.10M           0.08M       +24.2%
Date 10x                                 0.10M           0.08M       +29.6%
Time 10x                                 0.10M           0.03M      +258.1%
Hash 10x                                 0.09M           0.08M       +14.3%
Array 10x                                0.09M           0.08M       +10.7%
Range 10x                                0.09M           0.06M       +45.0%
BSON::ObjectId 10x                       0.08M           0.08M        +3.8%
iterate embedded (5 docs)                0.06M           0.04M       +32.9%
write then read                          0.18M           0.23M       -22.8%

The benchmark script is present in the PR - perf/benchmark_cache_attribute_values.rb.
write then read is interesting. The -23% overhead applies to any attribute mutation. This is significant for
write-heavy workloads.

I have rebased the branch, so the history is a bit messy now.
The last three commits were added after the review.
If preferred, I can open a new PR with a single squashed commit.

Also, I’m considering avoiding some overhead during mutations.

@jamis

jamis commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Sorry to be so slow to get back to this. I tried running the performance benchmarks locally, and got fairly different results:

================================================================================
BENCHMARK COMPARISON: Current Branch vs Master
================================================================================

Test                                   Current          Master  Improvement
--------------------------------------------------------------------------------
String 10x                               0.13M           0.13M        +3.0%
Integer 10x                              0.13M           0.12M       +11.4%
Float 10x                                0.13M           0.12M       +13.5%
Boolean 10x                              0.13M           0.13M        -3.2%
Date 10x                                 0.13M           0.11M       +10.0%
Time 10x                                 0.12M           0.04M      +199.9%
Hash 10x                                 0.12M           0.14M       -14.3%
Array 10x                                0.12M           0.13M       -12.8%
Range 10x                                0.12M           0.11M        +7.2%
BSON::ObjectId 10x                       0.11M           0.14M       -21.6%
iterate embedded (5 docs)                0.07M           0.07M        +0.5%
write then read                          0.22M           0.33M       -31.8%

================================================================================
BENCHMARK COMPARISON: Current Branch vs Master
================================================================================

Test                                   Current          Master  Improvement
--------------------------------------------------------------------------------
String 10x                               0.13M           0.13M        -1.9%
Integer 10x                              0.13M           0.12M        +7.5%
Float 10x                                0.13M           0.12M        +7.5%
Boolean 10x                              0.13M           0.13M        -6.3%
Date 10x                                 0.12M           0.11M        +7.0%
Time 10x                                 0.12M           0.04M      +193.3%
Hash 10x                                 0.11M           0.14M       -15.4%
Array 10x                                0.11M           0.13M       -14.2%
Range 10x                                0.11M           0.11M        +6.3%
BSON::ObjectId 10x                       0.11M           0.14M       -23.0%
iterate embedded (5 docs)                0.07M           0.07M        -1.7%
write then read                          0.23M           0.33M       -29.1%
================================================================================

It's not very clear what the +/- refers to -- I'm assuming a higher positive number is better, possibly referring to a larger number of operations per second? The only really consistent win is with the time type, which is very significant. For the others, master often seems to either outperform, or marginally underperform, your branch.

Am I reading this right?

Implement direct caching of demongoized field values on document instances
to achieve zero memory allocations on repeated field access.

Key optimizations:
- Add @__demongoized_cache (Concurrent::Map) to each document for thread-safe
  value caching
- Cache stores [raw_value, demongoized_value] tuples to detect stale cache
- Invalidate cache on field writes and atomic operations
- Handle edge cases: lazy defaults, localized fields, resizable values
- Initialize field caches early for consistent object shape (JIT optimization)

Performance improvements:
- Zero allocations for cached field reads (String, Integer, Float, etc.)
- Proper change tracking for resizable fields (Arrays, Hashes)
- Thread-safe concurrent access using Concurrent::Map
- Object shape consistency for JIT compiler optimization

Testing:
- Add comprehensive performance_spec.rb with 53 test cases
- Cover allocation optimizations, cache invalidation, edge cases
- Verify behavior with database-loaded documents and atomic operations
- Test concurrent access patterns and resizable field mutations

This optimization is always active and provides significant performance
gains while maintaining full backward compatibility.
@skcc321
skcc321 force-pushed the perf/optimize-field-access-alternative branch from 20d3251 to 8c3d5b7 Compare July 24, 2026 20:58
@skcc321
skcc321 requested a review from Copilot July 24, 2026 21:03

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Comment thread lib/mongoid/attributes.rb
Comment thread lib/mongoid/config.rb
Comment on lines +250 to +252
# This optimization can significantly improve performance for fields with
# expensive demongoization (e.g., Time, Date, custom types), especially
# in read-heavy workloads.
Comment on lines +448 to +450
# Bundle install might be needed if dependencies differ
puts "Ensuring dependencies are installed on master..."
system("bundle", "install", "--quiet", chdir: master_worktree, out: File::NULL, err: File::NULL)

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Comment on lines +875 to +878
# Each access should call process_raw_attribute
first_result = band.updated
second_result = band.updated

Comment on lines +879 to +882
# When caching is disabled, cache objects should not be initialized
expect(band.instance_variable_get(:@__demongoized_cache)).to be_nil
expect(band.instance_variable_get(:@__projector_cache)).to be_nil
end
Comment thread lib/mongoid/document.rb
Comment on lines +244 to +250
# Initialize field cache instance variables to ensure consistent object shape.
#
# Initializes @__projector_cache and @__demongoized_cache early in all document
# creation paths. This ensures all documents have the same instance variable
# layout from the start, allowing Ruby's JIT compilers (YJIT, MJIT) to generate
# optimized code. Without this, lazy cache creation would cause shape polymorphism,
# preventing JIT optimizations.
@skcc321

skcc321 commented Jul 24, 2026

Copy link
Copy Markdown
Author

Sorry to be so slow to get back to this. I tried running the performance benchmarks locally, and got fairly different results:

================================================================================
BENCHMARK COMPARISON: Current Branch vs Master
================================================================================

Test                                   Current          Master  Improvement
--------------------------------------------------------------------------------
String 10x                               0.13M           0.13M        +3.0%
Integer 10x                              0.13M           0.12M       +11.4%
Float 10x                                0.13M           0.12M       +13.5%
Boolean 10x                              0.13M           0.13M        -3.2%
Date 10x                                 0.13M           0.11M       +10.0%
Time 10x                                 0.12M           0.04M      +199.9%
Hash 10x                                 0.12M           0.14M       -14.3%
Array 10x                                0.12M           0.13M       -12.8%
Range 10x                                0.12M           0.11M        +7.2%
BSON::ObjectId 10x                       0.11M           0.14M       -21.6%
iterate embedded (5 docs)                0.07M           0.07M        +0.5%
write then read                          0.22M           0.33M       -31.8%

================================================================================
BENCHMARK COMPARISON: Current Branch vs Master
================================================================================

Test                                   Current          Master  Improvement
--------------------------------------------------------------------------------
String 10x                               0.13M           0.13M        -1.9%
Integer 10x                              0.13M           0.12M        +7.5%
Float 10x                                0.13M           0.12M        +7.5%
Boolean 10x                              0.13M           0.13M        -6.3%
Date 10x                                 0.12M           0.11M        +7.0%
Time 10x                                 0.12M           0.04M      +193.3%
Hash 10x                                 0.11M           0.14M       -15.4%
Array 10x                                0.11M           0.13M       -14.2%
Range 10x                                0.11M           0.11M        +6.3%
BSON::ObjectId 10x                       0.11M           0.14M       -23.0%
iterate embedded (5 docs)                0.07M           0.07M        -1.7%
write then read                          0.23M           0.33M       -29.1%
================================================================================

It's not very clear what the +/- refers to -- I'm assuming a higher positive number is better, possibly referring to a larger number of operations per second? The only really consistent win is with the time type, which is very significant. For the others, master often seems to either outperform, or marginally underperform, your branch.

Am I reading this right?

Hello @jamis
Yes, you’re reading it correctly (higher - better). The results look inconsistent, which may partly be caused by variability on my machine—an Intel-based Mac that tends to run quite hot during benchmarks.

I’ve made some further improvements since then. In particular, some scalar values are no longer cached because accessing them directly is already inexpensive, so caching them adds unnecessary overhead.

Here are the updated benchmark results comparing:

{0:05}~/Documents/Code/mongoid:perf/optimize-field-access-alternative ✓ ➭ ./perf/benchmark_cache_attribute_values.rb
Running benchmark on current branch with cache enabled (2 runs)...
  cache enabled run 1/2
  cache enabled run 2/2

Running benchmark on current branch with cache disabled (2 runs)...
  cache disabled run 1/2
  cache disabled run 2/2

Preparing temporary master worktree...
Ensuring dependencies are installed on master...
Running benchmark on master branch (2 runs)...
  master run 1/2
  master run 2/2

===============================================================================
BENCHMARK COMPARISON (median): Cache Enabled vs Cache Disabled vs Master
===============================================================================

[ Core Field Reads ]
Test                              Cache enabled    Cache disabled        Master
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━
String 10x                                94.4k             93.8k         41.6k
──────────────────────────────  ───────────────  ────────────────  ────────────
Integer 10x                               94.0k             92.2k         39.6k
──────────────────────────────  ───────────────  ────────────────  ────────────
Float 10x                                 95.5k             90.6k         39.3k
──────────────────────────────  ───────────────  ────────────────  ────────────
Boolean 10x                               92.0k             90.2k         45.4k
──────────────────────────────  ───────────────  ────────────────  ────────────
Date 10x                                  63.3k             36.7k         35.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
Time 10x                                  46.5k             12.3k         11.7k
──────────────────────────────  ───────────────  ────────────────  ────────────
Hash 10x                                  57.2k             47.8k         45.3k
──────────────────────────────  ───────────────  ────────────────  ────────────
Array 10x                                 55.7k             45.3k         45.6k
──────────────────────────────  ───────────────  ────────────────  ────────────
Range 10x                                 50.5k             29.5k         32.3k
──────────────────────────────  ───────────────  ────────────────  ────────────
BSON::ObjectId 10x                        85.0k             79.6k         43.5k
──────────────────────────────  ───────────────  ────────────────  ────────────
iterate embedded (5 docs)                 46.4k             48.0k         22.8k

[ Read/Write Scenarios ]
Test                              Cache enabled    Cache disabled        Master
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━
write then read                          133.8k            139.1k        104.1k
──────────────────────────────  ───────────────  ────────────────  ────────────
cold string read                         916.4k            867.4k        422.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
dotted field read 10x                     24.6k             24.0k         23.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
projected field access 10x                33.3k             34.0k         26.1k
──────────────────────────────  ───────────────  ────────────────  ────────────
string write only                        130.5k            135.7k        118.5k
──────────────────────────────  ───────────────  ────────────────  ────────────
scalar replace then read                 112.3k            113.7k         80.7k
──────────────────────────────  ───────────────  ────────────────  ────────────
array in-place mutate then read           280.8k            217.2k        203.5k
──────────────────────────────  ───────────────  ────────────────  ────────────
hash in-place mutate then read           163.0k            134.2k        126.6k
──────────────────────────────  ───────────────  ────────────────  ────────────
string write then read                   112.3k            114.0k         83.7k

Legend:
  Cache enabled  - current branch with cache_attribute_values=true.
  Cache disabled - current branch with cache_attribute_values=false; all other branch optimizations remain active.
  Master         - local master branch baseline, where attribute-value caching is unavailable.
  Values         - median iterations per second across independent process runs; higher is better.

another run

===============================================================================
BENCHMARK COMPARISON (median): Cache Enabled vs Cache Disabled vs Master
===============================================================================

[ Core Field Reads ]
Test                              Cache enabled    Cache disabled        Master
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━
String 10x                                92.9k             89.2k         40.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
Integer 10x                               91.1k             90.2k         39.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
Float 10x                                 92.5k             90.5k         37.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
Boolean 10x                               89.4k             86.0k         44.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
Date 10x                                  62.6k             36.5k         35.3k
──────────────────────────────  ───────────────  ────────────────  ────────────
Time 10x                                  46.1k             11.8k         11.7k
──────────────────────────────  ───────────────  ────────────────  ────────────
Hash 10x                                  57.2k             45.8k         43.0k
──────────────────────────────  ───────────────  ────────────────  ────────────
Array 10x                                 58.1k             46.1k         43.7k
──────────────────────────────  ───────────────  ────────────────  ────────────
Range 10x                                 53.5k             34.3k         32.8k
──────────────────────────────  ───────────────  ────────────────  ────────────
BSON::ObjectId 10x                        84.1k             78.3k         43.6k
──────────────────────────────  ───────────────  ────────────────  ────────────
iterate embedded (5 docs)                 46.0k             44.7k         22.5k

[ Read/Write Scenarios ]
Test                              Cache enabled    Cache disabled        Master
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━
write then read                          136.1k            135.6k        101.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
cold string read                         846.0k            872.1k        413.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
dotted field read 10x                     22.6k             23.5k         23.7k
──────────────────────────────  ───────────────  ────────────────  ────────────
projected field access 10x                32.7k             33.0k         25.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
string write only                        133.5k            131.7k        120.6k
──────────────────────────────  ───────────────  ────────────────  ────────────
scalar replace then read                 111.6k            108.7k         83.4k
──────────────────────────────  ───────────────  ────────────────  ────────────
array in-place mutate then read           278.8k            220.6k        182.1k
──────────────────────────────  ───────────────  ────────────────  ────────────
hash in-place mutate then read           160.9k            134.6k        115.7k
──────────────────────────────  ───────────────  ────────────────  ────────────
string write then read                   112.7k            111.7k         81.4k

Legend:
  Cache enabled  - current branch with cache_attribute_values=true.
  Cache disabled - current branch with cache_attribute_values=false; all other branch optimizations remain active.
  Master         - local master branch baseline, where attribute-value caching is unavailable.
  Values         - median iterations per second across independent process runs; higher is better.
===============================================================================

the third run

===============================================================================
BENCHMARK COMPARISON (median): Cache Enabled vs Cache Disabled vs Master
===============================================================================

[ Core Field Reads ]
Test                              Cache enabled    Cache disabled        Master
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━
String 10x                                89.7k             98.3k         37.6k
──────────────────────────────  ───────────────  ────────────────  ────────────
Integer 10x                               89.5k             91.8k         37.5k
──────────────────────────────  ───────────────  ────────────────  ────────────
Float 10x                                 89.1k             94.7k         37.8k
──────────────────────────────  ───────────────  ────────────────  ────────────
Boolean 10x                               87.1k             94.5k         44.0k
──────────────────────────────  ───────────────  ────────────────  ────────────
Date 10x                                  61.5k             37.7k         33.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
Time 10x                                  46.7k             12.2k         10.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
Hash 10x                                  57.3k             49.7k         42.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
Array 10x                                 55.8k             49.0k         39.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
Range 10x                                 50.6k             33.8k         29.0k
──────────────────────────────  ───────────────  ────────────────  ────────────
BSON::ObjectId 10x                        82.9k             82.0k         40.6k
──────────────────────────────  ───────────────  ────────────────  ────────────
iterate embedded (5 docs)                 45.0k             38.7k         21.3k

[ Read/Write Scenarios ]
Test                              Cache enabled    Cache disabled        Master
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━
write then read                          137.9k            121.7k         94.7k
──────────────────────────────  ───────────────  ────────────────  ────────────
cold string read                         815.9k            842.4k        397.0k
──────────────────────────────  ───────────────  ────────────────  ────────────
dotted field read 10x                     23.4k             23.9k         22.3k
──────────────────────────────  ───────────────  ────────────────  ────────────
projected field access 10x                32.3k             35.7k         23.4k
──────────────────────────────  ───────────────  ────────────────  ────────────
string write only                        130.4k            128.8k        105.8k
──────────────────────────────  ───────────────  ────────────────  ────────────
scalar replace then read                 106.6k            107.8k         76.5k
──────────────────────────────  ───────────────  ────────────────  ────────────
array in-place mutate then read           272.3k            229.4k        199.0k
──────────────────────────────  ───────────────  ────────────────  ────────────
hash in-place mutate then read           156.1k            142.2k        119.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
string write then read                   106.6k            112.0k         81.1k

Legend:
  Cache enabled  - current branch with cache_attribute_values=true.
  Cache disabled - current branch with cache_attribute_values=false; all other branch optimizations remain active.
  Master         - local master branch baseline, where attribute-value caching is unavailable.
  Values         - median iterations per second across independent process runs; higher is better.

Benchmark environment

The benchmark was run with:

  ./perf/benchmark_cache_attribute_values.rb

Host system

  • Machine: MacBook Pro (MacBookPro16,1)
  • CPU: Intel Core i9-9980HK @ 2.40 GHz
  • CPU cores: 8 physical / 16 logical
  • Memory: 32 GB
  • OS: macOS 26.5.2
  • Kernel: Darwin 25.5.0
  • Architecture: x86_64

Ruby environment

  • Ruby: 3.3.7
  • Bundler: 2.5.22
  • Mongoid: 9.1.0
  • MongoDB Ruby driver: 2.23.0
  • BSON: 5.2.0
  • benchmark-ips: 2.14.0

Ruby and the benchmark process ran directly on the macOS host.

MongoDB environment

  • MongoDB server: 8.2.5
  • Topology: standalone
  • Docker image: mongo:8
  • Container: mongoid-mongo
  • Connection: localhost:27017
  • Port mapping: host 27017 → container 27017
  • Docker Engine: 28.4.0
  • Container runtime: Colima 0.10.3
  • Docker VM: Ubuntu 24.04.2 LTS, x86_64
  • Docker VM resources: 4 vCPUs and approximately 6 GB RAM
  • Container-specific limits: none

Benchmark configuration

Unless overridden through environment variables, the script uses:

  • Repetitions: 2 independent process runs per comparison mode

  • In-memory benchmark warmup: 1 second per benchmark case

  • In-memory benchmark measurement time: 3 seconds per benchmark case

  • Database: mongoid_perf_field_cache at localhost:27017

  • Garbage collection: run three times before benchmarking, disabled during the in-memory microbenchmarks, and re-enabled afterward

  • Comparison modes, run in this fixed order:

    • Current branch with attribute caching enabled
    • Current branch with attribute caching disabled
    • master baseline
  • Result aggregation: median iterations per second across the repetitions

The benchmark creates an isolated temporary worktree for the master baseline. The enabled and disabled cache runs use a fixed order; they are not counterbalanced.

Side notes

I updated the PR description to reflect the current state of changes and squashed all commits into a single one, also rebased with the master branch.

@skcc321

skcc321 commented Jul 28, 2026

Copy link
Copy Markdown
Author

one more perf run after re-introducion of Concurrent::Map

{14:13}~/Documents/Code/mongoid:perf/optimize-field-access-alternative ✗ ➭   MONGOID_BENCH_REPETITIONS=5 \
  MONGOID_BENCH_MEM_WARMUP=2 \
  MONGOID_BENCH_MEM_TIME=5 \
  ./perf/benchmark_cache_attribute_values.rb

Running benchmark on current branch with cache enabled (5 runs)...
  cache enabled run 1/5
  cache enabled run 2/5
  cache enabled run 3/5
  cache enabled run 4/5
  cache enabled run 5/5

Running benchmark on current branch with cache disabled (5 runs)...
  cache disabled run 1/5
  cache disabled run 2/5
  cache disabled run 3/5
  cache disabled run 4/5
  cache disabled run 5/5

Preparing temporary master worktree...
Ensuring dependencies are installed on master...
Running benchmark on master branch (5 runs)...
  master run 1/5
  master run 2/5
  master run 3/5
  master run 4/5
  master run 5/5

===============================================================================
BENCHMARK COMPARISON (median): Cache Enabled vs Cache Disabled vs Master
===============================================================================

[ Core Field Reads ]
Test                              Cache enabled    Cache disabled        Master
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━
String 10x                                99.3k             99.4k         43.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
Integer 10x                               97.2k             97.6k         40.7k
──────────────────────────────  ───────────────  ────────────────  ────────────
Float 10x                                 98.6k             98.3k         41.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
Boolean 10x                               93.8k             97.1k         47.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
Date 10x                                  63.9k             39.5k         37.3k
──────────────────────────────  ───────────────  ────────────────  ────────────
Time 10x                                  48.5k             13.3k         12.3k
──────────────────────────────  ───────────────  ────────────────  ────────────
Hash 10x                                  59.0k             52.1k         46.3k
──────────────────────────────  ───────────────  ────────────────  ────────────
Array 10x                                 59.1k             50.4k         46.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
Range 10x                                 54.5k             37.5k         34.9k
──────────────────────────────  ───────────────  ────────────────  ────────────
BSON::ObjectId 10x                        90.8k             89.3k         45.7k
──────────────────────────────  ───────────────  ────────────────  ────────────
iterate embedded (5 docs)                 50.8k             49.4k         23.9k

[ Read/Write Scenarios ]
Test                              Cache enabled    Cache disabled        Master
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━
write then read                          137.0k            148.5k        107.5k
──────────────────────────────  ───────────────  ────────────────  ────────────
cold string read                         943.7k            892.0k        425.4k
──────────────────────────────  ───────────────  ────────────────  ────────────
dotted field read 10x                     25.8k             25.0k         24.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
projected field access 10x                34.7k             36.7k         28.5k
──────────────────────────────  ───────────────  ────────────────  ────────────
string write only                        139.6k            138.0k        124.8k
──────────────────────────────  ───────────────  ────────────────  ────────────
scalar replace then read                 118.8k            119.7k         89.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
array in-place mutate then read           281.9k            239.9k        224.0k
──────────────────────────────  ───────────────  ────────────────  ────────────
hash in-place mutate then read           166.1k            148.0k        138.2k
──────────────────────────────  ───────────────  ────────────────  ────────────
string write then read                   119.0k            116.6k         89.8k

Legend:
  Cache enabled  - current branch with cache_attribute_values=true.
  Cache disabled - current branch with cache_attribute_values=false; all other branch optimizations remain active.
  Master         - local master branch baseline, where attribute-value caching is unavailable.
  Values         - median iterations per second across independent process runs; higher is better.

@skcc321

skcc321 commented Jul 31, 2026

Copy link
Copy Markdown
Author

@jamis let me know if you see the same results on your machine this time

@jamis

jamis commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Thank you @skcc321. Things are hectic right now but I'll look closer at this as soon as I can.

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.

4 participants