mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
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.
184 lines
6.4 KiB
Python
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()
|