tile-ai / tile-ai/tilelang

[BUG][Fuzzer][accepts-invalid] Rebinding an immutable let-var to a constant inside an `if` is silently accepted and miscompiled instead of rejected

Open
#2,977 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
7.4k
Forks
745
Avg merge
1d 1h
Merged PRs (30d)
104

Description

### Required prerequisites

- [x] I have searched the [issue tracker](https://github.com/tile-ai/tilelang/issues) for a similar report and found none.
- [x] I have reproduced the problem on the latest release / a recent build.

### What version of TileLang are you using?

tilelang 0.1.13 (release tag `v0.1.13`)

### System information

- GPU: NVIDIA L40S (sm_89)
- CUDA 13.0, PyTorch 2.13.0+cu130
- Driver 580.126.16

(The defect is in the Python frontend `bind` path and is emitted into the TIR before any codegen, so it is architecture-independent — see the `.script()` dump below.)

### Problem description

Rebinding an immutable let-variable inside an `if` block is an illegal operation — with an expression right-hand side TileLang correctly rejects it (`RuntimeError: Immutable variable 'val' is used outside its defining region!`). But when the right-hand side is a **constant** (a Python `int`/`float`/`str` literal, a `bool`, or an `int32` `IntImm`), that guard is bypassed: the rebind is silently accepted, the conditional is dropped, and the constant is stored unconditionally. So the same illegal program is rejected or silently miscompiled depending only on the RHS form. A clamp written the natural way returns the wrong tensor with no error and no warning.

```python
val = A[i]
if val < 10:
val = 10 # literal reassignment inside the if
if val > 100:
val = 100
Out[i] = val
```

For input `[5, 50, 150, 10]` this returns `[10, 10, 10, 10]` instead of the expected clamp `[10, 50, 100, 10]`.

The emitted TIR shows both reassignments were lost — the in-`if` write became a no-op and the last literal leaked out as an unconditional store:

```python
for i in range(4):
val: T.int32 = A[i]
if val < 10:
T.evaluate(0) # `val = 10` compiled to a no-op
Out[i] = 10 # `val` is now the leaked Python literal 10, stored unconditionally
```

Notably, the exact same construct with a **PrimExpr** right-hand side (e.g. `val = A[i] + 1`) is correctly rejected at compile time with `RuntimeError: Immutable variable 'val' is used outside its defining region!`. Only the literal case slips through silently.

### Reproducible example code

```python
import tilelang
import tilelang.language as T
import torch

@T.prim_func
def test_clamp(A: T.Tensor((4,), "int32"), Out: T.Tensor((4,), "int32")):
with T.Kernel(1, threads=1) as bx:
for i in T.serial(4):
val = A[i]
if val < 10:
val = 10
if val > 100:
val = 100
Out[i] = val

k = tilelang.compile(test_clamp, out_idx=[1])
a = torch.tensor([5, 50, 150, 10], dtype=torch.int32, device="cuda")
print(k(a).tolist()) # [10, 10, 10, 10] -- wrong
# expected clamp: # [10, 50, 100, 10]
```

Minimal single-conditional form + working controls

Minimal culprit — one conditional, one literal reassignment:

```python
@T.prim_func
def minimal(A: T.Tensor((2,), "int32"), Out: T.Tensor((2,), "int32")):
with T.Kernel(1, threads=1) as bx:
for i in T.serial(2):
val = A[i]
if val < 10:
val = 999
Out[i] = val
# A=[5,50] -> got [999,999], expected [999,50]
```

The float case leaks the same way (`if val < 1.0: val = 9.0` gives `[9.0, 9.0]` for `[0.5, 5.0]`).

Control 1 — rewriting the same logic as nested `if/else` (no reassignment) is correct: returns `[10, 50, 100, 10]`.

Control 2 — using the documented mutable `T.alloc_var` is correct:

```python
@T.prim_func
def with_alloc(A: T.Tensor((4,), "int32"), Out: T.Tensor((4,), "int32")):
with T.Kernel(1, threads=1) as bx:
val = T.alloc_var("int32")
for i in T.serial(4):
val = A[i]
if val < 10:
val = 10
if val > 100:
val = 100
Out[i] = val
# returns [10, 50, 100, 10] -- correct
```

### Traceback

No traceback — the kernel compiles and runs successfully; the result is silently wrong. No warning is emitted.

### Expected behavior

Either of the following would be correct:

- **Reject** — raise the same `Immutable variable 'val' is used outside its defining region!` error that the PrimExpr and non-int32-`IntImm` RHS siblings already raise, and/or emit the existing "use T.alloc_var to create a mutable variable" warning. This is what the codebase already does for every RHS that doesn't hit a constant quick-return, so it is the consistent behavior.
- **Compute correctly** — treat the constant rebind inside the `if` as a conditional store, matching the behavior of `T.alloc_var` (which returns `[10, 50, 100, 10]`).

Silently accepting the illegal rebind — dropping the conditionals and storing the constant unconditionally — is not acceptable.

### Additional context

**Root cause.** `Builder.bind` has a "quick return for trivial types" at [`tilelang/language/eager/builder.py#L589-L590`](https://github.com/tile-ai/tilelang/blob/8001cc4ccf6149382d2019654a19f59c1d4d0482/tilelang/language/eager/builder.py#L589-L590):

```python
# 2. Quick return for trivil types
if isinstance(value, (tuple, list, tvm.ffi.Array, int, float, str)):
return value
```

The scope-tracking + guard logic lives at the *tail* of `bind` ([`#L608-L621`](https://github.com/tile-ai/tilelang/blob/8001cc4ccf6149382d2019654a19f59c1d4d0482/tilelang/language/eager/builder.py#L608-L621)): it (a) emits the "use `T.alloc_var`" warning on re-bind and (b) records `name_inside_frame['val'] = `, which is what makes the out-of-region guard in `rval` ([`#L812-L818`](https://github.com/tile-ai/tilelang/blob/8001cc4ccf6149382d2019654a19f59c1d4d0482/tilelang/language/eager/builder.py#L812-L818)) fire on the post-`if` read. The defect is that several `return`s *above* that tail short-circuit out of `bind` before the guard runs. Two of them are the constant paths — the trivial-type quick-return above, and the `int32` `IntImm` return right after it ([`#L591-L592`](https://github.com/tile-ai/tilelang/blob/8001cc4ccf6149382d2019654a19f59c1d4d0482/tilelang/language/eager/builder.py#L591-L592)) — so a constant rebind never updates `name_inside_frame`; the guard still sees `val`'s frame as the (still-live) outer let and passes the read. Meanwhile the DSL `if` is traced (executed as a Python conditional), so the Python name `val` is now bound to the constant, which leaks out and is stored unconditionally.

This is not literal-specific and not "reassigned twice" — it is precisely the intersection of *rebinding inside a control-flow frame* AND *an RHS that hits one of the constant quick-returns*. Which side of the guard a rebind lands on:

| RHS in the `if` | path | outcome |
|---|---|---|
| `10` (int / float / str literal) | trivial-type quick-return (L589-590) | **silently accepted + miscompiled** |
| `True` (bool ⊂ int) | trivial-type quick-return (L589-590) | **silently accepted + miscompiled** |
| `T.int32(999)` (int32 `IntImm`) | int32-`IntImm` return (L591-592) | **silently accepted + miscompiled** |
| `T.float32(9.0)` (non-int32 `IntImm`) | falls through to the tail guard | correctly **rejected** |
| `A[i] + 1` (PrimExpr) | falls through to the tail guard | correctly **rejected** |

A top-level rebind (no enclosing `if`) is correct for every RHS — `name_inside_frame`'s recorded frame is the current one, so the guard doesn't fire; the bug needs the *control-flow frame* ingredient. The mutable-store branches just above the quick-returns (`Ref.store` / `buffer_store`, taken when `orig_value` is a `Ref`/`Var`) are why `T.alloc_var` is correct — a plain let-var's `orig_value` is neither, so it falls through to the constant quick-returns.

**Suggested fix.** The scope/shadowing check the tail performs should run *before* the constant quick-returns (L589-592), or those quick-returns should perform it themselves: when `name` is already tracked in `name_inside_frame` and its recorded frame is still live (rebinding inside an inner control-flow frame), do what the non-constant paths already do — raise the "used outside its defining region" error, or at minimum emit the "use `T.alloc_var`" warning — and ideally route the constant through the mutable-store path so the natural clamp computes correctly. The constant fast paths must not bypass the immutable-var scope tracking every other RHS is subject to.

**Provenance.** The trivial-type quick-return in `Builder.bind` and the `rval` out-of-region guard are both present in the tilelang `v0.1.13` sources (permalinks above); reproduced at runtime on 0.1.13 this session (literal clamp → `[10,10,10,10]`; `T.alloc_var` control → `[10,50,100,10]`; PrimExpr-RHS sibling rejected with the "used outside its defining region" error). Not a recent regression; origin before 0.1.13 not traced.

**Dedup.** No existing report covers the eager `bind` trivial-type quick-return bypassing `name_inside_frame` scope tracking. Nearby `if`-guard miscompiles (`DecoupleTypeCast`, `MergeIfStmt`, `LoopUnswitching`, `ThreadSync`) are all C++ lowering passes and unrelated to this Python-frontend hole; `T.alloc_var` reports (float64 init) are a different mechanism.

**Reach.** Any immutable let-variable rebound to a Python `int`/`float`/`str` literal inside a traced `if`/`else` block. The `T.alloc_var` form and the PrimExpr-RHS form (which is rejected) are unaffected.

**Impact.** The trigger needs a conjunction — rebinding a let-var inside a control-flow (`if`) block AND a constant RHS (int/float/str literal, bool, or int32 `IntImm`) — with expression-RHS and top-level forms handled correctly. When it fires it is silent and deterministic: the conditional is dropped and the constant is stored unconditionally, so a natural clamp/guard pattern returns a wholly wrong tensor with no error. Because the same illegal program is loudly rejected under a different RHS form, the failure is inconsistent and hard to anticipate.

Contributor guide

Open the contributing guide

Research direction

Start in tilelang/language/eager/builder.py at Builder.bind, especially the constant quick-return paths around lines 589-592, then inspect the scope guard in rval around lines 812-818. Reproduce the clamp example and add regression coverage for constant rebinding inside an if. Done when the illegal rebind is no longer silently accepted and the behavior is consistent with the existing PrimExpr path or correctly computes the conditional result.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
compilers
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.