LegalizeTileCast Pass¶
Expands tile.cast (src, dst) pairs that the active pto.tcvt profile cannot emit as a single instruction into the shortest chain of native casts.
Overview¶
For each var = tile.cast(...):
- Ask the active
BackendHandlerfor its native-conversion table viaGetTcvtAdjacency()(transcribed from pto-isatcvtSupported Conversions). The pass holds no per-architecture knowledge of its own, so a new backend ships its own table and needs no change here. With no backend configured the pass is a no-op. - Already native: leave unchanged (including FIXPIPE-foldable
FP32→BF16/FP16withmode=rint). - Non-native: BFS for a shortest path; among equal-length paths prefer "same byte-width → float, then adjust width".
Typical A5 results: INT32→FP16 → INT32→FP32→FP16; FP16→BF16 → FP16→FP32→BF16.
Unreachable pairs hard-fail with src/dst/arch in the diagnostic.
Requires / Produces / Invalidates: none (empty PassProperties).
Native casts vs legalized chains¶
pl.cast does not always compile to one instruction. Whether a given
(src, dst) pair is a single hardware pto.tcvt or a legalized chain depends
entirely on the target architecture, and the difference is visible in both
performance and numerics. The tables below list, per architecture, which pairs
are native and which this pass expands; (n) is the number of tcvt
instructions emitted.
Cost. A legalized chain issues one tcvt per hop over the whole tile, plus
an intermediate tile per hop (subject to the usual buffer reuse). A 3-hop chain
is therefore roughly three times the vector work of a native cast on the same
shape. In a vector-bound kernel that is a real cost — if a hot loop shows an
unexpected chain, consider whether an equivalent dtype path avoids it.
Numerics. A chain is bit-identical to a direct conversion when every
intermediate represents the source values that fall inside the destination's
range exactly — then only the final hop rounds. INT32 -> FP32 -> FP16 is in
this class: FP16 saturates above 65504, and every integer below that is exact in
FP32, so the FP32 hop never rounds.
Where an intermediate rounds first, the chain double-rounds and can land one ULP of the destination away from a directly rounded conversion:
| Chain | Why it double-rounds | Measured |
|---|---|---|
INT32 -> FP32 -> BF16 |
BF16's range covers all of INT32, but FP32 holds only 24 significand bits, so the first hop rounds inputs above 2^24 | 3 in 200000 uniform INT32 differ by 1 BF16 ULP |
FP32 -> FP16 -> INT8 |
FP16 rounds before the integer hop | boundary values only |
This is not a regression against a better option: the ISA offers no direct
conversion for these pairs at all, so the chain is the only available lowering.
It also matches the reference — torch's own int32 -> bfloat16 agrees with the
chain on all of 2000000 uniform INT32 samples, because torch bridges through
fp32 the same way.
How to check what your kernel got. Each hop appears as a pto.tcvt in the
generated MLIR:
Ascend950 (a5)¶
| from | native (1 instruction) | legalized chain (n instructions) |
|---|---|---|
bf16 |
fp16, fp32, fp4, int32 |
fp8e4m3(2), fp8e5m2(2), hf8(2), int16(2), int8(2), uint16(2), uint8(2) |
fp16 |
fp32, hf8, int16, int32, int8, uint8 |
bf16(2), fp8e4m3(2), fp8e5m2(2), uint16(2), fp4(3) |
fp32 |
bf16, fp16, fp8e4m3, fp8e5m2, hf8, int16, int32, int64 |
fp4(2), int8(2), uint16(2), uint8(2) |
fp4 |
bf16 |
fp8e4m3(3), fp8e5m2(3), hf8(3), int8(3), uint8(3) |
fp8e4m3 |
fp32 |
bf16(2), fp16(2), fp8e5m2(2), hf8(2), int16(2), fp4(3), int8(3), uint16(3), uint8(3) |
fp8e5m2 |
fp32 |
bf16(2), fp16(2), fp8e4m3(2), hf8(2), int16(2), fp4(3), int8(3), uint16(3), uint8(3) |
hf8 |
fp32 |
bf16(2), fp16(2), fp8e4m3(2), fp8e5m2(2), int16(2), fp4(3), int8(3), uint16(3), uint8(3) |
int16 |
fp16, fp32, int32, uint32, uint8 |
bf16(2), fp8e4m3(2), fp8e5m2(2), hf8(2), int8(2), uint16(2), fp4(3) |
int32 |
fp32, int16, int64, uint16, uint8 |
bf16(2), fp16(2), fp8e4m3(2), fp8e5m2(2), hf8(2), fp4(3), int8(3) |
int64 |
fp32, int32 |
bf16(2), fp16(2), fp8e4m3(2), fp8e5m2(2), hf8(2), int16(2), uint16(2), uint8(2), fp4(3), int8(3) |
int8 |
fp16, int16, int32 |
hf8(2), uint16(2), uint8(2), fp8e4m3(3), fp8e5m2(3), fp4(4) |
uint32 |
int16, uint16, uint8 |
int8(3) |
uint8 |
fp16, uint16 |
hf8(2), int8(2), fp8e4m3(3), fp8e5m2(3), fp4(4) |
Ascend910B (a2a3)¶
| from | native (1 instruction) | legalized chain (n instructions) |
|---|---|---|
bf16 |
fp32, int32 |
fp16(2), int16(2), int4(3), int8(3), uint8(3) |
fp16 |
fp32, int16, int32, int4, int8, uint8 |
bf16(2) |
fp32 |
bf16, fp16, int16, int32, int64 |
int4(2), int8(2), uint8(2) |
int16 |
fp16, fp32 |
bf16(2), int4(2), int8(2), uint8(2) |
int32 |
fp16, fp32, int16, int64 |
bf16(2), int4(2), int8(2), uint8(2) |
int4 |
fp16 |
int8(2), uint8(2) |
int64 |
fp32, int32 |
bf16(2), fp16(2), int16(2), int4(3), int8(3), uint8(3) |
int8 |
fp16 |
int4(2), uint8(2) |
uint8 |
fp16 |
int4(2), int8(2) |
Pairs absent from both columns are rejected: the pass reports src, dst and arch rather than emitting a lossy chain.
When it runs¶
Default pipeline:
API¶
| C++ | Python |
|---|---|
pass::LegalizeTileCast() |
passes.legalize_tile_cast() |