From ca776b9f4cee42adcc8bd5be67905e826beda8c7 Mon Sep 17 00:00:00 2001 From: Fabian Peddinghaus Date: Fri, 10 Jul 2026 10:58:02 +0200 Subject: [PATCH] Add vector-clock lifetimes for concurrent allocation problems Lifetimes generalize from scalar interval bounds to vector clocks with one component per thread, so allocation problems from partially-synchronized concurrent executions can be expressed directly. Temporal overlap becomes the happens-before conflict test (neither free happens-before the other's alloc); scalar problems keep the existing sweep and semantics, and 1-tuples normalize to scalars. - Allocation (C++/nanobind) stores TimePoint = int64 | vector, with componentwise validation, span accessors, a scalar fast path in overlaps_temporally, and ValueError on scalar-accessor misuse - compute_temporal_overlaps dispatches to the scalar sweep or the pairwise vector loop; compute_conflict_degrees counts conflicts per allocation (multiplicity-correct under duplicate ids) without marshaling the graph - get_pressure computes the max-weight antichain of the happens-before order via a lower-bounded min flow (weighted Dilworth dual) - try_linearize synthesizes scalar lifetimes with the identical conflict relation when the order is an interval order (no induced 2+2), unlocking scalar-only allocators; offsets and conflicts carry over exactly - allocators declare supports_vector_time; conflict-relation allocators (greedy family, naive, order-search) accept vector clocks, minimalloc and supermalloc reject them with a precise error, and mixed dimensions are a hard error on every path - run_benchmark skips incompatible allocator/source pairs (decided once per variant) and fails with a precise error if a campaign ends up empty - ConcurrentTilingSource shards guillotine tilings across simulated workers with random sync messages; stacked bands keep capacity a provably achievable optimum (num_allocations >= num_threads enforced) - plot_allocation renders one lane per thread, supports memories mixing scalar and vector pools, and fixes the pool background color selection - dump/load round-trip vector times as :-joined components (an omnimalloc extension of the minimalloc CSV format) --- src/cpp/allocators/greedy_base.cpp | 82 +++++- src/cpp/allocators/greedy_base.hpp | 5 + src/cpp/allocators/supermalloc/partition.cpp | 6 + src/cpp/allocators/telamalloc.cpp | 8 + src/cpp/bindings.cpp | 37 ++- src/cpp/primitives/allocation.cpp | 131 ++++++++-- src/cpp/primitives/allocation.hpp | 65 ++++- src/python/omnimalloc/__init__.py | 2 + src/python/omnimalloc/_cpp.pyi | 15 +- src/python/omnimalloc/allocators/base.py | 19 +- src/python/omnimalloc/allocators/best_fit.py | 2 + src/python/omnimalloc/allocators/greedy.py | 2 + .../omnimalloc/allocators/greedy_base.py | 8 + .../omnimalloc/allocators/greedy_cpp.py | 2 + .../omnimalloc/allocators/minimalloc.py | 2 + src/python/omnimalloc/allocators/naive.py | 2 + .../allocators/simulated_annealing.py | 2 + .../omnimalloc/allocators/supermalloc.py | 2 + .../omnimalloc/allocators/tabu_search.py | 2 + .../omnimalloc/allocators/telamalloc.py | 2 + src/python/omnimalloc/benchmark/benchmark.py | 36 ++- .../omnimalloc/benchmark/sources/__init__.py | 1 + .../benchmark/sources/concurrent_tiling.py | 113 +++++++++ .../benchmark/sources/tiling_base.py | 41 ++- src/python/omnimalloc/dump.py | 23 +- src/python/omnimalloc/linearize.py | 59 +++++ src/python/omnimalloc/primitives/__init__.py | 1 + .../omnimalloc/primitives/allocation.py | 7 +- src/python/omnimalloc/primitives/flow.py | 72 ++++++ src/python/omnimalloc/primitives/utils.py | 77 +++++- src/python/omnimalloc/validate.py | 2 + src/python/omnimalloc/visualize.py | 99 +++++--- tests/unit/allocators/test_vector_time.py | 238 ++++++++++++++++++ .../sources/test_concurrent_tiling.py | 146 +++++++++++ tests/unit/benchmark/test_benchmark.py | 44 ++++ .../unit/primitives/test_allocation_vector.py | 138 ++++++++++ tests/unit/primitives/test_flow.py | 135 ++++++++++ tests/unit/primitives/test_utils.py | 158 +++++++++++- tests/unit/test_dump.py | 29 +++ tests/unit/test_linearize.py | 117 +++++++++ tests/unit/test_validate.py | 22 ++ tests/unit/test_visualize.py | 48 ++++ 42 files changed, 1907 insertions(+), 95 deletions(-) create mode 100644 src/python/omnimalloc/benchmark/sources/concurrent_tiling.py create mode 100644 src/python/omnimalloc/linearize.py create mode 100644 src/python/omnimalloc/primitives/flow.py create mode 100644 tests/unit/allocators/test_vector_time.py create mode 100644 tests/unit/benchmark/sources/test_concurrent_tiling.py create mode 100644 tests/unit/primitives/test_allocation_vector.py create mode 100644 tests/unit/primitives/test_flow.py create mode 100644 tests/unit/test_linearize.py diff --git a/src/cpp/allocators/greedy_base.cpp b/src/cpp/allocators/greedy_base.cpp index ded3758..a8fcd58 100644 --- a/src/cpp/allocators/greedy_base.cpp +++ b/src/cpp/allocators/greedy_base.cpp @@ -6,12 +6,18 @@ #include #include +#include +#include +#include #include #include namespace omnimalloc { -TemporalOverlaps compute_temporal_overlaps( +namespace { + +// Sweep line over the single timeline; valid only for all-scalar input. +TemporalOverlaps compute_scalar_overlaps( const std::vector& allocations) { std::vector> events; events.reserve(allocations.size() * 2); @@ -41,6 +47,80 @@ TemporalOverlaps compute_temporal_overlaps( return overlaps; } +// Component spans of all starts and ends, validated to one dimension and +// cached up front to keep the quadratic conflict loops branch-free. +struct ClockSpans { + std::vector> starts; + std::vector> ends; +}; + +ClockSpans cache_clock_spans(const std::vector& allocations) { + ClockSpans spans; + spans.starts.reserve(allocations.size()); + spans.ends.reserve(allocations.size()); + if (allocations.empty()) { + return spans; + } + const size_t dim = allocations.front().dim(); + for (const auto& alloc : allocations) { + if (alloc.dim() != dim) { + throw std::invalid_argument( + "clock dimension mismatch: " + std::to_string(dim) + " vs " + + std::to_string(alloc.dim())); + } + spans.starts.push_back(alloc.start_vec()); + spans.ends.push_back(alloc.end_vec()); + } + return spans; +} + +// Pairwise happens-before conflict test, O(n^2 * T); no linear timeline to +// sweep once lifetimes are vector clocks. Calls `on_conflict(i, j)` with i < j +// for every temporally overlapping pair. +template +void for_each_conflict_pair(const std::vector& allocations, + OnConflict&& on_conflict) { + const auto [starts, ends] = cache_clock_spans(allocations); + for (size_t i = 0; i < allocations.size(); ++i) { + for (size_t j = i + 1; j < allocations.size(); ++j) { + if (!happens_before(ends[i], starts[j]) && + !happens_before(ends[j], starts[i])) { + on_conflict(i, j); + } + } + } +} + +TemporalOverlaps compute_vector_overlaps( + const std::vector& allocations) { + TemporalOverlaps overlaps; + for_each_conflict_pair(allocations, [&](size_t i, size_t j) { + overlaps[allocations[i].id()].insert(allocations[j].id()); + overlaps[allocations[j].id()].insert(allocations[i].id()); + }); + return overlaps; +} + +} // namespace + +TemporalOverlaps compute_temporal_overlaps( + const std::vector& allocations) { + const bool all_scalar = + std::ranges::all_of(allocations, &Allocation::is_scalar_time); + return all_scalar ? compute_scalar_overlaps(allocations) + : compute_vector_overlaps(allocations); +} + +std::vector compute_conflict_degrees( + const std::vector& allocations) { + std::vector degrees(allocations.size(), 0); + for_each_conflict_pair(allocations, [&](size_t i, size_t j) { + ++degrees[i]; + ++degrees[j]; + }); + return degrees; +} + int64_t peak_of(const std::vector& placed) { int64_t peak = 0; for (const auto& alloc : placed) { diff --git a/src/cpp/allocators/greedy_base.hpp b/src/cpp/allocators/greedy_base.hpp index d29c8d0..7a70b45 100644 --- a/src/cpp/allocators/greedy_base.hpp +++ b/src/cpp/allocators/greedy_base.hpp @@ -23,6 +23,11 @@ using TemporalOverlaps = [[nodiscard]] TemporalOverlaps compute_temporal_overlaps( const std::vector& allocations); +// Per-allocation count of temporally overlapping allocations, aligned with +// `allocations`. Counts with multiplicity, so duplicate ids stay distinct. +[[nodiscard]] std::vector compute_conflict_degrees( + const std::vector& allocations); + // Greedily place allocations in order using first-fit, reusing a precomputed // overlap map [[nodiscard]] std::vector first_fit_place( diff --git a/src/cpp/allocators/supermalloc/partition.cpp b/src/cpp/allocators/supermalloc/partition.cpp index 9bcf8a8..fc65829 100644 --- a/src/cpp/allocators/supermalloc/partition.cpp +++ b/src/cpp/allocators/supermalloc/partition.cpp @@ -210,6 +210,12 @@ void Partition::init_search_state() { } Partition Partition::from_allocations(std::vector allocations) { + // The section grid needs a linear timeline; reject vector clocks here. + if (!std::ranges::all_of(allocations, &Allocation::is_scalar_time)) { + throw std::invalid_argument( + "Partition requires scalar time lifetimes; linearize vector clocks " + "first"); + } // Every offset, top, and floor + total the search computes is bounded by // twice the total size, so rejecting sums above INT64_MAX / 2 rules out // signed overflow everywhere downstream. diff --git a/src/cpp/allocators/telamalloc.cpp b/src/cpp/allocators/telamalloc.cpp index 2457ccf..2c719a7 100644 --- a/src/cpp/allocators/telamalloc.cpp +++ b/src/cpp/allocators/telamalloc.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -320,6 +321,13 @@ TelamallocAllocator::TelamallocAllocator(TelamallocConfig config) std::vector TelamallocAllocator::allocate( const std::vector& allocations) const { + // The event sweeps and load bounds need a linear timeline; reject vector + // clocks here. + if (!std::ranges::all_of(allocations, &Allocation::is_scalar_time)) { + throw std::invalid_argument( + "TelamallocAllocator requires scalar time lifetimes; linearize vector " + "clocks first"); + } if (allocations.size() < 2) { return first_fit_place(allocations, compute_temporal_overlaps(allocations)); } diff --git a/src/cpp/bindings.cpp b/src/cpp/bindings.cpp index ef413aa..1c522e6 100644 --- a/src/cpp/bindings.cpp +++ b/src/cpp/bindings.cpp @@ -28,6 +28,19 @@ namespace nb = nanobind; using namespace nb::literals; using namespace omnimalloc; +namespace { + +// nanobind's default caster renders std::vector as list; the Python surface +// wants tuples so scalar/tuple time points stay consistent under == and hash. +nb::object time_to_python(const TimePoint& time) { + if (const auto* scalar = std::get_if(&time)) { + return nb::int_(*scalar); + } + return nb::tuple(nb::cast(std::get>(time))); +} + +} // namespace + NB_MODULE(_cpp, m) { // BufferKind enum nb::enum_(m, "BufferKind") @@ -52,14 +65,22 @@ NB_MODULE(_cpp, m) { // Allocation class nb::class_(m, "Allocation") - .def(nb::init, - std::optional>(), + .def(nb::init, std::optional>(), "id"_a, "size"_a, "start"_a, "end"_a, "offset"_a = nb::none(), "kind"_a = nb::none()) .def_prop_ro("id", &Allocation::id, nb::rv_policy::copy) .def_prop_ro("size", &Allocation::size) - .def_prop_ro("start", &Allocation::start) - .def_prop_ro("end", &Allocation::end) + .def_prop_ro( + "start", + [](const Allocation& a) { return time_to_python(a.start_time()); }, + nb::for_getter( + nb::sig("def start(self, /) -> int | tuple[int, ...]"))) + .def_prop_ro( + "end", + [](const Allocation& a) { return time_to_python(a.end_time()); }, + nb::for_getter(nb::sig("def end(self, /) -> int | tuple[int, ...]"))) + .def_prop_ro("dim", &Allocation::dim) .def_prop_ro("offset", &Allocation::offset, nb::rv_policy::copy) .def_prop_ro("kind", &Allocation::kind, nb::rv_policy::copy) .def_prop_ro("is_allocated", &Allocation::is_allocated) @@ -88,12 +109,12 @@ NB_MODULE(_cpp, m) { .def("__hash__", std::hash{}) .def("__getstate__", [](const Allocation& a) { - return std::make_tuple(a.id(), a.size(), a.start(), a.end(), - a.offset(), a.kind()); + return std::make_tuple(a.id(), a.size(), a.start_time(), + a.end_time(), a.offset(), a.kind()); }) .def("__setstate__", [](Allocation& a, - const std::tuple, std::optional>& state) { new (&a) Allocation(std::get<0>(state), std::get<1>(state), @@ -103,6 +124,8 @@ NB_MODULE(_cpp, m) { m.def("compute_temporal_overlaps", &compute_temporal_overlaps, "allocations"_a, nb::rv_policy::move); + m.def("compute_conflict_degrees", &compute_conflict_degrees, "allocations"_a, + nb::rv_policy::move); m.def("first_fit_place", &first_fit_place, "allocations"_a, "overlaps"_a, nb::rv_policy::move); diff --git a/src/cpp/primitives/allocation.cpp b/src/cpp/primitives/allocation.cpp index 10b4144..cb05fa0 100644 --- a/src/cpp/primitives/allocation.cpp +++ b/src/cpp/primitives/allocation.cpp @@ -4,38 +4,108 @@ #include "allocation.hpp" +#include +#include #include +#include #include +#include +#include #include "hash_utils.hpp" namespace omnimalloc { -Allocation::Allocation(IdType id, int64_t size, int64_t start, int64_t end, +namespace { + +TimePoint normalized(TimePoint time) { + if (const auto* vec = std::get_if>(&time); + vec != nullptr && vec->size() == 1) { + return vec->front(); + } + return time; +} + +std::string to_string(const TimePoint& time) { + if (const auto* value = std::get_if(&time)) { + return std::to_string(*value); + } + std::ostringstream ss; + ss << '('; + const char* separator = ""; + for (int64_t component : std::get>(time)) { + ss << separator << component; + separator = ", "; + } + ss << ')'; + return ss.str(); +} + +} // namespace + +Allocation::Allocation(IdType id, int64_t size, TimePoint start, TimePoint end, std::optional offset, std::optional kind) : id_(std::move(id)), size_(size), - start_(start), - end_(end), + start_(normalized(std::move(start))), + end_(normalized(std::move(end))), offset_(offset), kind_(kind) { validate(); } +void Allocation::throw_not_scalar(const TimePoint& time) { + throw std::invalid_argument("scalar time accessor called on vector-time " + + to_string(time)); +} + +std::span Allocation::components( + const TimePoint& time) noexcept { + if (const auto* value = std::get_if(&time)) { + return {value, 1}; + } + return std::get>(time); +} + void Allocation::validate() const { if (size_ <= 0) { throw std::invalid_argument("size must be positive, got " + std::to_string(size_)); } - if (start_ < 0) { - throw std::invalid_argument("start must be non-negative, got " + - std::to_string(start_)); + if (dim() != end_vec().size()) { + throw std::invalid_argument("start " + to_string(start_) + " and end " + + to_string(end_) + + " must share one clock dimension"); + } + if (dim() == 0) { + throw std::invalid_argument("time points must have at least one component"); } - if (end_ <= start_) { - throw std::invalid_argument("end (" + std::to_string(end_) + - ") must be > start (" + std::to_string(start_) + - ")"); + if (is_scalar_time()) { + if (start() < 0) { + throw std::invalid_argument("start must be non-negative, got " + + std::to_string(start())); + } + if (end() <= start()) { + throw std::invalid_argument("end (" + std::to_string(end()) + + ") must be > start (" + + std::to_string(start()) + ")"); + } + } else { + if (std::ranges::any_of(start_vec(), [](int64_t c) { return c < 0; })) { + throw std::invalid_argument( + "start must be non-negative componentwise, got " + to_string(start_)); + } + if (!happens_before(start_vec(), end_vec())) { + throw std::invalid_argument("end " + to_string(end_) + + " must be >= start " + to_string(start_) + + " componentwise"); + } + if (start_ == end_) { + throw std::invalid_argument("end " + to_string(end_) + + " must be > start " + to_string(start_) + + " on at least one component"); + } } if (offset_.has_value() && offset_.value() < 0) { throw std::invalid_argument("offset must be non-negative, got " + @@ -43,8 +113,29 @@ void Allocation::validate() const { } } -bool Allocation::overlaps_temporally(const Allocation& other) const noexcept { - return start_ < other.end_ && other.start_ < end_; +int64_t Allocation::vector_duration() const noexcept { + const auto start = start_vec(); + const auto end = end_vec(); + int64_t longest = 0; + for (size_t i = 0; i < start.size(); ++i) { + longest = std::max(longest, end[i] - start[i]); + } + return longest; +} + +bool Allocation::overlaps_temporally(const Allocation& other) const { + // Fast path: plain interval test, no variant probing in the O(n^2) callers + if (is_scalar_time() && other.is_scalar_time()) { + return std::get(start_) < std::get(other.end_) && + std::get(other.start_) < std::get(end_); + } + if (dim() != other.dim()) { + throw std::invalid_argument( + "clock dimension mismatch: " + std::to_string(dim()) + " vs " + + std::to_string(other.dim())); + } + return !happens_before(end_vec(), other.start_vec()) && + !happens_before(other.end_vec(), start_vec()); } bool Allocation::overlaps_spatially(const Allocation& other) const noexcept { @@ -53,7 +144,7 @@ bool Allocation::overlaps_spatially(const Allocation& other) const noexcept { other.offset_.value() < offset_.value() + size_; } -bool Allocation::overlaps(const Allocation& other) const noexcept { +bool Allocation::overlaps(const Allocation& other) const { return overlaps_temporally(other) && overlaps_spatially(other); } @@ -68,7 +159,8 @@ Allocation Allocation::with_kind(BufferKind new_kind) const { std::ostream& operator<<(std::ostream& os, const Allocation& a) { os << "Allocation(id="; std::visit([&os](const auto& value) { os << value; }, a.id_); - os << ", size=" << a.size_ << ", start=" << a.start_ << ", end=" << a.end_; + os << ", size=" << a.size_ << ", start=" << to_string(a.start_) + << ", end=" << to_string(a.end_); if (a.offset_.has_value()) { os << ", offset=" << a.offset_.value(); } @@ -84,12 +176,19 @@ namespace std { size_t hash::operator()( const omnimalloc::Allocation& a) const noexcept { + const auto hash_time = [](std::span components) { + size_t seed = components.size(); + for (int64_t component : components) { + seed = omnimalloc::make_hash(seed, component); + } + return seed; + }; const size_t id_hash = omnimalloc::IdTypeHash{}(a.id()); const int64_t offset_val = a.offset().value_or(-1); const int kind_val = a.kind().has_value() ? static_cast(a.kind().value()) : -1; - return omnimalloc::make_hash(id_hash, a.size(), a.start(), a.end(), - offset_val, kind_val); + return omnimalloc::make_hash(id_hash, a.size(), hash_time(a.start_vec()), + hash_time(a.end_vec()), offset_val, kind_val); } } // namespace std diff --git a/src/cpp/primitives/allocation.hpp b/src/cpp/primitives/allocation.hpp index a6119ae..9cedd4b 100644 --- a/src/cpp/primitives/allocation.hpp +++ b/src/cpp/primitives/allocation.hpp @@ -4,32 +4,67 @@ #pragma once +#include #include +#include #include #include +#include +#include +#include #include "buffer_kind.hpp" #include "id_type.hpp" namespace omnimalloc { +// A point in time: a scalar step on one global timeline, or a vector clock +// with one component per thread. 1-element vectors normalize to scalars. +// Must match TimePoint in src/python/omnimalloc/primitives/allocation.py +using TimePoint = std::variant>; + +// Componentwise `end <= start`: the free at `end` happens-before (or +// coincides with) the alloc at `start`. Spans must have equal dimension. +// Inline: called per pair in the O(n^2) vector-clock overlap loop. +[[nodiscard]] inline bool happens_before( + std::span end, std::span start) noexcept { + return std::ranges::equal(end, start, std::less_equal{}); +} + class Allocation { public: - Allocation(IdType id, int64_t size, int64_t start, int64_t end, + Allocation(IdType id, int64_t size, TimePoint start, TimePoint end, std::optional offset = std::nullopt, std::optional kind = std::nullopt); // Accessors const IdType& id() const noexcept { return id_; } int64_t size() const noexcept { return size_; } - int64_t start() const noexcept { return start_; } - int64_t end() const noexcept { return end_; } + int64_t start() const { return scalar(start_); } // throws on vector time + int64_t end() const { return scalar(end_); } // throws on vector time + const TimePoint& start_time() const noexcept { return start_; } + const TimePoint& end_time() const noexcept { return end_; } + std::span start_vec() const noexcept { + return components(start_); + } + std::span end_vec() const noexcept { return components(end_); } const std::optional& offset() const noexcept { return offset_; } const std::optional& kind() const noexcept { return kind_; } // Computed properties bool is_allocated() const noexcept { return offset_.has_value(); } - int64_t duration() const noexcept { return end_ - start_; } + bool is_scalar_time() const noexcept { + return std::holds_alternative(start_); + } + size_t dim() const noexcept { return start_vec().size(); } + // L-inf: largest per-thread extent. Inline scalar fast path: called per + // allocation per node in the supermalloc branch-and-bound heuristics. + int64_t duration() const noexcept { + if (is_scalar_time()) { + return std::get(end_) - std::get(start_); + } + return vector_duration(); + } int64_t area() const noexcept { return duration() * size_; } std::optional height() const noexcept { @@ -39,10 +74,11 @@ class Allocation { return std::nullopt; } - // Overlap detection - bool overlaps_temporally(const Allocation& other) const noexcept; + // Overlap detection. Temporal overlap is the happens-before conflict test: + // neither free happens-before the other's alloc. Dimension mismatch throws. + bool overlaps_temporally(const Allocation& other) const; bool overlaps_spatially(const Allocation& other) const noexcept; - bool overlaps(const Allocation& other) const noexcept; + bool overlaps(const Allocation& other) const; // Transformations Allocation with_offset(int64_t new_offset) const; @@ -57,11 +93,22 @@ class Allocation { private: IdType id_; int64_t size_; - int64_t start_; - int64_t end_; + TimePoint start_; + TimePoint end_; std::optional offset_; std::optional kind_; + // Inline fast path for the scalar accessors; the throw stays out-of-line + static int64_t scalar(const TimePoint& time) { + const auto* value = std::get_if(&time); + if (value == nullptr) { + throw_not_scalar(time); + } + return *value; + } + [[noreturn]] static void throw_not_scalar(const TimePoint& time); + static std::span components(const TimePoint& time) noexcept; + int64_t vector_duration() const noexcept; void validate() const; }; diff --git a/src/python/omnimalloc/__init__.py b/src/python/omnimalloc/__init__.py index 6e2e4ce..af0e4f8 100644 --- a/src/python/omnimalloc/__init__.py +++ b/src/python/omnimalloc/__init__.py @@ -11,11 +11,13 @@ from .allocators import get_default_allocator as get_default_allocator from .dump import dump_allocation as dump_allocation from .dump import load_allocation as load_allocation +from .linearize import try_linearize as try_linearize from .primitives import Allocation as Allocation from .primitives import BufferKind as BufferKind from .primitives import IdType as IdType from .primitives import Memory as Memory from .primitives import Pool as Pool from .primitives import System as System +from .primitives import TimePoint as TimePoint from .validate import validate_allocation as validate_allocation from .visualize import plot_allocation as plot_allocation diff --git a/src/python/omnimalloc/_cpp.pyi b/src/python/omnimalloc/_cpp.pyi index e8c8fb3..9927fe8 100644 --- a/src/python/omnimalloc/_cpp.pyi +++ b/src/python/omnimalloc/_cpp.pyi @@ -21,7 +21,7 @@ class BufferKind(enum.Enum): def __hash__(self) -> int: ... class Allocation: - def __init__(self, id: int | str, size: int, start: int, end: int, offset: int | None = None, kind: BufferKind | None = None) -> None: ... + def __init__(self, id: int | str, size: int, start: int | Sequence[int], end: int | Sequence[int], offset: int | None = None, kind: BufferKind | None = None) -> None: ... @property def id(self) -> int | str: ... @@ -30,10 +30,13 @@ class Allocation: def size(self) -> int: ... @property - def start(self) -> int: ... + def start(self, /) -> int | tuple[int, ...]: ... @property - def end(self) -> int: ... + def end(self, /) -> int | tuple[int, ...]: ... + + @property + def dim(self) -> int: ... @property def offset(self) -> int | None: ... @@ -71,12 +74,14 @@ class Allocation: def __hash__(self) -> int: ... - def __getstate__(self) -> tuple[int | str, int, int, int, int | None, BufferKind | None]: ... + def __getstate__(self) -> tuple[int | str, int, int | list[int], int | list[int], int | None, BufferKind | None]: ... - def __setstate__(self, arg: tuple[int | str, int, int, int, int | None, BufferKind | None], /) -> None: ... + def __setstate__(self, arg: tuple[int | str, int, int | Sequence[int], int | Sequence[int], int | None, BufferKind | None], /) -> None: ... def compute_temporal_overlaps(allocations: Sequence[Allocation]) -> dict[int | str, set[int | str]]: ... +def compute_conflict_degrees(allocations: Sequence[Allocation]) -> list[int]: ... + def first_fit_place(allocations: Sequence[Allocation], overlaps: Mapping[int | str, Set[int | str]]) -> list[Allocation]: ... class FirstFitPlacer: diff --git a/src/python/omnimalloc/allocators/base.py b/src/python/omnimalloc/allocators/base.py index 0d12ec9..0a5871d 100644 --- a/src/python/omnimalloc/allocators/base.py +++ b/src/python/omnimalloc/allocators/base.py @@ -3,7 +3,7 @@ # from abc import abstractmethod -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, ClassVar, Final from omnimalloc.common.registry import Registered @@ -26,6 +26,10 @@ def require_unique_ids(allocations: tuple["Allocation", ...]) -> None: class BaseAllocator(Registered): """Base class for allocators with automatic registry.""" + # True for allocators that consume only the pairwise conflict relation and + # thus accept vector-clock lifetimes. + supports_vector_time: ClassVar[bool] = False + @abstractmethod def allocate( self, allocations: tuple["Allocation", ...] @@ -36,3 +40,16 @@ def allocate( def reset(self) -> None: """Optional: reset allocator state/config. Override if needed.""" ... + + def supports(self, allocations: tuple["Allocation", ...]) -> bool: + """Whether this allocator accepts the allocations' clock dimensions.""" + return self.supports_vector_time or all(alloc.dim == 1 for alloc in allocations) + + def _check_dims(self, allocations: tuple["Allocation", ...]) -> None: + """Reject vector-clock lifetimes if this allocator needs scalar time.""" + if not self.supports(allocations): + max_dim = max(alloc.dim for alloc in allocations) + raise ValueError( + f"{self.name()} requires scalar (interval) lifetimes, " + f"got {max_dim}-dim vector clocks" + ) diff --git a/src/python/omnimalloc/allocators/best_fit.py b/src/python/omnimalloc/allocators/best_fit.py index 64fbee5..0613c2f 100644 --- a/src/python/omnimalloc/allocators/best_fit.py +++ b/src/python/omnimalloc/allocators/best_fit.py @@ -19,6 +19,8 @@ class BestFitAllocator(BaseAllocator): useful gaps free for later, bigger allocations. """ + supports_vector_time = True + @cached_property def _cpp_allocator(self) -> _BestFitAllocatorCpp: return _BestFitAllocatorCpp() diff --git a/src/python/omnimalloc/allocators/greedy.py b/src/python/omnimalloc/allocators/greedy.py index 202f0b8..a4a0d1f 100644 --- a/src/python/omnimalloc/allocators/greedy.py +++ b/src/python/omnimalloc/allocators/greedy.py @@ -20,6 +20,8 @@ class GreedyAllocator(BaseAllocator): """Base greedy allocator using first-fit strategy.""" + supports_vector_time = True + def allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: return tuple( first_fit_place(allocations, compute_temporal_overlaps(allocations)) diff --git a/src/python/omnimalloc/allocators/greedy_base.py b/src/python/omnimalloc/allocators/greedy_base.py index 44997d6..ab26d97 100644 --- a/src/python/omnimalloc/allocators/greedy_base.py +++ b/src/python/omnimalloc/allocators/greedy_base.py @@ -6,7 +6,9 @@ from bisect import bisect_left, bisect_right from concurrent.futures import ProcessPoolExecutor +from omnimalloc._cpp import compute_conflict_degrees from omnimalloc.primitives import Allocation +from omnimalloc.primitives.utils import uniform_dim from .base import BaseAllocator @@ -18,6 +20,11 @@ def peak_memory(allocations: tuple[Allocation, ...]) -> int: def compute_conflicts(allocations: tuple[Allocation, ...]) -> dict[Allocation, int]: """Count temporally overlapping allocations for each allocation.""" + if uniform_dim(allocations) > 1: + # No linear timeline to bisect over; count conflicts pairwise (with + # multiplicity, matching the scalar sweep even under duplicate ids) + degrees = compute_conflict_degrees(allocations) + return dict(zip(allocations, degrees, strict=True)) starts = sorted(alloc.start for alloc in allocations) ends = sorted(alloc.end for alloc in allocations) # An allocation overlaps [start, end) iff it starts before `end` and does @@ -63,6 +70,7 @@ def order_by_conflict_size( def order_by_start(allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: """Order by start time (earliest first, largest ties first).""" + uniform_dim(allocations) # mixed scalar/tuple starts do not compare return tuple(sorted(allocations, key=lambda a: (a.start, -a.size))) diff --git a/src/python/omnimalloc/allocators/greedy_cpp.py b/src/python/omnimalloc/allocators/greedy_cpp.py index 28f1839..5900b16 100644 --- a/src/python/omnimalloc/allocators/greedy_cpp.py +++ b/src/python/omnimalloc/allocators/greedy_cpp.py @@ -22,6 +22,8 @@ class GreedyAllocatorCpp(BaseAllocator): """C++ implementation of the base greedy allocator using first-fit strategy.""" + supports_vector_time = True + @cached_property def _cpp_allocator(self) -> _GreedyAllocatorCpp: return _GreedyAllocatorCpp() diff --git a/src/python/omnimalloc/allocators/minimalloc.py b/src/python/omnimalloc/allocators/minimalloc.py index 93868cc..cd43652 100644 --- a/src/python/omnimalloc/allocators/minimalloc.py +++ b/src/python/omnimalloc/allocators/minimalloc.py @@ -56,6 +56,8 @@ def allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ... if not allocations: return allocations + # mm.Lifespan is inherently an interval on one timeline + self._check_dims(allocations) require_unique_ids(allocations) problem = mm.Problem(buffers=[_to_buffer(a) for a in allocations]) diff --git a/src/python/omnimalloc/allocators/naive.py b/src/python/omnimalloc/allocators/naive.py index c377308..ff77d6f 100644 --- a/src/python/omnimalloc/allocators/naive.py +++ b/src/python/omnimalloc/allocators/naive.py @@ -10,6 +10,8 @@ class NaiveAllocator(BaseAllocator): """Naive allocator that places allocations sequentially.""" + supports_vector_time = True + def allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: placed_allocations: list[Allocation] = [] current_offset = 0 diff --git a/src/python/omnimalloc/allocators/simulated_annealing.py b/src/python/omnimalloc/allocators/simulated_annealing.py index 4d595c6..c57461c 100644 --- a/src/python/omnimalloc/allocators/simulated_annealing.py +++ b/src/python/omnimalloc/allocators/simulated_annealing.py @@ -63,6 +63,8 @@ class SimulatedAnnealingAllocator(BaseAllocator): as the input grows, independent of `max_iterations`. """ + supports_vector_time = True + def __init__(self, config: SimulatedAnnealingConfig | None = None) -> None: self._config = config or SimulatedAnnealingConfig() diff --git a/src/python/omnimalloc/allocators/supermalloc.py b/src/python/omnimalloc/allocators/supermalloc.py index e6d6565..1156f43 100644 --- a/src/python/omnimalloc/allocators/supermalloc.py +++ b/src/python/omnimalloc/allocators/supermalloc.py @@ -148,6 +148,8 @@ def allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ... if not allocations: return allocations + # The partition solver's section grid needs a linear timeline + self._check_dims(allocations) deadline = time.monotonic() + self._config.timeout threads = self._config.num_threads() base = Partition.from_allocations(allocations) diff --git a/src/python/omnimalloc/allocators/tabu_search.py b/src/python/omnimalloc/allocators/tabu_search.py index 51bd50a..6760b44 100644 --- a/src/python/omnimalloc/allocators/tabu_search.py +++ b/src/python/omnimalloc/allocators/tabu_search.py @@ -61,6 +61,8 @@ class TabuSearchAllocator(BaseAllocator): wall-clock time as the input grows, independent of `max_iterations`. """ + supports_vector_time = True + def __init__(self, config: TabuSearchConfig | None = None) -> None: self._config = config or TabuSearchConfig() diff --git a/src/python/omnimalloc/allocators/telamalloc.py b/src/python/omnimalloc/allocators/telamalloc.py index 1fc8f9e..e3fe17e 100644 --- a/src/python/omnimalloc/allocators/telamalloc.py +++ b/src/python/omnimalloc/allocators/telamalloc.py @@ -62,5 +62,7 @@ def __init__(self, config: TelamallocConfig | None = None) -> None: def allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: if not allocations: return allocations + # The phase decomposition and load bounds sweep a linear timeline + self._check_dims(allocations) cpp_allocator = _TelamallocAllocatorCpp(self._config.to_cpp_config()) return tuple(cpp_allocator.allocate(list(allocations))) diff --git a/src/python/omnimalloc/benchmark/benchmark.py b/src/python/omnimalloc/benchmark/benchmark.py index ac3597f..9d881fa 100644 --- a/src/python/omnimalloc/benchmark/benchmark.py +++ b/src/python/omnimalloc/benchmark/benchmark.py @@ -7,7 +7,7 @@ from omnimalloc import run_allocation, validate_allocation from omnimalloc.allocators import BaseAllocator, get_available_allocators -from omnimalloc.primitives import IdType +from omnimalloc.primitives import IdType, Pool from .results import BenchmarkCampaign, BenchmarkReport, BenchmarkResult from .results.utils import get_date_time_snake_case @@ -84,14 +84,10 @@ def _get_variant_ids( def _benchmark_result( allocator: BaseAllocator, source: BaseSource, - variant_id: IdType, + pool: Pool, result_id: IdType, validate: bool, ) -> BenchmarkResult: - pool = source.get_variant(variant_id) - if pool is None: - raise ValueError(f"source {source.name()} returned no pool") - with Timer() as timer: allocated_pool = run_allocation(pool, allocator, validate=False) @@ -115,16 +111,30 @@ def _benchmark_report( report_id: int, result_id: int, validate: bool, -) -> BenchmarkReport: - results = [] +) -> BenchmarkReport | None: variant_desc = variant_id if isinstance(variant_id, str) else f"{variant_id} allocs" + + # Skip incompatible allocator/problem pairs instead of crashing the + # campaign; support is invariant per (allocator, variant), so decide once + # before iterating. + pool = source.get_variant(variant_id) + if pool is None: + raise ValueError(f"source {source.name()} returned no pool") + if not allocator.supports(pool.allocations): + logger.warning( + f"Skipping {allocator.name()} on {source.name()}[{variant_desc}]: " + "requires scalar (interval) lifetimes" + ) + return None + + results = [] for _ in tqdm( range(iterations), desc=f"Iterations [{variant_desc}]", position=3, leave=False, ): - result = _benchmark_result(allocator, source, variant_id, result_id, validate) + result = _benchmark_result(allocator, source, pool, result_id, validate) results.append(result) result_id += 1 @@ -209,12 +219,20 @@ def run_benchmark( result_id, validate, ) + if report is None: + continue reports.append(report) report_id += 1 result_id += iterations timer.stop() + if not reports: + raise ValueError( + "No benchmark reports produced; every allocator/source/variant " + "combination was skipped or empty" + ) + campaign = BenchmarkCampaign( id=campaign_id, reports=tuple(reports), diff --git a/src/python/omnimalloc/benchmark/sources/__init__.py b/src/python/omnimalloc/benchmark/sources/__init__.py index 766406a..108bc9c 100644 --- a/src/python/omnimalloc/benchmark/sources/__init__.py +++ b/src/python/omnimalloc/benchmark/sources/__init__.py @@ -3,6 +3,7 @@ # from .base import BaseSource as BaseSource +from .concurrent_tiling import ConcurrentTilingSource as ConcurrentTilingSource from .generator import HighContentionSource as HighContentionSource from .generator import PowerOf2Source as PowerOf2Source from .generator import RandomSource as RandomSource diff --git a/src/python/omnimalloc/benchmark/sources/concurrent_tiling.py b/src/python/omnimalloc/benchmark/sources/concurrent_tiling.py new file mode 100644 index 0000000..1cad821 --- /dev/null +++ b/src/python/omnimalloc/benchmark/sources/concurrent_tiling.py @@ -0,0 +1,113 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import random +from bisect import bisect_right + +from .tiling import TilingSource +from .tiling_base import _PlacedTile + +# Per-thread receive history: parallel (local times, merged clock snapshots) +_SyncHistory = tuple[list[int], list[tuple[int, ...]]] + + +class ConcurrentTilingSource(TilingSource): + """Guillotine tilings sharded across partially-synchronized workers. + + Each of ``num_threads`` workers tiles its own ``capacity / num_threads`` + memory band over a private local timeline, so lifetimes become vector + clocks of a simulated execution with ``num_syncs`` random cross-thread + sync messages (send/receive max-merge). Stacking the bands packs the whole + problem into ``capacity``, which therefore stays a provably achievable + optimum regardless of the sync rate. Sweeping ``num_syncs`` interpolates + between ``num_threads`` independent problems (0) and one near-lockstep + scalar problem (dense). + """ + + def __init__( + self, + num_allocations: int = 128, + num_threads: int = 2, + num_syncs: int = 16, + capacity: int = 1024 * 1024, + makespan: int = 1024 * 1024, + min_size: int = 1024, + min_duration: int = 1, + mem_cut_prob: float = 0.5, + seed: int | None = 42, + ) -> None: + if num_threads <= 0: + raise ValueError("num_threads must be positive") + if num_allocations < num_threads: + raise ValueError("num_allocations must be >= num_threads") + if capacity % num_threads: + raise ValueError("capacity must be divisible by num_threads") + if capacity // num_threads < min_size: + raise ValueError("per-thread capacity must be >= min_size") + if not 0 <= num_syncs < makespan: + raise ValueError("num_syncs must be in [0, makespan)") + super().__init__( + num_allocations, + capacity, + makespan, + min_size, + min_duration, + mem_cut_prob, + seed, + ) + self.num_threads = num_threads + self.num_syncs = num_syncs + + def _placed_tiles(self, num: int, skip: int) -> list[_PlacedTile]: + if num < self.num_threads: + # An empty band would break the capacity-is-optimal guarantee + raise ValueError("num_allocations must be >= num_threads") + rng = self._variant_rng(skip) + histories = self._simulate_syncs(rng) + band_capacity = self.capacity // self.num_threads + + placed: list[_PlacedTile] = [] + for thread in range(self.num_threads): + count = num // self.num_threads + if thread < num % self.num_threads: + count += 1 + placed.extend( + _PlacedTile( + size=tile.size, + start=self._project(histories[thread], thread, tile.start), + end=self._project(histories[thread], thread, tile.end), + offset=tile.offset + thread * band_capacity, + ) + for tile in self._build_tiles(count, rng, capacity=band_capacity) + ) + return placed + + def _simulate_syncs(self, rng: random.Random) -> list[_SyncHistory]: + """Deliver random sync messages, max-merging the receiver's clock.""" + knowledge = [[0] * self.num_threads for _ in range(self.num_threads)] + histories: list[_SyncHistory] = [([], []) for _ in range(self.num_threads)] + if self.num_threads < 2 or not self.num_syncs: + return histories + # All workers share the local step scale, so delivering messages in + # instant order is a causally consistent execution. + for instant in sorted(rng.sample(range(1, self.makespan), self.num_syncs)): + sender, receiver = rng.sample(range(self.num_threads), 2) + knowledge[sender][sender] = instant + merged = knowledge[receiver] + for component, value in enumerate(knowledge[sender]): + merged[component] = max(merged[component], value) + times, snapshots = histories[receiver] + times.append(instant) + snapshots.append(tuple(merged)) + return histories + + def _project( + self, history: _SyncHistory, thread: int, local_time: int + ) -> tuple[int, ...]: + """Vector clock of `thread` at `local_time`: own step + last receive.""" + times, snapshots = history + index = bisect_right(times, local_time) - 1 + clock = list(snapshots[index]) if index >= 0 else [0] * self.num_threads + clock[thread] = local_time + return tuple(clock) diff --git a/src/python/omnimalloc/benchmark/sources/tiling_base.py b/src/python/omnimalloc/benchmark/sources/tiling_base.py index 74e0b27..ee76476 100644 --- a/src/python/omnimalloc/benchmark/sources/tiling_base.py +++ b/src/python/omnimalloc/benchmark/sources/tiling_base.py @@ -5,8 +5,9 @@ import random from abc import abstractmethod from dataclasses import dataclass +from typing import NamedTuple -from omnimalloc.primitives import Allocation, Pool +from omnimalloc.primitives import Allocation, Pool, TimePoint from .base import BaseSource @@ -25,6 +26,15 @@ class _Tile: size: int +class _PlacedTile(NamedTuple): + """A tile ready to become an Allocation, with its ground-truth offset.""" + + size: int + start: TimePoint + end: TimePoint + offset: int + + class TilingBase(BaseSource): """Reverse-constructed packing problems with a known, tight optimum. @@ -64,10 +74,12 @@ def _can_split(self, tile: _Tile) -> bool: ... def _split(self, tile: _Tile, rng: random.Random) -> list[_Tile]: """Split a tile into children that exactly tile it.""" - def _build_tiles(self, num: int, rng: random.Random) -> list[_Tile]: + def _build_tiles( + self, num: int, rng: random.Random, capacity: int | None = None + ) -> list[_Tile]: if num <= 0: return [] - tiles = [_Tile(0, self.makespan, 0, self.capacity)] + tiles = [_Tile(0, self.makespan, 0, capacity or self.capacity)] while len(tiles) < num: splittable = [i for i, t in enumerate(tiles) if self._can_split(t)] if not splittable: @@ -81,21 +93,30 @@ def _build_tiles(self, num: int, rng: random.Random) -> list[_Tile]: tiles[idx : idx + 1] = self._split(tiles[idx], rng) return tiles + def _variant_rng(self, skip: int) -> random.Random: + """Deterministic per-variant stream: same seed and skip, same problem.""" + return random.Random(None if self.seed is None else self.seed + skip) + + def _placed_tiles(self, num: int, skip: int) -> list[_PlacedTile]: + """One placed tile per allocation to generate; the subclass hook.""" + return [ + _PlacedTile(size=t.size, start=t.start, end=t.end, offset=t.offset) + for t in self._build_tiles(num, self._variant_rng(skip)) + ] + def _tile_allocations( self, num_allocations: int | None, skip: int, with_offsets: bool ) -> tuple[Allocation, ...]: num = num_allocations if num_allocations is not None else self.num_allocations - seed = None if self.seed is None else self.seed + skip - tiles = self._build_tiles(num, random.Random(seed)) return tuple( Allocation( id=skip + i, - size=t.size, - start=t.start, - end=t.end, - offset=t.offset if with_offsets else None, + size=tile.size, + start=tile.start, + end=tile.end, + offset=tile.offset if with_offsets else None, ) - for i, t in enumerate(tiles) + for i, tile in enumerate(self._placed_tiles(num, skip)) ) def get_allocations( diff --git a/src/python/omnimalloc/dump.py b/src/python/omnimalloc/dump.py index d8b9cfc..9399ea1 100644 --- a/src/python/omnimalloc/dump.py +++ b/src/python/omnimalloc/dump.py @@ -5,11 +5,22 @@ import csv from pathlib import Path -from .primitives import Allocation, BufferKind, Memory, Pool, System +from .primitives import Allocation, BufferKind, Memory, Pool, System, TimePoint +from .primitives.utils import time_components _FIELDS = ("id", "lower", "upper", "size") +def _format_time(time_point: TimePoint) -> str: + return ":".join(str(component) for component in time_components(time_point)) + + +def _parse_time(text: str) -> TimePoint: + if ":" in text: + return tuple(int(component) for component in text.split(":")) + return int(text) + + def _collect_pools(entity: System | Memory | Pool) -> dict[str, Pool]: if isinstance(entity, Pool): return {str(entity.id): entity} @@ -32,7 +43,8 @@ def _write_pool(pool: Pool, file_path: Path) -> Path: writer = csv.writer(csvfile) writer.writerow(_FIELDS) for alloc in pool.allocations: - writer.writerow([alloc.id, alloc.start, alloc.end, alloc.size]) + lower, upper = _format_time(alloc.start), _format_time(alloc.end) + writer.writerow([alloc.id, lower, upper, alloc.size]) return file_path @@ -42,6 +54,9 @@ def dump_allocation( """Dump the entity's pools to disk as minimalloc-format CSV files. Path's stem is used as prefix, ie. ``_.csv`` per pool. + Vector-clock lifetimes use an omnimalloc extension (``:``-joined + components, e.g. ``3:0``); such files round-trip through + ``load_allocation`` but are no longer minimalloc-readable. """ path_ = Path(path) path_.parent.mkdir(parents=True, exist_ok=True) @@ -60,8 +75,8 @@ def load_allocation(file_path: str | Path) -> Pool: allocation = Allocation( id=str(row["id"]), size=int(row["size"]), - start=int(row["lower"]), - end=int(row["upper"]), + start=_parse_time(row["lower"]), + end=_parse_time(row["upper"]), offset=int(row["offset"]) if row.get("offset") else None, kind=BufferKind.WORKSPACE, ) diff --git a/src/python/omnimalloc/linearize.py b/src/python/omnimalloc/linearize.py new file mode 100644 index 0000000..3a970f7 --- /dev/null +++ b/src/python/omnimalloc/linearize.py @@ -0,0 +1,59 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from itertools import pairwise + +from .primitives import Allocation +from .primitives.utils import happens_before_pairs, uniform_dim + + +def try_linearize( + allocations: tuple[Allocation, ...], +) -> tuple[Allocation, ...] | None: + """Synthesize scalar lifetimes with the identical conflict relation, or None. + + Succeeds iff the happens-before order is an interval order (equivalently, + the conflict graph is an interval graph): any 2+2 in the order induces a + chordless 4-cycle of conflicts, which no intervals can realize. A success + unlocks scalar-only allocators (minimalloc, supermalloc) for that instance; + offsets carry over unchanged since the conflict relation — and thus the + packing problem — is identical. + """ + if uniform_dim(allocations) == 1: + return allocations + + indices = range(len(allocations)) + predecessor_sets: list[set[int]] = [set() for _ in indices] + successors: list[list[int]] = [[] for _ in indices] + for i, j in happens_before_pairs(allocations): + predecessor_sets[j].add(i) + successors[i].append(j) + predecessors = [frozenset(preds) for preds in predecessor_sets] + + # Interval-order test: the strict-predecessor sets must form a chain under + # inclusion (Fishburn: no induced 2+2). + chain = sorted(set(predecessors), key=len) + for smaller, larger in pairwise(chain): + if not smaller < larger: + return None + + # Canonical magnitude representation: start = rank of the predecessor set + # in the chain, end = smallest rank among strict successors' sets. Then + # end_i <= start_j iff i happens-before j, so conflicts are preserved. + rank = {predecessor_set: r for r, predecessor_set in enumerate(chain)} + ends_scalar = [ + min((rank[predecessors[j]] for j in successors[i]), default=len(chain)) + for i in indices + ] + return tuple( + Allocation( + id=alloc.id, + size=alloc.size, + start=rank[predecessors[i]], + end=ends_scalar[i], + offset=alloc.offset, + kind=alloc.kind, + ) + for i, alloc in enumerate(allocations) + ) diff --git a/src/python/omnimalloc/primitives/__init__.py b/src/python/omnimalloc/primitives/__init__.py index 1960a5c..bcd675e 100644 --- a/src/python/omnimalloc/primitives/__init__.py +++ b/src/python/omnimalloc/primitives/__init__.py @@ -5,6 +5,7 @@ from .allocation import Allocation as Allocation from .allocation import BufferKind as BufferKind from .allocation import IdType as IdType +from .allocation import TimePoint as TimePoint from .memory import Memory as Memory from .pool import Pool as Pool from .system import System as System diff --git a/src/python/omnimalloc/primitives/allocation.py b/src/python/omnimalloc/primitives/allocation.py index 310e8d5..eb2548f 100644 --- a/src/python/omnimalloc/primitives/allocation.py +++ b/src/python/omnimalloc/primitives/allocation.py @@ -8,4 +8,9 @@ # Must match IdType in src/cpp/primitives/id_type.hpp IdType = int | str -__all__ = ["Allocation", "BufferKind", "IdType"] +# Type alias for lifetime bounds: a scalar step on one global timeline, or a +# vector clock with one component per thread (1-tuples normalize to scalars) +# Must match TimePoint in src/cpp/primitives/allocation.hpp +TimePoint = int | tuple[int, ...] + +__all__ = ["Allocation", "BufferKind", "IdType", "TimePoint"] diff --git a/src/python/omnimalloc/primitives/flow.py b/src/python/omnimalloc/primitives/flow.py new file mode 100644 index 0000000..183db94 --- /dev/null +++ b/src/python/omnimalloc/primitives/flow.py @@ -0,0 +1,72 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from collections import deque + + +class FlowNetwork: + """Dinic max flow on an adjacency list of [head, residual, reverse-index].""" + + def __init__(self, num_nodes: int) -> None: + self.adjacency: list[list[list[int]]] = [[] for _ in range(num_nodes)] + + def add_edge(self, tail: int, head: int, capacity: int) -> list[int]: + edge = [head, capacity, len(self.adjacency[head])] + self.adjacency[tail].append(edge) + self.adjacency[head].append([tail, 0, len(self.adjacency[tail]) - 1]) + return edge + + def reverse(self, edge: list[int]) -> list[int]: + return self.adjacency[edge[0]][edge[2]] + + def max_flow(self, source: int, sink: int) -> int: + flow = 0 + while True: + levels = self._levels(source, sink) + if levels is None: + return flow + cursors = [0] * len(self.adjacency) + while True: + pushed = self._augment(source, sink, levels, cursors) + if not pushed: + break + flow += pushed + + def _levels(self, source: int, sink: int) -> list[int] | None: + levels = [-1] * len(self.adjacency) + levels[source] = 0 + queue = deque([source]) + while queue: + node = queue.popleft() + for head, residual, _ in self.adjacency[node]: + if residual > 0 and levels[head] < 0: + levels[head] = levels[node] + 1 + queue.append(head) + return levels if levels[sink] >= 0 else None + + def _augment( + self, source: int, sink: int, levels: list[int], cursors: list[int] + ) -> int: + path: list[tuple[int, list[int]]] = [] + node = source + while node != sink: + advanced = False + while cursors[node] < len(self.adjacency[node]): + edge = self.adjacency[node][cursors[node]] + if edge[1] > 0 and levels[edge[0]] == levels[node] + 1: + path.append((node, edge)) + node = edge[0] + advanced = True + break + cursors[node] += 1 + if not advanced: + levels[node] = -1 + if not path: + return 0 + node = path.pop()[0] + bottleneck = min(edge[1] for _, edge in path) + for _, edge in path: + edge[1] -= bottleneck + self.reverse(edge)[1] += bottleneck + return bottleneck diff --git a/src/python/omnimalloc/primitives/utils.py b/src/python/omnimalloc/primitives/utils.py index 9229ca7..6a450ac 100644 --- a/src/python/omnimalloc/primitives/utils.py +++ b/src/python/omnimalloc/primitives/utils.py @@ -3,8 +3,10 @@ # import hashlib +from collections.abc import Iterator -from .allocation import Allocation, IdType +from .allocation import Allocation, IdType, TimePoint +from .flow import FlowNetwork def hash_id(id_value: IdType) -> int: @@ -25,8 +27,44 @@ def hash_id(id_value: IdType) -> int: return hash_val +def time_components(time_point: TimePoint) -> tuple[int, ...]: + """Return the time point's clock components (scalars become 1-tuples).""" + if isinstance(time_point, tuple): + return time_point + return (time_point,) + + +def uniform_dim(allocations: tuple[Allocation, ...]) -> int: + """Return the shared clock dimension (1 if empty); raise on mixed dims.""" + dims = {alloc.dim for alloc in allocations} + if len(dims) > 1: + raise ValueError( + f"allocations must share one clock dimension, got {sorted(dims)}" + ) + return dims.pop() if dims else 1 + + +def happens_before(end: tuple[int, ...], start: tuple[int, ...]) -> bool: + """Componentwise `end <= start`; mirrors C++ `happens_before` in allocation.cpp.""" + return all(e <= s for e, s in zip(end, start, strict=True)) + + +def happens_before_pairs( + allocations: tuple[Allocation, ...], +) -> Iterator[tuple[int, int]]: + """Yield index pairs (i, j) where i's free happens-before j's alloc.""" + starts = [time_components(alloc.start) for alloc in allocations] + ends = [time_components(alloc.end) for alloc in allocations] + for i, end in enumerate(ends): + for j, start in enumerate(starts): + if i != j and happens_before(end, start): + yield i, j + + def get_pressure(allocations: tuple[Allocation, ...]) -> int: - """Calculate maximum memory pressure across all allocation intervals.""" + """Calculate maximum memory pressure across all allocation lifetimes.""" + if uniform_dim(allocations) > 1: + return _vector_pressure(allocations) events = [(alloc.start, alloc.size) for alloc in allocations] events.extend((alloc.end, -alloc.size) for alloc in allocations) events.sort() @@ -37,3 +75,38 @@ def get_pressure(allocations: tuple[Allocation, ...]) -> int: max_pressure = max(max_pressure, current) return max_pressure + + +def _vector_pressure(allocations: tuple[Allocation, ...]) -> int: + """Max-weight antichain of the happens-before order via min flow. + + Weighted Dilworth dual: the minimum feasible flow where each allocation's + node must carry at least its size equals the max-weight set of + pairwise-concurrent allocations. + """ + n = len(allocations) + weights = [alloc.size for alloc in allocations] + total = sum(weights) + + # Nodes: in_i = 2i, out_i = 2i + 1, then source, sink, and the + # super-source/super-sink of the lower-bound feasibility transform. + source, sink, feas_source, feas_sink = 2 * n, 2 * n + 1, 2 * n + 2, 2 * n + 3 + network = FlowNetwork(2 * n + 4) + for i in range(n): + network.add_edge(source, 2 * i, total) + network.add_edge(2 * i + 1, sink, total) + # Lower bound w_i on (in_i, out_i): unbounded residual arc plus the + # standard super-source/super-sink demand arcs. + network.add_edge(2 * i, 2 * i + 1, total) + network.add_edge(feas_source, 2 * i + 1, weights[i]) + network.add_edge(2 * i, feas_sink, weights[i]) + for i, j in happens_before_pairs(allocations): + network.add_edge(2 * i + 1, 2 * j, total) + circulation = network.add_edge(sink, source, total) + + if network.max_flow(feas_source, feas_sink) != total: + raise RuntimeError("lower-bound feasibility flow must saturate") + feasible = total - circulation[1] + circulation[1] = 0 + network.reverse(circulation)[1] = 0 + return feasible - network.max_flow(sink, source) diff --git a/src/python/omnimalloc/validate.py b/src/python/omnimalloc/validate.py index b9a495a..6ab36d0 100644 --- a/src/python/omnimalloc/validate.py +++ b/src/python/omnimalloc/validate.py @@ -3,6 +3,7 @@ # from .primitives import Allocation, IdType, Memory, Pool, System +from .primitives.utils import uniform_dim def _check_unique_ids(entities: tuple[Memory | Pool | Allocation, ...]) -> None: @@ -40,6 +41,7 @@ def _validate_allocations( allocations: tuple[Allocation, ...], require_allocated: bool ) -> None: _check_unique_ids(allocations) + uniform_dim(allocations) _check_overlaps(allocations, require_allocated) diff --git a/src/python/omnimalloc/visualize.py b/src/python/omnimalloc/visualize.py index 7595d7a..f74d7f3 100644 --- a/src/python/omnimalloc/visualize.py +++ b/src/python/omnimalloc/visualize.py @@ -9,6 +9,7 @@ from omnimalloc.common.optional import require_optional from omnimalloc.primitives import Allocation, BufferKind, IdType, Memory, Pool, System +from omnimalloc.primitives.utils import time_components, uniform_dim try: import matplotlib.pyplot as plt @@ -66,6 +67,12 @@ def _format_bytes(value: float) -> str: BufferKind.OUTPUT: "C3", } +LANE_CAVEAT: Final[str] = ( + "Lanes show each thread's local timeline: overlaps within a lane are real " + "conflicts, but cross-thread conflicts may not be visible anywhere; " + "validate_allocation() is the authority." +) + def _get_allocation_color(kind: BufferKind | None) -> str: if kind is None: @@ -75,13 +82,26 @@ def _get_allocation_color(kind: BufferKind | None) -> str: return KIND_COLOR_MAP[kind] -def _get_x_limits(system: System) -> tuple[int, int]: - max_len = 0 +def _memory_dim(memory: Memory) -> int: + # Dimension is uniform per pool (the validate.py contract), but pools of + # one memory may mix, e.g. after linearizing one pool; lanes cover the max. + return max((uniform_dim(pool.allocations) for pool in memory.pools), default=1) + + +def _lane_extent(alloc: Allocation, lane: int) -> tuple[int, int]: + """Project an allocation's lifetime onto one thread's local timeline.""" + return time_components(alloc.start)[lane], time_components(alloc.end)[lane] + + +def _get_x_limits(system: System) -> dict[int, tuple[int, int]]: + """Per-lane x-limits, shared across memories so aligned lanes compare.""" + max_ends: dict[int, int] = {} for memory in system.memories: for pool in memory.pools: for alloc in pool.allocations: - max_len = max(max_len, alloc.start + alloc.duration) - return 0, max_len + for lane, end in enumerate(time_components(alloc.end)): + max_ends[lane] = max(max_ends.get(lane, 0), end) + return {lane: (0, end) for lane, end in max_ends.items()} def _get_y_limits(system: System) -> dict[Memory, tuple[int, int]]: @@ -128,13 +148,20 @@ def _get_y_offsets(system: System) -> dict[Memory, dict[Pool, int]]: return offsets -def _draw_allocation(ax: Axes, alloc: Allocation, offset: int, color: str) -> None: - """Draw a single allocation rectangle.""" +def _draw_allocation( + ax: Axes, alloc: Allocation, offset: int, color: str, lane: int = 0 +) -> None: + """Draw a single allocation rectangle, projected onto the given lane.""" assert alloc.offset is not None + if lane >= alloc.dim: + return # Lower-dim allocation in a mixed memory; no such thread lane + start, end = _lane_extent(alloc, lane) + if start == end: + return # No local time on this thread; not visible in this lane y_pos = offset + alloc.offset rect = Rectangle( - xy=(alloc.start, y_pos), - width=alloc.duration, + xy=(start, y_pos), + width=end - start, height=alloc.size, edgecolor="black", facecolor=color, @@ -142,7 +169,7 @@ def _draw_allocation(ax: Axes, alloc: Allocation, offset: int, color: str) -> No ) ax.add_patch(rect) ax.text( - alloc.start + alloc.duration / 2, + (start + end) / 2, y_pos + alloc.size / 2, f"{alloc.id}", ha="center", @@ -156,7 +183,7 @@ def _draw_pool_background( ) -> None: """Draw background rectangle for allocation pool.""" if isinstance(color, set): - color = "gray" if len(color) > 1 else color.pop() + color = color.pop() if len(color) == 1 else "gray" x_min, x_max = ax.get_xlim() rect = Rectangle( xy=(x_min, y_offset), @@ -206,11 +233,18 @@ def _set_axes_ticks(ax: Axes, y_limit: int, num_ticks: int = 8) -> None: def _set_axes_labels( - ax: Axes, memory: Memory, memory_size: int | None, num_pools: int + ax: Axes, + memory: Memory, + memory_size: int | None, + num_pools: int, + lane: int, + dim: int, ) -> None: size_str = _format_bytes(memory_size) if memory_size is not None else "Unknown Size" - ax.set_title(f"{memory.id} ({size_str}, {num_pools} pools)") - ax.set_xlabel("Time (Step)") + threads_str = f", {dim} threads" if dim > 1 else "" + if lane == 0: + ax.set_title(f"{memory.id} ({size_str}, {num_pools} pools{threads_str})") + ax.set_xlabel("Time (Step)" if dim == 1 else f"Thread {lane} Time (Step)") def _set_axes_limits( @@ -258,36 +292,39 @@ def _visualize_system( show_inline: bool, memory_limits: dict[str, dict[IdType, int]], ) -> Path | None: - num_memories = len(system.memories) - fig_height = max(9, num_memories * 6) + dims = {memory: _memory_dim(memory) for memory in system.memories} + lanes = [ + (memory, lane) for memory in system.memories for lane in range(dims[memory]) + ] + fig_height = max(9, len(lanes) * 6) fig_width = 12 fig, axs = plt.subplots( - nrows=num_memories, + nrows=len(lanes), ncols=1, figsize=(fig_width, fig_height), layout="constrained", ) - axs = [axs] if num_memories == 1 else axs + axs = [axs] if len(lanes) == 1 else axs x_limits = _get_x_limits(system) y_limits = _get_y_limits(system) y_offsets = _get_y_offsets(system) - for ax, memory in zip(axs, system.memories, strict=True): + for ax, (memory, lane) in zip(axs, lanes, strict=True): memory_y_limits = y_limits[memory] - _set_axes_labels(ax, memory, memory.size, len(memory.pools)) - _set_axes_limits(ax, x_limits, memory_y_limits, memory.size) + _set_axes_labels(ax, memory, memory.size, len(memory.pools), lane, dims[memory]) + _set_axes_limits(ax, x_limits.get(lane, (0, 0)), memory_y_limits, memory.size) _set_axes_ticks(ax, memory_y_limits[1]) - colors: set[str] = set() for pool in memory.pools: y_offset = y_offsets[memory][pool] + colors: set[str] = set() for alloc in pool.allocations: color = _get_allocation_color(alloc.kind) - _draw_allocation(ax, alloc, y_offset, color) + _draw_allocation(ax, alloc, y_offset, color, lane) colors.add(color) - _draw_pool_background(ax, y_offset, pool.size, color) + _draw_pool_background(ax, y_offset, pool.size, colors) # Draw memory limit lines limits: dict[str, int] = {"used": memory.used_size} @@ -299,6 +336,10 @@ def _visualize_system( _draw_limit_lines(ax, limits) + if any(dim > 1 for dim in dims.values()): + # Lanes are per-thread projections of a partial order: same-lane + # collisions are real, but cross-thread conflicts need not be visible. + fig.suptitle(LANE_CAVEAT, fontsize=8) _add_legend(fig) if show_inline: @@ -320,6 +361,10 @@ def _canonicalize(system: System) -> System: def _id_sort_key(id_val: IdType) -> tuple[int, int | str]: return (0, id_val) if isinstance(id_val, int) else (1, id_val) + def _alloc_sort_key(alloc: Allocation) -> tuple[object, ...]: + # Lexicographic on the (possibly vector) start, then original id + return time_components(alloc.start), _id_sort_key(alloc.id) + # Collect all allocations and assign sequential IDs all_allocations = [ alloc @@ -328,8 +373,7 @@ def _id_sort_key(id_val: IdType) -> tuple[int, int | str]: for alloc in pool.allocations ] - # Sort by (start, original_id) for stable ordering - all_allocations.sort(key=lambda a: (a.start, _id_sort_key(a.id))) + all_allocations.sort(key=_alloc_sort_key) # Create mapping from old allocation to new ID alloc_to_new_id = { @@ -354,10 +398,7 @@ def _id_sort_key(id_val: IdType) -> tuple[int, int | str]: offset=alloc.offset, kind=alloc.kind, ) - for alloc in sorted( - pool.allocations, - key=lambda a: (a.start, _id_sort_key(a.id)), - ) + for alloc in sorted(pool.allocations, key=_alloc_sort_key) ), ) for pool in sorted(memory.pools, key=lambda p: _id_sort_key(p.id)) diff --git a/tests/unit/allocators/test_vector_time.py b/tests/unit/allocators/test_vector_time.py new file mode 100644 index 0000000..515d7e5 --- /dev/null +++ b/tests/unit/allocators/test_vector_time.py @@ -0,0 +1,238 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import pytest +from omnimalloc._cpp import FirstFitPlacer, Partition, compute_temporal_overlaps +from omnimalloc.allocators.base import BaseAllocator +from omnimalloc.allocators.best_fit import BestFitAllocator +from omnimalloc.allocators.genetic import HAS_DEAP, GeneticAllocator +from omnimalloc.allocators.greedy import GreedyAllocator +from omnimalloc.allocators.greedy_base import ( + compute_conflicts, + order_by_area, + order_by_conflict, + order_by_conflict_size, + order_by_duration, + order_by_size, + order_by_start, + peak_memory, +) +from omnimalloc.allocators.greedy_cpp import GreedyAllocatorCpp +from omnimalloc.allocators.hillclimb import HillClimbAllocator +from omnimalloc.allocators.minimalloc import HAS_MINIMALLOC, MinimallocAllocator +from omnimalloc.allocators.naive import NaiveAllocator +from omnimalloc.allocators.random import RandomAllocator +from omnimalloc.allocators.simulated_annealing import ( + SimulatedAnnealingAllocator, + SimulatedAnnealingConfig, +) +from omnimalloc.allocators.supermalloc import SupermallocAllocator +from omnimalloc.allocators.tabu_search import TabuSearchAllocator, TabuSearchConfig +from omnimalloc.allocators.telamalloc import TelamallocAllocator +from omnimalloc.primitives import Allocation +from omnimalloc.primitives.pool import Pool +from omnimalloc.validate import validate_allocation + + +def vector_problem(n: int = 10) -> tuple[Allocation, ...]: + return tuple( + Allocation( + id=i, + size=32 * (i % 3 + 1), + start=(i, max(0, i - 2)), + end=(i + 3, i + 1), + ) + for i in range(n) + ) + + +@pytest.mark.parametrize( + "allocator_cls", + [ + GreedyAllocator, + GreedyAllocatorCpp, + NaiveAllocator, + RandomAllocator, + HillClimbAllocator, + BestFitAllocator, + ], +) +def test_allocators_place_vector_problems( + allocator_cls: type[BaseAllocator], +) -> None: + allocs = vector_problem() + result = allocator_cls().allocate(allocs) + assert validate_allocation(Pool(id="p", allocations=result)) + assert {a.id for a in result} == {a.id for a in allocs} + + +@pytest.mark.skipif(not HAS_DEAP, reason="deap not installed") +def test_genetic_places_vector_problems() -> None: + allocator = GeneticAllocator(population_size=10, num_generations=3) + result = allocator.allocate(vector_problem()) + assert validate_allocation(Pool(id="p", allocations=result)) + + +def test_orderings_permute_vector_problems() -> None: + allocs = vector_problem() + orders = ( + order_by_size, + order_by_duration, + order_by_area, + order_by_conflict, + order_by_conflict_size, + order_by_start, + ) + for order in orders: + assert sorted(a.id for a in order(allocs)) == sorted(a.id for a in allocs) + + +def test_order_by_start_is_lexicographic() -> None: + allocs = ( + Allocation(id=1, size=1, start=(1, 0), end=(2, 1)), + Allocation(id=2, size=1, start=(0, 5), end=(1, 6)), + Allocation(id=3, size=1, start=(0, 2), end=(1, 3)), + ) + assert [a.id for a in order_by_start(allocs)] == [3, 2, 1] + + +def test_order_by_start_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"): + order_by_start(mixed) + + +def test_compute_conflicts_matches_overlap_map() -> None: + allocs = vector_problem() + overlaps = compute_temporal_overlaps(allocs) + conflicts = compute_conflicts(allocs) + for alloc in allocs: + assert conflicts[alloc] == len(overlaps.get(alloc.id, ())) + + +def test_compute_conflicts_scalar_matches_overlap_map() -> None: + allocs = tuple( + Allocation(id=i, size=8, start=i % 4, end=i % 4 + 2) for i in range(10) + ) + overlaps = compute_temporal_overlaps(allocs) + conflicts = compute_conflicts(allocs) + for alloc in allocs: + assert conflicts[alloc] == len(overlaps.get(alloc.id, ())) + + +def test_compute_conflicts_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 + ) + scalar_conflicts = compute_conflicts(scalar) + vector_conflicts = compute_conflicts(lockstep) + assert [scalar_conflicts[a] for a in scalar] == [ + vector_conflicts[a] for a in lockstep + ] + + +def test_overlap_map_matches_pairwise_test() -> None: + allocs = vector_problem(12) + overlaps = compute_temporal_overlaps(allocs) + for a in allocs: + for b in allocs: + if a.id == b.id: + continue + assert (b.id in overlaps.get(a.id, set())) == a.overlaps_temporally(b) + + +def test_first_fit_placer_accepts_vector_problems() -> None: + allocs = vector_problem() + placer = FirstFitPlacer(list(allocs)) + placed = tuple(placer.place(list(range(len(allocs))))) + assert validate_allocation(Pool(id="p", allocations=placed)) + assert placer.evaluate(list(range(len(allocs)))) == peak_memory(placed) + + +def test_order_search_allocators_place_vector_problems() -> None: + allocs = vector_problem() + for allocator in ( + SimulatedAnnealingAllocator(SimulatedAnnealingConfig(max_iterations=20)), + TabuSearchAllocator(TabuSearchConfig(max_iterations=20)), + ): + result = allocator.allocate(allocs) + assert validate_allocation(Pool(id="p", allocations=result)) + assert {a.id for a in result} == {a.id for a in allocs} + + +def test_partition_rejects_vector_time() -> None: + with pytest.raises(ValueError, match="scalar time"): + 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_allocator"].supports_vector_time + assert registry["best_fit_allocator"].supports_vector_time + assert registry["simulated_annealing_allocator"].supports_vector_time + assert registry["tabu_search_allocator"].supports_vector_time + assert not registry["supermalloc_allocator"].supports_vector_time + assert not registry["telamalloc_allocator"].supports_vector_time + + +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"): + 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)), + Allocation(id=2, size=100, start=(2, 1), end=(3, 2)), + ) + assert peak_memory(GreedyAllocator().allocate(ordered)) == 100 + + concurrent = ( + Allocation(id=1, size=100, start=(0, 5), end=(1, 6)), + Allocation(id=2, size=100, start=(2, 0), end=(3, 1)), + ) + assert peak_memory(GreedyAllocator().allocate(concurrent)) == 200 diff --git a/tests/unit/benchmark/sources/test_concurrent_tiling.py b/tests/unit/benchmark/sources/test_concurrent_tiling.py new file mode 100644 index 0000000..f3f06ee --- /dev/null +++ b/tests/unit/benchmark/sources/test_concurrent_tiling.py @@ -0,0 +1,146 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import pytest +from omnimalloc import run_allocation, validate_allocation +from omnimalloc.benchmark.sources import BaseSource +from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource +from omnimalloc.primitives import Allocation +from omnimalloc.primitives.utils import get_pressure + + +def _signatures( + allocations: tuple[Allocation, ...], +) -> list[tuple[object, object, int]]: + return [(a.start, a.end, a.size) for a in allocations] + + +def test_concurrent_tiling_is_registered() -> None: + assert "concurrent_tiling_source" in BaseSource.registry() + assert BaseSource.get("concurrent_tiling_source") is ConcurrentTilingSource + + +def test_concurrent_tiling_produces_requested_count_and_dim() -> None: + source = ConcurrentTilingSource(num_allocations=64, num_threads=4) + allocations = source.get_allocations() + assert len(allocations) == 64 + assert {a.dim for a in allocations} == {4} + + +def test_concurrent_tiling_single_thread_degenerates_to_scalar() -> None: + source = ConcurrentTilingSource(num_allocations=16, num_threads=1, num_syncs=0) + assert {a.dim for a in source.get_allocations()} == {1} + + +def test_concurrent_tiling_unsynced_threads_share_nothing() -> None: + source = ConcurrentTilingSource(num_allocations=32, num_threads=2, num_syncs=0) + for alloc in source.get_allocations(): + assert isinstance(alloc.start, tuple) + assert isinstance(alloc.end, tuple) + assert sorted(alloc.start).count(0) >= 1 + assert sorted(alloc.end).count(0) >= 1 + + +def test_concurrent_tiling_syncs_propagate_foreign_components() -> None: + source = ConcurrentTilingSource(num_allocations=64, num_threads=2, num_syncs=64) + allocations = source.get_allocations() + assert any(min(a.start) > 0 for a in allocations if isinstance(a.start, tuple)) + + +def test_concurrent_tiling_clocks_are_monotone() -> None: + source = ConcurrentTilingSource( + num_allocations=64, num_threads=3, num_syncs=32, capacity=3 * 256 * 1024 + ) + for alloc in source.get_allocations(): + assert all(s <= e for s, e in zip(alloc.start, alloc.end, strict=True)) + assert alloc.start != alloc.end + + +@pytest.mark.parametrize("num_syncs", [0, 16, 256]) +def test_concurrent_tiling_pressure_equals_capacity(num_syncs: int) -> None: + capacity = 1024 * 1024 + source = ConcurrentTilingSource( + num_allocations=64, num_threads=4, num_syncs=num_syncs, capacity=capacity + ) + assert get_pressure(source.get_allocations()) == capacity + + +def test_concurrent_tiling_ground_truth_is_valid_and_optimal() -> None: + capacity = 1024 * 1024 + source = ConcurrentTilingSource( + num_allocations=96, num_threads=4, num_syncs=32, capacity=capacity + ) + pool = source.get_ground_truth_pool() + validate_allocation(pool) + assert pool.is_allocated + assert pool.size == capacity + assert pool.pressure == capacity + assert pool.efficiency == 1.0 + + +def test_concurrent_tiling_ground_truth_matches_get_allocations() -> None: + source = ConcurrentTilingSource( + num_allocations=48, num_threads=3, capacity=3 * 256 * 1024 + ) + truth = source.get_ground_truth_pool() + allocs = source.get_allocations() + 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) + + +def test_concurrent_tiling_rejects_fewer_allocations_than_threads() -> None: + with pytest.raises(ValueError, match="num_threads"): + ConcurrentTilingSource( + num_allocations=2, num_threads=4, capacity=4096, min_size=1 + ) + source = ConcurrentTilingSource( + num_allocations=8, num_threads=4, capacity=4096, min_size=1 + ) + with pytest.raises(ValueError, match="num_threads"): + 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, min_size=2048) + + +def test_concurrent_tiling_rejects_num_syncs_out_of_range() -> None: + with pytest.raises(ValueError, match="num_syncs"): + ConcurrentTilingSource(num_syncs=-1) + with pytest.raises(ValueError, match="num_syncs"): + ConcurrentTilingSource(num_syncs=100, makespan=64, min_size=1, min_duration=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 = run_allocation(pool, "greedy_by_size_allocator_cpp", validate=True) + assert allocated.size >= capacity diff --git a/tests/unit/benchmark/test_benchmark.py b/tests/unit/benchmark/test_benchmark.py index f0635d7..f4bbdf5 100644 --- a/tests/unit/benchmark/test_benchmark.py +++ b/tests/unit/benchmark/test_benchmark.py @@ -3,8 +3,11 @@ # +import pytest from omnimalloc.allocators import GreedyAllocator, NaiveAllocator +from omnimalloc.allocators.supermalloc import SupermallocAllocator from omnimalloc.benchmark.benchmark import benchmark_campaign, run_benchmark +from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.benchmark.sources.generator import RandomSource @@ -74,3 +77,44 @@ def test_run_benchmark_metadata() -> None: def test_benchmark_campaign_alias() -> None: """Test that benchmark_campaign is an alias for run_benchmark.""" assert benchmark_campaign is run_benchmark + + +def test_run_benchmark_on_vector_clock_source() -> None: + source = ConcurrentTilingSource(num_allocations=16, num_threads=2, num_syncs=8) + + campaign = run_benchmark( + allocators=(GreedyAllocator(),), + sources=(source,), + iterations=1, + variants=16, + validate=True, + ) + + assert campaign.num_reports == 1 + assert campaign.reports[0].mean_allocation_efficiency > 0 + + +def test_run_benchmark_skips_scalar_only_allocators_on_vector_source() -> None: + source = ConcurrentTilingSource(num_allocations=16, num_threads=2, num_syncs=8) + + campaign = run_benchmark( + allocators=(SupermallocAllocator(), GreedyAllocator()), + sources=(source,), + iterations=1, + variants=16, + ) + + assert campaign.num_reports == 1 + assert campaign.reports[0].allocator_name == "greedy_allocator" + + +def test_run_benchmark_raises_when_all_pairs_skipped() -> None: + source = ConcurrentTilingSource(num_allocations=16, num_threads=2, num_syncs=8) + + with pytest.raises(ValueError, match="No benchmark reports"): + run_benchmark( + allocators=(SupermallocAllocator(),), + sources=(source,), + iterations=1, + variants=16, + ) diff --git a/tests/unit/primitives/test_allocation_vector.py b/tests/unit/primitives/test_allocation_vector.py new file mode 100644 index 0000000..8361217 --- /dev/null +++ b/tests/unit/primitives/test_allocation_vector.py @@ -0,0 +1,138 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import pickle + +import pytest +from omnimalloc.primitives import Allocation +from omnimalloc.primitives.utils import get_pressure + + +def test_vector_creation() -> None: + alloc = Allocation(id=7, size=64, start=(3, 0), end=(5, 2)) + assert alloc.start == (3, 0) + assert alloc.end == (5, 2) + assert alloc.dim == 2 + + +def test_scalar_dim_is_one() -> None: + assert Allocation(id=1, size=1, start=0, end=1).dim == 1 + + +def test_one_tuple_normalizes_to_scalar() -> None: + scalar = Allocation(id=1, size=10, start=3, end=7) + one_tuple = Allocation(id=1, size=10, start=(3,), end=(7,)) + assert one_tuple == scalar + assert hash(one_tuple) == hash(scalar) + assert one_tuple.start == 3 + assert one_tuple.dim == 1 + + +def test_negative_component_rejected() -> None: + with pytest.raises(ValueError, match="non-negative componentwise"): + Allocation(id=1, size=1, start=(0, -1), end=(1, 1)) + + +def test_end_below_start_componentwise_rejected() -> None: + with pytest.raises(ValueError, match="componentwise"): + Allocation(id=1, size=1, start=(2, 0), end=(1, 5)) + + +def test_end_equal_to_start_rejected() -> None: + with pytest.raises(ValueError, match="at least one component"): + Allocation(id=1, size=1, start=(2, 1), end=(2, 1)) + + +def test_start_end_dimension_mismatch_rejected() -> None: + with pytest.raises(ValueError, match="share one clock dimension"): + Allocation(id=1, size=1, start=(0, 0), end=(1, 1, 1)) + + +def test_empty_time_point_rejected() -> None: + with pytest.raises(ValueError, match="at least one component"): + Allocation(id=1, size=1, start=(), end=()) + + +def test_duration_is_max_per_thread_extent() -> None: + alloc = Allocation(id=1, size=10, start=(3, 0), end=(5, 4)) + assert alloc.duration == 4 + assert alloc.area == 40 + + +def test_scalar_duration_unchanged() -> None: + assert Allocation(id=1, size=10, start=3, end=7).duration == 4 + + +def test_no_overlap_when_ordered() -> None: + first = Allocation(id=1, size=1, start=(0, 0), end=(2, 1)) + second = Allocation(id=2, size=1, start=(2, 1), end=(3, 2)) + assert not first.overlaps_temporally(second) + assert not second.overlaps_temporally(first) + + +def test_overlap_when_concurrent() -> None: + a = Allocation(id=1, size=1, start=(0, 0), end=(5, 1)) + b = Allocation(id=2, size=1, start=(1, 0), end=(2, 3)) + assert a.overlaps_temporally(b) + assert b.overlaps_temporally(a) + + +def test_overlap_dimension_mismatch_rejected() -> None: + scalar = Allocation(id=1, size=1, start=0, end=1) + vector = Allocation(id=2, size=1, start=(0, 0), end=(1, 1)) + with pytest.raises(ValueError, match="dimension mismatch"): + scalar.overlaps_temporally(vector) + + +def test_overlaps_combines_temporal_and_spatial() -> None: + a = Allocation(id=1, size=100, start=(0, 0), end=(5, 1), offset=0) + b = Allocation(id=2, size=100, start=(1, 0), end=(2, 3), offset=50) + c = Allocation(id=3, size=100, start=(1, 0), end=(2, 3), offset=100) + assert a.overlaps(b) + assert not a.overlaps(c) + + +def test_with_offset_preserves_vector_time() -> None: + alloc = Allocation(id=1, size=8, start=(1, 0), end=(2, 2)).with_offset(16) + assert alloc.start == (1, 0) + assert alloc.end == (2, 2) + assert alloc.offset == 16 + + +def test_repr_shows_tuples() -> None: + alloc = Allocation(id=1, size=1, start=(3, 0), end=(5, 2)) + assert "start=(3, 0)" in repr(alloc) + assert "end=(5, 2)" in repr(alloc) + + +def test_pickle_roundtrip_vector() -> None: + alloc = Allocation(id="x", size=10, start=(0, 3), end=(5, 4), offset=3) + restored = pickle.loads(pickle.dumps(alloc)) # noqa: S301 + assert restored == alloc + assert hash(restored) == hash(alloc) + assert restored.start == (0, 3) + assert isinstance(restored.start, tuple) + + +def test_pressure_supports_vector_time() -> None: + allocs = (Allocation(id=1, size=1, start=(0, 0), end=(1, 1)),) + assert get_pressure(allocs) == 1 + + +def test_conflict_without_per_thread_overlap() -> None: + a = Allocation(id="a", size=1, start=(0, 5), end=(1, 6)) + b = Allocation(id="b", size=1, start=(2, 0), end=(3, 1)) + assert a.overlaps_temporally(b) + assert b.overlaps_temporally(a) + + +def test_before_relation_is_transitive_chain() -> None: + chain = tuple( + Allocation(id=i, size=1, start=(i, 2 * i), end=(i + 1, 2 * i + 1)) + for i in range(5) + ) + for i, earlier in enumerate(chain): + for later in chain[i + 1 :]: + assert not earlier.overlaps_temporally(later) + assert not later.overlaps_temporally(earlier) diff --git a/tests/unit/primitives/test_flow.py b/tests/unit/primitives/test_flow.py new file mode 100644 index 0000000..fd789ff --- /dev/null +++ b/tests/unit/primitives/test_flow.py @@ -0,0 +1,135 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import random +from collections import defaultdict, deque + +from omnimalloc.primitives.flow import FlowNetwork + + +def edmonds_karp(edges: list[tuple[int, int, int]], source: int, sink: int) -> int: + cap: dict[tuple[int, int], int] = defaultdict(int) + adjacency: dict[int, set[int]] = defaultdict(set) + for tail, head, capacity in edges: + cap[tail, head] += capacity + adjacency[tail].add(head) + adjacency[head].add(tail) + flow = 0 + while True: + parent = {source: source} + queue = deque([source]) + while queue: + node = queue.popleft() + for head in adjacency[node]: + if head not in parent and cap[node, head] > 0: + parent[head] = node + queue.append(head) + if sink not in parent: + return flow + bottleneck = float("inf") + node = sink + while node != source: + bottleneck = min(bottleneck, cap[parent[node], node]) + node = parent[node] + node = sink + while node != source: + cap[parent[node], node] -= bottleneck + cap[node, parent[node]] += bottleneck + node = parent[node] + flow += int(bottleneck) + + +def test_single_edge() -> None: + network = FlowNetwork(2) + network.add_edge(0, 1, 7) + assert network.max_flow(0, 1) == 7 + + +def test_chain_takes_bottleneck() -> None: + network = FlowNetwork(3) + network.add_edge(0, 1, 5) + network.add_edge(1, 2, 3) + assert network.max_flow(0, 2) == 3 + + +def test_parallel_edges_sum() -> None: + network = FlowNetwork(2) + network.add_edge(0, 1, 2) + network.add_edge(0, 1, 3) + assert network.max_flow(0, 1) == 5 + + +def test_disjoint_paths_sum() -> None: + network = FlowNetwork(4) + network.add_edge(0, 1, 3) + network.add_edge(1, 3, 3) + network.add_edge(0, 2, 2) + network.add_edge(2, 3, 2) + assert network.max_flow(0, 3) == 5 + + +def test_no_path_returns_zero() -> None: + network = FlowNetwork(3) + network.add_edge(0, 1, 5) + assert network.max_flow(0, 2) == 0 + + +def test_add_edge_creates_zero_capacity_reverse() -> None: + network = FlowNetwork(2) + forward = network.add_edge(0, 1, 9) + assert network.reverse(forward)[1] == 0 + assert forward[1] == 9 + + +def test_saturated_forward_matches_reverse_gain() -> None: + network = FlowNetwork(2) + forward = network.add_edge(0, 1, 4) + network.max_flow(0, 1) + assert forward[1] == 0 + assert network.reverse(forward)[1] == 4 + + +def test_antiparallel_edges_independent() -> None: + network = FlowNetwork(2) + network.add_edge(0, 1, 6) + network.add_edge(1, 0, 100) + assert network.max_flow(0, 1) == 6 + + +def test_clrs_reference_graph() -> None: + edges = [ + (0, 1, 16), + (0, 2, 13), + (1, 2, 10), + (2, 1, 4), + (1, 3, 12), + (3, 2, 9), + (2, 4, 14), + (4, 3, 7), + (3, 5, 20), + (4, 5, 4), + ] + network = FlowNetwork(6) + for tail, head, capacity in edges: + network.add_edge(tail, head, capacity) + assert network.max_flow(0, 5) == 23 + + +def test_matches_edmonds_karp_on_random_graphs() -> None: + rng = random.Random(7) + for _ in range(200): + num_nodes = rng.randint(2, 9) + edges = [] + network = FlowNetwork(num_nodes) + for _ in range(rng.randint(0, 18)): + tail = rng.randrange(num_nodes) + head = rng.randrange(num_nodes) + if tail == head: + continue + capacity = rng.randint(0, 12) + edges.append((tail, head, capacity)) + network.add_edge(tail, head, capacity) + assert network.max_flow(0, num_nodes - 1) == edmonds_karp( + edges, 0, num_nodes - 1 + ) diff --git a/tests/unit/primitives/test_utils.py b/tests/unit/primitives/test_utils.py index 349923e..bebe39a 100644 --- a/tests/unit/primitives/test_utils.py +++ b/tests/unit/primitives/test_utils.py @@ -2,8 +2,34 @@ # SPDX-License-Identifier: Apache-2.0 # +import itertools +import random + import pytest -from omnimalloc.primitives.utils import hash_id +from omnimalloc.primitives import Allocation +from omnimalloc.primitives.utils import ( + _vector_pressure, + get_pressure, + happens_before, + hash_id, + time_components, +) + + +def brute_max_weight_antichain(allocations: tuple[Allocation, ...]) -> int: + ends = [time_components(a.end) for a in allocations] + starts = [time_components(a.start) for a in allocations] + + def ordered(i: int, j: int) -> bool: + return happens_before(ends[i], starts[j]) or happens_before(ends[j], starts[i]) + + indices = range(len(allocations)) + best = 0 + for size in indices: + for subset in itertools.combinations(indices, size + 1): + if all(not ordered(i, j) for i, j in itertools.combinations(subset, 2)): + best = max(best, sum(allocations[i].size for i in subset)) + return best def test_hash_id_with_string() -> None: @@ -48,3 +74,133 @@ def test_hash_id_invalid_type() -> None: with pytest.raises(TypeError, match="id_value must be str or int"): hash_id([1, 2, 3]) # type: ignore[arg-type] + + +def test_pressure_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=4, end=8), + ) + assert get_pressure(allocations) == 150 + + +def test_pressure_empty() -> None: + assert get_pressure(()) == 0 + + +def test_pressure_vector_concurrent_pair_sums() -> None: + allocations = ( + Allocation(id=1, size=100, start=(0, 0), end=(2, 1)), + Allocation(id=2, size=100, start=(1, 0), end=(3, 1)), + ) + assert get_pressure(allocations) == 200 + + +def test_pressure_vector_ordered_pair_takes_max() -> None: + allocations = ( + Allocation(id=1, size=100, start=(0, 0), end=(2, 1)), + Allocation(id=2, size=50, start=(2, 1), end=(3, 2)), + ) + assert get_pressure(allocations) == 100 + + +def test_pressure_vector_chain_takes_max() -> None: + allocations = tuple( + Allocation(id=i, size=10 * (i + 1), start=(i, i), end=(i + 1, i + 1)) + for i in range(5) + ) + assert get_pressure(allocations) == 50 + + +def test_pressure_vector_conflict_without_lane_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)), + ) + assert get_pressure(allocations) == 200 + + +def test_pressure_vector_unsynced_threads_sum() -> None: + allocations = ( + Allocation(id=1, size=64, start=(3, 0), end=(5, 0)), + Allocation(id=2, size=32, start=(0, 2), end=(0, 4)), + ) + assert get_pressure(allocations) == 96 + + +def test_pressure_lockstep_matches_scalar() -> None: + rng = random.Random(42) + for _ in range(20): + allocs = [] + for i in range(rng.randint(1, 30)): + size = rng.randint(1, 100) + start = rng.randint(0, 20) + allocs.append( + Allocation(id=i, size=size, start=start, end=start + rng.randint(1, 10)) + ) + 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 + ) + assert get_pressure(lockstep) == get_pressure(scalar) + + +def test_pressure_mixed_dimensions_rejected() -> 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="clock dim"): + get_pressure(allocations) + + +def test_vector_pressure_single_allocation() -> None: + allocations = (Allocation(id=1, size=42, start=(0, 0), end=(1, 1)),) + assert _vector_pressure(allocations) == 42 + + +def test_vector_pressure_concurrent_pair_sums() -> None: + allocations = ( + Allocation(id=1, size=30, start=(0, 0), end=(2, 1)), + Allocation(id=2, size=70, start=(1, 0), end=(3, 1)), + ) + assert _vector_pressure(allocations) == 100 + + +def test_vector_pressure_ordered_pair_takes_max() -> None: + allocations = ( + Allocation(id=1, size=30, start=(0, 0), end=(1, 1)), + Allocation(id=2, size=70, start=(1, 1), end=(2, 2)), + ) + assert _vector_pressure(allocations) == 70 + + +def test_vector_pressure_maximizes_weight_not_cardinality() -> None: + allocations = ( + Allocation(id="y", size=10, start=(0, 0), end=(1, 1)), + Allocation(id="z", size=10, start=(0, 1), end=(1, 2)), + Allocation(id="x", size=100, start=(1, 2), end=(2, 3)), + ) + assert _vector_pressure(allocations) == 100 + + +def test_vector_pressure_matches_brute_force() -> None: + rng = random.Random(7) + for _ in range(200): + allocations = [] + for i in range(rng.randint(1, 7)): + dim = 3 + start = tuple(rng.randint(0, 4) for _ in range(dim)) + deltas = [rng.randint(0, 3) for _ in range(dim)] + if sum(deltas) == 0: + deltas[rng.randrange(dim)] = 1 + end = tuple(s + d for s, d in zip(start, deltas, strict=True)) + allocations.append( + Allocation(id=i, size=rng.randint(1, 9), start=start, end=end) + ) + allocations = tuple(allocations) + assert _vector_pressure(allocations) == brute_max_weight_antichain(allocations) diff --git a/tests/unit/test_dump.py b/tests/unit/test_dump.py index c7f9922..7f1f27b 100644 --- a/tests/unit/test_dump.py +++ b/tests/unit/test_dump.py @@ -105,3 +105,32 @@ def test_dump_system_round_trip(tmp_path: Path) -> None: for file_path in written: loaded = load_allocation(file_path) assert len(loaded.allocations) == 2 + + +def test_dump_vector_time_joins_components_with_colons(tmp_path: Path) -> None: + pool = Pool( + id="p0", + allocations=(Allocation(id="a", size=4, start=(3, 0), end=(5, 2)),), + ) + (file_path,) = dump_allocation(pool, tmp_path / "problem.csv") + assert file_path.read_text() == "id,lower,upper,size\na,3:0,5:2,4\n" + + +def test_dump_load_round_trip_preserves_vector_time(tmp_path: Path) -> None: + pool = Pool( + id="p0", + allocations=( + Allocation(id="a", size=4, start=(3, 0), end=(5, 2)), + Allocation(id="b", size=8, start=(0, 1), end=(2, 4)), + ), + ) + (file_path,) = dump_allocation(pool, tmp_path / "problem.csv") + loaded = load_allocation(file_path) + original = [(str(a.id), a.start, a.end, a.size) for a in pool.allocations] + restored = [(str(a.id), a.start, a.end, a.size) for a in loaded.allocations] + assert restored == original + + +def test_dump_scalar_files_stay_minimalloc_format(tmp_path: Path) -> None: + (file_path,) = dump_allocation(make_pool(), tmp_path / "problem.csv") + assert ":" not in file_path.read_text() diff --git a/tests/unit/test_linearize.py b/tests/unit/test_linearize.py new file mode 100644 index 0000000..6336496 --- /dev/null +++ b/tests/unit/test_linearize.py @@ -0,0 +1,117 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import random + +import pytest +from omnimalloc import try_linearize +from omnimalloc._cpp import compute_temporal_overlaps +from omnimalloc.allocators.supermalloc import SupermallocAllocator +from omnimalloc.primitives import Allocation +from omnimalloc.primitives.pool import Pool +from omnimalloc.validate import validate_allocation + + +def _overlap_map(allocations: tuple[Allocation, ...]) -> dict[object, set[object]]: + overlaps = compute_temporal_overlaps(allocations) + return {a.id: set(overlaps.get(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) + 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].overlaps_temporally(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)), + ) + assert try_linearize(allocations) is None + + +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), + Allocation(id="y", size=32, start=(2, 1), end=(3, 2)), + ) + linearized = try_linearize(allocations) + assert linearized is not None + assert [(a.id, a.size, a.offset) for a in linearized] == [ + ("x", 64, 128), + ("y", 32, 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_unlocks_supermalloc() -> 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=100, start=(2, 1), end=(4, 3)), + Allocation(id=4, size=50, start=(4, 3), end=(5, 4)), + ) + linearized = try_linearize(allocations) + assert linearized is not None + placed = SupermallocAllocator().allocate(linearized) + assert validate_allocation(Pool(id="p", allocations=placed)) diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py index 871777f..e033ece 100644 --- a/tests/unit/test_validate.py +++ b/tests/unit/test_validate.py @@ -244,3 +244,25 @@ def test_validate_complex_hierarchy_with_error() -> None: system = System(id=1, memories=(memory1, memory2)) assert validate_allocation(system, raise_on_error=False) is False + + +def test_validate_pool_vector_conflict_at_same_offset() -> None: + alloc1 = Allocation(id=1, size=100, start=(0, 5), end=(1, 6), offset=0) + alloc2 = Allocation(id=2, size=100, start=(2, 0), end=(3, 1), offset=0) + pool = Pool(id=1, allocations=(alloc1, alloc2)) + assert validate_allocation(pool, raise_on_error=False) is False + + +def test_validate_pool_vector_ordered_at_same_offset() -> None: + alloc1 = Allocation(id=1, size=100, start=(0, 0), end=(2, 1), offset=0) + alloc2 = Allocation(id=2, size=100, start=(2, 1), end=(3, 2), offset=0) + pool = Pool(id=1, allocations=(alloc1, alloc2)) + assert validate_allocation(pool, raise_on_error=False) is True + + +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) diff --git a/tests/unit/test_visualize.py b/tests/unit/test_visualize.py index b082916..cc436ab 100644 --- a/tests/unit/test_visualize.py +++ b/tests/unit/test_visualize.py @@ -309,3 +309,51 @@ def test_byte_unit_picks_largest_unit_that_keeps_value_above_one() -> None: def test_format_bytes_does_not_collapse_small_values_to_zero() -> None: assert _format_bytes(3000) == "2.9KB" assert _format_bytes(200) == "200.0B" + + +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" + result = plot_allocation(memory, file_path=output_path) + assert result == output_path + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +def test_visualize_empty_pool(artifacts_dir: Path) -> None: + empty = Pool(id=1, allocations=(), offset=0) + filled = Pool( + id=2, + allocations=(Allocation(id=1, size=100, start=0, end=4, offset=0),), + offset=100, + ) + memory = Memory(id="mem", pools=(empty, filled), size=500) + + output_path = artifacts_dir / "test_empty_pool.pdf" + result = plot_allocation(memory, file_path=output_path) + assert result == output_path + assert output_path.exists() + + +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" + result = plot_allocation(pool, file_path=output_path, canonicalize=True) + assert result == output_path + assert output_path.exists() + assert output_path.stat().st_size > 0