Repository navigation
perf: skip collected string handles during GC marking #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| # Run with CRuby or mere-ruby. MERE_RUBY_GC_LOG=1 also reports mark-table sizes. | ||
| # The first call's temporary strings die before the second call collects, | ||
| # while the enclosing expression still has an allocation interval to scan. | ||
| def discard_strings(count) | ||
| count.times { |i| i.to_s + 'temporary' } | ||
| GC.start | ||
| nil | ||
| end | ||
|
|
||
| def collect_repeatedly(count) | ||
| start = Process.clock_gettime(Process::CLOCK_MONOTONIC) | ||
| count.times { GC.start } | ||
| warn "collections=#{count} seconds=#{Process.clock_gettime(Process::CLOCK_MONOTONIC) - start}" | ||
| :collected | ||
| end | ||
|
|
||
| p [discard_strings(Integer(ARGV.fetch(0, '100000'))), | ||
| collect_repeatedly(Integer(ARGV.fetch(1, '20')))] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # Keep sparse strings, including empty strings, while many temporary strings | ||
| # become unreachable. Collection must preserve aliases, encodings and captures. | ||
| kept = [] | ||
| 6.times do |round| | ||
| 12000.times do |i| | ||
| value = "#{round}:#{i}" | ||
| kept << [value, value.dup, ''.encode(Encoding::UTF_16LE)] if i % 6000 == 0 | ||
| end | ||
| GC.start | ||
| end | ||
| p kept.map { |a, b, empty| [a == b, empty.empty?, empty.encoding] }.uniq | ||
| kept.each { |a, b, empty| a << '!'; empty << 'x'.encode(Encoding::UTF_16LE) } | ||
| GC.start | ||
| p kept.all? { |a, b, empty| a == b + '!' && empty.encode('UTF-8') == 'x' } | ||
| p kept.first[1], kept.last[1] | ||
|
|
||
| fiber = Fiber.new do | ||
| local = 'fiber'.dup | ||
| empty = +'' | ||
| Fiber.yield [local, empty] | ||
| GC.start | ||
| [local, empty] | ||
| end | ||
| aliases = fiber.resume | ||
| captured = -> { aliases } | ||
| 3.times do | ||
| 10000.times { |i| i.to_s * 2 } | ||
| GC.start | ||
| end | ||
| aliases[0] << '!' | ||
| aliases[1] << 'retained' | ||
| p fiber.resume == captured.call | ||
| p captured.call | ||
|
|
||
| # The enclosing expression spans allocations from an earlier call, including | ||
| # strings already collected by that call. Old survivors and fresh strings | ||
| # must both remain usable when the next call collects. | ||
| def discard_string_handles | ||
| 20000.times { |i| i.to_s + 'discarded' } | ||
| GC.start | ||
| nil | ||
| end | ||
|
|
||
| def retain_string_handles(old) | ||
| fresh = ['fresh'.dup, ''.encode(Encoding::UTF_32BE)] | ||
| GC.start | ||
| old[0] << '?' | ||
| fresh[1] << 'y'.encode(Encoding::UTF_32BE) | ||
| GC.start | ||
| [old[0], fresh[0], fresh[1].encode('UTF-8')] | ||
| end | ||
| p [discard_string_handles, retain_string_handles(aliases)] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -863,15 +863,49 @@ and gc_mark_vals = fn live -> fn (xs: Val list) -> | |
| | Cons (v, rest) -> let _ = gc_mark_val live v in gc_mark_vals live rest; | ||
| // an id a half-evaluated statement built (fb_stack_wm): kind 0 object, 1 array, | ||
| // 2 string, 3 hash. An id no store holds any more is skipped, not dereferenced. | ||
| // (an object id is marked only if it is an object still: a stopped stack's | ||
| // An object id is marked only if it is an object still: a stopped stack's | ||
| // interval runs over every id made since its statement began, and with two | ||
| // thousand stopped fibers each collection put millions of dead ids into the | ||
| // live set -- the arrays, strings and hashes were already asked; note 275) | ||
| // live set; arrays and hashes likewise check their stores (note 275). | ||
| // String slots keep their ids after collection, so range checks alone do not | ||
| // establish that a string is still present. | ||
| // The previous sweep's survivors are sorted by handle: it keeps the old | ||
| // survivors in order, then appends the newly allocated slots in order. | ||
| let gc_str_surv_lower = fn (slot: int) -> | ||
| let rec search = fn (lo: int) -> fn (hi: int) -> | ||
| if lo >= hi then lo | ||
| else (let mid = lo + (hi - lo) / 2 in | ||
| if vec_get gc_str_surv mid < slot then search (mid + 1) hi | ||
| else search lo mid) in | ||
| search 0 (vec_get gc_knob 10); | ||
| let gc_str_present = fn (id: int) -> | ||
| let slot = id - handle_base in | ||
| if slot < 0 || slot >= str_count () then false | ||
| else if slot >= vec_get gc_knob 8 then true | ||
| else (let j = gc_str_surv_lower slot in | ||
| j < vec_get gc_knob 10 && vec_get gc_str_surv j == slot); | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good distinction between logical and physical length: gc_str_surv can retain stale entries after its live prefix has been compacted. Searching only the prefix counted by gc_knob[10], while treating allocations since gc_knob[8] separately, avoids reviving those stale entries and keeps genuinely live empty strings eligible for marking.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for checking the sweep invariant. The search remains bounded by the logical survivor prefix, and newly allocated slots are handled from the previous allocation boundary. This keeps stale physical entries out of the mark set while preserving live empty strings. No code change is needed here. |
||
| let gc_mark_wm = fn live -> fn (k: int) -> fn (n: int) -> | ||
| if k == 0 then (if map_has obj_cls n then gc_mark_val live (VObj n) else ()) | ||
| else if k == 1 then (if map_has arr_store n then gc_mark_val live (VArr n) else ()) | ||
| else if k == 2 then (if n >= handle_base && n - handle_base < str_count () then gc_mark_val live (VStr n) else ()) | ||
| else if k == 2 then (if gc_str_present n then gc_mark_val live (VStr n) else ()) | ||
| else (if map_has hash_store n then gc_mark_val live (VHsh n) else ()); | ||
| // A conservative interval may include strings an earlier sweep already | ||
| // reclaimed. Visit only its old survivors and its slots allocated since that | ||
| // sweep. Empty live strings are survivors too; their contents are not a test | ||
| // for liveness. Marking never mutates this survivor list (the sweep does). | ||
| let gc_mark_str_interval = fn live -> fn (lo: int) -> fn (up: int) -> | ||
| let first = (if lo < handle_base then 0 else lo - handle_base) in | ||
| let stop = (if up - handle_base > str_count () then str_count () else up - handle_base) in | ||
| let rec old = fn (j: int) -> | ||
| if j >= vec_get gc_knob 10 then () | ||
| else (let slot = vec_get gc_str_surv j in | ||
| if slot >= stop then () | ||
| else (let _ = gc_mark_val live (VStr (handle_base + slot)) in old (j + 1))) in | ||
| let rec fresh = fn (slot: int) -> | ||
| if slot >= stop then () | ||
| else (let _ = gc_mark_val live (VStr (handle_base + slot)) in fresh (slot + 1)) in | ||
| let _ = old (gc_str_surv_lower first) in | ||
| fresh (if first > vec_get gc_knob 8 then first else vec_get gc_knob 8); | ||
|
|
||
| // A number some stack can still reach (fb_scan_stack), which may be a handle | ||
| // of any kind: it is marked as each kind a store still holds. Conservative -- | ||
|
|
@@ -890,7 +924,9 @@ let gc_mark_wm_merged = fn live -> fn (ivs: Vec['v, (int * int * int)]) -> | |
| else (let (k9, lo9, up9) = (vec_get ivs i : (int * int * int)) in | ||
| tolist (i - 1) k (if k9 == k then Cons ((lo9, up9), acc) else acc))) in | ||
| let rec each = fn (k: int) -> fn (n: int) -> fn (up: int) -> | ||
| (if n >= up then () else (let _ = gc_mark_wm live k n in each k (n + 1) up)) in | ||
| (if n >= up then () | ||
| else if k == 2 then gc_mark_str_interval live n up | ||
| else (let _ = gc_mark_wm live k n in each k (n + 1) up)) in | ||
| let rec go = fn (k: int) -> fn (ys: (int * int) list) -> fn (cur: int) -> | ||
| (match ys with | ||
| | Nil -> () | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The empty encoded strings are useful coverage here: a live empty string and a reclaimed slot can have identical contents, so content-based liveness would silently lose encoding or identity. This regression matched Ruby in both index modes, including stress period 100.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for independently checking this in both index modes with stress period 100. I have retained the empty encoded strings and the alias/capture checks. The PR description now records your additional validation and explicitly leaves the full regression at stress period 1 inconclusive after the 180-second deadline; it is not counted as a pass. All 29 hosted CI jobs have also completed successfully.