Skip to content

Model Inference

Score every record with the SAME frozen 4-16-1 PyTorch MLP (models/, trained once by models/make_model.py). No filter, so selectivity is 1.0 — this prices what each framework costs around a forward pass: decode, feature extraction, the call itself, encode, produce. The model is deliberately tiny so the frameworks, not the matmul, are what is measured. Benched at each product's max sustainable single-partition rate (its knee).

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

flowchart LR
  src[("bench-input<br/>1 partition")]
  s0["<b>map</b><br/>4 features scaled to [0, 1]"]
  s1(["<b>model</b><br/>MLP 4-16-1 → risk"])
  sink[("bench-out")]
  src --> s0
  s0 --> s1
  s1 -->|"at-least-once"| sink
Bench characteristics

In-process computation — Per record: four gendata metrics (latency_ms, queue_depth, cpu_usage, error_count) divided by their documented maxima into [0, 1], fed to a frozen 4-16-1 MLP (Linear/ReLU/Linear/Sigmoid) → risk. Stateless; every record is scored and republished.

product delivery processing engine scaling on one partition decode
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 (9 of 19 fields, Arrow)
turbine-torch at-least-once (idempotent, acks=all) batch (Arrow columnar → DLPack → torch eager; 100k rows / 500 ms) in-process · Rust core + free-threaded CPython 3.14t intra-partition shards (parallelism=N) schema projection (9 of 19 fields, Arrow)
arroyo at-least-once (checkpointed) vectorized SQL around a Rust UDF on ONNX Runtime (one ORT run per record) in-process · Rust (arroyo cluster: API + compiler + controller + one worker process) none for a per-record job (chained into the subtask reading the partition) schema projection (7 of 19 fields)
faststream at-least-once (idempotent, acks=all; fire-and-forget publish) batch I/O (getmany 100k rows / 500 ms), per-row compute in-process · pure-Python async (aiokafka; orjson JSON codec) none (one async consumer) full record (19 of 19 fields, orjson dict)
flink at-least-once (checkpointed) batch (pipelined, JVM code-gen SQL) in-process · JVM (local MiniCluster; Python only submits the job) rebalance to N slots (parallelism.default, in-process local channels; source stays 1 subtask) schema projection (7 of 19 fields, Jackson); connector-default fetch
pyflink at-least-once (checkpointed) Arrow batches (100k rows) over gRPC to a Python worker (pandas UDF, PROCESS mode) → torch eager in-process · JVM (local MiniCluster) + Python UDF worker rebalance to N slots (parallelism.default, in-process local channels; source stays 1 subtask) schema projection (7 of 19 fields, Jackson); connector-default fetch
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)
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 (7 of 19 fields)

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.

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 drained/s cpu_avg % rows/s per core rows/s per GB µs CPU / inference output/s run
turbine 0.3.5a1 120M 825,187 ███████░ 361.8 228,062 ███████░ 463,947 ███████░ 4.38 847,168 2026-11-10
turbine-torch 0.3.5a1 120M 952,463 ████████ 351.5 270,985 ████████ 496,895 ████████ 3.69 914,475 2026-11-10
arroyo 0.15.0 120M 357,441 ███░░░░░ 217.8 164,132 █████░░░ 180,470 ███░░░░░ 6.09 367,170 2026-10-28
faststream 0.7.1 120M 42,278 █░░░░░░░ 99.8 42,374 █░░░░░░░ 58,415 █░░░░░░░ 23.60 40,744 2026-10-28
flink 2.2.1 60M 357,104 ███░░░░░ 482.1 74,075 ██░░░░░░ 221,443 ████░░░░ 13.50 363,281 2026-10-20
pyflink 2.2.1 120M 320,794 ███░░░░░ 275.5 116,445 ███░░░░░ 148,513 ██░░░░░░ 8.59 325,008 2026-10-28
quix 3.23.6 60M 29,768 █░░░░░░░ 102.7 28,992 █░░░░░░░ 32,709 █░░░░░░░ 34.49 30,372 2026-10-20
risingwave v3.1.0 60M 148,990 █░░░░░░░ 173.2 86,016 ███░░░░░ 123,715 ██░░░░░░ 11.63 150,470 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.

Resources (raw)

