Skip to content
Closed
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
26 changes: 26 additions & 0 deletions packages/@glimmer/reference/lib/iterable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface IterationItem<T, U> {
export interface AbstractIterator<T, U, V extends IterationItem<T, U>> {
isEmpty(): boolean;
next(): Nullable<V>;
/**
* SPIKE: allocation-free iteration -- writes into `target` and returns
* it, instead of allocating a fresh item per step. Optional; callers
* must not retain the returned object across steps.
*/
nextInto?(target: V): Nullable<V>;
}

export type OpaqueIterationItem = IterationItem<unknown, unknown>;
Expand Down Expand Up @@ -263,4 +269,24 @@ class ArrayIterator implements OpaqueIterator {

return { key, value, memo };
}

nextInto(target: IterationItem<unknown, number>): Nullable<IterationItem<unknown, number>> {
let value: unknown;

let current = this.current;
if (current.kind === 'first') {
this.current = { kind: 'progress' };
value = current.value;
} else if (this.pos >= this.iterator.length - 1) {
return null;
} else {
value = this.iterator[++this.pos];
}

target.key = this.keyFor(value, this.pos);
target.value = value;
target.memo = this.pos;

return target;
}
}
264 changes: 223 additions & 41 deletions packages/@glimmer/runtime/lib/vm/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ import type {
} from '@glimmer/interfaces';
import type { OpaqueIterationItem, OpaqueIterator } from '@glimmer/reference/lib/iterable';
import type { Reference } from '@glimmer/reference/lib/reference';
import type { Revision, Tag } from '@glimmer/interfaces';
import { expect, unwrap } from '@glimmer/debug-util/lib/platform-utils';
import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable';
import { LOCAL_DEBUG } from '@glimmer/local-debug-flags';
import { updateRef, valueForRef } from '@glimmer/reference/lib/reference';
import { logStep } from '@glimmer/util/lib/debug-steps';
import { StackImpl as Stack } from '@glimmer/util/lib/collections';
import { debug } from '@glimmer/validator/lib/debug';
import { resetTracking } from '@glimmer/validator/lib/tracking';
import {
beginTrackFrame,
consumeTag,
endTrackFrame,
resetTracking,
} from '@glimmer/validator/lib/tracking';
import { INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators';

import type { Closure } from './append';
import type { AppendingBlockList } from './element-builder';
Expand All @@ -36,7 +42,15 @@ export class UpdatingVM implements IUpdatingVM {
public dom: GlimmerTreeChanges;
public alwaysRevalidate: boolean;

private frameStack: Stack<UpdatingVMFrame> = new Stack<UpdatingVMFrame>();
/**
* SPIKE: a flat frame stack (parallel arrays indexed by depth)
* instead of allocating an UpdatingVMFrame per block per render.
*/
#ops: UpdatingOpcode[][] = [];
#current: number[] = [];
#handlers: Nullable<ExceptionHandler>[] = [];
#finalizers: (((didError: boolean) => void) | undefined)[] = [];
#depth = -1;

constructor(env: Environment, { alwaysRevalidate = false }) {
this.env = env;
Expand Down Expand Up @@ -69,40 +83,64 @@ export class UpdatingVM implements IUpdatingVM {
}

private _execute(opcodes: UpdatingOpcode[], handler: ExceptionHandler) {
let { frameStack } = this;

this.try(opcodes, handler);

while (!frameStack.isEmpty()) {
let opcode = this.frame.nextStatement();
while (this.#depth >= 0) {
let depth = this.#depth;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depth checked
let ops = this.#ops[depth]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depth checked
let index = this.#current[depth]!;

if (opcode === undefined) {
frameStack.pop();
if (index >= ops.length) {
this.#pop(false);
continue;
}

opcode.evaluate(this);
this.#current[depth] = index + 1;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked
ops[index]!.evaluate(this);
}
}

private get frame() {
return expect(this.frameStack.current, 'bug: expected a frame');
#pop(didError: boolean) {
let depth = this.#depth;
let finalizer = this.#finalizers[depth];

// release references so retained arrays don't leak between renders
this.#ops[depth] = EMPTY_OPS;
this.#handlers[depth] = null;
this.#finalizers[depth] = undefined;
this.#depth = depth - 1;

finalizer?.(didError);
}

goto(index: number) {
this.frame.goto(index);
this.#current[this.#depth] = index;
}

try(ops: UpdatingOpcode[], handler: Nullable<ExceptionHandler>) {
this.frameStack.push(new UpdatingVMFrame(ops, handler));
try(
ops: UpdatingOpcode[],
handler: Nullable<ExceptionHandler>,
finalizer?: (didError: boolean) => void
) {
let depth = ++this.#depth;

this.#ops[depth] = ops;
this.#current[depth] = 0;
this.#handlers[depth] = handler;
this.#finalizers[depth] = finalizer;
}

throw() {
this.frame.handleException();
this.frameStack.pop();
this.#handlers[this.#depth]?.handleException();
this.#pop(true);
}
}

const EMPTY_OPS: UpdatingOpcode[] = [];

export interface VMState {
readonly pc: number;
readonly scope: Scope;
Expand Down Expand Up @@ -178,6 +216,15 @@ export class ListItemOpcode extends TryOpcode {
public retained = false;
public index = -1;

/**
* Everything this item's subtree consumed during its last update,
* combined. When still valid, the whole subtree is skipped -- one tag
* validation instead of walking every opcode in the item.
*/
private subtreeTag: Nullable<Tag> = null;
private subtreeRevision: Revision = INITIAL;
private isTrivial: boolean | null = null;

constructor(
state: Closure,
context: EvaluationContext,
Expand All @@ -189,6 +236,52 @@ export class ListItemOpcode extends TryOpcode {
super(state, context, bounds, []);
}

override evaluate(vm: UpdatingVM) {
// Trivial items (a text node or two) can't win: validating their
// combined tag costs as much as just updating them, so collection
// would be pure overhead. Skipping only pays off for items with a
// real subtree -- more than a couple of opcodes, or any nested
// block (a nested block child means an arbitrarily large subtree
// hides behind a small top-level count).
if (this.isTrivial ?? (this.isTrivial = computeIsTrivial(this.children))) {
vm.try(this.children, this);
return;
}

let { subtreeTag } = this;

if (
subtreeTag !== null &&
!vm.alwaysRevalidate &&
validateTag(subtreeTag, this.subtreeRevision)
) {
// propagate this item's dependencies to any enclosing tracking
// frame, exactly as executing the children would have
consumeTag(subtreeTag);
return;
}

beginTrackFrame();
vm.try(this.children, this, (didError) => {
// always balance beginTrackFrame, even when unwinding
let tag = endTrackFrame();

if (didError) return;

this.subtreeTag = tag;
this.subtreeRevision = valueForTag(tag);
consumeTag(tag);
});
}

override handleException() {
// children are about to be rebuilt; the collected tag and triviality
// no longer describe them
this.subtreeTag = null;
this.isTrivial = null;
super.handleException();
}

shouldRemove(): boolean {
return !this.retained;
}
Expand All @@ -198,6 +291,16 @@ export class ListItemOpcode extends TryOpcode {
}
}

function computeIsTrivial(children: UpdatingOpcode[]): boolean {
if (children.length > 2) return false;

for (const child of children) {
if (child instanceof BlockOpcode) return false;
}

return true;
}

export class ListBlockOpcode extends BlockOpcode {
public type = 'list-block';
declare public children: ListItemOpcode[];
Expand Down Expand Up @@ -228,27 +331,103 @@ export class ListBlockOpcode extends BlockOpcode {
let iterator = valueForRef(this.iterableRef);

if (this.lastIterator !== iterator) {
let { bounds } = this;
let { dom } = vm;
// SPIKE: deriving a fresh array from tracked state is the idiomatic
// pattern, so iterator identity changes every render even when the
// list's keys did not. When items match the existing children in
// order and count, just update the item refs -- no diff
// bookkeeping, no marker DOM, no children rebuild.
let buffered = this.tryFastSync(iterator);

if (buffered !== null) {
let { bounds } = this;
let { dom } = vm;

let marker = (this.marker = dom.createComment(''));
dom.insertAfter(
bounds.parentElement(),
marker,
expect(bounds.lastNode(), "can't insert after an empty bounds")
);

let marker = (this.marker = dom.createComment(''));
dom.insertAfter(
bounds.parentElement(),
marker,
expect(bounds.lastNode(), "can't insert after an empty bounds")
);
this.sync(new PrefixedIterator(buffered, iterator));

this.sync(iterator);
this.parentElement().removeChild(marker);
this.marker = null;
}

this.parentElement().removeChild(marker);
this.marker = null;
this.lastIterator = iterator;
}

// Run now-updated updating opcodes
super.evaluate(vm);
}

/**
* Streaming compare of the new iteration against existing children,
* applied as it matches: allocation-free on the happy path (a shared
* scratch item via nextInto). Returns null when everything matched in
* order; otherwise reconstructs the already-applied prefix (reading
* the just-updated refs back) plus the mismatched item, so the full
* sync can replay them.
*/
private tryFastSync(iterator: OpaqueIterator): Nullable<OpaqueIterationItem[]> {
let { children } = this;
let matched = 0;

while (true) {
let item =
iterator.nextInto !== undefined ? iterator.nextInto(SCRATCH_ITEM) : iterator.next();

if (item === null) {
if (matched === children.length) return null;

// the list shrank; replay the matched prefix through full sync
return this.reconstructPrefix(matched, null);
}

let opcode = children[matched];

if (opcode === undefined || opcode.key !== item.key) {
return this.reconstructPrefix(matched, {
key: item.key,
value: item.value,
memo: item.memo,
});
}

updateRef(opcode.memo, item.memo);
updateRef(opcode.value, item.value);
matched++;
}
}

/**
* The matched prefix was already applied to the item refs, so its
* items can be reconstructed from the opcodes themselves.
*/
private reconstructPrefix(
matched: number,
mismatch: Nullable<OpaqueIterationItem>
): OpaqueIterationItem[] {
let { children } = this;
let prefix: OpaqueIterationItem[] = [];

for (let i = 0; i < matched; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked
let opcode = children[i]!;

prefix.push({
key: opcode.key,
value: valueForRef(opcode.value),
memo: valueForRef(opcode.memo),
});
}

if (mismatch !== null) prefix.push(mismatch);

return prefix;
}

private sync(iterator: OpaqueIterator) {
let { opcodeMap: itemMap, children } = this;

Expand Down Expand Up @@ -423,25 +602,28 @@ export class ListBlockOpcode extends BlockOpcode {
}
}

class UpdatingVMFrame {
private current = 0;
/** Shared scratch for allocation-free fast-path iteration. */
const SCRATCH_ITEM: OpaqueIterationItem = { key: null, value: null, memo: null };

/** Replays already-consumed items before draining the rest. */
class PrefixedIterator implements OpaqueIterator {
private index = 0;

constructor(
private ops: UpdatingOpcode[],
private exceptionHandler: Nullable<ExceptionHandler>
private prefix: OpaqueIterationItem[],
private inner: OpaqueIterator
) {}

goto(index: number) {
this.current = index;
}

nextStatement(): UpdatingOpcode | undefined {
return this.ops[this.current++];
isEmpty(): boolean {
return this.index >= this.prefix.length && this.inner.isEmpty();
}

handleException() {
if (this.exceptionHandler) {
this.exceptionHandler.handleException();
next(): Nullable<OpaqueIterationItem> {
if (this.index < this.prefix.length) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked
return this.prefix[this.index++]!;
}

return this.inner.next();
}
}
Loading
Loading