diff --git a/changes/unreleased/runtime-subsetting-memo.changed.md b/changes/unreleased/runtime-subsetting-memo.changed.md new file mode 100644 index 0000000000..414636dad8 --- /dev/null +++ b/changes/unreleased/runtime-subsetting-memo.changed.md @@ -0,0 +1 @@ +- The runtime answers the subsetting walks a feature read repeats once per model — the relationships a feature declares of one kind, the features of a type they name, and whether a feature subsets another through any chain — and reading the subsetters an object holds no longer copies the list when the object holds every one. Verifying a constraint over gRPC allocates 3 547 fewer times per call (16 074 vs 19 621; 14 091 before the implicit-specialization runtime objects were added) and 822 706 bytes instead of 1 564 432 (849 347 before, same machine). diff --git a/internal/exec/runtime/kernel_collections.go b/internal/exec/runtime/kernel_collections.go index 56548fea5b..8ef214b199 100644 --- a/internal/exec/runtime/kernel_collections.go +++ b/internal/exec/runtime/kernel_collections.go @@ -34,7 +34,7 @@ func (ctx *Context) kernelCollection(typ *symbols.Symbol, name string) (*symbols return nil, false } for _, kind := range []ast.RelationshipKind{ast.RelInverseOf, ast.RelUnions} { - if len(relationshipsOfKind(sym, kind)) > 0 { + if len(ctx.relationshipsOfKind(sym, kind)) > 0 { return nil, false } } @@ -173,20 +173,38 @@ func (ctx *Context) shapeSubsettersOf(inst *Instance, sym *symbols.Symbol) []*Ef // subsetsTransitively reports whether feature, a member of owner, subsets target through // any chain of declared `:>` edges, implicit nesting subsettings and redefinitions. func (ctx *Context) subsetsTransitively(feature, owner, target *symbols.Symbol) bool { + key := subsettingKey{feature, owner, target} + if subsets, ok := ctx.model.subsetsTransitively[key]; ok { + return subsets + } + subsets := ctx.walkSubsetsTransitively(feature, owner, target) + ctx.model.subsetsTransitively[key] = subsets + return subsets +} + +// subsettingKey identifies one question subsetsTransitively answers. +type subsettingKey struct { + feature, owner, target *symbols.Symbol +} + +func (ctx *Context) walkSubsetsTransitively(feature, owner, target *symbols.Symbol) bool { seen := map[*symbols.Symbol]bool{feature: true} for queue := []*symbols.Symbol{feature}; len(queue) > 0; { cur := queue[0] queue = queue[1:] - next := ctx.relatedFeatures(cur, owner, ast.RelSubsets) - next = append(next, ctx.redefinedFeatures(cur, owner)...) - next = append(next, ctx.model.semantics.ImplicitSubsettings(cur)...) - for _, sub := range next { - if sub == target { - return true - } - if sub != nil && !seen[sub] { - seen[sub] = true - queue = append(queue, sub) + for _, next := range [][]*symbols.Symbol{ + ctx.relatedFeatures(cur, owner, ast.RelSubsets), + ctx.redefinedFeatures(cur, owner), + ctx.model.semantics.ImplicitSubsettings(cur), + } { + for _, sub := range next { + if sub == target { + return true + } + if sub != nil && !seen[sub] { + seen[sub] = true + queue = append(queue, sub) + } } } } @@ -197,12 +215,12 @@ func (ctx *Context) subsetsTransitively(feature, owner, target *symbols.Symbol) // an intersection its subsetting alone implies (`subobjects`), so it states no further value. func (ctx *Context) intersectsOnlySubsetted(sym *symbols.Symbol) bool { subsetted := map[*symbols.Symbol]bool{} - for _, rel := range relationshipsOfKind(sym, ast.RelSubsets) { + for _, rel := range ctx.relationshipsOfKind(sym, ast.RelSubsets) { if target := ctx.model.semantics.RelationshipTarget(sym, rel); target != nil { subsetted[target] = true } } - for _, rel := range relationshipsOfKind(sym, ast.RelIntersects) { + for _, rel := range ctx.relationshipsOfKind(sym, ast.RelIntersects) { if target := ctx.model.semantics.RelationshipTarget(sym, rel); target == nil || !subsetted[target] { return false } diff --git a/internal/exec/runtime/kernel_members.go b/internal/exec/runtime/kernel_members.go index 56a38f4534..6172fa9590 100644 --- a/internal/exec/runtime/kernel_members.go +++ b/internal/exec/runtime/kernel_members.go @@ -248,7 +248,7 @@ func (ctx *Context) kernelInverseValue(inst *Instance, sym *symbols.Symbol, feat composite := false owner, held := inst.Owner() self, hasSelf := ctx.model.semantics.LookupMember(inst.Type, "self") - for _, rel := range relationshipsOfKind(sym, ast.RelInverseOf) { + for _, rel := range ctx.relationshipsOfKind(sym, ast.RelInverseOf) { target := ctx.model.semantics.RelationshipTarget(sym, rel) if target == nil { continue diff --git a/internal/exec/runtime/model.go b/internal/exec/runtime/model.go index beceae7220..093124289c 100644 --- a/internal/exec/runtime/model.go +++ b/internal/exec/runtime/model.go @@ -150,6 +150,12 @@ type Model struct { // declaredSubsettedNames; callers read the shared slices. subsetted map[featureOfType][]string declaredSubsetted map[featureOfType][]string + // relationships memoizes relationshipsOfKind per feature and kind, related relatedFeatures + // per feature, owner and kind; callers read the shared slices, which have no spare capacity. + relationships map[relationshipsKey][]*ast.Relationship + related map[relatedKey][]*symbols.Symbol + // subsetsTransitively memoizes the walk of the same name per feature, owner and target. + subsetsTransitively map[subsettingKey]bool // toolExecutions memoizes toolExecutionOf per action; toolUnits the units tool // answers spell, per scope they are read in. @@ -267,6 +273,9 @@ func NewModel(sem *semantics.Model, resolver *resolve.Resolver) *Model { subsetters: make(map[*symbols.Symbol]map[string][]EffectiveFeature), subsetted: make(map[featureOfType][]string), declaredSubsetted: make(map[featureOfType][]string), + relationships: make(map[relationshipsKey][]*ast.Relationship), + related: make(map[relatedKey][]*symbols.Symbol), + subsetsTransitively: make(map[subsettingKey]bool), toolExecutions: make(map[*symbols.Symbol]*toolExecution), toolUnits: make(map[toolUnitKey]semantics.Unit), objectConns: make(map[*symbols.Symbol][]lower.Connection), diff --git a/internal/exec/runtime/namespace_binding.go b/internal/exec/runtime/namespace_binding.go index a7973d4088..3b69fc5939 100644 --- a/internal/exec/runtime/namespace_binding.go +++ b/internal/exec/runtime/namespace_binding.go @@ -90,7 +90,7 @@ func (ctx *Context) namespaceModelIndex() *namespaceModelIndex { if _, ok := member.Decl.(*ast.Usage); !ok { return true } - for _, rel := range relationshipsOfKind(member, ast.RelSubsets) { + for _, rel := range ctx.relationshipsOfKind(member, ast.RelSubsets) { if target := ctx.model.semantics.RelationshipTarget(member, rel); target != nil { out.subsetters[target] = append(out.subsetters[target], member) } diff --git a/internal/exec/runtime/relationships_memo_test.go b/internal/exec/runtime/relationships_memo_test.go new file mode 100644 index 0000000000..6e7ed50d2b --- /dev/null +++ b/internal/exec/runtime/relationships_memo_test.go @@ -0,0 +1,94 @@ +package runtime + +import ( + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +const relationshipsMemoModel = `package test { + part def Rig { + part panels[*]; + part solar[*] :> panels; + part front :> solar, panels; + } + part rig : Rig; +}` + +// relationshipQueryAllocs is what n repeated queries for one feature's `:>` +// relationships allocate after the first answered. +func relationshipQueryAllocs(t *testing.T, n int) float64 { + t.Helper() + ctx, idx := libraryShapeContext(t, relationshipsMemoModel) + front := lookupOne(t, idx, "test::Rig::front") + if rels := ctx.relationshipsOfKind(front, ast.RelSubsets); len(rels) != 2 { + t.Fatalf("relationshipsOfKind(front, subsets) = %d relationships, want 2", len(rels)) + } + return testing.AllocsPerRun(20, func() { + for i := 0; i < n; i++ { + ctx.relationshipsOfKind(front, ast.RelSubsets) + } + }) +} + +// A feature's relationships of a kind are read from its declaration once per +// model: twice the queries allocate no more than half of them. +func TestRelationshipsOfKindAreCollectedOncePerFeature(t *testing.T) { + small, large := relationshipQueryAllocs(t, 50), relationshipQueryAllocs(t, 100) + t.Logf("50 queries allocate %.0f objects, 100 queries %.0f", small, large) + if large > small { + t.Errorf("100 repeated relationship queries allocate %.0f objects, 50 allocate %.0f: the declaration is rescanned per query", large, small) + } +} + +// subsettingWalkAllocs is what n repeated subsetting walks over one type allocate +// after the first answered: the features front subsets, whether it reaches panels, +// and the features of rig subsetting panels. +func subsettingWalkAllocs(t *testing.T, n int) (related, transitive, subsetting float64) { + t.Helper() + ctx, idx := libraryShapeContext(t, relationshipsMemoModel) + rig := instantiateQualified(t, ctx, idx, "test::rig") + typ, front, panels := lookupOne(t, idx, "test::Rig"), lookupOne(t, idx, "test::Rig::front"), lookupOne(t, idx, "test::Rig::panels") + if got := ctx.relatedFeatures(front, typ, ast.RelSubsets); len(got) != 2 { + t.Fatalf("relatedFeatures(front, Rig, subsets) = %v, want solar and panels", got) + } + if !ctx.subsetsTransitively(front, typ, panels) { + t.Fatal("subsetsTransitively(front, Rig, panels) = false, want true") + } + if got := ctx.SubsettingFeatures(rig, typ, "panels"); len(got) != 2 { + t.Fatalf("SubsettingFeatures(rig, Rig, panels) = %v, want solar and front", featureNames(got)) + } + related = testing.AllocsPerRun(20, func() { + for i := 0; i < n; i++ { + ctx.relatedFeatures(front, typ, ast.RelSubsets) + } + }) + transitive = testing.AllocsPerRun(20, func() { + for i := 0; i < n; i++ { + ctx.subsetsTransitively(front, typ, panels) + } + }) + subsetting = testing.AllocsPerRun(20, func() { + for i := 0; i < n; i++ { + ctx.SubsettingFeatures(rig, typ, "panels") + } + }) + return related, transitive, subsetting +} + +// The subsetting walks a read repeats are answered once per model: twice the +// walks allocate no more than half of them. +func TestSubsettingWalksAreAnsweredOncePerModel(t *testing.T) { + r1, t1, s1 := subsettingWalkAllocs(t, 50) + r2, t2, s2 := subsettingWalkAllocs(t, 100) + t.Logf("50 walks allocate related %.0f, transitive %.0f, subsetting %.0f; 100 walks %.0f, %.0f, %.0f", r1, t1, s1, r2, t2, s2) + if r2 > r1 { + t.Errorf("100 relatedFeatures walks allocate %.0f objects, 50 allocate %.0f: the relationships are re-resolved per walk", r2, r1) + } + if t2 > t1 { + t.Errorf("100 subsetsTransitively walks allocate %.0f objects, 50 allocate %.0f: the chain is re-walked per question", t2, t1) + } + if s2 > s1 { + t.Errorf("100 SubsettingFeatures reads allocate %.0f objects, 50 allocate %.0f: the held subsetters are re-filtered per read", s2, s1) + } +} diff --git a/internal/exec/runtime/shape.go b/internal/exec/runtime/shape.go index bc9331644d..9361674eb5 100644 --- a/internal/exec/runtime/shape.go +++ b/internal/exec/runtime/shape.go @@ -562,7 +562,7 @@ func (ctx *Context) roleRedefiningDefault(sym, typeSym *symbols.Symbol) (ast.Nod // inherits any redefined feature's (KerML 1.0 §7.3.4.5), though no object carries them. func (ctx *Context) redefinedNamespaceUsages(sym *symbols.Symbol) []*symbols.Symbol { var usages []*symbols.Symbol - for _, rel := range relationshipsOfKind(sym, ast.RelRedefines) { + for _, rel := range ctx.relationshipsOfKind(sym, ast.RelRedefines) { resolved := ctx.model.semantics.RelationshipTarget(sym, rel) if resolved == nil || resolved == sym { continue diff --git a/internal/exec/runtime/subsetting.go b/internal/exec/runtime/subsetting.go index de915dff14..939030da25 100644 --- a/internal/exec/runtime/subsetting.go +++ b/internal/exec/runtime/subsetting.go @@ -28,8 +28,24 @@ import ( // A declaration that itself restates sym (`:>> elements` on an object, walked past // its library chain) masks nothing on that walk: the target stays the resolved one. func (ctx *Context) relatedFeatures(sym, owner *symbols.Symbol, kind ast.RelationshipKind) []*symbols.Symbol { + key := relatedKey{sym, owner, kind} + if features, ok := ctx.model.related[key]; ok { + return features + } + features := slices.Clip(ctx.collectRelatedFeatures(sym, owner, kind)) + ctx.model.related[key] = features + return features +} + +// relatedKey identifies the features of one owner a feature's relationships of a kind name. +type relatedKey struct { + sym, owner *symbols.Symbol + kind ast.RelationshipKind +} + +func (ctx *Context) collectRelatedFeatures(sym, owner *symbols.Symbol, kind ast.RelationshipKind) []*symbols.Symbol { var features []*symbols.Symbol - for _, rel := range relationshipsOfKind(sym, kind) { + for _, rel := range ctx.relationshipsOfKind(sym, kind) { qn := ast.AsQualifiedName(rel.Target) resolved := ctx.model.semantics.RelationshipTarget(sym, rel) if resolved != nil && resolved != sym { @@ -100,8 +116,26 @@ func (ctx *Context) ownDeclarationNamed(owner, sym *symbols.Symbol, names ...str return nil, false } -// relationshipsOfKind returns sym's relationships of the given kind that name a target. -func relationshipsOfKind(sym *symbols.Symbol, kind ast.RelationshipKind) []*ast.Relationship { +// relationshipsKey identifies the relationships of one kind a symbol declares. +type relationshipsKey struct { + sym *symbols.Symbol + kind ast.RelationshipKind +} + +// relationshipsOfKind returns sym's relationships of the given kind that name a target, +// memoized per symbol and kind; callers read the shared slice and never append to it. +func (ctx *Context) relationshipsOfKind(sym *symbols.Symbol, kind ast.RelationshipKind) []*ast.Relationship { + key := relationshipsKey{sym, kind} + if rels, ok := ctx.model.relationships[key]; ok { + return rels + } + rels := declaredRelationshipsOfKind(sym, kind) + ctx.model.relationships[key] = rels + return rels +} + +// declaredRelationshipsOfKind collects sym's relationships of the given kind that name a target. +func declaredRelationshipsOfKind(sym *symbols.Symbol, kind ast.RelationshipKind) []*ast.Relationship { var rels []*ast.Relationship for _, rel := range semantics.RelationshipsOf(sym) { if rel == nil || rel.Kind != kind || rel.Target == nil { @@ -604,13 +638,20 @@ func (ctx *Context) SubsettingFeatures(inst *Instance, typ *symbols.Symbol, name if inst == nil { return ofType } - var subsetting []EffectiveFeature - for _, feat := range ofType { + for i, feat := range ofType { if _, ok := inst.FeatureValues[feat.Name]; ok { - subsetting = append(subsetting, feat) + continue } + // The first feature inst does not hold: the rest is filtered into a slice of its own. + subsetting := append(make([]EffectiveFeature, 0, len(ofType)-1), ofType[:i]...) + for _, feat := range ofType[i+1:] { + if _, ok := inst.FeatureValues[feat.Name]; ok { + subsetting = append(subsetting, feat) + } + } + return subsetting } - return subsetting + return ofType } // subsettersOf is SubsettingFeatures for the type alone, memoized per type and name; @@ -637,6 +678,7 @@ func (ctx *Context) subsettersOf(typ *symbols.Symbol, name string) []EffectiveFe } } } + subsetting = slices.Clip(subsetting) byName[name] = subsetting return subsetting }