product cpu_avg % cpu_p95 % mem_avg MB mem_max MB samples instrument
turbine 0.3.5a1 361.8 386.2 1,821.3 1,929.1 58 /proc Δ, pss
turbine-torch 0.3.5a1 351.5 371.6 1,962.8 2,064.1 58 /proc Δ, pss
arroyo 0.15.0 217.8 251.1 2,028.1 2,345.2 59 /proc Δ, pss
faststream 0.7.1 99.8 101.0 741.1 851.7 59 /proc Δ, pss
flink 2.2.1 482.1 498.5 1,651.3 1,663.7 59 /proc Δ, pss
pyflink 2.2.1 275.5 290.7 2,211.9 2,230.4 58 /proc Δ, pss
quix 3.23.6 102.7 109.7 931.9 941.2 59 /proc Δ, pss
risingwave v3.1.0 173.2 207.5 1,233.2 1,245.3 58 /proc Δ, mixed
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

turbine — NATIVE operator — infer=ml.OnnxModel(...) on the subscription: the engine runs ONNX Runtime on every emitted batch in the worker's own thread (one session per partition), no Python in the loop; the handler only produces the four normalised feature columns (Arrow kernels). Same frozen network as everyone else (the ONNX export of the .pt, asserted against the card's probe rows at startup). Known surcharge: infer= appends the score to what the handler emits, so the four float32 feature columns are published too — four columns the other products do not write. Compare with turbine-torch, the same app with eager PyTorch in the handler.

turbine-torch — CONTROL LINE — Turbine with the model as eager PyTorch inside the handler (Arrow columns → DLPack, zero copy per column → one forward per batch), the iso-runtime row against the other products' eager-PyTorch lines. The gap to turbine is what Python-in-the-loop costs Turbine — here less than the native line's four published feature columns.

arroyo — The model is a Rust UDF on ONNX Runtime (arroyo/risk_udf.rs), compiled by Arroyo's own compiler service from its source — its Python UDFs run once per row in an embedded sub-interpreter, where numpy / torch do not load. arroyo run takes no UDF, so this workload runs arroyo cluster (API, controller, compiler and one worker process, all sampled) and submits the pipeline over its REST API. One ORT run per record, like Flink's Java UDF, on the same ONNX export and ONNX Runtime release as Turbine's operator. The per-record job is chained into the subtask reading the partition: no exchange spreads the model over the other cores.

faststream — CPU-bound on one core; the leanest footprint of the inference lines. The forward IS batched — the batch=True subscriber hands the handler a list — so what is being paid is the pure-Python loop around it and one publish per record.

flink — NATIVE — a Java ScalarFunction (flink/java/RiskUdf.java) on ONNX Runtime Java 1.29.0, the release Turbine's operator loads, on the same ONNX artefact, inside the JVM operator: the job is pure JVM like every other Flink workload (Python only submits it). Flink's scalar UDFs are called once per row — it has no vectorised Java UDF — so this is one ORT run PER RECORD, the framework's shape (Turbine runs one per batch). The features are normalised by the SQL around the call. Runs at cores 3 (the UDF rebalanced over 3 slots, ~10 % more CPU per record than cores 1 on the dev box); pyflink, the same job with the model as a pandas UDF, runs at cores 3 too (since 2026-10-20).

