Skip to content
Draft
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a3190f1
Add experimental AST JIT for integral add and multiply
thirtiseven Jul 21, 2026
9bb4ff2
Merge remote-tracking branch 'origin/main' into project-ast-jit-infra
thirtiseven Jul 21, 2026
4f6972a
Update copyright years
thirtiseven Jul 21, 2026
f75b0c1
Address AST JIT review feedback
thirtiseven Jul 21, 2026
b4a1182
Enable per-expression legacy AST projection
thirtiseven Jul 24, 2026
6cea0cf
Add signoff
thirtiseven Jul 24, 2026
6ed9b58
Merge branch 'main' into legacy-ast-per-expression
thirtiseven Jul 24, 2026
f3ebe60
small refactor
thirtiseven Jul 24, 2026
b242c73
address comments
thirtiseven Jul 29, 2026
8ffede9
fix tests
thirtiseven Jul 29, 2026
8fc3fb5
address comments
thirtiseven Jul 30, 2026
ce448ef
Document AST compatibility reason invariant
thirtiseven Jul 31, 2026
a98a812
Merge branch 'main' into legacy-ast-per-expression
igorpeshansky Jul 31, 2026
c20b430
Merge branch 'main' into legacy-ast-per-expression
igorpeshansky Aug 1, 2026
3cc28a8
fix scala 2.13 ci
thirtiseven Aug 2, 2026
555dc68
Merge branch 'main' into legacy-ast-per-expression
thirtiseven Aug 3, 2026
8adcdcd
Merge legacy per-expression AST into AST JIT
thirtiseven Aug 3, 2026
9772c07
Merge main into project-ast-jit-infra
thirtiseven Aug 4, 2026
fabcb01
Make Project AST JIT follow expression tiers
thirtiseven Aug 5, 2026
93b73a8
address local comments
thirtiseven Aug 5, 2026
116528e
Fix AST JIT cleanup registration failure
thirtiseven Aug 5, 2026
59e26f2
address comments
thirtiseven Aug 10, 2026
1058db8
address comments
thirtiseven Aug 12, 2026
084c57f
address comments
thirtiseven Aug 17, 2026
c3a1f6d
add nvtx docs
thirtiseven Aug 19, 2026
3372a30
Add multi-output AST JIT project waves
thirtiseven Aug 26, 2026
7dc0992
use new api
thirtiseven Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/dev/nvtx_ranges.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ DoubleBatchedWindow_PRE|Pre-processing for double-batched window operation
GpuGenerate project split|Splitting projection in generate operation
parquet parse filter footer|Parsing and filtering Parquet footer by range
Compile ASTs|Compiling abstract syntax trees for expression evaluation
Compile AST JIT|Compiling an AST expression for JIT evaluation
parquet filter blocks|Filtering Parquet row group blocks based on predicates
PageableH2D|Copying from pageable host memory to device
TOP N|Computing top N rows
Expand Down Expand Up @@ -134,6 +135,7 @@ single build batch concat|Concatenating batches for single build batch
sort copy boundaries|Copying boundary data for sort operation
WaitingForWrites|Rapids Shuffle Manager (multi threaded) is waiting for any queued writes to finish before finalizing the map output writer
Project AST|Applying AST-based projection to batch
Project AST JIT|Applying JIT-compiled AST projection to batch
ORC readBatches|Reading ORC batches
Shuffle Transfer Request|Handling shuffle data transfer request
consumeWindow|Consuming transfer window
Expand Down
76 changes: 76 additions & 0 deletions integration_tests/src/main/python/ast_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@
not (is_spark_359() or is_spark_403_or_404() or is_spark_412_or_later()))]

_project_ast_enabled_conf = {"spark.rapids.sql.projectAstEnabled": "true"}
_project_ast_jit_enabled_conf = {"spark.rapids.sql.projectAstJitEnabled": "true"}
_project_ast_jit_and_legacy_enabled_conf = {
"spark.rapids.sql.projectAstEnabled": "true",
"spark.rapids.sql.projectAstJitEnabled": "true"
}


