summaryrefslogtreecommitdiff
path: root/doc/0/A-meta_alloc.md
blob: 45d851a692bd36e18b9a4b70ae83eb60d8394ad8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# Meta Allocator

<!--TOC-->

The Zisp runtime aims to offer peak performance.  Although Zisp also
aims to offer a capability to generate optimal binaries with native
compiled code, performing its own memory management, with no runtime
dependencies, it is expected that many programs will simply opt for
interpreted code, or at least automatic memory management and other
runtime features like dynamically resizing collection objects.

If we manage to offer a runtime whose performance is already within
reasonable proximity to fine-tuned C code, then the number of times
programmers find themselves reaching for more complex code, such as
manual memory management, can be minimized.

To this end, Zisp entirely foregoes general-purpose heap allocators
offered by the platforms it runs on, or the standard library of the
language it's implemented in, and instead implements its own memory
allocator based directly on `mmap()` and equivalents.

This is called the Meta Allocator, because various parts of the Zisp
runtime are expected to implement their own local memory management
strategies, such as arena allocators or object pools, while still
relying on Meta Alloc so that they don't need to individually deal
with expensive `mmap()` calls.

This is not directly related to garbage collection, though a GC may
use Meta Alloc to manage auxiliary metadata, or even for the actual
heap memory returned to application code; it's up to the GC.

## Overview

The entire architecture is built on three pillars:

1. The actual heap memory acquired through `mmap()` which is a single
   contiguous block divided into N equally sized slabs; one per size
   class.

2. A global array of N metadata structures residing in static memory.

3. A per-thread array of N metadata structures in TLS memory.

Note that the slab array is a *virtual memory* block.  The operating
system automatically maps physical memory to it as application code
writes data into it.  Allocating one massive block of virtual memory
in advance and letting the operating system automatically handle the
actual mapping to physical memory provides an immense simplification
of the allocator implementation.

## Slabs and size classes

A single global pointer in static memory is initialized to point to
the massive block of slab virtual memory.

This initialization happens by calling the `init()` function once.
This must be done before any additional threads are spawned that
intend to interact with Meta Alloc.

The vmem block is divided in `N_SLABS` equal slabs, each divided into
`SLAB_LENGTH` many equal `Unit`s of memory, which are addressed via
*indexes* into slabs.  Expressed in pseudo-code:

    var slabs: *[N_SLABS][SLAB_LENGTH]Unit = undefined;

    export fn init() void {
      slabs = mmap(N_SLABS * SLAB_LENGTH * sizeof(Unit));
    }

The division of slabs into `Unit`s is simply to allow expressing the
address of an allocation through an index value, such as one fitting
into 32 bits, rather than a full pointer value.

Every slab is for a specific *size class*.  Since the slabs are equal
sized, this means that for larger size classes there is a lower bound
of the maximum number of allocations that can be made of that class.
The actual blocks of memory returned are called *slots* which consist
of a multiple of `Unit`s depending on the size class.

For example, if the unit size is 8 bytes, then a slot of the 512-byte
size class covers 64 units.  Meaning: The slab for the size class 512
allows up to `SLAB_LEN / 64` calls to `alloc()` before panicking, if
no slot of this size class is ever freed.

Current implementation details:

* `N_SLABS`: 16

* `SLAB_LENGTH`: 2^31

* `Unit` size: 8 bytes

Therefore:

* Size of each slab: `2^31 * 8 = 16 GiB`

* Total slab virtual memory: `16 GiB * 16 = 256 GiB`

If you notice, after launching the Zisp REPL or a program using the
Zisp runtime, that the operating system reports that the process is
using hundreds of gigabytes of virtual memory, do not panic; it's a
feature, not a bug, and completely benign.

As for the size classes: They simply go from 256 bytes to 8 MiB in
perfect powers of two; in other words, from 2^8 to 2^23.  There are
certain benefits to using exact powers of two, and 256 bytes to 8M
seems like a reasonable range.  Code using Meta Alloc must be aware
that only these size classes are supported, and try to make optimal
use of them.

**WARNING:** Calling `alloc()` with a size smaller than 256 or one
that is not a perfect power of two will lead to catastrophic bugs
under optimized builds of Zisp.

Generally, Zisp application code will have no direct access to this
function, so this should not be a big deal so long as Zisp's runtime
implementation is thoroughly tested.

If a size greater than the largest size class is requested, then Meta
Alloc simply forwards this to an `mmap()` call, acting as a mere thin
wrapper around mmap, calling `munmap()` on `free()`.

## Global metadata

The static array `slab_infos` holds `N_SLABS` many structures which
record just two pieces of information associated with each slab:

* The current *watermark* of the slab.

* The head of the global, shared *free-list* for this slab.

Expressed in pseudo-code:

    struct SlabInfo {
      watermark: Integer,
      free_list: ListHead,
    }

    var slab_infos: [N_SLABS]SlabInfo;

The `ListHead` need not be a direct pointer nor plain slot index; it
may be a composite value including an ABA counter to help against the
ABA problem:

