Skip to content

[Arith] Fix const-int-bound modular-set tightening for Mod/FloorMod#19978

Open
sbinabdullah wants to merge 2 commits into
apache:mainfrom
sbinabdullah:fix-arith-const-int-bound-modular-set
Open

[Arith] Fix const-int-bound modular-set tightening for Mod/FloorMod#19978
sbinabdullah wants to merge 2 commits into
apache:mainfrom
sbinabdullah:fix-arith-const-int-bound-modular-set

Conversation

@sbinabdullah

Copy link
Copy Markdown

Summary

The "tighter bounds using modular set" fast path in ConstIntBoundAnalyzer's Mod/FloorMod visitors normalizes the modular-set base with base % modulus instead of base % gcd(coeff, modulus). When the base is not already smaller than the gcd, this produces an invalid bound with min_value > max_value.

Example (free int64 n):

analyzer.const_int_bound((n * 320 + 255) % 256)
# before: ConstIntBound(min_value=255, max_value=191)   <- min > max
# after:  ConstIntBound(min_value=63,  max_value=255)

The true value set is {63, 127, 191, 255}: since gcd(320, 256) = 64, the dividend satisfies a == 255 == 63 (mod 64), so floormod(a, 256) can only take values 63 + 64k.

Why it matters (miscompilation, not just precision)

The invalid bound makes Analyzer::CanProve(..., kSymbolicBound) "prove" the bounds predicate of imperfect dynamic loop splits. For example, after sch.fuse() of T.grid(n, 5, 64) and sch.split(..., factors=[None, 256]), the predicate

bx*256 + tx < n*320

was deemed provable (the symbolic-bound path reduces it to const_int_bound((n*320 + 255) % 256 - 254).min_value >= 1, which the broken [255, 191] bound satisfies), so split() silently dropped the T.where predicate. Any kernel scheduled through dlight gpu.Fallback with a dynamic fused extent then ran without an out-of-bounds guard.

Downstream, this corrupted the paged-KV cache on WebGPU: the unguarded tir_kv_cache_transpose_append kernel's excess threads read position_map past ntoken (zero-initialized memory returns 0, not the -1 sentinel) and overwrote page 0 / slot 0 of the KV cache with stale data on every decode, breaking multi-sequence decoding.

Fix