def assert_gpu_ast(is_supported, func, conf={}):
ast_expression = "GpuProjectAstExpression"
Expand Down Expand Up @@ -386,6 +392,76 @@ def test_multiplication(data_descr):
f.lit(-12).cast(data_type) * f.col('b'),
f.col('a') * f.col('b')))

@pytest.mark.parametrize('data_gen', [int_gen, long_gen], ids=idfn)
@disable_ansi_mode
def test_jit_add_multiply(data_gen):
assert_cpu_and_gpu_are_equal_collect_with_capture(
lambda spark: binary_op_df(spark, data_gen).select(
f.col('a') + f.col('b'),
f.col('a') * f.col('b')),
exist_classes=r"GpuProject.*AST_JIT",
non_exist_classes="GpuProjectAst",
conf=_project_ast_jit_enabled_conf)

@pytest.mark.parametrize('data_gen', [int_gen, long_gen], ids=idfn)
@disable_ansi_mode
def test_jit_does_not_split_unique_unsupported_expression(data_gen):
assert_cpu_and_gpu_are_equal_collect_with_capture(
lambda spark: binary_op_df(spark, data_gen).select(
(f.col('a') + f.col('b')) - (f.col('a') * f.col('b'))),
exist_classes="GpuProject",
non_exist_classes="AST_JIT,GpuProjectAst",
conf=_project_ast_jit_enabled_conf)

@pytest.mark.parametrize('data_gen', [int_gen, long_gen], ids=idfn)
@disable_ansi_mode
def test_jit_cse_shared_subexpression(data_gen):
def project_shared_expression(spark):
df = binary_op_df(spark, data_gen)
shared = f.col('a') + f.col('b')
return df.select(
shared.alias('shared'),
(shared - f.col('a')).alias('left'),
(shared - f.col('b')).alias('right'))

assert_cpu_and_gpu_are_equal_collect_with_capture(
project_shared_expression,
exist_classes=r"GpuProject.*AST_JIT.*AS shared.*AS left.*AS right",
non_exist_classes="GpuProjectAst",
conf=_project_ast_jit_enabled_conf)

@pytest.mark.parametrize('data_gen', [int_gen, long_gen], ids=idfn)
@pytest.mark.parametrize(
'tiered_project_enabled', ['true', 'false'], ids=['tiered', 'single_tier'])
@disable_ansi_mode
def test_jit_mixed_project_expressions(data_gen, tiered_project_enabled):
assert_cpu_and_gpu_are_equal_collect_with_capture(
lambda spark: binary_op_df(spark, data_gen).select(
(f.col('a') + f.col('b')).alias('jit'),
(f.col('a') - f.col('b')).alias('gpu'),
((f.col('a') * f.col('b')) - f.col('a')).alias('mixed')),
exist_classes=r"GpuProject.*AST_JIT.*AS jit.*AS gpu.*AS mixed",
non_exist_classes=r"GpuProjectAst,AS gpu.*AST_JIT",
conf=copy_and_update(_project_ast_jit_enabled_conf, {
'spark.rapids.sql.tiered.project.enabled': tiered_project_enabled
}))

@pytest.mark.parametrize('data_gen', [int_gen, long_gen], ids=idfn)
@disable_ansi_mode
def test_jit_and_legacy_ast_mixed_project_expressions(data_gen):
assert_cpu_and_gpu_are_equal_collect_with_capture(
lambda spark: binary_op_df(spark, data_gen).select(
(f.col('a') + f.col('b')).alias('jit'),
(f.col('a') - f.col('b')).alias('legacy'),
((f.col('a') + f.col('b')) -
(f.col('a') * f.col('b'))).alias('mixed')),
exist_classes=(
r"GpuProject.*AST_JIT.*AS jit.*AST\(.*AS legacy.*"
r"AST\(.*AS mixed,GpuProjectAstExpression"),
non_exist_classes=r"AS legacy.*AST_JIT",
conf=_project_ast_jit_and_legacy_enabled_conf)


# Each descriptor contains a list of data generators and a corresponding boolean
Comment thread
thirtiseven marked this conversation as resolved.
# indicating whether that data type is supported by the AST
# all the below desc are not supported by the AST because ANSI mode is on
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2026, NVIDIA CORPORATION.
*
* Licensed 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.
*/

