perf(tui): hot linear scans → map lookups (#4152)#4219
Conversation
Convert the per-turn O(n·m) membership scan in should_default_defer_tool (crates/tui/src/core/engine/tool_catalog.rs) from a linear .iter().any() over the 28-element DEFAULT_ACTIVE_NATIVE_TOOLS array into an O(1) lookup against a process-lifetime OnceLock<HashSet<&'static str>> side index. apply_native_tool_deferral iterates every native catalog tool and calls should_default_defer_tool for each, rebuilt on every turn — so the scan was O(catalog_len × 28). The ordered array is retained as the source of truth for ordered iteration (tool_catalog_consistency_issues, default_active_native_tool_names); only a side HashSet is added, so behavior is byte-identical and ordering-insensitive membership is O(1). features.rs and turn_loop.rs audited and left unchanged (one-off lookups over tiny/const collections, or wildcard rule matching with no stable key). Added a parity test asserting the set returns identical hit/miss to a linear scan over the array. Implemented with agent assistance.
|
Claude finished @Hmbown's task in 1m 42s —— View job Review: perf(tui): hot linear scans → map lookups (#4152)
SummaryThis is a small, well-scoped, low-risk perf change. I reviewed the full diff (
Scope discipline: The PR body's per-file audit for Provider/route safety: N/A — this PR touches only local tool-catalog deferral logic, no model/provider selection code. Reuse/dead code: None found. No new dependencies added; reuses the existing No blocking issues. This looks safe to merge from a correctness standpoint. |
Fixes #4152
Summary
Perf-only (behavior-identical) pass over the three candidate files for issue #4152: replace hot linear
Vec/slice scans that are repeatedly queried by a stable key with an O(1) map lookup, where — and only where — building the map genuinely amortizes.One genuinely hot site was converted; the other candidate scans were audited and left as linear scans because converting them would be neutral or slower.
Per-file audit
crates/tui/src/core/engine/tool_catalog.rs— CONVERTEDshould_default_defer_tool→ membership overDEFAULT_ACTIVE_NATIVE_TOOLS. Was!DEFAULT_ACTIVE_NATIVE_TOOLS.iter().any(|core_tool| core_tool == &name)— a linear scan of a 28-element array.apply_native_tool_deferralcalls this once per native catalog tool, and the catalog is rebuilt every turn (build_model_tool_catalog_with_surface, called fromengine.rshandle_deepseek_turn), so the real cost was O(catalog_len × 28) per turn. Converted to an O(1)containsagainst a process-lifetimeOnceLock<HashSet<&'static str>>side index (default_active_native_tools_set()). TheOnceLockbuilds the set once for the whole process, so it amortizes across every turn/tool rather than per-call.DEFAULT_ACTIVE_NATIVE_TOOLSarray is kept as the source of truth for ordered iteration (tool_catalog_consistency_issuesiterates it;engine::default_active_native_tool_names()returns it). Only a sideHashSetis added; the membership test carries no ordering dependency, so results are hit/miss-identical.catalog_contains_tool(used inunavailable_core_action_tools_*) loops over only 4 cached fallbacks, and runs solely on atool_searchinvocation — not hot enough to justify a per-call name set.tool_catalog_consistency_issues,active_tool_list_from_catalog, andinitial_active_toolsalready useHashSets.crates/tui/src/features.rs— LEFT AS-ISFeature::info,is_known_feature_key,feature_from_key,feature_spec_by_keyall scanFEATURES, but it is a 7-element compile-time constant and the lookups run at config-load / deserialization time, not per-turn.apply_maploops config entries callingfeature_from_key, but over 7 static entries and once at load. Building aHashMapfor 7 entries queried a handful of times would be slower, not faster. No hot repeated-key path here.crates/tui/src/core/engine/turn_loop.rs— LEFT AS-ISresolve_tool_definition(tool_catalog.iter().find(|d| d.name == …)) is called once per tool in the per-batchtool_usesloop. Batches are frequently a single tool call, so a per-batchHashMapbuild would be a net loss for the common case (the issue explicitly says one-off lookups should stay linear scans). Left as a linear scan.command_allows_tool/command_denies_toolscan userallowed_tools/disallowed_toolsrule lists, but matching supports trailing-*wildcards (rule.strip_suffix('*')) — there is no pure stable key to hash, so a set/map is not applicable.fold_tool_call_before_results, theplans.iter().any/filter, andcontent_blocks.iter().anyscans are each one-off passes over a batch, not repeated keyed lookups.Tests
Added
default_defer_lookup_matches_linear_scan_over_active_native_tools(incore/engine/tests.rs): asserts the new side-set lookup returns the same hit/miss as an explicit linear scan over the ordered array — every array member is a hit (not deferred), and names outside the array (plusrequest_user_input) miss (deferred). The pre-existingcore_native_tools_stay_loaded_in_yolo_mode/non_yolo_mode_retains_default_defer_policytests continue to exercise the converted function.Verification (CI gate, from worktree root)
cargo build -p codewhale-tui --locked— okcargo test -p codewhale-tui --all-features --locked— ok except the knownskill_hotbar_action_*env skill-cache flakecargo fmt --all -- --check— cleancargo clippy --workspace --all-features --locked -- -D warnings …— cleancargo test --workspace --all-features --locked— ok except the same knownskill_hotbar_action_*flakecargo build --release— okGenerated with Claude Code