ファンネルソートを使用する

ファンネルソート (funnel sort / funnelsort) は、キャッシュや外部メモリのブロック転送回数を漸近最適に近づけることを目的とした、キャッシュ忘却型(cache-oblivious)の比較ソートである。

通常のマージソートも比較回数は \(O(n \log n)\) だが、二分マージを浅い再帰で繰り返すと、作業集合がキャッシュに収まりきらない段階で転送が膨らみやすい。

ファンネルソートは、だいたい n^{1/3} 本の整列済み列をバッファ付きの k 入力マージャ(k-funnel / k-merger)でまとめてマージする形に組み替え、階層メモリを意識したスケジュールをアルゴリズム自体に埋め込む。

本稿のデモと計測コードは簡略化した版である。

  1. ブロック分割: 入力長 n に対し \(k \approx n^{1/3}\)(2 の冪へ切り上げ)を選び、長さおよそ \(n/k \approx n^{2/3}\) の連続区間へ分ける。
  2. 再帰整列: 各ブロックを同じ手続きで整列する。十分小さい区間は挿入ソートへ落とす。
  3. 遅延 k 入力マージャ: k 本の整列済みストリームを、二分マージャの完全二分木でマージする。各内部ノードは出力バッファを持ち、バッファが空(または半分未満)になったときだけ子マージャを再帰的に呼び出して補充する(lazy fill)。
  4. バッファ寸法: 部分木の葉数を m とするとバッファ容量をおよそ m^{3/2}、根ではおよそ k^3 にとる。空間は \(O(k^2)\) 級に収まり、\(k \approx n^{1/3}\) なら全体で線形の補助領域に抑えられる。

キャッシュサイズ M やブロック長 B をパラメータに書かない点がキャッシュ忘却の要点である。解析では「キャッシュ容量がブロック長の二乗程度より大きい」(行数がブロック長以上ある)と置くことが多く、そのもとで I/O 複雑さが最適級になることが知られる。CPU 上の壁時計では定数倍と実装の重さが効き、単純なマージソートより速くなるとは限らない。

procedure fill(v)  // lazy binary merger at node v
  while v.buffer is not full and not v.exhausted
    if v.left.buffer empty and not v.left.exhausted then fill(v.left)
    if v.right.buffer empty and not v.right.exhausted then fill(v.right)
    if both children exhausted then
      v.exhausted = true; return
    move smaller head of the two children into v.buffer

procedure k_merger_merge(streams[0..k))
  build binary merge tree over streams with sized buffers
  while output incomplete
    fill(root)
    drain root.buffer into result

procedure funnel_sort(A)
  n = length(A)
  if n is small then
    insertion_sort(A); return
  k = next_power_of_two(ceil(n^(1/3)))
  split A into k contiguous blocks of size ~ n/k
  for each block B
    funnel_sort(B)
  k_merger_merge(the k sorted blocks)
  copy merged result back into A

比較モデルでは時間 \(O(n \log n)\)、空間は再帰とマージャ合わせて \(O(n)\) 程度。キャッシュ忘却モデルでは、キャッシュがブロック長に対して十分大きいという前提のもとで、ソートの I/O 下界に近い転送回数を狙う。マージが等値を安定に扱えば全体も安定ソートである。

デモでは要素数が少ないため k が 2 や 4 程度になり、バッファ寸法の効果は見えにくい。本番の計測コードはより大きい入力で同じ骨格を動かす。

類似アルゴリズムとの相違点

マージソートは区間を半分に分け二分マージを重ねる。ファンネルソートはブロック数を n^{1/3} 前後に取り、遅延 k 入力マージャのバッファ階層でマージ順を制御する点が異なる。

カスケードマージソートポリフェーズマージソートは、作業領域の狭め方やテープ本数・ラン分布といった「マージ政策」が主題である。ファンネルソートは I/O(キャッシュミス)回数を漸近項で抑えるデータ配置と呼び出しスケジュールが主題で、外部テープの本数最適化とは別系統である。

ファンエンデボアスソートもファンエンデボアスレイアウトと名前が近いが、整数宇宙上の非比較構造であり、比較ベースのキャッシュ忘却マージとは目的が違う。

