Skip to content

Apply xpath-style findings for efficiency, robustness - #846

Open
MichaelChirico wants to merge 3 commits into
masterfrom
xpath-skill
Open

MichaelChirico wants to merge 3 commits into
masterfrom
xpath-skill

Conversation

@MichaelChirico

@MichaelChirico MichaelChirico commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

In {lintr}, we started the @xpath-style:

https://github.com/r-lib/lintr/tree/main/.agents/skills/xpath-style

Most importantly, it encodes some accumulated learnings about performant XPath (on {xml2}, at least).

A few repeated themes emerge here:

  • Avoid any xml_* query that returns a full node set if it's not actually needed (e.g. we can use xml_find_lgl() and phrase the XPath itself to return a logical value, no nodeset needed)
  • Avoid //* queries, which visit every node in the document. Go // to the most specific node involved (e.g. //OP-COMMA can appear a large % of the time, too; //expr are only slightly better than //*)
  • Even preceding-sibling::* can be better to avoid, e.g. test files where 100s of elements are written into c(...); rephrased XPaths can be more concise by using the parent:: first too.
  • One for robustness -- prefer landing on EQ_SUB over SYMBOL_SUB to allow more cases of foo("a" = 1) to WAI without much further investment. I tend to think it's not worth phrasing the XPaths to accomodate this unusual pattern, but if we can get those "for free" by just using EQ_SUB, all the better.

I'm not very familiar with how to score ourselves here, but I deployed LLM to come up with some reasonable estimates, it produced:

VS Code / LSP Scenario Entry Point master xpath-skill Speedup
Inlay Hints — full file (textDocument/inlayHint on R/completion.R) inlay_hint_reply() (XML) 1,809.0 ms 324.0 ms 5.58×
Inlay Hints — 120-line viewport (textDocument/inlayHint on scroll/edit) inlay_hint_reply() (XML) 151.1 ms 33.5 ms 4.52×
Folding Ranges — blocks (textDocument/foldingRange) get_document_blocks() 14.4 ms 9.0 ms 1.60×
Section / Document Symbol blocks (textDocument/documentSymbol) extract_document_block_lines() 13.8 ms 9.1 ms 1.51×
Materializing XML top-level index (first XML provider access after parse) xdoc_top_level_index() 40.8 ms 35.5 ms 1.15×
Type Hierarchy on R6 class (textDocument/prepareTypeHierarchy on R/document.R) detect_type_definition() + find_r6_*() 19.6 ms 18.0 ms 1.09×
Semantic Tokens — full file (textDocument/semanticTokens/full on R/folding.R) semantic_tokens_full_reply() (XML) 96.9 ms 92.4 ms 1.05×
Code Actions on selection (textDocument/codeAction, 5 selections in R/completion.R) refactor_code_actions() 850.3 ms 811.6 ms 1.05×

One thing I didn't do here is add the faster xml2:: wrappers that pre-set ns = NULL:

empty_ns <- character()
names(empty_ns) <- character()
class(empty_ns) <- "xml_namespace"

xml_find_all_ <- function(x, xpath, ns = empty_ns, ...) {
  xml_find_all(x, xpath, ns = ns, ...)
}

# et al

See r-lib/lintr#3050, r-lib/xml2#327

@MichaelChirico

Copy link
Copy Markdown
Contributor Author

I have a change adding the empty_ns() fix as a child to this branch, I can upload it if seen fit, it has some pretty substantial further benefits to offer:

VS Code / LSP Scenario Entry Point master xpath-skill + empty_ns Speedup (vs master) Speedup (vs xpath-skill)
Inlay Hints — full file (textDocument/inlayHint on R/completion.R) inlay_hint_reply() (XML) 1,809.0 ms 136.4 ms 13.26× 2.38×
Inlay Hints — 120-line viewport (textDocument/inlayHint on scroll/edit) inlay_hint_reply() (XML) 151.1 ms 20.4 ms 7.41× 1.64×
Semantic Tokens — full file (textDocument/semanticTokens/full on R/folding.R) semantic_tokens_full_reply() (XML) 96.9 ms 46.0 ms 2.11× 2.01×
Folding Ranges — blocks (textDocument/foldingRange) get_document_blocks() 14.4 ms 8.3 ms 1.74× 1.09×
Section / Document Symbol blocks (textDocument/documentSymbol) extract_document_block_lines() 13.8 ms 8.0 ms 1.73× 1.14×
Materializing XML top-level index (first XML provider access after parse) xdoc_top_level_index() 40.8 ms 35.0 ms 1.17× 1.02×
Type Hierarchy on R6 class (textDocument/prepareTypeHierarchy on R/document.R) detect_type_definition() + find_r6_*() 19.6 ms 17.2 ms 1.13× 1.04×
Code Actions on selection (textDocument/codeAction, 5 selections in R/completion.R) refactor_code_actions() 850.3 ms 802.5 ms 1.06× 1.01×

@renkun-ken renkun-ken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the XPath improvements. I found one performance regression in class parsing; details are inline. The focused tests passed locally, and the hosted checks are green.

Comment thread R/type_hierarchy.R
# Check if this specific value expression contains a FUNCTION keyword
func_node <- class_xml_first(value_expr, ".//FUNCTION")
is_function <- length(func_node) > 0
is_function <- xml_find_lgl(value_expr, "boolean(.//FUNCTION)")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Keep namespace discovery out of the class-member loop

xml_find_lgl() defaults to ns = xml_ns(x), which scans the entire parser XML document. This runs once per R6 member during class_parse_data() on each parse, whereas the previous class_xml_first() call passed ns = character(). With 20 R6 classes of 25 members each followed by 5,000 simple assignments, I measured parse_document() at 1.11 s here versus 0.90 s when only this query used ns = character(), with identical class results. The overhead grows with both document size and member count. Could this pass ns = character() (or use a class-specific boolean wrapper)? The new placeholder query in class_xml_arguments() at R/class_index.R:50 has the same issue for piped calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks! That's exactly my proposed follow-up, which will have much broader benefits than just this one callsite. Let me upload the PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@renkun-ken renkun-ken left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rechecked 1a66a1c. The rebase restores R 4.0-compatible syntax; the focused semantic-token, range-index, and type-hierarchy tests pass locally, and #846's hosted checks are green.

My earlier performance finding remains at this head. For 20 classes with 500 members and 10,000 additional assignments, class_parse_data() took 0.37–0.45 s on #846 versus 0.13–0.19 s with #847 applied, with identical member results. #847 addresses both affected call sites, but is still open separately, so I'm keeping the existing change request until the fix is included in #846.

@MichaelChirico

Copy link
Copy Markdown
Contributor Author

I am thinking that it's nicer to keep the PRs more cleanly separate. I will merge them as a stack, so main will only have the 'regression' for a few milliseconds.

If you're thinking it's good for performance to be monotonically improving on main, I could reorder the stack so that #847 lands first, but that will generate some toil as I'll have to do some merge conflict resolution IINM. Not sure it's worth it.

@renkun-ken

Copy link
Copy Markdown
Member

I'm okay with either way. @randy3k what do you think?

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.

2 participants