Skip to content

Vectorize xml_find_* helpers - #480

Open
MichaelChirico wants to merge 1 commit into
r-lib:mainfrom
MichaelChirico:vectorize-find-helpers
Open

Vectorize xml_find_* helpers#480
MichaelChirico wants to merge 1 commit into
r-lib:mainfrom
MichaelChirico:vectorize-find-helpers

Conversation

@MichaelChirico

Copy link
Copy Markdown
Contributor

In {lintr}, I found that a natural replacement for the common idiom xml_text(xml_find_first(x, "XPATH")) is xml_find_chr(x, "string(XPATH)"). This already dramatically reduces the memory footprint, but the R-side looping kills the performance a bit (e.g. 30% slower).

This PR proposes vectorizing the whole operation in C(++) instead, providing 10-40x performance improvement and 50-75% memory reduction.

PR prepared by Gemini. There are a number of similar ways to implement the proposal -- filing first to probe interest in maintaining this approach before attempting to fine-tune the details.

Given the apparent "correctness" of using xml_find_chr(), I will be changing {lintr} to use it anyway -- it would be nice to get the free performance enhancement, too.

Benchmark Results

Medium Nodeset ($N = 500$ items)

Expression Median Time Iterations/sec Memory Allocated Speedup vs Baseline
xml_text(xml_find_first(x, 'name')) 3.85 ms 254 7.91 KB 1.0x (baseline)
xml_find_chr(x, 'string(name)') (OLD vapply) 4.55 ms 213 3.95 KB 0.85x
xml_find_chr(x, 'string(name)') (NEW C++) 243.9 µs 3,903 3.95 KB ~15.8x faster
!is.na(xml_find_first(x, 'active[...]')) 5.09 ms 194 7.95 KB 1.0x (baseline)
xml_find_lgl(x, 'boolean(...)') (OLD vapply) 4.24 ms 231 2.00 KB 1.2x
xml_find_lgl(x, 'boolean(...)') (NEW C++) 312.0 µs 3,071 2.00 KB ~16.3x faster
xml_find_num(x, 'number(...)') (OLD vapply) 4.25 ms 231 3.95 KB 1.0x
xml_find_num(x, 'number(...)') (NEW C++) 233.2 µs 4,175 3.95 KB ~18.2x faster
xml_find_int(x, 'count(...)') (OLD vapply) 4.09 ms 240 2.00 KB 1.0x
xml_find_int(x, 'count(...)') (NEW C++) 192.4 µs 5,032 2.00 KB ~21.3x faster

Large Nodeset ($N = 5{,}000$ items)

Expression Median Time Iterations/sec Memory Allocated Speedup vs Baseline
xml_text(xml_find_first(x, 'name')) 50.97 ms 17.8 78.2 KB 1.0x (baseline)
xml_find_chr(x, 'string(name)') (OLD vapply) 73.16 ms 13.5 39.1 KB 0.70x
xml_find_chr(x, 'string(name)') (NEW C++) 1.88 ms 432 39.1 KB ~27.1x faster (vs xml_text)
~38.9x faster (vs old xml_find_chr)
!is.na(xml_find_first(x, 'active[...]')) 79.80 ms 13.2 78.3 KB 1.0x (baseline)
xml_find_lgl(x, 'boolean(...)') (OLD vapply) 65.00 ms 15.0 19.6 KB 1.2x
xml_find_lgl(x, 'boolean(...)') (NEW C++) 2.60 ms 371 19.6 KB ~30.7x faster (vs !is.na)
~25.0x faster (vs old xml_find_lgl)
xml_find_num(x, 'number(...)') (OLD vapply) 42.66 ms 23.4 39.1 KB 1.0x
xml_find_num(x, 'number(...)') (NEW C++) 1.82 ms 511 39.1 KB ~23.4x faster
xml_find_int(x, 'count(...)') (OLD vapply) 39.75 ms 25.2 19.6 KB 1.0x
xml_find_int(x, 'count(...)') (NEW C++) 1.44 ms 656 19.6 KB ~27.6x faster
Benchmark script
library(xml2)
library(bench)

# Generate a synthetic XML catalog of N records
generate_xml <- function(n) {
  items <- sprintf(
    "<item id=\"%d\"><name>Product_%d</name><price>%.2f</price><stock>%d</stock><active>%s</active></item>",
    seq_len(n), seq_len(n), seq_len(n) * 1.5, seq_len(n) * 10L, ifelse(seq_len(n) %% 2 == 0, "true", "false")
  )
  xml_str <- paste0("<catalog>\n", paste(items, collapse = "\n"), "\n</catalog>")
  read_xml(xml_str)
}

# Baseline vapply implementations in xml2 <= 1.6.0
old_find_chr <- function(x, xpath, ns = xml_ns(x)) {
  vapply(x, \(x) xml_find_chr.xml_node(x, xpath = xpath, ns = ns), character(1))
}
old_find_lgl <- function(x, xpath, ns = xml_ns(x)) {
  vapply(x, \(x) xml_find_lgl.xml_node(x, xpath = xpath, ns = ns), logical(1))
}
old_find_num <- function(x, xpath, ns = xml_ns(x)) {
  vapply(x, \(x) xml_find_num.xml_node(x, xpath = xpath, ns = ns), numeric(1))
}
old_find_int <- function(x, xpath, ns = xml_ns(x)) {
  vapply(x, \(x) xml_find_int.xml_node(x, xpath = xpath, ns = ns), integer(1))
}

for (n in c(50, 500, 5000, 50000, 500000)) {
  run_bench = function(...) {
    print(bench::mark(..., iterations = if (n >= 5000) 30 else 100, check = TRUE))
  }
  doc <- generate_xml(n)
  items <- xml_find_all(doc, ".//item")

  # 1. String Extraction
  run_bench(
    `xml_text(xml_find_first(x, 'name'))`          = xml_text(xml_find_first(items, "name")),
    `xml_find_chr(x, 'string(name)') [OLD vapply]` = old_find_chr(items, "string(name)"),
    `xml_find_chr(x, 'string(name)') [NEW C++]`    = xml_find_chr(items, "string(name)")
  )

  # 2. Boolean Existence Query
  run_bench(
    `!is.na(xml_find_first(x, 'active[...]'))`      = !is.na(xml_find_first(items, "active[text()='true']")),
    `xml_find_lgl(x, 'boolean(...)') [OLD vapply]` = old_find_lgl(items, "boolean(active[text()='true'])"),
    `xml_find_lgl(x, 'boolean(...)') [NEW C++]`    = xml_find_lgl(items, "boolean(active[text()='true'])")
  )

  # 3. Numeric Extraction
  run_bench(
    `xml_find_num(x, 'number(price)') [OLD vapply]` = old_find_num(items, "number(price)"),
    `xml_find_num(x, 'number(price)') [NEW C++]`    = xml_find_num(items, "number(price)")
 )
  run_bench(
    `xml_find_int(x, 'count(*)') [OLD vapply]`      = old_find_int(items, "count(*)"),
    `xml_find_int(x, 'count(*)') [NEW C++]`         = xml_find_int(items, "count(*)")
  )
}

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