Skip to content

perf: Optimize field access to eliminate memory allocations#6112

Open
skcc321 wants to merge 14 commits intomongodb:masterfrom
skcc321:perf/optimize-field-access-alternative
Open

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

Conversation

@skcc321
Copy link
Copy Markdown

@skcc321 skcc321 commented Feb 21, 2026

Summary

This PR introduces comprehensive performance optimizations to reduce memory allocations when accessing Mongoid document fields, achieving zero allocations after warm-up while preserving all existing functionality and correctness.

Motivation

Field access in Mongoid involves multiple operations that allocate memory on every call:

  • String splitting for field name processing
  • Creating Projector instances for field projection checks
  • Repeated demongoization (type conversion) of field values
  • Array and hash allocations for simple field lookups

These allocations add up significantly in high-throughput applications. This PR addresses these issues with targeted optimizations.

Changes

1. Fast-path Optimizations for Simple Fields (lib/mongoid/fields.rb)

Added fast-path logic to skip unnecessary string operations when field names don't contain dots (the common case):

  • cleanse_localized_field_names: Skip split and array allocations for simple fields
  • traverse_association_tree: Direct hash lookup instead of iterating through split segments
  • database_field_name: Avoid split for non-nested fields

Impact: Zero allocations for simple field name processing (most common case).

2. Demongoized Value Caching (lib/mongoid/fields.rb)

Cache demongoized (type-converted) field values to avoid repeated conversions:

# Before: demongoize called on every field access
def name
  process_raw_attribute("name", read_raw_attribute("name"), field)
end

# After: demongoize called once, then cached
def name
  return @__demongoized_cache["name"] if @__demongoized_cache.key?("name")
  # ... demongoize and cache ...
end

Impact: Zero allocations for field access after first read.

Safety: Always calls demongoize() on first access, ensuring:

  • Time timezone conversions applied correctly
  • BSON::Document converted to Hash properly
  • All type validations and normalizations preserved

3. Cache Invalidation (lib/mongoid/attributes.rb)

Clear cached values when fields are modified:

def write_attribute(name, value)
  # ...
  _assigning do
    @__demongoized_cache.delete(field_name)  # Clear cache for modified field
    # ... write new value ...
  end
end

Also caches the Projector instance in attribute_missing? to avoid recreating it on every check.

4. Consistent Object Shape (lib/mongoid/document.rb, lib/mongoid/stateful.rb)

Initialize cache instance variables early to ensure all documents have the same object shape:

def initialize_field_caches
  @__projector = nil
  @__demongoized_cache = {}
end

Called from:

  • prepare_to_process_attributes (new documents via initialize)
  • instantiate_document (documents loaded from database via allocate)
  • reset_readonly (document reload)

Impact: Prevents shape polymorphism that degrades JIT compiler performance.

Performance Results

Zero Allocations After Warm-up

100% allocation reduction across all native MongoDB field types:

Field Type Before (10x) After (10x) Reduction
String 50 0 100%
Integer 50 0 100%
Float 50 0 100%
Boolean 50 0 100%
Hash 40 0 100%
Array 40 0 100%
Time 111 0 100%
Date 50 0 100%
BSON::ObjectId 50 0 100%
Range 50 0 100%
Symbol 50 0 100%

Comprehensive Test Coverage

Added 25 tests covering:

  • Zero allocation verification for all major field types
  • Time timezone conversion preservation
  • Database-loaded document handling
  • Document reload cache clearing
  • BSON::Document to Hash conversion
  • Field projection scenarios (.only(), .without())
  • Cache invalidation on write
  • Getter-after-setter behavior

All tests passing ✅

Correctness & Safety

No Unsafe Type-Check Optimizations

Earlier iterations considered skipping demongoize() when raw.is_a?(field.type), but this is unsafe because:

  • Time fields apply timezone conversions even when raw is already a Time instance
  • BSON::Document values need conversion to Hash
  • Other types may have validation/normalization logic in demongoize()

Final approach: Always call demongoize() on first access, then cache the result. This guarantees correctness while achieving zero allocations on subsequent reads.

All Instantiation Paths Covered

Cache initialization is guaranteed regardless of how documents are created:

  • New documents: initializeprepare_to_process_attributes
  • DB-loaded: instantiate_document (uses allocate which bypasses initialize)
  • Reloaded: reset_readonly

No NoMethodError risk when accessing uninitialized caches.

Breaking Changes

None. All changes are internal optimizations that preserve existing behavior.

Related Issues

  • Addresses memory allocation concerns in high-throughput applications
  • Improves performance without sacrificing correctness

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Comment thread lib/mongoid/fields.rb
Comment thread lib/mongoid/config.rb
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Comment thread lib/mongoid/fields.rb Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Comment thread spec/mongoid/fields/performance_spec.rb
Comment thread spec/mongoid/fields/performance_spec.rb Outdated
Comment thread lib/mongoid/fields.rb
Comment thread lib/mongoid/attributes.rb
Comment thread lib/mongoid/attributes.rb
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Author

skcc321 commented Feb 26, 2026

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

@jamis
Copy link
Copy Markdown
Contributor

jamis commented Mar 3, 2026

@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
Copy link
Copy Markdown
Author

skcc321 commented Mar 4, 2026

@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

Copy link
Copy Markdown
Contributor

@jamis jamis left a comment

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 14 commits March 11, 2026 21:06
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.
…emongoized_cache private

- Use compute_if_absent for atomic projector cache operations to prevent race conditions
- Make clear_demongoized_cache a private method to avoid expanding public API surface
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
Copy link
Copy Markdown
Author

skcc321 commented Mar 11, 2026

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.

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