--- title: "dataprep: fast reshaping with melt() and dcast()" output: rmarkdown::html_vignette vignette: > %\documentclass{article} %\VignetteIndexEntry{dataprep: fast reshaping with melt() and dcast()} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.align = "center", fig.width = 6, fig.height = 5.5, out.width = "75%", fig.retina = 2 ) ``` ```{r} library(dataprep) set.seed(1) ``` ## Why another reshape implementation `melt()` and `dcast()` in `dataprep` 0.1.8 are drop-in replacements for `reshape2::melt` / `reshape2::dcast`, but the underlying code is written in C++ with SIMD (AVX2 / AVX-512) and optional OpenMP parallelism. Both functions produce output identical to `reshape2`, `data.table`, `tidyr`, `pandas`, `polars`, `dask`, and `duckdb` on every tested shape, within `tol = 1e-12`. The speed-up relative to each of the seven alternatives spans: | Operation | Range across both hosts | |---|---:| | `melt` | 0.6–1628.9× | | `dcast` | 1.9–799.8× | The median across all tested cells and all competitors is 11.3× on Ubuntu and 5.6× on Windows for `melt`, and 46.5× on Ubuntu and 41.4× on Windows for `dcast`. The mean is 67.8× and 46.6× for `melt`, and 90.2× and 76.6× for `dcast`, respectively. The `melt` median is pulled down by the 1e5-row small tables; at larger scales the speed-up is much higher. Full tables — including mean, median, and per-competitor ranges — are in `vignette("dataprep-performance")` and `README.md`. The sub-1.0× cells are `polars` at 1e7 rows × 10 id + 9 val on Ubuntu (0.6×), and `polars` at 1e5 rows × 10 id + 9 val on Windows (0.7×). Every other cell has `dataprep` ahead of or on par with the fastest competitor. ## Test environment Benchmarks were run on two reference hosts. Only the core configuration is listed here; full hardware details are in `README.md`. * **Ubuntu 25.10** — 2× AMD EPYC 9965 192-Core (384 physical / 768 logical cores), 1.0 TiB (16 × 64 GiB Micron, DDR5-5600, Multi-bit ECC), full AVX-512; R 4.5.1, g++ 15.2.0. * **Windows 11 Pro for Workstations** — 2× AMD EPYC 7B12 64-Core (128 physical / 128 logical cores), about 224 GiB RAM, no AVX-512; R 4.6.1 (ucrt), GCC 14.3.0. Software versions on both hosts: `data.table` 1.18.6.1, `reshape2` 1.4.5, `tidyr` 1.3.2, `reticulate` 1.47.0; Python 3.13.7 (Ubuntu) / 3.13.15 (Windows), `pandas` 3.0.6, `polars` 1.44.2 (runtime rt64), `dask` 2026.8.0, `duckdb` 1.5.5. Reproducing the benchmarks: ```{r, eval = FALSE} Sys.setenv(DATAPREP_RUN_BENCHMARK = "1") source(system.file("benchmark_melt_dcast.R", package = "dataprep")) ``` ## Wide to long with `melt()` ### Basic usage ```{r} df <- data.frame( id = 1:3, category = factor(c("a", "b", "c")), v1 = c(1.1, 2.2, 3.3), v2 = c(4.4, 5.5, 6.6) ) melt(df, id.vars = c("id", "category")) ``` ### Measure-side specification ```{r} melt(df, measure.vars = c("v1", "v2")) ``` ### Automatic ID inference Non-numeric and factor columns are treated as IDs by default. ```{r} melt(df) ``` ### Custom column names and `na.rm` ```{r} df_na <- data.frame( id = 1:3, x = c(1, NA, 3), y = c(4, 5, NA) ) melt(df_na, id.vars = "id", variable.name = "var", value.name = "val", na.rm = TRUE) ``` ### Memory layout: `major = "row"` vs `major = "col"` Row-major (`"row"`) is usually faster when there are few measure columns; column-major (`"col"`) is often faster when there are many measure columns because bulk copies dominate. `major = NULL` (the default) is equivalent to `"col"`; there is no automatic switching based on the input shape. ```{r} wide50 <- data.frame(id = 1:100, matrix(rnorm(100 * 50), ncol = 50)) res_row <- melt(wide50, id.vars = "id", major = "row") res_col <- melt(wide50, id.vars = "id", major = "col") identical(as.data.frame(res_row), as.data.frame(res_col)) ``` ### Thread control `cores` controls OpenMP. `options(dataprep.cores = ...)` sets a default for the whole session. ```{r} options(dataprep.cores = 4L) melt(df, id.vars = "id") options(dataprep.cores = NULL) ``` ## Why `melt()` is fast `melt_cpp` uses six design choices that matter at scale. ### 1. Two layout paths, selected by `major` `melt()` produces the same long-format table as `reshape2::melt` (column-major, `major = "col"`) or `tidyr::pivot_longer` (row-major, `major = "row"`). The two layouts have very different memory access patterns: * **Column-major** writes each output column as one contiguous block: for measure column `k`, the output block `[k * n .. (k+1) * n)` is a direct `memcpy` of the input column. This is the fastest possible path when the number of value columns is moderate. * **Row-major** writes every row as `n_meas` consecutive doubles. For small `n_meas` this is compact, but for large `n_meas` it requires a per-row transpose. `major = NULL` (the default) is equivalent to `"col"`; there is no automatic switching based on the input shape. ### 2. SIMD streaming stores For large outputs, `melt_cpp` uses AVX-512 or AVX2 streaming stores (`_mm512_stream_pd`, `_mm256_stream_si256`) to write directly to memory, bypassing the CPU cache. This avoids the cache pollution that would otherwise evict useful input data, and it is the reason the 1e8-row `melt` finishes in under 0.5 s on Ubuntu (vs 2.6 s for the next-fastest engine, `polars`) and in under 1.3 s on Windows. A `_mm_sfence()` is issued at the end of each streaming region to guarantee visibility. ### 3. Hugepage hint for large outputs Output vectors larger than 512 KB are allocated through `Rf_allocVector()` and then hinted with `madvise(MADV_HUGEPAGE)`, so the kernel can back them with 2 MB pages. This reduces first-touch page faults on the 1e8-row case. There is no custom `R_allocator_t`, no `MAP_POPULATE`, and no free pool: those were described in earlier drafts but are not part of the shipped 0.1.8 backend. ### 4. Fast paths for small inputs Two fast paths are used. Tiny inputs (`n <= 2048`, `n_meas <= 64`, `n_id <= 8`) route to `melt_tiny_cpp`; small inputs (`total <= 131072`, `n_meas <= 256`) route to `melt_small_cpp`. Both skip hugepage hinting, OpenMP setup, and thread-cap detection. This is what makes `melt()` the fastest engine even on 1e3-row tables, where the initialization overhead of the other backends dominates their runtimes. ### 5. Cache-aware block sizing For the row-major path, the per-thread block size is computed from the L3 cache size (read once from `/sys/devices/system/cpu/cpu0/cache/index3/size` on Linux). Each block is sized to fit in half of L3, which keeps both the input reads and the output writes inside the cache for the duration of the block. ### 6. Per-call caches for SEXP and factor levels The `"data.frame"` class tag, the `"factor"` class tag, the default `"variable"` / `"value"` column names, and the factor levels vector are constructed once per process and reused afterwards via `R_PreserveObject`. This removes a small but measurable per-call cost that shows up on the small-input benchmarks. ## Long to wide with `dcast()` ### Basic usage ```{r} long <- melt(df, id.vars = c("id", "category")) dcast(long, id = c("id", "category"), variable = "variable", value = "value") ``` ### Formula interface ```{r} dcast(long, formula = id + category ~ variable, value.var = "value") ``` ### Fill missing cells ```{r} dcast(long, id = c("id", "category"), variable = "variable", value = "value", fill = 0) ``` ### Aggregating duplicate pairs If a `(id, variable)` pair appears more than once, pass `fun.aggregate`. The default is "last occurrence wins", matching `data.table::dcast`'s `fun.aggregate = NULL` behaviour. ```{r} long_dup <- data.frame( id = c(1, 1, 2), variable = c("x", "x", "x"), value = c(1, 2, 3) ) dcast(long_dup, id = "id", variable = "variable", value = "value", fun.aggregate = mean) ``` ### `na.rm` ```{r} long_na <- data.frame( id = c(1, 1, 2, 2), variable = c("x", "y", "x", "y"), value = c(1, NA, 3, 4) ) dcast(long_na, id = "id", variable = "variable", value = "value", na.rm = TRUE) ``` ## Why `dcast()` is fast `dcast_cpp` performs four design choices that matter at scale. ### 1. Block-path detection (Phase 0a–0c) When the input is a canonical `melt()` output — `variable` is periodic and every `id` column is constant within one period — `dcast_cpp` skips the hash tables entirely and performs a tile transpose: each tile of `TILE x period` doubles is read contiguously into an L1 buffer, transposed in place, and written contiguously to the output columns. This keeps both reads and writes sequential and enables OpenMP parallelisation. The cost per tile is `O(TILE * period)`, independent of the number of levels, which is why wide-level tables scale well. ### 2. 64-bit packed keys and 96-bit fingerprint fallback For block-aligned input with few `id` columns, `dcast_cpp` packs the row key into a single `uint64_t`. When the combined bit budget of the `id` columns exceeds 64, it falls back to a 96-bit fingerprint (`uint64_t h1 + uint32_t h2`) computed by a 4-way parallel FNV-1a and stored in a 16-byte slot table, which is more cache-friendly than the previous 128-bit scheme. ### 3. Radix sort for shuffle-friendly input When the input is not sorted, a 4-pass LSD radix sort over the packed keys replaces the hash table entirely. For inputs whose rows are already grouped — which is the common case after a `melt()` + `arrange()` pipeline — the sort is skipped. ### 4. Permutation shortcut When the block path is taken and the `id` column is a permutation of `1..n_blocks` (the most common case for canonical `melt()` output), a direct-index shortcut bypasses both the hash table and the sort. The full pipeline is described in the `dcast()` help page. ## Where the gap is narrowest The `dcast` 1e6 × 100 `id` cell is the only case in the entire benchmark suite where a competitor reaches a single-digit ratio: `polars` at 6.7× on Ubuntu and 2.6× on Windows. Both remain behind `dataprep`. This is because `polars`'s SIMD hash is competitive when the row key is very wide (100 columns), while `dcast_cpp`'s 96-bit fingerprint verification is `O(n_id)` per row in that regime. ## Cross-engine consistency `melt()` and `dcast()` produce output identical to `reshape2` (the reference implementation) on every tested cell. All pairs of engines agree pairwise within `tol = 1e-12`. ### `melt` consistency | rows | n_id | n_val | engines passed | pairwise | |---:|---:|---:|---:|---| | 1,000 | 1 | 9 | 8/8 | all consistent | | 100,000 | 1 | 9 | 8/8 | all consistent | | 1,000 | 1 | 100 | 8/8 | all consistent | | 10,000 | 10 | 10 | 8/8 | all consistent | Engines: `dataprep`, `reshape2`, `data.table`, `tidyr`, `pandas`, `polars`, `dask`, `duckdb`. ### `dcast` consistency | n_long | n_id | n_levels | engines passed | pairwise | |---:|---:|---:|---:|---| | 5,000 | 2 | 5 | 8/8 | all consistent | | 50,000 | 1 | 50 | 8/8 | all consistent | | 50,000 | 10 | 10 | 8/8 | all consistent | | 1,000,000 | 1 | 10 | 8/8 | all consistent | The consistency scripts are shipped under `inst/`: ```{r, eval = FALSE} Sys.setenv(DATAPREP_RUN_BENCHMARK = "1") source(system.file("benchmark_melt_dcast.R", package = "dataprep")) melt_all_engines(10000L, n_id = 1L, n_val = 9L) dcast_all_engines(1000L, n_id = 2L, n_val = 5L) ``` ## Round-trip example ```{r} wide <- data.frame(id = 1:5, a = rnorm(5), b = rnorm(5)) long <- melt(wide, id.vars = "id") back <- dcast(long, id = "id", variable = "variable", value = "value") all.equal(as.data.frame(back)[order(back$id), c("a", "b")], wide[, c("a", "b")], tolerance = 1e-12) ``` ## Headline numbers The tables below summarise the two hosts in one place. Full per-cell tables are in `vignette("dataprep-performance")`. ### Ubuntu 25.10 | Operation | Min | Median | Mean | Max | |---|---:|---:|---:|---:| | `melt()` | 0.6× (polars @ 1e7 × 19 × 10 × 9) | 11.3× | 67.8× | 1628.9× (dask @ 1e3 × 10001 × 1 × 10000) | | `dcast()` | 1.9× (reshape2 @ 1e3 × 1 × 10) | 46.5× | 90.2× | 463.8× (data.table @ 1e8 × 1 × 100) | ### Windows 11 Pro for Workstations | Operation | Min | Median | Mean | Max | |---|---:|---:|---:|---:| | `melt()` | 0.7× (polars @ 1e5 × 19 × 10 × 9) | 5.6× | 46.6× | 893.2× (dask @ 1e3 × 10001 × 1 × 10000) | | `dcast()` | 2.6× (polars @ 1e6 × 100 × 10) | 41.4× | 76.6× | 799.8× (duckdb @ 1e8 × 1 × 100) | For `melt()`, on the largest cells (1e8 rows, 8 GB of input), `dataprep` is the only engine that completes within 2.5 s: under 0.3 s on Ubuntu and under 1.0 s on Windows. ## Session info ```{r} sessionInfo() ```