perf: 4–6× faster LinearSystem.solve_one + MT19937 tempering#3
Open
n-WN wants to merge 4 commits into
Open
Conversation
- tests/test_solver.py: 46 pytest cases covering the `m4ri_solve` single and affine-space paths. Exercises over-/under-/square-determined systems, inconsistent systems (both "0=1" and contradicting-row shapes), and verifies that any single solution lies in the affine space reported by the kernel path (incremental RREF check on the basis). Also keeps the canonical MT19937 end-to-end recovery. - benchmarks/mt_benchmark.py: reproducible timing harness covering mt32/mt17/mt9/mt1/mt137/mt1337 shapes used to measure the solver improvements. - .gitignore: add m4ri-*/ and uv.lock — vendored m4ri is downloaded by setup.py on demand and should not be committed.
…_one Makes the `m4ri_solve` path 4–6× faster on MT19937 state-recovery workloads by cutting two layers of overhead: 1. Row ingestion: each equation PyLong is now packed directly into m4ri's word array via `pylong_pack_mzd_row_skip_lsb`, which walks the PyLong digits once and emits 64-bit words, instead of calling `mzd_write_bit` for every bit. `object_bit_constant` + a 0/1 fast path in `xor_tuple` short-circuit the common symbolic BitVec XOR. The Python-side zero-row padding in `LinearSystem._solve_internal` is dropped because the C solver now handles `rows < cols`. 2. Single-solve algorithm: `SOLVE_MODE_SINGLE` now builds the augmented matrix `[A | b]` once and runs `mzd_echelonize_m4ri(..., full=1)` to RREF, reading the solution from the last column. This replaces the PLUQ + `_mzd_pluq_solve_left` pipeline for the one-solution case; the affine-space path (SOLVE_MODE_AFFINE_SPACE) is unchanged. The new function (`m4ri_solve_single_augmented`) masks A's last word before writing the b column (guarding malformed inputs from bleeding into the augmented column), NULL-checks mzd_init / malloc, and releases the GIL around the echelonize and the pivot scan. All existing APIs are preserved; tests in tests/test_solver.py assert that the two paths agree on satisfiability and produce solutions in the same affine space (incremental RREF check).
…tVec Two layered optimizations for the symbolic-BitVec path of MersenneTwister (the MT state-recovery hot path); the plain-int behaviour and all public APIs are preserved. 1. Fused tempering. Each `y ^= (y shift) & mask` stage in `temper` used to allocate two intermediate tuples (shift result, and-result) and then a third for the XOR. For BitVec inputs we now call a single C helper `bv_xor_shift_mask(bits, shift, is_right, mask)` which walks the tuple once, pulling the shifted source bit and XOR-masking in one pass. This removes 2 tuple allocations and 2 C-call dispatches per stage (4 stages × 19968 temper calls in the mt1 shape). 2. Selective output via precomputed linear map. `MersenneTwister` now caches `_temper_output_map`: for each output bit position, the list of input bit indices that XOR to produce it. For MT19937 the average hamming weight per output bit is ~5. When `_getrandbits_word` is asked for `k <= w // 2` bits on a symbolic BitVec input it skips the full temper and computes only the requested output bits via the map — roughly 3 PyLong XORs per call for mt1 versus ~59 for the full temper. For `k > w // 2` the fused full temper reuses intermediate results and still wins, so the path is kept. The cache is lazy and per-instance; the property reads from `self.__dict__` directly to avoid a second descriptor lookup on every call from the hot loop.
Apple clang's OpenMP support needs `-Xpreprocessor -fopenmp`, which
m4ri 20260122's autoconf probe does not try (it only tests plain
`-fopenmp`, `-openmp`, `-qopenmp`, `-xopenmp`). As a result the
vendored m4ri was being built with `__M4RI_HAVE_OPENMP=0` on macOS
and the parallel sections in `brilliantrussian.c` / `mp.c` were
compiled out.
On macOS, `download_and_build_m4ri` now:
- runs `./configure` with `-Xpreprocessor -fopenmp -I{libomp}/include`
in CFLAGS and `-L{libomp}/lib -lomp` in LDFLAGS,
- rewrites `m4ri_config.h` to set `__M4RI_HAVE_OPENMP=1`,
- adds the same `-L{libomp}/lib -I{libomp}/include` to the extension's
compile and link args so `gf2bv._internal` itself links cleanly
without requiring the caller to set `LDFLAGS`/`CPPFLAGS`.
`LIBOMP_PREFIX` env var overrides the default
`/opt/homebrew/opt/libomp`. Non-macOS / non-Windows platforms fall
through to the old `--enable-openmp` configure invocation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Speeds up
LinearSystem.solve_oneby 4–6× on MT19937 state-recovery workloads through three layered optimizations, with a full regression-test suite and reproducible benchmark.Medians across 10 runs of
benchmarks/mt_benchmark.py, Python 3.12.13 on Apple Silicon, vendored m4ri20260122. All cases cross the 30 % threshold; the 5/6 workloads that spend most of their time in the solver see ≥60 % improvement.Why it's faster — call-site details
1.
perf(solver)— direct PyLong packing + augmented-matrix RREF (gf2bv/_internal.c)pylong_pack_mzd_row_skip_lsbwalks a PyLong's digits once and emits 64-bit words straight intomzd_row(A, r). This replaces the per-bitmzd_write_bitloop that the old path used.object_bit_constant+ a 0/1 short-circuit inxor_tupleskipPyNumber_Xorfor literal 0 and 1 on the symbolic BitVec XOR hot path.m4ri_solve(..., mode=SOLVE_MODE_SINGLE)now dispatches to a newm4ri_solve_single_augmented. It builds[A | b]of shaperows × (cols+1)in one pass and runsmzd_echelonize_m4ri(Aug, /*full=*/1, /*k=auto=*/0)to produce RREF. The solution is the b-column bit of each pivot row; any pivot landing on columncolsis an inconsistency (returnNone). This is substantially faster than the old_mzd_pluq + _mzd_pluq_solve_leftpipeline for this shape.mzd_init/mallocare NULL-checked. The affine-space path (SOLVE_MODE_AFFINE_SPACE) is unchanged —solve_all,solve_raw_space, and kernel enumeration all go through the existing PLUQ path.2.
perf(mt)— fused and selective MT19937 tempering (gf2bv/_internal.c+gf2bv/crypto/mt.py)The symbolic BitVec path of
MersenneTwisterused to allocate three tuples per temper stage (shift result, and-result, xor-result) and pay Python dispatch overhead for each of the four stages. Two stacked wins:bv_xor_shift_mask(bits, shift, is_right, mask)computesbits ^ ((bits shifted) & mask)in one tuple pass.MT.temperon a 32-bit BitVec now makes four C calls total instead of twelve — no intermediate tuple allocations, same semantics.MersenneTwister._temper_output_mapcaches the 32-bit linear map from input bits to output bits (computed once by running the integer temper on each basis vector). In_getrandbits_word(k), whenk ≤ w/2and the input is a 32-bit BitVec, we skip the full temper and compute only the requested topkoutput bits as XORs of the pre-selected input bits. For MT19937 the average hamming weight is ~5 per output bit, so mt1 drops from ~59 PyLong XORs per call to 3. Above the threshold the fused path wins because it reuses intermediate y₁/y₂/y₃, so the selective route is only taken when it's strictly cheaper.3.
build(macOS)— OpenMP for the vendored m4ri (setup.py)m4ri 20260122's autoconf probe for OpenMP only tries
-fopenmp,-openmp,-qopenmp,-xopenmp— all of which Apple clang rejects. The bundled m4ri was silently being built with__M4RI_HAVE_OPENMP=0on macOS, so the parallel sections inbrilliantrussian.c/mp.cwere compiled out. On macOS,download_and_build_m4rinow:-Xpreprocessor -fopenmp -I${LIBOMP_PREFIX}/includeinCFLAGSand-L${LIBOMP_PREFIX}/lib -lompinLDFLAGS,m4ri_config.hto set__M4RI_HAVE_OPENMP=1,gf2bv._internalitself links without the caller having to exportLDFLAGS/CPPFLAGS.LIBOMP_PREFIXdefaults to/opt/homebrew/opt/libompand can be overridden. Non-macOS builds fall through to the original--enable-openmpconfigure invocation.Proof of improvement
Regression tests —
tests/test_solver.py(46 cases, 0.2 s):Covers: over-/square-/under-determined satisfiable systems (3 seeds × 4 shapes), inconsistent systems (both "0=1" tails and contradicting-row pairs), equivalence of
solve_onevssolve_raw_space(incremental RREF check: the fast path's solution must lie inorigin ^ span(basis)), and the MT19937 end-to-end recovery.Reproducing the benchmark —
benchmarks/mt_benchmark.py:Prints build/solve/total timings (median, mean, and each individual run) for all six MT19937 shapes. Every run asserts
solution == statebefore the timing is accepted.Per-case build-vs-solve breakdown (optimized)
Test plan
python -m pytest tests/passes (46 tests).python benchmarks/mt_benchmark.py --runs 10runs to completion withok=trueon every run.examples/mt.py,examples/lfsr.py,examples/xoshiro.py,examples/simple.py,examples/nlfsr.py.BitVec,LinearSystem.solve_one/solve_all/solve_raw_one/solve_raw_space,QuadraticSystem,AffineSpace).solve_raw_space/ kernel enumeration path untouched; the regression suite asserts the new single-solve lies in the affine space returned by the old path.