From 2b7ce9e1feeae4b688e2121efbfe5422227de126 Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Sat, 31 Oct 2026 16:02:49 +0000 Subject: [PATCH] Apply Patch --- GentleGC.lua | 113 +++++++++++++++++++++--- GentleGC.toc | 2 +- tests/test_weirdperf.lua | 183 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 tests/test_weirdperf.lua diff --git a/GentleGC.lua b/GentleGC.lua index 48e58c3..20122fd 100644 --- a/GentleGC.lua +++ b/GentleGC.lua @@ -27,6 +27,10 @@ optional, used only when the ClassicAPI client mod is installed: * collect when you've been standing still for a few seconds (GetUnitSpeed) * run the once-a-second check from C_Timer instead of a frame OnUpdate + with weirdperformance.dll (WeirdUtils), whose collector does one full mark + per cycle and then frees memory in small chunks: + * recognize the chunks, stay out of a running cycle, and schedule only + the start of each cycle (the full mark) when collections are slow (default: averaging 100 ms or more): * careful mode: no stand-still or idle collections, only loading screens, death, taxi, NPC windows, AFK, after combat, and pressure @@ -93,6 +97,11 @@ local thrByName, thrCount = {}, 0 -- session: threshold changes by othe local wrappedBy = false -- addon(s) whose Lua wrapper our collectgarbage passes through (found at load) local intercepted, interceptWarned = 0, false -- our collections that didn't run normally local pn, px, py, pxx, pxy, pxMin, pxMax = 0, 0, 0, 0, 0, nil, nil -- pause (ms) vs MB freed, own collections +-- weirdperformance.dll: each cycle is one full mark, then the sweep runs in +-- chunks; between chunks it sets the threshold to the heap + 128 KB +local wp = false -- its collector is loaded (checked on ADDON_LOADED) +local wpSweeping = false -- a cycle is in progress (between chunks) +local wpChunks = 0 -- session: sweep chunks seen do local used, threshold = gcinfo() -- before any collection we can only assume the stock 2x ratio; the first @@ -218,6 +227,56 @@ local function AfterForeignCollection() if inCombat then EnterCombatThreshold() end end +-- weirdperformance's chunk boundary: threshold = heap + 128 KB (the heap may +-- have grown a little since it was set) +local function IsChunkThreshold(threshold, used) + return threshold >= used - 64 and threshold <= used + 256 +end + +-- With weirdperformance: recognize its cycle starts and sweep chunks. +-- Returns true when the change was one of them. +local function WpSync(used, threshold, oldThreshold) + if IsChunkThreshold(threshold, used) then + if not wpSweeping then + -- a cycle started: its full mark is the pause that matters + wpSweeping = true + savedThreshold = nil -- a raised combat threshold was used up by it + if used < lastUsed and lastUsed < oldThreshold * 0.9 then + CountExternal(nil, nil, lastUsed - used) + else + for _, s in ipairs({ period, total }) do + s.auto = s.auto + 1 + if inCombat then s.autoCombat = s.autoCombat + 1 end + end + if DebugLevel() >= 2 then + Msg(format("automatic GC detected%s (weirdperformance cycle started)", inCombat and " in combat" or "")) + end + end + else + wpChunks = wpChunks + 1 + end + if used < lastUsed then lastUsed = used else Account(used) end + liveKB = used -- unswept garbage is still in the heap: don't plan on it + return true + elseif wpSweeping then + -- the last chunk: the client's own threshold rule applies again + wpSweeping = false + wpChunks = wpChunks + 1 + if used < lastUsed then lastUsed = used else Account(used) end + liveKB = used + LearnRatio(used, threshold) + AfterForeignCollection() -- in combat: delay the next cycle now that this one is done + return true + elseif used >= lastUsed and threshold >= used * 1.2 then + -- a whole cycle fit inside one frame (start, chunks and end): the client's + -- own threshold rule, not an addon setting the threshold + Account(used) + AfterForeignCollection() + return true + end + return false +end + -- Detect collections we didn't run and thresholds we didn't set. A heap -- drop means a collection; a changed threshold without a drop means someone -- set it with collectgarbage(n). For a collection: the game's @@ -243,6 +302,8 @@ local function Sync() return end + if wp and WpSync(used, threshold, oldThreshold) then return end + if used < lastUsed then local est = used -- a threshold left within 5% of the heap wasn't set by the collector's @@ -319,6 +380,8 @@ end local function Collect(reason) Sync() + -- mid-cycle, collectgarbage() would only advance weirdperformance's sweep + if wp and wpSweeping then return end local before, thresholdBefore = gcinfo() local t0 = StartTimer() rawCollect() -- full collection; the client sets a fresh threshold @@ -329,7 +392,8 @@ local function Collect(reason) -- a full collection always leaves the threshold well above the heap (the -- client's rule is at least 1.25x live); within 5% means collectgarbage() -- was turned into something else by an addon that wraps it - local abnormal = not blocked and after > 1024 and threshold < after * 1.05 + -- (weirdperformance leaves heap + 128 KB on purpose) + local abnormal = not wp and not blocked and after > 1024 and threshold < after * 1.05 if blocked or abnormal then lastGC = GetTime() liveKB = after -- assume nothing to free: don't retry at once @@ -351,6 +415,7 @@ local function Collect(reason) liveKB = after lastUsed = after + if wp then wpSweeping = IsChunkThreshold(threshold, after) end LearnRatio(after, threshold) UpdateCareful(ms) knownThreshold = threshold @@ -360,10 +425,13 @@ local function Collect(reason) local freed = before - after -- pause against garbage freed: on this client freeing may be most of the cost - local x = MB(math.max(0, freed)) - pn, px, py, pxx, pxy = pn + 1, px + x, py + ms, pxx + x * x, pxy + x * ms - if not pxMin or x < pxMin then pxMin = x end - if not pxMax or x > pxMax then pxMax = x end + -- (not with weirdperformance: most of the freeing happens later, in chunks) + if not wp then + local x = MB(math.max(0, freed)) + pn, px, py, pxx, pxy = pn + 1, px + x, py + ms, pxx + x * x, pxy + x * ms + if not pxMin or x < pxMin then pxMin = x end + if not pxMax or x > pxMax then pxMax = x end + end for _, s in ipairs({ period, total }) do s.count = s.count + 1 s.freed = s.freed + freed @@ -372,8 +440,13 @@ local function Collect(reason) end if DebugLevel() >= 2 then - Msg(format("%s: %.1fmb -> %.1fmb (freed %.1fmb, next auto-GC at %.1fmb) in %dms", - reason, MB(before), MB(after), MB(freed), MB(threshold), ms)) + if wpSweeping then + Msg(format("%s: started a weirdperformance cycle in %dms (%.1fmb -> %.1fmb so far; the rest is freed in chunks)", + reason, ms, MB(before), MB(after))) + else + Msg(format("%s: %.1fmb -> %.1fmb (freed %.1fmb, next auto-GC at %.1fmb) in %dms", + reason, MB(before), MB(after), MB(freed), MB(threshold), ms)) + end end end @@ -399,6 +472,9 @@ end EnterCombatThreshold = function() if db.combatHeadroomMB <= 0 then return end + -- mid-cycle, let weirdperformance's chunks run; the headroom is added when + -- the cycle ends, to keep the next cycle's full mark out of the fight + if wp and wpSweeping then return end local used, threshold = gcinfo() local want = used + db.combatHeadroomMB * 1024 if want > threshold then @@ -426,7 +502,7 @@ local function LeaveCombat() return end TryCollect("post-combat") - if savedThreshold then + if savedThreshold and not (wp and wpSweeping) then rawCollect(savedThreshold) savedThreshold = nil local _, t = gcinfo() @@ -464,6 +540,7 @@ local function WrappedCollect(a, b) local ms = (GetTime() - t0) * 1000 local after, threshold = gcinfo() local full = a == nil or a == 0 or a == "collect" + if wp and full then wpSweeping = IsChunkThreshold(threshold, after) end if after < before - 256 or (full and threshold ~= knownThreshold) then -- a full collection (or a threshold set below the heap, which collects) liveKB = after @@ -599,7 +676,7 @@ local function Check(dt) local used, threshold = gcinfo() if used >= PressurePointKB(threshold) and GetTime() - lastGC >= 5 - and GarbageKB() >= math.max(1024, liveKB / 100) then + and GarbageKB() >= math.max(1024, liveKB / 100) and not (wp and wpSweeping) then -- The automatic collector is about to fire anyway and would cost the -- same pause, so collect now, out of combat, without the garbage minimum. -- But not when there's next to nothing to free: that happens when an @@ -649,6 +726,17 @@ end -- C_Timer callbacks run once per frame from the world tick, outside the -- frame OnUpdate walk. Without ClassicAPI, fall back to a frame OnUpdate. StartScheduler = function() + if GetWeirdUtilsVersion then + local ok, v = pcall(GetWeirdUtilsVersion, "weirdperformance") + wp = ok and v and true or false + end + if wp then + local used, threshold = gcinfo() + wpSweeping = IsChunkThreshold(threshold, used) + if DebugLevel() >= 1 then + Msg("weirdperformance detected: its collector frees memory in chunks; GentleGC schedules only the start of each cycle.") + end + end sampleFrame = sampleFrame or CreateFrame("Frame") sampleFrame:SetScript("OnUpdate", SampleOnUpdate) hasSpeed = type(GetUnitSpeed) == "function" @@ -837,7 +925,8 @@ local function HitchOnUpdate() cause = format("garbage collection run by %s%s", extName or "another addon", extMs and format(" took %dms", extMs) or "") elseif used < prevUsed - 256 or (threshold ~= prevThreshold and not weMovedThreshold) then - cause = format("the game's automatic garbage collection: heap %.1fmb -> %.1fmb", + cause = format("%s: heap %.1fmb -> %.1fmb", + wp and "weirdperformance's collector (a cycle's full mark, or a sweep chunk)" or "the game's automatic garbage collection", MB(prevUsed), MB(used)) else cause = "no garbage collection - something else caused it" @@ -991,6 +1080,10 @@ local OPTIONS = { -- Other addons' collections and thresholds, wrappers, and the pause model -- (split from PrintStatus: Lua 5.0 allows a function only 32 upvalues). local function PrintDiagnostics() + if wp then + Msg(format("weirdperformance: %d cycles started by the game (%d in combat), %d by GentleGC, %d sweep chunks; %s", + total.auto, total.autoCombat, total.count, wpChunks, wpSweeping and "a cycle is running now" or "no cycle running")) + end if total.ext > 0 then local parts = {} for name, n in pairs(extByName) do table.insert(parts, name .. " " .. n) end diff --git a/GentleGC.toc b/GentleGC.toc index d077c7c..0346605 100644 --- a/GentleGC.toc +++ b/GentleGC.toc @@ -2,7 +2,7 @@ ## Title: Gentle GC ## Notes: Schedules Lua garbage collection for moments you won't notice ## Author: Weird Vibes (MarcelineVQ); forks by TheRealFayz, jrc13245 -## Version: 2.6 +## Version: 2.7 ## SavedVariables: GentleGCDB GentleGC.lua diff --git a/tests/test_weirdperf.lua b/tests/test_weirdperf.lua new file mode 100644 index 0000000..5ff7038 --- /dev/null +++ b/tests/test_weirdperf.lua @@ -0,0 +1,183 @@ +-- weirdperformance.dll mode: its collector runs one full mark per cycle and +-- then sweeps in chunks, setting the threshold to heap + 128 KB in between. +-- Emulated here as the game would show it through gcinfo()/collectgarbage(). +-- Run: lua5.0 tests/test_weirdperf.lua GentleGC.lua +format = string.format +local now = 1000 +function GetTime() return now end +local combat = false +function UnitAffectingCombat() return combat end +function UnitIsAFK() return false end +local log = {} +DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } +SlashCmdList = {} +FRAMES = {} +function CreateFrame() + local f = { scripts = {} } + function f:RegisterEvent() end + function f:UnregisterEvent() end + function f:SetScript(k, fn) self.scripts[k] = fn end + table.insert(FRAMES, f) + return f +end +function GetWeirdUtilsVersion(m) + if m == "weirdperformance" then return "1.0" end + return { weirdperformance = "1.0" } +end + +-- the emulated collector (the real Lua threshold is kept out of the way) +local realGC, realInfo = collectgarbage, gcinfo +local W = { thr = 0, sweeping = false, left = 0, cycles = 0, chunks = 0 } +local CHUNKS = 3 +local function heap() return (realInfo()) end +local function wpRun() -- the hooked luaC_collectgarbage + if not W.sweeping then + W.cycles = W.cycles + 1 + realGC(); realGC(4000000) -- the mark (the emulation frees everything here) + W.sweeping, W.left = true, CHUNKS + W.thr = heap() + 128 + else + W.chunks = W.chunks + 1 + W.left = W.left - 1 + if W.left <= 0 then + W.sweeping = false + W.thr = math.floor(heap() * 1.25) + else + W.thr = heap() + 128 + end + end +end +gcinfo = function() return heap(), W.thr end +collectgarbage = function(n) -- lua_setgcthreshold + luaC_checkGC + if type(n) == "table" then return realGC(n) end -- (invalid argument: errors, like the game) + W.thr = n or 0 + if heap() >= W.thr then wpRun() end +end +local function engine() if heap() >= W.thr then wpRun() end end + +LIVE = {} +for i = 1, 300000 do LIVE[i] = { i } end +realGC(); realGC(4000000) +W.thr = math.floor(heap() * 1.25) + +dofile(arg[1]) +local F = FRAMES[1] +local HF = FRAMES[2] +event, arg1 = "ADDON_LOADED", "GentleGC"; this = F; F.scripts.OnEvent() +local SF = FRAMES[4] +local cmd = SlashCmdList.GENTLEGC +cmd("debug 2") + +local function count(pat) local n = 0; for _, m in ipairs(log) do if string.find(m, pat) then n = n + 1 end end; return n end +local function lastMatch(pat) for i = table.getn(log), 1, -1 do if string.find(log[i], pat) then return log[i] end end; return "" end +local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end +local junk, ji = {}, 0 +-- one frame: allocate `kb` of garbage, let the collector react, then GentleGC's OnUpdates +-- (allocation is counted in objects, ~9 per KB, so a collection in the middle +-- of the frame doesn't change how much the frame allocates) +local function frame(dt, kb) + local n = math.floor((kb or 0) * 9) + for k = 1, n do + ji = ji + 1; junk[ji] = { ji } + if ji > 20000 then junk, ji = {}, 0 end + if math.mod(k, 50) == 0 then engine() end + end + engine() + now = now + dt + SF.scripts.OnUpdate() + arg1 = dt; this = F; F.scripts.OnUpdate() + if HF.scripts.OnUpdate then HF.scripts.OnUpdate() end +end +local function play(seconds, kbPerSec) + for i = 1, seconds * 10 do frame(0.1, (kbPerSec or 0) / 10) end +end + +print("1. detected") +cmd("status") +check("status names weirdperformance", count("weirdperformance: ") >= 1) + +print("2. the game's cycles and chunks while playing") +cmd("pressure 100") -- GentleGC holds off: the game starts every cycle here +play(40, 400) -- 400 KB/s of garbage +local gameCycles = W.cycles +check("the emulated collector ran cycles and chunks", W.cycles >= 1 and W.chunks >= 2) +check("chunks aren't reported as threshold changes", count("threshold set to") == 0) +check("chunks aren't reported as collections", count("automatic GC detected") <= W.cycles) +check("each game-started cycle reported once", count("weirdperformance cycle started") == W.cycles) +check("no 'didn't run a normal collection' warnings", count("didn't run a normal collection") == 0) +check("no 'did not run' warnings", count("did not run") == 0) + +print("3. GentleGC starts a cycle only when none is running") +while W.sweeping do frame(0.1, 200) end +play(2) +local c0 = W.cycles +cmd("now") +check("GentleGC's collection started a cycle", W.cycles == c0 + 1 and W.sweeping) +check("logged as a cycle start", string.find(lastMatch("manual:"), "started a weirdperformance cycle") ~= nil) +local c1, k1 = W.cycles, W.chunks +cmd("now") +check("mid-cycle, GentleGC does nothing", W.cycles == c1 and W.chunks == k1) + +print("4. no pressure attempts in the middle of a cycle") +cmd("pressure 70") +local before = count("pressure:") +for i = 1, 30 do frame(0.1, 3) end -- 90 KB in all: stays inside one chunk +check("still mid-cycle", W.sweeping) +check("no pressure collection mid-cycle", count("pressure:") == before) + +print("5. the end of a cycle teaches the ratio and the live estimate") +while W.sweeping do frame(0.1, 200) end +play(1) +cmd("status") +check("ratio measured as ~1.25x (read a frame late, so a little lower)", string.find(lastMatch("collector:"), "lowest 1%.2[0-5]x") ~= nil) +local _, _, live = string.find(lastMatch("heap .* live ~"), "live ~([%d%.]+)mb") +check("live estimate matches the heap after the cycle", live and math.abs(tonumber(live) - heap() / 1024) < 1) + +print("6. combat: chunks keep running, the next cycle waits for the fight to end") +-- start a cycle, then pull while it runs +cmd("now") +check("a cycle is running at the pull", W.sweeping) +combat = true +event = "PLAYER_REGEN_DISABLED"; this = F; F.scripts.OnEvent() +check("threshold not raised mid-cycle (chunks continue)", W.thr <= heap() + 256) +while W.sweeping do frame(0.1, 200) end +play(1) +local used = heap() +check("after the cycle ends in combat, the headroom is added", W.thr >= used + 32 * 1024 - 256) +local cyclesInCombat = W.cycles +play(20, 1000) -- 20 MB of garbage in the fight +check("no new cycle during the fight", W.cycles == cyclesInCombat) +combat = false +event = "PLAYER_REGEN_ENABLED"; this = F; F.scripts.OnEvent() +play(2) +cmd("status") +check("status counts cycles and chunks", string.find(lastMatch("weirdperformance: "), "sweep chunks") ~= nil) + +print("6b. a cycle starting in the fight, and the fight ending mid-cycle") +while W.sweeping do frame(0.1, 200) end +play(1) +combat = true +event = "PLAYER_REGEN_DISABLED"; this = F; F.scripts.OnEvent() +local c6 = W.cycles +while W.cycles == c6 do frame(0.1, 100) end -- the fight fills the raised headroom +check("a cycle started in the fight", W.sweeping) +combat = false +event = "PLAYER_REGEN_ENABLED"; this = F; F.scripts.OnEvent() +check("leaving combat mid-cycle keeps the chunk threshold", W.thr <= heap() + 256) +local k6 = W.chunks +play(2, 400) +check("chunks keep running after the fight", W.chunks > k6) + +print("7. freeze detector names weirdperformance's collector") +cmd("pressure 100") +cmd("hitch") +while W.sweeping do frame(0.1, 200) end +play(1) +while heap() < W.thr - 96 and not W.sweeping do frame(0.1, 16) end -- creep up to the threshold... +now = now + 0.4 -- ...and cross it inside a long frame +frame(0.05, 200) +local f = lastMatch("freeze") +check("freeze names weirdperformance", string.find(f, "weirdperformance") ~= nil or string.find(f, "GentleGC collection") ~= nil) +cmd("hitch 0") + +print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED")