Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,19 @@ object GpuOrcTimezoneUtils {
val readerZone = ZoneId.of(readerTz, ZoneId.SHORT_IDS)
withResource(input) { _ =>
if (containsOrcTimestamp(input)) {
withResource(GpuTimeZoneDB.buildOrcTimezoneContext(writerTz, readerTz)) { tzCtx =>
rebaseColumns(input, Some(tzCtx), readerZone, writerUsedProlepticGregorian)
val legacyTimestampRebase = if (writerUsedProlepticGregorian) {
None
} else {
Some(new GpuTimestampRebaseUtils.LazyJulianToGregorianMicrosContext(readerZone.getId))
}
Comment on lines +147 to +151

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.

Updated.

withResource(legacyTimestampRebase) { legacyRebase =>
withResource(GpuTimeZoneDB.buildOrcTimezoneContext(writerTz, readerTz)) { tzCtx =>
rebaseColumns(input, Some(tzCtx), readerZone, legacyRebase,
writerUsedProlepticGregorian)
}
}
} else {
rebaseColumns(input, None, readerZone, writerUsedProlepticGregorian)
rebaseColumns(input, None, readerZone, None, writerUsedProlepticGregorian)
}
}
}
Expand Down Expand Up @@ -158,18 +166,21 @@ object GpuOrcTimezoneUtils {
input: Table,
tzCtx: Option[GpuTimeZoneDB.OrcTimezoneContext],
readerZone: ZoneId,
legacyTimestampRebase: Option[
GpuTimestampRebaseUtils.LazyJulianToGregorianMicrosContext],
writerUsedProlepticGregorian: Boolean): Table = {
val newColumns = (0 until input.getNumberOfColumns).safeMap { colIdx =>
val col = input.getColumn(colIdx)
val dType = col.getType
if (dType == DType.TIMESTAMP_DAYS && !writerUsedProlepticGregorian) {
DateTimeRebase.rebaseJulianToGregorian(col)
} else if (dType.hasTimeResolution) {
convertOrcTimestamp(col, tzCtx.get, readerZone)
convertOrcTimestamp(col, tzCtx.get, readerZone, legacyTimestampRebase)
} else if (dType == DType.LIST || dType == DType.STRUCT) {
withResource(new ArrayBuffer[ColumnView]) { toClose =>
val rebased = rebaseNestedWithWriterTimezone(
col, tzCtx, readerZone, writerUsedProlepticGregorian, toClose)
col, tzCtx, readerZone, legacyTimestampRebase,
writerUsedProlepticGregorian, toClose)
if (rebased eq col) {
col.incRefCount()
} else {
Expand All @@ -187,45 +198,70 @@ object GpuOrcTimezoneUtils {
}

/**
* Match the full Spark ORC timestamp path. Apache ORC uses java.util.TimeZone while decoding,
* but Spark materializes the resulting java.sql.Timestamp using java.time rules. Those rule
* sets can differ for historical and projected timestamps.
* Match the full Spark ORC timestamp path after reconstructing the writer-specific ORC epoch.
* Legacy-calendar files use Spark's timezone-specific Julian-to-Gregorian rebase map. Files
* already written with the proleptic calendar only need the java.util.TimeZone versus java.time
* rule correction from the ORC materialization path.
*/
private def convertOrcTimestamp(
col: ColumnView,
tzCtx: GpuTimeZoneDB.OrcTimezoneContext,
readerZone: ZoneId): ai.rapids.cudf.ColumnVector = {
readerZone: ZoneId,
legacyTimestampRebase: Option[
GpuTimestampRebaseUtils.LazyJulianToGregorianMicrosContext]): ColumnVector = {
withResource(GpuTimeZoneDB.convertOrcTimezones(col, tzCtx)) { orcTimestamp =>
val firstTransitionUs = tzCtx.getReaderFirstTransitionUs
if (firstTransitionUs == Long.MinValue) {
orcTimestamp.incRefCount()
} else {
val utilMicros = withResource(
GpuTimeZoneDB.convertOrcFromUtc(orcTimestamp, tzCtx)) { utilUtc =>
utilUtc.castTo(DType.INT64)
}
val ruleCorrection = withResource(utilMicros) { utilMicros =>
withResource(GpuTimeZoneDB.fromTimestampToUtcTimestamp(
orcTimestamp, readerZone.normalized())) { zoneUtc =>
withResource(zoneUtc.castTo(DType.INT64)) { zoneMicros =>
zoneMicros.sub(utilMicros)
}
legacyTimestampRebase match {
case Some(rebase) => rebase.rebase(orcTimestamp)
case None => correctOrcTimestampRules(orcTimestamp, tzCtx, readerZone)
}
}
}

/** Correct only the historical java.util.TimeZone/java.time rule difference. */
private def correctOrcTimestampRules(
orcTimestamp: ColumnVector,
tzCtx: GpuTimeZoneDB.OrcTimezoneContext,
readerZone: ZoneId): ColumnVector = {
val firstTransitionUs = tzCtx.getReaderFirstTransitionUs
if (firstTransitionUs == Long.MinValue) {
orcTimestamp.incRefCount()
} else {
withResource(correctOrcTimestampRulesBeforeTransition(
orcTimestamp, tzCtx, readerZone)) { correctedTimestamp =>
withResource(Scalar.timestampFromLong(
DType.TIMESTAMP_MICROSECONDS, firstTransitionUs)) { firstTransition =>
withResource(orcTimestamp.lessThan(firstTransition)) { needsCorrection =>
needsCorrection.ifElse(correctedTimestamp, orcTimestamp)
}
}
val correctedTimestamp = withResource(ruleCorrection) { ruleCorrection =>
withResource(orcTimestamp.castTo(DType.INT64)) { orcMicros =>
withResource(orcMicros.add(ruleCorrection)) { corrected =>
corrected.castTo(DType.TIMESTAMP_MICROSECONDS)
}
}
}
}
}

private def correctOrcTimestampRulesBeforeTransition(
orcTimestamp: ColumnVector,
tzCtx: GpuTimeZoneDB.OrcTimezoneContext,
readerZone: ZoneId): ColumnVector = {
withResource(computeOrcTimestampRuleCorrection(
orcTimestamp, tzCtx, readerZone)) { ruleCorrection =>
withResource(orcTimestamp.castTo(DType.INT64)) { orcMicros =>
withResource(orcMicros.add(ruleCorrection)) { corrected =>
corrected.castTo(DType.TIMESTAMP_MICROSECONDS)
}
withResource(correctedTimestamp) { _ =>
withResource(Scalar.timestampFromLong(
DType.TIMESTAMP_MICROSECONDS, firstTransitionUs)) { firstTransition =>
withResource(orcTimestamp.lessThan(firstTransition)) { needsCorrection =>
needsCorrection.ifElse(correctedTimestamp, orcTimestamp)
}
}
}
}
}

private def computeOrcTimestampRuleCorrection(
orcTimestamp: ColumnVector,
tzCtx: GpuTimeZoneDB.OrcTimezoneContext,
readerZone: ZoneId): ColumnVector = {
withResource(GpuTimeZoneDB.convertOrcFromUtc(orcTimestamp, tzCtx)) { utilUtc =>
withResource(GpuTimeZoneDB.fromTimestampToUtcTimestamp(
orcTimestamp, readerZone.normalized())) { zoneUtc =>
withResource(Seq(utilUtc, zoneUtc).safeMap(_.castTo(DType.INT64))) {
case Seq(utilMicros, zoneMicros) =>
zoneMicros.sub(utilMicros)
}
}
}
Expand All @@ -235,6 +271,8 @@ object GpuOrcTimezoneUtils {
col: ColumnView,
tzCtx: Option[GpuTimeZoneDB.OrcTimezoneContext],
readerZone: ZoneId,
legacyTimestampRebase: Option[
GpuTimestampRebaseUtils.LazyJulianToGregorianMicrosContext],
writerUsedProlepticGregorian: Boolean,
toClose: ArrayBuffer[ColumnView]): ColumnView = {
val addToClose = (v: ColumnView) => { toClose += v; v }
Expand All @@ -243,11 +281,12 @@ object GpuOrcTimezoneUtils {
if (dType == DType.TIMESTAMP_DAYS && !writerUsedProlepticGregorian) {
DateTimeRebase.rebaseJulianToGregorian(col)
} else if (dType.hasTimeResolution) {
convertOrcTimestamp(col, tzCtx.get, readerZone)
convertOrcTimestamp(col, tzCtx.get, readerZone, legacyTimestampRebase)
} else if (dType == DType.LIST) {
val child = addToClose(col.getChildColumnView(0))
val newChild = rebaseNestedWithWriterTimezone(
child, tzCtx, readerZone, writerUsedProlepticGregorian, toClose)
child, tzCtx, readerZone, legacyTimestampRebase,
writerUsedProlepticGregorian, toClose)
if (newChild ne child) {
col.replaceListChild(addToClose(newChild))
} else {
Expand All @@ -258,7 +297,8 @@ object GpuOrcTimezoneUtils {
val newViews = (0 until col.getNumChildren).map { i =>
val child = addToClose(col.getChildColumnView(i))
val newChild = rebaseNestedWithWriterTimezone(
child, tzCtx, readerZone, writerUsedProlepticGregorian, toClose)
child, tzCtx, readerZone, legacyTimestampRebase,
writerUsedProlepticGregorian, toClose)
if (newChild ne child) {
childChanged = true
addToClose(newChild)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* 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.{BinaryOp, ColumnVector, ColumnView, DType}
import ai.rapids.cudf.{HostColumnVector, OrderByArg, Table}
import com.nvidia.spark.rapids.Arm.{closeOnExcept, withResource}

import org.apache.spark.sql.rapids.RebaseDateTimeBridge

private[rapids] object GpuTimestampRebaseUtils {

private def retainOrCopy(input: ColumnView): ColumnVector = input match {
case columnVector: ColumnVector => columnVector.incRefCount()
case _ => input.copyToColumnVector()
}

private def isModernOrAllNull(input: ColumnView): Boolean = {
withResource(input.min()) { minValue =>
!minValue.isValid || minValue.getLong >= RebaseDateTimeBridge.lastSwitchJulianTs
}
}

final class JulianToGregorianMicrosContext(
timeZoneId: String,
switches: Option[Table],
paddedDiffs: Option[Table]) extends AutoCloseable {

private def rebaseOnHost(input: ColumnView): ColumnVector = {
val rowCount = input.getRowCount.toInt
withResource(input.copyToHost()) { hostInput =>
withResource(HostColumnVector.builder(DType.TIMESTAMP_MICROSECONDS, rowCount)) { builder =>
var row = 0
while (row < rowCount) {
if (hostInput.isNull(row)) {
builder.appendNull()
} else {
builder.append(RebaseDateTimeBridge.rebaseJulianToGregorianMicros(
timeZoneId, hostInput.getLong(row)))
}
row += 1
}
withResource(builder.build()) { hostOutput =>
hostOutput.copyToDevice()
}
}
}
}

private def rebaseWithSearchColumn(
input: ColumnView,
searchColumn: ColumnVector): ColumnVector = {
withResource(new Table(searchColumn)) { searchTable =>
withResource(switches.get.upperBound(searchTable, OrderByArg.asc(0, false))) { indices =>
val hasBeforeFirstSwitch = withResource(indices.min()) { minIndex =>
minIndex.isValid && minIndex.getInt == 0
}
if (hasBeforeFirstSwitch) {
// Spark's precomputed maps intentionally stop at the Common Era boundary. Preserve
// exact Spark semantics for rarer BCE data by using its Calendar-based slow path.
rebaseOnHost(input)
} else {
withResource(paddedDiffs.get.gather(indices)) { gatheredDiffs =>
input.binaryOp(
BinaryOp.ADD, gatheredDiffs.getColumn(0), DType.TIMESTAMP_MICROSECONDS)
}
}
}
}
}

def rebase(input: ColumnView): ColumnVector = {
require(input.getType == DType.TIMESTAMP_MICROSECONDS,
s"expected TIMESTAMP_MICROSECONDS but found ${input.getType}")
if (input.getRowCount == 0 || isModernOrAllNull(input)) {
retainOrCopy(input)
} else if (switches.isEmpty) {
// Spark's bundled map can lag valid IDs added by newer JDK timezone databases.
rebaseOnHost(input)
} else {
input match {
case columnVector: ColumnVector =>
rebaseWithSearchColumn(input, columnVector)
case _ =>
withResource(input.copyToColumnVector()) { searchColumn =>
rebaseWithSearchColumn(input, searchColumn)
}
}
}
}

override def close(): Unit = {
try {
switches.foreach(_.close())
} finally {
paddedDiffs.foreach(_.close())
}
}
}

final class LazyJulianToGregorianMicrosContext(timeZoneId: String) extends AutoCloseable {
private var delegate: JulianToGregorianMicrosContext = _

def rebase(input: ColumnView): ColumnVector = {
require(input.getType == DType.TIMESTAMP_MICROSECONDS,
s"expected TIMESTAMP_MICROSECONDS but found ${input.getType}")
if (input.getRowCount == 0 || isModernOrAllNull(input)) {
retainOrCopy(input)
} else {
if (delegate == null) {
delegate = createJulianToGregorianMicrosContext(timeZoneId)
}
delegate.rebase(input)
}
Comment on lines +130 to +134

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.

Updated.

}

override def close(): Unit = if (delegate != null) {
delegate.close()
delegate = null
}
}

def createJulianToGregorianMicrosContext(
timeZoneId: String): JulianToGregorianMicrosContext = {
RebaseDateTimeBridge.getJulianToGregorianMicros(timeZoneId).map { info =>
require(info.switches.nonEmpty, s"empty Spark timestamp rebase map for '$timeZoneId'")
require(info.switches.length == info.diffs.length,
s"invalid Spark timestamp rebase map for '$timeZoneId'")

val switchTable = withResource(
ColumnVector.timestampMicroSecondsFromLongs(info.switches: _*)) { switchColumn =>
new Table(switchColumn)
}
closeOnExcept(switchTable) { _ =>
// upperBound returns 0 before the first switch and k + 1 at switch k. The leading
// sentinel aligns every valid upper-bound index directly with its Spark rebase diff.
val padded = new Array[Long](info.diffs.length + 1)
System.arraycopy(info.diffs, 0, padded, 1, info.diffs.length)
val diffTable = withResource(
ColumnVector.durationMicroSecondsFromLongs(padded: _*)) { diffColumn =>
new Table(diffColumn)
}
new JulianToGregorianMicrosContext(
timeZoneId, Some(switchTable), Some(diffTable))
}
}.getOrElse {
new JulianToGregorianMicrosContext(timeZoneId, None, None)
}
}
}
Loading
Loading