This is an automated email from the ASF dual-hosted git repository.

zhouyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new d64a51dfd0 [GLUTEN-13487][VL] Offload Lance scans with vector columns 
(#13488)
d64a51dfd0 is described below

commit d64a51dfd01bbe1b8e4adb6c1f74331fb75f531f
Author: EJ Song <[email protected]>
AuthorDate: Mon Nov 2 22:33:51 2026 -0800

    [GLUTEN-13487][VL] Offload Lance scans with vector columns (#13488)
---
 .../gluten/execution/LanceScanTransformer.scala    | 129 ++++++++-
 .../execution/LanceFixedSizeListsSuite.scala       | 322 +++++++++++++++++++++
 .../apache/gluten/execution/VeloxLanceSuite.scala  | 196 ++++++++++++-
 docs/backends/velox/get-started/VeloxLance.md      |   6 +
 4 files changed, 632 insertions(+), 21 deletions(-)

diff --git 
a/backends-velox/src-lance/main/scala/org/apache/gluten/execution/LanceScanTransformer.scala
 
b/backends-velox/src-lance/main/scala/org/apache/gluten/execution/LanceScanTransformer.scala
index 7a3c8bf002..e603e2bfab 100644
--- 
a/backends-velox/src-lance/main/scala/org/apache/gluten/execution/LanceScanTransformer.scala
+++ 
b/backends-velox/src-lance/main/scala/org/apache/gluten/execution/LanceScanTransformer.scala
@@ -34,8 +34,11 @@ import org.apache.spark.sql.vectorized.{ColumnarBatch, 
ColumnVector}
 
 import org.apache.arrow.c.{ArrowArrayStream, Data}
 import org.apache.arrow.memory.BufferAllocator
-import org.apache.arrow.vector.FieldVector
+import org.apache.arrow.vector.{FieldVector, VectorSchemaRoot}
+import org.apache.arrow.vector.complex.{BaseRepeatedValueVector, 
FixedSizeListVector}
 import org.apache.arrow.vector.ipc.ArrowReader
+import org.apache.arrow.vector.ipc.message.ArrowFieldNode
+import org.apache.arrow.vector.types.pojo.{ArrowType, Field, FieldType}
 import org.lance.spark.internal.LanceArrowStreamScanner
 import org.lance.spark.read.{LanceInputPartition, LanceScan}
 
@@ -202,17 +205,7 @@ private class LanceFragmentIterator(
     advanced = false
     val root = reader.getVectorSchemaRoot
     val rowCount = root.getRowCount
-    // Transfer (zero-copy move) each column out of the reader's reused root 
so the emitted batch
-    // owns its buffers: it stays valid after the reader is closed, and is 
safe for the offload
-    // transition to close.
-    val transferred = new 
java.util.ArrayList[FieldVector](root.getFieldVectors.size())
-    root.getFieldVectors.asScala.foreach {
-      fv =>
-        val pair = fv.getTransferPair(allocator)
-        pair.transfer()
-        transferred.add(pair.getTo.asInstanceOf[FieldVector])
-    }
-    val vectors = ArrowColumnVectors.wrap(transferred)
+    val vectors = 
ArrowColumnVectors.wrap(LanceFragmentIterator.takeColumns(root, allocator))
     new ColumnarBatch(vectors.map(_.asInstanceOf[ColumnVector]), rowCount)
   }
 
@@ -228,3 +221,115 @@ private class LanceFragmentIterator(
       }
     }
 }