package com.nvidia.spark.rapids

import ai.rapids.cudf.Table
import ai.rapids.cudf.ast.CompiledExpression
import com.nvidia.spark.Retryable
import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource}
import com.nvidia.spark.rapids.GpuMetric.OP_TIME_LEGACY
import com.nvidia.spark.rapids.RapidsPluginImplicits._
import com.nvidia.spark.rapids.ScalableTaskCompletion.onTaskCompletion
import com.nvidia.spark.rapids.shims.ShimUnaryExpression

import org.apache.spark.TaskContext
import org.apache.spark.sql.catalyst.expressions.{Expression, NamedExpression}
import org.apache.spark.sql.types.DataType
import org.apache.spark.sql.vectorized.ColumnarBatch

object GpuAstJitExpression {
private def canUseAstJit(expression: GpuExpression): Boolean =
GpuBatchUtils.isFixedWidth(expression.dataType) &&
expression.supportsAstJit && expression.containsAstJitOperator

private def asAstJit(child: GpuExpression): Option[GpuAstJitExpression] = child match {
case jitExpression: GpuAstJitExpression => Some(jitExpression)
case astExpression: GpuProjectAstExpression => asAstJit(astExpression.child)
case other if canUseAstJit(other) => Some(GpuAstJitExpression(other))
case _ => None
}

private[rapids] def wrapTierExpression(expression: Expression): Expression = expression match {
case alias @ GpuAlias(child: GpuExpression, _) =>
asAstJit(child).map(GpuProjectAstExpression.replaceChild(alias, _)).getOrElse(alias)
case other => other
}
Comment thread
igorpeshansky marked this conversation as resolved.

private[rapids] def wrapProjectExpressions(
expressions: List[NamedExpression]): List[NamedExpression] = {
expressions.map(wrapTierExpression(_).asInstanceOf[NamedExpression])
}

/** Extracts a Project AST JIT wrapper after unwrapping any top-level aliases. */
private[rapids] def extractTopLevel(expression: Expression): Option[GpuAstJitExpression] =
GpuProjectAstExpressionBase.extractTopLevel(expression).collect {
case jitExpression: GpuAstJitExpression => jitExpression
}

private def hasJitCandidate(expression: Expression): Boolean = expression.find {
case gpuExpression: GpuExpression =>
gpuExpression.supportsAstJit && gpuExpression.containsAstJitOperator
case _ => false
}.isDefined

private def finalBackend(expression: Expression): String = {
GpuProjectAstExpressionBase.extractTopLevel(expression) match {
case Some(_: GpuAstJitExpression) => "Project AST JIT"
case Some(_: GpuProjectAstExpression) => "legacy Project AST"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: I don't think this explains it very well, and I am not sure a customer is going to understand. If this is not for a customer to follow, then can we make sure it is documented and drop the Project from it? Something like "AST JIT" and "AST Interpreted" feel better to me.

case _ => "the regular GPU projection"
}
}

private[rapids] def explainFinalSelections(
expressionTiers: Seq[Seq[Expression]],
all: Boolean): String = {
expressionTiers.zipWithIndex.flatMap { case (expressions, tier) =>
val explanations = expressions.iterator.collect {
case expression if all ||
(extractTopLevel(expression).isEmpty && hasJitCandidate(expression)) =>
s" $expression final backend: ${finalBackend(expression)}\n"
}.mkString
if (explanations.nonEmpty) {
Some(s" TIER $tier\n$explanations")
} else {
None
}
}.mkString
}
}

