perf: Optimize field access to eliminate memory allocations#6112
perf: Optimize field access to eliminate memory allocations#6112skcc321 wants to merge 14 commits intomongodb:masterfrom
Conversation
There was a problem hiding this comment.
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_statsdependency 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. |
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)
|
@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! |
jamis
left a comment
There was a problem hiding this comment.
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?
| 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 |
There was a problem hiding this comment.
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.
| @__demongoized_cache[name] = [raw, demongoized] | ||
| value = [raw, demongoized] |
There was a problem hiding this comment.
Rather than creating the same tuple twice, you could just assign value to the previous assignment:
| @__demongoized_cache[name] = [raw, demongoized] | |
| value = [raw, demongoized] | |
| value = @__demongoized_cache[name] = [raw, demongoized] |
|
|
||
| reloaded = Band.find(band.id) | ||
|
|
||
| if Mongoid::Config.use_utc? |
There was a problem hiding this comment.
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| band.updated # First read - caches value | ||
| band.updated # Second read - from cache | ||
|
|
||
| expect(band.updated.utc?).to be(true) if Mongoid::Config.use_utc? |
There was a problem hiding this comment.
Same comment as above (re: use_utc).
| # 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 |
There was a problem hiding this comment.
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.
| platforms :mri do | ||
| gem 'allocation_stats', require: false | ||
| end |
There was a problem hiding this comment.
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.
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)
ece0a28 to
25c777e
Compare
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 - I have rebased the branch, so the history is a bit messy now. Also, I’m considering avoiding some overhead during mutations. |
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:
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: Skipsplitand array allocations for simple fieldstraverse_association_tree: Direct hash lookup instead of iterating through split segmentsdatabase_field_name: Avoidsplitfor non-nested fieldsImpact: 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:
Impact: Zero allocations for field access after first read.
Safety: Always calls
demongoize()on first access, ensuring:3. Cache Invalidation (
lib/mongoid/attributes.rb)Clear cached values when fields are modified:
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:
Called from:
prepare_to_process_attributes(new documents viainitialize)instantiate_document(documents loaded from database viaallocate)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:
Comprehensive Test Coverage
Added 25 tests covering:
.only(),.without())All tests passing ✅
Correctness & Safety
No Unsafe Type-Check Optimizations
Earlier iterations considered skipping
demongoize()whenraw.is_a?(field.type), but this is unsafe because: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:
initialize→prepare_to_process_attributesinstantiate_document(usesallocatewhich bypassesinitialize)reset_readonlyNo
NoMethodErrorrisk when accessing uninitialized caches.Breaking Changes
None. All changes are internal optimizations that preserve existing behavior.
Related Issues