diff --git a/src/weirdperformance/AllocBench/AllocBench.lua b/src/weirdperformance/AllocBench/AllocBench.lua new file mode 100644 index 0000000..0273ddf --- /dev/null +++ b/src/weirdperformance/AllocBench/AllocBench.lua @@ -0,0 +1,103 @@ +-- AllocBench: Lua allocation benchmark +-- /allocbench to run, reports best-of-5 timing + +local ITERATIONS = 100000 + +local function bench_strings() + -- Short strings (interned), medium strings, concatenation + local t = {} + for i = 1, ITERATIONS do + t[i] = "key" .. i + end + for i = 1, ITERATIONS do + local s = t[i] .. "_suffix" + t[i] = s + end + return t +end + +local function bench_tables() + -- Small tables, growing tables, nested tables + local all = {} + for i = 1, ITERATIONS do + local t = {} + t.name = "entry" + t.value = i + t.flag = true + t.sub = { a = 1, b = 2, c = 3 } + all[i] = t + end + return all +end + +local function bench_mixed() + -- Mixed: tables with string keys, numeric arrays, realloc via growth + local result = {} + for i = 1, ITERATIONS do + local t = {} + -- Grow array part: triggers realloc at 1, 2, 4, 8, 16... + for j = 1, 20 do + t[j] = j * 0.5 + end + -- Grow hash part: triggers rehash + t.alpha = "hello" + t.beta = 42 + t.gamma = true + t.delta = { i, i + 1, i + 2 } + result[i] = t + end + return result +end + +local function bench_table_hash() + -- Pure hash table lookup/insert stress + local big = {} + for i = 1, ITERATIONS do + big["key_" .. i] = i + end + local sum = 0 + for i = 1, ITERATIONS do + sum = sum + big["key_" .. i] + end + return sum +end + +local function bench_churn() + -- Alloc/free churn: create and discard rapidly + for i = 1, ITERATIONS do + local t = { a = 1, b = "x", c = { 1, 2, 3 } } + t = nil + end +end + +local function run_all() + bench_strings() + bench_tables() + bench_mixed() + bench_table_hash() + bench_churn() +end + +local function run_bench() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00AllocBench|r: running 5 iterations...") + + -- Warmup + debugprofilestart() + run_all() + collectgarbage() + + local best = 999999999 + for trial = 1, 5 do + collectgarbage() + debugprofilestart() + run_all() + local elapsed = debugprofilestop() + if elapsed < best then best = elapsed end + DEFAULT_CHAT_FRAME:AddMessage(string.format(" trial %d: %.1f ms", trial, elapsed)) + end + + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00AllocBench|r: best = |cffffd700%.1f ms|r (%d iterations per test)", best, ITERATIONS)) +end + +SLASH_ALLOCBENCH1 = "/allocbench" +SlashCmdList["ALLOCBENCH"] = run_bench diff --git a/src/weirdperformance/AllocBench/AllocBench.toc b/src/weirdperformance/AllocBench/AllocBench.toc new file mode 100644 index 0000000..282d227 --- /dev/null +++ b/src/weirdperformance/AllocBench/AllocBench.toc @@ -0,0 +1,4 @@ +## Interface: 11200 +## Title: AllocBench +## Notes: Lua allocation benchmark - /allocbench to run +AllocBench.lua diff --git a/src/weirdperformance/luaalloc.zig b/src/weirdperformance/luaalloc.zig index 1927bf2..14c468d 100644 --- a/src/weirdperformance/luaalloc.zig +++ b/src/weirdperformance/luaalloc.zig @@ -17,9 +17,10 @@ //! calculate GC thresholds. Since pools are empty (all allocs go through us), //! we replace it with standard Lua 5.0 threshold logic: threshold = totalbytes * 1.25. -const std = @import("std"); const hook = @import("zhook"); +const logging = @import("../logging.zig"); +const PROFILE = false; // set true to collect size histogram, dump with dumpStats() // ============================================================================ // Slab allocator -- segment-based lookup @@ -52,25 +53,47 @@ const LARGE_CLASS = 0xFF; // Stays hot in L1/L2 since every alloc/free/realloc touches it. var segment_table: [65536]u8 = .{0} ** 65536; -// Slot sizes. Each class is roughly 1.5x the previous. -const class_sizes = [_]u32{ 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 2048, 4096 }; +// Slot sizes tuned from runtime profiling of 6M Lua allocations. +// Lua table nodes are 40 bytes; hash tables are power-of-2 arrays of nodes, +// giving hot sizes at 80, 160, 320, 640, 1280, 2560 (2..64 nodes * 40). +// Adding these 6 classes eliminates ~73 MB of the 84 MB total waste. +const class_sizes = [_]u32{ 16, 24, 32, 48, 64, 80, 96, 128, 160, 192, 256, 320, 384, 512, 640, 768, 1024, 1280, 2048, 2560, 4096 }; const NUM_CLASSES = class_sizes.len; -var free_lists: [NUM_CLASSES]u32 = .{0} ** NUM_CLASSES; +// Precomputed lookup table: size_class_lut[n] = class index for size n. +// Direct indexing -- one byte load, zero computation on the hot path. +const LUT_SIZE = 4097; // covers sizes 0-4096 +const size_class_lut: [LUT_SIZE]u8 = blk: { + @setEvalBranchQuota(100000); + var lut: [LUT_SIZE]u8 = .{0xFF} ** LUT_SIZE; + lut[0] = 0; // size 0 -> class 0 + for (1..LUT_SIZE) |sz| { + for (class_sizes, 0..) |cs, ci| { + if (sz <= cs) { + lut[sz] = @intCast(ci); + break; + } + } + } + break :blk lut; +}; /// Find the smallest size class index that fits `size` bytes. -fn sizeClassIndex(size: u32) ?usize { - inline for (class_sizes, 0..) |sz, i| { - if (size <= sz) return i; - } - return null; +/// Direct table lookup -- one byte load for sizes 0-4096. +inline fn sizeClassIndex(size: u32) ?usize { + if (size >= LUT_SIZE) return null; + const cls = size_class_lut[size]; + if (cls == 0xFF) return null; + return cls; } /// Look up class index from pointer via segment table. -fn classFromPtr(ptr: u32) u8 { +inline fn classFromPtr(ptr: u32) u8 { return segment_table[ptr >> SEGMENT_SHIFT]; } +var free_lists: [NUM_CLASSES]u32 = .{0} ** NUM_CLASSES; + /// Allocate a new 64KB page for the given class, register in segment table, /// and link all slots into the free list. fn refillClass(class_idx: usize) bool { @@ -97,6 +120,7 @@ fn refillClass(class_idx: usize) bool { } fn slabAlloc(size: u32) ?[*]u8 { + profileAlloc(size); const class_idx = sizeClassIndex(size) orelse return largeMalloc(size); if (free_lists[class_idx] == 0) { @@ -109,17 +133,13 @@ fn slabAlloc(size: u32) ?[*]u8 { return @ptrFromInt(slot_addr); } -fn slabFree(ptr: u32, pool_ctx: u32) void { +fn slabFree(ptr: u32) void { const seg_val = classFromPtr(ptr); - if (seg_val == 0) { - // Not ours (e.g. allocated before hook install). Let original handle it. - _ = pool_alloc_hook.callOriginal(.{ pool_ctx, ptr, @as(u32, 0) }); - return; - } if (seg_val == LARGE_CLASS) { largeFree(ptr); return; } + if (seg_val == 0) return; // not ours (shouldn't happen) const class_idx: usize = seg_val - 1; const next_ptr: *u32 = @ptrFromInt(ptr); @@ -127,18 +147,10 @@ fn slabFree(ptr: u32, pool_ctx: u32) void { free_lists[class_idx] = ptr; } -fn slabRealloc(ptr: u32, new_size: u32, pool_ctx: u32) ?[*]u8 { +fn slabRealloc(ptr: u32, new_size: u32) ?[*]u8 { const seg_val = classFromPtr(ptr); - if (seg_val == 0) { - // Not ours. Allocate from our slab, copy, free old via original. - const new_ptr = slabAlloc(new_size) orelse return null; - const dst: [*]u8 = new_ptr; - const src: [*]const u8 = @ptrFromInt(ptr); - @memcpy(dst[0..new_size], src[0..new_size]); - _ = pool_alloc_hook.callOriginal(.{ pool_ctx, ptr, @as(u32, 0) }); - return new_ptr; - } if (seg_val == LARGE_CLASS) return largeRealloc(ptr, new_size); + if (seg_val == 0) return null; // not ours (shouldn't happen) const class_idx: usize = seg_val - 1; const old_slot_size = class_sizes[class_idx]; @@ -152,7 +164,7 @@ fn slabRealloc(ptr: u32, new_size: u32, pool_ctx: u32) ?[*]u8 { const dst: [*]u8 = new_ptr; const src: [*]const u8 = @ptrFromInt(ptr); @memcpy(dst[0..copy_len], src[0..copy_len]); - slabFree(ptr, pool_ctx); + slabFree(ptr); return new_ptr; } @@ -231,26 +243,20 @@ fn largeRealloc(ptr: u32, new_size: u32) ?[*]u8 { // All 11 RET instructions are RET 0x4. // // Called from LuaMemoryRealloc (0x6FC980) which handles totalbytes accounting. -// Also called from non-Lua paths (ECX=NULL) for general pool allocation. // ============================================================================ const PoolAllocFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) ?[*]u8; var pool_alloc_hook: hook.Detour(PoolAllocFn) = .{}; -fn poolAllocDetour(pool_ctx: u32, old_ptr_raw: u32, new_size: u32) callconv(hook.cc.fastcall) ?[*]u8 { - // ECX=0 means non-Lua path -- pass through to original - if (pool_ctx == 0) { - return pool_alloc_hook.callOriginal(.{ pool_ctx, old_ptr_raw, new_size }); - } - +fn poolAllocDetour(_: u32, old_ptr_raw: u32, new_size: u32) callconv(hook.cc.fastcall) ?[*]u8 { if (new_size == 0) { - if (old_ptr_raw != 0) slabFree(old_ptr_raw, pool_ctx); + if (old_ptr_raw != 0) slabFree(old_ptr_raw); return null; } if (old_ptr_raw != 0) { - return slabRealloc(old_ptr_raw, new_size, pool_ctx); + return slabRealloc(old_ptr_raw, new_size); } return slabAlloc(new_size); @@ -281,6 +287,54 @@ fn gcStepDetour(lua_state: u32) callconv(hook.cc.fastcall) void { global_state[9] = totalbytes + (totalbytes >> 2); // offset 0x24 = GCthreshold } +// ============================================================================ +// Size profiling -- enabled by PROFILE flag +// Collects per-size allocation counts, dumps to log file on request. +// ============================================================================ + +const HIST_MAX = 4096; // track sizes 0..4095 individually +const HIST_BUCKETS = if (PROFILE) HIST_MAX + 1 else 0; +var size_histogram: [HIST_BUCKETS]u32 = .{0} ** HIST_BUCKETS; +var large_alloc_count: u32 = 0; +var total_alloc_count: u32 = 0; + +inline fn profileAlloc(size: u32) void { + if (!PROFILE) return; + total_alloc_count +|= 1; + if (size <= HIST_MAX) { + size_histogram[size] +|= 1; + } else { + large_alloc_count +|= 1; + } +} + +pub fn dumpStats() void { + if (!PROFILE) return; + var log = logging.Logger.open("luaalloc", .file); + defer log.close(); + var buf: [256]u8 = undefined; + + log.print("=== luaalloc size histogram ===\n"); + log.print(fmt(&buf, "total allocs: {d}\n", .{total_alloc_count})); + log.print(fmt(&buf, "large allocs (>{d}): {d}\n", .{ HIST_MAX, large_alloc_count })); + + log.print(" size count class waste total_waste\n"); + for (0..HIST_BUCKETS) |sz| { + const count = size_histogram[sz]; + if (count == 0) continue; + const s: u32 = @intCast(sz); + const cls_idx = sizeClassIndex(s); + const cls_size: u32 = if (cls_idx) |ci| class_sizes[ci] else 0; + const waste: u32 = if (cls_size > 0) cls_size - s else 0; + log.print(fmt(&buf, "{d:>6} {d:>8} {d:>6} {d:>6} {d:>10}\n", .{ s, count, cls_size, waste, waste *% count })); + } + log.print("=== end ===\n"); +} + +fn fmt(buf: []u8, comptime f: []const u8, args: anytype) []const u8 { + return @import("std").fmt.bufPrint(buf, f, args) catch "???"; +} + pub fn install() u32 { var installed: u32 = 0; if (pool_alloc_hook.attach(0x6FAE90, &poolAllocDetour) == .ok) installed += 1;