[ 
https://issues.apache.org/jira/browse/SPARK-58060?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Jakub Wozniak updated SPARK-58060:
----------------------------------
    Description: 
*Summary*

Reading a Parquet file whose data pages are large (which `parquet-mr` produces 
for tables
with few rows but very large `array`/`list` values) is **~100–270× slower** 
than reading the
exact same data written with small pages, and than reading it with pyarrow. The 
cost is in
the vectorized Parquet page-decode path and no reader configuration mitigates 
it.

*Steps to reproduce (self-contained, no external data)*

{code:python}
import time
from pyspark.sql import SparkSession
import pyspark.sql.functions as F

N_ROWS, N_ELEM = 24, 1_000_000     # 24 rows x 1M doubles/row (~192MB)
spark = (SparkSession.builder.master('local[4]').appName('pagesize-repro')
         .config('spark.driver.memory', '16g')
         .config('spark.driver.extraJavaOptions', '-XX:MaxDirectMemorySize=8g')
         .getOrCreate())

df = (spark.range(N_ROWS)
      .select(F.transform(F.sequence(F.lit(1), F.lit(N_ELEM)),
                          lambda i: F.rand()).alias('wave')))

def write_read(path, opts, label, n_reads=3):
    hc = spark._jsc.hadoopConfiguration()
    hc.set('parquet.page.size.row.check.min', '100')     # parquet-mr defaults
    hc.set('parquet.page.size.row.check.max', '10000')
    hc.set('parquet.page.size', str(1024*1024))
    for k, v in opts.items():
        hc.set(k, v)
    df.coalesce(1).write.mode('overwrite').parquet('file://'+path)
    for i in range(n_reads):
        s = time.time()
        
spark.read.parquet('file://'+path).select('wave').write.format('noop').mode('overwrite').save()
        print(f"[{time.time()-s:8.1f}s]  read #{i+1}  {label}", flush=True)

# Read the small-page file FIRST: its 3 reads are fast and fully warm the JVM 
(JIT,
# whole-stage codegen, the general Parquet read path). The big-page file's 
FIRST read is
# still ~150x slower despite that warm-up -> the penalty is specific to the 
giant-page
# decode path and is not relieved by warming on normal (small-page) data.
write_read('/tmp/repro_smallpage', {'parquet.page.size.row.check.min':'1',
                                    'parquet.page.size.row.check.max':'1'},
           "small-page file (many small pages) -- warms the JVM")
write_read('/tmp/repro_bigpage',   {},
           "BIG-page file (parquet-mr default, few huge pages) -- read #1 STILL 
slow after warmup")
spark.stop()
{code}

*Actual result (Spark 3.5.3)*

{noformat}
[     1.2s]  read #1  small-page file (many small pages) -- warms the JVM
[     0.7s]  read #2  small-page file (many small pages) -- warms the JVM
[     0.5s]  read #3  small-page file (many small pages) -- warms the JVM
[   142.4s]  read #1  BIG-page file (parquet-mr default) -- read #1 STILL slow 
after warmup
[     1.9s]  read #2  BIG-page file (parquet-mr default)
[     1.2s]  read #3  BIG-page file (parquet-mr default)
{noformat}

Same data, same reader. Three fast small-page reads fully warm the JVM, yet the 
big-page
file's first read is still ~150× slower — then its own subsequent reads are 
fast. This
shows the cost is (a) specific to large data pages and (b) a 
giant-page-specific cold JIT
cost, not relieved by warming on normal data.

*Expected result*

The first read of a Parquet file with large data pages should be comparable to 
reading the
same data with small pages (and to pyarrow, which reads the equivalent 1 GB 
real-world file
in ~4 s where Spark takes 5–18 min).

*Why this matters (real-world trigger)*

`parquet-mr` decides when to flush a data page using a **row-count** check
(`parquet.page.size.row.check.min`, default 100 rows), estimating page bytes 
only every N
rows. A table of wide rows (e.g. one `array<float>` of ~2M elements ≈ 8–15 MB 
per row, a few
dozen rows per row group — common for scientific/waveform data) therefore gets 
**a few
hundred-MB data pages**. Any Spark job reading such files — produced by Spark's 
own writer or
any `parquet-mr` producer — hits this. pyarrow flushes pages by bytes and is 
unaffected.

*Isolation performed*

1. **Real 1 GB file** (170 rows, `array<float>` ~7.6 MB/row): Spark 333 s vs 
pyarrow 4.4 s;
   an 85-row sibling: Spark 1065 s vs pyarrow 3.9 s. Scalar columns from the 
same file: 0.1 s.
2. **Writer is the only variable:** same 30×2M-float data written by pyarrow 
reads in Spark in
   1.6 s; written by `parquet-mr` (Spark's writer) reads in 218 s.
3. **`parquet.page.size` sweep** (Spark writes, Spark reads): 1 MB → 1.1 s, 8 
MB → 1.1 s,
   64 MB → 14.4 s, 256 MB → 52 s. Superlinear in page size.
4. **No reader config mitigates** (real 1 GB file, all ~330–410 s):
   `spark.sql.parquet.enableVectorizedReader=false`,
   `enableNestedColumnVectorizedReader=false`, `columnarReaderBatchSize=1` and 
`=16`.
   Both the vectorized and the row-based readers are affected.
5. **Mitigation confirmed on the real data:** rewriting the file with 
byte-flushed 1 MB pages
   (`parquet.page.size.row.check.min=1`) turns the 333 s read into 2.9 s.

*Observations that may help diagnosis*

These are just what we measured while narrowing it down; we may well be 
misreading them, and
we defer to the maintainers on the actual cause. The slow read appears to be a 
*cold
first-read* cost within a JVM rather than steady-state decode cost:

- Re-reading the same file in 5 consecutive *fresh* JVMs is slow every time (so 
it does not
  look like OS page cache).
- The 142 s cold read shows 0 GC collections / 0 ms GC time and a flat heap, 
i.e. it seems
  CPU-bound rather than allocation/GC-bound.
- Within one JVM, after the first big-page read, a *different* big-page file 
reads in ~1 s —
  suggesting the warm-up is code-level rather than data/file-specific.
- Reading small-page files first does **not** speed up the first big-page read 
(still ~140 s),
  so whatever warms up seems specific to the large-page path.

Cold-read stack samples (single worker thread) were consistently in the page 
decoder:

{noformat}
VectorizedColumnReader.readBatch -> readPage -> readPageV1 -> 
VectorizedColumnReader$1.visit
    java.io.DataInputStream.readFully
    jdk.internal.misc.Unsafe.setMemory
{noformat}

One *possible* reading (very much a guess) is that a single large page is 
decoded in one long
`readPageV1` invocation, and the intrinsic-backed operations there 
(`Unsafe.setMemory`,
`DataInputStream.readFully`) are cheap once JIT-compiled but expensive on the 
first,
interpreted pass — with many small pages the path stays warm from normal 
workloads. We are
not confident in this and would appreciate the maintainers' assessment.

*Potential directions (to be checked)*

We are not sure what the right fix is; a few directions that *might* be worth 
considering:

- Whether the large-page decode path could be exercised/compiled earlier, or 
processed in
  bounded chunks, so the first large page is not decoded on a cold path.
- Whether this is considered a reader issue at all, or expected given how 
`parquet-mr` sizes
  pages (its default row-count-based page flush produces very large pages for 
wide-row tables).
- At minimum, documenting the interaction may help others who hit it.

*Workaround*

Writing these tables with byte-based page flushing so pages stay ~1 MB avoids 
the slow read
entirely (on our real data this changed a 333 s read to 2.9 s):

{noformat}
parquet.page.size.row.check.min = 1
parquet.page.size.row.check.max = 1
parquet.page.size               = 1048576
{noformat}

Performance of writes with such settings yet to be checked... 

  was:
## Summary

Reading a Parquet file whose data pages are large (which `parquet-mr` produces 
for tables
with few rows but very large `array`/`list` values) is **~100–270× slower** 
than reading the
exact same data written with small pages, and than reading it with pyarrow. The 
cost is in
the vectorized Parquet page-decode path and no reader configuration mitigates 
it.

## Steps to reproduce (self-contained, no external data)

{code:python}
import time
from pyspark.sql import SparkSession
import pyspark.sql.functions as F

N_ROWS, N_ELEM = 24, 1_000_000     # 24 rows x 1M doubles/row (~192MB)
spark = (SparkSession.builder.master('local[4]').appName('pagesize-repro')
         .config('spark.driver.memory', '16g')
         .config('spark.driver.extraJavaOptions', '-XX:MaxDirectMemorySize=8g')
         .getOrCreate())

df = (spark.range(N_ROWS)
      .select(F.transform(F.sequence(F.lit(1), F.lit(N_ELEM)),
                          lambda i: F.rand()).alias('wave')))

def write_read(path, opts, label, n_reads=3):
    hc = spark._jsc.hadoopConfiguration()
    hc.set('parquet.page.size.row.check.min', '100')     # parquet-mr defaults
    hc.set('parquet.page.size.row.check.max', '10000')
    hc.set('parquet.page.size', str(1024*1024))
    for k, v in opts.items():
        hc.set(k, v)
    df.coalesce(1).write.mode('overwrite').parquet('file://'+path)
    for i in range(n_reads):
        s = time.time()
        
spark.read.parquet('file://'+path).select('wave').write.format('noop').mode('overwrite').save()
        print(f"[{time.time()-s:8.1f}s]  read #{i+1}  {label}", flush=True)

# Read the small-page file FIRST: its 3 reads are fast and fully warm the JVM 
(JIT,
# whole-stage codegen, the general Parquet read path). The big-page file's 
FIRST read is
# still ~150x slower despite that warm-up -> the penalty is specific to the 
giant-page
# decode path and is not relieved by warming on normal (small-page) data.
write_read('/tmp/repro_smallpage', {'parquet.page.size.row.check.min':'1',
                                    'parquet.page.size.row.check.max':'1'},
           "small-page file (many small pages) -- warms the JVM")
write_read('/tmp/repro_bigpage',   {},
           "BIG-page file (parquet-mr default, few huge pages) -- read #1 STILL 
slow after warmup")
spark.stop()
{code}

## Actual result (Spark 3.5.3)

{noformat}
[     1.2s]  read #1  small-page file (many small pages) -- warms the JVM
[     0.7s]  read #2  small-page file (many small pages) -- warms the JVM
[     0.5s]  read #3  small-page file (many small pages) -- warms the JVM
[   142.4s]  read #1  BIG-page file (parquet-mr default) -- read #1 STILL slow 
after warmup
[     1.9s]  read #2  BIG-page file (parquet-mr default)
[     1.2s]  read #3  BIG-page file (parquet-mr default)
{noformat}

Same data, same reader. Three fast small-page reads fully warm the JVM, yet the 
big-page
file's first read is still ~150× slower — then its own subsequent reads are 
fast. This
shows the cost is (a) specific to large data pages and (b) a 
giant-page-specific cold JIT
cost, not relieved by warming on normal data.

## Expected result

The first read of a Parquet file with large data pages should be comparable to 
reading the
same data with small pages (and to pyarrow, which reads the equivalent 1 GB 
real-world file
in ~4 s where Spark takes 5–18 min).

## Why this matters (real-world trigger)

`parquet-mr` decides when to flush a data page using a **row-count** check
(`parquet.page.size.row.check.min`, default 100 rows), estimating page bytes 
only every N
rows. A table of wide rows (e.g. one `array<float>` of ~2M elements ≈ 8–15 MB 
per row, a few
dozen rows per row group — common for scientific/waveform data) therefore gets 
**a few
hundred-MB data pages**. Any Spark job reading such files — produced by Spark's 
own writer or
any `parquet-mr` producer — hits this. pyarrow flushes pages by bytes and is 
unaffected.

## Isolation performed

1. **Real 1 GB file** (170 rows, `array<float>` ~7.6 MB/row): Spark 333 s vs 
pyarrow 4.4 s;
   an 85-row sibling: Spark 1065 s vs pyarrow 3.9 s. Scalar columns from the 
same file: 0.1 s.
2. **Writer is the only variable:** same 30×2M-float data written by pyarrow 
reads in Spark in
   1.6 s; written by `parquet-mr` (Spark's writer) reads in 218 s.
3. **`parquet.page.size` sweep** (Spark writes, Spark reads): 1 MB → 1.1 s, 8 
MB → 1.1 s,
   64 MB → 14.4 s, 256 MB → 52 s. Superlinear in page size.
4. **No reader config mitigates** (real 1 GB file, all ~330–410 s):
   `spark.sql.parquet.enableVectorizedReader=false`,
   `enableNestedColumnVectorizedReader=false`, `columnarReaderBatchSize=1` and 
`=16`.
   Both the vectorized and the row-based readers are affected.
5. **Mitigation confirmed on the real data:** rewriting the file with 
byte-flushed 1 MB pages
   (`parquet.page.size.row.check.min=1`) turns the 333 s read into 2.9 s.

## Observations that may help diagnosis

These are just what we measured while narrowing it down; we may well be 
misreading them, and
we defer to the maintainers on the actual cause. The slow read appears to be a 
*cold
first-read* cost within a JVM rather than steady-state decode cost:

- Re-reading the same file in 5 consecutive *fresh* JVMs is slow every time (so 
it does not
  look like OS page cache).
- The 142 s cold read shows 0 GC collections / 0 ms GC time and a flat heap, 
i.e. it seems
  CPU-bound rather than allocation/GC-bound.
- Within one JVM, after the first big-page read, a *different* big-page file 
reads in ~1 s —
  suggesting the warm-up is code-level rather than data/file-specific.
- Reading small-page files first does **not** speed up the first big-page read 
(still ~140 s),
  so whatever warms up seems specific to the large-page path.

Cold-read stack samples (single worker thread) were consistently in the page 
decoder:

{noformat}
VectorizedColumnReader.readBatch -> readPage -> readPageV1 -> 
VectorizedColumnReader$1.visit
    java.io.DataInputStream.readFully
    jdk.internal.misc.Unsafe.setMemory
{noformat}

One *possible* reading (very much a guess) is that a single large page is 
decoded in one long
`readPageV1` invocation, and the intrinsic-backed operations there 
(`Unsafe.setMemory`,
`DataInputStream.readFully`) are cheap once JIT-compiled but expensive on the 
first,
interpreted pass — with many small pages the path stays warm from normal 
workloads. We are
not confident in this and would appreciate the maintainers' assessment.

## Potential directions (for maintainers to weigh)

We are not sure what the right fix is; a few directions that *might* be worth 
considering:

- Whether the large-page decode path could be exercised/compiled earlier, or 
processed in
  bounded chunks, so the first large page is not decoded on a cold path.
- Whether this is considered a reader issue at all, or expected given how 
`parquet-mr` sizes
  pages (its default row-count-based page flush produces very large pages for 
wide-row tables).
- At minimum, documenting the interaction may help others who hit it.

## Workaround (works for us today)

Writing these tables with byte-based page flushing so pages stay ~1 MB avoids 
the slow read
entirely (on our real data this changed a 333 s read to 2.9 s):

{noformat}
parquet.page.size.row.check.min = 1
parquet.page.size.row.check.max = 1
parquet.page.size               = 1048576
{noformat}

Performance of writes with such settings yet to be checked... 


> Reading array columns from Parquet files with large data pages extremely slow
> -----------------------------------------------------------------------------
>
>                 Key: SPARK-58060
>                 URL: https://issues.apache.org/jira/browse/SPARK-58060
>             Project: Spark
>          Issue Type: Bug
>          Components: SQL
>    Affects Versions: 3.5.3
>         Environment: Spark 3.5.3, Java 11. 
>            Reporter: Jakub Wozniak
>            Priority: Major
>              Labels: parquet, perfomance, vectorization
>
> *Summary*
> Reading a Parquet file whose data pages are large (which `parquet-mr` 
> produces for tables
> with few rows but very large `array`/`list` values) is **~100–270× slower** 
> than reading the
> exact same data written with small pages, and than reading it with pyarrow. 
> The cost is in
> the vectorized Parquet page-decode path and no reader configuration mitigates 
> it.
> *Steps to reproduce (self-contained, no external data)*
> {code:python}
> import time
> from pyspark.sql import SparkSession
> import pyspark.sql.functions as F
> N_ROWS, N_ELEM = 24, 1_000_000     # 24 rows x 1M doubles/row (~192MB)
> spark = (SparkSession.builder.master('local[4]').appName('pagesize-repro')
>          .config('spark.driver.memory', '16g')
>          .config('spark.driver.extraJavaOptions', 
> '-XX:MaxDirectMemorySize=8g')
>          .getOrCreate())
> df = (spark.range(N_ROWS)
>       .select(F.transform(F.sequence(F.lit(1), F.lit(N_ELEM)),
>                           lambda i: F.rand()).alias('wave')))
> def write_read(path, opts, label, n_reads=3):
>     hc = spark._jsc.hadoopConfiguration()
>     hc.set('parquet.page.size.row.check.min', '100')     # parquet-mr defaults
>     hc.set('parquet.page.size.row.check.max', '10000')
>     hc.set('parquet.page.size', str(1024*1024))
>     for k, v in opts.items():
>         hc.set(k, v)
>     df.coalesce(1).write.mode('overwrite').parquet('file://'+path)
>     for i in range(n_reads):
>         s = time.time()
>         
> spark.read.parquet('file://'+path).select('wave').write.format('noop').mode('overwrite').save()
>         print(f"[{time.time()-s:8.1f}s]  read #{i+1}  {label}", flush=True)
> # Read the small-page file FIRST: its 3 reads are fast and fully warm the JVM 
> (JIT,
> # whole-stage codegen, the general Parquet read path). The big-page file's 
> FIRST read is
> # still ~150x slower despite that warm-up -> the penalty is specific to the 
> giant-page
> # decode path and is not relieved by warming on normal (small-page) data.
> write_read('/tmp/repro_smallpage', {'parquet.page.size.row.check.min':'1',
>                                     'parquet.page.size.row.check.max':'1'},
>            "small-page file (many small pages) -- warms the JVM")
> write_read('/tmp/repro_bigpage',   {},
>            "BIG-page file (parquet-mr default, few huge pages) -- read #1 
> STILL slow after warmup")
> spark.stop()
> {code}
> *Actual result (Spark 3.5.3)*
> {noformat}
> [     1.2s]  read #1  small-page file (many small pages) -- warms the JVM
> [     0.7s]  read #2  small-page file (many small pages) -- warms the JVM
> [     0.5s]  read #3  small-page file (many small pages) -- warms the JVM
> [   142.4s]  read #1  BIG-page file (parquet-mr default) -- read #1 STILL 
> slow after warmup
> [     1.9s]  read #2  BIG-page file (parquet-mr default)
> [     1.2s]  read #3  BIG-page file (parquet-mr default)
> {noformat}
> Same data, same reader. Three fast small-page reads fully warm the JVM, yet 
> the big-page
> file's first read is still ~150× slower — then its own subsequent reads are 
> fast. This
> shows the cost is (a) specific to large data pages and (b) a 
> giant-page-specific cold JIT
> cost, not relieved by warming on normal data.
> *Expected result*
> The first read of a Parquet file with large data pages should be comparable 
> to reading the
> same data with small pages (and to pyarrow, which reads the equivalent 1 GB 
> real-world file
> in ~4 s where Spark takes 5–18 min).
> *Why this matters (real-world trigger)*
> `parquet-mr` decides when to flush a data page using a **row-count** check
> (`parquet.page.size.row.check.min`, default 100 rows), estimating page bytes 
> only every N
> rows. A table of wide rows (e.g. one `array<float>` of ~2M elements ≈ 8–15 MB 
> per row, a few
> dozen rows per row group — common for scientific/waveform data) therefore 
> gets **a few
> hundred-MB data pages**. Any Spark job reading such files — produced by 
> Spark's own writer or
> any `parquet-mr` producer — hits this. pyarrow flushes pages by bytes and is 
> unaffected.
> *Isolation performed*
> 1. **Real 1 GB file** (170 rows, `array<float>` ~7.6 MB/row): Spark 333 s vs 
> pyarrow 4.4 s;
>    an 85-row sibling: Spark 1065 s vs pyarrow 3.9 s. Scalar columns from the 
> same file: 0.1 s.
> 2. **Writer is the only variable:** same 30×2M-float data written by pyarrow 
> reads in Spark in
>    1.6 s; written by `parquet-mr` (Spark's writer) reads in 218 s.
> 3. **`parquet.page.size` sweep** (Spark writes, Spark reads): 1 MB → 1.1 s, 8 
> MB → 1.1 s,
>    64 MB → 14.4 s, 256 MB → 52 s. Superlinear in page size.
> 4. **No reader config mitigates** (real 1 GB file, all ~330–410 s):
>    `spark.sql.parquet.enableVectorizedReader=false`,
>    `enableNestedColumnVectorizedReader=false`, `columnarReaderBatchSize=1` 
> and `=16`.
>    Both the vectorized and the row-based readers are affected.
> 5. **Mitigation confirmed on the real data:** rewriting the file with 
> byte-flushed 1 MB pages
>    (`parquet.page.size.row.check.min=1`) turns the 333 s read into 2.9 s.
> *Observations that may help diagnosis*
> These are just what we measured while narrowing it down; we may well be 
> misreading them, and
> we defer to the maintainers on the actual cause. The slow read appears to be 
> a *cold
> first-read* cost within a JVM rather than steady-state decode cost:
> - Re-reading the same file in 5 consecutive *fresh* JVMs is slow every time 
> (so it does not
>   look like OS page cache).
> - The 142 s cold read shows 0 GC collections / 0 ms GC time and a flat heap, 
> i.e. it seems
>   CPU-bound rather than allocation/GC-bound.
> - Within one JVM, after the first big-page read, a *different* big-page file 
> reads in ~1 s —
>   suggesting the warm-up is code-level rather than data/file-specific.
> - Reading small-page files first does **not** speed up the first big-page 
> read (still ~140 s),
>   so whatever warms up seems specific to the large-page path.
> Cold-read stack samples (single worker thread) were consistently in the page 
> decoder:
> {noformat}
> VectorizedColumnReader.readBatch -> readPage -> readPageV1 -> 
> VectorizedColumnReader$1.visit
>     java.io.DataInputStream.readFully
>     jdk.internal.misc.Unsafe.setMemory
> {noformat}
> One *possible* reading (very much a guess) is that a single large page is 
> decoded in one long
> `readPageV1` invocation, and the intrinsic-backed operations there 
> (`Unsafe.setMemory`,
> `DataInputStream.readFully`) are cheap once JIT-compiled but expensive on the 
> first,
> interpreted pass — with many small pages the path stays warm from normal 
> workloads. We are
> not confident in this and would appreciate the maintainers' assessment.
> *Potential directions (to be checked)*
> We are not sure what the right fix is; a few directions that *might* be worth 
> considering:
> - Whether the large-page decode path could be exercised/compiled earlier, or 
> processed in
>   bounded chunks, so the first large page is not decoded on a cold path.
> - Whether this is considered a reader issue at all, or expected given how 
> `parquet-mr` sizes
>   pages (its default row-count-based page flush produces very large pages for 
> wide-row tables).
> - At minimum, documenting the interaction may help others who hit it.
> *Workaround*
> Writing these tables with byte-based page flushing so pages stay ~1 MB avoids 
> the slow read
> entirely (on our real data this changed a 333 s read to 2.9 s):
> {noformat}
> parquet.page.size.row.check.min = 1
> parquet.page.size.row.check.max = 1
> parquet.page.size               = 1048576
> {noformat}
> Performance of writes with such settings yet to be checked... 



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to