フラックスソートを使用する

フラックスソート (fluxsort) はクイックソートと同じようにピボットで分割して再帰していくトップダウン方式で、二重書き込みによって安定に分割するのを主としたハイブリッドな比較ソートである。

整列度が高い区間や小区間・不均衡時はクワッドソート(マージソート系)へ切り替えるようになっており、分割時の分岐予測ミスを抑えやすい点と、先頭のアナライザで昇順・降順・区間の整列度を見て適応する点が特徴である。

本記事の実装は説明用に簡略化している。本番では小区間やフォールバックにクワッドソートを使うが、ここでは挿入ソート/マージソートで代用する。ピボットも準中央値(9)のみとし、大規模向けの立方根近似中央値は扱わない。

  1. アナライザ: 全体が昇順なら何もしない。降順(同値を含む非増加)なら反転して終了する(比較・移動とも \(O(n)\))。配列を 4 分割し、各区間の隣接昇順ペアが半数超ならその区間をマージソートで仕上げる(本番ではクワッドソート)。
  2. ピボット選択: 区間をほぼ等間隔に 9 点取り、3 組の三点中央値の中央値(準中央値)をピボットにする。
  3. 安定な二重書き込み分割: 要素を先頭から走査し、<= ピボット を本配列前方へ、> ピボット を後方(本番では swap 領域)へ、出現順を保ったまま書き分ける。
  4. 等値の第二走査: 右側が空(すべて <= ピボット)なら、< ピボット と = ピボット に分け直し、等値帯を再帰から外す。重複の多い入力向けの対策である。
  5. 不均衡フォールバック: 左右の長さ比が 1:16 を超えて偏ったら、両側をマージソートする(本番ではクワッドソート)。最悪計算量を \(O(n \log n)\) に抑えるためのガードである。
  6. 小区間: 要素数が閾値未満なら挿入ソートで仕上げる(本番の閾値付近ではクワッドソートの小区間ルーチン)。
procedure flux_stable_partition(A, pivot)
  L := empty; R := empty
  for each x in A in order
    if x <= pivot then append x to L else append x to R
  A := L concatenated with R
  return length(L)

procedure flux_sort_range(A)
  if length(A) < INSERTION_THRESHOLD then
    insertion_sort(A); return
  pivot := quasimedian_of_9(A)
  left := flux_stable_partition(A, pivot)
  right := length(A) - left
  if right = 0 then
    move keys < pivot to front, equals after them
    flux_sort_range(A[0 .. lt))
    return
  if left < length(A)/16 or right < length(A)/16 then
    merge_sort(A[0 .. left)); merge_sort(A[left .. end)); return
  flux_sort_range(A[0 .. left))
  flux_sort_range(A[left .. end))

procedure fluxsort(A)
  if A is sorted then return
  if A is reverse-sorted then reverse(A); return
  for each quarter Q of A
    if ordered_pairs(Q) > half then merge_sort(Q)
  if A is sorted then return
  flux_sort_range(A)

最良は整列済み検出により \(O(n)\)、平均・最悪は \(O(n \log n)\) である。補助配列に最大 \(O(n)\) を使う安定ソートである。

以下のデモでは視認性のため挿入閾値を 4、不均衡判定を 1/4 に緩めている。

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

クワッドソートは分割を行わず、ボトムアップのクワッドマージだけで完結する。フラックスソートはランダム寄りの入力では安定分割を主とし、整列度が高い区間・小区間・不均衡時だけクワッドソート系へ寄せる。

ロムート分割型クイックソートはインプレースで不安定な分割を行う。フラックスソートは補助メモリへの二重書き込みで安定分割し、分岐の少ない走査を前提にしている。

パターン撃退型クイックソートも悪パターン対策のハイブリッドだが、不安定で補助メモリをほぼ使わない。フラックスソートは安定性を保ったまま分割とマージを行き来する。

