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
26 changes: 26 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,29 @@ file-size-dependent work from the line reader.

The foreach runs ranged from 30.3–32.4 seconds before and 15.6–21.2 seconds
after. These are local workload measurements, not an end-to-end CI speedup.

## `buffered_file_writes.rb`

```sh
ruby bench/buffered_file_writes.rb [writes]
./mere-ruby bench/buffered_file_writes.rb [writes]
```

The default performs 16,384 small writes before flushing. Stdout checks the
position, persisted size, and complete contents; elapsed time goes to stderr.
The buffer now appends to the existing String's chunk queue instead of copying
its entire prefix and allocating another String handle for every write.

On Apple silicon at O2 with Mere `991f299b`, against mere-ruby `23ce853`
(2026-11-07), two local runs reduced peak process RSS from 409–526 MiB to
251–252 MiB. Whole-process wall times were 5.12–5.53 seconds before and
4.44–4.62 seconds after. Other validation was running concurrently, so these
wall times are indicative rather than an isolated timing comparison. Ruby
4.0.9 prints the same checks.

A bounded run of Ruby 4.0.9's `fileutils/test_fileutils.rb`, with both test
ordering and `Kernel.srand` fixed to 1, completed with peak RSS of 304 MiB.
Two runs before this change used 1,549–1,793 MiB. All runs reached the same
117 tests and retained the same five existing failures; this measures resource
usage, not full FileUtils compatibility. The CI limits and test inputs are
unchanged.
15 changes: 15 additions & 0 deletions bench/buffered_file_writes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
require 'tmpdir'

count = Integer(ARGV.fetch(0, '16384'))
piece = "aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa\n"
Dir.mktmpdir do |dir|
path = File.join(dir, 'data')
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
File.open(path, 'wb') do |file|
count.times { file.write(piece) }
file.flush
p [file.pos, File.size(path)]
end
warn "writes=#{count} seconds=#{Process.clock_gettime(Process::CLOCK_MONOTONIC) - start}"
p File.binread(path) == piece * count
end
59 changes: 59 additions & 0 deletions corpus/501_buffered_file_writes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Small appends must survive collection, seeking, truncation, and independent
# handles. File#dup starts with its own flushed write buffer.
require 'tmpdir'

Dir.mktmpdir do |dir|
path = File.join(dir, 'data')
expected = ''
File.open(path, 'w+b') do |f|
1200.times do |i|
piece = [i % 256, 0, 255].pack('C*')
expected << piece
f.write(piece)
GC.start if i % 200 == 0
Comment thread
284km marked this conversation as resolved.
Resolved
end
p f.pos
f.flush
p File.binread(path) == expected
f.seek(10)
f.write('xy')
f.write('z')
f.flush
expected[10, 3] = 'xyz'
p File.binread(path) == expected
f.truncate(20)
f.seek(0, IO::SEEK_END)
f.write('tail')
f.rewind
p f.read == expected[0, 20] + 'tail'
end

File.open(path, 'ab') do |f|
f.write('a')
f.write('b')
f.seek(0)
f.write('c')
end
p File.binread(path).end_with?('tailabc')

File.open(path, 'wb') do |f|
f.write('first')
other = f.dup
other.seek(0, IO::SEEK_END)
other.write('second')
other.flush
f.seek(0, IO::SEEK_END)
f.write('third')
f.flush
other.close
end
p File.binread(path)

File.open(path, 'wb') do |f|
f.sync = true
f.write('a')
p File.binread(path)
f.print('b', 'c')
p File.binread(path)
end
end
14 changes: 9 additions & 5 deletions main.mere
Original file line number Diff line number Diff line change
Expand Up @@ -45557,18 +45557,22 @@ and file_univ_code = fn ivars -> fn (k: str) -> fn (mode: str) ->
and file_write_at = fn world -> fn (id: int) -> fn (piece: str) ->
let (_, _, _, iv9) = world in
let k9 = str_of_int id in
let cur9 = (match (if map_has iv9 (k9 ++ "@__f_buf") then map_get iv9 (k9 ++ "@__f_buf") else VNil) with
| VStr h9 -> sget h9 | _ -> "") in
let buf9 = (if map_has iv9 (k9 ++ "@__f_buf") then map_get iv9 (k9 ++ "@__f_buf") else VNil) in
// Do not flatten the pending chunks just to ask whether the buffer is
// empty: repeated small writes must not copy all preceding bytes.
let empty9 = (match buf9 with VStr h9 -> str_blen h9 == 0 | _ -> true) in
Comment thread
284km marked this conversation as resolved.
Resolved
let pos9 = (match (if map_has iv9 (k9 ++ "@__f_pos") then map_get iv9 (k9 ++ "@__f_pos") else VNil) with
| VInt n9 -> n9 | _ -> 0) in
// what #ungetc pushed back is unread: a write goes where the program's
// position is (ruby drops its read buffer and seeks back), so
// `read(3); ungetc("c"); write("Z")` writes at 2, not after the "c"
let ugl9 = file_unget_len iv9 k9 in
let pos9 = (if ugl9 > 0 && str_eq cur9 ""
let pos9 = (if ugl9 > 0 && empty9
then (let _ = map_delete iv9 (k9 ++ "@__f_unget") in pos9 - ugl9) else pos9) in
let _ = (if str_eq cur9 "" then map_set iv9 (k9 ++ "@__f_wpos") (VInt pos9) else ()) in
let _ = map_set iv9 (k9 ++ "@__f_buf") (mkstr (cur9 ++ piece)) in
let _ = (if empty9 then map_set iv9 (k9 ++ "@__f_wpos") (VInt pos9) else ()) in
let _ = (match buf9 with
| VStr h9 -> str_push h9 piece

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.

Positive: The existing queued String append is preserved in the merged code. Its buffered-write regression also passes alongside the rebased positioned-read implementation in #33 with indexing ON and OFF.

| _ -> map_set iv9 (k9 ++ "@__f_buf") (mkstr piece)) in
map_set iv9 (k9 ++ "@__f_pos") (VInt (pos9 + str_len piece))

and file_truncate = fn world -> fn (path: str) -> fn (len: int) ->
Expand Down
Loading