stevedlawrence commented on code in PR #1717:
URL: https://github.com/apache/daffodil/pull/1717#discussion_r3830239952
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/dpath/DPath.scala:
##########
@@ -252,6 +254,24 @@ final class RuntimeExpressionDPath[T <: AnyRef](
whereBlockedInfo.block(ve.qname, ve.context, 0, ve)
case noLength: InfosetLengthUnknownException =>
whereBlockedInfo.block(noLength.diElement, noLength.erd, 0, noLength)
+ // Register a targeted wake-up alongside the periodic-sweep blocking
+ // above: once this element's length becomes computable (see
+ // CaptureEndOf{Content,Value}LengthUnparser), this suspension is
+ // retried directly instead of waiting for SuspensionTracker's next
+ // sweep. registerLengthStateWaiter (not registerWaiter directly)
+ // also deregisters from whichever LengthState this suspension was
+ // previously registered against, in case an earlier retry blocked
+ // on a different element's length - see Suspension.scala.
+ noLength match {
+ case _: InfosetContentLengthUnknownException =>
+
whereBlockedInfo.registerLengthStateWaiter(noLength.diElement.contentLength)
+ case _: InfosetValueLengthUnknownException =>
+
whereBlockedInfo.registerLengthStateWaiter(noLength.diElement.valueLength)
+ }
Review Comment:
I think if we make `lengthState` a val in InfosetLengthUnknownException we
can avoid the match/case and simplify this to:
```scala
whereBlockedInfo.registerLengthStateWaiter(noLength.lengthState)
```
I don't think there's any harm in making lengthState accessible and it
simplifies our code.
##########
daffodil-core/src/main/scala/org/apache/daffodil/lib/util/MStack.scala:
##########
@@ -167,9 +167,9 @@ private[util] final class MStackOfAnyRef private ()
extends MStack[AnyRef]((n: Int) => new Array[AnyRef](n),
null.asInstanceOf[AnyRef])
object MStackOfAnyRef {
- def apply() = {
+ def apply(initialSize: Int = 32) = {
Review Comment:
Should the other MStacks, e.g. `MStackOfBoolean`, `MStackofInt` have this
initialize size field in the apply method too. I guess we don't currently use
them, but they should be available.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/dpath/SuspendableExpression.scala:
##########
@@ -32,12 +32,35 @@ import
org.apache.daffodil.runtime1.processors.unparsers.UState
* dfdl:setVariable expressions (which variables are in-turn used by
* dfdl:outputValueCalc.
*/
+object SuspendableExpression {
+
+ /**
+ * A plain value/variable read can resolve as soon as the referenced
+ * value is known, regardless of whether anything has been written yet
+ * (the forward-reference case this speeds up during build). A
Review Comment:
I dont' think I understand what "(the forward-reference case this speeds up
during build)" means
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/infoset/InfosetImpl.scala:
##########
@@ -572,6 +573,44 @@ sealed abstract class LengthState(ie: DIElement) {
var maybeEndPos0bInBits: MaybeULong = MaybeULong.Nope
var maybeComputedLengthInBits: MaybeULong = MaybeULong.Nope
+ /**
+ * Suspensions blocked on this element's length being unknown
+ * (dfdl:contentLength/dfdl:valueLength), registered via registerWaiter
+ * and retried directly via notifyWaiters once this length becomes
+ * computable, instead of waiting for SuspensionTracker's next periodic
+ * sweep. Purely an optimization alongside that sweep, not a
+ * replacement: a suspension still sits in SuspensionTracker's own
+ * queues too, so requireFinal()'s deadlock detection is unaffected.
+ *
+ * Deduplicated by reference identity - a suspension that re-blocks
+ * here across multiple sweeps re-registers each time, and without
+ * dedup this list would grow without bound.
+ */
+ private var waiters: List[Suspension] = Nil
Review Comment:
Can we rename 'waiters'? Maybe `lengthSuspensions` to make it clear this is
only expected to contain suspensions specifically waiting for this elements
length? I imagine if we ever want additional kinds of waiters we'll want
separate variables so that we can notify them at the right time--different
types of suspensions likely will have different notification triggers.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/dpath/DPath.scala:
##########
@@ -252,6 +254,24 @@ final class RuntimeExpressionDPath[T <: AnyRef](
whereBlockedInfo.block(ve.qname, ve.context, 0, ve)
case noLength: InfosetLengthUnknownException =>
whereBlockedInfo.block(noLength.diElement, noLength.erd, 0, noLength)
+ // Register a targeted wake-up alongside the periodic-sweep blocking
+ // above: once this element's length becomes computable (see
Review Comment:
I'm not sure what "periodic-sweep blocking above" is referencing.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/SuspensionTracker.scala:
##########
@@ -28,7 +28,59 @@ class SuspensionTracker(suspensionWaitYoung: Int,
suspensionWaitOld: Int) {
private val suspensionsYoung = new Queue[Suspension]
private val suspensionsOld = new Queue[Suspension]
- def suspensions: Seq[Suspension] = suspensionsYoung.toSeq ++
suspensionsOld.toSeq
+ /**
+ * Suspensions unable to make progress until something external
+ * changes (a targeted wake-up firing, or real bytes getting written);
+ * moved out of suspensionsYoung/suspensionsOld so the periodic sweep
+ * (evalSuspensionsThrottled) doesn't keep re-visiting them every tick -
+ * even a cheap per-item skip costs real time at scale.
+ *
+ * Only drained by foldParkedIntoOld, from the two "must attempt
+ * everything" methods below (evalSuspensionsUnthrottled, requireFinal);
+ * the targeted wake-up itself (LengthState.notifyWaiters) retries a
+ * suspension directly regardless of which bucket it's in, so parking
+ * never delays that path.
+ */
+ private val suspensionsParked = new Queue[Suspension]
Review Comment:
Suggests this just wants to be a mutable.HashSet. Suspensions are added to
this when blocked by length state, and when a LengthState changes we can remove
it from the HashSet and add it to the young queue. Depending on how good our
lenght state logic can be to know if it will be blocked or not it could
potentially get blocked again and end up readded, but hopefully not.
This ensures we only ever evaluate this parked suspensions when we really do
think they have a chance of getting evaluated.
##########
daffodil-core/src/main/scala/org/apache/daffodil/lib/util/MStack.scala:
##########
@@ -190,9 +190,14 @@ protected abstract class MStack[@specialized T]
private[util] (
private var index = 0
private var table: Array[T] = null
- def init(): Unit = {
+ /**
+ * initialSize lets a caller that knows its element count up front (e.g.
+ * cloning another MStack of known depth, see UState.cloneForSuspension)
+ * avoid the default 32-slot allocation when that's more than needed.
+ */
Review Comment:
I believe the init function is not a function that users should call. So
this user-related documentation probably doesn't belong here. I would suggest
whatever documentation we do have for initialSize wants to be on the actual
user visible apply methods. I'd also suggest not provided examples referencing
other code--that code could change and then this documentation is wrong and
potentially confusing. If we do want to provide examples, we can include
example usage in this comment. That said, initialSize is pretty self
explanatory, so I'm not sure an example or even documentation provides much
value.
Also, I wonder if we can get rid of the init function entirely, e.g. just do
this in the constructor:
```scala
private var table: Array[T] = arrayAllocator(initialSize)
```
Currently all the MStack* constructors are priate so you have to use the
object apply method, and I think all the objects immediately call init after
allocating an MStack instance. So the init function doesn't seem to really do
anything special. Maybe we should just get rid of it if possible and simplify
the code.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/Suspension.scala:
##########
@@ -228,9 +277,51 @@ trait Suspension extends Serializable {
final def isMakingProgress = isMakingProgress_
+ /**
+ * True exactly when this suspension is blocked on
+ * InfosetLengthUnknownException with a targeted wake-up already
+ * registered against the relevant LengthState (set only from
+ * DPath.scala's InfosetLengthUnknownException catch clause, the one
+ * place that calls markWaitingOnLengthState). SuspensionTracker's
+ * periodic sweep uses this to skip re-running doTask until that
+ * wake-up fires. Not inferred from block()'s exc type
+ * (SuspendableOperation.scala also passes a RetryableException that
+ * could structurally match without a wake-up registered) - only the
+ * actual registration site sets this, so the two can't drift apart.
+ */
+ private var isWaitingOnLengthState_ : Boolean = false
+
+ final def markWaitingOnLengthState(): Unit = {
+ isWaitingOnLengthState_ = true
+ }
+
+ final def isWaitingOnLengthState: Boolean = isWaitingOnLengthState_
+
+ /**
+ * Which LengthState (if any) this suspension is registered with as a
+ * waiter. A suspension's dependency can shift between retries (e.g. a
+ * length expression whose which-element branch changes), so it must be
+ * deregistered from the old LengthState before registering a new one,
+ * or the old one's notifyWaiters() would retry it pointlessly.
+ */
+ private var maybeRegisteredLengthState: Maybe[LengthState] = Nope
+
+ final def registerLengthStateWaiter(ls: LengthState): Unit = {
+ if (maybeRegisteredLengthState.isDefined &&
(maybeRegisteredLengthState.get ne ls))
+ maybeRegisteredLengthState.get.removeWaiter(this)
+ maybeRegisteredLengthState = One(ls)
+ ls.registerWaiter(this)
+ }
+
final def block(nodeOrVar: AnyRef, info: AnyRef, index: Long, exc: AnyRef):
Unit = {
Logger.log.debug(s"blocking ${this} due to ${exc}")
+ // Reset unconditionally; DPath.scala's InfosetLengthUnknownException
+ // handling re-sets this immediately via markWaitingOnLengthState when
+ // that's the actual blocking reason, so it never drifts out of sync
+ // with whether a real wake-up is registered.
+ isWaitingOnLengthState_ = false
Review Comment:
I think this goes away if we switch to the idea where parked suspensions are
never evaluated until we determine they should be unblocked.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/SuspensionTracker.scala:
##########
@@ -46,16 +98,67 @@ class SuspensionTracker(suspensionWaitYoung: Int,
suspensionWaitOld: Int) {
* evaluate are moved to the old suspensions list. If we evaluate old
* suspensions, we attempt to evaluate them first, with the hope that their
* resolution might make the young suspensions more likely to evaluate.
+ *
+ * skipLengthStateWaiters = true here: a suspension with
+ * isWaitingOnLengthState true has a targeted wake-up already
+ * registered (fired from CaptureEndOf{Content,Value}LengthUnparsers
+ * once its length becomes computable) and can't progress until that
+ * fires - retrying it on the blind periodic schedule first is pure
+ * wasted DPath re-evaluation.
*/
- def evalSuspensions(): Unit = {
+ def evalSuspensions(): Unit =
+ evalSuspensionsThrottled(filterToBuildResolvable = false,
skipLengthStateWaiters = true)
+
+ /**
+ * A discard-sink sweep variant: same throttled cadence as
+ * evalSuspensions, but passes filterToBuildResolvable=true to
+ * evalSuspensionQueue. A suspension whose canResolveWithoutWriting is
+ * false can never be satisfied by a discard-sink traversal no matter
+ * how many retries, so it's skipped-and-requeued instead of really
+ * attempted - unless it's already isWaitingOnLengthState, in which
+ * case it's parked instead (parking only ever follows one of the real
+ * sweep's (evalSuspensions) own unfiltered attempts having set that
+ * flag; this filtered sweep never sets it itself). Either way the
+ * suspension stays pending for that real sweep's later, unfiltered
+ * attempts once real bytes exist for it to depend on.
+ *
+ * Eliminates the wasted doTask cost of this discard-sink sweep for
+ * these suspensions; doesn't eliminate the smaller per-tick
+ * dequeue/requeue cost for suspensions with no targeted wake-up at all
+ * (e.g. padding/target-length SuspendableOperations), which must stay
+ * on the skip-and-requeue path so the real sweep still finds them.
+ *
+ * skipLengthStateWaiters is left false here: canResolveWithoutWriting
Review Comment:
It feels like skipLengthStateWaiters can go away if we move to a deisgn
where we never evaluate LengthState waiters until they are notified, and when
they are notified we move them to young since they are likely to succeed. With
that design, we *always* skip length state waiters until something moves them
to the young queue.
##########
daffodil-core/src/main/scala/org/apache/daffodil/unparsers/runtime1/ElementUnparser.scala:
##########
@@ -360,13 +360,29 @@ class ElementOVCSpecifiedLengthUnparser(
private def suspendableExpression =
new ElementOVCSpecifiedLengthUnparserSuspendableExpression(this, expr)
+ // Negation of SuspendableExpression.canResolveWithoutWriting, computed
+ // once here since expr is already known at unparser-construction time.
+ // True when this OVC's expression can never succeed without a real
+ // written DOS bit position (e.g. references dfdl:valueLength/
+ // contentLength) - the first attempt is then guaranteed to block, no
+ // matter how many times retried.
+ private val requiresWriteTimeValue: Boolean =
+ !SuspendableExpression.canResolveWithoutWriting(expr)
+
Assert.invariant(context.dpathElementCompileInfo.isOutputValueCalc)
override def runContentUnparser(state: UState): Unit = {
computeTargetLength(
state
) // must happen before run() so that we can take advantage of knowing the
length
- suspendableExpression.run(state) // run the expression. It might or might
not have a value.
+ // Skip the guaranteed-to-fail doTask attempt when requiresWriteTimeValue
Review Comment:
Is this true that an OVC with a content/valueLength function is guaranteed
to fail? It's only guaranteed to fail if content/valueLentgh parameter is a
forward lookahead. Referencing the length of a previous element could succeed
without needing to suspend.
I don't think we have the logic to know at compile time is a path expression
is a forward lookahead or not.
Granted, most OVC's that reference the length of something almost always do
a forward reference (usually because the OVC is an element that contains the
length of some future thing). So since this is by far the most common case, if
this "suspend without attempt" thing makes a noticable performance difference
then it's probably worth it keeping. But we should update this comment to make
it clear it's not guaranteed, it's just super common so is an optimization for
the common case.The uncommon case will takea small hit by suspending
unnecessariyl, but that's the tradeoff we're willing to make.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/dpath/DPath.scala:
##########
@@ -252,6 +254,24 @@ final class RuntimeExpressionDPath[T <: AnyRef](
whereBlockedInfo.block(ve.qname, ve.context, 0, ve)
case noLength: InfosetLengthUnknownException =>
whereBlockedInfo.block(noLength.diElement, noLength.erd, 0, noLength)
+ // Register a targeted wake-up alongside the periodic-sweep blocking
+ // above: once this element's length becomes computable (see
+ // CaptureEndOf{Content,Value}LengthUnparser), this suspension is
Review Comment:
I think we might have to be careful about where we call notifyWaiters--I
don't think CaptureEndOf*LengthUnparser is the only place where the length of
an element can be finally resolved. In fact, I think it's not uncommon for
those unparsers to not be able to actually capture the actual length and
instead just capture relative bit positions of where the DOS started, which
often isn't enough for
I'm wondering if instead of notifying when an unparser ends, we should
instead notify when something calls `setRel/AbsBitPosition` on the LengthState,
since 1. that is the LengthState that supension is waiting for and 2. the
LengthState knows when it has been calculated.
The one thing I'm not sure about is if the length gets recalculated during
normal buffer resolution or when something (i.e. the suspension) asks for the
length. If it'sonly recalculated when a suspension asks for it, then we're in a
catch-22--we won't notify the suspension until the length is recalculated,
butwe won't recalculate the length until the suspension is run.
I *think* if we notify when things like setAbs/Rel are called, and maybe
some ofther functions, it might be sufficient to ensure the suspensions are
always triggered correctly.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/unparsers/UState.scala:
##########
@@ -553,8 +553,10 @@ final class UStateMain private (
// MStack, since the escape scheme cache logic requires an MStack. We
// reallyjust need the top for cloning for suspensions, but that
// requires changes to how the escape schema cache is accessed, which
- // isn't a trivial change.
- val esClone = new MStackOfMaybe[EscapeSchemeUnparserHelper]()
+ // isn't a trivial change. Sized to the source's actual depth
+ // (instead of MStack's default 32) since nothing ever pushes onto
Review Comment:
We don't need to document the default size of an MStack here. That's an
implementation detail of MStack that doesn't affect this. Same with below
comment.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/infoset/InfosetImpl.scala:
##########
@@ -572,6 +573,44 @@ sealed abstract class LengthState(ie: DIElement) {
var maybeEndPos0bInBits: MaybeULong = MaybeULong.Nope
var maybeComputedLengthInBits: MaybeULong = MaybeULong.Nope
+ /**
+ * Suspensions blocked on this element's length being unknown
+ * (dfdl:contentLength/dfdl:valueLength), registered via registerWaiter
+ * and retried directly via notifyWaiters once this length becomes
+ * computable, instead of waiting for SuspensionTracker's next periodic
+ * sweep. Purely an optimization alongside that sweep, not a
+ * replacement: a suspension still sits in SuspensionTracker's own
+ * queues too, so requireFinal()'s deadlock detection is unaffected.
+ *
+ * Deduplicated by reference identity - a suspension that re-blocks
+ * here across multiple sweeps re-registers each time, and without
+ * dedup this list would grow without bound.
+ */
+ private var waiters: List[Suspension] = Nil
Review Comment:
Should we just make this a mutabla.HashSet? We get duplication detection for
free. It also means the removeWaiting function will be constant time and will
never copy the full list which filterNot will do.
That said, it's possible using a List is still a win if this is expected to
always be a very small list of things, which I imagine it is in most cases. The
overhead of calculating hashes might actually be a bit slowerthan just looking
through a list of a couple things. The List scales very poorly, but it feels
very unlikely to have a bunch of things suspending on the same elements length.
If we do stick with a list, it might be worth documenting that this is
always expected to be very small and so is faster than a HashSet (with testing
to confirm)
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/infoset/InfosetImpl.scala:
##########
@@ -572,6 +573,44 @@ sealed abstract class LengthState(ie: DIElement) {
var maybeEndPos0bInBits: MaybeULong = MaybeULong.Nope
var maybeComputedLengthInBits: MaybeULong = MaybeULong.Nope
+ /**
+ * Suspensions blocked on this element's length being unknown
+ * (dfdl:contentLength/dfdl:valueLength), registered via registerWaiter
+ * and retried directly via notifyWaiters once this length becomes
+ * computable, instead of waiting for SuspensionTracker's next periodic
+ * sweep. Purely an optimization alongside that sweep, not a
+ * replacement: a suspension still sits in SuspensionTracker's own
+ * queues too, so requireFinal()'s deadlock detection is unaffected.
Review Comment:
> a suspension still sits in SuspensionTracker's own queues too, so
requireFinal()'s deadlock detection is unaffected.
1. This comment feels like it describing implementation details about
another part of code. I would suggest we don't do that. If we change that
implementation we also have to remember to update this. I would suggest this
simple says this is the list of suspensions waiting for the length of this
element to be resolved. We then leave it up to the suspension tracker to figure
out what how to handle those.
2. This feels a bit wasteful to have these suspension in the same queues,
aren't they always going to fail until the length is calculated? What if we
keep them completely separate, and when isFinal is true we can copy any
remaining length suspensions to the main queues and then let requireFinal due
it's last pass/deadlock detection. Note that in order for this to work best, we
probably need to ensure that we are notifying these suspensionsa the right
times, since other wise they won't get resolved until the very end.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/Suspension.scala:
##########
@@ -104,6 +137,20 @@ trait Suspension extends Serializable {
}
}
+ /**
+ * Public entry point to prepareToSuspend (private below), for callers
+ * that already know - from static information doTask can't see - that
+ * the first attempt is certain to block (e.g. an OVC referencing
+ * dfdl:valueLength/dfdl:contentLength from a discard-sink traversal
Review Comment:
What does a "discard-sink traversal that never writes real bytes" mean?
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/Suspension.scala:
##########
@@ -104,6 +137,20 @@ trait Suspension extends Serializable {
}
}
+ /**
+ * Public entry point to prepareToSuspend (private below), for callers
+ * that already know - from static information doTask can't see - that
+ * the first attempt is certain to block (e.g. an OVC referencing
+ * dfdl:valueLength/dfdl:contentLength from a discard-sink traversal
+ * that never writes real bytes). Only the wasted doTask attempt is
+ * skipped; prepareToSuspend still runs at this exact call site, so any
+ * caller-specific state-patching (e.g. a cloneForSuspension override)
+ * happens exactly as it would after a real blocked attempt - the
+ * suspension's freeze point isn't deferred to wherever the caller's
+ * result eventually gets used.
+ */
+ final def suspendWithoutAttempting(ustate: UState): Unit =
prepareToSuspend(ustate)
Review Comment:
I'm curious how much this really makes a differences. My assumption was also
that the doTask part of suspension like OVC is not particularly expensive, but
it's everything else related to suspensions (copying the UState, adding to
suspension tracker, evaluating in the suspension tracker, etc.) is the
expensive part.
This change doesn't seem unreasonable,but I'm also curoius if it was found
to make a differences. This PR has kindof three unrelated optimizations (MStack
size limit, parked suspensions, skip first doTask), so I'm curious which one
made a diferent, or if they each just make a little difference that adds up.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/Suspension.scala:
##########
@@ -70,26 +85,44 @@ trait Suspension extends Serializable {
protected def doTask(ustate: UState): Unit
+ /**
+ * Guards against runSuspension re-entering for this same instance
+ * while already on-stack. Expected to never trip today (suspend()
+ * clones the ustate at block time, so runSuspension only touches an
+ * isolated per-suspension clone) - exists because LengthState's
+ * targeted wake-up (notifyWaiters) gives a second path into
+ * runSuspension alongside SuspensionTracker's periodic sweeps; if
+ * those two ever nest, this fails loudly instead of silently
+ * corrupting output.
+ */
Review Comment:
What if instead of notifyWaiters actually running those targeted
suspensions, it notifies the suspension tracker to move a suspension from
parked to the head of the young queue, and then we just let the suspension
tracker evaluate things like normal whenever it happens to get triggered next?
It does mean the targeted suspension might get triggered a little later than
normal, but I assume the biggest benefit of this PR is avoiding running
suspensions that we know won't succeed, which this still achieves, just with a
slight delay. And then we can get rid of the isRunning check since there is
still only one entrypoint into running suspensions.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/Suspension.scala:
##########
@@ -51,6 +52,20 @@ trait Suspension extends Serializable {
*/
val isReadOnly = false
+ /**
+ * True if this suspension's task might succeed without any bytes having
+ * been written yet (e.g. a plain value/variable read); false if it
+ * needs write-time-only information (e.g. a real DOS bit position, as
+ * dfdl:valueLength/dfdl:contentLength and most padding/fill/alignment
+ * operations do). Distinct from isReadOnly above: those length
+ * operations don't write DOS bytes (so they're isReadOnly) but DO read
+ * write-time-only bit-position state, so they're NOT
+ * canResolveWithoutWriting. Defaults to false; used by
+ * SuspensionTracker.evalBuildResolvableSuspensions to skip retrying a
+ * suspension from a discard-sink traversal that can never satisfy it.
Review Comment:
"discard-sink" is used a lot in this PR and I'm not sure what it means in
this context, can you clarify?
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/SuspensionTracker.scala:
##########
@@ -28,7 +28,59 @@ class SuspensionTracker(suspensionWaitYoung: Int,
suspensionWaitOld: Int) {
private val suspensionsYoung = new Queue[Suspension]
private val suspensionsOld = new Queue[Suspension]
- def suspensions: Seq[Suspension] = suspensionsYoung.toSeq ++
suspensionsOld.toSeq
+ /**
+ * Suspensions unable to make progress until something external
+ * changes (a targeted wake-up firing, or real bytes getting written);
+ * moved out of suspensionsYoung/suspensionsOld so the periodic sweep
+ * (evalSuspensionsThrottled) doesn't keep re-visiting them every tick -
+ * even a cheap per-item skip costs real time at scale.
+ *
+ * Only drained by foldParkedIntoOld, from the two "must attempt
+ * everything" methods below (evalSuspensionsUnthrottled, requireFinal);
+ * the targeted wake-up itself (LengthState.notifyWaiters) retries a
+ * suspension directly regardless of which bucket it's in, so parking
+ * never delays that path.
+ */
+ private val suspensionsParked = new Queue[Suspension]
+
+ /**
+ * Every still-tracked, not-yet-done suspension across all buckets.
+ * Must include suspensionsParked, or a suspension moving into that
+ * bucket would look like (incorrectly) resolved progress to a caller
+ * comparing this count before/after evalSuspensionsUnthrottled().
+ */
+ def suspensions: Seq[Suspension] =
+ suspensionsYoung.toSeq ++ suspensionsOld.toSeq ++ suspensionsParked.toSeq
+
+ /**
+ * Count of suspensions currently parked (suspensionsParked above) -
+ * unable to progress until the real bytes their wake-up depends on
+ * actually get written (i.e. by a non-discard-sink sweep). Not a good
+ * backlog-sized throttle on its own (see pendingCount below): a
+ * suspension is only classified here once it's actually re-retried
+ * and re-blocks on InfosetLengthUnknownException. A discard-sink sweep
+ * (evalBuildResolvableSuspensions) never performs that retry - it
+ * always skip-and-requeues instead - so nothing parks purely from a
+ * discard-sink sweep before a real sweep (evalSuspensions) has run at
+ * least once.
+ */
+ def parkedCount: Int = suspensionsParked.length
Review Comment:
Never used, suggest we remove it
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/dpath/DPath.scala:
##########
@@ -252,6 +254,24 @@ final class RuntimeExpressionDPath[T <: AnyRef](
whereBlockedInfo.block(ve.qname, ve.context, 0, ve)
case noLength: InfosetLengthUnknownException =>
whereBlockedInfo.block(noLength.diElement, noLength.erd, 0, noLength)
+ // Register a targeted wake-up alongside the periodic-sweep blocking
+ // above: once this element's length becomes computable (see
+ // CaptureEndOf{Content,Value}LengthUnparser), this suspension is
+ // retried directly instead of waiting for SuspensionTracker's next
+ // sweep. registerLengthStateWaiter (not registerWaiter directly)
+ // also deregisters from whichever LengthState this suspension was
+ // previously registered against, in case an earlier retry blocked
+ // on a different element's length - see Suspension.scala.
+ noLength match {
+ case _: InfosetContentLengthUnknownException =>
+
whereBlockedInfo.registerLengthStateWaiter(noLength.diElement.contentLength)
+ case _: InfosetValueLengthUnknownException =>
+
whereBlockedInfo.registerLengthStateWaiter(noLength.diElement.valueLength)
+ }
+ // Tell SuspensionTracker's throttled sweep it can skip this
+ // suspension until the wake-up above fires (see
+ // Suspension.isWaitingOnLengthState).
+ whereBlockedInfo.markWaitingOnLengthState()
Review Comment:
Do we need this function, can the logic done is this function be moved to
resgisterLengthStateWaiter? Seems like you can never have one with out the
other so they could be combined.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/SuspensionTracker.scala:
##########
@@ -28,7 +28,59 @@ class SuspensionTracker(suspensionWaitYoung: Int,
suspensionWaitOld: Int) {
private val suspensionsYoung = new Queue[Suspension]
private val suspensionsOld = new Queue[Suspension]
- def suspensions: Seq[Suspension] = suspensionsYoung.toSeq ++
suspensionsOld.toSeq
+ /**
+ * Suspensions unable to make progress until something external
+ * changes (a targeted wake-up firing, or real bytes getting written);
+ * moved out of suspensionsYoung/suspensionsOld so the periodic sweep
+ * (evalSuspensionsThrottled) doesn't keep re-visiting them every tick -
+ * even a cheap per-item skip costs real time at scale.
+ *
+ * Only drained by foldParkedIntoOld, from the two "must attempt
+ * everything" methods below (evalSuspensionsUnthrottled, requireFinal);
+ * the targeted wake-up itself (LengthState.notifyWaiters) retries a
+ * suspension directly regardless of which bucket it's in, so parking
+ * never delays that path.
+ */
+ private val suspensionsParked = new Queue[Suspension]
+
+ /**
+ * Every still-tracked, not-yet-done suspension across all buckets.
+ * Must include suspensionsParked, or a suspension moving into that
+ * bucket would look like (incorrectly) resolved progress to a caller
+ * comparing this count before/after evalSuspensionsUnthrottled().
+ */
Review Comment:
We should add a comment that this should only ever be used for debugging
purposes. Combining these queues is a faiarly expensive operation that
internally we should never use in a hot path.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/Suspension.scala:
##########
@@ -175,8 +222,10 @@ trait Suspension extends Serializable {
//
// clone the ustate for use when evaluating the expression
//
- // TODO: Performance - copying this whole state, just for OVC is painful.
- // Some sort of copy-on-write scheme would be better.
+ // cloneForSuspension (UState.scala) is a targeted partial clone
+ // (shallow VariableMap copy, stack tops only), not a full deep copy;
+ // its escapeSchemeEVCache/delimiterStack clones are sized to actual
+ // depth instead of MStack's default 32 slots.
Review Comment:
I think this TODO is still needed. Although the cloneForSuspensions is
somewhat targeted and isn't a full deep clone, it does still copy a lot of
things that might never change. I've always wonder if these copies were a
sizable part of suspension overhead, if so copy-on-write implementation could
potentially help with that. Even fixing things so we don't copy the entire
escapeScheme stacks could be an improvement.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/Suspension.scala:
##########
@@ -228,9 +277,51 @@ trait Suspension extends Serializable {
final def isMakingProgress = isMakingProgress_
+ /**
+ * True exactly when this suspension is blocked on
+ * InfosetLengthUnknownException with a targeted wake-up already
+ * registered against the relevant LengthState (set only from
+ * DPath.scala's InfosetLengthUnknownException catch clause, the one
+ * place that calls markWaitingOnLengthState). SuspensionTracker's
+ * periodic sweep uses this to skip re-running doTask until that
+ * wake-up fires. Not inferred from block()'s exc type
+ * (SuspendableOperation.scala also passes a RetryableException that
+ * could structurally match without a wake-up registered) - only the
+ * actual registration site sets this, so the two can't drift apart.
+ */
+ private var isWaitingOnLengthState_ : Boolean = false
+
+ final def markWaitingOnLengthState(): Unit = {
+ isWaitingOnLengthState_ = true
+ }
+
+ final def isWaitingOnLengthState: Boolean = isWaitingOnLengthState_
+
+ /**
+ * Which LengthState (if any) this suspension is registered with as a
+ * waiter. A suspension's dependency can shift between retries (e.g. a
+ * length expression whose which-element branch changes), so it must be
+ * deregistered from the old LengthState before registering a new one,
+ * or the old one's notifyWaiters() would retry it pointlessly.
+ */
+ private var maybeRegisteredLengthState: Maybe[LengthState] = Nope
+
+ final def registerLengthStateWaiter(ls: LengthState): Unit = {
+ if (maybeRegisteredLengthState.isDefined &&
(maybeRegisteredLengthState.get ne ls))
+ maybeRegisteredLengthState.get.removeWaiter(this)
+ maybeRegisteredLengthState = One(ls)
+ ls.registerWaiter(this)
Review Comment:
This is a bit confusing to me, since this implies a suspension was waiting
for a previous state, but then somehow now depends on a different state? I'm
not sure how that can ever happen since the suspension should still be blocked
on the previous length state.
Feels like this should be something
```scala
Assert.invariant(!maybeRegisteredLengthState.isDefined)
```
Feels like the suspension tracker needs a function that liked
```scala
def moveFromParked(s: Suspension): Unit = {
Assert.invariant(s.maybeRegisterLengthStateWaiter.isDefined)
s.maybeRegisterLengthStateWaiter.get.removeSuspension(this)
s.maybeRegisterLenghtStateWaiter = Nope
suspensionsParked.remove(s)
suspensionsYoung.push(s)
}
```
So the idea is if something is parked then it must reference a LengthState
in the and the LengthState must references it. Something eventually notifies
the tracker that a parked suspension is unblocked so it unregisteres it (sets
the internal state to Nope and tells the LenghtState to no longer wait on it)
and then moves it to young to be evaluated later.
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/SuspensionTracker.scala:
##########
@@ -46,16 +98,67 @@ class SuspensionTracker(suspensionWaitYoung: Int,
suspensionWaitOld: Int) {
* evaluate are moved to the old suspensions list. If we evaluate old
* suspensions, we attempt to evaluate them first, with the hope that their
* resolution might make the young suspensions more likely to evaluate.
+ *
+ * skipLengthStateWaiters = true here: a suspension with
+ * isWaitingOnLengthState true has a targeted wake-up already
+ * registered (fired from CaptureEndOf{Content,Value}LengthUnparsers
+ * once its length becomes computable) and can't progress until that
+ * fires - retrying it on the blind periodic schedule first is pure
+ * wasted DPath re-evaluation.
*/
- def evalSuspensions(): Unit = {
+ def evalSuspensions(): Unit =
+ evalSuspensionsThrottled(filterToBuildResolvable = false,
skipLengthStateWaiters = true)
+
+ /**
+ * A discard-sink sweep variant: same throttled cadence as
+ * evalSuspensions, but passes filterToBuildResolvable=true to
+ * evalSuspensionQueue. A suspension whose canResolveWithoutWriting is
+ * false can never be satisfied by a discard-sink traversal no matter
+ * how many retries, so it's skipped-and-requeued instead of really
+ * attempted - unless it's already isWaitingOnLengthState, in which
+ * case it's parked instead (parking only ever follows one of the real
+ * sweep's (evalSuspensions) own unfiltered attempts having set that
+ * flag; this filtered sweep never sets it itself). Either way the
+ * suspension stays pending for that real sweep's later, unfiltered
+ * attempts once real bytes exist for it to depend on.
+ *
+ * Eliminates the wasted doTask cost of this discard-sink sweep for
+ * these suspensions; doesn't eliminate the smaller per-tick
+ * dequeue/requeue cost for suspensions with no targeted wake-up at all
+ * (e.g. padding/target-length SuspendableOperations), which must stay
+ * on the skip-and-requeue path so the real sweep still finds them.
+ *
+ * skipLengthStateWaiters is left false here: canResolveWithoutWriting
+ * already excludes every length-state-blocked suspension from this
+ * sweep's retries, and more completely (it also covers non-length
+ * forward references), so a second filter would be redundant.
+ */
+ def evalBuildResolvableSuspensions(): Unit =
Review Comment:
This is never used, and it's purpose isn't entirely clear, presumably for a
future change. Can this and things related to filterToBuildResolvable be moved
to a separate PR so we can review all the related stuff in one PR?
##########
daffodil-core/src/main/scala/org/apache/daffodil/runtime1/processors/SuspensionTracker.scala:
##########
@@ -28,7 +28,59 @@ class SuspensionTracker(suspensionWaitYoung: Int,
suspensionWaitOld: Int) {
private val suspensionsYoung = new Queue[Suspension]
private val suspensionsOld = new Queue[Suspension]
- def suspensions: Seq[Suspension] = suspensionsYoung.toSeq ++
suspensionsOld.toSeq
+ /**
+ * Suspensions unable to make progress until something external
+ * changes (a targeted wake-up firing, or real bytes getting written);
+ * moved out of suspensionsYoung/suspensionsOld so the periodic sweep
+ * (evalSuspensionsThrottled) doesn't keep re-visiting them every tick -
+ * even a cheap per-item skip costs real time at scale.
+ *
+ * Only drained by foldParkedIntoOld, from the two "must attempt
+ * everything" methods below (evalSuspensionsUnthrottled, requireFinal);
+ * the targeted wake-up itself (LengthState.notifyWaiters) retries a
+ * suspension directly regardless of which bucket it's in, so parking
+ * never delays that path.
+ */
+ private val suspensionsParked = new Queue[Suspension]
+
+ /**
+ * Every still-tracked, not-yet-done suspension across all buckets.
+ * Must include suspensionsParked, or a suspension moving into that
+ * bucket would look like (incorrectly) resolved progress to a caller
+ * comparing this count before/after evalSuspensionsUnthrottled().
+ */
+ def suspensions: Seq[Suspension] =
+ suspensionsYoung.toSeq ++ suspensionsOld.toSeq ++ suspensionsParked.toSeq
+
+ /**
+ * Count of suspensions currently parked (suspensionsParked above) -
+ * unable to progress until the real bytes their wake-up depends on
+ * actually get written (i.e. by a non-discard-sink sweep). Not a good
+ * backlog-sized throttle on its own (see pendingCount below): a
+ * suspension is only classified here once it's actually re-retried
+ * and re-blocks on InfosetLengthUnknownException. A discard-sink sweep
+ * (evalBuildResolvableSuspensions) never performs that retry - it
+ * always skip-and-requeues instead - so nothing parks purely from a
+ * discard-sink sweep before a real sweep (evalSuspensions) has run at
+ * least once.
+ */
+ def parkedCount: Int = suspensionsParked.length
+
+ /**
+ * Total not-yet-done suspensions across all three buckets, without
+ * allocating (unlike `suspensions` above - not safe to call once per
+ * node). Unlike parkedCount, grows the moment a suspension is created
+ * (trackSuspension), with no dependency on it having been retried yet
+ * - intended as a throttle signal for pacing a discard-sink traversal
+ * against the pending backlog. Still distinguishes the two workload
+ * shapes correctly: a canResolveWithoutWriting=true suspension
+ * resolves within a few ticks of its sibling being added to the tree
+ * (stays small/transient), while a length-dependent one (never
+ * resolvable without real bytes actually being written) accumulates
+ * here unboundedly.
+ */
+ def pendingCount: Int =
+ suspensionsYoung.length + suspensionsOld.length + suspensionsParked.length
Review Comment:
Never used, suggest we remove
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]