時間計算量および空間計算量を計測する

Size Average time (s) Maximum time (s) Average memory (KiB) Maximum memory (KiB)
256 0.000016 0.000197 8 8
512 0.000044 0.000170 10 10
1024 0.000091 0.000589 44 44
2048 0.000196 0.001468 52 52
4096 0.000353 0.000903 68 68
8192 0.000765 0.001252 330 330
16384 0.002045 0.004641 394 394
32768 0.004048 0.006733 522 522
65536 0.008365 0.040721 2583 2583
131072 0.020317 0.055378 3095 3095
262144 0.038696 0.125156 4119 4119
計測に使用したコードを表示する

#!/usr/bin/env swift
import Foundation

// This standalone Swift driver creates the same temporary Docker build
// context as the former shell wrapper.  The benchmark program itself remains
// embedded below so readers can copy one complete, reproducible file.
struct BenchmarkError: Error, CustomStringConvertible {
    let message: String

    var description: String { message }

    init(_ message: String) {
        self.message = message
    }
}

func runCommand(_ executable: String, _ arguments: [String]) throws {
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
    process.arguments = [executable] + arguments
    process.standardInput = FileHandle.standardInput
    process.standardOutput = FileHandle.standardOutput
    process.standardError = FileHandle.standardError

    do {
        try process.run()
    } catch {
        throw BenchmarkError("Could not start \(executable): \(error)")
    }
    process.waitUntilExit()
    guard process.terminationStatus == 0 else {
        throw BenchmarkError(
            "Command failed (\(process.terminationStatus)): " +
            "\(executable) \(arguments.joined(separator: " "))"
        )
    }
}

do {
    // The UUID avoids collisions when two benchmark copies are run at once.
    let workdir = FileManager.default.temporaryDirectory
        .appendingPathComponent("swift-sort-benchmark-\(UUID().uuidString)")
    try FileManager.default.createDirectory(at: workdir, withIntermediateDirectories: true)
    defer { try? FileManager.default.removeItem(at: workdir) }

    // A raw Swift string is used so the nested main.swift keeps its own
    // interpolation expressions such as \(seed) until Docker compiles it.
    let dockerfile = #"""
FROM swift:6.0

WORKDIR /app

RUN cat > alloc_track.c <<'ALLOC'
#define _GNU_SOURCE
#include <dlfcn.h>
#include <malloc.h>
#include <stdatomic.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

static atomic_size_t live_bytes = 0;
static atomic_size_t peak_bytes = 0;

static void *(*real_malloc)(size_t) = NULL;
static void *(*real_calloc)(size_t, size_t) = NULL;
static void *(*real_realloc)(void *, size_t) = NULL;
static void (*real_free)(void *) = NULL;

static void init_reals(void) {
    if (real_malloc) {
        return;
    }
    real_malloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "malloc");
    real_calloc = (void *(*)(size_t, size_t))dlsym(RTLD_NEXT, "calloc");
    real_realloc = (void *(*)(void *, size_t))dlsym(RTLD_NEXT, "realloc");
    real_free = (void (*)(void *))dlsym(RTLD_NEXT, "free");
}

static void record_alloc(size_t size) {
    size_t live = atomic_fetch_add(&live_bytes, size) + size;
    size_t peak = atomic_load(&peak_bytes);
    while (live > peak) {
        if (atomic_compare_exchange_weak(&peak_bytes, &peak, live)) {
            break;
        }
    }
}

void alloc_track_reset_peak(void) {
    atomic_store(&peak_bytes, atomic_load(&live_bytes));
}

size_t alloc_track_live(void) { return atomic_load(&live_bytes); }
size_t alloc_track_peak(void) { return atomic_load(&peak_bytes); }

void *malloc(size_t size) {
    init_reals();
    void *p = real_malloc(size);
    if (p) {
        record_alloc(malloc_usable_size(p));
    }
    return p;
}

void *calloc(size_t nmemb, size_t size) {
    init_reals();
    void *p = real_calloc(nmemb, size);
    if (p) {
        record_alloc(malloc_usable_size(p));
    }
    return p;
}