Normalize the residue modulo the gcd:

  • FloorMod (divisor > 0): result is in {r, r + g, ..., modulus - g + r} with r = base % g normalized to [0, g).
  • Truncated Mod: same set on the non-negative side; when the dividend can be negative, mirror the residue set (r' = (g - r) % g) on the negative side.

Testing

  • New regression cases in tests/python/arith/test_arith_const_int_bound.py (TestModBoundWithModularSet) covering the floormod case above, the truncmod mixed-sign / non-negative-dividend cases, and the original base = 0 example from the code comment.
  • tests/python/arith passes (964 passed); tests/python/s_tir/schedule -k "split or fuse" and tests/python/s_tir/dlight pass.
  • Verified end-to-end that sch.fuse + imperfect sch.split on a dynamic extent emits the T.where bounds predicate again, and that the recompiled WebGPU kernel contains the bounds guard.

The "tighter bounds using modular set" fast path in ConstIntBound's
Mod/FloorMod visitors normalized the modular-set base with
`base % modulus` instead of `base % gcd(coeff, modulus)`. When the base
was not already smaller than the gcd, this produced invalid bounds with
min_value > max_value.

Example: for free int64 n,
  const_int_bound((n * 320 + 255) % 256)
returned [255, 191], while the true value set is {63, 127, 191, 255},
i.e. [63, 255].

This is more than a precision issue: the invalid bound made
Analyzer::CanProve(..., kSymbolicBound) "prove" the bounds predicate of
imperfect dynamic loop splits (e.g. fuse(n, 5, 64) then split by 256:
bx*256 + tx < n*320), so tir schedule split() silently dropped the
T.where predicate. Every dlight gpu.Fallback-scheduled kernel with a
dynamic fused extent then ran without an out-of-bounds guard. In
practice this corrupted the paged-KV cache on WebGPU: the unguarded
tir_kv_cache_transpose_append kernel's excess threads read
position_map past ntoken (getting 0, not the -1 sentinel) and
overwrote page 0 / slot 0 with stale data on every decode.

Fix: normalize the residue modulo the gcd. For FloorMod (divisor > 0)
the result is in {r, r + g, ..., modulus - g + r} with
r = base % g >= 0. For truncated Mod, mirror the residue set on the
negative side when the dividend can be negative.

Adds const-int-bound regression tests covering the floormod case, the
truncmod mixed-sign / non-negative cases, and the original base=0
example from the comment.

@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 fixes a bug in ConstIntBoundAnalyzer where the residue for modular-set-based bounds was normalized modulo the divisor instead of the GCD of the coefficient and divisor, which could lead to invalid bounds and incorrect optimization of out-of-bounds guards. It also adds support for negative dividends in truncmod and introduces regression tests. The reviewer feedback suggests that immediately returning the modular-set-based bounds for both truncmod and floormod discards the interval-based bounds of the dividend. To prevent losing precision when the dividend has a tight range, the reviewer recommends intersecting the modular-set-based bounds with the fallback interval-based bounds.

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 on lines 301 to 314
if (gcd_coeff_mod > 1) {
int64_t base_mod = mod_a->base % modulus;
if (base_mod < 0) base_mod += modulus;
int64_t base_mod = mod_a->base % gcd_coeff_mod;
if (base_mod < 0) base_mod += gcd_coeff_mod;
int64_t tight_max = modulus - gcd_coeff_mod + base_mod;
if (tight_max >= modulus) tight_max -= modulus;
return MakeBound(base_mod, tight_max);
if (a.min_value >= 0) {
return MakeBound(base_mod, tight_max);
}
int64_t neg_base = (gcd_coeff_mod - base_mod) % gcd_coeff_mod;
int64_t tight_min = -(modulus - gcd_coeff_mod + neg_base);
if (a.max_value < 0) {
return MakeBound(tight_min, -neg_base);
}
return MakeBound(tight_min, tight_max);
}

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

When gcd_coeff_mod > 1, the modular-set-based bound is immediately returned, which completely discards the interval-based bounds of the dividend a. If a has a tight range (e.g., a.max_value < b.min_value), this can lead to unnecessarily loose bounds. Intersecting the modular-set-based bound with the fallback interval-based bound preserves maximum precision.

        if (gcd_coeff_mod > 1) {
          int64_t base_mod = mod_a->base % gcd_coeff_mod;
          if (base_mod < 0) base_mod += gcd_coeff_mod;
          int64_t tight_max = modulus - gcd_coeff_mod + base_mod;
          Entry fallback_bound;
          if (a.min_value >= 0) {
            if (a.max_value < b.min_value) {
              fallback_bound = a;
            } else {
              fallback_bound = MakeBound(0, std::min(a.max_value, b_max_cap));
            }
            return Intersect(fallback_bound, MakeBound(base_mod, tight_max));
          }
          int64_t neg_base = (gcd_coeff_mod - base_mod) % gcd_coeff_mod;
          int64_t tight_min = -(modulus - gcd_coeff_mod + neg_base);
          fallback_bound = MakeBound(std::max(a.min_value, -b_max_cap),
                                     std::min(std::max(a.max_value, (int64_t)0), b_max_cap));
          if (a.max_value < 0) {
            return Intersect(fallback_bound, MakeBound(tight_min, -neg_base));
          }
          return Intersect(fallback_bound, MakeBound(tight_min, tight_max));
        }

@sbinabdullah sbinabdullah Jul 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

addressed in 6fc6bfc. Both visitors now compute the interval-based bound first and return Intersect(interval_bound, modular_bound) when the modular fast path applies, so a tight dividend range is never lost. Added regression cases: (n*64+63) % 256 with n in [0,1] now gives [63, 127] (was [63, 255]), plus truncmod/floormod variants with a negative dividend range.

Comment on lines 385 to 390
if (gcd_coeff_mod > 1) {
int64_t base_mod = mod_a->base % modulus;
if (base_mod < 0) base_mod += modulus;
int64_t base_mod = mod_a->base % gcd_coeff_mod;
if (base_mod < 0) base_mod += gcd_coeff_mod;
int64_t tight_max = modulus - gcd_coeff_mod + base_mod;
if (tight_max >= modulus) tight_max -= modulus;
return MakeBound(base_mod, tight_max);
}

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

Similar to the Mod visitor, immediately returning the modular-set-based bound for FloorMod discards the interval-based bounds of a. Intersecting the modular-set-based bound with the fallback interval-based bound ensures we do not lose precision when a has a tight range.

        if (gcd_coeff_mod > 1) {
          int64_t base_mod = mod_a->base % gcd_coeff_mod;
          if (base_mod < 0) base_mod += gcd_coeff_mod;
          int64_t tight_max = modulus - gcd_coeff_mod + base_mod;
          Entry fallback_bound;
          if (a.min_value >= 0) {
            if (a.max_value < b.min_value) {
              fallback_bound = a;
            } else {
              fallback_bound = MakeBound(0, std::min(a.max_value, b_max_cap));
            }
          } else {
            fallback_bound = MakeBound(0, b_max_cap);
          }
          return Intersect(fallback_bound, MakeBound(base_mod, tight_max));
        }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 6fc6bfc together with the Mod visitor — FloorMod now intersects the modular-set bound with the interval-based bound as well (regression case: (n*64+63) % 256 with n in [0,1][63, 127]).

Address review feedback: returning the modular-set-based bound directly
discarded the interval-based bound of the dividend, so a tight dividend
range (e.g. a in [63, 127] with a == 63 (mod 64), where floormod(a, 256)
is exactly {63, 127}) produced an unnecessarily loose [63, 255].

Restructure the Mod/FloorMod visitors to always compute the
interval-based bound first and intersect it with the modular-set-based
bound when the fast path applies. Adds tight-range regression cases for
floormod, truncmod with a negative dividend range, and floormod of a
negative dividend range.
@sbinabdullah

Copy link
Copy Markdown
Author

CI note: the failing lint check is unrelated to this PR. The only failing hook is ruff-format --all-files, which modifies two files untouched by this PR:

  • python/tvm/relax/backend/dispatch_sort_scan.py
  • tests/python/relax/test_frontend_onnx.py

I reproduced this on a clean detached worktree at the PR target SHA apache/tvm@67bd1ea with:

pre-commit run ruff-format --all-files --show-diff-on-failure

It produces the exact same two-file diff and exits 1. Upstream main itself has had a red Lint workflow for its last five commits (the run at 67bd1ea is https://github.com/apache/tvm/actions/runs/29073743021). The PR files pass targeted pre-commit locally (ruff check, ruff format, clang-format, ASF headers), and the MacOS/Windows CI jobs pass. I have not included unrelated formatter changes in this focused arith fix.

@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.

Thanks for catching this. it looks good to me

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.

2 participants