+
+private object LanceFragmentIterator {
+
+  /**
+   * Transfers (zero-copy moves) each column out of the reader's reused `root` 
so the emitted batch
+   * owns its buffers: it stays valid after the reader is closed, and is safe 
for the offload
+   * transition to close. Columns holding a FixedSizeList (Lance vectors) are 
rebuilt as Lists on
+   * the way out, still without copying their values. If a column fails, the 
columns already taken
+   * are released.
+   */
+  def takeColumns(
+      root: VectorSchemaRoot,
+      allocator: BufferAllocator): java.util.List[FieldVector] = {
+    val taken = new 
java.util.ArrayList[FieldVector](root.getFieldVectors.size())
+    try {
+      root.getFieldVectors.asScala.foreach {
+        fv =>
+          if (FixedSizeLists.contains(fv.getField)) {
+            taken.add(FixedSizeLists.toLists(fv, allocator))
+          } else {
+            val pair = fv.getTransferPair(allocator)
+            pair.transfer()
+            taken.add(pair.getTo.asInstanceOf[FieldVector])
+          }
+      }
+      taken
+    } catch {
+      case t: Throwable =>
+        taken.asScala.foreach(_.close())
+        throw t
+    }
+  }
+}
+
+/**
+ * Rewrites FixedSizeList vectors, which is how Lance stores vector 
(embedding) columns, as
+ * variable-size Lists. Neither Spark's 
[[org.apache.spark.sql.vectorized.ArrowColumnVector]], which
+ * wraps the emitted batches, nor Velox's Arrow import supports FixedSizeList, 
while a List holds
+ * the same values and matches the ArrayType that lance-spark declares for the 
column. Element `i`
+ * of a FixedSizeList of size `n` is child range `[i * n, (i + 1) * n)` also 
for null entries, so
+ * the offsets are `i * n` throughout and the child values are reused as is: 
only the offsets buffer
+ * is new. Nested FixedSizeLists, e.g. a vector inside a struct or a list of 
vectors, are rewritten
+ * too.
+ */
+private object FixedSizeLists {
+
+  /** Whether `field` is a FixedSizeList or has one among its descendants. */
+  def contains(field: Field): Boolean =
+    field.getType.isInstanceOf[ArrowType.FixedSizeList] ||
+      field.getChildren.asScala.exists(contains)
+
+  /**
+   * Builds a vector in `allocator` holding the data of `source`, with every 
FixedSizeList rewritten
+   * as a List. Buffers are shared or moved rather than copied, so `source` 
may be left without its
+   * child data; its owner still closes it.
+   */
+  def toLists(source: FieldVector, allocator: BufferAllocator): FieldVector = {
+    val target = toListField(source.getField).createVector(allocator)
+    try {
+      load(source, target, allocator)
+      target
+    } catch {
+      case t: Throwable =>
+        target.close()
+        throw t
+    }
+  }
+
+  private def toListField(field: Field): Field =
+    if (!contains(field)) {
+      field
+    } else {
+      val fieldType = field.getType match {
+        case _: ArrowType.FixedSizeList =>
+          new FieldType(
+            field.isNullable,
+            ArrowType.List.INSTANCE,
+            field.getDictionary,
+            field.getMetadata)
+        case _ => field.getFieldType
+      }
+      new Field(field.getName, fieldType, 
field.getChildren.asScala.map(toListField).asJava)
+    }
+
+  /** Loads `source` into `target`, whose field is 
`toListField(source.getField)`. */
+  private def load(source: FieldVector, target: FieldVector, allocator: 
BufferAllocator): Unit =
+    if (!contains(source.getField)) {
+      // Same type on both sides: move the buffers.
+      source.makeTransferPair(target).transfer()
+    } else {
+      val node = new ArrowFieldNode(source.getValueCount, source.getNullCount)
+      source match {
+        case fixed: FixedSizeListVector =>
+          val width = BaseRepeatedValueVector.OFFSET_WIDTH
+          val offsets = allocator.buffer((fixed.getValueCount + 1).toLong * 
width)
+          try {
+            (0 to fixed.getValueCount).foreach {
+              i => offsets.setInt(i.toLong * width, i * fixed.getListSize)
+            }
+            target.loadFieldBuffers(node, 
java.util.Arrays.asList(fixed.getValidityBuffer, offsets))
+          } finally {
+            // loadFieldBuffers took its own reference.
+            offsets.close()
+          }
+        case _ =>
+          target.loadFieldBuffers(node, source.getFieldBuffers)
+      }
+      
source.getChildrenFromFields.asScala.zip(target.getChildrenFromFields.asScala).foreach
 {
+        case (s, t) => load(s, t, allocator)
+      }
+    }
+}
diff --git 
a/backends-velox/src-lance/test/scala/org/apache/gluten/execution/LanceFixedSizeListsSuite.scala
 