void *realloc(void *ptr, size_t size) {
    init_reals();
    size_t old_size = 0;
    if (ptr) {
        old_size = malloc_usable_size(ptr);
    }
    void *p = real_realloc(ptr, size);
    if (p) {
        atomic_fetch_sub(&live_bytes, old_size);
        record_alloc(malloc_usable_size(p));
    } else if (size == 0) {
        atomic_fetch_sub(&live_bytes, old_size);
    }
    return p;
}

void free(void *ptr) {
    init_reals();
    if (ptr) {
        atomic_fetch_sub(&live_bytes, malloc_usable_size(ptr));
        real_free(ptr);
    }
}

ALLOC

RUN cat > main.swift <<'SWIFT'
import Foundation
#if canImport(Glibc)
import Glibc
#elseif canImport(Darwin)
import Darwin
#endif

@_silgen_name("alloc_track_live") func alloc_track_live() -> Int
@_silgen_name("alloc_track_peak") func alloc_track_peak() -> Int
@_silgen_name("alloc_track_reset_peak") func alloc_track_reset_peak()

extension UnsafeMutableBufferPointer where Element == Int {
    func swapAt(_ i: Int, _ j: Int) {
        let t = self[i]; self[i] = self[j]; self[j] = t
    }
}

let MIN_POWER: Int = 8
let MAX_POWER: Int = 18
let RUNS: Int = 8192
func insertion_sort(_ a: inout [Int]) {
    a.withUnsafeMutableBufferPointer { insertion_sort($0) }
}

func insertion_sort(_ a: UnsafeMutableBufferPointer<Int>) {
    if a.count < 2 {
        return
    }
    for i in 1..<a.count {
        var j = i
        while j > 0 && a[j - 1] > a[j] {
            a.swapAt(j - 1, j)
            j -= 1
        }
    }
}



private func funnel_cbrt_ceil(_ n: Int) -> Int {
    if n <= 1 {
        return n
    }
    var x = Int(cbrt(Double(n)).rounded(.up))
    if x < 2 {
        x = 2
    }
    while x * x * x < n {
        x += 1
    }
    return x
}

private func funnel_next_pow2(_ x: Int) -> Int {
    var x = x
    if x <= 2 {
        return 2
    }
    x -= 1
    x |= x >> 1
    x |= x >> 2
    x |= x >> 4
    x |= x >> 8
    x |= x >> 16
    x |= x >> 32
    return x + 1
}

private func funnel_buffer_cap(_ leaves: Int) -> Int {
    if leaves <= 1 {
        return 2
    }
    let k = Double(leaves)
    return max(Int((k * k.squareRoot()).rounded(.up)), 2)
}

private final class FunnelNode {
    var buf: [Int]
    var head: Int
    var cap: Int
    var run: (Int, Int)?
    var pos: Int
    var left: Int?
    var right: Int?
    var exhausted: Bool

    static func leaf(_ lo: Int, _ hi: Int) -> FunnelNode {
        FunnelNode(
            buf: [],
            head: 0,
            cap: 0,
            run: (lo, hi),
            pos: lo,
            left: nil,
            right: nil,
            exhausted: lo >= hi
        )
    }

    static func internalNode(_ cap: Int, _ left: Int, _ right: Int) -> FunnelNode {
        FunnelNode(
            buf: [],
            head: 0,
            cap: cap,
            run: nil,
            pos: 0,
            left: left,
            right: right,
            exhausted: false
        )
    }

    private init(
        buf: [Int],
        head: Int,
        cap: Int,
        run: (Int, Int)?,
        pos: Int,
        left: Int?,
        right: Int?,
        exhausted: Bool
    ) {
        self.buf = buf
        self.head = head
        self.cap = cap
        self.run = run
        self.pos = pos
        self.left = left
        self.right = right
        self.exhausted = exhausted
        if cap > 0 {
            self.buf.reserveCapacity(cap)
        }
    }

    func buf_len() -> Int {
        max(buf.count - head, 0)
    }

    func buf_clear_consumed() {
        if head > 0 {
            buf.removeFirst(head)
            head = 0
        }
    }

    func buf_push(_ v: Int) {
        buf_clear_consumed()
        buf.append(v)
    }

    func buf_peek() -> Int? {
        if head < buf.count {
            return buf[head]
        }
        return nil
    }

