diff --git a/README.md b/README.md index 07e20df..d5727f4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,30 @@ -# OmniMalloc +

OmniMalloc

-Your one-stop shop for static memory allocation. +

State-of-the-art static memory allocation for neural networks.

+ +

+ Checks + Build + PyPI + Python versions + License +

+ + + + Solution quality vs. solve time across allocators + + +OmniMalloc is a Python library for **static memory allocation**: given buffers +with known sizes and lifetimes, assign offsets so that **peak memory is minimized**. +This is the memory-planning step at the heart of **ML compilers**, embedded +runtimes, and accelerator toolchains. + +It ships a collection of allocators and allocation algorithms behind one API, +implemented with an efficient C++ backend. This includes **SuperMalloc**, a new +allocator that **outperforms the best open-source alternatives** (see benchmarks +below). OmniMalloc also provides a rich benchmark harness and visualization +tools to develop and evaluate new allocation strategies. ## Installation @@ -10,7 +34,48 @@ pip install omnimalloc ## Usage -Please refer to [examples](examples/), in particular [examples/01_basic.py](examples/01_basic.py). +```python +from omnimalloc import Allocation, Pool, run_allocation + +pool = Pool(id="pool", allocations=( + Allocation(id=0, size=64, start=0, end=10), + Allocation(id=1, size=64, start=12, end=20), + Allocation(id=2, size=32, start=5, end=15), +)) + +pool = run_allocation(pool, allocator="supermalloc_allocator", validate=True) + +print(pool.size) # 96 +print([alloc.offset for alloc in pool.allocations]) # [0, 0, 64] +``` + +On a real problem, the result looks like this: 308 buffers of an ML workload +packed with zero wasted memory. + + + + A solved allocation problem rendered as offset/time rectangles + + +See [examples](examples/) for allocator selection, visualization, custom +allocation sources, and benchmarking. + +## Benchmarks + +

+ + + Packing efficiency per problem + + + + Solve time vs. problem size + +

