MAPD-B · Final Project  —  distributed pipeline + benchmark study

Listening for axions,
one batch at a time

A real-time pipeline for the QUAX experiment: radio-frequency physics data streamed from S3 through Kafka to a live spectrum — processed on Dask, on Spark, and with no engine at all. Then two benchmarks to measure what the engine is actually worth.

AVERAGED POWER SPECTRUM · 2048 BINS · ONE TRACE PER BATCH — WHAT THE LIVE DASHBOARD DRAWS

CloudVenetoKafka 3.7Dask Spark 4.1S3Streamlit
github.com/amirh0ss3in/MAPDBProject

01 · the problem

A detector that never stops talking

QUAX — the QUest for AXions — hunts dark-matter axions by listening for a faint microwave tone in a resonant cavity. Its readout is a continuous stream of I/Q radio samples. In this project, 31 pairs of ~64 MB binary files (duck_i_*.dat / duck_q_*.dat) in a CloudVeneto S3 bucket stand in for the live data-acquisition stream.

Build it three ways — Dask, Spark, no engine — and measure what distribution actually buys.

fetchpull each batch of raw I/Q bytes from S3 as it is announced
transformFFT every 2048-sample window → an averaged power spectrum (mean + std)
watchplot it on a live dashboard, seconds after the batch lands
measuresame job on three backends — which scheduler earns its keep, and when?

02 · infrastructure

Three small machines behind a gate

master · 10.67.22.111

Ubuntu 24.04 · 2 vCPU · 4 GB. Runs Kafka (+ ZooKeeper), the processor driver, and the Streamlit dashboard — and doubles as a compute node.

worker1 · 10.67.22.246

Ubuntu 24.04 · 2 vCPU · 4 GB. Pure compute: a Dask worker or a Spark executor, depending on the engine under test.

worker2 · 10.67.22.248

Ubuntu 24.04 · 2 vCPU · 4 GB. Same role as worker1 — both engines get the identical three machines, symmetrically.

ProxyJumpCloudVeneto VMs live on a private network — one ~/.ssh/config entry tunnels laptop → gate.cloudveneto.it → VM in a single hop
/etc/hostsall three nodes resolve each other by name — Dask and Spark both need seamless node-to-node chatter
ssh-keygenpasswordless SSH from master to workers, so the cluster can spawn remote processes without a human typing passwords
uvone command chain, run on all three nodes → byte-identical Python environments (dask, pyspark 4.1.2, numpy, boto3, kafka-python-ng, streamlit) + Java 17 for Spark

03 · architecture

Ship work orders, not data

heavy bytes skip the queue

The producer announces filenames, not payloads. Workers pull the 64 MB directly from S3 — Kafka never becomes a network bottleneck.

Kafka decouples the rates

If data arrives faster than compute drains it, nothing crashes — the backlog simply waits in quax_raw. That buffer is what Benchmark B later leans on.

results are just messages

Every processed spectrum lands in quax_processed; the dashboard is one more consumer, redrawing its plot per batch over an SSH port-forward.

04 · the physics kernel

One function, byte-for-byte, everywhere

# abridged — identical in all 7 scripts
def process_physics_data(work_order):
    s3 = boto3.client("s3", endpoint_url=S3_URL, ...)

    obj_i  = s3.get_object(Bucket="quax", Key=work_order["i_file"])
    obj_q  = s3.get_object(Bucket="quax", Key=work_order["q_file"])
    data_i = np.frombuffer(obj_i["Body"].read(), dtype="<f4")
    data_q = np.frombuffer(obj_q["Body"].read(), dtype="<f4")

    signal  = data_i + 1j * data_q        # complex IQ stream
    windows = signal.reshape(-1, 2048)     # rows of 2048 samples

    spectra = np.fft.fftshift(np.fft.fft(windows, axis=1), axes=1)
    power   = np.abs(spectra) ** 2

    return {"batch_id": work_order["batch_id"],
            "average": power.mean(axis=0),  # the dashboard plots this
            "std":     power.std(axis=0)}
