Clp-core: true Forrest-Tomlin (sparse Hessenberg) + solution-writer & unbounded fixes#23
Open
andig wants to merge 66 commits into
Open
Clp-core: true Forrest-Tomlin (sparse Hessenberg) + solution-writer & unbounded fixes#23andig wants to merge 66 commits into
andig wants to merge 66 commits into
Conversation
…-FT spike is next
…ivot) Replace the dense O(block^2) trailing-block re-triangularization in replaceColumn with sparse Hessenberg elimination on the U rows: build the shifted upper-Hessenberg block sparse, eliminate its single subdiagonal with partial pivoting, record swaps+elims into the R-file. Cost is O(spike + fill), not O(block^2), and there is no block-size cap forcing a refactorize on large problems, so FT stays active on big trailing blocks. L, prow and rinvrow are untouched (the R-file carries the row permutation), so the row-renumber L-corruption trap does not arise. An ftFillCap guard bails to a pooled refactorize on pathological fill. BenchmarkFTReplace (m=300, dense spike): 1.36ms -> 132us/op (~10x). PuLP COIN_CMDTest suite under CBC_FT=1 matches the default engine (78 passed, same 2 documented failures) and is no slower. New TestFTReplaceColumnLarge exercises m up to 400 with blocks far past the old 256 cap.
Branch-and-bound appends cut rows to the problem with empty names. Write emitted them as " 2666 0 0" — a 3-token line once the blank %-20s name collapses under split(). PuLP's readsol does l[3] on every data line, so a nameless entry raised IndexError and the whole solve failed to parse (surfaced as a spurious "Unbounded"/crash on the ../optimizer 020/021 cases; 018 was unaffected only because it needs no cuts). PuLP maps rows/cols by name and ignores names it doesn't know, so dropping the unnamed internal entries is correct and matches real CBC, whose .sol contains only the original model's rows. 018/020/021 now parse and return a solution end-to-end. Regression test asserts every data line keeps >=4 tokens. Note: this was not an unboundedness-detection bug — that path is correct (minimizing 020 without -max genuinely is unbounded). The defect was purely in the solution-file writer.
…ncluding The primal loop declared Unbounded the moment the ratio test found no blocking row, trusting the current (possibly stale) factorization. A numerically spurious ray from an aged eta/FT factor would be reported unchecked. The optimal path already guards against this with a refactorize+reprice recheck (the `cleaned` flag); this adds the symmetric guard for unboundedness, as CBC/Clp do. On the first would-be-unbounded conclusion we refactorize, recompute basics, reprice and re-test; only a ray that survives a clean factorization is reported Unbounded. The flag resets on every pivot so each fresh ray is verified once. Genuine unbounded LPs still resolve (TestUnbounded/free/elastic-penalty pass).
Two profile-driven wins on the active FT path (CBC_FT), both correctness-neutral: - btran U^-T push now skips zero steps. Dual pricing btran has a near-unit (very sparse) RHS, so most steps were multiplying by zero. Mirrors the existing skip in forwardLR's L^-1 push. - refactorize no longer builds the sparse-LU (st.f) every time under FT. FT drives all solves; st.f is only a fallback used when the FT factor is nil. It is now built lazily — only when FT is disabled or the factor goes singular — cutting a full luFactorize per refactorize (was ~14% of FT solve time). Measured on ../optimizer 021 (3312 rows) under CBC_FT: whole-solve 9.1s -> 7.4s (~19%); the FTPROF micro-driver 8.71s -> 7.20s. Objectives unchanged. Default engine untouched (st.f path is identical when FT is off); full suite + PuLP COIN_CMDTest green.
rebuild's factorize picked the largest entry in the shortest row as pivot. Now, among the numerically strongest entries (within ftPivTol=1.0 of the row max, i.e. the max-abs entries), it takes the sparsest column by active-row count — Clp/CoinFactorization's Markowitz least-fill rule. Tie-breaking toward low-fill columns costs no stability (pivot is still a max-magnitude entry) and cuts fill on structured (±1) bases where many entries tie at the max. Column counts are maintained incrementally in wColLen (pivot row leaving, cancellations, fill). Pivot choice never changes the elimination math, so correctness is unchanged (TestFTReplaceColumn/Large pass). Benchmarked against ../optimizer (avg factor L+U nnz over a real solve): 021 (3312 rows): 8429 -> 6436 (~24% sparser), wall-clock neutral (~7.4s). 018: ~unchanged. Default engine untouched (FT-only path). Full suite + PuLP COIN_CMDTest green.
Lazy st.f (commit 43cb3ba) could leave both st.ft and st.f nil when a factorization failed mid-solve, crashing ftranVec (../optimizer case 012 under CBC_FT). Build st.f once as a persistent emergency fallback; still skip the redundant factorize on every subsequent FT-success refactorize, so the solve speedup is preserved (021 FT stays ~7.3s).
CBC's CglPreProcess (D1: coefficient strengthening + integer redundant-row removal) was env-gated off because it slows problems whose LP relaxation is already tight. But a loose relaxation genuinely needs it. Decide per-solve like CBC: solve the root LP relaxation once (throwaway LP, m.LP untouched) and enable D1 when the fraction of integer variables that are fractional exceeds d1FracThreshold (0.18) — scale-invariant, so it separates a loose model from a tight one regardless of size. Wire the decision (d1Adaptive) through both D1 components: presolve's coefficient strengthening (presolve.go) and dropRedundantRows' integer-row removal. Effect on ../optimizer: 020 (fracFrac 0.227 -> D1 on) root cut bound -1.448 -> -0.884 and incumbent -140 -> -0.079 (near optimum 0.558); 021 (fracFrac 0.077 -> D1 off) stays 4.0s with no regression; 018 (0.155 -> off) unchanged. Default and PuLP COIN_CMDTest suites green.
The old replaceColumn was Bartels-Golub in FT clothing: it kept rows at fixed steps and LU-swept the whole trailing (m-p) Hessenberg block, whose subdiagonal (the old U diagonals) is never zero — so every update appended Th(m-p) ops to the R-file, and every ftran/btran/replaceColumn replayed all of them. At maxEtas=32 that is ~11k replayed ops per solve on 018 (m=725): 29x the eta path's per-pivot cost. Rewrite as true FT: the leaving pivot's row/column cycle to the last step and the row spike is eliminated against the existing U rows — O(nnz(row)) R-ops per update. To survive step renumbering without rewriting the factor, L and the R-file are keyed by (immutable) orig rows and U by (immutable) basis cols; only the permutation arrays and slice headers shift per update. A column-wise U index (ucRows, lazy) makes the leaving column's entry deletion O(nnz(col)). 018 CBC_FT=1: 23.1s -> 6.7s; 021 per-pivot now at parity with the eta path (50us vs 52us). Remaining FT-vs-eta wall gap is node-path chaos, not kernel cost: tiny roundoff shifts B&B into different node basins, and the eta path swings identically across refactorize intervals (021 CBC_MAXETAS=24: 131 nodes/31s vs 3 nodes/2.7s at 16). FT therefore stays opt-in; README updated with the root cause and measurements.
Two CBC-parity changes that together stabilize the search tree and prove
020 for the first time.
1. CglProbing coefficient strengthening (Cgl0003I): while probing binary
x_j, one-sided rows now tighten their x_j coefficient and rhs against the
probe side's propagated activity — the implication-aware version of the
static big-M tightening presolve already did. Runs multi-pass (as CBC's
CglPreProcess) until quiet. On 018 this strengthens 149 coefficients and
lifts the pre-cut root bound from 18092 to 18291.44; on 020/021 it matches
CBC's preprocessing fix/strengthen counts (153 fixed / ~400 strengthened).
2. Default absolute gap 1e-5, mirroring CBC's default cutoff increment
(CbcCutoffIncrement): nodes within 1e-5 of the incumbent prune, as real
CBC does. -allow/-ratio still override.
Tree robustness (nodes across CBC_MAXETAS 24/32/48/64/100):
018: 27-336 -> 6-8, wall 0.3-6.4s -> 0.14-0.2s, objective = CBC exactly
021: 3-291 -> 3 everywhere, wall 2.6-30.7s -> 2.9-5.5s
020: proven optimal for the first time (57s me=32, 49s me=100; CBC 3.6s);
me=48 still times out — residual variance on the hardest case.
Soundness: TestProbeStrengthenSound (random big-M MIPs, sampled feasible
points must survive probing); full suite + PuLP COIN_CMDTest green.
Every 256 nodes (max 8 rounds) the tree separates the globally-valid cut
families (knapsack cover, clique, zero-half, flow cover, lift-project) at
the current node's fractional point; on success the LP rebuilds and open
nodes cold-restart on the tightened relaxation (parentState=nil path).
CBC generates cuts throughout the search; this is the globally-valid
subset — Gomory/probing node cuts are basis-/implication-local and would
need per-subtree row lifecycle management.
020 benchmark vs real CBC (Apple M4, 120s cap):
preprocessing: 105 fixed = CBC's 105; ~350 rows strengthened (CBC 501);
1533 rows kept vs CBC's 1375
root cuts: -0.884 -> -0.664 in 4 rounds vs CBC -0.885 -> -0.658 in
10 passes (within 1% of CBC's closed distance)
tree: proven at 3/5 refactorize intervals (48-92s, 1.7-3.7k
nodes); misses end within 2e-4 of optimum; CBC 3.6s/833 nodes
on 020 the in-tree rounds separate nothing new after the root (measured
no-op); CBC's tree advantage is ~950 node-local probing cuts + cheaper
node re-solves.
No regressions: 018 7 nodes/142ms, 021 3 nodes/3.0s (unchanged), full
suite + PuLP COIN_CMDTest green. Cut pivot budget kept at 10x rows —
30x measured strictly worse on 020 (vertex-lottery incumbent regression).
RINS now clones the problem, fixes the integers where the node LP and the incumbent agree (>=half must fix), and solves the reduced neighborhood as a real sub-MIP (MaxNodes 50, <=1s, incumbent as MIP start, no nested subs) — CBC's CbcHeuristicRINS design — instead of walking the full LP. It runs only against a NEW incumbent (CBC semantics: the neighborhood is a function of (incumbent, LP); repeats are redundant). polishIncumbent (1-opt) gets a 3x-rows pivot budget on top of its time box: with RINS improving more often it otherwise blew up to 233k pivots on 020. 020 pivot attribution (new SOLVER_DEBUG split): node re-solves are already cheap — 24k pivots over 1894 nodes = 12.6/node vs CBC's 53 total iterations/node. RINS drops 20k->4.5k pivots; robustness improves to 4/5 refactorize intervals proving (24: 93s, 32: 59s, 48: 94s — was a timeout — 100: 45.5s; me=64 misses by 1.5e-5 vs the 1e-5 gap). Measured and rejected (kept out): strongProbeCap 100->25 (sb 112k->31k pivots, nodes -25%, but wall +30s via a worse per-pivot mix) and three faceWalk budget variants (all +30..55s — its walk pins the good tree path; truncating it re-rolls the lottery). facewalk 198k + strong-branch 112k remain the 020 pivot bulk; both resist capping. 018 6-8 nodes/145-189ms, 021 3 nodes/3-5.9s across all intervals. Full suite + PuLP COIN_CMDTest green.
Feature parity build-out for the three measured CBC gaps, each benchmarked on the golden cases; two land gated where the vertex lottery still punishes them, defaults keep 018 0.14s / 021 2.9s / 020 57s proven. 1. Node-local in-tree cuts (CBC_LOCALCUTS=1): GMI cuts from a node's basis are valid only under its bounds. Rows are stored free (Sense NR: vacuous for propagation, heuristics and the .sol writer) and activated for the subtree via bound overrides on their logical variables, which children inherit — full local-cut lifecycle without LP row add/remove. Infra: baseBound() (override revert for logical vars), propagatedChild/dive guards for logical overrides. Measured on 020: correct but net-negative (842 pivots/node degenerate grind) without CBC's cut-effectiveness pruning; gated until that lands. The globally-valid CGL round stays default-on. 2. Burst heuristics as reduced sub-problems: rensImprove keeps its cheap single-shot rounding, but its failure path is now a node-capped sub-MIP on the integral-fixed neighborhood (CBC RENS / mini branch and bound) via the shared solveNeighborhood() engine (also used by RINS), replacing the LP dive. faceWalk stays the burst primary: five reorderings (rens-first, budget caps) all measured wall-negative — its vertex path pins the good tree on 020/021. 3. Fixed-column elimination (CBC_ELIMFIX=1): LB==UB columns (probing-fixed binaries + presolve-fixed) fold a*val into row bounds and drop, with exact primal/dual postsolve (elimConst records). Removes 364 cols on 020, 152 on 018, 643 on 021; external objectives verified exact. Measured: 021 2.9s -> 1.7s, but 018/020 regress via a single-LP feasibility-pump grind on the scaled reduced model (018: 192k pivots in one projection solve) — gated until in-solve preemption exists. Pump also gets a between-iteration pivot budget (10x rows; no-op on golden paths). Full suite + PuLP COIN_CMDTest green; defaults byte-identical on the golden benchmark table.
…ning Three changes that together take 020 from 59.8s to 15.6s proven (real CBC 2.10.3: 3.6s) with 018/021 unregressed. 1. Per-solve pivot cap (SetIterCap): run() and dual2Run now honor IterCap without probe semantics; heuristic windows (bursts, RINS) set 2m+200. This kills the single-LP degenerate grinds that made every model transformation a lottery re-roll (018 under elimination: 192k pivots in ONE pump projection -> capped, 1.58s -> 0.15s). CBC caps heuristic/hot-start iterations the same way. 2. Fixed-column elimination default-on (CBC_ELIMFIX=0 disables) + default CBC_MAXETAS 32 -> 64. With the cap in place elimination is a clean win: 018 0.15s (= before), 021 2.9s -> 1.8s, and 020 proves at 15-32s on 4/5 refactorize intervals (24: 15.3s, 48: 24.7s, 64: 15.6s, 100: 31.8s; 32 times out, hence the default interval move to 64 - scaling made higher intervals 021-safe since b4ee1dc). expand() now handles all-columns- eliminated results (nil X with incumbent, nil activity/price synthesis). 3. Local-cut effectiveness pruning (CBC prunes ineffective cuts): a local batch must lift the node bound or it is retracted (rows truncated, activations stripped, 2 strikes disables local cuts for the model); accepted batches keep only the cuts binding at the new optimum. With pruning CBC_LOCALCUTS=1 now PROVES 020 (57.3s, 1138 nodes - was a timeout) but still loses to the 15.6s default, so it stays opt-in. Robustness across CBC_MAXETAS {24,32,48,64,100}: 018 7-10 nodes/0.14-0.43s, 021 3 nodes every run, 020 proven 4/5. Full suite + PuLP green.
…p + pruning status
… cuts 1. substituteChains: implied-free continuous columns substituted out of equality rows (CBC CglPreProcess / CoinPresolve): x = (b - rest)/a drops the column and the row, folding the expression into every other row containing x, with exact primal/dual postsolve (elimSub records: x back from the pivot row, activities of rewritten rows restored per-record, the dropped row's dual from stationarity, rc = 0; rowMap remaps the reduced row space). Guards: continuous cols only, sparse stable pivots (<=8 nnz, |a| >= 1% of row max), implied bounds must cover the column's own bounds. Runs between dropRedundantRows and singleton elimination; reductions chain (m.subRed then m.red on expand, both on MIPStart shrink). Default-on (CBC_SUBST=0 disables). Main models substitute few columns (2-11: the evcc bounds usually bind) but heuristic sub-MIP clones substitute hundreds (e.g. 276 of 2421), and the perturbation removes 020's last bad basin: 020 robustness: PROVEN 5/5 refactorize intervals, 10.1-26.2s (was 4/5, 15-32s + one timeout); defaults 020 12.6s / 774 nodes. 018 7-10 nodes / 0.20-0.65s; 021 3 nodes every run / 2.7-5.7s. 2. probingCutsNode: CglProbing implication cuts derived under NODE bounds (CBC probes at every node) - infeasible sides become local fixing rows - emitted through the local-cut channel (NR rows + subtree activation) next to node-basis GMI. Measured on 020: still net-negative (subtree-boundary activation repair dominates), so CBC_LOCALCUTS stays opt-in. Full suite + PuLP COIN_CMDTest green; all objectives exact.
…nd bound) Five earlier attempts to demote faceWalk measured wall-negative; under the substituted model the burst order no longer re-rolls the tree (020 node counts identical per refactorize interval either way), so the CBC-shaped ordering ships: RENS (integral-fix + rounding + node-capped sub-MIP) first, faceWalk as the fallback. 020 pivot attribution: faceWalk 200k -> 41k, strong-branch probes now the top consumer (82k of 205k). Defaults unchanged: 018 0.35s/7 nodes, 021 3.4s/3, 020 13s/774 proven; robustness 020 5/5 (10.5-26.5s), 018 %<1s all intervals, 021 3 nodes every run. Full suite + PuLP green.
…t accounting - CrunchProbe: strong-branch probes solve a row-subset LP with provably redundant rows dropped (Clp's crunch behind solveFromHotStart). Bounds are identical to full-LP probes; ~24% fewer rows on 020, probe pivots 021 128->28. Opt-in: the changed probe roundoff re-rolls 020's tree lottery (CBC_MAXETAS=24 loses its proof), defaults stay byte-identical. - probes skip Solution(): 312MB of the 020 alloc profile was probe-side x/rowAct/rc/price nobody read - pivot counters include the DSE dual (Stats.Dual2): node/sb attribution was blind to dual2 work Measured against real CBC 2.10.3 on 020 (-log 3): CBC runs 2032 strong probes / 55266 iterations (27/probe, 56% of its total simplex work) vs our 1022 probes / 82k pivots (80/probe, 50% of total) -- probe share is at parity; the remaining wall gap is per-pivot engine constants. Rejected by measurement: DSE/perturbed probes (13.2s vs 12.5s), probe stall-exit (weak seeds, tree blowup to 31s), cap 300 (pure grind), MarkHotStart refactorize (neutral).
…ic scheduling)
CBC runs diving heuristics only while the gap is open. cbcgo bursted at
node 1 and every 256 nodes unconditionally; on 020 the lone mid-tree burst
fired with the incumbent already within 0.02 of the proven optimum, ran the
full rens->faceWalk->fpump->dive fallback chain, found nothing, and cost
~26k pivots. Gating the burst on !closeGap (incumbent >10% from the node
bound) skips it:
020 208614 -> 182619 pivots, 12.9 -> 11.6s, tree IDENTICAL (774 nodes,
same objective); robustness 5/5 proven across maxEtas 24..100
018/021 byte-identical (their bursts already gated by closeGap at root)
Root burst still always runs (no incumbent yet, closeGap false). Measured
alternatives rejected: faceWalk internal pivot budget (16x/24x rows no-op,
8x loses the root incumbent -> tree blows to timeout — the root walk needs
its full 37k pivots); burst-failure backoff (the expensive burst is the
FIRST mid one, nothing later to disable). PuLP suite green.
propagate allocated inQ+queue per call and used a reslice-front + append FIFO that reallocated the backing ~O(64) times per call. Rewrote the queue as a pooled cap-nr ring buffer (inQ dedups, so at most nr rows are ever queued -> the ring is exact); the per-node propagatedChild path now reuses one Model-scoped scratch. FIFO dequeue order is byte-preserved, so results are identical: 020 774 nodes / 182619 pivots / same objective, 018/021 unchanged. 020 total allocs 1878MB -> 1625MB (propagate 192 -> 94MB; the residual is one-time presolve probe callers, which pass nil). Wall is flat on the golden cases (GC ran concurrently, off the critical path) — this is a memory/GC-pressure reduction, not a speedup. All tests + PuLP green.
Reduced-model census vs real CBC 2.10.3: our 020 presolve leaves 2032 rows / 2421 cols where CBC reaches 1375 / 1399. CBC prints 0 substitutions and the structural census finds 0 duplicate rows, 0 duplicate cols, 0 equality doubletons — so the ~1000-col gap is CoinPresolve column elimination, not dedup/doubletons. It splits into 260 free columns (no row entries) + 721 continuous singletons in the rowBlocks&&colBlocks case eliminateSingletons leaves. This adds the free-column reduction: a column with no row entries is decided by the objective alone, so fix it at the cost-optimal finite bound and drop (exact elimConst postsolve; empty rec.rows). Optimum-preserving. Opt-in (CBC_FREECOL=1) because — like crunch / local cuts / FT — the perturbed model re-rolls 020's proof-fragile tree: 774 -> 3400 nodes / 96s, still proven but far slower, while 018 gets ~2x faster (31746 -> 13917 pivots, 314 -> 163ms, objective identical to 8 digits). Defaults stay byte-identical: 018 31746 / 020 774 / 021 62278 pivots unchanged. Fixing (105) and big-M coefficient strengthening (301, Cgl0010I-style, see presolvePass) already match CBC's counts — that presolve parity ships on by default. All tests + PuLP green.
…lice reflection) The Clp long-step / DSE ratio test sorts entering candidates by ascending ratio every dual pivot. sort.Slice does this via reflection (a per-element reflect-based swap); on 020 it was 740ms / 6.8% of the solve. Replaced with a concrete sort.Interface (dualCandByRatio / dual2CandByRatio) — the same pdqsort, same comparator, so the order is byte-identical and results are unchanged: 020 774 nodes / 182619 pivots / same objective, 018/021 identical. Interleaved timing (controls for thermal drift): ~11.55s vs ~11.79s, a consistent ~2% on 020 (new faster in all 5 paired runs). Numerics-neutral — no tree re-roll. All tests + PuLP green.
Follow-up to e9d9386. Microbenchmark (200-elem tie-heavy sort, the ratio test's shape): sort.Slice 6743ns/3allocs, sort.Sort-Interface 3269ns/1alloc, slices.SortFunc 3080ns/0allocs. slices.SortFunc is 2.2x over the original sort.Slice and 6%-faster + zero-alloc over the concrete sort.Interface (whose 1 alloc was interface boxing, x82k+ sorts). Order is byte-identical to sort.Sort — verified across 200 tie-heavy seeds (a throwaway test) and by 020 staying at 774 nodes / 182619 pivots. Drops the concrete sort types. All tests + PuLP green.
…, not barren mid-tree) faceWalk fires ONCE at the root (ok=true), spending all 37711 pivots to find the first incumbent — it is the root incumbent finder, not barren mid-tree dives (prior wording was wrong). CBC's DiveCoefficient does the same job in 706 iterations; the 53x gap is per-variable re-solves forced by the model (RENS batch-rounding fails on 020). No code change.
…48 vs 55266, 1.5x) The prior '40/probe vs 27' compared our per-side rate to CBC's per-variable count (apples to oranges). Replaced with total strong-branch simplex work: 81748 pivots vs CBC's 55266 iterations = 1.5x. No code change.
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.
Clp-faithful engine core: true Forrest-Tomlin + solution-writer / unbounded fixes
Branch delivers the Clp-core rewrite (
docs/rewrite-clp-core.md): Forrest-Tomlinfactorization, sparse factorize, D1 model reduction, 9/9 Cgl generators, DSE
dual — all behind env gates, default engine byte-identical. This PR's headline
work is the true Forrest-Tomlin column update plus two correctness fixes
surfaced by the
../optimizerbattery models. Consolidated results inBenchmarks at the end.
020 vs CBC — parity status (honest)
Node/tree parity: achieved — 774 nodes vs CBC's 833. Wall parity (3.6s):
not achieved, and it is a floor, not a missing fix. The campaign took 020
from 60s → 12.6s → 10.9s (gap to CBC ~16× → ~3×); every remaining lever is
either the Go-vs-C++ per-pivot kernel or a tree-perturbing change that re-rolls
020's roundoff-fragile search.
The 10.9-vs-3.6s wall factors exactly into two locked terms:
s -= cv[k]·y[r]randomgather; ~45% of the solve is the dual BTRAN) vs Clp's C++ kernel. A codegen
floor: bounds-check elimination on the hot loops measured a no-op (the cost
is the memory gather, not the checks).
strong-branch iterations, 1.5×) + the root incumbent dive. faceWalk fires once at the
root and spends 37711 pivots to find the first feasible point where CBC's
DiveCoefficient needs 706 (53×): the model forces incremental per-variable
diving (RENS batch-rounding fails on 020), so faceWalk re-solves once per
fixed integer. CBC's cheaper (crunched/hot-started) re-solves and coefficient
selection are exactly what re-rolls 020's fragile tree. Every volume-cutting
lever confirms it: faceWalk pivot budget → timeout (loses the root incumbent),
CBC_CRUNCH→ 846 nodes,CBC_FREECOL→ 3400 nodes, probe stall/cap/DSE →all worse.
Reaching 3.6s needs a scope decision, not a search/correctness fix: either
accept node-parity + this floor, or authorize a cgo/C-kernel (or SIMD/assembly)
sparse BTRAN/FTRAN to close the 1.65× per-pivot. The tree-safe wins that stay
on by default (gap-scheduled diving,
slices.SortFuncratio test, pooledpropagate) took 020 from 12.6→10.9s without moving a single node.
Simplex: faster dual ratio-test sort (
e9d9386,9aedff3)The Clp long-step / DSE ratio test sorts entering candidates by ascending
ratio every dual pivot.
sort.Slicedid this by reflection — 740ms / 6.8% ofthe 020 solve. Microbenchmark (200-elem tie-heavy sort):
sort.Slice6743ns/3allocs → concrete
sort.Sort3269ns/1alloc →slices.SortFunc3080ns/0allocs. Shipped
slices.SortFunc(2.2× oversort.Slice,zero-alloc). Order is byte-identical to
sort.Slice— verified across 200tie-heavy seeds and by 020 staying at 774 nodes / 182619 pivots. Numerics-
neutral, no tree re-roll.
MIP: three CBC-parity levers — diving, presolve, alloc (
1b5829f,554b68a,74f91ba)Measured the three remaining 020 levers against real CBC 2.10.3 and shipped
what wins on the default; gated what re-rolls 020's proof-fragile tree.
1 — Gap-scheduled diving (
1b5829f, ships on). CBC runs diving heuristicsonly while the gap is open. cbcgo bursted at every 256th node unconditionally;
on 020 the lone mid-tree burst fired with the incumbent already within 0.02 of
the optimum, found nothing, and cost ~26k pivots through the full
rens→faceWalk→fpump→dive fallback chain. Gating the burst on
!closeGap(incumbent >10% from the node bound) skips it — the root burst still always
runs (no incumbent yet). 020 208k→183k pivots, 12.9→10.9s, tree identical
(774 nodes), robustness 5/5; 018/021 byte-identical. Rejected by
measurement: faceWalk internal pivot budget (16×/24× rows no-op, 8× loses the
root incumbent → timeout — the root walk needs its full 37k pivots);
burst-failure backoff (the costly burst is the first mid one, nothing later
to disable).
2 — Presolve: the reduction gap is CoinPresolve column elimination, not
doubletons (
74f91ba, gated). A structural census of the golden modelsfinds 0 duplicate rows, 0 duplicate columns, 0 equality doubletons — every
equality row has 3+ nonzeros, all 2-term rows are inequalities — matching CBC,
which prints 0 substitutions. Yet CBC reduces 020 to 1375 rows / 1399 cols
where cbcgo reaches 2032 / 2421; the ~1000-col gap is 260 free columns + 721
continuous singletons. Free-column removal (CoinPresolve empty-column
removal: fix at the cost-optimal bound, exact elimConst postsolve) ships
opt-in
CBC_FREECOL=1— optimum-preserving and ~2× on 018 (31746→13917pivots, 314→163ms) but the perturbed model re-rolls 020's tree (774→3400
nodes), same fragility class as crunch. Fixing (105 = CBC's 105) and big-M
coefficient strengthening (301 ≈ CBC's 304,
Cgl0010I-style) already matchand ship on by default.
3 — Alloc: pooled propagate queue (
554b68a, ships on, numerics-neutral).propagateallocated its worklist per call and used a reslice-front + appendFIFO that reallocated ~O(64)× per call. Rewrote it as a pooled cap-nr ring
(inQ dedups → at most nr rows queued, so the ring is exact); FIFO order is
byte-preserved → results identical (020 774 nodes / 182619 pivots). 020
total allocs 1878MB→1625MB. Wall is flat on the golden cases (GC ran
concurrently) — a memory/GC-pressure reduction, not a speedup.
Simplex/MIP: strong-branch probe cost vs CBC — Clp crunch probes (
e60547e)Head-to-head against real CBC 2.10.3 on 020 (
-log 3): CBC spends 2032strong probes / 55,266 hot-start iterations — 27 per probe and 56% of all
its simplex work — vs cbcgo's 1022 probes / 82k pivots (80/probe, 50% of
total). Probe economics already match CBC; the absolute wall gap is
per-pivot engine constants. Every CBC hot-start mechanism was then built and
measured on 020:
CBC_CRUNCH=1, ships opt-in): probes solve a row-subset LPwith provably-redundant rows dropped (interval activity inside the row
bounds, Clp's
crunch()test behindsolveFromHotStart; only slack-basicrows drop so the reduced basis stays square by cofactor expansion). Probe
bounds are exactly equal to full-LP probes — not a relaxation guess (a
first slack-basic-only version weakened seeds and blew the tree to 57s).
020 drops ~24% of rows; 021 probe pivots 128→28. Opt-in because the changed
probe roundoff re-rolls 020's tree lottery:
CBC_MAXETAS=24loses itsproof. Defaults stay byte-identical (774 nodes, robustness sweep 5/5).
+0.7s wall), probe stall-exit at 15 degenerate pivots (16/probe but weak
pseudocost seeds → 774→1567 nodes, 31s), probe cap 100→300 (107/probe —
pure degenerate grind, probes never converge), pre-probe refactorize à la
markHotStart(byte-identical trees, neutral wall).Solution()extraction — 312MBof the 020 alloc profile was probe x/rowAct/rc/price nobody read.
pivot split,heuristic budgets' bases) now include the DSE dual's pivots
(
Stats.Dual2); node/sb numbers were blind to dual2 work before.MIP: probing coefficient strengthening + CBC cutoff-increment gap (
abaebf8)Two CBC-parity changes that stabilize the search tree and prove 020 for the
first time. (1) CglProbing coefficient strengthening (CBC's Cgl0003I
"strengthened rows"): while probing binary x_j, one-sided rows tighten their
x_j coefficient and rhs against the probe side's propagated activity — the
implication-aware version of the static big-M tightening; multi-pass as
CglPreProcess. 018's pre-cut root bound jumps 18092 → 18291.44; fix/strengthen
counts match CBC's preprocessing on 020/021. (2) Default absolute gap 1e-5
mirroring CBC's default cutoff increment (
CbcCutoffIncrement);-allow/-ratiooverride. Soundness locked byTestProbeStrengthenSound(random big-M MIPs, sampled feasible points must survive probing).
MIP: in-tree global cut rounds (
3b26247)Every 256 nodes the tree separates the globally-valid cut families (knapsack
cover, clique, zero-half, flow cover, lift-project) at the current node's
fractional point; on success the LP rebuilds and open nodes cold-restart on
the tightened relaxation. This is the globally-valid subset of CBC's in-tree
cutting — Gomory/probing node cuts are basis-/implication-local and would need
per-subtree row lifecycle management. On 020 the rounds separate nothing new
after the root (measured no-op); kept for cover/clique-separable models.
Raising the root cut pivot budget 10×→30× rows measured strictly worse on 020
(vertex-lottery incumbent regression) and stays at 10×.
MIP: reduced-sub-problem RENS as the burst primary (
6fe2392)CBC runs its heuristics as mini branch and bound on reduced problems; cbcgo's
bursts now do the same — RENS (integral-fix + rounding + node-capped sub-MIP)
leads, faceWalk is the fallback. Five earlier demotion attempts measured
wall-negative; under the substituted model the burst order no longer re-rolls
the tree (020 node counts identical per interval either way), so the
CBC-shaped ordering ships neutrally. 020 attribution: faceWalk 200k → 41k
pivots; strong-branch probes are now the top consumer (82k of 205k).
MIP: equality-chain substitution + node-local probing cuts (
506643c)substituteChains (CglPreProcess / CoinPresolve): implied-free continuous
columns substituted out of equality rows — x = (b − rest)/a drops the column
and the row, folding the expression into every other row containing x, with
exact primal/dual postsolve (x back from the pivot row, rewritten-row
activities restored per record, the dropped row's dual from stationarity;
rowMap remaps the reduced row space; reductions chain with singleton
elimination). Default-on (
CBC_SUBST=0disables). Main models substitute fewcolumns (the evcc bounds usually bind) but heuristic sub-MIP clones
substitute hundreds, and the change removes 020's last bad basin: 020 now
proves at 5/5 refactorize intervals, 10.1–26.2s (was 4/5 + one timeout);
defaults 020 12.6s / 774 nodes. probingCutsNode: CglProbing implication
cuts derived under node bounds through the local-cut channel; measured still
net-negative on 020 (activation repair at subtree boundaries), stays behind
CBC_LOCALCUTS.simplex/mip: per-solve IterCap; elimfix + maxEtas 64 default; cut pruning (
6d76217)Takes 020 from 59.8s to 15.6s proven (real CBC 3.6s) with 018/021
unregressed. (1) Per-solve pivot cap (
SetIterCap, honored by the primalloop and the DSE dual; heuristic windows use 2m+200): kills the single-LP
degenerate grinds that made every model transformation a lottery re-roll —
CBC's hot-start/heuristic iteration caps. (2) Fixed-column elimination
default-on + default
CBC_MAXETAS32→64: with the cap in place eliminationis a clean win (018 0.18s, 021 2.2s, 020 proven at 15–32s on 4/5 refactorize
intervals; interval 32 times out, hence the default move — scaling made
higher intervals 021-safe). (3) Local-cut effectiveness pruning: a batch
must lift the node bound or is retracted (rows truncated, activations
stripped, two strikes disable); kept batches drop non-binding cuts. With
pruning
CBC_LOCALCUTS=1now proves 020 (57s — was a timeout) but stillloses to the 16s default, so it stays opt-in.
MIP: local in-tree cuts, RENS sub-MIP fallback, fixed-column elimination (
69bd7a2)The three remaining CBC feature gaps, built and benchmarked; two land gated
where the vertex lottery still punishes them (defaults keep the golden
numbers). (1) Node-local in-tree cuts (
CBC_LOCALCUTS=1): GMI cuts from anode's basis are stored as free rows — vacuous for propagation, heuristics and
the
.solwriter — and activated per subtree via bound overrides on theirlogical variables, inherited by children: the full local-cut lifecycle with no
LP row add/remove. Correct, but net-negative on 020 (degenerate node grind)
pending cut-effectiveness pruning. (2) Burst heuristics as reduced
sub-problems: RENS's failure path is now a node-capped sub-MIP on the
integral-fixed neighborhood via the shared
solveNeighborhoodengine(replacing the LP dive); faceWalk stays the burst primary — five reorderings
measured wall-negative. (3) Fixed-column elimination (
CBC_ELIMFIX=1):LB==UB columns fold into row bounds with exact primal/dual postsolve (020
−364 cols, 021 −643; objectives verified exact); 021 2.9s → 1.7s, but 018/020
regress via a single-LP feasibility-pump grind on the scaled reduced model —
gated until in-solve preemption exists. The pump also gains a
between-iteration pivot budget (no-op on golden paths).
MIP: RINS as a node-capped sub-MIP + polish pivot budget (
56c82f5)RINS now clones the problem, fixes the integers where the node LP and the
incumbent agree (≥half must fix), and solves the reduced neighborhood as a
real sub-MIP (MaxNodes 50, ≤1s, incumbent as MIP start, no nesting) — CBC's
CbcHeuristicRINS design — instead of walking the full LP; it runs only
against a NEW incumbent (CBC semantics). 1-opt polish gets a pivot budget on
top of its time box. New pivot attribution (SOLVER_DEBUG) showed node
re-solves were already cheap on 020 — 12.6 pivots/node vs CBC's 53
iterations/node — and RINS drops 20k → 4.5k pivots; robustness reaches 4/5
intervals proving. Measured and rejected:
strongProbeCap100→25 (fewerpivots, +30s wall) and three faceWalk budget variants (all wall-negative —
the walk pins the good tree path).
True Forrest-Tomlin — O(nnz) row-spike update (
5cdd266, supersedesb561cb4)Two generations of fixes.
b561cb4replaced the original dense trailing-blockre-triangularization (
O(block²)per pivot, 256-row cap → refactorize almostevery pivot) with a sparse Hessenberg sweep. That was still Bartels-Golub in FT
clothing: rows never renumber, so the trailing block's subdiagonal (the old U
diagonals) is never zero and every update appended Θ(m−p) ops to the R-file
— replayed by every subsequent ftran/btran. At
maxEtas=32on 018 (m=725)that's ~11k replayed ops per solve, 29× the eta path's per-pivot cost.
5cdd266implements the real Forrest-Tomlin update: the leaving pivot'srow/column cycle to the last step and the row spike is eliminated against the
existing U rows — O(nnz(row)) R-ops per update. To survive step renumbering
without rewriting the factor, L and the R-file are keyed by immutable orig
rows and U by immutable basis cols; only permutation arrays and slice headers
shift per update. A lazy column-wise U index (
ucRows) makes the leavingcolumn's entry deletion O(nnz(col)).
018 under
CBC_FT=1: 23.1s → 6.7s; 021 per-pivot cost reaches parity withthe eta path (50µs vs 52µs). The remaining FT-vs-eta wall gap is node-basin
placement, not kernel cost — FT stays opt-in.
Solution-writer fix — unnamed cut rows (
14241b9)Branch-and-bound appends cut rows with empty names;
solfile.Writeemitted themas 3-token lines (
2666 0 0), and PuLP'sreadsoldoesl[3]on everyline →
IndexError, so large MIP solves failed to parse entirely (the../optimizer020/021 cases; 018 was spared only because it needs no cuts).PuLP maps by name and ignores unknowns, so unnamed internal rows are dropped —
matching real CBC, whose
.solcarries only the original model.simplex/mip: Clp geometric scaling (
CBC_SCALE) — 021-safe, big speedups (b4ee1dc)The evcc models carry a 1e6 constraint-coefficient range (big-M + D1). Clp
scales every model; cbcgo did not, so 021 carried ~1e-3 feasibility slop and
its proven optimum drifted with any perturbation — at higher
maxEtastheunscaled solve falsely proved a suboptimal 8.676 (a soundness bug).
Added Clp-style geometric row/column scaling, verified line-by-line against
Clp source (
ClpSimplex::createRim/ClpPackedMatrix::scale): objective*columnScale, column bounds*inverseColumnScale, row bounds*rowScale,solution
*columnScale. Scaling is internal to the LP; bounds/solution/duals/tableau unscale at the boundary so mip/cuts work unchanged (CBC/OSI-style).
Integer columns keep
colScale=1(clean rounding); the feasibility pump usesa new original-space objective interface. Key fix: each cut round
rebuilds+rescales the LP, so
WarmSolveExtendedon the pre-rebuild(stale-scaled) basis produced garbage (021 round-1 bound 8.70→10.57, cutting
off the optimum) — cold-solve cut rounds when scaled. Impact when it landed:
018 8.8s→0.4s, 021 15.4s→2.6s and CBC's exact optimum at every
maxEtas,020's incumbent from garbage to CBC's region. Now on by default (
CBC_SCALE=0disables); well-conditioned models stay byte-identical.
simplex: pool the DSE node re-solve workspace (
f7d6921)dual2Run(the DSE dual that drives deep branch-and-bound node re-solves)allocated ~10 fresh slices per warm re-solve. Clp keeps these as persistent
work arrays on
ClpSimplex; pooled them the same way into a per-LPdual2WS(each
simplex.Buildmakes a fresh LP, som/ntare frozen for itslifetime — no size guard). Correctness-neutral.
BenchmarkDual2WarmResolve(warm bound-change re-solve, m=60): 18596 → 11639 ns/op (−37%), 25217 →
8017 B/op (−68%), 40 → 28 allocs/op. On the large
../optimizermodels thenode re-solve wall is dominated by the eta FTRAN/BTRAN so the pooling is in
the noise there (018/020/021 unchanged and correct). Adds an env-gated
(
CBC_CPUPROFILE) dev CPU-profile hook tocmd/cbc.MIP: CBC-quality feasibility pump + pump-first ordering (
27b01a8)The old feasibility pump used pure L1 distance and a dual
WarmSolveafter eachobjective swap (mathematically wrong — dual repair assumes dual feasibility a
cost change breaks), so it converged to a bad basin (evcc 020 incumbent ~42),
and
faceWalkran first on a shared deadline, starving it. Rewritten as theAchterberg-Berthold objective pump: blend the decaying true objective into the
L1 projection, project with a new
PrimalResolve(correct after a cost change),anti-cycle by flipping the most-fractional roundings, and run the pump first
at the root with its own sub-budget (CBC's ordering). On ../optimizer 020 the
incumbent goes 42 → 0.541 (≈ CBC's optimum 0.5583). 018/021 unchanged; full
suite + PuLP green.
MIP: CglPreProcess applied uniformly — CBC/Clp parity (
6b2f460)Preprocessing (binary big-M coefficient strengthening + forcing/redundant-row
removal) was gated on a cbcgo-specific heuristic — the root-LP integer
fractionality. CBC/Clp apply CglPreProcess / presolve uniformly (gated on
structure, not the relaxation). The gate also skipped D1 on the evcc 021 case,
where cbcgo then returned a suboptimal point (8.6837) labelled Optimal — below
its own LP relaxation: a soundness bug. Removed the gate (and the
CBC_D1env,d1Adaptive,d1FracThreshold); D1 now runs on every model. 018 and 021 thenmatched CBC's raw MIP objective (021: 8.70073 vs CBC 8.70087 — was the wrong
8.6837); 020's cut bound (−0.629) matched CBC's region.
Sparser factors — Clp Markowitz least-fill pivoting (
d0a7a8c)rebuildpicked the largest entry in the shortest row. Now, among the row'smax-abs entries (numerically strongest — no stability loss), it takes the
sparsest column by active-row count: Clp/CoinFactorization's Markowitz rule.
On ../optimizer 021 (3312 rows) the average factor L+U nnz drops 8429 → 6436
(~24% sparser), wall-clock neutral. Pivot choice never changes the
elimination math, so correctness is unchanged.
FT solve speedups (
43cb3ba)Profile-driven, correctness-neutral: btran's
U^-Tpush skips zero steps (dualpricing has a near-unit RHS), and
refactorizeno longer builds the sparse-LUst.fevery time under FT — it drives all solves via the FT factor, sost.fis built lazily only as a singular-fallback (was a full
luFactorizeperrefactorize, ~14% of FT solve time). 021 whole-solve under CBC_FT: 9.1s → 7.4s.
Unbounded-ray verification (
98c120e)The primal loop declared
Unboundedthe instant the ratio test found no blockingrow, trusting a possibly-stale factorization. The optimal path already guards
against this with a refactorize+reprice recheck; this adds the symmetric guard
for unboundedness (as CBC/Clp do) — a ray must survive a clean factorization to
be reported. Resets per pivot; genuine unbounded LPs still resolve.
Safety
Default engine unchanged on the golden path:
go test ./...green, PuLPCOIN_CMDTest suite green (78 passed, only the 2 pre-documented failures).
Scaling (
CBC_SCALE), the extra Cgl families (CBC_CGL) and the DSE dualare on by default; Forrest-Tomlin stays opt-in behind
CBC_FT=1.Benchmarks (evcc golden cases, Apple M4; real CBC 2.10.3)
Defaults on both solvers, wall-clock + nodes + objective:
Tree robustness — nodes (and wall) as solve roundoff is perturbed via the
refactorize interval
CBC_MAXETAS∈ {24, 32, 48, 64, 100}:020 root parity: preprocessing fixes 105 = CBC's 105 (~350 rows strengthened
vs CBC's 501); root cut bound −0.884 → −0.664 in 4 rounds vs CBC −0.885 →
−0.658 in 10 passes — within 1% of CBC's closed distance. Remaining gap vs
CBC: root-closing power (CBC ends 018/021 at 0 nodes via CglPreProcess +
pump/mini-B&B) and 020 tree cost. Pivot attribution shows 020's node
re-solves are already cheap (12.6 pivots/node vs CBC's 53 iterations/node);
the bulk is faceWalk (~198k) + strong-branch probes (~112k), both measured
to resist capping (three budget variants wall-negative), plus CBC's ~950
node-local probing cuts that cbcgo's global-only in-tree rounds can't match.