Algorithm Search⌘K

Balance Sort in Python

An optimal external sort for the parallel disk model that keeps records balanced across disks while distributing them.

File
balance_sort.py, 177 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
balance_sort.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/balance-sort/. The authors are listed in prov/A6ZN06.jsonl.

from bisect import bisect_right
from typing import Any, Protocol, TypeVar


class Ordered(Protocol):
    def __lt__(self, other: Any, /) -> bool: ...


T = TypeVar("T", bound=Ordered)


class Rec:
    """An item with its position in the input. Ties on the key go by position, so all records differ."""

    __slots__ = ("key", "pos")

    def __init__(self, key: Any, pos: int) -> None:
        self.key, self.pos = key, pos

    def __lt__(self, other: "Rec") -> bool:
        if self.key < other.key:
            return True
        if other.key < self.key:
            return False
        return self.pos < other.pos


class Stats:
    """Counts of parallel I/O steps, and the smallest share of a write step."""

    def __init__(self) -> None:
        self.ios = 0
        self.writes: list[tuple[int, int]] = []  # (blocks ready, blocks written) per write step


File = list[list[list[Rec]]]  # disk -> blocks on that disk -> items


def striped(items: list[Rec], b: int, d: int) -> File:
    """Lay out items in blocks of b, block j on disk j mod d."""
    f: File = [[] for _ in range(d)]
    for j, i in enumerate(range(0, len(items), b)):
        f[j % d].append(items[i:i + b])
    return f


def size(f: File) -> int:
    return sum(len(blk) for disk in f for blk in disk)


def read_cost(f: File) -> int:
    """Parallel reads of a whole file: one block from each disk per step."""
    return max((len(disk) for disk in f), default=0)


