プロックスマップソートを使用する

プロックスマップソートは、キーを近接写像 (proximity map) で決めた部分配列へ仕分け、各部分配列内を挿入しながら整列する分布ソートである。

  1. ヒット数 H: 各キーに mapKey 関数を適用し、同じ部分配列へ入る要素数を数える。
  2. 近接写像 P: ヒット数の累積和から、各部分配列が出力配列 A2 のどこから始まるかを求める。部分配列のサイズはちょうどそのヒット数分確保される。
  3. 位置 L: 元配列 A の各要素について、L[i] = P[mapKey(A[i])] として配置開始位置を記録する。
  4. 配置: A を左から走査し、各要素を A2 の対応部分配列へ置く。衝突したら部分配列内で挿入ソートし、より大きいキーを右へ 1 セルずつずらして空きを作る。部分配列はヒット数分だけ確保されているため、隣の部分配列へはみ出さない。
procedure proxmap_sort(A)
  n = length(A)
  for each bucket b: H[b] = 0
  for each x in A:
    b = mapKey(x)
    H[b] = H[b] + 1
  running = 0
  for each bucket b:
    if H[b] > 0 then
      P[b] = running
      running = running + H[b]
  for i from 0 to n - 1:
    L[i] = P[mapKey(A[i])]
  A2 = array of n empty slots
  for i from 0 to n - 1:
    start = L[i]
    insert A[i] into A2 at start, shifting larger keys right within the subarray
  A = A2

整数キー 1..nn 個の部分配列へ写す典型例では mapKey(x) = floor((x - min) / (max - min) * (n - 1)) のように値域を等分する。分布が一様なら O(n) に近づくが、同一部分配列に偏ると挿入のシフトが重なり O(n²) になる。

なお整列後に PmapKey を保持しておけば、ProxmapSearch により平均 O(1) でキー検索できる。静的な大規模データセットで検索頻度が高い場合に有利だが、更新のたびに近接写像を組み直す必要がある。

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

バケットソートは仕分け完了後にバケットごとに整列するが、プロックスマップは配置と挿入を同時に行い、整列後は ProxmapSearch で検索しやすい構造を残せる。

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

Size Average time Maximum time Average memory Maximum memory
256 0.000002 0.000041 14 14
512 0.000004 0.000052 28 28
1024 0.000009 0.000066 56 56
2048 0.000017 0.000059 112 112
4096 0.000041 0.000121 224 224
8192 0.000083 0.000239 448 448
16384 0.000199 0.000508 896 896
32768 0.000387 0.000868 1792 1792
65536 0.000779 0.001969 3584 3584
131072 0.001639 0.003205 7168 7168
262144 0.003059 0.005913 14336 14336
計測に使用したコードを表示する

set -euo pipefail

WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT

cat > "$WORKDIR/Dockerfile" <<'EOF'
FROM rust:1.95.0

WORKDIR /app

RUN mkdir -p src

RUN cat > Cargo.toml <<'CARGO'
[package]
name = "rust-benchmark"
version = "0.1.0"
edition = "2021"

[profile.release]
lto = true
codegen-units = 1
panic = "abort"
CARGO

RUN cat > src/main.rs <<'RUST'
use std::{
    alloc::{GlobalAlloc, Layout, System},
    env,
    process::Command,
    sync::atomic::{AtomicUsize, Ordering},
    time::{Duration, Instant},
};

/// Counts live heap bytes and the high-water mark so auxiliary sort buffers
/// (swap Vecs, etc.) are measured as explicit heap growth during the sort.
struct TrackingAllocator;

static LIVE_BYTES: AtomicUsize = AtomicUsize::new(0);
static PEAK_BYTES: AtomicUsize = AtomicUsize::new(0);

fn record_alloc(size: usize) {
    let live = LIVE_BYTES.fetch_add(size, Ordering::Relaxed) + size;
    PEAK_BYTES.fetch_max(live, Ordering::Relaxed);
}

