[CodeGen] Keep the register clears at the machine level - #14
[CodeGen] Keep the register clears at the machine level#14claude[bot] wants to merge 15 commits into
Conversation
|
|
|
Hello @claude[bot] 👋 Thank you for submitting a Pull Request (PR) to the LLVM Project. Since this is your first PR, here are a few useful links covering our main contribution policies and review practices.
Please reply to this message to confirm that you have read these policies, especially the LLVM AI Tool Use Policy, and that any AI tool usage has been noted in the PR description. Frequently asked questionsHow do I add reviewers? This PR will be automatically labeled, and the relevant teams will be notified. For some parts of the project, reviewers may also be added automatically. You can also add reviewers manually using the Reviewers section on this page. If you cannot use that section, it is probably because you do not have write permissions for the repository. In that case, you can request a review by tagging reviewers in a comment using What if there are no comments? If you have not received any comments on your PR after a week, you can request a review by pinging the PR with a comment such as “Ping”. The common courtesy ping rate is once a week. Please remember that you are asking for volunteer time from other developers. Are any special GitHub settings required to contribute to LLVM? We only require contributors to have a public email address associated with their GitHub commits, see this section of LLVM Developer Policy for details. If you have questions, feel free to leave a comment on this PR, or ask on LLVM Discord or LLVM Discourse. Thank you, |
Add an overloaded pointer-and-length intrinsic that writes zero over [dest, dest + len) and guarantees the clear is emitted without calling an external function and without establishing a call frame. That codegen guarantee is the reason for the intrinsic. Clearing a buffer by other means goes through memset, and a memset over a dynamic length or a large fixed one lowers to a libcall. Measured on x86-64 with six values live across the clear, the call forces all six into callee-saved registers and pushes them in the prologue, grows the frame from 8 to 56 bytes, and plants a callee frame below the stack pointer. A clear that spills the live state it is trying to erase, into the stack it is trying to erase, defeats the purpose. Expanded as a pseudo after register allocation the same clear is a rep stosb with no libcall at any size. Non-removability is part of the contract too, but it is not what makes the intrinsic necessary and the previous version of this change oversold it. A volatile memset is also not removable: DSEState::isRemovable refuses to remove volatile memory intrinsics unconditionally, so a volatile memset survives dead store elimination and a full pipeline in every shape the test covers. What a volatile memset cannot do is lower without the libcall. llvm.memset.inline does not close the gap either. It survives dead store elimination and lowers without a libcall, but SROA rewrites it back into a plain memset on non-escaping stack allocas, which is exactly the case this targets, and the no-libcall guarantee is gone before code generation. That rewrite is legal because LangRef defines the two as equivalent. llvm.memset.inline is also the precedent for adding an intrinsic whose optimizer semantics duplicate an existing one. Its Intrinsics.td entry carries byte-identical properties to int_memset and LangRef states its behavior is equivalent to llvm.memset, with a codegen guarantee as the whole of its justification. This is the same argument. The survival half of the contract comes out of the declared memory effects rather than out of changes to any pass. Claiming inaccessible memory in addition to argument memory is more pessimistic than the intrinsic really is, but a write that is not confined to argument pointees is not one dead store elimination can attribute to a single location, so it has nothing to remove, while the argument memory half keeps the write to the region itself visible to alias analysis. llvm.prefetch is pessimistic in the same way. IntrNoDuplicate keeps one clear from becoming several. The test covers the three situations dead store elimination handles with four intrinsics each: a plain memset, a volatile memset, a volatile llvm.memset.inline and llvm.zeroize. The plain memset is removed everywhere and llvm.zeroize survives everywhere; the volatile rows are carried so the file does not read as claiming a distinction it does not show, and the memset.inline rows pin the SROA rewrite. The lowering guarantee is not an IR-level property and is tested with the lowering change rather than here. Nothing lowers the intrinsic yet; expanding it for a target is left to a later change. The name is a recommendation still awaiting sign-off on trailofbits/vspells-ct-internal-notes#64.
The property defeats the intrinsic it was meant to protect. InlineCost refuses a call site whose callee contains a noduplicate call unless that site is the sole call to a local function, where inlining deletes the original and duplicates nothing. Every other site fails outright, with cost=never and noduplicate as the reason. So a small internal helper that wraps a clear and is called from more than one place, which is the natural way to reach for this intrinsic, is refused at every one of its call sites, and the clear ends up pinning open any caller it appears in. An intrinsic whose purpose is to be dropped wherever a buffer needs erasing cannot also be a barrier to inlining the code that erases it. The justification did not hold either. Clearing a region is idempotent: performing it twice leaves the region in the state one pass would have left it, so there is nothing a duplicated clear can undo and no reason to keep the call sites distinct. The half of the contract that does matter, that the write survives even where the region is provably never read again, does not rest on this property and is unchanged. It comes from the declared memory effects, and the dead store elimination test continues to pin it.
|
My recommendation on this one is to drop the patch rather than fix it. The bug first. The anchor gate is The reason not to bother: the patch's own analysis ( And if the property is worth expressing at all, it should be expressed once, uniformly, without a hook: Detail, and the rest of the findings on this patch, in the write-up: https://claude.ai/code/artifact/accb9215-36b5-4022-ae0f-249ee54e99cb Generated by Claude Code |
The entry argued for the intrinsic instead of specifying it. Drop the rationale for why a clear must not go through memset, the comparison with llvm.memset.inline, the explanation of why the usual grounds for discarding a store do not apply, and the aside noting that a volatile llvm.memset.* is not removable either. None of it constrains an implementation or a transform; the normative statements it surrounded are kept verbatim. Also drop the claim that the no-external-call, no-call-frame guarantee "holds for every target". No target lowers llvm.zeroize yet, so nothing backs the claim, and it stays wrong for the targets the stacked X86 expansion does not cover. Documentation only; Intrinsics.td and the tests are unchanged.
Register a fixed metadata kind marking an alloca whose contents should not be left readable in the stack frame once the function is done with them. The metadata is only used as a flag: its presence on the alloca is the entire signal and the node must be empty, with the contents of the node reserved for future use. The marked objects are what the "sensitive" mode of the "zeroize-stack" attribute clears, so the LangRef entry is written around the direction of the fallback between the two. The metadata buys precision and is not itself part of the guarantee. Dropping it is always permitted, and where the marked set has stopped describing the frame the response is to clear more, at the limit every stack slot the function used, never to clear less. Absence of the metadata on an object is not a statement that the object is insensitive, and a transform may not shrink the set of objects a function clears on the strength of what is or is not marked. What makes losing the metadata safe is that the mode's floor already covers every frame object with no source-level provenance, such as spill slots, the callee-save area and alignment padding, whether or not anything is marked. Parsing, printing and bitcode serialization of metadata attachments are generic, so the round-trip test needs no new parser code; registering the kind gives consumers a fixed ID to look the attachment up by. No pass or backend reads the metadata yet, and which transforms should carry it across the objects they create is left to a later change. The name is a recommendation still awaiting sign-off on trailofbits/vspells-ct-internal-notes#64.
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.
The three constructs added for stack zeroization were accepted in whatever shape they were written. A malformed one was therefore not a diagnostic but a guarantee quietly reduced: an attribute naming no mode, or a mark on something that is not a stack object, reads as a request that nothing ever honors. Reject the shapes that are genuinely malformed. "zeroize-stack" selects how much of the frame is cleared, so it has to name a mode, and the attribute with no value names none. An unrecognized mode is deliberately still accepted: LangRef gives it the meaning of "used", the widest mode, so that a mode string a consumer has not learned yet widens what is cleared rather than narrows it. A verifier error there would contradict that and give up the fail-safe for nothing. Amend the LangRef sentence to say the value is required and may not be empty, which is what the check enforces, so the two now agree. !sensitive marks a stack object and its presence is the whole signal, so reject it where there is no stack object to mark, on a non-alloca instruction, on a function, on a global, and reject a payload while the contents of the node are reserved. Ignoring operands today would let IR that relies on their being ignored fix their meaning before anything wants to give them one. llvm.zeroize needs no rule of its own. Intrinsic::isSignatureValid already rejects a wrong return type, a wrong argument count, and a non-pointer or non-integer argument, and the name is remangled from the signature on load, so a check here would only restate the generic machinery. Add bitcode round-trip tests for all three. The failure worth catching is a mode string or an attachment that parses and is then lost or rewritten on reload, which downgrades the guarantee without failing, so the tests check the reloaded text against what was written, including an unrecognized mode, which has to survive verbatim, rather than only that reloading succeeds.
Nothing expanded the intrinsic, so a function that used it did not build. What makes expanding it different from expanding a memset is that the write has to still be there at the end: the region is normally dead by the time the clear runs, which is exactly the shape a store-removing pass looks for. Emitting the stores at selection time and hoping they survive would rest on every later pass declining to remove them. Emit an opaque pseudo instead. ZEROIZE64 has no pattern and no memory operand, so no analysis can attribute a store to it and no pass can find a store to prove redundant, and it carries hasSideEffects and mayStore, so it is not dead either. X86ExpandPseudo turns it into "movb $0, %al; rep;stosb" in addPreSched2, after register allocation and so after every pass that deletes stores. Between selection and that point there is no store to remove, and past that point nothing is left that would remove one. Both selectors reach the pseudo through the hook each already has for an intrinsic needing custom handling rather than a generic call: LowerINTRINSIC_W_CHAIN for ISD::INTRINSIC_VOID, which X86 already marks Custom, and X86LegalizerInfo::legalizeIntrinsic, which until now accepted every intrinsic unchanged. Each copies the destination and the count into %rdi and %rcx and emits the pseudo, so the two produce the same machine code. The tests pair each clear with an ordinary store the backend does remove, one covered by a later store and one repeated, so what they pin is the difference rather than only that some instructions came out. A second test walks the three points that carry the guarantee: what each selector handed over, that it is still the pseudo once registers are allocated, and that the write exists only after the expansion. The clearing sequence is LP64 only. 32-bit x86 still reports the intrinsic as unlowered, and the sequences for other targets are separate work, on trailofbits/vspells-ct-internal-notes#11.
A request to clear call-used registers is dispatched through emitZeroCallUsedRegs, whose default body is empty. A target that has not implemented it therefore compiles the request into nothing at all, and nothing says so: the caller asked for the registers to be destroyed, the object file leaves them holding what was in them, and the only way to find out is to read the disassembly. ARM32 is the live case. The driver refuses the command-line flag there through a hard-coded list of triples, but the function attribute that flag turns into is accepted, carried through the whole pipeline, and dropped in the frame lowering, so anything that sets the attribute directly, including LTO and inlining of code compiled elsewhere, silently gets nothing. Add a capability query the target answers instead of inferring support from an emission that may do nothing. supportsZeroCallUsedRegs defaults to false, so a target is unsupported until it says otherwise, and prologue-epilogue insertion asks before computing what to clear: a target that answers false gets a diagnostic and no code, rather than no diagnostic and no code. The three targets that implement the emission today, X86, AArch64 and RISCV, answer true, which is what the driver's triple list already assumed, so no target changes what it generates. supportsZeroizeStack is the same query for the "zeroize-stack" attribute, and today every target answers false, because none of them clears the frame yet. That attribute has had no backend consumer at all since it was added, which is the same silence in a worse form, so it now reports itself as unsupported everywhere until an implementation exists. DiagnosticInfoUnsupported is what the backend already uses to refuse a request it cannot compile, including in frame lowering, where RISCV reports a reserved stack or frame pointer through it. It names the function, is an error rather than a warning, and leaves llc exiting non-zero, which is what fails closed means here. The scope is the query surface and the refusal. Clearing the stack is trailofbits/vspells-ct-internal-notes#26 and the set of registers to clear is unchanged, on trailofbits/vspells-ct-internal-notes#27. The frontend diagnostic that replaces the driver's triple list is trailofbits/vspells-ct-internal-notes#67, which will consult these queries. The names are recommendations awaiting sign-off on trailofbits/vspells-ct-internal-notes#64. This is trailofbits/vspells-ct-internal-notes#23, under the umbrella trailofbits/vspells-ct-internal-notes#17.
Register clearing finds where to emit by walking the blocks whose last instruction is a return, computing one register set for the whole function and handing it to the target at each of them. Finding return blocks answers a different question from the one a function asked to destroy its registers or its frame needs answered, and the two answers differ in both directions. A tail call is a call marked as a return on the targets that have one, so a tail-call block is in the walk; but the epilogue has already run there, the frame belongs to the callee and the outgoing arguments are live, so being visited is not the same as being protected. In the other direction, a landing pad that runs destructors leaves the function by calling the routine that resumes unwinding, and a funclet leaves through cleanupret or catchret. Neither is a return, so neither is in the walk, and no clearing happens on any unwind path today. Classify the exits rather than collect the return blocks. classifyMachineExit gives a block one of seven kinds, decided by what the exit does with the frame and by nothing else, because that is what decides whether a clearing sequence can be placed at it. Four are in scope: a return that is not a call, a tail call, a return out of an exception scope, and a call to the routine that resumes unwinding. That last one is asked for by libcall, _Unwind_Resume or __cxa_end_cleanup under the ARM EH ABI, rather than matched by name, so it is recognised wherever DwarfEHPrepare would have created it. Three are out of scope, for the same reason in each case: the frame is abandoned rather than released, so there is no position at which a sequence could run and still be the last thing to touch it. A call that does not return here, whether abort, exit, a throw with no cleanup in this function or longjmp reached as an ordinary call, hands the caller's context back through the unwinder or through the jump with nothing of ours in between. A non-local jump does the same by reloading another frame's stack and frame pointers. A trap, or an empty block left behind by an unreachable, does not transfer out of the frame at all. The threat model does not cover abandoned frames, and this is where that exclusion is recorded rather than restated at each emission site. Unwinding past a function that has no cleanup in it is excluded for a stronger reason than policy: at this point it is not expressible. A call that may unwind and is not caught here has no edge to anything in this function, so it is indistinguishable from a call that does not unwind, and there is no instruction a sequence could be attached to. A longjmp that crosses this frame from a callee is the same. Nothing of the function runs, so nothing can be put in it. Unreachable is a kind rather than an absence, so that a block which reaches the end of the function without matching any other shape is recorded as classified instead of being indistinguishable from a block the walk failed to reach. The classification runs in prologue and epilogue insertion, immediately in front of the existing register clearing, which is where registers are already allocated, the frame is laid out and frame indices have not been eliminated yet. Nothing consumes it yet, so it is computed only when -pei-print-exits asks for it, and that flag is also how the tests observe it. It prints rather than counting or tracing because a release build has neither statistics nor -debug-only, and a classification that decides what gets protected has to be checkable in the configuration a shipped compiler is built in. Each test pins a whole function's exit list with CHECK-NEXT between the opening and closing lines, so a kind that changes, an exit that appears and an exit that disappears all fail; each of the five distinctions the classifier draws was removed in turn, and each removal failed at least one test. Nothing is emitted differently. The clearing walk, the register set it computes and the diagnostics around it are untouched, the flag is off by default, and a run without it produces no output at all. Ordering the sequence at the return-shaped exits is trailofbits/vspells-ct-internal-notes#19, tail calls are trailofbits/vspells-ct-internal-notes#22, and emission on the unwind and cleanup exits is trailofbits/vspells-ct-internal-notes#25. This is trailofbits/vspells-ct-internal-notes#18, under the umbrella trailofbits/vspells-ct-internal-notes#17.
Prologue and epilogue insertion emits register clearing at every block whose last instruction is a return. It is one step with no defined relationship to anything else that will be emitted there, which has cost nothing so far because it is the only step there is, and stops being free with the second one. Clearing the frame cannot be done without registers to hold the address it stores through and the value it stores, so it ends leaving in registers what it has just taken out of memory, and a register clear in front of it is undone by it. Clearing the flags has to be behind everything, because a register is cleared on x86 with an exclusive-or, which writes them, and a frame clear that loops writes them too. Those are constraints between steps, and a single emission has nowhere to record them. Make the emission point a coordinator. ClearingSequence is the order: ClearStack, then ClearRegisters, then ClearFlags. The emission walks that array at each exit and dispatches each step, instead of calling the one step there is. Two of the three emit nothing yet, and they are here rather than arriving with their implementations because the order is what is being settled: a step that arrives without one goes where it is convenient, and the reason it belonged somewhere else then has to be found again. The comment above the enumeration is where the reason for each position is written down, because that, rather than the order itself, is what an implementer of the remaining steps needs. It also records what the order is over. It is over the emitted code, not over one insertion point: the steps that exist today all emit in front of the instruction control leaves through, which is after the epilogue, while clearing the frame has to happen before the epilogue moves the stack pointer and the frame stops being addressable as the frame. What each step does is decided once per function and before anything is emitted, since a sequence whose steps differed between exits would not be one sequence. A step is not requested, unsupported and reported, unimplemented, or emitting. Planning is where the two capability queries are asked and where the two fail-closed diagnostics are raised, so a function is told what will not happen for it whether or not it has an exit to emit at. Nothing in the plan or in the walk consults a value the function computes, so the sequence a protected function runs is the same on every input. Where the sequence runs is the exit classification, not a fresh walk over the blocks that end in a return. The two answers differ in one direction here. A tail call and a funclet return are both marked as returns in the instruction description, so blocks ending in TCRETURN, CLEANUPRET or CATCHRET were already reached by the walk and are reached by the classification too; nothing changes at them. A landing pad that resumes unwinding ends in a call, which is not marked as a return, so it was not reached, and a function unwinding out of a cleanup left every register it had used to the unwinder. That exit is in scope, and the sequence now runs at it. Reaching it needs two things the block-scoped emission could not express. The target used to choose where to emit, the first terminator of the block, and a landing pad that ends in a call has no terminator, so the clearing would have been appended after the call that leaves the function. Steps that choose their own positions cannot be ordered against one another either, so the position is the coordinator's and is passed to emitZeroCallUsedRegs. At an exit that leaves through a terminator it is the first terminator, which is where the three targets implementing the hook already emitted, so nothing they generate moves. The other is the register set: it is computed once for the function with the registers its returns read removed, and it does not know that the resume call reads the exception object in an argument register. Clearing that register would leave the unwinder nothing to resume with, so the registers an exit names are dropped at that exit, which at a return exit removes nothing the function-wide computation had not already removed. Computing the set per exit instead of narrowing one computed for the function is trailofbits/vspells-ct-internal-notes#21. No existing test changes, anywhere in CodeGen/X86 or CodeGen/ARM. Nothing in the tree combines "zero-call-used-regs" with an unwind path, which is the same reason the gap lasted this long, so the new site is pinned by a new test rather than by an old one starting to expect more. The order is pinned separately from what is emitted, through -pei-print-clearing-sequence, which lists the steps in sequence order with what each of them does at each in-scope exit; it is a flag rather than a debug print for the reason -pei-print-exits is, that a sequence deciding what gets destroyed has to be checkable in the configuration a shipped compiler is built in. Each new test was confirmed load-bearing by breaking the implementation once and restoring it: reordering the array, going back to the walk over return blocks, dropping the out-of-scope filter, letting the target pick the position again, dropping the per-exit narrowing, and skipping the funclet exits each failed at least one of them. The scope is the order and the sites. What the register clear emits is unchanged, the scratch registers a frame clear will need are trailofbits/vspells-ct-internal-notes#20, clearing the frame is trailofbits/vspells-ct-internal-notes#26, and what a tail call needs beyond being in scope is trailofbits/vspells-ct-internal-notes#22. This is trailofbits/vspells-ct-internal-notes#19, under the umbrella trailofbits/vspells-ct-internal-notes#17.
Register clearing decides what to destroy once for the function and emits that decision at every exit. Part of it genuinely belongs to the function: which registers a mode selects, and the callee-saved registers no exit may touch. The rest does not. A register can be cleared only where it is dead, and where it is dead is a property of one exit, so a set computed for the function has to take the union over the function's returns and deny every exit what every other exit needs. A function with two returns is where that shows. Each of them needs its own live-out registers and nothing else, but the set they are cleared from has both returns' registers taken out of it, so each exit leaves the other's holding whatever the function last put in them. That is the value the attribute exists to destroy, sitting in a register that is dead at the exit actually taken. Taken far enough the set empties: a function whose two exits are a return and a tail call needs the return value in %eax at one of them and the outgoing arguments in %edi and %esi at the other, the union of those is every register the function used, and the function clears nothing at either exit while reporting that it is protected. Split the computation in two. planClearRegisters keeps what the mode decides, which registers "used", "arg" and "gpr" select over the whole function, because that is what those words have always meant, and the callee-saved exclusion, which is the function's too: a callee-saved register has to hold what the caller left in it wherever the function leaves, so no exit can clear one. What comes out of it is a candidate set rather than a clear set, and computeRegsToClearAtExit turns it into one at each exit. What an exit needs is what runs after the sequence, and the sequence is emitted at the exit's insertion point, so that is the rest of the block: the return and the registers it carries the return value in, the jump of a tail call and the registers it leaves the outgoing arguments in, or the call that resumes unwinding and the argument register it takes the exception object in. Reading that off the instructions rather than off a list of exit kinds is what keeps it right for a kind added later. It also subsumes the narrowing that arrived with the clearing sequence, which removed the registers named by the exit instruction from a set computed for the function; there is no function-wide set left to narrow. The result never clears less than before. At an exit that leaves through a terminator the new exclusion is that block's terminator run, which is one of the terms the old union was taken over, so what is spared at that exit is a subset of what was spared before and what is cleared is a superset of what was cleared before. That was checked rather than argued: a temporary build recomputed the old union-based set at every exit and failed the compilation if the per-exit set was missing a register the old one had. Every test in CodeGen/X86 and CodeGen/ARM passed under it, and the check was confirmed to fire by weakening it on purpose. The scaffolding is not part of this change. What the existing modes mean is unchanged. Which registers are candidates is still answered by the same code from the same attribute, and only the exclusion has moved; a function with one exit has nothing to take a union over, so its output is identical, which is most of what is in the tree today. Compatibility with the shipped modes is trailofbits/vspells-ct-internal-notes#60. The register-unit reset in the exclusion is kept with its FIXME rather than corrected, for the same reason: it only ever spares registers, so correcting it would widen what every mode clears, which is a separate change from moving the exclusion. No existing test changes, in CodeGen/X86 or in CodeGen/ARM. Two functions in llvm/test/CodeGen/X86 carry "zero-call-used-regs" and have more than one exit in the IR, and both lower to a single machine exit, so there is nothing for them to take a union over either. The zero-call-used-regs tests under CodeGen/AArch64 and CodeGen/RISCV were not run, since neither target is built here; every function in them has one exit, which is the case in which the two computations agree by construction. The new test is the contrast between the two exits of one function: the tail call clears the return-value register the other exit needs, the return clears the argument registers the tail call needs, and neither cleared anything before. A second function makes the same contrast between a return and an exit that resumes unwinding, and keeps the exception object the resume call reads, which is the exit answering for itself rather than being told by another exit. A third has one exit and emits what it always emitted. On ARM, which refuses to clear registers at all, what is pinned is that the refusal is still the function's: one diagnostic for a function with two exits, and the same disposition reported at both. Each was confirmed load-bearing by breaking the implementation once and restoring it: putting the union back, dropping the per-exit exclusion, and planning the sequence at each exit instead of once each failed one of them. This is trailofbits/vspells-ct-internal-notes#21, under the umbrella trailofbits/vspells-ct-internal-notes#17.
A tail call replaces the caller's frame with the callee's and jumps, so control never comes back to the caller. A function carrying "zeroize-stack" has undertaken to clear its frame before it returns, and a tail call takes away the point at which it would do that: the frame stays live underneath the callee, and the function reports itself protected while leaving in memory exactly what the attribute exists to destroy. Tail-call optimization is suppressed in a protected function. This is decision DD8, which the design records as settled. The suppression goes where LLVM already decides tail-call eligibility rather than into a check of its own. isInTailCallPosition in CodeGen/Analysis.cpp is the target-independent answer to that question, and everything that forms a tail call out of a call in the IR reaches it: SelectionDAGBuilder through canTailCall, FastISel, and GlobalISel's CallLowering. Those are the same three places that each honor "disable-tail-calls" separately, which is what asking once here avoids. Folding a memcpy, memmove or memset into a tail call to the library routine goes through it too, and replaces the frame just as thoroughly. A libcall the legalizer generates has no call in the IR behind it and never reaches that function. It is asked separately, by the SDNode overload of TargetLowering::isInTailCallPosition, which carries its own "disable-tail-calls" check for the same reason, and the second half of the change sits next to it. The path is not hypothetical: an frem in return position becomes a tail call to fmod on both x86 and ARM, and was the one remaining way a protected function still jumped away from its frame. musttail is diagnosed rather than suppressed. Declining an ordinary tail call is available because forming one is an optimization; musttail is a requirement the caller is not allowed to drop. A function that must be replaced at the call and must clear its frame after it is a function that cannot be generated, so the combination is rejected instead of being honored in one direction without saying so. The rejection is in the Verifier, in verifyMustTailCall, next to "cannot use musttail call with inline asm". That neighbor has the same shape: not malformed IR, but musttail combined with something that makes it impossible to honor, and the Verifier is where that shape already lives. It is also the layer at which the conflict is fully visible without a target. Leaving it to CodeGen would surface as the backend's existing "failed to perform tail call elimination on a call site marked musttail", which is fatal but never names the attribute that caused it, and which is reached per target and twice over for a call FastISel starts and SelectionDAG finishes. The frontend diagnostic for the same conflict written in source is separate work. Rejecting the combination in the Verifier makes it a bug for a pass to build one, and one pass did. MergeFunctions rewrites a merged function into a thunk that tail-calls the body, copies the attributes of the function it replaces onto that thunk, and uses musttail when both functions are swifttailcc, so two identical protected swifttailcc functions became a thunk carrying "zeroize-stack" around a musttail call, aborting the compilation with "Broken module found" from valid input. Protected functions are excluded from merging, which is the answer inlining already gives them: a thunk standing in for a protected function undertakes to clear a frame that no longer holds anything, and where the convention makes its call musttail it cannot discharge the undertaking at all. tailcc and -tailcallopt are covered as well. Both exist to guarantee the optimization rather than to permit it, and the guarantee is over the frame the attribute is about, so a protected function does not obtain it by choosing the convention. The cost is real: a protected tailcc function doing unbounded mutual recursion now grows the stack. Whether that should be rejected the way musttail is, rather than quietly losing the guarantee, is left to trailofbits/vspells-ct-internal-notes#22 rather than settled here. Where this meets the per-exit register clearing is worth stating, because the two can look like they overlap. Clearing at a tail-call exit spares the registers the callee is about to read as outgoing arguments, and a protected function no longer has a tail-call exit: the exit classification for one now reports both exits as returns where it used to report a tail call and a return. That path is unreachable for a protected function. It is not dead. Register clearing is driven by "zero-call-used-regs", a separate attribute that long predates this work and that functions carry without "zeroize-stack"; those functions still tail-call, and the per-exit set is still what makes their tail-call exits correct. The test covering that case carries only "zero-call-used-regs" and is untouched here. The two mechanisms answer for disjoint sets of functions rather than for the same one twice. No existing test changes, in CodeGen/X86, CodeGen/ARM, or anywhere under Transforms. Nothing in the tree combines "zeroize-stack" with a tail call, which is why the suppression arrives without an old test starting to expect less. The new tests are the contrast in both directions on both targets: a protected function that would otherwise jump does not, an unprotected one with the same body still does, and the same pair for a legalizer libcall; the exit classification changing from a tail call to a return; musttail in a protected function rejected under two modes with an unprotected musttail untouched; and the merged pair left unmerged. Each was confirmed load-bearing by breaking the implementation once and restoring it: dropping either half of the suppression failed the CodeGen tests at the corresponding check, dropping the Verifier check failed the musttail test, and dropping the merging exclusion failed the MergeFunc test. This is trailofbits/vspells-ct-internal-notes#22, under the umbrella trailofbits/vspells-ct-internal-notes#17.
Every narrowing the clearing machinery does is an optimization over a guarantee: the guarantee is that nothing the function held survives it, and the narrowings exist so that discharging it costs less. A narrowing has to be able to say why each thing it drops is safe to drop. Where it cannot, the answer has to be to clear more, never less, because the two errors are not symmetric: clearing something that did not need it costs instructions, and clearing nothing that did costs the thing the attribute exists to protect. This is the same one-directional rule "zeroize-stack"="sensitive" already follows, where losing metadata widens the cleared set. Three places in the code as it stands resolved an incomplete analysis the wrong way, or resolved it not at all. Each is fixed here, and each is grounded in a path through the code rather than in a hypothetical. The one that leaked is the register set. A "used" mode of "zero-call-used-regs" clears the registers the function touched, and the sweep that computes them skipped implicit operands. An implicit operand is how the machine layer records a register an instruction touches without naming it, so the effect was that every such register was treated as untouched and left alone. Inline assembly is the worst case, because every register an asm block names -- its clobber list and its physical-register outputs alike -- arrives as an implicit operand of the INLINEASM instruction. A function whose register traffic was an asm block therefore cleared nothing at all under "used-gpr", and the asm's registers carried their contents past the return. It is not only inline assembly: rdtsc leaves a counter in %eax and %edx and names neither, and any pseudo that defines a register on the side reads the same way. Implicit operands now count. This widens what a "used" mode clears; it does not collapse it into "all", and a function that touches no call-used register still clears none. The second is the mode itself. The switch over the value of "zero-call-used-regs" had no default, so a value that is not one of the nine names ran off the end of a StringSwitch: an assertion where builds have them, and in a release compiler an uninitialized mode deciding what gets cleared. An unrecognized mode now means "all", the widest. The modes are a scale, an unknown name says nothing about where on it the producer meant to be, and "all" is the only reading that cannot clear less than was asked for. It is also the reading LangRef already fixes for an unrecognized "zeroize-stack" mode, so the two attributes now agree. "skip" is untouched: it is a name on the scale, not a failure to read one. The third is the exit classification. A block with no successors whose last instruction matched none of the recognized shapes was reported as unreachable, which is out of scope -- that is, the classification answered "control stops here" whenever it did not recognize what it was looking at. Not recognizing an instruction is not the same as knowing what it does. Inline assembly can jump, can issue a system call that does not come back, and can return into another frame, and nothing here can establish that it does not. Those blocks are now a kind of their own, Unknown, which is in scope, and the sequence is emitted in front of the opaque instruction. Unreachable keeps the cases that can be settled positively rather than merely not ruled out: a block with no instructions left in it, and an instruction the target has marked as a trap. A call that does not return keeps its own kind and its own reason, which is about an abandoned frame rather than about not knowing. At an Unknown exit the sequence spares the registers the opaque instruction declares, the same as at every other exit, because a sequence that breaks the instruction the exit leaves through is not an option. What it clears is what the rest of the function used, which is what would otherwise leave with the frame. The two halves fit together: those same asm-declared registers are now cleared at the function's ordinary returns, by the first change above. One more path is made fail-closed without being reachable today. An in-scope exit that the sequence could not be placed at was skipped with an assertion, which in a release compiler is a silent skip: a function that reports itself protected, leaves through a point at which nothing ran, and says nothing about it. It now diagnoses instead. No IR reaches it -- every in-scope exit has an insertion point by construction -- so it carries no test; it is here so that a later change which introduces one is stopped rather than absorbed. Three cases were checked and found already conservative, and are left alone rather than given redundant code. A target that cannot clear registers or the frame is asked before anything is computed and its refusal is reported as an error, so an unimplemented capability fails closed. An unrecognized "zeroize-stack" mode is fixed by LangRef as "used", the widest, and no code consumes the mode yet, so there is nothing to make conservative. A protected function's tail calls are suppressed and musttail rejected, which is the same rule applied to an exit that cannot be cleared at all. No existing test changes. CodeGen/X86, CodeGen/ARM and the whole of test/CodeGen pass unchanged, as do the tests this stack has added. The new tests pin the direction in each case: an asm clobber and an rdtsc cleared under "used-gpr" where they were not, against a function that touches nothing and still clears nothing; an unreadable mode and an empty one producing what "all" produces, against "used-gpr" and "skip" still meaning what they say; and an asm-terminated block reported in scope and cleared in front of, against a trap and an empty block still reported out of scope and left alone. The classification and the mode fallback are pinned on ARM as well, where they are decided before the target is consulted, and where the widened mode reaches that target's refusal rather than resolving quietly to clearing nothing. Each test was confirmed load-bearing by breaking the implementation once and restoring it: restoring the implicit-operand skip failed the register test, defaulting the mode to "skip" failed the mode test on both targets, and making Unknown out of scope failed the exit tests on both targets. "zero-call-used-regs" has no LangRef entry to record the mode rule in; adding one is separate work. This is trailofbits/vspells-ct-internal-notes#24, under the umbrella trailofbits/vspells-ct-internal-notes#17.
The clearing sequence runs the stack clear in front of the register clear because the stack clear cannot do its work without registers: it reads the frame through one and writes zeroes back through another, so when it finishes, the registers it worked through hold what it has just destroyed -- the value it overwrote, or the address inside the frame it overwrote it at. Leaving with those in registers discloses exactly what leaving with them on the stack would have. That order is already fixed. This is the coverage, which the order does not give on its own. What the register clear clears is chosen by "zero-call-used-regs", and every mode of it is a statement about the function: which registers the function used, which of them are argument registers, which are general purpose. A register the clearing machinery itself dirtied is none of those things. A "used" mode does not select it, because the sweep that computes the used set runs while the plan is made, before the stack clear has been emitted, and so cannot see it. An "arg" mode does not select it unless it happens to be an argument register. And a function that asked for its frame to be cleared need not have asked for its registers to be cleared at all, in which case there is no mode to select anything. So the coverage is declared rather than inferred. A step of the sequence records the registers it used, and the register clear adds what has been recorded to what it was already going to clear. Three things follow, and each is the point rather than a detail of how it is written. The declaration is per exit. A step is emitted once at each in-scope exit and need not use the same registers at each one, so the record is built as the sequence runs at an exit and read by the register clear at that same exit, rather than being settled for the function the way the plan is. The declarations are folded in after the exit has narrowed the candidate set and not before. That narrowing exists to spare what the exit still needs, and it would take a declared register straight back out again: a declared register is not one the function used, it is one the sequence dirtied on the way here. The register clear stops being optional once a step in front of it declares anything. A function with "zeroize-stack" and no "zero-call-used-regs" gets a register clear anyway, over nothing but the declared registers, because a request to clear the frame is not discharged while the frame's contents are sitting in registers. The same holds for a function that wrote "zero-call-used-regs"="skip", which declines a clear of what the function left in its registers and says nothing about what clearing its frame put there. It follows that a target that cannot clear registers cannot clear the frame either, and it now says so rather than emitting the half of the sequence it can do. A step may only declare a register whose value at the exit nothing depends on. That rules out the registers the exit itself names -- the return value, a tail call's outgoing arguments, the exception object an unwind resume is passed -- and it rules out the callee-saved registers, which have to reach the exit holding what the caller left in them whether or not the exit names them. A step that needs such a register has to save and restore it rather than declare it, because what is declared is cleared. Builds with assertions check both halves; a build without them clears what it was told to, which is the direction the rest of this machinery errs in. What is not here is stack clearing itself, which is trailofbits/vspells-ct-internal-notes#26 and which no target implements. The step that would declare registers is a placeholder: it emits nothing, uses nothing, and so declares nothing, and with no producer there is nothing to exercise the consumer with. A hidden option, -pei-stack-clear-scratch-regs, stands in for one. It makes the placeholder behave as a target that clears the frame using the registers it names, declaring them and emitting nothing else, which is the part of a real implementation the rest of this file has to cope with. It is inert unless a test asks for it, and it is the only thing that can reach this code today. That fixes what the tests can honestly show, and it is one thing: a register declared by a step in front of the register clear is cleared by it, in cases where nothing else would have cleared it. On X86 that is %r11 cleared under "used-gpr", which does not select it because the function does not use it; under no register attribute at all; under "skip"; and at each of a function's two returns rather than at one. The control is a function whose frame is not being cleared, where %r11 is left alone, so that the other cases could not pass on some unrelated reason for clearing it. The sequence printer reports the declared registers at each exit, so the declaration is visible without reading it back out of the emitted code. On ARM, which implements neither capability, a function that asks only for its frame to be cleared is refused for the register clear it did not ask for and needs, while a function that did ask for one is still refused on its own terms. The registers a real stack clear would pick, and the code that picks them, are not tested here, because they do not exist yet. No existing test changes. CodeGen/X86 and CodeGen/ARM pass unchanged, as do the tests this stack has added. Each new test was confirmed load-bearing by breaking the implementation once and restoring it: dropping the declared registers from what the register clear clears failed the X86 test, and leaving the register clear off in a function that did not ask for one failed both. The assertion cannot run in a build without assertions, so its predicate was checked by turning it into a hard error for one build: declaring a callee-saved register or the return-value register was caught, and declaring a register that is dead at the exit was not. This is trailofbits/vspells-ct-internal-notes#20, under the umbrella trailofbits/vspells-ct-internal-notes#17.
A register clear is an instruction whose only effect is to overwrite a register nothing reads afterwards, which is the definition of a dead instruction. It is emitted precisely because nothing reads the register: the value being destroyed is one the function is finished with, and if anything still needed it the clear would be wrong. So the machine-level reason to keep it is missing from the instruction itself, and it survives for as long as nothing looks. This is the machine-level counterpart of the non-removability llvm.zeroize already carries in the IR, and it is a separate problem, because an intrinsic's properties stop meaning anything once it has been lowered to an XOR. What supplies the missing reason is the exit. The clears run in front of the instruction control leaves through, and recording the cleared registers as implicit uses of a return says what is in fact true: their values at the point of leaving are part of what the function leaves behind, so an instruction that sets them has an effect that outlives the function. A pass that removes an instruction because its definitions are dead then finds them live and leaves the clear alone. Only a return carries the record. The other in-scope exits leave through a call or through an instruction the classification could not read, and neither is a place to state what the function leaves behind: a call's register operands describe its arguments and an opaque instruction's describe whatever the target or the asm string put there. The clears at those exits are left as they are, which is what they are today at every exit, and the test pins that gap rather than passing over it. What is recorded is what the clear wrote, not what it was asked to write. The request names every allocatable alias of a register, so x86 is asked for %rax, %eax, %ax, %al and %ah and emits one instruction defining %eax; reading the emitted instructions back is what gives the registers that exist. A clear also writes registers on the side that are not part of what it destroys -- an XOR sets the flags -- and those are excluded, because saying the function leaves them behind would be saying something else and something untrue. The order matters and is the one hazard here. The record is attached strictly after the whole sequence has run at an exit, and so strictly after the register clear has decided what it covers there. What it attaches sits between the insertion point and the end of the block, which is exactly the range computeRegsToClearAtExit reads to find the registers the exit still needs. Attach first and the register clear spares precisely the registers it was about to clear, and spares them silently: the function reports itself protected and clears nothing. That is not a hypothetical -- it was tried, and every clear in the function disappeared. A register operand on an exit is not read only as liveness, which is why the target gets a say in which register is named. On X86 the collision is real: X86InsertVZeroUpper treats a return that names a YMM or ZMM register as a return carrying a vector value and skips the vzeroupper it would otherwise put in front of it, so naming a cleared YMM there drops the vzeroupper from every AVX function that clears its registers and charges the caller an AVX-to-SSE transition. CodeGen/X86/zero-call-used-regs-simd.ll catches it immediately. Which registers a target reads off an exit, and for what, is not something a target-independent pass can know, so it asks: TargetFrameLowering::getClearedRegExitAnchor. The default is the register itself. X86 answers a YMM or ZMM with its 128-bit part, which keeps the definition live -- a definition is live as soon as any part of it is -- and says nothing about vector state. isBarrier was considered and is not the mechanism. It says that control does not fall through the instruction: Target.td spells the field "Can control flow fall through this instruction?" and MCInstrDesc documents it as whether execution stops there. It is a statement about the CFG, it is what separates an unconditional branch from a conditional one, it has nothing to say about whether an instruction's effects may be discarded, it would be false of a clear, and setting it would misdescribe the block to everything that reads it. An extra instruction was tried in place of the operands and does not work. FAKE_USE is the pseudo for exactly this -- "represents a use of the operand but generates no code" -- it is explicitly exempted from MachineInstr::wouldBeTriviallyDead, and it would cover every exit rather than only the returns. But it is not free of output: the asm printer writes a "# fake_use:" comment for it, so every function that clears registers grows a line and a dozen existing tests that read the emitted code line by line have to be rewritten. Recording the fact on an instruction that is already there costs nothing. How much this closes is worth stating exactly, because it is less than it sounds. DeadMachineInstructionElim is the pass that removes instructions on these grounds, and nothing runs it after prologue/epilogue insertion. Both of its occurrences are in addMachineSSAOptimization, before register allocation, in the legacy pipeline and in CodeGenPassBuilder alike. What does run after, checked with -debug-pass=Structure on X86 and ARM at -O2 and at -O0, is MachineLateInstrsCleanup, BranchFolder, TailDuplicate, MachineCopyPropagation, the post-RA expansions and schedulers, block placement, the target's pre-emit passes and the layout and metadata passes, and none of them removes an instruction for having no live definitions. MachineLateInstrsCleanup comes closest and declines an XOR before reaching the question, because its candidate test rejects an instruction reading a register other than the frame register. So this is not a fix for something the compiler does today. It is the property being made to hold in the code rather than left to the pipeline's composition, and the difference is visible rather than argued: run DeadMachineInstructionElim over what this pass emits and, before this change, every clear in the function goes. That is what the test does. It stops after prologue/epilogue insertion, pipes the result back through llc with -run-pass=dead-mi-elimination, and checks the clears are still there afterwards, at a single return and at each of two. The control is in the same file: the clear at an unwind-resume exit is not anchored, and it is gone after the pass runs, which is what the clears at the returns would do if the record were doing nothing. A fourth function pins the target's substitution, checking both that the return names the 128-bit register and that the function's vzeroupper is still emitted. No existing test changes; CodeGen/X86 and CodeGen/ARM pass unchanged, as do the tests this stack has added. The test was confirmed load-bearing by breaking the implementation three times and restoring it: dropping the record failed it; dropping X86's substitution failed it and took CodeGen/X86/zero-call-used-regs-simd.ll with it; and attaching the record before the clear set is computed failed it along with CodeGen/X86/zero-call-used-regs.ll, with the emitted code losing every clear it had. Nothing here is pinned on ARM, which implements no register clearing and so has no clears to keep. This is trailofbits/vspells-ct-internal-notes#66, under the umbrella trailofbits/vspells-ct-internal-notes#17.
9c4ac60 to
3e2a3e5
Compare
c7ec7aa to
0cb5632
Compare
|
A full
This bears on the earlier recommendation to drop this PR rather than rework it. The prior argument was cost/benefit, that no Build: Generated by Claude Code |
3e2a3e5 to
3405cff
Compare
Requested by Francesco Bertolaccini · Slack thread
A register clear is an instruction whose only effect is to overwrite a register
nothing reads afterwards, which is the definition of a dead instruction. It is
emitted precisely because nothing reads the register: the value being destroyed
is one the function is finished with, and if anything still needed it the clear
would be wrong. So the machine-level reason to keep it is missing from the
instruction itself, and it survives for as long as nothing looks. This is the
machine-level counterpart of the non-removability llvm.zeroize already carries
in the IR, and it is a separate problem, because an intrinsic's properties stop
meaning anything once it has been lowered to an XOR.
What supplies the missing reason is the exit. The clears run in front of the
instruction control leaves through, and recording the cleared registers as
implicit uses of a return says what is in fact true: their values at the point
of leaving are part of what the function leaves behind, so an instruction that
sets them has an effect that outlives the function. A pass that removes an
instruction because its definitions are dead then finds them live and leaves
the clear alone.
Only a return carries the record. The other in-scope exits leave through a call
or through an instruction the classification could not read, and neither is a
place to state what the function leaves behind: a call's register operands
describe its arguments and an opaque instruction's describe whatever the target
or the asm string put there. The clears at those exits are left as they are,
which is what they are today at every exit, and the test pins that gap rather
than passing over it.
What is recorded is what the clear wrote, not what it was asked to write. The
request names every allocatable alias of a register, so x86 is asked for %rax,
%eax, %ax, %al and %ah and emits one instruction defining %eax; reading the
emitted instructions back is what gives the registers that exist. A clear also
writes registers on the side that are not part of what it destroys -- an XOR
sets the flags -- and those are excluded, because saying the function leaves
them behind would be saying something else and something untrue.
The order matters and is the one hazard here. The record is attached strictly
after the whole sequence has run at an exit, and so strictly after the register
clear has decided what it covers there. What it attaches sits between the
insertion point and the end of the block, which is exactly the range
computeRegsToClearAtExit reads to find the registers the exit still needs.
Attach first and the register clear spares precisely the registers it was about
to clear, and spares them silently: the function reports itself protected and
clears nothing. That is not a hypothetical -- it was tried, and every clear in
the function disappeared.
A register operand on an exit is not read only as liveness, which is why the
target gets a say in which register is named. On X86 the collision is real:
X86InsertVZeroUpper treats a return that names a YMM or ZMM register as a
return carrying a vector value and skips the vzeroupper it would otherwise put
in front of it, so naming a cleared YMM there drops the vzeroupper from every
AVX function that clears its registers and charges the caller an AVX-to-SSE
transition. CodeGen/X86/zero-call-used-regs-simd.ll catches it immediately.
Which registers a target reads off an exit, and for what, is not something a
target-independent pass can know, so it asks:
TargetFrameLowering::getClearedRegExitAnchor. The default is the register
itself. X86 answers a YMM or ZMM with its 128-bit part, which keeps the
definition live -- a definition is live as soon as any part of it is -- and
says nothing about vector state.
isBarrier was considered and is not the mechanism. It says that control does not
fall through the instruction: Target.td spells the field "Can control flow fall
through this instruction?" and MCInstrDesc documents it as whether execution
stops there. It is a statement about the CFG, it is what separates an
unconditional branch from a conditional one, it has nothing to say about whether
an instruction's effects may be discarded, it would be false of a clear, and
setting it would misdescribe the block to everything that reads it.
An extra instruction was tried in place of the operands and does not work.
FAKE_USE is the pseudo for exactly this -- "represents a use of the operand but
generates no code" -- it is explicitly exempted from
MachineInstr::wouldBeTriviallyDead, and it would cover every exit rather than
only the returns. But it is not free of output: the asm printer writes a
"# fake_use:" comment for it, so every function that clears registers grows a
line and a dozen existing tests that read the emitted code line by line have to
be rewritten. Recording the fact on an instruction that is already there costs
nothing.
How much this closes is worth stating exactly, because it is less than it
sounds. DeadMachineInstructionElim is the pass that removes instructions on
these grounds, and nothing runs it after prologue/epilogue insertion. Both of
its occurrences are in addMachineSSAOptimization, before register allocation,
in the legacy pipeline and in CodeGenPassBuilder alike. What does run after,
checked with -debug-pass=Structure on X86 and ARM at -O2 and at -O0, is
MachineLateInstrsCleanup, BranchFolder, TailDuplicate, MachineCopyPropagation,
the post-RA expansions and schedulers, block placement, the target's pre-emit
passes and the layout and metadata passes, and none of them removes an
instruction for having no live definitions. MachineLateInstrsCleanup comes
closest and declines an XOR before reaching the question, because its candidate
test rejects an instruction reading a register other than the frame register.
So this is not a fix for something the compiler does today. It is the property
being made to hold in the code rather than left to the pipeline's composition,
and the difference is visible rather than argued: run
DeadMachineInstructionElim over what this pass emits and, before this change,
every clear in the function goes.
That is what the test does. It stops after prologue/epilogue insertion, pipes
the result back through llc with -run-pass=dead-mi-elimination, and checks the
clears are still there afterwards, at a single return and at each of two. The
control is in the same file: the clear at an unwind-resume exit is not anchored,
and it is gone after the pass runs, which is what the clears at the returns
would do if the record were doing nothing. A fourth function pins the target's
substitution, checking both that the return names the 128-bit register and that
the function's vzeroupper is still emitted.
No existing test changes; CodeGen/X86 and CodeGen/ARM pass unchanged, as do the
tests this stack has added. The test was confirmed load-bearing by breaking the
implementation three times and restoring it: dropping the record failed it;
dropping X86's substitution failed it and took
CodeGen/X86/zero-call-used-regs-simd.ll with it; and attaching the record before
the clear set is computed failed it along with
CodeGen/X86/zero-call-used-regs.ll, with the emitted code losing every clear it
had. Nothing here is pinned on ARM, which implements no register clearing and
so has no clears to keep.
This is trailofbits/vspells-ct-internal-notes#66, under the umbrella
trailofbits/vspells-ct-internal-notes#17.
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