perf: magpie opens and shows the Agents page in a fraction of the time it took (Junjie Zhou on Discord: 8 个 agent 30 个 provider … 每次我打开的时候都会很慢,要好几秒。setting 页面秒进后 我切换到 agent 就有问题) — at start, writing the agents' model lists (agent.SyncCatalog) built every provider's catalog thousands of times with nothing held, each build reading migrations.json, settings and groups again: 6000+ file reads and ~8.5s of CPU in the first 8s with the owner's 30-odd providers, all competing with the first /api/state and the Agents page. Now SyncCatalog, magpieModels and main's start-up moves run under provider.Hold, migrations, groups, settings and a provider's fetched models are held for a second like the rest, and filememo.Hold lets a GET /api/* not stat a file it confirmed unchanged within the last second (any magpie write, Changed() or a recent mtime still reads it again). A big ~/.codex/config.toml parsed in quadratic time (the TOML parser's Shape counts newlines from the start for every key): a 3 MB one took 5.4s, now 0.04s (tomlShaper). theInstalled reads through edit.Read's memo. Measured with a copy of the owner's magpie config and agent configs under a sandbox HOME, the real server in WebKit: CPU in the first 8s 8.59s → 0.39s, page up 475 → 75ms, Agents rows drawn 775 → 325ms after start; the agents' written model files are unchanged (pi byte for byte, opencode the same models). TestTOMLShaperMatchesParser, TestTOMLBigFileParsesInLinearTime (fails on the old code), TestHold (filememo), TestHoldReadsMigrationsOnce. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 03:02:40 +08:00
1 parent 399e5db63d
commit 2a4042a94d
18 files changed
+266 -16

No files matched your search

