[IR] Add the llvm.zeroize intrinsic - #2
Conversation
|
|
66b3ca1 to
a50c171
Compare
68b8fec to
0bd0b1b
Compare
| bit width for the length and for different address spaces. | ||
|
|
||
| ``` | ||
| declare void @llvm.zeroize.p0.i32(ptr <dest>, i32 <len>) |
There was a problem hiding this comment.
Do you need to add llvm.zeroize if there is llvm.memset intrinsic?llvm.memset does get optimized away by DCE but maintainers may pushback on having another intrinsic with overlapping job.
There was a problem hiding this comment.
You're right about the removability argument, and I'm reworking the PR around that.
A volatile llvm.memset (i1 true) survives -passes=dse and default<O2> in all three shapes this PR's test uses, and survives the backend too — DeadStoreElimination.cpp:1475-1477 refuses to remove volatile memory intrinsics unconditionally. So "it has to survive dead store elimination" does not distinguish llvm.zeroize from a volatile memset. I'm demoting that from the headline justification to a consequence of the clearing contract.
What does distinguish it is lowering. The shape that ships, __builtin_zeroize_memory(p, n), takes a dynamic n, and a volatile memset with a dynamic length — or with any large fixed length — lowers to callq memset@PLT. Measured on x86-64 with six values live across the clear:
- volatile memset: the libcall forces all six into callee-saved registers and pushes them in the prologue (
rbx,r12-r15,rbp), growing the frame 7x (56 bytes vs 8), and plants a callee frame below the stack pointer. llvm.zeroize, as a pseudo expanded after register allocation in the stacked lowering PR:movb $0, %al; rep;stosb, no libcall at any size.
The libcall manufactures exactly the residue class the feature exists to erase. That is a codegen guarantee, and there is no attribute on a memset that expresses it.
llvm.memset.inline is the obvious next suggestion, and it doesn't rescue the memset route. It survives DSE and lowers without a libcall, but SROA silently demotes it back to plain memset at SROA.cpp:3686, on exactly the non-escaping sensitive stack buffers this feature targets, so the no-libcall guarantee is gone before the backend sees it.
On the overlapping-job pushback specifically, llvm.memset.inline is also the governing precedent. Intrinsics.td:1185 gives it byte-identical properties to int_memset, and LangRef states its behaviour is equivalent to llvm.memset with a codegen guarantee on top. Upstream accepted a second intrinsic with identical optimizer semantics justified purely by a codegen guarantee; that is the same shape of argument here.
I'm updating the LangRef entry and the PR description to lead with the lowering argument, and adding volatile-memset and volatile-memset.inline rows to the DSE test so it pins the distinction that actually exists instead of the one it currently implies.
Generated by Claude Code
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.
0bd0b1b to
f01c5be
Compare
Requested by Francesco Bertolaccini · Slack thread
Add
llvm.zeroize, an overloaded pointer-and-length intrinsic that writes zeroover
[dest, dest + len)and guarantees the clear reaches the machine withoutcalling an external function and without establishing a call frame.
Why an intrinsic
The guarantee that matters here is a codegen guarantee. Every other way of
clearing a buffer goes through
memset, and amemsetover a dynamic length —the shape that ships, since
__builtin_zeroize_memory(p, n)takes a dynamicn— or over any large fixed length lowers tocallq memset@PLT. Measured onx86-64 with six values live across the clear:
memsetllvm.zeroizecallq memset@PLTmovb $0, %al; rep;stosb%rspA clear that spills the live state it is trying to erase into the stack it is
trying to erase, and then opens a fresh callee frame underneath the pointer it
was handed, manufactures exactly the residue class this feature exists to
remove. The stacked lowering change expands the intrinsic as a pseudo after
register allocation, so there is no libcall at any size.
Why not a volatile memset
An earlier revision of this description led with non-removability. That was
wrong, and @kumarak was right to push on it: a volatile
llvm.memset(i1 true) is not removable either.DSEState::isRemovablerefuses to removevolatile memory intrinsics unconditionally
(
llvm/lib/Transforms/Scalar/DeadStoreElimination.cpp:1475-1477), so a volatilememset survives
-passes=dse, survivesdefault<O2>, and survives the backend,in all three shapes this PR's test covers.
Non-removability is still part of the intrinsic's contract — a clearing
intrinsic that could be discarded would be useless — but it is a consequence of
the contract rather than the reason for it, and this PR no longer argues
otherwise. What a volatile memset cannot do is avoid the libcall above.
Why not llvm.memset.inline
llvm.memset.inlinesurvives dead store elimination and lowers without alibcall, so it looks like it closes the gap. It does not: SROA rewrites it back
into a plain
llvm.memsetatllvm/lib/Transforms/Scalar/SROA.cpp:3686, onexactly the non-escaping sensitive stack buffers this feature targets, so the
no-libcall guarantee is lost before code generation sees it. That rewrite is
legal precisely because LangRef defines the two as equivalent. The test pins the
rewrite so the argument does not have to be taken on trust.
On adding a second intrinsic with an overlapping job
llvm.memset.inlineis also the governing precedent for the objection itself.llvm/include/llvm/IR/Intrinsics.td:1185gives it byte-identical properties toint_memset, and LangRef states its behavior is equivalent tollvm.memset,with a codegen guarantee as the whole of its justification. Upstream accepted a
second intrinsic with identical optimizer semantics on that basis alone.
llvm.zeroizeis the same argument with a stronger guarantee: no external calland no call frame, and not rewritable into the weaker form.
Implementation
The non-removability half of the contract comes out of the intrinsic's 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
MemoryLocation::getForDestcan reduce to a single location, so dead storeelimination has nothing to remove; the argument memory half keeps the write to
the region itself visible to alias analysis, so the intrinsic does not become a
general optimization barrier.
llvm.prefetchis deliberately pessimistic in thesame way and carries a comment saying so, and
llvm.sideeffectuses theinaccessible memory half alone and therefore writes nothing.
IntrNoDuplicatekeeps one clear from becoming several. Overloading on both the pointer and the
length gives mangled names like
llvm.zeroize.p0.i64.LangRef now leads with the clearing contract and the codegen guarantee, states
the equivalence to a zero-valued volatile
llvm.memsetin the same formllvm.memset.inlineuses, and derives non-removability from the contract ratherthan presenting it as the point. It also records what stays permitted — moving
the call to a later point on the same control-flow paths when nothing in between
can read the region, and merging calls over adjacent or overlapping regions — so
the guarantee is not read as a blanket barrier.
Test
llvm/test/Transforms/DeadStoreElimination/zeroize.llcovers the threesituations dead store elimination handles — dead at the end of a function, fully
overwritten by a later write, dead at the end of the object's lifetime — with
four intrinsics in each: a plain
memset, a volatilememset, a volatilellvm.memset.inline, andllvm.zeroize. The plain memset is removedeverywhere;
llvm.zeroizesurvives everywhere, under-passes=dsefor thenarrow claim and under
default<O2>for the full pipeline.The volatile rows are carried deliberately: they show that at the IR level
non-removability does not distinguish
llvm.zeroizefrom a volatile memoryintrinsic, and a comment in the file says so, so the test is not read as pinning
a distinction it does not make. The
memset_inlinerows pin the SROA rewrite onthe alloca cases while showing it does not fire on a plain pointer argument. The
lowering guarantee is not an IR-level property and is tested with the lowering
change rather than here.
Notes
Nothing lowers the intrinsic yet. Expansion in the backend is deliberately left
to trailofbits/vspells-ct-internal-notes#11, and LangRef says the target
sequence is specified separately; IR containing the intrinsic will fail to
select if it reaches code generation before then.
The name and the argument shape are recommendations still awaiting sign-off on
trailofbits/vspells-ct-internal-notes#64; the internal draft signature was
llvm.zeroize.clear.p0. A rename touches onedefplus the test and the docreferences.
Stacked on
zeroize-stack-attributeand targets that branch, notenforced_secrecy_main. Closes trailofbits/vspells-ct-internal-notes#10.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