Deutsch
Learning · SAT solvers · measurement practice · September 2026

How CDCL Works — and How to Measure Without Fooling Yourself

What modern SAT solvers actually do, one real run step by step, what each building block buys, and the null measurements that keep an algorithm programmer honest.

Part I

What a SAT solver is asked

A formula in conjunctive normal form is a list of clauses; a clause is a list of literals, each a variable or its negation. The formula is satisfied if every clause has at least one true literal. The question is whether such an assignment exists.

One rule does almost all of the work in practice: unit propagation. If all literals of a clause but one are false, the last one must be true. Setting one variable can force another, which forces a third — a chain reaction that costs nothing to follow.

A modern solver spends most of its time propagating, not searching. Everything else it does is about choosing guesses that make propagation go far, and never repeating a mistake.
Part I · 1

DPLL: guess, propagate, undo

The classic procedure (Davis–Putnam–Logemann–Loveland, 1962) repeats three steps: pick an unassigned variable and guess a value, propagate, and if some clause ends up with all literals false — a conflict — undo the most recent guess and try the other value. That last step, undoing only the latest guess, is called chronological backtracking.

Its weakness: the reason for a conflict often lies several guesses back. DPLL does not know that, so it rediscovers the same conflict in every branch below the guess that actually caused it.

Part I · 2

CDCL: learn from every conflict

Conflict-driven clause learning keeps DPLL's loop and adds a memory. Its building blocks:

1 · The implication graph. Every propagated literal remembers the clause that forced it — its reason. When a conflict occurs, these reasons form a graph back to the guesses responsible.

2 · Conflict analysis (1UIP). Start from the conflicting clause and repeatedly resolve it with the reason of its most recently assigned literal. Resolution merges two clauses that disagree on one variable into a clause implied by both — so the result follows from the formula and is safe to add. Stop as soon as only one literal from the current decision level remains: the first unique implication point. The clause found is the learned clause.

3 · Non-chronological backjumping. Jump back to the second-highest decision level among the learned clause's literals. There the learned clause has exactly one unassigned literal, so propagation immediately forces the other way. Guesses that had nothing to do with the conflict are skipped.

4 · VSIDS. Variables that appear in recent conflicts get their score raised; all scores decay over time. The solver guesses on the highest score — it keeps working where the conflicts are.

5 · Restarts, phase saving, clause deletion. Occasionally throw away all guesses but keep learned clauses and scores (restart); when re-guessing a variable, reuse its last value (phase saving); regularly delete learned clauses that look useless, judged by how many decision levels they span (LBD).

6 · Watched literals. Each clause watches two of its literals and is only looked at when one of those becomes false. That makes propagation cheap enough to run millions of times per second.

Part I · 3

One run, step by step

A small unsatisfiable formula with 5 variables and 10 clauses. The solver below guesses the lowest unassigned variable and tries false first. The run was produced by a traced solver (lehrseite/mini_cdcl.py) that agrees with brute force on 3,000 random formulas.

c1 = (x3 ∨ ¬x4)
c2 = (x3 ∨ x5)
c3 = (x3 ∨ x4 ∨ ¬x5)
c4 = (x2 ∨ ¬x3 ∨ x5)
c5 = (¬x1 ∨ x3)
c6 = (¬x2 ∨ ¬x3)
c7 = (x2 ∨ ¬x3 ∨ ¬x4)
c8 = (x1 ∨ ¬x3 ∨ ¬x5)
c9 = (¬x2 ∨ x3)
c10 = (¬x1 ∨ x2 ∨ ¬x5)
#steplevelwhat happens
1decide1decide ¬x1 (nothing forces it — a guess)
2decide2decide ¬x2 (nothing forces it — a guess)
3decide3decide ¬x3 (nothing forces it — a guess)
4propagate3¬x4 forced by c1 = (x3 ∨ ¬x4)
5propagate3x5 forced by c2 = (x3 ∨ x5)
6conflict3c3 = (x3 ∨ x4 ∨ ¬x5) has every literal false
7learn3learn c11 = (x3) (1UIP analysis, see below)
8backjump0backjump from level 3 to level 0 — skipping 2 decisions
9propagate0x3 forced by c11 = (x3)
10propagate0¬x2 forced by c6 = (¬x2 ∨ ¬x3)
11propagate0¬x4 forced by c7 = (x2 ∨ ¬x3 ∨ ¬x4)
12propagate0x5 forced by c4 = (x2 ∨ ¬x3 ∨ x5)
13propagate0x1 forced by c8 = (x1 ∨ ¬x3 ∨ ¬x5)
14conflict0c10 = (¬x1 ∨ x2 ∨ ¬x5) has every literal false

The analysis at step 7. Three guesses have been made; at level 3, propagation forced two literals, and clause c3 became false. Resolving backwards through the reasons:

start with the conflict clause           (x3 ∨ x4 ∨ ¬x5)
resolve on x5 with its reason c2 (x3 ∨ x5)      →  (x3 ∨ x4)
resolve on x4 with its reason c1 (x3 ∨ ¬x4)     →  (x3)
only one literal of the current level is left: that is the 1UIP clause

The learned clause is (x3) — a single literal. None of the first two guesses played a role, so the solver jumps from level 3 straight to level 0 and sets x3 permanently. At level 0, propagation alone now runs into a conflict: the formula is unsatisfiable, proven with one learned clause. DPLL, undoing one guess at a time, would have revisited the guesses at levels 2 and 1 first.

Part I · 4

What each building block buys — measured

