Skip to content

Commit 034bd05

Browse files
Improve performance for multiple cursors by adding scope handler cache (#3101)
Expensive textual scopes like surrounding pair and collection item had unreasonable performance with large files and multiple cursors. I ran into this with a large json file and Cursorless stopped responding. This pull request introduces a cache for scope handlers as well as new tests
1 parent 47035e0 commit 034bd05

File tree

12 files changed

+204
-53
lines changed

12 files changed

+204
-53
lines changed

packages/cursorless-engine/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export * from "./cursorlessEngine";
99
export * from "./customCommandGrammar/parseCommand";
1010
export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters";
1111
export * from "./generateSpokenForm/generateSpokenForm";
12+
export * from "./languages/TreeSitterQuery/TreeSitterQueryCache";
13+
export * from "./processTargets/modifiers/scopeHandlers/ScopeHandlerCache";
1214
export * from "./singletons/ide.singleton";
1315
export * from "./spokenForms/defaultSpokenFormMap";
1416
export * from "./testUtil/extractTargetKeys";

packages/cursorless-engine/src/languages/LanguageDefinitions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
import { Notifier, showError } from "@cursorless/common";
99
import { toString } from "lodash-es";
1010
import { LanguageDefinition } from "./LanguageDefinition";
11-
import { treeSitterQueryCache } from "./TreeSitterQuery/treeSitterQueryCache";
11+
import { treeSitterQueryCache } from "./TreeSitterQuery/TreeSitterQueryCache";
1212

1313
/**
1414
* Sentinel value to indicate that a language doesn't have

packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
getStartOfEndOfRange,
1717
rewriteStartOfEndOf,
1818
} from "./rewriteStartOfEndOf";
19-
import { treeSitterQueryCache } from "./treeSitterQueryCache";
19+
import { treeSitterQueryCache } from "./TreeSitterQueryCache";
2020

2121
/**
2222
* Wrapper around a tree-sitter query that provides a more convenient API, and

packages/cursorless-engine/src/languages/TreeSitterQuery/treeSitterQueryCache.ts renamed to packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQueryCache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Position, TextDocument } from "@cursorless/common";
22
import type { QueryMatch } from "./QueryCapture";
33

4-
export class Cache {
4+
export class TreeSitterQueryCache {
55
private documentVersion: number = -1;
66
private documentUri: string = "";
77
private documentLanguageId: string = "";
@@ -58,4 +58,4 @@ function positionsEqual(a: Position | undefined, b: Position | undefined) {
5858
return a.isEqual(b);
5959
}
6060

61-
export const treeSitterQueryCache = new Cache();
61+
export const treeSitterQueryCache = new TreeSitterQueryCache();

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/CollectionItemScopeHandler/CollectionItemTextualScopeHandler.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ComplexScopeType,
1414
ScopeIteratorRequirements,
1515
} from "../scopeHandler.types";
16+
import { scopeHandlerCache } from "../ScopeHandlerCache";
1617
import type { ScopeHandlerFactory } from "../ScopeHandlerFactory";
1718
import { isEveryScopeModifier } from "../util/isHintsEveryScope";
1819
import { OneWayNestedRangeFinder } from "../util/OneWayNestedRangeFinder";
@@ -44,6 +45,25 @@ export class CollectionItemTextualScopeHandler extends BaseScopeHandler {
4445
hints: ScopeIteratorRequirements,
4546
): Iterable<TargetScope> {
4647
const isEveryScope = isEveryScopeModifier(hints);
48+
const cacheKey = "CollectionItemTextualScopeHandler_" + isEveryScope;
49+
50+
if (!scopeHandlerCache.isValid(cacheKey, editor.document)) {
51+
const scopes = this.getsScopes(editor, direction, isEveryScope);
52+
scopeHandlerCache.update(cacheKey, editor.document, scopes);
53+
}
54+
55+
const scopes = scopeHandlerCache.get<TargetScope>();
56+
57+
scopes.sort((a, b) => compareTargetScopes(direction, position, a, b));
58+
59+
yield* scopes;
60+
}
61+
62+
private getsScopes(
63+
editor: TextEditor,
64+
direction: Direction,
65+
isEveryScope: boolean,
66+
) {
4767
const separatorRanges = getSeparatorOccurrences(editor.document);
4868
const interiorRanges = getInteriorRanges(
4969
this.scopeHandlerFactory,
@@ -134,9 +154,7 @@ export class CollectionItemTextualScopeHandler extends BaseScopeHandler {
134154
}
135155
}
136156

137-
scopes.sort((a, b) => compareTargetScopes(direction, position, a, b));
138-
139-
yield* scopes;
157+
return scopes;
140158
}
141159

142160
private addScopes(scopes: TargetScope[], state: IterationState) {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { TextDocument } from "@cursorless/common";
2+
3+
export class ScopeHandlerCache {
4+
private key: string = "";
5+
private documentVersion: number = -1;
6+
private documentUri: string = "";
7+
private documentLanguageId: string = "";
8+
private matches: any[] = [];
9+
10+
clear() {
11+
this.key = "";
12+
this.documentUri = "";
13+
this.documentVersion = -1;
14+
this.documentLanguageId = "";
15+
this.matches = [];
16+
}
17+
18+
isValid(key: string, document: TextDocument) {
19+
return (
20+
this.key === key &&
21+
this.documentVersion === document.version &&
22+
this.documentUri === document.uri.toString() &&
23+
this.documentLanguageId === document.languageId
24+
);
25+
}
26+
27+
update(key: string, document: TextDocument, matches: any[]) {
28+
this.key = key;
29+
this.documentVersion = document.version;
30+
this.documentUri = document.uri.toString();
31+
this.documentLanguageId = document.languageId;
32+
this.matches = matches;
33+
}
34+
35+
get<T>(): T[] {
36+
return this.matches;
37+
}
38+
}
39+
40+
export const scopeHandlerCache = new ScopeHandlerCache();

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/SurroundingPairScopeHandler.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { getDelimiterOccurrences } from "./getDelimiterOccurrences";
1919
import { getIndividualDelimiters } from "./getIndividualDelimiters";
2020
import { getSurroundingPairOccurrences } from "./getSurroundingPairOccurrences";
2121
import type { SurroundingPairOccurrence } from "./types";
22+
import { scopeHandlerCache } from "../ScopeHandlerCache";
2223

2324
export class SurroundingPairScopeHandler extends BaseScopeHandler {
2425
public readonly iterationScopeType: ConditionalScopeType = {
@@ -52,22 +53,31 @@ export class SurroundingPairScopeHandler extends BaseScopeHandler {
5253
return;
5354
}
5455

55-
const delimiterOccurrences = getDelimiterOccurrences(
56-
this.languageDefinitions.get(this.languageId),
57-
editor.document,
58-
getIndividualDelimiters(this.scopeType.delimiter, this.languageId),
59-
);
56+
const cacheKey = "SurroundingPairScopeHandler_" + this.scopeType.delimiter;
57+
58+
if (!scopeHandlerCache.isValid(cacheKey, editor.document)) {
59+
const delimiterOccurrences = getDelimiterOccurrences(
60+
this.languageDefinitions.get(this.languageId),
61+
editor.document,
62+
getIndividualDelimiters(this.scopeType.delimiter, this.languageId),
63+
);
64+
65+
const surroundingPairs =
66+
getSurroundingPairOccurrences(delimiterOccurrences);
67+
68+
scopeHandlerCache.update(cacheKey, editor.document, surroundingPairs);
69+
}
6070

61-
let surroundingPairs = getSurroundingPairOccurrences(delimiterOccurrences);
71+
const surroundingPairs = scopeHandlerCache.get<SurroundingPairOccurrence>();
6272

63-
surroundingPairs = maybeApplyEmptyTargetHack(
73+
const updatedSurroundingPairs = maybeApplyEmptyTargetHack(
6474
direction,
6575
hints,
6676
position,
6777
surroundingPairs,
6878
);
6979

70-
yield* surroundingPairs
80+
yield* updatedSurroundingPairs
7181
.map((pair) =>
7282
createTargetScope(
7383
editor,

packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts

Lines changed: 86 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type ScopeType,
66
type SimpleScopeTypeType,
77
} from "@cursorless/common";
8-
import { openNewEditor, runCursorlessCommand } from "@cursorless/vscode-common";
8+
import { openNewEditor, runCursorlessAction } from "@cursorless/vscode-common";
99
import assert from "assert";
1010
import * as vscode from "vscode";
1111
import { endToEndTestSetup } from "../endToEndTestSetup";
@@ -51,7 +51,7 @@ suite("Performance", async function () {
5151
["paragraph", smallThresholdMs],
5252
["document", smallThresholdMs],
5353
["nonWhitespaceSequence", smallThresholdMs],
54-
// Parse tree based, containing/every scope
54+
// Parse tree based, containing / every scope
5555
["string", smallThresholdMs],
5656
["map", smallThresholdMs],
5757
["collectionKey", smallThresholdMs],
@@ -65,9 +65,11 @@ suite("Performance", async function () {
6565
["boundedParagraph", largeThresholdMs],
6666
["boundedNonWhitespaceSequence", largeThresholdMs],
6767
["collectionItem", largeThresholdMs],
68+
["collectionItem", largeThresholdMs, "every"],
69+
["collectionItem", largeThresholdMs, "previous"],
6870
// Surrounding pair
69-
[{ type: "surroundingPair", delimiter: "any" }, largeThresholdMs],
7071
[{ type: "surroundingPair", delimiter: "curlyBrackets" }, largeThresholdMs],
72+
[{ type: "surroundingPair", delimiter: "any" }, largeThresholdMs],
7173
[{ type: "surroundingPair", delimiter: "any" }, largeThresholdMs, "every"],
7274
[
7375
{ type: "surroundingPair", delimiter: "any" },
@@ -86,10 +88,29 @@ suite("Performance", async function () {
8688
asyncSafety(() => selectScopeType(scopeType, threshold, modifierType)),
8789
);
8890
}
91+
92+
test(
93+
"Select surroundingPair with multiple cursors",
94+
asyncSafety(() =>
95+
selectWithMultipleCursors(largeThresholdMs, {
96+
type: "surroundingPair",
97+
delimiter: "any",
98+
}),
99+
),
100+
);
101+
102+
test(
103+
"Select collectionItem with multiple cursors",
104+
asyncSafety(() =>
105+
selectWithMultipleCursors(largeThresholdMs, {
106+
type: "collectionItem",
107+
}),
108+
),
109+
);
89110
});
90111

91-
async function removeToken(thresholdMs: number) {
92-
await testPerformance(thresholdMs, {
112+
function removeToken(thresholdMs: number) {
113+
return testPerformance(thresholdMs, {
93114
name: "remove",
94115
target: {
95116
type: "primitive",
@@ -98,12 +119,36 @@ async function removeToken(thresholdMs: number) {
98119
});
99120
}
100121

101-
async function selectScopeType(
122+
function selectWithMultipleCursors(thresholdMs: number, scopeType: ScopeType) {
123+
return testPerformanceCallback(
124+
thresholdMs,
125+
() => {
126+
return runCursorlessAction({
127+
name: "setSelectionBefore",
128+
target: {
129+
type: "primitive",
130+
modifiers: [getModifier({ type: "collectionItem" }, "every")],
131+
},
132+
});
133+
},
134+
() => {
135+
return runCursorlessAction({
136+
name: "setSelection",
137+
target: {
138+
type: "primitive",
139+
modifiers: [getModifier(scopeType)],
140+
},
141+
});
142+
},
143+
);
144+
}
145+
146+
function selectScopeType(
102147
scopeType: ScopeType,
103148
thresholdMs: number,
104149
modifierType?: ModifierType,
105150
) {
106-
await testPerformance(thresholdMs, {
151+
return testPerformance(thresholdMs, {
107152
name: "setSelection",
108153
target: {
109154
type: "primitive",
@@ -112,41 +157,31 @@ async function selectScopeType(
112157
});
113158
}
114159

115-
function getModifier(
116-
scopeType: ScopeType,
117-
modifierType: ModifierType = "containing",
118-
): Modifier {
119-
switch (modifierType) {
120-
case "containing":
121-
return { type: "containingScope", scopeType };
122-
case "every":
123-
return { type: "everyScope", scopeType };
124-
case "previous":
125-
return {
126-
type: "relativeScope",
127-
direction: "backward",
128-
offset: 1,
129-
length: 1,
130-
scopeType,
131-
};
132-
}
160+
function testPerformance(thresholdMs: number, action: ActionDescriptor) {
161+
return testPerformanceCallback(thresholdMs, () => {
162+
return runCursorlessAction(action);
163+
});
133164
}
134165

135-
async function testPerformance(thresholdMs: number, action: ActionDescriptor) {
166+
async function testPerformanceCallback(
167+
thresholdMs: number,
168+
callback: () => Promise<unknown>,
169+
beforeCallback?: () => Promise<unknown>,
170+
) {
136171
const editor = await openNewEditor(testData, { languageId: "json" });
137172
// This is the position of the last json key in the document
138173
const position = new vscode.Position(editor.document.lineCount - 3, 5);
139174
const selection = new vscode.Selection(position, position);
140175
editor.selections = [selection];
141176
editor.revealRange(selection);
142177

178+
if (beforeCallback != null) {
179+
await beforeCallback();
180+
}
181+
143182
const start = performance.now();
144183

145-
await runCursorlessCommand({
146-
version: 7,
147-
usePrePhraseSnapshot: false,
148-
action,
149-
});
184+
await callback();
150185

151186
const duration = Math.round(performance.now() - start);
152187

@@ -158,6 +193,26 @@ async function testPerformance(thresholdMs: number, action: ActionDescriptor) {
158193
);
159194
}
160195

196+
function getModifier(
197+
scopeType: ScopeType,
198+
modifierType: ModifierType = "containing",
199+
): Modifier {
200+
switch (modifierType) {
201+
case "containing":
202+
return { type: "containingScope", scopeType };
203+
case "every":
204+
return { type: "everyScope", scopeType };
205+
case "previous":
206+
return {
207+
type: "relativeScope",
208+
direction: "backward",
209+
offset: 1,
210+
length: 1,
211+
scopeType,
212+
};
213+
}
214+
}
215+
161216
function getScopeTypeAndTitle(
162217
scope: SimpleScopeTypeType | ScopeType,
163218
): [ScopeType, string] {

packages/cursorless-vscode/src/constructTestHelpers.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ import type {
1313
TextEditor,
1414
} from "@cursorless/common";
1515
import type { StoredTargetMap } from "@cursorless/cursorless-engine";
16-
import { plainObjectToTarget } from "@cursorless/cursorless-engine";
16+
import {
17+
plainObjectToTarget,
18+
scopeHandlerCache,
19+
treeSitterQueryCache,
20+
} from "@cursorless/cursorless-engine";
21+
import { takeSnapshot } from "@cursorless/test-case-recorder";
1722
import type { VscodeTestHelpers } from "@cursorless/vscode-common";
1823
import type * as vscode from "vscode";
19-
import { takeSnapshot } from "@cursorless/test-case-recorder";
24+
import { toVscodeEditor } from "./ide/vscode/toVscodeEditor";
2025
import type { VscodeFileSystem } from "./ide/vscode/VscodeFileSystem";
2126
import type { VscodeIDE } from "./ide/vscode/VscodeIDE";
22-
import { toVscodeEditor } from "./ide/vscode/toVscodeEditor";
2327
import { vscodeApi } from "./vscodeApi";
2428
import type { VscodeTutorial } from "./VscodeTutorial";
2529

@@ -45,6 +49,11 @@ export function constructTestHelpers(
4549
return vscodeIDE.fromVscodeEditor(editor);
4650
},
4751

52+
clearCache() {
53+
scopeHandlerCache.clear();
54+
treeSitterQueryCache.clear();
55+
},
56+
4857
// FIXME: Remove this once we have a better way to get this function
4958
// accessible from our tests
5059
takeSnapshot(

0 commit comments

Comments
 (0)