Skip to content

Fix, simplify and speed up Boolean function evaluation - #636

Merged
julianspeith merged 6 commits into
masterfrom
feature/boolean_function_performance
Aug 13, 2026
Merged

Fix, simplify and speed up Boolean function evaluation#636
julianspeith merged 6 commits into
masterfrom
feature/boolean_function_performance

Conversation

@julianspeith

@julianspeith julianspeith commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Found while investigating why HAWKEYE S-box identification was slow on the OpenTitan benchmark: a sample showed 96% of the run inside BooleanFunction::compute_truth_table(). Digging into that turned up three correctness problems in constant folding, a large amount of avoidable work, and a gap in the simplification rules.

Correctness

Additions and subtractions were silently truncated to 32 bit. Add and Sub masked their result with 0xffffffff before truncating it to the width of the operands, so anything wider lost its upper bits. A 33 bit 2^31 + 2^31 returned 0 instead of 2^32, and a 33 bit 0 - 1 returned 0xffffffff instead of 0x1ffffffff. The mask was also redundant, Const() already takes exactly as many bits as the operands are wide.

Eq overclaimed on undefined bits. 0b00X1 == 0b0001 returned a definite 0, although the two would be equal if that X turned out to be zero. It now returns X, unless another bit already tells the values apart. That is how Ult, Ule, Slt and Sle already behaved.

Sdiv, Udiv, Srem and Urem were not implemented and made evaluation of any function containing them fail. They are translated to bvsdiv, bvudiv, bvsrem and bvurem when handed to an SMT solver, so the folding follows those definitions, including a division by zero yielding all ones as the quotient and the dividend as the remainder. Restoring long division is used rather than 64 bit integer arithmetic, so the operations work at any width like Mul does.

Simplification of the word level operations

BooleanFunction::simplify() runs the rule set and then hands the result to ABC. ABC works on single bits, so it collapses things like A ^ (A ^ B) even though there is no rule for it, but it cannot reach the word level operations. Those are exactly the ones the rule set covered least: Zext and Sext had no rules at all, Slice had two, each comparison had one.

None of these was simplified before:

ZEXT(A, |A|)                 =>  A
SEXT(A, |A|)                 =>  A
ZEXT(ZEXT(A, n), m)          =>  ZEXT(A, m)
SEXT(SEXT(A, n), m)          =>  SEXT(A, m)
SLICE(SLICE(A, i, j), k, l)  =>  SLICE(A, i+k, i+l)
SLICE(CONCAT(A, B), i, j)    =>  SLICE(B, i, j) or SLICE(A, i-|B|, j-|B|)
SLICE(ZEXT(A, n), i, j)      =>  0               for i >= |A|
SLICE(ZEXT(A, n), i, j)      =>  SLICE(A, i, j)  for j < |A|
SLICE(SEXT(A, n), i, j)      =>  SLICE(A, i, j)  for j < |A|
0 <=u A                      =>  1
A <=u 111...1                =>  1
111...1 <u A                 =>  0
A == ~A                      =>  0
A == 1, A == 0               =>  A, ~A     for a single bit
ITE(A, 1, 0), ITE(A, 0, 1)   =>  A, ~A     for a single bit

Only extensions of the same kind collapse, ZEXT(SEXT(A, n), m) is not ZEXT(A, m). A slice of a concatenation or of an extension is only reduced when it falls entirely into one part, a slice crossing the boundary would not get shorter.

This mainly helps the word level users such as module_identification, solve_fsm and z3_utils. It does nothing for the truth table path, which does not go through simplify() at all.

Performance

Evaluating a function walks its nodes and hands each to simplify(), which builds a std::vector<BooleanFunction> of operands and returns a BooleanFunction. Each of those owns a vector of nodes whose nodes own a string and a vector of values, so one AND of two bits costs several heap allocations. 63% of the evaluation went to malloc and free.

When every variable is bound to a constant, which is always the case for a truth table, no sub-expression can survive and that detour is unnecessary. SymbolicExecution now folds such a function on a stack of plain values, falling back to the general path as soon as a variable is not constant. To avoid a second implementation of the operator semantics, the switch that applies a node to constant operands moved into ConstantPropagation::fold(), shared by the new path and by constant_propagation().

