diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index bff0055..bb604bf 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 @@ -48,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 }} @@ -79,25 +81,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 --dist worksteal ${{ 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 +106,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/.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/.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/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 cca47d2..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"] @@ -102,8 +101,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 +110,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 +120,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 @@ -158,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/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/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/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, " 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 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] 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/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_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/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/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) 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/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/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]) 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_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_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: 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() 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) 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()) 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 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()) 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) 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) 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) 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 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" },