fix(db): reuse ReadOptions in ColumnDbSnapshot to reduce GC pressure#10894
fix(db): reuse ReadOptions in ColumnDbSnapshot to reduce GC pressure#10894smartprogrammer93 wants to merge 5 commits intomasterfrom
Conversation
…zer pressure ColumnDbSnapshot previously created 14 ReadOptions per snapshot (2 per column × 7 columns). ReadOptions in RocksDbSharp has a finalizer but no IDisposable, so these objects survive Gen0, get promoted to Gen1/Gen2, and cause expensive GC pauses. In FlatState block processing, 2000+ snapshots per BDN round produce 28,000+ finalizable objects, causing Gen1 (9/round) and Gen2 (1-2/round) collections that create timing spikes. Fix: Create 2 shared ReadOptions (normal + cache-miss) per ColumnDbSnapshot and pass them to all RocksDbReader instances. Explicitly destroy the native handles via rocksdb_readoptions_destroy + GC.SuppressFinalize in Dispose(). This reduces finalizable objects from 14 to 0 per snapshot scope. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…egate ColumnDbSnapshot used Dictionary<T, IReadOnlyKeyValueStore> to map columns to readers, plus 7 separate Func<ReadOptions> closure allocations. Replace with a flat array indexed by enum ordinal and a single shared delegate. This eliminates per-snapshot: 1 Dictionary + backing array + 6 extra delegate allocations (from 7 down to 1 shared). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@claude review |
|
Claude finished @smartprogrammer93's task in 2m 5s —— View job Code ReviewGood optimization overall — the profiling data clearly justifies the changes, and the approach of eagerly destroying native handles + suppressing finalizers is sound. Below are the issues I found. Critical1. int ord = Unsafe.As<T, int>(ref k);
Use 2. No double-dispose guard in public void Dispose()
{
Native.Instance.rocksdb_readoptions_destroy(_sharedReadOptions.Handle);
GC.SuppressFinalize(_sharedReadOptions);
Native.Instance.rocksdb_readoptions_destroy(_sharedCacheMissReadOptions.Handle);
GC.SuppressFinalize(_sharedCacheMissReadOptions);
_snapshot.Dispose();
}If Moderate3. Data race on T[] keys = columnsDb._cachedColumnKeys ??= columnsDb._columnDbs.Keys.ToArray();
if (columnsDb._cachedMaxOrdinal < 0)
{
...
columnsDb._cachedMaxOrdinal = max;
}
4. Missing regression test [ Per project guidelines: "When fixing a bug, always add a regression test." The PR notes no tests were written. Even a simple unit test verifying that Minor5. // Single shared delegate for GetViewBetween — avoids per-reader closure allocation
Func<ReadOptions> readOptionsFactory = () => { ... };The delegate is one allocation (good), but each call to 6. LINQ in constructor [ columnsDb._columnDbs.Keys.ToArray()The coding style says "No LINQ when a simple SummaryThe optimization is well-motivated and the profiling data is compelling. The two critical issues (unsafe enum cast, missing double-dispose guard) should be addressed before merge. The thread-safety concern on the parent-level cache is also worth fixing since |
- Replace Unsafe.As<T, int> with Convert.ToInt32 for safe enum-to-int conversion regardless of underlying type (critical: buffer overread) - Add _disposed guard to Dispose() to prevent double-free on native ReadOptions handles (critical: use-after-free) - Use volatile fields for _cachedColumnKeys/_cachedMaxOrdinal to ensure cross-thread visibility (moderate: data race) - Replace LINQ ToArray() with manual loop (minor: style) - Clarify comment on readOptionsFactory noting GetViewBetween still creates per-call ReadOptions (minor: documentation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@claude rereview |
|
Claude finished @smartprogrammer93's task in 1m 38s —— View job Re-reviewBoth critical issues from the previous review have been addressed. Here's the full updated status. Previously Critical — Now Fixed1. ✅ All three call sites now use 2. ✅ Double-dispose guard added [ if (_disposed) return;
_disposed = true;Clean and correct. Note: per coding-style, Previously Moderate — Addressed with Acknowledged Trade-off3. Both fields are now
This is acceptable. Previously Minor — Fixed4. ✅ LINQ removed from constructor [ Replaced with a manual 5. ✅ Clarifying comment on The comment now explicitly calls out that per-call Still Open6. Missing regression test Per project guidelines ( New Observation7. public IReadOnlyKeyValueStore GetColumn(T key)
{
return _readers[Convert.ToInt32(key)];
}After SummaryThe two blockers from the first review are resolved. The remaining items are:
The optimization itself is solid and the profiling data clearly justifies it. Good to merge once the test is added. |
LukaszRozmej
left a comment
There was a problem hiding this comment.
Use helper methods to provide better context - naming what you are doing, will make code more self-documenting
| public RocksDbReader(DbOnTheRocks mainDb, | ||
| ReadOptions options, | ||
| ReadOptions hintCacheMissOptions, | ||
| Func<ReadOptions> readOptionsFactory, | ||
| DbOnTheRocks.IteratorManager? iteratorManager = null, | ||
| ColumnFamilyHandle? columnFamily = null) |
There was a problem hiding this comment.
Can this constructor be called by the other constructor with : this()? I think so
| public void Dispose() | ||
| { | ||
| snapshot.Dispose(); | ||
| if (_disposed) return; |
There was a problem hiding this comment.
This doesn't need to be threadsafe?
We could do - although would need to by byte not bool
| if (_disposed) return; | |
| bool disposed = Interlocked.Exchange(ref _disposed, true); | |
| if (disposed) return; |
| public IReadOnlyKeyValueStore GetColumn(T key) | ||
| { | ||
| return _columnDbs[key]; | ||
| return _readers[Convert.ToInt32(key)]; |
There was a problem hiding this comment.
can be single line with =>
| _sharedReadOptions = new ReadOptions(); | ||
| _sharedReadOptions.SetVerifyChecksums(columnsDb.VerifyChecksum); | ||
| _sharedReadOptions.SetSnapshot(snapshot); |
| _sharedCacheMissReadOptions = new ReadOptions(); | ||
| _sharedCacheMissReadOptions.SetVerifyChecksums(columnsDb.VerifyChecksum); | ||
| _sharedCacheMissReadOptions.SetSnapshot(snapshot); | ||
| _sharedCacheMissReadOptions.SetFillCache(false); |
| T[]? keys = columnsDb._cachedColumnKeys; | ||
| if (keys is null) | ||
| { | ||
| IDictionary<T, ColumnDb> columnDbs = columnsDb._columnDbs; | ||
| keys = new T[columnDbs.Count]; | ||
| int idx = 0; | ||
| foreach (T key in columnDbs.Keys) | ||
| keys[idx++] = key; | ||
| columnsDb._cachedColumnKeys = keys; | ||
| } |
| if (columnsDb._cachedMaxOrdinal < 0) | ||
| { | ||
| int max = 0; | ||
| for (int i = 0; i < keys.Length; i++) | ||
| max = Math.Max(max, Convert.ToInt32(keys[i])); | ||
| columnsDb._cachedMaxOrdinal = max; | ||
| } |
| _readers = new RocksDbReader[columnsDb._cachedMaxOrdinal + 1]; | ||
| for (int i = 0; i < keys.Length; i++) | ||
| { | ||
| T k = keys[i]; | ||
| _readers[Convert.ToInt32(k)] = new RocksDbReader( | ||
| columnsDb, | ||
| _sharedReadOptions, | ||
| _sharedCacheMissReadOptions, | ||
| readOptionsFactory, | ||
| columnFamily: columnsDb._columnDbs[k]._columnFamily); | ||
| } |
|
@claude review |
|
Claude finished @smartprogrammer93's task in 2m 2s —— View job Re-review (Final)All previous critical and moderate issues have been resolved. LukaszRozmej's review suggestions are also addressed. Here is the full updated status. Previously Critical — Fixed ✅
LukaszRozmej's Suggestions — All Addressed ✅
Minor FindingMissing readonly ReadOptions _options;
readonly ReadOptions _hintCacheMissOptions;All other fields in the class (lines 20-23) have explicit Still Open
Missing regression test — Per SummaryThe optimization is well-implemented and the profiling data clearly justifies it. The PR has been approved by LukaszRozmej. The two open items ( |
Changes
ReadOptionsinstances (normal + cache-miss) across all 7 columnRocksDbReaderinstances inColumnDbSnapshot, instead of creating 14 separate onesReadOptionshandles viarocksdb_readoptions_destroy+GC.SuppressFinalizeinColumnDbSnapshot.Dispose(), with double-dispose guardDictionary<T, IReadOnlyKeyValueStore>with flat array indexed by enum ordinal viaConvert.ToInt32(safe for all enum underlying types)Func<ReadOptions>delegate across all column readers instead of 7 separate closuresColumnsDb<T>with volatile fields for thread safetyRocksDbReaderconstructor that accepts pre-createdReadOptionsfor shared useRoot cause:
ReadOptionsin RocksDbSharp has a finalizer (~ReadOptions()) but does not implementIDisposable. EachColumnDbSnapshotcreated 14 finalizableReadOptionsobjects (2 per column x 7 columns), plus aDictionary, 7RocksDbReaderinstances, and 7 closure delegates. In FlatState block processing, thousands of snapshots per second produce tens of thousands of finalizable objects that survive Gen0, get promoted to Gen1/Gen2, and trigger expensive stop-the-world GC collections.Profiling data (ContractCall_200 FlatState, 2000 block processing calls per round, 20 rounds):
Remaining Gen1/Gen2 pressure is from per-scope FlatState infrastructure objects (
FlatWorldStateScope,SnapshotBundle,ReadOnlySnapshotBundle,StateTree, etc.) that require deeper architectural changes to pool.Types of changes
Testing
Requires testing
If yes, did you write tests?
Notes on testing
Validated via ProfileRunner benchmark with per-round GC tracking. Existing tests should pass since the behavior is identical - same ReadOptions configuration, just shared instead of duplicated. The array-indexed lookup via
Convert.ToInt32is functionally equivalent to the dictionary lookup and safe for all enum underlying types.Documentation
Requires documentation update
Requires explanation in Release Notes