Repository navigation
perf(picker): stop rebuilding catalogs per model row when /model opens - #7026
Conversation
Opening `/model` blocked the UI thread while it built every configured route's rows. On a relay publishing 176 models that was not one pass over the route but 176 of them: - `cached_entry_for_route` returned a deep clone of the whole cached entry, so each row cloned all 176 offerings: 30,976 `CatalogOffering` copies for one picker open. - `catalog_offering_for_route` then rebuilt that provider's owned bundled + Models.dev row map for every row, cloning ~60 rows each time and allocating a fresh `BTreeMap`: 176 allocations. - `resolve_for_model` re-ran the credential resolver (including the secret store) per row, and `route_identity_for_model` inside it ran it a second time. The answer depends only on the route, so 176 identical walks produced 175 redundant ones. Three seams, each keeping its existing decision: - `with_cached_entry_for_route` lends the entry to a callback instead of cloning it. The picker path clones only the row that matched. - `source_rows_for_provider` is memoized on the live generation and the signed cloud layer's generation, the same way `merged_snapshot` already is; a partition is rebuilt only when one of them moves. `None` for the cloud layer is a distinct sentinel from an overlay whose generation is zero, so an unclouded route can never be served a partition the overlay already patched. - `RouteReadinessScope` hoists the route-scoped readiness facts (endpoint, credential state, auth class, credential generation) out of the per-model loop. `resolve_for_model` keeps its signature and is now a thin wrapper over it. Verified: `cargo test -p codewhale-tui --lib -- model_picker catalog_tests provider_picker provider_readiness` -> 248 passed; 0 failed. `cargo check -p codewhale-tui --lib` clean, rustfmt clean. Note on the test run: `tui::model_picker::tests:: a_custom_relays_live_roster_reaches_the_default_view` overflows a test thread's default 2 MiB stack on this branch AND on unmodified main (both reproduce with `git stash`; it passes with RUST_MIN_STACK=33554432), so the suite above was run with RUST_MIN_STACK=8388608. That overflow is pre-existing and unrelated to this change. Not included: making `EffectivePickerMetadata` lazy. After these three seams the remaining per-row work is a handful of in-memory lookups, so the mechanism (53 read sites, interior mutability, and losing `ModelPickerRow`'s plain Clone/Eq) would cost more than it saves.
The previous commit claimed the remaining per-row picker work was "a handful of in-memory lookups". That was wrong, and a review caught it: `route_is_valid_for_model` called `RouteResolver::new()` once per model row, and `new()` re-projects the entire bundled Models.dev asset -- every text-chat row, the corrections pass, and the deduplication maps -- for each call. On a 176-model relay that is still 176 full catalog projections per `/model` open. `RouteResolver::bundled()` returns a shared resolver built once from the committed asset. The asset is a compile-time constant, so the projection is identical for the life of the process, and the read-only `resolve` path needs no per-caller copy. `new()` and `with_configured_models` stay as they are for callers that add route-specific offerings. Also close the memo hole a review named: `source_rows_for_provider` keyed on the live generation alone, so a caller whose `LIVE_SNAPSHOT` read failed would cache a bundled-only partition under the current generation and shadow Models.dev rows until the next bump. The live layer's presence is now part of the key. Verified: `cargo test -p codewhale-tui --lib -- model_picker catalog_tests provider_picker provider_readiness` -> 248 passed; 0 failed (the suite runs in 55.5s against 87.2s for the same filter on the parent commit, with the same 248 tests). rustfmt clean on both crates.
Instrumentation attributed most of the picker build to one line of text: `auto_picker_hint` built a full `ModelInventory` -- every configured route's every model through runtime route resolution -- to read four router facts. The router block never touches `candidates`, so both callers now share one code path and only the candidate loop is skipped. No logic is duplicated: `from_config` and `from_config_with_health` keep their behavior, and `auto_router_only` is the same function with `include_candidates = false`.
Per-row metadata resolution reaches pricing through `verified_catalog_offering` -> `catalog_offering_for_model_identity` -> `offerings_for_provider_identity`. That lookup walked every offering in the merged snapshot and computed `catalog_partition_key` -- a `trim().to_string()` -- for each one, so a picker build over a few thousand offerings spent tens of millions of string allocations on provider lookups alone. On the reporter's machine this was the largest single stage of the build. `SharedSnapshot` now carries a `provider -> rows` index built once with the snapshot, and `offerings_for_provider` reads it. The type is private to this module; every construction site goes through the one constructor.
Review follow-up on the memo added earlier in this branch. `source_rows_for_provider` read `LIVE_GENERATION` after the caller had already taken its `LIVE_SNAPSHOT` read guard, so an entry could be tagged with a newer generation than the rows it holds. That is safe only while every caller keeps the guard alive across the call; a future caller that samples live state first would cache stale rows under the current generation and they would be served until the next bump. The generation is now a parameter, sampled before the snapshot read in all three call sites -- the order `merged_snapshot` already documents. Also drops the one-machine timings from four doc comments; they belong in this commit message, not in the source tree.
Review follow-up. The earlier commit claimed `resolve_for_model` became a thin wrapper over `RouteReadinessScope`, but it kept its own copy of the body, so the two could drift apart silently. It now delegates. Also prunes comments that restated the code or repeated a commit message, and drops a rationale that contradicted this branch's own observation that the stack overflow reproduces on unmodified main.
|
🤖 This reply is from Claude Opus 5.5, an AI agent working on behalf of the Codewhale maintainers. It is posted through @Hmbown's account, but Hunter did not write it. Thank you @SparkofSpike. You traced the /model freeze to four separate per-row rebuilds (the cloned cache entry, the Models.dev partition, the bundled resolver projection and the full-inventory Auto hint) and fixed each behind its existing seam, which made this easy to review. Reading the live generation before the snapshot, and keying routes without signed facts separately, were exactly right. Merged in e133977 for 0.10.2, which hasn't shipped yet. CI passed the full Linux and macOS suites, and Windows after re-running one unrelated plugin-state flake. The 3–4 s → ~150 ms timing is your measurement; we haven't reproduced it on a 52-route setup ourselves. |
Summary
Opening
/modelfroze the whole TUI. On a machine with 52 configuredroutes and 3,444 catalog rows it took 3-4 seconds; it now takes ~150 ms,
measured both before and after with temporary in-process marks that are
not part of this PR.
Four independent causes, each fixed in its own commit:
1. One relay's catalog was rebuilt once per model row. Three separate
seams each repeated work that depends only on the route:
cached_entry_for_routereturned a deep clone of the whole cachedentry, so every row cloned every offering. On a 176-model relay that
was 30,976
CatalogOfferingcopies for one picker open.catalog_offering_for_routethen rebuilt the provider's ownedbundled + Models.dev row map per row, cloning ~60 rows and allocating a
fresh
BTreeMapeach time.resolve_for_modelre-ran the credential resolver (secret storeincluded) and the auth-class classification per row, and
route_identity_for_modelinside it ran the class walk a second time.with_cached_entry_for_routelends the entry to a callback;source_rows_for_provideris memoized on the live generation;RouteReadinessScopehoists the route-scoped readiness facts.2.
route_is_valid_for_modelre-projected the whole bundled catalog perrow. It called
RouteResolver::new(), andnew()=from_offerings( default_offerings()), which re-projects every text-chat row of thecommitted Models.dev asset plus the corrections pass and the
de-duplication maps. On 176 rows that is 176 full catalog projections.
RouteResolver::bundled()now shares oneOnceLock-guarded resolver;new()andwith_configured_modelsare unchanged for callers that addroute-specific offerings.
3. The
autorow built a fullModelInventoryfor one line of text.auto_picker_hintread four router facts and paid for every configuredroute's every model through runtime route resolution: 308 ms of a
1048 ms build. The router block never reads
candidates, soModelInventory::auto_router_onlyskips only the candidate loop. Nologic is duplicated.
4.
offerings_for_provider_identityscanned the whole merged catalogper row. It walked every offering and computed
catalog_partition_key-- atrim().to_string()-- for each one, so a3,444-row build over a few thousand offerings spent tens of millions of
string allocations on provider lookups. This was the largest single
stage (662 ms of 757 ms).
SharedSnapshotnow carries aprovider -> rowsindex built once with the snapshot.Measurements
Stage marks from the temporary instrumentation, same machine, opening
/modelonce per run:autorowPer-route detail moved with it:
openrouter(390 rows) 108.6 ms ->17.0 ms,
edenai(334 rows) 95.9 ms -> 17.8 ms,openai(177 rows)48.0 ms -> 7.7 ms.
These numbers come from one machine and are quoted as an indication of
which seams mattered, not as a guarantee. The next bottleneck on a
large-route setup is
cloud_facts::overlay::snapshot(), which takes theprocess-wide overlay write lock once per row for every non-custom
provider; this PR does not touch it.
Correctness
Nothing here changes observable behavior; each commit is a caching or
hoisting change behind an existing seam.
catalog_offering_for_routekeeps all five outcomes exactly: listed(row cloned and completed from lower layers), fetched-but-unlisted
(only an attested-unlisted row, never a fall-through to the bundled
merge),
fetched_at == 0, absent entry, and cache error.source_rows_for_provider's memo key is(live generation, cloud generation or a sentinel, live layer present). The generation issampled before the caller reads
LIVE_SNAPSHOT, the orderingmerged_snapshotalready documents, so a concurrent refresh cannotleave stale rows tagged with the current generation.
SharedSnapshot's index uses the samecatalog_partition_keyderivation and preserves offering order, so
offerings_for_providerreturns the same rows in the same relative order as the scan it
replaces.
auto_router_onlycannot change a router field: every one of them isderived from
configafter the loop, never fromcandidates.RouteResolver::bundled()isSelf::newunder aOnceLock, andnew()has no environment, config, clock or network input, so itcannot freeze a stale answer.
resolvetakes&self.Type of Change
Testing
cargo test -p codewhale-tui --lib -- provider_lake model_picker provider_picker provider_readiness client::catalog_tests model_inventory pricing-> 411 passed; 0 failed; 0 ignoredcargo test -p codewhale-config --lib -- route::-> 102 passed; 0 failed; 1 ignored (commit 2 adds a public API in this crate)cargo check -p codewhale-tui --lib-> cleanrustfmt --edition 2024 --checkon all four changed files -> cleancargo build --release -p codewhale-cli -p codewhale-tui-> Finishedmachine: 3-4 s before the chain, ~150 ms after.
Notes for review
a second permanently resident
RouteResolver::bundled()projection(a few MB; the client already keeps one), and a 32-entry memo of
provider partitions that is evicted alphabetically rather than by use.
SharedSnapshotindex normalizes provider keys throughcatalog_partition_key(trimmed). No producer of padded providerstrings was found, so this is a latent change, not an observed one.
existing suites above; a direct assertion that
bundled()andnew()resolve identically would be the one test I would add if asked.
Related Issues
No-Issue: reported as local
/modellatency; no upstream issue was opened.Attribution
🤖 Generated by SpikeBot 000(CodeWhale-LOCAL)