An address can answer with a type, because the allocator's caller knew one
The table, and the half of the wiring that needs no type name. A struct is its C layout with no header and no tag word, so nothing at run time can say what is at an address — and adding a tag would break the FFI. The registry sidesteps it: the compiler knows the type at the moment memory is asked for, so the insert is emitted, and the dead-marking is not, because an address needs no type. Entries are blocks rather than values and lookup is containment, which is not an optimisation: every heap pointer a program can hold is interior. (at v i) is v->ptr + i*size and (resolve p h) is an item in the middle of a pool. Exact hits would answer nothing anyone can ask. Dead entries stay until the allocator hands the address out again, which is when the old answer stops being true. An arena's free-all marks its whole range dead — the release memcheck is never told about. That does not make memcheck report it; it makes the inspector able to.
This commit is contained in:
parent
83369196a9
commit
ac31ebc211
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user