Where QF_LIA time actually goes (gprof, 2026-08-21)#
A gprof profile of ten medium-hard QF_LIA instances, taken after stage 5 (patching) landed. Medium-hard means solved in 5-40s by the full-corpus run (Slurm 128125): hard enough that the solver does real work, easy enough that the profile shows a productive path rather than whatever a hopeless instance spins on.
Read the correction section before acting on the optimization proposal. The findings hold – integer stages are 0.1% of runtime, Rational arithmetic dominates, and that is confirmed independently by callgrind. But the
den_ == 1fast path proposed below was measured end-to-end at ~3%, not the ~13% the call counts suggest, for a reason worth reading: the gcds it skips are cheap precisely because the operands there are tiny.
Method, and two traps worth repeating#
Build: cmake -DCMAKE_BUILD_TYPE=None -DCMAKE_CXX_FLAGS="-pg -O2 -g -DNDEBUG".
-DNDEBUG is not optional, and leaving it out silently corrupts the
result. The first run of this profile used -pg -O2 -g – matching the flags
recorded in the pre-existing build-gprof* directories – and produced
Rational::check_invariant as the hottest function in the program at 29.8%
of self time. That function is wrapped entirely in #ifndef NDEBUG, so it is
a no-op in Release and the finding was pure build artifact. It also inflated
total runtime by 56% (153.9s vs 98.5s across the same ten instances) and
distorted every other entry’s share. CMAKE_BUILD_TYPE=None defines no
preprocessor symbols of its own, so NDEBUG has to be passed by hand.
Each instance runs in its own directory, because gmon.out is written to the
current directory and the next run would overwrite it.
Instances: one or more from nec-smt, 20220307-SMPT, 20210219-Dartagnan,
cut_lemmas, dillig, CAV_2009_benchmarks, bofill-scheduling,
2019-cmodelsdiff, mathsat. All ten solved under profiling.
Result#
98.5 self-seconds across ten runs.
component |
self |
share |
|---|---|---|
Rational / GMP arithmetic |
40.4s |
41.0% |
CaDiCaL (SAT) |
23.8s |
24.1% |
|
15.0s |
15.2% |
LRA propagator glue |
9.9s |
10.1% |
other |
9.4s |
9.5% |
LIA stages |
0.06s |
0.1% |
Hottest individual functions:
share |
self |
function |
|---|---|---|
25.1% |
24.69s |
|
9.0% |
8.85s |
|
6.8% |
6.71s |
|
5.7% |
5.58s |
|
5.0% |
4.89s |
|
4.7% |
4.60s |
|
3.3% |
3.25s |
|
Two things this settles#
1. Integer reasoning is not the bottleneck — not even close. Every QF_LIA stage combined (patching, GCD-consistency, Gomory cuts, branch-and-bound, conflict generalization) accounts for 0.1% of self time on medium-hard instances. This is the same conclusion an earlier instrumented run reached from a different direction, when it found 24 of 36 sampled timeouts never execute a single integer round. Further work inside the integer layer cannot move these instances; the time is in Boolean and LRA search.
2. The cost is Simplex pivoting arithmetic. Call-graph attribution over the same ten runs:
Rational::operator*: 844,353,807 calls, 91.8% of them fromLraTableau::pivot(8.0% fromLraTableau::update).Rational::operator+: 821,727,887 calls, 58.4% frompivot.Rational::operator<: 797,003,808 calls, 55.8% frombound_entails, 27.5% fromupdate_violation_status.
So pivot’s row-update inner loop, plus the propagator’s entailment scan, is
where QF_LIA actually spends its time.
The concrete opportunity: over half the gcd work in multiply is provably wasted#
Rational::operator*’s small-form path computes two binary gcds on every
call:
const std::int64_t left = gcd_u64(magnitude(num_), other.den_);
const std::int64_t right = gcd_u64(magnitude(other.num_), den_);
When a denominator is 1, the corresponding gcd is gcd(x, 1) == 1 by
definition and the whole call is wasted. Measured over the same ten instances
(844,353,807 small-form multiplies):
case |
calls |
share |
|---|---|---|
both denominators == 1 (both gcds skippable) |
467,703,359 |
55.4% |
one denominator == 1 (one gcd skippable) |
12,146,199 |
1.4% |
neither (current path is necessary) |
364,504,249 |
43.2% |
56.1% of all gcd calls in multiply are avoidable (947.6M of 1.69B), behind a
test as cheap as den_ == 1.
The distribution is sharply bimodal per instance, which matters for predicting
the payoff: seven of the ten instances are 100% both-integer (nec-smt,
SMPT, bofill-scheduling, cmodelsdiff, mathsat, Dartagnan), where the
fast path removes every gcd in multiply. The other three (cut_lemmas,
dillig, CAV_2009_benchmarks) are almost entirely fractional, where it does
nothing at all. So this is a large win on integer-coefficient families and a
no-op elsewhere — not a uniform speedup.
Correction: the fast path does not pay, and why#
The section above predicts a large win from the den_ == 1 fast path. An
end-to-end A/B says it is worth about 3%, not 13%. Building it and timing
against the unmodified Release binary on the same ten instances, answers
identical throughout: median 1.03x, range 0.98x-1.10x.
Three follow-up measurements explain the gap, and the explanation is more interesting than the optimization.
1. gcd_u64(a, 1) is genuinely expensive – for large a. It has no early
exit for b == 1: it strips a’s trailing zeros, swaps so a == 1, then
grinds b down one subtract-and-shift at a time. Microbenchmarked on random
odd operands: 4.5 iterations / 9.3 ns at 8 bits, rising to 32 iterations /
42 ns at 63 bits. So the premise was sound.
2. But the operands are tiny exactly where the fast path applies. Histogramming the numerator magnitude in every small-form multiply:
instance |
fast path applies? |
numerator width |
|---|---|---|
|
yes, 100% of calls |
98.3% are <= 4 bits (|num| <= 15) |
|
no, 96% of calls are fractional |
17-24 bits dominant (57%), 13-16 bits (34%) |
At 4 bits, gcd_u64(a, 1) costs two or three iterations – a couple of
nanoseconds. The microbenchmark above, run on random 8-to-63-bit values,
overstates the real per-call cost of precisely the calls the fast path removes.
3. The two conditions are anti-correlated, and that is the whole story. The fast path fires where denominators are 1, which is where coefficients are small integers, which is where the gcd was already nearly free. Where gcds are genuinely expensive – large operands – the denominators are not 1, so the fast path does not apply. Eliminating 55% of the gcd calls removes far less than 55% of the gcd work.
What about gprof’s attribution? Cross-checked with callgrind, which counts
instructions deterministically and has no per-call mcount bias. On
dillig/20-20: Rational::operator* 36.3% of instructions, operator+ 24.3%,
from_int128_reduced 13.7%, LraTableau::pivot 4.6%, __divti3 (128-bit
division) 4.1%. So gprof’s ranking was right, and if anything conservative –
Rational arithmetic really is the dominant cost. The error was mine, in the
inference from “55% of calls waste a gcd” to “55% of that cost is recoverable”.
Worth noting separately: the -pg build is 2.3x slower than Release on the
same instance (34.99s vs 15.19s), and that overhead is charged per function
call, so it does inflate functions called a billion times. It did not change
the ranking here, but it is a reason to treat gprof shares as approximate for
this workload and to prefer a sampling profiler where one is available (perf
is blocked in this sandbox by perf_event_paranoid = 4).
The fast path is not implemented. 3% median on integer-coefficient families
and nothing elsewhere does not justify touching Rational, which every logic
depends on. Recorded as a measured and explained dead end, so it is not
rediscovered from the profile and tried again.
Where the real headroom is. Rational arithmetic is ~74% of instructions on
dillig/20-20 (operator* + operator+ + from_int128_reduced), and it is
dominated by the fractional case, where the gcds are unavoidable as the code
stands. The question worth asking next is not how to skip the gcds but why so
many coefficients are fractional at all – scale-canonicalization divides rows
by a leading coefficient, which is what manufactures them. A primitive-integer
row representation would make the both-integer case the common one rather than
the exception. __divti3 at 4.1% is a second, smaller thread.
Reproducing#
cmake -S . -B build-gprof-lia -DCMAKE_BUILD_TYPE=None \
-DCMAKE_CXX_FLAGS="-pg -O2 -g -DNDEBUG"
make -C build-gprof-lia primo -j"$(nproc)"
mkdir -p /tmp/prof && cd /tmp/prof
/workspace/build-gprof-lia/primo --quiet <instance.smt2>
gprof /workspace/build-gprof-lia/primo gmon.out > flat.txt
gprof -q /workspace/build-gprof-lia/primo gmon.out > callgraph.txt