Required prerequisites
What version of TileLang are you using?
0.1.13
System information
TileLang 0.1.13 / CUDA 12.8 / PyTorch 2.8 ; run on NVIDIA L40S (sm_89). The failure is in LayoutInference (host-side layout planning), before any device code is generated, so it is architecture-independent.
Problem description
Passing an int to T.Parallel's documented coalesced_width keyword aborts compilation:
tvm.error.InternalError: coalesced_width should be an IntImmNode.
coalesced_width is a public keyword of T.Parallel, typed int | None in the signature (loop.py#L15) and documented as Optional[int] (loop.py#L28), so a plain Python int is exactly what the signature promises. It never reaches codegen — the crash fires during LayoutInference. The same int works everywhere else it is accepted: passing it via T.copy(..., coalesced_width=<int>) compiles fine, and wrapping the int in tir.IntImm before handing it to T.Parallel also compiles fine (both verified below), so the value itself is legal.
Not a regression — see Provenance.
Reproducible example code
import tilelang
import tilelang.language as T
import torch
# 0.1.13: IntImm moved from tvm.tir to tvm.tirx; tilelang re-exports it as T.IntImm.
N, blk = 4096, 256
A = torch.arange(N, device="cuda", dtype=torch.float32)
# --- FAIL: documented int coalesced_width aborts compilation ---
@T.prim_func
def bug(A: T.Tensor((N,), "float32"), B: T.Tensor((N,), "float32")):
with T.Kernel(N // blk, threads=64) as bx:
for i in T.Parallel(blk, coalesced_width=4): # int, per the signature
B[bx * blk + i] = A[bx * blk + i]
tilelang.compile(bug, out_idx=[1], target="cuda")
# -> tvm.error.InternalError: coalesced_width should be an IntImmNode.
# --- CONTROL: identical kernel, coalesced_width wrapped in IntImm -> compiles & correct ---
@T.prim_func
def ok(A: T.Tensor((N,), "float32"), B: T.Tensor((N,), "float32")):
with T.Kernel(N // blk, threads=64) as bx:
for i in T.Parallel(blk, coalesced_width=T.IntImm("int32", 4)):
B[bx * blk + i] = A[bx * blk + i]
k = tilelang.compile(ok, out_idx=[1], target="cuda")
print("control match:", torch.equal(k(A), A)) # -> True
Traceback
File ".../tilelang/cuda/pipeline.py", line 117, in CUDAPassPipelineBodyPrologue
mod = tilelang.transform.LayoutInference()(mod)
...
tvm::tl::ParallelOpNode::ComputePlanCandidate(...) const
tvm.error.InternalError: coalesced_width should be an IntImmNode.
Expected behavior
T.Parallel(..., coalesced_width=4) should compile and use the requested coalesced width, matching its own int | None signature and the sibling T.copy(..., coalesced_width=<int>) path, which accepts a bare int over the same value. At minimum a documented, correctly-typed argument should not abort compilation.
Additional context
Guard boundary tested (this session, 0.1.13, L40S) — the bare-int path aborts unconditionally
Each a separate compile of the same kernel, varying only how coalesced_width is passed to T.Parallel:
coalesced_width= |
result |
1 (bare int, smallest legal) |
CRASH coalesced_width should be an IntImmNode. |
2 (bare int) |
CRASH coalesced_width should be an IntImmNode. |
T.IntImm("int32", 2) (wrapped) |
compiles, output matches |
T.IntImm("int32", 4) (wrapped, CONTROL) |
compiles, output matches |
So the abort is unconditional on the bare-int path (independent of the value) and disappears the moment the same value is wrapped in IntImm — a missing-case / type-coercion gap, not a value problem. It is a distinct branch from the sibling divisibility abort ("Vector size N is not divisible by coalesced width M", parallel.cc:804): that one honors the int type and rejects on geometry; this one rejects the int type itself (parallel.cc:808). Same function ComputePlanCandidate, adjacent code, different root — reported separately.
Root cause. T.Parallel attaches the coalesced_width argument to the loop-annotation map as a raw Python int, but the C++ layout planner reads it back as an IntImmNode and hard-fails when it is not one. In loop.py#L79-L87 the value is placed in merged_annotations unconverted (merged_annotations["coalesced_width"] = coalesced_width) and handed straight to _ffi_api.Parallel(extents, merged_annotations), which attaches the dict to the For node's annotation Map; the value arrives as a runtime int, not a tir.IntImm. Then in parallel.cc#L798-L809, ParallelOpNode::ComputePlanCandidate does coalesced_width->as<IntImmNode>(), which returns null, and takes the LOG(FATAL) branch (parallel.cc:808).
The T.copy family stores the argument the same raw way (copy_op.py#L124-L125), and its lowering (copy.cc#L538-L540) does re-attach kCoalescedWidth onto a ForKind::kParallel loop that reaches the same ComputePlanCandidate / as<IntImmNode>() read. The reason copy does not crash is not that it skips that read — it is that copy passes its annotations through call_intrin(..., annotations=ann), which builds a TVM Map and coerces the Python int to a tir.IntImm (a proper IntImmNode) as part of node construction, whereas _ffi_api.Parallel attaches the raw Python dict directly and no such coercion happens. So the divergence is a coercion gap at the FFI boundary of T.Parallel, not a difference in what the C++ planner reads. (Verified this session: T.copy(..., coalesced_width=4) compiles and returns the correct result on CUDA; see Reach.)
Suggested fix. Wrapping the value at the Python boundary — e.g. merged_annotations["coalesced_width"] = IntImm("int32", coalesced_width) in T.Parallel — makes the annotation an IntImmNode and lets the existing parallel.cc branch run; the CONTROL above (which does exactly this wrap at the call site) compiles and returns the correct result, so this is the minimal change. Alternatively ComputePlanCandidate could coerce the annotation via Downcast/arith before the as<IntImmNode>() read.
Provenance. The coalesced_width keyword on T.Parallel was added by #1887 (merged 2026-03-06), which introduced the raw-int passthrough at loop.py:80; that is when the crash first became reachable. The as<IntImmNode>() FATAL in parallel.cc predates it (present since the codebase migration #10, 2025-01-11), so the two halves never agreed on the type — the argument crashes on every release that exposes the keyword, not a regression.
Dedup. Searched the open and closed tracker (coalesced_width, IntImmNode, should be an IntImmNode) and found no existing report of this defect.
Reach. The trigger is documented and typed: coalesced_width is declared int | None and documented Optional[int], so an int is the type the API asks for. coalesced_width appears in shipped code (git grep coalesced_width examples/ testing/ at v0.1.13): every site routes it through T.copy / T.async_copy, e.g. examples/amd/example_amd_flash_attn_bwd.py:163,172-173 and examples/amd/example_amd_flash_attn_fwd.py:183,192-193 (coalesced_width=vec_size), and testing/python/amd/test_tilelang_gfx950_copy_async.py. No example or test passes it to T.Parallel (git grep 'Parallel(' examples/ testing/ | grep coalesced_width at v0.1.13 is empty), so CI never exercises the crashing path. These shipped sites are all AMD gfx950-targeted and were not runnable on this session's hardware (L40S, sm_89), but the load-bearing fact they establish — that a bare int through the copy family is a legal coalesced_width — was verified directly on CUDA this session: T.copy(dst, src, coalesced_width=4) compiled and returned torch.equal(...) == True. Given the keyword on T.Parallel, any value trips the crash — the failure is unconditional on that int path (the CONTROL only differs by wrapping the same value in IntImm), and it also fires when the int is supplied via the generic annotations={"coalesced_width": <int>} dict, so it is the FFI-attach path of T.Parallel, not the specific keyword.
Impact. The narrow ingredient is passing coalesced_width as a bare int through T.Parallel specifically (the sibling T.copy path and the IntImm-wrapped form both compile). When it fires it is a loud compile-time abort during LayoutInference — no device code is produced, so nothing is silently miscompiled and no output is corrupted; the failure is deterministic and caught immediately at build time. Fixing it removes a type disagreement between the Python signature (int | None) and the C++ as<IntImmNode>() read, letting a documented, correctly-typed argument build the kernel it should.
Generalization (root analysis + 4-axis sweep, all cells run this session, 0.1.13 / L40S)
Two-level root.
- Source-level:
_ffi_api.Parallel(extents, merged_annotations) (loop.py#L79-L87) attaches the raw Python annotation dict to the For node with no int→IntImm coercion, while ParallelOpNode::ComputePlanCandidate reads the value through as<IntImmNode>() and LOG(FATAL)s on a miss (parallel.cc#L798-L809). The fragile code is this uncoerced FFI-attach on the T.Parallel frame.
- Operator-level: the
coalesced_width (kCoalescedWidth) annotation is consumed only in ComputePlanCandidate. Every op that produces a parallel loop carrying that annotation — T.Parallel, and via re-attach T.copy / T.async_copy (copy.cc#L538-L540) and the atomic reductions (atomic_add.cc:200-202, atomic_reduce.h:129-131) — funnels through that single as<IntImmNode>() read. The copy/atomic ops are safe only because their annotations are laundered into an IntImm by call_intrin's Map construction; T.Parallel is the one entry point that attaches raw.
| axis |
cell tested |
result |
same-root? |
| repro |
T.Parallel(..., coalesced_width=4) (bare int) |
CRASH coalesced_width should be an IntImmNode. |
— (the bug) |
| control |
T.Parallel(..., coalesced_width=T.IntImm("int32",4)) |
COMPILES, torch.equal==True |
— (control) |
| similar-logic |
same int via generic T.Parallel(..., annotations={"coalesced_width":4}) (bypass the typed kwarg) |
CRASH ... should be an IntImmNode. |
same root — proves it is the FFI-attach path, not the keyword |
| related-operator |
T.copy(dst, src, coalesced_width=4) (bare int) |
COMPILES, torch.equal==True |
distinct outcome — same as<IntImmNode>() read, but call_intrin coerces int→IntImm, so no crash (documents WHY sibling dodges) |
| related-operator |
T.copy(dst, src, annotations={"coalesced_width":4}) (bare int via dict) |
COMPILES |
same as above — copy launders the dict through Map construction |
| related-source |
divisibility sibling branch: coalesced_width=T.IntImm("int32",3) (vector_size=4) |
CRASH Vector size 4 is not divisible by coalesced width 3 (parallel.cc:804) |
distinct — honors the int type, rejects on geometry; not a type-coercion gap. Not filed. |
| related-type |
coalesced_width=T.IntImm("int64",4) (width neighbor) |
COMPILES, torch.equal==True |
any IntImmNode width passes — confirms the check is type-tag only |
| related-type |
coalesced_width=np.int32(4) (common user int, non-IntImm runtime scalar) |
CRASH ... should be an IntImmNode. |
same root — any non-IntImm scalar on the T.Parallel path aborts |
PART-1 example run. The shipped coalesced_width sites (AMD flash-attn fwd/bwd examples, gfx950 copy-async test) were located in the v0.1.13 tree; all route the value through T.copy/T.async_copy, none through T.Parallel, so none exercise this crash — the draft's Reach is confirmed by source, not merely inherited. They are AMD-targeted and were not run on this L40S session; the sibling-path claim they support (bare int is a legal coalesced_width) was instead verified directly on CUDA (row 3/4 above).
Framing decision. The two crashing cells (typed kwarg, generic annotations dict) share one root, so the write-up is kept at the T.Parallel-int-path level (title/Problem unchanged) — the class is exactly "a bare/non-IntImm coalesced_width reaching ComputePlanCandidate via the raw T.Parallel FFI attach." The divisibility branch (parallel.cc:804) is a distinct adjacent check and is not filed. No new bug was found while sweeping.
Required prerequisites
What version of TileLang are you using?
0.1.13
System information
TileLang 0.1.13 / CUDA 12.8 / PyTorch 2.8 ; run on NVIDIA L40S (sm_89). The failure is in
LayoutInference(host-side layout planning), before any device code is generated, so it is architecture-independent.Problem description
Passing an
inttoT.Parallel's documentedcoalesced_widthkeyword aborts compilation:coalesced_widthis a public keyword ofT.Parallel, typedint | Nonein the signature (loop.py#L15) and documented asOptional[int](loop.py#L28), so a plain Python int is exactly what the signature promises. It never reaches codegen — the crash fires duringLayoutInference. The same int works everywhere else it is accepted: passing it viaT.copy(..., coalesced_width=<int>)compiles fine, and wrapping the int intir.IntImmbefore handing it toT.Parallelalso compiles fine (both verified below), so the value itself is legal.Not a regression — see Provenance.
Reproducible example code
Traceback
Expected behavior
T.Parallel(..., coalesced_width=4)should compile and use the requested coalesced width, matching its ownint | Nonesignature and the siblingT.copy(..., coalesced_width=<int>)path, which accepts a bare int over the same value. At minimum a documented, correctly-typed argument should not abort compilation.Additional context
Guard boundary tested (this session, 0.1.13, L40S) — the bare-int path aborts unconditionally
Each a separate compile of the same kernel, varying only how
coalesced_widthis passed toT.Parallel:coalesced_width=1(bare int, smallest legal)coalesced_width should be an IntImmNode.2(bare int)coalesced_width should be an IntImmNode.T.IntImm("int32", 2)(wrapped)T.IntImm("int32", 4)(wrapped, CONTROL)So the abort is unconditional on the bare-
intpath (independent of the value) and disappears the moment the same value is wrapped inIntImm— a missing-case / type-coercion gap, not a value problem. It is a distinct branch from the sibling divisibility abort ("Vector size N is not divisible by coalesced width M",parallel.cc:804): that one honors the int type and rejects on geometry; this one rejects the int type itself (parallel.cc:808). Same functionComputePlanCandidate, adjacent code, different root — reported separately.Root cause.
T.Parallelattaches thecoalesced_widthargument to the loop-annotation map as a raw Python int, but the C++ layout planner reads it back as anIntImmNodeand hard-fails when it is not one. Inloop.py#L79-L87the value is placed inmerged_annotationsunconverted (merged_annotations["coalesced_width"] = coalesced_width) and handed straight to_ffi_api.Parallel(extents, merged_annotations), which attaches the dict to theFornode's annotation Map; the value arrives as a runtime int, not atir.IntImm. Then inparallel.cc#L798-L809,ParallelOpNode::ComputePlanCandidatedoescoalesced_width->as<IntImmNode>(), which returns null, and takes theLOG(FATAL)branch (parallel.cc:808).The
T.copyfamily stores the argument the same raw way (copy_op.py#L124-L125), and its lowering (copy.cc#L538-L540) does re-attachkCoalescedWidthonto aForKind::kParallelloop that reaches the sameComputePlanCandidate/as<IntImmNode>()read. The reason copy does not crash is not that it skips that read — it is that copy passes its annotations throughcall_intrin(..., annotations=ann), which builds a TVMMapand coerces the Python int to atir.IntImm(a properIntImmNode) as part of node construction, whereas_ffi_api.Parallelattaches the raw Python dict directly and no such coercion happens. So the divergence is a coercion gap at the FFI boundary ofT.Parallel, not a difference in what the C++ planner reads. (Verified this session:T.copy(..., coalesced_width=4)compiles and returns the correct result on CUDA; see Reach.)Suggested fix. Wrapping the value at the Python boundary — e.g.
merged_annotations["coalesced_width"] = IntImm("int32", coalesced_width)inT.Parallel— makes the annotation anIntImmNodeand lets the existingparallel.ccbranch run; the CONTROL above (which does exactly this wrap at the call site) compiles and returns the correct result, so this is the minimal change. AlternativelyComputePlanCandidatecould coerce the annotation viaDowncast/arithbefore theas<IntImmNode>()read.Provenance. The
coalesced_widthkeyword onT.Parallelwas added by #1887 (merged 2026-03-06), which introduced the raw-int passthrough atloop.py:80; that is when the crash first became reachable. Theas<IntImmNode>()FATAL inparallel.ccpredates it (present since the codebase migration #10, 2025-01-11), so the two halves never agreed on the type — the argument crashes on every release that exposes the keyword, not a regression.Dedup. Searched the open and closed tracker (
coalesced_width,IntImmNode,should be an IntImmNode) and found no existing report of this defect.Reach. The trigger is documented and typed:
coalesced_widthis declaredint | Noneand documentedOptional[int], so an int is the type the API asks for.coalesced_widthappears in shipped code (git grep coalesced_width examples/ testing/at v0.1.13): every site routes it throughT.copy/T.async_copy, e.g.examples/amd/example_amd_flash_attn_bwd.py:163,172-173andexamples/amd/example_amd_flash_attn_fwd.py:183,192-193(coalesced_width=vec_size), andtesting/python/amd/test_tilelang_gfx950_copy_async.py. No example or test passes it toT.Parallel(git grep 'Parallel(' examples/ testing/ | grep coalesced_widthat v0.1.13 is empty), so CI never exercises the crashing path. These shipped sites are all AMD gfx950-targeted and were not runnable on this session's hardware (L40S, sm_89), but the load-bearing fact they establish — that a bare int through the copy family is a legalcoalesced_width— was verified directly on CUDA this session:T.copy(dst, src, coalesced_width=4)compiled and returnedtorch.equal(...) == True. Given the keyword onT.Parallel, any value trips the crash — the failure is unconditional on that int path (the CONTROL only differs by wrapping the same value inIntImm), and it also fires when the int is supplied via the genericannotations={"coalesced_width": <int>}dict, so it is the FFI-attach path ofT.Parallel, not the specific keyword.Impact. The narrow ingredient is passing
coalesced_widthas a bare int throughT.Parallelspecifically (the siblingT.copypath and theIntImm-wrapped form both compile). When it fires it is a loud compile-time abort duringLayoutInference— no device code is produced, so nothing is silently miscompiled and no output is corrupted; the failure is deterministic and caught immediately at build time. Fixing it removes a type disagreement between the Python signature (int | None) and the C++as<IntImmNode>()read, letting a documented, correctly-typed argument build the kernel it should.Generalization (root analysis + 4-axis sweep, all cells run this session, 0.1.13 / L40S)
Two-level root.
_ffi_api.Parallel(extents, merged_annotations)(loop.py#L79-L87) attaches the raw Python annotation dict to theFornode with no int→IntImmcoercion, whileParallelOpNode::ComputePlanCandidatereads the value throughas<IntImmNode>()andLOG(FATAL)s on a miss (parallel.cc#L798-L809). The fragile code is this uncoerced FFI-attach on theT.Parallelframe.coalesced_width(kCoalescedWidth) annotation is consumed only inComputePlanCandidate. Every op that produces a parallel loop carrying that annotation —T.Parallel, and via re-attachT.copy/T.async_copy(copy.cc#L538-L540) and the atomic reductions (atomic_add.cc:200-202,atomic_reduce.h:129-131) — funnels through that singleas<IntImmNode>()read. The copy/atomic ops are safe only because their annotations are laundered into anIntImmbycall_intrin'sMapconstruction;T.Parallelis the one entry point that attaches raw.T.Parallel(..., coalesced_width=4)(bare int)coalesced_width should be an IntImmNode.T.Parallel(..., coalesced_width=T.IntImm("int32",4))torch.equal==TrueT.Parallel(..., annotations={"coalesced_width":4})(bypass the typed kwarg)... should be an IntImmNode.T.copy(dst, src, coalesced_width=4)(bare int)torch.equal==Trueas<IntImmNode>()read, butcall_intrincoerces int→IntImm, so no crash (documents WHY sibling dodges)T.copy(dst, src, annotations={"coalesced_width":4})(bare int via dict)Mapconstructioncoalesced_width=T.IntImm("int32",3)(vector_size=4)Vector size 4 is not divisible by coalesced width 3(parallel.cc:804)coalesced_width=T.IntImm("int64",4)(width neighbor)torch.equal==TrueIntImmNodewidth passes — confirms the check is type-tag onlycoalesced_width=np.int32(4)(common user int, non-IntImmruntime scalar)... should be an IntImmNode.IntImmscalar on theT.Parallelpath abortsPART-1 example run. The shipped
coalesced_widthsites (AMD flash-attn fwd/bwd examples, gfx950 copy-async test) were located in the v0.1.13 tree; all route the value throughT.copy/T.async_copy, none throughT.Parallel, so none exercise this crash — the draft's Reach is confirmed by source, not merely inherited. They are AMD-targeted and were not run on this L40S session; the sibling-path claim they support (bare int is a legalcoalesced_width) was instead verified directly on CUDA (row 3/4 above).Framing decision. The two crashing cells (typed kwarg, generic annotations dict) share one root, so the write-up is kept at the
T.Parallel-int-path level (title/Problem unchanged) — the class is exactly "a bare/non-IntImmcoalesced_widthreachingComputePlanCandidatevia the rawT.ParallelFFI attach." The divisibility branch (parallel.cc:804) is a distinct adjacent check and is not filed. No new bug was found while sweeping.