From d10b9ec9878813549ebb4582686bff409c3c2374 Mon Sep 17 00:00:00 2001 From: Altan Birler Date: Sat, 12 Jun 2021 17:06:50 +0200 Subject: [PATCH] Bound the memory use of `init_hydrate` Allocate the memory for `init_hydrate` in a single buffer and reuse this buffer internally with typed arrays to make memory usage easy to predict and potentially help performance. Add a test for a reordering corner case. --- src/runtime/internal/dom.ts | 139 ++++++++++++++----- test/hydration/samples/ordering/_after.html | 3 + test/hydration/samples/ordering/_before.html | 3 + test/hydration/samples/ordering/main.svelte | 3 + 4 files changed, 116 insertions(+), 32 deletions(-) create mode 100644 test/hydration/samples/ordering/_after.html create mode 100644 test/hydration/samples/ordering/_before.html create mode 100644 test/hydration/samples/ordering/main.svelte diff --git a/src/runtime/internal/dom.ts b/src/runtime/internal/dom.ts index 60f8b73ac6c4..437ddc7f7464 100644 --- a/src/runtime/internal/dom.ts +++ b/src/runtime/internal/dom.ts @@ -36,10 +36,35 @@ function init_hydrate(target: NodeEx) { if (target.hydrate_init) return; target.hydrate_init = true; - type NodeEx2 = NodeEx & {claim_order: number}; - // We know that all children have claim_order values since the unclaimed have been detached - const children = target.childNodes as NodeListOf; + const children = Array.from(target.childNodes as NodeListOf); + + const childCount = children.length; + + /* + * The memory used by this algorithm (except for `children`) is entirely contained in this buffer + * This buffer is split into multiple chunks that are reused + * + * + * chunk0 chunk1 chunk2 + * size=c size=c+1 size=c+1 + * |-----------------|------------------|------------------| + * |---claimOrders---|--------m---------|---------p-------*| + * |---claimOrders---|-lis-*---toMove--*|--anchors--*--bc--| + * + * c: childCount + * bc: bucketCounts + */ + const buffer = new ArrayBuffer((childCount * 3 + 2) * 4); + + const chunk0start = 0; + const chunk1start = childCount * 4; + const chunk2start = childCount * 8 + 4; + + const claimOrders = new Int32Array(buffer, chunk0start, childCount); + for (let i = 0; i < childCount; i++) { + claimOrders[i] = children[i].claim_order; + } /* * Reorder claimed children optimally. @@ -60,18 +85,18 @@ function init_hydrate(target: NodeEx) { // Compute longest increasing subsequence // m: subsequence length j => index k of smallest value that ends an increasing subsequence of length j - const m = new Int32Array(children.length + 1); + const m = new Int32Array(buffer, chunk1start, childCount + 1); // Predecessor indices + 1 - const p = new Int32Array(children.length); + const p = new Int32Array(buffer, chunk2start, childCount); m[0] = -1; let longest = 0; - for (let i = 0; i < children.length; i++) { - const current = children[i].claim_order; + for (let i = 0; i < claimOrders.length; i++) { + const current = claimOrders[i]; // Find the largest subsequence length such that it ends in a value less than our current value // upper_bound returns first greater value, so we subtract one - const seqLen = upper_bound(1, longest + 1, idx => children[m[idx]].claim_order, current) - 1; + const seqLen = upper_bound(1, longest + 1, idx => claimOrders[m[idx]], current) - 1; p[i] = m[seqLen] + 1; @@ -83,36 +108,86 @@ function init_hydrate(target: NodeEx) { longest = Math.max(newLen, longest); } - // The longest increasing subsequence of nodes (initially reversed) - const lis: NodeEx2[] = []; - for (let cur = m[longest] + 1; cur != 0; cur = p[cur - 1]) { - const node = children[cur - 1]; - lis.push(node); - node.is_in_lis = true; - } - lis.reverse(); + const start = m[longest] + 1; + // The longest increasing subsequence of nodes (initially reversed). + const lis = new Int32Array(buffer, chunk1start, longest); + // The nodes that are not in lis. Reuse m since it won't be used again + const toMove = new Int32Array(buffer, chunk1start + lis.length * 4, childCount - lis.length); + + for (let cur = start, lisInd = longest - 1; cur != 0; cur = p[cur - 1], --lisInd) { + lis[lisInd] = cur - 1; + } + + let toMoveInd = 0; + let j = 0; + for (let i = 0; i < lis.length; j++, i++) { + const cur = lis[i]; + for (; j < cur; j++, toMoveInd++) { + toMove[toMoveInd] = j; + } + } + for (; j < claimOrders.length; j++, toMoveInd++) { + toMove[toMoveInd] = j; + } + + // The lis node that a node will be moved before + const anchors = new Int32Array(buffer, chunk2start, childCount - longest); + // The number of nodes per bucket. A bucket is the space between two lis node (or the space before the first or after the last lis node) + const bucketCounts = new Int32Array(buffer, chunk2start + anchors.length * 4, longest + 1); + + for (let i = 0; i < toMove.length; i++) { + const idx = upper_bound(0, lis.length, idx => claimOrders[lis[idx]], claimOrders[toMove[i]]); + anchors[i] = idx; + bucketCounts[idx]++; + } + + // Compute the prefix sum of bucketCounts to get the bucket offsets + for (let i = 0, sum = 0; i < bucketCounts.length; i++) { + const cur = sum; + sum += bucketCounts[i]; + bucketCounts[i] = cur; + } - // Move all nodes that aren't in the longest increasing subsequence - const toMove = lis.map(() => [] as NodeEx2[]); - // For the nodes at the end - toMove.push([]); - for (let i = 0; i < children.length; i++) { - const node = children[i]; - if (!node.is_in_lis) { - const idx = upper_bound(0, lis.length, idx => lis[idx].claim_order, node.claim_order); - toMove[idx].push(node); + // Organize the toMove nodes by buckets, in-place + // Takes O(toMove.length) time since: + // * Outer loop visits an index only once + // * A node is moved to the right position (inner kernel) at most once + // * Thus there is O(1) operations per node/index + for (let i = 0; i < toMove.length; i++) { + while (anchors[i] !== -1) { + const targetBucket = anchors[i]; + const targetInd = bucketCounts[targetBucket]; + bucketCounts[targetBucket] += 1; + + // We move i to the right position + [toMove[targetInd], toMove[i]] = [toMove[i], toMove[targetInd]]; + [anchors[targetInd], anchors[i]] = [anchors[i], anchors[targetInd]]; + anchors[targetInd] = -1; } } - toMove.forEach((lst, idx) => { + // Make the insertBefore calls for the toMove nodes + let last = 0; + for (let i = 0; i < lis.length + 1; i++) { + const cur = bucketCounts[i]; + if (last == cur) { + // The bucket is empty + continue; + } + const curToMove = toMove.subarray(last, cur); + const anchorNode = i < lis.length ? children[lis[i]] : null; // We sort the nodes being moved to guarantee that their insertion order matches the claim order - lst.sort((a, b) => a.claim_order - b.claim_order); + // Only sorting within buckets is enough + curToMove.sort((a, b) => claimOrders[a] - claimOrders[b]); - const anchor = idx < lis.length ? lis[idx] : null; - lst.forEach(n => { - target.insertBefore(n, anchor); - }); - }); + // Move the nodes + for (const cInd of curToMove) { + const node = children[cInd]; + target.insertBefore(node, anchorNode); + } + + last = cur; + } } export function append(target: NodeEx, node: NodeEx) { diff --git a/test/hydration/samples/ordering/_after.html b/test/hydration/samples/ordering/_after.html new file mode 100644 index 000000000000..117301857711 --- /dev/null +++ b/test/hydration/samples/ordering/_after.html @@ -0,0 +1,3 @@ +
+

+
diff --git a/test/hydration/samples/ordering/_before.html b/test/hydration/samples/ordering/_before.html new file mode 100644 index 000000000000..a9ad709edb3a --- /dev/null +++ b/test/hydration/samples/ordering/_before.html @@ -0,0 +1,3 @@ +
+

+
diff --git a/test/hydration/samples/ordering/main.svelte b/test/hydration/samples/ordering/main.svelte new file mode 100644 index 000000000000..117301857711 --- /dev/null +++ b/test/hydration/samples/ordering/main.svelte @@ -0,0 +1,3 @@ +
+

+