miniextendr_api/dataframe_builder.rs
1//! Closure-per-column [`DataFrame`](crate::dataframe::DataFrame) builder.
2//!
3//! [`RDataFrameBuilder`] assembles an R `data.frame` from a set of typed columns,
4//! each with its own element type and fill closure. It is the heterogeneous-column
5//! analogue of `with_r_matrix`: you declare columns, the builder allocates the
6//! backing storage with strict PROTECT discipline, fills it, and assembles the
7//! `data.frame` on the R thread.
8//!
9//! # Fill strategy (parallel with `rayon`, serial otherwise)
10//!
11//! The builder type and its surface are **available regardless of the `rayon`
12//! feature** (#1055). Only the fill pass differs:
13//!
14//! - **With `rayon`** — the whole job is flattened into a single work-list of
15//! `(column_index, row-range)` items and filled in one work-stealing
16//! `par_iter` pass (see [`RDataFrameBuilder`] docs for the scheduling argument).
17//! - **Without `rayon`** — each column is filled in one full-range serial pass.
18//!
19//! Both paths produce an identical `data.frame`. Nothing else in the builder
20//! depends on rayon: `with_r_thread`, `Sendable`, `RNativeType`, the PROTECT
21//! discipline, and the assembly step are all rayon-independent. The `Send + Sync`
22//! bounds on the fill closures are simply unused when rayon is absent.
23
24use crate::worker::{Sendable, with_r_thread};
25use crate::{RNativeType, SEXP, SexpExt};
26
27// region: type-erased column fill machinery
28
29/// Type-erased "fill this row-range of this column" closure.
30///
31/// Called with `(offset, len)` describing a contiguous half-open row range
32/// `[offset, offset + len)` of one column. The concrete closure (captured at
33/// `.column::<T>()` / `.column_str()` registration time) knows the column's
34/// element type and destination buffer, and writes exactly that range — no
35/// other thread touches it (see the safety argument on [`RDataFrameBuilder`]).
36type RangeFiller = Box<dyn Fn(usize, usize) + Send + Sync>;
37
38/// Send+Sync wrapper for a raw column-data pointer carried across the flatten
39/// boundary. The pointer addresses R-owned memory for native columns or a
40/// `Vec<Option<String>>` backing buffer for character columns. Disjointness
41/// (per column and per row-range within a column) is the caller's invariant —
42/// see the safety argument on [`RDataFrameBuilder`].
43#[derive(Clone, Copy)]
44struct ColPtr(*mut ());
45
46unsafe impl Send for ColPtr {}
47unsafe impl Sync for ColPtr {}
48
49impl ColPtr {
50 /// Reinterpret the erased base pointer as `*mut T`.
51 ///
52 /// Taking `&self` (a method call on the whole struct) makes a capturing
53 /// closure capture the `Send + Sync` `ColPtr` as a whole rather than its
54 /// raw `*mut ()` field (which is neither), keeping the closure `Send + Sync`.
55 #[inline]
56 fn cast<T>(&self) -> *mut T {
57 self.0 as *mut T
58 }
59}
60
61/// How a column's R storage is materialized after the fill.
62enum ColumnKind {
63 /// Native column: the fill wrote directly into R memory; the SEXP is already
64 /// complete. Holds the allocated (and currently protected) SEXP.
65 Native(Sendable<SEXP>),
66 /// Character column: the fill computed `Option<String>` values into a `Vec`
67 /// (no R API on fill threads). The `CHARSXP`s are set serially on the R
68 /// thread during assembly. `None` becomes `NA_character_`.
69 Str(Vec<Option<String>>),
70}
71
72/// One registered column: a serial allocation step plus the range filler that
73/// the flattened work-list (or serial loop) dispatches.
74struct ColumnReg {
75 /// Allocates the column's backing storage (R SEXP for native columns, an
76 /// owned `Vec` for character columns) for `nrow` rows. Runs serially on the
77 /// R/worker thread. Returns the [`ColumnKind`] (carrying the protected SEXP
78 /// or the owned buffer) and the raw data pointer the range filler writes
79 /// through during the fill phase.
80 #[allow(clippy::type_complexity)]
81 alloc: Box<dyn FnOnce(usize) -> (ColumnKind, ColPtr) + Send>,
82 /// Builds the type-erased range filler once the data pointer is known.
83 make_filler: Box<dyn FnOnce(ColPtr, usize) -> RangeFiller + Send>,
84}
85
86// endregion
87
88// region: RDataFrameBuilder
89
90/// Builder for assembling an R `data.frame` from per-column fill closures.
91///
92/// This is the heterogeneous-column analogue of `with_r_matrix`: instead of one
93/// homogeneous matrix, you declare a set of typed columns (each with its own
94/// element type and fill closure) and the builder fills them all, then assembles
95/// the `data.frame`.
96///
97/// # Fill strategy (parallel with `rayon`, serial otherwise)
98///
99/// The builder is available regardless of the `rayon` feature; only the fill
100/// pass differs (see the [module docs](crate::dataframe_builder)). The rest of
101/// this section describes the parallel pass that `rayon` enables.
102///
103/// ## Two axes of parallelism, one work-stealing pass
104///
105/// There are two ways to parallelise a column fill:
106///
107/// - **Column-granular** — one task per column. Fan-out width equals the column
108/// count, so a 3-column × 10M-row frame only ever uses 3 threads.
109/// - **Row-slice-granular** — split *one* column into contiguous row ranges.
110/// Great for one long column, but on its own it serialises across columns.
111///
112/// `RDataFrameBuilder` does **not** choose. With `rayon`,
113/// [`build`][RDataFrameBuilder::build] flattens the entire job into a single
114/// work-list of `(column_index, row-range)` items — each native/character column
115/// is split into `chunk_size = max(1, nrow / (current_num_threads() * 4))`-row
116/// chunks (with 4× oversubscription) — then runs **one** `par_iter` over that
117/// flat list. Rayon's work-stealing balances both axes automatically:
118///
119/// - **wide** (100 cols × short) → ~100+ items, column-dominated.
120/// - **tall** (3 cols × 10M rows) → each column shatters into `~nthreads*4`
121/// chunks → hundreds of items, saturated even with 3 columns.
122/// - **skewed** (1 huge col + many tiny) → the huge column's chunks get stolen
123/// by threads idle after finishing the tiny columns.
124///
125/// This also avoids the per-column barrier and repeated pool spin-up that the
126/// naive "fill each column, each internally parallel" (nested `par_iter`) shape
127/// would cause. Without `rayon`, each column is filled in one full-range pass.
128///
129/// # Phases
130///
131/// 1. Allocate each column's backing storage **serially on the R/worker thread**
132/// (native columns get a protected R vector; character columns get an owned
133/// `Vec<Option<String>>`). Strict PROTECT discipline — the dangerous part.
134/// 2. Fill all columns (parallel flat pass with `rayon`, serial otherwise). No R
135/// API calls happen inside the parallel region.
136/// 3. Set character `CHARSXP`s serially on the R thread (CHARSXP allocation is
137/// forbidden on rayon threads), then assemble the `VECSXP`, `names`, compact
138/// `row.names` (`c(NA_integer_, -nrow)`), and `class = "data.frame"`.
139///
140/// # Column kinds
141///
142/// - [`column::<T>`][RDataFrameBuilder::column] — a native-typed column
143/// (`f64`/`i32`/`RLogical`/`u8`/`Rcomplex`). The fill closure receives a
144/// mutable chunk and its offset. The buffer is R memory, filled directly with
145/// zero intermediate allocation.
146/// - [`column_str`][RDataFrameBuilder::column_str] — a character (`STRSXP`)
147/// column. The per-row `Option<String>` values are computed during the fill
148/// pass, but the `CHARSXP`s are set **serially** afterward. `None` becomes
149/// `NA_character_`.
150///
151/// # Example
152///
153/// ```ignore
154/// use miniextendr_api::dataframe_builder::RDataFrameBuilder;
155///
156/// let df = RDataFrameBuilder::new(1000)
157/// .column::<f64>("x", |chunk, offset| {
158/// for (i, slot) in chunk.iter_mut().enumerate() {
159/// *slot = ((offset + i) as f64).sqrt();
160/// }
161/// })
162/// .column::<i32>("y", |chunk, offset| {
163/// for (i, slot) in chunk.iter_mut().enumerate() {
164/// *slot = (offset + i) as i32;
165/// }
166/// })
167/// .column_str("label", |i| Some(format!("row_{i}")))
168/// .build();
169/// ```
170///
171/// # Safety argument (disjoint mutation, no aliasing)
172///
173/// Neither fill path ever produces two items that overlap:
174///
175/// - Different columns address **different** backing buffers (distinct R vectors
176/// / distinct `Vec`s), so cross-column items are trivially disjoint.
177/// - Within a column, the row ranges are a partition of `[0, nrow)`. The serial
178/// path uses the single full range; the parallel path chunks `nrow` into
179/// fixed-size, non-overlapping spans. Each `(offset, len)` item therefore owns
180/// a unique slice of that column's buffer.
181///
182/// Each `RangeFiller` reconstitutes its slice via
183/// `slice::from_raw_parts_mut(base.add(offset), len)` and writes only that span.
184/// Because the spans are disjoint, no two threads ever form overlapping `&mut`
185/// references — there is no aliasing UB even though the work-list shares the raw
186/// base pointers (`ColPtr`, `Send + Sync`).
187///
188/// # Protection
189///
190/// Every native column SEXP is PROTECTed from allocation through insertion into
191/// the `VECSXP`; the `names` / `row.names` / class transients are likewise
192/// protected across each subsequent allocation. After
193/// [`build`][RDataFrameBuilder::build] returns, the resulting data.frame SEXP is
194/// unprotected and becomes the caller's responsibility (return it from a
195/// `#[miniextendr]` fn, or PROTECT it).
196pub struct RDataFrameBuilder {
197 nrow: usize,
198 names: Vec<String>,
199 columns: Vec<ColumnReg>,
200}
201
202impl RDataFrameBuilder {
203 /// Start building a data.frame with `nrow` rows.
204 pub fn new(nrow: usize) -> Self {
205 // Compact row.names uses i32, so nrow must fit in i32; this also implies
206 // it fits in R_xlen_t on all supported pointer widths.
207 assert!(
208 nrow <= i32::MAX as usize,
209 "RDataFrameBuilder: nrow {} exceeds i32 maximum (compact row.names)",
210 nrow
211 );
212 Self {
213 nrow,
214 names: Vec::new(),
215 columns: Vec::new(),
216 }
217 }
218
219 /// Add a native-typed column (`f64`/`i32`/`RLogical`/`u8`/`Rcomplex`).
220 ///
221 /// The fill closure `f(chunk, offset)` is dispatched over chunks of the
222 /// (already-allocated) R column buffer — in parallel with `rayon`, or in one
223 /// full-range pass otherwise. Chunk boundaries are deterministic for a given
224 /// `nrow` and thread count.
225 pub fn column<T>(
226 mut self,
227 name: impl Into<String>,
228 f: impl Fn(&mut [T], usize) + Send + Sync + 'static,
229 ) -> Self
230 where
231 T: RNativeType + Send + Sync,
232 {
233 self.names.push(name.into());
234 self.columns.push(ColumnReg {
235 alloc: Box::new(|nrow| {
236 // Allocate + protect the R vector serially on the R thread, then
237 // hand back its data pointer for the fill. The protection is
238 // balanced during assembly in `build`.
239 let (sexp, Sendable(ptr)) = with_r_thread(move || unsafe {
240 let sexp =
241 crate::sys::Rf_allocVector_unchecked(T::SEXP_TYPE, nrow as crate::R_xlen_t);
242 crate::sys::Rf_protect_unchecked(sexp);
243 let ptr = T::dataptr_mut(sexp);
244 (sexp, Sendable(ptr))
245 });
246 (ColumnKind::Native(Sendable(sexp)), ColPtr(ptr as *mut ()))
247 }),
248 make_filler: Box::new(move |base: ColPtr, nrow: usize| {
249 Box::new(move |offset: usize, len: usize| {
250 debug_assert!(offset + len <= nrow);
251 // Safety: this range `[offset, offset+len)` is a disjoint
252 // partition of this column's buffer (see the safety argument
253 // on `RDataFrameBuilder`); no other thread writes it. `base`
254 // is the `Send + Sync` `ColPtr`; cast inside the closure so
255 // the closure stays `Send + Sync`.
256 let ptr = base.cast::<T>();
257 let slice = unsafe { std::slice::from_raw_parts_mut(ptr.add(offset), len) };
258 f(slice, offset);
259 })
260 }),
261 });
262 self
263 }
264
265 /// Add a character (`STRSXP`) column.
266 ///
267 /// The fill closure `f(i)` returns the value for row `i` as `Option<String>`,
268 /// where `None` maps to `NA_character_`. Values are computed during the fill
269 /// pass (parallel with `rayon`, serial otherwise), then set into the R
270 /// `STRSXP` serially on the R thread (CHARSXP allocation cannot happen on
271 /// rayon threads).
272 pub fn column_str(
273 mut self,
274 name: impl Into<String>,
275 f: impl Fn(usize) -> Option<String> + Send + Sync + 'static,
276 ) -> Self {
277 self.names.push(name.into());
278 self.columns.push(ColumnReg {
279 alloc: Box::new(|nrow| {
280 // No R allocation here: the fill phase fills an owned Vec.
281 let mut buf: Vec<Option<String>> = (0..nrow).map(|_| None).collect();
282 let ptr = buf.as_mut_ptr();
283 (ColumnKind::Str(buf), ColPtr(ptr as *mut ()))
284 }),
285 make_filler: Box::new(move |base: ColPtr, nrow: usize| {
286 Box::new(move |offset: usize, len: usize| {
287 debug_assert!(offset + len <= nrow);
288 // Safety: disjoint partition of this column's Vec buffer.
289 // Cast `base` (Send + Sync `ColPtr`) inside the closure.
290 let ptr = base.cast::<Option<String>>();
291 let slice = unsafe { std::slice::from_raw_parts_mut(ptr.add(offset), len) };
292 for (i, slot) in slice.iter_mut().enumerate() {
293 *slot = f(offset + i);
294 }
295 })
296 }),
297 });
298 self
299 }
300
301 /// Allocate, fill, and assemble the [`DataFrame`](crate::dataframe::DataFrame).
302 ///
303 /// With `rayon`, flattens every column into a single `(column_index,
304 /// row-range)` work-list and runs one parallel pass over it (see the
305 /// type-level docs for the scheduling argument); without `rayon`, fills each
306 /// column serially. Then assembles the `data.frame` on the R thread.
307 pub fn build(self) -> crate::dataframe::DataFrame {
308 // SAFETY: `build_sexp` returns a well-formed data.frame VECSXP.
309 unsafe { crate::dataframe::DataFrame::from_built_sexp(self.build_sexp()) }
310 }
311
312 /// Assemble and return the raw `VECSXP` SEXP (internal; prefer [`build`](Self::build)).
313 fn build_sexp(self) -> SEXP {
314 let RDataFrameBuilder {
315 nrow,
316 names,
317 columns,
318 } = self;
319 let ncol = columns.len();
320 assert_eq!(
321 names.len(),
322 ncol,
323 "RDataFrameBuilder: names/columns length mismatch"
324 );
325 // Compact row.names `c(NA, -nrow)` are emitted as INTSXP, so `nrow` must
326 // fit in `i32`. Validate up front (panic → R error) rather than letting
327 // `-(nrow as i32)` below silently wrap for >2^31-row frames.
328 assert!(
329 nrow <= i32::MAX as usize,
330 "RDataFrameBuilder: nrow {nrow} exceeds i32 maximum for compact row.names"
331 );
332
333 // Phase 1: allocate every column's backing storage serially. Native
334 // columns return a freshly-protected R SEXP and its data pointer;
335 // character columns return an owned `Vec<Option<String>>` and its
336 // pointer. We re-protect native columns *as they are allocated* (inside
337 // `alloc`), so the per-column allocation in the next iteration cannot GC
338 // an earlier column. These protections are balanced during assembly.
339 let mut kinds: Vec<ColumnKind> = Vec::with_capacity(ncol);
340 let mut fillers: Vec<RangeFiller> = Vec::with_capacity(ncol);
341 for col in columns {
342 let ColumnReg { alloc, make_filler } = col;
343 let (kind, ptr) = alloc(nrow);
344 kinds.push(kind);
345 fillers.push(make_filler(ptr, nrow));
346 }
347
348 // Phase 2: fill every column. No R API calls happen here.
349 if nrow > 0 && ncol > 0 {
350 #[cfg(feature = "rayon")]
351 {
352 use rayon::prelude::*;
353 crate::optionals::parallel::ensure_pool();
354 // Flatten to ONE (column, row-range) work-list and run a single
355 // parallel pass. Each item is `(column_index, offset, len)`; the
356 // column's type-erased range filler writes exactly that disjoint
357 // span. Rayon's work-stealing balances the column axis and the
358 // row-slice axis together.
359 let chunk_size = std::cmp::max(1, nrow / (rayon::current_num_threads() * 4));
360 let work: Vec<(usize, usize, usize)> = (0..ncol)
361 .flat_map(|col_idx| {
362 (0..nrow).step_by(chunk_size).map(move |offset| {
363 let len = std::cmp::min(chunk_size, nrow - offset);
364 (col_idx, offset, len)
365 })
366 })
367 .collect();
368
369 work.par_iter().for_each(|&(col_idx, offset, len)| {
370 (fillers[col_idx])(offset, len);
371 });
372 }
373 #[cfg(not(feature = "rayon"))]
374 {
375 // Serial fill: each column filled in one full-range pass.
376 // ponytail: the chunked work-list only buys parallelism, which is
377 // exactly what `rayon` adds; the result is identical either way.
378 for filler in &fillers {
379 filler(0, nrow);
380 }
381 }
382 }
383 // Fillers are no longer needed; drop them before assembly so any captured
384 // closures release before we touch R.
385 drop(fillers);
386
387 // Phase 3: assemble on the R thread with strict PROTECT discipline. We
388 // are inside `with_r_thread`, a known-safe context, so `_unchecked` FFI
389 // is correct here (MXL301).
390 //
391 // PROTECT-stack invariant on entry: phase 1 left one protection per
392 // *native* column (character columns hold no SEXP yet). Track that exact
393 // count and balance it precisely.
394 with_r_thread(move || unsafe {
395 use crate::SEXPTYPE::{INTSXP, STRSXP, VECSXP};
396
397 // Materialize character columns into protected STRSXPs now (CHARSXP
398 // allocation must be serial on the R thread). Each freshly allocated
399 // STRSXP is protected immediately and stays protected until rooted in
400 // the parent VECSXP, exactly like the native columns.
401 //
402 // `native_protected` counts the protections phase 1 left on the
403 // stack; we add one per character column we protect here.
404 let mut native_protected = 0i32;
405 let mut col_sexps: Vec<SEXP> = Vec::with_capacity(ncol);
406 for kind in kinds {
407 match kind {
408 ColumnKind::Native(Sendable(sexp)) => {
409 native_protected += 1;
410 col_sexps.push(sexp);
411 }
412 ColumnKind::Str(values) => {
413 let sexp =
414 crate::sys::Rf_allocVector_unchecked(STRSXP, nrow as crate::R_xlen_t);
415 crate::sys::Rf_protect_unchecked(sexp);
416 native_protected += 1;
417 for (i, v) in values.iter().enumerate() {
418 match v {
419 Some(s) => {
420 sexp.set_string_elt_unchecked(i as isize, SEXP::charsxp(s))
421 }
422 None => {
423 sexp.set_string_elt_unchecked(i as isize, SEXP::na_string())
424 }
425 }
426 }
427 col_sexps.push(sexp);
428 }
429 }
430 }
431 // SAFETY: `native_protected` is a non-negative running count, so the
432 // sign cast to `usize` cannot lose data.
433 #[allow(clippy::cast_sign_loss)]
434 let native_protected_usize = native_protected as usize;
435 debug_assert_eq!(native_protected_usize, ncol);
436
437 // Allocate the parent list and protect it.
438 let df = crate::sys::Rf_allocVector_unchecked(VECSXP, ncol as crate::R_xlen_t);
439 crate::sys::Rf_protect_unchecked(df);
440
441 // Root every column in the parent (SET_VECTOR_ELT does not allocate).
442 for (i, col) in col_sexps.into_iter().enumerate() {
443 df.set_vector_elt_unchecked(i as isize, col);
444 }
445
446 // The columns are now reachable from `df`, so their individual
447 // protections are no longer needed. Drop all `ncol + 1` protections
448 // (the columns and `df`) and immediately re-protect `df` — no
449 // allocation happens between the two calls, so `df` cannot be
450 // collected in the gap.
451 crate::sys::Rf_unprotect_unchecked(native_protected + 1);
452 crate::sys::Rf_protect_unchecked(df);
453
454 // names: STRSXP of column names. Protect across CHARSXP allocations.
455 let names_sexp = crate::sys::Rf_allocVector_unchecked(STRSXP, ncol as crate::R_xlen_t);
456 crate::sys::Rf_protect_unchecked(names_sexp);
457 for (i, name) in names.iter().enumerate() {
458 let charsxp = SEXP::charsxp(name);
459 names_sexp.set_string_elt_unchecked(i as isize, charsxp);
460 }
461 df.set_names(names_sexp);
462 crate::sys::Rf_unprotect_unchecked(1); // names_sexp now reachable via df
463
464 // Compact row.names: c(NA_integer_, -nrow).
465 let row_names = crate::sys::Rf_allocVector_unchecked(INTSXP, 2);
466 crate::sys::Rf_protect_unchecked(row_names);
467 row_names.set_integer_elt(0, i32::MIN); // NA_integer_
468 // SAFETY: `nrow <= i32::MAX` asserted in `build_sexp`, so the
469 // narrowing cast cannot truncate the compact row.names count.
470 #[allow(clippy::cast_possible_truncation)]
471 let neg_nrow = -(nrow as i32);
472 row_names.set_integer_elt(1, neg_nrow);
473 df.set_row_names(row_names);
474 crate::sys::Rf_unprotect_unchecked(1); // row_names now reachable via df
475
476 // class = "data.frame" (cached STRSXP — no fresh allocation).
477 df.set_class(crate::cached_class::data_frame_class_sexp());
478
479 // Balance the remaining `df` protection. No allocation follows, so
480 // `df` survives until the caller takes ownership.
481 crate::sys::Rf_unprotect_unchecked(1);
482 df
483 })
484 }
485}
486
487// endregion
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 /// Serial-path proof (#1055): the builder API compiles and is reachable
494 /// without `rayon`, registering native and character columns. This builds
495 /// the registration machinery without invoking R (no `build()` call, which
496 /// needs the R runtime), so it runs in the plain `cargo test` harness on
497 /// both feature settings — the primary guarantee is that this module
498 /// compiles and the surface is callable serially.
499 #[test]
500 fn builder_surface_is_callable_without_rayon() {
501 let builder = RDataFrameBuilder::new(8)
502 .column::<f64>("x", |chunk, offset| {
503 for (i, slot) in chunk.iter_mut().enumerate() {
504 *slot = (offset + i) as f64;
505 }
506 })
507 .column_str("label", |i| Some(format!("row{i}")));
508 assert_eq!(builder.nrow, 8);
509 assert_eq!(builder.names, vec!["x".to_string(), "label".to_string()]);
510 assert_eq!(builder.columns.len(), 2);
511 }
512}