From aef09190f6488dc75a10bce4d72efe9f9bc82fac Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:45:55 +0000 Subject: [PATCH 1/4] perf(repl): let a submission's parse trees go before its analysis Co-Authored-By: jason.han --- .../release-preparse-trees.fixed.md | 5 ++ internal/frontend/repl/resolve_walk_test.go | 12 ++--- internal/frontend/repl/session.go | 2 +- internal/frontend/repl/workspace.go | 54 ++++++++++++------- 4 files changed, 44 insertions(+), 29 deletions(-) create mode 100644 changes/unreleased/release-preparse-trees.fixed.md diff --git a/changes/unreleased/release-preparse-trees.fixed.md b/changes/unreleased/release-preparse-trees.fixed.md new file mode 100644 index 0000000000..94e925b50d --- /dev/null +++ b/changes/unreleased/release-preparse-trees.fixed.md @@ -0,0 +1,5 @@ +A submission's parse trees are let go once its files are accepted, instead of being held through +the analysis for the root namespaces an unresolved reference would be looked up by (#1441); a +reference left unresolved parses the text again. Validating a generated 12 086-element model +peaks at 164 MiB of resident memory instead of 184, beside 0.11.0's 164 on the same toolchain +(152 against 147 under `GOGC=25`), with the load's time and allocations unchanged. diff --git a/internal/frontend/repl/resolve_walk_test.go b/internal/frontend/repl/resolve_walk_test.go index c55251603d..f6243a3ab6 100644 --- a/internal/frontend/repl/resolve_walk_test.go +++ b/internal/frontend/repl/resolve_walk_test.go @@ -4,18 +4,14 @@ import ( "fmt" "strings" "testing" - - "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" - "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) -func resolvedParses(n int) []parsed { +func resolvedSources(n int) []SourceFile { var sb strings.Builder for i := 0; i < n; i++ { fmt.Fprintf(&sb, "package P%d { attribute a%d = %d; }\n", i, i, i) } - root := parser.New(source.New("typed", []byte(sb.String()))).ParseFile() - return []parsed{{root: root}} + return []SourceFile{{Name: "typed", Text: sb.String()}} } // A submission that leaves nothing unresolved looks for no root namespace, so @@ -24,9 +20,9 @@ func TestResolvingNothingDoesNotWalkTheText(t *testing.T) { s, dir := workspaceSession(t) search := &rootSearch{find: s.workspaceIndex().Declaring, where: "under " + dir, onDemand: true} cost := func(n int) float64 { - parses := resolvedParses(n) + sources := resolvedSources(n) return testing.AllocsPerRun(20, func() { - s.resolveReferences(Result{}, parses, search) + s.resolveReferences(Result{}, sources, search) }) } if small, large := cost(50), cost(100); large > small { diff --git a/internal/frontend/repl/session.go b/internal/frontend/repl/session.go index a6c24ca129..620e5edc25 100644 --- a/internal/frontend/repl/session.go +++ b/internal/frontend/repl/session.go @@ -1139,7 +1139,7 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, // A root namespace the submission leaves unresolved may be declared by a // file beside a loaded one or under the workspace; it joins the submission. search := s.searchFor(load, files) - if ws, more := s.pullReferences(s.resolveReferences(res, parses, search), parses, search); ws.loaded || len(ws.notices) > 0 { + if ws, more := s.pullReferences(s.resolveReferences(res, files, search), files, search); ws.loaded || len(ws.notices) > 0 { if ws.loaded { whole = append(whole, more...) // A loaded file may have replaced an earlier snippet, moving the diff --git a/internal/frontend/repl/workspace.go b/internal/frontend/repl/workspace.go index 0428b77fd2..a3490df86d 100644 --- a/internal/frontend/repl/workspace.go +++ b/internal/frontend/repl/workspace.go @@ -70,12 +70,12 @@ func (s *Session) doWorkspace() []string { } // workspaceLoad is what resolving references did: the files it accepted into -// the current generation, if any, their parses, what they declared, what +// the current generation, if any, their sources, what they declared, what // accepting them dropped, and the lines to report about it. type workspaceLoad struct { loaded bool files []string - parses []parsed + sources []SourceFile declared []string drops []dropReport notices []string @@ -115,12 +115,12 @@ func (s *Session) searchFor(load bool, files []SourceFile) *rootSearch { return nil } -// resolveReferences loads, for a submission whose text parsed to parses and -// analyzed to res, the files declaring the root namespaces its imports and -// qualified references leave unresolved, found where search looks. Imports -// come first: a qualified name an import would answer is not looked for until -// the imports have been (import Lib::*; then Inner::Thing with Lib::Inner). -func (s *Session) resolveReferences(res Result, parses []parsed, search *rootSearch) workspaceLoad { +// resolveReferences loads, for a submission of sources that analyzed to res, +// the files declaring the root namespaces its imports and qualified references +// leave unresolved, found where search looks. Imports come first: a qualified +// name an import would answer is not looked for until the imports have been +// (import Lib::*; then Inner::Thing with Lib::Inner). +func (s *Session) resolveReferences(res Result, sources []SourceFile, search *rootSearch) workspaceLoad { if search == nil { return workspaceLoad{} } @@ -138,16 +138,17 @@ func (s *Session) resolveReferences(res Result, parses []parsed, search *rootSea return workspaceLoad{} } unresolved := func(name string) bool { return referenceBegins(subjects, name) } + roots := s.parseRoots(sources) var imported []string - for _, pre := range parses { - imported = append(imported, project.ImportedRoots(pre.root)...) + for _, root := range roots { + imported = append(imported, project.ImportedRoots(root)...) } ws := s.loadDeclaring(s.undeclaredRoots(imported, unresolved), search) if !ws.loaded { // An import no file answers must not keep the qualified names waiting. var referenced []string - for _, pre := range parses { - referenced = append(referenced, project.ReferencedRoots(pre.root)...) + for _, root := range roots { + referenced = append(referenced, project.ReferencedRoots(root)...) } more := s.loadDeclaring(s.undeclaredRoots(referenced, unresolved), search) more.notices = append(ws.notices, more.notices...) @@ -156,12 +157,24 @@ func (s *Session) resolveReferences(res Result, parses []parsed, search *rootSea return ws } -// pullReferences carries on from what loading ws did for the text parsed to -// parses: the session is rebuilt over the files loaded, and the root +// parseRoots parses the sources again for the root namespaces they import and +// reference. A submission is parsed once to be accepted and the trees are let +// go before it is analyzed; a reference left unresolved, the rare case, pays +// for a second parse rather than every load holding its trees through the analysis. +func (s *Session) parseRoots(sources []SourceFile) []*ast.RootNamespace { + roots := make([]*ast.RootNamespace, len(sources)) + model.ParallelFor(s.jobs, len(sources), func(i int) { + roots[i] = preparseWithKind(sources[i].Name, sources[i].Text, sources[i].Kind).root + }) + return roots +} + +// pullReferences carries on from what loading ws did for the submission of +// sources: the session is rebuilt over the files loaded, and the root // namespaces the text and those files leave unresolved in turn are loaded the // same way, round after round, until a round loads nothing. It reports what // the rounds did together, and what rebuilding did to the session. -func (s *Session) pullReferences(ws workspaceLoad, parses []parsed, search *rootSearch) (all workspaceLoad, whole []string) { +func (s *Session) pullReferences(ws workspaceLoad, sources []SourceFile, search *rootSearch) (all workspaceLoad, whole []string) { // A root two files declare is noticed by every round that wants it; once is enough. noticed := map[string]bool{} notice := func(notices []string) { @@ -183,8 +196,8 @@ func (s *Session) pullReferences(ws workspaceLoad, parses []parsed, search *root whole = append(whole, more...) all.notices = append(append(all.notices, dropNotices(ws.drops)...), more...) notice(ws.notices) - parses = append(parses, ws.parses...) - ws = s.resolveReferences(s.generationResult(), parses, search) + sources = append(sources, ws.sources...) + ws = s.resolveReferences(s.generationResult(), sources, search) } notice(ws.notices) return all, whole @@ -318,13 +331,14 @@ func (s *Session) loadDeclaring(names []string, search *rootSearch) workspaceLoa out.notices = append(out.notices, errPrefix+err.Error()) return out } - out.parses = make([]parsed, len(read.files)) + parses := make([]parsed, len(read.files)) model.ParallelFor(s.jobs, len(read.files), func(i int) { - out.parses[i] = preparseWithKind(read.files[i].Name, read.files[i].Text, read.files[i].Kind) + parses[i] = preparseWithKind(read.files[i].Name, read.files[i].Text, read.files[i].Kind) }) + out.sources = read.files seen := map[string]bool{} for i, f := range read.files { - names, dropped := s.acceptParsed(f.Name, f.key(), fileKeyOf(f.Of), f.Text, out.parses[i], f.Kind) + names, dropped := s.acceptParsed(f.Name, f.key(), fileKeyOf(f.Of), f.Text, parses[i], f.Kind) s.snippets[len(s.snippets)-1].onDemand = search.onDemand if f.Of != "" { out.files = append(out.files, f.Of) From b3e71f7ce350ebdce74b7f1251ef66a9c50ac9df Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:18:33 +0000 Subject: [PATCH 2/4] perf(repl): parse a submission's roots once across the rounds that pull its references Co-Authored-By: jason.han --- internal/frontend/repl/resolve_walk_test.go | 2 +- internal/frontend/repl/session.go | 3 +- internal/frontend/repl/workspace.go | 50 +++++++++++++++------ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/internal/frontend/repl/resolve_walk_test.go b/internal/frontend/repl/resolve_walk_test.go index f6243a3ab6..5437a63715 100644 --- a/internal/frontend/repl/resolve_walk_test.go +++ b/internal/frontend/repl/resolve_walk_test.go @@ -22,7 +22,7 @@ func TestResolvingNothingDoesNotWalkTheText(t *testing.T) { cost := func(n int) float64 { sources := resolvedSources(n) return testing.AllocsPerRun(20, func() { - s.resolveReferences(Result{}, sources, search) + s.resolveReferences(Result{}, &submissionRoots{sources: sources}, search) }) } if small, large := cost(50), cost(100); large > small { diff --git a/internal/frontend/repl/session.go b/internal/frontend/repl/session.go index 620e5edc25..0134b6f7a1 100644 --- a/internal/frontend/repl/session.go +++ b/internal/frontend/repl/session.go @@ -1139,7 +1139,8 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, // A root namespace the submission leaves unresolved may be declared by a // file beside a loaded one or under the workspace; it joins the submission. search := s.searchFor(load, files) - if ws, more := s.pullReferences(s.resolveReferences(res, files, search), files, search); ws.loaded || len(ws.notices) > 0 { + roots := &submissionRoots{sources: files} + if ws, more := s.pullReferences(s.resolveReferences(res, roots, search), roots, search); ws.loaded || len(ws.notices) > 0 { if ws.loaded { whole = append(whole, more...) // A loaded file may have replaced an earlier snippet, moving the diff --git a/internal/frontend/repl/workspace.go b/internal/frontend/repl/workspace.go index a3490df86d..60f19297e0 100644 --- a/internal/frontend/repl/workspace.go +++ b/internal/frontend/repl/workspace.go @@ -120,7 +120,7 @@ func (s *Session) searchFor(load bool, files []SourceFile) *rootSearch { // leave unresolved, found where search looks. Imports come first: a qualified // name an import would answer is not looked for until the imports have been // (import Lib::*; then Inner::Thing with Lib::Inner). -func (s *Session) resolveReferences(res Result, sources []SourceFile, search *rootSearch) workspaceLoad { +func (s *Session) resolveReferences(res Result, roots *submissionRoots, search *rootSearch) workspaceLoad { if search == nil { return workspaceLoad{} } @@ -138,16 +138,15 @@ func (s *Session) resolveReferences(res Result, sources []SourceFile, search *ro return workspaceLoad{} } unresolved := func(name string) bool { return referenceBegins(subjects, name) } - roots := s.parseRoots(sources) var imported []string - for _, root := range roots { + for _, root := range roots.of(s) { imported = append(imported, project.ImportedRoots(root)...) } ws := s.loadDeclaring(s.undeclaredRoots(imported, unresolved), search) if !ws.loaded { // An import no file answers must not keep the qualified names waiting. var referenced []string - for _, root := range roots { + for _, root := range roots.of(s) { referenced = append(referenced, project.ReferencedRoots(root)...) } more := s.loadDeclaring(s.undeclaredRoots(referenced, unresolved), search) @@ -157,10 +156,32 @@ func (s *Session) resolveReferences(res Result, sources []SourceFile, search *ro return ws } -// parseRoots parses the sources again for the root namespaces they import and -// reference. A submission is parsed once to be accepted and the trees are let -// go before it is analyzed; a reference left unresolved, the rare case, pays -// for a second parse rather than every load holding its trees through the analysis. +// submissionRoots is what a submission's sources parse to, parsed again on first +// need: the trees a load was accepted from are let go before it is analyzed. +type submissionRoots struct { + sources []SourceFile + roots []*ast.RootNamespace + parsed bool +} + +// of is the root namespaces, parsing the sources the first time they are asked for. +func (r *submissionRoots) of(s *Session) []*ast.RootNamespace { + if !r.parsed { + r.roots, r.parsed = s.parseRoots(r.sources), true + } + return r.roots +} + +// add takes in the files a round loaded, parsing just those once the rest are parsed. +func (r *submissionRoots) add(s *Session, sources []SourceFile) { + if r.parsed { + r.roots = append(r.roots, s.parseRoots(sources)...) + return + } + r.sources = append(r.sources, sources...) +} + +// parseRoots parses the sources for the root namespaces they import and reference. func (s *Session) parseRoots(sources []SourceFile) []*ast.RootNamespace { roots := make([]*ast.RootNamespace, len(sources)) model.ParallelFor(s.jobs, len(sources), func(i int) { @@ -169,12 +190,15 @@ func (s *Session) parseRoots(sources []SourceFile) []*ast.RootNamespace { return roots } -// pullReferences carries on from what loading ws did for the submission of -// sources: the session is rebuilt over the files loaded, and the root +// pullReferences carries on from what loading ws did for the submission whose +// roots these are: the session is rebuilt over the files loaded, and the root // namespaces the text and those files leave unresolved in turn are loaded the // same way, round after round, until a round loads nothing. It reports what // the rounds did together, and what rebuilding did to the session. -func (s *Session) pullReferences(ws workspaceLoad, sources []SourceFile, search *rootSearch) (all workspaceLoad, whole []string) { +func (s *Session) pullReferences(ws workspaceLoad, roots *submissionRoots, search *rootSearch) (all workspaceLoad, whole []string) { + if roots == nil { + roots = &submissionRoots{} + } // A root two files declare is noticed by every round that wants it; once is enough. noticed := map[string]bool{} notice := func(notices []string) { @@ -196,8 +220,8 @@ func (s *Session) pullReferences(ws workspaceLoad, sources []SourceFile, search whole = append(whole, more...) all.notices = append(append(all.notices, dropNotices(ws.drops)...), more...) notice(ws.notices) - sources = append(sources, ws.sources...) - ws = s.resolveReferences(s.generationResult(), sources, search) + roots.add(s, ws.sources) + ws = s.resolveReferences(s.generationResult(), roots, search) } notice(ws.notices) return all, whole From 1badde16f88eaf965308b5c5e322e62825dcd887 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:21:37 +0000 Subject: [PATCH 3/4] perf(repl): keep a submission's root names, not its trees, across the rounds that pull references Co-Authored-By: jason.han --- internal/frontend/repl/resolve_walk_test.go | 2 +- internal/frontend/repl/session.go | 2 +- internal/frontend/repl/workspace.go | 45 +++++++++------------ 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/internal/frontend/repl/resolve_walk_test.go b/internal/frontend/repl/resolve_walk_test.go index 5437a63715..2ca95759a5 100644 --- a/internal/frontend/repl/resolve_walk_test.go +++ b/internal/frontend/repl/resolve_walk_test.go @@ -22,7 +22,7 @@ func TestResolvingNothingDoesNotWalkTheText(t *testing.T) { cost := func(n int) float64 { sources := resolvedSources(n) return testing.AllocsPerRun(20, func() { - s.resolveReferences(Result{}, &submissionRoots{sources: sources}, search) + s.resolveReferences(Result{}, &submissionRoots{pending: sources}, search) }) } if small, large := cost(50), cost(100); large > small { diff --git a/internal/frontend/repl/session.go b/internal/frontend/repl/session.go index 0134b6f7a1..52a9dc7c6b 100644 --- a/internal/frontend/repl/session.go +++ b/internal/frontend/repl/session.go @@ -1139,7 +1139,7 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, // A root namespace the submission leaves unresolved may be declared by a // file beside a loaded one or under the workspace; it joins the submission. search := s.searchFor(load, files) - roots := &submissionRoots{sources: files} + roots := &submissionRoots{pending: files} if ws, more := s.pullReferences(s.resolveReferences(res, roots, search), roots, search); ws.loaded || len(ws.notices) > 0 { if ws.loaded { whole = append(whole, more...) diff --git a/internal/frontend/repl/workspace.go b/internal/frontend/repl/workspace.go index 60f19297e0..8cd2873f8f 100644 --- a/internal/frontend/repl/workspace.go +++ b/internal/frontend/repl/workspace.go @@ -138,17 +138,10 @@ func (s *Session) resolveReferences(res Result, roots *submissionRoots, search * return workspaceLoad{} } unresolved := func(name string) bool { return referenceBegins(subjects, name) } - var imported []string - for _, root := range roots.of(s) { - imported = append(imported, project.ImportedRoots(root)...) - } + imported, referenced := roots.names(s) ws := s.loadDeclaring(s.undeclaredRoots(imported, unresolved), search) if !ws.loaded { // An import no file answers must not keep the qualified names waiting. - var referenced []string - for _, root := range roots.of(s) { - referenced = append(referenced, project.ReferencedRoots(root)...) - } more := s.loadDeclaring(s.undeclaredRoots(referenced, unresolved), search) more.notices = append(ws.notices, more.notices...) ws = more @@ -156,30 +149,28 @@ func (s *Session) resolveReferences(res Result, roots *submissionRoots, search * return ws } -// submissionRoots is what a submission's sources parse to, parsed again on first -// need: the trees a load was accepted from are let go before it is analyzed. +// submissionRoots is the root namespaces a submission's sources import and +// reference, by name: a source is parsed once, on first need, and its tree let go. type submissionRoots struct { - sources []SourceFile - roots []*ast.RootNamespace - parsed bool + pending []SourceFile + imported []string + referenced []string } -// of is the root namespaces, parsing the sources the first time they are asked for. -func (r *submissionRoots) of(s *Session) []*ast.RootNamespace { - if !r.parsed { - r.roots, r.parsed = s.parseRoots(r.sources), true +// names is the imported and the referenced root names, parsing what is pending. +func (r *submissionRoots) names(s *Session) (imported, referenced []string) { + if len(r.pending) > 0 { + for _, root := range s.parseRoots(r.pending) { + r.imported = append(r.imported, project.ImportedRoots(root)...) + r.referenced = append(r.referenced, project.ReferencedRoots(root)...) + } + r.pending = nil } - return r.roots + return r.imported, r.referenced } -// add takes in the files a round loaded, parsing just those once the rest are parsed. -func (r *submissionRoots) add(s *Session, sources []SourceFile) { - if r.parsed { - r.roots = append(r.roots, s.parseRoots(sources)...) - return - } - r.sources = append(r.sources, sources...) -} +// add takes in the files a round loaded, to parse if a later round asks. +func (r *submissionRoots) add(sources []SourceFile) { r.pending = append(r.pending, sources...) } // parseRoots parses the sources for the root namespaces they import and reference. func (s *Session) parseRoots(sources []SourceFile) []*ast.RootNamespace { @@ -220,7 +211,7 @@ func (s *Session) pullReferences(ws workspaceLoad, roots *submissionRoots, searc whole = append(whole, more...) all.notices = append(append(all.notices, dropNotices(ws.drops)...), more...) notice(ws.notices) - roots.add(s, ws.sources) + roots.add(ws.sources) ws = s.resolveReferences(s.generationResult(), roots, search) } notice(ws.notices) From 1b2fb906098b8c1ec47859703d44a1ead61be001 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:38:00 +0000 Subject: [PATCH 4/4] docs(changes): write the fragment as the list item the changelog check requires Co-Authored-By: jason.han --- changes/unreleased/release-preparse-trees.fixed.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/changes/unreleased/release-preparse-trees.fixed.md b/changes/unreleased/release-preparse-trees.fixed.md index 94e925b50d..412d2c5aa2 100644 --- a/changes/unreleased/release-preparse-trees.fixed.md +++ b/changes/unreleased/release-preparse-trees.fixed.md @@ -1,5 +1 @@ -A submission's parse trees are let go once its files are accepted, instead of being held through -the analysis for the root namespaces an unresolved reference would be looked up by (#1441); a -reference left unresolved parses the text again. Validating a generated 12 086-element model -peaks at 164 MiB of resident memory instead of 184, beside 0.11.0's 164 on the same toolchain -(152 against 147 under `GOGC=25`), with the load's time and allocations unchanged. +- A submission's parse trees are let go once its files are accepted, instead of being held through the analysis for the root namespaces an unresolved reference would be looked up by (#1441); a reference left unresolved parses the text again. Validating a generated 12 086-element model peaks at 164 MiB of resident memory instead of 184, beside 0.11.0's 164 on the same toolchain (152 against 147 under `GOGC=25`), with the load's time and allocations unchanged.