-
Notifications
You must be signed in to change notification settings - Fork 455
perf: cache builtin alias map and DFS cycle-check to fix CompileMCPWorkflow regression #44996
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ import ( | |
| "os" | ||
| "strconv" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| "github.com/github/gh-aw/pkg/console" | ||
| "github.com/github/gh-aw/pkg/setutil" | ||
|
|
@@ -34,6 +35,13 @@ import ( | |
|
|
||
| var modelAliasValidationLog = newValidationLogger("model_alias") | ||
|
|
||
| // builtinCycleCheckOnce caches the result of detectCircularModelAliases on the | ||
| // pure-builtin alias map. Builtins are immutable, so the result never changes. | ||
| var ( | ||
| builtinCycleCheckOnce sync.Once | ||
| builtinCycleCheckErr error | ||
| ) | ||
|
|
||
| // ─── Known-parameter validation ─────────────────────────────────────────────── | ||
|
|
||
| // ValidateEffortParam validates the "effort" parameter value (V-MAF-002). | ||
|
|
@@ -241,6 +249,43 @@ func (c *Compiler) warnUnrecognizedModelParams(identifiers []string, markdownPat | |
| func detectCircularModelAliases(aliasMap map[string][]string, markdownPath string) error { | ||
| modelAliasValidationLog.Printf("Checking for circular alias references in %d aliases", len(aliasMap)) | ||
|
|
||
| // Fast path: when aliasMap has exactly the same key set as the raw builtins it | ||
| // IS the builtin map (MergeImportedModelAliases only ever adds new keys — it never | ||
| // removes or overrides them — so equal key sets imply identical content). Builtins | ||
| // are immutable, so we run the DFS once and cache the result for all future calls. | ||
| rawBuiltins, err := loadBuiltinModelAliases() | ||
| if err != nil { | ||
| modelAliasValidationLog.Printf("Warning: could not load builtin model aliases for cycle-check fast path, falling back to full validation: %v", err) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 DetailsFor every compilation that has custom models (i.e., non-nil
For the builtin-only fast path this is a net win. For the If this function is on the hot path for custom-model compilations too, the scan should be gated behind the pointer-identity check suggested in the other comment (which is O(1) and requires no iteration): if aliasMap == builtinOnlyAliasMap() {
// run/return cached DFS
}
// fall through to full DFS — no loadBuiltinModelAliases call at allThis eliminates both the |
||
| } else if isBuiltinOnlyAliasMap(aliasMap, rawBuiltins) { | ||
| builtinCycleCheckOnce.Do(func() { | ||
| // Pass an empty markdownPath: the cached result is shared across all | ||
| // compilations, so any path embedded in an error message would be | ||
| // misleading for callers other than the first one. | ||
| builtinCycleCheckErr = detectCircularModelAliasesImpl(rawBuiltins, "") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] No regression test exists for the 💡 Suggested testfunc TestDetectCircularModelAliases_BuiltinFastPath(t *testing.T) {
builtins := BuiltinModelAliases()
// First call — populates the cache
err1 := detectCircularModelAliases(builtins, "/path/a.md")
require.NoError(t, err1)
// Second call — must use cache (same result, no panic)
err2 := detectCircularModelAliases(builtins, "/path/b.md")
require.NoError(t, err2)
// Non-builtin map — must NOT use cache
custom := map[string][]string{"custom": {"gpt-4o"}}
for k, v := range builtins { custom[k] = v }
err3 := detectCircularModelAliases(custom, "/path/c.md")
require.NoError(t, err3)
}This guards the fast-path branch and ensures the key-set equality check behaves correctly for both the hit and miss cases. @copilot please address this. |
||
| }) | ||
| return builtinCycleCheckErr | ||
| } | ||
|
|
||
| return detectCircularModelAliasesImpl(aliasMap, markdownPath) | ||
| } | ||
|
|
||
| // isBuiltinOnlyAliasMap reports whether aliasMap has exactly the same set of keys as | ||
| // rawBuiltins. Because MergeImportedModelAliases never modifies existing builtin keys | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fragile key-equality assumption can return a wrong cached cycle-check result: the comment claims equal key sets imply identical content, but this is only true as long as callers go through 💡 Details and suggested fixThe logic is:
The invariant relies on
Safer approach: Instead of a key-set equality heuristic, use pointer identity. Cache a pointer to the builtin map from if aliasMap == builtinOnlyAliasMap() { // pointer equality — exact same map
...
}This is O(1), guaranteed-correct, and eliminates the indirect reasoning about key sets entirely. The fast path in |
||
| // (it only appends new ones), equal key sets mean equal content. | ||
| func isBuiltinOnlyAliasMap(aliasMap, rawBuiltins map[string][]string) bool { | ||
| if len(aliasMap) != len(rawBuiltins) { | ||
| return false | ||
| } | ||
| for k := range aliasMap { | ||
| if _, exists := rawBuiltins[k]; !exists { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| } | ||
|
Comment on lines
+272
to
+285
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The key-set equality check in |
||
|
|
||
| // detectCircularModelAliasesImpl is the actual DFS cycle-check implementation. | ||
| func detectCircularModelAliasesImpl(aliasMap map[string][]string, markdownPath string) error { | ||
| // visited tracks keys for which all DFS descendants have been fully explored | ||
| // (no cycle detected from that key). | ||
| visited := map[string]struct { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,6 +50,11 @@ var ( | |
| builtinModelAliasesOnce sync.Once | ||
| builtinModelAliasesData map[string][]string | ||
| builtinModelAliasesErr error | ||
|
|
||
| // mergedBuiltinOnlyOnce caches the result of MergeImportedModelAliases(nil, nil). | ||
| // Callers MUST treat the returned map as read-only (must not modify it). | ||
| mergedBuiltinOnlyOnce sync.Once | ||
| mergedBuiltinOnlyMap map[string][]string | ||
| ) | ||
|
|
||
| func loadBuiltinModelAliases() (map[string][]string, error) { | ||
|
|
@@ -64,6 +69,16 @@ func loadBuiltinModelAliases() (map[string][]string, error) { | |
| return builtinModelAliasesData, builtinModelAliasesErr | ||
| } | ||
|
|
||
| // builtinOnlyAliasMap returns a shared, read-only copy of the builtin model alias map. | ||
| // This is the same as calling BuiltinModelAliases() but the copy is allocated only once. | ||
| // The returned map MUST NOT be modified by callers. | ||
| func builtinOnlyAliasMap() map[string][]string { | ||
| mergedBuiltinOnlyOnce.Do(func() { | ||
| mergedBuiltinOnlyMap = BuiltinModelAliases() | ||
| }) | ||
| return mergedBuiltinOnlyMap | ||
| } | ||
|
|
||
| // BuiltinModelAliases returns the built-in model alias map that covers the main | ||
| // model families supported by gh-aw. The returned map is a freshly allocated | ||
| // copy so callers may freely modify it. | ||
|
|
@@ -120,9 +135,20 @@ func BuiltinModelAliases() map[string][]string { | |
| // 3. Main workflow frontmatter aliases (highest priority — main workflow file wins) | ||
| // | ||
| // If both importedModels and frontmatterModels are nil/empty, the builtin aliases are | ||
| // returned as-is (identical to MergeModelAliases(nil)). | ||
| // returned as a shared read-only map (callers MUST NOT modify it). This avoids an | ||
| // allocation-heavy deep copy on every compilation when no custom models are defined. | ||
| func MergeImportedModelAliases(importedModels []map[string][]string, frontmatterModels map[string][]string) map[string][]string { | ||
| modelAliasesLog.Printf("Merging model aliases: %d import(s), %d frontmatter override(s)", len(importedModels), len(frontmatterModels)) | ||
|
|
||
| // Fast path: no customisation needed — return a shared read-only builtin map. | ||
| // Callers that only read the result (WorkflowData.ModelMappings consumers) benefit | ||
| // from avoiding the O(n) deep copy on every compilation. | ||
| if len(importedModels) == 0 && len(frontmatterModels) == 0 { | ||
| result := builtinOnlyAliasMap() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] 💡 Suggested mitigationAdd a test that verifies mutating the returned map does not affect subsequent calls: func TestMergeImportedModelAliases_CachedMapIsolation(t *testing.T) {
m1 := MergeImportedModelAliases(nil, nil)
originalLen := len(m1)
// Simulate an accidental write by a caller
for k := range m1 { delete(m1, k); break }
m2 := MergeImportedModelAliases(nil, nil)
require.Len(t, m2, originalLen, "mutating returned map must not corrupt the shared cache")
}This test will currently fail — making the risk concrete. The fix could be returning a read-only wrapper, or defensive copying in @copilot please address this. |
||
| modelAliasesLog.Printf("Fast path: returning shared builtin alias map (%d entries)", len(result)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fast path returns a shared map, but the doc-comment change only adds a sentence at the top — the function signature still returns |
||
| return result | ||
|
Comment on lines
+146
to
+149
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shared map with no mutation enforcement: 💡 Details and suggested fixThe fast path at lines 143–151 returns Current callers look safe:
But the contract is enforced only by a comment, and result := MergeImportedModelAliases(nil, nil)
result["my-model"] = []string{"acme/model"} // silently corrupts the singletonwill corrupt every subsequent compilation without any test catching it. Fix options (in order of preference):
Option 1 is one line and eliminates the whole class of bug without a fragile API contract. |
||
| } | ||
|
|
||
| merged := BuiltinModelAliases() | ||
|
|
||
| // Layer 2 — imported models (first import to define a key wins among imports). | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/diagnosing-bugs]
loadBuiltinModelAliases()is still called on every invocation ofdetectCircularModelAliases, even when the fast path fires. The DFS is cached, but the key-set comparison and theloadBuiltinModelAliasescall itself add overhead on each compilation.💡 Suggested optimisation
Use pointer identity instead of key-set equality: since
builtinOnlyAliasMap()always returns the same pointer, you can compare map headers:Or more idiomatically, pass a
bool isBuiltinOnlyflag from the call site (validateModelAliasMap) to avoid loading builtins altogether in the fast path.@copilot please address this.