perf: Optimize field access to eliminate memory allocations - #6112
perf: Optimize field access to eliminate memory allocations#6112skcc321 wants to merge 2 commits into
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.
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. |
|
Sorry to be so slow to get back to this. I tried running the performance benchmarks locally, and got fairly different results: 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.
20d3251 to
8c3d5b7
Compare
| # This optimization can significantly improve performance for fields with | ||
| # expensive demongoization (e.g., Time, Date, custom types), especially | ||
| # in read-heavy workloads. |
| # 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) |
| # Each access should call process_raw_attribute | ||
| first_result = band.updated | ||
| second_result = band.updated | ||
|
|
| # 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 |
| # 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. |
Hello @jamis 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 environmentThe benchmark was run with: ./perf/benchmark_cache_attribute_values.rbHost system
Ruby environment
Ruby and the benchmark process ran directly on the macOS host. MongoDB environment
Benchmark configurationUnless overridden through environment variables, the script uses:
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 notesI 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. |
|
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. |
|
@jamis let me know if you see the same results on your machine this time |
|
Thank you @skcc321. Things are hectic right now but I'll look closer at this as soon as I can. |
Summary
This PR reduces allocations and improves Mongoid field-access performance through:
Demongoized-value caching is opt-in and disabled by default:
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:
Projectorinstances when no projection is active.These costs become significant in applications that repeatedly access fields across large numbers of documents.
The implementation deliberately separates two kinds of optimization:
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_namestraverse_association_treedatabase_field_nameNested and association paths continue to use the existing traversal logic.
2. Projection fast path
attribute_missing?now immediately returnsfalsewhen the document has no selected fields.This avoids constructing or looking up a
Projectorduring ordinary field access. Documents loaded with.onlyor.withoutcontinue to use projection checks, with lazily cachedProjectorinstances 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:
StringIntegerFloatSymbolMongoid::BooleanBSON::ObjectIdidentifier fieldsThe 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_valuesis enabled, Mongoid caches repeated demongoization for selected types where conversion is relatively expensive:ArrayHashDateDateTimeTimeRangeSetEach entry stores the raw value reference together with its demongoized result and any validity metadata required by the field type:
TimeandDateTimeentries retain the UTC configuration and current thread'sTime.zone, so changing the demongoization context refreshes the cached value.SetandRangeentries 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:
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
nilin every document construction path: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.rbnow runs three configurations:masterbaselineThis 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:
For a shorter validation run:
Performance Results
Representative local results, reported as median iterations per second; higher is better:
The three-way comparison shows two distinct effects:
Stringimprove primarily because of the general getter and projection fast paths. Cache-enabled and cache-disabled results are similar.Time,Date, andRangebenefit from caching because their demongoization is more expensive.The benchmark includes write-only and write-then-read cases so the read/write trade-offs remain visible.
Allocation Results
With
allocation_statsavailable on MRI, the performance specifications verify zero allocations after warm-up for the tested native getter paths, including:These checks cover both newly constructed and database-loaded documents where applicable.
Test Coverage
The added coverage includes:
TimeandDateTimecache refreshesSetandRange.onlyand.withoutCI 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.