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.

(cherry picked from commit 89e593749a)
This commit is contained in:
chenxiang 2026-06-23 10:44:54 +08:00 committed by Teknium
parent 2f5950a83a
commit d5eee133eb

View file

@ -27,6 +27,7 @@ import shutil
import stat import stat
import subprocess import subprocess
import sys import sys
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@ -528,16 +529,40 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
A custom alias (name != profile) is preferred over the profile-named wrapper A custom alias (name != profile) is preferred over the profile-named wrapper
so ``profile list``/``show`` surface the command the user actually typed. so ``profile list``/``show`` surface the command the user actually typed.
Results are sorted for deterministic output when several aliases match. 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() wrapper_dir = _get_wrapper_dir()
result: dict[str, str] = {}
if not wrapper_dir.is_dir(): if not wrapper_dir.is_dir():
return None return result
canon = normalize_profile_name(profile_name)
is_windows = sys.platform == "win32" 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()): for entry in sorted(wrapper_dir.iterdir()):
if not entry.is_file(): if not entry.is_file():
continue continue
@ -547,17 +572,28 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
if not is_windows and entry.suffix: if not is_windows and entry.suffix:
continue continue
try: 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): except (OSError, UnicodeDecodeError):
# UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper.
continue continue
if needle not in content: idx = content.find(prefix)
if idx == -1:
continue 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 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: if alias == canon:
profile_named = alias result.setdefault(canon, alias)
elif custom is None: else:
custom = alias result[canon] = alias
return custom if custom is not None else profile_named return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -674,16 +710,68 @@ def _check_gateway_running(profile_dir: Path) -> bool:
return False 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: 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" skills_dir = profile_dir / "skills"
if not skills_dir.is_dir(): if not skills_dir.is_dir():
return 0 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 count = 0
for md in skills_dir.rglob("SKILL.md"): for md in skills_dir.rglob("SKILL.md"):
if is_excluded_skill_path(md): if is_excluded_skill_path(md):
continue continue
count += 1 count += 1
_SKILL_COUNT_CACHE[key] = (signature, now, count)
return count return count
@ -797,6 +885,10 @@ def list_profiles() -> List[ProfileInfo]:
# Named profiles # Named profiles
profiles_root = _get_profiles_root() profiles_root = _get_profiles_root()
if profiles_root.is_dir(): 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()): for entry in sorted(profiles_root.iterdir()):
if not entry.is_dir(): if not entry.is_dir():
continue continue
@ -806,7 +898,7 @@ def list_profiles() -> List[ProfileInfo]:
if not _PROFILE_ID_RE.match(name): if not _PROFILE_ID_RE.match(name):
continue continue
model, provider = _read_config_model(entry) 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: if alias_name:
is_windows = sys.platform == "win32" is_windows = sys.platform == "win32"
alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name) alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name)