Skip to content

perf: hash-based vswitch/nswitch and duplicate-table tuning (#107) - #108

Merged
davidbudzynski merged 9 commits into
fastverse:masterfrom
davidbudzynski:perf/107-hash-switch
Nov 3, 2026
Merged

davidbudzynski merged 9 commits into
fastverse:masterfrom
davidbudzynski:perf/107-hash-switch

Conversation

@davidbudzynski

@davidbudzynski davidbudzynski commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Implements the three opportunities in #107.

vswitch/nswitch, >8 pairs
  values ──► open-addressed value → last-pair-index map
  x      ──► block lookup ──► output writer (atomic parallel, string/list serial)

funique/fduplicated/uniqLen
  rows ──► 64-bit rolling hash ──► duplicate table
                                      └─ high-cardinality sample pre-sizes it
  • vswitch()/nswitch() keep the value-major path for ≤8 pairs. Above that, they build the map once and scan x in cache-sized blocks. Last-duplicate-wins, NA/NaN, -0, Inf, complex, encoding, factor/Date and output recycling are unchanged.
  • Duplicate tables use a 64-bit rolling row hash instead of mixing every cell plus a second mix. A bounded random row sample pre-sizes the table only for large, genuinely high-cardinality inputs; low-cardinality inputs keep the grow-on-demand table from perf(c): profile and optimize hot-path kernels #74.
  • tools/benchmark-core.R is manual only: 2m/10m elements, 5/20/100 values, integer/string/factor/NA, data frames, 1/2/4 threads, optional base/data.table references.

Evidence

Rscript tools/benchmark-core.R <library> <label> 3; macOS, R 4.6.2, OpenMP disabled for these runs, medians in seconds.

Case 0.0.23 this PR Speedup
vswitch int, 20 values, 10m 0.059 0.024 2.5x
vswitch str, 20 values, 10m 0.066 0.033 2.0x
vswitch int, 100 values, 10m 0.283 0.050 5.7x
vswitch str, 100 values, 10m 0.275 0.059 4.7x
nswitch int, 100 pairs, 10m 0.283 0.050 5.7x
nswitch str, 100 pairs, 10m 0.281 0.059 4.8x
uniqLen 10m × 2 int df 0.436 0.176 2.5x
funique 10m × 2 int df 0.450 0.180 2.5x
uniqLen 10m mixed df 0.826 0.331 2.5x
funique 10m mixed df 0.853 0.373 2.3x

2m data frames improve 1.5–1.8x. Five-value switches and scalar random-int vectors are unchanged; the dedicated scalar-hash variants stayed under the 20% stop threshold. vswitch string 100 values on 10m is 0.059 s vs data.table::chmatch 0.029 s (chmatch is a first-match lookup, not a full switch).

Verification

  • R CMD check --no-manual: Status: OK.
  • Full tinytest suite passes; overall coverage 96.78%.
  • Differential fuzz against an independent R reference: 8,592 cases, 0 hash-path-specific mismatches (types, NA/NaN/-0/Inf, encodings, duplicates, scalar/vector defaults, outputs, block boundaries).
  • OpenMP build (libomp) tested with nThread = 4 on ALTREP x/defaults; serial and parallel results match.
  • Red/blue review found one critical issue: ALTREP accessors and R output pointers were being touched inside the OpenMP region, which can corrupt R's heap. All pointers are now materialised on the main thread before the parallel loop.

Pre-existing issues fixed here

These were found while fuzzing and are present on master too. They are now fixed in this PR with regression tests:

  1. enc2UTF8() mixed-encoding bug. It only checked element 1 to decide whether to convert, leaving later latin1 elements unconverted and turning NA_STRING into the literal "NA". It now checks every element and preserves NA_STRING. Value-major nswitch() also compares each pair against its own converted value instead of vans[0].

  2. Real row/vector hash vs REQUAL. Row and vector hashes used raw double bits while equality uses REQUAL, so -0 vs 0 (and differing NaN payloads) could be reported as distinct by funique()/uniqLen() where base R treats them equal. Double hashes now canonicalise -0/0 and NaN payloads while keeping NA distinct, across vector, matrix and data-frame paths.

Merge danger

Door: two-way — performance-only, no API, output format or data-layout changes.

Blast radius: switch functions and data-frame/matrix unique paths; low. Version bumped to 0.0.24 with NEWS notes.

Not covered here: 32-bit builds, >50 switch pairs / >8k element stress, and no PR timing assertions in CI.

)

Add regression tests for vswitch()/nswitch() with more than 8 value/output
pairs: last-duplicate-wins, NA/NaN behaviour per type, mixed encoding,
factor/Date outputs, vector recycling, and thread determinism. The tests pass
on the current value-major implementation and will guard the new hash path.
…erse#107)

For more than 8 value/output pairs, build a value -> last pair index map once
and scan x in blocks instead of comparing x against every value. Matching
semantics are unchanged: integer/logical NA matches NA, doubles/complex
canonicalise -0 and never match NA/NaN, strings compare CHARSXP identity.
Small maps are kept sparse (at least 256 slots, 4x values up to 4096) because
probe count matters more than a few cache lines; large maps use a 2x load
factor.