+ +Every figure on this page is generated from a deterministic benchmark run by +[`scripts/generate_readme_assets.py`](scripts/generate_readme_assets.py). +Run your own campaigns with the [benchmark harness](examples/05_benchmark.py). ## Development diff --git a/assets/allocation_dark.svg b/assets/allocation_dark.svg new file mode 100644 index 0000000..601ef49 --- /dev/null +++ b/assets/allocation_dark.svg @@ -0,0 +1,4453 @@ + + + + + + + + 2026-07-06T14:54:56.315364 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/allocation_light.svg b/assets/allocation_light.svg new file mode 100644 index 0000000..f813bd9 --- /dev/null +++ b/assets/allocation_light.svg @@ -0,0 +1,4453 @@ + + + + + + + + 2026-07-06T14:54:55.829702 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/hero_dark.svg b/assets/hero_dark.svg new file mode 100644 index 0000000..bb61313 --- /dev/null +++ b/assets/hero_dark.svg @@ -0,0 +1,2541 @@ + + + + + + + + 2026-07-06T14:54:55.951662 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/hero_light.svg b/assets/hero_light.svg new file mode 100644 index 0000000..e879ef4 --- /dev/null +++ b/assets/hero_light.svg @@ -0,0 +1,2541 @@ + + + + + + + + 2026-07-06T14:54:55.441941 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/quality_dark.svg b/assets/quality_dark.svg new file mode 100644 index 0000000..75f4962 --- /dev/null +++ b/assets/quality_dark.svg @@ -0,0 +1,2433 @@ + + + + + + + + 2026-07-06T14:54:56.061886 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/quality_light.svg b/assets/quality_light.svg new file mode 100644 index 0000000..0b95b57 --- /dev/null +++ b/assets/quality_light.svg @@ -0,0 +1,2433 @@ + + + + + + + + 2026-07-06T14:54:55.564089 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/scaling_dark.svg b/assets/scaling_dark.svg new file mode 100644 index 0000000..f127dbd --- /dev/null +++ b/assets/scaling_dark.svg @@ -0,0 +1,2024 @@ + + + + + + + + 2026-07-06T14:54:56.142872 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/scaling_light.svg b/assets/scaling_light.svg new file mode 100644 index 0000000..22f51b0 --- /dev/null +++ b/assets/scaling_light.svg @@ -0,0 +1,2024 @@ + + + + + + + + 2026-07-06T14:54:55.660859 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/generate_readme_assets.py b/scripts/generate_readme_assets.py new file mode 100644 index 0000000..e313846 --- /dev/null +++ b/scripts/generate_readme_assets.py @@ -0,0 +1,679 @@ +# +# SPDX-License-Identifier: Apache-2.0 +# +"""Generate the figures embedded in the README. + +Runs a deterministic benchmark suite and renders four figures, each in a light +and a dark variant with transparent backgrounds (GitHub picks the right one via +a ```` element): + +- ``hero``: packing efficiency vs. solve time across allocators (Pareto view) +- ``quality``: per-problem packing efficiency for three allocators +- ``scaling``: solve time vs. problem size +- ``allocation``: a solved allocation rendered as offset/time rectangles + +Regenerate the committed assets with: + + uv run python scripts/generate_readme_assets.py + +The benchmark portion takes a few minutes (genetic search and branch-and-bound +timeouts dominate). Use ``--dump data.json`` once and ``--data data.json`` to +iterate on rendering without re-running it. ``--preview DIR`` additionally +writes PNG previews. +""" + +from __future__ import annotations + +import argparse +import importlib +import json +import random +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import matplotlib as mpl +import matplotlib.pyplot as plt +from matplotlib.colors import LinearSegmentedColormap +from matplotlib.patches import Rectangle +from matplotlib.ticker import FuncFormatter, MultipleLocator +from omnimalloc import run_allocation, validate_allocation +from omnimalloc.allocators import BaseAllocator +from omnimalloc.allocators.supermalloc import SupermallocAllocator, SupermallocConfig +from omnimalloc.benchmark.sources import BaseSource +from omnimalloc.common.units import MB + +if TYPE_CHECKING: + from matplotlib.axes import Axes + from matplotlib.figure import Figure + from omnimalloc.primitives import Pool + +SEED = 0 +SUPERMALLOC_TIMEOUT = 10.0 +SCALING_TIMEOUT = 3.0 +MINIMALLOC_URL = "git+https://github.com/google/minimalloc.git" +SCALING_SIZES = (10, 32, 100, 316, 1000, 3162, 10000) +SCALING_SIZES_SLOW = SCALING_SIZES[:-1] # hill climbing needs minutes at 10k + +ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" + +# Hero points: allocator -> (display label, palette role). +HERO_ALLOCATORS: dict[str, tuple[str, str]] = { + "random_allocator": ("random search", "baseline"), + "greedy_by_area_allocator_cpp": ("greedy (area)", "greedy"), + "greedy_by_size_allocator_cpp": ("greedy (size)", "greedy"), + "greedy_by_all_allocator_cpp": ("greedy (all)", "greedy"), + "hill_climb_allocator": ("hill climbing", "search"), + "genetic_allocator": ("genetic", "search"), + "minimalloc": ("minimalloc", "minimalloc"), + "supermalloc": ("supermalloc", "exact"), +} + +QUALITY_ALLOCATORS = ( + "greedy_by_size_allocator_cpp", + "greedy_by_all_allocator_cpp", + "minimalloc", + "supermalloc", +) +QUALITY_PROBLEMS = ("mm-A", "mm-C", "mm-H", "mm-K", "pinwheel", "tiling", "random") + +SCALING_ALLOCATORS: dict[str, tuple[str, str]] = { + "naive_allocator": ("naive", "baseline"), + "greedy_by_size_allocator_cpp": ("greedy (size)", "greedy"), + "hill_climb_allocator": ("hill climbing", "search"), + "minimalloc": ("minimalloc", "minimalloc"), + "supermalloc": ("supermalloc", "exact"), +} + + +@dataclass(frozen=True) +class Theme: + name: str + ink: str + muted: str + faint: str + grid: str + optimal: str + role: dict[str, str] # baseline / greedy / search / exact + + +LIGHT = Theme( + name="light", + ink="#24292f", + muted="#59626d", + faint="#818b98", + grid="#d0d7de", + optimal="#1a7f37", + role={ + "baseline": "#848d97", + "greedy": "#0969da", + "greedy_alt": "#54aeff", + "search": "#bc4c00", + "minimalloc": "#bf3989", + "exact": "#8250df", + }, +) +DARK = Theme( + name="dark", + ink="#e6edf3", + muted="#9198a1", + faint="#767e88", + grid="#30363d", + optimal="#3fb950", + role={ + "baseline": "#768390", + "greedy": "#4493f8", + "greedy_alt": "#79c0ff", + "search": "#f0883e", + "minimalloc": "#db61a2", + "exact": "#ab7df8", + }, +) + + +def _pip_install(spec: str) -> None: + """Install a package into the running interpreter, preferring uv over pip.""" + for cmd in ( + ["uv", "pip", "install", "--python", sys.executable, spec], + [sys.executable, "-m", "pip", "install", spec], + ): + if shutil.which(cmd[0]) and subprocess.run(cmd, check=False).returncode == 0: + return + raise RuntimeError(f"Could not install {spec!r}; install it manually") + + +def _ensure_minimalloc() -> None: + """Install Google's minimalloc on demand (no PyPI wheel) and refresh the wrapper.""" + try: + import minimalloc # type: ignore # noqa: F401 + except ImportError: + pass + else: + return + print(f"minimalloc not installed — installing from {MINIMALLOC_URL} ...") + _pip_install(MINIMALLOC_URL) + importlib.invalidate_caches() + # The wrapper decides availability at import time; reload it post-install. + importlib.reload(importlib.import_module("omnimalloc.allocators.minimalloc")) + + +def _allocator(name: str, timeout: float = SUPERMALLOC_TIMEOUT) -> BaseAllocator: + if name == "supermalloc": + return SupermallocAllocator(config=SupermallocConfig(timeout=timeout)) + if name == "minimalloc": + from omnimalloc.allocators.minimalloc import MinimallocAllocator + + return MinimallocAllocator(timeout=int(timeout)) + return BaseAllocator.get(name)() + + +def _solve(pool: Pool, allocator: BaseAllocator) -> tuple[float, float, Pool]: + """Time the solve alone; validation is quadratic and would skew timings.""" + start = time.perf_counter() + solved = run_allocation(pool, allocator=allocator) + seconds = time.perf_counter() - start + validate_allocation(solved) + return seconds, solved.efficiency, solved + + +def _hard_suite() -> dict[str, Pool]: + """Real minimalloc benchmarks plus adversarial synthetic patterns.""" + suite: dict[str, Pool] = {} + minimalloc = BaseSource.get("minimalloc_source")() + for variant in minimalloc.get_available_variants(): + suite[f"mm-{variant.split('.')[0]}"] = minimalloc.get_variant(variant) + suite["pinwheel"] = BaseSource.get("pinwheel_source")().get_variant(101) + suite["tiling"] = BaseSource.get("tiling_source")().get_variant(100) + suite["random"] = BaseSource.get("random_source")().get_variant(250) + return suite + + +def collect_data() -> dict[str, Any]: + _ensure_minimalloc() + random.seed(SEED) + suite = _hard_suite() + hard = {k: v for k, v in suite.items() if k != "random"} + + # Hero + quality: every allocator over every problem. + runs: dict[str, dict[str, tuple[float, float]]] = {} + names = set(HERO_ALLOCATORS) | set(QUALITY_ALLOCATORS) + for name in sorted(names): + allocator = _allocator(name) + runs[name] = {} + for problem, pool in suite.items(): + seconds, efficiency, _ = _solve(pool, allocator) + runs[name][problem] = (seconds, efficiency) + print(f"{name:38s} {problem:10s} {seconds:8.3f}s {efficiency:7.2%}") + + hero = { + name: { + "seconds": sum(runs[name][p][0] for p in hard) / len(hard), + "efficiency": sum(runs[name][p][1] for p in hard) / len(hard) * 100, + } + for name in HERO_ALLOCATORS + } + quality = { + name: {p: runs[name][p][1] * 100 for p in QUALITY_PROBLEMS} + for name in QUALITY_ALLOCATORS + } + + # Scaling: solve time vs. problem size on the random source. + source = BaseSource.get("random_source")() + scaling: dict[str, dict[str, float]] = {} + for name in SCALING_ALLOCATORS: + allocator = _allocator(name, SCALING_TIMEOUT) + # Hill climbing needs minutes at 10k; minimalloc can't solve 10k in budget. + capped = name in ("hill_climb_allocator", "minimalloc") + sizes = SCALING_SIZES_SLOW if capped else SCALING_SIZES + scaling[name] = {} + for size in sizes: + seconds, _, _ = _solve(source.get_variant(size), allocator) + scaling[name][str(size)] = seconds + print(f"{name:38s} n={size:<6d} {seconds:8.3f}s") + + # Allocation rendering: one real problem solved to proven optimality. + problem = "mm-G" + _, efficiency, solved = _solve(suite[problem], _allocator("supermalloc")) + allocation = { + "problem": QUALITY_ROW_LABELS.get(problem, problem), + "efficiency": efficiency, + "size": solved.size, + "rects": [[a.start, a.duration, a.offset, a.size] for a in solved.allocations], + } + + return { + "hero": hero, + "quality": quality, + "scaling": scaling, + "allocation": allocation, + } + + +def _style(theme: Theme) -> dict[str, Any]: + return { + "font.family": ["Lato", "DejaVu Sans"], + "font.size": 9.0, + "svg.fonttype": "path", + "figure.facecolor": "none", + "axes.facecolor": "none", + "savefig.facecolor": "none", + "savefig.transparent": True, + "axes.edgecolor": theme.grid, + "axes.labelcolor": theme.muted, + "axes.linewidth": 0.8, + "xtick.color": theme.grid, + "ytick.color": theme.grid, + "xtick.labelcolor": theme.muted, + "ytick.labelcolor": theme.muted, + "xtick.labelsize": 8.0, + "ytick.labelsize": 8.0, + "axes.labelsize": 8.5, + } + + +def _despine(ax: Axes, keep: tuple[str, ...] = ("left", "bottom")) -> None: + for side, spine in ax.spines.items(): + spine.set_visible(side in keep) + + +def _title(fig: Figure, theme: Theme, title: str, subtitle: str) -> None: + fig.text( + 0.01, + 0.99, + title, + ha="left", + va="top", + fontsize=11.5, + fontweight="bold", + color=theme.ink, + ) + fig.text( + 0.01, 0.905, subtitle, ha="left", va="top", fontsize=8.5, color=theme.muted + ) + + +def _series_line(fig: Figure, series: list[tuple[str, str]], y: float) -> None: + """Draw an inline legend: colored '● label' entries on one figure line.""" + x = 0.012 + for label, color in series: + fig.text(x, y, f"● {label}", ha="left", va="top", fontsize=8, color=color) + x += 0.033 + len(label) * 0.0148 + + +def _format_seconds(value: float) -> str: + if value < 1e-3: + return f"{value * 1e6:.0f} µs" + if value < 1: + return f"{value * 1e3:.0f} ms" + return f"{value:.0f} s" + + +def _format_steps(value: float, _pos: int) -> str: + if value >= 1e6: + return f"{value / 1e6:g}M" + if value >= 1e3: + return f"{value / 1e3:g}k" + return f"{value:g}" + + +def _save(fig: Figure, name: str, theme: Theme, preview: Path | None) -> None: + ASSETS_DIR.mkdir(parents=True, exist_ok=True) + svg = ASSETS_DIR / f"{name}_{theme.name}.svg" + fig.savefig(svg, bbox_inches="tight", pad_inches=0.02) + # matplotlib emits trailing whitespace on some lines; strip it so the + # committed assets stay clean under the trailing-whitespace pre-commit hook. + svg.write_text( + "\n".join(line.rstrip() for line in svg.read_text().splitlines()) + "\n" + ) + if preview is not None: + preview.mkdir(parents=True, exist_ok=True) + fig.savefig( + preview / f"{name}_{theme.name}.png", + dpi=220, + bbox_inches="tight", + pad_inches=0.02, + facecolor="#ffffff" if theme.name == "light" else "#0d1117", + transparent=False, + ) + plt.close(fig) + + +# Direct-label offsets in points, tuned per hero point: (dx, dy, ha). +HERO_LABEL_OFFSETS: dict[str, tuple[float, float, str]] = { + "random search": (0, -11, "center"), + "greedy (area)": (0, 8, "center"), + "greedy (size)": (-8, 0, "right"), + "greedy (all)": (0, -11, "center"), + "hill climbing": (0, 8, "center"), + "genetic": (0, -11, "center"), + "minimalloc": (0, 8, "center"), + "supermalloc": (-10, 0, "right"), +} + + +def render_hero(data: dict[str, Any], theme: Theme, preview: Path | None) -> None: + fig, ax = plt.subplots(figsize=(7.4, 3.5)) + + points = { + label: (data[name]["seconds"], data[name]["efficiency"], role) + for name, (label, role) in HERO_ALLOCATORS.items() + } + + ax.axhline( + 100, + color=theme.optimal, + linewidth=0.8, + linestyle=(0, (4, 4)), + alpha=0.8, + zorder=1, + ) + ax.annotate( + "optimal", + xy=(1.0, 100), + xycoords=("axes fraction", "data"), + xytext=(-2, 3), + textcoords="offset points", + ha="right", + fontsize=7.5, + color=theme.optimal, + ) + + # Pareto frontier: lower time and higher efficiency dominate. + ordered = sorted(points.values(), key=lambda p: (p[0], -p[1])) + front, best = [], float("-inf") + for seconds, efficiency, _ in ordered: + if efficiency > best: + front.append((seconds, efficiency)) + best = efficiency + ax.plot( + *zip(*front, strict=False), + color=theme.faint, + linewidth=0.9, + linestyle=(0, (1, 2)), + zorder=2, + ) + + for label, (seconds, efficiency, role) in points.items(): + color = theme.role[role] + emphasis = label == "supermalloc" + ax.scatter( + seconds, + efficiency, + s=110 if emphasis else 52, + color=color, + linewidths=1.4 if emphasis else 0, + edgecolors=theme.ink if emphasis else "none", + zorder=4, + ) + dx, dy, ha = HERO_LABEL_OFFSETS[label] + ax.annotate( + label, + (seconds, efficiency), + xytext=(dx, dy), + textcoords="offset points", + ha=ha, + va="center", + fontsize=8.5 if emphasis else 8, + color=color, + fontweight="bold" if emphasis else "normal", + zorder=5, + ) + + ax.set_xscale("log") + ax.set_xlim(1.2e-3, 22) + ax.set_ylim(52, 104) + ticks = (1e-3, 1e-2, 1e-1, 1, 10) + ax.set_xticks(ticks) + ax.set_xticklabels([_format_seconds(t) for t in ticks]) + ax.grid(visible=True, axis="both", color=theme.grid, linewidth=0.5, alpha=0.45) + _despine(ax, keep=()) + ax.tick_params(length=0, which="both") + ax.minorticks_off() + ax.set_xlabel("mean solve time (log scale)") + ax.set_ylabel("mean packing efficiency (%)") + + _title( + fig, + theme, + "Solution quality vs. solve time", + "13 hard problems: 11 real-world minimalloc benchmarks " + "and 2 adversarial patterns · 100% = proven lower bound", + ) + fig.subplots_adjust(top=0.80, bottom=0.13, left=0.075, right=0.985) + _save(fig, "hero", theme, preview) + + +QUALITY_LABELS = { + "greedy_by_size_allocator_cpp": ("greedy (size)", "greedy_alt"), + "greedy_by_all_allocator_cpp": ("greedy (all)", "greedy"), + "minimalloc": ("minimalloc", "minimalloc"), + "supermalloc": ("supermalloc", "exact"), +} +QUALITY_ROW_LABELS = { + "mm-A": "minimalloc A", + "mm-C": "minimalloc C", + "mm-G": "minimalloc G", + "mm-H": "minimalloc H", + "mm-K": "minimalloc K", + "pinwheel": "pinwheel", + "tiling": "tiling", + "random": "random (easy)", +} + + +def render_quality(data: dict[str, Any], theme: Theme, preview: Path | None) -> None: + fig, ax = plt.subplots(figsize=(3.8, 3.35)) + + rows = list(QUALITY_PROBLEMS) + ys = range(len(rows), 0, -1) + + ax.axvline( + 100, + color=theme.optimal, + linewidth=0.8, + linestyle=(0, (4, 4)), + alpha=0.8, + zorder=1, + ) + + for row, y in zip(rows, ys, strict=True): + values = [data[name][row] for name in QUALITY_ALLOCATORS] + ax.plot( + [min(values), max(values)], + [y, y], + color=theme.grid, + linewidth=1.1, + zorder=2, + solid_capstyle="round", + ) + for name, value in zip(QUALITY_ALLOCATORS, values, strict=True): + _, role = QUALITY_LABELS[name] + emphasis = name == "supermalloc" + ax.scatter( + value, + y, + s=52 if emphasis else 34, + color=theme.role[role], + zorder=4, + linewidths=1.2 if emphasis else 0, + edgecolors=theme.ink if emphasis else "none", + ) + + ax.set_yticks(list(ys)) + ax.set_yticklabels([QUALITY_ROW_LABELS[r] for r in rows], fontsize=8.5) + ax.set_ylim(0.4, len(rows) + 0.6) + ax.set_xlim(60, 103) + ax.set_xticks((60, 70, 80, 90, 100)) + ax.grid(visible=True, axis="x", color=theme.grid, linewidth=0.5, alpha=0.45) + _despine(ax, keep=()) + ax.tick_params(length=0) + ax.set_xlabel("packing efficiency (%)") + + _title(fig, theme, "Quality per problem", "100% = proven lower bound") + series = [(label, theme.role[role]) for label, role in QUALITY_LABELS.values()] + _series_line(fig, series, y=0.842) + fig.subplots_adjust(top=0.775, bottom=0.125, left=0.265, right=0.97) + _save(fig, "quality", theme, preview) + + +# Direct-label offsets in points: (dx, dy, ha). minimalloc's line ends early +# (no 10k point), so its label anchors left, away from the 10k label cluster. +SCALING_LABEL_OFFSETS = { + "naive": (4, -2, "left"), + "greedy (size)": (4, -4, "left"), + "hill climbing": (4, 2, "left"), + "minimalloc": (0, 9, "center"), + "supermalloc": (4, 4, "left"), +} + + +def render_scaling(data: dict[str, Any], theme: Theme, preview: Path | None) -> None: + fig, ax = plt.subplots(figsize=(3.8, 3.35)) + + for name, (label, role) in SCALING_ALLOCATORS.items(): + series = data[name] + sizes = sorted(int(k) for k in series) + seconds = [series[str(n)] for n in sizes] + color = theme.role[role] + emphasis = name == "supermalloc" + ax.plot( + sizes, + seconds, + color=color, + linewidth=1.8 if emphasis else 1.4, + marker="o", + markersize=3.4, + markeredgewidth=0, + zorder=4, + ) + dx, dy, ha = SCALING_LABEL_OFFSETS[label] + ax.annotate( + label, + (sizes[-1], seconds[-1]), + xytext=(dx, dy), + textcoords="offset points", + ha=ha, + va="center", + fontsize=8, + color=color, + fontweight="bold" if emphasis else "normal", + ) + + ax.set_xscale("log") + ax.set_yscale("log") + ax.set_xlim(8, 1.5e5) + ax.set_xticks((10, 100, 1000, 10000)) + ax.set_xticklabels(["10", "100", "1k", "10k"]) + yticks = (1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1, 10, 100) + ax.set_ylim(3e-6, 400) + ax.set_yticks(yticks) + ax.set_yticklabels([_format_seconds(t) for t in yticks]) + ax.grid(visible=True, axis="both", color=theme.grid, linewidth=0.5, alpha=0.45) + _despine(ax, keep=()) + ax.tick_params(length=0, which="both") + ax.minorticks_off() + ax.set_xlabel("number of allocations") + ax.set_ylabel("solve time") + + _title( + fig, + theme, + "Scaling", + f"random problems · supermalloc budget {SCALING_TIMEOUT:.0f} s", + ) + fig.subplots_adjust(top=0.845, bottom=0.125, left=0.16, right=0.97) + _save(fig, "scaling", theme, preview) + + +def render_allocation(data: dict[str, Any], theme: Theme, preview: Path | None) -> None: + fig, ax = plt.subplots(figsize=(7.4, 2.9)) + + rects = data["rects"] + size = data["size"] + max_end = max(start + duration for start, duration, _, _ in rects) + + # Color each buffer by size quantile within the greedy-to-exact hue range. + cmap = LinearSegmentedColormap.from_list( + "buffers", [theme.role["greedy"], theme.role["exact"]] + ) + ordered_sizes = sorted(r[3] for r in rects) + for start, duration, offset, height in rects: + quantile = ordered_sizes.index(height) / max(len(ordered_sizes) - 1, 1) + ax.add_patch( + Rectangle( + (start, offset), + duration, + height, + facecolor=cmap(quantile), + alpha=0.85, + edgecolor=theme.ink, + linewidth=0.2, + ) + ) + + ax.axhline( + size, color=theme.optimal, linewidth=0.9, linestyle=(0, (4, 4)), zorder=5 + ) + ax.annotate( + f"peak {size / MB:.2f} MB = lower bound (proven optimal)", + xy=(0.995, size), + xycoords=("axes fraction", "data"), + xytext=(0, 4), + textcoords="offset points", + ha="right", + fontsize=8, + color=theme.optimal, + zorder=6, + ) + + ax.set_xlim(0, max_end) + ax.set_ylim(0, size * 1.14) + ax.yaxis.set_major_locator(MultipleLocator(0.25 * MB)) + ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v / MB:g}")) + ax.xaxis.set_major_formatter(FuncFormatter(_format_steps)) + ax.grid(visible=True, axis="y", color=theme.grid, linewidth=0.5, alpha=0.45) + _despine(ax, keep=()) + ax.tick_params(length=0) + ax.set_xlabel("time step") + ax.set_ylabel("offset (MB)") + + _title( + fig, + theme, + "A solved problem", + f"{data['problem']} · {len(rects)} buffers packed at " + f"{data['efficiency']:.0%} efficiency by supermalloc", + ) + fig.subplots_adjust(top=0.775, bottom=0.155, left=0.055, right=0.985) + _save(fig, "allocation", theme, preview) + + +def render_all(data: dict[str, Any], preview: Path | None) -> None: + for theme in (LIGHT, DARK): + with mpl.rc_context(_style(theme)): + render_hero(data["hero"], theme, preview) + render_quality(data["quality"], theme, preview) + render_scaling(data["scaling"], theme, preview) + render_allocation(data["allocation"], theme, preview) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data", type=Path, help="load benchmark data from JSON") + parser.add_argument("--dump", type=Path, help="write benchmark data to JSON") + parser.add_argument("--preview", type=Path, help="also write PNG previews") + args = parser.parse_args() + + data = json.loads(args.data.read_text()) if args.data else collect_data() + if args.dump: + args.dump.write_text(json.dumps(data, indent=2)) + + render_all(data, args.preview) + + +if __name__ == "__main__": + main()