Windowed Low Cardinality
Tumbling 30s event-time window grouped by region (16 fixed keys), aggregating count + avg(latency_ms). The tiny key set keeps state trivial, so this isolates framework + windowing overhead. Benched at each product's max sustainable single-partition rate (its knee).
Last updated: 2026-09-10 · same-machine, relative comparison.
Bench characteristics
In-process computation — Tumbling 30s event-time window on the Kafka message timestamp, 1 s grace, GROUP BY region (16 fixed keys) → count + avg(latency_ms); one pane per key per window close.
| product | delivery | processing | engine | scaling on one partition | decode | state |
|---|---|---|---|---|---|---|
| turbine | at-least-once (idempotent, acks=all) | batch (Arrow columnar) | in-process · Rust core + free-threaded CPython 3.14t | intra-partition shards (parallelism=N, hash of partition_key, in-process) |
schema projection (6 of 19 fields, Arrow) | own RocksDB store (merge operator for windows) |
| flink | at-least-once (checkpointed) | batch (pipelined, JVM code-gen SQL) | in-process · JVM (PyFlink local MiniCluster) | keyed exchange to N slots (parallelism.default, in-process local channels; source stays 1 subtask) |
schema projection (4 of 19 fields, Jackson); connector-default fetch | RocksDB (embedded, on-disk LSM) |
| quix | at-least-once (idempotent, acks=all) | row (per-record StreamingDataFrame) | in-process · pure-Python over librdkafka (C client) | none (one consumer per partition, GIL) | full record (19 of 19 fields, orjson dict) | RocksDB (embedded); changelog topic OFF for the bench |
| risingwave | at-least-once | batch (vectorized SQL) | external server · Rust (single-node: meta+compute+frontend) | hash exchange to N stream actors (SET streaming_parallelism, in-process) |
schema projection (4 of 19 fields) | server-managed (Hummock LSM store) |
Input is a single Kafka partition (lz4). Each product may spread it over several cores through its own in-process mechanism (column scaling on one partition; what it actually used is cpu_avg); an in-process exchange is not a broker hop — the extra rec/rec column counts records that DID cross the broker on other topics. All products run at-least-once, windows are tumbling 30 s event-time on the Kafka message timestamp.
Consumer fetch: Turbine's core hard-codes librdkafka fetch.min.bytes=64 KB, fetch.wait.max.ms=10, max.partition.fetch.bytes=50 MB and a 500k-msg / ~2 GB prefetch queue (not adjustable), so the SAME client gets the SAME settings: quix and RisingWave (librdkafka) run with them, and so does FastStream (aiokafka, whose equivalent knobs lifted it ~14%). Flink runs at its Java connector's defaults: an A/B (2026-09-10) showed the identical numbers throttled the Java consumer (373k/s vs 543k/s at the same ~285k rows/s per core) — identical numbers are not identical behaviour on a different client. Memory at saturation is mostly this prefetch queue, for every librdkafka product.
Throughput / efficiency (live)
| product | offered/s | consumed/s | regime | lag drift | cpu_avg % | rows/s per core | rows/s per GB | µs CPU / record | panes/s | extra rec/rec | run |
|---|---|---|---|---|---|---|---|---|---|---|---|
| turbine 0.2.0a1 | 593,545 | 588,790 | keep-up | +0.8% | 129.7 | 454,136 | 1,001,719 | 2.20 | 0.53 | 0 | 2026-09-10 |
| flink 2.2.1 | 727,896 | 723,677 n=2 [713,074–734,279] | keep-up | +0.6% | 113.3 | 638,562 | 652,059 | 1.57 | 0.60 | 0 | 2026-09-10 |
| quix 3.23.6 | 89,086 | 88,342 | keep-up | +0.8% | 89.0 | 99,304 | 891,608 | 10.07 | 0.53 | 0 | 2026-09-10 |
| risingwave v2.8.4 | 346,236 | 346,252 | keep-up | -0.0% | 103.6 | 334,128 | 694,405 | 2.99 | 0.53 | 0 | 2026-09-10 |
consumed/s is what the product actually drained (its consumer-group offset or engine counter), offered/s what gendata produced. regime: keep-up = consumed ≈ offered with a flat backlog (a sustainable point); saturated = fed beyond capacity, so consumed/s IS the ceiling and latency is unbounded; draining = still eating warmup backlog (consumed > offered; CPU is catch-up, not steady state — lengthen the warmup, or raise the rate if the ceiling is what you want). lag drift = backlog change over the window ÷ records consumed. cpu_avg is the whole process tree, 100 = one core busy. rows/s per core = consumed ÷ (cpu_avg/100); rows/s per GB = consumed ÷ memory; extra rec/rec = records written to topics other than the input and output per record consumed (0 = no broker hop).
panes/s is the window-emission cadence (≈ keys ÷ window size), a liveness signal, not a throughput figure — it only moves when windows close, which needs the consumer to keep up.
Latency (close latency — pane published minus window end)
| product | regime | p50 ms | p90 ms | p99 ms | p99.9 ms | max ms | records | setup floor p50 / p99 ms | added p50 / p99 ms |
|---|---|---|---|---|---|---|---|---|---|
| turbine 0.2.0a1 | keep-up | 1,202.2 | 1,614.3 | 1,614.3 | 1,614.3 | 1,614.3 | 64 | 106.5 / 204.1 | 1,095.7 / 1,410.2 |
| flink 2.2.1 | keep-up | 1,317.2 | 1,928.4 | 2,017.2 | 2,017.2 | 2,017.2 | 144 | 106.2 / 204.1 | 1,211.0 / 1,813.1 |
| quix 3.23.6 | keep-up | 1,117.4 | 1,127.2 | 1,127.2 | 1,127.2 | 1,127.2 | 64 | 106.0 / 204.0 | 1,011.4 / 923.3 |
| risingwave v2.8.4 | keep-up | 6,274.5 | 6,286.0 | 6,454.0 | 6,454.0 | 6,454.0 | 64 | 106.2 / 204.3 | 6,168.3 / 6,249.7 |
Measured by a probe consumer tailing the output topic (C client, 10 ms max fetch wait — the probe adds ≲10 ms, the same for every product). Only shown for keep-up runs: under saturation the figure is the backlog's age and grows without bound. setup floor is a harness producer writing one small record every 10 ms (acks=all, 5 ms linger) to its own topic during the same run, measured by the same probe: the broker's produce→visible latency at the products' output shape (on Redpanda, small produces surface on its flush cadence), which every product inherits. added = product − floor, the part the product is responsible for (fetch, batching, commit/epoch cadence, produce).
Resources (raw)
| product | cpu_avg % | cpu_p95 % | mem_avg MB | mem_max MB | samples | instrument |
|---|---|---|---|---|---|---|
| turbine 0.2.0a1 | 129.7 | 131.7 | 601.9 | 660.3 | 118 | /proc Δ, pss |
| flink 2.2.1 | 113.3 | 124.5 | 1,136.5 | 1,188.5 | 117 | /proc Δ, pss |
| quix 3.23.6 | 89.0 | 91.7 | 101.5 | 102.2 | 118 | /proc Δ, pss |
| risingwave v2.8.4 | 103.6 | 107.3 | 510.6 | 551.9 | 118 | /proc Δ, rss |
CPU is summed over the whole process tree (JVM, UDF servers, the RisingWave container's processes) as utime+stime deltas per 1 s sample — 100 = one core. Memory is PSS (shared pages counted once) where readable, RSS otherwise (instrument). Rows marked ⚠ legacy ps were measured with the old ps %CPU lifetime average and are not comparable: re-run them.
flink — PyFlink local MiniCluster, parallelism=1 (one task slot / one partition). RocksDB keyed-state backend (EmbeddedRocksDBStateBackend, on-disk LSM) — apples-to-apples with quix/Turbine/RisingWave, not Flink's default heap (RAM) — but with only 16 region keys the state never spills past the memtable, so it performs like heap. At-least-once checkpointing to the local filesystem. CPU/RSS are summed across the process tree INCLUDING the JVM child (GC/checkpoint/network threads), so CPU reads ~1.2 cores even at parallelism=1 — the "one core" is the WORK parallelism (a single slot), not a CPU pinning. expected.output_rate is the fixed 16-region pane cadence (0.53). Single-partition knee ≈ 650k (consume ≈ offered, lag ≈ 0); 800k decouples.
Sanity — are we benching the same thing?
| product | panes/s | expected | sample |
|---|---|---|---|
| turbine 0.2.0a1 | 0.53 | 0.53 | 80 |
| flink 2.2.1 | 0.60 | 0.53 | 96 |
| quix 3.23.6 | 0.53 | 0.53 | 96 |
| risingwave v2.8.4 | 0.53 | 0.53 | 80 |
Aggregated output: checked against the expected pane cadence (expected.output_rate in bench.yaml), not per-record selectivity.
Verdict: ✓ matches theory — panes/s within 2× of the expected cadence for every row.
Host: AMD Ryzen 5 7600X 6-Core Processor · 6 cores / 12 threads · 31241 MB · kernel 6.12.107+deb13-amd64.
Redpanda: rpk version: v26.1.6. Redpanda, gendata and the product under test share this host.