Skip to content

Commit 0f90033

Browse files
lambdalisueclaude
andcommitted
feat: implement performance optimizations for fall picker
Implements copy-on-write approach and multiple performance improvements: - Add item cloning to prevent in-place modifications during sort/render - Implement debounced preview processing to reduce CPU during navigation - Cache byte length calculations in input component - Optimize event consumption with for loops instead of forEach - Update processor restart logic to leverage copy-on-write These changes significantly improve performance for large item collections by avoiding redundant processing and reducing memory allocations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 6e24d80 commit 0f90033

File tree

13 files changed

+622
-50
lines changed

13 files changed

+622
-50
lines changed

PERFORMANCE_IMPROVEMENTS.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Performance Improvement Recommendations for vim-fall
2+
3+
After analyzing the codebase, here are key performance improvement
4+
opportunities:
5+
6+
## ✅ Implemented Improvements
7+
8+
### 1. Optimized Event Queue Processing
9+
10+
- **Change**: Replaced `forEach` with `for` loop and added early return for
11+
empty queue
12+
- **Impact**: Reduced overhead for event processing, especially with many events
13+
- **Files**: `event.ts`
14+
- **Tests**: Enhanced `event_test.ts` with performance and edge case tests
15+
16+
### 2. Cached Byte Length Calculations
17+
18+
- **Change**: Added caching for prefix/suffix byte lengths in input component
19+
- **Impact**: Avoids repeated UTF-8 encoding operations during rendering
20+
- **Files**: `component/input.ts`
21+
- **Tests**: Existing tests in `component/input_test.ts` ensure correctness
22+
23+
### 3. Lazy Preview Loading with Debounce
24+
25+
- **Change**: Added 150ms debounce to preview updates during cursor movement
26+
- **Impact**: Prevents unnecessary preview processing during rapid navigation
27+
- **Files**: `picker.ts`, `lib/debounce.ts` (new)
28+
- **Tests**: Created comprehensive tests in `lib/debounce_test.ts`
29+
30+
### 4. Copy-on-Write for Processor Items
31+
32+
- **Change**: Implemented deep cloning of items in sort and render processors
33+
- **Impact**: Eliminated need to restart from match processor when switching sorters/renderers
34+
- **Files**: `processor/sort.ts`, `processor/render.ts`, `picker.ts`, `lib/item_clone.ts` (new)
35+
- **Tests**: Created tests in `lib/item_clone_test.ts`, updated `processor/sort_test.ts`
36+
37+
## 📋 Remaining Opportunities
38+
39+
## 1. Reduce Component Re-renders
40+
41+
Components check multiple modified flags on every scheduler tick (10ms). The
42+
input component (input.ts:232-247) renders even when only spinner updates:
43+
44+
```typescript
45+
get #isSpinnerUpdated(): boolean {
46+
return (this.collecting || this.processing) && !this.#spinner.locked;
47+
}
48+
```
49+
50+
**Solution**: Implement dirty checking with granular update flags and batch DOM
51+
updates.
52+
53+
## 2. Improve Memory Management
54+
55+
UniqueOrderedList (unique_ordered_list.ts) maintains both Set and Array:
56+
57+
```typescript
58+
#seen: Set<unknown> = new Set();
59+
#items: T[];
60+
```
61+
62+
**Solution**: For large datasets, consider a single Map<unknown, T> with
63+
insertion order preservation.
64+
65+
## 3. Batch Async Operations
66+
67+
Multiple processors can trigger simultaneously, causing cascading updates:
68+
69+
```typescript
70+
// collect-processor-updated triggers match
71+
// match-processor-updated triggers sort
72+
// sort-processor-succeeded triggers render
73+
```
74+
75+
**Solution**: Implement update batching with a priority queue to coalesce rapid
76+
changes.
77+
78+
## 4. Optimize Scheduler Interval
79+
80+
Default 10ms scheduler interval may be too aggressive for some operations:
81+
82+
```typescript
83+
const SCHEDULER_INTERVAL = 10;
84+
```
85+
86+
**Solution**: Make scheduler adaptive based on workload - increase interval when
87+
idle, decrease during active input.
88+
89+
## 5. Virtual Scrolling Enhancement
90+
91+
Current implementation slices arrays on every render:
92+
93+
```typescript
94+
const displayItems = items
95+
.slice(this.offset, this.offset + this.height);
96+
```
97+
98+
**Solution**: Implement a viewport buffer that reuses item objects when
99+
scrolling incrementally.
100+
101+
## 6. Worker Thread Processing
102+
103+
For CPU-intensive operations like matching/sorting large datasets:
104+
105+
**Solution**: Move matcher/sorter processing to Web Workers to keep UI thread
106+
responsive.
107+
108+
## Implementation Priority
109+
110+
1. **High Impact, Low Effort**:
111+
✅ Lazy preview loading (implemented)
112+
✅ String operation caching (implemented)
113+
✅ Event queue optimization (implemented)
114+
✅ Copy-on-write for processor items (implemented)
115+
116+
2. **High Impact, Medium Effort**:
117+
- Batch async operations (#3)
118+
- Component re-render optimization (#1)
119+
120+
3. **High Impact, High Effort**:
121+
- Worker thread processing (#6)
122+
- Virtual scrolling enhancement (#5)
123+
124+
4. **Medium Impact**:
125+
- Memory management improvements (#2)
126+
- Adaptive scheduler (#4)
127+
128+
These optimizations would significantly improve latency, especially for large
129+
datasets and rapid user interactions.

deno.jsonc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"exclude": [
33
"docs/**",
4-
".coverage/**"
4+
".coverage/**",
5+
"PERFORMANCE_IMPROVEMENTS.md"
56
],
67
"tasks": {
78
"check": "deno check ./**/*.ts",

denops/fall/component/input.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export class InputComponent extends BaseComponent {
7979
#modifiedWindow = true;
8080
#modifiedContent = true;
8181

82+
// Cache for byte lengths to avoid repeated calculations
83+
#prefixCache?: { value: string; byteLength: number };
84+
#suffixCache?: { value: string; byteLength: number };
85+
8286
constructor(
8387
{
8488
title,
@@ -287,9 +291,25 @@ export class InputComponent extends BaseComponent {
287291
this.#offset,
288292
this.#offset + cmdwidth,
289293
);
290-
const prefixByteLength = getByteLength(prefix);
294+
295+
// Use cached byte lengths when possible
296+
let prefixByteLength: number;
297+
if (this.#prefixCache?.value === prefix) {
298+
prefixByteLength = this.#prefixCache.byteLength;
299+
} else {
300+
prefixByteLength = getByteLength(prefix);
301+
this.#prefixCache = { value: prefix, byteLength: prefixByteLength };
302+
}
303+
291304
const middleByteLength = getByteLength(middle);
292-
const suffixByteLength = getByteLength(suffix);
305+
306+
let suffixByteLength: number;
307+
if (this.#suffixCache?.value === suffix) {
308+
suffixByteLength = this.#suffixCache.byteLength;
309+
} else {
310+
suffixByteLength = getByteLength(suffix);
311+
this.#suffixCache = { value: suffix, byteLength: suffixByteLength };
312+
}
293313

294314
await buffer.replace(denops, bufnr, [prefix + middle + suffix]);
295315
signal?.throwIfAborted();

denops/fall/event.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ export function dispatch(event: Readonly<Event>): void {
77
}
88

99
export function consume(consumer: Consumer): void {
10+
// Optimize: Swap arrays instead of creating new ones each time
1011
const events = eventQueue;
12+
if (events.length === 0) return;
13+
1114
eventQueue = [];
12-
events.forEach(consumer);
15+
// Use for loop instead of forEach for better performance
16+
for (let i = 0; i < events.length; i++) {
17+
consumer(events[i]);
18+
}
1319
}
1420

1521
type SelectMethod = "on" | "off" | "toggle";

denops/fall/event_test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,80 @@ Deno.test("Event", async (t) => {
2323
});
2424
assertEquals(dispatchedEvents, []);
2525
});
26+
27+
await t.step("multiple consumers receive all events in order", () => {
28+
dispatch({ type: "vim-cmdline-changed", cmdline: "test1" });
29+
dispatch({ type: "vim-cmdpos-changed", cmdpos: 5 });
30+
dispatch({ type: "vim-cmdline-changed", cmdline: "test2" });
31+
32+
const results: Event[][] = [];
33+
consume((event) => {
34+
if (!results[0]) results[0] = [];
35+
results[0].push(event);
36+
});
37+
38+
assertEquals(results[0], [
39+
{ type: "vim-cmdline-changed", cmdline: "test1" },
40+
{ type: "vim-cmdpos-changed", cmdpos: 5 },
41+
{ type: "vim-cmdline-changed", cmdline: "test2" },
42+
]);
43+
});
44+
45+
await t.step("handles large number of events", () => {
46+
const eventCount = 10000;
47+
for (let i = 0; i < eventCount; i++) {
48+
dispatch({ type: "vim-cmdpos-changed", cmdpos: i });
49+
}
50+
51+
let receivedCount = 0;
52+
consume((event) => {
53+
assertEquals(event.type, "vim-cmdpos-changed");
54+
receivedCount++;
55+
});
56+
57+
assertEquals(receivedCount, eventCount);
58+
});
59+
60+
await t.step("events are cleared after consume", () => {
61+
dispatch({ type: "vim-cmdline-changed", cmdline: "test" });
62+
63+
let firstConsumeCount = 0;
64+
consume(() => {
65+
firstConsumeCount++;
66+
});
67+
assertEquals(firstConsumeCount, 1);
68+
69+
let secondConsumeCount = 0;
70+
consume(() => {
71+
secondConsumeCount++;
72+
});
73+
assertEquals(secondConsumeCount, 0);
74+
});
75+
76+
await t.step("handles events dispatched during consume", () => {
77+
dispatch({ type: "vim-cmdline-changed", cmdline: "initial" });
78+
79+
const events: Event[] = [];
80+
consume((event) => {
81+
events.push(event);
82+
if (event.type === "vim-cmdline-changed" && event.cmdline === "initial") {
83+
// This dispatch happens during consume - should not be consumed in this cycle
84+
dispatch({ type: "vim-cmdpos-changed", cmdpos: 42 });
85+
}
86+
});
87+
88+
assertEquals(events, [
89+
{ type: "vim-cmdline-changed", cmdline: "initial" },
90+
]);
91+
92+
// The event dispatched during consume should be available in next consume
93+
const nextEvents: Event[] = [];
94+
consume((event) => {
95+
nextEvents.push(event);
96+
});
97+
98+
assertEquals(nextEvents, [
99+
{ type: "vim-cmdpos-changed", cmdpos: 42 },
100+
]);
101+
});
26102
});

denops/fall/lib/debounce.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Creates a debounced version of a function that delays invoking the function
3+
* until after `delay` milliseconds have elapsed since the last time it was invoked.
4+
*
5+
* @param fn The function to debounce
6+
* @param delay The number of milliseconds to delay
7+
* @returns A debounced version of the function
8+
*/
9+
// deno-lint-ignore no-explicit-any
10+
export function debounce<T extends (...args: any[]) => any>(
11+
fn: T,
12+
delay: number,
13+
): (...args: Parameters<T>) => void {
14+
let timerId: number | undefined;
15+
16+
return function debounced(...args: Parameters<T>): void {
17+
if (timerId !== undefined) {
18+
clearTimeout(timerId);
19+
}
20+
21+
timerId = setTimeout(() => {
22+
timerId = undefined;
23+
fn(...args);
24+
}, delay);
25+
};
26+
}
27+
28+
/**
29+
* A debouncer class that provides more control over debounced execution.
30+
*/
31+
export class Debouncer {
32+
#timerId?: number;
33+
#delay: number;
34+
35+
constructor(delay: number) {
36+
this.#delay = delay;
37+
}
38+
39+
/**
40+
* Executes the callback after the specified delay.
41+
* If called again before the delay expires, the previous call is cancelled.
42+
*/
43+
call(callback: () => void): void {
44+
if (this.#timerId !== undefined) {
45+
clearTimeout(this.#timerId);
46+
}
47+
48+
this.#timerId = setTimeout(() => {
49+
this.#timerId = undefined;
50+
callback();
51+
}, this.#delay);
52+
}
53+
54+
/**
55+
* Cancels any pending execution.
56+
*/
57+
cancel(): void {
58+
if (this.#timerId !== undefined) {
59+
clearTimeout(this.#timerId);
60+
this.#timerId = undefined;
61+
}
62+
}
63+
64+
/**
65+
* Returns whether there is a pending execution.
66+
*/
67+
get pending(): boolean {
68+
return this.#timerId !== undefined;
69+
}
70+
}

0 commit comments

Comments
 (0)