Skip to content

perf: accelerate Dancing Links search and templates#60

Merged
TimBeyer merged 11 commits into
masterfrom
perf/dlx-cpu-optimizations
Jul 13, 2026
Merged

perf: accelerate Dancing Links search and templates#60
TimBeyer merged 11 commits into
masterfrom
perf/dlx-cpu-optimizations

Conversation

@TimBeyer

@TimBeyer TimBeyer commented Jul 13, 2026

Copy link
Copy Markdown
Owner

What changed

  • Stabilize and batch the hot DLX cover/uncover kernels, represent rows as contiguous ranges, and directly build exact-capacity typed-array stores.
  • Size node metadata independently by node, column, and row domains so only fields that cross the Uint16 boundary widen to Int32.
  • Keep unchecked sparse batches packed with indexed live-tail appends while retaining the original validated fallback semantics.
  • Compile explicit solver templates once, share immutable rows and metadata, and detach the row table only on first solver-local mutation.
  • Fix resumable generators after root-level solutions and return the mathematically correct empty cover for nonempty zero-primary problems through a cold specialization.
  • Add honest end-to-end Uint16, Int32, and mixed-width benchmarks plus regression tests for every fallback and behavioral difference.

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:

  • Fresh Sudoku: +132–135%
  • Fresh pentomino 1: +64–65%
  • Fresh pentomino 10: +36–37%
  • Fresh pentomino 100: +33–35%
  • Explicit template Sudoku: +275–286%
  • Explicit template pentomino cases: +35–72%

Additional isolated accepted changes:

  • Template row copy-on-write: +6–9% read-only Sudoku, +1–2% alternating template/fresh use, and +2–4% with local mutation.
  • Packed sparse ingestion: +7.8% in the official sparse Sudoku case and about +9–10% across paired focused runs.
  • Field-granular metadata at 65,536 nodes: 24.9% fewer field bytes, +17.7% on the exact Int32 case, +16.4% on the mixed pair, and neutral Uint16 performance.
  • The final zero-primary correctness specialization changes no ordinary search code; the final opposite-order full-suite pair classified every ordinary and width-specific case as no significant change.

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

  • npm test: 70 passing
  • npm run build
  • npm run build:dev
  • npm run lint
  • npm run format:check
  • Full internal benchmark suite in both baseline/candidate orders
  • CI across Node 22, Node 24, Node 25, Bun, and runtime-specific benchmarks

TimBeyer added 3 commits July 13, 2026 22:38
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.
@TimBeyer
TimBeyer force-pushed the perf/dlx-cpu-optimizations branch from 56fbcb8 to 1315b92 Compare July 13, 2026 20:38
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

🚀 Benchmark Results

Performance comparison against merge-base 58aa52d

bun-latest

dancing-links@4.3.7 compare-benchmarks
npm run build:dev && node built/scripts/compare-benchmarks.js baseline-results-bun-latest.json pr-results-bun-latest.json

dancing-links@4.3.7 build:dev
npm run clean && tsc -p tsconfig.dev.json

dancing-links@4.3.7 clean
rimraf built

🚀 Benchmark Results

All solutions to the sudoku

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 2,998.159 ±0.51% 3,322.514 ±0.51% +10.82% 🚀 Significant improvement
dancing-links (sparse) 12,565.329 ±0.18% 24,591.989 ±0.16% +95.71% 🚀 Significant improvement
dancing-links generator 12,080.182 ±0.25% 23,614.787 ±0.20% +95.48% 🚀 Significant improvement
dancing-links template 13,299.525 ±0.24% 44,051.757 ±0.14% +231.23% 🚀 Significant improvement

Finding one pentomino tiling on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 361.883 ±0.81% 498.069 ±0.74% +37.63% 🚀 Significant improvement
dancing-links (sparse) 403.439 ±0.71% 556.484 ±0.75% +37.94% 🚀 Significant improvement
dancing-links generator 397.349 ±0.78% 562.381 ±0.86% +41.53% 🚀 Significant improvement
dancing-links template 408.181 ±0.72% 584.697 ±1.03% +43.24% 🚀 Significant improvement

Finding ten pentomino tilings on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 61.502 ±1.02% 71.089 ±2.65% +15.59% 🚀 Significant improvement
dancing-links (sparse) 60.919 ±1.88% 80.367 ±2.12% +31.92% 🚀 Significant improvement
dancing-links generator 62.493 ±1.45% 76.997 ±2.82% +23.21% 🚀 Significant improvement
dancing-links template 61.431 ±1.74% 80.043 ±2.16% +30.30% 🚀 Significant improvement

Finding one hundred pentomino tilings on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 8.724 ±1.05% 11.485 ±1.13% +31.66% 🚀 Significant improvement
dancing-links (sparse) 8.621 ±1.69% 11.03 ±1.27% +27.94% 🚀 Significant improvement
dancing-links generator 8.879 ±0.83% 11.505 ±1.05% +29.57% 🚀 Significant improvement
dancing-links template 8.864 ±1.03% 11.418 ±1.28% +28.82% 🚀 Significant improvement

Unmatched Results

PR Only

Benchmark Library ops/sec Margin Runs
Fresh exhaustive search at 65,535 nodes (Uint16) dancing-links (fresh build + exhaustive search) 1,984.267 ±1.04% 1838
Fresh exhaustive search at 65,536 nodes (Int32) dancing-links (fresh build + exhaustive search) 1,009.234 ±0.65% 990
Fresh sequential Uint16 and Int32 exhaustive searches dancing-links (fresh Uint16 → Int32 pair) 491.591 ±1.21% 481

Updated: 2026-07-13T22:02:09.799Z

node-22

dancing-links@4.3.7 compare-benchmarks
npm run build:dev && node built/scripts/compare-benchmarks.js baseline-results-node-22.json pr-results-node-22.json

dancing-links@4.3.7 build:dev
npm run clean && tsc -p tsconfig.dev.json

dancing-links@4.3.7 clean
rimraf built

🚀 Benchmark Results

All solutions to the sudoku

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 4,814.584 ±0.35% 5,510.781 ±0.35% +14.46% 🚀 Significant improvement
dancing-links (sparse) 17,184.419 ±0.17% 32,994.911 ±0.11% +92.00% 🚀 Significant improvement
dancing-links generator 17,097.274 ±0.18% 30,638.537 ±0.12% +79.20% 🚀 Significant improvement
dancing-links template 19,374.119 ±0.14% 59,271.11 ±0.09% +205.93% 🚀 Significant improvement

Finding one pentomino tiling on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 537.485 ±0.59% 688.292 ±0.58% +28.06% 🚀 Significant improvement
dancing-links (sparse) 581.347 ±0.50% 789.864 ±0.35% +35.87% 🚀 Significant improvement
dancing-links generator 592.687 ±0.36% 781.191 ±0.42% +31.81% 🚀 Significant improvement
dancing-links template 592.764 ±0.42% 826.103 ±0.36% +39.36% 🚀 Significant improvement

Finding ten pentomino tilings on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 83.555 ±0.70% 111.982 ±0.95% +34.02% 🚀 Significant improvement
dancing-links (sparse) 87.236 ±0.69% 113.591 ±0.87% +30.21% 🚀 Significant improvement
dancing-links generator 82.737 ±1.23% 113.734 ±0.66% +37.47% 🚀 Significant improvement
dancing-links template 84.535 ±0.60% 115.193 ±0.81% +36.27% 🚀 Significant improvement

Finding one hundred pentomino tilings on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 12.546 ±0.33% 15.522 ±1.54% +23.71% 🚀 Significant improvement
dancing-links (sparse) 12.47 ±1.31% 15.744 ±1.26% +26.25% 🚀 Significant improvement
dancing-links generator 12.511 ±0.72% 16.143 ±0.68% +29.03% 🚀 Significant improvement
dancing-links template 12.207 ±1.06% 16.091 ±0.85% +31.82% 🚀 Significant improvement

Unmatched Results

PR Only

Benchmark Library ops/sec Margin Runs
Fresh exhaustive search at 65,535 nodes (Uint16) dancing-links (fresh build + exhaustive search) 3,100.292 ±0.28% 3070
Fresh exhaustive search at 65,536 nodes (Int32) dancing-links (fresh build + exhaustive search) 1,504.235 ±0.37% 1494
Fresh sequential Uint16 and Int32 exhaustive searches dancing-links (fresh Uint16 → Int32 pair) 805.347 ±0.40% 802

Updated: 2026-07-13T22:01:42.599Z

node-24

dancing-links@4.3.7 compare-benchmarks
npm run build:dev && node built/scripts/compare-benchmarks.js baseline-results-node-24.json pr-results-node-24.json

dancing-links@4.3.7 build:dev
npm run clean && tsc -p tsconfig.dev.json

dancing-links@4.3.7 clean
rimraf built

🚀 Benchmark Results

All solutions to the sudoku

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 4,003.237 ±0.72% 5,362.821 ±0.47% +33.96% 🚀 Significant improvement
dancing-links (sparse) 10,741.018 ±0.34% 22,620.592 ±0.21% +110.60% 🚀 Significant improvement
dancing-links generator 10,424.733 ±0.34% 22,787.852 ±0.20% +118.59% 🚀 Significant improvement
dancing-links template 11,817.21 ±0.32% 42,345.28 ±0.15% +258.34% 🚀 Significant improvement

Finding one pentomino tiling on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 350.802 ±0.99% 477.622 ±1.13% +36.15% 🚀 Significant improvement
dancing-links (sparse) 361.026 ±1.07% 528.642 ±1.17% +46.43% 🚀 Significant improvement
dancing-links generator 349.264 ±1.34% 536.284 ±1.05% +53.55% 🚀 Significant improvement
dancing-links template 339.565 ±1.47% 547.444 ±1.17% +61.22% 🚀 Significant improvement

