From 9304407354b1ba9bc5f55f3c0f34dde9876fee60 Mon Sep 17 00:00:00 2001 From: mizchi Date: Mon, 20 Jul 2026 23:31:18 +0900 Subject: [PATCH 1/3] perf(stringbuilder): reuse unshared reset buffer --- builtin/stringbuilder_buffer.mbt | 20 +++++++++++++++----- builtin/stringbuilder_wbtest.mbt | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 builtin/stringbuilder_wbtest.mbt diff --git a/builtin/stringbuilder_buffer.mbt b/builtin/stringbuilder_buffer.mbt index e275d7680..159281b42 100644 --- a/builtin/stringbuilder_buffer.mbt +++ b/builtin/stringbuilder_buffer.mbt @@ -16,6 +16,7 @@ struct StringBuilder { mut data : FixedArray[UInt16] mut len : Int + mut buffer_is_shared : Bool } ///| @@ -33,7 +34,7 @@ struct StringBuilder { pub fn StringBuilder::StringBuilder(size_hint? : Int = 0) -> StringBuilder { let initial = if size_hint < 1 { 1 } else { (size_hint + 1) / 2 } let data : FixedArray[UInt16] = FixedArray::make(initial, 0) - { data, len: 0 } + { data, len: 0, buffer_is_shared: false } } ///| @@ -84,6 +85,7 @@ fn StringBuilder::grow(self : StringBuilder, required : Int) -> Unit { len=self.len, ) self.data = new_data + self.buffer_is_shared = false } ///| @@ -184,6 +186,9 @@ pub fn StringBuilder::to_string(self : StringBuilder) -> String { if self.len == 0 { "" } else if self.len == self.data.length() { + // This conversion can reuse `data` without copying, so future resets must + // detach before the buffer is written again. + self.buffer_is_shared = true unsafe_fixedarray_uint16_to_string(self.data) } else { let data = FixedArray::make_and_blit( @@ -210,9 +215,14 @@ pub impl Show for StringBuilder with fn to_string(self) { ///| /// Resets the string builder to an empty state. pub fn StringBuilder::reset(self : StringBuilder) -> Unit { - self.data = FixedArray::make( - self.data.length(), - (Default::default() : UInt16), - ) + // A full buffer may have been returned directly from `to_string`. Retain the + // backing storage in every other case, since it only contains value types. + if self.buffer_is_shared { + self.data = FixedArray::make( + self.data.length(), + (Default::default() : UInt16), + ) + self.buffer_is_shared = false + } self.len = 0 } diff --git a/builtin/stringbuilder_wbtest.mbt b/builtin/stringbuilder_wbtest.mbt new file mode 100644 index 000000000..c7c007d24 --- /dev/null +++ b/builtin/stringbuilder_wbtest.mbt @@ -0,0 +1,28 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +#cfg(not(target="js")) +test "StringBuilder::reset reuses an unshared buffer" { + let builder = StringBuilder(size_hint=64) + let backing = builder.data + builder.write_string("hello") + let first = builder.to_string() + builder.reset() + builder.write_string("bye") + assert_true(first == "hello") + assert_true(backing[0] == ('b' : UInt16)) + assert_true(backing[1] == ('y' : UInt16)) + assert_true(backing[2] == ('e' : UInt16)) +} From 256b5e7ced3fc241d0f29b34ef12d597f228cc48 Mon Sep 17 00:00:00 2001 From: mizchi Date: Tue, 25 Aug 2026 01:01:00 +0900 Subject: [PATCH 2/3] perf(stringbuilder): avoid initializing spare capacity --- builtin/stringbuilder_buffer.mbt | 53 ++++++++++++++++++++++---------- builtin/stringbuilder_wbtest.mbt | 19 ++++++++++++ 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/builtin/stringbuilder_buffer.mbt b/builtin/stringbuilder_buffer.mbt index 159281b42..8e909f2ca 100644 --- a/builtin/stringbuilder_buffer.mbt +++ b/builtin/stringbuilder_buffer.mbt @@ -14,7 +14,7 @@ ///| struct StringBuilder { - mut data : FixedArray[UInt16] + mut data : UninitializedArray[UInt16] mut len : Int mut buffer_is_shared : Bool } @@ -33,7 +33,7 @@ struct StringBuilder { #alias(new, deprecated="Use `StringBuilder()` instead") pub fn StringBuilder::StringBuilder(size_hint? : Int = 0) -> StringBuilder { let initial = if size_hint < 1 { 1 } else { (size_hint + 1) / 2 } - let data : FixedArray[UInt16] = FixedArray::make(initial, 0) + let data : UninitializedArray[UInt16] = UninitializedArray::make(initial) { data, len: 0, buffer_is_shared: false } } @@ -78,16 +78,28 @@ fn StringBuilder::grow(self : StringBuilder, required : Int) -> Unit { self.len, required, ) - let new_data = FixedArray::make_and_blit( + let new_data = UninitializedArray::make_and_blit( self.data, allocate_len=new_capacity, - init=(Default::default() : UInt16), len=self.len, ) self.data = new_data self.buffer_is_shared = false } +///| +fn UninitializedArray::unsafe_blit_from_string_uninit( + self : UninitializedArray[UInt16], + dst_offset : Int, + str : String, + str_offset : Int, + len : Int, +) -> Unit { + unsafe_uint16_buffer_as_fixedarray(self).unsafe_blit_from_string( + dst_offset, str, str_offset, len, + ) +} + ///| fn FixedArray::unsafe_blit_from_string( self : FixedArray[UInt16], @@ -102,6 +114,12 @@ fn FixedArray::unsafe_blit_from_string( } } +///| +/// Reinterpret the backing storage. Callers must only read initialized slots. +fn unsafe_uint16_buffer_as_fixedarray( + buffer : UninitializedArray[UInt16], +) -> FixedArray[UInt16] = "%identity" + ///| /// Writes a string to the StringBuilder. pub impl Logger for StringBuilder with fn write_string(self, str) { @@ -113,7 +131,7 @@ pub impl Logger for StringBuilder with fn write_string(self, str) { if required > self.data.length() || required < self.len { self.grow(required) } - self.data.unsafe_blit_from_string(self.len, str, 0, str_len) + self.data.unsafe_blit_from_string_uninit(self.len, str, 0, str_len) self.len += str_len } @@ -125,15 +143,18 @@ pub impl Logger for StringBuilder with fn write_char(self, ch) { if self.len >= self.data.length() { self.grow(self.len + 1) } - self.data[self.len] = code.to_uint16() + self.data.unsafe_set(self.len, code.to_uint16()) self.len += 1 } else if code <= 0x10FFFFU { if self.data.length() - self.len < 2 { self.grow(self.len + 2) } let code = code - 0x10000U - self.data[self.len] = (0xD800U + (code >> 10)).to_uint16() - self.data[self.len + 1] = (0xDC00U + code.land(0x3FFU)).to_uint16() + self.data.unsafe_set(self.len, (0xD800U + (code >> 10)).to_uint16()) + self.data.unsafe_set( + self.len + 1, + (0xDC00U + code.land(0x3FFU)).to_uint16(), + ) self.len += 2 } else { abort("invalid code point") @@ -171,7 +192,7 @@ pub impl Logger for StringBuilder with fn write_view( if required > self.data.length() || required < self.len { self.grow(required) } - self.data.unsafe_blit_from_string( + self.data.unsafe_blit_from_string_uninit( self.len, str.data(), str.start_offset(), @@ -189,15 +210,16 @@ pub fn StringBuilder::to_string(self : StringBuilder) -> String { // This conversion can reuse `data` without copying, so future resets must // detach before the buffer is written again. self.buffer_is_shared = true - unsafe_fixedarray_uint16_to_string(self.data) + unsafe_fixedarray_uint16_to_string( + unsafe_uint16_buffer_as_fixedarray(self.data), + ) } else { - let data = FixedArray::make_and_blit( + let data = UninitializedArray::make_and_blit( self.data, allocate_len=self.len, - init=(Default::default() : UInt16), len=self.len, ) - unsafe_fixedarray_uint16_to_string(data) + unsafe_fixedarray_uint16_to_string(unsafe_uint16_buffer_as_fixedarray(data)) } } @@ -218,10 +240,7 @@ pub fn StringBuilder::reset(self : StringBuilder) -> Unit { // A full buffer may have been returned directly from `to_string`. Retain the // backing storage in every other case, since it only contains value types. if self.buffer_is_shared { - self.data = FixedArray::make( - self.data.length(), - (Default::default() : UInt16), - ) + self.data = UninitializedArray::make(self.data.length()) self.buffer_is_shared = false } self.len = 0 diff --git a/builtin/stringbuilder_wbtest.mbt b/builtin/stringbuilder_wbtest.mbt index c7c007d24..e151eb611 100644 --- a/builtin/stringbuilder_wbtest.mbt +++ b/builtin/stringbuilder_wbtest.mbt @@ -26,3 +26,22 @@ test "StringBuilder::reset reuses an unshared buffer" { assert_true(backing[1] == ('y' : UInt16)) assert_true(backing[2] == ('e' : UInt16)) } + +///| +#cfg(not(target="js")) +test "StringBuilder keeps initialized prefix valid across grow and reset" { + let builder = StringBuilder(size_hint=2) + builder.write_string("ab") + builder.write_string("cd") + inspect(builder.to_string(), content="abcd") + + // The full-capacity conversion may share the backing buffer. Reset must + // detach before the next write, while a partial conversion must only expose + // the initialized prefix. + builder.reset() + builder.write_char('x') + inspect(builder.to_string(), content="x") + builder.reset() + builder.write_char('🤣') + inspect(builder.to_string(), content="🤣") +} From 484863323e8b3312cf6abf44648b880f65a0def3 Mon Sep 17 00:00:00 2001 From: mizchi Date: Tue, 25 Aug 2026 01:05:14 +0900 Subject: [PATCH 3/3] bench(stringbuilder): cover large reserved buffers --- builtin/stringbuilder_large_bench_test.mbt | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 builtin/stringbuilder_large_bench_test.mbt diff --git a/builtin/stringbuilder_large_bench_test.mbt b/builtin/stringbuilder_large_bench_test.mbt new file mode 100644 index 000000000..342d65004 --- /dev/null +++ b/builtin/stringbuilder_large_bench_test.mbt @@ -0,0 +1,71 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +let stringbuilder_large_sizes : Array[(Int, Int)] = [ + (1 * 1024 * 1024, 512 * 1024), + (8 * 1024 * 1024, 4 * 1024 * 1024), + (32 * 1024 * 1024, 16 * 1024 * 1024), +] + +///| +test "bench StringBuilder reserve 1 MiB" (it : @bench.T) { + let (bytes, _) = stringbuilder_large_sizes[0] + it.bench(fn() { it.keep(StringBuilder(size_hint=bytes)) }) +} + +///| +test "bench StringBuilder reserve 8 MiB" (it : @bench.T) { + let (bytes, _) = stringbuilder_large_sizes[1] + it.bench(fn() { it.keep(StringBuilder(size_hint=bytes)) }) +} + +///| +test "bench StringBuilder reserve 32 MiB" (it : @bench.T) { + let (bytes, _) = stringbuilder_large_sizes[2] + it.bench(fn() { it.keep(StringBuilder(size_hint=bytes)) }) +} + +///| +test "bench StringBuilder build 1 MiB" (it : @bench.T) { + let (bytes, units) = stringbuilder_large_sizes[0] + let source = String::make(units, 'x') + it.bench(fn() { + let builder = StringBuilder(size_hint=bytes) + builder.write_string(source) + it.keep(builder.to_string()) + }) +} + +///| +test "bench StringBuilder build 8 MiB" (it : @bench.T) { + let (bytes, units) = stringbuilder_large_sizes[1] + let source = String::make(units, 'x') + it.bench(fn() { + let builder = StringBuilder(size_hint=bytes) + builder.write_string(source) + it.keep(builder.to_string()) + }) +} + +///| +test "bench StringBuilder build 32 MiB" (it : @bench.T) { + let (bytes, units) = stringbuilder_large_sizes[2] + let source = String::make(units, 'x') + it.bench(fn() { + let builder = StringBuilder(size_hint=bytes) + builder.write_string(source) + it.keep(builder.to_string()) + }) +}