perf: accelerate Dancing Links search and templates#60
Conversation
Move stable search kernels to module scope, batch row covering, and represent immutable rows as contiguous node ranges. This removes V8 wrong-call-target deopts, horizontal pointer chasing, repeated view loads, and redundant per-node payload storage. Build exact-capacity struct-of-arrays buffers directly, use 16-bit indices when safe with a transparent 32-bit fallback, share immutable template metadata, and clone only mutable search state. Every accepted optimization is documented inline with its CPU or allocation rationale. Against the original internal solve benchmark, sparse throughput improved about 143% for Sudoku, 70% for pentomino-one, 39% for pentomino-ten, and 40% for pentomino-100. These ordinary-solver measurements rebuild the matrix every invocation; no implicit repeated-solve cache is used. The explicit template API gained another 6-11% on Sudoku and 2-3% on pentomino-one from mutable-only cloning. Add regression coverage for matrices whose node and column counts exceed Uint16 capacity, compiled-template invalidation, and solver/template state isolation.
Move the once-per-matrix final row-boundary write into a stable helper. V8 can OSR-optimize buildRows inside its inner loop before that keyed store receives feedback, causing a recurring deoptimization when construction reaches the sentinel. Isolating the store keeps the full row-building loop optimized through return. Alternating fresh-solver end-to-end measurements improved cold Sudoku throughput by about 0.4% and cold pentomino-one by about 1.4%. The deoptimization trace no longer reports buildRows bailouts. This adds no reuse cache and leaves construction/search behavior unchanged.
Include both node and row index domains when selecting the compact NodeStore width. A matrix with more than 65,535 mostly empty rows can have few nodes but still require a 32-bit rowIndex; the node-only decision could wrap a selected row back to the wrong input payload. The documented 16-bit fast path remains unchanged for ordinary matrices. Add a behavioral regression with 65,536 empty rows followed by a covering row and assert that the returned index and data both remain 65,536. The existing test continues to cover the separate node-count and column-length fallback.
56fbcb8 to
1315b92
Compare
🚀 Benchmark ResultsPerformance comparison against merge-base bun-latest
🚀 Benchmark ResultsAll solutions to the sudoku
Finding one pentomino tiling on a 6x10 field
Finding ten pentomino tilings on a 6x10 field
Finding one hundred pentomino tilings on a 6x10 field
Unmatched ResultsPR Only
Updated: 2026-07-13T22:02:09.799Z node-22
🚀 Benchmark ResultsAll solutions to the sudoku
Finding one pentomino tiling on a 6x10 field
Finding ten pentomino tilings on a 6x10 field
Finding one hundred pentomino tilings on a 6x10 field
Unmatched ResultsPR Only
Updated: 2026-07-13T22:01:42.599Z node-24
🚀 Benchmark ResultsAll solutions to the sudoku
Finding one pentomino tiling on a 6x10 field
Finding ten pentomino tilings on a 6x10 field
Finding one hundred pentomino tilings on a 6x10 field
Unmatched ResultsPR Only
Updated: 2026-07-13T22:02:25.593Z node-25
🚀 Benchmark ResultsAll solutions to the sudoku
Finding one pentomino tiling on a 6x10 field
Finding ten pentomino tilings on a 6x10 field
Finding one hundred pentomino tilings on a 6x10 field
Unmatched ResultsPR Only
Updated: 2026-07-13T22:04:25.389Z |
Apply the repository lockfile's Prettier 3.6.2 spacing to four empty-update for-loops in the optimized cover/uncover kernels. This is a TypeScript formatting-only correction with identical emitted control flow and runtime behavior; it makes a clean npm ci workspace pass format:check.
Share the compiled template's packed row-reference table with each new solver in O(1), and shallow-copy it only when that solver adds local constraints. Ordinary solver construction and handler identities stay unchanged so fresh/template workloads remain monomorphic.\n\nSnapshot caller-owned column arrays once while retaining handler-owned row objects and a packed outer array. This preserves topology for later solver-local rebuilds without introducing wrong-map or holey-array deoptimizations in shared build/search kernels.\n\nAdd simple caller-mutation and complex copy-on-write isolation tests. Honest timings keep solver creation and search inside each operation: Sudoku template create+search improves 6-9%, alternating template+fresh pairs improve 1-2%, mutation/rebuild fallback improves 2-4%, and fresh-only solves remain effectively neutral. No implicit result cache is introduced.
Add exact 65,535-node Uint16 and 65,536-node Int32 workloads whose only solution forces search through the highest allocated node. Time a fresh public solver, batch ingestion, typed-store build, and exhaustive search on every operation. Add a sequential Uint16-to-Int32 task to expose mixed-width CPU and JIT costs without reusing compiled topology or cached results. Fail benchmark validation when required internal setup or any timed task fails, and verify the public result outside timing. Cover both storage widths and the terminal solution in a focused unit test. These new cases establish honest baselines for future pull requests; this pull request has no merge-base results for them.
Cache the simple handler's packed row-table tail and append unchecked sparse batches with consecutive indexed stores. This avoids a generic Array#push call and repeated length updates per row while deliberately not pre-growing length, which would make V8 retain a slower holey elements kind. Keep validation in a separate sequential fallback so earlier valid rows remain committed when a later row fails. Cover empty and nonempty appends, throwing input access, validation-prefix retention, and template copy-on-write isolation. Alternating fresh public Sudoku A/B pairs improved 5.9% to 11.7% with a median near 9%; the official sparse task improved from 43,781 to 47,175 ops/s (7.8%). Validation stayed within 0.6%, pentomino showed no persistent regression, and 5,000 randomized matrices matched the prior build. The analogous complex-handler experiment was neutral because column conversion dominates, so this commit intentionally leaves that path unchanged.
Read the live packed-array tail for every unchecked append instead of caching it for the whole batch. A caller-provided getter may append through the same solver; the live tail keeps that nested row and commits the outer row after it rather than overwriting it. Keep validation detection and the original push fallback per row so a getter that enables validation affects the current and remaining rows. Add regression tests for both nested appends and reentrant validation, alongside the existing throwing-input and partial-prefix cases. The semantics-safe unchecked path retains the optimization: the latest fresh public Sudoku pair measured 43,151 to 47,493 ops/s (+10.1%). The validated fallback measured 45,123 to 45,180 ops/s (+0.1%).
Recognize an empty covered root list as a resumable search state even when the current depth is zero. Limited searches can pause after choosing a root-column row, so depth alone cannot distinguish that state from exhausted search. Add a one-column regression with two direct choices: findAll already returned both, while createGenerator previously stopped after the first. The generator now recovers the first row and yields the second before reporting exhaustion.
Select navigation and row-boundary width from node count, column-ID width from column count, and row-ID width from row count. Large matrices can now keep col and rowIndex in Uint16 while up, down, and rowStart use the required Int32 fallback. Align the mixed typed-array views inside one backing allocation and retain up/down as the leading mutable region, so template clones still perform one native navigation copy while sharing immutable metadata. Narrow metadata cuts the 65,536-node NodeStore field bytes by 24.9% and keeps col/rowIndex load sites monomorphic across adjacent node widths. Honest fresh build-plus-search results moved from 1,654 to 1,946 ops/s for Int32 (+17.7%) and 943 to 1,098 paired ops/s for alternating Uint16/Int32 (+16.4%); Uint16 remained neutral at 3,995 to 4,029 (+0.8%). Four process-isolated mixed A/B pairs improved 14% to 16%. Cover independent node, row, and column cutoffs, odd mixed-width alignment, exact high-index storage, clone copy/share identity, clone search, and the public ingestion/search fallback.
Return one empty cover when a nonempty problem has no primary columns, while retaining the established no-constraints error for an entirely empty input. Select the cold behavior at solver construction so ordinary searches avoid a root-empty check. Install the overrides on an ordinary ProblemSolver instance instead of deriving from it, which keeps V8 constructor feedback monomorphic and preserves instanceof behavior. Carry the specialization through templates without losing validation or copy-on-write isolation. Cover simple and complex modes, find limits, generator exhaustion, lazy no-row errors, validation, and template detach/share behavior with regression tests. The final opposite-order full internal benchmark pair classified every ordinary and adaptive-width case as no significant change; sparse Sudoku was -0.09%, Uint16 -0.08%, Int32 -0.11%, and the mixed-width pair -1.60%.
What changed
Why
CPU profiles put about 94% of deep-search time in coverRow and uncoverRow, with repeated V8 call-target deoptimizations in the original nested-kernel implementation. The accepted changes reduce pointer chasing, redundant metadata, generic append overhead, copying, allocation, and GC pressure while keeping the ordinary search state machine monomorphic.
Performance
Every fresh timed operation creates a public solver, ingests constraints, builds the matrix, and searches it. There is no result or topology cache hidden in the fresh path.
Compared with the original merge base:
Additional isolated accepted changes:
The width benchmarks build and exhaustively search exact 65,535-node and 65,536-node matrices on every operation, plus a sequential mixed-width pair.
Validation