Primary workload: OTEL telemetry (OTLP/JSON logs, metrics, traces). GH Archive is a wide-schema stress test in the appendix.
All published numbers: DuckDB v1.5.5, default RawDuck settings, Apple M3 Ultra
(32 cores, 512 GiB) unless noted. Cold ingest = first raw_ingest_file in a fresh database;
warm = second ingest in the same process after DELETE (columns_added = 0). Report the
best of N sessions unless noted. See scripts/benchmark/README.md for metric definitions.
GEN=ninja make release
# OTEL bulk ingest (NDJSON file)
./scripts/benchmark/run_otel.sh --quick
./scripts/benchmark/run_otel.sh --records 1000000 --runs 5
# OTEL streaming ingest (OpenTelemetry SDK → raw_serve HTTP)
./scripts/benchmark/run_otel_streaming.sh --quick
./scripts/benchmark/run_otel_streaming.sh --workers 16 --spans-per-worker 60000
# VARIANT vs RawDuck (same trace dataset)
./scripts/benchmark/run_variant.sh --quick
./scripts/benchmark/run_variant.sh --records 1000000 --runs 31,000,000 records per signal, OTLP/JSON export envelopes (collector POST bodies), best of 5 sessions:
| signal | records | source NDJSON | cold ingest | records/s | throughput |
|---|---|---|---|---|---|
| traces | 1,000,000 | 435 MB | 1.19 s | 841k | 366 MB/s |
| logs | 1,000,000 | 294 MB | 0.87 s | 1.15M | 338 MB/s |
| metrics | 1,000,000 | 353 MB | 1.13 s | 889k | 314 MB/s |
3M telemetry records in 3.2 s (~940k records/s average). Warm ingest matches cold within ~2% on each signal.
Same spans — shredded typed columns vs one JSON object per span (->>), best of 3 runs:
| query | JSON ->> |
RawDuck | speedup |
|---|---|---|---|
error count by service (status>=500) |
39 ms | 1.5 ms | 26× |
| p99 latency by route | 99 ms | 3.2 ms | 31× |
| status-code distribution | 35 ms | 2.5 ms | 14× |
| storage | 143 MB | 39.5 MB | 3.6× smaller |
./scripts/benchmark/run_otel.sh --records 1000000 --runs 5
./scripts/benchmark/run_variant.sh --records 1000000 --runs 3 # queries + storage aboveReal OTLP/HTTP protobuf traffic via the OpenTelemetry Python SDK into raw_serve()
(concurrent exporter processes, not bulk NDJSON):
| workers | spans | wall | spans/s |
|---|---|---|---|
| 4 | 20,000 | 0.75 s | 27k |
| 16 | 960,000 | 3.85 s | 250k |
rows_ingested must equal total_spans_sent (checked by the harness).
./scripts/benchmark/run_otel_streaming.sh --workers 16 --spans-per-worker 60000First run creates benchmark/work/otel-streaming-venv (OpenTelemetry SDK dependency).
Same 1,000,000 OTLP/JSON trace spans. Paths:
| path | definition |
|---|---|
| RawDuck | raw_ingest_file(..., transform := 'otlp-traces') → typed columns |
| VARIANT OTLP | SQL unnest → one VARIANT {resource, span} per span (KeyValue arrays kept) |
| JSON OTLP | same exploded shape as JSON |
| VARIANT-flat | to_json(traces)::VARIANT of shredded RawDuck rows (encode/query only) |
| JSON-flat | same shredded rows as JSON, queried with ->> |
Disk = used_blocks × block_size after cold CHECKPOINT. Parenthetical file size is after
warm re-ingest (includes free-list holes; not comparable across paths). VARIANT requires
STORAGE_VERSION 'v1.5.0'.
| path | M3 Ultra | Spark GB10 aarch64 (--threads 8) |
|---|---|---|
| RawDuck | 0.99 s · 1.01M rec/s · 39.5 MB (108 MB file) | 1.18 s · 850k rec/s · 35.5 MB (91 MB file) |
| VARIANT-flat | encode · 35.8 MB | encode · 39.5 MB |
| VARIANT OTLP | 11.96 s · 84k · 53.5 MB (106 MB file) | 7.96 s · 126k · 54.5 MB (114 MB file) |
| JSON-flat | encode · 143 MB | encode · 142 MB |
| JSON OTLP | 4.72 s · 212k · 241 MB (484 MB file) | 5.71 s · 175k · 242 MB (484 MB file) |
Spark GB10: 20 cores, 122 GiB, Linux aarch64.
| encoding | M3 Ultra | Spark GB10 |
|---|---|---|
| errors / p99 / status | errors / p99 / status | |
| RawDuck | 1.5 / 3.2 / 2.5 | 1.3 / 4.9 / 5.1 |
| JSON-flat | 39 / 99 / 35 | 65 / 136 / 63 |
| JSON OTLP positional | 213 / 303 / 193 | 253 / 415 / 215 |
| JSON OTLP key lookup | 344 / 418 / 304 | 397 / 580 / 366 |
| VARIANT-flat | 436 / 1225 / 416 | 700 / 1988 / 696 |
| VARIANT OTLP positional | 1227 / 3479 / 1162 | 2013 / 5947 / 1852 |
| VARIANT OTLP key lookup | 1493 / 3658 / 1404 | 2107 / 5996 / 2049 |
./scripts/benchmark/run_variant.sh --records 1000000 --runs 3
./scripts/benchmark/run_variant.sh --records 1000000 --runs 3 --threads 8 # many-core ARMOne hour of GH Archive data — 247,199 events / 956 MB NDJSON / 914 columns. Apple Silicon, 10 cores, DuckDB v1.5.5:
| JSON column | RawDuck | ||
|---|---|---|---|
| count by event type | 231 ms | 1 ms | 231× |
| top repos by pushes | 268 ms | 3 ms | 89× |
| distinct repos per actor | 457 ms | 10 ms | 46× |
| sum of push payload sizes | 265 ms | 1 ms | 265× |
| events per minute | 236 ms | 3 ms | 79× |
| cold ingest | 1.4 s | ~13 s | one-time cost |
| warm re-ingest | — | ~4.9 s | steady state |
| storage | 1.05 GB | 636 MB | 40% smaller |
curl -sL https://data.gharchive.org/2024-01-15-10.json.gz -o gh.json.gzCALL raw_ingest_file('gh_events', 'gh.json.gz');
CHECKPOINT;- Split NDJSON on
\nonly (notsplitlines()—\u2028/\u2029appear in strings). - Use
.timer onviaduckdb -f script.sql(notduckdb -c). - Shallow duckdb clones without tags report
v0.0.1; fetch tagv1.5.5.