Resolving variables goes through the new SymbolicState::get_bindings(). Using get() per variable built a Boolean function as the key and looked it up in a std::map keyed by whole Boolean functions, comparing them node by node and therefore string by string.

Measured on a 50 node function over 8 variables, 256 rows per truth table:

ms per truth table
before 5.17
this PR 1.94 2.7x
with the changes on bugfix/misc too 1.64 3.15x

Tests

evaluate() was only covered for Var, Not, And, Or, Xor, Add, Sub and Mul. Added coverage for everything the folding touches: shifts and rotates including shifting by at least the bit width, slice, concat, zero and sign extension, all five comparisons using bit patterns that compare the other way round once read as signed, Ite, and the propagation of undefined values through the bitwise and arithmetic operations.

The four new operations are checked against reference implementations of their SMT-LIB definitions for every pair of 4 bit operands including every division by zero, plus an 80 bit division to cover the arbitrary width path. Factory level tests cover the four factory functions themselves.

The new simplification rules are checked by enumerating every assignment of the variables and comparing the value before and against after simplification, rather than by asserting one particular output shape, plus a check that anything was simplified at all.

All operator tests were written against the unmodified code first, so that they characterise the existing behaviour rather than the new one, except where this PR deliberately changes it.

Note on merge order

This branch and bugfix/misc both touch symbolic_execution.cpp, the latter in the operand loop of constant_propagation() and this one by rewriting that function to delegate to fold(). Whichever lands second needs a small manual resolution.

🤖 Generated with Claude Code

julianspeith and others added 5 commits August 12, 2026 12:18
Three corrections to constant folding of Boolean functions, all found while
adding evaluation tests for the operators that had none.

Addition and subtraction masked their result to 32 bit before truncating it to
the width of the operands, so every addition or subtraction of 33 to 64 bit
operands silently lost its upper bits. A 33 bit 2^31 + 2^31 returned 0 instead
of 2^32, and a 33 bit 0 - 1 returned 0xffffffff instead of 0x1ffffffff. The
mask is not only too narrow but redundant, since Const() already takes exactly
as many bits as the operands are wide.

Eq returned a definite "not equal" when an undefined bit was involved, even
though the two values would have been equal had that bit turned out the other
way. It now returns X in that case, unless another bit already tells the values
apart, which is how the other comparisons already behave.

Sdiv, Udiv, Srem and Urem were not implemented and made evaluation of any
function containing them fail. They are translated to bvsdiv, bvudiv, bvsrem
and bvurem when handed to an SMT solver, so the folding follows those
definitions, including a division by zero yielding all ones as the quotient and
the dividend as the remainder. Restoring long division is used rather than 64
bit integer arithmetic, so that the operations work at any width like Mul does.

The tests evaluate the four new operations against reference implementations of
the SMT-LIB definitions for every pair of 4 bit operands, and cover the
operators that had no evaluation coverage at all before: shifts, rotates,
slice, concat, zero and sign extension, the comparisons, Ite, and the
propagation of undefined values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the four factory functions themselves rather than their evaluation: that
they accept operands and a result of equal size, produce a node of the right
type and size over the right variables, and reject operands that differ in size
from each other or from the result.

The Python bindings for Sdiv, Udiv, Srem and Urem, including the node type
constants, already exist and work, verified by hand against the new constant
folding. They are not covered by tests/python_binding/test_boolean_function.py
since that file is not referenced by the build or by any workflow and still
calls get_variables(), get_truth_table(), is_constant_zero(), to_dnf() and
optimize(), none of which are bound any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Evaluating a Boolean function walks its nodes and hands each one to
SymbolicExecution::simplify(), which builds a std::vector<BooleanFunction> of
operands and returns a BooleanFunction result. Each of those owns a vector of
nodes whose nodes own a string and a vector of values, so computing a single
AND of two bits costs several heap allocations. A sample of a HAWKEYE S-box
identification run spent 63% of the evaluation in malloc and free.

Whenever every variable of a function is bound to a constant, which is the case
for anything computing a truth table, no sub-expression can survive and the
whole detour through Boolean functions is unnecessary. SymbolicExecution
therefore folds such a function on a stack of plain values and only falls back
to the general path when a variable turns out not to be constant.

