diff --git a/lib/system/yrc.nim b/lib/system/yrc.nim index 6c2ae06385..67d589aad0 100644 --- a/lib/system/yrc.nim +++ b/lib/system/yrc.nim @@ -1272,12 +1272,26 @@ proc demoteTouchedDead(j: var GcEnv; cap: ptr CaptureBufs) = # its out-edges still count toward its targets' rc. If that pruned # cell is itself dead (promoted while live, died later this epoch), # its phantom references inflate unrelated SCCs' external counts. - # Keeping ONE member of every survivor registered guarantees - # re-examination until the epoch advances. Cheap in practice: - # pruning keeps the captured set small. RC-dead stamped targets are - # additionally queued in prunedTgt (roots bypass stamps). + # Keeping ONE member of every survivor examinable is what catches + # that. RC-dead stamped targets are additionally queued in prunedTgt + # (roots bypass stamps). + # + # A SUSPECT, not a root: the epoch advance is the only thing that can + # ever settle these. Re-examining a survivor without tracing the + # pruned cell reproduces the same verdict, so as roots they are + # captured, survive, and re-register every single collection — a loop + # that cannot converge and that grows the captured set without bound + # in exactly the workloads pruning is meant to speed up (on the + # generational bench, 56% of all captures and 84% of the repeats). + # The suspect buffer keeps the cell just as findable: same + # inRootsFlag ownership, forced live by computeDeadness and refused + # by free while listed, spilled into the root set by the epoch + # advance, by thread exit and by GC_fullCollect (which loops until + # quiet). Dropping the registration ENTIRELY instead is unsound and + # leaks: a survivor that is neither root nor suspect is invisible + # forever, and no later full collect can find it again. let m = cap.sccMembers.d[cap.sccs.d[s].memStart] - registerLocal(cap.recs.d[m].cell, cap.recs.d[m].desc) + rememberGenSuspect(cap.recs.d[m].cell, cap.recs.d[m].desc, addr gCtx) proc validateDead(j: var GcEnv; cap: ptr CaptureBufs) = ## Demote every dead SCC that a mutator touched during capture: dirty via diff --git a/tests/benchmarks/yrcbech.nim b/tests/benchmarks/yrcbech.nim index a72ae1d83d..587a0dc5be 100644 --- a/tests/benchmarks/yrcbech.nim +++ b/tests/benchmarks/yrcbech.nim @@ -8,28 +8,32 @@ discard """ ## ## Each thread runs its own private copy of the torcbench workload: one long ## doubly-linked list of strings, and a stream of short-lived cyclic trees -## whose every node embeds a copy of the list header — that is two references +## whose every node embeds a copy of the list header — that is a reference ## into the list per tree node. Nothing is shared between threads, so the same ## program is a fair measurement under --mm:orc and --mm:yrc. ## -## Three changes vs torcbench, each needed to make the young -> old pattern +## Two changes vs torcbench, each needed to make the young -> old pattern ## measurable rather than incidental: ## ## 1. The list is built once per THREAD, not once per outer iteration, so it ## survives long enough to be promoted. It is the old generation; the trees ## are the young one. -## 2. A seed phase traces and stamps the list before the tree traffic starts. -## Needed because `flagDirty` is per-SCC and the list is ONE SCC: once trees -## are churning, every `parent` copy touches head and tail, so the SCC is -## permanently dirty and would never get stamped. Promoting it first makes -## the stamp carry it through the young phase. -## 3. Collection runs at a fixed cadence (GC_partialCollect per outer +## 2. Collection runs at a fixed cadence (GC_partialCollect per outer ## iteration) instead of being left to each collector's threshold ## heuristic. Without this the benchmark measures how often each collector ## decides to collect rather than what a collection over this heap costs — ## and ORC's threshold scales with heap size, so a bigger list makes it ## collect LESS and the re-trace it is supposed to be paying never appears. ## +## Nothing seeds the promotion: the tree stream itself is what ages the list, +## which is why the workload can stay an ordinary one. `DoublyLinkedNode.prev` +## and `DoublyLinkedList.tail` are `{.cursor.}`, so copying a `parent` header +## incRefs `head` alone, and the list is a chain of one-node SCCs rather than +## a single big one. Dirtiness is per-SCC, so the churn only ever dirties +## `head`; every node behind it is traced clean by the collections the trees +## trigger anyway and promotes after YrcPromoteAge of them. The capture then +## prunes one edge in — at `head.next` — instead of walking 60000 nodes. +## ## --mm:orc every collection follows `parent` into the list and re-traces ## all ListLen nodes of it. ## --mm:yrc once the list is promoted, capture prunes at the epoch-stamp @@ -41,8 +45,8 @@ discard """ ## the default because 8 threads on a 4-performance-core machine dilutes the ## result (1.15x vs 1.69x measured on an M1). ## -## nim c -r --mm:orc -d:release --threads:on yrcbench.nim -## nim c -r --mm:yrc -d:release --threads:on yrcbench.nim +## nim c -r --mm:orc -d:release --threads:on yrcbech.nim +## nim c -r --mm:yrc -d:release --threads:on yrcbech.nim ## ## Add -d:yrcBenchTime for a wall-clock line, -d:nimOrcStats for capture and ## prune counts (YRC only). @@ -55,23 +59,13 @@ const ListLen {.intdefine.} = 60000 ## the old generation TreeIters {.intdefine.} = 50 ## young trees per collection TreeDepth {.intdefine.} = 8 - SeedProbes {.intdefine.} = 6 ## must exceed YrcPromoteAge (default 3) type Node = ref object - parent: DoublyLinkedList[string] ## copy of the header: 2 refs into the list + parent: DoublyLinkedList[string] ## copy of the header: a ref into the list le, ri: Node self: Node ## self-cycle, forces cycle detection - Holder = ref object - ## Cyclic handle on the list, used only to get the list traced during the - ## seed phase. A plain reference would never be registered as a cycle - ## candidate; the self-edge is what makes it one. - list: DoublyLinkedList[string] - self: Holder - -var probeSlot {.threadvar.}: Holder - proc buildTree(parent: DoublyLinkedList[string]; depth: int): Node = if depth == 0: result = nil @@ -90,21 +84,10 @@ proc threadWork() {.thread.} = for j in 1 .. ListLen: leakList.append(newString(200)) - # (2) seed phase: age the list past the promotion threshold while nothing - # else is churning. GC_partialCollect, not GC_fullCollect — the latter - # advances the epoch and wipes the stamps this depends on. - block: - let holder = Holder(list: leakList) - holder.self = holder - for _ in 1 .. SeedProbes: - probeSlot = holder - probeSlot = nil - GC_partialCollect(0) - for i in 1 .. OuterIters: for k in 0 .. TreeIters: discard buildTree(leakList, TreeDepth) # young: dead the moment it returns - GC_partialCollect(0) # (3) fixed cadence + GC_partialCollect(0) # (2) fixed cadence var threads: array[NumThreads, Thread[void]]