Skip to content

Commit 89c64be

Browse files
authored
perf: convert stdlib hot paths from Scala collections to while-loops (#765)
## Motivation Scala collection methods like `forall`, `exists`, `map`, `mkString`, `indexWhere`, and `forEach` create closures, iterators, and intermediate collections that are harder for JIT/AOT compilers to optimize. In hot stdlib paths, these can be replaced with imperative while-loops for better performance. ## Key Design Decision Replace functional collection operations with while-loops only in hot stdlib paths where profiling shows measurable impact. This improves JIT inlining, reduces allocation pressure, and produces more predictable native code under Scala Native LTO. ## Modification **ObjectModule** (6 methods): - `objectFields`, `objectFieldsAll`, `objectFieldsEx`: iterator → while-loop over `allKeysArray` - `getObjValuesFromKeys`: for-comprehension → while-loop - `objectKeysValues`, `objectKeysValuesAll`: for-comprehension → while-loop with pre-allocated array - `mergePatch.distinctKeys`: `(lKeys ++ rKeys).distinct` → `LinkedHashSet`-based dedup **ArrayModule** (7 methods): - `all`: `forall` → early-exit while-loop returning `Val.staticTrue/False` - `any`: `exists` → early-exit while-loop returning `Val.staticTrue/False` - `member` (array path): `indexWhere` → while-loop with `found` flag - `contains`: `indexWhere` → while-loop with `found` flag - `remove`: `indexWhere` → while-loop with `idx` sentinel - `minArray/maxArray` (keyF path): `map.zipWithIndex.min/max` → single-pass while-loop - `repeat`: `ArrayBuilder ++= lazyArray` → `System.arraycopy` **ManifestModule** (1 method): - `manifestYamlStream`: `map.mkString` → `StringBuilder` with while-loop **StringModule** (1 method): - `escapeStringXML`: `for (c <- str)` → `while` with `charAt` **Val.Obj** (1 method): - `visibleKeyNames`: `forEach` lambda → `entrySet` iterator while-loop ## Benchmark Results ### JMH (JVM, single iteration, lower is better) | Benchmark | Before (ms/op) | After (ms/op) | Change | |-----------|----------------|---------------|--------| | gen_big_object | 1.122 | 0.933 | **-16.8%** ✅ | | large_string_template | 2.432 | 1.659 | **-31.8%** ✅ | | realistic2 | 61.774 | 56.379 | **-8.7%** ✅ | | large_string_join | 0.582 | 0.561 | -3.6% | | member | 0.665 | 0.661 | -0.6% | | setDiff | 0.426 | 0.416 | -2.3% | | setInter | 0.377 | 0.371 | -1.6% | | setUnion | 0.638 | 0.623 | -2.3% | | bench.02 | 35.330 | 34.461 | -2.5% | No regressions observed (bench.07 variance is JMH single-fork noise). ### Hyperfine (Scala Native vs jrsonnet, Apple Silicon) | Benchmark | sjsonnet (ms) | jrsonnet (ms) | Ratio | |-----------|--------------|--------------|-------| | gen_big_object | 10.1 | 13.2 | **1.31x faster** ✅ | | large_string_join | 9.0 | 7.7 | 1.17x slower | | large_string_template | 14.3 | 6.9 | 2.07x slower | | realistic2 | 161.2 | 100.4 | 1.61x slower | | member | 8.4 | 5.8 | 1.45x slower | | comparison | 18.3 | 13.5 | 1.36x slower | ## Analysis The biggest JMH win is `large_string_template` (-31.8%) which benefits from the ObjectModule while-loop conversions since format rendering triggers object field enumeration. The `gen_big_object` improvement (-16.8%) directly measures ObjectModule throughput. `realistic2` (-8.7%) shows compound benefits from all modules. On Scala Native, `gen_big_object` is now definitively faster than jrsonnet (1.31x). The remaining gaps in string-heavy benchmarks require rope strings (#761) and format-level optimizations. ## References Ported from jit branch exploration commits: - af4832f (std.all/any/member while-loops) - cd612df (escapeStringXML, std.contains/remove while-loops) - d1629fd (manifestYamlStream StringBuilder) - 8cd8d31 (minArray/maxArray single-pass) - 9149654 (visibleKeyNames entrySet iterator) ## Result All 420 tests pass across JVM/JS/WASM/Native × Scala 3.3.7/2.13.18/2.12.21. Consistent improvements on object-heavy and realistic workloads with no regressions.
1 parent 7a6144a commit 89c64be

5 files changed

Lines changed: 160 additions & 80 deletions

File tree

sjsonnet/src/sjsonnet/Val.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1126,7 +1126,11 @@ object Val {
11261126
buf.sizeHint(v0.size())
11271127
v0.forEach((k, m) => if (m.visibility != Visibility.Hidden) buf += k)
11281128
} else {
1129-
getAllKeys.forEach((k, b) => if (b == java.lang.Boolean.FALSE) buf += k)
1129+
val iter = getAllKeys.entrySet().iterator()
1130+
while (iter.hasNext()) {
1131+
val e = iter.next()
1132+
if (e.getValue() == java.lang.Boolean.FALSE) buf += e.getKey()
1133+
}
11301134
}
11311135
buf.result()
11321136
}