ティムソートは自然ランの検出とギャロッピングマージが中心である。フラックスソートのアナライザは先頭で 4 区間の整列度を見る程度に留め、以降は分割側の適応に寄せる。

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

Size Average time (s) Maximum time (s) Average memory (KiB) Maximum memory (KiB)
256 0.000009 0.000066 2 2
512 0.000021 0.000074 4 4
1024 0.000045 0.000099 8 8
2048 0.000109 0.000847 16 16
4096 0.000200 0.000330 32 32
8192 0.000389 0.000774 64 64
16384 0.000799 0.001524 128 128
32768 0.001635 0.002956 256 256
65536 0.003426 0.007196 512 512
131072 0.008024 0.064066 1024 1024
262144 0.016939 0.052819 2048 2048
計測に使用したコードを表示する

#!/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 let FLUX_INSERTION_THRESHOLD = 24

private func flux_is_sorted(_ a: UnsafeMutableBufferPointer<Int>) -> Bool {
    if a.count < 2 { return true }
    for i in 0..<(a.count - 1) {
        if a[i] > a[i + 1] { return false }
    }
    return true
}

private func flux_is_reverse_sorted(_ a: UnsafeMutableBufferPointer<Int>) -> Bool {
    if a.count < 2 { return true }
    for i in 0..<(a.count - 1) {
        if a[i] < a[i + 1] { return false }
    }
    return true
}

private func flux_reverse(_ a: UnsafeMutableBufferPointer<Int>) {
    var lo = 0
    var hi = a.count
    while lo + 1 < hi {
        hi -= 1
        a.swapAt(lo, hi)
        lo += 1
    }
}

private func flux_ordered_pairs(_ a: UnsafeMutableBufferPointer<Int>) -> Int {
    if a.count < 2 { return 0 }
    var c = 0
    for i in 0..<(a.count - 1) {
        if a[i] <= a[i + 1] { c += 1 }
    }
    return c
}

private func flux_median3_idx(
    _ a: UnsafeMutableBufferPointer<Int>,
    _ i: Int,
    _ j: Int,
    _ k: Int
) -> Int {
    let (x, y, z) = (a[i], a[j], a[k])
    if x < y {
        if y < z {
            return j
        } else if x < z {
            return k
        } else {
            return i
        }
    } else if x < z {
        return i
    } else if y < z {
        return k
    } else {
        return j
    }
}

private func flux_quasimedian9(_ a: UnsafeMutableBufferPointer<Int>) -> Int {
    let n = a.count
    if n < 9 {
        return a[n / 2]
    }
    let step = n / 8
    let i0 = 0
    let i1 = step
    let i2 = step * 2
    let i3 = step * 3
    let i4 = step * 4
    let i5 = step * 5
    let i6 = step * 6
    let i7 = step * 7
    let i8 = n - 1
    let m0 = flux_median3_idx(a, i0, i1, i2)
    let m1 = flux_median3_idx(a, i3, i4, i5)
    let m2 = flux_median3_idx(a, i6, i7, i8)
    return a[flux_median3_idx(a, m0, m1, m2)]
}

private func flux_merge(
    _ a: UnsafeMutableBufferPointer<Int>,
    _ swap: UnsafeMutableBufferPointer<Int>
) {
    let n = a.count
    if n <= 1 {
        return
    }
    let mid = n / 2
    flux_merge(UnsafeMutableBufferPointer(rebasing: a[0..<mid]), swap)
    flux_merge(UnsafeMutableBufferPointer(rebasing: a[mid..<n]), swap)
    var i = 0
    var j = 0
    var k = 0
    let leftLen = mid
    let rightLen = n - mid
    while i < leftLen && j < rightLen {
        if a[i] <= a[mid + j] {
            swap[k] = a[i]
            i += 1
        } else {
            swap[k] = a[mid + j]
            j += 1
        }
        k += 1
    }
    while i < leftLen {
        swap[k] = a[i]
        i += 1
        k += 1
    }
    while j < rightLen {
        swap[k] = a[mid + j]
        j += 1
        k += 1
    }
    for idx in 0..<n {
        a[idx] = swap[idx]
    }
}

