Skip to main content

data_beans/sparse_io_vector/
read.rs

1#![allow(dead_code)]
2
3use super::*;
4use crate::sparse_io::index_audit;
5
6/// Map a backend-local row through `l2g` then `g2c`: `Ok(None)` for a row
7/// outside the intersection, an error naming the backend, the column and the
8/// row when either lookup is out of range.
9#[inline]
10fn remap_row(
11    l2g: &[usize],
12    g2c: &[Option<usize>],
13    row: u64,
14    didx: usize,
15    col: usize,
16) -> anyhow::Result<Option<usize>> {
17    let Some(&g) = l2g.get(row as usize) else {
18        anyhow::bail!(
19            "backend {didx}, column {col}: row index {row} (0x{row:016x}) is outside \
20             the backend's {} rows",
21            l2g.len()
22        );
23    };
24    g2c.get(g).copied().ok_or_else(|| {
25        anyhow::anyhow!(
26            "backend {didx}, column {col}: row index {row} maps to global row {g}, \
27             outside the global row table"
28        )
29    })
30}
31
32impl SparseIoVec {
33    /// True when some row is observed by more than one backend, so a column
34    /// that several backends contribute to can receive two values at one row.
35    fn backends_share_rows(&self) -> bool {
36        self.row_count_by_global.iter().any(|&n| n > 1)
37    }
38
39    /// The factor reads divide global column `glob` by: 1 when no scales
40    /// are set, and IEEE division by 1 is exact, so such reads are the
41    /// counts as stored.
42    #[inline]
43    fn scale_of(&self, glob: usize) -> f32 {
44        self.column_scales.as_ref().map_or(1.0, |s| s[glob])
45    }
46
47    ////////////////////
48    // access columns //
49    ////////////////////
50
51    /// Stream every nonzero of the selected `cells` (global column
52    /// indices) through `f(row, col, val)` **without** materializing the
53    /// full triplet vector. `row` is a compact-row index and `col` runs
54    /// over `0..ncol` in the iteration order of `cells` — exactly the
55    /// indices [`Self::columns_triplets`] would emit.
56    ///
57    /// Columns are processed in slabs of at most `chunk_cols`, so the
58    /// only transient allocation is one backend slab's worth of
59    /// `(u64, u64, f32)` triplets: peak memory is bounded by `chunk_cols`,
60    /// not by the total nnz. Callers can therefore build a compact edge
61    /// list (e.g. 12-byte triplets) directly and never pay for the wide
62    /// intermediate. Returns the `(nrow, ncol)` dimensions.
63    ///
64    /// Each `(row, col)` is emitted at most once: where a row canonicalizer
65    /// folds several rows of one backend together, or several backends of a
66    /// [`ColumnAlignment::Union`] column observe the same row, the values are
67    /// summed first.
68    pub fn for_each_triplet<I, F>(
69        &self,
70        cells: I,
71        chunk_cols: usize,
72        mut f: F,
73    ) -> anyhow::Result<(usize, usize)>
74    where
75        I: Iterator<Item = usize>,
76        F: FnMut(u64, u64, f32),
77    {
78        let nrow = self.num_rows();
79        let cells: Vec<usize> = cells.collect();
80        let ncol = cells.len();
81        let chunk = chunk_cols.max(1);
82        let g2c = self.global_to_compact_row.as_slice();
83        let scale: Vec<f32> = cells.iter().map(|&g| self.scale_of(g)).collect();
84
85        // Backends of one column can only collide on a row they both observe.
86        let union_shares_rows =
87            self.column_alignment == ColumnAlignment::Union && self.backends_share_rows();
88        let mut pending: Vec<(u64, u64, f32)> = Vec::new();
89
90        for slab_start in (0..ncol).step_by(chunk) {
91            let slab_end = (slab_start + chunk).min(ncol);
92
93            // Group this slab's cells by backend, tracking (local_col,
94            // out_col) where out_col is the index into the *full* `cells`
95            // sequence. Under `ColumnAlignment::Union` one global cell can
96            // contribute entries to multiple backend groups (one per
97            // backend that observed it); all those reads target the same
98            // out_col.
99            let mut backend_groups: HashMap<usize, Vec<(usize, usize)>> = HashMap::default();
100            // Per slab column: do several backends feed it rows they share?
101            let mut multi_source = vec![false; slab_end - slab_start];
102            for (k, &glob) in cells[slab_start..slab_end].iter().enumerate() {
103                let out_col = slab_start + k;
104                let sources = self.col_to_data[glob].as_slice();
105                multi_source[k] = union_shares_rows && sources.len() > 1;
106                for source in sources {
107                    backend_groups
108                        .entry(source.backend as usize)
109                        .or_default()
110                        .push((source.local_col as usize, out_col));
111                }
112            }
113
114            for (&didx, group) in &backend_groups {
115                let local_cols: Vec<usize> = group.iter().map(|&(loc, _)| loc).collect();
116                let (_, _, group_triplets) =
117                    self.data_vec[didx].read_triplets_by_columns(local_cols)?;
118
119                let l2g = self.data_local_to_global_row[didx].as_slice();
120                // A canonicalizer that collapsed >=2 local rows of this
121                // dataset to one global row repeats (row, col) within the
122                // backend; a multi-source column can repeat it across
123                // backends. Those entries wait in `pending` to be summed;
124                // every entry of an out_col lives in this slab, so per-slab
125                // accumulation is exact. The rest pass straight through.
126                let intra_merges = self.data_has_intra_row_merges[didx];
127                for (i, j, v) in group_triplets {
128                    if let Some(c) = g2c[l2g[i as usize]] {
129                        let out_col = group[j as usize].1;
130                        if intra_merges || multi_source[out_col - slab_start] {
131                            pending.push((c as u64, out_col as u64, v));
132                        } else {
133                            f(c as u64, out_col as u64, v / scale[out_col]);
134                        }
135                    }
136                }
137            }
138
139            // Divided after the duplicates are summed: one factor per column.
140            crate::sparse_util::sum_duplicate_triplets(&mut pending);
141            for (r, c, v) in pending.drain(..) {
142                f(r, c, v / scale[c as usize]);
143            }
144        }
145
146        Ok((nrow, ncol))
147    }
148
149    /// Collect all nonzeros of the selected `cells` into one triplet
150    /// vector. Thin wrapper over [`Self::for_each_triplet`] with a single
151    /// slab spanning every column (identical one-pass behavior); prefer
152    /// `for_each_triplet` when the result is consumed once, to avoid the
153    /// full-width intermediate.
154    #[allow(clippy::type_complexity)]
155    pub fn columns_triplets<I>(
156        &self,
157        cells: I,
158    ) -> anyhow::Result<((usize, usize), Vec<(u64, u64, f32)>)>
159    where
160        I: Iterator<Item = usize>,
161    {
162        let cells: Vec<usize> = cells.collect();
163        let one_slab = cells.len().max(1);
164        let mut triplets = Vec::new();
165        let dims = self.for_each_triplet(cells.into_iter(), one_slab, |r, c, v| {
166            triplets.push((r, c, v));
167        })?;
168        Ok((dims, triplets))
169    }
170
171    #[cfg(feature = "ndarray")]
172    pub fn read_columns_ndarray<I>(&self, cells: I) -> anyhow::Result<ndarray::Array2<f32>>
173    where
174        I: Iterator<Item = usize>,
175    {
176        let ((nrow, ncol), triplets) = self.columns_triplets(cells)?;
177        ndarray::Array2::<f32>::from_nonzero_triplets(nrow, ncol, &triplets)
178    }
179
180    pub fn read_columns_dmatrix<I>(&self, cells: I) -> anyhow::Result<nalgebra::DMatrix<f32>>
181    where
182        I: Iterator<Item = usize>,
183    {
184        let ((nrow, ncol), triplets) = self.columns_triplets(cells)?;
185        DMatrix::<f32>::from_nonzero_triplets(nrow, ncol, &triplets)
186    }
187
188    /// Direct-slice CSC read: bypasses the `triplet → COO → CSC` roundtrip
189    /// when the underlying backends have preloaded column arrays.
190    ///
191    /// For each cell, we slice `(indices, values)` straight out of the
192    /// backend's preloaded `by_column_indices` / `by_column_data`, remap
193    /// row indices through `l2g` then `g2c` once, drop entries that fall
194    /// outside the row intersection, and assemble final CSC arrays in one
195    /// pass per column. Backends that aren't preloaded fall back to
196    /// per-column triplet reads, which still avoids the global triplet
197    /// vec and the column-major sort inside `CscMatrix::from(&coo)`.
198    pub fn read_columns_csc<I>(&self, cells: I) -> anyhow::Result<CscMatrix<f32>>
199    where
200        I: Iterator<Item = usize>,
201    {
202        let cells: Vec<usize> = cells.collect();
203        let nrow = self.num_rows();
204        let ncol = cells.len();
205
206        // Group cells by backend, carrying the output column index so we
207        // can scatter directly into the final per-column buckets. Under
208        // `ColumnAlignment::Union` one global cell can be observed by
209        // multiple backends — the loop visits every `(didx, loc)` for
210        // the same `out_col`, so the bucket accumulates contributions
211        // from all observing backends.
212        let mut backend_groups: Vec<Vec<(usize, usize)>> =
213            (0..self.data_vec.len()).map(|_| Vec::new()).collect();
214        for (out_col, &glob) in cells.iter().enumerate() {
215            for source in self.col_to_data[glob].iter() {
216                backend_groups[source.backend as usize].push((source.local_col as usize, out_col));
217            }
218        }
219
220        // Per-output-column buckets of (compact_row, value).
221        let mut buckets: Vec<Vec<(u32, f32)>> = (0..ncol).map(|_| Vec::new()).collect();
222        let g2c = self.global_to_compact_row.as_slice();
223
224        for (didx, group) in backend_groups.iter().enumerate() {
225            if group.is_empty() {
226                continue;
227            }
228            let l2g = self.data_local_to_global_row[didx].as_slice();
229
230            if let Some((indptr, indices, values)) = self.data_vec[didx].csc_column_arrays() {
231                // Fast path: zero-copy slicing into preloaded arrays. Every
232                // index is checked at use, so a value that went bad after the
233                // preload audit fails here, naming its backend, column and row.
234                for &(loc, out_col) in group {
235                    if loc + 1 >= indptr.len() {
236                        continue;
237                    }
238                    index_audit::check_slot(
239                        "preloaded column",
240                        loc,
241                        indptr[loc],
242                        indptr[loc + 1],
243                        indices.len().min(values.len()),
244                    )?;
245                    let s = indptr[loc] as usize;
246                    let e = indptr[loc + 1] as usize;
247                    let bucket = &mut buckets[out_col];
248                    bucket.reserve(e - s);
249                    for k in s..e {
250                        if let Some(c) = remap_row(l2g, g2c, indices[k], didx, loc)? {
251                            bucket.push((c as u32, values[k]));
252                        }
253                    }
254                }
255            } else {
256                // Cold path: route through `read_triplets_by_columns` so the
257                // backend can coalesce abutting indptr ranges into a single
258                // zarr/hdf5 retrieval (zarr uses `coalesce_and_emit` + chunk
259                // LRU cache). For a contiguous block of N cells this becomes
260                // ONE retrieval instead of N — the dominant win on cold reads
261                // from `.zarr.zip` over a slow disk.
262                // The indptr gives each column's size before any entry is
263                // read, so its bucket grows once.
264                for &(loc, out_col) in group {
265                    if let Some(nnz) = self.data_vec[didx].column_nnz(loc) {
266                        buckets[out_col].reserve(nnz as usize);
267                    }
268                }
269                let cols: Vec<usize> = group.iter().map(|&(loc, _)| loc).collect();
270                let (_, _, trip) = self.data_vec[didx].read_triplets_by_columns(cols)?;
271                for (i, jj, v) in trip {
272                    let &(loc, out_col) = group.get(jj as usize).ok_or_else(|| {
273                        anyhow::anyhow!(
274                            "backend {didx}: read returned output column {jj} of {}",
275                            group.len()
276                        )
277                    })?;
278                    if let Some(c) = remap_row(l2g, g2c, i, didx, loc)? {
279                        buckets[out_col].push((c as u32, v));
280                    }
281                }
282            }
283        }
284
285        // Assemble final CSC arrays in one pass.
286        let total_nnz: usize = buckets.iter().map(|b| b.len()).sum();
287        let mut col_offsets: Vec<usize> = Vec::with_capacity(ncol + 1);
288        let mut row_indices: Vec<usize> = Vec::with_capacity(total_nnz);
289        let mut values: Vec<f32> = Vec::with_capacity(total_nnz);
290        col_offsets.push(0);
291
292        for (out_col, bucket) in buckets.iter_mut().enumerate() {
293            // Canonical CSC requires within-column row indices sorted
294            // ascending AND unique. On-disk indices are sorted by local
295            // row, but two sources can land on the same compact row:
296            //   (a) a row canonicalizer that maps two local rows in the
297            //       same backend to one global row;
298            //   (b) `ColumnAlignment::Union` where multiple backends
299            //       contribute to one output column.
300            // The composition `l2g[g2c[..]]` is monotonic in the
301            // single-source, no-canonicalizer case (the historical
302            // fast path) — detect it cheaply and skip the rebuild;
303            // otherwise sort and fold duplicates by summing.
304            let strictly_sorted_unique = bucket.windows(2).all(|w| w[0].0 < w[1].0);
305            if !strictly_sorted_unique {
306                bucket.sort_by_key(|&(r, _)| r);
307                // Compact duplicates in-place: sum values for equal rows.
308                let mut write = 0usize;
309                let mut read = 0usize;
310                while read < bucket.len() {
311                    let (r, mut v) = bucket[read];
312                    read += 1;
313                    while read < bucket.len() && bucket[read].0 == r {
314                        v += bucket[read].1;
315                        read += 1;
316                    }
317                    bucket[write] = (r, v);
318                    write += 1;
319                }
320                bucket.truncate(write);
321            }
322            // Divided after the duplicates are summed: one factor per column.
323            let scale = self.scale_of(cells[out_col]);
324            for &(r, v) in bucket.iter() {
325                row_indices.push(r as usize);
326                values.push(v / scale);
327            }
328            col_offsets.push(row_indices.len());
329        }
330
331        CscMatrix::try_from_csc_data(nrow, ncol, col_offsets, row_indices, values)
332            .map_err(|e| anyhow::anyhow!("CSC construction failed: {:?}", e))
333    }
334
335    pub fn read_columns_csr<I>(&self, cells: I) -> anyhow::Result<CsrMatrix<f32>>
336    where
337        I: Iterator<Item = usize>,
338    {
339        let ((nrow, ncol), triplets) = self.columns_triplets(cells)?;
340        nalgebra_sparse::CsrMatrix::<f32>::from_nonzero_triplets(nrow, ncol, &triplets)
341    }
342
343    #[cfg(feature = "tensor")]
344    pub fn read_columns_tensor<I>(&self, cells: I) -> anyhow::Result<Tensor>
345    where
346        I: Iterator<Item = usize>,
347    {
348        let ((nrow, ncol), triplets) = self.columns_triplets(cells)?;
349        Tensor::from_nonzero_triplets(nrow, ncol, &triplets)
350    }
351
352    /// Build (shape, triplets) for the requested compact rows across
353    /// all backends. Output column index is the SparseIoVec-global
354    /// column (concatenation of backends in push order); output row
355    /// index is the position in `rows`.
356    ///
357    /// Each `(row, col)` appears at most once, with the same sums
358    /// [`Self::for_each_triplet`] reports for it.
359    #[allow(clippy::type_complexity)]
360    pub fn rows_triplets<I>(
361        &self,
362        rows: I,
363    ) -> anyhow::Result<((usize, usize), Vec<(u64, u64, f32)>)>
364    where
365        I: Iterator<Item = usize>,
366    {
367        let rows_compact: Vec<usize> = rows.collect();
368        let nrow_out = rows_compact.len();
369        let ncol_out = self.num_columns();
370
371        let n_compact = self.cached_num_rows;
372        let compact_to_global = self.compact_to_global_row.as_slice();
373
374        // Two backends can only land on one (row, col) when a Union column
375        // spans them and they observe the same row.
376        let mut has_duplicates =
377            self.column_alignment == ColumnAlignment::Union && self.backends_share_rows();
378
379        let mut triplets: Vec<(u64, u64, f32)> = Vec::new();
380        let mut local_to_out: Vec<usize> = Vec::with_capacity(rows_compact.len());
381        for didx in 0..self.data_vec.len() {
382            let g2l = self.data_global_to_local_row[didx].as_slice();
383
384            // Every local row folded onto a requested row is read, each
385            // mapped to that row's output position.
386            local_to_out.clear();
387            let mut local_rows: Vec<usize> = Vec::with_capacity(rows_compact.len());
388            for (out_row, &c) in rows_compact.iter().enumerate() {
389                if c >= n_compact {
390                    continue;
391                }
392                let g = compact_to_global[c];
393                let first = g2l.partition_point(|&(global, _)| global < g);
394                for &(_, l) in g2l[first..].iter().take_while(|&&(global, _)| global == g) {
395                    local_rows.push(l);
396                    local_to_out.push(out_row);
397                }
398            }
399            if local_rows.is_empty() {
400                continue;
401            }
402            has_duplicates |= self.data_has_intra_row_merges[didx];
403
404            let (_, _, group_triplets) = self.data_vec[didx].read_triplets_by_rows(local_rows)?;
405            let cols_map = self
406                .data_to_cols
407                .get(&didx)
408                .ok_or_else(|| anyhow::anyhow!("missing data_to_cols entry for didx {}", didx))?;
409            triplets.reserve(group_triplets.len());
410            for (i, j, v) in group_triplets {
411                // `usize::MAX` marks a cell dropped by `mask_columns`.
412                let mapped = cols_map[j as usize];
413                if mapped == usize::MAX {
414                    continue;
415                }
416                let out_row = local_to_out[i as usize] as u64;
417                triplets.push((out_row, mapped as u64, v));
418            }
419        }
420
421        if has_duplicates {
422            crate::sparse_util::sum_duplicate_triplets(&mut triplets);
423        }
424        // Divided after the duplicates are summed: one factor per column.
425        if let Some(scales) = self.column_scales.as_ref() {
426            for t in triplets.iter_mut() {
427                t.2 /= scales[t.1 as usize];
428            }
429        }
430
431        Ok(((nrow_out, ncol_out), triplets))
432    }
433
434    #[cfg(feature = "ndarray")]
435    pub fn read_rows_ndarray<I>(&self, rows: I) -> anyhow::Result<ndarray::Array2<f32>>
436    where
437        I: Iterator<Item = usize>,
438    {
439        let ((nrow, ncol), triplets) = self.rows_triplets(rows)?;
440        ndarray::Array2::<f32>::from_nonzero_triplets(nrow, ncol, &triplets)
441    }
442
443    pub fn read_rows_dmatrix<I>(&self, rows: I) -> anyhow::Result<nalgebra::DMatrix<f32>>
444    where
445        I: Iterator<Item = usize>,
446    {
447        let ((nrow, ncol), triplets) = self.rows_triplets(rows)?;
448        DMatrix::<f32>::from_nonzero_triplets(nrow, ncol, &triplets)
449    }
450
451    pub fn read_rows_csc<I>(&self, rows: I) -> anyhow::Result<CscMatrix<f32>>
452    where
453        I: Iterator<Item = usize>,
454    {
455        let ((nrow, ncol), triplets) = self.rows_triplets(rows)?;
456        nalgebra_sparse::CscMatrix::<f32>::from_nonzero_triplets(nrow, ncol, &triplets)
457    }
458
459    pub fn read_rows_csr<I>(&self, rows: I) -> anyhow::Result<CsrMatrix<f32>>
460    where
461        I: Iterator<Item = usize>,
462    {
463        let ((nrow, ncol), triplets) = self.rows_triplets(rows)?;
464        nalgebra_sparse::CsrMatrix::<f32>::from_nonzero_triplets(nrow, ncol, &triplets)
465    }
466
467    #[cfg(feature = "tensor")]
468    pub fn read_rows_tensor<I>(&self, rows: I) -> anyhow::Result<Tensor>
469    where
470        I: Iterator<Item = usize>,
471    {
472        let ((nrow, ncol), triplets) = self.rows_triplets(rows)?;
473        Tensor::from_nonzero_triplets(nrow, ncol, &triplets)
474    }
475}