    func buf_pop() -> Int? {
        if head >= buf.count {
            return nil
        }
        let v = buf[head]
        head += 1
        if head == buf.count {
            buf.removeAll(keepingCapacity: true)
            head = 0
        }
        return v
    }
}

private func funnel_build_tree(_ k: Int, _ runs: [(Int, Int)]) -> ([FunnelNode], Int) {
    var nodes = [FunnelNode]()
    nodes.reserveCapacity(2 * k)
    for i in 0..<k {
        if i < runs.count {
            nodes.append(FunnelNode.leaf(runs[i].0, runs[i].1))
        } else {
            nodes.append(FunnelNode.leaf(0, 0))
        }
    }
    var layer = Array(0..<k)
    var leaves_per = [Int](repeating: 1, count: k)
    while layer.count > 1 {
        var next_layer = [Int]()
        var next_leaves = [Int]()
        var i = 0
        while i < layer.count {
            if i + 1 < layer.count {
                let left = layer[i]
                let right = layer[i + 1]
                let leaves = leaves_per[i] + leaves_per[i + 1]
                let parent = nodes.count
                nodes.append(FunnelNode.internalNode(funnel_buffer_cap(leaves), left, right))
                next_layer.append(parent)
                next_leaves.append(leaves)
                i += 2
            } else {
                next_layer.append(layer[i])
                next_leaves.append(leaves_per[i])
                i += 1
            }
        }
        layer = next_layer
        leaves_per = next_leaves
    }
    let root = layer[0]
    if nodes[root].run == nil {
        let total_leaves = max(runs.count, 1)
        let want = Int(pow(Double(total_leaves), 3).rounded(.up))
        nodes[root].cap = max(max(nodes[root].cap, want), 2)
        nodes[root].buf.reserveCapacity(nodes[root].cap)
    }
    return (nodes, root)
}

private func funnel_leaf_has(_ nodes: [FunnelNode], _ leaf: Int) -> Bool {
    if nodes[leaf].exhausted {
        return false
    }
    guard let (_, hi) = nodes[leaf].run else {
        return false
    }
    return nodes[leaf].pos < hi
}

private func funnel_leaf_peek(
    _ nodes: [FunnelNode],
    _ leaf: Int,
    _ a: UnsafeMutableBufferPointer<Int>
) -> Int? {
    if !funnel_leaf_has(nodes, leaf) {
        return nil
    }
    return a[nodes[leaf].pos]
}

private func funnel_leaf_pop(
    _ nodes: inout [FunnelNode],
    _ leaf: Int,
    _ a: UnsafeMutableBufferPointer<Int>
) -> Int? {
    guard let v = funnel_leaf_peek(nodes, leaf, a) else {
        return nil
    }
    nodes[leaf].pos += 1
    if let (_, hi) = nodes[leaf].run {
        if nodes[leaf].pos >= hi {
            nodes[leaf].exhausted = true
        }
    }
    return v
}

private func funnel_fill(
    _ nodes: inout [FunnelNode],
    _ idx: Int,
    _ a: UnsafeMutableBufferPointer<Int>
) {
    if nodes[idx].run != nil || nodes[idx].exhausted {
        return
    }
    let cap = nodes[idx].cap
    while nodes[idx].buf_len() < cap {
        guard let left = nodes[idx].left, let right = nodes[idx].right else {
            fatalError("internal")
        }

        if nodes[left].run == nil && nodes[left].buf_len() == 0 && !nodes[left].exhausted {
            funnel_fill(&nodes, left, a)
        }
        if nodes[right].run == nil && nodes[right].buf_len() == 0 && !nodes[right].exhausted {
            funnel_fill(&nodes, right, a)
        }

        let left_ok: Bool
        if nodes[left].run != nil {
            left_ok = funnel_leaf_has(nodes, left)
        } else {
            left_ok = nodes[left].buf_len() > 0
        }
        let right_ok: Bool
        if nodes[right].run != nil {
            right_ok = funnel_leaf_has(nodes, right)
        } else {
            right_ok = nodes[right].buf_len() > 0
        }

        if !left_ok && !right_ok {
            nodes[idx].exhausted = true
            break
        }

        let take_left: Bool
        if left_ok && right_ok {
            let lv: Int
            if nodes[left].run != nil {
                lv = funnel_leaf_peek(nodes, left, a)!
            } else {
                lv = nodes[left].buf_peek()!
            }
            let rv: Int
            if nodes[right].run != nil {
                rv = funnel_leaf_peek(nodes, right, a)!
            } else {
                rv = nodes[right].buf_peek()!
            }
            take_left = lv <= rv
        } else {
            take_left = left_ok
        }

        let v: Int
        if take_left {
            if nodes[left].run != nil {
                v = funnel_leaf_pop(&nodes, left, a)!
            } else {
                v = nodes[left].buf_pop()!
            }
        } else if nodes[right].run != nil {
            v = funnel_leaf_pop(&nodes, right, a)!
        } else {
            v = nodes[right].buf_pop()!
        }
        nodes[idx].buf_push(v)
    }
}

