Skip to main content

data_beans/
column_subset.rs

1//! Stream a column selection (and an optional row selection or renumbering)
2//! of a sparse backend into a fresh backend without materialising the
3//! survivors. Shared by the `split` / `subsample` / `subset-*` CLI handlers,
4//! by the in-place subset and reorder of [`SparseIo`], and by downstream
5//! crates (faba's `qc`) that filter a matrix by cells and features.
6
7use crate::sparse_io::{SparseIo, SLAB_BUDGET_BYTES, TRIPLET_BYTES};
8use rayon::prelude::*;
9
10/// Every index of `selection` is below `n` and listed once; `what` names the
11/// side ("row", "column") in the error.
12pub(crate) fn distinct_within(selection: &[usize], n: usize, what: &str) -> anyhow::Result<()> {
13    let mut seen = vec![false; n];
14    for &i in selection {
15        let slot = seen
16            .get_mut(i)
17            .ok_or_else(|| anyhow::anyhow!("{what} {i} is past the {n} {what}s"))?;
18        anyhow::ensure!(
19            !std::mem::replace(slot, true),
20            "the {what} selection repeats {what} {i}"
21        );
22    }
23    Ok(())
24}
25
26/// Where each row of the input lands in the output: `to[old]` is its new
27/// index, `None` when the row is dropped; `nrow` is the output's row count.
28/// Rows may land in any order, and an output row may be fed by no input row
29/// (it is then empty), but never by two.
30pub struct RowMap {
31    pub to: Vec<Option<u64>>,
32    pub nrow: usize,
33}
34
35impl RowMap {
36    /// The map that keeps the rows `keep` of `nrow_full`, in `keep`'s order.
37    ///
38    /// # Errors
39    /// If `keep` lists a row past `nrow_full`, or one row twice.
40    pub fn keeping(keep: &[usize], nrow_full: usize) -> anyhow::Result<Self> {
41        distinct_within(keep, nrow_full, "row")?;
42        let mut to = vec![None; nrow_full];
43        for (new, &old) in keep.iter().enumerate() {
44            to[old] = Some(new as u64);
45        }
46        Ok(Self {
47            to,
48            nrow: keep.len(),
49        })
50    }
51
52    /// The map that puts the `nrow_full` rows named `old_names` in the order
53    /// of `order`: output row `i` is the input row named `order[i]`, empty
54    /// when no input row has that name; an input row whose name `order` does
55    /// not list is dropped. Row `r` is named `old_names[r]`: a row past the
56    /// names has none, so nothing places it, and a name past the rows names
57    /// no row.
58    ///
59    /// # Errors
60    /// If `order` lists a name twice (its second position would stay empty),
61    /// or two input rows carry one listed name (they would share a position).
62    pub fn by_names(
63        old_names: &[Box<str>],
64        order: &[Box<str>],
65        nrow_full: usize,
66    ) -> anyhow::Result<Self> {
67        let place = crate::sparse_io::build_name2index_map(order);
68        if place.len() != order.len() {
69            // The map keeps the last position of a repeated name.
70            let (first, name) = order
71                .iter()
72                .enumerate()
73                .find(|&(i, name)| place[name] != i)
74                .expect("a shorter map means a repeated name");
75            anyhow::bail!(
76                "the row order lists `{name}` twice (positions {first} and {})",
77                place[name]
78            );
79        }
80        // `claimed[new]` is the input row that took the output position.
81        let mut claimed: Vec<Option<usize>> = vec![None; order.len()];
82        let named = old_names.len().min(nrow_full);
83        let mut to = old_names[..named]
84            .iter()
85            .enumerate()
86            .map(|(old, name)| {
87                let Some(&new) = place.get(name) else {
88                    return Ok(None);
89                };
90                if let Some(first) = claimed[new].replace(old) {
91                    anyhow::bail!(
92                        "rows {first} and {old} are both named `{name}`; \
93                         they cannot share one position"
94                    );
95                }
96                Ok(Some(new as u64))
97            })
98            .collect::<anyhow::Result<Vec<_>>>()?;
99        to.resize(nrow_full, None);
100        Ok(Self {
101            to,
102            nrow: order.len(),
103        })
104    }
105
106    /// Each output row is in range and fed by at most one of the `nrow_full`
107    /// input rows.
108    fn check(&self, nrow_full: usize) -> anyhow::Result<()> {
109        anyhow::ensure!(
110            self.to.len() == nrow_full,
111            "the row map covers {} rows, the data has {nrow_full}",
112            self.to.len()
113        );
114        let mut fed = vec![false; self.nrow];
115        for &new in self.to.iter().flatten() {
116            let slot = fed.get_mut(new as usize).ok_or_else(|| {
117                anyhow::anyhow!("row {new} is past the {} output rows", self.nrow)
118            })?;
119            anyhow::ensure!(
120                !std::mem::replace(slot, true),
121                "two rows of the data land on output row {new}"
122            );
123        }
124        Ok(())
125    }
126}
127
128/// Number of entries in the rows `to` keeps (those it maps to `Some`), read
129/// off the row index `indptr` without touching the data: the survivors when
130/// every column is kept. `None` when the index is absent or does not
131/// describe the rows of `to`.
132pub(crate) fn nnz_of_kept_rows(indptr: &[u64], to: &[Option<u64>]) -> Option<u64> {
133    if indptr.len() != to.len() + 1 {
134        return None;
135    }
136    to.iter()
137        .zip(indptr.windows(2))
138        .filter(|(kept, _)| kept.is_some())
139        .try_fold(0u64, |acc, (_, w)| Some(acc + w[1].checked_sub(w[0])?))
140}
141
142/// Lay the triplets of one block read out as a CSC slab of `ncol` columns:
143/// `(colptr, row indices, values)`, where `colptr[j]` is the slab-local
144/// offset of column `j`'s first entry (the sentinel after the last column is
145/// the writer's to add).
146///
147/// The triplets carry slab-local column ids. With `rows`, each row is
148/// renumbered by `rows[old]` and an entry whose row maps to `None` is
149/// dropped. Entries are counted and placed by column, so those of one column
150/// keep their arrival order whatever order the columns arrive in; a column
151/// whose rows then do not ascend is sorted by row, stably, so entries sharing
152/// a row stay in arrival order too.
153pub(crate) fn csc_slab(
154    triplets: Vec<(u64, u64, f32)>,
155    ncol: usize,
156    rows: Option<&[Option<u64>]>,
157) -> (Vec<u64>, Vec<u64>, Vec<f32>) {
158    let new_row = |r: u64| match rows {
159        None => Some(r),
160        Some(to) => to[r as usize],
161    };
162
163    let mut colptr = vec![0u64; ncol + 1];
164    for &(r, c_local, _) in &triplets {
165        if new_row(r).is_some() {
166            colptr[c_local as usize + 1] += 1;
167        }
168    }
169    for c in 0..ncol {
170        colptr[c + 1] += colptr[c];
171    }
172    let slab_len = colptr[ncol] as usize;
173    let mut row_indices = vec![0u64; slab_len];
174    let mut values = vec![0f32; slab_len];
175    let mut cursor = colptr[..ncol].to_vec();
176    for (r, c_local, x) in triplets {
177        if let Some(new_r) = new_row(r) {
178            let at = &mut cursor[c_local as usize];
179            row_indices[*at as usize] = new_r;
180            values[*at as usize] = x;
181            *at += 1;
182        }
183    }
184
185    let mut column: Vec<(u64, f32)> = Vec::new();
186    for w in colptr.windows(2) {
187        let span = w[0] as usize..w[1] as usize;
188        if row_indices[span.clone()].is_sorted() {
189            continue;
190        }
191        column.clear();
192        column.extend(
193            row_indices[span.clone()]
194                .iter()
195                .copied()
196                .zip(values[span.clone()].iter().copied()),
197        );
198        column.sort_by_key(|&(r, _)| r);
199        for (k, &(r, x)) in span.zip(&column) {
200            row_indices[k] = r;
201            values[k] = x;
202        }
203    }
204
205    colptr.truncate(ncol);
206    (colptr, row_indices, values)
207}
208
209/// What a selection writes, settled before any output exists: the output's
210/// shape and the column blocks its slabs are read in.
211pub(crate) struct SelectionPlan {
212    nrow: usize,
213    nnz: u64,
214    /// `[lb, ub)` positions in the column selection, one slab each.
215    slabs: Vec<(usize, usize)>,
216}
217
218/// Check a selection of `src` (columns `old_cols`, rows renumbered by `rows`)
219/// and size its output, reading nothing but counts.
220///
221/// The output's nnz comes from the resident indptr when every row survives,
222/// or when every column does (the by-row indptr then counts the rows kept);
223/// otherwise from one counting pass that keeps counts, never entries.
224///
225/// # Errors
226/// If a column of `old_cols` is past the data's columns, or `rows` does not
227/// map the data's rows onto distinct output rows.
228pub(crate) fn plan_csc_selection<S>(
229    src: &S,
230    old_cols: &[usize],
231    rows: Option<&RowMap>,
232) -> anyhow::Result<SelectionPlan>
233where
234    S: SparseIo + Sync + ?Sized,
235{
236    plan_within(src, old_cols, rows, SLAB_BUDGET_BYTES)
237}
238
239/// [`plan_csc_selection`] with `slab_budget` bytes of triplets to a slab.
240fn plan_within<S>(
241    src: &S,
242    old_cols: &[usize],
243    rows: Option<&RowMap>,
244    slab_budget: usize,
245) -> anyhow::Result<SelectionPlan>
246where
247    S: SparseIo + Sync + ?Sized,
248{
249    use legume_numeric::matrix::utils::byte_budget_intervals;
250
251    let nrow_src = src
252        .num_rows()
253        .ok_or_else(|| anyhow::anyhow!("backend has no `nrow`"))?;
254    if let Some(rows) = rows {
255        rows.check(nrow_src)?;
256    }
257    let ncol_src = src
258        .num_columns()
259        .ok_or_else(|| anyhow::anyhow!("backend has no `ncol`"))?;
260
261    // Stored nnz of each selected column, off the resident indptr: exact when
262    // every row survives, an upper bound under a row filter.
263    let stored_nnz: Vec<u64> = old_cols
264        .iter()
265        .map(|&c| {
266            anyhow::ensure!(c < ncol_src, "column {c} is past the {ncol_src} columns");
267            src.column_nnz(c)
268                .ok_or_else(|| anyhow::anyhow!("no indptr entry for column {c}"))
269        })
270        .collect::<anyhow::Result<_>>()?;
271
272    // The writer needs the exact total up front. Without a row filter it is
273    // the stored total. With one, and every column kept, the resident row
274    // index gives it for free. Otherwise one counting pass.
275    //
276    // Slabs are read a window at a time, one to a thread, here and by the
277    // writer, so each is cut at a thread's share of the slab budget. A slab
278    // reads every stored entry of its columns, whatever the row map then
279    // drops, so the stored counts are what bound it.
280    let share = slab_budget / rayon::current_num_threads().max(1);
281    let slabs = byte_budget_intervals(&stored_nnz, share, TRIPLET_BYTES);
282    let nnz: u64 = match rows {
283        None => stored_nnz.iter().sum(),
284        Some(remap) => {
285            let every_column_once =
286                old_cols.len() == ncol_src && distinct_within(old_cols, ncol_src, "column").is_ok();
287            match every_column_once
288                .then(|| nnz_of_kept_rows(src.row_indptr(), &remap.to))
289                .flatten()
290            {
291                Some(total) => total,
292                None => slabs
293                    .par_iter()
294                    .map(|&(lb, ub)| -> anyhow::Result<u64> {
295                        let (_, _, triplets) = src
296                            .read_triplets_by_columns(old_cols[lb..ub].iter().copied().collect())?;
297                        Ok(triplets
298                            .iter()
299                            .filter(|(r, _, _)| remap.to[*r as usize].is_some())
300                            .count() as u64)
301                    })
302                    .sum::<anyhow::Result<u64>>()?,
303            }
304        }
305    };
306
307    Ok(SelectionPlan {
308        nrow: rows.map_or(nrow_src, |r| r.nrow),
309        nnz,
310        slabs,
311    })
312}
313
314/// Append the columns `cols` of `src`, cut into `slabs` (`[lb, ub)`
315/// positions in `cols` that tile it in order), to the column index `out` is
316/// streaming: position `p` of `cols` becomes column `col_offset + p`, and
317/// the first entry lands at `nnz_offset`. With `rows`, each row is
318/// renumbered by `rows[old]` and an entry whose row maps to `None` is
319/// dropped. Returns the number of entries written.
320///
321/// The slabs go through a window at a time: the slabs of a window are read
322/// at once, one to a thread, and laid out, then appended as one stretch. A
323/// window closes at as many slabs as there are threads, or sooner when the
324/// stored entries of its columns reach [`SLAB_BUDGET_BYTES`], so what is
325/// resident follows the budget, not the thread count, even where single
326/// columns outgrow a thread's share and each make a slab of their own.
327///
328/// # Errors
329/// If a block read fails. Nothing is skipped: the output's nnz is declared
330/// up front, so a block left out would leave a gap of fill values behind.
331pub(crate) fn append_csc_slabs<S, O>(
332    src: &S,
333    cols: &[usize],
334    rows: Option<&[Option<u64>]>,
335    slabs: &[(usize, usize)],
336    out: &mut O,
337    col_offset: u64,
338    nnz_offset: u64,
339) -> anyhow::Result<u64>
340where
341    S: SparseIo + Sync + ?Sized,
342    O: SparseIo + ?Sized,
343{
344    let threads = rayon::current_num_threads().max(1);
345    let stored_bytes = |&(lb, ub): &(usize, usize)| -> usize {
346        let nnz: u64 = cols[lb..ub].iter().filter_map(|&c| src.column_nnz(c)).sum();
347        (nnz as usize).saturating_mul(TRIPLET_BYTES)
348    };
349    let mut written = 0u64;
350    let mut rest = slabs;
351    while !rest.is_empty() {
352        // The first slab always goes in, so a window is never empty.
353        let mut resident = 0usize;
354        let len = rest
355            .iter()
356            .take(threads)
357            .take_while(|&slab| {
358                let fits = resident == 0 || resident + stored_bytes(slab) <= SLAB_BUDGET_BYTES;
359                resident += stored_bytes(slab);
360                fits
361            })
362            .count();
363        let (window, later) = rest.split_at(len);
364        rest = later;
365        // One block read per slab, never a read per column: a single-column
366        // read pays the cached-subset machinery per call, which dominates at
367        // hundreds of thousands of columns. The block read numbers its
368        // columns from the block's first, rows ascending within each; a row
369        // map that reorders is sorted for within each column.
370        let built = window
371            .par_iter()
372            .map(|&(lb, ub)| -> anyhow::Result<_> {
373                let (_, _, triplets) =
374                    src.read_triplets_by_columns(cols[lb..ub].iter().copied().collect())?;
375                Ok(csc_slab(triplets, ub - lb, rows))
376            })
377            .collect::<anyhow::Result<Vec<_>>>()?;
378
379        // The window as one slab: each colptr rebased by the entries of the
380        // slabs before it.
381        let window_nnz: usize = built.iter().map(|(_, _, values)| values.len()).sum();
382        let mut colptr: Vec<u64> = Vec::with_capacity(window[window.len() - 1].1 - window[0].0);
383        let mut row_indices: Vec<u64> = Vec::with_capacity(window_nnz);
384        let mut values: Vec<f32> = Vec::with_capacity(window_nnz);
385        for (slab_colptr, slab_rows, slab_values) in built {
386            let before = values.len() as u64;
387            colptr.extend(slab_colptr.iter().map(|&at| at + before));
388            row_indices.extend(slab_rows);
389            values.extend(slab_values);
390        }
391        out.append_csc_slab(
392            col_offset + window[0].0 as u64,
393            nnz_offset + written,
394            &colptr,
395            &row_indices,
396            &values,
397        )?;
398        written += window_nnz as u64;
399    }
400    Ok(written)
401}
402
403/// Write the selection `plan` was made for as the whole content of `out`, an
404/// empty streaming backend: the column index slab by slab, then the row
405/// index transposed from it. Names and metadata are left to the caller.
406/// Returns the output's `(rows, columns, non-zeros)`.
407pub(crate) fn write_csc_selection<S, O>(
408    src: &S,
409    old_cols: &[usize],
410    rows: Option<&RowMap>,
411    plan: &SelectionPlan,
412    out: &mut O,
413) -> anyhow::Result<(usize, usize, usize)>
414where
415    S: SparseIo + Sync + ?Sized,
416    O: SparseIo + ?Sized,
417{
418    let shape = (plan.nrow, old_cols.len(), plan.nnz as usize);
419    out.begin_streaming_csc(shape)?;
420
421    let to = rows.map(|r| r.to.as_slice());
422    append_csc_slabs(src, old_cols, to, &plan.slabs, out, 0, 0)?;
423
424    out.finalize_streaming_csc()?;
425    out.build_csr_from_csc_streaming()?;
426    Ok(shape)
427}
428
429/// Write columns `old_cols` of `src`, in that order and with rows renumbered
430/// by `rows` (`None` keeps every row), as the whole content of `out`:
431/// [`plan_csc_selection`], then [`write_csc_selection`]. A caller that
432/// creates `out` for the selection plans first, so a refused selection
433/// leaves nothing behind.
434pub(crate) fn stream_csc_selection<S, O>(
435    src: &S,
436    old_cols: &[usize],
437    rows: Option<&RowMap>,
438    out: &mut O,
439) -> anyhow::Result<(usize, usize, usize)>
440where
441    S: SparseIo + Sync + ?Sized,
442    O: SparseIo + ?Sized,
443{
444    let plan = plan_csc_selection(src, old_cols, rows)?;
445    write_csc_selection(src, old_cols, rows, &plan, out)
446}
447
448/// Stream a column selection (with an optional row filter) into a fresh
449/// backend, without ever materialising the survivors.
450///
451/// The shape `split` and `subsample` share: pick columns, optionally keep a
452/// subset of rows, write a new file. This is [`stream_mapped_selection`] over
453/// the rows `row_filter` keeps, in its order.
454pub fn stream_column_selection(
455    data: &dyn SparseIo<IndexIter = Vec<usize>>,
456    selected_columns: &[usize],
457    row_filter: Option<&[usize]>,
458    out_row_names: &[Box<str>],
459    out_col_names: &[Box<str>],
460    file_out: &str,
461    backend_out: &crate::sparse_io::SparseIoBackend,
462) -> anyhow::Result<(usize, usize, usize)> {
463    let nrow_full = data
464        .num_rows()
465        .ok_or_else(|| anyhow::anyhow!("backend has no `nrow`"))?;
466    let rows = row_filter
467        .map(|keep| RowMap::keeping(keep, nrow_full))
468        .transpose()?;
469    stream_mapped_selection(
470        data,
471        selected_columns,
472        rows.as_ref(),
473        out_row_names,
474        out_col_names,
475        file_out,
476        backend_out,
477    )
478}
479
480/// Stream the columns `selected_columns` of `data`, in that order, into a
481/// fresh backend at `file_out`, its rows renumbered (dropped, reordered) by
482/// `rows` when given, with the given names and `data`'s metadata. Returns the
483/// output's `(rows, columns, non-zeros)`.
484///
485/// The survivors are never materialised: they flow through the validated
486/// streaming writer in byte-budgeted slabs, cut from the resident indptr.
487///
488/// # Errors
489/// If a selected column is past the data's columns, or `rows` does not map
490/// the data's rows onto distinct output rows; `file_out` is then not created.
491pub fn stream_mapped_selection(
492    data: &dyn SparseIo<IndexIter = Vec<usize>>,
493    selected_columns: &[usize],
494    rows: Option<&RowMap>,
495    out_row_names: &[Box<str>],
496    out_col_names: &[Box<str>],
497    file_out: &str,
498    backend_out: &crate::sparse_io::SparseIoBackend,
499) -> anyhow::Result<(usize, usize, usize)> {
500    let plan = plan_csc_selection(data, selected_columns, rows)?;
501    let mut out =
502        crate::sparse_io::create_sparse_streaming_empty(Some(file_out), Some(backend_out))?;
503
504    let t_stream = std::time::Instant::now();
505    let shape = write_csc_selection(data, selected_columns, rows, &plan, &mut *out)?;
506    log::info!(
507        "streamed {} entries in {} columns ({:.1}s)",
508        shape.2,
509        shape.1,
510        t_stream.elapsed().as_secs_f32()
511    );
512    out.register_row_names_vec(out_row_names);
513    out.register_column_names_vec(out_col_names);
514    out.set_metadata(&data.metadata())?;
515    Ok(shape)
516}
517
518#[cfg(test)]
519#[path = "column_subset_tests.rs"]
520mod tests;