diff --git a/changes/unreleased/chain-declarations-memo.changed.md b/changes/unreleased/chain-declarations-memo.changed.md new file mode 100644 index 0000000000..43d8ca0b02 --- /dev/null +++ b/changes/unreleased/chain-declarations-memo.changed.md @@ -0,0 +1 @@ +- The runtime resolves the declarations a feature chain's segments name once per model and base feature instead of on every evaluation, and noting them for a chain no longer allocates a restore closure. A calc whose loop assigns four attributes from a calc usage's outputs allocates 800 fewer times per hundred steps (8 556 vs 9 356; 8 541 before feature values were held by feature rather than by name), 971 987 bytes instead of 1 000 812 (922 360 before). diff --git a/internal/exec/runtime/chain_declarations_memo_test.go b/internal/exec/runtime/chain_declarations_memo_test.go new file mode 100644 index 0000000000..c5bd9d9f0d --- /dev/null +++ b/internal/exec/runtime/chain_declarations_memo_test.go @@ -0,0 +1,59 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +const chainDeclarationsModel = `package test { + part def Panel { attribute area : ScalarValues::Real; } + part def Rig { part panel : Panel; } + part rig : Rig; +}` + +// chainNoteAllocs is what noting the declarations of one nested chain n times +// allocates beyond flattening it, as a loop re-evaluating the chain does both. +func chainNoteAllocs(t *testing.T, n int) (float64, int) { + t.Helper() + ctx, idx := libraryShapeContext(t, chainDeclarationsModel) + ec := newEvalContext(ctx, lookupOne(t, idx, "test").Scope) + chain, ok := parseExpr(t, "rig.panel.area").(*ast.FeatureChainExpr) + if !ok { + t.Fatal("rig.panel.area did not parse as a feature chain") + } + if _, nested := chain.Operand.(*ast.FeatureChainExpr); !nested { + t.Fatal("rig.panel.area did not parse as a nested chain") + } + base, parts := ast.FeatureChainBase(chain) + if decls := ec.chainDeclarations(chain, base, parts); len(decls) != 2 || decls[0] != lookupOne(t, idx, "test::Rig::panel") || decls[1] != lookupOne(t, idx, "test::Panel::area") { + t.Fatalf("chainDeclarations(rig, panel.area) = %v, want Rig::panel and Panel::area", decls) + } + flatten := testing.AllocsPerRun(20, func() { + for i := 0; i < n; i++ { + ast.FeatureChainBase(chain) + } + }) + note := testing.AllocsPerRun(20, func() { + for i := 0; i < n; i++ { + base, parts := ast.FeatureChainBase(chain) + ec.restoreChain(ec.noteChainMembers(chain, base, parts)) + } + }) + return note - flatten, len(ctx.model.chainDeclarations) +} + +// The declarations a chain's segments name are resolved once per model and chain +// expression, however often the nested chain is flattened afresh: noting them for +// twice the evaluations allocates no more, and the memo holds one entry. +func TestChainDeclarationsAreResolvedOncePerModel(t *testing.T) { + small, _ := chainNoteAllocs(t, 50) + large, entries := chainNoteAllocs(t, 100) + t.Logf("50 notes allocate %.0f objects beyond flattening, 100 notes %.0f; %d memo entries", small, large, entries) + if large > small { + t.Errorf("100 chain evaluations allocate %.0f objects beyond flattening, 50 allocate %.0f: the segments are re-resolved per evaluation", large, small) + } + if entries != 1 { + t.Errorf("memo holds %d entries for one chain expression, want 1", entries) + } +} diff --git a/internal/exec/runtime/chain_member_slot.go b/internal/exec/runtime/chain_member_slot.go index 8f66649d55..c005c1df79 100644 --- a/internal/exec/runtime/chain_member_slot.go +++ b/internal/exec/runtime/chain_member_slot.go @@ -24,20 +24,43 @@ type chainStatic struct { } // noteChainMembers records the declarations a chain's segments name for the -// reads of its parts, and returns the restore of what was recorded before. -func (ec *EvalContext) noteChainMembers(base ast.Node, parts []ast.NameSegment) func() { +// reads of its parts, and returns what was recorded before for restoreChain. +func (ec *EvalContext) noteChainMembers(chain *ast.FeatureChainExpr, base ast.Node, parts []ast.NameSegment) chainStatic { previous := ec.chain - ec.chain = chainStatic{parts: parts, decls: ec.chainDeclarations(base, parts)} - return func() { ec.chain = previous } + ec.chain = chainStatic{parts: parts, decls: ec.chainDeclarations(chain, base, parts)} + return previous +} + +// restoreChain records previous again once the chain it was noted for is evaluated. +func (ec *EvalContext) restoreChain(previous chainStatic) { + ec.chain = previous +} + +// chainDeclarationsKey identifies the declarations one chain expression's +// segments name from one base feature or type; the chain node is immutable AST. +type chainDeclarationsKey struct { + base *symbols.Symbol + chain *ast.FeatureChainExpr } // chainDeclarations resolves each segment in the namespace of the one before // it, from the feature or cast type the base names; nil when the base names none. -func (ec *EvalContext) chainDeclarations(base ast.Node, parts []ast.NameSegment) []*symbols.Symbol { +// The resolution is memoized per model, so a chain a loop re-evaluates reads it. +func (ec *EvalContext) chainDeclarations(chain *ast.FeatureChainExpr, base ast.Node, parts []ast.NameSegment) []*symbols.Symbol { cur, ok := ec.chainBaseSymbol(base) - if !ok { + if !ok || len(parts) == 0 { return nil } + key := chainDeclarationsKey{base: cur, chain: chain} + if decls, ok := ec.ctx.model.chainDeclarations[key]; ok { + return decls + } + decls := ec.resolveChainDeclarations(cur, parts) + ec.ctx.model.chainDeclarations[key] = decls + return decls +} + +func (ec *EvalContext) resolveChainDeclarations(cur *symbols.Symbol, parts []ast.NameSegment) []*symbols.Symbol { decls := make([]*symbols.Symbol, len(parts)) for i := range parts { decl, found := ec.ctx.declaredMember(cur, parts[i].Text) diff --git a/internal/exec/runtime/eval.go b/internal/exec/runtime/eval.go index 036f40ff5e..1ccf9a68c9 100644 --- a/internal/exec/runtime/eval.go +++ b/internal/exec/runtime/eval.go @@ -1574,7 +1574,7 @@ func (ec *EvalContext) evalFeatureChain(n *ast.FeatureChainExpr) (Value, error) return Value{}, fmt.Errorf("empty member chain") } base, parts := ast.FeatureChainBase(n) - defer ec.noteChainMembers(base, parts)() + defer ec.restoreChain(ec.noteChainMembers(n, base, parts)) // A chain's members resolve in the namespace of the feature before them, where // a redefined feature is not inherited under its name (KerML ยง7.3.4.5). diff --git a/internal/exec/runtime/model.go b/internal/exec/runtime/model.go index beceae7220..8025790026 100644 --- a/internal/exec/runtime/model.go +++ b/internal/exec/runtime/model.go @@ -75,6 +75,8 @@ type Model struct { // redefined memoizes, per feature of a type, the features it redefines // transitively; callers read the shared slice and never append to it. redefined map[featureOfType][]*symbols.Symbol + // chainDeclarations memoizes the declarations a chain expression's segments name per base. + chainDeclarations map[chainDeclarationsKey][]*symbols.Symbol // writeTargets memoizes the declaration an assignment's target names, per // scope the statement was written in: what a value written must conform to. @@ -248,6 +250,7 @@ func NewModel(sem *semantics.Model, resolver *resolve.Resolver) *Model { holders: make(map[*symbols.Symbol]map[string][]string), returnedParams: make(map[*calcShape]*returnedAnalysis), redefined: make(map[featureOfType][]*symbols.Symbol), + chainDeclarations: make(map[chainDeclarationsKey][]*symbols.Symbol), writeTargets: make(map[writeTargetKey]*writeTarget), calcShapes: make(map[*symbols.Symbol]*calcShape), predicateShapes: make(map[*symbols.Symbol]*calcShape),