Skip to main content

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}