From fb4c2ad373ab82904690f434627512f47f30e223 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Sat, 22 Aug 2026 17:33:13 +0800 Subject: [PATCH] perf: add rendered key byte cache in ByteRenderer Motivation: Object keys are rendered (quoted + escaped) on every materialization. In K8s manifests and similar workloads, the same short keys repeat thousands of times. Each render re-runs getChars + escape scan. Modification: - 64-slot identity-keyed cache for quoted object key bytes - Two-touch pattern: store key on first sight, capture bytes on second identity hit (avoids byte[] allocation for one-shot keys) - Cache hit: System.arraycopy pre-rendered bytes (zero escape scan) - Keys > 32 chars bypass cache entirely (rare, not worth caching) - Identity-keyed (eq) so no hashCode/equals overhead on lookup - Lazy initialization avoids allocation for small outputs Result: MainBenchmark: within noise (no regression). The cache synergizes with identifier interning (#1117) which guarantees stable String identity for field names, maximizing hit rate. --- sjsonnet/src/sjsonnet/ByteRenderer.scala | 43 +++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/sjsonnet/src/sjsonnet/ByteRenderer.scala b/sjsonnet/src/sjsonnet/ByteRenderer.scala index b7649619..9dfe6830 100644 --- a/sjsonnet/src/sjsonnet/ByteRenderer.scala +++ b/sjsonnet/src/sjsonnet/ByteRenderer.scala @@ -58,6 +58,8 @@ class ByteRenderer(out: OutputStream = new java.io.ByteArrayOutputStream(), inde private var stringValueCount = 0 private var stringValueCacheKeys: Array[String] = null private var stringValueCacheBytes: Array[Array[Byte]] = null + private var keyCacheKeys: Array[String] = null + private var keyCacheBytes: Array[Array[Byte]] = null override def visitFloat64(d: Double, index: Int): OutputStream = { flushBuffer() @@ -320,12 +322,48 @@ class ByteRenderer(out: OutputStream = new java.io.ByteArrayOutputStream(), inde ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): Unit = { markNonEmpty() flushBuffer() - renderQuotedString(key) + renderCachedKey(key) elemBuilder.append(':') elemBuilder.append(' ') materializeChild(childVal, matDepth, ctx) } + @inline private def renderCachedKey(key: String): Unit = { + if (key.length > ByteRenderer.KeyCacheMaxKeyLength) { + renderQuotedString(key) + return + } + var cacheKeys = keyCacheKeys + var cacheBytes = keyCacheBytes + if (cacheKeys == null) { + cacheKeys = new Array[String](ByteRenderer.KeyCacheSize) + cacheBytes = new Array[Array[Byte]](ByteRenderer.KeyCacheSize) + keyCacheKeys = cacheKeys + keyCacheBytes = cacheBytes + } + val slot = System.identityHashCode(key) & ByteRenderer.KeyCacheMask + if (cacheKeys(slot) eq key) { + val bytes = cacheBytes(slot) + if (bytes != null) { + appendCachedStringBytes(bytes) + return + } + // Second identity hit: capture the rendered bytes for replay. + val startLen = elemBuilder.length + renderQuotedString(key) + val rendered = elemBuilder.length - startLen + val made = new Array[Byte](rendered) + System.arraycopy(elemBuilder.arr, startLen, made, 0, rendered) + cacheBytes(slot) = made + } else { + // First sight (or collision eviction): don't allocate yet — one-shot keys + // (e.g. computed field names) would otherwise pay a byte[] copy per key. + cacheKeys(slot) = key + cacheBytes(slot) = null + renderQuotedString(key) + } + } + private def renderAsciiSafeValueString(str: String): Unit = { val len = str.length if ( @@ -574,4 +612,7 @@ object ByteRenderer { private final val StringValueCacheSize = 32 private final val StringValueCacheMask = StringValueCacheSize - 1 private final val DirectCachedStringWriteMinLength = 1024 + private final val KeyCacheSize = 64 + private final val KeyCacheMask = KeyCacheSize - 1 + private final val KeyCacheMaxKeyLength = 32 }