Skip to content

[VL] Add Velox offload for Lance scans via Arrow C stream - #12958

Merged
zhouyuan merged 6 commits into
apache:mainfrom
sezruby:feat/velox-lance-scan
Oct 19, 2026
Merged

zhouyuan merged 6 commits into
apache:mainfrom
sezruby:feat/velox-lance-scan

Conversation

@sezruby

@sezruby sezruby commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

What changes are proposed in this pull request?

Offloads read-only Lance BatchScanExec to the Velox backend through the Arrow C Data Interface. This is the Gluten consumer side of the three-step Lance integration; both upstream pieces are released:

How it works

  • A new OffloadLanceScan rule matches a Lance BatchScanExec and rewrites it to LanceScanTransformer, which produces an ArrowJavaBatchType columnar RDD.
  • Per fragment, LanceArrowStreamScanner.export(...) hands back an Arrow C stream; Gluten wraps the stream address with ArrowArrayStream.wrap(...) / Data.importArrayStream(...) and loads the batches into ArrowWritableColumnVector. Only the Arrow C-struct address crosses the classloader boundary, so Gluten's Arrow version stays decoupled from lance-spark's.
  • Offload is gated: it engages only for a plain columnar Lance scan (no pushed aggregation, no full-text query). Anything else falls back to vanilla Spark.
  • A velox-lance Component registers the rule and activates only when lance-spark is on the classpath (org.lance.spark.read.LanceScan present).

Relationship to the Iceberg scan transformer

LanceScanTransformer is intentionally different in shape from IcebergScanTransformer. Iceberg extends BatchScanExecTransformerBase and is a native Velox scan: it emits Substrait, pushes filters into Velox, and reports scan-level SQL metrics. Lance instead extends LeafExecNode with ValidatablePlan and bridges an already-produced Arrow stream into Velox: filters and projection are pushed into the Lance reader by lance-spark before export, not into Velox, and there is an Arrow-Java → Velox conversion at import. Consequences of this boundary:

  • No native Velox scan pushdown for Lance; the Lance reader does the pruning.
  • No scan-level SQL metrics on the Lance node.
  • Schemas that require JVM post-processing (_rowid / _rowaddr / blob columns) are not offloaded.

