Skip to content

Add distributed CTAS output for Presto benchmarks - #373

Open
misiugodfrey wants to merge 14 commits into
mainfrom
misiug/writeToFile
Open

Add distributed CTAS output for Presto benchmarks#373
misiugodfrey wants to merge 14 commits into
mainfrom
misiug/writeToFile

Conversation

@misiugodfrey

@misiugodfrey misiugodfrey commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add an opt-in --run-as-ctas-queries mode that executes benchmark queries as Hive CTAS operations, avoiding result-set transfer through the coordinator during measured execution
  • use a worker-visible PRESTO_CTAS_SCRATCH_DIR for temporary distributed output while keeping source data read-only
  • after timing and profiling, normalize each retained CTAS result into the same --output-dir[/tag]/query_results/qN.parquet layout produced by the default benchmark path
  • merge multi-part Parquet output and reconstruct resolvable SQL ORDER BY clauses before validation
  • keep validation and result posting independent of whether a query ran normally or as CTAS
  • use native CTAS column aliases (CREATE TABLE ... (c1, c2, ...) AS <original SELECT>) without rewriting explicit SELECT expressions; wildcard projections retain their original output names
  • retain the first iteration as the query result and remove later-iteration scratch tables
  • update Docker and Slurm mounts, configuration examples, help text, documentation, and focused tests

Output layout

Both execution modes now produce:

<output-dir>[/<tag>]/
└── query_results/
    ├── q1.parquet
    ├── q2.parquet
    └── ...

In CTAS mode, workers first write temporary qN/ datasets beneath PRESTO_CTAS_SCRATCH_DIR. Normalization happens after the measured query execution and before validation.

Verification

Current revision

  • Focused comparison, validation, normalization, and CTAS fixture tests: 35 passed
  • Multi-part normalization test produces one ordered qN.parquet
  • Single-part and empty-result normalization tests
  • Native CTAS alias-list tests, including TPC-H Q18
  • Parsed all 22 built-in TPC-H and 99 built-in TPC-DS queries
  • Confirmed all ten TPC-DS wildcard fallbacks expand to uniquely named columns using the bundled test schemas
  • Ruff on changed Python modules and tests
  • bash -n on changed benchmark and Slurm shell scripts
  • run_benchmark.sh --help exposes the renamed flag and scratch variable
  • git diff --check
  • Live Docker smoke using the final normalization and native CTAS alias-list implementation
  • Live multi-worker run confirming a multi-part qN/ dataset is normalized into one query_results/qN.parquet
  • Live TPC-DS CTAS run
  • Repeat the Slurm smoke with the final interface and normalization flow

Earlier CTAS revision

  • Docker native-CPU TPC-H Q1 smoke with two CTAS iterations and separate read-only source/writable result mounts
  • Live TPC-H Q18 CTAS run
  • Docker multi-worker CTAS run producing multiple Parquet parts
  • Live empty-result CTAS query
  • Docker native-GPU worker path
  • Slurm deployment path
  • Full TPC-H query suite

@copy-pr-bot

copy-pr-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

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.

Comment thread common/testing/validate_results.py Outdated
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:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple workers can each write their own Parquet part file, so we now have the results as a directory.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread common/testing/result_comparison.py Outdated
aggregate, etc.).
"""
expr = sqlglot.parse_one(query_sql)
def _get_orderby_sort_spec(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this comment also applies here.

Comment thread common/testing/result_comparison.py Outdated
prior_changed = prior_changed | value_changed


def _restore_orderby(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To merge multiple parquet files we now need to know the hierarchy of the order by columns from the original sql query.

@misiugodfrey
misiugodfrey marked this pull request as ready for review July 21, 2026 15:14
@misiugodfrey
misiugodfrey requested a review from a team as a code owner July 21, 2026 15:14
@misiugodfrey

misiugodfrey commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@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.
From the issue, it looks like we should be expecting much more of a gain, although I don't know exactly what context the POC was in, so let me know if further changes are needed to realize the full gain.

Comment thread presto/scripts/run_benchmark.sh Outdated
--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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we plan on running/publishing benchmarks for the CTAS queries?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be a question for @devavret . I believe we are allowed to, but I don't know if we have explicitly declared intentions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's the primary use case. The reason we want this CTAS option is to speed up two queries.

@devavret

Copy link
Copy Markdown
Contributor

@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.
From the issue, it looks like we should be expecting much more of a gain, although I don't know exactly what context the POC was in, so let me know if further changes are needed to realize the full gain.

@misiugodfrey If you see Q11 going from 50s to 3s on sf30k 8 nodes then this PR is sufficient.

@patdevinwilson patdevinwilson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread presto/scripts/run_benchmark.sh Outdated
@@ -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[*]}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll preserve the original behaviour when we are not using CTAS.

Comment thread common/testing/validate_results.py Outdated
actual,
expected,
query_sql,
actual_order_preserved=result_file.is_file(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 paul-aiyedun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread presto/scripts/run_benchmark.sh Outdated
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already write outputs (including result sets) to --output-dir. I think adding another PRESTO_OUTPUT_DIR variable here can be confusing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread presto/scripts/run_benchmark.sh Outdated
--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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a more direct parameter name e.g. --run-as-ctas-queries?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good.

Comment thread presto/scripts/run_benchmark.sh Outdated
exit 1
fi

if [[ "${WRITE_RESULTS_TO_FILE}" == "true" ]]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread common/testing/validate_results.py Outdated
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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread common/testing/result_comparison.py Outdated
aggregate, etc.).
"""
expr = sqlglot.parse_one(query_sql)
def _get_orderby_sort_spec(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this comment also applies here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is common to have tests for test fixtures?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the difference between the regular benchmark tag and this tag?

@misiugodfrey misiugodfrey Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we creating a new table for each iteration?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do the settings here interact with etc_worker/catalog/hive.properties and etc_coordinator/catalog/hive.properties?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a separate catalog and should not interact with them at all beyond inheriting some shared settings.

@misiugodfrey

Copy link
Copy Markdown
Contributor Author

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.
@copy-pr-bot

copy-pr-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants