diff --git a/.gitignore b/.gitignore index 3b59b5a..bb96ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ env*/ *.swp *~ +# Neural allocator training runs (bundled weights live in the package) +training/checkpoints/ + # Testing .coverage .ruff_cache/ diff --git a/pyproject.toml b/pyproject.toml index 88c22d6..87459b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,12 +38,14 @@ Issues = "https://github.com/fpedd/omnimalloc/issues" [project.optional-dependencies] +ml = ["torch>=2.2"] all = [ "deap>=1.4.0", "huggingface-hub>=0.35.0", "matplotlib>=3.10.0", "onnx>=1.19.0", "tqdm>=4.66.0", + "omnimalloc[ml]", ] [dependency-groups] @@ -70,6 +72,16 @@ metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" # setuptools-scm defaults, e.g. 0.3.1.dev3+ga8f8686 between releases. [tool.setuptools_scm] +# CPU-only torch for local development; the NeuralAllocator model is small +# enough that CPU inference (and even training) is entirely sufficient. +[tool.uv.sources] +torch = [{ index = "pytorch-cpu" }] + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + [tool.cibuildwheel] build = "cp310-* cp311-* cp312-* cp313-*" skip = "*-musllinux_*" diff --git a/src/python/omnimalloc/allocators/__init__.py b/src/python/omnimalloc/allocators/__init__.py index 336f32e..18e7f72 100644 --- a/src/python/omnimalloc/allocators/__init__.py +++ b/src/python/omnimalloc/allocators/__init__.py @@ -27,6 +27,7 @@ from .hillclimb import HillClimbAllocator as HillClimbAllocator from .minimalloc import MinimallocAllocator as MinimallocAllocator from .naive import NaiveAllocator as NaiveAllocator +from .neural import NeuralAllocator as NeuralAllocator from .random import RandomAllocator as RandomAllocator from .simulated_annealing import ( SimulatedAnnealingAllocator as SimulatedAnnealingAllocator, diff --git a/src/python/omnimalloc/allocators/neural.py b/src/python/omnimalloc/allocators/neural.py new file mode 100644 index 0000000..eadd55f --- /dev/null +++ b/src/python/omnimalloc/allocators/neural.py @@ -0,0 +1,233 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import time +from pathlib import Path +from typing import Any, Final, cast + +import numpy as np + +from omnimalloc._cpp import FirstFitPlacer +from omnimalloc.common.optional import require_optional +from omnimalloc.primitives import Allocation + +from .base import DEFAULT_MAX_SECONDS, BaseAllocator, require_unique_ids + +try: + import torch + + HAS_TORCH = True +except ImportError: + HAS_TORCH = False + torch = cast("Any", None) + +# Weights shipped with the package, produced by training/train_neural.py +DEFAULT_WEIGHTS: Final[Path] = Path(__file__).with_name("neural_weights.pt") + + +def peak_lower_bound(allocations: tuple[Allocation, ...]) -> int: + """Maximum live load over time: a lower bound on any peak memory.""" + events = sorted( + [(a.start, a.size) for a in allocations] + + [(a.end, -a.size) for a in allocations] + ) + load = peak = 0 + for _, delta in events: + load += delta + peak = max(peak, load) + return peak + + +def classic_orders(allocations: tuple[Allocation, ...]) -> list[list[int]]: + """Index permutations of the classic greedy orderings (identity last).""" + from .greedy_base import ( + order_by_area, + order_by_conflict, + order_by_conflict_size, + order_by_duration, + order_by_size, + order_by_start, + ) + + index = {a.id: i for i, a in enumerate(allocations)} + orderings = ( + order_by_size, + order_by_duration, + order_by_area, + order_by_conflict, + order_by_conflict_size, + order_by_start, + ) + orders = [[index[a.id] for a in ordering(allocations)] for ordering in orderings] + orders.append(list(range(len(allocations)))) + return orders + + +def extract_features( + allocations: tuple[Allocation, ...], + placer: FirstFitPlacer, +) -> np.ndarray: + """Per-allocation feature matrix (n, FEATURE_DIM) for the priority model. + + Scale-invariant: sizes, times, and areas are normalized per instance, and + rank features expose the orderings the classic greedy heuristics use. + Instance-level context (the relative peak of every classic greedy order + and a one-hot of the winner) is broadcast to every allocation so the model + can condition on which heuristic family suits the instance. + """ + n = len(allocations) + overlaps = placer.overlaps + size = np.array([a.size for a in allocations], dtype=np.float64) + start = np.array([a.start for a in allocations], dtype=np.float64) + end = np.array([a.end for a in allocations], dtype=np.float64) + duration = end - start + area = size * duration + + span = max(end.max() - start.min(), 1.0) + t0 = start.min() + + size_by_id = {a.id: float(a.size) for a in allocations} + conflicts = np.array( + [len(overlaps.get(a.id, ())) for a in allocations], dtype=np.float64 + ) + load = size + np.array( + [sum(size_by_id[i] for i in overlaps.get(a.id, ())) for a in allocations] + ) + + def norm(values: np.ndarray) -> np.ndarray: + return values / max(values.max(), 1.0) + + def rank(values: np.ndarray) -> np.ndarray: + order = np.argsort(values, kind="stable") + ranks = np.empty(n, dtype=np.float64) + ranks[order] = np.arange(n, dtype=np.float64) + return ranks / max(n - 1, 1) + + local = np.stack( + [ + norm(size), + np.log1p(size) / max(np.log1p(size.max()), 1.0), + (start - t0) / span, + (end - t0) / span, + duration / span, + norm(area), + conflicts / max(n - 1, 1), + norm(load), + rank(size), + rank(duration), + rank(area), + rank((conflicts + 1.0) * size), + # Lexicographic (start, -size) rank: integer starts spaced by + # n+1 leave room for the [0, 1] size-rank tiebreak. + rank(start * (n + 1.0) - rank(size)), + ], + axis=1, + ) + + lower_bound = max(peak_lower_bound(allocations), 1) + peaks = np.array( + [placer.evaluate(order) for order in classic_orders(allocations)], + dtype=np.float64, + ) + relative = np.clip(peaks / lower_bound - 1.0, 0.0, 2.0) / 2.0 + winner = np.zeros(len(peaks)) + winner[int(np.argmin(peaks))] = 1.0 + instance = np.tile(np.concatenate([relative, winner]), (n, 1)) + + return np.concatenate([local, instance], axis=1).astype(np.float32) + + +class NeuralAllocator(BaseAllocator): + """Learned score-and-sort allocator with a Transformer priority model. + + A permutation-equivariant Transformer scores each allocation and the + allocations are placed first-fit in descending score order (the order + fully determines the placement). Optionally evaluates `num_samples` + Gumbel-perturbed orders from the same policy and keeps the best result, + bounded by `max_seconds`. With `portfolio` the classic greedy orders are + also evaluated as candidates, so results never trail greedy_by_all. + Weights are trained by Plackett-Luce behavior cloning plus self-imitation + (see training/train_neural.py); the default checkpoint ships with the + package. + """ + + def __init__( + self, + weights: str | Path | None = None, + num_samples: int = 1024, + temperature: float = 1.0, + seed: int = 42, + max_seconds: float = DEFAULT_MAX_SECONDS, + portfolio: bool = True, + ) -> None: + if not HAS_TORCH: + require_optional("torch", "NeuralAllocator", "ml") + if num_samples < 0: + raise ValueError(f"num_samples must be non-negative, got {num_samples}") + if temperature <= 0: + raise ValueError(f"temperature must be positive, got {temperature}") + if max_seconds < 0: + raise ValueError(f"max_seconds must be non-negative, got {max_seconds}") + + self._weights = Path(weights) if weights is not None else DEFAULT_WEIGHTS + self._num_samples = num_samples + self._temperature = temperature + self._seed = seed + self._max_seconds = max_seconds + self._portfolio = portfolio + self._model: Any = None + + def _load_model(self) -> Any: # noqa: ANN401 + if self._model is None: + from .neural_model import load_model + + if not self._weights.is_file(): + raise FileNotFoundError( + f"Neural allocator weights not found at {self._weights}. " + f"Train them with training/train_neural.py --install" + ) + self._model = load_model(self._weights) + return self._model + + def allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: + if not allocations: + return allocations + require_unique_ids(allocations) + + model = self._load_model() + placer = FirstFitPlacer(list(allocations)) + features = extract_features(allocations, placer) + + with torch.inference_mode(): + scores = model(torch.from_numpy(features).unsqueeze(0)).squeeze(0) + best_order = torch.argsort(scores, descending=True).tolist() + best_peak = placer.evaluate(best_order) + + if self._portfolio: + for order in classic_orders(allocations): + peak = placer.evaluate(order) + if peak < best_peak: + best_order, best_peak = order, peak + + if self._num_samples: + from .neural_model import sample_orders + + deadline = ( + time.monotonic() + self._max_seconds if self._max_seconds else None + ) + generator = torch.Generator().manual_seed(self._seed) + orders = sample_orders( + scores.unsqueeze(0), + self._num_samples, + generator=generator, + temperature=self._temperature, + ).squeeze(0) + for order in orders.tolist(): + if deadline is not None and time.monotonic() >= deadline: + break + peak = placer.evaluate(order) + if peak < best_peak: + best_order, best_peak = order, peak + + return tuple(placer.place(best_order)) diff --git a/src/python/omnimalloc/allocators/neural_model.py b/src/python/omnimalloc/allocators/neural_model.py new file mode 100644 index 0000000..ed31f77 --- /dev/null +++ b/src/python/omnimalloc/allocators/neural_model.py @@ -0,0 +1,133 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +"""Torch model and Plackett-Luce utilities for the neural allocator. + +This module requires torch; import it lazily (see `neural.py`). +""" + +from pathlib import Path +from typing import Final + +import torch +from torch import Tensor, nn + +# Number of per-allocation input features (see neural.extract_features): +# 13 local (normalized magnitudes and ranks) + 14 instance-level (relative +# peaks of the classic greedy orders and a one-hot of the winner). +FEATURE_DIM: Final[int] = 27 + +# Scores are bounded to +-SCORE_CLIP via tanh so the Plackett-Luce policy +# never becomes deterministic and exploration survives long training runs. +SCORE_CLIP: Final[float] = 10.0 + +# Effectively -inf for masked logits, small enough to avoid NaNs in logcumsumexp +_NEG: Final[float] = -1e9 + + +class PriorityNet(nn.Module): + """Permutation-equivariant encoder emitting one priority score per allocation. + + A Transformer encoder without positional encoding treats the input as a + set; sorting allocations by the emitted scores yields the placement order. + """ + + def __init__( + self, + feature_dim: int = FEATURE_DIM, + dim: int = 64, + heads: int = 4, + layers: int = 3, + ) -> None: + super().__init__() + self.config = { # type: ignore + "feature_dim": feature_dim, + "dim": dim, + "heads": heads, + "layers": layers, + } + self.embed = nn.Linear(feature_dim, dim) + layer = nn.TransformerEncoderLayer( + d_model=dim, + nhead=heads, + dim_feedforward=2 * dim, + dropout=0.0, + batch_first=True, + norm_first=True, + ) + self.encoder = nn.TransformerEncoder( + layer, num_layers=layers, enable_nested_tensor=False + ) + self.head = nn.Sequential( + nn.LayerNorm(dim), nn.Linear(dim, dim), nn.GELU(), nn.Linear(dim, 1) + ) + + def forward(self, features: Tensor, padding_mask: Tensor | None = None) -> Tensor: + """Map (B, N, F) features to (B, N) bounded priority scores.""" + x = self.embed(features) + x = self.encoder(x, src_key_padding_mask=padding_mask) + scores = self.head(x).squeeze(-1) + return SCORE_CLIP * torch.tanh(scores) + + +def sample_orders( + scores: Tensor, + num_samples: int, + generator: torch.Generator | None = None, + valid_mask: Tensor | None = None, + temperature: float = 1.0, +) -> Tensor: + """Sample (B, K, N) placement orders from the Plackett-Luce policy. + + Perturbing logits with Gumbel noise and sorting descending is an exact + Plackett-Luce sample (Gumbel-top-k trick). Padded positions sort last. + """ + logits = scores.detach() / temperature + uniform = torch.rand( + (*logits.shape[:-1], num_samples, logits.shape[-1]), + generator=generator, + device=logits.device, + ) + exponential = -torch.log(uniform.clamp_min(1e-20)) + gumbel = -torch.log(exponential.clamp_min(1e-20)) + perturbed = logits.unsqueeze(-2) + gumbel + if valid_mask is not None: + perturbed = perturbed.masked_fill(~valid_mask.unsqueeze(-2), _NEG) + return perturbed.argsort(dim=-1, descending=True) + + +def order_log_prob( + scores: Tensor, orders: Tensor, valid_mask: Tensor | None = None +) -> Tensor: + """Exact Plackett-Luce log-probability of (B, K, N) orders under (B, N) scores. + + log p(order) = sum_i [s_{o_i} - logsumexp(s_{o_i}, ..., s_{o_N})], computed + with a reversed cumulative logsumexp over the ordered scores. + """ + expanded = scores.unsqueeze(-2).expand(*orders.shape) + ordered = expanded.gather(-1, orders) + if valid_mask is not None: + expanded_valid = valid_mask.unsqueeze(-2).expand(*orders.shape) + ordered_valid = expanded_valid.gather(-1, orders) + ordered = ordered.masked_fill(~ordered_valid, _NEG) + tail_lse = torch.logcumsumexp(ordered.flip(-1), dim=-1).flip(-1) + log_probs = ordered - tail_lse + if valid_mask is not None: + log_probs = log_probs.masked_fill(~ordered_valid, 0.0) + return log_probs.sum(-1) + + +def save_model(model: PriorityNet, path: Path) -> None: + """Save config and weights; float16 halves the shipped checkpoint size.""" + state = {key: value.half() for key, value in model.state_dict().items()} + torch.save({"config": model.config, "state_dict": state}, path) + + +def load_model(path: Path) -> PriorityNet: + checkpoint = torch.load(path, map_location="cpu", weights_only=True) + model = PriorityNet(**checkpoint["config"]) + state = {key: value.float() for key, value in checkpoint["state_dict"].items()} + model.load_state_dict(state) + model.eval() + return model diff --git a/src/python/omnimalloc/allocators/neural_weights.pt b/src/python/omnimalloc/allocators/neural_weights.pt new file mode 100644 index 0000000..b80bf4d Binary files /dev/null and b/src/python/omnimalloc/allocators/neural_weights.pt differ diff --git a/tests/unit/allocators/test_neural.py b/tests/unit/allocators/test_neural.py new file mode 100644 index 0000000..62d6446 --- /dev/null +++ b/tests/unit/allocators/test_neural.py @@ -0,0 +1,196 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import numpy as np +import pytest +from omnimalloc._cpp import FirstFitPlacer +from omnimalloc.allocators.neural import ( + HAS_TORCH, + NeuralAllocator, + extract_features, + peak_lower_bound, +) +from omnimalloc.primitives import Allocation +from omnimalloc.primitives.pool import Pool +from omnimalloc.validate import validate_allocation + +pytestmark = pytest.mark.skipif(not HAS_TORCH, reason="torch not installed") + + +def _allocs() -> tuple[Allocation, ...]: + return ( + Allocation(id=1, size=100, start=0, end=10), + Allocation(id=2, size=50, start=5, end=15), + Allocation(id=3, size=200, start=8, end=20), + Allocation(id=4, size=25, start=12, end=30), + ) + + +def test_peak_lower_bound_empty() -> None: + assert peak_lower_bound(()) == 0 + + +def test_peak_lower_bound_disjoint() -> None: + allocs = ( + Allocation(id=1, size=100, start=0, end=10), + Allocation(id=2, size=50, start=10, end=20), + ) + assert peak_lower_bound(allocs) == 100 + + +def test_peak_lower_bound_overlapping() -> None: + allocs = ( + Allocation(id=1, size=100, start=0, end=10), + Allocation(id=2, size=50, start=5, end=15), + ) + assert peak_lower_bound(allocs) == 150 + + +def test_extract_features_shape_and_range() -> None: + from omnimalloc.allocators.neural_model import FEATURE_DIM + + allocs = _allocs() + placer = FirstFitPlacer(list(allocs)) + features = extract_features(allocs, placer) + assert features.shape == (4, FEATURE_DIM) + assert features.dtype == np.float32 + assert np.all(features >= 0.0) + assert np.all(features <= 1.0) + + +def test_extract_features_single_allocation() -> None: + from omnimalloc.allocators.neural_model import FEATURE_DIM + + allocs = (Allocation(id=1, size=100, start=0, end=10),) + placer = FirstFitPlacer(list(allocs)) + features = extract_features(allocs, placer) + assert features.shape == (1, FEATURE_DIM) + assert np.all(np.isfinite(features)) + + +def test_neural_rejects_negative_samples() -> None: + with pytest.raises(ValueError, match="num_samples must be non-negative"): + NeuralAllocator(num_samples=-1) + + +def test_neural_rejects_bad_temperature() -> None: + with pytest.raises(ValueError, match="temperature must be positive"): + NeuralAllocator(temperature=0.0) + + +def test_neural_rejects_negative_max_seconds() -> None: + with pytest.raises(ValueError, match="max_seconds must be non-negative"): + NeuralAllocator(max_seconds=-1.0) + + +def test_neural_missing_weights() -> None: + allocator = NeuralAllocator(weights="/nonexistent/weights.pt") + with pytest.raises(FileNotFoundError, match="weights not found"): + allocator.allocate(_allocs()) + + +def test_neural_empty() -> None: + allocator = NeuralAllocator() + assert allocator.allocate(()) == () + + +def test_neural_single() -> None: + allocator = NeuralAllocator() + result = allocator.allocate((Allocation(id=1, size=100, start=0, end=10),)) + assert len(result) == 1 + assert result[0].offset == 0 + + +def test_neural_produces_valid_allocation() -> None: + allocator = NeuralAllocator() + result = allocator.allocate(_allocs()) + assert len(result) == 4 + assert {a.id for a in result} == {1, 2, 3, 4} + assert validate_allocation(Pool(id="test_pool", allocations=result)) + + +def test_neural_deterministic() -> None: + allocator = NeuralAllocator() + first = allocator.allocate(_allocs()) + second = allocator.allocate(_allocs()) + assert [a.offset for a in first] == [a.offset for a in second] + + +def test_neural_rejects_duplicate_ids() -> None: + allocator = NeuralAllocator() + allocs = ( + Allocation(id=1, size=100, start=0, end=10), + Allocation(id=1, size=50, start=5, end=15), + ) + with pytest.raises(ValueError, match="allocation ids must be unique"): + allocator.allocate(allocs) + + +def test_neural_greedy_decode_only() -> None: + allocator = NeuralAllocator(num_samples=0) + result = allocator.allocate(_allocs()) + assert validate_allocation(Pool(id="test_pool", allocations=result)) + + +def test_sample_orders_are_distinct_valid_permutations() -> None: + import torch + from omnimalloc.allocators.neural_model import sample_orders + + scores = torch.randn(2, 30) + generator = torch.Generator().manual_seed(0) + orders = sample_orders(scores, 8, generator=generator) + assert orders.shape == (2, 8, 30) + assert all(sorted(orders[0, k].tolist()) == list(range(30)) for k in range(8)) + unique = {tuple(orders[0, k].tolist()) for k in range(8)} + assert len(unique) > 1 + + +def test_sample_orders_temperature_changes_orders() -> None: + import torch + from omnimalloc.allocators.neural_model import sample_orders + + scores = torch.randn(1, 40) + cold = sample_orders( + scores, 4, generator=torch.Generator().manual_seed(0), temperature=0.1 + ) + hot = sample_orders( + scores, 4, generator=torch.Generator().manual_seed(0), temperature=10.0 + ) + assert not torch.equal(cold, hot) + + +def test_order_log_prob_finite_and_uniform_for_equal_scores() -> None: + import math + + import torch + from omnimalloc.allocators.neural_model import order_log_prob, sample_orders + + scores = torch.zeros(1, 20) + orders = sample_orders(scores, 4, generator=torch.Generator().manual_seed(0)) + log_probs = order_log_prob(scores, orders) + assert torch.isfinite(log_probs).all() + expected = -sum(math.log(i) for i in range(1, 21)) + assert torch.allclose(log_probs, torch.full_like(log_probs, expected), atol=1e-3) + + +def test_neural_portfolio_never_trails_greedy_by_all() -> None: + from omnimalloc.allocators.greedy import GreedyByAllAllocator + from omnimalloc.allocators.greedy_base import peak_memory + from omnimalloc.benchmark.sources import HighContentionSource + + allocs = HighContentionSource(num_allocations=40, seed=11).get_allocations() + neural_peak = peak_memory(NeuralAllocator().allocate(allocs)) + greedy_peak = peak_memory(GreedyByAllAllocator(cores=1).allocate(allocs)) + assert neural_peak <= greedy_peak + + +def test_neural_respects_lower_bound_on_larger_problem() -> None: + from omnimalloc.benchmark.sources import RandomSource + + allocs = RandomSource(num_allocations=60, seed=7).get_allocations() + allocator = NeuralAllocator() + result = allocator.allocate(allocs) + assert validate_allocation(Pool(id="test_pool", allocations=result)) + peak = max(a.offset + a.size for a in result if a.offset is not None) + assert peak >= peak_lower_bound(allocs) diff --git a/training/README.md b/training/README.md new file mode 100644 index 0000000..3a4a2cf --- /dev/null +++ b/training/README.md @@ -0,0 +1,48 @@ +# NeuralAllocator training + +The `NeuralAllocator` is a learned score-and-sort policy: a small +permutation-equivariant Transformer (`PriorityNet`, ~100k parameters) scores +every allocation of a problem, and placing the allocations first-fit in +descending score order yields the full solution. The placement order alone +determines the outcome (the insight behind minimalloc's canonical solutions), +so the model only has to learn a good ordering — never raw offsets. + +## Method + +- **Features**: per-allocation size/start/end/duration/area/conflict-degree + and their ranks, normalized per instance (scale-invariant), plus + instance-level context broadcast to every allocation: the relative peak of + each classic greedy order and a one-hot of the winner, so the model can + condition on which heuristic family suits the instance. +- **Policy**: allocation scores define a Plackett-Luce distribution over + permutations; sampling = Gumbel-perturb the scores and sort (Gumbel-top-k). +- **Pretraining**: behavior-cloning of the best classic greedy order per + instance via the exact Plackett-Luce log-likelihood. Near-ties (within 1%) + resolve to a fixed heuristic priority so the targets stay consistent. +- **Self-imitation fine-tuning** (expert iteration): sample `k` orders per + instance from the policy, evaluate their peak memory with the C++ + `FirstFitPlacer`, keep the best order ever seen per instance (the + incumbent, seeded with the greedy order), and imitate the incumbents. + Costs are normalized by the max-live-load lower bound. Plain REINFORCE + with a mean baseline was tried first and stagnated; imitating incumbents + gives a monotone, low-variance signal that exploits the cheap evaluator. +- **Data**: freshly generated problems from the omnimalloc generator sources + (random, uniform, power-of-2, high-contention, sequential, tiling, pinwheel) + with randomized parameters. Minimalloc CSV datasets stay held out for eval. + +At inference the allocator takes the best of the deterministic decode, the +classic greedy orders (portfolio guarantee: never worse than greedy_by_all), +and up to `num_samples` Gumbel-perturbed policy samples within `max_seconds`. + +## Usage + +```bash +# Train and install the best checkpoint into the package +uv run python training/train_neural.py --install + +# Evaluate against the classic allocators on held-out problems +uv run python training/eval_neural.py +``` + +Training runs on CPU in well under an hour with the default settings. +Checkpoints and a CSV metrics log land in `training/checkpoints/`. diff --git a/training/eval_neural.py b/training/eval_neural.py new file mode 100644 index 0000000..01887fd --- /dev/null +++ b/training/eval_neural.py @@ -0,0 +1,124 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +"""Evaluate the NeuralAllocator against the classic allocators. + +Runs every allocator on held-out generator instances (seeds disjoint from +training), the bundled minimalloc CSV datasets, and known-optimum tiling +problems. Reports mean peak/LB, win counts, and wall-clock time. + +Usage: + uv run python training/eval_neural.py [--weights path] [--quick] +""" + +import argparse +import random +import time +from collections import defaultdict +from pathlib import Path + +from omnimalloc.allocators import ( + BaseAllocator, + BestFitAllocator, + GreedyByAllAllocatorCpp, + GreedyBySizeAllocator, + HillClimbAllocator, + NeuralAllocator, + SimulatedAnnealingAllocator, + SupermallocAllocator, + TelamallocAllocator, +) +from omnimalloc.allocators.greedy_base import peak_memory +from omnimalloc.allocators.neural import DEFAULT_WEIGHTS, peak_lower_bound +from omnimalloc.benchmark.sources import MinimallocSource, MinimallocSubset +from omnimalloc.primitives import Allocation + + +def held_out_instances(quick: bool) -> dict[str, list[tuple[Allocation, ...]]]: + """Held-out problems: generator seeds >= 10^9 are never used in training.""" + import sys + + sys.path.insert(0, str(Path(__file__).parent)) + from train_neural import _random_source # type: ignore + + rng = random.Random(1_000_000_007) + sets: dict[str, list[tuple[Allocation, ...]]] = defaultdict(list) + count = 8 if quick else 24 + for size in (50, 100) if quick else (50, 100, 200): + for _ in range(count): + source = _random_source(rng, size, seed=rng.randrange(1 << 30) + (1 << 30)) + sets[f"generators_n{size}"].append(source.get_allocations()) + + for subset in ( + (MinimallocSubset.SMALL,) + if quick + else (MinimallocSubset.SMALL, MinimallocSubset.CHALLENGING) + ): + source = MinimallocSource(subset=subset) + for variant in source.get_available_variants(): + sets[f"minimalloc_{subset.value}"].append( + source.get_variant(variant).allocations + ) + return sets + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--weights", type=str, default=str(DEFAULT_WEIGHTS)) + parser.add_argument("--quick", action="store_true") + args = parser.parse_args() + + allocators: dict[str, BaseAllocator] = { + "neural (decode only)": NeuralAllocator( + weights=args.weights, num_samples=0, portfolio=False + ), + "neural (sampling only)": NeuralAllocator( + weights=args.weights, portfolio=False + ), + "neural (full)": NeuralAllocator(weights=args.weights), + "greedy_by_size": GreedyBySizeAllocator(), + "greedy_by_all_cpp": GreedyByAllAllocatorCpp(), + "best_fit": BestFitAllocator(), + "telamalloc": TelamallocAllocator(), + "hillclimb": HillClimbAllocator(), + "simulated_annealing": SimulatedAnnealingAllocator(), + "supermalloc": SupermallocAllocator(), + } + + sets = held_out_instances(args.quick) + total_gap: dict[str, list[float]] = defaultdict(list) + total_time: dict[str, float] = defaultdict(float) + wins: dict[str, int] = defaultdict(int) + + for set_name, problems in sets.items(): + gaps: dict[str, list[float]] = defaultdict(list) + for allocations in problems: + lb = max(peak_lower_bound(allocations), 1) + peaks: dict[str, int] = {} + for name, allocator in allocators.items(): + start = time.monotonic() + placed = allocator.allocate(allocations) + total_time[name] += time.monotonic() - start + peaks[name] = peak_memory(placed) + gaps[name].append(peaks[name] / lb) + total_gap[name].append(peaks[name] / lb) + best = min(peaks.values()) + for name, peak in peaks.items(): + if peak == best: + wins[name] += 1 + + print(f"\n=== {set_name} ({len(problems)} problems) ===") + for name in sorted(gaps, key=lambda n: sum(gaps[n])): + mean_gap = sum(gaps[name]) / len(gaps[name]) + print(f" {name:28s} mean peak/LB: {mean_gap:.4f}") + + print(f"\n=== overall ({sum(len(p) for p in sets.values())} problems) ===") + print(f" {'allocator':28s} {'mean peak/LB':>14s} {'wins':>6s} {'time':>9s}") + for name in sorted(total_gap, key=lambda n: sum(total_gap[n])): + mean_gap = sum(total_gap[name]) / len(total_gap[name]) + print(f" {name:28s} {mean_gap:14.4f} {wins[name]:6d} {total_time[name]:8.1f}s") + + +if __name__ == "__main__": + main() diff --git a/training/train_neural.py b/training/train_neural.py new file mode 100644 index 0000000..3718209 --- /dev/null +++ b/training/train_neural.py @@ -0,0 +1,464 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +"""Train the NeuralAllocator priority model. + +The model scores allocations; sorting by score gives a placement order that +first-fit placement turns into a full solution. Training has two phases: + +1. Pretraining: behavior-clone the best classic greedy order per instance via + the exact Plackett-Luce log-likelihood of the target permutation. +2. Self-imitation (expert iteration): sample orders from the current policy + (Gumbel-perturbed scores), evaluate their peak memory with the C++ + FirstFitPlacer, keep the best order ever seen per instance (the incumbent, + seeded with the greedy order), and imitate the incumbents. Costs are + normalized by the max-live-load lower bound, a true optimality gap proxy. + +Usage: + uv run python training/train_neural.py --steps 10000 --install +""" + +import argparse +import csv +import math +import random +import shutil +import time +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import torch +from omnimalloc._cpp import FirstFitPlacer +from omnimalloc.allocators.neural import ( + DEFAULT_WEIGHTS, + classic_orders, + extract_features, + peak_lower_bound, +) +from omnimalloc.allocators.neural_model import ( + PriorityNet, + order_log_prob, + sample_orders, + save_model, +) +from omnimalloc.benchmark.sources import ( + BaseSource, + HighContentionSource, + PinwheelSource, + PowerOf2Source, + RandomSource, + SequentialSource, + TilingSource, + UniformSource, +) +from omnimalloc.primitives import Allocation + + +@dataclass +class Instance: + allocations: tuple[Allocation, ...] + placer: FirstFitPlacer + features: torch.Tensor + lower_bound: int + greedy_best: int + greedy_order: list[int] + best_peak: int + best_order: list[int] + + +def _random_source(rng: random.Random, n: int, seed: int) -> BaseSource: + """Sample a source with randomized parameters for training diversity.""" + kind = rng.choice( + ( + "random", + "random", + "uniform", + "power_of_2", + "contention", + "sequential", + "tiling", + "pinwheel", + ) + ) + if kind == "random": + time_max = rng.choice((100, 1000, 10000)) + return RandomSource( + num_allocations=n, + size_min=1, + size_max=10 ** rng.randint(2, 7), + time_max=time_max, + duration_min=1, + duration_max=rng.randint(2, max(2, time_max // 2)), + seed=seed, + ) + if kind == "uniform": + time_max = rng.randint(20, 200) + return UniformSource( + num_allocations=n, + size=2 ** rng.randint(4, 20), + duration=rng.randint(1, time_max), + time_max=time_max, + seed=seed, + ) + if kind == "power_of_2": + exp_min = rng.randint(0, 10) + return PowerOf2Source( + num_allocations=n, + size_exponent_min=exp_min, + size_exponent_max=exp_min + rng.randint(1, 12), + time_max=100, + duration_min=1, + duration_max=rng.randint(2, 80), + seed=seed, + ) + if kind == "contention": + return HighContentionSource( + num_allocations=n, + size_min=1, + size_max=10 ** rng.randint(2, 6), + time_window=rng.randint(4, 64), + seed=seed, + ) + if kind == "sequential": + return SequentialSource( + num_allocations=n, + size_min=1, + size_max=10 ** rng.randint(2, 6), + duration_min=1, + duration_max=rng.randint(2, 30), + seed=seed, + ) + cls = TilingSource if kind == "tiling" else PinwheelSource + return cls( + num_allocations=n, + capacity=2 ** rng.randint(10, 24), + makespan=2 ** rng.randint(6, 16), + min_size=1, + min_duration=1, + seed=seed, + ) + + +def _greedy_best( + allocations: tuple[Allocation, ...], placer: FirstFitPlacer +) -> tuple[int, list[int]]: + """Best peak across the classic greedy orders, with a consistent teacher. + + The teacher order is the first ordering within 1% of the best peak, so + near-ties always resolve to the same heuristic and the cloning targets + stay consistent across instances. + """ + orders = classic_orders(allocations) + peaks = [placer.evaluate(order) for order in orders] + best_peak = min(peaks) + teacher = next( + order + for order, peak in zip(orders, peaks, strict=True) + if peak <= best_peak * 1.01 + ) + return best_peak, teacher + + +def make_instance(rng: random.Random, n_min: int, n_max: int) -> Instance: + n = round(math.exp(rng.uniform(math.log(n_min), math.log(n_max)))) + source = _random_source(rng, n, seed=rng.randrange(1 << 30)) + allocations = source.get_allocations() + placer = FirstFitPlacer(list(allocations)) + features = torch.from_numpy(extract_features(allocations, placer)) + greedy_best, greedy_order = _greedy_best(allocations, placer) + return Instance( + allocations=allocations, + placer=placer, + features=features, + lower_bound=max(peak_lower_bound(allocations), 1), + greedy_best=greedy_best, + greedy_order=greedy_order, + best_peak=greedy_best, + best_order=list(greedy_order), + ) + + +def make_batch( + instances: list[Instance], +) -> tuple[torch.Tensor, torch.Tensor]: + """Pad features to a (B, N_max, F) tensor with a (B, N_max) validity mask.""" + n_max = max(inst.features.shape[0] for inst in instances) + feature_dim = instances[0].features.shape[1] + features = torch.zeros(len(instances), n_max, feature_dim) + valid = torch.zeros(len(instances), n_max, dtype=torch.bool) + for i, inst in enumerate(instances): + n = inst.features.shape[0] + features[i, :n] = inst.features + valid[i, :n] = True + return features, valid + + +def make_target_orders(instances: list[Instance], n_max: int) -> torch.Tensor: + """(B, 1, N_max) winning greedy orders, padded indices appended at the end.""" + targets = torch.empty(len(instances), 1, n_max, dtype=torch.int64) + for i, inst in enumerate(instances): + n = len(inst.allocations) + targets[i, 0, :n] = torch.tensor(inst.greedy_order) + targets[i, 0, n:] = torch.arange(n, n_max) + return targets + + +def evaluate_orders(instances: list[Instance], orders: torch.Tensor) -> torch.Tensor: + """Peak/LB cost of each sampled order; padded indices sort last and drop. + + Also advances each instance's incumbent (best order found so far). + """ + costs = torch.empty(orders.shape[:2]) + for i, inst in enumerate(instances): + n = len(inst.allocations) + for k in range(orders.shape[1]): + order = orders[i, k, :n].tolist() + peak = inst.placer.evaluate(order) + costs[i, k] = peak / inst.lower_bound + if peak < inst.best_peak: + inst.best_peak, inst.best_order = peak, order + return costs + + +def greedy_decode_cost( + model: PriorityNet, instances: list[Instance], num_samples: int = 0 +) -> tuple[float, float]: + """Mean peak/LB of the deterministic decode and vs the greedy_by_all peak.""" + features, valid = make_batch(instances) + with torch.inference_mode(): + scores = model(features, padding_mask=~valid) + scores = scores.masked_fill(~valid, -torch.inf) + orders = scores.argsort(dim=-1, descending=True) + generator = torch.Generator().manual_seed(0) + sampled = ( + sample_orders(scores, num_samples, generator=generator, valid_mask=valid) + if num_samples + else None + ) + gaps, ratios = [], [] + for i, inst in enumerate(instances): + n = len(inst.allocations) + peak = inst.placer.evaluate(orders[i, :n].tolist()) + if sampled is not None: + peak = min( + peak, + *( + inst.placer.evaluate(sampled[i, k, :n].tolist()) + for k in range(sampled.shape[1]) + ), + ) + gaps.append(peak / inst.lower_bound) + ratios.append(peak / inst.greedy_best) + return float(np.mean(gaps)), float(np.mean(ratios)) + + +def pretrain( + model: PriorityNet, + pool: list[Instance], + val_set: list[Instance], + rng: random.Random, + args: argparse.Namespace, +) -> None: + """Behavior-clone the winning greedy order via Plackett-Luce likelihood. + + Starts REINFORCE from greedy_by_all quality instead of a random policy. + """ + optimizer = torch.optim.Adam(model.parameters(), lr=args.pretrain_lr) + for step in range(1, args.pretrain_steps + 1): + instances = rng.sample(pool, args.batch_size) + features, valid = make_batch(instances) + targets = make_target_orders(instances, features.shape[1]) + + scores = model(features, padding_mask=~valid) + loss = -order_log_prob(scores, targets, valid_mask=valid).mean() + + optimizer.zero_grad() + loss.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) + optimizer.step() + + if step % args.eval_every == 0 or step == args.pretrain_steps: + model.eval() + val_gap, val_vs_greedy = greedy_decode_cost(model, val_set) + model.train() + print( + f"pretrain {step:5d} nll {float(loss.detach()):9.3f} " + f"val {val_gap:.4f} vs_greedy {val_vs_greedy:.4f}" + ) + + +def self_imitation_step( + model: PriorityNet, + optimizer: torch.optim.Optimizer, + instances: list[Instance], + k: int, +) -> tuple[float, float, float]: + """One self-imitation (expert-iteration) update. + + Samples k orders per instance from the current policy, advances each + instance's incumbent, and takes a supervised Plackett-Luce likelihood step + toward the incumbents. Incumbents start at the best greedy order and only + improve, so this is a monotone hill-climb the policy generalizes across + instances; the NLL loss is a meaningful, decreasing quantity. + + Returns NLL loss, mean incumbent peak/LB, and score std. + """ + features, valid = make_batch(instances) + + scores = model(features, padding_mask=~valid) + with torch.no_grad(): + masked = scores.masked_fill(~valid, -torch.inf) + orders = sample_orders(masked, k, valid_mask=valid) + evaluate_orders(instances, orders) + + targets = torch.empty(len(instances), 1, features.shape[1], dtype=torch.int64) + for i, inst in enumerate(instances): + n = len(inst.allocations) + targets[i, 0, :n] = torch.tensor(inst.best_order) + targets[i, 0, n:] = torch.arange(n, features.shape[1]) + loss = -order_log_prob(scores, targets, valid_mask=valid).mean() + + optimizer.zero_grad() + loss.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) + optimizer.step() + + incumbent_cost = float( + np.mean([inst.best_peak / inst.lower_bound for inst in instances]) + ) + score_std = float(scores.detach()[valid].std()) + return float(loss.detach()), incumbent_cost, score_std + + +def generate_data(args: argparse.Namespace) -> tuple[list[Instance], list[Instance]]: + print(f"Generating {args.pool_size} training and {args.val_size} val instances") + t0 = time.monotonic() + rng = random.Random(args.seed + 500_009) + pool = [make_instance(rng, args.n_min, args.n_max) for _ in range(args.pool_size)] + val_rng = random.Random(args.seed + 1_000_003) + val_set = [ + make_instance(val_rng, args.n_min, args.n_max) for _ in range(args.val_size) + ] + print(f"Instance generation took {time.monotonic() - t0:.1f}s") + return pool, val_set + + +def train(args: argparse.Namespace) -> None: + rng = random.Random(args.seed) + torch.manual_seed(args.seed) + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + + pool, val_set = generate_data(args) + val_greedy_gap = float( + np.mean([inst.greedy_best / inst.lower_bound for inst in val_set]) + ) + print(f"Val greedy_by_all mean peak/LB: {val_greedy_gap:.4f}") + + model = PriorityNet(dim=args.dim, heads=args.heads, layers=args.layers) + if args.resume: + from omnimalloc.allocators.neural_model import load_model + + model = load_model(Path(args.resume)) + model.train() + elif args.pretrain_steps: + pretrain(model, pool, val_set, rng, args) + optimizer = torch.optim.Adam(model.parameters(), lr=args.lr) + scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, T_max=args.steps, eta_min=args.lr * 0.1 + ) + + log_path = out_dir / "train_log.csv" + best_val = float("inf") + ema_cost: float | None = None + with log_path.open("w", newline="") as log_file: + writer = csv.writer(log_file) + writer.writerow( + [ + "step", + "loss", + "train_cost", + "train_cost_ema", + "val_gap", + "val_vs_greedy", + "score_std", + "lr", + ] + ) + for step in range(1, args.steps + 1): + instances = rng.sample(pool, args.batch_size) + loss, train_cost, score_std = self_imitation_step( + model, optimizer, instances, args.k + ) + scheduler.step() + ema_cost = ( + train_cost if ema_cost is None else 0.98 * ema_cost + 0.02 * train_cost + ) + + if step % args.eval_every == 0 or step == args.steps: + model.eval() + val_gap, val_vs_greedy = greedy_decode_cost( + model, val_set, num_samples=32 + ) + model.train() + writer.writerow( + [ + step, + f"{loss:.5f}", + f"{train_cost:.5f}", + f"{ema_cost:.5f}", + f"{val_gap:.5f}", + f"{val_vs_greedy:.5f}", + f"{score_std:.4f}", + f"{scheduler.get_last_lr()[0]:.2e}", + ] + ) + log_file.flush() + marker = "" + if val_gap < best_val: + best_val = val_gap + save_model(model, out_dir / "best.pt") + marker = " *" + print( + f"step {step:6d} loss {loss:+.4f} " + f"train {ema_cost:.4f} val {val_gap:.4f} " + f"vs_greedy {val_vs_greedy:.4f} std {score_std:.2f}{marker}" + ) + + save_model(model, out_dir / "last.pt") + print(f"Best val peak/LB: {best_val:.4f} (greedy_by_all: {val_greedy_gap:.4f})") + + if args.install: + shutil.copyfile(out_dir / "best.pt", DEFAULT_WEIGHTS) + print(f"Installed best checkpoint to {DEFAULT_WEIGHTS}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--steps", type=int, default=10000) + parser.add_argument("--pretrain-steps", type=int, default=2000) + parser.add_argument("--batch-size", type=int, default=32) + parser.add_argument("--k", type=int, default=16, help="permutation samples") + parser.add_argument("--lr", type=float, default=3e-4) + parser.add_argument("--pretrain-lr", type=float, default=1e-3) + parser.add_argument("--dim", type=int, default=64) + parser.add_argument("--heads", type=int, default=4) + parser.add_argument("--layers", type=int, default=3) + parser.add_argument("--pool-size", type=int, default=4096) + parser.add_argument("--val-size", type=int, default=128) + parser.add_argument("--n-min", type=int, default=12) + parser.add_argument("--n-max", type=int, default=160) + parser.add_argument("--eval-every", type=int, default=200) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--out", type=str, default="training/checkpoints") + parser.add_argument("--resume", type=str, default=None) + parser.add_argument( + "--install", action="store_true", help="copy best checkpoint into the package" + ) + train(parser.parse_args()) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 2fc0154..422b9fa 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,14 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version >= '3.13' and sys_platform != 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform != 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform != 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", ] [[package]] @@ -215,7 +219,8 @@ name = "contourpy" version = "1.3.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version < '3.11' and sys_platform != 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", ] dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -285,9 +290,12 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", + "python_full_version >= '3.13' and sys_platform != 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform != 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", ] dependencies = [ { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -813,7 +821,8 @@ name = "ipython" version = "8.37.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version < '3.11' and sys_platform != 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", ] dependencies = [ { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, @@ -838,9 +847,12 @@ name = "ipython" version = "9.7.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", + "python_full_version >= '3.13' and sys_platform != 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform != 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, @@ -1325,6 +1337,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" }, ] +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "nbclient" version = "0.10.2" @@ -1389,6 +1410,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform != 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform != 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform != 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -1403,7 +1454,8 @@ name = "numpy" version = "2.2.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11'", + "python_full_version < '3.11' and sys_platform != 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", ] sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ @@ -1468,9 +1520,12 @@ name = "numpy" version = "2.3.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", + "python_full_version >= '3.13' and sys_platform != 'darwin'", + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform != 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", ] sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } wheels = [ @@ -1564,8 +1619,14 @@ all = [ { name = "huggingface-hub" }, { name = "matplotlib" }, { name = "onnx" }, + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, { name = "tqdm" }, ] +ml = [ + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, +] [package.dev-dependencies] dev = [ @@ -1585,11 +1646,13 @@ requires-dist = [ { name = "huggingface-hub", marker = "extra == 'all'", specifier = ">=0.35.0" }, { name = "matplotlib", marker = "extra == 'all'", specifier = ">=3.10.0" }, { name = "numpy", specifier = ">=1.25.0" }, + { name = "omnimalloc", extras = ["ml"], marker = "extra == 'all'" }, { name = "onnx", marker = "extra == 'all'", specifier = ">=1.19.0" }, + { name = "torch", marker = "extra == 'ml'", specifier = ">=2.2", index = "https://download.pytorch.org/whl/cpu" }, { name = "tqdm", marker = "extra == 'all'", specifier = ">=4.66.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, ] -provides-extras = ["all"] +provides-extras = ["ml", "all"] [package.metadata.requires-dev] dev = [ @@ -2258,6 +2321,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, ] +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -2308,6 +2380,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + [[package]] name = "tinycss2" version = "1.4.0" @@ -2369,6 +2453,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "torch" +version = "2.13.0" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform == 'darwin'" }, + { name = "fsspec", marker = "sys_platform == 'darwin'" }, + { name = "jinja2", marker = "sys_platform == 'darwin'" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'darwin'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'darwin'" }, + { name = "setuptools", marker = "sys_platform == 'darwin'" }, + { name = "sympy", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:94f0de129916f77b8dc2c7a8eff644cfeddfe59e39c9f55e9f6e17543410281d", upload-time = "2026-07-08T12:26:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", upload-time = "2026-07-08T12:26:13Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", upload-time = "2026-07-08T12:26:18Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", upload-time = "2026-07-08T12:26:23Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", upload-time = "2026-07-08T12:26:28Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", upload-time = "2026-07-08T12:26:33Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform != 'darwin'", + "python_full_version == '3.12.*' and sys_platform != 'darwin'", + "python_full_version == '3.11.*' and sys_platform != 'darwin'", + "python_full_version < '3.11' and sys_platform != 'darwin'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform != 'darwin'" }, + { name = "fsspec", marker = "sys_platform != 'darwin'" }, + { name = "jinja2", marker = "sys_platform != 'darwin'" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform != 'darwin'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform != 'darwin'" }, + { name = "setuptools", marker = "sys_platform != 'darwin'" }, + { name = "sympy", marker = "sys_platform != 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp310-cp310-linux_s390x.whl", hash = "sha256:0555fde6108ca90247ae33d4e1237cbae475c86a223bb2f0f91d9addf1f611bd", upload-time = "2026-07-08T19:27:11Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:f028e428bddee95cdb86e2470254e95c9af629362488550c200ed4793125a817", upload-time = "2026-07-08T19:27:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f5cbb61180a9793d9e12fe115a2310d2600bd449dfb9a01ec5640e21359fa5ea", upload-time = "2026-07-08T19:27:32Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp310-cp310-win_amd64.whl", hash = "sha256:3bbb357161e8db43ba7cdcc7e03561eba0c449392f2f27d3566887198fcb4ead", upload-time = "2026-07-08T19:27:43Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:6e9817dbdf5ea76789babd46e457eac5bf14ff566cf85f8addbfdff2d56601ce", upload-time = "2026-07-08T19:27:52Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:84453b69508ec79902f899c5ed9495acb9e2bbe9fda5f1d5d6f19e3c3842e1a7", upload-time = "2026-07-08T19:28:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6746dbcbeb526eb61330b76b41ff1b4eb848951103a892eeb080dfa2b264667b", upload-time = "2026-07-08T19:28:16Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:10717d8b3b67c45a4788bf7ffc0bab1ea1e5ebbedd24466be6100102d141fac1", upload-time = "2026-07-08T19:28:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:2b3d093abd919ad934c43d47e73ba63ceba7cbd7269fc2e9c1e4fc29e8fe45fa", upload-time = "2026-07-08T19:28:33Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:ffadde149901c8afa138daa38d898264003cfcf1a3336ca5cd964b5af227d867", upload-time = "2026-07-08T19:28:41Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6f307c2c32d764ffc6ff6893b801fad6d4752f3e67966cb8abf1843427c02604", upload-time = "2026-07-08T19:28:51Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ca4a9394b0c771238a4f73590fdbbc4debad85ed0fa63d026ae1b085da7d6e2", upload-time = "2026-07-08T19:29:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:a8b450c1e58e5800e5b4691dac412f8d2d65a1dc3298166f91596603a3531e6f", upload-time = "2026-07-08T19:29:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:fa0762705b933624d59f6823db9ce7ec2e35b3e1e9c319c9db51fbeecfc3e319", upload-time = "2026-07-08T19:29:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:966d020354f465672dc7dd10d3a5c6cd17d7eb48620aa1d265b48a1f78f06898", upload-time = "2026-07-08T19:29:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:0b8f7d0423027ae8b90c7977c627f3379f325363a08224dffad9b4b2d684a83d", upload-time = "2026-07-08T19:29:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3fbf9c9d1f3c10c2d59d04aca426dee9ccc6ceb32d255c61e93acc3b4f75fae6", upload-time = "2026-07-08T19:29:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:a17ff48608634db245e17e8bb00a9558554a49aeb1e4f5fe6cd039af2a10515b", upload-time = "2026-07-08T19:30:05Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:ac7aaf322be4777765a53bed7264a214dd81b3a1d276b93150515a3c5f75e4b0", upload-time = "2026-07-08T19:30:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:dec241fef3984c0d1edadd1f58708e218d4eae881ceef7bc10cf9964d41b68b9", upload-time = "2026-07-08T19:30:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca021f9eb2f8345c83fa03e3a04587308afb8df71bd472670b3ece00df58621c", upload-time = "2026-07-08T19:30:32Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d20fa53ee744502fa4c69818a720b05ca0d37abd055d4f6e66cae155114bc691", upload-time = "2026-07-08T19:30:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:e2e5134decf00e218da62318f3dc5df156231d367871918e91eba95ab0ad43ab", upload-time = "2026-07-08T19:30:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:991cc14b39e751122c01f017be6448533989868731cb5eecd1006893d26787c2", upload-time = "2026-07-08T19:31:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7b8d26e29bceafbdaa8d63bfe7612f23875b5af2cc07e13f809c3ed890bbe1d8", upload-time = "2026-07-08T19:31:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b222c15a0fc2ce207d1c1a59700b46c8fa6748df1f447ad11e5c870dde0933d9", upload-time = "2026-07-08T19:31:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:a43376bd094124ef626bfdd3d4c2c62eacb0b5ddc99776f4a32d4fd16f1f3420", upload-time = "2026-07-08T19:31:48Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5002ca81af00ae69b57540f615b58b8ae922b6d4848176b366a52bd2196e6", upload-time = "2026-07-08T19:32:00Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:1a3a35229fdc13446b4eab50e7fcf9399ff941e89a3b761497786297a5d8dde5", upload-time = "2026-07-08T19:32:16Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:8e109528e6bab044815daebaf71770fbaace3a66ef1c816cb55c875350f78a60", upload-time = "2026-07-08T19:32:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:222a6681467cc7f6f05cd3068dfbc603def3a1e46d1d4620c1c8cdf6178bd563", upload-time = "2026-07-08T19:32:44Z" }, +] + [[package]] name = "tornado" version = "6.5.2"