sjsonnet/src/sjsonnet/stdlib/ArrayModule.scala

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,20 @@ object ArrayModule extends AbstractFunctionModule {
2727
} else if (keyF.isInstanceOf[Val.False]) {
2828
arr.asStrictArray.min(ev)
2929
} else {
30-
val minTuple = arr.asStrictArray
31-
.map(v =>
32-
keyF
33-
.asInstanceOf[Val.Func]
34-
.apply1(v, pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled)
35-
)
36-
.zipWithIndex
37-
.min((x: (Val, Int), y: (Val, Int)) => ev.compare(x._1, y._1))
38-
arr.value(minTuple._2)
30+
val strict = arr.asStrictArray
31+
val func = keyF.asInstanceOf[Val.Func]
32+
var bestIdx = 0
33+
var bestVal = func.apply1(strict(0), pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled)
34+
var i = 1
35+
while (i < strict.length) {
36+
val v = func.apply1(strict(i), pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled)
37+
if (ev.compare(v, bestVal) < 0) {
38+
bestVal = v
39+
bestIdx = i
40+
}
41+
i += 1
42+
}
43+
strict(bestIdx)
3944
}
4045
}
4146
}
@@ -59,28 +64,45 @@ object ArrayModule extends AbstractFunctionModule {
5964
} else if (keyF.isInstanceOf[Val.False]) {
6065
arr.asStrictArray.max(ev)
6166
} else {
62-
val maxTuple = arr.asStrictArray
63-
.map(v =>
64-
keyF
65-
.asInstanceOf[Val.Func]
66-
.apply1(v, pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled)
67-
)
68-
.zipWithIndex
69-
.max((x: (Val, Int), y: (Val, Int)) => ev.compare(x._1, y._1))
70-
arr.value(maxTuple._2)
67+
val strict = arr.asStrictArray
68+
val func = keyF.asInstanceOf[Val.Func]
69+
var bestIdx = 0
70+
var bestVal = func.apply1(strict(0), pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled)
71+
var i = 1
72+
while (i < strict.length) {
73+
val v = func.apply1(strict(i), pos.fileScope.noOffsetPos)(ev, TailstrictModeDisabled)
74+
if (ev.compare(v, bestVal) > 0) {
75+
bestVal = v
76+
bestIdx = i
77+
}
78+
i += 1
79+
}
80+
strict(bestIdx)
7181
}
7282
}
7383
}
7484

7585
private object All extends Val.Builtin1("all", "arr") {
7686
def evalRhs(arr: Eval, ev: EvalScope, pos: Position): Val = {
77-
Val.bool(arr.value.asArr.forall(v => v.asBoolean))
87+
val a = arr.value.asArr
88+
var i = 0
89+
while (i < a.length) {
90+
if (!a.value(i).asBoolean) return Val.staticFalse
91+
i += 1
92+
}
93+
Val.staticTrue
7894
}
7995
}
8096

8197
private object Any extends Val.Builtin1("any", "arr") {
8298
def evalRhs(arr: Eval, ev: EvalScope, pos: Position): Val = {
83-
Val.bool(arr.value.asArr.iterator.exists(v => v.asBoolean))
99+
val a = arr.value.asArr
100+
var i = 0
101+
while (i < a.length) {
102+
if (a.value(i).asBoolean) return Val.staticTrue
103+
i += 1
104+
}
105+
Val.staticFalse
84106
}
85107
}
86108

@@ -271,7 +293,14 @@ object ArrayModule extends AbstractFunctionModule {
271293
}
272294
str.str.contains(secondArg)
273295
case a: Val.Arr =>
274-
a.asLazyArray.indexWhere(v => ev.equal(v.value, x.value)) >= 0
296+
val la = a.asLazyArray
297+
var i = 0
298+
var found = false
299+
while (i < la.length && !found) {
300+
if (ev.equal(la(i).value, x.value)) found = true
301+
i += 1
302+
}
303+
found
275304
case arr =>
276305
Error.fail(
277306
"std.member first argument must be an array or a string, got " + arr.prettyName
@@ -543,14 +572,14 @@ object ArrayModule extends AbstractFunctionModule {
543572
Val.Str(pos, builder.toString())
544573
case a: Val.Arr =>
545574
val lazyArray = a.asLazyArray
546-
val out = new mutable.ArrayBuilder.ofRef[Eval]
547-
out.sizeHint(lazyArray.length * count)
575+
val elemLen = lazyArray.length
576+
val result = new Array[Eval](elemLen * count)
548577
var i = 0
549578
while (i < count) {
550-
out ++= lazyArray
579+
System.arraycopy(lazyArray, 0, result, i * elemLen, elemLen)
551580
i += 1
552581
}
553-
Val.Arr(pos, out.result())
582+
Val.Arr(pos, result)
554583
case x => Error.fail("std.repeat first argument must be an array or a string")
555584
}
556585
res
@@ -579,10 +608,23 @@ object ArrayModule extends AbstractFunctionModule {
579608
)
580609
},
581610
builtin("contains", "arr", "elem") { (_, ev, arr: Val.Arr, elem: Val) =>
582-
arr.asLazyArray.indexWhere(s => ev.equal(s.value, elem)) != -1
611+
val la = arr.asLazyArray
612+
var i = 0
613+
var found = false
614+
while (i < la.length && !found) {
615+
if (ev.equal(la(i).value, elem)) found = true
616+
i += 1
617+
}
618+
found
583619
},
584620
builtin("remove", "arr", "elem") { (_, ev, arr: Val.Arr, elem: Val) =>
585-
val idx = arr.asLazyArray.indexWhere(s => ev.equal(s.value, elem))
621+
val la = arr.asLazyArray
622+
var idx = -1
623+
var i = 0
624+
while (i < la.length && idx == -1) {
625+
if (ev.equal(la(i).value, elem)) idx = i
626+
i += 1
627+
}
586628
if (idx == -1) {
587629
arr
588630
} else {

sjsonnet/src/sjsonnet/stdlib/ManifestModule.scala

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,16 +270,24 @@ object ManifestModule extends AbstractFunctionModule {
270270
}
271271
v match {
272272
case arr: Val.Arr =>
273-
arr.asLazyArray
274-
.map { item =>
273+
val items = arr.asLazyArray
274+
val sb = new java.lang.StringBuilder()
275+
var i = 0
276+
while (i < items.length) {
277+
if (i > 0) sb.append("\n---\n")
278+
else sb.append("---\n")
279+
sb.append(
275280
Materializer
276281
.apply0(
277-
item.value,
282+
items(i).value,
278283
new YamlRenderer(indentArrayInObject = indentArrayInObject, quoteKeys = quoteKeys)
279284
)(ev)
280285
.toString
281-
}
282-
.mkString("---\n", "\n---\n", if (cDocumentEnd) "\n...\n" else "\n")
286+
)
287+
i += 1
288+
}
289+
if (cDocumentEnd) sb.append("\n...\n") else sb.append('\n')
290+
sb.toString
283291
case _ => Error.fail("manifestYamlStream only takes arrays, got " + v.getClass)
284292
}
285293
},

sjsonnet/src/sjsonnet/stdlib/ObjectModule.scala

Lines changed: 68 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,26 @@ object ObjectModule extends AbstractFunctionModule {
3838
private object ObjectFields extends Val.Builtin1("objectFields", "o") {
3939
def evalRhs(o: Eval, ev: EvalScope, pos: Position): Val = {
4040
val keys = getVisibleKeys(ev, o.value.asObj)
41-
Val.Arr(pos, keys.map(k => Val.Str(pos, k)))
41+
val result = new Array[Eval](keys.length)
42+
var i = 0
43+
while (i < keys.length) {
44+
result(i) = Val.Str(pos, keys(i))
45+
i += 1
46+
}
47+
Val.Arr(pos, result)
4248
}
4349
}
4450

4551
private object ObjectFieldsAll extends Val.Builtin1("objectFieldsAll", "o") {
4652
def evalRhs(o: Eval, ev: EvalScope, pos: Position): Val = {
4753
val keys = getAllKeys(ev, o.value.asObj)
48-
Val.Arr(pos, keys.map(k => Val.Str(pos, k)))
54+
val result = new Array[Eval](keys.length)
55+
var i = 0
56+
while (i < keys.length) {
57+
result(i) = Val.Str(pos, keys(i))
58+
i += 1
59+
}
60+
Val.Arr(pos, result)
4961
}
5062
}
5163

@@ -54,7 +66,13 @@ object ObjectModule extends AbstractFunctionModule {
5466
val keys =
5567
if (incHidden.value.asBoolean) getAllKeys(ev, o.value.asObj)
5668
else getVisibleKeys(ev, o.value.asObj)
57-
Val.Arr(pos, keys.map(k => Val.Str(pos, k)))
69+
val result = new Array[Eval](keys.length)
70+
var i = 0
71+
while (i < keys.length) {
72+
result(i) = Val.Str(pos, keys(i))
73+
i += 1
74+
}
75+
Val.Arr(pos, result)
5876
}
5977
}
6078

@@ -165,13 +183,16 @@ object ObjectModule extends AbstractFunctionModule {
165183
pos: Position,
166184
ev: EvalScope,
167185
v1: Val.Obj,
168-
keys: Array[String]): Val.Arr =
169-
Val.Arr(
170-
pos,
171-
keys.map { k =>
172-
new LazyFunc(() => v1.value(k, pos.noOffset)(ev))
173-
}
174-
)
186+
keys: Array[String]): Val.Arr = {
187+
val result = new Array[Eval](keys.length)
188+
var i = 0
189+
while (i < keys.length) {
190+
val k = keys(i)
191+
result(i) = new LazyFunc(() => v1.value(k, pos.noOffset)(ev))
192+
i += 1
193+
}
194+
Val.Arr(pos, result)
195+
}
175196