case class GpuAstJitExpression(child: GpuExpression)
Comment thread
igorpeshansky marked this conversation as resolved.
Comment thread
igorpeshansky marked this conversation as resolved.
extends ShimUnaryExpression with GpuProjectAstExpressionBase
with GpuMetricsInjectable with Retryable with AutoCloseable {

@transient private[this] var compiledExpression: CompiledExpression = _
private[this] var opTime: GpuMetric = NoopMetric

override def dataType: DataType = child.dataType

override def nullable: Boolean = child.nullable

override def toString: String = s"AST_JIT($child)"

override def injectMetrics(metrics: Map[String, GpuMetric]): Unit = {
// OP_TIME_LEGACY is the owning operator's non-RDD timing metric, not a legacy AST metric.
opTime = metrics.getOrElse(OP_TIME_LEGACY, NoopMetric)
}

override def checkpoint(): Unit = {
getCompiledExpression
}

// Compiled ASTs are immutable and remain valid across retry attempts.
override def restore(): Unit = ()

override def close(): Unit = {
val toClose = synchronized {
val current = compiledExpression
compiledExpression = null
current
}
Option(toClose).foreach(_.safeClose())
}

override def columnarEval(batch: ColumnarBatch): GpuColumnVector = {
withResource(GpuProjectAstExpression.tableFromBatch(batch)) { table =>
computeColumn(table)
}
}

private[rapids] override def computeColumn(table: Table): GpuColumnVector = {
val compiled = getCompiledExpression
NvtxIdWithMetrics(NvtxRegistry.PROJECT_AST_JIT, opTime) {
closeOnExcept(compiled.computeColumnJit(table)) { result =>
GpuColumnVector.from(result, dataType)
}
}
}

private def getCompiledExpression: CompiledExpression = synchronized {
if (compiledExpression == null) {
val compiled = NvtxIdWithMetrics(NvtxRegistry.COMPILE_AST_JIT, opTime) {
// Force every bound reference to the left table; Project AST has one input table.
child.convertToAst(Int.MaxValue).compile()
}
closeOnExcept(compiled) { _ =>
var completed = false
Option(TaskContext.get()).foreach { taskContext =>
onTaskCompletion(taskContext) {
completed = true
close()
}
}
if (completed) {
throw new IllegalStateException(
"Task completed while registering the AST JIT cleanup callback")
}
compiledExpression = compiled
}
}
compiledExpression
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ trait GpuBind {

object GpuBindReferences extends Logging {

private def explainFinalProjectAstJitSelection(
tieredProject: GpuTieredProject,
conf: SQLConf): Unit = {
val explain = RapidsConf.EXPLAIN.get(conf)
if (!explain.equalsIgnoreCase("NONE")) {
Comment thread
igorpeshansky marked this conversation as resolved.
Outdated
val explanation = GpuAstJitExpression.explainFinalSelections(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Really optional] Unless the user sets "explain=ALL", none of the post-CSE JIT nodes would appear in the log, and thus it would be hard for them to know that the JIT is actually working. It would be useful for the explainer to show counts of the different kinds of nodes (e.g., "20 JIT nodes, 10 legacy AST nodes, 15 regular project") along with the errors/rejected nodes, either unconditionally or with a new "explain=STATS" setting. Definitely out of this PR's scope, so maybe just file a feature request to track?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, filed #15690

tieredProject.exprTiers, explain.equalsIgnoreCase("ALL"))
if (explanation.nonEmpty) {
logWarning(s"FINAL PROJECT AST JIT SELECTION\n$explanation")
}
}
}

/**
* An alternative to `Expression.transformDown`, but when a result is returned by `rule` it is
* assumed that it handled processing exp and all of its children, so rule will not be called on
Expand Down Expand Up @@ -124,18 +137,19 @@ object GpuBindReferences extends Logging {
}

/**
* Binding method for tiered expressions without metric injection.
* This is for use by GpuBind implementations and should not be called directly
* from SparkPlan nodes. Use the public API that requires metrics instead, except
* when absolutely needed.
* Shared implementation for generic and Project-specific tiered binding.
*
* @param enableProjectAstJit whether eligible tiers may use Project AST JIT
*/
def bindGpuReferencesTieredNoMetrics[A <: Expression](
private def bindGpuReferencesTieredNoMetricsInternal[A <: Expression](
expressions: Seq[A],
input: AttributeSeq,
conf: SQLConf): GpuTieredProject = {
conf: SQLConf,
enableProjectAstJit: Boolean): GpuTieredProject = {

if (RapidsConf.ENABLE_TIERED_PROJECT.get(conf)) {
val exprTiers = GpuProjectAstExpression.buildExprTiers(expressions, conf)
val tieredProject = if (RapidsConf.ENABLE_TIERED_PROJECT.get(conf)) {
val exprTiers = GpuProjectAstExpression.buildExprTiers(
expressions, conf, enableProjectAstJit)
val inputTiers = GpuEquivalentExpressions.getInputTiers(exprTiers, input)
// Update ExprTiers to include the columns that are pass through and drop unneeded columns
val newExprTiers = exprTiers.zipWithIndex.map {
Expand Down Expand Up @@ -174,8 +188,44 @@ object GpuBindReferences extends Logging {
}
GpuTieredProject(tiered)
} else {
GpuTieredProject(Seq(GpuBindReferences.bindGpuReferencesNoMetrics(expressions, input)))
val projectExpressions = if (enableProjectAstJit) {
expressions.map(GpuAstJitExpression.wrapTierExpression)
} else {
expressions
}
GpuTieredProject(Seq(
GpuBindReferences.bindGpuReferencesNoMetrics(projectExpressions, input)))
}
if (enableProjectAstJit) {
explainFinalProjectAstJitSelection(tieredProject, conf)
}
tieredProject
}

/**
* Binding method for tiered expressions without metric injection.
* This is for use by GpuBind implementations and should not be called directly
* from SparkPlan nodes. Use the public API that requires metrics instead, except
* when absolutely needed.
*/
def bindGpuReferencesTieredNoMetrics[A <: Expression](
Comment thread
igorpeshansky marked this conversation as resolved.
expressions: Seq[A],
input: AttributeSeq,
conf: SQLConf): GpuTieredProject = {
bindGpuReferencesTieredNoMetricsInternal(
expressions, input, conf, enableProjectAstJit = false)
}

/**
* Project-specific tiered binding without metric injection. Unlike the generic binder,
* this path allows configured Project AST JIT selection.
*/
private[rapids] def bindGpuProjectReferencesTieredNoMetrics[A <: Expression](
Comment thread
igorpeshansky marked this conversation as resolved.
expressions: Seq[A],
input: AttributeSeq,
conf: SQLConf): GpuTieredProject = {
bindGpuReferencesTieredNoMetricsInternal(
expressions, input, conf, RapidsConf.ENABLE_PROJECT_AST_JIT.get(conf))
}

// ========== Public "Front Door" APIs (for use by SparkPlan nodes) ==========
Expand Down Expand Up @@ -257,12 +307,32 @@ object GpuBindReferences extends Logging {
bound.injectMetrics(metrics)
bound
}

/**
* Bind Project expressions in a tiered manner and inject metrics. Project AST JIT selection is
* confined to this entry point so generic tiered binders do not enable it for other operators.
* @param expressions The expressions to bind
* @param input The input schema
* @param conf SQL configuration
* @param metrics Metrics to inject into the bound expressions
*/
def bindGpuProjectReferencesTiered[A <: Expression](
Comment thread
igorpeshansky marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I personally don't like the name. This just like bindGpuReferencesTiered returns a GpuTieredProject. All of these are binding for a "Project" operation. Adding Project to the name does not distinguish it from the other in any meaningful way by the name alone.
Why do we need to distinguish between these two APIs? If we can get a speedup on a Regular GpuProjectExec why do we not want to do it also for pre-processing on aggregations, expand and filter operations? Not it looks like join already does use this in some cases.

If there are good reasons to keep them separate, can we rename this or modify the original API to take in the JIT/AST enable param? To me that is much cleaner and less confusing.

expressions: Seq[A],
input: AttributeSeq,
conf: SQLConf,
metrics: Map[String, GpuMetric]): GpuTieredProject = {
val bound = bindGpuProjectReferencesTieredNoMetrics(expressions, input, conf)
bound.injectMetrics(metrics)
bound
}
}

case class GpuBoundReference(ordinal: Int, dataType: DataType, nullable: Boolean)
(val exprId: ExprId, val name: String)
extends GpuLeafExpression with ShimExpression {

override def selfSupportsAstJit: Boolean = true

override def toString: String =
s"input[$ordinal, ${dataType.simpleString}, $nullable]($name#${exprId.id})"

Expand Down
Loading
Loading