Repository navigation
accel: promote exact-nnz Wilcoxon to the CSC default, and fix the two CSC routes that lost for a reason - #559
Conversation
… that lost for a reason PR-G of the CSC plan: settle which ops `prefer_format="auto"` should reach, with a measurement for each, and pin the routes that already work. col_* (col_sums/nnz/min/max/var): not flipped, CSR wins all five. The first measurement had CSC ahead on col_min/col_max (1.45x) and col_var (3x); that was the CSR path passing an identity column list to the projected kernels, which project_csr-copied every decoded shard (twice for col_var). Unprojected handles now use the reader's whole-axis kernels, as the X.min/max/var dunders already did: tabula col_min 6.6 -> 0.93 s, col_var 12.9 -> 1.77 s; census_1m col_var 90.3 -> 10.5 s. CSC is then 2.4-4.8x slower at tabula and 4.0-8.5x at census_1m. col_var's CSR result now matches X.var(axis=0) bit for bit (the summation order used to differ in the low bits). The CSC col_* walk also decoded the whole sidecar as one slab on an unprojected handle (7.2 GB at census_500k, 12.9 GB at census_1m); runs are now split at CSC shard boundaries (1.8 GB / 5.7 GB, the open floor), with identical output since a column lives in one shard. HVG: stays csr, not deprecated. CSC is 4.4x (tabula) / 6.4x (census_1m) slower on CPU, but prefer_format="csc" is also how a filtered handle reaches gpu_csc_v3 on GPU. Docs stop recommending a sidecar for HVG or col_*. Backed + gene-only projection already takes cpu_csc under auto (PR-B's view unification); pinned for subset_var, filter_genes and adata[:, mask]. Exact-nnz Wilcoxon: bench_csc__de_csc_nnz arm (fresh process per run, since the env gate is read once, floored on route == cpu_csc_nnz) and a zero- tolerance parity pin against the densify kernel on a tie-heavy matrix; a 1e-15 rank-sum drift fails it while the 1e-9 proptest passes. Measured 3.1x (tabula) and 4.1x (census_1m) over densify. Still opt-in. Docs state the shard-cache regime each DE number was taken in. bench_de_csc_routes.py drops unused categorical levels, without which census_1m hit the two-cell guard.
There was a problem hiding this comment.
Code Review
This pull request introduces several benchmark variants, optimizes column aggregation operations by avoiding identity projections when no projection is active, and splits CSC runs at shard boundaries to reduce memory usage. It also updates documentation to clarify that CSC routes for HVG and column reductions are slower than CSR on CPU, and adds comprehensive tests including an exact-match test for the Wilcoxon exact-nnz kernel. The reviewer suggested reducing the extremely generous 3-hour timeout for fresh process benchmark runs to a more reasonable limit (e.g., 30 minutes) to prevent wasting cluster resources if a worker hangs.
|
|
||
| # Generous: the densify CSC DE route takes ~70 s on tabula_sapiens_100k and | ||
| # ~4 min at census_1m. | ||
| _FRESH_PROCESS_TIMEOUT_S = 3 * 3600 |
There was a problem hiding this comment.
The timeout of 3 hours (3 * 3600 seconds) is extremely generous for a task that is expected to take at most ~4 minutes. If a worker process hangs, this could block the SLURM job for a long time and waste cluster resources or exceed the job's walltime limit. Consider reducing this timeout to a more reasonable value, such as 15 or 30 minutes (e.g., 1800 seconds).
| _FRESH_PROCESS_TIMEOUT_S = 3 * 3600 | |
| _FRESH_PROCESS_TIMEOUT_S = 1800 |
Defects
Over-engineering
Checks
Reviewed by codex - gpt-5.6-terra. |
|
Cursor Agent - Grok 4.7 High Reviewed Defects1. Medium —
|
Review: PR #559 — accel: measure the CSC routes that do not auto-route, and fix the two that lost for a reasonReviewer: Antigravity - Gemini 3.8 Flash
Because Defects (Ranked by Severity)
Over-Engineering & Simplification Opportunities
Summary & Blast Radius
|
…here it matters Round-1 review fixes for PR #559. - bench_csc_dispatch._run_de unlabels groupby levels with fewer than two cells (and drops unused ones) before calling rank_genes_groups, falling back to the synthetic split when fewer than two levels remain. Census fixtures' cell_type has singleton levels, so every census DE arm of this benchmark, including the new de_csc_nnz one, raised under 0.17's two-cell guard. Unlabelled cells stay in the rank pool and in every group's rest. (codex, Cursor Agent) - The fresh-process variant table is one dict of (env, expected route) rather than two one-key dicts that had to agree. (all three) - The fresh worker reports user/sys CPU time around the op only, as the in-process arms do; peak RSS stays whole-process. (Antigravity) - performance.md's bold 13.7x / 21.7x sentences now say they are default-cache ratios and point at the file-cache 1.95x / 1.45x. (Cursor) - test_auto_takes_csc_on_a_gene_only_projection says it is a regression pin, not the test for a fix in this PR. (Cursor) - walk_csc_runs dedups shard starts (a projection-emptied shard repeats the next one's), and a new test runs all five CSC col_* ops on a projection that skips whole shards and starts mid-shard. (Antigravity)
Defects
Over-engineering
Prior-round findings vs bbd4a84
Correction block and blast radiusThe PR description's correction block matches bbd4a84: zero- and singleton-count categories are converted to missing values, fewer than two surviving groups select the synthetic split, and the route test exercises both layouts. The standalone DE script's narrower correction also matches its diff: it calls remove_unused_categories() for the selected categorical groupby. The behavioral blast radius is otherwise contained:
ChecksI ran gh pr checks 559 against bbd4a84 before posting. Format, Changed-path gate, Clippy, both feature-matrix clippy legs, and I-ORG-1 dedup guards pass; Docs anchors is skipped. Test, Test (hdf5 features), Base install, and Python bindings are still pending. CI is therefore not complete, and this is not a regression-free/pass verdict. Reviewed by codex - gpt-5.6-terra. |
Review: PR #559 (Round 2) — accel: measure the CSC routes that do not auto-route, and fix the two that lost for a reasonReviewer: Antigravity - Gemini 3.8 Flash
Defects (Ranked by Severity)
(No other defects found; the previous round's blockers have been cleanly resolved.) Over-Engineering & Simplification Opportunities
Prior Round Findings Audit (Commit
|
|
Cursor Agent - Grok 4.7 High Reviewed Defects1. Low —
|
…n honest gate comment Round-2 review fixes for PR #559. - The single fresh-process variant is a dedicated helper with its literal environment and expected route, not a one-entry registry, which also removes the unguarded route-key lookup in the worker. (codex, Cursor Agent, Antigravity) - A fresh child times out after 30 minutes, not 3 hours: ~30x the slowest observed run (56 s at census_1m). (codex, Antigravity) - The thresholds.yaml comment no longer says every `_csc` arm of the benchmark is gated; the col_* arms stamp no route and are outside it. (Cursor Agent)
…SC DE 1-vs-rest `rank_genes_groups` on the CSC route now runs the exact sparse-nnz kernel (route `cpu_csc_nnz`) by default. SCX_ACCEL_WILCOXON_NNZ=0 falls back to the densify kernel (`cpu_csc`); unset or any other value leaves the default on. `reference=` and `rankby_abs=True` still use densify, which is the only kernel that handles them, and pdex_ref is untouched. The evidence, from this PR: bit-identical output to densify at zero tolerance on a tie-heavy matrix (scores, p-values, adjusted p-values, log fold changes and gene order), and 3.1x (tabula_sapiens_100k, 19.6 -> 6.2 s) and 4.1x (census_1m, 225.9 -> 55.7 s) faster at the same or lower peak RSS. - The densify kernel is its own function, so the exact-parity test and the CSR-parity / non-finite tests call it directly rather than through the process-global gate, which the default no longer lets reach it. - bench_csc_dispatch: `de_csc` now times the nnz default and is floored on route `cpu_csc_nnz`; the fresh-process arm becomes `de_csc_densify` (SCX_ACCEL_WILCOXON_NNZ=0, floored on `cpu_csc`), the control that shows what the default is worth. - bench_de_csc_routes.py sets the gate itself from --mode before importing pyscx, so a mode always names the kernel that runs. - Tests asserting `cpu_csc` for a 1-vs-rest Wilcoxon call now expect `cpu_csc_nnz`; values were already compared against CSR and pass unchanged. - Docs (architecture env table, api route list, scanpy, performance, the usage skill) describe the new default and the opt-out.
All 16 versioned workspace members, `pyscx/pyproject.toml` and `rscx/DESCRIPTION`; `tests/scx-integration-tests` stays at `0.0.0`. README's `scx-cli` download snippet follows. (ROADMAP.md no longer exists, so there is no date stamp to move.) 0.20.0 collects the merged PRs since 0.19.0 (#544-#561): - CSC sidecar series: one-pass bucketed CSC builder (#555); `build-csc` as an in-place, rollback-able append (#556); same-pass CSC build on ingest and carry-through on rewrite ops (#557); parallel CSC build and `csc="auto"` as the ingest default (#558); CSC dispatch for row-indexed transforms and row-filtered / gene-subset handles (#553, #554); CSC route coverage and the exact-nnz Wilcoxon kernel as the 1-vs-rest CSC default (#559) - Correctness: Leiden parallel local moving keeps decliners eligible (#544); five CPU-accelerator parity / convergence fixes (#548); GPU cuVS stream sync, reduction determinism, decoder bounds and VRAM accounting (#549); CLI destination-overwrite safety and MTX multimodal / streaming / integer integrity (#550); unscoped whole-matrix reads of a multimodal file refused (#551); dictionary (categorical) output from append / merge / merge --sort-by (#546) and `scx sort`'s obs spill path (#547) - Benchmarks and docs: four community analytical benchmarks (#552), laptop-test recapture (#560), README refresh (#545), docs split into per-topic directories (#561) Behaviour changes worth calling out in the release notes: - **`csc="auto"` is the ingest default** on every entry point and preset (`from_mudata` keeps `off`): files with `n_obs >= 50000` and `n_vars >= 5000` now get a CSC sidecar, costing ingest wall time and +42-71 % on disk. `--csc off` / `csc="off"` opts out (#558). - **Rewrite ops carry the CSC sidecar by default** (`compact`, `merge`, `optimize`, `sort`, `subset`), rebuilt from the output's own shards; `--csc carry|always|off`. `--rebuild-csc` / `rebuild_csc=True` are deprecated aliases for `always` (#557). - **`scx build-csc` appends in place** rather than rewriting the file, and `scx rollback` removes the sidecar (#556). - **Ten CLI subcommands no longer silently overwrite an existing destination**; `--force` is required (convert, merge, subset, query --output, upgrade, cloud-optimize, explode, pack, pull; compact / optimize / sort migrated onto the same guard) and is refused where nothing is written (#550). - **MTX**: a declared-`integer` MTX with a value past 2^24, a non-integral value or `nan`/`inf` is refused on ingest (`--allow-lossy` restores the old behaviour); multimodal MTX export requires a modality (`pyscx.to_mtx` gains `modality=`); export streams one shard at a time (#550). - **An unscoped whole-matrix read of a multimodal file raises** `MultimodalRequiresModality` instead of folding every modality into one `n_obs x n_modalities`-row answer — `to_anndata()`, `to_memory()`, `read_all_csr_shards*`, `scx pull --filter` (#551). - **Numerical changes**: UMAP init scale and `random_init` now match umap-learn, the kNN sigma search, and NB-GLM dispersion shrinkage (the `above_min_disp` residual filter always runs; new `disp_outlier_sd=2.0` carve-out, `None` disables only the carve-out) (#548); parallel Leiden labels change (#544). - **Wilcoxon on the CPU CSC route** defaults to the exact-nnz kernel (route id `cpu_csc_nnz`); `SCX_ACCEL_WILCOXON_NNZ=0` restores densify (`cpu_csc`), and `reference=` / `rankby_abs=True` still use densify (#559). - **Categorical obs columns** keep their declared categories, unused levels and `ordered` bit through append / merge / sort (#546, #547). No benchmark recapture for this release. Pre-release gate on this tree: `cargo fmt --check`; `cargo clippy --workspace --exclude rscx --all-targets -D warnings`; `cargo test --workspace --exclude rscx` (4,220 passed, 0 failed); `cargo test -p scx-convert --features hdf5` (474 passed); `cargo test -p scx-cli --features hdf5 -- --test-threads=1` (253 passed); `maturin develop --release` + `pytest tests/` (3,247 passed, 140 skipped, 2 xfailed; `pyscx.__version__ == "0.20.0"`). Not run: rscx tests, `--features cloud`, GPU pytest, fuzzing, benchmarks. Claude-Session: https://claude.ai/code/session_01RwRKstgr4TNkXhLhkQwmjA Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This PR measures, one op at a time, which of the ops that don't default to
prefer_format="auto"should. None of the measurements justified moving another op toauto. Two of them turned out to be losing, or looking like they won, because of a defect, and this PR fixes both. It also promotes the exact-nnz Wilcoxon kernel to the 1-vs-rest CSC default, with a zero-tolerance parity test against the kernel it replaces.col_*: CSR stays the default, and CSRcol_min/col_max/col_varget 7–9× fasterThe first measurement had CSC ahead on
col_min/col_max(1.45×) andcol_var(3×) at tabula_sapiens_100k. That result was an artifact.pyscx.accel.col_min/col_max/col_varhanded the projected kernels an identity column list. Those kernels runproject_csron every decoded shard, so the whole matrix was copied once per pass, twice forcol_var.col_sums/col_nnzand theX.min/max/var(axis=0)dunders already dispatched four ways and used the reader's whole-axis kernels.col_aggs.rsnow does the same.Measured on one 16-core
cpunode, median of 3, one fresh process per run:mainCSRcol_mincol_varcol_mincol_varAfter the fix, CSR is faster on all five ops: CSC is 2.4–4.8× slower at tabula and 4.0–8.5× slower at census_1m. Every arm pair returned the same values (a hash of the output rounded to 1e-3).
On a multi-shard file, the CSR
col_varresult now matchesX.var(axis=0)exactly. Before, the two summed in different orders and differed in the low bits.The CSC
col_*walk no longer loads the whole sidecar into memory.walk_csc_runsread each contiguous run of requested columns as one slab. On an unprojected handle that run is the whole sidecar: 7.2 GB at census_500k and 12.9 GB at census_1m. Runs are now split at CSC shard boundaries, which brings peak down to 1.8 GB and 5.7 GB; 5.7 GB is also the CSR arm's peak on the same file. The output doesn't change, because each column lives in exactly one shard.HVG: stays CSR;
prefer_formatis not deprecatedOn CPU, the CSC route (single-batch seurat_v3) is 4.4× slower at tabula (9.8 s vs 2.2 s) and 6.4× slower at census_1m (84.7 s vs 13.3 s). It uses 1.5–2.2× the peak memory and selects the same genes.
The kwarg stays because on
device="gpu",prefer_format="csc"is the only way a filtered handle reachesgpu_csc_v3; the default auto-route leaves such a handle ongpu_csr. The docstring now states the CPU loss.docs/sharding.md§ When to add a CSC sidecar drops its HVG bullet and names HVG andcol_*as reasons not to add a sidecar.Backed handle + gene projection: already routes CSC
The plan said a backed handle with only a column projection took
cpu_csrunderauto. It already takescpu_csconmain: PR-B (#554) changed the backed handle to serve its view as the column source. This PR only pins that, forsubset_var,filter_genesandadata[:, mask]. The new test passes onmaintoo; it guards against a regression, not a fix.Docs: which shard-cache regime each number comes from
A new block in
docs/scanpy.md§prefer_formatstates which shard cache each DE number was measured with:LATESTbench_csc_dispatcharms, with the CSR arm at 83 GB of RSS.HVG and
col_*are single-pass ops, so the cache doesn't enter into them. The negative GPU result is kept.docs/performance.mdstates the default-cache setting for its DE table. The new HVG andcol_*numbers go inscanpy.md, marked as one-off captures, becauseperformance.mdclaims need a manifest entry.Exact-nnz Wilcoxon: now the default
The evidence:
nnz_matches_the_densify_kernel_exactly_on_tie_heavy_countscompares the nnz kernel to the densify kernel at zero tolerance: scores, p-values, adjusted p-values, log fold changes and gene order. The matrix is counts in {1, 2, 3} with explicit stored zeros, an empty column, a constant column, unlabelled cells, and a chunk width that doesn't dividen_vars. I checked it can fail: scaling the implicit-zero rank-sum term by1 + 1e-15fails this test, while the existing 1e-9 property test still passes. After the flip the test calls the densify kernel directly (wilcoxon_rank_sum_densify_csc), not through the process-global gate.disease: 225.9 s → 55.7 s (4.1×), same 5.7 GB peak.The switch (
c859d286):SCX_ACCEL_WILCOXON_NNZis0/false/FALSE/off. It is still read once per process.cpu_cscfrom a 1-vs-rest call now expectcpu_csc_nnz. Their value comparisons against CSR, some of them bit-for-bit, pass unchanged.Benchmarks:
bench_csc_dispatch'sde_cscarm now times the default, and itscsc_dispatch_correctpasses only on routecpu_csc_nnz.de_csc_densify, run withSCX_ACCEL_WILCOXON_NNZ=0and floored on routecpu_csc. It is the control that shows what the default is worth, and it needs a fresh process because the gate is read through aOnceLock.bench_de_csc_routes.pysets the gate itself from--modebefore importing pyscx, so each mode always runs the kernel it names.benchmarks/scripts/bench_de_csc_routes.pynow drops unused categorical levels. Without that, census_1m'sdiseasecolumn, which declares the census-wide vocabulary, has declared levels with no cells, and the two-cell minimum-group check (in place since 0.17) raises before any gene is tested.Census DE arms of
bench_csc_dispatch(fixed inbbd4a847)The first version of this description listed this as found, not fixed.
bench_csc_dispatch._run_depickedcell_typeon census fixtures, where some groups have a single cell, so every census DE arm raised under the two-cell minimum, including the newde_csc_nnzarm._run_denow does two things before callingrank_genes_groups:test_bench_csc_dispatch_groupby.pychecks the setup: the unmodified call raises on such a column, and_run_deruns on both routes. Found by codex and Cursor Agent.Local verification
pytest tests/: 3242 passed.cargo test -p scx-accelpasses, andbenchmarks/comprehensive/testshas 534 passed.cargo clippy --workspace --exclude rscx --all-targets -D warningsandcargo fmt --checkare clean.test_accel_col_ops_agree_with_the_dunders_without_a_projectionfails onmain's build (col_var, whole handle) and passes here.