From e327ba28091b7fb5b71f346b0f50083eb89f9fe7 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Sat, 30 May 2026 19:24:42 +0800 Subject: [PATCH 1/2] perf: use unsynchronized StringBuilderWriter in TomlRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit std.manifestTomlEx routed through java.io.StringWriter, whose backing StringBuffer pays a monitor enter/exit on every write/flush on the hot TOML manifestation path. Switch TomlRenderer and the manifestTomlEx render path in ManifestModule to the unsynchronized package-private StringBuilderWriter (the same writer the JSON manifest renderer uses). Output is byte-identical; std.deepJoin keeps StringWriter (separate concern). Result (Scala Native hyperfine, TOML-heavy workload, ~1.8 MB output): after ran 1.11 ± 0.07x faster than before (~10%); output byte-identical. --- sjsonnet/src/sjsonnet/TomlRenderer.scala | 54 ++++++++++--------- .../src/sjsonnet/stdlib/ManifestModule.scala | 12 +++-- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/sjsonnet/src/sjsonnet/TomlRenderer.scala b/sjsonnet/src/sjsonnet/TomlRenderer.scala index 7bbc59cb..f5b0e93b 100644 --- a/sjsonnet/src/sjsonnet/TomlRenderer.scala +++ b/sjsonnet/src/sjsonnet/TomlRenderer.scala @@ -2,22 +2,24 @@ package sjsonnet import upickle.core.{ArrVisitor, ObjVisitor, SimpleVisitor, Visitor} -import java.io.StringWriter - +// Uses the unsynchronized [[StringBuilderWriter]] rather than java.io.StringWriter: the latter is +// backed by a synchronized StringBuffer, paying a monitor enter/exit on every write/flush on the +// hot manifestTomlEx path. Output is byte-identical. Same swap as the JSON renderer in #874. class TomlRenderer( - out: StringWriter = new java.io.StringWriter(), + out: StringBuilderWriter = new StringBuilderWriter(), cumulatedIndent: String, indent: String) - extends SimpleVisitor[StringWriter, StringWriter] { + extends SimpleVisitor[StringBuilderWriter, StringBuilderWriter] { override def expectedMsg: String = "unimplemented type in Materializer" - private object objectKeyRenderer extends upickle.core.SimpleVisitor[StringWriter, StringWriter] { + private object objectKeyRenderer + extends upickle.core.SimpleVisitor[StringBuilderWriter, StringBuilderWriter] { override def expectedMsg = "expected string" - override def visitNull(index: Int): StringWriter = { + override def visitNull(index: Int): StringBuilderWriter = { TomlRenderer.this.visitNull(index) } - override def visitString(s: CharSequence, index: Int): StringWriter = { + override def visitString(s: CharSequence, index: Int): StringBuilderWriter = { if (s == null) visitNull(index) else { TomlRenderer.writeEscapedKey(out, s) @@ -33,19 +35,19 @@ class TomlRenderer( out } - override def visitNull(index: Int): StringWriter = Error.fail("Tried to manifest \"null\"") + override def visitNull(index: Int): StringBuilderWriter = Error.fail("Tried to manifest \"null\"") - override def visitTrue(index: Int): StringWriter = { + override def visitTrue(index: Int): StringBuilderWriter = { out.write("true") flush } - override def visitFalse(index: Int): StringWriter = { + override def visitFalse(index: Int): StringBuilderWriter = { out.write("false") flush } - override def visitString(s: CharSequence, index: Int): StringWriter = { + override def visitString(s: CharSequence, index: Int): StringBuilderWriter = { if (s == null) { visitNull(index) } else { @@ -54,7 +56,7 @@ class TomlRenderer( } } - override def visitFloat64(d: Double, index: Int): StringWriter = { + override def visitFloat64(d: Double, index: Int): StringBuilderWriter = { d match { case Double.PositiveInfinity => out.write("inf") case Double.NegativeInfinity => out.write("-inf") @@ -65,8 +67,10 @@ class TomlRenderer( flush } - override def visitArray(length: Int, index: Int): ArrVisitor[StringWriter, StringWriter] = - new ArrVisitor[StringWriter, StringWriter] { + override def visitArray( + length: Int, + index: Int): ArrVisitor[StringBuilderWriter, StringBuilderWriter] = + new ArrVisitor[StringBuilderWriter, StringBuilderWriter] { private val isInLine = length == 0 || depth > 0 private val newElementIndent = if (isInLine) "" else cumulatedIndent + indent private val separator = @@ -76,7 +80,7 @@ class TomlRenderer( depth += 1 out.write('[') out.write(separator) - def subVisitor: Visitor[StringWriter, StringWriter] = { + def subVisitor: Visitor[StringBuilderWriter, StringBuilderWriter] = { if (addComma) { out.write(',') out.write(separator) @@ -84,10 +88,10 @@ class TomlRenderer( out.write(newElementIndent) TomlRenderer.this } - def visitValue(v: StringWriter, index: Int): Unit = { + def visitValue(v: StringBuilderWriter, index: Int): Unit = { addComma = true } - def visitEnd(index: Int): StringWriter = { + def visitEnd(index: Int): StringBuilderWriter = { addComma = false depth -= 1 out.write(separator) @@ -100,23 +104,23 @@ class TomlRenderer( override def visitObject( length: Int, jsonableKeys: Boolean, - index: Int): ObjVisitor[StringWriter, StringWriter] = - new ObjVisitor[StringWriter, StringWriter] { + index: Int): ObjVisitor[StringBuilderWriter, StringBuilderWriter] = + new ObjVisitor[StringBuilderWriter, StringBuilderWriter] { private var addComma = false depth += 1 out.write("{ ") - def subVisitor: Visitor[StringWriter, StringWriter] = TomlRenderer.this - def visitKey(index: Int): Visitor[StringWriter, StringWriter] = { + def subVisitor: Visitor[StringBuilderWriter, StringBuilderWriter] = TomlRenderer.this + def visitKey(index: Int): Visitor[StringBuilderWriter, StringBuilderWriter] = { if (addComma) out.write(", ") objectKeyRenderer } def visitKeyValue(s: Any): Unit = { out.write(" = ") } - def visitValue(v: StringWriter, index: Int): Unit = { + def visitValue(v: StringBuilderWriter, index: Int): Unit = { addComma = true } - def visitEnd(index: Int): StringWriter = { + def visitEnd(index: Int): StringBuilderWriter = { addComma = false depth -= 1 out.write(" }") @@ -146,14 +150,14 @@ object TomlRenderer { } } - def writeEscapedKey(out: StringWriter, key: CharSequence): Unit = { + def writeEscapedKey(out: StringBuilderWriter, key: CharSequence): Unit = { if (isBareKey(key)) out.write(key.toString) else BaseRenderer.escape(out, key, unicode = true) } def escapeKey(key: String): String = if (isBareKey(key)) key else { - val out = new StringWriter() + val out = new StringBuilderWriter() writeEscapedKey(out, key) out.toString } diff --git a/sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala b/sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala index 14a2a8d1..c03fefe7 100644 --- a/sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala +++ b/sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala @@ -184,11 +184,11 @@ object ManifestModule extends AbstractFunctionModule { } private def renderTableInternal( - out: StringWriter, + out: StringBuilderWriter, v: Val.Obj, cumulatedIndent: String, indent: String, - path: mutable.ArrayBuffer[String])(implicit ev: EvalScope): StringWriter = { + path: mutable.ArrayBuffer[String])(implicit ev: EvalScope): StringBuilderWriter = { val keys = v.sortedVisibleKeyNames if (keys.length == 0) { out.write('\n') @@ -263,7 +263,7 @@ object ManifestModule extends AbstractFunctionModule { out } - private def renderTableHeader(out: StringWriter, path: mutable.ArrayBuffer[String]) = { + private def renderTableHeader(out: StringBuilderWriter, path: mutable.ArrayBuffer[String]) = { out.write('[') var i = 0 while (i < path.length) { @@ -275,7 +275,9 @@ object ManifestModule extends AbstractFunctionModule { out } - private def renderTableArrayHeader(out: StringWriter, path: mutable.ArrayBuffer[String]) = { + private def renderTableArrayHeader( + out: StringBuilderWriter, + path: mutable.ArrayBuffer[String]) = { out.write('[') renderTableHeader(out, path) out.write(']') @@ -283,7 +285,7 @@ object ManifestModule extends AbstractFunctionModule { } def evalRhs(v: Eval, indent: Eval, ev: EvalScope, pos: Position): Val = { - val out = new StringWriter + val out = new StringBuilderWriter renderTableInternal( out, v.value.asObj, From 9c6fa5c92712daa7d2d95cef9cbeca551b5e7f54 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Wed, 3 Jun 2026 12:41:43 +0800 Subject: [PATCH 2/2] perf: cache resolved field values and skip binary search in renderTableInternal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each TOML table iteration was doing redundant work for every key: * v.value(k) was called twice — once to classify scalar vs section, then again to render or recurse. The cache deduplicates the result but the lookup itself still costs. * visibleKeyNames was iterated and each key binary-searched back into sortedVisibleKeyNames. Iterating sortedVisibleKeyNames directly is simpler and skips O(n log n) compares per table. * childIndent (cumulatedIndent + indent) was allocated inside the section loop once per section, all producing the same String for sibling sections. Also pre-size the output StringBuilderWriter to 1 KiB at the manifestTomlEx entry point so small/medium outputs skip the first few StringBuilder doublings. Output byte-identical (no behavior change). --- .../src/sjsonnet/stdlib/ManifestModule.scala | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala b/sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala index c03fefe7..86441481 100644 --- a/sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala +++ b/sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala @@ -170,19 +170,6 @@ object ManifestModule extends AbstractFunctionModule { private def isSection(v: Val) = v.isInstanceOf[Val.Obj] || isTableArray(v) - private def sortedKeyIndex(keys: Array[String], key: String): Int = { - var low = 0 - var high = keys.length - 1 - while (low <= high) { - val mid = (low + high) >>> 1 - val comparison = Util.CodepointStringOrdering.compare(keys(mid), key) - if (comparison < 0) low = mid + 1 - else if (comparison > 0) high = mid - 1 - else return mid - } - -1 - } - private def renderTableInternal( out: StringBuilderWriter, v: Val.Obj, @@ -195,36 +182,40 @@ object ManifestModule extends AbstractFunctionModule { return out } + // Resolve each field once and cache the result: the value is needed twice + // (to classify scalars vs sections, then to render). Iterating `keys` directly + // also skips the binary search that mapped `visibleKeyNames` back into `keys`. + val resolved = new Array[Val](keys.length) val sectionFlags = new Array[Boolean](keys.length) - val visibleKeys = v.visibleKeyNames - var visibleKeyIdx = 0 - while (visibleKeyIdx < visibleKeys.length) { - val k = visibleKeys(visibleKeyIdx) - val sortedIdx = sortedKeyIndex(keys, k) - sectionFlags(sortedIdx) = isSection(v.value(k, v.pos)(ev)) - visibleKeyIdx += 1 + var keyIdx = 0 + while (keyIdx < keys.length) { + val r = v.value(keys(keyIdx), v.pos)(ev) + resolved(keyIdx) = r + sectionFlags(keyIdx) = isSection(r) + keyIdx += 1 } val renderer = new TomlRenderer(out, cumulatedIndent, indent) - var keyIdx = 0 + keyIdx = 0 while (keyIdx < keys.length) { if (!sectionFlags(keyIdx)) { - val k = keys(keyIdx) out.write(cumulatedIndent) - TomlRenderer.writeEscapedKey(out, k) + TomlRenderer.writeEscapedKey(out, keys(keyIdx)) out.write(" = ") - Materializer.apply0(v.value(k, v.pos)(ev), renderer)(ev) + Materializer.apply0(resolved(keyIdx), renderer)(ev) } keyIdx += 1 } out.write('\n') + // childIndent depends only on cumulatedIndent + indent, so compute it once + // instead of per section iteration. + val childIndent = cumulatedIndent + indent keyIdx = 0 while (keyIdx < keys.length) { if (sectionFlags(keyIdx)) { val k = keys(keyIdx) - val v0 = v.value(k, v.pos, v)(ev) - val childIndent = cumulatedIndent + indent + val v0 = resolved(keyIdx) path += k v0 match { case arr: Val.Arr => @@ -285,7 +276,9 @@ object ManifestModule extends AbstractFunctionModule { } def evalRhs(v: Eval, indent: Eval, ev: EvalScope, pos: Position): Val = { - val out = new StringBuilderWriter + // Pre-size at 1 KiB to skip the first ~6 doublings (16→1024) for typical TOML + // outputs without overcommitting memory on small ones. + val out = new StringBuilderWriter(1024) renderTableInternal( out, v.value.asObj,