Phase 1: Align the TPC-DS generation path with the TPC-H path - #400
Phase 1: Align the TPC-DS generation path with the TPC-H path#400qbacpey wants to merge 17 commits into
Conversation
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
- Updated `init_benchmark_tables` to accept a DuckDB connection parameter, allowing for more flexible database interactions. - Introduced `get_select_query` and `get_column_projection` functions to handle SQL queries and column type conversions, improving data handling in the generation process. - Modified `generate_data_files_with_duckdb` to utilize the new connection parameter and updated logic for writing metadata and table partitions. - Enhanced tests to validate the new functionality and ensure compatibility with both TPCH and TPCDS benchmarks.
| import pyarrow.parquet as pq | ||
| from duckdb_utils import get_select_query, init_benchmark_tables | ||
|
|
||
| _ROW_GROUP_GRANULARITY = 2048 # DuckDB rounds ROW_GROUP_SIZE to its vector size |
There was a problem hiding this comment.
Need statistic from TPC-DS 1K & 3K generation process to adjust these number
paul-aiyedun
left a comment
There was a problem hiding this comment.
Changes overall look good to me. However, I had a few questions and code cleanup comments.
| def test_generated_files_use_v2_page_format(setup_and_teardown): | ||
| """Verify every generated Parquet file is written with the v2 format.""" | ||
| @pytest.mark.parametrize( | ||
| "benchmark_type,use_duckdb", |
There was a problem hiding this comment.
I don't think the use_duckdb parameter is needed here. We can have the test execution be based on the default value. Also, can we apply the parameterization globally at the setup_and_teardown level, so that all tests cover TPC-DS?
Same comment applies to the row_group_size_test.py update.
| from generate_data_files import generate_data_files | ||
|
|
||
|
|
||
| def test_max_rows_per_file_splits_tables_tpcds(setup_and_teardown): |
There was a problem hiding this comment.
Please update the test case to cover both TPC-H and TPC-DS.
| charset-normalizer==3.4.3 | ||
| click==8.2.1 | ||
| duckdb==1.3.2 | ||
| # DuckDB 1.5.5 supports Parquet V2; parallel dsdgen is not yet in a stable release. |
There was a problem hiding this comment.
Nit: Remove comment about parallel dsdgen or add a TODO for this.
|
|
||
| def get_column_projection(column_metadata): | ||
| col_name, col_type, *_ = column_metadata | ||
| if is_decimal_column(col_type): |
There was a problem hiding this comment.
Why do we always do a conversion here?
There was a problem hiding this comment.
The conversion is not unconditional. get_column_projection() is only called inside the convert_decimals_to_floats branch of get_select_query(). Without -c, get_select_query() returns SELECT *, so no casts are generated.
There was a problem hiding this comment.
Renamed to get_column_projection_with_decimals_as_double
| with open(f"{args.data_dir_path}/metadata.json", "w") as file: | ||
| json.dump({"scale_factor": args.scale_factor}, file, indent=2) | ||
| file.write("\n") | ||
| write_metadata(args) |
There was a problem hiding this comment.
Can we write the metadata on a shared path (i.e. outside of generate_data_files_with_duckdb or generate_data_files_with_duckdb)?
| for part in range(num_partitions): | ||
| # Avoid a redundant LIMIT/OFFSET for a single part. | ||
| partition_query = ( | ||
| f"{select_query} LIMIT {max_rows_per_file} OFFSET {part * max_rows_per_file}" |
There was a problem hiding this comment.
Can you try comparing the performance with an implementation that runs this in parallel and does WHERE rowid >= {part * max_rows_per_file} AND rowid < {max_rows_per_file}?
There was a problem hiding this comment.
Observed performance scaling roughly proportional to the number of threads. Also set the concurrency to max_workers = min(num_threads, num_partitions) to avoid excessive memory growth and I/O contention.
There was a problem hiding this comment.
rowid query is consistently about 3× faster than LIMIT / OFFSET query across 1/4/8 outer writers
| Table | Outer Writers | LIMIT / OFFSET Median Copy Time |
rowid Median Copy Time |
|---|---|---|---|
inventory |
1 | 7.46s | 2.42s |
inventory |
4 | 2.07s | 0.70s |
inventory |
8 | 1.36s | 0.44s |
store_sales |
1 | 17.89s | 6.20s |
store_sales |
4 | 7.00s | 2.33s |
store_sales |
8 | 3.86s | 1.29s |
|
|
||
|
|
||
| def _write_probe(conn, query, path, row_group_rows=None): | ||
| options = "FORMAT parquet, PARQUET_VERSION 'V2'" |
There was a problem hiding this comment.
Can we move this to a function that is shared with _write_table_partitions?
| # A second write cannot fill the estimated row group or improve the result. | ||
| return rows | ||
|
|
||
| # Second pass: measure one full row group near the requested size. |
There was a problem hiding this comment.
Why is this second pass needed?
There was a problem hiding this comment.
Encoded bytes/row changes with row-group size, so the first pass only gives a rough estimate based on DuckDB’s default-sized groups. The second pass uses that estimate to write one complete group near the requested size and measures bytes/row again there.
|
|
||
| _ROW_GROUP_GRANULARITY = 2048 # DuckDB rounds ROW_GROUP_SIZE to its vector size | ||
| _MAX_PROBE_SCALE_FACTOR = 10 | ||
| _STAGE1_ROWS = 200_000 |
There was a problem hiding this comment.
How was this number derived?
There was a problem hiding this comment.
200,000 was originally chosen to ensure DuckDB wrote at least one complete row group. I will change it to 122,880 (DuckDB’s default row-group size).
_MAX_PROBE_SCALE_FACTOR = 10 is for MEM budget, plan to adjust this when able to run SF1K &3K generation.
There was a problem hiding this comment.
About the 1.2x: Use 1x can produce the same level of accuracy, so I removed it.
| TEST_NON_DEFAULT_COMPRESSION_PATH = TESTS_DIR / "test_codec_definitions_non_default_compression.json" | ||
| TEST_INVALID_COMPRESSION_PATH = TESTS_DIR / "test_codec_definitions_invalid_compression.json" | ||
|
|
||
| pytestmark = pytest.mark.parametrize("setup_and_teardown", ["tpch"], indirect=True) |
There was a problem hiding this comment.
Makes every test in codec_definitions_test.py run only with TPC-H (currently codec definitions are supported only for TPC-H)
- Updated `get_select_query` to use `get_column_projection_with_decimals_as_double` for improved handling of decimal columns. - Added installation command for DuckDB in `generate_data_files_with_duckdb` to prevent concurrent installations. - Simplified row group sizing logic in `row_group_sizing.py` for better performance and clarity.
| _MAX_PROBE_SCALE_FACTOR = 10 | ||
| _PROBE_MEMORY_LIMIT = "8GB" |
There was a problem hiding this comment.
Probe MEM budget, perhaps a better value? Or remove it?
- Revised the docstring of `_rows_for_target` to specify that it rounds to the nearest 2,048-row multiple instead of allowing DuckDB to round up, enhancing clarity for future developers.
| def _rows_for_target(bytes_per_row, target_bytes): | ||
| """Round to the nearest 2,048-row multiple instead of letting DuckDB round up.""" | ||
| if not bytes_per_row: | ||
| return None | ||
| rows = round(target_bytes / bytes_per_row / _ROW_GROUP_GRANULARITY) | ||
| return max(rows, 1) * _ROW_GROUP_GRANULARITY |
There was a problem hiding this comment.
This matters mainly for small row-group targets. DuckDB rounds ROW_GROUP_SIZE up to a 2,048-row multiple, and one step can be a large percentage when a group contains only a few thousand rows. Rounding to the nearest multiple reduced the SF1/1 MiB errors from −10% to +0.5% for web_sales and from −29% to +4.3% for item.
…rmat - Deleted AGENTS.md as it was no longer needed. - Updated `generate_data_files.py` to use Parquet version 2 for file generation. - Modified tests to verify that TPC-H files use the v2 page format and TPC-DS files use the v1 page format. - Adjusted row group size tests for improved accuracy and clarity.
…nality - Renamed test function to reflect expected page format verification for benchmarks. - Consolidated assertions to dynamically check for expected Parquet version based on benchmark type. - Removed outdated tests for TPC-DS and DuckDB copy functionality to streamline test suite.
|
|
||
| def init_benchmark_tables(benchmark_type, scale_factor): | ||
| tables = duckdb.sql("SHOW TABLES").fetchall() | ||
| def init_benchmark_tables(benchmark_type, scale_factor, conn=duckdb): |
There was a problem hiding this comment.
Ensure we reuse the same connection as generation duckdb
- Renamed parameter in `configure_duckdb_export` from `num_tasks` to `num_threads` for clarity. - Updated `export_tables` to use `args.num_threads` directly for DuckDB configuration. - Adjusted thread pool size in `ThreadPoolExecutor` to match the number of tasks, improving concurrency handling. - Enhanced help message in argument parser for better understanding of thread usage.
- Added validation for the `--memory-limit` argument to ensure it is only used with TPC-DS generation and is a positive value. - Implemented default memory limit calculation based on available system RAM when `--memory-limit` is not specified for TPC-DS and DuckDB usage. - Updated argument parser to reflect changes in memory limit handling and removed the deprecated `--duckdb-memory-limit` parameter. - Adjusted DuckDB connection configuration to utilize the new memory limit format.
- Added specific issue links to TODO comments in `generate_data_files.py`, `requirements.txt`, and `parquet_file_metadata_test.py` for improved traceability of tasks related to memory limit handling, DuckDB updates, and Parquet file compatibility.
| write_part(task, conn) | ||
| return | ||
|
|
||
| with ThreadPoolExecutor(max_workers=num_tasks) as executor: |
There was a problem hiding this comment.
Should we limit export concurrency here by args.num_threads? IIUC, every output file gets its own worker thread so in the case of many writers, it might make sense to limit number of worker threads to something like min(args.num_threads, num_tasks)
There was a problem hiding this comment.
Export concurrency (writers) and args.num_threads control different layers of parallelism:
args.num_threadsconfigures DuckDB's thread pool capacity.writerscontrols how manyCOPYstatements can be in flight concurrently.
So coupling them might not be so desirable. But it do make sense to add a cap for the writer (max_workers = min(num_tasks, available_cpus)).
For example, the following SF1000 results show COPY time in seconds (the machine has 128 physical cores):
| Maximum rows per file | Files (N) |
args.num_threads |
writers=N |
writers=N/2 |
writers=256 |
writers=128 |
writers=32 |
|---|---|---|---|---|---|---|---|
| 500M | 33 | 128 | 178 | 175 | — | — | 171 |
| 500M | 33 | 256 | 200 | 186 | — | — | 175 |
| 100M (default) | 83 | 128 | 167 | 174 | — | — | 173 |
| 100M | 83 | 256 | 196 | 187 | — | — | 200 |
| 20M | 337 | 128 | 201 | 201 | 191 | 187 | 192 |
| 20M | 337 | 256 | 249 | 255 | 262 | 273 | 270 |
| 5M | 1,288 | 128 | 256 | 237 | 229 | 215 | 277 |
| 5M | 1,288 | 256 | 318 | 321 | 293 | 275 | 330 |
When the number of output files is small or moderate, limiting writers to between 32 and 256 has little effect. Writer capping becomes more useful when the file count is very large: with 1,288 files, 128 writers outperformed both writers=N and 32 writers.
Added in 06da867
…. Updated `export_tables` to limit `ThreadPoolExecutor` workers to the lesser of specified tasks and available CPUs, enhancing performance and resource management during Parquet file exports.
TL;DR
This PR add these features for DuckDB TPC-DS datasets generations:
--approx-row-group-bytesis now supported with zero overhead.--max-rows-per-filesplit into<table>-<part>.parquet, matching the tpchgen layout.metadata.jsonrecordsapprox_row_group_bytesalongsidescale_factor.Generation overhead
Measured end to end on machine with 2 x AMD EPYC 9555 64-Core, 2.2TiB HMEM.
Overhead
"Probe wait" is the time the main thread blocked on the probe after materialization finished, i.e. the only part of it that is not hidden:
At SF1 the probe costs 0.9s because it generates the same scale factor as the target and so cannot finish first.
Accuracy
Probing yields complete row groups slightly under the requested size, because concurrent writers flush early. At a SF3000 128 MiB target the median is about 90% (~120 MiB): half of the complete groups are at or below that.
Query overhead comparsion
Measured end to end on machine with 8 x RTX PRO 6000 Blackwell
The differences come from four sources:
1. Row group size
--approx-row-group-bytesand targets 128 MiB per row group instead (median 90 MiB compressed).2. Partitioning
--max-rows-per-file, producing 83 files at SF1000.3. Data types
intcolumn asINT32.INT64.4. Encoding
RLE_DICTIONARYupstream are now written asPLAIN_DICTIONARY.Tests
All 15 tests pass with DuckDB 1.6dev.
The CI row-group test uses a 1 MiB target so that SF1 tables contain enough row groups to validate sizing.