summaryrefslogtreecommitdiff
path: root/examples/heapcheck.zig
diff options
context:
space:
mode:
Diffstat (limited to 'examples/heapcheck.zig')
-rw-r--r--examples/heapcheck.zig240
1 files changed, 240 insertions, 0 deletions
diff --git a/examples/heapcheck.zig b/examples/heapcheck.zig
new file mode 100644
index 0000000..9d61f71
--- /dev/null
+++ b/examples/heapcheck.zig
@@ -0,0 +1,240 @@
+//! Does `src/net/heap.zig` survive an editor's allocation pattern in 512 KiB?
+//!
+//! `Heap` was written for ESP-Hosted and sized by it: its own header says the workload it is
+//! "sized for is the one the parent measured - `mempool.c` recycling fixed-size buffers - which is
+//! exactly the pattern that keeps a coalescing free list short and its first fit O(1) in practice".
+//! An editor is not that workload. pardes allocates panes, text buffers, cell grids and per-frame
+//! scratch in a dozen different sizes and frees them in an order nobody chose. Two properties that
+//! were free under fixed-size recycling stop being free:
+//!
+//! * **First fit fragments.** Varied sizes leave gaps too small for the next request, and the
+//! symptom is not a clean failure - it is `largest_free` collapsing while `free` stays healthy,
+//! so the heap reports plenty of room and cannot satisfy a grid reallocation.
+//! * **First fit is O(n) in the free list.** One long-lived allocation in the middle of the arena
+//! splits it permanently, and every subsequent walk pays for it.
+//!
+//! So this measures both, on the die, before the editor depends on it. Each phase prints a line
+//! whether it passed or not: a number is the deliverable, not a verdict.
+//!
+//! The interesting column is `largest/free`. At 1.00 the free space is one block and the heap is
+//! pristine; as it falls, that fraction is the largest single allocation still possible. An editor
+//! that cannot get one contiguous cell grid is dead regardless of how many bytes are notionally
+//! free.
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const heapmod = @import("heap");
+
+/// The upper RAM chunk, from the linker script - the same span the firmware gets.
+///
+/// Reached with `@extern`, NOT with `extern const __heap_start: anyopaque` plus
+/// `@intFromPtr`/`@ptrFromInt`. That spelling cost real debugging on this board. Declaring a linker
+/// symbol as an `anyopaque` OBJECT gives the optimiser a zero-sized object to reason about, so a
+/// pointer derived from its address carries provenance for zero bytes - and the ordinary
+/// (non-volatile) store `Heap.init` makes through it was simply dropped. The symptom was the block
+/// header reading back as `size=2988759312 next=0x14284684` instead of `{393216, 0xFFFFFFFF}`, after
+/// which the first free-list walk followed garbage and never terminated. With asserts compiled out
+/// in ReleaseSmall that is a silent hang, and `examples/memprobe.zig` could not see it because it
+/// writes through a `volatile` pointer, which the optimiser must not touch.
+///
+/// `@extern` with a `[*]u8` result has no size to lose.
+const heap_start = @extern([*]align(heapmod.Heap.granule) u8, .{ .name = "__heap_start" });
+const heap_end = @extern([*]align(heapmod.Heap.granule) u8, .{ .name = "__heap_end" });
+
+var gpa_heap: heapmod.Heap = undefined;
+
+fn span() []align(heapmod.Heap.granule) u8 {
+ return heap_start[0 .. @intFromPtr(heap_end) - @intFromPtr(heap_start)];
+}
+
+fn report(tag: [*:0]const u8) void {
+ const s = gpa_heap.stats();
+ // Split across two calls, and no `%%`: the mask ROM's printf is size-optimised and this file's
+ // first version passed it six varargs plus a literal `%%`, which hung. Four is known to work
+ // (examples/memprobe.zig's range lines), so this stays inside what has been demonstrated.
+ soc.rom.print("MARK HEAP %s total=%u free=%u blocks=%u\r\n", .{
+ tag, s.total, s.free, s.free_blocks,
+ });
+ // `largest` is the number that matters under fragmentation: it is the largest single allocation
+ // still possible, whatever `free` claims.
+ soc.rom.print("MARK HEAP %s largest=%u\r\n", .{ tag, s.largest_free });
+}
+
+/// A deterministic LCG, so a bad run is reproducible. Numerical Recipes' constants.
+var rng_state: u32 = 0x1234_5678;
+fn rand() u32 {
+ rng_state = rng_state *% 1664525 +% 1013904223;
+ return rng_state;
+}
+
+/// How many live pointers the phases below track. 512 slots at an average of ~512 B is ~256 KiB,
+/// half the arena, which is enough to fragment it without trivially exhausting it.
+const slots = 512;
+var live: [slots][]u8 = undefined;
+var live_len: usize = 0;
+
+export fn zig_main() noreturn {
+ const s = span();
+ // The bootloader leaves the RTC watchdog running and expects the application to take it over.
+ // A bare image never did, so every demo in this repo has been resetting on a ten-second cycle
+ // (README.md:289-293) - invisible until a run lasted longer than eight seconds. The churn phase
+ // below does 20,000 allocations, so this is the first example here that would have hit it: the
+ // symptom was HEAP_BOOT printed twice and nothing after.
+ const was_armed = hal.rwdt.disable();
+ soc.rom.print("MARK HEAP_RWDT was_armed=%u now_armed=%u\r\n", .{
+ @as(u32, @intFromBool(was_armed)), @as(u32, @intFromBool(hal.rwdt.armed())),
+ });
+ soc.rom.print("\r\nMARK HEAP_BOOT 0x%08x..0x%08x %u KiB\r\n", .{
+ @as(u32, @intFromPtr(s.ptr)),
+ @as(u32, @intFromPtr(s.ptr)) + @as(u32, @intCast(s.len)),
+ @as(u32, @intCast(s.len / 1024)),
+ });
+ gpa_heap = heapmod.Heap.init(s);
+ soc.rom.print("MARK HEAP_INIT done\r\n", .{});
+ // Read the block header straight back through a volatile pointer. `stats()` walks the free list
+ // starting here, so if these two words are not {len, 0xFFFFFFFF} the walk follows garbage and
+ // never terminates - and with asserts compiled out in ReleaseSmall that is a silent hang.
+ {
+ const hdr: *volatile [2]u32 = @ptrFromInt(0x4FF4_0000);
+ soc.rom.print("MARK HEAP_HDR size=%u next=0x%08x\r\n", .{ hdr[0], hdr[1] });
+ }
+ const a = gpa_heap.allocator();
+ soc.rom.print("MARK HEAP_VTABLE done\r\n", .{});
+ const probe_stats = gpa_heap.stats();
+ soc.rom.print("MARK HEAP_STATS total=%u\r\n", .{probe_stats.total});
+ report("fresh");
+
+ // ---- phase 1: how much of the arena is actually reachable, and what does the header cost?
+ // Allocate one block at a time until refusal, to separate "512 KiB of RAM" from "512 KiB of
+ // usable allocations". Each block costs an 8-byte header, so the answer is strictly less.
+ {
+ var n: u32 = 0;
+ var total: usize = 0;
+ while (n < slots) {
+ const blk = a.alloc(u8, 512) catch break;
+ live[n] = blk;
+ total += blk.len;
+ n += 1;
+ }
+ live_len = n;
+ soc.rom.print("MARK HEAP_FILL %u blocks of 512 B = %u B payload\r\n", .{ n, @as(u32, @intCast(total)) });
+ report("filled");
+ for (live[0..live_len]) |blk| a.free(blk);
+ live_len = 0;
+ // Coalescing is the whole design: after freeing everything the arena must be ONE block
+ // again. If it is not, `insert`'s merge is wrong and every later number is meaningless.
+ report("emptied");
+ }
+
+ // ---- phase 2: the fragmentation case. Fill with alternating sizes, free every other block.
+ // This is the adversarial pattern for first fit: the holes are all the smaller size, and the
+ // next larger request has to walk past every one of them.
+ {
+ var n: usize = 0;
+ while (n < slots) : (n += 1) {
+ const size: usize = if (n % 2 == 0) 192 else 320;
+ live[n] = a.alloc(u8, size) catch break;
+ }
+ live_len = n;
+ var i: usize = 0;
+ while (i < live_len) : (i += 2) a.free(live[i]);
+ report("holed");
+
+ // Now ask for something that fits in no single hole and see what the heap does.
+ if (a.alloc(u8, 4096)) |big| {
+ soc.rom.print("MARK HEAP_BIG 4096 B satisfied after holing\r\n", .{});
+ a.free(big);
+ } else |_| {
+ soc.rom.print("MARK HEAP_BIG 4096 B REFUSED after holing\r\n", .{});
+ }
+ i = 1;
+ while (i < live_len) : (i += 2) a.free(live[i]);
+ live_len = 0;
+ report("unholed");
+ }
+
+ // ---- phase 3: the O(n) first-fit walk, measured rather than argued.
+ // Build a long free list, then time one allocation that has to traverse it. `soc.cycles()` is
+ // the cycle counter, so this is in real CPU cycles.
+ {
+ var n: usize = 0;
+ while (n < slots) : (n += 1) live[n] = a.alloc(u8, 256) catch break;
+ live_len = n;
+ // Free every other block to make `free_blocks` large, then measure a request that no hole
+ // can satisfy, which is the worst case: the full walk.
+ var i: usize = 0;
+ while (i < live_len) : (i += 2) a.free(live[i]);
+ const before = gpa_heap.stats().free_blocks;
+
+ const t0 = soc.cycles();
+ const probe = a.alloc(u8, 1024) catch null;
+ const cycles = soc.cycles() - t0;
+ soc.rom.print("MARK HEAP_WALK %u free blocks, alloc took %u cycles\r\n", .{ before, @as(u32, @intCast(cycles)) });
+ if (probe) |p| a.free(p);
+
+ i = 1;
+ while (i < live_len) : (i += 2) a.free(live[i]);
+ live_len = 0;
+ report("after-walk");
+ }
+
+ // ---- phase 4: a long random churn, which is the closest thing here to a real session.
+ // Random sizes, random free order, and a running count of refusals. A heap that fragments
+ // itself to death shows up as refusals climbing while `free` stays large.
+ {
+ var refusals: u32 = 0;
+ var ops: u32 = 0;
+ live_len = 0;
+ while (ops < 20000) : (ops += 1) {
+ const keep = live_len < slots and (live_len < 64 or rand() % 100 < 55);
+ if (keep) {
+ // 24 B to ~6 KiB: the spread pardes actually shows, from a small string to a
+ // reallocated row buffer.
+ const size = 24 + (rand() % 6000);
+ if (a.alloc(u8, size)) |blk| {
+ live[live_len] = blk;
+ live_len += 1;
+ } else |_| refusals += 1;
+ } else if (live_len > 0) {
+ const victim = rand() % @as(u32, @intCast(live_len));
+ a.free(live[victim]);
+ live[victim] = live[live_len - 1];
+ live_len -= 1;
+ }
+ }
+ soc.rom.print("MARK HEAP_CHURN %u ops, %u live, %u refusals\r\n", .{ ops, @as(u32, @intCast(live_len)), refusals });
+ report("churned");
+ for (live[0..live_len]) |blk| a.free(blk);
+ live_len = 0;
+ report("drained");
+ }
+
+ soc.rom.print("MARK HEAP_DONE\r\n", .{});
+ while (true) {}
+}
+
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK HEAP_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);