Add distributed CTAS output for Presto benchmarks - #373
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. |
| if query_numbers is not None: | ||
| result_files = sorted(f for q in query_numbers for f in [results_dir / f"q{q}.parquet"] if f.exists()) | ||
| result_files = [] | ||
| for q_num in query_numbers: |
There was a problem hiding this comment.
Multiple workers can each write their own Parquet part file, so we now have the results as a directory.
There was a problem hiding this comment.
Can we instead normalize the Parquet files and not have the validation logic be concerned with the details of how the result set is generated?
| aggregate, etc.). | ||
| """ | ||
| expr = sqlglot.parse_one(query_sql) | ||
| def _get_orderby_sort_spec( |
There was a problem hiding this comment.
We were able to skip null handling when we had a single parquet file, but now since we are merging the files back based on order-by info, we need to know where nulls should go.
| prior_changed = prior_changed | value_changed | ||
|
|
||
|
|
||
| def _restore_orderby( |
There was a problem hiding this comment.
To merge multiple parquet files we now need to know the hierarchy of the order by columns from the original sql query.
68a0655 to
97aa11e
Compare
|
@devavret I ran some performance benchmarks using these changes to make sure I saw the same performance increase you detailed in the issue on Q11. I saw about a %25 reduction in time on this query, although this was running a 4-gpu tpch-1k run with an older image; so I'm not sure how much that differs from your original observations. |
| --profile-script-path Path to a custom profiler functions script. Defaults to ./profiler_functions.sh. | ||
| --skip-drop-cache Skip dropping system caches before each benchmark query (dropped by default). | ||
| --skip-analyze-check Skip checking that ANALYZE TABLE has been run on all tables (checked by default). | ||
| --write-results-to-file Write query results with distributed Hive CTAS operations instead of returning them |
There was a problem hiding this comment.
Do we plan on running/publishing benchmarks for the CTAS queries?
There was a problem hiding this comment.
That would be a question for @devavret . I believe we are allowed to, but I don't know if we have explicitly declared intentions.
There was a problem hiding this comment.
Yes, that's the primary use case. The reason we want this CTAS option is to speed up two queries.
@misiugodfrey If you see Q11 going from 50s to 3s on sf30k 8 nodes then this PR is sufficient. |
patdevinwilson
left a comment
There was a problem hiding this comment.
Solid feature overall — separate writable hive_output catalog + reconstructing ORDER BY for unordered Parquet datasets is the right shape. Blocking items: style CI (ruff format) is red, and I think implicit NULLS placement for DESC is wrong in _restore_orderby. Details inline.
|
|
||
| sort_col_indices.append(resolved_idx) | ||
| ascending.append(not is_desc) | ||
| nulls_first.append(bool(ordered.args.get("nulls_first"))) |
There was a problem hiding this comment.
bool(ordered.args.get("nulls_first")) treats unspecified NULLS as False (NULLS LAST). Presto's default is NULLS LAST for ASC but NULLS FIRST for DESC. So for ORDER BY score DESC (no explicit NULLS clause), restore will put nulls last while the engine put them first, and the subsequent _validate_orderby / LIMIT tie handling can disagree with expected.
Suggest something like:
nulls_first_arg = ordered.args.get("nulls_first")
if nulls_first_arg is None:
nulls_first.append(bool(is_desc)) # Presto default
else:
nulls_first.append(bool(nulls_first_arg))And add a test for bare ORDER BY x DESC with nulls.
There was a problem hiding this comment.
Are you sure Presto changes NULLS FIRST/LAST based on the order? I was under the impression it was always last unless explicitly changed and as far as I can tell the documentation supports this: https://prestodb.github.io/docs/current/sql/select.html#order-by-clause:~:text=The%20default%20null%20ordering%20is%20NULLS%20LAST%2C%20regardless%20of%20the%20ordering%20direction.
If I'm missing something (or looking at the wrong docs), please link me to the appropriate documentation and I will change it.
There was a problem hiding this comment.
You're right — I was wrong here. Presto's docs are clear:
The default null ordering is NULLS LAST, regardless of the ordering direction.
(https://prestodb.github.io/docs/current/sql/select.html#order-by-clause)
I was mixing that up with other engines (e.g. Postgres / Spark defaults that do flip with ASC/DESC). For Presto, bool(ordered.args.get("nulls_first")) → False when unspecified correctly matches NULLS LAST. Thanks for the correction.
| @@ -321,6 +354,55 @@ echo "Using PRESTO_IMAGE_TAG: $PRESTO_IMAGE_TAG" | |||
| BENCHMARK_TEST_DIR=${TEST_DIR}/performance_benchmarks | |||
| pytest -q -s ${BENCHMARK_TEST_DIR}/${BENCHMARK_TYPE}_test.py ${PYTEST_ARGS[*]} | |||
There was a problem hiding this comment.
set -e is on (line 6). If pytest fails, we never reach normalize_ctas_result_permissions or validation — and for CTAS that also means permission fixup / result posting from Slurm may see unreadable files.
Same pattern as #382 would help:
pytest_exit=0
pytest ... || pytest_exit=$?
# ... normalize + validate ...
exit ${pytest_exit}| # Native workers otherwise stage writes under their container-private /tmp, | ||
| # which prevents the coordinator from committing the files. Write directly to | ||
| # the shared result mount; interrupted runs are cleaned before the next run. | ||
| hive.temporary-staging-directory-enabled=false |
There was a problem hiding this comment.
Good call disabling temporary staging so native workers write straight to the shared mount. Worth confirming (maybe already did) that coordinator commit still works for multi-worker CTAS with this setting — if an interrupted run leaves partial qN/ dirs, the fixture's shutil.rmtree + DROP TABLE path looks covered.
| - ./.hive_metastore:/var/lib/presto/data/hive/metastore | ||
| - ../../common/testing/integration_tests/data:/var/lib/presto/data/hive/data/integration_test | ||
| - ${PRESTO_DATA_DIR:-/dev/null}:/var/lib/presto/data/hive/data/user_data | ||
| - ${PRESTO_DATA_DIR:-/dev/null}:/var/lib/presto/data/hive/data/user_data:ro |
There was a problem hiding this comment.
Making PRESTO_DATA_DIR :ro is a nice hardening win for the CTAS split. Just flagging: any existing workflow that still wrote into user_data (not via hive_output) will now fail — hopefully none remain.
There was a problem hiding this comment.
I don't think any of our existing workflows should be writing to user_data. Our data-gen workflow acts outside of this container approach, so we should be good.
If anything was writing to the data like this, I think it would be considered a bug.
There was a problem hiding this comment.
Makes sense — thanks for confirming. If something was writing through this mount, treating that as a bug (and failing loudly under :ro) is the right call. No change needed from me.
| parquet_path = results_dir / f"{query_id.lower()}.parquet" | ||
| df.to_parquet(parquet_path, index=False) | ||
|
|
||
| result.append(cursor.stats["elapsedTimeMillis"]) |
There was a problem hiding this comment.
Timing is now recorded after fetchall() for CTAS (good — waits for commit). For non-CTAS, elapsedTimeMillis is also after fetch, matching prior behavior for iteration 0 and changing later iterations (previously timed before fetch on iter>0). Intentional? If the goal is "time until results ready", this is better; if comparing to historical non-CTAS numbers for iter>0, they may shift slightly.
There was a problem hiding this comment.
I'll preserve the original behaviour when we are not using CTAS.
| actual, | ||
| expected, | ||
| query_sql, | ||
| actual_order_preserved=result_file.is_file(), |
There was a problem hiding this comment.
actual_order_preserved=result_file.is_file() is a clean way to distinguish single-file vs distributed dataset dirs. For an empty CTAS table that only has .prestoSchema (your q15 test), a brief comment near the read_parquet call about directory/empty-schema behavior would help future readers.
Also: CI is failing ruff format on this file and performance_benchmark_fixtures_test.py — a format pass should unblock merge.
paul-aiyedun
left a comment
There was a problem hiding this comment.
I think adding a CTAS execution mode in order to understand exchange overhead makes sense. However, I think there is some interface divergence in the current implementation.
| Set to the full host path of the expected results directory | ||
| (e.g. PRESTO_EXPECTED_RESULTS_DIR=/data/sf100_expected). | ||
| Warning (not error) if set but the directory does not exist. | ||
| PRESTO_OUTPUT_DIR Writable host directory used for distributed CTAS result datasets. This must be set |
There was a problem hiding this comment.
We already write outputs (including result sets) to --output-dir. I think adding another PRESTO_OUTPUT_DIR variable here can be confusing.
There was a problem hiding this comment.
I can rename this to something separate PRESTO_CTAS_SCRATCH_DIR, but the problem is this directory needs to be mounted in the container when it starts - so using --output-dir isn't viable, since that variable isn't specified by the user until the containers are already running (at least in the docker path).
| --profile-script-path Path to a custom profiler functions script. Defaults to ./profiler_functions.sh. | ||
| --skip-drop-cache Skip dropping system caches before each benchmark query (dropped by default). | ||
| --skip-analyze-check Skip checking that ANALYZE TABLE has been run on all tables (checked by default). | ||
| --write-results-to-file Write query results with distributed Hive CTAS operations instead of returning them |
There was a problem hiding this comment.
Can we use a more direct parameter name e.g. --run-as-ctas-queries?
| exit 1 | ||
| fi | ||
|
|
||
| if [[ "${WRITE_RESULTS_TO_FILE}" == "true" ]]; then |
There was a problem hiding this comment.
Similar to the above comment, we currently write to --output-dir, so why do we need special handling here and in normalize_ctas_result_permissions?
| if query_numbers is not None: | ||
| result_files = sorted(f for q in query_numbers for f in [results_dir / f"q{q}.parquet"] if f.exists()) | ||
| result_files = [] | ||
| for q_num in query_numbers: |
There was a problem hiding this comment.
Can we instead normalize the Parquet files and not have the validation logic be concerned with the details of how the result set is generated?
| aggregate, etc.). | ||
| """ | ||
| expr = sqlglot.parse_one(query_sql) | ||
| def _get_orderby_sort_spec( |
There was a problem hiding this comment.
I don't think it is common to have tests for test fixtures?
There was a problem hiding this comment.
These tests aren't really "testing the test", so much as testing the aliasing, naming, and schema logic that happen to be used in that fixture. I have found it useful to catch regressions with these - but perhaps we should organize them differently?
|
|
||
|
|
||
| def ctas_schema_name(tag): | ||
| if tag and not re.fullmatch(r"[A-Za-z0-9_]+", tag): |
There was a problem hiding this comment.
What is the difference between the regular benchmark tag and this tag?
There was a problem hiding this comment.
It's the same tag; it's just used here for separating potentially concurrent runs. Probably not a major concern, so I can remove it for simplicity.
| return query.rstrip().removesuffix(";").rstrip() | ||
|
|
||
|
|
||
| def ctas_table_name(query_id, iteration_num): |
There was a problem hiding this comment.
Are we creating a new table for each iteration?
There was a problem hiding this comment.
Yes. The first table is retained for normalization and validation and every other iteration creates and then drops a table.
| return query_id.lower() if iteration_num == 0 else f"{query_id.lower()}_iteration_{iteration_num + 1}" | ||
|
|
||
|
|
||
| def alias_ctas_output_expressions(query): |
There was a problem hiding this comment.
Can you please expand on the reason for this? I would have thought that CTAS query construction would be more straightforward i.e. CREATE TABLE ... AS {original SELECT query}.
There was a problem hiding this comment.
That was the original approach, but some of the queries contained result sets with unnamed expressions (legal) but can't be expressed in CTAS because it needs unique column names.
For instance, TPCH Q18 has:
SELECT
c_name,
c_custkey,
o_orderkey,
o_orderdate,
o_totalprice,
sum(l_quantity)
And that sum(l_quantity) needs an explicit column name. This function is handling that.
There was a problem hiding this comment.
Got it. Do we care about the specific column names? If not, I think we can simplify this logic by changing the CREATE TABLE clause i.e. CREATE TABLE {table name} (c1, c2, c3, ...) AS {original SELECT query}. This also reduces the chances of unexpected changes to the SELECT query.
There was a problem hiding this comment.
How do the settings here interact with etc_worker/catalog/hive.properties and etc_coordinator/catalog/hive.properties?
There was a problem hiding this comment.
It's a separate catalog and should not interact with them at all beyond inheriting some shared settings.
|
I've updated the PR to have the CTAS results stored in a scratch location and then normalize them to a single parquet file (if multiple files were created). This should keep the rest of the verification unchanged, and adds a separate "result normalization" step that should run rarely (we should not often create multiple result files). |
Make get_orderby_sort_spec and restore_orderby public so they can be imported without crossing a private-symbol boundary. Remove redundant alphabetical sort in validate_results.py (numeric sort on iteration is the meaningful one). Add clarifying comment on scratch_table cleanup invariant in benchmark_query. Fix --run-as-ctas-queries alignment in run_benchmark.sh usage text.
…x-testing into misiug/writeToFile
Summary
--run-as-ctas-queriesmode that executes benchmark queries as Hive CTAS operations, avoiding result-set transfer through the coordinator during measured executionPRESTO_CTAS_SCRATCH_DIRfor temporary distributed output while keeping source data read-only--output-dir[/tag]/query_results/qN.parquetlayout produced by the default benchmark pathORDER BYclauses before validationCREATE TABLE ... (c1, c2, ...) AS <original SELECT>) without rewriting explicit SELECT expressions; wildcard projections retain their original output namesOutput layout
Both execution modes now produce:
In CTAS mode, workers first write temporary
qN/datasets beneathPRESTO_CTAS_SCRATCH_DIR. Normalization happens after the measured query execution and before validation.Verification
Current revision
35 passedqN.parquetbash -non changed benchmark and Slurm shell scriptsrun_benchmark.sh --helpexposes the renamed flag and scratch variablegit diff --checkqN/dataset is normalized into onequery_results/qN.parquetEarlier CTAS revision