Skip to content

Commit ef04cb4

Browse files
perf: inline fields, shared keys, and concrete Members for objComp/mapWithKey (#758)
## Summary - **Shared allKeyNames/visibleKeyNames** (Val.Obj): Objects from the same `MemberList` expression now lazily share key-name arrays via a `_sourceMemberList` reference on `Val.Obj`. The first access computes and caches on the MemberList AST node; subsequent objects reuse the shared arrays. - **Inline field arrays for visitObjComp**: Apply the same `singleKey -> inlineKeys -> builder` tiered storage from `visitMemberList` to object comprehensions, avoiding `LinkedHashMap` for comprehensions producing 1-8 fields. - **Concrete ObjCompMember/MapWithKeyMember classes**: Replace anonymous `Val.Obj.Member` inner classes in `visitObjComp` and `std.mapWithKey` with concrete final classes for monomorphic JIT dispatch. - **Inline field arrays for std.mapWithKey**: Use `singleFieldKey`/`inlineFieldKeys` constructor paths for mapped objects with up to 8 keys, sharing the source object's `allKeyNames` array directly. - **Remove fieldLookups/fieldCacheHits counters**: These DebugStats counters in `Val.Obj.value()` were bloating the method past the JIT inlining threshold, causing a ~5% regression on bench.02. ## Benchmark results (vs pre-optimization baseline at 8c33fd9) | Benchmark | Baseline (ms/op) | After (ms/op) | Change | |---|---|---|---| | bench.02 | 76.368 | 73.352 | -3.9% | | bench.07 | 9.948 | 5.254 | **-47.2%** | | comparison | 6.941 | 4.204 | **-39.4%** | | large_string_template | 5.591 | 3.806 | **-31.9%** | | large_string_join | 1.476 | 1.048 | **-29.0%** | | base64 | 1.150 | 0.820 | **-28.7%** | | setDiff | 1.373 | 0.999 | **-27.2%** | | setUnion | 1.969 | 1.461 | **-25.8%** | | base64_byte_array | 2.925 | 2.198 | **-24.9%** | | realistic1 | 4.592 | 3.534 | **-23.0%** | | bench.04 | 0.486 | 0.375 | **-22.8%** | | foldl | 0.277 | 0.215 | **-22.4%** | | setInter | 1.072 | 0.838 | **-21.8%** | | substr | 0.212 | 0.174 | **-17.9%** | | realistic2 | 115.444 | 98.109 | **-15.0%** | All other benchmarks improved or within noise. No regressions. ## Test plan - [x] `./mill 'sjsonnet.jvm[3.3.7]'.test` — all 3 test suites pass - [x] `./mill __.checkFormat` — formatting clean - [x] `./mill bench.runRegressions` — no regressions
1 parent 82301d7 commit ef04cb4

5 files changed

Lines changed: 236 additions & 81 deletions

File tree

sjsonnet/src/sjsonnet/DebugStats.scala

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ final class DebugStats {
2929
var addSuperChainWalks: Long = 0
3030
var maxSuperChainDepth: Int = 0
3131
var valueCacheOverflows: Long = 0
32-
var fieldLookups: Long = 0
33-
var fieldCacheHits: Long = 0
3432

3533
// -- Parse / import --
3634
var filesParsed: Long = 0
@@ -59,8 +57,6 @@ final class DebugStats {
5957
sb.append(formatLine("add_super_chain_walks", addSuperChainWalks))
6058
sb.append(formatLine("max_super_chain_depth", maxSuperChainDepth))
6159
sb.append(formatLine("value_cache_overflows", valueCacheOverflows))
62-
sb.append(formatLine("field_lookups", fieldLookups))
63-
sb.append(formatLine("field_cache_hits", fieldCacheHits))
6460
sb.append(formatLine("files_parsed", filesParsed))
6561
sb.append(formatLine("import_calls", importCalls))
6662
sb.append(formatLine("import_cache_hits", importCacheHits))

sjsonnet/src/sjsonnet/Evaluator.scala

Lines changed: 118 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,9 +1748,10 @@ class Evaluator(
17481748
inlineFieldKeys = finalKeys,
17491749
inlineFieldMembers = finalMembers
17501750
)
1751-
// Cache sorted field order on MemberList (shared across all objects from same expression)
1751+
// Cache sorted field order on MemberList (shared across all objects from same expression).
1752+
// Only safe when all field names are fixed (not computed at runtime).
17521753
var sortedOrder = e._cachedSortedOrder
1753-
if (sortedOrder == null && sup == null) {
1754+
if (sortedOrder == null && sup == null && e.allFieldNamesFixed) {
17541755
sortedOrder = Materializer.computeSortedInlineOrder(finalKeys, finalMembers)
17551756
e._cachedSortedOrder = sortedOrder
17561757
}
@@ -1767,40 +1768,119 @@ class Evaluator(
17671768
sup
17681769
)
17691770
}
1771+
// Only share key-name caches when all field names are fixed (compile-time constants).
1772+
// MemberLists with computed (Dyn) field names produce different keys per evaluation,
1773+
// so sharing allKeyNames/visibleKeyNames across objects would be incorrect.
1774+
if (sup == null && e.allFieldNamesFixed) factory.cachedObj._sourceMemberList = e
17701775
factory.cachedObj
17711776
}
17721777

17731778
def visitObjComp(e: ObjBody.ObjComp, sup: Val.Obj)(implicit scope: ValScope): Val.Obj = {
17741779
val binds = e.preLocals ++ e.postLocals
17751780
val compScope: ValScope = scope // .clearSuper
1776-
val builder = new java.util.LinkedHashMap[String, Val.Obj.Member]
17771781
val compScopes = visitComp(e.first :: e.rest, Array(compScope))
17781782
if (debugStats != null) debugStats.objectCompIterations += compScopes.length
1783+
1784+
var builder: java.util.LinkedHashMap[String, Val.Obj.Member] = null
1785+
var singleKey: String = null
1786+
var singleMember: Val.Obj.Member = null
1787+
var inlineKeys: Array[String] = null
1788+
var inlineMembers: Array[Val.Obj.Member] = null
1789+
var fieldCount = 0
1790+
val maxInlineFields = 8
1791+
17791792
for (s <- compScopes) {
17801793
visitExpr(e.key)(s) match {
17811794
case Val.Str(_, k) =>
1782-
val previousValue = builder.put(
1783-
k,
1784-
new Val.Obj.Member(e.plus, Visibility.Normal, deprecatedSkipAsserts = true) {
1785-
def invoke(self: Val.Obj, sup: Val.Obj, fs: FileScope, ev: EvalScope): Val = {
1786-
checkStackDepth(e.value.pos, "object comprehension")
1787-
try {
1788-
lazy val newScope: ValScope = s.extend(newBindings, self, sup)
1789-
lazy val newBindings = visitBindings(binds, newScope)
1790-
visitExpr(e.value)(newScope)
1791-
} finally decrementStackDepth()
1795+
val member = new ObjCompMember(
1796+
e.plus,
1797+
this,
1798+
binds,
1799+
s,
1800+
e.value
1801+
)
1802+
if (fieldCount == 0) {
1803+
singleKey = k
1804+
singleMember = member
1805+
} else if (fieldCount == 1) {
1806+
inlineKeys = new Array[String](math.min(compScopes.length, maxInlineFields))
1807+
inlineMembers = new Array[Val.Obj.Member](inlineKeys.length)
1808+
inlineKeys(0) = singleKey
1809+
inlineMembers(0) = singleMember
1810+
if (singleKey.equals(k))
1811+
Error.fail(s"Duplicate key $k in evaluated object comprehension.", e.pos)
1812+
inlineKeys(1) = k
1813+
inlineMembers(1) = member
1814+
singleKey = null
1815+
singleMember = null
1816+
} else if (fieldCount <= maxInlineFields && inlineKeys != null) {
1817+
var di = 0
1818+
while (di < fieldCount) {
1819+
if (inlineKeys(di).equals(k))
1820+
Error.fail(s"Duplicate key $k in evaluated object comprehension.", e.pos)
1821+
di += 1
1822+
}
1823+
if (fieldCount < inlineKeys.length) {
1824+
inlineKeys(fieldCount) = k
1825+
inlineMembers(fieldCount) = member
1826+
} else {
1827+
builder = Util.preSizedJavaLinkedHashMap[String, Val.Obj.Member](compScopes.length)
1828+
var mi = 0
1829+
while (mi < fieldCount) {
1830+
builder.put(inlineKeys(mi), inlineMembers(mi))
1831+
mi += 1
17921832
}
1833+
inlineKeys = null
1834+
inlineMembers = null
1835+
builder.put(k, member)
17931836
}
1794-
)
1795-
if (previousValue != null) {
1796-
Error.fail(s"Duplicate key $k in evaluated object comprehension.", e.pos)
1837+
} else {
1838+
val previousValue = builder.put(k, member)
1839+
if (previousValue != null)
1840+
Error.fail(s"Duplicate key $k in evaluated object comprehension.", e.pos)
17971841
}
1842+
fieldCount += 1
17981843
case Val.Null(_) => // do nothing
17991844
case x => fieldNameTypeError(x, e.pos)
18001845
}
18011846
}
18021847
if (debugStats != null) debugStats.objectsCreated += 1
1803-
new Val.Obj(e.pos, builder, false, null, sup)
1848+
if (fieldCount == 1 && singleKey != null) {
1849+
new Val.Obj(
1850+
e.pos,
1851+
null,
1852+
false,
1853+
null,
1854+
sup,
1855+
singleFieldKey = singleKey,
1856+
singleFieldMember = singleMember
1857+
)
1858+
} else if (inlineKeys != null && fieldCount >= 2) {
1859+
val finalKeys =
1860+
if (fieldCount == inlineKeys.length) inlineKeys
1861+
else java.util.Arrays.copyOf(inlineKeys, fieldCount)
1862+
val finalMembers =
1863+
if (fieldCount == inlineMembers.length) inlineMembers
1864+
else java.util.Arrays.copyOf(inlineMembers, fieldCount)
1865+
new Val.Obj(
1866+
e.pos,
1867+
null,
1868+
false,
1869+
null,
1870+
sup,
1871+
inlineFieldKeys = finalKeys,
1872+
inlineFieldMembers = finalMembers
1873+
)
1874+
} else {
1875+
new Val.Obj(
1876+
e.pos,
1877+
if (builder != null) builder
1878+
else Util.preSizedJavaLinkedHashMap[String, Val.Obj.Member](0),
1879+
false,
1880+
null,
1881+
sup
1882+
)
1883+
}
18041884
}
18051885

18061886
@tailrec
@@ -2098,6 +2178,27 @@ private[sjsonnet] final class ObjectScopeFactory(
20982178
}
20992179
}
21002180

2181+
/**
2182+
* Concrete [[Val.Obj.Member]] for object comprehension fields. Replaces the anonymous inner class
2183+
* in `visitObjComp`, giving the JIT a monomorphic type at `invoke` call sites.
2184+
*/
2185+
private[sjsonnet] final class ObjCompMember(
2186+
plus0: Boolean,
2187+
private val evaluator: Evaluator,
2188+
private val binds: Array[Expr.Bind],
2189+
private val compScope: ValScope,
2190+
private val valueExpr: Expr)
2191+
extends Val.Obj.Member(plus0, Visibility.Normal, deprecatedSkipAsserts = true) {
2192+
def invoke(self: Val.Obj, sup: Val.Obj, fs: FileScope, ev: EvalScope): Val = {
2193+
evaluator.checkStackDepth(valueExpr.pos, "object comprehension")
2194+
try {
2195+
lazy val newScope: ValScope = compScope.extend(newBindings, self, sup)
2196+
lazy val newBindings = evaluator.visitBindings(binds, newScope)
2197+
evaluator.visitExpr(valueExpr)(newScope)
2198+
} finally evaluator.decrementStackDepth()
2199+
}
2200+
}
2201+
21012202
/**
21022203
* Concrete [[Val.Obj.Member]] for expression-body fields (no argSpec). Replaces the anonymous inner
21032204
* class in `visitMemberList`, giving the JIT a single monomorphic type at `invoke` call sites for

sjsonnet/src/sjsonnet/Expr.scala

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,18 @@ object Expr {
416416
*/
417417
@volatile var _noSelfRef: java.lang.Boolean = null
418418

419+
/**
420+
* Cached key-name arrays shared across all Val.Obj instances from this MemberList (when super
421+
* == null). Computed from the first instance and reused by subsequent ones, avoiding per-object
422+
* allKeyNames/visibleKeyNames allocation.
423+
*/
424+
var _cachedAllKeyNames: Array[String] = null
425+
var _cachedVisibleKeyNames: Array[String] = null
426+
427+
/** True if all fields have compile-time fixed names (no computed/Dyn field names). */
428+
lazy val allFieldNamesFixed: Boolean =
429+
fields.forall(_.fieldName.isInstanceOf[FieldName.Fixed])
430+
419431
override def toString: String =
420432
s"MemberList($pos, ${arrStr(binds)}, ${arrStr(fields)}, ${arrStr(asserts)})"
421433
}

sjsonnet/src/sjsonnet/Val.scala

Lines changed: 58 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,13 @@ object Val {
686686
*/
687687
private[sjsonnet] var _skipFieldCache: Boolean = false
688688

689+
/**
690+
* Reference to the source MemberList expression, set for objects with super == null. Enables
691+
* lazy sharing of allKeyNames/visibleKeyNames: the first access computes and caches on the
692+
* MemberList; subsequent objects from the same expression find the cached result.
693+
*/
694+
private[sjsonnet] var _sourceMemberList: Expr.ObjBody.MemberList = null
695+
689696
/**
690697
* Store a computed field value in the object's inline cache, preserving memoization semantics
691698
* when bypassing `value()` during direct iteration. This ensures that subsequent accesses via
@@ -987,79 +994,87 @@ object Val {
987994
}
988995

989996
lazy val allKeyNames: Array[String] = {
990-
if (inlineFieldKeys != null && `super` == null) inlineFieldKeys.clone()
997+
val ml = _sourceMemberList
998+
val cached = if (ml != null) ml._cachedAllKeyNames else null
999+
if (cached != null) cached
9911000
else {
992-
val m = if (static || `super` != null) getAllKeys else getValue0
993-
m.keySet().toArray(new Array[String](m.size()))
1001+
val result =
1002+
if (inlineFieldKeys != null && `super` == null) inlineFieldKeys.clone()
1003+
else {
1004+
val m = if (static || `super` != null) getAllKeys else getValue0
1005+
m.keySet().toArray(new Array[String](m.size()))
1006+
}
1007+
if (ml != null) ml._cachedAllKeyNames = result
1008+
result
9941009
}
9951010
}
9961011

9971012
lazy val visibleKeyNames: Array[String] = {
998-
if (static) {
999-
allKeyNames
1000-
} else if (inlineFieldKeys != null && `super` == null) {
1001-
// Inline multi-field fast path: check if all visible (common case)
1002-
val keys = inlineFieldKeys
1003-
val members = inlineFieldMembers
1004-
val n = keys.length
1005-
var allVisible = true
1006-
var i = 0
1007-
while (allVisible && i < n) {
1008-
if (members(i).visibility == Visibility.Hidden) allVisible = false
1009-
i += 1
1010-
}
1011-
if (allVisible) keys.clone()
1012-
else {
1013+
val ml = _sourceMemberList
1014+
val cached = if (ml != null) ml._cachedVisibleKeyNames else null
1015+
if (cached != null) cached
1016+
else {
1017+
val result = if (static) {
1018+
allKeyNames
1019+
} else if (inlineFieldKeys != null && `super` == null) {
1020+
// Inline multi-field fast path: check if all visible (common case)
1021+
val keys = inlineFieldKeys
1022+
val members = inlineFieldMembers
1023+
val n = keys.length
1024+
var allVisible = true
1025+
var i = 0
1026+
while (allVisible && i < n) {
1027+
if (members(i).visibility == Visibility.Hidden) allVisible = false
1028+
i += 1
1029+
}
1030+
if (allVisible) keys.clone()
1031+
else {
1032+
val buf = new mutable.ArrayBuilder.ofRef[String]
1033+
buf.sizeHint(n)
1034+
var j = 0
1035+
while (j < n) {
1036+
if (members(j).visibility != Visibility.Hidden) buf += keys(j)
1037+
j += 1
1038+
}
1039+
buf.result()
1040+
}
1041+
} else {
10131042
val buf = new mutable.ArrayBuilder.ofRef[String]
1014-
buf.sizeHint(n)
1015-
var j = 0
1016-
while (j < n) {
1017-
if (members(j).visibility != Visibility.Hidden) buf += keys(j)
1018-
j += 1
1043+
if (`super` == null) {
1044+
val v0 = getValue0
1045+
// This size hint is based on an optimistic assumption that most fields are visible,
1046+
// avoiding re-sizing or trimming the buffer in the common case:
1047+
buf.sizeHint(v0.size())
1048+
v0.forEach((k, m) => if (m.visibility != Visibility.Hidden) buf += k)
1049+
} else {
1050+
getAllKeys.forEach((k, b) => if (b == java.lang.Boolean.FALSE) buf += k)
10191051
}
10201052
buf.result()
10211053
}
1022-
} else {
1023-
val buf = new mutable.ArrayBuilder.ofRef[String]
1024-
if (`super` == null) {
1025-
val v0 = getValue0
1026-
// This size hint is based on an optimistic assumption that most fields are visible,
1027-
// avoiding re-sizing or trimming the buffer in the common case:
1028-
buf.sizeHint(v0.size())
1029-
v0.forEach((k, m) => if (m.visibility != Visibility.Hidden) buf += k)
1030-
} else {
1031-
getAllKeys.forEach((k, b) => if (b == java.lang.Boolean.FALSE) buf += k)
1032-
}
1033-
buf.result()
1054+
if (ml != null) ml._cachedVisibleKeyNames = result
1055+
result
10341056
}
10351057
}
10361058

10371059
def value(k: String, pos: Position, self: Obj = this)(implicit evaluator: EvalScope): Val = {
1038-
val ds = evaluator.debugStats
1039-
if (ds != null) ds.fieldLookups += 1
10401060
if (static) {
10411061
valueCache.get(k) match {
10421062
case null => Error.fail("Field does not exist: " + k, pos)
1043-
case x =>
1044-
if (ds != null) ds.fieldCacheHits += 1
1045-
x
1063+
case x => x
10461064
}
10471065
} else {
10481066
if ((self eq this) && excludedKeys != null && excludedKeys.contains(k)) {
10491067
Error.fail("Field does not exist: " + k, pos)
10501068
}
10511069
val cacheKey: Any = if (self eq this) k else (k, self)
10521070
if (ck1 != null && ck1 == cacheKey) {
1053-
if (ds != null) ds.fieldCacheHits += 1
10541071
return cv1
10551072
}
10561073
if (ck2 != null && ck2 == cacheKey) {
1057-
if (ds != null) ds.fieldCacheHits += 1
10581074
return cv2
10591075
}
10601076
val cachedValue = if (valueCache != null) valueCache.get(cacheKey) else null
10611077
if (cachedValue != null) {
1062-
if (ds != null) ds.fieldCacheHits += 1
10631078
cachedValue
10641079
} else {
10651080
valueRaw(k, self, pos, this, cacheKey) match {

0 commit comments

Comments
 (0)