diff --git a/runtime/flan_dev.c b/runtime/flan_dev.c index 1226ad1..ca45d9c 100644 --- a/runtime/flan_dev.c +++ b/runtime/flan_dev.c @@ -817,3 +817,235 @@ void *flan_dev_frame_slot(const void *frame, int32_t i) { return f->slots[i]; } +/* ── The allocation registry: an address answers with a type ─────────── + * + * A Flan struct is exactly its C layout: no header, no tag word. That is + * deliberate — it is what makes a struct free and the FFI work — and it is + * also why "what is at this address" has no run-time answer. A tag cannot be + * added without breaking raylib. + * + * The registry sidesteps the question rather than answering it. The + * allocator's *caller* knows the type at the moment it asks for memory, and + * the compiler is standing right there: a dev build emits one note after every + * allocating call, naming the type the memory was asked for. Nothing about a + * value's layout changes. A release build emits no note and this table stays + * empty for the life of the process. + * + * What a note records is a *block*, not a value: base, extent, and the size of + * one element. So a lookup is containment rather than equality, and that is + * not an optimisation — every pointer a program can hold into heap storage is + * interior. (at v i) is v->ptr + i*size and (resolve p h) is an item in the + * middle of a pool's array; neither is ever a base address. A table that + * answered only exact hits would answer nothing anybody can ask it. + * + * Dead entries are kept, which is the second thing this buys: an address that + * was freed still names what died. An entry is dropped only when the allocator + * hands the same address out again, which is exactly when the old answer stops + * being true. + * + * The type name is a pointer into read-only data, not a copy. The strings are + * the ones the compiler already emits beside the call site, and this file's + * header says a module is never dlclose'd, so they outlive the table. + */ + +/* A power of two: the probe wraps with a mask. Fixed, and full is not fatal — + * see flan_dev_reg_note. */ +#define FLAN_REG_CAP 4096 + +typedef struct { + const char *type; /* the Flan spelling, e.g. "Enemy" or "(Vec i32)" */ + int64_t typelen; + uintptr_t base; /* 0 for an empty slot */ + int64_t bytes; /* the extent of the block */ + int64_t elem; /* one element's size, or 0 if it is not an array */ + int64_t seq; /* when it was made */ + int64_t died; /* when it was released, or 0 while it is live */ +} flan_reg_entry; + +static flan_reg_entry flan_reg[FLAN_REG_CAP]; +static int64_t flan_reg_used; /* live + dead slots in use */ +static int64_t flan_reg_seq; /* a monotonic clock, in events */ +static int flan_reg_on; /* only a dev build turns this on */ +static int flan_reg_full; /* something found no slot */ + +/* Armed by the program's entry in a dev build. The free-side hooks in + * flan_rt.c are called unconditionally and begin with this load and a + * not-taken branch, because flan_dev.c is linked into every build and a + * second version of the allocator gated on a build flag is worse than a + * branch. That is a real cost and not zero; BUILT.md says so rather than + * repeating the claim that a release build carries nothing. */ +void flan_dev_reg_enable(void) { flan_reg_on = 1; } + +int flan_dev_reg_enabled(void) { return flan_reg_on; } + +/* Knuth's multiplicative hash over the address, which is what an allocator + * hands out: aligned, and therefore dense in its low bits. */ +static size_t flan_reg_slot(uintptr_t a) { + return (size_t)(((a >> 3) * 11400714819323198485ULL) >> 40) + & (FLAN_REG_CAP - 1); +} + +/* Drop every dead entry and re-insert the live ones. Called when the table + * fills: in a long-running program the dead are the bulk of it, and losing + * them is much cheaper than losing the live half. */ +static void flan_reg_compact(void) { + static flan_reg_entry old[FLAN_REG_CAP]; /* static: 160KB is not stack */ + int64_t i; + memcpy(old, flan_reg, sizeof old); + memset(flan_reg, 0, sizeof flan_reg); + flan_reg_used = 0; + for (i = 0; i < FLAN_REG_CAP; i++) { + size_t s; + int64_t probe; + if (old[i].base == 0 || old[i].died != 0) continue; + s = flan_reg_slot(old[i].base); + for (probe = 0; probe < FLAN_REG_CAP; probe++) { + size_t j = (s + (size_t)probe) & (FLAN_REG_CAP - 1); + if (flan_reg[j].base == 0) { + flan_reg[j] = old[i]; + flan_reg_used++; + break; + } + } + } +} + +/* One note per allocation. [base] replaces whatever was recorded there, live + * or dead: the allocator handing out an address is the event that makes any + * older answer about it wrong. */ +void flan_dev_reg_note(void *base, int64_t bytes, int64_t elem, + const char *type, int64_t typelen) { + uintptr_t a = (uintptr_t)base; + size_t s; + int64_t probe; + if (!flan_reg_on || a == 0 || bytes <= 0) return; + if (flan_reg_used * 4 > (int64_t)FLAN_REG_CAP * 3) flan_reg_compact(); + s = flan_reg_slot(a); + for (probe = 0; probe < FLAN_REG_CAP; probe++) { + size_t j = (s + (size_t)probe) & (FLAN_REG_CAP - 1); + if (flan_reg[j].base != 0 && flan_reg[j].base != a) continue; + if (flan_reg[j].base == 0) flan_reg_used++; + flan_reg[j].type = type; + flan_reg[j].typelen = typelen; + flan_reg[j].base = a; + flan_reg[j].bytes = bytes; + flan_reg[j].elem = elem; + flan_reg[j].seq = ++flan_reg_seq; + flan_reg[j].died = 0; + return; + } + /* Full of live blocks. Killing the program because it ran out of diagnostic + * room would be the diagnostic shooting the patient — the watch table's rule + * and the same answer: a flag, readable by whoever asks, so that a missing + * entry is never mistaken for a freed one. */ + flan_reg_full = 1; +} + +int flan_dev_reg_overflowed(void) { return flan_reg_full; } + +/* The block containing [a], live or dead, or NULL. A linear scan, because the + * reader is a person pressing a key and the writer is a game loop: the cost + * belongs on this side of the table. */ +static flan_reg_entry *flan_reg_find(uintptr_t a) { + int64_t i; + flan_reg_entry *best = NULL; + if (a == 0) return NULL; + for (i = 0; i < FLAN_REG_CAP; i++) { + flan_reg_entry *e = &flan_reg[i]; + if (e->base == 0) continue; + if (a < e->base || a >= e->base + (uintptr_t)e->bytes) continue; + /* A live block wins over a dead one covering the same address: the dead + entry is a stale answer the allocator has already contradicted. */ + if (best == NULL || (best->died != 0 && e->died == 0)) best = e; + } + return best; +} + +/* One block dies. The heap allocator's free calls this, and so does a resize, + * for the block it moved away from. */ +void flan_dev_reg_dead(void *base) { + flan_reg_entry *e; + if (!flan_reg_on) return; + e = flan_reg_find((uintptr_t)base); + if (e != NULL && e->died == 0) e->died = ++flan_reg_seq; +} + +/* Every block inside [base, base+bytes) dies — which is an arena's free-all, + * and it is the release Valgrind cannot see. free-all is retain-capacity: the + * offset goes to zero and the pages stay mapped, so memcheck is never told + * anything died and a later read of stale bytes is a read of memory that is, + * as far as it knows, perfectly alive. The registry is told. That does not + * make memcheck report it; it makes the inspector able to. */ +void flan_dev_reg_dead_range(void *base, int64_t bytes) { + uintptr_t lo = (uintptr_t)base, hi = lo + (uintptr_t)bytes; + int64_t i, now; + if (!flan_reg_on || bytes <= 0) return; + now = ++flan_reg_seq; + for (i = 0; i < FLAN_REG_CAP; i++) { + flan_reg_entry *e = &flan_reg[i]; + if (e->base == 0 || e->died != 0) continue; + if (e->base >= lo && e->base < hi) e->died = now; + } +} + +/* Is it safe to follow this pointer? The one question the renderer asks, and + * the reason the answer is worth having: the type at the other end is already + * static — (Ptr Enemy) says Enemy — so the registry is not supplying the type. + * It is supplying permission. */ +int32_t flan_dev_reg_live(const void *p) { + flan_reg_entry *e; + if (!flan_reg_on) return 0; + e = flan_reg_find((uintptr_t)p); + return (int32_t)(e != NULL && e->died == 0 ? 1 : 0); +} + +/* What is at this address, in words, for the branch that may not follow it. + * Written into a static buffer rather than allocated: the caller is a render + * thunk, which has no allocator and must not acquire one. */ +static char flan_reg_desc[192]; + +const char *flan_dev_reg_describe(const void *p, int64_t *len) { + uintptr_t a = (uintptr_t)p; + flan_reg_entry *e = flan_reg_on ? flan_reg_find(a) : NULL; + int n; + if (e == NULL) { + /* Not "this is not a Flan allocation": a stack local's address is a + perfectly good pointer and is not in here by design. Say what is + known, which is the address. */ + n = snprintf(flan_reg_desc, sizeof flan_reg_desc, "0x%llx", + (unsigned long long)a); + } else { + int64_t off = (int64_t)(a - e->base); + char where[64]; + where[0] = '\0'; + if (e->elem > 0 && off % e->elem == 0 && off / e->elem > 0) + snprintf(where, sizeof where, "[%lld] of ", (long long)(off / e->elem)); + else if (off != 0) + snprintf(where, sizeof where, "+%lld into ", (long long)off); + if (e->died == 0) + n = snprintf(flan_reg_desc, sizeof flan_reg_desc, "0x%llx %s%.*s", + (unsigned long long)a, where, (int)e->typelen, e->type); + else + n = snprintf(flan_reg_desc, sizeof flan_reg_desc, + "0x%llx dead: was %s%.*s, freed at step %lld", + (unsigned long long)a, where, (int)e->typelen, e->type, + (long long)e->died); + } + if (n < 0) n = 0; + if (n > (int)sizeof flan_reg_desc) n = (int)sizeof flan_reg_desc; + *len = n; + return flan_reg_desc; +} + +/* How many blocks the table holds — everything, or only the live ones. For a + * test, and for the breakdown-by-type listing that is not built yet. */ +int64_t flan_dev_reg_count(int32_t live_only) { + int64_t i, n = 0; + for (i = 0; i < FLAN_REG_CAP; i++) { + if (flan_reg[i].base == 0) continue; + if (live_only && flan_reg[i].died != 0) continue; + n++; + } + return n; +} + diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 9f32348..8aec1fe 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -612,6 +612,24 @@ static int flan_over_budget(flan_allocator *a, int64_t size) { return a->budget > 0 && a->live_bytes + size > a->budget; } +/* ── The allocation registry's two halves, and why they are split ────── + * + * Recording *what type* a block was made for happens where the type is known, + * which is the compiler: a dev build emits a note after every allocating call. + * Nothing in this file has to learn a type name and no signature here grows + * one. See flan_dev.c, which holds the table. + * + * Recording that a block *died* happens here, and needs no type at all — it is + * an address, or a range of them. So this half is unconditional and calls into + * flan_dev.c, which is linked into every build and begins each of these with a + * load of a flag that only a dev build ever sets. A release build pays a load + * and a not-taken branch per free, which is not nothing, and BUILT.md says so. + */ +void flan_dev_reg_note(void *base, int64_t bytes, int64_t elem, + const char *type, int64_t typelen); +void flan_dev_reg_dead(void *base); +void flan_dev_reg_dead_range(void *base, int64_t bytes); + /* -- The heap allocator: malloc, realloc, free. ---------------------- */ static void *flan_heap_proc(flan_allocator *a, int32_t mode, void *p, @@ -640,11 +658,24 @@ static void *flan_heap_proc(flan_allocator *a, int32_t mode, void *p, if (!q) return NULL; if (p && old_size > 0) memcpy(q, p, (size_t)(old_size < size ? old_size : size)); - if (p) { free(p); a->live_blocks--; a->live_bytes -= old_size; } + /* The block moved, so the old address stops meaning what it meant. The + new one is named again by the note the compiler emits after the call + that got here — which is also why a resize needs no note of its own. */ + if (p) { + flan_dev_reg_dead(p); + free(p); + a->live_blocks--; + a->live_bytes -= old_size; + } return q; } case FLAN_ALLOC_FREE: - if (p) { free(p); a->live_blocks--; a->live_bytes -= old_size; } + if (p) { + flan_dev_reg_dead(p); + free(p); + a->live_blocks--; + a->live_bytes -= old_size; + } return NULL; case FLAN_ALLOC_FREE_ALL: default: @@ -721,6 +752,12 @@ static void *flan_arena_proc(flan_allocator *a, int32_t mode, void *p, case FLAN_ALLOC_FREE: return NULL; /* refused by the capability set above */ case FLAN_ALLOC_FREE_ALL: + /* The hole test_valgrind.ml measures. The pages stay mapped and the bytes + stay readable, so memcheck is told nothing and never will be by this + line; what it does is make the *registry* agree that everything in the + region died, so a later read through a pointer into it is answerable + rather than silent. */ + flan_dev_reg_dead_range(ar->base, ar->cap); ar->offset = 0; a->live_blocks = 0; a->live_bytes = 0; @@ -794,6 +831,7 @@ void flan_arena_destroy(flan_allocator *a) { if (a == flan_ctx_alloc) flan_ctx_alloc = &flan_heap; if (a == flan_ctx_tmp) flan_ctx_tmp = NULL; a->epoch++; + flan_dev_reg_dead_range(ar->base, ar->cap); free(ar->base); free(ar); free(a); @@ -2158,6 +2196,50 @@ int8_t flan_map_clone(flan_map *dst, flan_map *src, flan_allocator *a, return 1; } +/* ── Noting a container's storage ───────────────────────────────────── + * + * The type name comes from the compiler; the *extent* comes from here, because + * the header is the only thing that knows where the storage landed and how + * much of it there is. Three entry points rather than one because three + * headers are three layouts, and a pool is two blocks that are allocated and + * released together but are not adjacent. + * + * Each is called immediately after the operation that may have allocated — + * every one of them, not only the first — because storage moves. A note is an + * upsert keyed on the base address, so re-noting an unmoved block costs a + * probe and overwrites the entry with the same numbers. + * + * A container with no storage yet notes nothing: flan_dev_reg_note ignores a + * null base, so an empty Vec needs no branch on this side. */ + +void flan_dev_reg_note_vec(flan_vec *v, int64_t size, const char *type, + int64_t typelen) { + if (v) flan_dev_reg_note(v->ptr, v->cap * size, size, type, typelen); +} + +void flan_dev_reg_note_pool(flan_pool *p, int64_t size, const char *type, + int64_t typelen) { + if (!p) return; + flan_dev_reg_note(p->items, p->cap * size, size, type, typelen); + /* The slot headers are the pool's own bookkeeping and not the element type, + so they are named for what they are. Recording them matters for the same + reason the items do: after a free-all their bytes are still readable and + an address landing in them must not come back as an element. */ + flan_dev_reg_note(p->slots, p->cap * (int64_t)sizeof(flan_pool_slot), + (int64_t)sizeof(flan_pool_slot), "pool slots", 11); +} + +void flan_dev_reg_note_map(flan_map *m, int64_t ksize, int64_t vsize, + const char *type, int64_t typelen) { + if (!m || !m->data) return; + /* One block holding the hashes, the keys and the values, so the element + size is meaningless here and is passed as 0: an address inside it is + "+n into" rather than "[i] of". */ + flan_dev_reg_note(m->data, + flan_map_block_size(ksize, vsize, flan_map_cap(m)), 0, + type, typelen); +} + /* ── The filesystem, and the whole of what it adds to the host ABI ─── * * plan.org names the filesystem as the #1 portability risk — "pack assets, one