Skip to content

Commit 2965767

Browse files
committed
perf: speed up direct long rendering
Motivation: Direct long rendering already used scratch buffers and digit-pair lookup tables, but it still looped over /100 for every two digits. The TOML exact-long path also allocated an intermediate String and used a math.round guard that saturated integer doubles above Long.MaxValue. Modification: Introduce a shared FastLongRenderer that writes ASCII long digits with jsoniter-style 1e8/1e16 Math.multiplyHigh chunking plus jeaiii-style digit extraction for int-sized chunks. Wire BaseByteRenderer and BaseCharRenderer through the shared helper, keep TOML exact Long values on StringBuilder.append(Long), and fall back to RenderUtils.renderDouble for non-Long doubles. Result: Char and byte renderers now share one long-rendering implementation with boundary/random regression coverage. JVM, JS, WASM, and Native renderer tests pass for the Math.multiplyHigh path. Local JMH shows byte long rendering 1.22x faster and TOML exact-long rendering 1.92x faster, while char long rendering remains effectively flat.
1 parent e686d89 commit 2965767

7 files changed

Lines changed: 365 additions & 118 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package sjsonnet.bench
2+
3+
import org.openjdk.jmh.annotations.*
4+
import org.openjdk.jmh.infra.Blackhole
5+
import sjsonnet.*
6+
7+
import java.io.ByteArrayOutputStream
8+
import java.util.concurrent.TimeUnit
9+
10+
@BenchmarkMode(Array(Mode.AverageTime))
11+
@Fork(1)
12+
@Warmup(iterations = 5)
13+
@Measurement(iterations = 5)
14+
@OutputTimeUnit(TimeUnit.MICROSECONDS)
15+
@State(Scope.Thread)
16+
class LongRenderingBenchmark {
17+
import LongRenderingBenchmark.*
18+
19+
@Benchmark
20+
def baseCharRendererLongs(bh: Blackhole): Unit = {
21+
val out = new StringBuilderWriter(BatchSize * 20)
22+
val renderer = new ExposedCharRenderer(out)
23+
var i = 0
24+
while (i < BatchSize) {
25+
renderer.write(Values(i & ValuesMask))
26+
i += 1
27+
}
28+
renderer.flushCharBuilder()
29+
bh.consume(out.getBuilder.length)
30+
}
31+
32+
@Benchmark
33+
def baseByteRendererLongs(bh: Blackhole): Unit = {
34+
val out = new ByteArrayOutputStream(BatchSize * 20)
35+
val renderer = new ExposedByteRenderer(out)
36+
var i = 0
37+
while (i < BatchSize) {
38+
renderer.write(Values(i & ValuesMask))
39+
i += 1
40+
}
41+
renderer.flushByteBuilder()
42+
bh.consume(out.size)
43+
}
44+
45+
@Benchmark
46+
def tomlRendererExactLongDoubles(bh: Blackhole): Unit = {
47+
val out = new StringBuilderWriter(BatchSize * 20)
48+
val renderer = new TomlRenderer(out, "", " ")
49+
var i = 0
50+
while (i < BatchSize) {
51+
renderer.visitFloat64(TomlValues(i & TomlValuesMask), -1)
52+
i += 1
53+
}
54+
bh.consume(out.getBuilder.length)
55+
}
56+
}
57+
58+
object LongRenderingBenchmark {
59+
final val BatchSize = 4096
60+
61+
private val Values: Array[Long] = Array(
62+
0L,
63+
7L,
64+
42L,
65+
99L,
66+
100L,
67+
12345L,
68+
99999999L,
69+
100000000L,
70+
123456789L,
71+
999999999999L,
72+
9007199254740991L,
73+
9007199254740992L,
74+
Long.MaxValue,
75+
-1L,
76+
-123456789L,
77+
Long.MinValue
78+
)
79+
private val ValuesMask = Values.length - 1
80+
81+
private val TomlValues: Array[Double] = Array(
82+
0.0, 7.0, 42.0, 99.0, 100.0, 12345.0, 99999999.0, 100000000.0, 123456789.0, 999999999999.0,
83+
9007199254740991.0, 9007199254740992.0, -1.0, -123456789.0, -9007199254740991.0,
84+
-9007199254740992.0
85+
)
86+
private val TomlValuesMask = TomlValues.length - 1
87+
88+
private final class ExposedCharRenderer(out: StringBuilderWriter)
89+
extends BaseCharRenderer[StringBuilderWriter](out) {
90+
def write(v: Long): Unit = writeLongDirect(v)
91+
}
92+
93+
private final class ExposedByteRenderer(out: ByteArrayOutputStream)
94+
extends BaseByteRenderer[ByteArrayOutputStream](out) {
95+
def write(v: Long): Unit = writeLongDirect(v)
96+
}
97+
}

