Repository navigation
Conversation
…elihood and a pool of Bonsai runs tests/bonsai_testdata.py simulates features diffusing along a given newick tree and writes them in the Sanity 2.0, pre-2.0 Sanity or features layout; bonsai_testref.py computes the dense Gaussian log-likelihood and posteriors in Bonsai's units; bonsai_testtrees.py parses newick and compares topologies; bonsai_testrun.py and bonsai_testfixtures.py write configs and run Bonsai's scripts as subprocesses on a shared pool. tests/test_harness.py checks the harness itself. pytest.ini gains importlib import mode, -ra, --strict-markers, a 300 s per-test timeout and the mpi and scout markers; both development requirement files gain pytest-timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…I wrappers tests/area_a/ covers the data side of bonsai_dataprocessing.py (Sanity 2.0 and pre-2.0 readers, features input, prior division, zscore filtering, gene variances, storing and loading trees and posteriors), create_config_file.py and Run_Configs, the non-tree utilities of bonsai_helpers.py, bonsai_globals.py and mpi_wrapper.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… merging, SPR and NNI tests/area_b/ checks Tree and TreeNode in bonsai_treeHelpers.py, the SPR and NNI drivers in bonsai_dataprocessing.py, the tree utilities of bonsai_helpers.py and bonsai_approxNN.py against a dense reference likelihood, closed-form branch-length optima and brute-force enumeration of small topologies, and runs greedy merging on 200 and 520 cells under mpiexec -n 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs bonsai_main.py on synthetic Sanity 2.0, pre-2.0 Sanity and features input and checks the final tree, log-likelihood and posteriors against a dense reference on the final tree; every --step, --pickup_intermediate from the intermediate folders, every YAML option, and one process against mpiexec -n 2 and -n 4. Also runs backbone_based_bonsai.py, bonsai_add_cells.py and bonsai_iterative_first_split.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reprocessing tests/area_d/ covers bonsai_scout/ (preprocessing, the figure helpers, the tree layouts and the app helpers), downstream_analyses/ (clusterings, NMI, marker genes, averages over groups) and optional_preprocessing/ with batch_correction/. The modules that need h5py skip without the Bonsai-scout requirements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d coverage tests/README.md says how to run the suite and in which environments, describes the harness, the rules every test follows and the behaviour of Bonsai that tests must allow for, lists four bugs found while writing the harness, and reports line coverage. tests/TARGETS.md lists every function, command-line option and YAML key in scope with the test file that covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Make a whole bonsai_main.py --step all reconstruction faster without changing a byte of its output files. Pipeline and MPI work division: freeze the objects alive after bonsai_main's imports so that the explicit gc.collect() calls no longer scan them; build MpiInfo once and add mpi_wrapper.scatter; share the star tree's EM steps (optimiseTStar) over all processes; let the waiting processes compute the pairs of the merge rounds that process 0 runs alone (MergePairPool), with every decision still taken on process 0 in task order; deal nnnReorder's candidates round-robin; let processOptReconfig merge and re-count only the reconfigured nodes; keep nnnReorderRandom's node index and candidate counts between moves; free getOptEdgeList's throw-away trees by reference counting and skip its repeated whole-tree pass; give loaded trees plain-ndarray rows; store nearest-neighbour connections as sets (NNConnectivity) instead of a lil_array; read the pairs ordered by upper bound from their array on demand (PairSequence). Compiled kernels: add bonsai/bonsai_kernels.py, numba kernels with cache=True, error_model='numpy', explicit signatures and no fastmath, for the two-leaf branch-length search (a port of scipy 1.17.1's brentq), the three-leaf star-tree objectives, calcSingleDLogL, getLtqsAsIfRoot, estimateDerBasedDLogLUB, a node's position and log-likelihood from its children, and Tree.optTimes' objective on a table of the tree. Each keeps numpy's order of operations and pairwise summation, leaves logarithms to np.log, and hands back to the unchanged numpy code wherever numpy would warn. Add numba==0.68.0 to requirements.txt and requirements_bonsai_scout.txt. L-BFGS-B: add bonsai/bonsai_lbfgsb.py, which drives scipy's setulb as scipy 1.17.1's minimize does, without its per-call set-up, for optimiseT3LeafStarSequential, optimiseT3LeafStar and Tree.optTimes. It runs only on scipy 1.17.1; any other version uses minimize. The code adapted from scipy carries scipy's BSD-3-Clause notice, and bonsai_lbfgsb.py also the MIT notice of _lbfgsb_py.py. YAML options, defaults, output files and the intermediate folders are unchanged, and mpi4py stays optional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Hello! The above message was written by Claude, but this comment is written by me (though based on my other auto-optimization PRs). I have been developing a tool, called [auto-optimize], which basically applies the auto-research loop to code to improve speed and memory usage of code, but with extensive robustness checks to try to ensure nothing subtly breaks. I have been applying [auto-optimize] across the genomics ecosystem, starting with my own tools but now extending to other important ecosystem tools, with the hope of making things broadly better. Most recently, I've applied it to MACS3, Bowtie2, chromap, Picard MarkDuplicates, and bedtools. In an effort to be more robust, the process first augments the unit tests to make sure that nothing breaks during the search. See #99 (feel free to merge that independently of any choice you make here). Any iteration in our loop that broke any unit test was automatically discarded, and we used a set of external validations that were not directly optimized against each iteration to make sure we got identical results. At the end, we ran Bonsai several times to make sure it still reproduced on real inputs. If you look at the results below, you'll see that there were several big improvements, but also a collection of small improvements that all collectively led to the improved speed. (ignore the orange line)
In an effort to be consistent with https://rewrites.bio/, I'd like this to be merged into this repository so the original developers and maintainers can continue to get credit for their work. However, as a maintainer myself, I understand being skeptical of merging a PR of this size, particularly when the submitter can't explain everything in it (a consequence of so many changes being made at a low level). There are several paths forward: (1) you can merge the PR as-is, (2) you can push your own commits on top of it to revert any changes you dislike or built upon them, (3) you can cherry-pick out any individual or set of improvements you like and merge them as separate PRs (as Ben is doing with bowtie2 right now), (4) you can ignore this entirely. We're happy to share any logs or information that would help you! |
|
For now, we will not be adding many lines of AI-generated code to this repo, at least not if I don't have time to check it carefully. |

