perf: buffer BlobFile sequential reads in Rust - #9251
Conversation
|
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. |
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.
…read-buffer Keep sequential BlobFile prefetch tests and main's split_batch_by_bytes tests. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@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
left a comment
There was a problem hiding this comment.
Try to trigger lance gate keeper again, and will also rereview
|
@lancedb-robot review this PR again |
LuQQiu
left a comment
There was a problem hiding this comment.
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:
read_up_tocan now return short reads mid-file (inline comment) — a silent semantics change for Java/Rust callers. Please fix or document loudly..pyistub drift:LanceBlobFileinpython/python/lance/lance/__init__.pyiis missingset_buffer_sizeandrange_submission_count.BlobFile.__init__now callsset_buffer_sizeunconditionally, so the stub is actively wrong.
Everything else inline is non-blocking.
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.
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>
There was a problem hiding this comment.
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.
|
Thanks for the review @LuQQiu, i don't got merge perms so feel free once you feel like it's ready! |
|
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 tooTwo independent amplification paths:
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)
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 The release-on-consume fix is tinyOnly 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 });
I checked every submission-count assertion in this PR (7 Rust + 5 Python): none changes, so no existing test needs touching. Two notes:
#[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/CPlease document the memory model in |
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>
fixes #8831.
Small sequential reads on
BlobFilepreviously 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 PythonBufferedReaderfrom the earlier revision.The default is 4 MiB.
buffer_size=0disables read-ahead. Explicitread_rangeandread_rangescalls bypass the buffer and leave the cursor unchanged. Seeks within the buffered span reuse it; seeks outside discard it.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