peterxcli opened a new issue, #5770:
URL: https://github.com/apache/datafusion-comet/issues/5770

   ### What is the problem the feature request solves?
   
   Comet's date/time extraction paths reconstruct a `chrono` calendar datetime 
per row to produce
   values that are a pure function of the epoch day or the microsecond-of-day. 
The reconstruction
   is the expensive part and it is not needed for these fields.
   
   Measured on `main` (`6368c4e61`), 8192-row batches, aarch64 (Apple Silicon), 
Criterion, quiet
   machine. Two independent harnesses agree: an in-tree bench calling the real 
Comet UDFs
   (`ci` profile), and a standalone arrow-only bench built under comet's 
`[profile.release]`
   settings. Every bench asserts the replacement is bit-identical to the 
current path before it
   times anything.
   
   | path | current | integer kernel | speedup |
   | --- | --- | --- | --- |
   | `hour`/`minute`/`second`, `Timestamp` + UTC session | 93.4-106.6 us | 
4.4-7.1 us | **14.1-23.4x** |
   | `hour`/`minute`/`second`, `TimestampNTZ` | 48.3-61.2 us | 4.4-7.8 us | 
**7.0-12.6x** |
   | `dayofweek` / `weekday` on `Date32` | 44.9-49.3 us | 3.2-3.5 us | 
**12.9-14.1x** |
   | `iceberg_years`, year-only split | 17.2-18.5 us | 14.6-14.7 us | 
1.18-1.25x |
   
   (The release-profile harness, which omits Comet's UDF wrapper, gives the 
same ordering with
   smaller ratios: 9.8-16.1x, 6.2-9.6x, 9.4-10.3x, 1.12x.)
   
   **1. `hour` / `minute` / `second` -- the largest win**
   
   `native/spark-expr/src/datetime_funcs/extract_date_part.rs` already 
separates `TimestampNTZ`
   from timezone-aware timestamps, then calls arrow's `date_part`, which builds 
a datetime per row.
   For NTZ, and for timezone-aware timestamps in a UTC session, the clock 
fields are arithmetic on
   the stored microseconds:
   
   ```rust
   const MICROS_PER_DAY: i64 = 86_400_000_000;
   
   fn hour(micros: i64) -> i32 { (micros.rem_euclid(MICROS_PER_DAY) / 
3_600_000_000) as i32 }
   fn minute(micros: i64) -> i32 { micros.div_euclid(60_000_000).rem_euclid(60) 
as i32 }
   fn second(micros: i64) -> i32 { micros.div_euclid(1_000_000).rem_euclid(60) 
as i32 }
   ```
   
   Euclidean division is load-bearing: at UTC, `-1` microsecond is `1969-12-31 
23:59:59.999999`,
   and truncation toward zero gives the wrong second.
   
   Worth calling out: **a UTC-tagged timestamp costs about twice an NTZ one** 
through `date_part`
   (103.5 vs 61.2 us for `hour`) even though UTC applies no offset -- the tag 
alone forces the
   timezone-aware datetime construction. A UTC fast path erases that entirely. 
For reference, a
   real offset zone (`America/Los_Angeles`) costs 179-199 us and is *not* 
replaceable by this.
   
   **2. `dayofweek` / `weekday` on `Date32`**
   
   `CometDayOfWeek` and `CometWeekDay` in 
`spark/src/main/scala/org/apache/comet/serde/datetime.scala`
   emit `datepart('dow', child) + 1` and `datepart('isodow', child) - 1`. 
`datepart` resolves to
   DataFusion's `date_part`, which for `Date32` runs
   
   ```rust
   // arrow-arith/src/temporal.rs:405
   Ok(self.unary_opt(|d| date32_to_datetime(d).map(map_func)))
   ```
   
   -- a `NaiveDateTime` per row plus a recomputed null mask -- and the `+ 1` / 
`- 1` is a second
   pass allocating another `Int32Array`. Both collapse to one modulo:
   
   ```rust
   /// Spark: Sunday = 1, ..., Saturday = 7
   fn spark_dayofweek(days: i32) -> i32 { ((i64::from(days) + 4).rem_euclid(7) 
+ 1) as i32 }
   
   /// Spark: Monday = 0, ..., Sunday = 6
   fn spark_weekday(days: i32) -> i32 { (i64::from(days) + 3).rem_euclid(7) as 
i32 }
   ```
   
   Epoch day 0 is 1970-01-01, a Thursday: `5` and `3`. Widening to `i64` avoids 
overflow at
   `i32::MAX`. This one also deletes a plan node, since the `+1`/`-1` folds 
into the kernel.
   
   The `Int32` cast in the serde is already free -- `date_part` returns `Int32` 
for these parts in
   DF 55 and `cast.rs:230` short-circuits an identity cast -- so it is not part 
of the win.
   
   **3. `iceberg_funcs::temporal::civil_from_days` -- measured, and mostly not 
worth it**
   
   The shipped Hinnant split vectorizes well once inlined (17.2 us for 8192 
rows). Two variants
   were benchmarked:
   
   - **Year-only** (skip the `/153` and the month remap for `iceberg_years`, 
using
     `day_of_year >= 306` as the Jan/Feb test): **1.18-1.25x**. Real but small.
   - **Biased epoch** (add `146_097 * 14_700` so the era split uses plain `/` 
and `%` instead of
     `div_euclid`/`rem_euclid`): **0.65-0.82x -- a regression**, consistently 
across all four
     groups and both harnesses. LLVM handles the Euclidean correction for a 
constant divisor
     better than the extra bias arithmetic costs. Recorded so nobody tries it 
again.
   
   So this is the weakest of the three targets, not the best starting point.
   
   ### Describe the potential solution
   
   In value order:
   
   1. An integer fast path in `extract_date_part.rs` for `TimestampNTZ` and for 
`Timestamp` with a
      UTC session timezone, dispatched **once per batch**, falling through to 
the existing
      timezone-aware path otherwise.
   2. Native `spark_dayofweek` / `spark_weekday` kernels over `Date32`, folding 
the `+1` / `-1` in
      so the serde stops emitting a separate math node.
   3. Optionally the year-only split for `iceberg_years`. Low value; skip if it 
complicates the
      shared helper.
   
   Constraints that should not move:
   
   - Preserve null bitmaps, dictionary handling and sliced-array behaviour. The 
dictionary return
     type in `extract_date_part.rs` is part of the contract.
   - Keep NTZ vs timezone-aware semantics as they are. A UTC fast path is not 
licence to assume a
     fixed offset for a batch spanning a DST transition.
   - `dayofweek` (Sunday=1..7) and `weekday` (Monday=0..6) use different 
numbering;
     `second(timestamp)` is integer seconds, distinct from fractional-second 
extraction; ISO
     week/year is week-based-year arithmetic, not weekday arithmetic.
   - If this is generalized to `year` / `month` / `dayofmonth`, note DF 55's 
`date_part` implements
     `preimage` (`datafusion-functions-55.0.0/src/datetime/date_part.rs:263`), 
rewriting a year
     predicate into a date range. It only fires for `YEAR`, so `dow`/`isodow` 
lose nothing, but
     replacing `year` with a Comet UDF would give up that pruning.
   - Full `i32` epoch-day range must keep working -- the Iceberg transform 
covers `i32::MIN`/`MAX`
     days (years -5877641 to 5881580), outside chrono's range.
   
   Acceptance:
   
   - Kernel equivalence over a full 400-year cycle, negative epochs, `i32` 
boundaries, plus null /
     dictionary / sliced arrays.
   - Differential Spark tests with Comet on and off across session timezones, 
NTZ, non-hour
     offsets and DST transitions, asserting the native expression actually runs 
so a fallback
     cannot hide a broken kernel.
   - A query-level measurement, which this issue does **not** have (see below).
   
   ### Additional context
   
   Prompted by the fast-date / fast-day-of-week / fast-time-of-day articles at
   https://www.benjoffe.com/, though none of that code is used here -- the 
kernels above are plain
   Euclidean arithmetic. No third-party fast-calendar implementation was 
ported: doing so means
   transcribing constants that cannot be checked against the source, and those 
implementations use
   narrow intermediates in places, so a naive port can overflow near `i32::MIN`.
   
   **Scope of the evidence.** These are kernel microbenchmarks on 8192-row 
batches, not a Spark
   query. They show the expression is 7-23x cheaper; they do **not** show any 
end-to-end query is
   faster, which depends on what fraction of query time these expressions 
occupy. A
   plan-inspected, consumption-forced query benchmark
   (`SELECT sum(hour(ts)), sum(minute(ts)) ...`) is still needed, on x86-64 as 
well -- all numbers
   here are aarch64.
   
   One methodology note for anyone re-running this: passing the kernel to
   `PrimitiveArray::unary` through a `black_box`ed function pointer blocks 
inlining and
   vectorization, and understates the kernel by roughly 2-3x. It also inverted 
the target 3
   conclusion -- the biased-division variant looked like a 1.2x win when both 
arms were
   pessimized, and is a 0.75x regression once they inline. Pass the kernel as a 
generic
   `F: Fn(..) -> i32 + Copy` and `black_box` the array instead.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to