From ed9af56139ad6a066910483104bae165cef53d16 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Thu, 30 Apr 2026 13:02:22 +0800 Subject: [PATCH 1/2] perf: chunk long string byte escaping Motivation: Split the JMH-positive long-string rendering piece out of #776 without carrying over the broader Scala Native render-pipeline experiment. Modification: - Add CharSWAR.findFirstEscapeChar for byte arrays on JVM, JS, and Native. - Keep the existing UTF-8 byte array for long strings, but locate escape bytes and copy clean chunks with System.arraycopy. - Escape only the matching bytes inline. - Precompute the exact escaped output length before writing dirty strings so ByteBuilder does not grow repeatedly. Result: This keeps the change JDK17/JIT/GC friendly: straight byte-array loops, no internal JDK APIs, no extra temporary arrays beyond the existing UTF-8 encoding, and no regression on clean long strings. --- sjsonnet/src-js/sjsonnet/CharSWAR.scala | 10 ++ sjsonnet/src-jvm/sjsonnet/CharSWAR.java | 25 +++++ sjsonnet/src-native/sjsonnet/CharSWAR.scala | 36 +++++++ sjsonnet/src/sjsonnet/BaseByteRenderer.scala | 100 +++++++++++++++++-- 4 files changed, 160 insertions(+), 11 deletions(-) diff --git a/sjsonnet/src-js/sjsonnet/CharSWAR.scala b/sjsonnet/src-js/sjsonnet/CharSWAR.scala index bcdb85e7..6e64ce82 100644 --- a/sjsonnet/src-js/sjsonnet/CharSWAR.scala +++ b/sjsonnet/src-js/sjsonnet/CharSWAR.scala @@ -33,4 +33,14 @@ object CharSWAR { } false } + + def findFirstEscapeChar(arr: Array[Byte], from: Int, to: Int): Int = { + var i = from + while (i < to) { + val b = arr(i) & 0xff + if (b < 32 || b == '"' || b == '\\') return i + i += 1 + } + -1 + } } diff --git a/sjsonnet/src-jvm/sjsonnet/CharSWAR.java b/sjsonnet/src-jvm/sjsonnet/CharSWAR.java index 46bc7d11..97716ca0 100644 --- a/sjsonnet/src-jvm/sjsonnet/CharSWAR.java +++ b/sjsonnet/src-jvm/sjsonnet/CharSWAR.java @@ -90,6 +90,31 @@ static boolean hasEscapeChar(char[] arr, int from, int to) { return false; } + /** + * Find the first byte in {@code arr[from..to)} that needs JSON string escaping, or {@code -1} + * when the range is clean. + */ + static int findFirstEscapeChar(byte[] arr, int from, int to) { + int i = from; + int limit = to - 7; + while (i < limit) { + long word = (long) LONG_VIEW.get(arr, i); + if (swarHasMatch(word)) { + for (int j = i; j < i + 8; j++) { + int b = arr[j] & 0xFF; + if (b < 32 || b == '"' || b == '\\') return j; + } + } + i += 8; + } + while (i < to) { + int b = arr[i] & 0xFF; + if (b < 32 || b == '"' || b == '\\') return i; + i++; + } + return -1; + } + private static boolean hasEscapeCharSWAR(byte[] arr, int from, int to) { int i = from; int limit = to - 7; // 8 bytes per VarHandle.get diff --git a/sjsonnet/src-native/sjsonnet/CharSWAR.scala b/sjsonnet/src-native/sjsonnet/CharSWAR.scala index 5331c012..abe6afd3 100644 --- a/sjsonnet/src-native/sjsonnet/CharSWAR.scala +++ b/sjsonnet/src-native/sjsonnet/CharSWAR.scala @@ -89,6 +89,32 @@ object CharSWAR { false } + def findFirstEscapeChar(arr: Array[Byte], from: Int, to: Int): Int = { + val len = to - from + if (len < 8) return findFirstEscapeCharScalar(arr, from, to) + val barr = arr.asInstanceOf[ByteArray] + var i = from + val limit = to - 7 + while (i < limit) { + val word = Intrinsics.loadLong(barr.atRawUnsafe(i)) + if (swarHasMatch(word)) { + var j = i + while (j < i + 8) { + val b = arr(j) & 0xff + if (b < 32 || b == '"' || b == '\\') return j + j += 1 + } + } + i += 8 + } + while (i < to) { + val b = arr(i) & 0xff + if (b < 32 || b == '"' || b == '\\') return i + i += 1 + } + -1 + } + @inline private def hasEscapeCharScalar(s: String, len: Int): Boolean = { var i = 0 while (i < len) { @@ -108,4 +134,14 @@ object CharSWAR { } false } + + @inline private def findFirstEscapeCharScalar(arr: Array[Byte], from: Int, to: Int): Int = { + var i = from + while (i < to) { + val b = arr(i) & 0xff + if (b < 32 || b == '"' || b == '\\') return i + i += 1 + } + -1 + } } diff --git a/sjsonnet/src/sjsonnet/BaseByteRenderer.scala b/sjsonnet/src/sjsonnet/BaseByteRenderer.scala index 95a67aef..711c5c50 100644 --- a/sjsonnet/src/sjsonnet/BaseByteRenderer.scala +++ b/sjsonnet/src/sjsonnet/BaseByteRenderer.scala @@ -307,13 +307,14 @@ class BaseByteRenderer[T <: java.io.OutputStream]( } /** - * SWAR-accelerated path for long strings. Converts to UTF-8 bytes once, scans with SWAR, and - * bulk-copies if clean. The getBytes allocation is amortized by avoiding per-char processing. + * SWAR-accelerated path for long strings. Converts to UTF-8 bytes once, then bulk-copies clean + * chunks and escapes only the bytes that require it. */ private def visitLongString(str: String): Unit = { val bytes = str.getBytes(java.nio.charset.StandardCharsets.UTF_8) - if (!CharSWAR.hasEscapeChar(bytes, 0, bytes.length)) { - val bLen = bytes.length + val bLen = bytes.length + val firstEscape = CharSWAR.findFirstEscapeChar(bytes, 0, bLen) + if (firstEscape < 0) { elemBuilder.ensureLength(bLen + 2) val arr = elemBuilder.arr val pos = elemBuilder.length @@ -322,13 +323,87 @@ class BaseByteRenderer[T <: java.io.OutputStream]( arr(pos + 1 + bLen) = '"'.toByte elemBuilder.length = pos + bLen + 2 } else { - upickle.core.RenderUtils.escapeByte( - unicodeCharBuilder, - elemBuilder, - str, - escapeUnicode = false, - wrapQuotes = true - ) + val escapedLen = escapedStringLength(bytes, bLen, firstEscape) + elemBuilder.ensureLength(escapedLen) + elemBuilder.appendUnsafeC('"') + var from = 0 + var escPos = firstEscape + while (escPos >= 0) { + if (escPos > from) { + val chunkLen = escPos - from + elemBuilder.ensureLength(chunkLen) + val arr = elemBuilder.arr + val pos = elemBuilder.length + System.arraycopy(bytes, from, arr, pos, chunkLen) + elemBuilder.length = pos + chunkLen + } + escapeByteInline(bytes(escPos) & 0xff) + from = escPos + 1 + escPos = if (from < bLen) CharSWAR.findFirstEscapeChar(bytes, from, bLen) else -1 + } + if (from < bLen) { + val tailLen = bLen - from + elemBuilder.ensureLength(tailLen) + val arr = elemBuilder.arr + val pos = elemBuilder.length + System.arraycopy(bytes, from, arr, pos, tailLen) + elemBuilder.length = pos + tailLen + } + elemBuilder.ensureLength(1) + elemBuilder.appendUnsafeC('"') + } + } + + private def escapedStringLength(bytes: Array[Byte], bLen: Int, firstEscape: Int): Int = { + var len = bLen + 2 + var from = firstEscape + var escPos = firstEscape + while (escPos >= 0) { + len += escapeExtraLength(bytes(escPos) & 0xff) + from = escPos + 1 + escPos = if (from < bLen) CharSWAR.findFirstEscapeChar(bytes, from, bLen) else -1 + } + len + } + + @inline private def escapeExtraLength(b: Int): Int = + (b: @scala.annotation.switch) match { + case '"' | '\\' | '\b' | '\f' | '\n' | '\r' | '\t' => 1 + case _ => 5 + } + + /** Inline JSON escape for one byte that is known to require escaping. */ + private def escapeByteInline(b: Int): Unit = { + elemBuilder.ensureLength(6) + (b: @scala.annotation.switch) match { + case '"' => + elemBuilder.appendUnsafeC('\\') + elemBuilder.appendUnsafeC('"') + case '\\' => + elemBuilder.appendUnsafeC('\\') + elemBuilder.appendUnsafeC('\\') + case '\b' => + elemBuilder.appendUnsafeC('\\') + elemBuilder.appendUnsafeC('b') + case '\f' => + elemBuilder.appendUnsafeC('\\') + elemBuilder.appendUnsafeC('f') + case '\n' => + elemBuilder.appendUnsafeC('\\') + elemBuilder.appendUnsafeC('n') + case '\r' => + elemBuilder.appendUnsafeC('\\') + elemBuilder.appendUnsafeC('r') + case '\t' => + elemBuilder.appendUnsafeC('\\') + elemBuilder.appendUnsafeC('t') + case c => + elemBuilder.appendUnsafeC('\\') + elemBuilder.appendUnsafeC('u') + elemBuilder.appendUnsafeC('0') + elemBuilder.appendUnsafeC('0') + elemBuilder.appendUnsafeC(BaseByteRenderer.HEX_CHARS((c >> 4) & 0xf)) + elemBuilder.appendUnsafeC(BaseByteRenderer.HEX_CHARS(c & 0xf)) } } @@ -377,6 +452,9 @@ object BaseByteRenderer { a } + /** Hex digits used by inline byte escaping for control chars. */ + private[sjsonnet] val HEX_CHARS: Array[Char] = "0123456789abcdef".toCharArray + /** * Reusable scratch buffer for writeLongDirect (max 20 bytes for Long.MinValue). Not thread-safe, * but renderers are single-threaded. From ff70b63ee655a716d0d7f08973a01cafdd05587f Mon Sep 17 00:00:00 2001 From: He-Pin Date: Fri, 8 May 2026 01:45:54 +0800 Subject: [PATCH 2/2] perf: streamline chunked byte escaping --- sjsonnet/src-jvm/sjsonnet/CharSWAR.java | 21 +++-- sjsonnet/src-native/sjsonnet/CharSWAR.scala | 24 ++--- sjsonnet/src/sjsonnet/BaseByteRenderer.scala | 96 ++++++++++++-------- 3 files changed, 85 insertions(+), 56 deletions(-) diff --git a/sjsonnet/src-jvm/sjsonnet/CharSWAR.java b/sjsonnet/src-jvm/sjsonnet/CharSWAR.java index 97716ca0..e681409f 100644 --- a/sjsonnet/src-jvm/sjsonnet/CharSWAR.java +++ b/sjsonnet/src-jvm/sjsonnet/CharSWAR.java @@ -31,6 +31,7 @@ private CharSWAR() {} // MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder) private static final VarHandle LONG_VIEW = MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.nativeOrder()); + private static final boolean LITTLE_ENDIAN = ByteOrder.nativeOrder() == ByteOrder.LITTLE_ENDIAN; // --- 8-bit SWAR constants (Netty/Pekko pattern) --- // @@ -99,11 +100,9 @@ static int findFirstEscapeChar(byte[] arr, int from, int to) { int limit = to - 7; while (i < limit) { long word = (long) LONG_VIEW.get(arr, i); - if (swarHasMatch(word)) { - for (int j = i; j < i + 8; j++) { - int b = arr[j] & 0xFF; - if (b < 32 || b == '"' || b == '\\') return j; - } + long mask = swarMatchMask(word); + if (mask != 0L) { + return i + firstMatchedByte(mask); } i += 8; } @@ -120,7 +119,7 @@ private static boolean hasEscapeCharSWAR(byte[] arr, int from, int to) { int limit = to - 7; // 8 bytes per VarHandle.get while (i < limit) { long word = (long) LONG_VIEW.get(arr, i); - if (swarHasMatch(word)) return true; + if (swarMatchMask(word) != 0L) return true; i += 8; } // Tail: remaining 0-7 bytes @@ -139,7 +138,7 @@ private static boolean hasEscapeCharSWAR(byte[] arr, int from, int to) { *