b/backends-velox/src-lance/test/scala/org/apache/gluten/execution/LanceFixedSizeListsSuite.scala
new file mode 100644
index 0000000000..1f40068ccf
--- /dev/null
+++ 
b/backends-velox/src-lance/test/scala/org/apache/gluten/execution/LanceFixedSizeListsSuite.scala
@@ -0,0 +1,322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.gluten.execution
+
+import org.apache.spark.sql.vectorized.ArrowColumnVector
+
+import org.apache.arrow.memory.{BufferAllocator, OutOfMemoryException, 
RootAllocator}
+import org.apache.arrow.vector.{FieldVector, Float4Vector, IntVector, 
VarCharVector, VectorSchemaRoot}
+import org.apache.arrow.vector.complex.{FixedSizeListVector, ListVector, 
StructVector}
+import org.apache.arrow.vector.types.FloatingPointPrecision
+import org.apache.arrow.vector.types.pojo.{ArrowType, Field, FieldType}
+import org.scalatest.funsuite.AnyFunSuite
+
+import java.nio.charset.StandardCharsets
+
+import scala.collection.JavaConverters._
+
+/**
+ * Unit tests for how the Lance scan takes the columns of an imported batch
+ * ([[LanceFragmentIterator.takeColumns]]) and rewrites its FixedSizeList 
vectors as Lists
+ * ([[FixedSizeLists]]). Every test runs on its own [[RootAllocator]], whose 
`close()` fails if any
+ * buffer leaked.
+ */
+class LanceFixedSizeListsSuite extends AnyFunSuite {
+
+  private val floatType = new 
ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE)
+
+  private def withAllocator(f: BufferAllocator => Unit): Unit = {
+    val allocator = new RootAllocator(Long.MaxValue)
+    try f(allocator)
+    finally allocator.close()
+  }
+
+  private def vectorField(name: String, dim: Int): Field =
+    new Field(
+      name,
+      FieldType.nullable(new ArrowType.FixedSizeList(dim)),
+      Seq(new Field("item", FieldType.notNullable(floatType), null)).asJava)
+
+  /** Fills the FixedSizeList `vector` with `rows`; null slots get junk child 
values. */
+  private def fill(vector: FixedSizeListVector, rows: 
Seq[Option[Seq[Float]]]): Unit = {
+    val dim = vector.getListSize
+    val values = vector.getDataVector.asInstanceOf[Float4Vector]
+    rows.zipWithIndex.foreach {
+      case (row, i) =>
+        row match {
+          case Some(_) => vector.setNotNull(i)
+          case None => vector.setNull(i)
+        }
+        (0 until dim).foreach(j => values.setSafe(i * dim + j, 
row.map(_(j)).getOrElse(-1f)))
+    }
+  }
+
+  private def floatVectors(
+      allocator: BufferAllocator,
+      dim: Int,
+      rows: Seq[Option[Seq[Float]]]): FixedSizeListVector = {
+    val vector = vectorField("vec", 
dim).createVector(allocator).asInstanceOf[FixedSizeListVector]
+    vector.allocateNew()
+    fill(vector, rows)
+    vector.setValueCount(rows.size)
+    vector
+  }
+
+  /** Reads the arrays of `column` through Spark's ArrowColumnVector. */
+  private def read(column: ArrowColumnVector, rows: Int): 
Seq[Option[Seq[Float]]] =
+    (0 until rows).map {
+      i => if (column.isNullAt(i)) None else 
Some(column.getArray(i).toFloatArray.toSeq)
+    }
+
+  /**
+   * Rewrites the `dim`-sized vectors in `source`, checks the result reads as 
`expected`, and
+   * releases both vectors.
+   */
+  private def checkRewrite(
+      source: FieldVector,
+      allocator: BufferAllocator,
+      dim: Int,
+      expected: Seq[Option[Seq[Float]]]): Unit = {
+    val converted = FixedSizeLists.toLists(source, allocator)
+    source.close()
+    val column = new ArrowColumnVector(converted)
+    try {
+      assert(converted.getField.getType == ArrowType.List.INSTANCE)
+      assert(converted.getValueCount == expected.size)
+      val offsets = converted.asInstanceOf[ListVector].getOffsetBuffer
+      (0 to expected.size).foreach(i => assert(offsets.getInt(i * 4L) == i * 
dim))
+      assert(read(column, expected.size) == expected)
+    } finally {
+      column.close()
+    }
+  }
+
+  test("contains finds FixedSizeLists at any depth") {
+    val plain = new Field("id", FieldType.nullable(new ArrowType.Int(32, 
true)), null)
+    val list = new Field(
+      "values",
+      FieldType.nullable(ArrowType.List.INSTANCE),
+      Seq(new Field("item", FieldType.nullable(floatType), null)).asJava)
+    def struct(children: Field*): Field =
+      new Field("s", FieldType.nullable(ArrowType.Struct.INSTANCE), 
children.asJava)
+    def listOf(child: Field): Field =
+      new Field("l", FieldType.nullable(ArrowType.List.INSTANCE), 
Seq(child).asJava)
+
+    assert(!FixedSizeLists.contains(plain))
+    assert(!FixedSizeLists.contains(list))
+    assert(!FixedSizeLists.contains(struct(plain, list)))
+    assert(FixedSizeLists.contains(vectorField("vec", 4)))
+    assert(FixedSizeLists.contains(struct(plain, vectorField("vec", 4))))
+    assert(FixedSizeLists.contains(listOf(vectorField("vec", 4))))
+    assert(FixedSizeLists.contains(struct(listOf(struct(vectorField("vec", 
4))))))
+  }
+
+  test("rewrites a FixedSizeList with null rows as a List with the same 
values") {
+    withAllocator {
+      allocator =>
+        // The null row's child slots hold junk, which must not leak into the 
neighboring rows.
+        val rows = Seq(
+          Some(Seq(0f, 1f, 2f, 3f)),
+          None,
+          Some(Seq(8f, 9f, 10f, 11f)),
+          None,
+          Some(Seq(16f, 17f, 18f, 19f)))
+        checkRewrite(floatVectors(allocator, 4, rows), allocator, 4, rows)
+    }
+  }
+
+  test("rewrites FixedSizeLists with no nulls, only nulls, and no rows") {
+    withAllocator {
+      allocator =>
+        // Uniform validity, and an empty batch whose offsets buffer holds a 
single 0.
+        val noNulls = Seq(Some(Seq(1f, 2f)), Some(Seq(3f, 4f)), Some(Seq(5f, 
6f)))
+        checkRewrite(floatVectors(allocator, 2, noNulls), allocator, 2, 
noNulls)
+        val onlyNulls = Seq(None, None, None)
+        checkRewrite(floatVectors(allocator, 2, onlyNulls), allocator, 2, 
onlyNulls)
+        checkRewrite(floatVectors(allocator, 2, Seq.empty), allocator, 2, 
Seq.empty)
+    }
+  }
+
+  test("moves the vector values without copying them") {
+    withAllocator {
+      allocator =>
+        val source = floatVectors(allocator, 3, Seq(Some(Seq(1f, 2f, 3f)), 
None))
+        val values = 
source.getDataVector.asInstanceOf[Float4Vector].getDataBufferAddress
+        val converted = FixedSizeLists.toLists(source, allocator)
+        try {
+          val list = converted.asInstanceOf[ListVector]
+          
assert(list.getDataVector.asInstanceOf[Float4Vector].getDataBufferAddress == 
values)
+          assert(list.getValidityBufferAddress == 
source.getValidityBufferAddress)
+        } finally {
+          converted.close()
+          source.close()
+        }
+    }
+  }
+
+  test("rewrites vectors nested in a struct and in a list") {
+    withAllocator {
+      allocator =>
+        val docField = new Field(
+          "doc",
+          FieldType.nullable(ArrowType.Struct.INSTANCE),
+          Seq(
+            new Field("label", FieldType.nullable(ArrowType.Utf8.INSTANCE), 
null),
+            vectorField("emb", 2)).asJava
+        )
+        val doc = docField.createVector(allocator).asInstanceOf[StructVector]
+        doc.allocateNew()
+        val label = doc.getChild("label", classOf[VarCharVector])
+        val emb = doc.getChild("emb", classOf[FixedSizeListVector])
+        // Rows: a full struct, a struct with a null vector, and a null struct.
+        Seq(0, 1).foreach {
+          i =>
+            doc.setIndexDefined(i)
+            label.setSafe(i, s"d$i".getBytes(StandardCharsets.UTF_8))
+        }
+        doc.setNull(2)
+        fill(emb, Seq(Some(Seq(1f, 2f)), None, None))
+        doc.setValueCount(3)
+
+        val multiField =
+          new Field(
+            "multi",
+            FieldType.nullable(ArrowType.List.INSTANCE),
+            Seq(vectorField("e", 2)).asJava)
+        val multi = multiField.createVector(allocator).asInstanceOf[ListVector]
+        multi.allocateNew()
+        val elements = multi.getDataVector.asInstanceOf[FixedSizeListVector]
+        // Rows: two vectors, an empty list, and a null list.
+        multi.startNewValue(0)
+        multi.endValue(0, 2)
+        multi.startNewValue(1)
+        multi.endValue(1, 0)
+        multi.setNull(2)
+        fill(elements, Seq(Some(Seq(1f, 2f)), Some(Seq(3f, 4f))))
+        multi.setValueCount(3)
+
+        val convertedDoc = FixedSizeLists.toLists(doc, allocator)
+        val convertedMulti = FixedSizeLists.toLists(multi, allocator)
+        doc.close()
+        multi.close()
+        val docColumn = new ArrowColumnVector(convertedDoc)
+        val multiColumn = new ArrowColumnVector(convertedMulti)
+        try {
+          assert(
+            convertedDoc.getField.getChildren.asScala.map(_.getType) ==
+              Seq(ArrowType.Utf8.INSTANCE, ArrowType.List.INSTANCE))
+          assert((0 until 3).map(docColumn.isNullAt) == Seq(false, false, 
true))
+          assert((0 until 
2).map(docColumn.getChild(0).getUTF8String(_).toString) == Seq(
+            "d0",
+            "d1"))
+          assert(read(docColumn.getChild(1), 2) == Seq(Some(Seq(1f, 2f)), 
None))
+
+          assert(convertedMulti.getField.getChildren.get(0).getType == 
ArrowType.List.INSTANCE)
+          assert((0 until 3).map(multiColumn.isNullAt) == Seq(false, false, 
true))
+          val first = multiColumn.getArray(0)
+          assert(
+            (0 until 
first.numElements).map(first.getArray(_).toFloatArray.toSeq) ==
+              Seq(Seq(1f, 2f), Seq(3f, 4f)))
+          assert(multiColumn.getArray(1).numElements == 0)
+        } finally {
+          docColumn.close()
+          multiColumn.close()
+        }
+    }
+  }
+
+  test("releases the partial result when the rewrite fails") {
+    withAllocator {
+      allocator =>
+        val docField = new Field(
+          "doc",
+          FieldType.nullable(ArrowType.Struct.INSTANCE),
+          Seq(
+            new Field("id", FieldType.nullable(new ArrowType.Int(32, true)), 
null),
+            vectorField("emb", 2)).asJava
+        )
+        val doc = docField.createVector(allocator).asInstanceOf[StructVector]
+        doc.allocateNew()
+        val id = doc.getChild("id", classOf[IntVector])
+        doc.setIndexDefined(0)
+        id.setSafe(0, 7)
+        doc.setNull(1)
+        fill(doc.getChild("emb", classOf[FixedSizeListVector]), 
Seq(Some(Seq(1f, 2f)), None))
+        doc.setValueCount(2)
+
+        // `id` moves into the rewrite before the offsets of `emb` fail to 
allocate, so the failed
+        // rewrite has to release what it already took.
+        val limited = allocator.newChildAllocator("limited", 0, 0)
+        try {
+          intercept[OutOfMemoryException](FixedSizeLists.toLists(doc, limited))
+          assert(limited.getAllocatedMemory == 0)
+        } finally {
+          limited.close()
+          doc.close()
+        }
+    }
+  }
+
+  /** A batch with an int column `id` and a vector column `vec`. */
+  private def batch(allocator: BufferAllocator): VectorSchemaRoot = {
+    val id = new IntVector("id", allocator)
+    id.allocateNew(2)
+    id.set(0, 7)
+    id.set(1, 8)
+    id.setValueCount(2)
+    val vec = floatVectors(allocator, 2, Seq(Some(Seq(1f, 2f)), None))
+    new VectorSchemaRoot(Seq[FieldVector](id, vec).asJava)
+  }
+
+  test("takes the columns of a batch, rewriting only the vector columns") {
+    withAllocator {
+      allocator =>
+        val root = batch(allocator)
+        try {
+          val columns =
+            LanceFragmentIterator.takeColumns(root, allocator).asScala.map(new 
ArrowColumnVector(_))
+          try {
+            assert(
+              columns.map(_.getValueVector.getField.getType) ==
+                Seq(new ArrowType.Int(32, true), ArrowType.List.INSTANCE))
+            assert((0 until 2).map(columns(0).getInt) == Seq(7, 8))
+            assert(read(columns(1), 2) == Seq(Some(Seq(1f, 2f)), None))
+          } finally {
+            columns.foreach(_.close())
+          }
+        } finally {
+          root.close()
+        }
+    }
+  }
+
+  test("releases the columns already taken when a later column fails") {
+    withAllocator {
+      allocator =>
+        val root = batch(allocator)
+        // `id` moves into `limited` before the offsets of `vec` fail to 
allocate there.
+        val limited = allocator.newChildAllocator("limited", 0, 0)
+        try {
+          
intercept[OutOfMemoryException](LanceFragmentIterator.takeColumns(root, 
limited))
+          assert(limited.getAllocatedMemory == 0)
+        } finally {
+          limited.close()
+          root.close()
+        }
+    }
+  }
+}
diff --git 
a/backends-velox/src-lance/test/scala/org/apache/gluten/execution/VeloxLanceSuite.scala
 