176197
val functions: Seq[(String, Val.Func)] = Seq(
177198
builtin(ObjectHas),
@@ -193,45 +214,43 @@ object ObjectModule extends AbstractFunctionModule {
193214
builtin(MapWithKey),
194215
builtin("objectKeysValues", "o") { (pos, ev, o: Val.Obj) =>
195216
val keys = getVisibleKeys(ev, o)
196-
Val.Arr(
197-
pos,
198-
keys.map(k =>
199-
Val.Obj.mk(
200-
pos.fileScope.noOffsetPos,
201-
"key" -> new Val.Obj.ConstMember(
202-
false,
203-
Visibility.Normal,
204-
Val.Str(pos.fileScope.noOffsetPos, k)
205-
),
206-
"value" -> new Val.Obj.ConstMember(
207-
false,
208-
Visibility.Normal,
209-
o.value(k, pos.fileScope.noOffsetPos)(ev)
210-
)
217+
val noOffsetPos = pos.fileScope.noOffsetPos
218+
val result = new Array[Eval](keys.length)
219+
var i = 0
220+
while (i < keys.length) {
221+
val k = keys(i)
222+
result(i) = Val.Obj.mk(
223+
noOffsetPos,
224+
"key" -> new Val.Obj.ConstMember(false, Visibility.Normal, Val.Str(noOffsetPos, k)),
225+
"value" -> new Val.Obj.ConstMember(
226+
false,
227+
Visibility.Normal,
228+
o.value(k, noOffsetPos)(ev)
211229
)
212230
)
213-
)
231+
i += 1
232+
}
233+
Val.Arr(pos, result)
214234
},
215235
builtin("objectKeysValuesAll", "o") { (pos, ev, o: Val.Obj) =>
216236
val keys = getAllKeys(ev, o)
217-
Val.Arr(
218-
pos,
219-
keys.map(k =>
220-
Val.Obj.mk(
221-
pos.fileScope.noOffsetPos,
222-
"key" -> new Val.Obj.ConstMember(
223-
false,
224-
Visibility.Normal,
225-
Val.Str(pos.fileScope.noOffsetPos, k)
226-
),
227-
"value" -> new Val.Obj.ConstMember(
228-
false,
229-
Visibility.Normal,
230-
o.value(k, pos.fileScope.noOffsetPos)(ev)
231-
)
237+
val noOffsetPos = pos.fileScope.noOffsetPos
238+
val result = new Array[Eval](keys.length)
239+
var i = 0
240+
while (i < keys.length) {
241+
val k = keys(i)
242+
result(i) = Val.Obj.mk(
243+
noOffsetPos,
244+
"key" -> new Val.Obj.ConstMember(false, Visibility.Normal, Val.Str(noOffsetPos, k)),
245+
"value" -> new Val.Obj.ConstMember(
246+
false,
247+
Visibility.Normal,
248+
o.value(k, noOffsetPos)(ev)
232249
)
233250
)
234-
)
251+
i += 1
252+
}
253+
Val.Arr(pos, result)
235254
},
236255
builtin("objectRemoveKey", "obj", "key") { (pos, ev, o: Val.Obj, key: String) =>
237256
o.removeKeys(pos, key)
@@ -348,8 +367,13 @@ object ObjectModule extends AbstractFunctionModule {
348367
outArray
349368
}
350369
} else {
351-
// Fallback: Use hash-based deduplication for large RHS arrays:
352-
(lKeys ++ rKeys).distinct
370+
// Fallback: Use LinkedHashSet for large RHS arrays (preserves order, avoids quadratic):
371+
val allKeys = new java.util.LinkedHashSet[String](lKeys.length + rKeys.length)
372+
var li = 0
373+
while (li < lKeys.length) { allKeys.add(lKeys(li)); li += 1 }
374+
var ri = 0
375+
while (ri < rKeys.length) { allKeys.add(rKeys(ri)); ri += 1 }
376+
allKeys.toArray(new Array[String](allKeys.size))
353377
}
354378
}
355379
recPair(target.value, patch.value)

sjsonnet/src/sjsonnet/stdlib/StringModule.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -518,15 +518,17 @@ object StringModule extends AbstractFunctionModule {
518518
},
519519
builtin("escapeStringXML", "str") { (_, _, str: String) =>
520520
val out = new java.io.StringWriter()
521-
for (c <- str) {
522-
c match {
521+
var i = 0
522+
while (i < str.length) {
523+
str.charAt(i) match {
523524
case '<' => out.write("&lt;")
524525
case '>' => out.write("&gt;")
525526
case '&' => out.write("&amp;")
526527
case '"' => out.write("&quot;")
527528
case '\'' => out.write("&apos;")
528-
case _ => out.write(c)
529+
case c => out.write(c)
529530
}
531+
i += 1
530532
}
531533
out.toString
532534
},

0 commit comments

Comments
 (0)