Skip to content

[ARITH] Add optional Z3-backed proving to Analyzer#19667

Open
Ubospica wants to merge 15 commits into
apache:mainfrom
Ubospica:2026-06-03-arith-z3-analyzer
Open

[ARITH] Add optional Z3-backed proving to Analyzer#19667
Ubospica wants to merge 15 commits into
apache:mainfrom
Ubospica:2026-06-03-arith-z3-analyzer

Conversation

@Ubospica

@Ubospica Ubospica commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds a Z3 SMT solver backend to tvm::arith::Analyzer for stronger integer arithmetic proving.

The integration is guarded by USE_Z3, which defaults to AUTO. In the default mode, TVM enables Z3 when the static Z3 development artifacts are available and otherwise builds the conservative stub implementation. When Z3 is enabled, Analyzer::CanProve runs the existing TVM arithmetic analysis path first, then falls back to Z3 only when the existing analyzers cannot prove the predicate and the requested strength is kSymbolicBound. Z3 is linked statically from the PyPI z3-static package, so libtvm does not need a runtime libz3 dependency.

Features

  • Z3 build support through USE_Z3, defaulting to AUTO.
  • A new arith::Z3Prover sub-analyzer owned by arith::Analyzer.
  • SMT-LIB2 export for debugging and external solver reproduction.
  • Python debug/config APIs: Analyzer.get_smtlib2, Analyzer.set_z3_timeout_ms, Analyzer.set_z3_rlimit, and Analyzer.get_z3_stats.
  • C++ APIs for proving, binding, constraints, stats, model inspection, and satisfying-value counting.
  • Scalar integer, unsigned integer, and boolean expression translation to Z3.
  • Support for arithmetic, comparisons, boolean operators, min, max, select, if_then_else, let, casts, truncated division/modulo, floor division/modulo, and selected bitwise/shift operations.
  • Deterministic solver control using Z3 rlimit, with random_seed fixed to 42.
  • Thread-local Z3 context sharing to reduce initialization overhead while keeping thread safety.
  • A disabled-mode stub implementation that returns conservative results when Z3 is not built.

Implementation Notes

  • The real and stub implementations live in src/arith/z3_prover.cc, selected by the TVM_USE_Z3 macro from cmake/modules/contrib/Z3.cmake.
  • cmake/modules/contrib/Z3.cmake first resolves the PIC static libz3 layout provided by z3-static using its z3_static.get_cmake_dir() helper, then falls back to a custom Z3_DIR or CMAKE_PREFIX_PATH installation.
  • USE_Z3=ON requires Z3 to be found, while USE_Z3=AUTO allows source builds and CI jobs without Z3 artifacts to continue with the stub.
  • The Z3 fallback is exception-safe and gated behind kSymbolicBound, so the common kDefault path does not pay solver cost.
  • TVM Div and Mod are translated with truncating helpers rather than Z3's Euclidean operators to stay sound for negative dividends.
  • Shift handling relies on Z3's native bit-vector semantics and does not add hard assertions to the shared solver.

References

The implementation is based on the Z3 analyzer integration used in TileLang's TVM fork, with the upstream port kept scoped to TVM's arithmetic analyzer.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request integrates Z3 SMT solver support into TVM's arithmetic analyzer, adding a new Z3Prover sub-analyzer, updating CMake configurations, and exposing Z3-related APIs to Python. Feedback on the implementation highlights a critical soundness issue where shift assertions permanently poison the global solver context, an optimization failure in range binding due to unfolded expressions, and thread-safety limitations with the static thread-local Z3 context. Additionally, minor improvements are suggested to resolve an implicit double-to-unsigned conversion and remove redundant header includes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/target/z3/z3_prover_on.cc Outdated
Comment on lines +733 to +739
// Add constraint that shift amount should be non-negative
// This is a common assumption in many programming languages
solver.add(b_expr >= 0);

