Detail companion to the top-level README. Covers what the package computes, how fast it is, how close to upstream it stays, the repository layout and the algorithmic notes for the SpMM hot loop.
schicluster_rs.impute_chromosome(...) runs the same end-to-end pipeline
as schicluster.impute.impute_chromosome.impute_chromosome:
- Read raw single-cell contact matrix from a
.coolfile. - Drop the diagonal.
- 2-D Gaussian convolution (mirror padding) — replaces
scipy.ndimage.gaussian_filter. - Drop the diagonal again.
- Row-normalise → P.
- Random-walk-with-restart fixed point:
Q = (1−rp)·P·Q + rp·Pfor up to 30 iterations or until‖Q_t − Q_{t-1}‖_F < tol. - Symmetrise:
E = Q + Qᵀ. - SQRTVC normalise:
E ← D^{-1/2} · E · D^{-1/2}whereD = diag(Eᵀ𝟙). - Filter the upper triangle to entries with
j − i ≤ output_dist_bins. - Write the result to an HDF5 file (cooler-compatible).
Steps 2–9 run inside Rust; only the cooler read in step 1 and the HDF5 write in step 10 cross the Python boundary.
patch_schicluster() rebinds the upstream schicluster.loop per-chrom
inner functions to Rust kernels: calculate_chrom_background_normalization
(diagonal pctl-99 z-score plus donut-minus convolution), the per-chrom
cell-to-group (Σ, Σ²) accumulator, the four background convolutions
(scan_kernel's bl / donut / horizontal / vertical), and the graph plus
heap-based find_summit. Cooler/HDF5 I/O, the paired t-test and BH-FDR
stay in Python.
Real Chang 2024 LC462 mouse cortex Droplet Hi-C, 25 kb resolution (impute path):
| step | scipy upstream | rust | speedup |
|---|---|---|---|
| chr1 (n = 7820 bins) | 30.5 s | 3.2 s | 9.6× |
| chr19 (n = 2461 bins) | 0.4 s | 0.27 s | 1.5× |
| 20 chrs end-to-end per cell | 87 s | 33 s | 2.7× |
Multi-process parallelism (8 workers × 2 rayon threads = 16 cores total): 8 chr1 in parallel from 29 s → 9.4 s — an additional 3.1× beyond the per-cell speedup, by avoiding rayon thread oversubscription.
Bit-equivalent to upstream within float-32 ε. On real Chang chr1 (n = 7820, ~1.7 M output non-zeros):
max |E_rust − E_scipy|=8.94 × 10⁻⁸- Pearson correlation =
1.000000 - nnz match exactly.
The rebuildpy parity manifest (data/manifest.yaml) pre-registers a
threshold per ported function and is read-only once the agent loop
starts. As of 0.2.0, 11 of 17 outputs pass; the remaining 6 are
deferred to phases 2–4:
| Output | Class | Threshold | Status |
|---|---|---|---|
conv.convolved |
deterministic-bounded | 1e-6 |
green (1.79e-7) |
loop_bkg.E / loop_bkg.T |
deterministic-bounded | 1e-6 |
green (≤ 9.5e-7) |
merge.e_sum / merge.e2_sum |
deterministic-bounded | 1e-6 |
green (≤ 1.2e-7) |
scan_kernels.bl/donut/h/v |
deterministic-bounded | 1e-6 |
green (≤ 2.4e-7) |
find_summit.idx |
ranked (Jaccard) | 0.99 |
green (1.0) |
find_summit.sizes |
classification | 1.0 |
green (1.0) |
insulation.score |
deterministic-bounded | 1e-6 |
Phase 2 |
topdom.bed.* |
ranked + classification | 0.95 / 0.98 |
Phase 2 |
compartment.comp / .strength |
deterministic-bounded | 1e-6 |
Phase 3 |
embedding.cell_by_feature |
deterministic-strict | 0 (f32 exact) |
Phase 4 |
tests/test_parity.py (legacy) additionally runs random_walk_cpu over
(n, rp) ∈ {50, 200, 500} × {0.05, 0.5, 0.9} and asserts
max-relative-error < 1e-4 against scipy's reference implementation.
rust-scHiCluster/
├── README.md install + tutorial
├── LICENSE MIT
├── docs/ this file + protocol artefacts
│ ├── PERFORMANCE.md (you are here)
│ ├── DISCOVERY.md rebuildpy Phase 0.5
│ ├── MATH.md (B)-rewrite perturbation bounds
│ ├── ITERATION_LOG.md acceleration attempts
│ ├── AUDIT.md Python-API coverage
│ ├── RECONSTRUCTION_REPORT.md
│ └── superpowers/ per-phase specs + plans
├── pyproject.toml maturin build config
├── rust/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs module registrations + impute path
│ ├── utils.rs sparse / banded / dense helpers
│ ├── conv.rs shared 2-D convolution (mirror mode)
│ ├── loop_bkg.rs Phase 1 — per-cell loop background
│ ├── merge.rs Phase 1 — cell-to-group sparse sum
│ ├── scan_kernels.rs Phase 1 — 4 background kernel scans
│ └── find_summit.rs Phase 1 — graph + max-heap peak merge
├── python/schicluster_rs/
│ ├── __init__.py public API + patch_schicluster()
│ └── loop/
│ ├── __init__.py loop wrappers (Phase 1)
│ └── snakemake_template_loop.txt
├── tests/
│ ├── test_parity.py legacy random_walk_cpu unit parity
│ ├── test_exact_match.py rebuildpy manifest gate
│ ├── parity_harness.py manifest → engine.parity_metrics dispatch
│ ├── py_reference_driver.py (runs upstream Python in schicluster env)
│ ├── _run_candidate.py (runs Rust in rebuild-rust env)
│ └── run_parity.sh two-env orchestrator
└── data/
├── manifest.yaml pre-registered parity gate (read-only)
└── fixtures/ synthetic test fixtures (gitignored)
The hot loop is the iterative random-walk-with-restart, implemented as a Sparse-times-Dense matrix multiplication (SpMM) with rayon row-wise parallelism:
P(sparse, ~7 nnz per row after Gaussian smoothing) stays as CSR.Q(the iterate) is stored dense, since RWR diffuses it to ≥ 30 % density after 1–2 iterations anyway.- Each iteration:
Q' = (1−rp) · (P · Q) + rp · P. TheP · Qmatmul is computed row-wise; each output row is independent, so rayon splits row-chunks across cores. Within each row, the inner AXPY (accumulateP[i,k] · Q[k, :]for sparse k) vectorises cleanly.
Other steps (Gaussian convolution, SQRTVC normalize, triangle filter) are similarly multi-threaded over rows or chunks.
For users who can tolerate ≪1 % deviation from the strict scipy result, a
band_factor parameter is available that runs the RWR with a banded Q
(only entries with |j − i| ≤ band_factor × output_dist_bins), giving an
additional ~4× speedup. Default is 0 (off, strict).
- All five convolutions in the loop pipeline (one donut-minus in
loop_bkg, four inscan_kernels) share the sameconvolve2d_mirrorprimitive — kernel-flip semantics (i.e. scipy'sconvolve, notcorrelate) plus mirror reflect-without-edge-repeat boundary, parallel across output rows with fixed-order per-row reduction so the rewrite is (E)-exact vs a serial outer loop. merge_cells_sumaccumulates inBTreeMap<(u32, u32), f64>so the emit order is row-major deterministic and the cross-cell sum dodges the f32 reduction-order driftrayonwould introduce.find_summituses Rust'sBinaryHeapkeyed on(−E, idx)with ascending-idx tie-break — that matches Pythonheapqstability when ties on E are broken by insertion order.
Reduction-order discipline is tracked in MATH.md and per-attempt in ITERATION_LOG.md.