diff --git a/scripts/benchmark_pressure.py b/scripts/benchmark_pressure.py index b1bad52..95e8f34 100644 --- a/scripts/benchmark_pressure.py +++ b/scripts/benchmark_pressure.py @@ -4,11 +4,11 @@ """Benchmark exact pressure lower bounds on vector-clock workloads. Compares the shipping ``get_pressure`` (linearize then sweep, else the exact -antichain) against the two exact C++ methods: +antichain, under the default work budget) against the two exact C++ methods: -- ``get_antichain_pressure``: max-weight antichain (weighted Dilworth via - min flow), the tightest sound lower bound on any placement's peak and the - reference every ratio is measured against +- ``get_pressure(work_budget=None)``: unbudgeted max-weight antichain + (weighted Dilworth via min flow), the tightest sound lower bound on any + placement's peak and the reference every ratio is measured against - ``get_closure_pressure``: realizable peak via join-closure enumeration; instances whose closure exceeds ``--closure-cap`` are reported as capped and excluded from the means. Instances where ``get_pressure`` exceeds its @@ -50,13 +50,13 @@ from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.benchmark.timer import Timer from omnimalloc.primitives import ( - get_antichain_pressure, get_closure_pressure, get_per_allocation_closure_pressure, get_per_allocation_placement_pressure, get_per_allocation_pressure, + get_placement_pressure, + get_pressure, ) -from omnimalloc.primitives.pressure import get_pressure if TYPE_CHECKING: from collections.abc import Callable @@ -70,7 +70,7 @@ SIZES = (100, 300, 1_000, 3_000, 10_000) NUM_SYNCS = (0, 64, 1_024) -REFERENCE = "get_antichain_pressure" +REFERENCE = "get_pressure(work_budget=None)" PER_ALLOCATION_REFERENCE = "get_per_allocation_pressure" METHODS = ( "get_pressure", @@ -92,7 +92,7 @@ AXIS = "#c3c2b7" COLORS = { "get_pressure": "#2a78d6", - "get_antichain_pressure": "#1baf7a", + REFERENCE: "#1baf7a", "get_closure_pressure": "#eda100", "omni_allocator": "#4a3aa7", "get_per_allocation_pressure": "#1baf7a", @@ -111,11 +111,6 @@ Sample = dict[str, Any] -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 _capped( query: Callable[..., Any], allocations: tuple[Allocation, ...], cap: int ) -> Value: @@ -142,11 +137,13 @@ def _sample_runners( ) -> dict[str, Runner]: return { "get_pressure": lambda: _budgeted(allocations), - REFERENCE: lambda: get_antichain_pressure(allocations), + REFERENCE: lambda: get_pressure(allocations, work_budget=None), "get_closure_pressure": lambda: _capped( get_closure_pressure, allocations, args.closure_cap ), - "omni_allocator": lambda: _peak(allocator.allocate(allocations)), + "omni_allocator": lambda: get_placement_pressure( + allocator.allocate(allocations) + ), PER_ALLOCATION_REFERENCE: lambda: get_per_allocation_pressure(allocations), "get_per_allocation_closure_pressure": lambda: _capped( get_per_allocation_closure_pressure, allocations, args.closure_cap @@ -235,7 +232,7 @@ def _check_per_allocation( if closure and values.get("get_closure_pressure") is not None: assert max(closure.values()) == values["get_closure_pressure"] if placement: - assert max(placement.values()) == _peak(placed) + assert max(placement.values()) == get_placement_pressure(placed) def _ratios(values: dict[str, Any], reference: int) -> dict[str, float]: diff --git a/src/cpp/bindings.cpp b/src/cpp/bindings.cpp index 8519266..accf5c9 100644 --- a/src/cpp/bindings.cpp +++ b/src/cpp/bindings.cpp @@ -123,19 +123,21 @@ NB_MODULE(_cpp, m) { m.def("compute_conflict_degrees", &compute_conflict_degrees, "allocations"_a, nb::call_guard(), nb::rv_policy::move); m.def("try_linearize", &try_linearize, "allocations"_a, - "work_budget"_a = kNoLinearizeBudget, + "work_budget"_a = kNoWorkBudget, nb::call_guard(), nb::rv_policy::move); m.attr("DEFAULT_WORK_BUDGET") = kDefaultWorkBudget; + m.attr("DEFAULT_CLOSURE_CAP") = kDefaultClosureCap; m.def("antichain_pressure", &antichain_pressure, "allocations"_a, - "work_budget"_a = kNoLinearizeBudget, + "work_budget"_a = kNoWorkBudget, nb::call_guard()); - m.def("closure_pressure", &closure_pressure, "allocations"_a, "closure_cap"_a, + m.def("closure_pressure", &closure_pressure, "allocations"_a, + "closure_cap"_a = kDefaultClosureCap, nb::call_guard()); m.def("per_allocation_antichain_pressure", &per_allocation_antichain_pressure, - "allocations"_a, nb::call_guard(), - nb::rv_policy::move); + "allocations"_a, "work_budget"_a = kNoWorkBudget, + nb::call_guard(), nb::rv_policy::move); m.def("per_allocation_closure_pressure", &per_allocation_closure_pressure, - "allocations"_a, "closure_cap"_a, + "allocations"_a, "closure_cap"_a = kDefaultClosureCap, nb::call_guard(), nb::rv_policy::move); m.def("per_allocation_placement_pressure", &per_allocation_placement_pressure, "allocations"_a, "clique_cap"_a = false, diff --git a/src/cpp/primitives/antichain.cpp b/src/cpp/primitives/antichain.cpp index dd5ab83..91d078f 100644 --- a/src/cpp/primitives/antichain.cpp +++ b/src/cpp/primitives/antichain.cpp @@ -259,7 +259,7 @@ int64_t antichain_pressure(const std::vector& allocations, } std::vector per_allocation_antichain_pressure( - const std::vector& allocations) { + const std::vector& allocations, uint64_t work_budget) { const size_t n = allocations.size(); if (n == 0) { return {}; @@ -269,7 +269,7 @@ std::vector per_allocation_antichain_pressure( // Interval orders: every clique through an allocation shares a time point // inside its own lifetime (Helly), so the pinned antichain is the window // peak of the linearized sweep. - if (const auto times = linearize_times(allocations)) { + if (const auto times = linearize_times(allocations, work_budget)) { std::vector weights(n); std::ranges::transform(allocations, weights.begin(), &Allocation::size); return interval_peaks(*times, weights); @@ -302,7 +302,7 @@ std::vector per_allocation_antichain_pressure( weights.push_back(groups.weights[j]); } pinned[i] = groups.weights[i] + - max_antichain(starts, ends, weights, d, 1, kNoLinearizeBudget); + max_antichain(starts, ends, weights, d, 1, work_budget); }); std::vector peaks(n); diff --git a/src/cpp/primitives/antichain.hpp b/src/cpp/primitives/antichain.hpp index a6a338f..272b037 100644 --- a/src/cpp/primitives/antichain.hpp +++ b/src/cpp/primitives/antichain.hpp @@ -24,7 +24,7 @@ namespace omnimalloc { // of stalling or exhausting memory once the budget is exceeded. [[nodiscard]] int64_t antichain_pressure( const std::vector& allocations, - uint64_t work_budget = kNoLinearizeBudget); + uint64_t work_budget = kNoWorkBudget); // Exact per-allocation pressure, aligned with `allocations`: for each // allocation the max-weight antichain through it, i.e. the heaviest @@ -34,7 +34,10 @@ namespace omnimalloc { // Interval orders resolve through one linearized window sweep; genuinely // partial orders solve one pinned min flow per distinct lifetime over its // conflict neighborhood — built for certification, not the 10k+ hot path. +// A finite `work_budget` bounds the linearize attempt and each pinned flow +// (see antichain_pressure); the flow path throws once it is exceeded. [[nodiscard]] std::vector per_allocation_antichain_pressure( - const std::vector& allocations); + const std::vector& allocations, + uint64_t work_budget = kNoWorkBudget); } // namespace omnimalloc diff --git a/src/cpp/primitives/closure.hpp b/src/cpp/primitives/closure.hpp index 49570da..a3cd902 100644 --- a/src/cpp/primitives/closure.hpp +++ b/src/cpp/primitives/closure.hpp @@ -13,16 +13,21 @@ namespace omnimalloc { +// Default join-closure enumeration cap, so huge vector-clock instances +// fail fast instead of exhausting memory. Exported to Python as +// DEFAULT_CLOSURE_CAP. +inline constexpr size_t kDefaultClosureCap = 1 << 14; + // Exact realizable peak: the maximum total size jointly live at a single // cut, scored over the join-closure of the birth clocks (the minimal cut // where a candidate set is jointly live is the join of its births, and // joins of observed clocks span the consistent-cut lattice). Note that // pairwise-concurrent allocations need not share a cut, so this can sit // strictly below antichain_pressure; both soundly lower-bound any -// placement's peak. nullopt once the closure exceeds `closure_cap`; the -// default cap policy lives on the Python side (`DEFAULT_CLOSURE_CAP`). +// placement's peak. nullopt once the closure exceeds `closure_cap`. [[nodiscard]] std::optional closure_pressure( - const std::vector& allocations, size_t closure_cap); + const std::vector& allocations, + size_t closure_cap = kDefaultClosureCap); // Exact realizable peak while each allocation is live, aligned with // `allocations`: the maximum total size at any join-closure cut where the @@ -32,6 +37,6 @@ namespace omnimalloc { // equals closure_pressure. nullopt once the closure exceeds `closure_cap`. [[nodiscard]] std::optional> per_allocation_closure_pressure(const std::vector& allocations, - size_t closure_cap); + size_t closure_cap = kDefaultClosureCap); } // namespace omnimalloc diff --git a/src/cpp/primitives/linearize.hpp b/src/cpp/primitives/linearize.hpp index 9e7bb6f..97610d3 100644 --- a/src/cpp/primitives/linearize.hpp +++ b/src/cpp/primitives/linearize.hpp @@ -14,8 +14,7 @@ namespace omnimalloc { -inline constexpr uint64_t kNoLinearizeBudget = - std::numeric_limits::max(); +inline constexpr uint64_t kNoWorkBudget = std::numeric_limits::max(); // Default dominance-counting budget for implicit and hot-path callers (the // omni allocator's linearize attempt, `Pool.pressure`), so huge vector-clock @@ -30,13 +29,13 @@ inline constexpr uint64_t kDefaultWorkBudget = 100'000'000; // (undecided; the caller falls back to the vector conflict engine). [[nodiscard]] std::optional>> linearize_times(const std::vector& allocations, - uint64_t work_budget = kNoLinearizeBudget); + uint64_t work_budget = kNoWorkBudget); // Allocation-level wrapper: allocations rebuilt with the surrogate scalar // times, or nullopt when `linearize_times` yields none (not an interval // order — or undecided under a finite `work_budget`). [[nodiscard]] std::optional> try_linearize( const std::vector& allocations, - uint64_t work_budget = kNoLinearizeBudget); + uint64_t work_budget = kNoWorkBudget); } // namespace omnimalloc diff --git a/src/python/omnimalloc/__init__.py b/src/python/omnimalloc/__init__.py index c32a5f9..b453aed 100644 --- a/src/python/omnimalloc/__init__.py +++ b/src/python/omnimalloc/__init__.py @@ -20,8 +20,8 @@ from .primitives import System as System from .primitives import TimePoint as TimePoint from .primitives import VectorClock as VectorClock -from .primitives import get_antichain_pressure as get_antichain_pressure from .primitives import get_closure_pressure as get_closure_pressure +from .primitives import get_conflicts as get_conflicts from .primitives import ( get_per_allocation_closure_pressure as get_per_allocation_closure_pressure, ) @@ -29,6 +29,7 @@ get_per_allocation_placement_pressure as get_per_allocation_placement_pressure, ) from .primitives import get_per_allocation_pressure as get_per_allocation_pressure +from .primitives import get_placement_pressure as get_placement_pressure from .primitives import get_pressure as get_pressure from .primitives import try_linearize as try_linearize from .validate import validate_allocation as validate_allocation diff --git a/src/python/omnimalloc/_cpp.pyi b/src/python/omnimalloc/_cpp.pyi index d34e712..f0da989 100644 --- a/src/python/omnimalloc/_cpp.pyi +++ b/src/python/omnimalloc/_cpp.pyi @@ -86,13 +86,15 @@ def try_linearize(allocations: Sequence[Allocation], work_budget: int = 18446744 DEFAULT_WORK_BUDGET: int = 100000000 +DEFAULT_CLOSURE_CAP: int = 16384 + def antichain_pressure(allocations: Sequence[Allocation], work_budget: int = 18446744073709551615) -> int: ... -def closure_pressure(allocations: Sequence[Allocation], closure_cap: int) -> int | None: ... +def closure_pressure(allocations: Sequence[Allocation], closure_cap: int = 16384) -> int | None: ... -def per_allocation_antichain_pressure(allocations: Sequence[Allocation]) -> list[int]: ... +def per_allocation_antichain_pressure(allocations: Sequence[Allocation], work_budget: int = 18446744073709551615) -> list[int]: ... -def per_allocation_closure_pressure(allocations: Sequence[Allocation], closure_cap: int) -> list[int] | None: ... +def per_allocation_closure_pressure(allocations: Sequence[Allocation], closure_cap: int = 16384) -> list[int] | None: ... def per_allocation_placement_pressure(allocations: Sequence[Allocation], clique_cap: bool = False) -> list[int]: ... diff --git a/src/python/omnimalloc/primitives/__init__.py b/src/python/omnimalloc/primitives/__init__.py index 578ea3c..839b32c 100644 --- a/src/python/omnimalloc/primitives/__init__.py +++ b/src/python/omnimalloc/primitives/__init__.py @@ -7,10 +7,10 @@ from .allocation import IdType as IdType from .allocation import TimePoint as TimePoint from .allocation import VectorClock as VectorClock +from .conflicts import get_conflicts as get_conflicts from .linearize import try_linearize as try_linearize from .memory import Memory as Memory from .pool import Pool as Pool -from .pressure import get_antichain_pressure as get_antichain_pressure from .pressure import get_closure_pressure as get_closure_pressure from .pressure import ( get_per_allocation_closure_pressure as get_per_allocation_closure_pressure, @@ -19,5 +19,6 @@ get_per_allocation_placement_pressure as get_per_allocation_placement_pressure, ) from .pressure import get_per_allocation_pressure as get_per_allocation_pressure +from .pressure import get_placement_pressure as get_placement_pressure from .pressure import get_pressure as get_pressure from .system import System as System diff --git a/src/python/omnimalloc/primitives/conflicts.py b/src/python/omnimalloc/primitives/conflicts.py new file mode 100644 index 0000000..39ba89c --- /dev/null +++ b/src/python/omnimalloc/primitives/conflicts.py @@ -0,0 +1,22 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from omnimalloc._cpp import compute_temporal_overlaps + +from .allocation import Allocation, IdType +from .utils import ensure_unique_ids + + +def get_conflicts(allocations: tuple[Allocation, ...]) -> dict[IdType, set[IdType]]: + """Temporal conflict map: each allocation's id to the ids it overlaps in time. + + The relation every placement packs against: conflicting allocations must + occupy disjoint address ranges. Symmetric and total — every allocation is + a key, conflict-free ones map to an empty set. Handles scalar and + vector-clock lifetimes (mutually concurrent clocks conflict). C++ sweep + (allocators/first_fit.cpp). + """ + ensure_unique_ids(allocations) + overlaps = compute_temporal_overlaps(list(allocations)) + return {alloc.id: overlaps.get(alloc.id, set()) for alloc in allocations} diff --git a/src/python/omnimalloc/primitives/pressure.py b/src/python/omnimalloc/primitives/pressure.py index 487eacf..9e0e3b6 100644 --- a/src/python/omnimalloc/primitives/pressure.py +++ b/src/python/omnimalloc/primitives/pressure.py @@ -2,11 +2,12 @@ # SPDX-License-Identifier: Apache-2.0 # -# DEFAULT_WORK_BUDGET caps the implicit pressure query (`Pool.pressure`), -# bounding both the linearize attempt and the antichain flow so it can never -# hang or OOM on huge vector-clock instances; exported from C++ so it cannot -# drift from the OmniAllocator linearize budget. +# DEFAULT_WORK_BUDGET and DEFAULT_CLOSURE_CAP bound the exact queries so +# implicit callers (`Pool.pressure`) can never hang or OOM on huge +# vector-clock instances; both are exported from C++, next to the algorithms +# they tune, so they cannot drift from the OmniAllocator linearize budget. from omnimalloc._cpp import ( + DEFAULT_CLOSURE_CAP, DEFAULT_WORK_BUDGET, antichain_pressure, closure_pressure, @@ -18,33 +19,25 @@ from .allocation import Allocation, IdType from .utils import ensure_unique_ids -DEFAULT_CLOSURE_CAP = 1 << 14 - def get_pressure( - allocations: tuple[Allocation, ...], work_budget: int = DEFAULT_WORK_BUDGET + allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET ) -> int: - """Peak memory pressure (max-weight antichain of the happens-before order). - - Linearizable instances take the O(N log N) sweep — pairwise-overlapping - intervals share a common time point (Helly), so the peak cut attains the - max-weight antichain. Otherwise the exact C++ antichain (weighted Dilworth - via min flow); see `get_antichain_pressure`. Runs under `work_budget` and - raises rather than hang when the exact flow would exceed it; - `get_antichain_pressure` is the unbudgeted query. - """ - return antichain_pressure(list(allocations), work_budget) - - -def get_antichain_pressure(allocations: tuple[Allocation, ...]) -> int: - """Exact max-weight antichain of the happens-before order. + """Peak memory pressure: exact max-weight antichain of the happens-before order. The tightest order-derived lower bound on any placement's peak: pairwise - conflicts force disjoint address ranges. C++ weighted Dilworth (min flow - with per-allocation lower bounds); built to certify allocator optimality - on small and medium instances, not for the 10k+ hot path. + conflicts force disjoint address ranges. Linearizable instances take the + O(N log N) sweep — pairwise-overlapping intervals share a common time + point (Helly), so the peak cut attains the max-weight antichain. Genuinely + partial orders solve the exact C++ antichain (weighted Dilworth via min + flow), built to certify allocator optimality on small and medium + instances, not for the 10k+ hot path. A finite `work_budget` bounds both + phases and raises rather than hang when the flow would exceed it; pass + `None` to always compute the exact answer. """ - return antichain_pressure(list(allocations)) + if work_budget is None: + return antichain_pressure(list(allocations)) + return antichain_pressure(list(allocations), work_budget) def get_closure_pressure( @@ -54,8 +47,8 @@ def get_closure_pressure( C++ enumeration of the join-closure of the birth clocks. Pairwise- concurrent allocations need not share a cut, so this can sit strictly - below `get_antichain_pressure`; both soundly lower-bound any placement's - peak. Raises once the closure exceeds `closure_cap`. + below `get_pressure`; both soundly lower-bound any placement's peak. + Raises once the closure exceeds `closure_cap`. """ peak = closure_pressure(list(allocations), closure_cap) if peak is None: @@ -65,8 +58,25 @@ def get_closure_pressure( return peak +def get_placement_pressure(allocations: tuple[Allocation, ...]) -> int: + """Peak of a placement: the highest occupied address, max(offset + size). + + Simply the pressure the placement realizes after allocation — an upper + bound on `get_pressure` (and so on `get_closure_pressure`), equal to the + max entry of `get_per_allocation_placement_pressure`. Requires placed + allocations. + """ + heights = [] + for alloc in allocations: + height = alloc.height + if height is None: + raise ValueError("Placement pressure requires placed allocations") + heights.append(height) + return max(heights, default=0) + + def get_per_allocation_pressure( - allocations: tuple[Allocation, ...], + allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET ) -> dict[IdType, int]: """Peak pressure over each allocation's own lifetime, keyed by id. @@ -76,10 +86,15 @@ def get_per_allocation_pressure( Linearizable instances take one O(N log N) window sweep, genuinely partial orders solve one pinned antichain (min flow over the conflict neighborhood) per distinct lifetime — exact, but built to certify - placements, not for the 10k+ hot path. + placements, not for the 10k+ hot path. A finite `work_budget` bounds + the linearize attempt and each pinned flow and raises rather than hang; + pass `None` to always compute the exact answer. """ ensure_unique_ids(allocations) - peaks = per_allocation_antichain_pressure(list(allocations)) + if work_budget is None: + peaks = per_allocation_antichain_pressure(list(allocations)) + else: + peaks = per_allocation_antichain_pressure(list(allocations), work_budget) return _keyed_by_id(allocations, peaks) @@ -110,7 +125,7 @@ def get_per_allocation_placement_pressure( Read off assigned offsets: the highest occupied address among each allocation and its conflict neighbors, an upper bound on every exact - per-allocation pressure whose max entry equals the placement's peak. + per-allocation pressure whose max entry equals `get_placement_pressure`. With `clique_cap`, entries are additionally capped by their conflict clique's total size — elementwise tighter, but the max-equals-peak identity no longer holds. Requires placed allocations. diff --git a/tests/integration/test_omni_torture.py b/tests/integration/test_omni_torture.py index 790ddff..ca2c579 100644 --- a/tests/integration/test_omni_torture.py +++ b/tests/integration/test_omni_torture.py @@ -25,7 +25,6 @@ from omnimalloc.benchmark.sources.tiling import TilingSource from omnimalloc.primitives import Allocation, Pool from omnimalloc.primitives.pressure import ( - get_antichain_pressure, get_closure_pressure, get_per_allocation_placement_pressure, get_pressure, @@ -60,7 +59,7 @@ def _certify( allocations: tuple[Allocation, ...], placed: tuple[Allocation, ...] ) -> int: peak = _peak(placed) - assert get_antichain_pressure(allocations) <= peak + assert get_pressure(allocations, work_budget=None) <= peak assert peak <= sum(a.size for a in allocations) assert max(get_per_allocation_placement_pressure(placed).values()) == peak _assert_conflicting_pairs_disjoint(placed) @@ -131,7 +130,7 @@ def test_closure_antichain_peak_chain_on_sync_patterns(pattern: str) -> None: allocations = source.get_allocations() peak = _peak(OmniAllocator().allocate(allocations)) closure = get_closure_pressure(allocations, closure_cap=1 << 18) - antichain = get_antichain_pressure(allocations) + antichain = get_pressure(allocations, work_budget=None) assert closure <= antichain <= peak assert get_pressure(allocations) == antichain @@ -151,7 +150,7 @@ def test_concurrent_tiling_is_certified_near_optimum( allocations = source.get_allocations() placed = OmniAllocator().allocate(allocations) peak = _certify(allocations, placed) - assert get_antichain_pressure(allocations) <= capacity + assert get_pressure(allocations, work_budget=None) <= capacity assert capacity <= peak <= 2 * capacity @@ -231,7 +230,9 @@ def test_lane_permutation_preserves_exact_pressures() -> None: ) for a in allocations ) - assert get_antichain_pressure(permuted) == get_antichain_pressure(allocations) + assert get_pressure(permuted, work_budget=None) == get_pressure( + allocations, work_budget=None + ) assert get_closure_pressure(permuted, closure_cap=1 << 18) == get_closure_pressure( allocations, closure_cap=1 << 18 ) @@ -278,7 +279,7 @@ def test_one_hot_lanes_reach_the_exact_optimum(dim: int) -> None: placed = OmniAllocator().allocate(allocations) peak = _certify(allocations, placed) assert peak == dim * size - assert get_antichain_pressure(allocations) == dim * size + assert get_pressure(allocations, work_budget=None) == dim * size def test_total_order_chain_peak_is_max_size() -> None: @@ -334,7 +335,7 @@ def test_extreme_clock_values_and_sizes_place_validly() -> None: def test_tiled_crowns_are_certified_and_exact() -> None: allocations = _tiled_crowns(50) - assert get_antichain_pressure(allocations) == 80 + assert get_pressure(allocations, work_budget=None) == 80 assert get_pressure(allocations) == 80 placed = OmniAllocator().allocate(allocations) assert _certify(allocations, placed) >= 80 @@ -344,7 +345,7 @@ def test_pressure_budget_raises_but_default_succeeds_on_crowns() -> None: allocations = _tiled_crowns(50) with pytest.raises(RuntimeError, match="work_budget"): get_pressure(allocations, work_budget=1) - assert get_pressure(allocations) == get_antichain_pressure(allocations) + assert get_pressure(allocations) == get_pressure(allocations, work_budget=None) def test_exhaustive_first_fit_orders_bracket_omni_peak() -> None: @@ -358,7 +359,7 @@ def test_exhaustive_first_fit_orders_bracket_omni_peak() -> None: ) omni_peak = _peak(OmniAllocator().allocate(allocations)) closure = get_closure_pressure(allocations) - antichain = get_antichain_pressure(allocations) + antichain = get_pressure(allocations, work_budget=None) assert closure <= antichain <= best <= omni_peak @@ -389,7 +390,7 @@ def test_torture_concurrent_tiling_scale(num_threads: int, num_syncs: int) -> No allocations = source.get_allocations() placed = OmniAllocator().allocate(allocations) peak = _certify(allocations, placed) - assert get_antichain_pressure(allocations) <= capacity + assert get_pressure(allocations, work_budget=None) <= capacity assert capacity <= peak <= 2 * capacity @@ -445,12 +446,14 @@ def test_torture_concurrent_mixed_instances_stay_isolated() -> None: for seed in range(16) ) serial_peaks = [_peak(OmniAllocator().allocate(a)) for a in instances] - serial_pressures = [get_antichain_pressure(a) for a in instances] + serial_pressures = [get_pressure(a, work_budget=None) for a in instances] with ThreadPoolExecutor(max_workers=32) as executor: parallel_placed = list( executor.map(lambda a: OmniAllocator().allocate(a), instances) ) - parallel_pressures = list(executor.map(get_antichain_pressure, instances)) + parallel_pressures = list( + executor.map(lambda a: get_pressure(a, work_budget=None), instances) + ) assert [_peak(p) for p in parallel_placed] == serial_peaks assert parallel_pressures == serial_pressures for allocations, placed in zip(instances, parallel_placed, strict=True): diff --git a/tests/unit/allocators/test_omni.py b/tests/unit/allocators/test_omni.py index 50a2fea..9afdf4f 100644 --- a/tests/unit/allocators/test_omni.py +++ b/tests/unit/allocators/test_omni.py @@ -9,14 +9,10 @@ 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.primitives.pressure import get_pressure +from omnimalloc.primitives.pressure import get_placement_pressure, get_pressure from omnimalloc.validate import validate_allocation -def _peak(allocations: tuple[Allocation, ...]) -> int: - return max(alloc.offset + alloc.size for alloc in allocations) - - def _random_scalar(n: int, seed: int) -> tuple[Allocation, ...]: rng = random.Random(seed) allocations = [] @@ -60,15 +56,15 @@ def test_omni_scalar_placement_is_valid_and_bounded() -> None: allocations = _random_scalar(200, seed=1) placed = OmniAllocator().allocate(allocations) validate_allocation(Pool(id="p", allocations=placed)) - assert get_pressure(allocations) <= _peak(placed) - assert _peak(placed) <= sum(a.size for a in allocations) + assert get_pressure(allocations) <= get_placement_pressure(placed) + assert get_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 _peak(omni) <= _peak(naive) + assert get_placement_pressure(omni) <= get_placement_pressure(naive) def test_omni_preserves_vector_times_and_metadata() -> None: @@ -83,7 +79,7 @@ def test_omni_preserves_vector_times_and_metadata() -> None: def test_omni_non_linearizable_placement_is_valid() -> None: placed = OmniAllocator().allocate(_two_plus_two()) validate_allocation(Pool(id="p", allocations=placed)) - assert _peak(placed) >= 64 + 16 + assert get_placement_pressure(placed) >= 64 + 16 def test_omni_lockstep_matches_scalar_peak() -> None: @@ -92,9 +88,8 @@ def test_omni_lockstep_matches_scalar_peak() -> None: Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) for a in scalar ) - assert _peak(OmniAllocator().allocate(lockstep)) == _peak( - OmniAllocator().allocate(scalar) - ) + lockstep_peak = get_placement_pressure(OmniAllocator().allocate(lockstep)) + assert lockstep_peak == get_placement_pressure(OmniAllocator().allocate(scalar)) def test_omni_is_deterministic() -> None: @@ -111,7 +106,7 @@ def test_omni_ignores_existing_offsets() -> None: for i in range(4) ) placed = OmniAllocator().allocate(allocations) - assert _peak(placed) == 128 + assert get_placement_pressure(placed) == 128 def test_omni_handles_extreme_durations() -> None: @@ -120,7 +115,7 @@ def test_omni_handles_extreme_durations() -> None: ) placed = OmniAllocator().allocate(allocations) validate_allocation(Pool(id="p", allocations=placed)) - assert _peak(placed) == sum(a.size for a in allocations) + assert get_placement_pressure(placed) == sum(a.size for a in allocations) def test_omni_rejects_duplicate_ids() -> None: @@ -149,7 +144,7 @@ def test_omni_concurrent_tiling_stays_near_optimum(num_syncs: int) -> None: ) placed = OmniAllocator().allocate(source.get_allocations()) validate_allocation(Pool(id="p", allocations=placed)) - assert capacity <= _peak(placed) <= 2 * capacity + assert capacity <= get_placement_pressure(placed) <= 2 * capacity @pytest.mark.parametrize("pattern", SYNC_PATTERNS) @@ -161,7 +156,8 @@ def test_omni_torture_across_sync_patterns(pattern: str) -> None: allocations = source.get_allocations() placed = OmniAllocator().allocate(allocations) validate_allocation(Pool(id=f"{pattern}-{seed}", allocations=placed)) - assert _peak(placed) <= _peak(NaiveAllocator().allocate(allocations)) + naive = NaiveAllocator().allocate(allocations) + assert get_placement_pressure(placed) <= get_placement_pressure(naive) def test_omni_torture_across_tiling_variants() -> None: diff --git a/tests/unit/primitives/test_conflicts.py b/tests/unit/primitives/test_conflicts.py new file mode 100644 index 0000000..dc1f1f6 --- /dev/null +++ b/tests/unit/primitives/test_conflicts.py @@ -0,0 +1,94 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from random import Random + +import pytest +from omnimalloc.primitives import Allocation +from omnimalloc.primitives.conflicts import get_conflicts + + +def test_conflicts_empty() -> None: + assert get_conflicts(()) == {} + + +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 get_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 get_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 get_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 get_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"): + get_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"): + get_conflicts(mixed) + + +def _random_instance(rng: Random) -> tuple[Allocation, ...]: + dim = rng.choice((1, 2, 3)) + allocations = [] + for i in range(rng.randint(1, 12)): + start = tuple(rng.randint(0, 5) for _ in range(dim)) + delta = [rng.randint(0, 3) for _ in range(dim)] + if sum(delta) == 0: + delta[rng.randrange(dim)] = 1 + end = tuple(s + x for s, x in zip(start, delta, strict=True)) + if dim == 1: + allocations.append(Allocation(id=i, size=8, start=start[0], end=end[0])) + else: + allocations.append(Allocation(id=i, size=8, start=start, end=end)) + return tuple(allocations) + + +def test_conflicts_match_pairwise_overlaps() -> None: + rng = Random(5) + for _ in range(100): + allocations = _random_instance(rng) + conflicts = get_conflicts(allocations) + for alloc in allocations: + expected = { + other.id + for other in allocations + if other.id != alloc.id and alloc.overlaps_temporally(other) + } + assert conflicts[alloc.id] == expected diff --git a/tests/unit/primitives/test_pressure.py b/tests/unit/primitives/test_pressure.py index a6a5b31..b39ae47 100644 --- a/tests/unit/primitives/test_pressure.py +++ b/tests/unit/primitives/test_pressure.py @@ -9,11 +9,11 @@ from omnimalloc.allocators.omni import OmniAllocator from omnimalloc.primitives import Allocation from omnimalloc.primitives.pressure import ( - get_antichain_pressure, get_closure_pressure, get_per_allocation_closure_pressure, get_per_allocation_placement_pressure, get_per_allocation_pressure, + get_placement_pressure, get_pressure, ) @@ -96,8 +96,8 @@ def test_pressure_total_size_overflow_raises() -> None: get_pressure(allocations) -def test_antichain_pressure_empty_is_zero() -> None: - assert get_antichain_pressure(()) == 0 +def test_pressure_unbudgeted_empty_is_zero() -> None: + assert get_pressure((), work_budget=None) == 0 def test_closure_pressure_empty_is_zero() -> None: @@ -110,28 +110,18 @@ def test_exact_pressures_match_scalar_sweep() -> None: Allocation(id=2, size=50, start=2, end=6), Allocation(id=3, size=25, start=6, end=8), ) - assert get_antichain_pressure(allocations) == 150 + assert get_pressure(allocations, work_budget=None) == 150 assert get_closure_pressure(allocations) == 150 -def test_antichain_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 get_antichain_pressure(allocations) == 150 - - -def test_antichain_pressure_two_plus_two_exact() -> None: +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 get_antichain_pressure(two_plus_two) == 16 + 64 - assert get_pressure(two_plus_two) == 16 + 64 + assert get_pressure(two_plus_two, work_budget=None) == 16 + 64 def test_closure_pressure_below_antichain_without_common_cut() -> None: @@ -140,7 +130,7 @@ def test_closure_pressure_below_antichain_without_common_cut() -> None: Allocation(id="j", size=1, start=(3, 0), end=(4, 1)), Allocation(id="k", size=1, start=(0, 3), end=(1, 4)), ) - assert get_antichain_pressure(pinwheel) == 3 + assert get_pressure(pinwheel, work_budget=None) == 3 assert get_closure_pressure(pinwheel) == 2 @@ -159,7 +149,7 @@ def test_exact_pressures_reject_mixed_dimensions() -> None: Allocation(id=2, size=8, start=(0, 0, 0), end=(1, 1, 1)), ) with pytest.raises(ValueError, match="dimension"): - get_antichain_pressure(mixed) + get_pressure(mixed, work_budget=None) with pytest.raises(ValueError, match="dimension"): get_closure_pressure(mixed) @@ -174,7 +164,7 @@ def test_exact_pressures_match_scalar_equivalent_under_lockstep() -> None: Allocation(id=a.id, size=a.size, start=(a.start, a.start), end=(a.end, a.end)) for a in scalar ) - assert get_antichain_pressure(lockstep) == get_pressure(scalar) + assert get_pressure(lockstep, work_budget=None) == get_pressure(scalar) assert get_closure_pressure(lockstep) == get_pressure(scalar) @@ -207,6 +197,37 @@ def test_per_allocation_pressure_two_plus_two() -> None: assert max(expected.values()) == get_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 get_per_allocation_pressure(allocations, 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"): + get_per_allocation_pressure(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 get_per_allocation_pressure( + two_plus_two, work_budget=None + ) == get_per_allocation_pressure(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)), @@ -258,6 +279,25 @@ def test_per_allocation_placement_pressure_requires_offsets() -> None: get_per_allocation_placement_pressure(unplaced) +def test_placement_pressure_empty_is_zero() -> None: + assert get_placement_pressure(()) == 0 + + +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 get_placement_pressure(placed) == 55 + + +def test_placement_pressure_requires_offsets() -> None: + unplaced = (Allocation(id=1, size=8, start=0, end=2),) + with pytest.raises(ValueError, match="placed"): + get_placement_pressure(unplaced) + + def test_per_allocation_placement_pressure_max_equals_peak() -> None: placed = ( Allocation(id="x", size=5, start=0, end=2, offset=0), @@ -322,14 +362,16 @@ def test_antichain_pressure_matches_brute_force() -> None: rng = Random(7) for _ in range(150): allocations = _random_instance(rng) - assert get_antichain_pressure(allocations) == _brute_antichain(allocations) + assert get_pressure(allocations, work_budget=None) == _brute_antichain( + allocations + ) def test_closure_pressure_matches_brute_force_and_bound_order() -> None: rng = Random(11) for _ in range(150): allocations = _random_instance(rng) - antichain = get_antichain_pressure(allocations) + antichain = get_pressure(allocations, work_budget=None) closure = get_closure_pressure(allocations) assert closure == _brute_closure(allocations) assert closure <= antichain @@ -396,7 +438,7 @@ def test_per_allocation_bound_order_and_peak_identities() -> None: capped = get_per_allocation_placement_pressure(placed, clique_cap=True) assert max(pinned.values()) == get_pressure(allocations) assert max(closure.values()) == get_closure_pressure(allocations) - assert max(placement.values()) == max(a.height for a in placed) + assert max(placement.values()) == get_placement_pressure(placed) for alloc_id in pinned: assert closure[alloc_id] <= pinned[alloc_id] assert pinned[alloc_id] <= capped[alloc_id] <= placement[alloc_id]