--- title: "Benchmark: pure R vs. taxon-tools" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Benchmark: pure R vs. taxon-tools} %\VignetteEncoding{UTF-8} %\VignetteEngine{knitr::rmarkdown} editor_options: chunk_output_type: console --- ```{r detect, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") # The benchmark compares against the original taxon-tools, run from its Docker # image. The image bundles the aregex (gawk amatch) build of taxon-tools, i.e. # the faster of the tool's fuzzy-matching backends. The benchmark chunks below # are only evaluated when Docker and that image are available, so this vignette # still builds (showing a note instead of tables) on machines without Docker. image <- "camwebb/taxon-tools:v1.3.0" has_docker <- tryCatch( system2("docker", c("image", "inspect", image), stdout = FALSE, stderr = FALSE) == 0, error = function(e) FALSE ) ``` ## Background As of version 2.0.0, `taxastand` parses and matches taxonomic names with a pure-R reimplementation of [taxon-tools](https://github.com/camwebb/taxon-tools) (`parsenames` and `matchnames`). Earlier versions shelled out to the original `taxon-tools` programs, either via a local install or the `r image` Docker image. The pure-R engines produce **byte-for-byte identical output** to the original tool (this is checked in the package's test suite against the Docker image). The question this vignette answers is: how do they compare on **speed**? The comparison below is against the `aregex` build of `taxon-tools` shipped in the Docker image, which uses gawk's `amatch()` for fuzzy matching --- the faster of the tool's two fuzzy backends. ## Setup ```{r setup, eval = has_docker} library(taxastand) data(filmy_taxonomy) dir <- tempfile("bench") dir.create(dir) # Best-of-n elapsed seconds for a zero-argument function timeit <- function(f, reps = 2) { min(vapply(seq_len(reps), function(i) system.time(f())[["elapsed"]], numeric(1))) } # Run a taxon-tools command in the Docker image, mounting `dir` at /data docker_run <- function(args) { system2("docker", c("run", "--rm", "-v", paste0(dir, ":/data"), image, args), stdout = TRUE, stderr = FALSE) } base <- unique(filmy_taxonomy$scientificName) base <- base[!is.na(base) & nzchar(base)] length(base) ``` ## Parsing ```{r parse-bench, eval = has_docker} parse_rows <- lapply(c(1, 5, 10), function(mult) { names_vec <- rep(base, mult) records <- paste0("n", seq_along(names_vec), "|", names_vec) writeLines(records, file.path(dir, "p.txt"), useBytes = TRUE) n <- length(records) r_t <- timeit(function() taxastand:::tt_parsenames(records)) d_t <- timeit(function() docker_run(c("parsenames", "/data/p.txt"))) data.frame( `Names parsed` = n, `pure R (s)` = round(r_t, 2), `taxon-tools (s)` = round(d_t, 2), `R names/s` = round(n / r_t), `Speed-up` = sprintf("%.1fx", d_t / r_t), check.names = FALSE ) }) parse_tbl <- do.call(rbind, parse_rows) ``` ```{r parse-table, eval = has_docker} knitr::kable(parse_tbl, align = "r") ``` ## Matching The reference (the whole taxonomy) and a query set (misspellings and author-dropped variants of a sample) are parsed up front, so that only the matching step is timed. ```{r match-bench, eval = has_docker} parse_lines <- function(x, prefix) { lines <- taxastand:::tt_parsenames(paste0(prefix, seq_along(x), "|", x)) rest <- sub("^[^|]*\\|", "", lines) lines[gsub("\\|", "", rest) != ""] # drop unparseable names } ref_lines <- parse_lines(base, "r") set.seed(1) samp <- sample(base, 300) mis <- vapply(samp, function(s) { cs <- strsplit(s, "")[[1]] pos <- which(grepl("[a-z]", cs)) if (length(pos) > 3) cs[pos[length(pos) %/% 2]] <- "x" paste(cs, collapse = "") }, character(1)) query <- unique(c(samp, mis, sub(" [A-Z(].*$", "", samp))) query_lines <- parse_lines(query, "q") writeLines(ref_lines, file.path(dir, "ref.txt"), useBytes = TRUE) writeLines(query_lines, file.path(dir, "query.txt"), useBytes = TRUE) match_rows <- lapply(c(5, 10), function(e) { r_t <- timeit(function() taxastand:::tt_matchnames(query_lines, ref_lines, max_dist = e)) d_t <- timeit(function() docker_run(c( "matchnames", "-a", "/data/query.txt", "-b", "/data/ref.txt", "-o", "/data/out.txt", "-e", e, "-F"))) data.frame( `max_dist` = e, `pure R (s)` = round(r_t, 2), `taxon-tools (s)` = round(d_t, 2), `Speed-up` = sprintf("%.1fx", d_t / r_t), check.names = FALSE ) }) match_tbl <- do.call(rbind, match_rows) ``` ```{r match-table, eval = has_docker} cat(sprintf("%d queries against %d references\n", length(query_lines), length(ref_lines))) knitr::kable(match_tbl, align = "r") ``` ```{r no-docker-note, eval = !has_docker, echo = FALSE, results = "asis"} cat( "> **Docker (or the `", image, "` image) was not available when this ", "vignette was built, so the benchmark was not run.** Install Docker and ", "pull the image, then re-render this vignette to see live results.\n", sep = "" ) ``` ## Takeaways ```{r takeaway-numbers, eval = has_docker, echo = FALSE, results = "asis"} p1 <- parse_tbl[parse_tbl$`Names parsed` == length(base), ] m10 <- match_tbl[match_tbl$max_dist == 10, ] cat(sprintf( paste0("On this machine, parsing %s names took %.2fs in pure R vs %.2fs via ", "taxon-tools (%s), and matching at the default `max_dist = 10` was ", "%s faster.\n"), format(length(base), big.mark = ","), p1$`pure R (s)`, p1$`taxon-tools (s)`, p1$`Speed-up`, m10$`Speed-up` )) ``` - **Parsing** is several times faster in pure R. Both implementations have near-constant per-name cost; the gap is largest for small inputs, where Docker's container start-up dominates, but pure R stays well ahead even at tens of thousands of names. - **Matching** is where the difference is dramatic, and it *grows* with `max_dist`. The original tool fuzzy-matches with gawk's `amatch()` (TRE approximate-regex matching), whose cost climbs steeply as the allowed edit distance increases. The pure-R engine uses base R's vectorised `adist()` restricted to within-genus candidates, which barely moves from `max_dist` 5 to 10. - Beyond raw speed, the pure-R implementation removes the need to install `taxon-tools` or run Docker at all, while producing identical results. These numbers depend on hardware, dataset size, and the proportion of names that require fuzzy matching; re-render this vignette to measure them on your machine. ```{r session, eval = has_docker} sessionInfo() ```