Skip to main content

pagesift_sys/
alloc.rs

1//! 小块内存的分配缓存(全局分配器 [`SmallCache`])。
2//!
3//! 剖析(callgrind)里 PDF 转换约一成、xlsx 约 6% 的指令在 glibc 的 malloc/free 里:几十万个
4//! 几十字节的小对象一个个分配、释放。192 vCPU 双路机器上真实网页的正文抽取过了 32 线程反而
5//! 变慢,工作集是 DOM 和字符串分配,也是卡在分配器上(`docs/history/0.70_p4_step4_2_e1.md`)。
6//! 这里按"顺应计算机"的办法接管 512 字节以内、对齐不超过 16 字节的分配:
7//!
8//! - 16 档大小(16、32 … 512 字节),尺寸到档位查表,没有分支;
9//! - 每个线程每档一条空闲链表(空闲块的头 8 字节存下一块),后进先出:刚释放的块马上被复用,
10//!   它还在 CPU 缓存里;分配、释放都不加锁、不碰原子变量;
11//! - 链表空了从系统要一块 64 KB 的连续内存,按需从前往后一块块切(同一档的对象挨在一起,
12//!   顺着地址走);没切到的部分不碰,不占缓存也不占物理页;
13//! - 线程缓存和全局池之间整批搬(一批 32 块串成一条链),全局池里存的是"批"的链表,
14//!   锁里只摘、挂一个指针,不在锁里走链表;
15//! - 全局池按 NUMA 节点分片:线程只和自己所在节点的池打交道。192 vCPU 那轮的教训是跨节点
16//!   共享的东西会让缓存行在两个 Socket 之间来回搬,局部性优先(`0.70_p1_cpu_scaling_usl.md`);
17//!   分片按节点而不按 CPU,线程在节点内怎么迁移都不影响(`0.70_p2_step2_numa_executor.md`
18//!   证伪了逐逻辑 CPU 绑定)。块在哪个节点释放就留在哪个节点:同一轮还证实远端首次分配不是
19//!   主因,不值得为每次释放查块的归属;
20//! - 每个线程缓存的总量不超过它分到的那份 L2(L2 大小 ÷ 共用它的逻辑 CPU 数):超出的块
21//!   反正已经不在这个核的缓存里,留在线程里没有好处,还会在 192 个线程时把固定开销堆大
22//!   (`0.70_p4_memory_envelope.md`)。每档的上限从一批起步,向全局池要一次涨一批:只在
23//!   线程真的反复要这一档时才多留;
24//! - 线程退出时缓存整体还给全局池。
25//!
26//! 更大的、对齐要求更高的照旧交给系统分配器(大块还受 [`crate::tune_allocator`] 的 mmap 阈值
27//! 管着,用完马上还给系统)。切出来的 64 KB 块不还给系统:小对象的峰值用量通常不大,留着
28//! 反复用。
29
30#![allow(unsafe_code)]
31
32use std::alloc::{GlobalAlloc, Layout, System};
33use std::cell::{Cell, UnsafeCell};
34use std::ptr;
35use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
36
37/// 各档的块大小。都是 16 的倍数:从 64 字节对齐的大块切出来,每块都 16 字节对齐。
38const SIZES: [usize; 16] = [
39    16, 32, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 448, 512,
40];
41const CLASSES: usize = SIZES.len();
42const MAX_SMALL: usize = 512;
43const MAX_ALIGN: usize = 16;
44/// 向系统要的大块。
45const CHUNK: usize = 64 * 1024;
46/// 线程缓存和全局池之间一次搬多少块。
47const BATCH: usize = 32;
48/// 全局池的分片数,节点号对它取模。只是容量:单节点机器只用第 0 片,没碰过的分片不占物理内存。
49const SHARDS: usize = 64;
50/// CPU 号到分片的表能登记的 CPU 数,更大的 CPU 号归第 0 片。
51const MAX_CPUS: usize = 4096;
52
53/// `(size + 15) / 16` → 档位。
54const CLASS_OF: [u8; MAX_SMALL / 16 + 1] = {
55    let mut t = [0u8; MAX_SMALL / 16 + 1];
56    let mut i = 0;
57    while i < t.len() {
58        let size = if i == 0 { 1 } else { i * 16 };
59        let mut c = 0;
60        while SIZES[c] < size {
61            c += 1;
62        }
63        t[i] = c as u8;
64        i += 1;
65    }
66    t
67};
68
69/// 这次分配归不归缓存管,归的话是哪一档。
70#[inline]
71fn class(layout: Layout) -> Option<usize> {
72    (layout.size() <= MAX_SMALL && layout.align() <= MAX_ALIGN)
73        .then(|| usize::from(CLASS_OF[layout.size().div_ceil(16)]))
74}
75
76/// 空闲块:头 8 字节存链上的下一块;一批的第一块再用接下来 8 字节存池里的下一批
77/// (最小的块 16 字节,放得下)。
78struct Node {
79    next: *mut Node,
80    next_batch: *mut Node,
81}
82
83/// 全局池一个分片的一档:自旋锁保护的"批"链表。
84///
85/// 每个池独占 128 字节:x86 的相邻行预取一次拉两条 64 字节缓存行,各档的锁要是挨着,
86/// 一个线程拿 16 字节档的锁会把隔壁 32 字节档的锁所在的行也抢走。
87#[repr(align(128))]
88struct Pool {
89    locked: AtomicBool,
90    batches: UnsafeCell<*mut Node>,
91}
92
93// SAFETY: batches 只在持有 locked 时读写。
94unsafe impl Sync for Pool {}
95
96impl Pool {
97    const fn new() -> Self {
98        Pool {
99            locked: AtomicBool::new(false),
100            batches: UnsafeCell::new(ptr::null_mut()),
101        }
102    }
103
104    fn with<T>(&self, f: impl FnOnce(&mut *mut Node) -> T) -> T {
105        let mut spins = 0u32;
106        // 先只读着等,锁空了再抢:等的线程不会反复把缓存行抢成独占
107        while self
108            .locked
109            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
110            .is_err()
111        {
112            while self.locked.load(Ordering::Relaxed) {
113                spins += 1;
114                if spins < 64 {
115                    std::hint::spin_loop();
116                } else {
117                    std::thread::yield_now();
118                }
119            }
120        }
121        // SAFETY: 拿到了锁,独占 batches。
122        let r = f(unsafe { &mut *self.batches.get() });
123        self.locked.store(false, Ordering::Release);
124        r
125    }
126
127    /// 挂上一条以 null 结尾的链(一批)。
128    unsafe fn push(&self, first: *mut Node) {
129        self.with(|batches| {
130            // SAFETY: first 是调用方独占的空闲块。
131            unsafe { (*first).next_batch = *batches };
132            *batches = first;
133        });
134    }
135
136    /// 摘下一批,池空了返回 null。
137    unsafe fn pop(&self) -> *mut Node {
138        self.with(|batches| {
139            let first = *batches;
140            if !first.is_null() {
141                // SAFETY: 池里每批的第一块存着下一批。
142                *batches = unsafe { (*first).next_batch };
143            }
144            first
145        })
146    }
147}
148
149static POOLS: [[Pool; CLASSES]; SHARDS] = [const { [const { Pool::new() }; CLASSES] }; SHARDS];
150
151/// 有不止一个节点时为 true;否则不必查当前 CPU。
152static MULTI_NODE: AtomicBool = AtomicBool::new(false);
153/// CPU 号 → 分片。
154static CPU_SHARD: [AtomicU8; MAX_CPUS] = [const { AtomicU8::new(0) }; MAX_CPUS];
155/// 每个线程最多缓存多少字节;没探测到 L2 时每档一个大块。
156static BUDGET: AtomicUsize = AtomicUsize::new(CLASSES * CHUNK);
157
158/// 当前线程所在节点的分片。只在和全局池打交道时调用(每几十次分配一次)。
159fn shard() -> &'static [Pool; CLASSES] {
160    &POOLS[current_shard()]
161}
162
163fn current_shard() -> usize {
164    if !MULTI_NODE.load(Ordering::Relaxed) {
165        return 0;
166    }
167    #[cfg(all(target_os = "linux", target_env = "gnu"))]
168    {
169        // SAFETY: 无参数;glibc 走 rseq/vDSO,不进内核。
170        let cpu = unsafe { libc::sched_getcpu() };
171        if let Ok(cpu) = usize::try_from(cpu)
172            && cpu < MAX_CPUS
173        {
174            return usize::from(CPU_SHARD[cpu].load(Ordering::Relaxed));
175        }
176    }
177    0
178}
179
180/// 登记 CPU 拓扑:`cpu_node` 是 (CPU 号, NUMA 节点号),`l2_share` 是一个逻辑 CPU 分到的 L2 字节数。
181pub(crate) fn configure(cpu_node: &[(usize, usize)], l2_share: Option<usize>) {
182    let mut nodes = 0u64;
183    for &(cpu, node) in cpu_node {
184        let s = node % SHARDS;
185        nodes |= 1 << s;
186        if cpu < MAX_CPUS {
187            CPU_SHARD[cpu].store(s as u8, Ordering::Relaxed);
188        }
189    }
190    MULTI_NODE.store(nodes.count_ones() > 1, Ordering::Relaxed);
191    if let Some(b) = l2_share.filter(|&b| b > 0) {
192        BUDGET.store(b, Ordering::Relaxed);
193    }
194}
195
196/// 测试用:假装每个 CPU 是一个节点(线程在 CPU 间迁移时就会碰到不同的分片),并设线程缓存上限。
197#[doc(hidden)]
198pub fn small_cache_simulate_numa(budget: usize) {
199    let cpus: Vec<(usize, usize)> = (0..MAX_CPUS).map(|c| (c, c)).collect();
200    configure(&cpus, Some(budget));
201}
202
203/// 一个线程的缓存:每档一条空闲链表、长度和上限,正在切的大块(下一块的位置、剩下几块),
204/// 以及链表里的总字节数。
205struct Cache {
206    head: [Cell<*mut Node>; CLASSES],
207    len: [Cell<usize>; CLASSES],
208    max: [Cell<usize>; CLASSES],
209    bump: [Cell<*mut u8>; CLASSES],
210    left: [Cell<usize>; CLASSES],
211    bytes: Cell<usize>,
212}
213
214thread_local! {
215    static CACHE: Cache = const {
216        Cache {
217            head: [const { Cell::new(ptr::null_mut()) }; CLASSES],
218            len: [const { Cell::new(0) }; CLASSES],
219            max: [const { Cell::new(BATCH) }; CLASSES],
220            bump: [const { Cell::new(ptr::null_mut()) }; CLASSES],
221            left: [const { Cell::new(0) }; CLASSES],
222            bytes: Cell::new(0),
223        }
224    };
225}
226
227impl Cache {
228    #[inline]
229    unsafe fn alloc(&self, c: usize) -> *mut u8 {
230        let head = self.head[c].get();
231        if !head.is_null() {
232            // SAFETY: 链表里的块都是本缓存独占的空闲块,头 8 字节是 next。
233            self.head[c].set(unsafe { (*head).next });
234            self.len[c].set(self.len[c].get() - 1);
235            self.bytes.set(self.bytes.get() - SIZES[c]);
236            return head.cast();
237        }
238        unsafe { self.refill(c) }
239    }
240
241    /// 链表空了:先接着切手上的大块,切完了从本节点的全局池取一批,再没有才向系统要新的大块。
242    #[cold]
243    unsafe fn refill(&self, c: usize) -> *mut u8 {
244        let size = SIZES[c];
245        let left = self.left[c].get();
246        if left > 0 {
247            let p = self.bump[c].get();
248            self.left[c].set(left - 1);
249            // SAFETY: 还剩 left 块,下一块在大块之内。
250            self.bump[c].set(unsafe { p.add(size) });
251            return p;
252        }
253        // 反复来要说明这个线程确实常用这一档,多留一批
254        self.max[c].set(self.max[c].get().saturating_add(BATCH));
255        let first = unsafe { shard()[c].pop() };
256        if !first.is_null() {
257            // SAFETY: 摘下来的是一条以 null 结尾的链,归本缓存所有;块是本线程马上要用的,
258            // 在锁外数一遍顺便把它们拉进缓存。
259            let rest = unsafe { (*first).next };
260            let mut n = 0;
261            let mut p = rest;
262            while !p.is_null() {
263                n += 1;
264                p = unsafe { (*p).next };
265            }
266            self.head[c].set(rest);
267            self.len[c].set(n);
268            self.bytes.set(self.bytes.get() + n * size);
269            return first.cast();
270        }
271        // SAFETY: CHUNK、64 都是合法的大小和对齐。
272        let chunk = unsafe { System.alloc(Layout::from_size_align_unchecked(CHUNK, 64)) };
273        if chunk.is_null() {
274            return ptr::null_mut();
275        }
276        // 切块的是本线程,按 Linux 的首次触碰,这块内存落在本线程所在的节点上。
277        // SAFETY: 第二块还在大块之内。
278        self.bump[c].set(unsafe { chunk.add(size) });
279        self.left[c].set(CHUNK / size - 1);
280        chunk
281    }
282
283    #[inline]
284    unsafe fn free(&self, c: usize, p: *mut u8) {
285        let (len, bytes) = unsafe { self.free_quiet(c, p) };
286        if len > self.max[c].get() || bytes > BUDGET.load(Ordering::Relaxed) {
287            unsafe { self.spill(c) };
288        }
289    }
290
291    /// 挂回链表,不看上限;返回这一档的长度和总字节数。
292    #[inline]
293    unsafe fn free_quiet(&self, c: usize, p: *mut u8) -> (usize, usize) {
294        let node = p.cast::<Node>();
295        // SAFETY: p 是本分配器分出去、调用方已经不再使用的一块,至少 16 字节。
296        unsafe { (&raw mut (*node).next).write(self.head[c].get()) };
297        self.head[c].set(node);
298        let len = self.len[c].get() + 1;
299        self.len[c].set(len);
300        let bytes = self.bytes.get() + SIZES[c];
301        self.bytes.set(bytes);
302        (len, bytes)
303    }
304
305    /// 这一档留得比线程用得多,或者整个线程缓存超出了它那份 L2:挪一批到全局池;
306    /// 超出 L2 的话每档都挪走一半。
307    #[cold]
308    unsafe fn spill(&self, c: usize) {
309        let pools = shard();
310        if self.len[c].get() > self.max[c].get() {
311            unsafe { self.release(pools, c, BATCH) };
312        }
313        if self.bytes.get() > BUDGET.load(Ordering::Relaxed) {
314            for c in 0..CLASSES {
315                let n = self.len[c].get().div_ceil(2);
316                unsafe { self.release(pools, c, n) };
317            }
318        }
319    }
320
321    /// 从第 c 档摘下 n 块,按批挂到全局池。
322    unsafe fn release(&self, pools: &[Pool; CLASSES], c: usize, mut n: usize) {
323        while n > 0 {
324            let mut head = self.head[c].get();
325            let (first, k) = unsafe { take(&mut head, n.min(BATCH)) };
326            if k == 0 {
327                return;
328            }
329            self.head[c].set(head);
330            self.len[c].set(self.len[c].get() - k);
331            self.bytes.set(self.bytes.get() - k * SIZES[c]);
332            unsafe { pools[c].push(first) };
333            n -= k;
334        }
335    }
336}
337
338impl Drop for Cache {
339    /// 线程退出:缓存整体还给所在节点的全局池。
340    fn drop(&mut self) {
341        let pools = shard();
342        for (c, &size) in SIZES.iter().enumerate() {
343            // 没切完的大块也串成链交出去
344            for _ in 0..self.left[c].replace(0) {
345                let p = self.bump[c].get();
346                // SAFETY: 大块里还没分出去的一块。
347                self.bump[c].set(unsafe { p.add(size) });
348                unsafe { self.free_quiet(c, p) };
349            }
350            let n = self.len[c].get();
351            unsafe { self.release(pools, c, n) };
352        }
353    }
354}
355
356/// 从链表 `head` 头上摘下最多 `n` 块:返回摘下的那条链(以 null 结尾)和块数。
357/// 只在锁外、对线程自己的链表用。
358unsafe fn take(head: &mut *mut Node, n: usize) -> (*mut Node, usize) {
359    let first = *head;
360    if first.is_null() {
361        return (first, 0);
362    }
363    let mut last = first;
364    let mut count = 1;
365    // SAFETY: 链上的块都是空闲块,头 8 字节是 next。
366    while count < n && !unsafe { (*last).next }.is_null() {
367        last = unsafe { (*last).next };
368        count += 1;
369    }
370    *head = unsafe { (*last).next };
371    unsafe { (*last).next = ptr::null_mut() };
372    (first, count)
373}
374
375/// 线程缓存已经销毁(线程退出途中)时直接走全局池:摘一批,留一块,其余挂回去。
376unsafe fn global_alloc(c: usize) -> *mut u8 {
377    let pools = shard();
378    let first = unsafe { pools[c].pop() };
379    if !first.is_null() {
380        // SAFETY: first 是独占的一批。
381        let rest = unsafe { (*first).next };
382        if !rest.is_null() {
383            unsafe { pools[c].push(rest) };
384        }
385        return first.cast();
386    }
387    // SAFETY: SIZES[c] 是 16 的倍数,对齐 16 合法。
388    unsafe { System.alloc(Layout::from_size_align_unchecked(SIZES[c], MAX_ALIGN)) }
389}
390
391/// 小块分配缓存。只应该由 pagesift 自己的可执行程序用作全局分配器(`#[global_allocator]`);
392/// Python、Node 绑定跑在别人的进程里,不替宿主换分配器。
393pub struct SmallCache;
394
395// SAFETY: 小块从 64 字节对齐的大块里按 16 的倍数切出,满足 ≤ 16 的对齐;同一块在被释放之前
396// 不会再分出去(只在空闲链表里);其余请求原样交给 System。
397unsafe impl GlobalAlloc for SmallCache {
398    #[inline]
399    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
400        match class(layout) {
401            Some(c) => CACHE
402                .try_with(|k| unsafe { k.alloc(c) })
403                .unwrap_or_else(|_| unsafe { global_alloc(c) }),
404            None => unsafe { System.alloc(layout) },
405        }
406    }
407
408    #[inline]
409    unsafe fn dealloc(&self, p: *mut u8, layout: Layout) {
410        match class(layout) {
411            Some(c) => {
412                if CACHE.try_with(|k| unsafe { k.free(c, p) }).is_err() {
413                    let node = p.cast::<Node>();
414                    // SAFETY: 调用方不再使用 p;单块成一批。
415                    unsafe {
416                        (&raw mut (*node).next).write(ptr::null_mut());
417                        shard()[c].push(node);
418                    }
419                }
420            }
421            None => unsafe { System.dealloc(p, layout) },
422        }
423    }
424
425    #[inline]
426    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
427        if class(layout).is_none() {
428            return unsafe { System.alloc_zeroed(layout) };
429        }
430        let p = unsafe { self.alloc(layout) };
431        if !p.is_null() {
432            unsafe { ptr::write_bytes(p, 0, layout.size()) };
433        }
434        p
435    }
436
437    #[inline]
438    unsafe fn realloc(&self, p: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
439        // SAFETY: 调用方保证 new_size 按 layout 的对齐取整后不溢出。
440        let new = unsafe { Layout::from_size_align_unchecked(new_size, layout.align()) };
441        match (class(layout), class(new)) {
442            // 同一档:块本来就够大
443            (Some(a), Some(b)) if a == b => p,
444            // 都是大块:让系统原地扩
445            (None, None) => unsafe { System.realloc(p, layout, new_size) },
446            _ => {
447                let q = unsafe { self.alloc(new) };
448                if !q.is_null() {
449                    unsafe {
450                        ptr::copy_nonoverlapping(p, q, layout.size().min(new_size));
451                        self.dealloc(p, layout);
452                    }
453                }
454                q
455            }
456        }
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn every_small_size_maps_to_the_smallest_class_that_fits() {
466        for size in 1..=MAX_SMALL {
467            let c = class(Layout::from_size_align(size, 8).unwrap()).unwrap();
468            assert!(SIZES[c] >= size, "{size}");
469            assert!(c == 0 || SIZES[c - 1] < size, "{size}");
470        }
471        assert_eq!(class(Layout::from_size_align(0, 1).unwrap()), Some(0));
472        assert_eq!(class(Layout::from_size_align(513, 8).unwrap()), None);
473        assert_eq!(class(Layout::from_size_align(64, 32).unwrap()), None);
474    }
475
476    #[test]
477    fn a_node_fits_in_the_smallest_block_and_pools_do_not_share_lines() {
478        assert!(std::mem::size_of::<Node>() <= SIZES[0]);
479        assert_eq!(std::mem::align_of::<Pool>(), 128);
480    }
481}