Skip to content

Commit fc41151

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

4 files changed

Lines changed: 505 additions & 44 deletions

File tree

sjsonnet/src/sjsonnet/Materializer.scala

Lines changed: 290 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,17 @@ 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+
@inline private def materializeLeaf[T](v: Val, visitor: Visitor[T, T])(implicit
35+
evaluator: EvalScope): T = {
2136
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)
37+
case Val.Str(pos, s) => storePos(pos); visitor.visitString(s, -1)
38+
case Val.Num(pos, _) => storePos(pos); visitor.visitFloat64(v.asDouble, -1)
5739
case Val.True(pos) => storePos(pos); visitor.visitTrue(-1)
5840
case Val.False(pos) => storePos(pos); visitor.visitFalse(-1)
5941
case Val.Null(pos) => storePos(pos); visitor.visitNull(-1)
@@ -75,13 +57,236 @@ abstract class Materializer {
7557
case null =>
7658
Error.fail("Unknown value type " + v)
7759
}
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)
8360
}
8461

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

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