Skip to content

perf: buffer BlobFile sequential reads in Rust - #9251

Merged
LuQQiu merged 9 commits into
lance-format:mainfrom
geruh:perfomative-blobs
Sep 17, 2026
Merged

LuQQiu merged 9 commits into
lance-format:mainfrom
geruh:perfomative-blobs

Conversation

@geruh

@geruh geruh commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

fixes #8831.

Small sequential reads on BlobFile previously submitted a storage range for every call. File consumers reading in small chunks could spend most of their time waiting for object storage.

This adds lazy read-ahead to Rust BlobFile, shared by Python, Java, and Rust callers. It replaces the Python BufferedReader from the earlier revision.

The default is 4 MiB. buffer_size=0 disables read-ahead. Explicit read_range and read_ranges calls bypass the buffer and leave the cursor unchanged. Seeks within the buffered span reuse it; seeks outside discard it.

blob = ds.take_blobs("assets", indices=[0])[0]
blob = ds.take_blobs("assets", indices=[0], buffer_size=0)[0]

Buffering reduces requests for sequential reads but can fetch unused bytes for header probes and scattered reads. The size is configurable; the default has not yet been validated against S3.

Testing

added coverage for sequential read coalescing, disabled buffering, seeks, buffer resizing, and independent range reads. Python tests also cover reads across buffer boundaries, TextIOWrapper, ZIP, read-only destinations, and closed handles. Java tests cover disabled buffering and invalid buffer sizes.

cc: @wjones127

@github-actions github-actions Bot added A-python Python bindings A-docs Documentation performance labels Sep 15, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 15, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 15, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 15, 2026
@LuQQiu

LuQQiu commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Should this live in Rust instead? so this only fixes Python, while the Java binding and Rust callers hit the same request amplification. Not blocking — the Python buffer still avoids per-read FFI overhead. Suggest a follow-up to move buffering/prefetch into the Rust BlobFile.

@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 15, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 15, 2026
File-protocol consumers refill in 8 KiB, which used to be one storage fetch
each. Python and Java now share one buffer_size for a sequential prefetch;
buffer_size=0 fetches each read, and range APIs stay independent of that buffer.
@github-actions github-actions Bot added the A-java Java bindings + JNI label Sep 16, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 16, 2026
geruh and others added 2 commits September 16, 2026 08:30
…read-buffer

Keep sequential BlobFile prefetch tests and main's split_batch_by_bytes tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
@geruh geruh changed the title perf(python): buffer BlobFile sequential reads perf: buffer BlobFile sequential reads in Rust Sep 16, 2026
@geruh

geruh commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

@LuQQiu, thanks for the review! yeah, at first I was gonna just keep it in Python to unblock the issue. But yeah I agree this definitely can be in the rust logic.

Now the PR moved it there and exposed the buffer as a param consistent with other file apis!

Also, gatekeeper doesn't want to re-review lol

@LuQQiu LuQQiu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Try to trigger lance gate keeper again, and will also rereview

@LuQQiu

LuQQiu commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

@lancedb-robot review this PR again

@LuQQiu LuQQiu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for moving the buffering into Rust — this is exactly the cross-language design I was hoping for, and the test coverage (deterministic submission counts in Rust/Python, TextIOWrapper/ZIP/closed-handle cases, Java validation) is strong.

Requesting changes narrowly on two items; the rest are comments/follow-ups:

  1. read_up_to can now return short reads mid-file (inline comment) — a silent semantics change for Java/Rust callers. Please fix or document loudly.
  2. .pyi stub drift: LanceBlobFile in python/python/lance/lance/__init__.pyi is missing set_buffer_size and range_submission_count. BlobFile.__init__ now calls set_buffer_size unconditionally, so the stub is actively wrong.

Everything else inline is non-blocking.

Comment thread rust/lance/src/dataset/blob.rs Outdated
Comment thread rust/lance/src/dataset/blob.rs
Comment thread rust/lance/src/dataset/blob.rs
Comment thread rust/lance/src/dataset/blob.rs Outdated
Comment thread java/src/main/java/org/lance/Dataset.java
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 16, 2026
A leftover prefetch slice is no longer returned as a short mid-file read, so Rust and Java callers keep the full-read-until-EOF contract.
@lance-gatekeeper lance-gatekeeper Bot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 16, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 16, 2026
Catch RuntimeException as well as IOException so a later native failure still closes already-opened handles. Hide the range-submission counter behind `_range_submission_count`.

Co-authored-by: Cursor <cursoragent@cursor.com>
@lance-gatekeeper lance-gatekeeper Bot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 16, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Gate recommendation: approve with a non-blocking risk.

This revision closes all opened Java blob handles when buffer setup raises an I/O or runtime failure and hides the request counter as test instrumentation; the prior short-read and stub fixes remain intact. The remaining accepted risk is the 4 MiB default: probe-then-close reads can overfetch, and a small header read followed by read() can refetch buffered bytes. The author chose to keep this window for now. Callers with those workloads can use buffer_size=0, read_range, or continue sequential reads.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 16, 2026
@geruh
geruh requested a review from LuQQiu September 16, 2026 21:09

@LuQQiu LuQQiu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

looks good!

@geruh

geruh commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @LuQQiu, i don't got merge perms so feel free once you feel like it's ready!

@LuQQiu

LuQQiu commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Following up on my earlier review with a longer-term concern about customers who aren't reading sequentially, plus a concrete menu of acceptable ways to land this. Any one of the three end-states below works for me.

The concern: non-sequential readers pay for the buffer too

Two independent amplification paths:

  1. Byte amplification. Every cursor-read miss fetches max(buffer_size, still_need) — 4 MiB by default. Random access (seek + small read) turns every jump into a 4 MiB fetch, and this gets triggered "innocently": zipfile, tarfile, or any format parser over a blob does seek-to-directory / jump-to-member patterns. read_range bypasses the buffer, but file-interface consumers won't know to switch APIs.

  2. Memory retention. The retained prefetch is ~buffer_size per open handle, and take_blobs hands out handles in batches, so the real bound is open handles × buffer_size: probing 64 bytes from each of 1,000 large blobs transfers 4 GB and keeps 4 GB resident until the handles close. On top of that, the current code retains the fetch even when it is fully consumed (*prefetch = Some(...) unconditionally when buffer_size > 0), so an oversized read (still_need > buffer_size) pins the whole fetch — the 4 MiB "cap" doesn't hold on that branch — and a handle read to EOF keeps its last block until close.

Small blobs (< buffer size) are actually fine — the first read caches the whole blob and later seeks hit it. The bad combination is large blobs + scattered reads + many concurrent handles.

Three acceptable end-states (either is fine)

What lands in this PR Result
A Release-on-consume fix + ramp-up readahead Default-on, ideal — but ramp-up is a real algorithm change and may drag this PR out
B Release-on-consume fix + default lowered to 256 KiB (one constant); ramp-up as an issue-tracked fast-follow that raises the effective max back to 4 MiB Default-on, worst case capped
C Neither Then the buffer should default to buffer_size = 0 (explicit opt-in)

Why I'd rank B over C: opt-in defeats the point of the PR — #8831 is precisely about the default behavior of file-like consumers who will never discover buffer_size. And 256 KiB keeps most of the win: 8 KiB sequential reads still see 32× fewer requests, while the worst case drops from 4 GB resident / 4 MiB per jump to 256 MB / 256 KiB. (4 MiB is within ecosystem norms — s3fs uses 5 MB blocks — but those are single-file opens; take_blobs batching makes it multiplicative, and the PR itself notes the 4 MiB default is unvalidated against S3.)

The release-on-consume fix is tiny

Only read_up_to changes — keep the prefetch only when this read didn't drain it:

let result = data.slice(0..still_need.min(data.len()));
*cursor = fetch_cursor + result.len() as u64;
*prefetch = (*buffer_size > 0 && result.len() < data.len())
    .then(|| BlobPrefetch { start: fetch_cursor, bytes: data });

result.len() == data.len() covers exactly the three sharp corners: oversized reads, the EOF tail block, and a read that exactly drains a block. Header probes (64 < 4 MiB) still retain — that's legitimate caching, governed by the default size / ramp-up, not by this fix.

I checked every submission-count assertion in this PR (7 Rust + 5 Python): none changes, so no existing test needs touching. Two notes:

  • One deliberate tradeoff: today, seeking back into a fully-drained span still hits the old buffer (seek's contains check keeps it); after the fix it refetches. Pinning buffer_size per handle for that rare pattern isn't worth it, but it should be a conscious choice.
  • Please add a pinning test, since the field is private and only observable via the counter:
#[tokio::test]
async fn fully_consumed_prefetch_is_released() {
    // 40-byte payload, buffer_size = 16
    let _ = blob.read_up_to(16).await.unwrap(); // drains the block
    let after = blob.range_submission_count();
    blob.seek(0).await.unwrap();
    let _ = blob.read_up_to(4).await.unwrap();  // must refetch
    assert_eq!(blob.range_submission_count(), after + 1);
}

Regardless of A/B/C

Please document the memory model in blob.md and the docstrings: each open handle retains up to buffer_size; with batched take_blobs that is multiplicative; random-access consumers should pass buffer_size=0 or use read_range / read_ranges.

@lance-gatekeeper lance-gatekeeper Bot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Sep 16, 2026
@LuQQiu
LuQQiu merged commit 40b10b9 into lance-format:main Sep 17, 2026
38 of 39 checks passed
westonpace pushed a commit that referenced this pull request Sep 17, 2026
While working on the buffering pr for blobfiles in #9251 I noticed that
a header read followed by `read()` could download the same bytes twice
because `read()` ignored the existing prefetch window and always fetched
`cursor..size`.

So now we reuse the prefetched bytes and only fetch the missing
remainder. This avoids the duplicate download while preserving the
existing cursor behavior.

`do_with_cursor` was only used by `read()`, so the cursor/window
bookkeeping is inlined there.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-docs Documentation A-java Java bindings + JNI A-python Python bindings performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: BlobFile file-protocol consumers issue one object-store GET per 8 KiB

2 participants