def place(counts: list[int], total: int, used: set[int], d: int) -> int:
    """The disk for the next block of a bucket, or -1.

    counts[e] blocks of the bucket are on disk e, total in all. A disk is
    allowed if it is not used in this write step and if after the write it
    holds at most 2 * ceil((total + 1) / d) blocks of the bucket. Among the
    allowed disks take one with the fewest blocks of the bucket.
    """
    cap = 2 * (-(-(total + 1) // d))
    best = -1
    for e in range(d):
        if e not in used and counts[e] + 1 <= cap and (best < 0 or counts[e] < counts[best]):
            best = e
    return best


def distribute(f: File, splitters: list[Rec], b: int, d: int, st: Stats) -> list[File]:
    """One distribution pass. Bucket j holds the records x with s_j <= x < s_(j+1)."""
    k = len(splitters) + 1
    out: list[File] = [[[] for _ in range(d)] for _ in range(k)]
    counts = [[0] * d for _ in range(k)]
    totals = [0] * k
    partial: list[list[Rec]] = [[] for _ in range(k)]
    ready: list[tuple[int, list[Rec]]] = []

    def write_step() -> None:  # one parallel write
        nonlocal ready
        used: set[int] = set()
        rest: list[tuple[int, list[Rec]]] = []
        for j, blk in ready:
            e = place(counts[j], totals[j], used, d) if len(used) < d else -1  # @line L7
            if e < 0:
                rest.append((j, blk))
                continue
            used.add(e)
            out[j][e].append(blk)  # @line L8
            counts[j][e] += 1
            totals[j] += 1
        st.writes.append((len(ready), len(used)))
        st.ios += 1
        ready = rest

    for step in range(read_cost(f)):  # @line L5
        st.ios += 1
        for disk in f:
            if step < len(disk):
                for x in disk[step]:
                    j = bisect_right(splitters, x)  # @line L6
                    partial[j].append(x)
                    if len(partial[j]) == b:
                        ready.append((j, partial[j]))
                        partial[j] = []
        while len(ready) >= d:
            write_step()
    for j in range(k):
        if partial[j]:
            ready.append((j, partial[j]))
    while ready:
        write_step()
    return out


def splitters_of(f: File, m: int, b: int, d: int, s: int, st: Stats) -> list[Rec]:
    """Deterministic sampling: every q-th record of each sorted memoryload, then equal ranks."""
    q = max(2, -(-m // (4 * s)))
    sample: list[Rec] = []
    load: list[Rec] = []
    blocks_per_load = m // b
    for step in range(read_cost(f)):
        st.ios += 1
        for disk in f:
            if step < len(disk):
                load.extend(disk[step])
        if len(load) >= blocks_per_load * b or step == read_cost(f) - 1:
            load.sort()
            sample.extend(load[q - 1::q])
            load = []
    if len(sample) > m:  # the sample does not fit: write it and sort it the same way
        g = striped(sample, b, d)
        st.ios += -(-(-(-len(sample) // b)) // d)
        sample = sort_file(g, m, b, d, st)
    else:
        sample.sort()
    r = max(2, len(sample) // s)
    return [sample[i * r - 1] for i in range(1, s) if i * r <= len(sample)]


def sort_file(f: File, m: int, b: int, d: int, st: Stats) -> list[Rec]:
    n = size(f)
    if n <= m:  # @line L1
        st.ios += read_cost(f)
        items = sorted(x for disk in f for blk in disk for x in blk)
        st.ios += -(-(-(-n // b)) // d)
        return items
    s = max(2, min(m // b - 2 * d, -(-4 * n // m)))  # @line L2
    sp = splitters_of(f, m, b, d, s, st)  # @line L3
    result: list[Rec] = []
    for g in distribute(f, sp, b, d, st):  # @line L4
        result.extend(sort_file(g, m, b, d, st))  # @line L9
    return result


def balance_sort(xs: list[T], m: int = 64, b: int = 4, d: int = 4, stats: "Stats | None" = None) -> int:
    """Sort xs in place with a simulated Balance Sort on d disks; return the parallel I/O steps.

    Precondition: the items are mutually comparable, b >= 1, d >= 1 and
    m >= (2d + 4) b, where m is the memory in items and b the block size.
    The sort is stable and deterministic. Each bucket ends with at most
    2 ceil(X/d) of its X blocks on any disk, and each write step writes at
    least min(ready, floor(d/2) + 1) blocks, so a level costs O(n/d) parallel
    I/Os with n = N/b, and the sort costs O((n/d) log_(m/b) n).
    """
    if b < 1 or d < 1 or m < (2 * d + 4) * b:
        raise ValueError("need b >= 1, d >= 1 and m >= (2d + 4) b")
    st = stats if stats is not None else Stats()
    recs = [Rec(x, i) for i, x in enumerate(xs)]
    out = sort_file(striped(recs, b, d), m, b, d, st)
    xs[:] = [r.key for r in out]
    return st.ios
test_balance_sort.py 82 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/balance-sort/. The authors are listed in prov/A6ZN06.jsonl.

import random

import pytest

from balance_sort import Rec, Stats, balance_sort, distribute, read_cost, splitters_of, striped


class Item:
    def __init__(self, key: int, pos: int) -> None:
        self.key, self.pos = key, pos

    def __lt__(self, other: "Item") -> bool:
        return self.key < other.key


F = [19, 8, 9, 2, 16, 12, 4, 1, 15, 14, 5, 6, 17, 13, 11, 3, 18, 10, 7, 20]


def test_example() -> None:
    f = striped([Rec(x, i) for i, x in enumerate(F)], 1, 4)
    st = Stats()
    sp = splitters_of(f, 12, 1, 4, 4, st)
    assert [r.key for r in sp] == [5, 8, 12]
    out = distribute(f, sp, 1, 4, st)
    assert [[[blk[0].key for blk in disk] for disk in g] for g in out][0] == [[4], [3], [], [2, 1]]
    assert st.writes == [(4, 4), (4, 4), (4, 4), (4, 3), (5, 4), (1, 1)]
    g = list(F)
    assert balance_sort(g, 12, 1, 4) == 31
    assert g == list(range(1, 21))


def test_edges() -> None:
    for f in ([], [1], [2, 1], [3] * 50, list(range(60, 0, -1))):
        want = sorted(f)
        balance_sort(f, 12, 1, 4)
        assert f == want
    with pytest.raises(ValueError):
        balance_sort([1], 11, 1, 4)


def test_balance_invariant() -> None:
    rng = random.Random(1993)
    for _ in range(200):
        d = rng.randint(1, 9)
        b = rng.randint(1, 4)
        m = (2 * d + 4) * b + rng.randint(0, 30)
        n = rng.randint(m + 1, 6 * m)
        recs = [Rec(rng.randint(0, 9), i) for i in range(n)]
        f = striped(recs, b, d)
        st = Stats()
        sp = splitters_of(f, m, b, d, 4, st)
        st.writes = []
        for g in distribute(f, sp, b, d, st):
            x = sum(len(disk) for disk in g)
            assert read_cost(g) <= 2 * -(-x // d)
        for ready, written in st.writes:
            assert written >= min(ready, d // 2 + 1)


def test_stable() -> None:
    rng = random.Random(7)
    for _ in range(100):
        n = rng.randint(0, 300)
        f = [Item(rng.randint(0, 5), i) for i in range(n)]
        balance_sort(f, 24, 2, 3)
        assert [(r.key, r.pos) for r in f] == sorted((r.key, r.pos) for r in f)


def test_random_against_sorted() -> None:
    rng = random.Random(1990)
    for _ in range(600):
        n = rng.randint(0, 500)
        d = rng.randint(1, 6)
        b = rng.randint(1, 5)
        m = (2 * d + 4) * b + rng.randint(0, 40)
        f = [rng.randint(-50, 50) for _ in range(n)]
        want = sorted(f)
        balance_sort(f, m, b, d)
        assert f == want

What the next level needs

  • the tests did not run in this build

Read the full page about Balance Sort