andygrove opened a new pull request, #6566:
URL: https://github.com/apache/datafusion-comet/pull/6566

   ## Which issue does this PR close?
   
   Part of #6565. This PR does not close it.
   
   ## Rationale for this change
   
   `CometSparkToColumnarExec` converts Spark rows and Spark column vectors to 
Arrow through `ArrowWriter`, which `CometLocalTableScanExec` and the Comet 
cache serializer also use. Most common column types were written one value at a 
time:
   
   - decimals through Arrow's `BigDecimal` setter, which allocates a 
`BigInteger` and two byte arrays per value
   - strings through `setSafe`
   - arrays, maps and structs element by element
   
   Fixed-width columns lost the bulk copy from #5442 as soon as a batch held 
one null or was dictionary-encoded. #6565 has the per-type measurements.
   
   End to end, on Apple M3 Max, JDK 17, the Spark 4.1 profile and a release 
`libcomet`. Each number is the best of 5 runs at `local[1]` over 4M rows, with 
Spark's vectorized Parquet reader feeding Comet through 
`spark.comet.convert.parquet.enabled`. The cache queries use Spark's default 
cache serializer. Query results are identical on both builds.
   
   | Query | Wall time (ms) | `conversionTime` (ms) | Arrow batches |
   |---|---|---|---|
   | Parquet → TPC-H Q1-style aggregate | 1088 → 548 | 549 → 80 | 977 → 489 |
   | Parquet → `sum(length(string))` | 193 → 124 | 73 → 10 | 977 → 489 |
   | Parquet → `array<string>`, `struct`, `map<string,string>` | 345 → 225 | 
158 → 61 | 245 → 123 |
   | Parquet → int, long, double | 158 → 126 | 31 → 11 | 977 → 489 |
   | Cache (row output) → Q1-style aggregate | 1185 → 814 | 820 → 485 | 489 |
   | Cache (columnar output) → int, long, double | 76 → 64 | 24 → 9 | 800 → 489 
|
   
   Per column type, in ns per row with one column and 8192-row batches. These 
come from a JMH harness that ran `main`'s `ArrowWriter` and this one in 
alternating forks:
   
   | Columnar input | 0% nulls | 10% nulls |
   |---|---|---|
   | int | 0.55 → 0.55 | 2.37 → 0.85 |
   | long | 1.02 → 1.04 | 4.10 → 1.42 |
   | boolean | 2.00 → 0.38 | 2.32 → 1.01 |
   | decimal(9,2) | 34.5 → 1.5 | 37.7 → 1.9 |
   | decimal(18,2) | 49.9 → 1.6 | 55.2 → 1.9 |
   | decimal(38,10) | 60.9 → 6.0 | 56.2 → 7.3 |
   | string | 14.9 → 1.5 | 14.2 → 2.0 |
   | int, dictionary-encoded | 2.42 → 0.63 | 2.58 → 1.06 |
   | string, dictionary-encoded | 15.9 → 5.9 | 13.0 → 6.7 |
   | struct<int,long,double,date> | 34.4 → 3.7 | 37.3 → 5.5 |
   | map<string,string> | 229 → 22.4 | 189 → 22.4 |
   | array<int>, Parquet reader layout | 50.4 → 9.2 | 28.3 → 12.5 |
   | array<string>, Parquet reader layout | 86.8 → 14.0 | 75.4 → 15.0 |
   | array<struct<int,string>>, Parquet reader layout | 172 → 25.2 | 144 → 67.7 
|
   
   "Parquet reader layout" means a child slot for every null or empty 
collection, which is how Spark's nested Parquet reader lays out its vectors. 
The worst case for the array path is arrays of zero to two elements with half 
the rows null, so that nearly every row is its own run of children. That case 
is roughly even (0.9-1.1x).
   
   On row input, decimal(9,2) and decimal(18,2) go from 105-157 to 8.5-9.5 ns 
per row, and decimal(38,10) from 86 to 21. The other row types are unchanged.
   
   The machine was shared with other jobs while I measured, so take the numbers 