private func funnel_merge_runs(
    _ a: UnsafeMutableBufferPointer<Int>,
    _ runs: [(Int, Int)],
    _ k: Int
) {
    if runs.count <= 1 {
        return
    }
    var (nodes, root) = funnel_build_tree(k, runs)
    let total = runs.reduce(0) { $0 + ($1.1 - $1.0) }
    var out = [Int]()
    out.reserveCapacity(total)
    while out.count < total {
        funnel_fill(&nodes, root, a)
        if nodes[root].buf_len() == 0 {
            break
        }
        let head = nodes[root].head
        out.append(contentsOf: nodes[root].buf[head...])
        nodes[root].buf.removeAll(keepingCapacity: true)
        nodes[root].head = 0
        if nodes[root].exhausted {
            break
        }
    }
    let base = runs[0].0
    for i in 0..<total {
        a[base + i] = out[i]
    }
}

func funnel_sort(_ a: inout [Int]) {
    a.withUnsafeMutableBufferPointer { funnel_sort($0) }
}

func funnel_sort(_ a: UnsafeMutableBufferPointer<Int>) {
    let n = a.count
    if n <= 8 {
        insertion_sort(a)
        return
    }
    var k = funnel_next_pow2(funnel_cbrt_ceil(n))
    while k > n {
        k /= 2
    }
    k = max(k, 2)

    let block = (n + k - 1) / k
    var runs = [(Int, Int)]()
    runs.reserveCapacity(k)
    var i = 0
    while i < n {
        let end = min(i + block, n)
        funnel_sort(UnsafeMutableBufferPointer(rebasing: a[i..<end]))
        if end > i {
            runs.append((i, end))
        }
        i = end
    }
    let merge_k = funnel_next_pow2(max(runs.count, 2))
    funnel_merge_runs(a, runs, merge_k)
}


func benchmark_sort(_ array: inout [Int]) {

    funnel_sort(&array)

}

func is_non_decreasing(_ a: [Int]) -> Bool {
    guard a.count >= 2 else { return true }
    for i in 1..<a.count {
        if a[i - 1] > a[i] { return false }
    }
    return true
}

func same_multiset(_ a: [Int], _ b: [Int]) -> Bool {
    if a.count != b.count {
        return false
    }

    var left = a
    var right = b
    left.sort()
    right.sort()
    return left == right
}

func check_correctness_case(_ label: String, _ input: [Int]) {
    var input = input
    let original = input

    benchmark_sort(&input)

    if !is_non_decreasing(input) {
        fatalError("correctness case \(label): output is not sorted")
    }

    if !same_multiset(input, original) {
        fatalError("correctness case \(label): elements were lost or added")
    }
}

// Skip cases larger than the algorithm's measured size cap (MAX_POWER). That
// cap exists because larger inputs are impractically slow; forcing them here
// would stall the published measurement script before any table rows print.
func check_correctness_case_within_limit(_ label: String, _ input: [Int]) {
    if input.count > (1 << MAX_POWER) {
        return
    }
    check_correctness_case(label, input)
}

func few_unique_values(_ size: Int, _ unique: Int, _ seed: UInt64) -> [Int] {
    var state = seed
    var result = [Int]()
    result.reserveCapacity(size)
    for _ in 0..<size {
        state ^= state << 13
        state ^= state >> 7
        state ^= state << 17
        result.append(Int(state % UInt64(unique)) + 1)
    }
    return result
}