I + jQtwo float32 files become one complex signal, windowed into rows of 2048 samples
FFT → powerone shifted FFT per window; mean and std across windows give the averaged spectrum
1 batch = 1 taskthe FFT is deliberately not split across the cluster — each batch is a single task computed in one NumPy shot
why it mattersevery engine schedules the same unit of work — so the benchmarks compare engines, not algorithms

05 · engines

Same kernel, three schedulers

baseline · no engine
result = process_physics_data(work_order)

A plain function call, in-process on the master node. No cluster, no scheduler, no serialization — the control group every engine has to beat.

dask · 3-node SSHCluster
cluster = SSHCluster(["master", "worker1", "worker2"],
              worker_options={"env": s3_keys})
future = client.submit(process_physics_data, wo)

Master spawns workers over SSH; client.submit returns a future instantly, so the live processor keeps consuming Kafka while batches compute. S3 keys ride in via worker env.

spark · 3-node standalone
spark = (SparkSession.builder
    .master("spark://master:7077") ... )
result = (sc.parallelize([wo], numSlices=1)
    .map(process_physics_data).collect()[0])

A Standalone cluster — master daemon + one executor per worker — brought up for each benchmark. Keys via spark.executorEnv; executor Python pinned to the shared venv.

Same three machines, same S3 fetch, same FFT. The only variable left is the thing that schedules the work.

06 · benchmark design

One number can't answer two questions

benchmark A — per-task overhead

What does one batch cost?

Submit a batch, block until its result, submit the next. Only one task is ever in flight — which isolates exactly what an engine adds per task: cold start, steady-state time, jitter, CPU and memory.

5 independent trials × 20 batches per engine.

By construction, this test cannot show a speedup — nothing runs concurrently.

benchmark B — throughput under load

Does distribution help when work piles up?

Fire 100 batches as fast as Kafka accepts them — no artificial delay — and let a real backlog form. Measure sustained throughput and how long each batch waits, end to end.

4 configurations, each isolating one variable.

This is the regime a distributed engine is actually for.

A benchmark answers exactly the question it was built to ask — so we built two.

07 · method

The boring details that make numbers trustworthy

repeated trials, not one run

Each engine ran 5 independent trials; results report the mean and spread across trials. One unlucky batch — a GC pause, a slow S3 fetch — can dominate a single 20-sample stdev; it cannot manufacture a finding five times in a row.

fresh offsets per trial

Benchmark consumers use auto_offset_reset='latest' with no consumer group, so each trial only sees messages sent after it is ready. run_trials.sh polls the log for “Listening for work orders” before firing the producer.

the python -u gotcha

Python fully buffers stdout when redirected to a file — without -u, the readiness line never appears until the process exits, and every readiness poll times out. The trial still completes, just ~60 s slower for no reason. Unbuffered fixed it.

keys never touch a log

run_with_env.sh evals only the two export S3_* lines from ~/.bashrc — credentials reach the benchmark process without ever being echoed, printed, or committed.

Orchestration is two small shell scripts: run_trials.sh drives Benchmark A (start engine → wait ready → fire 20 batches → wait exit → repeat ×5); run_load.sh does the same for each Benchmark B configuration. Every batch lands as a row in a CSV under results/.

08 · benchmark A — per-task overhead

Warm speed ties; Spark pays at the edges

baseline · warm avg
1.35s / batch
± 0.09 across trials
dask · warm avg
1.45s / batch
± 0.06 across trials
spark · warm avg
1.54s / batch
± 0.05 across trials
spark cpu / mem
58% · 68%
vs ~52% · ~57–59% elsewhere
view the per-trial numbers
enginetrialcold start (s)warm avg (s)warm stdev (s)

