Index each child list once in REXML::XPathParser.sort - #359
Merged
Conversation
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.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
REXML::XPathParser.sortkeys each node on the index it holds under each of its ancestors, and it askedParent#indexfor 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#stringsorts 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#indexreports and the oneNode#next_sibling_nodeand friends already assume.Benchmark
Speed is bought with memory:
//leafover 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@documentand@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.