Skip to content

Comments

perf: Optimize field access to eliminate memory allocations#6112

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

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

Conversation

@skcc321
Copy link

@skcc321 skcc321 commented Feb 21, 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.

Motivation

Attribute reads in Mongoid currently perform type conversion (demongoization) on every access, causing unnecessary allocations and performance overhead. For applications with
frequent attribute access patterns, this can lead to significant memory pressure and reduced throughput.

Example of the problem:

user = User.find(id)

# Every read allocates memory for type conversion
10.times { user.name }  # 50 allocations (5 per read)
10.times { user.created_at }  # 111 allocations (11 per read)

This PR eliminates these repeated allocations through intelligent caching.

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%

All performance tests verified with allocation_stats gem.

Implementation Approach

This PR uses a direct caching approach - caching is embedded directly in field access methods and is always enabled.

Key Changes

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

    • Uses Concurrent::Map#compute_if_absent for atomic read-or-compute
    • Zero allocations after first read
    • Per-field cache invalidation on write
    • Localized fields NOT cached to preserve I18n.locale behavior
  2. Fast-path Optimizations (lib/mongoid/fields.rb)

    • Skip split operations when field names don't contain dots
    • Optimized methods: cleanse_localized_field_names, traverse_association_tree, database_field_name
  3. Hash-based Projector Cache (lib/mongoid/attributes.rb)

    • Automatic invalidation when __selected_fields changes
    • Zero allocations for attribute_missing? checks
  4. Thread-Safe Caches (lib/mongoid/document.rb)

    • Uses Concurrent::Map for thread-safe cache access
    • Atomic compute_if_absent prevents race conditions
  5. Consistent Object Shape (lib/mongoid/document.rb)

    • Initialize caches early in all document creation paths
    • Better JIT optimization

Advantages of this approach:

  • ✅ Simpler implementation with fewer abstractions
  • ✅ No configuration needed - works automatically
  • ✅ True atomic cache operations
  • ✅ Fewer files and less code

Trade-offs:

  • ❌ Cannot be disabled if issues arise
  • ❌ Memory overhead always present
  • ❌ Less flexible for future enhancements

Safety Guarantees

  • ✅ Always calls demongoize() on first access - no unsafe shortcuts
  • ✅ Time timezone conversions preserved
  • ✅ BSON::Document → Hash conversion works
  • ✅ All type validations applied
  • ✅ Cache invalidation on write/reload/remove/unset/rename
  • ✅ Thread-safe concurrent access
  • ✅ Atomic operations (no TOCTOU races)
  • ✅ Localized fields not cached - I18n.locale changes work correctly
  • ✅ Lazy-settable fields handled correctly

Test Coverage

performance tests covering:

  • Zero allocations for 11 native field types
  • Thread safety (concurrent access)
  • Localized fields i18n behavior
  • Time timezone handling
  • Cache invalidation scenarios
  • Field projections (.only, .without)
  • Lazy-settable fields
  • Database-loaded documents
  • Correctness verification

Backward Compatibility

100% backward compatible

This implementation:

  • Uses internal optimizations only
  • Preserves all existing behavior
  • No API changes
  • No configuration required
  • Transparent to users

Example Usage

class User
  include Mongoid::Document

  field :name, type: String
  field :email, type: String
  field :age, type: Integer
  field :metadata, type: Hash
end

user = User.find(id)

# First read - performs demongoization (some allocations)
user.name  # => "John"

# Subsequent reads - zero allocations (cached)
user.name  # => "John" (from cache, no allocations)
user.name  # => "John" (from cache, no allocations)

# Write invalidates cache
user.name = "Jane"

# Next read re-demongoizes and caches
user.name  # => "Jane" (fresh, then cached)

Breaking Changes

None. Internal optimizations only.

Related

See PR #6113 for a pluggable accessor classes caching implementation.

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.
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
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.

…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
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 14 out of 14 changed files in this pull request and generated 1 comment.

Comment on lines 444 to 457
it 'handles concurrent projector cache access safely' do
band = Band.create!(name: 'Test')
errors = Concurrent::Array.new

threads = Array.new(10) do
Thread.new do
100.times do
limited = Band.only(:name).find(band.id)
limited.attribute_missing?('rating')
rescue StandardError => e
errors << e
end
end
end
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

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

The concurrent projector-cache spec performs a DB query (Band.only(:name).find) inside a tight loop across 10 threads (1000 queries total). With the suite’s small connection pool (max_pool_size: 5), this can make CI slow/flaky and the test is not actually exercising concurrent access to the same document instance. Consider loading the projected document once outside the threads (or stubbing the projector/selected_fields) and then running the concurrent attribute_missing? calls against that same instance, or significantly reducing iterations/threads.

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