From c3e88ed977230fbdba71b6f44182099921f0d79e Mon Sep 17 00:00:00 2001 From: 284km Date: Sat, 7 Nov 2026 13:34:38 +0900 Subject: [PATCH] Avoid copying the accumulated File write buffer on every append --- bench/README.md | 26 +++++++++++++ bench/buffered_file_writes.rb | 15 ++++++++ corpus/501_buffered_file_writes.rb | 59 ++++++++++++++++++++++++++++++ main.mere | 14 ++++--- 4 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 bench/buffered_file_writes.rb create mode 100644 corpus/501_buffered_file_writes.rb diff --git a/bench/README.md b/bench/README.md index 12f2b1f..5a6920b 100644 --- a/bench/README.md +++ b/bench/README.md @@ -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. diff --git a/bench/buffered_file_writes.rb b/bench/buffered_file_writes.rb new file mode 100644 index 0000000..bade5e7 --- /dev/null +++ b/bench/buffered_file_writes.rb @@ -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 diff --git a/corpus/501_buffered_file_writes.rb b/corpus/501_buffered_file_writes.rb new file mode 100644 index 0000000..32172f6 --- /dev/null +++ b/corpus/501_buffered_file_writes.rb @@ -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 + 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 diff --git a/main.mere b/main.mere index b713f6d..3340e85 100644 --- a/main.mere +++ b/main.mere @@ -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 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 + | _ -> 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) ->