Steady state, the identical NumPy FFT dominates and the engines sit within ~0.2 s of each other. The repeatable differences live at the edges: Spark's five cold starts all land at 2.9 s or above (JVM warm-up) while Dask's never leave the 1.5–2.0 s band, and Spark's batch-to-batch jitter averages 0.33 s — roughly 80% above baseline or Dask — with its noisiest trials (0.36, 0.46 s) unmatched anywhere else.

One batch at a time, an engine is pure overhead. But that was only half the question.

09 · benchmark B — throughput under load

Four rungs, one variable apart

1 baseline · sequential No engine, strictly one batch at a time. The zero-concurrency floor — what "do nothing" costs under load.
2 baseline · 8 threads Still no engine, still one machine — just a ThreadPoolExecutor. Isolates whether the fix is concurrency itself, or genuinely needs a cluster.
3 dask · 3 nodes Non-blocking client.submit per batch as it arrives — exactly how the live processor runs, now measured.
4 spark · 3 nodes A single collect() blocks, so 8 driver threads submit jobs concurrently to one shared SparkContext — a supported pattern.
the load

load_producer.py fires 100 batches uncapped, cycling the 31 real file pairs with fresh batch ids — real physics data reprocessed, no synthetic data.

the clock

Latency is measured from the moment Kafka accepts the work order to the moment the spectrum is done — queue time included, because queue time is the point.

10 · benchmark B — under a real backlog

The queue is the story

end-to-end latency per batch, in arrival order · 100 batches, uncapped

sequential 8 threads dask · 3 nodes spark · 3 nodes
view as table
configurationthroughputp50p95p99
baseline · sequential0.80 /s64.2 s119.4 s124.2 s
baseline · 8 threads1.13 /s50.5 s84.5 s87.5 s
dask · 3 nodes1.20 /s43.5 s79.8 s82.8 s
spark · 3 nodes1.21 /s44.8 s80.2 s82.4 s

With arrivals uncapped, every configuration falls behind — what differs is how fast the wait grows. Sequential processing climbs relentlessly to a 125 s worst case; both clusters bend the curve down to ~83 s, cutting median latency ~30% and lifting throughput ~50%. Dask and Spark are statistically indistinguishable from each other here.

11 · decomposing the win

Most of “distributed” is just “concurrent”

sustained throughput · batches per second · 100-batch load

threads alone: +41%

Going from sequential to 8 threads on the very same machine — no cluster, no engine — takes 0.80 → 1.13 batches/s. That's most of the total improvement, because each batch spends most of its ~1.4 s waiting on S3, not computing.

the cluster: +6% more

Adding two more machines and a real engine takes 1.13 → 1.20–1.21 batches/s. Real, repeatable — and modest for this I/O-bound workload. Heavier per-batch compute would grow this share; these VMs and this workload put the crossover about here.

Before you buy a cluster, stop doing one thing at a time.

12 · what we learned

The engine matters exactly when the queue does

in isolation — benchmark A

For one batch at a time, engine choice barely moves speed, and overhead is all you buy: Spark's JVM cold start (~3.2 s vs ~1.7–2.0 s) and extra jitter are repeatable costs with no offsetting benefit at this scale. Dask stays lightest on CPU and memory.

under backlog — benchmark B

Distribution genuinely helps: ~50% more throughput, ~30% lower median latency. But most of that is ordinary concurrency — 8 threads on one box gets you from 0.80 to 1.13 of the final 1.21 batches/s. The cluster adds the last, modest step.

the method finding

A synchronous benchmark structurally cannot see scheduling wins; a load test cannot see per-task cost. Neither result is wrong — each answers only the question it was built to ask. Ask both.

Caveats: 2 vCPU / 4 GB VMs and a sub-2s, I/O-bound batch — bigger machines or heavier compute shift where the concurrency-vs-cluster crossover sits. Known follow-up: S3 keys passed via worker env are visible in ps aux on the worker VMs; harden via Dask's Security config or a secrets file.

Full build log, code, and raw per-batch CSVs:  github.com/amirh0ss3in/MAPDBProject