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
|
||||
0007 ns: announce overlay 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
|
||||
@@ -327,8 +328,13 @@ TEXT CACHE AND VISIBLE RUNS
|
||||
without bumping either modiff counter. The cache is also
|
||||
invalidated when the window tree is rebuilt. NS_AX_TEXT_CAP = 100,000
|
||||
UTF-16 units (~200 KB) caps total exposure; buffers larger than
|
||||
~50,000 lines are truncated for accessibility purposes. VoiceOver
|
||||
performance degrades noticeably beyond this threshold.
|
||||
~50,000 lines are truncated for accessibility purposes.
|
||||
|
||||
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
|
||||
@@ -645,10 +651,10 @@ KNOWN LIMITATIONS
|
||||
produce incomplete or garbled accessibility text.
|
||||
|
||||
- Line counting (accessibilityInsertionPointLineNumber,
|
||||
accessibilityLineForIndex:) uses O(lines) iteration via
|
||||
lineRangeForRange. For buffers with tens of thousands of visible
|
||||
lines this is acceptable but not optimal. A line-number cache
|
||||
keyed on cachedTextModiff could reduce this to O(1).
|
||||
accessibilityLineForIndex:) was O(lines) in patches 1-5.
|
||||
Patch 0009 adds a precomputed lineStartOffsets array built
|
||||
once per cache rebuild; queries are now O(log L) via binary
|
||||
search.
|
||||
|
||||
- Buffers larger than NS_AX_TEXT_CAP (100,000 UTF-16 units) are
|
||||
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
|
||||
C-x o to switch windows. Emacs must not hang.
|
||||
|
||||
Stress test:
|
||||
25. Open a large file (>5000 lines). Navigate with C-v / M-v.
|
||||
Verify no significant lag in VoiceOver speech response.
|
||||
Stress test (patch 0009 line index):
|
||||
25. Open a large file (>50,000 lines). Navigate to the end with
|
||||
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
|
||||
folded (invisible) text is not announced during navigation.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user