Fix, simplify and speed up Boolean function evaluation - #636
Merged
Conversation
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>
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
enabled auto-merge
August 12, 2026 12:05
julianspeith
disabled auto-merge
August 13, 2026 11:40
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.
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.
AddandSubmasked their result with0xffffffffbefore truncating it to the width of the operands, so anything wider lost its upper bits. A 33 bit2^31 + 2^31returned 0 instead of2^32, and a 33 bit0 - 1returned0xffffffffinstead of0x1ffffffff. The mask was also redundant,Const()already takes exactly as many bits as the operands are wide.Eqoverclaimed on undefined bits.0b00X1 == 0b0001returned a definite0, although the two would be equal if thatXturned out to be zero. It now returnsX, unless another bit already tells the values apart. That is howUlt,Ule,SltandSlealready behaved.Sdiv,Udiv,SremandUremwere not implemented and made evaluation of any function containing them fail. They are translated tobvsdiv,bvudiv,bvsremandbvuremwhen 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 likeMuldoes.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 likeA ^ (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:ZextandSexthad no rules at all,Slicehad two, each comparison had one.None of these was simplified before:
Only extensions of the same kind collapse,
ZEXT(SEXT(A, n), m)is notZEXT(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_fsmandz3_utils. It does nothing for the truth table path, which does not go throughsimplify()at all.Performance
Evaluating a function walks its nodes and hands each to
simplify(), which builds astd::vector<BooleanFunction>of operands and returns aBooleanFunction. 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 tomallocandfree.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.
SymbolicExecutionnow 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 intoConstantPropagation::fold(), shared by the new path and byconstant_propagation().Resolving variables goes through the new
SymbolicState::get_bindings(). Usingget()per variable built a Boolean function as the key and looked it up in astd::mapkeyed 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:
bugfix/misctooTests
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/miscboth touchsymbolic_execution.cpp, the latter in the operand loop ofconstant_propagation()and this one by rewriting that function to delegate tofold(). Whichever lands second needs a small manual resolution.🤖 Generated with Claude Code