mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-11 02:03:59 +08:00
Every class slice used to opt out of transparent huge pages, so each 4 KiB the bump pointers crossed cost a page fault. Those faults (fault entry, memcg charge, LRU insertion and the page clear) were 6-14% of tsc-ts samples in the kernel. Backing the whole reservation with huge pages removes most of them, but makes the 2 MiB page around every bump pointer resident at once. A worker executable has up to 32 class slices per script thread, so tRPC at 8 checkers grew from 558 to 955 MB. A slice now starts on small pages. Once it has carved 8 MiB, the rest of its range switches to huge pages (MADV_HUGEPAGE). Only slices that already hold 8 MiB pay for a partial last huge page. The reservation is huge-page aligned, so the switch point and every slice start on a 2 MiB boundary. The slow path raises a slice's bump limit in two steps (to the switch point, then to the slice end), so the inline fast paths are unchanged. The policy is Linux-only; other targets keep one limit per slice, as before. The allocator checks now build with _GNU_SOURCE on Linux like the runtime packs, so the madvise advice is compiled in and checked (alignment, contiguous carving across the switch, VmFlags nh/hg). Measured with tsc-ts best-v1 (parallel-check + 6247795 e24057a 64d9af6 1c42c55), built with this runtime vs origin/main, on two Linux Vercel Sandboxes (8 vCPU; 5-7 interleaved runs, medians). Columns: wall, max RSS, minor faults; "all" is MADV_HUGEPAGE on the whole reservation: single-threaded wall RSS faults all: wall / RSS Excalidraw -6.1% +0.8% 331k -> 112k -10.0% / +3.5% Playwright -1.8% +1.0% 371k -> 111k -8.4% / +3.2% TypeORM -7.2% +0.9% 259k -> 75k -11.8% / +4.1% tRPC -5.0% +2.0% 101k -> 47k -9.0% / +11.3% tsc-ts self-check -2.9% +2.6% 158k -> 64k -3.1% / +8.4% --checkers 8 Excalidraw -2.9% +2.6% 1075k -> 508k -6.3% / +10.6% Playwright -10.0% +2.6% 739k -> 381k -10.3% / +12.2% TypeORM -5.9% +2.2% 372k -> 232k -10.1% / +27.7% tRPC -2.5% +2.5% 159k -> 125k -4.3% / +71.2% tsc-ts self-check -4.0% +3.9% 213k -> 172k -12.1% / +46.3% Switching at 2 or 4 MiB was up to about 1.5 points faster single-threaded but cost up to +11% RSS at 8 checkers. 16 MiB gave back most of the gain. Prefaulting small pages with MADV_POPULATE_WRITE did not reduce sys time. Collapsing the small-page prefix with MADV_COLLAPSE at the switch did not help either. The runtime benchmark suite (--layouts=4 --runs=16) shows a geomean change of -1.2%: inventory-report -9.9%, validate-errors -5.3%, log-summary -2.9%, and regex-logs +2.1%. regex-logs peaks at 6.8 MB and never reaches the switch point; with one binary the policy measures -0.9% there, so its delta comes from code layout.
236 lines
7.5 KiB
C
236 lines
7.5 KiB
C
/* White-box checks for the small-object allocator (scr_alloc.c). Built by
|
|
* scr_alloc.test.ts without ASan (the size-class allocator is live), with
|
|
* ASan (every call must reach the system allocator), and as a worker
|
|
* executable's allocator (per-thread arenas, also under ThreadSanitizer). */
|
|
#include "scr_runtime.h"
|
|
#include <assert.h>
|
|
#include <stdio.h>
|
|
|
|
#if SCR_SMALL_ALLOC
|
|
static bool owned(const void *p) { return (uintptr_t)p - scr_sa.base < scr_sa.span; }
|
|
#endif
|
|
|
|
static void fill(unsigned char *p, size_t n, unsigned char seed) {
|
|
for (size_t i = 0; i < n; i++) p[i] = (unsigned char)(seed + i * 7);
|
|
}
|
|
|
|
static void verify(const unsigned char *p, size_t n, unsigned char seed) {
|
|
for (size_t i = 0; i < n; i++) assert(p[i] == (unsigned char)(seed + i * 7));
|
|
}
|
|
|
|
static void check_alignment_and_zeroing(void) {
|
|
for (size_t n = 1; n <= 600; n++) {
|
|
unsigned char *p = scr_mem_calloc(n);
|
|
assert(p && ((uintptr_t)p & 15) == 0);
|
|
for (size_t i = 0; i < n; i++) assert(p[i] == 0);
|
|
fill(p, n, (unsigned char)n);
|
|
scr_mem_free(p);
|
|
/* A recycled block (same class, LIFO) must come back zeroed. */
|
|
unsigned char *q = scr_mem_calloc(n);
|
|
assert(q && ((uintptr_t)q & 15) == 0);
|
|
for (size_t i = 0; i < n; i++) assert(q[i] == 0);
|
|
#if SCR_SMALL_ALLOC
|
|
if (n <= SCR_SA_MAX) assert(q == p && owned(q));
|
|
else assert(!owned(q));
|
|
#endif
|
|
scr_mem_free(q);
|
|
}
|
|
}
|
|
|
|
static void check_realloc(void) {
|
|
unsigned char *p = scr_mem_realloc(NULL, 10);
|
|
fill(p, 10, 3);
|
|
size_t sizes[] = {12, 16, 17, 100, 512, 513, 4000, 300, 40, 7};
|
|
size_t live = 10;
|
|
for (size_t i = 0; i < sizeof(sizes) / sizeof(sizes[0]); i++) {
|
|
size_t n = sizes[i];
|
|
p = scr_mem_realloc(p, n);
|
|
assert(p && ((uintptr_t)p & 15) == 0);
|
|
verify(p, live < n ? live : n, 3);
|
|
fill(p, n, 3);
|
|
live = n;
|
|
}
|
|
scr_mem_free(p);
|
|
}
|
|
|
|
static void check_system_pointers(void) {
|
|
/* scr_mem_free / scr_mem_realloc accept blocks the system allocated. */
|
|
void *a = malloc(32), *b = calloc(1, 4096), *c = malloc(24);
|
|
assert(a && b && c);
|
|
scr_mem_free(a);
|
|
b = scr_mem_realloc(b, 8192);
|
|
assert(b);
|
|
scr_mem_free(b);
|
|
c = scr_mem_realloc(c, 64);
|
|
assert(c);
|
|
scr_mem_free(c);
|
|
scr_mem_free(NULL);
|
|
scr_mem_free(scr_mem_alloc(0)); /* malloc(0) contract: NULL or freeable */
|
|
}
|
|
|
|
/* Interleaved alloc/free across every class: blocks never overlap and a
|
|
* live block's contents survive unrelated traffic. */
|
|
static void check_churn(void) {
|
|
enum { SLOTS = 4096 };
|
|
static unsigned char *slot[SLOTS];
|
|
static size_t len[SLOTS];
|
|
uint32_t x = 0x9e3779b9u;
|
|
for (unsigned round = 0; round < 400000; round++) {
|
|
x ^= x << 13;
|
|
x ^= x >> 17;
|
|
x ^= x << 5;
|
|
unsigned i = x % SLOTS;
|
|
if (slot[i]) {
|
|
verify(slot[i], len[i], (unsigned char)i);
|
|
scr_rt_free(slot[i]);
|
|
slot[i] = NULL;
|
|
} else {
|
|
len[i] = 1 + (x >> 12) % 700;
|
|
slot[i] = (x & 1) ? scr_rt_calloc(len[i]) : scr_mem_alloc(len[i]);
|
|
assert(slot[i]);
|
|
fill(slot[i], len[i], (unsigned char)i);
|
|
}
|
|
}
|
|
for (unsigned i = 0; i < SLOTS; i++) {
|
|
if (!slot[i]) continue;
|
|
verify(slot[i], len[i], (unsigned char)i);
|
|
scr_mem_free(slot[i]);
|
|
}
|
|
}
|
|
|
|
#if SCR_SMALL_ALLOC
|
|
#include <string.h>
|
|
#ifdef __linux__
|
|
/* The VmFlags line of the mapping that contains p, or "". */
|
|
static void vm_flags(const void *p, char *out, size_t cap) {
|
|
out[0] = '\0';
|
|
FILE *f = fopen("/proc/self/smaps", "r");
|
|
assert(f);
|
|
char line[512];
|
|
bool inside = false;
|
|
while (fgets(line, sizeof line, f)) {
|
|
unsigned long lo, hi;
|
|
if (sscanf(line, "%lx-%lx ", &lo, &hi) == 2 && strchr(line, '-') < strchr(line, ' ')) {
|
|
inside = (uintptr_t)p >= lo && (uintptr_t)p < hi;
|
|
} else if (inside && !strncmp(line, "VmFlags:", 8)) {
|
|
snprintf(out, cap, "%s", line + 8);
|
|
break;
|
|
}
|
|
}
|
|
fclose(f);
|
|
}
|
|
#endif
|
|
|
|
/* A class slice carves contiguously from its start, across the point where
|
|
* its limit is raised; on Linux the slices are huge-page aligned, the first
|
|
* 8 MiB of a slice stay on small pages and the rest may use huge pages. */
|
|
static void check_slice_growth(void) {
|
|
enum { SZ = 496, AFTER = 8 << 20 };
|
|
unsigned char *first = scr_mem_alloc(SZ), *prev = first;
|
|
assert(owned(first));
|
|
uintptr_t start = scr_sa.base + ((uintptr_t)((SZ - 1) / SCR_SA_STEP) << scr_sa.shift);
|
|
assert((uintptr_t)first == start); /* a class nothing else has touched */
|
|
size_t blocks = AFTER / SZ + 64;
|
|
for (size_t i = 1; i < blocks; i++) {
|
|
unsigned char *p = scr_mem_alloc(SZ);
|
|
assert(p == prev + SZ);
|
|
p[0] = (unsigned char)i; /* commit the pages */
|
|
prev = p;
|
|
}
|
|
#ifdef __linux__
|
|
assert((scr_sa.base & ((2u << 20) - 1)) == 0);
|
|
FILE *thp = fopen("/sys/kernel/mm/transparent_hugepage/enabled", "r");
|
|
if (thp) { /* a kernel without THP refuses both advices */
|
|
fclose(thp);
|
|
char flags[256];
|
|
vm_flags(first, flags, sizeof flags);
|
|
assert(strstr(flags, " nh") && !strstr(flags, " hg"));
|
|
vm_flags((void *)(start + AFTER), flags, sizeof flags);
|
|
assert(strstr(flags, " hg") && !strstr(flags, " nh"));
|
|
}
|
|
#endif
|
|
/* The blocks recycle through the free list like any others. */
|
|
for (size_t i = blocks; i-- > 0;) scr_mem_free(first + i * SZ);
|
|
assert(scr_mem_alloc(SZ) == first);
|
|
scr_mem_free(first);
|
|
}
|
|
#endif
|
|
|
|
#if defined(SCR_WORKERS) && SCR_SMALL_ALLOC
|
|
#include <pthread.h>
|
|
|
|
/* Script threads own disjoint arenas; blocks freed by another thread return
|
|
* to their owner through its remote list, and an exiting thread's arena
|
|
* (free lists included) passes to the next thread that claims one. */
|
|
enum { HANDOFF = 256 };
|
|
static void *handoff[HANDOFF];
|
|
static void *handed[HANDOFF]; /* the addresses, kept after the frees */
|
|
static uintptr_t worker_base;
|
|
|
|
static void *alloc_and_hand_off(void *arg) {
|
|
(void)arg;
|
|
for (unsigned i = 0; i < HANDOFF; i++) {
|
|
handoff[i] = scr_mem_calloc(48);
|
|
assert(handoff[i] && owned(handoff[i]));
|
|
handed[i] = handoff[i];
|
|
fill(handoff[i], 48, (unsigned char)i);
|
|
}
|
|
worker_base = scr_sa.base;
|
|
scr_sa_thread_park();
|
|
return NULL;
|
|
}
|
|
|
|
static void *reclaim_arena(void *arg) {
|
|
(void)arg;
|
|
/* The parked arena is the only free one besides main's: claimed again,
|
|
* with the main thread's remote frees drained into its free lists. */
|
|
void *p = scr_mem_alloc(48);
|
|
assert(scr_sa.base == worker_base);
|
|
bool recycled = false;
|
|
for (unsigned i = 0; i < HANDOFF; i++) recycled |= p == handed[i];
|
|
assert(recycled);
|
|
scr_mem_free(p);
|
|
scr_sa_thread_park();
|
|
return NULL;
|
|
}
|
|
|
|
static void check_threads(void) {
|
|
void *mine = scr_mem_alloc(48);
|
|
assert(owned(mine));
|
|
pthread_t t;
|
|
assert(pthread_create(&t, NULL, alloc_and_hand_off, NULL) == 0);
|
|
assert(pthread_join(t, NULL) == 0);
|
|
assert(worker_base != scr_sa.base);
|
|
for (unsigned i = 0; i < HANDOFF; i++) {
|
|
assert(!owned(handoff[i]));
|
|
verify(handoff[i], 48, (unsigned char)i);
|
|
if (i == 0) {
|
|
handoff[0] = scr_mem_realloc(handoff[0], 600); /* moves to the system */
|
|
verify(handoff[0], 48, 0);
|
|
free(handoff[0]);
|
|
handoff[0] = NULL;
|
|
} else scr_mem_free(handoff[i]); /* remote free */
|
|
}
|
|
assert(pthread_create(&t, NULL, reclaim_arena, NULL) == 0);
|
|
assert(pthread_join(t, NULL) == 0);
|
|
scr_mem_free(mine);
|
|
}
|
|
#endif
|
|
|
|
int main(void) {
|
|
#if SCR_SMALL_ALLOC
|
|
check_slice_growth(); /* first: its class must be untouched */
|
|
#endif
|
|
check_alignment_and_zeroing();
|
|
check_realloc();
|
|
check_system_pointers();
|
|
check_churn();
|
|
#if defined(SCR_WORKERS) && SCR_SMALL_ALLOC
|
|
check_threads();
|
|
printf("allocator checks passed: size-class workers\n");
|
|
#else
|
|
printf("allocator checks passed: %s\n", SCR_SMALL_ALLOC ? "size-class" : "system");
|
|
#endif
|
|
return 0;
|
|
}
|