b/backends-velox/src-lance/test/scala/org/apache/gluten/execution/VeloxLanceSuite.scala
index 16dcc4b478..8909c87b1f 100644
--- 
a/backends-velox/src-lance/test/scala/org/apache/gluten/execution/VeloxLanceSuite.scala
+++ 
b/backends-velox/src-lance/test/scala/org/apache/gluten/execution/VeloxLanceSuite.scala
@@ -18,8 +18,11 @@ package org.apache.gluten.execution
 
 import org.apache.spark.SparkConf
 import org.apache.spark.sql.{DataFrame, Row}
-import org.apache.spark.sql.execution.{ColumnarToRowExec, InputAdapter, 
SparkPlan}
+import org.apache.spark.sql.execution.{ColumnarShuffleExchangeExec, 
ColumnarToRowExec, InputAdapter, SparkPlan}
 import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
+import org.apache.spark.sql.types._
+
+import scala.collection.JavaConverters._
 
 /**
  * Exercises the read-only Lance offload: a columnar Lance scan is handed to 
Velox through the Arrow
@@ -52,27 +55,94 @@ class VeloxLanceSuite extends 
VeloxWholeStageTransformerSuite {
       view: String,
       rows: Int,
       writeOptions: Map[String, String] = Map.empty)(f: => Unit): Unit = {
+    val data = spark
+      .range(rows)
+      .selectExpr(
+        "cast(id as int) as id",
+        "cast(id * 2 as int) as v",
+        "concat('n', cast(id as string)) as name")
+    withLanceView(view, data, writeOptions, Map.empty)(f)
+  }
+
+  /** Writes `data` as a Lance dataset and registers it as a temp view. */
+  private def withLanceView(
+      view: String,
+      data: DataFrame,
+      writeOptions: Map[String, String],
+      readOptions: Map[String, String])(f: => Unit): Unit = {
     withTempPath {
       path =>
         val uri = s"${path.getCanonicalPath}/$view.lance"
-        spark
-          .range(rows)
-          .selectExpr(
-            "cast(id as int) as id",
-            "cast(id * 2 as int) as v",
-            "concat('n', cast(id as string)) as name")
-          .write
+        data.write
           .format("lance")
           .options(writeOptions)
           .option("path", uri)
           .save()
 
-        spark.read.format("lance").option("path", 
uri).load().createOrReplaceTempView(view)
+        spark.read
+          .format("lance")
+          .options(readOptions)
+          .option("path", uri)
+          .load()
+          .createOrReplaceTempView(view)
         try f
         finally spark.catalog.dropTempView(view)
     }
   }
 