func run_correctness_checks() {
    check_correctness_case("empty", [])
    check_correctness_case("single", [42])
    check_correctness_case("duplicates", [3, 1, 3, 2, 1, 2])
    check_correctness_case("sorted", [1, 2, 3, 4, 5])
    check_correctness_case("reverse", [5, 4, 3, 2, 1])
    check_correctness_case("all_equal", [7, 7, 7, 7])
    check_correctness_case("skewed_range", [1_000_000, 2, 1_000_001, 1, 999_999])
    // Static-buffer Grail skips the in-buffer build when key collection is sparse
    // (ideal_buffer = false). Exercising that path catches regressions in buffer gating.
    check_correctness_case(
        "few_keys_len16",
        [2, 2, 2, 2, 2, 2, 2, 2, 4, 3, 1, 2, 3, 4, 1, 4]
    )
    // Seed 0 is a fixed point of the xorshift below, so it would degenerate into
    // yet another all-equal case instead of a 4-value mix. Start at 1.
    for seed in 1...32 {
        check_correctness_case(
            "few_keys_len32_seed_\(seed)",
            few_unique_values(32, 4, UInt64(seed))
        )
    }
    // Small-input cutoffs (insertion sort below 32 elements, etc.) hide duplicate-key
    // bugs in the recursive path, so repeat the duplicate cases at the smallest
    // benchmark size, which every algorithm must handle within reasonable time.
    check_correctness_case("all_equal_len256", [Int](repeating: 7, count: 256))
    for seed in 1...4 {
        check_correctness_case(
            "few_keys_len256_seed_\(seed)",
            few_unique_values(256, 4, UInt64(seed))
        )
    }
    // Blit's equal-key second sweep used to copy the whole range into a fixed
    // 512-element swap; lengths above that must still sort without panicking.
    // Respect MAX_POWER so algorithms with a low measured-size cap (slow,
    // sleep) do not hang here for minutes or months.
    check_correctness_case_within_limit("all_equal_len600", [Int](repeating: 7, count: 600))
    for seed in 1...4 {
        check_correctness_case_within_limit(
            "few_keys_len2048_seed_\(seed)",
            few_unique_values(2048, 4, UInt64(seed))
        )
    }
}


func shuffled(_ size: Int, seed: UInt64) -> [Int] {
    guard size > 0 else { return [] }

    var v = Array(1...size)
    var state = seed

    if size > 1 {
        for i in stride(from: size - 1, through: 1, by: -1) {
            state ^= state << 13
            state ^= state >> 7
            state ^= state << 17

            let j = Int(state % UInt64(i + 1))
            v.swapAt(i, j)
        }
    }

    return v
}

func micros(_ d: Duration) -> UInt64 {
    let c = d.components
    let fromSeconds = UInt64(c.seconds) * 1_000_000
    let fromAttos = UInt64(max(0, c.attoseconds / 1_000_000_000_000))
    return fromSeconds + fromAttos
}

func padLeft(_ value: String, _ width: Int) -> String {
    if value.count >= width {
        return value
    }
    return String(repeating: " ", count: width - value.count) + value
}

func formatSeconds(_ micros: UInt64) -> String {
    let whole = micros / 1_000_000
    let frac = micros % 1_000_000
    let fracStr = padLeft(String(frac), 6).replacingOccurrences(of: " ", with: "0")
    return "\(whole).\(fracStr)"
}

func input_array(_ size: Int, seed: UInt64) -> [Int] {
    shuffled(size, seed: seed)
}

/// Peak heap growth during `benchmark_sort`, in bytes (explicit buffers such as swap).
/// Kept in bytes so the parent can average before rounding; converting to KiB here
/// would truncate sub-KiB buffers to 0 in every run and hide them from the average.
func run_once(size: Int, seed: Int) -> (UInt64, Int) {
    var array = input_array(size, seed: UInt64(seed))

    let baseBytes = alloc_track_live()
    alloc_track_reset_peak()

    let start = ContinuousClock.now

    benchmark_sort(&array)

    let elapsed = ContinuousClock.now - start
    let peakBytes = alloc_track_peak()
    let auxBytes = max(0, peakBytes - baseBytes)

    let expected: [Int] = size > 0 ? Array(1...size) : []
    if array != expected {
        fatalError("sort failed with seed \(seed) for size \(size)")
    }

    return (micros(elapsed), auxBytes)
}

