Files
lancedb/python/benchmarks/bench_fetch_blobs_rss.py
T
Xuanwo 10f89b3c32 perf: reduce Blob read buffering and expose read window controls (#4979)
Remote Blob reads retain and concatenate intermediate IPC buffers across
batches and HTTP requests, increasing peak memory. Decode fragmented IPC
bodies using their declared length, assemble Blob values once across
batches/requests, and collect large-object Range responses into
known-size buffers. Impossible IPC/Range allocations return errors
instead of panicking. Closing a remote Blob file releases cached bytes
immediately, including while a cross-window read is waiting for the
network.

Rust and Python expose configurable Blob read buffers. Python sync/async
`fetch_blob_files` and `open_blob_file(s)` accept `buffer_size`; the
default is 512 KiB and zero disables prefetch. Explicit ranges bypass
prefetch. New PE batch-endpoint adoption is separate.

Malformed-length and retained-cache regressions fail on the previous
implementation and pass after the correction. Real HTTP tests cover
buffer propagation, close during blocked input, batch/request
aggregation, and large-object fallback. The final extension was rebuilt
before Python validation. Non-Blob Arrow arrays may still require
alignment copies; a contiguous multi-batch result still requires its
final allocation.

## Benchmark

Peak client RSS increment in KiB, using
`python/benchmarks/bench_fetch_blobs_rss.py`, independent clients and
real loopback HTTP. Both extensions were rebuilt from clean, unchanged
source with the same toolchain/configuration; loaded artifact hashes
were checked. Baseline `42f11d13a2755e44b63876b5a5edd93901291150`, after
`0a8a76de5286949d80368f95a5c529adf422c187`.

| Equivalent fetch workload | Payload | Before | After |
| --- | ---: | ---: | ---: |
| Single response, one batch | 48 MiB | 63,812 | 63,896 |
| Multiple responses, one batch per response | 144 MiB | 423,104 |
195,628 |
| Single response, 12 rows per batch | 48 MiB | 130,520 | 71,348 |
| Multiple responses, 12 rows per batch | 144 MiB | 363,144 | 172,748 |
| One oversized row, Range fallback | 70 MiB | 182,816 | 85,548 |

These are one observation per workload and build, not a statistical
distribution or memory upper bound. The single-batch workload shows no
improvement. Cloud/Sophon deployment, credentialed S3 and long-running
stress have not been exercised.
2026-11-13 02:05:09 +08:00

184 lines
6.4 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
"""Measure fetch_blobs RSS in a fresh client process against real HTTP.
Run with the uv environment's Python. The server lives in a separate process so
payload generation and Arrow serialization are excluded from the client's RSS.
Each row contains 1 MiB; requests over the server's 64 MiB limit are split by the
SDK. Example: python benchmarks/bench_fetch_blobs_rss.py --rows 48 144
"""
import argparse
import json
import resource
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def serve(batch_rows, row_mib):
import pyarrow as pa
payload = b"x" * (row_mib << 20)
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def respond(self, status, data, content_type="application/json"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("phalanx-version", "0.5.0")
self.send_header("x-lancedb-version", "1")
self.end_headers()
for offset in range(0, len(data), 8192):
self.wfile.write(data[offset : offset + 8192])
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
request_body = self.rfile.read(length)
if self.path.endswith("/describe/"):
self.do_GET()
return
body = json.loads(request_body)
count = len(body["row_ids"])
if count * row_mib > 64:
self.respond(
400,
b"fetch_blobs accepts at most 67108864 total blob bytes",
)
return
schema = pa.schema([pa.field("image", pa.large_binary())])
sink = pa.BufferOutputStream()
with pa.ipc.new_stream(sink, schema) as writer:
for start in range(0, count, batch_rows):
array = pa.array(
[payload] * min(batch_rows, count - start),
type=pa.large_binary(),
)
writer.write_batch(pa.record_batch([array], schema=schema))
self.respond(200, sink.getvalue(), "application/vnd.apache.arrow.stream")
def do_GET(self):
if "/blob/image/" in self.path:
start, end = map(int, self.headers["Range"][6:].split("-"))
body = memoryview(payload)[start : end + 1]
self.send_response(206)
self.send_header("Content-Length", str(len(body)))
self.send_header("Content-Range", f"bytes {start}-{end}/{len(payload)}")
self.send_header("x-lancedb-version", "1")
self.end_headers()
for offset in range(0, len(body), 8192):
self.wfile.write(body[offset : offset + 8192])
return
self.respond(
200,
json.dumps(
{
"table": "test",
"version": 1,
"schema": {"fields": []},
}
).encode(),
)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
print(server.server_port, flush=True)
server.serve_forever()
def client(port, rows, row_mib):
import lancedb
db = lancedb.connect(
"db://rss-test", api_key="test", host_override=f"http://127.0.0.1:{port}"
)
table = db.open_table("test")
# Reset the Linux high-water mark after imports and connection setup.
with open("/proc/self/clear_refs", "w") as reset:
reset.write("5")
def memory_kib(field):
with open("/proc/self/status") as status:
return next(
int(line.split()[1]) for line in status if line.startswith(field + ":")
)
before = memory_kib("VmRSS")
values = table.fetch_blobs("image", list(range(rows)))
peak = memory_kib("VmHWM")
assert len(values) == rows
assert values.buffers()[2].size == rows * (row_mib << 20)
# Inspect slices without copying the result to Python bytes.
for i in (0, rows - 1):
assert values.slice(i, 1).buffers()[2][i * (row_mib << 20)] == ord("x")
print(
json.dumps(
{
"rows": rows,
"payload_mib": rows * row_mib,
"baseline_rss_kib": before,
"peak_rss_kib": peak,
"peak_delta_kib": peak - before,
"peak_delta_per_payload": (peak - before) / (rows * row_mib * 1024),
"rusage_maxrss_kib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
}
),
flush=True,
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--rows", nargs="+", type=int, default=[48, 144])
parser.add_argument("--batch-rows", type=int, default=48)
parser.add_argument("--row-mib", type=int, default=1)
parser.add_argument("--serve", action="store_true")
parser.add_argument("--client", type=int)
args = parser.parse_args()
if args.serve:
serve(args.batch_rows, args.row_mib)
elif args.client is not None:
client(args.client, args.rows[0], args.row_mib)
else:
server = subprocess.Popen(
[
sys.executable,
__file__,
"--serve",
"--batch-rows",
str(args.batch_rows),
"--row-mib",
str(args.row_mib),
],
stdout=subprocess.PIPE,
text=True,
)
try:
port = server.stdout.readline().strip()
for rows in args.rows:
subprocess.run(
[
sys.executable,
__file__,
"--client",
port,
"--rows",
str(rows),
"--row-mib",
str(args.row_mib),
],
check=True,
)
finally:
server.terminate()
server.wait()
if __name__ == "__main__":
main()