* [https://en.wikipedia.org/wiki/ABA_problem](ABA problem)

In other words, it may be implemented as a Treiber stack:

* [https://en.wikipedia.org/wiki/Treiber_stack](Treiber stack)

The initial value of the `free_list` head is of course some sort of
null indicator so we can know it's empty.

Detailed explanations of the watermark and free-list follow.

### Slab watermark

The watermark represents the point above which, within the slab, the
memory has not yet been touched at all, or has been explicitly given
back to the operating with a call to `madvise()` or similar so the
physically backing memory can be released.

Below the watermark is memory which is either currently in use by the
application, or has been marked for reuse by a `free()` call but is
still backed by physical memory.  In some circumstances, Meta Alloc
may use `madvise()` to tell the operating system that some regions
below the watermark can actually be reclaimed, because they are not
currently needed; in other circumstances, otherwise unused (freed)
memory below the watermark may actually hold metadata used by Meta
Alloc itself; this is where the free-list comes into play.

### Free-lists

Given that the allocator cannot trust the user to always free memory
in reverse order to which it was acquired, it cannot simply decrease
the watermark when `free()` is called.  It has to record that memory
slot for reuse somehow, even if it's deep below the watermark.

This introduces a little chicken-and-egg problem: Since we are the
allocator, who allocates the dynamic memory required to record the
pointers to these freed slots, of which there could be plenty?

Thankfully, a very elegant solution exists: Use the memory of the
freed slots themselves to form a linked list of free slots; where
within each freed slot, we store a pointer to the next.

Meta Alloc implements an improvement over this common strategy:

Given that even the smallest size class is fairly large, and given
that we can use small index values, rather than full pointers, to
represent addresses of slots, each node in the free-list actually
contains the following structure:

    {
      next_head_idx:   Index,
      extra_idx_count: Count,
      pad_to_64_bytes: Padding,
      extra_idx_array: [MaxCount]Index,
    }

The bit-size of indexes, the maximum allowed count, and the smallest
size class, must all be defined such that this works.  The current
values used by the implementation are as follows:

* Index: 32-bit integer

* Count: 32-bit integer

* Padding: 56 bytes

* Maximum count: 32 indexes

* Smallest size class: 256 bytes

The extra index array starts after 64 bytes, and requires 128 bytes,
since it stores up to 32 4-byte integers; that gives us a total size
of 192, which fits in under 256 bytes.

The 64-byte padding is to allow for efficient bulk memory transfer
using up to 512-bit SIMD instructions on modern processors.

Why we would need to bulk-transfer 32 index values will be explained
later, as we look into thread-local cache metadata.

## Per-thread metadata

We don't want to burden code using Meta Alloc with concerns about
thread safety; `alloc()` and `free()` should be inherently safe.

If each call to these functions needed to touch the global watermark
or free-list of a size class, it could lead to contention.  As such,
threads use two tricks to decrease their need to access the global,
shared metadata:

* Bumping the slab's watermark in chunks to reserve a number of slots
  for the current thread every time the global watermark needs to be
  increased.

* Using a free slot cache of static size within TLS memory, which is
  emptied into the global free-list in chunks when full, and fed from
  the global free-list in chunks when empty.

Pseudo-code follows; detailed explanations are further below:

    struct ThreadInfo {
      watermark_low: Integer,
      watermark_high: Integer,
      free_cache_count: Integer,
      free_cache_array: [FC_MAX]Index,
    }

    thread_local tl_info: [N_SLABS]ThreadInfo;

### Reserved memory

The reservation of slots is done simply by keeping a thread-local low
and high watermark value: Low is the starting point of reserved but
not yet used memory, and high is the endpoint.  When low meets high,
we need to check the global watermark again (it may have been bumped
by another thread) to set our new low, and bump the global, to which
we set our new high.

### Free slot cache

When `free()` is called in a thread, it checks whether there's still
room in its local free slot cache.  If it's full, it instead moves an
entire chunk into the global free-list.  When `alloc()` is called, it
checks if there's slots in its local cache; if not, it checks if the
global free-list has anything, and transfers a chunk from there to
feed some entries into the local cache.

This emptying and freeing is done in halves.  If the entire cache was
emptied when full, or filled when empty, it could cause "thrashing"
when a thread is repeatedly alternating between `alloc` and `free`
calls while just at the boundary: The alloc call fills the whole
cache, the free call empties it again, and so on.

For this reason, the maximum size of the free slot cache per thread
can be twice as large as the maximum chunk size in the slab's global
free-list.

There is one more nuance to be aware of:

Consider a free slot cache of 64 entries.  And remember that slabs
have equal size, meaning larger size classes allow for fewer total
numbers of allocations.  For very large size classes, we don't want
every thread that ever called `alloc()` once to immediately hog 64
slots as a reserve.  For this reason, although the free slot cache
array has a static maximum size, larger size classes have a lower
maximum element count that they enforce.

### Thread destruction

When a thread that used Meta Alloc is going to exit, it must flush
whatever reserves it holds so as to prevent leaks.  This is done via
the function `flush_thread_reserves()` which is also safe to call at
any other point, though this is typically pointless.  One must only
ensure that it's called at least once by a thread, before it exits,
without any other subsequent calls to `alloc()` or `free()` before
exiting.

This immediately transfers the thread's free slot cache into the
global free-list, in multiple chunks if necessary, and also creates
free-list chunks for memory it had reserved by bumping the global
watermark.

## Releasing vmem

Though not currently implemented, Meta Alloc may be able to release
virtual memory back to the operating system via strategies explained
in the following Zisp Note:

* [Releasing virtual memory](../../notes/260817-release.html)

End.