unsafe impl GlobalAlloc for TrackingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let ptr = System.alloc(layout);
        if !ptr.is_null() {
            record_alloc(layout.size());
        }
        ptr
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        LIVE_BYTES.fetch_sub(layout.size(), Ordering::Relaxed);
        System.dealloc(ptr, layout);
    }

    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
        let ptr = System.alloc_zeroed(layout);
        if !ptr.is_null() {
            record_alloc(layout.size());
        }
        ptr
    }

    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        let new_ptr = System.realloc(ptr, layout, new_size);
        if !new_ptr.is_null() {
            LIVE_BYTES.fetch_sub(layout.size(), Ordering::Relaxed);
            record_alloc(new_size);
        }
        new_ptr
    }
}

#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator;
const MIN_POWER: u32 = 8;
const MAX_POWER: u32 = 18;
const RUNS: usize = 8192;


fn proxmap_sort(a: &mut [usize]) {
    if a.is_empty() {
        return;
    }

    let n = a.len();
    let min = *a.iter().min().unwrap();
    let max = *a.iter().max().unwrap();
    let bucket_count = n;

    let map_key = |x: usize| -> usize {
        if max == min {
            0
        } else {
            ((x - min) * (bucket_count - 1)) / (max - min)
        }
    };

    let mut hit_count = vec![0usize; bucket_count];
    let mut map_keys = Vec::with_capacity(n);

    for &x in a.iter() {
        let mk = map_key(x);
        map_keys.push(mk);
        hit_count[mk] += 1;
    }

    let mut prox_map = vec![None; bucket_count];
    let mut running_total = 0usize;

    for (i, &hits) in hit_count.iter().enumerate() {
        if hits > 0 {
            prox_map[i] = Some(running_total);
            running_total += hits;
        }
    }

    let location: Vec<usize> = map_keys
        .iter()
        .map(|&mk| prox_map[mk].expect("missing prox map entry"))
        .collect();

    let mut a2: Vec<Option<usize>> = vec![None; n];

    for i in 0..n {
        let key = a[i];
        let start = location[i];
        let mut insert_idx = start;

        loop {
            if a2[insert_idx].is_none() {
                a2[insert_idx] = Some(key);
                break;
            }

            let current = a2[insert_idx].unwrap();

            if key < current {
                let mut end = insert_idx + 1;
                while end < n && a2[end].is_some() {
                    end += 1;
                }

                // Shift right into the hole at `end` (inclusive of insert_idx).
                for k in (insert_idx..end).rev() {
                    a2[k + 1] = a2[k];
                }

                a2[insert_idx] = Some(key);
                break;
            }

            insert_idx += 1;
        }
    }

    for i in 0..n {
        a[i] = a2[i].unwrap();
    }
}


fn benchmark_sort(array: &mut [usize]) {

    proxmap_sort(array);

}

fn is_non_decreasing(a: &[usize]) -> bool {
    a.windows(2).all(|w| w[0] <= w[1])
}

fn same_multiset(a: &[usize], b: &[usize]) -> bool {
    if a.len() != b.len() {
        return false;
    }

    let mut left = a.to_vec();
    let mut right = b.to_vec();
    left.sort_unstable();
    right.sort_unstable();
    left == right
}

