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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ jobs:
run: sh tools/build.sh
- name: Record binary provenance
run: python3 unittest/ci.py build-info --compiler .mere-compiler --output build-info.json
- name: Check deferred string-page compaction
run: python3 unittest/string_page_compact_check.py
- uses: actions/upload-artifact@v4
with:
name: mere-ruby-macos
Expand Down Expand Up @@ -567,6 +569,8 @@ jobs:
run: sh tools/build.sh
- name: Check C nesting headroom
run: MR_C=mr.c sh tools/bracket_depth_check.sh
- name: Check deferred string-page compaction
run: python3 unittest/string_page_compact_check.py
- name: Smoke test
run: |
ulimit -s 524288
Expand Down
22 changes: 22 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,28 @@ 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.

## `unchanged_string_pages.rb`

```sh
ruby bench/unchanged_string_pages.rb [strings] [collections]
./mere-ruby bench/unchanged_string_pages.rb [strings] [collections]
```

Keep 20,000 strings across 20 collections, check every value, mutate a subset,
and collect and check them again. Compaction now copies a live string page
only after allocation or mutation changed it; completely dead pages still
return to the pool. The mutation flag is separate from byte-accounting
dirtiness, which can be cleared between collections. The flag is cleared
only when compaction reduces the page's capacity: the compiler's unit result
does not distinguish a copy from a pin-induced deferral. Deferred and
equal-capacity compactions therefore remain pending for another collection.
Retired arenas remain managed by the runtime's global retirement list.

On Apple silicon at O2 with Mere `991f299b`, compared with `a4eb486`
(2026-11-07), two runs changed the timed collections from 0.057–0.058 seconds
to 0.035–0.039 seconds. Both validation checks match Ruby 4.0.9. This measures
the collection workload, not whole-process or CI duration.

## `exit_gc_workload.rb`