as indicative.
   
   ## What changes are included in this PR?
   
   All of the columnar fast paths produce exactly what the row path writes for 
the same rows. That includes the fields of a null struct coming out null, and a 
decimal past 18 digits that overflows its precision still failing as Spark's 
`getDecimal` does.
   
   - **Decimals.** The unscaled value is written directly, as a sign-extended 
long up to 18 digits. Past 18 digits it is written from its two's-complement 
bytes, which are range-checked in place for Spark's vectors and unsafe rows. 
The `Decimal` that `getDecimal` would build is only built for a value that does 
not fit.
   - **Fixed-width columns.** These are copied in bulk from Spark's vectors 
whether or not they hold nulls. Off-heap vectors are copied straight from their 
memory. Dictionary-encoded and other vectors are read value by value, skipping 
null rows, because Spark 3.4's bulk getters decode ids under nulls. Validity is 
written eight rows to a byte, and booleans are packed the same way.
   - **Strings and binary.** Offsets and validity are written in one pass, and 
the bytes are copied in one block (or one run of adjacent rows at a time). A 
dictionary-encoded column decodes each id once per input batch and copies the 
bytes for later rows. That cache is dropped at every input batch, because 
Spark's readers reuse a vector across row groups whose dictionaries differ.
   - **Arrays and maps.** Offsets are written in one pass and the elements are 
written in runs of adjacent rows. Spark's nested Parquet reader leaves a child 
slot for each null or empty collection, so a batch's elements are rarely 
contiguous.
   - **Structs.** These are written field by field. The fields of a null struct 
are then nulled, unless a field is an array or a map, in which case a struct 
with nulls takes the row path as before.
   - **`SparkColumnarArrowReader`.** It now fills each Arrow batch across Spark 
batches, so the 4096-row batches of Spark's Parquet reader and the 10000-row 
batches of its cache become 8192-row batches. `ArrowWriter.writeColumns` 
appends rather than overwriting its row count. Pulling the next source batch no 
longer counts as conversion time.
   - **Row input.** `CometSparkToColumnarExec` and `CometLocalTableScanExec` 
count input rows once per batch instead of through a per-row iterator callback.
   
   Two dispatch tests in `CometArrowStreamSuite` asserted that 
dictionary-encoded vectors and slices under 32 rows go through Arrow's 
per-value setters. They now assert that they don't.
   
   ## How are these changes tested?
   
   - A new `CometArrowWriterSuite` writes columnar slices in uneven appended 
chunks into an undersized root, and checks that every vector matches what the 
row path writes for the same rows, including fields under null parents. It 
covers:
     - 17 primitive types, plain and dictionary-encoded, with the ids under 
null rows out of range
     - 11 nested types, including child vectors laid out backwards
     - on-heap and off-heap vectors
     - 0%, 20% and 100% nulls
   
     It also checks decimals at every storage width, a wide decimal overflow, 
nulled struct fields, and dictionaries that change between the input batches 
the reader combines.
   - A new `CometArrowStreamSuite` test fills Arrow batches from input batches 
that reuse one set of vectors.
   - `CometArrowWriterSuite`, `CometArrowStreamSuite` and 
`CometStringWriterSuite` pass on Spark 4.1 and on Spark 3.4 / Scala 2.12.
   - On Spark 4.1, these also pass: the `SparkToColumnar` and `LocalTableScan` 
tests in `CometExecSuite`, `CometInMemoryCacheSuite`, 
`CometMapExpressionSuite`, `CometArrayExpressionSuite` and 
`CometTaskMetricsSuite`. All of them except `CometTaskMetricsSuite` also pass 
on 3.4.
   - The semantic scalafix check passes on the 3.5 profile.
   - Outside the repo, a harness compared this writer with `main`'s over about 
11,000 cases. It covered Spark's nested Parquet reader layouts, dictionaries, 
off-heap vectors, slices, appends and decimal precision boundaries, and caught 
each of the bugs I injected on purpose.
   


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