-
Notifications
You must be signed in to change notification settings - Fork 576
perf(tree): binary-search anchor removeChild + O(1) revision lookup in editManager #27428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a045ef5
7aed52a
9b64f11
20bb41a
87be3cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1226,9 +1226,8 @@ class PathNode extends ReferenceCountedBase implements AnchorNode { | |||||||||||||||||||||||||||||||||||||||||||
| assert(this.status === Status.Alive, 0x40e /* PathNode must be alive */); | ||||||||||||||||||||||||||||||||||||||||||||
| const key = child.parentField; | ||||||||||||||||||||||||||||||||||||||||||||
| const field = this.children.get(key); | ||||||||||||||||||||||||||||||||||||||||||||
| // TODO: should do more optimized search (ex: binary search or better) using child.parentIndex() | ||||||||||||||||||||||||||||||||||||||||||||
| // Note that this is the index in the list of child paths, not the index within the field | ||||||||||||||||||||||||||||||||||||||||||||
| const childIndex = field?.indexOf(child) ?? -1; | ||||||||||||||||||||||||||||||||||||||||||||
| // Note that this is the index in the list of child paths, not the index within the field. | ||||||||||||||||||||||||||||||||||||||||||||
| const childIndex = field === undefined ? -1 : binaryFindIndex(field, child.parentIndex); | ||||||||||||||||||||||||||||||||||||||||||||
| assert(childIndex !== -1, 0x35c /* child must be parented to be removed */); | ||||||||||||||||||||||||||||||||||||||||||||
| field?.splice(childIndex, 1); | ||||||||||||||||||||||||||||||||||||||||||||
| if (field?.length === 0) { | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1310,6 +1309,32 @@ function binaryFind(sorted: readonly PathNode[], index: number): PathNode | unde | |||||||||||||||||||||||||||||||||||||||||||
| return undefined; // If we reach here, target is not in array (or array was not sorted) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||
| * Find the array index of a child PathNode by its parentIndex using a binary search. | ||||||||||||||||||||||||||||||||||||||||||||
| * @param sorted - array of PathNode's sorted by parentIndex. | ||||||||||||||||||||||||||||||||||||||||||||
| * @param index - parentIndex being looked for. | ||||||||||||||||||||||||||||||||||||||||||||
| * @returns array index of the child with the requested parentIndex, or -1 if not found. | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
| function binaryFindIndex(sorted: readonly PathNode[], index: number): number { | ||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deep Review: This Suggest extracting |
||||||||||||||||||||||||||||||||||||||||||||
| // inclusive | ||||||||||||||||||||||||||||||||||||||||||||
| let min = 0; | ||||||||||||||||||||||||||||||||||||||||||||
| // exclusive | ||||||||||||||||||||||||||||||||||||||||||||
| let max = sorted.length; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| while (min !== max) { | ||||||||||||||||||||||||||||||||||||||||||||
| const mid = Math.floor((min + max) / 2); | ||||||||||||||||||||||||||||||||||||||||||||
| const found = sorted[mid]!.parentIndex; | ||||||||||||||||||||||||||||||||||||||||||||
| if (found === index) { | ||||||||||||||||||||||||||||||||||||||||||||
| return mid; | ||||||||||||||||||||||||||||||||||||||||||||
| } else if (found > index) { | ||||||||||||||||||||||||||||||||||||||||||||
| max = mid; | ||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||
| min = mid + 1; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| return -1; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deep Review: The PR description says the fast-path "doesn't apply to the index-by-parentIndex case", but when the field is dense Suggest adding the guess fast-path before the binary loop and updating
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| interface BufferedEvent { | ||||||||||||||||||||||||||||||||||||||||||||
| node: PathNode; | ||||||||||||||||||||||||||||||||||||||||||||
| event: keyof AnchorEvents; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,7 +3,7 @@ | |||||||||||||||||||||||||||||||||||||||||||
| * Licensed under the MIT License. | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| import { assert } from "@fluidframework/core-utils/internal"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { assert, oob } from "@fluidframework/core-utils/internal"; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| import type { FieldKey } from "../schema-stored/index.js"; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -99,8 +99,8 @@ export class SparseNode<TData> implements UpPath { | |||||||||||||||||||||||||||||||||||||||||||
| if (field === undefined) { | ||||||||||||||||||||||||||||||||||||||||||||
| return undefined; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| // TODO: should do more optimized search (ex: binary search or better) using index. | ||||||||||||||||||||||||||||||||||||||||||||
| return field.find((c) => c.parentIndex === index); | ||||||||||||||||||||||||||||||||||||||||||||
| const childIndex = binaryFindIndex(field, index); | ||||||||||||||||||||||||||||||||||||||||||||
| return childIndex === -1 ? undefined : field[childIndex]; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -111,9 +111,8 @@ export class SparseNode<TData> implements UpPath { | |||||||||||||||||||||||||||||||||||||||||||
| public removeChild(child: SparseNode<TData>): void { | ||||||||||||||||||||||||||||||||||||||||||||
| const key = child.parentField; | ||||||||||||||||||||||||||||||||||||||||||||
| const field = this.children.get(key); | ||||||||||||||||||||||||||||||||||||||||||||
| // TODO: should do more optimized search (ex: binary search or better) using child.parentIndex() | ||||||||||||||||||||||||||||||||||||||||||||
| // Note that this is the index in the list of child paths, not the index within the field | ||||||||||||||||||||||||||||||||||||||||||||
| const childIndex = field?.indexOf(child) ?? -1; | ||||||||||||||||||||||||||||||||||||||||||||
| // Note that this is the index in the list of child paths, not the index within the field. | ||||||||||||||||||||||||||||||||||||||||||||
| const childIndex = field === undefined ? -1 : binaryFindIndex(field, child.parentIndex); | ||||||||||||||||||||||||||||||||||||||||||||
| assert(childIndex !== -1, 0x4a5 /* child must be parented to be removed */); | ||||||||||||||||||||||||||||||||||||||||||||
| field?.splice(childIndex, 1); | ||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deep Review: Same identity-loss concern as the
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||
| if (field?.length === 0) { | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -145,6 +144,34 @@ export class SparseNode<TData> implements UpPath { | |||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||
| * Find the array index of a child SparseNode by its parentIndex using a binary search. | ||||||||||||||||||||||||||||||||||||||||||||
| * @param sorted - array of SparseNode's sorted by parentIndex. | ||||||||||||||||||||||||||||||||||||||||||||
| * @param index - parentIndex being looked for. | ||||||||||||||||||||||||||||||||||||||||||||
| * @returns array index of the child with the requested parentIndex, or -1 if not found. | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
| function binaryFindIndex<TData>(sorted: readonly SparseNode<TData>[], index: number): number { | ||||||||||||||||||||||||||||||||||||||||||||
| // inclusive | ||||||||||||||||||||||||||||||||||||||||||||
| let min = 0; | ||||||||||||||||||||||||||||||||||||||||||||
| // exclusive | ||||||||||||||||||||||||||||||||||||||||||||
| let max = sorted.length; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| while (min !== max) { | ||||||||||||||||||||||||||||||||||||||||||||
| const mid = Math.floor((min + max) / 2); | ||||||||||||||||||||||||||||||||||||||||||||
| // mid < max <= sorted.length, so this access is always defined. | ||||||||||||||||||||||||||||||||||||||||||||
| const midNode = sorted[mid] ?? oob(); | ||||||||||||||||||||||||||||||||||||||||||||
| const found = midNode.parentIndex; | ||||||||||||||||||||||||||||||||||||||||||||
| if (found === index) { | ||||||||||||||||||||||||||||||||||||||||||||
| return mid; | ||||||||||||||||||||||||||||||||||||||||||||
| } else if (found > index) { | ||||||||||||||||||||||||||||||||||||||||||||
| max = mid; | ||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||
| min = mid + 1; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| return -1; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deep Review: Same missing fast-path as the
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export function getDescendant<TData>( | ||||||||||||||||||||||||||||||||||||||||||||
| ancestor: SparseNode<TData>, | ||||||||||||||||||||||||||||||||||||||||||||
| path: UpPath | undefined, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -424,6 +424,19 @@ export class EditManager< | |
| return branch.getLocalCommits(); | ||
| } | ||
|
|
||
| /** | ||
| * @returns the index of the local commit with the given revision in {@link getLocalCommits} for the given branch, | ||
| * or `undefined` if no such commit exists. | ||
| * @remarks O(1) alternative to scanning the array returned by {@link getLocalCommits}. | ||
| */ | ||
| public getLocalCommitIndexByRevision( | ||
| branchId: BranchId, | ||
| revision: RevisionTag, | ||
| ): number | undefined { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deep Review: Both solution-space reviewers independently designed a The fair counter is that |
||
| const branch = this.getSharedBranch(branchId); | ||
| return branch.getLocalCommitIndexByRevision(revision); | ||
| } | ||
|
|
||
| /** | ||
| * Gets the length of the longest branch maintained by this `EditManager`. | ||
| * This may be the length of a peer branch or the local branch. | ||
|
|
@@ -676,6 +689,21 @@ class SharedBranch<TEditor extends ChangeFamilyEditor, TChangeset> { | |
| */ | ||
| private readonly localCommits: GraphCommit<TChangeset>[] = []; | ||
|
|
||
| /** | ||
| * Side-map for O(1) revision -\> {@link localCommits} index lookup. | ||
| * @remarks | ||
| * Stored values are "absolute" indices that are NOT affected by shifts off the front of {@link localCommits}. | ||
| * The actual array index is computed as `value - localCommitsHeadOffset`. | ||
| * This avoids having to update every entry in the map when a commit is sequenced (shifted) off the front. | ||
| */ | ||
| private readonly localCommitsByRevision = new Map<RevisionTag, number>(); | ||
|
|
||
| /** | ||
| * The number of commits that have been shifted off the front of {@link localCommits} since the last bulk rebuild. | ||
| * Used together with {@link localCommitsByRevision} to translate absolute indices into current array indices. | ||
| */ | ||
| private localCommitsHeadOffset = 0; | ||
|
|
||
| /** | ||
| * Records extra data associated with sequenced commits. | ||
| * This does not include an entry for the {@link trunkBase}. | ||
|
|
@@ -709,6 +737,10 @@ class SharedBranch<TEditor extends ChangeFamilyEditor, TChangeset> { | |
| this.localBranch.events.on("afterChange", (event) => { | ||
| if (event.type === "append") { | ||
| for (const commit of event.newCommits) { | ||
| this.localCommitsByRevision.set( | ||
| commit.revision, | ||
| this.localCommits.length + this.localCommitsHeadOffset, | ||
| ); | ||
| this.localCommits.push(commit); | ||
| } | ||
| } else { | ||
|
|
@@ -717,6 +749,11 @@ class SharedBranch<TEditor extends ChangeFamilyEditor, TChangeset> { | |
| [this.localBranch.getHead(), this.localCommits], | ||
| this.trunk.getHead(), | ||
| ); | ||
| this.localCommitsByRevision.clear(); | ||
| this.localCommitsHeadOffset = 0; | ||
| for (const [i, commit] of this.localCommits.entries()) { | ||
| this.localCommitsByRevision.set(commit.revision, i); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
@@ -890,6 +927,18 @@ class SharedBranch<TEditor extends ChangeFamilyEditor, TChangeset> { | |
| return this.localCommits; | ||
| } | ||
|
|
||
| /** | ||
| * @returns the index of the local commit with the given revision in {@link getLocalCommits}, or `undefined` if none exists. | ||
| * @remarks This is an O(1) alternative to calling `findIndex` on the array returned by {@link getLocalCommits}. | ||
| */ | ||
| public getLocalCommitIndexByRevision(revision: RevisionTag): number | undefined { | ||
| const absoluteIndex = this.localCommitsByRevision.get(revision); | ||
| if (absoluteIndex === undefined) { | ||
| return undefined; | ||
| } | ||
| return absoluteIndex - this.localCommitsHeadOffset; | ||
| } | ||
|
|
||
| /** | ||
| * Promote the oldest un-sequenced commit on the local branch to the head of the trunk. | ||
| * @param sequenceId - The sequence id of the new trunk commit | ||
|
|
@@ -918,6 +967,12 @@ class SharedBranch<TEditor extends ChangeFamilyEditor, TChangeset> { | |
| firstLocalCommit !== undefined, | ||
| 0x6b5 /* Received a sequenced change from the local session despite having no local changes */, | ||
| ); | ||
| this.localCommitsByRevision.delete(firstLocalCommit.revision); | ||
| this.localCommitsHeadOffset += 1; | ||
| if (this.localCommits.length === 0) { | ||
| // Reset the offset when the array drains so it cannot grow unboundedly across sessions. | ||
| this.localCommitsHeadOffset = 0; | ||
| } | ||
|
|
||
| const prevSequenceId = this.getCommitSequenceId(this.trunk.getHead().revision); | ||
| this.pushGraphCommitToTrunk(sequenceId, firstLocalCommit, sessionId); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deep Review: The previous
field?.indexOf(child)implicitly cross-checked object identity (it returns-1if the exactchildreference is absent). The replacementbinaryFindIndex(field, child.parentIndex)returns a hit whenever some node with thatparentIndexexists — if the sort/uniqueness invariant ever drifted (duplicateparentIndex, stalechildreference), the PR would silently splice the wrong node.Extend the assert to also verify identity. Zero runtime cost in non-debug builds; restores the implicit invariant
indexOfprovided.