diff --git a/CMakeLists.txt b/CMakeLists.txt index 957ff09..784f73e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,11 +42,10 @@ nanobind_add_module( NOMINSIZE src/cpp/allocators/best_fit.cpp src/cpp/allocators/first_fit.cpp - src/cpp/allocators/greedy.cpp src/cpp/allocators/local_search.cpp src/cpp/allocators/omni.cpp src/cpp/allocators/simulated_annealing.cpp - src/cpp/allocators/supermalloc/partition.cpp + src/cpp/allocators/supermalloc.cpp src/cpp/allocators/tabu_search.cpp src/cpp/allocators/telamalloc.cpp src/cpp/analysis/antichain.cpp diff --git a/README.md b/README.md index 727091e..abf7951 100644 --- a/README.md +++ b/README.md @@ -38,15 +38,15 @@ pip install git+https://github.com/fpedd/omnimalloc.git ## Usage ```python -from omnimalloc import Allocation, Pool, run_allocation +import omnimalloc as om -pool = Pool(id="pool", allocations=( - Allocation(id=0, size=64, start=0, end=10), - Allocation(id=1, size=64, start=12, end=20), - Allocation(id=2, size=32, start=5, end=15), +pool = om.Pool(id="pool", allocations=( + om.Allocation(id=0, size=64, start=0, end=10), + om.Allocation(id=1, size=64, start=12, end=20), + om.Allocation(id=2, size=32, start=5, end=15), )) -pool = run_allocation(pool, allocator="supermalloc_allocator", validate=True) +pool = om.allocate(pool, allocator="supermalloc", validate=True) print(pool.size) # 96 print([alloc.offset for alloc in pool.allocations]) # [0, 0, 64] diff --git a/examples/01_basic.py b/examples/01_basic.py index a862711..fcff110 100644 --- a/examples/01_basic.py +++ b/examples/01_basic.py @@ -2,19 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 # -from omnimalloc import Allocation, Pool, run_allocation +import omnimalloc as om def main() -> None: # Define allocations with temporal bounds - alloc_0 = Allocation(id="alloc_0", size=5, start=0, end=10) - alloc_1 = Allocation(id="alloc_1", size=5, start=12, end=20) - alloc_2 = Allocation(id="alloc_2", size=4, start=5, end=15) - alloc_3 = Allocation(id="alloc_3", size=5, start=15, end=23) + alloc_0 = om.Allocation(id="alloc_0", size=5, start=0, end=10) + alloc_1 = om.Allocation(id="alloc_1", size=5, start=12, end=20) + alloc_2 = om.Allocation(id="alloc_2", size=4, start=5, end=15) + alloc_3 = om.Allocation(id="alloc_3", size=5, start=15, end=23) # Create pool and allocate - pool = Pool(id="pool_0", allocations=(alloc_0, alloc_1, alloc_2, alloc_3)) - pool = run_allocation(pool, validate=True) + pool = om.Pool(id="pool_0", allocations=(alloc_0, alloc_1, alloc_2, alloc_3)) + pool = om.allocate(pool, validate=True) # View results print(f"Pool {pool.id!r} size: {pool.size}") diff --git a/examples/02_plotting.py b/examples/02_plotting.py index 0349332..477173b 100644 --- a/examples/02_plotting.py +++ b/examples/02_plotting.py @@ -2,19 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 # -from omnimalloc import Allocation, Pool, plot_allocation, run_allocation +import omnimalloc as om def main() -> None: # Define allocations with temporal bounds - alloc_0 = Allocation(id="alloc_0", size=5, start=0, end=10) - alloc_1 = Allocation(id="alloc_1", size=5, start=12, end=20) - alloc_2 = Allocation(id="alloc_2", size=4, start=5, end=15) - alloc_3 = Allocation(id="alloc_3", size=5, start=15, end=23) + alloc_0 = om.Allocation(id="alloc_0", size=5, start=0, end=10) + alloc_1 = om.Allocation(id="alloc_1", size=5, start=12, end=20) + alloc_2 = om.Allocation(id="alloc_2", size=4, start=5, end=15) + alloc_3 = om.Allocation(id="alloc_3", size=5, start=15, end=23) # Create pool and allocate - pool = Pool(id="pool_0", allocations=(alloc_0, alloc_1, alloc_2, alloc_3)) - pool = run_allocation(pool, validate=True) + pool = om.Pool(id="pool_0", allocations=(alloc_0, alloc_1, alloc_2, alloc_3)) + pool = om.allocate(pool, validate=True) # View results print(f"Pool {pool.id!r} size: {pool.size}") @@ -22,7 +22,7 @@ def main() -> None: print(f" {alloc.id!r} offset: {alloc.offset}") # Visualize (requires matplotlib) - plot_allocation(pool, "allocation.pdf") + om.plot_allocation(pool, "allocation.pdf") if __name__ == "__main__": diff --git a/examples/03_allocators.py b/examples/03_allocators.py index d39f11f..5619ef9 100644 --- a/examples/03_allocators.py +++ b/examples/03_allocators.py @@ -4,13 +4,8 @@ from pathlib import Path -from omnimalloc import ( - Allocation, - Pool, - plot_allocation, - run_allocation, -) -from omnimalloc.allocators import get_available_allocators, get_default_allocator +import omnimalloc as om +from omnimalloc.allocators import DEFAULT_ALLOCATOR, available_allocators from omnimalloc.allocators.minimalloc import HAS_MINIMALLOC @@ -18,33 +13,30 @@ def main() -> None: example_dir = Path("03_example_output") # Define allocations with temporal bounds - alloc_0 = Allocation(id="alloc_0", size=5, start=0, end=10) - alloc_1 = Allocation(id="alloc_1", size=5, start=12, end=20) - alloc_2 = Allocation(id="alloc_2", size=4, start=5, end=15) - alloc_3 = Allocation(id="alloc_3", size=5, start=15, end=23) + alloc_0 = om.Allocation(id="alloc_0", size=5, start=0, end=10) + alloc_1 = om.Allocation(id="alloc_1", size=5, start=12, end=20) + alloc_2 = om.Allocation(id="alloc_2", size=4, start=5, end=15) + alloc_3 = om.Allocation(id="alloc_3", size=5, start=15, end=23) # Create pool and allocate - pool = Pool(id="pool_0", allocations=(alloc_0, alloc_1, alloc_2, alloc_3)) + pool = om.Pool(id="pool_0", allocations=(alloc_0, alloc_1, alloc_2, alloc_3)) # Get and run the default allocator - default_allocator_name = get_default_allocator() - print(f"Running allocation with default allocator: {default_allocator_name}") - pool = run_allocation(pool, allocator=default_allocator_name, validate=True) + print(f"Running allocation with default allocator: {DEFAULT_ALLOCATOR}") + pool = om.allocate(pool, allocator=DEFAULT_ALLOCATOR, validate=True) print(f"Pool {pool.id!r} size: {pool.size}") - plot_allocation( - pool, example_dir / f"allocation_{default_allocator_name}_default.pdf" - ) + om.plot_allocation(pool, example_dir / f"{DEFAULT_ALLOCATOR}_default.pdf") # Run allocation with all available allocators - for allocator_name in get_available_allocators(): + for allocator_name in available_allocators(): # minimalloc is an optional dependency that only builds on some platforms if "minimalloc" in allocator_name and not HAS_MINIMALLOC: print(f"Skipping unavailable allocator: {allocator_name}") continue print(f"Running allocation with allocator: {allocator_name}") - pool = run_allocation(pool, allocator_name, validate=True) + pool = om.allocate(pool, allocator_name, validate=True) print(f"Pool {pool.id!r} size: {pool.size}") - plot_allocation(pool, example_dir / f"allocation_{allocator_name}.pdf") + om.plot_allocation(pool, example_dir / f"{allocator_name}.pdf") if __name__ == "__main__": diff --git a/examples/04_sources.py b/examples/04_sources.py index a147c42..9b9203a 100644 --- a/examples/04_sources.py +++ b/examples/04_sources.py @@ -4,38 +4,35 @@ from pathlib import Path -from omnimalloc import plot_allocation, run_allocation +import omnimalloc as om from omnimalloc.benchmark.sources import ( - get_available_sources, - get_default_source, - get_source_by_name, + DEFAULT_SOURCE, + BaseSource, + available_sources, ) +def allocate_and_plot(source: BaseSource, output: Path) -> None: + pool = source.get_pool() + pool = om.allocate(pool, validate=True) + print(f"Pool {pool.id!r} size: {pool.size}") + om.plot_allocation(pool, output) + + def main() -> None: example_dir = Path("04_example_output") # Get and use the default source - default_source_name = get_default_source() - default_source_class = get_source_by_name(default_source_name) - default_source = default_source_class() - print(f"Using default source: {default_source_name}") - - # Get allocations from the default source - pool = default_source.get_pool() - pool = run_allocation(pool, validate=True) - print(f"Pool {pool.id!r} size: {pool.size}") - plot_allocation(pool, example_dir / f"source_{default_source_name}_default.pdf") - - for source_name in get_available_sources(): - source_class = get_source_by_name(source_name) - source = source_class() + default_source = BaseSource.get(DEFAULT_SOURCE)() + print(f"Using default source: {DEFAULT_SOURCE}") + allocate_and_plot( + default_source, example_dir / f"source_{DEFAULT_SOURCE}_default.pdf" + ) + + for source_name in available_sources(): + source = BaseSource.get(source_name)() print(f"Using source: {source_name}") - - pool = source.get_pool() - pool = run_allocation(pool, validate=True) - print(f"Pool {pool.id!r} size: {pool.size}") - plot_allocation(pool, example_dir / f"source_{source_name}.pdf") + allocate_and_plot(source, example_dir / f"source_{source_name}.pdf") if __name__ == "__main__": diff --git a/examples/05_benchmark.py b/examples/05_benchmark.py index eebc316..48f8a62 100644 --- a/examples/05_benchmark.py +++ b/examples/05_benchmark.py @@ -17,25 +17,25 @@ def main() -> None: # Define allocators, sources, and variants to benchmark allocators = ( - "greedy_by_size_allocator", - "greedy_by_size_allocator_cpp", - "greedy_by_all_allocator_cpp", - "best_fit_allocator", - "telamalloc_allocator", + "greedy_by_size", + "greedy_by_all", + "omni", + "best_fit", + "telamalloc", ) # minimalloc is an optional dependency that only builds on some platforms if HAS_MINIMALLOC: - allocators += ("minimalloc_allocator",) + allocators += ("minimalloc",) sources = ( - "random_source", - "minimalloc_source", - "huggingface_source", + "random", + "minimalloc", + "huggingface", ) # Counts for the parameterizable source, "first 5" for the fixed ones variants = { - "random_source": (10, 50, 100, 250, 500), - "minimalloc_source": 5, - "huggingface_source": 5, + "random": (10, 50, 100, 250, 500), + "minimalloc": 5, + "huggingface": 5, } # Run benchmark campaign diff --git a/notebooks/custom_allocator.ipynb b/notebooks/custom_allocator.ipynb index b554793..2946f7e 100644 --- a/notebooks/custom_allocator.ipynb +++ b/notebooks/custom_allocator.ipynb @@ -11,14 +11,124 @@ "%autoreload 2" ] }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "# Custom allocator\n", + "\n", + "Subclass `BaseAllocator`, implement the `_allocate` hook, and the registry\n", + "picks the allocator up automatically — usable by name everywhere, including\n", + "`om.allocate` and the benchmark harness." + ] + }, { "cell_type": "code", "execution_count": null, - "id": "1", + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import omnimalloc as om\n", + "from omnimalloc.allocators import BaseAllocator, available_allocators\n", + "from omnimalloc.benchmark.sources import RandomSource" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "`_allocate` receives validated, non-empty allocations with unique ids and\n", + "returns them with offsets assigned. The one constraint to satisfy:\n", + "*conflicting* allocations — those whose lifetimes can coexist, as reported\n", + "by `om.conflicts` — must occupy disjoint address ranges.\n", + "\n", + "This allocator places largest-first, each allocation at the lowest offset\n", + "not blocked by an already-placed conflict. Because it consumes only the\n", + "conflict relation, it also accepts vector-clock lifetimes\n", + "(`supports_vector_time = True`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "class LargestFirstAllocator(BaseAllocator):\n", + " \"\"\"Place allocations largest-first, each at the lowest conflict-free offset.\"\"\"\n", + "\n", + " supports_vector_time = True\n", + "\n", + " def _allocate(\n", + " self, allocations: tuple[om.Allocation, ...]\n", + " ) -> tuple[om.Allocation, ...]:\n", + " conflict_map = om.conflicts(allocations)\n", + " sizes = {alloc.id: alloc.size for alloc in allocations}\n", + " offsets: dict[om.IdType, int] = {}\n", + " for alloc in sorted(allocations, key=lambda a: a.size, reverse=True):\n", + " blockers = sorted(\n", + " (offsets[other], offsets[other] + sizes[other])\n", + " for other in conflict_map[alloc.id]\n", + " if other in offsets\n", + " )\n", + " offset = 0\n", + " for lower, upper in blockers:\n", + " if lower - offset >= alloc.size:\n", + " break\n", + " offset = max(offset, upper)\n", + " offsets[alloc.id] = offset\n", + " return tuple(alloc.with_offset(offsets[alloc.id]) for alloc in allocations)" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "Defining the class registered it: the `Allocator` role token is stripped and\n", + "the rest snake_cased, so it answers to `\"largest_first\"`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "assert \"largest_first\" in available_allocators()\n", + "\n", + "pool = RandomSource(num_allocations=200).get_pool()\n", + "pool = om.allocate(pool, \"largest_first\", validate=True)\n", + "print(\n", + " f\"peak memory {pool.size}, lower bound {pool.pressure}, \"\n", + " f\"efficiency {pool.efficiency:.3f}\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "Compare it against a few built-in allocators, then plot its placement." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", "metadata": {}, "outputs": [], "source": [ - "print(\"TODO(fpedd): Implement this\")" + "for name in (\"naive\", \"greedy_by_size\", \"omni\", \"largest_first\"):\n", + " print(f\"{name:>14}: peak {om.allocate(pool, name).size}\")\n", + "\n", + "om.plot_allocation(pool)" ] } ], diff --git a/notebooks/custom_source.ipynb b/notebooks/custom_source.ipynb index 4d8b358..120a41a 100644 --- a/notebooks/custom_source.ipynb +++ b/notebooks/custom_source.ipynb @@ -11,20 +11,157 @@ "%autoreload 2" ] }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "# Custom source\n", + "\n", + "Subclass `BaseSource`, implement `get_allocations`, and the registry picks\n", + "the workload up automatically — the inherited `get_pool`/`get_memory`/\n", + "`get_system` build the entity hierarchy, and the benchmark harness accepts\n", + "it by name." + ] + }, { "cell_type": "code", "execution_count": null, - "id": "1", + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "\n", + "import omnimalloc as om\n", + "from omnimalloc.benchmark.sources import BaseSource, available_sources" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "`get_allocations(num_allocations, skip)` returns a deterministic tuple;\n", + "`skip` discards that many leading allocations so the base class can carve\n", + "consecutive, non-repeating batches for multi-pool workloads.\n", + "\n", + "This source models the activations of a sequential layer chain: layer `i`\n", + "writes its output at time `i` and the consumer at layer `c` reads it while\n", + "running, so the tensor lives `[i, c + 1)`. Most outputs feed the next layer;\n", + "occasional skip connections reach `skip_distance` layers ahead and stay live\n", + "in between — the long thin rectangles a good packer must thread around." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", "metadata": {}, "outputs": [], "source": [ - "print(\"TODO(fpedd): Implement this\")" + "class LayerChainSource(BaseSource):\n", + " \"\"\"Activations of a layer chain with occasional skip connections.\"\"\"\n", + "\n", + " def __init__(\n", + " self,\n", + " num_allocations: int = 100,\n", + " size_min: int = 1 << 10,\n", + " size_max: int = 1 << 20,\n", + " skip_distance: int = 8,\n", + " skip_probability: float = 0.2,\n", + " seed: int | None = 42,\n", + " ) -> None:\n", + " super().__init__(num_allocations=num_allocations)\n", + " self.size_min = size_min\n", + " self.size_max = size_max\n", + " self.skip_distance = skip_distance\n", + " self.skip_probability = skip_probability\n", + " self.seed = seed\n", + "\n", + " def get_allocations(\n", + " self, num_allocations: int | None = None, skip: int = 0\n", + " ) -> tuple[om.Allocation, ...]:\n", + " total = num_allocations if num_allocations is not None else self.num_allocations\n", + " rng = random.Random(self.seed)\n", + " for _ in range(skip):\n", + " self._generate_one(rng, 0)\n", + " return tuple(self._generate_one(rng, skip + i) for i in range(total))\n", + "\n", + " def _generate_one(self, rng: random.Random, layer: int) -> om.Allocation:\n", + " is_skip = rng.random() < self.skip_probability\n", + " consumer = layer + (self.skip_distance if is_skip else 1)\n", + " return om.Allocation(\n", + " id=layer,\n", + " size=rng.randint(self.size_min, self.size_max),\n", + " start=layer,\n", + " end=consumer + 1,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "Defining the class registered it as `\"layer_chain\"` (the `Source` role token\n", + "is stripped). Draw a pool, allocate it, and plot the placement." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "assert \"layer_chain\" in available_sources()\n", + "\n", + "pool = LayerChainSource(num_allocations=200).get_pool()\n", + "pool = om.allocate(pool, validate=True)\n", + "print(\n", + " f\"peak memory {pool.size}, lower bound {pool.pressure}, \"\n", + " f\"efficiency {pool.efficiency:.3f}\"\n", + ")\n", + "om.plot_allocation(pool)" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "Registered sources plug straight into the benchmark harness by name." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "from omnimalloc.benchmark import plot_benchmark, run_benchmark\n", + "\n", + "campaign = run_benchmark(\n", + " allocators=(\"naive\", \"greedy_by_size\", \"omni\"),\n", + " sources=(\"layer_chain\",),\n", + " variants=(50, 100, 200, 500),\n", + " validate=True,\n", + ")\n", + "plot_benchmark(campaign)" ] } ], "metadata": { + "kernelspec": { + "display_name": "omnimalloc", + "language": "python", + "name": "python3" + }, "language_info": { - "name": "python" + "name": "python", + "version": "3.10.12" } }, "nbformat": 4, diff --git a/notebooks/extensive_benchmark.ipynb b/notebooks/extensive_benchmark.ipynb index 36dddf2..6541091 100644 --- a/notebooks/extensive_benchmark.ipynb +++ b/notebooks/extensive_benchmark.ipynb @@ -33,17 +33,16 @@ "outputs": [], "source": [ "allocators = (\n", - " \"greedy_by_size_allocator\",\n", - " \"greedy_by_size_allocator_cpp\",\n", - " \"greedy_by_area_allocator\",\n", - " \"greedy_by_conflict_allocator\",\n", - " # \"minimalloc_allocator\",\n", + " \"greedy_by_size\",\n", + " \"greedy_by_area\",\n", + " \"greedy_by_conflict\",\n", + " # \"minimalloc\",\n", ")\n", "\n", "sources = (\n", - " \"random_source\",\n", - " \"minimalloc_source\",\n", - " # \"huggingface_source\",\n", + " \"random\",\n", + " \"minimalloc\",\n", + " # \"huggingface\",\n", ")\n", "\n", "variants = (10, 100, 500, 1_000, 2_000, 5_000, 10_000)" diff --git a/scripts/benchmark_allocation.py b/scripts/benchmark_allocation.py index 69a145b..35b4470 100644 --- a/scripts/benchmark_allocation.py +++ b/scripts/benchmark_allocation.py @@ -48,11 +48,11 @@ # fall back to peak-bound sanity checks on larger instances VALIDATE_LIMIT = 2_000 ALLOCATORS = ( - "omni_allocator", - "greedy_by_all_allocator_cpp", - "greedy_allocator_cpp", - "best_fit_allocator", - "naive_allocator", + "omni", + "greedy_by_all", + "greedy", + "best_fit", + "naive", ) SURFACE = "#fcfcfb" diff --git a/scripts/benchmark_pressure.py b/scripts/benchmark_pressure.py index a228279..249dc33 100644 --- a/scripts/benchmark_pressure.py +++ b/scripts/benchmark_pressure.py @@ -3,35 +3,34 @@ # """Benchmark exact pressure lower bounds on vector-clock workloads. -Compares the shipping ``get_pressure`` (linearize then sweep, else the exact +Compares the shipping ``pressure`` (linearize then sweep, else the exact antichain, under the default work budget) against the two exact C++ methods: -- ``get_pressure(work_budget=None)``: unbudgeted max-weight antichain +- ``pressure(work_budget=None)``: unbudgeted max-weight antichain (weighted Dilworth via min flow), the tightest sound lower bound on any placement's peak and the reference every ratio is measured against -- ``get_closure_pressure``: realizable peak via join-closure enumeration; +- ``closure_pressure``: realizable peak via join-closure enumeration; instances whose closure exceeds ``--closure-cap`` are reported as capped - and excluded from the means. Instances where ``get_pressure`` exceeds its + and excluded from the means. Instances where ``pressure`` exceeds its default work budget are reported the same way and their per-allocation counterparts (dashed in the figures; ratios are means over the per-allocation pinned antichain): -- ``get_per_allocation_pressure``: pinned antichain per distinct lifetime -- ``get_per_allocation_closure_pressure``: realizable peak per allocation -- ``get_per_allocation_placement_pressure``: placement-certified bound read - off an untimed ``OmniAllocator`` placement, plain and ``clique_cap`` forms +- ``pressure_per_allocation``: pinned antichain per distinct lifetime +- ``closure_pressure_per_allocation``: realizable peak per allocation +- ``placement_pressure_per_allocation``: placement-certified bound read + off an untimed ``OmniAllocator`` placement Each sample also places the workload with ``OmniAllocator`` and reports its peak over the antichain bound, so a ratio of 1.000 certifies the placement -optimal. Every sample cross-checks the bound order — globally ``get_pressure`` +optimal. Every sample cross-checks the bound order — globally ``pressure`` equal to the antichain, closure at or below it, omni peak at or above it, antichain at or below the tiling optimum; per allocation closure <= pinned -antichain <= clique-capped <= plain placement, with each per-allocation max -matching its global counterpart and the plain placement max matching the -placement's peak — so the sweep doubles as a torture pass for the exact -primitives. Any method whose single run exceeds ``--budget`` seconds is -dropped for the rest of the sweep. +antichain <= placement, with each per-allocation max matching its global +counterpart and the placement max matching the placement's peak — so the +sweep doubles as a torture pass for the exact primitives. Any method whose +single run exceeds ``--budget`` seconds is dropped for the rest of the sweep. uv run python scripts/benchmark_pressure.py --out benchmark_results_pressure """ @@ -47,12 +46,12 @@ import matplotlib.pyplot as plt from omnimalloc import OmniAllocator from omnimalloc.analysis import ( - get_closure_pressure, - get_per_allocation_closure_pressure, - get_per_allocation_placement_pressure, - get_per_allocation_pressure, - get_placement_pressure, - get_pressure, + closure_pressure, + closure_pressure_per_allocation, + placement_pressure, + placement_pressure_per_allocation, + pressure, + pressure_per_allocation, ) from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource @@ -70,17 +69,16 @@ SIZES = (100, 300, 1_000, 3_000, 10_000) NUM_SYNCS = (0, 64, 1_024) -REFERENCE = "get_pressure(work_budget=None)" -PER_ALLOCATION_REFERENCE = "get_per_allocation_pressure" +REFERENCE = "pressure(work_budget=None)" +PER_ALLOCATION_REFERENCE = "pressure_per_allocation" METHODS = ( - "get_pressure", + "pressure", REFERENCE, - "get_closure_pressure", - "omni_allocator", + "closure_pressure", + "omni", PER_ALLOCATION_REFERENCE, - "get_per_allocation_closure_pressure", - "get_per_allocation_placement_pressure", - "placement_clique_cap", + "closure_pressure_per_allocation", + "placement_pressure_per_allocation", ) DEFAULT_CLOSURE_CAP = 1 << 16 @@ -91,21 +89,19 @@ GRID = "#e1e0d9" AXIS = "#c3c2b7" COLORS = { - "get_pressure": "#2a78d6", + "pressure": "#2a78d6", REFERENCE: "#1baf7a", - "get_closure_pressure": "#eda100", - "omni_allocator": "#4a3aa7", - "get_per_allocation_pressure": "#1baf7a", - "get_per_allocation_closure_pressure": "#eda100", - "get_per_allocation_placement_pressure": "#4a3aa7", - "placement_clique_cap": "#4a3aa7", + "closure_pressure": "#eda100", + "omni": "#4a3aa7", + "pressure_per_allocation": "#1baf7a", + "closure_pressure_per_allocation": "#eda100", + "placement_pressure_per_allocation": "#4a3aa7", } # Per-allocation variants share their global counterpart's color, dashed LINESTYLES = { - "get_per_allocation_pressure": "--", - "get_per_allocation_closure_pressure": "--", - "get_per_allocation_placement_pressure": "--", - "placement_clique_cap": ":", + "pressure_per_allocation": "--", + "closure_pressure_per_allocation": "--", + "placement_pressure_per_allocation": "--", } Sample = dict[str, Any] @@ -122,9 +118,9 @@ def _capped( def _budgeted(allocations: tuple[Allocation, ...]) -> Value: - """None instead of raising when get_pressure exceeds its work budget.""" + """None instead of raising when pressure exceeds its work budget.""" try: - return get_pressure(allocations) + return pressure(allocations) except RuntimeError: return None @@ -136,23 +132,18 @@ def _sample_runners( args: argparse.Namespace, ) -> dict[str, Runner]: return { - "get_pressure": lambda: _budgeted(allocations), - REFERENCE: lambda: get_pressure(allocations, work_budget=None), - "get_closure_pressure": lambda: _capped( - get_closure_pressure, allocations, args.closure_cap + "pressure": lambda: _budgeted(allocations), + REFERENCE: lambda: pressure(allocations, work_budget=None), + "closure_pressure": lambda: _capped( + closure_pressure, allocations, args.closure_cap ), - "omni_allocator": lambda: get_placement_pressure( - allocator.allocate(allocations) + "omni": lambda: placement_pressure(allocator.allocate(allocations)), + PER_ALLOCATION_REFERENCE: lambda: pressure_per_allocation(allocations), + "closure_pressure_per_allocation": lambda: _capped( + closure_pressure_per_allocation, allocations, args.closure_cap ), - PER_ALLOCATION_REFERENCE: lambda: get_per_allocation_pressure(allocations), - "get_per_allocation_closure_pressure": lambda: _capped( - get_per_allocation_closure_pressure, allocations, args.closure_cap - ), - "get_per_allocation_placement_pressure": lambda: ( - get_per_allocation_placement_pressure(placed) - ), - "placement_clique_cap": lambda: ( - get_per_allocation_placement_pressure(placed, clique_cap=True) + "placement_pressure_per_allocation": lambda: ( + placement_pressure_per_allocation(placed) ), } @@ -205,9 +196,9 @@ def _finish_sample( reference = values.get(REFERENCE) if not reference: return sample - assert values.get("get_pressure", reference) == reference - assert values.get("get_closure_pressure", 0) <= reference - assert values.get("omni_allocator", reference) >= reference + assert values.get("pressure", reference) == reference + assert values.get("closure_pressure", 0) <= reference + assert values.get("omni", reference) >= reference assert optimum is None or reference <= optimum _check_per_allocation(values, reference, placed) sample["ratios"] = _ratios(values, reference) @@ -219,20 +210,18 @@ def _check_per_allocation( ) -> None: """Per-allocation identities: maxima match globals, bounds are ordered.""" per_alloc = values.get(PER_ALLOCATION_REFERENCE) - closure = values.get("get_per_allocation_closure_pressure") - placement = values.get("get_per_allocation_placement_pressure") - clique = values.get("placement_clique_cap") + closure = values.get("closure_pressure_per_allocation") + placement = values.get("placement_pressure_per_allocation") if per_alloc: assert max(per_alloc.values()) == reference for i, pinned in per_alloc.items(): low = closure[i] if closure else pinned - mid = clique[i] if clique else pinned - high = placement[i] if placement else mid - assert low <= pinned <= mid <= high - if closure and values.get("get_closure_pressure") is not None: - assert max(closure.values()) == values["get_closure_pressure"] + high = placement[i] if placement else pinned + assert low <= pinned <= high + if closure and values.get("closure_pressure") is not None: + assert max(closure.values()) == values["closure_pressure"] if placement: - assert max(placement.values()) == get_placement_pressure(placed) + assert max(placement.values()) == placement_pressure(placed) def _ratios(values: dict[str, Any], reference: int) -> dict[str, float]: diff --git a/scripts/generate_readme_assets.py b/scripts/generate_readme_assets.py index afe62f2..3543101 100644 --- a/scripts/generate_readme_assets.py +++ b/scripts/generate_readme_assets.py @@ -40,7 +40,7 @@ from matplotlib.colors import LinearSegmentedColormap from matplotlib.patches import Rectangle from matplotlib.ticker import FuncFormatter, MultipleLocator -from omnimalloc import run_allocation, validate_allocation +from omnimalloc import allocate, validate_allocation from omnimalloc.allocators import BaseAllocator from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.timer import Timer @@ -61,53 +61,53 @@ # Allocator display metadata: registry name -> (label, palette role). ALLOCATORS: dict[str, tuple[str, str]] = { - "naive_allocator": ("naive", "baseline"), - "random_allocator": ("random search", "baseline"), - "greedy_by_size_allocator_cpp": ("greedy (size)", "greedy"), - "greedy_by_all_allocator_cpp": ("greedy (all)", "greedy"), - "best_fit_allocator": ("best-fit", "greedy_alt"), - "omni_allocator": ("omni", "omni"), - "hill_climb_allocator": ("hill climbing", "search_alt"), - "genetic_allocator": ("genetic", "search_alt"), - "simulated_annealing_allocator": ("simulated annealing", "search"), - "tabu_search_allocator": ("tabu search", "search"), - "telamalloc_allocator": ("telamalloc", "telamalloc"), - "minimalloc_allocator": ("minimalloc", "minimalloc"), - "supermalloc_allocator": ("supermalloc", "exact"), + "naive": ("naive", "baseline"), + "random": ("random search", "baseline"), + "greedy_by_size": ("greedy (size)", "greedy"), + "greedy_by_all": ("greedy (all)", "greedy"), + "best_fit": ("best-fit", "greedy_alt"), + "omni": ("omni", "omni"), + "hill_climb": ("hill climbing", "search_alt"), + "genetic": ("genetic", "search_alt"), + "simulated_annealing": ("simulated annealing", "search"), + "tabu_search": ("tabu search", "search"), + "telamalloc": ("telamalloc", "telamalloc"), + "minimalloc": ("minimalloc", "minimalloc"), + "supermalloc": ("supermalloc", "exact"), } HERO_ALLOCATORS = ( - "random_allocator", - "greedy_by_size_allocator_cpp", - "greedy_by_all_allocator_cpp", - "best_fit_allocator", - "omni_allocator", - "hill_climb_allocator", - "genetic_allocator", - "simulated_annealing_allocator", - "tabu_search_allocator", - "telamalloc_allocator", - "minimalloc_allocator", - "supermalloc_allocator", + "random", + "greedy_by_size", + "greedy_by_all", + "best_fit", + "omni", + "hill_climb", + "genetic", + "simulated_annealing", + "tabu_search", + "telamalloc", + "minimalloc", + "supermalloc", ) QUALITY_ALLOCATORS = ( - "greedy_by_size_allocator_cpp", - "best_fit_allocator", - "omni_allocator", - "tabu_search_allocator", - "telamalloc_allocator", - "minimalloc_allocator", - "supermalloc_allocator", + "greedy_by_size", + "best_fit", + "omni", + "tabu_search", + "telamalloc", + "minimalloc", + "supermalloc", ) # best-fit is omitted: its curve coincides with greedy (size) at every size. SCALING_ALLOCATORS = ( - "naive_allocator", - "greedy_by_size_allocator_cpp", - "omni_allocator", - "hill_climb_allocator", - "telamalloc_allocator", - "minimalloc_allocator", - "supermalloc_allocator", + "naive", + "greedy_by_size", + "omni", + "hill_climb", + "telamalloc", + "minimalloc", + "supermalloc", ) QUALITY_PROBLEMS = ("mm-A", "mm-C", "mm-H", "mm-K", "pinwheel", "tiling", "random") @@ -124,31 +124,31 @@ # Direct-label offsets in points, tuned per hero point: (dx, dy, ha). HERO_LABEL_OFFSETS: dict[str, tuple[float, float, str]] = { - "random_allocator": (0, -11, "center"), - "greedy_by_size_allocator_cpp": (8, 0, "left"), - "greedy_by_all_allocator_cpp": (0, -11, "center"), - "best_fit_allocator": (8, 0, "left"), - "omni_allocator": (8, 0, "left"), - "hill_climb_allocator": (0, -11, "center"), - "genetic_allocator": (8, -6, "left"), - "simulated_annealing_allocator": (-8, 3, "right"), - "tabu_search_allocator": (-8, 0, "right"), - "telamalloc_allocator": (0, -11, "center"), - "minimalloc_allocator": (8, 0, "left"), - "supermalloc_allocator": (-10, 0, "right"), + "random": (0, -11, "center"), + "greedy_by_size": (8, 0, "left"), + "greedy_by_all": (0, -11, "center"), + "best_fit": (8, 0, "left"), + "omni": (8, 0, "left"), + "hill_climb": (0, -11, "center"), + "genetic": (8, -6, "left"), + "simulated_annealing": (-8, 3, "right"), + "tabu_search": (-8, 0, "right"), + "telamalloc": (0, -11, "center"), + "minimalloc": (8, 0, "left"), + "supermalloc": (-10, 0, "right"), } # Direct-label offsets in points: (dx, dy, ha). minimalloc's line ends early # (no 10k point), so its label anchors left, away from the 10k label cluster; # the four lines that converge on the 3 s budget at 10k get staggered dy. SCALING_LABEL_OFFSETS = { - "naive_allocator": (4, -2, "left"), - "greedy_by_size_allocator_cpp": (4, -4, "left"), - "omni_allocator": (4, 4, "left"), - "hill_climb_allocator": (4, 3, "left"), - "telamalloc_allocator": (4, -11, "left"), - "minimalloc_allocator": (0, 9, "center"), - "supermalloc_allocator": (4, 10, "left"), + "naive": (4, -2, "left"), + "greedy_by_size": (4, -4, "left"), + "omni": (4, 4, "left"), + "hill_climb": (4, 3, "left"), + "telamalloc": (4, -11, "left"), + "minimalloc": (0, 9, "center"), + "supermalloc": (4, 10, "left"), } @@ -228,7 +228,7 @@ def _solve( ) -> tuple[float, float, Pool]: """Time the solve alone; validation is quadratic and would skew timings.""" with Timer() as timer: - solved = run_allocation(pool, allocator=allocator) + solved = allocate(pool, allocator=allocator) if validate: validate_allocation(solved) return timer.elapsed_s, solved.efficiency, solved @@ -237,12 +237,12 @@ def _solve( def _hard_suite() -> dict[str, Pool]: """Real minimalloc benchmarks plus adversarial synthetic patterns.""" suite: dict[str, Pool] = {} - minimalloc = BaseSource.get("minimalloc_source")() + minimalloc = BaseSource.get("minimalloc")() for variant in minimalloc.get_available_variants(): suite[f"mm-{variant.split('.')[0]}"] = minimalloc.get_variant(variant) - suite["pinwheel"] = BaseSource.get("pinwheel_source")().get_variant(101) - suite["tiling"] = BaseSource.get("tiling_source")().get_variant(100) - suite["random"] = BaseSource.get("random_source")().get_variant(250) + suite["pinwheel"] = BaseSource.get("pinwheel")().get_variant(101) + suite["tiling"] = BaseSource.get("tiling")().get_variant(100) + suite["random"] = BaseSource.get("random")().get_variant(250) return suite @@ -262,7 +262,7 @@ def collect_data() -> dict[str, Any]: runs[name] = {} for problem in problems: seconds, efficiency, solved = _solve(suite[problem], allocator) - if name == "supermalloc_allocator": + if name == "supermalloc": supermalloc_pools[problem] = solved runs[name][problem] = (seconds, efficiency) print(f"{name:38s} {problem:10s} {seconds:8.3f}s {efficiency:7.2%}") @@ -281,12 +281,12 @@ def collect_data() -> dict[str, Any]: # Scaling: solve time vs. problem size on the random source. Skip validation # here: it is quadratic in pure Python and would dwarf the fast solves at 10k. - source = BaseSource.get("random_source")() + source = BaseSource.get("random")() scaling: dict[str, list[list[float]]] = {} for name in SCALING_ALLOCATORS: allocator = BaseAllocator.resolve(name) # minimalloc can't solve 10k within the budget and would error out. - capped = name == "minimalloc_allocator" + capped = name == "minimalloc" sizes = SCALING_SIZES_SLOW if capped else SCALING_SIZES scaling[name] = [] for size in sizes: @@ -299,7 +299,7 @@ def collect_data() -> dict[str, Any]: solved = supermalloc_pools[ALLOCATION_PROBLEM] allocation = { "problem": PROBLEM_LABELS.get(ALLOCATION_PROBLEM, ALLOCATION_PROBLEM), - "efficiency": runs["supermalloc_allocator"][ALLOCATION_PROBLEM][1], + "efficiency": runs["supermalloc"][ALLOCATION_PROBLEM][1], "size": solved.size, "rects": [[a.start, a.duration, a.offset, a.size] for a in solved.allocations], } @@ -458,7 +458,7 @@ def render_hero(data: dict[str, Any], theme: Theme, preview: Path | None) -> Non for name, (seconds, efficiency) in points.items(): label, role = ALLOCATORS[name] color = theme.role[role] - emphasis = name == "supermalloc_allocator" + emphasis = name == "supermalloc" ax.scatter( seconds, efficiency, @@ -522,7 +522,7 @@ def render_quality(data: dict[str, Any], theme: Theme, preview: Path | None) -> solid_capstyle="round", ) for name, value in zip(QUALITY_ALLOCATORS, values, strict=True): - emphasis = name == "supermalloc_allocator" + emphasis = name == "supermalloc" ax.scatter( value, y, @@ -559,7 +559,7 @@ def render_scaling(data: dict[str, Any], theme: Theme, preview: Path | None) -> label, role = ALLOCATORS[name] sizes, seconds = zip(*data[name], strict=True) color = theme.role[role] - emphasis = name == "supermalloc_allocator" + emphasis = name == "supermalloc" ax.plot( sizes, seconds, diff --git a/scripts/stress_omni.py b/scripts/stress_omni.py index 7a2bb38..ae11ea1 100644 --- a/scripts/stress_omni.py +++ b/scripts/stress_omni.py @@ -10,7 +10,7 @@ - dimension sweep: wall time and packing quality versus the clock dimension (source ``num_threads``) at a fixed problem size, one series per sync pattern. Quality is peak over the budgeted exact lower bound - (``get_pressure``); instances whose bound exceeds the work budget are + (``pressure``); instances whose bound exceeds the work budget are reported without a ratio rather than stalling the sweep. - caller sweep: aggregate throughput versus concurrent Python callers on one fixed instance. The bindings release the GIL and the C++ portfolio spawns @@ -32,7 +32,7 @@ import matplotlib.pyplot as plt from omnimalloc.allocators import OmniAllocator -from omnimalloc.analysis.pressure import get_pressure +from omnimalloc.analysis import pressure from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.benchmark.timer import Timer @@ -80,7 +80,7 @@ def _bounded_ratio( ) -> float: """Peak over the budgeted exact bound; NaN when the budget is exceeded.""" try: - return peak / get_pressure(allocations, work_budget=work_budget) + return peak / pressure(allocations, work_budget=work_budget) except RuntimeError: return nan diff --git a/src/cpp/allocators/best_fit.cpp b/src/cpp/allocators/best_fit.cpp index fccaff9..4472c99 100644 --- a/src/cpp/allocators/best_fit.cpp +++ b/src/cpp/allocators/best_fit.cpp @@ -27,30 +27,20 @@ int64_t find_best_fit_offset( cursor = std::max(cursor, end); } - // No finite gap fit: place after the last overlapping allocation + // No finite gap fit: place after the last conflicting allocation return best_gap < 0 ? cursor : best_offset; } } // namespace -std::vector BestFitAllocator::allocate( - const std::vector& allocations) const { +std::vector best_fit_place( + const std::vector& allocations) { // Lambda rather than the function pointer so the placement loop inlines // the offset scan instead of an indirect call per allocation - return place_indexed(allocations, compute_overlap_indices(allocations), + return place_indexed(allocations, compute_conflict_indices(allocations), [](int64_t size, const auto& spans) { return find_best_fit_offset(size, spans); }); } } // namespace omnimalloc - -namespace std { - -size_t hash::operator()( - const omnimalloc::BestFitAllocator&) const noexcept { - // Stateless class - all instances are equal, use constant hash - return 0x517cc1b7; // arbitrary constant -} - -} // namespace std diff --git a/src/cpp/allocators/best_fit.hpp b/src/cpp/allocators/best_fit.hpp index d88ef34..55f7787 100644 --- a/src/cpp/allocators/best_fit.hpp +++ b/src/cpp/allocators/best_fit.hpp @@ -11,25 +11,10 @@ namespace omnimalloc { // Best-fit placement: like first-fit, but among the gaps left by -// already-placed overlapping allocations it picks the smallest one that fits +// already-placed conflicting allocations it picks the smallest one that fits // (ties broken by lowest offset) rather than the first one found. Falls back -// to placing after the last overlapping allocation when no finite gap fits. -class BestFitAllocator { - public: - BestFitAllocator() = default; - - // Allocate the given allocations using a best-fit greedy strategy. - std::vector allocate( - const std::vector& allocations) const; - - bool operator==(const BestFitAllocator&) const noexcept = default; -}; +// to placing after the last conflicting allocation when no finite gap fits. +[[nodiscard]] std::vector best_fit_place( + const std::vector& allocations); } // namespace omnimalloc - -namespace std { -template <> -struct hash { - size_t operator()(const omnimalloc::BestFitAllocator&) const noexcept; -}; -} // namespace std diff --git a/src/cpp/allocators/first_fit.cpp b/src/cpp/allocators/first_fit.cpp index 7df4d01..c3efed5 100644 --- a/src/cpp/allocators/first_fit.cpp +++ b/src/cpp/allocators/first_fit.cpp @@ -21,9 +21,11 @@ namespace omnimalloc { void require_scalar_time(const std::vector& allocations, const char* who) { if (!std::ranges::all_of(allocations, &Allocation::is_scalar_time)) { - throw std::invalid_argument( - std::string(who) + - " requires scalar time lifetimes; linearize vector clocks first"); + const size_t max_dim = + std::ranges::max_element(allocations, {}, &Allocation::dim)->dim(); + throw std::invalid_argument(std::string(who) + + " requires scalar (interval) lifetimes, got " + + std::to_string(max_dim) + "-dim vector clocks"); } } @@ -244,7 +246,8 @@ int64_t first_fit_offset( } std::vector first_fit_place_indexed( - const std::vector& allocations, const OverlapIndices& indices) { + const std::vector& allocations, + const ConflictIndices& indices) { // Lambda rather than the function pointer so the placement loop inlines // the offset scan instead of an indirect call per allocation return place_indexed(allocations, indices, @@ -254,50 +257,40 @@ std::vector first_fit_place_indexed( } std::vector first_fit_place( - const std::vector& allocations, - const TemporalOverlaps& overlaps) { - // Translate the id-keyed map into index adjacency once, so placement only - // visits each allocation's neighbors instead of everything placed so far - std::unordered_map, IdTypeHash> by_id; - for (size_t i = 0; i < allocations.size(); ++i) { - by_id[allocations[i].id()].push_back(i); - } - - OverlapIndices indices(allocations.size()); - for (size_t i = 0; i < allocations.size(); ++i) { - auto it = overlaps.find(allocations[i].id()); - if (it == overlaps.end()) { - continue; - } - for (const IdType& other_id : it->second) { - auto jt = by_id.find(other_id); - if (jt == by_id.end()) { - continue; - } - for (size_t j : jt->second) { - if (j != i) { - indices[i].push_back(j); - } - } - } - } - - return first_fit_place_indexed(allocations, indices); + const std::vector& allocations) { + return first_fit_place_indexed(allocations, + compute_conflict_indices(allocations)); } FirstFitPlacer::FirstFitPlacer(std::vector allocations) : allocations_(std::move(allocations)), - indices_(compute_overlap_indices(allocations_)), - overlaps_(overlaps_from_indices(allocations_, indices_)) { + indices_(compute_conflict_indices(allocations_)), + conflicts_(conflict_map_from_indices(allocations_, indices_)) { check_total_size(allocations_); } +void FirstFitPlacer::check_order(const std::vector& order) const { + std::vector seen(allocations_.size()); + for (size_t idx : order) { + if (idx >= allocations_.size()) { + throw std::invalid_argument( + "order index " + std::to_string(idx) + " out of range for " + + std::to_string(allocations_.size()) + " allocations"); + } + if (seen[idx]) { + throw std::invalid_argument("order index " + std::to_string(idx) + + " appears more than once"); + } + seen[idx] = true; + } +} + std::vector> FirstFitPlacer::place_offsets( const std::vector& order) const { std::vector> offsets(allocations_.size()); std::vector> spans; for (size_t idx : order) { - const Allocation& alloc = allocations_.at(idx); + const Allocation& alloc = allocations_[idx]; gather_spans(indices_[idx], offsets, allocations_, spans); offsets[idx] = first_fit_offset(alloc.size(), spans); } @@ -306,6 +299,7 @@ std::vector> FirstFitPlacer::place_offsets( std::vector FirstFitPlacer::place( const std::vector& order) const { + check_order(order); const auto offsets = place_offsets(order); std::vector placed; placed.reserve(order.size()); @@ -315,7 +309,8 @@ std::vector FirstFitPlacer::place( return placed; } -int64_t FirstFitPlacer::evaluate(const std::vector& order) const { +int64_t FirstFitPlacer::peak(const std::vector& order) const { + check_order(order); const auto offsets = place_offsets(order); int64_t peak = 0; for (size_t idx : order) { diff --git a/src/cpp/allocators/first_fit.hpp b/src/cpp/allocators/first_fit.hpp index 0f2d92f..4ebf546 100644 --- a/src/cpp/allocators/first_fit.hpp +++ b/src/cpp/allocators/first_fit.hpp @@ -43,23 +43,23 @@ struct PortfolioPlacement { [[nodiscard]] PortfolioPlacement place_portfolio( const std::vector& allocations, const CsrAdjacency& adj); -// Greedily place allocations in order using first-fit, reusing a precomputed -// overlap map +// Greedily place allocations in input order using first-fit; computes the +// conflict relation natively (unbudgeted by design: placement kernels never +// give up mid-run). Map reuse across many orders is FirstFitPlacer's job. [[nodiscard]] std::vector first_fit_place( - const std::vector& allocations, - const TemporalOverlaps& overlaps); + const std::vector& allocations); // Greedily place allocations in order using first-fit over an index-based // adjacency (the fast path: each step only visits the allocation's neighbors) [[nodiscard]] std::vector first_fit_place_indexed( - const std::vector& allocations, const OverlapIndices& indices); + const std::vector& allocations, const ConflictIndices& indices); // Shared placement skeleton of the first-fit and best-fit placers: place // allocations in index order, choosing each offset with `choose_offset` over // the sorted spans of the allocation's already-placed neighbors template [[nodiscard]] std::vector place_indexed( - const std::vector& allocations, const OverlapIndices& indices, + const std::vector& allocations, const ConflictIndices& indices, OffsetFn choose_offset) { check_total_size(allocations); std::vector> offsets(allocations.size()); @@ -75,33 +75,40 @@ template } // Resident first-fit placer for the order-search allocators (genetic, random, -// hill-climb): owns the allocations and their overlap maps (computed once) so +// hill-climb): owns the allocations and their conflict maps (computed once) so // that placing many candidate orderings only passes an index permutation across -// the Python boundary, never re-marshaling the loop-invariant overlap map. +// the Python boundary, never re-marshaling the loop-invariant conflict map. class FirstFitPlacer { public: explicit FirstFitPlacer(std::vector allocations); - // Peak memory (highest end offset) of a first-fit placement in `order`. - [[nodiscard]] int64_t evaluate(const std::vector& order) const; + // Peak memory (highest end offset) of a first-fit placement in `order`; + // throws std::invalid_argument on out-of-range or repeated order indices. + [[nodiscard]] int64_t peak(const std::vector& order) const; - // First-fit placement of the allocations taken in `order`, in that order. + // First-fit placement of the allocations taken in `order`, in that order; + // throws std::invalid_argument on out-of-range or repeated order indices. [[nodiscard]] std::vector place( const std::vector& order) const; - // The resident overlap map, keyed by allocation id. - [[nodiscard]] const TemporalOverlaps& overlaps() const noexcept { - return overlaps_; + // The resident conflict map, keyed by allocation id. + [[nodiscard]] const ConflictMap& conflicts() const noexcept { + return conflicts_; } private: - // Offsets (indexed like allocations_) of a first-fit placement in `order`. + // Throw std::invalid_argument unless every index in `order` is in range + // and no index repeats. + void check_order(const std::vector& order) const; + + // Offsets (indexed like allocations_) of a first-fit placement in `order`; + // assumes `order` has been checked. [[nodiscard]] std::vector> place_offsets( const std::vector& order) const; std::vector allocations_; - OverlapIndices indices_; - TemporalOverlaps overlaps_; // derived from indices_, initialized after it + ConflictIndices indices_; + ConflictMap conflicts_; // derived from indices_, initialized after it }; } // namespace omnimalloc diff --git a/src/cpp/allocators/greedy.cpp b/src/cpp/allocators/greedy.cpp deleted file mode 100644 index d4a3545..0000000 --- a/src/cpp/allocators/greedy.cpp +++ /dev/null @@ -1,27 +0,0 @@ -// -// SPDX-License-Identifier: Apache-2.0 -// - -#include "greedy.hpp" - -#include "first_fit.hpp" - -namespace omnimalloc { - -std::vector GreedyAllocator::allocate( - const std::vector& allocations) const { - return first_fit_place_indexed(allocations, - compute_overlap_indices(allocations)); -} - -} // namespace omnimalloc - -namespace std { - -size_t hash::operator()( - const omnimalloc::GreedyAllocator&) const noexcept { - // Stateless class - all instances are equal, use constant hash - return 0x9e3779b9; // arbitrary constant -} - -} // namespace std diff --git a/src/cpp/allocators/greedy.hpp b/src/cpp/allocators/greedy.hpp deleted file mode 100644 index e28939e..0000000 --- a/src/cpp/allocators/greedy.hpp +++ /dev/null @@ -1,31 +0,0 @@ -// -// SPDX-License-Identifier: Apache-2.0 -// - -#pragma once - -#include - -#include "primitives/allocation.hpp" - -namespace omnimalloc { - -class GreedyAllocator { - public: - GreedyAllocator() = default; - - // Allocate the given allocations using a first-fit greedy strategy - std::vector allocate( - const std::vector& allocations) const; - - bool operator==(const GreedyAllocator&) const noexcept = default; -}; - -} // namespace omnimalloc - -namespace std { -template <> -struct hash { - size_t operator()(const omnimalloc::GreedyAllocator&) const noexcept; -}; -} // namespace std diff --git a/src/cpp/allocators/local_search.cpp b/src/cpp/allocators/local_search.cpp index 663ea92..df8abe8 100644 --- a/src/cpp/allocators/local_search.cpp +++ b/src/cpp/allocators/local_search.cpp @@ -42,11 +42,10 @@ std::vector initial_order(const std::vector& allocations) { std::vector earlier_neighbors( const std::vector& order, size_t target_pos, - const std::vector& allocations, - const TemporalOverlaps& overlaps) { + const std::vector& allocations, const ConflictMap& conflicts) { std::vector neighbors; - auto it = overlaps.find(allocations[order[target_pos]].id()); - if (it != overlaps.end()) { + auto it = conflicts.find(allocations[order[target_pos]].id()); + if (it != conflicts.end()) { for (size_t pos = 0; pos < target_pos; ++pos) { if (it->second.count(allocations[order[pos]].id())) { neighbors.push_back(pos); @@ -62,12 +61,12 @@ std::vector earlier_neighbors( std::optional> propose_peak_swap( const std::vector& peaks, const std::vector& order, - const std::vector& allocations, - const TemporalOverlaps& overlaps, std::mt19937_64& rng) { + const std::vector& allocations, const ConflictMap& conflicts, + std::mt19937_64& rng) { std::uniform_int_distribution pick_peak(0, peaks.size() - 1); const size_t target_pos = peaks[pick_peak(rng)]; const std::vector neighbors = - earlier_neighbors(order, target_pos, allocations, overlaps); + earlier_neighbors(order, target_pos, allocations, conflicts); if (neighbors.empty()) { return std::nullopt; } diff --git a/src/cpp/allocators/local_search.hpp b/src/cpp/allocators/local_search.hpp index 7cd1191..2662df4 100644 --- a/src/cpp/allocators/local_search.hpp +++ b/src/cpp/allocators/local_search.hpp @@ -28,19 +28,18 @@ namespace omnimalloc { [[nodiscard]] std::vector initial_order( const std::vector& allocations); -// Positions before `target_pos` in `order` whose allocation temporally -// overlaps the one at `target_pos`, or every earlier position if none do. +// Positions before `target_pos` in `order` whose allocation conflicts with +// the one at `target_pos`, or every earlier position if none do. [[nodiscard]] std::vector earlier_neighbors( const std::vector& order, size_t target_pos, - const std::vector& allocations, - const TemporalOverlaps& overlaps); + const std::vector& allocations, const ConflictMap& conflicts); // One random peak-lowering move for the local searches: a random position // among `peaks` paired with a random earlier temporal neighbor, or nullopt // when the chosen target has no earlier position to swap with. [[nodiscard]] std::optional> propose_peak_swap( const std::vector& peaks, const std::vector& order, - const std::vector& allocations, - const TemporalOverlaps& overlaps, std::mt19937_64& rng); + const std::vector& allocations, const ConflictMap& conflicts, + std::mt19937_64& rng); } // namespace omnimalloc diff --git a/src/cpp/allocators/omni.cpp b/src/cpp/allocators/omni.cpp index 3a03472..c21f69d 100644 --- a/src/cpp/allocators/omni.cpp +++ b/src/cpp/allocators/omni.cpp @@ -5,15 +5,14 @@ #include "omni.hpp" #include -#include #include "analysis/linearize.hpp" #include "first_fit.hpp" namespace omnimalloc { -std::vector OmniAllocator::allocate( - const std::vector& allocations) const { +std::vector omni_place(const std::vector& allocations, + std::optional linearize_budget) { if (allocations.empty()) { return {}; } @@ -28,7 +27,7 @@ std::vector OmniAllocator::allocate( std::ranges::all_of(allocations, &Allocation::is_scalar_time); std::optional> surrogates; if (!all_scalar) { - surrogates = try_linearize(allocations, linearize_budget_); + surrogates = try_linearize(allocations, linearize_budget); } const std::vector& problem = surrogates.has_value() ? *surrogates : allocations; @@ -45,12 +44,3 @@ std::vector OmniAllocator::allocate( } } // namespace omnimalloc - -namespace std { - -size_t hash::operator()( - const omnimalloc::OmniAllocator& allocator) const noexcept { - return hash>{}(allocator.linearize_budget()); -} - -} // namespace std diff --git a/src/cpp/allocators/omni.hpp b/src/cpp/allocators/omni.hpp index 5395a3e..3634b47 100644 --- a/src/cpp/allocators/omni.hpp +++ b/src/cpp/allocators/omni.hpp @@ -12,35 +12,14 @@ namespace omnimalloc { -// Generalized greedy-portfolio allocator for scalar and vector-clock +// Generalized greedy-portfolio placement for scalar and vector-clock // lifetimes: linearizes vector time to surrogate scalars when the // happens-before order allows (bounded by `linearize_budget`; nullopt means // unbounded), otherwise places truthfully on the vector conflict graph. // Either way the winning first-fit order of the 7-order portfolio decides // the offsets. -class OmniAllocator { - public: - explicit OmniAllocator(std::optional linearize_budget) - : linearize_budget_(linearize_budget) {} - - std::vector allocate( - const std::vector& allocations) const; - - [[nodiscard]] std::optional linearize_budget() const noexcept { - return linearize_budget_; - } - - bool operator==(const OmniAllocator&) const noexcept = default; - - private: - std::optional linearize_budget_; -}; +[[nodiscard]] std::vector omni_place( + const std::vector& allocations, + std::optional linearize_budget); } // namespace omnimalloc - -namespace std { -template <> -struct hash { - size_t operator()(const omnimalloc::OmniAllocator&) const noexcept; -}; -} // namespace std diff --git a/src/cpp/allocators/simulated_annealing.cpp b/src/cpp/allocators/simulated_annealing.cpp index 9e4d47a..80e5b37 100644 --- a/src/cpp/allocators/simulated_annealing.cpp +++ b/src/cpp/allocators/simulated_annealing.cpp @@ -14,12 +14,9 @@ namespace omnimalloc { -SimulatedAnnealingAllocator::SimulatedAnnealingAllocator( - SimulatedAnnealingConfig config) - : config_(config) {} - -std::vector SimulatedAnnealingAllocator::allocate( - const std::vector& allocations) const { +std::vector simulated_annealing_place( + const std::vector& allocations, + const SimulatedAnnealingConfig& config) { const FirstFitPlacer placer(allocations); std::vector order = initial_order(allocations); if (allocations.size() < 2) { @@ -31,13 +28,13 @@ std::vector SimulatedAnnealingAllocator::allocate( std::vector best_placed = current_placed; int64_t best_peak = current_peak; - std::mt19937_64 rng(config_.seed); + std::mt19937_64 rng(config.seed); std::uniform_real_distribution unit(0.0, 1.0); - double temperature = config_.initial_temperature; + double temperature = config.initial_temperature; - const auto deadline = make_deadline(config_.timeout); + const auto deadline = make_deadline(config.timeout); - for (int iteration = 0; iteration < config_.max_iterations; ++iteration) { + for (int iteration = 0; iteration < config.max_iterations; ++iteration) { if (deadline_expired(deadline)) { break; } @@ -49,9 +46,9 @@ std::vector SimulatedAnnealingAllocator::allocate( } const auto proposal = - propose_peak_swap(peaks, order, allocations, placer.overlaps(), rng); + propose_peak_swap(peaks, order, allocations, placer.conflicts(), rng); if (!proposal) { - temperature *= config_.cooling_rate; + temperature *= config.cooling_rate; continue; } const auto [target_pos, other_pos] = *proposal; @@ -79,7 +76,7 @@ std::vector SimulatedAnnealingAllocator::allocate( std::swap(order[target_pos], order[other_pos]); // undo the rejected swap } - temperature *= config_.cooling_rate; + temperature *= config.cooling_rate; } return best_placed; diff --git a/src/cpp/allocators/simulated_annealing.hpp b/src/cpp/allocators/simulated_annealing.hpp index 62ab5fe..2d01c5e 100644 --- a/src/cpp/allocators/simulated_annealing.hpp +++ b/src/cpp/allocators/simulated_annealing.hpp @@ -12,9 +12,9 @@ namespace omnimalloc { -// Cooling schedule and iteration budget for `SimulatedAnnealingAllocator`. -// Defaults live in the Python `SimulatedAnnealingConfig` dataclass; every -// field must be set explicitly. +// Cooling schedule and iteration budget for `simulated_annealing_place`. +// Policy defaults live on the Python `SimulatedAnnealingAllocator`; every +// field crosses the boundary explicitly. struct SimulatedAnnealingConfig { uint64_t seed{}; int max_iterations{}; @@ -35,15 +35,8 @@ struct SimulatedAnnealingConfig { // entire search runs natively (no Python round trip per candidate), so it can // evaluate far more candidate placements per second than an // equivalent Python-orchestrated local search. -class SimulatedAnnealingAllocator { - public: - explicit SimulatedAnnealingAllocator(SimulatedAnnealingConfig config); - - [[nodiscard]] std::vector allocate( - const std::vector& allocations) const; - - private: - SimulatedAnnealingConfig config_; -}; +[[nodiscard]] std::vector simulated_annealing_place( + const std::vector& allocations, + const SimulatedAnnealingConfig& config); } // namespace omnimalloc diff --git a/src/cpp/allocators/supermalloc/partition.cpp b/src/cpp/allocators/supermalloc.cpp similarity index 95% rename from src/cpp/allocators/supermalloc/partition.cpp rename to src/cpp/allocators/supermalloc.cpp index 384b82c..c235b99 100644 --- a/src/cpp/allocators/supermalloc/partition.cpp +++ b/src/cpp/allocators/supermalloc.cpp @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // -#include "partition.hpp" +#include "supermalloc.hpp" #include #include @@ -35,6 +35,18 @@ namespace omnimalloc { namespace { +// Placed allocations from an index-aligned offset vector; the Solution shape. +std::vector apply_offsets( + const std::vector& allocations, + const std::vector& offsets) { + std::vector placed; + placed.reserve(allocations.size()); + for (size_t i = 0; i < allocations.size(); ++i) { + placed.push_back(allocations[i].with_offset(offsets[i])); + } + return placed; +} + constexpr int kExitEvent = 0; constexpr int kEnterEvent = 1; @@ -498,7 +510,7 @@ Solution Partition::first_fit(const std::vector& order) const { offsets[i] = offset; height = std::max(height, offset + size); } - return Solution{data_->allocations, std::move(offsets), height}; + return Solution{apply_offsets(data_->allocations, offsets), height}; } Solution Partition::greedy_pack(const std::string& heuristic) const { @@ -695,18 +707,15 @@ struct SearchCtx { }; // Concatenate sub-part solutions; the sub-parts are disjoint by construction. -Solution merge_solutions(std::vector subs, int64_t height) { +Solution merge_solutions(std::vector subs, int64_t peak) { size_t total = 0; for (const Solution& s : subs) total += s.allocations.size(); - Solution merged{{}, {}, height}; + Solution merged{{}, peak}; merged.allocations.reserve(total); - merged.offsets.reserve(total); for (Solution& s : subs) { std::move(s.allocations.begin(), s.allocations.end(), std::back_inserter(merged.allocations)); - merged.offsets.insert(merged.offsets.end(), s.offsets.begin(), - s.offsets.end()); } return merged; } @@ -740,7 +749,7 @@ void solve_decomposed(std::vector& sub_parts, Partition& node, std::optional sub_best; solve_dfs(sub_parts[i], 0, 0, ctx, sub_best, nullptr); if (!sub_best) return; // no packing below the bound: merge is final - sub_heights[i] = sub_best->height; + sub_heights[i] = sub_best->peak; sub_solutions[i] = std::move(*sub_best); } merged_height = std::max(merged_height, sub_heights[i]); @@ -769,7 +778,7 @@ void solve_dfs(Partition& node, int64_t min_offset, int min_idx, SearchCtx& ctx, if (node.is_allocated()) { const int64_t h = node.height(); if (h < node.best_height()) { - best = Solution{node.allocations(), node.offsets(), h}; + best = Solution{apply_offsets(node.allocations(), node.offsets()), h}; node.set_best_height(h); lower_shared(shared, h); } @@ -956,11 +965,12 @@ std::chrono::steady_clock::time_point compute_deadline( } // namespace -Solution greedy_many(const Partition& partition, - const std::vector& heuristics, - std::optional timeout, int num_threads) { +Solution greedy_pack_portfolio(const Partition& partition, + const std::vector& heuristics, + std::optional timeout, int num_threads) { if (heuristics.empty()) { - throw std::invalid_argument("greedy_many requires at least one heuristic"); + throw std::invalid_argument( + "greedy_pack_portfolio requires at least one heuristic"); } validate_heuristics(heuristics); @@ -982,18 +992,20 @@ Solution greedy_many(const Partition& partition, std::optional best; for (auto& r : results) { - if (r && (!best || r->height < best->height)) best = std::move(*r); + if (r && (!best || r->peak < best->peak)) best = std::move(*r); } return std::move(*best); } -std::optional solve_many(const std::vector& partitions, - int64_t node_limit, - std::optional timeout, - int64_t best_bound, SearchOptions options, - int num_threads) { +std::optional try_solve_many(const std::vector& partitions, + int64_t best_bound, + std::optional max_nodes, + SearchOptions options, + std::optional timeout, + int num_threads) { if (partitions.empty()) return std::nullopt; + const int64_t node_limit = max_nodes.value_or(INT64_MAX); const auto deadline = compute_deadline(timeout); std::atomic shared_best{best_bound}; std::vector> results(partitions.size()); @@ -1015,7 +1027,7 @@ std::optional solve_many(const std::vector& partitions, std::optional best; for (auto& r : results) { - if (r && (!best || r->height < best->height)) best = std::move(*r); + if (r && (!best || r->peak < best->peak)) best = std::move(*r); } return best; } diff --git a/src/cpp/allocators/supermalloc/partition.hpp b/src/cpp/allocators/supermalloc.hpp similarity index 89% rename from src/cpp/allocators/supermalloc/partition.hpp rename to src/cpp/allocators/supermalloc.hpp index 25a9ba6..4289f53 100644 --- a/src/cpp/allocators/supermalloc/partition.hpp +++ b/src/cpp/allocators/supermalloc.hpp @@ -17,20 +17,22 @@ namespace omnimalloc { -// Per-rule toggles for the branch-and-bound pruning rules; all default on. +// Per-rule toggles for the branch-and-bound pruning rules. Default-free: +// every field crosses the binding boundary explicitly (the Python face +// always enables all five; ablations set them through _cpp.try_solve_many). struct SearchOptions { - bool canonical = true; - bool dominance = true; - bool floor_inference = true; - bool monotonic_floor = true; - bool decompose = true; + bool canonical; + bool dominance; + bool floor_inference; + bool monotonic_floor; + bool decompose; }; -// Search result; `offsets` is index-aligned with `allocations`. +// Search result: the placed allocations (offsets applied) and their peak, +// the placement's max height. struct Solution { std::vector allocations; - std::vector offsets; - int64_t height; + int64_t peak; }; // A temporal allocation problem: immutable structural data (sections, @@ -213,19 +215,19 @@ class Partition { // skipped, except the first, so a packing always exists (nullopt disables // the deadline). Throws std::invalid_argument when `heuristics` is empty or // contains an unknown sort key. -[[nodiscard]] Solution greedy_many(const Partition& partition, - const std::vector& heuristics, - std::optional timeout, - int num_threads); +[[nodiscard]] Solution greedy_pack_portfolio( + const Partition& partition, const std::vector& heuristics, + std::optional timeout, int num_threads); // Run `partitions` (typically the same problem under different heuristic // orderings) as an independent-search portfolio across `num_threads`, sharing // one atomic best bound so a solution found by any search prunes the others. -// `node_limit` caps each member's search independently. Returns the lowest -// solution found, or nullopt if none beats `best_bound`. -[[nodiscard]] std::optional solve_many( - const std::vector& partitions, int64_t node_limit, - std::optional timeout, int64_t best_bound, SearchOptions options, - int num_threads); +// `max_nodes` caps each member's search independently (nullopt = unbounded). +// Returns the lowest solution found, or nullopt if none beats `best_bound` — +// a valid answer, hence the try_ prefix. +[[nodiscard]] std::optional try_solve_many( + const std::vector& partitions, int64_t best_bound, + std::optional max_nodes, SearchOptions options, + std::optional timeout, int num_threads); } // namespace omnimalloc diff --git a/src/cpp/allocators/tabu_search.cpp b/src/cpp/allocators/tabu_search.cpp index afea707..7b390a1 100644 --- a/src/cpp/allocators/tabu_search.cpp +++ b/src/cpp/allocators/tabu_search.cpp @@ -27,11 +27,9 @@ int64_t tabu_key(size_t a, size_t b, size_t num_allocations) { } // namespace -TabuSearchAllocator::TabuSearchAllocator(TabuSearchConfig config) - : config_(config) {} - -std::vector TabuSearchAllocator::allocate( - const std::vector& allocations) const { +std::vector tabu_search_place( + const std::vector& allocations, + const TabuSearchConfig& config) { const FirstFitPlacer placer(allocations); std::vector order = initial_order(allocations); if (allocations.size() < 2) { @@ -45,12 +43,12 @@ std::vector TabuSearchAllocator::allocate( std::vector best_placed = current_placed; int64_t best_peak = current_peak; - std::mt19937_64 rng(config_.seed); + std::mt19937_64 rng(config.seed); std::unordered_map tabu_until; - const auto deadline = make_deadline(config_.timeout); + const auto deadline = make_deadline(config.timeout); - for (int iteration = 0; iteration < config_.max_iterations; ++iteration) { + for (int iteration = 0; iteration < config.max_iterations; ++iteration) { if (deadline_expired(deadline)) { break; } @@ -69,9 +67,9 @@ std::vector TabuSearchAllocator::allocate( std::vector best_candidate_placed; bool best_is_tabu = false; - for (int sample = 0; sample < config_.neighborhood_size; ++sample) { + for (int sample = 0; sample < config.neighborhood_size; ++sample) { const auto proposal = - propose_peak_swap(peaks, order, allocations, placer.overlaps(), rng); + propose_peak_swap(peaks, order, allocations, placer.conflicts(), rng); if (!proposal) { continue; } @@ -107,7 +105,7 @@ std::vector TabuSearchAllocator::allocate( current_peak = best_candidate_peak; if (!best_is_tabu) { tabu_until[tabu_key(order[best_p1], order[best_p2], n)] = - iteration + config_.tabu_tenure; + iteration + config.tabu_tenure; } if (current_peak < best_peak) { diff --git a/src/cpp/allocators/tabu_search.hpp b/src/cpp/allocators/tabu_search.hpp index 91968d7..04a57a0 100644 --- a/src/cpp/allocators/tabu_search.hpp +++ b/src/cpp/allocators/tabu_search.hpp @@ -13,8 +13,8 @@ namespace omnimalloc { // Neighborhood size, iteration budget, and tabu memory for -// `TabuSearchAllocator`. Defaults live in the Python `TabuSearchConfig` -// dataclass; every field must be set explicitly. +// `tabu_search_place`. Policy defaults live on the Python +// `TabuSearchAllocator`; every field crosses the boundary explicitly. struct TabuSearchConfig { uint64_t seed{}; int max_iterations{}; @@ -35,16 +35,8 @@ struct TabuSearchConfig { // being immediately reversed for `tabu_tenure` iterations, which lets the // search climb out of local optima without cycling between the same two // orders. Runs entirely in C++ for the same reason as -// `SimulatedAnnealingAllocator`: no Python round trip per candidate. -class TabuSearchAllocator { - public: - explicit TabuSearchAllocator(TabuSearchConfig config); - - [[nodiscard]] std::vector allocate( - const std::vector& allocations) const; - - private: - TabuSearchConfig config_; -}; +// `simulated_annealing_place`: no Python round trip per candidate. +[[nodiscard]] std::vector tabu_search_place( + const std::vector& allocations, const TabuSearchConfig& config); } // namespace omnimalloc diff --git a/src/cpp/allocators/telamalloc.cpp b/src/cpp/allocators/telamalloc.cpp index 15f46ff..ce96bec 100644 --- a/src/cpp/allocators/telamalloc.cpp +++ b/src/cpp/allocators/telamalloc.cpp @@ -29,7 +29,7 @@ constexpr int64_t kUnbounded = std::numeric_limits::max() / 4; // Connected components of the overlap graph: the paper's "phases". Buffers // in different components never interact, so each packs independently. -std::vector> build_phases(const OverlapIndices& neighbors) { +std::vector> build_phases(const ConflictIndices& neighbors) { const int n = static_cast(neighbors.size()); std::vector> phases; std::vector visited(n, 0); @@ -101,9 +101,10 @@ QueueKey queue_key(const Allocation& alloc, int idx, int evictions, // `capacity` at least the phase's max buffer size guarantees offset 0 is // always a repair candidate, so every iteration makes progress. std::optional> pack_phase( - const std::vector& allocations, const OverlapIndices& neighbors, - const std::vector& phase, int64_t capacity, int max_backtracks, - const Deadline& deadline, bool size_major, uint64_t seed) { + const std::vector& allocations, + const ConflictIndices& neighbors, const std::vector& phase, + int64_t capacity, int max_backtracks, const Deadline& deadline, + bool size_major, uint64_t seed) { std::vector offsets(allocations.size(), -1); std::vector evictions(allocations.size(), 0); std::mt19937_64 rng(seed); @@ -245,9 +246,10 @@ int64_t phase_peak(const std::vector& allocations, // treated as infeasible even though they are only budget-exhausted, keeping // the search anytime rather than exact. void solve_phase(const std::vector& allocations, - const OverlapIndices& neighbors, const std::vector& phase, - int64_t lower_bound, const TelamallocConfig& config, - const Deadline& deadline, std::vector& result) { + const ConflictIndices& neighbors, + const std::vector& phase, int64_t lower_bound, + const TelamallocConfig& config, const Deadline& deadline, + std::vector& result) { // Unbounded capacity never conflicts, so these incumbents are plain // first-fit in each tiered order and cannot fail. The winner's order also // steers the capacity search below. @@ -287,23 +289,21 @@ void solve_phase(const std::vector& allocations, } // namespace -TelamallocAllocator::TelamallocAllocator(TelamallocConfig config) - : config_(config) {} - -std::vector TelamallocAllocator::allocate( - const std::vector& allocations) const { +std::vector telamalloc_place( + const std::vector& allocations, + const TelamallocConfig& config) { // The event sweeps and load bounds need a linear timeline; reject vector // clocks here. - require_scalar_time(allocations, "TelamallocAllocator"); + require_scalar_time(allocations, "telamalloc_place"); // Bound by kUnbounded so the unbounded-capacity pack_phase incumbents in // solve_phase can never fail and the cursor arithmetic cannot overflow. check_total_size(allocations, kUnbounded); - const OverlapIndices neighbors = compute_overlap_indices(allocations); + const ConflictIndices neighbors = compute_conflict_indices(allocations); if (allocations.size() < 2) { return first_fit_place_indexed(allocations, neighbors); } - const Deadline deadline = make_deadline(config_.timeout); + const Deadline deadline = make_deadline(config.timeout); const auto phases = build_phases(neighbors); // Solve phases in descending load order: the global peak is the max over @@ -319,7 +319,7 @@ std::vector TelamallocAllocator::allocate( std::vector result(allocations.size(), -1); for (const auto& [lower_bound, p] : order) { - solve_phase(allocations, neighbors, phases[p], lower_bound, config_, + solve_phase(allocations, neighbors, phases[p], lower_bound, config, deadline, result); } diff --git a/src/cpp/allocators/telamalloc.hpp b/src/cpp/allocators/telamalloc.hpp index 0cccb8c..232ee45 100644 --- a/src/cpp/allocators/telamalloc.hpp +++ b/src/cpp/allocators/telamalloc.hpp @@ -12,8 +12,8 @@ namespace omnimalloc { -// Search budgets for `TelamallocAllocator`. Defaults live in the Python -// `TelamallocConfig` dataclass; every field must be set explicitly. +// Search budgets for `telamalloc_place`. Policy defaults live on the Python +// `TelamallocAllocator`; every field crosses the boundary explicitly. struct TelamallocConfig { // Seeds the random-walk step of the conflict repair (see `pack_phase`); // results are deterministic for a fixed seed. @@ -21,34 +21,26 @@ struct TelamallocConfig { // Eviction (backtrack) budget per capacity attempt; an attempt that // exhausts it reports the capacity as unreachable. int max_backtracks{}; - // Wall-clock budget for the whole allocate(); nullopt disables it, leaving + // Wall-clock budget for the whole placement; nullopt disables it, leaving // the per-attempt `max_backtracks` as the only bound. std::optional timeout; }; -// TelaMalloc-style allocator after Maas et al., ASPLOS 2023 ("TelaMalloc: +// TelaMalloc-style placement after Maas et al., ASPLOS 2023 ("TelaMalloc: // Efficient On-Chip Memory Allocation for Production Machine Learning // Accelerators"). The paper packs buffers below a fixed on-chip capacity by // interleaving a tiered buffer-ordering heuristic with constraint-solver // feedback and conflict-directed backtracking. This adaptation minimizes // peak memory instead: it splits the problem into phases (connected -// components of the temporal-overlap graph), packs each phase in the -// paper's tiered order (longest lifetime, then largest size) with -// min-conflict eviction as the backtracking mechanism, and binary-searches -// each phase's capacity between its load lower bound and a first-fit -// incumbent. Evicted buffers re-enter the queue with raised priority -// (squeaky-wheel), standing in for the paper's minor/major backtracking -// levels; an occasional seeded random-walk repair breaks the cycles a purely -// deterministic min-conflict search can fall into. -class TelamallocAllocator { - public: - explicit TelamallocAllocator(TelamallocConfig config); - - [[nodiscard]] std::vector allocate( - const std::vector& allocations) const; - - private: - TelamallocConfig config_; -}; +// components of the conflict graph), packs each phase in the paper's tiered +// order (longest lifetime, then largest size) with min-conflict eviction as +// the backtracking mechanism, and binary-searches each phase's capacity +// between its load lower bound and a first-fit incumbent. Evicted buffers +// re-enter the queue with raised priority (squeaky-wheel), standing in for +// the paper's minor/major backtracking levels; an occasional seeded +// random-walk repair breaks the cycles a purely deterministic min-conflict +// search can fall into. +[[nodiscard]] std::vector telamalloc_place( + const std::vector& allocations, const TelamallocConfig& config); } // namespace omnimalloc diff --git a/src/cpp/analysis/antichain.cpp b/src/cpp/analysis/antichain.cpp index 85aec19..ba92f1c 100644 --- a/src/cpp/analysis/antichain.cpp +++ b/src/cpp/analysis/antichain.cpp @@ -164,8 +164,8 @@ int64_t max_antichain(const std::vector>& start_rows, const size_t m = ends.count(); if (work_budget && static_cast(k) * m * d > *work_budget) { throw std::runtime_error( - "Antichain flow work exceeds work_budget; rerun without a budget " - "for the unbounded exact query"); + "Antichain flow work exceeds work_budget; pass None to always " + "compute the exact pressure"); } // Dominance edges end row -> start row, pruned to the suffix with @@ -260,7 +260,7 @@ int64_t antichain_pressure(const std::vector& allocations, std::numeric_limits::max(), work_budget); } -std::vector per_allocation_antichain_pressure( +std::vector antichain_pressure_per_allocation( const std::vector& allocations, std::optional work_budget) { const size_t n = allocations.size(); diff --git a/src/cpp/analysis/antichain.hpp b/src/cpp/analysis/antichain.hpp index 686d4b6..72b92aa 100644 --- a/src/cpp/analysis/antichain.hpp +++ b/src/cpp/analysis/antichain.hpp @@ -37,7 +37,7 @@ namespace omnimalloc { // conflict neighborhood — built for certification, not the 10k+ hot path. // A set `work_budget` bounds the linearize attempt and each pinned flow // (see antichain_pressure); the flow path throws once it is exceeded. -[[nodiscard]] std::vector per_allocation_antichain_pressure( +[[nodiscard]] std::vector antichain_pressure_per_allocation( const std::vector& allocations, std::optional work_budget); diff --git a/src/cpp/analysis/clock.hpp b/src/cpp/analysis/clock.hpp index aed31cd..c26959f 100644 --- a/src/cpp/analysis/clock.hpp +++ b/src/cpp/analysis/clock.hpp @@ -33,8 +33,8 @@ inline size_t checked_dim(const std::vector& allocations) { for (const Allocation& alloc : allocations) { if (alloc.dim() != dim) { throw std::invalid_argument( - "clock dimension mismatch: " + std::to_string(dim) + " vs " + - std::to_string(alloc.dim())); + "allocations must share one clock dimension, got " + + std::to_string(dim) + " and " + std::to_string(alloc.dim())); } } return dim; diff --git a/src/cpp/analysis/closure.cpp b/src/cpp/analysis/closure.cpp index 9b4c38f..df77cf4 100644 --- a/src/cpp/analysis/closure.cpp +++ b/src/cpp/analysis/closure.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include #include @@ -25,9 +27,9 @@ namespace omnimalloc { namespace { // The join-closure of the group birth clocks as a flat num_cuts x d arena, -// or nullopt once it exceeds `closure_cap`. +// or nullopt once it exceeds a set `closure_cap`. std::optional> build_cut_arena( - const LifetimeGroups& groups, size_t d, size_t closure_cap) { + const LifetimeGroups& groups, size_t d, std::optional closure_cap) { std::vector arena; const auto row = [&](size_t idx) { return arena.data() + idx * d; }; const auto hash_row = [&](size_t idx) { @@ -57,7 +59,7 @@ std::optional> build_cut_arena( for (size_t i = 0; i < groups.count(); ++i) { arena.insert(arena.end(), groups.starts[i].begin(), groups.starts[i].end()); if (insert_tail()) { - if (cuts.size() > closure_cap) { + if (closure_cap && cuts.size() > *closure_cap) { return std::nullopt; } births.push_back(arena.size() / d - 1); @@ -75,7 +77,7 @@ std::optional> build_cut_arena( arena[tail + t] = std::max(row(cut)[t], row(birth)[t]); } if (insert_tail()) { - if (cuts.size() > closure_cap) { + if (closure_cap && cuts.size() > *closure_cap) { return std::nullopt; } frontier.push_back(arena.size() / d - 1); @@ -113,10 +115,22 @@ std::vector> scalar_times( return times; } +// The arena, or the closure_cap throw shared by both entry points. +std::vector checked_cut_arena(const LifetimeGroups& groups, size_t d, + std::optional closure_cap) { + auto arena = build_cut_arena(groups, d, closure_cap); + if (!arena.has_value()) { + throw std::runtime_error( + "Join closure exceeded closure_cap; raise the cap or pass None for " + "the unbounded enumeration"); + } + return std::move(*arena); +} + } // namespace -std::optional closure_pressure( - const std::vector& allocations, size_t closure_cap) { +int64_t closure_pressure(const std::vector& allocations, + std::optional closure_cap) { if (allocations.empty()) { return 0; } @@ -129,19 +143,17 @@ std::optional closure_pressure( return interval_peak(scalar_times(groups), groups.weights); } - const auto arena = build_cut_arena(groups, d, closure_cap); - if (!arena.has_value()) { - return std::nullopt; - } - const std::vector live = live_weights(*arena, groups, d); + const std::vector arena = checked_cut_arena(groups, d, closure_cap); + const std::vector live = live_weights(arena, groups, d); return *std::ranges::max_element(live); } -std::optional> per_allocation_closure_pressure( - const std::vector& allocations, size_t closure_cap) { +std::vector closure_pressure_per_allocation( + const std::vector& allocations, + std::optional closure_cap) { const size_t n = allocations.size(); if (n == 0) { - return std::vector{}; + return {}; } const size_t d = checked_dim(allocations); const LifetimeGroups groups = group_lifetimes(allocations); @@ -151,21 +163,19 @@ std::optional> per_allocation_closure_pressure( if (d == 1) { per_group = interval_peaks(scalar_times(groups), groups.weights); } else { - const auto arena = build_cut_arena(groups, d, closure_cap); - if (!arena.has_value()) { - return std::nullopt; - } - const std::vector live = live_weights(*arena, groups, d); + const std::vector arena = + checked_cut_arena(groups, d, closure_cap); + const std::vector live = live_weights(arena, groups, d); // Peak per group: the heaviest cut where it is live; its own birth cut // always qualifies, so every entry is at least the group weight. - const size_t num_cuts = arena->size() / d; + const size_t num_cuts = arena.size() / d; per_group.resize(g); for_each_row_block(g, parallel_threads(g), [&](size_t i) { const int64_t* start = groups.starts[i].data(); const int64_t* end = groups.ends[i].data(); int64_t best = 0; for (size_t c = 0; c < num_cuts; ++c) { - const int64_t* cut = arena->data() + c * d; + const int64_t* cut = arena.data() + c * d; if (dominates(start, cut, d) && !dominates(end, cut, d)) { best = std::max(best, live[c]); } diff --git a/src/cpp/analysis/closure.hpp b/src/cpp/analysis/closure.hpp index 0078579..701127b 100644 --- a/src/cpp/analysis/closure.hpp +++ b/src/cpp/analysis/closure.hpp @@ -19,18 +19,23 @@ namespace omnimalloc { // joins of observed clocks span the consistent-cut lattice). Note that // pairwise-concurrent allocations need not share a cut, so this can sit // strictly below antichain_pressure; both soundly lower-bound any -// placement's peak. nullopt once the closure exceeds `closure_cap`. -[[nodiscard]] std::optional closure_pressure( - const std::vector& allocations, size_t closure_cap); +// placement's peak. A set `closure_cap` bounds the enumeration, throwing +// std::runtime_error once the closure exceeds it; nullopt enumerates +// unbounded (memory grows with the closure). +[[nodiscard]] int64_t closure_pressure( + const std::vector& allocations, + std::optional closure_cap); // Exact realizable peak while each allocation is live, aligned with // `allocations`: the maximum total size at any join-closure cut where the // allocation is live (every allocation is live at its own birth cut). Can -// sit elementwise strictly below per_allocation_antichain_pressure, since +// sit elementwise strictly below antichain_pressure_per_allocation, since // pairwise-concurrent allocations need not share a cut; the maximum entry -// equals closure_pressure. nullopt once the closure exceeds `closure_cap`. -[[nodiscard]] std::optional> -per_allocation_closure_pressure(const std::vector& allocations, - size_t closure_cap); +// equals closure_pressure. A set `closure_cap` bounds the enumeration, +// throwing std::runtime_error once the closure exceeds it; nullopt +// enumerates unbounded (memory grows with the closure). +[[nodiscard]] std::vector closure_pressure_per_allocation( + const std::vector& allocations, + std::optional closure_cap); } // namespace omnimalloc diff --git a/src/cpp/analysis/conflicts.cpp b/src/cpp/analysis/conflicts.cpp index 5885b39..3aa436a 100644 --- a/src/cpp/analysis/conflicts.cpp +++ b/src/cpp/analysis/conflicts.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -16,7 +17,7 @@ namespace omnimalloc { namespace { // Sweep line over the single timeline; valid only for all-scalar input. -OverlapIndices scalar_overlap_indices( +ConflictIndices scalar_conflict_indices( const std::vector& allocations) { std::vector> events; events.reserve(allocations.size() * 2); @@ -26,14 +27,14 @@ OverlapIndices scalar_overlap_indices( } // Sort events by time; ends sort before starts at equal times, matching the - // half-open interval semantics of Allocation::overlaps_temporally + // half-open interval semantics of Allocation::conflicts_with std::sort(events.begin(), events.end()); - OverlapIndices indices(allocations.size()); + ConflictIndices indices(allocations.size()); std::vector active; for (const auto& [time, is_start, idx] : events) { if (is_start) { - // Current allocation overlaps with all currently active allocations + // Current allocation conflicts with all currently active allocations for (size_t active_idx : active) { indices[idx].push_back(active_idx); indices[active_idx].push_back(idx); @@ -47,6 +48,31 @@ OverlapIndices scalar_overlap_indices( return indices; } +// Degrees on the single timeline without enumerating pairs: allocation i +// conflicts with everything that starts before its end minus everything +// already ended by its start (half-open lifetimes); the -1 removes i itself. +std::vector scalar_conflict_degrees( + const std::vector& allocations) { + const size_t n = allocations.size(); + std::vector starts(n); + std::vector ends(n); + for (size_t i = 0; i < n; ++i) { + starts[i] = allocations[i].start(); + ends[i] = allocations[i].end(); + } + std::ranges::sort(starts); + std::ranges::sort(ends); + std::vector degrees(n); + for (size_t i = 0; i < n; ++i) { + const auto started = + std::ranges::lower_bound(starts, allocations[i].end()) - starts.begin(); + const auto ended = + std::ranges::upper_bound(ends, allocations[i].start()) - ends.begin(); + degrees[i] = started - ended - 1; + } + return degrees; +} + // The pruned pairwise sweep itself lives in analysis/clock.hpp // (ConflictSweep), shared with the exact per-allocation pressure kernels. ConflictSweep build_conflict_sweep(const std::vector& allocations) { @@ -55,10 +81,10 @@ ConflictSweep build_conflict_sweep(const std::vector& allocations) { } // Pairwise happens-before adjacency; the only option for vector clocks -OverlapIndices vector_overlap_indices( +ConflictIndices vector_conflict_indices( const std::vector& allocations) { const CsrAdjacency adj = build_conflict_adjacency(allocations); - OverlapIndices indices(allocations.size()); + ConflictIndices indices(allocations.size()); for (size_t i = 0; i < allocations.size(); ++i) { indices[i].assign(adj.neighbors.begin() + adj.offsets[i], adj.neighbors.begin() + adj.offsets[i + 1]); @@ -74,44 +100,54 @@ CsrAdjacency build_conflict_adjacency( return sweep.adjacency(parallel_threads(sweep.count())); } -TemporalOverlaps overlaps_from_indices( - const std::vector& allocations, const OverlapIndices& indices) { - TemporalOverlaps overlaps; +ConflictMap conflict_map_from_indices( + const std::vector& allocations, + const ConflictIndices& indices) { + ConflictMap map; + map.reserve(allocations.size()); for (size_t i = 0; i < allocations.size(); ++i) { + auto& neighbors = map[allocations[i].id()]; for (size_t j : indices[i]) { - overlaps[allocations[i].id()].insert(allocations[j].id()); + neighbors.insert(allocations[j].id()); } } - return overlaps; + return map; } -OverlapIndices compute_overlap_indices( +ConflictIndices compute_conflict_indices( const std::vector& allocations) { const bool all_scalar = std::ranges::all_of(allocations, &Allocation::is_scalar_time); - return all_scalar ? scalar_overlap_indices(allocations) - : vector_overlap_indices(allocations); + return all_scalar ? scalar_conflict_indices(allocations) + : vector_conflict_indices(allocations); } -std::optional compute_temporal_overlaps( - const std::vector& allocations, - std::optional work_budget) { - // On scalar input the sweep work equals the exact overlap-pair count, the +ConflictMap conflicts(const std::vector& allocations, + std::optional work_budget) { + // On scalar input the sweep work equals the exact conflict-pair count, the // scalar sweep line's dominant cost, so one measure bounds both paths. if (work_budget && build_conflict_sweep(allocations).sweep_work() > *work_budget) { - return std::nullopt; + throw std::runtime_error( + "Conflict sweep work exceeds work_budget; pass None to always " + "compute the relation"); } - return overlaps_from_indices(allocations, - compute_overlap_indices(allocations)); + return conflict_map_from_indices(allocations, + compute_conflict_indices(allocations)); } -std::optional> compute_conflict_degrees( +std::vector conflict_degrees( const std::vector& allocations, std::optional work_budget) { + // Scalar timelines count degrees in O(N log N) via two binary searches per + // allocation; the budget guards only the pair-enumerating vector path. + if (std::ranges::all_of(allocations, &Allocation::is_scalar_time)) { + return scalar_conflict_degrees(allocations); + } const ConflictSweep sweep = build_conflict_sweep(allocations); if (work_budget && sweep.sweep_work() > *work_budget) { - return std::nullopt; + throw std::runtime_error( + "Conflict sweep work exceeds work_budget; pass None to always count"); } std::vector> degree(sweep.count()); sweep.for_each_pair(parallel_threads(sweep.count()), [&](size_t i, size_t j) { diff --git a/src/cpp/analysis/conflicts.hpp b/src/cpp/analysis/conflicts.hpp index 5e071bc..f11184b 100644 --- a/src/cpp/analysis/conflicts.hpp +++ b/src/cpp/analysis/conflicts.hpp @@ -16,35 +16,36 @@ namespace omnimalloc { -// Temporal overlap adjacency: allocation id -> ids of overlapping allocations -using TemporalOverlaps = +// Conflict adjacency: allocation id -> ids of conflicting allocations. +// Total: every allocation is a key, conflict-free ones map to an empty set. +using ConflictMap = std::unordered_map, IdTypeHash>; -// Index-based temporal adjacency: position i -> positions overlapping i -using OverlapIndices = std::vector>; +// Index-based conflict adjacency: position i -> positions conflicting with i +using ConflictIndices = std::vector>; -// Map each allocation id to the ids of temporally overlapping allocations. -// A set `work_budget` bounds the pairwise sweep (quadratic in the worst -// case), giving up (nullopt) instead of stalling the caller. -[[nodiscard]] std::optional compute_temporal_overlaps( - const std::vector& allocations, - std::optional work_budget); +// Map each allocation id to the ids of conflicting allocations (the +// happens-before conflict relation every placement packs against). A set +// `work_budget` bounds the pairwise sweep (quadratic in the worst case), +// throwing std::runtime_error instead of stalling the caller. +[[nodiscard]] ConflictMap conflicts(const std::vector& allocations, + std::optional work_budget); -// Map each allocation index to the indices of temporally overlapping -// allocations -[[nodiscard]] OverlapIndices compute_overlap_indices( +// Map each allocation index to the indices of conflicting allocations +[[nodiscard]] ConflictIndices compute_conflict_indices( const std::vector& allocations); -// Id-keyed overlap map from an index adjacency -[[nodiscard]] TemporalOverlaps overlaps_from_indices( - const std::vector& allocations, const OverlapIndices& indices); +// Total id-keyed conflict map from an index adjacency +[[nodiscard]] ConflictMap conflict_map_from_indices( + const std::vector& allocations, const ConflictIndices& indices); -// Per-allocation count of temporally overlapping allocations, aligned with +// Per-allocation count of conflicting allocations, aligned with // `allocations`. Counts with multiplicity, so duplicate ids stay distinct. -// A set `work_budget` bounds the pairwise sweep (quadratic in the worst -// case), giving up (nullopt) instead of stalling the caller. -[[nodiscard]] std::optional> compute_conflict_degrees( +// Scalar timelines count in O(N log N) without enumerating pairs; on vector +// clocks a set `work_budget` bounds the pairwise sweep (quadratic in the +// worst case), throwing std::runtime_error instead of stalling the caller. +[[nodiscard]] std::vector conflict_degrees( const std::vector& allocations, std::optional work_budget); diff --git a/src/cpp/analysis/placement.cpp b/src/cpp/analysis/placement.cpp index 9cdac55..2add585 100644 --- a/src/cpp/analysis/placement.cpp +++ b/src/cpp/analysis/placement.cpp @@ -20,9 +20,9 @@ // Reads pressure off an existing placement instead of solving for it: // everything live together with an allocation conflicts with it and // occupies disjoint address ranges below the neighborhood's top, so the -// top (and the clique total) certify upper bounds without any search. -// Interval orders take one single-timeline sweep with range-max queries; -// genuinely partial orders take the pruned pairwise conflict sweep. +// top certifies an upper bound without any search. Interval orders take +// one single-timeline sweep with range-max queries; genuinely partial +// orders take the pruned pairwise conflict sweep. namespace omnimalloc { @@ -60,47 +60,9 @@ std::vector slot_tops( return tops; } -// Conflict clique totals on the single timeline: everything started before -// each end minus everything already ended by each start (half-open -// lifetimes), each allocation's own weight included. -std::vector scalar_clique_sums( - const std::vector>& times, - const std::vector& weights) { - const size_t n = times.size(); - std::vector> by_start(n); - std::vector> by_end(n); - for (size_t i = 0; i < n; ++i) { - by_start[i] = {times[i].first, weights[i]}; - by_end[i] = {times[i].second, weights[i]}; - } - std::ranges::sort(by_start); - std::ranges::sort(by_end); - std::vector started(n + 1, 0); - std::vector ended(n + 1, 0); - for (size_t i = 0; i < n; ++i) { - started[i + 1] = started[i] + by_start[i].second; - ended[i + 1] = ended[i] + by_end[i].second; - } - const auto weight_below = - [](const std::vector>& events, - const std::vector& prefix, int64_t bound) { - const auto it = std::ranges::lower_bound( - events, bound, std::less{}, &std::pair::first); - return prefix[static_cast(it - events.begin())]; - }; - std::vector sums(n); - for (size_t i = 0; i < n; ++i) { - // starts strictly before the end, minus ends at or before the start - sums[i] = weight_below(by_start, started, times[i].second) - - weight_below(by_end, ended, times[i].first + 1); - } - return sums; -} - std::vector scalar_peaks( const std::vector>& times, - const std::vector& heights, const std::vector& weights, - bool clique_cap) { + const std::vector& heights) { const std::vector bounds = slot_bounds(times); const MaxSegtree tops(slot_tops(times, heights, bounds)); std::vector peaks(times.size()); @@ -108,68 +70,51 @@ std::vector scalar_peaks( peaks[i] = tops.max(slot_index(bounds, times[i].first), slot_index(bounds, times[i].second)); } - if (clique_cap) { - const std::vector sums = scalar_clique_sums(times, weights); - for (size_t i = 0; i < peaks.size(); ++i) { - peaks[i] = std::min(peaks[i], sums[i]); - } - } return peaks; } std::vector vector_peaks(const ClockSpans& spans, - const std::vector& heights, - const std::vector& weights, - bool clique_cap) { + const std::vector& heights) { const size_t n = spans.starts.size(); const ConflictSweep sweep(spans.starts, spans.ends, spans.dim); std::vector> top(n); - std::vector> sum(n); for (size_t i = 0; i < n; ++i) { top[i].store(heights[i], std::memory_order_relaxed); - sum[i].store(weights[i], std::memory_order_relaxed); } sweep.for_each_pair(parallel_threads(n), [&](size_t i, size_t j) { atomic_fetch_max(top[i], heights[j]); atomic_fetch_max(top[j], heights[i]); - sum[i].fetch_add(weights[j], std::memory_order_relaxed); - sum[j].fetch_add(weights[i], std::memory_order_relaxed); }); std::vector peaks(n); for (size_t i = 0; i < n; ++i) { - const int64_t peak = top[i].load(std::memory_order_relaxed); - peaks[i] = clique_cap - ? std::min(peak, sum[i].load(std::memory_order_relaxed)) - : peak; + peaks[i] = top[i].load(std::memory_order_relaxed); } return peaks; } } // namespace -std::vector per_allocation_placement_pressure( - const std::vector& allocations, bool clique_cap) { +std::vector placement_pressure_per_allocation( + const std::vector& allocations) { const size_t n = allocations.size(); if (n == 0) { return {}; } const ClockSpans spans = gather_clock_spans(allocations); std::vector heights(n); - std::vector weights(n); for (size_t i = 0; i < n; ++i) { if (!allocations[i].offset().has_value()) { throw std::invalid_argument( - "per-allocation placement pressure requires placed allocations"); + "Per-allocation placement pressure requires placed allocations"); } heights[i] = *allocations[i].offset() + allocations[i].size(); - weights[i] = allocations[i].size(); } // Linearization preserves the conflict relation exactly, so neighborhood - // tops and clique sums transfer verbatim to the surrogate timeline. + // tops transfer verbatim to the surrogate timeline. if (const auto times = linearize_times(allocations, std::nullopt)) { - return scalar_peaks(*times, heights, weights, clique_cap); + return scalar_peaks(*times, heights); } - return vector_peaks(spans, heights, weights, clique_cap); + return vector_peaks(spans, heights); } } // namespace omnimalloc diff --git a/src/cpp/analysis/placement.hpp b/src/cpp/analysis/placement.hpp index fc40fc6..094a372 100644 --- a/src/cpp/analysis/placement.hpp +++ b/src/cpp/analysis/placement.hpp @@ -14,11 +14,9 @@ namespace omnimalloc { // Placement-certified per-allocation pressure, aligned with `allocations`: // the highest occupied address among each allocation and its conflict // neighbors, an upper bound on the pressure while it is live whose maximum -// entry equals the placement's peak. With `clique_cap`, entries are -// additionally capped by their conflict clique's total size — elementwise -// tighter, but the max-equals-peak identity no longer holds. Throws unless -// every allocation is placed. -[[nodiscard]] std::vector per_allocation_placement_pressure( - const std::vector& allocations, bool clique_cap = false); +// entry equals the placement's peak. Throws unless every allocation is +// placed. +[[nodiscard]] std::vector placement_pressure_per_allocation( + const std::vector& allocations); } // namespace omnimalloc diff --git a/src/cpp/bindings.cpp b/src/cpp/bindings.cpp index 9fed298..b1b4088 100644 --- a/src/cpp/bindings.cpp +++ b/src/cpp/bindings.cpp @@ -15,10 +15,9 @@ #include "allocators/best_fit.hpp" #include "allocators/first_fit.hpp" -#include "allocators/greedy.hpp" #include "allocators/omni.hpp" #include "allocators/simulated_annealing.hpp" -#include "allocators/supermalloc/partition.hpp" +#include "allocators/supermalloc.hpp" #include "allocators/tabu_search.hpp" #include "allocators/telamalloc.hpp" #include "analysis/antichain.hpp" @@ -27,7 +26,7 @@ #include "analysis/linearize.hpp" #include "analysis/placement.hpp" #include "primitives/allocation.hpp" -#include "primitives/buffer_kind.hpp" +#include "primitives/allocation_kind.hpp" #include "primitives/id_type.hpp" namespace nb = nanobind; @@ -56,21 +55,21 @@ nb::object time_to_python(const TimePoint& time) { } // namespace NB_MODULE(_cpp, m) { - // BufferKind enum - nb::enum_(m, "BufferKind") - .value("WORKSPACE", BufferKind::WORKSPACE) - .value("CONSTANT", BufferKind::CONSTANT) - .value("INPUT", BufferKind::INPUT) - .value("OUTPUT", BufferKind::OUTPUT) - .def_prop_ro("is_io", [](BufferKind kind) { return is_io(kind); }) - .def("__str__", &stream_str) - .def("__repr__", &stream_str) - .def("__hash__", std::hash{}); + // AllocationKind enum + nb::enum_(m, "AllocationKind") + .value("WORKSPACE", AllocationKind::WORKSPACE) + .value("CONSTANT", AllocationKind::CONSTANT) + .value("INPUT", AllocationKind::INPUT) + .value("OUTPUT", AllocationKind::OUTPUT) + .def_prop_ro("is_io", [](AllocationKind kind) { return is_io(kind); }) + .def("__str__", &stream_str) + .def("__repr__", &stream_str) + .def("__hash__", std::hash{}); // Allocation class nb::class_(m, "Allocation") .def(nb::init, std::optional>(), + std::optional, std::optional>(), "id"_a, "size"_a, "start"_a, "end"_a, "offset"_a = nb::none(), "kind"_a = nb::none()) .def_prop_ro("id", &Allocation::id, nb::rv_policy::copy) @@ -91,37 +90,44 @@ NB_MODULE(_cpp, m) { .def_prop_ro("duration", &Allocation::duration) .def_prop_ro("height", &Allocation::height, nb::rv_policy::copy) .def_prop_ro("area", &Allocation::area) - .def("overlaps_temporally", &Allocation::overlaps_temporally, "other"_a) + .def("conflicts_with", &Allocation::conflicts_with, "other"_a) .def("overlaps_spatially", &Allocation::overlaps_spatially, "other"_a) .def("overlaps", &Allocation::overlaps, "other"_a) .def("with_offset", &Allocation::with_offset, "offset"_a, nb::rv_policy::move) - .def("with_kind", &Allocation::with_kind, "kind"_a, nb::rv_policy::move) .def("__str__", &stream_str) .def("__repr__", &stream_str) // is_operator: return NotImplemented for non-Allocation operands // instead of raising TypeError, per the Python equality protocol .def("__eq__", &Allocation::operator==, nb::is_operator()) .def("__hash__", std::hash{}) - .def("__getstate__", - [](const Allocation& a) { - return std::make_tuple(a.id(), a.size(), a.start_time(), - a.end_time(), a.offset(), a.kind()); - }) + // Times route through time_to_python so pickled payloads match the + // tuple form the start/end properties expose; old list-payload pickles + // still load through __setstate__'s sequence caster. + .def( + "__getstate__", + [](const Allocation& a) { + return nb::make_tuple(nb::cast(a.id()), a.size(), + time_to_python(a.start_time()), + time_to_python(a.end_time()), + nb::cast(a.offset()), nb::cast(a.kind())); + }, + nb::sig("def __getstate__(self) -> tuple[int | str, int, int | " + "tuple[int, ...], int | tuple[int, ...], int | None, " + "AllocationKind | None]")) .def("__setstate__", [](Allocation& a, const std::tuple, - std::optional>& state) { + std::optional>& state) { new (&a) Allocation(std::get<0>(state), std::get<1>(state), std::get<2>(state), std::get<3>(state), std::get<4>(state), std::get<5>(state)); }); - m.def("compute_temporal_overlaps", &compute_temporal_overlaps, - "allocations"_a, "work_budget"_a.none(), + m.def("conflicts", &conflicts, "allocations"_a, "work_budget"_a.none(), nb::call_guard(), nb::rv_policy::move); - m.def("compute_conflict_degrees", &compute_conflict_degrees, "allocations"_a, + m.def("conflict_degrees", &conflict_degrees, "allocations"_a, "work_budget"_a.none(), nb::call_guard(), nb::rv_policy::move); m.def("try_linearize", &try_linearize, "allocations"_a, @@ -129,146 +135,110 @@ NB_MODULE(_cpp, m) { nb::rv_policy::move); m.def("antichain_pressure", &antichain_pressure, "allocations"_a, "work_budget"_a.none(), nb::call_guard()); - m.def("closure_pressure", &closure_pressure, "allocations"_a, "closure_cap"_a, - nb::call_guard()); - m.def("per_allocation_antichain_pressure", &per_allocation_antichain_pressure, + m.def("closure_pressure", &closure_pressure, "allocations"_a, + "closure_cap"_a.none(), nb::call_guard()); + m.def("antichain_pressure_per_allocation", &antichain_pressure_per_allocation, "allocations"_a, "work_budget"_a.none(), nb::call_guard(), nb::rv_policy::move); - m.def("per_allocation_closure_pressure", &per_allocation_closure_pressure, - "allocations"_a, "closure_cap"_a, - nb::call_guard(), nb::rv_policy::move); - m.def("per_allocation_placement_pressure", &per_allocation_placement_pressure, - "allocations"_a, "clique_cap"_a, + m.def("closure_pressure_per_allocation", &closure_pressure_per_allocation, + "allocations"_a, "closure_cap"_a.none(), nb::call_guard(), nb::rv_policy::move); - m.def("first_fit_place", &first_fit_place, "allocations"_a, "overlaps"_a, + m.def("placement_pressure_per_allocation", &placement_pressure_per_allocation, + "allocations"_a, nb::call_guard(), + nb::rv_policy::move); + m.def("first_fit_place", &first_fit_place, "allocations"_a, nb::call_guard(), nb::rv_policy::move); - // FirstFitPlacer class: resident placer for the order-search allocators + // FirstFitPlacer class: resident placer for the order-search allocators. + // Standing invariant for every gil_scoped_release-guarded method in this + // module: it must be const and stateless-or-synchronized, because + // releasing the GIL admits concurrent same-object calls; every guarded + // path is also Python-free (IdType/TimePoint are std variants). nb::class_(m, "FirstFitPlacer") - .def(nb::init>(), "allocations"_a) - .def("evaluate", &FirstFitPlacer::evaluate, "order"_a, + .def(nb::init>(), "allocations"_a, + nb::call_guard()) + .def("peak", &FirstFitPlacer::peak, "order"_a, nb::call_guard()) .def("place", &FirstFitPlacer::place, "order"_a, nb::call_guard(), nb::rv_policy::move) - .def_prop_ro("overlaps", &FirstFitPlacer::overlaps, nb::rv_policy::copy); - - // GreedyAllocator class - nb::class_(m, "GreedyAllocatorCpp") - .def(nb::init<>()) - .def("allocate", &GreedyAllocator::allocate, "allocations"_a, - nb::call_guard(), nb::rv_policy::move) - .def("__str__", - [](const GreedyAllocator&) { return "GreedyAllocator()"; }) - .def("__repr__", - [](const GreedyAllocator&) { return "GreedyAllocator()"; }) - .def("__eq__", &GreedyAllocator::operator==, nb::is_operator()) - .def("__hash__", std::hash{}); - - // OmniAllocator class - const auto omni_repr = [](const OmniAllocator& allocator) { - const auto budget = allocator.linearize_budget(); - return "OmniAllocator(linearize_budget=" + - (budget ? std::to_string(*budget) : std::string("None")) + ")"; - }; - nb::class_(m, "OmniAllocatorCpp") - .def(nb::init>(), "linearize_budget"_a.none()) - .def("allocate", &OmniAllocator::allocate, "allocations"_a, - nb::call_guard(), nb::rv_policy::move) - .def("__str__", omni_repr) - .def("__repr__", omni_repr) - .def("__eq__", &OmniAllocator::operator==, nb::is_operator()) - .def("__hash__", std::hash{}); - - // BestFitAllocator class - nb::class_(m, "BestFitAllocatorCpp") - .def(nb::init<>()) - .def("allocate", &BestFitAllocator::allocate, "allocations"_a, - nb::call_guard(), nb::rv_policy::move) - .def("__str__", - [](const BestFitAllocator&) { return "BestFitAllocator()"; }) - .def("__repr__", - [](const BestFitAllocator&) { return "BestFitAllocator()"; }) - .def("__eq__", &BestFitAllocator::operator==, nb::is_operator()) - .def("__hash__", std::hash{}); - - // SimulatedAnnealingConfig / SimulatedAnnealingAllocator classes - nb::class_(m, "SimulatedAnnealingConfig") - .def(nb::init>(), - "seed"_a, "max_iterations"_a, "initial_temperature"_a, - "cooling_rate"_a, "timeout"_a.none()) - .def_rw("seed", &SimulatedAnnealingConfig::seed) - .def_rw("max_iterations", &SimulatedAnnealingConfig::max_iterations) - .def_rw("initial_temperature", - &SimulatedAnnealingConfig::initial_temperature) - .def_rw("cooling_rate", &SimulatedAnnealingConfig::cooling_rate) - .def_rw("timeout", &SimulatedAnnealingConfig::timeout); + .def_prop_ro("conflicts", &FirstFitPlacer::conflicts, + nb::rv_policy::copy); - nb::class_(m, "SimulatedAnnealingAllocatorCpp") - .def(nb::init(), "config"_a) - .def("allocate", &SimulatedAnnealingAllocator::allocate, "allocations"_a, - nb::call_guard(), nb::rv_policy::move); - - // TabuSearchConfig / TabuSearchAllocator classes - nb::class_(m, "TabuSearchConfig") - .def(nb::init>(), "seed"_a, - "max_iterations"_a, "neighborhood_size"_a, "tabu_tenure"_a, - "timeout"_a.none()) - .def_rw("seed", &TabuSearchConfig::seed) - .def_rw("max_iterations", &TabuSearchConfig::max_iterations) - .def_rw("neighborhood_size", &TabuSearchConfig::neighborhood_size) - .def_rw("tabu_tenure", &TabuSearchConfig::tabu_tenure) - .def_rw("timeout", &TabuSearchConfig::timeout); - - nb::class_(m, "TabuSearchAllocatorCpp") - .def(nb::init(), "config"_a) - .def("allocate", &TabuSearchAllocator::allocate, "allocations"_a, - nb::call_guard(), nb::rv_policy::move); - - // TelamallocConfig / TelamallocAllocator classes - nb::class_(m, "TelamallocConfig") - .def(nb::init>(), "seed"_a, - "max_backtracks"_a, "timeout"_a.none()) - .def_rw("seed", &TelamallocConfig::seed) - .def_rw("max_backtracks", &TelamallocConfig::max_backtracks) - .def_rw("timeout", &TelamallocConfig::timeout); - - nb::class_(m, "TelamallocAllocatorCpp") - .def(nb::init(), "config"_a) - .def("allocate", &TelamallocAllocator::allocate, "allocations"_a, - nb::call_guard(), nb::rv_policy::move); - - // SearchOptions class - nb::class_(m, "SearchOptions") - .def(nb::init(), "canonical"_a, - "dominance"_a, "floor_inference"_a, "monotonic_floor"_a, - "decompose"_a) - .def_rw("canonical", &SearchOptions::canonical) - .def_rw("dominance", &SearchOptions::dominance) - .def_rw("floor_inference", &SearchOptions::floor_inference) - .def_rw("monotonic_floor", &SearchOptions::monotonic_floor) - .def_rw("decompose", &SearchOptions::decompose); + // Placement functions: data types and flat functions cross the + // boundary, nothing else. The config structs stay C++-internal; each + // lambda constructs one from the flat parameters. + m.def("best_fit_place", &best_fit_place, "allocations"_a, + nb::call_guard(), nb::rv_policy::move); + m.def("omni_place", &omni_place, "allocations"_a, "linearize_budget"_a.none(), + nb::call_guard(), nb::rv_policy::move); + m.def( + "simulated_annealing_place", + [](const std::vector& allocations, uint64_t seed, + int max_iterations, double initial_temperature, double cooling_rate, + std::optional timeout) { + return simulated_annealing_place( + allocations, + {seed, max_iterations, initial_temperature, cooling_rate, timeout}); + }, + "allocations"_a, "seed"_a, "max_iterations"_a, "initial_temperature"_a, + "cooling_rate"_a, "timeout"_a.none(), + nb::call_guard(), nb::rv_policy::move); + m.def( + "tabu_search_place", + [](const std::vector& allocations, uint64_t seed, + int max_iterations, int neighborhood_size, int tabu_tenure, + std::optional timeout) { + return tabu_search_place( + allocations, + {seed, max_iterations, neighborhood_size, tabu_tenure, timeout}); + }, + "allocations"_a, "seed"_a, "max_iterations"_a, "neighborhood_size"_a, + "tabu_tenure"_a, "timeout"_a.none(), + nb::call_guard(), nb::rv_policy::move); + m.def( + "telamalloc_place", + [](const std::vector& allocations, uint64_t seed, + int max_backtracks, std::optional timeout) { + return telamalloc_place(allocations, {seed, max_backtracks, timeout}); + }, + "allocations"_a, "seed"_a, "max_backtracks"_a, "timeout"_a.none(), + nb::call_guard(), nb::rv_policy::move); // Partition class nb::class_(m, "Partition") .def_static("from_allocations", &Partition::from_allocations, - "allocations"_a, nb::rv_policy::move) - .def("greedy_pack", &Partition::greedy_pack, "heuristic"_a, - nb::rv_policy::move) - .def("reorder", &Partition::reorder, "heuristic"_a, nb::rv_policy::move) - .def("with_bound", &Partition::with_bound, "bound"_a, nb::rv_policy::move) + "allocations"_a, nb::call_guard(), + nb::rv_policy::move) + .def("reorder", &Partition::reorder, "heuristic"_a, + nb::call_guard(), nb::rv_policy::move) + .def("with_bound", &Partition::with_bound, "bound"_a, + nb::call_guard(), nb::rv_policy::move) .def_prop_ro("lower_bound", &Partition::lower_bound); - // Solution class + // Solution class: placed allocations and their peak nb::class_(m, "Solution") .def_ro("allocations", &Solution::allocations) - .def_ro("offsets", &Solution::offsets) - .def_ro("height", &Solution::height); + .def_ro("peak", &Solution::peak); - m.def("greedy_many", &greedy_many, "partition"_a, "heuristics"_a, - "timeout"_a.none(), "num_threads"_a, + m.def("greedy_pack_portfolio", &greedy_pack_portfolio, "partition"_a, + "heuristics"_a, "timeout"_a.none(), "num_threads"_a, nb::call_guard(), nb::rv_policy::move); - m.def("solve_many", &solve_many, "partitions"_a, "node_limit"_a, - "timeout"_a.none(), "best_bound"_a, "options"_a, "num_threads"_a, - nb::call_guard(), nb::rv_policy::move); + // Parameter order: problem inputs, algorithm knobs, timeout, num_threads. The + // five search switches bind flat; SearchOptions stays C++-internal. + m.def( + "try_solve_many", + [](const std::vector& partitions, int64_t best_bound, + std::optional max_nodes, bool canonical, bool dominance, + bool floor_inference, bool monotonic_floor, bool decompose, + std::optional timeout, int num_threads) { + return try_solve_many( + partitions, best_bound, max_nodes, + {canonical, dominance, floor_inference, monotonic_floor, decompose}, + timeout, num_threads); + }, + "partitions"_a, "best_bound"_a, "max_nodes"_a.none(), "canonical"_a, + "dominance"_a, "floor_inference"_a, "monotonic_floor"_a, "decompose"_a, + "timeout"_a.none(), "num_threads"_a, + nb::call_guard(), nb::rv_policy::move); } diff --git a/src/cpp/primitives/allocation.cpp b/src/cpp/primitives/allocation.cpp index 64c5940..00a3f34 100644 --- a/src/cpp/primitives/allocation.cpp +++ b/src/cpp/primitives/allocation.cpp @@ -45,7 +45,7 @@ std::string to_string(const TimePoint& time) { Allocation::Allocation(IdType id, int64_t size, TimePoint start, TimePoint end, std::optional offset, - std::optional kind) + std::optional kind) : id_(std::move(id)), size_(size), start_(normalized(std::move(start))), @@ -115,7 +115,7 @@ int64_t Allocation::vector_duration() const noexcept { return longest; } -bool Allocation::overlaps_temporally(const Allocation& other) const { +bool Allocation::conflicts_with(const Allocation& other) const { // Fast path: plain interval test, no variant probing in the O(n^2) callers if (is_scalar_time() && other.is_scalar_time()) { return std::get(start_) < std::get(other.end_) && @@ -123,8 +123,8 @@ bool Allocation::overlaps_temporally(const Allocation& other) const { } if (dim() != other.dim()) { throw std::invalid_argument( - "clock dimension mismatch: " + std::to_string(dim()) + " vs " + - std::to_string(other.dim())); + "allocations must share one clock dimension, got " + + std::to_string(dim()) + " and " + std::to_string(other.dim())); } return !happens_before(end_vec(), other.start_vec()) && !happens_before(other.end_vec(), start_vec()); @@ -137,17 +137,13 @@ bool Allocation::overlaps_spatially(const Allocation& other) const noexcept { } bool Allocation::overlaps(const Allocation& other) const { - return overlaps_temporally(other) && overlaps_spatially(other); + return conflicts_with(other) && overlaps_spatially(other); } Allocation Allocation::with_offset(int64_t new_offset) const { return {id_, size_, start_, end_, new_offset, kind_}; } -Allocation Allocation::with_kind(BufferKind new_kind) const { - return {id_, size_, start_, end_, offset_, new_kind}; -} - std::ostream& operator<<(std::ostream& os, const Allocation& a) { os << "Allocation(id="; std::visit([&os](const auto& value) { os << value; }, a.id_); diff --git a/src/cpp/primitives/allocation.hpp b/src/cpp/primitives/allocation.hpp index 1d5180c..79a81f7 100644 --- a/src/cpp/primitives/allocation.hpp +++ b/src/cpp/primitives/allocation.hpp @@ -15,7 +15,7 @@ #include #include -#include "buffer_kind.hpp" +#include "allocation_kind.hpp" #include "id_type.hpp" namespace omnimalloc { @@ -36,37 +36,46 @@ class Allocation { public: Allocation(IdType id, int64_t size, TimePoint start, TimePoint end, std::optional offset = std::nullopt, - std::optional kind = std::nullopt); + std::optional kind = std::nullopt); // Accessors - const IdType& id() const noexcept { return id_; } - int64_t size() const noexcept { return size_; } - int64_t start() const { return scalar(start_); } // throws on vector time - int64_t end() const { return scalar(end_); } // throws on vector time - const TimePoint& start_time() const noexcept { return start_; } - const TimePoint& end_time() const noexcept { return end_; } - std::span start_vec() const noexcept { + [[nodiscard]] const IdType& id() const noexcept { return id_; } + [[nodiscard]] int64_t size() const noexcept { return size_; } + // start()/end() throw on vector time + [[nodiscard]] int64_t start() const { return scalar(start_); } + [[nodiscard]] int64_t end() const { return scalar(end_); } + [[nodiscard]] const TimePoint& start_time() const noexcept { return start_; } + [[nodiscard]] const TimePoint& end_time() const noexcept { return end_; } + [[nodiscard]] std::span start_vec() const noexcept { return components(start_); } - std::span end_vec() const noexcept { return components(end_); } - const std::optional& offset() const noexcept { return offset_; } - const std::optional& kind() const noexcept { return kind_; } + [[nodiscard]] std::span end_vec() const noexcept { + return components(end_); + } + [[nodiscard]] const std::optional& offset() const noexcept { + return offset_; + } + [[nodiscard]] const std::optional& kind() const noexcept { + return kind_; + } // Computed properties - bool is_allocated() const noexcept { return offset_.has_value(); } - bool is_scalar_time() const noexcept { + [[nodiscard]] bool is_allocated() const noexcept { + return offset_.has_value(); + } + [[nodiscard]] bool is_scalar_time() const noexcept { return std::holds_alternative(start_); } - size_t dim() const noexcept { return start_vec().size(); } + [[nodiscard]] size_t dim() const noexcept { return start_vec().size(); } // L-inf: largest per-thread extent. Inline scalar fast path: called per // allocation per node in the supermalloc branch-and-bound heuristics. - int64_t duration() const noexcept { + [[nodiscard]] int64_t duration() const noexcept { if (is_scalar_time()) { return std::get(end_) - std::get(start_); } return vector_duration(); } - int64_t area() const noexcept { + [[nodiscard]] int64_t area() const noexcept { // Saturate instead of overflowing (UB) at int64 extremes const int64_t d = duration(); if (d > 0 && size_ > std::numeric_limits::max() / d) { @@ -75,22 +84,23 @@ class Allocation { return d * size_; } - std::optional height() const noexcept { + [[nodiscard]] std::optional height() const noexcept { if (offset_.has_value()) { return offset_.value() + size_; } return std::nullopt; } - // Overlap detection. Temporal overlap is the happens-before conflict test: - // neither free happens-before the other's alloc. Dimension mismatch throws. - bool overlaps_temporally(const Allocation& other) const; - bool overlaps_spatially(const Allocation& other) const noexcept; - bool overlaps(const Allocation& other) const; + // Pair predicates. `conflicts_with` is the happens-before conflict test + // (neither free happens-before the other's alloc; mixed clock dimensions + // throw): conflicting allocations must occupy disjoint address ranges. + // `overlaps` is the realized collision of two placed rectangles. + [[nodiscard]] bool conflicts_with(const Allocation& other) const; + [[nodiscard]] bool overlaps_spatially(const Allocation& other) const noexcept; + [[nodiscard]] bool overlaps(const Allocation& other) const; // Transformations - Allocation with_offset(int64_t new_offset) const; - Allocation with_kind(BufferKind new_kind) const; + [[nodiscard]] Allocation with_offset(int64_t new_offset) const; // Comparison bool operator==(const Allocation& other) const noexcept = default; @@ -104,7 +114,7 @@ class Allocation { TimePoint start_; TimePoint end_; std::optional offset_; - std::optional kind_; + std::optional kind_; // Inline fast path for the scalar accessors; the throw stays out-of-line static int64_t scalar(const TimePoint& time) { @@ -126,16 +136,18 @@ class Allocation { void validate() const; }; -// Throw std::overflow_error when the total allocation size exceeds `limit`, -// ruling out signed overflow in downstream sums of sizes: placer offset and -// cursor arithmetic, sweep deltas, and flow arc capacities. +// Throw std::invalid_argument (a precondition on the input sizes) when the +// total allocation size exceeds `limit`, ruling out signed overflow in +// downstream sums of sizes: placer offset and cursor arithmetic, sweep +// deltas, and flow arc capacities. The `limit` default is an internal +// correctness bound, not a policy value crossing the binding surface. inline void check_total_size( const std::vector& allocations, int64_t limit = std::numeric_limits::max() / 2) { int64_t total_size = 0; for (const Allocation& a : allocations) { if (a.size() > limit - total_size) { - throw std::overflow_error("Total allocation size exceeds int64 range"); + throw std::invalid_argument("Total allocation size exceeds int64 range"); } total_size += a.size(); } diff --git a/src/cpp/primitives/allocation_kind.hpp b/src/cpp/primitives/allocation_kind.hpp new file mode 100644 index 0000000..30bf281 --- /dev/null +++ b/src/cpp/primitives/allocation_kind.hpp @@ -0,0 +1,47 @@ +// +// SPDX-License-Identifier: Apache-2.0 +// + +#pragma once + +#include +#include +#include + +namespace omnimalloc { + +enum class AllocationKind { WORKSPACE, CONSTANT, INPUT, OUTPUT }; + +[[nodiscard]] constexpr bool is_io(AllocationKind kind) noexcept { + return kind == AllocationKind::INPUT || kind == AllocationKind::OUTPUT; +} + +[[nodiscard]] constexpr std::string_view to_string( + AllocationKind kind) noexcept { + switch (kind) { + case AllocationKind::WORKSPACE: + return "workspace"; + case AllocationKind::CONSTANT: + return "constant"; + case AllocationKind::INPUT: + return "input"; + case AllocationKind::OUTPUT: + return "output"; + } + return "unknown"; +} + +inline std::ostream& operator<<(std::ostream& os, AllocationKind kind) { + return os << to_string(kind); +} + +} // namespace omnimalloc + +namespace std { +template <> +struct hash { + size_t operator()(omnimalloc::AllocationKind kind) const noexcept { + return hash{}(static_cast(kind)); + } +}; +} // namespace std diff --git a/src/cpp/primitives/buffer_kind.hpp b/src/cpp/primitives/buffer_kind.hpp deleted file mode 100644 index ddf3eb6..0000000 --- a/src/cpp/primitives/buffer_kind.hpp +++ /dev/null @@ -1,46 +0,0 @@ -// -// SPDX-License-Identifier: Apache-2.0 -// - -#pragma once - -#include -#include -#include - -namespace omnimalloc { - -enum class BufferKind { WORKSPACE, CONSTANT, INPUT, OUTPUT }; - -constexpr bool is_io(BufferKind kind) noexcept { - return kind == BufferKind::INPUT || kind == BufferKind::OUTPUT; -} - -constexpr std::string_view to_string(BufferKind kind) noexcept { - switch (kind) { - case BufferKind::WORKSPACE: - return "workspace"; - case BufferKind::CONSTANT: - return "constant"; - case BufferKind::INPUT: - return "input"; - case BufferKind::OUTPUT: - return "output"; - } - return "unknown"; -} - -inline std::ostream& operator<<(std::ostream& os, BufferKind kind) { - return os << to_string(kind); -} - -} // namespace omnimalloc - -namespace std { -template <> -struct hash { - size_t operator()(omnimalloc::BufferKind kind) const noexcept { - return hash{}(static_cast(kind)); - } -}; -} // namespace std diff --git a/src/python/omnimalloc/__init__.py b/src/python/omnimalloc/__init__.py index 691aa3c..5394591 100644 --- a/src/python/omnimalloc/__init__.py +++ b/src/python/omnimalloc/__init__.py @@ -6,31 +6,19 @@ __version__ = version("omnimalloc") -from .allocate import run_allocation as run_allocation +from ._allocate import allocate as allocate from .allocators import OmniAllocator as OmniAllocator -from .allocators import get_available_allocators as get_available_allocators -from .allocators import get_default_allocator as get_default_allocator -from .analysis import get_closure_pressure as get_closure_pressure -from .analysis import get_conflicts as get_conflicts -from .analysis import ( - get_per_allocation_closure_pressure as get_per_allocation_closure_pressure, -) -from .analysis import ( - get_per_allocation_placement_pressure as get_per_allocation_placement_pressure, -) -from .analysis import get_per_allocation_pressure as get_per_allocation_pressure -from .analysis import get_placement_pressure as get_placement_pressure -from .analysis import get_pressure as get_pressure +from .analysis import conflicts as conflicts +from .analysis import pressure as pressure from .analysis import try_linearize as try_linearize -from .dump import dump_allocation as dump_allocation -from .dump import load_allocation as load_allocation +from .io import load_allocation as load_allocation +from .io import save_allocation as save_allocation from .primitives import Allocation as Allocation -from .primitives import BufferKind as BufferKind +from .primitives import AllocationKind as AllocationKind from .primitives import IdType as IdType from .primitives import Memory as Memory from .primitives import Pool as Pool from .primitives import System as System from .primitives import TimePoint as TimePoint -from .primitives import VectorClock as VectorClock from .validate import validate_allocation as validate_allocation from .visualize import plot_allocation as plot_allocation diff --git a/src/python/omnimalloc/_allocate.py b/src/python/omnimalloc/_allocate.py new file mode 100644 index 0000000..11846a4 --- /dev/null +++ b/src/python/omnimalloc/_allocate.py @@ -0,0 +1,39 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + + +from typing import TypeVar, cast + +from .allocators import DEFAULT_ALLOCATOR, BaseAllocator +from .primitives import Memory, Pool, System +from .validate import validate_allocation + +T = TypeVar("T", System, Memory, Pool) + + +def allocate( + entity: T, + allocator: BaseAllocator | type[BaseAllocator] | str | None = None, + *, + validate: bool = False, +) -> T: + """Return the entity (System, Memory, or Pool) with offsets assigned. + + `allocator` accepts an instance, a class, a registry name, or `None` + (the default allocator); `validate=True` additionally runs + `validate_allocation` on the result. + """ + + if allocator is None: + allocator = DEFAULT_ALLOCATOR + + resolved = BaseAllocator.resolve(allocator) + + # ty doesn't understand that TypeVar T (System|Memory|Pool) all have allocate method + allocated = entity.allocate(resolved) # type: ignore[invalid-argument-type] + + if validate: + validate_allocation(allocated) + + return cast("T", allocated) diff --git a/src/python/omnimalloc/_cpp.pyi b/src/python/omnimalloc/_cpp.pyi index 0ebeb69..182af95 100644 --- a/src/python/omnimalloc/_cpp.pyi +++ b/src/python/omnimalloc/_cpp.pyi @@ -1,8 +1,8 @@ -from collections.abc import Mapping, Sequence, Set +from collections.abc import Sequence import enum -class BufferKind(enum.Enum): +class AllocationKind(enum.Enum): def __str__(self) -> str: ... def __repr__(self) -> str: ... @@ -21,7 +21,7 @@ class BufferKind(enum.Enum): def __hash__(self) -> int: ... class Allocation: - def __init__(self, id: int | str, size: int, start: int | Sequence[int], end: int | Sequence[int], offset: int | None = None, kind: BufferKind | None = None) -> None: ... + def __init__(self, id: int | str, size: int, start: int | Sequence[int], end: int | Sequence[int], offset: int | None = None, kind: AllocationKind | None = None) -> None: ... @property def id(self) -> int | str: ... @@ -42,7 +42,7 @@ class Allocation: def offset(self) -> int | None: ... @property - def kind(self) -> BufferKind | None: ... + def kind(self) -> AllocationKind | None: ... @property def is_allocated(self) -> bool: ... @@ -56,7 +56,7 @@ class Allocation: @property def area(self) -> int: ... - def overlaps_temporally(self, other: Allocation) -> bool: ... + def conflicts_with(self, other: Allocation) -> bool: ... def overlaps_spatially(self, other: Allocation) -> bool: ... @@ -64,8 +64,6 @@ class Allocation: def with_offset(self, offset: int) -> Allocation: ... - def with_kind(self, kind: BufferKind) -> Allocation: ... - def __str__(self) -> str: ... def __repr__(self) -> str: ... @@ -74,218 +72,52 @@ class Allocation: def __hash__(self) -> int: ... - def __getstate__(self) -> tuple[int | str, int, int | list[int], int | list[int], int | None, BufferKind | None]: ... + def __getstate__(self) -> tuple[int | str, int, int | tuple[int, ...], int | tuple[int, ...], int | None, AllocationKind | None]: ... - def __setstate__(self, arg: tuple[int | str, int, int | Sequence[int], int | Sequence[int], int | None, BufferKind | None], /) -> None: ... + def __setstate__(self, arg: tuple[int | str, int, int | Sequence[int], int | Sequence[int], int | None, AllocationKind | None], /) -> None: ... -def compute_temporal_overlaps(allocations: Sequence[Allocation], work_budget: int | None) -> dict[int | str, set[int | str]] | None: ... +def conflicts(allocations: Sequence[Allocation], work_budget: int | None) -> dict[int | str, set[int | str]]: ... -def compute_conflict_degrees(allocations: Sequence[Allocation], work_budget: int | None) -> list[int] | None: ... +def conflict_degrees(allocations: Sequence[Allocation], work_budget: int | None) -> list[int]: ... def try_linearize(allocations: Sequence[Allocation], work_budget: int | None) -> list[Allocation] | None: ... def antichain_pressure(allocations: Sequence[Allocation], work_budget: int | None) -> int: ... -def closure_pressure(allocations: Sequence[Allocation], closure_cap: int) -> int | None: ... +def closure_pressure(allocations: Sequence[Allocation], closure_cap: int | None) -> int: ... -def per_allocation_antichain_pressure(allocations: Sequence[Allocation], work_budget: int | None) -> list[int]: ... +def antichain_pressure_per_allocation(allocations: Sequence[Allocation], work_budget: int | None) -> list[int]: ... -def per_allocation_closure_pressure(allocations: Sequence[Allocation], closure_cap: int) -> list[int] | None: ... +def closure_pressure_per_allocation(allocations: Sequence[Allocation], closure_cap: int | None) -> list[int]: ... -def per_allocation_placement_pressure(allocations: Sequence[Allocation], clique_cap: bool) -> list[int]: ... +def placement_pressure_per_allocation(allocations: Sequence[Allocation]) -> list[int]: ... -def first_fit_place(allocations: Sequence[Allocation], overlaps: Mapping[int | str, Set[int | str]]) -> list[Allocation]: ... +def first_fit_place(allocations: Sequence[Allocation]) -> list[Allocation]: ... class FirstFitPlacer: def __init__(self, allocations: Sequence[Allocation]) -> None: ... - def evaluate(self, order: Sequence[int]) -> int: ... + def peak(self, order: Sequence[int]) -> int: ... def place(self, order: Sequence[int]) -> list[Allocation]: ... @property - def overlaps(self) -> dict[int | str, set[int | str]]: ... - -class GreedyAllocatorCpp: - def __init__(self) -> None: ... - - def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... - - def __str__(self) -> str: ... - - def __repr__(self) -> str: ... - - def __eq__(self, arg: GreedyAllocatorCpp, /) -> bool: ... - - def __hash__(self) -> int: ... - -class OmniAllocatorCpp: - def __init__(self, linearize_budget: int | None) -> None: ... - - def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... - - def __str__(self) -> str: ... - - def __repr__(self) -> str: ... - - def __eq__(self, arg: OmniAllocatorCpp, /) -> bool: ... - - def __hash__(self) -> int: ... - -class BestFitAllocatorCpp: - def __init__(self) -> None: ... - - def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... - - def __str__(self) -> str: ... - - def __repr__(self) -> str: ... - - def __eq__(self, arg: BestFitAllocatorCpp, /) -> bool: ... - - def __hash__(self) -> int: ... - -class SimulatedAnnealingConfig: - def __init__(self, seed: int, max_iterations: int, initial_temperature: float, cooling_rate: float, timeout: float | None) -> None: ... - - @property - def seed(self) -> int: ... - - @seed.setter - def seed(self, arg: int, /) -> None: ... - - @property - def max_iterations(self) -> int: ... - - @max_iterations.setter - def max_iterations(self, arg: int, /) -> None: ... - - @property - def initial_temperature(self) -> float: ... - - @initial_temperature.setter - def initial_temperature(self, arg: float, /) -> None: ... - - @property - def cooling_rate(self) -> float: ... - - @cooling_rate.setter - def cooling_rate(self, arg: float, /) -> None: ... - - @property - def timeout(self) -> float | None: ... - - @timeout.setter - def timeout(self, arg: float | None) -> None: ... - -class SimulatedAnnealingAllocatorCpp: - def __init__(self, config: SimulatedAnnealingConfig) -> None: ... - - def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... - -class TabuSearchConfig: - def __init__(self, seed: int, max_iterations: int, neighborhood_size: int, tabu_tenure: int, timeout: float | None) -> None: ... - - @property - def seed(self) -> int: ... - - @seed.setter - def seed(self, arg: int, /) -> None: ... - - @property - def max_iterations(self) -> int: ... - - @max_iterations.setter - def max_iterations(self, arg: int, /) -> None: ... - - @property - def neighborhood_size(self) -> int: ... + def conflicts(self) -> dict[int | str, set[int | str]]: ... - @neighborhood_size.setter - def neighborhood_size(self, arg: int, /) -> None: ... +def best_fit_place(allocations: Sequence[Allocation]) -> list[Allocation]: ... - @property - def tabu_tenure(self) -> int: ... +def omni_place(allocations: Sequence[Allocation], linearize_budget: int | None) -> list[Allocation]: ... - @tabu_tenure.setter - def tabu_tenure(self, arg: int, /) -> None: ... +def simulated_annealing_place(allocations: Sequence[Allocation], seed: int, max_iterations: int, initial_temperature: float, cooling_rate: float, timeout: float | None) -> list[Allocation]: ... - @property - def timeout(self) -> float | None: ... +def tabu_search_place(allocations: Sequence[Allocation], seed: int, max_iterations: int, neighborhood_size: int, tabu_tenure: int, timeout: float | None) -> list[Allocation]: ... - @timeout.setter - def timeout(self, arg: float | None) -> None: ... - -class TabuSearchAllocatorCpp: - def __init__(self, config: TabuSearchConfig) -> None: ... - - def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... - -class TelamallocConfig: - def __init__(self, seed: int, max_backtracks: int, timeout: float | None) -> None: ... - - @property - def seed(self) -> int: ... - - @seed.setter - def seed(self, arg: int, /) -> None: ... - - @property - def max_backtracks(self) -> int: ... - - @max_backtracks.setter - def max_backtracks(self, arg: int, /) -> None: ... - - @property - def timeout(self) -> float | None: ... - - @timeout.setter - def timeout(self, arg: float | None) -> None: ... - -class TelamallocAllocatorCpp: - def __init__(self, config: TelamallocConfig) -> None: ... - - def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... - -class SearchOptions: - def __init__(self, canonical: bool, dominance: bool, floor_inference: bool, monotonic_floor: bool, decompose: bool) -> None: ... - - @property - def canonical(self) -> bool: ... - - @canonical.setter - def canonical(self, arg: bool, /) -> None: ... - - @property - def dominance(self) -> bool: ... - - @dominance.setter - def dominance(self, arg: bool, /) -> None: ... - - @property - def floor_inference(self) -> bool: ... - - @floor_inference.setter - def floor_inference(self, arg: bool, /) -> None: ... - - @property - def monotonic_floor(self) -> bool: ... - - @monotonic_floor.setter - def monotonic_floor(self, arg: bool, /) -> None: ... - - @property - def decompose(self) -> bool: ... - - @decompose.setter - def decompose(self, arg: bool, /) -> None: ... +def telamalloc_place(allocations: Sequence[Allocation], seed: int, max_backtracks: int, timeout: float | None) -> list[Allocation]: ... class Partition: @staticmethod def from_allocations(allocations: Sequence[Allocation]) -> Partition: ... - def greedy_pack(self, heuristic: str) -> Solution: ... - def reorder(self, heuristic: str) -> Partition: ... def with_bound(self, bound: int) -> Partition: ... @@ -298,11 +130,8 @@ class Solution: def allocations(self) -> list[Allocation]: ... @property - def offsets(self) -> list[int]: ... - - @property - def height(self) -> int: ... + def peak(self) -> int: ... -def greedy_many(partition: Partition, heuristics: Sequence[str], timeout: float | None, num_threads: int) -> Solution: ... +def greedy_pack_portfolio(partition: Partition, heuristics: Sequence[str], timeout: float | None, num_threads: int) -> Solution: ... -def solve_many(partitions: Sequence[Partition], node_limit: int, timeout: float | None, best_bound: int, options: SearchOptions, num_threads: int) -> Solution | None: ... +def try_solve_many(partitions: Sequence[Partition], best_bound: int, max_nodes: int | None, canonical: bool, dominance: bool, floor_inference: bool, monotonic_floor: bool, decompose: bool, timeout: float | None, num_threads: int) -> Solution | None: ... diff --git a/src/python/omnimalloc/allocate.py b/src/python/omnimalloc/allocate.py deleted file mode 100644 index 5ce778b..0000000 --- a/src/python/omnimalloc/allocate.py +++ /dev/null @@ -1,41 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - - -from typing import TypeVar, cast - -from .allocators import BaseAllocator, get_default_allocator -from .primitives import Memory, Pool, System -from .validate import validate_allocation - -T = TypeVar("T", System, Memory, Pool) - - -def run_allocation( - entity: T, - allocator: BaseAllocator | type[BaseAllocator] | str | None = None, - validate: bool = False, -) -> T: - """Run allocation on the given entity using the provided allocator. - - Args: - entity: The entity to allocate (System, Memory, or Pool). - allocator: The allocator to use (instance, class, or name). - validate: Whether to validate the allocated entity. - - Returns: - The allocated entity with the same type as the input. - """ - - if allocator is None: - allocator = get_default_allocator() - allocator = BaseAllocator.resolve(allocator) - - # ty doesn't understand that TypeVar T (System|Memory|Pool) all have allocate method - allocated = entity.allocate(allocator) # type: ignore[invalid-argument-type] - - if validate: - validate_allocation(allocated) - - return cast("T", allocated) diff --git a/src/python/omnimalloc/allocators/__init__.py b/src/python/omnimalloc/allocators/__init__.py index 8cb793d..9b31f2e 100644 --- a/src/python/omnimalloc/allocators/__init__.py +++ b/src/python/omnimalloc/allocators/__init__.py @@ -13,16 +13,6 @@ from .greedy import GreedyByDurationAllocator as GreedyByDurationAllocator from .greedy import GreedyBySizeAllocator as GreedyBySizeAllocator from .greedy import GreedyByStartAllocator as GreedyByStartAllocator -from .greedy_cpp import GreedyAllocatorCpp as GreedyAllocatorCpp -from .greedy_cpp import GreedyByAllAllocatorCpp as GreedyByAllAllocatorCpp -from .greedy_cpp import GreedyByAreaAllocatorCpp as GreedyByAreaAllocatorCpp -from .greedy_cpp import GreedyByConflictAllocatorCpp as GreedyByConflictAllocatorCpp -from .greedy_cpp import ( - GreedyByConflictSizeAllocatorCpp as GreedyByConflictSizeAllocatorCpp, -) -from .greedy_cpp import GreedyByDurationAllocatorCpp as GreedyByDurationAllocatorCpp -from .greedy_cpp import GreedyBySizeAllocatorCpp as GreedyBySizeAllocatorCpp -from .greedy_cpp import GreedyByStartAllocatorCpp as GreedyByStartAllocatorCpp from .hillclimb import HillClimbAllocator as HillClimbAllocator from .minimalloc import MinimallocAllocator as MinimallocAllocator from .naive import NaiveAllocator as NaiveAllocator @@ -31,14 +21,8 @@ from .simulated_annealing import ( SimulatedAnnealingAllocator as SimulatedAnnealingAllocator, ) -from .simulated_annealing import SimulatedAnnealingConfig as SimulatedAnnealingConfig from .supermalloc import SupermallocAllocator as SupermallocAllocator -from .supermalloc import SupermallocConfig as SupermallocConfig from .tabu_search import TabuSearchAllocator as TabuSearchAllocator -from .tabu_search import TabuSearchConfig as TabuSearchConfig from .telamalloc import TelamallocAllocator as TelamallocAllocator -from .telamalloc import TelamallocConfig as TelamallocConfig from .utils import DEFAULT_ALLOCATOR as DEFAULT_ALLOCATOR -from .utils import get_allocator_by_name as get_allocator_by_name -from .utils import get_available_allocators as get_available_allocators -from .utils import get_default_allocator as get_default_allocator +from .utils import available_allocators as available_allocators diff --git a/src/python/omnimalloc/allocators/base.py b/src/python/omnimalloc/allocators/base.py index 6bba010..8b2ff19 100644 --- a/src/python/omnimalloc/allocators/base.py +++ b/src/python/omnimalloc/allocators/base.py @@ -5,7 +5,7 @@ from abc import abstractmethod from typing import TYPE_CHECKING, ClassVar -from omnimalloc.analysis.clock import ensure_uniform_dim +from omnimalloc.analysis.clock import uniform_dim from omnimalloc.common.registry import Registered from omnimalloc.primitives.utils import ensure_unique_ids @@ -16,17 +16,27 @@ class BaseAllocator(Registered): """Base class for allocators with automatic registry.""" + # Registry keys drop the class-role token: GreedyBySizeAllocator + # registers as "greedy_by_size". + _strip_suffix: ClassVar[str] = "Allocator" + # True for allocators that consume only the pairwise conflict relation and # thus accept vector-clock lifetimes. Subclasses that add logic reading # scalar start/end directly must declare this False again. supports_vector_time: ClassVar[bool] = False + def __repr__(self) -> str: + kwargs = ", ".join( + f"{key.lstrip('_')}={value!r}" for key, value in vars(self).items() + ) + return f"{type(self).__name__}({kwargs})" + def allocate( self, allocations: tuple["Allocation", ...] ) -> tuple["Allocation", ...]: """Validate shared preconditions, then run the allocator.""" ensure_unique_ids(allocations) - ensure_uniform_dim(allocations) + uniform_dim(allocations) self.ensure_supported(allocations) if not allocations: return allocations @@ -39,10 +49,6 @@ def _allocate( """Place the validated, non-empty allocations. Implemented by subclasses.""" ... - def reset(self) -> None: - """Optional: reset allocator state/config. Override if needed.""" - ... - def supports(self, allocations: tuple["Allocation", ...]) -> bool: """Whether this allocator accepts the allocations' clock dimensions.""" return self.supports_vector_time or all(alloc.dim == 1 for alloc in allocations) diff --git a/src/python/omnimalloc/allocators/best_fit.py b/src/python/omnimalloc/allocators/best_fit.py index 002630a..d14ad29 100644 --- a/src/python/omnimalloc/allocators/best_fit.py +++ b/src/python/omnimalloc/allocators/best_fit.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # -from omnimalloc._cpp import BestFitAllocatorCpp as _BestFitAllocatorCpp +from omnimalloc._cpp import best_fit_place from omnimalloc.primitives import Allocation from .base import BaseAllocator @@ -20,4 +20,4 @@ class BestFitAllocator(BaseAllocator): supports_vector_time = True def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return tuple(_BestFitAllocatorCpp().allocate(list(allocations))) + return tuple(best_fit_place(allocations)) diff --git a/src/python/omnimalloc/allocators/genetic.py b/src/python/omnimalloc/allocators/genetic.py index 8ec2872..70d93d2 100644 --- a/src/python/omnimalloc/allocators/genetic.py +++ b/src/python/omnimalloc/allocators/genetic.py @@ -13,6 +13,7 @@ make_deadline, ) from omnimalloc.common.optional import require_optional +from omnimalloc.common.validation import ensure_non_negative, ensure_positive from omnimalloc.primitives import Allocation from .greedy import GreedyAllocator @@ -38,14 +39,15 @@ class GeneticAllocator(GreedyAllocator): """Genetic algorithm allocator that evolves greedy placement orders. `timeout` (default 3s) bounds wall-clock time between generations, - independent of `num_generations`; set it to None to disable the deadline. + independent of `max_generations`; set it to None to disable the deadline. """ def __init__( self, + *, seed: int = DEFAULT_SEED, population_size: int = 100, - num_generations: int = 50, + max_generations: int = 50, crossover_prob: float = 0.7, mutation_prob: float = 0.2, tournament_size: int = 3, @@ -53,28 +55,23 @@ def __init__( ) -> None: if not HAS_DEAP: require_optional("deap", "GeneticAllocator") - if population_size <= 0: - raise ValueError(f"population_size must be positive, got {population_size}") - if num_generations < 0: - raise ValueError( - f"num_generations must be non-negative, got {num_generations}" - ) + ensure_positive(population_size, "population_size") + ensure_non_negative(max_generations, "max_generations") if not 0.0 <= crossover_prob <= 1.0 or not 0.0 <= mutation_prob <= 1.0: raise ValueError( f"crossover_prob and mutation_prob must be in [0, 1], " f"got {crossover_prob} and {mutation_prob}" ) - if tournament_size <= 0: - raise ValueError(f"tournament_size must be positive, got {tournament_size}") + ensure_positive(tournament_size, "tournament_size") ensure_valid_timeout(timeout) - self.seed = seed - self.population_size = population_size - self.num_generations = num_generations - self.crossover_prob = crossover_prob - self.mutation_prob = mutation_prob - self.tournament_size = tournament_size - self.timeout = timeout + self._seed = seed + self._population_size = population_size + self._max_generations = max_generations + self._crossover_prob = crossover_prob + self._mutation_prob = mutation_prob + self._tournament_size = tournament_size + self._timeout = timeout # Setup DEAP creators (only once per process, they live in a global namespace) if not hasattr(creator, "FitnessMin"): @@ -87,7 +84,7 @@ def _evaluate_permutation( self, permutation: list[int], placer: FirstFitPlacer ) -> tuple[float]: """Evaluate a permutation by computing its greedy peak memory usage.""" - return (float(placer.evaluate(permutation)),) + return (float(placer.peak(permutation)),) def _heuristic_permutations( self, allocations: tuple[Allocation, ...] @@ -105,7 +102,7 @@ def _heuristic_permutations( permutations = [ [positions[alloc.id] for alloc in order(allocations)] for order in orders ] - return permutations[: self.population_size] + return permutations[: self._population_size] def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: """Evolve permutations using a genetic algorithm to find best allocation.""" @@ -115,14 +112,14 @@ def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, .. # DEAP operators draw from the global random module; seed it for # determinism but restore the caller's stream afterwards. random_state = random.getstate() - random.seed(self.seed) + random.seed(self._seed) try: return self._evolve(allocations) finally: random.setstate(random_state) def _evolve(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - placer = FirstFitPlacer(list(allocations)) + placer = FirstFitPlacer(allocations) toolbox = base.Toolbox() n = len(allocations) toolbox.register("indices", random.sample, range(n), n) @@ -137,7 +134,7 @@ def _evolve(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...] toolbox.register("mate", tools.cxOrdered) toolbox.register("mutate", tools.mutShuffleIndexes, indpb=0.05) # TODO(fpedd): Try larger tournsize and selNSGA2 - toolbox.register("select", tools.selTournament, tournsize=self.tournament_size) + toolbox.register("select", tools.selTournament, tournsize=self._tournament_size) # Seed the population with heuristic orders, fill up with random ones # Individual and individual() are dynamically created by DEAP @@ -147,7 +144,7 @@ def _evolve(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...] ] population += [ toolbox.individual() # type: ignore[possibly-missing-attribute] - for _ in range(self.population_size - len(population)) + for _ in range(self._population_size - len(population)) ] hall_of_fame = tools.HallOfFame(maxsize=1) @@ -160,15 +157,15 @@ def evaluate_invalid(individuals: list[Any]) -> None: # DEAP's eaSimple, unrolled so a wall-clock deadline can stop between # generations; varAnd keeps the RNG stream identical to eaSimple. # TODO(fpedd): Try eaMuPlusLambda and eaMuCommaLambda - deadline = make_deadline(self.timeout) + deadline = make_deadline(self._timeout) evaluate_invalid(population) hall_of_fame.update(population) - for _ in range(self.num_generations): + for _ in range(self._max_generations): if deadline_expired(deadline): break offspring = toolbox.select(population, len(population)) # type: ignore[unresolved-attribute] offspring = algorithms.varAnd( - offspring, toolbox, self.crossover_prob, self.mutation_prob + offspring, toolbox, self._crossover_prob, self._mutation_prob ) evaluate_invalid(offspring) hall_of_fame.update(offspring) diff --git a/src/python/omnimalloc/allocators/greedy.py b/src/python/omnimalloc/allocators/greedy.py index b0119b2..6950343 100644 --- a/src/python/omnimalloc/allocators/greedy.py +++ b/src/python/omnimalloc/allocators/greedy.py @@ -2,7 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 # -from omnimalloc._cpp import compute_temporal_overlaps, first_fit_place +from omnimalloc._cpp import first_fit_place +from omnimalloc.common.parallel import ensure_valid_num_threads from omnimalloc.primitives import Allocation from .base import BaseAllocator @@ -23,10 +24,9 @@ class GreedyAllocator(BaseAllocator): supports_vector_time = True def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - # Unbounded: placement needs the true conflict relation, never a degrade. - overlaps = compute_temporal_overlaps(allocations, None) - assert overlaps is not None - return tuple(first_fit_place(allocations, overlaps)) + # The C++ kernel computes the conflict relation natively, unbudgeted: + # placement needs the true relation and never degrades. + return tuple(first_fit_place(allocations)) class GreedyByDurationAllocator(GreedyAllocator): @@ -72,10 +72,14 @@ def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, .. class GreedyByAllAllocator(GreedyAllocator): - """Greedy allocator that runs every variant and keeps the best result.""" + """Greedy allocator that runs every variant and keeps the best result. - def __init__(self, cores: int | None = None) -> None: - self._cores = cores + `num_threads=None` uses all cores. + """ + + def __init__(self, *, num_threads: int | None = None) -> None: + ensure_valid_num_threads(num_threads) + self._num_threads = num_threads def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: variants: tuple[BaseAllocator, ...] = ( @@ -87,4 +91,4 @@ def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, .. GreedyByConflictSizeAllocator(), GreedyByStartAllocator(), ) - return allocate_parallel(variants, allocations, cores=self._cores) + return allocate_parallel(allocations, variants, num_threads=self._num_threads) diff --git a/src/python/omnimalloc/allocators/greedy_base.py b/src/python/omnimalloc/allocators/greedy_base.py index eab18f2..e00f2b8 100644 --- a/src/python/omnimalloc/allocators/greedy_base.py +++ b/src/python/omnimalloc/allocators/greedy_base.py @@ -2,40 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 # -import dataclasses -import os -from bisect import bisect_left, bisect_right from concurrent.futures import ProcessPoolExecutor -from omnimalloc.analysis.clock import ensure_uniform_dim -from omnimalloc.analysis.conflicts import get_conflict_degrees +from omnimalloc.analysis import conflict_degrees, placement_pressure +from omnimalloc.analysis.clock import uniform_dim +from omnimalloc.common.parallel import resolve_num_threads from omnimalloc.primitives import Allocation from .base import BaseAllocator -def peak_memory(allocations: tuple[Allocation, ...]) -> int: - """Return the highest end offset across the allocated allocations.""" - return max((a.height for a in allocations if a.height is not None), default=0) - - -def compute_conflicts(allocations: tuple[Allocation, ...]) -> dict[Allocation, int]: - """Count temporally overlapping allocations for each allocation.""" - if ensure_uniform_dim(allocations) > 1: - # No linear timeline to bisect over; count conflicts pairwise (with - # multiplicity, matching the scalar sweep even under duplicate ids). - # Unbounded: the sort order must not degrade on large instances. - degrees = get_conflict_degrees(allocations, work_budget=None) - assert degrees is not None - return dict(zip(allocations, degrees, strict=True)) - starts = sorted(alloc.start for alloc in allocations) - ends = sorted(alloc.end for alloc in allocations) - # An allocation overlaps [start, end) iff it starts before `end` and does - # not end by `start`; subtract 1 so the allocation does not count itself. - return { - alloc: bisect_left(starts, alloc.end) - bisect_right(ends, alloc.start) - 1 - for alloc in allocations - } +def _unbudgeted_degrees(allocations: tuple[Allocation, ...]) -> list[int]: + # Unbounded: the sort order must not degrade on large instances. + return conflict_degrees(allocations, work_budget=None) def order_by_size(allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: @@ -50,67 +29,66 @@ def order_by_duration(allocations: tuple[Allocation, ...]) -> tuple[Allocation, def order_by_area(allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: """Order by area (size * duration, largest first).""" - return tuple(sorted(allocations, key=lambda a: a.size * a.duration, reverse=True)) + return tuple(sorted(allocations, key=lambda a: a.area, reverse=True)) def order_by_conflict(allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: """Order by conflict degree (most conflicted first).""" - conflicts = compute_conflicts(allocations) - return tuple( - sorted(allocations, key=lambda a: (conflicts[a], a.size), reverse=True) + degrees = _unbudgeted_degrees(allocations) + paired = sorted( + zip(allocations, degrees, strict=True), + key=lambda pair: (pair[1], pair[0].size), + reverse=True, ) + return tuple(alloc for alloc, _ in paired) def order_by_conflict_size( allocations: tuple[Allocation, ...], ) -> tuple[Allocation, ...]: """Order by conflict degree times size (largest first).""" - conflicts = compute_conflicts(allocations) - return tuple( - sorted(allocations, key=lambda a: (conflicts[a] * a.size, a.size), reverse=True) + degrees = _unbudgeted_degrees(allocations) + paired = sorted( + zip(allocations, degrees, strict=True), + key=lambda pair: (pair[1] * pair[0].size, pair[0].size), + reverse=True, ) + return tuple(alloc for alloc, _ in paired) def order_by_start(allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: """Order by start time (earliest first, largest ties first).""" - ensure_uniform_dim(allocations) # mixed scalar/tuple starts do not compare + uniform_dim(allocations) # mixed scalar/tuple starts do not compare return tuple(sorted(allocations, key=lambda a: (a.start, -a.size))) -def _allocate(name: str, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - """Worker: rebuild the named allocator from the registry and run it.""" - return BaseAllocator.get(name)().allocate(allocations) +def _allocate( + allocator: BaseAllocator, allocations: tuple[Allocation, ...] +) -> tuple[Allocation, ...]: + """Worker: flat plain-typed kwargs make every allocator picklable.""" + return allocator.allocate(allocations) def allocate_parallel( - variants: tuple[BaseAllocator, ...], allocations: tuple[Allocation, ...], - cores: int | None = None, + variants: tuple[BaseAllocator, ...], + *, + num_threads: int | None = None, ) -> tuple[Allocation, ...]: - """Run each variant and return the lowest peak memory results.""" + """Run each variant and return the lowest peak memory results. + + `num_threads=None` uses all cores (capped at the variant count). + """ if not allocations: return allocations - workers = cores if cores is not None else min(os.cpu_count() or 1, len(variants)) + workers = resolve_num_threads(num_threads) + if num_threads is None: + workers = min(workers, len(variants)) if workers <= 1: - return min((v.allocate(allocations) for v in variants), key=peak_memory) - - def config(a: BaseAllocator) -> dict[str, object]: - # Frozen config dataclasses compare by value, so include them alongside - # plain types; anything else (caches, C++ handles) is not config. - plain = (bool, int, float, str, tuple, type(None)) - return { - k: v - for k, v in vars(a).items() - if isinstance(v, plain) or dataclasses.is_dataclass(v) - } - - for variant in variants: - if config(variant) != config(type(variant)()): - raise ValueError(f"variant '{variant.name()}' must be default-configured") - - names = [variant.name() for variant in variants] + return min((v.allocate(allocations) for v in variants), key=placement_pressure) + with ProcessPoolExecutor(max_workers=workers) as pool: - futures = [pool.submit(_allocate, name, allocations) for name in names] - return min((future.result() for future in futures), key=peak_memory) + futures = [pool.submit(_allocate, v, allocations) for v in variants] + return min((future.result() for future in futures), key=placement_pressure) diff --git a/src/python/omnimalloc/allocators/greedy_cpp.py b/src/python/omnimalloc/allocators/greedy_cpp.py deleted file mode 100644 index 19521ca..0000000 --- a/src/python/omnimalloc/allocators/greedy_cpp.py +++ /dev/null @@ -1,87 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -from omnimalloc._cpp import GreedyAllocatorCpp as _GreedyAllocatorCpp -from omnimalloc.primitives import Allocation - -from .base import BaseAllocator -from .greedy_base import ( - allocate_parallel, - order_by_area, - order_by_conflict, - order_by_conflict_size, - order_by_duration, - order_by_size, - order_by_start, -) - - -class GreedyAllocatorCpp(BaseAllocator): - """C++ implementation of the base greedy allocator using first-fit strategy.""" - - supports_vector_time = True - - def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return tuple(_GreedyAllocatorCpp().allocate(list(allocations))) - - -class GreedyByDurationAllocatorCpp(GreedyAllocatorCpp): - """C++ greedy allocator sorting by duration (longest first).""" - - def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return super()._allocate(order_by_duration(allocations)) - - -class GreedyByConflictAllocatorCpp(GreedyAllocatorCpp): - """C++ greedy allocator sorting by conflict degree (most conflicted first).""" - - def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return super()._allocate(order_by_conflict(allocations)) - - -class GreedyByConflictSizeAllocatorCpp(GreedyAllocatorCpp): - """C++ greedy allocator sorting by conflict degree times size (largest first).""" - - def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return super()._allocate(order_by_conflict_size(allocations)) - - -class GreedyByStartAllocatorCpp(GreedyAllocatorCpp): - """C++ greedy allocator sorting by start time (earliest, largest ties first).""" - - def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return super()._allocate(order_by_start(allocations)) - - -class GreedyByAreaAllocatorCpp(GreedyAllocatorCpp): - """C++ greedy allocator sorting by area (size * duration, largest first).""" - - def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return super()._allocate(order_by_area(allocations)) - - -class GreedyBySizeAllocatorCpp(GreedyAllocatorCpp): - """C++ greedy allocator sorting by size (largest first).""" - - def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return super()._allocate(order_by_size(allocations)) - - -class GreedyByAllAllocatorCpp(GreedyAllocatorCpp): - """C++ greedy allocator that runs every variant and keeps the best result.""" - - def __init__(self, cores: int | None = None) -> None: - self._cores = cores - - def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - variants: tuple[BaseAllocator, ...] = ( - GreedyAllocatorCpp(), - GreedyBySizeAllocatorCpp(), - GreedyByDurationAllocatorCpp(), - GreedyByAreaAllocatorCpp(), - GreedyByConflictAllocatorCpp(), - GreedyByConflictSizeAllocatorCpp(), - GreedyByStartAllocatorCpp(), - ) - return allocate_parallel(variants, allocations, cores=self._cores) diff --git a/src/python/omnimalloc/allocators/hillclimb.py b/src/python/omnimalloc/allocators/hillclimb.py index 999f512..2eb9f1c 100644 --- a/src/python/omnimalloc/allocators/hillclimb.py +++ b/src/python/omnimalloc/allocators/hillclimb.py @@ -6,16 +6,17 @@ import random from omnimalloc._cpp import FirstFitPlacer +from omnimalloc.analysis import placement_pressure from omnimalloc.common.constants import DEFAULT_SEED, DEFAULT_TIMEOUT from omnimalloc.common.deadline import ( deadline_expired, ensure_valid_timeout, make_deadline, ) +from omnimalloc.common.validation import ensure_non_negative, ensure_positive from omnimalloc.primitives import Allocation from .greedy import GreedyAllocator -from .greedy_base import compute_conflicts, peak_memory class HillClimbAllocator(GreedyAllocator): @@ -32,18 +33,14 @@ class HillClimbAllocator(GreedyAllocator): def __init__( self, + *, max_iterations: int = 100, seed: int = DEFAULT_SEED, acceptance_temperature: float = 2.0, timeout: float | None = DEFAULT_TIMEOUT, ) -> None: - if max_iterations <= 0: - raise ValueError(f"max_iterations must be positive, got {max_iterations}") - if acceptance_temperature < 0: - raise ValueError( - f"acceptance_temperature must be non-negative, " - f"got {acceptance_temperature}" - ) + ensure_positive(max_iterations, "max_iterations") + ensure_non_negative(acceptance_temperature, "acceptance_temperature") ensure_valid_timeout(timeout) self._max_iterations = max_iterations @@ -124,15 +121,17 @@ def _should_accept( def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: deadline = make_deadline(self._timeout) rng = random.Random(self._seed) - conflicts = compute_conflicts(allocations) - placer = FirstFitPlacer(list(allocations)) - adjacency = placer.overlaps + placer = FirstFitPlacer(allocations) + adjacency = placer.conflicts + # Ids are unique (enforced by allocate()), so the placer's resident + # conflict map doubles as the degree source for the starting order. + degrees = [len(adjacency[alloc.id]) for alloc in allocations] # Start from size * conflicts^2, size, then id for deterministic ordering order = sorted( range(len(allocations)), key=lambda i: ( - allocations[i].size * conflicts[allocations[i]] ** 2, + allocations[i].size * degrees[i] ** 2, allocations[i].size, str(allocations[i].id), ), @@ -141,7 +140,7 @@ def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, .. # Greedy placement preserves order, so placed[i] corresponds to order[i] current = tuple(placer.place(order)) - current_peak = peak_memory(current) + current_peak = placement_pressure(current) best, best_peak = current, current_peak for iteration in range(self._max_iterations): @@ -156,7 +155,7 @@ def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, .. idx1, idx2 = swap order[idx1], order[idx2] = order[idx2], order[idx1] candidate = tuple(placer.place(order)) - candidate_peak = peak_memory(candidate) + candidate_peak = placement_pressure(candidate) if self._should_accept(candidate_peak, current_peak, iteration, rng): current, current_peak = candidate, candidate_peak diff --git a/src/python/omnimalloc/allocators/minimalloc.py b/src/python/omnimalloc/allocators/minimalloc.py index 82567eb..b2256af 100644 --- a/src/python/omnimalloc/allocators/minimalloc.py +++ b/src/python/omnimalloc/allocators/minimalloc.py @@ -7,7 +7,7 @@ from omnimalloc.common.constants import DEFAULT_TIMEOUT, TB from omnimalloc.common.deadline import ensure_valid_timeout -from omnimalloc.common.optional import OptionalDependencyError +from omnimalloc.common.validation import ensure_positive from omnimalloc.primitives import Allocation from .base import BaseAllocator @@ -29,7 +29,7 @@ def _require_minimalloc() -> None: import minimalloc # type: ignore except ImportError: # TODO(fpedd): Make minimalloc more easily installable via PyPI - raise OptionalDependencyError( + raise ImportError( "The MinimallocAllocator feature requires 'minimalloc' which is not " "installed.\nInstall manually: pip install git+https://github.com/google/minimalloc.git" ) from None @@ -51,18 +51,17 @@ class MinimallocAllocator(BaseAllocator): supports_vector_time = False def __init__( - self, timeout: float | None = DEFAULT_TIMEOUT, max_capacity: int = 1 * TB + self, *, timeout: float | None = DEFAULT_TIMEOUT, capacity: int = 1 * TB ) -> None: _require_minimalloc() ensure_valid_timeout(timeout) - if max_capacity <= 0: - raise ValueError(f"max_capacity must be positive, got {max_capacity}") + ensure_positive(capacity, "capacity") self._timeout = timeout - self._max_capacity = max_capacity + self._capacity = capacity def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: problem = mm.Problem(buffers=[_to_buffer(a) for a in allocations]) - problem.capacity = self._max_capacity + problem.capacity = self._capacity params = mm.SolverParams() # minimalloc's own default timeout is infinite, matching None here; diff --git a/src/python/omnimalloc/allocators/omni.py b/src/python/omnimalloc/allocators/omni.py index 8f368a9..aeb6817 100644 --- a/src/python/omnimalloc/allocators/omni.py +++ b/src/python/omnimalloc/allocators/omni.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # -from omnimalloc._cpp import OmniAllocatorCpp as _OmniAllocatorCpp +from omnimalloc._cpp import omni_place from omnimalloc.common.constants import DEFAULT_WORK_BUDGET from omnimalloc.common.deadline import ensure_valid_budget from omnimalloc.primitives import Allocation @@ -23,10 +23,9 @@ class OmniAllocator(BaseAllocator): supports_vector_time = True - def __init__(self, linearize_budget: int | None = DEFAULT_WORK_BUDGET) -> None: + def __init__(self, *, linearize_budget: int | None = DEFAULT_WORK_BUDGET) -> None: ensure_valid_budget(linearize_budget, name="linearize_budget") self._linearize_budget = linearize_budget def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - cpp_allocator = _OmniAllocatorCpp(self._linearize_budget) - return tuple(cpp_allocator.allocate(list(allocations))) + return tuple(omni_place(allocations, self._linearize_budget)) diff --git a/src/python/omnimalloc/allocators/random.py b/src/python/omnimalloc/allocators/random.py index f22400d..3b8f43d 100644 --- a/src/python/omnimalloc/allocators/random.py +++ b/src/python/omnimalloc/allocators/random.py @@ -6,6 +6,7 @@ from omnimalloc._cpp import FirstFitPlacer from omnimalloc.common.constants import DEFAULT_SEED +from omnimalloc.common.validation import ensure_non_negative from omnimalloc.primitives import Allocation from .greedy import GreedyAllocator @@ -14,9 +15,8 @@ class RandomAllocator(GreedyAllocator): """Randomized allocator that tries multiple random orders and picks the best.""" - def __init__(self, num_trials: int = 100, seed: int = DEFAULT_SEED) -> None: - if num_trials < 0: - raise ValueError(f"num_trials must be non-negative, got {num_trials}") + def __init__(self, *, num_trials: int = 100, seed: int = DEFAULT_SEED) -> None: + ensure_non_negative(num_trials, "num_trials") self._seed = seed self._num_trials = num_trials @@ -26,14 +26,14 @@ def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, .. # Fresh RNG per call: repeated calls on one instance are deterministic rng = random.Random(self._seed) - placer = FirstFitPlacer(list(allocations)) + placer = FirstFitPlacer(allocations) order = list(range(len(allocations))) best_order: list[int] | None = None best_peak = 0 for _ in range(self._num_trials): rng.shuffle(order) - peak = placer.evaluate(order) + peak = placer.peak(order) if best_order is None or peak < best_peak: best_order, best_peak = list(order), peak diff --git a/src/python/omnimalloc/allocators/simulated_annealing.py b/src/python/omnimalloc/allocators/simulated_annealing.py index b7989c6..63f6234 100644 --- a/src/python/omnimalloc/allocators/simulated_annealing.py +++ b/src/python/omnimalloc/allocators/simulated_annealing.py @@ -2,74 +2,62 @@ # SPDX-License-Identifier: Apache-2.0 # -from dataclasses import dataclass - -from omnimalloc._cpp import ( - SimulatedAnnealingAllocatorCpp as _SimulatedAnnealingAllocatorCpp, -) -from omnimalloc._cpp import SimulatedAnnealingConfig as _SimulatedAnnealingConfig +from omnimalloc._cpp import simulated_annealing_place from omnimalloc.common.constants import DEFAULT_SEED, DEFAULT_TIMEOUT from omnimalloc.common.deadline import ensure_valid_timeout +from omnimalloc.common.validation import ensure_non_negative, ensure_positive from omnimalloc.primitives import Allocation from .base import BaseAllocator -@dataclass(frozen=True) -class SimulatedAnnealingConfig: - """Cooling schedule and iteration budget for `SimulatedAnnealingAllocator`.""" - - seed: int = DEFAULT_SEED - max_iterations: int = 3000 - # Percent memory worsening accepted with probability 1/e at iteration 0; - # decays geometrically by `cooling_rate` every iteration. - initial_temperature: float = 3.0 - cooling_rate: float = 0.998 - # Wall-clock budget in seconds; None disables it. - timeout: float | None = DEFAULT_TIMEOUT - - def __post_init__(self) -> None: - if self.max_iterations <= 0: - raise ValueError( - f"max_iterations must be positive, got {self.max_iterations}" - ) - if self.initial_temperature < 0: - raise ValueError( - f"initial_temperature must be non-negative, " - f"got {self.initial_temperature}" - ) - if not 0.0 < self.cooling_rate <= 1.0: - raise ValueError(f"cooling_rate must be in (0, 1], got {self.cooling_rate}") - ensure_valid_timeout(self.timeout) - - def to_cpp_config(self) -> _SimulatedAnnealingConfig: - return _SimulatedAnnealingConfig( - seed=self.seed, - max_iterations=self.max_iterations, - initial_temperature=self.initial_temperature, - cooling_rate=self.cooling_rate, - timeout=self.timeout, - ) - - class SimulatedAnnealingAllocator(BaseAllocator): """Simulated annealing over first-fit placement orders, run entirely in C++. Repeatedly swaps a peak allocation with an earlier temporal neighbor, accepting improving swaps outright and worsening ones with a probability - that anneals to zero over `max_iterations`. Because the whole search loop - (including every candidate placement) runs natively, it evaluates far more - candidates per second than an equivalent Python-orchestrated local search - such as `HillClimbAllocator`. Each iteration re-evaluates a full placement - of every allocation, so `timeout` (default 3s) bounds wall-clock time - as the input grows, independent of `max_iterations`. + that anneals to zero over `max_iterations`. `initial_temperature` is the + percent memory worsening accepted with probability 1/e at iteration 0; + it decays geometrically by `cooling_rate` every iteration. Because the + whole search loop (including every candidate placement) runs natively, + it evaluates far more candidates per second than an equivalent + Python-orchestrated local search such as `HillClimbAllocator`. Each + iteration re-evaluates a full placement of every allocation, so + `timeout` (default 3s) bounds wall-clock time as the input grows, + independent of `max_iterations`. """ supports_vector_time = True - def __init__(self, config: SimulatedAnnealingConfig | None = None) -> None: - self._config = config or SimulatedAnnealingConfig() + def __init__( + self, + *, + seed: int = DEFAULT_SEED, + max_iterations: int = 3000, + initial_temperature: float = 3.0, + cooling_rate: float = 0.998, + timeout: float | None = DEFAULT_TIMEOUT, + ) -> None: + ensure_positive(max_iterations, "max_iterations") + ensure_non_negative(initial_temperature, "initial_temperature") + if not 0.0 < cooling_rate <= 1.0: + raise ValueError(f"cooling_rate must be in (0, 1], got {cooling_rate}") + ensure_valid_timeout(timeout) + + self._seed = seed + self._max_iterations = max_iterations + self._initial_temperature = initial_temperature + self._cooling_rate = cooling_rate + self._timeout = timeout def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - cpp_allocator = _SimulatedAnnealingAllocatorCpp(self._config.to_cpp_config()) - return tuple(cpp_allocator.allocate(list(allocations))) + return tuple( + simulated_annealing_place( + allocations, + seed=self._seed, + max_iterations=self._max_iterations, + initial_temperature=self._initial_temperature, + cooling_rate=self._cooling_rate, + timeout=self._timeout, + ) + ) diff --git a/src/python/omnimalloc/allocators/supermalloc.py b/src/python/omnimalloc/allocators/supermalloc.py index d04b4db..99f226b 100644 --- a/src/python/omnimalloc/allocators/supermalloc.py +++ b/src/python/omnimalloc/allocators/supermalloc.py @@ -3,18 +3,10 @@ # import logging -import os -import sys from dataclasses import dataclass from enum import Enum -from omnimalloc._cpp import ( - Partition, - SearchOptions, - Solution, - greedy_many, - solve_many, -) +from omnimalloc._cpp import Partition, Solution, greedy_pack_portfolio, try_solve_many from omnimalloc.allocators.base import BaseAllocator from omnimalloc.common.constants import DEFAULT_TIMEOUT from omnimalloc.common.deadline import ( @@ -23,6 +15,7 @@ ensure_valid_timeout, make_deadline, ) +from omnimalloc.common.parallel import ensure_valid_num_threads, resolve_num_threads from omnimalloc.primitives.allocation import Allocation logger = logging.getLogger(__name__) @@ -33,76 +26,40 @@ class SortKey(str, Enum): AREA = "A" SECTIONS = "C" - LOWER = "L" - OVERLAPS = "O" + START = "L" + CONFLICTS = "O" SECTION_TOTAL = "T" - UPPER = "U" - WIDTH = "W" + END = "U" + DURATION = "W" SIZE = "Z" Heuristic = tuple[SortKey, ...] DEFAULT_HEURISTICS: tuple[Heuristic, ...] = ( - (SortKey.WIDTH, SortKey.AREA, SortKey.SECTION_TOTAL), - (SortKey.SECTION_TOTAL, SortKey.AREA, SortKey.WIDTH), - (SortKey.SECTION_TOTAL, SortKey.WIDTH, SortKey.AREA), + (SortKey.DURATION, SortKey.AREA, SortKey.SECTION_TOTAL), + (SortKey.SECTION_TOTAL, SortKey.AREA, SortKey.DURATION), + (SortKey.SECTION_TOTAL, SortKey.DURATION, SortKey.AREA), ) GREEDY_HEURISTICS: tuple[Heuristic, ...] = ( - (SortKey.AREA, SortKey.WIDTH, SortKey.SECTION_TOTAL), - (SortKey.AREA, SortKey.SECTION_TOTAL, SortKey.WIDTH), - (SortKey.WIDTH, SortKey.SECTION_TOTAL, SortKey.AREA), + (SortKey.AREA, SortKey.DURATION, SortKey.SECTION_TOTAL), + (SortKey.AREA, SortKey.SECTION_TOTAL, SortKey.DURATION), + (SortKey.DURATION, SortKey.SECTION_TOTAL, SortKey.AREA), (SortKey.SIZE, SortKey.AREA, SortKey.SECTION_TOTAL), (SortKey.SIZE, SortKey.SECTION_TOTAL, SortKey.AREA), - (SortKey.OVERLAPS, SortKey.AREA, SortKey.SECTION_TOTAL), - (SortKey.OVERLAPS, SortKey.SECTION_TOTAL, SortKey.AREA), - (SortKey.LOWER, SortKey.AREA, SortKey.SECTION_TOTAL), - (SortKey.UPPER, SortKey.AREA, SortKey.SECTION_TOTAL), + (SortKey.CONFLICTS, SortKey.AREA, SortKey.SECTION_TOTAL), + (SortKey.CONFLICTS, SortKey.SECTION_TOTAL, SortKey.AREA), + (SortKey.START, SortKey.AREA, SortKey.SECTION_TOTAL), + (SortKey.END, SortKey.AREA, SortKey.SECTION_TOTAL), ) -@dataclass(frozen=True) -class SupermallocConfig: - """Configuration for the SupermallocAllocator.""" - - # Wall-clock budget in seconds for greedy + search (problem setup is not - # counted against it); None lets the search run to optimality. - timeout: float | None = DEFAULT_TIMEOUT - heuristics: tuple[Heuristic, ...] = DEFAULT_HEURISTICS - cores: int | None = None - canonical: bool = True - dominance: bool = True - floor_inference: bool = True - monotonic_floor: bool = True - decompose: bool = True - - def __post_init__(self) -> None: - ensure_valid_timeout(self.timeout) - if not self.heuristics: - raise ValueError("SupermallocConfig requires at least one heuristic") - - def num_threads(self) -> int: - if self.cores is None: - return os.cpu_count() or 1 - return max(1, self.cores) - - def search_options(self) -> SearchOptions: - return SearchOptions( - canonical=self.canonical, - dominance=self.dominance, - floor_inference=self.floor_inference, - monotonic_floor=self.monotonic_floor, - decompose=self.decompose, - ) - - @dataclass(frozen=True) class _Portfolio: """Search invariants for one allocate() run.""" partitions: list[Partition] - options: SearchOptions threads: int # Absolute time.monotonic() deadline; None means the search is unbounded. deadline: float | None @@ -118,19 +75,24 @@ def solve(self, bounds: tuple[int, ...]) -> Solution | None: """Run one portfolio round, or None once the budget has expired. The budget is read once per round so the expiry check and the round's - timeout always agree. + timeout always agree. The five search switches are developer flags + that stay enabled here; ablations call `_cpp.try_solve_many` directly. """ remaining = self.remaining() if remaining is not None and remaining <= 0: return None members = [p.with_bound(b) for b in bounds for p in self.partitions] - return solve_many( + return try_solve_many( members, - sys.maxsize, - remaining, max(bounds), - self.options, - self.threads, + None, + canonical=True, + dominance=True, + floor_inference=True, + monotonic_floor=True, + decompose=True, + timeout=remaining, + num_threads=self.threads, ) @@ -142,55 +104,68 @@ def _bound_ladder(low: int, high: int, rungs: int) -> tuple[int, ...]: return tuple(b for b in unique if low < b <= high) -def _search(portfolio: _Portfolio, low: int, height: int) -> Solution | None: - """Run the concurrent bound-ladder search below the incumbent `height`.""" +def _search(portfolio: _Portfolio, low: int, peak: int) -> Solution | None: + """Run the concurrent bound-ladder search below the incumbent `peak`.""" best: Solution | None = None rungs = max(1, portfolio.threads // len(portfolio.partitions)) - while height > low: - result = portfolio.solve(_bound_ladder(low, height, rungs)) + while peak > low: + result = portfolio.solve(_bound_ladder(low, peak, rungs)) if result is None: break - best, height = result, result.height + best, peak = result, result.peak - if height > low and portfolio.expired(): - logger.debug("Supermalloc timed out above lower bound: %d > %d", height, low) + if peak > low and portfolio.expired(): + logger.debug("Supermalloc timed out above lower bound: %d > %d", peak, low) return best class SupermallocAllocator(BaseAllocator): - """Portfolio branch-and-bound allocator built on a C++ partition solver.""" + """Portfolio branch-and-bound allocator built on a C++ partition solver. + + `timeout` (default 3s) is the wall-clock budget for greedy + search + (problem setup is not counted against it); `None` lets the search run to + optimality. `num_threads=None` uses all cores. + """ # The partition solver's section grid needs a linear timeline supports_vector_time = False - def __init__(self, config: SupermallocConfig | None = None) -> None: - super().__init__() - self._config = config or SupermallocConfig() + def __init__( + self, + *, + timeout: float | None = DEFAULT_TIMEOUT, + heuristics: tuple[Heuristic, ...] = DEFAULT_HEURISTICS, + num_threads: int | None = None, + ) -> None: + ensure_valid_timeout(timeout) + ensure_valid_num_threads(num_threads) + if not heuristics: + raise ValueError("SupermallocAllocator requires at least one heuristic") + self._timeout = timeout + self._heuristics = heuristics + self._num_threads = num_threads def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - threads = self._config.num_threads() + threads = resolve_num_threads(self._num_threads) base = Partition.from_allocations(allocations) - heuristics = self._config.heuristics - heuristic_codes = ["".join(h) for h in heuristics] + heuristic_codes = ["".join(h) for h in self._heuristics] greedy_codes = [*heuristic_codes, ""] + [ - "".join(h) for h in GREEDY_HEURISTICS if h not in heuristics + "".join(h) for h in GREEDY_HEURISTICS if h not in self._heuristics ] portfolio = _Portfolio( partitions=[base.reorder(code) for code in heuristic_codes], - options=self._config.search_options(), threads=threads, # Deliberately started after partition construction and the # reorders above: the budget covers greedy + search only. - deadline=make_deadline(self._config.timeout), + deadline=make_deadline(self._timeout), ) - incumbent = greedy_many(base, greedy_codes, portfolio.remaining(), threads) - best = _search(portfolio, base.lower_bound, incumbent.height) + incumbent = greedy_pack_portfolio( + base, greedy_codes, portfolio.remaining(), threads + ) + best = _search(portfolio, base.lower_bound, incumbent.peak) if best is None: best = incumbent - return tuple( - a.with_offset(offset) - for a, offset in zip(best.allocations, best.offsets, strict=True) - ) + return tuple(best.allocations) diff --git a/src/python/omnimalloc/allocators/tabu_search.py b/src/python/omnimalloc/allocators/tabu_search.py index dbaf572..208084a 100644 --- a/src/python/omnimalloc/allocators/tabu_search.py +++ b/src/python/omnimalloc/allocators/tabu_search.py @@ -2,72 +2,60 @@ # SPDX-License-Identifier: Apache-2.0 # -from dataclasses import dataclass - -from omnimalloc._cpp import TabuSearchAllocatorCpp as _TabuSearchAllocatorCpp -from omnimalloc._cpp import TabuSearchConfig as _TabuSearchConfig +from omnimalloc._cpp import tabu_search_place from omnimalloc.common.constants import DEFAULT_SEED, DEFAULT_TIMEOUT from omnimalloc.common.deadline import ensure_valid_timeout +from omnimalloc.common.validation import ensure_positive from omnimalloc.primitives import Allocation from .base import BaseAllocator -@dataclass(frozen=True) -class TabuSearchConfig: - """Neighborhood size, iteration budget, and tabu memory for TabuSearchAllocator.""" - - seed: int = DEFAULT_SEED - max_iterations: int = 500 - # Candidate swaps sampled per iteration - neighborhood_size: int = 20 - # Iterations a reversed swap stays forbidden - tabu_tenure: int = 15 - # Wall-clock budget in seconds; None disables it. - timeout: float | None = DEFAULT_TIMEOUT - - def __post_init__(self) -> None: - if self.max_iterations <= 0: - raise ValueError( - f"max_iterations must be positive, got {self.max_iterations}" - ) - if self.neighborhood_size <= 0: - raise ValueError( - f"neighborhood_size must be positive, got {self.neighborhood_size}" - ) - if self.tabu_tenure <= 0: - raise ValueError(f"tabu_tenure must be positive, got {self.tabu_tenure}") - ensure_valid_timeout(self.timeout) - - def to_cpp_config(self) -> _TabuSearchConfig: - return _TabuSearchConfig( - seed=self.seed, - max_iterations=self.max_iterations, - neighborhood_size=self.neighborhood_size, - tabu_tenure=self.tabu_tenure, - timeout=self.timeout, - ) - - class TabuSearchAllocator(BaseAllocator): """Tabu search over first-fit placement orders, run entirely in C++. - Each iteration samples a neighborhood of candidate swaps between a - currently-peak allocation and an earlier temporal neighbor, and moves to - the best-scoring candidate that is not tabu (or, per the aspiration - criterion, a tabu one that beats the best solution found so far). The - swap just made is then forbidden from being immediately reversed for - `tabu_tenure` iterations, which helps the search escape local optima - without cycling between the same two orders. Each iteration evaluates - `neighborhood_size` full placements, so `timeout` (default 3s) bounds - wall-clock time as the input grows, independent of `max_iterations`. + Each iteration samples a neighborhood of `neighborhood_size` candidate + swaps between a currently-peak allocation and an earlier temporal + neighbor, and moves to the best-scoring candidate that is not tabu (or, + per the aspiration criterion, a tabu one that beats the best solution + found so far). The swap just made is then forbidden from being + immediately reversed for `tabu_tenure` iterations, which helps the + search escape local optima without cycling between the same two orders. + Each iteration evaluates `neighborhood_size` full placements, so + `timeout` (default 3s) bounds wall-clock time as the input grows, + independent of `max_iterations`. """ supports_vector_time = True - def __init__(self, config: TabuSearchConfig | None = None) -> None: - self._config = config or TabuSearchConfig() + def __init__( + self, + *, + seed: int = DEFAULT_SEED, + max_iterations: int = 500, + neighborhood_size: int = 20, + tabu_tenure: int = 15, + timeout: float | None = DEFAULT_TIMEOUT, + ) -> None: + ensure_positive(max_iterations, "max_iterations") + ensure_positive(neighborhood_size, "neighborhood_size") + ensure_positive(tabu_tenure, "tabu_tenure") + ensure_valid_timeout(timeout) + + self._seed = seed + self._max_iterations = max_iterations + self._neighborhood_size = neighborhood_size + self._tabu_tenure = tabu_tenure + self._timeout = timeout def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - cpp_allocator = _TabuSearchAllocatorCpp(self._config.to_cpp_config()) - return tuple(cpp_allocator.allocate(list(allocations))) + return tuple( + tabu_search_place( + allocations, + seed=self._seed, + max_iterations=self._max_iterations, + neighborhood_size=self._neighborhood_size, + tabu_tenure=self._tabu_tenure, + timeout=self._timeout, + ) + ) diff --git a/src/python/omnimalloc/allocators/telamalloc.py b/src/python/omnimalloc/allocators/telamalloc.py index 34ea3c9..fab890b 100644 --- a/src/python/omnimalloc/allocators/telamalloc.py +++ b/src/python/omnimalloc/allocators/telamalloc.py @@ -2,43 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 # -from dataclasses import dataclass - -from omnimalloc._cpp import TelamallocAllocatorCpp as _TelamallocAllocatorCpp -from omnimalloc._cpp import TelamallocConfig as _TelamallocConfig +from omnimalloc._cpp import telamalloc_place from omnimalloc.common.constants import DEFAULT_SEED, DEFAULT_TIMEOUT from omnimalloc.common.deadline import ensure_valid_timeout +from omnimalloc.common.validation import ensure_non_negative from omnimalloc.primitives import Allocation from .base import BaseAllocator -@dataclass(frozen=True) -class TelamallocConfig: - """Search budgets for TelamallocAllocator.""" - - seed: int = DEFAULT_SEED - # Eviction (backtrack) budget per capacity attempt; an attempt that - # exhausts it reports the capacity as unreachable. - max_backtracks: int = 10000 - # Wall-clock budget in seconds; None disables it. - timeout: float | None = DEFAULT_TIMEOUT - - def __post_init__(self) -> None: - if self.max_backtracks < 0: - raise ValueError( - f"max_backtracks must be non-negative, got {self.max_backtracks}" - ) - ensure_valid_timeout(self.timeout) - - def to_cpp_config(self) -> _TelamallocConfig: - return _TelamallocConfig( - seed=self.seed, - max_backtracks=self.max_backtracks, - timeout=self.timeout, - ) - - class TelamallocAllocator(BaseAllocator): """TelaMalloc-style allocator (Maas et al., ASPLOS 2023), run entirely in C++. @@ -47,23 +19,41 @@ class TelamallocAllocator(BaseAllocator): conflict-directed backtracking; it ships in Google's TPUv4 and Pixel 6. No reference implementation is public, so this is an adaptation that minimizes peak memory instead of testing satisfiability: independent - phases (connected components of the temporal-overlap graph) are each - packed in the paper's tiered order (longest lifetime, then largest size) - with min-conflict eviction as minor backtracking and squeaky-wheel - restarts as major backtracking, while a binary search drives each phase's - capacity from a first-fit incumbent down toward its load lower bound. An - occasional seeded random-walk repair breaks min-conflict cycles; results - are deterministic for a fixed `seed`, and setting `timeout` (default - 3s) to 0 makes them reproducible across machines via `max_backtracks` - alone. + phases (connected components of the conflict graph) are each packed in + the paper's tiered order (longest lifetime, then largest size) with + min-conflict eviction as minor backtracking and squeaky-wheel restarts + as major backtracking, while a binary search drives each phase's + capacity from a first-fit incumbent down toward its load lower bound. + `max_backtracks` is the eviction budget per capacity attempt; an attempt + that exhausts it reports the capacity as unreachable. An occasional + seeded random-walk repair breaks min-conflict cycles; results are + deterministic for a fixed `seed`, and `timeout=None` (default 3s) + makes them reproducible across machines via `max_backtracks` alone. """ # The phase decomposition and load bounds sweep a linear timeline supports_vector_time = False - def __init__(self, config: TelamallocConfig | None = None) -> None: - self._config = config or TelamallocConfig() + def __init__( + self, + *, + seed: int = DEFAULT_SEED, + max_backtracks: int = 10000, + timeout: float | None = DEFAULT_TIMEOUT, + ) -> None: + ensure_non_negative(max_backtracks, "max_backtracks") + ensure_valid_timeout(timeout) + + self._seed = seed + self._max_backtracks = max_backtracks + self._timeout = timeout def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - cpp_allocator = _TelamallocAllocatorCpp(self._config.to_cpp_config()) - return tuple(cpp_allocator.allocate(list(allocations))) + return tuple( + telamalloc_place( + allocations, + seed=self._seed, + max_backtracks=self._max_backtracks, + timeout=self._timeout, + ) + ) diff --git a/src/python/omnimalloc/allocators/utils.py b/src/python/omnimalloc/allocators/utils.py index ea24196..df423bf 100644 --- a/src/python/omnimalloc/allocators/utils.py +++ b/src/python/omnimalloc/allocators/utils.py @@ -5,21 +5,11 @@ from typing import Final from .base import BaseAllocator -from .greedy import GreedyBySizeAllocator +from .omni import OmniAllocator -DEFAULT_ALLOCATOR: Final[str] = GreedyBySizeAllocator.name() +DEFAULT_ALLOCATOR: Final[str] = OmniAllocator.name() -def get_available_allocators() -> tuple[str, ...]: +def available_allocators() -> tuple[str, ...]: """Return a tuple of available allocator names (including user-registered).""" return tuple(BaseAllocator.registry().keys()) - - -def get_default_allocator() -> str: - """Return the name of the default allocator.""" - return DEFAULT_ALLOCATOR - - -def get_allocator_by_name(name: str) -> type[BaseAllocator]: - """Get an allocator class by its registered name.""" - return BaseAllocator.registry()[name] diff --git a/src/python/omnimalloc/analysis/__init__.py b/src/python/omnimalloc/analysis/__init__.py index 611eaa6..b2f6cf0 100644 --- a/src/python/omnimalloc/analysis/__init__.py +++ b/src/python/omnimalloc/analysis/__init__.py @@ -2,18 +2,16 @@ # SPDX-License-Identifier: Apache-2.0 # -from .clock import ensure_uniform_dim as ensure_uniform_dim -from .clock import time_components as time_components -from .conflicts import get_conflict_degrees as get_conflict_degrees -from .conflicts import get_conflicts as get_conflicts -from .linearize import try_linearize as try_linearize -from .pressure import get_closure_pressure as get_closure_pressure -from .pressure import ( - get_per_allocation_closure_pressure as get_per_allocation_closure_pressure, +from ._conflicts import conflict_degrees as conflict_degrees +from ._conflicts import conflicts as conflicts +from ._pressure import closure_pressure as closure_pressure +from ._pressure import ( + closure_pressure_per_allocation as closure_pressure_per_allocation, ) -from .pressure import ( - get_per_allocation_placement_pressure as get_per_allocation_placement_pressure, +from ._pressure import placement_pressure as placement_pressure +from ._pressure import ( + placement_pressure_per_allocation as placement_pressure_per_allocation, ) -from .pressure import get_per_allocation_pressure as get_per_allocation_pressure -from .pressure import get_placement_pressure as get_placement_pressure -from .pressure import get_pressure as get_pressure +from ._pressure import pressure as pressure +from ._pressure import pressure_per_allocation as pressure_per_allocation +from .linearize import try_linearize as try_linearize diff --git a/src/python/omnimalloc/analysis/_conflicts.py b/src/python/omnimalloc/analysis/_conflicts.py new file mode 100644 index 0000000..7513183 --- /dev/null +++ b/src/python/omnimalloc/analysis/_conflicts.py @@ -0,0 +1,46 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from collections.abc import Sequence + +from omnimalloc._cpp import conflict_degrees as _conflict_degrees +from omnimalloc._cpp import conflicts as _conflicts +from omnimalloc.common.constants import DEFAULT_WORK_BUDGET +from omnimalloc.common.deadline import ensure_valid_budget +from omnimalloc.primitives.allocation import Allocation, IdType +from omnimalloc.primitives.utils import ensure_unique_ids + + +def conflicts( + allocations: Sequence[Allocation], work_budget: int | None = DEFAULT_WORK_BUDGET +) -> dict[IdType, set[IdType]]: + """Conflict map: each allocation's id to the ids it must not share addresses with. + + The happens-before conflict relation every placement packs against. + Symmetric and total — every allocation is a key, conflict-free ones map + to an empty set. Handles scalar and vector-clock lifetimes (mutually + concurrent clocks conflict). C++ sweep (analysis/conflicts.cpp). Raises + `RuntimeError` once the sweep (quadratic in the worst case) + exceeds `work_budget`; pass `None` to always compute. + """ + ensure_valid_budget(work_budget) + ensure_unique_ids(allocations) + return _conflicts(allocations, work_budget) + + +def conflict_degrees( + allocations: Sequence[Allocation], work_budget: int | None = DEFAULT_WORK_BUDGET +) -> list[int]: + """Conflict count per allocation, aligned with input order. + + The degree sequence of the conflict relation behind `conflicts`, + without materializing the adjacency. Positional rather than id-keyed, + so duplicate ids are allowed and counted with multiplicity. Scalar + lifetimes count in O(N log N) without enumerating pairs; vector clocks + take the pairwise C++ sweep (quadratic in the worst case), raising + `RuntimeError` once it exceeds `work_budget` — pass `None` to + always compute. + """ + ensure_valid_budget(work_budget) + return _conflict_degrees(allocations, work_budget) diff --git a/src/python/omnimalloc/analysis/pressure.py b/src/python/omnimalloc/analysis/_pressure.py similarity index 51% rename from src/python/omnimalloc/analysis/pressure.py rename to src/python/omnimalloc/analysis/_pressure.py index eda4175..32dfacf 100644 --- a/src/python/omnimalloc/analysis/pressure.py +++ b/src/python/omnimalloc/analysis/_pressure.py @@ -2,15 +2,18 @@ # SPDX-License-Identifier: Apache-2.0 # -# DEFAULT_WORK_BUDGET and DEFAULT_CLOSURE_CAP bound the exact queries so -# implicit callers (`Pool.pressure`) can never hang or OOM on huge -# vector-clock instances. +from collections.abc import Sequence + +from omnimalloc._cpp import antichain_pressure +from omnimalloc._cpp import ( + antichain_pressure_per_allocation as _antichain_pressure_per_allocation, +) +from omnimalloc._cpp import closure_pressure as _closure_pressure +from omnimalloc._cpp import ( + closure_pressure_per_allocation as _closure_pressure_per_allocation, +) from omnimalloc._cpp import ( - antichain_pressure, - closure_pressure, - per_allocation_antichain_pressure, - per_allocation_closure_pressure, - per_allocation_placement_pressure, + placement_pressure_per_allocation as _placement_pressure_per_allocation, ) from omnimalloc.common.constants import DEFAULT_CLOSURE_CAP, DEFAULT_WORK_BUDGET from omnimalloc.common.deadline import ensure_valid_budget @@ -18,8 +21,8 @@ from omnimalloc.primitives.utils import ensure_unique_ids -def get_pressure( - allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET +def pressure( + allocations: Sequence[Allocation], work_budget: int | None = DEFAULT_WORK_BUDGET ) -> int: """Peak memory pressure: exact max-weight antichain of the happens-before order. @@ -29,40 +32,35 @@ def get_pressure( point (Helly), so the peak cut attains the max-weight antichain. Genuinely partial orders solve the exact C++ antichain (weighted Dilworth via min flow), built to certify allocator optimality on small and medium - instances, not for the 10k+ hot path. A finite `work_budget` bounds both - phases and raises rather than hang when the flow would exceed it; pass - `None` to always compute the exact answer. + instances, not for the 10k+ hot path. Raises `RuntimeError` once + the flow work exceeds `work_budget`; pass `None` to always compute. """ ensure_valid_budget(work_budget) - return antichain_pressure(list(allocations), work_budget) + return antichain_pressure(allocations, work_budget) -def get_closure_pressure( - allocations: tuple[Allocation, ...], closure_cap: int = DEFAULT_CLOSURE_CAP +def closure_pressure( + allocations: Sequence[Allocation], closure_cap: int | None = DEFAULT_CLOSURE_CAP ) -> int: """Exact realizable peak: the max total size jointly live at one cut. C++ enumeration of the join-closure of the birth clocks. Pairwise- concurrent allocations need not share a cut, so this can sit strictly - below `get_pressure`; both soundly lower-bound any placement's peak. - Raises once the closure exceeds `closure_cap`. + below `pressure`; both soundly lower-bound any placement's peak. Raises + `RuntimeError` once the closure exceeds `closure_cap`; pass `None` to + always enumerate — memory then grows with the closure itself. """ ensure_valid_budget(closure_cap, name="closure_cap") - peak = closure_pressure(list(allocations), closure_cap) - if peak is None: - raise RuntimeError( - f"Join closure exceeded closure_cap={closure_cap}; raise the cap" - ) - return peak + return _closure_pressure(allocations, closure_cap) -def get_placement_pressure(allocations: tuple[Allocation, ...]) -> int: +def placement_pressure(allocations: Sequence[Allocation]) -> int: """Peak of a placement: the highest occupied address, max(offset + size). Simply the pressure the placement realizes after allocation — an upper - bound on `get_pressure` (and so on `get_closure_pressure`), equal to the - max entry of `get_per_allocation_placement_pressure`. Requires placed - allocations. + bound on `pressure` (and so on `closure_pressure`), equal to the max + entry of `placement_pressure_per_allocation`. Raises `ValueError` on + unplaced input. """ heights = [] for alloc in allocations: @@ -73,66 +71,61 @@ def get_placement_pressure(allocations: tuple[Allocation, ...]) -> int: return max(heights, default=0) -def get_per_allocation_pressure( - allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET +def pressure_per_allocation( + allocations: Sequence[Allocation], work_budget: int | None = DEFAULT_WORK_BUDGET ) -> dict[IdType, int]: """Peak pressure over each allocation's own lifetime, keyed by id. The max-weight antichain through each allocation: the tightest order-derived lower bound on the pressure any placement can exhibit - while that allocation is live; the max entry equals `get_pressure`. + while that allocation is live; the max entry equals `pressure`. Linearizable instances take one O(N log N) window sweep, genuinely partial orders solve one pinned antichain (min flow over the conflict neighborhood) per distinct lifetime — exact, but built to certify - placements, not for the 10k+ hot path. A finite `work_budget` bounds - the linearize attempt and each pinned flow and raises rather than hang; - pass `None` to always compute the exact answer. + placements, not for the 10k+ hot path. Raises `RuntimeError` + once the linearize attempt or a pinned flow exceeds `work_budget`; + pass `None` to always compute. """ ensure_valid_budget(work_budget) ensure_unique_ids(allocations) - peaks = per_allocation_antichain_pressure(list(allocations), work_budget) + peaks = _antichain_pressure_per_allocation(allocations, work_budget) return _keyed_by_id(allocations, peaks) -def get_per_allocation_closure_pressure( - allocations: tuple[Allocation, ...], closure_cap: int = DEFAULT_CLOSURE_CAP +def closure_pressure_per_allocation( + allocations: Sequence[Allocation], closure_cap: int | None = DEFAULT_CLOSURE_CAP ) -> dict[IdType, int]: """Exact realizable peak while each allocation is live, keyed by id. The max total size at any join-closure cut where the allocation is - live. Can sit elementwise strictly below `get_per_allocation_pressure`, + live. Can sit elementwise strictly below `pressure_per_allocation`, since pairwise-concurrent allocations need not share a cut; the max - entry equals `get_closure_pressure`. Raises once the closure exceeds - `closure_cap`. + entry equals `closure_pressure`. Raises `RuntimeError` once the + closure exceeds `closure_cap`; pass `None` to always enumerate — + memory then grows with the closure itself. """ ensure_valid_budget(closure_cap, name="closure_cap") ensure_unique_ids(allocations) - peaks = per_allocation_closure_pressure(list(allocations), closure_cap) - if peaks is None: - raise RuntimeError( - f"Join closure exceeded closure_cap={closure_cap}; raise the cap" - ) + peaks = _closure_pressure_per_allocation(allocations, closure_cap) return _keyed_by_id(allocations, peaks) -def get_per_allocation_placement_pressure( - allocations: tuple[Allocation, ...], clique_cap: bool = False +def placement_pressure_per_allocation( + allocations: Sequence[Allocation], ) -> dict[IdType, int]: """Placement-certified peak over each allocation's lifetime, keyed by id. Read off assigned offsets: the highest occupied address among each allocation and its conflict neighbors, an upper bound on every exact - per-allocation pressure whose max entry equals `get_placement_pressure`. - With `clique_cap`, entries are additionally capped by their conflict - clique's total size — elementwise tighter, but the max-equals-peak - identity no longer holds. Requires placed allocations. + per-allocation pressure whose max entry equals `placement_pressure`. + Raises `ValueError` on unplaced input. """ ensure_unique_ids(allocations) - peaks = per_allocation_placement_pressure(list(allocations), clique_cap) + peaks = _placement_pressure_per_allocation(allocations) return _keyed_by_id(allocations, peaks) def _keyed_by_id( - allocations: tuple[Allocation, ...], peaks: list[int] + allocations: Sequence[Allocation], peaks: list[int] ) -> dict[IdType, int]: return {alloc.id: peak for alloc, peak in zip(allocations, peaks, strict=True)} diff --git a/src/python/omnimalloc/analysis/clock.py b/src/python/omnimalloc/analysis/clock.py index 04273f8..22a795b 100644 --- a/src/python/omnimalloc/analysis/clock.py +++ b/src/python/omnimalloc/analysis/clock.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 # +from collections.abc import Sequence + from omnimalloc.primitives.allocation import Allocation, TimePoint, VectorClock @@ -12,7 +14,7 @@ def time_components(time_point: TimePoint) -> VectorClock: return (time_point,) -def ensure_uniform_dim(allocations: tuple[Allocation, ...]) -> int: +def uniform_dim(allocations: Sequence[Allocation]) -> int: """Return the shared clock dimension (1 if empty); raise on mixed dims.""" dims = {alloc.dim for alloc in allocations} if len(dims) > 1: diff --git a/src/python/omnimalloc/analysis/conflicts.py b/src/python/omnimalloc/analysis/conflicts.py deleted file mode 100644 index 7151f8e..0000000 --- a/src/python/omnimalloc/analysis/conflicts.py +++ /dev/null @@ -1,46 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -from omnimalloc._cpp import compute_conflict_degrees, compute_temporal_overlaps -from omnimalloc.common.constants import DEFAULT_WORK_BUDGET -from omnimalloc.common.deadline import ensure_valid_budget -from omnimalloc.primitives.allocation import Allocation, IdType -from omnimalloc.primitives.utils import ensure_unique_ids - - -def get_conflicts( - allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET -) -> dict[IdType, set[IdType]] | None: - """Temporal conflict map: each allocation's id to the ids it overlaps in time. - - The relation every placement packs against: conflicting allocations must - occupy disjoint address ranges. Symmetric and total — every allocation is - a key, conflict-free ones map to an empty set. Handles scalar and - vector-clock lifetimes (mutually concurrent clocks conflict). C++ sweep - (analysis/conflicts.cpp). A finite `work_budget` bounds the sweep - (quadratic in the worst case), giving up (None) instead of stalling; - pass `None` to always compute the relation. - """ - ensure_valid_budget(work_budget) - ensure_unique_ids(allocations) - overlaps = compute_temporal_overlaps(list(allocations), work_budget) - if overlaps is None: - return None - return {alloc.id: overlaps.get(alloc.id, set()) for alloc in allocations} - - -def get_conflict_degrees( - allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET -) -> list[int] | None: - """Temporal conflict count per allocation, aligned with input order. - - The degree sequence of the conflict relation behind `get_conflicts`, - from the same C++ sweep without materializing the adjacency. Positional - rather than id-keyed, so duplicate ids are allowed and counted with - multiplicity. A finite `work_budget` bounds the pairwise sweep - (quadratic in the worst case), giving up (None) instead of stalling; - pass `None` to always count. - """ - ensure_valid_budget(work_budget) - return compute_conflict_degrees(list(allocations), work_budget) diff --git a/src/python/omnimalloc/analysis/linearize.py b/src/python/omnimalloc/analysis/linearize.py index cccc264..b2708cd 100644 --- a/src/python/omnimalloc/analysis/linearize.py +++ b/src/python/omnimalloc/analysis/linearize.py @@ -2,16 +2,18 @@ # SPDX-License-Identifier: Apache-2.0 # +from collections.abc import Sequence + from omnimalloc._cpp import try_linearize as _try_linearize from omnimalloc.common.constants import DEFAULT_WORK_BUDGET from omnimalloc.common.deadline import ensure_valid_budget from omnimalloc.primitives.allocation import Allocation -from .clock import ensure_uniform_dim +from .clock import uniform_dim def try_linearize( - allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET + allocations: Sequence[Allocation], work_budget: int | None = DEFAULT_WORK_BUDGET ) -> tuple[Allocation, ...] | None: """Synthesize scalar lifetimes with the identical conflict relation, or None. @@ -21,12 +23,12 @@ def try_linearize( intervals can realize. A success unlocks scalar-only allocators (minimalloc, supermalloc) for that instance; offsets carry over unchanged since the conflict relation — and thus the packing problem — is - identical. Implemented in C++ (analysis/linearize.cpp). - A finite `work_budget` bounds the dominance-counting phase, giving up - undecided (None) instead of stalling; pass `None` to always decide. + identical. Implemented in C++ (analysis/linearize.cpp). `None` means no + linearization was obtained: the order is not an interval order, or + deciding would exceed `work_budget`; pass `None` to always decide. """ ensure_valid_budget(work_budget) - if ensure_uniform_dim(allocations) == 1: - return allocations - linearized = _try_linearize(list(allocations), work_budget) + if uniform_dim(allocations) == 1: + return tuple(allocations) + linearized = _try_linearize(allocations, work_budget) return None if linearized is None else tuple(linearized) diff --git a/src/python/omnimalloc/benchmark/__init__.py b/src/python/omnimalloc/benchmark/__init__.py index b9f39bf..aac7aba 100644 --- a/src/python/omnimalloc/benchmark/__init__.py +++ b/src/python/omnimalloc/benchmark/__init__.py @@ -8,6 +8,7 @@ from .results import BenchmarkResult as BenchmarkResult from .results import plot_benchmark as plot_benchmark from .results import save_benchmark as save_benchmark +from .sources import DEFAULT_SOURCE as DEFAULT_SOURCE from .sources import BaseSource as BaseSource from .sources import HighContentionSource as HighContentionSource from .sources import HuggingfaceSource as HuggingfaceSource @@ -19,5 +20,4 @@ from .sources import SequentialSource as SequentialSource from .sources import TilingSource as TilingSource from .sources import UniformSource as UniformSource -from .sources import get_available_sources as get_available_sources -from .sources import get_default_source as get_default_source +from .sources import available_sources as available_sources diff --git a/src/python/omnimalloc/benchmark/benchmark.py b/src/python/omnimalloc/benchmark/benchmark.py index 353a2f5..00dcf19 100644 --- a/src/python/omnimalloc/benchmark/benchmark.py +++ b/src/python/omnimalloc/benchmark/benchmark.py @@ -4,13 +4,13 @@ import logging -from omnimalloc import run_allocation, validate_allocation -from omnimalloc.allocators import BaseAllocator, get_available_allocators +from omnimalloc import allocate, validate_allocation +from omnimalloc.allocators import BaseAllocator, available_allocators from omnimalloc.primitives import IdType, Pool from .results import BenchmarkCampaign, BenchmarkReport, BenchmarkResult from .results.utils import get_date_time_snake_case -from .sources import BaseSource, get_default_source +from .sources import DEFAULT_SOURCE, BaseSource from .timer import Timer from .utils import tqdm @@ -82,7 +82,7 @@ def _benchmark_result( validate: bool, ) -> BenchmarkResult: with Timer() as timer: - allocated_pool = run_allocation(pool, allocator, validate=False) + allocated_pool = allocate(pool, allocator, validate=False) if validate: validate_allocation(allocated_pool) @@ -166,7 +166,7 @@ def run_benchmark( - (10, 100, 1000): Test with multiple sizes (parameterizable) - ("model1", "model2"): Test specific models (fixed sources) - 5: Test first 5 models (fixed sources) - - {"random_source": (10, 100), "minimalloc_source": 5}: + - {"random": (10, 100), "minimalloc": 5}: per-source variants (sources without an entry use defaults) - None: Use defaults (all models for fixed, 100 for parameterizable) campaign_id: Unique identifier for this campaign. @@ -176,8 +176,8 @@ def run_benchmark( Returns: BenchmarkCampaign containing all reports. """ - allocators = allocators or get_available_allocators() - sources = sources or (get_default_source(),) + allocators = allocators or available_allocators() + sources = sources or (DEFAULT_SOURCE,) campaign_id = campaign_id or "campaign_" + get_date_time_snake_case() reports = [] diff --git a/src/python/omnimalloc/benchmark/converters/model.py b/src/python/omnimalloc/benchmark/converters/model.py index 04a7d21..c5f6d0c 100644 --- a/src/python/omnimalloc/benchmark/converters/model.py +++ b/src/python/omnimalloc/benchmark/converters/model.py @@ -6,7 +6,14 @@ import numpy as np -from omnimalloc.primitives import Allocation, BufferKind, IdType, Memory, Pool, System +from omnimalloc.primitives import ( + Allocation, + AllocationKind, + IdType, + Memory, + Pool, + System, +) @dataclass(frozen=True) @@ -14,7 +21,7 @@ class Buffer: id: IdType shape: tuple[int, ...] dtype: np.dtype[np.generic] - kind: BufferKind + kind: AllocationKind def __post_init__(self) -> None: if not all(isinstance(dim, int) and dim > 0 for dim in self.shape): @@ -80,7 +87,7 @@ def _compute_buffer_lifetimes( # Apply infinite lifetime constraints (at least one step for op-less models) max_index = max(len(model.ops) - 1, 0) for buffer in model.buffers.values(): - if (buffer.kind == BufferKind.CONSTANT and const_inf_lifetime) or ( + if (buffer.kind == AllocationKind.CONSTANT and const_inf_lifetime) or ( buffer.kind.is_io and io_inf_lifetime ): buffer_to_first_index[buffer] = 0 @@ -107,7 +114,7 @@ def _create_allocations( ) for buffer in model.buffers.values() if ( - (include_const or buffer.kind != BufferKind.CONSTANT) + (include_const or buffer.kind != AllocationKind.CONSTANT) and (include_io or not buffer.kind.is_io) # Buffers referenced by no op have no lifetime and need no memory and buffer in buffer_to_first_index @@ -148,9 +155,9 @@ def model_to_pools( ) # Group allocations by kind - allocations_by_kind: dict[BufferKind, list[Allocation]] = {} + allocations_by_kind: dict[AllocationKind, list[Allocation]] = {} for alloc in allocations: - kind = alloc.kind if alloc.kind is not None else BufferKind.WORKSPACE + kind = alloc.kind if alloc.kind is not None else AllocationKind.WORKSPACE allocations_by_kind.setdefault(kind, []).append(alloc) return tuple( diff --git a/src/python/omnimalloc/benchmark/converters/onnx.py b/src/python/omnimalloc/benchmark/converters/onnx.py index da4dd94..81006ab 100644 --- a/src/python/omnimalloc/benchmark/converters/onnx.py +++ b/src/python/omnimalloc/benchmark/converters/onnx.py @@ -6,7 +6,7 @@ from pathlib import Path from omnimalloc.common.optional import require_optional -from omnimalloc.primitives import BufferKind +from omnimalloc.primitives import AllocationKind from .model import Buffer, Model, Op @@ -56,13 +56,13 @@ def _add_buffer(buffer: Buffer) -> None: # Legacy IR re-lists initializers under graph inputs; those are constants. if inp.name in buffers: continue - _add_buffer(_value_info_to_buffer(inp, BufferKind.INPUT)) + _add_buffer(_value_info_to_buffer(inp, AllocationKind.INPUT)) for out in graph.output: - _add_buffer(_value_info_to_buffer(out, BufferKind.OUTPUT)) + _add_buffer(_value_info_to_buffer(out, AllocationKind.OUTPUT)) for val in graph.value_info: - _add_buffer(_value_info_to_buffer(val, BufferKind.WORKSPACE)) + _add_buffer(_value_info_to_buffer(val, AllocationKind.WORKSPACE)) ops = {} for idx, node in enumerate(graph.node): @@ -88,11 +88,13 @@ def _tensor_proto_to_buffer(tensor: onnx.TensorProto) -> Buffer: id=tensor.name, shape=shape, dtype=onnx.helper.tensor_dtype_to_np_dtype(tensor.data_type), - kind=BufferKind.CONSTANT, + kind=AllocationKind.CONSTANT, ) -def _value_info_to_buffer(value_info: onnx.ValueInfoProto, kind: BufferKind) -> Buffer: +def _value_info_to_buffer( + value_info: onnx.ValueInfoProto, kind: AllocationKind +) -> Buffer: tt = value_info.type.tensor_type original_shape = tuple(int(dim.dim_value) for dim in tt.shape.dim) shape = tuple(dim for dim in original_shape if dim > 0) diff --git a/src/python/omnimalloc/benchmark/results/export.py b/src/python/omnimalloc/benchmark/results/export.py index 8efd913..45701f1 100644 --- a/src/python/omnimalloc/benchmark/results/export.py +++ b/src/python/omnimalloc/benchmark/results/export.py @@ -45,7 +45,7 @@ def _write_metadata(base_dir: Path, campaign: BenchmarkCampaign) -> None: def _write_campaign_visualization(base_dir: Path, campaign: BenchmarkCampaign) -> None: campaign_viz_file = base_dir / "campaign_overview.pdf" - plot_benchmark(campaign, file_path=campaign_viz_file, show_inline=False) + plot_benchmark(campaign, campaign_viz_file) def _create_zip_archive(output_path: Path, base_dir: Path, final_path: Path) -> None: @@ -69,7 +69,7 @@ def _write_iterations( for i, result in enumerate(report.results): iteration_file = iterations_dir / f"iteration_{i}.pdf" - result.visualize(file_path=iteration_file, show_inline=False) + result.visualize(iteration_file) pbar.update(1) diff --git a/src/python/omnimalloc/benchmark/results/result.py b/src/python/omnimalloc/benchmark/results/result.py index 636fe05..2b6e82f 100644 --- a/src/python/omnimalloc/benchmark/results/result.py +++ b/src/python/omnimalloc/benchmark/results/result.py @@ -43,7 +43,5 @@ def allocation_efficiency(self) -> float: def num_allocations(self) -> int: return len(self.entity.allocations) - def visualize( - self, file_path: Path | str | None = None, show_inline: bool = False - ) -> None: - plot_allocation(self.entity, file_path=file_path, show_inline=show_inline) + def visualize(self, path: Path | str | None = None) -> None: + plot_allocation(self.entity, path) diff --git a/src/python/omnimalloc/benchmark/results/visualize.py b/src/python/omnimalloc/benchmark/results/visualize.py index 929499f..2d7b518 100644 --- a/src/python/omnimalloc/benchmark/results/visualize.py +++ b/src/python/omnimalloc/benchmark/results/visualize.py @@ -247,9 +247,8 @@ def _create_figure(num_sources: int) -> tuple[Figure, list[Axes]]: def _visualize_campaign( campaign: BenchmarkCampaign, - file_path: Path | str | None, - show_inline: bool, -) -> Path | None: + path: Path | str | None, +) -> None: source_names = campaign.source_names allocator_names = campaign.allocator_names reports_by_source = campaign.reports_by_source_allocator_variant @@ -271,19 +270,16 @@ def _visualize_campaign( _add_footer(campaign, fig) _add_legend(fig, allocator_names) - if show_inline: + if path is None: plt.show() - - if file_path is not None: - file_path = Path(file_path) - file_path.parent.mkdir(parents=True, exist_ok=True) - fig.savefig(file_path, bbox_inches="tight", format="pdf") - logger.info(f"Visualization saved to {file_path}") + else: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path, bbox_inches="tight", format="pdf") + logger.info(f"Visualization saved to {path}") plt.close(fig) - return file_path - def _canonicalize_artifact( artifact: BenchmarkResult | BenchmarkReport | BenchmarkCampaign, @@ -297,21 +293,13 @@ def _canonicalize_artifact( def plot_benchmark( artifact: BenchmarkResult | BenchmarkReport | BenchmarkCampaign, - file_path: Path | str | None = None, - show_inline: bool = False, -) -> Path | None: - """Visualize benchmark results. - - Args: - artifact: The benchmark artifact to visualize. - file_path: Optional path to save the plot. - show_inline: Whether to display inline (for notebooks). - - Returns: - Path to the saved file, or None if not saved. + path: Path | str | None = None, +) -> None: + """Plot a benchmark artifact: `path=None` displays the figure, `path=...` saves it. + + Raises `ImportError` without matplotlib. """ if not HAS_MATPLOTLIB: require_optional("matplotlib", "benchmark visualization") - campaign = _canonicalize_artifact(artifact) - return _visualize_campaign(campaign, file_path, show_inline) + _visualize_campaign(_canonicalize_artifact(artifact), path) diff --git a/src/python/omnimalloc/benchmark/sources/__init__.py b/src/python/omnimalloc/benchmark/sources/__init__.py index 9132209..25ca227 100644 --- a/src/python/omnimalloc/benchmark/sources/__init__.py +++ b/src/python/omnimalloc/benchmark/sources/__init__.py @@ -17,6 +17,4 @@ from .sync_patterns import SyncPatternSource as SyncPatternSource from .tiling import TilingSource as TilingSource from .utils import DEFAULT_SOURCE as DEFAULT_SOURCE -from .utils import get_available_sources as get_available_sources -from .utils import get_default_source as get_default_source -from .utils import get_source_by_name as get_source_by_name +from .utils import available_sources as available_sources diff --git a/src/python/omnimalloc/benchmark/sources/base.py b/src/python/omnimalloc/benchmark/sources/base.py index db8d4be..9f30821 100644 --- a/src/python/omnimalloc/benchmark/sources/base.py +++ b/src/python/omnimalloc/benchmark/sources/base.py @@ -3,6 +3,7 @@ # from abc import abstractmethod +from typing import ClassVar from omnimalloc.common.registry import Registered from omnimalloc.primitives import Allocation, IdType, Memory, Pool, System @@ -11,6 +12,9 @@ class BaseSource(Registered): """Base class for benchmark allocation sources with automatic registry. + Registry keys drop the class-role token: RandomSource registers as + "random". + Sources provide workloads at different abstraction levels. Subclasses must implement `get_allocations()`. Higher-level methods (pools, memories, systems) have default implementations that build @@ -23,6 +27,8 @@ class BaseSource(Registered): (e.g., Huggingface) """ + _strip_suffix: ClassVar[str] = "Source" + def __init__( self, num_allocations: int = 100, diff --git a/src/python/omnimalloc/benchmark/sources/generator.py b/src/python/omnimalloc/benchmark/sources/generator.py index 4e7e418..ba10966 100644 --- a/src/python/omnimalloc/benchmark/sources/generator.py +++ b/src/python/omnimalloc/benchmark/sources/generator.py @@ -5,7 +5,7 @@ import random from omnimalloc.common.constants import DEFAULT_SEED, KB, MB -from omnimalloc.primitives import Allocation, BufferKind +from omnimalloc.primitives import Allocation, AllocationKind from .base import BaseSource @@ -22,7 +22,7 @@ def __init__( time_max: int = 10000, duration_min: int = 1, duration_max: int = 500, - kinds: tuple[BufferKind, ...] | None = None, + kinds: tuple[AllocationKind, ...] | None = None, kind_weights: tuple[float, ...] | None = None, seed: int | None = DEFAULT_SEED, ) -> None: diff --git a/src/python/omnimalloc/benchmark/sources/minimalloc.py b/src/python/omnimalloc/benchmark/sources/minimalloc.py index 92108dc..df2dfa2 100644 --- a/src/python/omnimalloc/benchmark/sources/minimalloc.py +++ b/src/python/omnimalloc/benchmark/sources/minimalloc.py @@ -2,14 +2,12 @@ # SPDX-License-Identifier: Apache-2.0 # -import csv import logging -from dataclasses import dataclass from enum import Enum -from pathlib import Path from omnimalloc.common.directories import EXTERNAL_DIR -from omnimalloc.primitives import Allocation, BufferKind, IdType, Pool +from omnimalloc.io import load_allocation +from omnimalloc.primitives import Allocation, IdType, Pool from .base import BaseSource @@ -24,61 +22,6 @@ class MinimallocSubset(str, Enum): CHALLENGING = "challenging" -@dataclass(frozen=True) -class _MinimallocBuffer: - id: IdType - lower: int - upper: int - size: int - - def __post_init__(self) -> None: - if isinstance(self.id, int) and self.id < 0: - raise ValueError(f"id must be non-negative, got {self.id}") - if self.size <= 0: - raise ValueError(f"size must be positive, got {self.size}") - if self.upper <= self.lower: - raise ValueError(f"upper ({self.upper}) <= lower ({self.lower})") - - -def _read_minimalloc_csv(file_path: Path) -> list[_MinimallocBuffer]: - buffers = [] - with Path.open(file_path, mode="r", newline="") as csvfile: - reader = csv.DictReader(csvfile) - for row in reader: - buffer = _MinimallocBuffer( - id=str(row["id"]), - lower=int(row["lower"]), - upper=int(row["upper"]), - size=int(row["size"]), - ) - buffers.append(buffer) - return buffers - - -def _from_minimalloc_csv(file_path: str | Path) -> Pool: - file_path_ = Path(file_path) - mm_buffers = _read_minimalloc_csv(file_path_) - allocations = [] - for mm_buffer in mm_buffers: - allocation = Allocation( - id=mm_buffer.id, - size=mm_buffer.size, - start=mm_buffer.lower, - end=mm_buffer.upper, - offset=None, - kind=BufferKind.WORKSPACE, - ) - allocations.append(allocation) - - pool = Pool( - id=file_path_.stem, - allocations=tuple(allocations), - offset=None, - ) - - return pool - - class MinimallocSource(BaseSource): """Load allocations from a bundled Minimalloc CSV subset. @@ -106,7 +49,7 @@ def _pools(self) -> list[Pool]: csv_dir = EXTERNAL_DIR / "minimalloc" / self.subset.value # Sort for a filesystem-independent, reproducible variant order self._cached_pools = [ - _from_minimalloc_csv(f) for f in sorted(csv_dir.glob("*.csv")) + load_allocation(f) for f in sorted(csv_dir.glob("*.csv")) ] return self._cached_pools diff --git a/src/python/omnimalloc/benchmark/sources/utils.py b/src/python/omnimalloc/benchmark/sources/utils.py index e587ceb..32f6a11 100644 --- a/src/python/omnimalloc/benchmark/sources/utils.py +++ b/src/python/omnimalloc/benchmark/sources/utils.py @@ -10,16 +10,6 @@ DEFAULT_SOURCE: Final[str] = RandomSource.name() -def get_available_sources() -> tuple[str, ...]: +def available_sources() -> tuple[str, ...]: """Return a tuple of available source names (including user-registered).""" return tuple(BaseSource.registry().keys()) - - -def get_default_source() -> str: - """Return the name of the default source.""" - return DEFAULT_SOURCE - - -def get_source_by_name(name: str) -> type[BaseSource]: - """Get a source class by its registered name.""" - return BaseSource.registry()[name] diff --git a/src/python/omnimalloc/common/__init__.py b/src/python/omnimalloc/common/__init__.py index d1f354e..cef260e 100644 --- a/src/python/omnimalloc/common/__init__.py +++ b/src/python/omnimalloc/common/__init__.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 # -from .optional import OptionalDependencyError as OptionalDependencyError from .optional import require_optional as require_optional from .optional import try_import as try_import from .registry import Registered as Registered diff --git a/src/python/omnimalloc/common/constants.py b/src/python/omnimalloc/common/constants.py index 169b3d0..a82f8e3 100644 --- a/src/python/omnimalloc/common/constants.py +++ b/src/python/omnimalloc/common/constants.py @@ -4,11 +4,6 @@ from typing import Final -# Cross-cutting defaults, owned here and only here: the C++ boundary (_cpp) -# takes every parameter explicitly, so a value can never drift between the -# languages. Algorithm-specific knobs live as field defaults on the -# allocator's own config dataclass instead. - # Shared wall-clock budget for every time-bounded allocator (seconds); # None disables the budget. DEFAULT_TIMEOUT: Final[float] = 3.0 diff --git a/src/python/omnimalloc/common/deadline.py b/src/python/omnimalloc/common/deadline.py index 0e8a305..ef462df 100644 --- a/src/python/omnimalloc/common/deadline.py +++ b/src/python/omnimalloc/common/deadline.py @@ -5,12 +5,9 @@ import math import time -# Python mirror of the C++ deadline helpers (src/cpp/common/deadline.hpp): -# one spelling of the shared timeout convention, where None disables the -# budget. The work-budget validator lives here too — same None-disables shape. - def ensure_valid_timeout(timeout: float | None) -> None: + """Raise ValueError if timeout is not positive or None (disabled).""" if timeout is not None and not (math.isfinite(timeout) and timeout > 0): raise ValueError( f"timeout must be positive or None, got {timeout}; " @@ -19,6 +16,7 @@ def ensure_valid_timeout(timeout: float | None) -> None: def ensure_valid_budget(budget: int | None, name: str = "work_budget") -> None: + """Raise ValueError if budget is not non-negative or None (disabled).""" if budget is not None and budget < 0: raise ValueError(f"{name} must be non-negative, got {budget}") @@ -34,4 +32,5 @@ def deadline_remaining(deadline: float | None) -> float | None: def deadline_expired(deadline: float | None) -> bool: + """Whether the budget has expired (False when disabled).""" return deadline is not None and time.monotonic() >= deadline diff --git a/src/python/omnimalloc/common/optional.py b/src/python/omnimalloc/common/optional.py index 39ed565..f4a360b 100644 --- a/src/python/omnimalloc/common/optional.py +++ b/src/python/omnimalloc/common/optional.py @@ -6,17 +6,13 @@ from types import ModuleType -class OptionalDependencyError(ImportError): - """Raised when an optional dependency is required but not installed.""" - - def require_optional( package_name: str, feature_name: str, install_extra: str = "all", ) -> None: """Raise an error indicating a missing optional dependency.""" - raise OptionalDependencyError( + raise ImportError( f"The {feature_name} feature requires '{package_name}' which is not " f"installed.\nInstall it with: pip install omnimalloc[{install_extra}]" ) diff --git a/src/python/omnimalloc/common/parallel.py b/src/python/omnimalloc/common/parallel.py new file mode 100644 index 0000000..7843a5a --- /dev/null +++ b/src/python/omnimalloc/common/parallel.py @@ -0,0 +1,20 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import os + + +def ensure_valid_num_threads(num_threads: int | None) -> None: + """Raise ValueError if num_threads is not positive or None (disabled).""" + if num_threads is not None and num_threads < 1: + raise ValueError( + f"num_threads must be positive or None, got {num_threads}; " + "use None for all cores" + ) + + +def resolve_num_threads(num_threads: int | None) -> int: + """Worker count for a parallel section; None resolves to all cores.""" + ensure_valid_num_threads(num_threads) + return num_threads if num_threads is not None else os.cpu_count() or 1 diff --git a/src/python/omnimalloc/common/registry.py b/src/python/omnimalloc/common/registry.py index f9a2f02..9aeb167 100644 --- a/src/python/omnimalloc/common/registry.py +++ b/src/python/omnimalloc/common/registry.py @@ -16,28 +16,37 @@ class Registered(ABC): Any direct subclass of Registered will maintain its own registry. Any subclass of that subclass that's not abstract will be registered in the direct subclass's registry. + + Registry names derive from the class name: the root's `_strip_suffix` + (its class-role suffix, e.g. "Allocator" or "Source") is stripped from + the end of the name (mid-name occurrences stay), then the remainder + converts to snake_case — the token carries zero information when the + registry is the namespace. Roots leave `_strip_suffix` empty to keep + full class names. """ _registry: ClassVar[dict[str, type[Self]]] _name: str + _strip_suffix: ClassVar[str] = "" def __init_subclass__(cls, **kwargs: object) -> None: super().__init_subclass__(**kwargs) - cls._name = _camel_to_snake(cls.__name__) - # Direct subclass of Registered - initialize registry, don't register if Registered in cls.__bases__: + cls._name = _camel_to_snake(cls.__name__) cls._registry = {} return - # Skip abstract classes from registration + # Abstract classes keep their full name and skip registration if inspect.isabstract(cls): + cls._name = _camel_to_snake(cls.__name__) return # Child class - register in parent's registry for base in reversed(cls.__mro__[1:]): if Registered in base.__bases__ and issubclass(base, Registered): + cls._name = _derive_name(cls.__name__, base._strip_suffix) # noqa: SLF001 registered = base._registry.get(cls._name) # noqa: SLF001 if registered is not None and registered is not cls: raise RuntimeError( @@ -83,6 +92,16 @@ def resolve(cls, value: "Self | type[Self] | str") -> Self: return value +def _derive_name(class_name: str, role_token: str) -> str: + """Registry key for `class_name`: strip the `role_token` suffix, snake_case.""" + stripped = class_name.removesuffix(role_token) if role_token else class_name + if not stripped: + raise RuntimeError( + f"Registry name for {class_name!r} is empty after stripping {role_token!r}" + ) + return _camel_to_snake(stripped) + + def _camel_to_snake(name: str) -> str: """Convert CamelCase to snake_case.""" s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) diff --git a/src/python/omnimalloc/common/validation.py b/src/python/omnimalloc/common/validation.py new file mode 100644 index 0000000..ed4175f --- /dev/null +++ b/src/python/omnimalloc/common/validation.py @@ -0,0 +1,15 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + + +def ensure_positive(value: float, name: str) -> None: + """Raise ValueError if value is not positive.""" + if value <= 0: + raise ValueError(f"{name} must be positive, got {value}") + + +def ensure_non_negative(value: float, name: str) -> None: + """Raise ValueError if value is not non-negative.""" + if value < 0: + raise ValueError(f"{name} must be non-negative, got {value}") diff --git a/src/python/omnimalloc/dump.py b/src/python/omnimalloc/dump.py deleted file mode 100644 index 021e990..0000000 --- a/src/python/omnimalloc/dump.py +++ /dev/null @@ -1,87 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -import csv -from pathlib import Path - -from .analysis.clock import time_components -from .primitives import Allocation, BufferKind, Memory, Pool, System, TimePoint - -_FIELDS = ("id", "lower", "upper", "size") - - -def _format_time(time_point: TimePoint) -> str: - return ":".join(str(component) for component in time_components(time_point)) - - -def _parse_time(text: str) -> TimePoint: - if ":" in text: - return tuple(int(component) for component in text.split(":")) - return int(text) - - -def _collect_pools(entity: System | Memory | Pool) -> dict[str, Pool]: - if isinstance(entity, Pool): - return {str(entity.id): entity} - if isinstance(entity, Memory): - pools = {str(pool.id): pool for pool in entity.pools} - if len(pools) != len(entity.pools): - raise ValueError("pool ids must be unique after string conversion") - return pools - if isinstance(entity, System): - pools = { - f"{memory.id}_{pool.id}": pool - for memory in entity.memories - for pool in memory.pools - } - if len(pools) != sum(len(memory.pools) for memory in entity.memories): - raise ValueError("memory/pool id combinations must be unique") - return pools - raise TypeError(f"Unsupported entity type: {type(entity)!r}") - - -def _write_pool(pool: Pool, file_path: Path) -> Path: - with file_path.open("w", newline="") as csvfile: - writer = csv.writer(csvfile) - writer.writerow(_FIELDS) - for alloc in pool.allocations: - lower, upper = _format_time(alloc.start), _format_time(alloc.end) - writer.writerow([alloc.id, lower, upper, alloc.size]) - return file_path - - -def dump_allocation( - entity: System | Memory | Pool, path: str | Path -) -> tuple[Path, ...]: - """Dump the entity's pools to disk as minimalloc-format CSV files. - - Path's stem is used as prefix, ie. ``_.csv`` per pool. - Vector-clock lifetimes use an omnimalloc extension (``:``-joined - components, e.g. ``3:0``); such files round-trip through - ``load_allocation`` but are no longer minimalloc-readable. - """ - path_ = Path(path) - path_.parent.mkdir(parents=True, exist_ok=True) - return tuple( - _write_pool(pool, path_.with_name(f"{path_.stem}_{name}.csv")) - for name, pool in _collect_pools(entity).items() - ) - - -def load_allocation(file_path: str | Path) -> Pool: - """Load a minimalloc-format CSV file into a Pool.""" - file_path_ = Path(file_path) - allocations = [] - with file_path_.open(newline="") as csvfile: - for row in csv.DictReader(csvfile): - allocation = Allocation( - id=str(row["id"]), - size=int(row["size"]), - start=_parse_time(row["lower"]), - end=_parse_time(row["upper"]), - offset=int(row["offset"]) if row.get("offset") else None, - kind=BufferKind.WORKSPACE, - ) - allocations.append(allocation) - return Pool(id=file_path_.stem, allocations=tuple(allocations)) diff --git a/src/python/omnimalloc/io.py b/src/python/omnimalloc/io.py new file mode 100644 index 0000000..ef0fc02 --- /dev/null +++ b/src/python/omnimalloc/io.py @@ -0,0 +1,104 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import csv +from pathlib import Path + +from .analysis.clock import time_components +from .primitives import Allocation, Memory, Pool, System, TimePoint + + +def _format_time(time_point: TimePoint) -> str: + return ":".join(str(component) for component in time_components(time_point)) + + +def _parse_time(text: str) -> TimePoint: + if ":" in text: + return tuple(int(component) for component in text.split(":")) + return int(text) + + +def _collect_pools(entity: Memory | System) -> dict[str, Pool]: + if isinstance(entity, Memory): + pools = {str(pool.id): pool for pool in entity.pools} + if len(pools) != len(entity.pools): + raise ValueError("pool ids must be unique after string conversion") + return pools + pools = { + f"{memory.id}_{pool.id}": pool + for memory in entity.memories + for pool in memory.pools + } + if len(pools) != sum(len(memory.pools) for memory in entity.memories): + raise ValueError("memory/pool id combinations must be unique") + return pools + + +def _write_pool(pool: Pool, path: Path) -> Path: + # Any placed allocation brings in the offset column (minimalloc's + # solution format; unplaced rows leave the cell blank), so save/load + # round-trips placements instead of silently stripping them. + with_offsets = any(alloc.is_allocated for alloc in pool.allocations) + fields = ("id", "lower", "upper", "size") + if with_offsets: + fields = (*fields, "offset") + with path.open("w", newline="") as csvfile: + writer = csv.writer(csvfile) + writer.writerow(fields) + for alloc in pool.allocations: + row = [alloc.id, _format_time(alloc.start), _format_time(alloc.end)] + row.append(alloc.size) + if with_offsets: + row.append(alloc.offset) + writer.writerow(row) + return path + + +def save_allocation( + entity: System | Memory | Pool, path: str | Path +) -> tuple[Path, ...]: + """Save the entity's pools to disk as minimalloc-format CSV files. + + Saving a `Pool` writes exactly `path`; a `Memory` or `System` fans out + to one `_.csv` per pool (the CSV is pool-level minimalloc + interchange). Returns the tuple of paths actually written. Pools with + any placed allocation include an `offset` column (minimalloc's solution + format; unplaced rows leave the cell blank), so `load_allocation` + round-trips placements. Vector-clock lifetimes use an + omnimalloc extension (``:``-joined components, e.g. ``3:0``); such files + round-trip through `load_allocation` but are no longer + minimalloc-readable. + """ + path_ = Path(path) + path_.parent.mkdir(parents=True, exist_ok=True) + if isinstance(entity, Pool): + return (_write_pool(entity, path_),) + if not isinstance(entity, (Memory, System)): + raise TypeError(f"Unsupported entity type: {type(entity)!r}") + return tuple( + _write_pool(pool, path_.with_name(f"{path_.stem}_{name}.csv")) + for name, pool in _collect_pools(entity).items() + ) + + +def load_allocation(path: str | Path) -> Pool: + """Load a minimalloc-format CSV file into a Pool. + + Loading is pool-level, matching the format's granularity; an `offset` + column (minimalloc's solution format) restores placements. The format + carries no allocation kind, so loaded allocations keep `kind=None`. + """ + path_ = Path(path) + allocations = [] + with path_.open(newline="") as csvfile: + for row in csv.DictReader(csvfile): + allocation = Allocation( + id=str(row["id"]), + size=int(row["size"]), + start=_parse_time(row["lower"]), + end=_parse_time(row["upper"]), + offset=int(row["offset"]) if row.get("offset") else None, + ) + allocations.append(allocation) + return Pool(id=path_.stem, allocations=tuple(allocations)) diff --git a/src/python/omnimalloc/primitives/__init__.py b/src/python/omnimalloc/primitives/__init__.py index 5775e84..c593f12 100644 --- a/src/python/omnimalloc/primitives/__init__.py +++ b/src/python/omnimalloc/primitives/__init__.py @@ -3,7 +3,7 @@ # from .allocation import Allocation as Allocation -from .allocation import BufferKind as BufferKind +from .allocation import AllocationKind as AllocationKind from .allocation import IdType as IdType from .allocation import TimePoint as TimePoint from .allocation import VectorClock as VectorClock diff --git a/src/python/omnimalloc/primitives/allocation.py b/src/python/omnimalloc/primitives/allocation.py index a0fa195..498bad5 100644 --- a/src/python/omnimalloc/primitives/allocation.py +++ b/src/python/omnimalloc/primitives/allocation.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # -from omnimalloc._cpp import Allocation, BufferKind +from omnimalloc._cpp import Allocation, AllocationKind # Type alias for allocation identifiers (int or str) # Must match IdType in src/cpp/primitives/id_type.hpp @@ -18,4 +18,4 @@ # Must match TimePoint in src/cpp/primitives/allocation.hpp TimePoint = int | VectorClock -__all__ = ["Allocation", "BufferKind", "IdType", "TimePoint", "VectorClock"] +__all__ = ["Allocation", "AllocationKind", "IdType", "TimePoint", "VectorClock"] diff --git a/src/python/omnimalloc/primitives/memory.py b/src/python/omnimalloc/primitives/memory.py index 4c6d636..6c4abb6 100644 --- a/src/python/omnimalloc/primitives/memory.py +++ b/src/python/omnimalloc/primitives/memory.py @@ -6,6 +6,8 @@ from functools import cached_property from typing import TYPE_CHECKING +from omnimalloc.common.validation import ensure_non_negative + from .allocation import IdType from .pool import Pool @@ -15,37 +17,27 @@ @dataclass(frozen=True) class Memory: - """A physical memory unit containing one or more pools.""" + """A physical memory unit containing one or more pools. + + `capacity` is the declared memory limit (an input); the computed extent + of a placement is `used_size`. `capacity=None` means unbounded. + """ id: IdType pools: tuple[Pool, ...] - size: int | None = None + capacity: int | None = None def __post_init__(self) -> None: if len({pool.id for pool in self.pools}) != len(self.pools): raise ValueError("pool ids must be unique") - if self.size is not None and self.size < 0: - raise ValueError(f"size must be non-negative, got {self.size}") + if self.capacity is not None: + ensure_non_negative(self.capacity, "capacity") @cached_property def used_size(self) -> int: """Total memory used by all pools.""" return sum(pool.size for pool in self.pools) - @cached_property - def free_size(self) -> int | None: - """Available memory remaining (None if memory size is unbounded).""" - if self.size is None: - return None - return self.size - self.used_size - - @cached_property - def utilization(self) -> float | None: - """Fraction of memory used (None if memory size is unbounded).""" - if self.size is None: - return None - return self.used_size / self.size if self.size > 0 else 0.0 - @cached_property def is_allocated(self) -> bool: """True if all pools have been allocated.""" @@ -53,7 +45,7 @@ def is_allocated(self) -> bool: def with_pools(self, pools: tuple[Pool, ...]) -> "Memory": """Return new Memory with specified pools.""" - return Memory(id=self.id, size=self.size, pools=pools) + return Memory(id=self.id, capacity=self.capacity, pools=pools) def allocate(self, allocator: "BaseAllocator") -> "Memory": """Apply allocator to all pools.""" diff --git a/src/python/omnimalloc/primitives/pool.py b/src/python/omnimalloc/primitives/pool.py index e133c0b..5ae52c1 100644 --- a/src/python/omnimalloc/primitives/pool.py +++ b/src/python/omnimalloc/primitives/pool.py @@ -9,7 +9,8 @@ if TYPE_CHECKING: from omnimalloc.allocators import BaseAllocator -from omnimalloc.analysis.pressure import get_pressure +from omnimalloc.analysis._pressure import pressure as _pressure +from omnimalloc.common.validation import ensure_non_negative from .allocation import Allocation, IdType @@ -25,8 +26,8 @@ class Pool: def __post_init__(self) -> None: if len({alloc.id for alloc in self.allocations}) != len(self.allocations): raise ValueError("allocation ids must be unique") - if self.offset is not None and self.offset < 0: - raise ValueError(f"offset must be non-negative, got {self.offset}") + if self.offset is not None: + ensure_non_negative(self.offset, "offset") @cached_property def size(self) -> int: @@ -44,19 +45,14 @@ def size(self) -> int: ] return max(ends, default=0) - @cached_property - def total_size(self) -> int: - """Sum of all allocation sizes (ignoring temporal overlap).""" - return sum(alloc.size for alloc in self.allocations) - @cached_property def pressure(self) -> int: """Peak memory pressure (max cut through all buffer lifetimes). Exact: scalar sweep for linearizable lifetimes, max-weight antichain - for non-linearizable vector-clock instances (see `get_pressure`). + for non-linearizable vector-clock instances (see `analysis.pressure`). """ - return get_pressure(self.allocations) + return _pressure(self.allocations) @cached_property def efficiency(self) -> float: diff --git a/src/python/omnimalloc/primitives/utils.py b/src/python/omnimalloc/primitives/utils.py index 17629d0..cca746e 100644 --- a/src/python/omnimalloc/primitives/utils.py +++ b/src/python/omnimalloc/primitives/utils.py @@ -2,10 +2,12 @@ # SPDX-License-Identifier: Apache-2.0 # +from collections.abc import Sequence + from .allocation import Allocation -def ensure_unique_ids(allocations: tuple[Allocation, ...]) -> None: +def ensure_unique_ids(allocations: Sequence[Allocation]) -> None: """Raise if any allocation id repeats; id-keyed placement assumes uniqueness.""" if len({alloc.id for alloc in allocations}) != len(allocations): raise ValueError("allocation ids must be unique") diff --git a/src/python/omnimalloc/validate.py b/src/python/omnimalloc/validate.py index 71e064c..1e66b6f 100644 --- a/src/python/omnimalloc/validate.py +++ b/src/python/omnimalloc/validate.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # -from .analysis.clock import ensure_uniform_dim +from .analysis.clock import uniform_dim from .primitives import Allocation, IdType, Memory, Pool, System @@ -16,22 +16,18 @@ def _check_unique_ids(entities: tuple[Memory | Pool | Allocation, ...]) -> None: seen[entity.id] = idx -def _check_overlaps( - entities: tuple[Pool | Allocation, ...], require_allocated: bool -) -> None: +def _check_overlaps(entities: tuple[Pool | Allocation, ...]) -> None: if not entities: return entity_name = str(type(entities[0]).__name__).lower() - if require_allocated: - for entity in entities: - if not entity.is_allocated: - raise ValueError(f"{entity_name} {entity.id!r} is not allocated") + for entity in entities: + if not entity.is_allocated: + raise ValueError(f"{entity_name} {entity.id!r} is not allocated") - allocated = [e for e in entities if e.is_allocated] - for i, entity_a in enumerate(allocated): - for entity_b in allocated[i + 1 :]: + for i, entity_a in enumerate(entities): + for entity_b in entities[i + 1 :]: if entity_a.overlaps(entity_b): # type: ignore[arg-type] raise ValueError( f"{entity_name} {entity_a.id!r} overlaps with " @@ -39,73 +35,59 @@ def _check_overlaps( ) -def _validate_allocations( - allocations: tuple[Allocation, ...], require_allocated: bool -) -> None: +def _validate_allocations(allocations: tuple[Allocation, ...]) -> None: _check_unique_ids(allocations) - ensure_uniform_dim(allocations) - _check_overlaps(allocations, require_allocated) + uniform_dim(allocations) + _check_overlaps(allocations) -def _validate_pools(pools: tuple[Pool, ...], require_allocated: bool) -> None: +def _validate_pools(pools: tuple[Pool, ...]) -> None: _check_unique_ids(pools) - _check_overlaps(pools, require_allocated) + _check_overlaps(pools) for pool in pools: try: - _validate_allocations(pool.allocations, require_allocated) + _validate_allocations(pool.allocations) except ValueError as e: raise ValueError(f"in pool {pool.id!r}, {e}") from e def _check_capacity(memory: Memory) -> None: - if memory.size is None or not memory.is_allocated: + if memory.capacity is None or not memory.is_allocated: return - if memory.used_size > memory.size: + if memory.used_size > memory.capacity: raise ValueError( - f"used size {memory.used_size} exceeds memory size {memory.size}" + f"used size {memory.used_size} exceeds capacity {memory.capacity}" ) -def _validate_memories(memories: tuple[Memory, ...], require_allocated: bool) -> None: +def _validate_memories(memories: tuple[Memory, ...]) -> None: _check_unique_ids(memories) for memory in memories: try: - _validate_pools(memory.pools, require_allocated) + _validate_pools(memory.pools) _check_capacity(memory) except ValueError as e: raise ValueError(f"in memory {memory.id!r}, {e}") from e -def validate_allocation( - entity: System | Memory | Pool, - raise_on_error: bool = True, - require_allocated: bool = True, -) -> bool: - """Validate the given allocated entity (System, Memory, or Pool). +def validate_allocation(entity: System | Memory | Pool) -> None: + """Raise ValueError unless the entity is fully placed with no collisions. - Args: - entity: The entity to validate. - raise_on_error: If True, raise ValueError on validation failure. - require_allocated: If True, require all allocations to be allocated. - - Returns: - True if valid, False otherwise. + Checks unique ids, that every allocation and pool carries an offset, + that no placed rectangles collide, and that each memory's used size + stays within its declared capacity. """ try: if isinstance(entity, System): - _validate_memories(entity.memories, require_allocated) + _validate_memories(entity.memories) elif isinstance(entity, Memory): - _validate_pools(entity.pools, require_allocated) + _validate_pools(entity.pools) _check_capacity(entity) elif isinstance(entity, Pool): - _validate_allocations(entity.allocations, require_allocated) + _validate_allocations(entity.allocations) else: raise TypeError(f"Unsupported entity type: {type(entity)!r}") except ValueError as e: - if raise_on_error: - raise ValueError( - f"Validation of {type(entity).__name__} {entity.id!r} failed, {e}." - ) from e - return False - - return True + raise ValueError( + f"Validation of {type(entity).__name__} {entity.id!r} failed, {e}." + ) from e diff --git a/src/python/omnimalloc/visualize.py b/src/python/omnimalloc/visualize.py index d08713b..69b8b5f 100644 --- a/src/python/omnimalloc/visualize.py +++ b/src/python/omnimalloc/visualize.py @@ -7,17 +7,12 @@ from pathlib import Path from typing import Final, Literal, NamedTuple -from omnimalloc.analysis import ( - ensure_uniform_dim, - get_conflict_degrees, - get_pressure, - time_components, - try_linearize, -) +from omnimalloc.analysis import conflict_degrees, pressure, try_linearize +from omnimalloc.analysis.clock import time_components, uniform_dim from omnimalloc.common.optional import require_optional from omnimalloc.primitives import ( Allocation, - BufferKind, + AllocationKind, IdType, Memory, Pool, @@ -73,11 +68,11 @@ def _format_bytes(value: float) -> str: return f"{value / divisor:.1f}{suffix}" -KIND_COLOR_MAP: Final[dict[BufferKind, str]] = { - BufferKind.WORKSPACE: "C0", - BufferKind.CONSTANT: "C1", - BufferKind.INPUT: "C2", - BufferKind.OUTPUT: "C3", +KIND_COLOR_MAP: Final[dict[AllocationKind, str]] = { + AllocationKind.WORKSPACE: "C0", + AllocationKind.CONSTANT: "C1", + AllocationKind.INPUT: "C2", + AllocationKind.OUTPUT: "C3", } LANE_CAVEAT: Final[str] = ( @@ -93,9 +88,9 @@ def _format_bytes(value: float) -> str: ) -def _get_allocation_color(kind: BufferKind | None) -> str: +def _get_allocation_color(kind: AllocationKind | None) -> str: if kind is None: - kind = BufferKind.WORKSPACE + kind = AllocationKind.WORKSPACE if kind not in KIND_COLOR_MAP: raise ValueError(f"Unknown allocation kind: {kind}") return KIND_COLOR_MAP[kind] @@ -104,9 +99,7 @@ def _get_allocation_color(kind: BufferKind | None) -> str: def _memory_dim(memory: Memory) -> int: # Dimension is uniform per pool (the validate.py contract), but pools of # one memory may mix, e.g. after linearizing one pool; lanes cover the max. - return max( - (ensure_uniform_dim(pool.allocations) for pool in memory.pools), default=1 - ) + return max((uniform_dim(pool.allocations) for pool in memory.pools), default=1) def _lane_extent(alloc: Allocation, lane: int) -> tuple[int, int]: @@ -156,10 +149,13 @@ def _panel_extents(memory: Memory) -> tuple[dict[int, tuple[int, int]], bool]: return {id(alloc): _sum_extent(alloc) for alloc in allocations}, False -def _overlap_pairs(allocations: tuple[Allocation, ...]) -> int | None: - """Count temporally overlapping allocation pairs, or None once over budget.""" - degrees = get_conflict_degrees(allocations) - return None if degrees is None else sum(degrees) // 2 +def _conflict_pairs(allocations: tuple[Allocation, ...]) -> int | None: + """Count conflicting allocation pairs, or None once over budget.""" + try: + degrees = conflict_degrees(allocations) + except RuntimeError: + return None + return sum(degrees) // 2 def _conflict_visibility( @@ -173,8 +169,8 @@ def _conflict_visibility( visible = total = 0 for pool in memory.pools: projected = tuple(_projected(a, extents[id(a)]) for a in pool.allocations) - pool_visible = _overlap_pairs(projected) - pool_total = _overlap_pairs(pool.allocations) + pool_visible = _conflict_pairs(projected) + pool_total = _conflict_pairs(pool.allocations) if pool_visible is None or pool_total is None: return None visible += pool_visible @@ -205,7 +201,7 @@ def _lane_peaks(allocations: list[Allocation], dim: int) -> list[int]: _projected(alloc, extent) for alloc, extent in _visible_lane_extents(allocations, lane) ) - peaks.append(get_pressure(projected)) + peaks.append(pressure(projected)) return peaks @@ -223,23 +219,23 @@ def _select_lanes( def _get_y_limits(system: System) -> dict[Memory, tuple[int, int]]: limits: dict[Memory, tuple[int, int]] = {} for memory in system.memories: - size = memory.size + capacity = memory.capacity used = memory.used_size - if size is None: - # No size limit defined, scale to 1.2x used + if capacity is None: + # No capacity declared, scale to 1.2x used y_limit = used * 1.2 - elif used > size: - # Usage exceeds size, scale to 1.2x usage + elif used > capacity: + # Usage exceeds capacity, scale to 1.2x usage y_limit = used * 1.2 - elif used >= size * 0.5: - # Usage is 50-100% of size, use size as limit - y_limit = size + elif used >= capacity * 0.5: + # Usage is 50-100% of capacity, use capacity as limit + y_limit = capacity else: - # Usage below 50% of size, scale to 2x usage + # Usage below 50% of capacity, scale to 2x usage y_limit = used * 2 # Clamp to at least 1 so downstream tick spacing stays positive @@ -311,7 +307,7 @@ def _draw_pool_background( def _draw_limit_lines(ax: Axes, limits: dict[str, int]) -> None: - """Draw horizontal lines with annotations for memory limits.""" + """Draw annotated horizontal lines for used size, capacity, and extras.""" _, x_max = ax.get_xlim() for name, value in limits.items(): ax.axhline(value, color="black", linestyle="--", linewidth=1, alpha=0.8) @@ -347,9 +343,11 @@ def _set_axes_ticks(ax: Axes, y_limit: int, num_ticks: int = 8) -> None: def _memory_title(memory: Memory, threads: str) -> str: - size = memory.size - size_str = _format_bytes(size) if size is not None else "Unknown Size" - return f"{memory.id} ({size_str}, {len(memory.pools)} pools{threads})" + capacity = memory.capacity + capacity_str = ( + _format_bytes(capacity) if capacity is not None else "Unbounded Capacity" + ) + return f"{memory.id} ({capacity_str}, {len(memory.pools)} pools{threads})" def _lane_panels( @@ -418,12 +416,12 @@ def _set_axes_limits( ax: Axes, x_limits: tuple[int, int], y_limits: tuple[int, int], - memory_size: int | None, + capacity: int | None, ) -> None: """Set axis limits and add scaling notice if needed.""" ax.set_xlim(x_limits) ax.set_ylim(y_limits) - if memory_size is not None and y_limits[1] < memory_size: + if capacity is not None and y_limits[1] < capacity: ax.text( 0.02, 0.98, @@ -458,13 +456,13 @@ def _draw_panel( panel: _Panel, y_limits: tuple[int, int], y_offsets: dict[Pool, int], - memory_limits: dict[str, dict[IdType, int]], + capacities: dict[str, dict[IdType, int]], ) -> None: memory = panel.memory if panel.title is not None: ax.set_title(panel.title) ax.set_xlabel(panel.xlabel) - _set_axes_limits(ax, panel.x_limits, y_limits, memory.size) + _set_axes_limits(ax, panel.x_limits, y_limits, memory.capacity) _set_axes_ticks(ax, y_limits[1]) if panel.note is not None: ax.text( @@ -490,25 +488,24 @@ def _draw_panel( _draw_allocation(ax, alloc, y_offset, color, extent) _draw_pool_background(ax, y_offset, pool.size, colors) - # Draw memory limit lines + # Draw used-size, capacity, and extra capacity lines limits: dict[str, int] = {"used": memory.used_size} - if memory.size is not None: - limits["size"] = memory.size - for limit_type, memory_id_to_limit in memory_limits.items(): - if memory.id in memory_id_to_limit: - limits[limit_type] = memory_id_to_limit[memory.id] + if memory.capacity is not None: + limits["capacity"] = memory.capacity + for label, per_memory_capacity in capacities.items(): + if memory.id in per_memory_capacity: + limits[label] = per_memory_capacity[memory.id] _draw_limit_lines(ax, limits) def _visualize_system( system: System, - file_path: Path | str | None, - show_inline: bool, - memory_limits: dict[str, dict[IdType, int]], + path: Path | str | None, + capacities: dict[str, dict[IdType, int]], view: Literal["panel", "lanes"], max_lanes: int | None, -) -> Path | None: +) -> None: if view == "lanes": panels, caveat = _lane_panels(system, max_lanes) else: @@ -534,7 +531,7 @@ def _visualize_system( panel, y_limits[panel.memory], y_offsets[panel.memory], - memory_limits, + capacities, ) if caveat is not None: @@ -543,92 +540,31 @@ def _visualize_system( fig.suptitle(caveat, fontsize=8) _add_legend(fig) - if show_inline: + if path is None: plt.show() - - if file_path is not None: - file_path = Path(file_path) - file_path.parent.mkdir(parents=True, exist_ok=True) - fig.savefig(file_path, bbox_inches="tight") + else: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path, bbox_inches="tight") plt.close(fig) - return file_path - - -def _canonicalize(system: System) -> System: - """Reassign allocation IDs sequentially for cleaner visualization.""" - - def _id_sort_key(id_val: IdType) -> tuple[int, int | str]: - return (0, id_val) if isinstance(id_val, int) else (1, id_val) - - def _alloc_sort_key(alloc: Allocation) -> tuple[object, ...]: - # Lexicographic on the (possibly vector) start, then original id - return time_components(alloc.start), _id_sort_key(alloc.id) - - # Collect all allocations and assign sequential IDs - all_allocations = [ - alloc - for memory in system.memories - for pool in memory.pools - for alloc in pool.allocations - ] - - all_allocations.sort(key=_alloc_sort_key) - - # Create mapping from old allocation to new ID - alloc_to_new_id = { - id(alloc): new_id for new_id, alloc in enumerate(all_allocations) - } - - # Rebuild with new IDs - canonical_memories = tuple( - Memory( - id=memory.id, - size=memory.size, - pools=tuple( - Pool( - id=pool.id, - offset=pool.offset, - allocations=tuple( - Allocation( - id=alloc_to_new_id[id(alloc)], - size=alloc.size, - start=alloc.start, - end=alloc.end, - offset=alloc.offset, - kind=alloc.kind, - ) - for alloc in sorted(pool.allocations, key=_alloc_sort_key) - ), - ) - for pool in sorted(memory.pools, key=lambda p: _id_sort_key(p.id)) - ), - ) - for memory in sorted(system.memories, key=lambda m: _id_sort_key(m.id)) - ) - - return System(id=system.id, memories=canonical_memories) - def plot_allocation( entity: System | Memory | Pool, - file_path: Path | str | None = None, - show_inline: bool = False, - canonicalize: bool = False, - memory_limits: dict[str, dict[IdType, int]] | None = None, + path: Path | str | None = None, + *, + capacities: dict[str, dict[IdType, int]] | None = None, view: Literal["panel", "lanes"] = "panel", max_lanes: int | None = None, -) -> Path | None: - """Plot an allocated entity (System, Memory, or Pool). +) -> None: + """Plot an allocated entity: `path=None` displays the figure, `path=...` saves it. Args: - entity: The entity to plot. - file_path: Optional path to save the plot. - show_inline: Whether to display inline (for notebooks). - canonicalize: Whether to canonicalize IDs for cleaner visualization. - memory_limits: Optional dict specifying custom memory limits - for each memory in the system. + entity: The entity to plot (System, Memory, or Pool). + path: Where to save the figure; `None` displays it instead. + capacities: Extra capacity lines to draw, keyed by label then by + memory id (byte limits, drawn as horizontal lines). view: "panel" draws each memory once over a happens-before-monotone virtual time (exact for scalar or linearizable lifetimes, else a sound clock-component-sum projection annotated with its @@ -641,8 +577,7 @@ def plot_allocation( memory to its top-k threads by peak definitely-live occupancy. - Returns: - Path to the saved file, or None if not saved. + Raises `ImportError` without matplotlib. """ if view not in ("panel", "lanes"): raise ValueError(f'view must be "panel" or "lanes", got {view!r}') @@ -659,14 +594,10 @@ def plot_allocation( if isinstance(entity, Memory): entity = System(id=f"system_{entity.id}", memories=(entity,)) - if canonicalize: - entity = _canonicalize(entity) - - return _visualize_system( + _visualize_system( system=entity, - file_path=file_path, - show_inline=show_inline, - memory_limits=memory_limits or {}, + path=path, + capacities=capacities or {}, view=view, max_lanes=max_lanes, ) diff --git a/tests/conftest.py b/tests/conftest.py index 07464b7..d0a5cb5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,15 @@ import pytest +# Headless backend: plot_allocation(path=None) displays the figure, which +# must never block the test run on an interactive backend. +try: + import matplotlib as mpl + + mpl.use("Agg") +except ImportError: + pass + @pytest.fixture # type: ignore[misc] def artifacts_dir(request: pytest.FixtureRequest) -> Path: diff --git a/tests/integration/test_allocators_on_sources.py b/tests/integration/test_allocators_on_sources.py index 2a0f95c..03e4387 100644 --- a/tests/integration/test_allocators_on_sources.py +++ b/tests/integration/test_allocators_on_sources.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from omnimalloc.allocate import run_allocation +from omnimalloc import allocate from omnimalloc.allocators.base import BaseAllocator from omnimalloc.allocators.greedy import ( GreedyAllocator, @@ -22,7 +22,6 @@ SequentialSource, UniformSource, ) -from omnimalloc.common.optional import OptionalDependencyError from omnimalloc.primitives.memory import Memory from omnimalloc.primitives.pool import Pool from omnimalloc.validate import validate_allocation @@ -35,9 +34,9 @@ def test_greedy_with_random_source() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = GreedyAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert all(a.offset is not None for a in allocated_pool.allocations) @@ -47,9 +46,9 @@ def test_greedy_by_size_with_random_source() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = GreedyBySizeAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert all(a.offset is not None for a in allocated_pool.allocations) @@ -59,9 +58,9 @@ def test_greedy_by_duration_with_sequential_source() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = GreedyByDurationAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert allocated_pool.size > 0 @@ -71,9 +70,9 @@ def test_greedy_by_conflict_with_high_contention() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = GreedyByConflictAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert all(a.offset is not None for a in allocated_pool.allocations) @@ -83,9 +82,9 @@ def test_greedy_by_area_with_power_of_2_source() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = GreedyByAreaAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert allocated_pool.size > 0 @@ -95,9 +94,9 @@ def test_greedy_with_uniform_source() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = GreedyAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert all(a.size == 1024 for a in allocated_pool.allocations) @@ -116,8 +115,8 @@ def test_greedy_allocators_produce_different_results() -> None: results = [] for allocator in allocators: - allocated_pool = run_allocation(pool, allocator) - assert validate_allocation(allocated_pool) + allocated_pool = allocate(pool, allocator) + validate_allocation(allocated_pool) results.append(allocated_pool.size) assert len(set(results)) > 1 @@ -130,9 +129,9 @@ def test_greedy_with_memory_hierarchy() -> None: memory = Memory(id="test_memory", pools=(pool,)) allocator = GreedyBySizeAllocator() - allocated_memory = run_allocation(memory, allocator) + allocated_memory = allocate(memory, allocator) - assert validate_allocation(allocated_memory) + validate_allocation(allocated_memory) assert allocated_memory.used_size > 0 assert len(allocated_memory.pools) == 1 @@ -143,9 +142,9 @@ def test_greedy_with_large_workload() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = GreedyAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert len(allocated_pool.allocations) == 200 @@ -157,11 +156,11 @@ def test_greedy_by_conflict_minimizes_peak_with_contention() -> None: conflict_allocator = GreedyByConflictAllocator() basic_allocator = GreedyAllocator() - conflict_pool = run_allocation(pool, conflict_allocator) - basic_pool = run_allocation(pool, basic_allocator) + conflict_pool = allocate(pool, conflict_allocator) + basic_pool = allocate(pool, basic_allocator) - assert validate_allocation(conflict_pool) - assert validate_allocation(basic_pool) + validate_allocation(conflict_pool) + validate_allocation(basic_pool) assert conflict_pool.size <= basic_pool.size * 1.5 @@ -172,8 +171,8 @@ def test_greedy_deterministic_across_runs() -> None: allocator = GreedyBySizeAllocator() - result1 = run_allocation(pool, allocator) - result2 = run_allocation(pool, allocator) + result1 = allocate(pool, allocator) + result2 = allocate(pool, allocator) assert result1.size == result2.size offsets1 = [a.offset for a in result1.allocations] @@ -187,9 +186,9 @@ def test_hill_climb_with_high_contention() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = HillClimbAllocator(max_iterations=200) - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert all(a.offset is not None for a in allocated_pool.allocations) @@ -199,9 +198,9 @@ def test_hill_climb_with_random_source() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = HillClimbAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert len(allocated_pool.allocations) == 50 @@ -211,9 +210,9 @@ def test_greedy_with_sequential_produces_small_footprint() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = GreedyAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) total_alloc_size = sum(a.size for a in allocations) avg_alloc_size = total_alloc_size // len(allocations) assert allocated_pool.size < avg_alloc_size * 10 @@ -232,9 +231,9 @@ def test_greedy_by_area_with_varying_durations() -> None: pool = Pool(id="test_pool", allocations=allocations) allocator = GreedyByAreaAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert allocated_pool.size > 0 @@ -250,8 +249,8 @@ def test_all_greedy_variants_handle_empty_pool() -> None: ] for allocator in allocators: - allocated_pool = run_allocation(pool, allocator) - assert validate_allocation(allocated_pool) + allocated_pool = allocate(pool, allocator) + validate_allocation(allocated_pool) assert len(allocated_pool.allocations) == 0 assert allocated_pool.size == 0 @@ -264,12 +263,12 @@ def test_every_registered_allocator_on_random_source(name: str) -> None: try: allocator = BaseAllocator.get(name)() - except OptionalDependencyError as error: + except ImportError as error: pytest.skip(str(error)) - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) - assert validate_allocation(allocated_pool) + validate_allocation(allocated_pool) assert {a.id for a in allocated_pool.allocations} == {a.id for a in allocations} assert all(a.offset is not None for a in allocated_pool.allocations) @@ -289,8 +288,8 @@ def test_greedy_allocators_with_artifacts(artifacts_dir: Path) -> None: } for name, allocator in allocators.items(): - allocated_pool = run_allocation(pool, allocator) - assert validate_allocation(allocated_pool) + allocated_pool = allocate(pool, allocator) + validate_allocation(allocated_pool) output_file = artifacts_dir / f"{name}.pdf" plot_allocation(allocated_pool, output_file) diff --git a/tests/integration/test_omni_torture.py b/tests/integration/test_omni_torture.py index 57e9ba6..70762ef 100644 --- a/tests/integration/test_omni_torture.py +++ b/tests/integration/test_omni_torture.py @@ -7,21 +7,21 @@ from concurrent.futures import ThreadPoolExecutor import pytest -from omnimalloc._cpp import FirstFitPlacer, compute_temporal_overlaps +from omnimalloc._cpp import FirstFitPlacer, conflicts from omnimalloc.allocators import OmniAllocator -from omnimalloc.allocators.greedy_cpp import ( - GreedyAllocatorCpp, - GreedyByAreaAllocatorCpp, - GreedyByConflictAllocatorCpp, - GreedyByConflictSizeAllocatorCpp, - GreedyByDurationAllocatorCpp, - GreedyBySizeAllocatorCpp, - GreedyByStartAllocatorCpp, +from omnimalloc.allocators.greedy import ( + GreedyAllocator, + GreedyByAreaAllocator, + GreedyByConflictAllocator, + GreedyByConflictSizeAllocator, + GreedyByDurationAllocator, + GreedyBySizeAllocator, + GreedyByStartAllocator, ) -from omnimalloc.analysis.pressure import ( - get_closure_pressure, - get_per_allocation_placement_pressure, - get_pressure, +from omnimalloc.analysis import ( + closure_pressure, + placement_pressure_per_allocation, + pressure, ) from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.benchmark.sources.generator import HighContentionSource, RandomSource @@ -32,13 +32,13 @@ from omnimalloc.validate import validate_allocation GREEDY_PORTFOLIO = ( - GreedyAllocatorCpp, - GreedyBySizeAllocatorCpp, - GreedyByDurationAllocatorCpp, - GreedyByAreaAllocatorCpp, - GreedyByConflictAllocatorCpp, - GreedyByConflictSizeAllocatorCpp, - GreedyByStartAllocatorCpp, + GreedyAllocator, + GreedyBySizeAllocator, + GreedyByDurationAllocator, + GreedyByAreaAllocator, + GreedyByConflictAllocator, + GreedyByConflictSizeAllocator, + GreedyByStartAllocator, ) @@ -48,7 +48,7 @@ def _peak(allocations: tuple[Allocation, ...]) -> int: def _assert_conflicting_pairs_disjoint(placed: tuple[Allocation, ...]) -> None: by_id = {a.id: a for a in placed} - for alloc_id, neighbor_ids in compute_temporal_overlaps(placed, None).items(): + for alloc_id, neighbor_ids in conflicts(placed, None).items(): a = by_id[alloc_id] for neighbor_id in neighbor_ids: b = by_id[neighbor_id] @@ -59,9 +59,9 @@ def _certify( allocations: tuple[Allocation, ...], placed: tuple[Allocation, ...] ) -> int: peak = _peak(placed) - assert get_pressure(allocations, work_budget=None) <= peak + assert pressure(allocations, work_budget=None) <= peak assert peak <= sum(a.size for a in allocations) - assert max(get_per_allocation_placement_pressure(placed).values()) == peak + assert max(placement_pressure_per_allocation(placed).values()) == peak _assert_conflicting_pairs_disjoint(placed) return peak @@ -129,10 +129,10 @@ def test_closure_antichain_peak_chain_on_sync_patterns(pattern: str) -> None: source = SyncPatternSource(num_allocations=32, num_threads=3, pattern=pattern) allocations = source.get_allocations() peak = _peak(OmniAllocator().allocate(allocations)) - closure = get_closure_pressure(allocations, closure_cap=1 << 18) - antichain = get_pressure(allocations, work_budget=None) + closure = closure_pressure(allocations, closure_cap=1 << 18) + antichain = pressure(allocations, work_budget=None) assert closure <= antichain <= peak - assert get_pressure(allocations) == antichain + assert pressure(allocations) == antichain @pytest.mark.parametrize("num_threads", [2, 4]) @@ -150,7 +150,7 @@ def test_concurrent_tiling_is_certified_near_optimum( allocations = source.get_allocations() placed = OmniAllocator().allocate(allocations) peak = _certify(allocations, placed) - assert get_pressure(allocations, work_budget=None) <= capacity + assert pressure(allocations, work_budget=None) <= capacity assert capacity <= peak <= 2 * capacity @@ -185,7 +185,7 @@ def test_vector_not_worse_than_input_order_greedy() -> None: source = SyncPatternSource(num_allocations=96, num_threads=5, pattern=pattern) allocations = source.get_allocations() omni_peak = _peak(OmniAllocator().allocate(allocations)) - assert omni_peak <= _peak(GreedyAllocatorCpp().allocate(allocations)) + assert omni_peak <= _peak(GreedyAllocator().allocate(allocations)) def test_size_scaling_scales_peak_linearly() -> None: @@ -230,10 +230,10 @@ def test_lane_permutation_preserves_exact_pressures() -> None: ) for a in allocations ) - assert get_pressure(permuted, work_budget=None) == get_pressure( + assert pressure(permuted, work_budget=None) == pressure( allocations, work_budget=None ) - assert get_closure_pressure(permuted, closure_cap=1 << 18) == get_closure_pressure( + assert closure_pressure(permuted, closure_cap=1 << 18) == closure_pressure( allocations, closure_cap=1 << 18 ) _certify(permuted, OmniAllocator().allocate(permuted)) @@ -279,7 +279,7 @@ def test_one_hot_lanes_reach_the_exact_optimum(dim: int) -> None: placed = OmniAllocator().allocate(allocations) peak = _certify(allocations, placed) assert peak == dim * size - assert get_pressure(allocations, work_budget=None) == dim * size + assert pressure(allocations, work_budget=None) == dim * size def test_total_order_chain_peak_is_max_size() -> None: @@ -335,8 +335,8 @@ def test_extreme_clock_values_and_sizes_place_validly() -> None: def test_tiled_crowns_are_certified_and_exact() -> None: allocations = _tiled_crowns(50) - assert get_pressure(allocations, work_budget=None) == 80 - assert get_pressure(allocations) == 80 + assert pressure(allocations, work_budget=None) == 80 + assert pressure(allocations) == 80 placed = OmniAllocator().allocate(allocations) assert _certify(allocations, placed) >= 80 @@ -344,8 +344,8 @@ def test_tiled_crowns_are_certified_and_exact() -> None: def test_pressure_budget_raises_but_default_succeeds_on_crowns() -> None: allocations = _tiled_crowns(50) with pytest.raises(RuntimeError, match="work_budget"): - get_pressure(allocations, work_budget=1) - assert get_pressure(allocations) == get_pressure(allocations, work_budget=None) + pressure(allocations, work_budget=1) + assert pressure(allocations) == pressure(allocations, work_budget=None) def test_exhaustive_first_fit_orders_bracket_omni_peak() -> None: @@ -354,12 +354,12 @@ def test_exhaustive_first_fit_orders_bracket_omni_peak() -> None: allocations = _random_vector_instance(rng) placer = FirstFitPlacer(list(allocations)) best = min( - placer.evaluate(list(order)) + placer.peak(list(order)) for order in itertools.permutations(range(len(allocations))) ) omni_peak = _peak(OmniAllocator().allocate(allocations)) - closure = get_closure_pressure(allocations) - antichain = get_pressure(allocations, work_budget=None) + closure = closure_pressure(allocations) + antichain = pressure(allocations, work_budget=None) assert closure <= antichain <= best <= omni_peak @@ -390,7 +390,7 @@ def test_torture_concurrent_tiling_scale(num_threads: int, num_syncs: int) -> No allocations = source.get_allocations() placed = OmniAllocator().allocate(allocations) peak = _certify(allocations, placed) - assert get_pressure(allocations, work_budget=None) <= capacity + assert pressure(allocations, work_budget=None) <= capacity assert capacity <= peak <= 2 * capacity @@ -446,13 +446,13 @@ def test_torture_concurrent_mixed_instances_stay_isolated() -> None: for seed in range(16) ) serial_peaks = [_peak(OmniAllocator().allocate(a)) for a in instances] - serial_pressures = [get_pressure(a, work_budget=None) for a in instances] + serial_pressures = [pressure(a, work_budget=None) for a in instances] with ThreadPoolExecutor(max_workers=32) as executor: parallel_placed = list( executor.map(lambda a: OmniAllocator().allocate(a), instances) ) parallel_pressures = list( - executor.map(lambda a: get_pressure(a, work_budget=None), instances) + executor.map(lambda a: pressure(a, work_budget=None), instances) ) assert [_peak(p) for p in parallel_placed] == serial_peaks assert parallel_pressures == serial_pressures diff --git a/tests/integration/test_supermalloc.py b/tests/integration/test_supermalloc.py index 842f7f4..251cd1f 100644 --- a/tests/integration/test_supermalloc.py +++ b/tests/integration/test_supermalloc.py @@ -6,15 +6,15 @@ import pytest from omnimalloc.allocators.minimalloc import HAS_MINIMALLOC, MinimallocAllocator -from omnimalloc.allocators.supermalloc import SupermallocAllocator, SupermallocConfig +from omnimalloc.allocators.supermalloc import SupermallocAllocator from omnimalloc.benchmark import plot_benchmark, run_benchmark, save_benchmark from omnimalloc.visualize import HAS_MATPLOTLIB ALLOCATORS = ( - "greedy_by_size_allocator_cpp", - "greedy_by_all_allocator_cpp", - "omni_allocator", - SupermallocAllocator(SupermallocConfig(timeout=2)), + "greedy_by_size", + "greedy_by_all", + "omni", + SupermallocAllocator(timeout=2), ) + ((MinimallocAllocator(timeout=2),) if HAS_MINIMALLOC else ()) SIZE_VARIANTS = (64, 128, 256, 512, 1024) @@ -26,10 +26,10 @@ @pytest.mark.parametrize( ("source", "variants"), [ - ("minimalloc_source", MINIMALLOC_VARIANTS), - ("tiling_source", SIZE_VARIANTS), - ("pinwheel_source", SIZE_VARIANTS), - ("random_source", SIZE_VARIANTS), + ("minimalloc", MINIMALLOC_VARIANTS), + ("tiling", SIZE_VARIANTS), + ("pinwheel", SIZE_VARIANTS), + ("random", SIZE_VARIANTS), ], ) def test_benchmark( @@ -43,8 +43,8 @@ def test_benchmark( ) assert len(campaign.reports) == len(ALLOCATORS) * len(variants) - plot_file = plot_benchmark(campaign, artifacts_dir / "benchmark.pdf") - assert plot_file is not None + plot_file = artifacts_dir / "benchmark.pdf" + plot_benchmark(campaign, plot_file) assert plot_file.exists() saved_path = save_benchmark(campaign, artifacts_dir / "results") diff --git a/tests/unit/allocators/test_best_fit.py b/tests/unit/allocators/test_best_fit.py index 0d7cd8b..c0738ee 100644 --- a/tests/unit/allocators/test_best_fit.py +++ b/tests/unit/allocators/test_best_fit.py @@ -4,13 +4,13 @@ from omnimalloc.allocators.best_fit import BestFitAllocator from omnimalloc.allocators.greedy import GreedyAllocator -from omnimalloc.allocators.greedy_base import peak_memory +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation, Pool from omnimalloc.validate import validate_allocation -def _is_valid(result: tuple[Allocation, ...]) -> bool: - return validate_allocation(Pool(id="test_pool", allocations=result)) +def _assert_valid(result: tuple[Allocation, ...]) -> None: + validate_allocation(Pool(id="test_pool", allocations=result)) def test_best_fit_empty() -> None: @@ -41,8 +41,8 @@ def test_best_fit_all_overlap_stacks_sequentially() -> None: allocator = BestFitAllocator() allocs = tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(5)) result = allocator.allocate(allocs) - assert _is_valid(result) - assert peak_memory(result) == 500 + _assert_valid(result) + assert placement_pressure(result) == 500 def test_best_fit_preserves_allocations() -> None: @@ -82,10 +82,10 @@ def test_best_fit_chooses_tighter_gap_than_first_fit() -> None: assert first_fit["t"] == 10 assert best_fit["t"] == 45 - assert _is_valid(BestFitAllocator().allocate(allocs)) - assert peak_memory(tuple(BestFitAllocator().allocate(allocs))) == peak_memory( - tuple(GreedyAllocator().allocate(allocs)) - ) + _assert_valid(BestFitAllocator().allocate(allocs)) + assert placement_pressure( + tuple(BestFitAllocator().allocate(allocs)) + ) == placement_pressure(tuple(GreedyAllocator().allocate(allocs))) def test_best_fit_complex_overlap_produces_valid_allocation() -> None: @@ -98,4 +98,4 @@ def test_best_fit_complex_overlap_produces_valid_allocation() -> None: Allocation(id=5, size=300, start=2, end=4), ) result = allocator.allocate(allocs) - assert _is_valid(result) + _assert_valid(result) diff --git a/tests/unit/allocators/test_genetic.py b/tests/unit/allocators/test_genetic.py index 1071047..a90000f 100644 --- a/tests/unit/allocators/test_genetic.py +++ b/tests/unit/allocators/test_genetic.py @@ -5,7 +5,7 @@ import pytest from omnimalloc.allocators.genetic import HAS_DEAP, GeneticAllocator from omnimalloc.allocators.greedy import GreedyBySizeAllocator -from omnimalloc.allocators.greedy_base import peak_memory +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation from omnimalloc.primitives.pool import Pool from omnimalloc.validate import validate_allocation @@ -14,7 +14,7 @@ def _fast_allocator(seed: int = 42) -> GeneticAllocator: - return GeneticAllocator(seed=seed, population_size=20, num_generations=5) + return GeneticAllocator(seed=seed, population_size=20, max_generations=5) def _allocs(count: int) -> tuple[Allocation, ...]: @@ -41,8 +41,8 @@ def test_genetic_rejects_invalid_population_size() -> None: def test_genetic_rejects_negative_generations() -> None: - with pytest.raises(ValueError, match="num_generations must be non-negative"): - GeneticAllocator(num_generations=-1) + with pytest.raises(ValueError, match="max_generations must be non-negative"): + GeneticAllocator(max_generations=-1) def test_genetic_rejects_out_of_range_probabilities() -> None: @@ -71,7 +71,7 @@ def test_genetic_rejects_duplicate_ids() -> None: def test_genetic_produces_valid_allocation() -> None: allocs = _allocs(20) result = _fast_allocator().allocate(allocs) - assert validate_allocation(Pool(id="test_pool", allocations=result)) + validate_allocation(Pool(id="test_pool", allocations=result)) assert {a.id for a in result} == {a.id for a in allocs} assert all(a.offset is not None for a in result) @@ -85,9 +85,9 @@ def test_genetic_deterministic_for_same_seed() -> None: def test_genetic_never_worse_than_greedy_by_size_seed() -> None: allocs = _allocs(30) - baseline = peak_memory(GreedyBySizeAllocator().allocate(allocs)) + baseline = placement_pressure(GreedyBySizeAllocator().allocate(allocs)) result = _fast_allocator().allocate(allocs) - assert peak_memory(result) <= baseline + assert placement_pressure(result) <= baseline def test_genetic_improves_or_matches_adversarial_insertion_order() -> None: @@ -101,9 +101,9 @@ def test_genetic_improves_or_matches_adversarial_insertion_order() -> None: for i in range(30) ) result = _fast_allocator().allocate(allocs) - assert validate_allocation(Pool(id="test_pool", allocations=result)) - baseline = peak_memory(GreedyBySizeAllocator().allocate(allocs)) - assert peak_memory(result) <= baseline + validate_allocation(Pool(id="test_pool", allocations=result)) + baseline = placement_pressure(GreedyBySizeAllocator().allocate(allocs)) + assert placement_pressure(result) <= baseline def test_genetic_preserves_global_random_state() -> None: diff --git a/tests/unit/allocators/test_greedy.py b/tests/unit/allocators/test_greedy.py index d0c0fc9..4032029 100644 --- a/tests/unit/allocators/test_greedy.py +++ b/tests/unit/allocators/test_greedy.py @@ -3,6 +3,7 @@ # import pytest +from omnimalloc._cpp import FirstFitPlacer, first_fit_place from omnimalloc.allocators.greedy import ( GreedyAllocator, GreedyByAllAllocator, @@ -13,11 +14,8 @@ GreedyBySizeAllocator, GreedyByStartAllocator, ) -from omnimalloc.allocators.greedy_base import ( - allocate_parallel, - compute_conflicts, - peak_memory, -) +from omnimalloc.allocators.greedy_base import allocate_parallel +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation @@ -124,41 +122,6 @@ def test_greedy_by_duration_allocates_correctly() -> None: assert result[1].offset == 100 -def test_compute_conflicts_empty() -> None: - assert compute_conflicts(()) == {} - - -def test_compute_conflicts_counts_overlaps() -> None: - alone = Allocation(id=1, size=10, start=0, end=5) - pair_a = Allocation(id=2, size=10, start=10, end=20) - pair_b = Allocation(id=3, size=10, start=15, end=25) - degrees = compute_conflicts((alone, pair_a, pair_b)) - assert degrees[alone] == 0 - assert degrees[pair_a] == 1 - assert degrees[pair_b] == 1 - - -def test_compute_conflicts_touching_intervals_do_not_conflict() -> None: - first = Allocation(id=1, size=10, start=0, end=10) - second = Allocation(id=2, size=10, start=10, end=20) - degrees = compute_conflicts((first, second)) - assert degrees[first] == 0 - assert degrees[second] == 0 - - -def test_compute_conflicts_matches_bruteforce() -> None: - allocs = tuple( - Allocation(id=i, size=i + 1, start=(i * 7) % 23, end=(i * 7) % 23 + i % 6 + 1) - for i in range(50) - ) - degrees = compute_conflicts(allocs) - for alloc in allocs: - expected = sum( - 1 for other in allocs if other != alloc and alloc.overlaps_temporally(other) - ) - assert degrees[alloc] == expected - - def test_greedy_by_conflict_empty() -> None: allocator = GreedyByConflictAllocator() result = allocator.allocate(()) @@ -388,7 +351,7 @@ def test_greedy_by_all_picks_best_peak() -> None: Allocation(id=5, size=300, start=2, end=4), ) result = allocator.allocate(allocs) - peak = peak_memory(result) + peak = placement_pressure(result) variants = ( GreedyAllocator(), @@ -399,7 +362,7 @@ def test_greedy_by_all_picks_best_peak() -> None: GreedyByConflictSizeAllocator(), GreedyByStartAllocator(), ) - best_variant_peak = min(peak_memory(v.allocate(allocs)) for v in variants) + best_variant_peak = min(placement_pressure(v.allocate(allocs)) for v in variants) assert peak == best_variant_peak @@ -415,7 +378,7 @@ def test_greedy_by_all_deterministic() -> None: def test_allocate_parallel_empty() -> None: - result = allocate_parallel((GreedyAllocator(),), (), cores=2) + result = allocate_parallel((), (GreedyAllocator(),), num_threads=2) assert result == () @@ -436,8 +399,8 @@ def test_allocate_parallel_matches_serial() -> None: ) for i in range(30) ) - serial = allocate_parallel(variants, allocs, cores=1) - parallel = allocate_parallel(variants, allocs, cores=2) + serial = allocate_parallel(allocs, variants, num_threads=1) + parallel = allocate_parallel(allocs, variants, num_threads=2) assert {a.id: a.offset for a in parallel} == {a.id: a.offset for a in serial} @@ -446,8 +409,8 @@ def test_allocate_parallel_matches_serial_order() -> None: Allocation(id="b", size=10, start=0, end=5), Allocation(id="a", size=20, start=3, end=8), ) - serial = allocate_parallel((GreedyBySizeAllocator(),), allocs, cores=1) - parallel = allocate_parallel((GreedyBySizeAllocator(),), allocs, cores=2) + serial = allocate_parallel(allocs, (GreedyBySizeAllocator(),), num_threads=1) + parallel = allocate_parallel(allocs, (GreedyBySizeAllocator(),), num_threads=2) assert [a.id for a in parallel] == [a.id for a in serial] == ["a", "b"] @@ -458,18 +421,33 @@ def test_allocate_parallel_tie_break_takes_first_variant() -> None: ) variants = (GreedyAllocator(), GreedyBySizeAllocator()) for result in ( - allocate_parallel(variants, allocs, cores=1), - allocate_parallel(variants, allocs, cores=2), + allocate_parallel(allocs, variants, num_threads=1), + allocate_parallel(allocs, variants, num_threads=2), ): assert {a.id: a.offset for a in result} == {"a": 0, "b": 10} - flipped = allocate_parallel(variants[::-1], allocs, cores=2) + flipped = allocate_parallel(allocs, variants[::-1], num_threads=2) assert {a.id: a.offset for a in flipped} == {"a": 20, "b": 0} -def test_allocate_parallel_rejects_configured_variant() -> None: - allocs = (Allocation(id=1, size=10, start=0, end=5),) - with pytest.raises(ValueError, match="default-configured"): - allocate_parallel((GreedyByAllAllocator(cores=2),), allocs, cores=2) +def test_allocate_parallel_accepts_configured_variant() -> None: + allocs = ( + Allocation(id="a", size=10, start=0, end=5), + Allocation(id="b", size=20, start=3, end=8), + ) + variants = (GreedyBySizeAllocator(), GreedyByAllAllocator(num_threads=1)) + result = allocate_parallel(allocs, variants, num_threads=2) + assert placement_pressure(result) == 30 + + +def test_allocate_parallel_rejects_non_positive_num_threads() -> None: + allocs = (Allocation(id="a", size=10, start=0, end=5),) + with pytest.raises(ValueError, match="num_threads must be positive"): + allocate_parallel(allocs, (GreedyAllocator(),), num_threads=0) + + +def test_greedy_by_all_rejects_non_positive_num_threads() -> None: + with pytest.raises(ValueError, match="num_threads must be positive"): + GreedyByAllAllocator(num_threads=0) def test_greedy_by_all_default_matches_single_core() -> None: @@ -478,14 +456,14 @@ def test_greedy_by_all_default_matches_single_core() -> None: for i in range(20) ) default = GreedyByAllAllocator().allocate(allocs) - single = GreedyByAllAllocator(cores=1).allocate(allocs) + single = GreedyByAllAllocator(num_threads=1).allocate(allocs) assert {a.id: a.offset for a in default} == {a.id: a.offset for a in single} def test_allocate_parallel_serial_when_single_core() -> None: allocs = (Allocation(id="a", size=10, start=0, end=5),) variants = (GreedyAllocator(), GreedyBySizeAllocator()) - result = allocate_parallel(variants, allocs, cores=1) + result = allocate_parallel(allocs, variants, num_threads=1) assert {a.id: a.offset for a in result} == {"a": 0} @@ -494,18 +472,49 @@ def test_greedy_by_all_parallel_matches_serial() -> None: Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 6, end=i % 6 + i % 7 + 1) for i in range(25) ) - serial = GreedyByAllAllocator(cores=1).allocate(allocs) - parallel = GreedyByAllAllocator(cores=2).allocate(allocs) + serial = GreedyByAllAllocator(num_threads=1).allocate(allocs) + parallel = GreedyByAllAllocator(num_threads=2).allocate(allocs) + assert {a.id: a.offset for a in parallel} == {a.id: a.offset for a in serial} + + +def test_allocate_parallel_configured_variant_keeps_kwargs() -> None: + from omnimalloc.allocators.simulated_annealing import SimulatedAnnealingAllocator + + allocs = ( + Allocation(id="a", size=10, start=0, end=5), + Allocation(id="b", size=20, start=3, end=8), + ) + variant = SimulatedAnnealingAllocator(seed=123, max_iterations=5) + serial = allocate_parallel(allocs, (variant,), num_threads=1) + parallel = allocate_parallel(allocs, (variant, variant), num_threads=2) assert {a.id: a.offset for a in parallel} == {a.id: a.offset for a in serial} -def test_allocate_parallel_rejects_configured_dataclass_variant() -> None: - from omnimalloc.allocators.simulated_annealing import ( - SimulatedAnnealingAllocator, - SimulatedAnnealingConfig, +def test_first_fit_placer_rejects_out_of_range_order() -> None: + placer = FirstFitPlacer([Allocation(id=1, size=10, start=0, end=5)]) + with pytest.raises(ValueError, match="out of range"): + placer.place([1]) + + +def test_first_fit_placer_rejects_repeated_order_index() -> None: + placer = FirstFitPlacer( + [ + Allocation(id=1, size=10, start=0, end=5), + Allocation(id=2, size=20, start=0, end=5), + ] ) + with pytest.raises(ValueError, match="more than once"): + placer.peak([0, 0]) - allocs = (Allocation(id=1, size=10, start=0, end=5),) - variant = SimulatedAnnealingAllocator(SimulatedAnnealingConfig(seed=123)) - with pytest.raises(ValueError, match="default-configured"): - allocate_parallel((variant, variant), allocs, cores=2) + +def test_first_fit_place_matches_greedy_across_orders() -> None: + allocs = tuple( + Allocation(id=i, size=(i % 4 + 1) * 10, start=i % 5, end=i % 5 + i % 3 + 1) + for i in range(30) + ) + for order in (allocs, tuple(reversed(allocs))): + result = first_fit_place(order) + expected = GreedyAllocator().allocate(order) + assert [(a.id, a.offset) for a in result] == [ + (a.id, a.offset) for a in expected + ] diff --git a/tests/unit/allocators/test_greedy_cpp.py b/tests/unit/allocators/test_greedy_cpp.py deleted file mode 100644 index aaa544a..0000000 --- a/tests/unit/allocators/test_greedy_cpp.py +++ /dev/null @@ -1,402 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -from omnimalloc._cpp import compute_temporal_overlaps, first_fit_place -from omnimalloc.allocators.greedy import GreedyAllocator, GreedyByAllAllocator -from omnimalloc.allocators.greedy_base import peak_memory -from omnimalloc.allocators.greedy_cpp import ( - GreedyAllocatorCpp, - GreedyByAllAllocatorCpp, - GreedyByAreaAllocatorCpp, - GreedyByConflictAllocatorCpp, - GreedyByConflictSizeAllocatorCpp, - GreedyByDurationAllocatorCpp, - GreedyBySizeAllocatorCpp, - GreedyByStartAllocatorCpp, -) -from omnimalloc.primitives import Allocation - - -def test_greedy_cpp_allocator_empty() -> None: - allocator = GreedyAllocatorCpp() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_greedy_cpp_allocator_single() -> None: - allocator = GreedyAllocatorCpp() - alloc = Allocation(id=1, size=100, start=0, end=10) - result = allocator.allocate((alloc,)) - assert len(result) == 1 - assert result[0].offset == 0 - assert result[0].size == 100 - - -def test_greedy_cpp_allocator_no_temporal_overlap() -> None: - allocator = GreedyAllocatorCpp() - alloc1 = Allocation(id=1, size=100, start=0, end=10) - alloc2 = Allocation(id=2, size=200, start=10, end=20) - result = allocator.allocate((alloc1, alloc2)) - assert result[0].offset == 0 - assert result[1].offset == 0 - - -def test_greedy_cpp_allocator_temporal_overlap_first_fit() -> None: - allocator = GreedyAllocatorCpp() - alloc1 = Allocation(id=1, size=100, start=0, end=10) - alloc2 = Allocation(id=2, size=50, start=5, end=15) - result = allocator.allocate((alloc1, alloc2)) - assert result[0].offset == 0 - assert result[1].offset == 100 - - -def test_greedy_cpp_allocator_gap_reuse() -> None: - allocator = GreedyAllocatorCpp() - alloc1 = Allocation(id=1, size=100, start=0, end=5) - alloc2 = Allocation(id=2, size=200, start=6, end=10) - alloc3 = Allocation(id=3, size=50, start=6, end=10) - result = allocator.allocate((alloc1, alloc2, alloc3)) - assert result[0].offset == 0 - assert result[1].offset == 0 - assert result[2].offset == 200 - - -def test_greedy_cpp_allocator_exact_gap_fit() -> None: - allocator = GreedyAllocatorCpp() - alloc1 = Allocation(id=1, size=100, start=0, end=10) - alloc2 = Allocation(id=2, size=100, start=5, end=15) - alloc3 = Allocation(id=3, size=100, start=5, end=15) - result = allocator.allocate((alloc1, alloc2, alloc3)) - assert result[0].offset == 0 - assert result[1].offset == 100 - assert result[2].offset == 200 - - -def test_greedy_cpp_allocator_complex_overlap() -> None: - allocator = GreedyAllocatorCpp() - alloc1 = Allocation(id=1, size=100, start=0, end=5) - alloc2 = Allocation(id=2, size=100, start=3, end=8) - alloc3 = Allocation(id=3, size=100, start=6, end=10) - alloc4 = Allocation(id=4, size=50, start=0, end=10) - result = allocator.allocate((alloc1, alloc2, alloc3, alloc4)) - assert result[0].offset == 0 - assert result[1].offset == 100 - assert result[2].offset == 0 - assert result[3].offset == 200 - - -def test_greedy_cpp_allocator_preserves_ids() -> None: - allocator = GreedyAllocatorCpp() - alloc1 = Allocation(id="alloc_a", size=100, start=0, end=10) - alloc2 = Allocation(id="alloc_b", size=50, start=5, end=15) - result = allocator.allocate((alloc1, alloc2)) - assert result[0].id == "alloc_a" - assert result[1].id == "alloc_b" - - -def test_greedy_cpp_by_duration_empty() -> None: - allocator = GreedyByDurationAllocatorCpp() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_greedy_cpp_by_duration_sorts_by_duration() -> None: - allocator = GreedyByDurationAllocatorCpp() - short = Allocation(id=1, size=100, start=0, end=2) - medium = Allocation(id=2, size=100, start=0, end=5) - long = Allocation(id=3, size=100, start=0, end=10) - result = allocator.allocate((short, medium, long)) - assert result[0].id == 3 - assert result[1].id == 2 - assert result[2].id == 1 - - -def test_greedy_cpp_by_duration_allocates_correctly() -> None: - allocator = GreedyByDurationAllocatorCpp() - short = Allocation(id=1, size=100, start=0, end=2) - long = Allocation(id=2, size=100, start=0, end=10) - result = allocator.allocate((short, long)) - assert result[0].offset == 0 - assert result[1].offset == 100 - - -def test_greedy_cpp_by_conflict_empty() -> None: - allocator = GreedyByConflictAllocatorCpp() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_greedy_cpp_by_conflict_no_conflicts() -> None: - allocator = GreedyByConflictAllocatorCpp() - alloc1 = Allocation(id=1, size=100, start=0, end=10) - alloc2 = Allocation(id=2, size=100, start=10, end=20) - result = allocator.allocate((alloc1, alloc2)) - assert len(result) == 2 - assert all(a.offset is not None for a in result) - - -def test_greedy_cpp_by_conflict_sorts_by_conflict_degree() -> None: - allocator = GreedyByConflictAllocatorCpp() - low_conflict = Allocation(id=1, size=100, start=0, end=5) - high_conflict = Allocation(id=2, size=100, start=10, end=20) - other1 = Allocation(id=3, size=100, start=12, end=18) - other2 = Allocation(id=4, size=100, start=15, end=25) - result = allocator.allocate((low_conflict, high_conflict, other1, other2)) - assert result[0].id in [2, 3, 4] - assert result[3].id == 1 - - -def test_greedy_cpp_by_conflict_uses_size_as_tiebreaker() -> None: - allocator = GreedyByConflictAllocatorCpp() - small = Allocation(id=1, size=50, start=0, end=10) - large = Allocation(id=2, size=200, start=0, end=10) - result = allocator.allocate((small, large)) - assert result[0].id == 2 - assert result[1].id == 1 - - -def test_greedy_cpp_by_area_empty() -> None: - allocator = GreedyByAreaAllocatorCpp() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_greedy_cpp_by_area_sorts_by_area() -> None: - allocator = GreedyByAreaAllocatorCpp() - small_area = Allocation(id=1, size=10, start=0, end=10) - medium_area = Allocation(id=2, size=100, start=0, end=10) - large_area = Allocation(id=3, size=100, start=0, end=100) - result = allocator.allocate((small_area, medium_area, large_area)) - assert result[0].id == 3 - assert result[1].id == 2 - assert result[2].id == 1 - - -def test_greedy_cpp_by_area_allocates_correctly() -> None: - allocator = GreedyByAreaAllocatorCpp() - alloc1 = Allocation(id=1, size=100, start=0, end=100) - alloc2 = Allocation(id=2, size=10, start=50, end=60) - result = allocator.allocate((alloc1, alloc2)) - assert result[0].offset == 0 - assert result[1].offset == 100 - - -def test_greedy_cpp_by_size_empty() -> None: - allocator = GreedyBySizeAllocatorCpp() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_greedy_cpp_by_size_sorts_by_size() -> None: - allocator = GreedyBySizeAllocatorCpp() - small = Allocation(id=1, size=10, start=0, end=10) - medium = Allocation(id=2, size=100, start=0, end=10) - large = Allocation(id=3, size=1000, start=0, end=10) - result = allocator.allocate((small, medium, large)) - assert result[0].id == 3 - assert result[1].id == 2 - assert result[2].id == 1 - - -def test_greedy_cpp_by_size_allocates_correctly() -> None: - allocator = GreedyBySizeAllocatorCpp() - small = Allocation(id=1, size=50, start=0, end=10) - large = Allocation(id=2, size=200, start=5, end=15) - result = allocator.allocate((small, large)) - assert result[0].id == 2 - assert result[1].id == 1 - assert result[0].offset == 0 - assert result[1].offset == 200 - - -def test_greedy_cpp_by_conflict_size_empty() -> None: - allocator = GreedyByConflictSizeAllocatorCpp() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_greedy_cpp_by_conflict_size_sorts_by_product() -> None: - allocator = GreedyByConflictSizeAllocatorCpp() - big_lonely = Allocation(id=1, size=1000, start=0, end=5) - busy_large = Allocation(id=2, size=100, start=10, end=20) - busy_medium = Allocation(id=3, size=50, start=10, end=20) - busy_small = Allocation(id=4, size=20, start=10, end=20) - result = allocator.allocate((big_lonely, busy_large, busy_medium, busy_small)) - assert [a.id for a in result] == [2, 3, 4, 1] - - -def test_greedy_cpp_by_start_empty() -> None: - allocator = GreedyByStartAllocatorCpp() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_greedy_cpp_by_start_sorts_by_start() -> None: - allocator = GreedyByStartAllocatorCpp() - late = Allocation(id=1, size=100, start=20, end=30) - early = Allocation(id=2, size=100, start=0, end=10) - middle = Allocation(id=3, size=100, start=10, end=20) - result = allocator.allocate((late, early, middle)) - assert [a.id for a in result] == [2, 3, 1] - - -def test_greedy_cpp_allocator_all_overlap() -> None: - allocator = GreedyAllocatorCpp() - allocs = tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(5)) - result = allocator.allocate(allocs) - offsets = [a.offset for a in result] - assert offsets == [0, 100, 200, 300, 400] - - -def test_greedy_cpp_allocator_partial_overlap_chain() -> None: - allocator = GreedyAllocatorCpp() - alloc1 = Allocation(id=1, size=100, start=0, end=10) - alloc2 = Allocation(id=2, size=100, start=5, end=15) - alloc3 = Allocation(id=3, size=100, start=10, end=20) - alloc4 = Allocation(id=4, size=100, start=15, end=25) - result = allocator.allocate((alloc1, alloc2, alloc3, alloc4)) - assert result[0].offset == 0 - assert result[1].offset == 100 - assert result[2].offset == 0 - assert result[3].offset == 100 - - -def test_greedy_cpp_by_duration_deterministic() -> None: - allocator = GreedyByDurationAllocatorCpp() - allocs = tuple( - Allocation(id=i, size=100, start=0, end=i % 5 + 1) for i in range(10) - ) - result1 = allocator.allocate(allocs) - result2 = allocator.allocate(allocs) - assert all(r1.offset == r2.offset for r1, r2 in zip(result1, result2, strict=True)) - - -def test_greedy_cpp_by_size_deterministic() -> None: - allocator = GreedyBySizeAllocatorCpp() - allocs = tuple( - Allocation(id=i, size=(i % 5 + 1) * 100, start=0, end=10) for i in range(10) - ) - result1 = allocator.allocate(allocs) - result2 = allocator.allocate(allocs) - assert all(r1.offset == r2.offset for r1, r2 in zip(result1, result2, strict=True)) - - -def test_greedy_cpp_allocator_fits_in_gap() -> None: - allocator = GreedyAllocatorCpp() - alloc1 = Allocation(id=1, size=50, start=0, end=5) - alloc2 = Allocation(id=2, size=50, start=0, end=5) - alloc3 = Allocation(id=3, size=40, start=0, end=5) - result = allocator.allocate((alloc1, alloc2, alloc3)) - assert result[0].offset == 0 - assert result[1].offset == 50 - assert result[2].offset == 100 - - -def test_greedy_cpp_matches_python() -> None: - """Test that C++ implementation produces same results as Python.""" - cpp_allocator = GreedyAllocatorCpp() - py_allocator = GreedyAllocator() - - # Test with various allocation patterns - test_cases = [ - tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(5)), - ( - Allocation(id=1, size=100, start=0, end=5), - Allocation(id=2, size=100, start=3, end=8), - Allocation(id=3, size=100, start=6, end=10), - Allocation(id=4, size=50, start=0, end=10), - ), - ( - Allocation(id=1, size=50, start=0, end=5), - Allocation(id=2, size=50, start=0, end=5), - Allocation(id=3, size=40, start=0, end=5), - ), - ] - - for allocs in test_cases: - cpp_result = cpp_allocator.allocate(allocs) - py_result = py_allocator.allocate(allocs) - assert len(cpp_result) == len(py_result) - for cpp_alloc, py_alloc in zip(cpp_result, py_result, strict=True): - assert cpp_alloc.offset == py_alloc.offset - assert cpp_alloc.id == py_alloc.id - - -def test_greedy_cpp_by_all_empty() -> None: - allocator = GreedyByAllAllocatorCpp() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_greedy_cpp_by_all_preserves_allocations() -> None: - allocator = GreedyByAllAllocatorCpp() - allocs = ( - Allocation(id=1, size=100, start=0, end=10), - Allocation(id=2, size=50, start=5, end=15), - ) - result = allocator.allocate(allocs) - assert len(result) == len(allocs) - assert {a.id for a in result} == {1, 2} - assert all(a.offset is not None for a in result) - - -def test_greedy_cpp_by_all_deterministic() -> None: - allocator = GreedyByAllAllocatorCpp() - allocs = tuple( - Allocation(id=i, size=(i % 5 + 1) * 100, start=0, end=i % 7 + 1) - for i in range(20) - ) - result1 = allocator.allocate(allocs) - result2 = allocator.allocate(allocs) - assert all(r1.offset == r2.offset for r1, r2 in zip(result1, result2, strict=True)) - - -def test_greedy_cpp_by_all_matches_python() -> None: - """C++ greedy-by-all should match the Python greedy-by-all result.""" - cpp_allocator = GreedyByAllAllocatorCpp() - py_allocator = GreedyByAllAllocator() - - test_cases = [ - tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(5)), - ( - Allocation(id=1, size=100, start=0, end=5), - Allocation(id=2, size=100, start=3, end=8), - Allocation(id=3, size=100, start=6, end=10), - Allocation(id=4, size=50, start=0, end=10), - Allocation(id=5, size=300, start=2, end=4), - ), - tuple( - Allocation(id=i, size=(i % 5 + 1) * 100, start=0, end=i % 7 + 1) - for i in range(20) - ), - ] - - for allocs in test_cases: - cpp_result = cpp_allocator.allocate(allocs) - py_result = py_allocator.allocate(allocs) - assert peak_memory(cpp_result) == peak_memory(py_result) - - -def test_greedy_cpp_by_all_parallel_matches_serial() -> None: - allocs = tuple( - Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 6, end=i % 6 + i % 7 + 1) - for i in range(25) - ) - serial = GreedyByAllAllocatorCpp(cores=1).allocate(allocs) - parallel = GreedyByAllAllocatorCpp(cores=2).allocate(allocs) - assert {a.id: a.offset for a in parallel} == {a.id: a.offset for a in serial} - - -def test_cpp_first_fit_place_reuses_overlaps_across_orders() -> None: - allocs = tuple( - Allocation(id=i, size=(i % 4 + 1) * 10, start=i % 5, end=i % 5 + i % 3 + 1) - for i in range(30) - ) - overlaps = compute_temporal_overlaps(allocs, None) - for order in (allocs, tuple(reversed(allocs))): - result = first_fit_place(order, overlaps) - expected = GreedyAllocator().allocate(order) - assert [(a.id, a.offset) for a in result] == [ - (a.id, a.offset) for a in expected - ] diff --git a/tests/unit/allocators/test_hillclimb.py b/tests/unit/allocators/test_hillclimb.py index ed7e5e3..10e72ed 100644 --- a/tests/unit/allocators/test_hillclimb.py +++ b/tests/unit/allocators/test_hillclimb.py @@ -3,15 +3,15 @@ # import pytest -from omnimalloc.allocators.greedy_base import peak_memory from omnimalloc.allocators.hillclimb import HillClimbAllocator +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation from omnimalloc.primitives.pool import Pool from omnimalloc.validate import validate_allocation -def _is_valid(result: tuple[Allocation, ...]) -> bool: - return validate_allocation(Pool(id="test_pool", allocations=result)) +def _assert_valid(result: tuple[Allocation, ...]) -> None: + validate_allocation(Pool(id="test_pool", allocations=result)) def test_hillclimb_empty() -> None: @@ -64,7 +64,7 @@ def test_hillclimb_produces_valid_allocation() -> None: Allocation(id=4, size=50, start=0, end=10), ) result = allocator.allocate(allocs) - assert _is_valid(result) + _assert_valid(result) def test_hillclimb_no_temporal_overlap_shares_offset() -> None: @@ -81,8 +81,8 @@ def test_hillclimb_all_overlap_stacks_sequentially() -> None: allocator = HillClimbAllocator() allocs = tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(5)) result = allocator.allocate(allocs) - assert _is_valid(result) - assert peak_memory(result) == 500 + _assert_valid(result) + assert placement_pressure(result) == 500 def test_hillclimb_survives_rejected_step_undo() -> None: @@ -96,7 +96,7 @@ def test_hillclimb_survives_rejected_step_undo() -> None: ) result = allocator.allocate(allocs) assert len(result) == len(allocs) - assert _is_valid(result) + _assert_valid(result) def test_hillclimb_deterministic() -> None: @@ -123,5 +123,5 @@ def test_hillclimb_not_worse_than_greedy_by_size() -> None: ) hillclimb = HillClimbAllocator(seed=42).allocate(allocs) greedy = GreedyBySizeAllocator().allocate(allocs) - assert _is_valid(hillclimb) - assert peak_memory(hillclimb) <= peak_memory(greedy) + _assert_valid(hillclimb) + assert placement_pressure(hillclimb) <= placement_pressure(greedy) diff --git a/tests/unit/allocators/test_minimalloc.py b/tests/unit/allocators/test_minimalloc.py index 1e738d2..4561c8e 100644 --- a/tests/unit/allocators/test_minimalloc.py +++ b/tests/unit/allocators/test_minimalloc.py @@ -3,8 +3,8 @@ # import pytest -from omnimalloc.allocators.greedy_base import peak_memory from omnimalloc.allocators.minimalloc import HAS_MINIMALLOC, MinimallocAllocator +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation from omnimalloc.primitives.pool import Pool from omnimalloc.validate import validate_allocation @@ -49,7 +49,7 @@ def test_minimalloc_no_temporal_overlap_shares_offset() -> None: Allocation(id=2, size=100, start=10, end=20), ) result = MinimallocAllocator().allocate(allocs) - assert peak_memory(result) == 100 + assert placement_pressure(result) == 100 def test_minimalloc_produces_valid_allocation() -> None: @@ -58,7 +58,7 @@ def test_minimalloc_produces_valid_allocation() -> None: for i in range(20) ) result = MinimallocAllocator().allocate(allocs) - assert validate_allocation(Pool(id="test_pool", allocations=result)) + validate_allocation(Pool(id="test_pool", allocations=result)) assert {a.id for a in result} == {a.id for a in allocs} @@ -70,5 +70,5 @@ def test_minimalloc_finds_optimal_packing() -> None: Allocation(id=4, size=50, start=0, end=10), ) result = MinimallocAllocator().allocate(allocs) - assert validate_allocation(Pool(id="test_pool", allocations=result)) - assert peak_memory(result) == 250 + validate_allocation(Pool(id="test_pool", allocations=result)) + assert placement_pressure(result) == 250 diff --git a/tests/unit/allocators/test_omni.py b/tests/unit/allocators/test_omni.py index a539229..26d6a1d 100644 --- a/tests/unit/allocators/test_omni.py +++ b/tests/unit/allocators/test_omni.py @@ -5,9 +5,8 @@ import random import pytest -from omnimalloc._cpp import OmniAllocatorCpp from omnimalloc.allocators import BaseAllocator, NaiveAllocator, OmniAllocator -from omnimalloc.analysis.pressure import get_placement_pressure, get_pressure +from omnimalloc.analysis import placement_pressure, pressure from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.primitives import Allocation, Pool @@ -40,7 +39,7 @@ def _two_plus_two() -> tuple[Allocation, ...]: def test_omni_is_registered_and_supports_vector_time() -> None: - assert BaseAllocator.get("omni_allocator") is OmniAllocator + assert BaseAllocator.get("omni") is OmniAllocator assert OmniAllocator.supports_vector_time is True @@ -49,11 +48,6 @@ def test_omni_rejects_negative_linearize_budget() -> None: OmniAllocator(linearize_budget=-1) -def test_omni_cpp_repr_includes_budget() -> None: - assert repr(OmniAllocatorCpp(100)) == "OmniAllocator(linearize_budget=100)" - assert repr(OmniAllocatorCpp(None)) == "OmniAllocator(linearize_budget=None)" - - def test_omni_empty_returns_empty() -> None: assert OmniAllocator().allocate(()) == () @@ -67,15 +61,15 @@ def test_omni_scalar_placement_is_valid_and_bounded() -> None: allocations = _random_scalar(200, seed=1) placed = OmniAllocator().allocate(allocations) validate_allocation(Pool(id="p", allocations=placed)) - assert get_pressure(allocations) <= get_placement_pressure(placed) - assert get_placement_pressure(placed) <= sum(a.size for a in allocations) + assert pressure(allocations) <= placement_pressure(placed) + assert placement_pressure(placed) <= sum(a.size for a in allocations) def test_omni_scalar_not_worse_than_naive() -> None: allocations = _random_scalar(150, seed=2) omni = OmniAllocator().allocate(allocations) naive = NaiveAllocator().allocate(allocations) - assert get_placement_pressure(omni) <= get_placement_pressure(naive) + assert placement_pressure(omni) <= placement_pressure(naive) def test_omni_preserves_vector_times_and_metadata() -> None: @@ -90,7 +84,7 @@ def test_omni_preserves_vector_times_and_metadata() -> None: def test_omni_non_linearizable_placement_is_valid() -> None: placed = OmniAllocator().allocate(_two_plus_two()) validate_allocation(Pool(id="p", allocations=placed)) - assert get_placement_pressure(placed) >= 64 + 16 + assert placement_pressure(placed) >= 64 + 16 def test_omni_lockstep_matches_scalar_peak() -> None: @@ -99,8 +93,8 @@ def test_omni_lockstep_matches_scalar_peak() -> None: Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) for a in scalar ) - lockstep_peak = get_placement_pressure(OmniAllocator().allocate(lockstep)) - assert lockstep_peak == get_placement_pressure(OmniAllocator().allocate(scalar)) + lockstep_peak = placement_pressure(OmniAllocator().allocate(lockstep)) + assert lockstep_peak == placement_pressure(OmniAllocator().allocate(scalar)) def test_omni_is_deterministic() -> None: @@ -117,7 +111,7 @@ def test_omni_ignores_existing_offsets() -> None: for i in range(4) ) placed = OmniAllocator().allocate(allocations) - assert get_placement_pressure(placed) == 128 + assert placement_pressure(placed) == 128 def test_omni_handles_extreme_durations() -> None: @@ -126,7 +120,7 @@ def test_omni_handles_extreme_durations() -> None: ) placed = OmniAllocator().allocate(allocations) validate_allocation(Pool(id="p", allocations=placed)) - assert get_placement_pressure(placed) == sum(a.size for a in allocations) + assert placement_pressure(placed) == sum(a.size for a in allocations) def test_omni_rejects_duplicate_ids() -> None: @@ -155,7 +149,7 @@ def test_omni_concurrent_tiling_stays_near_optimum(num_syncs: int) -> None: ) placed = OmniAllocator().allocate(source.get_allocations()) validate_allocation(Pool(id="p", allocations=placed)) - assert capacity <= get_placement_pressure(placed) <= 2 * capacity + assert capacity <= placement_pressure(placed) <= 2 * capacity @pytest.mark.parametrize("pattern", SYNC_PATTERNS) @@ -168,7 +162,7 @@ def test_omni_torture_across_sync_patterns(pattern: str) -> None: placed = OmniAllocator().allocate(allocations) validate_allocation(Pool(id=f"{pattern}-{seed}", allocations=placed)) naive = NaiveAllocator().allocate(allocations) - assert get_placement_pressure(placed) <= get_placement_pressure(naive) + assert placement_pressure(placed) <= placement_pressure(naive) def test_omni_torture_across_tiling_variants() -> None: diff --git a/tests/unit/allocators/test_random.py b/tests/unit/allocators/test_random.py index 4a871bd..5c7988f 100644 --- a/tests/unit/allocators/test_random.py +++ b/tests/unit/allocators/test_random.py @@ -2,9 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 # -from omnimalloc.allocators.greedy_base import peak_memory from omnimalloc.allocators.random import RandomAllocator -from omnimalloc.analysis.pressure import get_pressure +from omnimalloc.analysis import placement_pressure, pressure from omnimalloc.primitives import Allocation from omnimalloc.primitives.pool import Pool from omnimalloc.validate import validate_allocation @@ -40,7 +39,7 @@ def test_random_zero_trials_falls_back_to_insertion_order_greedy() -> None: def test_random_produces_valid_allocation() -> None: allocs = _allocs(20) result = RandomAllocator(num_trials=20).allocate(allocs) - assert validate_allocation(Pool(id="test_pool", allocations=result)) + validate_allocation(Pool(id="test_pool", allocations=result)) assert {a.id for a in result} == {a.id for a in allocs} @@ -63,11 +62,11 @@ def test_random_more_trials_never_worse_for_same_seed() -> None: allocs = _allocs(30) few = RandomAllocator(num_trials=5, seed=3).allocate(allocs) many = RandomAllocator(num_trials=50, seed=3).allocate(allocs) - assert peak_memory(many) <= peak_memory(few) + assert placement_pressure(many) <= placement_pressure(few) def test_random_peak_within_problem_bounds() -> None: allocs = _allocs(30) result = RandomAllocator(num_trials=30, seed=1).allocate(allocs) - assert validate_allocation(Pool(id="test_pool", allocations=result)) - assert get_pressure(allocs) <= peak_memory(result) <= sum(a.size for a in allocs) + validate_allocation(Pool(id="test_pool", allocations=result)) + assert pressure(allocs) <= placement_pressure(result) <= sum(a.size for a in allocs) diff --git a/tests/unit/allocators/test_simulated_annealing.py b/tests/unit/allocators/test_simulated_annealing.py index fd50b8b..fce55a2 100644 --- a/tests/unit/allocators/test_simulated_annealing.py +++ b/tests/unit/allocators/test_simulated_annealing.py @@ -4,17 +4,14 @@ import pytest from omnimalloc.allocators.greedy import GreedyBySizeAllocator -from omnimalloc.allocators.greedy_base import peak_memory -from omnimalloc.allocators.simulated_annealing import ( - SimulatedAnnealingAllocator, - SimulatedAnnealingConfig, -) +from omnimalloc.allocators.simulated_annealing import SimulatedAnnealingAllocator +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation, Pool from omnimalloc.validate import validate_allocation -def _is_valid(result: tuple[Allocation, ...]) -> bool: - return validate_allocation(Pool(id="test_pool", allocations=result)) +def _assert_valid(result: tuple[Allocation, ...]) -> None: + validate_allocation(Pool(id="test_pool", allocations=result)) def test_simulated_annealing_empty() -> None: @@ -33,19 +30,19 @@ def test_simulated_annealing_single() -> None: def test_simulated_annealing_rejects_non_positive_iterations() -> None: with pytest.raises(ValueError, match="max_iterations must be positive"): - SimulatedAnnealingConfig(max_iterations=0) + SimulatedAnnealingAllocator(max_iterations=0) def test_simulated_annealing_rejects_negative_temperature() -> None: with pytest.raises(ValueError, match="initial_temperature must be non-negative"): - SimulatedAnnealingConfig(initial_temperature=-1.0) + SimulatedAnnealingAllocator(initial_temperature=-1.0) def test_simulated_annealing_rejects_invalid_cooling_rate() -> None: with pytest.raises(ValueError, match="cooling_rate must be in"): - SimulatedAnnealingConfig(cooling_rate=0.0) + SimulatedAnnealingAllocator(cooling_rate=0.0) with pytest.raises(ValueError, match="cooling_rate must be in"): - SimulatedAnnealingConfig(cooling_rate=1.5) + SimulatedAnnealingAllocator(cooling_rate=1.5) def test_simulated_annealing_preserves_allocations() -> None: @@ -74,8 +71,8 @@ def test_simulated_annealing_all_overlap_stacks_sequentially() -> None: allocator = SimulatedAnnealingAllocator() allocs = tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(5)) result = allocator.allocate(allocs) - assert _is_valid(result) - assert peak_memory(result) == 500 + _assert_valid(result) + assert placement_pressure(result) == 500 def test_simulated_annealing_deterministic() -> None: @@ -83,15 +80,13 @@ def test_simulated_annealing_deterministic() -> None: Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 3, end=i % 3 + i % 7 + 1) for i in range(20) ) - config = SimulatedAnnealingConfig(max_iterations=200) - result1 = SimulatedAnnealingAllocator(config).allocate(allocs) - result2 = SimulatedAnnealingAllocator(config).allocate(allocs) + result1 = SimulatedAnnealingAllocator(max_iterations=200).allocate(allocs) + result2 = SimulatedAnnealingAllocator(max_iterations=200).allocate(allocs) assert all(r1.offset == r2.offset for r1, r2 in zip(result1, result2, strict=True)) def test_simulated_annealing_produces_valid_allocation_on_dense_overlap() -> None: - config = SimulatedAnnealingConfig(max_iterations=300) - allocator = SimulatedAnnealingAllocator(config) + allocator = SimulatedAnnealingAllocator(max_iterations=300) allocs = tuple( Allocation( id=i, @@ -102,7 +97,7 @@ def test_simulated_annealing_produces_valid_allocation_on_dense_overlap() -> Non for i in range(30) ) result = allocator.allocate(allocs) - assert _is_valid(result) + _assert_valid(result) assert {a.id for a in result} == {a.id for a in allocs} @@ -116,9 +111,15 @@ def test_simulated_annealing_matches_or_beats_single_pass_greedy() -> None: ) for i in range(40) ) - greedy_peak = peak_memory(GreedyBySizeAllocator().allocate(allocs)) - annealed = SimulatedAnnealingAllocator( - SimulatedAnnealingConfig(max_iterations=2000) - ).allocate(allocs) - assert _is_valid(annealed) - assert peak_memory(annealed) <= greedy_peak + greedy_peak = placement_pressure(GreedyBySizeAllocator().allocate(allocs)) + annealed = SimulatedAnnealingAllocator(max_iterations=2000).allocate(allocs) + _assert_valid(annealed) + assert placement_pressure(annealed) <= greedy_peak + + +def test_simulated_annealing_repr_shows_flat_kwargs() -> None: + allocator = SimulatedAnnealingAllocator(seed=7, max_iterations=10) + assert repr(allocator) == ( + "SimulatedAnnealingAllocator(seed=7, max_iterations=10, " + "initial_temperature=3.0, cooling_rate=0.998, timeout=3.0)" + ) diff --git a/tests/unit/allocators/test_supermalloc.py b/tests/unit/allocators/test_supermalloc.py index fe71a14..a4e004a 100644 --- a/tests/unit/allocators/test_supermalloc.py +++ b/tests/unit/allocators/test_supermalloc.py @@ -5,19 +5,45 @@ from typing import cast import pytest -from omnimalloc.allocators.greedy_base import peak_memory +from omnimalloc._cpp import Partition, try_solve_many from omnimalloc.allocators.supermalloc import ( Heuristic, SortKey, SupermallocAllocator, - SupermallocConfig, ) +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation, Pool from omnimalloc.validate import validate_allocation def _assert_valid(result: tuple[Allocation, ...]) -> None: - assert validate_allocation(Pool(id="pool", allocations=result)) + validate_allocation(Pool(id="test_pool", allocations=result)) + + +def _ablation_solve( + allocations: tuple[Allocation, ...], + canonical: bool = True, + dominance: bool = True, + floor_inference: bool = True, + monotonic_floor: bool = True, + decompose: bool = True, +) -> tuple[Allocation, ...]: + partition = Partition.from_allocations(allocations) + bound = sum(a.size for a in allocations) + 1 + solution = try_solve_many( + [partition.with_bound(bound)], + bound, + None, + canonical, + dominance, + floor_inference, + monotonic_floor, + decompose, + 2.0, + 1, + ) + assert solution is not None + return tuple(solution.allocations) def test_empty() -> None: @@ -51,7 +77,7 @@ def test_overlapping_reach_lower_bound() -> None: ) ) _assert_valid(result) - assert peak_memory(result) == 175 + assert placement_pressure(result) == 175 def test_preserves_ids_and_sizes() -> None: @@ -67,47 +93,45 @@ def test_preserves_ids_and_sizes() -> None: ["canonical", "dominance", "floor_inference", "monotonic_floor", "decompose"], ) def test_ablation_flags_still_valid(flag: str) -> None: - config = SupermallocConfig(timeout=2.0, **{flag: False}) allocations = tuple( Allocation(id=i, size=100 + i, start=i % 4, end=4 + i % 5) for i in range(8) ) - result = SupermallocAllocator(config).allocate(allocations) - _assert_valid(result) + placed = _ablation_solve(allocations, **{flag: False}) + _assert_valid(placed) def test_deterministic_single_threaded() -> None: - config = SupermallocConfig(cores=1) allocations = tuple( Allocation(id=i, size=50 + 7 * i, start=i % 3, end=5 + i % 4) for i in range(10) ) - first = SupermallocAllocator(config).allocate(allocations) - second = SupermallocAllocator(config).allocate(allocations) + first = SupermallocAllocator(num_threads=1).allocate(allocations) + second = SupermallocAllocator(num_threads=1).allocate(allocations) assert [(a.id, a.offset) for a in first] == [(a.id, a.offset) for a in second] -def test_custom_heuristics_config() -> None: - config = SupermallocConfig( - heuristics=((SortKey.SIZE,), (SortKey.AREA, SortKey.WIDTH)) +def test_custom_heuristics() -> None: + allocator = SupermallocAllocator( + heuristics=((SortKey.SIZE,), (SortKey.AREA, SortKey.DURATION)) ) allocations = tuple( Allocation(id=i, size=10 * (i + 1), start=i % 3, end=3 + i % 4) for i in range(6) ) - result = SupermallocAllocator(config).allocate(allocations) + result = allocator.allocate(allocations) _assert_valid(result) def test_empty_heuristics_rejected() -> None: with pytest.raises(ValueError, match="at least one heuristic"): - SupermallocConfig(heuristics=()) + SupermallocAllocator(heuristics=()) def test_invalid_sort_key_rejected() -> None: - config = SupermallocConfig(heuristics=cast("tuple[Heuristic, ...]", (("X",),))) + allocator = SupermallocAllocator( + heuristics=cast("tuple[Heuristic, ...]", (("X",),)) + ) with pytest.raises(ValueError, match="Unknown sort key"): - SupermallocAllocator(config).allocate( - (Allocation(id=1, size=10, start=0, end=5),) - ) + allocator.allocate((Allocation(id=1, size=10, start=0, end=5),)) def test_total_size_overflow_rejected() -> None: @@ -115,7 +139,7 @@ def test_total_size_overflow_rejected() -> None: Allocation(id=1, size=2**62, start=0, end=5), Allocation(id=2, size=2**62, start=0, end=5), ) - with pytest.raises(OverflowError): + with pytest.raises(ValueError, match="int64"): SupermallocAllocator().allocate(allocations) @@ -127,7 +151,7 @@ def test_perfect_tiling_stack() -> None: ) result = SupermallocAllocator().allocate(allocations) _assert_valid(result) - assert peak_memory(result) == 256 + assert placement_pressure(result) == 256 def test_interleaved_lifetimes() -> None: @@ -140,4 +164,4 @@ def test_interleaved_lifetimes() -> None: ) result = SupermallocAllocator().allocate(allocations) _assert_valid(result) - assert peak_memory(result) == 300 + assert placement_pressure(result) == 300 diff --git a/tests/unit/allocators/test_tabu_search.py b/tests/unit/allocators/test_tabu_search.py index 0d210ad..eeafb03 100644 --- a/tests/unit/allocators/test_tabu_search.py +++ b/tests/unit/allocators/test_tabu_search.py @@ -3,16 +3,16 @@ # import pytest -from omnimalloc._cpp import TabuSearchAllocatorCpp +from omnimalloc._cpp import tabu_search_place from omnimalloc.allocators.greedy import GreedyBySizeAllocator -from omnimalloc.allocators.greedy_base import peak_memory -from omnimalloc.allocators.tabu_search import TabuSearchAllocator, TabuSearchConfig +from omnimalloc.allocators.tabu_search import TabuSearchAllocator +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation, Pool from omnimalloc.validate import validate_allocation -def _is_valid(result: tuple[Allocation, ...]) -> bool: - return validate_allocation(Pool(id="test_pool", allocations=result)) +def _assert_valid(result: tuple[Allocation, ...]) -> None: + validate_allocation(Pool(id="test_pool", allocations=result)) def test_tabu_search_empty() -> None: @@ -31,29 +31,33 @@ def test_tabu_search_single() -> None: def test_tabu_search_rejects_non_positive_iterations() -> None: with pytest.raises(ValueError, match="max_iterations must be positive"): - TabuSearchConfig(max_iterations=0) + TabuSearchAllocator(max_iterations=0) def test_tabu_search_rejects_non_positive_neighborhood_size() -> None: with pytest.raises(ValueError, match="neighborhood_size must be positive"): - TabuSearchConfig(neighborhood_size=0) + TabuSearchAllocator(neighborhood_size=0) def test_tabu_search_rejects_non_positive_tabu_tenure() -> None: with pytest.raises(ValueError, match="tabu_tenure must be positive"): - TabuSearchConfig(tabu_tenure=0) + TabuSearchAllocator(tabu_tenure=0) def test_tabu_search_cpp_boundary_rejects_zero_timeout() -> None: - config = TabuSearchConfig().to_cpp_config() - config.timeout = 0.0 - allocator = TabuSearchAllocatorCpp(config) - allocations = [ + allocations = ( Allocation(id=1, size=100, start=0, end=10), Allocation(id=2, size=100, start=5, end=15), - ] + ) with pytest.raises(ValueError, match="timeout must be positive"): - allocator.allocate(allocations) + tabu_search_place( + allocations, + seed=0, + max_iterations=10, + neighborhood_size=5, + tabu_tenure=3, + timeout=0.0, + ) def test_tabu_search_preserves_allocations() -> None: @@ -82,8 +86,8 @@ def test_tabu_search_all_overlap_stacks_sequentially() -> None: allocator = TabuSearchAllocator() allocs = tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(5)) result = allocator.allocate(allocs) - assert _is_valid(result) - assert peak_memory(result) == 500 + _assert_valid(result) + assert placement_pressure(result) == 500 def test_tabu_search_deterministic() -> None: @@ -91,14 +95,13 @@ def test_tabu_search_deterministic() -> None: Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 3, end=i % 3 + i % 7 + 1) for i in range(20) ) - config = TabuSearchConfig(max_iterations=50) - result1 = TabuSearchAllocator(config).allocate(allocs) - result2 = TabuSearchAllocator(config).allocate(allocs) + result1 = TabuSearchAllocator(max_iterations=50).allocate(allocs) + result2 = TabuSearchAllocator(max_iterations=50).allocate(allocs) assert all(r1.offset == r2.offset for r1, r2 in zip(result1, result2, strict=True)) def test_tabu_search_produces_valid_allocation_on_dense_overlap() -> None: - allocator = TabuSearchAllocator(TabuSearchConfig(max_iterations=80)) + allocator = TabuSearchAllocator(max_iterations=80) allocs = tuple( Allocation( id=i, @@ -109,7 +112,7 @@ def test_tabu_search_produces_valid_allocation_on_dense_overlap() -> None: for i in range(30) ) result = allocator.allocate(allocs) - assert _is_valid(result) + _assert_valid(result) assert {a.id for a in result} == {a.id for a in allocs} @@ -123,8 +126,7 @@ def test_tabu_search_matches_or_beats_single_pass_greedy() -> None: ) for i in range(40) ) - greedy_peak = peak_memory(GreedyBySizeAllocator().allocate(allocs)) - config = TabuSearchConfig(max_iterations=200) - searched = TabuSearchAllocator(config).allocate(allocs) - assert _is_valid(searched) - assert peak_memory(searched) <= greedy_peak + greedy_peak = placement_pressure(GreedyBySizeAllocator().allocate(allocs)) + searched = TabuSearchAllocator(max_iterations=200).allocate(allocs) + _assert_valid(searched) + assert placement_pressure(searched) <= greedy_peak diff --git a/tests/unit/allocators/test_telamalloc.py b/tests/unit/allocators/test_telamalloc.py index 33600a5..1aeac0a 100644 --- a/tests/unit/allocators/test_telamalloc.py +++ b/tests/unit/allocators/test_telamalloc.py @@ -4,14 +4,14 @@ import pytest from omnimalloc.allocators.greedy import GreedyBySizeAllocator -from omnimalloc.allocators.greedy_base import peak_memory -from omnimalloc.allocators.telamalloc import TelamallocAllocator, TelamallocConfig +from omnimalloc.allocators.telamalloc import TelamallocAllocator +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation, Pool from omnimalloc.validate import validate_allocation -def _is_valid(result: tuple[Allocation, ...]) -> bool: - return validate_allocation(Pool(id="test_pool", allocations=result)) +def _assert_valid(result: tuple[Allocation, ...]) -> None: + validate_allocation(Pool(id="test_pool", allocations=result)) def test_telamalloc_empty() -> None: @@ -30,12 +30,12 @@ def test_telamalloc_single() -> None: def test_telamalloc_rejects_negative_backtracks() -> None: with pytest.raises(ValueError, match="max_backtracks must be non-negative"): - TelamallocConfig(max_backtracks=-1) + TelamallocAllocator(max_backtracks=-1) def test_telamalloc_rejects_negative_seconds() -> None: with pytest.raises(ValueError, match="timeout must be positive or None"): - TelamallocConfig(timeout=-1.0) + TelamallocAllocator(timeout=-1.0) def test_telamalloc_no_temporal_overlap_shares_offset() -> None: @@ -73,21 +73,20 @@ def test_telamalloc_all_overlap_stacks_sequentially() -> None: allocator = TelamallocAllocator() allocs = tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(5)) result = allocator.allocate(allocs) - assert _is_valid(result) - assert peak_memory(result) == 500 + _assert_valid(result) + assert placement_pressure(result) == 500 def test_telamalloc_overlapping_reach_lower_bound() -> None: - config = TelamallocConfig(timeout=None) - result = TelamallocAllocator(config).allocate( + result = TelamallocAllocator(timeout=None).allocate( ( Allocation(id=1, size=100, start=0, end=10), Allocation(id=2, size=50, start=5, end=15), Allocation(id=3, size=25, start=0, end=15), ) ) - assert _is_valid(result) - assert peak_memory(result) == 175 + _assert_valid(result) + assert placement_pressure(result) == 175 def test_telamalloc_independent_phases_share_address_space() -> None: @@ -95,8 +94,8 @@ def test_telamalloc_independent_phases_share_address_space() -> None: early = tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(3)) late = tuple(Allocation(id=10 + i, size=100, start=20, end=30) for i in range(3)) result = allocator.allocate(early + late) - assert _is_valid(result) - assert peak_memory(result) == 300 + _assert_valid(result) + assert placement_pressure(result) == 300 offsets = sorted(a.offset for a in result if a.id >= 10) assert offsets == [0, 100, 200] @@ -106,9 +105,8 @@ def test_telamalloc_deterministic_without_deadline() -> None: Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 3, end=i % 3 + i % 7 + 1) for i in range(20) ) - config = TelamallocConfig(timeout=None) - result1 = TelamallocAllocator(config).allocate(allocs) - result2 = TelamallocAllocator(config).allocate(allocs) + result1 = TelamallocAllocator(timeout=None).allocate(allocs) + result2 = TelamallocAllocator(timeout=None).allocate(allocs) assert all(r1.offset == r2.offset for r1, r2 in zip(result1, result2, strict=True)) @@ -124,7 +122,7 @@ def test_telamalloc_produces_valid_allocation_on_dense_overlap() -> None: for i in range(30) ) result = allocator.allocate(allocs) - assert _is_valid(result) + _assert_valid(result) assert {a.id for a in result} == {a.id for a in allocs} @@ -138,16 +136,15 @@ def test_telamalloc_matches_or_beats_single_pass_greedy() -> None: ) for i in range(40) ) - greedy_peak = peak_memory(GreedyBySizeAllocator().allocate(allocs)) - config = TelamallocConfig(timeout=None) - result = TelamallocAllocator(config).allocate(allocs) - assert _is_valid(result) - assert peak_memory(result) <= greedy_peak + greedy_peak = placement_pressure(GreedyBySizeAllocator().allocate(allocs)) + result = TelamallocAllocator(timeout=None).allocate(allocs) + _assert_valid(result) + assert placement_pressure(result) <= greedy_peak def test_telamalloc_rejects_int64_overflow_inputs() -> None: huge = tuple( Allocation(id=i, size=(2**63 - 1) // 4, start=0, end=10) for i in range(3) ) - with pytest.raises(OverflowError): + with pytest.raises(ValueError, match="int64"): TelamallocAllocator().allocate(huge) diff --git a/tests/unit/allocators/test_vector_time.py b/tests/unit/allocators/test_vector_time.py index e45ffd0..fa97632 100644 --- a/tests/unit/allocators/test_vector_time.py +++ b/tests/unit/allocators/test_vector_time.py @@ -3,34 +3,29 @@ # import pytest -from omnimalloc._cpp import FirstFitPlacer, Partition, compute_temporal_overlaps +from omnimalloc._cpp import FirstFitPlacer, Partition, conflicts from omnimalloc.allocators.base import BaseAllocator from omnimalloc.allocators.best_fit import BestFitAllocator from omnimalloc.allocators.genetic import HAS_DEAP, GeneticAllocator from omnimalloc.allocators.greedy import GreedyAllocator from omnimalloc.allocators.greedy_base import ( - compute_conflicts, order_by_area, order_by_conflict, order_by_conflict_size, order_by_duration, order_by_size, order_by_start, - peak_memory, ) -from omnimalloc.allocators.greedy_cpp import GreedyAllocatorCpp from omnimalloc.allocators.hillclimb import HillClimbAllocator from omnimalloc.allocators.minimalloc import HAS_MINIMALLOC, MinimallocAllocator from omnimalloc.allocators.naive import NaiveAllocator from omnimalloc.allocators.omni import OmniAllocator from omnimalloc.allocators.random import RandomAllocator -from omnimalloc.allocators.simulated_annealing import ( - SimulatedAnnealingAllocator, - SimulatedAnnealingConfig, -) +from omnimalloc.allocators.simulated_annealing import SimulatedAnnealingAllocator from omnimalloc.allocators.supermalloc import SupermallocAllocator -from omnimalloc.allocators.tabu_search import TabuSearchAllocator, TabuSearchConfig +from omnimalloc.allocators.tabu_search import TabuSearchAllocator from omnimalloc.allocators.telamalloc import TelamallocAllocator +from omnimalloc.analysis import conflict_degrees, placement_pressure from omnimalloc.primitives import Allocation from omnimalloc.primitives.pool import Pool from omnimalloc.validate import validate_allocation @@ -52,7 +47,6 @@ def vector_problem(n: int = 10) -> tuple[Allocation, ...]: "allocator_cls", [ GreedyAllocator, - GreedyAllocatorCpp, NaiveAllocator, OmniAllocator, RandomAllocator, @@ -65,15 +59,15 @@ def test_allocators_place_vector_problems( ) -> None: allocs = vector_problem() result = allocator_cls().allocate(allocs) - assert validate_allocation(Pool(id="p", allocations=result)) + validate_allocation(Pool(id="p", allocations=result)) assert {a.id for a in result} == {a.id for a in allocs} @pytest.mark.skipif(not HAS_DEAP, reason="deap not installed") def test_genetic_places_vector_problems() -> None: - allocator = GeneticAllocator(population_size=10, num_generations=3) + allocator = GeneticAllocator(population_size=10, max_generations=3) result = allocator.allocate(vector_problem()) - assert validate_allocation(Pool(id="p", allocations=result)) + validate_allocation(Pool(id="p", allocations=result)) def test_orderings_permute_vector_problems() -> None: @@ -108,25 +102,25 @@ def test_order_by_start_mixed_dimensions_rejected() -> None: order_by_start(mixed) -def test_compute_conflicts_matches_overlap_map() -> None: +def test_conflict_degrees_match_conflict_map() -> None: allocs = vector_problem() - overlaps = compute_temporal_overlaps(allocs, None) - conflicts = compute_conflicts(allocs) - for alloc in allocs: - assert conflicts[alloc] == len(overlaps.get(alloc.id, ())) + conflict_map = conflicts(allocs, None) + degrees = conflict_degrees(allocs, work_budget=None) + for alloc, degree in zip(allocs, degrees, strict=True): + assert degree == len(conflict_map[alloc.id]) -def test_compute_conflicts_scalar_matches_overlap_map() -> None: +def test_conflict_degrees_scalar_match_conflict_map() -> None: allocs = tuple( Allocation(id=i, size=8, start=i % 4, end=i % 4 + 2) for i in range(10) ) - overlaps = compute_temporal_overlaps(allocs, None) - conflicts = compute_conflicts(allocs) - for alloc in allocs: - assert conflicts[alloc] == len(overlaps.get(alloc.id, ())) + conflict_map = conflicts(allocs, None) + degrees = conflict_degrees(allocs, work_budget=None) + for alloc, degree in zip(allocs, degrees, strict=True): + assert degree == len(conflict_map[alloc.id]) -def test_compute_conflicts_duplicate_ids_match_scalar() -> None: +def test_conflict_degrees_duplicate_ids_match_scalar() -> None: scalar = ( Allocation(id=1, size=8, start=0, end=4), Allocation(id=1, size=16, start=1, end=5), @@ -136,44 +130,42 @@ def test_compute_conflicts_duplicate_ids_match_scalar() -> None: Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) for a in scalar ) - scalar_conflicts = compute_conflicts(scalar) - vector_conflicts = compute_conflicts(lockstep) - assert [scalar_conflicts[a] for a in scalar] == [ - vector_conflicts[a] for a in lockstep - ] + assert conflict_degrees(scalar, work_budget=None) == conflict_degrees( + lockstep, work_budget=None + ) -def test_overlap_map_matches_pairwise_test() -> None: +def test_conflict_map_matches_pairwise_test() -> None: allocs = vector_problem(12) - overlaps = compute_temporal_overlaps(allocs, None) + conflict_map = conflicts(allocs, None) for a in allocs: for b in allocs: if a.id == b.id: continue - assert (b.id in overlaps.get(a.id, set())) == a.overlaps_temporally(b) + assert (b.id in conflict_map[a.id]) == a.conflicts_with(b) def test_first_fit_placer_accepts_vector_problems() -> None: allocs = vector_problem() placer = FirstFitPlacer(list(allocs)) placed = tuple(placer.place(list(range(len(allocs))))) - assert validate_allocation(Pool(id="p", allocations=placed)) - assert placer.evaluate(list(range(len(allocs)))) == peak_memory(placed) + validate_allocation(Pool(id="p", allocations=placed)) + assert placer.peak(list(range(len(allocs)))) == placement_pressure(placed) def test_order_search_allocators_place_vector_problems() -> None: allocs = vector_problem() for allocator in ( - SimulatedAnnealingAllocator(SimulatedAnnealingConfig(max_iterations=20)), - TabuSearchAllocator(TabuSearchConfig(max_iterations=20)), + SimulatedAnnealingAllocator(max_iterations=20), + TabuSearchAllocator(max_iterations=20), ): result = allocator.allocate(allocs) - assert validate_allocation(Pool(id="p", allocations=result)) + validate_allocation(Pool(id="p", allocations=result)) assert {a.id for a in result} == {a.id for a in allocs} def test_partition_rejects_vector_time() -> None: - with pytest.raises(ValueError, match="scalar time"): + with pytest.raises(ValueError, match=r"requires scalar .* 2-dim vector clocks"): Partition.from_allocations([Allocation(id=1, size=8, start=(0, 1), end=(2, 2))]) @@ -195,13 +187,13 @@ def test_minimalloc_rejects_vector_time() -> None: def test_registry_declares_vector_time_support() -> None: registry = BaseAllocator.registry() - assert registry["greedy_allocator"].supports_vector_time - assert registry["omni_allocator"].supports_vector_time - assert registry["best_fit_allocator"].supports_vector_time - assert registry["simulated_annealing_allocator"].supports_vector_time - assert registry["tabu_search_allocator"].supports_vector_time - assert not registry["supermalloc_allocator"].supports_vector_time - assert not registry["telamalloc_allocator"].supports_vector_time + assert registry["greedy"].supports_vector_time + assert registry["omni"].supports_vector_time + assert registry["best_fit"].supports_vector_time + assert registry["simulated_annealing"].supports_vector_time + assert registry["tabu_search"].supports_vector_time + assert not registry["supermalloc"].supports_vector_time + assert not registry["telamalloc"].supports_vector_time def test_mixed_dimensions_rejected() -> None: @@ -232,10 +224,10 @@ def test_reuse_follows_happens_before() -> None: Allocation(id=1, size=100, start=(0, 0), end=(2, 1)), Allocation(id=2, size=100, start=(2, 1), end=(3, 2)), ) - assert peak_memory(GreedyAllocator().allocate(ordered)) == 100 + assert placement_pressure(GreedyAllocator().allocate(ordered)) == 100 concurrent = ( Allocation(id=1, size=100, start=(0, 5), end=(1, 6)), Allocation(id=2, size=100, start=(2, 0), end=(3, 1)), ) - assert peak_memory(GreedyAllocator().allocate(concurrent)) == 200 + assert placement_pressure(GreedyAllocator().allocate(concurrent)) == 200 diff --git a/tests/unit/analysis/test_conflicts.py b/tests/unit/analysis/test_conflicts.py index 1430790..5cf477c 100644 --- a/tests/unit/analysis/test_conflicts.py +++ b/tests/unit/analysis/test_conflicts.py @@ -5,12 +5,12 @@ from random import Random import pytest -from omnimalloc.analysis.conflicts import get_conflict_degrees, get_conflicts +from omnimalloc.analysis import conflict_degrees, conflicts from omnimalloc.primitives import Allocation def test_conflicts_empty() -> None: - assert get_conflicts(()) == {} + assert conflicts(()) == {} def test_conflicts_scalar_overlap() -> None: @@ -19,7 +19,7 @@ def test_conflicts_scalar_overlap() -> None: Allocation(id=2, size=8, start=2, end=6), Allocation(id=3, size=8, start=6, end=8), ) - assert get_conflicts(allocations) == {1: {2}, 2: {1}, 3: set()} + assert conflicts(allocations) == {1: {2}, 2: {1}, 3: set()} def test_conflicts_touching_intervals_do_not_conflict() -> None: @@ -27,7 +27,7 @@ def test_conflicts_touching_intervals_do_not_conflict() -> None: Allocation(id=1, size=8, start=0, end=4), Allocation(id=2, size=8, start=4, end=6), ) - assert get_conflicts(allocations) == {1: set(), 2: set()} + assert conflicts(allocations) == {1: set(), 2: set()} def test_conflicts_vector_concurrent() -> None: @@ -35,7 +35,7 @@ def test_conflicts_vector_concurrent() -> None: Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), Allocation(id="b", size=8, start=(0, 0), end=(0, 1)), ) - assert get_conflicts(allocations) == {"a": {"b"}, "b": {"a"}} + assert conflicts(allocations) == {"a": {"b"}, "b": {"a"}} def test_conflicts_vector_ordered_do_not_conflict() -> None: @@ -43,7 +43,7 @@ def test_conflicts_vector_ordered_do_not_conflict() -> None: Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), Allocation(id="b", size=8, start=(1, 0), end=(2, 0)), ) - assert get_conflicts(allocations) == {"a": set(), "b": set()} + assert conflicts(allocations) == {"a": set(), "b": set()} def test_conflicts_rejects_duplicate_ids() -> None: @@ -52,7 +52,7 @@ def test_conflicts_rejects_duplicate_ids() -> None: Allocation(id=1, size=8, start=1, end=3), ) with pytest.raises(ValueError, match="unique"): - get_conflicts(duplicated) + conflicts(duplicated) def test_conflicts_rejects_mixed_dimensions() -> None: @@ -61,26 +61,27 @@ def test_conflicts_rejects_mixed_dimensions() -> None: Allocation(id=2, size=8, start=(0, 0), end=(1, 1)), ) with pytest.raises(ValueError, match="dimension"): - get_conflicts(mixed) + conflicts(mixed) -def test_conflicts_over_budget_give_up() -> None: +def test_conflicts_over_budget_raise() -> None: allocations = tuple(Allocation(id=i, size=8, start=0, end=10) for i in range(4)) - assert get_conflicts(allocations, work_budget=1) is None + with pytest.raises(RuntimeError, match="work_budget"): + conflicts(allocations, work_budget=1) def test_conflicts_unbounded_budget_always_computes() -> None: allocations = tuple(Allocation(id=i, size=8, start=0, end=10) for i in range(2)) - assert get_conflicts(allocations, work_budget=None) == {0: {1}, 1: {0}} + assert conflicts(allocations, work_budget=None) == {0: {1}, 1: {0}} def test_conflicts_reject_negative_budget() -> None: with pytest.raises(ValueError, match="work_budget must be non-negative"): - get_conflicts((), work_budget=-1) + conflicts((), work_budget=-1) def test_conflict_degrees_empty() -> None: - assert get_conflict_degrees(()) == [] + assert conflict_degrees(()) == [] def test_conflict_degrees_align_with_input_order() -> None: @@ -89,7 +90,7 @@ def test_conflict_degrees_align_with_input_order() -> None: Allocation(id=2, size=8, start=2, end=6), Allocation(id=3, size=8, start=6, end=8), ) - assert get_conflict_degrees(allocations) == [1, 1, 0] + assert conflict_degrees(allocations) == [1, 1, 0] def test_conflict_degrees_allow_duplicate_ids() -> None: @@ -97,22 +98,30 @@ def test_conflict_degrees_allow_duplicate_ids() -> None: Allocation(id=1, size=8, start=0, end=2), Allocation(id=1, size=8, start=1, end=3), ) - assert get_conflict_degrees(duplicated) == [1, 1] + assert conflict_degrees(duplicated) == [1, 1] -def test_conflict_degrees_over_budget_give_up() -> None: +def test_conflict_degrees_over_budget_raise() -> None: + allocations = tuple( + Allocation(id=i, size=8, start=(0, 0), end=(10, 10)) for i in range(4) + ) + with pytest.raises(RuntimeError, match="work_budget"): + conflict_degrees(allocations, work_budget=1) + + +def test_conflict_degrees_scalar_ignores_budget() -> None: allocations = tuple(Allocation(id=i, size=8, start=0, end=10) for i in range(4)) - assert get_conflict_degrees(allocations, work_budget=1) is None + assert conflict_degrees(allocations, work_budget=1) == [3, 3, 3, 3] def test_conflict_degrees_unbounded_budget_always_counts() -> None: allocations = tuple(Allocation(id=i, size=8, start=0, end=10) for i in range(4)) - assert get_conflict_degrees(allocations, work_budget=None) == [3, 3, 3, 3] + assert conflict_degrees(allocations, work_budget=None) == [3, 3, 3, 3] def test_conflict_degrees_reject_negative_budget() -> None: with pytest.raises(ValueError, match="work_budget must be non-negative"): - get_conflict_degrees((), work_budget=-1) + conflict_degrees((), work_budget=-1) def _random_instance(rng: Random) -> tuple[Allocation, ...]: @@ -135,11 +144,11 @@ def test_conflicts_match_pairwise_overlaps() -> None: rng = Random(5) for _ in range(100): allocations = _random_instance(rng) - conflicts = get_conflicts(allocations) + conflict_map = conflicts(allocations) for alloc in allocations: expected = { other.id for other in allocations - if other.id != alloc.id and alloc.overlaps_temporally(other) + if other.id != alloc.id and alloc.conflicts_with(other) } - assert conflicts[alloc.id] == expected + assert conflict_map[alloc.id] == expected diff --git a/tests/unit/analysis/test_linearize.py b/tests/unit/analysis/test_linearize.py index aa9402f..53f8da0 100644 --- a/tests/unit/analysis/test_linearize.py +++ b/tests/unit/analysis/test_linearize.py @@ -6,7 +6,7 @@ import pytest from omnimalloc import try_linearize -from omnimalloc._cpp import compute_temporal_overlaps +from omnimalloc._cpp import conflicts from omnimalloc.allocators.supermalloc import SupermallocAllocator from omnimalloc.primitives import Allocation from omnimalloc.primitives.pool import Pool @@ -14,8 +14,8 @@ def _overlap_map(allocations: tuple[Allocation, ...]) -> dict[object, set[object]]: - overlaps = compute_temporal_overlaps(allocations, None) - return {a.id: set(overlaps.get(a.id, ())) for a in allocations} + conflict_map = conflicts(allocations, None) + return {a.id: set(conflict_map[a.id]) for a in allocations} def test_scalar_input_returned_unchanged() -> None: @@ -47,7 +47,7 @@ def test_concurrent_pair_linearizes_to_overlap() -> None: ) linearized = try_linearize(allocations) assert linearized is not None - assert linearized[0].overlaps_temporally(linearized[1]) + assert linearized[0].conflicts_with(linearized[1]) def test_two_plus_two_returns_none() -> None: @@ -194,4 +194,4 @@ def test_linearize_unlocks_supermalloc() -> None: linearized = try_linearize(allocations) assert linearized is not None placed = SupermallocAllocator().allocate(linearized) - assert validate_allocation(Pool(id="p", allocations=placed)) + validate_allocation(Pool(id="p", allocations=placed)) diff --git a/tests/unit/analysis/test_pressure.py b/tests/unit/analysis/test_pressure.py index efb839e..d86d06f 100644 --- a/tests/unit/analysis/test_pressure.py +++ b/tests/unit/analysis/test_pressure.py @@ -7,19 +7,19 @@ import pytest from omnimalloc.allocators.omni import OmniAllocator -from omnimalloc.analysis.pressure import ( - get_closure_pressure, - get_per_allocation_closure_pressure, - get_per_allocation_placement_pressure, - get_per_allocation_pressure, - get_placement_pressure, - get_pressure, +from omnimalloc.analysis import ( + closure_pressure, + closure_pressure_per_allocation, + placement_pressure, + placement_pressure_per_allocation, + pressure, + pressure_per_allocation, ) from omnimalloc.primitives import Allocation def test_pressure_empty_is_zero() -> None: - assert get_pressure(()) == 0 + assert pressure(()) == 0 def test_pressure_scalar_overlap() -> None: @@ -28,7 +28,7 @@ def test_pressure_scalar_overlap() -> None: Allocation(id=2, size=50, start=2, end=6), Allocation(id=3, size=25, start=6, end=8), ) - assert get_pressure(allocations) == 150 + assert pressure(allocations) == 150 def test_pressure_scalar_disjoint() -> None: @@ -36,7 +36,7 @@ def test_pressure_scalar_disjoint() -> None: Allocation(id=1, size=100, start=0, end=2), Allocation(id=2, size=50, start=2, end=4), ) - assert get_pressure(allocations) == 100 + assert pressure(allocations) == 100 def test_pressure_linearizable_vector_is_exact() -> None: @@ -45,7 +45,7 @@ def test_pressure_linearizable_vector_is_exact() -> None: Allocation(id=2, size=50, start=(1, 0), end=(3, 2)), Allocation(id=3, size=25, start=(3, 2), end=(4, 3)), ) - assert get_pressure(allocations) == 150 + assert pressure(allocations) == 150 def test_pressure_non_linearizable_is_exact() -> None: @@ -55,7 +55,7 @@ def test_pressure_non_linearizable_is_exact() -> None: Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), ) - assert get_pressure(two_plus_two) == 16 + 64 + assert pressure(two_plus_two) == 16 + 64 def test_pressure_matches_scalar_equivalent_under_lockstep() -> None: @@ -68,7 +68,7 @@ def test_pressure_matches_scalar_equivalent_under_lockstep() -> None: Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) for a in scalar ) - assert get_pressure(lockstep) == get_pressure(scalar) + assert pressure(lockstep) == pressure(scalar) def test_pressure_scalar_ignores_work_budget() -> None: @@ -76,7 +76,7 @@ def test_pressure_scalar_ignores_work_budget() -> None: Allocation(id=1, size=100, start=0, end=4), Allocation(id=2, size=50, start=2, end=6), ) - assert get_pressure(allocations, work_budget=1) == 150 + assert pressure(allocations, work_budget=1) == 150 def test_pressure_work_budget_exceeded_raises() -> None: @@ -87,31 +87,41 @@ def test_pressure_work_budget_exceeded_raises() -> None: Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), ) with pytest.raises(RuntimeError, match="work_budget"): - get_pressure(two_plus_two, work_budget=1) + pressure(two_plus_two, work_budget=1) def test_pressure_negative_work_budget_rejected() -> None: with pytest.raises(ValueError, match="work_budget must be non-negative"): - get_pressure((), work_budget=-1) + pressure((), work_budget=-1) def test_closure_pressure_negative_cap_rejected() -> None: with pytest.raises(ValueError, match="closure_cap must be non-negative"): - get_closure_pressure((), closure_cap=-1) + closure_pressure((), closure_cap=-1) + + +def test_closure_pressure_none_cap_enumerates_unbounded() -> None: + allocations = tuple( + Allocation(id=i, size=1, start=(i, 8 - i, 0), end=(i + 1, 9 - i, 9)) + for i in range(8) + ) + assert closure_pressure(allocations, closure_cap=None) == closure_pressure( + allocations + ) def test_pressure_total_size_overflow_raises() -> None: allocations = tuple(Allocation(id=i, size=2**62, start=0, end=1) for i in range(4)) - with pytest.raises(OverflowError, match="int64"): - get_pressure(allocations) + with pytest.raises(ValueError, match="int64"): + pressure(allocations) def test_pressure_unbudgeted_empty_is_zero() -> None: - assert get_pressure((), work_budget=None) == 0 + assert pressure((), work_budget=None) == 0 def test_closure_pressure_empty_is_zero() -> None: - assert get_closure_pressure(()) == 0 + assert closure_pressure(()) == 0 def test_exact_pressures_match_scalar_sweep() -> None: @@ -120,8 +130,8 @@ def test_exact_pressures_match_scalar_sweep() -> None: Allocation(id=2, size=50, start=2, end=6), Allocation(id=3, size=25, start=6, end=8), ) - assert get_pressure(allocations, work_budget=None) == 150 - assert get_closure_pressure(allocations) == 150 + assert pressure(allocations, work_budget=None) == 150 + assert closure_pressure(allocations) == 150 def test_pressure_unbudgeted_two_plus_two_exact() -> None: @@ -131,7 +141,7 @@ def test_pressure_unbudgeted_two_plus_two_exact() -> None: Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), ) - assert get_pressure(two_plus_two, work_budget=None) == 16 + 64 + assert pressure(two_plus_two, work_budget=None) == 16 + 64 def test_closure_pressure_below_antichain_without_common_cut() -> None: @@ -140,8 +150,8 @@ def test_closure_pressure_below_antichain_without_common_cut() -> None: Allocation(id="j", size=1, start=(3, 0), end=(4, 1)), Allocation(id="k", size=1, start=(0, 3), end=(1, 4)), ) - assert get_pressure(pinwheel, work_budget=None) == 3 - assert get_closure_pressure(pinwheel) == 2 + assert pressure(pinwheel, work_budget=None) == 3 + assert closure_pressure(pinwheel) == 2 def test_closure_pressure_cap_raises() -> None: @@ -150,7 +160,7 @@ def test_closure_pressure_cap_raises() -> None: for i in range(8) ) with pytest.raises(RuntimeError, match="closure_cap"): - get_closure_pressure(allocations, closure_cap=4) + closure_pressure(allocations, closure_cap=4) def test_exact_pressures_reject_mixed_dimensions() -> None: @@ -159,9 +169,9 @@ def test_exact_pressures_reject_mixed_dimensions() -> None: Allocation(id=2, size=8, start=(0, 0, 0), end=(1, 1, 1)), ) with pytest.raises(ValueError, match="dimension"): - get_pressure(mixed, work_budget=None) + pressure(mixed, work_budget=None) with pytest.raises(ValueError, match="dimension"): - get_closure_pressure(mixed) + closure_pressure(mixed) def test_exact_pressures_match_scalar_equivalent_under_lockstep() -> None: @@ -174,14 +184,14 @@ def test_exact_pressures_match_scalar_equivalent_under_lockstep() -> None: Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) for a in scalar ) - assert get_pressure(lockstep, work_budget=None) == get_pressure(scalar) - assert get_closure_pressure(lockstep) == get_pressure(scalar) + assert pressure(lockstep, work_budget=None) == pressure(scalar) + assert closure_pressure(lockstep) == pressure(scalar) def test_per_allocation_pressures_empty() -> None: - assert get_per_allocation_pressure(()) == {} - assert get_per_allocation_closure_pressure(()) == {} - assert get_per_allocation_placement_pressure(()) == {} + assert pressure_per_allocation(()) == {} + assert closure_pressure_per_allocation(()) == {} + assert placement_pressure_per_allocation(()) == {} def test_per_allocation_pressure_scalar() -> None: @@ -190,8 +200,8 @@ def test_per_allocation_pressure_scalar() -> None: Allocation(id=2, size=50, start=2, end=6), Allocation(id=3, size=25, start=6, end=8), ) - assert get_per_allocation_pressure(allocations) == {1: 150, 2: 150, 3: 25} - assert get_per_allocation_closure_pressure(allocations) == {1: 150, 2: 150, 3: 25} + assert pressure_per_allocation(allocations) == {1: 150, 2: 150, 3: 25} + assert closure_pressure_per_allocation(allocations) == {1: 150, 2: 150, 3: 25} def test_per_allocation_pressure_two_plus_two() -> None: @@ -202,9 +212,9 @@ def test_per_allocation_pressure_two_plus_two() -> None: Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), ) expected = {"a": 72, "b": 80, "c": 48, "d": 80} - assert get_per_allocation_pressure(two_plus_two) == expected - assert get_per_allocation_closure_pressure(two_plus_two) == expected - assert max(expected.values()) == get_pressure(two_plus_two) + assert pressure_per_allocation(two_plus_two) == expected + assert closure_pressure_per_allocation(two_plus_two) == expected + assert max(expected.values()) == pressure(two_plus_two) def test_per_allocation_pressure_scalar_ignores_work_budget() -> None: @@ -212,7 +222,7 @@ def test_per_allocation_pressure_scalar_ignores_work_budget() -> None: Allocation(id=1, size=100, start=0, end=4), Allocation(id=2, size=50, start=2, end=6), ) - assert get_per_allocation_pressure(allocations, work_budget=1) == {1: 150, 2: 150} + assert pressure_per_allocation(allocations, work_budget=1) == {1: 150, 2: 150} def test_per_allocation_pressure_work_budget_exceeded_raises() -> None: @@ -223,7 +233,7 @@ def test_per_allocation_pressure_work_budget_exceeded_raises() -> None: Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), ) with pytest.raises(RuntimeError, match="work_budget"): - get_per_allocation_pressure(two_plus_two, work_budget=1) + pressure_per_allocation(two_plus_two, work_budget=1) def test_per_allocation_pressure_unbudgeted_matches_default() -> None: @@ -233,9 +243,9 @@ def test_per_allocation_pressure_unbudgeted_matches_default() -> None: Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), ) - assert get_per_allocation_pressure( + assert pressure_per_allocation( two_plus_two, work_budget=None - ) == get_per_allocation_pressure(two_plus_two) + ) == pressure_per_allocation(two_plus_two) def test_per_allocation_closure_below_pinned_without_common_cut() -> None: @@ -244,8 +254,8 @@ def test_per_allocation_closure_below_pinned_without_common_cut() -> None: Allocation(id="j", size=1, start=(3, 0), end=(4, 1)), Allocation(id="k", size=1, start=(0, 3), end=(1, 4)), ) - assert get_per_allocation_pressure(pinwheel) == {"i": 3, "j": 3, "k": 3} - assert get_per_allocation_closure_pressure(pinwheel) == {"i": 2, "j": 2, "k": 2} + assert pressure_per_allocation(pinwheel) == {"i": 3, "j": 3, "k": 3} + assert closure_pressure_per_allocation(pinwheel) == {"i": 2, "j": 2, "k": 2} def test_per_allocation_pressure_matches_scalar_equivalent_under_lockstep() -> None: @@ -258,7 +268,7 @@ def test_per_allocation_pressure_matches_scalar_equivalent_under_lockstep() -> N Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) for a in scalar ) - assert get_per_allocation_pressure(lockstep) == get_per_allocation_pressure(scalar) + assert pressure_per_allocation(lockstep) == pressure_per_allocation(scalar) def test_per_allocation_closure_pressure_cap_raises() -> None: @@ -267,7 +277,7 @@ def test_per_allocation_closure_pressure_cap_raises() -> None: for i in range(8) ) with pytest.raises(RuntimeError, match="closure_cap"): - get_per_allocation_closure_pressure(allocations, closure_cap=4) + closure_pressure_per_allocation(allocations, closure_cap=4) def test_per_allocation_pressures_reject_duplicate_ids() -> None: @@ -276,21 +286,21 @@ def test_per_allocation_pressures_reject_duplicate_ids() -> None: Allocation(id=1, size=8, start=1, end=3, offset=8), ) with pytest.raises(ValueError, match="unique"): - get_per_allocation_pressure(duplicated) + pressure_per_allocation(duplicated) with pytest.raises(ValueError, match="unique"): - get_per_allocation_closure_pressure(duplicated) + closure_pressure_per_allocation(duplicated) with pytest.raises(ValueError, match="unique"): - get_per_allocation_placement_pressure(duplicated) + placement_pressure_per_allocation(duplicated) def test_per_allocation_placement_pressure_requires_offsets() -> None: unplaced = (Allocation(id=1, size=8, start=0, end=2),) with pytest.raises(ValueError, match="placed"): - get_per_allocation_placement_pressure(unplaced) + placement_pressure_per_allocation(unplaced) def test_placement_pressure_empty_is_zero() -> None: - assert get_placement_pressure(()) == 0 + assert placement_pressure(()) == 0 def test_placement_pressure_is_highest_occupied_address() -> None: @@ -299,13 +309,13 @@ def test_placement_pressure_is_highest_occupied_address() -> None: Allocation(id="y", size=50, start=1, end=3, offset=5), Allocation(id="z", size=5, start=2, end=4, offset=0), ) - assert get_placement_pressure(placed) == 55 + assert placement_pressure(placed) == 55 def test_placement_pressure_requires_offsets() -> None: unplaced = (Allocation(id=1, size=8, start=0, end=2),) with pytest.raises(ValueError, match="placed"): - get_placement_pressure(unplaced) + placement_pressure(unplaced) def test_per_allocation_placement_pressure_max_equals_peak() -> None: @@ -314,27 +324,16 @@ def test_per_allocation_placement_pressure_max_equals_peak() -> None: Allocation(id="y", size=50, start=1, end=3, offset=5), Allocation(id="z", size=5, start=2, end=4, offset=0), ) - peaks = get_per_allocation_placement_pressure(placed) + peaks = placement_pressure_per_allocation(placed) assert peaks == {"x": 55, "y": 55, "z": 55} assert max(peaks.values()) == 55 -def test_per_allocation_placement_pressure_clique_cap_tightens() -> None: - placed = ( - Allocation(id="a", size=1, start=0, end=2, offset=0), - Allocation(id="b", size=1, start=1, end=4, offset=10), - Allocation(id="c", size=10, start=3, end=5, offset=0), - ) - assert get_per_allocation_placement_pressure(placed) == {"a": 11, "b": 11, "c": 11} - capped = get_per_allocation_placement_pressure(placed, clique_cap=True) - assert capped == {"a": 2, "b": 11, "c": 11} - - def _brute_antichain(allocations: tuple[Allocation, ...]) -> int: best = 0 for count in range(1, len(allocations) + 1): for combo in combinations(allocations, count): - if all(a.overlaps_temporally(b) for a, b in combinations(combo, 2)): + if all(a.conflicts_with(b) for a, b in combinations(combo, 2)): best = max(best, sum(a.size for a in combo)) return best @@ -372,20 +371,18 @@ def test_antichain_pressure_matches_brute_force() -> None: rng = Random(7) for _ in range(150): allocations = _random_instance(rng) - assert get_pressure(allocations, work_budget=None) == _brute_antichain( - allocations - ) + assert pressure(allocations, work_budget=None) == _brute_antichain(allocations) def test_closure_pressure_matches_brute_force_and_bound_order() -> None: rng = Random(11) for _ in range(150): allocations = _random_instance(rng) - antichain = get_pressure(allocations, work_budget=None) - closure = get_closure_pressure(allocations) + antichain = pressure(allocations, work_budget=None) + closure = closure_pressure(allocations) assert closure == _brute_closure(allocations) assert closure <= antichain - assert get_pressure(allocations) == antichain + assert pressure(allocations) == antichain def _brute_pinned_antichain( @@ -398,7 +395,7 @@ def _brute_pinned_antichain( for count in range(1, len(others) + 1): for combo in combinations(others, count): group = (pin, *combo) - if all(a.overlaps_temporally(b) for a, b in combinations(group, 2)): + if all(a.conflicts_with(b) for a, b in combinations(group, 2)): best = max(best, sum(a.size for a in group)) peaks[pin.id] = best return peaks @@ -430,8 +427,8 @@ def test_per_allocation_pressures_match_brute_force() -> None: rng = Random(13) for _ in range(60): allocations = _random_instance(rng) - pinned = get_per_allocation_pressure(allocations) - closure = get_per_allocation_closure_pressure(allocations) + pinned = pressure_per_allocation(allocations) + closure = closure_pressure_per_allocation(allocations) assert pinned == _brute_pinned_antichain(allocations) assert closure == _brute_pinned_closure(allocations) @@ -441,14 +438,13 @@ def test_per_allocation_bound_order_and_peak_identities() -> None: allocator = OmniAllocator() for _ in range(40): allocations = _random_instance(rng) - pinned = get_per_allocation_pressure(allocations) - closure = get_per_allocation_closure_pressure(allocations) + pinned = pressure_per_allocation(allocations) + closure = closure_pressure_per_allocation(allocations) placed = allocator.allocate(allocations) - placement = get_per_allocation_placement_pressure(placed) - capped = get_per_allocation_placement_pressure(placed, clique_cap=True) - assert max(pinned.values()) == get_pressure(allocations) - assert max(closure.values()) == get_closure_pressure(allocations) - assert max(placement.values()) == get_placement_pressure(placed) + placement = placement_pressure_per_allocation(placed) + assert max(pinned.values()) == pressure(allocations) + assert max(closure.values()) == closure_pressure(allocations) + assert max(placement.values()) == placement_pressure(placed) for alloc_id in pinned: assert closure[alloc_id] <= pinned[alloc_id] - assert pinned[alloc_id] <= capped[alloc_id] <= placement[alloc_id] + assert pinned[alloc_id] <= placement[alloc_id] diff --git a/tests/unit/analysis/test_vector_conflict_properties.py b/tests/unit/analysis/test_vector_conflict_properties.py index d213ea0..828cde5 100644 --- a/tests/unit/analysis/test_vector_conflict_properties.py +++ b/tests/unit/analysis/test_vector_conflict_properties.py @@ -88,32 +88,32 @@ def test_pairwise_overlap_matches_oracle(dim: int) -> None: a = make_allocation(0, start_a, end_a) b = make_allocation(1, start_b, end_b) expected = oracle_conflict(start_a, end_a, start_b, end_b) - assert a.overlaps_temporally(b) == expected - assert b.overlaps_temporally(a) == expected + assert a.conflicts_with(b) == expected + assert b.conflicts_with(a) == expected def test_equal_end_start_boundary_is_safe() -> None: a = make_allocation(0, (2, 3), (5, 7)) b = make_allocation(1, (5, 7), (6, 8)) - assert not a.overlaps_temporally(b) + assert not a.conflicts_with(b) def test_single_earlier_component_conflicts() -> None: a = make_allocation(0, (2, 3), (5, 7)) b = make_allocation(1, (5, 6), (6, 8)) - assert a.overlaps_temporally(b) + assert a.conflicts_with(b) def test_incomparable_clocks_conflict() -> None: a = make_allocation(0, (2, 3), (5, 7)) b = make_allocation(1, (4, 8), (9, 9)) - assert a.overlaps_temporally(b) + assert a.conflicts_with(b) def test_dominated_lifetime_is_safe() -> None: a = make_allocation(0, (2, 3), (5, 7)) b = make_allocation(1, (6, 7), (7, 8)) - assert not a.overlaps_temporally(b) + assert not a.conflicts_with(b) @pytest.mark.parametrize("hi", [3, 6, 20]) @@ -126,7 +126,7 @@ def test_conflict_graph_matches_oracle(hi: int) -> None: allocations = [ make_allocation(i, start, end) for i, (start, end) in enumerate(lifetimes) ] - graph = _cpp.compute_temporal_overlaps(allocations, None) + graph = _cpp.conflicts(allocations, None) for i, j in itertools.combinations(range(count), 2): expected = oracle_conflict(*lifetimes[i], *lifetimes[j]) assert (j in graph.get(i, set())) == expected @@ -136,15 +136,14 @@ def test_conflict_graph_matches_oracle(hi: int) -> None: def test_same_offset_reuse_across_happens_before_passes() -> None: first = make_allocation(0, (0, 0), (2, 1), offset=0) second = make_allocation(1, (2, 1), (3, 3), offset=0) - assert validate_allocation(Pool(id=0, allocations=(first, second))) + validate_allocation(Pool(id=0, allocations=(first, second))) def test_one_flipped_component_fails_validation() -> None: first = make_allocation(0, (0, 0), (2, 1), offset=0) second = make_allocation(1, (2, 0), (3, 3), offset=0) - assert not validate_allocation( - Pool(id=0, allocations=(first, second)), raise_on_error=False - ) + with pytest.raises(ValueError, match="overlaps"): + validate_allocation(Pool(id=0, allocations=(first, second))) @pytest.mark.parametrize("pattern", sorted(SYNC_PATTERNS)) @@ -154,19 +153,19 @@ def test_validator_catches_corrupted_placements(pattern: str) -> None: num_allocations=200, num_threads=6, pattern=pattern, seed=13 ).get_allocations() placed = list(OmniAllocator().allocate(allocations)) - assert validate_allocation(Pool(id=0, allocations=tuple(placed))) - conflicts = _cpp.compute_temporal_overlaps(placed, None) + validate_allocation(Pool(id=0, allocations=tuple(placed))) + conflict_map = _cpp.conflicts(placed, None) index_by_id = {p.id: k for k, p in enumerate(placed)} + conflicting = sorted(a for a, neighbors in conflict_map.items() if neighbors) for _ in range(5): - i = rng.choice(sorted(conflicts)) - j = rng.choice(sorted(conflicts[i])) + i = rng.choice(conflicting) + j = rng.choice(sorted(conflict_map[i])) corrupted = list(placed) corrupted[index_by_id[j]] = placed[index_by_id[j]].with_offset( placed[index_by_id[i]].offset ) - assert not validate_allocation( - Pool(id=0, allocations=tuple(corrupted)), raise_on_error=False - ) + with pytest.raises(ValueError, match="overlaps"): + validate_allocation(Pool(id=0, allocations=tuple(corrupted))) def test_vector_clock_order_equals_causal_reachability() -> None: @@ -206,7 +205,7 @@ def test_overlap_equals_possible_co_liveness_in_executions() -> None: causally_ordered = ( free_a in ancestors[alloc_b] or free_b in ancestors[alloc_a] ) - assert a.overlaps_temporally(b) == (not causally_ordered) + assert a.conflicts_with(b) == (not causally_ordered) if not causally_ordered: down_closure = ancestors[alloc_a] | ancestors[alloc_b] assert free_a not in down_closure diff --git a/tests/unit/benchmark/converters/test_model.py b/tests/unit/benchmark/converters/test_model.py index 1bc2a44..fa96441 100644 --- a/tests/unit/benchmark/converters/test_model.py +++ b/tests/unit/benchmark/converters/test_model.py @@ -14,76 +14,82 @@ model_to_pools, model_to_system, ) -from omnimalloc.primitives import BufferKind +from omnimalloc.primitives import AllocationKind # Buffer tests def test_buffer_basic_creation_int_id() -> None: buffer = Buffer( - id=0, shape=(10, 20), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10, 20), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) assert buffer.id == 0 assert buffer.shape == (10, 20) assert buffer.dtype == np.dtype(np.float32) - assert buffer.kind == BufferKind.WORKSPACE + assert buffer.kind == AllocationKind.WORKSPACE def test_buffer_basic_creation_str_id() -> None: buffer = Buffer( - id="buf_0", shape=(10,), dtype=np.dtype(np.int8), kind=BufferKind.CONSTANT + id="buf_0", shape=(10,), dtype=np.dtype(np.int8), kind=AllocationKind.CONSTANT ) assert buffer.id == "buf_0" assert buffer.shape == (10,) assert buffer.dtype == np.dtype(np.int8) - assert buffer.kind == BufferKind.CONSTANT + assert buffer.kind == AllocationKind.CONSTANT def test_buffer_ndim_1d() -> None: buffer = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) assert buffer.ndim == 1 def test_buffer_ndim_2d() -> None: buffer = Buffer( - id=0, shape=(10, 20), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10, 20), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) assert buffer.ndim == 2 def test_buffer_ndim_4d() -> None: buffer = Buffer( - id=0, shape=(1, 3, 224, 224), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=0, + shape=(1, 3, 224, 224), + dtype=np.dtype(np.float32), + kind=AllocationKind.INPUT, ) assert buffer.ndim == 4 def test_buffer_size_float32() -> None: buffer = Buffer( - id=0, shape=(10, 20), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10, 20), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) assert buffer.size == 10 * 20 * 4 def test_buffer_size_int8() -> None: buffer = Buffer( - id=0, shape=(100,), dtype=np.dtype(np.int8), kind=BufferKind.WORKSPACE + id=0, shape=(100,), dtype=np.dtype(np.int8), kind=AllocationKind.WORKSPACE ) assert buffer.size == 100 def test_buffer_size_float64() -> None: buffer = Buffer( - id=0, shape=(5, 5), dtype=np.dtype(np.float64), kind=BufferKind.WORKSPACE + id=0, shape=(5, 5), dtype=np.dtype(np.float64), kind=AllocationKind.WORKSPACE ) assert buffer.size == 5 * 5 * 8 def test_buffer_size_complex_shape() -> None: buffer = Buffer( - id=0, shape=(2, 3, 4, 5), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, + shape=(2, 3, 4, 5), + dtype=np.dtype(np.float32), + kind=AllocationKind.WORKSPACE, ) assert buffer.size == 2 * 3 * 4 * 5 * 4 @@ -91,14 +97,20 @@ def test_buffer_size_complex_shape() -> None: def test_buffer_invalid_shape_zero() -> None: with pytest.raises(ValueError, match="shape dimensions must be positive integers"): Buffer( - id=0, shape=(10, 0), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, + shape=(10, 0), + dtype=np.dtype(np.float32), + kind=AllocationKind.WORKSPACE, ) def test_buffer_invalid_shape_negative() -> None: with pytest.raises(ValueError, match="shape dimensions must be positive integers"): Buffer( - id=0, shape=(10, -5), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, + shape=(10, -5), + dtype=np.dtype(np.float32), + kind=AllocationKind.WORKSPACE, ) @@ -108,12 +120,12 @@ def test_buffer_invalid_shape_float() -> None: id=0, shape=(10.5, 20), # type: ignore[arg-type] dtype=np.dtype(np.float32), - kind=BufferKind.WORKSPACE, + kind=AllocationKind.WORKSPACE, ) def test_buffer_various_kinds() -> None: - for kind in BufferKind: + for kind in AllocationKind: buffer = Buffer(id=0, shape=(10,), dtype=np.dtype(np.float32), kind=kind) assert buffer.kind == kind @@ -138,10 +150,10 @@ def test_op_basic_creation_str_id() -> None: def test_op_with_inputs_and_outputs() -> None: buf_in = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) buf_out = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) op = Op(id=0, inputs={buf_in}, outputs={buf_out}) assert buf_in in op.inputs @@ -160,7 +172,7 @@ def test_op_invalid_id_type() -> None: def test_op_duplicate_buffer_ids_input_output() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) with pytest.raises(ValueError, match="buffer ids must be unique"): Op(id=0, inputs={buf}, outputs={buf}) @@ -168,10 +180,10 @@ def test_op_duplicate_buffer_ids_input_output() -> None: def test_op_duplicate_buffer_ids_multiple_inputs() -> None: buf1 = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf2 = Buffer( - id=0, shape=(20,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(20,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) with pytest.raises(ValueError, match="buffer ids must be unique"): Op(id=0, inputs={buf1, buf2}) @@ -179,10 +191,10 @@ def test_op_duplicate_buffer_ids_multiple_inputs() -> None: def test_op_unique_buffer_ids_different_buffers() -> None: buf1 = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf2 = Buffer( - id=1, shape=(20,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=1, shape=(20,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op = Op(id=0, inputs={buf1}, outputs={buf2}) assert len(op.inputs) == 1 @@ -191,16 +203,16 @@ def test_op_unique_buffer_ids_different_buffers() -> None: def test_op_multiple_inputs_outputs() -> None: buf_in1 = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) buf_in2 = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) buf_out1 = Buffer( - id=2, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT + id=2, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) buf_out2 = Buffer( - id=3, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT + id=3, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) op = Op(id=0, inputs={buf_in1, buf_in2}, outputs={buf_out1, buf_out2}) assert len(op.inputs) == 2 @@ -224,7 +236,7 @@ def test_model_basic_creation_str_id() -> None: def test_model_with_ops_and_buffers() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op = Op(id=0, outputs={buf}) model = Model(id=0, ops={0: op}, buffers={0: buf}) @@ -246,10 +258,10 @@ def test_model_duplicate_op_ids() -> None: def test_model_duplicate_buffer_ids() -> None: buf1 = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf2 = Buffer( - id=0, shape=(20,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(20,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) with pytest.raises(ValueError, match="buffer ids must be unique"): Model(id=0, buffers={0: buf1, 1: buf2}) @@ -264,10 +276,10 @@ def test_model_unique_op_ids() -> None: def test_model_unique_buffer_ids() -> None: buf1 = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf2 = Buffer( - id=1, shape=(20,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=1, shape=(20,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) model = Model(id=0, buffers={0: buf1, 1: buf2}) assert len(model.buffers) == 2 @@ -278,10 +290,10 @@ def test_model_unique_buffer_ids() -> None: def test_compute_buffer_lifetimes_basic() -> None: buf1 = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf2 = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op1 = Op(id=0, outputs={buf1}) op2 = Op(id=1, inputs={buf1}, outputs={buf2}) @@ -298,10 +310,10 @@ def test_compute_buffer_lifetimes_basic() -> None: def test_compute_buffer_lifetimes_const_inf_lifetime() -> None: buf_const = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) buf_work = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op1 = Op(id=0, outputs={buf_const}) op2 = Op(id=1, inputs={buf_const}, outputs={buf_work}) @@ -318,10 +330,10 @@ def test_compute_buffer_lifetimes_const_inf_lifetime() -> None: def test_compute_buffer_lifetimes_io_inf_lifetime() -> None: buf_input = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) buf_output = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) op1 = Op(id=0, inputs={buf_input}, outputs={buf_output}) model = Model(id=0, ops={0: op1}, buffers={0: buf_input, 1: buf_output}) @@ -337,7 +349,7 @@ def test_compute_buffer_lifetimes_io_inf_lifetime() -> None: def test_compute_buffer_lifetimes_multiple_uses() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op1 = Op(id=0, outputs={buf}) op2 = Op(id=1, inputs={buf}) @@ -353,16 +365,16 @@ def test_compute_buffer_lifetimes_multiple_uses() -> None: def test_compute_buffer_lifetimes_all_kinds() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) buf_input = Buffer( - id=2, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=2, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) buf_output = Buffer( - id=3, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT + id=3, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) op = Op(id=0, inputs={buf_input, buf_const}, outputs={buf_work, buf_output}) model = Model( @@ -389,7 +401,7 @@ def test_compute_buffer_lifetimes_all_kinds() -> None: def test_create_allocations_basic() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op = Op(id=0, outputs={buf}) model = Model(id=0, ops={0: op}, buffers={0: buf}) @@ -408,15 +420,15 @@ def test_create_allocations_basic() -> None: assert allocations[0].size == 40 assert allocations[0].start == 0 assert allocations[0].end == 1 - assert allocations[0].kind == BufferKind.WORKSPACE + assert allocations[0].kind == AllocationKind.WORKSPACE def test_create_allocations_exclude_const() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) model = Model(id=0, buffers={0: buf_work, 1: buf_const}) first_index = {buf_work: 0, buf_const: 0} @@ -430,18 +442,18 @@ def test_create_allocations_exclude_const() -> None: buffer_to_last_index=last_index, ) assert len(allocations) == 1 - assert allocations[0].kind == BufferKind.WORKSPACE + assert allocations[0].kind == AllocationKind.WORKSPACE def test_create_allocations_exclude_io() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_input = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) buf_output = Buffer( - id=2, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT + id=2, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) model = Model(id=0, buffers={0: buf_work, 1: buf_input, 2: buf_output}) first_index = {buf_work: 0, buf_input: 0, buf_output: 0} @@ -455,21 +467,21 @@ def test_create_allocations_exclude_io() -> None: buffer_to_last_index=last_index, ) assert len(allocations) == 1 - assert allocations[0].kind == BufferKind.WORKSPACE + assert allocations[0].kind == AllocationKind.WORKSPACE def test_create_allocations_include_all() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) buf_input = Buffer( - id=2, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=2, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) buf_output = Buffer( - id=3, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT + id=3, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) model = Model( id=0, buffers={0: buf_work, 1: buf_const, 2: buf_input, 3: buf_output} @@ -489,10 +501,10 @@ def test_create_allocations_include_all() -> None: def test_create_allocations_exclude_all() -> None: buf_const = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) buf_input = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) model = Model(id=0, buffers={0: buf_const, 1: buf_input}) first_index = {buf_const: 0, buf_input: 0} @@ -510,7 +522,7 @@ def test_create_allocations_exclude_all() -> None: def test_create_allocations_end_index_increment() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) model = Model(id=0, buffers={0: buf}) first_index = {buf: 5} @@ -532,7 +544,7 @@ def test_create_allocations_end_index_increment() -> None: def test_model_to_allocations_basic() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op = Op(id=0, outputs={buf}) model = Model(id=0, ops={0: op}, buffers={0: buf}) @@ -544,25 +556,25 @@ def test_model_to_allocations_basic() -> None: def test_model_to_allocations_exclude_const() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) op = Op(id=0, inputs={buf_const}, outputs={buf_work}) model = Model(id=0, ops={0: op}, buffers={0: buf_work, 1: buf_const}) allocations = model_to_allocations(model, include_const=False) assert len(allocations) == 1 - assert all(a.kind != BufferKind.CONSTANT for a in allocations) + assert all(a.kind != AllocationKind.CONSTANT for a in allocations) def test_model_to_allocations_include_const() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) op = Op(id=0, inputs={buf_const}, outputs={buf_work}) model = Model(id=0, ops={0: op}, buffers={0: buf_work, 1: buf_const}) @@ -573,10 +585,10 @@ def test_model_to_allocations_include_const() -> None: def test_model_to_allocations_exclude_io() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_input = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) op = Op(id=0, inputs={buf_input}, outputs={buf_work}) model = Model(id=0, ops={0: op}, buffers={0: buf_work, 1: buf_input}) @@ -588,10 +600,10 @@ def test_model_to_allocations_exclude_io() -> None: def test_model_to_allocations_include_io() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_input = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) op = Op(id=0, inputs={buf_input}, outputs={buf_work}) model = Model(id=0, ops={0: op}, buffers={0: buf_work, 1: buf_input}) @@ -602,7 +614,7 @@ def test_model_to_allocations_include_io() -> None: def test_model_to_allocations_const_inf_lifetime() -> None: buf_const = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) op1 = Op(id=0, outputs={buf_const}) op2 = Op(id=1) @@ -619,7 +631,7 @@ def test_model_to_allocations_const_inf_lifetime() -> None: def test_model_to_allocations_io_inf_lifetime() -> None: buf_input = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) op1 = Op(id=0, inputs={buf_input}) op2 = Op(id=1) @@ -632,14 +644,18 @@ def test_model_to_allocations_io_inf_lifetime() -> None: def test_model_to_allocations_complex_model() -> None: - buf1 = Buffer(id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT) + buf1 = Buffer( + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT + ) buf2 = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf3 = Buffer( - id=2, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=2, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE + ) + buf4 = Buffer( + id=3, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) - buf4 = Buffer(id=3, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT) op1 = Op(id=0, inputs={buf1}, outputs={buf2}) op2 = Op(id=1, inputs={buf2}, outputs={buf3}) op3 = Op(id=2, inputs={buf3}, outputs={buf4}) @@ -658,7 +674,7 @@ def test_model_to_allocations_complex_model() -> None: def test_model_to_pools_basic() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op = Op(id=0, outputs={buf}) model = Model(id=0, ops={0: op}, buffers={0: buf}) @@ -670,13 +686,13 @@ def test_model_to_pools_basic() -> None: def test_model_to_pools_grouped_by_kind() -> None: buf_work1 = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_work2 = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=2, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=2, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) op = Op(id=0, inputs={buf_const}, outputs={buf_work1, buf_work2}) model = Model(id=0, ops={0: op}, buffers={0: buf_work1, 1: buf_work2, 2: buf_const}) @@ -690,16 +706,16 @@ def test_model_to_pools_grouped_by_kind() -> None: def test_model_to_pools_all_kinds() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) buf_input = Buffer( - id=2, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=2, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) buf_output = Buffer( - id=3, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT + id=3, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) op = Op(id=0, inputs={buf_input, buf_const}, outputs={buf_work, buf_output}) model = Model( @@ -719,10 +735,10 @@ def test_model_to_pools_all_kinds() -> None: def test_model_to_pools_include_const_true() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) op = Op(id=0, inputs={buf_const}, outputs={buf_work}) model = Model(id=0, ops={0: op}, buffers={0: buf_work, 1: buf_const}) @@ -734,10 +750,10 @@ def test_model_to_pools_include_const_true() -> None: def test_model_to_pools_include_const_false() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) op = Op(id=0, inputs={buf_const}, outputs={buf_work}) model = Model(id=0, ops={0: op}, buffers={0: buf_work, 1: buf_const}) @@ -749,10 +765,10 @@ def test_model_to_pools_include_const_false() -> None: def test_model_to_pools_include_io_true() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_input = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) op = Op(id=0, inputs={buf_input}, outputs={buf_work}) model = Model(id=0, ops={0: op}, buffers={0: buf_work, 1: buf_input}) @@ -764,10 +780,10 @@ def test_model_to_pools_include_io_true() -> None: def test_model_to_pools_include_io_false() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_input = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) op = Op(id=0, inputs={buf_input}, outputs={buf_work}) model = Model(id=0, ops={0: op}, buffers={0: buf_work, 1: buf_input}) @@ -779,13 +795,13 @@ def test_model_to_pools_include_io_false() -> None: def test_model_to_pools_allocations_count_per_pool() -> None: buf_work1 = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_work2 = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_work3 = Buffer( - id=2, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=2, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op = Op(id=0, outputs={buf_work1, buf_work2, buf_work3}) model = Model(id=0, ops={0: op}, buffers={0: buf_work1, 1: buf_work2, 2: buf_work3}) @@ -800,7 +816,7 @@ def test_model_to_pools_allocations_count_per_pool() -> None: def test_model_to_system_basic() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op = Op(id=0, outputs={buf}) model = Model(id=0, ops={0: op}, buffers={0: buf}) @@ -812,7 +828,7 @@ def test_model_to_system_basic() -> None: def test_model_to_system_model_id_preserved() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op = Op(id=0, outputs={buf}) model = Model(id="resnet50", ops={0: op}, buffers={0: buf}) @@ -823,7 +839,7 @@ def test_model_to_system_model_id_preserved() -> None: def test_model_to_system_memory_structure() -> None: buf = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) op = Op(id=0, outputs={buf}) model = Model(id=0, ops={0: op}, buffers={0: buf}) @@ -836,16 +852,16 @@ def test_model_to_system_memory_structure() -> None: def test_model_to_system_pools_included() -> None: buf_work = Buffer( - id=0, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id=0, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.WORKSPACE ) buf_const = Buffer( - id=1, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.CONSTANT + id=1, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.CONSTANT ) buf_input = Buffer( - id=2, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id=2, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) buf_output = Buffer( - id=3, shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.OUTPUT + id=3, shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.OUTPUT ) op = Op(id=0, inputs={buf_input, buf_const}, outputs={buf_work, buf_output}) model = Model( @@ -873,10 +889,13 @@ def test_model_to_system_empty_model() -> None: def test_model_to_pools_skips_unreferenced_buffers() -> None: used = Buffer( - id="used", shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id="used", + shape=(10,), + dtype=np.dtype(np.float32), + kind=AllocationKind.WORKSPACE, ) unused = Buffer( - id="unused", shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id="unused", shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) op = Op(id=0, outputs={used}) model = Model(id=0, ops={0: op}, buffers={"used": used, "unused": unused}) @@ -889,10 +908,16 @@ def test_model_to_pools_skips_unreferenced_buffers() -> None: def test_model_to_allocations_skips_unreferenced_buffers() -> None: used = Buffer( - id="used", shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id="used", + shape=(10,), + dtype=np.dtype(np.float32), + kind=AllocationKind.WORKSPACE, ) unused = Buffer( - id="unused", shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.WORKSPACE + id="unused", + shape=(10,), + dtype=np.dtype(np.float32), + kind=AllocationKind.WORKSPACE, ) op = Op(id=0, outputs={used}) model = Model(id=0, ops={0: op}, buffers={"used": used, "unused": unused}) @@ -904,7 +929,7 @@ def test_model_to_allocations_skips_unreferenced_buffers() -> None: def test_model_to_allocations_handles_model_without_ops() -> None: buf = Buffer( - id="io", shape=(10,), dtype=np.dtype(np.float32), kind=BufferKind.INPUT + id="io", shape=(10,), dtype=np.dtype(np.float32), kind=AllocationKind.INPUT ) model = Model(id=0, buffers={"io": buf}) diff --git a/tests/unit/benchmark/converters/test_onnx.py b/tests/unit/benchmark/converters/test_onnx.py index c3b53cf..07667dd 100644 --- a/tests/unit/benchmark/converters/test_onnx.py +++ b/tests/unit/benchmark/converters/test_onnx.py @@ -17,7 +17,7 @@ _value_info_to_buffer, from_onnx, ) - from omnimalloc.primitives import BufferKind + from omnimalloc.primitives import AllocationKind from onnx import TensorProto, helper pytestmark = pytest.mark.skipif(not HAS_ONNX, reason="onnx not installed") @@ -84,7 +84,7 @@ def test_tensor_proto_to_buffer() -> None: assert buffer.id == "test_tensor" assert buffer.shape == (2, 3, 4) assert buffer.dtype == np.float32 - assert buffer.kind == BufferKind.CONSTANT + assert buffer.kind == AllocationKind.CONSTANT def test_tensor_proto_to_buffer_different_dtype() -> None: @@ -102,37 +102,37 @@ def test_tensor_proto_to_buffer_different_dtype() -> None: assert buffer.id == "int_tensor" assert buffer.shape == (3, 5) assert buffer.dtype == np.int64 - assert buffer.kind == BufferKind.CONSTANT + assert buffer.kind == AllocationKind.CONSTANT def test_value_info_to_buffer() -> None: """Test converting ONNX ValueInfoProto to Buffer.""" value_info = helper.make_tensor_value_info("test_value", TensorProto.INT32, [5, 10]) - buffer = _value_info_to_buffer(value_info, BufferKind.WORKSPACE) + buffer = _value_info_to_buffer(value_info, AllocationKind.WORKSPACE) assert buffer.id == "test_value" assert buffer.shape == (5, 10) assert buffer.dtype == np.int32 - assert buffer.kind == BufferKind.WORKSPACE + assert buffer.kind == AllocationKind.WORKSPACE def test_value_info_to_buffer_input_kind() -> None: """Test converting ONNX ValueInfoProto with INPUT kind.""" value_info = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 3, 224]) - buffer = _value_info_to_buffer(value_info, BufferKind.INPUT) + buffer = _value_info_to_buffer(value_info, AllocationKind.INPUT) - assert buffer.kind == BufferKind.INPUT + assert buffer.kind == AllocationKind.INPUT def test_value_info_to_buffer_output_kind() -> None: """Test converting ONNX ValueInfoProto with OUTPUT kind.""" value_info = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 1000]) - buffer = _value_info_to_buffer(value_info, BufferKind.OUTPUT) + buffer = _value_info_to_buffer(value_info, AllocationKind.OUTPUT) - assert buffer.kind == BufferKind.OUTPUT + assert buffer.kind == AllocationKind.OUTPUT def test_value_info_to_buffer_filters_zero_dims() -> None: @@ -141,7 +141,7 @@ def test_value_info_to_buffer_filters_zero_dims() -> None: "test_value", TensorProto.FLOAT, [3, 0, 5] ) - buffer = _value_info_to_buffer(value_info, BufferKind.WORKSPACE) + buffer = _value_info_to_buffer(value_info, AllocationKind.WORKSPACE) assert buffer.shape == (3, 5) @@ -157,10 +157,10 @@ def test_node_to_op(simple_onnx_model: "onnx.ModelProto") -> None: buf = _tensor_proto_to_buffer(init) buffers[buf.id] = buf for inp in graph.input: - buf = _value_info_to_buffer(inp, BufferKind.INPUT) + buf = _value_info_to_buffer(inp, AllocationKind.INPUT) buffers[buf.id] = buf for val in graph.value_info: - buf = _value_info_to_buffer(val, BufferKind.WORKSPACE) + buf = _value_info_to_buffer(val, AllocationKind.WORKSPACE) buffers[buf.id] = buf op = _node_to_op(node, buffers, node.name) @@ -238,19 +238,19 @@ def test_from_onnx_buffer_kinds(simple_onnx_model: "onnx.ModelProto") -> None: model = from_onnx(simple_onnx_model) input_buffer = model.buffers["input"] - assert input_buffer.kind == BufferKind.INPUT + assert input_buffer.kind == AllocationKind.INPUT output_buffer = model.buffers["output"] - assert output_buffer.kind == BufferKind.OUTPUT + assert output_buffer.kind == AllocationKind.OUTPUT weights_buffer = model.buffers["weights"] - assert weights_buffer.kind == BufferKind.CONSTANT + assert weights_buffer.kind == AllocationKind.CONSTANT bias_buffer = model.buffers["bias"] - assert bias_buffer.kind == BufferKind.CONSTANT + assert bias_buffer.kind == AllocationKind.CONSTANT intermediate_buffer = model.buffers["intermediate"] - assert intermediate_buffer.kind == BufferKind.WORKSPACE + assert intermediate_buffer.kind == AllocationKind.WORKSPACE def test_from_onnx_ops_reference_buffers(simple_onnx_model: "onnx.ModelProto") -> None: @@ -298,4 +298,4 @@ def test_from_onnx_skips_initializers_relisted_as_inputs( model = from_onnx(simple_onnx_model) - assert model.buffers["weights"].kind == BufferKind.CONSTANT + assert model.buffers["weights"].kind == AllocationKind.CONSTANT diff --git a/tests/unit/benchmark/results/test_campaign.py b/tests/unit/benchmark/results/test_campaign.py index 8f19742..613bc6a 100644 --- a/tests/unit/benchmark/results/test_campaign.py +++ b/tests/unit/benchmark/results/test_campaign.py @@ -4,7 +4,7 @@ import pytest -from omnimalloc import run_allocation +from omnimalloc import allocate from omnimalloc.allocators import GreedyAllocator from omnimalloc.benchmark.results import ( BenchmarkCampaign, @@ -18,7 +18,7 @@ def test_benchmark_campaign_creation() -> None: """Test basic benchmark campaign creation.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) result = BenchmarkResult( id=0, allocator=allocator, source=source, entity=pool, duration=0.5 @@ -40,7 +40,7 @@ def test_benchmark_campaign_duplicate_report_ids_raises_error() -> None: """Test that duplicate report IDs raise ValueError.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) result1 = BenchmarkResult( id=0, allocator=allocator, source=source, entity=pool, duration=0.5 @@ -60,7 +60,7 @@ def test_benchmark_campaign_properties() -> None: """Test campaign properties.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) results = tuple( BenchmarkResult( @@ -82,7 +82,7 @@ def test_benchmark_campaign_finalize_metadata() -> None: """Test finalize_metadata method.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) result = BenchmarkResult( id=0, allocator=allocator, source=source, entity=pool, duration=0.5 diff --git a/tests/unit/benchmark/results/test_export.py b/tests/unit/benchmark/results/test_export.py index 77a6710..cb54d64 100644 --- a/tests/unit/benchmark/results/test_export.py +++ b/tests/unit/benchmark/results/test_export.py @@ -8,7 +8,7 @@ from zipfile import ZipFile import pytest -from omnimalloc import run_allocation +from omnimalloc import allocate from omnimalloc.allocators import GreedyAllocator from omnimalloc.benchmark.results import ( BenchmarkCampaign, @@ -28,7 +28,7 @@ def simple_campaign() -> BenchmarkCampaign: """Create a simple benchmark campaign for testing.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) result = BenchmarkResult( id=0, allocator=allocator, source=source, entity=pool, duration=0.5 @@ -99,7 +99,7 @@ def test_save_benchmark_raises_typeerror_for_non_campaign(artifacts_dir: Path) - """Test that save_benchmark raises TypeError for non-campaign objects.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) result = BenchmarkResult( id=0, allocator=allocator, source=source, entity=pool, duration=0.5 ) diff --git a/tests/unit/benchmark/results/test_report.py b/tests/unit/benchmark/results/test_report.py index f925bee..57f4091 100644 --- a/tests/unit/benchmark/results/test_report.py +++ b/tests/unit/benchmark/results/test_report.py @@ -4,7 +4,7 @@ import pytest -from omnimalloc import run_allocation +from omnimalloc import allocate from omnimalloc.allocators import GreedyAllocator, NaiveAllocator from omnimalloc.benchmark.results import BenchmarkReport, BenchmarkResult from omnimalloc.benchmark.sources.generator import RandomSource @@ -14,7 +14,7 @@ def test_benchmark_report_creation() -> None: """Test basic benchmark report creation.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) result = BenchmarkResult( id=0, allocator=allocator, source=source, entity=pool, duration=0.5 ) @@ -34,7 +34,7 @@ def test_benchmark_report_duplicate_ids_raises_error() -> None: """Test that duplicate result IDs raise ValueError.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) result1 = BenchmarkResult( id=0, allocator=allocator, source=source, entity=pool, duration=0.5 @@ -51,7 +51,7 @@ def test_benchmark_report_statistics() -> None: """Test report statistics with multiple results.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) results = tuple( BenchmarkResult( @@ -72,8 +72,8 @@ def test_benchmark_report_allocator_mismatch_raises_error() -> None: allocator1 = GreedyAllocator() allocator2 = NaiveAllocator() - pool1 = run_allocation(source.get_pool(), allocator1) - pool2 = run_allocation(source.get_pool(), allocator2) + pool1 = allocate(source.get_pool(), allocator1) + pool2 = allocate(source.get_pool(), allocator2) result1 = BenchmarkResult( id=0, allocator=allocator1, source=source, entity=pool1, duration=0.5 @@ -90,7 +90,7 @@ def test_benchmark_report_with_results() -> None: """Test with_results method.""" source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() - pool = run_allocation(source.get_pool(), allocator) + pool = allocate(source.get_pool(), allocator) result1 = BenchmarkResult( id=0, allocator=allocator, source=source, entity=pool, duration=0.5 diff --git a/tests/unit/benchmark/results/test_result.py b/tests/unit/benchmark/results/test_result.py index c42341a..5d226d4 100644 --- a/tests/unit/benchmark/results/test_result.py +++ b/tests/unit/benchmark/results/test_result.py @@ -8,7 +8,7 @@ from typing import Any import pytest -from omnimalloc import run_allocation +from omnimalloc import allocate from omnimalloc.allocators import GreedyAllocator from omnimalloc.benchmark.results.result import BenchmarkResult from omnimalloc.benchmark.sources.generator import RandomSource @@ -20,7 +20,7 @@ def allocated_pool() -> tuple[Any, GreedyAllocator, RandomSource]: source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() pool = source.get_pool() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) return allocated_pool, allocator, source @@ -228,6 +228,7 @@ def test_benchmark_result_frozen( result.duration = 1.0 # type: ignore[misc] +@pytest.mark.filterwarnings("ignore::UserWarning") def test_benchmark_result_visualize_no_file( allocated_pool: tuple[Any, GreedyAllocator, RandomSource], ) -> None: @@ -241,7 +242,7 @@ def test_benchmark_result_visualize_no_file( duration=0.5, ) # Should not raise an error - result.visualize(file_path=None, show_inline=False) + result.visualize() def test_benchmark_result_visualize_with_file( @@ -261,7 +262,7 @@ def test_benchmark_result_visualize_with_file( tmp_path = Path(tmp.name) try: - result.visualize(file_path=tmp_path, show_inline=False) + result.visualize(tmp_path) assert tmp_path.exists() finally: if tmp_path.exists(): @@ -274,8 +275,8 @@ def test_benchmark_result_different_num_allocations() -> None: source2 = RandomSource(num_allocations=15, seed=43) allocator = GreedyAllocator() - pool1 = run_allocation(source1.get_pool(), allocator) - pool2 = run_allocation(source2.get_pool(), allocator) + pool1 = allocate(source1.get_pool(), allocator) + pool2 = allocate(source2.get_pool(), allocator) result1 = BenchmarkResult( id=0, allocator=allocator, source=source1, entity=pool1, duration=0.5 diff --git a/tests/unit/benchmark/results/test_visualize.py b/tests/unit/benchmark/results/test_visualize.py index d32888f..2148a53 100644 --- a/tests/unit/benchmark/results/test_visualize.py +++ b/tests/unit/benchmark/results/test_visualize.py @@ -2,15 +2,18 @@ # SPDX-License-Identifier: Apache-2.0 # -from omnimalloc import run_allocation +import pytest +from omnimalloc import allocate from omnimalloc.allocators import GreedyAllocator from omnimalloc.benchmark.results.campaign import BenchmarkCampaign from omnimalloc.benchmark.results.report import BenchmarkReport from omnimalloc.benchmark.results.result import BenchmarkResult from omnimalloc.benchmark.results.visualize import ( + HAS_MATPLOTLIB, _canonicalize_artifact, _format_metadata, _get_allocator_color, + plot_benchmark, ) from omnimalloc.benchmark.sources.generator import RandomSource @@ -48,7 +51,7 @@ def test_canonicalize_artifact() -> None: source = RandomSource(num_allocations=10, seed=42) allocator = GreedyAllocator() pool = source.get_pool() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) result = BenchmarkResult( id=0, @@ -72,3 +75,24 @@ def test_canonicalize_artifact() -> None: campaign_from_campaign = _canonicalize_artifact(campaign) assert isinstance(campaign_from_campaign, BenchmarkCampaign) assert campaign_from_campaign is campaign + + +@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") +def test_plot_benchmark_without_path_shows_figure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import matplotlib.pyplot as plt + + source = RandomSource(num_allocations=10, seed=42) + allocator = GreedyAllocator() + result = BenchmarkResult( + id=0, + allocator=allocator, + source=source, + entity=allocate(source.get_pool(), allocator), + duration=0.5, + ) + shown = [] + monkeypatch.setattr(plt, "show", lambda: shown.append(True)) + plot_benchmark(result) + assert shown == [True] diff --git a/tests/unit/benchmark/sources/test_base.py b/tests/unit/benchmark/sources/test_base.py index 9fae3eb..b2d0a0c 100644 --- a/tests/unit/benchmark/sources/test_base.py +++ b/tests/unit/benchmark/sources/test_base.py @@ -145,7 +145,7 @@ def test_base_source_get_pool() -> None: source = RandomSource(num_allocations=5, seed=42) pool = source.get_pool() - assert pool.id == "random_source_pool_0" + assert pool.id == "random_pool_0" assert len(pool.allocations) == 5 @@ -156,9 +156,9 @@ def test_base_source_get_pools() -> None: assert len(pools) == 3 assert all(len(pool.allocations) == 5 for pool in pools) - assert pools[0].id == "random_source_pool_0" - assert pools[1].id == "random_source_pool_1" - assert pools[2].id == "random_source_pool_2" + assert pools[0].id == "random_pool_0" + assert pools[1].id == "random_pool_1" + assert pools[2].id == "random_pool_2" def test_base_source_get_memory() -> None: @@ -166,7 +166,7 @@ def test_base_source_get_memory() -> None: source = RandomSource(num_allocations=5, seed=42) memory = source.get_memory() - assert memory.id == "random_source_memory_0" + assert memory.id == "random_memory_0" assert len(memory.pools) == 1 @@ -176,8 +176,8 @@ def test_base_source_get_memories() -> None: memories = source.get_memories(num_memories=2) assert len(memories) == 2 - assert memories[0].id == "random_source_memory_0" - assert memories[1].id == "random_source_memory_1" + assert memories[0].id == "random_memory_0" + assert memories[1].id == "random_memory_1" def test_base_source_get_system() -> None: @@ -185,7 +185,7 @@ def test_base_source_get_system() -> None: source = RandomSource(num_allocations=5, seed=42) system = source.get_system() - assert system.id == "random_source_system_0" + assert system.id == "random_system_0" assert len(system.memories) == 1 @@ -195,8 +195,8 @@ def test_base_source_get_systems() -> None: systems = source.get_systems(num_systems=2) assert len(systems) == 2 - assert systems[0].id == "random_source_system_0" - assert systems[1].id == "random_source_system_1" + assert systems[0].id == "random_system_0" + assert systems[1].id == "random_system_1" def test_base_source_get_variant_with_int() -> None: diff --git a/tests/unit/benchmark/sources/test_concurrent_tiling.py b/tests/unit/benchmark/sources/test_concurrent_tiling.py index 1b9ff52..c8b68d6 100644 --- a/tests/unit/benchmark/sources/test_concurrent_tiling.py +++ b/tests/unit/benchmark/sources/test_concurrent_tiling.py @@ -3,8 +3,8 @@ # import pytest -from omnimalloc import run_allocation, validate_allocation -from omnimalloc.analysis.pressure import get_pressure +from omnimalloc import allocate, validate_allocation +from omnimalloc.analysis import pressure from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.primitives import Allocation @@ -17,8 +17,8 @@ def _signatures( def test_concurrent_tiling_is_registered() -> None: - assert "concurrent_tiling_source" in BaseSource.registry() - assert BaseSource.get("concurrent_tiling_source") is ConcurrentTilingSource + assert "concurrent_tiling" in BaseSource.registry() + assert BaseSource.get("concurrent_tiling") is ConcurrentTilingSource def test_concurrent_tiling_produces_requested_count_and_dim() -> None: @@ -64,7 +64,7 @@ def test_concurrent_tiling_pressure_bounded_by_capacity(num_syncs: int) -> None: num_allocations=64, num_threads=4, num_syncs=num_syncs, capacity=capacity ) allocations = source.get_allocations() - assert max(a.size for a in allocations) <= get_pressure(allocations) <= capacity + assert max(a.size for a in allocations) <= pressure(allocations) <= capacity def test_concurrent_tiling_ground_truth_is_valid_and_optimal() -> None: @@ -143,5 +143,5 @@ def test_concurrent_tiling_no_allocator_beats_the_optimum() -> None: num_allocations=96, num_threads=4, num_syncs=64, capacity=capacity ) pool = source.get_pool() - allocated = run_allocation(pool, "greedy_by_size_allocator_cpp", validate=True) + allocated = allocate(pool, "greedy_by_size", validate=True) assert allocated.size >= capacity diff --git a/tests/unit/benchmark/sources/test_generator.py b/tests/unit/benchmark/sources/test_generator.py index e7f6199..73c1c96 100644 --- a/tests/unit/benchmark/sources/test_generator.py +++ b/tests/unit/benchmark/sources/test_generator.py @@ -10,7 +10,7 @@ SequentialSource, UniformSource, ) -from omnimalloc.primitives import BufferKind +from omnimalloc.primitives import AllocationKind def test_random_source_basic_creation() -> None: @@ -69,21 +69,21 @@ def test_random_source_duration_bounds() -> None: assert all(5 <= alloc.duration <= 10 for alloc in allocations) -def test_random_source_buffer_kinds() -> None: - kinds = (BufferKind.WORKSPACE, BufferKind.CONSTANT) +def test_random_source_allocation_kinds() -> None: + kinds = (AllocationKind.WORKSPACE, AllocationKind.CONSTANT) source = RandomSource(num_allocations=50, kinds=kinds, seed=42) allocations = source.get_allocations() assert all(alloc.kind in kinds for alloc in allocations) -def test_random_source_buffer_kinds_with_weights() -> None: - kinds = (BufferKind.WORKSPACE, BufferKind.CONSTANT) +def test_random_source_allocation_kinds_with_weights() -> None: + kinds = (AllocationKind.WORKSPACE, AllocationKind.CONSTANT) weights = (0.8, 0.2) source = RandomSource( num_allocations=100, kinds=kinds, kind_weights=weights, seed=42 ) allocations = source.get_allocations() - workspace_count = sum(1 for a in allocations if a.kind == BufferKind.WORKSPACE) + workspace_count = sum(1 for a in allocations if a.kind == AllocationKind.WORKSPACE) assert workspace_count > 60 @@ -127,7 +127,7 @@ def test_random_source_validation_kind_weights() -> None: ValueError, match="kinds and kind_weights must have same length" ): RandomSource( - kinds=(BufferKind.WORKSPACE,), + kinds=(AllocationKind.WORKSPACE,), kind_weights=(0.5, 0.5), ) @@ -237,7 +237,7 @@ def test_high_contention_source_high_contention() -> None: overlaps = 0 for i, a1 in enumerate(allocations): for a2 in allocations[i + 1 :]: - if a1.overlaps_temporally(a2): + if a1.conflicts_with(a2): overlaps += 1 assert overlaps > 1000 @@ -279,7 +279,7 @@ def test_sequential_source_minimal_overlap() -> None: overlaps = 0 for i, a1 in enumerate(allocations): for a2 in allocations[i + 1 :]: - if a1.overlaps_temporally(a2): + if a1.conflicts_with(a2): overlaps += 1 assert overlaps < 200 diff --git a/tests/unit/benchmark/sources/test_huggingface.py b/tests/unit/benchmark/sources/test_huggingface.py index 403fd9b..3279261 100644 --- a/tests/unit/benchmark/sources/test_huggingface.py +++ b/tests/unit/benchmark/sources/test_huggingface.py @@ -85,12 +85,12 @@ def test_huggingface_source_caching(artifacts_dir: Path) -> None: assert allocations1 == allocations2 -def test_huggingface_source_buffer_kinds(artifacts_dir: Path) -> None: - """Test that allocations have appropriate buffer kinds.""" +def test_huggingface_source_allocation_kinds(artifacts_dir: Path) -> None: + """Test that allocations have appropriate allocation kinds.""" source = HuggingfaceSource(num_models=1, output_dir=str(artifacts_dir)) allocations = source.get_allocations() - # Should have various buffer kinds + # Should have various allocation kinds kinds = {alloc.kind for alloc in allocations if alloc.kind is not None} # At least workspace or input/output/constant should be present assert len(kinds) > 0 diff --git a/tests/unit/benchmark/sources/test_minimalloc.py b/tests/unit/benchmark/sources/test_minimalloc.py index dc5ffc9..7798f05 100644 --- a/tests/unit/benchmark/sources/test_minimalloc.py +++ b/tests/unit/benchmark/sources/test_minimalloc.py @@ -4,7 +4,6 @@ import pytest from omnimalloc.benchmark.sources.minimalloc import MinimallocSource, MinimallocSubset -from omnimalloc.primitives import BufferKind def test_minimalloc_source_default_subset_is_challenging() -> None: @@ -81,11 +80,11 @@ def test_minimalloc_source_get_pools_count_zero() -> None: assert len(pools) == 0 -def test_minimalloc_source_get_allocation_workspace_kind() -> None: - """All loaded allocations are tagged as WORKSPACE buffers.""" +def test_minimalloc_source_get_allocation_keeps_kind_none() -> None: + """The minimalloc format carries no kind, matching `load_allocation`.""" source = MinimallocSource(subset="examples") allocation = source.get_allocation() - assert allocation.kind == BufferKind.WORKSPACE + assert allocation.kind is None def test_minimalloc_source_get_variant_by_id() -> None: diff --git a/tests/unit/benchmark/sources/test_pinwheel.py b/tests/unit/benchmark/sources/test_pinwheel.py index f0cea1a..4342d00 100644 --- a/tests/unit/benchmark/sources/test_pinwheel.py +++ b/tests/unit/benchmark/sources/test_pinwheel.py @@ -3,8 +3,8 @@ # import pytest -from omnimalloc import run_allocation, validate_allocation -from omnimalloc.analysis.pressure import get_pressure +from omnimalloc import allocate, validate_allocation +from omnimalloc.analysis import pressure from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.pinwheel import PinwheelSource from omnimalloc.primitives import Allocation, Pool @@ -32,8 +32,8 @@ def _has_guillotine_cut(pool: Pool) -> bool: def test_pinwheel_source_is_registered() -> None: - assert "pinwheel_source" in BaseSource.registry() - assert BaseSource.get("pinwheel_source") is PinwheelSource + assert "pinwheel" in BaseSource.registry() + assert BaseSource.get("pinwheel") is PinwheelSource def test_pinwheel_count_rounds_up_to_pinwheel_size() -> None: @@ -47,7 +47,7 @@ def test_pinwheel_optimum_is_tight(num: int) -> None: capacity = 1024 * 1024 source = PinwheelSource(num_allocations=num, capacity=capacity) allocations = source.get_allocations() - assert get_pressure(allocations) == capacity + assert pressure(allocations) == capacity def test_pinwheel_allocations_fit_within_makespan() -> None: @@ -119,5 +119,5 @@ def test_pinwheel_no_allocator_beats_the_optimum() -> None: capacity = 1024 * 1024 source = PinwheelSource(num_allocations=150, capacity=capacity) pool = source.get_pool() - allocated = run_allocation(pool, "greedy_by_size_allocator_cpp", validate=True) + allocated = allocate(pool, "greedy_by_size", validate=True) assert allocated.size >= capacity diff --git a/tests/unit/benchmark/sources/test_sync_patterns.py b/tests/unit/benchmark/sources/test_sync_patterns.py index 84cc21b..bf76afb 100644 --- a/tests/unit/benchmark/sources/test_sync_patterns.py +++ b/tests/unit/benchmark/sources/test_sync_patterns.py @@ -3,7 +3,7 @@ # import pytest -from omnimalloc.analysis.pressure import get_pressure +from omnimalloc.analysis import pressure from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.primitives import Allocation @@ -16,8 +16,8 @@ def _signatures( def test_sync_patterns_is_registered() -> None: - assert "sync_pattern_source" in BaseSource.registry() - assert BaseSource.get("sync_pattern_source") is SyncPatternSource + assert "sync_pattern" in BaseSource.registry() + assert BaseSource.get("sync_pattern") is SyncPatternSource @pytest.mark.parametrize("pattern", SYNC_PATTERNS) @@ -114,6 +114,6 @@ def test_sync_patterns_pressure_is_bounded(pattern: str) -> None: num_allocations=10, num_threads=3, pattern=pattern, seed=5 ) allocations = source.get_allocations() - pressure = get_pressure(allocations) - assert pressure >= max(a.size for a in allocations) - assert pressure <= sum(a.size for a in allocations) + peak = pressure(allocations) + assert peak >= max(a.size for a in allocations) + assert peak <= sum(a.size for a in allocations) diff --git a/tests/unit/benchmark/sources/test_tiling.py b/tests/unit/benchmark/sources/test_tiling.py index fdc3212..7039ae8 100644 --- a/tests/unit/benchmark/sources/test_tiling.py +++ b/tests/unit/benchmark/sources/test_tiling.py @@ -3,8 +3,8 @@ # import pytest -from omnimalloc import run_allocation, validate_allocation -from omnimalloc.analysis.pressure import get_pressure +from omnimalloc import allocate, validate_allocation +from omnimalloc.analysis import pressure from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.tiling import TilingSource from omnimalloc.primitives import Allocation @@ -15,8 +15,8 @@ def _signatures(allocations: tuple[Allocation, ...]) -> list[tuple[int, int, int def test_tiling_source_is_registered() -> None: - assert "tiling_source" in BaseSource.registry() - assert BaseSource.get("tiling_source") is TilingSource + assert "tiling" in BaseSource.registry() + assert BaseSource.get("tiling") is TilingSource def test_tiling_source_produces_requested_count() -> None: @@ -30,7 +30,7 @@ def test_tiling_optimum_is_tight(num: int) -> None: capacity = 1024 * 1024 source = TilingSource(num_allocations=num, capacity=capacity) allocations = source.get_allocations() - assert get_pressure(allocations) == capacity + assert pressure(allocations) == capacity def test_tiling_allocations_fit_within_makespan() -> None: @@ -129,5 +129,5 @@ def test_tiling_no_allocator_beats_the_optimum() -> None: capacity = 1024 * 1024 source = TilingSource(num_allocations=150, capacity=capacity) pool = source.get_pool() - allocated = run_allocation(pool, "greedy_by_size_allocator_cpp", validate=True) + allocated = allocate(pool, "greedy_by_size", validate=True) assert allocated.size >= capacity diff --git a/tests/unit/benchmark/test_benchmark.py b/tests/unit/benchmark/test_benchmark.py index 0878a0a..e3589ee 100644 --- a/tests/unit/benchmark/test_benchmark.py +++ b/tests/unit/benchmark/test_benchmark.py @@ -82,7 +82,7 @@ def test_run_benchmark_per_source_variants() -> None: allocators=(allocator,), sources=(source,), iterations=1, - variants={"random_source": (5, 10)}, + variants={"random": (5, 10)}, ) assert campaign.num_reports == 2 @@ -115,7 +115,7 @@ def test_run_benchmark_skips_scalar_only_allocators_on_vector_source() -> None: ) assert campaign.num_reports == 1 - assert campaign.reports[0].allocator_name == "greedy_allocator" + assert campaign.reports[0].allocator_name == "greedy" def test_run_benchmark_skips_unsupported_variants() -> None: diff --git a/tests/unit/common/test_registry.py b/tests/unit/common/test_registry.py index 016d64a..66b1e7a 100644 --- a/tests/unit/common/test_registry.py +++ b/tests/unit/common/test_registry.py @@ -3,6 +3,7 @@ # from abc import abstractmethod +from typing import ClassVar import pytest from omnimalloc.allocators import ( @@ -20,7 +21,7 @@ class ExampleBase(Registered): - """Test base class.""" + """Test base class without a role token.""" class FooBar(ExampleBase): @@ -32,7 +33,17 @@ class BazQux(ExampleBase): class SimpleAllocator(ExampleBase): - """Should register as 'simple_allocator'.""" + """Keeps its full name: ExampleBase strips no role token.""" + + +class ExampleRoleBase(Registered): + """Test base class stripping the 'Widget' role token.""" + + _strip_suffix: ClassVar[str] = "Widget" + + +class SpinningWidget(ExampleRoleBase): + """Should register as 'spinning'.""" def test_registry_auto_registration() -> None: @@ -92,6 +103,32 @@ class Test123Thing(ExampleBase): assert Test123Thing.name() == "test123_thing" +def test_strip_suffix_stripped_once() -> None: + assert SpinningWidget.name() == "spinning" + assert ExampleRoleBase.registry()["spinning"] is SpinningWidget + + +def test_strip_suffix_absent_keeps_full_name() -> None: + class PlainThing(ExampleRoleBase): + pass + + assert PlainThing.name() == "plain_thing" + + +def test_strip_suffix_ignores_mid_name_token() -> None: + class WidgetFactoryWidget(ExampleRoleBase): + pass + + assert WidgetFactoryWidget.name() == "widget_factory" + + +def test_bare_strip_suffix_name_rejected() -> None: + with pytest.raises(RuntimeError, match="empty"): + + class Widget(ExampleRoleBase): + pass + + def test_abstract_intermediate_not_registered() -> None: class AbstractMid(ExampleBase): @abstractmethod @@ -108,37 +145,33 @@ def compute(self) -> int: def test_allocator_registry() -> None: registry = BaseAllocator.registry() - assert "greedy_allocator" in registry - assert registry["greedy_allocator"] is GreedyAllocator + assert "greedy" in registry + assert registry["greedy"] is GreedyAllocator def test_allocator_get() -> None: - cls = BaseAllocator.get("greedy_by_size_allocator") + cls = BaseAllocator.get("greedy_by_size") assert cls is GreedyBySizeAllocator -def test_allocator_name() -> None: - assert GreedyByAreaAllocator.name() == "greedy_by_area_allocator" +def test_allocator_name_drops_strip_suffix() -> None: + assert GreedyByAreaAllocator.name() == "greedy_by_area" def test_source_registry() -> None: registry = BaseSource.registry() - assert "random_source" in registry - assert registry["random_source"] is RandomSource + assert "random" in registry + assert registry["random"] is RandomSource def test_source_get() -> None: - cls = BaseSource.get("sequential_source") + cls = BaseSource.get("sequential") assert cls is SequentialSource -def test_source_name() -> None: - assert SequentialSource.name() == "sequential_source" - - -def test_source_includes_suffix() -> None: - assert RandomSource.name() == "random_source" - assert "source" in RandomSource.name() +def test_source_name_drops_strip_suffix() -> None: + assert SequentialSource.name() == "sequential" + assert RandomSource.name() == "random" def test_registry_rejects_duplicate_names() -> None: diff --git a/tests/unit/primitives/test_allocation.py b/tests/unit/primitives/test_allocation.py index 23446fe..0a0cf68 100644 --- a/tests/unit/primitives/test_allocation.py +++ b/tests/unit/primitives/test_allocation.py @@ -5,7 +5,7 @@ import pickle import pytest -from omnimalloc.primitives import Allocation, BufferKind +from omnimalloc.primitives import Allocation, AllocationKind def test_basic_creation_with_int_id_simple() -> None: @@ -53,9 +53,9 @@ def test_creation_with_kind() -> None: size=100, start=0, end=10, - kind=BufferKind.WORKSPACE, + kind=AllocationKind.WORKSPACE, ) - assert alloc.kind == BufferKind.WORKSPACE + assert alloc.kind == AllocationKind.WORKSPACE def test_negative_start() -> None: @@ -154,52 +154,52 @@ def test_area_different_values() -> None: assert alloc.area == 256 * 15 -def test_overlaps_temporally_partial_overlap() -> None: - """Test temporal overlap with partial overlap.""" +def test_conflicts_with_partial_overlap() -> None: + """Test conflict with partially overlapping lifetimes.""" alloc1 = Allocation(id=101, size=100, start=0, end=10) alloc2 = Allocation(id=102, size=100, start=5, end=15) - assert alloc1.overlaps_temporally(alloc2) - assert alloc2.overlaps_temporally(alloc1) + assert alloc1.conflicts_with(alloc2) + assert alloc2.conflicts_with(alloc1) -def test_overlaps_temporally_complete_overlap() -> None: - """Test temporal overlap when one contains the other.""" +def test_conflicts_with_contained_lifetime() -> None: + """Test conflict when one lifetime contains the other.""" alloc1 = Allocation(id=101, size=100, start=0, end=20) alloc2 = Allocation(id=102, size=100, start=5, end=15) - assert alloc1.overlaps_temporally(alloc2) - assert alloc2.overlaps_temporally(alloc1) + assert alloc1.conflicts_with(alloc2) + assert alloc2.conflicts_with(alloc1) -def test_overlaps_temporally_exact_match() -> None: - """Test temporal overlap with exact same time range.""" +def test_conflicts_with_exact_match() -> None: + """Test conflict with exact same time range.""" alloc1 = Allocation(id=101, size=100, start=5, end=15) alloc2 = Allocation(id=102, size=100, start=5, end=15) - assert alloc1.overlaps_temporally(alloc2) - assert alloc2.overlaps_temporally(alloc1) + assert alloc1.conflicts_with(alloc2) + assert alloc2.conflicts_with(alloc1) -def test_no_temporal_overlap_adjacent() -> None: - """Test no temporal overlap when adjacent.""" +def test_no_conflict_when_adjacent() -> None: + """Test no conflict when lifetimes are adjacent.""" alloc1 = Allocation(id=101, size=100, start=0, end=10) alloc2 = Allocation(id=102, size=100, start=10, end=20) - assert not alloc1.overlaps_temporally(alloc2) - assert not alloc2.overlaps_temporally(alloc1) + assert not alloc1.conflicts_with(alloc2) + assert not alloc2.conflicts_with(alloc1) -def test_no_temporal_overlap_separated() -> None: - """Test no temporal overlap when separated.""" +def test_no_conflict_when_separated() -> None: + """Test no conflict when lifetimes are separated.""" alloc1 = Allocation(id=101, size=100, start=0, end=5) alloc2 = Allocation(id=102, size=100, start=10, end=15) - assert not alloc1.overlaps_temporally(alloc2) - assert not alloc2.overlaps_temporally(alloc1) + assert not alloc1.conflicts_with(alloc2) + assert not alloc2.conflicts_with(alloc1) -def test_temporal_overlap_single_timestep() -> None: - """Test temporal overlap with single timestep overlap.""" +def test_conflicts_with_single_timestep() -> None: + """Test conflict with a single shared timestep.""" alloc1 = Allocation(id=101, size=100, start=0, end=10) alloc2 = Allocation(id=102, size=100, start=9, end=20) - assert alloc1.overlaps_temporally(alloc2) - assert alloc2.overlaps_temporally(alloc1) + assert alloc1.conflicts_with(alloc2) + assert alloc2.conflicts_with(alloc1) def test_overlaps_spatially_partial_overlap() -> None: @@ -355,10 +355,10 @@ def test_with_offset_preserves_kind() -> None: size=100, start=0, end=10, - kind=BufferKind.CONSTANT, + kind=AllocationKind.CONSTANT, ) new_alloc = alloc.with_offset(50) - assert new_alloc.kind == BufferKind.CONSTANT + assert new_alloc.kind == AllocationKind.CONSTANT def test_with_offset_immutability() -> None: @@ -408,7 +408,9 @@ def test_large_values() -> None: def test_pickle_roundtrip() -> None: """Test that pickling preserves all fields, equality, and hash.""" allocs = ( - Allocation(id="x", size=10, start=0, end=5, offset=3, kind=BufferKind.INPUT), + Allocation( + id="x", size=10, start=0, end=5, offset=3, kind=AllocationKind.INPUT + ), Allocation(id=7, size=10, start=0, end=5), ) for alloc in allocs: diff --git a/tests/unit/primitives/test_allocation_vector.py b/tests/unit/primitives/test_allocation_vector.py index 4181737..746733a 100644 --- a/tests/unit/primitives/test_allocation_vector.py +++ b/tests/unit/primitives/test_allocation_vector.py @@ -5,7 +5,7 @@ import pickle import pytest -from omnimalloc.analysis.pressure import get_pressure +from omnimalloc.analysis import pressure from omnimalloc.primitives import Allocation @@ -67,22 +67,22 @@ def test_scalar_duration_unchanged() -> None: def test_no_overlap_when_ordered() -> None: first = Allocation(id=1, size=1, start=(0, 0), end=(2, 1)) second = Allocation(id=2, size=1, start=(2, 1), end=(3, 2)) - assert not first.overlaps_temporally(second) - assert not second.overlaps_temporally(first) + assert not first.conflicts_with(second) + assert not second.conflicts_with(first) def test_overlap_when_concurrent() -> None: a = Allocation(id=1, size=1, start=(0, 0), end=(5, 1)) b = Allocation(id=2, size=1, start=(1, 0), end=(2, 3)) - assert a.overlaps_temporally(b) - assert b.overlaps_temporally(a) + assert a.conflicts_with(b) + assert b.conflicts_with(a) def test_overlap_dimension_mismatch_rejected() -> None: scalar = Allocation(id=1, size=1, start=0, end=1) vector = Allocation(id=2, size=1, start=(0, 0), end=(1, 1)) - with pytest.raises(ValueError, match="dimension mismatch"): - scalar.overlaps_temporally(vector) + with pytest.raises(ValueError, match="share one clock dimension"): + scalar.conflicts_with(vector) def test_overlaps_combines_temporal_and_spatial() -> None: @@ -117,14 +117,14 @@ def test_pickle_roundtrip_vector() -> None: def test_pressure_supports_vector_time() -> None: allocs = (Allocation(id=1, size=1, start=(0, 0), end=(1, 1)),) - assert get_pressure(allocs) == 1 + assert pressure(allocs) == 1 def test_conflict_without_per_thread_overlap() -> None: a = Allocation(id="a", size=1, start=(0, 5), end=(1, 6)) b = Allocation(id="b", size=1, start=(2, 0), end=(3, 1)) - assert a.overlaps_temporally(b) - assert b.overlaps_temporally(a) + assert a.conflicts_with(b) + assert b.conflicts_with(a) def test_before_relation_is_transitive_chain() -> None: @@ -134,5 +134,5 @@ def test_before_relation_is_transitive_chain() -> None: ) for i, earlier in enumerate(chain): for later in chain[i + 1 :]: - assert not earlier.overlaps_temporally(later) - assert not later.overlaps_temporally(earlier) + assert not earlier.conflicts_with(later) + assert not later.conflicts_with(earlier) diff --git a/tests/unit/primitives/test_allocationkind.py b/tests/unit/primitives/test_allocationkind.py new file mode 100644 index 0000000..0d7ee51 --- /dev/null +++ b/tests/unit/primitives/test_allocationkind.py @@ -0,0 +1,37 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import pytest +from omnimalloc.primitives import AllocationKind + +KIND_PROPERTIES = ( + (AllocationKind.WORKSPACE, 0, "workspace", False), + (AllocationKind.CONSTANT, 1, "constant", False), + (AllocationKind.INPUT, 2, "input", True), + (AllocationKind.OUTPUT, 3, "output", True), +) + + +@pytest.mark.parametrize(("kind", "value", "text", "is_io"), KIND_PROPERTIES) +def test_allocationkind_properties( + kind: AllocationKind, value: int, text: str, is_io: bool +) -> None: + assert kind.value == value + assert str(kind) == repr(kind) == text + assert kind.is_io is is_io + assert AllocationKind(value) is kind + assert AllocationKind[kind.name] is kind + + +def test_allocationkind_members_are_distinct_and_hashable() -> None: + kinds = list(AllocationKind) + assert len(kinds) == 4 + assert len(set(kinds)) == 4 + + +def test_allocationkind_invalid_lookups() -> None: + with pytest.raises(ValueError, match="4 is not a valid AllocationKind"): + AllocationKind(4) + with pytest.raises(KeyError): + AllocationKind["INVALID"] diff --git a/tests/unit/primitives/test_bufferkind.py b/tests/unit/primitives/test_bufferkind.py deleted file mode 100644 index 9a22602..0000000 --- a/tests/unit/primitives/test_bufferkind.py +++ /dev/null @@ -1,37 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -import pytest -from omnimalloc.primitives import BufferKind - -KIND_PROPERTIES = ( - (BufferKind.WORKSPACE, 0, "workspace", False), - (BufferKind.CONSTANT, 1, "constant", False), - (BufferKind.INPUT, 2, "input", True), - (BufferKind.OUTPUT, 3, "output", True), -) - - -@pytest.mark.parametrize(("kind", "value", "text", "is_io"), KIND_PROPERTIES) -def test_bufferkind_properties( - kind: BufferKind, value: int, text: str, is_io: bool -) -> None: - assert kind.value == value - assert str(kind) == repr(kind) == text - assert kind.is_io is is_io - assert BufferKind(value) is kind - assert BufferKind[kind.name] is kind - - -def test_bufferkind_members_are_distinct_and_hashable() -> None: - kinds = list(BufferKind) - assert len(kinds) == 4 - assert len(set(kinds)) == 4 - - -def test_bufferkind_invalid_lookups() -> None: - with pytest.raises(ValueError, match="4 is not a valid BufferKind"): - BufferKind(4) - with pytest.raises(KeyError): - BufferKind["INVALID"] diff --git a/tests/unit/primitives/test_memory.py b/tests/unit/primitives/test_memory.py index d9a73b5..35d392f 100644 --- a/tests/unit/primitives/test_memory.py +++ b/tests/unit/primitives/test_memory.py @@ -16,7 +16,7 @@ def test_basic_creation_with_int_id_simple() -> None: memory = Memory(id=301, pools=(pool,)) assert memory.id == 301 assert len(memory.pools) == 1 - assert memory.size is None + assert memory.capacity is None def test_basic_creation_with_int_id() -> None: @@ -34,15 +34,15 @@ def test_basic_creation_with_str_id() -> None: memory = Memory(id="mem_ddr", pools=(pool,)) assert memory.id == "mem_ddr" assert len(memory.pools) == 1 - assert memory.size is None + assert memory.capacity is None -def test_creation_with_size() -> None: - """Test creating a memory with specified size.""" +def test_creation_with_capacity() -> None: + """Test creating a memory with declared capacity.""" alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) pool = Pool(id=211, allocations=(alloc,)) - memory = Memory(id=301, pools=(pool,), size=1000) - assert memory.size == 1000 + memory = Memory(id=301, pools=(pool,), capacity=1000) + assert memory.capacity == 1000 def test_creation_with_multiple_pools() -> None: @@ -63,20 +63,20 @@ def test_empty_memory() -> None: assert memory.is_allocated is True -def test_negative_size() -> None: - """Test that negative size raises ValueError.""" +def test_negative_capacity() -> None: + """Test that negative capacity raises ValueError.""" alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) pool = Pool(id=211, allocations=(alloc,)) - with pytest.raises(ValueError, match="size must be non-negative"): - Memory(id=301, pools=(pool,), size=-1) + with pytest.raises(ValueError, match="capacity must be non-negative"): + Memory(id=301, pools=(pool,), capacity=-1) -def test_zero_size() -> None: - """Test that zero size is valid.""" +def test_zero_capacity() -> None: + """Test that zero capacity is valid.""" alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) pool = Pool(id=211, allocations=(alloc,)) - memory = Memory(id=301, pools=(pool,), size=0) - assert memory.size == 0 + memory = Memory(id=301, pools=(pool,), capacity=0) + assert memory.capacity == 0 def test_duplicate_pool_ids() -> None: @@ -113,70 +113,12 @@ def test_used_size_empty_memory() -> None: assert memory.used_size == 0 -def test_free_size_with_size() -> None: - """Test free_size calculation when size is set.""" +def test_used_size_can_exceed_capacity() -> None: + """Capacity is a declared limit, not a constructor constraint.""" alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) pool = Pool(id=211, allocations=(alloc,)) - memory = Memory(id=301, pools=(pool,), size=1000) - assert memory.free_size == 900 - - -def test_free_size_without_size() -> None: - """Test free_size returns None when size is not set.""" - alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) - pool = Pool(id=211, allocations=(alloc,)) - memory = Memory(id=301, pools=(pool,)) - assert memory.free_size is None - - -def test_free_size_zero() -> None: - """Test free_size when used_size equals size.""" - alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) - pool = Pool(id=211, allocations=(alloc,)) - memory = Memory(id=301, pools=(pool,), size=100) - assert memory.free_size == 0 - - -def test_free_size_empty_memory() -> None: - """Test free_size equals size for empty memory.""" - memory = Memory(id=1, pools=(), size=1000) - assert memory.free_size == 1000 - - -def test_utilization_with_size() -> None: - """Test utilization calculation when size is set.""" - alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) - pool = Pool(id=211, allocations=(alloc,)) - memory = Memory(id=301, pools=(pool,), size=1000) - assert memory.utilization == 0.1 - - -def test_utilization_without_size() -> None: - """Test utilization returns None when size is not set.""" - alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) - pool = Pool(id=211, allocations=(alloc,)) - memory = Memory(id=301, pools=(pool,)) - assert memory.utilization is None - - -def test_utilization_full() -> None: - """Test utilization when memory is fully used.""" - alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) - pool = Pool(id=211, allocations=(alloc,)) - memory = Memory(id=301, pools=(pool,), size=100) - assert memory.utilization == 1.0 - - -def test_utilization_empty() -> None: - """Test utilization when memory is empty.""" - memory = Memory(id=1, pools=(), size=1000) - assert memory.utilization == 0.0 - - -def test_utilization_zero_size() -> None: - """Test utilization returns zero for zero size.""" - memory = Memory(id=1, pools=(), size=0) - assert memory.utilization == 0.0 + memory = Memory(id=301, pools=(pool,), capacity=10) + assert memory.used_size == 100 def test_is_allocated_all_pools_allocated() -> None: @@ -221,12 +163,12 @@ def test_with_pools_replace() -> None: alloc2 = Allocation(id=102, size=200, start=0, end=10, offset=0) pool1 = Pool(id=211, allocations=(alloc1,)) pool2 = Pool(id=212, allocations=(alloc2,)) - memory = Memory(id=301, pools=(pool1,), size=1000) + memory = Memory(id=301, pools=(pool1,), capacity=1000) new_memory = memory.with_pools((pool2,)) assert len(new_memory.pools) == 1 assert new_memory.pools[0].id == 212 assert new_memory.id == memory.id - assert new_memory.size == memory.size + assert new_memory.capacity == memory.capacity def test_with_pools_immutability() -> None: @@ -269,11 +211,11 @@ def test_cannot_modify_pools() -> None: memory.pools = () # type: ignore[misc] -def test_cannot_modify_size() -> None: - """Test that size cannot be modified.""" - memory = Memory(id=301, pools=(), size=1000) +def test_cannot_modify_capacity() -> None: + """Test that capacity cannot be modified.""" + memory = Memory(id=301, pools=(), capacity=1000) with pytest.raises(AttributeError): - memory.size = 2000 # type: ignore[misc] + memory.capacity = 2000 # type: ignore[misc] def test_large_values() -> None: @@ -282,10 +224,9 @@ def test_large_values() -> None: alloc2 = Allocation(id=102, size=10**11, start=0, end=100, offset=0) pool1 = Pool(id=211, allocations=(alloc1,)) pool2 = Pool(id=212, allocations=(alloc2,)) - memory = Memory(id=999, pools=(pool1, pool2), size=10**15) + memory = Memory(id=999, pools=(pool1, pool2), capacity=10**15) assert memory.used_size == 10**12 + 10**11 - assert memory.free_size == 10**15 - (10**12 + 10**11) - assert memory.utilization == (10**12 + 10**11) / 10**15 + assert memory.capacity == 10**15 def test_complex_memory_structure() -> None: @@ -295,7 +236,7 @@ def test_complex_memory_structure() -> None: alloc3 = Allocation(id=103, size=75, start=10, end=20, offset=0) pool1 = Pool(id=211, allocations=(alloc1, alloc2)) pool2 = Pool(id=212, allocations=(alloc3,)) - memory = Memory(id=400, pools=(pool1, pool2), size=500) + memory = Memory(id=400, pools=(pool1, pool2), capacity=500) assert memory.used_size == pool1.size + pool2.size assert memory.is_allocated is True @@ -306,7 +247,7 @@ def test_allocate_with_allocator() -> None: alloc2 = Allocation(id=102, size=50, start=5, end=15) pool1 = Pool(id=211, allocations=(alloc1,)) pool2 = Pool(id=212, allocations=(alloc2,)) - memory = Memory(id=301, pools=(pool1, pool2), size=1000) + memory = Memory(id=301, pools=(pool1, pool2), capacity=1000) assert memory.is_allocated is False allocator = NaiveAllocator() @@ -314,7 +255,7 @@ def test_allocate_with_allocator() -> None: assert allocated_memory.is_allocated is True assert allocated_memory.id == memory.id - assert allocated_memory.size == memory.size + assert allocated_memory.capacity == memory.capacity assert len(allocated_memory.pools) == 2 # Original memory should be unchanged assert memory.is_allocated is False diff --git a/tests/unit/primitives/test_pool.py b/tests/unit/primitives/test_pool.py index e8a8afe..e8c7f39 100644 --- a/tests/unit/primitives/test_pool.py +++ b/tests/unit/primitives/test_pool.py @@ -128,28 +128,6 @@ def test_size_mixed_allocated_unallocated() -> None: _ = pool.size -def test_total_size_single_allocation() -> None: - """Test total_size calculation with single allocation.""" - alloc = Allocation(id=101, size=100, start=0, end=10) - pool = Pool(id=201, allocations=(alloc,)) - assert pool.total_size == 100 - - -def test_total_size_multiple_allocations() -> None: - """Test total_size calculation with multiple allocations.""" - alloc1 = Allocation(id=101, size=100, start=0, end=10) - alloc2 = Allocation(id=102, size=50, start=0, end=10) - alloc3 = Allocation(id=103, size=75, start=0, end=10) - pool = Pool(id=201, allocations=(alloc1, alloc2, alloc3)) - assert pool.total_size == 225 - - -def test_total_size_empty_pool() -> None: - """Test total_size calculation with empty pool.""" - pool = Pool(id=1, allocations=()) - assert pool.total_size == 0 - - def test_pressure_single_allocation() -> None: """Test pressure calculation with single allocation.""" alloc = Allocation(id=101, size=100, start=0, end=10) @@ -350,7 +328,6 @@ def test_large_values() -> None: alloc2 = Allocation(id=102, size=10**11, start=0, end=100, offset=10**12) pool = Pool(id=999, allocations=(alloc1, alloc2), offset=10**15) assert pool.size == 10**12 + 10**11 - assert pool.total_size == 10**12 + 10**11 assert pool.pressure == 10**12 + 10**11 assert pool.offset == 10**15 @@ -361,7 +338,6 @@ def test_multiple_allocations_complex() -> None: alloc2 = Allocation(id=102, size=50, start=5, end=15, offset=150) alloc3 = Allocation(id=103, size=75, start=10, end=20, offset=50) pool = Pool(id=300, allocations=(alloc1, alloc2, alloc3)) - assert pool.total_size == 225 assert pool.pressure == 150 assert pool.size == 200 assert pool.is_allocated is True diff --git a/tests/unit/primitives/test_system.py b/tests/unit/primitives/test_system.py index a8fb0cc..b929482 100644 --- a/tests/unit/primitives/test_system.py +++ b/tests/unit/primitives/test_system.py @@ -168,8 +168,8 @@ def test_complex_system_structure() -> None: pool1 = Pool(id=211, allocations=(alloc1, alloc2)) pool2 = Pool(id=212, allocations=(alloc3,)) pool3 = Pool(id=213, allocations=(alloc4,)) - memory1 = Memory(id=311, pools=(pool1, pool2), size=500) - memory2 = Memory(id=312, pools=(pool3,), size=300) + memory1 = Memory(id=311, pools=(pool1, pool2), capacity=500) + memory2 = Memory(id=312, pools=(pool3,), capacity=300) system = System(id=401, memories=(memory1, memory2)) assert len(system.memories) == 2 assert system.is_allocated is True @@ -193,7 +193,7 @@ def test_single_memory_system() -> None: """Test system with single memory.""" alloc = Allocation(id=101, size=100, start=0, end=10, offset=0) pool = Pool(id=211, allocations=(alloc,)) - memory = Memory(id=311, pools=(pool,), size=1000) + memory = Memory(id=311, pools=(pool,), capacity=1000) system = System(id=401, memories=(memory,)) assert len(system.memories) == 1 assert system.is_allocated is True @@ -221,8 +221,8 @@ def test_large_system() -> None: alloc2 = Allocation(id=102, size=10**11, start=0, end=100, offset=0) pool1 = Pool(id=211, allocations=(alloc1,)) pool2 = Pool(id=212, allocations=(alloc2,)) - memory1 = Memory(id=311, pools=(pool1,), size=10**15) - memory2 = Memory(id=312, pools=(pool2,), size=10**14) + memory1 = Memory(id=311, pools=(pool1,), capacity=10**15) + memory2 = Memory(id=312, pools=(pool2,), capacity=10**14) system = System(id=999, memories=(memory1, memory2)) assert memory1.used_size == 10**12 assert memory2.used_size == 10**11 @@ -237,8 +237,8 @@ def test_allocate_with_allocator() -> None: alloc3 = Allocation(id=103, size=75, start=10, end=20) pool1 = Pool(id=211, allocations=(alloc1,)) pool2 = Pool(id=212, allocations=(alloc2, alloc3)) - memory1 = Memory(id=311, pools=(pool1,), size=1000) - memory2 = Memory(id=312, pools=(pool2,), size=500) + memory1 = Memory(id=311, pools=(pool1,), capacity=1000) + memory2 = Memory(id=312, pools=(pool2,), capacity=500) system = System(id=401, memories=(memory1, memory2)) assert system.is_allocated is False diff --git a/tests/unit/test_allocate.py b/tests/unit/test_allocate.py index c28d0fd..da2849d 100644 --- a/tests/unit/test_allocate.py +++ b/tests/unit/test_allocate.py @@ -2,10 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 # -from omnimalloc.allocate import run_allocation +from omnimalloc import allocate from omnimalloc.allocators.greedy import GreedyAllocator from omnimalloc.allocators.naive import NaiveAllocator -from omnimalloc.primitives import Allocation, BufferKind, Memory, Pool, System +from omnimalloc.primitives import Allocation, AllocationKind, Memory, Pool, System def test_allocate_pool_with_naive_allocator() -> None: @@ -15,7 +15,7 @@ def test_allocate_pool_with_naive_allocator() -> None: pool = Pool(id=1, allocations=(alloc1, alloc2)) allocator = NaiveAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) # Check that allocations were placed assert allocated_pool.is_allocated @@ -34,7 +34,7 @@ def test_allocate_pool_with_greedy_allocator() -> None: pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3)) allocator = GreedyAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) assert allocated_pool.is_allocated assert all(a.offset is not None for a in allocated_pool.allocations) @@ -49,7 +49,7 @@ def test_allocate_memory_with_multiple_pools() -> None: memory = Memory(id=1, pools=(pool1, pool2)) allocator = NaiveAllocator() - allocated_memory = run_allocation(memory, allocator) + allocated_memory = allocate(memory, allocator) assert allocated_memory.is_allocated assert all(p.is_allocated for p in allocated_memory.pools) @@ -66,7 +66,7 @@ def test_allocate_system_with_multiple_memories() -> None: system = System(id=1, memories=(memory1, memory2)) allocator = NaiveAllocator() - allocated_system = run_allocation(system, allocator) + allocated_system = allocate(system, allocator) assert allocated_system.is_allocated assert all(m.is_allocated for m in allocated_system.memories) @@ -79,7 +79,7 @@ def test_allocate_with_validation_success() -> None: pool = Pool(id=1, allocations=(alloc1, alloc2)) allocator = NaiveAllocator() - allocated_pool = run_allocation(pool, allocator, validate=True) + allocated_pool = allocate(pool, allocator, validate=True) assert allocated_pool.is_allocated @@ -90,25 +90,25 @@ def test_allocate_preserves_pool_offset() -> None: pool = Pool(id=1, allocations=(alloc,), offset=50) allocator = NaiveAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) assert allocated_pool.offset == 50 -def test_allocate_with_buffer_kinds() -> None: - """Test allocating with different buffer kinds.""" - alloc1 = Allocation(id=1, size=100, start=0, end=5, kind=BufferKind.WORKSPACE) - alloc2 = Allocation(id=2, size=150, start=5, end=10, kind=BufferKind.CONSTANT) - alloc3 = Allocation(id=3, size=75, start=10, end=15, kind=BufferKind.INPUT) +def test_allocate_with_allocation_kinds() -> None: + """Test allocating with different allocation kinds.""" + alloc1 = Allocation(id=1, size=100, start=0, end=5, kind=AllocationKind.WORKSPACE) + alloc2 = Allocation(id=2, size=150, start=5, end=10, kind=AllocationKind.CONSTANT) + alloc3 = Allocation(id=3, size=75, start=10, end=15, kind=AllocationKind.INPUT) pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3)) allocator = NaiveAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) # Check kinds are preserved - assert allocated_pool.allocations[0].kind == BufferKind.WORKSPACE - assert allocated_pool.allocations[1].kind == BufferKind.CONSTANT - assert allocated_pool.allocations[2].kind == BufferKind.INPUT + assert allocated_pool.allocations[0].kind == AllocationKind.WORKSPACE + assert allocated_pool.allocations[1].kind == AllocationKind.CONSTANT + assert allocated_pool.allocations[2].kind == AllocationKind.INPUT def test_allocate_with_string_ids() -> None: @@ -118,7 +118,7 @@ def test_allocate_with_string_ids() -> None: pool = Pool(id="main_pool", allocations=(alloc1, alloc2)) allocator = NaiveAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) assert allocated_pool.is_allocated assert allocated_pool.id == "main_pool" @@ -140,19 +140,19 @@ def test_allocate_complex_hierarchy() -> None: pool1 = Pool(id=1, allocations=tuple(allocations_pool1)) pool2 = Pool(id=2, allocations=tuple(allocations_pool2)) - memory1 = Memory(id=1, pools=(pool1, pool2), size=2048) + memory1 = Memory(id=1, pools=(pool1, pool2), capacity=2048) # Second memory allocations_pool3 = [ Allocation(id=i + 10, size=75, start=i * 5, end=(i + 1) * 5) for i in range(4) ] pool3 = Pool(id=3, allocations=tuple(allocations_pool3)) - memory2 = Memory(id=2, pools=(pool3,), size=1024) + memory2 = Memory(id=2, pools=(pool3,), capacity=1024) system = System(id=1, memories=(memory1, memory2)) allocator = GreedyAllocator() - allocated_system = run_allocation(system, allocator) + allocated_system = allocate(system, allocator) # Verify everything is allocated assert allocated_system.is_allocated @@ -169,7 +169,7 @@ def test_allocate_empty_pool() -> None: pool = Pool(id=1, allocations=()) allocator = NaiveAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) assert allocated_pool.is_allocated assert len(allocated_pool.allocations) == 0 @@ -181,7 +181,7 @@ def test_allocate_single_allocation() -> None: pool = Pool(id=1, allocations=(alloc,)) allocator = NaiveAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) assert allocated_pool.is_allocated assert allocated_pool.allocations[0].offset == 0 @@ -190,18 +190,18 @@ def test_allocate_single_allocation() -> None: def test_allocate_preserves_original_properties() -> None: """Test that allocate preserves all original allocation properties.""" - alloc = Allocation(id="test", size=100, start=5, end=15, kind=BufferKind.OUTPUT) + alloc = Allocation(id="test", size=100, start=5, end=15, kind=AllocationKind.OUTPUT) pool = Pool(id="pool", allocations=(alloc,), offset=50) allocator = NaiveAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) allocated_alloc = allocated_pool.allocations[0] assert allocated_alloc.id == "test" assert allocated_alloc.size == 100 assert allocated_alloc.start == 5 assert allocated_alloc.end == 15 - assert allocated_alloc.kind == BufferKind.OUTPUT + assert allocated_alloc.kind == AllocationKind.OUTPUT assert allocated_alloc.offset is not None # This is the new property @@ -215,15 +215,15 @@ def test_allocate_returns_same_type() -> None: allocator = NaiveAllocator() # Test with Pool - result_pool = run_allocation(pool, allocator) + result_pool = allocate(pool, allocator) assert isinstance(result_pool, Pool) # Test with Memory - result_memory = run_allocation(memory, allocator) + result_memory = allocate(memory, allocator) assert isinstance(result_memory, Memory) # Test with System - result_system = run_allocation(system, allocator) + result_system = allocate(system, allocator) assert isinstance(result_system, System) @@ -234,11 +234,10 @@ def test_allocate_pool_calculates_correct_size() -> None: pool = Pool(id=1, allocations=(alloc1, alloc2)) allocator = NaiveAllocator() - allocated_pool = run_allocation(pool, allocator) + allocated_pool = allocate(pool, allocator) # Naive places sequentially: 0-100, 100-250 assert allocated_pool.size == 250 - assert allocated_pool.total_size == 250 # Sum of sizes def test_allocate_memory_calculates_used_size() -> None: @@ -247,11 +246,10 @@ def test_allocate_memory_calculates_used_size() -> None: alloc2 = Allocation(id=2, size=150, start=0, end=10) pool1 = Pool(id=1, allocations=(alloc1,)) pool2 = Pool(id=2, allocations=(alloc2,)) - memory = Memory(id=1, pools=(pool1, pool2), size=1000) + memory = Memory(id=1, pools=(pool1, pool2), capacity=1000) allocator = NaiveAllocator() - allocated_memory = run_allocation(memory, allocator) + allocated_memory = allocate(memory, allocator) # Each pool gets its allocations placed assert allocated_memory.used_size == 250 # 100 + 150 - assert allocated_memory.free_size == 750 # 1000 - 250 diff --git a/tests/unit/test_dump.py b/tests/unit/test_dump.py deleted file mode 100644 index d5b5cc0..0000000 --- a/tests/unit/test_dump.py +++ /dev/null @@ -1,143 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -from pathlib import Path - -import pytest -from omnimalloc.dump import dump_allocation, load_allocation -from omnimalloc.primitives import Allocation, Memory, Pool, System - - -def make_pool(pool_id: str = "p0") -> Pool: - allocations = ( - Allocation(id="a", size=4, start=0, end=3), - Allocation(id="b", size=8, start=2, end=9), - ) - return Pool(id=pool_id, allocations=allocations) - - -def test_dump_pool_uses_stem_prefix(tmp_path: Path) -> None: - file_path = tmp_path / "problem.csv" - written = dump_allocation(make_pool(), file_path) - assert written == (tmp_path / "problem_p0.csv",) - assert written[0].read_text() == "id,lower,upper,size\na,0,3,4\nb,2,9,8\n" - - -def test_dump_path_without_suffix_uses_stem_prefix(tmp_path: Path) -> None: - written = dump_allocation(make_pool("my_pool"), tmp_path / "problem") - assert written == (tmp_path / "problem_my_pool.csv",) - - -def test_dump_creates_missing_parent_directories(tmp_path: Path) -> None: - file_path = tmp_path / "nested" / "dir" / "problem.csv" - written = dump_allocation(make_pool(), file_path) - assert written == (tmp_path / "nested" / "dir" / "problem_p0.csv",) - - -def test_dump_memory_writes_one_file_per_pool(tmp_path: Path) -> None: - memory = Memory(id="mem", pools=(make_pool("p0"), make_pool("p1"))) - written = dump_allocation(memory, tmp_path / "problem.csv") - assert written == (tmp_path / "problem_p0.csv", tmp_path / "problem_p1.csv") - - -def test_dump_system_qualifies_names_with_memory_id(tmp_path: Path) -> None: - system = System( - id="sys", - memories=( - Memory(id="m0", pools=(make_pool("p0"),)), - Memory(id="m1", pools=(make_pool("p0"),)), - ), - ) - written = dump_allocation(system, tmp_path / "problem.csv") - assert written == ( - tmp_path / "problem_m0_p0.csv", - tmp_path / "problem_m1_p0.csv", - ) - - -def test_dump_omits_offsets_of_allocated_pool(tmp_path: Path) -> None: - allocation = Allocation(id="a", size=4, start=0, end=3, offset=16) - pool = Pool(id="p0", allocations=(allocation,)) - (file_path,) = dump_allocation(pool, tmp_path / "problem.csv") - assert file_path.read_text() == "id,lower,upper,size\na,0,3,4\n" - - -def test_load_pool_from_csv(tmp_path: Path) -> None: - file_path = tmp_path / "problem.csv" - file_path.write_text("id,lower,upper,size\na,0,3,4\nb,2,9,8\n") - pool = load_allocation(file_path) - assert pool.id == "problem" - assert [(a.id, a.start, a.end, a.size) for a in pool.allocations] == [ - ("a", 0, 3, 4), - ("b", 2, 9, 8), - ] - assert all(a.offset is None for a in pool.allocations) - - -def test_load_pool_with_offset_column(tmp_path: Path) -> None: - file_path = tmp_path / "solved.csv" - file_path.write_text("id,lower,upper,size,offset\na,0,3,4,16\nb,2,9,8,\n") - pool = load_allocation(file_path) - assert pool.allocations[0].offset == 16 - assert pool.allocations[1].offset is None - - -def test_dump_load_round_trip_preserves_problem(tmp_path: Path) -> None: - pool = make_pool("round_trip") - (file_path,) = dump_allocation(pool, tmp_path / "problem.csv") - loaded = load_allocation(file_path) - assert loaded.id == "problem_round_trip" - original = [(str(a.id), a.start, a.end, a.size) for a in pool.allocations] - restored = [(str(a.id), a.start, a.end, a.size) for a in loaded.allocations] - assert restored == original - - -def test_dump_system_round_trip(tmp_path: Path) -> None: - system = System( - id="sys", - memories=( - Memory(id="m0", pools=(make_pool("p0"), make_pool("p1"))), - Memory(id="m1", pools=(make_pool("p0"),)), - ), - ) - written = dump_allocation(system, tmp_path / "problem.csv") - assert len(written) == 3 - for file_path in written: - loaded = load_allocation(file_path) - assert len(loaded.allocations) == 2 - - -def test_dump_memory_rejects_pool_ids_colliding_after_str(tmp_path: Path) -> None: - memory = Memory(id="mem", pools=(make_pool(1), make_pool("1"))) - with pytest.raises(ValueError, match="unique"): - dump_allocation(memory, tmp_path / "problem.csv") - - -def test_dump_vector_time_joins_components_with_colons(tmp_path: Path) -> None: - pool = Pool( - id="p0", - allocations=(Allocation(id="a", size=4, start=(3, 0), end=(5, 2)),), - ) - (file_path,) = dump_allocation(pool, tmp_path / "problem.csv") - assert file_path.read_text() == "id,lower,upper,size\na,3:0,5:2,4\n" - - -def test_dump_load_round_trip_preserves_vector_time(tmp_path: Path) -> None: - pool = Pool( - id="p0", - allocations=( - Allocation(id="a", size=4, start=(3, 0), end=(5, 2)), - Allocation(id="b", size=8, start=(0, 1), end=(2, 4)), - ), - ) - (file_path,) = dump_allocation(pool, tmp_path / "problem.csv") - loaded = load_allocation(file_path) - original = [(str(a.id), a.start, a.end, a.size) for a in pool.allocations] - restored = [(str(a.id), a.start, a.end, a.size) for a in loaded.allocations] - assert restored == original - - -def test_dump_scalar_files_stay_minimalloc_format(tmp_path: Path) -> None: - (file_path,) = dump_allocation(make_pool(), tmp_path / "problem.csv") - assert ":" not in file_path.read_text() diff --git a/tests/unit/test_io.py b/tests/unit/test_io.py new file mode 100644 index 0000000..8f77ef5 --- /dev/null +++ b/tests/unit/test_io.py @@ -0,0 +1,182 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from pathlib import Path + +import pytest +from omnimalloc.io import load_allocation, save_allocation +from omnimalloc.primitives import Allocation, Memory, Pool, System + + +def make_pool(pool_id: str = "p0") -> Pool: + allocations = ( + Allocation(id="a", size=4, start=0, end=3), + Allocation(id="b", size=8, start=2, end=9), + ) + return Pool(id=pool_id, allocations=allocations) + + +def make_allocated_pool(pool_id: str = "p0") -> Pool: + allocations = ( + Allocation(id="a", size=4, start=0, end=3, offset=0), + Allocation(id="b", size=8, start=2, end=9, offset=4), + ) + return Pool(id=pool_id, allocations=allocations) + + +def test_save_pool_writes_exactly_path(tmp_path: Path) -> None: + file_path = tmp_path / "problem.csv" + written = save_allocation(make_pool(), file_path) + assert written == (file_path,) + assert file_path.read_text() == "id,lower,upper,size\na,0,3,4\nb,2,9,8\n" + + +def test_save_creates_missing_parent_directories(tmp_path: Path) -> None: + file_path = tmp_path / "nested" / "dir" / "problem.csv" + written = save_allocation(make_pool(), file_path) + assert written == (file_path,) + + +def test_save_memory_writes_one_file_per_pool(tmp_path: Path) -> None: + memory = Memory(id="mem", pools=(make_pool("p0"), make_pool("p1"))) + written = save_allocation(memory, tmp_path / "problem.csv") + assert written == (tmp_path / "problem_p0.csv", tmp_path / "problem_p1.csv") + + +def test_save_system_qualifies_names_with_memory_id(tmp_path: Path) -> None: + system = System( + id="sys", + memories=( + Memory(id="m0", pools=(make_pool("p0"),)), + Memory(id="m1", pools=(make_pool("p0"),)), + ), + ) + written = save_allocation(system, tmp_path / "problem.csv") + assert written == ( + tmp_path / "problem_m0_p0.csv", + tmp_path / "problem_m1_p0.csv", + ) + + +def test_save_allocated_pool_emits_offset_column(tmp_path: Path) -> None: + (file_path,) = save_allocation(make_allocated_pool(), tmp_path / "solved.csv") + assert file_path.read_text() == "id,lower,upper,size,offset\na,0,3,4,0\nb,2,9,8,4\n" + + +def test_save_unallocated_pool_omits_offset_column(tmp_path: Path) -> None: + (file_path,) = save_allocation(make_pool(), tmp_path / "problem.csv") + assert "offset" not in file_path.read_text() + + +def test_load_pool_from_csv(tmp_path: Path) -> None: + file_path = tmp_path / "problem.csv" + file_path.write_text("id,lower,upper,size\na,0,3,4\nb,2,9,8\n") + pool = load_allocation(file_path) + assert pool.id == "problem" + assert [(a.id, a.start, a.end, a.size) for a in pool.allocations] == [ + ("a", 0, 3, 4), + ("b", 2, 9, 8), + ] + assert all(a.offset is None for a in pool.allocations) + + +def test_load_keeps_kind_none(tmp_path: Path) -> None: + file_path = tmp_path / "problem.csv" + file_path.write_text("id,lower,upper,size\na,0,3,4\n") + pool = load_allocation(file_path) + assert pool.allocations[0].kind is None + + +def test_save_partially_allocated_pool_round_trips_placement( + tmp_path: Path, +) -> None: + pool = Pool( + id="partial", + allocations=( + Allocation(id="a", size=4, start=0, end=3, offset=0), + Allocation(id="b", size=8, start=2, end=9), + ), + ) + (file_path,) = save_allocation(pool, tmp_path / "partial.csv") + assert file_path.read_text() == "id,lower,upper,size,offset\na,0,3,4,0\nb,2,9,8,\n" + loaded = load_allocation(file_path) + assert loaded.allocations[0].offset == 0 + assert loaded.allocations[1].offset is None + + +def test_load_pool_with_offset_column(tmp_path: Path) -> None: + file_path = tmp_path / "solved.csv" + file_path.write_text("id,lower,upper,size,offset\na,0,3,4,16\nb,2,9,8,\n") + pool = load_allocation(file_path) + assert pool.allocations[0].offset == 16 + assert pool.allocations[1].offset is None + + +def test_save_load_round_trip_preserves_problem(tmp_path: Path) -> None: + pool = make_pool("round_trip") + (file_path,) = save_allocation(pool, tmp_path / "problem.csv") + loaded = load_allocation(file_path) + assert loaded.id == "problem" + original = [(str(a.id), a.start, a.end, a.size) for a in pool.allocations] + restored = [(str(a.id), a.start, a.end, a.size) for a in loaded.allocations] + assert restored == original + + +def test_save_load_round_trip_preserves_placement(tmp_path: Path) -> None: + pool = make_allocated_pool() + (file_path,) = save_allocation(pool, tmp_path / "solved.csv") + loaded = load_allocation(file_path) + original = [(str(a.id), a.offset) for a in pool.allocations] + restored = [(str(a.id), a.offset) for a in loaded.allocations] + assert restored == original + + +def test_save_system_round_trip(tmp_path: Path) -> None: + system = System( + id="sys", + memories=( + Memory(id="m0", pools=(make_pool("p0"), make_pool("p1"))), + Memory(id="m1", pools=(make_pool("p0"),)), + ), + ) + written = save_allocation(system, tmp_path / "problem.csv") + assert len(written) == 3 + for file_path in written: + loaded = load_allocation(file_path) + assert len(loaded.allocations) == 2 + + +def test_save_memory_rejects_pool_ids_colliding_after_str(tmp_path: Path) -> None: + memory = Memory(id="mem", pools=(make_pool(1), make_pool("1"))) + with pytest.raises(ValueError, match="unique"): + save_allocation(memory, tmp_path / "problem.csv") + + +def test_save_vector_time_joins_components_with_colons(tmp_path: Path) -> None: + pool = Pool( + id="p0", + allocations=(Allocation(id="a", size=4, start=(3, 0), end=(5, 2)),), + ) + (file_path,) = save_allocation(pool, tmp_path / "problem.csv") + assert file_path.read_text() == "id,lower,upper,size\na,3:0,5:2,4\n" + + +def test_save_load_round_trip_preserves_vector_time(tmp_path: Path) -> None: + pool = Pool( + id="p0", + allocations=( + Allocation(id="a", size=4, start=(3, 0), end=(5, 2)), + Allocation(id="b", size=8, start=(0, 1), end=(2, 4)), + ), + ) + (file_path,) = save_allocation(pool, tmp_path / "problem.csv") + loaded = load_allocation(file_path) + original = [(str(a.id), a.start, a.end, a.size) for a in pool.allocations] + restored = [(str(a.id), a.start, a.end, a.size) for a in loaded.allocations] + assert restored == original + + +def test_save_scalar_files_stay_minimalloc_format(tmp_path: Path) -> None: + (file_path,) = save_allocation(make_pool(), tmp_path / "problem.csv") + assert ":" not in file_path.read_text() diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py index 0774d08..da7d05f 100644 --- a/tests/unit/test_validate.py +++ b/tests/unit/test_validate.py @@ -11,110 +11,86 @@ def test_validate_pool_valid_single_allocation() -> None: - """Test validation of a pool with a single valid allocation.""" alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) pool = Pool(id=1, allocations=(alloc,)) - assert validate_allocation(pool, raise_on_error=False) is True + validate_allocation(pool) def test_validate_pool_valid_multiple_non_overlapping() -> None: - """Test validation of a pool with multiple non-overlapping allocations.""" alloc1 = Allocation(id=1, size=100, start=0, end=5, offset=0) alloc2 = Allocation(id=2, size=100, start=5, end=10, offset=0) pool = Pool(id=1, allocations=(alloc1, alloc2)) - assert validate_allocation(pool, raise_on_error=False) is True + validate_allocation(pool) -def test_validate_pool_overlapping_temporal() -> None: - """Test validation fails for allocations with temporal overlap.""" - alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) - alloc2 = Allocation(id=2, size=100, start=5, end=15, offset=0) - pool = Pool(id=1, allocations=(alloc1, alloc2)) - assert validate_allocation(pool, raise_on_error=False) is False +def test_validate_pool_returns_none() -> None: + alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) + pool = Pool(id=1, allocations=(alloc,)) + assert validate_allocation(pool) is None -def test_validate_pool_overlapping_temporal_raises() -> None: - """Test validation raises for allocations with temporal overlap.""" +def test_validate_pool_overlapping_raises() -> None: alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) alloc2 = Allocation(id=2, size=100, start=5, end=15, offset=0) pool = Pool(id=1, allocations=(alloc1, alloc2)) - with pytest.raises(ValueError, match=r"Validation .* failed"): - validate_allocation(pool, raise_on_error=True) + with pytest.raises(ValueError, match=r"Validation .* failed.*overlaps"): + validate_allocation(pool) def test_validate_pool_duplicate_allocation_ids() -> None: - """Test validation fails for duplicate allocation IDs (caught at Pool creation).""" alloc1 = Allocation(id=1, size=100, start=0, end=5, offset=0) alloc2 = Allocation(id=1, size=100, start=5, end=10, offset=100) with pytest.raises(ValueError, match="allocation ids must be unique"): Pool(id=1, allocations=(alloc1, alloc2)) -def test_validate_pool_unallocated_with_require_allocated_true() -> None: - """Test validation fails for unallocated allocations when require_allocated=True.""" +def test_validate_pool_unallocated_raises() -> None: alloc = Allocation(id=1, size=100, start=0, end=10) # No offset pool = Pool(id=1, allocations=(alloc,)) - assert ( - validate_allocation(pool, raise_on_error=False, require_allocated=True) is False - ) + with pytest.raises(ValueError, match="is not allocated"): + validate_allocation(pool) -def test_validate_pool_unallocated_with_require_allocated_false() -> None: - """Test validation for unallocated allocations with require_allocated=False.""" - alloc = Allocation(id=1, size=100, start=0, end=10) # No offset - pool = Pool(id=1, allocations=(alloc,)) - assert ( - validate_allocation(pool, raise_on_error=False, require_allocated=False) is True - ) +def test_validate_pool_partially_allocated_raises() -> None: + alloc1 = Allocation(id=1, size=100, start=0, end=5, offset=0) + alloc2 = Allocation(id=2, size=100, start=10, end=15) # Unallocated + pool = Pool(id=1, allocations=(alloc1, alloc2)) + with pytest.raises(ValueError, match="is not allocated"): + validate_allocation(pool) def test_validate_pool_empty() -> None: - """Test validation of empty pool.""" pool = Pool(id=1, allocations=()) - assert validate_allocation(pool, raise_on_error=False) is True + validate_allocation(pool) def test_validate_memory_valid_single_pool() -> None: - """Test validation of memory with a single valid pool.""" alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) pool = Pool(id=1, allocations=(alloc,), offset=0) memory = Memory(id=1, pools=(pool,)) - assert validate_allocation(memory, raise_on_error=False) is True + validate_allocation(memory) def test_validate_memory_valid_multiple_non_overlapping_pools() -> None: - """Test validation of memory with multiple non-overlapping pools.""" alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) alloc2 = Allocation(id=2, size=100, start=0, end=10, offset=0) pool1 = Pool(id=1, allocations=(alloc1,), offset=0) pool2 = Pool(id=2, allocations=(alloc2,), offset=200) memory = Memory(id=1, pools=(pool1, pool2)) - assert validate_allocation(memory, raise_on_error=False) is True - - -def test_validate_memory_overlapping_pools() -> None: - """Test validation fails for spatially overlapping pools.""" - alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) - alloc2 = Allocation(id=2, size=100, start=0, end=10, offset=0) - pool1 = Pool(id=1, allocations=(alloc1,), offset=0) - pool2 = Pool(id=2, allocations=(alloc2,), offset=50) # Overlaps with pool1 - memory = Memory(id=1, pools=(pool1, pool2)) - assert validate_allocation(memory, raise_on_error=False) is False + validate_allocation(memory) def test_validate_memory_overlapping_pools_raises() -> None: - """Test validation raises for spatially overlapping pools.""" alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) alloc2 = Allocation(id=2, size=100, start=0, end=10, offset=0) pool1 = Pool(id=1, allocations=(alloc1,), offset=0) - pool2 = Pool(id=2, allocations=(alloc2,), offset=50) + pool2 = Pool(id=2, allocations=(alloc2,), offset=50) # Overlaps with pool1 memory = Memory(id=1, pools=(pool1, pool2)) with pytest.raises(ValueError, match=r"Validation .* failed"): - validate_allocation(memory, raise_on_error=True) + validate_allocation(memory) def test_validate_memory_duplicate_pool_ids() -> None: - """Test validation fails for duplicate pool IDs (caught at Memory creation).""" alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) alloc2 = Allocation(id=2, size=100, start=0, end=10, offset=0) pool1 = Pool(id=1, allocations=(alloc1,), offset=0) @@ -123,32 +99,29 @@ def test_validate_memory_duplicate_pool_ids() -> None: Memory(id=1, pools=(pool1, pool2)) -def test_validate_memory_invalid_allocations_in_pool() -> None: - """Test validation fails when a pool contains overlapping allocations.""" +def test_validate_memory_invalid_allocations_in_pool_raises() -> None: alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) alloc2 = Allocation(id=2, size=100, start=5, end=15, offset=0) pool = Pool(id=1, allocations=(alloc1, alloc2), offset=0) memory = Memory(id=1, pools=(pool,)) - assert validate_allocation(memory, raise_on_error=False) is False + with pytest.raises(ValueError, match=r"in pool 1"): + validate_allocation(memory) def test_validate_memory_empty() -> None: - """Test validation of empty memory.""" memory = Memory(id=1, pools=()) - assert validate_allocation(memory, raise_on_error=False) is True + validate_allocation(memory) def test_validate_system_valid_single_memory() -> None: - """Test validation of system with a single valid memory.""" alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) pool = Pool(id=1, allocations=(alloc,), offset=0) memory = Memory(id=1, pools=(pool,)) system = System(id=1, memories=(memory,)) - assert validate_allocation(system, raise_on_error=False) is True + validate_allocation(system) def test_validate_system_valid_multiple_memories() -> None: - """Test validation of system with multiple valid memories.""" alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) alloc2 = Allocation(id=2, size=100, start=0, end=10, offset=0) pool1 = Pool(id=1, allocations=(alloc1,), offset=0) @@ -156,11 +129,10 @@ def test_validate_system_valid_multiple_memories() -> None: memory1 = Memory(id=1, pools=(pool1,)) memory2 = Memory(id=2, pools=(pool2,)) system = System(id=1, memories=(memory1, memory2)) - assert validate_allocation(system, raise_on_error=False) is True + validate_allocation(system) def test_validate_system_duplicate_memory_ids() -> None: - """Test validation fails for duplicate memory IDs (caught at System creation).""" alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) alloc2 = Allocation(id=2, size=100, start=0, end=10, offset=0) pool1 = Pool(id=1, allocations=(alloc1,), offset=0) @@ -171,48 +143,33 @@ def test_validate_system_duplicate_memory_ids() -> None: System(id=1, memories=(memory1, memory2)) -def test_validate_system_invalid_memory() -> None: - """Test validation fails when a memory contains invalid pools.""" +def test_validate_system_invalid_memory_raises() -> None: alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) alloc2 = Allocation(id=2, size=100, start=5, end=15, offset=0) pool = Pool(id=1, allocations=(alloc1, alloc2), offset=0) memory = Memory(id=1, pools=(pool,)) system = System(id=1, memories=(memory,)) - assert validate_allocation(system, raise_on_error=False) is False + with pytest.raises(ValueError, match=r"in memory 1"): + validate_allocation(system) def test_validate_system_empty() -> None: - """Test validation of empty system.""" system = System(id=1, memories=()) - assert validate_allocation(system, raise_on_error=False) is True + validate_allocation(system) def test_validate_unsupported_type() -> None: - """Test validation raises TypeError for unsupported entity type.""" with pytest.raises(TypeError, match="Unsupported entity type"): - validate_allocation("invalid_entity", raise_on_error=False) # type: ignore[arg-type] + validate_allocation("invalid_entity") # type: ignore[arg-type] def test_validate_allocation_directly() -> None: - """Test that validate_allocation() does not accept Allocation directly.""" alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) with pytest.raises(TypeError, match="Unsupported entity type"): - validate_allocation(alloc, raise_on_error=False) # type: ignore[arg-type] - - -def test_validate_mixed_allocated_unallocated_require_false() -> None: - """Test validation with mixed allocation states, require_allocated=False.""" - alloc1 = Allocation(id=1, size=100, start=0, end=5, offset=0) - alloc2 = Allocation(id=2, size=100, start=10, end=15) # Unallocated - pool = Pool(id=1, allocations=(alloc1, alloc2)) - assert ( - validate_allocation(pool, raise_on_error=False, require_allocated=False) is True - ) + validate_allocation(alloc) # type: ignore[arg-type] def test_validate_complex_hierarchy() -> None: - """Test validation of a complex System hierarchy.""" - # Create two valid memories with multiple pools each alloc1 = Allocation(id=1, size=100, start=0, end=5, offset=0) alloc2 = Allocation(id=2, size=100, start=5, end=10, offset=0) alloc3 = Allocation(id=3, size=50, start=0, end=10, offset=0) @@ -226,12 +183,10 @@ def test_validate_complex_hierarchy() -> None: memory2 = Memory(id=2, pools=(pool3,)) system = System(id=1, memories=(memory1, memory2)) - assert validate_allocation(system, raise_on_error=False) is True + validate_allocation(system) -def test_validate_complex_hierarchy_with_error() -> None: - """Test validation of a complex System hierarchy with error deep in hierarchy.""" - # Create a system with an error in one of the pools +def test_validate_complex_hierarchy_with_error_raises() -> None: alloc1 = Allocation(id=1, size=100, start=0, end=5, offset=0) alloc2 = Allocation(id=2, size=100, start=3, end=10, offset=0) # Overlaps alloc1 alloc3 = Allocation(id=3, size=50, start=0, end=10, offset=0) @@ -243,15 +198,16 @@ def test_validate_complex_hierarchy_with_error() -> None: memory2 = Memory(id=2, pools=(pool2,)) system = System(id=1, memories=(memory1, memory2)) - assert validate_allocation(system, raise_on_error=False) is False + with pytest.raises(ValueError, match=r"in memory 1.*in pool 1"): + validate_allocation(system) def test_validate_memory_over_capacity_fails() -> None: pool = Pool( id="p", allocations=(Allocation(id=1, size=100, start=0, end=5, offset=0),) ) - memory = Memory(id="m", size=50, pools=(pool,)) - with pytest.raises(ValueError, match="exceeds memory size"): + memory = Memory(id="m", capacity=50, pools=(pool,)) + with pytest.raises(ValueError, match="exceeds capacity"): validate_allocation(memory) @@ -259,22 +215,23 @@ def test_validate_memory_within_capacity_passes() -> None: pool = Pool( id="p", allocations=(Allocation(id=1, size=100, start=0, end=5, offset=0),) ) - memory = Memory(id="m", size=100, pools=(pool,)) - assert validate_allocation(memory) is True + memory = Memory(id="m", capacity=100, pools=(pool,)) + validate_allocation(memory) -def test_validate_pool_vector_conflict_at_same_offset() -> None: +def test_validate_pool_vector_conflict_at_same_offset_raises() -> None: alloc1 = Allocation(id=1, size=100, start=(0, 5), end=(1, 6), offset=0) alloc2 = Allocation(id=2, size=100, start=(2, 0), end=(3, 1), offset=0) pool = Pool(id=1, allocations=(alloc1, alloc2)) - assert validate_allocation(pool, raise_on_error=False) is False + with pytest.raises(ValueError, match="overlaps"): + validate_allocation(pool) def test_validate_pool_vector_ordered_at_same_offset() -> None: alloc1 = Allocation(id=1, size=100, start=(0, 0), end=(2, 1), offset=0) alloc2 = Allocation(id=2, size=100, start=(2, 1), end=(3, 2), offset=0) pool = Pool(id=1, allocations=(alloc1, alloc2)) - assert validate_allocation(pool, raise_on_error=False) is True + validate_allocation(pool) def test_validate_pool_mixed_dimensions_rejected() -> None: diff --git a/tests/unit/test_visualize.py b/tests/unit/test_visualize.py index a7e9fcc..6819ee8 100644 --- a/tests/unit/test_visualize.py +++ b/tests/unit/test_visualize.py @@ -6,15 +6,14 @@ import pytest from omnimalloc import visualize -from omnimalloc.primitives import Allocation, BufferKind, Memory, Pool, System +from omnimalloc.primitives import Allocation, AllocationKind, Memory, Pool, System from omnimalloc.visualize import ( HAS_MATPLOTLIB, _byte_unit, - _canonicalize, + _conflict_pairs, _conflict_visibility, _format_bytes, _lane_panels, - _overlap_pairs, _panel_extents, _projection_panels, _select_lanes, @@ -30,8 +29,7 @@ def test_visualize_single_allocation(artifacts_dir: Path) -> None: pool = Pool(id=1, allocations=(alloc,), offset=0) output_path = artifacts_dir / "test_single.pdf" - result = plot_allocation(pool, file_path=output_path) - assert result == output_path + plot_allocation(pool, output_path) assert output_path.exists() assert output_path.stat().st_size > 0 @@ -44,30 +42,28 @@ def test_visualize_multiple_allocations_in_pool(artifacts_dir: Path) -> None: pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3), offset=0) output_path = artifacts_dir / "test_multiple.pdf" - result = plot_allocation(pool, file_path=output_path) - assert result == output_path + plot_allocation(pool, output_path) assert output_path.exists() -def test_visualize_with_buffer_kinds(artifacts_dir: Path) -> None: - """Test visualization with different buffer kinds.""" +def test_visualize_with_allocation_kinds(artifacts_dir: Path) -> None: + """Test visualization with different allocation kinds.""" alloc1 = Allocation( - id=1, size=100, start=0, end=5, offset=0, kind=BufferKind.WORKSPACE + id=1, size=100, start=0, end=5, offset=0, kind=AllocationKind.WORKSPACE ) alloc2 = Allocation( - id=2, size=150, start=5, end=10, offset=0, kind=BufferKind.CONSTANT + id=2, size=150, start=5, end=10, offset=0, kind=AllocationKind.CONSTANT ) alloc3 = Allocation( - id=3, size=75, start=10, end=15, offset=0, kind=BufferKind.INPUT + id=3, size=75, start=10, end=15, offset=0, kind=AllocationKind.INPUT ) alloc4 = Allocation( - id=4, size=50, start=15, end=20, offset=0, kind=BufferKind.OUTPUT + id=4, size=50, start=15, end=20, offset=0, kind=AllocationKind.OUTPUT ) pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3, alloc4), offset=0) output_path = artifacts_dir / "test_kinds.pdf" - result = plot_allocation(pool, file_path=output_path) - assert result == output_path + plot_allocation(pool, output_path) assert output_path.exists() @@ -81,11 +77,10 @@ def test_visualize_memory_with_multiple_pools(artifacts_dir: Path) -> None: pool2 = Pool(id=2, allocations=(alloc2,), offset=200) pool3 = Pool(id=3, allocations=(alloc3,), offset=500) - memory = Memory(id="mem_1", pools=(pool1, pool2, pool3), size=1000) + memory = Memory(id="mem_1", pools=(pool1, pool2, pool3), capacity=1000) output_path = artifacts_dir / "test_memory_pools.pdf" - result = plot_allocation(memory, file_path=output_path) - assert result == output_path + plot_allocation(memory, output_path) assert output_path.exists() @@ -94,20 +89,19 @@ def test_visualize_system_with_multiple_memories(artifacts_dir: Path) -> None: # Memory 1: Simple pool alloc1 = Allocation(id=1, size=100, start=0, end=5, offset=0) pool1 = Pool(id=1, allocations=(alloc1,), offset=0) - memory1 = Memory(id="ddr4_1", pools=(pool1,), size=500) + memory1 = Memory(id="ddr4_1", pools=(pool1,), capacity=500) # Memory 2: Multiple pools alloc2 = Allocation(id=2, size=150, start=0, end=10, offset=0) alloc3 = Allocation(id=3, size=75, start=5, end=15, offset=0) pool2 = Pool(id=2, allocations=(alloc2,), offset=0) pool3 = Pool(id=3, allocations=(alloc3,), offset=200) - memory2 = Memory(id="ddr4_2", pools=(pool2, pool3), size=1000) + memory2 = Memory(id="ddr4_2", pools=(pool2, pool3), capacity=1000) system = System(id="test_system", memories=(memory1, memory2)) output_path = artifacts_dir / "test_system.pdf" - result = plot_allocation(system, file_path=output_path) - assert result == output_path + plot_allocation(system, output_path) assert output_path.exists() @@ -125,7 +119,7 @@ def test_visualize_complex_hierarchy(artifacts_dir: Path) -> None: start=i * 3, end=(i + 1) * 3, offset=0, - kind=BufferKind.CONSTANT, + kind=AllocationKind.CONSTANT, ) for i in range(3) ] @@ -133,7 +127,7 @@ def test_visualize_complex_hierarchy(artifacts_dir: Path) -> None: pool1 = Pool(id="pool_1", allocations=tuple(allocations_mem1_pool1), offset=0) pool2 = Pool(id="pool_2", allocations=tuple(allocations_mem1_pool2), offset=300) - memory1 = Memory(id="main_memory", pools=(pool1, pool2), size=2048) + memory1 = Memory(id="main_memory", pools=(pool1, pool2), capacity=2048) # Second memory with different allocation patterns allocations_mem2 = [ @@ -143,18 +137,17 @@ def test_visualize_complex_hierarchy(artifacts_dir: Path) -> None: start=i, end=i + 5, offset=0, - kind=BufferKind.WORKSPACE if i % 2 == 0 else BufferKind.OUTPUT, + kind=AllocationKind.WORKSPACE if i % 2 == 0 else AllocationKind.OUTPUT, ) for i in range(0, 20, 5) ] pool3 = Pool(id="pool_3", allocations=tuple(allocations_mem2), offset=0) - memory2 = Memory(id="cache_memory", pools=(pool3,), size=1024) + memory2 = Memory(id="cache_memory", pools=(pool3,), capacity=1024) system = System(id="complex_system", memories=(memory1, memory2)) output_path = artifacts_dir / "test_complex.pdf" - result = plot_allocation(system, file_path=output_path) - assert result == output_path + plot_allocation(system, output_path) assert output_path.exists() # Check file is reasonably sized (not empty, not huge) size = output_path.stat().st_size @@ -166,11 +159,10 @@ def test_visualize_with_string_ids(artifacts_dir: Path) -> None: alloc1 = Allocation(id="workspace_buf", size=100, start=0, end=5, offset=0) alloc2 = Allocation(id="temp_buf", size=150, start=5, end=10, offset=0) pool = Pool(id="tensor_pool", allocations=(alloc1, alloc2), offset=0) - memory = Memory(id="ddr_ram", pools=(pool,), size=512) + memory = Memory(id="ddr_ram", pools=(pool,), capacity=512) output_path = artifacts_dir / "test_string_ids.pdf" - result = plot_allocation(memory, file_path=output_path) - assert result == output_path + plot_allocation(memory, output_path) assert output_path.exists() @@ -183,72 +175,17 @@ def test_visualize_memory_without_size(artifacts_dir: Path) -> None: memory = Memory(id=1, pools=(pool1, pool2)) # No size specified output_path = artifacts_dir / "test_no_size.pdf" - result = plot_allocation(memory, file_path=output_path) - assert result == output_path + plot_allocation(memory, output_path) assert output_path.exists() -def test_canonicalize_assigns_sequential_ids() -> None: - """Test that canonicalize assigns sequential IDs to allocations.""" - alloc1 = Allocation(id="z", size=100, start=0, end=5, offset=0) - alloc2 = Allocation(id="a", size=150, start=5, end=10, offset=0) - alloc3 = Allocation(id="m", size=75, start=10, end=15, offset=0) - pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3), offset=0) - memory = Memory(id=1, pools=(pool,)) - system = System(id=1, memories=(memory,)) - - canonical = _canonicalize(system) - - # Check that allocations are sorted and have sequential IDs - canonical_pool = canonical.memories[0].pools[0] - alloc_ids = [alloc.id for alloc in canonical_pool.allocations] - assert alloc_ids == [0, 1, 2] # Sequential starting from 0 - - -def test_canonicalize_sorts_by_start_time() -> None: - """Test that canonicalize sorts allocations by start time.""" - alloc1 = Allocation(id=1, size=100, start=10, end=15, offset=0) - alloc2 = Allocation(id=2, size=150, start=0, end=5, offset=0) - alloc3 = Allocation(id=3, size=75, start=5, end=10, offset=0) - pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3), offset=0) - memory = Memory(id=1, pools=(pool,)) - system = System(id=1, memories=(memory,)) - - canonical = _canonicalize(system) - - canonical_pool = canonical.memories[0].pools[0] - starts = [alloc.start for alloc in canonical_pool.allocations] - assert starts == [0, 5, 10] # Sorted by start time - - -def test_canonicalize_preserves_allocation_properties() -> None: - """Test that canonicalize preserves allocation properties except ID.""" - alloc = Allocation( - id="original", size=100, start=5, end=15, offset=50, kind=BufferKind.CONSTANT - ) - pool = Pool(id="pool", allocations=(alloc,), offset=100) - memory = Memory(id="mem", pools=(pool,), size=1000) - system = System(id="sys", memories=(memory,)) - - canonical = _canonicalize(system) - - canonical_alloc = canonical.memories[0].pools[0].allocations[0] - assert canonical_alloc.id == 0 # Changed to sequential - assert canonical_alloc.size == 100 - assert canonical_alloc.start == 5 - assert canonical_alloc.end == 15 - assert canonical_alloc.offset == 50 - assert canonical_alloc.kind == BufferKind.CONSTANT - - def test_visualize_pool_converts_to_system(artifacts_dir: Path) -> None: """Test that visualizing a Pool creates appropriate wrapper structures.""" alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) pool = Pool(id=1, allocations=(alloc,), offset=0) output_path = artifacts_dir / "test_pool_wrapper.pdf" - result = plot_allocation(pool, file_path=output_path) - assert result == output_path + plot_allocation(pool, output_path) assert output_path.exists() @@ -256,20 +193,19 @@ def test_visualize_memory_converts_to_system(artifacts_dir: Path) -> None: """Test that visualizing a Memory creates appropriate System wrapper.""" alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) pool = Pool(id=1, allocations=(alloc,), offset=0) - memory = Memory(id=1, pools=(pool,), size=500) + memory = Memory(id=1, pools=(pool,), capacity=500) output_path = artifacts_dir / "test_memory_wrapper.pdf" - result = plot_allocation(memory, file_path=output_path) - assert result == output_path + plot_allocation(memory, output_path) assert output_path.exists() -def test_visualize_with_memory_limits(artifacts_dir: Path) -> None: - """Test visualization with custom memory limits.""" +def test_visualize_with_capacities(artifacts_dir: Path) -> None: + """Test visualization with extra capacity lines.""" alloc1 = Allocation(id=1, size=100, start=0, end=5, offset=0) alloc2 = Allocation(id=2, size=150, start=5, end=10, offset=100) pool = Pool(id=1, allocations=(alloc1, alloc2), offset=0) - memory = Memory(id="ddr_mem", pools=(pool,), size=1000) + memory = Memory(id="ddr_mem", pools=(pool,), capacity=1000) system = System(id="test_sys", memories=(memory,)) custom_limits = { @@ -278,8 +214,7 @@ def test_visualize_with_memory_limits(artifacts_dir: Path) -> None: } output_path = artifacts_dir / "test_memory_limits.pdf" - result = plot_allocation(system, file_path=output_path, memory_limits=custom_limits) - assert result == output_path + plot_allocation(system, output_path, capacities=custom_limits) assert output_path.exists() assert output_path.stat().st_size > 0 @@ -289,8 +224,7 @@ def test_visualize_saves_png_when_path_has_png_extension(artifacts_dir: Path) -> pool = Pool(id=1, allocations=(alloc,), offset=0) output_path = artifacts_dir / "test_extension.png" - result = plot_allocation(pool, file_path=output_path) - assert result == output_path + plot_allocation(pool, output_path) with output_path.open("rb") as f: assert f.read(8) == b"\x89PNG\r\n\x1a\n" @@ -300,8 +234,7 @@ def test_visualize_still_saves_pdf_by_default(artifacts_dir: Path) -> None: pool = Pool(id=1, allocations=(alloc,), offset=0) output_path = artifacts_dir / "test_extension.pdf" - result = plot_allocation(pool, file_path=output_path) - assert result == output_path + plot_allocation(pool, output_path) with output_path.open("rb") as f: assert f.read(5) == b"%PDF-" @@ -328,14 +261,14 @@ def test_visualize_memory_with_empty_pool(artifacts_dir: Path) -> None: ), ) output_path = artifacts_dir / "test_empty_pool.pdf" - assert plot_allocation(memory, file_path=output_path) == output_path + plot_allocation(memory, output_path) assert output_path.exists() def test_visualize_entity_with_zero_used_memory(artifacts_dir: Path) -> None: pool = Pool(id="empty", allocations=()) output_path = artifacts_dir / "test_zero_used.pdf" - assert plot_allocation(pool, file_path=output_path) == output_path + plot_allocation(pool, output_path) assert output_path.exists() @@ -350,11 +283,10 @@ def test_visualize_mixed_dimension_pools(artifacts_dir: Path) -> None: allocations=(Allocation(id=2, size=50, start=(0, 1), end=(2, 3), offset=0),), offset=200, ) - memory = Memory(id="mem", pools=(scalar_pool, vector_pool), size=1000) + memory = Memory(id="mem", pools=(scalar_pool, vector_pool), capacity=1000) output_path = artifacts_dir / "test_mixed_dim_pools.pdf" - result = plot_allocation(memory, file_path=output_path) - assert result == output_path + plot_allocation(memory, output_path) assert output_path.exists() assert output_path.stat().st_size > 0 @@ -366,11 +298,10 @@ def test_visualize_empty_pool(artifacts_dir: Path) -> None: allocations=(Allocation(id=1, size=100, start=0, end=4, offset=0),), offset=100, ) - memory = Memory(id="mem", pools=(empty, filled), size=500) + memory = Memory(id="mem", pools=(empty, filled), capacity=500) output_path = artifacts_dir / "test_empty_pool.pdf" - result = plot_allocation(memory, file_path=output_path) - assert result == output_path + plot_allocation(memory, output_path) assert output_path.exists() @@ -381,14 +312,23 @@ def test_visualize_vector_time_lanes(artifacts_dir: Path) -> None: pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3), offset=0) output_path = artifacts_dir / "test_vector_lanes.pdf" - result = plot_allocation( - pool, file_path=output_path, canonicalize=True, view="lanes" - ) - assert result == output_path + plot_allocation(pool, output_path, view="lanes") assert output_path.exists() assert output_path.stat().st_size > 0 +def test_plot_allocation_without_path_shows_figure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import matplotlib.pyplot as plt + + shown = [] + monkeypatch.setattr(plt, "show", lambda: shown.append(True)) + alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) + plot_allocation(Pool(id=1, allocations=(alloc,), offset=0)) + assert shown == [True] + + def test_plot_allocation_rejects_unknown_view() -> None: pool = Pool(id=1, allocations=(Allocation(id=1, size=1, start=0, end=1),)) with pytest.raises(ValueError, match="view"): @@ -420,8 +360,7 @@ def test_visualize_vector_time_panel(artifacts_dir: Path) -> None: pool = Pool(id=1, allocations=(alloc1, alloc2), offset=0) output_path = artifacts_dir / "test_vector_panel.pdf" - result = plot_allocation(pool, file_path=output_path) - assert result == output_path + plot_allocation(pool, output_path) assert output_path.exists() assert output_path.stat().st_size > 0 @@ -483,14 +422,14 @@ def test_panel_extents_concurrent_vector_memory_falls_back_to_sums() -> None: ([(0, 10), (1, 2), (3, 4)], 2), ], ) -def test_overlap_pairs_ignores_touching_intervals( +def test_conflict_pairs_ignores_touching_intervals( intervals: list[tuple[int, int]], expected: int ) -> None: allocations = [ Allocation(id=i, size=1, start=start, end=end) for i, (start, end) in enumerate(intervals) ] - assert _overlap_pairs(allocations) == expected + assert _conflict_pairs(allocations) == expected def test_conflict_visibility_counts_hidden_conflicts() -> None: @@ -516,7 +455,11 @@ def test_projection_panels_skip_conflict_note_over_budget( monkeypatch: pytest.MonkeyPatch, ) -> None: system = System(id="sys", memories=(_concurrent_memory(),)) - monkeypatch.setattr(visualize, "get_conflict_degrees", lambda _allocations: None) + + def _over_budget(_allocations: object) -> list[int]: + raise RuntimeError("Conflict sweep work exceeds work_budget") + + monkeypatch.setattr(visualize, "conflict_degrees", _over_budget) panels, caveat = _projection_panels(system) @@ -632,8 +575,8 @@ def test_panel_projection_never_shows_false_conflicts(artifacts_dir: Path) -> No continue (sa, ea), (sb, eb) = extents[id(a)], extents[id(b)] if sa < eb and sb < ea: - assert a.overlaps_temporally(b) + assert a.conflicts_with(b) output_path = artifacts_dir / "test_panel_soundness.pdf" - assert plot_allocation(memory, file_path=output_path) == output_path + plot_allocation(memory, output_path) assert output_path.exists()