feat: add adaptive GPU scheduling
This commit is contained in:
25
README.md
25
README.md
@@ -31,6 +31,8 @@ Optional tuning:
|
||||
- `MODELHUB_AGENT_POST_CYCLE_COOLDOWN_SECONDS` default `2`
|
||||
- `MODELHUB_AGENT_MAX_SUBMITS_PER_RUN` default `0` (fill all currently available slots)
|
||||
- `MODELHUB_AGENT_ACTIVE_TASK_CAP` default `100` per account
|
||||
- `MODELHUB_CAPACITY_PROBE_INTERVAL_CYCLES` default `3`
|
||||
- `MODELHUB_CAPACITY_STATE_PATH` default `.modelhub_state/account_capacity.json`
|
||||
- `MODELHUB_AGENT_ACTIVE_COUNTS_TTL_SECONDS` default `15`
|
||||
- `MODELHUB_AGENT_RESERVATION_TTL_SECONDS` default `120`
|
||||
- `MODELHUB_AGENT_INSTANCE_ID` optional stable worker identity used to spread concurrent agents across accounts and candidates
|
||||
@@ -39,6 +41,27 @@ Optional tuning:
|
||||
- `MODELHUB_AGENT_MIN_DOWNLOADS`
|
||||
- `MODELHUB_AGENT_GPUS`
|
||||
- `MODELHUB_AGENT_EXTRA_ARGS`
|
||||
- `MODELHUB_GPU_STRATEGY_STATE_PATH` default `.modelhub_state/gpu_strategy.json`
|
||||
- `MODELSCOPE_PAGE_INTERVAL_SECONDS` default `0.25`
|
||||
- `MODELSCOPE_PAGE_CACHE_TTL_SECONDS` default `900`
|
||||
|
||||
## Adaptive GPU Strategy
|
||||
|
||||
When no explicit GPU override is supplied, the worker uses a local 50/30/20
|
||||
strategy generation:
|
||||
|
||||
- 50%: the three long-term GPUs with the best Wilson lower confidence score and at least 100 terminal samples
|
||||
- 30%: round-robin exploration across every currently supported GPU
|
||||
- 20%: the best GPU among the latest 1,000 terminal tasks
|
||||
|
||||
Only platform-accepted tasks count. After exactly 200 accepted tasks, the next
|
||||
poll cycle reloads all account history, generates a new immutable strategy snapshot,
|
||||
and resets the generation counters to 100/60/40 targets. The active snapshot and
|
||||
progress are stored in `.modelhub_state/gpu_strategy.json`.
|
||||
|
||||
ModelScope HTTP 429 responses use exponential backoff and `Retry-After`. Successful
|
||||
pages remain cached, so a later cycle retries the failed page instead of restarting
|
||||
the whole pagination scan.
|
||||
|
||||
## Concurrent Agents
|
||||
|
||||
@@ -56,7 +79,7 @@ authority for account capacity and model/GPU uniqueness.
|
||||
If the platform reports that a model/GPU is already being validated, the claim
|
||||
is retained and the runner immediately draws replacement candidates from the
|
||||
same scan instead of retrying the duplicate every cycle. Startup logs and the
|
||||
health response expose `agent_version`; version `2026.08.02.2` or newer includes
|
||||
health response expose `agent_version`; version `2026.08.02.3` or newer includes
|
||||
this behavior.
|
||||
|
||||
## Deploy
|
||||
|
||||
@@ -8,6 +8,7 @@ It currently supports:
|
||||
- continuous queue refill via `run_poll.sh`
|
||||
- multiple ModelHub tokens read from `KEY.md` and `KEYS.md`
|
||||
- automatic task/framework/template selection across the supported GPU catalog
|
||||
- adaptive long-term/exploration/recent GPU scheduling with a persistent local snapshot
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -72,13 +73,21 @@ bash run_poll.sh --dry-run
|
||||
## Behavior
|
||||
|
||||
- The runner auto-discovers all safe GPU/template combinations from the public submit catalog.
|
||||
- Automatic GPU selection uses smooth 50/30/20 scheduling: long-term Wilson-ranked top 3 GPUs,
|
||||
all supported GPUs, and the best GPU from the latest 1,000 terminal tasks.
|
||||
- A strategy generation lasts exactly 200 platform-accepted submissions. Rejected API calls and
|
||||
duplicates do not advance it. The next cycle refreshes platform history before submitting again.
|
||||
- Strategy state is stored in `.modelhub_state/gpu_strategy.json`; a generation never recalculates
|
||||
during candidate submission.
|
||||
- Each model can be submitted at most once per GPU.
|
||||
- Multiple ModelHub tokens are pooled and used to route submissions to the account with available async capacity.
|
||||
- Concurrent submissions reserve account slots locally, and an account-capacity race automatically falls through to another account.
|
||||
- Concurrent local processes claim model/GPU pairs in `.modelhub_state/submission_claims.jsonl`; shared ledger, history, and outcome files use process locks and atomic replacement.
|
||||
- Isolated agent containers diversify candidate order by instance identity to reduce cross-container duplicate submissions.
|
||||
- History stats are online-only. The local ledger is used for local accounting, but platform history is only used after the local ledger reaches the configured threshold.
|
||||
- The default history threshold is `500` records.
|
||||
- ModelScope list pages are paced and cached for 15 minutes. HTTP 429 responses use exponential
|
||||
backoff and `Retry-After`; pages already downloaded remain usable and the failed page is retried
|
||||
on the next cycle.
|
||||
- Every third poll cycle, a full account gets one controlled capacity probe. A successful probe
|
||||
raises that account's persisted known limit; a capacity rejection enters cooldown.
|
||||
|
||||
## Important Flags
|
||||
|
||||
@@ -95,6 +104,8 @@ Common flags:
|
||||
- `--skip-outcome-sync`: skip outcome sync before scanning
|
||||
- `--skip-history-archive`: skip history archive download for this run
|
||||
- `--dry-run`: plan only, do not submit
|
||||
- `--gpu-strategy-refresh-submissions`: accepted tasks per strategy generation (default `200`)
|
||||
- `--disable-gpu-strategy`: restore legacy ordering; explicit `--gpu/--gpus` also bypasses adaptive selection
|
||||
- `run_daily.sh` injects `--daily-target 3` when no daily-target flag is provided. Set `SUBMIT_DAILY_TARGET` or pass `--daily-target` explicitly for a different target.
|
||||
|
||||
`run_poll.sh` adds:
|
||||
@@ -127,6 +138,11 @@ Each run typically includes:
|
||||
- `skipped.jsonl`
|
||||
- `failed.jsonl`
|
||||
|
||||
Persistent local scheduler state is written under `.modelhub_state/`:
|
||||
|
||||
- `gpu_strategy.json`: GPU ranks, generation progress, and 50/30/20 accepted counters
|
||||
- `account_capacity.json`: learned per-account active-task limits
|
||||
|
||||
## Verification
|
||||
|
||||
Run the full test suite:
|
||||
|
||||
@@ -7,9 +7,10 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from common import utc_now, write_json
|
||||
from gpu_strategy import DEFAULT_GPU_STRATEGY_PATH
|
||||
from hf_discovery import HuggingFaceDiscovery
|
||||
from main import DEFAULT_LEDGER_PATH, DEFAULT_RUNS_DIR, make_run_dir, run_submission
|
||||
from modelhub_client import ModelHubClient, ModelHubClientPool
|
||||
from modelhub_client import DEFAULT_CAPACITY_STATE_PATH, ModelHubClient, ModelHubClientPool
|
||||
from outcome_tracker import OutcomeTracker
|
||||
from runner_common import DEFAULT_KEY_PATH, ensure_tokens
|
||||
from submission_claims import DEFAULT_CLAIMS_PATH
|
||||
@@ -79,6 +80,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--skip-outcome-sync", action="store_true", help="Skip outcome sync from ModelHub before scanning")
|
||||
parser.add_argument("--skip-history-archive", action="store_true", help="Skip historical task archive download for this run")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Plan the day without creating tasks")
|
||||
parser.add_argument("--disable-gpu-strategy", action="store_true", help="Disable adaptive 50/30/20 GPU scheduling")
|
||||
parser.add_argument(
|
||||
"--gpu-strategy-refresh-submissions",
|
||||
type=int,
|
||||
default=200,
|
||||
help="Recalculate adaptive GPU choices after this many accepted submissions",
|
||||
)
|
||||
parser.add_argument("--key-path", default=str(DEFAULT_KEY_PATH), help="Path to KEY.md containing MODELSCOPE_TOKEN/XC_TOKEN")
|
||||
parser.add_argument("--runs-dir", default=str(DEFAULT_RUNS_DIR), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--ledger-path", default=str(DEFAULT_LEDGER_PATH), help=argparse.SUPPRESS)
|
||||
@@ -89,6 +97,24 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
parser.add_argument("--history-archive-path", default="history/platform_tasks.jsonl", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--history-archive-limit", type=int, default=5000, help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--gpu-strategy-state-path",
|
||||
default=os.getenv("MODELHUB_GPU_STRATEGY_STATE_PATH", str(DEFAULT_GPU_STRATEGY_PATH)),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--gpu-strategy-recent-window", type=int, default=1000, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--gpu-strategy-min-long-samples", type=int, default=100, help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--capacity-state-path",
|
||||
default=os.getenv("MODELHUB_CAPACITY_STATE_PATH", str(DEFAULT_CAPACITY_STATE_PATH)),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--capacity-probe-interval-cycles",
|
||||
type=int,
|
||||
default=int(os.getenv("MODELHUB_CAPACITY_PROBE_INTERVAL_CYCLES", "3")),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--daily-runs-dir", default=str(DEFAULT_DAILY_RUNS_DIR), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--hf-base-url", default=os.getenv("MODELSCOPE_BASE_URL", "https://modelscope.cn"), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--modelhub-base-url", default=os.getenv("MODELHUB_BASE_URL", "https://modelhub.org.cn"), help=argparse.SUPPRESS)
|
||||
@@ -118,6 +144,13 @@ def make_wave_namespace(base_args: argparse.Namespace, wave: WaveSpec) -> argpar
|
||||
scan_multiplier=getattr(base_args, "scan_multiplier", 4),
|
||||
skip_outcome_sync=getattr(base_args, "skip_outcome_sync", False),
|
||||
skip_history_archive=getattr(base_args, "skip_history_archive", False),
|
||||
disable_gpu_strategy=getattr(base_args, "disable_gpu_strategy", False),
|
||||
gpu_strategy_refresh_submissions=getattr(base_args, "gpu_strategy_refresh_submissions", 200),
|
||||
gpu_strategy_state_path=getattr(base_args, "gpu_strategy_state_path", str(DEFAULT_GPU_STRATEGY_PATH)),
|
||||
gpu_strategy_recent_window=getattr(base_args, "gpu_strategy_recent_window", 1000),
|
||||
gpu_strategy_min_long_samples=getattr(base_args, "gpu_strategy_min_long_samples", 100),
|
||||
capacity_state_path=getattr(base_args, "capacity_state_path", str(DEFAULT_CAPACITY_STATE_PATH)),
|
||||
capacity_probe_interval_cycles=getattr(base_args, "capacity_probe_interval_cycles", 3),
|
||||
submit_concurrency=getattr(base_args, "submit_concurrency", 1),
|
||||
max_submits_per_run=getattr(base_args, "max_submits_per_run", 0),
|
||||
runs_dir=base_args.runs_dir,
|
||||
@@ -149,7 +182,11 @@ def run_daily_batches(
|
||||
modelhub_tokens = list(getattr(base_args, "modelhub_tokens", None) or ([] if not base_args.modelhub_token else [base_args.modelhub_token]))
|
||||
token_values: list[str | None] = modelhub_tokens or [base_args.modelhub_token]
|
||||
clients = [ModelHubClient(token=token, base_url=base_args.modelhub_base_url) for token in token_values]
|
||||
modelhub_client = ModelHubClientPool(clients)
|
||||
modelhub_client = ModelHubClientPool(
|
||||
clients,
|
||||
capacity_probe_interval_cycles=max(0, int(getattr(base_args, "capacity_probe_interval_cycles", 3) or 0)),
|
||||
capacity_state_path=Path(getattr(base_args, "capacity_state_path", DEFAULT_CAPACITY_STATE_PATH)),
|
||||
)
|
||||
template_selector = template_selector or TemplateSelector()
|
||||
|
||||
daily_run_dir = make_run_dir(Path(base_args.daily_runs_dir), now)
|
||||
@@ -172,6 +209,11 @@ def run_daily_batches(
|
||||
log(f"[daily] round={round_index} start")
|
||||
for wave in waves:
|
||||
attempted_waves += 1
|
||||
if (
|
||||
hasattr(modelhub_client, "configure_capacity_probe")
|
||||
and not bool(getattr(base_args, "capacity_probe_already_configured", False))
|
||||
):
|
||||
modelhub_client.configure_capacity_probe(attempted_waves)
|
||||
wave_args = make_wave_namespace(base_args, wave)
|
||||
log(
|
||||
f"[daily] round={round_index} wave={wave.name} "
|
||||
|
||||
480
modelhub_submmit_api/gpu_strategy.py
Normal file
480
modelhub_submmit_api/gpu_strategy.py
Normal file
@@ -0,0 +1,480 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from common import parse_datetime, read_json, utc_now, write_json
|
||||
from history_stats import is_failure, is_success
|
||||
from modelhub_client import ModelHubClientPool
|
||||
from submission_claims import candidate_key
|
||||
|
||||
|
||||
DEFAULT_GPU_STRATEGY_PATH = Path(".modelhub_state/gpu_strategy.json")
|
||||
DEFAULT_REFRESH_SUBMISSIONS = 200
|
||||
DEFAULT_RECENT_TERMINAL_WINDOW = 1000
|
||||
DEFAULT_LONG_TERM_MIN_SAMPLES = 100
|
||||
STRATEGY_STATE_VERSION = 1
|
||||
|
||||
LONG_TERM = "long_term"
|
||||
ALL_SUPPORTED = "all_supported"
|
||||
RECENT = "recent"
|
||||
CATEGORIES = (LONG_TERM, ALL_SUPPORTED, RECENT)
|
||||
CATEGORY_WEIGHTS = {LONG_TERM: 5, ALL_SUPPORTED: 3, RECENT: 2}
|
||||
|
||||
# The long-term half is split 50/30/20 across its three ranked GPUs.
|
||||
LONG_TERM_GPU_PATTERN = (0, 1, 0, 2, 0, 1, 0, 1, 0, 2)
|
||||
|
||||
|
||||
def _empty_category_counts() -> dict[str, int]:
|
||||
return {category: 0 for category in CATEGORIES}
|
||||
|
||||
|
||||
def _gpu_name(record: dict[str, Any]) -> str:
|
||||
return str(record.get("gpuType") or record.get("targetGpu") or "").strip()
|
||||
|
||||
|
||||
def _terminal_outcome(record: dict[str, Any]) -> str | None:
|
||||
try:
|
||||
if is_success(record):
|
||||
return "success"
|
||||
if is_failure(record):
|
||||
return "failure"
|
||||
except TypeError:
|
||||
# Be tolerant of APIs that serialize verifyResult as a string.
|
||||
copied = dict(record)
|
||||
try:
|
||||
copied["verifyResult"] = float(record.get("verifyResult"))
|
||||
except (TypeError, ValueError):
|
||||
copied["verifyResult"] = None
|
||||
if is_success(copied):
|
||||
return "success"
|
||||
if is_failure(copied):
|
||||
return "failure"
|
||||
return None
|
||||
|
||||
|
||||
def _task_sort_key(record: dict[str, Any]) -> tuple[float, str]:
|
||||
timestamp = (
|
||||
parse_datetime(record.get("updateTime"))
|
||||
or parse_datetime(record.get("createTime"))
|
||||
or parse_datetime(record.get("submitTime"))
|
||||
)
|
||||
return (timestamp.timestamp() if timestamp else 0.0, str(record.get("taskId") or ""))
|
||||
|
||||
|
||||
def wilson_lower_bound(successes: int, total: int, *, z: float = 1.96) -> float:
|
||||
if total <= 0:
|
||||
return 0.0
|
||||
probability = successes / total
|
||||
z_squared = z * z
|
||||
denominator = 1.0 + z_squared / total
|
||||
centre = probability + z_squared / (2.0 * total)
|
||||
margin = z * math.sqrt(
|
||||
(probability * (1.0 - probability) / total) + z_squared / (4.0 * total * total)
|
||||
)
|
||||
return max(0.0, (centre - margin) / denominator)
|
||||
|
||||
|
||||
def _summarize_gpu_records(
|
||||
records: list[dict[str, Any]],
|
||||
supported_gpus: list[str],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
supported = set(supported_gpus)
|
||||
counts: dict[str, dict[str, int]] = {
|
||||
gpu: {"success": 0, "failure": 0}
|
||||
for gpu in supported_gpus
|
||||
}
|
||||
for record in records:
|
||||
gpu = _gpu_name(record)
|
||||
if gpu not in supported:
|
||||
continue
|
||||
outcome = _terminal_outcome(record)
|
||||
if outcome is not None:
|
||||
counts[gpu][outcome] += 1
|
||||
|
||||
summaries: dict[str, dict[str, Any]] = {}
|
||||
for gpu in supported_gpus:
|
||||
success = counts[gpu]["success"]
|
||||
failure = counts[gpu]["failure"]
|
||||
terminal = success + failure
|
||||
summaries[gpu] = {
|
||||
"gpu": gpu,
|
||||
"success": success,
|
||||
"failure": failure,
|
||||
"terminal": terminal,
|
||||
"successRate": success / terminal if terminal else 0.0,
|
||||
"wilsonLowerBound": wilson_lower_bound(success, terminal),
|
||||
}
|
||||
return summaries
|
||||
|
||||
|
||||
def _rank_gpu_summaries(summaries: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return sorted(
|
||||
summaries.values(),
|
||||
key=lambda item: (
|
||||
-float(item["wilsonLowerBound"]),
|
||||
-float(item["successRate"]),
|
||||
-int(item["terminal"]),
|
||||
str(item["gpu"]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_strategy_snapshot(
|
||||
tasks: list[dict[str, Any]],
|
||||
*,
|
||||
supported_gpus: list[str],
|
||||
generated_at: datetime | None = None,
|
||||
generation: int = 0,
|
||||
recent_terminal_window: int = DEFAULT_RECENT_TERMINAL_WINDOW,
|
||||
long_term_min_samples: int = DEFAULT_LONG_TERM_MIN_SAMPLES,
|
||||
refresh_submissions: int = DEFAULT_REFRESH_SUBMISSIONS,
|
||||
) -> dict[str, Any]:
|
||||
generated_at = generated_at or utc_now()
|
||||
supported_gpus = list(dict.fromkeys(gpu for gpu in supported_gpus if gpu))
|
||||
if not supported_gpus:
|
||||
raise ValueError("At least one supported GPU is required")
|
||||
|
||||
all_time = _summarize_gpu_records(tasks, supported_gpus)
|
||||
all_time_ranked = _rank_gpu_summaries(all_time)
|
||||
qualified_long = [
|
||||
item for item in all_time_ranked
|
||||
if int(item["terminal"]) >= max(1, int(long_term_min_samples))
|
||||
]
|
||||
long_term_gpus = [str(item["gpu"]) for item in qualified_long[:3]]
|
||||
if len(long_term_gpus) < min(3, len(supported_gpus)):
|
||||
for item in all_time_ranked:
|
||||
gpu = str(item["gpu"])
|
||||
if gpu not in long_term_gpus:
|
||||
long_term_gpus.append(gpu)
|
||||
if len(long_term_gpus) >= min(3, len(supported_gpus)):
|
||||
break
|
||||
|
||||
terminal_tasks = [record for record in tasks if _terminal_outcome(record) is not None]
|
||||
terminal_tasks.sort(key=_task_sort_key, reverse=True)
|
||||
recent_tasks = terminal_tasks[: max(1, int(recent_terminal_window))]
|
||||
recent = _summarize_gpu_records(recent_tasks, supported_gpus)
|
||||
recent_ranked = _rank_gpu_summaries(recent)
|
||||
recent_qualified = [item for item in recent_ranked if int(item["terminal"]) >= 20]
|
||||
if recent_qualified:
|
||||
recent_gpu = str(recent_qualified[0]["gpu"])
|
||||
elif recent_tasks and recent_ranked:
|
||||
recent_gpu = str(recent_ranked[0]["gpu"])
|
||||
else:
|
||||
recent_gpu = long_term_gpus[0]
|
||||
|
||||
return {
|
||||
"version": STRATEGY_STATE_VERSION,
|
||||
"generation": max(0, int(generation)),
|
||||
"generatedAt": generated_at.isoformat(),
|
||||
"historyReady": True,
|
||||
"historyTaskCount": len(tasks),
|
||||
"supportedGpus": supported_gpus,
|
||||
"longTermGpus": long_term_gpus,
|
||||
"recentGpu": recent_gpu,
|
||||
"recentTerminalCount": len(recent_tasks),
|
||||
"refreshSubmissions": max(1, int(refresh_submissions)),
|
||||
"recentTerminalWindow": max(1, int(recent_terminal_window)),
|
||||
"longTermMinSamples": max(1, int(long_term_min_samples)),
|
||||
"acceptedSinceRefresh": 0,
|
||||
"acceptedTotal": 0,
|
||||
"acceptedByCategory": _empty_category_counts(),
|
||||
"longTermStats": all_time_ranked,
|
||||
"recentStats": recent_ranked,
|
||||
"lastRefreshError": None,
|
||||
"refreshRetryAfter": None,
|
||||
}
|
||||
|
||||
|
||||
def choose_next_category(counts: dict[str, int]) -> str:
|
||||
normalized = {category: max(0, int(counts.get(category, 0))) for category in CATEGORIES}
|
||||
next_total = sum(normalized.values()) + 1
|
||||
|
||||
def deficit(category: str) -> int:
|
||||
return CATEGORY_WEIGHTS[category] * next_total - normalized[category] * sum(CATEGORY_WEIGHTS.values())
|
||||
|
||||
return max(CATEGORIES, key=lambda category: (deficit(category), -CATEGORIES.index(category)))
|
||||
|
||||
|
||||
class GPUStrategyManager:
|
||||
def __init__(
|
||||
self,
|
||||
path: Path | str = DEFAULT_GPU_STRATEGY_PATH,
|
||||
*,
|
||||
refresh_submissions: int = DEFAULT_REFRESH_SUBMISSIONS,
|
||||
recent_terminal_window: int = DEFAULT_RECENT_TERMINAL_WINDOW,
|
||||
long_term_min_samples: int = DEFAULT_LONG_TERM_MIN_SAMPLES,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.refresh_submissions = max(1, int(refresh_submissions))
|
||||
self.recent_terminal_window = max(1, int(recent_terminal_window))
|
||||
self.long_term_min_samples = max(1, int(long_term_min_samples))
|
||||
self.log = log_fn or (lambda message: print(message, flush=True))
|
||||
self.state: dict[str, Any] | None = None
|
||||
|
||||
def _load(self) -> dict[str, Any] | None:
|
||||
try:
|
||||
value = read_json(self.path)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def _is_compatible(self, state: dict[str, Any] | None, supported_gpus: list[str]) -> bool:
|
||||
if not state or int(state.get("version") or 0) != STRATEGY_STATE_VERSION:
|
||||
return False
|
||||
return list(state.get("supportedGpus") or []) == supported_gpus
|
||||
|
||||
def _refresh_due(self, state: dict[str, Any] | None, supported_gpus: list[str], now: datetime) -> tuple[bool, str]:
|
||||
if not self._is_compatible(state, supported_gpus):
|
||||
return True, "initial_or_gpu_catalog_changed"
|
||||
retry_after = parse_datetime(state.get("refreshRetryAfter")) if state is not None else None
|
||||
if state is not None and state.get("lastRefreshError") and retry_after is not None and retry_after > now:
|
||||
return False, "refresh_error_backoff"
|
||||
if not bool(state.get("historyReady", False)):
|
||||
return (retry_after is None or retry_after <= now), "history_not_ready"
|
||||
accepted = int(state.get("acceptedSinceRefresh") or 0)
|
||||
refresh_every = max(1, int(state.get("refreshSubmissions") or self.refresh_submissions))
|
||||
return accepted >= refresh_every, "accepted_submission_threshold"
|
||||
|
||||
@staticmethod
|
||||
def _load_platform_history(client: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(client, ModelHubClientPool):
|
||||
by_account: dict[int, list[dict[str, Any]]] = {}
|
||||
errors: list[int] = []
|
||||
with ThreadPoolExecutor(max_workers=min(len(client.clients), 12)) as executor:
|
||||
futures = {
|
||||
executor.submit(account_client.list_tasks, page_size=100, only_mine=True): index
|
||||
for index, account_client in enumerate(client.clients, start=1)
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
index = futures[future]
|
||||
try:
|
||||
by_account[index] = future.result()
|
||||
except Exception:
|
||||
errors.append(index)
|
||||
if errors:
|
||||
joined = ",".join(str(index) for index in sorted(errors))
|
||||
raise RuntimeError(f"ModelHub history fetch failed for account indexes: {joined}")
|
||||
tasks = [task for index in sorted(by_account) for task in by_account[index]]
|
||||
else:
|
||||
tasks = client.list_tasks(page_size=100, only_mine=True)
|
||||
|
||||
deduped: list[dict[str, Any]] = []
|
||||
seen_task_ids: set[str] = set()
|
||||
for task in tasks:
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
task_id = str(task.get("taskId")) if task.get("taskId") is not None else None
|
||||
if task_id and task_id in seen_task_ids:
|
||||
continue
|
||||
if task_id:
|
||||
seen_task_ids.add(task_id)
|
||||
deduped.append(task)
|
||||
return deduped
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
client: Any,
|
||||
*,
|
||||
supported_gpus: list[str],
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
now = now or utc_now()
|
||||
supported_gpus = list(dict.fromkeys(gpu for gpu in supported_gpus if gpu))
|
||||
state = self._load()
|
||||
refresh_due, reason = self._refresh_due(state, supported_gpus, now)
|
||||
if not refresh_due and state is not None:
|
||||
self.state = state
|
||||
self._log_state("loaded")
|
||||
return state
|
||||
|
||||
previous_generation = int(state.get("generation", -1)) if state is not None else -1
|
||||
self.log(f"[strategy] refresh_start reason={reason} supported_gpus={len(supported_gpus)}")
|
||||
try:
|
||||
tasks = self._load_platform_history(client)
|
||||
refreshed = build_strategy_snapshot(
|
||||
tasks,
|
||||
supported_gpus=supported_gpus,
|
||||
generated_at=now,
|
||||
generation=previous_generation + 1,
|
||||
recent_terminal_window=self.recent_terminal_window,
|
||||
long_term_min_samples=self.long_term_min_samples,
|
||||
refresh_submissions=self.refresh_submissions,
|
||||
)
|
||||
refreshed["acceptedTotal"] = int((state or {}).get("acceptedTotal") or 0)
|
||||
self.state = refreshed
|
||||
write_json(self.path, refreshed)
|
||||
self._log_state("refreshed")
|
||||
return refreshed
|
||||
except Exception as exc:
|
||||
self.log(f"[strategy] refresh_error reason={type(exc).__name__}: {exc}")
|
||||
if self._is_compatible(state, supported_gpus):
|
||||
assert state is not None
|
||||
state["lastRefreshError"] = str(exc)
|
||||
state["refreshRetryAfter"] = (now + timedelta(minutes=5)).isoformat()
|
||||
self.state = state
|
||||
write_json(self.path, state)
|
||||
return state
|
||||
|
||||
fallback = build_strategy_snapshot(
|
||||
[],
|
||||
supported_gpus=supported_gpus,
|
||||
generated_at=now,
|
||||
generation=0,
|
||||
recent_terminal_window=self.recent_terminal_window,
|
||||
long_term_min_samples=self.long_term_min_samples,
|
||||
refresh_submissions=self.refresh_submissions,
|
||||
)
|
||||
fallback["historyReady"] = False
|
||||
fallback["lastRefreshError"] = str(exc)
|
||||
fallback["refreshRetryAfter"] = (now + timedelta(minutes=5)).isoformat()
|
||||
self.state = fallback
|
||||
write_json(self.path, fallback)
|
||||
self._log_state("fallback")
|
||||
return fallback
|
||||
|
||||
def _log_state(self, action: str) -> None:
|
||||
if self.state is None:
|
||||
return
|
||||
counts = self.state.get("acceptedByCategory") or {}
|
||||
self.log(
|
||||
f"[strategy] {action} generation={self.state.get('generation', 0)} "
|
||||
f"accepted={self.state.get('acceptedSinceRefresh', 0)}/{self.state.get('refreshSubmissions', self.refresh_submissions)} "
|
||||
f"categories={counts.get(LONG_TERM, 0)},{counts.get(ALL_SUPPORTED, 0)},{counts.get(RECENT, 0)} "
|
||||
f"long={','.join(self.state.get('longTermGpus') or [])} recent={self.state.get('recentGpu') or 'n/a'}"
|
||||
)
|
||||
|
||||
@property
|
||||
def submissions_until_refresh(self) -> int:
|
||||
if self.state is None:
|
||||
return self.refresh_submissions
|
||||
refresh_every = max(1, int(self.state.get("refreshSubmissions") or self.refresh_submissions))
|
||||
accepted = max(0, int(self.state.get("acceptedSinceRefresh") or 0))
|
||||
return max(0, refresh_every - accepted)
|
||||
|
||||
def _category_gpu_order(self, category: str, occurrence: int) -> list[str]:
|
||||
assert self.state is not None
|
||||
supported = list(self.state.get("supportedGpus") or [])
|
||||
long_term = [gpu for gpu in self.state.get("longTermGpus") or [] if gpu in supported]
|
||||
recent_gpu = str(self.state.get("recentGpu") or "")
|
||||
|
||||
if category == LONG_TERM and long_term:
|
||||
desired_index = LONG_TERM_GPU_PATTERN[occurrence % len(LONG_TERM_GPU_PATTERN)]
|
||||
desired = long_term[min(desired_index, len(long_term) - 1)]
|
||||
return [desired, *(gpu for gpu in long_term if gpu != desired)]
|
||||
if category == RECENT and recent_gpu in supported:
|
||||
return [recent_gpu]
|
||||
if supported:
|
||||
offset = occurrence % len(supported)
|
||||
return [*supported[offset:], *supported[:offset]]
|
||||
return []
|
||||
|
||||
def order_candidates(self, candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if self.state is None or not candidates:
|
||||
return list(candidates)
|
||||
|
||||
by_gpu: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for candidate in candidates:
|
||||
by_gpu[str(candidate.get("targetGpu") or "")].append(candidate)
|
||||
gpu_indexes: dict[str, int] = defaultdict(int)
|
||||
used: set[str] = set()
|
||||
ordered: list[dict[str, Any]] = []
|
||||
virtual_counts = {
|
||||
category: max(0, int((self.state.get("acceptedByCategory") or {}).get(category, 0)))
|
||||
for category in CATEGORIES
|
||||
}
|
||||
|
||||
def take_from_gpu(gpu: str) -> dict[str, Any] | None:
|
||||
pool = by_gpu.get(gpu) or []
|
||||
index = gpu_indexes[gpu]
|
||||
while index < len(pool):
|
||||
candidate = pool[index]
|
||||
index += 1
|
||||
gpu_indexes[gpu] = index
|
||||
if candidate_key(candidate) not in used:
|
||||
return candidate
|
||||
gpu_indexes[gpu] = index
|
||||
return None
|
||||
|
||||
while len(ordered) < len(candidates):
|
||||
planned_category = choose_next_category(virtual_counts)
|
||||
selected: dict[str, Any] | None = None
|
||||
actual_category = planned_category
|
||||
categories_to_try = [planned_category, *(category for category in CATEGORIES if category != planned_category)]
|
||||
for category in categories_to_try:
|
||||
occurrence = virtual_counts[category]
|
||||
for gpu in self._category_gpu_order(category, occurrence):
|
||||
selected = take_from_gpu(gpu)
|
||||
if selected is not None:
|
||||
actual_category = category
|
||||
break
|
||||
if selected is not None:
|
||||
break
|
||||
|
||||
if selected is None:
|
||||
# Unknown/custom GPUs can only appear when callers bypass the normal resolver.
|
||||
selected = next((candidate for candidate in candidates if candidate_key(candidate) not in used), None)
|
||||
actual_category = ALL_SUPPORTED
|
||||
if selected is None:
|
||||
break
|
||||
|
||||
key = candidate_key(selected)
|
||||
used.add(key)
|
||||
annotated = dict(selected)
|
||||
annotated["strategyCategory"] = actual_category
|
||||
annotated["strategyPlannedCategory"] = planned_category
|
||||
annotated["strategyGeneration"] = int(self.state.get("generation") or 0)
|
||||
ordered.append(annotated)
|
||||
virtual_counts[actual_category] += 1
|
||||
|
||||
return ordered
|
||||
|
||||
def record_accepted(self, candidates: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
if not candidates:
|
||||
return self.state
|
||||
state = self._load() or self.state
|
||||
if state is None:
|
||||
return None
|
||||
|
||||
category_counts = {
|
||||
category: max(0, int((state.get("acceptedByCategory") or {}).get(category, 0)))
|
||||
for category in CATEGORIES
|
||||
}
|
||||
for candidate in candidates:
|
||||
category = str(candidate.get("strategyCategory") or ALL_SUPPORTED)
|
||||
if category not in category_counts:
|
||||
category = ALL_SUPPORTED
|
||||
category_counts[category] += 1
|
||||
|
||||
accepted_count = len(candidates)
|
||||
state["acceptedSinceRefresh"] = int(state.get("acceptedSinceRefresh") or 0) + accepted_count
|
||||
state["acceptedTotal"] = int(state.get("acceptedTotal") or 0) + accepted_count
|
||||
state["acceptedByCategory"] = category_counts
|
||||
self.state = state
|
||||
write_json(self.path, state)
|
||||
self._log_state("progress")
|
||||
return state
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
if self.state is None:
|
||||
return {"enabled": False}
|
||||
return {
|
||||
"enabled": True,
|
||||
"statePath": str(self.path),
|
||||
"generation": int(self.state.get("generation") or 0),
|
||||
"generatedAt": self.state.get("generatedAt"),
|
||||
"historyReady": bool(self.state.get("historyReady", False)),
|
||||
"historyTaskCount": int(self.state.get("historyTaskCount") or 0),
|
||||
"acceptedSinceRefresh": int(self.state.get("acceptedSinceRefresh") or 0),
|
||||
"refreshSubmissions": int(self.state.get("refreshSubmissions") or self.refresh_submissions),
|
||||
"acceptedByCategory": dict(self.state.get("acceptedByCategory") or {}),
|
||||
"longTermGpus": list(self.state.get("longTermGpus") or []),
|
||||
"recentGpu": self.state.get("recentGpu"),
|
||||
"recentTerminalCount": int(self.state.get("recentTerminalCount") or 0),
|
||||
"refreshDue": self.submissions_until_refresh <= 0,
|
||||
}
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
@@ -44,7 +45,9 @@ class HuggingFaceDiscovery:
|
||||
http_client: JsonHttpClient | None = None,
|
||||
legacy_http_client: JsonHttpClient | None = None,
|
||||
timeout: int = 30,
|
||||
retries: int = 2,
|
||||
retries: int = 5,
|
||||
page_interval_seconds: float | None = None,
|
||||
page_cache_ttl_seconds: float | None = None,
|
||||
) -> None:
|
||||
token = os.getenv("MODELSCOPE_API_TOKEN") or os.getenv("MODELSCOPE_TOKEN") or EMBEDDED_MODELSCOPE_TOKEN
|
||||
headers = {"User-Agent": "modelhub-submmit-cli/0.1"}
|
||||
@@ -56,6 +59,7 @@ class HuggingFaceDiscovery:
|
||||
default_headers=headers,
|
||||
timeout=timeout,
|
||||
retries=retries,
|
||||
backoff_seconds=2.0,
|
||||
)
|
||||
self.legacy_http_client = legacy_http_client or JsonHttpClient(
|
||||
base_url=base_url,
|
||||
@@ -65,6 +69,24 @@ class HuggingFaceDiscovery:
|
||||
)
|
||||
self._repo_tree_cache: dict[str, list[dict[str, Any]]] = {}
|
||||
self._repo_tree_lock = threading.Lock()
|
||||
self._model_page_cache: dict[tuple[str, int, int], tuple[float, list[dict[str, Any]]]] = {}
|
||||
self._model_page_cache_ttl = max(
|
||||
0.0,
|
||||
float(
|
||||
page_cache_ttl_seconds
|
||||
if page_cache_ttl_seconds is not None
|
||||
else os.getenv("MODELSCOPE_PAGE_CACHE_TTL_SECONDS", "900")
|
||||
),
|
||||
)
|
||||
self._page_interval_seconds = max(
|
||||
0.0,
|
||||
float(
|
||||
page_interval_seconds
|
||||
if page_interval_seconds is not None
|
||||
else os.getenv("MODELSCOPE_PAGE_INTERVAL_SECONDS", "0.25")
|
||||
),
|
||||
)
|
||||
self._last_model_page_request_at = 0.0
|
||||
|
||||
def list_recent_models(
|
||||
self,
|
||||
@@ -111,22 +133,37 @@ class HuggingFaceDiscovery:
|
||||
task_tag = MODELSCOPE_TASK_TAGS.get(pipeline_tag, pipeline_tag)
|
||||
models: list[HFModelSummary] = []
|
||||
for page_number in range(1, (max_items + page_size - 1) // page_size + 1):
|
||||
try:
|
||||
payload = self.http_client.request_json(
|
||||
"GET",
|
||||
"/models",
|
||||
query={
|
||||
"page_number": page_number,
|
||||
"page_size": page_size,
|
||||
"sort": "last_modified",
|
||||
"filter.task": task_tag,
|
||||
},
|
||||
)
|
||||
except HttpJsonError as exc:
|
||||
print(f"[modelscope] list_models_error task={task_tag} page={page_number} error={exc}", flush=True)
|
||||
break
|
||||
cache_key = (task_tag, page_number, page_size)
|
||||
cached = self._model_page_cache.get(cache_key)
|
||||
if cached is not None and time.monotonic() - cached[0] < self._model_page_cache_ttl:
|
||||
items = list(cached[1])
|
||||
else:
|
||||
elapsed = time.monotonic() - self._last_model_page_request_at
|
||||
if self._last_model_page_request_at > 0 and elapsed < self._page_interval_seconds:
|
||||
time.sleep(self._page_interval_seconds - elapsed)
|
||||
try:
|
||||
payload = self.http_client.request_json(
|
||||
"GET",
|
||||
"/models",
|
||||
query={
|
||||
"page_number": page_number,
|
||||
"page_size": page_size,
|
||||
"sort": "last_modified",
|
||||
"filter.task": task_tag,
|
||||
},
|
||||
)
|
||||
self._last_model_page_request_at = time.monotonic()
|
||||
except HttpJsonError as exc:
|
||||
self._last_model_page_request_at = time.monotonic()
|
||||
print(
|
||||
f"[modelscope] list_models_error task={task_tag} page={page_number} "
|
||||
f"partial_models={len(models)} retry_next_cycle=true error={exc}",
|
||||
flush=True,
|
||||
)
|
||||
break
|
||||
|
||||
items = self._extract_models(payload)
|
||||
items = self._extract_models(payload)
|
||||
self._model_page_cache[cache_key] = (time.monotonic(), list(items))
|
||||
if not items:
|
||||
break
|
||||
for item in items:
|
||||
|
||||
@@ -231,23 +231,32 @@ def summarize_records(records: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def is_success(record: dict[str, Any]) -> bool:
|
||||
verify_result = record.get("verifyResult")
|
||||
verify_result = _numeric_verify_result(record.get("verifyResult"))
|
||||
status = str(record.get("status") or "").lower()
|
||||
return verify_result is not None and verify_result > 0 and status == "success"
|
||||
|
||||
|
||||
def is_failure(record: dict[str, Any]) -> bool:
|
||||
verify_result = record.get("verifyResult")
|
||||
verify_result = _numeric_verify_result(record.get("verifyResult"))
|
||||
status = str(record.get("status") or "").lower()
|
||||
if verify_result is not None and verify_result < 0:
|
||||
return True
|
||||
return status in {"failed", "error", "cancelled"}
|
||||
return status in {"failed", "error", "cancelled", "canceled", "rejected", "timeout"}
|
||||
|
||||
|
||||
def _numeric_verify_result(value: Any) -> float | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def classify_failure(record: dict[str, Any]) -> str:
|
||||
status = str(record.get("status") or "").lower()
|
||||
verify_result = record.get("verifyResult")
|
||||
if status in {"failed", "error", "cancelled"}:
|
||||
verify_result = _numeric_verify_result(record.get("verifyResult"))
|
||||
if status in {"failed", "error", "cancelled", "canceled", "rejected", "timeout"}:
|
||||
return "参数/模板问题"
|
||||
if status in WAITING_STATUSES or status in RUNNING_STATUSES or verify_result is None:
|
||||
return "排队中/未知"
|
||||
|
||||
@@ -4,6 +4,8 @@ import json
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from http.client import IncompleteRead, RemoteDisconnected
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
@@ -76,17 +78,34 @@ class JsonHttpClient:
|
||||
except HTTPError as exc:
|
||||
payload = self._decode_error_payload(exc)
|
||||
if exc.code in retryable_statuses and attempt < self.retries:
|
||||
time.sleep(self.backoff_seconds * (attempt + 1))
|
||||
retry_after = exc.headers.get("Retry-After") if exc.headers is not None else None
|
||||
time.sleep(self._retry_delay(attempt, retry_after=retry_after))
|
||||
continue
|
||||
raise HttpJsonError(f"HTTP request failed for {url}", status_code=exc.code, payload=payload) from exc
|
||||
except (URLError, TimeoutError, socket.timeout, RemoteDisconnected, IncompleteRead) as exc:
|
||||
if attempt < self.retries:
|
||||
time.sleep(self.backoff_seconds * (attempt + 1))
|
||||
time.sleep(self._retry_delay(attempt))
|
||||
continue
|
||||
raise HttpJsonError(f"Network request failed for {url}") from exc
|
||||
|
||||
raise HttpJsonError(f"Request exhausted retries for {url}")
|
||||
|
||||
def _retry_delay(self, attempt: int, *, retry_after: str | None = None) -> float:
|
||||
exponential_delay = self.backoff_seconds * (2**attempt)
|
||||
retry_after_delay = 0.0
|
||||
if retry_after:
|
||||
try:
|
||||
retry_after_delay = max(0.0, float(retry_after))
|
||||
except ValueError:
|
||||
try:
|
||||
parsed = parsedate_to_datetime(retry_after)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
retry_after_delay = max(0.0, (parsed - datetime.now(timezone.utc)).total_seconds())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
retry_after_delay = 0.0
|
||||
return min(60.0, max(exponential_delay, retry_after_delay))
|
||||
|
||||
def _build_url(self, path: str, query: dict[str, Any] | None) -> str:
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
url = path
|
||||
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import parse_datetime, runtime_instance_id, utc_now, write_json, write_jsonl
|
||||
from gpu_strategy import DEFAULT_GPU_STRATEGY_PATH, GPUStrategyManager
|
||||
from hf_discovery import HuggingFaceDiscovery
|
||||
from history_stats import (
|
||||
append_ledger_entry,
|
||||
@@ -16,7 +17,7 @@ from history_stats import (
|
||||
load_ledger,
|
||||
update_history_archive,
|
||||
)
|
||||
from modelhub_client import ModelHubAPIError, ModelHubClient, ModelHubClientPool, is_duplicate_submission_error
|
||||
from modelhub_client import DEFAULT_CAPACITY_STATE_PATH, ModelHubAPIError, ModelHubClient, ModelHubClientPool, is_duplicate_submission_error
|
||||
from models import CandidateModel, HFModelSummary, ModelInspection
|
||||
from outcome_tracker import DEFAULT_OUTCOMES_PATH, OutcomeTracker
|
||||
from submission_claims import DEFAULT_CLAIMS_PATH, SubmissionClaimStore, candidate_key, diversify_candidates
|
||||
@@ -44,6 +45,17 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--min-downloads", type=int, default=50, help="Minimum downloads threshold")
|
||||
parser.add_argument("--daily-target", type=int, default=0, help="Daily submission target across all auto runs; 0 means unlimited")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Only write artifacts without creating tasks")
|
||||
parser.add_argument(
|
||||
"--disable-gpu-strategy",
|
||||
action="store_true",
|
||||
help="Disable adaptive 50/30/20 GPU scheduling and keep the legacy candidate order",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gpu-strategy-refresh-submissions",
|
||||
type=int,
|
||||
default=200,
|
||||
help="Recalculate adaptive GPU choices after this many accepted submissions",
|
||||
)
|
||||
parser.add_argument("--skip-outcome-sync", action="store_true", help="Skip outcome sync from ModelHub before scanning")
|
||||
parser.add_argument("--skip-history-archive", action="store_true", help="Skip historical task archive download for this run")
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
@@ -79,6 +91,24 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
parser.add_argument("--history-archive-path", default="history/platform_tasks.jsonl", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--history-archive-limit", type=int, default=5000, help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--gpu-strategy-state-path",
|
||||
default=os.getenv("MODELHUB_GPU_STRATEGY_STATE_PATH", str(DEFAULT_GPU_STRATEGY_PATH)),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--gpu-strategy-recent-window", type=int, default=1000, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--gpu-strategy-min-long-samples", type=int, default=100, help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--capacity-state-path",
|
||||
default=os.getenv("MODELHUB_CAPACITY_STATE_PATH", str(DEFAULT_CAPACITY_STATE_PATH)),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--capacity-probe-interval-cycles",
|
||||
type=int,
|
||||
default=int(os.getenv("MODELHUB_CAPACITY_PROBE_INTERVAL_CYCLES", "3")),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--hf-base-url", default=os.getenv("MODELSCOPE_BASE_URL", "https://modelscope.cn"), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--modelhub-base-url", default=os.getenv("MODELHUB_BASE_URL", "https://modelhub.org.cn"), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--modelhub-token", default=os.getenv("MODELHUB_XC_TOKEN") or os.getenv("XC_TOKEN"), help=argparse.SUPPRESS)
|
||||
@@ -376,7 +406,11 @@ def run_submission(
|
||||
modelhub_tokens = list(getattr(args, "modelhub_tokens", None) or ([] if not args.modelhub_token else [args.modelhub_token]))
|
||||
token_values: list[str | None] = modelhub_tokens or [args.modelhub_token]
|
||||
clients = [ModelHubClient(token=token, base_url=args.modelhub_base_url) for token in token_values]
|
||||
modelhub_client = ModelHubClientPool(clients)
|
||||
modelhub_client = ModelHubClientPool(
|
||||
clients,
|
||||
capacity_probe_interval_cycles=max(0, int(getattr(args, "capacity_probe_interval_cycles", 3) or 0)),
|
||||
capacity_state_path=Path(getattr(args, "capacity_state_path", DEFAULT_CAPACITY_STATE_PATH)),
|
||||
)
|
||||
|
||||
if hasattr(modelhub_client, "begin_cycle"):
|
||||
modelhub_client.begin_cycle()
|
||||
@@ -389,6 +423,13 @@ def run_submission(
|
||||
history_archive_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
outcome_tracker = outcome_tracker or OutcomeTracker(Path(args.outcomes_path))
|
||||
strategy_manager: GPUStrategyManager | None = None
|
||||
strategy_summary: dict[str, Any] = {"enabled": False}
|
||||
strategy_enabled = (
|
||||
not bool(getattr(args, "disable_gpu_strategy", False))
|
||||
and not bool(getattr(args, "gpu", None) or getattr(args, "gpus", None))
|
||||
and len(target_gpus) > 1
|
||||
)
|
||||
synced_count = 0
|
||||
if not getattr(args, "skip_outcome_sync", False):
|
||||
try:
|
||||
@@ -466,6 +507,7 @@ def run_submission(
|
||||
"historyStatsThreshold": getattr(args, "history_stats_threshold", 500),
|
||||
"historyArchivePath": str(history_archive_path),
|
||||
"historyArchiveRecordCount": len(archived_history),
|
||||
"gpuStrategy": strategy_summary,
|
||||
"scanLimit": 0,
|
||||
"scannedModels": 0,
|
||||
"candidateCount": 0,
|
||||
@@ -481,6 +523,16 @@ def run_submission(
|
||||
"outcomeSyncCount": synced_count,
|
||||
}
|
||||
|
||||
if strategy_enabled:
|
||||
strategy_manager = GPUStrategyManager(
|
||||
Path(getattr(args, "gpu_strategy_state_path", DEFAULT_GPU_STRATEGY_PATH)),
|
||||
refresh_submissions=max(1, int(getattr(args, "gpu_strategy_refresh_submissions", 200) or 200)),
|
||||
recent_terminal_window=max(1, int(getattr(args, "gpu_strategy_recent_window", 1000) or 1000)),
|
||||
long_term_min_samples=max(1, int(getattr(args, "gpu_strategy_min_long_samples", 100) or 100)),
|
||||
)
|
||||
strategy_manager.prepare(modelhub_client, supported_gpus=target_gpus, now=now)
|
||||
strategy_summary = strategy_manager.summary()
|
||||
|
||||
if getattr(args, "skip_history_archive", False):
|
||||
archived_history = []
|
||||
else:
|
||||
@@ -561,8 +613,6 @@ def run_submission(
|
||||
skipped.extend(model_skipped)
|
||||
failed.extend(model_failed)
|
||||
|
||||
write_jsonl(run_dir / "candidates.jsonl", candidates)
|
||||
|
||||
submitted: list[dict[str, Any]] = []
|
||||
duplicate_candidates: list[dict[str, Any]] = []
|
||||
target_submit_count = resolve_max_submit_count(
|
||||
@@ -570,8 +620,13 @@ def run_submission(
|
||||
planned_count=len(candidates),
|
||||
remaining_daily_quota=remaining_daily_quota,
|
||||
)
|
||||
if strategy_manager is not None:
|
||||
target_submit_count = min(target_submit_count, strategy_manager.submissions_until_refresh)
|
||||
instance_id = runtime_instance_id()
|
||||
diversified_candidates = diversify_candidates(candidates, instance_id=instance_id)
|
||||
if strategy_manager is not None:
|
||||
diversified_candidates = strategy_manager.order_candidates(diversified_candidates)
|
||||
write_jsonl(run_dir / "candidates.jsonl", diversified_candidates)
|
||||
claim_store: SubmissionClaimStore | None = None
|
||||
attempted_candidates: list[dict[str, Any]] = []
|
||||
submit_workers = 1
|
||||
@@ -698,6 +753,8 @@ def run_submission(
|
||||
|
||||
claim_store.mark_submitted([*batch_submitted_candidates, *batch_duplicate_candidates])
|
||||
claim_store.release(batch_failed_candidates)
|
||||
if strategy_manager is not None:
|
||||
strategy_manager.record_accepted(batch_submitted_candidates)
|
||||
|
||||
if hasattr(modelhub_client, "available_submit_slots") and modelhub_client.available_submit_slots() <= 0:
|
||||
break
|
||||
@@ -706,6 +763,9 @@ def run_submission(
|
||||
write_jsonl(run_dir / "skipped.jsonl", skipped)
|
||||
write_jsonl(run_dir / "failed.jsonl", failed)
|
||||
|
||||
if strategy_manager is not None:
|
||||
strategy_summary = strategy_manager.summary()
|
||||
|
||||
outcome_tracker.save()
|
||||
|
||||
summary = {
|
||||
@@ -723,6 +783,7 @@ def run_submission(
|
||||
"historyStatsThreshold": getattr(args, "history_stats_threshold", 500),
|
||||
"historyArchivePath": str(history_archive_path),
|
||||
"historyArchiveRecordCount": len(archived_history),
|
||||
"gpuStrategy": strategy_summary,
|
||||
"scanLimit": scan_limit,
|
||||
"scannedModels": len(models),
|
||||
"candidateCount": len(candidates),
|
||||
|
||||
@@ -6,9 +6,10 @@ import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import format_modelhub_datetime, parse_datetime, runtime_instance_id
|
||||
from common import format_modelhub_datetime, parse_datetime, read_json, runtime_instance_id, write_json
|
||||
from defaults import EMBEDDED_MODELHUB_XC_TOKEN
|
||||
from http_json import HttpJsonError, JsonHttpClient
|
||||
|
||||
@@ -255,6 +256,8 @@ DUPLICATE_SUBMISSION_MARKERS = (
|
||||
"already in progress",
|
||||
)
|
||||
|
||||
DEFAULT_CAPACITY_STATE_PATH = Path(".modelhub_state/account_capacity.json")
|
||||
|
||||
|
||||
def is_capacity_error(error: ModelHubAPIError) -> bool:
|
||||
if error.code in {409, 429}:
|
||||
@@ -281,12 +284,24 @@ class ModelHubClientPool:
|
||||
active_counts_ttl: float | None = None,
|
||||
reservation_ttl: float | None = None,
|
||||
instance_id: str | None = None,
|
||||
capacity_probe_interval_cycles: int = 3,
|
||||
capacity_probe_cooldown_cycles: int = 3,
|
||||
capacity_state_path: Path | str | None = None,
|
||||
) -> None:
|
||||
if not clients:
|
||||
raise ValueError("At least one ModelHub client is required")
|
||||
self.clients = clients
|
||||
configured_cap = active_task_cap if active_task_cap is not None else os.getenv("MODELHUB_AGENT_ACTIVE_TASK_CAP", "100")
|
||||
self.active_task_cap = max(1, int(configured_cap))
|
||||
self._capacity_state_path = Path(capacity_state_path) if capacity_state_path else None
|
||||
self._account_keys = [self._account_key(client, index) for index, client in enumerate(clients)]
|
||||
self._account_caps = self._load_account_caps(self.active_task_cap)
|
||||
self._capacity_probe_interval_cycles = max(0, int(capacity_probe_interval_cycles))
|
||||
self._capacity_probe_cooldown_cycles = max(1, int(capacity_probe_cooldown_cycles))
|
||||
self._capacity_probe_cycle = 0
|
||||
self._capacity_probe_enabled = False
|
||||
self._capacity_probe_attempted: set[int] = set()
|
||||
self._capacity_probe_cooldown_until: list[int] = [0 for _ in clients]
|
||||
configured_ttl = active_counts_ttl if active_counts_ttl is not None else os.getenv("MODELHUB_AGENT_ACTIVE_COUNTS_TTL_SECONDS", "15")
|
||||
configured_reservation_ttl = (
|
||||
reservation_ttl
|
||||
@@ -310,11 +325,65 @@ class ModelHubClientPool:
|
||||
# Single reader client to avoid fanout on read operations
|
||||
self._reader = clients[0]
|
||||
|
||||
def _safe_count_active_tasks(self, client: ModelHubClient) -> int:
|
||||
@staticmethod
|
||||
def _account_key(client: ModelHubClient, index: int) -> str:
|
||||
token = str(getattr(client, "token", "") or "")
|
||||
identity = token if token else f"account-index:{index}"
|
||||
return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:20]
|
||||
|
||||
def _load_account_caps(self, default_cap: int) -> list[int]:
|
||||
stored: dict[str, Any] = {}
|
||||
if self._capacity_state_path is not None:
|
||||
try:
|
||||
payload = read_json(self._capacity_state_path)
|
||||
if isinstance(payload, dict):
|
||||
stored = payload.get("accounts") or {}
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
return [
|
||||
max(1, int((stored.get(key) or {}).get("knownCap") or default_cap))
|
||||
for key in self._account_keys
|
||||
]
|
||||
|
||||
def _persist_account_caps(self) -> None:
|
||||
if self._capacity_state_path is None:
|
||||
return
|
||||
with self._state_lock:
|
||||
payload = {
|
||||
"version": 1,
|
||||
"updatedAt": datetime.now().astimezone().isoformat(),
|
||||
"accounts": {
|
||||
key: {"accountIndex": index + 1, "knownCap": self._account_caps[index]}
|
||||
for index, key in enumerate(self._account_keys)
|
||||
},
|
||||
}
|
||||
write_json(self._capacity_state_path, payload)
|
||||
|
||||
def configure_capacity_probe(self, cycle_number: int) -> None:
|
||||
cycle_number = max(0, int(cycle_number))
|
||||
with self._state_lock:
|
||||
if cycle_number != self._capacity_probe_cycle:
|
||||
self._capacity_probe_attempted.clear()
|
||||
self._capacity_probe_cycle = cycle_number
|
||||
self._capacity_probe_enabled = (
|
||||
self._capacity_probe_interval_cycles > 0
|
||||
and cycle_number > 0
|
||||
and cycle_number % self._capacity_probe_interval_cycles == 0
|
||||
)
|
||||
|
||||
def account_capacity_limits(self) -> list[int]:
|
||||
with self._state_lock:
|
||||
return list(self._account_caps)
|
||||
|
||||
def capacity_probe_enabled(self) -> bool:
|
||||
with self._state_lock:
|
||||
return self._capacity_probe_enabled
|
||||
|
||||
def _safe_count_active_tasks(self, client: ModelHubClient, max_count: int) -> int:
|
||||
try:
|
||||
return client.count_active_tasks(max_count=self.active_task_cap, page_size=200)
|
||||
return client.count_active_tasks(max_count=max_count, page_size=200)
|
||||
except Exception:
|
||||
return self.active_task_cap
|
||||
return max(0, max_count - 1)
|
||||
|
||||
def _safe_search_by_model_id(self, client: ModelHubClient, model_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -346,8 +415,11 @@ class ModelHubClientPool:
|
||||
if not force and self._counts_are_fresh_locked(now):
|
||||
return
|
||||
|
||||
with self._state_lock:
|
||||
count_limits = [cap + 1 for cap in self._account_caps]
|
||||
|
||||
def _to_indexed_result(index: int, client: ModelHubClient) -> tuple[int, int]:
|
||||
return index, self._safe_count_active_tasks(client)
|
||||
return index, self._safe_count_active_tasks(client, count_limits[index])
|
||||
|
||||
results: list[tuple[int, int]] = []
|
||||
with ThreadPoolExecutor(max_workers=min(len(self.clients), 12)) as executor:
|
||||
@@ -363,10 +435,14 @@ class ModelHubClientPool:
|
||||
results.append((index, self.active_task_cap))
|
||||
|
||||
refreshed_at = time.monotonic()
|
||||
caps_changed = False
|
||||
with self._state_lock:
|
||||
for index, count in results:
|
||||
old_remote_count = self._remote_counts[index]
|
||||
new_remote_count = min(self.active_task_cap, max(0, int(count)))
|
||||
new_remote_count = max(0, int(count))
|
||||
if new_remote_count > self._account_caps[index]:
|
||||
self._account_caps[index] = new_remote_count
|
||||
caps_changed = True
|
||||
acknowledged = max(0, new_remote_count - old_remote_count)
|
||||
completed_reservations = sorted(
|
||||
(
|
||||
@@ -390,12 +466,25 @@ class ModelHubClientPool:
|
||||
self._remote_counts[index] = new_remote_count
|
||||
self._counts_initialized = True
|
||||
self._active_refresh_at = refreshed_at
|
||||
if caps_changed:
|
||||
self._persist_account_caps()
|
||||
|
||||
def _effective_count_locked(self, index: int) -> int:
|
||||
return self._remote_counts[index] + len(self._reservations[index])
|
||||
|
||||
def _counts_snapshot_locked(self) -> list[int]:
|
||||
return [min(self.active_task_cap, self._effective_count_locked(index)) for index in range(len(self.clients))]
|
||||
return [self._effective_count_locked(index) for index in range(len(self.clients))]
|
||||
|
||||
def _probe_slot_count_locked(self) -> int:
|
||||
if not self._capacity_probe_enabled:
|
||||
return 0
|
||||
return sum(
|
||||
1
|
||||
for index in range(len(self.clients))
|
||||
if index not in self._capacity_probe_attempted
|
||||
and self._capacity_probe_cycle >= self._capacity_probe_cooldown_until[index]
|
||||
and self._effective_count_locked(index) >= self._account_caps[index]
|
||||
)
|
||||
|
||||
def active_task_counts(self) -> list[int]:
|
||||
self._refresh_active_counts()
|
||||
@@ -405,10 +494,11 @@ class ModelHubClientPool:
|
||||
def available_submit_slots(self) -> int:
|
||||
self._refresh_active_counts()
|
||||
with self._state_lock:
|
||||
return sum(
|
||||
max(0, self.active_task_cap - self._effective_count_locked(index))
|
||||
normal_slots = sum(
|
||||
max(0, self._account_caps[index] - self._effective_count_locked(index))
|
||||
for index in range(len(self.clients))
|
||||
)
|
||||
return normal_slots if normal_slots > 0 else self._probe_slot_count_locked()
|
||||
|
||||
def list_tasks(self, **kwargs): # noqa: ANN003, ANN001
|
||||
# Default: use only the reader client to avoid fanout amplification.
|
||||
@@ -480,7 +570,7 @@ class ModelHubClientPool:
|
||||
def _reserve_account(self, excluded: set[int]) -> tuple[int, int] | None:
|
||||
with self._state_lock:
|
||||
remaining_by_index = {
|
||||
index: self.active_task_cap - self._effective_count_locked(index)
|
||||
index: self._account_caps[index] - self._effective_count_locked(index)
|
||||
for index in range(len(self.clients))
|
||||
if index not in excluded
|
||||
}
|
||||
@@ -502,6 +592,35 @@ class ModelHubClientPool:
|
||||
}
|
||||
return selected_index, reservation_id
|
||||
|
||||
def _reserve_probe_account(self, excluded: set[int]) -> tuple[int, int] | None:
|
||||
with self._state_lock:
|
||||
if not self._capacity_probe_enabled:
|
||||
return None
|
||||
eligible = [
|
||||
index
|
||||
for index in range(len(self.clients))
|
||||
if index not in excluded
|
||||
and index not in self._capacity_probe_attempted
|
||||
and self._capacity_probe_cycle >= self._capacity_probe_cooldown_until[index]
|
||||
and self._effective_count_locked(index) >= self._account_caps[index]
|
||||
]
|
||||
if not eligible:
|
||||
return None
|
||||
selected_index = min(
|
||||
eligible,
|
||||
key=lambda index: (index - self._selection_cursor) % len(self.clients),
|
||||
)
|
||||
self._selection_cursor = (selected_index + 1) % len(self.clients)
|
||||
self._capacity_probe_attempted.add(selected_index)
|
||||
self._reservation_sequence += 1
|
||||
reservation_id = self._reservation_sequence
|
||||
self._reservations[selected_index][reservation_id] = {
|
||||
"inflight": True,
|
||||
"updated_at": time.monotonic(),
|
||||
"capacity_probe": True,
|
||||
}
|
||||
return selected_index, reservation_id
|
||||
|
||||
def _finish_reservation(self, index: int, reservation_id: int, *, succeeded: bool) -> None:
|
||||
with self._state_lock:
|
||||
reservation = self._reservations[index].get(reservation_id)
|
||||
@@ -513,11 +632,32 @@ class ModelHubClientPool:
|
||||
reservation["inflight"] = False
|
||||
reservation["updated_at"] = time.monotonic()
|
||||
|
||||
def _mark_account_saturated(self, index: int) -> None:
|
||||
def _mark_account_saturated(self, index: int, *, capacity_probe: bool) -> None:
|
||||
cap_changed = False
|
||||
with self._state_lock:
|
||||
self._remote_counts[index] = self.active_task_cap
|
||||
if capacity_probe:
|
||||
self._capacity_probe_cooldown_until[index] = (
|
||||
self._capacity_probe_cycle + self._capacity_probe_cooldown_cycles
|
||||
)
|
||||
else:
|
||||
observed_capacity = max(1, self._effective_count_locked(index))
|
||||
if observed_capacity < self._account_caps[index]:
|
||||
self._account_caps[index] = observed_capacity
|
||||
cap_changed = True
|
||||
self._remote_counts[index] = self._account_caps[index]
|
||||
self._counts_initialized = True
|
||||
self._active_refresh_at = time.monotonic()
|
||||
if cap_changed:
|
||||
self._persist_account_caps()
|
||||
|
||||
def _promote_account_capacity(self, index: int) -> None:
|
||||
with self._state_lock:
|
||||
discovered_cap = max(self._account_caps[index] + 1, self._effective_count_locked(index))
|
||||
if discovered_cap <= self._account_caps[index]:
|
||||
return
|
||||
self._account_caps[index] = discovered_cap
|
||||
self._persist_account_caps()
|
||||
print(f"[capacity] account={index + 1:02d} discovered_cap={discovered_cap}", flush=True)
|
||||
|
||||
def add_task(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
self._refresh_active_counts()
|
||||
@@ -527,6 +667,10 @@ class ModelHubClientPool:
|
||||
|
||||
while True:
|
||||
reservation = self._reserve_account(attempted_accounts)
|
||||
capacity_probe = False
|
||||
if reservation is None:
|
||||
reservation = self._reserve_probe_account(attempted_accounts)
|
||||
capacity_probe = reservation is not None
|
||||
if reservation is None:
|
||||
if not forced_refresh_done:
|
||||
self._refresh_active_counts(force=True)
|
||||
@@ -535,7 +679,7 @@ class ModelHubClientPool:
|
||||
if last_capacity_error is not None:
|
||||
raise last_capacity_error
|
||||
raise ModelHubAPIError(
|
||||
f"当前等待中或运行中的异步模型验证任务数量已达上限({self.active_task_cap})"
|
||||
"当前等待中或运行中的异步模型验证任务数量已达已知上限"
|
||||
)
|
||||
|
||||
selected_index, reservation_id = reservation
|
||||
@@ -548,7 +692,7 @@ class ModelHubClientPool:
|
||||
raise
|
||||
# Another process may have filled this account after our count
|
||||
# refresh. Mark it full locally and immediately try another one.
|
||||
self._mark_account_saturated(selected_index)
|
||||
self._mark_account_saturated(selected_index, capacity_probe=capacity_probe)
|
||||
attempted_accounts.add(selected_index)
|
||||
last_capacity_error = exc
|
||||
continue
|
||||
@@ -557,6 +701,8 @@ class ModelHubClientPool:
|
||||
raise
|
||||
|
||||
self._finish_reservation(selected_index, reservation_id, succeeded=True)
|
||||
if capacity_probe:
|
||||
self._promote_account_capacity(selected_index)
|
||||
return response
|
||||
|
||||
def list_tasks_page(self, **kwargs): # noqa: ANN003, ANN001
|
||||
|
||||
@@ -10,9 +10,10 @@ from typing import Any, Callable
|
||||
|
||||
from common import utc_now, write_json
|
||||
from daily_runner import DEFAULT_DAILY_RUNS_DIR, log, run_daily_batches
|
||||
from gpu_strategy import DEFAULT_GPU_STRATEGY_PATH
|
||||
from hf_discovery import HuggingFaceDiscovery
|
||||
from main import DEFAULT_LEDGER_PATH, DEFAULT_RUNS_DIR, make_run_dir
|
||||
from modelhub_client import ModelHubClient, ModelHubClientPool
|
||||
from modelhub_client import DEFAULT_CAPACITY_STATE_PATH, ModelHubClient, ModelHubClientPool
|
||||
from outcome_tracker import DEFAULT_OUTCOMES_PATH, OutcomeTracker
|
||||
from runner_common import DEFAULT_KEY_PATH, ensure_tokens
|
||||
from submission_claims import DEFAULT_CLAIMS_PATH
|
||||
@@ -58,6 +59,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--skip-outcome-sync", action="store_true", help="Skip outcome sync from ModelHub before scanning")
|
||||
parser.add_argument("--skip-history-archive", action="store_true", help="Skip historical task archive download for this run")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Plan the day without creating tasks")
|
||||
parser.add_argument("--disable-gpu-strategy", action="store_true", help="Disable adaptive 50/30/20 GPU scheduling")
|
||||
parser.add_argument(
|
||||
"--gpu-strategy-refresh-submissions",
|
||||
type=int,
|
||||
default=200,
|
||||
help="Recalculate adaptive GPU choices after this many accepted submissions",
|
||||
)
|
||||
parser.add_argument("--key-path", default=str(DEFAULT_KEY_PATH), help="Path to KEY.md containing MODELSCOPE_TOKEN/XC_TOKEN")
|
||||
parser.add_argument("--runs-dir", default=str(DEFAULT_RUNS_DIR), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--ledger-path", default=str(DEFAULT_LEDGER_PATH), help=argparse.SUPPRESS)
|
||||
@@ -68,6 +76,24 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
parser.add_argument("--history-archive-path", default="history/platform_tasks.jsonl", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--history-archive-limit", type=int, default=5000, help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--gpu-strategy-state-path",
|
||||
default=os.getenv("MODELHUB_GPU_STRATEGY_STATE_PATH", str(DEFAULT_GPU_STRATEGY_PATH)),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--gpu-strategy-recent-window", type=int, default=1000, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--gpu-strategy-min-long-samples", type=int, default=100, help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--capacity-state-path",
|
||||
default=os.getenv("MODELHUB_CAPACITY_STATE_PATH", str(DEFAULT_CAPACITY_STATE_PATH)),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--capacity-probe-interval-cycles",
|
||||
type=int,
|
||||
default=int(os.getenv("MODELHUB_CAPACITY_PROBE_INTERVAL_CYCLES", "3")),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--daily-runs-dir", default=str(DEFAULT_DAILY_RUNS_DIR), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--poll-runs-dir", default=str(DEFAULT_POLL_RUNS_DIR), help=argparse.SUPPRESS)
|
||||
parser.add_argument("--outcomes-path", default=str(DEFAULT_OUTCOMES_PATH), help=argparse.SUPPRESS)
|
||||
@@ -97,7 +123,11 @@ def _build_modelhub_client(base_args: argparse.Namespace) -> ModelHubClientPool:
|
||||
modelhub_tokens = list(getattr(base_args, "modelhub_tokens", None) or ([] if not base_args.modelhub_token else [base_args.modelhub_token]))
|
||||
token_values: list[str | None] = modelhub_tokens or [base_args.modelhub_token]
|
||||
clients = [ModelHubClient(token=token, base_url=base_args.modelhub_base_url) for token in token_values]
|
||||
return ModelHubClientPool(clients)
|
||||
return ModelHubClientPool(
|
||||
clients,
|
||||
capacity_probe_interval_cycles=max(0, int(getattr(base_args, "capacity_probe_interval_cycles", 3) or 0)),
|
||||
capacity_state_path=Path(getattr(base_args, "capacity_state_path", DEFAULT_CAPACITY_STATE_PATH)),
|
||||
)
|
||||
|
||||
|
||||
def run_poll_loop(
|
||||
@@ -140,10 +170,16 @@ def run_poll_loop(
|
||||
break
|
||||
|
||||
cycles += 1
|
||||
if hasattr(modelhub_client, "configure_capacity_probe"):
|
||||
modelhub_client.configure_capacity_probe(cycles)
|
||||
active_counts = modelhub_client.active_task_counts() if hasattr(modelhub_client, "active_task_counts") else []
|
||||
capacity_limits = modelhub_client.account_capacity_limits() if hasattr(modelhub_client, "account_capacity_limits") else []
|
||||
capacity_probe = modelhub_client.capacity_probe_enabled() if hasattr(modelhub_client, "capacity_probe_enabled") else False
|
||||
available_slots = modelhub_client.available_submit_slots() if hasattr(modelhub_client, "available_submit_slots") else None
|
||||
log(
|
||||
f"[poll] cycle={cycles} active_counts={','.join(str(count) for count in active_counts) if active_counts else 'n/a'} "
|
||||
f"capacity_limits={','.join(str(limit) for limit in capacity_limits) if capacity_limits else 'n/a'} "
|
||||
f"capacity_probe={'on' if capacity_probe else 'off'} "
|
||||
f"available_slots={available_slots if available_slots is not None else 'n/a'}"
|
||||
)
|
||||
|
||||
@@ -152,8 +188,10 @@ def run_poll_loop(
|
||||
time.sleep(base_args.poll_interval_seconds)
|
||||
continue
|
||||
|
||||
cycle_args = _make_cycle_args(base_args)
|
||||
cycle_args.capacity_probe_already_configured = True
|
||||
cycle_summary = run_fn(
|
||||
base_args=_make_cycle_args(base_args),
|
||||
base_args=cycle_args,
|
||||
now=utc_now(),
|
||||
hf_discovery=hf_discovery,
|
||||
modelhub_client=modelhub_client,
|
||||
|
||||
@@ -1 +1 @@
|
||||
AGENT_VERSION = "2026.08.02.2"
|
||||
AGENT_VERSION = "2026.08.02.3"
|
||||
|
||||
@@ -14,7 +14,7 @@ if str(PACKAGE_DIR) in sys.path:
|
||||
sys.path.remove(str(PACKAGE_DIR))
|
||||
sys.path.insert(0, str(PACKAGE_DIR))
|
||||
|
||||
from common import read_jsonl # noqa: E402
|
||||
from common import read_json, read_jsonl # noqa: E402
|
||||
from main import build_parser, make_run_dir, run_submission, submit_candidate # noqa: E402
|
||||
from modelhub_client import ModelHubAPIError, ModelHubClientPool, is_duplicate_submission_error # noqa: E402
|
||||
from models import HFModelSummary, ModelInspection # noqa: E402
|
||||
@@ -106,6 +106,54 @@ class DuplicateThenSuccessClient:
|
||||
return {"code": 0, "data": {"id": f"task-{self.calls}"}}
|
||||
|
||||
|
||||
class DynamicCapacityClient:
|
||||
def __init__(self, *, active: int, limit: int, token: str = "dynamic-test") -> None:
|
||||
self.active = active
|
||||
self.limit = limit
|
||||
self.token = token
|
||||
|
||||
def count_active_tasks(self, *, max_count: int, **_kwargs) -> int: # noqa: ANN003
|
||||
return min(self.active, max_count)
|
||||
|
||||
def add_task(self, _payload: dict) -> dict:
|
||||
if self.active >= self.limit:
|
||||
raise ModelHubAPIError("当前等待中或运行中的异步模型验证任务数量已达上限")
|
||||
self.active += 1
|
||||
return {"code": 0, "data": {"id": self.active}}
|
||||
|
||||
|
||||
class AutoStrategyClient:
|
||||
def __init__(self, available: int) -> None:
|
||||
self.available = available
|
||||
self.calls = 0
|
||||
|
||||
@staticmethod
|
||||
def begin_cycle() -> None:
|
||||
return None
|
||||
|
||||
def available_submit_slots(self) -> int:
|
||||
return self.available
|
||||
|
||||
@staticmethod
|
||||
def list_tasks(**_kwargs) -> list[dict]: # noqa: ANN003
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def list_tasks_page(**_kwargs) -> dict: # noqa: ANN003
|
||||
return {"code": 0, "data": {"records": [], "pages": 0}}
|
||||
|
||||
@staticmethod
|
||||
def processed_gpus_for_model(_model_id: str) -> set[str]:
|
||||
return set()
|
||||
|
||||
def add_task(self, _payload: dict) -> dict:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise ModelHubAPIError("模型正在验证中,请勿重复提交")
|
||||
self.available -= 1
|
||||
return {"code": 0, "data": {"id": f"strategy-{self.calls}"}}
|
||||
|
||||
|
||||
def make_candidate(index: int) -> dict:
|
||||
return {
|
||||
"repoId": f"owner/model-{index}",
|
||||
@@ -115,6 +163,42 @@ def make_candidate(index: int) -> dict:
|
||||
|
||||
|
||||
class ClientPoolConcurrencyTests(unittest.TestCase):
|
||||
def test_adaptive_strategy_counts_only_platform_accepted_tasks(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
root = Path(temporary_dir)
|
||||
args = build_parser().parse_args(
|
||||
[
|
||||
"--task-types",
|
||||
"text-generation",
|
||||
"--limit",
|
||||
"4",
|
||||
"--max-scan-models",
|
||||
"4",
|
||||
"--skip-outcome-sync",
|
||||
"--skip-history-archive",
|
||||
]
|
||||
)
|
||||
args.runs_dir = str(root / "runs")
|
||||
args.ledger_path = str(root / "ledger.jsonl")
|
||||
args.outcomes_path = str(root / "outcomes.jsonl")
|
||||
args.claims_path = str(root / "claims.jsonl")
|
||||
args.history_archive_path = str(root / "history.jsonl")
|
||||
args.gpu_strategy_state_path = str(root / "strategy.json")
|
||||
client = AutoStrategyClient(available=3)
|
||||
|
||||
summary = run_submission(
|
||||
args,
|
||||
now=datetime(2026, 1, 1, 12, tzinfo=timezone.utc),
|
||||
hf_discovery=FakeDiscovery(4), # type: ignore[arg-type]
|
||||
modelhub_client=client, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
state = read_json(root / "strategy.json")
|
||||
self.assertEqual(3, summary["submittedCount"])
|
||||
self.assertEqual(1, summary["duplicateCount"])
|
||||
self.assertEqual(3, state["acceptedSinceRefresh"])
|
||||
self.assertEqual(3, sum(state["acceptedByCategory"].values()))
|
||||
|
||||
def test_duplicate_batch_is_replaced_until_available_slots_are_filled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
root = Path(temporary_dir)
|
||||
@@ -231,6 +315,45 @@ class ClientPoolConcurrencyTests(unittest.TestCase):
|
||||
pool.search_by_model_id("owner/model")
|
||||
self.assertEqual(2, client.search_calls)
|
||||
|
||||
def test_capacity_probe_discovers_a_higher_dynamic_account_limit(self) -> None:
|
||||
client = DynamicCapacityClient(active=2, limit=3)
|
||||
pool = ModelHubClientPool(
|
||||
[client], # type: ignore[arg-type]
|
||||
active_task_cap=2,
|
||||
active_counts_ttl=60,
|
||||
capacity_probe_interval_cycles=3,
|
||||
instance_id="capacity-growth-test",
|
||||
)
|
||||
|
||||
pool.configure_capacity_probe(2)
|
||||
self.assertEqual(0, pool.available_submit_slots())
|
||||
pool.configure_capacity_probe(3)
|
||||
self.assertEqual(1, pool.available_submit_slots())
|
||||
pool.add_task({"model": "probe"})
|
||||
|
||||
self.assertEqual([3], pool.account_capacity_limits())
|
||||
self.assertEqual([3], pool.active_task_counts())
|
||||
self.assertEqual(0, pool.available_submit_slots())
|
||||
|
||||
def test_rejected_capacity_probe_keeps_known_limit_and_enters_cooldown(self) -> None:
|
||||
client = DynamicCapacityClient(active=2, limit=2)
|
||||
pool = ModelHubClientPool(
|
||||
[client], # type: ignore[arg-type]
|
||||
active_task_cap=2,
|
||||
active_counts_ttl=60,
|
||||
capacity_probe_interval_cycles=3,
|
||||
capacity_probe_cooldown_cycles=3,
|
||||
instance_id="capacity-rejection-test",
|
||||
)
|
||||
|
||||
pool.configure_capacity_probe(3)
|
||||
self.assertEqual(1, pool.available_submit_slots())
|
||||
with self.assertRaises(ModelHubAPIError):
|
||||
pool.add_task({"model": "probe"})
|
||||
|
||||
self.assertEqual([2], pool.account_capacity_limits())
|
||||
self.assertEqual(0, pool.available_submit_slots())
|
||||
|
||||
|
||||
class ProcessCoordinationTests(unittest.TestCase):
|
||||
def test_duplicate_claims_are_retained_so_next_batch_moves_forward(self) -> None:
|
||||
|
||||
122
tests/test_gpu_strategy.py
Normal file
122
tests/test_gpu_strategy.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PACKAGE_DIR = Path(__file__).resolve().parents[1] / "modelhub_submmit_api"
|
||||
if str(PACKAGE_DIR) in sys.path:
|
||||
sys.path.remove(str(PACKAGE_DIR))
|
||||
sys.path.insert(0, str(PACKAGE_DIR))
|
||||
|
||||
from gpu_strategy import ( # noqa: E402
|
||||
ALL_SUPPORTED,
|
||||
LONG_TERM,
|
||||
RECENT,
|
||||
GPUStrategyManager,
|
||||
build_strategy_snapshot,
|
||||
choose_next_category,
|
||||
)
|
||||
|
||||
|
||||
def make_history(gpu: str, success: int, failure: int, *, start: datetime) -> list[dict]:
|
||||
records: list[dict] = []
|
||||
for index in range(success + failure):
|
||||
passed = index < success
|
||||
records.append(
|
||||
{
|
||||
"taskId": f"{gpu}-{index}",
|
||||
"gpuType": gpu,
|
||||
"status": "success",
|
||||
"verifyResult": 1 if passed else -1,
|
||||
"updateTime": (start + timedelta(seconds=index)).isoformat(),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
class HistoryClient:
|
||||
def __init__(self, tasks: list[dict]) -> None:
|
||||
self.tasks = tasks
|
||||
self.list_calls = 0
|
||||
|
||||
def list_tasks(self, **_kwargs) -> list[dict]: # noqa: ANN003
|
||||
self.list_calls += 1
|
||||
return list(self.tasks)
|
||||
|
||||
|
||||
class GPUStrategyTests(unittest.TestCase):
|
||||
def test_long_term_ranking_rejects_tiny_high_rate_sample(self) -> None:
|
||||
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
supported = ["tiny", "best", "second", "third", "bad"]
|
||||
tasks = [
|
||||
*make_history("tiny", 8, 0, start=start),
|
||||
*make_history("best", 40, 160, start=start),
|
||||
*make_history("second", 30, 270, start=start),
|
||||
*make_history("third", 45, 455, start=start),
|
||||
*make_history("bad", 5, 495, start=start),
|
||||
]
|
||||
|
||||
snapshot = build_strategy_snapshot(
|
||||
tasks,
|
||||
supported_gpus=supported,
|
||||
long_term_min_samples=100,
|
||||
)
|
||||
|
||||
self.assertEqual(["best", "second", "third"], snapshot["longTermGpus"])
|
||||
self.assertNotIn("tiny", snapshot["longTermGpus"])
|
||||
|
||||
def test_weighted_category_planner_is_exact_over_200_accepts(self) -> None:
|
||||
counts = {LONG_TERM: 0, ALL_SUPPORTED: 0, RECENT: 0}
|
||||
for _ in range(200):
|
||||
category = choose_next_category(counts)
|
||||
counts[category] += 1
|
||||
|
||||
self.assertEqual({LONG_TERM: 100, ALL_SUPPORTED: 60, RECENT: 40}, counts)
|
||||
|
||||
def test_candidate_order_uses_50_30_20_for_first_strategy_generation(self) -> None:
|
||||
supported = ["gpu-a", "gpu-b", "gpu-c", "gpu-d"]
|
||||
snapshot = build_strategy_snapshot([], supported_gpus=supported)
|
||||
manager = GPUStrategyManager("unused.json")
|
||||
manager.state = snapshot
|
||||
candidates = [
|
||||
{"repoId": f"owner/model-{model}", "targetGpu": gpu}
|
||||
for model in range(250)
|
||||
for gpu in supported
|
||||
]
|
||||
|
||||
ordered = manager.order_candidates(candidates)
|
||||
categories = Counter(candidate["strategyCategory"] for candidate in ordered[:200])
|
||||
|
||||
self.assertEqual(100, categories[LONG_TERM])
|
||||
self.assertEqual(60, categories[ALL_SUPPORTED])
|
||||
self.assertEqual(40, categories[RECENT])
|
||||
|
||||
def test_refresh_happens_only_after_200_accepted_submissions(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
path = Path(temporary_dir) / "strategy.json"
|
||||
client = HistoryClient([])
|
||||
manager = GPUStrategyManager(path, refresh_submissions=200)
|
||||
manager.prepare(client, supported_gpus=["gpu-a", "gpu-b", "gpu-c"])
|
||||
self.assertEqual(1, client.list_calls)
|
||||
|
||||
manager.record_accepted([{"strategyCategory": LONG_TERM}] * 199)
|
||||
self.assertEqual(1, manager.submissions_until_refresh)
|
||||
manager.prepare(client, supported_gpus=["gpu-a", "gpu-b", "gpu-c"])
|
||||
self.assertEqual(1, client.list_calls)
|
||||
|
||||
manager.record_accepted([{"strategyCategory": RECENT}])
|
||||
self.assertEqual(0, manager.submissions_until_refresh)
|
||||
manager.prepare(client, supported_gpus=["gpu-a", "gpu-b", "gpu-c"])
|
||||
self.assertEqual(2, client.list_calls)
|
||||
self.assertEqual(1, manager.state["generation"])
|
||||
self.assertEqual(200, manager.state["acceptedTotal"])
|
||||
self.assertEqual(0, manager.state["acceptedSinceRefresh"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,6 +11,7 @@ if str(PACKAGE_DIR) in sys.path:
|
||||
sys.path.insert(0, str(PACKAGE_DIR))
|
||||
|
||||
from hf_discovery import HuggingFaceDiscovery # noqa: E402
|
||||
from http_json import HttpJsonError # noqa: E402
|
||||
|
||||
|
||||
class RecordingHttpClient:
|
||||
@@ -22,6 +23,30 @@ class RecordingHttpClient:
|
||||
return {"success": True, "data": {"models": []}}
|
||||
|
||||
|
||||
class RateLimitedPageClient:
|
||||
def __init__(self) -> None:
|
||||
self.page_calls: list[int] = []
|
||||
self.page_two_attempts = 0
|
||||
|
||||
def request_json(self, _method: str, _path: str, *, query: dict) -> dict:
|
||||
page = int(query["page_number"])
|
||||
self.page_calls.append(page)
|
||||
if page == 2:
|
||||
self.page_two_attempts += 1
|
||||
if self.page_two_attempts == 1:
|
||||
raise HttpJsonError("rate limited", status_code=429)
|
||||
start = (page - 1) * 50
|
||||
items = [
|
||||
{
|
||||
"id": f"owner/model-{index}",
|
||||
"downloads": 100,
|
||||
"last_modified": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
for index in range(start, start + 50)
|
||||
]
|
||||
return {"success": True, "data": {"models": items}}
|
||||
|
||||
|
||||
class ModelScopeDiscoveryTests(unittest.TestCase):
|
||||
def test_openapi_page_size_never_exceeds_platform_limit(self) -> None:
|
||||
http_client = RecordingHttpClient()
|
||||
@@ -36,6 +61,29 @@ class ModelScopeDiscoveryTests(unittest.TestCase):
|
||||
self.assertEqual([], models)
|
||||
self.assertEqual(50, http_client.queries[0]["page_size"])
|
||||
|
||||
def test_rate_limited_page_keeps_previous_pages_and_resumes_from_cache(self) -> None:
|
||||
http_client = RateLimitedPageClient()
|
||||
discovery = HuggingFaceDiscovery(
|
||||
http_client=http_client, # type: ignore[arg-type]
|
||||
page_interval_seconds=0,
|
||||
page_cache_ttl_seconds=60,
|
||||
)
|
||||
|
||||
partial = discovery.list_recent_models(
|
||||
pipeline_tags=["text-generation"],
|
||||
limit=100,
|
||||
min_downloads=0,
|
||||
)
|
||||
resumed = discovery.list_recent_models(
|
||||
pipeline_tags=["text-generation"],
|
||||
limit=100,
|
||||
min_downloads=0,
|
||||
)
|
||||
|
||||
self.assertEqual(50, len(partial))
|
||||
self.assertEqual(100, len(resumed))
|
||||
self.assertEqual([1, 2, 2], http_client.page_calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user