blob: 90be7378a5aaf41ab2c90702e8e39e653b5fd8e5 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Synthesizes parquet datasets for the `wide_schema` / `narrow_schema`
//! benchmark suites.
//!
//! The base schema has 8 columns with deterministic synthetic data:
//!
//! id Int64 monotonic primary key
//! value Float64 numeric value (~5 figures)
//! count Int64 auxiliary integer
//! ts Date32 spread across ~6 years
//! category Utf8 low-cardinality (7 values)
//! flag Utf8 low-cardinality (3 values)
//! status Utf8 low-cardinality (2 values)
//! text Utf8 ~40-char synthetic string
//!
//! `--width-factor N` replicates the schema with `_2`, `_3`, …, `_N`
//! suffix copies. The suffix-renamed copies are zero-filled arrays of
//! the same datatype, laid out **before** the base columns in the
//! output schema — so the base data columns sit at the end. This
//! makes column lookup for the filter / project columns traverse
//! past all the padding, exercising any per-column-position cost in
//! the scanner / planner.
//!
//! Zero-filled rather than null because the parquet reader can
//! shortcut on all-null statistics, muting the wide-schema slowdown
//! by ~35 %. Despite the wide schema, every column still has its own
//! footer / page index / column-chunk metadata; the on-disk size
//! stays small — the point being to measure per-file metadata
//! overhead, not row IO.
//! Zero-filled was chosen over random data or other options because
//! it’s simple to implement, avoids any questions about what
//! the data distribution or shape should look like, and still achieves
//! the goal of having a wide table.
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use arrow::array::{
ArrayRef, Date32Array, Float64Array, Int32Array, Int64Array, RecordBatch, StringArray,
};
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
use clap::Parser;
use datafusion::error::Result;
use datafusion_common::exec_datafusion_err;
use parquet::arrow::ArrowWriter;
use parquet::basic::{Compression, ZstdLevel};
use parquet::file::properties::WriterProperties;
#[derive(Debug, Parser)]
#[command(
name = "gen_wide_data",
about = "Synthesize a wide-schema parquet dataset for the wide_schema benchmark suite"
)]
struct Cli {
/// Destination directory. Output files are written as
/// `events_0001.parquet`, `events_0002.parquet`, …
#[arg(long)]
dst_dir: PathBuf,
/// Number of times to replicate each base column. Copy 1 keeps the
/// original column names; copies 2..N append `_2`, `_3`, …, all
/// stored as zero-filled arrays. With the 8-column base schema,
/// factor 128 yields 1024 columns. Set to 1 for the narrow
/// variant (no replication, base columns only).
#[arg(long, default_value_t = 128)]
width_factor: usize,
/// Number of output files to write. Each file gets one row group.
#[arg(long, default_value_t = 256)]
num_files: usize,
/// Rows per output file.
#[arg(long, default_value_t = 50_000)]
rows_per_file: usize,
/// Per-batch row count for synthesis. Smaller batches reduce peak
/// memory; doesn't change output.
#[arg(long, default_value_t = 16_384)]
batch_size: usize,
}
const CATEGORIES: &[&str] = &["c0", "c1", "c2", "c3", "c4", "c5", "c6"];
const FLAGS: &[&str] = &["f0", "f1", "f2"];
const STATUSES: &[&str] = &["s0", "s1"];
/// Build a zero-filled array of the given datatype. Used for the
/// suffix-renamed padding columns. Zero-filled rather than all-null so
/// the parquet reader can't shortcut on null-array statistics — the
/// wide-schema slowdown reproduces ~35 % wider with zeros than with
/// nulls.
fn zero_array(dt: &DataType, n: usize) -> ArrayRef {
match dt {
DataType::Int32 => {
Arc::new(Int32Array::from_iter_values(std::iter::repeat_n(0i32, n)))
}
DataType::Int64 => {
Arc::new(Int64Array::from_iter_values(std::iter::repeat_n(0i64, n)))
}
DataType::Float64 => Arc::new(Float64Array::from_iter_values(
std::iter::repeat_n(0.0f64, n),
)),
DataType::Date32 => {
Arc::new(Date32Array::from_iter_values(std::iter::repeat_n(0i32, n)))
}
DataType::Utf8 => {
Arc::new(StringArray::from_iter_values(std::iter::repeat_n("", n)))
}
_ => panic!("zero_array: unsupported datatype {dt:?}"),
}
}
/// Eight-column base schema. All fields nullable so the schema is
/// uniform across base and zero-filled replicated copies.
fn base_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, true),
Field::new("value", DataType::Float64, true),
Field::new("count", DataType::Int64, true),
Field::new("ts", DataType::Date32, true),
Field::new("category", DataType::Utf8, true),
Field::new("flag", DataType::Utf8, true),
Field::new("status", DataType::Utf8, true),
Field::new("text", DataType::Utf8, true),
]))
}
/// Synthesize one batch of length `n` covering rows `[start, start+n)`.
fn synthesize_batch(start: usize, n: usize, schema: &SchemaRef) -> Result<RecordBatch> {
let id = Int64Array::from_iter_values((start..start + n).map(|i| i as i64));
let value = Float64Array::from_iter_values(
(start..start + n).map(|i| 900.0 + ((i as f64 * 13.7) % 99100.0)),
);
let count =
Int64Array::from_iter_values((start..start + n).map(|i| (i % 1000) as i64));
// Date32 spread across ~6 years (epoch days; 8035 ≈ 1992-01-01).
let ts = Date32Array::from_iter_values(
(start..start + n).map(|i| 8035 + ((i % 2200) as i32)),
);
let category = StringArray::from_iter_values(
(start..start + n).map(|i| CATEGORIES[i % CATEGORIES.len()]),
);
let flag =
StringArray::from_iter_values((start..start + n).map(|i| FLAGS[i % FLAGS.len()]));
let status = StringArray::from_iter_values(
(start..start + n).map(|i| STATUSES[i % STATUSES.len()]),
);
let text = StringArray::from_iter_values(
(start..start + n).map(|i| format!("synthetic event row {i:010} payload text")),
);
let cols: Vec<ArrayRef> = vec![
Arc::new(id),
Arc::new(value),
Arc::new(count),
Arc::new(ts),
Arc::new(category),
Arc::new(flag),
Arc::new(status),
Arc::new(text),
];
RecordBatch::try_new(Arc::clone(schema), cols)
.map_err(|e| exec_datafusion_err!("building synthetic batch: {e}"))
}
/// Builds the wide schema by laying out the suffix-renamed zero-padded
/// copies first and the unsuffixed base columns last. Putting the base
/// columns at the *end* of the schema is deliberate — column lookup
/// for the filter / project columns has to traverse past all the
/// padding entries, exercising any per-column-position cost in the
/// scanner / planner.
fn widen_schema(src: &SchemaRef, factor: usize) -> SchemaRef {
let src_fields = src.fields();
let mut fields: Vec<Arc<Field>> = Vec::with_capacity(src_fields.len() * factor);
for copy in 2..=factor {
for f in src_fields {
fields.push(Arc::new(Field::new(
format!("{}_{}", f.name(), copy),
f.data_type().clone(),
true,
)));
}
}
for f in src_fields {
fields.push(Arc::new(Field::new(
f.name().clone(),
f.data_type().clone(),
f.is_nullable(),
)));
}
Arc::new(Schema::new(fields))
}
fn widen_batch(
batch: &RecordBatch,
wide_schema: &SchemaRef,
factor: usize,
) -> Result<RecordBatch> {
let cols = batch.columns();
let n_rows = batch.num_rows();
let mut wide = Vec::with_capacity(cols.len() * factor);
// Zero-padded copies first…
for _ in 2..=factor {
for c in cols {
wide.push(zero_array(c.data_type(), n_rows));
}
}
// …then the base data columns at the end.
for c in cols {
wide.push(Arc::clone(c));
}
RecordBatch::try_new(Arc::clone(wide_schema), wide)
.map_err(|e| exec_datafusion_err!("building wide batch: {e}"))
}
fn open_writer(
path: &Path,
schema: SchemaRef,
rows_per_group: usize,
) -> Result<ArrowWriter<File>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| exec_datafusion_err!("creating {}: {e}", parent.display()))?;
}
let file = File::create(path)
.map_err(|e| exec_datafusion_err!("creating {}: {e}", path.display()))?;
let writer_props = WriterProperties::builder()
.set_max_row_group_row_count(Some(rows_per_group.max(1)))
.set_compression(Compression::ZSTD(ZstdLevel::try_new(1).unwrap()))
.build();
ArrowWriter::try_new(file, schema, Some(writer_props))
.map_err(|e| exec_datafusion_err!("creating ArrowWriter: {e}"))
}
fn main() -> Result<()> {
let cli = Cli::parse();
if cli.width_factor == 0 {
return Err(exec_datafusion_err!("--width-factor must be >= 1"));
}
if cli.num_files == 0 {
return Err(exec_datafusion_err!("--num-files must be >= 1"));
}
if cli.rows_per_file == 0 {
return Err(exec_datafusion_err!("--rows-per-file must be >= 1"));
}
if cli.batch_size == 0 {
return Err(exec_datafusion_err!("--batch-size must be >= 1"));
}
let raw_schema = base_schema();
let wide_schema = widen_schema(&raw_schema, cli.width_factor);
let cap_rows = cli.rows_per_file.saturating_mul(cli.num_files);
println!(
"Synthesizing: {} base cols × {} factor = {} cols × {} files of {} rows each (total {} rows, ZSTD(1), 1 row group/file).",
raw_schema.fields().len(),
cli.width_factor,
wide_schema.fields().len(),
cli.num_files,
cli.rows_per_file,
cap_rows,
);
std::fs::create_dir_all(&cli.dst_dir)
.map_err(|e| exec_datafusion_err!("creating {}: {e}", cli.dst_dir.display()))?;
let mut file_idx: usize = 0;
let mut writer: Option<ArrowWriter<File>> = None;
let mut rows_in_current: usize = 0;
let mut total_written: usize = 0;
let mut row_cursor: usize = 0;
'outer: while total_written < cap_rows {
let batch_n = cli.batch_size.min(cap_rows - total_written);
let base_batch = synthesize_batch(row_cursor, batch_n, &raw_schema)?;
let wide_batch = widen_batch(&base_batch, &wide_schema, cli.width_factor)?;
row_cursor += batch_n;
let mut remaining = wide_batch;
while remaining.num_rows() > 0 {
if writer.is_none() {
if file_idx >= cli.num_files {
break 'outer;
}
file_idx += 1;
let path = if cli.num_files == 1 {
cli.dst_dir.join("events.parquet")
} else {
cli.dst_dir.join(format!("events_{file_idx:04}.parquet"))
};
writer = Some(open_writer(
&path,
Arc::clone(&wide_schema),
cli.rows_per_file,
)?);
rows_in_current = 0;
}
let space = cli.rows_per_file.saturating_sub(rows_in_current);
let take = remaining.num_rows().min(space.max(1));
let chunk = remaining.slice(0, take);
writer
.as_mut()
.unwrap()
.write(&chunk)
.map_err(|e| exec_datafusion_err!("writing chunk: {e}"))?;
rows_in_current += take;
total_written += take;
if take == remaining.num_rows() {
remaining = remaining.slice(0, 0);
} else {
remaining = remaining.slice(take, remaining.num_rows() - take);
}
if rows_in_current >= cli.rows_per_file {
writer
.take()
.unwrap()
.close()
.map_err(|e| exec_datafusion_err!("closing writer: {e}"))?;
}
}
}
if let Some(w) = writer.take() {
w.close()
.map_err(|e| exec_datafusion_err!("closing writer: {e}"))?;
}
println!(
"Wrote {} rows across {} files in {}",
total_written,
file_idx,
cli.dst_dir.display(),
);
Ok(())
}