[IR] Do not inline functions carrying "zeroize-stack" - #4
Conversation
|
|
ce6cbab to
6099f1a
Compare
055238d to
a36416f
Compare
frabert
left a comment
There was a problem hiding this comment.
Where does the inlining rejection actually happen? I can see an attribute definition but no logic that acts on its presence or absence
There was a problem hiding this comment.
The rejection happens in the attribute compatibility table rather than in the inliner: checkZeroizeStack in llvm/lib/IR/Attributes.cpp:2606 returns !Callee.hasFnAttribute("zeroize-stack") (line 2623), and def : CompatRule<"checkZeroizeStack">; in llvm/include/llvm/IR/Attributes.td:472 is what makes TableGen emit the call to it inside hasCompatibleFnAttrs, which is all that AttributeFuncs::areInlineCompatible returns (llvm/lib/IR/Attributes.cpp:2803).
A false from the predicate becomes a refusal in getAttributeBasedInliningDecision: functionsHaveCompatibleAttributes (llvm/lib/Analysis/InlineCost.cpp:3082, consulting areInlineCompatible at :3092) fails, and the call site is rejected with InlineResult::failure("conflicting attributes") at llvm/lib/Analysis/InlineCost.cpp:3224.
There is deliberately no inliner-side code — this is where mismatched sanitizer and strictfp instrumentation are already refused, and stating the rule as a property of the caller/callee attribute pair rather than of one pass is what makes it hold wherever inline compatibility is decided, LTO and ThinLTO included; alwaysinline call sites are the one exception, as they skip the compatibility check entirely, and diagnosing that combination is left to the frontend.
llvm/test/Transforms/Inline/zeroize-stack.ll covers it under both -passes=inline and default<O2>, for annotated and unannotated callers, and zeroize-stack-lto.ll pins the same behaviour down across regular LTO and ThinLTO — each with an unannotated callee that must still be inlined, so a missing inline is evidence of the rule rather than of nothing having been inlined at all.
Generated by Claude Code
@kumarak @wizardengineer Do we like this? |
6099f1a to
e92228d
Compare
a36416f to
c3a85e0
Compare
e92228d to
f548a8c
Compare
c3a85e0 to
79babda
Compare
We need to develop an approach to handle |
60ebb07 to
c4971d5
Compare
A function carrying the attribute promises to clear its own stack frame before returning. Inlining it dissolves that frame into the caller's: the bytes it promised to clear become bytes of a frame that outlives the point where the clear was due, and nothing is left in the IR to record the obligation. Until now the inliner would fold such a function into a caller that clears nothing and drop the guarantee silently. Refuse it where mismatched sanitizer instrumentation is already refused, as a compatibility rule keyed on the IR attribute and consulted through AttributeFuncs::areInlineCompatible. Placing the check there rather than in a pass of its own is what makes it hold wherever inline compatibility is decided, LTO and ThinLTO included; the test covers both, since the callee reaching the inliner through the link rather than through its own module is the case worth pinning down. The rule is callee-side and has no same-attribute exemption. A caller that carries the attribute clears its own frame at its own returns, which is neither the clear the callee owed at the point it would have returned nor necessarily as much of the frame, as the two functions may ask for different amounts of it. Inlining a callee that does not carry the attribute into one that does stays allowed and is worth having: the callee's frame lies below the stack pointer once it returns and no clear reaches it, whereas inlining turns those bytes into frame bytes of the caller, which are cleared. Rejecting alwaysinline combined with the attribute is left to the frontend, which is where the combination can be diagnosed; alwaysinline call sites bypass the attribute compatibility check in getAttributeBasedInliningDecision. The rule is the one decided on trailofbits/vspells-ct-internal-notes#14.
c4971d5 to
2e4351c
Compare
|
@claude Apart from leaving the responsibility to the frontend, can you comment on what strategies you think we could apply? |
|
The bypass isn't specific to this rule. Every Refuse the inline, even for Correcting something I said earlier in this thread: I described Or propagate the obligation to the caller. What gives me pause about it: absence of the attribute is not an opt-out, so propagating would quietly make every caller of an So refusing is the faithful reading of what the attribute promises, and merging is the option that lets the inline happen at a codegen cost. Choosing between them is really a question about whether widening the clear to the caller's frame is acceptable. Which is the thing worth saying plainly: none of this protects anything yet. I measured at this branch's head rather than relying on the earlier review. Two things in this PR I would fix either way, since they are wrong now and cost little:
Last thing: the frontend cannot reach most of this class of problem. Inlining is one of several places the obligation moves or disappears, and none of the others goes near
Generated by Claude Code |
|
@claude, refusing |
|
Propagation works, and LangRef already fixes the ordering it needs.
/// If the inlined function asked for its frame to be cleared, the caller now
/// owns that obligation, over at least as much of the frame as the callee asked
/// for.
static void adjustCallerZeroizeStack(Function &Caller, const Function &Callee) {
Attribute CalleeAttr = Callee.getFnAttribute("zeroize-stack");
if (!CalleeAttr.isValid())
return;
// "used" is the widest mode, and LangRef gives any unrecognized value that
// same meaning, so "sensitive" is the only value that can clear less.
if (!Caller.hasFnAttribute("zeroize-stack") ||
CalleeAttr.getValueAsString() != "sensitive")
Caller.addFnAttr(CalleeAttr);
}Registered as one line beside the existing merge rules. Three things come with it, none of them objections. The refusal has to go rather than sit alongside. Keeping both would refuse on the ordinary path and propagate only where the refusal was already bypassed, which is incoherent. So A caller with no attribute does acquire one, and that is the deliberate half. The cost moves rather than disappearing. A One drive-by worth taking while the file is open: the two mode bullets are not literally nested. Implementing it rewrites this commit and restacks the ten branches above it, so it needs a go-ahead before I push. If the mode rename is going ahead too, both want the same restack and should travel together. Generated by Claude Code |
|
@frabert, what do you think about going with propagation and seeing how much performance cost it can have? |
|
To take the scope question directly: the design above already applies to all inlining, not only the On evaluating the cost, no measurement is possible yet. No target emits the clear: The two costs are also not symmetric, which matters if only one gets built. Blocking costs missed inlining: bounded, and visible in a profile as a call that did not disappear. Propagation costs a wider clear in the caller, and that cost spreads up the inline chain, landing in functions whose authors never asked for it. It is also the novel half — nothing upstream propagates a clearing obligation, and the closest analogue, Generated by Claude Code |
|
What we're proposing here is to propagate the safety tags of a callee to the callers that would inline it, am I understanding this correctly? |
|
Yes, that's it, with three precisions worth pinning down. It's the function attribute that propagates, not the per-object metadata. The It happens at the moment of the inline, and only to the caller that actually inlined. There's no pass over potential callers, and a function that never gets inlined anywhere is unaffected. The caller ends up with the wider of the two modes, not necessarily the callee's. If the caller already asks for more of its frame to be cleared than the callee did, it keeps what it had. Generated by Claude Code |
|
I feel like that's the wrong direction to be looking at, though: the unsafe path is a caller marked sensitive inlining a nonsensitive callee, or am I wrong? |
|
You're right that there's a hazard in that direction, and it's one nothing currently rejects — but it isn't the direction where inlining loses a guarantee, and propagation wouldn't fix it. Your direction isn't a regression. An unprotected callee's frame was never cleared: once it returns, those bytes sit below the stack pointer and no clear reaches them. Inline it into a protected caller and they become the caller's frame bytes, which the caller does clear. Nothing gets worse, and in the common case it gets better. What does get worse in your direction is what the caller's mode means. If the unprotected callee's allocas carry And it has a cheap fix that is not this PR's merge rule. The direction that does lose a guarantee is the other one. A protected callee inlined into an unprotected caller: before, its frame was cleared on return; after, nothing clears those bytes at all, and no IR remains that records the obligation. The program is strictly worse than it was. That's the regression the merge rule addresses, and it's why the rule is callee-side. Generated by Claude Code |
|
I see. So, what we would need is a pass that propagates the sensitive tag from callee to caller when inlinining, while also marking all the caller's allocas as |
|
I don't think so, and the reason is the hazard you named a moment ago. Marking the caller's allocas Two smaller things also get in the way: The marks only mean anything in The saving would be partial anyway. LangRef already requires that slots with no source-level provenance are cleared regardless of marks: spill slots, the callee-save area, alignment padding, on the grounds that they can hold copies of unmarked data and nothing records where those copies came from. Those are exactly the slots that an inline multiplies. If bounding the cost is the goal — and I think it should be, because that's the real objection to propagation — the mechanism to reach for is a positive per-slot obligation rather than a negative one. Today the promise lives on the function and the only per-object marker says "spare this", so every option is a choice between clearing more than we need and asserting something untrue. If the obligation lived on the slots, an inline would carry it in with them and there would be nothing to propagate and nothing to re-mark. That's a larger change than this PR, but it's the shape in which the question stops recurring, and it's worth knowing that's where the current design's awkwardness comes from. Generated by Claude Code |
2026-08-18 weekly sync — minutes bearing on this PRPosting the meeting record, not a position. I was not in the room; this is quoted from the transcript of the 2026-08-18 Compiler Enforced Secrecy sync, which ran roughly 14:29–15:05Z. The room converged on a third option — neither this PR's current flat refusal, nor propagation to the caller: allow inlining only when the caller is also marked, refuse otherwise, plus an opt-in warning rather than an error.
— Francesco Bertolaccini ("color" is the transcript mishearing "caller")
— Akshay Kumar
— Francesco Bertolaccini, on the diagnostic. The flag name was explicitly a placeholder, and an error was proposed and withdrawn in the meeting once it was pointed out that Two gaps the room left: which marking the test reads — every speaker used the positive sense, and neither the source-level attribute nor the IR spelling was named — and mode mismatch between a marked caller and a marked callee, which never came up. @kumarak — your comment above directs propagating the attribute to the caller. The meeting started about an hour later and landed on refusal with an exemption, where your own words were "at the moment I will say just go with the preventing inline." Nobody referenced this comment in the meeting, so the transcript does not tell me whether it is superseded. Which stands? Nothing has been implemented and no code has changed on the back of this. Recorded here so it is not only in a meeting doc. Generated by Claude Code |
|
@claude, What was discussed in the meeting about handing |
The attribute's inlining rule refused every caller, including one carrying the attribute itself. Inlining into a caller that clears at least as much of its frame keeps the callee's guarantee, so restate the rule to permit it and to name the mode ordering it depends on. The mode ordering was not settled in discussion; requiring the caller to clear at least as much is the only reading that preserves what the callee promised. Also state that the refusal is not overridden by a request to inline, and that declining is a warning rather than an error.
|
LangRef is updated in One thing I chose rather than asked, and the commit message says so too: the meeting did not cover both functions being marked in different modes. The text requires the caller's mode to clear at least as much of the frame as the callee's, because Not in this commit, and still to come: the compatibility-rule change that permits a marked caller, and the opt-in warning. Two things worth settling before I write them. Which marking the caller test reads. Every speaker in the meeting used the positive sense, and neither the source-level attribute nor the IR spelling was named. Since the inversion those are two different rules, so the answer needs to name the spelling rather than the concept. And a correction to something I said earlier in this thread. I called the stray-mark Verifier rule a cheap fix. It is not. Every Generated by Claude Code |
Requested by Francesco Bertolaccini · Slack thread
A function carrying the
"zeroize-stack"attribute promises to clear its own stack frame before returning. Inlining it dissolves that frame into the caller's: the bytes it promised to clear become bytes of a frame that outlives the point where the clear was due, and nothing is left in the IR to record the obligation. Until now the inliner would fold such a function into a caller that clears nothing and drop the guarantee silently.Refuse it where mismatched sanitizer instrumentation is already refused, as a compatibility rule keyed on the IR attribute and consulted through
AttributeFuncs::areInlineCompatible. Placing the check there rather than in a pass of its own is what makes it hold wherever inline compatibility is decided, LTO and ThinLTO included; the test covers both, since the callee reaching the inliner through the link rather than through its own module is the case worth pinning down.The rule is callee-side and has no same-attribute exemption. A caller that carries the attribute clears its own frame at its own returns, which is neither the clear the callee owed at the point it would have returned nor necessarily as much of the frame, as the two functions may ask for different amounts of it. Inlining a callee that does not carry the attribute into one that does stays allowed and is worth having: the callee's frame lies below the stack pointer once it returns and no clear reaches it, whereas inlining turns those bytes into frame bytes of the caller, which are cleared.
The LangRef entry for the attribute said the inlining rule was specified separately; it now states the rule.
A reviewer coming from
trailofbits/vspells-ct-internal-notes#14should note that what is implemented here is broader than what that issue describes, deliberately and per the merged design decision the issue predates. Three differences. The rule is keyed on the IR function attribute rather than on anoinlinethe frontend attaches, so it also holds for IR that was not produced by Clang and cannot be undone by a pass that dropsnoinline. The qualifier about the callee being inlined across a trust boundary is gone: there is no condition on the caller at all, because no property of the caller reinstates the callee's clear. And protected-into-protected is refused too, for the reason given above; the issue's framing would have exempted it.Rejecting
always_inlinecombined with the attribute is left to the frontend, which is where the combination can be diagnosed as an error on the declaration;alwaysinlinecall sites bypass the attribute compatibility check ingetAttributeBasedInliningDecision, so this half of the decision is not enforceable in the middle end and is deferred to the Clang change.Testing:
llvm/test/Transforms/Inline/zeroize-stack.llcovers the three single-module cases under both-passes=inlineanddefault<O2>, andllvm/test/Transforms/Inline/zeroize-stack-lto.llcovers regular LTO and ThinLTO throughllvm-lto2, which is the completion criterion the issue names. Both LTO configurations also check that an unannotated callee from the other module is still inlined into an annotated caller, so a missing inline is evidence of the rule rather than of nothing having been inlined.llvm/test/Transforms/Inlinepasses with no new failures.Stacked on #3 (
sensitivity-metadata), which is where the attribute's LangRef entry and the!nozeroizemetadata come from. Draft until the branches below it land.AI tool use
This pull request contains AI-generated content. It was prepared with the assistance of Claude Code; the contributor has reviewed the generated code and text, is the author of the contribution, and is accountable for it, per the LLVM AI Tool Use Policy.
Generated by Claude Code