These are follow-up opportunities, not blockers for the read-only offload. (See also #13140 for the more general "Spark Arrow batch → Velox" direction.)

Where the code lives / build

The read path is entirely Velox-specific (ArrowWritableColumnVector, ArrowBufferAllocators, ValidatablePlan), so it is placed under backends-velox/src-lance behind a new lance Maven profile — mirroring the src-iceberg Velox-integration layout rather than adding a top-level module.

  • lance.version (root pom.xml) = 0.8.0.
  • -Plance: compiles against lance-spark-base (provided). lance-core declares a newer Arrow than Gluten, so lance's transitive Arrow / jackson-jaxrs are excluded and Gluten's Arrow is kept (arrow-dataset is added at ${arrow.version}). This matches the published lance-spark-bundle-3.5, which ships Arrow 15. With these exclusions -Plance only adds jars to the classpath; it does not change any existing dependency version.
  • -Plance-test: adds the Spark-version-specific lance-spark-${sparkbundle.version} runtime (registers the lance data source) and the test sources.

Build: -Pbackends-velox -Pspark-3.5 -Plance. docs/get-started/VeloxLance.md documents scope, build, and requirements.

Open question for reviewers: a top-level gluten-lance module (like gluten-iceberg) makes sense once there is engine-agnostic code to host — e.g. a write path. The read-only offload has none today, so this PR keeps it Velox-local. Happy to restructure if you'd prefer the module up front.

How was this patch tested?

VeloxLanceSuite (-Plance -Plance-test) asserts:

  1. a columnar Lance scan offloads to LanceScanTransformer; with no Velox consumer its Arrow batches go straight to vanilla ColumnarToRowExec (no Velox round trip),
  2. with a Velox consumer (grouped aggregation) the scan feeds ArrowColumnarToVeloxColumnarExec(OffloadArrowDataExec(scan)) into a Velox aggregate, with no row conversion off the scan,
  3. projection + a pushed filter stay offloaded and return correct results,
  4. a multi-fragment dataset reads correctly across partitions, including a LIMIT that stops early (native scans are released per fragment on exhaustion, with a task-completion listener as the safety net),
  5. a pushed-down aggregation falls back to a vanilla BatchScanExec.

CI: the Spark 3.5 and Spark 4.0 x86 jobs (group 1, which runs the org.apache.gluten suites) now build with -Plance -Plance-test, so the suite runs on every PR. The suite sets spark.sql.ansi.enabled=false because Spark 4 defaults to ANSI mode.

Locally, the suite passes (5/5) on linux-aarch64 (apache/gluten:centos-8-jdk8, JDK 17, Spark 3.5 and Spark 4.0 / Scala 2.13) against lance-spark 0.8.0 and the current nightly Velox native libs:

./build/mvn test -Pspark-3.5 -Pjava-17 -Pbackends-velox -Plance -Plance-test -pl backends-velox -am \
  -Dtest=none -DwildcardSuites=org.apache.gluten.execution.VeloxLanceSuite

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code; GitHub Copilot CLI 1.0.93 (Claude Opus 5.5)

Relates to #12263.

@github-actions github-actions Bot added CORE works for Gluten Core VELOX labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@github-actions github-actions Bot added the DOCS label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@zhouyuan

Copy link
Copy Markdown
Member

@sezruby the coding style issue can be fixed by running this tool:
https://github.com/apache/gluten/blob/main/docs/developers/NewToGluten.md?plain=1#L94

@sezruby
sezruby force-pushed the feat/velox-lance-scan branch from 0a7569f to 5080d2f Compare September 21, 2026 19:41
@sezruby

sezruby commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @zhouyuan! Ran ./dev/format-scala-code.sh and pushed the fix (also rebased on latest main) — spotless:check is clean now.

Heads up on the rest of CI: this is still a draft because the lance-spark release carrying lance-format/lance-spark#778 isn't out yet, so the -Plance dependency (the 0.8.0-beta.2 placeholder) won't resolve until that beta ships. I'll bump lance.version and take it out of draft once it's published.

@github-actions

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@FelixYBW

FelixYBW commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

#13140
#12276

just link, no followup needed

This is the scan-side version of the same problem. With the Spark changes proposed in #13140, a connector-specific scan wouldn't be needed:

  • lance-spark's own BatchScanExec already produces columnar batches, and I expect they are Arrow-backed; I haven't checked lance-spark's reader.
  • If Spark let that node declare an Arrow batch type (SPARK-57468), Gluten would only need one general "Spark Arrow batch → Velox" conversion. Lance and every other Arrow data source would then work with no connector code, and would keep DPP, storage-partitioned join and metrics, because BatchScanExec stays in the plan.
  • The classloader/Arrow-version isolation this PR gets from the C stream could become a small optional Spark DSv2 interface, e.g. a PartitionReader that can exportArrowStream(address). The general conversion would use it when present and wrap the Java vectors otherwise.

@sezruby
sezruby force-pushed the feat/velox-lance-scan branch from 5080d2f to 5b47a2b Compare October 8, 2026 05:27
@github-actions github-actions Bot added the INFRA label Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@sezruby
sezruby marked this pull request as ready for review October 8, 2026 06:04
@sezruby
sezruby force-pushed the feat/velox-lance-scan branch from 5b47a2b to 10d5502 Compare October 8, 2026 18:19
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@zhouyuan
zhouyuan requested a balanced review from Copilot October 9, 2026 10:36

@zhouyuan zhouyuan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks.

TAGS_EXCLUDE="org.apache.spark.tags.ExtendedSQLTest,org.apache.spark.tags.SlowHiveTest,org.apache.gluten.tags.UDFTest,org.apache.gluten.tags.EnhancedFeaturesTest,org.apache.gluten.tags.CudfTest,org.apache.gluten.tags.SkipTest"
if [ "${{ matrix.group }}" = "1" ]; then
$MVN_CMD clean test -Pspark-3.5 -Pjava-17 -Pbackends-velox -Piceberg -Pdelta -Phudi -Ppaimon -Pspark-ut \
$MVN_CMD clean test -Pspark-3.5 -Pjava-17 -Pbackends-velox -Piceberg -Pdelta -Phudi -Ppaimon -Plance -Plance-test -Pspark-ut \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could also enable the 4.0 test for lance if it's available?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! lance-spark 0.8.0 publishes lance-spark-4.0_2.13, so I enabled -Plance -Plance-test in the Spark 4.0 job (group 1, which runs org.apache.gluten.*). VeloxLanceSuite passes 5/5 locally on Spark 3.5 and 4.0. I also pinned spark.sql.ansi.enabled=false in the suite, since Spark 4 defaults to ANSI. Docs updated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

4 open findings
What changed in this PR

Adds Velox-side offload support for read-only Lance BatchScanExec by importing Lance-exported Arrow C streams into Gluten’s Arrow-Java columnar batches, with opt-in build/test profiles and CI validation.

Changes:

  • Introduces a new Velox “lance” component + rule to rewrite exportable Lance scans to LanceScanTransformer.
  • Implements LanceScanTransformer that imports per-fragment Arrow C streams and emits ArrowJavaBatchType columnar batches.
  • Adds Maven profiles, documentation, CI wiring, and a new VeloxLanceSuite covering offload vs fallback cases.
File Description
pom.xml Adds lance / lance-test profiles to include Lance-specific sources/resources.
backends-velox/​pom.xml Adds Lance dependencies (provided/test) with Arrow/Jackson exclusions and Arrow dataset API.
backends-velox/​src-lance/​main/​.../​OffloadLanceScan.scala Adds heuristic rule to rewrite exportable Lance scans to LanceScanTransformer.
backends-velox/​src-lance/​main/​.../​LanceScanTransformer.scala Implements Arrow C stream import path into Arrow-Java columnar batches.
backends-velox/​src-lance/​main/​.../​VeloxLanceComponent.scala Registers the Lance offload component, gated by runtime class presence.
backends-velox/​src-lance/​test/​.../​VeloxLanceSuite.scala Adds execution-plan and correctness tests for offload/fallback behavior.
docs/​get-started/​VeloxLance.md Documents requirements, build flags, behavior, and limitations.
.github/​workflows/​velox_backend_x86.yml Enables Lance profiles in CI group 1 so new suite runs.

🧠 Review effort: Lite


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +142 to +152
val stream = ArrowArrayStream.wrap(handle.streamAddress())
val reader = Data.importArrayStream(allocator, stream)
context.addTaskCompletionListener[Unit] {
_ =>
try {
// Drains + runs the C release callback, tearing down the native scan.
reader.close()
} finally {
// Frees the caller-owned stream struct and closes the scanner + dataset.
handle.close()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d017739. Per-fragment reading is now in LanceFragmentIterator. If wrap/importArrayStream throws, the handle is closed before rethrowing, and any close failure is added as suppressed.

Comment on lines +141 to +168
val handle = LanceArrowStreamScanner.export(fragId.intValue(), lancePartition)
val stream = ArrowArrayStream.wrap(handle.streamAddress())
val reader = Data.importArrayStream(allocator, stream)
context.addTaskCompletionListener[Unit] {
_ =>
try {
// Drains + runs the C release callback, tearing down the native scan.
reader.close()
} finally {
// Frees the caller-owned stream struct and closes the scanner + dataset.
handle.close()
}
}

new Iterator[ColumnarBatch] {
private var advanced = false
private var hasMore = false

private def advance(): Unit =
if (!advanced) {
hasMore = reader.loadNextBatch()
advanced = true
}

override def hasNext: Boolean = {
advance()
hasMore
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d017739. Reader + handle are closed as soon as loadNextBatch() returns false, so a multi-fragment partition holds at most one open scan. close() is idempotent, and the task-completion listener stays as a safety net for early termination. Emitted batches own their transferred buffers, so closing early doesn't affect them. Added a multi-fragment test (including a LIMIT query that stops early and relies on the listener).

Comment thread pom.xml
Comment on lines +1972 to +2049
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<executions>
<execution>
<id>add-lance-sources</id>
<goals>
<goal>add-source</goal>
</goals>
<phase>generate-sources</phase>
<configuration>
<sources>
<source>${project.basedir}/src-lance/main/scala</source>
</sources>
</configuration>
</execution>
<execution>
<id>add-lance-resources</id>
<goals>
<goal>add-resource</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<resources>
<resource>
<directory>${project.basedir}/src-lance/main/resources</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>lance-test</id>
<activation>
<activeByDefault>false</activeByDefault>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<executions>
<execution>
<id>add-lance-test-sources</id>
<goals>
<goal>add-test-source</goal>
</goals>
<phase>generate-test-sources</phase>
<configuration>
<sources>
<source>${project.basedir}/src-lance/test/scala</source>
</sources>
</configuration>
</execution>
<execution>
<id>add-lance-test-resources</id>
<goals>
<goal>add-test-resource</goal>
</goals>
<phase>generate-test-resources</phase>
<configuration>
<resources>
<resource>
<directory>${project.basedir}/src-lance/test/resources</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional and mirrors the existing delta/iceberg/hudi/paimon profiles in the root pom, which add src-<format> roots the same way. build-helper skips non-existent directories, so other modules are unaffected.

Comment thread docs/get-started/VeloxLance.md Outdated

## Reading

Offload / Fallback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed, thanks.

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

sezruby and others added 6 commits October 14, 2026 11:46
Offloads read-only Lance BatchScanExec to the Velox backend through the
Arrow C Data Interface, using lance-spark's LanceArrowStreamScanner
(lance-spark#778). Only the Arrow C-struct address crosses the classloader
boundary, so Gluten's Arrow version stays decoupled from lance-spark's.

The offload is gated on being a plain columnar Lance scan (no pushed
aggregation, no full-text query); anything else falls back to vanilla
Spark. A velox-lance Component registers the rule and only engages when
lance-spark is on the classpath.

Code lives under backends-velox/src-lance behind a new `lance` Maven
profile, mirroring the src-iceberg velox-integration layout. The read path
is entirely velox-specific (ArrowWritableColumnVector, ArrowBufferAllocators,
ValidatablePlan), so no top-level gluten-lance module is added yet; one can
be introduced when an engine-agnostic write path lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Supportability follow-ups to the Lance Velox offload:

- lance-test profile (root pom): add-test-source/add-test-resource for
  backends-velox/src-lance/test, activated alongside -Plance.
- VeloxLanceSuite: asserts a columnar Lance scan offloads to
  LanceScanTransformer, that projection + pushed filter stay offloaded with
  correct results, and that a pushed-down aggregation falls back to a vanilla
  BatchScanExec. API-grounded against VeloxWholeStageTransformerSuite
  (checkGlutenPlan/checkSparkPlan/runQueryAndCompare, Iceberg-suite parity).
- docs/get-started/VeloxLance.md: read-only scope, -Plance build, lance-spark
  version + linux-x86-64 platform requirements, offload/fallback conditions,
  and the Arrow C stream topology note.

The suite requires the lance-spark native runtime, published for linux-x86-64
only, so it executes in that environment; the offload/fallback behavior it
encodes was validated under linux-x86-64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ran ./dev/format-scala-code.sh (spotless:apply) so the Lance offload
sources conform to Gluten's scalafmt config -- wraps javadoc lines over
the 100-column limit and drops a stray import-group blank line.
spotless:check is now clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pin lance.version to the released lance-spark 0.8.0 (first release with
  LanceArrowStreamScanner) in the root pom.
- lance-test: add the Spark-version-specific lance-spark artifact, which
  registers the "lance" data source used by the suite.
- Keep Gluten's Arrow on the classpath: exclude lance-core's newer Arrow and
  jackson-jaxrs, and add arrow-dataset at ${arrow.version}.
- Fix an executor-side NPE: LanceScanTransformer.output was read through the
  transient batchScan after serialization (e.g. by DeserializeToObjectExec on
  collect()); capture it eagerly.
- Enable -Plance -Plance-test in the Spark 3.5 x86 CI job.
- Docs: lance-spark 0.8.0 requirement, supported native platforms, test command.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Plain scan: the Lance scan's Arrow Java batches go straight to vanilla
  ColumnarToRowExec (no Velox round trip).
- New test: with a Velox consumer (grouped aggregation), the scan feeds
  ArrowColumnarToVeloxColumnarExec(OffloadArrowDataExec(scan)) and is never
  converted to rows first.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ark 4.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sezruby
sezruby force-pushed the feat/velox-lance-scan branch from d017739 to 0f9b38a Compare October 14, 2026 18:48
@github-actions

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@zhouyuan
zhouyuan merged commit d9b84ee into apache:main Oct 19, 2026
77 checks passed
@FelixYBW

FelixYBW commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

For the record: longer term, Gluten should reuse lance-spark's own columnar scan, with at most a thin LanceScanTransformer, instead of having its own execution path.

Proposal. lance-spark keeps the standard BatchScanExec → DataSourceRDD → createColumnarReader path and adds an option to emit plain Arrow batches. Spark's native data source PR (apache/spark#59313) works the same way: it imports an ArrowArrayStream into ArrowColumnVectors with supportColumnarReads = true. Gluten then only has to recognize the batches as Arrow, and LanceScanTransformer / LanceColumnarRDD / LanceFragmentIterator are no longer needed. Staying on DataSourceRDD also keeps what this PR's custom RDD skips today: Lance custom metrics, runtime filtering of partitions, the scan's output partitioning, and the OpenTelemetry setup.

Three pieces are needed:

1. lance-spark: an Arrow output mode in the columnar reader

Today LanceFragmentColumnarBatchScanner.buildSparkOrderedVectors doesn't produce plain Arrow batches:

  • It wraps each column in lance's own LanceArrowColumnVector.
  • It adds JVM-only virtual columns: ConstantColumnVector for _fragid, and BlobPositionColumnVector / BlobSizeColumnVector for blob columns.

An Arrow mode would:

  • Emit Spark's ArrowColumnVector in Spark schema order. The reader already reorders columns, so this also covers the reordered-column case that LanceArrowStreamScanner.export() rejects today.
  • Build _fragid as a real Arrow vector (a filled IntVector).
  • For blob position/size columns, either build real Arrow vectors or mark the partition as not Arrow-capable. Gluten could then leave the scan on vanilla Spark at planning time instead of failing at runtime in export().
  • Document the batch lifetime. ArrowReader reuses one VectorSchemaRoot, so a batch is valid only until the next next(), and consumers must transfer the vectors to keep them. [SPARK-60101][SQL] Support native data sources implemented in Rust or C++ spark#59313 has the same reuse behavior.

2. lance-spark: solve the Arrow version/classloader mismatch on its side

This mismatch is why this PR passes a C-struct address. If lance-spark's batches carry lance's own (newer or relocated) Arrow classes, Gluten can't use them as Arrow. Two options:

  • lance-spark builds the Arrow-mode output vectors with Spark's Arrow, doing the C Data import itself from the stream it already exports.
  • Or the batch exposes only a C Data export hook, and Gluten does the import.

The first is cleaner and matches apache/spark#59313.

3. Gluten: declare the batch type and accept Spark's ArrowColumnVector

  • Batch type. On Spark 3.5/4.0, Spark treats BatchScanExec output as vanilla columnar. Gluten needs a small rule that marks BatchScanExec(LanceScan) in Arrow mode as ArrowJavaBatchType while still calling vanilla doExecuteColumnar(). With SPARK-57468 (Spark 4.2+), the scan can declare ArrowBatchType itself and the rule isn't needed.
  • Offload input. ColumnarBatches.offload currently requires ArrowWritableColumnVector (isHeavyBatch). It needs to accept Spark's ArrowColumnVector and export from its FieldVector through C Data. [GLUTEN-13140][VL] Replace ArrowWritableColumnVector with Spark's ArrowColumnVector and ArrowWriter #13280 is doing this.

With these in place, the plan becomes BatchScanExec(Lance) → OffloadArrowDataExec → ArrowColumnarToVeloxColumnarExec → Velox, using only existing transitions. Fixed-width columns are zero-copy, and the only Lance-specific Gluten code left is the batch-type rule. The same rule would also work for apache/spark#59313 native sources and any other DSv2 source that emits Arrow.

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

Labels

CORE works for Gluten Core DOCS INFRA VELOX

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants