patches: add 0009 line index perf fix, update README.txt
New patch 0009 fixes O(L) line scanning in accessibilityLineForIndex: and accessibilityRangeForLine: by adding a precomputed lineStartOffsets array built once per cache rebuild. Queries go from O(L) linear scan to O(log L) binary search. README.txt: updated patch listing, text cache section, known limitations (O(L) issue now resolved), stress test threshold raised to 50,000 lines.
This commit is contained in:
266
patches/0009-ns-fix-O-n-line-scanning-in-accessibility.patch
Normal file
266
patches/0009-ns-fix-O-n-line-scanning-in-accessibility.patch
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
From 8dc935c6d88c129dcf4394c0ef4dc070ffc9aac8 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
|
Date: Sat, 28 Feb 2026 21:15:04 +0100
|
||||||
|
Subject: [PATCH] =?UTF-8?q?ns:=20fix=20O(n)=20line=20scanning=20in=20acces?=
|
||||||
|
=?UTF-8?q?sibility=20=E2=80=94=20use=20precomputed=20line=20index?=
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=UTF-8
|
||||||
|
Content-Transfer-Encoding: 8bit
|
||||||
|
|
||||||
|
accessibilityLineForIndex: and accessibilityRangeForLine: scanned
|
||||||
|
from position 0 using lineRangeForRange: in a loop, making them
|
||||||
|
O(L) where L is the line number. At line 50,000 this caused
|
||||||
|
VoiceOver to stall (~150,000 iterations per cursor move via
|
||||||
|
postFocusedCursorNotification: which calls these 3 times).
|
||||||
|
|
||||||
|
Build a precomputed lineStartOffsets array in ensureTextCache,
|
||||||
|
populated once per cache rebuild (O(L) amortized). Line queries
|
||||||
|
now use binary search: O(log L).
|
||||||
|
|
||||||
|
* src/nsterm.h (EmacsAccessibilityBuffer): Add lineStartOffsets
|
||||||
|
and lineCount ivars. Add lineForAXIndex: and
|
||||||
|
rangeForLine:textLength: method declarations.
|
||||||
|
* src/nsterm.m (lineForAXIndex:): New method. Binary search
|
||||||
|
over lineStartOffsets for O(log L) line lookup.
|
||||||
|
(rangeForLine:textLength:): New method. O(1) range lookup
|
||||||
|
using lineStartOffsets.
|
||||||
|
(ensureTextCache): Build lineStartOffsets after setting
|
||||||
|
cachedText.
|
||||||
|
(invalidateTextCache): Free lineStartOffsets.
|
||||||
|
(dealloc): Free lineStartOffsets.
|
||||||
|
(accessibilityInsertionPointLineNumber): Delegate to
|
||||||
|
lineForAXIndex: instead of linear scan.
|
||||||
|
(accessibilityLineForIndex:): Likewise.
|
||||||
|
(accessibilityRangeForLine:): Delegate to rangeForLine:textLength:
|
||||||
|
instead of linear scan.
|
||||||
|
|
||||||
|
Performance: cursor movement at line 50,000 goes from ~150,000
|
||||||
|
iterations to ~51 (3 × log2(50000) ≈ 3 × 17).
|
||||||
|
---
|
||||||
|
src/nsterm.h | 4 ++
|
||||||
|
src/nsterm.m | 148 ++++++++++++++++++++++++++++++++++-----------------
|
||||||
|
2 files changed, 103 insertions(+), 49 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/src/nsterm.h b/src/nsterm.h
|
||||||
|
index 8b34300..1a8a84d 100644
|
||||||
|
--- a/src/nsterm.h
|
||||||
|
+++ b/src/nsterm.h
|
||||||
|
@@ -499,6 +499,8 @@ typedef struct ns_ax_visible_run
|
||||||
|
{
|
||||||
|
ns_ax_visible_run *visibleRuns;
|
||||||
|
NSUInteger visibleRunCount;
|
||||||
|
+ NSUInteger *lineStartOffsets; /* AX string index of each line start. */
|
||||||
|
+ NSUInteger lineCount; /* Number of entries in lineStartOffsets. */
|
||||||
|
NSMutableArray *cachedInteractiveSpans;
|
||||||
|
BOOL interactiveSpansDirty;
|
||||||
|
}
|
||||||
|
@@ -515,6 +517,8 @@ typedef struct ns_ax_visible_run
|
||||||
|
@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd;
|
||||||
|
@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint;
|
||||||
|
- (void)invalidateTextCache;
|
||||||
|
+- (NSInteger)lineForAXIndex:(NSUInteger)idx;
|
||||||
|
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen;
|
||||||
|
- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx;
|
||||||
|
- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos;
|
||||||
|
@end
|
||||||
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
|
index 2e7d776..057ebe7 100644
|
||||||
|
--- a/src/nsterm.m
|
||||||
|
+++ b/src/nsterm.m
|
||||||
|
@@ -7825,6 +7825,8 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
[cachedInteractiveSpans release];
|
||||||
|
if (visibleRuns)
|
||||||
|
xfree (visibleRuns);
|
||||||
|
+ if (lineStartOffsets)
|
||||||
|
+ xfree (lineStartOffsets);
|
||||||
|
[super dealloc];
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -7842,10 +7844,65 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
visibleRuns = NULL;
|
||||||
|
}
|
||||||
|
visibleRunCount = 0;
|
||||||
|
+ if (lineStartOffsets)
|
||||||
|
+ {
|
||||||
|
+ xfree (lineStartOffsets);
|
||||||
|
+ lineStartOffsets = NULL;
|
||||||
|
+ }
|
||||||
|
+ lineCount = 0;
|
||||||
|
}
|
||||||
|
[self invalidateInteractiveSpans];
|
||||||
|
}
|
||||||
|
|
||||||
|
+/* ---- Line index helpers ---- */
|
||||||
|
+
|
||||||
|
+/* Return the line number for AX string index IDX using the
|
||||||
|
+ precomputed lineStartOffsets array. Binary search: O(log L)
|
||||||
|
+ where L is the number of lines in the cached text.
|
||||||
|
+
|
||||||
|
+ lineStartOffsets[i] holds the AX string index where line i
|
||||||
|
+ begins. Built once per cache rebuild in ensureTextCache. */
|
||||||
|
+- (NSInteger)lineForAXIndex:(NSUInteger)idx
|
||||||
|
+{
|
||||||
|
+ @synchronized (self)
|
||||||
|
+ {
|
||||||
|
+ if (!lineStartOffsets || lineCount == 0)
|
||||||
|
+ return 0;
|
||||||
|
+
|
||||||
|
+ /* Binary search for the largest line whose start offset <= idx. */
|
||||||
|
+ NSUInteger lo = 0, hi = lineCount;
|
||||||
|
+ while (lo < hi)
|
||||||
|
+ {
|
||||||
|
+ NSUInteger mid = lo + (hi - lo) / 2;
|
||||||
|
+ if (lineStartOffsets[mid] <= idx)
|
||||||
|
+ lo = mid + 1;
|
||||||
|
+ else
|
||||||
|
+ hi = mid;
|
||||||
|
+ }
|
||||||
|
+ return (NSInteger) (lo > 0 ? lo - 1 : 0);
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+/* Return the AX string range for LINE using the precomputed
|
||||||
|
+ lineStartOffsets array. O(1) lookup. */
|
||||||
|
+- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen
|
||||||
|
+{
|
||||||
|
+ @synchronized (self)
|
||||||
|
+ {
|
||||||
|
+ if (!lineStartOffsets || lineCount == 0)
|
||||||
|
+ return NSMakeRange (NSNotFound, 0);
|
||||||
|
+
|
||||||
|
+ if (line >= lineCount)
|
||||||
|
+ return NSMakeRange (NSNotFound, 0);
|
||||||
|
+
|
||||||
|
+ NSUInteger start = lineStartOffsets[line];
|
||||||
|
+ NSUInteger end = (line + 1 < lineCount)
|
||||||
|
+ ? lineStartOffsets[line + 1]
|
||||||
|
+ : tlen;
|
||||||
|
+ return NSMakeRange (start, end - start);
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
- (void)ensureTextCache
|
||||||
|
{
|
||||||
|
NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
|
||||||
|
@@ -7895,6 +7952,45 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
xfree (visibleRuns);
|
||||||
|
visibleRuns = runs;
|
||||||
|
visibleRunCount = nruns;
|
||||||
|
+
|
||||||
|
+ /* Build line-start index for O(log L) line queries.
|
||||||
|
+ Walk the cached text once, recording the start offset
|
||||||
|
+ of each line. This runs once per cache rebuild (on text
|
||||||
|
+ change or narrowing), not per cursor move. */
|
||||||
|
+ if (lineStartOffsets)
|
||||||
|
+ xfree (lineStartOffsets);
|
||||||
|
+ lineStartOffsets = NULL;
|
||||||
|
+ lineCount = 0;
|
||||||
|
+
|
||||||
|
+ NSUInteger tlen = [cachedText length];
|
||||||
|
+ if (tlen > 0)
|
||||||
|
+ {
|
||||||
|
+ NSUInteger cap = 256;
|
||||||
|
+ lineStartOffsets = xmalloc (cap * sizeof (NSUInteger));
|
||||||
|
+ lineStartOffsets[0] = 0;
|
||||||
|
+ lineCount = 1;
|
||||||
|
+ NSUInteger pos = 0;
|
||||||
|
+ while (pos < tlen)
|
||||||
|
+ {
|
||||||
|
+ NSRange lr = [cachedText lineRangeForRange:
|
||||||
|
+ NSMakeRange (pos, 0)];
|
||||||
|
+ NSUInteger next = NSMaxRange (lr);
|
||||||
|
+ if (next <= pos)
|
||||||
|
+ break; /* safety */
|
||||||
|
+ if (next < tlen)
|
||||||
|
+ {
|
||||||
|
+ if (lineCount >= cap)
|
||||||
|
+ {
|
||||||
|
+ cap *= 2;
|
||||||
|
+ lineStartOffsets = xrealloc (lineStartOffsets,
|
||||||
|
+ cap * sizeof (NSUInteger));
|
||||||
|
+ }
|
||||||
|
+ lineStartOffsets[lineCount] = next;
|
||||||
|
+ lineCount++;
|
||||||
|
+ }
|
||||||
|
+ pos = next;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -8362,21 +8458,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
if (point_idx > [cachedText length])
|
||||||
|
point_idx = [cachedText length];
|
||||||
|
|
||||||
|
- /* Count lines by iterating lineRangeForRange from the start.
|
||||||
|
- Each call jumps an entire line â O(lines) not O(chars). */
|
||||||
|
- NSInteger line = 0;
|
||||||
|
- NSUInteger scan = 0;
|
||||||
|
- NSUInteger len = [cachedText length];
|
||||||
|
- while (scan < point_idx && scan < len)
|
||||||
|
- {
|
||||||
|
- NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
|
||||||
|
- NSUInteger next = NSMaxRange (lr);
|
||||||
|
- if (next <= scan) break; /* safety */
|
||||||
|
- if (next > point_idx) break;
|
||||||
|
- line++;
|
||||||
|
- scan = next;
|
||||||
|
- }
|
||||||
|
- return line;
|
||||||
|
+ return [self lineForAXIndex:point_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)accessibilityStringForRange:(NSRange)range
|
||||||
|
@@ -8419,20 +8501,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
if (idx > [cachedText length])
|
||||||
|
idx = [cachedText length];
|
||||||
|
|
||||||
|
- /* Count lines by iterating lineRangeForRange --- O(lines). */
|
||||||
|
- NSInteger line = 0;
|
||||||
|
- NSUInteger scan = 0;
|
||||||
|
- NSUInteger len = [cachedText length];
|
||||||
|
- while (scan < idx && scan < len)
|
||||||
|
- {
|
||||||
|
- NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
|
||||||
|
- NSUInteger next = NSMaxRange (lr);
|
||||||
|
- if (next <= scan) break;
|
||||||
|
- if (next > idx) break;
|
||||||
|
- line++;
|
||||||
|
- scan = next;
|
||||||
|
- }
|
||||||
|
- return line;
|
||||||
|
+ return [self lineForAXIndex:idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSRange)accessibilityRangeForLine:(NSInteger)line
|
||||||
|
@@ -8454,26 +8523,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem,
|
||||||
|
return (line == 0) ? NSMakeRange (0, 0)
|
||||||
|
: NSMakeRange (NSNotFound, 0);
|
||||||
|
|
||||||
|
- /* Skip to the requested line using lineRangeForRange â O(lines)
|
||||||
|
- not O(chars), consistent with accessibilityLineForIndex:. */
|
||||||
|
- NSInteger cur_line = 0;
|
||||||
|
- NSUInteger scan = 0;
|
||||||
|
- while (cur_line < line && scan < len)
|
||||||
|
- {
|
||||||
|
- NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
|
||||||
|
- NSUInteger next = NSMaxRange (lr);
|
||||||
|
- if (next <= scan) break; /* safety */
|
||||||
|
- cur_line++;
|
||||||
|
- scan = next;
|
||||||
|
- }
|
||||||
|
- if (cur_line != line)
|
||||||
|
- return NSMakeRange (NSNotFound, 0);
|
||||||
|
-
|
||||||
|
- /* Return the range of the target line. */
|
||||||
|
- if (scan >= len)
|
||||||
|
- return NSMakeRange (len, 0); /* phantom line after final newline */
|
||||||
|
- NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)];
|
||||||
|
- return lr;
|
||||||
|
+ return [self rangeForLine:(NSUInteger)line textLength:len];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSRange)accessibilityRangeForIndex:(NSInteger)index
|
||||||
|
--
|
||||||
|
2.43.0
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ PATCH SERIES
|
|||||||
0006 doc: add VoiceOver accessibility section to macOS appendix
|
0006 doc: add VoiceOver accessibility section to macOS appendix
|
||||||
0007 ns: announce overlay completion candidates for VoiceOver
|
0007 ns: announce overlay completion candidates for VoiceOver
|
||||||
0008 ns: announce child frame completion candidates for VoiceOver
|
0008 ns: announce child frame completion candidates for VoiceOver
|
||||||
|
0009 Performance: precomputed line index for O(log L) line queries
|
||||||
|
|
||||||
|
|
||||||
OVERVIEW
|
OVERVIEW
|
||||||
@@ -327,8 +328,13 @@ TEXT CACHE AND VISIBLE RUNS
|
|||||||
without bumping either modiff counter. The cache is also
|
without bumping either modiff counter. The cache is also
|
||||||
invalidated when the window tree is rebuilt. NS_AX_TEXT_CAP = 100,000
|
invalidated when the window tree is rebuilt. NS_AX_TEXT_CAP = 100,000
|
||||||
UTF-16 units (~200 KB) caps total exposure; buffers larger than
|
UTF-16 units (~200 KB) caps total exposure; buffers larger than
|
||||||
~50,000 lines are truncated for accessibility purposes. VoiceOver
|
~50,000 lines are truncated for accessibility purposes.
|
||||||
performance degrades noticeably beyond this threshold.
|
|
||||||
|
A lineStartOffsets array is built during each cache rebuild,
|
||||||
|
recording the AX string index where each line begins. This
|
||||||
|
makes accessibilityLineForIndex: and accessibilityRangeForLine:
|
||||||
|
O(log L) via binary search instead of O(L) linear scanning.
|
||||||
|
The index is freed and rebuilt alongside the text cache.
|
||||||
|
|
||||||
|
|
||||||
COMPLETION ANNOUNCEMENTS
|
COMPLETION ANNOUNCEMENTS
|
||||||
@@ -645,10 +651,10 @@ KNOWN LIMITATIONS
|
|||||||
produce incomplete or garbled accessibility text.
|
produce incomplete or garbled accessibility text.
|
||||||
|
|
||||||
- Line counting (accessibilityInsertionPointLineNumber,
|
- Line counting (accessibilityInsertionPointLineNumber,
|
||||||
accessibilityLineForIndex:) uses O(lines) iteration via
|
accessibilityLineForIndex:) was O(lines) in patches 1-5.
|
||||||
lineRangeForRange. For buffers with tens of thousands of visible
|
Patch 0009 adds a precomputed lineStartOffsets array built
|
||||||
lines this is acceptable but not optimal. A line-number cache
|
once per cache rebuild; queries are now O(log L) via binary
|
||||||
keyed on cachedTextModiff could reduce this to O(1).
|
search.
|
||||||
|
|
||||||
- Buffers larger than NS_AX_TEXT_CAP (100,000 UTF-16 units) are
|
- Buffers larger than NS_AX_TEXT_CAP (100,000 UTF-16 units) are
|
||||||
truncated. The truncation is silent; AT tools navigating past the
|
truncated. The truncation is silent; AT tools navigating past the
|
||||||
@@ -746,9 +752,10 @@ TESTING CHECKLIST
|
|||||||
*Completions*, Tab to a candidate, Enter to execute, then
|
*Completions*, Tab to a candidate, Enter to execute, then
|
||||||
C-x o to switch windows. Emacs must not hang.
|
C-x o to switch windows. Emacs must not hang.
|
||||||
|
|
||||||
Stress test:
|
Stress test (patch 0009 line index):
|
||||||
25. Open a large file (>5000 lines). Navigate with C-v / M-v.
|
25. Open a large file (>50,000 lines). Navigate to the end with
|
||||||
Verify no significant lag in VoiceOver speech response.
|
M-> or C-v repeatedly. VoiceOver speech should remain fluid
|
||||||
|
at all positions (no progressive slowdown).
|
||||||
26. Open an org-mode file with many folded sections. Verify that
|
26. Open an org-mode file with many folded sections. Verify that
|
||||||
folded (invisible) text is not announced during navigation.
|
folded (invisible) text is not announced during navigation.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user