sjsonnet/src/sjsonnet/BaseByteRenderer.scala

Lines changed: 7 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class BaseByteRenderer[T <: java.io.OutputStream](
2828

2929
protected val elemBuilder = new upickle.core.ByteBuilder
3030
private val unicodeCharBuilder = new upickle.core.CharBuilder
31-
private val longScratchBuf: Array[Byte] = new Array[Byte](20)
31+
private val longScratchBuf: Array[Byte] = new Array[Byte](FastLongRenderer.MaxLongChars)
3232

3333
def flushByteBuilder(): Unit = {
3434
elemBuilder.writeOutToIfLongerThan(out, if (depth == 0) 0 else 8192)
@@ -166,49 +166,18 @@ class BaseByteRenderer[T <: java.io.OutputStream](
166166

167167
/**
168168
* Write a long integer directly into elemBuilder without intermediate String allocation. Uses
169-
* digit-pair lookup table for fast two-digits-at-a-time conversion.
169+
* digit-pair lookup tables and 8-digit chunking to keep hardware long division out of the common
170+
* int-sized path and to reduce it to at most two divisions for larger longs.
170171
*/
171172
protected def writeLongDirect(v: Long): Unit = {
172173
flushBuffer()
173-
if (v == 0L) {
174-
elemBuilder.ensureLength(1)
175-
elemBuilder.appendUnsafeC('0')
176-
return
177-
}
178-
if (v == Long.MinValue) {
179-
visitFloat64StringParts("-9223372036854775808", -1, -1, -1)
180-
return
181-
}
182-
val negative = v < 0
183-
var abs = if (negative) -v else v
184-
// Write digits backward into a small local buffer, then bulk-copy.
185-
// Max Long digits = 19, plus sign = 20.
186174
val buf = longScratchBuf
187-
var pos = 20
188-
while (abs >= 100) {
189-
val q = abs / 100
190-
val r = (abs - q * 100L).toInt
191-
abs = q
192-
pos -= 2
193-
buf(pos + 1) = BaseByteRenderer.DIGIT_ONES(r)
194-
buf(pos) = BaseByteRenderer.DIGIT_TENS(r)
195-
}
196-
if (abs >= 10) {
197-
val r = abs.toInt
198-
pos -= 2
199-
buf(pos + 1) = BaseByteRenderer.DIGIT_ONES(r)
200-
buf(pos) = BaseByteRenderer.DIGIT_TENS(r)
201-
} else {
202-
pos -= 1
203-
buf(pos) = ('0' + abs.toInt).toByte
204-
}
205-
if (negative) { pos -= 1; buf(pos) = '-'.toByte }
206-
val totalLen = 20 - pos
207-
elemBuilder.ensureLength(totalLen)
175+
val len = FastLongRenderer.writeLong(v, buf, 0)
176+
elemBuilder.ensureLength(len)
208177
val bArr = elemBuilder.arr
209178
val startPos = elemBuilder.length
210-
System.arraycopy(buf, pos, bArr, startPos, totalLen)
211-
elemBuilder.length = startPos + totalLen
179+
System.arraycopy(buf, 0, bArr, startPos, len)
180+
elemBuilder.length = startPos + len
212181
}
213182

214183
def visitString(s: CharSequence, index: Int): T = {
@@ -545,21 +514,4 @@ object BaseByteRenderer {
545514
'f'.toByte
546515
)
547516

548-
/**
549-
* Digit-pair lookup tables for two-digits-at-a-time integer rendering. DIGIT_TENS(i) gives the
550-
* tens digit byte for value i (0..99). DIGIT_ONES(i) gives the ones digit byte for value i
551-
* (0..99).
552-
*/
553-
private[sjsonnet] val DIGIT_TENS: Array[Byte] = {
554-
val a = new Array[Byte](100)
555-
var i = 0
556-
while (i < 100) { a(i) = ('0' + i / 10).toByte; i += 1 }
557-
a
558-
}
559-
private[sjsonnet] val DIGIT_ONES: Array[Byte] = {
560-
val a = new Array[Byte](100)
561-
var i = 0
562-
while (i < 100) { a(i) = ('0' + i % 10).toByte; i += 1 }
563-
a
564-
}
565517
}

sjsonnet/src/sjsonnet/BaseCharRenderer.scala

Lines changed: 14 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,6 @@ object BaseCharRenderer {
1515
*/
1616
final val MaxCachedDepth = 32
1717

18-
/** Digit-pair lookup tables for two-digits-at-a-time integer rendering. */
19-
private[sjsonnet] val DIGIT_TENS: Array[Char] = {
20-
val a = new Array[Char](100)
21-
var i = 0
22-
while (i < 100) { a(i) = ('0' + i / 10).toChar; i += 1 }
23-
a
24-
}
25-
private[sjsonnet] val DIGIT_ONES: Array[Char] = {
26-
val a = new Array[Char](100)
27-
var i = 0
28-
while (i < 100) { a(i) = ('0' + i % 10).toChar; i += 1 }
29-
a
30-
}
31-
3218
private[sjsonnet] val HEX_CHARS: Array[Char] =
3319
Array('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
3420
}
@@ -54,7 +40,7 @@ class BaseCharRenderer[T <: upickle.core.CharOps.Output](
5440
elemBuilder.writeOutToIfLongerThan(out, if (depth == 0) 0 else 1000)
5541
}
5642

57-
private val longScratchBuf: Array[Char] = new Array[Char](20)
43+
private val longScratchBuf: Array[Byte] = new Array[Byte](FastLongRenderer.MaxLongChars)
5844

5945
protected var depth: Int = 0
6046

@@ -218,49 +204,24 @@ class BaseCharRenderer[T <: upickle.core.CharOps.Output](
218204

219205
/**
220206
* Write a long integer directly into elemBuilder without intermediate String allocation. Uses
221-
* digit-pair lookup table for fast two-digits-at-a-time conversion.
207+
* digit-pair lookup tables and 8-digit chunking to keep hardware long division out of the common
208+
* int-sized path and to reduce it to at most two divisions for larger longs.
222209
*/
223210
protected def writeLongDirect(v: Long): Unit = {
224211
flushBuffer()
225-
if (v == 0L) {
226-
elemBuilder.ensureLength(1)
227-
elemBuilder.appendUnsafe('0')
228-
return
229-
}
230-
if (v == Long.MinValue) {
231-
visitFloat64StringParts("-9223372036854775808", -1, -1, -1)
232-
return
233-
}
234-
val negative = v < 0
235-
var abs = if (negative) -v else v
236-
// Write digits backward into a small local buffer, then bulk-copy.
237-
// Max Long digits = 19, plus sign = 20.
238212
val buf = longScratchBuf
239-
var pos = 20
240-
while (abs >= 100) {
241-
val q = abs / 100
242-
val r = (abs - q * 100L).toInt
243-
abs = q
244-
pos -= 2
245-
buf(pos + 1) = BaseCharRenderer.DIGIT_ONES(r)
246-
buf(pos) = BaseCharRenderer.DIGIT_TENS(r)
247-
}
248-
if (abs >= 10) {
249-
val r = abs.toInt
250-
pos -= 2
251-
buf(pos + 1) = BaseCharRenderer.DIGIT_ONES(r)
252-
buf(pos) = BaseCharRenderer.DIGIT_TENS(r)
253-
} else {
254-
pos -= 1
255-
buf(pos) = ('0' + abs.toInt).toChar
256-
}
257-
if (negative) { pos -= 1; buf(pos) = '-' }
258-
val totalLen = 20 - pos
259-
elemBuilder.ensureLength(totalLen)
213+
val len = FastLongRenderer.writeLong(v, buf, 0)
214+
elemBuilder.ensureLength(len)
260215
val cbArr = elemBuilder.arr
261-
val startPos = elemBuilder.getLength
262-
System.arraycopy(buf, pos, cbArr, startPos, totalLen)
263-
elemBuilder.length = startPos + totalLen
216+
var pos = elemBuilder.getLength
217+
val end = pos + len
218+
var i = 0
219+
while (pos < end) {
220+
cbArr(pos) = buf(i).toChar
221+
pos += 1
222+
i += 1
223+
}
224+
elemBuilder.length = end
264225
}
265226

266227
def visitString(s: CharSequence, index: Int): T = {

0 commit comments

Comments
 (0)