// Also limit shift amount to avoid unrealistic large shifts
// We'll limit to 64 bits (reasonable for most use cases)
solver.add(b_expr < 64);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Soundness Issue with Shift Amount Assertions

Calling solver.add() inside VisitShiftOp permanently adds the assertions b_expr >= 0 and b_expr < 64 to the global solver context. Because CanProve does not use solver.push() and solver.pop(), these assertions persist across different CanProve calls.

This poisons the solver for all subsequent unrelated proofs involving the same variable b, leading to unsound results (e.g., proving b < 100 is always true even when b is completely unbounded in other contexts).

To fix this, we should rely on Z3's native bit-vector shift behavior without adding hard assertions to the solver.

      // Rely on Z3's native bit-vector shift behavior without adding hard assertions
      // to the solver, which would otherwise permanently poison the global solver context.

@tlopex tlopex Jun 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense. Could you have a correction?

Comment thread src/target/z3/z3_prover_on.cc Outdated
Comment on lines +268 to +274
if (tirx::is_const_int(range->min) && tirx::is_const_int(range->min + range->extent)) {
int64_t min_value = *tirx::as_const_int(range->min);
int64_t max_value = *tirx::as_const_int(range->min + range->extent);
if (min_value < max_value) {
solver.add(ctx->int_val(min_value) <= var_expr);
solver.add(var_expr < ctx->int_val(max_value));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Constant Range Optimization is Never Triggered

range->min + range->extent is constructed as a new AddNode and is not automatically folded. Therefore, tirx::is_const_int(range->min + range->extent) will always return false, meaning the optimized path that adds simple bounds directly to the solver is never executed.

Instead, it always falls back to the else branch, generating a much more complex Z3 expression involving ConvertBool and or/and operators, which slows down Z3 solving.

We should check is_const_int on range->min and range->extent individually, and sum their values in C++.

    if (tirx::is_const_int(range->min) && tirx::is_const_int(range->extent)) {
      int64_t min_value = *tirx::as_const_int(range->min);
      int64_t extent_value = *tirx::as_const_int(range->extent);
      int64_t max_value = min_value + extent_value;
      if (min_value < max_value) {
        solver.add(ctx->int_val(min_value) <= var_expr);
        solver.add(var_expr < ctx->int_val(max_value));
      }
    }

Comment thread src/target/z3/z3_prover_on.cc Outdated
Comment on lines +281 to +288
void CopyFrom(const Self& other_) {
// 1. create a new solver
// because this->solver depends on this->ctx
// we need to deconstruct the old solver, and create a new one depending on other_.ctx
solver = CreateSolver(*other_.ctx);
// 2. copy the context
// the context is a shared_ptr, we can just copy the pointer
ctx = other_.ctx;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Thread-Safety and Cross-Thread Copying Limitation

ctx is declared as inline static thread_local std::shared_ptr<z3::context>. Because it is static thread_local, any access to other_.ctx (even if other_ was created on a different thread) will resolve to the thread-local variable of the current thread executing the code.

Therefore, ctx = other_.ctx; is a redundant self-assignment on the same thread, and fails to copy or reference the actual context of other_ if it was created on a different thread. If Z3Prover is copied across threads, attempting to add assertions/expressions from the other thread's context to the current thread's solver will cause Z3 to crash or throw an exception.

We should remove the redundant self-assignment and add a warning comment about this limitation.

  void CopyFrom(const Self& other_) {
    // 1. create a new solver
    //    because this->solver depends on this->ctx
    //    we need to deconstruct the old solver, and create a new one depending on other_.ctx
    solver = CreateSolver(*ctx);
    // 2. Note: ctx is static thread_local, so other_.ctx is the same as ctx on the current thread.
    //    Cross-thread copying of Z3Prover is not supported because Z3 expressions cannot be
    //    shared across different thread-local contexts without translation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove it as gemini suggested

Comment thread src/target/z3/z3_prover_on.cc Outdated
// Z3's implementation of timeout, when setting timeout T ms, it will stop at T - 1 ms
// SetTimeoutMs(5);
// use rlimit, not timeout to ensure determinstic behavior
SetRLimit(1e4);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Implicit Double-to-Unsigned Conversion

SetRLimit(1e4) passes a double literal 1e4 to a function expecting unsigned rlimit. This triggers an implicit double-to-unsigned conversion.

We should use an integer literal like 10000U to avoid implicit conversion warnings.

    SetRLimit(10000U);

Comment thread src/target/z3/z3_prover_on.cc Outdated
#include <unordered_set>
#include <vector>

#include "tvm/arith/analyzer.h"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Redundant Include

#include "tvm/arith/analyzer.h" is redundant because <tvm/arith/analyzer.h> is already included at the top of the file.

Comment thread src/target/z3/z3_prover_off.cc Outdated
#include <tvm/tirx/expr.h>
#include <tvm/tirx/op.h>

#include "tvm/arith/analyzer.h"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Redundant Include

#include "tvm/arith/analyzer.h" is redundant because <tvm/arith/analyzer.h> is already included at the top of the file.

@Ubospica Ubospica changed the title [ARITH] Add optional Z3-backed proving to Analyzer [ARITH] Add optional Z3-backend proving to Analyzer Jun 3, 2026
@Ubospica Ubospica changed the title [ARITH] Add optional Z3-backend proving to Analyzer [ARITH] Add optional Z3-backed proving to Analyzer Jun 3, 2026
Comment thread src/target/z3/z3_prover_off.cc Outdated

@tlopex tlopex Jun 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's better to remove z3_prover_off.cc and z3_prover_on.cc to /src/arith?
And we should have ASF license header on those two cc files

@tlopex tlopex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no negative test currently, could you add one?
And seems the new tests don't actually run in CI. USE_Z3 defaults to OFF and no buildconfig enables it , so every job builds the Z3-off stub and _require_z3() skips all four real tests. Do we need to enable it during CI? cc @tqchen

Comment thread src/arith/z3_prover.cc
z3::expr VisitExpr_(const SubNode* op) override {
return VisitArith(z3::operator-, op, op->a, op->b);
}
z3::expr VisitExpr_(const MulNode* op) override {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VisitExpr_(DivNode) and VisitExpr_(ModNode) map TVM's truncated div/mod (round toward zero) onto Z3's native operator/ / operator%, which are Euclidean (floor / non-negative remainder for a positive divisor). These disagree once the dividend can be negative.
CanProve can return true for a predicate that is false under TVM semantics (its negation comes out unsat under Euclidean div) which is unsound. Just like FloorDiv/FloorMod via the custom floordiv/floormod helpers. Div/Mod need the same treatment with truncating helpers rather than the raw Z3 operators I think.
I saw the same place on Tilelang, seems it's the same but still think it's an issue

Comment thread src/arith/z3_prover.cc
}
}

z3::expr VisitExprDefault_(const Object* op) override {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here VisitExprDefault_ does a LOG(FATAL) whenever it hits a node type it doesn't handle, which kills the whole build. That's a problem here because Z3 is just a fallback — Analyzer::CanProve only reaches it after the native analyzers have already given up. So if an expression happens to contain some unsupported node, what used to be a harmless "couldn't prove it" now turns into a hard crash. Could we just return a fresh free variable here (return Create(op);)?

Comment thread src/arith/analyzer.cc Outdated
}
}

if (z3_prover.CanProve(simplified)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Z3 fallback isn't exception-safe — Z3 can throw z3::exception and nothing catches it, so it escapes Analyzer::CanProve and breaks the calling pass. A fallback shouldn't turn a failed proof into a crash. it's good to wrap the body of Z3Prover::CanProve in try { ... } catch (const z3::exception&) { return false; }.

@tlopex

tlopex commented Jun 4, 2026

Copy link
Copy Markdown
Member

Z3 fallback currently ignores the strength gate, so enabling it at all (especially in deeply recursive kDefault CanProve calls) causes unnecessary Z3 invocations and a measurable perf hit; it should be gated behind a higher threshold like kSymbolicBound to avoid paying prover cost on cheap paths I think.
And EnterConstraint duplicates EnterWithScope without any real caller in this PR, and the new is_assume flag is effectively dead code since it is never set to true, so we should either deduplicate the entry logic and wire the flag with a real use/test or drop both changes until they’re actually needed.

@SiriusNEO

Copy link
Copy Markdown
Contributor

BTW, the original Z3 integration is in this commit (Not a PR): tile-ai@e633295

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the tests, do we need to cover all tests in the original PR?: tile-ai/tilelang#1367

Comment thread src/target/z3/z3_prover_off.cc Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using two source files to control z3_off/z3_on is a bit strange. Can we use macros to achieve this so that we can merge them into a single z3_prover.cc?

@tqchen

tqchen commented Jun 6, 2026

Copy link
Copy Markdown
Member

given this is optional registration, single file and cmake control addition of the file perhaps is the best approach, great to add testcases to cover things when z3 is detected

Ubospica added 9 commits June 11, 2026 12:25
Move TVM's core C++ and CUDA dialects to C++20 and update first-party helper
build paths that compile TVM-generated C++ code. This keeps the codebase ready
for C++20 native dependencies while leaving vendored third-party projects to
manage their own minimum standards.
Limit the C++20 baseline update to TVM's core CMake configuration and docs.
Leave app, JVM, web, and Python helper compile flags at their existing C++17
settings so those downstream build surfaces can migrate separately.
Replace deprecated C++ traits and make captures explicit so the C++20 baseline builds cleanly under -Werror.
Add an optional Z3 SMT solver backend to tvm::arith::Analyzer for
stronger integer arithmetic proving. The integration is guarded by a
new USE_Z3 CMake option (default OFF). When enabled, Analyzer::CanProve
runs the existing analysis path first and only falls back to Z3 when the
existing analyzers cannot prove the predicate. When disabled, a stub
implementation keeps the C++ and Python APIs available without Z3.
Signed-off-by: Ubospica <ubospica@gmail.com>
- Remove python/tvm/_version.py: it is auto-generated by the build's VCS
  versioning ("don't track in version control") and is not imported at
  runtime; the package __version__ comes from python/tvm/libinfo.py.
  Also add it to .gitignore so it is not re-committed.
- Remove four stray 3rdparty gitlinks (cnpy, dmlc-core, rang, zlib) that
  are not real submodules (absent from .gitmodules).
USE_Z3=ON now resolves Z3 through the PyPI z3-staticlib package (PIC
static libz3 + headers + CMake package files) so libtvm carries no
runtime libz3 dependency. The z3-solver shared-library fallback is
removed; a custom Z3 installation can still be selected via
Z3_DIR/CMAKE_PREFIX_PATH. Wheel builds enable USE_Z3 by default and pull
z3-staticlib as a build requirement.
Use the z3-static CMake package path first and make the rebased Z3 analyzer build cleanly with LLVM and CUDA enabled.
@Ubospica Ubospica force-pushed the 2026-06-03-arith-z3-analyzer branch from ce0b276 to 98b2aec Compare June 14, 2026 10:58
Ubospica added 6 commits June 14, 2026 07:04
Keep the LLVM header interface target on explicit compile options so object targets inherit llvm-config include paths reliably.
Default USE_Z3 to AUTO so source builds use z3-static when installed while CI environments without the package still build the conservative stub.
Avoid passing GCC-style -isystem options to cl while keeping explicit LLVM include propagation for non-MSVC builds.
Clarify why the LLVM header target uses include directories instead of GCC-style -isystem options on MSVC.
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.

4 participants