TritonToUnstructured: handle BitcastOp on pointer tensors - #32
Conversation
Change-Id: I1a7e0eab7e7c9391c8d3a28cfee3a80f7f9d663e
|
Hi @sdalvi-quic! Thank you for your pull request and welcome to our community. Action RequiredIn order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you. ProcessIn order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA. Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
|
Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks! |
Change-Id: I396e5e80842ab8e81b515710e8f4bdf26b8de1cb
mitekoth
left a comment
There was a problem hiding this comment.
LGTM, with minor comments! Will let @nhat-nguyen chime in if this lowering is valid and/if any other changes are required.
| // Tensor bitcast consumed; scalar bitcast of base pointer inserted. | ||
| // CHECK: tt.bitcast %arg0 : !tt.ptr<i1> -> !tt.ptr<i8> | ||
| // CHECK: tts.gather | ||
| // CHECK: tts.scatter |
There was a problem hiding this comment.
Please expand the lit test check to be more specific.
|
@nhat-nguyen Would be great if you could provide @sdalvi-quic with access to run CI, thanks! |
Change-Id: I34f7ebeda9ba1af82f3a2fff2aae0cb6ae02d800
|
Hi @nhat-nguyen, can you please review the PR? Thanks! |
| PtrOffset newOffsetInfo{newBasePtr, resType, | ||
| offsetInfo.bitWidth, | ||
| offsetInfo.offset}; | ||
|
|
There was a problem hiding this comment.
In general, reusing the previous offset after bitcast is not correct.
I think bitcast is generally hard and ambiguous to handle in this pass. Let's look at this example:
%base_ptr = single ptr of !tt.ptr<i8>
%const_1 = 1
%const_2 = 2
%ptr_0 = tt.addptr %base_ptr %const_1 !tt.ptr<i8> // offset: +1
%ptr_2 = tt.addptr %ptr_0 %const_2 !tt.ptr<i8> // = offset: +3 (since we + 1 above)
%new_ptr = tt.bitcast from !tt.ptr<i8> to !tt.ptr<i16>
%new_ptr_0 = tt.addptr %new_ptr %const_2 // offset: +5 (wrong!)
Each tt.addptr scales the offsets according to the base pointer type (so address + sizeof(sizeof(pointee_type))). When we bitcast, the base has changed so we cannot reuse the old accumulated offsets anymore.
For a scalar pointer, we can fix this by treating the bitcast as the start of a new offset chain (i.e: offset start from 0). The new base pointer will be at the result of the bitcast. We have a patch internally that will handle this. For the above example, this looks roughly like:
// we have accumulated offset = 3 so far, so materialize it using tt.addptr:
%ptr = tt.addptr %base_ptr %const_3
%new_ptr = tt.bitcast from !tt.ptr<i8> to !tt.ptr<i16> // now this is the new base pointer going forward, offset is now reset to 0 too
For tensor of pointers, which is what you are trying to support here, I think there's no good way to represent this generally. This is because after we "materialize" the bitcast, we end up with a tensor of pointers with no single base.
%out_addptr = tt.addptr %out_splat, %offsets : tensor<1024x!tt.ptr<i8>>, tensor<1024xi32>
%bitcast = tt.bitcast %out_addptr from tensor<1024x!tt.ptr<i8>> to tensor<1024x!tt.ptr<i16>>
^ we do not have a single base pointer to transform anymore
We cannot use the original base pointer because the offsets while in !tt.ptr<i8> are scaled by sizeof(i8), while the new offsets in the new type will follow sizeof(new_type).
There was a problem hiding this comment.
Thanks for the explanation!
The failure we hit is specifically with boolean kernels where Triton widens i1 loads to i8, inserting tt.bitcast on tensor<1024x!tt.ptr<i1>> → tensor<1024x!tt.ptr<i8>>. For this case, both types have effective stride of 1 byte, so reusing the offset is correct.
Two options I see:
- Handle only same-stride cases in this pass: Add a BitcastOp handler that propagates offsets only when sizeof(src) == sizeof(dst), and errors on other conversions.
- Eliminate the bitcast upstream: Fix it so the i1→i8 bitcast never reaches this pass (e.g., canonicalize the pointer type earlier in the pipeline). What would be the best place to address this?
Which approach would you recommend?
There was a problem hiding this comment.
Handle only same-stride cases in this pass: Add a BitcastOp handler that propagates offsets only when sizeof(src) == sizeof(dst), and errors on other conversions.
I'm not sure I follow sizeof(src) == sizeof(dst). We are casting i1 to i8, so sizeof(i1) != sizeof(i8) right?
Do you only need to handle i1 to other larger-width integer types, and that the bitcast is guaranteed not to be used later in other pointer arithmetics?
I think i1 -> larger-width types is safe (like a void* to other types reinterpret_cast). But since this pass just does the transformation generally, we should probably handle this elsewhere.
Eliminate the bitcast upstream: Fix it so the i1→i8 bitcast never reaches this pass (e.g., canonicalize the pointer type earlier in the pipeline). What would be the best place to address this?
Could you elaborate on what the transformation could look like?
There was a problem hiding this comment.
Handle only same-stride cases in this pass: Add a BitcastOp handler that propagates offsets only when sizeof(src) == sizeof(dst), and errors on other conversions.
By this I wanted to refer to the behavior where MLIR's DataLayout reports
getTypeSize(i1) == 1 and getTypeSize(i8) == 1 (sub-byte types
occupy 1 byte in memory). This would result in same offset for both. I have updated the PR with this logic.
For ptrs with different strides, I updated the approach to guard against those cases and fail the pass as per the existing behavior.
Eliminate the bitcast upstream
About this, I am not sure if there is any more appropriate place for the fix. I am open to suggestions.
There was a problem hiding this comment.
By this I wanted to refer to the behavior where MLIR's DataLayout reports
getTypeSize(i1) == 1 and getTypeSize(i8) == 1 (sub-byte types
occupy 1 byte in memory).
I'm not sure I follow this. Is this generally true? a (void*) + 1 is different from a (int8*) + 1 even if getTypeSize(i1) == 1 and getTypeSize(i8) == 1. I think this is only safe to do if the bitcast is the last user of an offset chain. If you use the offset afterwards it's still going to be illegal.
There was a problem hiding this comment.
@sdalvi-quic can you add more tests cases to demonstrate that we're correctly handling multiple bitcast chains?
There was a problem hiding this comment.
Hi @nhat-nguyen, I have added python and mlir test for multiple bitcast chains.
The primary motivation for this patch is supporting i1 → i8 pointer bitcast, which Triton emits when a kernel operates on boolean tensors. In MLIR's i1 has a store size of 1 byte (no sub-byte packing) similar to i8, so i1 and i8 share the same byte stride. Reusing the accumulated element offset across the bitcast should be fine. The pass rejects cases where the pointee byte size differs (e.g., i8 → i16).
I have also added a Python E2E test (test_bitcast_ptr.py) that verifies correct memory access at runtime. Can you please take a look and let me know if the PR looks good?
Change-Id: I01201db2f01124e5b78a2de399807b7d401d769f
Change-Id: I9c3e4cfb41c846fea5499e4ce1e1828ebb18cb18
|
Hi @nhat-nguyen, I have made the recommended changes. Can you please review the PR and also help with the CI? Thanks! |
…ptr-unstructured Change-Id: I764f8fed65136b8a2c5cc85086ea8cf2d13a1ce2
Change-Id: Iae1fe13041014fdf28bea30ebc66893026c0ca03
Change-Id: Iac44c2c83ccd8c2b391b88a2b198bb0e33e085d9
| cast<RankedTensorType>(resType).getElementType()) | ||
| .getPointeeType(); | ||
| } else { | ||
| srcPointeeTy = cast<triton::PointerType>(srcType) |
There was a problem hiding this comment.
I think this crashes on a loop-carried pointer, though I haven't run it — could you check? The guard only looks at the result type, but then we cast the source type. When the bitcast's source is an scf.for iter-arg, the for-loop handler retypes that iter-arg to the integer offset type in place before this use gets processed, so src.getType() ends up tensor<...xi32> and cast<triton::PointerType> on it asserts. Rough repro:
tt.func public @loop_carried_bitcast(%arg0: !tt.ptr<i1>, %arg1: !tt.ptr<i8>) {
%c0 = arith.constant 0 : i32
%c1 = arith.constant 1 : i32
%c4 = arith.constant 4 : i32
%step = arith.constant dense<8> : tensor<128xi32>
%0 = tt.make_range {end = 128 : i32, start = 0 : i32} : tensor<128xi32>
%1 = tt.splat %arg0 : !tt.ptr<i1> -> tensor<128x!tt.ptr<i1>>
%2 = tt.addptr %1, %0 : tensor<128x!tt.ptr<i1>>, tensor<128xi32>
%res = scf.for %i = %c0 to %c4 step %c1 iter_args(%p = %2)
-> (tensor<128x!tt.ptr<i1>>) : i32 {
%bc = tt.bitcast %p : tensor<128x!tt.ptr<i1>> -> tensor<128x!tt.ptr<i8>>
%ld = tt.load %bc : tensor<128x!tt.ptr<i8>>
%sp = tt.splat %arg1 : !tt.ptr<i8> -> tensor<128x!tt.ptr<i8>>
%so = tt.addptr %sp, %0 : tensor<128x!tt.ptr<i8>>, tensor<128xi32>
tt.store %so, %ld : tensor<128x!tt.ptr<i8>>
%next = tt.addptr %p, %step : tensor<128x!tt.ptr<i1>>, tensor<128xi32>
scf.yield %next : tensor<128x!tt.ptr<i1>>
}
tt.return
}offsetInfo.ptrType still holds the real pointer type, so reading the pointees off that avoids it (and drops the tensor/scalar dyn_cast branch):
auto offsetInfo = offsetMap.at(src);
Type srcPointee = triton::getPointeeType(offsetInfo.ptrType);
Type dstPointee = triton::getPointeeType(resType);
unsigned srcBytes = dataLayout.getTypeSize(srcPointee);
unsigned dstBytes = dataLayout.getTypeSize(dstPointee);Would be great to add that loop case as a lit test too.
There was a problem hiding this comment.
Thanks for the pick. Updated the code.
| // The error from Test 4 (i8->i16 pointee byte size mismatch) appears first | ||
| // in the combined output because stderr is unbuffered. | ||
| // CHECK: error: bitcast between pointer types with different strides |
There was a problem hiding this comment.
this looks fragile, could we split the i8→i16 rejection into its own // ----- section (or file) with --verify-diagnostics and // expected-error {{bitcast between pointer types with different strides}} right on the op?
There was a problem hiding this comment.
Yes, added a failing test. Thanks!
nhat-nguyen
left a comment
There was a problem hiding this comment.
@sdalvi-quic thanks for adding more tests, generally lgtm. just 2 minor comments and we're good to go :)
Change-Id: I97531d22163d60a8ba51c022b739efd6a33ade67
|
@nhat-nguyen I have addressed the comments. Can you please review? |
|
Hi @nhat-nguyen, can you please review the PR? |
|
Ping for review @nhat-nguyen. Thanks! |
nhat-nguyen
left a comment
There was a problem hiding this comment.
Thanks for the great work!
|
@sdalvi-quic could you fix the failing lit test? |
Change-Id: Ic591d7c814d7efb9efbd77054c265e1bdc372762
Change-Id: Ifa8691f850644d08a37a56f87824e72a0dcbbe66
|
@nhat-nguyen, can you please trigger the CI? I fixed the test. Thanks! |
|
@nhat-nguyen the build has completed. Can you please merge the PR? Thanks! |
Summary
Handle
tt.bitcaston pointer tensors in the TritonToUnstructured pass's offset propagation worklist.When a kernel bitcasts a tensor of pointers (e.g.,
tensor<1024x!tt.ptr<i1>>→tensor<1024x!tt.ptr<i8>>), the pass previously hit the.Defaultcase and failed with "unexpected op in ptr sequence", blocking gather scatter lowering for the entire module.Approach
The fix adds a
.Case<triton::BitcastOp>handler that:Guards against different-stride bitcasts — uses
DataLayout::getTypeSizeto compare pointee byte sizes. If they differ (e.g.,ptr<i8>→ptr<i16>), the offset cannot be safely reused and the pass emits an error.Reuses the accumulated offset when strides match — when both pointee types have the same byte size (e.g.,
i1andi8both occupy 1 byte in DataLayout), the numeric offset remains valid across the bitcast.Bitcasts the scalar base pointer — creates a new
tt.bitcaston the scalar base pointer (kernel arg) so downstream gather/scatter ops see the correct element type.This is intentionally conservative: only same-stride ptr→ptr bitcasts are supported. Different-stride cases fail explicitly rather than silently producing incorrect code.
Change-Id: I1a7e0eab7e7c9391c8d3a28cfee3a80f7f9d663e