Skip to content

Commit 37b18a8

Browse files
mjp41Copilot
andcommitted
Round metadata to MIN_META_ALIGN, not next_pow2
BackendArenaRange / SmallArenaRange accept any UNIT_SIZE-aligned request; the pow2 rounding the backend was applying to metadata sizes was a leftover from the buddy era and inflated every slab's metadata block to the next power of two. With a ClientMeta provider whose per-slab storage is non-pow2 (e.g. allocation bitmap + small fixed header), this rounding doubled the metadata overhead. Publish MIN_META_ALIGN on each LocalState (= MetaRange::UNIT_SIZE). Add BackendAllocator::meta_size_round, which pads to MIN_META_ALIGN and steps up to MIN_CHUNK_SIZE for requests that would bypass the small range to the parent. Replace all four next_pow2-rounded metadata sites in backend.h with this helper. A new test func/client_meta_nonpow2 installs a ClientMetaDataProvider whose per-slab storage is non-pow2 and exercises alloc/dealloc round-tripping across several sizeclasses; any disagreement between alloc-side and dealloc-side rounding would trip the meta range's dealloc_range assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4bc2c8f commit 37b18a8

5 files changed

Lines changed: 254 additions & 8 deletions

File tree

docs/Arena.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Bitmap-Indexed Coalescing Range
2+
3+
## The problem
4+
5+
snmalloc's `LargeBuddyRange` only stores power-of-two blocks. A request for 5
6+
chunks must be served from an 8-chunk buddy block, wasting 3 chunks. We want
7+
to store blocks at their actual size and use snmalloc's full size class
8+
sequence at the range level.
9+
10+
## The core idea: search upward, skip a mask
11+
12+
Free blocks are binned by the set of size classes they can serve. To allocate,
13+
search upward through bins — any larger block can be carved down. This almost
14+
works perfectly, but some bins hold blocks whose alignment is too poor to
15+
serve certain smaller, more-aligned sizes. Those bins must be masked out
16+
during the search.
17+
18+
The mechanism: `find_first_set(bitmap & ~skip_mask)`. The skip mask depends
19+
only on the requested size class, not on the block. It's a small constant
20+
that can be precomputed.
21+
22+
## Why skips exist
23+
24+
snmalloc's size classes follow `S = 2^e + m · 2^(e−B)`, where `B` is the
25+
number of intermediate bits. Each size class has a natural alignment
26+
`align(S) = S & ~(S−1)`.
27+
28+
A size class with high alignment needs padding to reach an aligned address
29+
within a block. A block of a *larger* size class with *lower* alignment may
30+
not have room for that padding. Concretely: a block of size 5 at address 1
31+
can serve size 5 (alignment 1) but cannot serve size 4 (alignment 4) — there
32+
aren't enough chunks left after padding to the first 4-aligned address.
33+
34+
Same size block, different address, different capability. This is what creates
35+
the need for separate bins and skip masks.
36+
37+
## The general structure
38+
39+
At each exponent level, the distinct "servable sets" (which size classes a
40+
block can serve) form a structure with some incomparable pairs. Exhaustive
41+
enumeration shows:
42+
43+
| B | Mantissas/exponent | Bins/exponent | Max skip mask bits |
44+
|---|-------------------:|--------------:|-------------------:|
45+
| 1 | 2 | 2 | 0 |
46+
| 2 | 4 | 5 | 1 |
47+
| 3 | 8 | 13 | 4 |
48+
| 4 | 16 | 34 | 11 |
49+
50+
Each bin corresponds to a distinct servable set. The bins are ordered so that
51+
upward search is almost always correct — the skip mask handles the exceptions.
52+
53+
For any B, the structure is:
54+
- **Most requests need no skips.** Only size classes with alignment higher
55+
than expected for their position in the sequence need to mask anything.
56+
- **The skip mask is a small constant** per size class, precomputable at
57+
compile time.
58+
- **The mechanism is identical** regardless of B:
59+
`find_first_set(bitmap & ~skip_mask, start_bit)`.
60+
61+
`prototype/skip_analysis.py` verifies this exhaustively for B = 1, 2, 3.
62+
63+
## The bitmap design
64+
65+
Each free block gets one bin based on its size and alignment. Within each
66+
exponent, there are as many bins as there are distinct servable sets (5 for
67+
B=2, 13 for B=3). A flat bitmap tracks which bins are non-empty.
68+
69+
To allocate size class `(e, m)`:
70+
71+
1. Compute the **start bit** — the first bin that could serve this size class.
72+
2. Compute the **skip mask** — bits for bins that can't serve this request.
73+
3. `find_first_set(bitmap & ~skip_mask, start_bit)` → pop a block from that
74+
bin.
75+
76+
The returned block may not be exactly aligned for the requested size class.
77+
The caller **carves** the aligned region and returns any prefix/suffix
78+
remainders to the free pool.
79+
80+
## Contrast with buddy allocators
81+
82+
A buddy allocator guarantees alignment by construction — a 16-chunk buddy is
83+
always 16-aligned — but wastes space by decomposing everything into
84+
power-of-two pieces.
85+
86+
This design stores blocks at their actual size (no decomposition, no waste)
87+
and handles alignment at allocation time by carving. The skip mask makes
88+
lookup O(1) despite blocks having arbitrary size and alignment.
89+
90+
## Concrete example (B = 2)
91+
92+
At exponent `e = 2`, the size classes are 4, 5, 6, 7. There are 5 bins,
93+
each labeled by the set of size classes it can serve at this exponent:
94+
95+
Bin 0: serves {4}
96+
Bin 1: serves {5}
97+
Bin 2: serves {4, 5}
98+
Bin 3: serves {4, 5, 6}
99+
Bin 4: serves {4, 5, 6, 7}
100+
101+
Allocation searches upward from the smallest sufficient bin:
102+
103+
Request for 7: can use bin 4 → search bits {4}
104+
Request for 6: can use bins 3, 4 → search bits {3, 4}
105+
Request for 5: can use bins 1, 2, 3, 4 → search bits {1, 2, 3, 4}
106+
Request for 4: can use bins 0, 2, 3, 4 — skip 1 → search bits {0, 2, 3, 4}
107+
108+
Only the request for size 4 needs to skip a bin: bin 1 holds blocks that can
109+
serve 5 but not 4. The skip mask is just bit 1.
110+
111+
## Concrete example (B = 3)
112+
113+
At exponent `e = 4`, the size classes are 16, 18, 20, 22, 24, 26, 28, 30.
114+
There are 13 bins. The skip analysis shows:
115+
116+
Request for 16 (align 16): must skip bins for {18}, {20}, {22}, {26}
117+
Request for 24 (align 8): must skip bin for {26}
118+
All other requests: no skips needed
119+
120+
The pattern: size 16 has high alignment and must skip 4 bins whose blocks
121+
are large enough but too poorly aligned. Size 24 is a "sub-power-of-two"
122+
(alignment 8) and must skip 1 bin. All odd-coefficient sizes have low
123+
alignment and never need to skip anything.
124+
125+
Same mechanism, wider mask, same `find_first_set(bitmap & ~mask)` operation.

