Skip to content

Refactor: rewrite in rust#26

Merged
jjti merged 16 commits into
mainfrom
claude/mystifying-joliot-8cb798
Jun 2, 2026
Merged

Refactor: rewrite in rust#26
jjti merged 16 commits into
mainfrom
claude/mystifying-joliot-8cb798

Conversation

@jjti

@jjti jjti commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Background

This is a rewrite with Claude Code with Claude 4.8, 1M context, High.

cons:

  • ...all the usual of AI slop including
    • Claude messed this PR up a few times (omitted cross-platform wheel builds, pinned the wrong version, ...)
    • no one will understand the new code terribly well

pros:

  • seqfold becomes faster, uses less energy (hopefully enough to offset the tokens I just burned)
  • seqfold gets an upgrade to more modern Python conventions (pyproject.toml for e.g.)
  • I was not regularly maintaining this repo anyway. iI's not a well-curated garden, no great amount of mental context is getting lost

I am inspired to do this because of, well, how code gets written now. And also:

Overview

Reimplements the seqfold algorithm and Tm calculation in Rust, exposed to Python via PyO3 and packaged with maturin. It stays a drop-in pip install: the public Python API (fold, dg, dg_cache, dot_bracket, tm, tm_cache, gc_cache, Struct) and the seqfold CLI are unchanged, and outputs are byte-for-byte identical to the previous Python implementation.

Motivation: speed. The 200bp profiling sequence goes from ~6.5s (CPython) to ~0.84s on this machine, same result.

dg() on real, structured sequences (examples/known_structures.fasta — yeast tRNA-Phe, E. coli 5S rRNA, tRNAs, structured oligos; these actually fold, unlike random sequences), original pure-Python seqfold vs. the Rust engine (10-core Apple M-series, CPython 3.9; dg identical):

Sequence Length Python (CPython) Rust 0.10.0 Speedup
E. coli 5S rRNA (3-way junction) 120 nt 1200 ms 6.8 ms ~178×
structured RNA 109 nt 783 ms 5.1 ms ~153×
structured DNA oligo 99 nt 631 ms 4.4 ms ~142×
tRNA 76 nt 244 ms 2.6 ms ~94×
yeast tRNA-Phe (4-way junction) 76 nt 204 ms 2.5 ms ~82×

Several optimizations stack (each verified output-identical): moving off CPython; dense integer-keyed energy tables instead of string-keyed hash lookups; deferring all human-readable label rendering out of the matrix fill (the BIFURCATION: label alone was format!-ed ~n³ times); inline 32-bit per-cell basepair lists (SmallVec, no heap alloc for the common case); and a parallel anti-diagonal fill via rayon for sequences ≥64 nt (the speedup grows with length — ~2× at 120 nt, more on longer inputs). Reproduce with cargo run --release --example bench.

Compatibility / breaking changes

  • API, CLI, and numeric outputs are unchanged — byte-for-byte identical to the previous release (verified by a differential suite against the original Python).
  • Declared minimum Python is now 3.8 (requires-python = ">=3.8", enforced by the cp38-abi3 wheels), up from the old setup.py's python_requires=">=3.5". In strict semver, raising the floor is breaking — but in practice it restricts no working install: the previous release declared >=3.5 yet its __init__.py imports importlib.metadata, which only entered the stdlib in 3.8, so import seqfold already failed on 3.5–3.7. This just makes the declared floor honest.
  • Installing from the sdist now requires a Rust toolchain. Prebuilt wheels are published for Linux glibc/musl (x86_64, aarch64), macOS (x86_64, arm64), and Windows (x64, x86, arm64) — those need no toolchain. Any platform without a matching wheel falls back to compiling the Rust extension from source.
  • The pypy3 (recommended) install guidance is gone: the engine is now a native extension, so the interpreter no longer affects folding speed.

Verification (all exact)

