Skip to content

Commit 321026a

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

4 files changed

Lines changed: 506 additions & 44 deletions

File tree

sjsonnet/src/sjsonnet/Materializer.scala

Lines changed: 291 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
31+
* [[materializeRecursiveObj]]/[[materializeRecursiveArr]] (recursive mode) or
32+
* [[materializeIterative]] (iterative fallback). Passing a container will fall through to the
33+
* catch-all branch and throw an error.
34+
*/
35+
@inline private def materializeLeaf[T](v: Val, visitor: Visitor[T, T])(implicit
36+
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,236 @@ 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]].
67+
*/
68+
def apply0[T](v: Val, visitor: Visitor[T, T])(implicit evaluator: EvalScope): T = {
69+
val ctx = Materializer.MaterializeContext(evaluator)
70+
v match {
71+
case obj: Val.Obj => materializeRecursiveObj(obj, visitor, 0, ctx)
72+
case xs: Val.Arr => materializeRecursiveArr(xs, visitor, 0, ctx)
73+
case _ => materializeLeaf(v, visitor)
74+
}
75+
}
76+
77+
@inline private def materializeRecursiveObj[T](
78+
obj: Val.Obj,
79+
visitor: Visitor[T, T],
80+
depth: Int,
81+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): T = {
82+
storePos(obj.pos)
83+
obj.triggerAllAsserts(ctx.brokenAssertionLogic)
84+
val keys =
85+
if (ctx.sort) obj.visibleKeyNames.sorted(Util.CodepointStringOrdering)
86+
else obj.visibleKeyNames
87+
val ov = visitor.visitObject(keys.length, jsonableKeys = true, -1)
88+
var i = 0
89+
var prevKey: String = null
90+
while (i < keys.length) {
91+
val key = keys(i)
92+
val childVal = obj.value(key, ctx.emptyPos)
93+
storePos(childVal)
94+
if (ctx.sort) {
95+
if (prevKey != null && Util.compareStringsByCodepoint(key, prevKey) <= 0)
96+
Error.fail(
97+
s"""Internal error: Unexpected key "$key" after "$prevKey" in sorted object materialization""",
98+
childVal.pos
99+
)
100+
prevKey = key
101+
}
102+
ov.visitKeyValue(ov.visitKey(-1).visitString(key, -1))
103+
val sub = ov.subVisitor.asInstanceOf[Visitor[T, T]]
104+
ov.visitValue(materializeRecursiveChild(childVal, sub, depth, ctx), -1)
105+
i += 1
106+
}
107+
ov.visitEnd(-1)
108+
}
109+
110+
@inline private def materializeRecursiveArr[T](
111+
xs: Val.Arr,
112+
visitor: Visitor[T, T],
113+
depth: Int,
114+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): T = {
115+
storePos(xs.pos)
116+
val av = visitor.visitArray(xs.length, -1)
117+
var i = 0
118+
while (i < xs.length) {
119+
val childVal = xs.value(i)
120+
av.visitValue(
121+
materializeRecursiveChild(childVal, av.subVisitor.asInstanceOf[Visitor[T, T]], depth, ctx),
122+
-1
123+
)
124+
i += 1
125+
}
126+
av.visitEnd(-1)
127+
}
128+
129+
@inline private def materializeRecursiveChild[T](
130+
childVal: Val,
131+
childVisitor: Visitor[T, T],
132+
depth: Int,
133+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): T = childVal match {
134+
case obj: Val.Obj =>
135+
val nextDepth = depth + 1
136+
if (nextDepth < ctx.recursiveDepthLimit)
137+
materializeRecursiveObj(obj, childVisitor, nextDepth, ctx)
138+
else
139+
materializeStackless(childVal, childVisitor, ctx)
140+
case xs: Val.Arr =>
141+
val nextDepth = depth + 1
142+
if (nextDepth < ctx.recursiveDepthLimit)
143+
materializeRecursiveArr(xs, childVisitor, nextDepth, ctx)
144+
else
145+
materializeStackless(childVal, childVisitor, ctx)
146+
case _ =>
147+
materializeLeaf(childVal, childVisitor)
148+
}
149+
150+
// Iterative materialization for deep nesting. Used as a fallback when recursive depth exceeds
151+
// the recursive depth limit. Uses an explicit ArrayDeque stack to avoid StackOverflowError.
152+
private def materializeStackless[T](
153+
v: Val,
154+
visitor: Visitor[T, T],
155+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): T = {
156+
try {
157+
val stack = new java.util.ArrayDeque[Materializer.MaterializeFrame](
158+
ctx.recursiveDepthLimit << 2
159+
)
160+
161+
// Push the initial container frame
162+
v match {
163+
case obj: Val.Obj => pushObjFrame(obj, visitor, stack, ctx)
164+
case xs: Val.Arr => pushArrFrame(xs, visitor, stack, ctx)
165+
case _ => () // unreachable
166+
}
167+
168+
while (true) {
169+
stack.peekFirst() match {
170+
case frame: Materializer.MaterializeObjFrame[T @unchecked] =>
171+
val keys = frame.keys
172+
val ov = frame.objVisitor
173+
if (frame.index < keys.length) {
174+
val key = keys(frame.index)
175+
val childVal = frame.obj.value(key, ctx.emptyPos)
176+
storePos(childVal)
177+
178+
if (frame.sort) {
179+
if (
180+
frame.prevKey != null && Util.compareStringsByCodepoint(key, frame.prevKey) <= 0
181+
)
182+
Error.fail(
183+
s"""Internal error: Unexpected key "$key" after "${frame.prevKey}" in sorted object materialization""",
184+
childVal.pos
185+
)
186+
frame.prevKey = key
187+
}
188+
189+
ov.visitKeyValue(ov.visitKey(-1).visitString(key, -1))
190+
frame.index += 1
191+
192+
val sub = ov.subVisitor.asInstanceOf[Visitor[T, T]]
193+
materializeChild(childVal, sub, ov, stack, ctx)
194+
} else {
195+
val result = ov.visitEnd(-1)
196+
stack.removeFirst()
197+
if (stack.isEmpty) return result
198+
feedResult(stack.peekFirst(), result)
199+
}
200+
201+
case frame: Materializer.MaterializeArrFrame[T @unchecked] =>
202+
val arr = frame.arr
203+
val av = frame.arrVisitor
204+
if (frame.index < arr.length) {
205+
val childVal = arr.value(frame.index)
206+
frame.index += 1
207+
208+
val sub = av.subVisitor.asInstanceOf[Visitor[T, T]]
209+
materializeChild(childVal, sub, av, stack, ctx)
210+
} else {
211+
val result = av.visitEnd(-1)
212+
stack.removeFirst()
213+
if (stack.isEmpty) return result
214+
feedResult(stack.peekFirst(), result)
215+
}
216+
}
217+
}
218+
219+
null.asInstanceOf[T] // unreachable — while(true) exits via return
220+
} catch {
221+
case _: StackOverflowError =>
222+
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
223+
case _: OutOfMemoryError =>
224+
Error.fail("Out of memory while materializing, possibly due to recursive value", v.pos)
225+
}
226+
}
227+
228+
// Materialize a child value in iterative mode. Single match dispatches leaf values directly
229+
// and pushes a new stack frame for containers. Avoids redundant isInstanceOf pre-checks.
230+
@inline private def materializeChild[T](
231+
childVal: Val,
232+
childVisitor: Visitor[T, T],
233+
parentVisitor: upickle.core.ObjArrVisitor[T, T],
234+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
235+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): Unit = {
236+
childVal match {
237+
case obj: Val.Obj =>
238+
pushObjFrame(obj, childVisitor, stack, ctx)
239+
case xs: Val.Arr =>
240+
pushArrFrame(xs, childVisitor, stack, ctx)
241+
case _ =>
242+
parentVisitor.visitValue(materializeLeaf(childVal, childVisitor), -1)
243+
}
244+
}
245+
246+
@inline private def pushObjFrame[T](
247+
obj: Val.Obj,
248+
visitor: Visitor[T, T],
249+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
250+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): Unit = {
251+
checkDepth(obj.pos, stack.size, ctx.maxDepth)
252+
storePos(obj.pos)
253+
obj.triggerAllAsserts(ctx.brokenAssertionLogic)
254+
val keyNames =
255+
if (ctx.sort) obj.visibleKeyNames.sorted(Util.CodepointStringOrdering)
256+
else obj.visibleKeyNames
257+
val objVisitor = visitor.visitObject(keyNames.length, jsonableKeys = true, -1)
258+
stack.push(
259+
new Materializer.MaterializeObjFrame[T](objVisitor, keyNames, obj, ctx.sort, 0, null)
260+
)
261+
}
262+
263+
@inline private def pushArrFrame[T](
264+
xs: Val.Arr,
265+
visitor: Visitor[T, T],
266+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
267+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): Unit = {
268+
checkDepth(xs.pos, stack.size, ctx.maxDepth)
269+
storePos(xs.pos)
270+
val arrVisitor = visitor.visitArray(xs.length, -1)
271+
stack.push(new Materializer.MaterializeArrFrame[T](arrVisitor, xs, 0))
272+
}
273+
274+
// Feed a completed child result into the parent frame's visitor.
275+
@inline private def feedResult[T](parentFrame: Materializer.MaterializeFrame, result: T): Unit =
276+
parentFrame match {
277+
case f: Materializer.MaterializeObjFrame[T @unchecked] =>
278+
f.objVisitor.visitValue(result, -1)
279+
case f: Materializer.MaterializeArrFrame[T @unchecked] =>
280+
f.arrVisitor.visitValue(result, -1)
281+
}
282+
283+
@inline private def checkDepth(pos: Position, stackSize: Int, maxDepth: Int)(implicit
284+
ev: EvalErrorScope): Unit =
285+
if (stackSize >= maxDepth)
286+
Error.fail(
287+
"Stackoverflow while materializing, possibly due to recursive value",
288+
pos
289+
)
290+
85291
def reverse(pos: Position, v: ujson.Value): Val = v match {
86292
case ujson.True => Val.True(pos)
87293
case ujson.False => Val.False(pos)
@@ -156,6 +362,48 @@ object Materializer extends Materializer {
156362
final val emptyStringArray = new Array[String](0)
157363
final val emptyLazyArray = new Array[Eval](0)
158364

365+
/**
366+
* Immutable snapshot of all settings needed during a single materialization pass. Created once
367+
* per top-level call and threaded through recursive/iterative helpers, avoiding repeated field
368+
* lookups on the [[Settings]] object on every frame.
369+
*/
370+
private[sjsonnet] final class MaterializeContext(
371+
val sort: Boolean,
372+
val brokenAssertionLogic: Boolean,
373+
val emptyPos: Position,
374+
val recursiveDepthLimit: Int,
375+
val maxDepth: Int)
376+
377+
private[sjsonnet] object MaterializeContext {
378+
def apply(ev: EvalScope): MaterializeContext = new MaterializeContext(
379+
sort = !ev.settings.preserveOrder,
380+
brokenAssertionLogic = ev.settings.brokenAssertionLogic,
381+
emptyPos = ev.emptyMaterializeFileScopePos,
382+
recursiveDepthLimit = ev.settings.materializeRecursiveDepthLimit,
383+
maxDepth = ev.settings.maxMaterializeDepth
384+
)
385+
}
386+
387+
/** Common parent for stack frames used in iterative materialization. */
388+
private[sjsonnet] sealed trait MaterializeFrame
389+
390+
/** Stack frame for in-progress object materialization. */
391+
private[sjsonnet] final class MaterializeObjFrame[T](
392+
val objVisitor: ObjVisitor[T, T],
393+
val keys: Array[String],
394+
val obj: Val.Obj,
395+
val sort: Boolean,
396+
var index: Int,
397+
var prevKey: String)
398+
extends MaterializeFrame
399+
400+
/** Stack frame for in-progress array materialization. */
401+
private[sjsonnet] final class MaterializeArrFrame[T](
402+
val arrVisitor: ArrVisitor[T, T],
403+
val arr: Val.Arr,
404+
var index: Int)
405+
extends MaterializeFrame
406+
159407
/**
160408
* Trait for providing custom materialization logic to the Materializer.
161409
* @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)