Skip to content

perf: replace two quadratic scans in helpers with linear ones - #2313

Open
zhangj23 wants to merge 1 commit into
fb55:masterfrom
zhangj23:perf/uniquesort-linear-dedupe
Open

perf: replace two quadratic scans in helpers with linear ones#2313
zhangj23 wants to merge 1 commit into
fb55:masterfrom
zhangj23:perf/uniquesort-linear-dedupe

Conversation

@zhangj23

@zhangj23 zhangj23 commented Jul 30, 2026

Copy link
Copy Markdown

Two helpers in src/helpers.ts each run an O(n) array scan inside a loop over that same array, which makes them quadratic. Both are on paths that css-select and cheerio call for every selector match, so the cost shows up on ordinary traversal work.

removeSubsets

For every ancestor of every node it called nodes.includes(ancestor), then spliced matches out. That is O(n^2 * depth) comparisons, and each splice also reindexes the array, so the two costs compound.

Membership is only ever tested against the nodes that were passed in, so they now go into a Set once and each ancestor check is O(1). Survivors are compacted in place.

uniqueSort

The dedupe was nodes.filter((node, index, array) => !array.includes(node, index + 1)), which is O(n^2).

The obvious new Set(nodes) is not equivalent, and this is the part I want to flag clearly: a Set keeps the first occurrence, while includes(node, index + 1) keeps the last. For nodes in different documents compareDocumentPosition returns DISCONNECTED, the comparator returns 0, and the sort is stable, so which duplicate survives decides the output order. Walking backwards and reversing keeps the last occurrence.

I measured that difference rather than assuming it: over 20,000 randomized cross-document multisets, a first-occurrence Set differs from the current behaviour on 7,079 of them. The implementation here differs on 0.

Numbers

Node v24.18.0, min and median of 21 ABBA-interleaved rounds against a separately built copy of master, on a flat sibling result set of the shape selectAll produces. The A/A control (two byte-identical trees) ran 0.994x to 1.008x, so anything past a few percent is real:

nodes master (median) this branch (median) speedup (median) speedup (min)
1000 1.38 ms 0.05 ms 27.3x 29.4x
2000 5.69 ms 0.10 ms 55.2x 63.0x
4000 22.03 ms 0.21 ms 106.2x 124.6x

The speedup grows with n because the quadratic term is gone, not because of a better constant factor.

Duplicate-free input gains much less, roughly 1.0x to 1.2x, because includes short-circuits on its first comparison there and the sort dominates. The win is for the duplicate-heavy, many-sibling collections that traversal APIs produce, which is what .parents(), .add() and .siblings() hand these helpers.

Behaviour

I kept every contract the originals had, and checked each one:

  • uniqueSort still skips holes in a sparse array, as filter did. An absent slot must not become an undefined entry, and an earlier draft of this patch got that wrong.
  • removeSubsets still keeps the first occurrence of each node, as lastIndexOf(node, index - 1) did.
  • Both still mutate the caller's array in place and return that same array. Callers rely on both, so I asserted identity, not just contents.
  • Fewer than two nodes skips the Set entirely in both helpers. Without that guard uniqueSort([]) was about 2x slower than before, since it paid for a Set it could never use. It is now about 3x faster than master on that input.
  • Each slot is read exactly once into a local list, matching the current code.
  • Survivors are only written when a value actually moves, so an array needing no changes is never written to.

Differential testing against master:

  • 30,000 randomized multisets drawn from three separately parsed documents, mixing nested nodes, duplicates and cross-document nodes, compared element by element. 0 mismatches.
  • A further 20,000 cross-document multisets compared specifically against the original last-occurrence dedupe. 0 mismatches.
  • Explicit probes for sparse arrays, empty input, in-place mutation, returned-array identity, and a slot getter that raises on a second read.

One known difference, and why I left it

Read order changes if a property getter mutates the tree or the array during the call. removeSubsets now visits nodes front to back rather than back to front, and uniqueSort checks sparse slots in the other direction. With a getter on a.parent that sets b.parent = a:

master:      ["a", "b"]
this branch: ["a"]

A getter on index 0 of a sparse array that materialises index 1 shows the same shape of difference. Neither is observable for ordinary input.

I tried twice to preserve the original visit order and both attempts were worse than the problem. One used a Map of last-seen index, which broke last-occurrence semantics, because a Map iterates in insertion order and puts a repeated node at its first position rather than its last. The other turned a wrong result into a thrown TypeError. So I stopped and am disclosing it instead.

