From 0ad8313d1561b29905bd555b4db4879c5cf2d125 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 15:35:59 +0000 Subject: [PATCH 01/17] Share the scripts' plot helpers and run the stress harness nightly The fuzz sweep passed misspelled budget names (generations, iterations) and swallowed the TypeError, so the search allocators silently ran their seconds-scale defaults. Budget names are now exact and a wrong one raises; the iteration-bounded searchers run without a timeout, so repeats must match exactly, and only deadline-bound supermalloc is exempt. The optimality oracle skips minimalloc when it is not installed instead of crashing. The nightly stress job installs minimalloc and runs fuzz.py --deep and oracles.py. --- .github/workflows/nightly.yml | 14 +-- scripts/_plot.py | 124 +++++++++++++++++++++++++++ scripts/benchmark_allocation.py | 147 +++++++------------------------- scripts/benchmark_pressure.py | 125 ++++++--------------------- scripts/stress/fuzz.py | 38 ++++----- scripts/stress/invariants.py | 16 ++-- scripts/stress/oracles.py | 8 +- scripts/stress_omni.py | 110 ++++++------------------ 8 files changed, 245 insertions(+), 337 deletions(-) create mode 100644 scripts/_plot.py diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 729c355..782ffd7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -42,6 +42,12 @@ jobs: fetch-depth: 0 - uses: astral-sh/setup-uv@v5 - run: uv sync --all-extras + - run: uv pip install ${{ env.MINIMALLOC_URL }} + # Fuzz every workload and allocator, then the optimality, timeout and pin oracles + - run: uv run python fuzz.py --deep + working-directory: scripts/stress + - run: uv run python oracles.py + working-directory: scripts/stress - run: uv run python scripts/stress_omni.py # Two workers keep greedy_by_all's per-process copies inside runner memory - run: uv run python scripts/benchmark_allocation.py --max-threads 2 @@ -51,6 +57,7 @@ jobs: with: name: nightly-figures path: | + scripts/stress/fuzz_results_stress/ stress_results_omni/ benchmark_results_allocation/ benchmark_results_pressure/ @@ -108,8 +115,8 @@ jobs: - run: uv run ruff format --check - run: uv run ty check - # The examples job in checks.yml stops at 04; notebooks run nowhere else. - examples-notebooks: + # The examples job in checks.yml stops at 04: 05 is slow and hits the HF Hub. + examples-benchmark: runs-on: ubuntu-latest timeout-minutes: 120 steps: @@ -120,6 +127,3 @@ jobs: - run: uv sync --all-extras - run: uv pip install ${{ env.MINIMALLOC_URL }} - run: uv run python examples/05_benchmark.py - - run: > - uv run jupyter nbconvert --execute --to notebook - --output-dir executed_notebooks notebooks/*.ipynb diff --git a/scripts/_plot.py b/scripts/_plot.py new file mode 100644 index 0000000..2266c1e --- /dev/null +++ b/scripts/_plot.py @@ -0,0 +1,124 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# +"""House style and helpers shared by the benchmark and stress scripts.""" + +from math import isnan, nan +from statistics import mean +from typing import TYPE_CHECKING, Any + +import matplotlib.pyplot as plt + +if TYPE_CHECKING: + from matplotlib.axes import Axes + from matplotlib.figure import Figure + +SURFACE = "#fcfcfb" +INK = "#0b0b0b" +INK_SECONDARY = "#52514e" +INK_MUTED = "#898781" +GRID = "#e1e0d9" +AXIS = "#c3c2b7" +# Most-separated hues first; per-series markers keep identity readable +SERIES = ("#2a78d6", "#1baf7a", "#eda100", "#e34948", "#4a3aa7", "#008300", "#e87ba4") +MARKERS = ("o", "s", "^", "D", "v", "P", "X") + +Sample = dict[str, Any] + + +def fmt_seconds(seconds: float) -> str: + if isnan(seconds): + return "-" + if seconds < 1e-3: + return f"{seconds * 1e6:.0f} us" + if seconds < 1.0: + return f"{seconds * 1e3:.1f} ms" + return f"{seconds:.2f} s" + + +def series_means( + samples: list[Sample], + sizes: list[int], + family: str, + name: str, + key: str, + expected: int, +) -> list[float]: + """Mean of `key[name]` per size; NaN where fewer than `expected` samples.""" + means = [] + for size in sizes: + values = [ + s[key][name] + for s in samples + if s["family"] == family and s["size"] == size and name in s.get(key, {}) + ] + means.append(mean(values) if len(values) >= expected else nan) + return means + + +def two_panel_figure() -> "tuple[Figure, Axes, Axes]": + """A tall time panel over a short quality panel sharing the x axis.""" + fig, (top, bottom) = plt.subplots( + 2, + 1, + sharex=True, + figsize=(8, 6.4), + height_ratios=(3, 1.4), + layout="constrained", + ) + fig.set_facecolor(SURFACE) + return fig, top, bottom + + +def style_axes(ax: "Axes") -> None: + ax.set_facecolor(SURFACE) + ax.grid(visible=True, which="major", color=GRID, linewidth=0.8) + ax.set_axisbelow(True) + ax.tick_params(colors=INK_MUTED, labelcolor=INK_SECONDARY, labelsize=9) + for side in ("top", "right"): + ax.spines[side].set_visible(False) + for side in ("bottom", "left"): + ax.spines[side].set_color(AXIS) + + +def log_time_axes(ax: "Axes", xs: list[int], base: int = 10) -> None: + style_axes(ax) + ax.set_yscale("log") + ax.set_xscale("log", base=base) + ax.set_xticks(xs, [f"{x:,}" for x in xs]) + ax.tick_params(which="minor", bottom=False) + ax.set_ylabel("wall time [s]", color=INK_SECONDARY, fontsize=10) + + +def plot_series( + ax: "Axes", + xs: list[int], + values: list[float], + label: str, + color: str, + marker: str = "o", + linestyle: str = "-", +) -> None: + # An all-NaN series leaves a log axis empty and crashes on save + if all(isnan(value) for value in values): + return + ax.plot( + xs, + values, + label=label, + color=color, + linestyle=linestyle, + linewidth=2, + marker=marker, + markersize=5.5, + markeredgecolor=SURFACE, + markeredgewidth=1, + ) + + +def titles(ax: "Axes", title: str, caption: str) -> None: + ax.set_title(title, loc="left", color=INK, fontsize=12, fontweight="medium", pad=22) + note = ax.text( + 0.0, 1.04, caption, transform=ax.transAxes, fontsize=8.5, color=INK_MUTED + ) + note.set_in_layout(False) diff --git a/scripts/benchmark_allocation.py b/scripts/benchmark_allocation.py index 2c78721..b8b9a0e 100644 --- a/scripts/benchmark_allocation.py +++ b/scripts/benchmark_allocation.py @@ -8,13 +8,26 @@ """ import argparse -from math import isnan, nan +from math import isnan from pathlib import Path -from statistics import mean -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import matplotlib.pyplot as plt +from _plot import ( + AXIS, + INK_SECONDARY, + SERIES, + Sample, + fmt_seconds, + log_time_axes, + plot_series, + series_means, + style_axes, + titles, + two_panel_figure, +) from omnimalloc.allocators import BaseAllocator +from omnimalloc.analysis import placement_pressure from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.benchmark.timer import Timer @@ -40,16 +53,6 @@ "naive", ) -SURFACE = "#fcfcfb" -INK = "#0b0b0b" -INK_SECONDARY = "#52514e" -INK_MUTED = "#898781" -GRID = "#e1e0d9" -AXIS = "#c3c2b7" -SERIES = ("#2a78d6", "#1baf7a", "#eda100", "#008300", "#4a3aa7", "#e34948", "#e87ba4") - -Sample = dict[str, Any] - def _timed( allocator: BaseAllocator, allocations: "tuple[Allocation, ...]" @@ -65,11 +68,6 @@ def _timed( return seconds, placed -def _peak(placed: "tuple[Allocation, ...]") -> int: - heights = [alloc.height for alloc in placed if alloc.height is not None] - return max(heights, default=0) - - def _run_allocators( allocations: "tuple[Allocation, ...]", allocators: dict[str, BaseAllocator], @@ -95,12 +93,12 @@ def _run_allocators( validate_allocation(Pool(id=context, allocations=placed)) else: assert all(a.offset is not None and a.offset >= 0 for a in placed) - assert _peak(placed) <= sum(a.size for a in placed) + assert placement_pressure(placed) <= sum(a.size for a in placed) sample["times"][name] = seconds - sample["peaks"][name] = _peak(placed) + sample["peaks"][name] = placement_pressure(placed) if seconds > budget: dropped.add(name) - print(f"dropping {name} after {_fmt(seconds)} at {context}") + print(f"dropping {name} after {fmt_seconds(seconds)} at {context}") return sample @@ -159,35 +157,6 @@ def collect(args: argparse.Namespace) -> list[Sample]: return samples -def _series_means( - samples: list[Sample], - sizes: list[int], - family: str, - name: str, - key: str, - expected: int, -) -> list[float]: - means = [] - for size in sizes: - values = [ - s[key][name] - for s in samples - if s["family"] == family and s["size"] == size and name in s[key] - ] - means.append(mean(values) if len(values) >= expected else nan) - return means - - -def _fmt(seconds: float) -> str: - if isnan(seconds): - return "-" - if seconds < 1e-3: - return f"{seconds * 1e6:.0f} us" - if seconds < 1.0: - return f"{seconds * 1e3:.1f} ms" - return f"{seconds:.2f} s" - - def _print_summary(samples: list[Sample], args: argparse.Namespace) -> None: expected = { "sync": len(args.patterns) * args.repeats, @@ -198,66 +167,18 @@ def _print_summary(samples: list[Sample], args: argparse.Namespace) -> None: print(f"\n{family} wall time / quality (peak over optimum or best-of-run)") print(f"{'':<{width}} " + "".join(f"{size:>16}" for size in args.sizes)) for name in args.allocators: - times = _series_means( + times = series_means( samples, args.sizes, family, name, "times", family_expected ) - ratios = _series_means(samples, args.sizes, family, name, "ratios", 1) + ratios = series_means(samples, args.sizes, family, name, "ratios", 1) cells = [ - f"{_fmt(t)} {'-' if isnan(r) else f'{r:.3f}'}" + f"{fmt_seconds(t)} {'-' if isnan(r) else f'{r:.3f}'}" for t, r in zip(times, ratios, strict=True) ] print(f"{name:<{width}} " + "".join(f"{cell:>16}" for cell in cells)) print() -def _style_axes(ax: "Axes") -> None: - ax.set_facecolor(SURFACE) - ax.grid(visible=True, which="major", color=GRID, linewidth=0.8) - ax.set_axisbelow(True) - ax.tick_params(colors=INK_MUTED, labelcolor=INK_SECONDARY, labelsize=9) - for side in ("top", "right"): - ax.spines[side].set_visible(False) - for side in ("bottom", "left"): - ax.spines[side].set_color(AXIS) - - -def _plot_series( - ax: "Axes", sizes: list[int], values: list[float], label: str, color: str -) -> None: - # An all-NaN series (allocator skipped or dropped everywhere) would leave - # the log-scaled axis without positive values and crash on save - if all(isnan(value) for value in values): - return - ax.plot( - sizes, - values, - label=label, - color=color, - linewidth=2, - marker="o", - markersize=5.5, - markeredgecolor=SURFACE, - markeredgewidth=1, - ) - - -def _finish_time_axes(ax: "Axes", sizes: list[int]) -> None: - _style_axes(ax) - ax.set_yscale("log") - ax.set_xscale("log") - ax.set_xticks(sizes, [f"{size:,}" for size in sizes]) - ax.tick_params(which="minor", bottom=False) - ax.set_ylabel("wall time [s]", color=INK_SECONDARY, fontsize=10) - - -def _titles(ax: "Axes", title: str, caption: str) -> None: - ax.set_title(title, loc="left", color=INK, fontsize=12, fontweight="medium", pad=22) - note = ax.text( - 0.0, 1.04, caption, transform=ax.transAxes, fontsize=8.5, color=INK_MUTED - ) - note.set_in_layout(False) - - def _legend(ax: "Axes") -> None: ax.legend(frameon=False, fontsize=8.5, labelcolor=INK_SECONDARY, loc="upper left") @@ -271,24 +192,16 @@ def _render_family( quality_label: str, expected: int, ) -> "Figure": - fig, (ax_time, ax_ratio) = plt.subplots( - 2, - 1, - sharex=True, - figsize=(8, 6.4), - height_ratios=(3, 1.4), - layout="constrained", - ) - fig.set_facecolor(SURFACE) + fig, ax_time, ax_ratio = two_panel_figure() for name, color in zip(args.allocators, SERIES, strict=False): - times = _series_means(samples, args.sizes, family, name, "times", expected) - _plot_series(ax_time, args.sizes, times, name, color) - ratios = _series_means(samples, args.sizes, family, name, "ratios", 1) - _plot_series(ax_ratio, args.sizes, ratios, name, color) - _finish_time_axes(ax_time, args.sizes) - _titles(ax_time, title, caption) + times = series_means(samples, args.sizes, family, name, "times", expected) + plot_series(ax_time, args.sizes, times, name, color) + ratios = series_means(samples, args.sizes, family, name, "ratios", 1) + plot_series(ax_ratio, args.sizes, ratios, name, color) + log_time_axes(ax_time, args.sizes) + titles(ax_time, title, caption) _legend(ax_time) - _style_axes(ax_ratio) + style_axes(ax_ratio) ax_ratio.axhline(1.0, color=AXIS, linewidth=1) ax_ratio.set_ylabel(quality_label, color=INK_SECONDARY, fontsize=9) ax_ratio.set_xlabel("problem size [allocations]", color=INK_SECONDARY, fontsize=10) diff --git a/scripts/benchmark_pressure.py b/scripts/benchmark_pressure.py index 9ccb81b..9e3fb76 100644 --- a/scripts/benchmark_pressure.py +++ b/scripts/benchmark_pressure.py @@ -9,12 +9,23 @@ """ import argparse -from math import isnan, nan +from math import isnan from pathlib import Path from statistics import mean from typing import TYPE_CHECKING, Any import matplotlib.pyplot as plt +from _plot import ( + INK_SECONDARY, + Sample, + fmt_seconds, + log_time_axes, + plot_series, + series_means, + style_axes, + titles, + two_panel_figure, +) from omnimalloc.allocators import OmniAllocator from omnimalloc.analysis import ( antichain_pressure, @@ -53,12 +64,6 @@ ) DEFAULT_CLOSURE_CAP = 1 << 16 -SURFACE = "#fcfcfb" -INK = "#0b0b0b" -INK_SECONDARY = "#52514e" -INK_MUTED = "#898781" -GRID = "#e1e0d9" -AXIS = "#c3c2b7" COLORS = { "pressure": "#2a78d6", REFERENCE: "#1baf7a", @@ -75,8 +80,6 @@ "placement_pressure_per_allocation": "--", } -Sample = dict[str, Any] - def _capped( query: "Callable[..., Any]", allocations: "tuple[Allocation, ...]", cap: int @@ -155,7 +158,7 @@ def _run_methods( sample["values"][name] = value if seconds > budget: dropped.add(name) - print(f"dropping {name} after {_fmt(seconds)} at {context}") + print(f"dropping {name} after {fmt_seconds(seconds)} at {context}") return sample @@ -263,35 +266,6 @@ def collect(args: argparse.Namespace) -> tuple[list[Sample], dict[str, dict[int, return samples, capped -def _series_means( - samples: list[Sample], - sizes: list[int], - family: str, - name: str, - key: str, - expected: int, -) -> list[float]: - means = [] - for size in sizes: - values = [ - s[key][name] - for s in samples - if s["family"] == family and s["size"] == size and name in s.get(key, {}) - ] - means.append(mean(values) if len(values) >= expected else nan) - return means - - -def _fmt(seconds: float) -> str: - if isnan(seconds): - return "-" - if seconds < 1e-3: - return f"{seconds * 1e6:.0f} us" - if seconds < 1.0: - return f"{seconds * 1e3:.1f} ms" - return f"{seconds:.2f} s" - - def _print_summary( samples: list[Sample], capped: dict[str, dict[int, int]], args: argparse.Namespace ) -> None: @@ -307,12 +281,12 @@ def _print_summary( ) print(f"{'':<{width}} " + "".join(f"{size:>16}" for size in args.sizes)) for name in METHODS: - times = _series_means( + times = series_means( samples, args.sizes, family, name, "times", family_expected ) - ratios = _series_means(samples, args.sizes, family, name, "ratios", 1) + ratios = series_means(samples, args.sizes, family, name, "ratios", 1) cells = [ - f"{_fmt(t)} {'-' if isnan(r) else f'{r:.3f}'}" + f"{fmt_seconds(t)} {'-' if isnan(r) else f'{r:.3f}'}" for t, r in zip(times, ratios, strict=True) ] print(f"{name:<{width}} " + "".join(f"{cell:>16}" for cell in cells)) @@ -322,49 +296,12 @@ def _print_summary( print() -def _style_axes(ax: "Axes") -> None: - ax.set_facecolor(SURFACE) - ax.grid(visible=True, which="major", color=GRID, linewidth=0.8) - ax.set_axisbelow(True) - ax.tick_params(colors=INK_MUTED, labelcolor=INK_SECONDARY, labelsize=9) - for side in ("top", "right"): - ax.spines[side].set_visible(False) - for side in ("bottom", "left"): - ax.spines[side].set_color(AXIS) - - -def _plot_series(ax: "Axes", sizes: list[int], values: list[float], name: str) -> None: - ax.plot( - sizes, - values, - label=name, - color=COLORS[name], - linestyle=LINESTYLES.get(name, "-"), - linewidth=2, - marker="o", - markersize=5.5, - markeredgecolor=SURFACE, - markeredgewidth=1, +def _plot_method(ax: "Axes", sizes: list[int], values: list[float], name: str) -> None: + plot_series( + ax, sizes, values, name, COLORS[name], linestyle=LINESTYLES.get(name, "-") ) -def _finish_time_axes(ax: "Axes", sizes: list[int]) -> None: - _style_axes(ax) - ax.set_yscale("log") - ax.set_xscale("log") - ax.set_xticks(sizes, [f"{size:,}" for size in sizes]) - ax.tick_params(which="minor", bottom=False) - ax.set_ylabel("wall time [s]", color=INK_SECONDARY, fontsize=10) - - -def _titles(ax: "Axes", title: str, caption: str) -> None: - ax.set_title(title, loc="left", color=INK, fontsize=12, fontweight="medium", pad=22) - note = ax.text( - 0.0, 1.04, caption, transform=ax.transAxes, fontsize=8.5, color=INK_MUTED - ) - note.set_in_layout(False) - - def _render_family( samples: list[Sample], args: argparse.Namespace, @@ -373,23 +310,15 @@ def _render_family( caption: str, expected: int, ) -> "Figure": - fig, (ax_time, ax_ratio) = plt.subplots( - 2, - 1, - sharex=True, - figsize=(8, 6.4), - height_ratios=(3, 1.4), - layout="constrained", - ) - fig.set_facecolor(SURFACE) + fig, ax_time, ax_ratio = two_panel_figure() for name in METHODS: - times = _series_means(samples, args.sizes, family, name, "times", expected) - _plot_series(ax_time, args.sizes, times, name) + times = series_means(samples, args.sizes, family, name, "times", expected) + _plot_method(ax_time, args.sizes, times, name) if name not in (REFERENCE, PER_ALLOCATION_REFERENCE): - ratios = _series_means(samples, args.sizes, family, name, "ratios", 1) - _plot_series(ax_ratio, args.sizes, ratios, name) - _finish_time_axes(ax_time, args.sizes) - _titles(ax_time, title, caption) + ratios = series_means(samples, args.sizes, family, name, "ratios", 1) + _plot_method(ax_ratio, args.sizes, ratios, name) + log_time_axes(ax_time, args.sizes) + titles(ax_time, title, caption) ax_time.legend( frameon=False, fontsize=8, @@ -397,7 +326,7 @@ def _render_family( loc="upper left", ncols=2, ) - _style_axes(ax_ratio) + style_axes(ax_ratio) ax_ratio.axhline(1.0, color=COLORS[REFERENCE], linewidth=1) ax_ratio.set_ylabel("value /\nantichain", color=INK_SECONDARY, fontsize=9) ax_ratio.set_xlabel("problem size [allocations]", color=INK_SECONDARY, fontsize=10) diff --git a/scripts/stress/fuzz.py b/scripts/stress/fuzz.py index bc9d449..86f3ffe 100644 --- a/scripts/stress/fuzz.py +++ b/scripts/stress/fuzz.py @@ -34,18 +34,19 @@ # The search allocators get a short budget: this sweep checks contracts, and # their default seconds-scale budgets would dominate its runtime. -FAST_KWARGS: dict[str, dict[str, float | int]] = { - "genetic": {"population_size": 12, "generations": 4, "timeout": 1.0}, - "simulated_annealing": {"max_iterations": 500, "timeout": 1.0}, - "tabu_search": {"max_iterations": 200, "timeout": 1.0}, - "hill_climb": {"iterations": 200, "timeout": 1.0}, +# No timeouts: under load a deadline would stop identical calls at other depths +FAST_KWARGS: dict[str, dict[str, float | int | None]] = { + "genetic": {"population_size": 12, "max_generations": 4, "timeout": None}, + "simulated_annealing": {"max_iterations": 500, "timeout": None}, + "tabu_search": {"max_iterations": 50, "timeout": None}, + "hill_climb": {"max_iterations": 200, "timeout": None}, + "random": {"num_trials": 20, "timeout": None}, + "telamalloc": {"max_backtracks": 1000, "timeout": None}, "supermalloc": {"timeout": 1.0}, - "telamalloc": {"timeout": 1.0}, } -# The budget makes these anytime: under load two identical calls can stop at -# different search depths, so only the resulting peak is required to agree. -ANYTIME = frozenset(FAST_KWARGS) +# Bounded only by its deadline, so repeats may stop at different depths +ANYTIME = frozenset({"supermalloc"}) # Brute-force references are quadratic, so they run only on small instances BRUTE_LIMIT = 200 @@ -56,15 +57,13 @@ def build_allocator(name: str) -> BaseAllocator | None: - """Instantiate with the sweep's short budget, or plainly, or not at all.""" - allocator_type = BaseAllocator.get(name) + """Instantiate with the sweep's budget; None if a dependency is missing.""" try: - return allocator_type(**FAST_KWARGS.get(name, {})) - except (TypeError, ImportError, RuntimeError): - try: - return allocator_type() - except Exception: # noqa: BLE001 - return None + return BaseAllocator.get(name)(**FAST_KWARGS.get(name, {})) + except (ImportError, RuntimeError): + if name not in OPTIONAL: + raise + return None def fresh(name: str) -> BaseAllocator: @@ -150,10 +149,7 @@ def check_allocator( fails = check_placement(allocations, placed, name, {}) repeat = tuple(fresh(name).allocate(allocations)) - if name in ANYTIME: - if peak_of(repeat) != peak_of(placed): - fails.append(f"{name}: peak differs across two identical calls") - elif [a.offset for a in repeat] != [a.offset for a in placed]: + if name not in ANYTIME and [a.offset for a in repeat] != [a.offset for a in placed]: fails.append(f"{name}: not deterministic across two identical calls") if deep: fails += _check_pins(allocations, placed, name, rng, allocator) diff --git a/scripts/stress/invariants.py b/scripts/stress/invariants.py index 823def6..b0da2c0 100644 --- a/scripts/stress/invariants.py +++ b/scripts/stress/invariants.py @@ -73,9 +73,9 @@ def brute_collisions(allocations: Allocs) -> list[tuple[int, int]]: if second.offset is None: continue other_low, other_high = second.offset, second.offset + second.size - if low < other_high and other_low < high: - if first.conflicts_with(second): - bad.append((i, j)) + overlap = low < other_high and other_low < high + if overlap and first.conflicts_with(second): + bad.append((i, j)) return bad @@ -286,11 +286,11 @@ def check_order_invariance(allocations: Allocs, rng: random.Random) -> list[str] fails.append("conflict_degrees is not permutation-equivariant") ids = [a.id for a in allocations] - if len(set(ids)) == len(ids): - if antichain_pressure_per_allocation( - shuffled - ) != antichain_pressure_per_allocation(allocations): - fails.append("antichain_pressure_per_allocation depends on input order") + unique = len(set(ids)) == len(ids) + if unique and antichain_pressure_per_allocation( + shuffled + ) != antichain_pressure_per_allocation(allocations): + fails.append("antichain_pressure_per_allocation depends on input order") return fails diff --git a/scripts/stress/oracles.py b/scripts/stress/oracles.py index 09d3705..059faec 100644 --- a/scripts/stress/oracles.py +++ b/scripts/stress/oracles.py @@ -118,13 +118,15 @@ def _check_solution(report: Report, allocations: tuple, where: str) -> int: def _check_unbeaten(report: Report, allocations: tuple, peak: int, where: str) -> None: """No allocator may undercut a peak the search proved optimal.""" for name in SCALAR_RIVALS: - allocator = BaseAllocator.get(name)() - if not allocator.supports(allocations): - continue try: + allocator = BaseAllocator.get(name)() + if not allocator.supports(allocations): + continue rival = tuple(allocator.allocate(allocations)) except (ImportError, RuntimeError) as e: # minimalloc wraps a package that need not be installed + if name != "minimalloc": + raise print(f" skipped {name} on {where}: {e}", flush=True) continue if peak_of(rival) < peak: diff --git a/scripts/stress_omni.py b/scripts/stress_omni.py index f72f1cc..28bd85d 100644 --- a/scripts/stress_omni.py +++ b/scripts/stress_omni.py @@ -15,13 +15,24 @@ from typing import TYPE_CHECKING import matplotlib.pyplot as plt +from _plot import ( + AXIS, + INK_SECONDARY, + MARKERS, + SERIES, + SURFACE, + log_time_axes, + plot_series, + style_axes, + titles, + two_panel_figure, +) from omnimalloc.allocators import OmniAllocator -from omnimalloc.analysis import antichain_pressure +from omnimalloc.analysis import antichain_pressure, placement_pressure from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.benchmark.timer import Timer if TYPE_CHECKING: - from matplotlib.axes import Axes from matplotlib.figure import Figure from omnimalloc.primitives import Allocation @@ -29,22 +40,6 @@ PATTERNS = ("independent", "sparse", "barrier", "dense") CALLERS = (1, 2, 4, 8, 16, 32) -SURFACE = "#fcfcfb" -INK = "#0b0b0b" -INK_SECONDARY = "#52514e" -INK_MUTED = "#898781" -GRID = "#e1e0d9" -AXIS = "#c3c2b7" -# House palette (see benchmark_allocation.py), most-separated hues first; -# per-series markers keep identity readable without color -SERIES = ("#2a78d6", "#1baf7a", "#eda100", "#e34948", "#4a3aa7", "#008300", "#e87ba4") -MARKERS = ("o", "s", "^", "D", "v", "P", "X") - - -def _peak(placed: "tuple[Allocation, ...]") -> int: - heights = [alloc.height for alloc in placed if alloc.height is not None] - return max(heights, default=0) - def _timed_allocate( allocations: "tuple[Allocation, ...]", repeats: int @@ -85,7 +80,9 @@ def dim_sweep(args: argparse.Namespace) -> dict[str, dict[int, tuple[float, floa seconds, placed = _timed_allocate(allocations, repeats=1) times.append(seconds) ratios.append( - _bounded_ratio(allocations, _peak(placed), args.work_budget) + _bounded_ratio( + allocations, placement_pressure(placed), args.work_budget + ) ) clean = [r for r in ratios if not isnan(r)] results[pattern][dim] = (min(times), mean(clean) if clean else nan) @@ -123,83 +120,26 @@ def run_once(_: int) -> list[int | None]: return throughput -def _style_axes(ax: "Axes") -> None: - ax.set_facecolor(SURFACE) - ax.grid(visible=True, which="major", color=GRID, linewidth=0.8) - ax.set_axisbelow(True) - ax.tick_params(colors=INK_MUTED, labelcolor=INK_SECONDARY, labelsize=9) - for side in ("top", "right"): - ax.spines[side].set_visible(False) - for side in ("bottom", "left"): - ax.spines[side].set_color(AXIS) - - -def _plot_series( - ax: "Axes", - xs: list[int], - values: list[float], - label: str, - color: str, - marker: str, -) -> None: - if all(isnan(value) for value in values): - return - ax.plot( - xs, - values, - label=label, - color=color, - linewidth=2, - marker=marker, - markersize=5.5, - markeredgecolor=SURFACE, - markeredgewidth=1, - ) - - -def _titles(ax: "Axes", title: str, caption: str) -> None: - ax.set_title(title, loc="left", color=INK, fontsize=12, fontweight="medium", pad=22) - note = ax.text( - 0.0, 1.04, caption, transform=ax.transAxes, fontsize=8.5, color=INK_MUTED - ) - note.set_in_layout(False) - - def render_dim_sweep( results: dict[str, dict[int, tuple[float, float]]], args: argparse.Namespace ) -> "Figure": - fig, (ax_time, ax_ratio) = plt.subplots( - 2, - 1, - sharex=True, - figsize=(8, 6.4), - height_ratios=(3, 1.4), - layout="constrained", - ) - fig.set_facecolor(SURFACE) + fig, ax_time, ax_ratio = two_panel_figure() dims = list(args.dims) for (pattern, by_dim), color, marker in zip( results.items(), SERIES, MARKERS, strict=False ): - _plot_series( - ax_time, dims, [by_dim[d][0] for d in dims], pattern, color, marker - ) - _plot_series( + plot_series(ax_time, dims, [by_dim[d][0] for d in dims], pattern, color, marker) + plot_series( ax_ratio, dims, [by_dim[d][1] for d in dims], pattern, color, marker ) - _style_axes(ax_time) - ax_time.set_yscale("log") - ax_time.set_xscale("log", base=2) - ax_time.set_xticks(dims, [str(d) for d in dims]) - ax_time.tick_params(which="minor", bottom=False) - ax_time.set_ylabel("wall time [s]", color=INK_SECONDARY, fontsize=10) - _titles( + log_time_axes(ax_time, dims, base=2) + titles( ax_time, "omni allocator vs clock dimension", f"{args.size:,} allocations, best of {args.repeats} seeds per point", ) ax_time.legend(frameon=False, fontsize=8.5, labelcolor=INK_SECONDARY) - _style_axes(ax_ratio) + style_axes(ax_ratio) ax_ratio.axhline(1.0, color=AXIS, linewidth=1) ax_ratio.set_ylabel("peak /\nexact bound", color=INK_SECONDARY, fontsize=9) ax_ratio.set_xlabel("clock dimension [threads]", color=INK_SECONDARY, fontsize=10) @@ -214,16 +154,16 @@ def render_caller_sweep( callers = list(throughput) ideal = [throughput[callers[0]] * c / callers[0] for c in callers] ax.plot(callers, ideal, color=AXIS, linewidth=1, linestyle="--", label="ideal") - _plot_series( + plot_series( ax, callers, [throughput[c] for c in callers], "measured", SERIES[0], "o" ) - _style_axes(ax) + style_axes(ax) ax.set_xscale("log", base=2) ax.set_xticks(callers, [str(c) for c in callers]) ax.tick_params(which="minor", bottom=False) ax.set_ylabel("allocate calls / s", color=INK_SECONDARY, fontsize=10) ax.set_xlabel("concurrent Python callers", color=INK_SECONDARY, fontsize=10) - _titles( + titles( ax, "omni allocator throughput vs concurrent callers", f"{args.calls} calls on one {args.size:,}-allocation sparse instance, " From 319f8c4bb6024091362f27c86186fa8a6fdd1255 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 15:38:00 +0000 Subject: [PATCH 02/17] Drop the ruff ignores that no longer fire and fix the few that barely did --- pyproject.toml | 14 ----------- scripts/generate_readme_assets.py | 2 +- src/python/omnimalloc/_allocate.py | 1 - .../omnimalloc/allocators/minimalloc.py | 4 +-- .../omnimalloc/benchmark/results/export.py | 1 - .../omnimalloc/benchmark/results/utils.py | 4 +-- .../omnimalloc/benchmark/sources/base.py | 1 - src/python/omnimalloc/benchmark/timer.py | 4 ++- src/python/omnimalloc/common/intervals.py | 25 ++++++++++--------- 9 files changed, 21 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cca47d2..29cf87e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,8 +102,6 @@ exclude = ["*.ipynb", "*.pyi"] [tool.ruff.lint] select = ["ALL"] ignore = [ - "B019", # cache decorator - "B027", # non-abstract methods in abc "COM812", # trailing comma (formatter) "CPY001", # copyright notice "D100", # module docstring @@ -113,12 +111,9 @@ ignore = [ "D104", # package docstring "D105", # magic method docstring "D107", # init docstring - "D202", # blank lines in docstring "D203", # incompatible with D211 "D213", # incompatible with D212 "D401", # imperative mood - "D413", # blank line after last section - "DTZ005", # datetime.now() "EM101", # string literals in exceptions "EM102", # f-strings in exceptions "FBT001", # boolean positional args @@ -126,23 +121,14 @@ ignore = [ "FIX002", # TODOs "G004", # f-strings in logging "INP001", # allow scripts without init - "N801", # class names not CamelCase "PERF203", # try-except inside loop - "PGH003", # generic type: ignore - "PIE790", # ellipsis in functions "PLC0415", # import outside top-level "PLR0913", # too many arguments "PLR0917", # too many positional arguments "PLR2004", # magic value comparison - "PLW2901", # reassign for loop vars - "PYI019", # custom TypeVar - "PYI034", # return Self in dunders - "RET504", # unnecessary variable before return "S101", # assert "S311", # pseudo-random generators "S603", # allow untrusted subprocess - "SIM102", # nested if - "SIM110", # for loop replaceable with any() "T201", # print() "TD003", # missing issue link "TRY003", # long exception messages diff --git a/scripts/generate_readme_assets.py b/scripts/generate_readme_assets.py index 4c79925..a81cf43 100644 --- a/scripts/generate_readme_assets.py +++ b/scripts/generate_readme_assets.py @@ -199,7 +199,7 @@ def _pip_install(spec: str) -> None: def _ensure_minimalloc() -> None: """Install Google's minimalloc on demand (no PyPI wheel).""" try: - import minimalloc # type: ignore # noqa: F401 + import minimalloc # ty: ignore[unresolved-import] # noqa: F401 except ImportError: print(f"minimalloc not installed, installing from {MINIMALLOC_URL} ...") _pip_install(MINIMALLOC_URL) diff --git a/src/python/omnimalloc/_allocate.py b/src/python/omnimalloc/_allocate.py index 56f318a..c712d32 100644 --- a/src/python/omnimalloc/_allocate.py +++ b/src/python/omnimalloc/_allocate.py @@ -42,7 +42,6 @@ def allocate( Accepts a System, Memory, or Pool (returned as the same type) or a raw sequence of Allocations. An allocation carrying an offset is pinned. """ - if allocator is None: allocator = DEFAULT_ALLOCATOR diff --git a/src/python/omnimalloc/allocators/minimalloc.py b/src/python/omnimalloc/allocators/minimalloc.py index e430c0f..9d350c4 100644 --- a/src/python/omnimalloc/allocators/minimalloc.py +++ b/src/python/omnimalloc/allocators/minimalloc.py @@ -13,7 +13,7 @@ from .base import BaseAllocator try: - import minimalloc as mm # type: ignore + import minimalloc as mm # ty: ignore[unresolved-import] except ImportError: mm = cast("Any", None) @@ -26,7 +26,7 @@ def _require_minimalloc() -> None: if mm is not None: return try: - import minimalloc # type: ignore + import minimalloc # ty: ignore[unresolved-import] except ImportError: # TODO(fpedd): Make minimalloc more easily installable via PyPI raise ImportError( diff --git a/src/python/omnimalloc/benchmark/results/export.py b/src/python/omnimalloc/benchmark/results/export.py index 83e29ed..5b8ee81 100644 --- a/src/python/omnimalloc/benchmark/results/export.py +++ b/src/python/omnimalloc/benchmark/results/export.py @@ -121,7 +121,6 @@ def save_benchmark( overwrite: bool = True, ) -> Path: """Save a campaign, defaulting to `artifacts/` under the cwd.""" - if not isinstance(campaign, BenchmarkCampaign): raise TypeError(f"Expected a BenchmarkCampaign, got {type(campaign)!r}") diff --git a/src/python/omnimalloc/benchmark/results/utils.py b/src/python/omnimalloc/benchmark/results/utils.py index 8e47aef..5f5d889 100644 --- a/src/python/omnimalloc/benchmark/results/utils.py +++ b/src/python/omnimalloc/benchmark/results/utils.py @@ -18,13 +18,13 @@ def source_label(source: BaseSource | type[BaseSource] | str) -> str: def get_date_time_snake_case() -> str: """Get the current time and date as a formatted string in snake_case.""" - return datetime.now().strftime("%Y_%m_%d_%H_%M_%S") + return datetime.now().astimezone().strftime("%Y_%m_%d_%H_%M_%S") def get_environment_metadata() -> dict[str, Any]: """Generate environment metadata for benchmark results.""" return { - "date_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "date_time": datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S"), "omnimalloc_version": str(__version__), "os_info": f"{platform.system()} {platform.release()}", "cpu_info": platform.processor(), diff --git a/src/python/omnimalloc/benchmark/sources/base.py b/src/python/omnimalloc/benchmark/sources/base.py index c7f8c07..ba2cccb 100644 --- a/src/python/omnimalloc/benchmark/sources/base.py +++ b/src/python/omnimalloc/benchmark/sources/base.py @@ -83,7 +83,6 @@ def get_available_variants(self) -> tuple[str, ...] | None: def get_variant(self, variant_id: IdType) -> Pool: """Get a specific pool variant by ID.""" - if isinstance(variant_id, int): allocations = self.get_allocations(num_allocations=variant_id) if not allocations: diff --git a/src/python/omnimalloc/benchmark/timer.py b/src/python/omnimalloc/benchmark/timer.py index 1db37b4..92348fe 100644 --- a/src/python/omnimalloc/benchmark/timer.py +++ b/src/python/omnimalloc/benchmark/timer.py @@ -5,13 +5,15 @@ import time from types import TracebackType +from typing_extensions import Self + class Timer: """Context manager measuring the wall time of its block.""" elapsed_ns: int = 0 - def __enter__(self) -> "Timer": + def __enter__(self) -> Self: self._start_ns = time.perf_counter_ns() return self diff --git a/src/python/omnimalloc/common/intervals.py b/src/python/omnimalloc/common/intervals.py index e088599..b04c3b7 100644 --- a/src/python/omnimalloc/common/intervals.py +++ b/src/python/omnimalloc/common/intervals.py @@ -37,18 +37,19 @@ def stack_around_pins(sizes: Sequence[int], offsets: Sequence[int | None]) -> li resolved = [] for size, offset in zip(sizes, offsets, strict=True): - if offset is None: - node = 1 - while node < leaves: - node = 2 * node if free[2 * node] >= size else 2 * node + 1 - gap = node - leaves - offset = los[gap] - los[gap] += size - free[node] -= size - while node > 1: - node //= 2 - free[node] = max(free[2 * node], free[2 * node + 1]) - resolved.append(offset) + if offset is not None: + resolved.append(offset) + continue + node = 1 + while node < leaves: + node = 2 * node if free[2 * node] >= size else 2 * node + 1 + gap = node - leaves + resolved.append(los[gap]) + los[gap] += size + free[node] -= size + while node > 1: + node //= 2 + free[node] = max(free[2 * node], free[2 * node + 1]) return resolved From 6a9897741a415ae39ec75fb92b3158356cca0351 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 15:41:23 +0000 Subject: [PATCH 03/17] Run the examples and notebooks only in their CI jobs --- .pre-commit-config.yaml | 2 +- tests/integration/test_examples.py | 37 --------------- tests/integration/test_notebooks.py | 45 ------------------- tests/markers.py | 8 ---- tests/paths.py | 11 ----- tests/unit/benchmark/results/test_export.py | 4 +- tests/unit/benchmark/results/test_result.py | 22 +++------ .../unit/benchmark/results/test_visualize.py | 8 +--- .../unit/benchmark/sources/test_minimalloc.py | 3 +- 9 files changed, 12 insertions(+), 128 deletions(-) delete mode 100644 tests/integration/test_examples.py delete mode 100644 tests/integration/test_notebooks.py delete mode 100644 tests/paths.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6bed4c..aa6a25f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: check-merge-conflict - id: name-tests-test args: [--pytest-test-first] - exclude: '^tests/(markers|paths)\.py$' + exclude: '^tests/markers\.py$' - repo: https://github.com/pre-commit/mirrors-clang-format rev: v22.1.8 diff --git a/tests/integration/test_examples.py b/tests/integration/test_examples.py deleted file mode 100644 index e4499a9..0000000 --- a/tests/integration/test_examples.py +++ /dev/null @@ -1,37 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -import subprocess -import sys -from pathlib import Path - -import pytest - -from tests.markers import needs_examples -from tests.paths import EXAMPLES_DIR - -pytestmark = needs_examples - -EXAMPLE_FILES = sorted(EXAMPLES_DIR.glob("*.py")) -TIMEOUT_SECONDS = 600 # 10 minutes -RATE_LIMIT_MARKERS = ("429 Too Many Requests", "huggingface.co") - - -@pytest.mark.parametrize("example_file", EXAMPLE_FILES, ids=lambda p: p.name) -def test_examples(example_file: Path, tmp_path: Path) -> None: - result = subprocess.run( - [sys.executable, str(example_file)], - cwd=tmp_path, - capture_output=True, - text=True, - timeout=TIMEOUT_SECONDS, - check=False, - ) - - if result.returncode != 0: - if all(marker in result.stderr for marker in RATE_LIMIT_MARKERS): - pytest.skip("Hugging Face Hub rate limited the request") - print(f"\n=== STDOUT ===\n{result.stdout}") - print(f"\n=== STDERR ===\n{result.stderr}") - pytest.fail(f"Example {example_file.name} failed with code {result.returncode}") diff --git a/tests/integration/test_notebooks.py b/tests/integration/test_notebooks.py deleted file mode 100644 index 962eca6..0000000 --- a/tests/integration/test_notebooks.py +++ /dev/null @@ -1,45 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -import subprocess -import sys -from pathlib import Path - -import pytest - -from tests.markers import needs_notebooks -from tests.paths import NOTEBOOKS_DIR - -pytestmark = needs_notebooks - -NOTEBOOK_FILES = sorted(NOTEBOOKS_DIR.glob("*.ipynb")) -TIMEOUT_SECONDS = 300 # 5 minutes - - -@pytest.mark.parametrize("notebook_file", NOTEBOOK_FILES, ids=lambda p: p.name) -def test_notebooks(notebook_file: Path) -> None: - result = subprocess.run( - [ - sys.executable, - "-m", - "nbconvert", - "--execute", - "--to", - "notebook", - "--stdout", - str(notebook_file), - ], - cwd=NOTEBOOKS_DIR.parent, - capture_output=True, - text=True, - timeout=TIMEOUT_SECONDS, - check=False, - ) - - if result.returncode != 0: - print(f"\n=== STDOUT ===\n{result.stdout}") - print(f"\n=== STDERR ===\n{result.stderr}") - pytest.fail( - f"Notebook {notebook_file.name} failed with code {result.returncode}" - ) diff --git a/tests/markers.py b/tests/markers.py index 68ac344..d648719 100644 --- a/tests/markers.py +++ b/tests/markers.py @@ -5,14 +5,6 @@ import pytest from omnimalloc.visualize import HAS_MATPLOTLIB -from tests.paths import EXAMPLES_DIR, NOTEBOOKS_DIR - needs_matplotlib = pytest.mark.skipif( not HAS_MATPLOTLIB, reason="matplotlib not installed" ) -needs_examples = pytest.mark.skipif( - not EXAMPLES_DIR.is_dir(), reason="examples/ lives in the repository" -) -needs_notebooks = pytest.mark.skipif( - not NOTEBOOKS_DIR.is_dir(), reason="notebooks/ lives in the repository" -) diff --git a/tests/paths.py b/tests/paths.py deleted file mode 100644 index 7806732..0000000 --- a/tests/paths.py +++ /dev/null @@ -1,11 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -from pathlib import Path - -# Repository layout: the installed package ships none of these directories. -REPO_DIR = Path(__file__).resolve().parent.parent -EXAMPLES_DIR = REPO_DIR / "examples" -NOTEBOOKS_DIR = REPO_DIR / "notebooks" -EXTERNAL_DIR = REPO_DIR / "external" diff --git a/tests/unit/benchmark/results/test_export.py b/tests/unit/benchmark/results/test_export.py index d3f53d3..56bc534 100644 --- a/tests/unit/benchmark/results/test_export.py +++ b/tests/unit/benchmark/results/test_export.py @@ -26,9 +26,7 @@ from omnimalloc.io import save_allocation from omnimalloc.primitives import Allocation, Pool -from tests.markers import needs_matplotlib - -pytestmark = needs_matplotlib +pytest.importorskip("matplotlib") @pytest.fixture diff --git a/tests/unit/benchmark/results/test_result.py b/tests/unit/benchmark/results/test_result.py index b9ba459..0693ad7 100644 --- a/tests/unit/benchmark/results/test_result.py +++ b/tests/unit/benchmark/results/test_result.py @@ -3,7 +3,6 @@ # -import tempfile from pathlib import Path from typing import Any @@ -13,8 +12,6 @@ from omnimalloc.benchmark.results.result import BenchmarkResult from omnimalloc.benchmark.sources.generator import RandomSource -from tests.markers import needs_matplotlib - Fixture = tuple[Any, GreedyAllocator, RandomSource] @@ -104,22 +101,17 @@ def test_benchmark_result_frozen(allocated_pool: Fixture) -> None: @pytest.mark.filterwarnings("ignore::UserWarning") -@needs_matplotlib def test_benchmark_result_visualize_no_file(allocated_pool: Fixture) -> None: + pytest.importorskip("matplotlib") _make(allocated_pool).visualize() -@needs_matplotlib -def test_benchmark_result_visualize_with_file(allocated_pool: Fixture) -> None: - result = _make(allocated_pool) - with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: - tmp_path = Path(tmp.name) - try: - result.visualize(tmp_path) - assert tmp_path.exists() - finally: - if tmp_path.exists(): - tmp_path.unlink() +def test_benchmark_result_visualize_with_file( + allocated_pool: Fixture, tmp_path: Path +) -> None: + pytest.importorskip("matplotlib") + _make(allocated_pool).visualize(tmp_path / "result.pdf") + assert (tmp_path / "result.pdf").exists() def test_benchmark_result_different_num_allocations() -> None: diff --git a/tests/unit/benchmark/results/test_visualize.py b/tests/unit/benchmark/results/test_visualize.py index 75300c5..72e7363 100644 --- a/tests/unit/benchmark/results/test_visualize.py +++ b/tests/unit/benchmark/results/test_visualize.py @@ -17,8 +17,6 @@ ) from omnimalloc.benchmark.sources.generator import RandomSource -from tests.markers import needs_matplotlib - def _result(size: int = 10) -> BenchmarkResult: source = RandomSource(num_allocations=size, seed=42) @@ -39,7 +37,6 @@ def _report(report_id: str, variant_id: object, size: int = 10) -> BenchmarkRepo def test_get_allocator_color() -> None: - """Test allocator color cycling.""" color_0 = _get_allocator_color(0) color_5 = _get_allocator_color(5) color_10 = _get_allocator_color(10) @@ -50,7 +47,6 @@ def test_get_allocator_color() -> None: def test_format_metadata() -> None: - """Test metadata formatting.""" metadata = {"model_name": "resnet50", "batch_size": 32, "dtype": "float32"} formatted = _format_metadata(metadata) @@ -67,7 +63,6 @@ def test_format_metadata() -> None: def test_canonicalize_artifact() -> None: - """Test artifact canonicalization to campaign.""" result = _result() campaign_from_result = _canonicalize_artifact(result) @@ -86,11 +81,10 @@ def test_canonicalize_artifact() -> None: assert campaign_from_campaign is campaign -@needs_matplotlib def test_plot_benchmark_without_path_shows_figure( monkeypatch: pytest.MonkeyPatch, ) -> None: - import matplotlib.pyplot as plt + plt = pytest.importorskip("matplotlib.pyplot") shown = [] monkeypatch.setattr(plt, "show", lambda: shown.append(True)) diff --git a/tests/unit/benchmark/sources/test_minimalloc.py b/tests/unit/benchmark/sources/test_minimalloc.py index 9fc8779..b914281 100644 --- a/tests/unit/benchmark/sources/test_minimalloc.py +++ b/tests/unit/benchmark/sources/test_minimalloc.py @@ -11,7 +11,8 @@ from omnimalloc.io import save_allocation from omnimalloc.primitives import Allocation, Pool -from tests.paths import EXTERNAL_DIR +# The CSV datasets live in the repository, not in the installed package +EXTERNAL_DIR = Path(__file__).resolve().parents[4] / "external" def _source(subset: str) -> MinimallocSource: From 92e64b2f5432da3f170a8930a0a161cd1e3cea6d Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 15:41:23 +0000 Subject: [PATCH 04/17] Parallelize the CI test job and drop redundant CI steps and config --- .github/workflows/checks.yml | 20 ++++---------------- README.md | 4 ++-- pyproject.toml | 5 +---- uv.lock | 4 ---- 4 files changed, 7 insertions(+), 26 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index bff0055..dbfc4e2 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -26,7 +26,6 @@ jobs: - uses: actions/checkout@v5 with: fetch-depth: 0 - - run: sudo apt-get update && sudo apt-get install -y clang-format - uses: astral-sh/setup-uv@v5 - run: uv sync --all-extras - run: uv run pre-commit run --all-files @@ -79,25 +78,14 @@ jobs: # Gate coverage only where every optional path runs: Hub rate limits and # the Linux-only minimalloc install starve the other legs of statements - run: > - uv run pytest -v + uv run pytest -v -n auto ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == env.DEFAULT_PYTHON && '--cov=omnimalloc --cov-fail-under=90 --cov-report=term-missing' || '' }} - build: - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: astral-sh/setup-uv@v5 - - run: uv sync --all-extras - - run: uv build - - # TODO(fpedd): clang-tidy and iwyu findings still only get reported, not - # enforced; compiler warnings do fail the job via -Werror. + # Also builds the sdist and wheel. TODO(fpedd): clang-tidy and iwyu findings + # still only get reported, not enforced; compiler warnings fail via -Werror. cpp-quality: runs-on: ubuntu-latest timeout-minutes: 60 @@ -115,7 +103,7 @@ jobs: checks-status: runs-on: ubuntu-latest if: always() - needs: [pre-commit, examples, test, build, cpp-quality] + needs: [pre-commit, examples, test, cpp-quality] steps: - run: exit 1 if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') diff --git a/README.md b/README.md index 378ed83..a43d808 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,10 @@ Run your own campaigns with the [benchmark harness](examples/05_benchmark.py). # Initial setup git clone git@github.com:fpedd/omnimalloc.git cd omnimalloc -uv sync --all-extras --group dev +uv sync --all-extras # Run tests, linting, type checking -uv run pytest +uv run pytest -n auto uv run ruff check --fix && uv run ruff format && uv run ty check # Setup pre-commit hooks (run once) diff --git a/pyproject.toml b/pyproject.toml index 29cf87e..3f6bf2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ all = [ "deap>=1.4.0", "huggingface-hub>=0.35.0", "matplotlib>=3.10.0", - "numpy>=1.25.0", "onnx>=1.19.0", "tqdm>=4.66.0", ] @@ -86,7 +85,7 @@ test-command = "pytest {project}/tests/unit -x --no-header -q" [tool.cibuildwheel.linux] archs = ["auto"] -before-all = "yum install -y cmake || apk add cmake" +before-all = "yum install -y cmake" [tool.cibuildwheel.macos] archs = ["arm64"] @@ -144,8 +143,6 @@ log_cli_level = "INFO" log_cli_format = "%(asctime)s [%(levelname)s] %(message)s (%(filename)s:%(lineno)d)" log_cli_date_format = "%Y-%m-%d %H:%M:%S" -[tool.ty] - [tool.ty.rules] # Ignores guarding optional imports are needed without the dep, unused with it unused-type-ignore-comment = "ignore" diff --git a/uv.lock b/uv.lock index 06a9b69..08cf8fa 100644 --- a/uv.lock +++ b/uv.lock @@ -1756,9 +1756,6 @@ all = [ { name = "huggingface-hub" }, { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "matplotlib", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "onnx" }, { name = "tqdm" }, ] @@ -1782,7 +1779,6 @@ requires-dist = [ { name = "deap", marker = "extra == 'all'", specifier = ">=1.4.0" }, { name = "huggingface-hub", marker = "extra == 'all'", specifier = ">=0.35.0" }, { name = "matplotlib", marker = "extra == 'all'", specifier = ">=3.10.0" }, - { name = "numpy", marker = "extra == 'all'", specifier = ">=1.25.0" }, { name = "onnx", marker = "extra == 'all'", specifier = ">=1.19.0" }, { name = "tqdm", marker = "extra == 'all'", specifier = ">=4.66.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, From 021b6c0b514b7bdc1d6a35a36aa6038cc9ed4670 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 15:52:01 +0000 Subject: [PATCH 05/17] Check the allocator contract once over the registry --- .../integration/test_allocators_on_sources.py | 255 ---------- tests/unit/allocators/test_best_fit.py | 91 +--- tests/unit/allocators/test_contract.py | 206 +++++++++ tests/unit/allocators/test_genetic.py | 133 +----- tests/unit/allocators/test_greedy.py | 23 - tests/unit/allocators/test_hillclimb.py | 112 +---- tests/unit/allocators/test_minimalloc.py | 55 +-- tests/unit/allocators/test_naive.py | 54 +-- tests/unit/allocators/test_omni.py | 435 +++++------------- tests/unit/allocators/test_pinning.py | 135 ++---- tests/unit/allocators/test_random.py | 76 +-- .../allocators/test_simulated_annealing.py | 127 +---- tests/unit/allocators/test_tabu_search.py | 120 +---- tests/unit/allocators/test_telamalloc.py | 134 +----- tests/unit/allocators/test_vector_time.py | 162 +------ tests/unit/test_boundary.py | 61 --- 16 files changed, 466 insertions(+), 1713 deletions(-) delete mode 100644 tests/integration/test_allocators_on_sources.py create mode 100644 tests/unit/allocators/test_contract.py diff --git a/tests/integration/test_allocators_on_sources.py b/tests/integration/test_allocators_on_sources.py deleted file mode 100644 index 02ee391..0000000 --- a/tests/integration/test_allocators_on_sources.py +++ /dev/null @@ -1,255 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -from pathlib import Path - -import pytest -from omnimalloc import allocate -from omnimalloc.allocators.base import BaseAllocator -from omnimalloc.allocators.greedy import ( - GreedyAllocator, - GreedyByAreaAllocator, - GreedyByConflictAllocator, - GreedyByDurationAllocator, - GreedyBySizeAllocator, -) -from omnimalloc.allocators.hillclimb import HillClimbAllocator -from omnimalloc.benchmark.sources.generator import ( - HighContentionSource, - PowerOf2Source, - RandomSource, - SequentialSource, - UniformSource, -) -from omnimalloc.primitives.memory import Memory -from omnimalloc.primitives.pool import Pool -from omnimalloc.validate import validate_allocation -from omnimalloc.visualize import plot_allocation - -from tests.markers import needs_matplotlib - - -def test_greedy_by_duration_with_sequential_source() -> None: - source = SequentialSource(num_allocations=30, seed=42) - pool = Pool(id="test_pool", allocations=source.get_allocations()) - allocated_pool = allocate(pool, GreedyByDurationAllocator()) - - validate_allocation(allocated_pool) - assert allocated_pool.size > 0 - assert all(a.offset is not None for a in allocated_pool.allocations) - - -def test_greedy_by_conflict_with_high_contention() -> None: - source = HighContentionSource(num_allocations=40, time_window=10, seed=42) - pool = Pool(id="test_pool", allocations=source.get_allocations()) - allocated_pool = allocate(pool, GreedyByConflictAllocator()) - - validate_allocation(allocated_pool) - assert allocated_pool.size > 0 - assert all(a.offset is not None for a in allocated_pool.allocations) - - -def test_greedy_by_area_with_power_of_2_source() -> None: - source = PowerOf2Source(num_allocations=25, seed=42) - pool = Pool(id="test_pool", allocations=source.get_allocations()) - allocated_pool = allocate(pool, GreedyByAreaAllocator()) - - validate_allocation(allocated_pool) - assert allocated_pool.size > 0 - assert all(a.offset is not None for a in allocated_pool.allocations) - - -def test_greedy_by_area_with_varying_durations() -> None: - source = RandomSource( - num_allocations=30, - duration_min=1, - duration_max=50, - size_min=1024, - size_max=10240, - seed=42, - ) - pool = Pool(id="test_pool", allocations=source.get_allocations()) - allocated_pool = allocate(pool, GreedyByAreaAllocator()) - - validate_allocation(allocated_pool) - assert allocated_pool.size > 0 - assert all(a.offset is not None for a in allocated_pool.allocations) - - -def test_greedy_with_uniform_source() -> None: - source = UniformSource(num_allocations=20, size=1024, duration=5, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - - allocator = GreedyAllocator() - allocated_pool = allocate(pool, allocator) - - validate_allocation(allocated_pool) - assert all(a.size == 1024 for a in allocated_pool.allocations) - - -def test_greedy_allocators_produce_different_results() -> None: - source = RandomSource(num_allocations=30, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - - allocators = [ - GreedyAllocator(), - GreedyBySizeAllocator(), - GreedyByDurationAllocator(), - GreedyByConflictAllocator(), - GreedyByAreaAllocator(), - ] - - results = [] - for allocator in allocators: - allocated_pool = allocate(pool, allocator) - validate_allocation(allocated_pool) - results.append(allocated_pool.size) - - assert len(set(results)) > 1 - - -def test_greedy_with_memory_hierarchy() -> None: - source = RandomSource(num_allocations=40, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - memory = Memory(id="test_memory", pools=(pool,)) - - allocator = GreedyBySizeAllocator() - allocated_memory = allocate(memory, allocator) - - validate_allocation(allocated_memory) - assert allocated_memory.extent > 0 - assert len(allocated_memory.pools) == 1 - - -def test_greedy_with_large_workload() -> None: - source = RandomSource(num_allocations=200, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - - allocator = GreedyAllocator() - allocated_pool = allocate(pool, allocator) - - validate_allocation(allocated_pool) - assert len(allocated_pool.allocations) == 200 - - -def test_greedy_by_conflict_minimizes_peak_with_contention() -> None: - source = HighContentionSource(num_allocations=50, time_window=15, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - - conflict_allocator = GreedyByConflictAllocator() - basic_allocator = GreedyAllocator() - - conflict_pool = allocate(pool, conflict_allocator) - basic_pool = allocate(pool, basic_allocator) - - validate_allocation(conflict_pool) - validate_allocation(basic_pool) - assert conflict_pool.size <= basic_pool.size * 1.5 - - -def test_greedy_deterministic_across_runs() -> None: - source = RandomSource(num_allocations=50, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - - allocator = GreedyBySizeAllocator() - - result1 = allocate(pool, allocator) - result2 = allocate(pool, allocator) - - assert result1.size == result2.size - offsets1 = [a.offset for a in result1.allocations] - offsets2 = [a.offset for a in result2.allocations] - assert offsets1 == offsets2 - - -def test_hill_climb_with_high_contention() -> None: - source = HighContentionSource(num_allocations=40, time_window=10, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - - allocator = HillClimbAllocator(max_iterations=200) - allocated_pool = allocate(pool, allocator) - - validate_allocation(allocated_pool) - assert all(a.offset is not None for a in allocated_pool.allocations) - - -def test_greedy_with_sequential_produces_small_footprint() -> None: - source = SequentialSource(num_allocations=100, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - - allocator = GreedyAllocator() - allocated_pool = allocate(pool, allocator) - - 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 - - -def test_all_greedy_variants_handle_empty_pool() -> None: - pool = Pool(id="empty_pool", allocations=()) - - allocators = [ - GreedyAllocator(), - GreedyBySizeAllocator(), - GreedyByDurationAllocator(), - GreedyByConflictAllocator(), - GreedyByAreaAllocator(), - ] - - for allocator in allocators: - allocated_pool = allocate(pool, allocator) - validate_allocation(allocated_pool) - assert len(allocated_pool.allocations) == 0 - assert allocated_pool.size == 0 - - -@pytest.mark.parametrize("name", sorted(BaseAllocator.registry())) -def test_every_registered_allocator_on_random_source(name: str) -> None: - source = RandomSource(num_allocations=25, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - - try: - allocator = BaseAllocator.get(name)() - except ImportError as error: - pytest.skip(str(error)) - - allocated_pool = allocate(pool, allocator) - - 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) - - -@needs_matplotlib -def test_greedy_allocators_with_artifacts(artifacts_dir: Path) -> None: - source = RandomSource(num_allocations=30, seed=42) - allocations = source.get_allocations() - pool = Pool(id="test_pool", allocations=allocations) - - allocators = { - "greedy": GreedyAllocator(), - "greedy_by_size": GreedyBySizeAllocator(), - "greedy_by_duration": GreedyByDurationAllocator(), - "greedy_by_conflict": GreedyByConflictAllocator(), - "greedy_by_area": GreedyByAreaAllocator(), - } - - for name, allocator in allocators.items(): - allocated_pool = allocate(pool, allocator) - validate_allocation(allocated_pool) - - output_file = artifacts_dir / f"{name}.pdf" - plot_allocation(allocated_pool, output_file) - assert output_file.exists() - assert output_file.stat().st_size > 0 diff --git a/tests/unit/allocators/test_best_fit.py b/tests/unit/allocators/test_best_fit.py index c0738ee..ff5b8bd 100644 --- a/tests/unit/allocators/test_best_fit.py +++ b/tests/unit/allocators/test_best_fit.py @@ -4,71 +4,11 @@ from omnimalloc.allocators.best_fit import BestFitAllocator from omnimalloc.allocators.greedy import GreedyAllocator -from omnimalloc.analysis import placement_pressure -from omnimalloc.primitives import Allocation, Pool -from omnimalloc.validate import validate_allocation +from omnimalloc.primitives import Allocation -def _assert_valid(result: tuple[Allocation, ...]) -> None: - validate_allocation(Pool(id="test_pool", allocations=result)) - - -def test_best_fit_empty() -> None: - allocator = BestFitAllocator() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_best_fit_single() -> None: - allocator = BestFitAllocator() - alloc = Allocation(id=1, size=100, start=0, end=10) - result = allocator.allocate((alloc,)) - assert len(result) == 1 - assert result[0].offset == 0 - - -def test_best_fit_no_temporal_overlap_shares_offset() -> None: - allocator = BestFitAllocator() - 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)) - by_id = {a.id: a for a in result} - assert by_id[1].offset == 0 - assert by_id[2].offset == 0 - - -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_valid(result) - assert placement_pressure(result) == 500 - - -def test_best_fit_preserves_allocations() -> None: - allocator = BestFitAllocator() - 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_best_fit_deterministic() -> None: - allocs = tuple( - Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 3, end=i % 3 + i % 7 + 1) - for i in range(20) - ) - result1 = BestFitAllocator().allocate(allocs) - result2 = BestFitAllocator().allocate(allocs) - assert all(r1.offset == r2.offset for r1, r2 in zip(result1, result2, strict=True)) - - -def test_best_fit_chooses_tighter_gap_than_first_fit() -> None: - allocs = ( +def test_best_fit_takes_the_tighter_gap_that_first_fit_passes_over() -> None: + allocations = ( Allocation(id="a", size=10, start=0, end=100), Allocation(id="filler1", size=50, start=1, end=2), Allocation(id="b", size=10, start=1, end=55), @@ -76,26 +16,5 @@ def test_best_fit_chooses_tighter_gap_than_first_fit() -> None: Allocation(id="c", size=10, start=3, end=55), Allocation(id="t", size=15, start=50, end=55), ) - - first_fit = {a.id: a.offset for a in GreedyAllocator().allocate(allocs)} - best_fit = {a.id: a.offset for a in BestFitAllocator().allocate(allocs)} - - assert first_fit["t"] == 10 - assert best_fit["t"] == 45 - _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: - allocator = BestFitAllocator() - allocs = ( - 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), - ) - result = allocator.allocate(allocs) - _assert_valid(result) + assert GreedyAllocator().allocate(allocations)[-1].offset == 10 + assert BestFitAllocator().allocate(allocations)[-1].offset == 45 diff --git a/tests/unit/allocators/test_contract.py b/tests/unit/allocators/test_contract.py new file mode 100644 index 0000000..dbe288e --- /dev/null +++ b/tests/unit/allocators/test_contract.py @@ -0,0 +1,206 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import inspect +import random + +import pytest +from omnimalloc import validate_allocation +from omnimalloc.allocators import BaseAllocator, available_allocators +from omnimalloc.analysis import antichain_pressure, placement_pressure +from omnimalloc.benchmark.sources import ( + ConcurrentTilingSource, + HighContentionSource, + PinwheelSource, + SyncPatternSource, + TilingSource, +) +from omnimalloc.primitives import Allocation + +FAST = { + "genetic": {"population_size": 10, "max_generations": 3}, + "hill_climb": {"max_iterations": 100}, + "random": {"num_trials": 10}, + "simulated_annealing": {"max_iterations": 200}, + "tabu_search": {"max_iterations": 30}, +} + +NAMES = sorted( + name + for name in available_allocators() + if BaseAllocator.get(name).__module__.startswith("omnimalloc.") +) +VECTOR = [name for name in NAMES if BaseAllocator.get(name).supports_vector_time] +PINNED = [name for name in NAMES if BaseAllocator.get(name).supports_pinned] +SCALAR_ONLY = sorted(set(NAMES) - set(VECTOR)) +UNPINNED = sorted(set(NAMES) - set(PINNED)) +SEARCHES = ("genetic", "hill_climb", "simulated_annealing", "tabu_search", "telamalloc") + + +def build(name: str, **kwargs: object) -> BaseAllocator: + try: + return BaseAllocator.get(name)(**kwargs) + except ImportError as error: # an optional dependency is missing + pytest.skip(str(error)) + + +def fast(name: str) -> BaseAllocator: + return build(name, **FAST.get(name, {})) + + +def _takes(name: str, parameter: str) -> bool: + return parameter in inspect.signature(BaseAllocator.get(name)).parameters + + +def _scalar(seed: int = 0, count: int = 40) -> tuple[Allocation, ...]: + rng = random.Random(seed) + allocations = [] + for i in range(count): + start = rng.randint(0, 30) + size, duration = rng.randint(1, 512), rng.randint(1, 8) + allocations.append( + Allocation(id=i, size=size, start=start, end=start + duration) + ) + return tuple(allocations) + + +def _vector(seed: int = 0, count: int = 24, dim: int = 3) -> tuple[Allocation, ...]: + rng = random.Random(seed) + allocations = [] + for i in range(count): + start = tuple(rng.randint(0, 6) for _ in range(dim)) + delta = [rng.randint(0, 3) for _ in range(dim)] + delta[rng.randrange(dim)] += 1 + end = tuple(s + d for s, d in zip(start, delta, strict=True)) + allocations.append( + Allocation(id=i, size=rng.randint(1, 256), start=start, end=end) + ) + return tuple(allocations) + + +SCALAR_INSTANCES = { + "random": _scalar(), + "contention": HighContentionSource( + num_allocations=40, time_window=10, seed=1 + ).get_allocations(), + "tiling": TilingSource(num_allocations=64, seed=0).get_allocations(), + "pinwheel": PinwheelSource(num_allocations=65, seed=0).get_allocations(), +} +VECTOR_INSTANCES = { + "random_vector": _vector(), + "sync_pattern": SyncPatternSource( + num_allocations=40, num_threads=3, pattern="sparse", seed=2 + ).get_allocations(), + "concurrent_tiling": ConcurrentTilingSource( + num_allocations=48, num_threads=4, capacity=1 << 20, seed=0 + ).get_allocations(), +} +CASES = [ + *((name, instance) for name in NAMES for instance in SCALAR_INSTANCES), + *((name, instance) for name in VECTOR for instance in VECTOR_INSTANCES), +] +INSTANCES = SCALAR_INSTANCES | VECTOR_INSTANCES + + +def _offsets(placed: tuple[Allocation, ...]) -> list[int | None]: + return [alloc.offset for alloc in placed] + + +@pytest.mark.parametrize("name", NAMES) +def test_nothing_places_as_nothing(name: str) -> None: + assert fast(name).allocate(()) == () + + +@pytest.mark.parametrize("name", NAMES) +def test_a_single_allocation_lands_at_zero(name: str) -> None: + placed = fast(name).allocate((Allocation(id=1, size=100, start=0, end=10),)) + assert _offsets(placed) == [0] + + +@pytest.mark.parametrize("name", sorted(set(NAMES) - {"naive"})) +def test_disjoint_lifetimes_share_offset_zero(name: str) -> None: + allocations = tuple( + Allocation(id=i, size=10 * (i + 1), start=i, end=i + 1) for i in range(4) + ) + assert _offsets(fast(name).allocate(allocations)) == [0, 0, 0, 0] + + +@pytest.mark.parametrize("name", NAMES) +def test_identical_lifetimes_stack(name: str) -> None: + allocations = tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(5)) + placed = fast(name).allocate(allocations) + validate_allocation(placed) + assert placement_pressure(placed) == 500 + + +@pytest.mark.parametrize(("name", "instance"), CASES) +def test_placement_is_valid_bounded_and_in_input_order( + name: str, instance: str +) -> None: + allocations = INSTANCES[instance] + placed = fast(name).allocate(allocations) + validate_allocation(placed) + assert [(a.id, a.size, a.start, a.end) for a in placed] == [ + (a.id, a.size, a.start, a.end) for a in allocations + ] + bound = antichain_pressure(allocations, work_budget=None) + assert bound <= placement_pressure(placed) <= sum(a.size for a in allocations) + + +@pytest.mark.parametrize("name", NAMES) +def test_placement_is_deterministic(name: str) -> None: + allocations = SCALAR_INSTANCES["random"] + allocator = fast(name) + first = _offsets(allocator.allocate(allocations)) + assert _offsets(allocator.allocate(allocations)) == first + assert _offsets(fast(name).allocate(allocations)) == first + + +@pytest.mark.parametrize("name", PINNED) +def test_a_pin_is_honored(name: str) -> None: + allocations = ( + Allocation(id="pinned", size=10, start=0, end=5, offset=100), + Allocation(id="free", size=10, start=0, end=5), + ) + placed = fast(name).allocate(allocations) + validate_allocation(placed) + assert placed[0].offset == 100 + + +@pytest.mark.parametrize("name", UNPINNED) +def test_a_pin_is_rejected(name: str) -> None: + allocations = (Allocation(id=1, size=10, start=0, end=5, offset=0),) + with pytest.raises(ValueError, match="cannot honor pinned offsets"): + fast(name).allocate(allocations) + + +@pytest.mark.parametrize("name", SCALAR_ONLY) +def test_vector_time_is_rejected(name: str) -> None: + with pytest.raises(ValueError, match=r"requires scalar .* 3-dim vector clocks"): + fast(name).allocate(VECTOR_INSTANCES["random_vector"]) + + +def test_only_the_interval_solvers_need_scalar_time_and_no_pins() -> None: + assert SCALAR_ONLY == UNPINNED == ["minimalloc", "supermalloc", "telamalloc"] + + +@pytest.mark.parametrize("name", SEARCHES) +def test_a_search_never_loses_to_greedy_by_size(name: str) -> None: + allocations = _scalar(seed=3) + baseline = placement_pressure(fast("greedy_by_size").allocate(allocations)) + assert placement_pressure(fast(name).allocate(allocations)) <= baseline + + +@pytest.mark.parametrize("name", [name for name in NAMES if _takes(name, "seed")]) +@pytest.mark.parametrize("seed", [-1, 2**64]) +def test_an_out_of_range_seed_is_rejected(name: str, seed: int) -> None: + with pytest.raises(ValueError, match="seed must be in"): + build(name, seed=seed) + + +@pytest.mark.parametrize("name", [name for name in NAMES if _takes(name, "timeout")]) +@pytest.mark.parametrize("timeout", [0, -1.0]) +def test_a_non_positive_timeout_is_rejected(name: str, timeout: float) -> None: + with pytest.raises(ValueError, match="timeout must be positive or None"): + build(name, timeout=timeout) diff --git a/tests/unit/allocators/test_genetic.py b/tests/unit/allocators/test_genetic.py index c8d5e87..19bf4e5 100644 --- a/tests/unit/allocators/test_genetic.py +++ b/tests/unit/allocators/test_genetic.py @@ -3,120 +3,37 @@ # import pytest +from omnimalloc import Allocation, allocate from omnimalloc.allocators.genetic import HAS_DEAP, GeneticAllocator -from omnimalloc.allocators.greedy import GreedyBySizeAllocator from omnimalloc.analysis import placement_pressure -from omnimalloc.primitives import Allocation -from omnimalloc.primitives.pool import Pool -from omnimalloc.validate import validate_allocation +from omnimalloc.benchmark.sources import HighContentionSource pytestmark = pytest.mark.skipif(not HAS_DEAP, reason="deap not installed") -def _fast_allocator(seed: int = 42) -> GeneticAllocator: - return GeneticAllocator(seed=seed, population_size=20, max_generations=5) - - -def _allocs(count: int) -> tuple[Allocation, ...]: - return tuple( - Allocation(id=i, size=(i % 5 + 1) * 10, start=i % 3, end=i % 3 + i % 4 + 1) - for i in range(count) - ) - - -def test_genetic_empty() -> None: - result = _fast_allocator().allocate(()) - assert len(result) == 0 - - -def test_genetic_single() -> None: - result = _fast_allocator().allocate((Allocation(id=1, size=100, start=0, end=10),)) - assert len(result) == 1 - assert result[0].offset == 0 - - -def test_genetic_rejects_invalid_population_size() -> None: - with pytest.raises(ValueError, match="population_size must be positive"): - GeneticAllocator(population_size=0) - - -def test_genetic_rejects_negative_generations() -> None: - with pytest.raises(ValueError, match="max_generations must be non-negative"): - GeneticAllocator(max_generations=-1) - - -def test_genetic_rejects_out_of_range_probabilities() -> None: - with pytest.raises(ValueError, match="must be in"): - GeneticAllocator(crossover_prob=1.5) - with pytest.raises(ValueError, match="must be in"): - GeneticAllocator(mutation_prob=-0.1) - - -def test_genetic_rejects_invalid_tournament_size() -> None: - with pytest.raises(ValueError, match="tournament_size must be positive"): - GeneticAllocator(tournament_size=0) - - -def test_genetic_rejects_negative_timeout() -> None: - with pytest.raises(ValueError, match="timeout must be positive or None"): - GeneticAllocator(timeout=-1.0) - - -def test_genetic_rejects_duplicate_ids() -> None: - alloc = Allocation(id=1, size=100, start=0, end=10) - with pytest.raises(ValueError, match="allocation ids must be unique"): - _fast_allocator().allocate((alloc, alloc)) - - -def test_genetic_produces_valid_allocation() -> None: - allocs = _allocs(20) - result = _fast_allocator().allocate(allocs) - 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) - - -def test_genetic_deterministic_for_same_seed() -> None: - allocs = _allocs(20) - result1 = _fast_allocator(seed=7).allocate(allocs) - result2 = _fast_allocator(seed=7).allocate(allocs) - assert {a.id: a.offset for a in result1} == {a.id: a.offset for a in result2} - - -def test_genetic_never_worse_than_greedy_by_size_seed() -> None: - allocs = _allocs(30) - baseline = placement_pressure(GreedyBySizeAllocator().allocate(allocs)) - result = _fast_allocator().allocate(allocs) - assert placement_pressure(result) <= baseline - - -def test_genetic_improves_or_matches_adversarial_insertion_order() -> None: - allocs = tuple( +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"population_size": 0}, "population_size must be positive"), + ({"max_generations": -1}, "max_generations must be non-negative"), + ({"crossover_prob": 1.5}, "must be in"), + ({"mutation_prob": -0.1}, "must be in"), + ({"tournament_size": 0}, "tournament_size must be positive"), + ], +) +def test_genetic_rejects(kwargs: dict[str, float], match: str) -> None: + with pytest.raises(ValueError, match=match): + GeneticAllocator(**kwargs) + + +def test_genetic_seeds_contention_on_linearized_clocks() -> None: + scalar = HighContentionSource(num_allocations=40, seed=6).get_allocations() + vector = tuple( Allocation( - id=i, - size=10 if i < 15 else 1000, - start=(i * 2) % 10, - end=(i * 2) % 10 + 3, + id=a.id, size=a.size, start=(a.start, 2 * a.start), end=(a.end, 2 * a.end) ) - for i in range(30) + for a in scalar ) - result = _fast_allocator().allocate(allocs) - 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: - import random - - random.seed(7) - expected = [random.random() for _ in range(3)] - random.seed(7) - _fast_allocator().allocate(_allocs(10)) - assert [random.random() for _ in range(3)] == expected - - -@pytest.mark.parametrize("seed", [-1, 2**64]) -def test_genetic_rejects_out_of_range_seed(seed: int) -> None: - with pytest.raises(ValueError, match="seed must be in"): - GeneticAllocator(seed=seed) + seeded = GeneticAllocator(population_size=7, max_generations=0).allocate(vector) + contention = allocate(vector, "greedy_by_contention") + assert placement_pressure(seeded) <= placement_pressure(contention) diff --git a/tests/unit/allocators/test_greedy.py b/tests/unit/allocators/test_greedy.py index 6f3b262..81f1c3b 100644 --- a/tests/unit/allocators/test_greedy.py +++ b/tests/unit/allocators/test_greedy.py @@ -40,11 +40,6 @@ def _alloc(i: int, size: int, start: int, end: int) -> Allocation: return Allocation(id=i, size=size, start=start, end=end) -@pytest.mark.parametrize("allocator_cls", [*SINGLE_ORDERS, GreedyByAllAllocator]) -def test_greedy_allocator_empty(allocator_cls: type) -> None: - assert allocator_cls().allocate(()) == () - - @pytest.mark.parametrize( ("allocations", "offsets"), [ @@ -168,24 +163,6 @@ def test_greedy_allocator_places_in_its_order( assert [a.offset for a in allocator_cls().allocate(allocs)] == offsets -@pytest.mark.parametrize("allocator_cls", [*SINGLE_ORDERS, GreedyByAllAllocator]) -def test_greedy_places_vector_time(allocator_cls: type) -> None: - allocs = tuple( - Allocation(id=i, size=8, start=(i, 0), end=(i + 2, 1)) for i in range(20) - ) - validate_allocation(allocator_cls().allocate(allocs)) - - -@pytest.mark.parametrize( - "allocator_cls", - [GreedyByDurationAllocator, GreedyBySizeAllocator, GreedyByAllAllocator], -) -def test_greedy_deterministic(allocator_cls: type) -> None: - allocator = allocator_cls() - allocs = tuple(_alloc(i, (i % 5 + 1) * 100, 0, i % 7 + 1) for i in range(20)) - assert allocator.allocate(allocs) == allocator.allocate(allocs) - - def test_greedy_by_all_picks_best_peak() -> None: allocs = ( _alloc(1, 100, 0, 5), diff --git a/tests/unit/allocators/test_hillclimb.py b/tests/unit/allocators/test_hillclimb.py index 8c4c7c5..162250e 100644 --- a/tests/unit/allocators/test_hillclimb.py +++ b/tests/unit/allocators/test_hillclimb.py @@ -4,131 +4,27 @@ import pytest 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 _assert_valid(result: tuple[Allocation, ...]) -> None: - validate_allocation(Pool(id="test_pool", allocations=result)) - - -def test_hillclimb_empty() -> None: - allocator = HillClimbAllocator() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_hillclimb_single() -> None: - allocator = HillClimbAllocator() - alloc = Allocation(id=1, size=100, start=0, end=10) - result = allocator.allocate((alloc,)) - assert len(result) == 1 - assert result[0].offset == 0 - - -def test_hillclimb_rejects_positive_iterations() -> None: +def test_hillclimb_rejects_non_positive_iterations() -> None: with pytest.raises(ValueError, match="max_iterations must be positive"): HillClimbAllocator(max_iterations=0) -def test_hillclimb_rejects_negative_timeout() -> None: - with pytest.raises(ValueError, match="timeout must be positive or None"): - HillClimbAllocator(timeout=-1.0) - - -def test_hillclimb_preserves_allocations() -> None: - allocator = HillClimbAllocator() - 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_hillclimb_produces_valid_allocation() -> None: - allocator = HillClimbAllocator() - allocs = ( - 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), - ) - result = allocator.allocate(allocs) - _assert_valid(result) - - -def test_hillclimb_no_temporal_overlap_shares_offset() -> None: - allocator = HillClimbAllocator() - 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)) - by_id = {a.id: a for a in result} - assert by_id[1].offset == 0 - assert by_id[2].offset == 0 - - -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_valid(result) - assert placement_pressure(result) == 500 - - def test_hillclimb_survives_rejected_step_undo() -> None: - allocator = HillClimbAllocator(max_iterations=6) - allocs = ( + allocations = ( Allocation(id=0, size=50, start=4, end=7), Allocation(id=1, size=40, start=2, end=6), Allocation(id=2, size=30, start=2, end=4), Allocation(id=3, size=40, start=4, end=5), Allocation(id=4, size=70, start=2, end=3), ) - result = allocator.allocate(allocs) - assert len(result) == len(allocs) - _assert_valid(result) - - -def test_hillclimb_deterministic() -> None: - allocs = tuple( - Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 3, end=i % 3 + i % 7 + 1) - for i in range(20) - ) - result1 = HillClimbAllocator(max_iterations=50).allocate(allocs) - result2 = HillClimbAllocator(max_iterations=50).allocate(allocs) - assert all(r1.offset == r2.offset for r1, r2 in zip(result1, result2, strict=True)) + validate_allocation(HillClimbAllocator(max_iterations=6).allocate(allocations)) -def test_hillclimb_not_worse_than_greedy_by_size() -> None: - from omnimalloc.allocators.greedy import GreedyBySizeAllocator - - allocs = tuple( - Allocation( - id=i, - size=(i * 37 % 90 + 10), - start=(i * 13) % 40, - end=(i * 13) % 40 + (i * 7) % 15 + 1, - ) - for i in range(40) - ) - hillclimb = HillClimbAllocator(seed=42).allocate(allocs) - greedy = GreedyBySizeAllocator().allocate(allocs) - _assert_valid(hillclimb) - assert placement_pressure(hillclimb) <= placement_pressure(greedy) - - -def test_hillclimb_repr_omits_the_pinned_kernel_knobs() -> None: +def test_hillclimb_repr_omits_the_pinned_annealing_knobs() -> None: assert repr(HillClimbAllocator()) == ( "HillClimbAllocator(seed=42, max_iterations=3000, timeout=3.0)" ) - - -@pytest.mark.parametrize("seed", [-1, 2**64]) -def test_hillclimb_rejects_out_of_range_seed(seed: int) -> None: - with pytest.raises(ValueError, match="seed must be in"): - HillClimbAllocator(seed=seed) diff --git a/tests/unit/allocators/test_minimalloc.py b/tests/unit/allocators/test_minimalloc.py index 4561c8e..525410d 100644 --- a/tests/unit/allocators/test_minimalloc.py +++ b/tests/unit/allocators/test_minimalloc.py @@ -6,69 +6,24 @@ 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 pytestmark = pytest.mark.skipif(not HAS_MINIMALLOC, reason="minimalloc not installed") -def test_minimalloc_empty() -> None: - result = MinimallocAllocator().allocate(()) - assert len(result) == 0 - - -def test_minimalloc_single() -> None: - result = MinimallocAllocator().allocate( - (Allocation(id=1, size=100, start=0, end=10),) - ) - assert len(result) == 1 - assert result[0].offset == 0 - - def test_minimalloc_preserves_id_types() -> None: - allocs = ( + allocations = ( Allocation(id=1, size=100, start=0, end=10), Allocation(id="two", size=50, start=5, end=15), ) - result = MinimallocAllocator().allocate(allocs) - assert {a.id for a in result} == {1, "two"} - - -def test_minimalloc_rejects_duplicate_ids() -> None: - allocs = ( - Allocation(id=1, size=100, start=0, end=10), - Allocation(id=1, size=50, start=5, end=15), - ) - with pytest.raises(ValueError, match="allocation ids must be unique"): - MinimallocAllocator().allocate(allocs) - - -def test_minimalloc_no_temporal_overlap_shares_offset() -> None: - allocs = ( - Allocation(id=1, size=100, start=0, end=10), - Allocation(id=2, size=100, start=10, end=20), - ) - result = MinimallocAllocator().allocate(allocs) - assert placement_pressure(result) == 100 - - -def test_minimalloc_produces_valid_allocation() -> None: - allocs = tuple( - Allocation(id=i, size=(i % 5 + 1) * 64, start=i % 4, end=i % 4 + i % 3 + 1) - for i in range(20) - ) - result = MinimallocAllocator().allocate(allocs) - validate_allocation(Pool(id="test_pool", allocations=result)) - assert {a.id for a in result} == {a.id for a in allocs} + placed = MinimallocAllocator().allocate(allocations) + assert [a.id for a in placed] == [1, "two"] def test_minimalloc_finds_optimal_packing() -> None: - allocs = ( + allocations = ( 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), ) - result = MinimallocAllocator().allocate(allocs) - validate_allocation(Pool(id="test_pool", allocations=result)) - assert placement_pressure(result) == 250 + assert placement_pressure(MinimallocAllocator().allocate(allocations)) == 250 diff --git a/tests/unit/allocators/test_naive.py b/tests/unit/allocators/test_naive.py index bbc104a..1131024 100644 --- a/tests/unit/allocators/test_naive.py +++ b/tests/unit/allocators/test_naive.py @@ -2,48 +2,24 @@ # SPDX-License-Identifier: Apache-2.0 # +import pytest from omnimalloc.allocators.naive import NaiveAllocator from omnimalloc.primitives import Allocation -def test_naive_empty() -> None: - result = NaiveAllocator().allocate(()) - assert len(result) == 0 - - -def test_naive_single() -> None: - result = NaiveAllocator().allocate((Allocation(id=1, size=100, start=0, end=10),)) - assert len(result) == 1 - assert result[0].offset == 0 - - -def test_naive_stacks_sequentially() -> None: - allocs = tuple(Allocation(id=i, size=100, start=0, end=10) for i in range(4)) - result = NaiveAllocator().allocate(allocs) - assert [a.offset for a in result] == [0, 100, 200, 300] - - -def test_naive_does_not_reuse_freed_space() -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=10) - alloc2 = Allocation(id=2, size=200, start=10, end=20) - result = NaiveAllocator().allocate((alloc1, alloc2)) - assert result[0].offset == 0 - assert result[1].offset == 100 - - -def test_naive_preserves_input_order_and_ids() -> None: - allocs = ( - Allocation(id="b", size=50, start=0, end=5), - Allocation(id="a", size=100, start=5, end=10), - ) - result = NaiveAllocator().allocate(allocs) - assert [a.id for a in result] == ["b", "a"] - - -def test_naive_mixed_sizes_cumulative_offsets() -> None: - sizes = (10, 250, 3, 42) +@pytest.mark.parametrize( + ("allocations", "offsets"), + [ + ([(100, 0, 10)] * 4, [0, 100, 200, 300]), + ([(100, 0, 10), (200, 10, 20)], [0, 100]), + ([(10, 0, 2), (250, 1, 3), (3, 2, 4), (42, 3, 5)], [0, 10, 260, 263]), + ], +) +def test_naive_stacks_in_input_order_without_reuse( + allocations: list[tuple[int, int, int]], offsets: list[int] +) -> None: allocs = tuple( - Allocation(id=i, size=size, start=i, end=i + 2) for i, size in enumerate(sizes) + Allocation(id=i, size=size, start=start, end=end) + for i, (size, start, end) in enumerate(allocations) ) - result = NaiveAllocator().allocate(allocs) - assert [a.offset for a in result] == [0, 10, 260, 263] + assert [a.offset for a in NaiveAllocator().allocate(allocs)] == offsets diff --git a/tests/unit/allocators/test_omni.py b/tests/unit/allocators/test_omni.py index 10202d2..7e4a1ae 100644 --- a/tests/unit/allocators/test_omni.py +++ b/tests/unit/allocators/test_omni.py @@ -5,40 +5,80 @@ import random import pytest -from omnimalloc.allocators import ( - BaseAllocator, - GreedyAllocator, - GreedyByAreaAllocator, - GreedyByConflictAllocator, - GreedyByConflictSizeAllocator, - GreedyByContentionAllocator, - GreedyByDurationAllocator, - GreedyBySizeAllocator, - GreedyByStartAllocator, - NaiveAllocator, - OmniAllocator, -) +from omnimalloc.allocators import BaseAllocator, OmniAllocator from omnimalloc.analysis import antichain_pressure, placement_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 -from omnimalloc.validate import validate_allocation +from omnimalloc.benchmark.sources import HighContentionSource, RandomSource +from omnimalloc.common.constants import DEFAULT_WORK_BUDGET +from omnimalloc.primitives import Allocation + +MATCHED = ( + Allocation(id=0, size=30, start=(0, 2), end=(0, 3)), + Allocation(id=1, size=2, start=(0, 2), end=(0, 3)), + Allocation(id=2, size=19, start=(0, 1), end=(2, 1)), + Allocation(id=3, size=6, start=(4, 0), end=(6, 2)), + Allocation(id=4, size=23, start=(4, 4), end=(5, 6)), + Allocation(id=5, size=9, start=(1, 1), end=(1, 3)), + Allocation(id=6, size=24, start=(0, 3), end=(2, 3)), + Allocation(id=7, size=11, start=(2, 3), end=(3, 3)), + Allocation(id=8, size=26, start=(4, 0), end=(4, 1)), + Allocation(id=9, size=26, start=(2, 4), end=(4, 6)), + Allocation(id=10, size=29, start=(1, 4), end=(3, 5)), +) +WIDENED = ( + Allocation(id=0, size=1, start=(6, 12), end=(9, 15)), + Allocation(id=1, size=2, start=(16, 7), end=(24, 11)), + Allocation(id=2, size=16, start=(17, 8), end=(20, 14)), + Allocation(id=3, size=16, start=(8, 2), end=(14, 10)), + Allocation(id=4, size=4, start=(0, 4), end=(7, 12)), + Allocation(id=5, size=32, start=(12, 19), end=(13, 25)), + Allocation(id=6, size=2, start=(5, 9), end=(12, 17)), + Allocation(id=7, size=8, start=(9, 17), end=(14, 23)), + Allocation(id=8, size=1, start=(16, 4), end=(22, 9)), + Allocation(id=9, size=16, start=(6, 12), end=(9, 19)), + Allocation(id=10, size=8, start=(11, 20), end=(15, 24)), + Allocation(id=11, size=8, start=(17, 9), end=(20, 10)), + Allocation(id=12, size=16, start=(19, 9), end=(25, 12)), + Allocation(id=13, size=32, start=(19, 7), end=(24, 11)), + Allocation(id=14, size=16, start=(3, 7), end=(9, 9)), +) + +TIED_STARTS = ( + Allocation(id="f", size=3, start=0, end=1), + Allocation(id="i", size=3, start=2, end=5), + Allocation(id="c", size=3, start=0, end=3), + Allocation(id="a", size=1, start=0, end=2), + Allocation(id="b", size=2, start=1, end=4), +) +CONTENTION_GAP = ( + Allocation(id=0, size=1, start=0, end=1), + Allocation(id=1, size=3, start=0, end=2), + Allocation(id=2, size=2, start=1, end=4), + Allocation(id=3, size=2, start=1, end=3), + Allocation(id=4, size=4, start=3, end=8), +) +GREEDY_ORDERS = ( + "greedy", + "greedy_by_size", + "greedy_by_duration", + "greedy_by_area", + "greedy_by_conflict", + "greedy_by_conflict_size", + "greedy_by_start", +) -def _random_scalar(n: int, seed: int) -> tuple[Allocation, ...]: - rng = random.Random(seed) - allocations = [] - for i in range(n): - start = rng.randint(0, 50) - allocations.append( - Allocation( - id=i, - size=rng.randint(1, 128), - start=start, - end=start + rng.randint(1, 20), - ) - ) - return tuple(allocations) +def _peak(allocations: tuple[Allocation, ...], linearize_budget: int | None) -> int: + placed = OmniAllocator(linearize_budget=linearize_budget).allocate(allocations) + return placement_pressure(placed) + + +def _greedy_peak(allocations: tuple[Allocation, ...], name: str) -> int: + return placement_pressure(BaseAllocator.get(name)().allocate(allocations)) + + +def _best_greedy_peak(allocations: tuple[Allocation, ...]) -> int: + orders = (*GREEDY_ORDERS, "greedy_by_contention") + return min(_greedy_peak(allocations, name) for name in orders) def _random_vector(n: int, dim: int, seed: int) -> tuple[Allocation, ...]: @@ -47,253 +87,55 @@ def _random_vector(n: int, dim: int, seed: int) -> tuple[Allocation, ...]: for i in range(n): start = tuple(rng.randint(0, 6) for _ in range(dim)) delta = [rng.randint(0, 2) for _ in range(dim)] - if sum(delta) == 0: - delta[rng.randrange(dim)] = 1 + delta[rng.randrange(dim)] += 1 + end = tuple(s + d for s, d in zip(start, delta, strict=True)) allocations.append( - Allocation( - id=i, - size=rng.randint(1, 64), - start=start, - end=tuple(s + d for s, d in zip(start, delta, strict=True)), - ) + Allocation(id=i, size=rng.randint(1, 64), start=start, end=end) ) return tuple(allocations) -def _two_plus_two() -> tuple[Allocation, ...]: - return ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=16, start=(1, 0), end=(2, 0)), - Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), - Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), - ) - - -GREEDY_ORDERS = ( - GreedyAllocator, - GreedyBySizeAllocator, - GreedyByDurationAllocator, - GreedyByAreaAllocator, - GreedyByConflictAllocator, - GreedyByConflictSizeAllocator, - GreedyByStartAllocator, - GreedyByContentionAllocator, -) - - -def _best_greedy_peak(allocations: tuple[Allocation, ...]) -> int: - return min(placement_pressure(cls().allocate(allocations)) for cls in GREEDY_ORDERS) - - -def test_omni_is_registered_and_supports_vector_time() -> None: - assert BaseAllocator.get("omni") is OmniAllocator - assert OmniAllocator.supports_vector_time is True - - def test_omni_rejects_negative_linearize_budget() -> None: with pytest.raises(ValueError, match="linearize_budget"): OmniAllocator(linearize_budget=-1) -def test_omni_empty_returns_empty() -> None: - assert OmniAllocator().allocate(()) == () - - -def test_omni_single_allocation_at_offset_zero() -> None: - placed = OmniAllocator().allocate((Allocation(id=1, size=64, start=0, end=4),)) - assert placed[0].offset == 0 - - -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 antichain_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 placement_pressure(omni) <= placement_pressure(naive) - - -def test_omni_preserves_vector_times_and_metadata() -> None: - allocations = _two_plus_two() - placed = OmniAllocator().allocate(allocations) - assert [(a.id, a.size, a.start, a.end) for a in placed] == [ - (a.id, a.size, a.start, a.end) for a in allocations - ] - assert all(a.offset is not None for a in placed) - - -def test_omni_non_linearizable_placement_is_valid() -> None: - placed = OmniAllocator().allocate(_two_plus_two()) - validate_allocation(Pool(id="p", allocations=placed)) - assert placement_pressure(placed) >= 64 + 16 - - -def test_omni_lockstep_matches_scalar_peak() -> None: - scalar = _random_scalar(100, seed=3) - lockstep = tuple( - Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) - for a in scalar - ) - lockstep_peak = placement_pressure(OmniAllocator().allocate(lockstep)) - assert lockstep_peak == placement_pressure(OmniAllocator().allocate(scalar)) - - -def test_omni_is_deterministic() -> None: - source = SyncPatternSource(num_allocations=64, num_threads=4, pattern="groups") - allocations = source.get_allocations() - first = OmniAllocator().allocate(allocations) - second = OmniAllocator().allocate(allocations) - assert [a.offset for a in first] == [a.offset for a in second] - - -def test_omni_keeps_existing_offsets_pinned() -> None: - allocations = tuple( - Allocation(id=i, size=32, start=0, end=4, offset=1024 * (i + 1)) - for i in range(4) - ) - placed = OmniAllocator().allocate(allocations) - assert [a.offset for a in placed] == [1024, 2048, 3072, 4096] - - -def test_omni_packs_free_allocations_around_pins() -> None: - allocations = ( - Allocation(id="pinned", size=32, start=0, end=4, offset=64), - *(Allocation(id=i, size=32, start=0, end=4) for i in range(3)), - ) - placed = OmniAllocator().allocate(allocations) - offsets = {a.id: a.offset for a in placed} - assert offsets["pinned"] == 64 - assert sorted(offsets[i] for i in range(3)) == [0, 32, 96] - assert placement_pressure(placed) == 128 - - -def test_omni_handles_extreme_durations() -> None: - allocations = tuple( - Allocation(id=i, size=16 + i, start=0, end=10**18) for i in range(4) - ) - placed = OmniAllocator().allocate(allocations) - validate_allocation(Pool(id="p", allocations=placed)) - assert placement_pressure(placed) == sum(a.size for a in allocations) - - -def test_omni_rejects_duplicate_ids() -> None: - allocations = ( - Allocation(id=1, size=8, start=0, end=4), - Allocation(id=1, size=8, start=0, end=4), - ) - with pytest.raises(ValueError, match="unique"): - OmniAllocator().allocate(allocations) - - -def test_omni_rejects_mixed_dimensions() -> None: - allocations = ( - Allocation(id=1, size=8, start=0, end=4), - Allocation(id=2, size=8, start=(0, 1), end=(2, 2)), - ) - with pytest.raises(ValueError, match="dimension"): - OmniAllocator().allocate(allocations) - - -def test_omni_matches_greedy_portfolio_on_scalar_input() -> None: - allocations = _random_scalar(120, seed=4) - omni = placement_pressure(OmniAllocator().allocate(allocations)) - assert omni == _best_greedy_peak(allocations) - - -def test_omni_matches_greedy_portfolio_on_tied_starts() -> None: - allocations = ( - Allocation(id="f", size=3, start=0, end=1), - Allocation(id="i", size=3, start=2, end=5), - Allocation(id="c", size=3, start=0, end=3), - Allocation(id="a", size=1, start=0, end=2), - Allocation(id="b", size=2, start=1, end=4), - ) - omni = placement_pressure(OmniAllocator().allocate(allocations)) - assert omni == _best_greedy_peak(allocations) - - -def test_omni_without_linearization_matches_greedy_on_non_interval_input() -> None: - allocations = _random_vector(30, dim=3, seed=5) - omni = placement_pressure(OmniAllocator(linearize_budget=0).allocate(allocations)) - assert omni == _best_greedy_peak(allocations) - - -def test_omni_linearize_budget_does_not_lower_quality() -> None: - allocations = ( - Allocation(id=0, size=30, start=(0, 2), end=(0, 3)), - Allocation(id=1, size=2, start=(0, 2), end=(0, 3)), - Allocation(id=2, size=19, start=(0, 1), end=(2, 1)), - Allocation(id=3, size=6, start=(4, 0), end=(6, 2)), - Allocation(id=4, size=23, start=(4, 4), end=(5, 6)), - Allocation(id=5, size=9, start=(1, 1), end=(1, 3)), - Allocation(id=6, size=24, start=(0, 3), end=(2, 3)), - Allocation(id=7, size=11, start=(2, 3), end=(3, 3)), - Allocation(id=8, size=26, start=(4, 0), end=(4, 1)), - Allocation(id=9, size=26, start=(2, 4), end=(4, 6)), - Allocation(id=10, size=29, start=(1, 4), end=(3, 5)), - ) - unlinearized = OmniAllocator(linearize_budget=0).allocate(allocations) - linearized = OmniAllocator(linearize_budget=None).allocate(allocations) - assert placement_pressure(unlinearized) == 104 - assert placement_pressure(linearized) == 104 - - -def test_omni_linearization_widens_the_portfolio() -> None: - allocations = ( - Allocation(id=0, size=1, start=(6, 12), end=(9, 15)), - Allocation(id=1, size=2, start=(16, 7), end=(24, 11)), - Allocation(id=2, size=16, start=(17, 8), end=(20, 14)), - Allocation(id=3, size=16, start=(8, 2), end=(14, 10)), - Allocation(id=4, size=4, start=(0, 4), end=(7, 12)), - Allocation(id=5, size=32, start=(12, 19), end=(13, 25)), - Allocation(id=6, size=2, start=(5, 9), end=(12, 17)), - Allocation(id=7, size=8, start=(9, 17), end=(14, 23)), - Allocation(id=8, size=1, start=(16, 4), end=(22, 9)), - Allocation(id=9, size=16, start=(6, 12), end=(9, 19)), - Allocation(id=10, size=8, start=(11, 20), end=(15, 24)), - Allocation(id=11, size=8, start=(17, 9), end=(20, 10)), - Allocation(id=12, size=16, start=(19, 9), end=(25, 12)), - Allocation(id=13, size=32, start=(19, 7), end=(24, 11)), - Allocation(id=14, size=16, start=(3, 7), end=(9, 9)), - ) - widened = OmniAllocator().allocate(allocations) - base_only = OmniAllocator(linearize_budget=0).allocate(allocations) - assert placement_pressure(base_only) == 140 - assert placement_pressure(widened) == _best_greedy_peak(allocations) == 139 - - -CONTENTION_GAP = ( - Allocation(id=0, size=1, start=0, end=1), - Allocation(id=1, size=3, start=0, end=2), - Allocation(id=2, size=2, start=1, end=4), - Allocation(id=3, size=2, start=1, end=3), - Allocation(id=4, size=4, start=3, end=8), +@pytest.mark.parametrize( + ("allocations", "linearize_budget", "greedy_peak", "peak"), + [(MATCHED, None, 104, 104), (WIDENED, DEFAULT_WORK_BUDGET, 140, 139)], ) +def test_omni_linearization_never_loses_to_the_greedy_portfolio( + allocations: tuple[Allocation, ...], + linearize_budget: int | None, + greedy_peak: int, + peak: int, +) -> None: + assert _peak(allocations, linearize_budget=0) == greedy_peak + assert _peak(allocations, linearize_budget) == peak + assert peak <= _best_greedy_peak(allocations) + + +@pytest.mark.parametrize( + "allocations", + [ + TIED_STARTS, + CONTENTION_GAP, + RandomSource(num_allocations=200, seed=5).get_allocations(), + HighContentionSource( + num_allocations=200, time_window=12, seed=5 + ).get_allocations(), + ], +) +def test_omni_matches_the_best_greedy_order_on_scalar_input( + allocations: tuple[Allocation, ...], +) -> None: + assert _peak(allocations, DEFAULT_WORK_BUDGET) == _best_greedy_peak(allocations) -def test_omni_packs_at_the_bound_where_other_orders_miss() -> None: - others = ( - "greedy", - "greedy_by_size", - "greedy_by_duration", - "greedy_by_area", - "greedy_by_conflict", - "greedy_by_conflict_size", - "greedy_by_start", - ) - - def peak(allocator: BaseAllocator) -> int: - return placement_pressure(allocator.allocate(CONTENTION_GAP)) - +def test_omni_contention_order_packs_at_the_bound_where_others_miss() -> None: assert antichain_pressure(CONTENTION_GAP) == 7 - assert peak(OmniAllocator()) == peak(GreedyByContentionAllocator()) == 7 - assert min(peak(BaseAllocator.get(name)()) for name in others) == 8 + assert _peak(CONTENTION_GAP, DEFAULT_WORK_BUDGET) == 7 + assert min(_greedy_peak(CONTENTION_GAP, name) for name in GREEDY_ORDERS) == 8 def test_omni_contention_order_needs_a_scalar_timeline() -> None: @@ -301,55 +143,20 @@ def test_omni_contention_order_needs_a_scalar_timeline() -> None: Allocation(id=a.id, size=a.size, start=(a.start,) * 2, end=(a.end,) * 2) for a in CONTENTION_GAP ) - assert placement_pressure(OmniAllocator().allocate(lockstep)) == 7 - assert placement_pressure(GreedyByContentionAllocator().allocate(lockstep)) == 7 - assert placement_pressure(OmniAllocator(linearize_budget=0).allocate(lockstep)) == 8 - - -@pytest.mark.parametrize("num_syncs", [0, 16, 256]) -def test_omni_concurrent_tiling_stays_near_optimum(num_syncs: int) -> None: - capacity = 1024 * 1024 - source = ConcurrentTilingSource( - num_allocations=96, num_threads=4, num_syncs=num_syncs, capacity=capacity - ) - placed = OmniAllocator().allocate(source.get_allocations()) - validate_allocation(Pool(id="p", allocations=placed)) - assert capacity <= placement_pressure(placed) <= 2 * capacity + assert _peak(lockstep, DEFAULT_WORK_BUDGET) == 7 + assert _greedy_peak(lockstep, "greedy_by_contention") == 7 + assert _peak(lockstep, linearize_budget=0) == 8 -@pytest.mark.parametrize("pattern", SYNC_PATTERNS) -def test_omni_torture_across_sync_patterns(pattern: str) -> None: - for seed in (0, 1, 2): - source = SyncPatternSource( - num_allocations=48, num_threads=4, pattern=pattern, seed=seed - ) - allocations = source.get_allocations() - placed = OmniAllocator().allocate(allocations) - validate_allocation(Pool(id=f"{pattern}-{seed}", allocations=placed)) - naive = NaiveAllocator().allocate(allocations) - assert placement_pressure(placed) <= placement_pressure(naive) - - -def test_omni_torture_across_tiling_variants() -> None: - for seed in (0, 1, 2): - for num_syncs in (0, 32): - source = ConcurrentTilingSource( - num_allocations=64, num_threads=2, num_syncs=num_syncs, seed=seed - ) - placed = OmniAllocator().allocate(source.get_allocations()) - validate_allocation(Pool(id=f"{num_syncs}-{seed}", allocations=placed)) - - -def test_omni_linearize_budget_is_quality_monotone() -> None: +@pytest.mark.parametrize("dim", [2, 3, 4]) +def test_omni_linearize_budget_is_quality_monotone(dim: int) -> None: for seed in range(10): - for dim in (2, 3, 4): - allocations = _random_vector(24, dim=dim, seed=1000 * dim + seed) - default = OmniAllocator().allocate(allocations) - unbounded = OmniAllocator(linearize_budget=None).allocate(allocations) - floor = _best_greedy_peak(allocations) - assert placement_pressure(default) <= floor - assert placement_pressure(unbounded) <= floor - validate_allocation(Pool(id=f"{dim}-{seed}", allocations=default)) + allocations = _random_vector(24, dim=dim, seed=1000 * dim + seed) + floor = min( + _peak(allocations, linearize_budget=0), _best_greedy_peak(allocations) + ) + assert _peak(allocations, DEFAULT_WORK_BUDGET) <= floor + assert _peak(allocations, linearize_budget=None) <= floor def test_omni_degenerate_clock_columns_match_the_scalar_instance() -> None: @@ -370,8 +177,6 @@ def test_omni_degenerate_clock_columns_match_the_scalar_instance() -> None: Allocation(id=i, size=z, start=(s,) * 3, end=(s + d,) * 3) for i, z, s, d in base ) - expected = placement_pressure(OmniAllocator().allocate(scalar)) - for allocations in (padded, lockstep): - placed = OmniAllocator().allocate(allocations) - validate_allocation(Pool(id="p", allocations=placed)) - assert placement_pressure(placed) == expected + expected = _peak(scalar, DEFAULT_WORK_BUDGET) + assert _peak(padded, DEFAULT_WORK_BUDGET) == expected + assert _peak(lockstep, DEFAULT_WORK_BUDGET) == expected diff --git a/tests/unit/allocators/test_pinning.py b/tests/unit/allocators/test_pinning.py index 9d25407..709d7ea 100644 --- a/tests/unit/allocators/test_pinning.py +++ b/tests/unit/allocators/test_pinning.py @@ -3,65 +3,56 @@ # import pytest -from omnimalloc.allocators import BaseAllocator, available_allocators from omnimalloc.allocators.naive import NaiveAllocator from omnimalloc.allocators.omni import OmniAllocator -from omnimalloc.allocators.telamalloc import TelamallocAllocator -from omnimalloc.primitives import Allocation, Pool +from omnimalloc.primitives import Allocation from omnimalloc.validate import validate_allocation -def _supporting_allocators() -> list[BaseAllocator]: - instances = [] - for name in available_allocators(): - allocator = BaseAllocator.get(name) - if not allocator.supports_pinned: - continue - try: - instances.append(allocator()) - except ImportError: - continue - return instances - - -def test_pin_survives_every_supporting_allocator() -> None: - allocations = ( - Allocation(id="pinned", size=10, start=0, end=5, offset=100), - Allocation(id="free", size=10, start=0, end=5), - ) - for allocator in _supporting_allocators(): - placed = {a.id: a.offset for a in allocator.allocate(allocations)} - assert placed["pinned"] == 100, allocator - assert placed["free"] != 100, allocator - - -def test_allocator_without_pin_support_rejects_pins() -> None: - allocations = (Allocation(id=1, size=10, start=0, end=5, offset=0),) - with pytest.raises(ValueError, match="cannot honor pinned offsets"): - TelamallocAllocator().allocate(allocations) - - -def test_allocator_without_pin_support_accepts_free_allocations() -> None: - allocations = (Allocation(id=1, size=10, start=0, end=5),) - assert TelamallocAllocator().allocate(allocations)[0].offset == 0 - - -def test_pin_blocks_the_address_of_a_conflicting_allocation() -> None: - allocations = ( - Allocation(id="pinned", size=10, start=0, end=5, offset=0), - Allocation(id="free", size=10, start=2, end=7), - ) - placed = {a.id: a.offset for a in OmniAllocator().allocate(allocations)} - assert placed == {"pinned": 0, "free": 10} - - -def test_free_allocation_reuses_a_pinned_address_when_lifetimes_are_disjoint() -> None: - allocations = ( - Allocation(id="pinned", size=10, start=0, end=5, offset=0), - Allocation(id="free", size=10, start=5, end=9), - ) - placed = {a.id: a.offset for a in OmniAllocator().allocate(allocations)} - assert placed == {"pinned": 0, "free": 0} +def _alloc( + i: int, size: int, start: object, end: object, offset: int | None = None +) -> Allocation: + return Allocation(id=i, size=size, start=start, end=end, offset=offset) + + +@pytest.mark.parametrize( + ("allocator", "allocations", "offsets"), + [ + ( + OmniAllocator(), + [_alloc(0, 10, 0, 5, offset=0), _alloc(1, 10, 2, 7)], + [0, 10], + ), + (OmniAllocator(), [_alloc(0, 10, 0, 5, offset=0), _alloc(1, 10, 5, 9)], [0, 0]), + ( + OmniAllocator(), + [_alloc(0, 10, (0, 0), (4, 1), offset=64), _alloc(1, 10, (0, 0), (1, 4))], + [64, 0], + ), + ( + OmniAllocator(), + [_alloc(0, 32, 0, 4, offset=64), *(_alloc(i, 32, 0, 4) for i in (1, 2, 3))], + [64, 0, 32, 96], + ), + ( + OmniAllocator(), + [_alloc(i, 10, 0, 5, offset=10 * i) for i in range(5)], + [0, 10, 20, 30, 40], + ), + ( + NaiveAllocator(), + [_alloc(0, 10, 0, 5, offset=50), _alloc(1, 20, 0, 5), _alloc(2, 90, 0, 5)], + [50, 0, 60], + ), + ], +) +def test_a_pin( + allocator: OmniAllocator | NaiveAllocator, + allocations: list[Allocation], + offsets: list[int], +) -> None: + placed = allocator.allocate(tuple(allocations)) + assert [a.offset for a in placed] == offsets def test_colliding_pins_are_rejected() -> None: @@ -73,40 +64,12 @@ def test_colliding_pins_are_rejected() -> None: OmniAllocator().allocate(allocations) -def test_fully_pinned_input_comes_back_unchanged() -> None: - allocations = tuple( - Allocation(id=i, size=10, start=0, end=5, offset=10 * i) for i in range(5) - ) - assert OmniAllocator().allocate(allocations) == allocations - - -def test_pin_is_honored_with_vector_clocks() -> None: - allocations = ( - Allocation(id="pinned", size=10, start=(0, 0), end=(4, 1), offset=64), - Allocation(id="free", size=10, start=(0, 0), end=(1, 4)), - ) - placed = {a.id: a.offset for a in OmniAllocator().allocate(allocations)} - assert placed["pinned"] == 64 - assert placed["free"] == 0 - - -def test_pinned_placement_validates() -> None: +def test_many_allocations_place_validly_around_a_pin() -> None: allocations = ( Allocation(id="pinned", size=64, start=0, end=10, offset=1024), *(Allocation(id=i, size=32, start=i, end=i + 4) for i in range(20)), ) - placed = OmniAllocator().allocate(allocations) - validate_allocation(Pool(id="p", allocations=placed)) - - -def test_naive_fills_the_gap_below_a_pin() -> None: - allocations = ( - Allocation(id="pinned", size=10, start=0, end=5, offset=50), - Allocation(id="small", size=20, start=0, end=5), - Allocation(id="large", size=90, start=0, end=5), - ) - placed = {a.id: a.offset for a in NaiveAllocator().allocate(allocations)} - assert placed == {"pinned": 50, "small": 0, "large": 60} + validate_allocation(OmniAllocator().allocate(allocations)) def test_staged_allocation_extends_an_earlier_placement() -> None: @@ -115,5 +78,5 @@ def test_staged_allocation_extends_an_earlier_placement() -> None: ) extra = tuple(Allocation(id=100 + i, size=24, start=i, end=i + 5) for i in range(4)) second = OmniAllocator().allocate((*first, *extra)) - assert {a.id: a.offset for a in second[:8]} == {a.id: a.offset for a in first} - validate_allocation(Pool(id="p", allocations=second)) + assert second[:8] == first + validate_allocation(second) diff --git a/tests/unit/allocators/test_random.py b/tests/unit/allocators/test_random.py index 10953d3..26b66df 100644 --- a/tests/unit/allocators/test_random.py +++ b/tests/unit/allocators/test_random.py @@ -4,28 +4,13 @@ import pytest from omnimalloc.allocators.random import RandomAllocator -from omnimalloc.analysis import antichain_pressure, placement_pressure +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation -from omnimalloc.primitives.pool import Pool -from omnimalloc.validate import validate_allocation - -def _allocs(count: int) -> tuple[Allocation, ...]: - return tuple( - Allocation(id=i, size=(i % 5 + 1) * 10, start=i % 3, end=i % 3 + i % 4 + 1) - for i in range(count) - ) - - -def test_random_empty() -> None: - result = RandomAllocator().allocate(()) - assert len(result) == 0 - - -def test_random_single() -> None: - result = RandomAllocator().allocate((Allocation(id=1, size=100, start=0, end=10),)) - assert len(result) == 1 - assert result[0].offset == 0 +ALLOCATIONS = tuple( + Allocation(id=i, size=(i % 5 + 1) * 10, start=i % 3, end=i % 3 + i % 4 + 1) + for i in range(30) +) def test_random_rejects_zero_trials() -> None: @@ -33,57 +18,12 @@ def test_random_rejects_zero_trials() -> None: RandomAllocator(num_trials=0) -def test_random_produces_valid_allocation() -> None: - allocs = _allocs(20) - result = RandomAllocator(num_trials=20).allocate(allocs) - validate_allocation(Pool(id="test_pool", allocations=result)) - assert {a.id for a in result} == {a.id for a in allocs} - - -def test_random_deterministic_for_same_seed() -> None: - allocs = _allocs(20) - result1 = RandomAllocator(num_trials=10, seed=7).allocate(allocs) - result2 = RandomAllocator(num_trials=10, seed=7).allocate(allocs) - assert {a.id: a.offset for a in result1} == {a.id: a.offset for a in result2} - - -def test_random_repeated_calls_on_same_instance_are_deterministic() -> None: - allocs = _allocs(20) - allocator = RandomAllocator(num_trials=10, seed=7) - first = allocator.allocate(allocs) - second = allocator.allocate(allocs) - assert {a.id: a.offset for a in first} == {a.id: a.offset for a in second} - - 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) + few = RandomAllocator(num_trials=5, seed=3).allocate(ALLOCATIONS) + many = RandomAllocator(num_trials=50, seed=3).allocate(ALLOCATIONS) 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) - validate_allocation(Pool(id="test_pool", allocations=result)) - assert ( - antichain_pressure(allocs) - <= placement_pressure(result) - <= sum(a.size for a in allocs) - ) - - -@pytest.mark.parametrize("seed", [-1, 2**64]) -def test_random_rejects_out_of_range_seed(seed: int) -> None: - with pytest.raises(ValueError, match="seed must be in"): - RandomAllocator(seed=seed) - - -def test_random_rejects_invalid_timeout() -> None: - with pytest.raises(ValueError, match="timeout must be positive or None"): - RandomAllocator(timeout=0) - - def test_random_timeout_bounds_the_trials() -> None: allocator = RandomAllocator(num_trials=10**9, timeout=0.05) - validate_allocation(allocator.allocate(_allocs(20))) + assert len(allocator.allocate(ALLOCATIONS)) == len(ALLOCATIONS) diff --git a/tests/unit/allocators/test_simulated_annealing.py b/tests/unit/allocators/test_simulated_annealing.py index e3f31fe..22cac59 100644 --- a/tests/unit/allocators/test_simulated_annealing.py +++ b/tests/unit/allocators/test_simulated_annealing.py @@ -3,118 +3,21 @@ # import pytest -from omnimalloc.allocators.greedy import GreedyBySizeAllocator 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 _assert_valid(result: tuple[Allocation, ...]) -> None: - validate_allocation(Pool(id="test_pool", allocations=result)) - - -def test_simulated_annealing_empty() -> None: - allocator = SimulatedAnnealingAllocator() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_simulated_annealing_single() -> None: - allocator = SimulatedAnnealingAllocator() - alloc = Allocation(id=1, size=100, start=0, end=10) - result = allocator.allocate((alloc,)) - assert len(result) == 1 - assert result[0].offset == 0 - - -def test_simulated_annealing_rejects_non_positive_iterations() -> None: - with pytest.raises(ValueError, match="max_iterations must be positive"): - SimulatedAnnealingAllocator(max_iterations=0) - - -def test_simulated_annealing_rejects_negative_temperature() -> None: - with pytest.raises(ValueError, match="initial_temperature must be non-negative"): - SimulatedAnnealingAllocator(initial_temperature=-1.0) - - -def test_simulated_annealing_rejects_invalid_cooling_rate() -> None: - with pytest.raises(ValueError, match="cooling_rate must be in"): - SimulatedAnnealingAllocator(cooling_rate=0.0) - with pytest.raises(ValueError, match="cooling_rate must be in"): - SimulatedAnnealingAllocator(cooling_rate=1.5) - - -def test_simulated_annealing_preserves_allocations() -> None: - allocator = SimulatedAnnealingAllocator() - 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_simulated_annealing_no_temporal_overlap_shares_offset() -> None: - allocator = SimulatedAnnealingAllocator() - 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)) - by_id = {a.id: a for a in result} - assert by_id[1].offset == 0 - assert by_id[2].offset == 0 - - -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_valid(result) - assert placement_pressure(result) == 500 - - -def test_simulated_annealing_deterministic() -> None: - allocs = tuple( - Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 3, end=i % 3 + i % 7 + 1) - for i in range(20) - ) - 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: - allocator = SimulatedAnnealingAllocator(max_iterations=300) - allocs = tuple( - Allocation( - id=i, - size=(i % 7 + 1) * 10, - start=i % 4, - end=i % 4 + (i % 3 + 1) * 3, - ) - for i in range(30) - ) - result = allocator.allocate(allocs) - _assert_valid(result) - assert {a.id for a in result} == {a.id for a in allocs} - - -def test_simulated_annealing_matches_or_beats_single_pass_greedy() -> None: - allocs = tuple( - Allocation( - id=i, - size=(i * 37 % 50 + 1) * 10, - start=i % 6, - end=i % 6 + (i * 13 % 5 + 1), - ) - for i in range(40) - ) - greedy_peak = placement_pressure(GreedyBySizeAllocator().allocate(allocs)) - annealed = SimulatedAnnealingAllocator(max_iterations=2000).allocate(allocs) - _assert_valid(annealed) - assert placement_pressure(annealed) <= greedy_peak +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"max_iterations": 0}, "max_iterations must be positive"), + ({"initial_temperature": -1.0}, "initial_temperature must be non-negative"), + ({"cooling_rate": 0.0}, "cooling_rate must be in"), + ({"cooling_rate": 1.5}, "cooling_rate must be in"), + ], +) +def test_simulated_annealing_rejects(kwargs: dict[str, float], match: str) -> None: + with pytest.raises(ValueError, match=match): + SimulatedAnnealingAllocator(**kwargs) def test_simulated_annealing_repr_shows_flat_kwargs() -> None: @@ -123,9 +26,3 @@ def test_simulated_annealing_repr_shows_flat_kwargs() -> None: "SimulatedAnnealingAllocator(seed=7, max_iterations=10, " "initial_temperature=3.0, cooling_rate=0.998, timeout=3.0)" ) - - -@pytest.mark.parametrize("seed", [-1, 2**64]) -def test_simulated_annealing_rejects_out_of_range_seed(seed: int) -> None: - with pytest.raises(ValueError, match="seed must be in"): - SimulatedAnnealingAllocator(seed=seed) diff --git a/tests/unit/allocators/test_tabu_search.py b/tests/unit/allocators/test_tabu_search.py index 8474f48..d0795b9 100644 --- a/tests/unit/allocators/test_tabu_search.py +++ b/tests/unit/allocators/test_tabu_search.py @@ -4,44 +4,16 @@ import pytest from omnimalloc._cpp import tabu_search_place -from omnimalloc.allocators.greedy import GreedyBySizeAllocator 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 +from omnimalloc.primitives import Allocation -def _assert_valid(result: tuple[Allocation, ...]) -> None: - validate_allocation(Pool(id="test_pool", allocations=result)) - - -def test_tabu_search_empty() -> None: - allocator = TabuSearchAllocator() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_tabu_search_single() -> None: - allocator = TabuSearchAllocator() - alloc = Allocation(id=1, size=100, start=0, end=10) - result = allocator.allocate((alloc,)) - assert len(result) == 1 - assert result[0].offset == 0 - - -def test_tabu_search_rejects_non_positive_iterations() -> None: - with pytest.raises(ValueError, match="max_iterations must be positive"): - TabuSearchAllocator(max_iterations=0) - - -def test_tabu_search_rejects_non_positive_neighborhood_size() -> None: - with pytest.raises(ValueError, match="neighborhood_size must be positive"): - TabuSearchAllocator(neighborhood_size=0) - - -def test_tabu_search_rejects_non_positive_tabu_tenure() -> None: - with pytest.raises(ValueError, match="tabu_tenure must be positive"): - TabuSearchAllocator(tabu_tenure=0) +@pytest.mark.parametrize( + "parameter", ["max_iterations", "neighborhood_size", "tabu_tenure"] +) +def test_tabu_search_rejects_non_positive(parameter: str) -> None: + with pytest.raises(ValueError, match=f"{parameter} must be positive"): + TabuSearchAllocator(**{parameter: 0}) def test_tabu_search_cpp_boundary_rejects_zero_timeout() -> None: @@ -58,81 +30,3 @@ def test_tabu_search_cpp_boundary_rejects_zero_timeout() -> None: tabu_tenure=3, timeout=0.0, ) - - -def test_tabu_search_preserves_allocations() -> None: - allocator = TabuSearchAllocator() - 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_tabu_search_no_temporal_overlap_shares_offset() -> None: - allocator = TabuSearchAllocator() - 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)) - by_id = {a.id: a for a in result} - assert by_id[1].offset == 0 - assert by_id[2].offset == 0 - - -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_valid(result) - assert placement_pressure(result) == 500 - - -def test_tabu_search_deterministic() -> None: - allocs = tuple( - Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 3, end=i % 3 + i % 7 + 1) - for i in range(20) - ) - 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(max_iterations=80) - allocs = tuple( - Allocation( - id=i, - size=(i % 7 + 1) * 10, - start=i % 4, - end=i % 4 + (i % 3 + 1) * 3, - ) - for i in range(30) - ) - result = allocator.allocate(allocs) - _assert_valid(result) - assert {a.id for a in result} == {a.id for a in allocs} - - -def test_tabu_search_matches_or_beats_single_pass_greedy() -> None: - allocs = tuple( - Allocation( - id=i, - size=(i * 37 % 50 + 1) * 10, - start=i % 6, - end=i % 6 + (i * 13 % 5 + 1), - ) - for i in range(40) - ) - greedy_peak = placement_pressure(GreedyBySizeAllocator().allocate(allocs)) - searched = TabuSearchAllocator(max_iterations=200).allocate(allocs) - _assert_valid(searched) - assert placement_pressure(searched) <= greedy_peak - - -@pytest.mark.parametrize("seed", [-1, 2**64]) -def test_tabu_search_rejects_out_of_range_seed(seed: int) -> None: - with pytest.raises(ValueError, match="seed must be in"): - TabuSearchAllocator(seed=seed) diff --git a/tests/unit/allocators/test_telamalloc.py b/tests/unit/allocators/test_telamalloc.py index b6365ac..1facf4a 100644 --- a/tests/unit/allocators/test_telamalloc.py +++ b/tests/unit/allocators/test_telamalloc.py @@ -3,29 +3,9 @@ # import pytest -from omnimalloc.allocators.greedy import GreedyBySizeAllocator 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 _assert_valid(result: tuple[Allocation, ...]) -> None: - validate_allocation(Pool(id="test_pool", allocations=result)) - - -def test_telamalloc_empty() -> None: - allocator = TelamallocAllocator() - result = allocator.allocate(()) - assert len(result) == 0 - - -def test_telamalloc_single() -> None: - allocator = TelamallocAllocator() - alloc = Allocation(id=1, size=100, start=0, end=10) - result = allocator.allocate((alloc,)) - assert len(result) == 1 - assert result[0].offset == 0 +from omnimalloc.primitives import Allocation def test_telamalloc_rejects_negative_backtracks() -> None: @@ -33,113 +13,21 @@ def test_telamalloc_rejects_negative_backtracks() -> None: TelamallocAllocator(max_backtracks=-1) -def test_telamalloc_rejects_negative_seconds() -> None: - with pytest.raises(ValueError, match="timeout must be positive or None"): - TelamallocAllocator(timeout=-1.0) - - -def test_telamalloc_no_temporal_overlap_shares_offset() -> None: - allocator = TelamallocAllocator() - 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)) - by_id = {a.id: a for a in result} - assert by_id[1].offset == 0 - assert by_id[2].offset == 0 - - -def test_telamalloc_preserves_allocations() -> None: - allocator = TelamallocAllocator() - allocs = ( +def test_telamalloc_reaches_the_lower_bound_on_overlapping_lifetimes() -> None: + allocations = ( 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), ) - 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_telamalloc_preserves_input_order() -> None: - allocator = TelamallocAllocator() - allocs = tuple( - Allocation(id=i, size=(i % 3 + 1) * 10, start=0, end=10) for i in range(6) - ) - result = allocator.allocate(allocs) - assert [a.id for a in result] == [a.id for a in allocs] - - -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_valid(result) - assert placement_pressure(result) == 500 - - -def test_telamalloc_overlapping_reach_lower_bound() -> None: - 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_valid(result) - assert placement_pressure(result) == 175 + placed = TelamallocAllocator(timeout=None).allocate(allocations) + assert placement_pressure(placed) == 175 def test_telamalloc_independent_phases_share_address_space() -> None: - allocator = TelamallocAllocator() 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_valid(result) - assert placement_pressure(result) == 300 - offsets = sorted(a.offset for a in result if a.id >= 10) - assert offsets == [0, 100, 200] - - -def test_telamalloc_deterministic_without_deadline() -> None: - allocs = tuple( - Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 3, end=i % 3 + i % 7 + 1) - for i in range(20) - ) - 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)) - - -def test_telamalloc_produces_valid_allocation_on_dense_overlap() -> None: - allocator = TelamallocAllocator() - allocs = tuple( - Allocation( - id=i, - size=(i % 7 + 1) * 10, - start=i % 4, - end=i % 4 + (i % 3 + 1) * 3, - ) - for i in range(30) - ) - result = allocator.allocate(allocs) - _assert_valid(result) - assert {a.id for a in result} == {a.id for a in allocs} - - -def test_telamalloc_matches_or_beats_single_pass_greedy() -> None: - allocs = tuple( - Allocation( - id=i, - size=(i * 37 % 50 + 1) * 10, - start=i % 6, - end=i % 6 + (i * 13 % 5 + 1), - ) - for i in range(40) - ) - greedy_peak = placement_pressure(GreedyBySizeAllocator().allocate(allocs)) - result = TelamallocAllocator(timeout=None).allocate(allocs) - _assert_valid(result) - assert placement_pressure(result) <= greedy_peak + placed = TelamallocAllocator().allocate(early + late) + assert sorted(a.offset for a in placed[3:]) == [0, 100, 200] def test_telamalloc_rejects_int64_overflow_inputs() -> None: @@ -148,9 +36,3 @@ def test_telamalloc_rejects_int64_overflow_inputs() -> None: ) with pytest.raises(ValueError, match="int64"): TelamallocAllocator().allocate(huge) - - -@pytest.mark.parametrize("seed", [-1, 2**64]) -def test_telamalloc_rejects_out_of_range_seed(seed: int) -> None: - with pytest.raises(ValueError, match="seed must be in"): - TelamallocAllocator(seed=seed) diff --git a/tests/unit/allocators/test_vector_time.py b/tests/unit/allocators/test_vector_time.py index 41bcf28..fea9832 100644 --- a/tests/unit/allocators/test_vector_time.py +++ b/tests/unit/allocators/test_vector_time.py @@ -4,24 +4,8 @@ import pytest from omnimalloc._cpp import FirstFitPlacer, GreedyOrder, Partition, greedy_order -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, - GreedyByAllAllocator, - GreedyByStartAllocator, -) -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 -from omnimalloc.allocators.supermalloc import SupermallocAllocator -from omnimalloc.allocators.tabu_search import TabuSearchAllocator -from omnimalloc.allocators.telamalloc import TelamallocAllocator -from omnimalloc.analysis import conflict_degrees, conflicts, placement_pressure +from omnimalloc.allocators.greedy import GreedyAllocator +from omnimalloc.analysis import placement_pressure from omnimalloc.primitives import Allocation from omnimalloc.primitives.pool import Pool from omnimalloc.validate import validate_allocation @@ -39,33 +23,6 @@ def vector_problem(n: int = 10) -> tuple[Allocation, ...]: ) -@pytest.mark.parametrize( - "allocator_cls", - [ - GreedyAllocator, - NaiveAllocator, - OmniAllocator, - RandomAllocator, - HillClimbAllocator, - BestFitAllocator, - ], -) -def test_allocators_place_vector_problems( - allocator_cls: type[BaseAllocator], -) -> None: - allocs = vector_problem() - result = allocator_cls().allocate(allocs) - 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, max_generations=3) - result = allocator.allocate(vector_problem()) - validate_allocation(Pool(id="p", allocations=result)) - - @pytest.mark.parametrize("order", list(GreedyOrder)) def test_orders_permute_vector_problems(order: GreedyOrder) -> None: allocs = vector_problem() @@ -101,26 +58,6 @@ def test_start_order_breaks_full_ties_by_input_order() -> None: assert greedy_order(allocs, GreedyOrder.START) == [0, 1] -def test_greedy_by_start_places_vector_time() -> None: - allocs = ( - Allocation(id=1, size=1, start=(1, 0), end=(2, 1)), - Allocation(id=2, size=1, start=(0, 5), end=(1, 6)), - ) - placed = GreedyByStartAllocator().allocate(allocs) - assert all(a.offset is not None for a in placed) - - -def test_greedy_by_all_places_every_allocation_on_vector_time() -> None: - allocs = ( - Allocation(id=1, size=8, start=(1, 0), end=(2, 1)), - Allocation(id=2, size=8, start=(0, 5), end=(1, 6)), - Allocation(id=3, size=4, start=(0, 0), end=(3, 3)), - ) - placed = GreedyByAllAllocator().allocate(allocs) - assert {a.id for a in placed} == {1, 2, 3} - assert all(a.offset is not None for a in placed) - - def test_greedy_order_mixed_dimensions_rejected() -> None: mixed = ( Allocation(id=1, size=8, start=0, end=4), @@ -130,49 +67,6 @@ def test_greedy_order_mixed_dimensions_rejected() -> None: greedy_order(mixed, GreedyOrder.START) -def test_conflict_degrees_match_conflict_map() -> None: - allocs = vector_problem() - 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_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) - ) - 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_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), - Allocation(id=2, size=8, start=2, end=6), - ) - lockstep = tuple( - Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) - for a in scalar - ) - assert conflict_degrees(scalar, work_budget=None) == conflict_degrees( - lockstep, work_budget=None - ) - - -def test_conflict_map_matches_pairwise_test() -> None: - allocs = vector_problem(12) - conflict_map = conflicts(allocs, None) - for a in allocs: - for b in allocs: - if a.id == b.id: - continue - 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)) @@ -181,49 +75,11 @@ def test_first_fit_placer_accepts_vector_problems() -> None: 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(max_iterations=20), - TabuSearchAllocator(max_iterations=20), - ): - result = allocator.allocate(allocs) - 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=r"requires scalar .* 2-dim vector clocks"): Partition.from_allocations([Allocation(id=1, size=8, start=(0, 1), end=(2, 2))]) -def test_supermalloc_rejects_vector_time() -> None: - with pytest.raises(ValueError, match=r"requires scalar .* 2-dim vector clocks"): - SupermallocAllocator().allocate(vector_problem()) - - -def test_telamalloc_rejects_vector_time() -> None: - with pytest.raises(ValueError, match=r"requires scalar .* 2-dim vector clocks"): - TelamallocAllocator().allocate(vector_problem()) - - -@pytest.mark.skipif(not HAS_MINIMALLOC, reason="minimalloc not installed") -def test_minimalloc_rejects_vector_time() -> None: - with pytest.raises(ValueError, match=r"requires scalar .* 2-dim vector clocks"): - MinimallocAllocator().allocate(vector_problem()) - - -def test_registry_declares_vector_time_support() -> None: - registry = BaseAllocator.registry() - 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: mixed = ( Allocation(id=1, size=8, start=0, end=4), @@ -233,20 +89,6 @@ def test_mixed_dimensions_rejected() -> None: GreedyAllocator().allocate(mixed) -def test_scalar_problems_place_identically_via_vector_path() -> None: - scalar = tuple( - Allocation(id=i, size=16 * (i % 4 + 1), start=i % 5, end=i % 5 + i % 3 + 1) - for i in range(15) - ) - lockstep = tuple( - Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) - for a in scalar - ) - scalar_result = GreedyAllocator().allocate(scalar) - vector_result = GreedyAllocator().allocate(lockstep) - assert [a.offset for a in scalar_result] == [a.offset for a in vector_result] - - def test_reuse_follows_happens_before() -> None: ordered = ( Allocation(id=1, size=100, start=(0, 0), end=(2, 1)), diff --git a/tests/unit/test_boundary.py b/tests/unit/test_boundary.py index 7aa81b0..27ff6ba 100644 --- a/tests/unit/test_boundary.py +++ b/tests/unit/test_boundary.py @@ -4,7 +4,6 @@ import pytest from omnimalloc import allocate, validate_allocation -from omnimalloc.allocators import BaseAllocator, available_allocators from omnimalloc.analysis import ( antichain_pressure, conflict_degrees, @@ -18,49 +17,6 @@ LARGE = 2**61 -def _allocators() -> list[BaseAllocator]: - instances = [] - for name in available_allocators(): - allocator = BaseAllocator.get(name) - if not allocator.__module__.startswith("omnimalloc."): - continue - try: - instances.append(allocator(timeout=1.0)) - except TypeError: - try: - instances.append(allocator()) - except ImportError: - continue - except ImportError: - continue - return instances - - -def test_empty_pool_places_and_validates() -> None: - placed = allocate(Pool(id="p", allocations=())) - assert placed.allocations == () - validate_allocation(placed) - - -def test_single_allocation_lands_at_zero() -> None: - placed = allocate((Allocation(id=1, size=10, start=0, end=5),)) - assert placed[0].offset == 0 - - -def test_all_identical_lifetimes_stack() -> None: - allocations = tuple(Allocation(id=i, size=10, start=0, end=5) for i in range(8)) - placed = allocate(allocations, "omni") - validate_allocation(placed) - assert placement_pressure(placed) == 80 - - -def test_all_disjoint_lifetimes_share_one_offset() -> None: - allocations = tuple(Allocation(id=i, size=10, start=i, end=i + 1) for i in range(8)) - placed = allocate(allocations, "omni") - validate_allocation(placed) - assert placement_pressure(placed) == 10 - - def test_a_single_huge_allocation_places() -> None: placed = allocate((Allocation(id=1, size=LARGE, start=0, end=1),)) assert placed[0].offset == 0 @@ -106,11 +62,6 @@ def test_empty_analysis_inputs_are_defined() -> None: assert try_linearize(()) == () -def test_a_zero_size_allocation_is_rejected() -> None: - with pytest.raises(ValueError, match="size must be positive"): - Allocation(id="empty", size=0, start=0, end=5) - - def test_a_one_byte_allocation_places_beside_a_large_one() -> None: allocations = ( Allocation(id="tiny", size=1, start=0, end=5), @@ -125,15 +76,3 @@ def test_empty_hierarchy_levels_validate() -> None: validate_allocation(System(id="s", memories=())) validate_allocation(Memory(id="m", pools=(), size=0)) validate_allocation(allocate(Memory(id="m", pools=(Pool(id="p", allocations=()),)))) - - -def test_every_allocator_handles_a_single_allocation() -> None: - allocations = (Allocation(id=1, size=10, start=0, end=5),) - for allocator in _allocators(): - placed = allocator.allocate(allocations) - assert placed[0].offset == 0, allocator - - -def test_every_allocator_handles_no_allocations() -> None: - for allocator in _allocators(): - assert allocator.allocate(()) == (), allocator From 0ff8744cbb04bbeeaa446bb7e778195bc4108b9a Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 15:52:01 +0000 Subject: [PATCH 06/17] Keep test_properties to the metamorphic relations --- tests/unit/test_properties.py | 547 +++++++--------------------------- 1 file changed, 108 insertions(+), 439 deletions(-) diff --git a/tests/unit/test_properties.py b/tests/unit/test_properties.py index 73966b0..f1476e9 100644 --- a/tests/unit/test_properties.py +++ b/tests/unit/test_properties.py @@ -5,35 +5,19 @@ import itertools import random -from omnimalloc import ( - Allocation, - Pool, - allocate, - validate_allocation, -) -from omnimalloc.allocators import BaseAllocator, available_allocators +import pytest +from omnimalloc import Allocation, allocate, validate_allocation from omnimalloc.analysis import ( antichain_pressure, closure_pressure, conflict_degrees, - conflict_graph, conflicts, placement_pressure, try_linearize, ) -from omnimalloc.benchmark.sources import ( - ConcurrentTilingSource, - HighContentionSource, - PinwheelSource, - RandomSource, - SyncPatternSource, - TilingSource, -) SEEDS = (0, 1, 2, 3) -PERMUTATION_SEEDS = (0, 1, 2, 3, 4, 5, 6, 7) - REORDERING_SENSITIVE = ( Allocation(id=0, size=6, start=0, end=3), Allocation(id=1, size=5, start=2, end=5), @@ -50,32 +34,27 @@ ) -def _scalar_instance(seed: int, count: int = 40) -> tuple[Allocation, ...]: +def _scalar_instance( + seed: int, count: int = 40, horizon: int = 30 +) -> tuple[Allocation, ...]: rng = random.Random(seed) allocations = [] for i in range(count): - start = rng.randint(0, 30) + start = rng.randint(0, horizon) + size, duration = rng.randint(1, 512), rng.randint(1, 8) allocations.append( - Allocation( - id=i, - size=rng.randint(1, 512), - start=start, - end=start + rng.randint(1, 8), - ) + Allocation(id=i, size=size, start=start, end=start + duration) ) return tuple(allocations) -def _vector_instance( - seed: int, count: int = 24, dim: int = 3 -) -> tuple[Allocation, ...]: +def _vector_instance(seed: int, count: int = 24) -> tuple[Allocation, ...]: rng = random.Random(seed) allocations = [] for i in range(count): - start = tuple(rng.randint(0, 6) for _ in range(dim)) - delta = [rng.randint(0, 3) for _ in range(dim)] - if sum(delta) == 0: - delta[rng.randrange(dim)] = 1 + start = tuple(rng.randint(0, 6) for _ in range(3)) + delta = [rng.randint(0, 3) for _ in range(3)] + delta[rng.randrange(3)] += 1 end = tuple(s + d for s, d in zip(start, delta, strict=True)) allocations.append( Allocation(id=i, size=rng.randint(1, 256), start=start, end=end) @@ -83,457 +62,147 @@ def _vector_instance( return tuple(allocations) -def _lockstep_instance(seed: int, count: int = 30) -> tuple[Allocation, ...]: - return tuple( - Allocation( - id=alloc.id, - size=alloc.size, - start=(alloc.start, alloc.start, 0), - end=(alloc.end, alloc.end, 0), - ) - for alloc in _scalar_instance(seed, count) - ) - - -def _staircase_instance(count: int = 30) -> tuple[Allocation, ...]: - return tuple( - Allocation(id=i, size=8 + i, start=(i, 2 * i), end=(i + 2, 2 * i + 3)) - for i in range(count) - ) - - -def _source_instances(seed: int) -> dict[str, tuple[Allocation, ...]]: - return { - "random": RandomSource(num_allocations=60, seed=seed).get_allocations(), - "contention": HighContentionSource( - num_allocations=60, time_window=10, seed=seed - ).get_allocations(), - "tiling": TilingSource(num_allocations=64, seed=seed).get_allocations(), - "pinwheel": PinwheelSource(num_allocations=65, seed=seed).get_allocations(), - "sync": SyncPatternSource( - num_allocations=48, num_threads=3, pattern="sparse", seed=seed - ).get_allocations(), - "concurrent_tiling": ConcurrentTilingSource( - num_allocations=48, num_threads=4, capacity=1 << 20, seed=seed - ).get_allocations(), - } - - -def _allocator_classes() -> tuple[type[BaseAllocator], ...]: - registered = (BaseAllocator.get(name) for name in available_allocators()) - return tuple( - allocator_cls - for allocator_cls in registered - if allocator_cls.__module__.startswith("omnimalloc.") - ) - - -def _allocators() -> tuple[BaseAllocator, ...]: - instances = [] - for allocator_cls in _allocator_classes(): - try: - instances.append(allocator_cls()) - except ImportError: - continue - return tuple(instances) +INSTANCES = [ + *(_scalar_instance(seed) for seed in SEEDS), + *(_vector_instance(seed) for seed in SEEDS), +] +VECTOR_INSTANCES = INSTANCES[len(SEEDS) :] def _peak(allocations: tuple[Allocation, ...]) -> int: - return placement_pressure(allocations) + return placement_pressure(allocate(allocations, "omni")) -def _offsets(allocations: tuple[Allocation, ...]) -> dict[int | str, int | None]: - return {alloc.id: alloc.offset for alloc in allocations} +def _antichain(allocations: tuple[Allocation, ...]) -> int: + return antichain_pressure(allocations, work_budget=None) -def _shuffled(allocations: tuple[Allocation, ...], seed: int) -> tuple[Allocation, ...]: - reordered = list(allocations) - random.Random(seed).shuffle(reordered) - return tuple(reordered) - - -def _scaled(allocations: tuple[Allocation, ...], factor: int) -> tuple[Allocation, ...]: - return tuple( - Allocation( - id=alloc.id, - size=factor * alloc.size, - start=alloc.start, - end=alloc.end, - ) - for alloc in allocations - ) - - -def _dilated( - allocations: tuple[Allocation, ...], factor: int -) -> tuple[Allocation, ...]: - return tuple( - Allocation( - id=alloc.id, - size=alloc.size, - start=tuple(factor * t for t in alloc.start), - end=tuple(factor * t for t in alloc.end), - ) - for alloc in allocations - ) - - -def _padded(allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return tuple( - Allocation( - id=alloc.id, - size=alloc.size, - start=(*alloc.start, 0), - end=(*alloc.end, 0), - ) - for alloc in allocations - ) +def _closure(allocations: tuple[Allocation, ...]) -> int: + return closure_pressure(allocations, closure_cap=None) -def _lane_permuted( - allocations: tuple[Allocation, ...], lanes: tuple[int, ...] +def _map( + allocations: tuple[Allocation, ...], size: int = 1, clock: object = None ) -> tuple[Allocation, ...]: return tuple( Allocation( - id=alloc.id, - size=alloc.size, - start=tuple(alloc.start[lane] for lane in lanes), - end=tuple(alloc.end[lane] for lane in lanes), + id=a.id, + size=size * a.size, + start=clock(a.start) if clock else a.start, + end=clock(a.end) if clock else a.end, ) - for alloc in allocations + for a in allocations ) def _optimum(allocations: tuple[Allocation, ...]) -> int: return min( - _peak(allocate([allocations[i] for i in order], "greedy")) + placement_pressure(allocate([allocations[i] for i in order], "greedy")) for order in itertools.permutations(range(len(allocations))) ) -def _tiny_instance(rng: random.Random, count: int = 6) -> tuple[Allocation, ...]: - allocations = [] - for i in range(count): - start = rng.randint(0, 6) - allocations.append( - Allocation( - id=i, - size=rng.randint(1, 50), - start=start, - end=start + rng.randint(1, 4), - ) - ) - return tuple(allocations) +@pytest.mark.parametrize("allocations", INSTANCES) +def test_the_closure_bound_stays_under_the_antichain_bound_and_the_peak( + allocations: tuple[Allocation, ...], +) -> None: + assert _closure(allocations) <= _antichain(allocations) <= _peak(allocations) -def test_every_allocator_places_a_valid_pool() -> None: - for seed in SEEDS[:3]: - for name, allocations in _source_instances(seed).items(): - pool = Pool(id=name, allocations=allocations) - for allocator in _allocators(): - if not allocator.supports(allocations): - continue - validate_allocation(allocate(pool, allocator)) - - -def test_every_allocator_places_generated_instances_validly() -> None: - for seed in SEEDS: - for allocations in (_scalar_instance(seed), _vector_instance(seed)): - for allocator in _allocators(): - if not allocator.supports(allocations): - continue - validate_allocation(allocate(allocations, allocator)) - - -def test_placement_pressure_never_drops_below_the_antichain_bound() -> None: - for seed in SEEDS: - for allocations in (_scalar_instance(seed), _vector_instance(seed)): - bound = antichain_pressure(allocations, work_budget=None) - for allocator in _allocators(): - if not allocator.supports(allocations): - continue - placed = allocate(allocations, allocator) - assert placement_pressure(placed) >= bound - - -def test_closure_bound_stays_under_the_antichain_bound_and_the_peak() -> None: - for seed in SEEDS: - for allocations in (_scalar_instance(seed), _vector_instance(seed)): - closure = closure_pressure(allocations, closure_cap=None) - antichain = antichain_pressure(allocations, work_budget=None) - peak = _peak(allocate(allocations, "omni")) - assert closure <= antichain <= peak - - -def test_placement_preserves_ids_sizes_and_lifetimes() -> None: - for seed in SEEDS: - for allocations in (_scalar_instance(seed), _vector_instance(seed)): - expected = {a.id: (a.size, a.start, a.end) for a in allocations} - for allocator in _allocators(): - if not allocator.supports(allocations): - continue - placed = allocate(allocations, allocator) - assert {a.id: (a.size, a.start, a.end) for a in placed} == expected - - -def test_placement_leaves_no_allocation_unplaced() -> None: - for seed in SEEDS: - for allocations in (_scalar_instance(seed), _vector_instance(seed)): - for allocator in _allocators(): - if not allocator.supports(allocations): - continue - placed = allocate(allocations, allocator) - assert len(placed) == len(allocations) - assert all(a.offset is not None and a.offset >= 0 for a in placed) - - -def test_repeated_calls_on_one_instance_return_identical_offsets() -> None: - for allocations in (_scalar_instance(1), _vector_instance(1)): - for allocator in _allocators(): - if not allocator.supports(allocations): - continue - first = allocate(allocations, allocator) - second = allocate(allocations, allocator) - assert _offsets(first) == _offsets(second) - - -def test_a_fresh_instance_returns_the_offsets_of_a_reused_one() -> None: - for allocations in (_scalar_instance(2), _vector_instance(2)): - for allocator_cls in _allocator_classes(): - try: - allocator = allocator_cls() - except ImportError: - continue - if not allocator.supports(allocations): - continue - reused = allocate(allocations, allocator) - fresh = allocate(allocations, allocator_cls()) - assert _offsets(reused) == _offsets(fresh) +@pytest.mark.parametrize("allocations", INSTANCES) +@pytest.mark.parametrize("factor", [3, 11]) +def test_uniform_size_scaling_scales_the_pressures_and_the_peak( + allocations: tuple[Allocation, ...], factor: int +) -> None: + scaled = _map(allocations, size=factor) + assert _antichain(scaled) == factor * _antichain(allocations) + assert _closure(scaled) == factor * _closure(allocations) + assert _peak(scaled) == factor * _peak(allocations) + + +@pytest.mark.parametrize("allocations", VECTOR_INSTANCES) +@pytest.mark.parametrize( + "clock", + [ + lambda t: (*t, 0), + lambda t: tuple(7 * x for x in t), + lambda t: (t[2], t[0], t[1]), + lambda t: (3 * t[1], 3 * t[0], 0, 3 * t[2]), + ], +) +def test_order_preserving_clock_maps_keep_conflicts_pressures_and_peak( + allocations: tuple[Allocation, ...], clock: object +) -> None: + mapped = _map(allocations, clock=clock) + assert conflicts(mapped, None) == conflicts(allocations, None) + assert conflict_degrees(mapped, None) == conflict_degrees(allocations, None) + assert _antichain(mapped) == _antichain(allocations) + assert _closure(mapped) == _closure(allocations) + assert _peak(mapped) == _peak(allocations) + + +@pytest.mark.parametrize("allocations", VECTOR_INSTANCES) +def test_every_thread_relabelling_keeps_the_omni_peak( + allocations: tuple[Allocation, ...], +) -> None: + peak = _peak(allocations) + for lanes in itertools.permutations(range(3)): + permuted = _map( + allocations, clock=lambda t, lanes=lanes: tuple(t[i] for i in lanes) + ) + assert _peak(permuted) == peak -def test_a_placed_result_keeps_its_offsets_when_allocated_again() -> None: - allocations = _scalar_instance(3) - placed = allocate(allocations, "omni") - assert _offsets(allocate(placed, "omni")) == _offsets(placed) +def test_thread_permutation_keeps_the_peak_of_a_lane_sensitive_instance() -> None: + swapped = _map(LANE_SENSITIVE, clock=lambda t: t[::-1]) + assert conflicts(swapped, None) == conflicts(LANE_SENSITIVE, None) + placed = allocate(swapped, "omni") + validate_allocation(placed) + assert placement_pressure(placed) == _peak(LANE_SENSITIVE) -def test_reordering_the_input_keeps_the_omni_peak_on_random_instances() -> None: - for seed in PERMUTATION_SEEDS: - allocations = _scalar_instance(seed) - shuffled = _shuffled(allocations, seed + 100) - assert _peak(allocate(shuffled, "omni")) == _peak(allocate(allocations, "omni")) +@pytest.mark.parametrize("seed", SEEDS) +def test_reordering_the_input_keeps_the_omni_peak(seed: int) -> None: + shuffled = list(_scalar_instance(seed)) + random.Random(seed + 100).shuffle(shuffled) + assert _peak(tuple(shuffled)) == _peak(_scalar_instance(seed)) def test_reordering_the_input_can_change_the_omni_peak() -> None: reordered = tuple(REORDERING_SENSITIVE[i] for i in (2, 1, 3, 0)) - original = allocate(REORDERING_SENSITIVE, "omni") - permuted = allocate(reordered, "omni") - validate_allocation(permuted) - assert _peak(permuted) > _peak(original) - assert _peak(original) == antichain_pressure(REORDERING_SENSITIVE, work_budget=None) - - -def test_reordering_the_input_changes_the_greedy_peak() -> None: - for seed in PERMUTATION_SEEDS: - allocations = _scalar_instance(seed) - shuffled = _shuffled(allocations, seed + 100) - assert _peak(allocate(shuffled, "greedy")) != _peak( - allocate(allocations, "greedy") - ) - - -def test_zero_thread_padding_preserves_the_conflict_relation() -> None: - for seed in SEEDS: - allocations = _vector_instance(seed) - padded = _padded(allocations) - assert conflicts(padded, None) == conflicts(allocations, None) - assert conflict_degrees(padded, None) == conflict_degrees(allocations, None) - + optimum = _antichain(REORDERING_SENSITIVE) + assert _peak(reordered) > _peak(REORDERING_SENSITIVE) == optimum -def test_zero_thread_padding_preserves_the_pressures_and_the_peak() -> None: - for seed in SEEDS: - allocations = _vector_instance(seed) - padded = _padded(allocations) - assert antichain_pressure(padded, work_budget=None) == antichain_pressure( - allocations, work_budget=None - ) - assert _peak(allocate(padded, "omni")) == _peak(allocate(allocations, "omni")) - - -def test_thread_permutation_preserves_the_conflict_relation() -> None: - lanes = (2, 0, 1) - for seed in SEEDS: - allocations = _vector_instance(seed) - permuted = _lane_permuted(allocations, lanes) - assert conflicts(permuted, None) == conflicts(allocations, None) - -def test_thread_permutation_preserves_the_pressure_bounds() -> None: - lanes = (2, 0, 1) - for seed in SEEDS: - allocations = _vector_instance(seed) - permuted = _lane_permuted(allocations, lanes) - assert antichain_pressure(permuted, work_budget=None) == antichain_pressure( - allocations, work_budget=None - ) - assert closure_pressure(permuted, closure_cap=None) == closure_pressure( - allocations, closure_cap=None - ) - - -def test_thread_permutation_keeps_the_omni_peak_under_every_relabelling() -> None: - for seed in SEEDS: - allocations = _vector_instance(seed) - peak = _peak(allocate(allocations, "omni")) - for lanes in itertools.permutations(range(3)): - permuted = _lane_permuted(allocations, lanes) - assert _peak(allocate(permuted, "omni")) == peak - - -def test_thread_permutation_keeps_the_peak_of_a_lane_sensitive_instance() -> None: - swapped = _lane_permuted(LANE_SENSITIVE, (1, 0)) - assert conflicts(swapped, None) == conflicts(LANE_SENSITIVE, None) - original = allocate(LANE_SENSITIVE, "omni") - permuted = allocate(swapped, "omni") - validate_allocation(permuted) - assert _peak(permuted) == _peak(original) - assert _peak(original) >= antichain_pressure(LANE_SENSITIVE, work_budget=None) - - -def test_thread_permutation_keeps_the_omni_peak_on_a_sync_pattern() -> None: - allocations = SyncPatternSource( - num_allocations=48, num_threads=4, pattern="tree", seed=3 - ).get_allocations() - peak = _peak(allocate(allocations, "omni")) - for lanes in itertools.permutations(range(4)): - permuted = _lane_permuted(allocations, lanes) - placed = allocate(permuted, "omni") - validate_allocation(placed) - assert _peak(placed) == peak - - -def test_uniform_size_scaling_scales_the_peak() -> None: - for factor in (3, 11): - for seed in SEEDS: - for allocations in (_scalar_instance(seed), _vector_instance(seed)): - scaled = _scaled(allocations, factor) - assert _peak(allocate(scaled, "omni")) == factor * _peak( - allocate(allocations, "omni") - ) - - -def test_uniform_size_scaling_scales_the_pressure() -> None: - for factor in (3, 11): - for seed in SEEDS: - for allocations in (_scalar_instance(seed), _vector_instance(seed)): - scaled = _scaled(allocations, factor) - assert antichain_pressure( - scaled, work_budget=None - ) == factor * antichain_pressure(allocations, work_budget=None) - assert closure_pressure( - scaled, closure_cap=None - ) == factor * closure_pressure(allocations, closure_cap=None) - - -def test_time_dilation_preserves_the_conflict_relation() -> None: - for factor in (2, 7): - for seed in SEEDS: - allocations = _vector_instance(seed) - dilated = _dilated(allocations, factor) - assert conflicts(dilated, None) == conflicts(allocations, None) - - -def test_time_dilation_preserves_the_peak() -> None: - for factor in (2, 7): - for seed in SEEDS: - allocations = _vector_instance(seed) - dilated = _dilated(allocations, factor) - assert _peak(allocate(dilated, "omni")) == _peak( - allocate(allocations, "omni") - ) +def test_a_placed_result_keeps_its_offsets_when_allocated_again() -> None: + placed = allocate(_scalar_instance(3), "omni") + assert allocate(placed, "omni") == placed def test_removing_an_allocation_cannot_raise_the_optimum() -> None: rng = random.Random(11) for _ in range(5): - allocations = _tiny_instance(rng) + allocations = _scalar_instance(rng.randrange(1000), count=6, horizon=6) optimum = _optimum(allocations) for i in range(len(allocations)): - reduced = allocations[:i] + allocations[i + 1 :] - assert _optimum(reduced) <= optimum + assert _optimum(allocations[:i] + allocations[i + 1 :]) <= optimum -def test_adding_an_allocation_cannot_lower_the_bound() -> None: - for seed in SEEDS: - allocations = _scalar_instance(seed, count=20) - bound = antichain_pressure(allocations, work_budget=None) - for i in range(len(allocations)): - reduced = allocations[:i] + allocations[i + 1 :] - assert antichain_pressure(reduced, work_budget=None) <= bound - +@pytest.mark.parametrize("seed", SEEDS) +def test_removing_an_allocation_cannot_raise_the_bound(seed: int) -> None: + allocations = _scalar_instance(seed, count=20) + bound = _antichain(allocations) + for i in range(len(allocations)): + assert _antichain(allocations[:i] + allocations[i + 1 :]) <= bound -def test_linearization_preserves_the_conflict_relation() -> None: - for seed in SEEDS: - allocations = _lockstep_instance(seed) - linearized = try_linearize(allocations, work_budget=None) - assert linearized is not None - assert conflicts(tuple(linearized), None) == conflicts(allocations, None) - -def test_linearization_preserves_the_antichain_pressure() -> None: - for seed in SEEDS: - allocations = _lockstep_instance(seed) - linearized = try_linearize(allocations, work_budget=None) - assert linearized is not None - assert antichain_pressure( - tuple(linearized), work_budget=None - ) == antichain_pressure(allocations, work_budget=None) - - -def test_linearization_of_a_staircase_preserves_conflicts_and_pressure() -> None: - allocations = _staircase_instance() +def test_linearizing_a_staircase_keeps_its_conflicts_and_pressure() -> None: + allocations = tuple( + Allocation(id=i, size=8 + i, start=(i, 2 * i), end=(i + 2, 2 * i + 3)) + for i in range(30) + ) linearized = try_linearize(allocations, work_budget=None) assert linearized is not None assert all(alloc.dim == 1 for alloc in linearized) - assert conflicts(tuple(linearized), None) == conflicts(allocations, None) - assert antichain_pressure( - tuple(linearized), work_budget=None - ) == antichain_pressure(allocations, work_budget=None) - - -def test_scaling_padding_and_dilation_compose_into_one_scaled_instance() -> None: - factor = 5 - for seed in SEEDS: - allocations = _vector_instance(seed) - derived = _scaled(_padded(_dilated(allocations, 3)), factor) - assert conflict_degrees(derived, None) == conflict_degrees(allocations, None) - assert antichain_pressure( - derived, work_budget=None - ) == factor * antichain_pressure(allocations, work_budget=None) - assert closure_pressure(derived, closure_cap=None) == factor * closure_pressure( - allocations, closure_cap=None - ) - assert _peak(allocate(derived, "omni")) == factor * _peak( - allocate(allocations, "omni") - ) - - -def test_every_allocator_stays_between_the_bound_and_the_total_size() -> None: - for seed in SEEDS[:2]: - for allocations in ( - TilingSource(num_allocations=64, seed=seed).get_allocations(), - PinwheelSource(num_allocations=65, seed=seed).get_allocations(), - SyncPatternSource( - num_allocations=40, num_threads=3, pattern="barrier", seed=seed - ).get_allocations(), - ): - bound = antichain_pressure(allocations, work_budget=None) - total = sum(alloc.size for alloc in allocations) - graph = conflict_graph(allocations, None) - assert len(graph) == len(allocations) - for allocator in _allocators(): - if not allocator.supports(allocations): - continue - placed = allocate(allocations, allocator) - validate_allocation(placed) - assert bound <= _peak(placed) <= total + assert conflicts(linearized, None) == conflicts(allocations, None) + assert _antichain(tuple(linearized)) == _antichain(allocations) From b55bb42edbe64ba0e8e6b10620e568d77008c2e5 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 15:52:01 +0000 Subject: [PATCH 07/17] Drop the integration tests that repeat unit coverage --- tests/integration/test_omni_torture.py | 101 ------------------------- tests/integration/test_torture.py | 49 ++---------- 2 files changed, 6 insertions(+), 144 deletions(-) diff --git a/tests/integration/test_omni_torture.py b/tests/integration/test_omni_torture.py index aba179f..5651ca2 100644 --- a/tests/integration/test_omni_torture.py +++ b/tests/integration/test_omni_torture.py @@ -9,16 +9,6 @@ import pytest from omnimalloc._cpp import FirstFitPlacer from omnimalloc.allocators import OmniAllocator -from omnimalloc.allocators.greedy import ( - GreedyAllocator, - GreedyByAreaAllocator, - GreedyByConflictAllocator, - GreedyByConflictSizeAllocator, - GreedyByContentionAllocator, - GreedyByDurationAllocator, - GreedyBySizeAllocator, - GreedyByStartAllocator, -) from omnimalloc.analysis import ( antichain_pressure, closure_pressure, @@ -26,24 +16,12 @@ placement_pressure_per_allocation, ) from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource -from omnimalloc.benchmark.sources.generator import HighContentionSource, RandomSource from omnimalloc.benchmark.sources.pinwheel import PinwheelSource from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.benchmark.sources.tiling import TilingSource from omnimalloc.primitives import Allocation, Pool from omnimalloc.validate import validate_allocation -GREEDY_PORTFOLIO = ( - GreedyAllocator, - GreedyBySizeAllocator, - GreedyByDurationAllocator, - GreedyByAreaAllocator, - GreedyByConflictAllocator, - GreedyByConflictSizeAllocator, - GreedyByStartAllocator, - GreedyByContentionAllocator, -) - def _peak(allocations: tuple[Allocation, ...]) -> int: return max(a.offset + a.size for a in allocations) @@ -172,39 +150,6 @@ def test_scalar_tiling_peak_stays_near_known_optimum( assert capacity <= peak <= 2 * capacity -def test_scalar_portfolio_not_worse_than_any_greedy_variant() -> None: - sources = ( - RandomSource(num_allocations=200, seed=5), - HighContentionSource(num_allocations=200, time_window=12, seed=5), - TilingSource(num_allocations=256, seed=5), - ) - for source in sources: - allocations = source.get_allocations() - omni_peak = _peak(OmniAllocator().allocate(allocations)) - for variant_cls in GREEDY_PORTFOLIO: - assert omni_peak <= _peak(variant_cls().allocate(allocations)) - - -def test_vector_not_worse_than_input_order_greedy() -> None: - for pattern in ("independent", "ring", "barrier", "dense"): - source = SyncPatternSource(num_allocations=96, num_threads=5, pattern=pattern) - allocations = source.get_allocations() - omni_peak = _peak(OmniAllocator().allocate(allocations)) - assert omni_peak <= _peak(GreedyAllocator().allocate(allocations)) - - -def test_size_scaling_scales_peak_linearly() -> None: - source = SyncPatternSource(num_allocations=80, num_threads=4, pattern="sparse") - allocations = source.get_allocations() - scaled = tuple( - Allocation(id=a.id, size=7 * a.size, start=a.start, end=a.end) - for a in allocations - ) - assert _peak(OmniAllocator().allocate(scaled)) == 7 * _peak( - OmniAllocator().allocate(allocations) - ) - - def test_clock_translation_preserves_offsets() -> None: source = SyncPatternSource(num_allocations=80, num_threads=4, pattern="groups") allocations = source.get_allocations() @@ -222,52 +167,6 @@ def test_clock_translation_preserves_offsets() -> None: ] -def test_lane_permutation_preserves_exact_pressures() -> None: - source = SyncPatternSource(num_allocations=48, num_threads=4, pattern="fork_join") - allocations = source.get_allocations() - lanes = (2, 0, 3, 1) - permuted = tuple( - Allocation( - id=a.id, - size=a.size, - start=tuple(a.start[lane] for lane in lanes), - end=tuple(a.end[lane] for lane in lanes), - ) - for a in allocations - ) - assert antichain_pressure(permuted, work_budget=None) == antichain_pressure( - allocations, work_budget=None - ) - assert closure_pressure(permuted, closure_cap=1 << 18) == closure_pressure( - allocations, closure_cap=1 << 18 - ) - _certify(permuted, OmniAllocator().allocate(permuted)) - - -def test_zero_lane_padding_preserves_offsets() -> None: - source = SyncPatternSource(num_allocations=64, num_threads=3, pattern="ring") - allocations = source.get_allocations() - padded = tuple( - Allocation(id=a.id, size=a.size, start=(*a.start, 0, 0), end=(*a.end, 0, 0)) - for a in allocations - ) - assert [x.offset for x in OmniAllocator().allocate(padded)] == [ - x.offset for x in OmniAllocator().allocate(allocations) - ] - - -@pytest.mark.parametrize("dim", [2, 8]) -def test_lockstep_embedding_matches_scalar_peak(dim: int) -> None: - allocations = _random_scalar(300, seed=9) - lockstep = tuple( - Allocation(id=a.id, size=a.size, start=(a.start,) * dim, end=(a.end,) * dim) - for a in allocations - ) - assert _peak(OmniAllocator().allocate(lockstep)) == _peak( - OmniAllocator().allocate(allocations) - ) - - @pytest.mark.parametrize("dim", [2, 8, 32]) def test_one_hot_lanes_reach_the_exact_optimum(dim: int) -> None: size = 64 diff --git a/tests/integration/test_torture.py b/tests/integration/test_torture.py index 6dbfcad..1456e81 100644 --- a/tests/integration/test_torture.py +++ b/tests/integration/test_torture.py @@ -72,11 +72,11 @@ GreedyByContentionAllocator(), GreedyByAllAllocator(), BestFitAllocator(), - TelamallocAllocator(timeout=1.0), - HillClimbAllocator(timeout=1.0), - TabuSearchAllocator(timeout=1.0), - SimulatedAnnealingAllocator(timeout=1.0), - SupermallocAllocator(timeout=1.0), + TelamallocAllocator(timeout=0.2), + HillClimbAllocator(timeout=0.2), + TabuSearchAllocator(timeout=0.2), + SimulatedAnnealingAllocator(timeout=0.2), + SupermallocAllocator(timeout=0.2), ) SOAK_ALLOCATORS = ( @@ -95,7 +95,7 @@ SupermallocAllocator(timeout=0.02), ) -TIMEOUT_BUDGET = 0.5 +TIMEOUT_BUDGET = 0.1 PLACEMENT_SLACK_MULTIPLE = 8 FIXED_SLACK = 0.4 SANE_PEAK_FACTOR = 3.0 @@ -404,15 +404,7 @@ def test_seeded_allocators_agree_across_core_counts() -> None: ) for name in seeded: assert single[name] == quad[name], name - - -def test_supermalloc_verdict_agrees_across_core_counts() -> None: - if shutil.which("taskset") is None: - pytest.skip("taskset is unavailable") - single = _pinned_probe("0", 1) - quad = _pinned_probe("0-3", 4) assert single["supermalloc_verdict"] == quad["supermalloc_verdict"] - assert len(single["supermalloc_offsets"]) == len(quad["supermalloc_offsets"]) def test_supermalloc_verdict_is_stable_across_thread_counts() -> None: @@ -429,24 +421,6 @@ def test_supermalloc_verdict_is_stable_across_thread_counts() -> None: assert placement_pressure(result.allocations) == result.peak -def test_greedy_by_all_still_matches_its_best_variant() -> None: - allocations = _small_instance(200, seed=8) - best = min( - placement_pressure(variant.allocate(allocations)) - for variant in ( - GreedyAllocator(), - GreedyBySizeAllocator(), - GreedyByDurationAllocator(), - GreedyByAreaAllocator(), - GreedyByConflictAllocator(), - GreedyByConflictSizeAllocator(), - GreedyByStartAllocator(), - GreedyByContentionAllocator(), - ) - ) - assert placement_pressure(GreedyByAllAllocator().allocate(allocations)) == best - - def test_concurrent_genetic_calls_match_the_solo_result() -> None: allocations = _small_instance(80, seed=3) @@ -462,17 +436,6 @@ def run(_: int) -> list[int | None]: assert all(result == solo for result in results) -def test_genetic_leaves_the_callers_global_random_stream_untouched() -> None: - allocations = _small_instance(80, seed=3) - random.seed(20250802) - expected = [random.random() for _ in range(5)] - random.seed(20250802) - GeneticAllocator(timeout=None, max_generations=4, population_size=20).allocate( - allocations - ) - assert [random.random() for _ in range(5)] == expected - - def test_concurrent_genetic_calls_leave_the_global_stream_untouched() -> None: allocations = _small_instance(80, seed=3) From 9fc202f4e49437e45e8f0781287aac051f166364 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 15:55:24 +0000 Subject: [PATCH 08/17] Flatten the plot smoke tests into one table over entities --- tests/unit/test_visualize.py | 433 ++++++++++------------------------- 1 file changed, 123 insertions(+), 310 deletions(-) diff --git a/tests/unit/test_visualize.py b/tests/unit/test_visualize.py index 8828b01..c650a84 100644 --- a/tests/unit/test_visualize.py +++ b/tests/unit/test_visualize.py @@ -5,6 +5,9 @@ from pathlib import Path import pytest + +pytest.importorskip("matplotlib") + from omnimalloc import visualize from omnimalloc.primitives import Allocation, AllocationKind, Memory, Pool, System from omnimalloc.visualize import ( @@ -20,201 +23,143 @@ plot_allocation, ) -from tests.markers import needs_matplotlib - -pytestmark = needs_matplotlib - - -def test_visualize_single_allocation(artifacts_dir: Path) -> None: - 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_single.pdf" - plot_allocation(pool, output_path) - assert output_path.exists() - assert output_path.stat().st_size > 0 - - -def test_visualize_multiple_allocations_in_pool(artifacts_dir: Path) -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=5, offset=0) - alloc2 = Allocation(id=2, size=150, start=5, end=10, offset=0) - alloc3 = Allocation(id=3, size=75, start=10, end=15, offset=0) - pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3), offset=0) - - output_path = artifacts_dir / "test_multiple.pdf" - plot_allocation(pool, output_path) - assert output_path.exists() - - -def test_visualize_with_allocation_kinds(artifacts_dir: Path) -> None: - alloc1 = Allocation( - 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=AllocationKind.CONSTANT - ) - alloc3 = Allocation( - 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=AllocationKind.OUTPUT - ) - pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3, alloc4), offset=0) - - output_path = artifacts_dir / "test_kinds.pdf" - plot_allocation(pool, output_path) - assert output_path.exists() - - -def test_visualize_memory_with_multiple_pools(artifacts_dir: Path) -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) - alloc2 = Allocation(id=2, size=150, start=0, end=10, offset=0) - alloc3 = Allocation(id=3, size=75, start=5, end=15, offset=0) - - pool1 = Pool(id=1, allocations=(alloc1,), offset=0) - 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) - - output_path = artifacts_dir / "test_memory_pools.pdf" - plot_allocation(memory, output_path) - assert output_path.exists() +def _placed( + i: int, size: int = 100, start: object = 0, end: object = 10, **kw: object +) -> Allocation: + return Allocation(id=i, size=size, start=start, end=end, offset=0, **kw) -def test_visualize_system_with_multiple_memories(artifacts_dir: Path) -> None: - 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) - 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) +def _pool(pool_id: object, *allocations: Allocation, offset: int = 0) -> Pool: + return Pool(id=pool_id, allocations=allocations, offset=offset) - system = System(id="test_system", memories=(memory1, memory2)) - - output_path = artifacts_dir / "test_system.pdf" - plot_allocation(system, output_path) - assert output_path.exists() - - -def test_visualize_complex_hierarchy(artifacts_dir: Path) -> None: - allocations_mem1_pool1 = [ - Allocation(id=i, size=50 + i * 10, start=i * 2, end=(i + 1) * 2, offset=0) - for i in range(5) - ] - allocations_mem1_pool2 = [ - Allocation( - id=i + 5, - size=100 + i * 20, - start=i * 3, - end=(i + 1) * 3, - offset=0, - kind=AllocationKind.CONSTANT, - ) - for i in range(3) - ] - 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) - - allocations_mem2 = [ - Allocation( - id=i + 10, - size=75, - start=i, - end=i + 5, - offset=0, - 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) - - system = System(id="complex_system", memories=(memory1, memory2)) - - output_path = artifacts_dir / "test_complex.pdf" - plot_allocation(system, output_path) - assert output_path.exists() - size = output_path.stat().st_size - assert 1000 < size < 10_000_000 - - -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) - - output_path = artifacts_dir / "test_string_ids.pdf" - plot_allocation(memory, output_path) - assert output_path.exists() - - -def test_visualize_memory_without_size(artifacts_dir: Path) -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) - alloc2 = Allocation(id=2, size=150, 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)) - - output_path = artifacts_dir / "test_no_size.pdf" - plot_allocation(memory, output_path) - assert output_path.exists() +KINDS = tuple( + _placed(i, start=5 * i, end=5 * i + 5, kind=kind) + for i, kind in enumerate(AllocationKind) +) +ONE_POOL = _pool(1, _placed(1)) -def test_visualize_raw_allocations_converts_to_pool(artifacts_dir: Path) -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=5, offset=0), - Allocation(id=2, size=150, start=5, end=10, offset=0), +def _concurrent_memory() -> Memory: + alloc1 = Allocation(id=1, size=10, start=(0, 0), end=(1, 0), offset=0) + alloc2 = Allocation(id=2, size=10, start=(2, 0), end=(3, 0), offset=0) + alloc3 = Allocation(id=3, size=10, start=(0, 0), end=(0, 1), offset=10) + alloc4 = Allocation(id=4, size=10, start=(0, 2), end=(0, 3), offset=10) + return Memory( + id="mem", pools=(Pool(id=1, allocations=(alloc1, alloc2, alloc3, alloc4)),) ) - output_path = artifacts_dir / "test_raw_allocations.pdf" - plot_allocation(allocations, output_path) - assert output_path.exists() +PLOTS = [ + (ONE_POOL, {}), + (_pool(1, *KINDS), {}), + (KINDS, {}), + (_pool("empty"), {}), + ( + Memory(id="m", pools=(ONE_POOL, _pool(2, _placed(2), offset=200)), size=500), + {}, + ), + (Memory(id="m", pools=(ONE_POOL,)), {}), + (Memory(id="m", pools=(ONE_POOL, _pool(2, _placed(2), offset=50))), {}), + ( + Memory( + id="m", + pools=tuple( + Pool(id=name, allocations=(_placed(1),)) for name in ("a", "b") + ), + ), + {}, + ), + ( + Memory(id="m", pools=(_pool("empty"), _pool("full", _placed(1)))), + {}, + ), + ( + System( + id="s", + memories=( + Memory(id="a", pools=(ONE_POOL,), size=500), + Memory(id="b", pools=(_pool("x", _placed("y", start=5)),), size=500), + ), + ), + {}, + ), + ( + System(id="s", memories=(Memory(id="m", pools=(ONE_POOL,), size=1000),)), + {"capacities": {"m": {"budget": 200, "threshold": 220}, "other": {"x": 9}}}, + ), + (_pool("p", _placed(1)), {"capacities": {"p": {"budget": 200}}}), + ( + Memory( + id="m", + pools=( + ONE_POOL, + _pool(2, _placed(2, start=(0, 1), end=(2, 3)), offset=200), + ), + size=1000, + ), + {}, + ), + ( + _pool( + 1, + _placed(1, start=(0, 0), end=(3, 0)).with_offset(100), + _placed(2, start=(0, 0), end=(0, 3)), + ), + {}, + ), + ( + Memory(id="m", pools=(_pool(1, *_concurrent_memory().pools[0].allocations),)), + {}, + ), + ( + _pool( + 1, + _placed(1, start=(0, 1), end=(2, 3)), + _placed(2, start=(2, 3), end=(4, 5)), + _placed(3, size=50, start=(1, 0), end=(3, 4)).with_offset(100), + ), + {"view": "lanes"}, + ), +] -def test_visualize_with_capacities(artifacts_dir: Path) -> None: - 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) - system = System(id="test_sys", memories=(memory,)) - custom_limits = { - "ddr_mem": {"budget": 200, "threshold": 220}, - "other_mem": {"ignored": 999}, - } +@pytest.mark.parametrize(("entity", "kwargs"), PLOTS) +def test_plot_allocation_writes_a_pdf( + entity: object, kwargs: dict[str, object], tmp_path: Path +) -> None: + path = tmp_path / "plot.pdf" + plot_allocation(entity, path, **kwargs) + assert path.read_bytes().startswith(b"%PDF-") - output_path = artifacts_dir / "test_memory_limits.pdf" - plot_allocation(system, output_path, capacities=custom_limits) - assert output_path.exists() - assert output_path.stat().st_size > 0 +def test_plot_allocation_writes_a_png_for_a_png_path(tmp_path: Path) -> None: + path = tmp_path / "plot.png" + plot_allocation(ONE_POOL, path) + assert path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") -def test_visualize_saves_png_when_path_has_png_extension(artifacts_dir: Path) -> None: - 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_extension.png" - plot_allocation(pool, output_path) - with output_path.open("rb") as f: - assert f.read(8) == b"\x89PNG\r\n\x1a\n" +UNPLACED = Allocation(id=1, size=10, start=0, end=5) +EMPTY_SYSTEM = System(id="empty", memories=()) -def test_visualize_still_saves_pdf_by_default(artifacts_dir: Path) -> None: - 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_extension.pdf" - plot_allocation(pool, output_path) - with output_path.open("rb") as f: - assert f.read(5) == b"%PDF-" +@pytest.mark.parametrize( + ("entity", "kwargs", "match"), + [ + (ONE_POOL, {"view": "spiral"}, "view"), + (ONE_POOL, {"max_lanes": 0}, "max_lanes"), + (ONE_POOL, {"max_lanes": 2}, "max_lanes"), + (EMPTY_SYSTEM, {}, "no memories"), + (EMPTY_SYSTEM, {"view": "lanes"}, "no memories"), + (Pool(id="p", allocations=(UNPLACED,)), {}, "offsets"), + ], +) +def test_plot_allocation_rejects( + entity: object, kwargs: dict[str, object], match: str +) -> None: + with pytest.raises(ValueError, match=match): + plot_allocation(entity, **kwargs) def test_byte_unit_picks_largest_unit_that_keeps_value_above_one() -> None: @@ -229,58 +174,6 @@ def test_format_bytes_does_not_collapse_small_values_to_zero() -> None: assert _format_bytes(200) == "200.0B" -def test_visualize_memory_with_empty_pool(artifacts_dir: Path) -> None: - alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) - memory = Memory( - id="mem", - pools=( - Pool(id="empty", allocations=(), offset=0), - Pool(id="full", allocations=(alloc,), offset=0), - ), - ) - output_path = artifacts_dir / "test_empty_pool.pdf" - 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" - plot_allocation(pool, output_path) - assert output_path.exists() - - -def test_visualize_mixed_dimension_pools(artifacts_dir: Path) -> None: - scalar_pool = Pool( - id=1, - allocations=(Allocation(id=1, size=100, start=0, end=4, offset=0),), - offset=0, - ) - vector_pool = Pool( - id=2, - 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) - - output_path = artifacts_dir / "test_mixed_dim_pools.pdf" - plot_allocation(memory, output_path) - assert output_path.exists() - assert output_path.stat().st_size > 0 - - -def test_visualize_vector_time_lanes(artifacts_dir: Path) -> None: - alloc1 = Allocation(id=1, size=100, start=(0, 1), end=(2, 3), offset=0) - alloc2 = Allocation(id=2, size=100, start=(2, 3), end=(4, 5), offset=0) - alloc3 = Allocation(id=3, size=50, start=(1, 0), end=(3, 4), offset=100) - pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3), offset=0) - - output_path = artifacts_dir / "test_vector_lanes.pdf" - 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: @@ -293,42 +186,6 @@ def test_plot_allocation_without_path_shows_figure( 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"): - plot_allocation(pool, view="spiral") - - -def test_plot_allocation_rejects_non_positive_max_lanes() -> None: - pool = Pool(id=1, allocations=(Allocation(id=1, size=1, start=0, end=1),)) - with pytest.raises(ValueError, match="max_lanes"): - plot_allocation(pool, max_lanes=0) - - -def test_plot_allocation_rejects_max_lanes_with_panel_view() -> None: - pool = Pool(id=1, allocations=(Allocation(id=1, size=1, start=0, end=1),)) - with pytest.raises(ValueError, match="max_lanes"): - plot_allocation(pool, max_lanes=2) - - -def test_plot_allocation_rejects_empty_system() -> None: - with pytest.raises(ValueError, match="no memories"): - plot_allocation(System(id="empty", memories=())) - with pytest.raises(ValueError, match="no memories"): - plot_allocation(System(id="empty", memories=()), view="lanes") - - -def test_visualize_vector_time_panel(artifacts_dir: Path) -> None: - alloc1 = Allocation(id=1, size=100, start=(0, 0), end=(3, 0), offset=100) - alloc2 = Allocation(id=2, size=100, start=(0, 0), end=(0, 3), offset=0) - pool = Pool(id=1, allocations=(alloc1, alloc2), offset=0) - - output_path = artifacts_dir / "test_vector_panel.pdf" - plot_allocation(pool, output_path) - assert output_path.exists() - assert output_path.stat().st_size > 0 - - def test_panel_extents_scalar_memory_is_identity_and_exact() -> None: alloc1 = Allocation(id=1, size=10, start=2, end=7, offset=0) alloc2 = Allocation(id=2, size=10, start=5, end=9, offset=10) @@ -355,16 +212,6 @@ def test_panel_extents_linearizable_vector_memory_is_exact() -> None: assert all(start < end for start, end in starts) -def _concurrent_memory() -> Memory: - alloc1 = Allocation(id=1, size=10, start=(0, 0), end=(1, 0), offset=0) - alloc2 = Allocation(id=2, size=10, start=(2, 0), end=(3, 0), offset=0) - alloc3 = Allocation(id=3, size=10, start=(0, 0), end=(0, 1), offset=10) - alloc4 = Allocation(id=4, size=10, start=(0, 2), end=(0, 3), offset=10) - return Memory( - id="mem", pools=(Pool(id=1, allocations=(alloc1, alloc2, alloc3, alloc4)),) - ) - - def test_panel_extents_concurrent_vector_memory_falls_back_to_sums() -> None: memory = _concurrent_memory() alloc1, alloc2, alloc3, alloc4 = memory.pools[0].allocations @@ -516,7 +363,7 @@ def test_lane_panels_titles_report_truncation() -> None: assert caveat is not None -def test_panel_projection_never_shows_false_conflicts(artifacts_dir: Path) -> None: +def test_panel_projection_never_shows_false_conflicts() -> None: rng_starts = [(i, 0) if i % 2 == 0 else (0, i) for i in range(1, 9)] allocations = tuple( Allocation( @@ -541,10 +388,6 @@ def test_panel_projection_never_shows_false_conflicts(artifacts_dir: Path) -> No if sa < eb and sb < ea: assert a.conflicts_with(b) - output_path = artifacts_dir / "test_panel_soundness.pdf" - plot_allocation(memory, output_path) - assert output_path.exists() - def test_y_limits_cover_a_pool_pinned_above_the_sum_of_pool_sizes() -> None: high = Pool( @@ -559,33 +402,3 @@ def test_y_limits_cover_a_pool_pinned_above_the_sum_of_pool_sizes() -> None: ) _, upper = _y_limits(Memory(id="m", pools=(high, low))) assert upper >= 1010 - - -def test_plot_stacks_packed_pools_without_a_base(artifacts_dir: Path) -> None: - pools = tuple( - Pool( - id=name, allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),) - ) - for name in ("a", "b") - ) - path = artifacts_dir / "unplaced_bases.pdf" - plot_allocation(Memory(id="m", pools=pools), path) - assert path.read_bytes().startswith(b"%PDF-") - - -def test_plot_rejects_unallocated_entity() -> None: - pool = Pool(id="p", allocations=(Allocation(id=1, size=10, start=0, end=5),)) - with pytest.raises(ValueError, match="offsets"): - plot_allocation(pool) - - -def test_pool_capacities_key_by_pool_id(artifacts_dir: Path) -> None: - pool = Pool( - id="scratch", - allocations=(Allocation(id=1, size=100, start=0, end=5, offset=0),), - offset=0, - ) - output_path = artifacts_dir / "test_pool_capacities.pdf" - plot_allocation(pool, output_path, capacities={"scratch": {"budget": 200}}) - assert output_path.exists() - assert output_path.stat().st_size > 0 From eb7699018c05256a0677883c728ce706d451c58b Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 15:59:42 +0000 Subject: [PATCH 09/17] Flatten the analysis tests into tables of instances and errors --- tests/unit/analysis/test_conflicts.py | 249 +++---- tests/unit/analysis/test_linearize.py | 232 +++---- tests/unit/analysis/test_pressure.py | 617 ++++++------------ .../test_vector_conflict_properties.py | 34 +- 4 files changed, 359 insertions(+), 773 deletions(-) diff --git a/tests/unit/analysis/test_conflicts.py b/tests/unit/analysis/test_conflicts.py index d2d6d14..f08a0b5 100644 --- a/tests/unit/analysis/test_conflicts.py +++ b/tests/unit/analysis/test_conflicts.py @@ -9,96 +9,79 @@ from omnimalloc.primitives import Allocation -def test_conflicts_empty() -> None: - assert conflicts(()) == {} +def _a(i: object, start: object, end: object) -> Allocation: + return Allocation(id=i, size=8, start=start, end=end) + + +OVERLAP = (_a(1, 0, 4), _a(2, 2, 6), _a(3, 6, 8)) +CLIQUE = tuple(_a(i, 0, 10) for i in range(4)) +DUPLICATED = (_a(1, 0, 2), _a(1, 1, 3)) +MIXED = (_a(1, 0, 1), _a(2, (0, 0), (1, 1))) +SKEWED = tuple(_a(i, (i, 2 * i), (i + 10, 2 * i + 10)) for i in range(4)) +FIFTY = tuple(_a(i, 0, 1) for i in range(50)) + + +@pytest.mark.parametrize( + ("allocations", "expected"), + [ + ((), {}), + (OVERLAP, {1: {2}, 2: {1}, 3: set()}), + ((_a(1, 0, 4), _a(2, 4, 6)), {1: set(), 2: set()}), + ((_a("a", (0, 0), (1, 0)), _a("b", (0, 0), (0, 1))), {"a": {"b"}, "b": {"a"}}), + ((_a("a", (0, 0), (1, 0)), _a("b", (1, 0), (2, 0))), {"a": set(), "b": set()}), + ((_a(1, (0, 5), (4, 5)), _a(2, (5, 0), (9, 0))), {1: {2}, 2: {1}}), + ], +) +def test_conflicts( + allocations: tuple[Allocation, ...], expected: dict[object, set[object]] +) -> None: + assert conflicts(allocations) == expected + assert conflicts(allocations, work_budget=None) == expected + + +@pytest.mark.parametrize( + ("allocations", "work_budget", "expected"), + [ + ((), 1, []), + (OVERLAP, 1, [1, 1, 0]), + (DUPLICATED, 1, [1, 1]), + ((_a(1, (0, 0), (2, 2)), _a(1, (1, 1), (3, 3))), None, [1, 1]), + (CLIQUE, 1, [3, 3, 3, 3]), + (CLIQUE, None, [3, 3, 3, 3]), + ], +) +def test_conflict_degrees( + allocations: tuple[Allocation, ...], work_budget: int | None, expected: list[int] +) -> None: + assert conflict_degrees(allocations, work_budget=work_budget) == expected + + +@pytest.mark.parametrize( + ("query", "allocations", "kwargs", "error", "match"), + [ + (conflicts, DUPLICATED, {}, ValueError, "unique"), + (conflicts, MIXED, {}, ValueError, "dimension"), + (conflicts, CLIQUE, {"work_budget": 1}, RuntimeError, "work_budget"), + (conflicts, (), {"work_budget": -1}, ValueError, "must be non-negative"), + (conflict_degrees, SKEWED, {"work_budget": 1}, RuntimeError, "work_budget"), + (conflict_degrees, (), {"work_budget": -1}, ValueError, "must be non-negative"), + (conflict_graph, FIFTY, {"max_entries": 100}, RuntimeError, "neighbor entries"), + (conflict_graph, FIFTY, {"max_entries": -1}, ValueError, "max_entries"), + ], +) +def test_conflict_queries_reject( + query: object, + allocations: tuple[Allocation, ...], + kwargs: dict[str, int], + error: type[Exception], + match: str, +) -> None: + with pytest.raises(error, match=match): + query(allocations, **kwargs) -def test_conflicts_scalar_overlap() -> None: - allocations = ( - Allocation(id=1, size=8, start=0, end=4), - Allocation(id=2, size=8, start=2, end=6), - Allocation(id=3, size=8, start=6, end=8), - ) - assert conflicts(allocations) == {1: {2}, 2: {1}, 3: set()} - - -def test_conflicts_touching_intervals_do_not_conflict() -> None: - allocations = ( - Allocation(id=1, size=8, start=0, end=4), - Allocation(id=2, size=8, start=4, end=6), - ) - assert conflicts(allocations) == {1: set(), 2: set()} - - -def test_conflicts_vector_concurrent() -> None: - allocations = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=8, start=(0, 0), end=(0, 1)), - ) - assert conflicts(allocations) == {"a": {"b"}, "b": {"a"}} - - -def test_conflicts_vector_ordered_do_not_conflict() -> None: - allocations = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=8, start=(1, 0), end=(2, 0)), - ) - assert conflicts(allocations) == {"a": set(), "b": set()} - - -def test_conflicts_rejects_duplicate_ids() -> None: - duplicated = ( - Allocation(id=1, size=8, start=0, end=2), - Allocation(id=1, size=8, start=1, end=3), - ) - with pytest.raises(ValueError, match="unique"): - conflicts(duplicated) - - -def test_conflicts_rejects_mixed_dimensions() -> None: - mixed = ( - Allocation(id=1, size=8, start=0, end=1), - Allocation(id=2, size=8, start=(0, 0), end=(1, 1)), - ) - with pytest.raises(ValueError, match="dimension"): - conflicts(mixed) - - -def test_conflicts_over_budget_raise() -> None: - allocations = tuple(Allocation(id=i, size=8, start=0, end=10) for i in range(4)) - 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 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"): - conflicts((), work_budget=-1) - - -def test_conflict_degrees_empty() -> None: - assert conflict_degrees(()) == [] - - -def test_conflict_degrees_align_with_input_order() -> None: - allocations = ( - Allocation(id=1, size=8, start=0, end=4), - Allocation(id=2, size=8, start=2, end=6), - Allocation(id=3, size=8, start=6, end=8), - ) - assert conflict_degrees(allocations) == [1, 1, 0] - - -def test_conflict_degrees_allow_duplicate_ids() -> None: - duplicated = ( - Allocation(id=1, size=8, start=0, end=2), - Allocation(id=1, size=8, start=1, end=3), - ) - assert conflict_degrees(duplicated) == [1, 1] +def test_conflict_graph_builds_an_adjacency_inside_the_entry_ceiling() -> None: + assert conflict_graph(FIFTY, max_entries=50 * 49).pair_count == 50 * 49 // 2 def test_conflict_degrees_tiny_budget_admits_degenerate_clock_columns() -> None: @@ -108,30 +91,6 @@ def test_conflict_degrees_tiny_budget_admits_degenerate_clock_columns() -> None: assert conflict_degrees(lockstep, work_budget=1) == conflict_degrees(lockstep) -def test_conflict_degrees_over_budget_raise() -> None: - allocations = tuple( - Allocation(id=i, size=8, start=(i, 2 * i), end=(i + 10, 2 * i + 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 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 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"): - conflict_degrees((), work_budget=-1) - - def test_conflict_degrees_default_budget_admits_a_wide_clock_sweep() -> None: allocations = tuple( Allocation(id=i, size=8, start=(0,) * 64, end=(1,) * 64) for i in range(3000) @@ -139,18 +98,6 @@ def test_conflict_degrees_default_budget_admits_a_wide_clock_sweep() -> None: assert set(conflict_degrees(allocations)) == {2999} -def test_conflicts_and_degrees_agree_on_vector_input() -> None: - allocations = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=8, start=(0, 0), end=(0, 1)), - Allocation(id="c", size=8, start=(1, 1), end=(2, 1)), - Allocation(id="d", size=8, start=(0, 0), end=(2, 2)), - ) - conflict_map = conflicts(allocations) - degrees = conflict_degrees(allocations) - assert [len(conflict_map[a.id]) for a in allocations] == degrees - - def test_conflicts_is_deterministic_under_parallel_fill() -> None: rng = Random(9) allocations = [] @@ -163,40 +110,6 @@ def test_conflicts_is_deterministic_under_parallel_fill() -> None: assert conflicts(fixed) == conflicts(fixed) -def test_conflicts_ignore_constant_padding_columns() -> None: - scalar = ( - Allocation(id=1, size=8, start=0, end=4), - Allocation(id=2, size=8, start=2, end=6), - Allocation(id=3, size=8, start=6, end=8), - ) - padded = ( - Allocation(id=1, size=8, start=(0, 0, 0), end=(4, 0, 0)), - Allocation(id=2, size=8, start=(2, 0, 0), end=(6, 0, 0)), - Allocation(id=3, size=8, start=(6, 0, 0), end=(8, 0, 0)), - ) - assert conflicts(padded) == conflicts(scalar) - - -def test_conflicts_ignore_duplicate_columns() -> None: - scalar = ( - Allocation(id=1, size=8, start=0, end=4), - Allocation(id=2, size=8, start=2, end=6), - ) - lockstep = ( - Allocation(id=1, size=8, start=(0, 0, 0), end=(4, 4, 4)), - Allocation(id=2, size=8, start=(2, 2, 2), end=(6, 6, 6)), - ) - assert conflicts(lockstep) == conflicts(scalar) - - -def test_conflicts_keep_column_pinned_per_row_but_varying_across_rows() -> None: - allocations = ( - Allocation(id=1, size=8, start=(0, 5), end=(4, 5)), - Allocation(id=2, size=8, start=(5, 0), end=(9, 0)), - ) - assert conflicts(allocations) == {1: {2}, 2: {1}} - - def _random_instance(rng: Random) -> tuple[Allocation, ...]: dim = rng.choice((1, 2, 3)) allocations = [] @@ -225,6 +138,8 @@ def test_conflicts_match_pairwise_overlaps() -> None: if other.id != alloc.id and alloc.conflicts_with(other) } assert conflict_map[alloc.id] == expected + degrees = [len(conflict_map[alloc.id]) for alloc in allocations] + assert conflict_degrees(allocations) == degrees def test_degrees_match_conflict_map_on_degenerate_clocks() -> None: @@ -254,24 +169,6 @@ def test_degrees_match_conflict_map_on_degenerate_clocks() -> None: assert degree == len(expected[alloc.id]) -def test_conflict_graph_refuses_an_adjacency_over_the_entry_ceiling() -> None: - allocations = tuple(Allocation(id=i, size=8, start=0, end=1) for i in range(50)) - with pytest.raises(RuntimeError, match="neighbor entries"): - conflict_graph(allocations, max_entries=100) - - -def test_conflict_graph_builds_an_adjacency_inside_the_entry_ceiling() -> None: - allocations = tuple(Allocation(id=i, size=8, start=0, end=1) for i in range(50)) - graph = conflict_graph(allocations, max_entries=50 * 49) - assert graph.pair_count == 50 * 49 // 2 - - -def test_conflict_graph_rejects_a_negative_entry_ceiling() -> None: - allocations = (Allocation(id=1, size=8, start=0, end=1),) - with pytest.raises(ValueError, match="max_entries"): - conflict_graph(allocations, max_entries=-1) - - def test_columns_agreeing_until_the_last_row_keep_the_exact_relation() -> None: # The shape a column-against-column reduction spends O(n * d^2) on: every # column matches column 0 until the final row, where each diverges, so diff --git a/tests/unit/analysis/test_linearize.py b/tests/unit/analysis/test_linearize.py index 1766d52..4e4369c 100644 --- a/tests/unit/analysis/test_linearize.py +++ b/tests/unit/analysis/test_linearize.py @@ -8,6 +8,7 @@ from omnimalloc import analysis from omnimalloc.allocators.supermalloc import SupermallocAllocator from omnimalloc.analysis import conflicts, try_linearize +from omnimalloc.common.constants import DEFAULT_WORK_BUDGET from omnimalloc.primitives import Allocation from omnimalloc.primitives.pool import Pool from omnimalloc.validate import validate_allocation @@ -18,92 +19,104 @@ def _overlap_map(allocations: tuple[Allocation, ...]) -> dict[object, set[object return {a.id: set(conflict_map[a.id]) for a in allocations} -def test_scalar_input_returned_unchanged() -> None: - allocations = ( - Allocation(id=1, size=8, start=0, end=4), - Allocation(id=2, size=8, start=2, end=6), - ) - assert try_linearize(allocations) is allocations - - -def test_empty_input_returned_unchanged() -> None: - assert try_linearize(()) == () - - -def test_ordered_chain_linearizes() -> None: - allocations = tuple( - Allocation(id=i, size=8, start=(i, i), end=(i + 1, i + 1)) for i in range(4) - ) - linearized = try_linearize(allocations) +def _a(i: object, start: object, end: object) -> Allocation: + return Allocation(id=i, size=8, start=start, end=end) + + +TWO_PLUS_TWO = ( + _a("a", (0, 0), (1, 0)), + _a("b", (1, 0), (2, 0)), + _a("c", (0, 0), (0, 1)), + _a("d", (0, 1), (0, 2)), +) +SKEWED = tuple(_a(i, (i, 2 * i), (i + 1, 2 * i + 2)) for i in range(4)) +DEGENERATE = tuple(_a(i, (i, i, 0), (i + 1, i + 1, 0)) for i in range(4)) + + +@pytest.mark.parametrize( + ("allocations", "work_budget"), + [ + (tuple(_a(i, (i, i), (i + 1, i + 1)) for i in range(4)), DEFAULT_WORK_BUDGET), + ((_a(1, (0, 5), (1, 6)), _a(2, (2, 0), (3, 1))), DEFAULT_WORK_BUDGET), + (SKEWED, None), + (DEGENERATE, 2 * 4 * 3), + (tuple(_a(i, (i,) * 4, (i + 2,) * 4) for i in range(4)), DEFAULT_WORK_BUDGET), + ( + ( + _a(1, (0, 0), (2, 1)), + _a(2, (0, 0), (2, 1)), + _a(3, (2, 1), (3, 2)), + _a(4, (2, 1), (3, 2)), + ), + DEFAULT_WORK_BUDGET, + ), + ], +) +def test_linearization_keeps_the_conflicts( + allocations: tuple[Allocation, ...], work_budget: int | None +) -> None: + linearized = try_linearize(allocations, work_budget=work_budget) assert linearized is not None assert all(a.dim == 1 for a in linearized) assert _overlap_map(linearized) == _overlap_map(allocations) -def test_concurrent_pair_linearizes_to_overlap() -> None: - allocations = ( - Allocation(id=1, size=100, start=(0, 5), end=(1, 6)), - Allocation(id=2, size=100, start=(2, 0), end=(3, 1)), - ) - linearized = try_linearize(allocations) - assert linearized is not None - assert linearized[0].conflicts_with(linearized[1]) - - -def test_two_plus_two_returns_none() -> None: - allocations = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=8, start=(1, 0), end=(2, 0)), - Allocation(id="c", size=8, start=(0, 0), end=(0, 1)), - Allocation(id="d", size=8, start=(0, 1), end=(0, 2)), - ) +@pytest.mark.parametrize( + "allocations", + [ + TWO_PLUS_TWO, + tuple( + _a( + a.id, + (a.start[0], a.start[0], 3, a.start[1]), + (a.end[0], a.end[0], 3, a.end[1]), + ) + for a in TWO_PLUS_TWO + ), + ( + _a(0, (0, 0), (1, 0)), + _a(1, (0, 0), (0, 1)), + _a(2, (2, 0), (3, 0)), + _a(3, (0, 2), (0, 3)), + ), + ], +) +def test_a_non_interval_order_does_not_linearize( + allocations: tuple[Allocation, ...], +) -> None: assert try_linearize(allocations) is None + assert try_linearize(allocations, work_budget=None) is None + + +@pytest.mark.parametrize( + ("allocations", "work_budget", "error", "match"), + [ + (SKEWED, 1, RuntimeError, "exceeds work_budget"), + (DEGENERATE, 0, RuntimeError, "exceeds work_budget"), + ((), -1, ValueError, "work_budget must be non-negative"), + ((_a(1, 0, 4), _a(2, (0, 1), (2, 2))), None, ValueError, "dimension"), + ], +) +def test_linearize_rejects( + allocations: tuple[Allocation, ...], + work_budget: int | None, + error: type[Exception], + match: str, +) -> None: + with pytest.raises(error, match=match): + try_linearize(allocations, work_budget=work_budget) -def test_tiny_work_budget_raises_instead_of_reporting_no_linearization() -> None: - allocations = tuple( - Allocation(id=i, size=8, start=(i, 2 * i), end=(i + 1, 2 * i + 2)) - for i in range(4) - ) - with pytest.raises(RuntimeError, match="exceeds work_budget"): - try_linearize(allocations, work_budget=1) - - -def test_unbounded_work_budget_decides() -> None: - allocations = tuple( - Allocation(id=i, size=8, start=(i, 2 * i), end=(i + 1, 2 * i + 2)) - for i in range(4) - ) - assert try_linearize(allocations, work_budget=None) is not None - - -def test_degenerate_columns_decide_once_budget_covers_the_reduction() -> None: - lockstep = tuple( - Allocation(id=i, size=8, start=(i, i, 0), end=(i + 1, i + 1, 0)) - for i in range(4) - ) - linearized = try_linearize(lockstep, work_budget=2 * 4 * 3) - assert linearized is not None - assert _overlap_map(linearized) == _overlap_map(lockstep) - - -def test_zero_budget_refuses_even_degenerate_columns() -> None: - lockstep = tuple( - Allocation(id=i, size=8, start=(i, i, 0), end=(i + 1, i + 1, 0)) - for i in range(4) +def test_scalar_input_returned_unchanged() -> None: + allocations = ( + Allocation(id=1, size=8, start=0, end=4), + Allocation(id=2, size=8, start=2, end=6), ) - with pytest.raises(RuntimeError, match="exceeds work_budget"): - try_linearize(lockstep, work_budget=0) + assert try_linearize(allocations) is allocations -def test_all_duplicate_columns_linearize_and_preserve_overlaps() -> None: - quad = tuple( - Allocation(id=i, size=8, start=(i,) * 4, end=(i + 2,) * 4) for i in range(4) - ) - linearized = try_linearize(quad) - assert linearized is not None - assert all(a.dim == 1 for a in linearized) - assert _overlap_map(linearized) == _overlap_map(quad) +def test_empty_input_returned_unchanged() -> None: + assert try_linearize(()) == () def test_single_varying_column_behaves_like_the_scalar_instance() -> None: @@ -138,30 +151,6 @@ def test_partial_column_reduction_matches_the_unpadded_instance() -> None: ] -def test_partial_column_reduction_keeps_two_plus_two_refused() -> None: - padded = ( - Allocation(id="a", size=8, start=(0, 0, 3, 0), end=(1, 1, 3, 0)), - Allocation(id="b", size=8, start=(1, 1, 3, 0), end=(2, 2, 3, 0)), - Allocation(id="c", size=8, start=(0, 0, 3, 0), end=(0, 0, 3, 1)), - Allocation(id="d", size=8, start=(0, 0, 3, 1), end=(0, 0, 3, 2)), - ) - assert try_linearize(padded, work_budget=None) is None - - -def test_negative_work_budget_rejected() -> None: - with pytest.raises(ValueError, match="work_budget must be non-negative"): - try_linearize((), work_budget=-1) - - -def test_mixed_dimensions_rejected() -> None: - mixed = ( - Allocation(id=1, size=8, start=0, end=4), - Allocation(id=2, size=8, start=(0, 1), end=(2, 2)), - ) - with pytest.raises(ValueError, match="dimension"): - try_linearize(mixed) - - def test_linearize_preserves_metadata() -> None: allocations = ( Allocation(id="x", size=64, start=(0, 0), end=(2, 1), offset=128), @@ -175,40 +164,6 @@ def test_linearize_preserves_metadata() -> None: ] -def test_linearize_preserves_conflicts_on_random_interval_orders() -> None: - rng = random.Random(3) - for _ in range(20): - allocs = [] - for i in range(rng.randint(2, 20)): - size = rng.randint(1, 64) - start = rng.randint(0, 12) - allocs.append( - Allocation(id=i, size=size, start=start, end=start + rng.randint(1, 6)) - ) - scalar = tuple(allocs) - lockstep = tuple( - Allocation( - id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end) - ) - for a in scalar - ) - linearized = try_linearize(lockstep) - assert linearized is not None - assert _overlap_map(linearized) == _overlap_map(scalar) - - -def test_linearize_handles_duplicate_clock_values() -> None: - allocations = ( - Allocation(id=1, size=8, start=(0, 0), end=(2, 1)), - Allocation(id=2, size=16, start=(0, 0), end=(2, 1)), - Allocation(id=3, size=32, start=(2, 1), end=(3, 2)), - Allocation(id=4, size=64, start=(2, 1), end=(3, 2)), - ) - linearized = try_linearize(allocations) - assert linearized is not None - assert _overlap_map(linearized) == _overlap_map(allocations) - - def test_linearize_merges_distinct_starts_with_equal_predecessors() -> None: allocations = ( Allocation(id=1, size=8, start=(0, 0), end=(1, 1)), @@ -271,16 +226,5 @@ def test_linearize_unlocks_supermalloc() -> None: validate_allocation(Pool(id="p", allocations=placed)) -def test_non_interval_order_returns_none_under_any_budget() -> None: - obstruction = ( - Allocation(id=0, size=8, start=(0, 0), end=(1, 0)), - Allocation(id=1, size=8, start=(0, 0), end=(0, 1)), - Allocation(id=2, size=8, start=(2, 0), end=(3, 0)), - Allocation(id=3, size=8, start=(0, 2), end=(0, 3)), - ) - assert try_linearize(obstruction, work_budget=None) is None - assert try_linearize(obstruction) is None - - def test_linearize_module_hidden_from_analysis_namespace() -> None: assert not hasattr(analysis, "linearize") diff --git a/tests/unit/analysis/test_pressure.py b/tests/unit/analysis/test_pressure.py index a0a59e3..954b20b 100644 --- a/tests/unit/analysis/test_pressure.py +++ b/tests/unit/analysis/test_pressure.py @@ -2,11 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 # +from collections.abc import Callable from itertools import combinations from random import Random import pytest -from omnimalloc import allocate from omnimalloc.allocators.omni import OmniAllocator from omnimalloc.analysis import ( antichain_pressure, @@ -19,360 +19,192 @@ from omnimalloc.primitives import Allocation -def test_pressure_empty_is_zero() -> None: - assert antichain_pressure(()) == 0 +def _a( + i: object, size: int, start: object, end: object, offset: int | None = None +) -> Allocation: + return Allocation(id=i, size=size, start=start, end=end, offset=offset) -def test_pressure_scalar_overlap() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=4), - Allocation(id=2, size=50, start=2, end=6), - Allocation(id=3, size=25, start=6, end=8), - ) - assert antichain_pressure(allocations) == 150 - - -def test_pressure_scalar_disjoint() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=2), - Allocation(id=2, size=50, start=2, end=4), - ) - assert antichain_pressure(allocations) == 100 - - -def test_pressure_linearizable_vector_is_exact() -> None: - allocations = ( - Allocation(id=1, size=100, start=(0, 0), end=(2, 1)), - Allocation(id=2, size=50, start=(1, 0), end=(3, 2)), - Allocation(id=3, size=25, start=(3, 2), end=(4, 3)), - ) - assert antichain_pressure(allocations) == 150 - - -def test_pressure_non_linearizable_is_exact() -> None: - two_plus_two = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=16, start=(1, 0), end=(2, 0)), - Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), - Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), - ) - assert antichain_pressure(two_plus_two) == 16 + 64 - - -def test_pressure_matches_scalar_equivalent_under_lockstep() -> None: - scalar = ( - Allocation(id=1, size=100, start=0, end=4), - Allocation(id=2, size=50, start=2, end=6), - Allocation(id=3, size=25, start=5, end=8), - ) - lockstep = tuple( - Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) - for a in scalar - ) - assert antichain_pressure(lockstep) == antichain_pressure(scalar) - - -def test_pressure_scalar_ignores_work_budget() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=4), - Allocation(id=2, size=50, start=2, end=6), - ) - assert antichain_pressure(allocations, work_budget=1) == 150 - - -def test_pressure_work_budget_exceeded_raises() -> None: - two_plus_two = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=16, start=(1, 0), end=(2, 0)), - Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), - Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), - ) - with pytest.raises(RuntimeError, match="work_budget"): - antichain_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"): - antichain_pressure((), work_budget=-1) - - -def test_closure_pressure_negative_cap_rejected() -> None: - with pytest.raises(ValueError, match="closure_cap must be non-negative"): - 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(ValueError, match="int64"): - antichain_pressure(allocations) - - -def test_closure_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(ValueError, match="int64"): - closure_pressure(allocations) - with pytest.raises(ValueError, match="int64"): - closure_pressure_per_allocation(allocations) - - -def test_pressure_unbudgeted_empty_is_zero() -> None: - assert antichain_pressure((), work_budget=None) == 0 - - -def test_closure_pressure_empty_is_zero() -> None: - assert closure_pressure(()) == 0 - - -def test_exact_pressures_match_scalar_sweep() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=4), - Allocation(id=2, size=50, start=2, end=6), - Allocation(id=3, size=25, start=6, end=8), - ) - assert antichain_pressure(allocations, work_budget=None) == 150 - assert closure_pressure(allocations) == 150 - - -def test_pressure_unbudgeted_two_plus_two_exact() -> None: - two_plus_two = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=16, start=(1, 0), end=(2, 0)), - Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), - Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), - ) - assert antichain_pressure(two_plus_two, work_budget=None) == 16 + 64 - - -def test_closure_pressure_below_antichain_without_common_cut() -> None: - pinwheel = ( - Allocation(id="i", size=1, start=(0, 0), end=(2, 2)), - Allocation(id="j", size=1, start=(3, 0), end=(4, 1)), - Allocation(id="k", size=1, start=(0, 3), end=(1, 4)), - ) - assert antichain_pressure(pinwheel, work_budget=None) == 3 - assert closure_pressure(pinwheel) == 2 - - -def test_closure_pressure_cap_raises() -> None: - allocations = tuple( - Allocation(id=i, size=1, start=(i, 8 - i, 0), end=(i + 1, 9 - i, 9)) - for i in range(8) - ) - with pytest.raises(RuntimeError, match="closure_cap"): - closure_pressure(allocations, closure_cap=4) - - -def test_exact_pressures_reject_mixed_dimensions() -> None: - mixed = ( - Allocation(id=1, size=8, start=(0, 0), end=(1, 1)), - Allocation(id=2, size=8, start=(0, 0, 0), end=(1, 1, 1)), - ) - with pytest.raises(ValueError, match="dimension"): - antichain_pressure(mixed, work_budget=None) - with pytest.raises(ValueError, match="dimension"): - closure_pressure(mixed) - - -def test_exact_pressures_match_scalar_equivalent_under_lockstep() -> None: - scalar = ( - Allocation(id=1, size=100, start=0, end=4), - Allocation(id=2, size=50, start=2, end=6), - Allocation(id=3, size=25, start=5, end=8), - ) - lockstep = tuple( - Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) - for a in scalar - ) - assert antichain_pressure(lockstep, work_budget=None) == antichain_pressure(scalar) - assert closure_pressure(lockstep) == antichain_pressure(scalar) - - -def test_per_allocation_pressures_empty() -> None: - assert antichain_pressure_per_allocation(()) == {} - assert closure_pressure_per_allocation(()) == {} - assert placement_pressure_per_allocation(()) == {} - - -def test_per_allocation_pressure_scalar() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=4), - Allocation(id=2, size=50, start=2, end=6), - Allocation(id=3, size=25, start=6, end=8), - ) - assert antichain_pressure_per_allocation(allocations) == {1: 150, 2: 150, 3: 25} - assert closure_pressure_per_allocation(allocations) == {1: 150, 2: 150, 3: 25} +OVERLAP = (_a(1, 100, 0, 4), _a(2, 50, 2, 6), _a(3, 25, 6, 8)) +DISJOINT = (_a(1, 100, 0, 2), _a(2, 50, 2, 4)) +LINEARIZABLE = ( + _a(1, 100, (0, 0), (2, 1)), + _a(2, 50, (1, 0), (3, 2)), + _a(3, 25, (3, 2), (4, 3)), +) +TWO_PLUS_TWO = ( + _a("a", 8, (0, 0), (1, 0)), + _a("b", 16, (1, 0), (2, 0)), + _a("c", 32, (0, 0), (0, 1)), + _a("d", 64, (0, 1), (0, 2)), +) +# Pairwise concurrent, but no cut holds all three: the closure bound is lower +PINWHEEL = ( + _a("i", 1, (0, 0), (2, 2)), + _a("j", 1, (3, 0), (4, 1)), + _a("k", 1, (0, 3), (1, 4)), +) +NO_COMMON_CUT = ( + _a(0, 20, (1, 0, 0), (1, 2, 1)), + _a(1, 20, (0, 2, 0), (1, 2, 1)), + _a(2, 30, (1, 0, 0), (1, 2, 0)), +) +WIDE_CLOSURE = tuple(_a(i, 1, (i, 8 - i, 0), (i + 1, 9 - i, 9)) for i in range(8)) +OVERFLOW = tuple(_a(i, 2**62, 0, 1) for i in range(4)) +MIXED = (_a(1, 8, (0, 0), (1, 1), 0), _a(2, 8, (0, 0, 0), (1, 1, 1), 8)) +DUPLICATED = (_a(1, 8, 0, 2, 0), _a(1, 8, 1, 3, 8)) +UNPLACED = (_a(1, 8, 0, 2),) +PLACED_TWO_PLUS_TWO = tuple( + a.with_offset(offset) + for a, offset in zip(TWO_PLUS_TWO, (96, 96, 0, 32), strict=True) +) -def test_per_allocation_pressure_two_plus_two() -> None: - two_plus_two = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=16, start=(1, 0), end=(2, 0)), - Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), - Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), +@pytest.mark.parametrize( + ("allocations", "antichain", "closure"), + [ + ((), 0, 0), + (OVERLAP, 150, 150), + (DISJOINT, 100, 100), + (LINEARIZABLE, 150, 150), + (TWO_PLUS_TWO, 80, 80), + (PINWHEEL, 3, 2), + (NO_COMMON_CUT, 70, 50), + (WIDE_CLOSURE, 8, 8), + ], +) +def test_pressures( + allocations: tuple[Allocation, ...], antichain: int, closure: int +) -> None: + assert antichain_pressure(allocations) == antichain + assert antichain_pressure(allocations, work_budget=None) == antichain + assert closure_pressure(allocations) == closure + assert closure_pressure(allocations, closure_cap=None) == closure + + +@pytest.mark.parametrize( + ("allocations", "antichain", "closure"), + [ + ((), {}, {}), + (OVERLAP, {1: 150, 2: 150, 3: 25}, {1: 150, 2: 150, 3: 25}), + ( + TWO_PLUS_TWO, + {"a": 72, "b": 80, "c": 48, "d": 80}, + {"a": 72, "b": 80, "c": 48, "d": 80}, + ), + (PINWHEEL, dict.fromkeys("ijk", 3), dict.fromkeys("ijk", 2)), + ], +) +def test_per_allocation_pressures( + allocations: tuple[Allocation, ...], + antichain: dict[object, int], + closure: dict[object, int], +) -> None: + assert antichain_pressure_per_allocation(allocations) == antichain + assert antichain_pressure_per_allocation(allocations, work_budget=None) == antichain + assert closure_pressure_per_allocation(allocations) == closure + + +@pytest.mark.parametrize( + ("allocations", "peak", "per_allocation"), + [ + ((), 0, {}), + ( + (_a("x", 5, 0, 2, 0), _a("y", 50, 1, 3, 5), _a("z", 5, 2, 4, 0)), + 55, + {"x": 55, "y": 55, "z": 55}, + ), + ( + ( + _a("long", 8, 0, 10, 0), + _a("tall", 100, 2, 4, 8), + _a("short", 10, 6, 8, 8), + ), + 108, + {"long": 108, "tall": 108, "short": 18}, + ), + ((_a("a", 8, 0, 2, 0), _a("b", 64, 8, 10, 0)), 64, {"a": 8, "b": 64}), + (PLACED_TWO_PLUS_TWO, 112, {"a": 104, "b": 112, "c": 112, "d": 112}), + ], +) +def test_placement_pressures( + allocations: tuple[Allocation, ...], peak: int, per_allocation: dict[object, int] +) -> None: + assert placement_pressure(allocations) == peak + assert placement_pressure_per_allocation(allocations) == per_allocation + assert ( + placement_pressure_per_allocation(allocations, work_budget=None) + == per_allocation ) - expected = {"a": 72, "b": 80, "c": 48, "d": 80} - assert antichain_pressure_per_allocation(two_plus_two) == expected - assert closure_pressure_per_allocation(two_plus_two) == expected - assert max(expected.values()) == antichain_pressure(two_plus_two) -def test_per_allocation_pressure_scalar_ignores_work_budget() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=4), - Allocation(id=2, size=50, start=2, end=6), - ) - assert antichain_pressure_per_allocation(allocations, work_budget=1) == { +def test_scalar_pressures_ignore_the_work_budget() -> None: + assert antichain_pressure(OVERLAP[:2], work_budget=1) == 150 + assert antichain_pressure_per_allocation(OVERLAP[:2], work_budget=1) == { 1: 150, 2: 150, } -def test_per_allocation_pressure_work_budget_exceeded_raises() -> None: - two_plus_two = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=16, start=(1, 0), end=(2, 0)), - Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), - Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), - ) - with pytest.raises(RuntimeError, match="work_budget"): - antichain_pressure_per_allocation(two_plus_two, work_budget=1) - - -def test_per_allocation_pressure_unbudgeted_matches_default() -> None: - two_plus_two = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0)), - Allocation(id="b", size=16, start=(1, 0), end=(2, 0)), - Allocation(id="c", size=32, start=(0, 0), end=(0, 1)), - Allocation(id="d", size=64, start=(0, 1), end=(0, 2)), - ) - assert antichain_pressure_per_allocation( - two_plus_two, work_budget=None - ) == antichain_pressure_per_allocation(two_plus_two) - - -def test_per_allocation_closure_below_pinned_without_common_cut() -> None: - pinwheel = ( - Allocation(id="i", size=1, start=(0, 0), end=(2, 2)), - Allocation(id="j", size=1, start=(3, 0), end=(4, 1)), - Allocation(id="k", size=1, start=(0, 3), end=(1, 4)), - ) - assert antichain_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: - scalar = ( - Allocation(id=1, size=100, start=0, end=4), - Allocation(id=2, size=50, start=2, end=6), - Allocation(id=3, size=25, start=5, end=8), - ) - lockstep = tuple( - Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) - for a in scalar - ) +def test_pressures_match_the_scalar_instance_under_lockstep() -> None: + scalar = (_a(1, 100, 0, 4), _a(2, 50, 2, 6), _a(3, 25, 5, 8)) + lockstep = tuple(_a(a.id, a.size, (a.start,) * 2, (a.end,) * 2) for a in scalar) + assert antichain_pressure(lockstep) == antichain_pressure(scalar) + assert antichain_pressure(lockstep, work_budget=None) == antichain_pressure(scalar) + assert closure_pressure(lockstep) == closure_pressure(scalar) assert antichain_pressure_per_allocation( lockstep ) == antichain_pressure_per_allocation(scalar) -def test_per_allocation_closure_pressure_cap_raises() -> None: - allocations = tuple( - Allocation(id=i, size=1, start=(i, 8 - i, 0), end=(i + 1, 9 - i, 9)) - for i in range(8) - ) - with pytest.raises(RuntimeError, match="closure_cap"): - closure_pressure_per_allocation(allocations, closure_cap=4) - - -def test_per_allocation_pressures_reject_duplicate_ids() -> None: - duplicated = ( - Allocation(id=1, size=8, start=0, end=2, offset=0), - Allocation(id=1, size=8, start=1, end=3, offset=8), - ) - with pytest.raises(ValueError, match="unique"): - antichain_pressure_per_allocation(duplicated) - with pytest.raises(ValueError, match="unique"): - closure_pressure_per_allocation(duplicated) - with pytest.raises(ValueError, match="unique"): - 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"): - placement_pressure_per_allocation(unplaced) - - -def test_placement_pressure_empty_is_zero() -> None: - assert placement_pressure(()) == 0 +BUDGET = {"work_budget": 1} +CAP = {"closure_cap": 4} -def test_placement_pressure_is_highest_occupied_address() -> None: - placed = ( - Allocation(id="x", size=5, start=0, end=2, offset=0), - Allocation(id="y", size=50, start=1, end=3, offset=5), - Allocation(id="z", size=5, start=2, end=4, offset=0), - ) - 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"): - placement_pressure(unplaced) - - -def test_placement_pressure_rejects_mixed_dimensions() -> None: - mixed = ( - Allocation(id=1, size=8, start=(0, 0), end=(1, 1), offset=0), - Allocation(id=2, size=8, start=(0, 0, 0), end=(1, 1, 1), offset=8), - ) - with pytest.raises(ValueError, match="dimension"): - placement_pressure(mixed) - - -def test_per_allocation_placement_pressure_max_equals_peak() -> None: - placed = ( - Allocation(id="x", size=5, start=0, end=2, offset=0), - Allocation(id="y", size=50, start=1, end=3, offset=5), - Allocation(id="z", size=5, start=2, end=4, offset=0), - ) - 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_budget_raises() -> None: - placed = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0), offset=96), - Allocation(id="b", size=16, start=(1, 0), end=(2, 0), offset=96), - Allocation(id="c", size=32, start=(0, 0), end=(0, 1), offset=0), - Allocation(id="d", size=64, start=(0, 1), end=(0, 2), offset=32), - ) - with pytest.raises(RuntimeError, match="work_budget"): - placement_pressure_per_allocation(placed, work_budget=1) - - -def test_per_allocation_placement_pressure_unbounded_budget_computes() -> None: - placed = ( - Allocation(id="a", size=8, start=(0, 0), end=(1, 0), offset=96), - Allocation(id="b", size=16, start=(1, 0), end=(2, 0), offset=96), - Allocation(id="c", size=32, start=(0, 0), end=(0, 1), offset=0), - Allocation(id="d", size=64, start=(0, 1), end=(0, 2), offset=32), - ) - expected = {"a": 104, "b": 112, "c": 112, "d": 112} - assert placement_pressure_per_allocation(placed, work_budget=None) == expected - assert placement_pressure_per_allocation(placed) == expected +@pytest.mark.parametrize( + ("query", "allocations", "kwargs", "error", "match"), + [ + (antichain_pressure, TWO_PLUS_TWO, BUDGET, RuntimeError, "work_budget"), + (antichain_pressure_per_allocation, TWO_PLUS_TWO, BUDGET, RuntimeError, "work"), + ( + placement_pressure_per_allocation, + PLACED_TWO_PLUS_TWO, + BUDGET, + RuntimeError, + "work", + ), + (closure_pressure, WIDE_CLOSURE, CAP, RuntimeError, "closure_cap"), + ( + closure_pressure_per_allocation, + WIDE_CLOSURE, + CAP, + RuntimeError, + "closure_cap", + ), + (antichain_pressure, (), {"work_budget": -1}, ValueError, "non-negative"), + (closure_pressure, (), {"closure_cap": -1}, ValueError, "non-negative"), + (antichain_pressure, OVERFLOW, {}, ValueError, "int64"), + (closure_pressure, OVERFLOW, {}, ValueError, "int64"), + (closure_pressure_per_allocation, OVERFLOW, {}, ValueError, "int64"), + (antichain_pressure, MIXED, {"work_budget": None}, ValueError, "dimension"), + (closure_pressure, MIXED, {}, ValueError, "dimension"), + (placement_pressure, MIXED, {}, ValueError, "dimension"), + (antichain_pressure_per_allocation, DUPLICATED, {}, ValueError, "unique"), + (closure_pressure_per_allocation, DUPLICATED, {}, ValueError, "unique"), + (placement_pressure_per_allocation, DUPLICATED, {}, ValueError, "unique"), + (placement_pressure, UNPLACED, {}, ValueError, "placed"), + (placement_pressure_per_allocation, UNPLACED, {}, ValueError, "placed"), + ], +) +def test_pressure_queries_reject( + query: Callable[..., object], + allocations: tuple[Allocation, ...], + kwargs: dict[str, int | None], + error: type[Exception], + match: str, +) -> None: + with pytest.raises(error, match=match): + query(allocations, **kwargs) def _brute_antichain(allocations: tuple[Allocation, ...]) -> int: @@ -505,60 +337,28 @@ def test_per_allocation_placement_pressure_matches_brute_force() -> None: ) == _brute_placement(scrambled) -def _assert_matches_brute(allocations: tuple[Allocation, ...]) -> None: +@pytest.mark.parametrize( + "allocations", + [ + tuple(_a(i, 8, i, i + 3, 0) for i in range(40)), + tuple(_a(i, 8, 0, 1, 8 * i) for i in range(40)), + tuple(_a(i, 4, i, 80 - i, 4 * i) for i in range(40)), + tuple(_a(i, 4, i, 80 - i, 4 * (40 - i)) for i in range(40)), + tuple(_a(i, 8, i, i + 1, 0) for i in range(40)), + ( + _a("span", 1, 0, 100, 0), + *(_a(i, 100, i, i + 1, 1) for i in range(1, 40)), + ), + ], +) +def test_per_allocation_placement_pressure_matches_brute_force_on( + allocations: tuple[Allocation, ...], +) -> None: assert placement_pressure_per_allocation(allocations) == _brute_placement( allocations ) -def test_per_allocation_placement_pressure_on_a_shared_offset() -> None: - _assert_matches_brute( - tuple(Allocation(id=i, size=8, start=i, end=i + 3, offset=0) for i in range(40)) - ) - - -def test_per_allocation_placement_pressure_on_one_shared_instant() -> None: - _assert_matches_brute( - tuple(Allocation(id=i, size=8, start=0, end=1, offset=8 * i) for i in range(40)) - ) - - -def test_per_allocation_placement_pressure_on_a_nested_staircase() -> None: - _assert_matches_brute( - tuple( - Allocation(id=i, size=4, start=i, end=80 - i, offset=4 * i) - for i in range(40) - ) - ) - - -def test_per_allocation_placement_pressure_on_a_reversed_staircase() -> None: - _assert_matches_brute( - tuple( - Allocation(id=i, size=4, start=i, end=80 - i, offset=4 * (40 - i)) - for i in range(40) - ) - ) - - -def test_per_allocation_placement_pressure_on_disjoint_unit_lifetimes() -> None: - _assert_matches_brute( - tuple(Allocation(id=i, size=8, start=i, end=i + 1, offset=0) for i in range(40)) - ) - - -def test_per_allocation_placement_pressure_under_one_tall_spanner() -> None: - _assert_matches_brute( - ( - Allocation(id="span", size=1, start=0, end=100, offset=0), - *( - Allocation(id=i, size=100, start=i, end=i + 1, offset=1) - for i in range(1, 40) - ), - ) - ) - - def test_per_allocation_placement_pressure_matches_brute_force_on_scalar_time() -> None: rng = Random(23) for _ in range(200): @@ -629,25 +429,6 @@ def test_per_allocation_bound_order_and_peak_identities() -> None: assert pinned[alloc_id] <= placement[alloc_id] -def test_placement_pressure_per_allocation_paints_nested_lifetimes() -> None: - allocations = ( - Allocation(id="long", size=8, start=0, end=10, offset=0), - Allocation(id="tall", size=100, start=2, end=4, offset=8), - Allocation(id="short", size=10, start=6, end=8, offset=8), - ) - peaks = placement_pressure_per_allocation(allocations) - assert peaks == {"long": 108, "tall": 108, "short": 18} - - -def test_placement_pressure_per_allocation_ignores_uncovered_slots() -> None: - allocations = ( - Allocation(id="a", size=8, start=0, end=2, offset=0), - Allocation(id="b", size=64, start=8, end=10, offset=0), - ) - peaks = placement_pressure_per_allocation(allocations) - assert peaks == {"a": 8, "b": 64} - - def test_placement_pressure_per_allocation_survives_degenerate_columns() -> None: rng = Random(23) for _ in range(40): @@ -670,31 +451,3 @@ def test_placement_pressure_per_allocation_survives_degenerate_columns() -> None expected = placement_pressure_per_allocation(scalar) assert placement_pressure_per_allocation(padded) == expected assert placement_pressure_per_allocation(lockstep) == expected - - -def test_closure_bound_is_strictly_looser_than_the_antichain_bound() -> None: - allocations = ( - Allocation(id=0, size=20, start=(1, 0, 0), end=(1, 2, 1)), - Allocation(id=1, size=20, start=(0, 2, 0), end=(1, 2, 1)), - Allocation(id=2, size=30, start=(1, 0, 0), end=(1, 2, 0)), - ) - assert all( - a.conflicts_with(b) - for a, b in ((allocations[0], allocations[1]), (allocations[0], allocations[2])) - ) - assert closure_pressure(allocations) == 50 - assert antichain_pressure(allocations, work_budget=None) == 70 - - -def test_the_bound_chain_holds_on_the_no_common_cut_clique() -> None: - allocations = ( - Allocation(id=0, size=20, start=(1, 0, 0), end=(1, 2, 1)), - Allocation(id=1, size=20, start=(0, 2, 0), end=(1, 2, 1)), - Allocation(id=2, size=30, start=(1, 0, 0), end=(1, 2, 0)), - ) - placed = allocate(allocations, "omni") - assert ( - closure_pressure(allocations) - <= antichain_pressure(allocations, work_budget=None) - <= placement_pressure(placed) - ) diff --git a/tests/unit/analysis/test_vector_conflict_properties.py b/tests/unit/analysis/test_vector_conflict_properties.py index a844ea8..ee47d5d 100644 --- a/tests/unit/analysis/test_vector_conflict_properties.py +++ b/tests/unit/analysis/test_vector_conflict_properties.py @@ -93,28 +93,20 @@ def test_pairwise_overlap_matches_oracle(dim: int) -> None: assert b.conflicts_with(a) == expected -def test_equal_end_start_boundary_is_safe() -> None: +@pytest.mark.parametrize( + ("other", "conflicts"), + [ + (((5, 7), (6, 8)), False), + (((5, 6), (6, 8)), True), + (((4, 8), (9, 9)), True), + (((6, 7), (7, 8)), False), + ], +) +def test_a_vector_lifetime_against_one_spanning_2_3_to_5_7( + other: tuple[tuple[int, ...], tuple[int, ...]], conflicts: bool +) -> None: a = make_allocation(0, (2, 3), (5, 7)) - b = make_allocation(1, (5, 7), (6, 8)) - 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.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.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.conflicts_with(b) + assert a.conflicts_with(make_allocation(1, *other)) is conflicts @pytest.mark.parametrize("hi", [3, 6, 20]) From 4b789da13557f64fcc6083561512d3da0980f5a3 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 16:00:15 +0000 Subject: [PATCH 10/17] Flatten the interval and validation helper tests into tables --- tests/unit/common/test_intervals.py | 53 +++++++------------- tests/unit/common/test_validation.py | 75 ++++++++++++---------------- 2 files changed, 50 insertions(+), 78 deletions(-) diff --git a/tests/unit/common/test_intervals.py b/tests/unit/common/test_intervals.py index 6fc1aad..1b72046 100644 --- a/tests/unit/common/test_intervals.py +++ b/tests/unit/common/test_intervals.py @@ -6,41 +6,24 @@ from omnimalloc.common.intervals import first_overlap, stack_around_pins -def test_stack_around_pins_stacks_when_nothing_is_pinned() -> None: - assert stack_around_pins([4, 8, 16], [None, None, None]) == [0, 4, 12] - - -def test_stack_around_pins_returns_nothing_for_no_items() -> None: - assert stack_around_pins([], []) == [] - - -def test_stack_around_pins_leaves_a_pinned_offset_alone() -> None: - assert stack_around_pins([4, 8], [64, None]) == [64, 0] - - -def test_stack_around_pins_fills_the_gap_below_a_pin() -> None: - assert stack_around_pins([8, 4, 4], [16, None, None]) == [16, 0, 4] - - -def test_stack_around_pins_steps_over_a_pin_that_blocks_the_gap() -> None: - assert stack_around_pins([8, 16], [4, None]) == [4, 12] - - -def test_stack_around_pins_packs_several_free_items_around_two_pins() -> None: - offsets = stack_around_pins([4, 4, 2, 2, 8], [0, 12, None, None, None]) - assert offsets == [0, 12, 4, 6, 16] - - -def test_stack_around_pins_backfills_a_gap_a_larger_item_skipped() -> None: - assert stack_around_pins([4, 8, 2], [6, None, None]) == [6, 10, 0] - - -def test_stack_around_pins_merges_overlapping_pins() -> None: - assert stack_around_pins([8, 8, 4], [0, 4, None]) == [0, 4, 12] - - -def test_stack_around_pins_ignores_a_zero_width_pin() -> None: - assert stack_around_pins([0, 8], [4, None]) == [4, 0] +@pytest.mark.parametrize( + ("sizes", "pins", "expected"), + [ + ([], [], []), + ([4, 8, 16], [None, None, None], [0, 4, 12]), + ([4, 8], [64, None], [64, 0]), + ([8, 4, 4], [16, None, None], [16, 0, 4]), + ([8, 16], [4, None], [4, 12]), + ([4, 4, 2, 2, 8], [0, 12, None, None, None], [0, 12, 4, 6, 16]), + ([4, 8, 2], [6, None, None], [6, 10, 0]), + ([8, 8, 4], [0, 4, None], [0, 4, 12]), + ([0, 8], [4, None], [4, 0]), + ], +) +def test_stack_around_pins( + sizes: list[int], pins: list[int | None], expected: list[int] +) -> None: + assert stack_around_pins(sizes, pins) == expected @pytest.mark.parametrize( diff --git a/tests/unit/common/test_validation.py b/tests/unit/common/test_validation.py index bfd6956..5a0af5a 100644 --- a/tests/unit/common/test_validation.py +++ b/tests/unit/common/test_validation.py @@ -6,46 +6,35 @@ from omnimalloc.common.validation import ensure_non_negative, ensure_positive -@pytest.mark.parametrize("value", [1, 0.5, 100]) -def test_ensure_positive_accepts_positive(value: float) -> None: - ensure_positive(value, "value") - - -@pytest.mark.parametrize("value", [0, -1, -0.5]) -def test_ensure_positive_rejects_non_positive(value: float) -> None: - with pytest.raises(ValueError, match="value must be positive"): - ensure_positive(value, "value") - - -def test_ensure_positive_none_needs_allow_none() -> None: - ensure_positive(None, "num_threads", allow_none=True) - with pytest.raises(ValueError, match="num_threads must be positive"): - ensure_positive(None, "num_threads") - - -@pytest.mark.parametrize("value", [0, 1, 100_000_000]) -def test_ensure_non_negative_accepts_non_negative(value: int) -> None: - ensure_non_negative(value, "work_budget") - - -def test_ensure_non_negative_rejects_negative() -> None: - with pytest.raises(ValueError, match="work_budget must be non-negative"): - ensure_non_negative(-1, "work_budget") - - -def test_ensure_non_negative_none_needs_allow_none() -> None: - ensure_non_negative(None, "work_budget", allow_none=True) - with pytest.raises(ValueError, match="work_budget must be non-negative"): - ensure_non_negative(None, "work_budget") - - -def test_error_names_the_given_parameter() -> None: - with pytest.raises(ValueError, match="linearize_budget must be non-negative"): - ensure_non_negative(-1, "linearize_budget", allow_none=True) - - -def test_allow_none_messages_name_none_as_an_option() -> None: - with pytest.raises(ValueError, match="num_threads must be positive or None"): - ensure_positive(0, "num_threads", allow_none=True) - with pytest.raises(ValueError, match="work_budget must be non-negative or None"): - ensure_non_negative(-1, "work_budget", allow_none=True) +@pytest.mark.parametrize( + ("check", "value", "allow_none"), + [ + (ensure_positive, 1, False), + (ensure_positive, 0.5, False), + (ensure_positive, None, True), + (ensure_non_negative, 0, False), + (ensure_non_negative, 100_000_000, False), + (ensure_non_negative, None, True), + ], +) +def test_accepts(check: object, value: float | None, allow_none: bool) -> None: + check(value, "x", allow_none=allow_none) + + +@pytest.mark.parametrize( + ("check", "value", "allow_none", "message"), + [ + (ensure_positive, 0, False, "x must be positive,"), + (ensure_positive, -0.5, False, "x must be positive,"), + (ensure_positive, None, False, "x must be positive,"), + (ensure_positive, 0, True, "x must be positive or None"), + (ensure_non_negative, -1, False, "x must be non-negative,"), + (ensure_non_negative, None, False, "x must be non-negative,"), + (ensure_non_negative, -1, True, "x must be non-negative or None"), + ], +) +def test_rejects_naming_the_parameter( + check: object, value: float | None, allow_none: bool, message: str +) -> None: + with pytest.raises(ValueError, match=message): + check(value, "x", allow_none=allow_none) From 8150c46be0310ec1de06ed68acf4d0eae439cad7 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 16:01:35 +0000 Subject: [PATCH 11/17] Flatten the validation tests into accepted and rejected tables --- tests/unit/test_validate.py | 716 +++++++++++------------------------- 1 file changed, 223 insertions(+), 493 deletions(-) diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py index f8b0346..0097824 100644 --- a/tests/unit/test_validate.py +++ b/tests/unit/test_validate.py @@ -3,499 +3,248 @@ # import pytest -from omnimalloc.primitives import Allocation -from omnimalloc.primitives.memory import Memory -from omnimalloc.primitives.pool import Pool -from omnimalloc.primitives.system import System +from omnimalloc.primitives import Allocation, Memory, Pool, System from omnimalloc.validate import validate_allocation -def test_validate_pool_valid_single_allocation() -> 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 _at( + i: object, size: int, start: object, end: object, offset: int | None = 0 +) -> Allocation: + return Allocation(id=i, size=size, start=start, end=end, offset=offset) -def test_validate_pool_valid_multiple_non_overlapping() -> None: - 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)) - validate_allocation(pool) +def _unplaced(i: object, size: int, start: object, end: object) -> Allocation: + return _at(i, size, start, end, offset=None) -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.*overlaps"): - validate_allocation(pool) +def _pool(pool_id: object, *allocations: Allocation, base: int | None = 0) -> Pool: + return Pool(id=pool_id, allocations=allocations, offset=base) -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,)) - with pytest.raises(ValueError, match="is not allocated"): - validate_allocation(pool) +def _memory(memory_id: object, *pools: Pool, size: int | None = None) -> Memory: + return Memory(id=memory_id, pools=pools, size=size) -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 _system(*memories: Memory) -> System: + return System(id=1, memories=memories) -def test_validate_pool_empty() -> None: - pool = Pool(id=1, allocations=()) - validate_allocation(pool) +ONE = _at(1, 10, 0, 5) +SHARED = _at("shared", 10, 0, 5) +DISJOINT = tuple(_at(i, 50, 5 * i, 5 * (i + 1)) for i in range(10)) +BAD_POOL = _pool(1, _at(1, 100, 0, 10), _at(2, 100, 5, 15)) -def test_validate_memory_valid_single_pool() -> None: - 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,)) - validate_allocation(memory) - - -def test_validate_memory_valid_multiple_non_overlapping_pools() -> None: - 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)) - validate_allocation(memory) - - -def test_validate_memory_overlapping_pools_raises() -> None: - 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)) - with pytest.raises(ValueError, match=r"Validation .* failed"): - validate_allocation(memory) - - -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,)) - with pytest.raises(ValueError, match=r"in pool 1"): - validate_allocation(memory) - - -def test_validate_memory_empty() -> None: - memory = Memory(id=1, pools=()) - validate_allocation(memory) - - -def test_validate_system_valid_single_memory() -> None: - 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,)) - validate_allocation(system) - - -def test_validate_system_valid_multiple_memories() -> None: - 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=0) - memory1 = Memory(id=1, pools=(pool1,)) - memory2 = Memory(id=2, pools=(pool2,)) - system = System(id=1, memories=(memory1, memory2)) - validate_allocation(system) - - -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,)) - with pytest.raises(ValueError, match=r"in memory 1"): - validate_allocation(system) - - -def test_validate_system_empty() -> None: - system = System(id=1, memories=()) - validate_allocation(system) - - -def test_validate_unsupported_type() -> None: - with pytest.raises(TypeError, match="Unsupported entity type"): - validate_allocation("invalid_entity") # type: ignore[arg-type] - - -def test_validate_allocation_directly() -> None: - alloc = Allocation(id=1, size=100, start=0, end=10, offset=0) - with pytest.raises(TypeError, match="Unsupported entity type"): - validate_allocation(alloc) # type: ignore[arg-type] - - -def test_validate_complex_hierarchy() -> None: - 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) - alloc4 = Allocation(id=4, size=75, start=10, end=18, offset=0) # Non-overlapping - - pool1 = Pool(id=1, allocations=(alloc1,), offset=0) - pool2 = Pool(id=2, allocations=(alloc2,), offset=200) - pool3 = Pool(id=3, allocations=(alloc3, alloc4), offset=0) - - memory1 = Memory(id=1, pools=(pool1, pool2)) - memory2 = Memory(id=2, pools=(pool3,)) - - system = System(id=1, memories=(memory1, memory2)) - validate_allocation(system) - - -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) - - pool1 = Pool(id=1, allocations=(alloc1, alloc2), offset=0) # Invalid - pool2 = Pool(id=2, allocations=(alloc3,), offset=0) - - memory1 = Memory(id=1, pools=(pool1,)) - memory2 = Memory(id=2, pools=(pool2,)) - - system = System(id=1, memories=(memory1, memory2)) - with pytest.raises(ValueError, match=r"in memory 1.*in pool 1"): - validate_allocation(system) - - -def test_validate_memory_over_size_fails() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=100, start=0, end=5, offset=0),), - offset=0, - ) - memory = Memory(id="m", size=50, pools=(pool,)) - with pytest.raises(ValueError, match="exceeds memory size"): - validate_allocation(memory) - - -def test_validate_memory_within_size_passes() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=100, start=0, end=5, offset=0),), - offset=0, - ) - memory = Memory(id="m", size=100, pools=(pool,)) - validate_allocation(memory) - - -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)) - 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)) - validate_allocation(pool) - - -def test_validate_pool_mixed_dimensions_rejected() -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=10, offset=0) - alloc2 = Allocation(id=2, size=100, start=(20, 0), end=(30, 1), offset=200) - pool = Pool(id=1, allocations=(alloc1, alloc2)) - with pytest.raises(ValueError, match="share one clock dimension"): - validate_allocation(pool) - - -def test_validate_raw_allocations_valid() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=5, offset=0), - Allocation(id=2, size=100, start=5, end=10, offset=0), - ) - validate_allocation(allocations) - - -def test_validate_raw_allocations_list() -> None: - validate_allocation([Allocation(id=1, size=10, start=0, end=5, offset=0)]) - - -def test_validate_raw_allocations_empty() -> None: - validate_allocation(()) - - -def test_validate_raw_allocations_overlapping_raises() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=10, offset=0), - Allocation(id=2, size=100, start=5, end=15, offset=50), - ) - with pytest.raises(ValueError, match=r"Validation of 2 allocations failed"): - validate_allocation(allocations) - - -def test_validate_raw_allocations_unallocated_raises() -> None: - with pytest.raises(ValueError, match="is not allocated"): - validate_allocation((Allocation(id=1, size=10, start=0, end=5),)) - - -def test_validate_raw_allocations_duplicate_ids_raise() -> None: - allocations = ( - Allocation(id=1, size=10, start=0, end=5, offset=0), - Allocation(id=1, size=10, start=5, end=10, offset=0), - ) - with pytest.raises(ValueError, match="duplicate id"): - validate_allocation(allocations) - - -def test_validate_raw_allocations_rejects_non_allocation_elements() -> None: - with pytest.raises(TypeError, match="Expected Allocation"): - validate_allocation([1, 2, 3]) - - -def test_validate_raw_allocations_complex_mix() -> None: - valid = tuple( - Allocation(id=i, size=50, start=5 * i, end=5 * (i + 1), offset=0) - for i in range(10) - ) - validate_allocation(valid) - overlapping = (*valid, Allocation(id=99, size=50, start=12, end=18, offset=25)) - with pytest.raises(ValueError, match=r"Validation of 11 allocations failed"): - validate_allocation(overlapping) - - -def test_validate_memory_unplaced_pool_raises() -> None: - pool = Pool( - id="p", allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),) - ) - memory = Memory(id="m", pools=(pool,), size=100) - with pytest.raises(ValueError, match="pool 'p' is not placed"): - validate_allocation(memory) - - -def test_validate_memory_default_pools_at_same_base_raise() -> None: - pool1 = Pool( - id="p1", allocations=(Allocation(id="a", size=10, start=0, end=5, offset=0),) - ) - pool2 = Pool( - id="p2", allocations=(Allocation(id="b", size=10, start=0, end=5, offset=0),) - ) - memory = Memory(id="m", pools=(pool1, pool2), size=100) - with pytest.raises(ValueError, match="is not placed"): - validate_allocation(memory) - - -def test_validate_memory_duplicate_ids_across_pools_raise() -> None: - alloc = Allocation(id="shared", size=10, start=0, end=5, offset=0) - pool1 = Pool(id="p1", allocations=(alloc,), offset=0) - pool2 = Pool(id="p2", allocations=(alloc,), offset=100) - memory = Memory(id="m", pools=(pool1, pool2), size=1000) - with pytest.raises(ValueError, match="duplicate allocation id 'shared'"): - validate_allocation(memory) - - -def test_validate_memory_same_id_in_separate_memories_passes() -> None: - alloc = Allocation(id="shared", size=10, start=0, end=5, offset=0) - memory1 = Memory(id="m1", pools=(Pool(id="p", allocations=(alloc,), offset=0),)) - memory2 = Memory(id="m2", pools=(Pool(id="p", allocations=(alloc,), offset=0),)) - validate_allocation(System(id="s", memories=(memory1, memory2))) - - -def test_validate_require_capacity_rejects_missing_size() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),), - offset=0, - ) - memory = Memory(id="m", pools=(pool,)) - validate_allocation(memory) - with pytest.raises(ValueError, match="no size declared"): - validate_allocation(memory, require_capacity=True) - - -def test_validate_require_capacity_accepts_declared_size() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),), - offset=0, - ) - memory = Memory(id="m", pools=(pool,), size=10) - validate_allocation(memory, require_capacity=True) - - -def test_validate_memory_capacity_counts_pool_base() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),), - offset=1000, - ) - memory = Memory(id="m", pools=(pool,), size=100) - with pytest.raises(ValueError, match="exceeds memory size"): - validate_allocation(memory) - - -def test_validate_accepts_an_aligned_placement() -> None: - allocations = ( - Allocation(id=1, size=64, start=0, end=5, offset=0), - Allocation(id=2, size=64, start=0, end=5, offset=64), - ) - validate_allocation(allocations, alignment=64) - - -def test_validate_rejects_a_misaligned_offset() -> None: - allocations = (Allocation(id=1, size=10, start=0, end=5, offset=12),) - validate_allocation(allocations) - with pytest.raises(ValueError, match="not 64-byte aligned"): - validate_allocation(allocations, alignment=64) - - -def test_validate_rejects_a_non_positive_alignment() -> None: - allocations = (Allocation(id=1, size=10, start=0, end=5, offset=0),) - with pytest.raises(ValueError, match="alignment must be positive"): - validate_allocation(allocations, alignment=0) - - -def test_validate_checks_alignment_inside_a_system() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=8),), - offset=0, - ) - system = System(id="s", memories=(Memory(id="m", pools=(pool,)),)) - with pytest.raises(ValueError, match="not 16-byte aligned"): - validate_allocation(system, alignment=16) - - -def test_validate_alignment_counts_the_pool_base() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),), - offset=3, - ) - with pytest.raises(ValueError, match="address 3 is not 8-byte aligned"): - validate_allocation(Memory(id="m", pools=(pool,)), alignment=8) - - -def test_validate_alignment_accepts_an_aligned_pool_base() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),), - offset=8, - ) - validate_allocation(Memory(id="m", pools=(pool,)), alignment=8) - - -def test_validate_alignment_counts_the_base_of_a_standalone_pool() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=4),), - offset=4, - ) - validate_allocation(pool, alignment=8) - - -def test_validate_accepts_a_memory_that_declares_no_size() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),), - offset=0, - ) - validate_allocation(Memory(id="m", pools=(pool,))) - - -def test_validate_require_capacity_accepts_a_large_declared_size() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),), - offset=0, - ) - memory = Memory(id="m", pools=(pool,), size=1 << 50) - validate_allocation(memory, require_capacity=True) - - -def test_validate_partial_placement_passes_without_require_allocated() -> None: - placed = Allocation(id=1, size=100, start=0, end=10, offset=0) - unplaced = Allocation(id=2, size=100, start=0, end=10) - validate_allocation((placed, unplaced), require_allocated=False) - - -def test_validate_partial_placement_still_catches_pin_collisions() -> None: - first = Allocation(id=1, size=100, start=0, end=10, offset=0) - second = Allocation(id=2, size=100, start=5, end=15, offset=50) - unplaced = Allocation(id=3, size=100, start=0, end=10) - with pytest.raises(ValueError, match="overlaps"): - validate_allocation((first, second, unplaced), require_allocated=False) - - -def test_validate_unplaced_pool_base_passes_without_require_allocated() -> None: - placed = Pool( - id="p1", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),), - offset=0, - ) - unplaced = Pool(id="p2", allocations=(Allocation(id=2, size=10, start=0, end=5),)) - validate_allocation( - Memory(id="m", pools=(placed, unplaced)), require_allocated=False - ) - - -def test_validate_pinned_pool_bases_collide_without_require_allocated() -> None: - pool1 = Pool( - id="p1", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=0),), - offset=0, - ) - pool2 = Pool( - id="p2", - allocations=(Allocation(id=2, size=10, start=0, end=5, offset=0),), - offset=5, - ) - with pytest.raises(ValueError, match="overlaps with pool"): - validate_allocation( - Memory(id="m", pools=(pool1, pool2)), require_allocated=False - ) - - -def test_validate_default_still_requires_full_placement() -> None: - placed = Allocation(id=1, size=100, start=0, end=10, offset=0) - unplaced = Allocation(id=2, size=100, start=0, end=10) - with pytest.raises(ValueError, match="is not allocated"): - validate_allocation((placed, unplaced)) - - -def test_validate_loosened_skips_alignment_for_baseless_pools() -> None: - pool = Pool( - id="p", allocations=(Allocation(id=1, size=10, start=0, end=5, offset=3),) - ) - validate_allocation(pool, require_allocated=False, alignment=8) - - -def test_validate_loosened_checks_alignment_for_placed_pools() -> None: - pool = Pool( - id="p", - allocations=(Allocation(id=1, size=10, start=0, end=5, offset=3),), - offset=0, - ) - with pytest.raises(ValueError, match="aligned"): - validate_allocation(pool, require_allocated=False, alignment=8) +@pytest.mark.parametrize( + ("entity", "kwargs"), + [ + (_pool(1, _at(1, 100, 0, 10)), {}), + (_pool(1, _at(1, 100, 0, 5), _at(2, 100, 5, 10)), {}), + (_pool(1), {}), + (_memory(1), {}), + (_system(), {}), + ( + _memory( + 1, _pool(1, _at(1, 100, 0, 10)), _pool(2, _at(2, 100, 0, 10), base=200) + ), + {}, + ), + ( + _system(_memory(1, _pool(1, ONE)), _memory(2, _pool(2, _at(2, 10, 0, 5)))), + {}, + ), + ( + _system( + _memory( + 1, + _pool(1, _at(1, 100, 0, 5)), + _pool(2, _at(2, 100, 5, 10), base=200), + ), + _memory(2, _pool(3, _at(3, 50, 0, 10), _at(4, 75, 10, 18))), + ), + {}, + ), + (_memory("m", _pool("p", _at(1, 100, 0, 5)), size=100), {}), + (_pool(1, _at(1, 100, (0, 0), (2, 1)), _at(2, 100, (2, 1), (3, 2))), {}), + ((), {}), + (DISJOINT, {}), + (list(DISJOINT), {}), + ( + _system( + _memory("m1", _pool("p", SHARED)), _memory("m2", _pool("p", SHARED)) + ), + {}, + ), + (_memory("m", _pool("p", ONE)), {}), + (_memory("m", _pool("p", ONE), size=10), {"require_capacity": True}), + (_memory("m", _pool("p", ONE), size=1 << 50), {"require_capacity": True}), + ((_at(1, 64, 0, 5), _at(2, 64, 0, 5, 64)), {"alignment": 64}), + ((_at(1, 10, 0, 5, 12),), {}), + (_memory("m", _pool("p", ONE, base=8)), {"alignment": 8}), + (_pool("p", _at(1, 10, 0, 5, 4), base=4), {"alignment": 8}), + ((ONE, _unplaced(2, 100, 0, 10)), {"require_allocated": False}), + ( + _memory( + "m", _pool("p1", ONE), _pool("p2", _unplaced(2, 10, 0, 5), base=None) + ), + {"require_allocated": False}, + ), + ( + _pool("p", _at(1, 10, 0, 5, 3), base=None), + {"require_allocated": False, "alignment": 8}, + ), + (_memory("m", _pool("a", _at(1, 16, 0, 1)), _pool("b", base=8)), {}), + ], +) +def test_validate_accepts(entity: object, kwargs: dict[str, object]) -> None: + validate_allocation(entity, **kwargs) + + +@pytest.mark.parametrize( + ("entity", "kwargs", "error", "match"), + [ + (BAD_POOL, {}, ValueError, r"Validation .* failed.*overlaps"), + (_pool(1, _unplaced(1, 100, 0, 10)), {}, ValueError, "is not allocated"), + ((ONE, _unplaced(2, 100, 0, 10)), {}, ValueError, "is not allocated"), + ( + _memory( + 1, _pool(1, _at(1, 100, 0, 10)), _pool(2, _at(2, 100, 0, 10), base=50) + ), + {}, + ValueError, + r"Validation .* failed", + ), + (_memory(1, BAD_POOL), {}, ValueError, "in pool 1"), + (_system(_memory(1, BAD_POOL)), {}, ValueError, "in memory 1"), + ( + _system(_memory(1, BAD_POOL), _memory(2, _pool(2, _at(3, 50, 0, 10)))), + {}, + ValueError, + r"in memory 1.*in pool 1", + ), + ( + _memory("m", _pool("p", _unplaced(1, 10, 0, 5))), + {}, + ValueError, + r"^Validation of Memory 'm' failed, in pool", + ), + ("invalid_entity", {}, TypeError, "Unsupported entity type"), + (ONE, {}, TypeError, "Unsupported entity type"), + ([1, 2, 3], {}, TypeError, "Expected Allocation"), + ( + _memory("m", _pool("p", _at(1, 100, 0, 5)), size=50), + {}, + ValueError, + "exceeds memory size", + ), + ( + _memory("m", _pool("p", ONE, base=1000), size=100), + {}, + ValueError, + "exceeds memory size", + ), + ( + _pool(1, _at(1, 100, (0, 5), (1, 6)), _at(2, 100, (2, 0), (3, 1))), + {}, + ValueError, + "overlaps", + ), + ( + _pool(1, _at(1, 100, 0, 10), _at(2, 100, (20, 0), (30, 1), 200)), + {}, + ValueError, + "share one clock dimension", + ), + ( + (_at(1, 100, 0, 10), _at(2, 100, 5, 15, 50)), + {}, + ValueError, + "Validation of 2 allocations failed", + ), + ( + (*DISJOINT, _at(99, 50, 12, 18, 25)), + {}, + ValueError, + "Validation of 11 allocations failed", + ), + ((_at(1, 10, 0, 5), _at(1, 10, 5, 10)), {}, ValueError, "duplicate id"), + ( + _memory("m", _pool("p", ONE, base=None), size=100), + {}, + ValueError, + "pool 'p' is not placed", + ), + ( + _memory( + "m", + _pool("p1", ONE, base=None), + _pool("p2", _at(2, 10, 0, 5), base=None), + ), + {}, + ValueError, + "is not placed", + ), + ( + _memory("m", _pool("p1", SHARED), _pool("p2", SHARED, base=100)), + {}, + ValueError, + "duplicate allocation id 'shared'", + ), + ( + _memory("m", _pool("p", ONE)), + {"require_capacity": True}, + ValueError, + "no size declared", + ), + ((_at(1, 10, 0, 5, 12),), {"alignment": 64}, ValueError, "not 64-byte aligned"), + ((ONE,), {"alignment": 0}, ValueError, "alignment must be positive"), + ( + _system(_memory("m", _pool("p", _at(1, 10, 0, 5, 8)))), + {"alignment": 16}, + ValueError, + "not 16-byte aligned", + ), + ( + _memory("m", _pool("p", ONE, base=3)), + {"alignment": 8}, + ValueError, + "address 3 is not 8-byte aligned", + ), + ( + (_at(1, 100, 0, 10), _at(2, 100, 5, 15, 50), _unplaced(3, 100, 0, 10)), + {"require_allocated": False}, + ValueError, + "overlaps", + ), + ( + _memory("m", _pool("p1", ONE), _pool("p2", _at(2, 10, 0, 5), base=5)), + {"require_allocated": False}, + ValueError, + "overlaps with pool", + ), + ( + _pool("p", _at(1, 10, 0, 5, 3)), + {"require_allocated": False, "alignment": 8}, + ValueError, + "aligned", + ), + ], +) +def test_validate_rejects( + entity: object, kwargs: dict[str, object], error: type[Exception], match: str +) -> None: + with pytest.raises(error, match=match): + validate_allocation(entity, **kwargs) -@pytest.mark.parametrize(("offset", "fits"), [(90, True), (95, False), (None, True)]) +@pytest.mark.parametrize(("base", "fits"), [(90, True), (95, False), (None, True)]) def test_validate_loosened_capacity_counts_placed_pools_only( - offset: int | None, fits: bool + base: int | None, fits: bool ) -> None: - alloc = Allocation(id=1, size=10, start=0, end=5, offset=0) - unplaced = Allocation(id=2, size=500, start=0, end=5) - memory = Memory( - id="m", - pools=( - Pool(id="p", allocations=(alloc,), offset=offset), - Pool(id="q", allocations=(unplaced,)), - ), + memory = _memory( + "m", + _pool("p", _at(1, 10, 0, 5), base=base), + _pool("q", _unplaced(2, 500, 0, 5), base=None), size=100, ) if fits: @@ -503,22 +252,3 @@ def test_validate_loosened_capacity_counts_placed_pools_only( else: with pytest.raises(ValueError, match="extent 105 exceeds memory size 100"): validate_allocation(memory, require_allocated=False) - - -def test_validate_empty_pool_pinned_inside_another_pool_passes() -> None: - alloc = Allocation(id=1, size=16, start=0, end=1, offset=0) - memory = Memory( - id="m", - pools=( - Pool(id="a", allocations=(alloc,), offset=0), - Pool(id="b", allocations=(), offset=8), - ), - ) - validate_allocation(memory) - - -def test_validate_memory_error_names_the_memory_once() -> None: - alloc = Allocation(id=1, size=10, start=0, end=5) - memory = Memory(id="m", pools=(Pool(id="p", allocations=(alloc,), offset=0),)) - with pytest.raises(ValueError, match=r"^Validation of Memory 'm' failed, in pool"): - validate_allocation(memory) From 0992a60797a9806d98bd813dd8c79b57abbc3f5c Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 16:02:15 +0000 Subject: [PATCH 12/17] Flatten the CSV save and load tests into tables --- tests/unit/test_io.py | 370 ++++++++++++++---------------------------- 1 file changed, 119 insertions(+), 251 deletions(-) diff --git a/tests/unit/test_io.py b/tests/unit/test_io.py index 0d6cda7..cb6cac7 100644 --- a/tests/unit/test_io.py +++ b/tests/unit/test_io.py @@ -8,270 +8,138 @@ from omnimalloc.io import load_allocation, save_allocation from omnimalloc.primitives import Allocation, AllocationKind, 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), - ) +PROBLEM = ( + Allocation(id="a", size=4, start=0, end=3), + Allocation(id="b", size=8, start=2, end=9), +) +SOLVED = (PROBLEM[0].with_offset(0), PROBLEM[1].with_offset(4)) +PARTIAL = (PROBLEM[0].with_offset(0), PROBLEM[1]) +VECTOR = ( + Allocation(id="a", size=4, start=(3, 0), end=(5, 2)), + Allocation(id="b", size=8, start=(0, 1), end=(2, 4)), +) +TYPED = ( + Allocation(id=7, size=10, start=0, end=5, offset=0, kind=AllocationKind.CONSTANT), + Allocation(id="named", size=4, start=2, end=8, offset=16), +) + + +def _pool(pool_id: object, allocations: tuple[Allocation, ...] = PROBLEM) -> Pool: 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_raw_allocations_writes_one_pool(tmp_path: Path) -> None: - file_path = tmp_path / "problem.csv" - written = save_allocation(list(make_pool().allocations), file_path) - assert written == (file_path,) - assert file_path.read_text() == "id,lower,upper,size\na,0,3,4\nb,2,9,8\n" +@pytest.mark.parametrize( + ("entity", "text"), + [ + (_pool("p"), "id,lower,upper,size\na,0,3,4\nb,2,9,8\n"), + (list(PROBLEM), "id,lower,upper,size\na,0,3,4\nb,2,9,8\n"), + (SOLVED, "id,lower,upper,size,offset\na,0,3,4,0\nb,2,9,8,4\n"), + (PARTIAL, "id,lower,upper,size,offset\na,0,3,4,0\nb,2,9,8,\n"), + (VECTOR[:1], "id,lower,upper,size\na,3:0,5:2,4\n"), + ], +) +def test_save_writes_minimalloc_csv(entity: object, text: str, tmp_path: Path) -> None: + path = tmp_path / "problem.csv" + assert save_allocation(entity, path) == (path,) + assert path.read_text() == text + + +@pytest.mark.parametrize( + "pool", + [ + _pool("p"), + _pool("p", SOLVED), + _pool("p", PARTIAL), + _pool("p", VECTOR), + _pool("p", TYPED), + Pool(id="p", allocations=SOLVED[:1], offset=4096), + ], +) +def test_load_restores_what_save_wrote(pool: Pool, tmp_path: Path) -> None: + (path,) = save_allocation(pool, tmp_path / "problem.csv") + loaded = load_allocation(path) + assert loaded.id == "problem" + assert loaded.allocations == pool.allocations + assert loaded.offset == pool.offset -def test_save_raw_allocations_rejects_non_allocation_elements( - tmp_path: Path, +@pytest.mark.parametrize( + ("ids", "loaded"), + [(["12"], [12]), (["7", "007"], [7, "007"])], +) +def test_load_reads_canonical_digit_ids_as_integers( + ids: list[str], loaded: list[object], tmp_path: Path ) -> None: - with pytest.raises(TypeError, match="Expected Allocation"): - save_allocation((1, 2), tmp_path / "problem.csv") - - -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 + path = tmp_path / "pool.csv" + save_allocation([Allocation(id=i, size=4, start=0, end=1) for i in ids], path) + assert [a.id for a in load_allocation(path).allocations] == loaded -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), +@pytest.mark.parametrize( + ("entity", "name", "written"), + [ + (_pool("p"), "nested/dir/problem.csv", ["nested/dir/problem.csv"]), + ( + Memory(id="mem", pools=(_pool("p0"), _pool("p1"))), + "problem.csv", + ["problem_p0.csv", "problem_p1.csv"], ), - ) - (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"),)), + ( + System( + id="sys", + memories=( + Memory(id="m0", pools=(_pool("p0"),)), + Memory(id="m1", pools=(_pool("p0"),)), + ), + ), + "problem.csv", + ["problem_m0_p0.csv", "problem_m1_p0.csv"], ), - ) - 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_pool_rejects_allocation_ids_colliding_after_str(tmp_path: Path) -> None: - allocations = ( - Allocation(id=1, size=4, start=0, end=1), - Allocation(id="1", size=4, start=0, end=1), - ) - with pytest.raises(ValueError, match="unique after string conversion"): - save_allocation(allocations, tmp_path / "pool.csv") - - -def test_save_memory_writes_nothing_when_a_later_pool_is_rejected( - tmp_path: Path, + ], +) +def test_save_writes_one_file_per_pool( + entity: object, name: str, written: list[str], tmp_path: Path ) -> None: - colliding = Pool( - id="bad", - allocations=( - Allocation(id=1, size=4, start=0, end=1), - Allocation(id="1", size=4, start=0, end=1), - ), - ) - memory = Memory(id="mem", pools=(make_pool("ok"), colliding)) - with pytest.raises(ValueError, match="unique after string conversion"): - save_allocation(memory, tmp_path / "problem.csv") - assert not any(tmp_path.iterdir()) - + paths = save_allocation(entity, tmp_path / name) + assert paths == tuple(tmp_path / path for path in written) + assert all(load_allocation(path).allocations == PROBLEM for path in paths) -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() - - -def test_round_trip_preserves_integer_ids_and_kinds(tmp_path: Path) -> None: - allocations = ( - Allocation( - id=7, size=10, start=0, end=5, offset=0, kind=AllocationKind.CONSTANT +@pytest.mark.parametrize( + ("entity", "error", "match"), + [ + ((1, 2), TypeError, "Expected Allocation"), + (Memory(id="mem", pools=(_pool(1), _pool("1"))), ValueError, "unique"), + ( + ( + Allocation(id=1, size=4, start=0, end=1), + Allocation(id="1", size=4, start=0, end=1), + ), + ValueError, + "unique after string conversion", ), - Allocation(id="named", size=4, start=2, end=8, offset=16), - ) - path = tmp_path / "pool.csv" - save_allocation(allocations, path) - assert load_allocation(path).allocations == allocations - - -def test_round_trip_keeps_digit_like_string_ids_as_integers(tmp_path: Path) -> None: - path = tmp_path / "pool.csv" - save_allocation((Allocation(id="12", size=4, start=0, end=1),), path) - assert load_allocation(path).allocations[0].id == 12 - - -def test_files_without_kinds_stay_minimalloc_shaped(tmp_path: Path) -> None: - path = tmp_path / "pool.csv" - save_allocation((Allocation(id=1, size=4, start=0, end=1),), path) - assert path.read_text().splitlines()[0] == "id,lower,upper,size" - - -def test_round_trip_keeps_noncanonical_digit_ids_as_strings(tmp_path: Path) -> None: - path = tmp_path / "pool.csv" - save_allocation( ( - Allocation(id="7", size=4, start=0, end=1), - Allocation(id="007", size=4, start=2, end=3), + Memory( + id="mem", + pools=( + _pool("ok"), + _pool( + "bad", + ( + Allocation(id=1, size=4, start=0, end=1), + Allocation(id="1", size=4, start=0, end=1), + ), + ), + ), + ), + ValueError, + "unique after string conversion", ), - path, - ) - assert [a.id for a in load_allocation(path).allocations] == [7, "007"] - - -def test_round_trip_restores_pinned_pool_base(tmp_path: Path) -> None: - pool = Pool( - id="pool", - allocations=(Allocation(id="a", size=4, start=0, end=3, offset=0),), - offset=4096, - ) - path = tmp_path / "pool.csv" - save_allocation(pool, path) - loaded = load_allocation(path) - assert loaded.offset == 4096 - assert loaded.allocations == pool.allocations - - -def test_unpinned_pool_writes_no_base_line(tmp_path: Path) -> None: - path = tmp_path / "pool.csv" - save_allocation(make_allocated_pool(), path) - assert path.read_text().splitlines()[0] == "id,lower,upper,size,offset" - assert load_allocation(path).offset is None + ], +) +def test_save_rejects_before_writing_anything( + entity: object, error: type[Exception], match: str, tmp_path: Path +) -> None: + with pytest.raises(error, match=match): + save_allocation(entity, tmp_path / "problem.csv") + assert not any(tmp_path.iterdir()) From 609b0b649d73acc67b45c653019dfea771e1e58a Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 16:02:59 +0000 Subject: [PATCH 13/17] Flatten the allocate entry point tests into tables --- tests/unit/test_allocate.py | 306 +++++++----------------------------- 1 file changed, 60 insertions(+), 246 deletions(-) diff --git a/tests/unit/test_allocate.py b/tests/unit/test_allocate.py index 2716da0..06219be 100644 --- a/tests/unit/test_allocate.py +++ b/tests/unit/test_allocate.py @@ -4,257 +4,71 @@ import pytest from omnimalloc import allocate -from omnimalloc.allocators.greedy import GreedyAllocator, GreedyBySizeAllocator from omnimalloc.allocators.naive import NaiveAllocator from omnimalloc.primitives import Allocation, AllocationKind, Memory, Pool, System - -def test_allocate_pool_with_naive_allocator() -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=5) - alloc2 = Allocation(id=2, size=150, start=5, end=10) - pool = Pool(id=1, allocations=(alloc1, alloc2)) - - allocator = NaiveAllocator() - allocated_pool = allocate(pool, allocator) - - assert allocated_pool.is_allocated - assert all(a.offset is not None for a in allocated_pool.allocations) - assert allocated_pool.allocations[0].offset == 0 - assert allocated_pool.allocations[1].offset == 100 - - -def test_allocate_memory_with_multiple_pools() -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=10) - 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)) - - allocator = NaiveAllocator() - allocated_memory = allocate(memory, allocator) - - assert allocated_memory.is_allocated - assert all(p.is_allocated for p in allocated_memory.pools) - - -def test_allocate_system_with_multiple_memories() -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=5) - alloc2 = Allocation(id=2, size=150, start=0, end=5) - pool1 = Pool(id=1, allocations=(alloc1,)) - pool2 = Pool(id=2, allocations=(alloc2,)) - memory1 = Memory(id=1, pools=(pool1,)) - memory2 = Memory(id=2, pools=(pool2,)) - system = System(id=1, memories=(memory1, memory2)) - - allocator = NaiveAllocator() - allocated_system = allocate(system, allocator) - - assert allocated_system.is_allocated - assert all(m.is_allocated for m in allocated_system.memories) - - -def test_allocate_with_validation_success() -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=5) - alloc2 = Allocation(id=2, size=150, start=5, end=10) - pool = Pool(id=1, allocations=(alloc1, alloc2)) - - allocator = NaiveAllocator() - allocated_pool = allocate(pool, allocator, validate=True) - - assert allocated_pool.is_allocated - - -def test_allocate_preserves_pool_offset() -> None: - alloc = Allocation(id=1, size=100, start=0, end=10) - pool = Pool(id=1, allocations=(alloc,), offset=50) - - allocator = NaiveAllocator() - allocated_pool = allocate(pool, allocator) - - assert allocated_pool.offset == 50 - - -def test_allocate_with_allocation_kinds() -> None: - 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 = allocate(pool, allocator) - - 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: - alloc1 = Allocation(id="buf_a", size=100, start=0, end=5) - alloc2 = Allocation(id="buf_b", size=150, start=5, end=10) - pool = Pool(id="main_pool", allocations=(alloc1, alloc2)) - - allocator = NaiveAllocator() - allocated_pool = allocate(pool, allocator) - - assert allocated_pool.is_allocated - assert allocated_pool.id == "main_pool" - assert allocated_pool.allocations[0].id == "buf_a" - assert allocated_pool.allocations[1].id == "buf_b" - - -def test_allocate_complex_hierarchy() -> None: - allocations_pool1 = [ - Allocation(id=i, size=50 + i * 10, start=i * 2, end=(i + 1) * 2) - for i in range(5) - ] - allocations_pool2 = [ - Allocation(id=i + 5, size=100, start=i * 3, end=(i + 1) * 3) for i in range(3) - ] - - 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) - - 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) - - system = System(id=1, memories=(memory1, memory2)) - - allocator = GreedyAllocator() - allocated_system = allocate(system, allocator) - - assert allocated_system.is_allocated - for memory in allocated_system.memories: - assert memory.is_allocated - for pool in memory.pools: - assert pool.is_allocated - for alloc in pool.allocations: - assert alloc.is_allocated - - -def test_allocate_empty_pool() -> None: - pool = Pool(id=1, allocations=()) - - allocator = NaiveAllocator() - allocated_pool = allocate(pool, allocator) - - assert allocated_pool.is_allocated - assert len(allocated_pool.allocations) == 0 - - -def test_allocate_preserves_original_properties() -> None: - 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 = 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 == AllocationKind.OUTPUT - assert allocated_alloc.offset is not None - - -def test_allocate_returns_same_type() -> None: - alloc = Allocation(id=1, size=100, start=0, end=10) - pool = Pool(id=1, allocations=(alloc,)) - memory = Memory(id=1, pools=(pool,)) - system = System(id=1, memories=(memory,)) - - allocator = NaiveAllocator() - - assert isinstance(allocate(pool, allocator), Pool) - assert isinstance(allocate(memory, allocator), Memory) - assert isinstance(allocate(system, allocator), System) - - -def test_allocate_pool_calculates_correct_size() -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=5) - alloc2 = Allocation(id=2, size=150, start=5, end=10) - pool = Pool(id=1, allocations=(alloc1, alloc2)) - - allocator = NaiveAllocator() - allocated_pool = allocate(pool, allocator) - - # Naive places sequentially: 0-100, 100-250 - assert allocated_pool.size == 250 - - -def test_allocate_memory_calculates_extent() -> None: - alloc1 = Allocation(id=1, size=100, start=0, end=10) - 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) - - allocator = NaiveAllocator() - allocated_memory = allocate(memory, allocator) - - assert allocated_memory.extent == 250 - - -def test_allocate_raw_allocations_returns_tuple() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=5), - Allocation(id=2, size=150, start=5, end=10), +FIRST = Allocation(id="a", size=100, start=0, end=5, kind=AllocationKind.WORKSPACE) +SECOND = Allocation(id="b", size=150, start=5, end=10, kind=AllocationKind.CONSTANT) +THIRD = Allocation(id="c", size=50, start=0, end=5) +POOL = Pool(id="p", allocations=(FIRST, SECOND)) + + +@pytest.mark.parametrize( + "entity", + [ + POOL, + Pool(id="empty", allocations=()), + Memory(id="m", pools=(POOL, Pool(id="q", allocations=(THIRD,)))), + System(id="s", memories=(Memory(id="m", pools=(POOL,)),)), + ], +) +def test_allocate_returns_the_entity_placed(entity: Pool | Memory | System) -> None: + placed = allocate(entity, "naive", validate=True) + assert type(placed) is type(entity) + assert placed.is_allocated + + +@pytest.mark.parametrize( + "allocations", [(THIRD, FIRST, SECOND), [THIRD, FIRST, SECOND], ()] +) +def test_allocate_returns_raw_allocations_as_a_placed_tuple( + allocations: list[Allocation] | tuple[Allocation, ...], +) -> None: + placed = allocate(allocations, "greedy_by_size", validate=True) + assert type(placed) is tuple + assert all(a.is_allocated for a in placed) + assert [a.id for a in placed] == [a.id for a in allocations] + + +def test_allocate_keeps_everything_but_the_offsets() -> None: + placed = allocate(Pool(id="p", allocations=(FIRST, SECOND), offset=50), "naive") + assert placed.offset == 50 + assert placed.allocations == (FIRST.with_offset(0), SECOND.with_offset(100)) + assert placed.size == 250 + + +def test_allocate_stacks_the_pools_of_a_memory() -> None: + memory = Memory( + id="m", + pools=(Pool(id=1, allocations=(FIRST,)), Pool(id=2, allocations=(SECOND,))), + size=1000, ) - allocated = allocate(allocations, NaiveAllocator(), validate=True) - assert isinstance(allocated, tuple) - assert all(a.offset is not None for a in allocated) - assert {a.id for a in allocated} == {1, 2} + assert allocate(memory, NaiveAllocator()).extent == 250 -def test_allocate_raw_allocations_accepts_list_and_registry_name() -> None: - allocations = [ - Allocation(id="a", size=100, start=0, end=5), - Allocation(id="b", size=150, start=0, end=5), - ] - allocated = allocate(allocations, "naive") - assert isinstance(allocated, tuple) - assert all(a.is_allocated for a in allocated) +def test_allocate_leaves_its_input_alone() -> None: + allocate((FIRST,), "naive") + assert FIRST.offset is None -def test_allocate_raw_allocations_does_not_mutate_input() -> None: - allocations = (Allocation(id=1, size=100, start=0, end=5),) - allocate(allocations, NaiveAllocator()) - assert allocations[0].offset is None - - -def test_allocate_raw_allocations_duplicate_ids_raise() -> None: - allocations = ( - Allocation(id=1, size=100, start=0, end=5), - Allocation(id=1, size=150, start=5, end=10), - ) - with pytest.raises(ValueError, match="allocation ids must be unique"): - allocate(allocations, NaiveAllocator()) - - -def test_allocate_raw_allocations_empty() -> None: - assert allocate((), NaiveAllocator()) == () - - -def test_allocate_raw_allocations_rejects_non_allocation_elements() -> None: - with pytest.raises(TypeError, match="Expected Allocation"): - allocate((1, 2, 3), NaiveAllocator()) - - -def test_allocate_rejects_unsupported_entity() -> None: - with pytest.raises(TypeError, match="Unsupported entity type"): - allocate("naive") - - -def test_allocate_raw_allocations_preserves_input_order() -> None: - allocations = tuple( - Allocation(id=i, size=10 * (i + 1), start=0, end=5) for i in range(10) - ) - allocated = allocate(allocations, GreedyBySizeAllocator(), validate=True) - assert tuple(a.id for a in allocated) == tuple(a.id for a in allocations) - assert all(a.offset is not None for a in allocated) - assert tuple(a.size for a in allocated) == tuple(a.size for a in allocations) +@pytest.mark.parametrize( + ("entity", "error", "match"), + [ + ((FIRST, FIRST), ValueError, "allocation ids must be unique"), + ((1, 2, 3), TypeError, "Expected Allocation"), + ("naive", TypeError, "Unsupported entity type"), + ], +) +def test_allocate_rejects(entity: object, error: type[Exception], match: str) -> None: + with pytest.raises(error, match=match): + allocate(entity, NaiveAllocator()) From 2987c7c1a12022393f77afb2486f3c6f3379c1d7 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 16:03:16 +0000 Subject: [PATCH 14/17] Drop hierarchy and public API checks that other tests imply --- tests/integration/test_hierarchy.py | 8 -------- tests/unit/test_public_api.py | 6 ------ 2 files changed, 14 deletions(-) diff --git a/tests/integration/test_hierarchy.py b/tests/integration/test_hierarchy.py index 32a4b9d..471ff50 100644 --- a/tests/integration/test_hierarchy.py +++ b/tests/integration/test_hierarchy.py @@ -50,14 +50,6 @@ def test_a_six_memory_system_places_and_validates() -> None: validate_allocation(placed, require_capacity=True) -def test_every_pool_of_a_memory_gets_a_distinct_base() -> None: - placed = allocate(_accelerator(), "omni") - for memory in placed.memories: - bases = [pool.offset for pool in memory.pools] - assert len(set(bases)) == len(bases) - assert all(base is not None for base in bases) - - def test_pool_bases_stack_without_gaps() -> None: placed = allocate(_accelerator(), "omni") for memory in placed.memories: diff --git a/tests/unit/test_public_api.py b/tests/unit/test_public_api.py index 4cca94c..a2fbbac 100644 --- a/tests/unit/test_public_api.py +++ b/tests/unit/test_public_api.py @@ -7,7 +7,6 @@ import omnimalloc import omnimalloc.analysis import omnimalloc.benchmark -from omnimalloc.allocators import BaseAllocator TOP_LEVEL_API = { "Allocation", @@ -94,8 +93,3 @@ def test_benchmark_api_is_pinned() -> None: def test_version_is_exposed() -> None: assert omnimalloc.__version__ - - -def test_allocate_resolves_every_advertised_allocator_name() -> None: - for name in omnimalloc.available_allocators(): - assert isinstance(BaseAllocator.get(name), type) From d9154686610303470b9cdc4b130d943edefadd32 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 16:05:51 +0000 Subject: [PATCH 15/17] Check the shared source behavior once over the seeded sources --- .../benchmark/sources/test_adversarial.py | 32 ++++------ tests/unit/benchmark/sources/test_base.py | 24 ++----- .../sources/test_concurrent_tiling.py | 56 ++++------------- .../unit/benchmark/sources/test_generator.py | 17 ----- tests/unit/benchmark/sources/test_pinwheel.py | 60 ++++-------------- .../benchmark/sources/test_source_contract.py | 56 +++++++++++++++++ .../benchmark/sources/test_sync_patterns.py | 62 +++++-------------- tests/unit/benchmark/sources/test_tiling.py | 59 ++++-------------- 8 files changed, 123 insertions(+), 243 deletions(-) create mode 100644 tests/unit/benchmark/sources/test_source_contract.py diff --git a/tests/unit/benchmark/sources/test_adversarial.py b/tests/unit/benchmark/sources/test_adversarial.py index 308bf42..fd4c833 100644 --- a/tests/unit/benchmark/sources/test_adversarial.py +++ b/tests/unit/benchmark/sources/test_adversarial.py @@ -17,18 +17,6 @@ def _share_of_largest(sizes: tuple[int, ...], count: int) -> float: return sum(ordered[:count]) / sum(ordered) -def test_skewed_source_generates_the_requested_count() -> None: - allocations = SkewedSource(num_allocations=64).get_allocations() - assert len(allocations) == 64 - assert {a.id for a in allocations} == set(range(64)) - - -def test_skewed_source_is_deterministic_for_a_fixed_seed() -> None: - first = SkewedSource(num_allocations=32, seed=7).get_allocations() - second = SkewedSource(num_allocations=32, seed=7).get_allocations() - assert first == second - - def test_skewed_source_respects_the_size_bounds() -> None: for distribution in SIZE_DISTRIBUTIONS: allocations = SkewedSource( @@ -48,9 +36,18 @@ def test_dominant_distribution_concentrates_the_bytes() -> None: assert _share_of_largest(skewed, 1) > 5 * _share_of_largest(flat, 1) -def test_skewed_source_rejects_an_unknown_distribution() -> None: - with pytest.raises(ValueError, match="not a valid SizeDistribution"): - SkewedSource(distribution="gaussian") +@pytest.mark.parametrize( + ("source_cls", "kwargs", "match"), + [ + (SkewedSource, {"distribution": "gaussian"}, "not a valid SizeDistribution"), + (TwoPlusTwoSource, {"noise": 1.0}, "noise must be in"), + ], +) +def test_adversarial_sources_reject( + source_cls: type, kwargs: dict[str, object], match: str +) -> None: + with pytest.raises(ValueError, match=match): + source_cls(**kwargs) def test_skewed_placements_validate() -> None: @@ -74,11 +71,6 @@ def test_two_plus_two_stays_non_linearizable_under_noise() -> None: assert try_linearize(allocations, work_budget=None) is None, noise -def test_two_plus_two_rejects_a_noise_of_one() -> None: - with pytest.raises(ValueError, match="noise must be in"): - TwoPlusTwoSource(noise=1.0) - - def test_two_plus_two_generates_vector_clocks() -> None: allocations = TwoPlusTwoSource(num_allocations=16).get_allocations() assert len(allocations) == 16 diff --git a/tests/unit/benchmark/sources/test_base.py b/tests/unit/benchmark/sources/test_base.py index 7226ac4..a7b046b 100644 --- a/tests/unit/benchmark/sources/test_base.py +++ b/tests/unit/benchmark/sources/test_base.py @@ -49,24 +49,12 @@ def test_base_source_counts_are_plain_assignable_attributes() -> None: assert (source.num_memories, source.num_systems) == (5, 4) -def test_base_source_constructor_validates_num_allocations() -> None: - with pytest.raises(ValueError, match="num_allocations must be positive"): - ProbeSource(num_allocations=0) - - -def test_base_source_constructor_validates_num_pools() -> None: - with pytest.raises(ValueError, match="num_pools must be positive"): - ProbeSource(num_pools=0) - - -def test_base_source_constructor_validates_num_memories() -> None: - with pytest.raises(ValueError, match="num_memories must be positive"): - ProbeSource(num_memories=0) - - -def test_base_source_constructor_validates_num_systems() -> None: - with pytest.raises(ValueError, match="num_systems must be positive"): - ProbeSource(num_systems=0) +@pytest.mark.parametrize( + "count", ["num_allocations", "num_pools", "num_memories", "num_systems"] +) +def test_base_source_rejects_a_non_positive(count: str) -> None: + with pytest.raises(ValueError, match=f"{count} must be positive"): + ProbeSource(**{count: 0}) def test_base_source_is_parameterizable() -> None: diff --git a/tests/unit/benchmark/sources/test_concurrent_tiling.py b/tests/unit/benchmark/sources/test_concurrent_tiling.py index 5d5c718..b3e6009 100644 --- a/tests/unit/benchmark/sources/test_concurrent_tiling.py +++ b/tests/unit/benchmark/sources/test_concurrent_tiling.py @@ -3,9 +3,8 @@ # import pytest -from omnimalloc import allocate, validate_allocation +from omnimalloc import validate_allocation from omnimalloc.analysis import antichain_pressure -from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.primitives import Allocation @@ -16,11 +15,6 @@ def _signatures( return [(a.start, a.end, a.size) for a in allocations] -def test_concurrent_tiling_is_registered() -> None: - assert "concurrent_tiling" in BaseSource.registry() - assert BaseSource.get("concurrent_tiling") is ConcurrentTilingSource - - def test_concurrent_tiling_produces_requested_count_and_dim() -> None: source = ConcurrentTilingSource(num_allocations=64, num_threads=4) allocations = source.get_allocations() @@ -91,28 +85,17 @@ def test_concurrent_tiling_ground_truth_matches_get_allocations() -> None: assert _signatures(truth.allocations) == _signatures(allocs) -def test_concurrent_tiling_is_deterministic_per_seed() -> None: - a = ConcurrentTilingSource(num_allocations=64, seed=7).get_allocations() - b = ConcurrentTilingSource(num_allocations=64, seed=7).get_allocations() - c = ConcurrentTilingSource(num_allocations=64, seed=8).get_allocations() - assert _signatures(a) == _signatures(b) - assert _signatures(a) != _signatures(c) - - -def test_concurrent_tiling_distinct_pools_differ() -> None: - source = ConcurrentTilingSource(num_allocations=32) - pools = source.get_pools(num_pools=2) - assert _signatures(pools[0].allocations) != _signatures(pools[1].allocations) - - -def test_concurrent_tiling_rejects_indivisible_capacity() -> None: - with pytest.raises(ValueError, match="divisible"): - ConcurrentTilingSource(num_threads=3, capacity=1024) - - -def test_concurrent_tiling_rejects_nonpositive_threads() -> None: - with pytest.raises(ValueError, match="num_threads"): - ConcurrentTilingSource(num_threads=0) +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"num_threads": 3, "capacity": 1024}, "divisible"), + ({"num_threads": 0}, "num_threads"), + ({"num_threads": 4, "capacity": 4096, "size_min": 2048}, "per-thread capacity"), + ], +) +def test_concurrent_tiling_rejects(kwargs: dict[str, object], match: str) -> None: + with pytest.raises(ValueError, match=match): + ConcurrentTilingSource(**kwargs) def test_concurrent_tiling_rejects_fewer_allocations_than_threads() -> None: @@ -121,11 +104,6 @@ def test_concurrent_tiling_rejects_fewer_allocations_than_threads() -> None: source.get_allocations(num_allocations=2) -def test_concurrent_tiling_rejects_band_below_min_size() -> None: - with pytest.raises(ValueError, match="per-thread capacity"): - ConcurrentTilingSource(num_threads=4, capacity=4096, size_min=2048) - - def test_concurrent_tiling_rejects_num_syncs_out_of_range() -> None: with pytest.raises(ValueError, match="num_syncs"): ConcurrentTilingSource(num_syncs=-1) @@ -133,16 +111,6 @@ def test_concurrent_tiling_rejects_num_syncs_out_of_range() -> None: ConcurrentTilingSource(num_syncs=100, makespan=64, size_min=1, duration_min=1) -def test_concurrent_tiling_no_allocator_beats_the_optimum() -> None: - capacity = 1024 * 1024 - source = ConcurrentTilingSource( - num_allocations=96, num_threads=4, num_syncs=64, capacity=capacity - ) - pool = source.get_pool() - allocated = allocate(pool, "greedy_by_size", validate=True) - assert allocated.size >= capacity - - def test_concurrent_tiling_label_carries_thread_count() -> None: assert ( ConcurrentTilingSource(num_allocations=16, num_threads=4).label() diff --git a/tests/unit/benchmark/sources/test_generator.py b/tests/unit/benchmark/sources/test_generator.py index bea7e60..d3dbfbf 100644 --- a/tests/unit/benchmark/sources/test_generator.py +++ b/tests/unit/benchmark/sources/test_generator.py @@ -22,14 +22,6 @@ ] -@pytest.mark.parametrize("source_cls", SOURCES) -def test_source_basic_creation(source_cls: type[BaseSource]) -> None: - source = source_cls(num_allocations=10, seed=42) - allocations = source.get_allocations() - assert len(allocations) == 10 - assert all(alloc.id == i for i, alloc in enumerate(allocations)) - - @pytest.mark.parametrize("source_cls", SOURCES) def test_source_count_parameter(source_cls: type[BaseSource]) -> None: source = source_cls(num_allocations=100, seed=42) @@ -47,15 +39,6 @@ def test_source_skip_parameter(source_cls: type[BaseSource]) -> None: assert allocs_no_skip[3].start == allocs_skip[0].start -@pytest.mark.parametrize("source_cls", SOURCES) -def test_source_determinism(source_cls: type[BaseSource]) -> None: - allocs1 = source_cls(num_allocations=10, seed=42).get_allocations() - allocs2 = source_cls(num_allocations=10, seed=42).get_allocations() - assert [(a.size, a.start, a.end) for a in allocs1] == [ - (a.size, a.start, a.end) for a in allocs2 - ] - - def test_random_source_size_bounds() -> None: source = RandomSource(num_allocations=100, size_min=1024, size_max=2048, seed=42) allocations = source.get_allocations() diff --git a/tests/unit/benchmark/sources/test_pinwheel.py b/tests/unit/benchmark/sources/test_pinwheel.py index 973f1f2..9b3c9aa 100644 --- a/tests/unit/benchmark/sources/test_pinwheel.py +++ b/tests/unit/benchmark/sources/test_pinwheel.py @@ -3,9 +3,8 @@ # import pytest -from omnimalloc import allocate, validate_allocation +from omnimalloc import validate_allocation from omnimalloc.analysis import antichain_pressure -from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.pinwheel import PinwheelSource from omnimalloc.primitives import Allocation, Pool @@ -31,16 +30,6 @@ def _has_guillotine_cut(pool: Pool) -> bool: return False -def test_pinwheel_source_is_registered() -> None: - assert "pinwheel" in BaseSource.registry() - assert BaseSource.get("pinwheel") is PinwheelSource - - -def test_pinwheel_returns_exactly_the_requested_count() -> None: - allocations = PinwheelSource(num_allocations=65).get_allocations() - assert len(allocations) == 65 - - def test_pinwheel_rejects_unreachable_count() -> None: with pytest.raises(ValueError, match="cannot reach exactly 64"): PinwheelSource(num_allocations=64).get_allocations() @@ -66,34 +55,17 @@ def test_pinwheel_respects_min_size() -> None: assert all(a.size >= 2048 for a in source.get_allocations()) -def test_pinwheel_is_deterministic_per_seed() -> None: - a = PinwheelSource(num_allocations=129, seed=7).get_allocations() - b = PinwheelSource(num_allocations=129, seed=7).get_allocations() - c = PinwheelSource(num_allocations=129, seed=8).get_allocations() - assert _signatures(a) == _signatures(b) - assert _signatures(a) != _signatures(c) - - -def test_pinwheel_distinct_pools_differ() -> None: - source = PinwheelSource(num_allocations=33) - pools = source.get_pools(num_pools=2) - assert len(pools) == 2 - assert _signatures(pools[0].allocations) != _signatures(pools[1].allocations) - - -def test_pinwheel_rejects_capacity_too_small() -> None: - with pytest.raises(ValueError, match="capacity"): - PinwheelSource(capacity=2048, size_min=1024) - - -def test_pinwheel_rejects_makespan_too_small() -> None: - with pytest.raises(ValueError, match="makespan"): - PinwheelSource(makespan=2, duration_min=1) - - -def test_pinwheel_rejects_nonpositive_min_size() -> None: - with pytest.raises(ValueError, match="size_min"): - PinwheelSource(size_min=0) +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"capacity": 2048, "size_min": 1024}, "capacity"), + ({"makespan": 2, "duration_min": 1}, "makespan"), + ({"size_min": 0}, "size_min"), + ], +) +def test_pinwheel_rejects(kwargs: dict[str, object], match: str) -> None: + with pytest.raises(ValueError, match=match): + PinwheelSource(**kwargs) def test_pinwheel_ground_truth_is_valid_and_optimal() -> None: @@ -117,11 +89,3 @@ def test_pinwheel_ground_truth_matches_get_allocations() -> None: def test_pinwheel_packing_is_non_guillotine() -> None: pool = PinwheelSource(num_allocations=65).get_ground_truth_pool() assert not _has_guillotine_cut(pool) - - -def test_pinwheel_no_allocator_beats_the_optimum() -> None: - capacity = 1024 * 1024 - source = PinwheelSource(num_allocations=149, capacity=capacity) - pool = source.get_pool() - allocated = allocate(pool, "greedy_by_size", validate=True) - assert allocated.size >= capacity diff --git a/tests/unit/benchmark/sources/test_source_contract.py b/tests/unit/benchmark/sources/test_source_contract.py new file mode 100644 index 0000000..2a7988c --- /dev/null +++ b/tests/unit/benchmark/sources/test_source_contract.py @@ -0,0 +1,56 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import inspect + +import pytest +from omnimalloc import allocate +from omnimalloc.benchmark.sources import BaseSource, available_sources + +GENERATIVE = sorted( + name + for name in available_sources() + if "seed" in inspect.signature(BaseSource.get(name)).parameters +) +WITH_OPTIMUM = [ + name + for name in GENERATIVE + if BaseSource.get(name).get_known_optimum is not BaseSource.get_known_optimum +] +COUNTS = {"pinwheel": 33} + + +def _source(name: str, seed: int = 7) -> BaseSource: + return BaseSource.get(name)(num_allocations=COUNTS.get(name, 32), seed=seed) + + +def _signature(source: BaseSource, **kwargs: int) -> list[tuple[object, ...]]: + return [(a.id, a.size, a.start, a.end) for a in source.get_allocations(**kwargs)] + + +@pytest.mark.parametrize("name", GENERATIVE) +def test_a_source_yields_the_requested_count_numbered_from_zero(name: str) -> None: + allocations = _source(name).get_allocations() + assert [a.id for a in allocations] == list(range(COUNTS.get(name, 32))) + + +@pytest.mark.parametrize("name", GENERATIVE) +def test_a_seed_fixes_the_allocations(name: str) -> None: + assert _signature(_source(name)) == _signature(_source(name)) + assert _signature(_source(name)) != _signature(_source(name, seed=8)) + + +@pytest.mark.parametrize("name", GENERATIVE) +def test_consecutive_pools_differ(name: str) -> None: + first, second = _source(name).get_pools(num_pools=2) + assert [(a.size, a.start, a.end) for a in first.allocations] != [ + (a.size, a.start, a.end) for a in second.allocations + ] + + +@pytest.mark.parametrize("name", WITH_OPTIMUM) +def test_greedy_by_size_never_beats_the_known_optimum(name: str) -> None: + source = _source(name) + placed = allocate(source.get_pool(), "greedy_by_size", validate=True) + assert placed.size >= source.get_known_optimum() diff --git a/tests/unit/benchmark/sources/test_sync_patterns.py b/tests/unit/benchmark/sources/test_sync_patterns.py index c0bc39a..e6a264c 100644 --- a/tests/unit/benchmark/sources/test_sync_patterns.py +++ b/tests/unit/benchmark/sources/test_sync_patterns.py @@ -4,7 +4,6 @@ import pytest from omnimalloc.analysis import antichain_pressure, try_linearize -from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.primitives import Allocation @@ -15,11 +14,6 @@ def _signatures( return [(a.start, a.end, a.size) for a in allocations] -def test_sync_patterns_is_registered() -> None: - assert "sync_pattern" in BaseSource.registry() - assert BaseSource.get("sync_pattern") is SyncPatternSource - - @pytest.mark.parametrize("pattern", SYNC_PATTERNS) def test_sync_patterns_lifetimes_are_valid(pattern: str) -> None: source = SyncPatternSource(num_allocations=32, num_threads=4, pattern=pattern) @@ -74,38 +68,20 @@ def test_sync_patterns_groups_stay_isolated_before_global_barrier() -> None: assert alloc.end[2:] == (0, 0) or alloc.end[:2] == (0, 0) -def test_sync_patterns_is_deterministic_per_seed() -> None: - a = SyncPatternSource(num_allocations=32, seed=7).get_allocations() - b = SyncPatternSource(num_allocations=32, seed=7).get_allocations() - c = SyncPatternSource(num_allocations=32, seed=8).get_allocations() - assert _signatures(a) == _signatures(b) - assert _signatures(a) != _signatures(c) - - -def test_sync_patterns_distinct_pools_differ() -> None: - source = SyncPatternSource(num_allocations=16) - pools = source.get_pools(num_pools=2) - assert _signatures(pools[0].allocations) != _signatures(pools[1].allocations) - - -def test_sync_patterns_rejects_unknown_pattern() -> None: - with pytest.raises(ValueError, match="not a valid SyncPattern"): - SyncPatternSource(pattern="mesh") - - -def test_sync_patterns_rejects_nonpositive_threads() -> None: - with pytest.raises(ValueError, match="num_threads"): - SyncPatternSource(num_threads=0) - - -def test_sync_patterns_rejects_nonpositive_sync_period() -> None: - with pytest.raises(ValueError, match="sync_period"): - SyncPatternSource(sync_period=0) - - -def test_sync_patterns_rejects_too_few_steps() -> None: - with pytest.raises(ValueError, match="steps"): - SyncPatternSource(steps=1) +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"pattern": "mesh"}, "not a valid SyncPattern"), + ({"num_threads": 0}, "num_threads"), + ({"sync_period": 0}, "sync_period"), + ({"steps": 1}, "steps"), + ({"size_distribution": "normal"}, "not a valid SizeDistribution"), + ({"speed_skew": 0}, "speed_skew must be positive"), + ], +) +def test_sync_patterns_rejects(kwargs: dict[str, object], match: str) -> None: + with pytest.raises(ValueError, match=match): + SyncPatternSource(**kwargs) @pytest.mark.parametrize("pattern", SYNC_PATTERNS) @@ -174,16 +150,6 @@ def test_label_differs_between_thread_counts() -> None: ) -def test_unknown_size_distribution_rejected() -> None: - with pytest.raises(ValueError, match="not a valid SizeDistribution"): - SyncPatternSource(size_distribution="normal") - - -def test_non_positive_speed_skew_rejected() -> None: - with pytest.raises(ValueError, match="speed_skew must be positive"): - SyncPatternSource(speed_skew=0) - - def test_every_step_barrier_linearizes_while_a_sparser_one_does_not() -> None: tight = SyncPatternSource( num_allocations=128, num_threads=8, pattern="barrier", sync_period=1, seed=1 diff --git a/tests/unit/benchmark/sources/test_tiling.py b/tests/unit/benchmark/sources/test_tiling.py index 33703c7..d02d409 100644 --- a/tests/unit/benchmark/sources/test_tiling.py +++ b/tests/unit/benchmark/sources/test_tiling.py @@ -7,7 +7,6 @@ import pytest from omnimalloc import allocate, validate_allocation from omnimalloc.analysis import antichain_pressure -from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.tiling import TilingSource from omnimalloc.primitives import Allocation @@ -16,17 +15,6 @@ def _signatures(allocations: tuple[Allocation, ...]) -> list[tuple[int, int, int return [(a.start, a.end, a.size) for a in allocations] -def test_tiling_source_is_registered() -> None: - assert "tiling" in BaseSource.registry() - assert BaseSource.get("tiling") is TilingSource - - -def test_tiling_source_produces_requested_count() -> None: - source = TilingSource(num_allocations=128) - allocations = source.get_allocations() - assert len(allocations) == 128 - - @pytest.mark.parametrize("num", [1, 16, 64, 256, 512]) def test_tiling_optimum_is_tight(num: int) -> None: capacity = 1024 * 1024 @@ -51,34 +39,17 @@ def test_tiling_zero_requested_returns_empty() -> None: assert TilingSource().get_allocations(num_allocations=0) == () -def test_tiling_is_deterministic_per_seed() -> None: - a = TilingSource(num_allocations=128, seed=7).get_allocations() - b = TilingSource(num_allocations=128, seed=7).get_allocations() - c = TilingSource(num_allocations=128, seed=8).get_allocations() - assert _signatures(a) == _signatures(b) - assert _signatures(a) != _signatures(c) - - -def test_tiling_distinct_pools_differ() -> None: - source = TilingSource(num_allocations=32) - pools = source.get_pools(num_pools=2) - assert len(pools) == 2 - assert _signatures(pools[0].allocations) != _signatures(pools[1].allocations) - - -def test_tiling_rejects_invalid_mem_cut_prob() -> None: - with pytest.raises(ValueError, match="mem_cut_prob"): - TilingSource(mem_cut_prob=1.5) - - -def test_tiling_rejects_capacity_below_min_size() -> None: - with pytest.raises(ValueError, match="capacity"): - TilingSource(capacity=10, size_min=1024) - - -def test_tiling_rejects_nonpositive_min_size() -> None: - with pytest.raises(ValueError, match="size_min"): - TilingSource(size_min=0) +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"mem_cut_prob": 1.5}, "mem_cut_prob"), + ({"capacity": 10, "size_min": 1024}, "capacity"), + ({"size_min": 0}, "size_min"), + ], +) +def test_tiling_rejects(kwargs: dict[str, object], match: str) -> None: + with pytest.raises(ValueError, match=match): + TilingSource(**kwargs) def test_tiling_raises_when_count_unreachable() -> None: @@ -132,14 +103,6 @@ def test_tiling_variant_sweep_builds_ladder() -> None: assert pool.pressure == 1024 * 1024 -def test_tiling_no_allocator_beats_the_optimum() -> None: - capacity = 1024 * 1024 - source = TilingSource(num_allocations=150, capacity=capacity) - pool = source.get_pool() - allocated = allocate(pool, "greedy_by_size", validate=True) - assert allocated.size >= capacity - - def test_memory_declares_the_achievable_capacity() -> None: source = TilingSource(num_allocations=64, capacity=4096, makespan=1024) memory = source.get_memory() From ba0e48129af4c14c066be7516ad02eb98d7a381c Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 16:06:17 +0000 Subject: [PATCH 16/17] Point artifacts_dir at tmp_path instead of the repository tree --- tests/conftest.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d704424..84f3c2d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 # -import shutil from collections.abc import Iterator from pathlib import Path @@ -21,18 +20,9 @@ @pytest.fixture # type: ignore[misc] -def artifacts_dir(request: pytest.FixtureRequest) -> Path: - artifacts_root = Path(__file__).parent / "artifacts" - test_name = request.node.name - test_file = Path(request.node.fspath).stem - test_dir = artifacts_root / test_file / test_name - - if test_dir.exists(): - shutil.rmtree(test_dir) - - test_dir.mkdir(parents=True) - - return Path(test_dir) +def artifacts_dir(tmp_path: Path) -> Path: + # Kept only for tests/integration/test_supermalloc.py; use tmp_path directly + return tmp_path @pytest.fixture(autouse=True) # type: ignore[misc] From 126a5fccc177719081378099b797e39adb9d6f41 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Tue, 29 Sep 2026 16:12:25 +0000 Subject: [PATCH 17/17] Let idle xdist workers steal queued tests in CI --- .github/workflows/checks.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index dbfc4e2..bb604bf 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -47,6 +47,9 @@ jobs: - run: uv run python examples/02_plotting.py - run: uv run python examples/03_allocators.py - run: uv run python examples/04_sources.py + - run: > + uv run jupyter nbconvert --execute --to notebook + --output-dir executed_notebooks notebooks/*.ipynb test: runs-on: ${{ matrix.os }} @@ -78,7 +81,7 @@ jobs: # Gate coverage only where every optional path runs: Hub rate limits and # the Linux-only minimalloc install starve the other legs of statements - run: > - uv run pytest -v -n auto + uv run pytest -v -n auto --dist worksteal ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == env.DEFAULT_PYTHON && '--cov=omnimalloc --cov-fail-under=90 --cov-report=term-missing'