Motivation
We model memory allocation as “creating” provenance and memory deallocation as “destroying” provenance. This is reasonably well-defined in cases where the implementation of the allocator lives in a different module. However, it becomes unclear what exactly these terms mean when the allocator is visible in the same module. This RFC is an attempt to specify the provenance semantics of allocators in LLVM, and provide the means to correctly handle them even if asymmetrically inlined.
Additionally, the proposal also provides a mechanism to represent one large allocation that contains multiple smaller ones in a way that can be understood by the optimizer. This desire has previously come up in N-ary separate_objects / non-argument `noalias` groups, esp. in the context of GPU targets.
There are a number of challenges related to allocator provenance, but I think the most important ones are:
- Nesting. For example, a bump pointer allocator might use a custom allocator, which might in turn use malloc. Our specified semantics for return-position
noalias(which is used primarily to denote allocators) is “a pointer to allocated storage disjoint from the storage for any other object accessible to the caller”. However, if there are (visible) nested allocators, both the allocator return value and the underlying allocation it is serviced from may both be “accessible”. - Free-lists. The provenance of the pointer passed to the deallocator function is destroyed – however, the deallocator may stash the pointer inside a free list and then return it from the allocator again. The pointer returned from the allocator of course can’t have destroyed provenance.
- Leaked allocations. It’s possible for allocations to never be explicitly deallocated (i.e. leak), but the parent allocation to be freed. A typical example is a bump pointer allocator going out of scope. How to handle this is an open question of the proposal.
Proposal
Intrinsics
We model allocator provenance using two new intrinsics llvm.provenance.alloc and llvm.provenance.dealloc. Allocators should call llvm.provenance.alloc when returning the pointer from the allocator and llvm.provenance.dealloc when accepting the pointer argument to the deallocator:
define ptr noalias @"operator new"(i64 %size) {
%p = ... ; Somehow obtain the memory
%p.alloc = call ptr @llvm.provenance.alloc(ptr %p, i64 %size)
ret ptr %p.alloc
}
define void @"operator delete"(ptr %p.alloc) {
%p = call ptr @llvm.provenance.dealloc(ptr %p.alloc)
... ; Do something with the freed memory, e.g. put it in a free list.
ret void
}
For allocators that are visible and may be inlined, these intrinsics should be explicitly materialized. For inaccessible allocators, we can just pretend these operations are there to understand their semantics. The intrinsics can also be used entirely standalone to manage sub-allocations of a larger allocation.
Allocator provenance
Allocator provenance forms a tree, where each allocation adds a new leaf provenance, which remembers which parent allocator provenance it was derived from. A deallocation removes a leaf (or subtree). Only the leaves have (full) access to the memory range they cover.
More explicitly, the semantics of the two intrinsics are:
%ptr = @llvm.provenance.alloc(%ptr_orig, %size):
- If
%ptr_origis a null pointer, return a null pointer. - We call the provenance of
%ptr_origthe parent allocator provenance. It must have access permissions for%sizebytes starting at%ptr_orig, otherwise the behavior is undefined. - Access permissions for
%sizebytes starting at%ptr_origare masked in the parent allocator provenance (including any provenance derived from it). “Masked” means that loads (using the parent allocator provenance) return poison, while stored caused undefined behavior. - Returns a pointer to a new allocated object of size
%sizeat address%ptr_orig(which has access permissions for%sizebytes starting at%ptr_orig).
%ptr_orig = @llvm.provenance.dealloc(%ptr):
- If
%ptris a null pointer, return a null pointer. %ptrmust have allocator provenance, otherwise the behavior is undefined.- Destroy the allocated object
%ptr(which includes “destroying” the provenance and disabling all access permissions). What happens if there is any live child allocator provenance is discussed later. - Restore masked access permissions for
%sizebytes starting at%ptrin the parent allocator provenance. - Return a pointer with the parent allocator provenance.
Permission masking
When a sub-allocation is created, we want the memory covered by that sub-allocation to no longer be accessible by the parent allocation, otherwise we could not treat the sub-allocation as an identified object for alias analysis purposes.
At the same time, making all memory accesses to the memory region through the parent allocation UB would imply that the parent allocation is no longer fully dereferenceable, in the sense that speculating loads into it may cause UB. This is a problem, because we assume that allocas and globals are always dereferenceable, and we’d like it to be possible to produce sub-allocations of allocas/globals.
As such, “masked permissions” have the same semantics as allocas after lifetime.end: Loads with masked permissions result in a poison value, while stores cause undefined behavior. This also means that llvm.provenance.alloc is nofree, as it does not render any bytes non-dereferenceable.
Conversely, llvm.provenance.dealloc fully disables permissions (“destroys” the provenance), so both loads and stores become UB.
Open question: Leaked allocations
An open question of this proposal is how to handle leaked allocations. Consider the following setup: We allocate a chunk of memory using malloc() and use it as the backing memory for a bump pointer allocator. The allocations returned by the bump pointer allocator are (typically) never freed. However, the backing storage will get freed().
This poses a problem for using return-position noalias on the result of the bump pointer allocator.
%ptr = call noalias ptr @bump_ptr_alloc(ptr %underlying_allocation, i64 %size)
%v = load i32, ptr %ptr
call void @free(ptr %underlying_allocation)
Under current semantics, the return-position noalias here implies that the @free() call cannot affect %ptr, so it would be fine to sink the load below the @free() call. Of course, this does not hold in this setup.
I think there are basically two ways to solve this:
- Do not allow deallocating a parent allocation while there are still live child allocations. In practice, this means that the allocator modelling framework can mostly only be used for global allocators which don’t have a freeable parent allocation (or at least we can pretend that they don’t, because it’s only freed on program shutdown). This means bump pointer allocators cannot be annotated
noalias(or__attribute__((malloc))in C). - Weaken return-position
noaliasto not be noalias with regard to unknown non-nofreecalls.
A possible middle-ground here would be to change the llvm.provenance.alloc signature to be something like this:
declare ptr @llvm.provenance.alloc(ptr %p, i64 %size, i1 immarg %can_free_via_parent)
Where the %can_free_via_parent argument determines whether it’s legal to free an ancestor allocation while this allocation is still live. If %can_free_via_parent is false, then the return value is noalias (with its current meaning). If %can_free_via_parent is true, then it’s not noalias (though if we wanted, we could add an attribute to encode the weaker semantics, like a freeable_noalias).
Reallocation
Reallocation can be represented by a combination of @llvm.provenance.dealloc (on entry to the reallocator) and @llvm.provenance.alloc (on return from the reallocator).
Notably, this combination of dealloc+alloc implies the usual reallocation semantics, where accesses through the original pointer are UB after reallocation, even if the address of the allocation stays the same. LLVM also has a notion of in-place growable allocations, where the original pointer can be used after the grow operation. We could introduce an additional @llvm.provenance.grow intrinsic to represent such an in-place grow operation, but this proposal doesn’t do so.
A consequence of representing realloc using dealloc+alloc is that dealloc cannot poison the memory (contrary to a previous version of this proposal). However, LLVM currently implicitly assumes that allockind("free") poisons the memory (or more specifically, that removing stores prior to free is legal). An additional allockind flag to indicate whether free poisons the memory will be needed (like allockind("free,poisons_memory")).
Address identity
LLVM currently makes a number of assumptions about address identity of functions with noalias returns. It was always dubious to bind these to noalias (which is a statement about provenance only), but this RFC makes the problem more obvious.
The first assumption is that the address of the allocation is unpredictable. That is, we can always fold comparisons of the address with another address (not derived from the allocation) to false, as long as this happens consistently for all observations of the allocation address. This clearly does not hold up under this proposal, because the address of the @llvm.provenance.alloc return value is the same as its argument.
The second assumption is that noalias return allocations can not overlap with allocas or globals. This is also not true under this proposal, as it’s possible to perform an @llvm.provenance.alloc on a global or alloc (and in fact, doing this on globals is a primary use case, see “standalone usage” below).
To address this, assumptions about allocation addresses should be decoupled from noalias and moved to allockind properties instead. There should be an address_unpredictable property for the first assumption, and an alloc_disjoint property for the second. alloc_disjoint can only be used for top-level allocators which are not nested within another allocator.
Standalone usage
The allocator provenance intrinsics can also be used standalone, for use cases like the ones discussed in N-ary separate_objects / non-argument `noalias` groups.
For example, a global of multiple merged allocations could looks something like this:
@merged_global = [TOTAL_SIZE x i8]
; ...
%global1 = call noalias ptr @llvm.provenance.alloc(ptr @merged_global, i64 SIZE_1)
%global2 = call noalias ptr @llvm.provenance.alloc(
ptr getelementptr (i8, ptr @merged_global, i64 SIZE_1), i64 SIZE_2)
; ...
This is of course under the assumption that there is a single place where these sub-allocations can be created (e.g. at the start of a kernel). This model does not work if you need multiple places to independently derive pointers to sub-allocations.
This also covers the case where allocations can be freed again (via @llvm.provenance.dealloc) and the memory reused.
Attributes
The attributes that are applicable to the two intrinsics are as follows:
declare noalias ptr @llvm.provenance.alloc(ptr %ptr_orig, i64 %size)
nofree nosync nocallback
memory(argmem: readwrite, inaccessiblemem: readwrite)
allockind("alloc") allocsize(1) "alloc-family"="provenance-alloc"
declare ptr @llvm.provenance.dealloc(ptr allocptr captures(address) %ptr)
nosync nocallback
memory(argmem: readwrite, inaccessiblemem: readwrite)
allockind("free") "alloc-family"="provenance-alloc"
Some notes on the attributes:
- The argument to dealloc is
captures(address), i.e. the provenance is not captured. This is valid, because provenance non-capture means that the provenance of the pointer passed to the intrinsic is disabled when the intrinsic returns. This is fine, because the return value of@llvm.provenance.deallocrecovers the parent allocator provenance, so free-list style usages are still valid. - Pairs of provenance.alloc/provenance.dealloc on the same pointer can be elided, under the usual
allockindconstraints. - We do not assume that the pointers are
nonnull, to make integration with fallible allocators easier. - We do not assume that
@llvm.provenance.allocinitializes the memory to any particular value. - Both intrinsics read and write argument memory, to indicate that accesses (to the parent or child allocation) cannot be reordered around these intrinsics. Both intrinsics also have
inaccessiblemem: readwrite, because they cannot be freely moved. (Is the inaccessiblemem actually needed if we already have argmem: readwrite?) - The presence of the
noaliasreturn attribute is contingent on which semantics we pick for “leaked allocations”. E.g. if we have a%can_free_via_parentflag,noaliaswould only apply if it is false. - We don’t specify allockinds
address_unpredictable,alloc_disjointon alloc orpoisons_memoryon dealloc for reasons explained above.
Changelog
- 2026-06-22: Changed dealloc to not poison memory, and added section on “reallocation”.
- 2026-06-22: Added section on address identity, with proposed additional allockind values to control address identitiy optimizations.