Skip to content

feat(lance-linalg): runtime SIMD dispatch for pre-Haswell x86_64 from-source builds#2

Draft
tobocop2 wants to merge 189 commits into
mainfrom
fix/runtime-simd-multiversion
Draft

feat(lance-linalg): runtime SIMD dispatch for pre-Haswell x86_64 from-source builds#2
tobocop2 wants to merge 189 commits into
mainfrom
fix/runtime-simd-multiversion

Conversation

@tobocop2

@tobocop2 tobocop2 commented Apr 26, 2026

Copy link
Copy Markdown
Owner

Adds runtime SIMD dispatch to lance-linalg's hot distance kernels so a from-source build with a lower x86_64 baseline produces a working binary on pre-Haswell hardware (Sandy Bridge / Ivy Bridge / Steamroller). fast-by-default with a documented from-source override for legacy users.

Today, import lancedb SIGILLs on AVX-without-AVX2 CPUs because the wheel bakes AVX2 into every compiled function with no runtime guard. numpy and pyarrow handle the same hardware via runtime dispatch. This PR brings lance to parity for the from-source legacy build path.

Summary

  • 5-tier runtime SIMD dispatch (scalar / AVX / AVX+FMA / AVX2+FMA / AVX-512) on all 10 hot f32/f64 distance kernels in lance-linalg, using the same match *SIMD_SUPPORT + mod x86 { #[target_feature(enable=...)] pub unsafe fn ... } shape as dot_u8.rs / cosine_u8.rs / l2_u8.rs. On the haswell baseline, dispatch always lands on AVX2 — modern compile output is unchanged from today.
  • lance.simd_info() Python API mirroring pyarrow.runtime_info() for tier introspection.
  • qemu-pre-haswell CI gate that builds with RUSTFLAGS="-C target-cpu=x86-64-v2" (env-var-scoped to that one job — workspace .cargo/config.toml is unchanged) and runs lance-linalg tests under qemu Nehalem.
  • CONTRIBUTING.md documents the legacy build: RUSTFLAGS="-C target-cpu=x86-64-v2" cargo build --release.

Zero new external dependencies. Dispatch shape extends an idiom lance already uses for u8 and f16/bf16 kernels rather than introducing a new convention. Recent precedent: @justinrmiller's #6540, #6517, #6506, #6510.

Verification

  • cargo test -p lance-linalg --lib — 83/83 on aarch64 dev box.
  • 38 new proptest cases verifying scalar↔SIMD bit-for-bit equivalence per tier per kernel; gated on is_x86_feature_detected!() so each runs on hosts that can execute its tier.
  • cargo clippy --all-targets -- -D warnings clean; cargo fmt --check clean; Cargo.lock unchanged.
  • SIGILL gone on Sandy Bridge Xeon E5-2609 with the documented RUSTFLAGS override (via companion lancedb wheel — see tobocop2/lancedb#2 for the verification PASS output).
  • Modern-hardware bench delta still pending. The AVX2 path is preserved as one of the per-tier kernels and the workspace baseline still bakes AVX2 into surrounding code, so by construction the modern compile is unchanged — but I'll confirm with criterion change: lines once I find a host that can hold the full cargo bench -p lance-linalg --bench {cosine,dot,l2,norm_l2} suite (Codespace's 30-min idle timeout killed my last attempt mid-run).

Closes #1.

@tobocop2 tobocop2 changed the title Runtime SIMD dispatch: 5-tier coverage from Nehalem 2008 through Sapphire Rapids 2023, full numpy/pyarrow parity fix(lance-linalg): SIGILL on pre-Haswell x86_64 — add runtime SIMD dispatch Apr 26, 2026
@tobocop2 tobocop2 changed the title fix(lance-linalg): SIGILL on pre-Haswell x86_64 — add runtime SIMD dispatch fix(lance-linalg): lancedb unusable on pre-Haswell x86_64 (import SIGILL) — add runtime SIMD dispatch Apr 26, 2026
tobocop2 added a commit that referenced this pull request Apr 26, 2026
…ch dot_u8.rs convention

Per-function doc comments on `*_scalar`/`*_avx`/`*_avx_fma`/`*_avx2`/`*_avx512`
inner functions are now one-liners matching the existing convention in
`dot_u8.rs` (see e.g. its `pub unsafe fn dot_u8_avx2` comment). Drops the
redundant "Caller must ensure..." precondition lines — those are implicit
from `unsafe fn` + `#[target_feature]`. Module-level `//!` docs and
public-API `///` docs are left detailed per project convention.

Refs #1, #2.
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch from a3df856 to 9193496 Compare April 28, 2026 04:59
@tobocop2 tobocop2 changed the title fix(lance-linalg): lancedb unusable on pre-Haswell x86_64 (import SIGILL) — add runtime SIMD dispatch feat(lance-linalg): runtime SIMD dispatch for pre-Haswell x86_64 from-source builds Apr 28, 2026
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch from 58325e8 to 7db5171 Compare April 28, 2026 05:43
@github-actions github-actions Bot added enhancement New feature or request python labels Apr 28, 2026
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch from 7db5171 to 26aa7f4 Compare April 28, 2026 05:46
@github-actions github-actions Bot added the java label Apr 28, 2026
@tobocop2
tobocop2 force-pushed the fix/runtime-simd-multiversion branch 3 times, most recently from 126a6a5 to 26a520b Compare June 10, 2026 16:56
hamersaw and others added 12 commits June 25, 2026 21:14
…p-table (lance-format#7361)

## What

The mem-wal primitives sophon's drop-table two-phase commit needs:

- **`ShardWriter::abort(&self)`** — shut down the background flush tasks
(`task_executor.shutdown_all()`) *without* flushing, discarding buffered
memtable state. Unlike `close(self)` it takes `&self` (so it's callable
through the `Arc<ShardWriter>` callers hold) and does no object-store
IO. The caller must quiesce writes first (documented). Idempotent. Acked
data is **not** lost — it's durable in the WAL log and replays on the
next claim, which is what makes the drop's prepare phase reversible.
- **`ShardStatus { Active | Sealed }` on `ShardManifest`** — a durable,
reversible lifecycle marker (proto + struct + serde). `claim_epoch`
refuses a `Sealed` manifest with a **distinguishable** error instead of
minting a new epoch, so a shard mid-drop can't be re-claimed — even by a
caller that skips its own status check — and a reader can tell an
in-doubt drop apart from an ordinary epoch fence. Set/cleared through
the existing epoch-guarded `commit_update` CAS; carried across claims
via `..base`, so only the genuinely-fresh constructions default it to
`Active`.

## Why

A WAL-enabled table's drop spans two durable resources — the owning
pod's fresh-tier state and the catalog/object-store data — so sophon's
teardown is a two-phase commit. Before the dataset directory is removed,
the owning pod must:

- `abort` the writer so its background flush task can't re-create
`_mem_wal/` under the just-deleted directory (a graceful `close()` would
flush it back), and
- durably mark the shard `Sealed` so the drop is in-doubt across a pod
crash or a Maglev rehome — which the in-memory fence flag cannot
survive. The seal is reversible (rollback clears it back to `Active`),
making the prepare phase abortable without data loss.

Both build on existing machinery (`shutdown_all`, the manifest CAS) —
thin exposure, not new infrastructure.

> **Changed since first draft:** this PR previously also added
`Session::invalidate_dataset`; it has been **dropped**. Base-table read
freshness rides the recreate's new object-store `e_tag` (the QN→PE and
lance metadata caches are `e_tag`-keyed and miss the stale entry), so
cache invalidation isn't load-bearing — only `abort` and the `Sealed`
marker remain.

## Tests

- `test_abort_discards_without_flushing_and_is_idempotent` — `abort`
leaves no new L0 generation (contrast with `close`), idempotent on a
second call.
- `test_claim_epoch_refuses_sealed_manifest` — a `Sealed` manifest is
refused with the distinguishable error and left untouched (no epoch
minted); rolling the status back to `Active` makes the shard claimable
again (reversibility).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
## What

Adds `LsmPointLookupPlanner::lookup_keep_tombstone` and
`lookup_many_keep_tombstone` — point-lookup variants that carry the
`_tombstone` marker through instead of filtering deleted keys out.

## Why

The existing `lookup` / `lookup_many` collapse two distinct states —
**deleted in the fresh tier** and **never written** — into a single
`None`. That's correct for a normal reader, but a caller doing a
read-on-write merge needs to tell them apart: a fresh-deleted PK must be
treated as *absent* (so a later partial update resurrects it from
carried columns + NULLs), while a never-written PK falls back to the
base row.

These variants return the deleted row with `_tombstone = true`; absent
keys still return `None`. Implemented by refactoring `plan_lookup` to
share a `plan_lookup_coalesced` helper and skipping the post-coalesce
tombstone filter, so the hot read path is unchanged.

## Tests

- `test_lookup_keep_tombstone_returns_deleted_row_with_marker`
- `test_lookup_many_keep_tombstone_includes_tombstoned_keys`

## Context

Prerequisite for a downstream WAL partial-column-update resurrection
fix: a partial update arriving after a delete must resurrect carried
columns + NULL untouched, never stale pre-delete base data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
lance-format#7495)

## What

The `lance-index` **test build** is currently broken on `main`:
`error[E0046]: not all trait items implemented, missing:
with_io_priority` at `rust/lance-index/src/scalar/fmindex.rs`.

`with_io_priority` was added to the `IndexStore` trait in lance-format#7449. The
`FailNewFileStore` test fixture was added later in lance-format#7422 (which branched
before lance-format#7449 landed), so it never implemented the new method. Each PR
was green on its own; together on `main` the test build fails — a
semantic merge conflict. It only surfaces in the `Build tests` step
because the fixture is `#[cfg(test)]`.

## Fix

Implement `with_io_priority` on `FailNewFileStore` by delegating to the
inner store and re-wrapping, mirroring how the fixture already delegates
its other read methods while keeping the injected `new_index_file`
failure.

## Verification

- `cargo check -p lance-index --tests` — compiles (was failing on
`main`).
- `cargo test -p lance-index --lib fmindex` — 24 passed.
- `cargo fmt -p lance-index -- --check` and `cargo clippy -p lance-index
--tests` — clean.
## What changed

This PR narrows OSS-1346 to codec replacement work only. It adds
Lance-owned `BitPacker4x` and internal `BitPacker8x` implementations in
`lance-bitpacking`, and switches existing FTS posting-list users from
the external `bitpacking` runtime dependency to `lance-bitpacking`.

The external `bitpacking` crate remains only as a dev-dependency for
byte-for-byte compatibility tests against the legacy 4x/8x formats.

## Why

FTS currently depends on an external bitpacking crate for 128-value
posting blocks. Keeping the implementation in `lance-bitpacking` lets
Lance own the codec while preserving existing 128-block wire
compatibility. The 8x implementation is included as an internal
algorithm building block only; this PR does not expose a new block-size
option.

## How it works

- Exposes `BitPacker` / `BitPacker4x` from `lance-bitpacking` with the
same call shape used by existing FTS code.
- Adds owned backends for 128-value 4x blocks:
  - SSE3 on `x86_64`
  - NEON on `aarch64`
  - scalar fallback when SIMD backends are unavailable
- Adds an internal 256-value `BitPacker8x` algorithm:
  - AVX2 on `x86_64`
  - NEON on little-endian `aarch64`
  - scalar fallback when AVX2 or NEON is unavailable
  - not re-exported from `lance-bitpacking`
- Does not add or change FTS `block_size` parameters, metadata, Python,
Java, or docs.

## Validation

- `cargo fmt --all`
- `cargo test -p lance-bitpacking -- --nocapture`
- `cargo check -p lance-index --tests`
- `cargo check -p lance --tests`
- `cargo clippy -p lance-bitpacking --tests -- -D warnings`
- `cargo clippy -p lance-index --tests -- -D warnings`
- `cargo clippy -p lance --tests -- -D warnings`
- `cargo clippy --all --tests --benches -- -D warnings`
- `cargo metadata --manifest-path python/Cargo.toml --locked
--format-version=1 --no-deps`
- `CARGO_TARGET_DIR=/tmp/lance-target-oss1346-aarch64 cargo check -p
lance-bitpacking --tests --target aarch64-apple-darwin`
- `CARGO_TARGET_DIR=/tmp/lance-target-oss1346-x86 cargo check -p
lance-bitpacking --tests --target x86_64-apple-darwin`
- `git diff --check`
…#7483)

## What

Adds `ShardWriter::delete_no_wait`, the delete analog of `put_no_wait`:
it inserts the tombstone into the in-memory tier (visible to reads on
this writer the instant it returns) and hands back the durability
watcher *without* awaiting it. `delete` becomes a thin wrapper that
awaits the watcher.

## Why

Lets a caller hold an external lock across only the in-memory tombstone
insert and await durability after releasing it — matching how
`put_no_wait` is already used so flushes still coalesce. A downstream
WAL serializes deletes against partial-update merges under a per-bucket
lock; with the blocking `delete` it would hold that lock across
durability, and `delete_no_wait` lets it release first.

## Tests

- `test_shard_writer_delete_no_wait_visible_before_durability`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
)

The Merge arm only dropped indices for fields removed from the schema,
so a field rewritten in place (new backing data file, same field id)
kept its index covering the fragment with stale entries. Index-served
queries then silently returned wrong results -- a filter on the
rewritten column matches the old values rather than the current data.
Prune the rewritten fragment from any index over a field whose backing
data file changed, reusing the same primitive the DataReplacement arm
already uses.
## Summary

Add `lance/arrow.py` and `tests/test_arrow.py` to the pyright include
list and fix the resulting type errors. Closes lance-format#3292.

- **arrow.py**: narrow union types via typed locals / `typing.cast`,
annotate the internal decoder return types, switch the fallback to
`pyarrow.compute as pc`, and add a single `# pyright:
ignore[reportOptionalCall]` for `tf.stack` (typed as `None` by
tensorflow's incomplete stubs).
- **lance/lance/__init__.pyi**: fix the `bfloat16_array` stub signature
from `List[str | None]` to `Sequence[float | None]` — the Rust function
takes `Vec<Option<f32>>`, and `Sequence` (covariant) lets `list[float]`
callers type-check.
- **test_arrow.py**: add `import lance.arrow` so the `lance.arrow.*`
attribute accesses resolve.


## Test plan

`tensorflow` is linux-only in `pyproject.toml`, so it was installed
locally to mirror the CI (Linux) lint environment before checking.

- `pyright` → **0 errors** (15 warnings, all pre-existing missing-stub
warnings for pyarrow/pandas/etc.)
- `ruff format --check` + `ruff check` → pass
- `pytest python/tests/test_arrow.py` → **20 passed**
This fixes low recall for IVF_SQ and IVF_HNSW_SQ indexes using Dot
distance when the scalar quantization bounds include negative values.

The previous SQ Dot path computed dot distance directly in quantized u8
code space. That drops the affine offset from scalar quantization, so
zero-centered embeddings can be ranked incorrectly. The updated path
computes Dot distance against the dequantized SQ value model while
keeping the stored-code HNSW path allocation-free and able to use the u8
dot kernel.

The change also adds regression coverage for a crafted negative-bound SQ
Dot case and end-to-end IVF_SQ / IVF_HNSW_SQ Dot recall on
negative-valued vectors.
…format#7484)

## Problem

An indexed merge-insert delete by a composite primary key whose columns
are **all** indexed silently removes nothing. `merge_insert(when_matched
= Delete, use_index = true)` reports `deleted = 0`, the matched rows
stay live in the table, and resurface in later reads.

## Root cause

`WhenMatched::Delete` is only implemented in the v2 plan
(`DeleteOnlyMergeInsertExec`), which never uses a scalar index. When
every join column is indexed, `can_use_create_plan` routes the merge to
the legacy `Merger` instead (the only engine with the indexed-scan
probe). That engine's matched-row handler only ever distinguished
`DoNothing` from "update" — it folded **both** `Delete` and `Fail` into
the update path:

- a fully-indexed `Delete` rewrote the matched rows in place → 0
deletes;
- a fully-indexed `Fail` silently updated instead of erroring.

A single indexed column hits the same path, so this was never
composite-specific — composite keys just make it the common case
(partial-column updates require an index on every PK column).

## Fix

Make the legacy `Merger` dispatch every `WhenMatched` variant
explicitly, mirroring the v2 classifier (`merge_insert_action`):

- **`Delete`** collects matched row ids as deletions and emits no
replacement batch.
- **`Fail`** aborts on any match, with the same message as the v2 path.
- A **delete-only commit branch** drains the merger and applies the
deletions (resolving row ids → addresses via the row-id index for stable
row ids) without writing fragments — this keeps the O(keys) indexed
delete instead of falling back to a full table scan.
- The partial-schema (column-rewrite) commit branch physically cannot
express row deletions, so combining `Delete` with inserts from a
partial-schema source now returns a descriptive error rather than
silently dropping the deletes.

## Tests

`cargo test -p lance --lib dataset::write::merge_insert` (152 passing),
covering:

- composite-key indexed delete (index on first / second / both / neither
— the both-indexed case is the original bug);
- single-column indexed delete;
- multi-fragment + stable-row-id variants, including an appended
fragment neither index covers (unindexed-remainder union);
- `Delete` combined with `InsertAll` (not delete-only);
- `Fail` on an indexed key (match aborts, no-match inserts cleanly).

`cargo fmt` and `cargo clippy -p lance --lib --tests -- -D warnings` are
clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
…-format#7223)

## Summary

`LabelListIndexPlugin::train_index` unconditionally rejected
fragment-scoped
training (`fragment_ids.is_some()`), so a LabelList index could not be
built
through the distributed / segmented path (per-fragment
`execute_uncommitted` +
`merge_existing_index_segments`) the way BTree, Inverted, Bitmap, and FM
already
are. This makes LabelList a first-class distributed scalar index.

## Changes

- `lance-index/src/scalar/label_list.rs`: `train_index` ignores
`fragment_ids` and
builds over the already fragment-scoped stream (mirrors
`FMIndexPlugin`); a partial
index over a fragment subset is correct since it covers exactly those
rows. Add
`merge_label_list_indices`, which unions the per-segment bitmap states
and the
`list_nulls` row sets. LabelList wraps a `BitmapIndex` plus a null-row
set and
distributed segments cover disjoint rows, so this is a cheap union (the
same
operation `LabelListIndex::update` performs) — no source re-scan.
Mirrors
  `merge_bitmap_indices` but also carries `list_nulls`.
- `lance/src/index/scalar/label_list.rs` (new): `merge_segments` opens
each source
segment as a `LabelListIndex` and calls `merge_label_list_indices`
(mirrors
  `scalar/bitmap.rs`).
- `lance/src/index.rs`: `merge_existing_index_segments` routes
`all_label_list`
  segments (details `LabelListIndexDetails`) to the new merge; add
  `segment_has_label_list_details`.
- `lance/src/index/scalar.rs`: declare the `label_list` module.

Covered by `test_label_list_merge_existing_index_segments`: one
LabelList segment per
fragment, merged via `merge_existing_index_segments`, answers
`array_has_any` across
both fragments with the same row count as a pre-index full scan.
Summary:
- Improve FM `contains` queries so normal reads demand-load needed
wavelet blocks and page nearby blocks instead of prewarming whole
partitions.
- Add chunked explicit FM prewarm, parallel partition loading/search,
and resumable partition builds.
- Add FM `contains` benchmark and FM index management tooling.

Optimizations applied:
- Parallel FM search across partitions/segments.
- Removed query-time full-partition prewarm; explicit prewarm still
fully warms the index.
- Chunked contiguous wavelet-row prewarm with
`LANCE_FMINDEX_PREWARM_CHUNK_BYTES` and
`LANCE_FMINDEX_PREWARM_CHUNK_CONCURRENCY`.
- Cold-read demand paging with `LANCE_FMINDEX_DEMAND_PAGE_BYTES` to
reduce object-store RPS.
- Parallelized FM metadata/partition loading.
- Added resumable FM partition creation and explicit `--index-uuid`
recovery support.
- Final benchmark layout: 1 logical FM segment,
`LANCE_FMINDEX_PARTITION_ROWS=100000`, large partition-byte cap, 1,000
partitions.

Performance:
Dataset: 100M-row
`az://datasets/mmlb/mmlb_100m_fts_en_fm_20260626.lance`.
Query workload: 4 sampled 5-term patterns from `summary_in_image`,
`contains(full_content, pattern)`, `k=100`, `_rowid` only
(`projection=[]`, `row_id=true`).

| Run | Index layout | Prewarm | Query result |
| --- | --- | --- | --- |
| Baseline | 6 segments / 10k partitions | 6,525s / 108.8m | 1t: 1.58
qps, mean 633ms, p95 768ms; 8t: 12.48 qps, mean 320ms, p95 320ms |
| Demand-load + chunked prewarm | 6 segments / 10k partitions | 2,182s /
36.4m, 3.0x faster | 1t: 5.38 qps, mean 185ms, p95 307ms; 8t: 12.12 qps,
mean 326ms, p95 330ms |
| Single segment | 1 segment / 100k-row partitions | 356s / 5.94m, 18.3x
faster than baseline | 1t: 22.38 qps, mean 44.6ms, p95 54.4ms; 8t: 84.40
qps, mean 42.8ms, p95 47.3ms |

Index size:
- Final FM index UUID `78600545-625f-40e2-8790-204f35097ec0`: 1,000
partition files, 920,064,767,792 bytes / 856.9 GiB / 0.837 TiB.
- Previous 6-segment FM layout was 920,202,650,467 bytes / 857.0 GiB, so
the final relayout is effectively size-neutral.
wkalt and others added 30 commits July 9, 2026 18:02
…nce-format#7717)

A valid empty value is stored as {position: 0, size: 0} while nulls pack
their non-zero rep/def levels into position. The page scheduler skips
size-0 rows when scheduling reads, but the load task assigned read
results to every def == 0 blob, so an empty value consumed the next
blob's payload and the last consumer crashed on an exhausted iterator
("Expected option to have value"). Give empty values their zero-length
bytes at scheduling time and only assign read results to blobs that
scheduled a read.

Fixes lance-format#7716

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Fixed decoding/round-trip handling for empty blob (`LargeBinary`)
values so they’re preserved and remain correctly aligned when mixed with
non-empty payloads and nulls.
* Prevented empty blob byte data from being overwritten during page
loading and ensured empty values are treated as present during decoding.
* **Tests**
* Added an async round-trip test covering empty (`LargeBinary`) values
interleaved with nulls when blob metadata is enabled.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Hoisting the tier choice out of the per-vector loop is only half the job. The
first attempt also replaced the kernel with the auto-vectorized scalar one,
on the theory that an AVX2-baseline build makes the SIMD kernel redundant.
Benchmarks say otherwise: at dim 8, `L2(simd,f32x8)` went from -41.6% (before
this work) to +156% against upstream/main. Removing a cheap branch is worthless
if it costs the kernel behind it.

The baseline already guarantees avx2+fma, so call the AVX+FMA kernel directly:
no runtime check, and the `#[target_feature]` contract is satisfied statically,
so it inlines into the loop.

Measured on an AMD EPYC 7B13 (avx2, fma, no avx512f), against upstream/main:

                          before this work   with this commit
  L2(simd,f32x8)  dim 8        -41.6%             -45.4%
  L2(f32, simd)   dim 1024      +1.5%              +0.4%
  Dot(f32, SIMD)                -0.1%              -0.5%

The iterator stays a bare `Map`: `Map<ChunksExact, _>` is `TrustedLen`, so
`.collect()` preallocates, and `Map::fold` drives `ChunksExact` in one inlined
loop. Any wrapper — trait object or enum — loses both.
`cfg(target_feature = "avx2")` does not imply `fma` — `-C target-feature=+avx2`
sets one and not the other. The batch path guarded on it calls a kernel declared
`#[target_feature(enable = "avx,fma")]`, so such a build emitted `vfmadd` while
claiming a baseline without FMA.

Gate the direct-kernel arm on both features, and make the runtime-dispatch arm
its exact complement so the two remain total. Verified by building the crate at
`+avx2`, `+avx2,+fma`, `+avx`, `target-cpu=haswell`, and the default baseline.
Bumps the pinned toolchain from 1.94.0 to 1.97.0 in
`rust-toolchain.toml` (the `python/` and `java/lance-jni/` copies are
symlinks to it) and fixes the clippy lints newly reported by 1.97 across
the workspace.

New lints fixed:
- `manual_filter`, `manual_checked_ops`, `question_mark`
- `useless_conversion`, `useless_borrows_in_formatting`
- `collapsible_match`, `while_let_loop`, `explicit_counter_loop`,
`unnecessary_sort_by`

All fixes are behavior-preserving. `cargo clippy -- -D warnings` is
clean across the main workspace (all features, all targets), `python/`,
and `java/lance-jni/`; `cargo fmt --check` passes on all three.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of floating-point `NaN` values in range queries for
more conservative, reliable results.
* Prevented potential division-by-zero issues in decoding and statistics
calculations.
* Preserved correct behavior when processing all-null data and missing
fields.

* **Maintenance**
  * Updated the Rust toolchain used to build the project.
* Simplified internal processing across indexing, scanning, encoding,
storage, and query-planning components without changing expected
behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
`dot_scalar`/`l2_scalar` chunk by 16 lanes. At or below that width the chunking
degenerates to the scalar remainder loop and vectorizes nothing, which is why
the explicit AVX kernel is worth ~40% at dim 8 — the width PQ sub-vectors use.
Above 16 lanes the autovectorizer is already good, and on Zen 3 the 8-wide
kernel loses to it.

Measured on an AMD EPYC 7B13 (avx2, fma, no avx512f), two repetitions each,
against upstream/main:

                       kernel everywhere      this commit
  L2(simd,f32x8)         -42.2% / -41.4%      (unchanged, kernel)
  Dot(f32, SIMD)         +1.9%  / +3.6%       (scalar, == base)

So above the threshold keep exactly the kernel the pre-dispatch code used. The
wide path is then byte-identical to base and cannot regress; the narrow path
keeps the win. The branch is loop-invariant.
## Performance issue

No-position FTS prewarm currently decodes posting rows into a
`Vec<PostingList>` object graph. This substantially amplifies the index
cache for workloads dominated by singleton and small posting lists.

Measured before this change:

- 10M globally unique terms: 2.76 GB cache for a 239.6 MB index (11.53x)

## How this improves performance

This PR:

- replaces index-time posting-group metadata with runtime synthetic
groups, so existing indexes benefit without rebuilding;
- stores no-position prewarmed groups as compact Arrow-backed posting
rows instead of materializing every posting list;
- creates a lightweight posting-list view only for the term used by a
query, sharing the cached posting buffers;
- keeps score and length metadata in the posting reader for prewarmed
groups;
- retains the materialized fallback for legacy and position-bearing
paths;
- versions the cache codec while continuing to read the previous
materialized group representation.

The persistent FTS index format and public API do not change.
`prewarm_index` still populates the posting cache for subsequent
queries.

## Benchmark

Fresh `c4-standard-16` VM, no-position format-v2 indexes created once by
baseline Lance `5dbd1400d`, then reused unchanged by the target. The
target uses the default runtime group size of 128. Each prewarm result
is the mean of five runs.

| Dataset | Main cache | Target cache | Change | Cold prewarm |
|---|---:|---:|---:|---:|
| 10M unique terms | 2.762 GB | 250.3 MB | -90.94% | 6.440s -> 0.895s |
| Wikipedia-40M | 8.423 GB | 3.724 GB | -55.79% | 15.697s -> 6.860s |

A 64/128/256 sweep selected 128: compared with 256 it halves cache-group
granularity for a 2.36% cache increase on 10M unique terms and 0.29% on
Wikipedia-40M. Size 64 increased the 10M cache by 7.07% and cold prewarm
time by 30.8%.

Three repeated, prewarmed query runs at group size 128 showed no
materialization regression. Mean QPS changed by -0.7% to +0.6% across
the 10M unique-term and Wikipedia k=10/k=100 workloads, within
run-to-run variance.

## Validation

- `cargo fmt --all -- --check`
- `cargo test -p lance-index scalar::inverted --lib --profile
release-with-debug` (197 passed)
- `cargo clippy --all --tests --benches -- -D warnings`
- same-index benchmark validation: every query run reported
`index_action=reused` and `prewarm_with_position=false`

Addresses OSS-1409.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added runtime synthetic posting-list grouping for non-legacy v2
indexes.
* Introduced a more compact packed posting-list cache format and
packed-group prewarming.
* **Bug Fixes**
* Preserved backward-compatible decoding by falling back to the legacy
layout.
* Added consistency validation for packed cache contents and ensured
BM25 correctness.
* **Refactor**
* Removed persisted posting-group boundary bookkeeping; grouping is now
computed at runtime.
* **Tests**
* Updated and extended tests for packed vs materialized groups,
including roundtrip and zero-copy coverage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Yang Cen <[email protected]>
…ance-format#7682)

## What & why

`lance-core` keeps a process-global CPU thread-pool runtime behind
`CPU_RUNTIME: AtomicPtr<Runtime>`. `global_cpu_runtime()` dereferenced
that raw pointer into `&'static mut Runtime`. Its only caller,
`spawn_cpu`, runs concurrently on many worker threads, so multiple
`&'static mut` references to the same `Runtime` could be live at once —
a violation of `&mut` uniqueness, which is undefined behavior on the
Rust abstract machine even though every `Runtime` method actually used
takes `&self` and never mutates. It is latent UB the compiler is
permitted to miscompile under noalias assumptions (Miri flags it), not
an observed crash today.

## How & why it is correct

Return `&'static Runtime` and dereference with `&*ptr` instead. Three
facts make this sound:

- The only method called is `Runtime::spawn_blocking`, which takes
`&self` — the `&mut` was never needed.
- `Runtime: Sync`, so many threads may hold `&'static Runtime`
concurrently without UB, which is exactly the aliasing the old code got
wrong.
- The runtime is created via `Box::into_raw` and never reclaimed, so the
`&'static` lifetime is genuine (no use-after-free); `atfork_tokio_child`
only nulls the pointer, it does not free.

Both `unsafe` derefs now carry `// SAFETY:` comments documenting these
invariants.

## Compatibility

Fully backward compatible, zero behavior change. `global_cpu_runtime` is
private, so the return-type change is internal-only; `spawn_cpu`'s
public signature is unchanged. `&mut Runtime` already auto-reborrowed to
`&self` for `spawn_blocking`, so narrowing to `&Runtime` is
byte-for-byte identical at runtime — purely tightening a gratuitous,
unsound `&mut` into a shared `&`.

## Test plan

- `cargo test -p lance-core`
- `cargo clippy -p lance-core --tests -- -D warnings`
- `cargo fmt --all -- --check`
…at#7723)

## Bug Fix

### What is the bug?

`test_simple_index_nearest_centroid` builds a small HNSW index in
parallel and then requires an exact nearest-centroid result. HNSW node
insertion mutates the shared graph concurrently, so Rayon scheduling can
produce different graph topologies even though node-level RNG is seeded.

### What incorrect behavior does the bug cause?

The approximate search occasionally fails the strict `id == 42`
assertion and returns a nearby centroid such as 43 or 44. This makes the
test flaky, especially under CPU contention, without indicating a
production regression.

### How does this PR fix the problem?

Build the test's 100-point HNSW index inside a dedicated single-thread
Rayon pool. This preserves the exact assertions while making graph
construction deterministic. Production index construction and the binary
nearest-centroid test are unchanged.

## Validation

- `cargo test -p lance-index test_simple_index_nearest_centroid --
--nocapture`
- 3,000 concurrent stress-test runs with 16 processes and
`RAYON_NUM_THREADS=8`: 0 failures
- `cargo fmt --all -- --check`
- `cargo clippy -p lance-index --tests -- -D warnings`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Updated vector index testing to run index construction within a
controlled single-threaded environment.
  * No user-facing behavior changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Yang Cen <[email protected]>
)