+2 -1
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
@@ -182,7 +183,7 @@ func (a *Agent) theInstalled() bool {
if !elsewhere() || a.Path == "" {
return false
}
b, err := os.ReadFile(a.Path)
b, err := edit.Read(a.Path)
if err != nil {
return false
}
+3
View File
@@ -94,6 +94,9 @@ func firstOf(xs []string) string {
// magpieModels is the catalog as agent is shown it, as catalog.Models, for
// agents that keep their own model files.
func magpieModels(agent string) []catalog.Model {
// the providers built once for all the models' prices, not for each:
// dsh's round at every start built them for each of hundreds
defer provider.Hold()()
var out []catalog.Model
shown, _ := provider.CatalogFor(agent)
labels := provider.Labels(shown)
+5
View File
@@ -9,6 +9,7 @@ import (
"sync"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/provider"
"gopkg.in/yaml.v3"
)
@@ -40,11 +41,15 @@ func SyncCatalog() {
syncing.running = true
syncing.Unlock()
for {
// the catalog built once for every agent's lists, not for each
// look-up of each (thousands at magpie's start, with 30 providers)
release := provider.Hold()
for _, a := range All() {
if a.Sync != nil {
_ = a.Sync()
}
}
release()
syncing.Lock()
if !syncing.again {
syncing.running = false
+10 -2
View File
@@ -13,16 +13,23 @@ import (
"strings"
"time"
"github.com/yetone/magpie/internal/filememo"
"github.com/yetone/magpie/internal/steady"
)
// Read returns the file contents, or (nil, nil) when the file does not exist.
// A file unchanged since it was last read is not read again (the Agents
// page looks at each agent's files many times over), and what is returned
// is the caller's own to change.
func Read(path string) ([]byte, error) {
b, err := os.ReadFile(path)
b, err := filememo.Read("bytes", path, func(b []byte) ([]byte, error) { return b, nil })
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return b, err
if err != nil {
return nil, err
}
return bytes.Clone(b), nil
}
// WriteAtomic writes data to path via a temp file + rename so a crash can
@@ -32,6 +39,7 @@ func Read(path string) ([]byte, error) {
// written in place, see writeInPlace. Once written, temp files earlier
// writes of path left behind go, see removeStaleTemps.
func WriteAtomic(path string, data []byte) error {
defer filememo.Forget() // read again, where a request holds it
path, err := Target(path)
if err != nil {
return err
+23 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
@@ -413,6 +414,7 @@ type tomlFile struct {
func parseTOMLFile(lines []string) (tomlFile, error) {
p := unstable.Parser{KeepComments: true}
p.Reset([]byte(strings.Join(lines, "\n")))
shapeOf := tomlShaper(lines)
root := tomlTableSpan{to: len(lines)}
var tables []tomlTableSpan
current := func() *tomlTableSpan {
@@ -470,7 +472,7 @@ func parseTOMLFile(lines []string) (tomlFile, error) {
keyRange.Length = key.Raw.Offset + key.Raw.Length - keyRange.Offset
}
}
shape := p.Shape(keyRange)
shape := shapeOf(keyRange)
i := shape.Start.Line - 1
if err := finishKey(i); err != nil {
return tomlFile{}, err
@@ -496,7 +498,7 @@ func parseTOMLFile(lines []string) (tomlFile, error) {
value: string(value.Data), scalar: value.Kind != unstable.Array && value.Kind != unstable.InlineTable,
}
if comment := expr.Next(); comment != nil && comment.Kind == unstable.Comment {
kv.comment = p.Shape(comment.Raw).Start.Column - 1
kv.comment = shapeOf(comment.Raw).Start.Column - 1
}
table.keys = append(table.keys, kv)
}
@@ -510,6 +512,25 @@ func parseTOMLFile(lines []string) (tomlFile, error) {
return tomlFile{root: root, tables: tables}, nil
}
// tomlShaper is the parser's Shape for the document of lines, found from
// where each line starts rather than by counting the newlines before each
// range, as Shape does: that is a pass over the file for each key, which
// took a third of a second on a 1.5 MB config.toml of Codex's.
func tomlShaper(lines []string) func(unstable.Range) unstable.Shape {
starts := make([]int, len(lines))
for i, off := 1, 0; i < len(lines); i++ {
off += len(lines[i-1]) + 1
starts[i] = off
}
at := func(off int) unstable.Position {
i := sort.Search(len(starts), func(i int) bool { return starts[i] > off }) - 1
return unstable.Position{Offset: off, Line: i + 1, Column: off - starts[i] + 1}
}
return func(r unstable.Range) unstable.Shape {
return unstable.Shape{Start: at(int(r.Offset)), End: at(int(r.Offset + r.Length))}
}
}
func tomlParseError(p *unstable.Parser, err error) error {
pe, ok := err.(*unstable.ParserError)
if !ok {
+71
View File
@@ -0,0 +1,71 @@
package edit
import (
"fmt"
"strings"
"testing"
"time"
"github.com/pelletier/go-toml/v2/unstable"
)
// tomlShaper finds every key and comment where the parser's own Shape does.
func TestTOMLShaperMatchesParser(t *testing.T) {
doc := "# top\na = 1 # one\n\n[t]\n b.c = \"x#y\" # two\n\"q\" = [\n 1,\n]\n[[arr]]\nz = { k = 1 }\n"
lines := splitLines(doc)
shapeOf := tomlShaper(lines)
p := unstable.Parser{KeepComments: true}
p.Reset([]byte(strings.Join(lines, "\n")))
n := 0
check := func(it *unstable.Node) {
if it.Raw.Length == 0 {
return
}
if got, want := shapeOf(it.Raw), p.Shape(it.Raw); got != want {
t.Errorf("%v %q: %+v, the parser says %+v", it.Kind, p.Raw(it.Raw), got, want)
}
n++
}
for p.NextExpression() {
e := p.Expression()
check(e)
if e.Kind != unstable.Comment {
for k := e.Key(); k.Next(); {
check(k.Node())
}
}
if c := e.Next(); c != nil {
check(c)
}
}
if err := p.Error(); err != nil || n < 8 {
t.Fatalf("parsed %d nodes: %v", n, err)
}
}
// A big config.toml is parsed in a time that grows with its size, not its
// square: Codex's own reached 1.5 MB, and each key's line was counted from
// the top of the file, a third of a second at every magpie start.
func TestTOMLBigFileParsesInLinearTime(t *testing.T) {
var b strings.Builder
for i := 0; b.Len() < 3<<20; i++ {
fmt.Fprintf(&b, "[projects.\"/Users/me/p%d\"]\ntrust_level = \"trusted\" # note\n\n", i)
}
b.WriteString("model_provider = \"magpie\"\n")
lines := splitLines("model_provider = \"x\"\n" + b.String())
start := time.Now()
f, err := parseTOMLFile(lines)
if err != nil {
t.Fatal(err)
}
if d := time.Since(start); d > time.Second {
t.Errorf("a 3 MB file took %v to parse", d)
}
if len(f.tables) < 30000 || f.root.keys[0].name != "model_provider" {
t.Fatalf("parsed %d tables, root %+v", len(f.tables), f.root.keys)
}
last := f.tables[len(f.tables)-1]
if k := last.keys[len(last.keys)-1]; k.name != "model_provider" || k.from != len(lines)-2 && k.from != len(lines)-1 {
t.Errorf("last key %+v of %d lines", k, len(lines))
}
}
+53 -4
View File
@@ -20,6 +20,10 @@ type entry struct {
// milliseconds) coarse, so two writes of one size inside a tick (two
// accounts swapped) leave the same time and size
raw []byte
// when the file was last found unchanged, while a request held them
// (Hold), and in which of Forget's generations
checked time.Time
gen uint64
}
// settled is how old a file's time must be for any later write to stamp
@@ -29,23 +33,66 @@ const settled = 2 * time.Second
var (
mu sync.Mutex
seen = map[string]entry{}
// holds is how many requests hold what was read (Hold); gen is moved
// on by every write magpie makes (Forget)
holds int
gen uint64
)
// heldFor is how long a file found unchanged is taken as unchanged, while
// a request holds what was read, before it is looked at again.
const heldFor = time.Second
// Hold has a file found unchanged not looked at again, for a request that
// only reads, until release is called or a second has gone by: the GUI's
// state looked at each agent's files hundreds of times over, a few
// thousand looks at the disk that a slow one (a Mac's antivirus looking
// at each) made seconds. A write meanwhile (Forget) has them looked at.
func Hold() (release func()) {
mu.Lock()
holds++
mu.Unlock()
return sync.OnceFunc(func() {
mu.Lock()
holds--
mu.Unlock()
})
}
// Forget has every file looked at again on its next read: magpie wrote
// one.
func Forget() {
mu.Lock()
gen++
mu.Unlock()
}
// Read is parse of the file at path, from what it gave last time when the
// file has the same size and time as then (and, written just now, the same
// bytes). kind tells apart two parses of
// one file. What it returns is shared: the caller must not change it.
func Read[T any](kind, path string, parse func([]byte) (T, error)) (T, error) {
var zero T
key := kind + "\x00" + path
mu.Lock()
e, ok := seen[key]
if ok && holds > 0 && e.raw == nil && e.gen == gen && time.Since(e.checked) < heldFor {
mu.Unlock()
return e.v.(T), nil
}
g := gen
mu.Unlock()
fi, err := os.Stat(path)
if err != nil {
return zero, err
}
key := kind + "\x00" + path
mu.Lock()
e, ok := seen[key]
mu.Unlock()
if ok && e.mod.Equal(fi.ModTime()) && e.size == fi.Size() && e.raw == nil {
mu.Lock()
if holds > 0 && g == gen {
e.checked, e.gen = time.Now(), g
seen[key] = e
}
mu.Unlock()
return e.v.(T), nil
}
b, err := steady.ReadFile(path)
@@ -69,6 +116,8 @@ func Read[T any](kind, path string, parse func([]byte) (T, error)) (T, error) {
e = entry{mod: fi.ModTime(), size: fi.Size(), v: v}
if time.Since(e.mod) <= settled {
e.raw = b
} else if holds > 0 && g == gen {
e.checked, e.gen = time.Now(), g
}
seen[key] = e
mu.Unlock()
+46
View File
@@ -50,3 +50,49 @@ func TestReadSameSizeSameTick(t *testing.T) {
}
}
}
// While a request holds what was read, a file found unchanged is not looked
// at again (the GUI's state looked a few thousand times, seconds on a slow
// disk); a write magpie makes (Forget), a second gone by or the release has
// it looked at, and a file just written is always read again.
func TestHold(t *testing.T) {
p := filepath.Join(t.TempDir(), "f")
os.WriteFile(p, []byte("a"), 0o600)
old := time.Now().Add(-time.Minute)
os.Chtimes(p, old, old)
parse := func(b []byte) (string, error) { return string(b), nil }
write := func(s string) {
os.WriteFile(p, []byte(s), 0o600)
at := old.Add(time.Duration(len(s)) * time.Second)
os.Chtimes(p, at, at)
}
release := Hold()
if v, _ := Read("hold", p, parse); v != "a" {
t.Fatal(v)
}
write("bb")
if v, _ := Read("hold", p, parse); v != "a" {
t.Fatalf("looked at again while held: %q", v)
}
Forget()
if v, _ := Read("hold", p, parse); v != "bb" {
t.Fatalf("after a write magpie made: %q", v)
}
write("ccc")
release()
release()
if v, _ := Read("hold", p, parse); v != "ccc" {
t.Fatalf("released: %q", v)
}
if holds != 0 {
t.Fatalf("holds left: %d", holds)
}
// a file written just now is read each time, held or not
defer Hold()()
os.WriteFile(p, []byte("d"), 0o600)
Read("hold", p, parse)
os.WriteFile(p, []byte("e"), 0o600)
if v, _ := Read("hold", p, parse); v != "e" {
t.Fatalf("a file written just now: %q", v)
}
}
+1 -2
View File
@@ -15,7 +15,6 @@ import (
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// A model that can't see images is given what a model that can says of
@@ -96,7 +95,7 @@ func blindTo(pid, model string, in *bool) bool {
// seer is the model that describes images: the Settings' Vision while it
// resolves, else AutoVision's. None when Vision is off or no model sees.
func seer() (string, bool) {
switch v := settings.Load().Vision; v {
switch v := provider.HeldSettings().Vision; v {
case "off":
return "", false
case "":
+3
View File
@@ -29,6 +29,7 @@ import (
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/autostart"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/filememo"
"github.com/yetone/magpie/internal/fx"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/library"
@@ -1274,6 +1275,8 @@ func held(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/") {
defer provider.Hold()()
// and the files read, not looked at again for each look-up
defer filememo.Hold()()
} else {
defer provider.Changed()
}
+2 -2
View File
@@ -225,7 +225,7 @@ func Groups() []Group {
}
func groupsIn(entries []Entry) []Group {
f := load()
f := heldOf("file", load)
var out []Group
hidden := map[string]bool{}
for _, g := range f.Groups {
@@ -238,7 +238,7 @@ func groupsIn(entries []Entry) []Group {
if f.NoAutoGroups {
return orderedGroups(out, f.GroupOrder)
}
for _, g := range autoGroups(entries, settings.Load().ModelSameAs) {
for _, g := range autoGroups(entries, heldSettings().ModelSameAs) {
if slices.ContainsFunc(out, func(o Group) bool { return o.ID == g.ID }) {
continue // the user changed it: theirs now
}
+6
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/filememo"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/settings"
)
@@ -56,6 +57,7 @@ func Hold() (release func()) {
// Changed drops what is held: a provider, an account, a plugin or a
// setting was written.
func Changed() {
filememo.Forget()
held.Lock()
held.gen++
held.built = nil
@@ -102,3 +104,7 @@ func heldPlugins() []plugin.Provider {
// catalog: a look at the agents asks them for every model of every agent.
// Only for reading — what it gives is shared by the request's look-ups.
func heldSettings() settings.Settings { return heldOf("settings", settings.Load) }
// HeldSettings is the settings as heldSettings reads them, for a look
// outside the package made many times in one request.
func HeldSettings() settings.Settings { return heldSettings() }
+22
View File
@@ -172,3 +172,25 @@ func TestCursorKeychainReadOnce(t *testing.T) {
t.Fatalf("after a fresh read: %q, %d runs", tok, runs())
}
}
// migrations.json is read once while a request holds the catalog (the
// GUI's state looked at it thousands of times, one per provider's API),
// and a move written meanwhile, even one read from the file as it was
// before, is seen at once.
func TestHoldReadsMigrationsOnce(t *testing.T) {
defer Hold()()
if Moved("held-sub") {
t.Fatal("moved before any move")
}
os.MkdirAll(filepath.Dir(migrationsPath()), 0o755)
os.WriteFile(migrationsPath(), []byte(`{"other-sub":{"state":"plugin"}}`), 0o600)
if Moved("other-sub") {
t.Fatal("migrations.json read again while held")
}
if err := setMigration("held-sub", func(m *Migration) { m.State = MovePlugin }); err != nil {
t.Fatal(err)
}
if !Moved("held-sub") || !Moved("other-sub") {
t.Fatalf("a move written while held: %v %v", Moved("held-sub"), Moved("other-sub"))
}
}
+1
View File
@@ -156,6 +156,7 @@ func writeLogins(ls []savedLogin) error {
// writePrivate replaces a file readable by the user alone, atomically, so
// an agent reading it at that moment sees either version, never half.
func writePrivate(path string, b []byte) error {
defer filememo.Forget() // read again, where a request holds it
path, err := edit.Target(path) // a symlink stays, its target written
if err != nil {
return err
+6 -2
View File
@@ -291,7 +291,11 @@ func migrationsPath() string { return filepath.Join(filepath.Dir(Path()), "migra
var migrationsMu sync.Mutex
func readMigrations() map[string]Migration {
// readMigrations is held while a request reads: the GUI's state asks each
// provider's move several times over, thousands of looks at the file.
func readMigrations() map[string]Migration { return heldOf("migrations", readMigrationsFile) }
func readMigrationsFile() map[string]Migration {
m, _ := filememo.Read("migrations", migrationsPath(), func(b []byte) (map[string]Migration, error) {
var m map[string]Migration
_ = json.Unmarshal(b, &m)
@@ -344,7 +348,7 @@ func setMigration(id string, f func(m *Migration)) error {
migrationsMu.Lock()
defer migrationsMu.Unlock()
all := map[string]Migration{}
for k, v := range readMigrations() {
for k, v := range readMigrationsFile() {
all[k] = v
}
m := all[id]
+7 -1
View File
@@ -132,7 +132,13 @@ func (p Provider) live() ([]catalog.Model, time.Time, bool) {
if p.IsPlugin() {
return nil, time.Time{}, false
}
return catalog.Live(p.ID)
type live struct {
ms []catalog.Model
at time.Time
ok bool
}
l := heldOf("fetched:"+p.ID, func() live { ms, at, ok := catalog.Live(p.ID); return live{ms, at, ok} })
return l.ms, l.at, l.ok
}
// Fetch asks the vendor which models it serves and remembers the answer.
+1
View File
@@ -631,6 +631,7 @@ func CheckProxy(p string) error {
func Save(s Settings) error {
fileMu.Lock()
defer fileMu.Unlock()
defer filememo.Forget() // read again, where a request holds it
s = s.normal()
if !slices.Contains(Themes, s.Theme) {
return fmt.Errorf("theme must be one of %v, not %q", Themes, s.Theme)
+4
View File
@@ -192,10 +192,14 @@ func run(args []string) error {
}
makeDirs()
settings.Migrate()
// the providers and settings read once for every agent's fields, which
// the moves below look at (a write among them reads them again)
release := provider.Hold()
agent.RenameLegacy()
agent.MoveCursorEfforts()
agent.MoveAntigravityEfforts()
agent.MoveOffAccountIDs()
release()
// a provider added, edited or removed, or a list fetched anew, reaches
// the model lists agents keep in files of their own
catalog.Changed = agent.SyncCatalog