src/snmalloc/backend/backend.h

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,27 @@ namespace snmalloc
2323
using Pal = PAL;
2424
using SlabMetadata = typename PagemapEntry::SlabMetadata;
2525

26-
public:
26+
/**
27+
* Round a metadata allocation size to a value the meta range can
28+
* service.
29+
*
30+
* - Pads to `LocalState::MIN_META_ALIGN` so that the in-band small
31+
* meta range (`SmallArenaRange`) accepts it.
32+
* - If the result reaches `MIN_CHUNK_SIZE`, the request will bypass
33+
* the small range to the parent `LargeArenaRange`, which requires
34+
* `MIN_CHUNK_SIZE` alignment; step up to satisfy that.
35+
*
36+
* Alloc and dealloc sites MUST share this helper so a chunk's
37+
* metadata is freed at the same size it was allocated.
38+
*/
39+
SNMALLOC_FAST_PATH static size_t meta_size_round(size_t size)
40+
{
41+
size_t r = bits::align_up(size, LocalState::MIN_META_ALIGN);
42+
if (r >= MIN_CHUNK_SIZE)
43+
r = bits::align_up(r, MIN_CHUNK_SIZE);
44+
return r;
45+
}
46+
2747
/**
2848
* Provide a block of meta-data with size and align.
2949
*
@@ -47,18 +67,15 @@ namespace snmalloc
4767
if (local_state != nullptr)
4868
{
4969
auto& meta_range = local_state->get_meta_range();
50-
using MetaRangeT = stl::remove_reference_t<decltype(meta_range)>;
51-
size_t alignment =
52-
bits::max(bits::next_pow2(size), MetaRangeT::UNIT_SIZE);
53-
p = meta_range.alloc_size_with_align(size, alignment);
70+
p = meta_range.alloc_range(meta_size_round(size));
5471
}
5572
else
5673
{
5774
static_assert(
5875
GlobalMetaRange::ConcurrencySafe,
5976
"Global meta data range needs to be concurrency safe.");
6077
GlobalMetaRange global_state;
61-
p = global_state.alloc_range(bits::next_pow2(size));
78+
p = global_state.alloc_range(meta_size_round(size));
6279
}
6380

6481
if (p == nullptr)
@@ -110,7 +127,7 @@ namespace snmalloc
110127
// Calculate the extra bytes required to store the client meta-data.
111128
size_t extra_bytes = SlabMetadata::get_extra_bytes(sizeclass);
112129

113-
auto meta_size = bits::next_pow2(sizeof(SlabMetadata) + extra_bytes);
130+
auto meta_size = meta_size_round(sizeof(SlabMetadata) + extra_bytes);
114131

115132
#ifdef SNMALLOC_TRACING
116133
message<1024>(
@@ -210,7 +227,7 @@ namespace snmalloc
210227
// Calculate the extra bytes required to store the client meta-data.
211228
size_t extra_bytes = SlabMetadata::get_extra_bytes(sizeclass);
212229

213-
auto meta_size = bits::next_pow2(sizeof(SlabMetadata) + extra_bytes);
230+
auto meta_size = meta_size_round(sizeof(SlabMetadata) + extra_bytes);
214231
local_state.get_meta_range().dealloc_range(
215232
capptr::Arena<void>::unsafe_from(&slab_metadata), meta_size);
216233

src/snmalloc/backend/meta_protected_range.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ namespace snmalloc
112112
MetaRange meta_range;
113113

114114
public:
115+
/// Granularity of the local meta range. Backend rounds metadata
116+
/// allocation sizes up to this; replaces pow2 rounding.
117+
static constexpr size_t MIN_META_ALIGN = MetaRange::UNIT_SIZE;
118+
static_assert(
119+
bits::is_pow2(MIN_META_ALIGN), "MIN_META_ALIGN must be a power of two");
120+
115121
using Stats = StatsCombiner<CentralObjectRange, CentralMetaRange>;
116122

117123
ObjectRange* get_object_range()

src/snmalloc/backend/standard_range.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ namespace snmalloc
6262
ObjectRange object_range;
6363

6464
public:
65+
/// Granularity of the local meta range. Backend rounds metadata
66+
/// allocation sizes up to this; replaces pow2 rounding.
67+
static constexpr size_t MIN_META_ALIGN = ObjectRange::UNIT_SIZE;
68+
static_assert(
69+
bits::is_pow2(MIN_META_ALIGN), "MIN_META_ALIGN must be a power of two");
70+
6571
// Expose a global range for the initial allocation of meta-data.
6672
using GlobalMetaRange = Pipe<ObjectRange, GlobalRange>;
6773

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Exercises the slab metadata allocation path with a ClientMetaDataProvider
3+
* whose per-slab extra_bytes is non-power-of-two.
4+
*
5+
* The backend rounds slab metadata sizes to `MIN_META_ALIGN` (= the meta
6+
* range's UNIT_SIZE) rather than the next power of two, so a non-pow2
7+
* client meta size actually occupies a non-pow2 slab metadata block.
8+
* This test gates the alloc/dealloc round-trip on that path: if
9+
* `meta_size_round` is wrong, an inconsistent alloc/dealloc size would
10+
* either trip an assertion in the meta range or leak.
11+
*/
12+
13+
#include "test/setup.h"
14+
15+
#include <iostream>
16+
#include <snmalloc/backend/globalconfig.h>
17+
#include <snmalloc/snmalloc_core.h>
18+
#include <vector>
19+
20+
namespace snmalloc
21+
{
22+
/**
23+
* Per-slab client meta: `max_count + 7` bytes of storage. With
24+
* `StorageType = uint8_t`, the resulting extra_bytes
25+
* (= (required_count - 1) * 1) is non-power-of-two for typical
26+
* sizeclass slab object counts.
27+
*/
28+
struct NonPow2ClientMetaDataProvider
29+
{
30+
using StorageType = uint8_t;
31+
using DataRef = uint8_t&;
32+
33+
static size_t required_count(size_t max_count)
34+
{
35+
return max_count + 7;
36+
}
37+
38+
static DataRef get(StorageType* base, size_t index)
39+
{
40+
return base[index];
41+
}
42+
};
43+
44+
using Config = snmalloc::StandardConfigClientMeta<NonPow2ClientMetaDataProvider>;
45+
} // namespace snmalloc
46+
47+
#define SNMALLOC_PROVIDE_OWN_CONFIG
48+
#include <snmalloc/snmalloc.h>
49+
50+
int main()
51+
{
52+
#if defined(SNMALLOC_ENABLE_GWP_ASAN_INTEGRATION)
53+
// This test does not make sense in GWP-ASan mode.
54+
return 0;
55+
#else
56+
// Spread allocations across several small sizeclasses to force a
57+
// variety of slab metadata sizes; each combination of (slab object
58+
// count, +7 bytes) produces a different non-pow2 extra_bytes.
59+
constexpr size_t sizes[] = {16, 48, 96, 192, 512, 1024};
60+
std::vector<std::pair<void*, uint8_t>> ptrs;
61+
62+
for (size_t round = 0; round < 5; round++)
63+
{
64+
for (size_t s : sizes)
65+
{
66+
for (size_t i = 0; i < 200; i++)
67+
{
68+
auto p = snmalloc::libc::malloc(s);
69+
auto& meta = snmalloc::get_client_meta_data(p);
70+
uint8_t tag = static_cast<uint8_t>((round * 31 + s + i) & 0xff);
71+
meta = tag;
72+
memset(p, tag, s);
73+
ptrs.emplace_back(p, tag);
74+
}
75+
}
76+
}
77+
78+
for (auto [p, tag] : ptrs)
79+
{
80+
auto& meta = snmalloc::get_client_meta_data(p);
81+
if (meta != tag)
82+
{
83+
std::cout << "Meta mismatch: expected " << int(tag) << " got "
84+
<< int(meta) << std::endl;
85+
abort();
86+
}
87+
snmalloc::libc::free(p);
88+
}
89+
90+
return 0;
91+
#endif
92+
}

0 commit comments

Comments
 (0)