YRC: refinements plus a benchmark that is more realistic

This commit is contained in:
Araq
2026-08-03 12:33:08 +02:00
parent 51b638c301
commit 67144c81dc
2 changed files with 35 additions and 38 deletions

View File

@@ -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

View File

@@ -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]]