diff --git a/bench/README.md b/bench/README.md index c6ae9f6..f6c151a 100644 --- a/bench/README.md +++ b/bench/README.md @@ -369,3 +369,24 @@ setup that builds `text` and `log` is in it) are steadier: The VM was measured again for rebuilding and left: scan_words is 2.0x ruby's and the VM about 30% of it. + +## `recycled_string_pages.rb` + +```sh +ruby bench/recycled_string_pages.rb +MERE_RUBY_GC_LOG=1 ./mere-ruby bench/recycled_string_pages.rb +``` + +The first argument selects temporary allocations (default 100,000); the second +selects explicit collections (default 20). The two calls share an expression, +so the caller's conservative allocation interval includes handles that the +first call has already discarded and collected. The result remains +`[nil, :collected]`; elapsed collection time goes to stderr. With GC logging, +compare the `gc: END live=` counts as well as time. The collector uses the previous sweep's survivor list to skip +collected handles, including holes inside pages that still hold live strings. + +Measured on Apple silicon at O2 with Mere `01ed9680` (2026-11-07), using +100,000 temporary allocations and 20 collections, the median of three runs +changed from 0.352 seconds to 0.021 seconds. Peak mark-table entries in these +runs changed from 301,767 to 1,772. Both builds print the same result. These +numbers measure this collector workload, not the duration of the CI workflow. diff --git a/bench/recycled_string_pages.rb b/bench/recycled_string_pages.rb new file mode 100644 index 0000000..cb68682 --- /dev/null +++ b/bench/recycled_string_pages.rb @@ -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')))] diff --git a/corpus/499_gc_collected_string_handles.rb b/corpus/499_gc_collected_string_handles.rb new file mode 100644 index 0000000..88194e1 --- /dev/null +++ b/corpus/499_gc_collected_string_handles.rb @@ -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)] diff --git a/main.mere b/main.mere index e55306c..0779017 100644 --- a/main.mere +++ b/main.mere @@ -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); 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 -> ()