fn check_correctness_case(label: &str, mut input: Vec<usize>) {
    let original = input.clone();

    benchmark_sort(&mut input);

    if !is_non_decreasing(&input) {
        panic!("correctness case {}: output is not sorted", label);
    }

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

fn few_unique_values(size: usize, unique: usize, seed: u64) -> Vec<usize> {
    let mut state = seed;

    (0..size)
        .map(|_| {
            state ^= state << 13;
            state ^= state >> 7;
            state ^= state << 17;
            (state as usize % unique) + 1
        })
        .collect()
}

fn run_correctness_checks() {
    check_correctness_case("empty", vec![]);
    check_correctness_case("single", vec![42]);
    check_correctness_case("duplicates", vec![3, 1, 3, 2, 1, 2]);
    check_correctness_case("sorted", vec![1, 2, 3, 4, 5]);
    check_correctness_case("reverse", vec![5, 4, 3, 2, 1]);
    check_correctness_case("all_equal", vec![7, 7, 7, 7]);
    check_correctness_case("skewed_range", vec![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",
        vec![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(
            &format!("few_keys_len32_seed_{seed}"),
            few_unique_values(32, 4, seed),
        );
    }
}


fn shuffled(size: usize, seed: u64) -> Vec<usize> {
    let mut v: Vec<usize> = (1..=size).collect();

    let mut state = seed;

    for i in (1..size).rev() {
        state ^= state << 13;
        state ^= state >> 7;
        state ^= state << 17;

        let j = (state as usize) % (i + 1);

        v.swap(i, j);
    }

    v
}

fn micros(d: Duration) -> u128 {
    d.as_micros()
}

fn input_array(size: usize, seed: u64) -> Vec<usize> {
    shuffled(size, seed)
}

/// Peak heap growth during `benchmark_sort`, in KiB (explicit buffers such as swap).
fn run_once(size: usize, seed: usize) -> (u128, usize) {
    let mut array = input_array(size, seed as u64);

    let base_bytes = LIVE_BYTES.load(Ordering::Relaxed);
    PEAK_BYTES.store(base_bytes, Ordering::Relaxed);

    let start = Instant::now();

    benchmark_sort(&mut array);

    let elapsed = start.elapsed();
    let peak_bytes = PEAK_BYTES.load(Ordering::Relaxed);
    let aux_kb = peak_bytes.saturating_sub(base_bytes) / 1024;

    let expected: Vec<usize> = (1..=size).collect();
    if array != expected {
        panic!(
            "sort failed with seed {} for size {}",
            seed,
            size
        );
    }

    (micros(elapsed), aux_kb)
}

fn run_child(args: &[String]) {
    let size = args[2].parse::<usize>().expect("invalid size");
    let seed = args[3].parse::<usize>().expect("invalid seed");
    let (elapsed_us, mem) = run_once(size, seed);
    println!("{} {}", elapsed_us, mem);
}

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.get(1).is_some_and(|arg| arg == "--run-once") {
        run_child(&args);
        return;
    }

    run_correctness_checks();

    println!(
        "| {:>10} | {:>15} | {:>15} | {:>15} | {:>15} |",
        "Size",
        "Average time",
        "Maximum time",
        "Average memory",
        "Maximum memory"
    );

    println!(
        "|{:-<11}:|{:-<16}:|{:-<16}:|{:-<16}:|{:-<16}:|",
        "",
        "",
        "",
        "",
        ""
    );

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

        let mut total_time: u128 = 0;
        let mut max_time: u128 = 0;

        let mut total_mem: usize = 0;
        let mut max_mem: usize = 0;

        for seed in 1..=RUNS {
            let output = Command::new(env::current_exe().expect("failed to find current executable"))
                .arg("--run-once")
                .arg(size.to_string())
                .arg(seed.to_string())
                .output()
                .expect("failed to run benchmark child process");

            if !output.status.success() {
                panic!(
                    "benchmark child process failed: {}",
                    String::from_utf8_lossy(&output.stderr)
                );
            }

            let stdout = String::from_utf8(output.stdout)
                .expect("child process returned non-UTF-8 output");
            let mut fields = stdout.split_whitespace();
            let elapsed_us = fields
                .next()
                .expect("missing elapsed time")
                .parse::<u128>()
                .expect("invalid elapsed time");
            let aux_mem = fields
                .next()
                .expect("missing memory usage")
                .parse::<usize>()
                .expect("invalid memory usage");

            total_time += elapsed_us;

            if elapsed_us > max_time {
                max_time = elapsed_us;
            }

            total_mem += aux_mem;

            if aux_mem > max_mem {
                max_mem = aux_mem;
            }
        }

        let avg_time = total_time / RUNS as u128;
        let avg_mem = total_mem / RUNS;

        println!(
            "| {:>10} | {:>15} | {:>15} | {:>15} | {:>15} |",
            size,
            format!("{}.{:06}", avg_time / 1_000_000, avg_time % 1_000_000),
            format!("{}.{:06}", max_time / 1_000_000, max_time % 1_000_000),
            avg_mem,
            max_mem
        );
    }
}
RUST

RUN cargo build --release

CMD ["./target/release/rust-benchmark"]
EOF

docker build -t rust-benchmark "$WORKDIR"
docker run --rm --init rust-benchmark