perf: Optimize field access to reduce memory allocations#6110
perf: Optimize field access to reduce memory allocations#6110skcc321 wants to merge 1 commit intomongodb:masterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces performance optimizations to reduce memory allocations during Mongoid document field access by implementing fast-path logic for simple (non-nested) field names and caching frequently-used objects.
Changes:
- Fast-path optimizations for simple field names (avoiding string splits and array allocations)
- Caching of Projector instance in attribute_missing? to avoid repeated allocations
- Type-check optimization to skip demongoize when value is already correct type
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| spec/mongoid/fields/performance_spec.rb | Adds performance tests using allocation_stats to verify zero-allocation field access |
| lib/mongoid/fields.rb | Implements fast-path optimizations for cleanse_localized_field_names, traverse_association_tree, and database_field_name |
| lib/mongoid/attributes.rb | Adds Projector caching in attribute_missing? and type-check short-circuit in process_raw_attribute |
| Gemfile | Adds allocation_stats gem for performance testing |
05a4f08 to
e849875
Compare
6c412f6 to
c6a7400
Compare
48b87b5 to
020bbee
Compare
020bbee to
fa6d7d7
Compare
fa6d7d7 to
74860c8
Compare
74860c8 to
9bd91ff
Compare
9bd91ff to
abb4b4e
Compare
This commit introduces comprehensive performance optimizations to reduce
memory allocations when accessing Mongoid document fields, achieving zero
allocations after warm-up while preserving all existing functionality.
## Motivation
Field access in Mongoid allocates memory on every call due to:
- String splitting for field name processing (even simple fields)
- Creating new Projector instances for field projection checks
- Repeated demongoization (type conversion) of field values
- Unnecessary array and hash allocations
In high-throughput applications, these allocations add up significantly.
## Optimizations
### 1. Fast-path for Simple Field Names (lib/mongoid/fields.rb)
Skip string split and array allocations when field names don't contain dots:
- cleanse_localized_field_names: Direct field lookup for simple names
- traverse_association_tree: Skip split iteration for non-nested keys
- database_field_name: Avoid split for simple fields
Impact: Zero allocations for simple field name processing (most common case).
### 2. Demongoized Value Caching (lib/mongoid/fields.rb)
Cache demongoized values in generated field getters:
def name
return @__demongoized_cache["name"] if @__demongoized_cache.key?("name")
# ... demongoize once and cache ...
end
For lazy-settable fields, cache the default value immediately after
write_attribute to avoid cache churn (clear-then-set pattern).
Safety: Always calls demongoize() on first access, ensuring Time timezone
conversions, BSON::Document to Hash conversion, and all type validations
are applied correctly.
Impact: Zero allocations for field access after first read.
### 3. Hash-based Projector Memoization (lib/mongoid/attributes.rb)
Cache Projector instances keyed by __selected_fields:
@__projector_cache[__selected_fields] ||= Projector.new(__selected_fields)
This automatically handles invalidation when selected fields change,
without requiring explicit cache clearing.
Impact: Zero allocations for attribute_missing? checks, automatic
invalidation when field selections change.
### 4. Thread-Safe Caches (lib/mongoid/document.rb)
Use Concurrent::Map for thread-safe cache access:
def initialize_field_caches
@__projector_cache = Concurrent::Map.new
@__demongoized_cache = Concurrent::Map.new
end
Thread safety without Mutex overhead, allows documents to be safely
accessed from multiple threads concurrently.
### 5. Consistent Object Shape (lib/mongoid/document.rb, lib/mongoid/stateful.rb)
Initialize cache instance variables early via initialize_field_caches():
Called from:
- prepare_to_process_attributes (new documents via initialize)
- instantiate_document (DB-loaded documents via allocate)
- reset_readonly (document reload)
Impact: All documents have the same object shape from start, enabling
better JIT optimization. Includes comprehensive YARD documentation
explaining object shape consistency and performance implications.
## Performance Results
Zero allocations after warm-up for all field types:
- String/Integer/Float/Boolean: 50 → 0 allocations (100% reduction)
- Time: 111 → 0 allocations (100% reduction)
- Hash/Array: 40 → 0 allocations (100% reduction)
Tested for both new documents (via new) and database-loaded documents (via find).
Thread-safe for concurrent access from multiple threads.
## Test Coverage
34 comprehensive tests covering:
- Zero allocation verification for all major field types (new documents)
- Zero allocation verification for all major field types (DB-loaded documents)
- Zero allocations after setters (Hash, String, Time)
- Thread safety for concurrent field access
- Thread safety for concurrent projector cache access
- Projector cache with different field selections
- 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
- Correctness verification
- Class-level method optimizations
All tests passing.
## Breaking Changes
None. All changes are internal optimizations that preserve existing behavior.
Files changed: 6 files, 438 insertions(+), 2 deletions(-)
abb4b4e to
b0d8214
Compare
| class LocalizedBand | ||
| include Mongoid::Document | ||
| field :title, type: String, localize: true | ||
| end |
There was a problem hiding this comment.
Defining a class inside a test block creates a class in the global namespace that persists beyond the test. This can cause test pollution. Consider either:
- Using
stub_constto create a temporary constant - Defining the class at the top level of the spec file
- Using an anonymous class assigned to a local variable
| # doc1.name # Creates @__demongoized_cache, doc1 gets shape A | ||
| # | ||
| # doc2 = Band.new | ||
| # doc2.rating # Creates @__demongoized_cache, doc2 gets shape B | ||
| # # Now doc1 and doc2 have different shapes! |
There was a problem hiding this comment.
The documentation example is misleading. In the "BAD" example (lines 290-295), both doc1 and doc2 would get the same shape (with @__demongoized_cache), not different shapes (A and B).
Shape polymorphism would occur if field access created the cache lazily in the old code (before this PR), but that's not what the example shows. The example should demonstrate either:
- Different execution paths creating ivars in different orders
- Different numbers of ivars being created (e.g., one doc has cache, another doesn't)
Consider revising the example to accurately demonstrate the shape polymorphism problem this PR solves.
| # doc1.name # Creates @__demongoized_cache, doc1 gets shape A | |
| # | |
| # doc2 = Band.new | |
| # doc2.rating # Creates @__demongoized_cache, doc2 gets shape B | |
| # # Now doc1 and doc2 have different shapes! | |
| # doc1.name # First field access; lazily creates @__demongoized_cache on doc1 | |
| # | |
| # doc2 = Band.new | |
| # # doc2 never accesses any fields, so it never gets @__demongoized_cache | |
| # # Now doc1 and doc2 have different shapes (doc1 has an extra ivar)! |
| @__demongoized_cache.compute_if_absent(name) do | ||
| raw = read_raw_attribute(name) | ||
| if lazy_settable?(field, raw) | ||
| # Lazy evaluation: compute default and write to DB | ||
| # The value is cached by compute_if_absent, so even though | ||
| # write_attribute will delete the cache, we're computing it | ||
| # inside the atomic block so another thread won't see missing cache | ||
| default_val = field.eval_default(self) | ||
| write_attribute(name, default_val) | ||
| # Re-set cache after write_attribute deleted it | ||
| @__demongoized_cache[name] = default_val | ||
| default_val | ||
| else | ||
| # Normal path: demongoize the raw value | ||
| process_raw_attribute(name.to_s, raw, field) | ||
| end | ||
| end |
There was a problem hiding this comment.
There's a potential race condition in the lazy_settable handling. The compute_if_absent block checks lazy_settable? and then calls write_attribute, which deletes the cache entry. If multiple threads access this field concurrently during lazy evaluation, they could both enter the block and both call write_attribute, leading to:
- Multiple evaluations of the default value
- Potential inconsistency if the default value computation is not idempotent
Additionally, manually re-setting the cache at line 708 breaks the atomicity guarantee of compute_if_absent, as there's a window where the cache is deleted but not yet re-set.
| @__demongoized_cache.compute_if_absent(name) do | |
| raw = read_raw_attribute(name) | |
| if lazy_settable?(field, raw) | |
| # Lazy evaluation: compute default and write to DB | |
| # The value is cached by compute_if_absent, so even though | |
| # write_attribute will delete the cache, we're computing it | |
| # inside the atomic block so another thread won't see missing cache | |
| default_val = field.eval_default(self) | |
| write_attribute(name, default_val) | |
| # Re-set cache after write_attribute deleted it | |
| @__demongoized_cache[name] = default_val | |
| default_val | |
| else | |
| # Normal path: demongoize the raw value | |
| process_raw_attribute(name.to_s, raw, field) | |
| end | |
| end | |
| value = @__demongoized_cache.compute_if_absent(name) do | |
| raw = read_raw_attribute(name) | |
| if lazy_settable?(field, raw) | |
| # Lazy evaluation: compute default value only. We avoid | |
| # mutating attributes or the cache inside this block so | |
| # that compute_if_absent can retain its atomicity. | |
| field.eval_default(self) | |
| else | |
| # Normal path: demongoize the raw value | |
| process_raw_attribute(name.to_s, raw, field) | |
| end | |
| end | |
| # If the field is still lazily settable, persist the computed | |
| # default value outside the compute_if_absent block. This avoids | |
| # deleting or rewriting the cache entry while it is being | |
| # computed, preventing race conditions. | |
| raw_after = read_raw_attribute(name) | |
| if lazy_settable?(field, raw_after) | |
| write_attribute(name, value) | |
| end | |
| value |
| it "achieves zero allocations for Symbol fields" do | ||
| Band.field :status, type: Symbol | ||
| band = Band.new(status: :active) | ||
| band.status # warm up | ||
| stats = AllocationStats.trace { 10.times { band.status } } | ||
| expect(stats.new_allocations.size).to eq(0) | ||
| end |
There was a problem hiding this comment.
Dynamically adding a field to the Band class at the class level can cause test pollution if tests run in any order or if they're not properly isolated. The field :status will persist for all subsequent tests that use the Band model. Consider either:
- Using a separate test-specific model class
- Adding an
afterhook to remove the field - Using
letto create a fresh class for this test
Optimize field access to eliminate memory allocations
Summary
Eliminates memory allocations for field access after warm-up through caching and fast-path optimizations, while preserving all existing functionality and ensuring thread
safety.
Performance Impact
100% allocation reduction across all native MongoDB field types:
First access cost: 1-11 allocations (one-time demongoization)
Subsequent accesses: 0 allocations ✅
Key Changes
1. Cache Demongoized Values (
lib/mongoid/fields.rb)compute_if_absentfor atomic read-or-compute (no TOCTOU race)demongoize()on first access (preserves Time TZ, BSON::Document conversion, etc.)2. Fast-path for Simple Fields (
lib/mongoid/fields.rb)Skip
splitoperations when field names don't contain dots (common case):cleanse_localized_field_namestraverse_association_treedatabase_field_name(with proper&.dupfor frozen strings)3. Hash-based Projector Cache (
lib/mongoid/attributes.rb)__selected_fieldschangesattribute_missing?checks4. Thread-Safe Caches (
lib/mongoid/document.rb)Uses
Concurrent::Map(already a Mongoid dependency) for thread-safe cache access:compute_if_absentprevents TOCTOU races5. Consistent Object Shape (
lib/mongoid/document.rb)Initialize caches early in all document creation paths:
initialize→prepare_to_process_attributesinstantiate_document(viaallocate)reset_readonlyWhy: Same object shape for all documents → better JIT optimization.
Coverage
Tested & Zero Allocations (11 types):
Not Cached (by design):
Also Supported (covered by general logic):
Safety Guarantees
✅ No unsafe type-check shortcuts - always calls
demongoize()first time✅ Time timezone conversions preserved
✅ BSON::Document → Hash conversion works
✅ All type validations applied
✅ Cache invalidation on write/reload
✅ All instantiation paths covered
✅ Thread-safe for concurrent access
✅ No TOCTOU races - atomic
compute_if_absent✅ Proper handling of frozen strings (
&.dup)✅ Localized fields not cached - I18n.locale changes work correctly
✅ Lazy-settable fields handled correctly
Test Coverage
39 comprehensive tests covering:
.only,.without)All passing ✅
Breaking Changes
None. Internal optimizations only.