Repository navigation
feature: make LambdaInstUseVisitor clobbering aware - #526
johnbowen42 wants to merge 55 commits into
Conversation
37dfebc to
25cb26d
Compare
e1298c3 to
ad2d3ee
Compare
ad2d3ee to
976c996
Compare
c328997 to
5091dc4
Compare
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c465b25 to
3a50d90
Compare
3a50d90 to
34051bc
Compare
d2b9335 to
fd0f564
Compare
|
marking as draft temporarily while I push up several commits |
637be03 to
a1de26b
Compare
lambda_interior_slot_overwrite re-stores a two-slot functor's first slot from the authoritative source and overwrites the second slot from a donor functor before the call. The analysis anchors its reconciliation query at the narrow slot-0 store, so the slot-1 write is invisible: it does not cover the tracked byte (candidate collection skips its GEP) and does not alias the 4-byte anchor once SROA gives both stores one provable base. The analysis concludes the whole functor is authoritative from Initial and specializes; slot 1 executes with Donor's value, so both FileCheck assertions fail (a LambdaSpec line appears against CHECK-NOT, and Y prints 757 instead of 999). This FAILS until functor-extent tracking lands (gate item 8 in docs/dev/reviews/clobbering-aware-use-analysis.md): the query and candidate coverage must reason about [offset, offset + sizeof(functor)), not byte 0. Note the optnone variant of this shape declines only by accident -- spill reloads give the two slot stores unrelatable bases, so AA conservatively aliases the anchor -- which is the anchor-luck failure mode the review describes.
…ysis The wrapper operator's proteus.wrapper_call annotation gains a second payload: sizeof the registered closure, folded per-target by Sema at template instantiation, immune to IR type erasure. Both compilations parse it and stamp proteus.functor_size metadata on the wrapper operator; on the host, attachFunctorSizeToCallsiteMetadata recomputes the size from the registered functor type, so setU64FunctionMetadata's conflict fatal doubles as a host/device layout cross-check. The device manifest requires the metadata and passes the extent to analyzeLambdaUses.
Three size-based checks, each pinned by a test, none touching witness resolution: (a) the fallback MemorySSA query location is bounded to the prefix [0, tracked offset + size), so writes past the functor (envelope suffixes, enclosure trailing captures) can no longer be selected as the clobber and spuriously decline correct shapes; (b) a transfer that covers the tracked byte must cover the functor's full extent or decline, in both visitors -- a shorter copy (the classic wrong-sizeof bug, legal C++ with no casts) splices two sources into one functor and the byte-0 witness would lie for the uncovered slots; (c) memset declines on any overlap with the extent instead of ignoring interior-only hits. The precise zero-remaining-GEP location is deliberately NOT widened to the extent: interior slot stores are legitimate parallel copies in slot-wise assembly (the mfem shape), and extent-wide visibility requires per-piece reconciliation to accept them. Interior-store soundness is a frontend contract; see gate 8 in docs/dev/reviews/clobbering-aware-use-analysis.md. Verified via the pseudo-GPU harness on LLVM 19: all 16 lambda suites decision-identical to the pre-change baseline, and lambda_size_test's envelope-suffix case flips from spurious decline to specialize.
lambda_size_test pins the two extent behaviors: the enclosure shape (slot-wise assembly with a trailing capture past the functor) must specialize, and the envelope-suffix shape must specialize rather than spuriously decline when zero-index GEPs fold at -O1 and the suffix store would otherwise be selected as the clobber. The review doc records the resolved gate-8 design and the remaining frontend-diagnostic work item.
Records the open design decision underlying the interior-slot/mfem tension: specialization needs every registered slot's reaching definition to trace to one kernel argument, but the analysis resolves a single witness chain, so a provenance horizon must be chosen -- assume parallel copies behind a frontend contract (A, current), prove a common source within the assembling frame (B, loses mfem/RAJA whose pieces arrive SROA-decomposed), or merge per-piece kernel-arg resolutions (C, complete but N backward walks per callsite). Pins the decision drivers, the current position, and every artifact that must change if the position changes. Adds a Design Decisions section to the developer manual nav.
The kernel-arg merge (C) re-proves what A assumes rather than accepting anything new: mfem already specializes under the witness model, and the shapes C would newly reject -- closures dissected and reassembled from different kernel arguments through type erasure -- are written by no portability layer, forbidden by the usage contract, and visible to the AST checker, which is the cheaper and sharper enforcement point. Records the PR-level framing: the value of this work is the change in failure category (miscompile -> conservative decline/frontend error), not new acceptance.
Records the target IR shape (partial closure writes tracing to different kernel arguments past an accepting byte-0 witness), why conforming C++ cannot emit it (closure storage is writable only through whole-object special members; field-granular heterogeneity requires naming interior bytes, which requires type erasure), and the two probes: the union pun -- the only cast-free storage aliasing -- declines in both variants (MemoryPhi on the conditional path, unresolvable candidate set on the unconditional one) and is structurally whole-object either way; the aggregate-of-closures member splice correctly specializes, confirming the analysis keys on the closure, not its container. Fixes the AST diagnostic's scope as the exact erasure residue.
tests/gpu/lambda-spec-groundtruth.json records per-test specialize/decline decisions parsed from the ProteusPass debug trace; tools/lambda-spec-ci/lambda_spec_ci.py captures, checks, and renders it. The trace lines are identical for ROCm and pseudo-GPU runs, so one truth file serves both; CI can fail on any decision drift. Initial capture is from the pseudo-GPU pipeline and is marked as requiring ROCm re-capture before being treated as hardware ground truth.
One source base is not sufficient for multi-store reconciliation: a field-swapping copy from a single source (dest slot 0 <- src slot 1, dest slot 1 <- src slot 0) passed the same-base check and specialized transposed slots. Each candidate's (source offset - destination offset) must now equal the selected clobber's delta; mismatches decline. Verified with the lambda-spec ground-truth checker: zero decision drift across all 38 lambda GPU tests (pseudo-GPU pipeline, LLVM 19).
Three diagnostics implement the frontend soundness boundary decided in docs/dev/design/lambda-closure-provenance.md -- the IR analysis's witness model assumes closures are written only as whole objects, and these are exactly the type-erasure constructs that can break that assumption: - reinterpreting storage that contains a registered wrapper (directly or nested by value) through a type-erased mutable pointer; const reads stay legal. - memcpy/memmove/memset builtins whose destination is wrapper-containing storage (the wrong-sizeof partial copy needs no casts; the IR guards decline the shapes they can see, the frontend rejects them all). - unions with a wrapper-containing member. lambda_interior_slot_overwrite is retired from the GPU suite: its shape is now a compile error, covered by tests/ast/lambda_wrapper_storage_invalid.cpp together with partial-memcpy, envelope-memset, const-read-allowed, and union-pun cases. Ground truth updated (37 tests); spec-CI clean; all six existing AST tests and the mfem/factory/size-test GPU positives show no new diagnostics.
Removes code orphaned by the clobbering redesign, none of it referenced anywhere in the tree: the reaching-pointer-stores family and getCallBaseIdentityArgOperand/getRetInst in KernelArgVisitor.h (superseded by the MemorySSA oracle), and needsDefUseAnalysis, the DominatorTree member, isSelectedClobber with its commented-out call, the write-only StoreInstToOffsetBasePtrMap, and the std::variant sketch in KernelArgPtrUseVisitor.h. getReturnInstructions in PointerClobberAnalysis.h becomes inline (non-inline definition in a header; ODR hazard on a second include site). Verified: both plugins build on LLVM 19 and 22; lambda-spec ground-truth check reports zero decision drift across all 37 tests.
…offset The paired AnalysisFailed/AnalysisSuccess writes (47 sites across both visitors) collapse into a fail() helper, making the never-both-true invariant structural; several sites that set only the failure flag now clear the success flag too. The ptr-use visitor's visitor-global Offset was write-only after the range refactor -- its one consumer was the CallBase candidate's OffsetOfWriteToBasePtr, which the resolution path never reads for calls (a selected call resumes interprocedural traversal) -- so the member, its GEP accumulation, and the unused back() helper are gone. offsetValueMapFailure is renamed rangeMapFailure to match the ValueRangeMap it actually reports on. Verified: both plugins build (LLVM 19); lambda-spec ground truth reports zero decision drift across 37 tests; the AST rejection suite passes.
The decision-drift checker, ground-truth JSON, and its doc ship with the verification-tooling PR (feature/bowen/pseudo-gpu-harness) so reviewers of that small PR get the whole apparatus, and this branch's PR consumes it once landed. The branch-tip decision capture will be re-established from a ROCm run as part of final validation.
Its durable content lives on: the resolved design in docs/dev/design/lambda-closure-provenance.md, the behavioral baseline in the spec-CI ground truth (tooling branch), and the fixes themselves in history. The finding-by-finding ledger was a working document for the review cycle, now stale.
…n mode The first ROCm run of the contract diagnostics rejected the analysis suite's own provenance tests: the checks were type-level overapproximations. Narrowed to what the design contract actually forbids: - memcpy/memmove: diagnose only writes that PARTIALLY overlap the wrapper's byte interval (computed from the destination expression's member chain and record layout) or whose dynamic length cannot be proven to miss it. Whole-wrapper copies and non-overlapping prefix/suffix writes are the whole-object discipline itself and stay legal. memset stays strict on any overlap. - reinterpret_cast: recording the cast is no longer the error; only a WRITE through the erased pointer (directly or via a variable initialized from it) is diagnosed, scanned post-traversal. container_of-style address math and const reads stay legal. New plugin arg -contract-warn downgrades the contract diagnostics to warnings; the Proteus test tree passes it so adversarial tests that deliberately violate the contract can keep pinning the IR analysis's conservative declines, while consumer builds via add_proteus() keep hard errors. The AST rejection test gains positive cases for every legal form. Verified: wrapper-storage AST test passes strict; all six prior AST tests pass; the full GPU lambda suite compiles with zero contract errors under -contract-warn.
a1de26b to
90b339f
Compare
Each test asserts the correct outcome and currently fails on the clobbering-aware analysis branch. Verified by running the device-side compile of each file through libProteusPass (LLVM 19, gfx90a, no ROCm) and reading the lambda manifest: - lambda_pointee_overwrite: a helper memcpy's over the closure it received by pointer and invokes it directly on the argument. The direct-argument clobber guard only runs when a StoreInst writes the argument, so the manifest records kernel argument 0 (Initial) while the device executes Replacement. - lambda_displaced_helper_memcpy: the closure sits at offset 4 in an envelope and a helper overwrites it through a field pointer. The transfer offset correction is frame-relative, so the manifest records kernel argument 1 at offset 4 instead of 0 and would fold Body.Y and Spare.X as the two slots. - lambda_callee_join_no_phi: a helper memcpy's the closure, then branches on an unrelated value with register-only arms. The oracle reports live-on-entry for the multi-predecessor return block that has no MemoryPhi and the analysis declines; the straight-line twin specializes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
davidbeckingsale
left a comment
There was a problem hiding this comment.
I went through the full diff and spent some time running the pass offline against device IR for the shapes I was unsure about (libProteusPass built from this branch with LLVM 19, HIP device-only compile for gfx90a, reading the lambda manifest). Overall the design holds together well: the forward walk only collects candidates and acceptance requires MemorySSA's own selection to be among them, so most mistakes degrade to a decline. The findings below are the places where that does not hold, plus a few smaller things.
I pushed three regression tests to this branch (fe9f34d) that pin the confirmed issues. They assert the correct behavior, so they fail today and CI will go red until the fixes land; I verified each one reproduces by compiling its device side through the pass and reading the manifest, but I have not run them on a GPU.
Confirmed (reproduced against the built pass)
- Direct-argument clobber guard is bypassed by any write that is not a bare
StoreInst(miscompile,lambda_pointee_overwrite). - memcpy offset correction is frame-relative, so a displaced destination in a callee resolves to the wrong byte (miscompile,
lambda_displaced_helper_memcpy). - Oracle reports live-on-entry for a multi-predecessor block with no MemoryPhi, declining straight-line memory histories through branchy callees (spurious decline,
lambda_callee_join_no_phi).
Not reproduced, from reading
4. One MemorySSA oracle per callsite.
5. Virtual bases crash getWrapperIntervalInType.
6. The erased-write diagnostic only sees reinterpret_cast.
7. LambdaArgVisitor::visitMemIntrinsic is unreachable.
8. Comments cite a review doc that is not in the tree; one test comment is stale.
9. The CUDA integration script reverts main's absolute miniforge prefix.
Details inline. Happy to pair on 1 and 2 if useful; the offline harness makes the loop a couple of minutes.
| return StoreBase == &A && StoreSize && | ||
| rangesOverlap(StoreOffset, *StoreSize, 0, FunctorSize); | ||
| }); | ||
| if (MemoryAnalysisPtrUse == LambdaCB && LambdaCB->getArgOperand(0) == &A && |
There was a problem hiding this comment.
The MemorySSA query here only runs when HasDirectPriorStore finds a same-block StoreInst whose pointer operand is the argument itself. Any other write skips it: a memcpy, a constructor or helper call that writes through the argument, a store through a GEP of the argument, a store in a dominating block, or any write at all when an addrspacecast sits between the argument and the call (that path passes &ASC as the boundary, so MemoryAnalysisPtrUse == LambdaCB is false). The walk then falls through to the caller and accepts the original object.
Reproduced with tests/gpu/lambda_pointee_overwrite.cpp: a helper does __builtin_memcpy(Target, Replacement, sizeof(F)); (*Target)();. The manifest records kernel arg 0 (Initial) while the device executes Replacement. The whole-object memcpy is legal under the new checker contract, so the frontend does not catch it either.
I see the pre-filter came in with "Fix cuda unit test", which suggests the unfiltered query produced spurious declines (probably unrelated calls before the lambda call showing up as clobbers on a non-alloca location). I think the query needs to run unconditionally on this shape, with the non-aliasing-call case handled the way callCannotModifyTrackedLocal does for allocas, rather than being gated on a syntactic store.
| // source bases, rather than the source's absolute offset. For example, | ||
| // copying a field at byte 8 into a field at byte 24 needs a correction of | ||
| // 16, so the caller turns 24 into 8. | ||
| int64_t OffsetCorrection = DstOff - SrcOff; |
There was a problem hiding this comment.
OffsetCorrection = DstOff - SrcOff is computed from GetPointerBaseWithConstantOffset on the raw destination, which is relative to the current frame's base (the formal, in a callee). The store path at line 1153 derives its correction relative to the visitor root instead: ValueRangeMap[TrackedBase].Offset - StoreRange.Offset. When a transfer is resolved in a callee whose formal was passed a displaced pointer (&env.Body), the two disagree by that displacement.
Reproduced with tests/gpu/lambda_displaced_helper_memcpy.cpp: closure at offset 4 of an envelope, helper memcpy's over it through a field pointer. The pass log shows the callee memcpy resolving with offset-correction=0, and the manifest records kernel arg 1 at offset 4 instead of 0. With a 16-byte Pair source that stays in bounds and would fold Body.Y and Spare.X as the two slots. With an 8-byte source the same error happens to trip the full-extent check and declines, which is probably why nothing in the existing suite caught it.
Using the same root-relative displacement as the store path, (ValueRangeMap[TrackedBase].Offset - Range.Offset) - SrcOff, gives the right answer in both the root frame and callees; DstOff should drop out.
| return MSSA.getLiveOnEntryDef(); | ||
| if (BasicBlock *Pred = BB->getSinglePredecessor()) | ||
| return getStateBefore(MSSA, *Pred->getTerminator(), VisitedBlocks); | ||
| return MSSA.getLiveOnEntryDef(); |
There was a problem hiding this comment.
When the boundary block has no prior MemoryDef, no MemoryPhi, and more than one predecessor, this returns live-on-entry. The absence of a MemoryPhi means every predecessor carries the same reaching access, so the walk should continue through the immediate dominator (or any predecessor) rather than give up. This matters because every deferred callee resolution queries at the callee's ReturnInst, which has no memory access of its own.
Reproduced with tests/gpu/lambda_callee_join_no_phi.cpp: a helper memcpy's the closure, then if (Flag) V = pureScale(Flag); with pureScale marked const, then returns V. The return block has two predecessors and an SSA phi but no MemoryPhi; the analysis logs "MemorySSA did not identify a clobbering instruction" and declines. The straight-line twin specializes. At -O2 any helper with a register-only diamond before its return hits this.
| DEBUG(Logger::logs("proteus-pass") << "Beginning analysis " << "\n"); | ||
| for (auto *FunctorCB : CBToAnalyze) { | ||
| LambdaArgVisitor Visitor(FunctorCB, M); | ||
| LambdaArgVisitor Visitor(FunctorCB, M, FunctorStructSize); |
There was a problem hiding this comment.
analyzeLambdaUses builds a new LambdaArgVisitor per callsite and the constructor (line 227) makes a fresh MemorySSAClobberOracle, so DominatorTree, AssumptionCache, AAResults and MemorySSA are rebuilt for the same enclosing functions once per callsite, and again per functor operator in emitLambdaDeviceCallsiteManifest. The IR is not mutated during analysis, so one oracle could be shared across both loops.
| const auto *BaseRecord = getCanonicalRecord(Base.getType()); | ||
| uint64_t BaseOff = | ||
| Layout | ||
| .getBaseClassOffset(llvm::cast<clang::CXXRecordDecl>(BaseRecord)) |
There was a problem hiding this comment.
Record->bases() includes virtual bases, and ASTRecordLayout::getBaseClassOffset requires a direct non-virtual base (it asserts in debug builds and reads a missing map entry otherwise). A struct Derived : virtual Base where Base holds a wrapper, memcpy'd as a whole, would hit this. Checking Base.isVirtual() and either using getVBaseClassOffset or skipping conservatively would close it.
| TypeErasedWrapperStorage(TypeErasedWrapperStorage), | ||
| MemoryBuiltinOnWrapper(MemoryBuiltinOnWrapper), Reported(Reported) {} | ||
|
|
||
| bool VisitCXXReinterpretCastExpr(clang::CXXReinterpretCastExpr *Cast) { |
There was a problem hiding this comment.
The erased-write diagnostic records only CXXReinterpretCastExpr and only diagnoses BinaryOperator assignments. auto *W = (uint32_t *)&Copy; W[1] = 5; compiles clean, as do static_cast<void*> chains, ++W[1], and passing the erased pointer to a function that writes through it. The IR analysis does still decline these shapes (I checked the C-style cast case), so this is diagnostic coverage rather than soundness, but the design doc leans on the checker as the contract boundary, so worth tightening or documenting.
| @@ -520,7 +459,7 @@ class LambdaArgVisitor : public InstVisitor<LambdaArgVisitor> { | |||
| auto *MT = dyn_cast<MemTransferInst>(&I); // memcpy/memmove | |||
| if (!MT) { | |||
| // memset doesn't preserve any src->dst relationship we can use | |||
There was a problem hiding this comment.
I believe this visitor is unreachable: LambdaArgVisitor only ever pushes SSA values (store value operands, memcpy raw sources, call operands, pointer operands), never a void-typed intrinsic, so visitMemIntrinsic and visitIntrinsicInst here cannot execute and the FunctorSize coverage check added in this PR is dead. The real check is the one in LambdaInstUseVisitor::visitMemIntrinsic. Suggest deleting these or asserting unreachable so there is one source of truth.
| /// Record the registered functor's byte size on each wrapper operator so | ||
| /// the kernel-arg analysis can reason about the functor's full extent | ||
| /// rather than a single byte (see gate item 8 in | ||
| /// docs/dev/reviews/clobbering-aware-use-analysis.md). |
There was a problem hiding this comment.
This and KernelArgPtrUseVisitor.h:1010 and the design doc point readers to docs/dev/reviews/clobbering-aware-use-analysis.md and "gate 8", but no docs/dev/reviews directory exists on the branch. Either add the doc or inline the rationale. Relatedly, tests/gpu/lambda_size_test.cpp:27 still says the envelope case is EXPECTED TO FAIL while its CHECK lines require success.
|
|
||
| # Install Clang/LLVM through conda. | ||
| install_miniforge "${PWD}/miniforge3" | ||
| install_miniforge miniforge3 |
There was a problem hiding this comment.
Currently if we have LLVM IR shapes like
our analysis of the lambda callsite cannot automatically tell which store to trust as the definitive source of truth for the first slot of the lambda at the callsite. Fortunately, LLVM has O(N) (N is size of the function) built-in analysis called MemorySSA, which can answer, at the callsite
call registered_lambda(%0), which store is definitive. In the parlance of MemorySSA, we say the instructionstore int32 %2, %0"clobbers", where clobbering basically means is the source of truth for that memory value and the callsite.Fortunately, this LLVM IR shape is relatively exotic for lambda closure pointer. Nonetheless, we want Proteus to conservatively and correctly deal with simple cases of memory clobbering, explicitly reject complex LLVM IR shapes, and above all not trust the def-use graph traversal ordering as a source of ground truth for memory locations. This PR introduces a MemorySSA oracle model, that the def-use walk uses as a guide when analyzing instructions that write to lambda closure pointers. The oracle allows the analysis to ask, "which instruction clobbers the lambda closure pointer at a given lambda callsite??" Of course, an adversarial user can cook up examples where lambda closures pass through phi nodes, or a lambda is re-assembled piecemeal from disparate memory locations. These are all cases we currently conservatively reject, and update the clang frontend to do help users understand why.
Here are some of the edge cases where we fail to specialize
(1) What if the analysis finds that different slots in a lambda are stored from different pointers? -- fail to specialize
(2) What if we find that multiple user-defined function calls determine the value of different slots of the lambda closure -- fail to specialize
(3) What if we find multiple memcpys that copy different lambda slots from different source pointers? -- fail to specialize
Here are some cases where we do specialize
✅ the lambda closure pointer is clobbered at the callsite by a collection of stores, all from the same base ptr
✅ the lambda closure pointer is stored to multiple times, only one store clobbers
✅ the lambda closure pointer is clobbered at the callsite by a memory intrinsic
✅ the lambda closure pointer is clobbered at the callsite by a function call, which writes to the closure pointer using one of the above patterns
We try to handle all unsupported LLVM IR patterns from the Clang frontend which is also updated here. I also have added a flag to weaken the Clang frontend so that we can test that the LLVM IR analysis also knows about the bad shapes I describe above.