Skip to content

[IR] Add the llvm.zeroize intrinsic - #2

Draft
claude[bot] wants to merge 1 commit into
zeroize-stack-attributefrom
zeroize-intrinsic
Draft

[IR] Add the llvm.zeroize intrinsic#2
claude[bot] wants to merge 1 commit into
zeroize-stack-attributefrom
zeroize-intrinsic

Conversation

@claude

@claude claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Requested by Francesco Bertolaccini · Slack thread

Add llvm.zeroize, an overloaded pointer-and-length intrinsic that writes zero
over [dest, dest + len) and guarantees the clear reaches the machine without
calling 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 a memset over a dynamic length —
the shape that ships, since __builtin_zeroize_memory(p, n) takes a dynamic
n — or over any large fixed length lowers to callq memset@PLT. Measured on
x86-64 with six values live across the clear:

volatile memset llvm.zeroize
the clear callq memset@PLT movb $0, %al; rep;stosb
the six live values forced into callee-saved registers, pushed in the prologue left where they were
frame size 56 bytes 8 bytes
callee frame below %rsp yes none

A 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::isRemovable refuses to remove
volatile memory intrinsics unconditionally
(llvm/lib/Transforms/Scalar/DeadStoreElimination.cpp:1475-1477), so a volatile
memset survives -passes=dse, survives default<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.inline survives dead store elimination and lowers without a
libcall, so it looks like it closes the gap. It does not: SROA rewrites it back
into a plain llvm.memset at llvm/lib/Transforms/Scalar/SROA.cpp:3686, on
exactly 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.inline is also the governing precedent for the objection itself.
llvm/include/llvm/IR/Intrinsics.td:1185 gives it 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. Upstream accepted a
second intrinsic with identical optimizer semantics on that basis alone.
llvm.zeroize is the same argument with a stronger guarantee: no external call
and 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::getForDest can reduce to a single location, so dead store
elimination 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.prefetch is deliberately pessimistic in the
same way and carries a comment saying so, and llvm.sideeffect uses the
inaccessible memory half alone and therefore writes nothing. IntrNoDuplicate
keeps 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.memset in the same form
llvm.memset.inline uses, and derives non-removability from the contract rather
than 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.ll covers the three
situations 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 volatile memset, a volatile
llvm.memset.inline, and llvm.zeroize. The plain memset is removed
everywhere; llvm.zeroize survives everywhere, under -passes=dse for the
narrow 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.zeroize from a volatile memory
intrinsic, and a comment in the file says so, so the test is not read as pinning
a distinction it does not make. The memset_inline rows pin the SROA rewrite on
the 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 one def plus the test and the doc
references.

Stacked on zeroize-stack-attribute and targets that branch, not
enforced_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

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@claude
claude Bot force-pushed the zeroize-stack-attribute branch from 66b3ca1 to a50c171 Compare August 11, 2026 16:33
@claude
claude Bot force-pushed the zeroize-intrinsic branch from 68b8fec to 0bd0b1b Compare August 11, 2026 16:33
@claude claude Bot changed the title Define the llvm.zeroize clearing intrinsic [IR] Add the llvm.zeroize intrinsic Aug 11, 2026
Comment thread llvm/docs/LangRef.md
bit width for the length and for different address spaces.

```
declare void @llvm.zeroize.p0.i32(ptr <dest>, i32 <len>)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@claude
claude Bot force-pushed the zeroize-intrinsic branch from 0bd0b1b to f01c5be Compare August 11, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants