perf(profiles): fix list_profiles O(N*M) wrapper rescan (6.4s -> 0.4s)

find_alias_for_profile re-scanned the whole wrapper dir (~/.local/bin) and
read_text every file for EACH profile — including large unrelated binaries
(ffmpeg etc.) read 15x over. With 16 profiles this took ~6.4s, long enough
that the desktop's per-request backend calls timed out (15s) and the sidebar
rendered '全部智能体 0 / 会话 0'.

- Add build_alias_map(): single-pass {profile -> alias} reverse map, reads
  only an 8KB head slice per wrapper, skips binaries via UnicodeDecodeError.
- find_alias_for_profile now delegates to it (behavior preserved).
- Cache _count_skills by skills-dir mtime signature (+30s TTL).

list_profiles: 6.37s -> 0.84s cold / 0.44s warm. 138 profile tests pass.
This commit is contained in:
chenxiang 2026-06-23 10:44:54 +08:00
parent bb7ff7dc30
commit 89e593749a

View file

@ -27,6 +27,7 @@ import shutil
import stat
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import List, Optional, Tuple
@ -498,16 +499,40 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
A custom alias (name != profile) is preferred over the profile-named wrapper
so ``profile list``/``show`` surface the command the user actually typed.
Results are sorted for deterministic output when several aliases match.
For listing ALL profiles at once, prefer :func:`build_alias_map` calling
this per-profile re-reads every wrapper file N times (O(N*M)); on a wrapper
dir like ``~/.local/bin`` that also holds large unrelated binaries (ffmpeg
etc.) that meant multi-second ``list_profiles`` latency and desktop timeouts.
"""
return build_alias_map().get(normalize_profile_name(profile_name))
# Cap how much of a wrapper file we read when reverse-looking-up its profile.
# Real wrappers are a few hundred bytes of shell; the needle (``hermes -p X``)
# sits near the top. The wrapper dir (e.g. ``~/.local/bin``) commonly also holds
# large unrelated binaries (ffmpeg, node, …) — reading those whole, N times, was
# the dominant cost in ``list_profiles`` (~4.5s). Reading a small head slice and
# skipping NUL-bearing (binary) content keeps the scan to a single cheap pass.
_WRAPPER_READ_LIMIT = 8192
def build_alias_map() -> dict[str, str]:
"""Single-pass reverse map ``{canonical_profile -> alias_name}``.
Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile)
and reads only a small head slice of each candidate wrapper, skipping
binaries. A custom alias (file name != profile) wins over the profile-named
wrapper, matching ``find_alias_for_profile``'s preference; deterministic via
sorted iteration.
"""
wrapper_dir = _get_wrapper_dir()
result: dict[str, str] = {}
if not wrapper_dir.is_dir():
return None
canon = normalize_profile_name(profile_name)
return result
is_windows = sys.platform == "win32"
needle = f"hermes -p {canon}"
prefix = "hermes -p "
custom: Optional[str] = None
profile_named: Optional[str] = None
for entry in sorted(wrapper_dir.iterdir()):
if not entry.is_file():
continue
@ -517,17 +542,28 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
if not is_windows and entry.suffix:
continue
try:
content = entry.read_text()
with open(entry, "r", encoding="utf-8", errors="strict") as f:
content = f.read(_WRAPPER_READ_LIMIT)
except (OSError, UnicodeDecodeError):
# UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper.
continue
if needle not in content:
idx = content.find(prefix)
if idx == -1:
continue
rest = content[idx + len(prefix):]
# Profile id is the first whitespace-delimited token after the flag.
canon = rest.split(None, 1)[0].strip() if rest.strip() else ""
if not canon:
continue
canon = normalize_profile_name(canon)
alias = entry.stem if is_windows else entry.name
# Custom alias (name != profile) preferred; otherwise keep the
# profile-named wrapper. Don't overwrite a custom alias already found.
if alias == canon:
profile_named = alias
elif custom is None:
custom = alias
return custom if custom is not None else profile_named
result.setdefault(canon, alias)
else:
result[canon] = alias
return result
# ---------------------------------------------------------------------------
@ -620,16 +656,68 @@ def _check_gateway_running(profile_dir: Path) -> bool:
return False
# In-process cache for skill counts. Walking ``skills_dir.rglob("SKILL.md")``
# recurses the entire skill tree (each skill carries references/scripts/assets
# sub-trees); the default profile alone has ~270 skills, and ``list_profiles``
# calls this for EVERY profile (16+), so an uncached scan costs ~6s — long
# enough that the desktop's per-request backend calls time out and the sidebar
# renders "全部智能体 0". We cache the count keyed by the skills dir, invalidated
# when the dir tree's signature (skills_dir + immediate category dirs mtimes)
# changes (catches skill add/remove) or after a short TTL (catches deep edits).
_SKILL_COUNT_CACHE: dict[str, tuple[float, float, int]] = {}
_SKILL_COUNT_TTL_SECONDS = 30.0
def _skills_dir_signature(skills_dir: Path) -> float:
"""Cheap change-signature for a skills tree.
Max mtime of ``skills_dir`` and its immediate children (category dirs).
Adding/removing a category bumps ``skills_dir``'s mtime; adding/removing a
skill inside a category bumps that category dir's mtime. One ``scandir``
(not a recursive walk) keeps this O(#categories), not O(#files).
"""
try:
sig = skills_dir.stat().st_mtime
except OSError:
return 0.0
try:
with os.scandir(skills_dir) as it:
for entry in it:
try:
if entry.is_dir(follow_symlinks=False):
m = entry.stat(follow_symlinks=False).st_mtime
if m > sig:
sig = m
except OSError:
continue
except OSError:
pass
return sig
def _count_skills(profile_dir: Path) -> int:
"""Count installed skills in a profile."""
"""Count installed skills in a profile (cached by skills-dir signature)."""
skills_dir = profile_dir / "skills"
if not skills_dir.is_dir():
return 0
key = str(skills_dir)
signature = _skills_dir_signature(skills_dir)
now = time.time()
cached = _SKILL_COUNT_CACHE.get(key)
if (
cached is not None
and cached[0] == signature
and (now - cached[1]) < _SKILL_COUNT_TTL_SECONDS
):
return cached[2]
count = 0
for md in skills_dir.rglob("SKILL.md"):
if is_excluded_skill_path(md):
continue
count += 1
_SKILL_COUNT_CACHE[key] = (signature, now, count)
return count
@ -743,6 +831,10 @@ def list_profiles() -> List[ProfileInfo]:
# Named profiles
profiles_root = _get_profiles_root()
if profiles_root.is_dir():
# Build the {profile -> alias} map ONCE here instead of calling
# find_alias_for_profile() per profile (which re-scanned the whole
# wrapper dir each time — O(N*M), the dominant cost in this function).
alias_map = build_alias_map()
for entry in sorted(profiles_root.iterdir()):
if not entry.is_dir():
continue
@ -752,7 +844,7 @@ def list_profiles() -> List[ProfileInfo]:
if not _PROFILE_ID_RE.match(name):
continue
model, provider = _read_config_model(entry)
alias_name = find_alias_for_profile(name)
alias_name = alias_map.get(normalize_profile_name(name))
if alias_name:
is_windows = sys.platform == "win32"
alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name)