From the ablation in this project: the in-house CDCL solver with individual components switched off, on 150 unsatisfiable random 3-SAT instances each at the threshold. Median conflicts per instance (median over 21 seeds; for the backtracking variant, 41).

configurationn = 100× fulln = 130× full
full CDCL5401.01,6361.0
without phase saving5381.01,6201.0
without restarts5921.11,9731.2
without 1UIP learning2,1524.011,4137.0
static variable order instead of VSIDS2,3504.315,2629.3
static order and no learning (plain backtracking)42,44478.5

On these instances phase saving buys nothing and restarts little. Learning and adaptive branching each buy roughly a factor of 4 at n = 100 and 7–9 at n = 130; removing both costs a factor of about 80. The two are largely redundant — either one recovers most of the benefit. On industrial instances the picture differs; this is one family, two sizes.

Part II

Null measurements: how a number looks when nothing is there

Every measurement mixes signal with the effects of noise, selection and the measuring procedure itself. A null measurement runs the complete pipeline on a case where the true effect is known to be zero, and shows how large an “effect” the pipeline produces on its own. Only what clearly exceeds that is a finding.

The rules below each stopped a concrete wrong result in this project.

rulewhat it catcheswhere it caught something
A against Anoise that looks like an effecta “growth with hardness” (slope +1.2 to +2.0) that a neutral arm with no effect reproduced exactly
renaming / invariance testmeasuring tie-breaking instead of the inputDPLL node counts reacting more to variable names than to a changed literal
positive controla probe that cannot see what it is supposed to finda boundary heuristic that missed closed clause sets an exact enumeration proves exist
reliability firstcorrelations capped by measurement noisea “trend over n” that turned out to be the unreliability of the reference
threshold for the maximumpicking the best of many noisy candidatesr = 0.78 as the best of ten at N = 12, falling to 0.39 on 40 fresh instances
pre-registrationchoosing the analysis after seeing the dataprediction, statistic and decision rule written into the source before each run
replicationone lucky sampler = 0.70 at N = 18 that became 0.38 on a second sample
The question to ask of any number before believing it: what would this number look like if what I think is happening were not happening? Then measure exactly that.
Part III

In everyday programming

None of this needs statistics software. It needs a habit.

1 · Before believing a speed-up: measure A against A

Run the old code against itself with the same harness. The spread you see is what “no change” looks like. A new version counts as faster only if it lands clearly outside that spread. Interleave the runs (old, new, old, new) so that background load hits both equally, and use medians.

import statistics, time

def runtime(f, x):
    t = time.perf_counter(); f(x); return time.perf_counter() - t

def ratios(old, new, inputs, rounds=15):
    """Per input: median(new) / median(old), measured interleaved."""
    out = []
    for x in inputs:
        a, b = [], []
        for _ in range(rounds):
            a.append(runtime(old, x))
            b.append(runtime(new, x))          # interleaved, not all old runs first
        out.append(statistics.median(b) / statistics.median(a))
    return out

null = ratios(sort, sort, inputs)          # A against A: no real difference
real = ratios(sort, sort_new, inputs)      # A against B
print("'effect' produced by noise alone:", min(null), "to", max(null))
print("measured effect:", statistics.median(real))

2 · An answer must not depend on names

Shuffle ids, reorder inputs, rename keys — anything that should not matter. If the result changes, it is a bug. If the runtime changes a lot, your benchmark measures tie-breaking and hash order, not your algorithm.

import random

def test_answer_does_not_depend_on_names(algo, graph, trials=50):
    expected = algo(graph)
    for _ in range(trials):
        new = list(graph.nodes); random.shuffle(new)
        renamed = graph.rename(dict(zip(graph.nodes, new)))
        # holds for results that contain no names themselves (count, length, yes/no);
        # otherwise translate the result back first
        assert algo(renamed) == expected
        # and if the RUNTIME varies a lot: the benchmark measures tie-breaking,
        # not the input -- know this before benchmarking

3 · Positive controls: can the code find what is known to be there?

Test against brute force on small inputs, and against inputs with a planted answer. A test suite that only ever checks “no exception” is a probe without a positive control.

# positive control 1: against brute force on small inputs
for _ in range(10_000):
    x = small_random_input()
    assert fast(x) == brute_force(x), x

# positive control 2: an input with a known, planted answer
x, answer = input_with_planted_solution()
assert fast(x) == answer

4 · Ablation instead of guessing which part helps

When an optimisation consists of several ideas, put each behind a switch. First check that all switches on reproduce the old behaviour exactly. Then turn off one at a time. You will regularly find that one idea carries everything and another does nothing — or that two are redundant, as learning and VSIDS are above.

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Switches:
    cache: bool = True
    early_exit: bool = True
    heuristic: bool = True

def algo(x, s=Switches()):
    ...

# 1. default switches must reproduce the old behaviour exactly (else you measure the refactor)
assert all(algo(x) == algo_before(x) for x in test_cases)

# 2. remove one building block at a time and measure against the full version
for name in ("cache", "early_exit", "heuristic"):
    without = replace(Switches(), **{name: False})
    print(name, statistics.median(ratios(algo, lambda x: algo(x, without), inputs)))

5 · Regression to the mean in performance work

“My change helped the slowest benchmarks most” is what you see even without a change: the cases that were slowest in one measurement are, on average, less slow in the next. Re-run the unchanged code and look at the same plot before drawing that conclusion.

6 · The best of fifty settings is optimistic

Tuning a parameter on one benchmark set and reporting the best value overstates the gain, because the maximum of noisy numbers is biased upwards. Choose on one set, report on another.