mirror of
https://github.com/exo-explore/exo.git
synced 2026-09-08 11:35:40 -04:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ce4e9052 | ||
|
|
4ea5244e85 | ||
|
|
45df74ba98 | ||
|
|
ce37bdceb6 | ||
|
|
e5a1e5dadb | ||
|
|
fa57131374 | ||
|
|
414132ae9c |
No files matched your search
@@ -40,3 +40,4 @@ bench/**/*.json
|
||||
tmp/models
|
||||
/build/exo
|
||||
/.claude/skills
|
||||
/.claude
|
||||
@@ -120,6 +120,81 @@ From .cursorrules:
|
||||
|
||||
Tests use pytest-asyncio with `asyncio_mode = "auto"`. Tests are in `tests/` subdirectories alongside the code they test. The `EXO_TESTS=1` env var is set during tests.
|
||||
|
||||
Integration tests live in `tests/` (root) and are opt-in via `--ignore=tests` in the default pytest addopts. They require an `eco`-managed cluster:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/ -v # constraint-driven host pick
|
||||
uv run pytest tests/ -v --hosts s4 # explicit host override
|
||||
```
|
||||
|
||||
## Benchmarking
|
||||
|
||||
Benchmarks live in `bench/`. The framework is a CLI with subcommands; each benchmark is a small library module under `bench/lib/<name>.py` plus a CLI front-end under `bench/cli/<name>.py`.
|
||||
|
||||
```
|
||||
bench/
|
||||
├── lib/ # composable, typed building blocks
|
||||
│ ├── prompt.py # PromptSizer, load_tokenizer_for_bench
|
||||
│ ├── completion.py # run_one_completion + typed payloads
|
||||
│ ├── session.py # BenchSession (cluster + client + instance)
|
||||
│ ├── results.py # RunMetadata, ResultsBundle, JSON schema
|
||||
│ ├── model_meta.py # HF API: total weights size, max context, layers
|
||||
│ ├── cluster.py # managed_cluster + managed_instance ctx-managers
|
||||
│ └── context_scaling.py # prompt-TPS / decode-TPS vs context-size sweep
|
||||
├── cli/ # CLI subcommands
|
||||
│ ├── _common.py # shared argparse args + SharedOptions
|
||||
│ ├── context_scaling.py # `python -m bench.cli context-scaling …`
|
||||
│ └── __main__.py # subcommand dispatcher
|
||||
└── exo_bench.py, prefill_decode_bench.py
|
||||
# legacy CLI scripts; PromptSizer / run_one_completion
|
||||
# / load_tokenizer_for_bench are re-exports of bench.lib.
|
||||
```
|
||||
|
||||
Run a benchmark:
|
||||
|
||||
```bash
|
||||
# Defaults assume a multi-node, Thunderbolt-connected cluster with tensor
|
||||
# parallelism + JACCL: --sharding Tensor --comm MlxJaccl --thunderbolt a2a.
|
||||
# Memory + disk minimums are auto-derived from HF metadata.
|
||||
uv run python -m bench.cli context-scaling \
|
||||
--model mlx-community/Qwen3-30B-A3B-4bit --nodes 2 --num-steps 32
|
||||
|
||||
# Single-node smoke: opt out of TB / tensor / jaccl
|
||||
uv run python -m bench.cli context-scaling --hosts s4 \
|
||||
--model mlx-community/Llama-3.2-1B-Instruct-4bit --num-steps 4 \
|
||||
--sharding Pipeline --comm MlxRing --thunderbolt none
|
||||
|
||||
# From a TOML config (CLI flags override config values)
|
||||
uv run python -m bench.cli context-scaling \
|
||||
--config bench/configs/context_scaling.example.toml --hosts s4,s9
|
||||
```
|
||||
|
||||
Shared CLI flags (every subcommand inherits these via `bench/cli/_common.py`):
|
||||
- `--config <path>.toml` — load run parameters from a TOML file
|
||||
- `--model`, `--sharding {Pipeline,Tensor}` (default Tensor), `--comm {MlxRing,MlxJaccl}` (default MlxJaccl), `--min-nodes` — placement
|
||||
- `--hosts`, `--nodes` (number of cluster hosts; distinct from `--min-nodes`), `--thunderbolt {a2a,ring,none}` (default a2a), `--chip` — host pool
|
||||
- `--min-memory-gb`, `--max-memory-gb`, `--min-disk-gb`, `--max-disk-gb` (minimums auto-derived from HF model size when not supplied)
|
||||
- `--evict-downloads` (default on; auto-evicts smallest-first when disk is short)
|
||||
- `--cleanup-instance` (default on; deletes the instance on exit)
|
||||
- `--output-dir`, `--tag key=value` (repeatable)
|
||||
|
||||
Run a multi-run campaign from a single TOML file (each `[[runs]]` = its own cluster deploy + bench + teardown; `[defaults]` is shared, per-run keys override; `[plot]` triggers a comparison PNG):
|
||||
|
||||
```bash
|
||||
uv run python -m bench.cli campaign bench/configs/llama-family-smoke.toml
|
||||
```
|
||||
|
||||
Plot any results JSON to a PNG (auto-detects benchmark type from `metadata.benchmark`):
|
||||
|
||||
```bash
|
||||
uv run python -m bench.cli plot bench/results/context_scaling/latest.json
|
||||
uv run python -m bench.cli plot a.json b.json --label-tag operator # multi-run comparison
|
||||
```
|
||||
|
||||
Adding a new benchmark = (1) write a `bench/lib/<name>.py` exposing a typed `run(session, params, bundle)` callable; (2) add a `bench/cli/<name>.py` with `add_subparser(...)` + `run(args) -> Path`; (3) register the imports in `bench/cli/__main__.py`. To enable plotting for the new benchmark, add a `render_<name>(inputs)` function in `bench/lib/plotting.py` and a dispatch entry in `bench/cli/plot.py::run`.
|
||||
|
||||
Results land at `bench/results/<benchmark>/<run_id>.json` (with a `latest.json` symlink alongside) containing metadata (exo SHA, hostname, platform, ISO timestamps, methodology version, user tags), full cluster snapshot, the resolved + derived params, per-step rows, optional cold-control rows, and any derived summaries (e.g. `t_cum_seconds[]` for context-scaling).
|
||||
|
||||
## Dashboard UI Testing & Screenshots
|
||||
|
||||
### Building and Running the Dashboard
|
||||
|
||||
Generated
+39
-3
@@ -916,11 +916,13 @@ dependencies = [
|
||||
"libp2p",
|
||||
"log",
|
||||
"networking",
|
||||
"pidfile-rs",
|
||||
"pin-project",
|
||||
"pyo3",
|
||||
"pyo3-async-runtimes",
|
||||
"pyo3-log",
|
||||
"pyo3-stub-gen",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"util",
|
||||
]
|
||||
@@ -964,6 +966,16 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
|
||||
|
||||
[[package]]
|
||||
name = "flopen"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbfb8b5fbd1f27929f216650081a07b6ceb0741f0542c8c43ff7ef8e93a35a5d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"nix 0.31.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1789,9 +1801,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.178"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libp2p"
|
||||
@@ -2807,6 +2819,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -3060,6 +3084,18 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pidfile-rs"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1a8aa9a30b1b65ef48b333931b80f2324a14e00208eb2b8f5788f1180791bcc"
|
||||
dependencies = [
|
||||
"flopen",
|
||||
"libc",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
@@ -3668,7 +3704,7 @@ dependencies = [
|
||||
"netlink-packet-utils",
|
||||
"netlink-proto",
|
||||
"netlink-sys",
|
||||
"nix",
|
||||
"nix 0.26.4",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -550,6 +550,120 @@ uv run bench/exo_bench.py \
|
||||
|
||||
The tool outputs performance metrics including prompt tokens per second (prompt_tps), generation tokens per second (generation_tps), and peak memory usage for each configuration.
|
||||
|
||||
### Composable benchmarks (CLI)
|
||||
|
||||
For benchmarks that need an `eco`-managed cluster and a stable JSON result format, exo ships a CLI under `bench/cli/`. The CLI handles cluster lifecycle, instance placement, model-metadata resolution (HuggingFace), and result capture; benchmark logic lives in `bench/lib/` so each new benchmark is a small library module + a CLI subcommand.
|
||||
|
||||
**Run the prompt-TPS / decode-TPS vs context-size sweep:**
|
||||
|
||||
The defaults assume a multi-node, Thunderbolt-connected cluster with tensor parallelism + JACCL — the typical exo benchmarking setup:
|
||||
|
||||
```bash
|
||||
# Defaults: --sharding Tensor --comm MlxJaccl --thunderbolt a2a, with
|
||||
# memory/disk minimums auto-derived from the HF model size. eco picks
|
||||
# `--nodes` hosts from its inventory that form a TB clique and satisfy
|
||||
# those constraints.
|
||||
uv run python -m bench.cli context-scaling \
|
||||
--model mlx-community/Qwen3-30B-A3B-4bit --nodes 2 --num-steps 32
|
||||
|
||||
# Pin to specific hosts (defaults still apply for sharding/comm/topology)
|
||||
uv run python -m bench.cli context-scaling --hosts s4,s9 \
|
||||
--model mlx-community/Qwen3-30B-A3B-4bit --num-steps 16
|
||||
|
||||
# Single-node smoke test: explicit single-node placement overrides
|
||||
uv run python -m bench.cli context-scaling --hosts s4 \
|
||||
--model mlx-community/Llama-3.2-1B-Instruct-4bit --num-steps 4 \
|
||||
--sharding Pipeline --comm MlxRing --thunderbolt none
|
||||
|
||||
# Override the auto-derived ramp / cold controls
|
||||
uv run python -m bench.cli context-scaling --hosts s4,s9 --model X \
|
||||
--pp-step 4096 --num-steps 32 --cold-controls 8192,32768,65536,131072
|
||||
|
||||
# Custom output dir + tags
|
||||
uv run python -m bench.cli context-scaling --hosts s4,s9 --model X \
|
||||
--output-dir bench/results/2026-05-10/ --tag operator=$USER --tag run=full
|
||||
|
||||
# Run from a TOML config (CLI flags override values from the file)
|
||||
uv run python -m bench.cli context-scaling \
|
||||
--config bench/configs/context_scaling.example.toml
|
||||
```
|
||||
|
||||
**Shared flags (every benchmark subcommand has these):**
|
||||
|
||||
- `--model` — HuggingFace model id (required)
|
||||
- `--config <path>.toml` — load run parameters from a TOML file
|
||||
- `--sharding {Pipeline,Tensor}` (default **Tensor**) — sharding mode
|
||||
- `--comm {MlxRing,MlxJaccl}` (default **MlxJaccl**) — inter-node comm mode
|
||||
- `--min-nodes N` (default 1) — minimum nodes for the placement
|
||||
- `--hosts s4,s9` — pin to specific hosts; bypasses constraint search
|
||||
- `--nodes N` (default 1) — number of cluster hosts to reserve when `--hosts` is unset (distinct from `--min-nodes`, which controls the model's instance placement)
|
||||
- `--thunderbolt {a2a,ring,none}` (default **a2a**) — required Thunderbolt topology
|
||||
- `--chip "M3 Ultra"` — required chip (substring match; comment to allow any)
|
||||
- `--min-memory-gb`, `--max-memory-gb`, `--min-disk-gb`, `--max-disk-gb` — host RAM / disk constraints. The minimums are auto-derived from the HF model size (×1.30 + 1 GiB for memory, ×1.10 + 1 GiB for disk) when not supplied; explicit values always win.
|
||||
- `--evict-downloads` (default **on**) — auto-evict existing models smallest-first on disk-full; pass `--no-evict-downloads` to keep
|
||||
- `--cleanup-instance` (default **on**) — delete the placed instance after exit; pass `--no-cleanup-instance` to leave it running for debugging
|
||||
- `--output-dir bench/results` — base directory for JSON results (subcommands add their own subfolder)
|
||||
- `--tag key=value` — append to `metadata.tags` (repeatable)
|
||||
|
||||
**Context-scaling-specific flags:**
|
||||
|
||||
- `--num-steps N` — number of equally-spaced ramp points (default 32)
|
||||
- `--pp-step Δ` — explicit Δ in tokens (overrides auto-derivation from `max_position_embeddings`)
|
||||
- `--fraction-of-max F` — when Δ is auto-derived, use `F × max_context` as the upper bound
|
||||
- `--tg` — tokens generated per step (default 64)
|
||||
- `--warmup` — warmup requests at `pp=Δ` (default 1)
|
||||
- `--cold-controls auto` (4 evenly-spaced points across the ramp) **or** `--cold-controls 8192,32768,…` (explicit pp values). Default: no cold controls.
|
||||
|
||||
**Output:** each run writes `bench/results/<benchmark>/<run_id>.json` plus a `latest.json` symlink. The JSON contains metadata (exo SHA, hostname, platform, user tags), the full cluster snapshot at run start, the resolved + derived params, per-step rows, cold-control rows, and derived summaries (`t_cum_seconds`, `control_gaps`).
|
||||
|
||||
**Multi-run campaigns** — `bench campaign` runs a list of bench invocations from a single TOML file. Each `[[runs]]` entry is its own cluster deploy + bench + teardown, with a shared `[defaults]` table for DRY config:
|
||||
|
||||
```toml
|
||||
# bench/configs/llama-family-smoke.toml
|
||||
[defaults]
|
||||
nodes = 4
|
||||
num_steps = 8
|
||||
fraction_of_max = 0.5
|
||||
|
||||
[[runs]]
|
||||
subcommand = "context-scaling"
|
||||
model = "mlx-community/Llama-3.2-3B-Instruct-4bit"
|
||||
[runs.tags]
|
||||
model_short = "llama-3.2-3b-4bit"
|
||||
|
||||
[[runs]]
|
||||
subcommand = "context-scaling"
|
||||
model = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
|
||||
[runs.tags]
|
||||
model_short = "llama-3.1-8b-4bit"
|
||||
|
||||
[plot]
|
||||
label_tag = "model_short"
|
||||
```
|
||||
|
||||
```bash
|
||||
uv run python -m bench.cli campaign bench/configs/llama-family-smoke.toml
|
||||
```
|
||||
|
||||
After all runs finish, an optional `[plot]` table triggers a comparison plot per benchmark group (one PNG per benchmark type with ≥2 runs).
|
||||
|
||||
**Plotting** — `bench plot` renders any results JSON to a 2-panel PNG (prompt_tps + generation_tps vs pp_tokens, cold controls overlaid as 'x' markers):
|
||||
|
||||
```bash
|
||||
# Plot the most recent run next to its JSON
|
||||
uv run python -m bench.cli plot bench/results/context_scaling/latest.json
|
||||
|
||||
# Compare multiple runs (one line per run; legend label = the chosen tag)
|
||||
uv run python -m bench.cli plot run_a.json run_b.json --label-tag operator
|
||||
|
||||
# Custom output path + title
|
||||
uv run python -m bench.cli plot run.json --output /tmp/scaling.png --title "30B 4-node sweep"
|
||||
```
|
||||
|
||||
The benchmark type is detected from each JSON's `metadata.benchmark`, so the same `plot` command will work for future benchmarks once their renderer is registered in `bench/lib/plotting.py`.
|
||||
|
||||
Methodology for the context-scaling benchmark is documented in detail in `bench/lib/context_scaling.py`'s module docstring and in `bench/METHODOLOGY.md`.
|
||||
|
||||
---
|
||||
|
||||
## Hardware Accelerator Support
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""CLI front-ends for bench library benchmarks.
|
||||
|
||||
Each benchmark is a sub-package / module with two pieces:
|
||||
|
||||
- a ``run(...)`` callable in ``bench.lib.<name>`` that does the actual
|
||||
measurement (no argparse, no eco, no I/O)
|
||||
- an ``add_subparser(subparsers)`` helper here that wires CLI args to a
|
||||
handler invoking the lib
|
||||
|
||||
The main entry point dispatches to the requested subcommand:
|
||||
|
||||
uv run python -m bench.cli context-scaling --hosts s4 \\
|
||||
--model mlx-community/Qwen3-30B-A3B-4bit
|
||||
"""
|
||||
@@ -0,0 +1,56 @@
|
||||
"""``python -m bench.cli`` — dispatcher for benchmark subcommands.
|
||||
|
||||
To add a new benchmark:
|
||||
|
||||
1. Implement the methodology in ``bench.lib.<name>`` exposing a typed
|
||||
``run(session, params, bundle)`` callable (no argparse, no eco I/O).
|
||||
2. Implement a ``bench.cli.<name>`` module with an ``add_subparser`` and
|
||||
a ``run(args) -> Path`` handler.
|
||||
3. Add an ``import + add_subparser(subparsers)`` line below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from bench.cli import campaign, context_scaling, plot
|
||||
from bench.cli._common import expand_config_in_argv
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m bench.cli",
|
||||
description=(
|
||||
"Composable, eco-managed benchmarks for exo. "
|
||||
"Pick a subcommand and pass its model / cluster options."
|
||||
),
|
||||
)
|
||||
subparsers = parser.add_subparsers(
|
||||
dest="subcommand",
|
||||
required=True,
|
||||
metavar="SUBCOMMAND",
|
||||
)
|
||||
context_scaling.add_subparser(subparsers)
|
||||
plot.add_subparser(subparsers)
|
||||
campaign.add_subparser(subparsers)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
raw_argv = list(argv if argv is not None else sys.argv[1:])
|
||||
expanded = expand_config_in_argv(raw_argv)
|
||||
args = _build_parser().parse_args(expanded)
|
||||
handler = getattr(args, "handler", None)
|
||||
if not callable(handler):
|
||||
subcommand = getattr(args, "subcommand", "<unknown>")
|
||||
raise SystemExit(f"subcommand {subcommand!r} did not register a handler")
|
||||
cast("Callable[[argparse.Namespace], Path]", handler)(args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Shared CLI argument parsing for the bench command-line interface.
|
||||
|
||||
Every benchmark subcommand inherits the same model / cluster / output
|
||||
arguments via :func:`add_shared_args` and consumes them through
|
||||
:class:`SharedOptions`. argparse's ``Namespace.<attr>`` is fundamentally
|
||||
typed ``Any``; the :func:`get_arg` / :func:`get_arg_optional` helpers are
|
||||
the single boundary where we coerce to typed values.
|
||||
|
||||
A ``--config <path>.toml`` flag lets the caller capture a run definition
|
||||
in a TOML file. :func:`expand_config_in_argv` rewrites argv in place,
|
||||
substituting the config's keys as CLI flags placed *before* any explicit
|
||||
user args so that explicit CLI flags always win.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from exo_tools.cluster import Chip, Thunderbolt
|
||||
from exo_tools.harness import Comm, Sharding
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def get_arg(args: argparse.Namespace, name: str, type_: type[_T]) -> _T:
|
||||
"""Return ``args.<name>``, asserting it's an instance of ``type_``.
|
||||
|
||||
For ``int`` and ``float`` we additionally accept inputs that ``int(.)`` /
|
||||
``float(.)`` would parse, since argparse's ``type=int`` already coerces
|
||||
cleanly on input but post-`set_defaults` callers may pass raw values.
|
||||
"""
|
||||
raw: Any = getattr(args, name) # type: ignore[reportAny]
|
||||
if isinstance(raw, type_):
|
||||
return raw
|
||||
if type_ is int and isinstance(raw, (int, str)):
|
||||
return int(raw) # type: ignore[return-value]
|
||||
if type_ is float and isinstance(raw, (int, float, str)):
|
||||
return float(raw) # type: ignore[return-value]
|
||||
raise TypeError(
|
||||
f"argparse field {name!r} expected {type_.__name__}, got {type(raw).__name__}" # type: ignore[reportUnknownArgumentType]
|
||||
)
|
||||
|
||||
|
||||
def get_arg_optional(args: argparse.Namespace, name: str, type_: type[_T]) -> _T | None:
|
||||
"""Like :func:`get_arg` but allows the field to be missing or None."""
|
||||
raw = getattr(args, name, None)
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, type_):
|
||||
return raw
|
||||
if type_ is int and isinstance(raw, (int, str)):
|
||||
return int(raw) # type: ignore[return-value]
|
||||
if type_ is float and isinstance(raw, (int, float, str)):
|
||||
return float(raw) # type: ignore[return-value]
|
||||
raise TypeError(
|
||||
f"argparse field {name!r} expected {type_.__name__} or None, "
|
||||
f"got {type(raw).__name__}" # type: ignore[reportUnknownArgumentType]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SharedOptions:
|
||||
"""Parsed shared CLI options for any benchmark."""
|
||||
|
||||
model: str
|
||||
hosts: tuple[str, ...]
|
||||
nodes: int
|
||||
thunderbolt: Thunderbolt | None
|
||||
chip: Chip | None
|
||||
min_memory_gb: float | None
|
||||
max_memory_gb: float | None
|
||||
min_disk_gb: float | None
|
||||
max_disk_gb: float | None
|
||||
evict_downloads: bool
|
||||
sharding: Sharding
|
||||
comm: Comm
|
||||
min_nodes: int
|
||||
output_dir: Path
|
||||
tags: dict[str, str]
|
||||
cleanup_instance: bool
|
||||
user_prefix: str
|
||||
|
||||
@classmethod
|
||||
def from_namespace(cls, args: argparse.Namespace) -> SharedOptions:
|
||||
hosts_raw = get_arg_optional(args, "hosts", str)
|
||||
thunderbolt_raw = get_arg_optional(args, "thunderbolt", str)
|
||||
chip_raw = get_arg_optional(args, "chip", str)
|
||||
tag_list_raw: object = getattr(args, "tag", None) or []
|
||||
if isinstance(tag_list_raw, list):
|
||||
tag_list: list[str] = [
|
||||
str(t) # type: ignore[reportUnknownArgumentType]
|
||||
for t in tag_list_raw # type: ignore[reportUnknownVariableType]
|
||||
]
|
||||
else:
|
||||
tag_list = []
|
||||
return cls(
|
||||
model=get_arg(args, "model", str),
|
||||
hosts=tuple(_parse_csv(hosts_raw)) if hosts_raw else (),
|
||||
nodes=get_arg(args, "nodes", int),
|
||||
thunderbolt=Thunderbolt(thunderbolt_raw) if thunderbolt_raw else None,
|
||||
chip=Chip(chip_raw) if chip_raw else None,
|
||||
min_memory_gb=get_arg_optional(args, "min_memory_gb", float),
|
||||
max_memory_gb=get_arg_optional(args, "max_memory_gb", float),
|
||||
min_disk_gb=get_arg_optional(args, "min_disk_gb", float),
|
||||
max_disk_gb=get_arg_optional(args, "max_disk_gb", float),
|
||||
evict_downloads=get_arg(args, "evict_downloads", bool),
|
||||
sharding=Sharding(get_arg(args, "sharding", str)),
|
||||
comm=Comm(get_arg(args, "comm", str)),
|
||||
min_nodes=get_arg(args, "min_nodes", int),
|
||||
output_dir=Path(get_arg(args, "output_dir", str)),
|
||||
tags=_parse_tags(tag_list),
|
||||
cleanup_instance=get_arg(args, "cleanup_instance", bool),
|
||||
user_prefix=get_arg(args, "eco_user_prefix", str),
|
||||
)
|
||||
|
||||
|
||||
def add_shared_args(parser: argparse.ArgumentParser) -> None:
|
||||
"""Register the shared-arg group on ``parser``.
|
||||
|
||||
The bool flags (``--auto-constrain``, ``--evict-downloads``,
|
||||
``--cleanup-instance``) all default to True and use
|
||||
:class:`argparse.BooleanOptionalAction` so callers opt out via the
|
||||
``--no-X`` form (or set ``X = false`` in a TOML config).
|
||||
"""
|
||||
g_config = parser.add_argument_group("config file")
|
||||
g_config.add_argument(
|
||||
"--config",
|
||||
default=None,
|
||||
help="TOML file with run parameters. CLI flags placed after --config "
|
||||
"override values from the file.",
|
||||
)
|
||||
|
||||
g_model = parser.add_argument_group("model")
|
||||
g_model.add_argument(
|
||||
"--model",
|
||||
required=True,
|
||||
help="HuggingFace model id. To run multiple models in one go, use "
|
||||
"the 'campaign' subcommand with a TOML file listing each as a "
|
||||
"separate [[runs]] entry.",
|
||||
)
|
||||
g_model.add_argument(
|
||||
"--sharding",
|
||||
default=Sharding.TENSOR.value,
|
||||
choices=[s.value for s in Sharding],
|
||||
help="Sharding mode for the placed instance. Default 'Tensor' (splits "
|
||||
"layers within nodes; pairs with --comm MlxJaccl for high throughput "
|
||||
"on TB-connected clusters). Use 'Pipeline' for layer-per-node sharding "
|
||||
"(typical for single-node smoke tests).",
|
||||
)
|
||||
g_model.add_argument(
|
||||
"--comm",
|
||||
default=Comm.JACCL.value,
|
||||
choices=[c.value for c in Comm],
|
||||
help="Inter-node communication mode. Default 'MlxJaccl' (RDMA over "
|
||||
"Thunderbolt; pairs with --sharding Tensor and --thunderbolt a2a). "
|
||||
"Use 'MlxRing' for ring all-reduce over the regular network.",
|
||||
)
|
||||
g_model.add_argument("--min-nodes", type=int, default=1)
|
||||
|
||||
g_cluster = parser.add_argument_group("cluster")
|
||||
g_cluster.add_argument(
|
||||
"--hosts",
|
||||
default=None,
|
||||
help="Comma-separated host list (e.g. s4,s9). Bypasses constraint search.",
|
||||
)
|
||||
g_cluster.add_argument(
|
||||
"--nodes",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of cluster nodes (hosts) to deploy on. "
|
||||
"Distinct from --min-nodes which controls the model's instance placement.",
|
||||
)
|
||||
g_cluster.add_argument(
|
||||
"--thunderbolt",
|
||||
default=Thunderbolt.A2A.value,
|
||||
choices=[t.value for t in Thunderbolt],
|
||||
help="Thunderbolt topology required: 'a2a' (clique, default; needed "
|
||||
"for tensor parallelism + JACCL), 'ring' (cycle; for pipeline + JACCL), "
|
||||
"or 'none' (exclude TB-connected hosts; pair with --sharding Pipeline "
|
||||
"--comm MlxRing).",
|
||||
)
|
||||
g_cluster.add_argument(
|
||||
"--chip",
|
||||
default=None,
|
||||
choices=[c.value for c in Chip],
|
||||
help="Chip required (e.g. 'M3 Ultra')",
|
||||
)
|
||||
g_cluster.add_argument(
|
||||
"--min-memory-gb",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Min RAM (GB) on each host. If unset, auto-derived from the HF "
|
||||
"model size (×1.30 + 1 GiB).",
|
||||
)
|
||||
g_cluster.add_argument(
|
||||
"--max-memory-gb",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Max RAM (GB) on each host. Useful to leave bigger machines "
|
||||
"free for other workloads.",
|
||||
)
|
||||
g_cluster.add_argument(
|
||||
"--min-disk-gb",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Min free disk (GB) on each host. If unset, auto-derived from "
|
||||
"the HF model size (×1.10 + 1 GiB).",
|
||||
)
|
||||
g_cluster.add_argument(
|
||||
"--max-disk-gb",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Max disk (GB) on each host.",
|
||||
)
|
||||
|
||||
g_runtime = parser.add_argument_group("runtime")
|
||||
g_runtime.add_argument(
|
||||
"--evict-downloads",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="Auto-evict existing models (smallest first) when disk is short to "
|
||||
"make room for the bench model. Default on; pass --no-evict-downloads "
|
||||
"to keep existing downloads.",
|
||||
)
|
||||
g_runtime.add_argument(
|
||||
"--cleanup-instance",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="Clean up the placed instance after the benchmark exits. "
|
||||
"Default on; pass --no-cleanup-instance to leave it running for debugging.",
|
||||
)
|
||||
g_runtime.add_argument(
|
||||
"--eco-user-prefix",
|
||||
default="bench",
|
||||
help="USER prefix for the eco session (default: 'bench').",
|
||||
)
|
||||
|
||||
g_output = parser.add_argument_group("output")
|
||||
g_output.add_argument(
|
||||
"--output-dir",
|
||||
default="bench/results",
|
||||
help="Base directory for JSON results. Subcommands may add a sub-folder.",
|
||||
)
|
||||
g_output.add_argument(
|
||||
"--tag",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Add a 'key=value' tag to metadata.tags (repeatable).",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TOML config expansion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def expand_config_in_argv(argv: list[str]) -> list[str]:
|
||||
"""If ``--config <path>`` appears in ``argv``, splice the TOML's contents in.
|
||||
|
||||
The TOML file's keys are converted to CLI flags (``foo_bar`` →
|
||||
``--foo-bar``) and inserted *before* the user's other args, so explicit
|
||||
CLI flags always override the config. The ``--config <path>`` pair
|
||||
itself is removed from argv. The first arg (the subcommand name) is
|
||||
preserved at index 0.
|
||||
|
||||
Special handling:
|
||||
- ``[tags]`` table → repeated ``--tag key=value`` occurrences
|
||||
- lists → joined as a comma-separated value (matches the parser's
|
||||
CSV handling for ``--hosts``)
|
||||
- bool true/false → ``--key`` / ``--no-key`` (assumes the underlying
|
||||
flag uses :class:`argparse.BooleanOptionalAction`)
|
||||
"""
|
||||
if "--config" not in argv:
|
||||
return list(argv)
|
||||
|
||||
idx = argv.index("--config")
|
||||
if idx + 1 >= len(argv):
|
||||
raise ValueError("--config requires a path argument")
|
||||
config_path = Path(argv[idx + 1])
|
||||
if not config_path.is_file():
|
||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||
|
||||
with config_path.open("rb") as f:
|
||||
config_data: dict[str, Any] = tomllib.load(f)
|
||||
|
||||
expanded = _config_to_argv(config_data)
|
||||
stripped = list(argv[:idx]) + list(argv[idx + 2 :])
|
||||
if not stripped:
|
||||
return expanded
|
||||
# The subcommand name must come first; insert config-derived args
|
||||
# right after it so that the user's later explicit args override.
|
||||
return [stripped[0]] + expanded + stripped[1:]
|
||||
|
||||
|
||||
def _config_to_argv(data: dict[str, Any]) -> list[str]:
|
||||
"""Convert a TOML-loaded dict to a list of argv-style CLI flags."""
|
||||
out: list[str] = []
|
||||
for key in data:
|
||||
value: Any = data[key] # type: ignore[reportAny]
|
||||
if key == "tags" and isinstance(value, dict):
|
||||
for tag_key, tag_value in value.items(): # type: ignore[reportUnknownVariableType]
|
||||
out.extend(["--tag", f"{tag_key}={tag_value}"])
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
flag = "--" + key.replace("_", "-")
|
||||
if isinstance(value, bool):
|
||||
out.append(flag if value else f"--no-{key.replace('_', '-')}")
|
||||
elif isinstance(value, list):
|
||||
joined = ",".join(
|
||||
str(x) # type: ignore[reportUnknownArgumentType]
|
||||
for x in value # type: ignore[reportUnknownVariableType]
|
||||
)
|
||||
out.extend([flag, joined])
|
||||
else:
|
||||
out.extend([flag, str(value)]) # type: ignore[reportAny]
|
||||
return out
|
||||
|
||||
|
||||
def _parse_csv(raw: str) -> list[str]:
|
||||
return [s.strip() for s in raw.split(",") if s.strip()]
|
||||
|
||||
|
||||
def _parse_tags(raw: list[str]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for entry in raw:
|
||||
if "=" not in entry:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"--tag must be 'key=value', got {entry!r}"
|
||||
)
|
||||
k, v = entry.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Return value from a benchmark CLI handler."""
|
||||
|
||||
output_path: Path | None = None
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Run a campaign of bench invocations from a single TOML file.
|
||||
|
||||
A campaign config has a ``[defaults]`` table (applied to every run) and a
|
||||
list of ``[[runs]]`` entries (each a fully-formed invocation with its own
|
||||
``subcommand``). The campaign runner merges defaults with each run's
|
||||
overrides, dispatches to the matching subcommand handler, and collects
|
||||
the output JSON paths.
|
||||
|
||||
Each run gets its own cluster — the deploy / teardown happens per-run.
|
||||
After all runs finish, an optional ``[plot]`` table triggers a comparison
|
||||
plot per benchmark group.
|
||||
|
||||
Schema::
|
||||
|
||||
[defaults]
|
||||
nodes = 4
|
||||
num_steps = 8
|
||||
|
||||
[[runs]]
|
||||
subcommand = "context-scaling"
|
||||
model = "mlx-community/Llama-3.2-3B-Instruct-4bit"
|
||||
[runs.tags]
|
||||
model_short = "llama-3.2-3b"
|
||||
|
||||
[[runs]]
|
||||
subcommand = "context-scaling"
|
||||
model = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
|
||||
[runs.tags]
|
||||
model_short = "llama-3.1-8b"
|
||||
|
||||
[plot]
|
||||
label_tag = "model_short"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import tomllib
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from bench.cli import context_scaling
|
||||
from bench.cli._common import (
|
||||
_config_to_argv, # type: ignore[reportPrivateUsage]
|
||||
get_arg,
|
||||
)
|
||||
from bench.lib.plotting import PlotInputs, render_context_scaling
|
||||
|
||||
# Each subcommand exposes its argparse via add_subparser. The campaign
|
||||
# runner builds a one-off parser per run with only the chosen subcommand
|
||||
# registered, parses the run-derived argv, and invokes the handler.
|
||||
_SUBCOMMAND_PARSERS: dict[
|
||||
str,
|
||||
Callable[
|
||||
[Any], None
|
||||
], # subparsers action — argparse private; Any-typed at boundary
|
||||
] = {
|
||||
"context-scaling": context_scaling.add_subparser,
|
||||
}
|
||||
|
||||
|
||||
def add_subparser(
|
||||
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # type: ignore[type-arg]
|
||||
) -> None:
|
||||
parser = subparsers.add_parser(
|
||||
"campaign",
|
||||
help="Run a list of bench invocations from a single TOML config.",
|
||||
description=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"config",
|
||||
type=str,
|
||||
help="TOML campaign file (with [defaults] + [[runs]] tables).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-plot",
|
||||
action="store_true",
|
||||
help="Skip the optional comparison plot at the end of the campaign.",
|
||||
)
|
||||
parser.set_defaults(handler=run)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> Path | None:
|
||||
config_path = Path(get_arg(args, "config", str))
|
||||
if not config_path.is_file():
|
||||
raise SystemExit(f"campaign: file not found: {config_path}")
|
||||
|
||||
with config_path.open("rb") as f:
|
||||
raw = tomllib.load(f)
|
||||
|
||||
defaults = _table(raw, "defaults")
|
||||
runs_obj = raw.get("runs")
|
||||
if not isinstance(runs_obj, list) or not runs_obj:
|
||||
raise SystemExit(f"campaign: {config_path}: missing or empty [[runs]] list")
|
||||
runs_raw: list[Any] = cast("list[Any]", runs_obj)
|
||||
plot_cfg = _table(raw, "plot")
|
||||
|
||||
n_runs = len(runs_raw)
|
||||
output_paths: dict[str, list[Path]] = {}
|
||||
for i, run_obj in enumerate(runs_raw): # type: ignore[reportAny]
|
||||
if not isinstance(run_obj, dict):
|
||||
raise SystemExit(
|
||||
f"campaign: run #{i + 1}: expected a TOML table, "
|
||||
f"got {type(run_obj).__name__}" # type: ignore[reportUnknownArgumentType]
|
||||
)
|
||||
run_cfg = cast("dict[str, Any]", run_obj)
|
||||
|
||||
merged = _merge(defaults, run_cfg)
|
||||
subcommand_obj: Any = merged.pop("subcommand", None) # type: ignore[reportAny]
|
||||
if not isinstance(subcommand_obj, str):
|
||||
raise SystemExit(
|
||||
f"campaign: run #{i + 1}: 'subcommand' field is required (str)"
|
||||
)
|
||||
if subcommand_obj not in _SUBCOMMAND_PARSERS:
|
||||
raise SystemExit(
|
||||
f"campaign: run #{i + 1}: unknown subcommand "
|
||||
f"{subcommand_obj!r} (have {sorted(_SUBCOMMAND_PARSERS)})"
|
||||
)
|
||||
|
||||
argv_for_run = _config_to_argv(merged)
|
||||
sub_args = _parse_for_subcommand(subcommand_obj, argv_for_run)
|
||||
handler = getattr(sub_args, "handler", None)
|
||||
if not callable(handler):
|
||||
raise SystemExit(f"campaign: subcommand {subcommand_obj!r} has no handler")
|
||||
|
||||
logger.info(
|
||||
f"campaign: starting run {i + 1}/{n_runs} "
|
||||
f"({subcommand_obj}; {len(merged)} flags)"
|
||||
)
|
||||
out = cast("Callable[[argparse.Namespace], Path]", handler)(sub_args)
|
||||
output_paths.setdefault(subcommand_obj, []).append(Path(out))
|
||||
logger.info(f"campaign: finished run {i + 1}/{n_runs} → {out}")
|
||||
|
||||
last_path: Path | None = None
|
||||
for paths in output_paths.values():
|
||||
if paths:
|
||||
last_path = paths[-1]
|
||||
|
||||
if get_arg(args, "no_plot", bool):
|
||||
return last_path
|
||||
|
||||
comparison = _render_comparisons(output_paths, plot_cfg)
|
||||
return comparison or last_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _table(data: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
"""Return ``data[key]`` if it's a table, else an empty dict."""
|
||||
val: Any = data.get(key)
|
||||
return cast("dict[str, Any]", val) if isinstance(val, dict) else {}
|
||||
|
||||
|
||||
def _merge(defaults: dict[str, Any], run: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Shallow-merge ``defaults`` with ``run``; ``run`` wins on conflict.
|
||||
|
||||
The ``tags`` table is deep-merged (defaults' tags + run's tags) so a
|
||||
campaign-level operator tag and a per-run model_short tag both survive.
|
||||
"""
|
||||
merged: dict[str, Any] = {**defaults, **run}
|
||||
default_tags = _table(defaults, "tags")
|
||||
run_tags = _table(run, "tags")
|
||||
if default_tags or run_tags:
|
||||
merged["tags"] = {**default_tags, **run_tags}
|
||||
return merged
|
||||
|
||||
|
||||
def _parse_for_subcommand(
|
||||
subcommand: str, argv_for_run: list[str]
|
||||
) -> argparse.Namespace:
|
||||
"""Build a one-off parser with ``subcommand`` registered + parse argv."""
|
||||
parser = argparse.ArgumentParser(prog=f"bench campaign:{subcommand}")
|
||||
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
||||
_SUBCOMMAND_PARSERS[subcommand](subparsers)
|
||||
return parser.parse_args([subcommand] + argv_for_run)
|
||||
|
||||
|
||||
def _render_comparisons(
|
||||
output_paths: dict[str, list[Path]],
|
||||
plot_cfg: dict[str, Any],
|
||||
) -> Path | None:
|
||||
"""Render one comparison plot per benchmark group with ≥2 outputs."""
|
||||
label_tag = _str_or_none(plot_cfg.get("label_tag"))
|
||||
title = _str_or_none(plot_cfg.get("title"))
|
||||
|
||||
last: Path | None = None
|
||||
for subcommand, paths in output_paths.items():
|
||||
if len(paths) < 2:
|
||||
continue
|
||||
if subcommand != "context-scaling":
|
||||
logger.warning(
|
||||
f"campaign: no comparison renderer registered for {subcommand!r}; "
|
||||
"skipping comparison plot"
|
||||
)
|
||||
continue
|
||||
out = paths[0].with_name(f"campaign_{subcommand}_compare.png")
|
||||
last = render_context_scaling(
|
||||
PlotInputs(
|
||||
results=paths,
|
||||
output=out,
|
||||
label_tag=label_tag,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
logger.info(f"campaign: wrote comparison plot {last}")
|
||||
return last
|
||||
|
||||
|
||||
def _str_or_none(value: Any) -> str | None: # type: ignore[reportAny]
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
__all__ = ["add_subparser", "run"]
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Context-scaling benchmark — CLI subcommand.
|
||||
|
||||
Wraps :func:`bench.lib.context_scaling.run` with:
|
||||
|
||||
- HF model-metadata resolution
|
||||
- Auto-derived constraints (memory, disk) and context ramp (Δ, K)
|
||||
- eco cluster + instance lifecycle (managed_cluster + managed_instance)
|
||||
- Cold-control isolation (delete sweep instance before controls)
|
||||
- JSON results + ``latest.json`` symlink under ``<output-dir>/context_scaling/``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from exo_tools.cluster import EcoSession
|
||||
from loguru import logger
|
||||
|
||||
from bench.cli._common import (
|
||||
SharedOptions,
|
||||
add_shared_args,
|
||||
get_arg,
|
||||
get_arg_optional,
|
||||
)
|
||||
from bench.lib import context_scaling
|
||||
from bench.lib.cluster import managed_cluster, managed_instance
|
||||
from bench.lib.context_scaling import (
|
||||
ContextScalingParams,
|
||||
make_cold_control_factory,
|
||||
)
|
||||
from bench.lib.model_meta import (
|
||||
ModelMeta,
|
||||
derive_cold_controls,
|
||||
derive_context_ramp,
|
||||
fetch_model_meta,
|
||||
)
|
||||
from bench.lib.results import ResultsBundle, RunMetadata, find_repo_root
|
||||
|
||||
|
||||
def add_subparser(
|
||||
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # type: ignore[type-arg]
|
||||
) -> None:
|
||||
parser = subparsers.add_parser(
|
||||
"context-scaling",
|
||||
help="Prompt-TPS / decode-TPS vs context-size sweep",
|
||||
description=__doc__,
|
||||
)
|
||||
add_shared_args(parser)
|
||||
g = parser.add_argument_group("context-scaling")
|
||||
g.add_argument(
|
||||
"--num-steps",
|
||||
type=int,
|
||||
default=32,
|
||||
help="Number of equally-spaced PP points in the ramp (K).",
|
||||
)
|
||||
g.add_argument(
|
||||
"--pp-step",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Δ (token step). If unset, derived from the model's max context.",
|
||||
)
|
||||
g.add_argument(
|
||||
"--fraction-of-max",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="When Δ is auto-derived, use this fraction of the model's "
|
||||
"max_position_embeddings as the ramp's upper bound (0 < f ≤ 1).",
|
||||
)
|
||||
g.add_argument(
|
||||
"--tg",
|
||||
type=int,
|
||||
default=64,
|
||||
help="Tokens to generate per step (decode duration; constant across ramp).",
|
||||
)
|
||||
g.add_argument(
|
||||
"--warmup",
|
||||
type=int,
|
||||
default=2,
|
||||
help="Warmup requests at pp=Δ before the measured ramp. "
|
||||
"First warmup is cache-disabled (kernel JIT only); subsequent "
|
||||
"warmups are cache-enabled (the second is the one that primes "
|
||||
"the cache entry with a hot-kernel rate). Default 2 is the "
|
||||
"sweet spot: warmup=0 leaves JIT cost in step 0; warmup=1 has "
|
||||
"step 0 as a 'none' hit (still hot-kernel cold prefill, just "
|
||||
"classified differently).",
|
||||
)
|
||||
g.add_argument(
|
||||
"--cold-controls",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Cold-control pp values to take after the cached sweep. Either "
|
||||
"'auto' (4 evenly-spaced points across the ramp) or a comma-separated "
|
||||
"list of explicit pp values (e.g. '8192,32768,65536'). "
|
||||
"Default: no cold controls.",
|
||||
)
|
||||
g.add_argument(
|
||||
"--sleep-between-s",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Seconds to sleep between consecutive sweep requests.",
|
||||
)
|
||||
parser.set_defaults(handler=run)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> Path:
|
||||
"""Execute the context-scaling benchmark per the parsed args.
|
||||
|
||||
Returns the path of the JSON results file.
|
||||
"""
|
||||
shared = SharedOptions.from_namespace(args)
|
||||
repo_root = find_repo_root()
|
||||
|
||||
# 1. Fetch HF metadata up-front; everything else can be derived from it.
|
||||
logger.info(f"fetching HuggingFace metadata for {shared.model}")
|
||||
meta = fetch_model_meta(shared.model)
|
||||
logger.info(
|
||||
f" weights: {meta.total_weight_gb:.1f}GB; "
|
||||
f"max context: {meta.max_position_embeddings} tokens; "
|
||||
f"layers: {meta.num_hidden_layers}"
|
||||
)
|
||||
|
||||
# 2. Derive constraints (user values always win; otherwise fall back to
|
||||
# ModelMeta heuristics for the *minimums*).
|
||||
min_memory_gb = (
|
||||
shared.min_memory_gb
|
||||
if shared.min_memory_gb is not None
|
||||
else meta.memory_constraint_gb
|
||||
)
|
||||
min_disk_gb = (
|
||||
shared.min_disk_gb
|
||||
if shared.min_disk_gb is not None
|
||||
else meta.disk_constraint_gb
|
||||
)
|
||||
logger.info(f" cluster constraint: min memory {min_memory_gb:.1f}GB")
|
||||
logger.info(f" cluster constraint: min disk {min_disk_gb:.1f}GB")
|
||||
if shared.max_memory_gb is not None:
|
||||
logger.info(f" cluster constraint: max memory {shared.max_memory_gb:.1f}GB")
|
||||
if shared.max_disk_gb is not None:
|
||||
logger.info(f" cluster constraint: max disk {shared.max_disk_gb:.1f}GB")
|
||||
|
||||
explicit_pp_step = get_arg_optional(args, "pp_step", int)
|
||||
num_steps = get_arg(args, "num_steps", int)
|
||||
if explicit_pp_step is not None:
|
||||
pp_step = explicit_pp_step
|
||||
else:
|
||||
pp_step, num_steps = derive_context_ramp(
|
||||
meta,
|
||||
num_steps=num_steps,
|
||||
fraction_of_max=get_arg(args, "fraction_of_max", float),
|
||||
)
|
||||
logger.info(
|
||||
f" derived ramp: Δ={pp_step} × K={num_steps} "
|
||||
f"= {pp_step * num_steps} tokens (max {meta.max_position_embeddings})"
|
||||
)
|
||||
|
||||
cold_controls = _resolve_cold_controls(
|
||||
args, meta, pp_step=pp_step, num_steps=num_steps
|
||||
)
|
||||
if cold_controls:
|
||||
logger.info(f" cold controls: {list(cold_controls)}")
|
||||
|
||||
# 3. Spin up cluster + instance + run.
|
||||
eco = EcoSession(user_prefix=shared.user_prefix)
|
||||
output_dir = (shared.output_dir / "context_scaling").resolve()
|
||||
metadata = RunMetadata.new(
|
||||
benchmark="context_scaling",
|
||||
repo_root=repo_root,
|
||||
tags={**shared.tags, "host_pool": ",".join(shared.hosts) or "<auto>"},
|
||||
)
|
||||
bundle = ResultsBundle(metadata=metadata)
|
||||
|
||||
with (
|
||||
managed_cluster(
|
||||
eco,
|
||||
hosts=list(shared.hosts) or None,
|
||||
count=shared.nodes,
|
||||
thunderbolt=shared.thunderbolt,
|
||||
chip=shared.chip,
|
||||
min_memory_gb=min_memory_gb,
|
||||
max_memory_gb=shared.max_memory_gb,
|
||||
min_disk_gb=min_disk_gb,
|
||||
max_disk_gb=shared.max_disk_gb,
|
||||
) as cluster,
|
||||
managed_instance(
|
||||
cluster,
|
||||
eco,
|
||||
shared.model,
|
||||
sharding=shared.sharding,
|
||||
comm=shared.comm,
|
||||
min_nodes=shared.min_nodes,
|
||||
evict_downloads=shared.evict_downloads,
|
||||
cleanup_on_exit=shared.cleanup_instance,
|
||||
) as session,
|
||||
):
|
||||
params = ContextScalingParams(
|
||||
pp_step=pp_step,
|
||||
num_steps=num_steps,
|
||||
tg=get_arg(args, "tg", int),
|
||||
warmup=get_arg(args, "warmup", int),
|
||||
cold_controls=cold_controls,
|
||||
sleep_between_s=get_arg(args, "sleep_between_s", float),
|
||||
)
|
||||
factory = (
|
||||
make_cold_control_factory(
|
||||
session, shared.sharding, shared.comm, shared.min_nodes
|
||||
)
|
||||
if cold_controls
|
||||
else None
|
||||
)
|
||||
context_scaling.run(session, params, bundle, cold_control_factory=factory)
|
||||
|
||||
out_path = bundle.write_json(output_dir)
|
||||
_update_latest_symlink(out_path)
|
||||
logger.info(f"wrote results → {out_path}")
|
||||
|
||||
_validate_partial_hits(bundle)
|
||||
return out_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_cold_controls(
|
||||
args: argparse.Namespace,
|
||||
meta: ModelMeta,
|
||||
*,
|
||||
pp_step: int,
|
||||
num_steps: int,
|
||||
) -> tuple[int, ...]:
|
||||
raw = get_arg_optional(args, "cold_controls", str)
|
||||
if raw is None or not raw.strip():
|
||||
return ()
|
||||
if raw.strip().lower() == "auto":
|
||||
return derive_cold_controls(meta, pp_step=pp_step, num_steps=num_steps, count=4)
|
||||
return tuple(int(s.strip()) for s in raw.split(",") if s.strip())
|
||||
|
||||
|
||||
def _update_latest_symlink(out_path: Path) -> None:
|
||||
"""Update ``<dir>/latest.json`` to point at the newly-written file."""
|
||||
link = out_path.parent / "latest.json"
|
||||
try:
|
||||
if link.is_symlink() or link.exists():
|
||||
link.unlink()
|
||||
os.symlink(out_path.name, link)
|
||||
except OSError as e:
|
||||
logger.warning(f"could not update latest.json symlink: {e}")
|
||||
|
||||
|
||||
def _validate_partial_hits(bundle: ResultsBundle) -> None:
|
||||
"""Hard-fail if the cached sweep didn't see ``partial`` on every step ≥ 1.
|
||||
|
||||
Step 0 is allowed to be ``exact`` (warmup primed the cache at pp=Δ); a
|
||||
later ``exact`` means Δ was effectively absorbed into the cache and the
|
||||
cold-rate measurement is meaningless. ``none`` means the cache was
|
||||
discarded mid-sweep and ``T_cum`` is unreliable.
|
||||
"""
|
||||
cached = [r for r in bundle.runs if r.get("phase") == "cached_sweep"]
|
||||
bad = [r for r in cached[1:] if r.get("prefix_cache_hit") != "partial"]
|
||||
if bad:
|
||||
bad_summary = [(r["step_index"], r["prefix_cache_hit"]) for r in bad]
|
||||
raise RuntimeError(
|
||||
f"{len(bad)} cached-sweep step(s) reported "
|
||||
f"prefix_cache_hit != 'partial': {bad_summary!r}; "
|
||||
"T_cum is unreliable."
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Plot benchmark results — CLI subcommand.
|
||||
|
||||
uv run python -m bench.cli plot bench/results/context_scaling/latest.json
|
||||
uv run python -m bench.cli plot run_a.json run_b.json --label-tag operator
|
||||
uv run python -m bench.cli plot latest.json --output /tmp/scaling.png
|
||||
|
||||
The benchmark type is detected from each JSON's ``metadata.benchmark`` —
|
||||
all input files must share the same benchmark.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from bench.cli._common import get_arg_optional
|
||||
from bench.lib.plotting import PlotInputs, render_context_scaling
|
||||
|
||||
|
||||
def add_subparser(
|
||||
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # type: ignore[type-arg]
|
||||
) -> None:
|
||||
parser = subparsers.add_parser(
|
||||
"plot",
|
||||
help="Render benchmark JSON result(s) as a PNG.",
|
||||
description=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"paths",
|
||||
nargs="+",
|
||||
type=str,
|
||||
help="One or more bench results JSON files. Multiple files are "
|
||||
"rendered as a comparison plot (one line per file).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default=None,
|
||||
help="PNG output path. Default: replace the first JSON's '.json' "
|
||||
"suffix with '.png' (or '.compare.png' when multiple inputs).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--label-tag",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Use metadata.tags[<KEY>] as the legend label for each run "
|
||||
"(falls back to run_id if unset or missing).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--title",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override the auto-generated figure title.",
|
||||
)
|
||||
parser.set_defaults(handler=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> Path:
|
||||
paths_raw = getattr(args, "paths", None)
|
||||
if not isinstance(paths_raw, list) or not paths_raw:
|
||||
raise SystemExit("plot: at least one JSON path is required")
|
||||
paths = [
|
||||
Path(str(p)) # type: ignore[reportUnknownArgumentType]
|
||||
for p in cast("list[Any]", paths_raw) # type: ignore[reportAny]
|
||||
]
|
||||
for p in paths:
|
||||
if not p.is_file():
|
||||
raise SystemExit(f"plot: file not found: {p}")
|
||||
|
||||
benchmarks = {_benchmark_for(p) for p in paths}
|
||||
if len(benchmarks) != 1:
|
||||
raise SystemExit(
|
||||
f"plot: all input JSONs must share the same benchmark, got {benchmarks!r}"
|
||||
)
|
||||
benchmark = next(iter(benchmarks))
|
||||
|
||||
output_arg = get_arg_optional(args, "output", str)
|
||||
output = Path(output_arg) if output_arg is not None else _default_output(paths)
|
||||
inputs = PlotInputs(
|
||||
results=paths,
|
||||
output=output,
|
||||
label_tag=get_arg_optional(args, "label_tag", str),
|
||||
title=get_arg_optional(args, "title", str),
|
||||
)
|
||||
|
||||
if benchmark == "context_scaling":
|
||||
out_path = render_context_scaling(inputs)
|
||||
else:
|
||||
raise SystemExit(f"plot: no renderer registered for benchmark {benchmark!r}")
|
||||
|
||||
logger.info(f"plot: wrote {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _benchmark_for(path: Path) -> str:
|
||||
with path.open() as f:
|
||||
loaded: Any = json.load(f) # type: ignore[reportAny]
|
||||
if not isinstance(loaded, dict):
|
||||
raise SystemExit(f"plot: {path}: expected top-level JSON object")
|
||||
metadata: Any = loaded.get("metadata", {}) # type: ignore[reportAny]
|
||||
if not isinstance(metadata, dict):
|
||||
raise SystemExit(f"plot: {path}: metadata is not an object")
|
||||
benchmark: Any = metadata.get("benchmark") # type: ignore[reportAny]
|
||||
if not isinstance(benchmark, str):
|
||||
raise SystemExit(f"plot: {path}: metadata.benchmark missing or not a string")
|
||||
return benchmark
|
||||
|
||||
|
||||
def _default_output(paths: list[Path]) -> Path:
|
||||
"""Auto-derive a PNG path next to the first JSON.
|
||||
|
||||
Single input → ``<path>.png`` (replaces ``.json``).
|
||||
Multiple inputs → ``<path>.compare.png`` next to the first JSON.
|
||||
"""
|
||||
first = paths[0]
|
||||
if len(paths) == 1:
|
||||
return first.with_suffix(".png")
|
||||
return first.with_name(first.stem + ".compare.png")
|
||||
File renamed without changes.
@@ -0,0 +1,109 @@
|
||||
"""Unit tests for ``bench.cli.campaign``.
|
||||
|
||||
The pure helpers (defaults+run merge, table-lookup, str-or-none) are
|
||||
tested here. End-to-end campaign execution requires a real eco cluster
|
||||
and is exercised manually via ``bench campaign <toml>``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bench.cli.campaign import (
|
||||
_merge, # type: ignore[reportPrivateUsage]
|
||||
_str_or_none, # type: ignore[reportPrivateUsage]
|
||||
_table, # type: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTable:
|
||||
def test_present_table(self) -> None:
|
||||
data = {"defaults": {"nodes": 4}}
|
||||
assert _table(data, "defaults") == {"nodes": 4}
|
||||
|
||||
def test_missing_key_returns_empty(self) -> None:
|
||||
assert _table({}, "absent") == {}
|
||||
|
||||
def test_non_table_value_returns_empty(self) -> None:
|
||||
# `nodes = 4` is an int at top level, not a table; treat as empty.
|
||||
assert _table({"nodes": 4}, "nodes") == {}
|
||||
|
||||
def test_list_value_returns_empty(self) -> None:
|
||||
assert _table({"runs": [{"a": 1}]}, "runs") == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _merge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMerge:
|
||||
def test_run_wins_on_conflict(self) -> None:
|
||||
defaults = {"nodes": 4, "tg": 64}
|
||||
run = {"nodes": 2}
|
||||
assert _merge(defaults, run) == {"nodes": 2, "tg": 64}
|
||||
|
||||
def test_disjoint_keys(self) -> None:
|
||||
defaults = {"nodes": 4}
|
||||
run = {"model": "test/foo"}
|
||||
assert _merge(defaults, run) == {"nodes": 4, "model": "test/foo"}
|
||||
|
||||
def test_run_only(self) -> None:
|
||||
assert _merge({}, {"a": 1, "b": 2}) == {"a": 1, "b": 2}
|
||||
|
||||
def test_defaults_only(self) -> None:
|
||||
assert _merge({"a": 1}, {}) == {"a": 1}
|
||||
|
||||
def test_tags_deep_merged_defaults_only(self) -> None:
|
||||
defaults = {"tags": {"operator": "ciaranbor"}}
|
||||
run = {"model": "test/foo"}
|
||||
merged = _merge(defaults, run)
|
||||
assert merged["tags"] == {"operator": "ciaranbor"}
|
||||
|
||||
def test_tags_deep_merged_run_only(self) -> None:
|
||||
defaults = {"nodes": 4}
|
||||
run = {"tags": {"model_short": "llama-3b"}}
|
||||
merged = _merge(defaults, run)
|
||||
assert merged["tags"] == {"model_short": "llama-3b"}
|
||||
|
||||
def test_tags_deep_merged_both(self) -> None:
|
||||
defaults = {"tags": {"operator": "ciaranbor", "campaign": "smoke"}}
|
||||
run = {"tags": {"model_short": "llama-3b"}}
|
||||
merged = _merge(defaults, run)
|
||||
assert merged["tags"] == {
|
||||
"operator": "ciaranbor",
|
||||
"campaign": "smoke",
|
||||
"model_short": "llama-3b",
|
||||
}
|
||||
|
||||
def test_run_tags_override_defaults_tags(self) -> None:
|
||||
defaults = {"tags": {"operator": "ciaranbor"}}
|
||||
run = {"tags": {"operator": "alice"}}
|
||||
merged = _merge(defaults, run)
|
||||
assert merged["tags"] == {"operator": "alice"}
|
||||
|
||||
def test_no_tags_table_means_no_tags_key(self) -> None:
|
||||
# When neither side has tags, we don't synthesise an empty dict.
|
||||
merged = _merge({"nodes": 4}, {"model": "test/foo"})
|
||||
assert "tags" not in merged
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _str_or_none
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStrOrNone:
|
||||
def test_str_passes_through(self) -> None:
|
||||
assert _str_or_none("hello") == "hello"
|
||||
|
||||
def test_none_returns_none(self) -> None:
|
||||
assert _str_or_none(None) is None
|
||||
|
||||
def test_int_returns_none(self) -> None:
|
||||
assert _str_or_none(42) is None
|
||||
|
||||
def test_list_returns_none(self) -> None:
|
||||
assert _str_or_none(["a", "b"]) is None
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Unit tests for the argparse boundary helpers in ``bench.cli._common``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bench.cli._common import (
|
||||
_config_to_argv, # type: ignore[reportPrivateUsage]
|
||||
_parse_csv, # type: ignore[reportPrivateUsage]
|
||||
_parse_tags, # type: ignore[reportPrivateUsage]
|
||||
expand_config_in_argv,
|
||||
get_arg,
|
||||
get_arg_optional,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_csv
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseCsv:
|
||||
def test_simple_list(self) -> None:
|
||||
assert _parse_csv("a,b,c") == ["a", "b", "c"]
|
||||
|
||||
def test_strips_whitespace(self) -> None:
|
||||
assert _parse_csv(" a , b , c ") == ["a", "b", "c"]
|
||||
|
||||
def test_skips_empty_entries(self) -> None:
|
||||
assert _parse_csv("a,,b,") == ["a", "b"]
|
||||
assert _parse_csv(",,,") == []
|
||||
|
||||
def test_empty_string_returns_empty(self) -> None:
|
||||
assert _parse_csv("") == []
|
||||
|
||||
def test_single_value(self) -> None:
|
||||
assert _parse_csv("only") == ["only"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_tags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseTags:
|
||||
def test_empty_input_returns_empty_dict(self) -> None:
|
||||
assert _parse_tags([]) == {}
|
||||
|
||||
def test_single_tag(self) -> None:
|
||||
assert _parse_tags(["operator=ciaranbor"]) == {"operator": "ciaranbor"}
|
||||
|
||||
def test_multiple_tags(self) -> None:
|
||||
assert _parse_tags(["a=1", "b=2", "c=3"]) == {"a": "1", "b": "2", "c": "3"}
|
||||
|
||||
def test_strips_whitespace_around_key_and_value(self) -> None:
|
||||
assert _parse_tags([" key = value "]) == {"key": "value"}
|
||||
|
||||
def test_value_can_contain_equals(self) -> None:
|
||||
assert _parse_tags(["url=http://example.com/?a=b"]) == {
|
||||
"url": "http://example.com/?a=b"
|
||||
}
|
||||
|
||||
def test_later_duplicate_key_wins(self) -> None:
|
||||
# Standard dict behaviour; explicit so we notice if it changes.
|
||||
assert _parse_tags(["k=v1", "k=v2"]) == {"k": "v2"}
|
||||
|
||||
def test_missing_equals_raises(self) -> None:
|
||||
with pytest.raises(argparse.ArgumentTypeError, match="key=value"):
|
||||
_ = _parse_tags(["malformed"])
|
||||
|
||||
def test_one_malformed_in_list_raises(self) -> None:
|
||||
with pytest.raises(argparse.ArgumentTypeError):
|
||||
_ = _parse_tags(["good=1", "bad", "alsogood=2"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_arg / get_arg_optional
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetArg:
|
||||
def test_str_passes_through(self) -> None:
|
||||
ns = argparse.Namespace(name="hello")
|
||||
assert get_arg(ns, "name", str) == "hello"
|
||||
|
||||
def test_int_passes_through(self) -> None:
|
||||
ns = argparse.Namespace(count=42)
|
||||
assert get_arg(ns, "count", int) == 42
|
||||
|
||||
def test_int_coerces_from_string(self) -> None:
|
||||
ns = argparse.Namespace(count="42")
|
||||
assert get_arg(ns, "count", int) == 42
|
||||
|
||||
def test_float_passes_through(self) -> None:
|
||||
ns = argparse.Namespace(rate=3.14)
|
||||
assert get_arg(ns, "rate", float) == 3.14
|
||||
|
||||
def test_float_coerces_from_int(self) -> None:
|
||||
ns = argparse.Namespace(rate=3)
|
||||
assert get_arg(ns, "rate", float) == 3.0
|
||||
|
||||
def test_float_coerces_from_string(self) -> None:
|
||||
ns = argparse.Namespace(rate="3.14")
|
||||
assert get_arg(ns, "rate", float) == 3.14
|
||||
|
||||
def test_bool_passes_through(self) -> None:
|
||||
ns = argparse.Namespace(flag=True)
|
||||
assert get_arg(ns, "flag", bool) is True
|
||||
|
||||
def test_wrong_type_raises(self) -> None:
|
||||
ns = argparse.Namespace(name=42)
|
||||
with pytest.raises(TypeError, match="expected str"):
|
||||
_ = get_arg(ns, "name", str)
|
||||
|
||||
def test_missing_attribute_raises(self) -> None:
|
||||
ns = argparse.Namespace()
|
||||
with pytest.raises(AttributeError):
|
||||
_ = get_arg(ns, "missing", str)
|
||||
|
||||
|
||||
class TestGetArgOptional:
|
||||
def test_missing_returns_none(self) -> None:
|
||||
ns = argparse.Namespace()
|
||||
assert get_arg_optional(ns, "missing", str) is None
|
||||
|
||||
def test_explicit_none_returns_none(self) -> None:
|
||||
ns = argparse.Namespace(value=None)
|
||||
assert get_arg_optional(ns, "value", str) is None
|
||||
|
||||
def test_present_value_returns_typed(self) -> None:
|
||||
ns = argparse.Namespace(value="present")
|
||||
assert get_arg_optional(ns, "value", str) == "present"
|
||||
|
||||
def test_int_coerces_from_string(self) -> None:
|
||||
ns = argparse.Namespace(value="42")
|
||||
assert get_arg_optional(ns, "value", int) == 42
|
||||
|
||||
def test_float_coerces_from_int(self) -> None:
|
||||
ns = argparse.Namespace(value=42)
|
||||
assert get_arg_optional(ns, "value", float) == 42.0
|
||||
|
||||
def test_wrong_type_raises(self) -> None:
|
||||
ns = argparse.Namespace(value=[1, 2, 3])
|
||||
with pytest.raises(TypeError, match="expected str or None"):
|
||||
_ = get_arg_optional(ns, "value", str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _config_to_argv
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigToArgv:
|
||||
def test_empty(self) -> None:
|
||||
assert _config_to_argv({}) == []
|
||||
|
||||
def test_string_value(self) -> None:
|
||||
assert _config_to_argv({"model": "mlx/foo"}) == ["--model", "mlx/foo"]
|
||||
|
||||
def test_int_and_float_values(self) -> None:
|
||||
out = _config_to_argv({"num_steps": 32, "fraction_of_max": 0.5})
|
||||
assert out == ["--num-steps", "32", "--fraction-of-max", "0.5"]
|
||||
|
||||
def test_underscore_keys_become_hyphenated_flags(self) -> None:
|
||||
out = _config_to_argv({"min_memory_gb": 21.0})
|
||||
assert out == ["--min-memory-gb", "21.0"]
|
||||
|
||||
def test_bool_true_emits_flag(self) -> None:
|
||||
assert _config_to_argv({"auto_constrain": True}) == ["--auto-constrain"]
|
||||
|
||||
def test_bool_false_emits_no_form(self) -> None:
|
||||
assert _config_to_argv({"auto_constrain": False}) == ["--no-auto-constrain"]
|
||||
|
||||
def test_none_value_skipped(self) -> None:
|
||||
assert _config_to_argv({"chip": None, "model": "foo"}) == [
|
||||
"--model",
|
||||
"foo",
|
||||
]
|
||||
|
||||
def test_list_joined_as_csv(self) -> None:
|
||||
out = _config_to_argv({"hosts": ["s4", "s9"], "cold_controls": [1024, 2048]})
|
||||
assert out == [
|
||||
"--hosts",
|
||||
"s4,s9",
|
||||
"--cold-controls",
|
||||
"1024,2048",
|
||||
]
|
||||
|
||||
def test_tags_table_expands_to_repeated_tag_args(self) -> None:
|
||||
out = _config_to_argv({"tags": {"operator": "ciaranbor", "run": "full"}})
|
||||
# Order within a TOML table is preserved by tomllib
|
||||
assert out == [
|
||||
"--tag",
|
||||
"operator=ciaranbor",
|
||||
"--tag",
|
||||
"run=full",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# expand_config_in_argv
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExpandConfigInArgv:
|
||||
def test_no_config_flag_passthrough(self) -> None:
|
||||
argv = ["context-scaling", "--model", "foo"]
|
||||
assert expand_config_in_argv(argv) == argv
|
||||
|
||||
def test_config_at_end(self, tmp_path: Path) -> None:
|
||||
cfg = tmp_path / "run.toml"
|
||||
_ = cfg.write_text('model = "from_config"\nnum_steps = 16\n')
|
||||
argv = ["context-scaling", "--config", str(cfg)]
|
||||
# Config flags are inserted right after the subcommand
|
||||
assert expand_config_in_argv(argv) == [
|
||||
"context-scaling",
|
||||
"--model",
|
||||
"from_config",
|
||||
"--num-steps",
|
||||
"16",
|
||||
]
|
||||
|
||||
def test_explicit_cli_overrides_config(self, tmp_path: Path) -> None:
|
||||
cfg = tmp_path / "run.toml"
|
||||
_ = cfg.write_text('model = "from_config"\nnum_steps = 16\n')
|
||||
# User overrides --num-steps explicitly. Argparse takes the last
|
||||
# occurrence for non-append actions, so the user's 32 wins.
|
||||
argv = ["context-scaling", "--config", str(cfg), "--num-steps", "32"]
|
||||
out = expand_config_in_argv(argv)
|
||||
assert out == [
|
||||
"context-scaling",
|
||||
"--model",
|
||||
"from_config",
|
||||
"--num-steps",
|
||||
"16",
|
||||
"--num-steps",
|
||||
"32",
|
||||
]
|
||||
|
||||
def test_missing_path_arg_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="--config requires a path"):
|
||||
_ = expand_config_in_argv(["context-scaling", "--config"])
|
||||
|
||||
def test_nonexistent_file_raises(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError, match="Config file not found"):
|
||||
_ = expand_config_in_argv(
|
||||
["context-scaling", "--config", str(tmp_path / "missing.toml")]
|
||||
)
|
||||
|
||||
def test_bool_false_in_config(self, tmp_path: Path) -> None:
|
||||
cfg = tmp_path / "run.toml"
|
||||
_ = cfg.write_text("auto_constrain = false\n")
|
||||
argv = ["context-scaling", "--config", str(cfg)]
|
||||
assert expand_config_in_argv(argv) == [
|
||||
"context-scaling",
|
||||
"--no-auto-constrain",
|
||||
]
|
||||
|
||||
def test_tags_table(self, tmp_path: Path) -> None:
|
||||
cfg = tmp_path / "run.toml"
|
||||
_ = cfg.write_text(
|
||||
'model = "foo"\n[tags]\noperator = "ciaranbor"\nrun = "full"\n'
|
||||
)
|
||||
argv = ["context-scaling", "--config", str(cfg)]
|
||||
assert expand_config_in_argv(argv) == [
|
||||
"context-scaling",
|
||||
"--model",
|
||||
"foo",
|
||||
"--tag",
|
||||
"operator=ciaranbor",
|
||||
"--tag",
|
||||
"run=full",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
# Example context-scaling run configuration.
|
||||
#
|
||||
# Use it like this:
|
||||
#
|
||||
# uv run python -m bench.cli context-scaling --config bench/configs/context_scaling.example.toml
|
||||
#
|
||||
# CLI flags placed after `--config` override individual values.
|
||||
#
|
||||
# All shared and subcommand-specific flags can appear here. Keys mirror the
|
||||
# CLI flag names with hyphens replaced by underscores. Boolean keys map to
|
||||
# `--key` / `--no-key`; lists are joined as CSV; the `[tags]` table maps to
|
||||
# repeated `--tag key=value` flags.
|
||||
#
|
||||
# NOTE: TOML scoping — once a `[table]` header is opened, all subsequent
|
||||
# top-level-looking assignments belong to that table until the next header.
|
||||
# Keep tables (like `[tags]`) at the END of the file.
|
||||
|
||||
# ---- Model + placement ----
|
||||
model = "mlx-community/Qwen3-30B-A3B-4bit"
|
||||
# sharding = "Tensor" # default: "Tensor"; pairs with --comm MlxJaccl + --thunderbolt a2a
|
||||
# comm = "MlxJaccl" # default: "MlxJaccl" (RDMA over Thunderbolt)
|
||||
# min_nodes = 1
|
||||
|
||||
# ---- Cluster ----
|
||||
# Either pin to specific hosts...
|
||||
# hosts = ["s4"]
|
||||
# ...or let eco pick hosts that satisfy the constraints below.
|
||||
# nodes = 1
|
||||
# chip = "M3 Ultra" # eco chip name (case-insensitive substring); comment to allow any
|
||||
# thunderbolt = "a2a" # default: "a2a" (clique, for Tensor+JACCL)
|
||||
# "ring" (cycle; for Pipeline+JACCL)
|
||||
# "none" (exclude TB; pair with sharding=Pipeline + comm=MlxRing for non-TB hosts)
|
||||
|
||||
# Memory + disk minimums are auto-derived from the HF model size
|
||||
# (×1.30 + 1 GiB for memory, ×1.10 + 1 GiB for disk). Set any of these
|
||||
# explicitly to override the auto-derived value.
|
||||
# min_memory_gb = 96.0
|
||||
# max_memory_gb = 256.0 # leave bigger machines free for other workloads
|
||||
# min_disk_gb = 24.0
|
||||
# max_disk_gb = 4000.0
|
||||
|
||||
# ---- Runtime ----
|
||||
# evict_downloads is true by default — frees disk smallest-first to fit
|
||||
# the bench model. Set to false to keep existing downloads.
|
||||
# evict_downloads = false
|
||||
|
||||
# cleanup_instance is true by default — deletes the placed instance on exit.
|
||||
# Set to false to leave it running for debugging.
|
||||
# cleanup_instance = false
|
||||
|
||||
# ---- Output ----
|
||||
output_dir = "bench/results"
|
||||
|
||||
# ---- Context-scaling sweep ----
|
||||
num_steps = 32 # K — number of equally-spaced ramp points
|
||||
# pp_step = 1024 # Δ — explicit override; otherwise auto-derived
|
||||
# fraction_of_max = 1.0 # use this fraction of max_position_embeddings
|
||||
tg = 64 # tokens generated per step
|
||||
# warmup = 2 # default: 2 (1 cache-disabled JIT warmup + 1 cache-priming warmup)
|
||||
# cold_controls = "auto" # 4 evenly-spaced controls across the ramp, or:
|
||||
# cold_controls = "8192,16384,32768,40960" # explicit pp values
|
||||
sleep_between_s = 1.0
|
||||
|
||||
# ---- Tags ----
|
||||
# Survive into metadata.tags in the output JSON; useful for filtering or
|
||||
# grouping runs across SHAs / hosts / configs. `$USER` is NOT expanded
|
||||
# (TOML is literal); pass `--tag operator=$USER` on the CLI for shell expansion.
|
||||
# Must be the LAST table in the file (see TOML scoping note above).
|
||||
[tags]
|
||||
run = "full"
|
||||
@@ -0,0 +1,34 @@
|
||||
# 4-node smoke campaign: two small/medium Llama models, abbreviated ramps,
|
||||
# auto-everything else (TB a2a + tensor + JACCL + auto-derived constraints).
|
||||
#
|
||||
# Run with:
|
||||
# uv run python -m bench.cli campaign bench/configs/llama-family-smoke.toml
|
||||
#
|
||||
# Each [[runs]] gets its own cluster (deploy + bench + teardown). After
|
||||
# both runs finish, a side-by-side comparison plot is written next to the
|
||||
# JSONs.
|
||||
|
||||
[defaults]
|
||||
nodes = 4
|
||||
num_steps = 8
|
||||
fraction_of_max = 0.5
|
||||
|
||||
[defaults.tags]
|
||||
campaign = "llama-family-smoke"
|
||||
|
||||
[[runs]]
|
||||
subcommand = "context-scaling"
|
||||
model = "mlx-community/Llama-3.2-3B-Instruct-4bit"
|
||||
[runs.tags]
|
||||
model_short = "llama-3.2-3b-4bit"
|
||||
|
||||
[[runs]]
|
||||
subcommand = "context-scaling"
|
||||
model = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
|
||||
[runs.tags]
|
||||
model_short = "llama-3.1-8b-4bit"
|
||||
|
||||
# Final comparison plot (one PNG per benchmark group with ≥2 runs).
|
||||
[plot]
|
||||
label_tag = "model_short"
|
||||
title = "Llama 3 family — 4-node tensor + JACCL smoke"
|
||||
@@ -15,9 +15,8 @@ from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
from exo_tools.client import ExoClient, ExoHttpError
|
||||
from exo_tools.harness import (
|
||||
add_common_instance_args,
|
||||
capture_cluster_snapshot,
|
||||
instance_id_from_instance,
|
||||
|
||||
+36
-273
@@ -24,15 +24,12 @@ import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from statistics import mean
|
||||
from typing import Any
|
||||
|
||||
from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
from exo_tools.client import ExoClient, ExoHttpError
|
||||
from exo_tools.harness import (
|
||||
add_common_instance_args,
|
||||
capture_cluster_snapshot,
|
||||
find_existing_instance,
|
||||
@@ -46,125 +43,44 @@ from harness import (
|
||||
wait_for_instance_ready,
|
||||
)
|
||||
from loguru import logger
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
# Monkey-patch for transformers 5.x compatibility
|
||||
# Kimi's tokenization_kimi.py imports bytes_to_unicode from the old location
|
||||
# which was moved in transformers 5.0.0rc2
|
||||
try:
|
||||
import transformers.models.gpt2.tokenization_gpt2 as gpt2_tokenization
|
||||
from transformers.convert_slow_tokenizer import bytes_to_unicode
|
||||
# PromptSizer / run_one_completion / load_tokenizer_for_bench are the
|
||||
# canonical, fully-typed implementations under bench/lib/. They are
|
||||
# re-exported here for backwards compatibility with prefill_decode_bench.py
|
||||
# and any other consumers of `from exo_bench import …`.
|
||||
from bench.lib.completion import run_one_completion as _lib_run_one_completion
|
||||
from bench.lib.prompt import (
|
||||
PromptSizer as _LibPromptSizer,
|
||||
)
|
||||
from bench.lib.prompt import (
|
||||
load_tokenizer_for_bench as _lib_load_tokenizer_for_bench,
|
||||
)
|
||||
|
||||
if not hasattr(gpt2_tokenization, "bytes_to_unicode"):
|
||||
gpt2_tokenization.bytes_to_unicode = bytes_to_unicode # type: ignore[attr-defined]
|
||||
except ImportError:
|
||||
pass # transformers < 5.0 or bytes_to_unicode not available
|
||||
PromptSizer = _LibPromptSizer
|
||||
load_tokenizer_for_bench = _lib_load_tokenizer_for_bench
|
||||
|
||||
|
||||
def load_tokenizer_for_bench(model_id: str) -> Any:
|
||||
"""
|
||||
Load tokenizer for benchmarking, with special handling for Kimi models.
|
||||
|
||||
Kimi uses a custom TikTokenTokenizer that transformers 5.x can't load via AutoTokenizer.
|
||||
This function replicates the logic from utils_mlx.py for bench compatibility.
|
||||
"""
|
||||
model_id_lower = model_id.lower()
|
||||
|
||||
if "kimi-k2" in model_id_lower:
|
||||
import importlib.util
|
||||
import types
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
# Download/get the model path
|
||||
model_path = Path(
|
||||
snapshot_download(
|
||||
model_id,
|
||||
allow_patterns=["*.json", "*.py", "*.tiktoken", "*.model", "*.jinja"],
|
||||
)
|
||||
)
|
||||
|
||||
sys.path.insert(0, str(model_path))
|
||||
|
||||
# Load tool_declaration_ts first (tokenization_kimi imports it with relative import)
|
||||
tool_decl_path = model_path / "tool_declaration_ts.py"
|
||||
if tool_decl_path.exists():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"tool_declaration_ts", tool_decl_path
|
||||
)
|
||||
if spec and spec.loader:
|
||||
tool_decl_module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["tool_declaration_ts"] = tool_decl_module
|
||||
spec.loader.exec_module(tool_decl_module)
|
||||
|
||||
# Load tokenization_kimi with patched source (convert relative to absolute import)
|
||||
tok_path = model_path / "tokenization_kimi.py"
|
||||
source = tok_path.read_text()
|
||||
source = source.replace("from .tool_declaration_ts", "from tool_declaration_ts")
|
||||
spec = importlib.util.spec_from_file_location("tokenization_kimi", tok_path)
|
||||
if spec:
|
||||
tok_module = types.ModuleType("tokenization_kimi")
|
||||
tok_module.__file__ = str(tok_path)
|
||||
sys.modules["tokenization_kimi"] = tok_module
|
||||
exec(compile(source, tok_path, "exec"), tok_module.__dict__) # noqa: S102
|
||||
TikTokenTokenizer = tok_module.TikTokenTokenizer # noqa: N806
|
||||
else:
|
||||
from tokenization_kimi import TikTokenTokenizer # type: ignore[import-not-found] # noqa: I001
|
||||
|
||||
hf_tokenizer: Any = TikTokenTokenizer.from_pretrained(model_path)
|
||||
|
||||
# Patch encode to use internal tiktoken model directly
|
||||
# transformers 5.x has a bug in the encode->pad path for slow tokenizers
|
||||
def _patched_encode(text: str, **kwargs: object) -> list[int]:
|
||||
# Pass allowed_special="all" to handle special tokens like <|im_user|>
|
||||
return list(hf_tokenizer.model.encode(text, allowed_special="all"))
|
||||
|
||||
hf_tokenizer.encode = _patched_encode
|
||||
|
||||
return hf_tokenizer
|
||||
|
||||
# TODO: Change back to using only transformers
|
||||
try:
|
||||
return AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
|
||||
except (AttributeError, ValueError):
|
||||
from huggingface_hub import snapshot_download
|
||||
from transformers import PretrainedConfig
|
||||
|
||||
model_path = Path(
|
||||
snapshot_download(
|
||||
model_id,
|
||||
allow_patterns=[
|
||||
"*.json",
|
||||
"*.py",
|
||||
"tokenizer.model",
|
||||
"*.tiktoken",
|
||||
"tiktoken.model",
|
||||
"*.txt",
|
||||
"*.jsonl",
|
||||
"*.jinja",
|
||||
],
|
||||
)
|
||||
)
|
||||
stub_kwargs: dict[str, Any] = {}
|
||||
config_file = model_path / "config.json"
|
||||
if config_file.exists():
|
||||
with open(config_file) as f:
|
||||
raw = json.load(f)
|
||||
for key in (
|
||||
"model_type",
|
||||
"max_position_embeddings",
|
||||
"vocab_size",
|
||||
"bos_token_id",
|
||||
"eos_token_id",
|
||||
"pad_token_id",
|
||||
):
|
||||
if key in raw:
|
||||
stub_kwargs[key] = raw[key]
|
||||
return AutoTokenizer.from_pretrained(
|
||||
str(model_path),
|
||||
config=PretrainedConfig(**stub_kwargs),
|
||||
trust_remote_code=True,
|
||||
)
|
||||
def run_one_completion(
|
||||
client: ExoClient,
|
||||
model_id: str,
|
||||
pp_hint: int,
|
||||
tg: int,
|
||||
prompt_sizer: PromptSizer,
|
||||
*,
|
||||
use_prefix_cache: bool = False,
|
||||
stream: bool = False,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""Backwards-compatible shim returning a plain ``dict`` row."""
|
||||
row, pp_tokens = _lib_run_one_completion(
|
||||
client,
|
||||
model_id,
|
||||
pp_hint,
|
||||
tg,
|
||||
prompt_sizer,
|
||||
use_prefix_cache=use_prefix_cache,
|
||||
stream=stream,
|
||||
)
|
||||
return dict(row), pp_tokens
|
||||
|
||||
|
||||
def format_peak_memory(b: float) -> str:
|
||||
@@ -270,159 +186,6 @@ def parse_int_list(values: list[str]) -> list[int]:
|
||||
return items
|
||||
|
||||
|
||||
def run_one_completion(
|
||||
client: ExoClient,
|
||||
model_id: str,
|
||||
pp_hint: int,
|
||||
tg: int,
|
||||
prompt_sizer: PromptSizer,
|
||||
*,
|
||||
use_prefix_cache: bool = False,
|
||||
stream: bool = False,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
content, pp_tokens = prompt_sizer.build(pp_hint)
|
||||
payload: dict[str, Any] = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"max_tokens": tg,
|
||||
"logprobs": False,
|
||||
"use_prefix_cache": use_prefix_cache,
|
||||
}
|
||||
|
||||
if not stream:
|
||||
payload["stream"] = False
|
||||
t0 = time.perf_counter()
|
||||
out = client.post_bench_chat_completions(payload)
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
||||
stats = out.get("generation_stats")
|
||||
choices = out.get("choices") or [{}]
|
||||
message = choices[0].get("message", {}) if choices else {}
|
||||
content = message.get("content") or ""
|
||||
preview = content[:200] if content else ""
|
||||
else:
|
||||
tokens = 0
|
||||
first_token_time = None
|
||||
t0 = time.perf_counter()
|
||||
text_parts: list[str] = []
|
||||
stats = None
|
||||
|
||||
for raw_line in client.stream_bench_chat_completions(payload):
|
||||
line = raw_line.strip()
|
||||
if line.startswith(": generation_stats "):
|
||||
with contextlib.suppress(json.JSONDecodeError):
|
||||
stats = json.loads(line[len(": generation_stats ") :])
|
||||
continue
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
if delta.get("content"):
|
||||
if first_token_time is None:
|
||||
first_token_time = time.perf_counter()
|
||||
tokens += 1
|
||||
text_parts.append(delta["content"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
preview = "".join(text_parts)[:200]
|
||||
|
||||
if not stats:
|
||||
ttft = (first_token_time - t0) if first_token_time else elapsed
|
||||
gen_time = elapsed - ttft if tokens > 1 else elapsed
|
||||
gen_tps = (tokens - 1) / gen_time if tokens > 1 and gen_time > 0 else 0.0
|
||||
prompt_tps = pp_tokens / ttft if ttft > 0 else 0.0
|
||||
stats = {
|
||||
"prompt_tokens": pp_tokens,
|
||||
"generation_tokens": tokens,
|
||||
"prompt_tps": round(prompt_tps, 2),
|
||||
"generation_tps": round(gen_tps, 2),
|
||||
"peak_memory_usage": {"inBytes": 0},
|
||||
}
|
||||
|
||||
return {
|
||||
"elapsed_s": elapsed,
|
||||
"output_text_preview": preview,
|
||||
"stats": stats,
|
||||
}, pp_tokens
|
||||
|
||||
|
||||
class PromptSizer:
|
||||
def __init__(self, tokenizer: Any, atom: str = "a "):
|
||||
self.tokenizer = tokenizer
|
||||
self.atom = atom
|
||||
self.count_fn = PromptSizer._make_counter(tokenizer)
|
||||
self.base_tokens = self.count_fn("")
|
||||
|
||||
@staticmethod
|
||||
def _make_counter(tokenizer: Any) -> Callable[[str], int]:
|
||||
def count_fn(user_content: str) -> int:
|
||||
messages = [{"role": "user", "content": user_content}]
|
||||
try:
|
||||
ids = tokenizer.apply_chat_template(
|
||||
messages, tokenize=True, add_generation_prompt=True
|
||||
)
|
||||
except ValueError:
|
||||
# Models without a Jinja chat template (e.g. DeepSeek V4 which
|
||||
# ships its own Python encoder). Use the exo-side V4 encoder.
|
||||
from exo.worker.engines.mlx.deepseek_v4_encoding import (
|
||||
encode_messages as encode_v4,
|
||||
)
|
||||
|
||||
prompt = encode_v4(messages, thinking_mode="thinking")
|
||||
ids = tokenizer.encode(prompt, add_special_tokens=False)
|
||||
# Fix for transformers 5.x
|
||||
if hasattr(ids, "input_ids"):
|
||||
ids = ids.input_ids
|
||||
return int(len(ids))
|
||||
|
||||
return count_fn
|
||||
|
||||
def build(self, target_prompt_tokens: int) -> tuple[str, int]:
|
||||
target = int(target_prompt_tokens)
|
||||
if target < self.base_tokens:
|
||||
raise RuntimeError(
|
||||
f"Target ({target}) is smaller than template overhead ({self.base_tokens})."
|
||||
)
|
||||
|
||||
# Estimate tokens per atom using a sample
|
||||
sample_count = 100
|
||||
sample_content = self.atom * sample_count
|
||||
sample_tokens = self.count_fn(sample_content) - self.base_tokens
|
||||
tokens_per_atom = sample_tokens / sample_count
|
||||
|
||||
# Estimate starting point
|
||||
needed_tokens = target - self.base_tokens
|
||||
estimated_atoms = int(needed_tokens / tokens_per_atom)
|
||||
|
||||
# Binary search to find exact atom count
|
||||
low, high = 0, estimated_atoms * 2 + 100
|
||||
while low < high:
|
||||
mid = (low + high) // 2
|
||||
tok = self.count_fn(self.atom * mid)
|
||||
if tok < target:
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid
|
||||
|
||||
content = self.atom * low
|
||||
tok = self.count_fn(content)
|
||||
logger.info(f"{tok=}")
|
||||
|
||||
if tok != target:
|
||||
raise RuntimeError(
|
||||
f"Overshot: got {tok} tokens (target {target}). "
|
||||
f"Pick a different atom (try ' a' or '\\n' or '0 ')."
|
||||
)
|
||||
|
||||
return content, tok
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="exo-bench",
|
||||
|
||||
+2
-3
@@ -42,9 +42,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
from exo_tools.client import ExoClient, ExoHttpError
|
||||
from exo_tools.harness import (
|
||||
add_common_instance_args,
|
||||
capture_cluster_snapshot,
|
||||
find_existing_instance,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Composable bench library for exo.
|
||||
|
||||
Provides reusable building blocks for benchmarks:
|
||||
|
||||
- :class:`bench.lib.session.BenchSession` — cluster + instance + client wrapper
|
||||
- :class:`bench.lib.results.ResultsBundle` — structured results + JSON writer
|
||||
- :func:`bench.lib.cluster.managed_cluster` /
|
||||
:func:`bench.lib.cluster.managed_instance` — eco-managed lifecycle ctx-managers
|
||||
- :func:`bench.lib.model_meta.fetch_model_meta` — HF metadata fetcher driving
|
||||
cluster constraints + auto-derived context ramps
|
||||
- :mod:`bench.lib.context_scaling` — prompt-TPS / decode-TPS vs context-size sweep
|
||||
|
||||
CLI entrypoints under ``bench/cli/`` consume this library via
|
||||
``python -m bench.cli <subcommand>``. Adding a new benchmark = (i) write
|
||||
``bench/lib/<name>.py`` exposing a typed ``run(session, params, bundle)``
|
||||
callable, (ii) write ``bench/cli/<name>.py`` with an ``add_subparser`` and
|
||||
a handler, (iii) register it in ``_REGISTRY`` in ``bench/cli/__main__.py``.
|
||||
"""
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Eco-managed cluster + instance lifecycle helpers for the bench CLI.
|
||||
|
||||
Two context managers:
|
||||
|
||||
- :func:`managed_cluster` deploys exo on the requested hosts (or via
|
||||
constraint-based reservation) and tears it down on exit.
|
||||
- :func:`managed_instance` resolves the model on the cluster, optionally
|
||||
frees disk via ``--danger-delete-downloads`` (default on for benches),
|
||||
places the instance, and deletes it on exit.
|
||||
|
||||
The library never reaches for global state — every call takes an
|
||||
explicit :class:`EcoSession`. Callers are expected to instantiate one
|
||||
session per CLI invocation and use it across both context managers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, cast
|
||||
|
||||
from exo_tools.client import ExoClient
|
||||
from exo_tools.cluster import Chip, ClusterInfo, EcoSession, Thunderbolt
|
||||
from exo_tools.harness import (
|
||||
Comm,
|
||||
Sharding,
|
||||
cleanup_all_instances,
|
||||
place_instance,
|
||||
resolve_model_short_id,
|
||||
run_planning_phase,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from .session import BenchSession
|
||||
|
||||
|
||||
@contextmanager
|
||||
def managed_cluster(
|
||||
eco: EcoSession,
|
||||
*,
|
||||
hosts: list[str] | None = None,
|
||||
count: int = 1,
|
||||
thunderbolt: Thunderbolt | None = None,
|
||||
chip: Chip | None = None,
|
||||
min_memory_gb: float | None = None,
|
||||
max_memory_gb: float | None = None,
|
||||
min_disk_gb: float | None = None,
|
||||
max_disk_gb: float | None = None,
|
||||
deploy_timeout_s: int = 600,
|
||||
) -> Iterator[ClusterInfo]:
|
||||
"""Deploy exo for the duration of the ``with`` block, then ``eco stop``.
|
||||
|
||||
If ``hosts`` is given, deploys on exactly those hosts (constraint flags
|
||||
are ignored — eco doesn't re-validate the explicit list). Otherwise eco
|
||||
reserves any matching hosts that satisfy all of:
|
||||
|
||||
- ``count`` (number of hosts)
|
||||
- ``thunderbolt`` topology (``A2A``, ``RING``, or ``NONE`` to
|
||||
exclude TB-connected hosts)
|
||||
- ``chip`` (substring match against eco's chip names)
|
||||
- memory bounds (``min_memory_gb`` / ``max_memory_gb``)
|
||||
- disk bounds (``min_disk_gb`` / ``max_disk_gb``)
|
||||
"""
|
||||
if hosts:
|
||||
cluster = eco.start_deploy(
|
||||
hosts=hosts[:count],
|
||||
wait=True,
|
||||
timeout=deploy_timeout_s,
|
||||
)
|
||||
else:
|
||||
cluster = eco.start_deploy(
|
||||
count=count,
|
||||
thunderbolt=thunderbolt,
|
||||
chip=chip,
|
||||
min_memory_gb=min_memory_gb,
|
||||
max_memory_gb=max_memory_gb,
|
||||
min_disk_gb=min_disk_gb,
|
||||
max_disk_gb=max_disk_gb,
|
||||
wait=True,
|
||||
timeout=deploy_timeout_s,
|
||||
)
|
||||
logger.info(
|
||||
f"cluster deployed: {len(cluster.hosts)} host(s) "
|
||||
f"({', '.join(cluster.hosts)}); namespace={cluster.namespace}"
|
||||
)
|
||||
try:
|
||||
yield cluster
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
eco.stop(cluster.hosts)
|
||||
logger.info("cluster stopped")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def managed_instance(
|
||||
cluster: ClusterInfo,
|
||||
eco: EcoSession,
|
||||
model_id: str,
|
||||
*,
|
||||
sharding: Sharding = Sharding.PIPELINE,
|
||||
comm: Comm = Comm.RING,
|
||||
min_nodes: int = 1,
|
||||
evict_downloads: bool = True,
|
||||
cleanup_on_exit: bool = True,
|
||||
instance_timeout_s: float = 7200.0,
|
||||
settle_timeout_s: float = 60.0,
|
||||
) -> Iterator[BenchSession]:
|
||||
"""Resolve the model on the cluster, place an instance, yield a session.
|
||||
|
||||
Steps on entry:
|
||||
1. Resolve ``model_id`` to ``(short_id, full_id)`` against the cluster's
|
||||
``/models`` endpoint (auto-adds from HuggingFace if missing).
|
||||
2. Run the harness's planning phase: validates each node has enough
|
||||
disk for the model and starts the download (or reuses an existing
|
||||
download). When ``evict_downloads=True`` (the default for benches),
|
||||
this also evicts smaller existing models if disk is short.
|
||||
3. Place the instance, wait for it to be ``RunnerReady``.
|
||||
4. Yield a :class:`BenchSession` pointing at the cluster's primary API.
|
||||
|
||||
On exit: deletes the placed instance (and any other lingering
|
||||
instances) so the cluster is clean for the next benchmark.
|
||||
"""
|
||||
client = cluster.make_client(timeout_s=instance_timeout_s)
|
||||
|
||||
short_id, full_id = resolve_model_short_id(client, model_id, force_download=True)
|
||||
logger.info(f"resolved model: short_id={short_id} full_id={full_id}")
|
||||
|
||||
# The planning phase needs a concrete preview (instance + runner-to-shard
|
||||
# mapping) to know which nodes to download to. Pull the placements API
|
||||
# directly and take the first valid one — bench cares about disk +
|
||||
# download, not the specific shard mapping.
|
||||
preview = _first_valid_preview(client, full_id, settle_timeout_s)
|
||||
if preview is None:
|
||||
raise RuntimeError(
|
||||
f"No placement available for {full_id} on cluster {cluster.hosts}"
|
||||
)
|
||||
|
||||
duration = run_planning_phase(
|
||||
client,
|
||||
full_id,
|
||||
preview,
|
||||
danger_delete=evict_downloads,
|
||||
timeout=instance_timeout_s,
|
||||
settle_deadline=None,
|
||||
)
|
||||
if duration is not None:
|
||||
logger.info(f"download: {duration:.1f}s (freshly downloaded)")
|
||||
else:
|
||||
logger.info("download: model already cached on all nodes")
|
||||
|
||||
instance_id = place_instance(
|
||||
client,
|
||||
model_id,
|
||||
sharding=sharding,
|
||||
comm=comm,
|
||||
min_nodes=min_nodes,
|
||||
timeout=instance_timeout_s,
|
||||
)
|
||||
logger.info(f"placed instance {instance_id} ({sharding.value}/{comm.value})")
|
||||
|
||||
sess = BenchSession(
|
||||
cluster=cluster,
|
||||
eco=eco,
|
||||
instance_id=instance_id,
|
||||
model_id=short_id,
|
||||
full_model_id=full_id,
|
||||
)
|
||||
try:
|
||||
yield sess
|
||||
finally:
|
||||
if cleanup_on_exit:
|
||||
with contextlib.suppress(Exception):
|
||||
cleanup_all_instances(sess.client)
|
||||
else:
|
||||
logger.info(
|
||||
f"cleanup_on_exit=False: leaving instance(s) on {cluster.hosts}"
|
||||
)
|
||||
|
||||
|
||||
def _first_valid_preview(
|
||||
client: ExoClient, full_model_id: str, settle_timeout_s: float
|
||||
) -> dict[str, Any] | None:
|
||||
"""Poll ``/instance/previews`` until at least one valid preview comes back."""
|
||||
deadline = time.monotonic() + settle_timeout_s
|
||||
backoff_s = 1.0
|
||||
while True:
|
||||
resp_obj: Any = client.request_json( # type: ignore[reportAny]
|
||||
"GET", "/instance/previews", params={"model_id": full_model_id}
|
||||
)
|
||||
resp: dict[str, Any] = (
|
||||
cast("dict[str, Any]", resp_obj) if isinstance(resp_obj, dict) else {}
|
||||
)
|
||||
previews_raw: object = resp.get("previews") or []
|
||||
previews: list[Any] = (
|
||||
cast("list[Any]", previews_raw) if isinstance(previews_raw, list) else []
|
||||
)
|
||||
for raw in previews: # type: ignore[reportAny]
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
entry = cast("dict[str, Any]", raw)
|
||||
if entry.get("error") is not None:
|
||||
continue
|
||||
instance = entry.get("instance")
|
||||
if isinstance(instance, dict):
|
||||
return entry
|
||||
if time.monotonic() >= deadline:
|
||||
return None
|
||||
logger.info(
|
||||
f"waiting for placement to appear for {full_model_id} "
|
||||
f"({deadline - time.monotonic():.0f}s remaining)..."
|
||||
)
|
||||
time.sleep(min(backoff_s, max(0.0, deadline - time.monotonic())))
|
||||
backoff_s = min(backoff_s * 2, 30.0)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Typed wrapper around ``/bench/chat/completions`` for benchmarks.
|
||||
|
||||
The bench endpoint disables EOS suppression and KV prefix caching by
|
||||
default (see ``bench/METHODOLOGY.md``). This module exposes a single
|
||||
function :func:`run_one_completion` that:
|
||||
|
||||
1. Builds an exact-token-length prompt via :class:`PromptSizer`.
|
||||
2. POSTs to ``/bench/chat/completions``.
|
||||
3. Returns a ``(BenchRow, prompt_tokens)`` pair where ``BenchRow`` is a
|
||||
:class:`typing.TypedDict` with the fields the caller needs.
|
||||
|
||||
Streaming is supported but rarely needed for context-scaling — the
|
||||
non-streaming path is the default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Literal, NotRequired, TypedDict, cast
|
||||
|
||||
from exo_tools.client import ExoClient
|
||||
|
||||
from .prompt import PromptSizer
|
||||
|
||||
PrefixCacheHit = Literal["none", "partial", "exact"]
|
||||
|
||||
|
||||
class GenerationStats(TypedDict, total=False):
|
||||
"""Server-reported per-task timing stats."""
|
||||
|
||||
prompt_tps: float
|
||||
generation_tps: float
|
||||
prompt_tokens: int
|
||||
generation_tokens: int
|
||||
peak_memory_usage: dict[str, int]
|
||||
prefix_cache_hit: PrefixCacheHit
|
||||
|
||||
|
||||
class BenchRow(TypedDict):
|
||||
"""Per-request result row returned to callers."""
|
||||
|
||||
elapsed_s: float
|
||||
output_text_preview: str
|
||||
stats: GenerationStats
|
||||
error: NotRequired[str]
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any]: # type: ignore[reportAny]
|
||||
"""Narrow an arbitrary JSON value to a typed ``dict[str, Any]``."""
|
||||
if isinstance(value, dict):
|
||||
return cast("dict[str, Any]", value)
|
||||
return {}
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[Any]: # type: ignore[reportAny]
|
||||
if isinstance(value, list):
|
||||
return cast("list[Any]", value)
|
||||
return []
|
||||
|
||||
|
||||
def _extract_stats(raw_response: dict[str, Any]) -> GenerationStats:
|
||||
stats_obj = raw_response.get("generation_stats")
|
||||
if not isinstance(stats_obj, dict):
|
||||
return {}
|
||||
return cast("GenerationStats", cast("object", stats_obj))
|
||||
|
||||
|
||||
def _extract_preview(raw_response: dict[str, Any], limit: int = 200) -> str:
|
||||
choices = _as_list(raw_response.get("choices"))
|
||||
if not choices:
|
||||
return ""
|
||||
first = _as_dict(choices[0])
|
||||
message = _as_dict(first.get("message"))
|
||||
content_obj = message.get("content")
|
||||
if isinstance(content_obj, str):
|
||||
return content_obj[:limit]
|
||||
return ""
|
||||
|
||||
|
||||
def run_one_completion(
|
||||
client: ExoClient,
|
||||
model_id: str,
|
||||
pp_hint: int,
|
||||
tg: int,
|
||||
prompt_sizer: PromptSizer,
|
||||
*,
|
||||
use_prefix_cache: bool = False,
|
||||
stream: bool = False,
|
||||
) -> tuple[BenchRow, int]:
|
||||
"""Send one request to ``/bench/chat/completions`` and return its row.
|
||||
|
||||
``pp_hint`` is the *target* prompt-token count; the actual prompt is
|
||||
sized via :class:`PromptSizer` and the verified value is returned as
|
||||
the second element of the tuple.
|
||||
"""
|
||||
content, pp_tokens = prompt_sizer.build(pp_hint)
|
||||
payload: dict[str, Any] = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"max_tokens": tg,
|
||||
"logprobs": False,
|
||||
"use_prefix_cache": use_prefix_cache,
|
||||
}
|
||||
|
||||
if not stream:
|
||||
payload["stream"] = False
|
||||
t0 = time.perf_counter()
|
||||
raw_obj = client.post_bench_chat_completions(payload)
|
||||
elapsed = time.perf_counter() - t0
|
||||
raw = _as_dict(raw_obj)
|
||||
return (
|
||||
BenchRow(
|
||||
elapsed_s=elapsed,
|
||||
output_text_preview=_extract_preview(raw),
|
||||
stats=_extract_stats(raw),
|
||||
),
|
||||
pp_tokens,
|
||||
)
|
||||
|
||||
return _run_streaming(client, payload, pp_tokens)
|
||||
|
||||
|
||||
def _run_streaming(
|
||||
client: ExoClient,
|
||||
payload: dict[str, Any],
|
||||
pp_tokens: int,
|
||||
) -> tuple[BenchRow, int]:
|
||||
"""Streaming variant: parse SSE lines, recover ``GenerationStats``."""
|
||||
payload = {**payload, "stream": True}
|
||||
|
||||
tokens = 0
|
||||
first_token_time: float | None = None
|
||||
t0 = time.perf_counter()
|
||||
text_parts: list[str] = []
|
||||
stats: GenerationStats = {}
|
||||
|
||||
for raw_line in client.stream_bench_chat_completions(payload):
|
||||
line = raw_line.strip()
|
||||
if line.startswith(": generation_stats "):
|
||||
with contextlib.suppress(json.JSONDecodeError):
|
||||
parsed_obj: Any = json.loads( # type: ignore[reportAny]
|
||||
line[len(": generation_stats ") :]
|
||||
)
|
||||
if isinstance(parsed_obj, dict):
|
||||
stats = cast("GenerationStats", cast("object", parsed_obj))
|
||||
continue
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk_obj: Any = json.loads(data) # type: ignore[reportAny]
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
chunk = _as_dict(chunk_obj)
|
||||
choices = _as_list(chunk.get("choices"))
|
||||
if not choices:
|
||||
continue
|
||||
first = _as_dict(choices[0])
|
||||
delta = _as_dict(first.get("delta"))
|
||||
delta_content_obj = delta.get("content")
|
||||
if isinstance(delta_content_obj, str) and delta_content_obj:
|
||||
if first_token_time is None:
|
||||
first_token_time = time.perf_counter()
|
||||
tokens += 1
|
||||
text_parts.append(delta_content_obj)
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
preview = "".join(text_parts)[:200]
|
||||
|
||||
if not stats:
|
||||
ttft = (first_token_time - t0) if first_token_time is not None else elapsed
|
||||
gen_time = elapsed - ttft if tokens > 1 else elapsed
|
||||
gen_tps = (tokens - 1) / gen_time if tokens > 1 and gen_time > 0 else 0.0
|
||||
prompt_tps = pp_tokens / ttft if ttft > 0 else 0.0
|
||||
stats = GenerationStats(
|
||||
prompt_tokens=pp_tokens,
|
||||
generation_tokens=tokens,
|
||||
prompt_tps=round(prompt_tps, 2),
|
||||
generation_tps=round(gen_tps, 2),
|
||||
peak_memory_usage={"inBytes": 0},
|
||||
)
|
||||
|
||||
return (
|
||||
BenchRow(
|
||||
elapsed_s=elapsed,
|
||||
output_text_preview=preview,
|
||||
stats=stats,
|
||||
),
|
||||
pp_tokens,
|
||||
)
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Prompt-TPS / decode-TPS vs context-size sweep.
|
||||
|
||||
Methodology (see also ``bench/METHODOLOGY.md``):
|
||||
|
||||
Run a single ascending ramp of equally-spaced prompt lengths
|
||||
``pp ∈ {Δ, 2Δ, …, K·Δ}`` with ``prefix_cache=enabled``, ``repeat=1``,
|
||||
``concurrency=1`` and one warmup at ``pp=Δ``.
|
||||
|
||||
Because each step's prefix is exactly what the previous step left in
|
||||
the cache, every step beyond the first is a *partial* hit and the
|
||||
server-reported ``prompt_tps`` reflects the true cold rate over the
|
||||
fresh ``Δ``-token suffix. We accept the warmup's reported rate as the
|
||||
cold equivalent for ``pp=Δ`` (the warmup itself is the cold prefill).
|
||||
|
||||
``decode TPS`` is independent of prefill mechanics — every step's
|
||||
``generation_tps`` is a real decode-rate-at-N data point.
|
||||
|
||||
Cumulative cold-prefill upper bound:
|
||||
``T_cum(pp_k) = Σ_{i=1..k} (Δ_i / prompt_tps_i)``
|
||||
|
||||
Optional cold-control points (``prefix_cache=disabled``) validate the
|
||||
approximation; the gap quantifies per-task overhead. To preserve the
|
||||
``none`` cache-hit classification AND ensure the request actually
|
||||
hits a freshly-placed runner (the master picks the instance with the
|
||||
lowest in-flight task count, which is non-deterministic when multiple
|
||||
same-model instances exist), :func:`run` deletes the sweep instance
|
||||
*before* invoking the cold-control factory. The factory itself places
|
||||
a fresh instance per control and deletes it on exit; the
|
||||
:func:`bench.lib.cluster.managed_instance` ctx-manager calls
|
||||
``cleanup_all_instances`` on exit as a final safety net.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from exo_tools.client import ExoClient, ExoHttpError
|
||||
from exo_tools.harness import (
|
||||
Comm,
|
||||
Sharding,
|
||||
place_instance,
|
||||
wait_for_instance_gone,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from .completion import GenerationStats, PrefixCacheHit, run_one_completion
|
||||
from .prompt import PromptSizer
|
||||
from .results import ResultsBundle
|
||||
from .session import BenchSession
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextScalingParams:
|
||||
"""Inputs for a single context-scaling sweep."""
|
||||
|
||||
pp_step: int
|
||||
num_steps: int
|
||||
tg: int
|
||||
warmup: int = 1
|
||||
cold_controls: tuple[int, ...] = ()
|
||||
sleep_between_s: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
pp_tokens: int
|
||||
delta_tokens: int
|
||||
prompt_tps: float
|
||||
generation_tps: float
|
||||
prefix_cache_hit: PrefixCacheHit | str
|
||||
prompt_tokens: int
|
||||
generation_tokens: int
|
||||
elapsed_s: float
|
||||
peak_memory_bytes: int = 0
|
||||
output_text_preview: str = ""
|
||||
|
||||
|
||||
def _peak_bytes(stats: GenerationStats) -> int:
|
||||
pm = stats.get("peak_memory_usage") or {}
|
||||
return int(pm.get("inBytes") or pm.get("in_bytes") or 0)
|
||||
|
||||
|
||||
def _build_step_result(
|
||||
pp_tokens: int,
|
||||
delta_tokens: int,
|
||||
elapsed_s: float,
|
||||
output_text_preview: str,
|
||||
stats: GenerationStats,
|
||||
) -> StepResult:
|
||||
return StepResult(
|
||||
pp_tokens=pp_tokens,
|
||||
delta_tokens=delta_tokens,
|
||||
prompt_tps=float(stats.get("prompt_tps") or 0.0),
|
||||
generation_tps=float(stats.get("generation_tps") or 0.0),
|
||||
prefix_cache_hit=stats.get("prefix_cache_hit") or "unknown",
|
||||
prompt_tokens=int(stats.get("prompt_tokens") or pp_tokens),
|
||||
generation_tokens=int(stats.get("generation_tokens") or 0),
|
||||
elapsed_s=elapsed_s,
|
||||
peak_memory_bytes=_peak_bytes(stats),
|
||||
output_text_preview=output_text_preview[:200],
|
||||
)
|
||||
|
||||
|
||||
def _run_request(
|
||||
client: ExoClient,
|
||||
full_model_id: str,
|
||||
pp: int,
|
||||
tg: int,
|
||||
sizer: PromptSizer,
|
||||
*,
|
||||
use_prefix_cache: bool,
|
||||
) -> tuple[StepResult, int]:
|
||||
"""Send one request and return ``(StepResult, actual_pp_tokens)``."""
|
||||
row, actual_pp = run_one_completion(
|
||||
client,
|
||||
full_model_id,
|
||||
pp,
|
||||
tg,
|
||||
sizer,
|
||||
use_prefix_cache=use_prefix_cache,
|
||||
stream=False,
|
||||
)
|
||||
step = _build_step_result(
|
||||
pp_tokens=actual_pp,
|
||||
delta_tokens=actual_pp, # caller overrides for cached sweep
|
||||
elapsed_s=row["elapsed_s"],
|
||||
output_text_preview=row["output_text_preview"],
|
||||
stats=row["stats"],
|
||||
)
|
||||
return step, actual_pp
|
||||
|
||||
|
||||
def _compute_t_cum(steps: list[StepResult]) -> list[float]:
|
||||
t_cum = 0.0
|
||||
out: list[float] = []
|
||||
for s in steps:
|
||||
if s.prompt_tps > 0 and s.delta_tokens > 0:
|
||||
t_cum += s.delta_tokens / s.prompt_tps
|
||||
out.append(round(t_cum, 6))
|
||||
return out
|
||||
|
||||
|
||||
def run_cached_sweep(
|
||||
session: BenchSession,
|
||||
params: ContextScalingParams,
|
||||
bundle: ResultsBundle,
|
||||
) -> list[StepResult]:
|
||||
"""Run the ascending PP sweep with ``prefix_cache=enabled``.
|
||||
|
||||
Mutates ``bundle.runs`` in place and returns the typed step list.
|
||||
"""
|
||||
if session.full_model_id is None:
|
||||
raise RuntimeError(
|
||||
"BenchSession.full_model_id must be set for context-scaling."
|
||||
)
|
||||
|
||||
sizer = session.get_prompt_sizer()
|
||||
client = session.client
|
||||
pp_targets = [params.pp_step * i for i in range(1, params.num_steps + 1)]
|
||||
logger.info(
|
||||
f"context-scaling: K={params.num_steps} steps, Δ={params.pp_step} tokens, "
|
||||
f"tg={params.tg}, warmup={params.warmup}, cached"
|
||||
)
|
||||
|
||||
# Warmup discipline:
|
||||
# - First warmup runs with the prefix cache DISABLED. This triggers
|
||||
# the MLX kernel JIT compile + KV-buffer alloc for this exact
|
||||
# (Δ, dtype, batch) shape, but does NOT write a cache entry — so
|
||||
# the cold-with-JIT rate isn't fossilised.
|
||||
# - Subsequent warmups run with the prefix cache ENABLED. The
|
||||
# second one finds an empty cache, does a real cold prefill with
|
||||
# a HOT kernel, and writes the resulting rate into the cache
|
||||
# entry at pp=Δ.
|
||||
# - Step 0 (also cache-enabled) is then an exact hit on that entry
|
||||
# and reports the hot rate.
|
||||
# Default warmup=2 gives both effects; warmup=1 still does the JIT
|
||||
# warmup but leaves step 0 as a "none" hit (cold prefill at the hot
|
||||
# kernel, creates the cache entry on the way through).
|
||||
for w in range(params.warmup):
|
||||
is_jit_warmup = w == 0
|
||||
kind = "JIT warmup" if is_jit_warmup else "cache-prime warmup"
|
||||
logger.info(
|
||||
f" warmup {w + 1}/{params.warmup} ({kind}, pp={params.pp_step})"
|
||||
)
|
||||
_run_request(
|
||||
client,
|
||||
session.full_model_id,
|
||||
params.pp_step,
|
||||
params.tg,
|
||||
sizer,
|
||||
use_prefix_cache=not is_jit_warmup,
|
||||
)
|
||||
|
||||
steps: list[StepResult] = []
|
||||
prev_pp = 0
|
||||
for i, pp in enumerate(pp_targets):
|
||||
time.sleep(params.sleep_between_s)
|
||||
try:
|
||||
step, actual_pp = _run_request(
|
||||
client,
|
||||
session.full_model_id,
|
||||
pp,
|
||||
params.tg,
|
||||
sizer,
|
||||
use_prefix_cache=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"step {i + 1}/{params.num_steps} (pp={pp}) failed: {e}")
|
||||
raise
|
||||
|
||||
step.delta_tokens = actual_pp - prev_pp
|
||||
steps.append(step)
|
||||
bundle.runs.append({"step_index": i, "phase": "cached_sweep", **asdict(step)})
|
||||
logger.info(
|
||||
f" step {i + 1}/{params.num_steps} pp={actual_pp} Δ={step.delta_tokens} "
|
||||
f"prompt_tps={step.prompt_tps:.1f} gen_tps={step.generation_tps:.2f} "
|
||||
f"hit={step.prefix_cache_hit}"
|
||||
)
|
||||
prev_pp = actual_pp
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def run_cold_controls(
|
||||
factory: Callable[[], AbstractContextManager[ExoClient]],
|
||||
session: BenchSession,
|
||||
params: ContextScalingParams,
|
||||
bundle: ResultsBundle,
|
||||
) -> list[StepResult]:
|
||||
"""Run cold-control points on a fresh instance to preserve ``none`` hits.
|
||||
|
||||
A cold control is a single request at ``pp=N`` with
|
||||
``prefix_cache=disabled``, executed against a freshly-placed instance
|
||||
(and with no other same-model instance live, so the master's task
|
||||
routing is deterministic). The caller is expected to delete the
|
||||
sweep instance before invoking this — see :func:`run`.
|
||||
"""
|
||||
if not params.cold_controls:
|
||||
return []
|
||||
if session.full_model_id is None:
|
||||
raise RuntimeError("BenchSession.full_model_id must be set for cold controls.")
|
||||
|
||||
sizer = session.get_prompt_sizer()
|
||||
out: list[StepResult] = []
|
||||
for control_pp in params.cold_controls:
|
||||
logger.info(f"cold control: pp={control_pp} (fresh instance, cache disabled)")
|
||||
with factory() as fresh_client:
|
||||
step, actual_pp = _run_request(
|
||||
fresh_client,
|
||||
session.full_model_id,
|
||||
control_pp,
|
||||
params.tg,
|
||||
sizer,
|
||||
use_prefix_cache=False,
|
||||
)
|
||||
step.delta_tokens = actual_pp
|
||||
out.append(step)
|
||||
bundle.cold_controls.append({"phase": "cold_control", **asdict(step)})
|
||||
logger.info(
|
||||
f" cold pp={actual_pp} prompt_tps={step.prompt_tps:.1f} "
|
||||
f"gen_tps={step.generation_tps:.2f} hit={step.prefix_cache_hit}"
|
||||
)
|
||||
if step.prefix_cache_hit != "none":
|
||||
logger.warning(
|
||||
f"cold control at pp={actual_pp} reported "
|
||||
f"prefix_cache_hit={step.prefix_cache_hit!r}; "
|
||||
f"control may not be cold."
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def derive_summary(
|
||||
steps: list[StepResult],
|
||||
cold_controls: list[StepResult],
|
||||
) -> dict[str, Any]:
|
||||
"""Compute the cumulative cold-prefill upper bound + control gaps."""
|
||||
t_cum = _compute_t_cum(steps)
|
||||
bracketed = sorted(
|
||||
((s.pp_tokens, t) for s, t in zip(steps, t_cum, strict=True)),
|
||||
key=lambda x: x[0],
|
||||
)
|
||||
control_gaps: list[dict[str, float]] = []
|
||||
for ctrl in cold_controls:
|
||||
cold_t = ctrl.pp_tokens / ctrl.prompt_tps if ctrl.prompt_tps > 0 else 0.0
|
||||
cum_t = _interp(bracketed, ctrl.pp_tokens)
|
||||
gap = cum_t - cold_t
|
||||
control_gaps.append(
|
||||
{
|
||||
"pp_tokens": ctrl.pp_tokens,
|
||||
"cold_t_seconds": round(cold_t, 4),
|
||||
"t_cum_seconds_at_pp": round(cum_t, 4),
|
||||
"gap_seconds": round(gap, 4),
|
||||
"gap_fraction": round(gap / cold_t, 4) if cold_t > 0 else 0.0,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"t_cum_seconds": t_cum,
|
||||
"control_gaps": control_gaps,
|
||||
}
|
||||
|
||||
|
||||
def _interp(points: list[tuple[int, float]], x: int) -> float:
|
||||
"""Linear interpolate y at x, given sorted ``(x, y)`` points."""
|
||||
if not points:
|
||||
return 0.0
|
||||
if x <= points[0][0]:
|
||||
return points[0][1]
|
||||
if x >= points[-1][0]:
|
||||
return points[-1][1]
|
||||
for i in range(1, len(points)):
|
||||
x0, y0 = points[i - 1]
|
||||
x1, y1 = points[i]
|
||||
if x0 <= x <= x1 and x1 != x0:
|
||||
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
|
||||
return points[-1][1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cold-control instance factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_cold_control_factory(
|
||||
session: BenchSession,
|
||||
sharding: Sharding,
|
||||
comm: Comm,
|
||||
min_nodes: int,
|
||||
instance_timeout_s: float = 1800.0,
|
||||
) -> Callable[[], AbstractContextManager[ExoClient]]:
|
||||
"""Return a callable yielding a context manager that places a fresh instance.
|
||||
|
||||
Each ``with factory() as client:`` block places a brand-new instance,
|
||||
yields its client, then deletes the instance on exit. Used to isolate
|
||||
cold-control runs.
|
||||
|
||||
The caller is responsible for ensuring no other same-model instance is
|
||||
live during the ``with`` block — otherwise master routing is
|
||||
non-deterministic and the cold control may be served by a stale runner.
|
||||
See :func:`run` for the orchestration.
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
def factory() -> Iterator[ExoClient]:
|
||||
if session.full_model_id is None:
|
||||
raise RuntimeError("session.full_model_id is unset")
|
||||
client = session.client
|
||||
instance_id = place_instance(
|
||||
client,
|
||||
session.full_model_id,
|
||||
sharding=sharding,
|
||||
comm=comm,
|
||||
min_nodes=min_nodes,
|
||||
timeout=instance_timeout_s,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
with contextlib.suppress(ExoHttpError):
|
||||
client.request_json("DELETE", f"/instance/{instance_id}")
|
||||
with contextlib.suppress(Exception):
|
||||
wait_for_instance_gone(client, instance_id, timeout=60.0)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
def _delete_instance(client: ExoClient, instance_id: str) -> None:
|
||||
"""Best-effort delete of a placed instance."""
|
||||
with contextlib.suppress(ExoHttpError):
|
||||
client.request_json("DELETE", f"/instance/{instance_id}")
|
||||
with contextlib.suppress(Exception):
|
||||
wait_for_instance_gone(client, instance_id, timeout=60.0)
|
||||
|
||||
|
||||
def run(
|
||||
session: BenchSession,
|
||||
params: ContextScalingParams,
|
||||
bundle: ResultsBundle,
|
||||
*,
|
||||
cold_control_factory: Callable[[], AbstractContextManager[ExoClient]] | None = None,
|
||||
) -> ResultsBundle:
|
||||
"""End-to-end: cached sweep + optional cold controls + derived summary.
|
||||
|
||||
To make cold controls truly isolated from the sweep instance, we
|
||||
delete the sweep instance *before* running the controls (otherwise
|
||||
the master might route a control's request to the stale sweep
|
||||
instance, since both match the same ``model_id``). The controls then
|
||||
each place their own fresh instance via the factory.
|
||||
"""
|
||||
bundle.params.update(
|
||||
{
|
||||
"pp_step": params.pp_step,
|
||||
"num_steps": params.num_steps,
|
||||
"tg": params.tg,
|
||||
"warmup": params.warmup,
|
||||
"cold_controls": list(params.cold_controls),
|
||||
"sleep_between_s": params.sleep_between_s,
|
||||
"model_id": session.model_id,
|
||||
"full_model_id": session.full_model_id,
|
||||
}
|
||||
)
|
||||
bundle.capture_cluster(session.client)
|
||||
|
||||
cached_steps = run_cached_sweep(session, params, bundle)
|
||||
|
||||
cold_steps: list[StepResult] = []
|
||||
if params.cold_controls and cold_control_factory is not None:
|
||||
# Delete the sweep instance so the cold-control fresh instance is
|
||||
# the only same-model instance live for the duration of the controls.
|
||||
if session.instance_id is not None:
|
||||
logger.info(
|
||||
f"cold controls: deleting sweep instance {session.instance_id} "
|
||||
"to isolate fresh instance routing"
|
||||
)
|
||||
_delete_instance(session.client, session.instance_id)
|
||||
session.instance_id = None
|
||||
cold_steps = run_cold_controls(cold_control_factory, session, params, bundle)
|
||||
elif params.cold_controls and cold_control_factory is None:
|
||||
logger.warning(
|
||||
"Cold controls requested but no cold_control_factory supplied; skipping."
|
||||
)
|
||||
|
||||
bundle.derived.update(derive_summary(cached_steps, cold_steps))
|
||||
return bundle
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Fetch HuggingFace model metadata for benchmark planning.
|
||||
|
||||
Two pieces of metadata drive every benchmark we run:
|
||||
|
||||
1. **Total weight size** — used to derive ``min-memory`` and ``min-disk``
|
||||
constraints when picking a host. We sum the sizes of all
|
||||
``.safetensors`` (or ``.bin``) shards from the repo's file listing.
|
||||
2. **Max position embeddings** — the model's training context length.
|
||||
Used to bound a context-scaling sweep at the model's max context, and
|
||||
to derive a sensible Δ given a target step count.
|
||||
|
||||
The fetcher uses the ``huggingface_hub`` python API, which talks to the
|
||||
public HF Hub HTTPS endpoints — no exo cluster required, no download
|
||||
of weights.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
|
||||
# Files that count toward the on-disk weight footprint.
|
||||
_WEIGHT_SUFFIXES = (".safetensors", ".bin", ".gguf", ".pt", ".npz")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelMeta:
|
||||
"""Subset of HF metadata that a benchmark needs."""
|
||||
|
||||
model_id: str
|
||||
total_weight_bytes: int
|
||||
max_position_embeddings: int
|
||||
num_hidden_layers: int
|
||||
raw_config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def total_weight_gb(self) -> float:
|
||||
return self.total_weight_bytes / (1024**3)
|
||||
|
||||
@property
|
||||
def memory_constraint_gb(self) -> float:
|
||||
"""Estimated minimum host memory to hold weights + overhead.
|
||||
|
||||
Picks the model size + 30 % headroom (KV cache, activations,
|
||||
framework bookkeeping). Rounded up to the next whole GiB.
|
||||
"""
|
||||
return float(int(self.total_weight_gb * 1.30) + 1)
|
||||
|
||||
@property
|
||||
def disk_constraint_gb(self) -> float:
|
||||
"""Disk space the host must have free for the download."""
|
||||
return float(int(self.total_weight_gb * 1.10) + 1)
|
||||
|
||||
|
||||
def _read_config_json(model_id: str) -> dict[str, Any]:
|
||||
from huggingface_hub import (
|
||||
hf_hub_download, # type: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
raw_path = hf_hub_download(repo_id=model_id, filename="config.json", dry_run=False)
|
||||
with open(raw_path) as f:
|
||||
loaded: Any = json.load(f) # type: ignore[reportAny]
|
||||
return cast("dict[str, Any]", loaded) if isinstance(loaded, dict) else {}
|
||||
|
||||
|
||||
def _sum_weight_sizes(model_id: str) -> int:
|
||||
"""Sum sizes of all weight-shard files in the repo's file listing."""
|
||||
from huggingface_hub import HfApi
|
||||
|
||||
api = HfApi()
|
||||
info = api.model_info(repo_id=model_id, files_metadata=True)
|
||||
siblings = info.siblings or []
|
||||
total = 0
|
||||
for sib in siblings:
|
||||
rfilename = getattr(sib, "rfilename", None)
|
||||
size = getattr(sib, "size", None)
|
||||
if not isinstance(rfilename, str) or not isinstance(size, int):
|
||||
continue
|
||||
if any(rfilename.endswith(suf) for suf in _WEIGHT_SUFFIXES):
|
||||
total += size
|
||||
return total
|
||||
|
||||
|
||||
def _first_int(config: dict[str, Any], *keys: str) -> int:
|
||||
"""Return the first key from ``config`` that holds a usable positive int."""
|
||||
for key in keys:
|
||||
value = config.get(key)
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError:
|
||||
continue
|
||||
if parsed > 0:
|
||||
return parsed
|
||||
return 0
|
||||
|
||||
|
||||
def fetch_model_meta(model_id: str) -> ModelMeta:
|
||||
"""Fetch the metadata our benchmarks care about for ``model_id``.
|
||||
|
||||
Args:
|
||||
model_id: HuggingFace repo id, e.g. ``mlx-community/Qwen3-30B-A3B-4bit``.
|
||||
|
||||
Returns:
|
||||
Populated :class:`ModelMeta`.
|
||||
|
||||
Raises:
|
||||
Exception: any HTTP / parse error from ``huggingface_hub`` propagates.
|
||||
"""
|
||||
config = _read_config_json(model_id)
|
||||
return ModelMeta(
|
||||
model_id=model_id,
|
||||
total_weight_bytes=_sum_weight_sizes(model_id),
|
||||
max_position_embeddings=_first_int(
|
||||
config,
|
||||
"max_position_embeddings",
|
||||
"max_seq_len",
|
||||
"model_max_length",
|
||||
"n_positions",
|
||||
),
|
||||
num_hidden_layers=_first_int(
|
||||
config,
|
||||
"num_hidden_layers",
|
||||
"num_layers",
|
||||
"n_layer",
|
||||
"n_layers",
|
||||
"num_decoder_layers",
|
||||
),
|
||||
raw_config=config,
|
||||
)
|
||||
|
||||
|
||||
def derive_context_ramp(
|
||||
meta: ModelMeta,
|
||||
*,
|
||||
num_steps: int,
|
||||
fraction_of_max: float = 1.0,
|
||||
min_pp_step: int = 256,
|
||||
round_to: int = 256,
|
||||
) -> tuple[int, int]:
|
||||
"""Pick ``(pp_step, num_steps)`` covering ``fraction_of_max`` of the context.
|
||||
|
||||
Δ is rounded down to the nearest ``round_to`` so the per-step prompt is a
|
||||
clean number, and clamped to ``min_pp_step`` for tiny-context models.
|
||||
"""
|
||||
if meta.max_position_embeddings <= 0:
|
||||
raise ValueError(
|
||||
f"{meta.model_id} reports max_position_embeddings=0 in config.json"
|
||||
)
|
||||
if not (0.0 < fraction_of_max <= 1.0):
|
||||
raise ValueError(f"fraction_of_max must be in (0, 1], got {fraction_of_max}")
|
||||
if num_steps <= 0:
|
||||
raise ValueError(f"num_steps must be >0, got {num_steps}")
|
||||
|
||||
target_max = int(meta.max_position_embeddings * fraction_of_max)
|
||||
raw_step = max(min_pp_step, target_max // num_steps)
|
||||
pp_step = (raw_step // round_to) * round_to or round_to
|
||||
return pp_step, num_steps
|
||||
|
||||
|
||||
def derive_cold_controls(
|
||||
meta: ModelMeta,
|
||||
*,
|
||||
pp_step: int,
|
||||
num_steps: int,
|
||||
count: int = 4,
|
||||
) -> tuple[int, ...]:
|
||||
"""Pick ``count`` evenly-spaced cold-control points across the ramp.
|
||||
|
||||
Always includes the largest ramp point (``pp_step * num_steps``).
|
||||
Returns control pp values in ascending order, deduped.
|
||||
"""
|
||||
if count <= 0:
|
||||
return ()
|
||||
max_pp = pp_step * num_steps
|
||||
if count == 1:
|
||||
return (max_pp,)
|
||||
spaced = sorted({(max_pp * (i + 1)) // count for i in range(count)})
|
||||
# Filter out anything below pp_step (a control at <Δ is meaningless).
|
||||
return tuple(p for p in spaced if p >= pp_step)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Typed matplotlib renderers for benchmark JSON results.
|
||||
|
||||
This module owns the *visualisation* of bench results, mirroring how
|
||||
``bench/lib/<name>.py`` owns the methodology and ``bench/cli/<name>.py``
|
||||
owns the orchestration. Adding plotting for a new benchmark = a new
|
||||
``render_<name>`` function here + a dispatch entry in ``bench/cli/plot.py``.
|
||||
|
||||
Functions take typed inputs (``Path`` lists, options) and write a PNG.
|
||||
They never touch argparse or stdout — that's the CLI's job.
|
||||
|
||||
matplotlib's type stubs are thin (most return values are ``Any``), so all
|
||||
calls into ``pyplot`` are concentrated at the bottom of this file with
|
||||
targeted ``# type: ignore[reportUnknownMemberType, reportAny]`` per line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
# Tab10 cycle from matplotlib's default; we pick colours by index ourselves
|
||||
# instead of fishing them out of `Line2D.get_color()` so the strict-type
|
||||
# fallout stays small and predictable.
|
||||
_COLOR_CYCLE: tuple[str, ...] = (
|
||||
"C0",
|
||||
"C1",
|
||||
"C2",
|
||||
"C3",
|
||||
"C4",
|
||||
"C5",
|
||||
"C6",
|
||||
"C7",
|
||||
"C8",
|
||||
"C9",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlotInputs:
|
||||
"""Inputs for any benchmark renderer.
|
||||
|
||||
Attributes:
|
||||
results: One or more bench JSON files. The first is used to
|
||||
auto-derive the title when ``title`` is unset.
|
||||
output: Path to write the PNG to.
|
||||
label_tag: When set, use ``metadata.tags[label_tag]`` as the
|
||||
legend label for each run; otherwise use the run id.
|
||||
title: Override for the figure title.
|
||||
"""
|
||||
|
||||
results: list[Path]
|
||||
output: Path
|
||||
label_tag: str | None = None
|
||||
title: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RunSeries:
|
||||
"""Pre-extracted plot data for one results JSON.
|
||||
|
||||
``cached_prefill_seconds`` is the cumulative cold-prefill estimate
|
||||
(``T_cum`` from the methodology — read from ``derived.t_cum_seconds``).
|
||||
``control_prefill_seconds`` is the actual cold prefill time per
|
||||
control (``pp_tokens / prompt_tps`` from the cold-control row).
|
||||
"""
|
||||
|
||||
label: str
|
||||
cached_pp: list[int]
|
||||
cached_prefill_seconds: list[float]
|
||||
cached_gen_tps: list[float]
|
||||
control_pp: list[int]
|
||||
control_prefill_seconds: list[float]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure data extraction (strict-typed, no matplotlib)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, Any]:
|
||||
"""Read a bench JSON file and assert top-level shape."""
|
||||
with path.open() as f:
|
||||
loaded: Any = json.load(f) # type: ignore[reportAny]
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"{path}: expected top-level JSON object")
|
||||
return cast("dict[str, Any]", loaded)
|
||||
|
||||
|
||||
# dict[str, Any].get(...) returns Any. The five _get_* helpers below
|
||||
# concentrate the Any boundary so the rest of the module can be strict.
|
||||
|
||||
|
||||
def _get_dict(d: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
val: Any = d.get(key)
|
||||
return cast("dict[str, Any]", val) if isinstance(val, dict) else {}
|
||||
|
||||
|
||||
def _get_list(d: dict[str, Any], key: str) -> list[Any]:
|
||||
val: Any = d.get(key)
|
||||
return cast("list[Any]", val) if isinstance(val, list) else []
|
||||
|
||||
|
||||
def _get_str(d: dict[str, Any], key: str, default: str = "") -> str:
|
||||
val: Any = d.get(key, default) # type: ignore[reportAny]
|
||||
return val if isinstance(val, str) else default
|
||||
|
||||
|
||||
def _get_int(row: dict[str, Any], key: str) -> int:
|
||||
val: Any = row.get(key, 0) # type: ignore[reportAny]
|
||||
if isinstance(val, bool): # bool is int; reject explicitly
|
||||
return 0
|
||||
if isinstance(val, (int, float)):
|
||||
return int(val)
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return int(float(val))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _get_float(row: dict[str, Any], key: str) -> float:
|
||||
val: Any = row.get(key, 0.0) # type: ignore[reportAny]
|
||||
if isinstance(val, bool):
|
||||
return 0.0
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return float(val)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def _label_for(data: dict[str, Any], label_tag: str | None) -> str:
|
||||
if label_tag is not None:
|
||||
tags = _get_dict(_get_dict(data, "metadata"), "tags")
|
||||
if label_tag in tags:
|
||||
return _get_str(tags, label_tag, "(unnamed)")
|
||||
return _get_str(_get_dict(data, "metadata"), "run_id", "(unnamed)")
|
||||
|
||||
|
||||
def _extract_series(data: dict[str, Any], label: str) -> _RunSeries:
|
||||
"""Pre-extract typed lists from a context-scaling bench JSON."""
|
||||
cached_pp: list[int] = []
|
||||
cached_gen_tps: list[float] = []
|
||||
for raw in _get_list(data, "runs"): # type: ignore[reportAny]
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
row = cast("dict[str, Any]", raw)
|
||||
if _get_str(row, "phase") != "cached_sweep":
|
||||
continue
|
||||
cached_pp.append(_get_int(row, "pp_tokens"))
|
||||
cached_gen_tps.append(_get_float(row, "generation_tps"))
|
||||
|
||||
# Cumulative cold-prefill estimate is computed in derive_summary and
|
||||
# written to derived.t_cum_seconds (parallel to the cached steps).
|
||||
derived = _get_dict(data, "derived")
|
||||
t_cum_raw = _get_list(derived, "t_cum_seconds")
|
||||
cached_prefill_seconds: list[float] = []
|
||||
for raw in t_cum_raw: # type: ignore[reportAny]
|
||||
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
|
||||
cached_prefill_seconds.append(float(raw))
|
||||
|
||||
# Cold controls give us the actual cold prefill time directly:
|
||||
# pp_tokens / prompt_tps. Skip rows with zero/missing prompt_tps.
|
||||
control_pp: list[int] = []
|
||||
control_prefill_seconds: list[float] = []
|
||||
for raw in _get_list(data, "cold_controls"): # type: ignore[reportAny]
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
row = cast("dict[str, Any]", raw)
|
||||
pp = _get_int(row, "pp_tokens")
|
||||
tps = _get_float(row, "prompt_tps")
|
||||
if pp > 0 and tps > 0:
|
||||
control_pp.append(pp)
|
||||
control_prefill_seconds.append(pp / tps)
|
||||
|
||||
return _RunSeries(
|
||||
label=label,
|
||||
cached_pp=cached_pp,
|
||||
cached_prefill_seconds=cached_prefill_seconds,
|
||||
cached_gen_tps=cached_gen_tps,
|
||||
control_pp=control_pp,
|
||||
control_prefill_seconds=control_prefill_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _auto_title(data: dict[str, Any]) -> str:
|
||||
metadata = _get_dict(data, "metadata")
|
||||
params = _get_dict(data, "params")
|
||||
model = (
|
||||
_get_str(params, "full_model_id") or _get_str(params, "model_id") or "(unknown)"
|
||||
)
|
||||
sha = _get_str(metadata, "exo_sha") or "(no-sha)"
|
||||
host = _get_str(metadata, "hostname") or "(no-host)"
|
||||
return f"{model}\n{sha} on {host}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matplotlib boundary — each call site has a narrow, justified ignore.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_context_scaling(inputs: PlotInputs) -> Path:
|
||||
"""Render a 2-panel context-scaling plot.
|
||||
|
||||
Top: pp_tokens vs prompt_tps (line per run; cold controls as 'x' scatter)
|
||||
Bottom: pp_tokens vs generation_tps (line per run)
|
||||
|
||||
Each line is a separate result file. Multi-file mode is for comparing
|
||||
runs across exo SHAs / hosts / configs; the title is taken from the
|
||||
first file's metadata unless ``inputs.title`` is set.
|
||||
"""
|
||||
if not inputs.results:
|
||||
raise ValueError("at least one results JSON path is required")
|
||||
|
||||
# Validate + extract first so any data-shape error surfaces before we
|
||||
# even import matplotlib.
|
||||
first_data: dict[str, Any] | None = None
|
||||
series: list[_RunSeries] = []
|
||||
for path in inputs.results:
|
||||
data = _load(path)
|
||||
if first_data is None:
|
||||
first_data = data
|
||||
benchmark = _get_str(_get_dict(data, "metadata"), "benchmark")
|
||||
if benchmark != "context_scaling":
|
||||
raise ValueError(
|
||||
f"{path}: expected benchmark=='context_scaling', got {benchmark!r}"
|
||||
)
|
||||
series.append(_extract_series(data, _label_for(data, inputs.label_tag)))
|
||||
|
||||
title = inputs.title
|
||||
if title is None and first_data is not None:
|
||||
title = _auto_title(first_data)
|
||||
if len(inputs.results) > 1:
|
||||
title = f"{title}\n(comparison of {len(inputs.results)} runs)"
|
||||
|
||||
inputs.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
_draw(series, inputs.output, title=title)
|
||||
return inputs.output
|
||||
|
||||
|
||||
def _draw(series: list[_RunSeries], output: Path, *, title: str | None) -> None:
|
||||
"""Concentrated matplotlib boundary."""
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, axes = plt.subplots( # type: ignore[reportUnknownMemberType]
|
||||
2, 1, figsize=(10, 8), sharex=True
|
||||
)
|
||||
top: Any = axes[0] # type: ignore[reportAny]
|
||||
bottom: Any = axes[1] # type: ignore[reportAny]
|
||||
|
||||
for i, run in enumerate(series):
|
||||
color = _COLOR_CYCLE[i % len(_COLOR_CYCLE)]
|
||||
# Cumulative cold-prefill estimate (T_cum). Only plot points where
|
||||
# we have a t_cum value — skip if derived was empty for this run.
|
||||
n = min(len(run.cached_pp), len(run.cached_prefill_seconds))
|
||||
if n > 0:
|
||||
top.plot( # type: ignore[reportAny, reportUnknownMemberType]
|
||||
run.cached_pp[:n],
|
||||
run.cached_prefill_seconds[:n],
|
||||
"-o",
|
||||
color=color,
|
||||
label=run.label,
|
||||
)
|
||||
if run.control_pp:
|
||||
top.scatter( # type: ignore[reportAny, reportUnknownMemberType]
|
||||
run.control_pp,
|
||||
run.control_prefill_seconds,
|
||||
marker="x",
|
||||
s=80,
|
||||
color=color,
|
||||
label=f"{run.label} (cold one-shot)",
|
||||
)
|
||||
bottom.plot( # type: ignore[reportAny, reportUnknownMemberType]
|
||||
run.cached_pp,
|
||||
run.cached_gen_tps,
|
||||
"-o",
|
||||
color=color,
|
||||
label=run.label,
|
||||
)
|
||||
|
||||
top.set_ylabel("prefill time (s)") # type: ignore[reportAny, reportUnknownMemberType]
|
||||
top.set_title( # type: ignore[reportAny, reportUnknownMemberType]
|
||||
"cumulative cold-prefill time vs context size "
|
||||
"(line: T_cum estimate; ✕: cold one-shot control)"
|
||||
)
|
||||
top.grid(True, alpha=0.3) # type: ignore[reportAny, reportUnknownMemberType]
|
||||
top.legend(loc="best", fontsize=8) # type: ignore[reportAny, reportUnknownMemberType]
|
||||
|
||||
bottom.set_xlabel("pp_tokens") # type: ignore[reportAny, reportUnknownMemberType]
|
||||
bottom.set_ylabel("generation_tps (tok/s)") # type: ignore[reportAny, reportUnknownMemberType]
|
||||
bottom.set_title("decode throughput vs context size") # type: ignore[reportAny, reportUnknownMemberType]
|
||||
bottom.grid(True, alpha=0.3) # type: ignore[reportAny, reportUnknownMemberType]
|
||||
|
||||
if title is not None:
|
||||
fig.suptitle(title, fontsize=10) # type: ignore[reportUnknownMemberType]
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(output, dpi=120, bbox_inches="tight") # type: ignore[reportUnknownMemberType]
|
||||
plt.close(fig)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Typed prompt-sizing utilities for benchmarks.
|
||||
|
||||
Wraps the HuggingFace ``transformers`` tokenizer (a fundamentally dynamic
|
||||
object — different models return different types from
|
||||
``apply_chat_template``) behind a small typed API so the rest of the bench
|
||||
library can stay strict-typed.
|
||||
|
||||
``PromptSizer.build(target)`` returns a ``(content, exact_token_count)``
|
||||
pair. Internally it:
|
||||
1. Tokenises the empty user message to learn the chat-template overhead
|
||||
(``base_tokens``).
|
||||
2. Estimates tokens-per-atom from a 100-atom sample.
|
||||
3. Binary-searches over the atom count so the resulting message
|
||||
tokenises to *exactly* ``target`` tokens.
|
||||
|
||||
Callers downstream (``run_one_completion`` etc.) receive the verified
|
||||
token count, so analysis can confirm the prompt hit its target.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, cast
|
||||
|
||||
|
||||
def _coerce_token_ids(raw: object) -> list[int]:
|
||||
"""Normalise ``apply_chat_template`` output to a flat list of token ids.
|
||||
|
||||
transformers' ``apply_chat_template`` may return:
|
||||
- ``list[int]`` (slow tokenizers, ``tokenize=True``)
|
||||
- a ``BatchEncoding`` with ``.input_ids`` (fast tokenizers)
|
||||
- a tensor wrapped object (some models)
|
||||
|
||||
We only need ``len(.)`` of the result, so we just need to flatten to a
|
||||
list and return it.
|
||||
"""
|
||||
if isinstance(raw, list):
|
||||
return cast("list[int]", raw)
|
||||
input_ids = getattr(raw, "input_ids", None)
|
||||
if isinstance(input_ids, list):
|
||||
return cast("list[int]", input_ids)
|
||||
raise TypeError(
|
||||
f"Unsupported tokenizer output type {type(raw).__name__}; "
|
||||
"expected list[int] or BatchEncoding-like with .input_ids."
|
||||
)
|
||||
|
||||
|
||||
def _build_token_counter(tokenizer: object) -> Callable[[str], int]:
|
||||
"""Return a closure that counts tokens for a user message.
|
||||
|
||||
Tries ``apply_chat_template`` first; falls back to the DeepSeek-V4
|
||||
Python encoder for models that don't ship a Jinja chat template.
|
||||
"""
|
||||
apply_chat_template = cast(
|
||||
Callable[..., object],
|
||||
tokenizer.apply_chat_template, # type: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
||||
)
|
||||
encode = cast(
|
||||
Callable[..., list[int]],
|
||||
tokenizer.encode, # type: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
||||
)
|
||||
|
||||
def count_fn(user_content: str) -> int:
|
||||
messages = [{"role": "user", "content": user_content}]
|
||||
try:
|
||||
raw = apply_chat_template(
|
||||
messages, tokenize=True, add_generation_prompt=True
|
||||
)
|
||||
except ValueError:
|
||||
# Models without a Jinja chat template (e.g. DeepSeek V4 which
|
||||
# ships its own Python encoder). Use the exo-side V4 encoder.
|
||||
from exo.worker.engines.mlx.vendor.deepseek_v4_encoding import ( # type: ignore[reportMissingTypeStubs]
|
||||
encode_messages as encode_v4,
|
||||
)
|
||||
|
||||
prompt = cast(str, encode_v4(messages, thinking_mode="thinking")) # type: ignore[reportUnknownArgumentType]
|
||||
raw = encode(prompt, add_special_tokens=False)
|
||||
return len(_coerce_token_ids(raw))
|
||||
|
||||
return count_fn
|
||||
|
||||
|
||||
class PromptSizer:
|
||||
"""Build a chat-completion content string of an exact token length."""
|
||||
|
||||
DEFAULT_ATOM: Final[str] = "a "
|
||||
|
||||
def __init__(self, tokenizer: object, atom: str = DEFAULT_ATOM):
|
||||
self._tokenizer = tokenizer
|
||||
self.atom = atom
|
||||
self._count_fn = _build_token_counter(tokenizer)
|
||||
self.base_tokens = self._count_fn("")
|
||||
|
||||
def count(self, content: str) -> int:
|
||||
"""Return the token count for ``content`` after chat-template expansion."""
|
||||
return self._count_fn(content)
|
||||
|
||||
def build(self, target_prompt_tokens: int) -> tuple[str, int]:
|
||||
"""Return ``(content, exact_token_count)`` summing to ``target``.
|
||||
|
||||
Raises ``RuntimeError`` if the chosen ``atom`` overshoots the target
|
||||
(try a different atom — see ``DEFAULT_ATOM``).
|
||||
"""
|
||||
target = int(target_prompt_tokens)
|
||||
if target < self.base_tokens:
|
||||
raise RuntimeError(
|
||||
f"Target ({target}) is smaller than template overhead "
|
||||
f"({self.base_tokens})."
|
||||
)
|
||||
|
||||
# Estimate tokens per atom using a sample.
|
||||
sample_count = 100
|
||||
sample_tokens = self._count_fn(self.atom * sample_count) - self.base_tokens
|
||||
tokens_per_atom = sample_tokens / sample_count
|
||||
needed_tokens = target - self.base_tokens
|
||||
estimated_atoms = int(needed_tokens / tokens_per_atom)
|
||||
|
||||
# Binary search to find exact atom count.
|
||||
low, high = 0, estimated_atoms * 2 + 100
|
||||
while low < high:
|
||||
mid = (low + high) // 2
|
||||
if self._count_fn(self.atom * mid) < target:
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid
|
||||
|
||||
content = self.atom * low
|
||||
actual = self._count_fn(content)
|
||||
if actual != target:
|
||||
raise RuntimeError(
|
||||
f"Overshot: got {actual} tokens (target {target}). "
|
||||
f"Pick a different atom (try ' a' or '\\n' or '0 ')."
|
||||
)
|
||||
return content, actual
|
||||
|
||||
|
||||
def _load_kimi_tokenizer(model_id: str) -> object:
|
||||
"""Special-case Kimi K2's custom TikTokenTokenizer (transformers 5.x quirk)."""
|
||||
from huggingface_hub import (
|
||||
snapshot_download, # type: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
raw_path = snapshot_download(
|
||||
model_id,
|
||||
allow_patterns=[
|
||||
"*.json",
|
||||
"*.py",
|
||||
"*.tiktoken",
|
||||
"*.model",
|
||||
"*.jinja",
|
||||
],
|
||||
dry_run=False,
|
||||
)
|
||||
model_path = Path(raw_path)
|
||||
sys.path.insert(0, str(model_path))
|
||||
|
||||
tool_decl_path = model_path / "tool_declaration_ts.py"
|
||||
if tool_decl_path.exists():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"tool_declaration_ts", tool_decl_path
|
||||
)
|
||||
if spec is not None and spec.loader is not None:
|
||||
tool_decl_module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["tool_declaration_ts"] = tool_decl_module
|
||||
spec.loader.exec_module(tool_decl_module)
|
||||
|
||||
tok_path = model_path / "tokenization_kimi.py"
|
||||
source = tok_path.read_text().replace(
|
||||
"from .tool_declaration_ts", "from tool_declaration_ts"
|
||||
)
|
||||
tok_module = types.ModuleType("tokenization_kimi")
|
||||
tok_module.__file__ = str(tok_path)
|
||||
sys.modules["tokenization_kimi"] = tok_module
|
||||
exec(compile(source, str(tok_path), "exec"), tok_module.__dict__) # noqa: S102
|
||||
|
||||
tik_token_cls = cast(Any, tok_module).TikTokenTokenizer # type: ignore[reportAny]
|
||||
hf_tokenizer = cast(Any, tik_token_cls.from_pretrained(model_path)) # type: ignore[reportAny]
|
||||
|
||||
# Patch encode to use internal tiktoken model directly (transformers 5.x
|
||||
# bug in the encode→pad path for slow tokenizers).
|
||||
def _patched_encode(text: str, **_kwargs: object) -> list[int]:
|
||||
return list(
|
||||
hf_tokenizer.model.encode(text, allowed_special="all") # type: ignore[reportAny, reportUnknownMemberType]
|
||||
)
|
||||
|
||||
hf_tokenizer.encode = _patched_encode
|
||||
return cast(object, hf_tokenizer)
|
||||
|
||||
|
||||
def load_tokenizer_for_bench(model_id: str) -> object:
|
||||
"""Load a HuggingFace tokenizer with bench-specific compatibility shims.
|
||||
|
||||
Returns the tokenizer as ``object`` because transformers' types are
|
||||
fundamentally dynamic (concrete class depends on the model). Callers
|
||||
should pass the result straight to :class:`PromptSizer`.
|
||||
"""
|
||||
# Monkey-patch for transformers 5.x: Kimi's tokenization_kimi.py imports
|
||||
# bytes_to_unicode from gpt2_tokenization which moved.
|
||||
try:
|
||||
import transformers.models.gpt2.tokenization_gpt2 as gpt2_tokenization
|
||||
from transformers.convert_slow_tokenizer import bytes_to_unicode
|
||||
|
||||
if not hasattr(gpt2_tokenization, "bytes_to_unicode"):
|
||||
gpt2_tokenization.bytes_to_unicode = bytes_to_unicode # type: ignore[reportAttributeAccessIssue]
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if "kimi-k2" in model_id.lower():
|
||||
return _load_kimi_tokenizer(model_id)
|
||||
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
try:
|
||||
return cast(
|
||||
object,
|
||||
AutoTokenizer.from_pretrained(model_id, trust_remote_code=True), # type: ignore[reportUnknownMemberType]
|
||||
)
|
||||
except (AttributeError, ValueError):
|
||||
# Some models ship a Jinja template / encoder that AutoTokenizer
|
||||
# can't introspect from HF directly — download artefacts and load
|
||||
# from the local snapshot path.
|
||||
from huggingface_hub import (
|
||||
snapshot_download, # type: ignore[reportUnknownVariableType]
|
||||
)
|
||||
from transformers import PretrainedConfig
|
||||
|
||||
raw_full_path = snapshot_download(
|
||||
model_id,
|
||||
allow_patterns=[
|
||||
"*.json",
|
||||
"*.py",
|
||||
"tokenizer.model",
|
||||
"*.tiktoken",
|
||||
"tiktoken.model",
|
||||
"*.txt",
|
||||
"*.jsonl",
|
||||
"*.jinja",
|
||||
],
|
||||
dry_run=False,
|
||||
)
|
||||
model_path = Path(raw_full_path)
|
||||
stub_kwargs: dict[str, Any] = {}
|
||||
config_file = model_path / "config.json"
|
||||
if config_file.exists():
|
||||
with config_file.open() as f:
|
||||
raw_config: dict[str, Any] = json.load(f) # type: ignore[reportAny]
|
||||
for key in (
|
||||
"model_type",
|
||||
"max_position_embeddings",
|
||||
"vocab_size",
|
||||
"bos_token_id",
|
||||
"eos_token_id",
|
||||
"pad_token_id",
|
||||
):
|
||||
if key in raw_config:
|
||||
stub_kwargs[key] = raw_config[key]
|
||||
return cast(
|
||||
object,
|
||||
AutoTokenizer.from_pretrained( # type: ignore[reportUnknownMemberType]
|
||||
str(model_path),
|
||||
config=PretrainedConfig(**stub_kwargs), # type: ignore[reportArgumentType, reportAny]
|
||||
trust_remote_code=True,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Structured benchmark results — metadata capture + JSON output.
|
||||
|
||||
Every benchmark run produces a single JSON file with a stable schema:
|
||||
|
||||
- ``metadata``: exo SHA, ISO timestamps, hostnames, and any user-supplied
|
||||
tags identifying the run.
|
||||
- ``cluster``: snapshot from the API (node identities, topology, memory).
|
||||
- ``params``: the benchmark's input parameters (sweep config, etc).
|
||||
- ``runs``: per-request result rows.
|
||||
- ``derived``: any computed summaries (``t_cum_seconds`` for context scaling).
|
||||
|
||||
The format is intentionally additive so downstream tooling (plot scripts,
|
||||
dashboards) can rely on optional fields being absent rather than malformed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from exo_tools.client import ExoClient
|
||||
from exo_tools.harness import capture_cluster_snapshot
|
||||
|
||||
|
||||
def _git_describe(repo_root: Path) -> str | None:
|
||||
"""Return ``<short-sha>[-dirty]`` for the repo at ``repo_root`` or None."""
|
||||
try:
|
||||
sha = subprocess.run(
|
||||
["git", "rev-parse", "--short=12", "HEAD"],
|
||||
cwd=str(repo_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
FileNotFoundError,
|
||||
subprocess.TimeoutExpired,
|
||||
):
|
||||
return None
|
||||
try:
|
||||
dirty = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=str(repo_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
return f"{sha}-dirty" if dirty else sha
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
FileNotFoundError,
|
||||
subprocess.TimeoutExpired,
|
||||
):
|
||||
return sha
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunMetadata:
|
||||
"""Identifies a single bench run."""
|
||||
|
||||
run_id: str
|
||||
benchmark: str
|
||||
started_at: str
|
||||
finished_at: str | None = None
|
||||
exo_sha: str | None = None
|
||||
hostname: str = ""
|
||||
platform: str = ""
|
||||
tags: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def new(
|
||||
cls,
|
||||
benchmark: str,
|
||||
repo_root: Path,
|
||||
*,
|
||||
tags: dict[str, str] | None = None,
|
||||
) -> RunMetadata:
|
||||
now = datetime.now(timezone.utc)
|
||||
run_id = f"{benchmark}_{now.strftime('%Y%m%dT%H%M%SZ')}_{os.getpid()}"
|
||||
return cls(
|
||||
run_id=run_id,
|
||||
benchmark=benchmark,
|
||||
started_at=now.isoformat(),
|
||||
exo_sha=_git_describe(repo_root),
|
||||
hostname=socket.gethostname(),
|
||||
platform=f"{platform.system()} {platform.release()} ({platform.machine()})",
|
||||
tags=dict(tags or {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultsBundle:
|
||||
"""Container for a single benchmark's results, before being written."""
|
||||
|
||||
metadata: RunMetadata
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
cluster: dict[str, Any] = field(default_factory=dict)
|
||||
runs: list[dict[str, Any]] = field(default_factory=list)
|
||||
cold_controls: list[dict[str, Any]] = field(default_factory=list)
|
||||
derived: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def capture_cluster(self, client: ExoClient) -> None:
|
||||
"""Snapshot the cluster state into ``self.cluster``."""
|
||||
try:
|
||||
snapshot = capture_cluster_snapshot(client)
|
||||
if snapshot:
|
||||
self.cluster.update(snapshot)
|
||||
except Exception:
|
||||
# Non-fatal: a benchmark without cluster snapshot is still valid
|
||||
pass
|
||||
|
||||
def write_json(self, output_dir: Path) -> Path:
|
||||
"""Write the bundle as ``<output_dir>/<run_id>.json`` and return the path."""
|
||||
if self.metadata.finished_at is None:
|
||||
self.metadata.finished_at = datetime.now(timezone.utc).isoformat()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = output_dir / f"{self.metadata.run_id}.json"
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(asdict(self), f, indent=2, ensure_ascii=False)
|
||||
return path
|
||||
|
||||
|
||||
def find_repo_root(start: Path | None = None) -> Path:
|
||||
"""Walk upwards from ``start`` (or this file) until a ``.git`` dir is found."""
|
||||
cur = (start or Path(__file__)).resolve()
|
||||
for parent in (cur, *cur.parents):
|
||||
if (parent / ".git").is_dir() or (parent / ".git").is_file():
|
||||
return parent
|
||||
raise RuntimeError(f"Could not locate repo root above {cur}")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""BenchSession — wires together cluster + client + instance + tokenizer.
|
||||
|
||||
Holds the ``EcoSession``, a deployed ``ClusterInfo``, an ``ExoClient`` for
|
||||
the cluster's primary endpoint, and (for benchmarks that need exact-token
|
||||
prompts) a lazily-constructed :class:`PromptSizer`.
|
||||
|
||||
Benchmarks consume this via :func:`bench.lib.cluster.managed_instance`,
|
||||
which yields a populated ``BenchSession``. Library helpers (e.g.
|
||||
``context_scaling.run``) take a ``BenchSession`` and never reach for
|
||||
global state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
|
||||
from exo_tools.client import ExoClient
|
||||
from exo_tools.cluster import ClusterInfo, EcoSession, make_client_from_url
|
||||
|
||||
from .prompt import PromptSizer, load_tokenizer_for_bench
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchSession:
|
||||
"""Bundle of cluster + client + (optional) instance for benchmarks."""
|
||||
|
||||
cluster: ClusterInfo
|
||||
eco: EcoSession
|
||||
instance_id: str | None = None
|
||||
model_id: str | None = None
|
||||
full_model_id: str | None = None
|
||||
_prompt_sizer: PromptSizer | None = field(default=None, repr=False)
|
||||
|
||||
@property
|
||||
def client(self) -> ExoClient:
|
||||
return make_client_from_url(self.cluster.api_url)
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
raw: Any = self.client.request_json("GET", "/state") # type: ignore[reportAny]
|
||||
if isinstance(raw, dict):
|
||||
return cast("dict[str, Any]", raw)
|
||||
return {}
|
||||
|
||||
def instances(self) -> dict[str, Any]:
|
||||
result: Any = self.state().get("instances", {}) # type: ignore[reportAny]
|
||||
if isinstance(result, dict):
|
||||
return cast("dict[str, Any]", result)
|
||||
return {}
|
||||
|
||||
def get_prompt_sizer(self) -> PromptSizer:
|
||||
"""Return a cached :class:`PromptSizer` for ``self.full_model_id``.
|
||||
|
||||
Loaded lazily because tokenizer load is expensive and not every
|
||||
benchmark needs prompt sizing.
|
||||
"""
|
||||
if self._prompt_sizer is not None:
|
||||
return self._prompt_sizer
|
||||
if self.full_model_id is None:
|
||||
raise RuntimeError(
|
||||
"BenchSession.full_model_id is not set; cannot build a PromptSizer."
|
||||
)
|
||||
tokenizer = load_tokenizer_for_bench(self.full_model_id)
|
||||
self._prompt_sizer = PromptSizer(tokenizer)
|
||||
return self._prompt_sizer
|
||||
Whitespace-only changes.
@@ -0,0 +1,186 @@
|
||||
"""Unit tests for the pure helpers in ``bench.lib.context_scaling``.
|
||||
|
||||
The orchestration entry points (``run``, ``run_cached_sweep``,
|
||||
``run_cold_controls``, ``make_cold_control_factory``) need a real
|
||||
``BenchSession`` and exo cluster, so they're exercised end-to-end via
|
||||
``python -m bench.cli context-scaling``. This module covers the
|
||||
underscore-prefixed pure helpers via direct private-symbol access (the
|
||||
private prefix discourages library users; tests for those helpers are
|
||||
the explicit exception).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import cast
|
||||
|
||||
from bench.lib.context_scaling import (
|
||||
StepResult,
|
||||
_compute_t_cum, # type: ignore[reportPrivateUsage]
|
||||
_interp, # type: ignore[reportPrivateUsage]
|
||||
derive_summary,
|
||||
)
|
||||
|
||||
|
||||
def _step(
|
||||
*,
|
||||
pp: int,
|
||||
delta: int,
|
||||
prompt_tps: float,
|
||||
generation_tps: float = 100.0,
|
||||
hit: str = "partial",
|
||||
) -> StepResult:
|
||||
return StepResult(
|
||||
pp_tokens=pp,
|
||||
delta_tokens=delta,
|
||||
prompt_tps=prompt_tps,
|
||||
generation_tps=generation_tps,
|
||||
prefix_cache_hit=hit,
|
||||
prompt_tokens=pp,
|
||||
generation_tokens=32,
|
||||
elapsed_s=delta / prompt_tps if prompt_tps else 0.0,
|
||||
)
|
||||
|
||||
|
||||
def _close(actual: float, expected: float, abs_tol: float = 1e-3) -> bool:
|
||||
return math.isclose(actual, expected, abs_tol=abs_tol)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compute_t_cum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComputeTCum:
|
||||
def test_empty_returns_empty(self) -> None:
|
||||
assert _compute_t_cum([]) == []
|
||||
|
||||
def test_single_step(self) -> None:
|
||||
# 256 tokens at 1024 tps -> 0.25s
|
||||
out = _compute_t_cum([_step(pp=256, delta=256, prompt_tps=1024.0)])
|
||||
assert len(out) == 1
|
||||
assert _close(out[0], 0.25)
|
||||
|
||||
def test_cumulative_sum_across_three_steps(self) -> None:
|
||||
steps = [
|
||||
_step(pp=256, delta=256, prompt_tps=1000.0), # 0.256s
|
||||
_step(pp=512, delta=256, prompt_tps=2000.0), # +0.128s = 0.384s
|
||||
_step(pp=768, delta=256, prompt_tps=512.0), # +0.500s = 0.884s
|
||||
]
|
||||
out = _compute_t_cum(steps)
|
||||
assert _close(out[0], 0.256)
|
||||
assert _close(out[1], 0.384)
|
||||
assert _close(out[2], 0.884)
|
||||
# Monotonically non-decreasing
|
||||
assert out == sorted(out)
|
||||
|
||||
def test_zero_tps_step_skipped(self) -> None:
|
||||
# A row with prompt_tps == 0 contributes nothing to the cumulative sum
|
||||
steps = [
|
||||
_step(pp=256, delta=256, prompt_tps=1024.0), # +0.25s
|
||||
_step(pp=512, delta=256, prompt_tps=0.0), # +0
|
||||
_step(pp=768, delta=256, prompt_tps=512.0), # +0.5s
|
||||
]
|
||||
out = _compute_t_cum(steps)
|
||||
assert _close(out[0], 0.25)
|
||||
assert _close(out[1], 0.25) # unchanged
|
||||
assert _close(out[2], 0.75)
|
||||
|
||||
def test_zero_delta_step_skipped(self) -> None:
|
||||
# Defensive: a Δ=0 row would otherwise add zero anyway, but we
|
||||
# explicitly guard against negative delta + 0/0.
|
||||
steps = [
|
||||
_step(pp=256, delta=256, prompt_tps=1000.0),
|
||||
_step(pp=256, delta=0, prompt_tps=1000.0), # explicit Δ=0
|
||||
]
|
||||
out = _compute_t_cum(steps)
|
||||
assert _close(out[0], 0.256)
|
||||
assert _close(out[1], 0.256)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _interp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInterp:
|
||||
def test_empty_points_returns_zero(self) -> None:
|
||||
assert _interp([], 100) == 0.0
|
||||
|
||||
def test_single_point_returns_y(self) -> None:
|
||||
assert _interp([(100, 1.5)], 50) == 1.5
|
||||
assert _interp([(100, 1.5)], 100) == 1.5
|
||||
assert _interp([(100, 1.5)], 200) == 1.5
|
||||
|
||||
def test_clamps_below_first(self) -> None:
|
||||
points = [(100, 0.1), (200, 0.3), (300, 0.6)]
|
||||
assert _interp(points, 0) == 0.1
|
||||
assert _interp(points, 50) == 0.1
|
||||
assert _interp(points, 100) == 0.1
|
||||
|
||||
def test_clamps_above_last(self) -> None:
|
||||
points = [(100, 0.1), (200, 0.3), (300, 0.6)]
|
||||
assert _interp(points, 300) == 0.6
|
||||
assert _interp(points, 500) == 0.6
|
||||
assert _interp(points, 1_000_000) == 0.6
|
||||
|
||||
def test_mid_bracket_linear_interpolation(self) -> None:
|
||||
points = [(100, 0.0), (200, 1.0)]
|
||||
assert _close(_interp(points, 150), 0.5)
|
||||
assert _close(_interp(points, 175), 0.75)
|
||||
|
||||
def test_multi_segment_linear_interpolation(self) -> None:
|
||||
# Two adjacent segments, x=250 falls in the second one
|
||||
points = [(100, 0.1), (200, 0.3), (300, 0.6)]
|
||||
# 200..300: 0.3 + (0.6-0.3) * (250-200)/(300-200) = 0.3 + 0.15 = 0.45
|
||||
assert _close(_interp(points, 250), 0.45)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gap_at(summary: dict[str, object], index: int) -> dict[str, float]:
|
||||
"""Cast ``summary['control_gaps'][index]`` into the typed shape we expect."""
|
||||
raw = summary["control_gaps"]
|
||||
assert isinstance(raw, list)
|
||||
entry = cast("dict[str, float]", raw[index])
|
||||
return entry
|
||||
|
||||
|
||||
class TestDeriveSummary:
|
||||
def test_no_controls_only_t_cum(self) -> None:
|
||||
steps = [
|
||||
_step(pp=256, delta=256, prompt_tps=1024.0),
|
||||
_step(pp=512, delta=256, prompt_tps=1024.0),
|
||||
]
|
||||
summary = derive_summary(steps, [])
|
||||
t_cum = cast("list[float]", summary["t_cum_seconds"])
|
||||
assert _close(t_cum[0], 0.25)
|
||||
assert _close(t_cum[1], 0.5)
|
||||
assert summary["control_gaps"] == []
|
||||
|
||||
def test_control_gap_at_known_pp(self) -> None:
|
||||
# Sweep: 0.25s @ pp=256, 0.5s @ pp=512
|
||||
steps = [
|
||||
_step(pp=256, delta=256, prompt_tps=1024.0),
|
||||
_step(pp=512, delta=256, prompt_tps=1024.0),
|
||||
]
|
||||
# Cold control at pp=512, 2x faster than the per-step rate -> 0.25s
|
||||
controls = [_step(pp=512, delta=512, prompt_tps=2048.0, hit="none")]
|
||||
summary = derive_summary(steps, controls)
|
||||
gap = _gap_at(summary, 0)
|
||||
assert gap["pp_tokens"] == 512
|
||||
assert _close(gap["cold_t_seconds"], 0.25, abs_tol=0.01)
|
||||
assert _close(gap["t_cum_seconds_at_pp"], 0.5, abs_tol=0.01)
|
||||
assert _close(gap["gap_seconds"], 0.25, abs_tol=0.01)
|
||||
# gap_fraction = 0.25 / 0.25 = 1.0
|
||||
assert _close(gap["gap_fraction"], 1.0, abs_tol=0.01)
|
||||
|
||||
def test_control_gap_zero_cold_tps_yields_zero_fraction(self) -> None:
|
||||
steps = [_step(pp=256, delta=256, prompt_tps=1000.0)]
|
||||
controls = [_step(pp=256, delta=256, prompt_tps=0.0, hit="none")]
|
||||
gap = _gap_at(derive_summary(steps, controls), 0)
|
||||
assert gap["cold_t_seconds"] == 0.0
|
||||
assert gap["gap_fraction"] == 0.0
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Unit tests for ``bench.lib.model_meta``.
|
||||
|
||||
These exercise the pure derivation helpers (no HF round-trip). The HTTP
|
||||
fetchers (``fetch_model_meta``, ``_read_config_json``, ``_sum_weight_sizes``)
|
||||
hit the public hub and aren't covered here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from bench.lib.model_meta import (
|
||||
ModelMeta,
|
||||
derive_cold_controls,
|
||||
derive_context_ramp,
|
||||
)
|
||||
|
||||
|
||||
def _meta(
|
||||
*,
|
||||
weight_bytes: int = 0,
|
||||
max_pos: int = 4096,
|
||||
layers: int = 32,
|
||||
) -> ModelMeta:
|
||||
return ModelMeta(
|
||||
model_id="test/model",
|
||||
total_weight_bytes=weight_bytes,
|
||||
max_position_embeddings=max_pos,
|
||||
num_hidden_layers=layers,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ModelMeta properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModelMetaConstraints:
|
||||
def test_zero_weight_yields_one_gib_floor(self) -> None:
|
||||
meta = _meta(weight_bytes=0)
|
||||
# int(0 * 1.30) + 1 == 1; int(0 * 1.10) + 1 == 1
|
||||
assert meta.memory_constraint_gb == 1.0
|
||||
assert meta.disk_constraint_gb == 1.0
|
||||
|
||||
def test_one_gib_weight_rounds_up(self) -> None:
|
||||
meta = _meta(weight_bytes=1 * (1024**3))
|
||||
# int(1.0 * 1.30) + 1 = 2; int(1.0 * 1.10) + 1 = 2
|
||||
assert meta.memory_constraint_gb == 2.0
|
||||
assert meta.disk_constraint_gb == 2.0
|
||||
|
||||
def test_sixteen_gib_weight_uses_30pct_memory_10pct_disk(self) -> None:
|
||||
meta = _meta(weight_bytes=16 * (1024**3))
|
||||
# memory: int(16 * 1.30) + 1 = 21; disk: int(16 * 1.10) + 1 = 18
|
||||
assert meta.memory_constraint_gb == 21.0
|
||||
assert meta.disk_constraint_gb == 18.0
|
||||
|
||||
def test_total_weight_gb_property(self) -> None:
|
||||
meta = _meta(weight_bytes=2_147_483_648) # 2 GiB exactly
|
||||
assert math.isclose(meta.total_weight_gb, 2.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_context_ramp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeriveContextRamp:
|
||||
def test_full_max_evenly_divides_round_to(self) -> None:
|
||||
meta = _meta(max_pos=131072) # 128k
|
||||
pp_step, num_steps = derive_context_ramp(meta, num_steps=32)
|
||||
# 131072 // 32 = 4096; rounded down to multiple of 256 = 4096
|
||||
assert pp_step == 4096
|
||||
assert num_steps == 32
|
||||
# Top of ramp == max
|
||||
assert pp_step * num_steps == 131072
|
||||
|
||||
def test_qwen30b_a3b_ramp(self) -> None:
|
||||
meta = _meta(max_pos=40960) # Qwen3-30B-A3B
|
||||
pp_step, num_steps = derive_context_ramp(meta, num_steps=32)
|
||||
# 40960 // 32 = 1280; multiple of 256
|
||||
assert pp_step == 1280
|
||||
assert pp_step * num_steps == 40960
|
||||
|
||||
def test_fraction_of_max_half(self) -> None:
|
||||
meta = _meta(max_pos=131072)
|
||||
pp_step, num_steps = derive_context_ramp(meta, num_steps=8, fraction_of_max=0.5)
|
||||
# half = 65536; 65536 // 8 = 8192
|
||||
assert pp_step == 8192
|
||||
assert num_steps == 8
|
||||
|
||||
def test_min_pp_step_floor(self) -> None:
|
||||
meta = _meta(max_pos=512)
|
||||
# 512 // 32 = 16, but min_pp_step=256 floors it; rounded to 256
|
||||
pp_step, num_steps = derive_context_ramp(meta, num_steps=32)
|
||||
assert pp_step == 256
|
||||
assert num_steps == 32
|
||||
|
||||
def test_round_to_truncates_down(self) -> None:
|
||||
meta = _meta(max_pos=10000)
|
||||
pp_step, _ = derive_context_ramp(meta, num_steps=32, round_to=256)
|
||||
# 10000 // 32 = 312; (312 // 256) * 256 = 256
|
||||
assert pp_step == 256
|
||||
|
||||
def test_round_to_zero_step_falls_back_to_round_to(self) -> None:
|
||||
# Pathological: huge round_to relative to per-step size
|
||||
meta = _meta(max_pos=1024)
|
||||
pp_step, _ = derive_context_ramp(meta, num_steps=8, round_to=1024)
|
||||
# 1024 // 8 = 128, but min_pp_step=256 → 256; (256 // 1024) * 1024 = 0;
|
||||
# `or round_to` rescues to 1024.
|
||||
assert pp_step == 1024
|
||||
|
||||
def test_max_pos_zero_raises(self) -> None:
|
||||
meta = _meta(max_pos=0)
|
||||
with pytest.raises(ValueError, match="max_position_embeddings=0"):
|
||||
_ = derive_context_ramp(meta, num_steps=32)
|
||||
|
||||
@pytest.mark.parametrize("fraction", [0.0, -0.1, 1.5, 2.0])
|
||||
def test_fraction_outside_unit_interval_raises(self, fraction: float) -> None:
|
||||
meta = _meta(max_pos=4096)
|
||||
with pytest.raises(ValueError, match="fraction_of_max"):
|
||||
_ = derive_context_ramp(meta, num_steps=4, fraction_of_max=fraction)
|
||||
|
||||
@pytest.mark.parametrize("steps", [0, -1, -100])
|
||||
def test_num_steps_must_be_positive(self, steps: int) -> None:
|
||||
meta = _meta(max_pos=4096)
|
||||
with pytest.raises(ValueError, match="num_steps"):
|
||||
_ = derive_context_ramp(meta, num_steps=steps)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_cold_controls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeriveColdControls:
|
||||
def test_count_zero_returns_empty_tuple(self) -> None:
|
||||
meta = _meta()
|
||||
assert derive_cold_controls(meta, pp_step=4096, num_steps=32, count=0) == ()
|
||||
|
||||
def test_count_one_returns_top_only(self) -> None:
|
||||
meta = _meta()
|
||||
assert derive_cold_controls(meta, pp_step=4096, num_steps=32, count=1) == (
|
||||
131072,
|
||||
)
|
||||
|
||||
def test_evenly_spaced_four(self) -> None:
|
||||
meta = _meta()
|
||||
out = derive_cold_controls(meta, pp_step=4096, num_steps=32, count=4)
|
||||
# max_pp = 131072; (131072 * (i+1)) // 4 for i in {0,1,2,3}
|
||||
# = {32768, 65536, 98304, 131072}
|
||||
assert out == (32768, 65536, 98304, 131072)
|
||||
|
||||
def test_filters_below_pp_step(self) -> None:
|
||||
meta = _meta()
|
||||
out = derive_cold_controls(meta, pp_step=8192, num_steps=2, count=4)
|
||||
# max_pp = 16384; spaced points = {4096, 8192, 12288, 16384};
|
||||
# 4096 < pp_step=8192 → dropped.
|
||||
assert out == (8192, 12288, 16384)
|
||||
|
||||
def test_dedups_at_low_count_high_step(self) -> None:
|
||||
meta = _meta()
|
||||
# max_pp = 1024; count=2 → spaced = {512, 1024}; 512 < pp_step? No (=).
|
||||
out = derive_cold_controls(meta, pp_step=512, num_steps=2, count=2)
|
||||
assert out == (512, 1024)
|
||||
|
||||
def test_returned_in_ascending_order(self) -> None:
|
||||
meta = _meta()
|
||||
out = derive_cold_controls(meta, pp_step=1024, num_steps=8, count=4)
|
||||
assert list(out) == sorted(out)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Smoke tests for ``bench.lib.plotting``.
|
||||
|
||||
Renders a synthetic benchmark JSON to a tmp PNG and verifies the file is
|
||||
non-empty. We deliberately don't assert on pixel values — matplotlib
|
||||
output isn't byte-stable across versions — but a non-empty PNG with a
|
||||
valid header is a strong signal the renderer didn't throw.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from bench.lib.plotting import PlotInputs, render_context_scaling
|
||||
|
||||
|
||||
def _write_synthetic_run(path: Path, *, run_id: str, model: str = "test/model") -> None:
|
||||
"""Write a minimal context-scaling-shaped JSON for plotting tests."""
|
||||
payload = {
|
||||
"metadata": {
|
||||
"run_id": run_id,
|
||||
"benchmark": "context_scaling",
|
||||
"started_at": "2026-05-10T00:00:00Z",
|
||||
"exo_sha": "deadbeef",
|
||||
"hostname": "test-host",
|
||||
"platform": "Linux 6.0 (x86_64)",
|
||||
"tags": {"operator": "tester"},
|
||||
},
|
||||
"params": {
|
||||
"pp_step": 256,
|
||||
"num_steps": 4,
|
||||
"tg": 32,
|
||||
"warmup": 1,
|
||||
"full_model_id": model,
|
||||
},
|
||||
"cluster": {},
|
||||
"runs": [
|
||||
{
|
||||
"step_index": 0,
|
||||
"phase": "cached_sweep",
|
||||
"pp_tokens": 256,
|
||||
"delta_tokens": 256,
|
||||
"prompt_tps": 1800.0,
|
||||
"generation_tps": 410.0,
|
||||
"prefix_cache_hit": "exact",
|
||||
"prompt_tokens": 256,
|
||||
"generation_tokens": 32,
|
||||
"elapsed_s": 0.14,
|
||||
"peak_memory_bytes": 1_000_000_000,
|
||||
"output_text_preview": "",
|
||||
},
|
||||
{
|
||||
"step_index": 1,
|
||||
"phase": "cached_sweep",
|
||||
"pp_tokens": 512,
|
||||
"delta_tokens": 256,
|
||||
"prompt_tps": 2000.0,
|
||||
"generation_tps": 395.0,
|
||||
"prefix_cache_hit": "partial",
|
||||
"prompt_tokens": 512,
|
||||
"generation_tokens": 32,
|
||||
"elapsed_s": 0.13,
|
||||
"peak_memory_bytes": 1_100_000_000,
|
||||
"output_text_preview": "",
|
||||
},
|
||||
{
|
||||
"step_index": 2,
|
||||
"phase": "cached_sweep",
|
||||
"pp_tokens": 768,
|
||||
"delta_tokens": 256,
|
||||
"prompt_tps": 2200.0,
|
||||
"generation_tps": 378.0,
|
||||
"prefix_cache_hit": "partial",
|
||||
"prompt_tokens": 768,
|
||||
"generation_tokens": 32,
|
||||
"elapsed_s": 0.12,
|
||||
"peak_memory_bytes": 1_200_000_000,
|
||||
"output_text_preview": "",
|
||||
},
|
||||
],
|
||||
"cold_controls": [
|
||||
{
|
||||
"phase": "cold_control",
|
||||
"pp_tokens": 512,
|
||||
"delta_tokens": 512,
|
||||
"prompt_tps": 3200.0,
|
||||
"generation_tps": 400.0,
|
||||
"prefix_cache_hit": "none",
|
||||
"prompt_tokens": 512,
|
||||
"generation_tokens": 32,
|
||||
"elapsed_s": 0.16,
|
||||
"peak_memory_bytes": 1_500_000_000,
|
||||
"output_text_preview": "",
|
||||
},
|
||||
],
|
||||
"derived": {
|
||||
"t_cum_seconds": [0.14, 0.27, 0.39],
|
||||
"control_gaps": [],
|
||||
},
|
||||
}
|
||||
_ = path.write_text(json.dumps(payload))
|
||||
|
||||
|
||||
def _png_is_valid(path: Path) -> bool:
|
||||
"""A PNG file starts with the 8-byte magic ``\\x89PNG\\r\\n\\x1a\\n``."""
|
||||
if not path.is_file():
|
||||
return False
|
||||
if path.stat().st_size < 100:
|
||||
return False
|
||||
head = path.read_bytes()[:8]
|
||||
return head == b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderContextScaling:
|
||||
def test_single_run(self, tmp_path: Path) -> None:
|
||||
json_path = tmp_path / "run.json"
|
||||
_write_synthetic_run(json_path, run_id="r1")
|
||||
out = tmp_path / "out.png"
|
||||
|
||||
returned = render_context_scaling(PlotInputs(results=[json_path], output=out))
|
||||
assert returned == out
|
||||
assert _png_is_valid(out)
|
||||
|
||||
def test_creates_output_parent_dir(self, tmp_path: Path) -> None:
|
||||
json_path = tmp_path / "run.json"
|
||||
_write_synthetic_run(json_path, run_id="r1")
|
||||
out = tmp_path / "nested" / "deep" / "out.png"
|
||||
|
||||
_ = render_context_scaling(PlotInputs(results=[json_path], output=out))
|
||||
assert _png_is_valid(out)
|
||||
|
||||
def test_comparison_two_runs(self, tmp_path: Path) -> None:
|
||||
a = tmp_path / "a.json"
|
||||
b = tmp_path / "b.json"
|
||||
_write_synthetic_run(a, run_id="run-a", model="test/model-a")
|
||||
_write_synthetic_run(b, run_id="run-b", model="test/model-b")
|
||||
out = tmp_path / "compare.png"
|
||||
|
||||
_ = render_context_scaling(PlotInputs(results=[a, b], output=out))
|
||||
assert _png_is_valid(out)
|
||||
|
||||
def test_label_tag_uses_metadata_tag(self, tmp_path: Path) -> None:
|
||||
# Smoke test: just confirm passing label_tag doesn't throw and the
|
||||
# PNG renders. Label content is too matplotlib-internal to inspect.
|
||||
json_path = tmp_path / "run.json"
|
||||
_write_synthetic_run(json_path, run_id="r1")
|
||||
out = tmp_path / "out.png"
|
||||
|
||||
_ = render_context_scaling(
|
||||
PlotInputs(results=[json_path], output=out, label_tag="operator")
|
||||
)
|
||||
assert _png_is_valid(out)
|
||||
|
||||
def test_explicit_title(self, tmp_path: Path) -> None:
|
||||
json_path = tmp_path / "run.json"
|
||||
_write_synthetic_run(json_path, run_id="r1")
|
||||
out = tmp_path / "out.png"
|
||||
|
||||
_ = render_context_scaling(
|
||||
PlotInputs(results=[json_path], output=out, title="Custom Title")
|
||||
)
|
||||
assert _png_is_valid(out)
|
||||
|
||||
def test_empty_results_raises(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match="at least one"):
|
||||
_ = render_context_scaling(
|
||||
PlotInputs(results=[], output=tmp_path / "out.png")
|
||||
)
|
||||
|
||||
def test_wrong_benchmark_raises(self, tmp_path: Path) -> None:
|
||||
# Same shape but with the wrong metadata.benchmark
|
||||
json_path = tmp_path / "run.json"
|
||||
_write_synthetic_run(json_path, run_id="r1")
|
||||
raw_loaded: Any = json.loads(json_path.read_text()) # type: ignore[reportAny]
|
||||
assert isinstance(raw_loaded, dict)
|
||||
data = cast("dict[str, dict[str, str]]", raw_loaded)
|
||||
data["metadata"]["benchmark"] = "something_else"
|
||||
_ = json_path.write_text(json.dumps(data))
|
||||
|
||||
with pytest.raises(ValueError, match="context_scaling"):
|
||||
_ = render_context_scaling(
|
||||
PlotInputs(results=[json_path], output=tmp_path / "out.png")
|
||||
)
|
||||
@@ -35,9 +35,8 @@ from exo_bench import (
|
||||
load_tokenizer_for_bench,
|
||||
parse_int_list,
|
||||
)
|
||||
from harness import (
|
||||
ExoClient,
|
||||
ExoHttpError,
|
||||
from exo_tools.client import ExoClient, ExoHttpError
|
||||
from exo_tools.harness import (
|
||||
add_common_instance_args,
|
||||
instance_id_from_instance,
|
||||
node_ids_from_instance,
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"lm-eval[api,math]>=0.4.0",
|
||||
"human-eval>=1.0.3",
|
||||
"numpy>=1.24.0",
|
||||
"matplotlib>=3.8",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
+17
-4
@@ -40,6 +40,7 @@ exo = "exo.main:main"
|
||||
dev = [
|
||||
"basedpyright>=1.29.0",
|
||||
"pyinstaller>=6.17.0",
|
||||
"playwright>=1.52.0",
|
||||
"pytest>=8.4.0",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"pytest-env",
|
||||
@@ -75,7 +76,7 @@ cuda13 = [
|
||||
###
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["rust/exo_pyo3_bindings", "bench"]
|
||||
members = ["rust/exo_pyo3_bindings", "bench", "tools"]
|
||||
|
||||
[tool.uv.sources]
|
||||
exo-pyo3-bindings = { workspace = true }
|
||||
@@ -112,7 +113,7 @@ build-backend = "uv_build"
|
||||
###
|
||||
|
||||
[tool.basedpyright]
|
||||
include = ["src", "bench"]
|
||||
include = ["src", "bench", "tools"]
|
||||
typeCheckingMode = "strict"
|
||||
failOnWarnings = true
|
||||
|
||||
@@ -146,6 +147,15 @@ reportMissingModuleSource = false
|
||||
[[tool.basedpyright.executionEnvironments]]
|
||||
root = "src"
|
||||
|
||||
[[tool.basedpyright.executionEnvironments]]
|
||||
root = "bench"
|
||||
# `.` keeps `from bench.lib.X import …` resolvable (pytest adds the project
|
||||
# root to sys.path; we want type-checking to agree with runtime).
|
||||
extraPaths = ["tools/src", "."]
|
||||
|
||||
[[tool.basedpyright.executionEnvironments]]
|
||||
root = "tools/src"
|
||||
|
||||
|
||||
###
|
||||
# uv configuration
|
||||
@@ -216,9 +226,12 @@ extend-exclude = [
|
||||
extend-select = ["I", "N", "B", "A", "PIE", "SIM"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = "."
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
markers = ["slow: marks tests as slow (deselected by default)"]
|
||||
env = ["EXO_TESTS=1"]
|
||||
addopts = "-m 'not slow' --ignore=tests/start_distributed_test.py"
|
||||
# `tests/` requires an eco cluster (opt-in). `tmp/` holds throwaway scripts
|
||||
# that run a top-level `sys.exit(...)` at import time, which otherwise blows
|
||||
# up the default pytest collection.
|
||||
addopts = "-m 'not slow' --ignore=tests --ignore=tmp"
|
||||
filterwarnings = ["ignore:builtin type Swig:DeprecationWarning"]
|
||||
@@ -46,9 +46,12 @@ pyo3-async-runtimes = { version = "0.27.0", features = [
|
||||
] }
|
||||
pyo3-log = "0.13.2"
|
||||
|
||||
pidfile-rs = "0.3"
|
||||
|
||||
# macro dependencies
|
||||
extend = { workspace = true }
|
||||
delegate = { workspace = true }
|
||||
thiserror = "2.0"
|
||||
|
||||
# async runtime
|
||||
tokio = { workspace = true, features = ["full", "tracing"] }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# ruff: noqa: E501, F401
|
||||
|
||||
import builtins
|
||||
import os
|
||||
import pathlib
|
||||
import typing
|
||||
|
||||
@typing.final
|
||||
@@ -69,6 +71,48 @@ class NoPeersSubscribedToTopicError(builtins.Exception):
|
||||
def __repr__(self) -> builtins.str: ...
|
||||
def __str__(self) -> builtins.str: ...
|
||||
|
||||
@typing.final
|
||||
class Pidfile:
|
||||
r"""
|
||||
A PID file protected with a lock.
|
||||
|
||||
An instance of `Pidfile` can be used to manage a PID file: create it,
|
||||
lock it, detect already running daemons. It is backed by [`pidfile`][]
|
||||
functions of `libbsd`/`libutil` which use `flopen` to lock the PID
|
||||
file.
|
||||
|
||||
When a PID file is created, the process ID of the current process is
|
||||
*not* written there, making it possible to lock the PID file before
|
||||
forking and only write the ID of the forked process when it is ready.
|
||||
|
||||
The PID file is deleted automatically when the `Pidfile` comes out of
|
||||
the scope. To close the PID file without deleting it, for example, in
|
||||
the parent process of a forked daemon, call `close()`.
|
||||
|
||||
[`exit`]: https://doc.rust-lang.org/std/process/fn.exit.html
|
||||
[`pidfile`]: https://linux.die.net/man/3/pidfile
|
||||
[`daemon`(3)]: https://linux.die.net/man/3/daemon
|
||||
"""
|
||||
def __new__(cls, path: builtins.str | os.PathLike | pathlib.Path, mode: builtins.int) -> Pidfile:
|
||||
r"""
|
||||
Creates a new PID file and locks it.
|
||||
|
||||
If the PID file cannot be locked, returns `PidfileError::AlreadyRunning` with
|
||||
a PID of the already running process, or `None` if no PID has been written to
|
||||
the PID file yet.
|
||||
"""
|
||||
def write(self) -> None:
|
||||
r"""
|
||||
Writes the current process ID to the PID file.
|
||||
|
||||
The file is truncated before writing.
|
||||
"""
|
||||
|
||||
@typing.final
|
||||
class PidfileError(builtins.Exception):
|
||||
def __repr__(self) -> builtins.str: ...
|
||||
def __str__(self) -> builtins.str: ...
|
||||
|
||||
class PyFromSwarm:
|
||||
@typing.final
|
||||
class Connection(PyFromSwarm):
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
mod allow_threading;
|
||||
mod ident;
|
||||
mod networking;
|
||||
mod pidfile;
|
||||
|
||||
use crate::ident::PyKeypair;
|
||||
use crate::networking::networking_submodule;
|
||||
use crate::pidfile::pidfile_submodule;
|
||||
use pyo3::prelude::PyModule;
|
||||
use pyo3::types::PyModuleMethods;
|
||||
use pyo3::{Bound, PyResult, pyclass, pymodule};
|
||||
@@ -164,6 +166,7 @@ fn main_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
// too many importing issues...
|
||||
m.add_class::<PyKeypair>()?;
|
||||
networking_submodule(m)?;
|
||||
pidfile_submodule(m)?;
|
||||
|
||||
// top-level constructs
|
||||
// TODO: ...
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
use pidfile_rs::{Pidfile, PidfileError};
|
||||
use pyo3::exceptions::PyException;
|
||||
use pyo3::prelude::{PyModule, PyModuleMethods};
|
||||
use pyo3::{Bound, PyErr, PyResult, Python, pyclass, pymethods};
|
||||
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
|
||||
use std::fs::Permissions;
|
||||
use std::os::unix::prelude::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[gen_stub_pyclass]
|
||||
#[pyclass(frozen, extends=PyException, name="PidfileError")]
|
||||
pub struct PyPidfileError(PidfileError);
|
||||
|
||||
impl PyPidfileError {
|
||||
// TODO: I actually like this pattern a LOT more but how to abstract??
|
||||
fn into_pyerr(self, py: Python) -> PyErr {
|
||||
match Bound::new(py, self) {
|
||||
Ok(err) => PyErr::from_value(err.into_any()),
|
||||
Err(err) => err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gen_stub_pymethods]
|
||||
#[pymethods]
|
||||
impl PyPidfileError {
|
||||
fn __repr__(&self) -> String {
|
||||
format!("PidfileError(\"{}\")", self.0)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A PID file protected with a lock.
|
||||
///
|
||||
/// An instance of `Pidfile` can be used to manage a PID file: create it,
|
||||
/// lock it, detect already running daemons. It is backed by [`pidfile`][]
|
||||
/// functions of `libbsd`/`libutil` which use `flopen` to lock the PID
|
||||
/// file.
|
||||
///
|
||||
/// When a PID file is created, the process ID of the current process is
|
||||
/// *not* written there, making it possible to lock the PID file before
|
||||
/// forking and only write the ID of the forked process when it is ready.
|
||||
///
|
||||
/// The PID file is deleted automatically when the `Pidfile` comes out of
|
||||
/// the scope. To close the PID file without deleting it, for example, in
|
||||
/// the parent process of a forked daemon, call `close()`.
|
||||
///
|
||||
/// [`exit`]: https://doc.rust-lang.org/std/process/fn.exit.html
|
||||
/// [`pidfile`]: https://linux.die.net/man/3/pidfile
|
||||
/// [`daemon`(3)]: https://linux.die.net/man/3/daemon
|
||||
#[gen_stub_pyclass]
|
||||
#[pyclass(name = "Pidfile")]
|
||||
pub struct PyPidfile(Pidfile);
|
||||
|
||||
#[gen_stub_pymethods]
|
||||
#[pymethods]
|
||||
impl PyPidfile {
|
||||
/// Creates a new PID file and locks it.
|
||||
///
|
||||
/// If the PID file cannot be locked, returns `PidfileError::AlreadyRunning` with
|
||||
/// a PID of the already running process, or `None` if no PID has been written to
|
||||
/// the PID file yet.
|
||||
#[new]
|
||||
fn py_new(py: Python, path: PathBuf, mode: u32) -> PyResult<Self> {
|
||||
Ok(Self(
|
||||
Pidfile::new(&path, Permissions::from_mode(mode))
|
||||
.map_err(|e| PyPidfileError(e).into_pyerr(py))?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Writes the current process ID to the PID file.
|
||||
///
|
||||
/// The file is truncated before writing.
|
||||
fn write<'py>(&mut self, py: Python<'py>) -> PyResult<()> {
|
||||
self.0.write().map_err(|e| PyPidfileError(e).into_pyerr(py))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pidfile_submodule(m: &Bound<PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyPidfileError>()?;
|
||||
m.add_class::<PyPidfile>()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from _pytest.capture import CaptureFixture
|
||||
from exo_pyo3_bindings import (
|
||||
Keypair,
|
||||
NetworkingHandle,
|
||||
NoPeersSubscribedToTopicError,
|
||||
Pidfile,
|
||||
PyFromSwarm,
|
||||
)
|
||||
|
||||
@@ -26,6 +28,13 @@ async def test_sleep_on_multiple_items() -> None:
|
||||
print("caught it", e)
|
||||
|
||||
|
||||
def test_pidfile(capsys: CaptureFixture[str]):
|
||||
with capsys.disabled():
|
||||
print("\nbefore python")
|
||||
scoped_lock_file()
|
||||
print("after python")
|
||||
|
||||
|
||||
async def _await_recv(h: NetworkingHandle):
|
||||
while True:
|
||||
event = await h.recv()
|
||||
@@ -34,3 +43,7 @@ async def _await_recv(h: NetworkingHandle):
|
||||
print(f"PYTHON: connection update: {c}")
|
||||
case PyFromSwarm.Message() as m:
|
||||
print(f"PYTHON: message: {m}")
|
||||
|
||||
|
||||
def scoped_lock_file():
|
||||
a = Pidfile("/tmp/lock.pid", 0o0600)
|
||||
@@ -3,6 +3,7 @@ import multiprocessing as mp
|
||||
import os
|
||||
import resource
|
||||
import signal
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Self
|
||||
|
||||
@@ -22,6 +23,8 @@ from exo.shared.election import Election, ElectionResult
|
||||
from exo.shared.logging import logger_cleanup, logger_setup
|
||||
from exo.shared.types.common import NodeId, SessionId
|
||||
from exo.utils.channels import Receiver, channel
|
||||
from exo.utils.daemon import detach_stdio_to_devnull
|
||||
from exo.utils.pidfile import PidfileLockError, acquire_exo_pidfile
|
||||
from exo.utils.pydantic_ext import FrozenModel
|
||||
from exo.utils.task_group import TaskGroup
|
||||
from exo.worker.main import Worker
|
||||
@@ -264,14 +267,26 @@ class Node:
|
||||
|
||||
|
||||
def main():
|
||||
# Exit early if no PID file (not compatible with double-for daemonization yet)
|
||||
try:
|
||||
pidfile = acquire_exo_pidfile()
|
||||
except PidfileLockError as exception:
|
||||
print(exception, file=sys.stderr)
|
||||
raise SystemExit(1) from exception
|
||||
|
||||
args = Args.parse()
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
target = min(max(soft, 65535), hard)
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard))
|
||||
|
||||
mp.set_start_method("spawn", force=True)
|
||||
|
||||
# TODO: Refactor the current verbosity system
|
||||
logger_setup(EXO_LOG, args.verbosity)
|
||||
if args.no_stdio:
|
||||
detach_stdio_to_devnull()
|
||||
logger.info("Detached stdio to /dev/null")
|
||||
|
||||
logger.info(f"{'=' * 40}")
|
||||
logger.info(f"Starting EXO | pid={os.getpid()}")
|
||||
logger.info(f"{'=' * 40}")
|
||||
@@ -306,6 +321,7 @@ def main():
|
||||
finally:
|
||||
logger.info("EXO Shutdown complete")
|
||||
logger_cleanup()
|
||||
del pidfile
|
||||
|
||||
|
||||
class Args(FrozenModel):
|
||||
@@ -319,6 +335,7 @@ class Args(FrozenModel):
|
||||
offline: bool = os.getenv("EXO_OFFLINE", "false").lower() == "true"
|
||||
no_batch: bool = False
|
||||
fast_synch: bool | None = None # None = auto, True = force on, False = force off
|
||||
no_stdio: bool = False
|
||||
bootstrap_peers: list[str] = []
|
||||
libp2p_port: int
|
||||
|
||||
@@ -378,6 +395,11 @@ class Args(FrozenModel):
|
||||
action="store_true",
|
||||
help="Disable continuous batching, use sequential generation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-stdio",
|
||||
action="store_true",
|
||||
help="Detach stdin/stdout/stderr to /dev/null after logging is configured",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bootstrap-peers",
|
||||
type=lambda s: [p for p in s.split(",") if p],
|
||||
|
||||
@@ -69,6 +69,7 @@ DASHBOARD_DIR = (
|
||||
EXO_LOG_DIR = EXO_CACHE_HOME / "exo_log"
|
||||
EXO_LOG = EXO_LOG_DIR / "exo.log"
|
||||
EXO_TEST_LOG = EXO_CACHE_HOME / "exo_test.log"
|
||||
EXO_PID_FILE = EXO_CACHE_HOME / "exo.pid"
|
||||
|
||||
# Identity (config)
|
||||
EXO_NODE_ID_KEYPAIR = EXO_CONFIG_HOME / "node_id.keypair"
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import faulthandler
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from multiprocessing.process import BaseProcess
|
||||
from multiprocessing.resource_sharer import DupFd
|
||||
from typing import final
|
||||
|
||||
from anyio import (
|
||||
TASK_STATUS_IGNORED,
|
||||
BrokenResourceError,
|
||||
CancelScope,
|
||||
ClosedResourceError,
|
||||
Event,
|
||||
create_task_group,
|
||||
move_on_after,
|
||||
sleep,
|
||||
wait_readable,
|
||||
)
|
||||
from anyio.abc import TaskStatus
|
||||
from loguru import logger
|
||||
|
||||
from exo.utils.channels import Receiver, Sender, channel
|
||||
|
||||
_STDOUT_FD = 1
|
||||
_STDERR_FD = 2
|
||||
_READ_CHUNK_SIZE = 64 * 1024
|
||||
_TERMINATE_GRACE_SECONDS = 10.0
|
||||
_TERMINATE_RETRY_GRACE_SECONDS = 2.0
|
||||
_TERMINATE_ATTEMPTS = 10
|
||||
_KILL_GRACE_SECONDS = 5.0
|
||||
|
||||
|
||||
@final
|
||||
class AsyncProcess:
|
||||
def __init__(
|
||||
self,
|
||||
target: Callable[..., object] | None = None,
|
||||
name: str | None = None,
|
||||
args: Iterable[object] = (),
|
||||
kwargs: Mapping[str, object] | None = None,
|
||||
*,
|
||||
daemon: bool | None = None,
|
||||
) -> None:
|
||||
# setup state
|
||||
self._target = target
|
||||
self._name = name
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
self._daemon = daemon
|
||||
|
||||
# lifecycle state
|
||||
self._process: BaseProcess | None = None
|
||||
self._pid: int | None = None
|
||||
self._stdout_tx, self._stdout_rx = channel[bytes]()
|
||||
self._stderr_tx, self._stderr_rx = channel[bytes]()
|
||||
self._started = Event()
|
||||
self._done = Event()
|
||||
self._run_cancel_scope: CancelScope | None = None
|
||||
self._start_error: BaseException | None = None
|
||||
self._exitcode: int | None = None
|
||||
|
||||
async def run(self, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED) -> None:
|
||||
if self._run_cancel_scope is not None or self._done.is_set():
|
||||
raise RuntimeError("process has already been started")
|
||||
|
||||
stdout_read_fd: int | None = None
|
||||
stdout_write_fd: int | None = None
|
||||
stderr_read_fd: int | None = None
|
||||
stderr_write_fd: int | None = None
|
||||
|
||||
def cleanup_stdio_fd() -> None:
|
||||
nonlocal stdout_read_fd, stdout_write_fd, stderr_read_fd, stderr_write_fd
|
||||
stdout_read_fd = _close_fd(stdout_read_fd)
|
||||
stdout_write_fd = _close_fd(stdout_write_fd)
|
||||
stderr_read_fd = _close_fd(stderr_read_fd)
|
||||
stderr_write_fd = _close_fd(stderr_write_fd)
|
||||
|
||||
try:
|
||||
with CancelScope() as run_cancel_scope:
|
||||
self._run_cancel_scope = run_cancel_scope
|
||||
stdout_read_fd, stdout_write_fd = os.pipe()
|
||||
stderr_read_fd, stderr_write_fd = os.pipe()
|
||||
|
||||
process = mp.Process(
|
||||
target=_run_with_captured_stdio,
|
||||
name=self._name,
|
||||
args=(
|
||||
DupFd(stdout_write_fd),
|
||||
DupFd(stderr_write_fd),
|
||||
self._target,
|
||||
*self._args,
|
||||
),
|
||||
kwargs={} if self._kwargs is None else self._kwargs,
|
||||
daemon=self._daemon,
|
||||
)
|
||||
process.start()
|
||||
pid = process.pid
|
||||
if pid is None:
|
||||
raise RuntimeError("started process has no pid")
|
||||
|
||||
# important to close parent write-side FD to prevent hangs
|
||||
stdout_write_fd = _close_fd(stdout_write_fd)
|
||||
stderr_write_fd = _close_fd(stderr_write_fd)
|
||||
|
||||
self._process = process
|
||||
self._pid = pid
|
||||
self._started.set()
|
||||
|
||||
async with create_task_group() as tg:
|
||||
tg.start_soon(_drain_fd, stdout_read_fd, self._stdout_tx)
|
||||
stdout_read_fd = None
|
||||
tg.start_soon(_drain_fd, stderr_read_fd, self._stderr_tx)
|
||||
stderr_read_fd = None
|
||||
task_status.started()
|
||||
await self.wait()
|
||||
except BaseException as exc:
|
||||
if not self._started.is_set():
|
||||
self._start_error = exc
|
||||
self._started.set()
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
with CancelScope(shield=True):
|
||||
await self._terminate_if_still_alive()
|
||||
finally:
|
||||
cleanup_stdio_fd()
|
||||
for tx in (self._stdout_tx, self._stderr_tx):
|
||||
with contextlib.suppress(Exception):
|
||||
await tx.aclose()
|
||||
if self._process is not None:
|
||||
with contextlib.suppress(ValueError):
|
||||
self._process.close()
|
||||
self._run_cancel_scope = None
|
||||
self._done.set()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._run_cancel_scope is None and not self._done.is_set():
|
||||
raise RuntimeError("process has not been started")
|
||||
if self._run_cancel_scope is not None:
|
||||
self._run_cancel_scope.cancel()
|
||||
await self._done.wait()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
async def wait(self) -> int:
|
||||
if self._exitcode is not None:
|
||||
return self._exitcode
|
||||
|
||||
await self._started.wait()
|
||||
if self._start_error is not None:
|
||||
raise self._start_error
|
||||
assert self._process is not None
|
||||
|
||||
while True:
|
||||
exitcode = self.exitcode
|
||||
if exitcode is not None:
|
||||
return exitcode
|
||||
await sleep(0.01)
|
||||
|
||||
@property
|
||||
def pid(self) -> int:
|
||||
if self._pid is None:
|
||||
raise RuntimeError("process has not been started")
|
||||
return self._pid
|
||||
|
||||
@property
|
||||
def exitcode(self) -> int | None:
|
||||
if self._exitcode is not None:
|
||||
return self._exitcode
|
||||
if self._process is None:
|
||||
return None
|
||||
|
||||
with contextlib.suppress(ValueError):
|
||||
exitcode = self._process.exitcode
|
||||
if exitcode is not None:
|
||||
self._exitcode = exitcode
|
||||
return exitcode
|
||||
return None
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
if self._process is None:
|
||||
return False
|
||||
|
||||
with contextlib.suppress(ValueError):
|
||||
return self._process.is_alive()
|
||||
return False
|
||||
|
||||
# TODO: maybe in the future if needed, create stdin that is also installed,
|
||||
# and a ByteSendStream handle is provided for it :)
|
||||
|
||||
@property
|
||||
def stdout(self) -> Receiver[bytes]:
|
||||
return self._stdout_rx
|
||||
|
||||
@property
|
||||
def stderr(self) -> Receiver[bytes]:
|
||||
return self._stderr_rx
|
||||
|
||||
async def _terminate_if_still_alive(self) -> None:
|
||||
process = self._process
|
||||
if process is None:
|
||||
return
|
||||
|
||||
if self.exitcode is not None:
|
||||
return
|
||||
|
||||
with contextlib.suppress(ValueError):
|
||||
if not process.is_alive():
|
||||
return
|
||||
|
||||
logger.warning("Child process didn't shut down successfully, terminating")
|
||||
process.terminate()
|
||||
with move_on_after(_TERMINATE_GRACE_SECONDS):
|
||||
await self.wait()
|
||||
|
||||
if self.exitcode is not None or not process.is_alive():
|
||||
logger.warning("Terminated nicely in the first attempt!")
|
||||
return
|
||||
|
||||
for attempt in range(2, _TERMINATE_ATTEMPTS + 1):
|
||||
process.terminate()
|
||||
with move_on_after(_TERMINATE_RETRY_GRACE_SECONDS):
|
||||
await self.wait()
|
||||
|
||||
if self.exitcode is not None or not process.is_alive():
|
||||
logger.warning(f"That took {attempt} attempts :)")
|
||||
return
|
||||
|
||||
logger.critical("Child process didn't respond to SIGTERM, killing")
|
||||
j = 0
|
||||
while True:
|
||||
process.kill()
|
||||
with move_on_after(_KILL_GRACE_SECONDS):
|
||||
await self.wait()
|
||||
j += 1
|
||||
if self.exitcode is not None or not process.is_alive():
|
||||
break
|
||||
logger.warning(f"That took {j} attempts :(")
|
||||
|
||||
|
||||
# Spawn-mode multiprocessing requires a module-level target that can be pickled.
|
||||
def _run_with_captured_stdio(
|
||||
stdout: DupFd,
|
||||
stderr: DupFd,
|
||||
target: Callable[..., object] | None,
|
||||
*target_args: object,
|
||||
**target_kwargs: object,
|
||||
) -> None:
|
||||
stdout_fd = stdout.detach()
|
||||
stderr_fd = stderr.detach()
|
||||
|
||||
try:
|
||||
os.dup2(stdout_fd, _STDOUT_FD)
|
||||
os.dup2(stderr_fd, _STDERR_FD)
|
||||
finally:
|
||||
for fd in (stdout_fd, stderr_fd):
|
||||
if fd not in (_STDOUT_FD, _STDERR_FD):
|
||||
_close_fd(fd)
|
||||
|
||||
faulthandler.enable(file=sys.stderr, all_threads=True)
|
||||
if target is not None:
|
||||
target(*target_args, **target_kwargs)
|
||||
|
||||
|
||||
async def _drain_fd(fd: int, tx: Sender[bytes]) -> None:
|
||||
try:
|
||||
while True:
|
||||
await wait_readable(fd)
|
||||
chunk = os.read(fd, _READ_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
return
|
||||
await tx.send(chunk)
|
||||
except (BrokenPipeError, BrokenResourceError, ClosedResourceError):
|
||||
pass
|
||||
finally:
|
||||
_close_fd(fd)
|
||||
await tx.aclose()
|
||||
|
||||
|
||||
def _close_fd(fd: int | None) -> None:
|
||||
if fd is None:
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(fd)
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
_STDIN_FD = 0
|
||||
_STDOUT_FD = 1
|
||||
_STDERR_FD = 2
|
||||
|
||||
|
||||
def detach_stdio_to_devnull() -> None:
|
||||
"""Redirect process stdio file descriptors to /dev/null."""
|
||||
|
||||
for stream in (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__):
|
||||
if stream is not None:
|
||||
stream.flush()
|
||||
|
||||
stdin_fd = os.open(os.devnull, os.O_RDONLY)
|
||||
stdout_fd = os.open(os.devnull, os.O_WRONLY)
|
||||
stderr_fd = os.open(os.devnull, os.O_WRONLY)
|
||||
|
||||
try:
|
||||
# dup2 closes the target fd first, but leaves the source fd open.
|
||||
os.dup2(stdin_fd, _STDIN_FD)
|
||||
os.dup2(stdout_fd, _STDOUT_FD)
|
||||
os.dup2(stderr_fd, _STDERR_FD)
|
||||
finally:
|
||||
for fd in (stdin_fd, stdout_fd, stderr_fd):
|
||||
if fd not in (_STDIN_FD, _STDOUT_FD, _STDERR_FD):
|
||||
os.close(fd)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Final
|
||||
|
||||
from exo_pyo3_bindings import Pidfile, PidfileError
|
||||
|
||||
from exo.shared.constants import EXO_PID_FILE
|
||||
|
||||
_PIDFILE_MODE: Final = 0o600
|
||||
|
||||
|
||||
class PidfileLockError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def acquire_exo_pidfile() -> Pidfile:
|
||||
path = EXO_PID_FILE
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
try:
|
||||
pidfile = Pidfile(path, _PIDFILE_MODE)
|
||||
pidfile.write()
|
||||
except (OSError, PidfileError) as exception:
|
||||
raise PidfileLockError(
|
||||
f"Failed to acquire EXO pidfile at {path}: {exception}"
|
||||
) from exception
|
||||
|
||||
return pidfile
|
||||
@@ -19,19 +19,21 @@ class PowerSampler:
|
||||
):
|
||||
self._get_node_system = get_node_system
|
||||
self._interval = interval
|
||||
self._samples: defaultdict[NodeId, list[SystemPerformanceProfile]] = (
|
||||
defaultdict(list)
|
||||
)
|
||||
self._samples: defaultdict[
|
||||
NodeId, list[tuple[float, SystemPerformanceProfile]]
|
||||
] = defaultdict(list)
|
||||
self._start_time: float | None = None
|
||||
self._stopped = False
|
||||
|
||||
def _take_sample(self) -> None:
|
||||
def _take_sample(self, t_rel: float | None = None) -> None:
|
||||
assert self._start_time is not None
|
||||
ts = t_rel if t_rel is not None else time.perf_counter() - self._start_time
|
||||
for node_id, profile in self._get_node_system().items():
|
||||
self._samples[node_id].append(profile)
|
||||
self._samples[node_id].append((ts, profile))
|
||||
|
||||
async def run(self) -> None:
|
||||
self._start_time = time.perf_counter()
|
||||
self._take_sample()
|
||||
self._take_sample(t_rel=0.0)
|
||||
while not self._stopped:
|
||||
await anyio.sleep(self._interval)
|
||||
self._take_sample()
|
||||
@@ -39,26 +41,51 @@ class PowerSampler:
|
||||
def result(self) -> PowerUsage:
|
||||
self._stopped = True
|
||||
assert self._start_time is not None, "result() called before run()"
|
||||
self._take_sample()
|
||||
elapsed = time.perf_counter() - self._start_time
|
||||
self._take_sample(t_rel=elapsed)
|
||||
|
||||
node_stats: list[NodePowerStats] = []
|
||||
for node_id, profiles in self._samples.items():
|
||||
n = len(profiles)
|
||||
total_energy_j = 0.0
|
||||
for node_id, ts_profiles in self._samples.items():
|
||||
n = len(ts_profiles)
|
||||
if n == 0:
|
||||
continue
|
||||
node_energy_j = trapezoidal_energy(ts_profiles, elapsed)
|
||||
avg_power_w = node_energy_j / elapsed if elapsed > 0 else 0.0
|
||||
total_energy_j += node_energy_j
|
||||
node_stats.append(
|
||||
NodePowerStats(
|
||||
node_id=node_id,
|
||||
samples=n,
|
||||
avg_sys_power=sum(p.sys_power for p in profiles) / n,
|
||||
avg_sys_power=avg_power_w,
|
||||
)
|
||||
)
|
||||
|
||||
total_avg_sys = sum(ns.avg_sys_power for ns in node_stats)
|
||||
total_avg_sys_w = total_energy_j / elapsed if elapsed > 0 else 0.0
|
||||
return PowerUsage(
|
||||
elapsed_seconds=elapsed,
|
||||
nodes=node_stats,
|
||||
total_avg_sys_power_watts=total_avg_sys,
|
||||
total_energy_joules=total_avg_sys * elapsed,
|
||||
total_avg_sys_power_watts=total_avg_sys_w,
|
||||
total_energy_joules=total_energy_j,
|
||||
)
|
||||
|
||||
|
||||
def trapezoidal_energy(
|
||||
ts_profiles: list[tuple[float, SystemPerformanceProfile]],
|
||||
elapsed: float,
|
||||
) -> float:
|
||||
"""Integrate sys_power(t) over the sample window using the trapezoidal rule.
|
||||
First sample is anchored at t=0 and last at t=elapsed (set by `run` /
|
||||
`result`), so the integral spans the full request interval. Falls back to
|
||||
power * elapsed when only one sample exists (constant-power assumption)."""
|
||||
if len(ts_profiles) == 1:
|
||||
return ts_profiles[0][1].sys_power * elapsed
|
||||
energy_j = 0.0
|
||||
for i in range(1, len(ts_profiles)):
|
||||
t_prev, p_prev = ts_profiles[i - 1]
|
||||
t_cur, p_cur = ts_profiles[i]
|
||||
dt = t_cur - t_prev
|
||||
if dt <= 0:
|
||||
continue
|
||||
energy_j += (p_prev.sys_power + p_cur.sys_power) / 2.0 * dt
|
||||
return energy_j
|
||||
@@ -0,0 +1,8 @@
|
||||
import multiprocessing as mp
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def mp_force_spawn():
|
||||
mp.set_start_method("spawn", force=True)
|
||||
@@ -0,0 +1,515 @@
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from types import FrameType
|
||||
|
||||
import mlx.core as mx
|
||||
import pytest
|
||||
from _pytest.capture import CaptureFixture
|
||||
from anyio import EndOfStream, create_task_group, fail_after
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import exo.utils.async_process as async_process
|
||||
from exo.utils.async_process import (
|
||||
AsyncProcess,
|
||||
)
|
||||
from exo.utils.channels import MpSender, Receiver, mp_channel
|
||||
|
||||
|
||||
def _write_to_stdio(prefix: str, *, stderr_suffix: str) -> None:
|
||||
print(f"{prefix}: python stdout")
|
||||
print(f"{prefix}: python stderr {stderr_suffix}", file=sys.stderr)
|
||||
os.write(1, f"{prefix}: fd stdout\n".encode())
|
||||
os.write(2, f"{prefix}: fd stderr {stderr_suffix}\n".encode())
|
||||
|
||||
|
||||
def _write_large_output() -> None:
|
||||
os.write(1, b"stdout-0123456789")
|
||||
os.write(2, b"stderr-0123456789")
|
||||
|
||||
|
||||
def _write_all(fd: int, data: bytes) -> None:
|
||||
remaining = memoryview(data)
|
||||
while remaining:
|
||||
written = os.write(fd, remaining)
|
||||
remaining = remaining[written:]
|
||||
|
||||
|
||||
def _write_large_exact_output(size: int) -> None:
|
||||
_write_all(1, b"stdout:" + (b"x" * size))
|
||||
_write_all(2, b"stderr:" + (b"y" * size))
|
||||
|
||||
|
||||
def _raise_after_stderr_write() -> None:
|
||||
os.write(2, b"stderr before exception\n")
|
||||
raise RuntimeError("child boom")
|
||||
|
||||
|
||||
def _exit_after_stdio_write(prefix: str, exitcode: int) -> None:
|
||||
os.write(1, f"{prefix}: stdout before _exit\n".encode())
|
||||
os.write(2, f"{prefix}: stderr before _exit\n".encode())
|
||||
os._exit(exitcode)
|
||||
|
||||
|
||||
def _abort_after_stdio_write(prefix: str) -> None:
|
||||
os.write(1, f"{prefix}: stdout before abort\n".encode())
|
||||
os.write(2, f"{prefix}: stderr before abort\n".encode())
|
||||
os.abort()
|
||||
|
||||
|
||||
def _close_stdio_and_exit() -> None:
|
||||
os.close(1)
|
||||
os.close(2)
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def _exit_on_sigterm(exitcode: int) -> None:
|
||||
def handle_sigterm(_signum: int, _frame: FrameType | None) -> None:
|
||||
os._exit(exitcode)
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||
os.write(1, b"sigterm-ready\n")
|
||||
while True:
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _exit_after_repeated_sigterm(required_count: int, exitcode: int) -> None:
|
||||
sigterm_count = 0
|
||||
|
||||
def handle_sigterm(_signum: int, _frame: FrameType | None) -> None:
|
||||
nonlocal sigterm_count
|
||||
sigterm_count += 1
|
||||
if sigterm_count >= required_count:
|
||||
os._exit(exitcode)
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||
os.write(1, b"sigterm-ready\n")
|
||||
while True:
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _ignore_sigterm_forever() -> None:
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
os.write(1, b"sigterm-ready\n")
|
||||
while True:
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _sleep_forever() -> None:
|
||||
while True:
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _send_over_mp_channel(send: MpSender[str]) -> None:
|
||||
send.send("hello from child")
|
||||
send.close()
|
||||
|
||||
|
||||
def _mlx_force_oom(size: int = 40_000) -> None:
|
||||
"""
|
||||
Force an Out-Of-Memory (OOM) error in MLX by performing large tensor operations.
|
||||
"""
|
||||
print("CHILD: start")
|
||||
|
||||
mx.set_default_device(mx.gpu)
|
||||
a = mx.random.uniform(shape=(size, size), dtype=mx.float32)
|
||||
b = mx.random.uniform(shape=(size, size), dtype=mx.float32)
|
||||
mx.eval(a, b)
|
||||
c = mx.matmul(a, b)
|
||||
d = mx.matmul(a, c)
|
||||
e = mx.matmul(b, c)
|
||||
f = mx.sigmoid(d + e)
|
||||
mx.eval(f)
|
||||
|
||||
print("CHILD: end")
|
||||
|
||||
|
||||
async def _collect_stream(
|
||||
stream: Receiver[bytes],
|
||||
output: bytearray,
|
||||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
output.extend(await stream.receive())
|
||||
except EndOfStream:
|
||||
return
|
||||
|
||||
|
||||
async def _collect_process_output(
|
||||
process: AsyncProcess,
|
||||
) -> tuple[int, bytes, bytes]:
|
||||
stdout = bytearray()
|
||||
stderr = bytearray()
|
||||
exitcodes: list[int] = []
|
||||
|
||||
async with create_task_group() as task_group:
|
||||
task_group.start_soon(_collect_stream, process.stdout, stdout)
|
||||
task_group.start_soon(_collect_stream, process.stderr, stderr)
|
||||
exitcodes.append(await process.wait())
|
||||
|
||||
if not exitcodes:
|
||||
raise RuntimeError("process exited without a return code")
|
||||
return exitcodes[0], bytes(stdout), bytes(stderr)
|
||||
|
||||
|
||||
def _fd_identity(fd: int) -> tuple[int, int]:
|
||||
fd_stat = os.fstat(fd)
|
||||
return fd_stat.st_dev, fd_stat.st_ino
|
||||
|
||||
|
||||
def _fd_count() -> int | None:
|
||||
for fd_dir in ("/proc/self/fd", "/dev/fd"):
|
||||
with contextlib.suppress(OSError):
|
||||
return len(os.listdir(fd_dir))
|
||||
return None
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _started_process(process: AsyncProcess) -> AsyncIterator[None]:
|
||||
async with create_task_group() as task_group:
|
||||
await task_group.start(process.run)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await process.stop()
|
||||
|
||||
|
||||
async def _run_and_collect(
|
||||
target: Callable[..., object] | None,
|
||||
*,
|
||||
args: tuple[object, ...] = (),
|
||||
kwargs: dict[str, object] | None = None,
|
||||
) -> tuple[int, bytes, bytes]:
|
||||
process = AsyncProcess(
|
||||
target,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
async with _started_process(process):
|
||||
return await _collect_process_output(process)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_spawn_process_captures_stdout_and_stderr_separately(
|
||||
capfd: CaptureFixture[str],
|
||||
) -> None:
|
||||
process = AsyncProcess(
|
||||
_write_to_stdio,
|
||||
args=("child",),
|
||||
kwargs={"stderr_suffix": "error"},
|
||||
)
|
||||
async with _started_process(process):
|
||||
exitcode, stdout_bytes, stderr_bytes = await _collect_process_output(process)
|
||||
|
||||
parent_output = capfd.readouterr()
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
assert exitcode == 0
|
||||
assert "child: python stdout" in stdout
|
||||
assert "child: fd stdout" in stdout
|
||||
assert "child: python stderr error" in stderr
|
||||
assert "child: fd stderr error" in stderr
|
||||
assert "child:" not in parent_output.out
|
||||
assert "child:" not in parent_output.err
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_process_with_no_target_exits_successfully() -> None:
|
||||
exitcode, stdout, stderr = await _run_and_collect(None)
|
||||
|
||||
assert exitcode == 0
|
||||
assert stdout == b""
|
||||
assert stderr == b""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_output_receivers_and_wait_are_safe_immediately_after_run_starts() -> (
|
||||
None
|
||||
):
|
||||
process = AsyncProcess(
|
||||
_write_to_stdio,
|
||||
args=("immediate",),
|
||||
kwargs={"stderr_suffix": "error"},
|
||||
)
|
||||
result: tuple[int, bytes, bytes] | None = None
|
||||
|
||||
async with create_task_group() as task_group:
|
||||
await task_group.start(process.run)
|
||||
try:
|
||||
result = await _collect_process_output(process)
|
||||
finally:
|
||||
await process.stop()
|
||||
|
||||
assert result is not None
|
||||
exitcode, stdout, stderr = result
|
||||
assert exitcode == 0
|
||||
assert b"immediate: fd stdout\n" in stdout
|
||||
assert b"immediate: fd stderr error\n" in stderr
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stop_before_run_raises() -> None:
|
||||
process = AsyncProcess(
|
||||
_write_to_stdio,
|
||||
args=("never",),
|
||||
kwargs={"stderr_suffix": "run"},
|
||||
)
|
||||
|
||||
assert not process.is_alive()
|
||||
with pytest.raises(RuntimeError, match="process has not been started"):
|
||||
await process.stop()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_process_run_is_one_shot() -> None:
|
||||
process = AsyncProcess(None)
|
||||
|
||||
await process.run()
|
||||
|
||||
with pytest.raises(RuntimeError, match="process has already been started"):
|
||||
await process.run()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_process_started_with_task_group_start_can_stop_immediately() -> None:
|
||||
process = AsyncProcess(_sleep_forever)
|
||||
|
||||
async with create_task_group() as task_group:
|
||||
await task_group.start(process.run)
|
||||
assert process.is_alive()
|
||||
with fail_after(2):
|
||||
await process.stop()
|
||||
|
||||
assert not process.is_alive()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stdout_receiver_yields_bytes_chunks() -> None:
|
||||
process = AsyncProcess(_write_large_output)
|
||||
|
||||
async with _started_process(process):
|
||||
first_stdout = await process.stdout.receive()
|
||||
exitcode, remaining_stdout, stderr = await _collect_process_output(process)
|
||||
|
||||
assert exitcode == 0
|
||||
assert first_stdout + remaining_stdout == b"stdout-0123456789"
|
||||
assert stderr == b"stderr-0123456789"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_output_can_be_read_after_process_exits() -> None:
|
||||
process = AsyncProcess(_write_large_output)
|
||||
|
||||
async with create_task_group() as task_group:
|
||||
await task_group.start(process.run)
|
||||
assert await process.wait() == 0
|
||||
|
||||
assert await process.stdout.receive() == b"stdout-0123456789"
|
||||
assert await process.stderr.receive() == b"stderr-0123456789"
|
||||
with pytest.raises(EndOfStream):
|
||||
await process.stdout.receive()
|
||||
with pytest.raises(EndOfStream):
|
||||
await process.stderr.receive()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_large_stdout_and_stderr_are_not_lost() -> None:
|
||||
size = 1024 * 1024
|
||||
exitcode, stdout, stderr = await _run_and_collect(
|
||||
_write_large_exact_output,
|
||||
args=(size,),
|
||||
)
|
||||
|
||||
assert exitcode == 0
|
||||
assert stdout == b"stdout:" + (b"x" * size)
|
||||
assert stderr == b"stderr:" + (b"y" * size)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_child_exception_traceback_is_captured_from_stderr() -> None:
|
||||
process = AsyncProcess(_raise_after_stderr_write)
|
||||
|
||||
async with _started_process(process):
|
||||
exitcode, _, stderr_bytes = await _collect_process_output(process)
|
||||
|
||||
assert exitcode == 1
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
||||
assert "stderr before exception" in stderr
|
||||
assert "RuntimeError: child boom" in stderr
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repeated_bad_children_do_not_pollute_or_replace_parent_stdio(
|
||||
capfd: CaptureFixture[str],
|
||||
) -> None:
|
||||
stdout_object = sys.stdout
|
||||
stderr_object = sys.stderr
|
||||
stdout_identity = _fd_identity(1)
|
||||
stderr_identity = _fd_identity(2)
|
||||
|
||||
cases: tuple[tuple[Callable[..., object], tuple[object, ...]], ...] = (
|
||||
(_raise_after_stderr_write, ()),
|
||||
(_exit_after_stdio_write, ("exit-child", 17)),
|
||||
(_abort_after_stdio_write, ("abort-child",)),
|
||||
)
|
||||
|
||||
for iteration in range(3):
|
||||
for target, args in cases:
|
||||
exitcode, stdout, stderr = await _run_and_collect(
|
||||
target,
|
||||
args=args,
|
||||
)
|
||||
|
||||
assert exitcode != 0
|
||||
if target is _exit_after_stdio_write:
|
||||
assert stdout == b"exit-child: stdout before _exit\n"
|
||||
assert stderr == b"exit-child: stderr before _exit\n"
|
||||
elif target is _abort_after_stdio_write:
|
||||
assert b"abort-child: stdout before abort\n" in stdout
|
||||
assert b"abort-child: stderr before abort\n" in stderr
|
||||
assert exitcode == -signal.SIGABRT
|
||||
else:
|
||||
assert stdout == b""
|
||||
assert b"stderr before exception\n" in stderr
|
||||
assert b"RuntimeError: child boom" in stderr
|
||||
|
||||
print(f"parent stdout still works {iteration}")
|
||||
print(f"parent stderr still works {iteration}", file=sys.stderr)
|
||||
|
||||
parent_output = capfd.readouterr()
|
||||
|
||||
assert sys.stdout is stdout_object
|
||||
assert sys.stderr is stderr_object
|
||||
assert _fd_identity(1) == stdout_identity
|
||||
assert _fd_identity(2) == stderr_identity
|
||||
assert "parent stdout still works 0" in parent_output.out
|
||||
assert "parent stdout still works 2" in parent_output.out
|
||||
assert "parent stderr still works 0" in parent_output.err
|
||||
assert "parent stderr still works 2" in parent_output.err
|
||||
assert "exit-child:" not in parent_output.out
|
||||
assert "exit-child:" not in parent_output.err
|
||||
assert "abort-child:" not in parent_output.out
|
||||
assert "abort-child:" not in parent_output.err
|
||||
assert "child boom" not in parent_output.err
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_child_can_close_stdio_without_corrupting_parent_stdio(
|
||||
capfd: CaptureFixture[str],
|
||||
) -> None:
|
||||
stdout_identity = _fd_identity(1)
|
||||
stderr_identity = _fd_identity(2)
|
||||
|
||||
exitcode, stdout, stderr = await _run_and_collect(_close_stdio_and_exit)
|
||||
os.write(1, b"parent stdout after child closed stdio\n")
|
||||
os.write(2, b"parent stderr after child closed stdio\n")
|
||||
parent_output = capfd.readouterr()
|
||||
|
||||
assert exitcode == 0
|
||||
assert stdout == b""
|
||||
assert stderr == b""
|
||||
assert _fd_identity(1) == stdout_identity
|
||||
assert _fd_identity(2) == stderr_identity
|
||||
assert "parent stdout after child closed stdio" in parent_output.out
|
||||
assert "parent stderr after child closed stdio" in parent_output.err
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repeated_crashing_children_do_not_grow_parent_fd_table() -> None:
|
||||
await _run_and_collect(_exit_after_stdio_write, args=("warmup", 23))
|
||||
before = _fd_count()
|
||||
if before is None:
|
||||
pytest.skip("fd table count is not available on this platform")
|
||||
|
||||
for iteration in range(20):
|
||||
exitcode, stdout, stderr = await _run_and_collect(
|
||||
_exit_after_stdio_write,
|
||||
args=(f"fd-child-{iteration}", 31),
|
||||
)
|
||||
|
||||
assert exitcode == 31
|
||||
assert stdout == f"fd-child-{iteration}: stdout before _exit\n".encode()
|
||||
assert stderr == f"fd-child-{iteration}: stderr before _exit\n".encode()
|
||||
|
||||
after = _fd_count()
|
||||
assert after is not None
|
||||
assert after <= before + 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stop_allows_child_to_exit_after_sigterm() -> None:
|
||||
process = AsyncProcess(_exit_on_sigterm, args=(43,))
|
||||
|
||||
async with _started_process(process):
|
||||
assert await process.stdout.receive() == b"sigterm-ready\n"
|
||||
|
||||
with fail_after(2):
|
||||
await process.stop()
|
||||
|
||||
assert process.exitcode == 43
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stop_retries_sigterm_before_sigkill(monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(async_process, "_TERMINATE_GRACE_SECONDS", 0.01)
|
||||
monkeypatch.setattr(async_process, "_TERMINATE_RETRY_GRACE_SECONDS", 0.01)
|
||||
process = AsyncProcess(_exit_after_repeated_sigterm, args=(3, 44))
|
||||
|
||||
async with _started_process(process):
|
||||
assert await process.stdout.receive() == b"sigterm-ready\n"
|
||||
|
||||
with fail_after(2):
|
||||
await process.stop()
|
||||
|
||||
assert process.exitcode == 44
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stop_escalates_to_sigkill_when_child_ignores_sigterm(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(async_process, "_TERMINATE_GRACE_SECONDS", 0.1)
|
||||
monkeypatch.setattr(async_process, "_TERMINATE_RETRY_GRACE_SECONDS", 0.01)
|
||||
process = AsyncProcess(_ignore_sigterm_forever)
|
||||
|
||||
async with _started_process(process):
|
||||
assert await process.stdout.receive() == b"sigterm-ready\n"
|
||||
|
||||
with fail_after(3):
|
||||
await process.stop()
|
||||
|
||||
assert process.exitcode == -signal.SIGKILL
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_process_can_use_mp_channel_with_global_spawn_context() -> None:
|
||||
send, recv = mp_channel[str]()
|
||||
process = AsyncProcess(_send_over_mp_channel, args=(send,))
|
||||
|
||||
async with _started_process(process):
|
||||
with fail_after(2):
|
||||
assert await recv.receive_async() == "hello from child"
|
||||
assert await process.wait() == 0
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
recv.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.skip(reason="manual MLX OOM isolation check")
|
||||
async def test_death(capsys: CaptureFixture[str]) -> None:
|
||||
with capsys.disabled():
|
||||
process = AsyncProcess(_mlx_force_oom)
|
||||
stdout = b""
|
||||
stderr = b""
|
||||
async with _started_process(process):
|
||||
_, stdout, stderr = await _collect_process_output(process)
|
||||
|
||||
print("PARENT: done")
|
||||
|
||||
print("CHILD out:", stdout.decode("utf-8", errors="replace"))
|
||||
print("CHILD err:", stderr.decode("utf-8", errors="replace"), "hello :)")
|
||||
@@ -0,0 +1,168 @@
|
||||
import contextlib
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from anyio import EndOfStream, create_task_group, fail_after
|
||||
|
||||
from exo.utils.async_process import AsyncProcess
|
||||
from exo.utils.channels import MpReceiver, MpSender, Receiver, mp_channel
|
||||
from exo.utils.daemon import detach_stdio_to_devnull
|
||||
|
||||
|
||||
def _write_before_and_after_detach() -> None:
|
||||
os.write(1, b"before stdout\n")
|
||||
os.write(2, b"before stderr\n")
|
||||
detach_stdio_to_devnull()
|
||||
os.write(1, b"after stdout\n")
|
||||
os.write(2, b"after stderr\n")
|
||||
|
||||
|
||||
def _write_grandchild_stdio(label: str) -> None:
|
||||
os.write(1, f"{label} stdout\n".encode())
|
||||
os.write(2, f"{label} stderr\n".encode())
|
||||
|
||||
|
||||
async def _spawn_grandchild_and_report(
|
||||
result_sender: MpSender[tuple[int, bytes, bytes]],
|
||||
label: str,
|
||||
) -> None:
|
||||
result_sender.send(await _collect_spawned_child(label))
|
||||
result_sender.close()
|
||||
|
||||
|
||||
async def _collect_spawned_child(label: str) -> tuple[int, bytes, bytes]:
|
||||
process = AsyncProcess(_write_grandchild_stdio, args=(label,))
|
||||
async with _started_process(process):
|
||||
return await _collect_process_output(process)
|
||||
|
||||
|
||||
def _detach_stdio_then_spawn_captured_child(
|
||||
result_sender: MpSender[tuple[int, bytes, bytes]],
|
||||
) -> None:
|
||||
detach_stdio_to_devnull()
|
||||
anyio.run(_spawn_grandchild_and_report, result_sender, "grandchild")
|
||||
|
||||
|
||||
def _detach_stdio_then_spawn_captured_children_sequentially(
|
||||
result_sender: MpSender[list[tuple[int, bytes, bytes]]],
|
||||
) -> None:
|
||||
async def run_children() -> list[tuple[int, bytes, bytes]]:
|
||||
results: list[tuple[int, bytes, bytes]] = []
|
||||
for index in range(5):
|
||||
results.append(await _collect_spawned_child(f"grandchild-{index}"))
|
||||
return results
|
||||
|
||||
detach_stdio_to_devnull()
|
||||
result_sender.send(anyio.run(run_children))
|
||||
result_sender.close()
|
||||
|
||||
|
||||
async def _collect_stream(stream: Receiver[bytes], output: bytearray) -> None:
|
||||
while True:
|
||||
try:
|
||||
output.extend(await stream.receive())
|
||||
except EndOfStream:
|
||||
return
|
||||
|
||||
|
||||
async def _collect_process_output(
|
||||
process: AsyncProcess,
|
||||
) -> tuple[int, bytes, bytes]:
|
||||
stdout = bytearray()
|
||||
stderr = bytearray()
|
||||
exitcodes: list[int] = []
|
||||
|
||||
async with create_task_group() as collect_group:
|
||||
collect_group.start_soon(_collect_stream, process.stdout, stdout)
|
||||
collect_group.start_soon(_collect_stream, process.stderr, stderr)
|
||||
exitcodes.append(await process.wait())
|
||||
|
||||
if not exitcodes:
|
||||
raise RuntimeError("process exited without a return code")
|
||||
return exitcodes[0], bytes(stdout), bytes(stderr)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _started_process(process: AsyncProcess) -> AsyncIterator[None]:
|
||||
async with create_task_group() as task_group:
|
||||
await task_group.start(process.run)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await process.stop()
|
||||
|
||||
|
||||
async def _run_process_and_receive[T](
|
||||
process: AsyncProcess,
|
||||
recv: MpReceiver[T],
|
||||
*,
|
||||
timeout: float,
|
||||
) -> tuple[int, T]:
|
||||
async with _started_process(process):
|
||||
with fail_after(timeout):
|
||||
result = await recv.receive_async()
|
||||
exitcode = await process.wait()
|
||||
|
||||
return exitcode, result
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_detach_stdio_to_devnull_redirects_stdio_away_from_capture() -> None:
|
||||
process = AsyncProcess(_write_before_and_after_detach)
|
||||
|
||||
async with _started_process(process):
|
||||
exitcode, stdout, stderr = await _collect_process_output(process)
|
||||
|
||||
assert exitcode == 0
|
||||
assert stdout == b"before stdout\n"
|
||||
assert stderr == b"before stderr\n"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_detached_stdio_process_can_spawn_and_capture_child_stdio() -> None:
|
||||
send, recv = mp_channel[tuple[int, bytes, bytes]]()
|
||||
process = AsyncProcess(_detach_stdio_then_spawn_captured_child, args=(send,))
|
||||
|
||||
try:
|
||||
daemonized_parent_exitcode, result = await _run_process_and_receive(
|
||||
process, recv, timeout=5
|
||||
)
|
||||
finally:
|
||||
recv.close()
|
||||
|
||||
child_exitcode, child_stdout, child_stderr = result
|
||||
|
||||
assert daemonized_parent_exitcode == 0
|
||||
assert child_exitcode == 0
|
||||
assert child_stdout == b"grandchild stdout\n"
|
||||
assert child_stderr == b"grandchild stderr\n"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_detached_stdio_process_can_spawn_captured_children_sequentially() -> (
|
||||
None
|
||||
):
|
||||
send, recv = mp_channel[list[tuple[int, bytes, bytes]]]()
|
||||
process = AsyncProcess(
|
||||
_detach_stdio_then_spawn_captured_children_sequentially,
|
||||
args=(send,),
|
||||
)
|
||||
|
||||
try:
|
||||
daemonized_parent_exitcode, results = await _run_process_and_receive(
|
||||
process, recv, timeout=10
|
||||
)
|
||||
finally:
|
||||
recv.close()
|
||||
|
||||
assert daemonized_parent_exitcode == 0
|
||||
assert results == [
|
||||
(
|
||||
0,
|
||||
f"grandchild-{index} stdout\n".encode(),
|
||||
f"grandchild-{index} stderr\n".encode(),
|
||||
)
|
||||
for index in range(5)
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
import exo.utils.pidfile as pidfile
|
||||
from exo.utils.pidfile import acquire_exo_pidfile
|
||||
|
||||
_CHILD_ACQUIRE_PIDFILE_SCRIPT: Final = textwrap.dedent(
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import exo.utils.pidfile as pidfile
|
||||
from exo.utils.pidfile import PidfileLockError, acquire_exo_pidfile
|
||||
|
||||
with patch.object(pidfile, "EXO_PID_FILE", Path(sys.argv[1])):
|
||||
try:
|
||||
handle = acquire_exo_pidfile()
|
||||
except PidfileLockError as exception:
|
||||
print(str(exception))
|
||||
raise SystemExit(73) from exception
|
||||
|
||||
del handle
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _use_pidfile_path(monkeypatch: pytest.MonkeyPatch, path: Path) -> None:
|
||||
monkeypatch.setattr(pidfile, "EXO_PID_FILE", path)
|
||||
|
||||
|
||||
def _run_child_acquire_pidfile(path: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", _CHILD_ACQUIRE_PIDFILE_SCRIPT, str(path)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_acquire_exo_pidfile_writes_current_pid_and_removes_on_drop(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
path = tmp_path / "exo.pid"
|
||||
_use_pidfile_path(monkeypatch, path)
|
||||
|
||||
handle = acquire_exo_pidfile()
|
||||
assert path.read_text() == str(os.getpid())
|
||||
|
||||
del handle
|
||||
gc.collect()
|
||||
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_acquire_exo_pidfile_rejects_second_process(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
path = tmp_path / "exo.pid"
|
||||
_use_pidfile_path(monkeypatch, path)
|
||||
|
||||
handle = acquire_exo_pidfile()
|
||||
try:
|
||||
blocked_child = _run_child_acquire_pidfile(path)
|
||||
assert blocked_child.returncode == 73
|
||||
assert "Failed to acquire EXO pidfile" in blocked_child.stdout
|
||||
finally:
|
||||
del handle
|
||||
gc.collect()
|
||||
|
||||
unblocked_child = _run_child_acquire_pidfile(path)
|
||||
assert unblocked_child.returncode == 0
|
||||
assert unblocked_child.stdout == ""
|
||||
@@ -111,6 +111,36 @@ async def test_empty_state() -> None:
|
||||
assert result.total_energy_joules == 0.0
|
||||
|
||||
|
||||
def test_trapezoidal_unit_dt_weighting() -> None:
|
||||
"""Pure unit test on the integration helper. Crafted samples where the
|
||||
arithmetic mean is wildly wrong vs the time-weighted result."""
|
||||
from exo.utils.power_sampler import trapezoidal_energy
|
||||
|
||||
# 5 s window. Power = 10 W for the first 4.9 s, then 100 W for the last 0.1 s.
|
||||
# Three samples: t=0 W=10, t=4.9 W=10, t=5.0 W=100.
|
||||
samples = [
|
||||
(0.0, _make_profile(10.0)),
|
||||
(4.9, _make_profile(10.0)),
|
||||
(5.0, _make_profile(100.0)),
|
||||
]
|
||||
energy = trapezoidal_energy(samples, elapsed=5.0)
|
||||
# (10+10)/2 * 4.9 + (10+100)/2 * 0.1 = 49 + 5.5 = 54.5 J
|
||||
assert abs(energy - 54.5) < 1e-9
|
||||
avg = energy / 5.0 # 10.9 W
|
||||
# Arithmetic mean of the three samples would be (10+10+100)/3 ≈ 40 W.
|
||||
# Trapezoidal correctly weights each segment by its dt.
|
||||
assert abs(avg - 10.9) < 1e-9
|
||||
|
||||
|
||||
def test_trapezoidal_unit_single_sample() -> None:
|
||||
"""One sample: no window to integrate over, so fall back to constant power
|
||||
over the elapsed duration."""
|
||||
from exo.utils.power_sampler import trapezoidal_energy
|
||||
|
||||
samples = [(0.0, _make_profile(42.0))]
|
||||
assert trapezoidal_energy(samples, elapsed=3.0) == 42.0 * 3.0
|
||||
|
||||
|
||||
async def test_result_stops_sampling() -> None:
|
||||
"""Calling result() should stop the sampler's run loop."""
|
||||
state: dict[NodeId, SystemPerformanceProfile] = {
|
||||
|
||||
@@ -115,7 +115,8 @@ def mlx_distributed_init(
|
||||
|
||||
os.environ["MLX_HOSTFILE"] = coordination_file
|
||||
os.environ["MLX_RANK"] = str(rank)
|
||||
os.environ["MLX_RING_VERBOSE"] = "1"
|
||||
# os.environ["MLX_RING_VERBOSE"] = "1" # NOTE: we don't use it enough to care (turn on again if need to)
|
||||
|
||||
group = mx.distributed.init(backend="ring", strict=True)
|
||||
|
||||
case MlxJacclInstance(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import contextlib
|
||||
import multiprocessing as mp
|
||||
import signal
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Self
|
||||
@@ -8,7 +7,7 @@ import anyio
|
||||
from anyio import (
|
||||
BrokenResourceError,
|
||||
ClosedResourceError,
|
||||
to_thread,
|
||||
EndOfStream,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
@@ -41,7 +40,8 @@ from exo.shared.types.worker.runners import (
|
||||
RunnerWarmingUp,
|
||||
)
|
||||
from exo.shared.types.worker.shards import ShardMetadata
|
||||
from exo.utils.channels import MpReceiver, MpSender, Sender, mp_channel
|
||||
from exo.utils.async_process import AsyncProcess
|
||||
from exo.utils.channels import MpReceiver, MpSender, Receiver, Sender, mp_channel
|
||||
from exo.utils.task_group import TaskGroup
|
||||
from exo.worker.runner.bootstrap import entrypoint
|
||||
|
||||
@@ -53,7 +53,7 @@ DECODE_TIMEOUT_SECONDS = 5
|
||||
class RunnerSupervisor:
|
||||
shard_metadata: ShardMetadata
|
||||
bound_instance: BoundInstance
|
||||
runner_process: mp.Process
|
||||
runner_process: AsyncProcess
|
||||
initialize_timeout: float
|
||||
_ev_recv: MpReceiver[Event]
|
||||
_task_sender: MpSender[Task]
|
||||
@@ -81,7 +81,7 @@ class RunnerSupervisor:
|
||||
task_sender, task_recv = mp_channel[Task]()
|
||||
cancel_sender, cancel_recv = mp_channel[TaskId]()
|
||||
|
||||
runner_process = mp.Process(
|
||||
runner_process = AsyncProcess(
|
||||
target=entrypoint,
|
||||
args=(
|
||||
bound_instance,
|
||||
@@ -109,9 +109,25 @@ class RunnerSupervisor:
|
||||
return self
|
||||
|
||||
async def run(self):
|
||||
self.runner_process.start()
|
||||
try:
|
||||
async with self._tg as tg:
|
||||
# start the process itself
|
||||
await tg.start(self.runner_process.run)
|
||||
|
||||
# start tasks to drain/collect stdout/stderr into usable errors
|
||||
#
|
||||
# TODO: right now it logs them as warnings, but in the future they should be split
|
||||
# into being logged AND a seperate task which tries to best-effort figure out cause
|
||||
# of error and package into error enum, which then is used by rest of app to act on it;
|
||||
# inferring what the error is would be done by pattern-matching in the text for things
|
||||
# e.g. certain VLLM error codes and so on
|
||||
tg.start_soon(
|
||||
self._forward_runner_output, "stdout", self.runner_process.stdout
|
||||
)
|
||||
tg.start_soon(
|
||||
self._forward_runner_output, "stderr", self.runner_process.stderr
|
||||
)
|
||||
|
||||
tg.start_soon(self._watch_runner)
|
||||
tg.start_soon(self._forward_events)
|
||||
finally:
|
||||
@@ -129,41 +145,11 @@ class RunnerSupervisor:
|
||||
with contextlib.suppress(ClosedResourceError):
|
||||
self._cancel_sender.close()
|
||||
|
||||
await to_thread.run_sync(self.runner_process.join, 5)
|
||||
|
||||
if self.runner_process.is_alive():
|
||||
logger.warning(
|
||||
"Runner process didn't shutdown succesfully, terminating"
|
||||
with anyio.CancelScope(shield=True):
|
||||
await self.runner_process.stop()
|
||||
logger.info(
|
||||
f"Runner process successfully terminated: {self.runner_process.exitcode}"
|
||||
)
|
||||
self.runner_process.terminate()
|
||||
self.runner_process.join(timeout=10)
|
||||
|
||||
if not self.runner_process.is_alive():
|
||||
logger.warning("Terminated nicely in the first attempt!")
|
||||
|
||||
else:
|
||||
# Try really hard to terminate
|
||||
for i in range(2, 11):
|
||||
self.runner_process.terminate()
|
||||
self.runner_process.join(timeout=2)
|
||||
if not self.runner_process.is_alive():
|
||||
logger.warning(f"That took {i} attempts :)")
|
||||
break
|
||||
# Try even harder to kill
|
||||
else:
|
||||
logger.critical(
|
||||
"Runner process didn't respond to SIGTERM, killing"
|
||||
)
|
||||
j = 0
|
||||
while self.runner_process.is_alive():
|
||||
j += 1
|
||||
self.runner_process.kill()
|
||||
self.runner_process.join(timeout=5)
|
||||
logger.warning(f"That took {j} attempts :(")
|
||||
else:
|
||||
logger.info("Runner process succesfully terminated")
|
||||
|
||||
self.runner_process.close()
|
||||
|
||||
def shutdown(self):
|
||||
self._tg.cancel_tasks()
|
||||
@@ -249,13 +235,33 @@ class RunnerSupervisor:
|
||||
if not self.runner_process.is_alive():
|
||||
await self._check_runner(RuntimeError("Runner found to be dead"))
|
||||
|
||||
async def _forward_runner_output(
|
||||
self,
|
||||
stream_name: str,
|
||||
stream: Receiver[bytes],
|
||||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
chunk = await stream.receive()
|
||||
except (EndOfStream, ClosedResourceError, BrokenResourceError):
|
||||
return
|
||||
|
||||
message = chunk.decode("utf-8", errors="replace").rstrip()
|
||||
if not message:
|
||||
continue
|
||||
if stream_name == "stderr":
|
||||
logger.warning(f"Runner stderr: {message}")
|
||||
else:
|
||||
logger.debug(f"Runner stdout: {message}")
|
||||
|
||||
async def _check_runner(self, e: Exception) -> None:
|
||||
if not self._cancel_watch_runner.cancel_called:
|
||||
self._cancel_watch_runner.cancel()
|
||||
logger.info("Checking runner's status")
|
||||
if self.runner_process.is_alive():
|
||||
logger.info("Runner was found to be alive, attempting to join process")
|
||||
await to_thread.run_sync(self.runner_process.join, 5)
|
||||
logger.info("Runner was found to be alive, stopping process")
|
||||
with anyio.CancelScope(shield=True):
|
||||
await self.runner_process.stop()
|
||||
rc = self.runner_process.exitcode
|
||||
logger.info(f"Runner exited with exit code {rc}")
|
||||
if rc == 0:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import multiprocessing as mp
|
||||
from typing import cast
|
||||
|
||||
import anyio
|
||||
@@ -16,6 +15,7 @@ from exo.shared.types.text_generation import (
|
||||
)
|
||||
from exo.shared.types.worker.instances import BoundInstance, InstanceId
|
||||
from exo.shared.types.worker.runners import RunnerFailed, RunnerId
|
||||
from exo.utils.async_process import AsyncProcess
|
||||
from exo.utils.channels import channel, mp_channel
|
||||
from exo.worker.runner.supervisor import RunnerSupervisor
|
||||
from exo.worker.tests.unittests.conftest import get_bound_mlx_ring_instance
|
||||
@@ -24,23 +24,11 @@ from exo.worker.tests.unittests.conftest import get_bound_mlx_ring_instance
|
||||
class _DeadProcess:
|
||||
exitcode = -6
|
||||
|
||||
def start(self) -> None:
|
||||
return None
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return False
|
||||
|
||||
def join(self, _timeout: float | None = None) -> None:
|
||||
return None
|
||||
|
||||
def terminate(self) -> None:
|
||||
return None
|
||||
|
||||
def kill(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.anyio
|
||||
async def test_check_runner_emits_error_chunk_for_inflight_text_generation() -> None:
|
||||
event_sender, event_receiver = channel[Event]()
|
||||
task_sender, _ = mp_channel[Task]()
|
||||
@@ -57,7 +45,7 @@ async def test_check_runner_emits_error_chunk_for_inflight_text_generation() ->
|
||||
supervisor = RunnerSupervisor(
|
||||
shard_metadata=bound_instance.bound_shard,
|
||||
bound_instance=bound_instance,
|
||||
runner_process=cast("mp.Process", cast(object, _DeadProcess())),
|
||||
runner_process=cast(AsyncProcess, cast(object, _DeadProcess())),
|
||||
initialize_timeout=400,
|
||||
_ev_recv=ev_recv,
|
||||
_task_sender=task_sender,
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,181 @@
|
||||
# type: ignore
|
||||
"""Pytest configuration for marker-driven exo integration tests.
|
||||
|
||||
Test authors declare requirements via markers:
|
||||
|
||||
@pytest.mark.cluster(count=2, thunderbolt='a2a')
|
||||
@pytest.mark.instance('mlx-community/Llama-3.2-1B-Instruct-4bit',
|
||||
sharding='tensor', comm='jaccl')
|
||||
def test_jaccl_inference(session):
|
||||
resp = session.chat('What is 2+2?')
|
||||
assert '4' in resp
|
||||
|
||||
Clusters are cached by `ClusterSpec`; tests with the same cluster_spec
|
||||
share a deployment. Each test places its own instance (matching its
|
||||
`@pytest.mark.instance`), and instances are cleaned up after the test.
|
||||
|
||||
Run with:
|
||||
uv run pytest tests/ -v
|
||||
uv run pytest tests/ -v --hosts s2,s4,s9,s10
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from exo_tools.cluster import ClusterInfo, EcoSession
|
||||
from exo_tools.harness import cleanup_all_instances, place_instance
|
||||
|
||||
from .framework import (
|
||||
ClusterSpec,
|
||||
Session,
|
||||
parse_cluster_marker,
|
||||
parse_instance_marker,
|
||||
)
|
||||
|
||||
# Single eco session for the entire test process.
|
||||
eco = EcoSession(user_prefix="test")
|
||||
|
||||
# Cluster cache keyed by ClusterSpec — tests with the same spec share a deployment.
|
||||
# Cleared at session teardown.
|
||||
_cluster_cache: dict[ClusterSpec, ClusterInfo] = {}
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--hosts",
|
||||
default=None,
|
||||
help="Comma-separated list of hosts (e.g. s2,s4,s9,s10). "
|
||||
"Overrides constraint-based reservation.",
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Register custom markers."""
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"cluster(count=N, thunderbolt=Thunderbolt|None, min_memory=GB, chip=PATTERN): "
|
||||
"declare cluster requirements for a test",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"instance(model_id, sharding=Sharding, comm=Comm, min_nodes=N): "
|
||||
"declare instance placement for a test",
|
||||
)
|
||||
|
||||
|
||||
def pytest_report_header(config):
|
||||
"""Show the eco user and hosts for this test session."""
|
||||
hosts = config.getoption("--hosts")
|
||||
lines = [f"eco user: {eco.user}"]
|
||||
if hosts:
|
||||
lines.append(f"hosts override: {hosts}")
|
||||
return lines
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def _host_pool(request) -> list[str] | None:
|
||||
raw = request.config.getoption("--hosts")
|
||||
if raw:
|
||||
return [h.strip() for h in raw.split(",") if h.strip()]
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(request, _host_pool) -> Session:
|
||||
"""Per-test fixture providing a Session matching the test's markers.
|
||||
|
||||
Reads @pytest.mark.cluster and @pytest.mark.instance from the test, deploys
|
||||
a matching cluster (cached across tests with the same spec), places the
|
||||
model, and yields a Session for the test to interact with. Cleans up the
|
||||
instance after the test, and invalidates the cluster cache if the test
|
||||
left nodes disconnected.
|
||||
"""
|
||||
cluster_marker = request.node.get_closest_marker("cluster")
|
||||
instance_marker = request.node.get_closest_marker("instance")
|
||||
|
||||
cluster_spec = parse_cluster_marker(cluster_marker)
|
||||
instance_spec = parse_instance_marker(instance_marker)
|
||||
|
||||
# Deploy or reuse a cluster matching the spec
|
||||
cluster = _cluster_cache.get(cluster_spec)
|
||||
if cluster is None:
|
||||
if _host_pool:
|
||||
cluster = eco.start_deploy(
|
||||
hosts=_host_pool[: cluster_spec.count], wait=True
|
||||
)
|
||||
else:
|
||||
cluster = eco.start_deploy(
|
||||
count=cluster_spec.count,
|
||||
thunderbolt=cluster_spec.thunderbolt,
|
||||
chip=cluster_spec.chip,
|
||||
min_memory_gb=cluster_spec.min_memory_gb,
|
||||
wait=True,
|
||||
)
|
||||
_cluster_cache[cluster_spec] = cluster
|
||||
|
||||
# Place an instance for this test if the test specified one
|
||||
instance_id = None
|
||||
if instance_spec is not None:
|
||||
client = cluster.make_client()
|
||||
instance_id = place_instance(
|
||||
client,
|
||||
instance_spec.model_id,
|
||||
sharding=instance_spec.sharding,
|
||||
comm=instance_spec.comm,
|
||||
min_nodes=instance_spec.min_nodes,
|
||||
)
|
||||
|
||||
sess = Session(
|
||||
cluster=cluster,
|
||||
eco=eco,
|
||||
instance_spec=instance_spec,
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
yield sess
|
||||
|
||||
# ---- Teardown ----
|
||||
|
||||
# If the test left nodes disconnected, invalidate the cluster cache and
|
||||
# stop the cluster so the next test deploys fresh.
|
||||
if sess._stopped_hosts:
|
||||
_cluster_cache.pop(cluster_spec, None)
|
||||
with contextlib.suppress(Exception):
|
||||
eco.stop(sess.cluster.hosts)
|
||||
return
|
||||
|
||||
# Otherwise, clean up any instances created during the test
|
||||
with contextlib.suppress(Exception):
|
||||
cleanup_all_instances(sess.client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-level teardown — stop all cached clusters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _teardown_clusters():
|
||||
yield
|
||||
for cluster in _cluster_cache.values():
|
||||
with contextlib.suppress(Exception):
|
||||
eco.stop(cluster.hosts)
|
||||
_cluster_cache.clear()
|
||||
|
||||
|
||||
def pytest_runtest_makereport(item, call):
|
||||
"""Attach cluster logs to the test report when a test fails."""
|
||||
if call.when != "call" or call.excinfo is None:
|
||||
return
|
||||
|
||||
sess = item.funcargs.get("session")
|
||||
if sess is None:
|
||||
return
|
||||
try:
|
||||
logs = eco.logs(sess.cluster.hosts, lines=200)
|
||||
item.add_report_section("call", "Cluster Logs", json.dumps(logs, indent=2))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Marker-driven test framework for exo integration tests.
|
||||
|
||||
Test authors declare requirements via markers:
|
||||
|
||||
@pytest.mark.cluster(count=2, thunderbolt='a2a')
|
||||
@pytest.mark.instance('mlx-community/Llama-3.2-1B-Instruct-4bit',
|
||||
sharding='tensor', comm='jaccl')
|
||||
def test_jaccl_inference(session):
|
||||
resp = session.chat('What is 2+2?')
|
||||
assert '4' in resp
|
||||
|
||||
The `session` fixture reads the markers, deploys the cluster, places the
|
||||
instance, and provides a `Session` object. All cluster/instance orchestration
|
||||
lives in `exo_tools.harness`; this module is purely the pytest-facing layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from exo_tools.client import ExoClient
|
||||
from exo_tools.cluster import (
|
||||
Chip,
|
||||
ClusterInfo,
|
||||
EcoSession,
|
||||
Thunderbolt,
|
||||
make_client_from_url,
|
||||
)
|
||||
from exo_tools.harness import Comm, Sharding
|
||||
|
||||
from exo.api.types.api import (
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionResponse,
|
||||
)
|
||||
|
||||
DEFAULT_MODEL = "mlx-community/Llama-3.2-1B-Instruct-4bit"
|
||||
|
||||
|
||||
def _extract_content(resp: ChatCompletionResponse) -> str:
|
||||
"""Extract plain-text content from a non-streaming chat completion."""
|
||||
choice = resp.choices[0]
|
||||
if not isinstance(choice, ChatCompletionChoice):
|
||||
raise RuntimeError(
|
||||
f"Expected non-streaming choice, got {type(choice).__name__}"
|
||||
)
|
||||
content = choice.message.content
|
||||
if not isinstance(content, str):
|
||||
raise RuntimeError(f"Expected string content, got {type(content).__name__}")
|
||||
return content
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClusterSpec:
|
||||
count: int = 1
|
||||
thunderbolt: Thunderbolt | None = None
|
||||
min_memory_gb: float | None = None
|
||||
chip: Chip | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstanceSpec:
|
||||
model_id: str
|
||||
sharding: Sharding = Sharding.PIPELINE
|
||||
comm: Comm = Comm.RING
|
||||
min_nodes: int = 1
|
||||
|
||||
|
||||
def parse_cluster_marker(marker) -> ClusterSpec:
|
||||
if marker is None:
|
||||
return ClusterSpec()
|
||||
return ClusterSpec(
|
||||
count=marker.kwargs.get("count", 1),
|
||||
thunderbolt=marker.kwargs.get("thunderbolt"),
|
||||
min_memory_gb=marker.kwargs.get("min_memory"),
|
||||
chip=marker.kwargs.get("chip"),
|
||||
)
|
||||
|
||||
|
||||
def parse_instance_marker(marker) -> InstanceSpec | None:
|
||||
if marker is None:
|
||||
return None
|
||||
if not marker.args:
|
||||
raise ValueError(
|
||||
"@pytest.mark.instance requires a positional model_id argument"
|
||||
)
|
||||
return InstanceSpec(
|
||||
model_id=marker.args[0],
|
||||
sharding=marker.kwargs.get("sharding", Sharding.PIPELINE),
|
||||
comm=marker.kwargs.get("comm", Comm.RING),
|
||||
min_nodes=marker.kwargs.get("min_nodes", 1),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
cluster: ClusterInfo
|
||||
eco: EcoSession
|
||||
instance_spec: InstanceSpec | None = None
|
||||
instance_id: str | None = None
|
||||
_stopped_hosts: set[str] = field(default_factory=set)
|
||||
|
||||
@property
|
||||
def client(self) -> ExoClient:
|
||||
for host in self.cluster.hosts:
|
||||
if host not in self._stopped_hosts:
|
||||
return make_client_from_url(self.cluster.api_endpoints[host])
|
||||
return self.cluster.make_client()
|
||||
|
||||
@property
|
||||
def state(self) -> dict[str, Any]:
|
||||
return self.client.request_json("GET", "/state") or {}
|
||||
|
||||
@property
|
||||
def instances(self) -> dict[str, Any]:
|
||||
return self.state.get("instances", {})
|
||||
|
||||
# ---- Inference ----
|
||||
|
||||
def chat(self, prompt: str, max_tokens: int = 100) -> str:
|
||||
resp = self.chat_raw(prompt, max_tokens=max_tokens)
|
||||
return _extract_content(resp)
|
||||
|
||||
def chat_raw(self, prompt: str, **kwargs: Any) -> ChatCompletionResponse:
|
||||
if not self.instance_spec:
|
||||
raise RuntimeError(
|
||||
"No instance placed; add @pytest.mark.instance to the test"
|
||||
)
|
||||
max_tokens = kwargs.pop("max_tokens", 100)
|
||||
request = ChatCompletionRequest.model_validate(
|
||||
{
|
||||
"model": self.instance_spec.model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
return self._post_chat(request)
|
||||
|
||||
def multi_turn(self, messages: list[dict[str, str]], max_tokens: int = 100) -> str:
|
||||
if not self.instance_spec:
|
||||
raise RuntimeError(
|
||||
"No instance placed; add @pytest.mark.instance to the test"
|
||||
)
|
||||
request = ChatCompletionRequest.model_validate(
|
||||
{
|
||||
"model": self.instance_spec.model_id,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
)
|
||||
return _extract_content(self._post_chat(request))
|
||||
|
||||
def _post_chat(self, request: ChatCompletionRequest) -> ChatCompletionResponse:
|
||||
raw = self.client.request_json(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
body=request.model_dump(exclude_none=True),
|
||||
)
|
||||
return ChatCompletionResponse.model_validate(raw)
|
||||
|
||||
def disconnect_node(self, index: int) -> None:
|
||||
"""Stop exo on a node and wait for the cluster to observe the disconnect."""
|
||||
host = self.cluster.hosts[index]
|
||||
self.eco.stop([host], keep=True)
|
||||
self._stopped_hosts.add(host)
|
||||
|
||||
def reconnect_node(self, index: int) -> None:
|
||||
"""Restart a previously disconnected node into the existing namespace."""
|
||||
host = self.cluster.hosts[index]
|
||||
self.eco.start_hosts([host], namespace=self.cluster.namespace)
|
||||
self._stopped_hosts.discard(host)
|
||||
|
||||
def wait_ready(
|
||||
self, expected_nodes: int | None = None, timeout: float = 60
|
||||
) -> None:
|
||||
"""Wait until the cluster has exactly `expected_nodes` visible and reporting memory.
|
||||
|
||||
Defaults to the count of non-stopped hosts. Use this after
|
||||
`disconnect_node` / `reconnect_node` to wait for the cluster to settle.
|
||||
"""
|
||||
if expected_nodes is None:
|
||||
expected_nodes = len(self.cluster.hosts) - len(self._stopped_hosts)
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
state = self.state
|
||||
identities = len(state.get("nodeIdentities", {}))
|
||||
memory = len(state.get("nodeMemory", {}))
|
||||
if identities == expected_nodes and memory == expected_nodes:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2.0)
|
||||
raise TimeoutError(
|
||||
f"Cluster did not reach exactly {expected_nodes} ready nodes within {timeout}s"
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
# type: ignore
|
||||
"""Single-node integration tests.
|
||||
|
||||
Run with:
|
||||
uv run pytest tests/test_1node.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from exo_tools.harness import is_model_downloaded, place_instance
|
||||
|
||||
from .framework import DEFAULT_MODEL, InstanceSpec
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=1)
|
||||
@pytest.mark.instance(DEFAULT_MODEL)
|
||||
def test_place_instance_and_chat(session):
|
||||
resp = session.chat("Say hello in one sentence.")
|
||||
assert len(resp) > 0
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=1)
|
||||
@pytest.mark.instance(DEFAULT_MODEL)
|
||||
def test_chat_multiple_turns(session):
|
||||
first_reply = session.chat("What is 2 + 2?")
|
||||
assert len(first_reply) > 0
|
||||
|
||||
second_reply = session.multi_turn(
|
||||
[
|
||||
{"role": "user", "content": "What is 2 + 2?"},
|
||||
{"role": "assistant", "content": first_reply},
|
||||
{"role": "user", "content": "Now multiply that by 3."},
|
||||
]
|
||||
)
|
||||
assert len(second_reply) > 0
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=1)
|
||||
@pytest.mark.instance(DEFAULT_MODEL)
|
||||
def test_delete_instance(session):
|
||||
from exo_tools.harness import wait_for_instance_gone
|
||||
|
||||
session.client.request_json("DELETE", f"/instance/{session.instance_id}")
|
||||
wait_for_instance_gone(session.client, session.instance_id, timeout=30.0)
|
||||
assert len(session.instances) == 0, (
|
||||
f"Expected no instances, found {len(session.instances)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=1)
|
||||
def test_download_from_scratch(session):
|
||||
"""Ensure the model is not on the cluster, then place an instance to
|
||||
trigger a fresh download and verify inference.
|
||||
"""
|
||||
node_id = next(iter(session.state.get("nodeIdentities", {})))
|
||||
|
||||
# Delete any existing download — the API call is idempotent
|
||||
session.client.request_json("DELETE", f"/download/{node_id}/{DEFAULT_MODEL}")
|
||||
|
||||
# Poll until the model is gone (it may already be gone)
|
||||
deadline = time.time() + 60.0
|
||||
while time.time() < deadline:
|
||||
if not is_model_downloaded(session.client, DEFAULT_MODEL):
|
||||
break
|
||||
time.sleep(2.0)
|
||||
else:
|
||||
raise AssertionError(f"Expected {DEFAULT_MODEL} to be deleted from cluster")
|
||||
|
||||
place_instance(session.client, DEFAULT_MODEL, timeout=900.0)
|
||||
session.instance_spec = InstanceSpec(model_id=DEFAULT_MODEL)
|
||||
resp = session.chat("Say hello in one sentence.")
|
||||
assert len(resp) > 0
|
||||
@@ -0,0 +1,49 @@
|
||||
# type: ignore
|
||||
"""Two-node integration tests (ring + jaccl parallelism).
|
||||
|
||||
Run with:
|
||||
uv run pytest tests/test_2node.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from exo_tools.cluster import Thunderbolt
|
||||
from exo_tools.harness import Comm, Sharding
|
||||
|
||||
from .framework import DEFAULT_MODEL
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=2, thunderbolt=Thunderbolt.A2A)
|
||||
@pytest.mark.instance(
|
||||
DEFAULT_MODEL, sharding=Sharding.TENSOR, comm=Comm.JACCL, min_nodes=2
|
||||
)
|
||||
def test_2node_jaccl(session):
|
||||
resp = session.chat("Say hello in one sentence.")
|
||||
assert len(resp) > 0
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=2, thunderbolt=Thunderbolt.A2A)
|
||||
@pytest.mark.instance(
|
||||
DEFAULT_MODEL, sharding=Sharding.PIPELINE, comm=Comm.RING, min_nodes=2
|
||||
)
|
||||
def test_2node_ring(session):
|
||||
resp = session.chat("Say hello in one sentence.")
|
||||
assert len(resp) > 0
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=2, thunderbolt=Thunderbolt.A2A)
|
||||
@pytest.mark.instance(
|
||||
DEFAULT_MODEL, sharding=Sharding.TENSOR, comm=Comm.JACCL, min_nodes=2
|
||||
)
|
||||
def test_2node_jaccl_multi_turn(session):
|
||||
first = session.chat("What is the capital of France?")
|
||||
assert len(first) > 0
|
||||
second = session.multi_turn(
|
||||
[
|
||||
{"role": "user", "content": "What is the capital of France?"},
|
||||
{"role": "assistant", "content": first},
|
||||
{"role": "user", "content": "What country is it in?"},
|
||||
]
|
||||
)
|
||||
assert len(second) > 0
|
||||
@@ -0,0 +1,32 @@
|
||||
# type: ignore
|
||||
"""Four-node integration tests.
|
||||
|
||||
Run with:
|
||||
uv run pytest tests/test_4node.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from exo_tools.cluster import Thunderbolt
|
||||
from exo_tools.harness import Comm, Sharding
|
||||
|
||||
from .framework import DEFAULT_MODEL
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=4, thunderbolt=Thunderbolt.A2A)
|
||||
@pytest.mark.instance(
|
||||
DEFAULT_MODEL, sharding=Sharding.PIPELINE, comm=Comm.RING, min_nodes=4
|
||||
)
|
||||
def test_4node_pipeline_ring(session):
|
||||
resp = session.chat("Say hello in one sentence.")
|
||||
assert len(resp) > 0
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=4, thunderbolt=Thunderbolt.A2A)
|
||||
@pytest.mark.instance(
|
||||
DEFAULT_MODEL, sharding=Sharding.TENSOR, comm=Comm.JACCL, min_nodes=4
|
||||
)
|
||||
def test_4node_tensor_jaccl(session):
|
||||
resp = session.chat("Say hello in one sentence.")
|
||||
assert len(resp) > 0
|
||||
@@ -0,0 +1,102 @@
|
||||
# type: ignore
|
||||
"""Dashboard end-to-end tests using Playwright (headless Chromium).
|
||||
|
||||
Prerequisites:
|
||||
uv run playwright install chromium
|
||||
|
||||
Run with:
|
||||
uv run pytest tests/test_dashboard.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
_HAS_PLAYWRIGHT = True
|
||||
except ImportError:
|
||||
_HAS_PLAYWRIGHT = False
|
||||
|
||||
# Check if Chromium is installed by attempting a quick launch
|
||||
_HAS_CHROMIUM = False
|
||||
if _HAS_PLAYWRIGHT:
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
browser.close()
|
||||
_HAS_CHROMIUM = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _HAS_PLAYWRIGHT or not _HAS_CHROMIUM,
|
||||
reason="playwright or chromium not installed (run: uv run playwright install chromium)",
|
||||
)
|
||||
|
||||
|
||||
def _mark_onboarding_complete(session) -> None:
|
||||
"""Mark onboarding complete on the server so the wizard doesn't auto-launch a model."""
|
||||
with contextlib.suppress(Exception):
|
||||
session.client.request_json("POST", "/onboarding")
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=1)
|
||||
def test_dashboard_chat_inference(session):
|
||||
"""Full UI flow: open dashboard, pick a model, send a chat, verify response.
|
||||
|
||||
The instance is created via the dashboard UI (model picker → chat send
|
||||
triggers the dashboard's auto-launch flow), not via @pytest.mark.instance.
|
||||
"""
|
||||
_mark_onboarding_complete(session)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
page.goto(session.cluster.api_url, wait_until="networkidle")
|
||||
page.wait_for_timeout(3000)
|
||||
page.screenshot(path="/tmp/dashboard_initial.png")
|
||||
|
||||
# Open the model picker by clicking the "SELECT MODEL" button
|
||||
page.get_by_text("SELECT MODEL", exact=False).first.click()
|
||||
page.wait_for_timeout(1000)
|
||||
page.screenshot(path="/tmp/dashboard_picker_open.png")
|
||||
|
||||
# Search for the model — uses the model id substring; the picker
|
||||
# matches against name/id so "Llama-3.2-1B" filters to the small Llama.
|
||||
search_input = page.locator('input[placeholder*="Search models"]').first
|
||||
search_input.fill("Llama-3.2-1B")
|
||||
page.wait_for_timeout(1500)
|
||||
page.screenshot(path="/tmp/dashboard_picker_search.png")
|
||||
|
||||
# Click the only matching result. The picker shows the model's
|
||||
# display name (e.g. "Llama 3.2 1B") which differs from the model_id.
|
||||
# We click the first visible button-like row in the result list.
|
||||
page.get_by_text("Llama 3.2 1B", exact=False).first.click()
|
||||
page.wait_for_timeout(1500)
|
||||
page.screenshot(path="/tmp/dashboard_model_selected.png")
|
||||
|
||||
# Type a chat message — sending triggers the dashboard's auto-launch
|
||||
# flow: it picks an optimal placement for the selected model and POSTs
|
||||
# to /instance, then sends the chat once the runner is ready.
|
||||
chat_input = page.locator("textarea").first
|
||||
chat_input.fill("Say hello")
|
||||
chat_input.press("Enter")
|
||||
page.screenshot(path="/tmp/dashboard_chat_sent.png")
|
||||
|
||||
# Wait for the instance to launch and respond. Generous timeout
|
||||
# because this includes model placement + load + generation.
|
||||
page.wait_for_timeout(60000)
|
||||
page.screenshot(path="/tmp/dashboard_after_chat.png")
|
||||
|
||||
# Verify an instance was created and the chat got a response
|
||||
instances = session.client.request_json("GET", "/state").get("instances", {})
|
||||
assert len(instances) > 0, "Expected the dashboard to have created an instance"
|
||||
|
||||
body_text = page.text_content("body") or ""
|
||||
assert len(body_text) > 0
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,56 @@
|
||||
# type: ignore
|
||||
"""Resilience tests: disconnect/reconnect nodes and verify cluster recovery.
|
||||
|
||||
Run with:
|
||||
uv run pytest tests/test_resilience.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from exo_tools.cluster import Thunderbolt
|
||||
from exo_tools.harness import Comm, Sharding, cleanup_all_instances, place_instance
|
||||
|
||||
from .framework import DEFAULT_MODEL, InstanceSpec
|
||||
|
||||
|
||||
@pytest.mark.cluster(count=2, thunderbolt=Thunderbolt.A2A)
|
||||
@pytest.mark.instance(
|
||||
DEFAULT_MODEL, sharding=Sharding.PIPELINE, comm=Comm.RING, min_nodes=2
|
||||
)
|
||||
def test_node_recovery(session):
|
||||
"""Full disconnect/reconnect cycle.
|
||||
|
||||
1. Place a 2-node instance, verify inference
|
||||
2. Disconnect one node
|
||||
3. Place a 1-node instance on remaining node, verify inference
|
||||
4. Reconnect the stopped node, wait for the cluster to reform
|
||||
5. Place a 2-node instance again, verify inference
|
||||
"""
|
||||
# --- Phase 1: 2-node inference ---
|
||||
resp = session.chat("Hello")
|
||||
assert len(resp) > 0
|
||||
|
||||
# --- Phase 2: disconnect one node ---
|
||||
session.disconnect_node(1)
|
||||
session.wait_ready(60)
|
||||
|
||||
# Clean up the now-broken 2-node instance
|
||||
cleanup_all_instances(session.client)
|
||||
|
||||
# --- Phase 3: 1-node inference on the remaining node ---
|
||||
place_instance(session.client, DEFAULT_MODEL, min_nodes=1)
|
||||
session.instance_spec = InstanceSpec(model_id=DEFAULT_MODEL, min_nodes=1)
|
||||
resp = session.chat("Hello")
|
||||
assert len(resp) > 0
|
||||
|
||||
# --- Phase 4: reconnect and restore 2-node cluster ---
|
||||
cleanup_all_instances(session.client)
|
||||
session.reconnect_node(1)
|
||||
session.wait_ready(60)
|
||||
|
||||
# --- Phase 5: 2-node inference again ---
|
||||
place_instance(session.client, DEFAULT_MODEL, min_nodes=2)
|
||||
session.instance_spec = InstanceSpec(model_id=DEFAULT_MODEL, min_nodes=2)
|
||||
resp = session.chat("Hello again")
|
||||
assert len(resp) > 0
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,10 @@
|
||||
[project]
|
||||
name = "exo-tools"
|
||||
version = "0.1.0"
|
||||
description = "Shared tooling for interacting with exo clusters"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["loguru>=0.7.3"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
Whitespace-only changes.
@@ -0,0 +1,117 @@
|
||||
# type: ignore
|
||||
"""HTTP client for the exo API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
class ExoHttpError(RuntimeError):
|
||||
def __init__(self, status: int, reason: str, body_preview: str):
|
||||
super().__init__(f"HTTP {status} {reason}: {body_preview}")
|
||||
self.status = status
|
||||
|
||||
|
||||
class ExoClient:
|
||||
def __init__(self, host: str, port: int, timeout_s: float = 7200.0):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout_s = timeout_s
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> Any:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if params:
|
||||
path = path + "?" + urlencode(params)
|
||||
|
||||
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
|
||||
try:
|
||||
payload: bytes | None = None
|
||||
hdrs: dict[str, str] = {"Accept": "application/json"}
|
||||
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
if headers:
|
||||
hdrs.update(headers)
|
||||
|
||||
conn.request(method.upper(), path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read()
|
||||
text = raw.decode("utf-8", errors="replace") if raw else ""
|
||||
|
||||
if resp.status >= 400:
|
||||
raise ExoHttpError(resp.status, resp.reason, text[:300])
|
||||
|
||||
if not text:
|
||||
return None
|
||||
return json.loads(text)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def post_bench_chat_completions(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.request_json("POST", "/bench/chat/completions", body=payload)
|
||||
|
||||
def stream_bench_chat_completions(self, payload: dict[str, Any]) -> Iterator[str]:
|
||||
"""POST /bench/chat/completions with stream=True, yielding raw SSE lines."""
|
||||
payload = {**payload, "stream": True}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
|
||||
try:
|
||||
conn.request(
|
||||
"POST",
|
||||
"/bench/chat/completions",
|
||||
body=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
)
|
||||
resp = conn.getresponse()
|
||||
if resp.status >= 400:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
raise ExoHttpError(resp.status, resp.reason, raw[:300])
|
||||
for line in resp:
|
||||
yield line.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_state_path(self, path: str) -> Any:
|
||||
try:
|
||||
return self.request_json("GET", f"/state/{path}")
|
||||
except ExoHttpError as e:
|
||||
if e.status == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def get_instance(self, instance_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"instances/{instance_id}")
|
||||
|
||||
def get_runner(self, runner_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"runners/{runner_id}")
|
||||
|
||||
def get_node_downloads(self, node_id: str) -> list[dict[str, Any]] | None:
|
||||
return self.get_state_path(f"downloads/{node_id}")
|
||||
|
||||
def get_node_disk(self, node_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"nodeDisk/{node_id}")
|
||||
|
||||
def get_node_system(self, node_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"nodeSystem/{node_id}")
|
||||
|
||||
def get_node_identities(self) -> dict[str, Any] | None:
|
||||
return self.get_state_path("nodeIdentities")
|
||||
|
||||
def get_topology(self) -> dict[str, Any] | None:
|
||||
return self.get_state_path("topology")
|
||||
@@ -0,0 +1,262 @@
|
||||
# type: ignore
|
||||
"""Cluster lifecycle management via eco.
|
||||
|
||||
Provides subprocess wrappers for eco commands (deploy, stop, start, release,
|
||||
logs, exec) and a ClusterInfo dataclass. Reusable by integration tests,
|
||||
bench, eval, and CI workflows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from .client import ExoClient
|
||||
|
||||
|
||||
class Thunderbolt(str, Enum):
|
||||
A2A = "a2a" # all-to-all (eco --tb-a2a)
|
||||
RING = "ring" # ring topology (eco --tb-ring)
|
||||
NONE = "none" # exclude Thunderbolt-connected hosts (eco --no-thunderbolt)
|
||||
|
||||
|
||||
class Chip(str, Enum):
|
||||
M1 = "M1"
|
||||
M1_PRO = "M1 Pro"
|
||||
M1_MAX = "M1 Max"
|
||||
M1_ULTRA = "M1 Ultra"
|
||||
M2 = "M2"
|
||||
M2_PRO = "M2 Pro"
|
||||
M2_MAX = "M2 Max"
|
||||
M2_ULTRA = "M2 Ultra"
|
||||
M3 = "M3"
|
||||
M3_PRO = "M3 Pro"
|
||||
M3_MAX = "M3 Max"
|
||||
M3_ULTRA = "M3 Ultra"
|
||||
M4 = "M4"
|
||||
M4_PRO = "M4 Pro"
|
||||
M4_MAX = "M4 Max"
|
||||
M4_ULTRA = "M4 Ultra"
|
||||
|
||||
|
||||
logger = logging.getLogger("exo_tools.cluster")
|
||||
|
||||
# When set, deploy from a GitHub branch/tag instead of local source (rsync).
|
||||
_EXO_REF = os.environ.get("EXO_REF")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClusterInfo:
|
||||
"""Holds the result of an `eco start --deploy` invocation."""
|
||||
|
||||
hosts: list[str]
|
||||
namespace: str
|
||||
api_endpoints: dict[str, str] # host -> url
|
||||
api_url: str # primary endpoint for ExoClient
|
||||
|
||||
primary_host: str = ""
|
||||
_host: str = field(init=False, repr=False, default="")
|
||||
_port: int = field(init=False, repr=False, default=52415)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.primary_host:
|
||||
self.primary_host = self.hosts[0]
|
||||
url = self.api_url.replace("http://", "").replace("https://", "")
|
||||
parts = url.split(":")
|
||||
self._host = parts[0]
|
||||
self._port = int(parts[1]) if len(parts) > 1 else 52415
|
||||
|
||||
def make_client(self, timeout_s: float = 7200.0) -> ExoClient:
|
||||
return ExoClient(self._host, self._port, timeout_s=timeout_s)
|
||||
|
||||
|
||||
class EcoSession:
|
||||
"""Manages an eco session with a unique user and automatic cleanup.
|
||||
|
||||
Usage:
|
||||
session = EcoSession(user_prefix="test")
|
||||
cluster = session.start_deploy(count=2, thunderbolt=True)
|
||||
...
|
||||
session.stop_all() # or let atexit handle it
|
||||
|
||||
The session registers atexit and signal handlers to ensure cleanup
|
||||
on normal exit, uncaught exceptions, SIGTERM, and SIGHUP. SIGINT
|
||||
is left unhandled so KeyboardInterrupt propagates normally.
|
||||
"""
|
||||
|
||||
def __init__(self, user_prefix: str = "test") -> None:
|
||||
self._session_id = uuid.uuid4().hex[:8]
|
||||
self.user = f"{user_prefix}-{self._session_id}"
|
||||
self._env = {**os.environ, "USER": self.user}
|
||||
|
||||
# Register cleanup handlers
|
||||
atexit.register(self.stop_all)
|
||||
for sig in (signal.SIGTERM, signal.SIGHUP):
|
||||
signal.signal(sig, self._signal_handler)
|
||||
|
||||
def _signal_handler(self, signum: int, _frame: object) -> None:
|
||||
self.stop_all()
|
||||
raise SystemExit(128 + signum)
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Stop all clusters and release all reservations for this session."""
|
||||
with contextlib.suppress(Exception):
|
||||
subprocess.run(
|
||||
["eco", "stop"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=self._env,
|
||||
)
|
||||
|
||||
def _run(
|
||||
self, args: list[str], *, check: bool = True, timeout: int = 120
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run an eco command as this session's user.
|
||||
|
||||
stdout is captured (JSON output), stderr is passed through to the
|
||||
console so eco's progress messages are visible.
|
||||
"""
|
||||
logger.info(f"eco: {' '.join(args)}")
|
||||
return subprocess.run(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=None,
|
||||
text=True,
|
||||
check=check,
|
||||
timeout=timeout,
|
||||
env=self._env,
|
||||
)
|
||||
|
||||
def start_deploy(
|
||||
self,
|
||||
hosts: list[str] | None = None,
|
||||
*,
|
||||
count: int | None = None,
|
||||
thunderbolt: Thunderbolt | None = None,
|
||||
chip: Chip | None = None,
|
||||
min_memory_gb: float | None = None,
|
||||
max_memory_gb: float | None = None,
|
||||
min_disk_gb: float | None = None,
|
||||
max_disk_gb: float | None = None,
|
||||
wait: bool = True,
|
||||
ref: str | None = _EXO_REF,
|
||||
timeout: int = 600,
|
||||
) -> ClusterInfo:
|
||||
"""Start and deploy exo on a set of hosts via eco.
|
||||
|
||||
By default, deploys from local source via rsync. Set EXO_REF
|
||||
or pass ref= to deploy from a GitHub branch/tag instead (for CI).
|
||||
|
||||
Selection constraints (memory/disk in GiB, chip substring,
|
||||
Thunderbolt topology) are forwarded as eco CLI flags. Pass
|
||||
``thunderbolt=Thunderbolt.NONE`` to exclude TB-connected hosts.
|
||||
"""
|
||||
cmd: list[str] = ["eco", "--json", "start", "--deploy"]
|
||||
if hosts:
|
||||
cmd.extend(hosts)
|
||||
if count is not None:
|
||||
cmd.extend(["--count", str(count)])
|
||||
if thunderbolt is Thunderbolt.NONE:
|
||||
cmd.append("--no-thunderbolt")
|
||||
elif thunderbolt is not None:
|
||||
cmd.append(f"--tb-{thunderbolt.value}")
|
||||
if chip is not None:
|
||||
cmd.extend(["--chip", chip.value])
|
||||
# eco's GB args are integer-typed. Round mins up + maxes down so
|
||||
# we never relax the user's constraint.
|
||||
if min_memory_gb is not None:
|
||||
cmd.extend(["--min-memory", str(math.ceil(min_memory_gb))])
|
||||
if max_memory_gb is not None:
|
||||
cmd.extend(["--max-memory", str(math.floor(max_memory_gb))])
|
||||
if min_disk_gb is not None:
|
||||
cmd.extend(["--min-disk", str(math.ceil(min_disk_gb))])
|
||||
if max_disk_gb is not None:
|
||||
cmd.extend(["--max-disk", str(math.floor(max_disk_gb))])
|
||||
if wait:
|
||||
cmd.append("--wait")
|
||||
if ref:
|
||||
cmd.extend(["--ref", ref])
|
||||
|
||||
result = self._run(cmd, timeout=timeout)
|
||||
data = json.loads(result.stdout)["data"]
|
||||
endpoints: dict[str, str] = data["api_endpoints"]
|
||||
primary_host = data["hosts"][0]
|
||||
|
||||
return ClusterInfo(
|
||||
hosts=data["hosts"],
|
||||
namespace=data["namespace"],
|
||||
api_endpoints=endpoints,
|
||||
api_url=endpoints[primary_host],
|
||||
primary_host=primary_host,
|
||||
)
|
||||
|
||||
def stop(self, hosts: list[str], *, keep: bool = False, timeout: int = 120) -> None:
|
||||
"""Stop exo on the given hosts. If keep=True, keep the reservation."""
|
||||
cmd: list[str] = ["eco", "stop"]
|
||||
cmd.extend(hosts)
|
||||
if keep:
|
||||
cmd.append("--keep")
|
||||
self._run(cmd, timeout=timeout)
|
||||
|
||||
def start_hosts(
|
||||
self, hosts: list[str], *, namespace: str, timeout: int = 300
|
||||
) -> None:
|
||||
"""Start (previously stopped) hosts back into an existing namespace."""
|
||||
cmd: list[str] = ["eco", "--json", "start"]
|
||||
cmd.extend(hosts)
|
||||
cmd.extend(["--namespace", namespace])
|
||||
self._run(cmd, timeout=timeout)
|
||||
|
||||
def release(self, hosts: list[str], timeout: int = 120) -> None:
|
||||
"""Release hosts from the reservation."""
|
||||
cmd: list[str] = ["eco", "release"]
|
||||
cmd.extend(hosts)
|
||||
self._run(cmd, timeout=timeout)
|
||||
|
||||
def logs(
|
||||
self, hosts: list[str], lines: int = 500, timeout: int = 60
|
||||
) -> dict[str, list[str]]:
|
||||
"""Fetch recent logs from cluster hosts."""
|
||||
cmd: list[str] = ["eco", "--json", "logs"]
|
||||
cmd.extend(hosts)
|
||||
cmd.extend(["-n", str(lines), "--raw"])
|
||||
result = self._run(cmd, check=False, timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
return {"_error": [result.stderr]}
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"_raw": result.stdout.splitlines()}
|
||||
|
||||
def exec(self, hosts: list[str], command: str, timeout: int = 120) -> str:
|
||||
"""Run an arbitrary command on the given hosts via eco."""
|
||||
cmd: list[str] = ["eco", "exec"]
|
||||
cmd.extend(hosts)
|
||||
cmd.append("--")
|
||||
cmd.extend(command.split())
|
||||
result = self._run(cmd, check=False, timeout=timeout)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def make_client(cluster: ClusterInfo, timeout_s: float = 7200.0) -> ExoClient:
|
||||
"""Create an ExoClient from a ClusterInfo."""
|
||||
return cluster.make_client(timeout_s=timeout_s)
|
||||
|
||||
|
||||
def make_client_from_url(url: str, timeout_s: float = 7200.0) -> ExoClient:
|
||||
"""Create an ExoClient from a URL string like 'http://host:port'."""
|
||||
url_clean = url.replace("http://", "").replace("https://", "")
|
||||
parts = url_clean.split(":")
|
||||
host = parts[0]
|
||||
port = int(parts[1]) if len(parts) > 1 else 52415
|
||||
return ExoClient(host, port, timeout_s=timeout_s)
|
||||
@@ -1,129 +1,39 @@
|
||||
# type: ignore
|
||||
"""Instance lifecycle helpers for exo clusters.
|
||||
|
||||
Provides utilities for placing instances, waiting for readiness,
|
||||
managing downloads, filtering placements, and common CLI arguments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import contextlib
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .client import ExoClient, ExoHttpError
|
||||
|
||||
|
||||
class Sharding(str, Enum):
|
||||
PIPELINE = "Pipeline" # layers split across nodes
|
||||
TENSOR = "Tensor" # layers split within (across nodes)
|
||||
|
||||
|
||||
class Comm(str, Enum):
|
||||
RING = "MlxRing" # ring all-reduce over network
|
||||
JACCL = "MlxJaccl" # RDMA over Thunderbolt
|
||||
|
||||
|
||||
_SETTLE_INITIAL_BACKOFF_S = 1.0
|
||||
_SETTLE_MAX_BACKOFF_S = 60.0
|
||||
_SETTLE_BACKOFF_MULTIPLIER = 2.0
|
||||
|
||||
|
||||
class ExoHttpError(RuntimeError):
|
||||
def __init__(self, status: int, reason: str, body_preview: str):
|
||||
super().__init__(f"HTTP {status} {reason}: {body_preview}")
|
||||
self.status = status
|
||||
|
||||
|
||||
class ExoClient:
|
||||
def __init__(self, host: str, port: int, timeout_s: float = 7200.0):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout_s = timeout_s
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> Any:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if params:
|
||||
path = path + "?" + urlencode(params)
|
||||
|
||||
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
|
||||
try:
|
||||
payload: bytes | None = None
|
||||
hdrs: dict[str, str] = {"Accept": "application/json"}
|
||||
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
if headers:
|
||||
hdrs.update(headers)
|
||||
|
||||
conn.request(method.upper(), path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read()
|
||||
text = raw.decode("utf-8", errors="replace") if raw else ""
|
||||
|
||||
if resp.status >= 400:
|
||||
raise ExoHttpError(resp.status, resp.reason, text[:300])
|
||||
|
||||
if not text:
|
||||
return None
|
||||
return json.loads(text)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def post_bench_chat_completions(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self.request_json("POST", "/bench/chat/completions", body=payload)
|
||||
|
||||
def stream_bench_chat_completions(self, payload: dict[str, Any]) -> Iterator[str]:
|
||||
"""POST /bench/chat/completions with stream=True, yielding raw SSE lines."""
|
||||
payload = {**payload, "stream": True}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
|
||||
try:
|
||||
conn.request(
|
||||
"POST",
|
||||
"/bench/chat/completions",
|
||||
body=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
)
|
||||
resp = conn.getresponse()
|
||||
if resp.status >= 400:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
raise ExoHttpError(resp.status, resp.reason, raw[:300])
|
||||
for line in resp:
|
||||
yield line.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_state_path(self, path: str) -> Any:
|
||||
try:
|
||||
return self.request_json("GET", f"/state/{path}")
|
||||
except ExoHttpError as e:
|
||||
if e.status == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def get_instance(self, instance_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"instances/{instance_id}")
|
||||
|
||||
def get_runner(self, runner_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"runners/{runner_id}")
|
||||
|
||||
def get_node_downloads(self, node_id: str) -> list[dict[str, Any]] | None:
|
||||
return self.get_state_path(f"downloads/{node_id}")
|
||||
|
||||
def get_node_disk(self, node_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"nodeDisk/{node_id}")
|
||||
|
||||
def get_node_system(self, node_id: str) -> dict[str, Any] | None:
|
||||
return self.get_state_path(f"nodeSystem/{node_id}")
|
||||
|
||||
def get_node_identities(self) -> dict[str, Any] | None:
|
||||
return self.get_state_path("nodeIdentities")
|
||||
|
||||
def get_topology(self) -> dict[str, Any] | None:
|
||||
return self.get_state_path("topology")
|
||||
|
||||
|
||||
def unwrap_instance(instance: dict[str, Any]) -> dict[str, Any]:
|
||||
if len(instance) != 1:
|
||||
raise KeyError(f"Expected 1 key, got keys={list(instance.keys())}")
|
||||
@@ -555,7 +465,6 @@ def find_existing_instance(client: ExoClient, model_id: str) -> str | None:
|
||||
except Exception:
|
||||
return None
|
||||
for inst_id, inst in state.get("instances", {}).items():
|
||||
# Instance structure is nested: {"MlxJacclInstance": {"shardAssignments": {"modelId": ...}}}
|
||||
for _inst_type, inner in inst.items():
|
||||
if not isinstance(inner, dict):
|
||||
continue
|
||||
@@ -623,3 +532,112 @@ def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
|
||||
action="store_true",
|
||||
help="Reuse an existing running instance for this model instead of creating a new one.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster/instance orchestration helpers (used by tests, bench, eval)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_instance_ids(client: ExoClient) -> set[str]:
|
||||
"""Return the set of current instance IDs from cluster state."""
|
||||
state = client.request_json("GET", "/state") or {}
|
||||
result: set[str] = set()
|
||||
for instance in state.get("instances", {}).values():
|
||||
with contextlib.suppress(Exception):
|
||||
result.add(instance_id_from_instance(instance))
|
||||
return result
|
||||
|
||||
|
||||
def wait_for_cluster_ready(
|
||||
client: ExoClient, expected_nodes: int = 1, timeout: float = 120.0
|
||||
) -> None:
|
||||
"""Wait until the cluster has all expected nodes visible and reporting memory.
|
||||
|
||||
Placement requires nodeMemory for all nodes in a cycle. This polls until
|
||||
both nodeIdentities and nodeMemory have at least `expected_nodes` entries.
|
||||
"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
state = client.request_json("GET", "/state") or {}
|
||||
if (
|
||||
len(state.get("nodeIdentities", {})) >= expected_nodes
|
||||
and len(state.get("nodeMemory", {})) >= expected_nodes
|
||||
):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
raise TimeoutError(f"Cluster not ready: expected {expected_nodes} nodes")
|
||||
|
||||
|
||||
def place_instance(
|
||||
client: ExoClient,
|
||||
model_id: str,
|
||||
*,
|
||||
sharding: Sharding = Sharding.PIPELINE,
|
||||
comm: Comm = Comm.RING,
|
||||
min_nodes: int = 1,
|
||||
timeout: float = 600.0,
|
||||
placement_retries: int = 10,
|
||||
placement_retry_delay: float = 10.0,
|
||||
) -> str:
|
||||
"""Place an instance and wait for it to be ready. Returns the instance_id.
|
||||
|
||||
The /place_instance API returns a command_id, but instances are stored
|
||||
under a separately-generated instance_id. This polls cluster state for the
|
||||
new instance, retrying placement if the cluster is still settling.
|
||||
"""
|
||||
wait_for_cluster_ready(client, expected_nodes=min_nodes)
|
||||
|
||||
body = {
|
||||
"model_id": model_id,
|
||||
"sharding": sharding.value,
|
||||
"instance_meta": comm.value,
|
||||
"min_nodes": min_nodes,
|
||||
}
|
||||
|
||||
instance_id: str | None = None
|
||||
for attempt in range(placement_retries):
|
||||
before_ids = get_instance_ids(client)
|
||||
client.request_json("POST", "/place_instance", body=body)
|
||||
|
||||
poll_deadline = time.time() + 30.0
|
||||
while time.time() < poll_deadline:
|
||||
new_ids = get_instance_ids(client) - before_ids
|
||||
if new_ids:
|
||||
instance_id = next(iter(new_ids))
|
||||
break
|
||||
time.sleep(1.0)
|
||||
|
||||
if instance_id is not None:
|
||||
break
|
||||
|
||||
if attempt < placement_retries - 1:
|
||||
time.sleep(placement_retry_delay)
|
||||
|
||||
if instance_id is None:
|
||||
raise TimeoutError(
|
||||
f"Placement failed after {placement_retries} attempts "
|
||||
f"({sharding.value}/{comm.value} for {model_id})"
|
||||
)
|
||||
|
||||
wait_for_instance_ready(client, instance_id, timeout=timeout)
|
||||
return instance_id
|
||||
|
||||
|
||||
def cleanup_all_instances(client: ExoClient) -> None:
|
||||
"""Remove all running instances from the cluster."""
|
||||
state = client.request_json("GET", "/state") or {}
|
||||
for instance in state.get("instances", {}).values():
|
||||
with contextlib.suppress(Exception):
|
||||
iid = instance_id_from_instance(instance)
|
||||
client.request_json("DELETE", f"/instance/{iid}")
|
||||
wait_for_instance_gone(client, iid, timeout=30.0)
|
||||
|
||||
|
||||
def is_model_downloaded(client: ExoClient, model_id: str) -> bool:
|
||||
response = client.request_json("GET", "/models", params={"status": "downloaded"})
|
||||
data = (response or {}).get("data", [])
|
||||
return all(model.get("id") == model_id for model in data)
|
||||
@@ -23,6 +23,7 @@ members = [
|
||||
"exo",
|
||||
"exo-bench",
|
||||
"exo-pyo3-bindings",
|
||||
"exo-tools",
|
||||
]
|
||||
constraints = [{ name = "transformers", specifier = ">=5.6.2" }]
|
||||
overrides = [
|
||||
@@ -87,8 +88,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -200,9 +199,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -233,9 +229,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
@@ -262,7 +255,7 @@ name = "contourpy"
|
||||
version = "1.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
|
||||
wheels = [
|
||||
@@ -274,9 +267,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" },
|
||||
@@ -285,9 +275,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -439,6 +426,7 @@ cuda13 = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "basedpyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "playwright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "pyinstaller", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
@@ -498,6 +486,7 @@ provides-extras = ["build", "cpu", "cuda12", "cuda13"]
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "basedpyright", specifier = ">=1.29.0" },
|
||||
{ name = "playwright", specifier = ">=1.52.0" },
|
||||
{ name = "pyinstaller", specifier = ">=6.17.0" },
|
||||
{ name = "pytest", specifier = ">=8.4.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
|
||||
@@ -518,6 +507,7 @@ dependencies = [
|
||||
{ name = "lm-eval", extra = ["api", "math"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "math-verify", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
@@ -534,6 +524,7 @@ requires-dist = [
|
||||
{ name = "lm-eval", extras = ["api", "math"], specifier = ">=0.4.0" },
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "math-verify", specifier = ">=0.7.0" },
|
||||
{ name = "matplotlib", specifier = ">=3.8" },
|
||||
{ name = "numpy", specifier = ">=1.24.0" },
|
||||
{ name = "protobuf", specifier = ">=5.29.0" },
|
||||
{ name = "tiktoken", specifier = ">=0.12.0" },
|
||||
@@ -561,6 +552,17 @@ dev = [
|
||||
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exo-tools"
|
||||
version = "0.1.0"
|
||||
source = { editable = "tools" }
|
||||
dependencies = [
|
||||
{ name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "loguru", specifier = ">=0.7.3" }]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.128.0"
|
||||
@@ -609,8 +611,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
|
||||
]
|
||||
|
||||
@@ -633,9 +633,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
|
||||
@@ -649,9 +646,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
@@ -669,6 +663,22 @@ http = [
|
||||
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -719,8 +729,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/1a/9c748befbe3decf7cb415e34f8a0c3789a0a9c55910dea73d581e48c0ce5/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc7fff1345980d6c0ebb92c811d24afa4b98b3e07ed070c8e38cc91fd80478c5", size = 3390096, upload-time = "2025-01-07T10:04:59.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/85/4c03da147b6b4b7cb12e074d3d44eee28604a387ed0eaf7eaaead5069c57/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1a6bd16c667ebe89a069ca163060127a794fa3a3525292c900b8c8cc47985b0d", size = 3664743, upload-time = "2025-01-07T10:05:05.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/6e/e597b04f753f1b09e6893075d53a82a30c13855cbaa791402695b01e369f/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d2fde99d502093ade3ab1b53f80da18480e9902aa960dab7f74fb1b9e5bc5746", size = 3695243, upload-time = "2025-01-07T10:05:11.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/89/d4e234727a26b2546c8fb70a276cd924260d60135f2165bf8b9ed67bb9a4/hf_transfer-0.1.9-cp38-abi3-win32.whl", hash = "sha256:435cc3cdc8524ce57b074032b8fd76eed70a4224d2091232fa6a8cef8fd6803e", size = 1086605, upload-time = "2025-01-07T10:05:18.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/14/f1e15b851d1c2af5b0b1a82bf8eb10bda2da62d98180220ba6fd8879bb5b/hf_transfer-0.1.9-cp38-abi3-win_amd64.whl", hash = "sha256:16f208fc678911c37e11aa7b586bc66a37d02e636208f18b6bc53d29b5df40ad", size = 1160240, upload-time = "2025-01-07T10:05:14.324Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -735,16 +743,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c1/a0a44d1f98934f7bdf17f7a915b934f9fca44bb826628c553589900f6df8/hf_xet-1.4.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:769431385e746c92dc05492dde6f687d304584b89c33d79def8367ace06cb555", size = 3988266, upload-time = "2026-03-13T06:58:22.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/82/be713b439060e7d1f1d93543c8053d4ef2fe7e6922c5b31642eaa26f3c4b/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c9dd1c1bc4cc56168f81939b0e05b4c36dd2d28c13dc1364b17af89aa0082496", size = 4188513, upload-time = "2026-03-13T06:58:40.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/a6/cbd4188b22abd80ebd0edbb2b3e87f2633e958983519980815fb8314eae5/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fca58a2ae4e6f6755cc971ac6fcdf777ea9284d7e540e350bb000813b9a3008d", size = 4428287, upload-time = "2026-03-13T06:58:42.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/4e/84e45b25e2e3e903ed3db68d7eafa96dae9a1d1f6d0e7fc85120347a852f/hf_xet-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:163aab46854ccae0ab6a786f8edecbbfbaa38fcaa0184db6feceebf7000c93c0", size = 3665574, upload-time = "2026-03-13T06:58:53.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/71/c5ac2b9a7ae39c14e91973035286e73911c31980fe44e7b1d03730c00adc/hf_xet-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:09b138422ecbe50fd0c84d4da5ff537d27d487d3607183cd10e3e53f05188e82", size = 3528760, upload-time = "2026-03-13T06:58:52.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847, upload-time = "2026-03-13T06:58:25.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843, upload-time = "2026-03-13T06:58:44.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751, upload-time = "2026-03-13T06:58:46.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149, upload-time = "2026-03-13T06:58:57.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -969,8 +973,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/a3/cdc5fef9b8110d60e9185104067ef8a6b7c56b9315475cb73e5c10953633/kiwisolver-1.4.10rc0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3dde1fe2838d9ef93f0c66a564c9b369652127190b8da1e6378075d7a0176281", size = 2321418, upload-time = "2025-08-10T20:21:14.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/b8/12c5187d08c79c053ba9bb0622720322991edfd3fd14e9ef3d2a2cfd4036/kiwisolver-1.4.10rc0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:319c1c56b4497fe729c5c9c2a319957b8bf70b5bd036f478c20b8dccb906f8ad", size = 2488384, upload-time = "2025-08-10T20:21:16.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/3e/4f6800de4b1ca9c0f011ffd46f4871cbf3b10b2d02a38a4c37c1445fe88e/kiwisolver-1.4.10rc0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:244946ee11b873e9ae4f01d8bc8cfe44d6c7369421e1980b3220b27e5dccae79", size = 2292042, upload-time = "2025-08-10T20:21:17.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/16/fb202e13497ff1a9f62bbfb5362e49b7895718abdd33ebbeb2f7dc4373bd/kiwisolver-1.4.10rc0-cp313-cp313-win_amd64.whl", hash = "sha256:08362526667a90be7cca47bb67f8d4a17f43a835f31d06dbb6fadc097624d443", size = 73946, upload-time = "2025-08-10T20:21:19.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/31/f2f8296942535dbd8a7c36c7532c135a0bbe34b1eacbafdc58695bcb2621/kiwisolver-1.4.10rc0-cp313-cp313-win_arm64.whl", hash = "sha256:de14f1d8093397cfac557fb020db25c4082c2ae488d6127fbc9273b7ae9af3fd", size = 65078, upload-time = "2025-08-10T20:21:20.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/f2/2b3ec9b63e57f948a0bf1867e7e5b6a1aca12623335a6a7bdbccd72fa49d/kiwisolver-1.4.10rc0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f0ec8b92ac6bee771883865afd9a8725fef2ad420f77b88c91313ff1d417b5f7", size = 126584, upload-time = "2025-08-10T20:21:21.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e3/c6647c859796dfb6b60b5c2b6216877831adec5558e21bc9bd061d8b2e08/kiwisolver-1.4.10rc0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0315b7f45a244696093b53308d2546879341b3e85d4bf4a66e21d35e076aa7eb", size = 67962, upload-time = "2025-08-10T20:21:22.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/8a/85ef96d5f220887b60fee183a4ac977fab7189404b625382c6aeae297eb6/kiwisolver-1.4.10rc0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:65ff3f2320ced57b1d020a9c31ccdfa9eb8b58e2b40be1e47feafc8785c16a1a", size = 66478, upload-time = "2025-08-10T20:21:23.471Z" },
|
||||
@@ -982,7 +984,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/bf/b91302b110eb3adabaa429d9597bb98dba4e43c39570a75c59460883ece5/kiwisolver-1.4.10rc0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59bb9e7089552273187c8e7b7af62543d3198684231f26d5da60b7bc31a73395", size = 2420031, upload-time = "2025-08-10T20:21:32.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/3a/8bc22b09b485775a4fda94a37fd1d6d0c8db2640481a2941277ce0c0fd81/kiwisolver-1.4.10rc0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:dcdbe9d777d2a55749db7ff810ba58f530c06f52e612e4e407fc19457709b148", size = 2594729, upload-time = "2025-08-10T20:21:33.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/12/597a6c2f00a09ca83e7c0a567b756ac6ad7896428ea4677128cf9ee7e9b2/kiwisolver-1.4.10rc0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9b485e2e377a594dbcf131e8c90f2561d10b4e654025c0760a8bbd2e23427748", size = 2391799, upload-time = "2025-08-10T20:21:36.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/67/bcf5fe263a8da1ad3ce39830c3e9342fe9041f1806d1ac8493600e29fed1/kiwisolver-1.4.10rc0-cp313-cp313t-win_arm64.whl", hash = "sha256:6fac44a17ac78b8952a07f8261f25cc35f7b4d1278c835332576ec7bf9429ce4", size = 68698, upload-time = "2025-08-10T20:21:37.415Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1073,9 +1074,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1116,9 +1114,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
@@ -1127,9 +1122,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1154,15 +1146,15 @@ name = "matplotlib"
|
||||
version = "3.10.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "contourpy", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "cycler", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "fonttools", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "kiwisolver", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "numpy", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "packaging", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "pillow", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "pyparsing", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "contourpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "cycler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "kiwisolver", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "pyparsing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" }
|
||||
wheels = [
|
||||
@@ -1171,15 +1163,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1377,8 +1365,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/83/36557b04cfdc317ed8a525c4993b23e43a8fbcddaddd78619112ca07138c/msgspec-0.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fac7e9c92eddcd24c19d9e5f6249760941485dff97802461ae7c995a2450111", size = 224917, upload-time = "2025-11-24T03:55:48.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/56/362037a1ed5be0b88aced59272442c4b40065c659700f4b195a7f4d0ac88/msgspec-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f953a66f2a3eb8d5ea64768445e2bb301d97609db052628c3e1bcb7d87192a9f", size = 222821, upload-time = "2025-11-24T03:55:49.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/75/fa2370ec341cedf663731ab7042e177b3742645c5dd4f64dc96bd9f18a6b/msgspec-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:247af0313ae64a066d3aea7ba98840f6681ccbf5c90ba9c7d17f3e39dbba679c", size = 227227, upload-time = "2025-11-24T03:55:51.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/25/5e8080fe0117f799b1b68008dc29a65862077296b92550632de015128579/msgspec-0.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:67d5e4dfad52832017018d30a462604c80561aa62a9d548fc2bd4e430b66a352", size = 189966, upload-time = "2025-11-24T03:55:52.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b6/63363422153937d40e1cb349c5081338401f8529a5a4e216865decd981bf/msgspec-0.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:91a52578226708b63a9a13de287b1ec3ed1123e4a088b198143860c087770458", size = 175378, upload-time = "2025-11-24T03:55:53.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1402,9 +1388,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" },
|
||||
@@ -1420,9 +1403,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
|
||||
]
|
||||
|
||||
@@ -1479,9 +1459,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1511,8 +1488,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/0c/31f3d8c327df06df26393fdbe4082398e768429132f2690c57290da7d7ca/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:ce9410db0cd11b9ce5e56774f58b9d4ca6f06a6a6237801a1d70a6a2b4d57ae9", size = 61289023, upload-time = "2025-11-24T22:55:56.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e6/7b1680085d0fc863ab3d0c8fe43c71ea2999140b083130b506c69d4e5351/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:30d9a0bb559006689c10561dbcc7748cd7e73d51d2d2318cfffc46ba08c2c539", size = 62740952, upload-time = "2025-11-24T22:56:00.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3a/865f45bca0f6daf6a6150e20ae4e1ef1757574967b5c1a55705eb1a3aa51/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8c30fe61adfcf89002002438fe810ebd660a856417540578aeb6eb4b9ef88c74", size = 63431735, upload-time = "2025-11-24T22:56:07.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/67/edcaf9408b7da9cf1cf28bbb51e19c26abd98b02f5df073e29d12b2bc17c/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-win_amd64.whl", hash = "sha256:5f26d20e030c5604ab175b7942c5f6bcad4a162dde0176da897e03c0b78555b5", size = 41845476, upload-time = "2025-11-24T22:56:11.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/f662bf1eb15168642ccfe23f2208a6cbb1bebc2c92b8fecb3ac31c860210/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-win_arm64.whl", hash = "sha256:843a502d7ddd394be67411bfb5816eb6325b915606ac473f659c9b96c5101bc9", size = 39441608, upload-time = "2025-11-24T22:56:15.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1529,9 +1504,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" },
|
||||
@@ -1539,9 +1511,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1551,7 +1520,6 @@ source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f5/f50bc3f5c2bb57ab8f5b4d78bc1146b57810d42cb8fcb28cbe2e14050376/nvidia_cublas-13.1.0.3-py3-none-win_amd64.whl", hash = "sha256:2a3b94a37def342471c59fad7856caee4926809a72dd5270155d6a31b5b277be", size = 404355960, upload-time = "2025-10-09T09:07:00.987Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1564,7 +1532,6 @@ dependencies = [
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a2/c96163a0fff1839c0c9548bbdeae7b853b867009e33b9b9264adc238b1cf/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:5572131a59c3eebeeb1c4c8144f772d49372c20124916e072a0e3fc30df421d5", size = 575012079, upload-time = "2026-04-08T18:51:47.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/c0/0a517bfe63ccd3b92eb254d264e28fca3c7cab75d07daea315250fb1bf73/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:e4f53a8ca8c5d6e8c492d0d0a3d565ecb59a751b19cfdaa4f6da0ab2104c1702", size = 581240110, upload-time = "2026-04-08T18:52:31.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/e2/fc9a0e985249d873150276d5afb02e39a66817fedbf1a385724393e505ed/nvidia_cublas_cu12-12.9.2.10-py3-none-win_amd64.whl", hash = "sha256:623f43027d40d44ceadf0043f002bd25cf353e8f13ce90b9a87057019f560661", size = 553162896, upload-time = "2026-04-08T18:53:10.035Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1574,7 +1541,6 @@ source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/af/345fedb9f4c76c84ab4fa445b36bd4048a4d9db60e6bc76b4f913ff4b852/nvidia_cuda_nvrtc-13.0.88-py3-none-win_amd64.whl", hash = "sha256:6bcd4e7f8e205cbe644f5a98f2f799bef9556fefc89dd786e79a16312ce49872", size = 76807835, upload-time = "2025-09-04T08:39:15.274Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1584,7 +1550,6 @@ source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/85/e4af82cc9202023862090bfca4ea827d533329e925c758f0cde964cb54b7/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:210cf05005a447e29214e9ce50851e83fc5f4358df8b453155d5e1918094dcb4", size = 89568129, upload-time = "2025-06-05T20:02:41.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/eb/c2295044b8f3b3b08860e2f6a912b702fc92568a167259df5dddb78f325e/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:096d4de6bda726415dfaf3198d4f5c522b8e70139c97feef5cd2ca6d4cd9cead", size = 44528905, upload-time = "2025-06-05T20:02:29.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/de/823919be3b9d0ccbf1f784035423c5f18f4267fb0123558d58b813c6ec86/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:72972ebdcf504d69462d3bcd67e7b81edd25d0fb85a2c46d3ea3517666636349", size = 76408187, upload-time = "2025-06-05T20:12:27.819Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1597,7 +1562,6 @@ dependencies = [
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/e9/aea85c214a5dad046e56131428c22ff40d0359db3a930040698c0c6c8e68/nvidia_cudnn_cu12-9.21.0.82-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:8ba7c5067854d2b8d8dc21a65bbc5a642b31e4dddc8864cf3a093d25a92e874c", size = 759281118, upload-time = "2026-04-14T15:30:44.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/cf/47778414dd633ba93395f9d34c87283916c3163f57000c2aeef1f869c649/nvidia_cudnn_cu12-9.21.0.82-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:286af0a8ee51e3e5eb0e858b0b93a85a5ae6a686f22a0c83c8d7d4dc9151402f", size = 704763924, upload-time = "2026-04-15T16:43:40.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/e9/e91296c0d7b4b565f38de9ccbf757a8c3172b62a485222751d39f673a26c/nvidia_cudnn_cu12-9.21.0.82-py3-none-win_amd64.whl", hash = "sha256:29c69af1d2f8a6778ef6dbc7829416e6a4c8585b7ca2cab5fb1876bf2e393589", size = 686896833, upload-time = "2026-04-14T15:35:00.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1610,7 +1574,6 @@ dependencies = [
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/a2/f020386683ee9ab2c9a9f7f79290d9b0d07f7241de54dc746af2abd188d2/nvidia_cudnn_cu13-9.19.0.56-py3-none-win_amd64.whl", hash = "sha256:40d8c375005bcb01495f8edf375230b203a411a0c05fb6dc92a3781edcb23eac", size = 350547366, upload-time = "2026-02-03T20:50:49.563Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1650,8 +1613,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/10/4327dbf87f75ae813405fd9a9b4a5cde63d506ffed0a096a440a4cabd89c/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:cbaa3bda75ef0d8836e1f8cc84af62f971b1d756d740efc95c38c3e04c0bfde2", size = 2932931, upload-time = "2025-11-05T19:07:01.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/c8/1774eec4f6f360ef57618fb8f52e3d3af245b2491bd0297513aa09eec04b/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:772922a9bd24e133950fad71eb1550836f415a88e8c77870e12d0c3bd688ddc2", size = 2996140, upload-time = "2025-11-05T19:07:03.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/c3/3d1e01e2dba517a91760e4a03e4f20ffc75039a6fe584d0e6f9b5c78fd15/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:007b0476a1f331f8130783f901f1da6f5a7057af1a4891f1b6a31dec364189b5", size = 3205080, upload-time = "2025-11-05T19:07:05.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/63/119de431572d7c70a7bf1037034a9be6ed0a7502a7498ba7302bca5b3242/openai_harmony-0.0.8-cp38-abi3-win32.whl", hash = "sha256:a9b5f893326b28d9e935ade14b4f655f5a840942473bc89b201c25f7a15af9cf", size = 2082457, upload-time = "2025-11-05T19:07:09.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/c83cf5a206c263ee70448a5ae4264682555f4d0b5bed0d2cc6ca1108103d/openai_harmony-0.0.8-cp38-abi3-win_amd64.whl", hash = "sha256:39d44f0d8f466bd56698e7ead708bead3141e27b9b87e3ab7d5a6d0e4a869ee5", size = 2438369, upload-time = "2025-11-05T19:07:08.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1668,8 +1629,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/02/d9b73dbce28712204e85ae4c1e179505e9a771f95b33743a97e170caedde/opencv_python-4.13.0.90-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9911581e37b24169e4842069ff01d6645ea2bc4af7e10a022d9ebe340fd035ec", size = 70460479, upload-time = "2026-01-18T09:01:16.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/1c/87fa71968beb71481ed359e21772061ceff7c9b45a61b3e7daa71e5b0b66/opencv_python-4.13.0.90-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1150b8f1947761b848bbfa9c96ceba8877743ffef157c08a04af6f7717ddd709", size = 46707819, upload-time = "2026-01-18T09:02:48.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/16/915a94e5b537c328fa3e96b769c7d4eed3b67d1be978e0af658a3d3faed8/opencv_python-4.13.0.90-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:d6716f16149b04eea52f953b8ca983d60dd9cd4872c1fd5113f6e2fcebb90e93", size = 72926629, upload-time = "2026-01-18T09:04:29.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/84/9c63c84be013943dd4c5fff36157f1ec0ec894b69a2fc3026fd4e3c9280a/opencv_python-4.13.0.90-cp37-abi3-win32.whl", hash = "sha256:458a00f2ba47a877eca385be3e7bcc45e6d30a4361d107ce73c1800f516dab09", size = 30932151, upload-time = "2026-01-18T09:05:22.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/de/291cbb17f44242ed6bfd3450fc2535d6bd298115c0ccd6f01cd51d4a11d7/opencv_python-4.13.0.90-cp37-abi3-win_amd64.whl", hash = "sha256:526bde4c33a86808a751e2bb57bf4921beb49794621810971926c472897f6433", size = 40211706, upload-time = "2026-01-18T09:06:06.749Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1697,15 +1656,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1743,9 +1699,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||
@@ -1754,9 +1707,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1768,6 +1718,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.58.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
{ name = "pyee", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -1813,9 +1779,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
|
||||
@@ -1828,9 +1791,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
|
||||
]
|
||||
|
||||
@@ -1840,8 +1800,6 @@ version = "5.29.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" },
|
||||
@@ -1858,16 +1816,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1882,14 +1836,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1936,9 +1888,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1971,9 +1932,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/1e/e8e36e1568f6865ac706c6e1f875c1a346ddaa9f9a8f923d66545d2240ed/pyinstaller-6.17.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:2a147b83cdebb07855bd5a663600891550062373a2ca375c58eacead33741a27", size = 737795, upload-time = "2025-11-24T19:42:50.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/15/9dc0f81ccb746c27bfa6ee53164422fe47ee079c7a717d9c4791aba78797/pyinstaller-6.17.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f8cfbbfa6708e54fb936df6dd6eafaf133e84efb0d2fe25b91cfeefa793c4ca4", size = 736891, upload-time = "2025-11-24T19:42:54.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/e6/bed54821c1ebe1275c559661d3e7bfa23c406673b515252dfbf89db56c65/pyinstaller-6.17.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:97f4c1942f7b4cd73f9e38b49cc8f5f8a6fbb44922cb60dd3073a189b77ee1ae", size = 736752, upload-time = "2025-11-24T19:42:58.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/84/897d759198676b910d69d42640b6d25d50b449f2209e18127a974cf59dbe/pyinstaller-6.17.0-py3-none-win32.whl", hash = "sha256:ce0be227a037fd4be672226db709088565484f597d6b230bceec19850fdd4c85", size = 1317851, upload-time = "2025-11-24T19:43:04.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f5/6a122efe024433ecc34aab6f499e0bd2bbe059c639b77b0045aa2421b0bf/pyinstaller-6.17.0-py3-none-win_amd64.whl", hash = "sha256:b019940dbf7a01489d6b26f9fb97db74b504e0a757010f7ad078675befc85a82", size = 1378685, upload-time = "2025-11-24T19:43:10.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/96/14991773c9e599707a53594429ccf372f9ee638df3b7d26b65fd1a7433f0/pyinstaller-6.17.0-py3-none-win_arm64.whl", hash = "sha256:3c92a335e338170df7e615f75279cfeea97ade89e6dd7694943c8c185460f7b7", size = 1320032, upload-time = "2025-11-24T19:43:16.388Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2098,9 +2056,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2134,9 +2089,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" },
|
||||
@@ -2148,9 +2100,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2235,9 +2184,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2258,8 +2204,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/5b/281bb21d091ab4e36cf377088366d55d0875fa2347b3189c580ec62b44c7/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246cc252053f89e36209535b9c58755960197e6ae08d48d3973760141c62ac95", size = 2221186, upload-time = "2025-08-13T01:43:38.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/2d/30a941a21b81e9db50c4c3ef8a64c5ee1c8eea3a90506ca0326ce39d021f/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c10d25e9f0e87d6a273d1ea390b636b4fb3fede2094bf0cb3fe565d696a91b48", size = 2123510, upload-time = "2025-08-13T01:43:40.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ef/c9199e4b6336ee5a9f1979c11b5779c5cf9ab6f8386e0b9a96c8ffba7009/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:48784a673cf8d04f3cd246fa6b53fd1ccc4d83304503463bd561c153517bccc1", size = 2302783, upload-time = "2025-08-13T01:43:42.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/3d/a49ab633e99fca4ccbb9c9f4bd41904186c175ebc25c530435529f71c480/rustworkx-0.17.1-cp39-abi3-win32.whl", hash = "sha256:5dbc567833ff0a8ad4580a4fe4bde92c186d36b4c45fca755fb1792e4fafe9b5", size = 1931541, upload-time = "2025-08-13T01:43:43.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ec/cee878c1879b91ab8dc7d564535d011307839a2fea79d2a650413edf53be/rustworkx-0.17.1-cp39-abi3-win_amd64.whl", hash = "sha256:d0a48fb62adabd549f9f02927c3a159b51bf654c7388a12fc16d45452d5703ea", size = 2055049, upload-time = "2025-08-13T01:43:44.926Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2297,8 +2241,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2317,14 +2259,10 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2344,8 +2282,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
|
||||
@@ -2354,8 +2290,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2369,17 +2303,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2522,14 +2450,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2553,9 +2479,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2595,11 +2518,9 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" },
|
||||
]
|
||||
|
||||
@@ -2774,9 +2695,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
|
||||
@@ -2789,9 +2707,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2818,9 +2733,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
|
||||
@@ -2834,9 +2746,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
|
||||
]
|
||||
|
||||
@@ -2860,7 +2769,4 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
|
||||
]
|
||||
Reference in new issue
Block a user