Every other chapter uses small inputs so the page renders quickly. This chapter doesn’t: every benchmark below runs on 3 million strings, because this package is built for exactly that scale — record linkage and ETL pipelines where a “small” file is already six figures of rows. If a function is only 10% faster at 3M rows, that’s a real, honestly-reported result, not a number picked to flatter the package.
Comparison baselines, used wherever they have a real equivalent:
base R — the function this package’s API mirrors.
stringi/stringr — the standard fast-string ecosystem package for general string operations (stringr wraps stringi; benchmarks below call stringi directly).
stringdist — method = "jw" is the closest equivalent to jaro_winkler(); method = "lv"/"osa"/"dl"/"hamming"/"jaccard" are the closest equivalents to levenshtein()/osa_distance()/ damerau_levenshtein()/hamming()/jaccard_index().
RecordLinkage — soundex() and jarowinkler() are the closest equivalents to this package’s phonetic and fuzzy-matching functions.
phonics — metaphone() and caverphone() are throughput comparison points for double_metaphone()/caverphone(); they implement different algorithm revisions, so outputs aren’t expected to match (the comparison is “a phonetic-coding R function over this vector”, not output parity).
fcount() is compared against the base idiom it replaces — gregexpr() plus a vapply() — which pays not just for the match loop but for allocating an integer vector of match positions per element only to take its length, and which needs an explicit NA branch to match fcount()’s missing-value handling. stri_count_regex() is the fair third baseline.
fsubstr is the one operation in this whole chapter where parallelism doesn’t help: base R’s substr() is already close to memcpy speed for a fixed-width extraction, so there’s little headroom — consistent with the ~1x result in Table 6.1. The win shows up on operations with real per-element branching cost (trimws, chartr) or where base R’s implementation does something surprisingly expensive (nchar).
6.3 Dates
dates<-as.Date(sample(seq(as.Date("1950-01-01"), as.Date("2024-12-31"), by ="day"), n, replace =TRUE))dates[na_idx]<-NAdate_strings_iso<-fast.string::format_date(dates, "iso")t_base<-system.time(format(dates, "%Y-%m-%d"))["elapsed"]t_fast<-system.time(fast.string::format_date(dates, "iso"))["elapsed"]cat(sprintf("base format.Date %.3fs\nfast.string::format_date %.3fs\n", t_base, t_fast))
base format.Date 31.420s
fast.string::format_date 0.140s
Timestamps are the larger win of the two, because base::as.POSIXct() carries strptime()’s locale handling and timezone resolution, while fas.POSIXct() does fixed-shape field extraction into UTC epoch seconds — and still validates the full calendar, which fas.Date() does not.
soundex()/nysiis() have no base R equivalent; RecordLinkage::soundex() is the closest comparison (algorithm details and why the two can disagree on specific inputs are in Chapter 3). RecordLinkage::soundex() runs a single timed pass rather than microbenchmark’s repeated runs — at 3M rows it’s the single slowest operation in this chapter, and repeating it five times buys little additional statistical confidence for a lot of extra render time.
nysiis() is consistently slower than soundex() here — not a parallelism gap (both run through the same dispatch), but because NYSIIS’s rule table is genuinely heavier per element (leading/trailing transforms plus a stateful per-letter pass, vs. Soundex’s single fixed lookup), as described in Chapter 3.
refined_soundex() and cologne() sit in the same cost class as soundex() — a table lookup per character, with no truncation for Refined Soundex and a small amount of multi-byte decoding for Cologne’s umlaut folding. phonics implements both, and the two baselines differ in kind: with truncation disabled (maxCodeLen raised above the longest code here), phonics::refinedSoundex() produces output identical to refined_soundex(), so that row is a genuine like-for-like comparison. phonics::cologne() agrees on the canonical reference names but takes a different reading of two under-specified rules (Chapter 3), so that row is a throughput comparison only.
if(has_phonics){# maxCodeLen = 40 disables phonics' 10-character default truncation, so# the two implementations are computing the same thing.t_ph_rs<-system.time(phonics::refinedSoundex(names_x, maxCodeLen =40))["elapsed"]t_ph_co<-system.time(phonics::cologne(names_x))["elapsed"]cat(sprintf("phonics::refinedSoundex %.3fs\nphonics::cologne %.3fs\n",t_ph_rs, t_ph_co))record("refined_soundex", "phonics::refinedSoundex", t_ph_rs, t_fast_rs)record("cologne", "phonics::cologne (differs on some inputs)", t_ph_co, t_fast_co)}else{cat("phonics not installed — skipping cross-package comparison.\n")}
double_metaphone() and caverphone() are considerably heavier rulesets again (Double Metaphone’s branching is the most involved of the four; Caverphone 2.0 is a long fixed chain of substring transforms), so absolute throughput is lower — phonics::metaphone()/phonics::caverphone() are shown alongside as “another phonetic-coding R function over this vector” throughput comparisons, not as algorithm-equivalent baselines (see the note at the top of this chapter):
# All three should agree numerically (RecordLinkage's defaults and# stringdist with p = 0.1 both implement the same standard Jaro-Winkler).a3<-c("MARTHA", "DWAYNE", "DIXON")b3<-c("MARHTA", "DUANE", "DICKSONX")fast.string::jaro_winkler(a3, b3)
if(has_stringdist)1-stringdist::stringdist(a3, b3, method ="jw", p =0.1)
[1] 0.9611111 0.8400000 0.8133333
jaro_winkler_matrix() is \(O(n \times m)\) by construction, so a 3M x 3M matrix is neither a realistic workload nor something most machines could hold in memory — a 3,000 x 3,000 block (9M cell comparisons) is a more representative blocking-table size:
m<-3000a_small<-names_x[seq_len(m)]b_small<-names_x[(n-m+1):n]t_fast<-system.time(fast.string::jaro_winkler_matrix(a_small, b_small))["elapsed"]cat(sprintf("fast.string::jaro_winkler_matrix: %s x %s (%s cells) in %.3fs\n",m, m, format(m*m, big.mark =","), t_fast))
fast.string::jaro_winkler_matrix: 3000 x 3000 (9e+06 cells) in 0.110s
jaro_winkler_tokens() does more work per pair (tokenising, an internal similarity matrix per row, greedy assignment, plus the collapsed comparison — Chapter 3) than plain jaro_winkler(), so it’s meaningfully slower per comparison. No package in this comparison set has a token-reorder-aware equivalent, so this is absolute throughput only:
fuzzy_match() computes the same best-match result without retaining the full score matrix. The output matrix below is modest for documentation, but its allocation grows as length(query) * length(table) while lookup output remains one integer per query.
# Each edit-distance implementation should agree with its stringdist method.a3<-c("kitten", "Saturday", "flaw")b3<-c("sitting", "Sunday", "lawn")fast.string::levenshtein(a3, b3)
t_fast_dice<-system.time(fast.string::dice_coefficient(names_x, rev(names_x)))["elapsed"]t_fast_tver<-system.time(fast.string::tversky_index(names_x, rev(names_x), alpha =0.3, beta =0.7))["elapsed"]cat(sprintf("fast.string::dice_coefficient %.3fs (no direct stringdist equivalent)\n", t_fast_dice))
fast.string::dice_coefficient 0.280s (no direct stringdist equivalent)
cat(sprintf("fast.string::tversky_index %.3fs (no direct stringdist equivalent)\n", t_fast_tver))
fast.string::tversky_index 0.280s (no direct stringdist equivalent)
cosine_similarity() does strictly more work than the three set metrics — its q-gram vectors are sorted but not deduplicated, so the merge pass walks more elements and accumulates three running sums instead of one intersection count. stringdist::stringsim(method = "cosine") is the direct equivalent.
Chapter 3 claims every parallel function applies a size threshold and falls back to single-threaded execution below it, because thread-pool dispatch overhead would otherwise exceed the time saved. Here’s that claim checked directly, at n = 20 — small enough that the claim predicts fast.string will be no faster, possibly slower than base R:
small<-stri_rand_strings(20, sample(5:15, 20, replace =TRUE))print(microbenchmark( base =base::grepl("[0-9]", small), fast =fast.string::fgrepl("[0-9]", small), times =50))
Unit: microseconds
expr min lq mean median uq max neval
base 5.3 6.1 7.862 6.6 7.5 39.5 50
fast 56.0 56.8 60.184 57.3 58.8 159.1 50
At this scale the two are within noise of each other (sometimes base R even wins outright) — exactly what the size-threshold design predicts, and the reason the rest of this chapter uses 3M rows rather than 20: the benefit this package offers only shows up once there’s enough work to amortise parallel dispatch.
Table 6.1: Observed speedups, n = 3,000,000, 16 cores, 1 run per cell unless noted (this machine — yours will vary).
Operation
Baseline
Baseline (s)
fast.string (s)
Speedup
fgrepl (regex)
base::grepl
0.78
0.09
8.7x
fgsub (regex)
base::gsub
5.01
3.00
1.7x
gsub_all (5 patterns)
base (chained gsub)
6.96
0.78
8.9x
fcount (regex)
base gregexpr + vapply
39.58
0.15
263.9x
ftrimws
base::trimws
3.86
0.90
4.3x
fsubstr
base::substr
1.21
0.98
1.2x
fnchar
base::nchar
0.59
0.10
5.9x
fchartr
base::chartr
3.78
1.03
3.7x
format_date
format.Date
31.42
0.14
224.4x
fas.Date
as.Date
13.25
0.05
265x
fas.POSIXct
as.POSIXct
35.31
0.06
588.5x
format_datetime
format.POSIXct
32.62
1.27
25.7x
soundex
RecordLinkage::soundex
3.09
0.16
19.3x
refined_soundex
phonics::refinedSoundex
7.77
0.96
8.1x
cologne
phonics::cologne (differs on some inputs)
42.53
0.86
49.5x
double_metaphone
phonics::metaphone (different algorithm)
8.31
0.44
18.9x
caverphone
phonics::caverphone (different revision)
90.33
1.44
62.7x
jaro_winkler
stringdist::stringdist(jw)
0.27
0.20
1.3x
jaro_winkler
RecordLinkage::jarowinkler
1.16
0.20
5.8x
levenshtein
stringdist::stringdist(lv)
0.28
0.19
1.5x
osa_distance
stringdist::stringdist(osa)
0.29
0.22
1.3x
damerau_levenshtein
stringdist::stringdist(dl)
0.44
0.51
0.9x
hamming
stringdist::stringdist(hamming)
0.31
0.24
1.3x
jaccard_index
stringdist::stringsim(jaccard)
0.45
0.28
1.6x
cosine_similarity
stringdist::stringsim(cosine)
1.52
0.27
5.6x
A few patterns worth drawing out explicitly, in descending order of speedup:
The biggest wins aren’t parallelism wins at all.fas.POSIXct(), fas.Date(), format_date(), and fcount() land in the hundreds of times faster, and the reason is that they do less work, not that they spread the same work over more cores: no locale handling, no timezone database, no %-format interpretation, and — for fcount() — no per-element allocation of a match-position vector that gets discarded. These are the rows to read with the trade-off in mind: each one accepts a narrower contract (one fixed shape, no format guessing) in exchange, and Chapter 3 is explicit about where that narrowness bites.
Phonetic encoding and multi-pattern substitution are the genuine parallelism wins — roughly 8-60x, on operations with real per-element branching to spread across cores. cologne() and caverphone() sit at the top of that band mainly because their single-threaded R baselines are expensive.
The smallest wins are where the baseline is already tight C. The edit-distance and q-gram kernels against stringdist run 1.3-1.8x (damerau_levenshtein is actually slower here, at 0.8x), because stringdist is a well-optimised C implementation and there is little headroom beyond threading. Same story for substr() at 1.1x, which base R already does close to memcpy speed. cosine_similarity() is the exception in that group at 5.7x, since stringdist tabulates q-grams generically where this package packs them into integers.
Cross-package comparisons (stringdist, RecordLinkage, phonics) confirm correctness as much as speed — the Jaro-Winkler numbers agree to floating-point precision, cosine_similarity() matches stringdist’s cosine, and refined_soundex() matches phonics::refinedSoundex() once its truncation is disabled. Agreement is the more important result, since a faster-but-wrong implementation isn’t a useful trade. Where outputs don’t agree — cologne() versus phonics::cologne(), and the Soundex H/W rule — Chapter 3 says exactly which rule each side takes.