Skip to content

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.