+  /** Field metadata that makes lance-spark write an array column as a 
FixedSizeList (a vector). */
+  private def vectorMetadata(dim: Int): Metadata =
+    new MetadataBuilder().putLong("arrow.fixed-size-list.size", dim).build()
+
+  /**
+   * Row `i` of the vector dataset (see [[withLanceVectorView]]). Every tenth 
row, from row 3 on,
+   * has a null `vec`, a null `doc`, a null vector inside `doc`, and a null 
`multi`, respectively.
+   */
+  private def vectorRow(i: Int): Row = {
+    val f = i.toFloat
+    Row(
+      i,
+      if (i % 10 == 3) null else Seq(f, f + 1, f + 2, f + 3),
+      Seq(i * 10.0, i * 10.0 + 1),
+      if (i % 10 == 5) null else Row(s"d$i", if (i % 10 == 6) null else 
Seq(-f, -f - 1, -f - 2)),
+      if (i % 10 == 7) null else Seq.tabulate(i % 3)(j => Seq(f * 100 + j, f * 
100 + j + 0.5f))
+    )
+  }
+
+  /**
+   * Writes `rows` rows of FixedSizeList vector columns as a Lance dataset: 
`vec` (float, size 4),
+   * `dvec` (double, size 2), a vector inside the struct `doc`, and the list 
of vectors `multi`.
+   */
+  private def withLanceVectorView(
+      view: String,
+      rows: Int = 10,
+      writeOptions: Map[String, String] = Map.empty,
+      readOptions: Map[String, String] = Map.empty)(f: => Unit): Unit = {
+    // The element metadata of `multi` makes each element a FixedSizeList of 
size 2.
+    val multiMetadata =
+      new MetadataBuilder().putString("_lance.element", 
vectorMetadata(2).json).build()
+    val schema = new StructType()
+      .add("id", IntegerType)
+      .add("vec", ArrayType(FloatType, containsNull = false), nullable = true, 
vectorMetadata(4))
+      .add("dvec", ArrayType(DoubleType, containsNull = false), nullable = 
true, vectorMetadata(2))
+      .add(
+        "doc",
+        new StructType()
+          .add("label", StringType)
+          .add(
+            "emb",
+            ArrayType(FloatType, containsNull = false),
+            nullable = true,
+            vectorMetadata(3)))
+      .add(
+        "multi",
+        ArrayType(ArrayType(FloatType, containsNull = false), containsNull = 
false),
+        nullable = true,
+        multiMetadata)
+    val data = spark.createDataFrame((0 until rows).map(vectorRow).asJava, 
schema)
+    withLanceView(view, data, writeOptions, readOptions)(f)
+  }
+
   private def isLanceScan(plan: SparkPlan): Boolean = plan match {
     case _: LanceScanTransformer => true
     case InputAdapter(child) => isLanceScan(child)
@@ -85,6 +155,15 @@ class VeloxLanceSuite extends 
VeloxWholeStageTransformerSuite {
       .filterNot(_.isInstanceOf[InputAdapter])
       .filter(_.children.exists(isLanceScan))
 
+  /** Asserts that Velox reads the Lance scan's batches directly, with no row 
conversion between. */
+  private def assertScanFeedsVelox(df: DataFrame): Unit = {
+    val parents = lanceScanParents(df)
+    assert(
+      parents.nonEmpty && parents.forall(_.isInstanceOf[OffloadArrowDataExec]),
+      s"Expected OffloadArrowDataExec over the Lance scan, 
got:\n${df.queryExecution.executedPlan}"
+    )
+  }
+
   test("lance scan offloads to LanceScanTransformer") {
     withLanceView("lance_basic", 100) {
       runQueryAndCompare("select id, v, name from lance_basic") {
@@ -170,4 +249,103 @@ class VeloxLanceSuite extends 
VeloxWholeStageTransformerSuite {
       checkAnswer(df, Seq(Row(50L)))
     }
   }
+
+  test("lance scan offloads vector columns") {
+    withLanceVectorView("lance_vectors") {
+      runQueryAndCompare("select id, vec, dvec from lance_vectors") {
+        df =>
+          checkGlutenPlan[LanceScanTransformer](df)
+          checkAnswer(df, (0 until 10).map(vectorRow).map(r => Row(r(0), r(1), 
r(2))))
+      }
+    }
+  }
+
+  test("lance scan offloads nested vector columns") {
+    withLanceVectorView("lance_nested_vectors") {
+      runQueryAndCompare("select id, doc, multi from lance_nested_vectors 
where id >= 2") {
+        df =>
+          checkGlutenPlan[LanceScanTransformer](df)
+          checkAnswer(df, (2 until 10).map(vectorRow).map(r => Row(r(0), r(3), 
r(4))))
+      }
+    }
+  }
+
+  test("lance vector columns feed Velox operators") {
+    withLanceVectorView("lance_vectors_velox") {
+      // Velox evaluates the element accesses and the aggregation over the 
imported vectors; null
+      // vectors, structs, and lists must stay null rather than expose their 
child slots.
+      runQueryAndCompare(
+        """select id % 2 as k, count(vec) as c, sum(vec[0]) as s, sum(dvec[1]) 
as d,
+          |  count(doc) as docs, count(doc.emb) as embs, sum(doc.emb[2]) as e,
+          |  count(multi) as m, sum(multi[0][1]) as mm
+          |from lance_vectors_velox group by id % 2""".stripMargin) {
+        df =>
+          checkGlutenPlan[LanceScanTransformer](df)
+          checkGlutenPlan[HashAggregateExecBaseTransformer](df)
+          assertScanFeedsVelox(df)
+      }
+    }
+  }
+
+  test("lance vector columns pass through a Velox filter, shuffle and sort") {
+    withLanceVectorView("lance_vectors_shuffle") {
+      // `id % 3` is not pushed into Lance, so Velox filters the imported 
vectors, shuffles and
+      // sorts them, and converts them back to rows.
+      runQueryAndCompare(
+        "select id, vec, doc, multi from lance_vectors_shuffle where id % 3 != 
1 order by id") {
+        df =>
+          checkGlutenPlan[LanceScanTransformer](df)
+          assertScanFeedsVelox(df)
+          checkGlutenPlan[FilterExecTransformerBase](df)
+          checkGlutenPlan[ColumnarShuffleExchangeExec](df)
+          checkGlutenPlan[SortExecTransformer](df)
+          checkAnswer(
+            df,
+            (0 until 10).filter(_ % 3 != 1).map(vectorRow).map(r => Row(r(0), 
r(1), r(3), r(4))))
+      }
+    }
+  }
+
+  test("lance scan reads vector batches that are all null or empty") {
+    withLanceVectorView("lance_vectors_edge") {
+      // Single-row batches whose vector, struct, or list is null, and a scan 
with no rows.
+      Seq(
+        "select id, vec from lance_vectors_edge where id = 3" -> Seq(Row(3, 
null)),
+        "select id, doc from lance_vectors_edge where id = 5" -> Seq(Row(5, 
null)),
+        "select id, doc from lance_vectors_edge where id = 6" -> Seq(Row(6, 
Row("d6", null))),
+        "select id, multi from lance_vectors_edge where id = 7" -> Seq(Row(7, 
null)),
+        "select id, vec, doc, multi from lance_vectors_edge where id < 0" -> 
Seq.empty[Row]
+      ).foreach {
+        case (query, expected) =>
+          runQueryAndCompare(query) {
+            df =>
+              checkGlutenPlan[LanceScanTransformer](df)
+              checkAnswer(df, expected)
+          }
+      }
+    }
+  }
+
+  test("lance scan reads vectors across fragments and batches") {
+    withLanceVectorView(
+      "lance_vectors_multi",
+      rows = 1000,
+      writeOptions = Map("max_row_per_file" -> "300"),
+      readOptions = Map("batch_size" -> "64")) {
+      runQueryAndCompare("select * from lance_vectors_multi") {
+        df =>
+          checkGlutenPlan[LanceScanTransformer](df)
+          assert(df.rdd.getNumPartitions > 1, "Expected one partition per 
Lance fragment")
+          checkAnswer(df, (0 until 1000).map(vectorRow))
+      }
+      runQueryAndCompare(
+        """select count(vec), sum(vec[0]), sum(vec[3]), sum(doc.emb[1]), 
sum(multi[1][0])
+          |from lance_vectors_multi""".stripMargin) {
+        df =>
+          checkGlutenPlan[LanceScanTransformer](df)
+          checkGlutenPlan[HashAggregateExecBaseTransformer](df)
+          assertScanFeedsVelox(df)
+      }
+    }
+  }
 }
diff --git a/docs/backends/velox/get-started/VeloxLance.md 
b/docs/backends/velox/get-started/VeloxLance.md
index 7a789101b8..256be3a6b4 100644
--- a/docs/backends/velox/get-started/VeloxLance.md
+++ b/docs/backends/velox/get-started/VeloxLance.md
@@ -58,9 +58,15 @@ serve it:
 | Query shape                    | Behavior |
 |--------------------------------|----------|
 | Projection / filter            | Offload  |
+| Vector (`FixedSizeList`) columns | Offload  |
 | Pushed-down aggregation (e.g. `COUNT(*)`) | Fallback |
 | Full-text query (`_score`)     | Fallback |
 
+Vector columns are Arrow `FixedSizeList`s, which neither Spark's 
`ArrowColumnVector` nor Velox
+reads, so the scan rewrites them as variable-size lists, the `ArrayType` 
lance-spark already
+exposes. The vector values are not copied; each vector column only gets an 
offsets buffer. This
+also applies to vectors nested in a struct or a list.
+
 The offload decision is made at plan time 
(`LanceScanTransformer.isExportable`), so a
 non-offloadable scan is detected before execution rather than failing inside 
Velox.
 


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

Reply via email to