diff --git a/builtin/stringbuilder_buffer.mbt b/builtin/stringbuilder_buffer.mbt index e275d7680..8e909f2ca 100644 --- a/builtin/stringbuilder_buffer.mbt +++ b/builtin/stringbuilder_buffer.mbt @@ -14,8 +14,9 @@ ///| struct StringBuilder { - mut data : FixedArray[UInt16] + mut data : UninitializedArray[UInt16] mut len : Int + mut buffer_is_shared : Bool } ///| @@ -32,8 +33,8 @@ 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) - { data, len: 0 } + let data : UninitializedArray[UInt16] = UninitializedArray::make(initial) + { data, len: 0, buffer_is_shared: false } } ///| @@ -77,13 +78,26 @@ 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, + ) } ///| @@ -100,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) { @@ -111,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 } @@ -123,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") @@ -169,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(), @@ -184,15 +207,19 @@ pub fn StringBuilder::to_string(self : StringBuilder) -> String { if self.len == 0 { "" } else if self.len == self.data.length() { - unsafe_fixedarray_uint16_to_string(self.data) + // 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( + 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)) } } @@ -210,9 +237,11 @@ 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 = UninitializedArray::make(self.data.length()) + self.buffer_is_shared = false + } self.len = 0 } 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()) + }) +} diff --git a/builtin/stringbuilder_wbtest.mbt b/builtin/stringbuilder_wbtest.mbt new file mode 100644 index 000000000..e151eb611 --- /dev/null +++ b/builtin/stringbuilder_wbtest.mbt @@ -0,0 +1,47 @@ +// 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)) +} + +///| +#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="🤣") +}