Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 18 additions & 0 deletions bench/recycled_string_pages.rb
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')))]
52 changes: 52 additions & 0 deletions corpus/499_gc_collected_string_handles.rb
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

Copy link
Copy Markdown
Owner Author

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.

Copy link
Copy Markdown
Owner Author

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.

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)]
44 changes: 40 additions & 4 deletions main.mere
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 --
Expand All @@ -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 -> ()
Expand Down
Loading