Check Result
cargo test — ported internal tests (_pair/_stack/_bulge/_hairpin/_internal_loop/_w) 9/9 pass
Python public-API suite (tests/) 9/9 pass
examples/dna.csv + examples/rna.csv regenerated byte-for-byte identical (incl. 118bp)
CLI parity (--celcius, -d -r, -r, --help, --version) byte-identical
200bp sequence vs original Python -21.7 = -21.7, ~6.5s → ~0.84s
Differential fuzz: 120 random DNA/RNA seqs (dg + dot-bracket + every struct's e/desc/ij) 0 mismatches
Differential fuzz: 80 random tm/tm_cache/gc_cache 0 mismatches
Clean wheel installed in a fresh venv (true pip install) import + CLI + suite pass

Reviewer notes

  • CLI argument parsing stays in Python (argparse). argparse's exact help/usage/error text is impractical to reproduce byte-for-byte in clap, and exact CLI parity was a requirement. main.py is ~10 lines of glue; all computation is Rust. Happy to switch to a clap-based Rust CLI if preferred (would add an output-parity test).
  • Building from source now needs a Rust toolchain + maturin (maturin develop --release, or pip install . via the maturin backend). Prebuilt wheels bundle the compiled extension, so end users installing from PyPI need no Rust.
  • src/core/data.rs is generated; regenerating via codegen/gen_data.py requires the original seqfold/dna.py/rna.py (available in git history).

jjti added 8 commits June 2, 2026 14:04
Reimplement the seqfold algorithm and Tm calculation in Rust, exposed to
Python via PyO3 and packaged with maturin. Stays a drop-in `pip install`:
the public API (fold/dg/dg_cache/dot_bracket/tm/tm_cache/gc_cache + Struct)
and the `seqfold` CLI are unchanged, and outputs are byte-for-byte identical
to the previous Python implementation.

What changed:
- src/core/: pure-Rust engine (no Python dep), unit-tested via `cargo test`
  - fold.rs: line-by-line port of the Zuker 1981 DP (_w/_v/_stack/_hairpin/
    _bulge/_internal_loop/_multi_branch/_traceback). Indices kept as i64 to
    preserve the -1 dangling-end sentinels.
  - tm.rs: port of tm/tm_cache/gc_cache incl. the Owczarzy salt-correction tree
  - pyfloat.rs: Python-compatible round() (ties-to-even) and str(float) so
    textual output matches exactly
  - data.rs: energy tables codegen'd from the original Python modules
    (codegen/gen_data.py) for identical parameters
- src/python.rs: PyO3 `seqfold._core` module; bad sequences map to
  RuntimeError/ValueError as before
- Packaging: pyproject.toml (maturin backend, mixed layout), Cargo.toml,
  thin python/seqfold/{__init__,main}.py. CLI keeps the original argparse
  main.py (exact --help/--version/error text) and calls into Rust. Removed
  setup.py/requirements.txt and the old seqfold/*.py implementation.

Verification (all exact):
- cargo test: ported internal tests (_pair/_stack/_bulge/_hairpin/
  _internal_loop/_w) pass
- Python public-API suite passes
- examples/dna.csv + rna.csv regenerate byte-for-byte (incl. 118bp)
- CLI parity (--celcius, -d -r, -r, --help, --version) byte-identical
- 200bp sequence matches original Python (-21.7), ~6.5s -> ~0.84s
- Differential fuzz: 120 random DNA/RNA seqs (dg + dot-bracket + every
  struct's e/desc/ij) and 80 random tm/tm_cache/gc_cache: 0 mismatches
- Clean wheel installed in a fresh venv: import + CLI + suite all pass

Note: the CLI argument parsing stays in Python (argparse) because argparse's
exact help/usage/error text is impractical to reproduce in clap; all compute
is in Rust.
… output)

Optimize the folding hot path without changing any output. Verified
byte-for-byte identical via the differential fuzz suite (120 + 150 fresh
random DNA/RNA sequences vs the original Python), the examples CSVs, and both
test suites.

Changes (all output-preserving):
- Dense integer-keyed energy tables: pack a stack's 4 nucleotides into a
  12-bit code (energies::encode4) and index a flat array, replacing
  String-key construction + SipHash HashMap lookups in stack/hairpin/bulge/
  internal-loop/interior-search. Eliminates millions of heap allocations and
  string hashes. String maps are retained for the cold Tm path and the
  variable-length tri/tetra-loop keys.
- Complement lookup table ([u8;256]) instead of HashMap<u8,u8>.
- Hoist loop-invariants out of the E2 interior-loop search: STACK_DE-ness and
  the left pair's NN membership depend only on (i, j).
- Defer desc-string formatting: build the description only for the winning
  candidate (after the search), not for every candidate.
- Energy-only cache accessors (v_energy/w_energy) for the two hot sites that
  read just `.e`, avoiding a full Struct clone (desc String + ij Vec) on every
  memoization hit.

Benchmark (examples/bench: 200bp DNA + 118bp RNA + 98bp + 76bp, release):
  ~1.117s -> ~0.424s  (checksum unchanged)
- Version 0.8.0, single-sourced from Cargo.toml. pyproject.toml uses
  `dynamic = ["version"]` so the version is no longer duplicated; bumpversion
  updates only Cargo.toml.
- Enable PyO3 abi3-py38: one stable-ABI wheel (cp38-abi3) per platform works
  on CPython 3.8+, instead of a wheel per Python minor version. Output verified
  byte-for-byte identical via the differential fuzz suite.
- Add .github/workflows/CI.yml (maturin generate-ci): builds + tests wheels for
  Linux manylinux & musllinux (x86_64/x86/aarch64/armv7/s390x/ppc64le),
  Windows (x64/x86/arm64), macOS (x86_64/arm64), plus sdist, and publishes to
  PyPI on tag push. This is where cross-platform wheels are produced.
- Scope pytest to tests/ (suite is unittest-based) for a deterministic CI run.
- README: drop the redundant API/CLI note and the from-source build section.
PyO3 0.22 doesn't support the free-threaded/no-GIL CPython build
("the Python interpreter was built with the GIL disabled, which is not yet
supported by PyO3"), so the maturin-generated 'Build free-threaded wheels'
steps failed every job. The standard abi3 (cp38-abi3) wheels build fine; remove
the 3.14t steps. Release job remains gated on tag pushes only (no PyPI publish
on PRs).
The armv7 (and other exotic) wheels build fine but uraimo/run-on-arch-action
has no armv7/ubuntu24.04 image, so the emulated pytest step can't run there.
Rather than ship untested wheels, scope linux/musllinux to mainstream,
test-emulatable arches (x86_64, aarch64); s390x/ppc64le/32-bit install from the
sdist. fail-fast: false so one arch never cancels its siblings.
run-on-arch-action@v2 has no ubuntu24.04 image (the maturin-generated default),
which broke the emulated pytest step on aarch64. Pin to ubuntu22.04, which the
action provides. Build always succeeded; this fixes only the test step.
- Rename .github/workflows/CI.yml -> release.yml (name: Release); it builds +
  tests wheels on push/PR and publishes to PyPI on tag.
- tests/cli_test.py: exercise the installed `seqfold` console entry point
  (dg, --celcius, -d dot-bracket, -r substructures, --version) as a subprocess,
  so CI now covers the CLI too. Collected by the existing pytest step.
- README: add a Performance section benchmarking CPython vs the Rust engine
  across lengths (~16-25x, identical output); make pip the recommended install
  and drop the now-obsolete pypy speed note.
@jjti jjti changed the title Rewrite folding/Tm engine in Rust (PyO3 + maturin) Rust rewrite Jun 2, 2026
@jjti jjti changed the title Rust rewrite Rust migration Jun 2, 2026
jjti added 5 commits June 2, 2026 15:26
Replace the recursive memoized fill with a bottom-up fill by anti-diagonal
(span d = j - i). Every cell on a diagonal depends only on strictly smaller
spans, so each diagonal's cells are computed in parallel with rayon
(into_par_iter) for sequences >= 64 nt; shorter ones run sequentially. Each
cell is a pure function of already-finalized smaller cells, so the result is
independent of evaluation order -> output is byte-for-byte identical and
deterministic.

The recursive w/v/multi_branch are replaced by pure compute_v/compute_w/mb that
read the (immutable) caches; compute_cell does v then w per cell; fill() drives
the diagonals and writes results back after each parallel batch.

Verified identical (vs the original Python) on the real abi3 artifact:
- cargo test (9) + Python suite incl. CLI (14)
- differential fuzz: 120 short + 40 large (64-300 nt, the parallel path) +
  80 tm/tm_cache/gc_cache -> 0 mismatches
- examples/dna.csv + rna.csv regenerate byte-for-byte
- determinism: repeated folds identical

Parallel speedup (10 cores), dg unchanged:
  120 nt 3.4x | 200 nt 4.3x | 300 nt 5.1x  vs single-threaded
  -> up to ~130x vs the original CPython implementation.

Bump version to 0.10.0 (minor): API/output unchanged, but the
packaging/runtime model changed (native extension, min Python 3.5->3.8, sdist
needs a Rust toolchain). Single-sourced from Cargo.toml.
Stop building "BIFURCATION:/STACK:/HAIRPIN:/..." label strings during the
matrix fill. Cache cells now carry a compact `Desc` tag (None/Hairpin/Pair/
Bifurcation{unpaired,count}); the human-readable label is reconstructed only
for the handful of structures on the traceback path (render_desc), from the
cell coordinates + the stored inner pair.

The bifurcation label was previously `format!`-ed in mb() on the order of n^3
times (once per multibranch candidate, nearly all discarded), and those heap
allocations also serialized across rayon worker threads on the global
allocator. Removing them helps both single-thread time and parallel scaling.

Output is byte-for-byte identical (labels reconstructed exactly): verified vs
the original Python on 120 short + 40 large (64-300 nt) + 80 tm cases, the
examples CSVs, and the 14-test Python suite (incl. CLI) + 9 cargo tests.

Speedup (10-core box), dg unchanged:
  4-seq bench  single-thread 0.398s -> 0.175s (2.3x) | parallel 0.106s -> 0.059s (1.8x)
  python 200nt 70ms -> 37ms | 300nt 219ms -> 116ms
  -> ~115x (120nt) to ~250x (300nt) vs the original CPython implementation.
Store each cell's basepair list as SmallVec<[(i32,i32);1]> instead of
Vec<(i64,i64)>: inline (no heap allocation) for the common 0-1 pair case, and
32-bit since sequences are far shorter than 2^31. The multi-branch working list
in mb() likewise becomes SmallVec<[(i64,i64);4]> (inline for the typical
fan-out; its arithmetic stays i64 and is unchanged).

This removes two O(n^3) allocation sources from the hot path: the temporary
branch list built in every mb() call, and the per-cell list cloned every time a
finalized cell is read by a larger span. Indices flow to Python as ints
(converted to i64 at the PyO3 boundary), so the API is unchanged.

Output byte-for-byte identical: differential vs original Python (120 + 40 large
+ 80 tm), examples CSVs, 14 Python tests, 9 cargo tests.

Speedup (10-core box), dg unchanged:
  4-seq bench  single-thread 0.175s -> 0.091s (1.9x) | parallel 0.059s -> 0.041s (1.4x)
  Cumulative vs the faithful Rust port: ~27x (parallel).
Random sequences barely fold, so they under-represent the folding search and
make poor examples. Add examples/known_structures.fasta: real sequences with
known secondary structure (yeast tRNA-Phe, E. coli 5S rRNA, tRNAs, and
structured RNA/DNA oligos from the regression set, each with hairpins/stems/
multi-branch junctions and a reference free energy). Point examples/bench.rs at
these real sequences and add examples/README.md.

On these real structured sequences the Rust engine is ~80-180x faster than the
original CPython implementation (identical dg).
Releasing is now just: bump the version in Cargo.toml, commit, and push a
matching git tag (the tag triggers the build+publish workflow). No separate
bump tool needed; pyproject.toml derives its version from Cargo.toml.
@jjti jjti changed the title Rust migration Refactor: rewrite in rust Jun 2, 2026
jjti added 3 commits June 2, 2026 16:16
setup-python can't reliably provide 32-bit CPython 3.13 on the x86 runner
("Version 3.13 was not found in the local cache"), failing the job before it
builds. 32-bit Windows is a legacy target with effectively no users for this
tool; keep windows x64 + arm64 and let x86 install from the sdist.
- Release trigger is now `release: published` (a GitHub Release), not a bare
  tag push. The version is taken from the release tag and injected into
  Cargo.toml before each wheel/sdist build, so the tag is the single source of
  truth and no version file is edited by hand. Cargo.toml's version is just a
  dev default (noted in a comment).
- Switch publishing to PyPI Trusted Publishing over GitHub OIDC: no API token
  (removed UV_PUBLISH_TOKEN / PYPI_API_TOKEN), `uv publish
  --trusted-publishing always`, and a dedicated `environment: pypi` on the
  release job so publish rights can be gated with required reviewers.
- Add DEVELOPMENT.md: build/test/bench, codegen, and the full release +
  trusted-publishing setup.
Self-contained check of the public API + CLI against known-good values; works
against a local maturin build, a CI wheel, or a published release. Document it
in DEVELOPMENT.md.
@jjti jjti marked this pull request as ready for review June 2, 2026 22:50
@jjti jjti merged commit 8b7818d into main Jun 2, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant