From 35f2fa24742804005e440d1586185fb8a562e741 Mon Sep 17 00:00:00 2001 From: mizchi Date: Fri, 3 Jul 2026 15:33:47 +0900 Subject: [PATCH 1/7] Optimize JSON string escaping --- json/json.mbt | 47 ++++++++++++++++++---------- json/stringify_escape_bench_test.mbt | 45 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 json/stringify_escape_bench_test.mbt diff --git a/json/json.mbt b/json/json.mbt index 82081eadec..98cb6c6cb3 100644 --- a/json/json.mbt +++ b/json/json.mbt @@ -384,32 +384,45 @@ pub fn Json::stringify( ///| fn escape(str : String, escape_slash~ : Bool) -> String { + let len = str.length() + let mut needs_escape = false + for i in 0.. buf.write_string("\\\"") - '\\' => buf.write_string("\\\\") - '/' => + for i in 0.. buf.write_string("\\\"") + ('\\' : UInt16) => buf.write_string("\\\\") + ('/' : UInt16) => if escape_slash { buf.write_string("\\/") } else { - buf.write_char(c) + buf.write_char('/') } - '\n' => buf.write_string("\\n") - '\r' => buf.write_string("\\r") - '\b' => buf.write_string("\\b") - '\t' => buf.write_string("\\t") - _ => { - let code = c.to_int() - if code == 0x0C { - buf.write_string("\\f") - } else if code < ' ' { + ('\n' : UInt16) => buf.write_string("\\n") + ('\r' : UInt16) => buf.write_string("\\r") + ('\b' : UInt16) => buf.write_string("\\b") + ('\t' : UInt16) => buf.write_string("\\t") + (0x0C : UInt16) => buf.write_string("\\f") + _ => + if code < (' ' : UInt16) { buf.write_string("\\u00") buf.write_string(code.to_byte().to_hex()) } else { - buf.write_char(c) + buf.write_char(code.to_int().unsafe_to_char()) } - } } } buf.to_string() diff --git a/json/stringify_escape_bench_test.mbt b/json/stringify_escape_bench_test.mbt new file mode 100644 index 0000000000..de7ae652d6 --- /dev/null +++ b/json/stringify_escape_bench_test.mbt @@ -0,0 +1,45 @@ +// 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 stringify_escape_bench_count = 2048 + +///| +fn make_stringify_escape_bench_array(value : String) -> Json { + Array::makei(stringify_escape_bench_count, i => value + i.to_string()).to_json() +} + +///| +test "bench Json::stringify strings no escape n=2048" (it : @bench.T) { + let json = make_stringify_escape_bench_array("moonbit-core-json-value-") + it.bench(fn() { it.keep(json.stringify().length()) }) +} + +///| +test "bench Json::stringify strings slash no escape n=2048" (it : @bench.T) { + let json = make_stringify_escape_bench_array("moonbit/core/json/value/") + it.bench(fn() { it.keep(json.stringify().length()) }) +} + +///| +test "bench Json::stringify strings slash escaped n=2048" (it : @bench.T) { + let json = make_stringify_escape_bench_array("moonbit/core/json/value/") + it.bench(fn() { it.keep(json.stringify(escape_slash=true).length()) }) +} + +///| +test "bench Json::stringify strings quotes controls n=2048" (it : @bench.T) { + let json = make_stringify_escape_bench_array("moonbit\"core\\json\nvalue") + it.bench(fn() { it.keep(json.stringify().length()) }) +} From 3d317a1c16d6c6fbeb723697e56eef0af370fec3 Mon Sep 17 00:00:00 2001 From: mizchi Date: Mon, 6 Jul 2026 00:44:20 +0900 Subject: [PATCH 2/7] Address JSON escape review comments --- json/json.mbt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/json/json.mbt b/json/json.mbt index 98cb6c6cb3..9f12547dd1 100644 --- a/json/json.mbt +++ b/json/json.mbt @@ -399,7 +399,7 @@ fn escape(str : String, escape_slash~ : Bool) -> String { if !needs_escape { return str } - let buf = StringBuilder(size_hint=str.length()) + let buf = StringBuilder(size_hint=len) for i in 0.. String { buf.write_string("\\u00") buf.write_string(code.to_byte().to_hex()) } else { - buf.write_char(code.to_int().unsafe_to_char()) + buf.write_char(code.unsafe_to_char()) } } } From 7a31dd99474ce887051dab77f50bc75e34f2dec2 Mon Sep 17 00:00:00 2001 From: Yu Zhang Date: Mon, 20 Jul 2026 15:08:27 +0800 Subject: [PATCH 3/7] perf(json): vectorize escape precheck --- json/escape_simd_wbtest.mbt | 33 +++++++++++++++++++ json/json.mbt | 64 +++++++++++++++++++++++++++++++++---- json/moon.pkg | 3 ++ 3 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 json/escape_simd_wbtest.mbt diff --git a/json/escape_simd_wbtest.mbt b/json/escape_simd_wbtest.mbt new file mode 100644 index 0000000000..25fd87486b --- /dev/null +++ b/json/escape_simd_wbtest.mbt @@ -0,0 +1,33 @@ +// 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. + +///| +test "need_escape SIMD blocks and scalar tail" { + let cases : Array[(String, Bool, Bool)] = [ + ("", false, false), + ("0123456", false, false), + ("01234567", false, false), + ("012345678", false, false), + ("0123456\"", false, true), + ("01234567\\", false, true), + ("012345678901234\n", false, true), + ("01234567/89", false, false), + ("01234567/89", true, true), + ("0123456😀", false, false), + ] + for case in cases { + let (str, escape_slash, expected) = case + @debug.assert_eq(need_escape(str, escape_slash), expected) + } +} diff --git a/json/json.mbt b/json/json.mbt index 9f12547dd1..00c19fb1d3 100644 --- a/json/json.mbt +++ b/json/json.mbt @@ -383,20 +383,70 @@ pub fn Json::stringify( } ///| -fn escape(str : String, escape_slash~ : Bool) -> String { - let len = str.length() - let mut needs_escape = false - for i in 0.. Bool { + for i in start.. Bool { + need_escape_scalar(str, escape_slash, 0, str.length()) +} + +///| +// Scan eight UTF-16 code units at a time on linear-memory backends, then scan +// the remaining tail one code unit at a time. +#cfg(any(target="native", target="wasm")) +fn need_escape(str : String, escape_slash : Bool) -> Bool { + let len = str.length() + guard len >= 8 else { return need_escape_scalar(str, escape_slash, 0, len) } + let control_limit = @v128.i16x8_splat((' ' : UInt16)) + let quote = @v128.i16x8_splat(('"' : UInt16)) + let backslash = @v128.i16x8_splat(('\\' : UInt16)) + let slash = @v128.i16x8_splat(('/' : UInt16)) + let tail_start = for pos = 0; pos + 8 <= len; { + let block = @v128.v128_load_i16x8(str, pos) + let escaped = @v128.v128_or_( + @v128.i16x8_lt_u(block, control_limit), + @v128.v128_or_( + @v128.i16x8_eq(block, quote), + @v128.i16x8_eq(block, backslash), + ), + ) + let escaped = if escape_slash { + @v128.v128_or_(escaped, @v128.i16x8_eq(block, slash)) + } else { + escaped + } + if @v128.v128_any_true(escaped) { + return true } + continue pos + 8 + } nobreak { + pos } - if !needs_escape { + need_escape_scalar(str, escape_slash, tail_start, len) +} + +///| +fn escape(str : String, escape_slash~ : Bool) -> String { + let len = str.length() + if !need_escape(str, escape_slash) { return str } let buf = StringBuilder(size_hint=len) diff --git a/json/moon.pkg b/json/moon.pkg index 197f8e7fb7..b83f34b4a8 100644 --- a/json/moon.pkg +++ b/json/moon.pkg @@ -8,6 +8,7 @@ import { "moonbitlang/core/internal/strconv" @internal/strconv, "moonbitlang/core/option", "moonbitlang/core/buffer", + "moonbitlang/core/v128", // Used only by the native and wasm SIMD implementation. } import { @@ -21,3 +22,5 @@ import { "moonbitlang/core/quickcheck/shrink", "moonbitlang/core/quickcheck/splitmix", } for "test" + +warnings = "-29" From d9b8ccbf5a1b352d48170e97f6d245fcbeacd646 Mon Sep 17 00:00:00 2001 From: Yu Zhang Date: Mon, 20 Jul 2026 15:31:58 +0800 Subject: [PATCH 4/7] refactor(json): scope v128 import workaround --- json/json.mbt | 7 +++++++ json/moon.pkg | 2 -- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/json/json.mbt b/json/json.mbt index 00c19fb1d3..9d83e03476 100644 --- a/json/json.mbt +++ b/json/json.mbt @@ -402,6 +402,13 @@ fn need_escape_scalar( false } +///| +#cfg(not(any(target="native", target="wasm"))) +#warnings("-unused_value") +fn suppress_unused_v128_import_on_scalar_targets() -> Unit { + ignore(@v128.i16x8_splat(0)) +} + ///| #cfg(not(any(target="native", target="wasm"))) fn need_escape(str : String, escape_slash : Bool) -> Bool { diff --git a/json/moon.pkg b/json/moon.pkg index b83f34b4a8..50a8d50b65 100644 --- a/json/moon.pkg +++ b/json/moon.pkg @@ -22,5 +22,3 @@ import { "moonbitlang/core/quickcheck/shrink", "moonbitlang/core/quickcheck/splitmix", } for "test" - -warnings = "-29" From 78a4b4029c63021e1911d6ac4179d0c05aaa03bd Mon Sep 17 00:00:00 2001 From: Yu Zhang Date: Mon, 20 Jul 2026 15:41:54 +0800 Subject: [PATCH 5/7] refactor(json): iterate escape code units directly --- json/json.mbt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/json/json.mbt b/json/json.mbt index 9d83e03476..ea02aefd10 100644 --- a/json/json.mbt +++ b/json/json.mbt @@ -457,8 +457,7 @@ fn escape(str : String, escape_slash~ : Bool) -> String { return str } let buf = StringBuilder(size_hint=len) - for i in 0.. buf.write_string("\\\"") ('\\' : UInt16) => buf.write_string("\\\\") From 2ab0bc647c720e847fcfe528e584633f1561d0a0 Mon Sep 17 00:00:00 2001 From: Hongbo Zhang Date: Thu, 20 Aug 2026 22:00:26 +0800 Subject: [PATCH 6/7] test(json): property-test the escape fast path Add quickcheck properties pinning the SIMD need_escape against the scalar reference, escape against a per-code-unit model and the real parser, and an exhaustive sweep of escapable code units across SIMD block boundaries. Co-Authored-By: Claude Fable 5 --- json/escape_quickcheck_wbtest.mbt | 160 ++++++++++++++++++++++++++++++ json/moon.pkg | 4 + 2 files changed, 164 insertions(+) create mode 100644 json/escape_quickcheck_wbtest.mbt diff --git a/json/escape_quickcheck_wbtest.mbt b/json/escape_quickcheck_wbtest.mbt new file mode 100644 index 0000000000..09e5c320ff --- /dev/null +++ b/json/escape_quickcheck_wbtest.mbt @@ -0,0 +1,160 @@ +// 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. + +// Property-based tests for the `Json::stringify` escape fast path: the SIMD +// `need_escape` must agree with the scalar reference, and `escape` must agree +// with a straightforward per-code-unit model and with the real parser, +// including at SIMD block boundaries and on surrogate halves. + +///| +/// Builds a string whose code units are biased toward everything the escaper +/// branches on: quotes, backslashes, slashes, control characters, both sides +/// of the 0x20 boundary, and surrogate halves (whose high bit set exercises +/// the unsigned SIMD comparisons). With `allow_surrogates=false` the +/// surrogate range is remapped so the string is well-formed UTF-16. +fn adversarial_string(seeds : Array[Int], allow_surrogates~ : Bool) -> String { + let buf = StringBuilder(size_hint=seeds.length()) + for seed in seeds { + let code : UInt16 = match seed & 0xF { + 0 => '"' + 1 => '\\' + 2 => '/' + 3 => '\n' + 4 => 0x1F + 5 => ' ' + 6 => 0x0C + 7 => 0xD800 + 8 => 0xDFFF + 9 => 0xFFFF + 10 => 'a' + _ => ((seed >> 4) & 0xFFFF).to_uint16() + } + let code = if !allow_surrogates && code is (0xD800..=0xDFFF) { + code ^ 0x2000 + } else { + code + } + buf.write_char(code.unsafe_to_char()) + } + buf.to_string() +} + +///| +/// Per-code-unit model of `escape`, written directly from the JSON string +/// grammar with no fast path and no SIMD. +fn model_escape(str : String, escape_slash : Bool) -> String { + let buf = StringBuilder(size_hint=str.length()) + for code in str.code_units() { + match code.to_int() { + 0x22 => buf.write_string("\\\"") + 0x5C => buf.write_string("\\\\") + 0x2F => buf.write_string(if escape_slash { "\\/" } else { "/" }) + 0x08 => buf.write_string("\\b") + 0x09 => buf.write_string("\\t") + 0x0A => buf.write_string("\\n") + 0x0C => buf.write_string("\\f") + 0x0D => buf.write_string("\\r") + c => + if c < 0x20 { + buf.write_string("\\u00") + buf.write_string(c.to_byte().to_hex()) + } else { + buf.write_char(code.unsafe_to_char()) + } + } + } + buf.to_string() +} + +///| +test "quickcheck: need_escape agrees with the scalar reference" { + @quickcheck.check(count=300, (input : (Array[Int], Bool)) => { + let (seeds, escape_slash) = input + let str = adversarial_string(seeds, allow_surrogates=true) + need_escape(str, escape_slash) == + need_escape_scalar(str, escape_slash, 0, str.length()) + }) + // Also over ordinary ASCII-biased Unicode strings, which cover other + // lengths and multi-code-unit characters. + @quickcheck.check(count=300, (input : (String, Bool)) => { + let (str, escape_slash) = input + need_escape(str, escape_slash) == + need_escape_scalar(str, escape_slash, 0, str.length()) + }) +} + +///| +test "quickcheck: escape matches the per-code-unit model" { + @quickcheck.check(count=300, (input : (Array[Int], Bool)) => { + let (seeds, escape_slash) = input + let str = adversarial_string(seeds, allow_surrogates=true) + guard escape(str, escape_slash~) == model_escape(str, escape_slash) else { + return false + } + // The no-copy fast path must fire exactly when nothing is escapable. + (escape(str, escape_slash~) == str) == + !need_escape_scalar(str, escape_slash, 0, str.length()) + }) + @quickcheck.check(count=300, (input : (String, Bool)) => { + let (str, escape_slash) = input + escape(str, escape_slash~) == model_escape(str, escape_slash) + }) +} + +///| +/// Roundtrip through the real parser, whose string lexer is an independent +/// implementation of the same grammar. Surrogate halves are excluded because +/// well-formed MoonBit strings contain no unpaired surrogates. +test "quickcheck: stringify/parse roundtrip on adversarial strings" { + @quickcheck.check(count=300, (input : (Array[Int], Bool)) => { + let (seeds, escape_slash) = input + let json = Json::string(adversarial_string(seeds, allow_surrogates=false)) + parse(json.stringify(escape_slash~)) == json + }) +} + +///| +/// Exhaustively places each escapable code unit at every position of an +/// otherwise clean string, for every length spanning several 8-unit SIMD +/// blocks, so block starts, block interiors, and the scalar tail are all +/// covered for both `escape_slash` values. +test "need_escape boundary sweep" { + let specials : Array[UInt16] = ['"', '\\', '\n', 0x00, 0x1F] + fn place(len : Int, pos : Int, special : UInt16) -> String { + let buf = StringBuilder(size_hint=len) + for i in 0.. Date: Thu, 20 Aug 2026 22:37:05 +0800 Subject: [PATCH 7/7] refactor(json): drop inferable UInt16 ascriptions Co-Authored-By: Claude Fable 5 --- json/json.mbt | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/json/json.mbt b/json/json.mbt index ea02aefd10..c42271ca8e 100644 --- a/json/json.mbt +++ b/json/json.mbt @@ -392,10 +392,10 @@ fn need_escape_scalar( ) -> Bool { for i in start.. Bool { fn need_escape(str : String, escape_slash : Bool) -> Bool { let len = str.length() guard len >= 8 else { return need_escape_scalar(str, escape_slash, 0, len) } - let control_limit = @v128.i16x8_splat((' ' : UInt16)) - let quote = @v128.i16x8_splat(('"' : UInt16)) - let backslash = @v128.i16x8_splat(('\\' : UInt16)) - let slash = @v128.i16x8_splat(('/' : UInt16)) + let control_limit = @v128.i16x8_splat(' ') + let quote = @v128.i16x8_splat('"') + let backslash = @v128.i16x8_splat('\\') + let slash = @v128.i16x8_splat('/') let tail_start = for pos = 0; pos + 8 <= len; { let block = @v128.v128_load_i16x8(str, pos) let escaped = @v128.v128_or_( @@ -459,21 +459,21 @@ fn escape(str : String, escape_slash~ : Bool) -> String { let buf = StringBuilder(size_hint=len) for code in str.code_units() { match code { - ('"' : UInt16) => buf.write_string("\\\"") - ('\\' : UInt16) => buf.write_string("\\\\") - ('/' : UInt16) => + '"' => buf.write_string("\\\"") + '\\' => buf.write_string("\\\\") + '/' => if escape_slash { buf.write_string("\\/") } else { buf.write_char('/') } - ('\n' : UInt16) => buf.write_string("\\n") - ('\r' : UInt16) => buf.write_string("\\r") - ('\b' : UInt16) => buf.write_string("\\b") - ('\t' : UInt16) => buf.write_string("\\t") - (0x0C : UInt16) => buf.write_string("\\f") + '\n' => buf.write_string("\\n") + '\r' => buf.write_string("\\r") + '\b' => buf.write_string("\\b") + '\t' => buf.write_string("\\t") + 0x0C => buf.write_string("\\f") _ => - if code < (' ' : UInt16) { + if code < ' ' { buf.write_string("\\u00") buf.write_string(code.to_byte().to_hex()) } else {