GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — [connectors] Parse Arrow batches faster in the Delta Lake and Iceberg inputs. by bestouff · Pull Request #7910 · feldera/feldera Conversation
|
| State | Time for each batch |
|---|---|
main |
about 205 ms |
| This PR | about 58 ms |
delta_pipeline benchmark in static-connectors (new in this PR): the Delta Lake input connector in snapshot mode, through the controller, into an input table of a circuit with 8 workers that counts the records. The table is local, with 300 files and 2 457 600 of the same wide rows (4.3 GB). Median of 3 interleaved runs on a machine with 18 cores.
num_parsers |
main |
This PR | Speed-up |
|---|---|---|---|
Default (4 on main, 16 in this PR) |
16.9 s, 145 000 records/s | 3.06 s, 804 000 records/s | 5.5x |
| 1 | 62.9 s, 39 000 records/s | 17.05 s, 144 000 records/s | 3.7x |
| 4 | 16.9 s, 146 000 records/s | 4.67 s, 526 000 records/s | 3.6x |
| 16 | 6.24 s, 394 000 records/s | 3.11 s, 791 000 records/s | 2.0x |
The circuit has no views and the table is on a local disk, so this shows the connector, not a full SQL pipeline on object storage.
| Change | Where |
|---|---|
Read struct fields by position. A layout pass puts the columns in the order of the SQL table and marks the batch with feldera.positional_fields. The mark is a random value of the process, so a batch from a file or a client that carries the key is read by name. A batch with an absent column is read by name as before. |
crates/adapterlib/src/utils/arrow_layout.rs, crates/feldera-types/src/serde_with_context/deserialize.rs, crates/static-connectors/src/deinput.rs |
| Erase the deserializer one time only. The context deserializer erased a deserializer that was already erased, which made one heap allocation for each visitor call. | crates/feldera-types/src/serde_with_context/deserialize.rs (as_erased) |
Read a Decimal128 column as 128-bit significands when its scale is the scale of the SQL column. Other decimals go through a string as before. |
crates/adapterlib/src/utils/arrow_layout.rs, crates/fxp/src/serde_impl.rs, crates/fxp/src/dbsp_impl.rs |
Normalize timestamp columns one time for each batch, and read them as integers. The new TimestampFormat::MicrosSinceEpochOrString also parses a string column with the format that these connectors used before, so a string column continues to load into a TIMESTAMP column. |
crates/adapterlib/src/utils/arrow_timestamps.rs, crates/sqllib/src/timestamp.rs |
Run the parsers on a dedicated CPU thread pool. All job queues together keep at most two jobs per CPU core in flight, so many connectors do not each hold num_parsers batches in memory. |
crates/adapterlib/src/utils/cpu_pool.rs, crates/adapterlib/src/utils/job_queue.rs |
Make num_parsers optional. When it is absent, the pipeline selects the value from the number of CPUs of its host, not the pipeline manager. The Iceberg file read concurrency keeps its default of 4. |
crates/feldera-types/src/transport/delta_table.rs, crates/feldera-types/src/transport/iceberg.rs |
as_erased contains one unsafe block with a SAFETY comment. It casts a deserializer to &mut dyn erased_serde::Deserializer after a typeid::of comparison shows that it has that type.
None.
feldera-types, feldera-fxp (with dbsp), feldera-adapterlib, feldera-iceberg, static-connectors (the two Kafka metadata tests skipped, they need a broker).connector_suites: the Delta Lake and format:: suites, and the Iceberg file-system suites (--features iceberg-tests-fs).crates/static-connectors/src/test/delta_tests.rs send a batch through the record deserializer: columns in a different order with nanosecond timestamps, an absent nullable column, and a string timestamp column.as_erased, deserialize_significand, decimal_significands, in_flight_limit_is_shared and delta_string_column_is_read_as_timestamp, I reverted the related change and saw each test fail.cargo bench -p static-connectors --bench delta_parse, with interleaved A/B rounds against the previous state.cargo bench -p static-connectors --bench delta_pipeline -- <table directory> 300 <num_parsers>, on this branch and on main with the same table.num_parsers of the Delta Lake and Iceberg input connectors is now nullable in the OpenAPI schema, and Option<u32> in feldera-types. Existing configurations that set a number continue to work. The default when it is absent changes from 4 to the number of CPU cores, at most 16.num_parsers, now makes the additional batches wait.feldera-fxp: Fixed<P, S> implements DeserializeWithContext for SqlSerdeConfig only. It was generic over the configuration type before.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
|
What I ran
Findings
QA test (crates/static-connectors/src/test/delta_tests.rs)+/// A batch with reversed columns, an extra column, and nanosecond timestamps, +/// read by position after `timestamps_to_micros` and `order_columns`, gives +/// back the records that were written. +#[test] +fn delta_positional_read_matches_written_records() { + use std::sync::Arc; + use arrow::array::{ArrayRef, Int64Array, RecordBatch}; + use arrow::compute::cast; + use arrow::datatypes::{DataType, Field as ArrowField, TimeUnit}; + use feldera_adapterlib::utils::arrow_layout::{has_positional_fields, order_columns}; + use feldera_adapterlib::utils::arrow_timestamps::timestamps_to_micros; + use feldera_types::transport::delta_table::DeltaVariantEncoding; + + let records: Vec<DeltaTestStruct> = (0..50).map(|i| delta_test_record(i * 2)).collect(); + let fields = DeltaTestStruct::schema(); + let arrow_schema = ArrowSchema::new(relation_to_arrow_fields(&fields, delta_schema_options())); + let batch = serde_arrow::to_record_batch(arrow_schema.fields(), + &SerializeWithContextWrapper::new(&records, &delta_output_serde_config(DeltaVariantEncoding::Variant))).unwrap(); + let mut new_fields = vec![ArrowField::new("extra", DataType::Int64, false)]; + let mut new_columns: Vec<ArrayRef> = vec![Arc::new(Int64Array::from(vec![7; records.len()]))]; + for (field, column) in batch.schema().fields().iter().zip(batch.columns()).rev() { + match field.data_type() { + DataType::Timestamp(_, tz) => { + let ns = DataType::Timestamp(TimeUnit::Nanosecond, tz.clone()); + new_fields.push(field.as_ref().clone().with_data_type(ns.clone())); + new_columns.push(cast(column, &ns).unwrap()); + } + _ => { new_fields.push(field.as_ref().clone()); new_columns.push(column.clone()); } + } + } + let batch = RecordBatch::try_new(Arc::new(ArrowSchema::new(new_fields)), new_columns).unwrap(); + let batch = timestamps_to_micros(batch).unwrap(); + let (batch, reason) = order_columns(batch, &fields); + assert_eq!(reason, None); + assert!(has_positional_fields(batch.schema_ref())); + let read: Vec<DeltaTestStruct> = Vec::deserialize_with_context( + serde_arrow::Deserializer::from_record_batch(&batch).unwrap(), + &delta_input_serde_config().with_positional_fields(true)).unwrap(); + assert_eq!(read, records); +} |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
090c35c to
7734c2b
Compare
There was an error while loading. Please reload this page.
|
What I ran (debug builds, at 24b9e84)
Missing, medium (SQL type coverage gate): the new positional and Missing, medium: Iceberg has no deserialization test for the new path ( QA tests (all pass)diff --git a/crates/adapterlib/src/utils/arrow_layout.rs b/crates/adapterlib/src/utils/arrow_layout.rs index 26fda65..da14dba 100644 --- a/crates/adapterlib/src/utils/arrow_layout.rs +++ b/crates/adapterlib/src/utils/arrow_layout.rs @@ -400,3 +400,37 @@ mod test { assert_eq!(ordered.column(1).data_type(), &DataType::Decimal128(10, 3)); } } + +#[cfg(test)] +mod qa_test { + use std::sync::Arc; + + use arrow::array::{Array, ArrayRef, AsArray, Decimal128Array, RecordBatch}; + use feldera_types::program_schema::{ColumnType, Field, SqlIdentifier}; + + use super::order_columns; + + /// A sliced batch, as DataFusion produces, keeps the slice offset. + #[test] + fn sliced_decimal_significands() { + let decimals: ArrayRef = Arc::new( + Decimal128Array::from(vec![Some(1), Some(2), None, Some(4)]) + .with_precision_and_scale(10, 2) + .unwrap(), + ); + let batch = RecordBatch::try_from_iter(vec![("d", decimals)]) + .unwrap() + .slice(1, 3); + let fields = [Field::new( + SqlIdentifier::new("d", false), + ColumnType::decimal(10, 2, true), + )]; + let (ordered, reason) = order_columns(batch, &fields); + assert_eq!(reason, None); + let d = ordered.column(0).as_fixed_size_binary(); + assert_eq!(d.len(), 3); + assert_eq!(d.value(0), 2_i128.to_ne_bytes()); + assert!(d.is_null(1)); + assert_eq!(d.value(2), 4_i128.to_ne_bytes()); + } +} diff --git a/crates/static-connectors/src/test/delta_tests.rs b/crates/static-connectors/src/test/delta_tests.rs index 77a05d0..3231ab4 100644 --- a/crates/static-connectors/src/test/delta_tests.rs +++ b/crates/static-connectors/src/test/delta_tests.rs @@ -264,3 +264,178 @@ fn delta_string_column_is_read_as_timestamp() { assert!(!positional); assert_eq!(read, expected); } + +#[derive(Debug, Default, PartialEq, Eq, Clone)] +struct QaInner { + a: bool, + b: Option<i64>, +} +feldera_types::deserialize_table_record!(QaInner["QaInner", feldera_sqllib::SqlVariant, 2] { + (a, "a", false, bool, |_| None), + (b, "b", false, Option<i64>, |_| Some(None)) +}); + +#[derive(Debug, Default, PartialEq, Eq, Clone)] +struct QaOuter { + id: i64, + inner: Option<QaInner>, + list: Vec<QaInner>, +} +feldera_types::deserialize_table_record!(QaOuter["QaOuter", feldera_sqllib::SqlVariant, 3] { + (id, "id", false, i64, |_| None), + (inner, "inner", false, Option<QaInner>, |_| Some(None)), + (list, "list", false, Vec<QaInner>, |_| None) +}); + +/// A nullable nested ROW with reversed fields, generated as the compiler does, +/// is read by position. +#[test] +fn qa_positional_nullable_nested_row() { + use arrow::array::{ArrayRef, BooleanArray, Int64Array, ListArray, RecordBatch, StructArray}; + use arrow::buffer::{NullBuffer, OffsetBuffer}; + use arrow::datatypes::{DataType, Field as AF, Fields}; + use feldera_adapterlib::utils::arrow_layout::{has_positional_fields, order_columns}; + use feldera_types::program_schema::{ColumnType, Field, SqlIdentifier}; + use std::sync::Arc; + + // Fields stored as (b, a). + let inner_fields = Fields::from(vec![ + AF::new("b", DataType::Int64, true), + AF::new("A", DataType::Boolean, false), + ]); + let inner_cols = |b: Vec<Option<i64>>, a: Vec<bool>| -> Vec<ArrayRef> { + vec![Arc::new(Int64Array::from(b)), Arc::new(BooleanArray::from(a))] + }; + let inner = StructArray::new( + inner_fields.clone(), + inner_cols(vec![Some(1), None, Some(3)], vec![true, false, true]), + Some(NullBuffer::from(vec![true, false, true])), + ); + let items = StructArray::new( + inner_fields.clone(), + inner_cols(vec![Some(7), None], vec![false, true]), + None, + ); + let list = ListArray::new( + Arc::new(AF::new("item", DataType::Struct(inner_fields), false)), + OffsetBuffer::from_lengths([2, 0, 0]), + Arc::new(items), + None, + ); + let batch = RecordBatch::try_from_iter(vec![ + ("list", Arc::new(list) as ArrayRef), + ("inner", Arc::new(inner) as ArrayRef), + ("id", Arc::new(Int64Array::from(vec![1, 2, 3])) as ArrayRef), + ]) + .unwrap(); + + let f = |n: &str, t| Field::new(SqlIdentifier::new(n, false), t); + let row = ColumnType::structure( + true, + &[f("a", ColumnType::boolean(false)), f("b", ColumnType::bigint(true))], + ); + let mut row_nn = row.clone(); + row_nn.nullable = false; + let fields = [ + f("id", ColumnType::bigint(false)), + f("inner", row), + f("list", ColumnType::array(false, row_nn)), + ]; + let (batch, reason) = order_columns(batch, &fields); + assert_eq!(reason, None); + assert!(has_positional_fields(batch.schema_ref())); + + let read: Vec<QaOuter> = Vec::deserialize_with_context( + serde_arrow::Deserializer::from_record_batch(&batch).unwrap(), + &delta_input_serde_config().with_positional_fields(true), + ) + .unwrap(); + assert_eq!( + read, + vec![ + QaOuter { + id: 1, + inner: Some(QaInner { a: true, b: Some(1) }), + list: vec![ + QaInner { a: false, b: Some(7) }, + QaInner { a: true, b: None } + ], + }, + QaOuter { id: 2, inner: None, list: vec![] }, + QaOuter { + id: 3, + inner: Some(QaInner { a: true, b: Some(3) }), + list: vec![], + }, + ] + ); +} + +#[derive(Debug, Default, PartialEq, Eq, Clone)] +struct QaDec { + arr: Vec<Option<feldera_sqllib::SqlDecimal<10, 2>>>, + map: BTreeMap<String, feldera_sqllib::SqlDecimal<10, 2>>, +} +feldera_types::deserialize_table_record!(QaDec["QaDec", feldera_sqllib::SqlVariant, 2] { + (arr, "arr", false, Vec<Option<feldera_sqllib::SqlDecimal<10, 2>>>, |_| None), + (map, "map", false, BTreeMap<String, feldera_sqllib::SqlDecimal<10, 2>>, |_| None) +}); + +#[test] +fn qa_positional_nested_decimals() { + use arrow::array::{ + ArrayRef, Decimal128Array, ListArray, MapArray, RecordBatch, StringArray, StructArray, + }; + use arrow::buffer::OffsetBuffer; + use arrow::datatypes::{DataType, Field as AF, Fields}; + use feldera_adapterlib::utils::arrow_layout::order_columns; + use feldera_types::program_schema::{ColumnType, Field, SqlIdentifier}; + use std::sync::Arc; + + let dec = |v: Vec<Option<i128>>| { + Arc::new(Decimal128Array::from(v).with_precision_and_scale(10, 2).unwrap()) as ArrayRef + }; + let arr = ListArray::new( + Arc::new(AF::new("item", DataType::Decimal128(10, 2), true)), + OffsetBuffer::from_lengths([2]), + dec(vec![Some(12345), None]), + None, + ); + let entries = StructArray::new( + Fields::from(vec![ + AF::new("key", DataType::Utf8, false), + AF::new("value", DataType::Decimal128(10, 2), false), + ]), + vec![Arc::new(StringArray::from(vec!["k"])) as ArrayRef, dec(vec![Some(-5)])], + None, + ); + let map = MapArray::new( + Arc::new(AF::new("entries", arrow::array::Array::data_type(&entries).clone(), false)), + OffsetBuffer::from_lengths([1]), + entries, + None, + false, + ); + let batch = RecordBatch::try_from_iter(vec![ + ("map", Arc::new(map) as ArrayRef), + ("arr", Arc::new(arr) as ArrayRef), + ]) + .unwrap(); + let f = |n: &str, t| Field::new(SqlIdentifier::new(n, false), t); + let fields = [ + f("arr", ColumnType::array(false, ColumnType::decimal(10, 2, true))), + f( + "map", + ColumnType::map(false, ColumnType::varchar(false), ColumnType::decimal(10, 2, false)), + ), + ]; + let (batch, reason) = order_columns(batch, &fields); + assert_eq!(reason, None); + let read: Vec<QaDec> = Vec::deserialize_with_context( + serde_arrow::Deserializer::from_record_batch(&batch).unwrap(), + &delta_input_serde_config().with_positional_fields(true), + ) + .unwrap(); + assert_eq!(read[0].arr, vec![Some("123.45".parse().unwrap()), None]); + assert_eq!(read[0].map.get("k"), Some(&"-0.05".parse().unwrap())); +} |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
… inputs. The Delta Lake and Iceberg input connectors spent most of their CPU time in the conversion of Arrow record batches to SQL records. On a benchmark with wide rows (about 100 fields), one batch of 8192 rows took about 205 ms to parse. It now takes about 58 ms. Changes: - Read struct fields by position. A layout pass puts the columns of a batch in the order of the SQL table and marks the batch with `feldera.positional_fields`. The deserializer then reads a sequence and does not compare field names. A batch with an absent column is read by name as before. - Erase the deserializer one time only. The context deserializer erased a deserializer that was already erased, which made one heap allocation for each visitor call. - Read a `Decimal128` column as 128-bit significands when its scale is the scale of the SQL column. Other decimals go through a string as before. - Normalize timestamp columns one time for each batch. - Run the parsers on a dedicated CPU thread pool, so that they do not occupy the Tokio worker threads. - Make `num_parsers` optional. When it is absent, the pipeline selects the value from the number of CPUs of its host, not the pipeline manager. The Iceberg file read concurrency keeps its default of 4. Also add the `delta_parse` benchmark in `static-connectors`. Signed-off-by: Xavier Bestel <[email protected]>
Signed-off-by: feldera-bot <[email protected]>
… in flight. Changes after review of the faster Arrow parsing: - A string column loads into a SQL TIMESTAMP column again. The new `TimestampFormat::MicrosSinceEpochOrString` reads an integer as microseconds and parses a string with the format that the Delta Lake and Iceberg inputs used before. The startup check accepts string columns and continues to reject integer columns. - All job queues together keep at most two jobs per CPU core in flight. The default `num_parsers` is now up to 16 for each connector, so without this limit a pipeline with many connectors held more batches in memory than before. - The Iceberg input panics with a clear message if a batch is read before the SQL columns are set. Before, it read a batch with no columns, and each record failed. - Add tests that send a batch through the record deserializer: columns in a different order with nanosecond timestamps, an absent nullable column, and a string timestamp column. - Add the changelog entry. Signed-off-by: Xavier Bestel <[email protected]>
…ble. `delta_pipeline` writes a local Delta table with the wide CDC record of the `delta_parse` benchmark. It then runs the Delta Lake input connector in `snapshot` mode, through the controller, into an input table of a circuit that counts the records, and prints the rate. Unlike `delta_parse`, it covers the Parquet read, DataFusion, the job queue and the parser pool. Arguments: the table directory, the number of batches of 8192 rows in a new table, and `num_parsers`. Signed-off-by: Xavier Bestel <[email protected]>
`ArrowZSetStream` and `ArrowMapStream` read a batch by position when its schema metadata has `feldera.positional_fields`. They serve all Arrow inputs, and schema metadata also comes from a file or from a client. A batch that only claimed to be in order was read into the wrong columns. The mark is now a random value of the process, which only `order_columns` writes, so a batch from outside is read by name. Also add tests for the positional and significand paths: a nullable nested ROW and a ROW in an ARRAY with records as the SQL compiler generates them, a DECIMAL in an ARRAY and in a MAP, a sliced batch, and the Iceberg configuration with a binary UUID, a nanosecond timestamp and a decimal. Signed-off-by: Xavier Bestel <[email protected]>
45e66d8 to
5e43a58
Compare
At least 1 approving review is required to merge this pull request.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.