3
0
Fork 0
mirror of https://github.com/Z3Prover/z3 synced 2026-07-24 07:52:33 +00:00
z3/src/ast/rewriter/quant_hoist.cpp
Michael Tautschnig 09aaadf963
Sequence AST-creating arguments in rewriters for cross-compiler determinism (#10165)
### Problem

The order of evaluation of function arguments is unspecified in C++
(arguments are indeterminately sequenced since C++17). Compilers use
this freedom differently:

```c++
static int f(int i) { printf("%d ", i); return i; }
static void g(int, int, int) { printf("\n"); }
int main() { g(f(1), f(2), f(3)); }
```
| compiler/target | output |
|---|---|
| gcc 13, x86_64 | `3 2 1` |
| gcc 13, aarch64 | `1 2 3` |
| clang 18, x86_64 | `1 2 3` |

Z3 has many call sites where **two or more arguments each create AST
nodes**, e.g. (before this PR, `bv_rewriter.cpp:876`):

```c++
result = m.mk_ite(c, m_mk_extract(high, low, t), m_mk_extract(high, low, e));
```

The two extract nodes are hash-consed and receive their AST ids in
evaluation order, so the id assignment differs between
compilers/targets. AST ids feed heuristic tie-breaking throughout the
solver (`bool_rewriter`'s `m_order_eq` equality-operand ordering,
id-based sorts in `array_rewriter`, case-split ordering, ...), so
**byte-identical input takes different solver paths depending on the
compiler and architecture z3 was built with**.

### Evidence

Investigated while chasing cross-platform proof-time instability in
CBMC/mldsa-native CI (diffblue/cbmc#8991), on byte-identical ~12 MB SMT2
instances (bit-vectors + arrays + quantifiers), with the `string_hash`
fix from #10163 applied to isolate this effect. Z3 4.15.3, gcc 13 on
x86_64 Linux and aarch64 Linux (Graviton):

* one instance: **17 s on x86_64 vs 1633 s on aarch64** (both `unsat`; a
sibling instance shows the reverse direction). Run-to-run within one
host: ±1 %.
* Instrumenting `ast_manager::register_node_core` with an order
fingerprint (running hash over `(node hash, node id)`) shows both
architectures construct **identical AST sequences up to registration
#41,789**, where x86_64 creates `(extract[0:0] #xFFFFFFFF)` before
`(extract[0:0] #xFFFFFFFE)` and aarch64 the other way around — from
identical call stacks at the `mk_ite`-over-two-`mk_extract` site quoted
above. All divergence between the two hosts flows from such events
(pointer/ASLR effects experimentally excluded: fingerprints are
invariant under `setarch -R` and across repeated runs).
* Sequencing that one site by hand moved the first divergence to
#248,118 — the analogous `mk_ite(c, mk_select(...), mk_select(...))`
site in `array_rewriter.cpp`. Sequencing that one, too, moved it to
#248,411, inside `nnf:👿:process_iff_xor` — i.e. the next layer of
the same onion.
* With the whole `ast/rewriter` layer swept (this PR), the instrumented
builds produce **identical AST construction traces on both architectures
throughout the entire rewriter phase** of this 546k-line industrial
instance; the first divergence left is the NNF one.

### Fix

Following the precedent of 37904b9e8, e113d39aa, 360193098, 93ff8c76d,
9b88aaf13 ("parameter evaluation order", `bool_rewriter`/`seq_rewriter`)
and the existing comments in `seq_rewriter.cpp` ("introduce temporaries
to ensure deterministic evaluation order..."), this PR hoists
AST-creating arguments into named temporaries with a defined evaluation
order, across `src/ast/rewriter/` — 126 call sites in 17 files. The
transformation is purely sequencing: it selects one of the two valid C++
evaluation orders and makes it the same everywhere. (Temporaries are raw
pointers in rewriter-local scope, matching the precedent commits;
nothing can trigger GC between creation and consumption.)

The sites were found with a small AST-argument scanner (statement-level
call sites whose argument list contains ≥ 2 top-level arguments that
each contain an AST-creating call); I am happy to share/contribute the
script. Known remaining work, deliberately out of scope here to keep the
diff reviewable:

* 41 sites in `src/ast/rewriter/` that need manual treatment (inside
`if` conditions, ternaries, or multi-statement expressions) — list
available on request;
* `src/ast/normal_forms/nnf.cpp` (`process_iff_xor`, proven divergent by
the trace above), ~7 sites in `src/ast/simplifiers/`, ~3 in
`src/ast/converters/`, ~9 in `src/ast/`;
* other theory/solver layers (`src/smt/`, `src/sat/`, ...) — divergences
there only matter after search starts, where paths have usually already
split, but a full sweep would be needed for bit-reproducibility across
compilers.

Together with #10163, this is a step towards z3 builds whose behaviour
does not depend on the compiler or target architecture — which matters
for verification CI that runs identical proofs on heterogeneous
platforms and expects comparable runtimes.

---------

Co-authored-by: Kiro <kiro-agent@users.noreply.github.com>
2026-07-21 19:03:02 -07:00

332 lines
12 KiB
C++

/*++
Copyright (c) 2010 Microsoft Corporation
Module Name:
quant_hoist.cpp
Abstract:
Quantifier hoisting utility.
Author:
Nikolaj Bjorner (nbjorner) 2010-02-19
Revision History:
Hoisted from quant_elim.
--*/
#include "ast/rewriter/quant_hoist.h"
#include "ast/expr_functors.h"
#include "ast/ast_smt_pp.h"
#include "ast/rewriter/bool_rewriter.h"
#include "ast/rewriter/var_subst.h"
#include "ast/ast_pp.h"
#include "ast/rewriter/ast_counter.h"
#include "ast/rewriter/expr_safe_replace.h"
//
// Bring quantifiers of common type into prenex form.
//
class quantifier_hoister::impl {
ast_manager& m;
bool_rewriter m_rewriter;
public:
impl(ast_manager& m) :
m(m),
m_rewriter(m)
{}
void operator()(expr* fml, app_ref_vector& vars, bool& is_fa, expr_ref& result, bool use_fresh, bool rewrite_ok) {
quantifier_type qt = Q_none_pos;
pull_quantifier(fml, qt, vars, result, use_fresh, rewrite_ok);
TRACE(qe_verbose,
tout << mk_pp(fml, m) << "\n";
tout << mk_pp(result, m) << "\n";);
SASSERT(is_positive(qt));
is_fa = (Q_forall_pos == qt);
}
void pull_exists(expr* fml, app_ref_vector& vars, expr_ref& result, bool use_fresh, bool rewrite_ok) {
quantifier_type qt = Q_exists_pos;
pull_quantifier(fml, qt, vars, result, use_fresh, rewrite_ok);
TRACE(qe_verbose,
tout << mk_pp(fml, m) << "\n";
tout << mk_pp(result, m) << "\n";);
}
void pull_quantifier(bool is_forall, expr_ref& fml, app_ref_vector& vars, bool use_fresh, bool rewrite_ok) {
quantifier_type qt = is_forall?Q_forall_pos:Q_exists_pos;
expr_ref result(m);
pull_quantifier(fml, qt, vars, result, use_fresh, rewrite_ok);
TRACE(qe_verbose,
tout << mk_pp(fml, m) << "\n";
tout << mk_pp(result, m) << "\n";);
fml = std::move(result);
}
void extract_quantifier(quantifier* q, app_ref_vector& vars, expr_ref& result, bool use_fresh) {
unsigned nd = q->get_num_decls();
for (unsigned i = 0; i < nd; ++i) {
sort* s = q->get_decl_sort(i);
symbol const& sym = q->get_decl_name (i);
app* a = use_fresh ? m.mk_fresh_const(sym.str ().c_str (), s)
: m.mk_const (sym, s);
vars.push_back(a);
}
expr * const * exprs = (expr* const*) (vars.data() + vars.size()- nd);
result = instantiate(m, q, exprs);
}
unsigned pull_quantifier(bool _is_forall, expr_ref& fml, ptr_vector<sort>* sorts, svector<symbol>* names, bool use_fresh, bool rewrite_ok) {
unsigned index = var_counter().get_next_var(fml);
while (is_quantifier(fml) && _is_forall == is_forall(fml)) {
quantifier* q = to_quantifier(fml);
index += q->get_num_decls();
if (names) {
names->append(q->get_num_decls(), q->get_decl_names());
}
if (sorts) {
sorts->append(q->get_num_decls(), q->get_decl_sorts());
}
fml = q->get_expr();
}
if (!has_quantifiers(fml)) {
return index;
}
app_ref_vector vars(m);
pull_quantifier(_is_forall, fml, vars, use_fresh, rewrite_ok);
if (vars.empty()) {
return index;
}
// replace vars by de-bruijn indices
expr_safe_replace rep(m);
svector<symbol> bound_names;
ptr_vector<sort> bound_sorts;
for (unsigned i = 0; i < vars.size(); ++i) {
app* v = vars[i].get();
if (names) {
bound_names.push_back(v->get_decl()->get_name());
}
if (sorts) {
bound_sorts.push_back(v->get_sort());
}
rep.insert(v, m.mk_var(index++, v->get_sort()));
}
if (names && !bound_names.empty()) {
bound_names.reverse();
bound_names.append(*names);
names->reset();
names->append(bound_names);
}
if (sorts && !bound_sorts.empty()) {
bound_sorts.reverse();
bound_sorts.append(*sorts);
sorts->reset();
sorts->append(bound_sorts);
}
rep(fml);
return index;
}
private:
enum quantifier_type {
Q_forall_pos = 0x10,
Q_exists_pos = 0x20,
Q_none_pos = 0x40,
Q_forall_neg = 0x11,
Q_exists_neg = 0x21,
Q_none_neg = 0x41
};
void display(quantifier_type qt, std::ostream& out) {
switch(qt) {
case Q_forall_pos: out << "Forall+"; break;
case Q_exists_pos: out << "Exists+"; break;
case Q_none_pos: out << "None+"; break;
case Q_forall_neg: out << "Forall-"; break;
case Q_exists_neg: out << "Exists-"; break;
case Q_none_neg: out << "None-"; break;
}
}
quantifier_type& negate(quantifier_type& qt) {
qt = static_cast<quantifier_type>(qt ^0x1);
return qt;
}
static bool is_negative(quantifier_type qt) {
return 0 != (qt & 0x1);
}
static bool is_positive(quantifier_type qt) {
return 0 == (qt & 0x1);
}
static void set_quantifier_type(quantifier_type& qt, bool is_forall) {
switch(qt) {
case Q_forall_pos: SASSERT(is_forall); break;
case Q_forall_neg: SASSERT(!is_forall); break;
case Q_exists_pos: SASSERT(!is_forall); break;
case Q_exists_neg: SASSERT(is_forall); break;
case Q_none_pos: qt = is_forall?Q_forall_pos:Q_exists_pos; break;
case Q_none_neg: qt = is_forall?Q_exists_neg:Q_forall_neg; break;
}
}
bool is_compatible(quantifier_type qt, bool is_forall) {
switch(qt) {
case Q_forall_pos: return is_forall;
case Q_forall_neg: return !is_forall;
case Q_exists_pos: return !is_forall;
case Q_exists_neg: return is_forall;
case Q_none_pos: return true;
case Q_none_neg: return true;
default:
UNREACHABLE();
}
return false;
}
void pull_quantifier(expr* fml, quantifier_type& qt, app_ref_vector& vars, expr_ref& result, bool use_fresh, bool rewrite_ok) {
if (!has_quantifiers(fml)) {
result = fml;
return;
}
switch(fml->get_kind()) {
case AST_APP: {
expr_ref_vector args(m);
expr_ref tmp(m);
expr* t1, *t2, *t3;
unsigned num_args = 0;
app* a = to_app(fml);
if (m.is_and(fml)) {
num_args = a->get_num_args();
for (unsigned i = 0; i < num_args; ++i) {
pull_quantifier(a->get_arg(i), qt, vars, tmp, use_fresh, rewrite_ok);
args.push_back(tmp);
}
if (rewrite_ok)
m_rewriter.mk_and(args.size(), args.data(), result);
else
result = m.mk_and(std::span<expr* const>(args.data(), args.size()));
}
else if (m.is_or(fml)) {
num_args = to_app(fml)->get_num_args();
for (unsigned i = 0; i < num_args; ++i) {
pull_quantifier(to_app(fml)->get_arg(i), qt, vars, tmp, use_fresh, rewrite_ok);
args.push_back(tmp);
}
if (rewrite_ok)
m_rewriter.mk_or(args.size(), args.data(), result);
else
result = m.mk_or (args.size (), args.data ());
}
else if (m.is_not(fml)) {
pull_quantifier(to_app(fml)->get_arg(0), negate(qt), vars, tmp, use_fresh, rewrite_ok);
negate(qt);
result = m.mk_not(tmp);
}
else if (m.is_implies(fml, t1, t2)) {
pull_quantifier(t1, negate(qt), vars, tmp, use_fresh, rewrite_ok);
negate(qt);
pull_quantifier(t2, qt, vars, result, use_fresh, rewrite_ok);
result = m.mk_implies(tmp, result);
}
else if (m.is_ite(fml, t1, t2, t3)) {
expr_ref tt1(m), tt2(m), tt3(m), ntt1(m), nt1(m);
pull_quantifier(t2, qt, vars, tt2, use_fresh, rewrite_ok);
pull_quantifier(t3, qt, vars, tt3, use_fresh, rewrite_ok);
if (has_quantifiers(t1)) {
pull_quantifier(t1, qt, vars, tt1, use_fresh, rewrite_ok);
nt1 = m.mk_not(t1);
pull_quantifier(nt1, qt, vars, ntt1, use_fresh, rewrite_ok);
{
auto _seq252_0 = m.mk_or(ntt1, tt2);
auto _seq252_1 = m.mk_or(tt1, tt3);
result = m.mk_and(_seq252_0, _seq252_1);
}
}
else {
result = m.mk_ite(t1, tt2, tt3);
}
}
else if (m.is_eq(fml, t1, t2) && m.is_bool(t1)) {
expr_ref tt1(m), tt2(m), ntt1(m), ntt2(m), nt1(m), nt2(m);
pull_quantifier(t1, qt, vars, tt1, use_fresh, rewrite_ok);
pull_quantifier(t2, qt, vars, tt2, use_fresh, rewrite_ok);
nt1 = m.mk_not(t1);
nt2 = m.mk_not(t2);
pull_quantifier(nt1, qt, vars, ntt1, use_fresh, rewrite_ok);
pull_quantifier(nt2, qt, vars, ntt2, use_fresh, rewrite_ok);
{
auto _seq266_0 = m.mk_or(ntt1, tt2);
auto _seq266_1 = m.mk_or(ntt2, tt1);
result = m.mk_and(_seq266_0, _seq266_1);
}
}
else {
// the formula contains a quantifier, but it is "inaccessible"
result = fml;
}
break;
}
case AST_QUANTIFIER: {
quantifier* q = to_quantifier(fml);
if (is_lambda(q)) {
result = fml;
break;
}
expr_ref tmp(m);
if (!is_compatible(qt, is_forall(q))) {
result = fml;
break;
}
set_quantifier_type(qt, is_forall(q));
extract_quantifier(q, vars, tmp, use_fresh);
pull_quantifier(tmp, qt, vars, result, use_fresh, rewrite_ok);
break;
}
case AST_VAR:
result = fml;
break;
default:
UNREACHABLE();
result = fml;
break;
}
}
};
quantifier_hoister::quantifier_hoister(ast_manager& m) {
m_impl = alloc(impl, m);
}
quantifier_hoister::~quantifier_hoister() {
dealloc(m_impl);
}
void quantifier_hoister::operator()(expr* fml, app_ref_vector& vars, bool& is_fa, expr_ref& result, bool use_fresh, bool rewrite_ok) {
(*m_impl)(fml, vars, is_fa, result, use_fresh, rewrite_ok);
}
void quantifier_hoister::pull_exists(expr* fml, app_ref_vector& vars, expr_ref& result, bool use_fresh, bool rewrite_ok) {
m_impl->pull_exists(fml, vars, result, use_fresh, rewrite_ok);
}
void quantifier_hoister::pull_quantifier(bool is_forall, expr_ref& fml, app_ref_vector& vars, bool use_fresh, bool rewrite_ok) {
m_impl->pull_quantifier(is_forall, fml, vars, use_fresh, rewrite_ok);
}
unsigned quantifier_hoister::pull_quantifier(bool is_forall, expr_ref& fml, ptr_vector<sort>* sorts, svector<symbol>* names, bool use_fresh, bool rewrite_ok) {
return m_impl->pull_quantifier(is_forall, fml, sorts, names, use_fresh, rewrite_ok);
}