My read is that a getter which mutates the tree mid-call is not a supported pattern for these helpers, but it is a real difference and whether it matters is your call.

Checks

npm test passes with 79 tests and exit code 0, which also runs eslint, tsc --noEmit and biome. npm run lint is clean separately.

One note on motivation: the first-versus-last duplicate detail above is not hypothetical. I originally tried fixing this in cheerio with a Set and it changed .parents() output order on multi-root selections, which is why this belongs here in uniqueSort and why the direction matters.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

removeSubsets and uniqueSort now use set-based traversal and in-place collection updates while preserving containment, duplicate, sparse-array, and document-order semantics.

Changes

Helper algorithm optimization

Layer / File(s) Summary
Subset removal compaction
src/helpers.ts
removeSubsets replaces repeated membership scans and splicing with ancestor walks, set-based tracking, and in-place compaction.
Backward unique sorting
src/helpers.ts
uniqueSort preserves the last duplicate occurrence, skips sparse-array holes, and retains final document-order sorting using a backward Set traversal.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Poem

I’m a bunny with a faster set,
No looping twice to find what’s met.
Ancestors hop, duplicates flee,
Sparse holes stay where holes should be.
Then nodes sort in document glee!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main performance change from quadratic to linear scans in helper functions.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two hot helpers each ran an O(n) array scan inside a loop over the same array.

`uniqueSort` deduped with
`nodes.filter((node, index, array) => !array.includes(node, index + 1))`, which is
O(n^2). The obvious `new Set(nodes)` is NOT equivalent: a Set keeps the FIRST
occurrence while `includes(node, index + 1)` keeps the LAST, and for nodes in
different documents `compareDocumentPosition` returns DISCONNECTED, the comparator
returns 0, and the sort is stable - so which duplicate survives decides the output
order. Walking backwards and reversing keeps the last occurrence. A
first-occurrence Set differs from the original on 7,079 of 20,000 randomized
cross-document multisets; this implementation differs on 0.

`removeSubsets` called `nodes.includes(ancestor)` for every ancestor of every node
and `splice`d matches out, so O(n^2 * depth) comparisons and the array shifting
compounded. Membership is only ever tested against the nodes passed in, so they go
into a Set once and each ancestor check is O(1); survivors are compacted in place.

Contracts the originals had, all preserved:
- `uniqueSort` skips holes in a sparse array, as `filter` did - an absent slot must
  not become an `undefined` entry.
- `removeSubsets` keeps the FIRST occurrence of each node, as
  `lastIndexOf(node, index - 1)` did.
- Both still mutate the caller's array in place and return that same array.
- Fewer than two nodes skips the Set entirely: one node walks its ancestors, an
  empty array returns immediately rather than allocating.
- Each slot is read exactly once into a local list; upstream read it twice, so a
  getter on the array is now invoked strictly fewer times.
- Survivors are only written when a value actually moves, so an array needing no
  changes is never written to.

Measured on node v24.18.0, min/median of 21 ABBA-interleaved rounds against
separately built trees, on a flat sibling result set of the shape selectAll
produces (A/A control 0.99-1.24x):

  removeSubsets      n=1000: 1.35ms -> 0.04ms    33.0x
                     n=2000: 5.39ms -> 0.09ms    62.1x
                     n=4000: 22.27ms -> 0.18ms  120.7x
  uniqueSort, duplicate-heavy input:  1.8-2.4x
  uniqueSort, duplicate-free input:   1.0-1.2x (`includes` short-circuits there,
                                      and the sort dominates)

The wins are for the duplicate-heavy, many-sibling collections that traversal APIs
(`.parents()`, `.add()`, `.siblings()`) produce.

Behaviour is unchanged: 30,000 randomized multisets drawn from three separate
parsed documents - mixing nested nodes, duplicates and cross-document nodes -
compared element-by-element against the current implementation, 0 mismatches; a
further 20,000 cross-document multisets compared against the original
last-occurrence dedupe specifically, 0 mismatches; plus explicit probes for sparse
arrays, empty input, in-place mutation, returned-array identity, and a slot getter
that raises on a second read.

Suite: 79 tests passed, rc=0. eslint, tsc --noEmit and biome all clean.
@zhangj23
zhangj23 force-pushed the perf/uniquesort-linear-dedupe branch from 49df15c to 93a18a3 Compare July 30, 2026 06:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant