Skip to content

Reduce immutable list copying and write contention - #582

Open
niemyjski wants to merge 13 commits into
fix/in-memory-cache-immutable-entriesfrom
perf/in-memory-list-snapshot
Open

niemyjski wants to merge 13 commits into
fix/in-memory-cache-immutable-entriesfrom
perf/in-memory-list-snapshot

Conversation

@niemyjski

@niemyjski niemyjski commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Stacked on #570. Reduce copying and retry work from atomic list updates while preserving comparers and collection snapshots. Include #587's expiration, calculator-recognition, cloning, and size-limit corrections before merging; it also adds an explicit fast path for custom sizing.

Compatibility: existing callbacks still receive concrete dictionaries and must not reenter the cache, because contended writes may invoke them under a stripe lock. Item expirations read back as UTC. Oversized fallback estimates are rejected or throw the configured size-limit exception. No existing CLR signature break.

Performance decision remains: existing concrete-dictionary custom calculators still copy whole lists. At 1,000 items with cloning off, the compatibility path costs 3.27× time / 37× allocation versus main. #587 adds an explicit read-only sizing opt-in: 0.95 µs / 7.80 KiB, versus 7.04 µs / 85.57 KiB without it and 2.16 µs / 2.31 KiB on main. This removes sizing copies, not all immutable-update allocation. Accept this migration/cost tradeoff before merging the cache stack.

Validation: combined Release 2,448 passed / 22 skipped / 0 failed; current provider source workspace build, Redis 777 passed / 18 skipped, repository tests 152 passed, net8/net10 API compatibility, and changed-file formatting passed. All five stack heads have green hosted Build workflows.

Verification and implementation details

Validated heads: #570 351d2cba, #580 9760d3a8, #582 11488067, #586 e19ee7f3, #587 3f24b90b; baseline main be10785c. Final combined core: 2,470 total, 2,448 passed, 22 skipped. Redis selection: 795 total, 777 passed, 18 skipped.

The restore failures are resolved by the separate DataProtection update already on main (be10785c): 8.0.32 resolves Xml 8.0.5 and Pkcs 8.0.2. Current provider revisions also resolve the old xUnit pins and Parsers cryptography advisory. No workflow edits or audit suppression.

Implementation

Dictionary-backed snapshots defer indexing until repeated incremental changes. Indexed writes copy affected blocks and partitions; bulk changes still copy a dictionary. Writers serialize by key stripe after a failed compare-and-swap, so colliding keys can contend.

With cloning enabled, strings and value types without references can share immutable storage. Mutable payloads retain cloning. Cloned dictionary/Items reads preserve incremental writes; uncloned raw dictionary reads expose a shared dictionary and force subsequent writes to copy it.

Built-in fixed/dynamic sizing already accepts read-only views. #587 adds SizeCalculator(calculateSize, supportsReadOnlyCollections: true) and SizeCalculatorSupportsReadOnlyCollections. A custom calculator must accept ordinary dictionaries and read-only views through interfaces and never mutate them. The old overload resets that opt-in, protecting replacement callbacks. This does not change cache reads or eliminate callback enumeration costs.

Whole-result size limits apply to add/remove. An oversized result can reject a removal. Rejected writes preserve live value/TTL/size; an already-expired entry can still be reclaimed with its expiration event.

Paired measurements on the current production implementation

BenchmarkDotNet 0.15.8, MemoryDiagnoser, .NET 10.0.12, Apple M4 Max. Main be10785c versus the production code in #587 3f24b90b (production unchanged from 9e111be1). Calculator reads IDictionary<int, DateTime?>.Count; each operation adds one item and removes it. KiB are BDN's “KB”.

Three paired jobs: main, compatibility default, and read-only opt-in. Same harness/session with no overlapping builds/tests. 18 ShortRun cases; 32,768 invocations, unroll16, three warmups/measurements. Fast cases are directional because some iterations are below 100 ms.

Clone Items Main time / KiB Compatible time / KiB Read-only opt-in time / KiB
Off 32 0.407 µs / 2.31 0.805 µs / 8.16 0.599 µs / 5.34
Off 1,000 2.097 µs / 2.31 6.990 µs / 85.57 1.248 µs / 7.80
Off 10,000 18.948 µs / 2.31 147.770 µs / 735.46 2.547 µs / 24.48
On 32 1.132 µs / 8.47 0.805 µs / 8.16 0.597 µs / 5.34
On 1,000 7.598 µs / 158.38 7.041 µs / 85.57 1.061 µs / 7.83
On 10,000 207.893 µs / 1,425.37 149.824 µs / 735.46 3.447 µs / 24.48

A longer confirmation of the 1,000-item, cloning-off case used 131,072 invocations, five warmups and eight measured iterations: main 2.156 µs / 2.31 KiB; compatible 7.043 µs / 85.57 KiB; opt-in 0.947 µs / 7.80 KiB. The opt-in is 7.4× faster and allocates about 91% less than the compatibility path, but still allocates 3.38× main. At 10,000 items the compatibility default remains 7.80× slower / 318× allocation; the opt-in still allocates 10.58× main. Small clone-disabled lists also retain a slowdown. Do not describe this as eliminating all regressions.

