Skip to content

Wide nested scans hold GiBs of native memory: tiny misaligned splits each decode whole column chunks #11123

Open

Description

@ChristopherWirt

What happened?

A JNI scan of one wide, nested Vortex file holds far more native memory than the Arrow batch Java has open. With list layout it is GiBs. Several scans per JVM, as a multi-core Spark executor runs, are enough to OOM-kill executors.

Measured with a single-threaded reader that loads one batch at a time and closes it before loading the next, as VortexPartitionReader does. Live native bytes come from a counting allocator:

File Splits Smallest split Peak live native
180 MB, list layout 139 70 rows 3.57 GiB
111 MB, default layout 59 — 224 MiB

One 176-row output array referenced 42 MB.

Cause:

  • A scan splits a file wherever any column's chunk ends.
  • A split decodes the whole chunk of every column it overlaps.
  • When a struct's fields chunk at unrelated rows, as list elements and dictionary-encoded columns of a wide nested file do, the union of their boundaries leaves spans of a few dozen rows.
  • Successive splits then each decode the same chunks of the other fields again.

I expected splits to be no narrower than the chunks they decode, so memory stays close to the batches being read.

Steps to reproduce

  1. Write a struct column whose fields chunk at different rows. For example, write two fields with RepartitionStrategy, one chunked every 1,000 rows and the other every 3,000.
  2. Scan it with SplitBy::LayoutSubSplitting (the default).
  3. Collect the splits: there is one at every boundary of either field, so most splits are much narrower than either field's chunks.

On a real file: write a few hundred leaf columns, with lists of structs several levels deep, using list layout. Then scan it through DataSource.open → scan(ScanOptions) → Partition.scanArrow, closing each batch before loading the next.

Environment

  • Vortex: develop (measured on a fork of develop that has the same scan code)
  • Entry point: Java JNI scan, also used by the Spark connector
  • Java 17, macOS (aarch64)

Additional context

What else was ruled out:

  • Not a leak across files. Scanning the same file six times in one process reaches the same peak each time.
  • Not batches in flight to Java. NativeRuntime.setWorkerThreads at 4, 1 and 0 barely moves the peak.
  • Not read-ahead I/O. readConcurrency at 0, 16, 8 and 2 leaves the peak unchanged.

The default 4 × available_parallelism() splits in flight makes this much worse: each of them holds whole chunks. #11124 proposes a per-scan cap for that.

I have a fix ready. The struct reader marks boundaries that only some of its fields have as soft, and LayoutSubSplitting merges spans that end at a soft boundary up to 8,192 rows. On the 180 MB file this cuts peak live memory from 3.57 to 1.10 GiB and scan time from 1.7 to 0.6 s.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions