Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions packages/@glimmer/validator/lib/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import type { Revision } from './validators';

import { debug } from './debug';
import { unwrap } from './utils';
import { combine, CONSTANT_TAG, isConstTag, validateTag, valueForTag } from './validators';
import {
combine,
CONSTANT_TAG,
isCombinationOf,
isConstTag,
validateTag,
valueForTag,
} from './validators';

/**
* A tag with the `slot` field that `Tracker#add` reads and writes.
Expand All @@ -17,6 +24,30 @@ interface ConsumedTag extends Tag {
slot: number;
}

/**
* Makes the tag of a frame that consumed two or more tags.
*
* `previous` is the tag that the frame produced the last time it ran.
* If the frame consumed the same tags again, in the same order,
* the result is `previous`.
* That tag keeps its memoized revision, and nothing is allocated.
*
* 1. last run: consumed [ a, b, c ] result: tag X.
* 2. this run: consumed [ a, b, c ] result: tag X again.
* 3. or: consumed [ a, c ] result: a new tag.
*
* This code is not in `Tracker#combineForCache`, to keep that method small.
* V8 puts a small method inline into the end of the frame.
* With this code in it, a frame that consumed one tag was 6% slower.
*/
function combineOrReuse(previous: Tag | undefined, tags: (Tag | null)[], size: number): Tag {
if (previous !== undefined && isCombinationOf(previous, tags, size)) {
return previous;
}

return combine(tags.slice(0, size) as Tag[]);
}

/**
* An object that tracks @tracked properties that were consumed.
*
Expand Down Expand Up @@ -109,6 +140,29 @@ class Tracker {
return result;
}

/**
* `combine()` for the frame of a cache.
*
* `previous` is the tag that the cache has from its last run.
* See `combineOrReuse`.
*/
combineForCache(previous: Tag | undefined): Tag {
let { tags, size } = this;
let result: Tag;

if (size === 0) {
result = CONSTANT_TAG;
} else if (size === 1) {
result = tags[0] as Tag;
} else {
result = combineOrReuse(previous, tags, size);
}

this.clear();

return result;
}

/**
* Empties the tracker for the next frame at the same depth.
*
Expand Down Expand Up @@ -208,6 +262,32 @@ export function endTrackFrame(): Tag {
return unwrap(current).combine();
}

/**
* `endTrackFrame()` for the frame of a cache.
*
* `previous` is the tag that the cache has from its last run.
* If the cache consumed the same tags again, that tag is the result.
*
* Only `getValue` calls this.
* The frames of the render VM end in `endTrackFrame`,
* which has no code for the reuse.
*/
function endCacheFrame(previous: Tag | undefined): Tag {
let current = CURRENT_TRACKER;

if (DEBUG) {
if (OPEN_TRACK_FRAMES.length === 0) {
throw new Error('attempted to close a tracking frame, but one was not open');
}

unwrap(debug.endTrackingTransaction)();
}

CURRENT_TRACKER = OPEN_TRACK_FRAMES.pop() || null;

return unwrap(current).combineForCache(previous);
}

export function beginUntrackFrame(): void {
OPEN_TRACK_FRAMES.push(CURRENT_TRACKER);
CURRENT_TRACKER = null;
Expand Down Expand Up @@ -313,7 +393,7 @@ export function getValue<T>(cache: Cache<T>): T | undefined {
try {
cache[LAST_VALUE] = fn();
} finally {
tag = endTrackFrame();
tag = endCacheFrame(tag);
cache[TAG] = tag;
cache[SNAPSHOT] = valueForTag(tag);
consumeTag(tag);
Expand Down
17 changes: 17 additions & 0 deletions packages/@glimmer/validator/lib/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,23 @@ export const CURRENT_TAG = new CurrentTag();

export const combine = MonomorphicTagImpl.combine;

/**
* Whether `tag` is the combination of the first `size` entries of `tags`, in order.
*/
export function isCombinationOf(tag: Tag, tags: (Tag | null)[], size: number): boolean {
if (tag[TYPE] !== COMBINATOR_TAG_ID) return false;

let subtags = (tag as MonomorphicTagImpl).subtag as Tag[];

if (subtags.length !== size) return false;

for (let i = 0; i < size; i++) {
if (subtags[i] !== tags[i]) return false;
}

return true;
}

// Warm

let tag1 = createUpdatableTag();
Expand Down
193 changes: 193 additions & 0 deletions packages/@glimmer/validator/test/tracking-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,199 @@ module('@glimmer/validator: tracking', () => {
assert.deepEqual(getValue(outerCache), [3, 2], 'memoized result returned correctly');
});

/**
* The tag that a cache has after a read.
*
* The outer frame consumes only the tag of the cache,
* and a frame with one tag returns that tag.
*/
function tagOf(cache: ReturnType<typeof createCache>) {
beginTrackFrame();
getValue(cache);

return endTrackFrame();
}

test('it keeps its tag if it consumes the same tags in the next run', (assert) => {
let tag1 = createTag();
let tag2 = createTag();

let cache = createCache(() => {
assert.step('cache reads tag1 and tag2');

consumeTag(tag1);
consumeTag(tag2);
});

let first = tagOf(cache);

assert.verifySteps(['cache reads tag1 and tag2']);

dirtyTag(tag1);

let second = tagOf(cache);

assert.verifySteps(['cache reads tag1 and tag2'], 'the cache ran again');
assert.strictEqual(second, first, 'the cache has the tag of its first run');
});

test('it gets a new tag if it consumes other tags in the next run', (assert) => {
let tag1 = createTag();
let tag2 = createTag();
let tag3 = createTag();
let useThird = false;

let cache = createCache(() => {
consumeTag(tag1);

if (useThird) {
assert.step('cache reads tag1 and tag3');
consumeTag(tag3);
} else {
assert.step('cache reads tag1 and tag2');
consumeTag(tag2);
}
});

let first = tagOf(cache);

assert.verifySteps(['cache reads tag1 and tag2']);

useThird = true;
dirtyTag(tag1);

let second = tagOf(cache);

assert.verifySteps(['cache reads tag1 and tag3'], 'the cache ran again');
assert.notStrictEqual(second, first);
});

test('it gets a new tag if it consumes the same tags in another order', (assert) => {
let tag1 = createTag();
let tag2 = createTag();
let swap = false;

let cache = createCache(() => {
if (swap) {
assert.step('cache reads tag2, then tag1');
consumeTag(tag2);
consumeTag(tag1);
} else {
assert.step('cache reads tag1, then tag2');
consumeTag(tag1);
consumeTag(tag2);
}
});

let first = tagOf(cache);

assert.verifySteps(['cache reads tag1, then tag2']);

swap = true;
dirtyTag(tag1);

let second = tagOf(cache);

assert.verifySteps(['cache reads tag2, then tag1'], 'the cache ran again');
assert.notStrictEqual(second, first);

let snapshot = valueForTag(second);

dirtyTag(tag2);
assert.notOk(validateTag(second, snapshot), 'the new tag follows both tags');
});

test('it tracks the new tags if the tags change between two runs', (assert) => {
let tag1 = createTag();
let tag2 = createTag();
let tag3 = createTag();
let useThird = false;

let cache = createCache(() => {
consumeTag(tag1);

if (useThird) {
assert.step('cache reads tag1 and tag3');
consumeTag(tag3);
} else {
assert.step('cache reads tag1 and tag2');
consumeTag(tag2);
}
});

getValue(cache);
assert.verifySteps(['cache reads tag1 and tag2']);

dirtyTag(tag2);
getValue(cache);
assert.verifySteps(['cache reads tag1 and tag2'], 'the cache ran again with the same tags');

dirtyTag(tag2);
getValue(cache);
assert.verifySteps(['cache reads tag1 and tag2'], 'the reused tag still follows its tags');

useThird = true;
dirtyTag(tag1);
getValue(cache);
assert.verifySteps(['cache reads tag1 and tag3']);

dirtyTag(tag2);
getValue(cache);
assert.verifySteps([], 'the cache does not depend on the old tag');

dirtyTag(tag3);
getValue(cache);
assert.verifySteps(['cache reads tag1 and tag3'], 'the cache depends on the new tag');
});

test('an outer cache runs again if a tag of a reused inner tag changes', (assert) => {
let tag1 = createTag();
let tag2 = createTag();
let tag3 = createTag();

let inner = createCache(() => {
assert.step('inner reads tag1 and tag2');

consumeTag(tag1);
consumeTag(tag2);
});

let outer = createCache(() => {
assert.step('outer reads tag3 and inner');

consumeTag(tag3);
getValue(inner);
});

getValue(outer);
assert.verifySteps(['outer reads tag3 and inner', 'inner reads tag1 and tag2']);

dirtyTag(tag1);
getValue(outer);
assert.verifySteps(
['outer reads tag3 and inner', 'inner reads tag1 and tag2'],
'the inner cache ran again with the same tags'
);

dirtyTag(tag2);
getValue(outer);
assert.verifySteps(
['outer reads tag3 and inner', 'inner reads tag1 and tag2'],
'the outer cache follows the reused inner tag'
);

dirtyTag(tag3);
getValue(outer);
assert.verifySteps(['outer reads tag3 and inner'], 'the inner cache did not run again');

dirtyTag(tag1);
getValue(outer);
assert.verifySteps(
['outer reads tag3 and inner', 'inner reads tag1 and tag2'],
'the outer cache still follows the inner tag'
);
});

test('isTracking works within a memoized function and untrack frame', (assert) => {
assert.notOk(isTracking());

Expand Down
Loading