diff --git a/CMakeLists.txt b/CMakeLists.txt index 0e88b43..957ff09 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,11 +49,12 @@ nanobind_add_module( src/cpp/allocators/supermalloc/partition.cpp src/cpp/allocators/tabu_search.cpp src/cpp/allocators/telamalloc.cpp + src/cpp/analysis/antichain.cpp + src/cpp/analysis/closure.cpp + src/cpp/analysis/conflicts.cpp + src/cpp/analysis/linearize.cpp + src/cpp/analysis/placement.cpp src/cpp/primitives/allocation.cpp - src/cpp/primitives/antichain.cpp - src/cpp/primitives/closure.cpp - src/cpp/primitives/linearize.cpp - src/cpp/primitives/placement.cpp src/cpp/bindings.cpp) target_include_directories(_cpp PRIVATE src/cpp) diff --git a/scripts/benchmark_pressure.py b/scripts/benchmark_pressure.py index 95e8f34..a228279 100644 --- a/scripts/benchmark_pressure.py +++ b/scripts/benchmark_pressure.py @@ -46,10 +46,7 @@ import matplotlib.pyplot as plt from omnimalloc import OmniAllocator -from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource -from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource -from omnimalloc.benchmark.timer import Timer -from omnimalloc.primitives import ( +from omnimalloc.analysis import ( get_closure_pressure, get_per_allocation_closure_pressure, get_per_allocation_placement_pressure, @@ -57,6 +54,9 @@ get_placement_pressure, get_pressure, ) +from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource +from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource +from omnimalloc.benchmark.timer import Timer if TYPE_CHECKING: from collections.abc import Callable diff --git a/scripts/generate_readme_assets.py b/scripts/generate_readme_assets.py index 5545d83..afe62f2 100644 --- a/scripts/generate_readme_assets.py +++ b/scripts/generate_readme_assets.py @@ -41,10 +41,10 @@ from matplotlib.patches import Rectangle from matplotlib.ticker import FuncFormatter, MultipleLocator from omnimalloc import run_allocation, validate_allocation -from omnimalloc.allocators import DEFAULT_TIMEOUT, BaseAllocator +from omnimalloc.allocators import BaseAllocator from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.timer import Timer -from omnimalloc.common.units import MB +from omnimalloc.common.constants import DEFAULT_TIMEOUT, MB if TYPE_CHECKING: from matplotlib.axes import Axes diff --git a/scripts/stress_omni.py b/scripts/stress_omni.py index 67256ff..7a2bb38 100644 --- a/scripts/stress_omni.py +++ b/scripts/stress_omni.py @@ -32,9 +32,9 @@ import matplotlib.pyplot as plt from omnimalloc.allocators import OmniAllocator +from omnimalloc.analysis.pressure import get_pressure from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.benchmark.timer import Timer -from omnimalloc.primitives.pressure import get_pressure if TYPE_CHECKING: from matplotlib.axes import Axes diff --git a/src/cpp/allocators/first_fit.cpp b/src/cpp/allocators/first_fit.cpp index 982bf33..7df4d01 100644 --- a/src/cpp/allocators/first_fit.cpp +++ b/src/cpp/allocators/first_fit.cpp @@ -5,7 +5,6 @@ #include "first_fit.hpp" #include -#include #include #include #include @@ -13,7 +12,6 @@ #include #include #include -#include #include #include "common/parallel.hpp" @@ -31,111 +29,6 @@ void require_scalar_time(const std::vector& allocations, namespace { -// Sweep line over the single timeline; valid only for all-scalar input. -OverlapIndices scalar_overlap_indices( - const std::vector& allocations) { - std::vector> events; - events.reserve(allocations.size() * 2); - for (size_t i = 0; i < allocations.size(); ++i) { - events.emplace_back(allocations[i].start(), true, i); - events.emplace_back(allocations[i].end(), false, i); - } - - // Sort events by time; ends sort before starts at equal times, matching the - // half-open interval semantics of Allocation::overlaps_temporally - std::sort(events.begin(), events.end()); - - OverlapIndices indices(allocations.size()); - std::vector active; - for (const auto& [time, is_start, idx] : events) { - if (is_start) { - // Current allocation overlaps with all currently active allocations - for (size_t active_idx : active) { - indices[idx].push_back(active_idx); - indices[active_idx].push_back(idx); - } - active.push_back(idx); - } else { - active.erase(std::find(active.begin(), active.end(), idx)); - } - } - - return indices; -} - -// The pruned pairwise sweep itself lives in primitives/clock_rows.hpp -// (ConflictSweep), shared with the exact per-allocation pressure kernels. -ConflictSweep build_conflict_sweep(const std::vector& allocations) { - const ClockSpans spans = gather_clock_spans(allocations); - return {spans.starts, spans.ends, spans.dim}; -} - -} // namespace - -CsrAdjacency build_conflict_adjacency( - const std::vector& allocations) { - const ConflictSweep sweep = build_conflict_sweep(allocations); - return sweep.adjacency(parallel_threads(sweep.count())); -} - -namespace { - -// Pairwise happens-before adjacency; the only option for vector clocks -OverlapIndices vector_overlap_indices( - const std::vector& allocations) { - const CsrAdjacency adj = build_conflict_adjacency(allocations); - OverlapIndices indices(allocations.size()); - for (size_t i = 0; i < allocations.size(); ++i) { - indices[i].assign(adj.neighbors.begin() + adj.offsets[i], - adj.neighbors.begin() + adj.offsets[i + 1]); - } - return indices; -} - -TemporalOverlaps overlaps_from_indices( - const std::vector& allocations, const OverlapIndices& indices) { - TemporalOverlaps overlaps; - for (size_t i = 0; i < allocations.size(); ++i) { - for (size_t j : indices[i]) { - overlaps[allocations[i].id()].insert(allocations[j].id()); - } - } - return overlaps; -} - -} // namespace - -OverlapIndices compute_overlap_indices( - const std::vector& allocations) { - const bool all_scalar = - std::ranges::all_of(allocations, &Allocation::is_scalar_time); - return all_scalar ? scalar_overlap_indices(allocations) - : vector_overlap_indices(allocations); -} - -TemporalOverlaps compute_temporal_overlaps( - const std::vector& allocations) { - return overlaps_from_indices(allocations, - compute_overlap_indices(allocations)); -} - -std::vector compute_conflict_degrees( - const std::vector& allocations) { - const ConflictSweep sweep = build_conflict_sweep(allocations); - std::vector> degree(sweep.count()); - sweep.for_each_pair(parallel_threads(sweep.count()), [&](size_t i, size_t j) { - degree[i].fetch_add(1, std::memory_order_relaxed); - degree[j].fetch_add(1, std::memory_order_relaxed); - }); - std::vector degrees(sweep.count()); - for (size_t i = 0; i < sweep.count(); ++i) { - degrees[i] = degree[i].load(std::memory_order_relaxed); - } - return degrees; -} - -namespace { - // Occupied (offset, end) span of a placed allocation, matching the span // shape that `first_fit_offset` consumes using Interval = std::pair; diff --git a/src/cpp/allocators/first_fit.hpp b/src/cpp/allocators/first_fit.hpp index 8e3cb2a..0f2d92f 100644 --- a/src/cpp/allocators/first_fit.hpp +++ b/src/cpp/allocators/first_fit.hpp @@ -6,39 +6,19 @@ #include #include -#include -#include #include #include +#include "analysis/conflicts.hpp" #include "primitives/allocation.hpp" -#include "primitives/clock_rows.hpp" -#include "primitives/id_type.hpp" namespace omnimalloc { -// Temporal overlap adjacency: allocation id -> ids of overlapping allocations -using TemporalOverlaps = - std::unordered_map, - IdTypeHash>; - -// Index-based temporal adjacency: position i -> positions overlapping i -using OverlapIndices = std::vector>; - // Throw unless every allocation has scalar (interval) lifetimes; `who` names // the rejecting entry point in the message. void require_scalar_time(const std::vector& allocations, const char* who); -// Map each allocation id to the ids of temporally overlapping allocations -[[nodiscard]] TemporalOverlaps compute_temporal_overlaps( - const std::vector& allocations); - -// Map each allocation index to the indices of temporally overlapping -// allocations -[[nodiscard]] OverlapIndices compute_overlap_indices( - const std::vector& allocations); - // Occupied (offset, end) spans of the already-placed neighbors of one // allocation, sorted by offset so the gap scans can go left-to-right void gather_spans(const std::vector& neighbors, @@ -50,16 +30,6 @@ void gather_spans(const std::vector& neighbors, [[nodiscard]] int64_t first_fit_offset( int64_t size, const std::vector>& spans); -// 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); - -// Pairwise happens-before conflict adjacency over the pruned vector sweep; -// handles scalar and vector-clock lifetimes alike. -[[nodiscard]] CsrAdjacency build_conflict_adjacency( - const std::vector& allocations); - // Offsets (aligned with `allocations`) and peak of the winning placement. struct PortfolioPlacement { std::vector offsets; diff --git a/src/cpp/allocators/omni.cpp b/src/cpp/allocators/omni.cpp index 5c2c226..3a03472 100644 --- a/src/cpp/allocators/omni.cpp +++ b/src/cpp/allocators/omni.cpp @@ -7,8 +7,8 @@ #include #include +#include "analysis/linearize.hpp" #include "first_fit.hpp" -#include "primitives/linearize.hpp" namespace omnimalloc { @@ -28,7 +28,7 @@ std::vector OmniAllocator::allocate( std::ranges::all_of(allocations, &Allocation::is_scalar_time); std::optional> surrogates; if (!all_scalar) { - surrogates = try_linearize(allocations, kDefaultWorkBudget); + surrogates = try_linearize(allocations, linearize_budget_); } const std::vector& problem = surrogates.has_value() ? *surrogates : allocations; @@ -49,9 +49,8 @@ std::vector OmniAllocator::allocate( namespace std { size_t hash::operator()( - const omnimalloc::OmniAllocator&) const noexcept { - // Stateless class - all instances are equal, use constant hash - return 0x9e3779b9; // arbitrary constant + const omnimalloc::OmniAllocator& allocator) const noexcept { + return hash>{}(allocator.linearize_budget()); } } // namespace std diff --git a/src/cpp/allocators/omni.hpp b/src/cpp/allocators/omni.hpp index c80d0eb..5395a3e 100644 --- a/src/cpp/allocators/omni.hpp +++ b/src/cpp/allocators/omni.hpp @@ -4,6 +4,8 @@ #pragma once +#include +#include #include #include "primitives/allocation.hpp" @@ -12,17 +14,26 @@ namespace omnimalloc { // Generalized greedy-portfolio allocator for scalar and vector-clock // lifetimes: linearizes vector time to surrogate scalars when the -// happens-before order allows (budgeted), otherwise places truthfully on the -// vector conflict graph. Either way the winning first-fit order of the -// 7-order portfolio decides the offsets. +// happens-before order allows (bounded by `linearize_budget`; nullopt means +// unbounded), otherwise places truthfully on the vector conflict graph. +// Either way the winning first-fit order of the 7-order portfolio decides +// the offsets. class OmniAllocator { public: - OmniAllocator() = default; + explicit OmniAllocator(std::optional linearize_budget) + : linearize_budget_(linearize_budget) {} std::vector allocate( const std::vector& allocations) const; + [[nodiscard]] std::optional linearize_budget() const noexcept { + return linearize_budget_; + } + bool operator==(const OmniAllocator&) const noexcept = default; + + private: + std::optional linearize_budget_; }; } // namespace omnimalloc diff --git a/src/cpp/allocators/simulated_annealing.cpp b/src/cpp/allocators/simulated_annealing.cpp index 1da1e6c..9e4d47a 100644 --- a/src/cpp/allocators/simulated_annealing.cpp +++ b/src/cpp/allocators/simulated_annealing.cpp @@ -8,6 +8,7 @@ #include #include +#include "common/deadline.hpp" #include "first_fit.hpp" #include "local_search.hpp" diff --git a/src/cpp/allocators/simulated_annealing.hpp b/src/cpp/allocators/simulated_annealing.hpp index 405f3fd..62ab5fe 100644 --- a/src/cpp/allocators/simulated_annealing.hpp +++ b/src/cpp/allocators/simulated_annealing.hpp @@ -5,25 +5,27 @@ #pragma once #include +#include #include -#include "allocators/defaults.hpp" #include "primitives/allocation.hpp" namespace omnimalloc { // Cooling schedule and iteration budget for `SimulatedAnnealingAllocator`. +// Defaults live in the Python `SimulatedAnnealingConfig` dataclass; every +// field must be set explicitly. struct SimulatedAnnealingConfig { - uint64_t seed = 42; - int max_iterations = 3000; + uint64_t seed{}; + int max_iterations{}; // Percent memory worsening accepted with probability 1/e at iteration 0; // decays geometrically by `cooling_rate` every iteration. - double initial_temperature = 3.0; - double cooling_rate = 0.998; - // Wall-clock budget checked once per iteration; 0 disables it. Each + double initial_temperature{}; + double cooling_rate{}; + // Wall-clock budget checked once per iteration; nullopt disables it. Each // iteration re-evaluates a full O(n) placement, so `max_iterations` alone // does not bound runtime as `allocations` grows - this does. - double timeout = kDefaultTimeout; + std::optional timeout; }; // Simulated annealing over first-fit placement orders. Each iteration swaps a @@ -35,8 +37,7 @@ struct SimulatedAnnealingConfig { // equivalent Python-orchestrated local search. class SimulatedAnnealingAllocator { public: - explicit SimulatedAnnealingAllocator( - SimulatedAnnealingConfig config = SimulatedAnnealingConfig{}); + explicit SimulatedAnnealingAllocator(SimulatedAnnealingConfig config); [[nodiscard]] std::vector allocate( const std::vector& allocations) const; diff --git a/src/cpp/allocators/supermalloc/partition.cpp b/src/cpp/allocators/supermalloc/partition.cpp index 9e4a9b9..384b82c 100644 --- a/src/cpp/allocators/supermalloc/partition.cpp +++ b/src/cpp/allocators/supermalloc/partition.cpp @@ -18,8 +18,8 @@ #include #include -#include "allocators/defaults.hpp" #include "allocators/first_fit.hpp" +#include "common/deadline.hpp" #if defined(_WIN32) #ifndef NOMINMAX @@ -939,18 +939,26 @@ void run_workers(const std::function& work, int num_threads, if (error) std::rethrow_exception(error); } -// Unlike the allocators, `greedy_many`/`solve_many` treat a non-positive -// timeout as an already-expired budget rather than a disabled one, so a -// missing deadline collapses to "now". -std::chrono::steady_clock::time_point compute_deadline(double timeout) { - return make_deadline(timeout).value_or(std::chrono::steady_clock::now()); +// A nullopt timeout disables the deadline entirely, while a non-positive +// one is an already-expired budget, so a caller whose wall-clock budget ran +// out still gets the incumbent back. The expired case is resolved here +// because make_deadline requires a positive budget. +std::chrono::steady_clock::time_point compute_deadline( + std::optional timeout) { + if (!timeout) { + return std::chrono::steady_clock::time_point::max(); + } + if (*timeout <= 0.0) { + return std::chrono::steady_clock::now(); + } + return *make_deadline(timeout); } } // namespace Solution greedy_many(const Partition& partition, - const std::vector& heuristics, double timeout, - int num_threads) { + const std::vector& heuristics, + std::optional timeout, int num_threads) { if (heuristics.empty()) { throw std::invalid_argument("greedy_many requires at least one heuristic"); } @@ -980,7 +988,8 @@ Solution greedy_many(const Partition& partition, } std::optional solve_many(const std::vector& partitions, - int64_t node_limit, double timeout, + int64_t node_limit, + std::optional timeout, int64_t best_bound, SearchOptions options, int num_threads) { if (partitions.empty()) return std::nullopt; diff --git a/src/cpp/allocators/supermalloc/partition.hpp b/src/cpp/allocators/supermalloc/partition.hpp index ce24dd6..25a9ba6 100644 --- a/src/cpp/allocators/supermalloc/partition.hpp +++ b/src/cpp/allocators/supermalloc/partition.hpp @@ -210,12 +210,13 @@ class Partition { // Run `partition.greedy_pack` under each heuristic ordering across // `num_threads` and return the best packing; ties go to the lowest heuristic // index for determinism. Heuristics claimed after `timeout` elapse are -// skipped, except the first, so a packing always exists. Throws -// std::invalid_argument when `heuristics` is empty or contains an unknown -// sort key. +// skipped, except the first, so a packing always exists (nullopt disables +// the deadline). Throws std::invalid_argument when `heuristics` is empty or +// contains an unknown sort key. [[nodiscard]] Solution greedy_many(const Partition& partition, const std::vector& heuristics, - double timeout, int num_threads); + std::optional timeout, + int num_threads); // Run `partitions` (typically the same problem under different heuristic // orderings) as an independent-search portfolio across `num_threads`, sharing @@ -224,6 +225,7 @@ class Partition { // solution found, or nullopt if none beats `best_bound`. [[nodiscard]] std::optional solve_many( const std::vector& partitions, int64_t node_limit, - double timeout, int64_t best_bound, SearchOptions options, int num_threads); + std::optional timeout, int64_t best_bound, SearchOptions options, + int num_threads); } // namespace omnimalloc diff --git a/src/cpp/allocators/tabu_search.cpp b/src/cpp/allocators/tabu_search.cpp index 725b9fc..afea707 100644 --- a/src/cpp/allocators/tabu_search.cpp +++ b/src/cpp/allocators/tabu_search.cpp @@ -8,6 +8,7 @@ #include #include +#include "common/deadline.hpp" #include "first_fit.hpp" #include "local_search.hpp" diff --git a/src/cpp/allocators/tabu_search.hpp b/src/cpp/allocators/tabu_search.hpp index b0e26b6..91968d7 100644 --- a/src/cpp/allocators/tabu_search.hpp +++ b/src/cpp/allocators/tabu_search.hpp @@ -5,25 +5,26 @@ #pragma once #include +#include #include -#include "allocators/defaults.hpp" #include "primitives/allocation.hpp" namespace omnimalloc { // Neighborhood size, iteration budget, and tabu memory for -// `TabuSearchAllocator`. +// `TabuSearchAllocator`. Defaults live in the Python `TabuSearchConfig` +// dataclass; every field must be set explicitly. struct TabuSearchConfig { - uint64_t seed = 42; - int max_iterations = 500; - int neighborhood_size = 20; // candidate swaps sampled per iteration - int tabu_tenure = 15; // iterations a reversed swap stays forbidden - // Wall-clock budget checked once per iteration; 0 disables it. Each + uint64_t seed{}; + int max_iterations{}; + int neighborhood_size{}; // candidate swaps sampled per iteration + int tabu_tenure{}; // iterations a reversed swap stays forbidden + // Wall-clock budget checked once per iteration; nullopt disables it. Each // iteration evaluates `neighborhood_size` full O(n) placements, so // `max_iterations` alone does not bound runtime as `allocations` grows - // this does. - double timeout = kDefaultTimeout; + std::optional timeout; }; // Tabu search over first-fit placement orders. Each iteration samples @@ -37,7 +38,7 @@ struct TabuSearchConfig { // `SimulatedAnnealingAllocator`: no Python round trip per candidate. class TabuSearchAllocator { public: - explicit TabuSearchAllocator(TabuSearchConfig config = TabuSearchConfig{}); + explicit TabuSearchAllocator(TabuSearchConfig config); [[nodiscard]] std::vector allocate( const std::vector& allocations) const; diff --git a/src/cpp/allocators/telamalloc.cpp b/src/cpp/allocators/telamalloc.cpp index bbd634a..15f46ff 100644 --- a/src/cpp/allocators/telamalloc.cpp +++ b/src/cpp/allocators/telamalloc.cpp @@ -14,6 +14,7 @@ #include #include +#include "common/deadline.hpp" #include "first_fit.hpp" namespace omnimalloc { diff --git a/src/cpp/allocators/telamalloc.hpp b/src/cpp/allocators/telamalloc.hpp index d448360..0cccb8c 100644 --- a/src/cpp/allocators/telamalloc.hpp +++ b/src/cpp/allocators/telamalloc.hpp @@ -5,24 +5,25 @@ #pragma once #include +#include #include -#include "allocators/defaults.hpp" #include "primitives/allocation.hpp" namespace omnimalloc { -// Search budgets for `TelamallocAllocator`. +// Search budgets for `TelamallocAllocator`. Defaults live in the Python +// `TelamallocConfig` dataclass; every field must be set explicitly. struct TelamallocConfig { // Seeds the random-walk step of the conflict repair (see `pack_phase`); // results are deterministic for a fixed seed. - uint64_t seed = 42; + uint64_t seed{}; // Eviction (backtrack) budget per capacity attempt; an attempt that // exhausts it reports the capacity as unreachable. - int max_backtracks = 10000; - // Wall-clock budget for the whole allocate(); 0 disables it, leaving the - // per-attempt `max_backtracks` as the only bound. - double timeout = kDefaultTimeout; + int max_backtracks{}; + // Wall-clock budget for the whole allocate(); nullopt disables it, leaving + // the per-attempt `max_backtracks` as the only bound. + std::optional timeout; }; // TelaMalloc-style allocator after Maas et al., ASPLOS 2023 ("TelaMalloc: @@ -41,7 +42,7 @@ struct TelamallocConfig { // deterministic min-conflict search can fall into. class TelamallocAllocator { public: - explicit TelamallocAllocator(TelamallocConfig config = TelamallocConfig{}); + explicit TelamallocAllocator(TelamallocConfig config); [[nodiscard]] std::vector allocate( const std::vector& allocations) const; diff --git a/src/cpp/primitives/antichain.cpp b/src/cpp/analysis/antichain.cpp similarity index 95% rename from src/cpp/primitives/antichain.cpp rename to src/cpp/analysis/antichain.cpp index 91d078f..85aec19 100644 --- a/src/cpp/primitives/antichain.cpp +++ b/src/cpp/analysis/antichain.cpp @@ -12,7 +12,7 @@ #include #include -#include "clock_rows.hpp" +#include "clock.hpp" #include "common/parallel.hpp" #include "linearize.hpp" @@ -146,12 +146,14 @@ class Dinic { // Max-weight antichain over explicit lifetime rows via the min-flow // construction above; `max_threads` caps the parallel dominance-edge pass // so the nested per-allocation solves stay serial inside a parallel outer -// loop. A finite `work_budget` bounds the dominance pass and the network -// it feeds; past it, throw instead of stalling or exhausting memory. +// loop. A set `work_budget` (nullopt means unbounded) bounds the dominance +// pass and the network it feeds; past it, throw instead of stalling or +// exhausting memory. int64_t max_antichain(const std::vector>& start_rows, const std::vector>& end_rows, const std::vector& weights, size_t d, - unsigned max_threads, uint64_t work_budget) { + unsigned max_threads, + std::optional work_budget) { const size_t g = weights.size(); if (g == 0) { return 0; @@ -160,7 +162,7 @@ int64_t max_antichain(const std::vector>& start_rows, const DedupedRows ends = dedupe_rows(end_rows, d); const size_t k = starts.count(); const size_t m = ends.count(); - if (static_cast(k) * m * d > work_budget) { + if (work_budget && static_cast(k) * m * d > *work_budget) { throw std::runtime_error( "Antichain flow work exceeds work_budget; rerun without a budget " "for the unbounded exact query"); @@ -233,7 +235,7 @@ int64_t max_antichain(const std::vector>& start_rows, } // namespace int64_t antichain_pressure(const std::vector& allocations, - uint64_t work_budget) { + std::optional work_budget) { if (allocations.empty()) { return 0; } @@ -259,7 +261,8 @@ int64_t antichain_pressure(const std::vector& allocations, } std::vector per_allocation_antichain_pressure( - const std::vector& allocations, uint64_t work_budget) { + const std::vector& allocations, + std::optional work_budget) { const size_t n = allocations.size(); if (n == 0) { return {}; diff --git a/src/cpp/primitives/antichain.hpp b/src/cpp/analysis/antichain.hpp similarity index 73% rename from src/cpp/primitives/antichain.hpp rename to src/cpp/analysis/antichain.hpp index 272b037..686d4b6 100644 --- a/src/cpp/primitives/antichain.hpp +++ b/src/cpp/analysis/antichain.hpp @@ -5,10 +5,10 @@ #pragma once #include +#include #include -#include "allocation.hpp" -#include "linearize.hpp" +#include "primitives/allocation.hpp" namespace omnimalloc { @@ -18,13 +18,14 @@ namespace omnimalloc { // ranges. Interval orders resolve through linearization and the scalar // sweep; genuinely partial orders through min flow with per-allocation // lower bounds. Built to certify allocator optimality at small and medium -// scale, not for the 10k+ hot path. A finite `work_budget` bounds both the -// linearize attempt and the flow construction (dominance-counting work, -// O(k * m * d) over deduplicated clock rows); the flow path throws instead -// of stalling or exhausting memory once the budget is exceeded. +// scale, not for the 10k+ hot path. A set `work_budget` (nullopt means +// unbounded) bounds both the linearize attempt and the flow construction +// (dominance-counting work, O(k * m * d) over deduplicated clock rows); the +// flow path throws instead of stalling or exhausting memory once the budget +// is exceeded. [[nodiscard]] int64_t antichain_pressure( const std::vector& allocations, - uint64_t work_budget = kNoWorkBudget); + std::optional work_budget); // Exact per-allocation pressure, aligned with `allocations`: for each // allocation the max-weight antichain through it, i.e. the heaviest @@ -34,10 +35,10 @@ namespace omnimalloc { // Interval orders resolve through one linearized window sweep; genuinely // partial orders solve one pinned min flow per distinct lifetime over its // conflict neighborhood — built for certification, not the 10k+ hot path. -// A finite `work_budget` bounds the linearize attempt and each pinned flow +// A set `work_budget` bounds the linearize attempt and each pinned flow // (see antichain_pressure); the flow path throws once it is exceeded. [[nodiscard]] std::vector per_allocation_antichain_pressure( const std::vector& allocations, - uint64_t work_budget = kNoWorkBudget); + std::optional work_budget); } // namespace omnimalloc diff --git a/src/cpp/primitives/clock_rows.hpp b/src/cpp/analysis/clock.hpp similarity index 95% rename from src/cpp/primitives/clock_rows.hpp rename to src/cpp/analysis/clock.hpp index 1fd2be7..aed31cd 100644 --- a/src/cpp/primitives/clock_rows.hpp +++ b/src/cpp/analysis/clock.hpp @@ -17,10 +17,10 @@ #include #include -#include "allocation.hpp" #include "common/parallel.hpp" +#include "primitives/allocation.hpp" -// Shared clock-row utilities for the exact vector-time primitives +// Shared clock-row utilities for the exact vector-time analyses // (linearize, antichain, closure) and the conflict-graph consumers: row // deduplication, componentwise dominance, lifetime grouping, the scalar // sweep peaks, and the pruned pairwise conflict sweep. @@ -300,6 +300,19 @@ class ConflictSweep { size_t count() const noexcept { return n_; } + // Work the pruned pair sweep will perform, in component comparisons: row + // a scans until the ascending min-starts reach its cutoff, so each scan + // length falls out of one binary search and no pair is touched. + [[nodiscard]] uint64_t sweep_work() const noexcept { + uint64_t pairs = 0; + for (size_t a = 0; a < n_; ++a) { + const auto begin = min_start_.begin() + static_cast(a) + 1; + pairs += static_cast( + std::lower_bound(begin, min_start_.end(), cutoff_[a]) - begin); + } + return pairs * dim_; + } + // Calls `on_pair(i, j)` once per conflicting pair, in input indices; // `on_pair` must be thread-safe when num_threads > 1. template diff --git a/src/cpp/primitives/closure.cpp b/src/cpp/analysis/closure.cpp similarity index 99% rename from src/cpp/primitives/closure.cpp rename to src/cpp/analysis/closure.cpp index e01eddc..9b4c38f 100644 --- a/src/cpp/primitives/closure.cpp +++ b/src/cpp/analysis/closure.cpp @@ -10,7 +10,7 @@ #include #include -#include "clock_rows.hpp" +#include "clock.hpp" #include "common/parallel.hpp" // Join-closure enumeration of the cut lattice. Cuts live in a flat row diff --git a/src/cpp/primitives/closure.hpp b/src/cpp/analysis/closure.hpp similarity index 76% rename from src/cpp/primitives/closure.hpp rename to src/cpp/analysis/closure.hpp index a3cd902..0078579 100644 --- a/src/cpp/primitives/closure.hpp +++ b/src/cpp/analysis/closure.hpp @@ -9,15 +9,10 @@ #include #include -#include "allocation.hpp" +#include "primitives/allocation.hpp" namespace omnimalloc { -// Default join-closure enumeration cap, so huge vector-clock instances -// fail fast instead of exhausting memory. Exported to Python as -// DEFAULT_CLOSURE_CAP. -inline constexpr size_t kDefaultClosureCap = 1 << 14; - // Exact realizable peak: the maximum total size jointly live at a single // cut, scored over the join-closure of the birth clocks (the minimal cut // where a candidate set is jointly live is the join of its births, and @@ -26,8 +21,7 @@ inline constexpr size_t kDefaultClosureCap = 1 << 14; // strictly below antichain_pressure; both soundly lower-bound any // placement's peak. nullopt once the closure exceeds `closure_cap`. [[nodiscard]] std::optional closure_pressure( - const std::vector& allocations, - size_t closure_cap = kDefaultClosureCap); + const std::vector& allocations, size_t closure_cap); // Exact realizable peak while each allocation is live, aligned with // `allocations`: the maximum total size at any join-closure cut where the @@ -37,6 +31,6 @@ inline constexpr size_t kDefaultClosureCap = 1 << 14; // equals closure_pressure. nullopt once the closure exceeds `closure_cap`. [[nodiscard]] std::optional> per_allocation_closure_pressure(const std::vector& allocations, - size_t closure_cap = kDefaultClosureCap); + size_t closure_cap); } // namespace omnimalloc diff --git a/src/cpp/analysis/conflicts.cpp b/src/cpp/analysis/conflicts.cpp new file mode 100644 index 0000000..5885b39 --- /dev/null +++ b/src/cpp/analysis/conflicts.cpp @@ -0,0 +1,128 @@ +// +// SPDX-License-Identifier: Apache-2.0 +// + +#include "conflicts.hpp" + +#include +#include +#include +#include + +#include "common/parallel.hpp" + +namespace omnimalloc { + +namespace { + +// Sweep line over the single timeline; valid only for all-scalar input. +OverlapIndices scalar_overlap_indices( + const std::vector& allocations) { + std::vector> events; + events.reserve(allocations.size() * 2); + for (size_t i = 0; i < allocations.size(); ++i) { + events.emplace_back(allocations[i].start(), true, i); + events.emplace_back(allocations[i].end(), false, i); + } + + // Sort events by time; ends sort before starts at equal times, matching the + // half-open interval semantics of Allocation::overlaps_temporally + std::sort(events.begin(), events.end()); + + OverlapIndices indices(allocations.size()); + std::vector active; + for (const auto& [time, is_start, idx] : events) { + if (is_start) { + // Current allocation overlaps with all currently active allocations + for (size_t active_idx : active) { + indices[idx].push_back(active_idx); + indices[active_idx].push_back(idx); + } + active.push_back(idx); + } else { + active.erase(std::find(active.begin(), active.end(), idx)); + } + } + + return indices; +} + +// The pruned pairwise sweep itself lives in analysis/clock.hpp +// (ConflictSweep), shared with the exact per-allocation pressure kernels. +ConflictSweep build_conflict_sweep(const std::vector& allocations) { + const ClockSpans spans = gather_clock_spans(allocations); + return {spans.starts, spans.ends, spans.dim}; +} + +// Pairwise happens-before adjacency; the only option for vector clocks +OverlapIndices vector_overlap_indices( + const std::vector& allocations) { + const CsrAdjacency adj = build_conflict_adjacency(allocations); + OverlapIndices indices(allocations.size()); + for (size_t i = 0; i < allocations.size(); ++i) { + indices[i].assign(adj.neighbors.begin() + adj.offsets[i], + adj.neighbors.begin() + adj.offsets[i + 1]); + } + return indices; +} + +} // namespace + +CsrAdjacency build_conflict_adjacency( + const std::vector& allocations) { + const ConflictSweep sweep = build_conflict_sweep(allocations); + return sweep.adjacency(parallel_threads(sweep.count())); +} + +TemporalOverlaps overlaps_from_indices( + const std::vector& allocations, const OverlapIndices& indices) { + TemporalOverlaps overlaps; + for (size_t i = 0; i < allocations.size(); ++i) { + for (size_t j : indices[i]) { + overlaps[allocations[i].id()].insert(allocations[j].id()); + } + } + return overlaps; +} + +OverlapIndices compute_overlap_indices( + const std::vector& allocations) { + const bool all_scalar = + std::ranges::all_of(allocations, &Allocation::is_scalar_time); + return all_scalar ? scalar_overlap_indices(allocations) + : vector_overlap_indices(allocations); +} + +std::optional compute_temporal_overlaps( + const std::vector& allocations, + std::optional work_budget) { + // On scalar input the sweep work equals the exact overlap-pair count, the + // scalar sweep line's dominant cost, so one measure bounds both paths. + if (work_budget && + build_conflict_sweep(allocations).sweep_work() > *work_budget) { + return std::nullopt; + } + return overlaps_from_indices(allocations, + compute_overlap_indices(allocations)); +} + +std::optional> compute_conflict_degrees( + const std::vector& allocations, + std::optional work_budget) { + const ConflictSweep sweep = build_conflict_sweep(allocations); + if (work_budget && sweep.sweep_work() > *work_budget) { + return std::nullopt; + } + std::vector> degree(sweep.count()); + sweep.for_each_pair(parallel_threads(sweep.count()), [&](size_t i, size_t j) { + degree[i].fetch_add(1, std::memory_order_relaxed); + degree[j].fetch_add(1, std::memory_order_relaxed); + }); + std::vector degrees(sweep.count()); + for (size_t i = 0; i < sweep.count(); ++i) { + degrees[i] = degree[i].load(std::memory_order_relaxed); + } + return degrees; +} + +} // namespace omnimalloc diff --git a/src/cpp/analysis/conflicts.hpp b/src/cpp/analysis/conflicts.hpp new file mode 100644 index 0000000..5e071bc --- /dev/null +++ b/src/cpp/analysis/conflicts.hpp @@ -0,0 +1,56 @@ +// +// SPDX-License-Identifier: Apache-2.0 +// + +#pragma once + +#include +#include +#include +#include +#include + +#include "clock.hpp" +#include "primitives/allocation.hpp" +#include "primitives/id_type.hpp" + +namespace omnimalloc { + +// Temporal overlap adjacency: allocation id -> ids of overlapping allocations +using TemporalOverlaps = + std::unordered_map, + IdTypeHash>; + +// Index-based temporal adjacency: position i -> positions overlapping i +using OverlapIndices = std::vector>; + +// Map each allocation id to the ids of temporally overlapping allocations. +// A set `work_budget` bounds the pairwise sweep (quadratic in the worst +// case), giving up (nullopt) instead of stalling the caller. +[[nodiscard]] std::optional compute_temporal_overlaps( + const std::vector& allocations, + std::optional work_budget); + +// Map each allocation index to the indices of temporally overlapping +// allocations +[[nodiscard]] OverlapIndices compute_overlap_indices( + const std::vector& allocations); + +// Id-keyed overlap map from an index adjacency +[[nodiscard]] TemporalOverlaps overlaps_from_indices( + const std::vector& allocations, const OverlapIndices& indices); + +// Per-allocation count of temporally overlapping allocations, aligned with +// `allocations`. Counts with multiplicity, so duplicate ids stay distinct. +// A set `work_budget` bounds the pairwise sweep (quadratic in the worst +// case), giving up (nullopt) instead of stalling the caller. +[[nodiscard]] std::optional> compute_conflict_degrees( + const std::vector& allocations, + std::optional work_budget); + +// Pairwise happens-before conflict adjacency over the pruned vector sweep; +// handles scalar and vector-clock lifetimes alike. +[[nodiscard]] CsrAdjacency build_conflict_adjacency( + const std::vector& allocations); + +} // namespace omnimalloc diff --git a/src/cpp/primitives/linearize.cpp b/src/cpp/analysis/linearize.cpp similarity index 96% rename from src/cpp/primitives/linearize.cpp rename to src/cpp/analysis/linearize.cpp index fb0cbba..7aaceb2 100644 --- a/src/cpp/primitives/linearize.cpp +++ b/src/cpp/analysis/linearize.cpp @@ -9,7 +9,7 @@ #include #include -#include "clock_rows.hpp" +#include "clock.hpp" #include "common/parallel.hpp" // Fishburn's interval-order test without materializing predecessor sets. @@ -78,7 +78,8 @@ bool find_incomparability_witness(const DedupedRows& starts, } // namespace std::optional>> linearize_times( - const std::vector& allocations, uint64_t work_budget) { + const std::vector& allocations, + std::optional work_budget) { const size_t n = allocations.size(); std::vector> times(n); if (n == 0) { @@ -102,9 +103,9 @@ std::optional>> linearize_times( if (find_incomparability_witness(starts, ends)) { return std::nullopt; } - // Dominance counting costs O(k * m * d) before pruning; under a finite + // Dominance counting costs O(k * m * d) before pruning; under a set // budget, give up undecided instead of stalling the caller. - if (static_cast(k) * m * d > work_budget) { + if (work_budget && static_cast(k) * m * d > *work_budget) { return std::nullopt; } @@ -198,7 +199,8 @@ std::optional>> linearize_times( } std::optional> try_linearize( - const std::vector& allocations, uint64_t work_budget) { + const std::vector& allocations, + std::optional work_budget) { const auto times = linearize_times(allocations, work_budget); if (!times.has_value()) { return std::nullopt; diff --git a/src/cpp/primitives/linearize.hpp b/src/cpp/analysis/linearize.hpp similarity index 52% rename from src/cpp/primitives/linearize.hpp rename to src/cpp/analysis/linearize.hpp index 97610d3..8bfdc91 100644 --- a/src/cpp/primitives/linearize.hpp +++ b/src/cpp/analysis/linearize.hpp @@ -5,37 +5,29 @@ #pragma once #include -#include #include #include #include -#include "allocation.hpp" +#include "primitives/allocation.hpp" namespace omnimalloc { -inline constexpr uint64_t kNoWorkBudget = std::numeric_limits::max(); - -// Default dominance-counting budget for implicit and hot-path callers (the -// omni allocator's linearize attempt, `Pool.pressure`), so huge vector-clock -// instances fail fast instead of stalling or exhausting memory. Exported to -// Python as DEFAULT_WORK_BUDGET. -inline constexpr uint64_t kDefaultWorkBudget = 100'000'000; - // Surrogate scalar (start, end) times preserving the happens-before conflict // relation exactly, aligned with `allocations`; scalar input passes through // unchanged. nullopt when the order is provably not an interval order — or, -// under a finite `work_budget`, when deciding would exceed the budget -// (undecided; the caller falls back to the vector conflict engine). +// under a set `work_budget` (nullopt means unbounded), when deciding would +// exceed the budget (undecided; the caller falls back to the vector conflict +// engine). [[nodiscard]] std::optional>> linearize_times(const std::vector& allocations, - uint64_t work_budget = kNoWorkBudget); + std::optional work_budget); // Allocation-level wrapper: allocations rebuilt with the surrogate scalar // times, or nullopt when `linearize_times` yields none (not an interval -// order — or undecided under a finite `work_budget`). +// order — or undecided under a set `work_budget`). [[nodiscard]] std::optional> try_linearize( const std::vector& allocations, - uint64_t work_budget = kNoWorkBudget); + std::optional work_budget); } // namespace omnimalloc diff --git a/src/cpp/primitives/placement.cpp b/src/cpp/analysis/placement.cpp similarity index 98% rename from src/cpp/primitives/placement.cpp rename to src/cpp/analysis/placement.cpp index 43410cd..9cdac55 100644 --- a/src/cpp/primitives/placement.cpp +++ b/src/cpp/analysis/placement.cpp @@ -13,7 +13,7 @@ #include #include -#include "clock_rows.hpp" +#include "clock.hpp" #include "common/parallel.hpp" #include "linearize.hpp" @@ -166,7 +166,7 @@ std::vector per_allocation_placement_pressure( } // Linearization preserves the conflict relation exactly, so neighborhood // tops and clique sums transfer verbatim to the surrogate timeline. - if (const auto times = linearize_times(allocations)) { + if (const auto times = linearize_times(allocations, std::nullopt)) { return scalar_peaks(*times, heights, weights, clique_cap); } return vector_peaks(spans, heights, weights, clique_cap); diff --git a/src/cpp/primitives/placement.hpp b/src/cpp/analysis/placement.hpp similarity index 95% rename from src/cpp/primitives/placement.hpp rename to src/cpp/analysis/placement.hpp index 86150fd..fc40fc6 100644 --- a/src/cpp/primitives/placement.hpp +++ b/src/cpp/analysis/placement.hpp @@ -7,7 +7,7 @@ #include #include -#include "allocation.hpp" +#include "primitives/allocation.hpp" namespace omnimalloc { diff --git a/src/cpp/bindings.cpp b/src/cpp/bindings.cpp index accf5c9..9fed298 100644 --- a/src/cpp/bindings.cpp +++ b/src/cpp/bindings.cpp @@ -21,13 +21,14 @@ #include "allocators/supermalloc/partition.hpp" #include "allocators/tabu_search.hpp" #include "allocators/telamalloc.hpp" +#include "analysis/antichain.hpp" +#include "analysis/closure.hpp" +#include "analysis/conflicts.hpp" +#include "analysis/linearize.hpp" +#include "analysis/placement.hpp" #include "primitives/allocation.hpp" -#include "primitives/antichain.hpp" #include "primitives/buffer_kind.hpp" -#include "primitives/closure.hpp" #include "primitives/id_type.hpp" -#include "primitives/linearize.hpp" -#include "primitives/placement.hpp" namespace nb = nanobind; using namespace nb::literals; @@ -118,29 +119,26 @@ NB_MODULE(_cpp, m) { }); m.def("compute_temporal_overlaps", &compute_temporal_overlaps, - "allocations"_a, nb::call_guard(), - nb::rv_policy::move); - m.def("compute_conflict_degrees", &compute_conflict_degrees, "allocations"_a, + "allocations"_a, "work_budget"_a.none(), nb::call_guard(), nb::rv_policy::move); + m.def("compute_conflict_degrees", &compute_conflict_degrees, "allocations"_a, + "work_budget"_a.none(), nb::call_guard(), + nb::rv_policy::move); m.def("try_linearize", &try_linearize, "allocations"_a, - "work_budget"_a = kNoWorkBudget, - nb::call_guard(), nb::rv_policy::move); - m.attr("DEFAULT_WORK_BUDGET") = kDefaultWorkBudget; - m.attr("DEFAULT_CLOSURE_CAP") = kDefaultClosureCap; + "work_budget"_a.none(), nb::call_guard(), + nb::rv_policy::move); m.def("antichain_pressure", &antichain_pressure, "allocations"_a, - "work_budget"_a = kNoWorkBudget, - nb::call_guard()); - m.def("closure_pressure", &closure_pressure, "allocations"_a, - "closure_cap"_a = kDefaultClosureCap, + "work_budget"_a.none(), nb::call_guard()); + m.def("closure_pressure", &closure_pressure, "allocations"_a, "closure_cap"_a, nb::call_guard()); m.def("per_allocation_antichain_pressure", &per_allocation_antichain_pressure, - "allocations"_a, "work_budget"_a = kNoWorkBudget, + "allocations"_a, "work_budget"_a.none(), nb::call_guard(), nb::rv_policy::move); m.def("per_allocation_closure_pressure", &per_allocation_closure_pressure, - "allocations"_a, "closure_cap"_a = kDefaultClosureCap, + "allocations"_a, "closure_cap"_a, nb::call_guard(), nb::rv_policy::move); m.def("per_allocation_placement_pressure", &per_allocation_placement_pressure, - "allocations"_a, "clique_cap"_a = false, + "allocations"_a, "clique_cap"_a, nb::call_guard(), nb::rv_policy::move); m.def("first_fit_place", &first_fit_place, "allocations"_a, "overlaps"_a, nb::call_guard(), nb::rv_policy::move); @@ -167,12 +165,17 @@ NB_MODULE(_cpp, m) { .def("__hash__", std::hash{}); // OmniAllocator class + const auto omni_repr = [](const OmniAllocator& allocator) { + const auto budget = allocator.linearize_budget(); + return "OmniAllocator(linearize_budget=" + + (budget ? std::to_string(*budget) : std::string("None")) + ")"; + }; nb::class_(m, "OmniAllocatorCpp") - .def(nb::init<>()) + .def(nb::init>(), "linearize_budget"_a.none()) .def("allocate", &OmniAllocator::allocate, "allocations"_a, nb::call_guard(), nb::rv_policy::move) - .def("__str__", [](const OmniAllocator&) { return "OmniAllocator()"; }) - .def("__repr__", [](const OmniAllocator&) { return "OmniAllocator()"; }) + .def("__str__", omni_repr) + .def("__repr__", omni_repr) .def("__eq__", &OmniAllocator::operator==, nb::is_operator()) .def("__hash__", std::hash{}); @@ -189,14 +192,10 @@ NB_MODULE(_cpp, m) { .def("__hash__", std::hash{}); // SimulatedAnnealingConfig / SimulatedAnnealingAllocator classes - constexpr SimulatedAnnealingConfig kDefaultSaConfig{}; nb::class_(m, "SimulatedAnnealingConfig") - .def(nb::init(), - "seed"_a = kDefaultSaConfig.seed, - "max_iterations"_a = kDefaultSaConfig.max_iterations, - "initial_temperature"_a = kDefaultSaConfig.initial_temperature, - "cooling_rate"_a = kDefaultSaConfig.cooling_rate, - "timeout"_a = kDefaultSaConfig.timeout) + .def(nb::init>(), + "seed"_a, "max_iterations"_a, "initial_temperature"_a, + "cooling_rate"_a, "timeout"_a.none()) .def_rw("seed", &SimulatedAnnealingConfig::seed) .def_rw("max_iterations", &SimulatedAnnealingConfig::max_iterations) .def_rw("initial_temperature", @@ -205,19 +204,15 @@ NB_MODULE(_cpp, m) { .def_rw("timeout", &SimulatedAnnealingConfig::timeout); nb::class_(m, "SimulatedAnnealingAllocatorCpp") - .def(nb::init(), "config"_a = kDefaultSaConfig) + .def(nb::init(), "config"_a) .def("allocate", &SimulatedAnnealingAllocator::allocate, "allocations"_a, nb::call_guard(), nb::rv_policy::move); // TabuSearchConfig / TabuSearchAllocator classes - constexpr TabuSearchConfig kDefaultTabuConfig{}; nb::class_(m, "TabuSearchConfig") - .def(nb::init(), - "seed"_a = kDefaultTabuConfig.seed, - "max_iterations"_a = kDefaultTabuConfig.max_iterations, - "neighborhood_size"_a = kDefaultTabuConfig.neighborhood_size, - "tabu_tenure"_a = kDefaultTabuConfig.tabu_tenure, - "timeout"_a = kDefaultTabuConfig.timeout) + .def(nb::init>(), "seed"_a, + "max_iterations"_a, "neighborhood_size"_a, "tabu_tenure"_a, + "timeout"_a.none()) .def_rw("seed", &TabuSearchConfig::seed) .def_rw("max_iterations", &TabuSearchConfig::max_iterations) .def_rw("neighborhood_size", &TabuSearchConfig::neighborhood_size) @@ -225,35 +220,28 @@ NB_MODULE(_cpp, m) { .def_rw("timeout", &TabuSearchConfig::timeout); nb::class_(m, "TabuSearchAllocatorCpp") - .def(nb::init(), "config"_a = kDefaultTabuConfig) + .def(nb::init(), "config"_a) .def("allocate", &TabuSearchAllocator::allocate, "allocations"_a, nb::call_guard(), nb::rv_policy::move); // TelamallocConfig / TelamallocAllocator classes - constexpr TelamallocConfig kDefaultTelaConfig{}; nb::class_(m, "TelamallocConfig") - .def(nb::init(), - "seed"_a = kDefaultTelaConfig.seed, - "max_backtracks"_a = kDefaultTelaConfig.max_backtracks, - "timeout"_a = kDefaultTelaConfig.timeout) + .def(nb::init>(), "seed"_a, + "max_backtracks"_a, "timeout"_a.none()) .def_rw("seed", &TelamallocConfig::seed) .def_rw("max_backtracks", &TelamallocConfig::max_backtracks) .def_rw("timeout", &TelamallocConfig::timeout); nb::class_(m, "TelamallocAllocatorCpp") - .def(nb::init(), "config"_a = kDefaultTelaConfig) + .def(nb::init(), "config"_a) .def("allocate", &TelamallocAllocator::allocate, "allocations"_a, nb::call_guard(), nb::rv_policy::move); // SearchOptions class - constexpr SearchOptions kDefaultOptions{}; nb::class_(m, "SearchOptions") - .def(nb::init(), - "canonical"_a = kDefaultOptions.canonical, - "dominance"_a = kDefaultOptions.dominance, - "floor_inference"_a = kDefaultOptions.floor_inference, - "monotonic_floor"_a = kDefaultOptions.monotonic_floor, - "decompose"_a = kDefaultOptions.decompose) + .def(nb::init(), "canonical"_a, + "dominance"_a, "floor_inference"_a, "monotonic_floor"_a, + "decompose"_a) .def_rw("canonical", &SearchOptions::canonical) .def_rw("dominance", &SearchOptions::dominance) .def_rw("floor_inference", &SearchOptions::floor_inference) @@ -276,11 +264,11 @@ NB_MODULE(_cpp, m) { .def_ro("offsets", &Solution::offsets) .def_ro("height", &Solution::height); - m.def("greedy_many", &greedy_many, "partition"_a, "heuristics"_a, "timeout"_a, - "num_threads"_a, nb::call_guard(), - nb::rv_policy::move); + m.def("greedy_many", &greedy_many, "partition"_a, "heuristics"_a, + "timeout"_a.none(), "num_threads"_a, + nb::call_guard(), nb::rv_policy::move); - m.def("solve_many", &solve_many, "partitions"_a, "node_limit"_a, "timeout"_a, - "best_bound"_a, "options"_a, "num_threads"_a, + m.def("solve_many", &solve_many, "partitions"_a, "node_limit"_a, + "timeout"_a.none(), "best_bound"_a, "options"_a, "num_threads"_a, nb::call_guard(), nb::rv_policy::move); } diff --git a/src/cpp/allocators/defaults.hpp b/src/cpp/common/deadline.hpp similarity index 50% rename from src/cpp/allocators/defaults.hpp rename to src/cpp/common/deadline.hpp index 7b0719e..0631376 100644 --- a/src/cpp/allocators/defaults.hpp +++ b/src/cpp/common/deadline.hpp @@ -4,25 +4,32 @@ #pragma once +#include #include +#include #include +#include +#include namespace omnimalloc { -// Shared wall-clock budget for every time-bounded allocator (seconds). -// Mirrors DEFAULT_TIMEOUT in the Python package. -inline constexpr double kDefaultTimeout = 3.0; - -// Deadline `timeout` seconds from now, or nullopt when `timeout` is not -// positive (the budget is disabled). The cap keeps the duration cast -// representable and also absorbs inf and NaN. +// Deadline `timeout` seconds from now, or nullopt when `timeout` is nullopt +// (the budget is disabled). Throws on a non-positive or non-finite set +// `timeout`, so the boundary has exactly two states — positive budget or +// nullopt — even for raw-binding callers that bypass the Python-side +// validation. The cap keeps the duration cast representable. [[nodiscard]] inline std::optional -make_deadline(double timeout) noexcept { - if (timeout <= 0.0) { +make_deadline(std::optional timeout) { + if (!timeout) { return std::nullopt; } + if (!std::isfinite(*timeout) || *timeout <= 0.0) { + throw std::invalid_argument("timeout must be positive or nullopt, got " + + std::to_string(*timeout) + + "; use nullopt (None) to disable the deadline"); + } constexpr double kMaxSeconds = 1e9; // ~31 years - const double seconds = timeout < kMaxSeconds ? timeout : kMaxSeconds; + const double seconds = std::min(*timeout, kMaxSeconds); return std::chrono::steady_clock::now() + std::chrono::duration_cast( std::chrono::duration(seconds)); diff --git a/src/python/omnimalloc/__init__.py b/src/python/omnimalloc/__init__.py index b453aed..691aa3c 100644 --- a/src/python/omnimalloc/__init__.py +++ b/src/python/omnimalloc/__init__.py @@ -10,6 +10,18 @@ from .allocators import OmniAllocator as OmniAllocator from .allocators import get_available_allocators as get_available_allocators from .allocators import get_default_allocator as get_default_allocator +from .analysis import get_closure_pressure as get_closure_pressure +from .analysis import get_conflicts as get_conflicts +from .analysis import ( + get_per_allocation_closure_pressure as get_per_allocation_closure_pressure, +) +from .analysis import ( + get_per_allocation_placement_pressure as get_per_allocation_placement_pressure, +) +from .analysis import get_per_allocation_pressure as get_per_allocation_pressure +from .analysis import get_placement_pressure as get_placement_pressure +from .analysis import get_pressure as get_pressure +from .analysis import try_linearize as try_linearize from .dump import dump_allocation as dump_allocation from .dump import load_allocation as load_allocation from .primitives import Allocation as Allocation @@ -20,17 +32,5 @@ from .primitives import System as System from .primitives import TimePoint as TimePoint from .primitives import VectorClock as VectorClock -from .primitives import get_closure_pressure as get_closure_pressure -from .primitives import get_conflicts as get_conflicts -from .primitives import ( - get_per_allocation_closure_pressure as get_per_allocation_closure_pressure, -) -from .primitives import ( - get_per_allocation_placement_pressure as get_per_allocation_placement_pressure, -) -from .primitives import get_per_allocation_pressure as get_per_allocation_pressure -from .primitives import get_placement_pressure as get_placement_pressure -from .primitives import get_pressure as get_pressure -from .primitives import try_linearize as try_linearize from .validate import validate_allocation as validate_allocation from .visualize import plot_allocation as plot_allocation diff --git a/src/python/omnimalloc/_cpp.pyi b/src/python/omnimalloc/_cpp.pyi index f0da989..0ebeb69 100644 --- a/src/python/omnimalloc/_cpp.pyi +++ b/src/python/omnimalloc/_cpp.pyi @@ -78,25 +78,21 @@ class Allocation: 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_temporal_overlaps(allocations: Sequence[Allocation], work_budget: int | None) -> dict[int | str, set[int | str]] | None: ... -def compute_conflict_degrees(allocations: Sequence[Allocation]) -> list[int]: ... +def compute_conflict_degrees(allocations: Sequence[Allocation], work_budget: int | None) -> list[int] | None: ... -def try_linearize(allocations: Sequence[Allocation], work_budget: int = 18446744073709551615) -> list[Allocation] | None: ... +def try_linearize(allocations: Sequence[Allocation], work_budget: int | None) -> list[Allocation] | None: ... -DEFAULT_WORK_BUDGET: int = 100000000 +def antichain_pressure(allocations: Sequence[Allocation], work_budget: int | None) -> int: ... -DEFAULT_CLOSURE_CAP: int = 16384 +def closure_pressure(allocations: Sequence[Allocation], closure_cap: int) -> int | None: ... -def antichain_pressure(allocations: Sequence[Allocation], work_budget: int = 18446744073709551615) -> int: ... +def per_allocation_antichain_pressure(allocations: Sequence[Allocation], work_budget: int | None) -> list[int]: ... -def closure_pressure(allocations: Sequence[Allocation], closure_cap: int = 16384) -> int | None: ... +def per_allocation_closure_pressure(allocations: Sequence[Allocation], closure_cap: int) -> list[int] | None: ... -def per_allocation_antichain_pressure(allocations: Sequence[Allocation], work_budget: int = 18446744073709551615) -> list[int]: ... - -def per_allocation_closure_pressure(allocations: Sequence[Allocation], closure_cap: int = 16384) -> list[int] | None: ... - -def per_allocation_placement_pressure(allocations: Sequence[Allocation], clique_cap: bool = False) -> list[int]: ... +def per_allocation_placement_pressure(allocations: Sequence[Allocation], clique_cap: bool) -> list[int]: ... def first_fit_place(allocations: Sequence[Allocation], overlaps: Mapping[int | str, Set[int | str]]) -> list[Allocation]: ... @@ -124,7 +120,7 @@ class GreedyAllocatorCpp: def __hash__(self) -> int: ... class OmniAllocatorCpp: - def __init__(self) -> None: ... + def __init__(self, linearize_budget: int | None) -> None: ... def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... @@ -150,7 +146,7 @@ class BestFitAllocatorCpp: def __hash__(self) -> int: ... class SimulatedAnnealingConfig: - def __init__(self, seed: int = 42, max_iterations: int = 3000, initial_temperature: float = 3.0, cooling_rate: float = 0.998, timeout: float = 3.0) -> None: ... + def __init__(self, seed: int, max_iterations: int, initial_temperature: float, cooling_rate: float, timeout: float | None) -> None: ... @property def seed(self) -> int: ... @@ -177,18 +173,18 @@ class SimulatedAnnealingConfig: def cooling_rate(self, arg: float, /) -> None: ... @property - def timeout(self) -> float: ... + def timeout(self) -> float | None: ... @timeout.setter - def timeout(self, arg: float, /) -> None: ... + def timeout(self, arg: float | None) -> None: ... class SimulatedAnnealingAllocatorCpp: - def __init__(self, config: SimulatedAnnealingConfig = ...) -> None: ... + def __init__(self, config: SimulatedAnnealingConfig) -> None: ... def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... class TabuSearchConfig: - def __init__(self, seed: int = 42, max_iterations: int = 500, neighborhood_size: int = 20, tabu_tenure: int = 15, timeout: float = 3.0) -> None: ... + def __init__(self, seed: int, max_iterations: int, neighborhood_size: int, tabu_tenure: int, timeout: float | None) -> None: ... @property def seed(self) -> int: ... @@ -215,18 +211,18 @@ class TabuSearchConfig: def tabu_tenure(self, arg: int, /) -> None: ... @property - def timeout(self) -> float: ... + def timeout(self) -> float | None: ... @timeout.setter - def timeout(self, arg: float, /) -> None: ... + def timeout(self, arg: float | None) -> None: ... class TabuSearchAllocatorCpp: - def __init__(self, config: TabuSearchConfig = ...) -> None: ... + def __init__(self, config: TabuSearchConfig) -> None: ... def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... class TelamallocConfig: - def __init__(self, seed: int = 42, max_backtracks: int = 10000, timeout: float = 3.0) -> None: ... + def __init__(self, seed: int, max_backtracks: int, timeout: float | None) -> None: ... @property def seed(self) -> int: ... @@ -241,18 +237,18 @@ class TelamallocConfig: def max_backtracks(self, arg: int, /) -> None: ... @property - def timeout(self) -> float: ... + def timeout(self) -> float | None: ... @timeout.setter - def timeout(self, arg: float, /) -> None: ... + def timeout(self, arg: float | None) -> None: ... class TelamallocAllocatorCpp: - def __init__(self, config: TelamallocConfig = ...) -> None: ... + def __init__(self, config: TelamallocConfig) -> None: ... def allocate(self, allocations: Sequence[Allocation]) -> list[Allocation]: ... class SearchOptions: - def __init__(self, canonical: bool = True, dominance: bool = True, floor_inference: bool = True, monotonic_floor: bool = True, decompose: bool = True) -> None: ... + def __init__(self, canonical: bool, dominance: bool, floor_inference: bool, monotonic_floor: bool, decompose: bool) -> None: ... @property def canonical(self) -> bool: ... @@ -307,6 +303,6 @@ class Solution: @property def height(self) -> int: ... -def greedy_many(partition: Partition, heuristics: Sequence[str], timeout: float, num_threads: int) -> Solution: ... +def greedy_many(partition: Partition, heuristics: Sequence[str], timeout: float | None, num_threads: int) -> Solution: ... -def solve_many(partitions: Sequence[Partition], node_limit: int, timeout: float, best_bound: int, options: SearchOptions, num_threads: int) -> Solution | None: ... +def solve_many(partitions: Sequence[Partition], node_limit: int, timeout: float | None, best_bound: int, options: SearchOptions, num_threads: int) -> Solution | None: ... diff --git a/src/python/omnimalloc/allocators/__init__.py b/src/python/omnimalloc/allocators/__init__.py index 85633f0..8cb793d 100644 --- a/src/python/omnimalloc/allocators/__init__.py +++ b/src/python/omnimalloc/allocators/__init__.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 # -from .base import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT from .base import BaseAllocator as BaseAllocator from .best_fit import BestFitAllocator as BestFitAllocator from .genetic import GeneticAllocator as GeneticAllocator diff --git a/src/python/omnimalloc/allocators/base.py b/src/python/omnimalloc/allocators/base.py index 8e43458..6bba010 100644 --- a/src/python/omnimalloc/allocators/base.py +++ b/src/python/omnimalloc/allocators/base.py @@ -3,18 +3,15 @@ # from abc import abstractmethod -from typing import TYPE_CHECKING, ClassVar, Final +from typing import TYPE_CHECKING, ClassVar +from omnimalloc.analysis.clock import ensure_uniform_dim from omnimalloc.common.registry import Registered from omnimalloc.primitives.utils import ensure_unique_ids -from omnimalloc.primitives.vector_clock import ensure_uniform_dim if TYPE_CHECKING: from omnimalloc.primitives import Allocation -# Shared wall-clock budget for every time-bounded allocator (seconds). -DEFAULT_TIMEOUT: Final[float] = 3.0 - class BaseAllocator(Registered): """Base class for allocators with automatic registry.""" diff --git a/src/python/omnimalloc/allocators/genetic.py b/src/python/omnimalloc/allocators/genetic.py index f54f459..8ec2872 100644 --- a/src/python/omnimalloc/allocators/genetic.py +++ b/src/python/omnimalloc/allocators/genetic.py @@ -3,14 +3,18 @@ # import random -import time from typing import Any, cast from omnimalloc._cpp import FirstFitPlacer +from omnimalloc.common.constants import DEFAULT_SEED, DEFAULT_TIMEOUT +from omnimalloc.common.deadline import ( + deadline_expired, + ensure_valid_timeout, + make_deadline, +) from omnimalloc.common.optional import require_optional from omnimalloc.primitives import Allocation -from .base import DEFAULT_TIMEOUT from .greedy import GreedyAllocator from .greedy_base import ( order_by_area, @@ -34,18 +38,18 @@ class GeneticAllocator(GreedyAllocator): """Genetic algorithm allocator that evolves greedy placement orders. `timeout` (default 3s) bounds wall-clock time between generations, - independent of `num_generations`; set it to 0 to disable the deadline. + independent of `num_generations`; set it to None to disable the deadline. """ def __init__( self, - seed: int = 42, + seed: int = DEFAULT_SEED, population_size: int = 100, num_generations: int = 50, crossover_prob: float = 0.7, mutation_prob: float = 0.2, tournament_size: int = 3, - timeout: float = DEFAULT_TIMEOUT, + timeout: float | None = DEFAULT_TIMEOUT, ) -> None: if not HAS_DEAP: require_optional("deap", "GeneticAllocator") @@ -62,8 +66,7 @@ def __init__( ) if tournament_size <= 0: raise ValueError(f"tournament_size must be positive, got {tournament_size}") - if timeout < 0: - raise ValueError(f"timeout must be non-negative, got {timeout}") + ensure_valid_timeout(timeout) self.seed = seed self.population_size = population_size @@ -157,11 +160,11 @@ def evaluate_invalid(individuals: list[Any]) -> None: # DEAP's eaSimple, unrolled so a wall-clock deadline can stop between # generations; varAnd keeps the RNG stream identical to eaSimple. # TODO(fpedd): Try eaMuPlusLambda and eaMuCommaLambda - deadline = time.monotonic() + self.timeout if self.timeout else None + deadline = make_deadline(self.timeout) evaluate_invalid(population) hall_of_fame.update(population) for _ in range(self.num_generations): - if deadline is not None and time.monotonic() >= deadline: + if deadline_expired(deadline): break offspring = toolbox.select(population, len(population)) # type: ignore[unresolved-attribute] offspring = algorithms.varAnd( diff --git a/src/python/omnimalloc/allocators/greedy.py b/src/python/omnimalloc/allocators/greedy.py index 55b7769..b0119b2 100644 --- a/src/python/omnimalloc/allocators/greedy.py +++ b/src/python/omnimalloc/allocators/greedy.py @@ -23,9 +23,10 @@ class GreedyAllocator(BaseAllocator): supports_vector_time = True def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return tuple( - first_fit_place(allocations, compute_temporal_overlaps(allocations)) - ) + # Unbounded: placement needs the true conflict relation, never a degrade. + overlaps = compute_temporal_overlaps(allocations, None) + assert overlaps is not None + return tuple(first_fit_place(allocations, overlaps)) class GreedyByDurationAllocator(GreedyAllocator): diff --git a/src/python/omnimalloc/allocators/greedy_base.py b/src/python/omnimalloc/allocators/greedy_base.py index 3e8a7c7..eab18f2 100644 --- a/src/python/omnimalloc/allocators/greedy_base.py +++ b/src/python/omnimalloc/allocators/greedy_base.py @@ -7,9 +7,9 @@ from bisect import bisect_left, bisect_right from concurrent.futures import ProcessPoolExecutor -from omnimalloc._cpp import compute_conflict_degrees +from omnimalloc.analysis.clock import ensure_uniform_dim +from omnimalloc.analysis.conflicts import get_conflict_degrees from omnimalloc.primitives import Allocation -from omnimalloc.primitives.vector_clock import ensure_uniform_dim from .base import BaseAllocator @@ -23,8 +23,10 @@ def compute_conflicts(allocations: tuple[Allocation, ...]) -> dict[Allocation, i """Count temporally overlapping allocations for each allocation.""" if ensure_uniform_dim(allocations) > 1: # No linear timeline to bisect over; count conflicts pairwise (with - # multiplicity, matching the scalar sweep even under duplicate ids) - degrees = compute_conflict_degrees(allocations) + # multiplicity, matching the scalar sweep even under duplicate ids). + # Unbounded: the sort order must not degrade on large instances. + degrees = get_conflict_degrees(allocations, work_budget=None) + assert degrees is not None return dict(zip(allocations, degrees, strict=True)) starts = sorted(alloc.start for alloc in allocations) ends = sorted(alloc.end for alloc in allocations) diff --git a/src/python/omnimalloc/allocators/hillclimb.py b/src/python/omnimalloc/allocators/hillclimb.py index 66f649a..999f512 100644 --- a/src/python/omnimalloc/allocators/hillclimb.py +++ b/src/python/omnimalloc/allocators/hillclimb.py @@ -4,12 +4,16 @@ import math import random -import time from omnimalloc._cpp import FirstFitPlacer +from omnimalloc.common.constants import DEFAULT_SEED, DEFAULT_TIMEOUT +from omnimalloc.common.deadline import ( + deadline_expired, + ensure_valid_timeout, + make_deadline, +) from omnimalloc.primitives import Allocation -from .base import DEFAULT_TIMEOUT from .greedy import GreedyAllocator from .greedy_base import compute_conflicts, peak_memory @@ -23,15 +27,15 @@ class HillClimbAllocator(GreedyAllocator): schedule). `acceptance_temperature` is the percent worsening accepted with probability 1/e at the start; it cools linearly to zero. `timeout` (default 3s) bounds wall-clock time as the input grows, - independent of `max_iterations`; set it to 0 to disable the deadline. + independent of `max_iterations`; set it to None to disable the deadline. """ def __init__( self, max_iterations: int = 100, - seed: int = 42, + seed: int = DEFAULT_SEED, acceptance_temperature: float = 2.0, - timeout: float = DEFAULT_TIMEOUT, + timeout: float | None = DEFAULT_TIMEOUT, ) -> None: if max_iterations <= 0: raise ValueError(f"max_iterations must be positive, got {max_iterations}") @@ -40,8 +44,7 @@ def __init__( f"acceptance_temperature must be non-negative, " f"got {acceptance_temperature}" ) - if timeout < 0: - raise ValueError(f"timeout must be non-negative, got {timeout}") + ensure_valid_timeout(timeout) self._max_iterations = max_iterations self._seed = seed @@ -119,7 +122,7 @@ def _should_accept( return rng.random() < math.exp(-worsening_percent / temperature) def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - deadline = time.monotonic() + self._timeout if self._timeout else None + deadline = make_deadline(self._timeout) rng = random.Random(self._seed) conflicts = compute_conflicts(allocations) placer = FirstFitPlacer(list(allocations)) @@ -142,7 +145,7 @@ def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, .. best, best_peak = current, current_peak for iteration in range(self._max_iterations): - if deadline is not None and time.monotonic() >= deadline: + if deadline_expired(deadline): break swap = self._propose_swap( order, current, current_peak, rng, allocations, adjacency diff --git a/src/python/omnimalloc/allocators/minimalloc.py b/src/python/omnimalloc/allocators/minimalloc.py index 557b066..82567eb 100644 --- a/src/python/omnimalloc/allocators/minimalloc.py +++ b/src/python/omnimalloc/allocators/minimalloc.py @@ -2,13 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 # +import math from typing import Any, cast +from omnimalloc.common.constants import DEFAULT_TIMEOUT, TB +from omnimalloc.common.deadline import ensure_valid_timeout from omnimalloc.common.optional import OptionalDependencyError -from omnimalloc.common.units import TB from omnimalloc.primitives import Allocation -from .base import DEFAULT_TIMEOUT, BaseAllocator +from .base import BaseAllocator try: import minimalloc as mm # type: ignore @@ -49,11 +51,10 @@ class MinimallocAllocator(BaseAllocator): supports_vector_time = False def __init__( - self, timeout: int = int(DEFAULT_TIMEOUT), max_capacity: int = 1 * TB + self, timeout: float | None = DEFAULT_TIMEOUT, max_capacity: int = 1 * TB ) -> None: _require_minimalloc() - if timeout < 0: - raise ValueError(f"timeout must be non-negative, got {timeout}") + ensure_valid_timeout(timeout) if max_capacity <= 0: raise ValueError(f"max_capacity must be positive, got {max_capacity}") self._timeout = timeout @@ -64,7 +65,10 @@ def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, .. problem.capacity = self._max_capacity params = mm.SolverParams() - params.timeout = self._timeout + # minimalloc's own default timeout is infinite, matching None here; + # its solver takes whole seconds, so round up to never shorten the budget + if self._timeout is not None: + params.timeout = math.ceil(self._timeout) params.minimize_capacity = True solution = mm.Solver(params).solve(problem) diff --git a/src/python/omnimalloc/allocators/omni.py b/src/python/omnimalloc/allocators/omni.py index 1f70b58..8f368a9 100644 --- a/src/python/omnimalloc/allocators/omni.py +++ b/src/python/omnimalloc/allocators/omni.py @@ -3,6 +3,8 @@ # from omnimalloc._cpp import OmniAllocatorCpp as _OmniAllocatorCpp +from omnimalloc.common.constants import DEFAULT_WORK_BUDGET +from omnimalloc.common.deadline import ensure_valid_budget from omnimalloc.primitives import Allocation from .base import BaseAllocator @@ -14,10 +16,17 @@ class OmniAllocator(BaseAllocator): Linearizes vector-clock lifetimes to surrogate scalars when the happens-before order allows, otherwise places truthfully on the vector conflict graph; either way the best of the seven greedy first-fit orders - wins (see src/cpp/allocators/omni.cpp). + wins (see src/cpp/allocators/omni.cpp). A finite `linearize_budget` + keeps the linearize attempt from dominating the placement it is meant + to speed up; pass `None` to always decide linearizability. """ supports_vector_time = True + def __init__(self, linearize_budget: int | None = DEFAULT_WORK_BUDGET) -> None: + ensure_valid_budget(linearize_budget, name="linearize_budget") + self._linearize_budget = linearize_budget + def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - return tuple(_OmniAllocatorCpp().allocate(list(allocations))) + cpp_allocator = _OmniAllocatorCpp(self._linearize_budget) + return tuple(cpp_allocator.allocate(list(allocations))) diff --git a/src/python/omnimalloc/allocators/random.py b/src/python/omnimalloc/allocators/random.py index 8231da5..f22400d 100644 --- a/src/python/omnimalloc/allocators/random.py +++ b/src/python/omnimalloc/allocators/random.py @@ -5,6 +5,7 @@ import random from omnimalloc._cpp import FirstFitPlacer +from omnimalloc.common.constants import DEFAULT_SEED from omnimalloc.primitives import Allocation from .greedy import GreedyAllocator @@ -13,7 +14,7 @@ class RandomAllocator(GreedyAllocator): """Randomized allocator that tries multiple random orders and picks the best.""" - def __init__(self, num_trials: int = 100, seed: int = 42) -> None: + def __init__(self, num_trials: int = 100, seed: int = DEFAULT_SEED) -> None: if num_trials < 0: raise ValueError(f"num_trials must be non-negative, got {num_trials}") self._seed = seed diff --git a/src/python/omnimalloc/allocators/simulated_annealing.py b/src/python/omnimalloc/allocators/simulated_annealing.py index 10c4425..b7989c6 100644 --- a/src/python/omnimalloc/allocators/simulated_annealing.py +++ b/src/python/omnimalloc/allocators/simulated_annealing.py @@ -8,20 +8,25 @@ SimulatedAnnealingAllocatorCpp as _SimulatedAnnealingAllocatorCpp, ) from omnimalloc._cpp import SimulatedAnnealingConfig as _SimulatedAnnealingConfig +from omnimalloc.common.constants import DEFAULT_SEED, DEFAULT_TIMEOUT +from omnimalloc.common.deadline import ensure_valid_timeout from omnimalloc.primitives import Allocation -from .base import DEFAULT_TIMEOUT, BaseAllocator +from .base import BaseAllocator @dataclass(frozen=True) class SimulatedAnnealingConfig: """Cooling schedule and iteration budget for `SimulatedAnnealingAllocator`.""" - seed: int = 42 + seed: int = DEFAULT_SEED max_iterations: int = 3000 + # Percent memory worsening accepted with probability 1/e at iteration 0; + # decays geometrically by `cooling_rate` every iteration. initial_temperature: float = 3.0 cooling_rate: float = 0.998 - timeout: float = DEFAULT_TIMEOUT + # Wall-clock budget in seconds; None disables it. + timeout: float | None = DEFAULT_TIMEOUT def __post_init__(self) -> None: if self.max_iterations <= 0: @@ -35,8 +40,7 @@ def __post_init__(self) -> None: ) if not 0.0 < self.cooling_rate <= 1.0: raise ValueError(f"cooling_rate must be in (0, 1], got {self.cooling_rate}") - if self.timeout < 0: - raise ValueError(f"timeout must be non-negative, got {self.timeout}") + ensure_valid_timeout(self.timeout) def to_cpp_config(self) -> _SimulatedAnnealingConfig: return _SimulatedAnnealingConfig( diff --git a/src/python/omnimalloc/allocators/supermalloc.py b/src/python/omnimalloc/allocators/supermalloc.py index 8365ed4..d04b4db 100644 --- a/src/python/omnimalloc/allocators/supermalloc.py +++ b/src/python/omnimalloc/allocators/supermalloc.py @@ -5,7 +5,6 @@ import logging import os import sys -import time from dataclasses import dataclass from enum import Enum @@ -16,7 +15,14 @@ greedy_many, solve_many, ) -from omnimalloc.allocators.base import DEFAULT_TIMEOUT, BaseAllocator +from omnimalloc.allocators.base import BaseAllocator +from omnimalloc.common.constants import DEFAULT_TIMEOUT +from omnimalloc.common.deadline import ( + deadline_expired, + deadline_remaining, + ensure_valid_timeout, + make_deadline, +) from omnimalloc.primitives.allocation import Allocation logger = logging.getLogger(__name__) @@ -60,7 +66,9 @@ class SortKey(str, Enum): class SupermallocConfig: """Configuration for the SupermallocAllocator.""" - timeout: float = DEFAULT_TIMEOUT + # Wall-clock budget in seconds for greedy + search (problem setup is not + # counted against it); None lets the search run to optimality. + timeout: float | None = DEFAULT_TIMEOUT heuristics: tuple[Heuristic, ...] = DEFAULT_HEURISTICS cores: int | None = None canonical: bool = True @@ -70,6 +78,7 @@ class SupermallocConfig: decompose: bool = True def __post_init__(self) -> None: + ensure_valid_timeout(self.timeout) if not self.heuristics: raise ValueError("SupermallocConfig requires at least one heuristic") @@ -95,15 +104,30 @@ class _Portfolio: partitions: list[Partition] options: SearchOptions threads: int - deadline: float + # Absolute time.monotonic() deadline; None means the search is unbounded. + deadline: float | None + + def remaining(self) -> float | None: + """Seconds left on the budget (0.0 once expired), or None when unbounded.""" + return deadline_remaining(self.deadline) + + def expired(self) -> bool: + return deadline_expired(self.deadline) - def solve(self, bounds: tuple[int, ...], timeout: float) -> Solution | None: - """Run one portfolio round: each heuristic searches below each bound.""" + def solve(self, bounds: tuple[int, ...]) -> Solution | None: + """Run one portfolio round, or None once the budget has expired. + + The budget is read once per round so the expiry check and the round's + timeout always agree. + """ + remaining = self.remaining() + if remaining is not None and remaining <= 0: + return None members = [p.with_bound(b) for b in bounds for p in self.partitions] return solve_many( members, sys.maxsize, - timeout, + remaining, max(bounds), self.options, self.threads, @@ -123,15 +147,12 @@ def _search(portfolio: _Portfolio, low: int, height: int) -> Solution | None: best: Solution | None = None rungs = max(1, portfolio.threads // len(portfolio.partitions)) while height > low: - remaining = portfolio.deadline - time.monotonic() - if remaining <= 0: - break - result = portfolio.solve(_bound_ladder(low, height, rungs), remaining) + result = portfolio.solve(_bound_ladder(low, height, rungs)) if result is None: break best, height = result, result.height - if height > low and time.monotonic() >= portfolio.deadline: + if height > low and portfolio.expired(): logger.debug("Supermalloc timed out above lower bound: %d > %d", height, low) return best @@ -148,7 +169,6 @@ def __init__(self, config: SupermallocConfig | None = None) -> None: self._config = config or SupermallocConfig() def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, ...]: - deadline = time.monotonic() + self._config.timeout threads = self._config.num_threads() base = Partition.from_allocations(allocations) heuristics = self._config.heuristics @@ -157,15 +177,16 @@ def _allocate(self, allocations: tuple[Allocation, ...]) -> tuple[Allocation, .. "".join(h) for h in GREEDY_HEURISTICS if h not in heuristics ] - budget = max(0.0, deadline - time.monotonic()) - incumbent = greedy_many(base, greedy_codes, budget, threads) portfolio = _Portfolio( partitions=[base.reorder(code) for code in heuristic_codes], options=self._config.search_options(), threads=threads, - deadline=deadline, + # Deliberately started after partition construction and the + # reorders above: the budget covers greedy + search only. + deadline=make_deadline(self._config.timeout), ) + incumbent = greedy_many(base, greedy_codes, portfolio.remaining(), threads) best = _search(portfolio, base.lower_bound, incumbent.height) if best is None: best = incumbent diff --git a/src/python/omnimalloc/allocators/tabu_search.py b/src/python/omnimalloc/allocators/tabu_search.py index 2080dfd..dbaf572 100644 --- a/src/python/omnimalloc/allocators/tabu_search.py +++ b/src/python/omnimalloc/allocators/tabu_search.py @@ -6,20 +6,25 @@ from omnimalloc._cpp import TabuSearchAllocatorCpp as _TabuSearchAllocatorCpp from omnimalloc._cpp import TabuSearchConfig as _TabuSearchConfig +from omnimalloc.common.constants import DEFAULT_SEED, DEFAULT_TIMEOUT +from omnimalloc.common.deadline import ensure_valid_timeout from omnimalloc.primitives import Allocation -from .base import DEFAULT_TIMEOUT, BaseAllocator +from .base import BaseAllocator @dataclass(frozen=True) class TabuSearchConfig: """Neighborhood size, iteration budget, and tabu memory for TabuSearchAllocator.""" - seed: int = 42 + seed: int = DEFAULT_SEED max_iterations: int = 500 + # Candidate swaps sampled per iteration neighborhood_size: int = 20 + # Iterations a reversed swap stays forbidden tabu_tenure: int = 15 - timeout: float = DEFAULT_TIMEOUT + # Wall-clock budget in seconds; None disables it. + timeout: float | None = DEFAULT_TIMEOUT def __post_init__(self) -> None: if self.max_iterations <= 0: @@ -32,8 +37,7 @@ def __post_init__(self) -> None: ) if self.tabu_tenure <= 0: raise ValueError(f"tabu_tenure must be positive, got {self.tabu_tenure}") - if self.timeout < 0: - raise ValueError(f"timeout must be non-negative, got {self.timeout}") + ensure_valid_timeout(self.timeout) def to_cpp_config(self) -> _TabuSearchConfig: return _TabuSearchConfig( diff --git a/src/python/omnimalloc/allocators/telamalloc.py b/src/python/omnimalloc/allocators/telamalloc.py index 14b2d77..34ea3c9 100644 --- a/src/python/omnimalloc/allocators/telamalloc.py +++ b/src/python/omnimalloc/allocators/telamalloc.py @@ -6,26 +6,30 @@ from omnimalloc._cpp import TelamallocAllocatorCpp as _TelamallocAllocatorCpp from omnimalloc._cpp import TelamallocConfig as _TelamallocConfig +from omnimalloc.common.constants import DEFAULT_SEED, DEFAULT_TIMEOUT +from omnimalloc.common.deadline import ensure_valid_timeout from omnimalloc.primitives import Allocation -from .base import DEFAULT_TIMEOUT, BaseAllocator +from .base import BaseAllocator @dataclass(frozen=True) class TelamallocConfig: """Search budgets for TelamallocAllocator.""" - seed: int = 42 + seed: int = DEFAULT_SEED + # Eviction (backtrack) budget per capacity attempt; an attempt that + # exhausts it reports the capacity as unreachable. max_backtracks: int = 10000 - timeout: float = DEFAULT_TIMEOUT + # Wall-clock budget in seconds; None disables it. + timeout: float | None = DEFAULT_TIMEOUT def __post_init__(self) -> None: if self.max_backtracks < 0: raise ValueError( f"max_backtracks must be non-negative, got {self.max_backtracks}" ) - if self.timeout < 0: - raise ValueError(f"timeout must be non-negative, got {self.timeout}") + ensure_valid_timeout(self.timeout) def to_cpp_config(self) -> _TelamallocConfig: return _TelamallocConfig( diff --git a/src/python/omnimalloc/analysis/__init__.py b/src/python/omnimalloc/analysis/__init__.py new file mode 100644 index 0000000..611eaa6 --- /dev/null +++ b/src/python/omnimalloc/analysis/__init__.py @@ -0,0 +1,19 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from .clock import ensure_uniform_dim as ensure_uniform_dim +from .clock import time_components as time_components +from .conflicts import get_conflict_degrees as get_conflict_degrees +from .conflicts import get_conflicts as get_conflicts +from .linearize import try_linearize as try_linearize +from .pressure import get_closure_pressure as get_closure_pressure +from .pressure import ( + get_per_allocation_closure_pressure as get_per_allocation_closure_pressure, +) +from .pressure import ( + get_per_allocation_placement_pressure as get_per_allocation_placement_pressure, +) +from .pressure import get_per_allocation_pressure as get_per_allocation_pressure +from .pressure import get_placement_pressure as get_placement_pressure +from .pressure import get_pressure as get_pressure diff --git a/src/python/omnimalloc/primitives/vector_clock.py b/src/python/omnimalloc/analysis/clock.py similarity index 88% rename from src/python/omnimalloc/primitives/vector_clock.py rename to src/python/omnimalloc/analysis/clock.py index 819f695..04273f8 100644 --- a/src/python/omnimalloc/primitives/vector_clock.py +++ b/src/python/omnimalloc/analysis/clock.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # -from .allocation import Allocation, TimePoint, VectorClock +from omnimalloc.primitives.allocation import Allocation, TimePoint, VectorClock def time_components(time_point: TimePoint) -> VectorClock: diff --git a/src/python/omnimalloc/analysis/conflicts.py b/src/python/omnimalloc/analysis/conflicts.py new file mode 100644 index 0000000..7151f8e --- /dev/null +++ b/src/python/omnimalloc/analysis/conflicts.py @@ -0,0 +1,46 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from omnimalloc._cpp import compute_conflict_degrees, compute_temporal_overlaps +from omnimalloc.common.constants import DEFAULT_WORK_BUDGET +from omnimalloc.common.deadline import ensure_valid_budget +from omnimalloc.primitives.allocation import Allocation, IdType +from omnimalloc.primitives.utils import ensure_unique_ids + + +def get_conflicts( + allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET +) -> dict[IdType, set[IdType]] | None: + """Temporal conflict map: each allocation's id to the ids it overlaps in time. + + The relation every placement packs against: conflicting allocations must + occupy disjoint address ranges. Symmetric and total — every allocation is + a key, conflict-free ones map to an empty set. Handles scalar and + vector-clock lifetimes (mutually concurrent clocks conflict). C++ sweep + (analysis/conflicts.cpp). A finite `work_budget` bounds the sweep + (quadratic in the worst case), giving up (None) instead of stalling; + pass `None` to always compute the relation. + """ + ensure_valid_budget(work_budget) + ensure_unique_ids(allocations) + overlaps = compute_temporal_overlaps(list(allocations), work_budget) + if overlaps is None: + return None + return {alloc.id: overlaps.get(alloc.id, set()) for alloc in allocations} + + +def get_conflict_degrees( + allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET +) -> list[int] | None: + """Temporal conflict count per allocation, aligned with input order. + + The degree sequence of the conflict relation behind `get_conflicts`, + from the same C++ sweep without materializing the adjacency. Positional + rather than id-keyed, so duplicate ids are allowed and counted with + multiplicity. A finite `work_budget` bounds the pairwise sweep + (quadratic in the worst case), giving up (None) instead of stalling; + pass `None` to always count. + """ + ensure_valid_budget(work_budget) + return compute_conflict_degrees(list(allocations), work_budget) diff --git a/src/python/omnimalloc/analysis/linearize.py b/src/python/omnimalloc/analysis/linearize.py new file mode 100644 index 0000000..cccc264 --- /dev/null +++ b/src/python/omnimalloc/analysis/linearize.py @@ -0,0 +1,32 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from omnimalloc._cpp import try_linearize as _try_linearize +from omnimalloc.common.constants import DEFAULT_WORK_BUDGET +from omnimalloc.common.deadline import ensure_valid_budget +from omnimalloc.primitives.allocation import Allocation + +from .clock import ensure_uniform_dim + + +def try_linearize( + allocations: tuple[Allocation, ...], work_budget: int | None = DEFAULT_WORK_BUDGET +) -> tuple[Allocation, ...] | None: + """Synthesize scalar lifetimes with the identical conflict relation, or None. + + Within the work budget, 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. Implemented in C++ (analysis/linearize.cpp). + A finite `work_budget` bounds the dominance-counting phase, giving up + undecided (None) instead of stalling; pass `None` to always decide. + """ + ensure_valid_budget(work_budget) + if ensure_uniform_dim(allocations) == 1: + return allocations + linearized = _try_linearize(list(allocations), work_budget) + return None if linearized is None else tuple(linearized) diff --git a/src/python/omnimalloc/primitives/pressure.py b/src/python/omnimalloc/analysis/pressure.py similarity index 90% rename from src/python/omnimalloc/primitives/pressure.py rename to src/python/omnimalloc/analysis/pressure.py index 9e0e3b6..eda4175 100644 --- a/src/python/omnimalloc/primitives/pressure.py +++ b/src/python/omnimalloc/analysis/pressure.py @@ -4,20 +4,18 @@ # DEFAULT_WORK_BUDGET and DEFAULT_CLOSURE_CAP bound the exact queries so # implicit callers (`Pool.pressure`) can never hang or OOM on huge -# vector-clock instances; both are exported from C++, next to the algorithms -# they tune, so they cannot drift from the OmniAllocator linearize budget. +# vector-clock instances. from omnimalloc._cpp import ( - DEFAULT_CLOSURE_CAP, - DEFAULT_WORK_BUDGET, antichain_pressure, closure_pressure, per_allocation_antichain_pressure, per_allocation_closure_pressure, per_allocation_placement_pressure, ) - -from .allocation import Allocation, IdType -from .utils import ensure_unique_ids +from omnimalloc.common.constants import DEFAULT_CLOSURE_CAP, DEFAULT_WORK_BUDGET +from omnimalloc.common.deadline import ensure_valid_budget +from omnimalloc.primitives.allocation import Allocation, IdType +from omnimalloc.primitives.utils import ensure_unique_ids def get_pressure( @@ -35,8 +33,7 @@ def get_pressure( phases and raises rather than hang when the flow would exceed it; pass `None` to always compute the exact answer. """ - if work_budget is None: - return antichain_pressure(list(allocations)) + ensure_valid_budget(work_budget) return antichain_pressure(list(allocations), work_budget) @@ -50,6 +47,7 @@ def get_closure_pressure( below `get_pressure`; both soundly lower-bound any placement's peak. Raises once the closure exceeds `closure_cap`. """ + ensure_valid_budget(closure_cap, name="closure_cap") peak = closure_pressure(list(allocations), closure_cap) if peak is None: raise RuntimeError( @@ -90,11 +88,9 @@ def get_per_allocation_pressure( the linearize attempt and each pinned flow and raises rather than hang; pass `None` to always compute the exact answer. """ + ensure_valid_budget(work_budget) ensure_unique_ids(allocations) - if work_budget is None: - peaks = per_allocation_antichain_pressure(list(allocations)) - else: - peaks = per_allocation_antichain_pressure(list(allocations), work_budget) + peaks = per_allocation_antichain_pressure(list(allocations), work_budget) return _keyed_by_id(allocations, peaks) @@ -109,6 +105,7 @@ def get_per_allocation_closure_pressure( entry equals `get_closure_pressure`. Raises once the closure exceeds `closure_cap`. """ + ensure_valid_budget(closure_cap, name="closure_cap") ensure_unique_ids(allocations) peaks = per_allocation_closure_pressure(list(allocations), closure_cap) if peaks is None: diff --git a/src/python/omnimalloc/benchmark/sources/concurrent_tiling.py b/src/python/omnimalloc/benchmark/sources/concurrent_tiling.py index d871a14..83ba2a9 100644 --- a/src/python/omnimalloc/benchmark/sources/concurrent_tiling.py +++ b/src/python/omnimalloc/benchmark/sources/concurrent_tiling.py @@ -5,6 +5,7 @@ import random from bisect import bisect_right +from omnimalloc.common.constants import DEFAULT_SEED, KB, MB from omnimalloc.primitives import TimePoint, VectorClock from .tiling import TilingSource @@ -32,12 +33,12 @@ def __init__( num_allocations: int = 128, num_threads: int = 2, num_syncs: int = 16, - capacity: int = 1024 * 1024, + capacity: int = MB, makespan: int = 1024 * 1024, - min_size: int = 1024, + min_size: int = KB, min_duration: int = 1, mem_cut_prob: float = 0.5, - seed: int | None = 42, + seed: int | None = DEFAULT_SEED, ) -> None: if num_threads <= 0: raise ValueError("num_threads must be positive") diff --git a/src/python/omnimalloc/benchmark/sources/generator.py b/src/python/omnimalloc/benchmark/sources/generator.py index 40f1d43..4e7e418 100644 --- a/src/python/omnimalloc/benchmark/sources/generator.py +++ b/src/python/omnimalloc/benchmark/sources/generator.py @@ -4,6 +4,7 @@ import random +from omnimalloc.common.constants import DEFAULT_SEED, KB, MB from omnimalloc.primitives import Allocation, BufferKind from .base import BaseSource @@ -15,15 +16,15 @@ class RandomSource(BaseSource): def __init__( self, num_allocations: int = 100, - size_min: int = 1024, - size_max: int = 1024 * 1024, + size_min: int = KB, + size_max: int = MB, time_min: int = 0, time_max: int = 10000, duration_min: int = 1, duration_max: int = 500, kinds: tuple[BufferKind, ...] | None = None, kind_weights: tuple[float, ...] | None = None, - seed: int | None = 42, + seed: int | None = DEFAULT_SEED, ) -> None: super().__init__(num_allocations=num_allocations) if size_min <= 0: @@ -89,10 +90,10 @@ class UniformSource(BaseSource): def __init__( self, num_allocations: int = 100, - size: int = 4096, + size: int = 4 * KB, duration: int = 10, time_max: int = 100, - seed: int | None = 42, + seed: int | None = DEFAULT_SEED, ) -> None: super().__init__(num_allocations=num_allocations) if size <= 0: @@ -143,7 +144,7 @@ def __init__( time_max: int = 100, duration_min: int = 1, duration_max: int = 50, - seed: int | None = 42, + seed: int | None = DEFAULT_SEED, ) -> None: super().__init__(num_allocations=num_allocations) if size_exponent_min < 0: @@ -194,10 +195,10 @@ class HighContentionSource(BaseSource): def __init__( self, num_allocations: int = 100, - size_min: int = 1024, - size_max: int = 1024 * 1024, + size_min: int = KB, + size_max: int = MB, time_window: int = 20, - seed: int | None = 42, + seed: int | None = DEFAULT_SEED, ) -> None: super().__init__(num_allocations=num_allocations) if size_min <= 0: @@ -242,11 +243,11 @@ class SequentialSource(BaseSource): def __init__( self, num_allocations: int = 100, - size_min: int = 1024, - size_max: int = 1024 * 1024, + size_min: int = KB, + size_max: int = MB, duration_min: int = 5, duration_max: int = 15, - seed: int | None = 42, + seed: int | None = DEFAULT_SEED, ) -> None: super().__init__(num_allocations=num_allocations) if size_min <= 0: diff --git a/src/python/omnimalloc/benchmark/sources/pinwheel.py b/src/python/omnimalloc/benchmark/sources/pinwheel.py index 0ef617a..4d8247d 100644 --- a/src/python/omnimalloc/benchmark/sources/pinwheel.py +++ b/src/python/omnimalloc/benchmark/sources/pinwheel.py @@ -4,6 +4,8 @@ import random +from omnimalloc.common.constants import DEFAULT_SEED, KB, MB + from .tiling_base import TilingBase, _Tile @@ -23,11 +25,11 @@ class PinwheelSource(TilingBase): def __init__( self, num_allocations: int = 129, - capacity: int = 1024 * 1024, + capacity: int = MB, makespan: int = 1024 * 1024, - min_size: int = 1024, + min_size: int = KB, min_duration: int = 1, - seed: int | None = 42, + seed: int | None = DEFAULT_SEED, ) -> None: if capacity < 3 * min_size: raise ValueError("capacity must be >= 3 * min_size to seat a pinwheel") diff --git a/src/python/omnimalloc/benchmark/sources/sync_patterns.py b/src/python/omnimalloc/benchmark/sources/sync_patterns.py index d3b47b3..5687ff6 100644 --- a/src/python/omnimalloc/benchmark/sources/sync_patterns.py +++ b/src/python/omnimalloc/benchmark/sources/sync_patterns.py @@ -6,6 +6,7 @@ from math import isqrt from typing import Final +from omnimalloc.common.constants import DEFAULT_SEED, KB, MB from omnimalloc.primitives import Allocation, VectorClock from .base import BaseSource @@ -52,10 +53,10 @@ def __init__( steps: int | None = None, sync_period: int = 8, group_size: int | None = None, - size_min: int = 1024, - size_max: int = 1024 * 1024, + size_min: int = KB, + size_max: int = MB, max_lifetime: int | None = None, - seed: int | None = 42, + seed: int | None = DEFAULT_SEED, ) -> None: if num_threads <= 0: raise ValueError("num_threads must be positive") diff --git a/src/python/omnimalloc/benchmark/sources/tiling.py b/src/python/omnimalloc/benchmark/sources/tiling.py index a0af84c..433464d 100644 --- a/src/python/omnimalloc/benchmark/sources/tiling.py +++ b/src/python/omnimalloc/benchmark/sources/tiling.py @@ -4,6 +4,8 @@ import random +from omnimalloc.common.constants import DEFAULT_SEED, KB, MB + from .tiling_base import TilingBase, _Tile @@ -20,12 +22,12 @@ class TilingSource(TilingBase): def __init__( self, num_allocations: int = 128, - capacity: int = 1024 * 1024, + capacity: int = MB, makespan: int = 1024 * 1024, - min_size: int = 1024, + min_size: int = KB, min_duration: int = 1, mem_cut_prob: float = 0.5, - seed: int | None = 42, + seed: int | None = DEFAULT_SEED, ) -> None: if capacity < min_size: raise ValueError("capacity must be >= min_size") diff --git a/src/python/omnimalloc/common/constants.py b/src/python/omnimalloc/common/constants.py new file mode 100644 index 0000000..169b3d0 --- /dev/null +++ b/src/python/omnimalloc/common/constants.py @@ -0,0 +1,40 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +from typing import Final + +# Cross-cutting defaults, owned here and only here: the C++ boundary (_cpp) +# takes every parameter explicitly, so a value can never drift between the +# languages. Algorithm-specific knobs live as field defaults on the +# allocator's own config dataclass instead. + +# Shared wall-clock budget for every time-bounded allocator (seconds); +# None disables the budget. +DEFAULT_TIMEOUT: Final[float] = 3.0 + +# Shared seed for every randomized allocator and benchmark source. +DEFAULT_SEED: Final[int] = 42 + +# Dominance-counting budget for implicit and hot-path callers of the exact +# order queries (the omni allocator's linearize attempt, `Pool.pressure`), so +# huge vector-clock instances fail fast instead of stalling or exhausting +# memory; None means unbounded. +DEFAULT_WORK_BUDGET: Final[int] = 100_000_000 + +# Join-closure enumeration cap for the exact realizable-peak queries, so huge +# vector-clock instances fail fast instead of exhausting memory. +DEFAULT_CLOSURE_CAP: Final[int] = 1 << 14 + +# Storage units in bytes +B: Final[int] = 1 +KB: Final[int] = 1_024 +MB: Final[int] = 1_024 * KB +GB: Final[int] = 1_024 * MB +TB: Final[int] = 1_024 * GB + +# Frequency units in hertz +HZ: Final[int] = 1 +KHZ: Final[int] = 1_000 * HZ +MHZ: Final[int] = 1_000 * KHZ +GHZ: Final[int] = 1_000 * MHZ diff --git a/src/python/omnimalloc/common/deadline.py b/src/python/omnimalloc/common/deadline.py new file mode 100644 index 0000000..0e8a305 --- /dev/null +++ b/src/python/omnimalloc/common/deadline.py @@ -0,0 +1,37 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import math +import time + +# Python mirror of the C++ deadline helpers (src/cpp/common/deadline.hpp): +# one spelling of the shared timeout convention, where None disables the +# budget. The work-budget validator lives here too — same None-disables shape. + + +def ensure_valid_timeout(timeout: float | None) -> None: + if timeout is not None and not (math.isfinite(timeout) and timeout > 0): + raise ValueError( + f"timeout must be positive or None, got {timeout}; " + "use None to disable the deadline" + ) + + +def ensure_valid_budget(budget: int | None, name: str = "work_budget") -> None: + if budget is not None and budget < 0: + raise ValueError(f"{name} must be non-negative, got {budget}") + + +def make_deadline(timeout: float | None) -> float | None: + """Absolute time.monotonic() deadline, or None when the budget is disabled.""" + return None if timeout is None else time.monotonic() + timeout + + +def deadline_remaining(deadline: float | None) -> float | None: + """Seconds left on the budget (0.0 once expired), or None when disabled.""" + return None if deadline is None else max(0.0, deadline - time.monotonic()) + + +def deadline_expired(deadline: float | None) -> bool: + return deadline is not None and time.monotonic() >= deadline diff --git a/src/python/omnimalloc/common/units.py b/src/python/omnimalloc/common/units.py deleted file mode 100644 index 8e3e861..0000000 --- a/src/python/omnimalloc/common/units.py +++ /dev/null @@ -1,18 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -from typing import Final - -# Storage units in bytes -B: Final[int] = 1 -KB: Final[int] = 1_024 -MB: Final[int] = 1_024 * KB -GB: Final[int] = 1_024 * MB -TB: Final[int] = 1_024 * GB - -# Frequency units in hertz -HZ: Final[int] = 1 -KHZ: Final[int] = 1_000 * HZ -MHZ: Final[int] = 1_000 * KHZ -GHZ: Final[int] = 1_000 * MHZ diff --git a/src/python/omnimalloc/dump.py b/src/python/omnimalloc/dump.py index 2b8a315..021e990 100644 --- a/src/python/omnimalloc/dump.py +++ b/src/python/omnimalloc/dump.py @@ -5,8 +5,8 @@ import csv from pathlib import Path +from .analysis.clock import time_components from .primitives import Allocation, BufferKind, Memory, Pool, System, TimePoint -from .primitives.vector_clock import time_components _FIELDS = ("id", "lower", "upper", "size") diff --git a/src/python/omnimalloc/primitives/__init__.py b/src/python/omnimalloc/primitives/__init__.py index 839b32c..5775e84 100644 --- a/src/python/omnimalloc/primitives/__init__.py +++ b/src/python/omnimalloc/primitives/__init__.py @@ -7,18 +7,6 @@ from .allocation import IdType as IdType from .allocation import TimePoint as TimePoint from .allocation import VectorClock as VectorClock -from .conflicts import get_conflicts as get_conflicts -from .linearize import try_linearize as try_linearize from .memory import Memory as Memory from .pool import Pool as Pool -from .pressure import get_closure_pressure as get_closure_pressure -from .pressure import ( - get_per_allocation_closure_pressure as get_per_allocation_closure_pressure, -) -from .pressure import ( - get_per_allocation_placement_pressure as get_per_allocation_placement_pressure, -) -from .pressure import get_per_allocation_pressure as get_per_allocation_pressure -from .pressure import get_placement_pressure as get_placement_pressure -from .pressure import get_pressure as get_pressure from .system import System as System diff --git a/src/python/omnimalloc/primitives/conflicts.py b/src/python/omnimalloc/primitives/conflicts.py deleted file mode 100644 index 39ba89c..0000000 --- a/src/python/omnimalloc/primitives/conflicts.py +++ /dev/null @@ -1,22 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -from omnimalloc._cpp import compute_temporal_overlaps - -from .allocation import Allocation, IdType -from .utils import ensure_unique_ids - - -def get_conflicts(allocations: tuple[Allocation, ...]) -> dict[IdType, set[IdType]]: - """Temporal conflict map: each allocation's id to the ids it overlaps in time. - - The relation every placement packs against: conflicting allocations must - occupy disjoint address ranges. Symmetric and total — every allocation is - a key, conflict-free ones map to an empty set. Handles scalar and - vector-clock lifetimes (mutually concurrent clocks conflict). C++ sweep - (allocators/first_fit.cpp). - """ - ensure_unique_ids(allocations) - overlaps = compute_temporal_overlaps(list(allocations)) - return {alloc.id: overlaps.get(alloc.id, set()) for alloc in allocations} diff --git a/src/python/omnimalloc/primitives/linearize.py b/src/python/omnimalloc/primitives/linearize.py deleted file mode 100644 index 57dd43c..0000000 --- a/src/python/omnimalloc/primitives/linearize.py +++ /dev/null @@ -1,24 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# - -from omnimalloc._cpp import try_linearize as _try_linearize - -from .allocation import Allocation -from .vector_clock import ensure_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. Implemented in C++ (primitives/linearize.cpp). - """ - if ensure_uniform_dim(allocations) == 1: - return allocations - linearized = _try_linearize(list(allocations)) - return None if linearized is None else tuple(linearized) diff --git a/src/python/omnimalloc/primitives/pool.py b/src/python/omnimalloc/primitives/pool.py index d7c8111..e133c0b 100644 --- a/src/python/omnimalloc/primitives/pool.py +++ b/src/python/omnimalloc/primitives/pool.py @@ -9,8 +9,9 @@ if TYPE_CHECKING: from omnimalloc.allocators import BaseAllocator +from omnimalloc.analysis.pressure import get_pressure + from .allocation import Allocation, IdType -from .pressure import get_pressure @dataclass(frozen=True) diff --git a/src/python/omnimalloc/validate.py b/src/python/omnimalloc/validate.py index 88c1a71..71e064c 100644 --- a/src/python/omnimalloc/validate.py +++ b/src/python/omnimalloc/validate.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 # +from .analysis.clock import ensure_uniform_dim from .primitives import Allocation, IdType, Memory, Pool, System -from .primitives.vector_clock import ensure_uniform_dim def _check_unique_ids(entities: tuple[Memory | Pool | Allocation, ...]) -> None: diff --git a/src/python/omnimalloc/visualize.py b/src/python/omnimalloc/visualize.py index 2fb7eac..d08713b 100644 --- a/src/python/omnimalloc/visualize.py +++ b/src/python/omnimalloc/visualize.py @@ -5,11 +5,24 @@ import logging from collections import defaultdict from pathlib import Path -from typing import Final - +from typing import Final, Literal, NamedTuple + +from omnimalloc.analysis import ( + ensure_uniform_dim, + get_conflict_degrees, + get_pressure, + time_components, + try_linearize, +) from omnimalloc.common.optional import require_optional -from omnimalloc.primitives import Allocation, BufferKind, IdType, Memory, Pool, System -from omnimalloc.primitives.vector_clock import ensure_uniform_dim, time_components +from omnimalloc.primitives import ( + Allocation, + BufferKind, + IdType, + Memory, + Pool, + System, +) try: import matplotlib.pyplot as plt @@ -68,9 +81,15 @@ def _format_bytes(value: float) -> str: } 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." + "Lanes show each thread's local timeline: temporal overlaps within a lane " + "are real conflicts, but cross-thread conflicts may not be visible " + "anywhere; validate_allocation() is the authority." +) + +PANEL_CAVEAT: Final[str] = ( + "Virtual global time is a linear extension of happens-before: visible " + "temporal overlaps are always real conflicts, but concurrent allocations " + "may be drawn apart; validate_allocation() is the authority." ) @@ -95,16 +114,110 @@ def _lane_extent(alloc: Allocation, lane: int) -> tuple[int, int]: 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: - for lane, end in enumerate(time_components(alloc.end)): - max_ends[lane] = max(max_ends.get(lane, 0), end) - # Clamp to at least 1 so empty lanes keep a non-degenerate x-axis - return {lane: (0, max(end, 1)) for lane, end in max_ends.items()} +def _sum_extent(alloc: Allocation) -> tuple[int, int]: + """Project an allocation's lifetime onto the monotone clock-component sum.""" + return sum(time_components(alloc.start)), sum(time_components(alloc.end)) + + +class _Panel(NamedTuple): + """One subplot: a memory drawn over one projection of its lifetimes.""" + + memory: Memory + extents: dict[int, tuple[int, int]] # id(allocation) -> projected lifetime + xlabel: str + title: str | None = None + note: str | None = None + + @property + def x_limits(self) -> tuple[int, int]: + """X-limits covering the panel's extents; empty panels keep a (0, 1) axis.""" + return 0, max((end for _, end in self.extents.values()), default=1) + + +def _memory_allocations(memory: Memory) -> list[Allocation]: + return [alloc for pool in memory.pools for alloc in pool.allocations] + + +def _projected(alloc: Allocation, extent: tuple[int, int]) -> Allocation: + """Scalar stand-in carrying the allocation's size over a projected extent.""" + return Allocation(id=alloc.id, size=alloc.size, start=extent[0], end=extent[1]) + + +def _panel_extents(memory: Memory) -> tuple[dict[int, tuple[int, int]], bool]: + """Projected lifetimes for one memory's panel; True when conflict-exact.""" + allocations = _memory_allocations(memory) + if len({alloc.dim for alloc in allocations}) == 1: + linearized = try_linearize(tuple(allocations)) + if linearized is not None: + return { + id(alloc): (lin.start, lin.end) + for alloc, lin in zip(allocations, linearized, strict=True) + }, True + return {id(alloc): _sum_extent(alloc) for alloc in allocations}, False + + +def _overlap_pairs(allocations: tuple[Allocation, ...]) -> int | None: + """Count temporally overlapping allocation pairs, or None once over budget.""" + degrees = get_conflict_degrees(allocations) + return None if degrees is None else sum(degrees) // 2 + + +def _conflict_visibility( + memory: Memory, extents: dict[int, tuple[int, int]] +) -> tuple[int, int] | None: + """Same-pool conflict pairs (visible under the projection, total), or None. + + Counting conflict pairs is quadratic in the worst case; the default work + budget gives up (None) rather than stall rendering on huge memories. + """ + visible = total = 0 + for pool in memory.pools: + projected = tuple(_projected(a, extents[id(a)]) for a in pool.allocations) + pool_visible = _overlap_pairs(projected) + pool_total = _overlap_pairs(pool.allocations) + if pool_visible is None or pool_total is None: + return None + visible += pool_visible + total += pool_total + return visible, total + + +def _visible_lane_extents( + allocations: list[Allocation], lane: int +) -> list[tuple[Allocation, tuple[int, int]]]: + """Allocations visible in one lane, with their local-time extents.""" + visible = [] + for alloc in allocations: + if lane >= alloc.dim: + continue # Lower-dim allocation in a mixed memory; no such lane + start, end = _lane_extent(alloc, lane) + if start == end: + continue # No local time on this thread; not visible in this lane + visible.append((alloc, (start, end))) + return visible + + +def _lane_peaks(allocations: list[Allocation], dim: int) -> list[int]: + """Definite-occupancy peak per lane: pressure of its projected extents.""" + peaks = [] + for lane in range(dim): + projected = tuple( + _projected(alloc, extent) + for alloc, extent in _visible_lane_extents(allocations, lane) + ) + peaks.append(get_pressure(projected)) + return peaks + + +def _select_lanes( + allocations: list[Allocation], dim: int, max_lanes: int | None +) -> list[int]: + """Lanes to draw: all, or the top-k by definite-occupancy peak.""" + if max_lanes is None or dim <= max_lanes: + return list(range(dim)) + peaks = _lane_peaks(allocations, dim) + ranked = sorted(range(dim), key=lambda lane: (-peaks[lane], lane)) + return sorted(ranked[:max_lanes]) def _get_y_limits(system: System) -> dict[Memory, tuple[int, int]]: @@ -151,15 +264,15 @@ def _get_y_offsets(system: System) -> dict[Memory, dict[Pool, int]]: def _draw_allocation( - ax: Axes, alloc: Allocation, offset: int, color: str, lane: int + ax: Axes, + alloc: Allocation, + offset: int, + color: str, + extent: tuple[int, int], ) -> None: - """Draw a single allocation rectangle, projected onto the given lane.""" + """Draw a single allocation rectangle over its projected lifetime.""" 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 + start, end = extent y_pos = offset + alloc.offset rect = Rectangle( xy=(start, y_pos), @@ -233,19 +346,72 @@ def _set_axes_ticks(ax: Axes, y_limit: int, num_ticks: int = 8) -> None: ax.set_ylabel(f"Memory ({unit})") -def _set_axes_labels( - 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" - 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 _memory_title(memory: Memory, threads: str) -> str: + size = memory.size + size_str = _format_bytes(size) if size is not None else "Unknown Size" + return f"{memory.id} ({size_str}, {len(memory.pools)} pools{threads})" + + +def _lane_panels( + system: System, max_lanes: int | None +) -> tuple[list[_Panel], str | None]: + """One panel per (memory, thread): each thread's local-time projection.""" + dims = {memory: _memory_dim(memory) for memory in system.memories} + panels = [] + for memory in system.memories: + dim = dims[memory] + allocations = _memory_allocations(memory) + lanes = _select_lanes(allocations, dim, max_lanes) + threads = f", {dim} threads" if dim > 1 else "" + if len(lanes) < dim: + threads = f", top {len(lanes)} of {dim} threads" + for index, lane in enumerate(lanes): + extents = { + id(alloc): extent + for alloc, extent in _visible_lane_extents(allocations, lane) + } + panels.append( + _Panel( + memory=memory, + extents=extents, + xlabel="Time (Step)" if dim == 1 else f"Thread {lane} Time (Step)", + title=_memory_title(memory, threads) if index == 0 else None, + ) + ) + caveat = LANE_CAVEAT if any(dim > 1 for dim in dims.values()) else None + return panels, caveat + + +def _projection_panels(system: System) -> tuple[list[_Panel], str | None]: + """One panel per memory over a happens-before-monotone virtual time.""" + panels = [] + caveat = None + for memory in system.memories: + dim = _memory_dim(memory) + extents, exact = _panel_extents(memory) + if dim == 1: + threads, xlabel, note = "", "Time (Step)", None + elif exact: + threads = f", {dim} threads, linearized" + xlabel, note = "Virtual Time (Step)", None + else: + threads, xlabel = f", {dim} threads", "Virtual Global Time (Step)" + note = None + visibility = _conflict_visibility(memory, extents) + if visibility is not None: + visible, total = visibility + note = f"{visible}/{total} conflicts visible" if total else None + caveat = PANEL_CAVEAT + panels.append( + _Panel( + memory=memory, + extents=extents, + xlabel=xlabel, + title=_memory_title(memory, threads), + note=note, + ) + ) + return panels, caveat def _set_axes_limits( @@ -287,60 +453,94 @@ def _add_legend(fig: Figure) -> None: # TODO(fpedd): Add a pools size descriptor on the right side of each pool +def _draw_panel( + ax: Axes, + panel: _Panel, + y_limits: tuple[int, int], + y_offsets: dict[Pool, int], + memory_limits: dict[str, dict[IdType, int]], +) -> None: + memory = panel.memory + if panel.title is not None: + ax.set_title(panel.title) + ax.set_xlabel(panel.xlabel) + _set_axes_limits(ax, panel.x_limits, y_limits, memory.size) + _set_axes_ticks(ax, y_limits[1]) + if panel.note is not None: + ax.text( + 0.98, + 0.98, + panel.note, + transform=ax.transAxes, + ha="right", + va="top", + fontsize=8, + alpha=0.7, + ) + + for pool in memory.pools: + y_offset = y_offsets[pool] + + colors: set[str] = set() + for alloc in pool.allocations: + color = _get_allocation_color(alloc.kind) + colors.add(color) + extent = panel.extents.get(id(alloc)) + if extent is not None: + _draw_allocation(ax, alloc, y_offset, color, extent) + _draw_pool_background(ax, y_offset, pool.size, colors) + + # Draw memory limit lines + limits: dict[str, int] = {"used": memory.used_size} + if memory.size is not None: + limits["size"] = memory.size + for limit_type, memory_id_to_limit in memory_limits.items(): + if memory.id in memory_id_to_limit: + limits[limit_type] = memory_id_to_limit[memory.id] + + _draw_limit_lines(ax, limits) + + def _visualize_system( system: System, file_path: Path | str | None, show_inline: bool, memory_limits: dict[str, dict[IdType, int]], + view: Literal["panel", "lanes"], + max_lanes: int | None, ) -> Path | None: - 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) + if view == "lanes": + panels, caveat = _lane_panels(system, max_lanes) + else: + panels, caveat = _projection_panels(system) + if not panels: + raise ValueError(f"Nothing to plot: system {system.id!r} has no memories") + fig_height = max(9, len(panels) * 6) fig_width = 12 fig, axs = plt.subplots( - nrows=len(lanes), + nrows=len(panels), ncols=1, figsize=(fig_width, fig_height), layout="constrained", ) - axs = [axs] if len(lanes) == 1 else axs + axs = [axs] if len(panels) == 1 else axs - x_limits = _get_x_limits(system) y_limits = _get_y_limits(system) y_offsets = _get_y_offsets(system) - 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), lane, dims[memory]) - _set_axes_limits(ax, x_limits.get(lane, (0, 1)), memory_y_limits, memory.size) - _set_axes_ticks(ax, memory_y_limits[1]) + for ax, panel in zip(axs, panels, strict=True): + _draw_panel( + ax, + panel, + y_limits[panel.memory], + y_offsets[panel.memory], + memory_limits, + ) - 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, lane) - colors.add(color) - _draw_pool_background(ax, y_offset, pool.size, colors) - - # Draw memory limit lines - limits: dict[str, int] = {"used": memory.used_size} - if memory.size is not None: - limits["size"] = memory.size - for limit_type, memory_id_to_limit in memory_limits.items(): - if memory.id in memory_id_to_limit: - limits[limit_type] = memory_id_to_limit[memory.id] - - _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) + if caveat is not None: + # Both projections of a partial order are sound but lossy: visible + # collisions are real, but some conflicts need not be visible. + fig.suptitle(caveat, fontsize=8) _add_legend(fig) if show_inline: @@ -417,6 +617,8 @@ def plot_allocation( show_inline: bool = False, canonicalize: bool = False, memory_limits: dict[str, dict[IdType, int]] | None = None, + view: Literal["panel", "lanes"] = "panel", + max_lanes: int | None = None, ) -> Path | None: """Plot an allocated entity (System, Memory, or Pool). @@ -427,10 +629,27 @@ def plot_allocation( canonicalize: Whether to canonicalize IDs for cleaner visualization. memory_limits: Optional dict specifying custom memory limits for each memory in the system. + view: "panel" draws each memory once over a happens-before-monotone + virtual time (exact for scalar or linearizable lifetimes, else + a sound clock-component-sum projection annotated with its + conflict coverage on modestly sized memories) + "lanes" draws one subplot per thread showing its local-time + projection. Both views only ever show genuine conflicts as + temporal overlaps; scalar entities render identically under + either. + max_lanes: Lanes view only (rejected under other views): cap each + memory to its top-k threads by peak definitely-live + occupancy. Returns: Path to the saved file, or None if not saved. """ + if view not in ("panel", "lanes"): + raise ValueError(f'view must be "panel" or "lanes", got {view!r}') + if max_lanes is not None and max_lanes < 1: + raise ValueError(f"max_lanes must be positive, got {max_lanes}") + if max_lanes is not None and view != "lanes": + raise ValueError('max_lanes requires view="lanes"') if not HAS_MATPLOTLIB: require_optional("matplotlib", "visualization") @@ -448,4 +667,6 @@ def plot_allocation( file_path=file_path, show_inline=show_inline, memory_limits=memory_limits or {}, + view=view, + max_lanes=max_lanes, ) diff --git a/tests/integration/test_omni_torture.py b/tests/integration/test_omni_torture.py index ca2c579..57e9ba6 100644 --- a/tests/integration/test_omni_torture.py +++ b/tests/integration/test_omni_torture.py @@ -18,17 +18,17 @@ GreedyBySizeAllocatorCpp, GreedyByStartAllocatorCpp, ) +from omnimalloc.analysis.pressure import ( + get_closure_pressure, + get_per_allocation_placement_pressure, + get_pressure, +) from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.benchmark.sources.generator import HighContentionSource, RandomSource from omnimalloc.benchmark.sources.pinwheel import PinwheelSource from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.benchmark.sources.tiling import TilingSource from omnimalloc.primitives import Allocation, Pool -from omnimalloc.primitives.pressure import ( - get_closure_pressure, - get_per_allocation_placement_pressure, - get_pressure, -) from omnimalloc.validate import validate_allocation GREEDY_PORTFOLIO = ( @@ -48,7 +48,7 @@ def _peak(allocations: tuple[Allocation, ...]) -> int: def _assert_conflicting_pairs_disjoint(placed: tuple[Allocation, ...]) -> None: by_id = {a.id: a for a in placed} - for alloc_id, neighbor_ids in compute_temporal_overlaps(placed).items(): + for alloc_id, neighbor_ids in compute_temporal_overlaps(placed, None).items(): a = by_id[alloc_id] for neighbor_id in neighbor_ids: b = by_id[neighbor_id] diff --git a/tests/unit/allocators/test_genetic.py b/tests/unit/allocators/test_genetic.py index aeb01ad..1071047 100644 --- a/tests/unit/allocators/test_genetic.py +++ b/tests/unit/allocators/test_genetic.py @@ -58,7 +58,7 @@ def test_genetic_rejects_invalid_tournament_size() -> None: def test_genetic_rejects_negative_timeout() -> None: - with pytest.raises(ValueError, match="timeout must be non-negative"): + with pytest.raises(ValueError, match="timeout must be positive or None"): GeneticAllocator(timeout=-1.0) diff --git a/tests/unit/allocators/test_greedy_cpp.py b/tests/unit/allocators/test_greedy_cpp.py index 5aaca12..aaa544a 100644 --- a/tests/unit/allocators/test_greedy_cpp.py +++ b/tests/unit/allocators/test_greedy_cpp.py @@ -393,7 +393,7 @@ def test_cpp_first_fit_place_reuses_overlaps_across_orders() -> None: Allocation(id=i, size=(i % 4 + 1) * 10, start=i % 5, end=i % 5 + i % 3 + 1) for i in range(30) ) - overlaps = compute_temporal_overlaps(allocs) + overlaps = compute_temporal_overlaps(allocs, None) for order in (allocs, tuple(reversed(allocs))): result = first_fit_place(order, overlaps) expected = GreedyAllocator().allocate(order) diff --git a/tests/unit/allocators/test_hillclimb.py b/tests/unit/allocators/test_hillclimb.py index 4d00a46..ed7e5e3 100644 --- a/tests/unit/allocators/test_hillclimb.py +++ b/tests/unit/allocators/test_hillclimb.py @@ -39,7 +39,7 @@ def test_hillclimb_rejects_negative_temperature() -> None: def test_hillclimb_rejects_negative_timeout() -> None: - with pytest.raises(ValueError, match="timeout must be non-negative"): + with pytest.raises(ValueError, match="timeout must be positive or None"): HillClimbAllocator(timeout=-1.0) diff --git a/tests/unit/allocators/test_omni.py b/tests/unit/allocators/test_omni.py index 9afdf4f..a539229 100644 --- a/tests/unit/allocators/test_omni.py +++ b/tests/unit/allocators/test_omni.py @@ -5,11 +5,12 @@ import random import pytest +from omnimalloc._cpp import OmniAllocatorCpp from omnimalloc.allocators import BaseAllocator, NaiveAllocator, OmniAllocator +from omnimalloc.analysis.pressure import get_placement_pressure, get_pressure from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.primitives import Allocation, Pool -from omnimalloc.primitives.pressure import get_placement_pressure, get_pressure from omnimalloc.validate import validate_allocation @@ -43,6 +44,16 @@ def test_omni_is_registered_and_supports_vector_time() -> None: assert OmniAllocator.supports_vector_time is True +def test_omni_rejects_negative_linearize_budget() -> None: + with pytest.raises(ValueError, match="linearize_budget"): + OmniAllocator(linearize_budget=-1) + + +def test_omni_cpp_repr_includes_budget() -> None: + assert repr(OmniAllocatorCpp(100)) == "OmniAllocator(linearize_budget=100)" + assert repr(OmniAllocatorCpp(None)) == "OmniAllocator(linearize_budget=None)" + + def test_omni_empty_returns_empty() -> None: assert OmniAllocator().allocate(()) == () diff --git a/tests/unit/allocators/test_random.py b/tests/unit/allocators/test_random.py index 4ecb1c4..4a871bd 100644 --- a/tests/unit/allocators/test_random.py +++ b/tests/unit/allocators/test_random.py @@ -4,9 +4,9 @@ from omnimalloc.allocators.greedy_base import peak_memory from omnimalloc.allocators.random import RandomAllocator +from omnimalloc.analysis.pressure import get_pressure from omnimalloc.primitives import Allocation from omnimalloc.primitives.pool import Pool -from omnimalloc.primitives.pressure import get_pressure from omnimalloc.validate import validate_allocation diff --git a/tests/unit/allocators/test_tabu_search.py b/tests/unit/allocators/test_tabu_search.py index 364952e..0d210ad 100644 --- a/tests/unit/allocators/test_tabu_search.py +++ b/tests/unit/allocators/test_tabu_search.py @@ -3,6 +3,7 @@ # import pytest +from omnimalloc._cpp import TabuSearchAllocatorCpp from omnimalloc.allocators.greedy import GreedyBySizeAllocator from omnimalloc.allocators.greedy_base import peak_memory from omnimalloc.allocators.tabu_search import TabuSearchAllocator, TabuSearchConfig @@ -43,6 +44,18 @@ def test_tabu_search_rejects_non_positive_tabu_tenure() -> None: TabuSearchConfig(tabu_tenure=0) +def test_tabu_search_cpp_boundary_rejects_zero_timeout() -> None: + config = TabuSearchConfig().to_cpp_config() + config.timeout = 0.0 + allocator = TabuSearchAllocatorCpp(config) + allocations = [ + Allocation(id=1, size=100, start=0, end=10), + Allocation(id=2, size=100, start=5, end=15), + ] + with pytest.raises(ValueError, match="timeout must be positive"): + allocator.allocate(allocations) + + def test_tabu_search_preserves_allocations() -> None: allocator = TabuSearchAllocator() allocs = ( diff --git a/tests/unit/allocators/test_telamalloc.py b/tests/unit/allocators/test_telamalloc.py index 856f212..33600a5 100644 --- a/tests/unit/allocators/test_telamalloc.py +++ b/tests/unit/allocators/test_telamalloc.py @@ -34,7 +34,7 @@ def test_telamalloc_rejects_negative_backtracks() -> None: def test_telamalloc_rejects_negative_seconds() -> None: - with pytest.raises(ValueError, match="timeout must be non-negative"): + with pytest.raises(ValueError, match="timeout must be positive or None"): TelamallocConfig(timeout=-1.0) @@ -78,7 +78,7 @@ def test_telamalloc_all_overlap_stacks_sequentially() -> None: def test_telamalloc_overlapping_reach_lower_bound() -> None: - config = TelamallocConfig(timeout=0) + config = TelamallocConfig(timeout=None) result = TelamallocAllocator(config).allocate( ( Allocation(id=1, size=100, start=0, end=10), @@ -106,7 +106,7 @@ def test_telamalloc_deterministic_without_deadline() -> None: Allocation(id=i, size=(i % 5 + 1) * 100, start=i % 3, end=i % 3 + i % 7 + 1) for i in range(20) ) - config = TelamallocConfig(timeout=0) + config = TelamallocConfig(timeout=None) result1 = TelamallocAllocator(config).allocate(allocs) result2 = TelamallocAllocator(config).allocate(allocs) assert all(r1.offset == r2.offset for r1, r2 in zip(result1, result2, strict=True)) @@ -139,7 +139,7 @@ def test_telamalloc_matches_or_beats_single_pass_greedy() -> None: for i in range(40) ) greedy_peak = peak_memory(GreedyBySizeAllocator().allocate(allocs)) - config = TelamallocConfig(timeout=0) + config = TelamallocConfig(timeout=None) result = TelamallocAllocator(config).allocate(allocs) assert _is_valid(result) assert peak_memory(result) <= greedy_peak diff --git a/tests/unit/allocators/test_vector_time.py b/tests/unit/allocators/test_vector_time.py index e4a5706..e45ffd0 100644 --- a/tests/unit/allocators/test_vector_time.py +++ b/tests/unit/allocators/test_vector_time.py @@ -110,7 +110,7 @@ def test_order_by_start_mixed_dimensions_rejected() -> None: def test_compute_conflicts_matches_overlap_map() -> None: allocs = vector_problem() - overlaps = compute_temporal_overlaps(allocs) + overlaps = compute_temporal_overlaps(allocs, None) conflicts = compute_conflicts(allocs) for alloc in allocs: assert conflicts[alloc] == len(overlaps.get(alloc.id, ())) @@ -120,7 +120,7 @@ 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) + overlaps = compute_temporal_overlaps(allocs, None) conflicts = compute_conflicts(allocs) for alloc in allocs: assert conflicts[alloc] == len(overlaps.get(alloc.id, ())) @@ -145,7 +145,7 @@ def test_compute_conflicts_duplicate_ids_match_scalar() -> None: def test_overlap_map_matches_pairwise_test() -> None: allocs = vector_problem(12) - overlaps = compute_temporal_overlaps(allocs) + overlaps = compute_temporal_overlaps(allocs, None) for a in allocs: for b in allocs: if a.id == b.id: diff --git a/tests/unit/analysis/__init__.py b/tests/unit/analysis/__init__.py new file mode 100644 index 0000000..0480730 --- /dev/null +++ b/tests/unit/analysis/__init__.py @@ -0,0 +1,3 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/tests/unit/primitives/test_conflicts.py b/tests/unit/analysis/test_conflicts.py similarity index 60% rename from tests/unit/primitives/test_conflicts.py rename to tests/unit/analysis/test_conflicts.py index dc1f1f6..1430790 100644 --- a/tests/unit/primitives/test_conflicts.py +++ b/tests/unit/analysis/test_conflicts.py @@ -5,8 +5,8 @@ from random import Random import pytest +from omnimalloc.analysis.conflicts import get_conflict_degrees, get_conflicts from omnimalloc.primitives import Allocation -from omnimalloc.primitives.conflicts import get_conflicts def test_conflicts_empty() -> None: @@ -64,6 +64,57 @@ def test_conflicts_rejects_mixed_dimensions() -> None: get_conflicts(mixed) +def test_conflicts_over_budget_give_up() -> None: + allocations = tuple(Allocation(id=i, size=8, start=0, end=10) for i in range(4)) + assert get_conflicts(allocations, work_budget=1) is None + + +def test_conflicts_unbounded_budget_always_computes() -> None: + allocations = tuple(Allocation(id=i, size=8, start=0, end=10) for i in range(2)) + assert get_conflicts(allocations, work_budget=None) == {0: {1}, 1: {0}} + + +def test_conflicts_reject_negative_budget() -> None: + with pytest.raises(ValueError, match="work_budget must be non-negative"): + get_conflicts((), work_budget=-1) + + +def test_conflict_degrees_empty() -> None: + assert get_conflict_degrees(()) == [] + + +def test_conflict_degrees_align_with_input_order() -> None: + allocations = ( + Allocation(id=1, size=8, start=0, end=4), + Allocation(id=2, size=8, start=2, end=6), + Allocation(id=3, size=8, start=6, end=8), + ) + assert get_conflict_degrees(allocations) == [1, 1, 0] + + +def test_conflict_degrees_allow_duplicate_ids() -> None: + duplicated = ( + Allocation(id=1, size=8, start=0, end=2), + Allocation(id=1, size=8, start=1, end=3), + ) + assert get_conflict_degrees(duplicated) == [1, 1] + + +def test_conflict_degrees_over_budget_give_up() -> None: + allocations = tuple(Allocation(id=i, size=8, start=0, end=10) for i in range(4)) + assert get_conflict_degrees(allocations, work_budget=1) is None + + +def test_conflict_degrees_unbounded_budget_always_counts() -> None: + allocations = tuple(Allocation(id=i, size=8, start=0, end=10) for i in range(4)) + assert get_conflict_degrees(allocations, work_budget=None) == [3, 3, 3, 3] + + +def test_conflict_degrees_reject_negative_budget() -> None: + with pytest.raises(ValueError, match="work_budget must be non-negative"): + get_conflict_degrees((), work_budget=-1) + + def _random_instance(rng: Random) -> tuple[Allocation, ...]: dim = rng.choice((1, 2, 3)) allocations = [] diff --git a/tests/unit/test_linearize.py b/tests/unit/analysis/test_linearize.py similarity index 89% rename from tests/unit/test_linearize.py rename to tests/unit/analysis/test_linearize.py index 8cbe4d7..aa9402f 100644 --- a/tests/unit/test_linearize.py +++ b/tests/unit/analysis/test_linearize.py @@ -14,7 +14,7 @@ def _overlap_map(allocations: tuple[Allocation, ...]) -> dict[object, set[object]]: - overlaps = compute_temporal_overlaps(allocations) + overlaps = compute_temporal_overlaps(allocations, None) return {a.id: set(overlaps.get(a.id, ())) for a in allocations} @@ -60,6 +60,25 @@ def test_two_plus_two_returns_none() -> None: assert try_linearize(allocations) is None +def test_tiny_work_budget_gives_up_undecided() -> None: + allocations = tuple( + Allocation(id=i, size=8, start=(i, i), end=(i + 1, i + 1)) for i in range(4) + ) + assert try_linearize(allocations, work_budget=1) is None + + +def test_unbounded_work_budget_decides() -> None: + allocations = tuple( + Allocation(id=i, size=8, start=(i, i), end=(i + 1, i + 1)) for i in range(4) + ) + assert try_linearize(allocations, work_budget=None) is not None + + +def test_negative_work_budget_rejected() -> None: + with pytest.raises(ValueError, match="work_budget must be non-negative"): + try_linearize((), work_budget=-1) + + def test_mixed_dimensions_rejected() -> None: mixed = ( Allocation(id=1, size=8, start=0, end=4), diff --git a/tests/unit/primitives/test_pressure.py b/tests/unit/analysis/test_pressure.py similarity index 97% rename from tests/unit/primitives/test_pressure.py rename to tests/unit/analysis/test_pressure.py index b39ae47..efb839e 100644 --- a/tests/unit/primitives/test_pressure.py +++ b/tests/unit/analysis/test_pressure.py @@ -7,8 +7,7 @@ import pytest from omnimalloc.allocators.omni import OmniAllocator -from omnimalloc.primitives import Allocation -from omnimalloc.primitives.pressure import ( +from omnimalloc.analysis.pressure import ( get_closure_pressure, get_per_allocation_closure_pressure, get_per_allocation_placement_pressure, @@ -16,6 +15,7 @@ get_placement_pressure, get_pressure, ) +from omnimalloc.primitives import Allocation def test_pressure_empty_is_zero() -> None: @@ -90,6 +90,16 @@ def test_pressure_work_budget_exceeded_raises() -> None: get_pressure(two_plus_two, work_budget=1) +def test_pressure_negative_work_budget_rejected() -> None: + with pytest.raises(ValueError, match="work_budget must be non-negative"): + get_pressure((), work_budget=-1) + + +def test_closure_pressure_negative_cap_rejected() -> None: + with pytest.raises(ValueError, match="closure_cap must be non-negative"): + get_closure_pressure((), closure_cap=-1) + + def test_pressure_total_size_overflow_raises() -> None: allocations = tuple(Allocation(id=i, size=2**62, start=0, end=1) for i in range(4)) with pytest.raises(OverflowError, match="int64"): diff --git a/tests/unit/test_vector_conflict_properties.py b/tests/unit/analysis/test_vector_conflict_properties.py similarity index 98% rename from tests/unit/test_vector_conflict_properties.py rename to tests/unit/analysis/test_vector_conflict_properties.py index 41a0455..d213ea0 100644 --- a/tests/unit/test_vector_conflict_properties.py +++ b/tests/unit/analysis/test_vector_conflict_properties.py @@ -126,7 +126,7 @@ def test_conflict_graph_matches_oracle(hi: int) -> None: allocations = [ make_allocation(i, start, end) for i, (start, end) in enumerate(lifetimes) ] - graph = _cpp.compute_temporal_overlaps(allocations) + graph = _cpp.compute_temporal_overlaps(allocations, None) for i, j in itertools.combinations(range(count), 2): expected = oracle_conflict(*lifetimes[i], *lifetimes[j]) assert (j in graph.get(i, set())) == expected @@ -155,7 +155,7 @@ def test_validator_catches_corrupted_placements(pattern: str) -> None: ).get_allocations() placed = list(OmniAllocator().allocate(allocations)) assert validate_allocation(Pool(id=0, allocations=tuple(placed))) - conflicts = _cpp.compute_temporal_overlaps(placed) + conflicts = _cpp.compute_temporal_overlaps(placed, None) index_by_id = {p.id: k for k, p in enumerate(placed)} for _ in range(5): i = rng.choice(sorted(conflicts)) diff --git a/tests/unit/benchmark/sources/test_concurrent_tiling.py b/tests/unit/benchmark/sources/test_concurrent_tiling.py index 16b991b..1b9ff52 100644 --- a/tests/unit/benchmark/sources/test_concurrent_tiling.py +++ b/tests/unit/benchmark/sources/test_concurrent_tiling.py @@ -4,10 +4,10 @@ import pytest from omnimalloc import run_allocation, validate_allocation +from omnimalloc.analysis.pressure import get_pressure from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.concurrent_tiling import ConcurrentTilingSource from omnimalloc.primitives import Allocation -from omnimalloc.primitives.pressure import get_pressure def _signatures( diff --git a/tests/unit/benchmark/sources/test_pinwheel.py b/tests/unit/benchmark/sources/test_pinwheel.py index 7bd5a0a..f0cea1a 100644 --- a/tests/unit/benchmark/sources/test_pinwheel.py +++ b/tests/unit/benchmark/sources/test_pinwheel.py @@ -4,10 +4,10 @@ import pytest from omnimalloc import run_allocation, validate_allocation +from omnimalloc.analysis.pressure import get_pressure from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.pinwheel import PinwheelSource from omnimalloc.primitives import Allocation, Pool -from omnimalloc.primitives.pressure import get_pressure def _signatures(allocations: tuple[Allocation, ...]) -> list[tuple[int, int, int]]: diff --git a/tests/unit/benchmark/sources/test_sync_patterns.py b/tests/unit/benchmark/sources/test_sync_patterns.py index cfefc34..84cc21b 100644 --- a/tests/unit/benchmark/sources/test_sync_patterns.py +++ b/tests/unit/benchmark/sources/test_sync_patterns.py @@ -3,10 +3,10 @@ # import pytest +from omnimalloc.analysis.pressure import get_pressure from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.sync_patterns import SYNC_PATTERNS, SyncPatternSource from omnimalloc.primitives import Allocation -from omnimalloc.primitives.pressure import get_pressure def _signatures( diff --git a/tests/unit/benchmark/sources/test_tiling.py b/tests/unit/benchmark/sources/test_tiling.py index 5d6a47e..fdc3212 100644 --- a/tests/unit/benchmark/sources/test_tiling.py +++ b/tests/unit/benchmark/sources/test_tiling.py @@ -4,10 +4,10 @@ import pytest from omnimalloc import run_allocation, validate_allocation +from omnimalloc.analysis.pressure import get_pressure from omnimalloc.benchmark.sources import BaseSource from omnimalloc.benchmark.sources.tiling import TilingSource from omnimalloc.primitives import Allocation -from omnimalloc.primitives.pressure import get_pressure def _signatures(allocations: tuple[Allocation, ...]) -> list[tuple[int, int, int]]: diff --git a/tests/unit/common/test_deadline.py b/tests/unit/common/test_deadline.py new file mode 100644 index 0000000..e2eba02 --- /dev/null +++ b/tests/unit/common/test_deadline.py @@ -0,0 +1,64 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# + +import time + +import pytest +from omnimalloc.common.deadline import ( + deadline_expired, + deadline_remaining, + ensure_valid_budget, + ensure_valid_timeout, + make_deadline, +) + + +@pytest.mark.parametrize("timeout", [0.001, 1, 3.5, None]) +def test_ensure_valid_timeout_accepts_positive_and_none(timeout: float | None) -> None: + ensure_valid_timeout(timeout) + + +@pytest.mark.parametrize( + "timeout", [0, 0.0, -1, -2.5, float("nan"), float("inf"), float("-inf")] +) +def test_ensure_valid_timeout_rejects_nonpositive_and_nonfinite( + timeout: float, +) -> None: + with pytest.raises(ValueError, match="positive or None"): + ensure_valid_timeout(timeout) + + +@pytest.mark.parametrize("budget", [0, 1, 100_000_000, None]) +def test_ensure_valid_budget_accepts_non_negative_and_none(budget: int | None) -> None: + ensure_valid_budget(budget) + + +def test_ensure_valid_budget_rejects_negative() -> None: + with pytest.raises(ValueError, match="work_budget must be non-negative"): + ensure_valid_budget(-1) + + +def test_ensure_valid_budget_names_the_parameter() -> None: + with pytest.raises(ValueError, match="linearize_budget must be non-negative"): + ensure_valid_budget(-1, name="linearize_budget") + + +def test_make_deadline_none_disables_budget() -> None: + assert make_deadline(None) is None + assert deadline_remaining(None) is None + assert deadline_expired(None) is False + + +def test_deadline_remaining_positive_before_expiry() -> None: + deadline = make_deadline(60.0) + remaining = deadline_remaining(deadline) + assert remaining is not None + assert 0 < remaining <= 60.0 + assert deadline_expired(deadline) is False + + +def test_deadline_remaining_clamps_to_zero_after_expiry() -> None: + expired = time.monotonic() - 1.0 + assert deadline_remaining(expired) == 0.0 + assert deadline_expired(expired) is True diff --git a/tests/unit/primitives/test_allocation_vector.py b/tests/unit/primitives/test_allocation_vector.py index addd581..4181737 100644 --- a/tests/unit/primitives/test_allocation_vector.py +++ b/tests/unit/primitives/test_allocation_vector.py @@ -5,8 +5,8 @@ import pickle import pytest +from omnimalloc.analysis.pressure import get_pressure from omnimalloc.primitives import Allocation -from omnimalloc.primitives.pressure import get_pressure def test_vector_creation() -> None: diff --git a/tests/unit/test_visualize.py b/tests/unit/test_visualize.py index de3e584..a7e9fcc 100644 --- a/tests/unit/test_visualize.py +++ b/tests/unit/test_visualize.py @@ -5,12 +5,19 @@ from pathlib import Path import pytest +from omnimalloc import visualize from omnimalloc.primitives import Allocation, BufferKind, Memory, Pool, System from omnimalloc.visualize import ( HAS_MATPLOTLIB, _byte_unit, _canonicalize, + _conflict_visibility, _format_bytes, + _lane_panels, + _overlap_pairs, + _panel_extents, + _projection_panels, + _select_lanes, plot_allocation, ) @@ -374,7 +381,259 @@ def test_visualize_vector_time_lanes(artifacts_dir: Path) -> None: pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3), offset=0) output_path = artifacts_dir / "test_vector_lanes.pdf" - result = plot_allocation(pool, file_path=output_path, canonicalize=True) + result = plot_allocation( + pool, file_path=output_path, canonicalize=True, view="lanes" + ) assert result == output_path assert output_path.exists() assert output_path.stat().st_size > 0 + + +def test_plot_allocation_rejects_unknown_view() -> None: + pool = Pool(id=1, allocations=(Allocation(id=1, size=1, start=0, end=1),)) + with pytest.raises(ValueError, match="view"): + plot_allocation(pool, view="spiral") + + +def test_plot_allocation_rejects_non_positive_max_lanes() -> None: + pool = Pool(id=1, allocations=(Allocation(id=1, size=1, start=0, end=1),)) + with pytest.raises(ValueError, match="max_lanes"): + plot_allocation(pool, max_lanes=0) + + +def test_plot_allocation_rejects_max_lanes_with_panel_view() -> None: + pool = Pool(id=1, allocations=(Allocation(id=1, size=1, start=0, end=1),)) + with pytest.raises(ValueError, match="max_lanes"): + plot_allocation(pool, max_lanes=2) + + +def test_plot_allocation_rejects_empty_system() -> None: + with pytest.raises(ValueError, match="no memories"): + plot_allocation(System(id="empty", memories=())) + with pytest.raises(ValueError, match="no memories"): + plot_allocation(System(id="empty", memories=()), view="lanes") + + +def test_visualize_vector_time_panel(artifacts_dir: Path) -> None: + alloc1 = Allocation(id=1, size=100, start=(0, 0), end=(3, 0), offset=100) + alloc2 = Allocation(id=2, size=100, start=(0, 0), end=(0, 3), offset=0) + pool = Pool(id=1, allocations=(alloc1, alloc2), offset=0) + + output_path = artifacts_dir / "test_vector_panel.pdf" + result = plot_allocation(pool, file_path=output_path) + assert result == output_path + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +def test_panel_extents_scalar_memory_is_identity_and_exact() -> None: + alloc1 = Allocation(id=1, size=10, start=2, end=7, offset=0) + alloc2 = Allocation(id=2, size=10, start=5, end=9, offset=10) + memory = Memory(id="mem", pools=(Pool(id=1, allocations=(alloc1, alloc2)),)) + + extents, exact = _panel_extents(memory) + + assert exact + assert extents[id(alloc1)] == (2, 7) + assert extents[id(alloc2)] == (5, 9) + + +def test_panel_extents_linearizable_vector_memory_is_exact() -> None: + alloc1 = Allocation(id=1, size=10, start=(0, 0), end=(1, 1), offset=0) + alloc2 = Allocation(id=2, size=10, start=(1, 1), end=(2, 2), offset=0) + alloc3 = Allocation(id=3, size=10, start=(2, 2), end=(3, 3), offset=0) + memory = Memory(id="mem", pools=(Pool(id=1, allocations=(alloc1, alloc2, alloc3)),)) + + extents, exact = _panel_extents(memory) + + assert exact + starts = [extents[id(a)] for a in (alloc1, alloc2, alloc3)] + assert starts == sorted(starts) + assert all(start < end for start, end in starts) + + +def _concurrent_memory() -> Memory: + alloc1 = Allocation(id=1, size=10, start=(0, 0), end=(1, 0), offset=0) + alloc2 = Allocation(id=2, size=10, start=(2, 0), end=(3, 0), offset=0) + alloc3 = Allocation(id=3, size=10, start=(0, 0), end=(0, 1), offset=10) + alloc4 = Allocation(id=4, size=10, start=(0, 2), end=(0, 3), offset=10) + return Memory( + id="mem", pools=(Pool(id=1, allocations=(alloc1, alloc2, alloc3, alloc4)),) + ) + + +def test_panel_extents_concurrent_vector_memory_falls_back_to_sums() -> None: + memory = _concurrent_memory() + alloc1, alloc2, alloc3, alloc4 = memory.pools[0].allocations + + extents, exact = _panel_extents(memory) + + assert not exact + assert extents[id(alloc1)] == (0, 1) + assert extents[id(alloc2)] == (2, 3) + assert extents[id(alloc3)] == (0, 1) + assert extents[id(alloc4)] == (2, 3) + + +@pytest.mark.parametrize( + ("intervals", "expected"), + [ + ([(0, 2), (2, 4), (4, 6)], 0), + ([(0, 3), (1, 4), (2, 5)], 3), + ([(0, 10), (1, 2), (3, 4)], 2), + ], +) +def test_overlap_pairs_ignores_touching_intervals( + intervals: list[tuple[int, int]], expected: int +) -> None: + allocations = [ + Allocation(id=i, size=1, start=start, end=end) + for i, (start, end) in enumerate(intervals) + ] + assert _overlap_pairs(allocations) == expected + + +def test_conflict_visibility_counts_hidden_conflicts() -> None: + memory = _concurrent_memory() + extents, _ = _panel_extents(memory) + + visible, total = _conflict_visibility(memory, extents) + + assert total == 4 + assert visible == 2 + + +def test_projection_panels_note_reports_conflict_visibility() -> None: + system = System(id="sys", memories=(_concurrent_memory(),)) + + panels, caveat = _projection_panels(system) + + assert panels[0].note == "2/4 conflicts visible" + assert caveat is not None + + +def test_projection_panels_skip_conflict_note_over_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + system = System(id="sys", memories=(_concurrent_memory(),)) + monkeypatch.setattr(visualize, "get_conflict_degrees", lambda _allocations: None) + + panels, caveat = _projection_panels(system) + + assert panels[0].note is None + assert caveat is not None + + +def test_projection_panels_use_per_memory_x_limits() -> None: + short_mem = Memory( + id="short", + pools=(Pool(id=1, allocations=(Allocation(id=1, size=10, start=0, end=4),)),), + ) + long_mem = Memory( + id="long", + pools=( + Pool(id=2, allocations=(Allocation(id=2, size=10, start=0, end=100_000),)), + ), + ) + system = System(id="sys", memories=(short_mem, long_mem)) + + panels, _ = _projection_panels(system) + + assert panels[0].x_limits == (0, 4) + assert panels[1].x_limits == (0, 100_000) + + +def test_lane_panels_use_per_lane_x_limits() -> None: + alloc = Allocation(id=1, size=10, start=(0, 0), end=(3, 7), offset=0) + memory = Memory(id="mem", pools=(Pool(id=1, allocations=(alloc,)),)) + system = System(id="sys", memories=(memory,)) + + panels, _ = _lane_panels(system, max_lanes=None) + + assert panels[0].x_limits == (0, 3) + assert panels[1].x_limits == (0, 7) + + +def test_lane_panels_x_limits_stay_independent_across_memories() -> None: + long_alloc = Allocation(id=1, size=10, start=(0, 0), end=(100, 5), offset=0) + short_alloc = Allocation(id=2, size=10, start=(0, 0), end=(10, 5), offset=0) + system = System( + id="sys", + memories=( + Memory(id="a", pools=(Pool(id=1, allocations=(long_alloc,)),)), + Memory(id="b", pools=(Pool(id=2, allocations=(short_alloc,)),)), + ), + ) + + panels, _ = _lane_panels(system, max_lanes=None) + + assert [panel.x_limits for panel in panels] == [ + (0, 100), + (0, 5), + (0, 10), + (0, 5), + ] + + +def test_select_lanes_keeps_all_lanes_when_under_cap() -> None: + allocations = [Allocation(id=1, size=10, start=(0, 0), end=(1, 1), offset=0)] + + assert _select_lanes(allocations, 2, None) == [0, 1] + assert _select_lanes(allocations, 2, 2) == [0, 1] + assert _select_lanes(allocations, 2, 5) == [0, 1] + + +def test_select_lanes_picks_top_k_by_definite_peak() -> None: + allocations = [ + Allocation(id=1, size=100, start=(0, 0), end=(4, 0), offset=0), + Allocation(id=2, size=60, start=(0, 0), end=(0, 4), offset=100), + Allocation(id=3, size=60, start=(0, 1), end=(0, 3), offset=160), + ] + + assert _select_lanes(allocations, 2, 1) == [1] + + +def test_lane_panels_titles_report_truncation() -> None: + alloc1 = Allocation(id=1, size=100, start=(0, 0), end=(4, 0), offset=0) + alloc2 = Allocation(id=2, size=60, start=(0, 0), end=(0, 4), offset=100) + alloc3 = Allocation(id=3, size=60, start=(0, 1), end=(0, 3), offset=160) + pool = Pool(id=1, allocations=(alloc1, alloc2, alloc3), offset=0) + system = System(id="sys", memories=(Memory(id="mem", pools=(pool,)),)) + + panels, caveat = _lane_panels(system, max_lanes=1) + + assert len(panels) == 1 + assert panels[0].title is not None + assert "top 1 of 2 threads" in panels[0].title + assert panels[0].xlabel == "Thread 1 Time (Step)" + assert caveat is not None + + +def test_panel_projection_never_shows_false_conflicts(artifacts_dir: Path) -> None: + rng_starts = [(i, 0) if i % 2 == 0 else (0, i) for i in range(1, 9)] + allocations = tuple( + Allocation( + id=i, + size=10 + i, + start=start, + end=(start[0] + 2, start[1]) if start[1] == 0 else (0, start[1] + 2), + offset=20 * i, + ) + for i, start in enumerate(rng_starts) + ) + memory = Memory(id="mem", pools=(Pool(id=1, allocations=allocations),)) + + extents, exact = _panel_extents(memory) + + assert not exact + for a in allocations: + for b in allocations: + if a.id >= b.id: + continue + (sa, ea), (sb, eb) = extents[id(a)], extents[id(b)] + if sa < eb and sb < ea: + assert a.overlaps_temporally(b) + + output_path = artifacts_dir / "test_panel_soundness.pdf" + assert plot_allocation(memory, file_path=output_path) == output_path + assert output_path.exists()