pyflink — CONTROL LINE — the flink job with the model as a Pandas UDF in PROCESS mode: every batch is serialised to Arrow, crosses gRPC to a separate Python worker (eager PyTorch, one thread, one forward per batch), and comes back — what "run a model in PyFlink" costs. The gap to flink is what that hop costs Flink. Rows measured before 2026-10-13 (as flink: ~10 cores at 81k/s) ran torch with its default thread pool in the worker — a bench bug (the driver's model check shipped the model pre-built, so the worker never pinned one thread), not PyFlink's cost: do not quote them. THREAD mode would remove the hop but forbids pandas UDFs and is GIL-bound.

quix — One PyTorch call PER RECORD — quixstreams' StreamingDataFrame has no batching primitive, so this is not a handicap, it is the framework: every other product here batches the forward because its API hands over a batch.

risingwave — Measured as the CONTAINER + the host UDF server (see benches.py also_process_tree): RisingWave cannot run a model in-engine — embedded Python UDFs are disabled by default and stdlib-only — so the model lives in an Arrow Flight server and every batch crosses that boundary. One thing the batching does NOT buy: arrow-udf boxes every incoming cell to a Python object before the function sees it, whatever the mode, so the columnar wire format does not survive to the model.

Sanity — ✓ matches theory

Are we benching the same thing?

product selectivity (out/consumed) expected ✓
turbine 0.3.5a1 1.0252 1.0000 ✓
turbine-torch 0.3.5a1 0.9391 1.0000 ✓
arroyo 0.15.0 1.0082 1.0000 ✓
faststream 0.7.1 0.9669 1.0000 ✓
flink 2.2.1 1.0174 1.0000 ✓
pyflink 2.2.1 1.0111 1.0000 ✓
quix 3.23.6 1.0188 1.0000 ✓
risingwave v3.1.0 1.0024 1.0000 ✓

Verdict: ✓ matches theory — every row's selectivity is within 25% of the expected value (output ÷ consumed).

Turbine releases

line version bench regime offered/s consumed/s rows/s per core µs CPU / inference rows/s per GB p99 ms Δ efficiency Δ capacity run
turbine · drain 0.3.5a1 1 drain — 825,187 228,062 4.38 463,947 - -6.6% ⚠ -5.3% ⚠ 2026-11-10
turbine · drain 0.3.4a1 1 drain — 871,252 244,053 4.10 484,188 - +0.4% +0.8% 2026-11-10
turbine · drain 0.3.3a1 1 drain — 864,264 242,985 4.12 464,259 - -5.2% ⚠ +0.5% 2026-11-06
turbine · drain 0.3.2a1 1 drain — 860,212 256,343 3.90 419,811 - +2.4% -3.4% 2026-11-03
turbine · drain 0.3.1a2 1 drain — 890,112 250,296 4.00 436,754 - +1.5% +3.7% 2026-11-01
turbine · drain 0.3.1a1 1 drain — 858,742 246,596 4.06 427,018 - -12.2% ⚠ +41.2% 2026-10-28
turbine · drain 0.2.2a2 1 drain — 608,349 280,703 3.56 293,823 - ≠ config ≠ config 2026-10-28
turbine · drain 0.2.2a1 1 drain — 594,639 270,287 3.70 284,953 - -0.9% +14.4% 2026-10-18
turbine · drain 0.2.1a4 1 drain — 519,819 272,629 3.67 294,150 - +1.6% -0.3% 2026-10-18
turbine · drain 0.2.1a3 1 drain — 521,358 268,464 3.72 287,747 - -1.5% -1.0% 2026-10-18
turbine · drain 0.2.1a2 1 drain — 526,715 272,527 3.67 287,069 - -5.5% ⚠ +82.2% 2026-10-18
turbine · drain 0.2.0a2 1 drain — 289,138 288,449 3.47 206,260 - - - 2026-10-18
turbine-torch · drain 0.3.5a1 1 drain — 952,463 270,985 3.69 496,895 - +2.2% +3.4% 2026-11-10
turbine-torch · drain 0.3.4a1 1 drain — 920,902 265,159 3.77 469,146 - -5.2% ⚠ -5.9% ⚠ 2026-11-10
turbine-torch · drain 0.3.3a1 1 drain — 978,793 279,620 3.58 504,797 - -1.5% +2.5% 2026-11-06
turbine-torch · drain 0.3.2a1 1 drain — 954,768 283,978 3.52 476,586 - +4.1% +0.4% 2026-11-03
turbine-torch · drain 0.3.1a2 1 drain — 950,791 272,696 3.67 500,475 - -2.8% -1.8% 2026-11-01
turbine-torch · drain 0.3.1a1 1 drain — 968,298 280,466 3.57 478,907 - -6.7% ⚠ +21.2% 2026-10-28
turbine-torch · drain 0.2.2a2 1 drain — 798,837 300,467 3.33 396,947 - ≠ config ≠ config 2026-10-28
turbine-torch · drain 0.2.2a1 1 drain — 764,775 291,800 3.43 389,755 - -1.2% +30.5% 2026-10-18
turbine-torch · drain 0.2.1a4 1 drain — 585,924 295,344 3.39 331,219 - -1.1% -1.1% 2026-10-18
turbine-torch · drain 0.2.1a3 1 drain — 592,630 298,744 3.35 339,976 - -4.5% -1.0% 2026-10-18
turbine-torch · drain 0.2.1a2 1 drain — 598,918 312,693 3.20 324,542 - -3.5% +69.3% 2026-10-18
turbine-torch · drain 0.2.0a2 1 drain — 353,777 323,949 3.09 240,478 - - - 2026-10-18
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.