perf(marktree): binary search the node intersect array #41331

Problem:
Undo of a change spanning many paired marks is quadratic. A node's
"intersect" array holds every pair crossing that node, and both
intersect_node() and unintersect_node() walked it linearly. Undoing an
edit over 1M paired marks spends 68% of its time in unintersect_node()'s
scan alone.

Solution:
The array is sorted, so binary search it.

    marks    undo before    after
    200k          831ms     385ms
      1M        14006ms    3820ms

Redo is unaffected: it is dominated by marktree_move() actually
repositioning the marks.
This commit is contained in:
Justin M. Keyes
2026-08-16 08:29:58 -04:00
committed by GitHub
parent 2edb1c0009
commit 83730db647
2 changed files with 33 additions and 21 deletions
+29 -21
View File
@@ -332,36 +332,44 @@ static bool intersection_has(Intersection *x, uint64_t id)
return false;
}
/// Finds the one position `id` can occupy in the ascending "intersect" array of `x`.
///
/// @return Index of `id`, or the index it would be inserted at if absent.
static size_t intersect_item_pos(MTNode *x, uint64_t id)
{
size_t lo = 0;
size_t hi = kv_size(x->intersect);
while (lo < hi) {
size_t mid = lo + (hi - lo) / 2;
if (kv_A(x->intersect, mid) < id) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
static void intersect_node(MarkTree *b, MTNode *x, uint64_t id)
{
assert(!(id & MARKTREE_END_FLAG));
kvi_pushp(x->intersect);
// optimized for the common case: new key is always in the end
for (ssize_t i = (ssize_t)kv_size(x->intersect) - 1; i >= 0; i--) {
if (i > 0 && kv_A(x->intersect, i - 1) > id) {
kv_A(x->intersect, i) = kv_A(x->intersect, i - 1);
} else {
kv_A(x->intersect, i) = id;
break;
}
// Common case: ids arrive in ascending order, so the new key belongs at the end.
if (kv_size(x->intersect) == 0 || kv_A(x->intersect, kv_size(x->intersect) - 1) < id) {
kvi_push(x->intersect, id);
return;
}
size_t i = intersect_item_pos(x, id);
kvi_pushp(x->intersect);
memmove(&kv_A(x->intersect, i + 1), &kv_A(x->intersect, i),
(kv_size(x->intersect) - i - 1) * sizeof(kv_A(x->intersect, 0)));
kv_A(x->intersect, i) = id;
}
static void unintersect_node(MarkTree *b, MTNode *x, uint64_t id, bool strict)
{
assert(!(id & MARKTREE_END_FLAG));
bool seen = false;
size_t i;
for (i = 0; i < kv_size(x->intersect); i++) {
if (kv_A(x->intersect, i) < id) {
continue;
} else if (kv_A(x->intersect, i) == id) {
seen = true;
break;
} else { // (kv_A(x->intersect, i) > id)
break;
}
}
size_t i = intersect_item_pos(x, id);
bool seen = i < kv_size(x->intersect) && kv_A(x->intersect, i) == id;
if (strict) {
#ifndef RELDEBUG
// TODO(bfredl): This assert has been seen to fail for end users
+4
View File
@@ -3240,6 +3240,10 @@ void f_undotree(typval_T *argvars, typval_T *rettv, EvalFuncData fptr)
///
/// The mark is then left to mark_adjust(), so undo shifts it with its text, like other marks not
/// touched by the change.
///
/// TODO(justinmk): could drop this and use a more "architectural" approach: compare
/// `fmark_T.timestamp` vs `uh_time` and skip the restore if the mark is newer. But that requires
/// changing the timestamps to nanosecond precision.
void u_update_named_mark(buf_T *buf, int idx)
{
u_header_T *uhp = buf->b_u_curhead != NULL ? buf->b_u_curhead : buf->b_u_newhead;