Skip to content

Commit cf2c2ef

Browse files
Merge branch 'main' into regex-inline-flags
2 parents 60b395d + ee64344 commit cf2c2ef

12 files changed

Lines changed: 613 additions & 70 deletions

File tree

integration_tests/src/main/python/orc_cast_test.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2020-2025, NVIDIA CORPORATION.
1+
# Copyright (c) 2020-2026, NVIDIA CORPORATION.
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -17,7 +17,8 @@
1717
from asserts import assert_gpu_and_cpu_are_equal_collect, assert_gpu_and_cpu_error
1818
from conftest import is_not_utc
1919
from data_gen import *
20-
from marks import allow_non_gpu, datagen_overrides
20+
from marks import (allow_non_gpu, datagen_overrides, tz_sensitive_test,
21+
validate_execs_in_gpu_plan)
2122
from pyspark.sql.types import *
2223
from spark_session import with_cpu_session
2324
from orc_test import reader_opt_confs
@@ -128,11 +129,61 @@ def test_casting_from_double_to_timestamp(spark_tmp_path, data_gen):
128129
)
129130

130131

132+
@tz_sensitive_test
133+
@pytest.mark.skipif(not is_not_utc(), reason="non-UTC ORC timestamp regression test")
134+
@allow_non_gpu(*non_utc_allow_orc_scan)
135+
@validate_execs_in_gpu_plan('GpuFileSourceScanExec')
136+
def test_non_utc_timestamp_regressions(spark_tmp_path):
137+
integer_path = spark_tmp_path + '/orc_integer_timestamp_regression'
138+
double_path = spark_tmp_path + '/orc_double_timestamp_regression'
139+
physical_path = spark_tmp_path + '/orc_physical_timestamp_regression'
140+
141+
with_cpu_session(
142+
lambda spark: spark.createDataFrame([(514952012,)], "a long")
143+
.write.orc(integer_path)
144+
)
145+
with_cpu_session(
146+
lambda spark: spark.createDataFrame(
147+
[(0.0,),
148+
(float('nan'),),
149+
(float('inf'),),
150+
(float('-inf'),),
151+
(-8589934591.999999,),
152+
(-7953731124.723491,),
153+
(-0.0015,),
154+
(-0.0005,),
155+
(0.0005,),
156+
(0.0015,)],
157+
"a double")
158+
.write.orc(double_path)
159+
)
160+
with_cpu_session(
161+
lambda spark: spark.createDataFrame(
162+
[(-2957649381472612,), (-3649379812521628,)], "a long")
163+
.selectExpr("timestamp_micros(a) AS a")
164+
.write.orc(physical_path)
165+
)
166+
167+
assert_gpu_and_cpu_are_equal_collect(
168+
lambda spark: spark.read.schema("a timestamp").orc(integer_path)
169+
)
170+
assert_gpu_and_cpu_are_equal_collect(
171+
lambda spark: spark.read.schema("a timestamp").orc(double_path)
172+
)
173+
assert_gpu_and_cpu_are_equal_collect(
174+
lambda spark: spark.read.orc(physical_path)
175+
)
176+
177+
131178
@allow_non_gpu(*non_utc_allow_for_test_casting_from_overflow_long)
132-
def test_casting_from_overflow_double_to_timestamp(spark_tmp_path):
179+
# Keep both signs beyond the Long microsecond range after any timezone correction.
180+
@pytest.mark.parametrize("overflow_value", [9223372123255.0, -9223372123255.0])
181+
def test_casting_from_overflow_double_to_timestamp(spark_tmp_path, overflow_value):
133182
orc_path = spark_tmp_path + '/orc_casting_from_overflow_double_to_timestamp'
134183
with_cpu_session(
135-
lambda spark: unary_op_df(spark, DoubleGen(min_exp=38)).write.orc(orc_path)
184+
lambda spark: spark.createDataFrame(
185+
[(overflow_value,)], "a double")
186+
.write.orc(orc_path)
136187
)
137188
assert_gpu_and_cpu_error(
138189
df_fun=lambda spark: spark.read.schema("a timestamp").orc(orc_path).collect(),

sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuOrcScan.scala

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import java.nio.channels.Channels
2323
import java.nio.charset.StandardCharsets
2424
import java.time.ZoneId
2525
import java.util
26+
import java.util.TimeZone
2627
import java.util.regex.Pattern
2728

2829
import scala.annotation.tailrec
@@ -322,8 +323,7 @@ object GpuOrcScan {
322323
case (DType.BOOL8 | DType.INT8 | DType.INT16 | DType.INT32 | DType.INT64,
323324
DType.TIMESTAMP_MICROSECONDS) =>
324325
withResource(OrcCastingShims.castIntegerToTimestamp(col, fromDt)) { timestamp =>
325-
GpuTimeZoneDB.fromTimestampToUtcTimestamp(
326-
timestamp, ZoneId.systemDefault().normalized())
326+
GpuTimeZoneDB.convertOrcFromUtc(timestamp, ZoneId.systemDefault().getId)
327327
}
328328

329329
// float to bool/integral
@@ -382,16 +382,19 @@ object GpuOrcScan {
382382
// Math.round half up can be implemented in terms of floor
383383
// Math.round(x) = n iff x is in [n-0.5, n+0.5) iff x+0.5 is in [n,n+1) iff floor(x+0.5) = n
384384
//
385-
val milliseconds = withResource(Scalar.fromDouble(DateTimeConstants.MILLIS_PER_SECOND)) {
386-
thousand =>
387-
// ORC assumes value is in seconds
388-
withResource(col.mul(thousand, DType.FLOAT64)) { doubleMillis =>
389-
withResource(Scalar.fromDouble(0.5)) { half =>
390-
withResource(doubleMillis.add(half)) { doubleMillisPlusHalf =>
391-
withResource(doubleMillisPlusHalf.floor()) { millis =>
392-
withResource(getOverflowFlags(doubleMillis, millis)) { overflowFlags =>
393-
withResource(Scalar.fromNull(millis.getType)) { nullVal =>
394-
overflowFlags.ifElse(millis, nullVal)
385+
val milliseconds = withResource(col.castTo(DType.FLOAT64)) { doubleSeconds =>
386+
withResource(convertOrcFloatingPointSeconds(doubleSeconds)) { convertedSeconds =>
387+
withResource(Scalar.fromDouble(DateTimeConstants.MILLIS_PER_SECOND)) { thousand =>
388+
// ORC applies timezone conversion while the value is still in seconds.
389+
withResource(convertedSeconds.mul(thousand, DType.FLOAT64)) { doubleMillis =>
390+
withResource(Scalar.fromDouble(0.5)) { half =>
391+
withResource(doubleMillis.add(half)) { doubleMillisPlusHalf =>
392+
withResource(doubleMillisPlusHalf.floor()) { millis =>
393+
withResource(getOverflowFlags(doubleMillis, millis)) { overflowFlags =>
394+
withResource(Scalar.fromNull(millis.getType)) { nullVal =>
395+
overflowFlags.ifElse(millis, nullVal)
396+
}
397+
}
395398
}
396399
}
397400
}
@@ -419,12 +422,11 @@ object GpuOrcScan {
419422
}
420423
withResource(Scalar.fromDouble(DateTimeConstants.MICROS_PER_MILLIS)) { thousand =>
421424
withResource(milliseconds.mul(thousand)) { microseconds =>
422-
withResource(microseconds.castTo(DType.INT64)) { longVec =>
423-
withResource(longVec.castTo(DType.TIMESTAMP_MICROSECONDS)) { timestamp =>
424-
GpuTimeZoneDB.fromTimestampToUtcTimestamp(
425-
timestamp, ZoneId.systemDefault().normalized())
426-
}
425+
withResource(microseconds.castTo(DType.INT64)) { longVec =>
426+
withResource(longVec.castTo(DType.TIMESTAMP_MICROSECONDS)) { timestamp =>
427+
timestamp.incRefCount()
427428
}
429+
}
428430
}
429431
}
430432
}
@@ -444,6 +446,59 @@ object GpuOrcScan {
444446
}
445447
}
446448

449+
/**
450+
* Apply ORC's offset lookup ordering before its millisecond rounding and overflow check.
451+
* ORC looks up the offset at `(localMillis - rawOffset)`, while Spark materializes the final
452+
* timestamp with java.time rules. Apply only that integral timezone delta to the original
453+
* floating-point value so both the DST and historical behavior match Spark's CPU ORC path.
454+
*/
455+
private def convertOrcFloatingPointSeconds(seconds: ColumnVector): ColumnVector = {
456+
if (GpuOverrides.isUTCTimezone(ZoneId.systemDefault())) {
457+
return seconds.incRefCount()
458+
}
459+
withResource(Scalar.fromDouble(DateTimeConstants.MICROS_PER_SECOND)) { microsPerSecond =>
460+
val localTimestamp = withResource(
461+
Scalar.fromDouble(DateTimeConstants.MILLIS_PER_SECOND)) { millisPerSecond =>
462+
withResource(seconds.mul(millisPerSecond, DType.FLOAT64)) { doubleMillis =>
463+
withResource(doubleMillis.castTo(DType.INT64)) { localMillis =>
464+
withResource(localMillis.bitCastTo(DType.TIMESTAMP_MILLISECONDS)) {
465+
localMillisTimestamp =>
466+
localMillisTimestamp.castTo(DType.TIMESTAMP_MICROSECONDS)
467+
}
468+
}
469+
}
470+
}
471+
withResource(localTimestamp) { _ =>
472+
withResource(localTimestamp.bitCastTo(DType.INT64)) { localMicros =>
473+
val rawOffsetMicros = TimeZone.getDefault.getRawOffset.toLong *
474+
DateTimeConstants.MICROS_PER_MILLIS
475+
withResource(Scalar.fromLong(rawOffsetMicros)) { rawOffset =>
476+
withResource(localMicros.sub(rawOffset)) { offsetLookupMicros =>
477+
withResource(offsetLookupMicros.castTo(DType.TIMESTAMP_MICROSECONDS)) {
478+
offsetLookupTimestamp =>
479+
val localAtLookup = GpuTimeZoneDB.fromUtcTimestampToTimestamp(
480+
offsetLookupTimestamp, ZoneId.systemDefault().normalized())
481+
withResource(localAtLookup) { _ =>
482+
withResource(localAtLookup.bitCastTo(DType.INT64)) { localAtLookupMicros =>
483+
val offsetSeconds = withResource(
484+
localAtLookupMicros.sub(offsetLookupMicros)) { offsetMicros =>
485+
withResource(offsetMicros.castTo(DType.FLOAT64)) { doubleOffsetMicros =>
486+
doubleOffsetMicros.div(microsPerSecond, DType.FLOAT64)
487+
}
488+
}
489+
withResource(offsetSeconds) { _ =>
490+
seconds.sub(offsetSeconds, DType.FLOAT64)
491+
}
492+
}
493+
}
494+
}
495+
}
496+
}
497+
}
498+
}
499+
}
500+
}
501+
447502
/**
448503
* Whether the type casting is supported by GPU ORC reading.
449504
*

sql-plugin/src/main/scala/com/nvidia/spark/rapids/GpuOrcTimezoneUtils.scala

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import java.util.Optional
2121

2222
import scala.collection.mutable.ArrayBuffer
2323

24-
import ai.rapids.cudf.{ColumnView, DType, Table}
24+
import ai.rapids.cudf.{ColumnView, DType, Scalar, Table}
2525
import com.nvidia.spark.rapids.Arm.withResource
2626
import com.nvidia.spark.rapids.RapidsPluginImplicits.AutoCloseableProducingSeq
2727
import com.nvidia.spark.rapids.jni.GpuTimeZoneDB
@@ -77,16 +77,17 @@ object GpuOrcTimezoneUtils {
7777
*/
7878
private def rebaseWithWriterTimezone(
7979
input: Table, writerTz: String, readerTz: String): Table = {
80+
val readerZone = ZoneId.of(readerTz, ZoneId.SHORT_IDS)
8081
withResource(input) { _ =>
8182
withResource(GpuTimeZoneDB.buildOrcTimezoneContext(writerTz, readerTz)) { tzCtx =>
8283
val newColumns = (0 until input.getNumberOfColumns).safeMap { colIdx =>
8384
val col = input.getColumn(colIdx)
8485
val dType = col.getType
8586
if (dType.hasTimeResolution) {
86-
GpuTimeZoneDB.convertOrcTimezones(col, tzCtx)
87+
convertOrcTimestamp(col, tzCtx, readerZone)
8788
} else if (dType == DType.LIST || dType == DType.STRUCT) {
8889
withResource(new ArrayBuffer[ColumnView]) { toClose =>
89-
val rebased = rebaseNestedWithWriterTimezone(col, tzCtx, toClose)
90+
val rebased = rebaseNestedWithWriterTimezone(col, tzCtx, readerZone, toClose)
9091
if (rebased eq col) {
9192
col.incRefCount()
9293
} else {
@@ -105,18 +106,64 @@ object GpuOrcTimezoneUtils {
105106
}
106107
}
107108

109+
/**
110+
* Match the full Spark ORC timestamp path. Apache ORC uses java.util.TimeZone while decoding,
111+
* but Spark materializes the resulting java.sql.Timestamp using java.time rules. Those rule
112+
* sets can differ for historical and projected timestamps.
113+
*/
114+
private def convertOrcTimestamp(
115+
col: ColumnView,
116+
tzCtx: GpuTimeZoneDB.OrcTimezoneContext,
117+
readerZone: ZoneId): ai.rapids.cudf.ColumnVector = {
118+
withResource(GpuTimeZoneDB.convertOrcTimezones(col, tzCtx)) { orcTimestamp =>
119+
val firstTransitionUs = tzCtx.getReaderFirstTransitionUs
120+
if (firstTransitionUs == Long.MinValue) {
121+
orcTimestamp.incRefCount()
122+
} else {
123+
val utilMicros = withResource(
124+
GpuTimeZoneDB.convertOrcFromUtc(orcTimestamp, tzCtx)) { utilUtc =>
125+
utilUtc.castTo(DType.INT64)
126+
}
127+
withResource(utilMicros) { _ =>
128+
val ruleCorrection = withResource(GpuTimeZoneDB.fromTimestampToUtcTimestamp(
129+
orcTimestamp, readerZone.normalized())) { zoneUtc =>
130+
withResource(zoneUtc.castTo(DType.INT64)) { zoneMicros =>
131+
zoneMicros.sub(utilMicros)
132+
}
133+
}
134+
withResource(ruleCorrection) { _ =>
135+
val correctedTimestamp = withResource(orcTimestamp.castTo(DType.INT64)) { orcMicros =>
136+
withResource(orcMicros.add(ruleCorrection)) { corrected =>
137+
corrected.castTo(DType.TIMESTAMP_MICROSECONDS)
138+
}
139+
}
140+
withResource(correctedTimestamp) { _ =>
141+
withResource(Scalar.timestampFromLong(
142+
DType.TIMESTAMP_MICROSECONDS, firstTransitionUs)) { firstTransition =>
143+
withResource(orcTimestamp.lessThan(firstTransition)) { needsCorrection =>
144+
needsCorrection.ifElse(correctedTimestamp, orcTimestamp)
145+
}
146+
}
147+
}
148+
}
149+
}
150+
}
151+
}
152+
}
153+
108154
private def rebaseNestedWithWriterTimezone(
109155
col: ColumnView,
110156
tzCtx: GpuTimeZoneDB.OrcTimezoneContext,
157+
readerZone: ZoneId,
111158
toClose: ArrayBuffer[ColumnView]): ColumnView = {
112159
val addToClose = (v: ColumnView) => { toClose += v; v }
113160
val dType = col.getType
114161

115162
if (dType.hasTimeResolution) {
116-
GpuTimeZoneDB.convertOrcTimezones(col, tzCtx)
163+
convertOrcTimestamp(col, tzCtx, readerZone)
117164
} else if (dType == DType.LIST) {
118165
val child = addToClose(col.getChildColumnView(0))
119-
val newChild = rebaseNestedWithWriterTimezone(child, tzCtx, toClose)
166+
val newChild = rebaseNestedWithWriterTimezone(child, tzCtx, readerZone, toClose)
120167
if (newChild ne child) {
121168
col.replaceListChild(addToClose(newChild))
122169
} else {
@@ -125,7 +172,7 @@ object GpuOrcTimezoneUtils {
125172
} else if (dType == DType.STRUCT) {
126173
val newViews = (0 until col.getNumChildren).map { i =>
127174
val child = addToClose(col.getChildColumnView(i))
128-
val newChild = rebaseNestedWithWriterTimezone(child, tzCtx, toClose)
175+
val newChild = rebaseNestedWithWriterTimezone(child, tzCtx, readerZone, toClose)
129176
if (newChild ne child) addToClose(newChild)
130177
newChild
131178
}

sql-plugin/src/main/spark412/scala/com/nvidia/spark/rapids/shims/SparkShims.scala

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
/*** spark-rapids-shim-json-lines
1818
{"spark": "412"}
1919
{"spark": "413"}
20-
{"spark": "420"}
2120
spark-rapids-shim-json-lines ***/
2221
package com.nvidia.spark.rapids.shims
2322

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (c) 2026, NVIDIA CORPORATION.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/*** spark-rapids-shim-json-lines
18+
{"spark": "420"}
19+
spark-rapids-shim-json-lines ***/
20+
package com.nvidia.spark.rapids.shims
21+
22+
import com.nvidia.spark.rapids._
23+
24+
import org.apache.spark.sql.execution.SparkPlan
25+
import org.apache.spark.sql.execution.datasources.v2.GroupPartitionsExec
26+
27+
class GroupPartitionsExecMeta(
28+
groupPartitions: GroupPartitionsExec,
29+
conf: RapidsConf,
30+
parent: Option[RapidsMeta[_, _, _]],
31+
rule: DataFromReplacementRule)
32+
extends SparkPlanMeta[GroupPartitionsExec](groupPartitions, conf, parent, rule) {
33+
34+
override def tagPlanForGpu(): Unit = {
35+
willNotWorkOnGpu("GroupPartitionsExec is not supported on GPU")
36+
}
37+
38+
override def convertToCpu(): SparkPlan = {
39+
// GroupPartitionsExec reads its child's KeyedPartitioning at execution time.
40+
// GPU conversions can replace it with UnknownPartitioning, so keep the original
41+
// CPU subtree until GroupPartitionsExec has a GPU implementation.
42+
groupPartitions
43+
}
44+
45+
override def convertToGpu(): GpuExec = {
46+
throw new IllegalStateException("GroupPartitionsExec cannot be converted to GPU")
47+
}
48+
}

0 commit comments

Comments
 (0)