saluki_common/resource_tracking/allocator.rs
1//! Global allocator implementation that allows tracking allocations on a per-group basis.
2
3// TODO: The current design does not allow for deregistering groups, which is currently fine and
4// likely will be for a while, but would be a limitation in a world where we dynamically launched
5// data pipelines and wanted to clean up removed components, and so on.
6
7use std::{
8 alloc::{GlobalAlloc, Layout},
9 ptr,
10};
11
12use super::{groups::CURRENT_GROUP, stats::ResourceStats};
13
14const STATS_LAYOUT: Layout = Layout::new::<*const ResourceStats>();
15
16/// A global allocator that tracks allocations on a per-group basis.
17///
18/// This allocator provides the ability to track the allocations/deallocations, both in bytes and objects, for
19/// different, user-defined resource groups.
20///
21/// # Resource groups
22///
23/// Allocation (and deallocations) are tracked by **resource group**. When this allocator is used, every allocation is
24/// associated with an resource group. Resource groups are user-defined, except for the default "root" resource
25/// group which acts as a catch-all when a user-defined group isn't currently entered.
26///
27/// # Token guard
28///
29/// When an resource group is registered, an `ResourceGroupToken` is returned. This token can be used to "enter" the
30/// group, which attribute all allocations on the current thread to that group. Entering the group returns a drop guard
31/// that restores the previously entered allocation when it's dropped.
32///
33/// This allows for arbitrarily nested resource groups.
34///
35/// ## Changes to memory layout
36///
37/// In order to associate an allocation with the current resource group, a small trailer is added to the requested
38/// allocation layout, in the form of a pointer to the statistics for the resource group. This allows updating the
39/// statistics directly when an allocation is deallocated, without having to externally keep track of what group a given
40/// allocation belongs to. These statistics are updated directly when the allocation is initially made, and when it's
41/// deallocated.
42///
43/// This means that all requested allocations end up being one machine word larger: 4 bytes on 32-bit systems, and 8
44/// bytes on 64-bit systems.
45pub struct TrackingAllocator<A> {
46 allocator: A,
47}
48
49impl<A> TrackingAllocator<A> {
50 /// Creates a new `TrackingAllocator` that wraps another allocator.
51 ///
52 /// The wrapped allocator is used to actually allocate and deallocate memory, while this allocator is responsible
53 /// purely for tracking the allocations and deallocations themselves.
54 pub const fn new(allocator: A) -> Self {
55 Self { allocator }
56 }
57}
58
59unsafe impl<A> GlobalAlloc for TrackingAllocator<A>
60where
61 A: GlobalAlloc,
62{
63 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
64 // Adjust the requested layout to fit our trailer and then try and allocate it.
65 let (layout, trailer_start) = get_layout_with_group_trailer(layout);
66 let layout_size = layout.size();
67 let ptr = self.allocator.alloc(layout);
68 if ptr.is_null() {
69 return ptr;
70 }
71
72 // Store the pointer to the current resource group in the trailer, and also update the statistics.
73 let trailer_ptr = trailer_ptr_from(ptr, trailer_start);
74 CURRENT_GROUP.with(|current_group| {
75 let group_ptr = current_group.borrow();
76 group_ptr.as_ref().track_allocation(layout_size);
77
78 trailer_ptr.write(group_ptr.as_ptr());
79 });
80
81 ptr
82 }
83
84 unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
85 // Read the pointer to the owning resource group from the trailer and update the statistics.
86 let (layout, trailer_start) = get_layout_with_group_trailer(layout);
87 let trailer_ptr = trailer_ptr_from(ptr, trailer_start);
88 let group = (*trailer_ptr).as_ref().unwrap();
89 group.track_deallocation(layout.size());
90
91 // Deallocate the memory.
92 self.allocator.dealloc(ptr, layout);
93 }
94}
95
96/// Returns a pointer to the group trailer of the allocation based at `ptr`.
97///
98/// # Provenance
99///
100/// The trailer sits past the end of the layout the caller asked for, and the caller's pointer does not carry provenance
101/// that reaches it. `<*mut u8>::add` requires its result to stay within the allocated object, and the object the
102/// compiler believes it is offsetting within is exactly `layout.size()` bytes: the allocation shim generated for a
103/// global allocator advertises the requested size to the optimizer, not whatever larger block the allocator actually
104/// carved out. Reaching the trailer with ordinary pointer arithmetic is therefore out of bounds, and the resulting
105/// offset folds to a poison value wherever the optimizer can see both the allocation and the offset -- which it can
106/// whenever the shim is inlined into the allocating function. A poisoned pointer then fails checks it should pass,
107/// including the alignment check inserted under debug assertions.
108///
109/// Synthesizing the pointer from the address instead keeps the trailer reachable. `alloc` exposes the provenance of the
110/// whole padded block, so a pointer recovered from an address within that block carries provenance for all of it,
111/// trailer included, on both the allocation and deallocation paths.
112fn trailer_ptr_from(ptr: *mut u8, trailer_start: usize) -> *mut *mut ResourceStats {
113 ptr::with_exposed_provenance_mut(ptr.expose_provenance().wrapping_add(trailer_start))
114}
115
116fn get_layout_with_group_trailer(layout: Layout) -> (Layout, usize) {
117 let (new_layout, trailer_start) = layout.extend(STATS_LAYOUT).unwrap();
118 (new_layout.pad_to_align(), trailer_start)
119}