Skip to content

Comments

perf: Optimize field access to reduce memory allocations#6110

Draft
skcc321 wants to merge 1 commit intomongodb:masterfrom
skcc321:perf/optimize-field-access-allocations
Draft

perf: Optimize field access to reduce memory allocations#6110
skcc321 wants to merge 1 commit intomongodb:masterfrom
skcc321:perf/optimize-field-access-allocations

Conversation

@skcc321
Copy link

@skcc321 skcc321 commented Feb 20, 2026

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:

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%

First access cost: 1-11 allocations (one-time demongoization)
Subsequent accesses: 0 allocations ✅

Key Changes

1. Cache Demongoized Values (lib/mongoid/fields.rb)

@__demongoized_cache.compute_if_absent(name) do
  # demongoize once and cache atomically
end
  • Zero allocations after first read
  • Uses compute_if_absent for atomic read-or-compute (no TOCTOU race)
  • Always calls demongoize() on first access (preserves Time TZ, BSON::Document conversion, etc.)
  • Per-field cache invalidation on write
  • Localized fields NOT cached to preserve I18n.locale behavior

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

Skip split operations when field names don't contain dots (common case):

  • cleanse_localized_field_names
  • traverse_association_tree
  • database_field_name (with proper &.dup for frozen strings)

3. Hash-based Projector Cache (lib/mongoid/attributes.rb)

@__projector_cache[__selected_fields] ||= Projector.new(__selected_fields)
  • Automatic invalidation when __selected_fields changes
  • Zero allocations for attribute_missing? checks

4. Thread-Safe Caches (lib/mongoid/document.rb)

Uses Concurrent::Map (already a Mongoid dependency) for thread-safe cache access:

@__projector_cache = Concurrent::Map.new
@__demongoized_cache = Concurrent::Map.new
  • Documents can be safely accessed from multiple threads
  • No Mutex overhead on read operations
  • Atomic compute_if_absent prevents TOCTOU races

5. Consistent Object Shape (lib/mongoid/document.rb)

Initialize caches early in all document creation paths:

  • New docs: initializeprepare_to_process_attributes
  • DB-loaded: instantiate_document (via allocate)
  • Reloaded: reset_readonly

Why: Same object shape for all documents → better JIT optimization.

Coverage

Tested & Zero Allocations (11 types):

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

Not Cached (by design):

  • Localized fields (preserve I18n.locale behavior)

Also Supported (covered by general logic):

  • DateTime, BigDecimal, BSON::Decimal128, Regexp, BSON::Binary

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:

  • Zero allocations for 11 native field types (new + DB-loaded docs)
  • Thread safety (concurrent field access + projector cache)
  • Localized fields i18n behavior
  • Time timezone handling
  • Cache invalidation scenarios
  • Field projections (.only, .without)
  • Projector cache with different field selections
  • Lazy-settable fields
  • Correctness verification for all types

All passing ✅

Breaking Changes

None. Internal optimizations only.

Copilot AI review requested due to automatic review settings February 20, 2026 18:45
@skcc321 skcc321 requested a review from a team as a code owner February 20, 2026 18:45
@skcc321 skcc321 requested a review from jamis February 20, 2026 18:45
Copy link
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 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

@skcc321 skcc321 force-pushed the perf/optimize-field-access-allocations branch 2 times, most recently from 05a4f08 to e849875 Compare February 20, 2026 18:53
@skcc321 skcc321 marked this pull request as draft February 20, 2026 19:03
@skcc321 skcc321 force-pushed the perf/optimize-field-access-allocations branch 4 times, most recently from 6c412f6 to c6a7400 Compare February 20, 2026 19:41
@skcc321 skcc321 requested a review from Copilot February 20, 2026 19:42
Copy link
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 6 out of 6 changed files in this pull request and generated 9 comments.

@skcc321 skcc321 force-pushed the perf/optimize-field-access-allocations branch 2 times, most recently from 48b87b5 to 020bbee Compare February 20, 2026 21:23
@skcc321 skcc321 requested a review from Copilot February 20, 2026 21:30
Copy link
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 6 out of 6 changed files in this pull request and generated 3 comments.

@skcc321 skcc321 force-pushed the perf/optimize-field-access-allocations branch from 020bbee to fa6d7d7 Compare February 20, 2026 21:48
@skcc321 skcc321 requested a review from Copilot February 20, 2026 21:49
Copy link
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 6 out of 6 changed files in this pull request and generated no new comments.

@skcc321 skcc321 force-pushed the perf/optimize-field-access-allocations branch from fa6d7d7 to 74860c8 Compare February 20, 2026 21:55
@skcc321 skcc321 requested a review from Copilot February 20, 2026 21:57
Copy link
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 6 out of 6 changed files in this pull request and generated 1 comment.

@skcc321 skcc321 force-pushed the perf/optimize-field-access-allocations branch from 74860c8 to 9bd91ff Compare February 20, 2026 22:04
@skcc321 skcc321 requested a review from Copilot February 20, 2026 22:05
Copy link
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 6 out of 6 changed files in this pull request and generated 2 comments.

@skcc321 skcc321 force-pushed the perf/optimize-field-access-allocations branch from 9bd91ff to abb4b4e Compare February 20, 2026 22:19
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(-)
@skcc321 skcc321 force-pushed the perf/optimize-field-access-allocations branch from abb4b4e to b0d8214 Compare February 20, 2026 22:22
@skcc321 skcc321 requested a review from Copilot February 20, 2026 22:28
Copy link
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 6 out of 6 changed files in this pull request and generated 4 comments.

Comment on lines +392 to +395
class LocalizedBand
include Mongoid::Document
field :title, type: String, localize: true
end
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Using stub_const to create a temporary constant
  2. Defining the class at the top level of the spec file
  3. Using an anonymous class assigned to a local variable

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +295
# 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!
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Different execution paths creating ivars in different orders
  2. 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.

Suggested change
# 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)!

Copilot uses AI. Check for mistakes.
Comment on lines +698 to +714
@__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
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Multiple evaluations of the default value
  2. 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.

Suggested change
@__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

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +91
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
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Using a separate test-specific model class
  2. Adding an after hook to remove the field
  3. Using let to create a fresh class for this test

Copilot uses AI. Check for mistakes.
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.

1 participant