Adds a specification for **data overlay files**: small files attached to
a fragment that supply new values for a subset of `(row offset, field)`
cells without rewriting the base data files. They make cell-level
updates cheap when only a small fraction of rows and/or columns change.

This PR is **spec + proto only** — no read/write implementation yet. It
is also explicitly *experimental*. The released libraries will not
produce tables with this feature enabled. Once the implementation is
done in the library, we will vote on the final design before releasing.
This is similar to how we have done file format updates.

## Changes
- **`protos/table.proto`**
- Rework `DataOverlayFile`: a `oneof coverage { bytes
shared_offset_bitmap | FieldCoverage field_coverage }` to support both
dense (rectangular) and sparse overlays; add the `FieldCoverage`
message.
- Rename `read_version` → `committed_version` (`uint64`), with
effective/commit-stamped semantics so overlay-vs-index ordering is
correct.
- Drop the in-file offset key column in favor of rank-based addressing
off the coverage bitmap.
- Document reader feature flag `64` (and previously-undocumented
`16`/`32`).
- **`docs/src/format/table/data_overlay_file.md`** (new): full
specification — coverage/resolution, deletion precedence, NULL-override,
layout + rank addressing, dense vs. sparse, versioning, field-aware
index exclusion with flat re-evaluation, the correctness invariant, both
compaction modes, row lineage, a worked example (write → read → index
query → sparse write → read → compaction), and a guidance stub with open
questions.
- **`docs/src/format/table/index.md`**: concise overview + link to the
new spec (replacing the earlier inline sketch).