private func flux_stable_partition(
    _ a: UnsafeMutableBufferPointer<Int>,
    _ swap: UnsafeMutableBufferPointer<Int>,
    _ pivot: Int
) -> Int {
    let n = a.count
    for i in 0..<n {
        swap[i] = a[i]
    }
    var left = 0
    for i in 0..<n {
        if swap[i] <= pivot {
            left += 1
        }
    }
    var l = 0
    var r = left
    for i in 0..<n {
        let x = swap[i]
        if x <= pivot {
            a[l] = x
            l += 1
        } else {
            a[r] = x
            r += 1
        }
    }
    return left
}

private func flux_partition_sort(
    _ a: UnsafeMutableBufferPointer<Int>,
    _ swap: UnsafeMutableBufferPointer<Int>
) {
    let n = a.count
    if n <= 1 {
        return
    }
    if n < FLUX_INSERTION_THRESHOLD {
        insertion_sort(a)
        return
    }

    let pivot = flux_quasimedian9(a)
    let left = flux_stable_partition(a, swap, pivot)
    let right = n - left

    if right == 0 {
        for i in 0..<n {
            swap[i] = a[i]
        }
        var lt = 0
        for i in 0..<n {
            if swap[i] < pivot {
                a[lt] = swap[i]
                lt += 1
            }
        }
        var eq = lt
        for i in 0..<n {
            if swap[i] == pivot {
                a[eq] = swap[i]
                eq += 1
            }
        }
        if lt > 1 {
            flux_partition_sort(UnsafeMutableBufferPointer(rebasing: a[0..<lt]), swap)
        }
        return
    }

    let unbalanced = left > 0 && (left < n / 16 || right < n / 16)

    if unbalanced {
        flux_merge(UnsafeMutableBufferPointer(rebasing: a[0..<left]), swap)
        flux_merge(UnsafeMutableBufferPointer(rebasing: a[left..<n]), swap)
        return
    }

    if left > 1 {
        flux_partition_sort(UnsafeMutableBufferPointer(rebasing: a[0..<left]), swap)
    }
    if right > 1 {
        flux_partition_sort(UnsafeMutableBufferPointer(rebasing: a[left..<n]), swap)
    }
}

private func flux_analyze(
    _ a: UnsafeMutableBufferPointer<Int>,
    _ swap: UnsafeMutableBufferPointer<Int>
) -> Bool {
    let n = a.count
    if n <= 1 {
        return true
    }
    if flux_is_sorted(a) {
        return true
    }
    if flux_is_reverse_sorted(a) {
        flux_reverse(a)
        return true
    }

    let q = n / 4
    if q >= 2 {
        let bounds = [0, q, q * 2, q * 3, n]
        for s in 0..<4 {
            let lo = bounds[s]
            let hi = bounds[s + 1]
            if hi - lo < 2 {
                continue
            }
            let pairs = hi - lo - 1
            if flux_ordered_pairs(UnsafeMutableBufferPointer(rebasing: a[lo..<hi])) * 2 > pairs {
                flux_merge(UnsafeMutableBufferPointer(rebasing: a[lo..<hi]), swap)
            }
        }
        if flux_is_sorted(a) {
            return true
        }
    }
    return false
}

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

func flux_sort(_ a: UnsafeMutableBufferPointer<Int>) {
    let n = a.count
    if n <= 1 {
        return
    }
    var swapStorage = [Int](repeating: 0, count: n)
    swapStorage.withUnsafeMutableBufferPointer { swap in
        if flux_analyze(a, swap) {
            return
        }
        flux_partition_sort(a, swap)
    }
}


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

    flux_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)
}