Skip to content

Windowed High Cardinality

The low-cardinality bench's tumbling 30s window, aggregating count + avg(latency_ms) grouped by user_id from a key pool PINNED across products (500k users — see tenants below) — one open window per active user. Stresses the keyed-state store rather than framework overhead. Capacity is read in drain mode, on a backlog carrying synthetic event time (every product meets the same windows and keys); the live runs, at each product's max sustainable single-partition rate (its knee), give the close latency.

Last updated: 2026-11-10 · same-machine, relative comparison.

flowchart LR
  src[("bench-input<br/>1 partition")]
  s0{{"<b>key by</b><br/>500k keys (user_id)"}}
  s1[["<b>window</b><br/>tumbling 30 s, event time<br/>count, avg(latency_ms)"]]
  sink[("bench-out")]
  state[("keyed state<br/>one open window per key")]
  probe>"latency probe<br/>arrival − window_end_ms"]
  src --> s0
  s0 --> s1
  s1 -->|"at-least-once"| sink
  s1 -.- state
  sink -.-> probe
Bench characteristics

In-process computation — Tumbling 30s event-time window on the Kafka message timestamp, 1 s grace, GROUP BY user_id (key pool pinned to 500k users — identical for every product regardless of its rate) → 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; 100k rows / 500 ms) 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)
turbine-resident at-least-once (idempotent, acks=all) batch (Arrow columnar; 100k rows / 500 ms) in-process · Rust core + free-threaded CPython 3.14t none — one unkeyed shard per partition (resident storage refuses parallelism) schema projection (6 of 19 fields, Arrow) resident typed columns in memory, checkpointed whole (storage="resident")
arroyo at-least-once (checkpointed) batch (vectorized SQL, Arrow / DataFusion; source batches of 100k rows / 500 ms) in-process · Rust (arroyo run: controller + one worker process) hash shuffle to N subtasks (arroyo run --parallelism, in-process queues) schema projection (2 of 19 fields) in-memory tables, checkpointed to the state dir
flink at-least-once (checkpointed) batch (pipelined, JVM code-gen SQL) in-process · JVM (local MiniCluster; Python only submits the job) keyed exchange to N slots (parallelism.default, in-process local channels; source stays 1 subtask) schema projection (3 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 (2 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.

Drain ceiling (--prefill: static backlog, no producer running)

product backlog event time/s warmup + window drained/s cpu_avg % rows/s per core rows/s per GB µs CPU / record panes/s run
turbine 0.3.5a1 200M 300k 40 + 120 s 458,202 ████░░░░ 294.2 155,737 ███░░░░░ 146,435 ███░░░░░ 6.42 24,714.40 2026-11-10
turbine-resident 0.3.5a1 300M 300k 40 + 120 s 858,879 ████████ 287.2 299,018 █████░░░ 444,738 ████████ 3.34 45,257.50 2026-11-10
arroyo 0.15.0 200M 300k 40 + 240 s 596,249 ██████░░ 123.7 481,907 ████████ 431,098 ████████ 2.08 33,019.85 2026-10-27
flink 2.2.1 200M 300k 268 + 515 s 57,064 █░░░░░░░ 178.6 31,959 █░░░░░░░ 32,612 █░░░░░░░ 31.29 3,111.83 2026-10-27
quix 3.23.6 140M 300k 385 + 750 s 33,191 █░░░░░░░ 103.3 32,143 █░░░░░░░ 19,557 █░░░░░░░ 31.11 1,993.86 2026-10-20
risingwave v3.1.0 140M 300k 85 + 150 s 107,751 █░░░░░░░ 206.1 52,286 █░░░░░░░ 7,946 █░░░░░░░ 19.13 6,622.10 2026-10-20
How to read this table

gendata wrote the backlog and exited BEFORE the consumer started, so the window measures a drain with no producer competing for the box — the product's consume ceiling free of gendata's CPU and 10 Hz burstiness. Not comparable with a live table (different regime); ⚠ exhausted = the backlog ran out mid-window, rate understated.

The backlog's records carry synthetic event time, event time/s records per second of it (gendata --event-time-rate): it stands for a live stream at that rate, so every product meets the SAME windows — the same records and keys per window, which live runs at each product's own knee do not — and drained/s is its capacity on that problem, closes included. Event time runs at drained ÷ event-time rate × the wall clock, panes/s with it. A product draining far below the event-time rate gets a longer warmup and window (≥ its first close, then two more, at its live knee's pace: warmup + window). No latency here: a pane's age is the backlog's. 0 ⚠ panes/s: no pane was published during the window — the drain did not run the whole pipeline, its drained/s is not comparable.

Throughput / efficiency (live, at each product's knee) — capped by the machine: gendata produces ~430k/s at most, so read capacity in the drain table
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.3.5a1 422,132 418,445 ████████ keep-up (+0.7%) 231.8 180,546 ███░░░░░ 135,006 ███░░░░░ 5.54 16,587.46 0 2026-11-10
turbine-resident 0.3.5a1 423,125 418,242 ████████ keep-up (+1.1%) 126.7 329,985 ██████░░ 295,741 ██████░░ 3.03 16,473.13 0 2026-11-10
arroyo 0.15.0 395,609 393,786 ████████ keep-up (+0.5%) 86.3 456,105 ████████ 392,350 ████████ 2.19 16,547.97 0 2026-10-27
flink 2.2.1 34,613 35,898 █░░░░░░░ keep-up (-3.6%) 124.2 28,912 █░░░░░░░ 23,553 █░░░░░░░ 34.59 14,531.28 0 2026-10-27
quix 3.23.6 23,746 23,410 █░░░░░░░ keep-up (+1.5%) 91.9 25,475 █░░░░░░░ 21,696 █░░░░░░░ 39.25 12,562.70 0 2026-10-20
risingwave v3.1.0 118,681 117,630 ██░░░░░░ keep-up (+0.9%) 201.0 58,509 █░░░░░░░ 16,661 █░░░░░░░ 17.09 16,468.68 0 2026-10-20
How to read this table

consumed/s is what the product actually drained (its consumer-group offset or engine counter), offered/s what gendata produced; a dotted underline marks the median of a repeated batch — hover it for the run count and the min – max. regime (lag drift): 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). The lag drift in parentheses = backlog change over the window ÷ records consumed — the evidence for the regime (≈ 0 at keep-up, positive when saturated, negative while draining). 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.3.5a1 keep-up 4,366.5 6,133.5 6,706.7 6,866.0 7,021.1 6058232 106.5 / 205.0 4,259.9 / 6,501.7
turbine-resident 0.3.5a1 keep-up 3,367.6 4,651.3 5,493.2 5,666.1 5,745.0 5980024 106.5 / 204.8 3,261.1 / 5,288.4
arroyo 0.15.0 keep-up 3,739.2 5,502.4 6,455.9 6,560.9 6,765.7 11958552 106.4 / 204.7 3,632.8 / 6,251.2
flink 2.2.1 keep-up 2,994.8 11,183.5 15,776.1 16,273.1 16,508.1 10601271 106.5 / 204.7 2,888.3 / 15,571.5
quix 3.23.6 keep-up 3,552.0 5,613.9 6,207.3 6,417.5 6,563.2 4547095 106.5 / 204.7 3,445.5 / 6,002.6
risingwave v3.1.0 keep-up 5,286.0 5,778.8 5,980.9 6,068.3 6,273.0 5977776 106.5 / 204.7 5,179.5 / 5,776.2
How to read this table

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). The floor is a low-volume topic's (100 small records/s, surfacing on Redpanda's 100 ms flush.ms): an output topic carrying several MB/s — every record scored and republished — surfaces sooner: a percentile under the floor's reads below, added does not apply. Colours: each percentile against the best (lowest) of its column — green under 2×, yellow from 2×, orange from 5×, red from 10× (the ratio on hover).

Resources (raw)

product cpu_avg % cpu_p95 % mem_avg MB mem_max MB samples instrument
turbine 0.3.5a1 294.2 414.2 3,204.1 3,492.6 116 /proc Δ, pss
turbine 0.3.5a1 231.8 354.7 3,173.9 3,425.3 116 /proc Δ, pss
turbine-resident 0.3.5a1 126.7 146.9 1,448.2 1,504.7 117 /proc Δ, pss
turbine-resident 0.3.5a1 287.2 303.9 1,977.6 2,041.1 116 /proc Δ, pss
arroyo 0.15.0 123.7 191.8 1,416.3 1,672.1 234 /proc Δ, pss
arroyo 0.15.0 86.3 109.4 1,027.7 1,235.8 235 /proc Δ, pss
flink 2.2.1 178.6 348.1 1,791.8 1,839.7 504 /proc Δ, pss
flink 2.2.1 124.2 366.9 1,560.7 1,641.6 234 /proc Δ, pss
quix 3.23.6 91.9 116.4 1,104.9 1,226.6 117 /proc Δ, pss
quix 3.23.6 103.3 118.3 1,737.9 1,968.2 730 /proc Δ, pss
risingwave v3.1.0 206.1 272.2 13,886.1 16,870.8 147 /proc Δ, rss
risingwave v3.1.0 201.0 331.4 7,229.6 13,487.0 118 /proc Δ, rss
How to read this table

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.

Product notes

flink — Local MiniCluster (PyFlink only submits the job), parallelism = the run's cores (3 since 2026-10-20, 1 before), RocksDB keyed-state backend (EmbeddedRocksDBStateBackend, on-disk LSM) — apples-to-apples with quix/Turbine/RisingWave, NOT Flink's default heap (RAM) backend (which sustained 3.5× more at the PRE-PIN pool, but kept every window key in the JVM heap at ~3.5 GB). At-least-once checkpointing. CPU/RSS include the JVM child (see the stateless note). Under the pinned 500k-key pool the keyed-state close path (≈500k panes per close at any rate ≥ 60k) is the binding constraint, not ingestion — well under one core at the knee — so flink lands below quix, where the pre-pin pool had it above.

risingwave — Server-style product: the dataflow runs in the risingwave container (single node, streaming_parallelism = the run's cores: 3 since 2026-10-20, its adaptive default before — every core of the box), the app is a thin DDL client; CPU/RSS are the whole container, consumed/s is read from its source state (the Kafka offset its source has read). The container is re-created before each run and stopped after it: a clean state store, and no compaction left running into the next run. At the pinned 500k-key pool its keyed aggregation parallelises across actors and holds a rate an order of magnitude above the single-core in-process products — read its RSS with the whole-single-node-server caveat.

Sanity — ✓ matches theory

Are we benching the same thing?

product panes/s expected sample
turbine 0.3.5a1 · drain 24,714.40 25,455.65 500
turbine 0.3.5a1 16,587.46 16,666.67 500
turbine-resident 0.3.5a1 16,473.13 16,666.67 500
turbine-resident 0.3.5a1 · drain 45,257.50 48,009.24 500
arroyo 0.15.0 · drain 33,019.85 33,124.92 500
arroyo 0.15.0 16,547.97 16,666.67 500
flink 2.2.1 · drain 3,111.83 2,669.16 500
flink 2.2.1 14,531.28 14,625.73 500
quix 3.23.6 12,562.70 12,717.87 500
quix 3.23.6 · drain 1,993.86 1,843.94 500
risingwave v3.1.0 · drain 6,622.10 5,747.95 500
risingwave v3.1.0 16,468.68 16,654.22 500

Aggregated output: checked against the expected pane cadence (expected.output_rate in bench.yaml; × drained ÷ event-time rate for a windowed drain), not per-record selectivity.

Verdict: ✓ matches theory — panes/s within 2× of the expected cadence for every row.

Turbine releases

line version bench regime offered/s consumed/s rows/s per core µs CPU / record rows/s per GB p99 ms Δ efficiency Δ capacity run
turbine 0.3.5a1 3 keep-up 422,132 418,445 180,546 5.54 135,006 6,706.7 +2.1% - 2026-11-10
turbine 0.3.4a1 3 keep-up 421,384 417,602 176,847 5.65 120,480 6,922.4 +6.6% - 2026-11-10
turbine 0.3.3a1 3 keep-up 425,059 417,302 165,853 6.03 117,920 11,918.6 +10.6% - 2026-11-06
turbine 0.3.2a1 3 keep-up 423,918 409,818 149,967 6.67 107,166 37,406.5 ≠ config ≠ config 2026-11-04
turbine 0.3.1a2 3 keep-up 385,768 383,376 154,814 6.46 107,239 35,021.2 ≠ config ≠ config 2026-11-01
turbine 0.3.1a1 3 keep-up 296,864 294,616 141,761 7.05 91,054 31,615.3 ≠ bench ≠ bench 2026-10-27
turbine 0.2.2a2 2 ⚠ deprecated keep-up 296,693 293,620 121,322 8.24 90,228 21,390.7 +0.8% - 2026-10-20
turbine 0.2.2a1 2 ⚠ deprecated keep-up 296,807 293,736 120,304 8.31 91,082 21,623.0 +4.4% - 2026-10-18
turbine 0.2.1a4 2 ⚠ deprecated saturated 296,770 276,846 115,249 8.68 79,834 - -2.7% - 2026-10-18
turbine 0.2.1a3 2 ⚠ deprecated keep-up 296,809 287,980 118,469 8.44 84,241 23,443.1 +2.7% - 2026-10-18
turbine 0.2.1a2 2 ⚠ deprecated saturated 296,778 282,548 115,322 8.67 85,276 - -3.3% +54.3% 2026-10-18
turbine 0.2.0a2 2 ⚠ deprecated saturated 296,729 183,165 119,296 8.38 54,154 - - - 2026-10-18
turbine · drain 0.3.5a1 3 drain — 458,202 155,737 6.42 146,435 - +5.8% +11.4% 2026-11-10
turbine · drain 0.3.4a1 3 drain — 411,147 147,204 6.79 113,686 - -9.6% ⚠ -10.3% ⚠ 2026-11-10
turbine · drain 0.3.3a1 3 drain — 458,159 162,812 6.14 119,340 - +9.5% +20.7% 2026-11-06
turbine · drain 0.3.2a1 3 drain — 379,542 148,627 6.73 91,009 - -3.7% -5.3% ⚠ 2026-11-03
turbine · drain 0.3.1a2 3 drain — 400,765 154,328 6.48 96,363 - +2.9% +4.3% 2026-11-01
turbine · drain 0.3.1a1 3 drain — 384,177 149,945 6.67 91,627 - +2.9% +0.5% 2026-10-28
turbine · drain 0.2.2a2 3 drain — 382,231 145,699 6.86 83,065 - - - 2026-10-27
turbine-resident 0.3.5a1 3 keep-up 423,125 418,242 329,985 3.03 295,741 5,493.2 -2.4% - 2026-11-10
turbine-resident 0.3.4a1 3 keep-up 421,064 417,723 338,091 2.96 280,895 5,650.2 - - 2026-11-10
turbine-resident · drain 0.3.5a1 3 drain — 858,879 299,018 3.34 444,738 - -6.2% ⚠ -6.0% ⚠ 2026-11-10
turbine-resident · drain 0.3.4a1 3 drain — 914,056 318,755 3.14 468,017 - - - 2026-11-10
How to read this table

The Turbine lines measured on several releases, newest first: a regression check, not part of the cross-product comparison above (which shows the pinned release only). Δ compares each release with the one below it: efficiency = rows/s per core (the inverse of µs CPU per record), capacity = consumed/s, only when both runs were fed beyond capacity (saturated or drain — at a keep-up point consumed/s is just the offered rate). ⚠ = worse by more than 5%, about the single-run noise: confirm with --repeat 3. Two releases measured in two sessions also differ by the host-to-host spread — same flavor, another physical host: −0.7 % consumed / −3.5 % rows/s per core in the c3 control of 2026-10-07 —, which leaves little headroom under the 5 %: re-measure both releases in ONE session (suite.py run … --version X) before acting on a ⚠. No delta is computed across a change of bench version (bench, from bench.yaml), host or run config; ⚠ deprecated rows were measured on an older bench definition — re-run that release with suite.py run turbine WORKLOAD --version X.

Host: ovh:c3-32 (ovh:c3-32@DE1, ovh:c3-32@SBG5, ovh:c3-32@UK1) · AMD EPYC-Milan Processor (2400 MHz) · 16 cores / 16 threads · 31345 MB · kernel 6.12.100+deb13-cloud-amd64.

Redpanda: rpk version: v26.2.2. Redpanda, gendata and the product under test share this host.