The native Parquet reader (VectorParquetScanExec)
This page is the design note for VecRuntime's own Parquet scan, #559. It covers what the scan does today, how it is put together, what it does not decode yet, and the work that is left.
In short (2026-10-18): the scan reads every Parquet type Spark 4.1 reads and every encoding parquet-java's v1 and v2 writers produce, natively: flat types, unsigned integers, integers read as wide decimals, ANSI intervals, Spark 4.1's time (any INT64 unit) and Spark 4's variant, shredded or not, with variant extraction pushed into the scan (#787, #812). Structs, lists and maps nested in one another at any depth are read natively too (#568), and so are INT96 timestamps and files in any calendar rebase mode, files older than the table (missing columns, existence defaults, widened types), collated strings, bucketed tables and the _metadata columns. Over the whole apache/parquet-testing and Hardwood corpora (303 files, #808), every file gives Spark's rows or Spark's refusal, and all but two are read natively. What still goes to Spark's reader is listed, with the reason, under Current limits. On the 1 TB TPC-DS run of 2026-10-02 it took VecRuntime to 1,542 s for the 103 queries, against 3,099 s for Spark, 1,999 s for Comet and 1,954 s for Comet's reader under our operators.
The scan is on by default since 0.0.6 (spark.vecruntime.scan.nativeParquet.enabled), except in a session where Comet's scan is active: Comet's plugin or extension registered, with spark.comet.enabled and spark.comet.scan.enabled not false. There Comet's reader stays the scan unless the key is set. Setting the key to false brings back Spark's vectorized reader under our operators. Its configuration keys are in the Configuration reference, and the planner's fallback reasons are on the Operators page.
Why the scan exists
Spark's vectorized Parquet reader decodes into on-heap ColumnVectors. VecRuntime's operators then copy those batches into native Arrow memory, once per operator chain. Comet's native scan avoids that copy, but it brings its own operators and memory model.
VectorParquetScanExec decodes Parquet pages straight into the Arrow buffers that VecRuntime's operators read, so there is no conversion between the scan and the first operator. It reuses everything Spark already computed for the scan:
- the selected partitions, including dynamic partition pruning;
- the file splits and bucketing;
- the pushed data filters;
- the required schema and the partition schema.
Data path
FileSourceScanExec (planned by Spark)
└─ VectorParquetScanExec (replaces it when the plan is supported; VectorParquetScanPlanner)
└─ VectorParquetRDD (one partition per Spark FilePartition)
└─ VectorParquetPartitionReader (task thread)
├─ prepare(file) open: getFileStatus, footer, clip to the required schema,
│ encoding check, ParquetFileReader.open, first filtered row group
├─ install(prepared) column readers, partition values, or the per-file fallback
└─ next() NativeParquetColumnReader per column → ColumnChunkDecoder (kernels)
→ batch-owned Arrow vectors + constant partition columns
Opening a file. Each split opens its file once. The FileStatus comes from the split instead of a separate HEAD request (#559 slice 1). The footer is read in a single stream, and readNextFilteredRowGroup applies the pushed filters through parquet-java: row-group statistics, dictionary and bloom filters, and the column index for page skipping (#568), off by default since #873 (below): with it on, pages outside the filter's row ranges are dropped and each column reader decodes only the selected rows. Rows are not otherwise filtered inside the decoder. Each task prefers the same hosts as Spark's FileScanRDD: up to three holding the most of its bytes, with localhost dropped. S3A reports localhost for every block, so on S3 the tasks have no preference, as Spark's do; on HDFS they keep block locality. Comet's equivalent switch, spark.comet.parquet.rowFilterPushdown.enabled, defaults to off as well.
Decoding. NativeParquetColumnReader turns each DataPageV1 or DataPageV2 into a kernel page. Definition levels and values are written into one reusable buffer per reader. ColumnChunkDecoder then decodes, at a row offset into the output:
- the validity bitmap, built from the definition levels;
- fixed-width lanes, written straight into the Arrow data buffer;
- dictionary indices, resolved against a decoded dictionary held in a GC-managed arena, for every lane but strings;
- for a string column (#612,
…dictionaryStrings, on by default), dictionary ids instead: the batch is aVectorDictionaryColumnVectorover one Arrow dictionary per row group, shared by its batches and reference-counted, closed when the reader and the last batch let go. A data page that falls back from the dictionary (parquet-java does so mid-chunk once the dictionary outgrows its page limit) resolves the ids already in the batch into bytes, and the rest of the row group decodes flat.
Parquet-java's BytePacker is injected for the bit-unpacking. Vectors are pooled and reused from one row group to the next.
Per-file fallback. A file whose column-chunk metadata lists an encoding the decoder does not handle goes to SparkFallbackFileReader, which is Spark's own vectorized reader for that one file. The check runs when the file is opened, so results stay correct and nothing fails part-way through a decode.
Read-ahead
At 1 TB, TPC-DS store_sales is about 14.6k files of about 7 MB, and most of them hold a single row group. When everything ran on the task thread, JFR showed task threads parked on S3 for at least 66% of their time, and executors used 2–3 of their 13 cores.
The read-ahead fixes that with two independent depths:
| Key | Default | What runs in the background |
|---|---|---|
…nativeParquet.prefetchFiles |
12 | The next N files of the split: prepare(file) in full, including the first row group. |
…nativeParquet.prefetchRowGroups |
4 | The next M row groups of the current file. |
A few rules hold the design together:
- Ordering. Row-group reads form a chained
CompletableFuturepipeline: each read starts after the previous one completes. AParquetFileReaderis therefore used by one thread at a time, and row groups arrive in file order. - Decode on the task thread. Pages stay compressed until the task thread decompresses and decodes them, in order.
- Task context. Prefetch steps run with the task's
TaskContextset. Files opened ahead and not used are closed at task end, including when a LIMIT stops the task early. - Platform threads. The pool is cached daemon platform threads; the number alive is bounded by task slots × depth. Virtual threads deadlocked on JDK 25. A file open runs class initializers (Parquet's
BloomFilterImpl, the S3 client), and a virtual thread blocked in a class initializer still pins its carrier. On q88, every carrier of one executor was pinned behindBloomFilterImpl.<clinit>, which was waiting for a log4j lock held by an unmounted virtual thread.VectorParquetPrefetchPoolSuitechecks the pool's thread kind. - Metrics. Task input metrics follow
FileScanRDD: records are counted per batch, and bytes are the task thread's FileSystem read count plus the bytes each prefetch step read on its own thread. - Memory. Per task, read-ahead adds up to N opened files and M row groups of compressed pages, capped in bytes by
prefetchMaxBytes(default 256 MiB, #568; see the work list).
Decode-ahead (#606, off by default). With …nativeParquet.decodeAhead = K, the reader loop runs on a producer thread of the task's own, up to K batches ahead (DecodeAheadIterator). The task thread takes batches in order and runs the operators above the scan on them, while the producer decodes the next ones across row groups and files.
- Ownership. A batch is the consumer's until its next
next(), when its vectors go back to their column reader's pool. The pool is guarded for the two threads; a vector released after its file closed is closed instead. - Fallback files. A batch of Spark's fallback reader is recycled by that reader, so the producer waits until the consumer has moved past it.
- Threads. A fresh virtual thread per task by default, or a platform thread. The first batch is decoded on the task thread, so the decoder's classes are initialized before a virtual thread runs it; file opens stay on the platform prefetch pool. A fresh thread also keeps the task's FileSystem byte count right.
- Close. At task end, LIMIT or failure, the producer is stopped, the queue is drained, the producer is joined, and only then is the reader closed. A producer failure is rethrown on the task thread in order.
- Tests.
DecodeAheadSuitechecks results against Spark's reader for K = 1 and 4 on both thread kinds, across row groups and files; a LIMIT that ends the task early, with the allocator back at its baseline; a table mixing native and fallback files; and a producer failure.
Decoding the next row group ahead within a split. The two stages compose into exactly that: prefetchRowGroups reads the next row group's compressed pages ahead (chained, in order), and decodeAhead decompresses and decodes a row group's pages on the producer thread while the task thread consumes the batches of the row group before it. Both run on the bounded prefetch pool and both reserve against the same execution-memory accounting (ReadAheadMemory, #915) — compressed pages while a row group waits, decoded Arrow bytes while a batch is queued — given back when the row group or batch is used or dropped, so a task ended mid-split (a LIMIT, a kill) releases every reserved byte, held byte and Arrow vector through the close path. Row order is preserved end to end. The decode stays on one producer thread rather than a second column-reader set per in-flight row group: a row group's column readers are stateful and single-instance, decode is CPU-bound (a second decode thread per task would contend for the same cores, not overlap I/O the way prefetchRowGroups already does), and the existing producer already overlaps decode with the operators above the scan. NativeParquetDecodeAheadSuite drives multi-row-group splits with both stages on: rows equal the sequential path, order holds, a mid-split LIMIT releases all read-ahead memory and vectors, and a local timing confirms the ahead path is no slower.
Arena lifetime. A column reader exists per column per file, so it must not close a shared FFM arena. Closing a shared arena triggers a JVM-wide handshake, and on q88 those handshakes cost 8–9% of executor CPU; with GC-managed scratch they are 0.2–0.4%.
How reads are issued (#834)
On the classic S3A stream each ParquetFileReader read is its own request, and at 1 TB the scan-bound queries were request-latency-bound, not byte-bound: store_sales is ~14.6k files of ~7 MB, mostly one row group, so a file costs a footer-length read, a footer read, the row-group reads and — with the column index on — a GET per selected page. Switching S3A off the Analytics Accelerator (AAL 1.3.1) to the classic stream took the 103-query suite from 846 s to 664 s but gave up three things AAL did; #834 does two of them inside our scan, scoped to what the reader already knows (the file size from the split, the footer, the exact column chunks), with the Accelerator's design as reference but none of its process-wide shared state. Both are off by default until a 1 TB A/B sets the thresholds; neither changes results (same rows, same checksums — NativeParquetRangeMergeSuite).
1. Small-object prefetch (…nativeParquet.smallFileThreshold, a byte size; 0 off). A file at or under the threshold is fetched whole in one ranged GET, on the prefetch thread, before the footer is read; ParquetFileReader is then handed a BufferBackedSeekableInputStream over that buffer, so the footer, every column chunk and the page indexes all come from memory with no further request. That turns the per-file footer-length + footer + row-group(s) (+ per-page, under the column index) reads into one. The buffer is heap byte[], bounded by the existing read-ahead budgets (prefetchExecutorMaxBytes). The threshold itself is the gate, as AAL prefetches a small object whole; size it, from AnyBlob's (PVLDB 2023) measured optimal request size, at the point where reading every byte beats several ranged reads (the ~7 MB store_sales files are the target).
2. Range merging (…nativeParquet.rangeMergeGap, a byte size; -1 off, 0 adjacent-only). When a file is not read whole, ParquetReadRangePlanner plans the exact byte ranges the reader needs — the projected column chunks of the row groups the split keeps, and with the column index on only the selected pages plus the offset/column index reads — and (a) coalesces ranges whose gap is at or under rangeMergeGap into one larger GET, and (b) splits a coalesced run longer than rangeTargetSize (× a 1.4 tolerance ratio, short tail folded back) into parts issued in parallel on the prefetch threads, so one long run is not a single giant stream that starves bandwidth (DuckDB 2.0's too-few-too-large result). The planned ranges are read into a BufferBackedSeekableInputStream and served to parquet-java; the original stream is kept as the fallback for any byte the plan did not cover, so a miss is correct, never wrong. It engages only when it reduces the request count. The planner is pure arithmetic over half-open ranges and is unit-tested on its own (ParquetReadRangePlannerSuite): the coalesce/split invariant is that every input byte lands in exactly one sorted, non-overlapping output range.
Both buffers live for the file's reader and are read on the prefetch thread, so they fit the existing single-owner-per-reader rule (no ParquetFileReader touched by two threads). Request-count reductions are proven with a counting FileSystem (NativeParquetRangeMergeSuite): a small file reads in one request (footer and chunks from the buffer), and with the column index on a selective filter over a small file is still one request rather than one GET per selected page. Not done here: the block cache (#834 part 3) — our scan reads each byte range once, so a general cache buys little; a footer cache is left for a measurement that shows reuse.
Support matrix
| Decoded natively | Notes | |
|---|---|---|
| Types with an engine lane | boolean, tinyint, smallint, int, bigint, float (FLOAT64 lane, #712), double, date, timestamp and timestamp_ntz (INT64 MICROS/MILLIS), decimal up to precision 38, UTF8 string (a non-default collation too, #568/#705), binary (UTF8 lane, #705); time (any INT64, nanoseconds of the day, #787/#812) and interval day to second on INT64, interval year to month on INT32 (#812): carried, compared, null-tested, grouped, sorted, joined and windowed, with count / min / max / first / last; arithmetic, casts, functions, sum and avg over them stay Spark's |
NativeParquetSupport.isReadable; the planner and the reader share this check |
| Types carried without a lane | variant (the struct of its value and metadata binaries, #787; shredded at the top level rebuilt from its typed columns, #812). No lane by design: Spark defines no equality or order on a variant (it can be neither a grouping, join nor sort key), and every function over one interprets its binary encoding |
Decoded on the lane of the same layout and emitted as vectors Spark reads; operators carry it, and an operator that computes on one stays Spark's |
| Physical storage | INT32, INT64, DOUBLE, FLOAT, BOOLEAN, BINARY; FIXED_LEN_BYTE_ARRAY for decimals and binary; INT96 timestamps; unsigned integers (UINT_32 as bigint, zero-extended; UINT_64 as decimal(20,0), #812); INT32 and INT64 read as decimals wider than 18 digits (#812) |
Checked per file at open (physicalMatches), including the decimal scale |
| Encodings | PLAIN, PLAIN_DICTIONARY, RLE_DICTIONARY; RLE for booleans; DELTA_BINARY_PACKED on INT32/INT64; DELTA_LENGTH_BYTE_ARRAY on BINARY; DELTA_BYTE_ARRAY on BINARY and FIXED_LEN_BYTE_ARRAY; BYTE_STREAM_SPLIT on INT32/INT64/FLOAT/DOUBLE and FIXED_LEN_BYTE_ARRAY (#812); definition levels in RLE/BIT_PACKED |
VectorParquetScanExec.supportsEncoding |
| Calendar | Every rebase mode: CORRECTED as stored, LEGACY rebased from the hybrid calendar, EXCEPTION failing on an ancient value with Spark's error; INT96 with its own mode and time-zone conversion (#568) |
Resolved per file from the footer and the session, as Spark resolves it (ParquetRebase) |
| Data pages | v1 and v2 | v2 levels and data are read separately |
| Schema | Flat columns; structs, structs of structs included; top-level lists and maps of flat types, lists of structs of flat types, lists of lists of any depth ending in a flat type, maps of structs of flat types, any of these as a struct's field, and any other nesting of lists, maps and structs (#568) | A nested column holding a type with no decode path keeps Spark's scan for the plan |
| Metadata columns | The file-constant _metadata fields (file_path, file_name, file_size, file_block_start, file_block_length, file_modification_time), _metadata.row_index, and input_file_name() with its block siblings (#568) |
Constants per file, from the format's extractors as FileScanRDD fills them; the row index is the row group's first row index plus the position. Under decode-ahead too (#568) |
| Codecs | Whatever parquet-java decompresses (snappy, zstd, gzip, lz4, lz4_raw, brotli and lzo with their codec jars, uncompressed) | zstd and snappy decompress into one reused buffer per column chunk, which the decoder reads in place (#810, #786); uncompressed pages are read in place; every other codec is parquet-java's own decompressor, one copy per page. See Codecs under Current limits |
What happens when a column is not supported: the scan keeps Spark's result in every case, through Spark's reader for one file or Spark's scan for the plan, as listed under Current limits below. input_file_name() under decode-ahead is native (#568). A bucketed scan is native (#568): it reports Spark's bucket partitioning and reads Spark's own bucketed file partitions (pruned and coalesced as Spark plans them), so a join of tables bucketed alike still needs no shuffle.
Conformance corpus (#808). ParquetCorpusConformanceSuite reads every file of apache/parquet-testing and of Hardwood's test corpus and pins how each one is read; see "Whole-corpus conformance" below.
Current limits
Everything below returns Spark's own result: either Spark's reader decodes it, or Spark's scan stays in the plan, or the query fails with Spark's error. None of these limits is a wrong answer. The planner records a reason for each whole-plan fallback; the Operators page lists them.
On Spark's reader on purpose, because Spark's outcome there is an error, or a reading only Spark's reader defines:
- Encrypted Parquet (modular encryption) is not a native feature. Decryption is parquet-java's: with Spark's crypto factory and KMS settings configured, parquet-java decrypts the footer and pages and the scan decodes them; without the keys the read fails as Spark's does. The footer-and-columns-encrypted path is tested (
NativeParquetPerFileSuite). KMS integrations, plaintext footers and per-column key mixes are not tested; they are Spark's and parquet-java's to support. - A UTC-adjusted timestamp read as
timestamp_ntz(Spark refuses it), INT32TIME(MILLIS)(Spark refuses it), and a missingREQUIREDcolumn (Spark fails the read). - The field-id corners: a file with no field ids under a read schema with ids (Spark's error unless
ignoreMissing), and an id match carrying a sibling's name (Spark reads it as the sibling). - A legacy two-level list whose element is a repeated group holding a repeated field (Hardwood's
list_of_lists_legacy_two_level_test). Spark's backward-compatibility rule reads it asarray<int>over two repeated levels, returning[1, 2]where the file holds[[1, 2], [3]]. No writer produces that shape for that type, and the native readers do not follow it. - A bare repeated group annotated
VARIANT(Hardwood'srepeated_variant_group_test), which Spark clips only against its ownarray<variant>.
Spark's reader for that one file (the rest of the scan stays native): a shredded variant below the top level, inside a struct, a list or a map.
Spark's scan for the whole plan:
- A variant extraction Spark pushed into the scan below the top level (
spark.sql.variant.pushVariantIntoScan). - On Spark 4.2, its geospatial types (
geometry,geography). There is no decode path for them; the file's other columns are native. - Any type Spark 4.1 cannot read itself (
FLOAT16, the legacyINTERVALconverted type, 12-byte timestamps): Spark refuses the file before any scan runs.CalendarIntervalnever reaches the scan, because Spark cannot write it to Parquet. (AUUIDcolumn —FIXED_LEN_BYTE_ARRAY(16)with theUUIDlogical type — is read natively as its canonical-textstring,#rtypes.)
In the operators above the scan:
varianthas no engine lane, by design. Spark defines no equality or order on a variant, so it can be no grouping, join or sort key, and every function over one interprets its binary encoding. Operators carry it; an operator that computes on one stays Spark's.timeand the ANSI intervals ride the INT64 / INT32 lanes for carrying, comparison, null tests, grouping, sorting, joins, windows, count, min, max, first and last. Arithmetic, casts, functions, sum and avg over them stay Spark's. A row input holding atimekeeps the operator above it Spark's, because Spark 4.1'sRowToColumnConverterhas notimeconverter.
Performance, not correctness:
- Late materialization (
lateMaterialization, #611/#962) defaults toauto: the decode filter is attached at plan time only when the filter columns are a small fraction of the projected bytes, and the reader then decides per row group whether to late-materialize it from the row group's statistics, dictionary pages, bloom filters and the observed selectivity of earlier row groups (with hysteresis), late-materializing only when the estimated surviving fraction is low and decoding eagerly otherwise.off/onforce it off/on. A global flip regressed at 1 TB (q24b +11.5%, #901), whichautoavoids by keeping the eager path except where a filter is selective on a wide projection. Its two-pass mode (#812), which also skips the I/O of pages with no surviving row, stays behind its owntwoPassflag; underautoit fires only for very-low-selectivity row groups. - Column-index page skipping depends on the file's offset index, which parquet-java writes by default. A file without one is read over whole row groups, as Spark reads it.
Codecs
Compression is not a decode concern of the scan's own. The scan installs ReusingCodecFactory, which decompresses zstd and snappy through one reused buffer per column chunk (#810) and hands every other codec to parquet-java's own CodecFactory; each codec's decompressor then lives as long as its file (#828, #829). So the codec is never a fall-back reason — VectorParquetScanExec.hasUnsupportedEncodingInFooter checks only encodings and physical types — and a file in any codec whose decompressor is on the classpath is read natively, byte for byte as Spark's own reader (which uses the same parquet-java decompressors). NativeParquetCodecSuite pins this for every such codec across v1/v2 pages, dictionary/plain pages and flat/nested columns. Only zstd and snappy have the per-page reuse; the others are no slower than in Spark's reader but do not get it. Dedicated gzip and LZ4_RAW paths are tracked in #830, to be measured first.
| Codec | Decompression | Page into the decoder |
|---|---|---|
| ZSTD, SNAPPY | The scan's own (ReusingCodecFactory, #810): one reused buffer per column chunk |
Read in place, no copy (#786) |
| UNCOMPRESSED | None | Read in place from the chunk buffer |
| GZIP, LZ4 (Hadoop framing) | parquet-java, through a Hadoop stream codec, decompressor from Hadoop's CodecPool |
One copy into the reader's reused page buffer; no new array per page |
| LZ4_RAW | parquet-java's own codec, through the same stream interface | One copy into the reused page buffer |
The codecs above are all decoded by aircompressor's pure-Java codecs, which ship on the classpath, so the shipped build reads every one of them. BROTLI and LZO are the exception: no Brotli or LZO codec is on the classpath (not in aircompressor, parquet-java or Spark — LZO is GPL and not shipped with Hadoop), so a file in either is read by neither Spark's reader nor this one. Because the codec is not a fall-back reason, such a file is not diverted to Spark's reader — the read reaches the decompressor and parquet-java raises BadConfigurationException (the missing BrotliCodec/LzoCodec class), exactly as Spark's own reader would raise it for the same file. Decoding Brotli natively would add a new runtime dependency (brotli4j or org.brotli:dec); it is left out until that dependency is accepted, and would then wire into ReusingCodecFactory beside zstd and snappy.
Default measurement: column index vs late materialization (SF10, local)
A local SF10 A/B of the two page-skipping defaults (local[8], warm OS cache, so this isolates decode CPU, not the S3 I/O the 1 TB numbers above measure), median of 3 runs after 1 warm-up, each flag combination its own JVM, on selective TPC-DS queries:
| query | baseline (both off) | columnindex=true |
lateMaterialization=true |
+ twoPass |
|---|---|---|---|---|
| q3 | 1095 ms | 1086 ms (−1%) | 722 ms (−34%) | 781 ms (−29%) |
| q7 | 1355 ms | 1153 ms (−15%) | 1116 ms (−18%) | 1138 ms (−16%) |
| q19 | 959 ms | 1084 ms (+13%) | 730 ms (−24%) | 748 ms (−22%) |
| q42 | 643 ms | 451 ms (−30%) | 399 ms (−38%) | 437 ms (−32%) |
| q52 | 450 ms | 403 ms (−11%) | 373 ms (−17%) | 421 ms (−7%) |
| q55 | 453 ms | 412 ms (−9%) | 433 ms (−4%) | 363 ms (−20%) |
| q96 | 399 ms | 375 ms (−6%) | 294 ms (−26%) | 305 ms (−24%) |
Total task CPU (executorRunTimeMs) moves the same way: late materialization lowers it on every query here.
What this shows, and the recommendation — defaults are left unchanged here, this is input for the maintainer:
- Late materialization is the consistent win on these selective queries: faster on all seven (−4% to −38%, ~−24% median) with lower task CPU throughout, no regression. These are exactly the selective-filter shapes it targets. But its current
falsedefault rests on a 1 TB reading where q24b was 10% slower (the regression a warm local cache cannot reproduce), and onminScanBytes=1 GiBwhich an SF10 scan does not clear without the override used here. Recommend: keep the defaultfalse, but settle it with a 1 TB A/B over this query set — the local evidence is strong enough to justify that run, which the 1 TB guidance indocs/configuration.mdhas not yet covered for these queries. - Column index helps most queries (−6% to −30%) but regressed q19 by +13% even on a warm local cache with no per-page S3 request to pay — consistent with the documented #873 reason (index/offset reads and per-page bookkeeping that only range-merging, #834, pays back). Recommend: keep the default
falseuntil #834 lands; the local data does not contradict the existing guidance. - Two-pass is roughly even with plain late materialization here (better on q55, worse on q42/q52): its distinctive benefit is skipping the page I/O of non-surviving rows, which a warm local cache does not exercise, so this bench cannot credit it. Recommend: keep the default
false; it needs the 1 TB I/O-bound measurement its config note already calls for.
The three suites that cover these paths — NativeParquetColumnIndexSuite, LateMaterializationSuite, TwoPassLateMaterializationSuite — pass with the flags on.
Status at 1 TB TPC-DS
Full run, 2026-10-02. All 103 queries once, the four engines back to back in one session (8 × m5.4xlarge, one AZ, the launcher's defaults):
| Engine | Total | Faster than Spark on |
|---|---|---|
| Spark 4.1.3 | 3,099 s | -- |
| Comet 1.0.0 | 1,999 s | 97 |
| Comet's native scan + VecRuntime operators and shuffle | 1,954 s | 98 |
| VecRuntime with this reader | 1,542 s | 99 |
The scan-bound queries gain most: q88 35 s against Spark's 96 and Comet's 84, q9 31 against 73 and 51, q28 58 against 102 and 67. The per-query tables are in docs/results.md.
The scan-bound queries, flag off against flag on. These numbers come from 8 × m5.4xlarge in one AZ, with ACCP and the S3A read settings on. The flag-off and flag-on legs ran in one session; checksums equal the references.
| Query | Flag off | Flag on | Comet's native scan (earlier session, same operators) |
|---|---|---|---|
| q28 | 113.7 s | 34.7 s | 69.2 s |
| q9 | 95.9 s | 26.9 s | 53.2 s |
| q44 | 34.6 s | 10.8 s | 22.5 s |
| q88 | 119.7 s | 92.4 s (iterations vary 38–110 s) | 81.6 s |
The flag-on column comes from the read-ahead build with the arena fix. Comet's column comes from an earlier session that had the read-ahead but not the arena fix; that session's flag-on numbers were q28 46.6 s, q9 31.0 s, q44 14.1 s and q88 83.5 s, already ahead of Comet on the first three. The flag-on/off pair (round 14) and the Comet column (round 10) are from different sessions, so compare across that boundary with care.
Where executor CPU goes on q88 with the flag on (async-profiler):
| Bucket | Share |
|---|---|
| TLS | 25% |
| Our decode | 22% |
| S3 HTTP client | 21% |
| Our operators | 17% |
| zstd | 4% |
Work list
The items run roughly from most to least valuable.
Encodings
These are the Parquet v2 writer encodings. A file that uses one the reader does not decode yet goes entirely to Spark's reader. Parquet-java and Spark 3.x/4.x write them when parquet.writer.version=v2, and other engines often do by default.
-
DELTA_BINARY_PACKEDfor INT32 and INT64: done (slice 2).DeltaBinaryPackedReaderreads the block and miniblock headers, unpacks each miniblock through the injectedBytePacker(widths up to 32; INT64 widths above 32 with a scalar long unpack), and adds the deltas in the column's width, wrapping as the writer does. It decodes one miniblock at a time, so a batch can stop inside one.ColumnChunkDecoderwrites the values straight into the lane, including INT32 widened to INT64. The footer check admits the encoding only on INT32/INT64 columns. It is checked:- against a from-scratch encoder of the spec (
DeltaBinaryPackedReaderTest,ColumnChunkDecoderTest); - against parquet-java's own writers (
DeltaBinaryPackedCrossCheckSuite); - through Spark with
parquet.writer.version=v2and the dictionary off (VectorParquetScanSuite).
DeltaBinaryPackedBenchmark(JMH, 20,000 values a page) decodes 2.0–4.0x as many pages per ms as Spark'sVectorizedDeltaBinaryPackedReader:values ours (ops/ms) Spark (ops/ms) sorted INT32 35.2 17.7 random INT32 24.4 10.3 INT64 timestamps 32.7 14.9 44-bit random INT64 29.1 7.2 That is a kernel check, not a verdict; no TPC-DS file uses the encoding.
- against a from-scratch encoder of the spec (
-
DELTA_LENGTH_BYTE_ARRAYfor strings: done. AtfeedPage,DeltaBinaryPackedReaderdecodes the page's lengths. Itsposition()after the last value is where the bytes start; the spec pads the last miniblock, so this is exact. Batches then copy the bytes into Arrow offsets and data likePLAIN. The total length is checked against the page's extent. JMH: 1.46x the pages per ms of parquet-java'sDeltaLengthByteArrayValuesReader(10.2 against 7.0, 20,000 short strings, 1024-row batches). -
DELTA_BYTE_ARRAYfor strings: done. This is what parquet-java writes for v2 strings without a dictionary. The page is aDELTA_BINARY_PACKEDrun of prefix lengths (bytes shared with the previous value), then aDELTA_LENGTH_BYTE_ARRAYof the suffixes. Each value needs the previous one, sofeedPagerebuilds the page's values back to back in one sequential pass into a reused buffer, and the batches copy out of it likeDELTA_LENGTH_BYTE_ARRAY. The prefix chain restarts on each page. A prefix longer than the previous value, a count mismatch between the two runs, or suffix bytes past the page end fail the page. JMH: 2.0x the pages per ms of parquet-java'sDeltaByteArrayReader(2.54 against 1.26, 20,000 sorted URL-like keys). -
BYTE_STREAM_SPLITfor INT32, INT64 and DOUBLE: done. Byte j of value i is at stream j, position i, and the stride is the value region's length over the width. The scan now passes each page's real extent to the decoder, not its reused buffer's. Each value's K bytes are gathered from the streams straight into the lane, including INT32 widened to INT64. FLOAT and fixed-length byte arrays have no lane. Spark's vectorized reader rejects this encoding on INT32/INT64, so those files read only with the flag on. JMH: 8.8x the pages per ms of parquet-java'sByteStreamSplitValuesReaderForDouble/ForLong(20.5 against 2.3). It is a transpose (Vector API or SWAR, by vector width); see the optimization pass below. -
RLEfor values: done, for BOOLEAN (the only physical type it applies to as a value encoding). The page holds a 4-byte little-endian length, then a width-1 RLE/bit-packed hybrid stream.RleBitPackingReader.readBitsdecodes straight into the batch's bitmap: an RLE run of 1s sets its bit range a word at a time, a run of 0s writes nothing, and a width-1 bit-packed run is already an LSB-first bitmap, so it moves 64 bits a step. See BOOLEAN below for the numbers.
Optimization pass on items 1–4. JMH, one 20,000-value page in 1024-row batches, ops/ms. Each before/after pair ran on one host: x86 is an AVX-512 host, Graviton4 is m8g.4xlarge (Neoverse V2) with the default SVE codegen. Graviton4 with NEON codegen (-XX:UseSVE=0) matched SVE within a few percent, except the strings: DELTA_BYTE_ARRAY 2.8 → 3.6, DELTA_LENGTH_BYTE_ARRAY 7.0 → 7.6.
| page | x86 before → after | Graviton4 before → after | what changed |
|---|---|---|---|
BYTE_STREAM_SPLIT DOUBLE / INT64 |
21.0 / 20.5 → 77.9 / 75.2 | 15.2 / 15.2 → 31.0 / 31.0 | a transpose instead of a byte gather: the Vector API (per register, a byte vector from each stream widened to lanes, shifted and ORed) from 256 bits, SWAR (an 8x8 byte transpose in three mask-and-shift stages on plain longs) at 128 bits (ByteStreamSplitKernels) |
BYTE_STREAM_SPLIT INT32 |
46.1 → 298.0 | 60.9 (scalar) → 62.6 | the Vector API with int lanes |
DELTA_BINARY_PACKED sorted / random INT32 |
36.1 / 23.9 → 42.4 / 28.5 | 32.6 / 19.6 → 37.0 / 24.2 | whole miniblocks unpacked and summed in the caller's array; only a miniblock a read stops inside goes through the buffer |
DELTA_BINARY_PACKED INT64 timestamps |
33.4 → 41.0 | 31.2 → 33.9 | the same |
DELTA_LENGTH_BYTE_ARRAY |
9.4 → 11.2 | 9.7 → 11.1 | the faster length decode |
DELTA_BYTE_ARRAY |
2.8 → 3.7 | 3.1 → 4.6 | values rebuilt straight into the batch's bytes; the prefix comes from the previous value in the batch, or from a saved copy at a batch boundary. No per-page rebuild buffer and copy |
The transpose and the prefix sum are switches, read once at startup, for A/B runs:
-Dvecruntime.parquet.bssMode=auto|vector|swar|scalar, defaultauto.autouses the Vector API for 4-byte values at every width, and for 8-byte values when a register holds at least four longs. At 128 bits (NEON, or SVE on Graviton4) widening a byte vector into two long lanes is not intrinsified, and the 8-byte vector transpose ran 0.45 against SWAR's 31. It is the same on x86 forced to-Dvecruntime.vectorBits=128.-Dvecruntime.parquet.deltaScan=scalar|vector, defaultscalar. The vector prefix sum (a log-step scan per register) gives the same wrapping sums. It measured the same as the serial add on x86 (43.5 against 42.4) and slower on Graviton4 NEON (28.9 against 37.0).
ByteStreamSplitKernelsTest compares every variant, and the dispatch, with its scalar reference at every count, offset and array-end position. It passes at 128, 256 and 512 bits (-Dvecruntime.vectorBits).
For each encoding:
- Add a
ParquetPageDecoder.Encodingcase. - Add a
ColumnChunkDecoderpath that writes at a row offset. - Admit the encoding in
SupportedEncodingsonly once all of these pass:- cross-check tests against parquet-java's own readers, in the style of
ParquetPageDecoderCrossCheckSuite; - a round trip through Spark with
parquet.writer.version=v2; - a decode micro-benchmark in
ParquetDecodeBenchmark.
- cross-check tests against parquet-java's own readers, in the style of
Types
Physical types are checked per file. The planner admits a column by its Spark type, but each file decides how the column is stored. A decimal(p <= 18) can be INT32, INT64, FIXED_LEN_BYTE_ARRAY or BYTE_ARRAY: Spark's legacy writer, Hive and Impala use FIXED_LEN_BYTE_ARRAY. At open, VectorParquetScanExec.physicalMatches checks every requested column's physical type against its lane, and a mismatch falls that file over to Spark's reader, like an unsupported encoding. Before this check (0.0.4–0.0.5), a FIXED_LEN_BYTE_ARRAY decimal was decoded as INT64 and returned wrong values. The apache/parquet-testing corpus found it.
Conformance corpus. ParquetTestingCorpusSuite reads 27 files from apache/parquet-testing. They are vendored under spark/src/test/resources/parquet-testing/, Apache-2.0, at the commit in SOURCE_SHA. The files were written by parquet-mr of several ages, parquet-cpp, arrow-rs and Impala, and cover v2 encodings, empty and null pages, checksums, a dictionary page at offset 0, and corrupt files from bad_data/. The suite reads each file column by column:
- a column the scan supports must be read natively, with no per-file fallback, and match Spark's row-based reader;
- any other column must return what Spark returns with the plugin off, rows or refusal;
- a corrupt file Spark refuses must be refused too.
The corpus approach follows Hardwood (hardwood-hq/hardwood), as does bounding every file-declared size before allocating: the DELTA_BINARY_PACKED block size, a BYTE_STREAM_SPLIT region that is not a whole number of values, and bit-packed runs past the stream end.
Whole-corpus conformance (#808). ParquetCorpusConformanceSuite reads every Parquet file of apache/parquet-testing (data/ and bad_data/, 80 files) and of Hardwood's core test corpus (spark/src/test/resources/hardwood/, 223 files, Apache-2.0, commit in its SOURCE_SHA): every physical and logical type, encodings, codecs, page and bloom-filter indexes, nested shapes, variants and malformed files. Each file is read with the plugin on and checked against Spark's outcome with it off (its vectorized reader, else its row-based reader, else the refusal): the same rows by value, or both refuse. How the plugin read it is pinned per file, and per column when the file is not native as a whole, in parquet-corpus-expected.tsv; a read that changes fails the suite until CORPUS_UPDATE_BASELINE=true regenerates the file, so a fallback is always a recorded choice.
Today: 240 files are read natively, 2 are empty, 59 are refused by Spark and by the plugin alike (malformed files; types Spark 4.1 does not read, such as FLOAT16, the legacy INTERVAL converted type, 12-byte timestamps and geospatial types; encrypted files read without keys; a few legacy list and map shapes Spark rejects), and 2 go to Spark's reader for the file:
list_of_lists_legacy_two_level_test: a legacy two-level list,mylist (LIST) { repeated group bag { repeated int32 num } }. Spark's backward-compatibility rule (isElementType) does not takebagas the element, so Spark readsmylistasarray<int>over two repeated levels (on this file it returns[1, 2], where Hardwood reads[{num: [1, 2]}, {num: [3]}]), a shape no Parquet writer produces for that type and the native readers do not follow. Kept on Spark's reader on purpose, for Spark's reading.repeated_variant_group_test: a bare repeated group annotatedVARIANT, which Spark clips only against its ownarray<variant>.
On Spark 4.2 one file reads differently, recorded in parquet-corpus-expected-spark-4.2.tsv, which replaces the base's lines for it: Spark 4.2 reads the GEOMETRY column of geospatial_e2e_test (Spark 4.1 refuses the file), and the native scan has no decode path for Spark's geospatial types, so a plan reading that column keeps Spark's scan; its other columns are native.
The parquet-java reader tests themselves build their files in code; the cases they cover (encodings, page versions, dictionary fallbacks, nulls, nesting, schema evolution, field ids) are exercised by the suites listed under each feature below.
-
BOOLEAN. Done: v1
PLAIN(bit-packed, LSB first) and v2RLE, into aBOOLlane (Arrow's bit-packed vector).PLAINwith every row present copies up to 64 bits a step from any source bit offset to any destination bit offset. With nulls, the bits are scattered one per present row.flushBoolmasks stray bits past the batch's last row. A page that claims more values than it holds fails.- Checked against a scalar reference, at every batch size, with nulls and across pages (
ColumnChunkDecoderTest). - Checked against
readIntat random bit offsets (RleBitPackingReaderTest.readBitsMatchesReadIntAtWidthOne). - Cross-checked against parquet-java's
BooleanPlainValuesWriter/ReaderandRunLengthBitPackingHybridValuesWriter/Reader(DeltaBinaryPackedCrossCheckSuite). - Round-tripped through Spark with v1 and v2 pages (
VectorParquetScanSuite). - Covered by the corpus file
rle_boolean_encoding.parquet.
JMH (
V2EncodingsBenchmark, x86, one 20,000-value page in 1024-row batches):page ours (ops/ms) parquet-java (ops/ms) PLAIN, random values837 10.7 ( BooleanPlainValuesReader)RLE, runs of 1–64212 11.2 ( RunLengthBitPackingHybridValuesReader)RLEmeasured 60.7 beforereadBits, which expands each value to anintand repacks it. - Checked against a scalar reference, at every batch size, with nulls and across pages (
-
TINYINT / SMALLINT. Done. INT32 physical (
INTEGER(8|16, true)), decoded by the INT32 path (every encoding it has) into the INT32 lane, with the declared type on output (VectorNarrowIntColumnVector). A writer may store any INT32 under the annotation. Spark's readers narrow with a(byte)/(short)cast, sofillFixedsign-extends the low 8 or 16 bits of each slot in place. Operators compute on theint, and a value outside the range must wrap there exactly as Spark wraps it. This is one pass over the batch's ints; there is no new decode kernel, so no new JMH.- Checked through Spark with dictionary,
PLAINand v2DELTA_BINARY_PACKEDpages, nulls, the extremes, filters, an aggregate, arithmetic and casts (VectorParquetScanSuite). - Checked on a file written by parquet-java's
ExampleParquetWriterwith out-of-range values, against Spark's parquet-java-based reader. Without the narrowing, that test's sum differs. - The corpus columns
tinyint_col/smallint_colof thealltypes_*files are now read natively.
- Checked through Spark with dictionary,
-
FLOAT, BINARY and TIMESTAMP_NTZ. Done, as option A of the design note: decoded by the scan, no engine lane. Each is decoded on the lane of the same layout:
- FLOAT's 4-byte IEEE bits on INT32:
PLAIN, dictionary,BYTE_STREAM_SPLIT; - BINARY on the UTF8 path:
PLAIN, dictionary, DLBA, DBA, andFIXED_LEN_BYTE_ARRAYread as binary; - TIMESTAMP_NTZ on INT64: micros, MILLIS scaled with Spark's overflow check, only from a column not adjusted to UTC; a UTC-adjusted column falls over to Spark's reader, which refuses it.
They are emitted as Arrow
Float4Vector/VarBinaryVector/TimeStampMicroVector, which Spark'sArrowColumnVectorreads. FLOAT's 4-byte bits come out in aFloat4Vectorthat operators widen onto the FLOAT64 lane it has had since #712. BINARY has had the UTF8 lane since #705, and TIMESTAMP_NTZ the INT64 lane: operators compare, hash, sort and carry them, and the expressions that read characters or the session time zone accept onlystring/timestamp, so over these they stay Spark's.Filters and projections carry them as foreign columns; an operator that computes on one stays Spark's, reading our batches as it reads Spark's scan. A table with such a column no longer keeps all its other columns on Spark's reader.
- Tests:
DeltaBinaryPackedCrossCheckSuite: FLOATPLAINandBYTE_STREAM_SPLITfrom parquet-java's writers, checked bit for bit against its readers (NaN payloads, -0.0, infinities).VectorParquetScanSuite: round trips for all three (v1 dictionary, v1PLAINwith MILLIS, v2), and filters, an aggregate and predicates on them. Also the UTC-adjusted TIMESTAMP_NTZ fallback.- The corpus columns
float_col,date_string_col,string_col,f32,float_plain,float_byte_stream_split,flba5_plainandbinary_fieldare now read natively.
- JMH: no new decode kernel (the INT32, UTF8 and INT64 paths are the ones measured above), so there is no new benchmark.
- Option B (real lanes, so operators compute on these types): done --
floatrides the FLOAT64 lane (#712),binarythe UTF8 lane (#705), andtimeand the ANSI intervals their INT64 / INT32 lanes (#812, below);variantstays without a lane by design (the types table above). - Time and ANSI intervals on lanes (#812).
TypeMapping.isCarriedLong/isComputeRestricted:timeandinterval day to secondare longs andinterval year to monthan int in every Spark vector and row, so the kernels carry, compare, hash, sort, group and join them as Spark does, and shuffle and spill buckets hash them as Spark's hash partitioning does (a long, an int). The expression compiler admits over them only references, comparisons, null tests and the conditionals that pick a value; the aggregates only count, min, max, first and last. Arithmetic (an interval's+fails on overflow in Spark;timetakes+ intervalthroughTimeAddInterval), casts, functions, sum and avg stay Spark's. Spark 4.1'sRowToColumnConverterhas notimeconverter, so a row input holding atimekeeps the operator above it Spark's. Tests:VectorTimeIntervalSuite(filters, projections, grouping with min / max / count, top-k, a join on a time and an interval, windows -- each asserting our operator -- and arithmetic, casts, functions, sum and avg with Spark's answer).
- FLOAT's 4-byte IEEE bits on INT32:
-
TIME (#787): done. Spark 4.1's
timeis nanoseconds of the day; Spark writes it as INT64TIME(MICROS).physicalMatchesadmits it (and, #812, any INT64, as Spark does), and the INT64 decode path (every encoding it has) fills the INT64 lane, scaled by 1000 withMath.multiplyExactas Spark'sLongAsNanosUpdater(DateTimeUtils.microsToNanos) does, on the present rows only. The batch is an ArrowTimeNanoVector, which Spark'sArrowColumnVectorreads (TimeNanoAccessor); no engine lane holds it, so operators carry it and one that computes on it stays Spark's. ATIME(NANOS)or un-annotated INT64 file is read the same way, as Spark'sLongAsNanosUpdaterreads any INT64 (#812); an INT32TIME(MILLIS)file, which Spark refuses, goes to Spark's reader for Spark's error. Flat, in lists and structs, and as a partition column (a constant column).- Tests (
NativeParquetTimeSuite), against Spark's reader: v1 dictionary, v1PLAINand v2 pages, two batch sizes, nulls, values across the whole day with fractional micros, flat, in an array and in a struct, filters and aggregates over it, a partition column, and the per-file unit match. - JMH: no new decode kernel (the INT64 path measured above, plus the scaling pass
TIMESTAMP(MILLIS)already runs).
- Tests (
-
VARIANT (#787, #750): done for an unshredded variant, flat, in lists and in structs. Spark writes a variant as a group of two required binaries,
valueandmetadata. The readers decode the schema with every variant replaced by the struct<value: binary, metadata: binary>(NativeParquetSupport.variantAsStruct); clipping by name puts the file's group in that order whatever order it is stored in, and the struct, list and tree readers above read it like any struct of binaries, every binary encoding and the dictionary path included. The struct column the scan emits is Spark's variant column:ColumnVector.getVariantreads child 0 as the value and child 1 as the metadata. Spark's fallback reader keeps the requested schema.- Shredded variants (#812): done for a top-level variant. A
typed_valuebeside the group'smetadataandvaluemakes the file's read schema take that variant as the group's own struct (Spark'sSparkShreddingUtils.parquetTypeToSparkType), so the struct, list and tree readers decode every typed column and the residual binary natively;NativeParquetVariantColumnthen rebuilds each row's variant with Spark'sSparkShreddingUtils.assembleVariant, as Spark'sassembleVariantBatchdoes, into the struct of its two binaries. A shredded variant below the top level (in a struct, a list or a map) keeps the file on Spark's reader. - Pushed extraction (#812): done for a top-level column. With
spark.sql.variant.pushVariantIntoScan, Spark asks the scan for a struct of the extracted paths (VariantMetadata). The scan reads the variant's file group as its struct, shredded or not, and builds each row's struct with Spark'sassembleVariantStructand the paths' casts (getFieldsToExtract), as Spark'sassembleVariantStructBatchdoes. The assembly allocates per row, as Spark's does. A pushed struct below the top level keeps Spark's scan for the plan. - Tests (
NativeParquetVariantSuite), against Spark's reader: objects, arrays, scalars, JSON and SQL nulls; v1 dictionary, v1PLAINand v2 pages, two batch sizes; flat, in an array and in a struct;to_json,variant_get,schema_of_variant,is_variant_nulland aggregates; the rows themselves compared with Spark's; a shredded file (decoded natively, #812), and pushed-down extraction over shredded and unshredded files (native, #812). - JMH: no new decode kernel; the binaries take the UTF8 path measured above.
- Shredded variants (#812): done for a top-level variant. A
-
TIMESTAMP. Done for INT64
MICROSandMILLIS, into the INT64 lane of micros.physicalMatchesadmits theTIMESTAMPannotation with either unit and eitherisAdjustedToUTC, the same match as Spark'sParquetVectorUpdaterFactory.MILLISis scaled by 1000 withMath.multiplyExact, as Spark'sLongAsMicrosUpdaterdoes, so an overflowing value fails the read instead of wrapping. Only present rows are scaled, because a null slot holds whatever the reused buffer held.- Rebase, per file (#568 item 6). At open,
fallbackModesresolves the file's datetime and INT96 rebase specs withDataSourceUtils.datetimeRebaseSpec/int96RebaseSpec, from the writer version and legacy keys in the footer, or else the session configuration, and the INT96 time-zone conversion the wayParquetFileFormatdoes. Every column reader gets them as oneParquetRebase, which runs over each decoded date or timestamp batch as Spark'sParquetVectorUpdaterFactoryruns per value: a value below the last Julian switch (RebaseDateTime.lastSwitchJulianDay/lastSwitchJulianTs) fails the read underEXCEPTIONwithDataSourceUtils.newRebaseExceptionInReadand is rebased from the hybrid calendar underLEGACY(in the file's zone);CORRECTEDleaves it. Above the switch both rebases are the identity, so modern data costs one comparison per value and no write. Row-group pushdown buildsParquetFilterswith the file's own rebase spec, as Spark does. - INT96 (Spark's default output timestamp type, and Hive's and Impala's): done. The 12-byte values (nanoseconds in the day, then the Julian day, little-endian) are staged on the fixed-length binary path,
PLAINand dictionary, andColumnChunkDecoder.flushInt96converts them to micros exactly as Spark'sbinaryToSQLTimestamp/fromJulianDaydo; the file's INT96 rebase mode and, for a file not written by parquet-mr underint96TimestampConversion, the conversion from the session zone to UTC follow. - The fallback reader is opened as
ParquetFileFormatopens Spark's vectorized reader: over the split's byte range, with the scan's Hadoop conf carryingsetupHadoopConf's flags (INT96 as timestamp, binary as string, the session time zone, ...), and with the file's own rebase modes and INT96 conversion. The earlierinitialize(path, columns)read the whole file on the first split, turnedint96AsTimestampoff, and was hard-wired toCORRECTED. ALEGACYfile would have been read without its rebase. - Checked through Spark with
TIMESTAMP_MICROSandTIMESTAMP_MILLISoutput, v1 and v2 pages, nulls, values before 1582 and after 2038, filters and an aggregate, all natively. - Checked across one table holding an INT96 file, a
LEGACY-calendar file and aCORRECTEDfile, each decoded natively with Spark's values, underCORRECTEDandEXCEPTION.NativeParquetRebaseSuiteadds INT96 over v1 and v2 pages with and without dictionaries, inside a struct and a list, parquet-java's and parquet-testing'sint96_from_spark, the time-zone conversion in two zones,LEGACYdates and timestamps on both sides of the switch with a pushed-down filter, andEXCEPTIONon files without Spark's metadata: native when no value needs a rebase, Spark's own error when one does. - Checked on a parquet-java-written
MILLISfile whose micros overflow. It fails withArithmeticExceptionunder both readers.
- Rebase, per file (#568 item 6). At open,
-
Wide decimals and decimals stored as bytes. Done. A decimal stored as
FIXED_LEN_BYTE_ARRAYorBINARYis decoded by the binary page paths:PLAIN(fixed-length values have no length prefix), dictionary,DELTA_LENGTH_BYTE_ARRAYandDELTA_BYTE_ARRAY, which is what parquet-java's v2 writer uses for FLBA. Its big-endian two's-complement bytes are then converted at flush (ColumnChunkDecoder.flushDecimal):decimal(p > 18)goes into the DECIMAL128 lane: two little-endian limbs, sign-extended, which is Arrow's Decimal128 layout. A 16-byte value is read as two big-endian longs. A value longer than 16 bytes must be a sign extension, or the read fails.decimal(p <= 18)stored as bytes goes into the INT64 lane, exactly as Spark'sbinaryToUnscaledLongcomputes it. Spark's legacy writer, Hive and Impala store such decimals; since #592 those files had fallen over to Spark's reader.- Scale check.
physicalMatchesnow also requires the file's decimal annotation to have the requested scale and at most the requested precision. Spark reads, for example, adecimal(9,2)file asdecimal(12,4)by rescaling, and the lanes carry the stored unscaled values as they are, so such a file fell over to Spark's reader until #568 schema evolution rescaled it natively (below). Before this check, an INT64 (or narrow INT32) decimal read with another scale was decoded natively without the rescale: wrong values. - Tests.
ColumnChunkDecoderTest: every width from 1 to 16, FLBA and BINARY (with sign-extended values longer than 16 bytes), both lanes, againstBigIntegerand Spark's formula.DeltaBinaryPackedCrossCheckSuite: parquet-java'sFixedLenByteArrayPlainValuesWriterandDeltaByteArrayWriter, read back by its matching readers.VectorParquetScanSuite:decimal(20|25|38)round trips (v1 dictionary,PLAIN, v2DELTA_BYTE_ARRAY, legacy format) and the rescale fallback.- The corpus files
fixed_length_decimal*andbyte_array_decimalare now read natively.
- JMH (
V2EncodingsBenchmarkflba-dec38, x86, one 20,000-value page): 13.5 pages per ms. parquet-java'sFixedLenByteArrayPlainValuesReader.readBytesdoes 13.7, but it only slices the bytes and does no conversion. Before the big-endian long reads this path measured 3.4.
-
Structs (#568): done, structs of structs included, over every leaf type and encoding above. A struct has no column chunk of its own; Parquet records its nulls in its leaves' definition levels.
NativeParquetStructReaderholds one reader per field (a leaf'sNativeParquetColumnReader, or another struct reader), and each leaf decodes its rows straight into its own batch vector, as a flat column does. The struct is present on a row where its first leaf's level reaches the struct's own definition level (the optional groups from the root down to it), soColumnChunkDecoderkeeps that one leaf's batch levels (setRecordLevels) and the struct reader turns them into the struct's nulls in one pass. That is how Spark's vectorized reader computes them too. The batch is Spark's struct column over the fields' columns (VectorNestedColumnVector.struct), which our operators read throughgetStruct/getChild.- Per file,
forEachLeafwalks the clipped group in the struct's field order and checks each leaf's physical type against its lane (physicalMatches). Fields are matched by position, as the clipped schema keeps the requested order (under field-id matching a field keeps the file's name). A shape it cannot follow (a repeated group where a struct is requested, say) falls the file over. A field the file does not hold is null (schema evolution, below). Dates and timestamps under a struct count for the calendar-rebase check, and anINT96leaf falls the file over as at top level. - Strings under a struct are dictionary ids too (#612; since #568), at any struct depth, where the leaf allows it, as at the top level. A struct field is read zero-copy by the operators:
ColumnVectorAdapterslooks under the null-safe wrapper the nested vectors put on their leaves (VectorNestedColumnVector.unwrapLeaf) and reads the leaf's own buffers (an Arrow vector, a dictionary vector) instead of copying it through Spark's getters. The strings of lists and maps (elements, keys, values, and the fields of their structs, at any depth) are dictionary ids too, the same way. - Tests (
NativeParquetStructSuite), each against Spark's row-based reader, with the native scan asserted to decode the row groups itself:- Spark-written files: every lane type as a field, nulls at the struct and at the leaf, a struct of structs, v1 and v2 pages, dictionary on and off. Nested schema pruning, filters and aggregates on fields, a shuffle.
- parquet-java's
ExampleParquetWriter: required groups, required leaves under optional groups, and an optional group under a required one, which Spark does not write. v1 and v2, dictionary on and off. - Hardwood's
NestedSchemaTeststruct-of-struct shape, and theapache/parquet-testingdeep struct from arrow-rs (required groups of required leaves; its unsignedINT64fields are a decimal on Spark's side, so selecting them keeps that file on Spark's reader). - A struct field the file lacks is null, natively since #568 schema evolution (below); an
INT96timestamp in a struct is decoded natively since #568 item 6. A struct holding a list or a map is read natively (below). Field names resolve case-insensitively.
- JMH: no new decode kernel. The leaves are the flat decoders measured above, and the struct adds one pass over one leaf's levels per batch.
- Per file,
-
Lists and maps (#568): done for a top-level
array<flat>ormap<flat, flat>, every flat type and encoding above. Each value slot of a list's leaf is an entry with a repetition and a definition level, and repetition level 0 starts a row.- Levels. On the leaf's path there is exactly one repeated node, in every list form Spark reads as an array of a flat type: the three-level
LIST, Spark's legacybag/arrayform, the two-level form, and a barerepeatedleaf. Below the level of that node's parent, the row's list is null. Below the repeated node's own level it is empty. At or above it the entry is an element, and the element is null below the leaf's maximum level. - The leaf in repeated mode (
NativeParquetColumnReader). It reads pages ahead of the decoder, into their own buffers, because a batch's rows may run past the page in hand. Each page's repetition and definition levels are kept, and the page past its repetition levels goes to the decoder (Page.v1At/v2At).entriesForRows(n)counts the entries of the nextnrows. - Decoding.
readElementswalks those entries in runs: element runs decode back to back into the batch vector, and the entries of null and empty lists are skipped (ColumnChunkDecoder.skip, which consumes no value for them). - The batch.
NativeParquetListReaderbuilds the offsets and the null rows from the first leaf's levels. A map's key and value are two leaves under onekey_valuegroup, so each decodes its own elements. The batch is Spark's list or map column over the leaves' vectors (VectorNestedColumnVector.list/map). - Per file,
forEachLeafrequires one leaf for a list, or two for a map (key first, under the same repeated group). Each leaf must be repeated exactly once and stored as its lane needs; anything else falls the file over. - Tests (
NativeParquetListSuite), each against Spark's row-based reader, with the native scan asserted to decode the row groups itself:- Spark-written files, three-level and legacy form, v1 and v2 pages, dictionary on and off. Lists of every lane type with null lists, empty lists and null elements, a list longer than many pages, and maps with null values and empty maps. Queries cover
size, element access,exists/filter/transform,map_keys/map_values,explodeof a list and of a map, aggregates, and a shuffle. - parquet-java's
ExampleParquetWriter, v1 and v2, dictionary on and off: the two-level list, required and optional three-level elements, a bare repeated double, and a map with optional values. - Hardwood's dictionary-encoded list spanning batches.
- The
apache/parquet-testingfiles.list_columns,null_listand the top-level repeated primitive ofrepeated_primitive_no_listare read natively. The nested maps,incorrect_map_schemaandmap_no_valueanswer what Spark answers; the nested lists and the legacyLIST<LIST<int32>>are read natively (lists of lists, below). - Fallbacks: none left for nesting itself; a nested column keeps Spark's scan only for an unreadable leaf type.
- Spark-written files, three-level and legacy form, v1 and v2 pages, dictionary on and off. Lists of every lane type with null lists, empty lists and null elements, a list longer than many pages, and maps with null values and empty maps. Queries cover
- JMH: no new decode kernel. The leaf decoders are the ones measured above; the list adds one pass over a leaf's levels per batch, plus the page buffers.
- Levels. On the leaf's path there is exactly one repeated node, in every list form Spark reads as an array of a flat type: the three-level
-
Lists of structs (#568): done for an array of structs of flat fields, in Spark's three-level form, the legacy two-level form (the repeated group is the struct) and parquet-java's. Every field is a leaf under the same struct group, repeated once, so all share one entry stream: the offsets and null lists come from the first field's levels as for a list of leaves, an element's struct is null where that field's level is below the struct group's, and each field decodes its own elements (
readElements). The batch is Spark's list column over a struct column over the fields' vectors. Tests: Spark-written files (legacy and three-level form, v1 and v2 pages, dictionary on and off; nulls at the list, the struct and the field;transform,filter,exists,explode, aggregates) and parquet-java's three-level and two-level shapes, v1 and v2, each against Spark's row-based reader. -
Lists of lists (#568): done, any depth, over one flat leaf (
NativeParquetNestedListReader). The leaf is repeated as deep as the arrays nest; the j-th repeated node on its path gives two thresholds, where the depth-j list is present (its parent's definition level) and where an entry is one of its elements (the node's own). An entry opens a depth-j list where its repetition level is at most j - 1 and the list exists there, and a depth-j element where its repetition level is at most j and its definition level reaches the element threshold; one pass over the leaf's levels builds every depth's offsets and nulls, and the leaf decodes its elements back to back. Spark's three-levelLISTofLIST, the legacy two-level form and parquet-java's are all read. Tests: Spark-written lists two and three deep (legacy and three-level form, v1 and v2 pages, dictionary on and off; nulls and empty lists at every depth;flatten, nestedexists,explode), parquet-java's three-level and two-level shapes, and parquet-testing'snested_listsandold_list_structure, which now read natively. -
Lists and maps inside structs (#568): done. A struct's field may be any list or map the list readers follow (lists and maps of flat types, lists of structs of flat types, lists of lists); each is read at its path under the struct. The struct's nulls come from its first field, whatever it is: a flat leaf's batch levels as before, or for a list or map the level of each row's first entry (
RowLevels), since every entry of a row carries the struct's own definition. Tests: Spark-written structs whose first field is a list, with a leaf and a map after it, and a struct of a struct of a list of lists (legacy and three-level form, v1 and v2 pages, dictionary on and off; nulls at the struct, the list, the map and the leaves), and parquet-java's struct holding a three-levelLIST, a legacy two-level list and aMAP, v1 and v2, each against Spark's row-based reader. -
Maps of structs (#568): done for a map whose value is a struct of flat fields, at the top or as a struct's field. The key and every value field are leaves under the same
key_valuegroup, repeated once, so they share one entry stream: the offsets and null maps come from the key's levels, as for a map of leaves; a value struct is null where its first field's level is below the value group's (the key is required, so its own levels cannot tell); and each leaf decodes its own elements. The batch is Spark's map column over the key's vector and a struct column over the value fields' vectors. Tests: Spark-written maps of structs, at the top and inside a struct (v1 and v2 pages, dictionary on and off; nulls at the map, the value struct and its fields; element access,map_keys,explode, aggregates), and parquet-java'sMAPwith an optional value group, v1 and v2, each against Spark's row-based reader. -
Any nesting (#568): done. A list or map whose nesting the readers above do not follow (a list of structs holding a list or a map, a map of structs holding a list, a list of lists of structs, a list of maps, a map of lists, a map of maps) is read by
NativeParquetTreeReader. It builds a node tree from the clipped schema and the Spark type (Spark's three-levelLIST, the legacy two-level forms by Spark'sisElementTyperule, bare repeated fields,MAPgroups), and every node takes its structure from the levels of the first leaf below it, which encodes it as every other leaf there does: a node insidejlists exists at an entry with repetition level at mostjwhose definition level reaches the innermost enclosing list's element level; a list or map at depthj + 1is null below its own level and has an element at an entry with repetition level at mostj + 1reaching its element level; a struct is null below its own level; and each leaf decodes its elements back to back. Tests: Spark-written lists of structs holding a list and a map, lists of lists of structs, maps of structs holding a list, maps of lists and lists of maps (legacy and three-level form, v1 and v2 pages, dictionary on and off; nulls and empty collections at every level), parquet-java's three-level list of structs holding a list and the legacy two-level group with a repeated field, and parquet-testing'snested_maps, each against Spark's row-based reader. -
Metadata columns (#568): done. The file-constant
_metadatafields are constant columns after the partition columns, filled per file byFileFormat.updateMetadataInternalRowwith the format's extractors, on native and fallback files alike (a fallback file's batch gets them appended, asFileScanRDDdoes)._metadata.row_indexisNativeParquetRowIndexColumn: the footer's first row index of the group plus the position, or, in a row group filtered by the column index, plus the selected row's index in the group. The reader setsInputFileBlockHolderper file on the task thread forinput_file_name(),input_file_block_start()andinput_file_block_length(). Under decode-ahead the files are opened on the producer thread, whose holder is the task thread's own (it is inheritable), so the producer sets nothing: each batch carries its file, andDecodeAheadIteratorsets the holder as the task thread takes a batch from another file. Before this, a query reading_metadatafailed in the native scan andinput_file_name()did not return Spark's value. Tests:NativeParquetMetadataSuite, against Spark's row-based reader, on Spark- and parquet-java-written files, partitioned tables, split files, a decode filter and an INT96 file next to native ones. -
Schema evolution (#568): done: missing columns and fields (at any depth, inside lists and maps too), type widening and field-id matching. A table read with a schema newer than some of its files:
-
Missing columns and struct fields are null in every row, as Spark's
ParquetColumnVectormakes them (setMissing). A top-level column the file lacks (flat or nested) isNativeParquetNullColumn. A struct field the file lacks, at any struct depth, is one too, and the struct's nulls come from its first field the file holds.missingReadableadmits a missing node that is a top-level column or a field of a struct at any depth, optional, and (top level) without an existence default. Inside a list or a map,NativeParquetTreeReadertakes the column: a missing field is aMissingnode, an all-null Spark vector as long as its struct's instances, and the struct's structure comes from its first field the file holds. A top-level column added with aDEFAULTholds its existence default in every row of a file without it, a constant vector as Spark fills it (#812; a type with an engine lane, a string or a binary). A struct the file holds none of the requested fields of takes its nulls from the field Spark 4.1's clipping appends for them, the file's cheapest leaf, which the struct reader reads as its level source and does not emit (#812). AREQUIREDmissing column stays on Spark's reader, which fails the read with its own error. -
Type widening, as Spark's
ParquetVectorUpdaterFactorywidens.physicalMatchesadmits each pairing below, andNativeParquetColumnReader.widenLaneconverts each decoded batch in place: INT32 and FLOAT values are decoded sign-extended into INT64 slots (the kernel's existing INT32-to-INT64 path, every encoding).- INT32 and FLOAT as
double. - DATE as
timestamp_ntz: midnight UTC, after the file's date rebase (ParquetRebase.daysToMicros). - A decimal
(p, s)read as(p', s')withs' >= sandp' - p >= s' - s: the unscaled values times10^(s' - s), in the INT64 lane by a long multiply and in the DECIMAL128 lane throughBigInteger(Spark rescales throughBigDecimalper value). - An un-annotated or signed-integer INT32 as a decimal with at least 10 integer digits.
- An INT32 or INT64 read as a decimal of more than 18 digits: each value is decoded into an 8-byte slot and sign-extended to a 16-byte DECIMAL128 slot in place (
WIDEN_LONG_TO_DEC128; zero-extended for aUINT_64,WIDEN_ULONG_TO_DEC128), rescaled when the requested scale is larger, as Spark'sIntegerToBinaryUpdater/LongToBinaryUpdater(and theirToDecimalforms) widen it. Spark reads an un-annotated integer as a decimal only when the requested precision leaves room for its integer digits (INT32 at least 10, INT64 at least 20).NativeParquetSchemaEvolutionSuitecoversdecimal(20,2)anddecimal(38,0)from INT32 anddecimal(30,2)/decimal(38,0)from INT64.
- INT32 and FLOAT as
-
Field-id matching (
spark.sql.parquet.fieldId.read.enabled).clipParquetSchemamatches by id and keeps the requested order, under the file's names, with a generated name for an id the file lacks (a missing column: null). So every reader pairs the requested fields with the clipped ones by position, not by name:clippedNameat the top level, and the struct, list and tree readers' fields and leaves. Row-group pushdown is built over the file's schema, asParquetFileFormatbuilds it. Two cases go to Spark's reader for Spark's outcome:- a file with no ids at all when the read schema has some, unless
fieldId.read.ignoreMissingis set (Spark'sParquetReadSupportfails it); - a field matched by id that carries a sibling's requested name (
idNameClash). Spark's reader pairs the clipped fields with the read schema by name after the id match, so it reads such a field as the sibling: a type mismatch, or another column's values.
A read error from Spark's reader inside the native scan carries the file, as
FileScanRDDattaches it (FileDataSourceV2.attachFilePath). - a file with no ids at all when the read schema has some, unless
-
Tests:
NativeParquetFieldIdSuite, against Spark's vectorized reader, v1 and v2 pages × dictionary on and off. Columns and fields renamed and reordered at the top level, in a struct, in a list of structs and in a map's struct values, with ids the file lacks; a filter on id-matched columns; swapped names (Spark's outcome); a file with no ids (Spark's error, and nulls underignoreMissing); and struct fields missing inside a list, a map and a list of lists of structs. -
Tests:
NativeParquetSchemaEvolutionSuite, against Spark's vectorized reader, v1 and v2 pages × dictionary on and off. Missing flat, struct, list, map and wide-decimal columns; missing struct fields at two depths, including the first field; widening flat, in a struct and in a list; legacy-format FLBA decimals; aLEGACY-calendar date read astimestamp_ntz; parquet-java INT32,INTEGER(16)and FLOAT files; and the fallbacks above.VectorParquetScanSuite's rescale test andNativeParquetStructSuite's missing-field test now assert native reads.
-
Performance
-
Nested readers, JMH (#568,
NestedParquetReadBenchmark): one Spark-written 1M-row snappy file, one nested column at a time, read whole and folded into a checksum through Spark'sColumnVectorgetters (the way an operator above the scan reads it). Ours is compared with Spark'sVectorizedParquetRecordReader; both agree on the checksum. x86,-wi 2 -i 3 -f 1, ms per read:column reader ours Spark array<int>list 79.2 ± 9.1 88.1 ± 4.4 array<string>list 114.9 ± 2.4 117.9 ± 3.1 struct<int, string>struct 62.1 ± 5.0 59.9 ± 4.1 map<string, int>list (map) 113.7 ± 5.4 98.5 ± 9.5 array<array<int>>nested list 200.8 ± 16.0 230.6 ± 8.8 array<struct<int, array<string>>>tree 250.5 ± 8.6 250.6 ± 2.5 At parity or ahead everywhere but the map (15% behind at the time of this table; since #812 the map reads faster than Spark's reader, below). The folding through the getters is a large share of both columns. Run with
benchmarks/scripts/run-parquet-e2e.sh's classpath and JVM options.#812, the getters. A profile of the map read put a quarter of its time in Arrow's bounds-checked
isNull, reached once per element through Spark'sArrowColumnVectoraccessors, andgetIntandgetUTF8Stringcheck the bit again inside Arrow.VectorArrowColumnVectornow reads the validity bit, INT32 and INT64 values and string offsets and bytes straight from the buffers' addresses, as Spark's own vectors read theirs, with no allocation; it reads each buffer's address on every call, so a vector refilled between batches is read where it lives now (VectorArrowColumnVectorSuite). Same run settings,-wi 3 -i 6, ms per read, ours before / after, then Spark in the same session as after:column ours, main ours, #812 Spark map<string, int>108.4 ± 1.6 92.6 ± 1.1 111.6 ± 2.0 array<string>112.6 ± 5.1 101.6 ± 4.3 111.0 ± 3.7 array<int>66.5 ± 1.2 86.7 ± 1.5 struct<int, string>49.5 ± 0.5 56.3 ± 0.7 The map now reads 17% faster than Spark's reader. Since every Arrow-backed column a Spark operator reads goes through this wrapper, the gain is not specific to nested columns.
oursIdsreads the same columns with strings as dictionary ids, as the scan does by default (#612; in structs, lists and maps since #568). Same run settings, ms per read,ours/oursIds:array<string>115.7 / 103.6,struct<int, string>62.3 / 57.3,map<string, int>112.6 / 109.0 (± 38),array<struct<int, array<string>>>246.0 / 238.6. The read itself gains up to 10%; an operator that groups, joins or compares the strings by id gains more, which this benchmark does not measure. -
Column-index page filtering (#568, #626): done, for every column the scan reads natively.
useColumnIndexFilteris off by default (#873) and on withparquet.filter.columnindex.enabled=true(a session key, orspark.hadoop.parquet.filter.columnindex.enabled). On the classic S3A stream every selected page is a separate seek and GET, plus the column and offset index reads: at 1 TB TPC-DS on 9 x m5.4xlarge, q75, q95, q85, q80 and q44 took 100.2 s with it on and 73.2 s off (75.4 s before #748 added it), so it waits for page ranges to be merged into larger reads (#834). Row-group statistics, dictionary and bloom-filter pruning stay on. With it on:readNextFilteredRowGroupthen keeps only the pages overlapping the filter's row ranges, and the store carries the selected row indexes.NativeParquetColumnReaderturns them into ranges and follows them: it moves the decoder to each range's first row, skipping rows within a page and over the pages Parquet dropped (each page's first row index gives the gap), then decodes the range. So every column emits the same selected rows although their pages start at different rows. A list or map counts entries, not rows (#626). A page of a file with an offset index begins on a row boundary, as the format requires, so each repeated leaf keeps every pulled page's first entry and first row index: before a range it skips the entries of the rows ahead of it in the page in hand and jumps over the pages Parquet dropped, then counts and decodes the range's entries as before. The list, map, struct and tree readers take their structure from those entries unchanged. A leaf skips only at a batch's start, so in a file that requests a repeated leaf a batch ends where a range ends. Before #568 the column index was off: with it, the columns' pages misaligned.- Pages skipped (#626):
numPagesSkippedcounts the requested pages the column index dropped: per row group read, the pages in each requested column's offset index that overlap none of the selected ranges. It is computed on the thread that read the row group, from the offset indexes Parquet's filter had already read, so it adds no I/O. - Late materialization's I/O (#611): the decode filter does not change which pages are read.
readNextFilteredRowGroupfetches every requested column's pages that overlap the column index's ranges before any row is decoded, so the non-filter columns of a row group are read in full over those ranges, even where the decode filter then drops every row. Skipping them would mean reading a row group in two passes: the filter columns first, then the others throughreadFilteredRowGroup(index, RowRanges)over the surviving rows. That stays open until the measurements say it pays. - Tests:
NativeParquetColumnIndexSuitechecks against Spark's own scan, over sorted data with small pages. It covers every flat type and a struct, and a list and a map of flat types, a list of structs, a list of lists, a struct holding a list and a map, a map of structs and a list of structs holding a list (#626). Each is run with v1 and v2 pages, dictionary on and off, one and several ranges, a filter no page matches,_metadata.row_index, late materialization, small batches and the switch off. The native scan must output under a quarter of the rows it reads with the column index off, and must count skipped pages.
- Pages skipped (#626):
-
Make the sort compare robust against C2 uncommon-trap storms. When q67 runs after q88, q28, q9 and q44 in the same app, 0–3 of 8 executors run q67's sort stage at about 2.3× CPU per task for the rest of the app.
- The deoptimization log shows hundreds of thousands of
unstable_iftraps with actionnoneinStringCompareKernels.compareBytesat bytecode 33, where the 8-byte loop runs out. The profile never saw that exit taken, and once HotSpot stops recompiling, the trap stays. - Ruled out: a separate copy of the method for sorts, and
-XX:-OptimizeUnstableIf. - Being validated: warming the method's branch profile at class initialization.
This is not specific to the native scan. It also happens with the flag off, less often.
- The deoptimization log shows hundreds of thousands of
-
Decode in parallel (#606): done, off by default. Decode-ahead (above) runs decoding on ONE producer thread per task.
spark.vecruntime.scan.nativeParquet.parallelDecode= K (default 0, off) adds a second step: the row groups AFTER the one the task thread consumes are decoded by K HELPER threads, each on its own column-reader set and child allocator, and handed back in file order so the output is identical (ParallelDecodeIterator). It engages per file, only for a file with at least two row groups at or above…parallelDecode.minRowGroupBytes(default 32 MiB) and only on the plain whole-row-group path (no decode filter, no two-pass): a file outside that — the TPC-DSstore_salesshape, ~7 MB single-row-group files — decodes single-producer as before. The lead reader keeps all of file iteration, read-ahead (which has already fetched a row group's compressed pages when the job is handed out), per-file fallback, metrics and order; a helper only decompresses and decodes a fetched row group into vectors it owns. Threads are virtual by default (…parallelDecode.threads), with the JDK 25 rules the decode-ahead producer follows (the first job of a task decodes on the task thread, so the decode classes are initialized before any helper runs; file opens stay on the platform prefetch pool). Off by default for the same reason decode-ahead is modest: a cluster running as many tasks as cores already keeps every core busy, so a second decode thread per task only contends there — the 1 TB TPC-DS A/B measured +1-5% for decode-ahead, and parallel decode adds more helpers to the same contention. The win is where cores sit idle: few large files with many large row groups, and stage tails. A local A/B on one large file with idle cores (the target shape): a scan-heavy aggregate over a 3M-row mixed-type file of two 64 MB row groups on one task ran 436 ms serial, 421 ms decode-ahead=4, and 333 ms / 337 ms at parallelDecode=2 / 4 (−24% / −23%); over five 16 MB row groups the gap narrows (297 ms serial, 259 ms decode-ahead, 262 ms parallelDecode=4) as the fixed per-row-group reader-set setup dilutes a smaller group's decode. The 1 TB cluster A/B over this query set is the next step before changing the default. Tests:ParallelDecodeSuite(rows equal the serial path for scans, filters, group-bys and nested / decimal / string columns at K = 1, 2, 4 on virtual and platform helpers; order preserved row by row; a LIMIT and a helper failure leave the allocator at baseline; a mixed native / fallback table; the minRowGroupBytes gate and a decode filter keep a file on the lead; the column index on), andIcebergNativeScanSuite(identical over positional, deletion-vector and equality deletes through the Iceberg native scan). -
Cap the read-ahead in bytes (#568): done. Besides the depths (
prefetchFiles,prefetchRowGroups),spark.vecruntime.scan.nativeParquet.prefetchMaxBytes(default 256 MiB) caps the compressed bytes one task's read-ahead holds andprefetchExecutorMaxBytes(default 2 GiB) caps every task of the executor together;0lifts either. A file opened ahead counts at its split's length and a row group read ahead at its requested column chunks' size; each is given back when it is used or dropped. One file and one row group ahead are always allowed, so the overlap never stops. At the TPC-DS shapes (files of ~7 MB, depth 12) neither cap binds. Tests:DecodeAheadSuite(Spark's rows under each cap, the peak bounded by one item ahead, every byte given back). -
Read-ahead in the task's memory budget (#915): done. The compressed pages read ahead and the decoded batches queued by decode-ahead are held in the task's Spark execution memory, granted only from memory that is free now: the pool must have the bytes, and in the operators' memory mode the task must stay within its share (
pool / spark.executor.cores). A request that would make Spark spill another consumer of the task (a sort's runs, a join's build) is refused instead, and the window narrows: one file and one row group ahead stay allowed, and decode-ahead waits for its queue to empty before queuing the next batch. So a wide scan stage with free memory reads deep and a narrow stage whose operators hold the pool reads shallow; the byte caps above remain the upper clamp. Compressed pages are charged to the on-heap pool (parquet-mr reads column chunks into heap buffers), decoded batches to the mode Arrow memory is budgeted in. Executor metrics:scanReadAheadHeldBytes,scanReadAheadPeakBytes,scanReadAheadRefusals. Tests:ReadAheadMemorySuite(a grant held and given back; refused without spilling another consumer; the task's share),DecodeAheadSuite(every byte given back). -
Row filtering during decode (late materialization, #611). Done for flat columns: with
spark.vecruntime.scan.nativeParquet.lateMaterializationon, the scan decodes the decode filter's columns first, then the other columns only at the rows that survive (readBatchSelected; a string column skips the others in its pages, a fixed-width one decodes whole and carries the selection). Since #568 a struct does the same field by field: each flat string leaf decodes the survivors only, and the struct's nulls come from its level source as before. A list, a map or the tree reader still decodes the batch whole (their leaves count entries, not rows), and the batch's selection excludes the rest. Tests:LateMaterializationSuite, including a struct of strings and a struct inside it at five selectivities. Still to measure against Comet withrowFilterPushdownon.- Two passes, skipping I/O (#812). One pass saves decode CPU only: parquet-java fetches every requested column's pages before a row is decoded. With
spark.vecruntime.scan.nativeParquet.lateMaterialization.twoPasson, a row group is read twice. First only the decode filter's columns (readRowGroup), decoded and filtered batch by batch over the whole row group, recording the surviving rows as runs. Then every requested column over those runs only:readFilteredRowGroup(i, ranges)fetches the pages that hold a surviving row, and the column readers align to the ranges as they do for column-index filtering (#748). The filter is evaluated again over those rows by the one-pass path, so pass 1 only chooses the row set. The runs become aRowRangesthrough aRunsOffsetIndexwhose "pages" are the runs. Pass 1 does not usereadFilteredRowGroup: parquet-java caches a row group's column-index store with the paths requested when it is first built, which would leave pass 2 without the other columns' offset indexes. Read in one pass: a row group with a requested column chunk that has no offset index, one where the filter keeps more thantwoPass.maxFraction(0.5) of the rows, and, after two of those in a row, the rest of the file; a row group with no survivor is skipped. Row-group read-ahead is off under two-pass (it reads by index on the task thread), and so is reading the first row group when the file is opened ahead.- Tests (
TwoPassLateMaterializationSuite): Spark's rows for eleven filters (none, a narrow run pushed and not pushed to Parquet, a quarter, scattered, runs, most, all, nulls, strings, dates) on a file sorted by the filter column and an unsorted one, with decode-ahead and row-group read-ahead; and a selective unpushed filter on the sorted file reads under half the bytes of one pass, with no allocator leak. - Local measurement, 8M rows sorted by
idin 4 files,SELECT sum, sum, sum, max(string), max(string) WHERE <filter>, median of 5 ms, one pass / two passes:id * 2in 0.01% of the range 529 / 157; 1% 465 / 154; 10% 497 / 301; scattered 1% (k * 2 < 20) 457 / 409. Not yet run on the cluster.
- Tests (
- Two passes, skipping I/O (#812). One pass saves decode CPU only: parquet-java fetches every requested column's pages before a row is decoded. With
-
TLS and HTTP client CPU (about 46% of q88's executor CPU). Options: fewer and larger range reads (#834 — small-file prefetch and range merging are now in the scan itself, behind off-by-default confs; see How reads are issued — since the benchmarks no longer read through the Analytics Accelerator, #835); HTTP/1.1 connection reuse settings; and checking that the CRT-based client really uses ACCP's AES-GCM. The 1 TB A/B that sets
smallFileThresholdandrangeMergeGap(and that could turn the column index back on, #873) is the remaining step. -
q88 iteration variance (38–110 s within one leg). Next step is a per-task timeline across iterations to tell stragglers apart from S3 throttling or connection churn.
-
Vectored reads.
parquet.hadoop.vectored.io.enabled=falsewas 3–8% faster in one A/B. Re-measure it with read-ahead on before changing any default.
Correctness and operations
- Run the golden suite with the flag on in CI, not only in local gates.
- Run the read-ahead tests under an executor-loss and task-retry stress test, to show that files opened ahead are always closed.
- Add metrics for the number of files opened ahead, the time task threads waited on read-ahead, and the number of fallback files.