Finding ten pentomino tilings on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 53.211 ±1.94% 73.01 ±2.74% +37.21% 🚀 Significant improvement
dancing-links (sparse) 53.494 ±2.23% 78.789 ±2.04% +47.28% 🚀 Significant improvement
dancing-links generator 56.45 ±2.32% 76.642 ±2.47% +35.77% 🚀 Significant improvement
dancing-links template 54.477 ±2.25% 78.482 ±2.05% +44.06% 🚀 Significant improvement

Finding one hundred pentomino tilings on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 8.028 ±1.68% 10.746 ±2.18% +33.86% 🚀 Significant improvement
dancing-links (sparse) 7.872 ±2.01% 10.225 ±2.25% +29.89% 🚀 Significant improvement
dancing-links generator 8.024 ±1.49% 10.502 ±1.82% +30.88% 🚀 Significant improvement
dancing-links template 7.877 ±1.78% 10.572 ±1.88% +34.21% 🚀 Significant improvement

Unmatched Results

PR Only

Benchmark Library ops/sec Margin Runs
Fresh exhaustive search at 65,535 nodes (Uint16) dancing-links (fresh build + exhaustive search) 2,015.191 ±0.80% 1900
Fresh exhaustive search at 65,536 nodes (Int32) dancing-links (fresh build + exhaustive search) 1,053.421 ±1.31% 993
Fresh sequential Uint16 and Int32 exhaustive searches dancing-links (fresh Uint16 → Int32 pair) 550.238 ±1.53% 526

Updated: 2026-07-13T22:02:25.593Z

node-25

dancing-links@4.3.7 compare-benchmarks
npm run build:dev && node built/scripts/compare-benchmarks.js baseline-results-node-25.json pr-results-node-25.json

dancing-links@4.3.7 build:dev
npm run clean && tsc -p tsconfig.dev.json

dancing-links@4.3.7 clean
rimraf built

🚀 Benchmark Results

All solutions to the sudoku

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 6,078.379 ±0.35% 7,418.413 ±0.42% +22.05% 🚀 Significant improvement
dancing-links (sparse) 16,237.673 ±0.22% 35,723.183 ±0.10% +120.00% 🚀 Significant improvement
dancing-links generator 16,733.194 ±0.21% 33,997.088 ±0.12% +103.17% 🚀 Significant improvement
dancing-links template 18,348.526 ±0.23% 63,552.535 ±0.09% +246.36% 🚀 Significant improvement

Finding one pentomino tiling on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 542.85 ±0.47% 748.068 ±0.23% +37.80% 🚀 Significant improvement
dancing-links (sparse) 589.097 ±0.38% 827.826 ±0.34% +40.52% 🚀 Significant improvement
dancing-links generator 594.043 ±0.34% 834.621 ±0.31% +40.50% 🚀 Significant improvement
dancing-links template 597.895 ±0.55% 878.609 ±0.19% +46.95% 🚀 Significant improvement

Finding ten pentomino tilings on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 87.763 ±0.70% 117.225 ±0.80% +33.57% 🚀 Significant improvement
dancing-links (sparse) 87.072 ±0.96% 119.951 ±0.73% +37.76% 🚀 Significant improvement
dancing-links generator 87.016 ±1.11% 119.871 ±0.75% +37.76% 🚀 Significant improvement
dancing-links template 87.748 ±0.98% 121.154 ±0.70% +38.07% 🚀 Significant improvement

Finding one hundred pentomino tilings on a 6x10 field

Library Baseline (ops/sec) PR (ops/sec) Change Performance
dancing-links (binary) 12.455 ±0.67% 16.611 ±0.47% +33.37% 🚀 Significant improvement
dancing-links (sparse) 12.61 ±0.36% 16.69 ±0.57% +32.36% 🚀 Significant improvement
dancing-links generator 12.766 ±0.55% 16.531 ±0.54% +29.49% 🚀 Significant improvement
dancing-links template 12.447 ±0.62% 16.487 ±0.67% +32.46% 🚀 Significant improvement

Unmatched Results

PR Only

Benchmark Library ops/sec Margin Runs
Fresh exhaustive search at 65,535 nodes (Uint16) dancing-links (fresh build + exhaustive search) 3,230.255 ±0.40% 3132
Fresh exhaustive search at 65,536 nodes (Int32) dancing-links (fresh build + exhaustive search) 1,866.719 ±0.49% 1816
Fresh sequential Uint16 and Int32 exhaustive searches dancing-links (fresh Uint16 → Int32 pair) 968.82 ±0.59% 952

Updated: 2026-07-13T22:04:25.389Z

ℹ️ Benchmark Details
  • Baseline: Merge-base commit 58aa52d where this PR branched from master
  • Comparison: Current PR head def5a77
  • Timestamp: 2026-07-13 22:04:36 UTC

TimBeyer added 8 commits July 13, 2026 22:45
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%.
@TimBeyer
TimBeyer marked this pull request as ready for review July 13, 2026 22:00
@TimBeyer
TimBeyer merged commit 39b44e4 into master Jul 13, 2026
12 checks passed
@TimBeyer
TimBeyer deleted the perf/dlx-cpu-optimizations branch July 13, 2026 23:07
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