To avoid a second implementation of the operator semantics, the switch that
applies a node to constant operands moved into ConstantPropagation::fold(),
shared by the new path and by constant_propagation(), which wraps its result
back into a Boolean function as before. The 18 helpers of that namespace now
return the value vector they were computing all along instead of wrapping it.
They are local to the translation unit, no public signature changes.

Resolving the variables is now done through SymbolicState::get_bindings() as
well. Going through get() per variable built a Boolean function to use as the
key and looked it up in a std::map keyed by whole Boolean functions, comparing
them node by node and therefore string by string.

Measured on a 50 node function over 8 variables, 5.17 ms per truth table before
and 1.94 ms after, 2.7x. Together with the changes already on the misc branch
it comes to 1.64 ms, 3.15x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reference implementations of the SMT-LIB division semantics let the
compiler deduce their return type. On macOS that works out, u64 and the
unsigned long long of the ~0ull and 1ull literals are the same type there. On
Linux u64 is unsigned long, so the branches of the signed variants deduced
unsigned long from one helper and unsigned long long from another, which GCC
rejects:

  error: inconsistent types 'long unsigned int' and 'long long unsigned int'
  deduced for lambda return type

Spelling out u64 as the return type fixes it. The loop over the bit widths of
the wide arithmetic test no longer relies on a narrowing conversion either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BooleanFunction::simplify() runs the rule set of SymbolicExecution::simplify()
and then hands the result to ABC. ABC works on single bits, so it collapses
things like A ^ (A ^ B) even though the rule set has no rule for it, but it
cannot reach the word level operations. Those are exactly the ones the rule set
covers least: Zext and Sext had no rules at all, Slice had two and each
comparison had one.

None of the following was simplified before:

  ZEXT(A, |A|)                 =>  A
  SEXT(A, |A|)                 =>  A
  ZEXT(ZEXT(A, n), m)          =>  ZEXT(A, m)
  SEXT(SEXT(A, n), m)          =>  SEXT(A, m)
  SLICE(SLICE(A, i, j), k, l)  =>  SLICE(A, i+k, i+l)
  SLICE(CONCAT(A, B), i, j)    =>  SLICE(B, i, j) or SLICE(A, i-|B|, j-|B|)
  0 <=u A                      =>  1
  A <=u 111...1                =>  1
  111...1 <u A                 =>  0
  A == ~A                      =>  0
  A == 1, A == 0               =>  A, ~A     for a single bit
  ITE(A, 1, 0), ITE(A, 0, 1)   =>  A, ~A     for a single bit

Only extensions of the same kind collapse, ZEXT(SEXT(A, n), m) is not
ZEXT(A, m). A slice of a concatenation is only reduced when it falls entirely
into one of the two halves.

The tests check that simplification does not change the value of a function for
any assignment of its variables, rather than that it produces one particular
shape, and that something was simplified at all.

Also fixes the rule comments of Urem, which were copied from Srem and claimed to
describe the signed operation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@julianspeith julianspeith changed the title Fix and speed up constant folding of Boolean functions Fix, simplify and speed up Boolean function evaluation Aug 12, 2026
An extension leaves the bits of the original value untouched and pads above
them, so a slice that does not cross the boundary only depends on one of the
two parts:

  SLICE(ZEXT(X, n), i, j)  =>  0             for i >= |X|, the range is padding
  SLICE(ZEXT(X, n), i, j)  =>  SLICE(X, i, j) for j < |X|
  SLICE(SEXT(X, n), i, j)  =>  SLICE(X, i, j) for j < |X|

A slice that crosses the boundary is left alone. For a zero extension it would
become a zero extension of a slice, and for a sign extension the padding is the
replicated sign bit rather than a constant, so neither case gets shorter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@joern274
joern274 enabled auto-merge August 12, 2026 12:05
@julianspeith
julianspeith disabled auto-merge August 13, 2026 11:40
@julianspeith
julianspeith merged commit 89332e5 into master Aug 13, 2026
4 checks passed
@julianspeith
julianspeith deleted the feature/boolean_function_performance branch August 13, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant