Skip to content

Commit

Permalink
fix: Fix ref counting and cascading evictions in lazy store (#405)
Browse files Browse the repository at this point in the history
Problem
======
1. Ref counting in the lazy store is buggy resulting in incorrect and even negative ref counts.  The main problem is that the refs of cached chunks (lazily loaded or put) in a Write that were already "reachable" (already had a positive ref count) are not counted.  This is because `computeRefCountUpdates` assumes that the refs of all "reachable" chunks are already counted (i.e. new refs can only come from new heads), it does not have logic for handling the lazy discovery of refs via chunk lazy loading.
2. Current eviction logic in the lazy store kind of mixes the ideas of eviction and deletion.  When a chunk is evicted, the ref counts of all of the chunks its ref's are decremented.  If any go to zero they are evicted, and so on recursively.  This can lead to very large cascading evictions.  In particular if we are traversing a DAG that exceeds the cache limit, at the time the limit is exceeded the least recently accessed node is the root.  Evicting the root can cascade and cause everything cached so far to be evicted.

Solution
======
Cache refs separately from chunks.  Clearly distinguish eviction vs deletion of chunks.

- Eviction
  When evicting a chunk do not modify its ref count or delete its cached refs, only remove the chunk from the cache.
- Deletion
  When deleting a chunk (because its ref count has gone to 0), delete its ref count entry, cached refs and the chunk itself.

Add logic to `computeRefCountUpdates` for handling lazily discovered refs.

Improve documentation and tests.

Performance
==========

Performance impact looks neutral.

Perf comparisons made on my mac laptop
  Model Name: MacBook Pro
  Model Identifier: MacBookPro18,4
  Chip: Apple M1 Max
  Total Number of Cores: 10 (8 performance and 2 efficiency)
  Memory: 64 GB
  Physical Drive:
    Device Name: APPLE SSD AP1024R
    Media Name: AppleAPFSMedia
    Medium Type: SSD
    Capacity: 994.66 GB (994,662,584,320 bytes)

***main***
[greg replicache-internal [main]$ npm run perf -- --format replicache

> [email protected] perf
> npm run build-perf && node perf/runner.js "--format" "replicache"

> [email protected] build-perf
> node tool/build.js --perf

Running 24 benchmarks on Chromium...
writeSubRead 1MB total, 64 subs total, 5 subs dirty, 16kb read per sub 50/75/90/95%=1.30/1.50/1.60/2.00 ms avg=1.31 ms (19 runs sampled)
writeSubRead 4MB total, 128 subs total, 5 subs dirty, 16kb read per sub 50/75/90/95%=0.90/1.00/1.10/1.40 ms avg=1.02 ms (19 runs sampled)
writeSubRead 16MB total, 128 subs total, 5 subs dirty, 16kb read per sub 50/75/90/95%=1.20/1.30/1.40/2.30 ms avg=1.33 ms (16 runs sampled)
writeSubRead 64MB total, 128 subs total, 5 subs dirty, 16kb read per sub 50/75/90/95%=1.50/2.40/3.80/3.80 ms avg=2.14 ms (7 runs sampled)
populate 1024x1000 (clean, indexes: 0) 50/75/90/95%=7.70/9.00/12.60/16.60 ms avg=9.03 ms (19 runs sampled)
populate 1024x1000 (clean, indexes: 1) 50/75/90/95%=16.30/18.90/20.90/23.20 ms avg=18.31 ms (19 runs sampled)
populate 1024x1000 (clean, indexes: 2) 50/75/90/95%=22.70/24.50/26.50/29.20 ms avg=23.78 ms (19 runs sampled)
populate 1024x10000 (clean, indexes: 0) 50/75/90/95%=42.10/45.10/67.70/67.70 ms avg=53.04 ms (10 runs sampled)
populate 1024x10000 (clean, indexes: 1) 50/75/90/95%=94.30/96.70/114.00/114.00 ms avg=122.79 ms (7 runs sampled)
populate 1024x10000 (clean, indexes: 2) 50/75/90/95%=141.60/167.60/172.70/172.70 ms avg=188.91 ms (7 runs sampled)
scan 1024x1000 50/75/90/95%=0.90/1.20/1.40/1.80 ms avg=0.96 ms (19 runs sampled)
scan 1024x10000 50/75/90/95%=6.30/6.30/8.20/9.20 ms avg=7.13 ms (19 runs sampled)
create index with definition 1024x5000 50/75/90/95%=109.20/112.80/119.80/119.80 ms avg=138.80 ms (7 runs sampled)
create index 1024x5000 50/75/90/95%=23.10/24.20/27.80/30.10 ms avg=26.30 ms (19 runs sampled)
startup read 1024x100 from 1024x100000 stored 50/75/90/95%=56.00/71.70/89.00/89.00 ms avg=72.20 ms (7 runs sampled)
startup scan 1024x100 from 1024x100000 stored 50/75/90/95%=16.50/27.80/59.70/60.70 ms avg=22.79 ms (19 runs sampled)
persist 1024x1000 (indexes: 0) 50/75/90/95%=138.60/163.70/441.20/441.20 ms avg=181.53 ms (7 runs sampled)
persist 1024x1000 (indexes: 1) 50/75/90/95%=176.50/177.60/177.60/177.60 ms avg=210.80 ms (7 runs sampled)
persist 1024x1000 (indexes: 2) 50/75/90/95%=220.90/223.50/238.30/238.30 ms avg=272.47 ms (7 runs sampled)
persist 1024x10000 (indexes: 0) 50/75/90/95%=553.30/561.70/646.00/646.00 ms avg=709.26 ms (7 runs sampled)
persist 1024x10000 (indexes: 1) 50/75/90/95%=2765.30/2819.80/2894.20/2894.20 ms avg=3496.86 ms (7 runs sampled)
persist 1024x10000 (indexes: 2) 50/75/90/95%=4254.80/4311.80/4349.60/4349.60 ms avg=5438.34 ms (7 runs sampled)
populate tmcw 50/75/90/95%=57.30/63.20/82.20/82.20 ms avg=75.66 ms (7 runs sampled)
persist tmcw 50/75/90/95%=318.00/318.80/328.30/328.30 ms avg=402.17 ms (7 runs sampled)
Done!

***this change***
greg replicache-internal [grgbkr/lazy-store-gc-evicition-fixes]$ npm run perf -- --format replicache

> [email protected] perf
> npm run build-perf && node perf/runner.js "--format" "replicache"

> [email protected] build-perf
> node tool/build.js --perf

Running 24 benchmarks on Chromium...
writeSubRead 1MB total, 64 subs total, 5 subs dirty, 16kb read per sub 50/75/90/95%=1.00/1.10/1.20/1.70 ms avg=1.03 ms (19 runs sampled)
writeSubRead 4MB total, 128 subs total, 5 subs dirty, 16kb read per sub 50/75/90/95%=1.00/1.10/1.20/1.30 ms avg=1.09 ms (19 runs sampled)
writeSubRead 16MB total, 128 subs total, 5 subs dirty, 16kb read per sub 50/75/90/95%=1.10/1.30/1.60/2.30 ms avg=1.26 ms (16 runs sampled)
writeSubRead 64MB total, 128 subs total, 5 subs dirty, 16kb read per sub 50/75/90/95%=1.30/1.60/2.40/2.40 ms avg=1.74 ms (7 runs sampled)
populate 1024x1000 (clean, indexes: 0) 50/75/90/95%=7.50/9.10/11.40/15.40 ms avg=9.05 ms (19 runs sampled)
populate 1024x1000 (clean, indexes: 1) 50/75/90/95%=14.20/16.80/19.20/21.20 ms avg=16.41 ms (19 runs sampled)
populate 1024x1000 (clean, indexes: 2) 50/75/90/95%=18.80/21.10/25.20/29.40 ms avg=21.88 ms (19 runs sampled)
populate 1024x10000 (clean, indexes: 0) 50/75/90/95%=43.20/54.60/70.60/70.60 ms avg=58.29 ms (9 runs sampled)
populate 1024x10000 (clean, indexes: 1) 50/75/90/95%=95.90/97.70/116.40/116.40 ms avg=124.99 ms (7 runs sampled)
populate 1024x10000 (clean, indexes: 2) 50/75/90/95%=141.80/167.90/173.00/173.00 ms avg=191.01 ms (7 runs sampled)
scan 1024x1000 50/75/90/95%=0.90/1.00/1.50/1.60 ms avg=0.92 ms (19 runs sampled)
scan 1024x10000 50/75/90/95%=6.20/6.30/8.20/8.90 ms avg=7.07 ms (19 runs sampled)
create index with definition 1024x5000 50/75/90/95%=106.10/111.20/113.60/113.60 ms avg=135.33 ms (7 runs sampled)
create index 1024x5000 50/75/90/95%=24.00/24.80/27.10/31.70 ms avg=26.95 ms (19 runs sampled)
startup read 1024x100 from 1024x100000 stored 50/75/90/95%=55.40/70.70/71.70/71.70 ms avg=63.96 ms (8 runs sampled)
startup scan 1024x100 from 1024x100000 stored 50/75/90/95%=14.30/29.60/59.40/60.90 ms avg=20.55 ms (19 runs sampled)
persist 1024x1000 (indexes: 0) 50/75/90/95%=184.80/282.80/329.70/329.70 ms avg=237.26 ms (7 runs sampled)
persist 1024x1000 (indexes: 1) 50/75/90/95%=171.60/172.80/176.00/176.00 ms avg=197.54 ms (7 runs sampled)
persist 1024x1000 (indexes: 2) 50/75/90/95%=224.80/227.20/231.60/231.60 ms avg=275.53 ms (7 runs sampled)
persist 1024x10000 (indexes: 0) 50/75/90/95%=539.20/545.10/558.50/558.50 ms avg=678.00 ms (7 runs sampled)
persist 1024x10000 (indexes: 1) 50/75/90/95%=2312.30/2403.00/2428.90/2428.90 ms avg=2843.89 ms (7 runs sampled)
persist 1024x10000 (indexes: 2) 50/75/90/95%=3606.60/3656.50/3665.10/3665.10 ms avg=4601.70 ms (7 runs sampled)
populate tmcw 50/75/90/95%=50.60/53.90/80.60/80.60 ms avg=64.89 ms (8 runs sampled)
persist tmcw 50/75/90/95%=313.10/314.70/339.90/339.90 ms avg=396.77 ms (7 runs sampled)
Done!

(cherry picked from commit 4378df2)
  • Loading branch information
grgbkr authored and arv committed May 5, 2023
1 parent caee58c commit be903e1
Show file tree
Hide file tree
Showing 12 changed files with 800 additions and 562 deletions.
7 changes: 1 addition & 6 deletions src/btree/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {BTreeWrite} from './write.js';
import {skipBTreeNodeAsserts} from '../config.js';
import {binarySearch as binarySearchWithFunc} from '../binary-search.js';
import type {IndexKey} from '../mod.js';
import {joinIterables} from '../iterables.js';

export type Entry<V> = readonly [key: string, value: V];

Expand Down Expand Up @@ -364,12 +365,6 @@ function readonlySplice<T>(
return arr;
}

function* joinIterables<T>(...iters: Iterable<T>[]) {
for (const iter of iters) {
yield* iter;
}
}

export class InternalNodeImpl extends NodeImpl<Hash> {
readonly level: number;

Expand Down
25 changes: 8 additions & 17 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
const isProd = process.env.NODE_ENV === 'production';

export const skipCommitDataAsserts = isProd;

export const skipAssertJSONValue = isProd;

export const skipBTreeNodeAsserts = isProd;

/**
* In debug mode we assert that chunks and BTree data is deeply frozen. In
* release mode we skip these asserts.
*/
export const skipFrozenAsserts = isProd;

/**
* In debug mode we deeply freeze the values we read out of the IDB store and we
* deeply freeze the values we put into the stores.
*/
export const skipFreeze = isProd;
export {
isProd as skipCommitDataAsserts,
isProd as skipAssertJSONValue,
isProd as skipBTreeNodeAsserts,
isProd as skipGCAsserts,
isProd as skipFrozenAsserts,
isProd as skipFreeze,
};
91 changes: 87 additions & 4 deletions src/dag/gc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {expect} from '@esm-bundle/chai';
import {fakeHash, Hash} from '../hash.js';
import {computeRefCountUpdates, GarbageCollectionDelegate} from './gc.js';
import {computeRefCountUpdates, RefCountUpdatesDelegate} from './gc.js';

function createGraph(args: {
graph: Record<string, string[]>;
Expand Down Expand Up @@ -30,7 +30,7 @@ function createGraph(args: {
}
}

const delegate: GarbageCollectionDelegate = {
const delegate: RefCountUpdatesDelegate = {
getRefCount: hash => refCounts[hash.toString()] || 0,
getRefs: hash => refs[hash.toString()] || [],
};
Expand All @@ -43,14 +43,33 @@ function createGraph(args: {
};
}

function createLazyDelegate(
refCounts: Record<string, number>,
refs: Record<string, readonly string[]>,
counted: Set<string>,
): RefCountUpdatesDelegate {
const refCountsHashes = Object.fromEntries(
Object.keys(refCounts).map(k => [fakeHash(k), refCounts[k]]),
);
const refsHashes = Object.fromEntries(
Object.keys(refs).map(k => [fakeHash(k), refs[k].map(w => fakeHash(w))]),
);
const countedHashes = new Set([...counted.values()].map(w => fakeHash(w)));
return {
getRefCount: hash => refCountsHashes[hash],
getRefs: hash => refsHashes[hash],
areRefsCounted: hash => countedHashes.has(hash),
};
}

function expectRefCountUpdates(
actual: Map<Hash, number>,
expected: Record<string, number>,
) {
const expectedAsMap = new Map(
const expectedHashes = Object.fromEntries(
Object.entries(expected).map(([k, v]) => [fakeHash(k), v]),
);
expect(actual).to.deep.equal(expectedAsMap);
expect(Object.fromEntries(actual)).to.deep.equal(expectedHashes);
}

test('computeRefCountUpdates includes entry for every putChunk', async () => {
Expand Down Expand Up @@ -226,6 +245,70 @@ test('computeRefCountUpdates for 3 incoming refs bypassing one level', async ()
});
});

test('computeRefCountUpdates with lazy delegate', async () => {
// 0
// / | \
// A B C
// \ | / \
// D E
// | |
// F AA
// | /
// BB
// Refs for 0, A, B are counted, ref for C, D, E, F, AA, and BB are not
// Changing to the below, and putting 1, C, E, and F
//
// 1
// |
// 0
// / | \
// A B C
// \ | / \
// D E
// | |
// F AA
// | /
// BB

const delegate1 = createLazyDelegate(
{0: 1, a: 1, b: 1, c: 1, d: 2},
{
1: ['0'],
0: ['a', 'b', 'c'],
a: ['d'],
b: ['d'],
c: ['d', 'e'],
e: ['aa'],
f: ['bb'],
},
new Set(['0', 'a', 'b']),
);

const refCountUpdates = await computeRefCountUpdates(
[{old: fakeHash('0'), new: fakeHash('1')}],
new Set([fakeHash('1'), fakeHash('c'), fakeHash('e'), fakeHash('f')]),
delegate1,
);

// Expect C and E refs to be counted. C was already known to be reachable
// (positive ref count) but not counted, E becomes reachable via C. Expect
// F's refs not to be counted, it was not known to be reachable
// (since D's refs have not been counted), and is still not known
// to be reachable after the write (D's refs remain uncounted).
// While the counting of E's refs discovers that AA is reachable (giving
// it a ref count of 1), AA's refs are not counted because they are not
// returned by the delegate (and thus BB's ref count is still 0).
expectRefCountUpdates(refCountUpdates, {
'0': 1,
'1': 1,
'c': 1,
'd': 3,
'e': 1,
'aa': 1,
'f': 0,
});
});

test('computeRefCountUpdates for heads updating to same hash should have no refcount updates', async () => {
const {hashes, delegate} = createGraph({
graph: {
Expand Down
Loading

0 comments on commit be903e1

Please sign in to comment.