Sample variance: bound its memory, fix the planner, and document what it costs (0.9.0) - #29
settylab-dotto-bot[bot] wants to merge 26 commits into
Conversation
…-pass workflow
Supplying `sample_col` to `kompot.de()` is the one option whose cost is
multiplied by the gene count, and nothing a user read on the way to that
decision said so. Without it, every gene is scored against a single shared
posterior covariance of shape (n_landmarks, n_landmarks); with it, every gene
gets its own, so the dominant allocation becomes
`3 * n_landmarks**2 * n_genes * 8` bytes -- about 0.56 GiB per gene at the
default n_landmarks=5000, ~560 GiB for 1 000 genes, and terabytes for a whole
transcriptome. Compute scales too: the Mahalanobis step then performs one
Cholesky factorisation per gene, in a Python loop, instead of one in total,
and GPSettings.batch_size does not bound that loop.
New canonical guide docs/source/resource_planning.rst, linked from the other
surfaces rather than duplicated into them. It derives the scaling from the
allocation in the source, carries measured dry-run plans at realistic sizes,
prescribes the two-pass workflow (cheap first pass over all genes, then
sample variance restricted to the top ~1 000), ranks the levers -- `genes`
linear, `n_landmarks` quadratic, `null_genes=0`, disk offload -- and shows how
to price a run with `dry_run=True`.
The same warning now appears where the decision is actually made: `kompot.de`'s
docstring (so `help()` carries it), GPSettings.n_landmarks, StorageSettings,
SampleVarianceEstimator, `kompot de --sample-col`, the DE config template, the
README, and notebooks 02 and 03 (markdown cells only; outputs untouched).
Two docs/code disagreements corrected rather than papered over:
- StorageSettings.max_memory_ratio was documented as "Fraction of RAM before
triggering disk storage". It triggers nothing. `store_arrays_on_disk=None`
resolves to `disk_storage_dir is not None`, and the ratio only sets the
threshold at which the estimator escalates warnings; SampleVarianceEstimator
hardcodes 0.8 for its own check, so a user's value never reaches it. The DE
config template's `store_arrays_on_disk: null # (null = auto)` implied the
same non-existent automatic decision.
- The CLI "Complete Analysis" example paired `--sample-col Sample` with
`--n-landmarks 5000` over every gene: the single most expensive
configuration the package can express (~11 TiB on a 20 000-gene dataset),
presented as the recommended form. Replaced with the restricted second pass
through a config file.
Two measured behavioural defects are documented rather than fixed here, and
filed separately:
- #25 the dry run does not resolve `null_genes="auto"`, so a default-settings
plan without `sample_col` omits the 2 000 null genes the run will add
(measured 4.44x optimistic on a 4 000x500 input).
- #26 `store_arrays_on_disk=True` does not reduce peak memory, and with dask
installed nothing is written to disk at all. Measured at two scales with
peak anonymous memory from /proc/self/smaps_rollup, against a plan
predicting a 17x-28x drop.
Numbers in the guide come from `kompot.de(..., dry_run=True)` on synthetic
AnnData, reproducible with the snippets shown alongside them.
Refs #27
Three silent defects around the per-gene covariance tensor, plus the version bump that ships them. #26 — store_arrays_on_disk did not bound memory ------------------------------------------------------------- With `sample_col` set, each condition produces a (n_landmarks, n_landmarks, n_genes) float64 covariance tensor. The code summed the two into a THIRD dense tensor and then added the shared posterior covariance into it gene by gene with __setitem__. With np.memmap inputs that sum materialises the whole thing in RAM, which is exactly the array store_arrays_on_disk exists to avoid; under Dask the per-gene __setitem__ rebuilt the graph once per gene. Separately, compute_mahalanobis_distances ran `cov = jnp.array(covariance)` in its gene-specific branch and never read `cov` — a second full-size copy for nothing. The sum is now assembled lazily by kompot.utils.LazyGeneCovariance, which keeps references to the terms and materialises one (n_landmarks, n_landmarks) matrix when the Mahalanobis step asks for a gene. A LazyGeneCovariance deliberately does not take the Dask delayed branch: that branch submits every gene at once and the view materialises its own slices inside each task, so the two nest and peak memory scales with concurrency rather than with one gene. Measured, 1 500 cells / 150 genes / 600 landmarks (412 MiB per tensor), peak ANONYMOUS memory from /proc/self/smaps_rollup, same machine and input, 19ee1a1 vs this tree: run before after written wall no sample variance 949 MiB 972 MiB 0 -> 0 33.0 -> 32.6 s sample variance, in memory 2 401 MiB 1 973 MiB 0 -> 0 45.7 -> 46.0 s store_arrays_on_disk, with dask 2 782 MiB 1 153 MiB 0 -> 0 231.8 -> 47.8 s store_arrays_on_disk, no dask 2 284 MiB 1 148 MiB 824 MiB 48.2 -> 47.7 s Disk-backed storage is now cheaper than in-memory storage (1 153 vs 1 973), where before it was dearer (2 782 vs 2 401), and the Dask path is 4.9x faster. Results are unchanged: distances differ only by floating-point summation order, max relative difference 1.7e-12, the same order as the pre-existing difference BETWEEN the in-memory, Dask and memmap paths on 19ee1a1 alone. #25 — the dry run did not price the run it was pricing --------------------------------------------------------------------- de(dry_run=True) built its plan before null_genes="auto" was resolved, so the estimator received the literal string, matched neither its int nor its list branch, and counted zero null genes. Measured on 4 000 x 500: 0.485 GiB planned against 2.156 GiB for the run it described, 4.44x optimistic. Resolution moved above the dry-run branch, and the estimator now raises on a null_genes it cannot interpret instead of falling through to zero. Estimator accounting -------------------- The plan no longer charges a third tensor that is never built, charges disk only on the path that actually writes (nothing reaches disk_storage_dir when dask is installed), and charges the per-gene working set that replaced the dense sum. Verified against the measurement: on the no-dask path the plan's disk figure and the bytes observed under disk_storage_dir now agree exactly (824 MiB = 824 MiB); on the dask path the plan reports 0 and 0 bytes were observed. Tests ----- tests/test_sample_variance_cost_regressions.py, 11 cases. Every expected value is re-derived from the allocation (`2 * L**2 * n_genes * 8`, `3 * L**2 * 8`), not copied from a run. Verified to FAIL on 19ee1a1: the five that can run there fail, and the LazyGeneCovariance cases cannot even import. One existing test changed and it is the one predicted: test_resource_estimation.py::test_dry_run_with_disk_storage asserted a bare `len(disk_reqs) > 0`, which is false once disk is charged only on the path that writes. It is re-derived rather than loosened — see that commit. Version 0.8.0 -> 0.9.0 in kompot/version.py and pyproject.toml, with a changelog entry per issue. Fixes #25 Fixes #26
…surement The docs commit on this branch described 0.8.0's behaviour: 0.56 GiB per gene, and store_arrays_on_disk carrying a warning that it did not deliver the memory saving it advertised. The fix commit changed both facts, so this rewrites the prose from the new measurement rather than patching the old sentences — carrying them forward half-edited would leave a page that is pessimistic in one paragraph and confident in the next about the same flag. What changed in the guidance ---------------------------- The headline is now 2 * n_landmarks^2 * n_genes * 8 bytes, about 0.37 GiB per gene at the default 5 000 landmarks, and disk offload is presented as a genuine remedy with no hedge. More substantively, the ARGUMENT for the two-pass workflow has moved. Memory used to be the reason to restrict genes on pass 2; store_arrays_on_disk now answers that (1 000 genes plans at 7.7 GiB, the whole transcriptome at 101 GiB). So the prescription now rests on the per-gene Cholesky factorisation, which is the one cost no storage mode touches and only a shorter gene list reduces. Measured: 0.52 s per gene at 500 landmarks rising to 8.5 s at 2 000, against a shared-covariance column flat in the gene count. Multiplied out at 2 000 landmarks: ~2.4 h for 1 000 genes, ~47 h for 20 000. The prescription survives and is better founded; a whole-transcriptome sample-variance run is no longer impossible, merely interminable, which is the worse failure because it looks like progress. `genes` is now visibly the only lever that appears in BOTH cost columns. The memmap cost is documented where the path is chosen ------------------------------------------------------ Without dask the tensors are memory-mapped, and a gene slice of a C-contiguous (n_points, n_points, n_genes) map is strided, so per-gene reads touch the whole file where the old code read it once sequentially: about 12% slower on a measured 900-landmark pair, 74 s against 66 s, for 57% less memory. That sentence sits in the bullet describing the no-dask path, so a reader meets it before waiting on it rather than after. Also in this commit ------------------- tests/test_resource_estimation.py::test_dry_run_with_disk_storage re-derived. It asserted a bare `len(disk_reqs) > 0`, which passed for the wrong reason: the estimator charged disk unconditionally, including for the dask path that writes nothing. The replacement asserts an exact byte count taken from the allocation (2 * L^2 * n_genes * 8) on the no-dask path, zero on the dask path, and that the tensors are not charged to memory in either — strictly stronger than what it replaced, not loosened. One published snippet was wrong and is corrected: the with/without comparison left null_genes at "auto", which resolves to 2 000 on the no-sample-variance arm and 0 on the other, so it was silently comparing different gene counts. It now pins null_genes=0 on both. Caught by executing it, which is also how the whole snippet set is checked. Verification ------------ sphinx-build exits 0 with 24 warnings, an identical set to origin/main. Every published snippet executed against a synthetic AnnData. Rendered HTML scanned across every page for raw reStructuredText leakage: zero.
The #26 fix replaced a nested if/else over (variance_predictor1, variance_predictor2) x (gene-specific, shared) with a flat list of terms. The single-predictor arms are reachable through ModelSettings and the suite did not exercise them, so a restructuring bug there would have been invisible. Three cases: one predictor and two both give the dense arithmetic's answer bitwise (maxdiff 0.000e+00 on the probe these were lifted from), and a 2-D shared term handed to LazyGeneCovariance is refused rather than broadcast silently -- a shared sample variance is folded into the base by the caller, so its arrival as a term is a caller bug the view should be loud about.
The shared-covariance column is not monotone in n_landmarks (8.7 s at 1 000, 11.8 at 1 500, 10.8 at 2 000) because at these sizes it is dominated by JAX compilation rather than by the solve. Presenting it as a clean measurement would invite a reader to derive a scaling from it that is not there. It is labelled as a fixed cost independent of the gene count, which is the only property the argument actually uses, and the per-gene column -- the one that is linear in genes and carries the two-pass prescription -- is named as the one to take seriously.
Caught by re-reading the page against its own table. I had written that the per-gene Cholesky is 'roughly cubic in n_landmarks', which a Cholesky's O(n^3) flop count makes plausible and which the measurement does not support. 500 -> 2 000 landmarks is 4x and 0.52 -> 8.5 s per gene is 16x, i.e. an overall exponent of 2.0, not 3. Worse, the per-interval ratios are 11.7x, 1.3x, 1.1x (exponents 3.55, 0.62, 0.30), which is not a power law at all -- it looks like JAX warm-up at the first size and then a flat region, not a scaling. So no exponent is claimed. The four points are presented as four measured points in the range they cover, with an explicit instruction not to extrapolate a scaling from them and to time a single gene at your own landmark count if the number matters. The memory claim is untouched: quadratic there is exact arithmetic (2 * n_landmarks^2 * n_genes * 8), not a fit. Same correction in GPSettings.n_landmarks and in notebook 03's lever table.
pyflakes over the whole package, diffed against origin/main, reported exactly two findings that were mine: 'jax' and 'tqdm.auto.tqdm' imported but unused in differential/differential_expression.py. Both were used only by the eager per-gene assembly that LazyGeneCovariance replaced -- jax for the isinstance(..., jax.Array) checks that chose whether to convert combined_cov, tqdm for the three per-gene __setitem__ progress loops. Nothing else in the module references either. With them gone the package's pyflakes findings are an identical SET to origin/main's: no new ones, none removed. CI does not run flake8, so this is housekeeping rather than a gate -- but they are dead because of this branch and should not be left for someone else to wonder about.
w242sk returned `check`/second-pass on PR #29 with six findings. All six are accepted and remediated; every number below is my own re-measurement, not the skeptic's, taken with a positive control printed. F1 — the Cholesky table measured thread contention, not Kompot -------------------------------------------------------------- The published table (8.5 s/gene at n_landmarks=2000) and the 2.4 h / 4.7 h / 47 h projection built on it were taken with unrestricted BLAS threads on a shared node at load 40-96. They measured oversubscription. The table refuted itself and I could have seen it without measuring anything: it claimed 170.9 s for a 20-gene factorisation while a complete 20-gene sample-variance run at the same n_landmarks finishes in ~48 s. A part cannot exceed its whole. Re-measured inside a real kompot.de run, OMP_NUM_THREADS=1, with the covariance object printed as a positive control (`LazyGeneCovariance (2000, 2000, 20)`): L=500 0.0160 s/gene L=2000 0.2462 s/gene L=1000 0.0616 s/gene L=5000 2.0837 s/gene against 6.1353 s/gene unrestricted at L=2000 on the same host, same commit, same run — 25x. Bare np.linalg.cholesky at n=2000: 0.125 s at 21.3 GFLOPS pinned vs 5.20 s at 0.5 GFLOPS unrestricted. A throughput figure that COLLAPSES as threads are added is contention, not work. Corrected: 20 000 genes is ~1.4 h at L=2000 and ~11.6 h at L=5000. Two conclusions drawn from the wrong number are withdrawn rather than patched. "Memory stops being the binding constraint, compute takes over" and "merely interminable" are gone from every surface. The prescription now rests on the MEMORY argument, which is exact arithmetic, and names the regime: held in memory a whole transcriptome is 7 552 GiB and out of reach; offloaded it is 101 GiB and a long job. So ~1 000 genes is a budget justified on value — pass 1 ranks the genes anyway, 1 000 costs a twentieth on both axes, and FDR is not calibrated for sample variance so the rest buy no testable calls. A new warning tells users to pin OMP_NUM_THREADS on a shared node, which is the product finding hiding inside F1: the per-gene loop is one multi-threaded LAPACK call on a matrix small relative to the core count. F2 — the memory metric could not see what the memmap path moved --------------------------------------------------------------- Reporting only `Anonymous` and justifying it as keeping page cache from "flattering the reading" was backwards: choosing Anonymous is what EXCLUDES the 823 MiB the dask-less path moves into file-backed residency. Four instruments, 600 landmarks / 150 genes / 4 donors / 4 threads, MiB: no SV anon 958 rss 1218 file 260 disk 0 in-mem anon 1975 rss 2238 file 263 disk 0 dask anon 1180 rss 1442 file 262 disk 0 nodask anon 1139 rss 2224 file 1085 disk 824 As a share of the in-memory extra: dask 22% anon / 22% Rss; nodask 18% / 99%. So the dask path — the recommended one — wins on every instrument, and now leads the section. The dask-less path is described as converting unreclaimable anonymous pages into reclaimable page cache: a win for surviving a squeeze, not a smaller footprint, with the cgroup caveat stated plainly (Slurm --mem and container limits charge page cache to the same budget) and an explicit note that this is a mechanism, not an OOM verdict. F3 — the regression suite had no potency ----------------------------------------- All 14 cases passed against a mutant that rebuilt the dense sum in __init__ — #26's exact defect — because they asserted numerical equivalence and input validation, which an eager implementation satisfies. Base absence could not stand in: the pre-existing suite passed on 19ee1a1, which HAD the defect. Two guards added that assert the ALLOCATION property: - a recording proxy distinguishing `[:, :, g]` from any whole-array read; - a measured guard that walks a 62 MiB tensor and asserts anonymous growth stays within 12 gene-matrices. Both verified RED against the same mutation ("anonymous memory grew 67.4 MiB walking a 61.9 MiB tensor") while the other 14 stayed green on it, which is what makes this a demonstration rather than an assertion. utils.py restored from a byte-checked backup, md5 verified. F4, F5, F6 ---------- Five shipped sites still asserted the pre-fix #25 behaviour in the PR that fixes it — the de() docstring that ships in help(), the guide's warning, anndata.rst, simplified.rst and notebook 02 cell 24. Measured ratio is now 1.000 and they say so. examples/03_sample_variance.ipynb cells 10 and 12 shipped a pre-fix plan as stored output (18.63 GB of disk that is now 0, and a per-gene Dask path LazyGeneCovariance no longer takes). Cleared with a note that says what was cleared and why: a blank that says why beats a number that lies. Not re-executed — that means running the real analysis on shared infrastructure at a configuration this PR prices in hours, and publishing fresh outputs from a lab dataset into a public repo; both are the operator's call. Every RESULT cell is untouched and still correct, since the fix is bitwise identical. resource_estimation.py no longer promises that dask "parallelises the per-gene work" — LazyGeneCovariance deliberately bypasses the delayed branch — and now names the cgroup charge instead. The is_dask branch in compute_mahalanobis_distances is DOCUMENTED, not deleted: it is unreachable from Kompot's own call graph, but `compute_mahalanobis_distance` is in `kompot.__all__` and forwards a caller-supplied covariance, so an external caller can still reach it. Unreachable internally is not dead. The plan's sample-variance memory delta is 13.3x optimistic (16.7 MiB charged against 222 MiB measured). Documented as "the plan is a floor, not a total", with the reason it is useful anyway: what it gets exactly right is the part that explodes. Verification ------------ Suite 27 failed / 2014 passed. The one failure outside the established set, test_smooth_infrastructure.py::TestRunInfoSmooth::test_auto_detect_smooth, is a load artefact from a run at load 85 in which an xdist worker crashed and was replaced: it passes 3/3 serially at load 46, its whole file passes 27/27, and it passes on origin/main run the same way. It is in `smooth`, which this PR does not touch. Docs build 24 warnings, an identical set to origin/main. Rendered-HTML leak scan zero — it caught nested inline RST markup twice more in the new prose, the third and fourth instances this session, which is filed as #30 because a missing lint is not a habit problem.
… a range Three corrections from the skeptic's answer on the reconciliation channel. None is a dispute; all three are places where my remediation was imprecise. F6 REASON WAS WRONG, CONCLUSION WAS RIGHT. My comment justified keeping the is_dask branch by saying `compute_mahalanobis_distance` is in `kompot.__all__` and forwards a caller-supplied covariance. But the exported name is the SINGULAR, which passes a 2-D covariance at utils.py:602 and so cannot reach a 3-D branch; the PLURAL is not exported at all. Verified both. The real reason is concrete and checkable: tests/test_mahalanobis_approaches.py builds a bare 3-D dask array with DiskStorage.as_dask_array and passes it straight into compute_mahalanobis_distances, asserting it agrees with the gene-specific path. So the branch is TESTED but production-unreachable, which is not the same as dead. The comment now enumerates every in-repo caller and cites the covering test, so the next reader does not re-derive it. DASK MEMORY SHARE IS NOW A RANGE. I published 22%/22%; the skeptic measured 17%/17% on two independent rigs. Neither is wrong: Dask's threaded scheduler sizes its pool from the CPU count independently of OMP_NUM_THREADS, so that arm's peak is set by a concurrency neither of us pinned. Published as 17-22% with the mechanism stated, rather than two significant figures that would not reproduce. The dask-less row agreed to within a point across both rigs and is unchanged. PINNING ALSO BUYS REPRODUCIBILITY, which is worth more than the speed. Two independent measurements of the same configuration at 5 000 landmarks, single-threaded, at 1-minute loads of 45.9 and 62.8 -- a 37% difference -- came out 1.4% apart (2.084 vs 2.113 s/gene). Unpinned, the same step moved by 25x. That is now the second half of the threading warning.
…urrency I published 17-22% with a mechanism -- Dask's threaded scheduler sizing its pool from the CPU count independently of OMP_NUM_THREADS -- and the mechanism does not survive measurement. Pinning the pool at 1, at 4, and leaving it at the default 36 gave dask-arm peaks of 1173/1209, 1152/1163 and 1134/1138 MiB: all inside one spread, and the SINGLE-worker runs were if anything the highest. What actually explains the 17% vs 22% between two rigs is that the share is a ratio of two differences -- an extra of ~200 MiB between peaks of ~950 and ~1150 MiB -- so ordinary noise in either peak swings it hard. Across twelve runs here the no-sample-variance baseline spans 938-981 MiB and the dask peak spans 1134-1209 MiB, which brackets the share at 14-27% with nothing changing but the run. Two rigs landing on 17 and 22 is that spread, not a methodology difference, and neither figure was reproducible enough to print. So the table now gives one significant figure and states the spread, and says what the measurement does support, which is not close: about a fifth on Anonymous for both offloaded paths, against essentially all of it on Rss for the dask-less one. A factor of five on the same run is the finding; the second digit never was. Found while settling whether the concurrency could be pinned at all. It can, by the caller, through dask.config -- but kompot's own knob for it, SampleVarianceEstimator(dask_num_workers=N), sets the config key "pool.num-workers" where the threaded scheduler reads "num_workers", so it configures nothing and logs that it succeeded. Filed as #31; pre-existing and not fixed here.
18891dd to
85fcb5e
Compare
Second pass by w242sk at 85fcb5e. Five findings, all accepted. D5 -- MY POTENCY GUARD WAS A FALSE GREEN IN CI'S CONFIGURATION -------------------------------------------------------------- I had asked the skeptic not to take "both guards went red" on my word. It found that test_peak_memory_stays_bounded_by_one_gene passes against the eager-dense mutant whenever the whole file runs, and fails only standalone. Reproduced here before fixing: whole-file x3 against the mutant, only the recording-proxy guard failed; `-k peak_memory` alone, the memory guard failed. Cause was one line. The window opened AFTER the constructor: view = LazyGeneCovariance(terms, base=base) before = anon_bytes() # <- too late An eager implementation materialises in __init__ -- that is #26's actual shape -- so the 62 MiB allocation happened entirely outside the measured window, and what the guard measured was incidental churn whose size depends on allocator state left by sibling tests. Window now opens before the constructor. Verified RED 3/3 whole-file against the same mutant (67.4 MiB against a 6.19 MiB bound, the order-of-magnitude margin the comment claims), and GREEN 3/3 restored, utils.py md5-verified against a pre-mutation backup. I verified that guard went red. I verified it in the configuration where it works. The rule: run a potency check in the configuration the suite actually runs in, not the one that isolates the case. D1 -- THE CUBIC CLAIM I WITHDREW CAME BACK IN MY OWN REMEDIATION ---------------------------------------------------------------- 6685622 withdrew "roughly cubic in n_landmarks" as unsupported. 03b4730 then wrote "close to the cubic flop count a Cholesky implies -- 500 to 5 000 is 10x the landmarks for 130x the time", which refutes itself: cubic is 1000x, not 130x. Measured exponent is 2.11 over the four pinned points, consistent interval to interval (1.94, 2.00, 2.33); cubic predicts 16 s/gene at L=5000 against 2.08 s measured, a 7.7x overshoot. Now stated as quadratic-ish WITH the mechanism: the step also carries O(n^2) work (per-gene materialisation, the triangular solve) that dominates at small n, and single-threaded dpotrf throughput climbs 8.2 -> 37.7 GFLOPS across the range, a 4.6x improvement that cancels much of the extra work. Noted that the exponent should drift toward 3 at larger n. The original withdrawal was correct because the UNPINNED data was not a power law at all, so no exponent was claimable. Pinning the threads is what made a scaling statement possible -- and the statement made was the wrong one. A correction can restore the error it corrected, once better evidence makes the claim sayable again. D2 -- de() AND GPSettings CONTRADICTED EACH OTHER IN THE SAME PACKAGE --------------------------------------------------------------------- Four sites said n_landmarks does not touch the per-gene factorisation and only a shorter gene list reduces it: resource_planning.rst's top-of-page summary (543 lines above its own table showing 0.016 s at 500 against 2.08 s at 5 000), simplified.rst, README.md, and de()'s docstring -- which ships in help() -- while settings.py:50, written in the original PR, already said the correct thing. Swept for the class rather than patching the three that were named. Like D1 this is a consequence of the F1 fix rather than a survivor of it: the clean measurement made the landmark effect on compute visible for the first time, and the older prose was never revisited. D3 -- THE 25x WAS A LEVEL CONTRAST PRESENTED AS A SPREAD --------------------------------------------------------- The reproducibility paragraph read as "unpinned measurements vary by 25x". The 25x is pinned against unrestricted at one moment: it says how far the level moves, not how much an unpinned measurement scatters. The two unrestricted measurements taken here agreed to within 6-10%. What makes them unusable is not scatter but being wrong by more than an order of magnitude. Prescription and level claim were both right; only the inference was unsupported. D4 -- THE CHANGELOG WAS NEVER TOUCHED BY THE ENTIRE REMEDIATION ---------------------------------------------------------------- `git diff --name-only a489772 85fcb5e` did not include it. At the tip it still reported peak anonymous memory as the sole instrument -- zero occurrences of Rss, file-backed or page cache in the whole file -- still said "disk-backed storage is now cheaper than in-memory storage" unqualified across two offloaded rows presented identically, and still credited the dask-less path with "57% less memory", the Anonymous-only figure that is about 1% on Rss. Both instruments are now in the table, the "cheaper than in-memory" claim is scoped to the dask path where it holds on every instrument, the dask-less path is described as converting anonymous pages to reclaimable page cache rather than as a smaller footprint, and the cgroup caveat is stated. This file becomes the GitHub release note; the docs page had been corrected and the release narrative had not. VERIFICATION ------------ Suite 26 failed / 2014 passed, no crashed workers, failing set BYTE-IDENTICAL to the established pre-change set (cmp clean). Docs 24 warnings, identical set to origin/main. Rendered-HTML leak scan zero. Every published snippet executed.
Round three by w242sk at b30c601. Three items, all accepted. R1 -- THREE SITES MY SWEEP MISSED, AND THE INSTRUMENT THAT HID THEM -------------------------------------------------------------------- My D2 sweep reported clean. It was not. Three sites still carried retired claims: docs/source/cli.rst "about 0.37 GiB and several seconds per gene" (D1) docs/source/cli.rst "nothing bounds the per-gene factorisation except a shorter gene list" (D2) examples/03_sample_variance.ipynb cell 0 "Nothing makes this cheaper except analysing fewer genes." (D2) The last is verbatim the sentence the README fix replaced, sitting in the notebook's opening cost warning -- the first thing a reader sees. THE INSTRUMENT IS THE FINDING. Every one of those is LINE-WRAPPED, so a line-based grep cannot match it. cli.rst has "about 0.37 GiB and several" on line 255 and "seconds per gene" on line 256. My grep for the phrase returned zero against text that plainly contains it, and the zero ran in the flattering direction: it confirmed a remediation that was incomplete. The working form: git show "${ref}:${path}" | tr '\n' ' ' | tr -s ' ' | grep -iF 'phrase' Re-swept all 219 tracked text files with twelve retired phrases, including eight negative controls (only a shorter gene list reduces / neither touches the / binding constraint / interminable / 47 hours / 2.4 hours / 8.5 s / close to the cubic): ZERO hits. So the rest of the sweep was complete and these three were the whole remainder. AND THE TWO FILES FAILED FOR DIFFERENT REASONS. cli.rst was last touched by f7d7476, an original-PR commit; no remediation opened it, so the sweep never reached the file. But 03_sample_variance.ipynb was opened BY 03b4730 -- my own first remediation -- to clear its stale outputs for F5, and the retired claim in its opening cell was left in place. A file being open in the editor is not a file being swept. Also reconciled: cli.rst and kompot/cli/de.py disagreed about --sample-col, the --help text saying ~2 s per gene and its own documentation page saying several seconds. Both now read ~0.37 GiB and ~2 s per gene at --n-landmarks 5000. R2 -- THE CHANGELOG TABLE MIXED TWO RUNS ------------------------------------------ Adding an Rss column spliced values from the later four-BLAS-thread run into a table whose Anonymous column came from the original paired run, updating only the dask-less anon figure. Three of four anon values then disagreed with the docs page. Worse, the anon-to-Rss gap IS the file-backed quantity, so the dask row computed it across two runs: 1442 - 1153 = 289 MiB against the 262 MiB the docs state, with no file-backed column in the CHANGELOG for a reader to catch it with. Rss is dropped from the CHANGELOG entirely and the two-instrument treatment is cross-referenced to the docs page. A release note wants a self-consistent single-instrument before/after; unlike carrying the whole table, this shape cannot re-acquire the defect later. Rebuilding from the paired run also identified the spliced value: the dask-less 0.9.0 figure is 1170 MiB, from the same run as its 2284 baseline, not the 1139 taken from the four-thread run. Every figure in that table is now one paired run. R3 -- A THIRD UNMEASURED SCALING CLAIM, IN A DOCUMENT THAT HAS WITHDRAWN TWO ----------------------------------------------------------------------------- "Expect the exponent to drift up toward 3 at larger n as that headroom runs out" was not measured, and the measurement refutes it: 2.3, 2.8, 2.3 across 2000->5000->8000->11000, no drift, with single-threaded throughput still climbing at n=11 000 (37.8 -> 42.3 -> 53.1 GFLOPS). So "as that headroom runs out" has not begun to operate anywhere a user can reach. Scoped to the range measured, with the counter-evidence stated. VERIFICATION ------------ Suite 26 failed / 2014 passed, no crashed workers, failing set byte-identical to the established pre-change set (cmp clean). Docs 24 warnings, identical set to origin/main. Rendered-HTML leak scan zero. Wrap-insensitive sweep zero.
An independent reproduction of the defect this branch fixes, arrived at from a
stalled user run rather than from this branch, added as a regression guard.
It drives the real DifferentialExpression.compute_mahalanobis_distances and
asserts on the object that method hands downstream, so it exercises whatever
the shipped code builds rather than a particular helper. It counts TASKS, not
wall time, so it cannot flake on a loaded CI machine.
On origin/main it fails:
gene-specific covariance graph grew superlinearly: 7790 tasks at 20 genes
vs 314360 at 80 (40.4x for 4x the genes; linear is 4x, quadratic is 16x)
On this branch it passes unmodified, because a LazyGeneCovariance carries no
task graph at all: the guard finds nothing to measure and returns. The blowup
is not bounded here, it is unrepresentable.
The first version of this test asserted the REPRESENTATION rather than the
property and errored on this branch with
AttributeError: 'LazyGeneCovariance' object has no attribute '__dask_graph__'
which was the probe's assumption, not this branch's behaviour. _task_count now
returns None for an object with no graph and the guard treats that as a pass,
so the same test expresses the property across implementations that differ in
KIND rather than in degree.
The accompanying equivalence test checks the gene-specific path against an
independent per-gene reference on both the numpy and dask backends, and passes
on origin/main too - the old code computed the same numbers, only slowly.
|
One measurement that is an argument about this PR's test suite rather than Your 16 cost-regression tests survive the mutant that reintroduces the defectAgainst an immutable export of The mutant. Revert the wrap site in gene_specific_covariance = combined_variance # aliases its own input
for g in range(combined_variance.shape[2]):
gene_specific_covariance[:, :, g] = (
combined_variance[:, :, g] + combined_cov_to_add
)Result:
Controls, so the mutant is not trivially detectable and the guard is not What that means for the merge
Scope, stated so it is not oversold: the guard asserts graph scaling, not To reproduce: apply the mutant above to a checkout of this branch and run both |
# Conflicts: # CHANGELOG.md
|
@settylab-dotto-bot suggest only 200 instead of 1000 genes for the with sample var rerun. |
#33) Branch refs and packed-refs live in the common dir named by the worktree's commondir file; resolution looked only in the per-worktree git dir and returned None. Look up refs in both, follow symbolic refs a bounded number of steps, and log a warning when a sha cannot be resolved inside a checkout instead of stamping None silently. Fixes #28 Fixes #33
…23) anndata 0.11 and 0.12 refuse to write a pandas StringArray unless allow_write_nullable_strings is set, and pandas 3 produces one for every string column, so every CLI write failed. One write_output helper now opts in for the duration of the write only; a no-op where the setting is absent. Fixes #23
It set pool.num-workers, which no scheduler reads, mutated Dask's global config and logged a limit that was never applied. The covariance tensor is evaluated one gene at a time, so there is no pool to bound. Warn instead, and touch no configuration. Fixes #31
Nested inline markup renders as literal backticks and sphinx exits 0. A build-finished check scans every rendered page for unrendered literals, roles and directives and raises. It found ten live instances, all in notebook markdown where pandoc turns code-inside-bold or code-inside-link into nested RST; those are rewritten. Notebook 03 also carries the ~200-gene recommendation from the next commit. Fixes #30
Per the maintainer's review on #29. Every suggestion of a gene budget for the second pass now says ~200, and the figures stated alongside it are recomputed for 200: the timing column is the measured per-gene time x 200, and the n_landmarks table is re-run from dry_run=True on the same synthetic 20 000 x 20 000 input, whose 200- and 1 000-gene rows reproduce the published plan table exactly.
Under anndata 0.11/0.12 with pandas 3 even the obs index is a StringArray, so the fixtures' own write_h5ad failed before the CLI ran: that, not the CLI, is where the 49 failures reported on #23 were raised. Writing the inputs through the CLI's writer keeps the global setting untouched, so the CLI tests still exercise the CLI's own opt-in.
…not kompot's own Skeptic review of a308f94. #30: Markdown [`x`](url) becomes nested RST that renders as <code>`x</code> <url>`__; the inner literal is a tag, so tag-stripping hid it from the double-backtick pattern and the check passed while 14 such links were live (notebooks 01 x8, 03 x1, 04 x4, 05 x1). Add a pattern for the leaked anonymous-hyperlink tail and rewrite the 14 links. Measured on the base build with the final checker: 27 constructs in 5 pages, all fixed. #28: _find_git_dir accepts any enclosing .git, so a kompot tree vendored into an unrelated repository stamped that repository's HEAD -- a wrong sha, worse than None. Provenance now accepts a git dir only when its .git sits at the package's parent, kompot's own repository root. Also: drop a trailing space in the DE config template, and state what the nullable-string write test has and has not been run against.
…kout Its skip guard used the unguarded walk-up, so a kompot tree exported or vendored inside another work tree ran the test and, before the root guard, passed it by stamping the enclosing repository's sha: the defect the guard fixes, read as a pass. It now skips unless kompot has its own .git.
…ow a root A kompot/ package copied to the root of another repository still stamps that repository's sha: by position it cannot be told apart from kompot's own checkout. Say so in the CHANGELOG and the docstrings. Text only.
Fixes #25
Fixes #26
Fixes #27
Fixes #23
Fixes #28
Fixes #30
Fixes #31
Fixes #33
Cuts 0.9.0. Three issues that are really one story: sample variance costs
much more than it needs to, the tool you would use to find that out was lying,
and nothing in the documentation said any of it.
It now also carries every other open issue: see "Bundled into 0.9.0" directly below.
Bundled into 0.9.0: every other open issue (added 2026-09-25)
The maintainer asked for every remaining open issue to ship in 0.9.0, so this
PR now also fixes #23, #28, #30, #31 and #33, and carries one
review change to the sample-variance guidance.
main(#22, #32) is merged in;the only conflict was
CHANGELOG.md, and #22's[Unreleased]entries now situnder 0.9.0. Each fix has a test that fails without it, verified against a
git archiveof the pre-fix merge commit.Revised after an adversarial review of
a308f94(533ab0f,b1a85f2). Thereview found that the #30 check was blind to one leak shape and passed while 14
leaks were live, so the "clean" result below was the second false zero on
#30 in this PR. It also found that the #28 fix could stamp a wrong sha. Both
are fixed; details are in the #30 and #28 sections. Head after the revision:
eac37df.74e62c6,151f9b804c8574,533ab0fda7979c,533ab0fe6ddfc704c8574#23: the CLI could not write under anndata 0.11 and 0.12
anndata 0.11 and 0.12 refuse to write a pandas nullable-string array unless
anndata.settings.allow_write_nullable_stringsis set, and pandas 3 producesone for every string column, the obs index included. anndata 0.13 defaults
the setting to
None, which allows the write, and that is why CI (anndata0.13.4) never saw it.
The 49 failures on the issue were not raised by the CLI. They came from the
tests' own fixtures writing their input with a plain
write_h5ad, before anyKompot code ran. The CLI would have failed next, at its output write, so both
halves were real:
kompot.cli.utils.write_outputis now the one writer forde,da,smoothanddm. It opts in withanndata.settings.override(...)for theduration of the write only, and is a no-op where the setting does not exist.
Your global anndata settings are never changed.
write_outputtoo. The global setting staysoff, so the CLI tests still exercise the CLI's own opt-in rather than a
test-wide switch.
Coercing string columns to
objectwas the other option on the issue. I didnot take it: it rewrites the dtype of user data in the output file, and the
opt-in matches what anndata 0.13 now does by default.
anndata < 0.11 cannot write a
StringArrayat all, with or without an opt-in.Under pandas 2 those versions produce
objectcolumns, so #23 does not arisethere, and the new write tests skip on them with that reason.
#28 and #33: the provenance sha in a linked worktree
One root cause. A linked worktree's git dir holds
HEAD; its branch refs andpacked-refslive in the shared repository dir named by the worktree'scommondirfile._resolve_shalooked only in the per-worktree dir andreturned
None. It now looks there and then in the common dir, follows asymbolic ref a bounded number of steps, and stays stdlib-only, so no
gitbinary is needed.
A
Noneinside a checkout is now logged._resolvewarns once, atresolution time, when it finds a git dir but cannot resolve the sha. That
answers #28's "failing to resolve a sha should be visible" without making
provenance able to fail a run.
On #28's second point: the packed-refs fallback was already correct in an
ordinary clone, and
test_resolve_sha_from_packed_refscovered it. It waswrong only in a worktree, where it looked in the per-worktree dir. Both cases
are now tested against real git in every shape (clone, branch worktree,
detached worktree, each loose and packed), asserting equality with
git rev-parse HEAD. Against base, exactly the branch-worktree rows and thesynthetic
commondircases fail, while clone and detached pass: the flip setpredicted before running.
A guard added after review: a wrong sha is worse than none.
_find_git_dirwalks up to any enclosing
.git. So a Kompot tree vendored or copied into anunrelated repository, nested below that repository's root, whether in a plain
clone or a branch worktree, was stamped with that repository's HEAD. Before this PR the worktree variant returned
None; the worktree fix would have turned that into a confident wrong answer.Provenance now accepts a git dir only when its
.gitentry sits at the package'sparent, which is where it sits in Kompot's own checkout. Residual: a
kompot/package copied to the root of another repository still stamps thatrepository's sha, because by position it cannot be told apart from Kompot's own
checkout. Two real-git tests plant the nested case, asserting that the walk-up alone would resolve the unrelated repo
and that provenance returns
None. The reviewer's independent 14-scenarioreal-git matrix gives the same result through the guard as without it.
The guard caught this PR's own test harness. The suite comparisons run each
commit from a
git archiveexported inside the clone, which makes it exactlya Kompot tree inside another work tree. Under the guard,
test_source_checkout_resolves_sha_and_editablefailed there, because its skipprecondition used the unguarded walk-up. In the earlier rounds it had passed
there by stamping the enclosing clone's sha: the defect itself, read as a pass.
The test now skips unless the package's parent holds a
.git(eac37df). In CI's plaincheckout it runs and passes as before.
This also retires the one branch-only failure discussed in "Tests" below,
test_source_checkout_resolves_sha_and_editablein a linked worktree.#30: the docs build now fails on markup that survives rendering
docs/source/markup_leak_check.pyruns at Sphinx'sbuild-finishedand raisesif any rendered page contains an unrendered literal, role or directive. It
needs no rule per RST construct, and it runs on Read the Docs as well as
locally.
tests/test_docs_markup_leak_check.pycovers the three shapes quotedon #30, the code-block-comment role, and a real
sphinx-buildthat must failon nested markup and pass on clean markup. That last test was red with the hook
neutered.
This section's first version was the second false zero on #30, and the
counts below replace it. The earlier "zero" in "Verification" missed
notebooks/because its scan was depth-1. The check as first pushed here thenmissed a whole shape. Markdown
[`x`](url)becomes nested RST that rendersas
<code>x__: the inner literal is a tag, so stripping tagsbefore matching removed the evidence, and the check passed with 14 such
links live. One of them (
LazyGeneCovariance, notebook 03) was added by thisPR. A fourth pattern now matches the leaked hyperlink tail, and a test plants
that exact rendering.
Measured with the final check on a build of the pre-fix base (
ec4ce74):25 leaked constructs in 5 pages. 9 are literals nested in bold, 2 are
links whose text contained a literal, and 14 are links whose whole text was a
literal. (The check reports 27, because those 2 links trip two patterns each.)
The first round fixed 11 of them and this revision fixes the other 14. The
build at the new head is clean with the hook active. By
git grepagainst19ee1a1, 5 of the 25 came from this PR's own notebook edits and 20 predateit.
What the check covers now: an unrendered double-backtick literal, a role, a
directive, and an anonymous-hyperlink tail, on every page at any depth. What
it does not: markup whose leaked form carries no marker, such as a single
*of italic nested in bold, because a bare asterisk is too common in real text.
Boundary: it catches markup that should be markup. A single
*of italicnested in bold is not caught, because a bare asterisk is too common in real
text to flag.
#31:
dask_num_workersis deprecated, not plumbed throughI checked what the knob could govern before choosing. The Dask covariance
tensor is returned lazily, and since #26 the Mahalanobis step materialises it
one gene at a time, so there is no pool of workers for any setting to
bound. Plumbing it into
de()would have added a second knob that doesnothing. Instead, passing it now emits a
FutureWarningthat says so, andthe global
dask.config.setand the "Configured Dask to use N workers" logline are gone. A test asserts Dask's global config is byte-identical after a
disk-backed
predict.Review change: ~200 genes, not ~1 000, for the sample-variance pass
Per @katosh's review. Every place that suggests a gene budget for the second
pass now says ~200: the guide,
cli.rst,simplified.rst, the README, thekompot.dedocstring example, the DE config template and notebook 03. Everyfigure quoted beside a budget is priced for 200, not left at 1 000:
(3 s, 12 s, 49 s, 7 min);
n_landmarkstable is re-run fromdry_run=Trueat 200 genes:5.4 / 14.6 / 29.7 / 78.3 GiB in memory, and 2.5 / 2.6 / 2.9 / 3.8 GiB
offloaded.
The re-run uses the same synthetic 20 000 × 20 000 input as the published plan
table, and as a positive control it reproduces that table's 200-gene row
(3.1 / 78.3 / 3.8) and 1 000-gene row (6.4 / 380.2 / 7.7) exactly. The
measurements elsewhere in this body that mention 1 000 genes describe a
specific stalled run and are left as measured. The measured case for 200 is
already in this body: at 200 genes with
store_arrays_on_disk=Truethis branchruns in 57.2 s at 1.80 GiB, where
origin/maintook 697.9 s.Suite, set against set
Full suite re-run after the review fixes, at
eac37df, the pushed head. It iscompared against base
ec4ce74(this branch before any of the new fixes,
mainmerged in). Each arm is a cleangit archiveof its commit, run on one Slurm node with BLAS pinned to 2threads, in venvs pinned with
uv --exclude-newer 2026-09-25. The comparison isby test id, not by count:
The one row per arm is
test_source_checkout_resolves_sha_and_editable, whichnow skips in these exported trees. That is the vendored-tree guard at work:
see "The guard caught this PR's own test harness" in #28 above. In a real
checkout it runs and passes.
31 test ids are new; none disappeared. On anndata 0.12.6, 47 of the 49 base
failures now pass. The other 2 now skip, and a failure turning into a skip
is the direction worth checking, so I checked it. Both tests call
pytest.skip("Requires palantir mocking")unconditionally, after their fixturewrite. On base they died at that write before reaching the skip, and they skip
identically on base under anndata 0.13. The extra skips on anndata 0.10.9 are
the new
write_outputtests, which need the setting that version lacks.CI runs a single anndata (0.13.4, whatever
pipresolves), so the 0.12 armabove is the only place #23 is exercised at all. Pinning an anndata 0.12 CI
leg would keep it that way; I have not added one, because changing the CI
matrix is the maintainer's call.
The story
Supplying
sample_coltokompot.de()replaces the single shared posteriorcovariance with one
(n_landmarks, n_landmarks)matrix per gene, percondition. Kompot summed those two tensors into a third dense array
before use, then added the shared covariance into it gene by gene with
__setitem__. So:store_arrays_on_disk=Truedid not keep anything out of memory — withnp.memmapinputs the sum materialised the whole tensor in RAM, which isexactly the array the flag exists to avoid (store_arrays_on_disk=True does not reduce peak memory, and with dask nothing is written to disk #26);
compute_mahalanobis_distancesthen rancov = jnp.array(covariance)in itsgene-specific branch and never read
cov— a second full-size copy, fornothing (store_arrays_on_disk=True does not reduce peak memory, and with dask nothing is written to disk #26);
zero null genes while the run it described would add 2 000 (dry run does not resolve null_genes="auto", under-estimating the plan by 2 000 genes #25);
cli.rstrecommended the single most expensive configuration the package canexpress (docs: the cost of sample variance is not stated where users meet the decision #27).
What changed
kompot.utils.LazyGeneCovarianceholds references to the per-conditiontensors and materialises one
(n_landmarks, n_landmarks)matrix when theMahalanobis step asks for a gene. The dense sum and the
__setitem__loop aregone, and so is the dead
jnp.array(covariance).A
LazyGeneCovariancedeliberately does not take the Dask delayed branch.That branch submits every gene as a task at once, and a lazy view materialises
its own slices inside each task, so the two nest and peak memory scales with
concurrency rather than with one gene. This was not in the original diagnosis —
the first version of the fix left the Dask arm at 1 886 MiB where a run with no
sample variance sat at 972, and only re-measuring found it.
null_genes="auto"is resolved above the dry-run branch, and the estimatornow raises on a
null_genesit cannot interpret rather than falling through tozero — a
strreaching that comparison is the failure signature.The plan describes the code that runs: no third tensor, disk charged only on
the path that writes, and the per-gene working set charged explicitly.
Measured
Same script, same machine, same synthetic input,
19ee1a1against this branch.Peak anonymous memory from
/proc/self/smaps_rollup, so page cache for amemory-mapped file cannot flatter the reading. 1 500 cells, 4 donors.
150 genes, 600 landmarks (412 MiB per tensor):
store_arrays_on_disk, withdaskstore_arrays_on_disk, nodaskTwo instruments, because one is not enough.
Anonymouscounts private heappages;
Rsscounts those plus resident file-backed pages, which is exactlywhere a memory map puts its data. As a share of the extra memory the in-memory
run costs: the
daskpath is 22% on both, and thedask-less path is 18%on anon but 99% on Rss.
So the claim to rely on is the
daskpath, which wins on every instrument andis the recommended one. The
dask-less path converts unreclaimable anonymouspages into reclaimable page cache — a real win for surviving a memory squeeze,
not a smaller resident footprint, and under a cgroup (Slurm
--mem, acontainer) page cache is charged to the same budget anyway. An earlier revision
of this PR reported only
Anonymousand justified it as stopping page cache"flattering the reading", which was backwards: choosing
Anonymousis whatexcludes those 823 MiB.
120 genes, 900 landmarks (742 MiB per tensor): 3 502 → 2 809 in memory,
4 341 → 1 325 with
dask, 3 079 → 1 341 without.Disk-backed storage is now cheaper than in-memory storage (1 153 against
1 973), where before it was dearer (2 782 against 2 401). In-memory cost
per gene drops from
3 × n_landmarks² × 8to2 × n_landmarks² × 8bytes —0.37 GiB rather than 0.56 GiB at the default 5 000 landmarks.
#25, measured: on a 4 000 × 500 input the default dry run reported 0.485 GiB
for a run the explicit
null_genes=2000plan priced at 2.156 GiB — 4.44×optimistic. Now identical.
Results are unchanged. Distances differ only by floating-point summation
order: max relative difference 1.7e-12 across the in-memory, Dask and
memmap paths, which is the same order as the pre-existing difference between
those paths on
19ee1a1alone (1.7e-13 absolute on values ~0.5).One cost moved the other way
Without
daskthe tensors are memory-mapped, and a gene slice of aC-contiguous
(n_points, n_points, n_genes)map is strided, so reading onegene at a time touches the whole file where the old code did one sequential
read into RAM. On a clean 900-landmark pair: 74 s against 66 s, about 12%
slower for 57% less memory, on the path that is not the recommended one.
Installing
daskavoids it in both directions — that path is 4.9× fasterthan 0.8.0 as well as lighter.
A first scale-2 run showed 646 s for that arm, but it was running under the
pytest suite at load 60+; the clean re-measurement is the 74 s above. Every
wall-clock figure here comes from a shared machine under load and should be
read as an order of magnitude. The memory figures are high-water marks of
allocation and are far less sensitive to that.
Read this first: what a skeptic pass changed after the initial review
w242skreturnedcheck/second-pass with six findings. All six are acceptedand remediated in
03b4730; every figure below is a re-measurement of minewith a positive control, not a copy of the skeptic's.
The largest finding invalidated a claim I had already rewritten this PR
around. An earlier revision said memory stops being the binding constraint
and compute takes over, on the strength of a timing table showing 8.5 s per
gene at
n_landmarks=2000and 47 hours for a transcriptome. That tablemeasured BLAS thread oversubscription on a loaded shared node, not Kompot.
It refuted itself, and I could have seen it without measuring anything: it
claimed 170.9 s for a 20-gene factorisation while a complete 20-gene
sample-variance run at the same
n_landmarksfinishes in ~48 s. A partcannot exceed its whole.
Re-measured inside a real
kompot.derun withOMP_NUM_THREADS=1:n_landmarksnp.linalg.choleskyat n=2000 says the same thing from the other side:21.3 GFLOPS pinned against 0.5 GFLOPS unrestricted. A throughput figure
that collapses as threads are added is contention, not work — that was the
tell, and it generalises well past this PR.
So the corrected picture, and the prescription rebuilt on it. 20 000 genes
is ~1.4 h at 2 000 landmarks and ~11.6 h at 5 000, not 47 h. Which means the
two-pass workflow rests on the memory argument, which is exact arithmetic
rather than a timing:
reach, and this is the hard limit;
So ~200 genes is a budget rather than a barrier once you offload, justified
on value: pass 1 ranks the genes anyway, 200 costs about a hundredth on both
axes, and FDR is not calibrated for sample variance, so the other 19 800 buy no
testable calls. (This said ~1 000 until the maintainer's review; see
"Review change" above.) The docs say exactly that, and no longer imply a wall that is
not there.
There is a product finding inside this one, now a warning in the guide: the
per-gene loop is a single multi-threaded LAPACK call on a matrix that is small
relative to the core count, so on a busy shared node — which is the machine
most people run this on — it is ~25x slower than it needs to be. Pin
OMP_NUM_THREADS=1.Tests
The first version of these tests had no potency, and the skeptic proved it
All 14 cases passed against a mutant that rebuilt the dense
(n_points, n_points, n_genes)sum in__init__— #26's exact defect. Theyasserted numerical equivalence and input validation, and an eagerly-materialising
implementation satisfies both, so they were never about allocation at all. Base
absence could not stand in either: the pre-existing suite passed on
19ee1a1,which had that behaviour.
Two guards were added that assert the allocation property itself:
[:, :, g]from any whole-array read(
np.asarray,+, a slice spanning genes) — an eager build cannot avoid thesecond kind;
grows by roughly one gene's matrix rather than by the tensor.
Both were verified red against the same mutation, the measured one reporting
"anonymous memory grew 67.4 MiB walking a 61.9 MiB tensor", while the other 14
stayed green on it. That last part is what makes this a demonstration rather
than an assertion: the new guards bite and the old ones still do not.
…and one of them was a false green in CI's configuration, which the skeptic caught
I asked the skeptic not to take the paragraph above on my word. It found that
test_peak_memory_stays_bounded_by_one_genepassed against the mutant wheneverthe whole file ran, and failed only standalone — so the configuration I
verified in was not the configuration CI uses.
The cause was one line: the measurement window opened after the constructor.
An eager implementation materialises in
__init__— #26's actual shape — sothe 62 MiB allocation happened entirely outside the window, and what the guard
measured was incidental churn whose size depended on allocator state left by
sibling tests.
Reproduced before fixing (whole-file: only the proxy guard failed;
-k peak_memoryalone: the memory guard failed), then the window moved above theconstructor. Now red 3/3 whole-file against the same mutant and green 3/3
restored, with
kompot/utils.pymd5-verified against a pre-mutation backup.The transferable rule: run a potency check in the configuration the suite
actually runs in, not the one that isolates the case. Isolation is what makes
such a check readable, and it is what made this one wrong.
kompot/utils.pywas restored from a byte-checked backup afterwards, md5verified.
The rest
tests/test_sample_variance_cost_regressions.py, 16 cases. Every expectedvalue is derived from the allocation (
2 * L**2 * n_genes * 8,3 * L**2 * 8), not copied from a run. Verified to fail on19ee1a1: the fivethat can run there fail, and the
LazyGeneCovariancecases cannot even import.One existing test changed, and it is the one predicted. The set of tests
expected to change was written down before the estimator was touched:
test_resource_estimation.py::test_dry_run_with_disk_storage, because itasserted a bare
len(disk_reqs) > 0and disk is now charged only on the paththat writes. The failure-set diff after the change is exactly that test —
nothing else new, nothing gone. The prediction held in both directions.
It is re-derived, not loosened, and is strictly stronger than what it
replaced: the old assertion was an existence check that passed for the wrong
reason. The new one asserts an exact byte count from the allocation on the
no-
daskpath, zero on thedaskpath, and that the tensors are not chargedto memory in either. No assertion in this PR was widened — no
==became a>=, no exact figure became a range, no tolerance grew.Suite result is reported as a set difference, not a count — a matching
total is not evidence in either direction.
Against the same tree before any of these changes, the failing set is
byte-identical: 26 failures in, 26 out, nothing added and nothing removed.
The one test the change did break,
test_dry_run_with_disk_storage, is fixedby the re-derivation above, so the branch ends with the failing set it started
with and 11 more passing tests.
Against a clean
origin/mainworktree with a package-for-package matchedvenv (diffed with
importlib.metadata, identical apart frompytest-cov):That one branch-only failure is not a regression, and it is measured rather
than argued. It is #28:
_provenance.pycannot resolve a sha whenHEADisa branch ref inside a linked worktree. The
origin/mainarm escaped it onlybecause
git worktree add --detachleavesHEADholding the sha literally. Iattached that same
origin/mainworktree to a branch and re-ran the singletest against
origin/main's own code:Detached again, it passes again. So the failure tracks the shape of the
checkout, not this branch. (The worktree was restored to detached
19ee1a1and the temporary branch deleted.)
This is the only "only on branch" row, so it is the one a reviewer will stop
on, and it is worth saying what it turned into. It could have been explained
away with the #28 mechanism, which was already in hand — but an explanation
that flatters the branch is the one to distrust, so it was reproduced instead.
Doing that also confirms #28 from a direction #28 does not itself claim: the
issue reports that a sha fails to resolve in a linked worktree, and this shows
the resulting test failure appearing and disappearing purely as
HEADisattached to and detached from a branch, on unmodified
origin/maincode.The common failures are local-environment artefacts, not CI ones: 24 are
plotting tests that need
scanpy, which neither local venv has, while.github/workflows/tests.ymlinstalls.[all]and therefore does have it. Oneis the provenance test — see #28, and it fails here because these are linked
worktrees, which CI's plain checkout is not. One is a CLI diffusion-map test.
None are in code this branch touches, and I expect CI to be green where local
is not; if it is not, that is worth reading rather than assuming.
On reading that comparison: both arms ran back to back on the same shared
machine, with the 1-minute load average sampled throughout and ranging from
42 to 96 across the pair, which is high. Load
manufactures false reds, never false greens, so the greens are trustworthy
and any red outside the established set would be a statement about the machine
rather than about the branch. Both arms met comparable load by construction —
they ran sequentially, not concurrently — which matters because a spuriously
red test on the
origin/mainarm would read as "fixed by the branch", theflattering direction and therefore the one to guard against.
Documentation
New
docs/source/resource_planning.rst, the canonical explanation, linked fromindex.rst,simplified.rst,anndata.rst,cli.rst,README.mdand themarkdown of notebooks 02 and 03 rather than duplicated into them. It was
written from the post-fix measurement, not patched from a pre-fix draft.
The framing changed substantively as a result. Memory is no longer the reason
to restrict genes — disk offload answers that — so the two-pass workflow now
rests on the per-gene Cholesky, which is the one cost no storage mode touches.
Measured: 0.52 s per gene at 500 landmarks rising to 8.5 s at 2 000, against a
shared-covariance column that is flat in the gene count.
Also corrected:
kompot.de's docstring,GPSettings.n_landmarks,GPSettings.batch_size,StorageSettings,SampleVarianceEstimator,kompot de --sample-colandthe DE config template, so
help()and--helpcarry the warning too.cli.rst's "Example: Complete Analysis" paired--sample-col Samplewith--n-landmarks 5000over every gene — copy-pasteable, and presented as therecommended complete form. Replaced with the restricted second pass through a
config file, verified working end to end.
StorageSettings.max_memory_ratiowas documented as "Fraction of RAMbefore triggering disk storage". It triggers nothing:
store_arrays_on_diskdefaults to
None, which resolves todisk_storage_dir is not None, andSampleVarianceEstimator.predict()hardcodes0.8for its own check, so auser's value never reached it. The config template's
store_arrays_on_disk: null # (null = auto)implied the same non-existentautomatic decision.
A finding worth more than its fix: the comparison snippet printed the inverse of the claim
The guide's with/without comparison — the snippet a reader is most likely to
copy, because it is the one that answers "what does sample variance cost me?" —
left
null_genesat its"auto"default on both arms. After the #25 fix,"auto"resolves to 2 000 withoutsample_coland to 0 with it. So thetwo arms were silently priced over different gene counts, and the snippet
printed:
Sample variance looking half the price of no sample variance — the exact
inverse of the claim this entire PR exists to make, in the most copyable
artefact in the document. With
null_genes=0pinned on both arms it prints5.8x.Nothing in review would have caught this. The code was correct, the API was
used correctly, and the defect was entirely in a configuration that is
asymmetric by design and invisible at the call site. It was caught because
every published snippet is executed rather than read, which is the practice
that earned it and the reason to keep doing it.
It is also the second time on this branch that the
"auto"sentinel produced aconfidently wrong number in the safe-looking direction; #25 is the first.
A claim withdrawn against my own measurement
I had written that the per-gene Cholesky is "roughly cubic in
n_landmarks".A Cholesky is O(n³) in flops, so it reads as obviously right, and it was in the
guide, in
GPSettings.n_landmarksand in notebook 03's lever table before Ichecked it against the table sitting directly above it.
500 → 2 000 landmarks is 4×, and 0.52 → 8.5 s per gene is 16×: an overall
exponent of 2.0, not 3. Worse, the per-interval ratios are 11.7×, 1.3×,
1.1× — exponents 3.55, 0.62, 0.30 — which is not a power law at all. It looks
like JAX warm-up at the first size followed by a flat region.
No exponent is claimed anywhere now. The four points are presented as four
measured points in the range they cover, with an explicit instruction not to
extrapolate and to time a single gene at your own landmark count if the number
matters.
The memory claim is untouched, and the reason is the part worth keeping.
2 × n_landmarks² × n_genes × 8is derived from an allocation — it is theshape of an array the source asks for, and it is exact. The cubic claim was
fitted to four timings. Those are different epistemic objects and should
never be stated in the same voice, however similar they look once they are both
sitting in a sentence as "quadratic in
n_landmarks" and "cubic inn_landmarks". One can be read off the code; the other needed evidence it didnot have. A plausible mechanism — a Cholesky is O(n³) in flops — is the
hardest kind of wrong claim to see, precisely because review confirms the
mechanism rather than the measurement.
A withdrawn claim that came back in its own remediation
6685622withdrew "roughly cubic inn_landmarks" as unsupported. Theremediation commit then reintroduced it — "close to the cubic flop count a
Cholesky implies — 500 to 5 000 is 10x the landmarks for 130x the time" — a
sentence that refutes itself, since cubic is 1000x and not 130x.
The measured exponent over the four pinned points is 2.11, consistent
interval to interval (1.94, 2.00, 2.33). Cubic predicts 16 s per gene at 5 000
landmarks against 2.08 s measured, a 7.7x overshoot. It now says quadratic-ish
with the mechanism: the step carries O(n²) work that dominates at small n,
and single-threaded
dpotrfthroughput climbs 8.2 → 37.7 GFLOPS across therange, cancelling much of the third power — with the note that the exponent
should drift toward 3 at larger n.
The irony is the content. The original withdrawal was right because the
unpinned data was not a power law at all, so no exponent was claimable. Pinning
the threads is what made a scaling statement possible for the first time — and
the statement made was the wrong one. A correction can restore the error it
corrected, once better evidence makes the claim sayable again.
Two further findings were consequences of the same fix rather than survivors of
it, and both are swept rather than patched: four sites (including
de()'sdocstring, which ships in
help()) claimedn_landmarksdoes not affectper-gene compute, contradicting
GPSettings.n_landmarksin the samepackage; and the CHANGELOG — the file that becomes the release note — had
never been touched by the entire remediation, still carrying
Anonymousas itssole instrument and an unqualified "cheaper than in-memory".
A sweep that reported clean, and the instrument that made it lie
Three skeptic rounds; the third found that my post-fix sweep had missed three
sites still carrying retired claims — including, in
examples/03_sample_variance.ipynbcell 0, verbatim the sentence the READMEfix replaced, in the notebook's opening cost warning.
The instrument is the finding. Every one of those sites is line-wrapped,
so a line-based
grepcannot match it:My search for
'several seconds per gene'returned zero against text thatplainly contains it, and the zero ran in the flattering direction — it
confirmed a remediation that was incomplete. The working form:
Re-swept across all 219 tracked text files with twelve retired phrases,
including eight negative controls: zero hits. So the rest of the sweep was
complete and those three were the whole remainder.
The two files failed for different reasons, and the second is the one to
learn.
docs/source/cli.rstwas never opened by any remediation commit, sothe sweep never reached it. But
examples/03_sample_variance.ipynbwas openedby the first remediation commit, to clear its stale outputs — and the
retired claim in its opening cell was left in place. A file being open in the
editor is not a file being swept.
Two related repairs came with it:
cli.rstandkompot/cli/de.pyhaddisagreed about
--sample-col(the--helptext saying ~2 s per gene, its owndocumentation page saying several seconds), now reconciled; and the CHANGELOG's
memory table had acquired an
Rsscolumn spliced from a different run thanits
Anonymouscolumn, so a reader could compute file-backed residency acrosstwo runs and get 289 MiB against the 262 MiB the docs state.
Rssis droppedfrom the release note entirely and cross-referenced to the docs page — a
self-consistent single-instrument before/after is the right shape there, and it
cannot re-acquire the defect.
Verification
sphinx-build -b htmlexits 0 with 24 warnings, an identical set toorigin/main— no new ones. They are 22 pre-existing duplicate-objectwarnings from
anndata.rstoverlappingsimplified.rst, oneanndata.rst isn't included in any toctree, and onenbsphinxnote aboutipywidgets. (anndata.rstis orphaned onmaintoo; out of scope here.)leakage — unrendered literals, directives, unresolved roles: zero. Two
real leaks were caught this way and fixed (nested inline markup in the
de()docstring, a
:ref:inside a code-block comment).Correction (2026-09-25): that zero was wrong, twice. The scan globbed
{build}/*.htmland never descended intonotebooks/. Then the firstversion of the replacement check could not see code-in-link leaks. The
measured total is 25 constructs, and all are fixed; see docs build does not catch nested inline RST markup (renders as literal backticks, sphinx exits 0) #30 above.
synthetic AnnData — see the finding below for why that is not ceremony.
(
maxdiff 0.000e+00) to the dense arithmetic they replace: both predictors,variance_predictor1alone,variance_predictor2alone, and a 2-D sharedterm, which the view refuses rather than broadcasting. Kept as tests.
use_empirical_variance=Truecombined withsample_colverified separately,since the empirical diagonal is added to the per-gene matrix after the view
assembles it: identical to the dense path (
maxdiff 0.0), demonstrablychanging the result rather than being silently dropped, and finite end to end.
adds
..._mahalanobis_sample_var(NaN outside its gene subset), leaves pass1's
..._mahalanobisand..._mean_lfcintact, rewrites the shared layersonly for the genes it analysed, and adds two
_stdlayers.kompot/version.py,pyproject.tomland the changelogheading, and the installed import agrees.
origin/mainas a set of(file, finding) pairs so line-number drift does not mask anything: identical,
none added and none removed. It caught two dead imports this branch created
(
jaxandtqdmindifferential/differential_expression.py, orphaned whenthe eager assembly went), which are now gone. CI does not run flake8, so this
was housekeeping rather than a gate.
Deliberately not in this PR
CLI write path fails on anndata >= 0.11: allow_write_nullable_strings is False #23 (CLI write path onanndata >= 0.11) is older and unrelated to thisstory. Not folded in, so the omission is visible rather than accidental.
Now bundled at the maintainer's request (2026-09-25); see CLI write path fails on anndata >= 0.11: allow_write_nullable_strings is False #23 above.
kompot/_provenance.pycannot resolve a sha in a linked worktree checkedout on a branch. It reads
<gitdir>/refs/heads/<branch>, but a linkedworktree's branch refs live in the main gitdir, so it returns
Noneandstamps runs with a null sha. Pre-existing — confirmed by calling
origin/main's own_resolve_shaagainst this worktree's gitdir — and it isthe single head-only failure in the A/B, a property of the tree layout rather
than of this branch. Filed as provenance: git sha silently resolves to None in a linked worktree (and packed-refs is a second silent None) #28, with the packed-refs variant that a worktree-only fix would leave live.
Now fixed here, together with _provenance: sha does not resolve in a git worktree with a branch checked out #33; see "provenance: git sha silently resolves to None in a linked worktree (and packed-refs is a second silent None) #28 and _provenance: sha does not resolve in a git worktree with a branch checked out #33" above.
so a gene slice is contiguous. That changes the on-disk layout and is a
bigger change than a minor version should carry; the recommended
daskpathdoes not have the problem.
An independent reproduction, and one number this PR does not yet carry
Added from a separate investigation that started from a stalled user run rather
than from this branch, and arrived at the same defect by a different route. The
run supplied
sample_colover a restricted list of 1 000 genes at 1 000landmarks with
store_arrays_on_disk=True— the two-pass workflow this PRdocuments, already followed — and did not finish, on a dataset whose pooled
first pass had scored every gene in 93 s.
What the graph actually does, as a closed form rather than an exponent
The
__setitem__loop this PR removes has an exact cost, and it is worth statingas a count rather than as a time. Instrumenting the loop directly and reading
len(arr.__dask_graph__())after each iteration, at 200 points and 120 genes:Least squares over all eleven points gives
An exact quadratic, not a fitted exponent — which matters here given the
withdrawal recorded above. A naive power-law fit over the same points returns
g^1.27with a 61% residual, and the per-interval exponents climb 0.50 → 1.86,because the linear term dominates until g is large. Reading the exponent off
successive intervals understates this one; the closed form does not. The
quadratic term is the
__setitem__chain, the linear term is the stack itself.Through the real
DifferentialExpression.compute_mahalanobis_distances,intercepting the object handed downstream: 7 790 tasks at 20 genes against
314 360 at 80 — 40.4× for 4× the genes, where linear is 4× and quadratic 16×.
Different constants from the isolated series because the shape differs; the
point is that it is not a constant factor.
End to end through
kompot.de()on19ee1a1, synthetic data at the shapesthat run had (~9 500 cells, 6 samples per condition, 1 000 landmarks), one node,
one job,
random_statepinned:n_genesstore_arrays_on_disk=True=FalseNo exponent is claimed from that table. The ratio per doubling grows rather than
holding, so it is not a power law, and these are four measured points in the
range they cover. About 45 s of each row is fixed cost.
What this branch does to those numbers
Same job, same node, same synthetic data,
store_arrays_on_disk=Truethroughout —the configuration that stalled:
n_genesorigin/mainorigin/main12.2x at 200 genes, and the memory column is the part worth reading: this
branch's peak moves 10% across an 8x increase in genes (1.64 → 1.80 GiB)
where
origin/main's moves 140% (2.25 → 5.39 GiB). That is the lazy assemblydoing what it says — peak is one gene's matrix rather than the tensor — and it
is visible from the second row onward. Every checksum matches across both
columns, so this is the same arithmetic at a different cost.
On this branch,
store_arrays_on_disk=Trueat 100 genes (50.6 s, 1.73 GiB) isnow faster and lighter than
origin/mainwith the flag off (53.4 s, 3.84GiB), which is the first configuration in which the flag is worth setting.
At 1 000 genes — the scale the stalled run was actually asking for — the trade
is visible rather than free, and it is worth stating in the same breath as the
12.2x above:
origin/main, flag onorigin/main, flag offThis branch improves both paths: flag off is 28% faster and 30% lighter than
origin/main's, and flag on goes from unrunnable to usable. Within the branchthe flag costs 2.06x the wall clock for 5.2x less memory at this scale — a
real trade rather than a free win, and the direction a disk mode should trade
in. Below ~200 genes the flag is faster and lighter, so the crossover sits
somewhere between; I did not locate it.
Results are unchanged across all of it. At 100 genes with
random_statepinned,the sample-variance Mahalanobis vector was saved from five arms —
origin/mainflag on and off, this branch flag on and off, and a broadcast-add variant I
wrote and discarded — and compared element by element across all ten pairs:
max absolute difference 9.3e-13, max relative 2.8e-12. Not bit-identical,
and it should not be, since this branch sums its terms one gene at a time rather
than up front. That is a wider and looser check than the
maxdiff 0.000e+00reported above for the lazy assembly against the dense arithmetic it replaces —
separately built trees, end to end through
kompot.de(), rather than one codepath on the same inputs — and the two agree about what they each measure.
This PR's fix, checked against that reproduction rather than against its prose
The guard from that work is added here as
tests/test_sample_variance_graph_scaling.py. It drives the realcompute_mahalanobis_distancesand asserts on the object handed downstream, soit exercises whatever the shipped code builds rather than a particular helper,
and it counts tasks rather than wall time so it cannot flake on a loaded CI
machine.
origin/mainit fails:40.4x for 4x the genes.It passes because
LazyGeneCovariancecarries no task graph at all, so the guardfinds nothing to measure and returns. The blowup is not bounded on this branch,
it is unrepresentable — which is a stronger property than the test was written to
check, and the test now says so explicitly rather than asserting a dask-specific
shape. Its first version asserted the representation and errored here with
AttributeError: 'LazyGeneCovariance' object has no attribute '__dask_graph__';that was the probe's assumption, not this branch's behaviour.
Two things deliberately NOT added, both because measuring them said not to
compute_gene_mahalanobisis captured rather than passed, so Dask cannot see the covariance as a
dependency. I had this filed as a second compounding mechanism and intended to
fix it here. Measured against this branch with a bare 3-D Dask array — the
shape
tests/test_mahalanobis_approaches.pypasses in — the graph is linearand so is the cost: 0.67 / 1.16 / 1.80 s at 25 / 50 / 100 genes, falling per
gene. Against
origin/mainwith the same linear graph, 0.24 / 0.88 / 2.18 s.Within a small constant factor of each other. The capture only ever mattered
because it multiplied against a quadratic graph; with the graph linear it costs
nothing measurable. Removing the
__setitem__chain was the whole fix, and Iam withdrawing the "two compounding mechanisms" framing I published on store_arrays_on_disk=True does not reduce peak memory, and with dask nothing is written to disk #26.
da.from_array(np.load(file_path), chunks="auto")atmemory_utils.py:889.No
mmap_mode, so the array is fully resident before Dask wraps it, and itlooks like the answer to "why does the flag not reduce peak memory". It is not:
DiskStorage.store_arrayandload_arrayare not reachable from thedifferential-expression entry points on either arm, on
origin/mainor onthis branch: the Dask arm writes nothing and returns a lazy graph, the
no-Dask arm writes a memmap and then sums two memmaps into a dense in-RAM
array, and neither route calls them. (Every call site in this repo is a test,
which supports that without establishing it — they are public module-level
functions, so enumerating one tree cannot rule out an external caller.)
Changing it would read as an answer to store_arrays_on_disk=True does not reduce peak memory, and with dask nothing is written to disk #26 while moving nothing the DE path
reaches.
Unchanged by any of this
store_arrays_on_diskdoes not auto-enable under memory pressure — the"critical"path atmemory_utils.py:391only escalates warning text, and theflag resolves from
disk_storage_dir is not Noneunless set explicitly. So foranyone on a released version, where this branch has not landed, the mitigation is
simply not to pass
store_arrays_on_disk=True: on the Dask path it creates astorage directory, logs an expected size, and writes nothing to it.
Not merging — over to you.