The prior October 12 constant-return callback is a different workload. Its historical unsized/dynamic/cold-write results were not rerun. This pass does not establish scalar, compaction, bulk, retained-heap, or contended throughput neutrality. Non-cloning contended main throws and is not a valid timing baseline.

Reproduction and coverage

The checked-in InMemoryListBenchmarks includes Custom and CustomReadOnly with the same Count callback. Build saved main/candidate DLLs and use paired BDN jobs with conditional Reference/HintPath references. Enable the capability only during candidate setup; setup/reflection is outside measurement.

dotnet run --project benchmarks/Foundatio.Benchmarks.csproj -c Release -- --filter '*InMemoryListBenchmarks.AddRemove*'

The new rejected-write/snapshot/allocation cases failed before the capability was wired and pass afterward. Contention coverage includes unsized and both custom-sizing paths with cloning off/on and verifies final tracked memory. Existing comparer, expiration, retry, cloning, and snapshot-model coverage remains.

Ten current sibling repositories build with ReferenceFoundatioSource=true; two existing ASPIRE010 warnings remain. Redis 8.10.2 uses the exact combined core DLL, with the 14 lock cases from Redis #191 and six extra shared cache registrations in the temporary validation checkout. These registrations are not a claim that provider main/CI discovers them. Six Hybrid and three FastCloner cloning skips remain. Non-Redis service-backed suites and the documentation-site build were not run.

dotnet build Foundatio.All.slnx -c Release --disable-build-servers -m:1 -p:UseSharedCompilation=false -p:ReferenceFoundatioSource=true
dotnet tests/Foundatio.Tests/bin/Release/net10.0/Foundatio.Tests.dll -noColor
git diff --check

Review #587 into this branch before merging. Retarget after #570, and release with the reviewed lock stack. Hosted CI is green. No new workflow changes, skips, cloner modifications, or file decomposition.

Pure move, no behavior change: ListSnapshot<T> and IListSnapshot were private nested types of InMemoryCacheClient and
are now internal types in InMemoryListSnapshot.cs so they can be tested directly.
- A snapshot built from a dictionary keeps only the dictionary and its expiration bounds; blocks and the key index are
  built on the first small update, so bulk writes are one dictionary copy (fast copy constructor, no rehash).
- Replace the ImmutableDictionary key index with small hash-partitioned dictionaries that are copied on write. The
  first small change on a 1,000-item list went from ~129 us / 90 KB (building the AVL tree) to a single pass.
- Track min/max expiration per block instead of an ImmutableSortedDictionary; pruning skips blocks with nothing expired.
- Add AsReadOnlyDictionary(), a read-only IDictionary/ICollection view used to size lists without copying them.
- Add InMemoryListSnapshotTests, including a 3,000-step randomized comparison against a Dictionary model.
- Lists are shared (no full copy per write) whenever cloning is off, in every sizing mode; previously only no sizing
  and WithFixedSizing shared them. Snapshot updates size the whole list through ListSnapshot.AsReadOnlyDictionary().
- ListAddAsync no longer sizes and clones the incoming batch on its own when the key already holds a shared list.
- Behavior change: a custom SizeCalculator receives list values as a read-only IDictionary<T, DateTime?> rather than a
  Dictionary<T, DateTime?>. Documented in the SizeCalculator contract, the caching guide and the skill.
- Remove FixedSizeCalculator, which existed only for the sharing check; WithFixedSizing is back to main's lambda.
…ts, share immutable items under cloning

- The first small change to an unindexed list copies its dictionary (~3 us at 1,000 items) and only a list that keeps
  changing builds the block and partition index, so a list written once or twice never pays for indexing.
- ListAddAsync and ListRemoveAsync take a lock striped by key (64 stripes) after a lost compare-and-swap, so writers
  queue instead of each redoing an O(n) copy. Publishing still uses compare-and-swap; other writers stay lock-free.
- With CloneValues on, lists whose items never need cloning (numbers, strings, value types) also keep a shared
  snapshot. Raw dictionary reads still deep-clone the materialized dictionary; reference-type items keep copying.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-28T04:52:10.078910Z
We couldn’t complete this review. Comment @codex review to try again.
439e0d3 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 736871fce9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@niemyjski
niemyjski added this pull request to stack #583 October 7, 2026 19:59
…reference-holding structs, keep snapshot values owned

Moves the conflict lock onto the single remaining update loop (eager stripes, mask index, System.Threading.Lock on net9+). Custom SizeCalculators receive a Dictionary copy again; only the built-in calculators read the read-only view. Struct items holding references are cloned under CloneValues. GetSnapshot always hands out a copy, so reads use the owned dictionary without layout checks. Skips publishing an unchanged list, avoids a discarded clone for new shareable lists, and adds Utc suffixes to new expiration names.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

This branch has not been deployed

No deployments
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