cdclkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cdclkit/__init__.py +152 -0
- cdclkit/__main__.py +10 -0
- cdclkit/brute.py +210 -0
- cdclkit/cli.py +513 -0
- cdclkit/encodings.py +842 -0
- cdclkit/heap.py +180 -0
- cdclkit/model.py +420 -0
- cdclkit/mus.py +159 -0
- cdclkit/native.py +111 -0
- cdclkit/pipeline.py +212 -0
- cdclkit/portfolio.py +683 -0
- cdclkit/preprocess.py +500 -0
- cdclkit/pyeq.py +824 -0
- cdclkit/solver.py +1377 -0
- cdclkit-0.1.0.dist-info/METADATA +136 -0
- cdclkit-0.1.0.dist-info/RECORD +20 -0
- cdclkit-0.1.0.dist-info/WHEEL +5 -0
- cdclkit-0.1.0.dist-info/entry_points.txt +2 -0
- cdclkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- cdclkit-0.1.0.dist-info/top_level.txt +1 -0
cdclkit/portfolio.py
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
|
|
3
|
+
"""A parallel portfolio: run several differently-configured solvers, take the
|
|
4
|
+
first answer.
|
|
5
|
+
|
|
6
|
+
Why a portfolio rather than a parallel search
|
|
7
|
+
---------------------------------------------
|
|
8
|
+
Splitting a SAT search across cores is genuinely hard -- the search tree is
|
|
9
|
+
irregular, and dividing it well requires knowing which subtree is expensive,
|
|
10
|
+
which is the thing you are trying to find out. A *portfolio* sidesteps that: run
|
|
11
|
+
the same formula under different heuristic configurations, and stop when any of
|
|
12
|
+
them finishes. No coordination, no shared state, no load balancing.
|
|
13
|
+
|
|
14
|
+
What it can and cannot buy you, stated up front:
|
|
15
|
+
|
|
16
|
+
* **Satisfiable instances: real gains.** CDCL runtimes on satisfiable instances
|
|
17
|
+
are heavy-tailed -- the same solver with a different seed can be an order of
|
|
18
|
+
magnitude faster or slower, because it either wanders into the right region
|
|
19
|
+
early or does not. Running k configurations takes the *minimum* of k draws
|
|
20
|
+
from that distribution, and the minimum of a heavy-tailed sample is much
|
|
21
|
+
better than its mean.
|
|
22
|
+
* **Unsatisfiable instances: close to nothing.** A refutation has to exhaust the
|
|
23
|
+
search space no matter which heuristic walks it. Diversity changes the
|
|
24
|
+
constant, not the requirement. Expect a speedup near 1.0, and be suspicious
|
|
25
|
+
of any claim otherwise that does not come with a measurement.
|
|
26
|
+
|
|
27
|
+
This module deliberately does **no clause sharing**. That keeps each worker's
|
|
28
|
+
DRAT proof self-contained and independently verifiable: the winner's proof is a
|
|
29
|
+
refutation of the original formula, full stop. Sharing clauses between workers
|
|
30
|
+
would invalidate that -- an imported clause is not RUP in the importing worker's
|
|
31
|
+
proof stream -- and buying throughput with an unverifiable answer is a bad trade
|
|
32
|
+
for this project. See `PLAN.md` for how sharing would have to be handled if it
|
|
33
|
+
is ever added.
|
|
34
|
+
|
|
35
|
+
Threads versus processes
|
|
36
|
+
------------------------
|
|
37
|
+
CPython's GIL is enabled on this interpreter, so threads would serialise on the
|
|
38
|
+
solver's pure-Python inner loop and buy nothing. Processes it is, with the
|
|
39
|
+
`spawn` start method (the default on macOS). Spawn re-imports the module in each
|
|
40
|
+
worker, which is why the worker entry point is a module-level function and the
|
|
41
|
+
formula crosses as plain tuples rather than as a `CNF` object.
|
|
42
|
+
|
|
43
|
+
Asymmetric cores
|
|
44
|
+
----------------
|
|
45
|
+
On Apple Silicon the core count is not the parallelism you get: an M3 Pro has 5
|
|
46
|
+
performance and 6 efficiency cores, and the E-cores run this workload at a
|
|
47
|
+
fraction of P-core throughput. A worker that lands on an E-core takes much
|
|
48
|
+
longer, which does not hurt a first-to-finish portfolio (the P-core workers win
|
|
49
|
+
the race) but does mean **worker count is not speedup**. The default is the
|
|
50
|
+
performance-core count; `jobs` overrides it, and `bench/compare.py` measures both
|
|
51
|
+
rather than trusting either.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import multiprocessing
|
|
57
|
+
import os
|
|
58
|
+
import queue as _queue
|
|
59
|
+
import subprocess
|
|
60
|
+
import sys
|
|
61
|
+
import time
|
|
62
|
+
import warnings
|
|
63
|
+
from typing import Sequence
|
|
64
|
+
|
|
65
|
+
from dratify.cnf import CNF
|
|
66
|
+
from .solver import Config, Solver
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"solve_portfolio",
|
|
70
|
+
"PortfolioResult",
|
|
71
|
+
"default_configs",
|
|
72
|
+
"performance_cores",
|
|
73
|
+
"usable_start_method",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------------------
|
|
78
|
+
# machine topology
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def performance_cores() -> int:
|
|
83
|
+
"""Number of performance cores, falling back to the logical CPU count.
|
|
84
|
+
|
|
85
|
+
On Apple Silicon, `hw.perflevel0.physicalcpu` is the P-core count; on other
|
|
86
|
+
platforms the sysctl is absent and we use `os.cpu_count()`.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
out = subprocess.run(
|
|
90
|
+
["sysctl", "-n", "hw.perflevel0.physicalcpu"],
|
|
91
|
+
capture_output=True, text=True, timeout=5,
|
|
92
|
+
)
|
|
93
|
+
n = int(out.stdout.strip())
|
|
94
|
+
if n > 0:
|
|
95
|
+
return n
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return os.cpu_count() or 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
#: Marks a process as a portfolio worker. Set in the environment the children
|
|
102
|
+
#: inherit, so it is visible even before their `__main__` is re-imported.
|
|
103
|
+
_WORKER_ENV = "CDCLKIT_PORTFOLIO_WORKER"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def in_worker() -> bool:
|
|
107
|
+
"""True when this process is already a portfolio worker.
|
|
108
|
+
|
|
109
|
+
Guards against recursive process explosion. `multiprocessing` with the
|
|
110
|
+
`spawn` method re-imports the parent's `__main__` in every child, so a
|
|
111
|
+
caller who forgets the `if __name__ == "__main__":` guard has their whole
|
|
112
|
+
script re-executed per child -- and if that script calls
|
|
113
|
+
`solve_portfolio`, each child starts its own portfolio, and so on. The
|
|
114
|
+
standard library's answer is "always write the guard", which is correct and
|
|
115
|
+
also not something a library should rely on: the failure mode is a fork
|
|
116
|
+
bomb, not an error message.
|
|
117
|
+
|
|
118
|
+
So children are marked, and a marked process runs sequentially.
|
|
119
|
+
"""
|
|
120
|
+
return os.environ.get(_WORKER_ENV) == "1"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def usable_start_method() -> str | None:
|
|
124
|
+
"""Pick a start method that will actually work in this process.
|
|
125
|
+
|
|
126
|
+
`spawn` (the macOS default) re-imports the parent's `__main__` in every
|
|
127
|
+
child. From a script that is fine. From a REPL, a `python -c`, or a
|
|
128
|
+
heredoc, `__main__` has no importable file and **every child dies on
|
|
129
|
+
startup** -- and a `Pool` cheerfully respawns them, so the run hangs
|
|
130
|
+
forever instead of failing. That is worth detecting rather than
|
|
131
|
+
documenting.
|
|
132
|
+
|
|
133
|
+
Returns the method to use, or None when no multiprocessing method is
|
|
134
|
+
viable and the caller should run sequentially.
|
|
135
|
+
"""
|
|
136
|
+
available = multiprocessing.get_all_start_methods()
|
|
137
|
+
main = sys.modules.get("__main__")
|
|
138
|
+
main_file = getattr(main, "__file__", None)
|
|
139
|
+
main_importable = bool(main_file) and os.path.exists(main_file)
|
|
140
|
+
|
|
141
|
+
if main_importable and "spawn" in available:
|
|
142
|
+
return "spawn"
|
|
143
|
+
# No importable __main__: spawn cannot work, but fork does not re-import
|
|
144
|
+
# anything, so it still can.
|
|
145
|
+
if "fork" in available:
|
|
146
|
+
return "fork"
|
|
147
|
+
if "forkserver" in available and main_importable:
|
|
148
|
+
return "forkserver"
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --------------------------------------------------------------------------
|
|
153
|
+
# configuration diversity
|
|
154
|
+
# --------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def default_configs(n: int) -> list[Config]:
|
|
158
|
+
"""`n` meaningfully different solver configurations.
|
|
159
|
+
|
|
160
|
+
Diversity along the axes that actually change the search trajectory:
|
|
161
|
+
restart policy (when to abandon a region), phase (which half of the space
|
|
162
|
+
to try first), clause minimisation (how strong learnt clauses are), and
|
|
163
|
+
randomisation (tie-breaking). Seeds differ throughout, so even two workers
|
|
164
|
+
with the same policy explore differently.
|
|
165
|
+
|
|
166
|
+
The order matters: index 0 is the plain default, so a 1-worker portfolio
|
|
167
|
+
reproduces sequential behaviour exactly, and each added worker brings the
|
|
168
|
+
next most different configuration rather than a near-duplicate.
|
|
169
|
+
"""
|
|
170
|
+
recipes = [
|
|
171
|
+
dict(),
|
|
172
|
+
# Glucose EMA restarts with saved phases -- the default until the
|
|
173
|
+
# measurement in CHECKPOINT_LOG moved it to Luby plus target phases.
|
|
174
|
+
# It stays as a diversity axis rather than disappearing: it is still
|
|
175
|
+
# the better configuration on some instances, and index 1 used to read
|
|
176
|
+
# `restart="luby"`, which the flip silently turned into a duplicate of
|
|
177
|
+
# index 0. A portfolio worker running the same search as another
|
|
178
|
+
# worker is a wasted core.
|
|
179
|
+
dict(restart="glucose", target_phase=False),
|
|
180
|
+
# Local search with the gate lifted. Ungated walking is 34x on large
|
|
181
|
+
# random satisfiable instances and a loss elsewhere, so it is wrong as
|
|
182
|
+
# a default and right as one worker out of several: the cost is one
|
|
183
|
+
# core, and the other workers are untouched.
|
|
184
|
+
dict(walk_min_conflicts=0, walk_flips=50_000),
|
|
185
|
+
dict(phase_saving=False, init_phase=True),
|
|
186
|
+
dict(restart="luby", luby_base=1000, ccmin="basic"),
|
|
187
|
+
dict(rnd_freq=0.02, var_decay=0.75),
|
|
188
|
+
dict(restart="none", var_decay=0.9),
|
|
189
|
+
dict(phase_saving=False, rnd_freq=0.05),
|
|
190
|
+
dict(restart="luby", luby_base=32, glue_keep=3),
|
|
191
|
+
dict(var_decay=0.95, first_reduce=8000),
|
|
192
|
+
dict(ccmin="none", restart="luby", luby_base=256),
|
|
193
|
+
dict(rnd_freq=0.1, init_phase=True, var_decay=0.7),
|
|
194
|
+
]
|
|
195
|
+
out = []
|
|
196
|
+
for i in range(n):
|
|
197
|
+
kw = dict(recipes[i % len(recipes)])
|
|
198
|
+
# a distinct seed per worker, so duplicated recipes still diverge
|
|
199
|
+
kw["rnd_seed"] = 91648253 + 7919 * i
|
|
200
|
+
out.append(Config(**kw))
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --------------------------------------------------------------------------
|
|
205
|
+
# worker
|
|
206
|
+
# --------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _worker_native(index, nvars, clauses, cfg_kwargs, want_proof):
|
|
210
|
+
"""Run one configuration on the native engine. Returns None if unavailable.
|
|
211
|
+
|
|
212
|
+
Each worker imports the native module independently -- under `spawn` the
|
|
213
|
+
child is a fresh interpreter, so there is nothing to inherit. A worker that
|
|
214
|
+
cannot import it falls back to Python rather than failing the whole
|
|
215
|
+
portfolio, which keeps the dependency-free path working even here.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
import cdclkit_native
|
|
219
|
+
except ImportError:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
from dratify.lits import from_dimacs
|
|
223
|
+
|
|
224
|
+
cfg = Config(**cfg_kwargs)
|
|
225
|
+
t0 = time.perf_counter()
|
|
226
|
+
s = cdclkit_native.Solver(
|
|
227
|
+
nvars,
|
|
228
|
+
restart=cfg.restart, ccmin=cfg.ccmin,
|
|
229
|
+
phase_saving=cfg.phase_saving, init_phase=cfg.init_phase,
|
|
230
|
+
target_phase=cfg.target_phase, target_reset=cfg.target_reset,
|
|
231
|
+
walk_flips=cfg.walk_flips, walk_interval=cfg.walk_interval,
|
|
232
|
+
walk_patience=cfg.walk_patience,
|
|
233
|
+
walk_min_conflicts=cfg.walk_min_conflicts,
|
|
234
|
+
var_decay=cfg.var_decay, var_decay_max=cfg.var_decay_max,
|
|
235
|
+
cla_decay=cfg.cla_decay, luby_base=float(cfg.luby_base),
|
|
236
|
+
first_reduce=cfg.first_reduce, reduce_inc=cfg.reduce_inc,
|
|
237
|
+
glue_keep=cfg.glue_keep, block_restart=cfg.block_restart,
|
|
238
|
+
)
|
|
239
|
+
if want_proof:
|
|
240
|
+
s.enable_proof() # must precede the first clause
|
|
241
|
+
ok = True
|
|
242
|
+
for c in clauses:
|
|
243
|
+
if not s.add_clause(c):
|
|
244
|
+
ok = False
|
|
245
|
+
break
|
|
246
|
+
res = s.solve() if ok else False
|
|
247
|
+
dt = time.perf_counter() - t0
|
|
248
|
+
|
|
249
|
+
stats = {
|
|
250
|
+
"conflicts": s.conflicts,
|
|
251
|
+
"decisions": s.decisions,
|
|
252
|
+
"propagations": s.propagations,
|
|
253
|
+
"restarts": s.restarts,
|
|
254
|
+
"seconds": dt,
|
|
255
|
+
"engine": "native",
|
|
256
|
+
}
|
|
257
|
+
model = list(s.model) if res else None
|
|
258
|
+
steps = None
|
|
259
|
+
if want_proof and not res:
|
|
260
|
+
# normalise to the same internal-literal form MemoryProof uses, so a
|
|
261
|
+
# caller cannot tell which engine produced the proof
|
|
262
|
+
steps = [(k, tuple(from_dimacs(d) for d in lits))
|
|
263
|
+
for k, lits in s.proof_steps()]
|
|
264
|
+
return (index, bool(res), model, steps, stats)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _worker(payload):
|
|
268
|
+
"""Run one configuration. Must be module-level: `spawn` pickles by name."""
|
|
269
|
+
(index, nvars, clauses, cfg_kwargs, want_proof, engine, preprocess) = payload
|
|
270
|
+
|
|
271
|
+
if preprocess:
|
|
272
|
+
# This worker preprocesses first. Measurement says the two strategies
|
|
273
|
+
# win on disjoint instance classes -- a portfolio wins on satisfiable
|
|
274
|
+
# and heavy-tailed instances (rand3(250): 0.08 s against 0.99 s),
|
|
275
|
+
# preprocessing wins on structured unsatisfiable ones (factor, php,
|
|
276
|
+
# colouring). Since workers race in parallel, preprocessing does not
|
|
277
|
+
# have to be chosen *instead of* diversity: it can be one of the
|
|
278
|
+
# diverse strategies, and whichever suits the instance wins.
|
|
279
|
+
from dratify.cnf import CNF
|
|
280
|
+
|
|
281
|
+
f = CNF(nvars)
|
|
282
|
+
for c in clauses:
|
|
283
|
+
f.add(c)
|
|
284
|
+
f.nvars = nvars
|
|
285
|
+
try:
|
|
286
|
+
reduced, unsat, reconstruct, _ = _preprocess_for_worker(f, engine)
|
|
287
|
+
except Exception:
|
|
288
|
+
reduced, unsat, reconstruct = None, False, None
|
|
289
|
+
if unsat:
|
|
290
|
+
return (index, False, None, None,
|
|
291
|
+
{"conflicts": 0, "decisions": 0, "propagations": 0,
|
|
292
|
+
"restarts": 0, "seconds": 0.0,
|
|
293
|
+
"engine": engine, "preprocessed": True})
|
|
294
|
+
if reduced is not None:
|
|
295
|
+
inner = (index, nvars, [list(c) for c in reduced.clauses],
|
|
296
|
+
cfg_kwargs, False, engine, False)
|
|
297
|
+
idx, sat, model, _steps, stats = _worker(inner)
|
|
298
|
+
stats["preprocessed"] = True
|
|
299
|
+
if sat and reconstruct is not None:
|
|
300
|
+
model = list(reconstruct(model))
|
|
301
|
+
# proofs are not returned from a preprocessing worker: the proof
|
|
302
|
+
# would be of the *reduced* formula, and the preprocessing steps
|
|
303
|
+
# that justify the reduction live in this process only
|
|
304
|
+
return (idx, sat, model, None, stats)
|
|
305
|
+
|
|
306
|
+
if engine == "native":
|
|
307
|
+
got = _worker_native(index, nvars, clauses, cfg_kwargs, want_proof)
|
|
308
|
+
if got is not None:
|
|
309
|
+
return got
|
|
310
|
+
# fall through to Python when the native module is missing
|
|
311
|
+
|
|
312
|
+
from dratify.proof import MemoryProof # local import keeps worker startup lean
|
|
313
|
+
|
|
314
|
+
t0 = time.perf_counter()
|
|
315
|
+
proof = MemoryProof() if want_proof else None
|
|
316
|
+
s = Solver(nvars, proof=proof, config=Config(**cfg_kwargs))
|
|
317
|
+
ok = True
|
|
318
|
+
for c in clauses:
|
|
319
|
+
if not s.add_clause(c):
|
|
320
|
+
ok = False
|
|
321
|
+
break
|
|
322
|
+
res = s.solve() if ok else False
|
|
323
|
+
dt = time.perf_counter() - t0
|
|
324
|
+
|
|
325
|
+
stats = {
|
|
326
|
+
"conflicts": s.stats.conflicts,
|
|
327
|
+
"decisions": s.stats.decisions,
|
|
328
|
+
"propagations": s.stats.propagations,
|
|
329
|
+
"restarts": s.stats.restarts,
|
|
330
|
+
"seconds": dt,
|
|
331
|
+
"engine": "python",
|
|
332
|
+
}
|
|
333
|
+
model = list(s.model) if res else None
|
|
334
|
+
steps = proof.steps if (proof is not None and not res) else None
|
|
335
|
+
return (index, bool(res), model, steps, stats)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _preprocess_for_worker(f, engine):
|
|
339
|
+
"""Preprocess inside a worker, preferring the native preprocessor."""
|
|
340
|
+
from .pipeline import _preprocess
|
|
341
|
+
|
|
342
|
+
return _preprocess(f, engine)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _worker_proc(payload, out_queue):
|
|
346
|
+
"""Process entry point: run `_worker` and post the answer."""
|
|
347
|
+
try:
|
|
348
|
+
out_queue.put(_worker(payload))
|
|
349
|
+
except BaseException as e: # never leave the parent waiting on a silent death
|
|
350
|
+
out_queue.put(("error", payload[0], f"{type(e).__name__}: {e}"))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# --------------------------------------------------------------------------
|
|
354
|
+
# result
|
|
355
|
+
# --------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class PortfolioResult:
|
|
359
|
+
"""Outcome of a portfolio run."""
|
|
360
|
+
|
|
361
|
+
__slots__ = ("sat", "model", "proof_steps", "winner", "winner_config",
|
|
362
|
+
"stats", "elapsed", "jobs", "finished", "engine")
|
|
363
|
+
|
|
364
|
+
def __init__(self) -> None:
|
|
365
|
+
self.sat: bool | None = None
|
|
366
|
+
self.model: list[bool] | None = None
|
|
367
|
+
self.proof_steps = None
|
|
368
|
+
self.winner: int = -1
|
|
369
|
+
self.winner_config: Config | None = None
|
|
370
|
+
self.stats: dict = {}
|
|
371
|
+
self.elapsed: float = 0.0
|
|
372
|
+
self.jobs: int = 0
|
|
373
|
+
self.finished: bool = False
|
|
374
|
+
self.engine: str = "python"
|
|
375
|
+
|
|
376
|
+
def __bool__(self) -> bool:
|
|
377
|
+
return bool(self.sat)
|
|
378
|
+
|
|
379
|
+
def report(self) -> str:
|
|
380
|
+
if not self.finished:
|
|
381
|
+
return f"c portfolio: no answer within the budget ({self.jobs} workers)"
|
|
382
|
+
verdict = "SATISFIABLE" if self.sat else "UNSATISFIABLE"
|
|
383
|
+
cfg = self.winner_config
|
|
384
|
+
desc = (f"restart={cfg.restart} ccmin={cfg.ccmin} "
|
|
385
|
+
f"phase_saving={cfg.phase_saving} seed={cfg.rnd_seed}"
|
|
386
|
+
if cfg else "?")
|
|
387
|
+
return (
|
|
388
|
+
f"c portfolio: {self.jobs} workers on the {self.engine} engine, "
|
|
389
|
+
f"{self.elapsed:.3f}s\n"
|
|
390
|
+
f"c winner : worker {self.winner} ({desc})\n"
|
|
391
|
+
f"c {self.stats.get('conflicts', 0)} conflicts, "
|
|
392
|
+
f"{self.stats.get('propagations', 0)} propagations\n"
|
|
393
|
+
f"c verdict : {verdict}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# --------------------------------------------------------------------------
|
|
398
|
+
# native threaded path
|
|
399
|
+
# --------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _config_tuples(configs):
|
|
403
|
+
"""Flatten Configs into the shape the native binding accepts.
|
|
404
|
+
|
|
405
|
+
Dicts rather than tuples. pyo3 stops extracting tuples past 12 elements,
|
|
406
|
+
and a positional tuple mis-binds silently when a field is inserted in the
|
|
407
|
+
middle -- `phase_saving` and `target_phase` are both booleans and adjacent,
|
|
408
|
+
so a swap would run happily and just search differently.
|
|
409
|
+
"""
|
|
410
|
+
return [
|
|
411
|
+
dict(restart=c.restart, ccmin=c.ccmin, phase_saving=c.phase_saving,
|
|
412
|
+
init_phase=c.init_phase, target_phase=c.target_phase,
|
|
413
|
+
target_reset=c.target_reset, walk_flips=c.walk_flips,
|
|
414
|
+
walk_interval=c.walk_interval, walk_patience=c.walk_patience,
|
|
415
|
+
walk_min_conflicts=c.walk_min_conflicts,
|
|
416
|
+
var_decay=c.var_decay, var_decay_max=c.var_decay_max,
|
|
417
|
+
cla_decay=c.cla_decay, luby_base=float(c.luby_base),
|
|
418
|
+
first_reduce=c.first_reduce, reduce_inc=c.reduce_inc,
|
|
419
|
+
glue_keep=c.glue_keep, block_restart=c.block_restart)
|
|
420
|
+
for c in configs
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _native_threaded(formula, cfgs, result, preprocess_workers=0,
|
|
425
|
+
want_proof=False):
|
|
426
|
+
"""Try the native threaded portfolio. Returns True when it answered.
|
|
427
|
+
|
|
428
|
+
Threads instead of processes removes ~60 ms of startup per solve, which on
|
|
429
|
+
short instances was the entire runtime. The native side releases the GIL,
|
|
430
|
+
so the threads genuinely run in parallel.
|
|
431
|
+
|
|
432
|
+
Proofs work here too: each thread carries its own DRAT buffer and the
|
|
433
|
+
winner's is returned. Because threads share no clauses, that buffer is
|
|
434
|
+
already a complete standalone refutation -- there is nothing to merge. It is
|
|
435
|
+
the payoff of the no-sharing decision, and it means the fastest
|
|
436
|
+
configuration is also a certifying one.
|
|
437
|
+
"""
|
|
438
|
+
try:
|
|
439
|
+
import cdclkit_native
|
|
440
|
+
except ImportError:
|
|
441
|
+
return False
|
|
442
|
+
if not hasattr(cdclkit_native, "solve_portfolio"):
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
clauses = [list(c) for c in formula.clauses]
|
|
446
|
+
|
|
447
|
+
# Some threads solve a preprocessed copy instead. Preprocessing is worth
|
|
448
|
+
# 1.3-1.5x on structured instances and a loss on easy ones, and the formula
|
|
449
|
+
# does not say which it is -- so run it as one of the parallel strategies
|
|
450
|
+
# rather than as a decision. When it helps, that thread wins; when it does
|
|
451
|
+
# not, it cost nothing on the critical path.
|
|
452
|
+
alt = None
|
|
453
|
+
reconstruct = None
|
|
454
|
+
if preprocess_workers > 0:
|
|
455
|
+
from .pipeline import _preprocess
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
red, unsat, reconstruct, _ = _preprocess(formula, "native")
|
|
459
|
+
if unsat:
|
|
460
|
+
result.sat = False
|
|
461
|
+
result.model = None
|
|
462
|
+
result.winner, result.winner_config = 0, cfgs[0]
|
|
463
|
+
result.stats = {"conflicts": 0, "engine": "native-threads"}
|
|
464
|
+
result.engine = "native-threads"
|
|
465
|
+
result.finished = True
|
|
466
|
+
return True
|
|
467
|
+
alt = [list(c) for c in red.clauses]
|
|
468
|
+
except Exception:
|
|
469
|
+
alt, reconstruct = None, None
|
|
470
|
+
|
|
471
|
+
out = cdclkit_native.solve_portfolio(
|
|
472
|
+
formula.nvars, clauses, _config_tuples(cfgs),
|
|
473
|
+
alt, preprocess_workers if alt is not None else 0, want_proof)
|
|
474
|
+
if out is None:
|
|
475
|
+
return False
|
|
476
|
+
(winner, clause_set, sat, model, conflicts, decisions, propagations,
|
|
477
|
+
restarts, proof) = out
|
|
478
|
+
if sat and clause_set == 1 and reconstruct is not None:
|
|
479
|
+
model = list(reconstruct(list(model)))
|
|
480
|
+
from dratify.lits import from_dimacs
|
|
481
|
+
|
|
482
|
+
result.sat = sat
|
|
483
|
+
result.model = list(model) if sat else None
|
|
484
|
+
result.proof_steps = (
|
|
485
|
+
[("d" if is_del else "a", tuple(from_dimacs(d) for d in lits))
|
|
486
|
+
for is_del, lits in proof]
|
|
487
|
+
if proof else None
|
|
488
|
+
)
|
|
489
|
+
result.winner = winner
|
|
490
|
+
result.winner_config = cfgs[winner]
|
|
491
|
+
result.stats = {
|
|
492
|
+
"conflicts": conflicts, "decisions": decisions,
|
|
493
|
+
"propagations": propagations, "restarts": restarts,
|
|
494
|
+
"engine": "native-threads",
|
|
495
|
+
}
|
|
496
|
+
result.engine = "native-threads"
|
|
497
|
+
result.finished = True
|
|
498
|
+
return True
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# --------------------------------------------------------------------------
|
|
502
|
+
# driver
|
|
503
|
+
# --------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def solve_portfolio(
|
|
507
|
+
formula: CNF,
|
|
508
|
+
jobs: int | None = None,
|
|
509
|
+
configs: Sequence[Config] | None = None,
|
|
510
|
+
want_proof: bool = False,
|
|
511
|
+
timeout: float | None = None,
|
|
512
|
+
engine: str = "native",
|
|
513
|
+
preprocess_workers: int | None = None,
|
|
514
|
+
) -> PortfolioResult:
|
|
515
|
+
"""Solve `formula` with `jobs` differently-configured workers in parallel.
|
|
516
|
+
|
|
517
|
+
Returns as soon as any worker produces a definitive answer; the rest are
|
|
518
|
+
terminated. Every worker solves the same formula, so whichever answers
|
|
519
|
+
first is authoritative.
|
|
520
|
+
|
|
521
|
+
`jobs=1` runs in-process with no multiprocessing at all, which keeps the
|
|
522
|
+
single-worker path byte-for-byte identical to a plain `Solver` run --
|
|
523
|
+
useful as a control, and it means the default configuration of any caller
|
|
524
|
+
that does not ask for parallelism is unchanged.
|
|
525
|
+
|
|
526
|
+
`engine` selects the per-worker solver. "native" uses the Rust engine when
|
|
527
|
+
the module imports in the worker and falls back to Python otherwise, so the
|
|
528
|
+
default is safe on a machine with no Rust toolchain. Pass "python" to force
|
|
529
|
+
the reference engine -- worth doing when comparing against
|
|
530
|
+
`bench/baseline.json`, since the two engines are bit-exact but only the
|
|
531
|
+
Python one is what the baseline was recorded from.
|
|
532
|
+
"""
|
|
533
|
+
if engine not in ("native", "python"):
|
|
534
|
+
raise ValueError(f"unknown engine {engine!r} (expected native or python)")
|
|
535
|
+
if preprocess_workers is None:
|
|
536
|
+
# Roughly two fifths of the workers preprocess. Measured on the
|
|
537
|
+
# benchmark set at jobs=5 (total seconds over 17 instances):
|
|
538
|
+
# 0 preprocessing workers 1.243
|
|
539
|
+
# 1 1.002
|
|
540
|
+
# 2 0.927
|
|
541
|
+
# 3 0.914
|
|
542
|
+
# Past 2 the gain is marginal and it is bought with diversity, which
|
|
543
|
+
# this benchmark set under-represents -- only 3 of its 17 instances are
|
|
544
|
+
# satisfiable, and diversity is what wins those.
|
|
545
|
+
preprocess_workers = max(1, jobs * 2 // 5) if jobs > 1 else 0
|
|
546
|
+
if want_proof:
|
|
547
|
+
# A preprocessing worker solves the *reduced* formula, so its proof
|
|
548
|
+
# would refute that rather than the original, and the steps justifying
|
|
549
|
+
# the reduction live in this process. Proof runs therefore use plain
|
|
550
|
+
# configurations only, and every thread's buffer refutes the formula
|
|
551
|
+
# the caller actually passed in.
|
|
552
|
+
preprocess_workers = 0
|
|
553
|
+
if jobs is None:
|
|
554
|
+
jobs = performance_cores()
|
|
555
|
+
jobs = max(1, int(jobs))
|
|
556
|
+
cfgs = list(configs) if configs is not None else default_configs(jobs)
|
|
557
|
+
jobs = min(jobs, len(cfgs))
|
|
558
|
+
|
|
559
|
+
result = PortfolioResult()
|
|
560
|
+
result.jobs = jobs
|
|
561
|
+
clauses = [list(c) for c in formula.clauses]
|
|
562
|
+
t0 = time.perf_counter()
|
|
563
|
+
|
|
564
|
+
if in_worker():
|
|
565
|
+
# Already inside a portfolio worker: never fan out again.
|
|
566
|
+
jobs = 1
|
|
567
|
+
cfgs = cfgs[:1]
|
|
568
|
+
result.jobs = 1
|
|
569
|
+
|
|
570
|
+
# Threads first: same design, a thousandth of the startup cost. Skipped
|
|
571
|
+
# for proof runs and for the preprocessing-worker strategy, both of which
|
|
572
|
+
# need the per-process path.
|
|
573
|
+
if jobs > 1 and engine == "native" and not in_worker():
|
|
574
|
+
if _native_threaded(formula, cfgs, result, preprocess_workers, want_proof):
|
|
575
|
+
result.elapsed = time.perf_counter() - t0
|
|
576
|
+
return result
|
|
577
|
+
|
|
578
|
+
if jobs == 1:
|
|
579
|
+
idx, sat, model, steps, stats = _worker(
|
|
580
|
+
(0, formula.nvars, clauses, cfgs[0].as_dict(), want_proof, engine,
|
|
581
|
+
False))
|
|
582
|
+
result.sat, result.model, result.proof_steps = sat, model, steps
|
|
583
|
+
result.winner, result.winner_config, result.stats = idx, cfgs[0], stats
|
|
584
|
+
result.engine = stats.get("engine", engine)
|
|
585
|
+
result.elapsed = time.perf_counter() - t0
|
|
586
|
+
result.finished = True
|
|
587
|
+
return result
|
|
588
|
+
|
|
589
|
+
method = usable_start_method()
|
|
590
|
+
if method is None:
|
|
591
|
+
warnings.warn(
|
|
592
|
+
"no usable multiprocessing start method; running the portfolio "
|
|
593
|
+
"sequentially with the first configuration",
|
|
594
|
+
RuntimeWarning, stacklevel=2,
|
|
595
|
+
)
|
|
596
|
+
return solve_portfolio(formula, jobs=1, configs=cfgs[:1],
|
|
597
|
+
want_proof=want_proof, timeout=timeout,
|
|
598
|
+
engine=engine, preprocess_workers=0)
|
|
599
|
+
|
|
600
|
+
ctx = multiprocessing.get_context(method)
|
|
601
|
+
# The last `preprocess_workers` slots preprocess first; the rest race with
|
|
602
|
+
# plain configuration diversity.
|
|
603
|
+
payloads = [
|
|
604
|
+
(i, formula.nvars, clauses, cfgs[i].as_dict(), want_proof, engine,
|
|
605
|
+
i >= jobs - preprocess_workers)
|
|
606
|
+
for i in range(jobs)
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
# Explicit processes rather than a Pool: a Pool silently respawns workers
|
|
610
|
+
# that die at startup, which turns a configuration error into an infinite
|
|
611
|
+
# hang. With plain processes, "everyone died" is observable.
|
|
612
|
+
out: multiprocessing.Queue = ctx.Queue()
|
|
613
|
+
procs = [ctx.Process(target=_worker_proc, args=(p, out), daemon=True)
|
|
614
|
+
for p in payloads]
|
|
615
|
+
# Mark the environment the children inherit, then restore ours. This has
|
|
616
|
+
# to happen around `start()` rather than inside the worker: under `spawn`
|
|
617
|
+
# the child re-imports `__main__` *before* the worker function runs, and
|
|
618
|
+
# that re-import is exactly what has to be stopped from fanning out again.
|
|
619
|
+
previous = os.environ.get(_WORKER_ENV)
|
|
620
|
+
os.environ[_WORKER_ENV] = "1"
|
|
621
|
+
try:
|
|
622
|
+
for p in procs:
|
|
623
|
+
p.start()
|
|
624
|
+
finally:
|
|
625
|
+
if previous is None:
|
|
626
|
+
os.environ.pop(_WORKER_ENV, None)
|
|
627
|
+
else:
|
|
628
|
+
os.environ[_WORKER_ENV] = previous
|
|
629
|
+
|
|
630
|
+
deadline = (time.monotonic() + timeout) if timeout else None
|
|
631
|
+
winner = None
|
|
632
|
+
errors: list[str] = []
|
|
633
|
+
try:
|
|
634
|
+
while True:
|
|
635
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
636
|
+
break
|
|
637
|
+
try:
|
|
638
|
+
item = out.get(timeout=0.05)
|
|
639
|
+
except _queue.Empty:
|
|
640
|
+
if not any(p.is_alive() for p in procs):
|
|
641
|
+
try:
|
|
642
|
+
item = out.get_nowait()
|
|
643
|
+
except _queue.Empty:
|
|
644
|
+
break
|
|
645
|
+
else:
|
|
646
|
+
continue
|
|
647
|
+
if item and item[0] == "error":
|
|
648
|
+
errors.append(f"worker {item[1]}: {item[2]}")
|
|
649
|
+
continue
|
|
650
|
+
winner = item
|
|
651
|
+
break
|
|
652
|
+
finally:
|
|
653
|
+
for p in procs:
|
|
654
|
+
if p.is_alive():
|
|
655
|
+
p.terminate()
|
|
656
|
+
for p in procs:
|
|
657
|
+
p.join(timeout=5)
|
|
658
|
+
out.close()
|
|
659
|
+
out.join_thread()
|
|
660
|
+
|
|
661
|
+
result.elapsed = time.perf_counter() - t0
|
|
662
|
+
if winner is None:
|
|
663
|
+
if errors:
|
|
664
|
+
raise RuntimeError(
|
|
665
|
+
"every portfolio worker failed:\n " + "\n ".join(errors))
|
|
666
|
+
if deadline is None:
|
|
667
|
+
# Workers died without reporting -- almost always a start-method
|
|
668
|
+
# problem the detector missed. Degrade rather than return nothing.
|
|
669
|
+
warnings.warn(
|
|
670
|
+
"portfolio workers exited without an answer; falling back to "
|
|
671
|
+
"a sequential solve", RuntimeWarning, stacklevel=2,
|
|
672
|
+
)
|
|
673
|
+
return solve_portfolio(formula, jobs=1, configs=cfgs[:1],
|
|
674
|
+
want_proof=want_proof, timeout=timeout,
|
|
675
|
+
engine=engine, preprocess_workers=0)
|
|
676
|
+
return result # genuine timeout
|
|
677
|
+
|
|
678
|
+
idx, sat, model, steps, stats = winner
|
|
679
|
+
result.sat, result.model, result.proof_steps = sat, model, steps
|
|
680
|
+
result.winner, result.winner_config, result.stats = idx, cfgs[idx], stats
|
|
681
|
+
result.engine = stats.get("engine", engine)
|
|
682
|
+
result.finished = True
|
|
683
|
+
return result
|