```sh
Expand Down
12 changes: 12 additions & 0 deletions bench/unchanged_string_pages.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Keep strings across repeated collections, then change some old strings.
count = Integer(ARGV.fetch(0, '20000'))
rounds = Integer(ARGV.fetch(1, '20'))
strings = Array.new(count) { |i| ('x' * 256) + i.to_s }
GC.start
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
rounds.times { GC.start }
warn "collections=#{rounds} seconds=#{Process.clock_gettime(Process::CLOCK_MONOTONIC) - start}"
p strings.each_with_index.all? { |s, i| s == ('x' * 256) + i.to_s }
strings.each_with_index { |s, i| s.replace("changed#{i}") if i % 499 == 0 }
GC.start
p strings.each_with_index.all? { |s, i| s == (i % 499 == 0 ? "changed#{i}" : ('x' * 256) + i.to_s) }
32 changes: 32 additions & 0 deletions corpus/505_string_pages_across_collections.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
strings = Array.new(9000) { |i| "value#{i}" }
empty = Array.new(50) { '' }
binary = "a\0\xff".b
held = Fiber.new do
strings[4096].each_char { |char| Fiber.yield(char) }
:done
end
first = held.resume
3.times { GC.start }
p strings.each_with_index.all? { |s, i| s == "value#{i}" }
p [empty.all?(&:empty?), binary.bytes]

# Old pages must become eligible for compaction again after a mutation.
strings[0].replace('changed')
strings[4095] << ' appended'
strings[8192].clear
binary << "\x80".b
3.times { GC.start }
p [strings[0], strings[4095], strings[8192], binary.bytes]
rest = ''
while held.alive?
char = held.resume
rest << char unless char == :done
end
p first + rest

# Drop the old values and exercise pages reused by fresh allocations.
strings = nil
GC.start
fresh = Array.new(9000) { |i| i.to_s }
GC.start
p fresh.each_with_index.all? { |s, i| s == i.to_s }
22 changes: 19 additions & 3 deletions m_state.mere
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,9 @@ let str_count = fn (u: unit) -> vec_get str_n 0;
// (str_pg_dq) and str_bytes measures just those, keeping the sum.
let str_pg_bytes = vec_new ();
let str_pg_dirty = vec_new ();
// Measurement dirtiness is cleared by str_bytes. Compaction needs its own
// mutation bit: an unchanged live page has no overwritten bytes to reclaim.
let str_pg_changed = vec_new ();
Comment thread
284km marked this conversation as resolved.
Resolved
let str_pg_dq = vec_new ();
let str_pg_dq_n = vec_new ();
let _ = vec_push str_pg_dq_n 0;
Expand All @@ -697,10 +700,19 @@ let sput = fn (id: int) -> fn (s: str) ->
let i = id - handle_base in
let pg = vec_get str_store (i / 4096) in
let o = i - (i / 4096) * 4096 in
if o < vec_len pg then (let _ = str_pg_touch (i / 4096) in vec_set pg o s) else ();
if o < vec_len pg then
(let _ = vec_set str_pg_changed (i / 4096) 1 in
let _ = str_pg_touch (i / 4096) in vec_set pg o s) else ();
// (the sweep: surv holds the slots that lived, k of them -- a page with none
// is wound back, the others compacted unless nc)
let str_pg_live = vec_new ();
// vec_compact returns unit even when a native pin defers the copy. Only a
// strict capacity decrease proves reclamation with the current compiler API.
// Equal-capacity copies conservatively remain pending for another collection.
let str_pg_try_compact = fn pg ->
let before = vec_bytes pg in
let _ = vec_compact pg in
vec_bytes pg < before;
let str_pages_sweep = fn surv -> fn (k: int) -> fn (nc: bool) ->
let rec pz = fn (p: int) -> (if p >= vec_len str_store then () else (let _ = (if p < vec_len str_pg_live then vec_set str_pg_live p 0 else vec_push str_pg_live 0) in pz (p + 1))) in
let _ = pz 0 in
Expand All @@ -719,8 +731,10 @@ let str_pages_sweep = fn surv -> fn (k: int) -> fn (nc: bool) ->
let _ = vec_set str_pg_pool_n 0 (n9 + 1) in
let _ = vec_set str_store p str_pg_empty in
vec_set str_pg_dead p 1)
else if nc then ()
else (let _ = str_pg_touch p in vec_compact (vec_get str_store p))) in
else if nc || vec_get str_pg_changed p == 0 then ()
else (let _ = str_pg_touch p in
if str_pg_try_compact (vec_get str_store p)
then vec_set str_pg_changed p 0 else ())) in
Comment thread
284km marked this conversation as resolved.
Resolved
pw (p + 1))) in
pw 0;
// the bytes of the pages not wound back: the kept sum, after measuring the
Expand Down Expand Up @@ -767,12 +781,14 @@ let mkstr = fn (s: str) ->
vec_push str_store pg)) in
let _ = vec_push str_pg_bytes 0 in
let _ = vec_push str_pg_dirty 0 in
let _ = vec_push str_pg_changed 1 in
let _ = vec_push str_pg_dead 0 in
// (the page it fills next, and the one it filled, are measured)
let _ = (if n > 0 then str_pg_touch (n / 4096 - 1) else ()) in
str_pg_touch (n / 4096))
else ()) in
let _ = vec_set str_pg_add 0 (vec_get str_pg_add 0 + str_len s) in
let _ = vec_set str_pg_changed (n / 4096) 1 in
let _ = vec_push (vec_get str_store (n / 4096)) s in
let _ = vec_set str_n 0 (n + 1) in
VStr (handle_base + n);
Expand Down
63 changes: 63 additions & 0 deletions unittest/string_page_compact_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Exercise the production compaction decision against the pinned Vec runtime."""
import os
from pathlib import Path
import re
import subprocess
import tempfile

ROOT = Path(__file__).resolve().parent.parent
compiler = os.environ.get('MERE', str(ROOT / '.mere-compiler/_build/default/bin/mere.exe'))
state = (ROOT / 'm_state.mere').read_text()
# Compile the actual production helper, rather than duplicating its decision
# in a C model. The controlled pin exercises both native early-return paths.
helper = re.search(r'let str_pg_try_compact = fn pg ->.*?;', state, re.S)
assert helper, 'production compaction helper not found'
program = '''
extern fn test_set_pin: int -> int;
let pg = vec_new ();
let _ = vec_push pg "kept";
let _ = vec_compact pg;
let _ = vec_set pg 0 (str_repeat "x" 8388608);
let _ = vec_set pg 0 "kept";
let before = vec_bytes pg;
let _ = print_bool (before > 8388608);
let _ = test_set_pin 1;
// First establish the native baseline; then defer for below-threshold growth.
let _ = print_bool (not (str_pg_try_compact pg));
let _ = print_bool (not (str_pg_try_compact pg));
Comment thread
284km marked this conversation as resolved.
Resolved
let _ = print_bool (vec_bytes pg == before);
let _ = print_bool (vec_get pg 0 == "kept");
let _ = test_set_pin 0;
// No intervening page mutation: the pending work must reclaim old storage.
let _ = print_bool (str_pg_try_compact pg);
let _ = print_bool (vec_bytes pg == 4096);
let _ = print_bool (vec_get pg 0 == "kept");
// Equal-capacity copies cannot be distinguished from deferrals; retry safely.
let _ = print_bool (not (str_pg_try_compact pg));
let _ = print_bool (vec_get pg 0 == "kept");
'''
native = '''
static int test_forced_pin(__lang_region *region, int growth) {
(void)region; (void)growth;
return 1;
}
int test_set_pin(int enabled) {
__lang_region_pinned = enabled ? test_forced_pin : 0;
return 0;
}
'''
with tempfile.TemporaryDirectory(prefix='mere-string-page-') as directory:
path = Path(directory)
source = path / 'check.mere'
source.write_text(helper[0] + '\n' + program)
emitted = subprocess.run([compiler, '-c', str(source)], capture_output=True,
check=True, timeout=60).stdout
cfile = path / 'check.c'
cfile.write_bytes(emitted + native.encode())
binary = path / 'check'
subprocess.run(['clang', '-O2', '-w', '-fbracket-depth=1024', str(cfile),
'-lm', '-o', str(binary)], check=True, timeout=60)
result = subprocess.run([str(binary)], capture_output=True, check=True, timeout=20)
assert result.stdout == b'true\n' * 10, result.stdout
print('String page compaction: deferred pins retry and reclaim without another write')
Loading