use std::io::{Read, Seek, SeekFrom, Write};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
static ENABLE_SPILL: AtomicBool = AtomicBool::new(true);
static BUDGET_RECORDS: AtomicUsize = AtomicUsize::new(10_000_000);
static BYTES_COMPRESSED: AtomicUsize = AtomicUsize::new(0);
static SHARED_STATS: OnceLock<Mutex<Vec<Arc<SpillStats>>>> = OnceLock::new();
fn register_stats(stats: Arc<SpillStats>) {
SHARED_STATS.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap().push(stats);
}
fn collect_stats() -> (usize, usize, usize) {
if let Some(m) = SHARED_STATS.get() {
let v = m.lock().unwrap();
let chunks: usize = v.iter().map(|s| s.spilled_chunks.load(Ordering::Relaxed)).sum();
let records: usize = v.iter().map(|s| s.spilled_records.load(Ordering::Relaxed)).sum();
let decompressed: usize = v.iter().map(|s| s.bytes_written.load(Ordering::Relaxed)).sum();
(chunks, records, decompressed)
} else { (0, 0, 0) }
}
fn reset_stats() {
if let Some(m) = SHARED_STATS.get() { m.lock().unwrap().clear(); }
BYTES_COMPRESSED.store(0, Ordering::Relaxed);
}
use differential_dataflow::columnar::trace::{Batcher as ValBatcher, Builder as ValBuilder, Chunker as ValChunker, Spine as ValSpine};
use differential_dataflow::columnar::collection::Builder as ValColBuilder;
use differential_dataflow::columnar::trace::spill::{self, BytesSource, BytesStore, SpillStats};
use differential_dataflow::columnar::updates::{Updates, UpdatesTyped};
use differential_dataflow::operators::arrange::arrangement::arrange_core;
use timely::dataflow::channels::pact::Pipeline;
use timely::dataflow::operators::probe::{Handle as ProbeHandle, Probe};
use timely::dataflow::operators::Input;
use timely::dataflow::InputHandle;
struct FileStore {
current: Option<Arc<Mutex<std::fs::File>>>,
offset: u64,
}
impl FileStore {
const ROTATE_AFTER_BYTES: u64 = 1 << 30;
fn new() -> Self { Self { current: None, offset: 0 } }
fn file(&mut self) -> Arc<Mutex<std::fs::File>> {
if self.current.is_none() || self.offset >= Self::ROTATE_AFTER_BYTES {
self.current = Some(Arc::new(Mutex::new(tempfile::tempfile().expect("tempfile"))));
self.offset = 0;
}
self.current.as_ref().unwrap().clone()
}
}
impl BytesStore for FileStore {
fn store(&mut self, bytes: &[u8]) -> Box<dyn BytesSource> {
let decompressed_len = bytes.len();
let compressed = lz4_flex::block::compress(bytes);
BYTES_COMPRESSED.fetch_add(compressed.len(), Ordering::Relaxed);
let file = self.file();
let offset = self.offset;
{
let mut f = file.lock().unwrap();
f.seek(SeekFrom::Start(offset)).unwrap();
f.write_all(&compressed).unwrap();
}
self.offset += compressed.len() as u64;
Box::new(FileSource { file, offset, compressed_len: compressed.len(), decompressed_len })
}
}
struct FileSource {
file: Arc<Mutex<std::fs::File>>,
offset: u64,
compressed_len: usize,
decompressed_len: usize,
}
impl BytesSource for FileSource {
fn load(&self) -> Vec<u8> {
let mut compressed = vec![0u8; self.compressed_len];
{
let mut f = self.file.lock().unwrap();
f.seek(SeekFrom::Start(self.offset)).unwrap();
f.read_exact(&mut compressed).unwrap();
}
lz4_flex::block::decompress(&compressed, self.decompressed_len).expect("lz4 decompress")
}
}
type TestUpdate = (u64, u64, u64, i64);
fn make_chunk(updates: &[TestUpdate]) -> UpdatesTyped<TestUpdate> {
use columnar::Push;
let mut out = UpdatesTyped::<TestUpdate>::default();
for (k, v, t, d) in updates { out.push((k, v, t, d)); }
out.consolidate()
}
fn collect(chunk: &UpdatesTyped<TestUpdate>) -> Vec<TestUpdate> {
chunk.iter().map(|(k, v, t, d)| (*k, *v, *t, *d)).collect()
}
fn main() {
{
let chunk = make_chunk(&[(1, 10, 100, 1), (1, 10, 200, 2), (1, 20, 100, 3), (2, 20, 200, 5)]);
let mut buf = Vec::new();
Updates::<TestUpdate>::from(chunk.clone()).write_to(&mut buf);
let mut store = FileStore::new();
let source = store.store(&buf);
let back = Updates::<TestUpdate>::read_from(
timely::bytes::arc::BytesMut::from(source.load()).freeze()).into_typed();
assert_eq!(collect(&chunk), collect(&back), "store round-trip mismatch");
println!("ok: trie codec + file store round-tripped");
}
let cfg = match parse_args() { Some(c) => c, None => return };
let total_records = (cfg.times * cfg.keys_per_time) as usize * 2;
let bytes_per_record = std::mem::size_of::<TestUpdate>();
let raw_gb = (total_records * bytes_per_record) as f64 / (1u64 << 30) as f64;
let per_worker_budget = cfg.budget / cfg.workers.max(1);
println!(
"config: times={} keys={} workers={} budget={} ({} per worker) mode={:?} sample_secs={}",
cfg.times, cfg.keys_per_time, cfg.workers, cfg.budget, per_worker_budget, cfg.mode, cfg.sample_secs,
);
println!("workload: {} records ({:.2} GB raw, {} bytes/record)", total_records, raw_gb, bytes_per_record);
if cfg.mode != Mode::Baseline {
ENABLE_SPILL.store(true, Ordering::Relaxed);
BUDGET_RECORDS.store(per_worker_budget, Ordering::Relaxed);
reset_stats();
let elapsed = run_timely_dataflow(cfg.times, cfg.keys_per_time, cfg.workers, cfg.sample_secs);
let (chunks, records, decompressed) = collect_stats();
let compressed = BYTES_COMPRESSED.load(Ordering::Relaxed);
println!(
"spill: {:.2}s | {:.2} M records/s | spilled {} chunks ({} records)",
elapsed.as_secs_f64(), total_records as f64 / elapsed.as_secs_f64() / 1e6, chunks, records,
);
if decompressed > 0 {
println!(
"compression: {:.2} GB → {:.2} GB ({:.2}× ratio, lz4)",
decompressed as f64 / (1u64 << 30) as f64,
compressed as f64 / (1u64 << 30) as f64,
decompressed as f64 / compressed.max(1) as f64,
);
}
}
if cfg.mode != Mode::Spill {
ENABLE_SPILL.store(false, Ordering::Relaxed);
reset_stats();
let elapsed = run_timely_dataflow(cfg.times, cfg.keys_per_time, cfg.workers, cfg.sample_secs);
println!(
"baseline: {:.2}s | {:.2} M records/s",
elapsed.as_secs_f64(), total_records as f64 / elapsed.as_secs_f64() / 1e6,
);
}
}
fn run_timely_dataflow(times: u64, keys_per_time: u64, workers: usize, sample_secs: u64) -> std::time::Duration {
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = stop.clone();
let label = if ENABLE_SPILL.load(Ordering::Relaxed) { "spill" } else { "baseline" };
let sampler = (sample_secs > 0).then(|| {
std::thread::spawn(move || {
let start = std::time::Instant::now();
while !stop_clone.load(Ordering::Relaxed) {
if let Some(rss) = rss_kb() {
println!(" [{}] +{:>5.0}s RSS {:>9} kB", label, start.elapsed().as_secs_f64(), rss);
}
std::thread::sleep(std::time::Duration::from_secs(sample_secs));
}
})
});
let timer = std::time::Instant::now();
timely::execute(timely::Config::process(workers), move |worker| {
if ENABLE_SPILL.load(Ordering::Relaxed) {
let stats = Arc::new(SpillStats::default());
register_stats(stats.clone());
spill::install(BUDGET_RECORDS.load(Ordering::Relaxed), Box::new(FileStore::new()), stats);
} else {
spill::uninstall();
}
let index = worker.index();
let peers = worker.peers();
let mut input = <InputHandle<u64, ValColBuilder<TestUpdate>>>::new_with_builder();
let mut probe: ProbeHandle<u64> = ProbeHandle::new();
worker.dataflow::<u64, _, _>(|scope| {
let stream = scope.input_from(&mut input);
let arranged = arrange_core::<
_, _,
ValChunker<(u64, u64, u64, i64)>,
ValBatcher<u64, u64, u64, i64>,
ValBuilder<u64, u64, u64, i64>,
ValSpine<u64, u64, u64, i64>,
>(stream, Pipeline, "ColumnarSpillArrange");
arranged.stream.probe_with(&mut probe);
});
fn mix(k: u64) -> u64 { let x = k.wrapping_mul(0x9E3779B97F4A7C15); x ^ (x >> 32) }
const STEP_EVERY: usize = 1 << 16;
let mut sent_since_step = 0usize;
for sign in [1i64, -1] {
for t in 0..times {
let mut k = index as u64;
while k < keys_per_time {
let kh = mix(k);
let d = ((kh as i64) >> 1) | 1; input.send((kh, kh & 0x3, t, sign * d));
k += peers as u64;
sent_since_step += 1;
if sent_since_step >= STEP_EVERY { worker.step(); sent_since_step = 0; }
}
}
}
input.advance_to(1);
input.flush();
while probe.less_than(input.time()) { worker.step(); }
spill::uninstall();
}).expect("timely::execute failed");
let elapsed = timer.elapsed();
stop.store(true, Ordering::Relaxed);
if let Some(s) = sampler { let _ = s.join(); }
elapsed
}
#[derive(Debug, PartialEq)]
enum Mode { Both, Spill, Baseline }
struct Config {
times: u64,
keys_per_time: u64,
budget: usize,
workers: usize,
sample_secs: u64,
mode: Mode,
}
fn parse_args() -> Option<Config> {
let mut cfg = Config {
times: 8,
keys_per_time: 500_000,
budget: 10_000_000,
workers: 1,
sample_secs: 0,
mode: Mode::Both,
};
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
let take = |it: &mut dyn Iterator<Item = String>, name: &str| -> String {
it.next().unwrap_or_else(|| { print_usage(); panic!("--{} requires a value", name) })
};
match a.as_str() {
"-h" | "--help" => { print_usage(); return None; }
"--times" => { cfg.times = take(&mut it, "times").parse().expect("times: integer"); }
"--keys" => { cfg.keys_per_time = take(&mut it, "keys").parse().expect("keys: integer"); }
"--budget" => { cfg.budget = take(&mut it, "budget").parse().expect("budget: integer"); }
"--workers" => { cfg.workers = take(&mut it, "workers").parse().expect("workers: integer"); }
"--sample-secs" => { cfg.sample_secs = take(&mut it, "sample-secs").parse().expect("sample-secs: integer"); }
"--mode" => {
cfg.mode = match take(&mut it, "mode").as_str() {
"both" => Mode::Both,
"spill" => Mode::Spill,
"baseline" => Mode::Baseline,
other => { print_usage(); panic!("unknown mode: {}", other); }
};
}
other => { print_usage(); panic!("unknown arg: {}", other); }
}
}
Some(cfg)
}
fn print_usage() {
eprintln!("Usage: columnar_spill [OPTIONS]");
eprintln!();
eprintln!(" --times N distinct data timestamps (default 8)");
eprintln!(" --keys N keys per timestamp (default 500000)");
eprintln!(" --budget N resident-record budget (split across workers) (default 10000000)");
eprintln!(" --workers N timely worker threads (default 1)");
eprintln!(" --sample-secs N print RSS every N seconds (default 0 = off)");
eprintln!(" --mode MODE spill | baseline | both (default both)");
eprintln!();
eprintln!("Total records pushed = 2 * times * keys (positives + negatives that cancel).");
}
fn rss_kb() -> Option<usize> {
let pid = std::process::id();
let output = std::process::Command::new("ps")
.args(["-o", "rss=", "-p", &pid.to_string()]).output().ok()?;
std::str::from_utf8(&output.stdout).ok()?.trim().parse::<usize>().ok()
}