Skip to content

Commit e784c0c

Browse files
committed
chore: Make materializer stackless for obj and arr
1 parent 415524b commit e784c0c

4 files changed

Lines changed: 483 additions & 44 deletions

File tree

sjsonnet/src/sjsonnet/Materializer.scala

Lines changed: 268 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@ package sjsonnet
22

33
import sjsonnet.Expr.{FieldName, Member, ObjBody}
44
import sjsonnet.Expr.Member.Visibility
5-
import upickle.core.Visitor
5+
import upickle.core.{ArrVisitor, ObjVisitor, Visitor}
66

77
/**
88
* Serializes the given [[Val]] out to the given [[upickle.core.Visitor]], which can transform it
9-
* into [[ujson.Value]]s or directly serialize it to `String`s
9+
* into [[ujson.Value]]s or directly serialize it to `String`s.
10+
*
11+
* TCO boundary: all [[Val]] values entering materialization — whether from object field evaluation
12+
* (`Val.Obj.value`), array element forcing (`Val.Arr.value`), or top-level evaluation — must not
13+
* contain unresolved [[TailCall]] sentinels. This invariant is maintained by the evaluator: object
14+
* field `invoke` calls `visitExpr` (not `visitExprWithTailCallSupport`), and `Val.Func.apply*`
15+
* resolves TailCalls when called with `TailstrictModeDisabled`. A defensive check in
16+
* `materializeLeaf` guards against accidental TailCall leakage with a clear internal-error
17+
* diagnostic.
1018
*/
1119
abstract class Materializer {
1220
def storePos(pos: Position): Unit
@@ -17,43 +25,18 @@ abstract class Materializer {
1725
apply0(v, new sjsonnet.Renderer()).toString
1826
}
1927

20-
def apply0[T](v: Val, visitor: Visitor[T, T])(implicit evaluator: EvalScope): T = try {
28+
/**
29+
* Materialize a leaf value (non-container) to the given visitor. Callers must ensure that
30+
* container values (Obj/Arr) are never passed to this method — they are handled by the iterative
31+
* stack-based loop in [[materializeContainer]]. Passing a container will fall through to the
32+
* catch-all branch and throw an error.
33+
*/
34+
private def materializeLeaf[T](
35+
v: Val,
36+
visitor: Visitor[T, T])(implicit evaluator: EvalScope): T = {
2137
v match {
22-
case Val.Str(pos, s) => storePos(pos); visitor.visitString(s, -1)
23-
case obj: Val.Obj =>
24-
storePos(obj.pos)
25-
obj.triggerAllAsserts(evaluator.settings.brokenAssertionLogic)
26-
val objVisitor = visitor.visitObject(obj.visibleKeyNames.length, jsonableKeys = true, -1)
27-
val sort = !evaluator.settings.preserveOrder
28-
var prevKey: String = null
29-
obj.foreachElement(sort, evaluator.emptyMaterializeFileScopePos) { (k, v) =>
30-
storePos(v)
31-
objVisitor.visitKeyValue(objVisitor.visitKey(-1).visitString(k, -1))
32-
objVisitor.visitValue(
33-
apply0(v, objVisitor.subVisitor.asInstanceOf[Visitor[T, T]]),
34-
-1
35-
)
36-
if (sort) {
37-
if (prevKey != null && Util.compareStringsByCodepoint(k, prevKey) <= 0)
38-
Error.fail(
39-
s"""Internal error: Unexpected key "$k" after "$prevKey" in sorted object materialization""",
40-
v.pos
41-
)
42-
prevKey = k
43-
}
44-
}
45-
objVisitor.visitEnd(-1)
46-
case Val.Num(pos, _) => storePos(pos); visitor.visitFloat64(v.asDouble, -1)
47-
case xs: Val.Arr =>
48-
storePos(xs.pos)
49-
val arrVisitor = visitor.visitArray(xs.length, -1)
50-
var i = 0
51-
while (i < xs.length) {
52-
val sub = arrVisitor.subVisitor.asInstanceOf[Visitor[T, T]]
53-
arrVisitor.visitValue(apply0(xs.value(i), sub), -1)
54-
i += 1
55-
}
56-
arrVisitor.visitEnd(-1)
38+
case Val.Str(pos, s) => storePos(pos); visitor.visitString(s, -1)
39+
case Val.Num(pos, _) => storePos(pos); visitor.visitFloat64(v.asDouble, -1)
5740
case Val.True(pos) => storePos(pos); visitor.visitTrue(-1)
5841
case Val.False(pos) => storePos(pos); visitor.visitFalse(-1)
5942
case Val.Null(pos) => storePos(pos); visitor.visitNull(-1)
@@ -75,13 +58,235 @@ abstract class Materializer {
7558
case null =>
7659
Error.fail("Unknown value type " + v)
7760
}
78-
} catch {
79-
case _: StackOverflowError =>
80-
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
81-
case _: OutOfMemoryError =>
82-
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
8361
}
8462

63+
/**
64+
* Hybrid materialization: uses JVM stack recursion for shallow nesting (zero heap allocation,
65+
* JIT-friendly) and automatically switches to an explicit stack-based iterative loop when the
66+
* recursion depth exceeds [[Settings.materializeRecursiveDepthLimit]]. This gives optimal
67+
* performance for the 99.9% common case while still handling arbitrarily deep structures (e.g.
68+
* those built via TCO) without StackOverflowError.
69+
*/
70+
def apply0[T](v: Val, visitor: Visitor[T, T])(implicit evaluator: EvalScope): T = v match {
71+
case obj: Val.Obj => materializeRecursive(obj, visitor, 0)
72+
case xs: Val.Arr => materializeRecursive(xs, visitor, 0)
73+
case _ => materializeLeaf(v, visitor)
74+
}
75+
76+
// Recursive materialization for shallow nesting. Each container consumes one JVM stack frame.
77+
// When depth reaches settings.materializeRecursiveDepthLimit, switches to the iterative materializeContainer to
78+
// avoid StackOverflowError. The method is kept small to encourage JIT inlining.
79+
private def materializeRecursive[T](v: Val, visitor: Visitor[T, T], depth: Int)(implicit
80+
evaluator: EvalScope): T = {
81+
val sort = !evaluator.settings.preserveOrder
82+
val brokenAssertionLogic = evaluator.settings.brokenAssertionLogic
83+
val emptyPos = evaluator.emptyMaterializeFileScopePos
84+
v match {
85+
case obj: Val.Obj =>
86+
storePos(obj.pos)
87+
obj.triggerAllAsserts(brokenAssertionLogic)
88+
val keys =
89+
if (sort) obj.visibleKeyNames.sorted(Util.CodepointStringOrdering)
90+
else obj.visibleKeyNames
91+
val ov = visitor.visitObject(keys.length, jsonableKeys = true, -1)
92+
var i = 0
93+
var prevKey: String = null
94+
while (i < keys.length) {
95+
val key = keys(i)
96+
val childVal = obj.value(key, emptyPos)
97+
storePos(childVal)
98+
if (sort) {
99+
if (prevKey != null && Util.compareStringsByCodepoint(key, prevKey) <= 0)
100+
Error.fail(
101+
s"""Internal error: Unexpected key "$key" after "$prevKey" in sorted object materialization""",
102+
childVal.pos
103+
)
104+
prevKey = key
105+
}
106+
ov.visitKeyValue(ov.visitKey(-1).visitString(key, -1))
107+
val sub = ov.subVisitor.asInstanceOf[Visitor[T, T]]
108+
ov.visitValue(materializeRecursiveChild(childVal, sub, depth), -1)
109+
i += 1
110+
}
111+
ov.visitEnd(-1)
112+
case xs: Val.Arr =>
113+
storePos(xs.pos)
114+
val av = visitor.visitArray(xs.length, -1)
115+
var i = 0
116+
while (i < xs.length) {
117+
val childVal = xs.value(i)
118+
val sub = av.subVisitor.asInstanceOf[Visitor[T, T]]
119+
av.visitValue(materializeRecursiveChild(childVal, sub, depth), -1)
120+
i += 1
121+
}
122+
av.visitEnd(-1)
123+
case _ =>
124+
materializeLeaf(v, visitor)
125+
}
126+
}
127+
128+
// Materialize a child value during recursive mode. Leaf values are handled directly;
129+
// container children either recurse (if depth < limit) or switch to iterative mode.
130+
private def materializeRecursiveChild[T](childVal: Val, childVisitor: Visitor[T, T], depth: Int)(
131+
implicit evaluator: EvalScope): T = {
132+
if (!childVal.isInstanceOf[Val.Obj] && !childVal.isInstanceOf[Val.Arr]) {
133+
materializeLeaf(childVal, childVisitor)
134+
} else {
135+
val nextDepth = depth + 1
136+
if (nextDepth < evaluator.settings.materializeRecursiveDepthLimit)
137+
materializeRecursive(childVal, childVisitor, nextDepth)
138+
else
139+
materializeContainer(childVal, childVisitor)
140+
}
141+
}
142+
143+
// Iterative materialization for deep nesting. Used as a fallback when recursive depth exceeds
144+
// settings.materializeRecursiveDepthLimit. Uses an explicit ArrayDeque stack to avoid StackOverflowError.
145+
private def materializeContainer[T](v: Val, visitor: Visitor[T, T])(implicit
146+
evaluator: EvalScope): T = {
147+
try {
148+
val maxDepth = evaluator.settings.maxMaterializeDepth
149+
val sort = !evaluator.settings.preserveOrder
150+
val brokenAssertionLogic = evaluator.settings.brokenAssertionLogic
151+
val emptyPos = evaluator.emptyMaterializeFileScopePos
152+
val stack = new java.util.ArrayDeque[Materializer.MaterializeFrame](
153+
evaluator.settings.materializeRecursiveDepthLimit << 2
154+
)
155+
156+
// Push the initial container frame
157+
v match {
158+
case obj: Val.Obj => pushObjFrame(obj, visitor, stack, maxDepth, sort, brokenAssertionLogic)
159+
case xs: Val.Arr => pushArrFrame(xs, visitor, stack, maxDepth)
160+
case _ => () // unreachable
161+
}
162+
163+
while (true) {
164+
stack.peekFirst() match {
165+
case frame: Materializer.MaterializeObjFrame[T @unchecked] =>
166+
val keys = frame.keys
167+
val ov = frame.objVisitor
168+
if (frame.index < keys.length) {
169+
val key = keys(frame.index)
170+
val childVal = frame.obj.value(key, emptyPos)
171+
storePos(childVal)
172+
173+
if (frame.sort) {
174+
if (
175+
frame.prevKey != null && Util.compareStringsByCodepoint(key, frame.prevKey) <= 0
176+
)
177+
Error.fail(
178+
s"""Internal error: Unexpected key "$key" after "${frame.prevKey}" in sorted object materialization""",
179+
childVal.pos
180+
)
181+
frame.prevKey = key
182+
}
183+
184+
ov.visitKeyValue(ov.visitKey(-1).visitString(key, -1))
185+
frame.index += 1
186+
187+
val sub = ov.subVisitor.asInstanceOf[Visitor[T, T]]
188+
materializeChild(childVal, sub, ov, stack, maxDepth, sort, brokenAssertionLogic)
189+
} else {
190+
val result = ov.visitEnd(-1)
191+
stack.removeFirst()
192+
if (stack.isEmpty) return result
193+
feedResult(stack.peekFirst(), result)
194+
}
195+
196+
case frame: Materializer.MaterializeArrFrame[T @unchecked] =>
197+
val arr = frame.arr
198+
val av = frame.arrVisitor
199+
if (frame.index < arr.length) {
200+
val childVal = arr.value(frame.index)
201+
frame.index += 1
202+
203+
val sub = av.subVisitor.asInstanceOf[Visitor[T, T]]
204+
materializeChild(childVal, sub, av, stack, maxDepth, sort, brokenAssertionLogic)
205+
} else {
206+
val result = av.visitEnd(-1)
207+
stack.removeFirst()
208+
if (stack.isEmpty) return result
209+
feedResult(stack.peekFirst(), result)
210+
}
211+
}
212+
}
213+
214+
null.asInstanceOf[T] // unreachable — while(true) exits via return
215+
} catch {
216+
case _: StackOverflowError =>
217+
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
218+
case _: OutOfMemoryError =>
219+
Error.fail("Out of memory while materializing, possibly due to recursive value", v.pos)
220+
}
221+
}
222+
223+
// Materialize a child value in iterative mode: leaf fast-path avoids a full pattern match for
224+
// the common case (strings, numbers, booleans, null). Only containers push a new frame.
225+
private def materializeChild[T](
226+
childVal: Val,
227+
childVisitor: Visitor[T, T],
228+
parentVisitor: upickle.core.ObjArrVisitor[T, T],
229+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
230+
maxDepth: Int,
231+
sort: Boolean,
232+
brokenAssertionLogic: Boolean)(implicit evaluator: EvalScope): Unit = {
233+
if (!childVal.isInstanceOf[Val.Obj] && !childVal.isInstanceOf[Val.Arr]) {
234+
parentVisitor.visitValue(materializeLeaf(childVal, childVisitor), -1)
235+
} else
236+
childVal match {
237+
case obj: Val.Obj =>
238+
pushObjFrame(obj, childVisitor, stack, maxDepth, sort, brokenAssertionLogic)
239+
case xs: Val.Arr =>
240+
pushArrFrame(xs, childVisitor, stack, maxDepth)
241+
case _ => () // unreachable — guarded by isInstanceOf checks above
242+
}
243+
}
244+
245+
private def pushObjFrame[T](
246+
obj: Val.Obj,
247+
visitor: Visitor[T, T],
248+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
249+
maxDepth: Int,
250+
sort: Boolean,
251+
brokenAssertionLogic: Boolean)(implicit evaluator: EvalScope): Unit = {
252+
checkDepth(obj.pos, stack.size, maxDepth)
253+
storePos(obj.pos)
254+
obj.triggerAllAsserts(brokenAssertionLogic)
255+
val keyNames =
256+
if (sort) obj.visibleKeyNames.sorted(Util.CodepointStringOrdering)
257+
else obj.visibleKeyNames
258+
val objVisitor = visitor.visitObject(keyNames.length, jsonableKeys = true, -1)
259+
stack.push(new Materializer.MaterializeObjFrame[T](objVisitor, keyNames, obj, sort, 0, null))
260+
}
261+
262+
private def pushArrFrame[T](
263+
xs: Val.Arr,
264+
visitor: Visitor[T, T],
265+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
266+
maxDepth: Int)(implicit evaluator: EvalScope): Unit = {
267+
checkDepth(xs.pos, stack.size, maxDepth)
268+
storePos(xs.pos)
269+
val arrVisitor = visitor.visitArray(xs.length, -1)
270+
stack.push(new Materializer.MaterializeArrFrame[T](arrVisitor, xs, 0))
271+
}
272+
273+
// Feed a completed child result into the parent frame's visitor.
274+
private def feedResult[T](parentFrame: Materializer.MaterializeFrame, result: T): Unit =
275+
parentFrame match {
276+
case f: Materializer.MaterializeObjFrame[T @unchecked] =>
277+
f.objVisitor.visitValue(result, -1)
278+
case f: Materializer.MaterializeArrFrame[T @unchecked] =>
279+
f.arrVisitor.visitValue(result, -1)
280+
}
281+
282+
private def checkDepth(pos: Position, stackSize: Int, maxDepth: Int)(implicit
283+
ev: EvalErrorScope): Unit =
284+
if (stackSize >= maxDepth)
285+
Error.fail(
286+
"Stackoverflow while materializing, possibly due to recursive value",
287+
pos
288+
)
289+
85290
def reverse(pos: Position, v: ujson.Value): Val = v match {
86291
case ujson.True => Val.True(pos)
87292
case ujson.False => Val.False(pos)
@@ -156,6 +361,26 @@ object Materializer extends Materializer {
156361
final val emptyStringArray = new Array[String](0)
157362
final val emptyLazyArray = new Array[Eval](0)
158363

364+
/** Common parent for stack frames used in iterative materialization. */
365+
private[sjsonnet] sealed trait MaterializeFrame
366+
367+
/** Stack frame for in-progress object materialization. */
368+
private[sjsonnet] final class MaterializeObjFrame[T](
369+
val objVisitor: ObjVisitor[T, T],
370+
val keys: Array[String],
371+
val obj: Val.Obj,
372+
val sort: Boolean,
373+
var index: Int,
374+
var prevKey: String)
375+
extends MaterializeFrame
376+
377+
/** Stack frame for in-progress array materialization. */
378+
private[sjsonnet] final class MaterializeArrFrame[T](
379+
val arrVisitor: ArrVisitor[T, T],
380+
val arr: Val.Arr,
381+
var index: Int)
382+
extends MaterializeFrame
383+
159384
/**
160385
* Trait for providing custom materialization logic to the Materializer.
161386
* @since 1.0.0

sjsonnet/src/sjsonnet/Settings.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ final case class Settings(
1010
throwErrorForInvalidSets: Boolean = false,
1111
useNewEvaluator: Boolean = false,
1212
maxParserRecursionDepth: Int = 1000,
13-
brokenAssertionLogic: Boolean = false
13+
brokenAssertionLogic: Boolean = false,
14+
maxMaterializeDepth: Int = 1000,
15+
materializeRecursiveDepthLimit: Int = 64
1416
)
1517

1618
object Settings {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Test hybrid materialization mode with deeply nested empty arrays.
2+
// Depth 100 exceeds the default materializeRecursiveDepthLimit (64),
3+
// so the first 64 levels use JVM stack recursion and the remaining
4+
// levels fall back to the iterative ArrayDeque-based materializer.
5+
local nest(depth) =
6+
local aux(acc, i) =
7+
if i <= 0 then acc
8+
else aux([acc], i - 1) tailstrict;
9+
aux([], depth);
10+
11+
nest(100)

0 commit comments

Comments
 (0)