vswitch string 100 values on 2m elements: 0.063s -> 0.007s; 10m: 0.317s ->
~0.04s. nswitch 100 pairs on 2m: 0.069s -> 0.012s (int), 0.072s -> 0.018s
(string). The <= 8 pair value-major path is untouched.
Use a 64-bit rolling hash for duplicate-table rows instead of mixing every
cell and mixing again, and reuse the shared kit_hash_rows_* helpers in the
matrix/data-frame loops. A bounded row sample estimates cardinality: when
the input is mostly unique the table is allocated at its final size, while
low-cardinality inputs keep the small grow-on-demand table from fastverse#74.

10m x 2 integer data.frame: uniqLen 0.435s -> 0.177s, funique 0.450s ->
0.182s. 10m mixed int/double data.frame: uniqLen 0.828s -> 0.332s, funique
0.852s -> 0.378s. 2m frames improve 1.6-1.8x; vector paths are unchanged.
Manual-only benchmark covering 2m/10m elements, 5/20/100 switch values,
integer/string/factor/NA inputs, data-frame funique/uniqLen, and 1/2/4
threads. data.table reference cases are optional and skipped when the
package is unavailable. No timing assertions are added to CI.
Record the measured vswitch/nswitch and data-frame unique improvements and
cover the remaining hash-path output and input types in tests.
…tverse#107)

Only take the hash path for the five atomic types it supports, so raw or
other unsupported inputs still fail with the existing argument-'x' message.
- Hoist every LOGICAL/INTEGER/REAL/COMPLEX/STRING_PTR_RO accessor and output
  pointer out of the OpenMP region. ALTREP accessors are not thread-safe; the
  previous code could corrupt R's heap with nThread > 1 and ALTREP x/default.
- Use R_alloc for the switch hash table so an allocation error cannot leak it.
- Keep the old argument-'outputs' error for unsupported output types, and bound
  the pair count that uses the per-pair map.
- Only sample cardinality for inputs of at least 262k rows, use random sample
  positions, and cap wide-frame samples; this removes the low-cardinality
  presize regression and the fixed-stride false positive.
- Reuse kit_hash_rows_int_raw in countOccurDataFrameR.
- Extend tests for hash-path encodings, checkEnc=FALSE, vector defaults and
  all output writers. Refresh benchmark references and NEWS numbers.
@codecov-commenter

codecov-commenter commented Nov 2, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.92%. Comparing base (99413b6) to head (8db8de9).
⚠️ Report is 140 commits behind head on master.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #108      +/-   ##
==========================================
- Coverage   98.01%   96.92%   -1.09%     
==========================================
  Files          13       15       +2     
  Lines        5083     5338     +255     
==========================================
+ Hits         4982     5174     +192     
- Misses        101      164      +63     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- enc2UTF8() now checks every string instead of just the first and preserves
  NA_STRING. Value-major nswitch() compares each pair with its own converted
  value instead of the first.
- Double hashes canonicalise -0/0 and NaN payloads the way REQUAL does, so
  funique(), fduplicated() and uniqLen() agree with base R on vectors,
  matrices and data frames.
- Add regression tests and NEWS entries.

Measurement: ISNAN() is inlined and R_IsNA() is only called for actual NaNs,
so scalar double hashing is 11-14% faster. The double-frame row hash pays
about 4% for the special-value check, leaving the PR's speedup intact.
@davidbudzynski

Copy link
Copy Markdown
Collaborator Author

Follow-up: encoding and double-equality fixes

Picked up the two pre-existing issues from the PR body (and the related value-major nswitch() bug). Pushed as 8db8de9.

Changes

  • enc2UTF8() now checks every element instead of only element 1, converts later latin1 values, and preserves NA_STRING.
  • Value-major nswitch() now compares each pair against its own converted value instead of always vans[0].
  • Double hashes canonicalise -0/0 and all NaN payloads while keeping NA distinct, so vector, matrix and data-frame funique()/fduplicated()/uniqLen() now match base R's REQUAL behaviour.
  • Added regression tests in test-enc.R, test-switch-hash.R and test-unique.R; updated NEWS.md.

Performance check

The first version called R_IsNA()/R_IsNaN() per double and doubled the double-frame uniqLen() time (0.16s -> 0.31s on 10m x 2). Reworked it to use the inlined ISNAN() and only call R_IsNA() for actual NaNs.

Medians below: 10m rows, 15 reps, 1 thread, macOS R 4.6.2, comparing the previous PR head (accaecf) with this commit (8db8de9).

Case previous PR head 8db8de9 change
uniqLen 10m x 2 double df 0.159s 0.165s ~4% slower
funique 10m x 2 double df 0.181s 0.179s same
uniqLen 10m mixed int/double df 0.328s 0.256s ~22% faster
funique 10m mixed int/double df 0.368s 0.299s ~19% faster
uniqLen 10m double vector 0.378s 0.325s ~14% faster
funique 10m double vector 0.387s 0.345s ~11% faster
fduplicated 10m double vector 0.379s 0.331s ~13% faster

Scalar double paths are faster because the old code called two R API functions per element. The double-frame row hash pays about 4% for the per-element special-value check, which still leaves the PR's speedup over 0.0.23 intact (uniqLen was 0.436s there).

vswitch/nswitch with 100 int/string values and the int-frame uniqLen/funique cases measured unchanged; those paths were not touched.

Verification

  • Full tinytest suite: 1675 passed, 0 failed.
  • R CMD check --no-manual: Status: OK.

@davidbudzynski
davidbudzynski merged commit adb621b into fastverse:master Nov 3, 2026
11 checks passed
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