## Out of scope / follow-ups
- Write transaction shape (new `Operation` variant in
`transaction.proto` + Rust).
- Writer support for unequal-length columns (needed for single-file
sparse overlays).
- Coverage bitmap external spill for very large coverage.
- Per-fragment vs. per-table overlays / LSM analogy (open question in
the doc).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added documentation for experimental Data Overlay Files, including
storage, versioning, querying, compaction, and transaction behavior.
* Added format and transaction schema support for describing data
overlays.
  * Added navigation links to the new documentation.

* **Bug Fixes**
* Datasets using unsupported data overlays are now explicitly rejected
instead of risking stale results.

* **Documentation**
* Expanded guidance on invalidated index results and overlay-related
filtering scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Weston Pace <[email protected]>
…alue buffers (lance-format#7460)

Closes lance-format#7459

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Zstandard compression now correctly honors the configured compression
level for large variable-width values.
- Improved consistency between compression settings and the resulting
encoded data.
- **Tests**
- Added coverage to verify the compression level is preserved for large
per-value Zstandard compression.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…cy (lance-format#7632)

## Background & Motivation

Under concurrent-scan workloads like Spark, a single large worker
(Executor) typically runs many Tasks in parallel (in Lance's read path,
**1 Task = 1 fragment = 1 scan**). The problem: **each Task
independently sizes its own scan concurrency from the core count of the
current Executor / host**.

Concretely in Lance, the decode concurrency of the v2 read path
`FilteredReadExec` (plan name `LanceRead`) — the
`try_buffered(num_threads)` — is unconditionally set to
`get_num_compute_intensive_cpus()` = `num_cpus − 2`.

```
total decode concurrency per Executor ≈ (concurrent Tasks) × (num_cpus − 2)
```

## Why It Was Deprecated, and the Gap It Left Behind

`Scanner.batch_readahead` is marked `Ignored in v2 and newer format` — a
deliberate decision, not an oversight. In v1 it controlled two things:
**prefetch depth** and **decode concurrency**. v2 replaced prefetch with
a byte-budget model (`io_buffer_size` + `fragment_readahead`), so its
prefetch role became meaningless and was rightly dropped.

The gap: v2 split the **decode-concurrency** role into
`FilteredReadExec`'s threading mode (`try_buffered(num_threads)`),
hard-coded to `get_num_compute_intensive_cpus()`. The only override
today is the process-wide `LANCE_CPU_THREADS` env var — far too coarse,
since it governs *every* compute-intensive path at once (vector/KNN
search, index building, `take`, update/merge-insert, …), not just scan
decode concurrency. With no per-scan knob, there's no way to rein in the
over-parallelization above.


## What This Change Does & Its Impact

Have `new_filtered_read` pass `Scanner.batch_readahead` through as
`FilteredReadThreadingMode::OnePartitionMultipleThreads(batch_readahead)`,
reattaching `batch_readahead` to the decode-concurrency dimension that
v2 had left without a knob.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Invalid `batch_readahead` / `batchReadahead` values (0 or less) are
now rejected consistently across Java, Python, and Rust.
* Error messages now clearly state `batch_readahead` must be greater
than 0.
* Filtered scan execution now applies `batch_readahead` to control
decoding parallelism more reliably.

* **Tests**
* Strengthened `batch_readahead` tests to validate deterministic scanned
content in addition to row counts.
* Added Rust coverage for default behavior, custom values, and rejection
of zero-parallelism configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Feature

Linear:
[OSS-1344](https://linear.app/lancedb/issue/OSS-1344/make-fts-index-block-size-configurable)

### What is the new feature?

FTS inverted index creation now accepts a `block_size` parameter for
compressed posting blocks. Supported values are `128` and `256`.

### Why do we need this feature?

The posting block size was previously fixed at `128`, which made the
block-max granularity impossible to tune for different datasets and
query profiles.

### How does it work?

- Adds `block_size` to `InvertedIndexParams`, protobuf details,
posting-list schema metadata, and cache headers.
- Uses `128` as the default for newly created indexes.
- Treats older serialized params, schema metadata, and cache entries
that omit `block_size` as legacy `128`.
- Rejects unsupported values, including `512`, with a clear validation
error.
- Uses Lance-owned `BitPacker4x` for physical 128-value posting blocks
and `BitPacker8x` for physical 256-value posting blocks.
- Marks `block_size=256` as experimental in public API docs because it
may introduce breaking changes.
- Keeps position-stream packing on the legacy 128-value block format.
- Keeps downgrade compatibility tests on explicit legacy
`block_size=128`, since older wheels cannot read current-created
physical 256 FTS posting blocks.
- Threads the configured block size through FTS build, read, iterator,
WAND, cache, and MemWAL flush paths.
- Exposes the parameter in Python and Java FTS index creation APIs, with
docs and focused tests.

## Validation

- `cargo fmt --all`
- `cargo fmt --all --check`
- `git diff --check`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-no512 cargo test -p
lance-index block_size -- --nocapture`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-no512 cargo clippy -p
lance-index --tests -- -D warnings`
- `uv run make build` from `python/`
- `uv run pytest
python/tests/test_scalar_index.py::test_create_scalar_index_fts_block_size`
from `python/`
- `uv run ruff format --check python/tests/test_scalar_index.py
python/lance/dataset.py` from `python/`
- `uv run ruff check python/tests/test_scalar_index.py
python/lance/dataset.py` from `python/`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo test -p
lance-index block_size -- --nocapture`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo test -p
lance-index test_256_posting_block_uses_single_physical_bitpack_chunk --
--nocapture`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo test -p
lance-bitpacking`
- `CARGO_TARGET_DIR=/tmp/lance-target-a479-merge-main cargo clippy -p
lance-bitpacking -p lance-index --tests -- -D warnings`
- `uv run ruff format --check
python/tests/compat/test_scalar_indices.py` from `python/`
- `uv run ruff check python/tests/compat/test_scalar_indices.py` from
`python/`
- `uv run pytest --run-compat -vvv -s
python/tests/compat/test_scalar_indices.py::test_FtsIndex_downgrade
--durations=30` from `python/`
- `CARGO_TARGET_DIR=/tmp/lance-a479-target cargo test -p lance-index
test_new_training_request_defaults_missing_block_size_to_128`
- `CARGO_TARGET_DIR=/tmp/lance-a479-target cargo test -p lance-index
block_size`
- `uv run ruff format --check python/lance/dataset.py` from `python/`
- `uv run ruff check python/lance/dataset.py` from `python/`

Not run locally: Java focused test / spotless check, because this
machine has no Java Runtime installed (`Unable to locate a Java
Runtime`).

---

## Update: all V3 breaking changes consolidated here

Per review direction, every breaking change for the 256-doc block format
now lands in this single PR (the follow-up stack
lance-format#7602/lance-format#7603/lance-format#7604/lance-format#7624/lance-format#7625/lance-format#7629 carries none). On top of the
configurable block size and PFOR frequency encoding, this PR now also
includes:

- **Quantized doc-length scoring (Lucene norm semantics), 256-doc blocks
only.** BM25 doc lengths are quantized to a SmallFloat-style byte code
(4 mantissa bits: 0-7 exact, <= 6.25% relative error, decode = bucket
floor). The byte-norm slab bakes lazily per loaded DocSet and quarters
the doc-length bytes scoring pulls through the cache (200M docs: 800MB
-> 200MB). 128-block indexes keep exact-length scoring bit-for-bit.
Measured top-k overlap vs exact scoring on the (score-clustered,
synthetic) mmlb corpus: 98.1% mean for phrase, 89.7% for 3-word AND;
corpora with more score spread shift less.
- **256-doc posting blocks drop the leading block-max-score f32** (~1.5G
on a 200M-doc index; 131G -> 130G). Block layout: `[first_doc u32][doc
num_bits u8][docs][pfor freqs]`;
`posting_block_score_prefix_len(block_size)` keys every reader/writer.
The impact skip data from the stacked lance-format#7602 supplies a tighter per-block
bound; until it lands, 256-block block-max pruning falls back to the
(valid, looser) list-level max score.

**BREAKING:** 256-doc-block (v3) indexes must be rebuilt; v3 is
unreleased so no migration is provided. BM25 scores on v3 differ from
exact-length BM25 by the norm quantization, matching Lucene's norm
semantics. The format discussion lance-format#7606 documents the final layout and
scoring semantics.

Additional validation for this update: bulk-vs-classic A/B under
quantized scoring is score-identical (both paths quantize identically);
the full stack's warm benchmarks vs Lucene 10.4 on mmlb-200m: OR k10
0.0249s/318qps and OR k100 0.0467s/170qps (both ahead of Lucene sliced),
AND k10 0.0443s (1.29x), AND k100 0.0883s (1.94x).

---

## Standalone results vs main (per-branch-tip wheels)

**Legacy (128) read-path parity.** Threading a runtime `block_size`
through
`PostingIterator` initially replaced the compile-time `BLOCK_SIZE`
division
(a shift) with real `div` instructions in the `doc()`/`next()` hot
loops,
measured as +11-14% on 3-word OR against the 200M legacy index
(`PostingIterator::next` grew from 16.5% to 23.6% of the profile). Block
sizes are validated powers of two, so the iterator now derives block
indices
with `trailing_zeros` shifts and masks; after that fix the legacy path
is at
parity with main: 3-word OR k10 0.131s (main 0.132-0.134s), k100
0.255-0.256s (main 0.256-0.257s), single-term 0.025s (main 0.027s)
across 3 warm passes on the 200M legacy index, 400G cache.

**block_size=256 index size** (5M-doc controlled build, same wheel,
`with_position=false`): postings shrink **3.33 GiB → 2.62 GiB (−21%)**
from
PFOR frequencies + no per-block max-score prefix + half the block
headers.
Index build 192s → 210s (+10%, PFOR encode cost). Top-10 overlap vs the
128 exact-length scoring on 10 3-word OR queries: 95% mean (5/10
identical
sets; the rest differ by 1-2 near-tie docs, from the quantized-norm
scoring).

**Query wins for 256 land in the stacked PRs.** A 256 index without
impact
skip data prunes on the (valid, looser) list-level max and is *slower*
than
128 — e.g. classic AND k10 0.547s until lance-format#7602's impacts restore
block-granular bounds (0.115s), and lance-format#7603/lance-format#7604/lance-format#7624/lance-format#7625/lance-format#7629 take
the
same index to 0.025s OR k10 / 0.045s AND k10.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added configurable FTS posting `block_size` (128/256) to scalar index
creation, including updated examples and APIs.
* Enabled FTS format version 3 (`v3`) for `block_size=256`, with
quantized doc-length scoring for v3.
* **Bug Fixes**
* Enforced `block_size`/`format_version` compatibility (invalid
combinations now error).
* Persisted and restored FTS metadata for format version and posting
block size, with legacy indexes defaulting to `128`.
* **Documentation**
* Updated full-text-search and quickstart guides and parameter docs for
`block_size`, defaults, accepted values, and the experimental `256`/`v3`
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Yang Cen <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
## Summary

This adds scan-based support for `List<Blob>` and nested blob leaves
such as `Struct<List<Blob>>`.

Descriptor scans preserve the original Arrow nesting and expose blob
leaves as descriptors without loading payload bytes.
`BlobHandling::AllBinary` materializes only blob leaves to binary values
while keeping the surrounding `List` / `Struct` layout intact.

This keeps the existing one-blob-per-row APIs unchanged and avoids
adding a public random-access API for list blob values.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added Blob V2 binary view support for list and nested fields, with
correct scan/filtered read/take output schema behavior.
* Improved Blob V2 payload resolution for inline, dedicated, packed, and
external sources.
* **Bug Fixes**
* Stricter Blob V2 detection and more consistent conversion/unload
behavior.
* Improved projection and schema intersection handling for Blob V2,
including nested cases and better type-ignore behavior.
* Preserved nulls, list offsets, and descriptor validity; ensured the
binary-view marker is handled consistently.
* **Tests**
* Expanded Blob V2 coverage for binary/view materialization, descriptor
projection, and nested list/struct scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Lance releases frequently, which can make users worry that the Lance
format itself is unstable. This PR updates the README to distinguish
SDK/API release cadence from the dataset `data_storage_version`
compatibility contract.

It explicitly states that stable storage versions remain readable by
future Lance releases, that a dataset's storage version is fixed at
creation, and that `next` is only for experimentation.
Part of lance-format#7750

This isolates and names the existing dense mini-block rep/def
page-budget decision so later sparse auto-selection can consume an
honest contract. It renames the planner result and related encoder
methods and fields without changing the underlying decision or page
splitting.

There is no format or behavior change.

Validation:
- `cargo fmt --all`
- `cargo test -p lance-encoding` (427 passed, 5 ignored; doc-tests: 0
passed, 1 ignored)
- `cargo clippy -p lance-encoding --tests --benches -- -D warnings`
- `cargo clippy --all --tests --benches -- -D warnings`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of pages with large repetition and definition-level
data.
* Prevented encoding failures when a single row exceeds mini-block
limits.
* Added safer fallback behavior for oversized rows during page encoding.
* Improved page splitting to keep data within encoding budgets while
preserving correctness.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Part of lance-format#7750

Inline bitpacking mini-block decompression currently enters the
non-empty unchunk path for zero values, which expects an inline
bit-width header and panics on an empty payload. Return an empty
`FixedWidthDataBlock` with the configured bit width instead.

This is the generic zero-value codec prerequisite used when sparse pages
project to no leaf values. It contains no sparse format or layout
behavior.

Validation:
- `cargo fmt --all -- --check`
- `cargo test -p lance-encoding encodings::physical::bitpacking::test`
- `cargo test -p lance-encoding`
- `cargo clippy -p lance-encoding --tests --benches -- -D warnings`
- `cargo clippy --all --tests --benches -- -D warnings`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
  * Fixed decompression for empty bitpacked data blocks.
* Empty miniblocks now return correctly formatted empty results without
attempting to read missing metadata.

* **Tests**
  * Added coverage to verify empty miniblock handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Labeling incoming issues by hand as bug / feature / performance is
tedious. This applies the label automatically through three
complementary layers, so it works no matter how an issue is opened. The
aim is reliable labeling, not enforcing issue structure.

## 1. Issue templates (web UI)

Templates under `.github/ISSUE_TEMPLATE/`, each auto-applying its label:

- **Bug report** → `bug` — a light form: description, reproduction,
Lance version, language binding (env and logs optional).
- **Feature request** → `feature` — free-form.
- **Performance issue** → `performance` — free-form.

`config.yml` keeps blank issues enabled and routes usage questions to
Discord.

## 2. Content-based labeler (everything else)

Templates only fire for the web-UI chooser. Issues opened via `gh issue
create`, the REST API, or an agent bypass them and land unlabeled.
`.github/workflows/issue-labeler.yml` closes that gap: it runs
`srvaroa/labeler` (the same pinned action the PR labeler uses) on
`issues` events, keyed by `.github/labeler-issues.yml`.

- Primary signal: a leading marker in the **title** (`bug:`,
`[feature]`, `perf -`, …), which people commonly type by hand.
- Fallback: body keywords (panic/crash → bug, regression/OOM →
performance, …).
- Unmatched issues get **no** label and are left for human triage rather
than guessed at. `appendOnly` means it never strips a template/human
label and is idempotent on edit.

## 3. Agent contract (AGENTS.md)

A template can't bind an agent, so `AGENTS.md` now instructs agents to
pass an explicit `--label bug|feature|performance` (and prefix the
title) when opening issues.

## Notes

- Templates can't be previewed from a diff — worth clicking through the
"New issue" chooser on a fork before merging.
- Left blank issues enabled so no one is forced through a template.
- A future refinement could add an LLM classify step in the labeler
workflow for the unmatched residual; out of scope here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Added structured templates for bug, feature, and performance issue
reports.
* Added guidance for filing issues, including recommended labels and
title prefixes.
* **Chores**
  * Enabled automatic issue labeling based on titles and descriptions.
* Added a contact link directing usage questions to the community
Discord.
  * Enabled blank issue creation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
…ance-format#6980)

# Summary

Fixes an HNSW-IVF index build bug analogous to lance-format#6957, but in the legacy
HNSW partition writer path.

# Changes

- Keeps the `TempStdDir` guard alive for the full
`write_hnsw_quantization_index_partitions` function.
- Prevents scratch `hnsw_part_*` files from disappearing before they are
read back into the final index files.
- No new test added: existing
`index::vector::ivf::tests::test_create_index_nulls` already covers
`IVF_HNSW_PQ` and `IVF_HNSW_SQ` with `IndexFileVersion::Legacy`.

# Testing

- `cargo test -p lance test_create_index_nulls -- --nocapture`
- Result: 10 passed, including the legacy `IVF_HNSW_PQ` and
`IVF_HNSW_SQ` cases.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved reliability when building IVF_HNSW indexes with concurrent
per-partition jobs.
* Ensured temporary scratch directories and intermediate files persist
correctly during in-flight work, and are cleaned up consistently after
errors.
* Improved error propagation so index build failures stop background
work promptly and report late task issues accurately.
* **Tests**
* Added Tokio-based coverage for scratch-directory lifetime, failure
draining/abort behavior, and cleanup under isolated temporary
directories.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Builds on the merged configurable posting block size work in lance-format#7466.
Format discussion: lance-format#7606.

Store per-block `(freq, doc_len)` impact frontiers alongside compressed
posting blocks, plus one level-1 entry per 32 blocks, and drive
block-max WAND pruning from them instead of build-time scores that can
become stale as index statistics drift.

- Both 128- and 256-doc blocks use the same compact impact codec:
quantized `u8` document-length norms, delta-encoded frequencies, and an
omitted norm byte for the common `+1` delta.
- Scorer-specific bounds bake once into cache-shared state; query-local
caches reuse the slab without sharing stale bounds across different
corpus statistics.
- Impact data survives packed prewarm and persistent-cache round trips.
Posting-list cache versions are bumped while older versions remain
readable.
- Packed posting views share both impact-derived state and the
block-head cache introduced by lance-format#7466.
- Malformed or missing impact entries fall back to conservative infinite
bounds instead of enabling unsafe WAND skips.
- Public posting cache-key struct literals remain source-compatible;
impact-bearing entries use an internal namespaced key.
- V3 indexes without impacts retain the finite BM25 ceiling, while
custom scorers without a declared safe bound fall back to `INFINITY`.

The query benchmark below predates this codec follow-up; the V3 scoring
bounds are unchanged, but impact size and decode cost need to be
remeasured.

## Benchmark

Measured before this restack against lance-format#7466 using per-branch-tip wheels:
200M-doc V3/256 index, 24 partitions, 1000 warm queries at 8 concurrent
requests.

| query | lance-format#7466 list-max fallback | this PR |
|---|---|---|
| OR 3w k10 | 0.363s / 22 qps | **0.103s / 76 qps (3.5x)** |
| OR 3w k100 | 0.392s / 20 qps | **0.198s / 40 qps (2.0x)** |
| AND 3w k10 | 0.547s / 15 qps | **0.115s / 69 qps (4.8x)** |
| AND 3w k100 | 0.558s / 14 qps | **0.245s / 33 qps (2.3x)** |

## Validation

- `cargo test -p lance-index`: 773 passed, 2 ignored; doctest passed
- After the final rebase to current `main`, `cargo test -p lance-index
--lib scalar::inverted`: 249 passed
- `cargo check --workspace --tests --benches`
- `cargo clippy --all --tests --benches -- -D warnings`
- `cargo fmt --all -- --check`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Full-text search now optionally uses impact skip data to improve
block-max scoring and pruning when index partitions provide it.
- Impacts are carried through compressed and packed posting data, with
impacts-aware WAND routing and scorer-weighted bound caching.

- **Bug Fixes**
- Improved decoding/validation of impacts envelopes, including safe
behavior for malformed, null, or truncated data.
- More robust posting component extraction and cache-key isolation to
prevent mixing impact vs non-impact partitions.

- **Tests**
- Expanded roundtrip, cache isolation, and backward/forward
compatibility tests for impact-enabled and legacy postings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Yang Cen <[email protected]>
…at#7758)

## Summary

`hamming_clustering_for_ivf_partition` and `get_ivf_partition_info`
resolved the target index with `find(name)` — the first physical segment
only. On a logical IVF_FLAT index made of multiple segments (delta
segments from append-mode `optimize_indices`, or distributed builds
committed via `commit_existing_index_segments`), this silently processed
only the oldest segment: appended rows were excluded and cross-segment
duplicate pairs were never compared, with no error raised.

Both functions now cover **all** segments of the logical index. Delta
segments of one IVF index share centroids, so partition `p` denotes the
same centroid region in every segment; the correct clustering unit is
the union of partition `p`'s rows across all segments in a single
pairwise pass (representative = min row id, so results are
order-independent).

## Changes

- **Rust** (`lance-index::vector::hamming`): all-segments behavior for
both functions. Every selected segment is validated to share
byte-identical global IVF centroids; a mismatch (or missing centroids)
raises a descriptive error naming the diverging segment UUIDs and
partition counts, advising a retrain. New
`hamming_clustering_for_ivf_partition_segments` /
`get_ivf_partition_info_segments` accept explicit segment UUIDs,
following the `prewarm_index_segments` precedent (lance-format#7677).
- **Python**: keyword-only `index_segments` argument on
`hamming_clustering_for_ivf_partition` and `get_ivf_partition_info`
(`None` = all segments, the default). Segment-id normalization used in
three places (these wrappers, `prewarm_index`,
`ScannerBuilder.with_index_segments`) is consolidated into
`lance.util._normalize_index_segment_ids`.

## Notes

- Fixes a latent bug: `get_ivf_partition_info` returned `size: 0` for
every partition on v3 index files because the loaded IVF model carries
no partition lengths. Sizes now come from partition storage via
`VectorIndex::partition_size`.
- Behavior tightening: `get_ivf_partition_info` now validates the
indexed column is `FixedSizeList<UInt8, 8>`, a check it previously
skipped. This is consistent with the clustering function, which always
required 8-byte hashes.
- An empty explicit segment selection is rejected rather than silently
returning "no duplicates", to avoid a silent data-quality hazard in
dedup pipelines.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for running IVF_FLAT Hamming clustering and partition
analysis across multiple index segments.
* Added optional segment selection to limit clustering and partition
statistics to specific physical segments.
* Segment identifiers now accept UUID strings or UUID objects with
validation for invalid selections.

* **Bug Fixes**
* Improved handling and validation of index segment identifiers across
dataset and scanner operations.
* Added validation to ensure selected segments are compatible before
processing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…lance-format#7535)

> **Supersedes lance-format#7407.** Relocated into `lance-format/lance` (head + base
in-repo) so the OSS-1324 PR can stack on it directly and show a clean,
specific diff.

Data model, feature flag, and write/commit foundation for [Data Overlay
Files](lance-format#7381) (OSS-1322).

**Stacked on lance-format#7381** (spec/proto). To keep this diff clean, the base is
an in-repo mirror of lance-format#7381's branch (`will/data-overlay-spec-base`);
retarget to `main` once lance-format#7381 lands. (lance-format#7406 / OSS-1323, needed for the
sparse overlay sink, has merged.)

## What's here

- **Data model** (`lance-table/src/format/overlay.rs`) —
`DataOverlayFile` + `OverlayCoverage` on `Fragment.overlays`, dense
(`shared_offset_bitmap`) and sparse (per-field). Lives in its own
module: a small public surface (the two types, `dense`/`sparse`,
`coverage_for_field`) with the coverage / rank / parse-once /
newest-last invariants documented at the module level and the
serde/proto/Roaring plumbing kept private. Coverage bitmaps are parsed
once on load into `Arc<RoaringBitmap>` (not re-deserialized per access),
and a fragment's overlays are stable-sorted by `committed_version`
(newest last) on load so resolution can rely on the ordering.
Protobuf/serde round-trip + `coverage_for_field` tested.
- **Feature flag 64** (`FLAG_DATA_OVERLAY_FILES`) — set when any
fragment has overlays. Release-gated: treated as an unknown flag in
release builds (so release readers/writers refuse overlay datasets)
unless `LANCE_ENABLE_DATA_OVERLAY_FILES` is set; debug builds understand
it. Tested (release-gating policy is asserted profile-independently).
- **`Operation::DataOverlay` transaction** — appends overlays to a
fragment (preserving concurrently-written ones) and stamps
`committed_version` at commit, re-stamped on retry. Protobuf round-trip
+ multi-fragment `build_manifest` (distinct targets + untargeted
pass-through) tested.
- **Conflict resolution** (v2 `CommitConflictResolver`) — permissive
like `DataReplacement`: compatible with append / column-rewrite /
index-build / data-replacement / other overlays, and with
deletes/updates that leave the overlaid fragment in place. Retryable
when a concurrent op row-rewrites or consumes the overlays on an
overlaid fragment (`Rewrite`/`Merge`) or *removes* one
(`Update`/`Delete` removal); incompatible with whole-dataset
`Overwrite`/`Restore` and with `UpdateMemWalState` (matching the spec —
both commit directions now agree). Tested (both-direction matrix,
including remove-fragment and `Rewrite`×overlay).

The fragment read path refuses overlays until the scan merge lands
(OSS-1324).

## Follow-ups on this stack

- [ ] `DataOverlayFileWriter` — streaming dense+sparse sink, used
internally behind `update`/`merge_insert`.
- [ ] `validate()` overlay checks — value-column length vs. coverage
cardinality, dtype vs. schema (I/O-bound; cheap structural invariants
are enforced at parse/commit time).
- [ ] Scan/take merge (OSS-1324), resolved at read time fetching only
the touched coverage ranks.

Blocks OSS-1324 (take/scan), OSS-1325 (index masking), OSS-1326
(compaction).

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added experimental data overlay file support with opt-in release
gating via an environment variable.
* Introduced first-class `DataOverlay` transaction support to append
per-fragment overlay updates during commits, including newest-last
overlay ordering validation.
* **Bug Fixes**
* Improved overlay conflict handling across rewrites, data replacements,
and row-moving updates, including deferred row-overlap validation.
* Prevented reads from proceeding on fragments containing overlay data
when overlay merge is in progress.
* **Documentation**
* Updated transaction compatibility docs with clearer overlay stacking
rules, rewrite/replacement overlay interactions, and added conflict
scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
… to avoid role chaining (lance-format#7757)

## Problem

The credential vendor's `api_key` and static flows assume the target
role with `AssumeRole`, signed by the process's **ambient** credentials.
When the vendor runs with temporary credentials — e.g. an IRSA / EKS pod
role — that is **role chaining**, which STS **hard-caps to 1 hour**
regardless of the role's `MaxSessionDuration`.

In that setup the returned `Expiration` can also exceed the token's
*real* validity, so downstream credential caches
(`StorageOptionsAccessor`, `CachingCredentialVendor`) keep serving a
token S3 already rejects with `ExpiredToken` and never refresh — their
expiry checks trust the inflated `expires_at_millis`.

## Change

Add an optional **pod web-identity** path. When
`pod_web_identity_token_file` is configured — via property
`credential_vendor.aws_assume_via_pod_web_identity=true` (resolving the
EKS-injected `AWS_WEB_IDENTITY_TOKEN_FILE`), or an explicit
`credential_vendor.aws_pod_web_identity_token_file` — the scoped assume
uses `AssumeRoleWithWebIdentity` with the pod's projected
service-account OIDC token.

`AssumeRoleWithWebIdentity` is a **direct** (non-chained) assumption, so
STS honors the role's `MaxSessionDuration` (up to 12h) and reports an
accurate expiration.

- Per-table scoped session policy, permission handling, and the default
chained `AssumeRole` behavior are **unchanged**.
- The token file is re-read on every vend (kubelet rotates it).
- Requires the target role's trust policy to federate the cluster OIDC
provider for the service account.

This change also improves error handling to propagate the object store
error message in namespace error messages.

## Tests

- `test_config_builder` extended for the new field.
- `test_pod_web_identity_path_reads_token_file` — verifies the
web-identity branch is selected and reads the token file.

🤖 Co-authored with Claude Opus 4.8

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added AWS pod-based web identity authentication for credential
vending.
* New configuration option to set the projected service-account token
file path (with optional opt-in fallback to the standard AWS env var).
* When configured, scoped role vending uses web identity instead of the
legacy chained assume-role flow.
* **Bug Fixes**
* Preserves existing ambient-credential behavior when web identity is
not configured.
* **Tests**
* Added coverage to ensure vending fails with a clear token-file read
error when the configured token file path is missing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
…7698)

Fixes lance-format#7697

## Summary

Fix Lance-generated DataFusion field-path expressions so schema-derived
column names preserve exact casing.

Previously, `field_path_to_expr` used DataFusion `col(...)`, which
lowercases unquoted identifiers. This caused generated nullable-vector
filters like `VECTOR IS NOT NULL` to resolve as `vector`, breaking
case-sensitive schemas during
  vector index creation / append optimization.

## Changes

- Build root field-path expressions with
`Expr::Column(Column::new_unqualified(...))` instead of `col(...)`.
- Preserve existing nested field handling through `field_newstyle(...)`.
  - Add regression coverage for:
    - uppercase nullable vector column indexing / append optimization
    - exact-case root column expression generation
    - mixed-case root plus escaped nested field path containing dots

## Testing

  ```bash
cargo test -p lance-datafusion
logical_expr::tests::test_field_path_to_expr
cargo test -p lance
test_optimize_append_preserves_case_sensitive_nullable_vector_column
  cargo fmt --all

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved query parsing to correctly preserve case sensitivity for root
column names and handle escaped nested field paths (including dots
inside escaped segments).
* Fixed an issue where appending data and optimizing indexes could leave
unindexed fragments, affecting vector scans.
* Vector search results now remain consistent after append + index
optimization when using case-sensitive nullable vector columns.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
The per-batch `dot_batch_f32_avx*` / `l2_batch_f32_avx*` wrappers are only
called from the runtime-dispatch path, which is compiled out when the build
baseline already guarantees avx2+fma (the repo's `target-cpu=haswell`
default). Under that baseline the wrappers, their test helper, and their
tests were unused, so `cargo clippy -D warnings` failed on dead_code.

Gate the definitions and their tests with the same
`not(all(target_feature = "avx2", target_feature = "fma"))` cfg as their
caller, so definition, dispatch, and test compile together. No behavior
change: at the avx2+fma baseline `dot_batch`/`l2_batch` already inline the
kernel directly; below it the runtime dispatch and these wrappers remain.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

import lance / lancedb crashes with SIGILL on x86_64 CPUs without AVX2 (Sandy Bridge, Ivy Bridge, FX-7500-class AMDs)