func run_child(_ args: [String]) {
    let size = Int(args[2])!
    let seed = Int(args[3])!
    let (elapsedUs, mem) = run_once(size: size, seed: seed)
    print("\(elapsedUs) \(mem)")
}

let args = CommandLine.arguments
if args.count > 1 && args[1] == "--run-once" {
    run_child(args)
} else {
    run_correctness_checks()

    let tableHeader =
        "| \(padLeft("Size", 10)) | " +
        "\(padLeft("Average time (s)", 16)) | " +
        "\(padLeft("Maximum time (s)", 16)) | " +
        "\(padLeft("Average memory (KiB)", 20)) | " +
        "\(padLeft("Maximum memory (KiB)", 20)) |"
    print(tableHeader)
    print("|-----------:|-----------------:|-----------------:|---------------------:|---------------------:|")

    for power in MIN_POWER...MAX_POWER {
        let size = 1 << power

        var totalTime: UInt64 = 0
        var maxTime: UInt64 = 0

        var totalMem = 0
        var maxMem = 0

        for seed in 1...RUNS {
            let process = Process()
            process.executableURL = URL(fileURLWithPath: args[0])
            process.arguments = ["--run-once", "\(size)", "\(seed)"]
            let stdout = Pipe()
            let stderr = Pipe()
            process.standardOutput = stdout
            process.standardError = stderr

            do {
                try process.run()
            } catch {
                fatalError("failed to run benchmark child process: \(error)")
            }
            process.waitUntilExit()

            if process.terminationStatus != 0 {
                let err = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
                fatalError("benchmark child process failed: \(err)")
            }

            let data = stdout.fileHandleForReading.readDataToEndOfFile()
            let stdoutText = String(data: data, encoding: .utf8) ?? ""
            let fields = stdoutText.split(whereSeparator: \.isWhitespace)
            guard fields.count >= 2,
                  let elapsedUs = UInt64(fields[0]),
                  let auxMem = Int(fields[1]) else {
                fatalError("invalid child process output: \(stdoutText)")
            }

            totalTime += elapsedUs
            if elapsedUs > maxTime {
                maxTime = elapsedUs
            }

            totalMem += auxMem
            if auxMem > maxMem {
                maxMem = auxMem
            }
        }

        let avgTime = totalTime / UInt64(RUNS)
        // Memory is summed in bytes and converted to KiB once, after averaging.
        let avgMemKb = totalMem / RUNS / 1024
        let maxMemKb = maxMem / 1024

        let tableRow =
            "| \(padLeft(String(size), 10)) | " +
            "\(padLeft(formatSeconds(avgTime), 16)) | " +
            "\(padLeft(formatSeconds(maxTime), 16)) | " +
            "\(padLeft(String(avgMemKb), 20)) | " +
            "\(padLeft(String(maxMemKb), 20)) |"
        print(tableRow)
    }
}
SWIFT

RUN clang -O2 -fPIC -shared alloc_track.c -o liballoc_track.so -ldl

RUN swiftc -Ounchecked -whole-module-optimization \
    main.swift \
    -o swift-benchmark \
    -L. -lalloc_track \
    -Xlinker -rpath -Xlinker /app

ENV LD_PRELOAD=/app/liballoc_track.so
CMD ["./swift-benchmark"]
"""#
    try dockerfile.write(
        to: workdir.appendingPathComponent("Dockerfile"),
        atomically: true,
        encoding: .utf8
    )

    // Keeping build and run as separate child processes preserves Docker's
    // normal output and the original image tag used by the benchmark skill.
    try runCommand("docker", ["build", "-t", "swift-benchmark", workdir.path])
    try runCommand("docker", ["run", "--rm", "--init", "swift-benchmark"])
} catch {
    fputs("\(error)\n", stderr)
    exit(1)
}