Summary
While reviewing #215340, @banach-space raised a question bigger than that patch:
We have effectively been walking away from
in_boundsin favour of masking. Given SVE’s
solid HW support for predication, that’s what we’ve been using instead. But even more
broadly, I am not aware ofin_boundsproviding anything that masking wouldn’t. So, instead
of making the logic aroundin_boundsmore complex, I’d rather seein_boundscompletely
removed and instead rely on masks.
This affects the whole dialect, not one patch, so I am bringing it here with measurements.
@banach-space: I moved this out of the PR thread rather than let it stall there. I have tried to state your proposal the way you made it. Please correct me if I got any of it wrong: the idea is yours, the framing is mine.
Short version. Masking replaces in_bounds completely on hardware that has predication. On hardware that does not, LLVM turns every masked load into one branch per lane, and the code gets about ten times bigger. Only 8 of the 25 in-tree LLVM targets implement masked load/store at all; the other 17 fall back to that per-lane expansion. So removing in_bounds today would make generated code worse on most targets.
I am not arguing against removing it. I am arguing against removing it first. The proposal in Sec.8 is about ordering.
My interest, stated up front: I have an open PR that adds in_bounds inference, so I am not a neutral person. That is why this post is measurements and source links rather than argument. Everything below can be re-run.
1. What in_bounds actually does
in_bounds is not just a hint. It picks which lowering you get. From the op description in VectorOps.td:
A
vector.transfer_readcan be lowered to a simple load if all dimensions are specified to
be within bounds and nomaskwas specified.
And the code that enforces it, LowerVectorTransfer.cpp:459:
// Out-of-bounds dims are handled by MaterializeTransferMask.
if (read.hasOutOfBoundsDim())
return rewriter.notifyMatchFailure(read, "out-of-bounds needs mask");
So “remove in_bounds and rely on masks” means “always take the masked path”.
2. What the masked path costs, per target
This is the main point, and it is decided in the LLVM backend, not in MLIR.
llvm/lib/Transforms/Scalar/ScalarizeMaskedMemIntrin.cpp says what it does:
This pass replaces masked memory intrinsics - when unsupported by the target
- with a chain of basic blocks, that deal with the elements one-by-one if the
appropriate mask bit is set.
It asks the target through TTI.isLegalMaskedLoad / isLegalMaskedStore. If the target says no, you get one conditional block per lane.
The default answer is no. TargetTransformInfoImpl.h:363:
virtual bool isLegalMaskedLoad(Type *DataType, Align Alignment,
unsigned AddressSpace,
TTI::MaskKind MaskKind) const {
return false;
}
BasicTTIImpl does not override this, so any target that does not implement it inherits false. 8 of 25 in-tree targets override it: AArch64, ARM, Hexagon, NVPTX, PowerPC, RISCV, VE, X86. The other 17, including AMDGPU, WebAssembly, SPIR-V, SystemZ, MIPS, LoongArch and SPARC, always scalarize.
And even the 8 that implement it only allow it under conditions:
| target | masked load/store allowed when |
|---|---|
| AArch64 | isSVEorStreamingSVEAvailable(). Fixed-width vectors also need to be 64- or 128-bit, or useSVEForFixedLengthVectors (AArch64TargetTransformInfo.h:311) |
| X86 | ST->hasAVX() (X86TargetTransformInfo.cpp:6690) |
| ARM | hasMVEIntegerOps() and EnableMaskedLoadStores (ARMTargetTransformInfo.cpp:1115) |
| RISC-V | hasVInstructions(). Fixed-width also needs useRVVForFixedLengthVectors() (RISCVTargetTransformInfo.h:281) |
| the other 17 | never |
The part worth noticing: this is not “x86 versus Arm”. It is a split inside AArch64. SVE has predication and NEON does not, so the same architecture lands on both sides.
3. Measurement A: the mechanism on its own
Hand-written LLVM IR, no MLIR involved. The only difference between the two runs is one target flag.
declare <4 x float> @llvm.masked.load.v4f32.p0(ptr, i32, <4 x i1>, <4 x float>)
declare void @llvm.masked.store.v4f32.p0(<4 x float>, ptr, i32, <4 x i1>)
define <4 x float> @masked_load(ptr %p, <4 x i1> %m, <4 x float> %pass) {
%r = call <4 x float> @llvm.masked.load.v4f32.p0(ptr %p, i32 4, <4 x i1> %m, <4 x float> %pass)
ret <4 x float> %r
}
define void @masked_store(<4 x float> %v, ptr %p, <4 x i1> %m) {
call void @llvm.masked.store.v4f32.p0(<4 x float> %v, ptr %p, i32 4, <4 x i1> %m)
ret void
}
define <4 x float> @plain_load(ptr %p) {
%r = load <4 x float>, ptr %p, align 4
ret <4 x float> %r
}
llc -mtriple=aarch64-unknown-linux-gnu masked.ll -o neon.s # NEON
llc -mtriple=aarch64-unknown-linux-gnu -mattr=+sve masked.ll -o sve.s # SVE
Instructions and branches in each function:
| NEON | SVE | |
|---|---|---|
masked_load |
25 instrs, 7 branches | 7 instrs, 0 branches |
masked_store |
23 instrs, 7 branches | 6 instrs, 0 branches |
plain_load |
2 instrs, 0 branches | 2 instrs, 0 branches |
On NEON you get one conditional block per lane, exactly as the pass description says:
tbnz w8, #0, .LBB0_5
tbnz w8, #1, .LBB0_6
...
.LBB0_5: // %cond.load
ld1 { v1.s }[0], [x0]
.LBB0_6: // %cond.load1
add x9, x0, #4
ld1 { v1.s }[1], [x9]
On SVE the mask is just a predicate register:
cmpne p1.s, p0/z, z0.s, #0
ld1w { z0.s }, p1/z, [x0]
4. Measurement B: the same thing through MLIR
Same vector.transfer_read twice, differing only in the attribute, put through the standard lowering pipeline:
mlir-opt ib.mlir -lower-affine -convert-vector-to-scf -convert-scf-to-cf \
-convert-vector-to-llvm -finalize-memref-to-llvm -convert-func-to-llvm \
-convert-arith-to-llvm -convert-cf-to-llvm -convert-ub-to-llvm \
-reconcile-unrealized-casts -o ib-llvm.mlir
mlir-translate --mlir-to-llvmir ib-llvm.mlir -o ib.ll
llc -mtriple=aarch64-unknown-linux-gnu ib.ll -o ib-neon.s
llc -mtriple=aarch64-unknown-linux-gnu -mattr=+sve ib.ll -o ib-sve.s
func.func @with_in_bounds(%m: memref<1000xf32>, %i: index, %p: f32) -> vector<4xf32> {
%v = vector.transfer_read %m[%i], %p {in_bounds = [true]} : memref<1000xf32>, vector<4xf32>
return %v : vector<4xf32>
}
func.func @without_in_bounds(%m: memref<1000xf32>, %i: index, %p: f32) -> vector<4xf32> {
%v = vector.transfer_read %m[%i], %p : memref<1000xf32>, vector<4xf32>
return %v : vector<4xf32>
}
| function | NEON | SVE |
|---|---|---|
with_in_bounds |
3 instrs, 0 branches | 3 instrs, 0 branches |
without_in_bounds |
32 instrs, 7 branches | 13 instrs, 0 branches |
Two things come out of this. The second one surprised me.
- On NEON, dropping
in_boundsmakes this about 10x bigger and adds seven branches. The four%cond.loadblocks are there, same as Measurement A. - Even on SVE it goes from 3 instructions to 13. The load itself is fine. But the mask still has to be built from the memref size before every transfer. Predication makes the memory access cheap. It does not make the mask free.
5. Measurement C: a real kernel
PolyBench 4.2.1 jacobi-2d, MEDIUM size (N=250, TSTEPS=100), unmodified. Both arms are the same MLIR; the only difference is whether in_bounds was inferred. The two arms run interleaved in one process, their outputs are compared bit-for-bit before any timing, and an A-vs-A run gives the noise floor.
| AArch64 NEON, vector width 4 | masked | with in_bounds |
|---|---|---|
| kernel instructions | 317 | 85 |
| kernel branches | 108 | 12 |
llvm.masked.* in the module |
14 | 0 |
| runtime | 14.79 ms | 2.91 ms (-80%) |
For contrast, on AVX-512, where a masked load really is one instruction, the same kernel still gets 13-32% faster depending on vector width, and drops from 88 to 59 instructions. The reason is the same as point 2 above: this kernel does 12 masked memory operations per iteration and builds a mask for each one.
What this does not show. It is one kernel. The change only helps when the trailing dimension is not divisible by the vector width and the loop bound is smaller than that dimension. That describes stencils, not dense linear algebra: 1 of the 7 PolyBench kernels I transcribed. The AArch64 numbers were measured on Apple silicon, which has NEON and no SVE; the AVX-512 numbers on a c7i.8xlarge. Nothing in this post was timed on SVE hardware. The SVE columns in Measurements A and B are static instruction counts from cross-compiling, not timings.
6. Where in_bounds is used today
@banach-space is right that the project has been moving toward masking. But the move is only
half done. createReadOrMaskedRead / createWriteOrMaskedWrite take a
useInBoundsInsteadOfMasking flag, and on main today:
| caller | which mode |
|---|---|
Linalg vectorizer, when the user gives vector sizes (Vectorization.cpp:1866, :2068) |
masking |
Linalg pack / unpack / insert-slice, static shapes (:1699, :1808, :3012) |
in_bounds |
-affine-super-vectorize (SuperVectorize.cpp:1270, :1329) |
in_bounds |
So removing the attribute also means migrating those callers.
How much code is involved, counted on upstream/main @ 7214eb03c282:
| count | |
|---|---|
.cpp/.h files under mlir/lib using the attribute or its accessors |
28 |
files under mlir/include |
5 |
test files mentioning in_bounds |
95 |
| times it appears in tests | ~1,810 |
Plus the op description itself, and any out-of-tree code that relies on it.
7. The part of the argument I think is correct
in_bounds really has caused subtle bugs. I found two of them myself, both in the same
fifteen-line function:
- #213506: the fold ignored scalable
dimensions. Avector<[4]xf32>read from amemref<4xf32>was marked in-bounds and lowered
to an unpredicated load. I confirmed it as a segfault on a Graviton3 against a guard page. - The same check accepted negative constant indices, which contradicts the spec line saying
in_boundscovers the starting point too.
An attribute that claims a safety property has to be got right in every folder and every lowering. A mask is safe because of what it is, not because someone proved something. That is a real argument for removal and I do not want to talk it away.
8. Proposal
Not “keep in_bounds”. An ordering:
in_boundscan go oncellvm.masked.load/storelower well on targets without predication. Right now they do not, and that is an LLVM backend problem rather than an MLIR one. Removing the attribute before that just moves the cost onto those 17 targets.- The migration in Sec.6 has to finish first. The callers still passing
useInBoundsInsteadOfMasking=trueneed to move over, and that is where the remaining semantic questions actually are. - Separately from both, there is a question of whether MLIR should keep inferring
in_boundswhile it still exists. My open PR does that inside a folder, which @matthias-springer rightly objected to because of compile-time cost. Both he and @banach-space prefer an opt-in pass, and that is what I plan to write.
Questions
- Is the target-support argument in Sec.2 the right way to look at this? Or is the intent to accept slower code on the other 17 targets in exchange for a simpler dialect?
- Is there a plan to improve masked lowering on targets without predication that I have missed? If
ScalarizeMaskedMemIntrinis expected to get better, the ordering changes. - Point 2 in Sec.4, the cost of building the mask even where masking is native, suggests in_bounds` still buys something on SVE and AVX-512, just less. Is that overhead considered acceptable, or is there a lowering that avoids rebuilding the mask for every transfer?
- If removal does go ahead, should the inference work stop now, or is an opt-in pass still worth having in the meantime?
Reproducing
Measurements A and B are two small files and four llc runs. The IR and the exact commands are above, so they should be reproducible from this post alone. Measurement C, the PolyBench kernels, the A/B timing harness and the fold survey are here:
https://github.com/dhairyashilRG/mlir-inbounds-harness
If any number here does not reproduce, please say so and I will correct the post.