This optimization was found with auto-optimize, a tool developed by the Programmable Genomics Laboratory at UMass Chan Medical School: it speeds up
bonsai_main.py --step allby 4.2x to 7.9x on every input timed except Bonsai's 64-cell example (3.2x and 3.6x, on runs of about 3 s), with output byte-identical tomain. See How the output was checked.On the 500-cell PBMC input at 4 MPI processes, the median wall time falls from 80.22 s to 13.41 s (5.98x, six paired runs from a fresh clone); on the four full 10x Genomics experiments the geometric mean speedup is 5.99x, and pbmc_10k_v3 (11,769 cells) at 16 processes takes 525.62 s instead of 3,526.12 s. Most of the gain comes from three things: numba kernels for the per-gene likelihood loops, freezing the garbage collector after the imports, and calling scipy's L-BFGS-B routine without
minimize's per-call set-up. Peak memory of the largest process is 0.79 to 1.28 timesmain's (median 1.02; see Memory), and no OS-specific calls are added.It builds on #99, which adds a pytest suite and changes no source. Of this branch's seven commits, the first six are #99's; this pull request's own change is the last one: 8 files, +2,368 / -186. Once #99 is merged, only that commit remains.
Changes
Roughly in the order the pipeline reaches them:
bonsai/bonsai_main.py): onegc.freeze(), so Bonsai's explicitgc.collect()calls no longer rescan every object the imported libraries leave alive.MpiInfobuilt once (bonsai/mpi_wrapper.py): the namedtuple class is defined at import instead of on everyget_mpi_info()call.scatter()is added besidebcast()andgather().optimiseTStarinbonsai/bonsai_treeHelpers.py,SCData.__init__inbonsai/bonsai_dataprocessing.py): each step of the EM-like scheme computes the children's times on all MPI processes instead of process 0 alone, in preprocessing and in the periodic re-optimisation during merging.getLogLikAndGradStarTreeSequentialWrapper,getLogLikAndGradStarTreeWrapper): the objective and gradient that L-BFGS-B evaluates for every candidate pair are one numba kernel.minimize's set-up (newbonsai/bonsai_lbfgsb.py): the three optimisation sites call scipy'ssetulbdirectly, with the arguments and evaluation pointsminimizeuses. It runs only on scipy 1.17.1, the versionrequirements.txtpins; elsewhereminimizeruns as before.calcSingleDLogL): two kernels.estimateDerBasedDLogLUB): one kernel; the norms stay with numpy.MergePairPoolinbonsai_treeHelpers.py): in the merge rounds process 0 used to run alone, processes 1 and up compute batches of candidate pairs. Every decision stays on process 0, in task order.getNewPairs): membership is tested in a dict instead of a list.PairSequence):getNewPairsno longer converts the whole array of pairs into a list of tuples at four places.NNConnectivity): replaces thescipy.sparse.lil_array, with the same entries, row reads and errors.findNodeLtqsGivenLeafs,getLtqsComplete): one kernel for nodes with 2 to 4 children.Tree.optTimes' objective on a table (_OptTimesObjective): the tree is read into arrays once per call, and each evaluation is two kernels instead of a recursion over the nodes.getOptTime2LeafTree,get_dlogl_spr_move): the bracket search and a port of scipy 1.17.1'sbrentqin one kernel, with the SPR target's log-likelihood.getLtqsAsIfRoot): one kernel.nnnReordershares the candidates it computes (bonsai_dataprocessing.py): round-robin over the processes instead of in contiguous blocks, with oneallgatherinstead of three.processOptReconfigtidies only what a move rebuilt (Tree.processOptReconfig): instead of two passes over the whole tree after every NNI move.Tree.getOptEdgeList,nnnReorderRandom): freed by reference counting instead of the cyclic collector, andgetOptEdgeListno longer repeats the whole-tree pass thatoptTimesends with.nnnReorderRandomkeeps its node index between moves (bonsai_dataprocessing.py): a random candidate is found from per-node counts instead of rebuilding three lists before each move. The random draws are unchanged.load_data_for_tree): views of the same memory map, withoutnp.memmap's Python hooks, below 25,000 cells.getLtqsComplete(mem_friendly=False)): one kernel per node for any number of children.bonsai/bonsai_kernels.py,requirements.txt,requirements_bonsai_scout.txt):numba==0.68.0is required. The kernels have explicit signatures, compile at the first import after an install, andcache=Truekeeps the result.Every kernel repeats the numpy code it replaces operation by operation and in numpy's order, without
fastmath, leaves logarithms tonp.log, and hands back to the unchanged numpy code wherever numpy would warn; that is how the output stays byte-identical.bonsai/bonsai_lbfgsb.pyand thebrentqport inbonsai/bonsai_kernels.pyare adapted from scipy 1.17.1 and carry scipy's BSD-3-Clause notice, andbonsai_lbfgsb.pyalso the MIT notice of scipy's_lbfgsb_py.py.Tests and CI
The pytest suite from #99 passes on this branch: 843 passed, 4 skipped (
bonsai_scoutmodules that need h5py). Bonsai has no CI workflows, so the suite was run in these environments:requirements.txt's pins, numba 0.68.0, mpi4py 4.1.2 on Open MPI 4.1.6main(b16ef6f) with this branch merged, a new environment fromrequirements-dev.txtPython 3.12, an environment without mpi4py, the
bonsai_scoutenvironment, macOS and Windows were not run on this branch.Behaviour changes
No output file changes. What a user can notice:
numba==0.68.0, with llvmlite 0.50.0. It installed without a build step here.main. Later runs load the compiled kernels.bonsai/__pycache__/, or to a per-user cache directory where that is not writable;NUMBA_CACHE_DIRoverrides both.nnnReorderprogress lines come from another process.exit()ingetOptTime2LeafTree; we did not see either on any input.dispwarning:Tree.optTimesno longer triggers scipy'sDeprecationWarningforminimize'sdispoption, which Python hides by default.minimizeand the speedup is smaller. Thebrentqport keeps 1.17.1's arithmetic with every scipy.Usage benchmarks
Machine: AMD EPYC 9575F, 2 sockets x 64 cores (256 hardware threads, 2 NUMA nodes), 1 TB RAM, Ubuntu 24.04.5, Linux 7.0, Open MPI 4.1.6. Both versions are the checkouts and virtual environments of the fresh-clone check in How the output was checked: Python 3.14.3, numpy 2.4.6, scipy 1.17.1, pandas 3.0.3, scikit-learn 1.9.0 and mpi4py 4.1.2, plus numba 0.68.0 and llvmlite 0.50.0 for this branch.
Inputs: single-cell RNA-seq from 10x Genomics (CC BY 4.0), each put through Sanity 2.0.0 (
-e 1 -v_m MAP) once, so both versions read the same Sanity output; and Bonsai's own example.numpy.random.default_rng(20261002)). The 500- and 250-cell inputs are its first cells afterdefault_rng(20261027).permutation(2000).zscore_cutoff -1, and the same input asfeatures.txtandstandard_deviations.txt(Sanity'sdelta.txtandd_delta.txt) withinput_is_sanity_output False.default_rng(7)), which end on zero-length branches.examples/example_data/simulated_binary_6_gens_samplingNoise(pre-2.0 Sanity layout), withnnn_n_randommoves 100andnnn_n_randomtrees 2.How it was timed:
bonsai_main.py --step allfrom each checkout, undermpiexec --bind-to none -n <N> python -m mpi4py(plainpythonfor one process), with a YAML from that checkout'screate_config_file.py. Every option not named above is at the template's default.mpiexecstarting to its exit: preprocessing, the reconstruction and the metadata step./usr/bin/time %M); the table gives the largest process.main(s)mainpeak (MB)zscore_cutoff -1), 15,143 genesfeatures.txtandstandard_deviations.txt, 15,143 genesThe output files were byte-identical in every configuration.
Over the 9 configurations of the four full experiments, the geometric mean speedup is 5.99x (4.73x to 7.87x), and the summed wall time is 7,348 s for
mainand 1,143 s for this branch.How the output was checked
The usage benchmarks above check speed and memory. Separately, we compared
mainand this branch as follows:tree.nwk,edgeInfo.txt,vertInfo.txt,metadata.json,metadata_bonsai.txtand the twoposterior_*_vertByGene.npy), byte for byte. The absolute results and data paths inmetadata.jsonwere replaced by tokens first, and thereconsTimecolumn, the run's wall time, was removed frommetadata_bonsai.txt.What ran
use_knn 3, thelong_branch_sortedandsuper_sureSPR strategies, one signal gene), and 29 direct calls of the changed functions on small and unusual inputsmain(b16ef6f) cloned anew with this branch merged, each version in a new environment from its ownrequirements-dev.txt; the main case in three cell orders, each twice, alternating which version went firstResults
Every comparison was identical. The usage benchmarks' 2,000-cell outputs also differed, in both versions, from the validation row's
mainoutput in one field:metadata.json'sdataset, which records the dataset name the YAML gives, and the two runs gave the dataset different names. A small case that aborted was retried up to three times, becausemainitself aborts now and then withspr_strategy: super_sureunder MPI. At one process, where items 3, 8 and 16 do nothing, the speedup is still 5.14x on the main case, 5.29x on pbmc_1k_v3 and 4.82x on neuron_1k_v3, and both versions use 0.97 to 1.00 CPU-seconds per wall second, so the gain does not come from extra threads.Memory
Peak resident memory of the largest MPI process ranges from 0.79 to 1.28 times
main's, with a median of 1.02. It is lower on the all-genes input (281 MB against 357 MB) and on neuron_1k_v3 at 1 and 4 processes (456 to 460 MB against 502 to 522 MB), and higher on the 64-cell example (246 to 248 MB against 192 to 194 MB), pbmc4k (449 to 453 MB against 375 to 392 MB) and pbmc_10k_v3 at 16 processes (1,307 MB against 1,083 MB). numba and the compiled kernels take about 46 MB per process, most of the difference on the 64-cell example, andTree.optTimesholds a table of four to five float64 values per node and gene while it runs. We did not measure how much of the increase on pbmc4k and pbmc_10k_v3 comes from each.What this testing does not cover
mem_friendlymode Bonsai switches on above 25,000 cells, where this branch keepsmain's code innnnReorder,load_data_for_treeandTree.optTimes.--stepruns andpickup_intermediatewere not compared, though the test suite runs them and passes; other options were compared only in the small cases.backbone_based_bonsai/, the scripts that add cells, andbonsai_scoutwere checked only by the test suite.Bugs in
mainfound along the wayWhile writing the tests and checking this change, we reproduced bugs in
mainand reported them in #98. This pull request fixes none of them, since each fix would change some output. Two touch this change: the occasional abort ofspr_strategy: super_sureunder MPI, and the bareexit()ingetOptTime2LeafTree(see Behaviour changes).🤖 Generated with Claude Code