Uses Netty/Pekko pattern: XOR to produce zero lanes, then * Hacker's Delight formula to detect zero bytes. */ - private static boolean swarHasMatch(long word) { + private static long swarMatchMask(long word) { // 1. Detect '"' via XOR + zero-detection (Netty SWARUtil.applyPattern) long q = word ^ QUOTE; long qz = ~((q & HOLE) + HOLE | q | HOLE); @@ -152,7 +151,13 @@ private static boolean swarHasMatch(long word) { long c = word & CTRL; long cz = ~((c & HOLE) + HOLE | c | HOLE); - return (qz | bz | cz) != 0L; + return qz | bz | cz; + } + + private static int firstMatchedByte(long mask) { + return (LITTLE_ENDIAN + ? Long.numberOfTrailingZeros(mask) + : Long.numberOfLeadingZeros(mask)) >>> 3; } /** Scalar scan for String (used for short strings). */ diff --git a/sjsonnet/src-native/sjsonnet/CharSWAR.scala b/sjsonnet/src-native/sjsonnet/CharSWAR.scala index abe6afd3..da1f3b35 100644 --- a/sjsonnet/src-native/sjsonnet/CharSWAR.scala +++ b/sjsonnet/src-native/sjsonnet/CharSWAR.scala @@ -21,12 +21,14 @@ object CharSWAR { private final val QUOTE = 0x2222222222222222L private final val BSLAS = 0x5c5c5c5c5c5c5c5cL private final val CTRL = 0xe0e0e0e0e0e0e0e0L + private final val LITTLE_ENDIAN = + java.nio.ByteOrder.nativeOrder() == java.nio.ByteOrder.LITTLE_ENDIAN /** - * SWAR: returns true if any byte lane in `word` contains '"' (0x22), '\\' (0x5C), or a control + * SWAR: returns a mask for byte lanes in `word` containing '"' (0x22), '\\' (0x5C), or a control * char (< 0x20). */ - @inline private def swarHasMatch(word: Long): Boolean = { + @inline private def swarMatchMask(word: Long): Long = { // 1. Detect '"' via XOR + zero-detection val q = word ^ QUOTE val qz = ~((q & HOLE) + HOLE | q | HOLE) @@ -39,9 +41,13 @@ object CharSWAR { val c = word & CTRL val cz = ~((c & HOLE) + HOLE | c | HOLE) - (qz | bz | cz) != 0L + qz | bz | cz } + @inline private def firstMatchedByte(mask: Long): Int = + (if (LITTLE_ENDIAN) java.lang.Long.numberOfTrailingZeros(mask) + else java.lang.Long.numberOfLeadingZeros(mask)) >>> 3 + def hasEscapeChar(s: String): Boolean = { val len = s.length if (len < 128) { @@ -77,7 +83,7 @@ object CharSWAR { val limit = to - 7 while (i < limit) { val word = Intrinsics.loadLong(barr.atRawUnsafe(i)) - if (swarHasMatch(word)) return true + if (swarMatchMask(word) != 0L) return true i += 8 } // Tail: remaining 0-7 bytes @@ -97,13 +103,9 @@ object CharSWAR { val limit = to - 7 while (i < limit) { val word = Intrinsics.loadLong(barr.atRawUnsafe(i)) - if (swarHasMatch(word)) { - var j = i - while (j < i + 8) { - val b = arr(j) & 0xff - if (b < 32 || b == '"' || b == '\\') return j - j += 1 - } + val mask = swarMatchMask(word) + if (mask != 0L) { + return i + firstMatchedByte(mask) } i += 8 } diff --git a/sjsonnet/src/sjsonnet/BaseByteRenderer.scala b/sjsonnet/src/sjsonnet/BaseByteRenderer.scala index 711c5c50..4cfc3f32 100644 --- a/sjsonnet/src/sjsonnet/BaseByteRenderer.scala +++ b/sjsonnet/src/sjsonnet/BaseByteRenderer.scala @@ -325,32 +325,29 @@ class BaseByteRenderer[T <: java.io.OutputStream]( } else { val escapedLen = escapedStringLength(bytes, bLen, firstEscape) elemBuilder.ensureLength(escapedLen) - elemBuilder.appendUnsafeC('"') + val arr = elemBuilder.arr + var outPos = elemBuilder.length + arr(outPos) = '"'.toByte + outPos += 1 var from = 0 var escPos = firstEscape while (escPos >= 0) { if (escPos > from) { val chunkLen = escPos - from - elemBuilder.ensureLength(chunkLen) - val arr = elemBuilder.arr - val pos = elemBuilder.length - System.arraycopy(bytes, from, arr, pos, chunkLen) - elemBuilder.length = pos + chunkLen + System.arraycopy(bytes, from, arr, outPos, chunkLen) + outPos += chunkLen } - escapeByteInline(bytes(escPos) & 0xff) + outPos = escapeByteInline(bytes(escPos) & 0xff, arr, outPos) from = escPos + 1 escPos = if (from < bLen) CharSWAR.findFirstEscapeChar(bytes, from, bLen) else -1 } if (from < bLen) { val tailLen = bLen - from - elemBuilder.ensureLength(tailLen) - val arr = elemBuilder.arr - val pos = elemBuilder.length - System.arraycopy(bytes, from, arr, pos, tailLen) - elemBuilder.length = pos + tailLen + System.arraycopy(bytes, from, arr, outPos, tailLen) + outPos += tailLen } - elemBuilder.ensureLength(1) - elemBuilder.appendUnsafeC('"') + arr(outPos) = '"'.toByte + elemBuilder.length = outPos + 1 } } @@ -373,37 +370,45 @@ class BaseByteRenderer[T <: java.io.OutputStream]( } /** Inline JSON escape for one byte that is known to require escaping. */ - private def escapeByteInline(b: Int): Unit = { - elemBuilder.ensureLength(6) + @inline private def escapeByteInline(b: Int, arr: Array[Byte], outPos0: Int): Int = { + val outPos = outPos0 (b: @scala.annotation.switch) match { case '"' => - elemBuilder.appendUnsafeC('\\') - elemBuilder.appendUnsafeC('"') + arr(outPos) = '\\'.toByte + arr(outPos + 1) = '"'.toByte + outPos + 2 case '\\' => - elemBuilder.appendUnsafeC('\\') - elemBuilder.appendUnsafeC('\\') + arr(outPos) = '\\'.toByte + arr(outPos + 1) = '\\'.toByte + outPos + 2 case '\b' => - elemBuilder.appendUnsafeC('\\') - elemBuilder.appendUnsafeC('b') + arr(outPos) = '\\'.toByte + arr(outPos + 1) = 'b'.toByte + outPos + 2 case '\f' => - elemBuilder.appendUnsafeC('\\') - elemBuilder.appendUnsafeC('f') + arr(outPos) = '\\'.toByte + arr(outPos + 1) = 'f'.toByte + outPos + 2 case '\n' => - elemBuilder.appendUnsafeC('\\') - elemBuilder.appendUnsafeC('n') + arr(outPos) = '\\'.toByte + arr(outPos + 1) = 'n'.toByte + outPos + 2 case '\r' => - elemBuilder.appendUnsafeC('\\') - elemBuilder.appendUnsafeC('r') + arr(outPos) = '\\'.toByte + arr(outPos + 1) = 'r'.toByte + outPos + 2 case '\t' => - elemBuilder.appendUnsafeC('\\') - elemBuilder.appendUnsafeC('t') + arr(outPos) = '\\'.toByte + arr(outPos + 1) = 't'.toByte + outPos + 2 case c => - elemBuilder.appendUnsafeC('\\') - elemBuilder.appendUnsafeC('u') - elemBuilder.appendUnsafeC('0') - elemBuilder.appendUnsafeC('0') - elemBuilder.appendUnsafeC(BaseByteRenderer.HEX_CHARS((c >> 4) & 0xf)) - elemBuilder.appendUnsafeC(BaseByteRenderer.HEX_CHARS(c & 0xf)) + arr(outPos) = '\\'.toByte + arr(outPos + 1) = 'u'.toByte + arr(outPos + 2) = '0'.toByte + arr(outPos + 3) = '0'.toByte + arr(outPos + 4) = BaseByteRenderer.HEX_BYTES((c >> 4) & 0xf) + arr(outPos + 5) = BaseByteRenderer.HEX_BYTES(c & 0xf) + outPos + 6 } } @@ -453,7 +458,24 @@ object BaseByteRenderer { } /** Hex digits used by inline byte escaping for control chars. */ - private[sjsonnet] val HEX_CHARS: Array[Char] = "0123456789abcdef".toCharArray + private[sjsonnet] val HEX_BYTES: Array[Byte] = Array( + '0'.toByte, + '1'.toByte, + '2'.toByte, + '3'.toByte, + '4'.toByte, + '5'.toByte, + '6'.toByte, + '7'.toByte, + '8'.toByte, + '9'.toByte, + 'a'.toByte, + 'b'.toByte, + 'c'.toByte, + 'd'.toByte, + 'e'.toByte, + 'f'.toByte + ) /** * Reusable scratch buffer for writeLongDirect (max 20 bytes for Long.MinValue). Not thread-safe,