Skip to content

Index each child list once in REXML::XPathParser.sort - #359

Merged
kou merged 1 commit into
ruby:masterfrom
naitoh:optimize_xpath_document_order
Aug 19, 2026
Merged

Index each child list once in REXML::XPathParser.sort#359
kou merged 1 commit into
ruby:masterfrom
naitoh:optimize_xpath_document_order

Conversation

@naitoh

@naitoh naitoh commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

REXML::XPathParser.sort keys each node on the index it holds under each of its ancestors, and it asked Parent#index for every one of those, which walks the child list looking for the node. A node set normally has many nodes under the same few parents, so the same list was walked again and again and sorting was quadratic in the number of children of a wide parent.

Walk a child list once and remember the index of every child in it, the way the attribute list already is. Sorting is then proportional to the children of the parents that the node set actually hangs off, rather than to those of any one of them squared.

Children and attributes now share one cache, and it is passed to sort as an argument so that it can also be shared across the sorts of one evaluation. A sort can happen many times per evaluation -- Functions#string sorts once per candidate node -- and those sorts usually revisit the same parents. XPathParser makes a new cache for each evaluation, so a document modified between two evaluations is not ordered by stale indexes.

Ordering is unchanged, including for a child list that holds the same node twice: the first index of a node is the one kept, which is the one Parent#index reports and the one Node#next_sibling_node and friends already assume.

Benchmark

$ benchmark-driver benchmark/xpath_sort_latest.yaml
Calculating -------------------------------------
                                                  before       after  before(YJIT)  after(YJIT)
  //leaf (hits scattered among a wide parent)    373.713     952.844       534.805       1.257k i/s -     100.000 times in 0.267585s 0.104949s 0.186984s 0.079572s
//item[string(a) = 'v3'] (sort per candidate)    125.080     176.305       194.029      266.828 i/s -     100.000 times in 0.799488s 0.567199s 0.515387s 0.374773s

Comparison:
               //leaf (hits scattered among a wide parent)
                                  after(YJIT):      1256.7 i/s
                                        after:       952.8 i/s - 1.32x  slower
                                 before(YJIT):       534.8 i/s - 2.35x  slower
                                       before:       373.7 i/s - 3.36x  slower

             //item[string(a) = 'v3'] (sort per candidate)
                                  after(YJIT):       266.8 i/s
                                 before(YJIT):       194.0 i/s - 1.38x  slower
                                        after:       176.3 i/s - 1.51x  slower
                                       before:       125.1 i/s - 2.13x  slower
  • YJIT=ON : 1.38x - 2.35x faster
  • YJIT=OFF : 1.41x - 2.55x faster

Speed is bought with memory: //leaf over a document of 20000 children holds 20001 indexes, about 0.9MB. It cannot grow past one entry per node of the document, whatever the path asks for, and match drops it on the way out rather than carry it into the next evaluation. That does not free the document: a parser holds the one it last queried through @document and @element_namespaces_cache, neither of which the evaluation clears.

The FIXME asking for a document index per node goes away with it. It worried about mutable documents, which is why the cache lives for one evaluation only. Within one evaluation the tree is taken to be fixed: one changed part way through may still be ordered by indexes taken before the change.

REXML::XPathParser.sort keys each node on the index it holds under each
of its ancestors, and it asked Parent#index for every one of those, which
walks the child list looking for the node.  A node set normally has many
nodes under the same few parents, so the same list was walked again and
again and sorting was quadratic in the number of children of a wide
parent.

Walk a child list once and remember the index of every child in it, the
way the attribute list already is.  Sorting is then proportional to the
children of the parents that the node set actually hangs off, rather
than to those of any one of them squared.

Children and attributes now share one cache, and it is passed to sort as
an argument so that it can also be shared across the sorts of one
evaluation.  A sort can happen many times per evaluation --
Functions#string sorts once per candidate node -- and those sorts
usually revisit the same parents.  XPathParser makes a new cache for
each evaluation, so a document modified between two evaluations is not
ordered by stale indexes.

Ordering is unchanged, including for a child list that holds the same
node twice: the first index of a node is the one kept, which is the one
Parent#index reports and the one Node#next_sibling_node and friends
already assume.

## Benchmark
```
$ benchmark-driver benchmark/xpath_sort_latest.yaml
Calculating -------------------------------------
                                                  before       after  before(YJIT)  after(YJIT)
  //leaf (hits scattered among a wide parent)    373.713     952.844       534.805       1.257k i/s -     100.000 times in 0.267585s 0.104949s 0.186984s 0.079572s
//item[string(a) = 'v3'] (sort per candidate)    125.080     176.305       194.029      266.828 i/s -     100.000 times in 0.799488s 0.567199s 0.515387s 0.374773s

Comparison:
               //leaf (hits scattered among a wide parent)
                                  after(YJIT):      1256.7 i/s
                                        after:       952.8 i/s - 1.32x  slower
                                 before(YJIT):       534.8 i/s - 2.35x  slower
                                       before:       373.7 i/s - 3.36x  slower

             //item[string(a) = 'v3'] (sort per candidate)
                                  after(YJIT):       266.8 i/s
                                 before(YJIT):       194.0 i/s - 1.38x  slower
                                        after:       176.3 i/s - 1.51x  slower
                                       before:       125.1 i/s - 2.13x  slower
```
- YJIT=ON : 1.38x - 2.35x faster
- YJIT=OFF : 1.41x - 2.55x faster

Speed is bought with memory: //leaf over a document of 20000 children
holds 20001 indexes, about 0.9MB.  It cannot grow past one entry per node
of the document, whatever the path asks for, and match drops it on the way
out rather than carry it into the next evaluation.  That does not free the
document: a parser holds the one it last queried through @document and
@element_namespaces_cache, neither of which the evaluation clears.

The FIXME asking for a document index per node goes away with it.  It
worried about mutable documents, which is why the cache lives for one
evaluation only.  Within one evaluation the tree is taken to be fixed:
one changed part way through may still be ordered by indexes taken
before the change.
@naitoh

naitoh commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Since deeply nested XML isn't a common use case, I reworked the PR to use a caching approach instead of the DFS approach described at #314.

@naitoh
naitoh requested a review from kou August 16, 2026 23:31
@kou
kou requested a lite review from Copilot August 17, 2026 01:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kou
kou merged commit 8d59e3b into ruby:master Aug 19, 2026
71 checks passed
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.

3 participants