From 653298f62518d215b481c639494e98cf4ae12854 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:37:01 +0000 Subject: [PATCH 1/3] fix(runtime): skip the behavior scan while the context is quiescent Co-Authored-By: jason.han --- .../quiescent-behavior-scan.fixed.md | 1 + internal/exec/runtime/advance.go | 6 +- internal/exec/runtime/check_run.go | 2 +- internal/exec/runtime/classifier_behavior.go | 36 +++++- internal/exec/runtime/context.go | 9 +- internal/exec/runtime/held_image.go | 5 +- internal/exec/runtime/held_image_behavior.go | 1 + internal/exec/runtime/quiescence_test.go | 111 ++++++++++++++++++ internal/exec/runtime/signal.go | 1 + internal/exec/runtime/start_behavior.go | 1 + internal/exec/runtime/state_executor.go | 18 ++- 11 files changed, 173 insertions(+), 18 deletions(-) create mode 100644 changes/unreleased/quiescent-behavior-scan.fixed.md create mode 100644 internal/exec/runtime/quiescence_test.go diff --git a/changes/unreleased/quiescent-behavior-scan.fixed.md b/changes/unreleased/quiescent-behavior-scan.fixed.md new file mode 100644 index 000000000..53297177d --- /dev/null +++ b/changes/unreleased/quiescent-behavior-scan.fixed.md @@ -0,0 +1 @@ +- Checking a constraint or writing a feature no longer rescans every object's behaviors when nothing has changed since the last scan found them all idle, so a batch of checks over many instantiated objects is linear again. diff --git a/internal/exec/runtime/advance.go b/internal/exec/runtime/advance.go index 5caf38d09..602dc3e97 100644 --- a/internal/exec/runtime/advance.go +++ b/internal/exec/runtime/advance.go @@ -123,10 +123,10 @@ func (ctx *Context) AdvanceUntil(duration float64, halted func() bool) (AdvanceR if !ok || next > deadline { break } - ctx.clock.now = next + ctx.setClock(next) progress.unsettle() } - ctx.clock.now = deadline + ctx.setClock(deadline) report.To = deadline return report.counting(progress, ctx.run.notes[noted:]), ctx.advanceEnded() } @@ -155,7 +155,7 @@ func (ctx *Context) advanceToNextDue(progress *dueProgress) bool { if !ok { return false } - ctx.clock.now = next + ctx.setClock(next) progress.unsettle() return true } diff --git a/internal/exec/runtime/check_run.go b/internal/exec/runtime/check_run.go index 77df2ed80..6577502b6 100644 --- a/internal/exec/runtime/check_run.go +++ b/internal/exec/runtime/check_run.go @@ -162,7 +162,7 @@ func (r *invocationRun) advanceClock() bool { if !ok || next <= r.ctx.clock.now { return false } - r.ctx.clock.now = next + r.ctx.setClock(next) r.turn = nil return true } diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 00c0b28ce..1df2b81ba 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -662,12 +662,23 @@ func (ctx *Context) startBehaviorsOf(inst *Instance) error { inst.behaviors = append(inst.behaviors, behavior) ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + ctx.workChanged() } } return ctx.runAttachedBehaviors() } +// workChanged counts a change that can leave an attached behavior holding work: +// a message posted, the clock moved, an event queued, an executor run or left. +func (ctx *Context) workChanged() { ctx.work++ } + +// setClock moves the shared clock, the work due on it moving with it. +func (ctx *Context) setClock(now float64) { + ctx.clock.now = now + ctx.workChanged() +} + // holdDrivenWork marks, at an outermost start, the behaviors already holding // work: a driver put it in flight, so the start leaves it to that driver. Once // the start returns, the behaviors it attached are as their start left them. @@ -676,13 +687,20 @@ func (ctx *Context) holdDrivenWork() func() { return func() { /* an outer start already holds them */ } } held := make(map[*ObjectBehavior]bool) - ctx.behaviorRunDepth++ - for _, behavior := range ctx.objectBehaviors { - if behavior.hasPendingWork() { - held[behavior] = true + if ctx.quiescentAt != 0 && ctx.quiescentAt == ctx.work { + // Nothing woke a behavior since a full scan found them all idle. + } else { + ctx.behaviorRunDepth++ + for _, behavior := range ctx.objectBehaviors { + if behavior.hasPendingWork() { + held[behavior] = true + } + } + ctx.behaviorRunDepth-- + if len(held) == 0 { + ctx.quiescentAt = ctx.work } } - ctx.behaviorRunDepth-- ctx.heldBehaviors = held attached := len(ctx.objectBehaviors) return func() { @@ -771,6 +789,7 @@ func (ctx *Context) forgetBehaviors(behaviors []*ObjectBehavior) { } ctx.objectBehaviors = behaviorsExcept(ctx.objectBehaviors, dropped) ctx.pendingBehaviors = behaviorsExcept(ctx.pendingBehaviors, dropped) + ctx.workChanged() } // leaveClock releases the behavior's execution, ending the work it left paused @@ -838,11 +857,18 @@ func (ctx *Context) nextRunnableBehavior() (*ObjectBehavior, bool) { return behavior, true } } + // A context a full scan found idle, unchanged since, holds no runnable behavior. + if ctx.quiescentAt != 0 && ctx.quiescentAt == ctx.work { + return nil, false + } for _, behavior := range ctx.objectBehaviors[attached:] { if !ctx.heldBehaviors[behavior] && behavior.hasPendingWork() { return behavior, true } } + if attached == 0 && len(ctx.heldBehaviors) == 0 { + ctx.quiescentAt = ctx.work + } return nil, false } diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index e1835cdb1..5110984ec 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -241,6 +241,10 @@ type Context struct { // clockRun the run an advance of it draws its due-order choices from. clock Clock clockRun executorRun + // work counts the changes that can leave an attached behavior holding work; + // quiescentAt is the work value a full scan last found them all idle at, 0 none. + work uint64 + quiescentAt uint64 // onStack lists the runs of the executors whose calls are under way, outermost first. onStack []*executorRun @@ -746,6 +750,7 @@ func (ctx *Context) beginExecutorRun(run *executorRun) func() { run.stir(1) ctx.onStack = append(ctx.onStack, run) leave := ctx.enterRun(run.state) + ctx.workChanged() // A call into an executor whose performer ended in between finds its performance over. if run.exec != nil && run.exec.performerEnded() { run.exec.endTerminated() @@ -754,6 +759,7 @@ func (ctx *Context) beginExecutorRun(run *executorRun) func() { leave() ctx.onStack = ctx.onStack[:len(ctx.onStack)-1] run.stir(-1) + ctx.workChanged() } } @@ -806,7 +812,8 @@ func (ctx *Context) previewExecutorRun(run *executorRun) func() { } else { ctx.run = ctx.newRunState() } - return func() { ctx.run = saved } + ctx.workChanged() + return func() { ctx.run = saved; ctx.workChanged() } } // endExecutorRun brackets the release of a call-by-call driven run: its leftovers diff --git a/internal/exec/runtime/held_image.go b/internal/exec/runtime/held_image.go index 55e030ab0..86b617ba0 100644 --- a/internal/exec/runtime/held_image.go +++ b/internal/exec/runtime/held_image.go @@ -649,7 +649,7 @@ func (mark materializeMark) rollBack(ctx *Context) { ctx.ids.release(ctx, mark.nextID) } ctx.activations, ctx.runs = mark.activations, mark.runs - ctx.clock.now = mark.clock + ctx.setClock(mark.clock) ctx.clockRun.state = mark.clockRun ctx.lifetimes.dependents = mark.readLives } @@ -721,7 +721,7 @@ func (m *materializing) run() error { dst.livesChanged() dst.activations = max(dst.activations, img.activations) dst.runs = max(dst.runs, img.runs) - dst.clock.now = img.clock + dst.setClock(img.clock) for _, run := range img.runStates { m.runs = append(m.runs, m.runState(run)) } @@ -744,6 +744,7 @@ func (m *materializing) run() error { // Nothing below fails: what names the objects made is installed once they all stand. dst.messages = append(dst.messages, messages...) dst.bus.posts += uint64(len(messages)) + dst.workChanged() for sym, ids := range img.occurrences { dst.occurrences[sym] = slices.Clone(ids) } diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go index 446de82d5..b67ff035e 100644 --- a/internal/exec/runtime/held_image_behavior.go +++ b/internal/exec/runtime/held_image_behavior.go @@ -484,6 +484,7 @@ func (m *materializing) behavior(b imagedBehavior) error { } inst.behaviors = append(inst.behaviors, behavior) dst.objectBehaviors = append(dst.objectBehaviors, behavior) + dst.workChanged() return nil } diff --git a/internal/exec/runtime/quiescence_test.go b/internal/exec/runtime/quiescence_test.go new file mode 100644 index 000000000..8b3c56ef5 --- /dev/null +++ b/internal/exec/runtime/quiescence_test.go @@ -0,0 +1,111 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" +) + +const quiescentLampSource = ` + private import ScalarValues::*; + private import SI::*; + attribute def go; + state def Lamp { + entry; then off; + state off; + transition first off accept go then on; + state on; + } + part def Bulb { exhibit state lamp : Lamp; } +` + +const quiescentTimerSource = ` + private import ScalarValues::*; + private import SI::*; + state def Watch { + entry; then waiting; + state waiting; + transition first waiting accept after 1 [s] then done; + state done; + } + part def Winder { exhibit state watch : Watch; } +` + +func quiescentBulb(t *testing.T, src, doc, part string) (*Context, *Instance, *symbols.Scope) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, doc, parseAndBuild(t, src)) + root := idx.DocumentRoot(doc) + inst, err := ctx.Instantiate(resolveSymbol(t, root, part)) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + return ctx, inst, root +} + +// A signal posted into a context a scan found idle is seen as pending work +// again: the next run advances the machine it wakes. +func TestQuiescenceBrokenByPostedSignal(t *testing.T) { + ctx, bulb, root := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") + if ctx.quiescentAt != ctx.work { + t.Fatalf("context not quiescent after materialization: work=%d quiescentAt=%d", ctx.work, ctx.quiescentAt) + } + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatal("the bulb exhibits no machine") + } + exec := behavior.State + + msg, err := ctx.SignalMessage(resolveSymbol(t, root, "go"), nil, bulb) + if err != nil { + t.Fatalf("SignalMessage(go): %v", err) + } + ctx.PostMessage(msg) + if ctx.quiescentAt == ctx.work { + t.Fatal("the posted signal left the context quiescent") + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if got := activeLeaf(exec); got != "on" { + t.Fatalf("state after go = %s, want on", got) + } +} + +// A clock moved past a wait's due instant is seen as pending work again: the +// advance fires the transition the wait armed. +func TestQuiescenceBrokenByClockAdvance(t *testing.T) { + ctx, winder, _ := quiescentBulb(t, quiescentTimerSource, "quiescent_watch.sysml", "Winder") + if ctx.quiescentAt != ctx.work { + t.Fatalf("context not quiescent after materialization: work=%d quiescentAt=%d", ctx.work, ctx.quiescentAt) + } + behavior, ok := winder.ExhibitedState() + if !ok { + t.Fatal("the winder exhibits no machine") + } + exec := behavior.State + if _, err := ctx.Advance(2); err != nil { + t.Fatalf("Advance(2): %v", err) + } + if got := activeLeaf(exec); got != "done" { + t.Fatalf("state after advancing past the timer = %s, want done", got) + } +} + +// A run begun while the context is quiescent counts as work itself: the run +// can leave a machine's new state accepting what the bus already holds. +func TestQuiescenceBrokenByExecutorRun(t *testing.T) { + ctx, bulb, _ := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") + if ctx.quiescentAt != ctx.work { + t.Fatalf("context not quiescent after materialization: work=%d quiescentAt=%d", ctx.work, ctx.quiescentAt) + } + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatal("the bulb exhibits no machine") + } + if err := behavior.State.RunToQuiescence(); err != nil { + t.Fatalf("RunToQuiescence: %v", err) + } + if ctx.quiescentAt == ctx.work { + t.Fatal("an executor run left the context quiescent") + } +} diff --git a/internal/exec/runtime/signal.go b/internal/exec/runtime/signal.go index ff5cb62e6..e67c59744 100644 --- a/internal/exec/runtime/signal.go +++ b/internal/exec/runtime/signal.go @@ -108,6 +108,7 @@ func (ctx *Context) postFrom(msg Message, from *Instance, behavior *symbols.Symb } ctx.messages = append(ctx.messages, msg) ctx.bus.posts++ + ctx.workChanged() if ctx.trace != nil { target, _ := ctx.Instance(msg.Object) ctx.trace.RecordSend(TraceOrigin{At: ctx.clock.now, Object: from, Behavior: behavior}, msg, target) diff --git a/internal/exec/runtime/start_behavior.go b/internal/exec/runtime/start_behavior.go index 581814ef2..d6db7a6c4 100644 --- a/internal/exec/runtime/start_behavior.go +++ b/internal/exec/runtime/start_behavior.go @@ -80,6 +80,7 @@ func (ctx *Context) startBehaviorOn(inst *Instance, member *symbols.Symbol) erro inst.behaviors = append(inst.behaviors, behavior) ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + ctx.workChanged() err = ctx.runAttachedBehaviors() endBoundary() if err != nil { diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 878f16cb8..f91e57083 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -556,7 +556,7 @@ func (e *StateExecutor) scheduleCompletionTransitions(state *ast.StateNode) erro if trans.Trigger != nil { continue } - e.eventQueue.Push(Event{ + e.enqueue(Event{ ID: e.nextEventID, Type: EventTime, // Use EventTime with nil trigger Timestamp: e.ctx.clock.now, @@ -623,7 +623,7 @@ func (e *StateExecutor) scheduleTimeTransitions(state *ast.StateNode) error { return err } - e.eventQueue.Push(Event{ + e.enqueue(Event{ ID: e.nextEventID, Type: EventTime, Timestamp: due, @@ -664,7 +664,7 @@ func (e *StateExecutor) processNextEvent() error { } e.moved = true // The clock never lags a dispatched event: a timer popped ahead of it moves it. - e.ctx.clock.now = math.Max(e.ctx.clock.now, event.Timestamp) + e.ctx.setClock(math.Max(e.ctx.clock.now, event.Timestamp)) e.lastEventAt = e.ctx.clock.now e.markDispatch() @@ -1068,7 +1068,7 @@ func (e *StateExecutor) recallDeferredEvents() { continue } event.Timestamp = e.ctx.clock.now - e.eventQueue.Push(event) + e.enqueue(event) } e.deferred = retained } @@ -4155,7 +4155,7 @@ func (e *StateExecutor) InvokeOperation(operation string, args map[string]Value) // queueCall queues a call event carrying the payload. func (e *StateExecutor) queueCall(payload Call) { e.moved = true - e.eventQueue.Push(Event{ + e.enqueue(Event{ ID: e.nextEventID, Type: EventCall, Timestamp: e.ctx.clock.now, @@ -4167,10 +4167,16 @@ func (e *StateExecutor) queueCall(payload Call) { // enqueueSignal queues a message as an accept event, to fire immediately. func (e *StateExecutor) enqueueSignal(msg Message) { e.moved = true - e.eventQueue.Push(e.signalEvent(msg)) + e.enqueue(e.signalEvent(msg)) e.nextEventID++ } +// enqueue queues ev as work the machine's next run takes. +func (e *StateExecutor) enqueue(ev Event) { + e.eventQueue.Push(ev) + e.ctx.workChanged() +} + // signalEvent is the event enqueueSignal queues for a message in flight. func (e *StateExecutor) signalEvent(msg Message) Event { return Event{ From fbc29db7c633bec7433027a3a8238c958969f0e8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:55:46 +0000 Subject: [PATCH 2/3] fix(runtime): invalidate quiescence on restores and data-dependent polls Co-Authored-By: jason.han --- internal/exec/runtime/classifier_behavior.go | 41 ++++++- internal/exec/runtime/context.go | 6 +- internal/exec/runtime/quiescence_test.go | 110 +++++++++++++++++-- internal/exec/runtime/snapshot.go | 2 + internal/exec/runtime/state_executor.go | 3 + 5 files changed, 146 insertions(+), 16 deletions(-) diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 1df2b81ba..f02d27647 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -673,6 +673,21 @@ func (ctx *Context) startBehaviorsOf(inst *Instance) error { // a message posted, the clock moved, an event queued, an executor run or left. func (ctx *Context) workChanged() { ctx.work++ } +// quiescence is the memo a full behavior scan leaves when it finds every +// attached behavior idle: the work and write marks it holds under, and whether +// the scan read the objects' data, so writes since then invalidate it. +type quiescence struct { + at uint64 + writes uint64 + readsData bool +} + +// holds reports whether the memo still answers: taken, and nothing it depends +// on moved since, the objects' data counting only where the scan read it. +func (q quiescence) holds(ctx *Context) bool { + return q.at != 0 && q.at == ctx.work && (!q.readsData || q.writes == ctx.writes) +} + // setClock moves the shared clock, the work due on it moving with it. func (ctx *Context) setClock(now float64) { ctx.clock.now = now @@ -687,9 +702,12 @@ func (ctx *Context) holdDrivenWork() func() { return func() { /* an outer start already holds them */ } } held := make(map[*ObjectBehavior]bool) - if ctx.quiescentAt != 0 && ctx.quiescentAt == ctx.work { + if ctx.quiescent.holds(ctx) { // Nothing woke a behavior since a full scan found them all idle. } else { + memo := &pendingMemo{} + saved := ctx.polling + ctx.polling = memo ctx.behaviorRunDepth++ for _, behavior := range ctx.objectBehaviors { if behavior.hasPendingWork() { @@ -697,8 +715,12 @@ func (ctx *Context) holdDrivenWork() func() { } } ctx.behaviorRunDepth-- + ctx.polling = saved + if saved != nil && memo.readsData { + saved.readsData = true + } if len(held) == 0 { - ctx.quiescentAt = ctx.work + ctx.quiescent = quiescence{at: ctx.work, writes: ctx.writes, readsData: memo.readsData} } } ctx.heldBehaviors = held @@ -858,16 +880,27 @@ func (ctx *Context) nextRunnableBehavior() (*ObjectBehavior, bool) { } } // A context a full scan found idle, unchanged since, holds no runnable behavior. - if ctx.quiescentAt != 0 && ctx.quiescentAt == ctx.work { + if ctx.quiescent.holds(ctx) { return nil, false } + memo := &pendingMemo{} + saved := ctx.polling + ctx.polling = memo for _, behavior := range ctx.objectBehaviors[attached:] { if !ctx.heldBehaviors[behavior] && behavior.hasPendingWork() { + ctx.polling = saved + if saved != nil && memo.readsData { + saved.readsData = true + } return behavior, true } } + ctx.polling = saved + if saved != nil && memo.readsData { + saved.readsData = true + } if attached == 0 && len(ctx.heldBehaviors) == 0 { - ctx.quiescentAt = ctx.work + ctx.quiescent = quiescence{at: ctx.work, writes: ctx.writes, readsData: memo.readsData} } return nil, false } diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index 5110984ec..af12af3bf 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -242,9 +242,9 @@ type Context struct { clock Clock clockRun executorRun // work counts the changes that can leave an attached behavior holding work; - // quiescentAt is the work value a full scan last found them all idle at, 0 none. - work uint64 - quiescentAt uint64 + // quiescent is the memo a full scan leaves when it finds them all idle. + work uint64 + quiescent quiescence // onStack lists the runs of the executors whose calls are under way, outermost first. onStack []*executorRun diff --git a/internal/exec/runtime/quiescence_test.go b/internal/exec/runtime/quiescence_test.go index 8b3c56ef5..15b2bf7ad 100644 --- a/internal/exec/runtime/quiescence_test.go +++ b/internal/exec/runtime/quiescence_test.go @@ -16,7 +16,10 @@ const quiescentLampSource = ` transition first off accept go then on; state on; } - part def Bulb { exhibit state lamp : Lamp; } + part def Bulb { + attribute level : Integer = 0; + exhibit state lamp : Lamp; +} ` const quiescentTimerSource = ` @@ -46,8 +49,8 @@ func quiescentBulb(t *testing.T, src, doc, part string) (*Context, *Instance, *s // again: the next run advances the machine it wakes. func TestQuiescenceBrokenByPostedSignal(t *testing.T) { ctx, bulb, root := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") - if ctx.quiescentAt != ctx.work { - t.Fatalf("context not quiescent after materialization: work=%d quiescentAt=%d", ctx.work, ctx.quiescentAt) + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after materialization: work=%d memo=%+v", ctx.work, ctx.quiescent) } behavior, ok := bulb.ExhibitedState() if !ok { @@ -60,7 +63,7 @@ func TestQuiescenceBrokenByPostedSignal(t *testing.T) { t.Fatalf("SignalMessage(go): %v", err) } ctx.PostMessage(msg) - if ctx.quiescentAt == ctx.work { + if ctx.quiescent.holds(ctx) { t.Fatal("the posted signal left the context quiescent") } if err := exec.ProcessNextEvent(); err != nil { @@ -75,8 +78,8 @@ func TestQuiescenceBrokenByPostedSignal(t *testing.T) { // advance fires the transition the wait armed. func TestQuiescenceBrokenByClockAdvance(t *testing.T) { ctx, winder, _ := quiescentBulb(t, quiescentTimerSource, "quiescent_watch.sysml", "Winder") - if ctx.quiescentAt != ctx.work { - t.Fatalf("context not quiescent after materialization: work=%d quiescentAt=%d", ctx.work, ctx.quiescentAt) + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after materialization: work=%d memo=%+v", ctx.work, ctx.quiescent) } behavior, ok := winder.ExhibitedState() if !ok { @@ -95,8 +98,8 @@ func TestQuiescenceBrokenByClockAdvance(t *testing.T) { // can leave a machine's new state accepting what the bus already holds. func TestQuiescenceBrokenByExecutorRun(t *testing.T) { ctx, bulb, _ := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") - if ctx.quiescentAt != ctx.work { - t.Fatalf("context not quiescent after materialization: work=%d quiescentAt=%d", ctx.work, ctx.quiescentAt) + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after materialization: work=%d memo=%+v", ctx.work, ctx.quiescent) } behavior, ok := bulb.ExhibitedState() if !ok { @@ -105,7 +108,96 @@ func TestQuiescenceBrokenByExecutorRun(t *testing.T) { if err := behavior.State.RunToQuiescence(); err != nil { t.Fatalf("RunToQuiescence: %v", err) } - if ctx.quiescentAt == ctx.work { + if ctx.quiescent.holds(ctx) { t.Fatal("an executor run left the context quiescent") } } + +// A restore puts back the bus, clock and behaviors as they stood: work the +// snapshot saw is work again, so a context found idle since scans once more. +func TestQuiescenceBrokenBySnapshotRestore(t *testing.T) { + ctx, bulb, root := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatal("the bulb exhibits no machine") + } + exec := behavior.State + + msg, err := ctx.SignalMessage(resolveSymbol(t, root, "go"), nil, bulb) + if err != nil { + t.Fatalf("SignalMessage(go): %v", err) + } + ctx.PostMessage(msg) + snap, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent: %v", err) + } + if got := activeLeaf(exec); got != "on" { + t.Fatalf("state after go = %s, want on", got) + } + if err := ctx.drainObjectBehaviors(); err != nil { + t.Fatalf("drain: %v", err) + } + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after drain: work=%d memo=%+v", ctx.work, ctx.quiescent) + } + snap.Restore() + if ctx.quiescent.holds(ctx) { + t.Fatal("the restore left the context quiescent") + } + if err := ctx.drainObjectBehaviors(); err != nil { + t.Fatalf("drain after restore: %v", err) + } + if got := activeLeaf(exec); got != "on" { + t.Fatalf("state after restored go = %s, want on", got) + } +} + +// A scan whose poll read the objects' data holds only until the next write; +// one that read nothing holds across writes, as quiescence is meant to. +func TestQuiescenceInvalidatedByWritesOnlyWhenTheScanReadData(t *testing.T) { + ctx, bulb, root := quiescentBulb(t, quiescentLampSource, "quiescent_lamp.sysml", "Bulb") + behavior, ok := bulb.ExhibitedState() + if !ok { + t.Fatal("the bulb exhibits no machine") + } + exec := behavior.State + if !ctx.quiescent.holds(ctx) { + t.Fatalf("context not quiescent after materialization: memo=%+v", ctx.quiescent) + } + if err := bulb.SetFeatureValue(ctx, "level", intArgument(3)); err != nil { + t.Fatalf("SetFeatureValue: %v", err) + } + if !ctx.quiescent.holds(ctx) { + t.Fatal("a write invalidated a scan that read no data") + } + + // A cached poll that read data reports the read to a poll enclosing it, so + // a scan memoizing over it marks its quiescence data-dependent. + exec.pendingSignal() + exec.pending.readsData = true + exec.pending.writes = ctx.writes + probe := &pendingMemo{} + saved := ctx.polling + ctx.polling = probe + exec.pendingSignal() + ctx.polling = saved + if !probe.readsData { + t.Fatal("a cached data-reading poll did not report its read to the enclosing poll") + } + + // Marked as data-reading, the memo no longer covers the write: the work the + // write drives rescans and records a fresh quiescence instead of standing on it. + ctx.quiescent.readsData = true + stale := ctx.quiescent + if err := bulb.SetFeatureValue(ctx, "level", intArgument(4)); err != nil { + t.Fatalf("SetFeatureValue: %v", err) + } + if ctx.quiescent == stale { + t.Fatal("a write left a data-reading scan's quiescence standing") + } + _ = root +} diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index 725ab58f6..0cbec8cbc 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -332,6 +332,7 @@ func (ctx *Context) rollbackJournal(mark journalMark) { ctx.messages = slices.Clone(mark.messages) ctx.bus.cuts++ ctx.writes++ + ctx.workChanged() ctx.abandonCreationSince(mark.created, mark.attached) ctx.clock.now, ctx.clock.waiters = mark.clockNow, slices.Clone(mark.clockWaiters) mark.traced.restore(mark.trace) @@ -370,6 +371,7 @@ func (c runCapture) restore(ctx *Context) { ctx.pendingBehaviors = slices.Clone(c.pendingBehaviors) ctx.heldBehaviors = c.heldBehaviors.restore() ctx.clockRun.state = c.clockRun + ctx.workChanged() } // captureRunState captures a run's state once, however many executors share it. diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index f91e57083..9c4d10f21 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -4437,6 +4437,9 @@ func (e *StateExecutor) pendingSignal() (Message, bool) { memo := &e.pending if memo.holds(e) { if memo.ok || memo.bus.posts == e.ctx.bus.posts { + if memo.readsData { + e.ctx.notePollReadsData() + } return memo.msg, memo.ok } // The bus only grew since a negative answer: the messages added are examined. From 301bc12f62e533a7e438027c979c8e4b82ee409a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:45:03 +0000 Subject: [PATCH 3/3] perf(runtime): allocate run bookkeeping only when a store has behaviors to run Co-Authored-By: jason.han --- internal/exec/runtime/classifier_behavior.go | 18 ++++++++++++-- internal/exec/runtime/context.go | 11 +++++---- internal/exec/runtime/extent.go | 3 +++ internal/exec/runtime/quiescence_test.go | 25 ++++++++++++++++++++ internal/exec/runtime/snapshot.go | 3 +++ 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index f02d27647..b17df48a9 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -698,12 +698,14 @@ func (ctx *Context) setClock(now float64) { // work: a driver put it in flight, so the start leaves it to that driver. Once // the start returns, the behaviors it attached are as their start left them. func (ctx *Context) holdDrivenWork() func() { - if ctx.behaviorRunDepth > 0 || ctx.heldBehaviors != nil { + if ctx.behaviorRunDepth > 0 || ctx.holdingDriven { return func() { /* an outer start already holds them */ } } - held := make(map[*ObjectBehavior]bool) + var held map[*ObjectBehavior]bool if ctx.quiescent.holds(ctx) { // Nothing woke a behavior since a full scan found them all idle. + } else if len(ctx.objectBehaviors) == 0 { + ctx.quiescent = quiescence{at: ctx.work, writes: ctx.writes} } else { memo := &pendingMemo{} saved := ctx.polling @@ -711,6 +713,9 @@ func (ctx *Context) holdDrivenWork() func() { ctx.behaviorRunDepth++ for _, behavior := range ctx.objectBehaviors { if behavior.hasPendingWork() { + if held == nil { + held = map[*ObjectBehavior]bool{} + } held[behavior] = true } } @@ -724,8 +729,10 @@ func (ctx *Context) holdDrivenWork() func() { } } ctx.heldBehaviors = held + ctx.holdingDriven = true attached := len(ctx.objectBehaviors) return func() { + ctx.holdingDriven = false ctx.heldBehaviors = nil for _, behavior := range ctx.objectBehaviors[min(attached, len(ctx.objectBehaviors)):] { behavior.settle() @@ -883,6 +890,13 @@ func (ctx *Context) nextRunnableBehavior() (*ObjectBehavior, bool) { if ctx.quiescent.holds(ctx) { return nil, false } + if attached >= len(ctx.objectBehaviors) { + // Every behavior pending is already held by a driver, or there are none. + if attached == 0 && len(ctx.heldBehaviors) == 0 { + ctx.quiescent = quiescence{at: ctx.work, writes: ctx.writes} + } + return nil, false + } memo := &pendingMemo{} saved := ctx.polling ctx.polling = memo diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index af12af3bf..88690bf51 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -124,6 +124,9 @@ type Context struct { // heldBehaviors are the behaviors already holding work when the outermost // start under way began: a driver put it in flight, and dispatches it. heldBehaviors map[*ObjectBehavior]bool + // holdingDriven marks that hold however it came out, nil map included; a + // nested start leaves the driving to the outermost one. + holdingDriven bool // objectBehaviors are every behavior an object of this context runs, so a // drain to quiescence can re-run one a sibling's send woke. @@ -306,8 +309,7 @@ func NewContext(model *Model, maxSteps int64) *Context { compileCalcs: CalcCompileFromEnv(), run: &runState{ - calcUsageRuns: make(map[int64]map[calcUsageKey]*calcRun), - extentCandidates: make(map[*symbols.Symbol]*extentCandidates), + calcUsageRuns: make(map[int64]map[calcUsageKey]*calcRun), }, calcUsageRunning: make(map[calcUsageKey]*calcShape), @@ -662,9 +664,8 @@ type runState struct { // newRunState is the state a run starts with, under the schedule policy set now. func (ctx *Context) newRunState() *runState { return &runState{ - scheduler: ctx.newScheduler(), - calcUsageRuns: make(map[int64]map[calcUsageKey]*calcRun), - extentCandidates: make(map[*symbols.Symbol]*extentCandidates), + scheduler: ctx.newScheduler(), + calcUsageRuns: make(map[int64]map[calcUsageKey]*calcRun), } } diff --git a/internal/exec/runtime/extent.go b/internal/exec/runtime/extent.go index c6ba02360..c355fe7a9 100644 --- a/internal/exec/runtime/extent.go +++ b/internal/exec/runtime/extent.go @@ -407,6 +407,9 @@ func (ctx *Context) extentCandidates(target *symbols.Symbol) []*symbols.Symbol { } } found.judged = append(found.judged, target) + if ctx.run.extentCandidates == nil { + ctx.run.extentCandidates = map[*symbols.Symbol]*extentCandidates{} + } ctx.run.extentCandidates[target] = found } for _, sym := range found.usages { diff --git a/internal/exec/runtime/quiescence_test.go b/internal/exec/runtime/quiescence_test.go index 15b2bf7ad..9883685f6 100644 --- a/internal/exec/runtime/quiescence_test.go +++ b/internal/exec/runtime/quiescence_test.go @@ -201,3 +201,28 @@ func TestQuiescenceInvalidatedByWritesOnlyWhenTheScanReadData(t *testing.T) { } _ = root } + +// A store on an object with nothing to run allocates no run bookkeeping: the +// behavior scan it brackets finds no behaviors to scan. +func TestIdleStoreAllocatesNoRunBookkeeping(t *testing.T) { + const doc = "idle_store.sysml" + const src = ` + private import ScalarValues::*; + part def Box { attribute level : Integer = 0; } + ` + idx, _, ctx := buildRuntimeWithLibraries(t, doc, parseAndBuild(t, src)) + inst, err := ctx.Instantiate(resolveSymbol(t, idx.DocumentRoot(doc), "Box")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + vals := [2]Value{constInt(1), constInt(2)} + i := 0 + if got := testing.AllocsPerRun(1000, func() { + i++ + if err := inst.SetFeatureValue(ctx, "level", vals[i&1]); err != nil { + t.Fatal(err) + } + }); got > 9 { + t.Fatalf("%.0f allocations per idle store, want <= 9", got) + } +} diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index 0cbec8cbc..65222c276 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -67,6 +67,7 @@ type runCapture struct { evaluations *evaluationLog pendingBehaviors []*ObjectBehavior heldBehaviors mapState[*ObjectBehavior, bool] + holdingDriven bool clockRun *runState } @@ -350,6 +351,7 @@ func (ctx *Context) captureRun() runCapture { evaluations: ctx.evaluations, pendingBehaviors: slices.Clone(ctx.pendingBehaviors), heldBehaviors: captureMap(ctx.heldBehaviors), + holdingDriven: ctx.holdingDriven, clockRun: ctx.clockRun.state, } return c @@ -370,6 +372,7 @@ func (c runCapture) restore(ctx *Context) { ctx.evaluations = c.evaluations ctx.pendingBehaviors = slices.Clone(c.pendingBehaviors) ctx.heldBehaviors = c.heldBehaviors.restore() + ctx.holdingDriven = c.holdingDriven ctx.clockRun.state = c.clockRun ctx.workChanged() }